From 0f1cb2fe6cb787f1b7ab2de5f89c3211b255ce5b Mon Sep 17 00:00:00 2001 From: 33333-33333 Date: Fri, 26 Jun 2026 22:35:26 +0900 Subject: [PATCH 1/2] published --- app_manifest.json | 2 +- index.html | 280 +++++++++++++++++++------------------- js/constraint_system.js | 121 +++++++++++++++- js/render.js | 29 ++-- js/snapshot_system.js | 63 +++++++-- js/ui_layout_dialogs.js | 6 + js/version.js | 2 +- js/world_placement_log.js | 186 +++++++++++++++++++++---- js/world_tool_actions.js | 4 + service-worker.js | 2 +- 10 files changed, 497 insertions(+), 198 deletions(-) diff --git a/app_manifest.json b/app_manifest.json index b498e50..e030626 100644 --- a/app_manifest.json +++ b/app_manifest.json @@ -1,5 +1,5 @@ { - "version": "38.00.00", + "version": "39.00.00", "css": [ "css/base.css", "css/layout.css", diff --git a/index.html b/index.html index 2fe75ae..84efbfd 100644 --- a/index.html +++ b/index.html @@ -7,11 +7,11 @@ - - - - - + + + + +
@@ -189,140 +189,140 @@
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/js/constraint_system.js b/js/constraint_system.js index df4f2fc..b549762 100644 --- a/js/constraint_system.js +++ b/js/constraint_system.js @@ -30,22 +30,129 @@ function resolveEndpointObject(endpoint, worldRef) { if (!endpoint || !worldRef) return null; + const allowFuzzy = endpoint.fuzzyResolve === true || endpoint.legacyFuzzy === true; if (endpoint.kind === "tarinai") { let found = (worldRef.tarinai || []).find(t => t && !t.dead && t.id === endpoint.id); - if (!found && Number.isFinite(endpoint.x) && Number.isFinite(endpoint.y)) { + if (!found && allowFuzzy && Number.isFinite(endpoint.x) && Number.isFinite(endpoint.y)) { found = (worldRef.tarinai || []).find(t => t && !t.dead && distXY(t.x, t.y, endpoint.x, endpoint.y) <= Math.max(40, (t.radius || 20) * 1.6)); - if (found) endpoint.id = found.id; + if (found) { + endpoint.id = found.id; + endpoint.fuzzyResolve = false; + endpoint.legacyFuzzy = false; + } } return found || null; } let found = (worldRef.items || []).find(it => it && !it.dead && it.id === endpoint.id); - if (!found && Number.isFinite(endpoint.x) && Number.isFinite(endpoint.y)) { + if (!found && allowFuzzy && Number.isFinite(endpoint.x) && Number.isFinite(endpoint.y)) { found = (worldRef.items || []).find(it => it && !it.dead && it.type === endpoint.type && distXY(it.x, it.y, endpoint.x, endpoint.y) <= Math.max(44, (it.r || 20) * 1.8)); - if (found) endpoint.id = found.id; + if (found) { + endpoint.id = found.id; + endpoint.fuzzyResolve = false; + endpoint.legacyFuzzy = false; + } } return found || null; } + function itemAngle(item) { + return typeof itemAngleFor === "function" ? itemAngleFor(item) : (Number(item?.angle) || 0); + } + + function isMechanicalLinkTarget(item) { + return Boolean(item && !item.dead && global.TarinaiMechanicalSystem?.isMechanicalType?.(item.type)); + } + + function localToWorld(item, lx, ly) { + const a = itemAngle(item); + const c = Math.cos(a), s = Math.sin(a); + return { + x: (Number(item?.x || 0) || 0) + lx * c - ly * s, + y: (Number(item?.y || 0) || 0) + lx * s + ly * c, + }; + } + + function worldToLocal(item, x, y) { + const a = itemAngle(item); + const dx = (Number(x || 0) || 0) - (Number(item?.x || 0) || 0); + const dy = (Number(y || 0) || 0) - (Number(item?.y || 0) || 0); + const c = Math.cos(-a), s = Math.sin(-a); + return { x: dx * c - dy * s, y: dx * s + dy * c }; + } + + function nearestMechanicalLocalPoint(item, lx, ly) { + if (!isMechanicalLinkTarget(item)) return null; + const segments = global.TarinaiMechanicalSystem?.sanitizeSegments?.(item) || []; + let best = null; + for (const seg of segments) { + if (!Array.isArray(seg) || seg.length < 4) continue; + const x1 = Number(seg[0]) || 0, y1 = Number(seg[1]) || 0; + const x2 = Number(seg[2]) || 0, y2 = Number(seg[3]) || 0; + const vx = x2 - x1, vy = y2 - y1; + const len2 = vx * vx + vy * vy; + if (len2 < 16) continue; + const t = clamp(((lx - x1) * vx + (ly - y1) * vy) / len2, 0, 1); + const x = x1 + vx * t; + const y = y1 + vy * t; + const d = Math.hypot(lx - x, ly - y); + if (!best || d < best.d) best = { x, y, d }; + } + return best; + } + + function snapEndpointToTarget(endpoint, worldRef) { + const obj = resolveEndpointObject(endpoint, worldRef); + if (!endpoint || !isMechanicalLinkTarget(obj)) return endpoint; + let local; + if (!endpoint.center && (Number.isFinite(Number(endpoint.localX)) || Number.isFinite(Number(endpoint.localY)))) { + local = { x: Number(endpoint.localX || 0) || 0, y: Number(endpoint.localY || 0) || 0 }; + } else if (Number.isFinite(Number(endpoint.x)) && Number.isFinite(Number(endpoint.y))) { + local = worldToLocal(obj, endpoint.x, endpoint.y); + } else { + local = { x: 0, y: 0 }; + } + const snapped = nearestMechanicalLocalPoint(obj, local.x, local.y); + if (!snapped) return endpoint; + const world = localToWorld(obj, snapped.x, snapped.y); + endpoint.localX = snapped.x; + endpoint.localY = snapped.y; + endpoint.x = world.x; + endpoint.y = world.y; + endpoint.center = false; + return endpoint; + } + + function remapLinksForEditedMechanicalItem(worldRef, target, reason = "mechanical-shape-edited") { + if (!worldRef || !isMechanicalLinkTarget(target)) return 0; + let changed = 0; + const remap = (endpoint) => { + if (!endpoint || endpoint.kind !== "item" || endpoint.id !== target.id) return false; + const beforeX = Number(endpoint.localX || 0) || 0; + const beforeY = Number(endpoint.localY || 0) || 0; + const beforeWorldX = Number(endpoint.x || 0) || 0; + const beforeWorldY = Number(endpoint.y || 0) || 0; + snapEndpointToTarget(endpoint, worldRef); + const localDelta = Math.hypot((Number(endpoint.localX || 0) || 0) - beforeX, (Number(endpoint.localY || 0) || 0) - beforeY); + const worldDelta = Math.hypot((Number(endpoint.x || 0) || 0) - beforeWorldX, (Number(endpoint.y || 0) || 0) - beforeWorldY); + return localDelta > 0.001 || worldDelta > 0.001; + }; + for (const item of worldRef.items || []) { + if (!item || item.dead || (item.type !== "rope" && item.type !== "rod")) continue; + const a = remap(item.linkA); + const b = remap(item.linkB); + if (a || b) { + item.world = worldRef; + changed += 1; + } + } + if (remap(worldRef.pendingLinkEndpoint)) changed += 1; + if (changed) { + worldRef.drawListDirty = true; + worldRef.markSpatialDirty?.(reason); + } + return changed; + } + function endpointWorld(endpoint, worldRef, depth = 0, seen = null) { const obj = resolveEndpointObject(endpoint, worldRef); if (obj && (obj.type === "rope" || obj.type === "rod") && worldRef) obj.world = worldRef; @@ -66,7 +173,7 @@ const lx = Number(endpoint.localX || 0) || 0; const ly = Number(endpoint.localY || 0) || 0; if (endpoint.center || (!lx && !ly)) return { x: obj.x || 0, y: obj.y || 0, obj }; - const a = typeof itemAngleFor === "function" ? itemAngleFor(obj) : (Number(obj.angle) || 0); + const a = itemAngle(obj); const c = Math.cos(a), s = Math.sin(a); return { x: (obj.x || 0) + lx * c - ly * s, y: (obj.y || 0) + lx * s + ly * c, obj }; } @@ -283,6 +390,8 @@ resolveEndpointObject, endpointMass, moveEndpointObject, + snapEndpointToTarget, + remapLinksForEditedMechanicalItem, updateFlexibleLink, updateRigidLink, updateLink, @@ -290,5 +399,5 @@ }); global.TarinaiConstraintSystem = api; - global.TarinaiLinkRuntime = Object.freeze({ endpointWorld, pointSegmentDistance }); + global.TarinaiLinkRuntime = Object.freeze({ endpointWorld, pointSegmentDistance, snapEndpointToTarget, remapLinksForEditedMechanicalItem }); })(typeof window !== "undefined" ? window : globalThis); diff --git a/js/render.js b/js/render.js index 37ab3b2..e4a3a81 100644 --- a/js/render.js +++ b/js/render.js @@ -258,10 +258,23 @@ function drawOperationToolPreview(ctx, world) { const type = world.copyBuffer.type || ""; const item = world.copyPreviewItemAt?.(p.x, p.y); if (item) { + const pad = typeof CONFIG !== "undefined" ? (CONFIG.worldPadding ?? 0) : 0; + const rectOutside = (rect) => Boolean(rect && (rect.left < pad || rect.top < pad || rect.right > world.w - pad || rect.bottom > world.h - pad)); + const circleOutside = (cx, cy, radius) => cx - radius < pad || cy - radius < pad || cx + radius > world.w - pad || cy + radius > world.h - pad; + const previewRects = world.solidObstacleRects?.(item) || []; + const boundsBlocked = previewRects.length + ? previewRects.some(rr => rectOutside(rr.oriented ? { + left: (rr.cx || p.x) - (rr.halfW || 10) - (rr.halfH || 5), + right: (rr.cx || p.x) + (rr.halfW || 10) + (rr.halfH || 5), + top: (rr.cy || p.y) - (rr.halfW || 10) - (rr.halfH || 5), + bottom: (rr.cy || p.y) + (rr.halfW || 10) + (rr.halfH || 5), + } : rr)) + : circleOutside(p.x, p.y, Math.max(14, (item.r || itemRadiusFor?.(type, 16) || 16) * 1.25)); + const blocked = boundsBlocked || Boolean(world.placementBlocked?.(item) || world.fencePlacementBlocked?.(item)); let copyPreviewDrew = false; if (typeof item.draw === "function") { ctx.save(); - ctx.globalAlpha = 0.74; + ctx.globalAlpha = blocked ? 0.44 : 0.74; try { item.draw(ctx, world.time || 0, typeof getLightingState === "function" ? getLightingState(world) : null); copyPreviewDrew = true; } catch (_) { copyPreviewDrew = false; } ctx.restore(); } @@ -273,18 +286,18 @@ function drawOperationToolPreview(ctx, world) { const bctx = buffer.getContext("2d"); if (bctx && globalThis.drawToolItemPreview(bctx, type, { width: buffer.width, height: buffer.height, compact: true }) !== false) { ctx.save(); - ctx.globalAlpha = 0.74; + ctx.globalAlpha = blocked ? 0.44 : 0.74; ctx.drawImage(buffer, p.x - previewSize / 2, p.y - previewSize / 2, previewSize, previewSize); ctx.restore(); } } ctx.save(); - ctx.globalAlpha = 0.50; - ctx.setLineDash([7, 5]); - ctx.lineWidth = 2; - ctx.strokeStyle = "rgba(128,92,198,0.86)"; - ctx.fillStyle = "rgba(150,110,220,0.12)"; - const rects = world.solidObstacleRects?.(item) || []; + ctx.globalAlpha = blocked ? 0.58 : 0.50; + ctx.setLineDash(blocked ? [5, 4] : [7, 5]); + ctx.lineWidth = blocked ? 2.2 : 2; + ctx.strokeStyle = blocked ? "rgba(194,70,58,0.88)" : "rgba(110,178,55,0.88)"; + ctx.fillStyle = blocked ? "rgba(232,84,72,0.14)" : "rgba(190,236,92,0.15)"; + const rects = previewRects; if (rects.length) { for (const rr of rects) { if (rr.oriented) { diff --git a/js/snapshot_system.js b/js/snapshot_system.js index 3c1ed9c..d76bb81 100644 --- a/js/snapshot_system.js +++ b/js/snapshot_system.js @@ -311,7 +311,7 @@ if (typeof t.refreshEffectiveSize === "function") t.refreshEffectiveSize(); return t; } - function itemExtra(item, tarinaiIndex = new Map()) { + function itemExtra(item, tarinaiIndex = new Map(), itemIndex = new Map()) { const type = item?.type || ""; if (item?.isStructure) { const owner = tarinaiIndex.get(item.ownerId) ?? -1; @@ -346,7 +346,24 @@ ]; if (type === "rope" || type === "rod") { - const cloneEndpoint = ep => ep ? { kind: ep.kind || "item", id: ep.id || "", type: ep.type || "", label: ep.label || "", localX: q(ep.localX || 0, 1), localY: q(ep.localY || 0, 1), center: ep.center ? 1 : 0, x: q(ep.x || 0, 1), y: q(ep.y || 0, 1), attachT: Number.isFinite(Number(ep.attachT)) ? q(ep.attachT, 1000) : null } : null; + const cloneEndpoint = ep => { + if (!ep) return null; + const kind = ep.kind || "item"; + const refIdx = kind === "tarinai" ? (tarinaiIndex.get(ep.id) ?? -1) : (itemIndex.get(ep.id) ?? -1); + return { + kind, + id: ep.id || "", + refIdx, + type: ep.type || "", + label: ep.label || "", + localX: q(ep.localX || 0, 1), + localY: q(ep.localY || 0, 1), + center: ep.center ? 1 : 0, + x: q(ep.x || 0, 1), + y: q(ep.y || 0, 1), + attachT: Number.isFinite(Number(ep.attachT)) ? q(ep.attachT, 1000) : null, + }; + }; return [cloneEndpoint(item.linkA), cloneEndpoint(item.linkB), q(item.linkLength || item.r * 2 || 80, 1), q(item.linkMidX || item.x || 0, 1), q(item.linkMidY || item.y || 0, 1), q(item.linkMidVX || 0, 10), q(item.linkMidVY || 0, 10)]; } if (type === "duplicator") return [enumIndex(ITEM_TYPE_IDS, item.storedFoodType || "", -1)]; @@ -359,12 +376,12 @@ if (typeof global.isServingFoodType === "function" && global.isServingFoodType(type)) return [q(item.foodServingsRemaining ?? item.amount, 10), q(item.foodServingsMax, 10)]; return []; } - function compactItem(item, index, tarinaiIndex = new Map()) { + function compactItem(item, index, tarinaiIndex = new Map(), itemIndex = new Map()) { const typeId = enumIndex(ITEM_TYPE_IDS, item?.type || "", -1); if (typeId < 0 || item?.type === "trace") return null; - return [typeId, q(item?.x, 1), q(item?.y, 1), q(item?.amount ?? item?.hp, 10), itemExtra(item, tarinaiIndex)]; + return [typeId, q(item?.x, 1), q(item?.y, 1), q(item?.amount ?? item?.hp, 10), itemExtra(item, tarinaiIndex, itemIndex)]; } - function applyItemExtra(item, extra, tarinaiList = []) { + function applyItemExtra(item, extra, tarinaiList = [], itemList = []) { if (!Array.isArray(extra)) return; const type = item.type || ""; if (item.isStructure) { @@ -426,7 +443,27 @@ item.r = Math.max(item.r || 64, Math.min(460, extent)); } else if (type === "rope" || type === "rod") { const e = Array.isArray(extra) ? extra : []; - const normalizeEndpoint = ep => ep ? { kind: ep.kind || "item", id: ep.id || "", type: ep.type || "", label: ep.label || "", localX: u(ep.localX, 1, 0), localY: u(ep.localY, 1, 0), center: !!ep.center, x: u(ep.x, 1, item.x || 0), y: u(ep.y, 1, item.y || 0), attachT: ep.attachT == null ? null : clamp(u(ep.attachT, 1000, 0.5), 0, 1) } : null; + const normalizeEndpoint = ep => { + if (!ep) return null; + const kind = ep.kind || "item"; + const refIdx = Number(ep.refIdx); + const indexedTarget = Number.isInteger(refIdx) && refIdx >= 0 + ? (kind === "tarinai" ? tarinaiList[refIdx] : itemList[refIdx]) + : null; + return { + kind, + id: indexedTarget?.id || ep.id || "", + type: ep.type || indexedTarget?.type || "", + label: ep.label || indexedTarget?.name || "", + localX: u(ep.localX, 1, 0), + localY: u(ep.localY, 1, 0), + center: !!ep.center, + x: u(ep.x, 1, item.x || 0), + y: u(ep.y, 1, item.y || 0), + attachT: ep.attachT == null ? null : clamp(u(ep.attachT, 1000, 0.5), 0, 1), + fuzzyResolve: !indexedTarget && !Number.isInteger(refIdx), + }; + }; item.linkA = normalizeEndpoint(e[0]); item.linkB = normalizeEndpoint(e[1]); item.linkLength = Math.max(24, u(e[2], 1, item.linkLength || 80)); @@ -467,13 +504,13 @@ const itemRecords = []; for (const it of (worldRef.items || [])) { if (!it || it.dead || it.type === "trace" || enumIndex(ITEM_TYPE_IDS, it.type || "", -1) < 0) continue; - const row = compactItem(it, itemRecords.length, tarinaiIndex); - if (row) itemRecords.push({ item: it, row }); + const typeId = enumIndex(ITEM_TYPE_IDS, it.type || "", -1); + itemRecords.push({ item: it, sortTypeId: typeId, sortX: q(it.x, 1), sortY: q(it.y, 1) }); } - itemRecords.sort((a, b) => (a.row[0] || 0) - (b.row[0] || 0) || mortonKeyXY(a.row[1], a.row[2]) - mortonKeyXY(b.row[1], b.row[2]) || String(a.item?.id || "").localeCompare(String(b.item?.id || ""))); + itemRecords.sort((a, b) => (a.sortTypeId || 0) - (b.sortTypeId || 0) || mortonKeyXY(a.sortX, a.sortY) - mortonKeyXY(b.sortX, b.sortY) || String(a.item?.id || "").localeCompare(String(b.item?.id || ""))); const itemIndex = new Map(); itemRecords.forEach((rec, i) => { if (rec.item?.id) itemIndex.set(rec.item.id, i); }); - const itemRows = itemRecords.map(rec => rec.row); + const itemRows = itemRecords.map((rec, i) => compactItem(rec.item, i, tarinaiIndex, itemIndex)).filter(Boolean); return { v: SNAPSHOT_VERSION, a: "tj1", @@ -535,9 +572,13 @@ item.id = `li${idx}`; item.world = worldRef; item.amount = u(row[3], 10, item.amount || 1); - applyItemExtra(item, extra, worldRef.tarinai); + item.__savedExtra = extra; worldRef.items.push(item); } + for (const item of worldRef.items) { + applyItemExtra(item, item.__savedExtra || [], worldRef.tarinai, worldRef.items); + delete item.__savedExtra; + } for (const t of worldRef.tarinai) { if (Number.isInteger(t.__savedPinIdx) && t.__savedPinIdx >= 0) t.stuckPushpinId = worldRef.items[t.__savedPinIdx]?.id || null; if (Number.isInteger(t.__savedNestIdx) && t.__savedNestIdx >= 0) t.insideNestBoxId = worldRef.items[t.__savedNestIdx]?.id || null; diff --git a/js/ui_layout_dialogs.js b/js/ui_layout_dialogs.js index 4b2cdc5..331ef1b 100644 --- a/js/ui_layout_dialogs.js +++ b/js/ui_layout_dialogs.js @@ -125,9 +125,15 @@ function resizeFieldFromWheel(e) { } function selectTool(tool) { + const wasCopyArmed = world.tool === "copy" && tool === "copy" && Boolean(world.copyBuffer); const result = window.TarinaiCommands?.dispatch?.(world, { type: "tool.select", toolId: tool }); const activeTool = result?.toolId || world.tool || tool; if (activeTool !== "rope" && activeTool !== "rod") world.pendingLinkEndpoint = null; + if (activeTool !== "copy") world.copyBuffer = null; + else if (wasCopyArmed) { + world.copyBuffer = null; + showToast("\u30b3\u30d4\u30fc\u3092\u89e3\u9664\u3057\u307e\u3057\u305f\u3002"); + } for (const btn of uiCache.toolButtons) { btn.classList.toggle("selected", btn.dataset.tool === activeTool); } diff --git a/js/version.js b/js/version.js index 36e9222..51ab968 100644 --- a/js/version.js +++ b/js/version.js @@ -1,7 +1,7 @@ "use strict"; (function () { - const APP_VERSION = "38.00.00"; + const APP_VERSION = "39.00.00"; const APP_BUILD = "lab-ground-wash-and-freezer-removal-v38"; const APP_CACHE_NAME = `tarinai-colony-${APP_VERSION}`; const STATIC_VERSION_PARAM = `v=${APP_VERSION}`; diff --git a/js/world_placement_log.js b/js/world_placement_log.js index 7669abd..ce97607 100644 --- a/js/world_placement_log.js +++ b/js/world_placement_log.js @@ -392,6 +392,7 @@ const extent = worldRef?.rotatorExtent?.(item); if (Number.isFinite(extent)) item.r = Math.max(32, Math.min(460, extent)); item.rotatorEditorOpenAt = worldRef?.time || 0; + global.TarinaiLinkRuntime?.remapLinksForEditedMechanicalItem?.(worldRef, item, "rotator-link-remap"); worldRef?.markSpatialDirty?.("rotator-edited"); worldRef.drawListDirty = true; worldRef?.log?.("回転体の設計を変更した。", "observe"); @@ -672,6 +673,7 @@ item.reciprocatorAnchorY = item.y - Math.sin(sa) * halfTravel * (Number(item.reciprocatorPhase || 0) || 0); const extent = worldRef?.rotatorExtent?.(item); if (Number.isFinite(extent)) item.r = Math.max(32, Math.min(460, extent)); + global.TarinaiLinkRuntime?.remapLinksForEditedMechanicalItem?.(worldRef, item, "reciprocator-link-remap"); worldRef?.markSpatialDirty?.("reciprocator-edited"); worldRef.drawListDirty = true; worldRef?.log?.("往復体の設計を変更した。", "observe"); @@ -687,11 +689,47 @@ return true; } + function copyTargetAt(worldRef, x, y) { + let blockedReason = ""; + const collision = global.TarinaiCollisionFootprints; + const items = worldRef?.items || []; + for (let i = items.length - 1; i >= 0; i--) { + const it = items[i]; + if (!it || it.dead) continue; + const type = String(it.type || ""); + if (type === "trace" || type === "splat" || type === "ant_corpse") continue; + if (type === "rope" || type === "rod") { + const runtime = global.TarinaiLinkRuntime; + const a = runtime?.endpointWorld?.(it.linkA, worldRef); + const b = runtime?.endpointWorld?.(it.linkB, worldRef); + const d = runtime?.pointSegmentDistance?.(x, y, a?.x ?? it.x, a?.y ?? it.y, b?.x ?? it.x, b?.y ?? it.y) ?? Infinity; + const tolerance = worldRef?.screenSizeToWorld ? worldRef.screenSizeToWorld(12) : 12; + if (d <= tolerance && !blockedReason) blockedReason = "link"; + continue; + } + if (typeof isPinType === "function" && isPinType(type) && it.pinState === "lodged") { + const d = distXY(x, y, it.x, it.y); + if (d <= Math.max(24, (it.r || 12) * 1.8) && !blockedReason) blockedReason = "lodged_pin"; + continue; + } + const d = distXY(x, y, it.x, it.y); + let result = null; + if (collision?.hitTestItem) { + const isPrecise = Boolean(global.TarinaiMechanicalSystem?.isMechanicalType?.(type) || worldRef?.isFenceType?.(type) || type === "nest_box"); + result = collision.hitTestItem(worldRef, it, x, y, { + padding: isPrecise ? 10 : 4, + radiusMultiplier: isPrecise ? 1.12 : 1.18, + }); + } + const hit = result ? Boolean(result.hit) : d <= Math.max(16, (it.r || 16) * 1.18); + if (!hit) continue; + return { item: it, reason: "" }; + } + return { item: null, reason: blockedReason }; + } + function copyableItemAt(worldRef, x, y) { - const it = worldRef?.findDeleteToolTargetAt?.(x, y); - if (!it || it.dead || it.type === "trace" || it.type === "splat" || it.type === "ant_corpse") return null; - if (typeof isPinType === "function" && isPinType(it.type) && it.pinState === "lodged") return null; - return it; + return copyTargetAt(worldRef, x, y).item; } function makeCopyBufferForItem(item) { @@ -704,17 +742,12 @@ toolSize: item.toolSize || "medium", foodServingScale: Number(item.foodServingScale || 1) || 1, foodServingsMax: Number(item.foodServingsMax || 0) || 0, - foodServingsRemaining: Number(item.foodServingsRemaining || 0) || 0, seed: Number.isFinite(Number(item.seed)) ? Number(item.seed) : null, spin: Number.isFinite(Number(item.spin)) ? Number(item.spin) : 0, zunchiVariant: item.zunchiVariant || "", - stage: item.stage || "", - freshness: Number.isFinite(Number(item.freshness)) ? Number(item.freshness) : null, - fertility: Number.isFinite(Number(item.fertility)) ? Number(item.fertility) : null, grassStage: Number.isFinite(Number(item.grassStage)) ? Number(item.grassStage) : null, growth: Number.isFinite(Number(item.growth)) ? Number(item.growth) : null, health: Number.isFinite(Number(item.health)) ? Number(item.health) : null, - fuseTimer: Number.isFinite(Number(item.fuseTimer)) ? Number(item.fuseTimer) : null, fuseMax: Number.isFinite(Number(item.fuseMax)) ? Number(item.fuseMax) : null, }; if (item.type === "signboard") data.text = item.text || ""; @@ -747,9 +780,11 @@ if (Number.isFinite(Number(data.spin))) item.spin = Number(data.spin); if (item.type === "zunchi") { if (data.zunchiVariant) item.zunchiVariant = String(data.zunchiVariant); - if (data.stage) item.stage = String(data.stage); - if (Number.isFinite(Number(data.freshness))) item.freshness = Number(data.freshness); - if (Number.isFinite(Number(data.fertility))) item.fertility = Number(data.fertility); + item.stage = "fresh"; + item.stageTimer = 0; + item.freshness = 1; + item.fertility = 0; + item.spawnGrace = Math.max(Number(item.spawnGrace || 0) || 0, 10); } if (item.type === "grass") { if (Number.isFinite(Number(data.grassStage))) item.grassStage = Number(data.grassStage); @@ -757,8 +792,8 @@ if (Number.isFinite(Number(data.health))) item.health = Number(data.health); } if (item.type === "firecracker") { - if (Number.isFinite(Number(data.fuseTimer))) item.fuseTimer = Number(data.fuseTimer); if (Number.isFinite(Number(data.fuseMax))) item.fuseMax = Number(data.fuseMax); + item.fuseTimer = Math.max(0.1, Number(item.fuseMax || 5) || 5); } if (typeof isRotatableItemType === "function" && isRotatableItemType(item.type)) item.angle = Number(data.angle || 0) || 0; if (item.type === "signboard") item.text = String(data.text || ""); @@ -797,13 +832,23 @@ item.toolSize = data.toolSize || item.toolSize || "medium"; item.foodServingScale = Number(data.foodServingScale || item.foodServingScale || 1) || 1; item.foodServingsMax = Math.max(1, Number(data.foodServingsMax || item.foodServingsMax || item.amount || 1)); - item.foodServingsRemaining = Math.max(1, Number(data.foodServingsRemaining || item.foodServingsRemaining || item.amount || 1)); + item.foodServingsRemaining = item.foodServingsMax; item.amount = item.foodServingsRemaining; } + if (Number.isFinite(Number(item.x))) item.prevX = item.x; + if (Number.isFinite(Number(item.y))) item.prevY = item.y; + item.vx = 0; + item.vy = 0; + item.spinVelocity = 0; if (typeof isPinType === "function" && isPinType(item.type)) { item.pinState = "loose"; item.pinTargetId = ""; - item.vx = 0; item.vy = 0; item.spinVelocity = 0; + item.pinAttachAngle = 0; + item.pinAttachDistance = 0; + item.pinOffsetY = 0; + item.pinDamageTick = 0; + item.pinFallCheckTimer = 0; + item.pinLogAt = -999; } return item; } @@ -819,8 +864,14 @@ for (let i = (worldRef?.items || []).length - 1; i >= 0; i--) { const it = worldRef.items[i]; if (!isEditableItem(it)) continue; - const d = distXY(x, y, it.x, it.y); - if (d <= Math.max(54, (it.r || 24) * 2.4) && d < bestD) { best = it; bestD = d; } + let d = distXY(x, y, it.x, it.y); + let hit = d <= Math.max(30, (it.r || 24) * 1.35); + if (global.TarinaiMechanicalSystem?.isMechanicalType?.(it.type) || worldRef?.isFenceType?.(it.type)) { + const result = global.TarinaiCollisionFootprints?.hitTestItem?.(worldRef, it, x, y, { padding: 14, radiusMultiplier: 1.25 }) || { hit: false, distance: d }; + hit = Boolean(result.hit); + d = Math.min(d, result.distance ?? d); + } + if (hit && d < bestD) { best = it; bestD = d; } } return best; } @@ -885,9 +936,82 @@ return true; } + function isPhysicsObstacleItemType(type = "") { + const key = String(type || ""); + return Boolean( + key === "rope" || key === "rod" + || global.TarinaiMechanicalSystem?.isMechanicalType?.(key) + || (typeof isFenceItemType === "function" && isFenceItemType(key)) + ); + } + + function isSoftPhysicsIgnoredItemType(type = "") { + const key = String(type || ""); + return key === "water" || key === "grass_bed"; + } + + function softItemRadius(item) { + if (!item) return 12; + return Math.max(8, Number(item.r || item.radius || itemRadiusFor?.(item.type, 12) || 12) || 12); + } + + function physicsItemOverlapsSoftItem(worldRef, physicsItem, softItem) { + if (!worldRef || !physicsItem || !softItem || softItem.dead) return false; + const r = softItemRadius(softItem); + if (physicsItem.type === "rope" || physicsItem.type === "rod") { + const runtime = global.TarinaiLinkRuntime; + const a = runtime?.endpointWorld?.(physicsItem.linkA, worldRef); + const b = runtime?.endpointWorld?.(physicsItem.linkB, worldRef); + if (a && b) { + const d = runtime?.pointSegmentDistance?.(softItem.x, softItem.y, a.x, a.y, b.x, b.y); + return Number.isFinite(d) && d <= r + (physicsItem.type === "rod" ? 9 : 7); + } + } + const rects = worldRef.solidObstacleRects?.(physicsItem) || []; + if (rects.length) { + return rects.some(rect => global.TarinaiCollisionFootprints?.rectCircleOverlap?.(rect, softItem.x, softItem.y, r, 0.5)); + } + const pr = Math.max(10, Number(physicsItem.r || physicsItem.radius || itemRadiusFor?.(physicsItem.type, 12) || 12) || 12); + return distXY(physicsItem.x, physicsItem.y, softItem.x, softItem.y) <= pr + r; + } + + function clearSoftItemsUnderPhysicsItem(worldRef, item, reason = "physics-overlap-clear") { + if (!worldRef || !item || !isPhysicsObstacleItemType(item.type)) return 0; + let search = Math.max(80, Number(item.r || item.radius || 36) + 90); + if (global.TarinaiMechanicalSystem?.isMechanicalType?.(item.type)) { + search = Math.max(search, (global.TarinaiMechanicalSystem?.reach?.(item) || item.r || 80) + 96); + } else if (item.type === "rope" || item.type === "rod") { + search = Math.max(search, Number(item.linkLength || (item.r || 40) * 2 || 80) * 0.55 + 96); + } + let removed = 0; + for (const soft of worldRef.nearbyItems?.(item.x, item.y, search, true) || worldRef.items || []) { + if (!soft || soft === item || soft.dead || !isSoftPhysicsIgnoredItemType(soft.type)) continue; + if (!physicsItemOverlapsSoftItem(worldRef, item, soft)) continue; + const ok = global.TarinaiStructureLifecycle?.deleteItem?.(worldRef, soft, { + reason, + userReason: "物理アイテムに重なった", + wake: true, + }); + if (ok) removed += 1; + } + if (removed) { + worldRef.markItemBucketsDirty?.(reason); + worldRef.markSpatialDirty?.(reason); + worldRef.drawListDirty = true; + } + return removed; + } + + global.TarinaiPhysicsSoftClear = Object.freeze({ + isPhysicsType: isPhysicsObstacleItemType, + isSoftType: isSoftPhysicsIgnoredItemType, + overlaps: physicsItemOverlapsSoftItem, + clearForItem: clearSoftItemsUnderPhysicsItem, + }); + function clearOverlappingGrassBedsForPlacement(worldRef, item) { if (!worldRef || !item || item.type === "grass_bed" || item.type === "trace" || item.type === "splat") return 0; - if (global.TarinaiMechanicalSystem?.isMechanicalType?.(item.type) || item.type === "rope" || item.type === "rod" || (typeof isFenceItemType === "function" && isFenceItemType(item.type))) return 0; + if (isPhysicsObstacleItemType(item.type)) return clearSoftItemsUnderPhysicsItem(worldRef, item, "physics-placement-soft-clear"); const radius = Math.max(16, item.r || item.radius || 12); let removed = 0; for (const bed of worldRef.nearbyItems?.(item.x, item.y, radius + 72) || worldRef.items || []) { @@ -944,11 +1068,17 @@ useCopyToolAt(x, y) { if (!this.copyBuffer) { - const target = copyableItemAt(this, x, y); - if (!target) { showToast("コピーできるものがありません。"); return true; } + const targetInfo = copyTargetAt(this, x, y); + const target = targetInfo.item; + if (!target) { + if (targetInfo.reason === "link") showToast("\u30ed\u30fc\u30d7\u3068\u68d2\u306f\u30b3\u30d4\u30fc\u3067\u304d\u307e\u305b\u3093\u3002"); + else if (targetInfo.reason === "lodged_pin") showToast("\u523a\u3055\u3063\u305f\u30d4\u30f3\u306f\u30b3\u30d4\u30fc\u3067\u304d\u307e\u305b\u3093\u3002"); + else showToast("\u30b3\u30d4\u30fc\u3067\u304d\u308b\u3082\u306e\u304c\u3042\u308a\u307e\u305b\u3093\u3002"); + return true; + } this.copyBuffer = makeCopyBufferForItem(target); - showToast(`${toolLabel(target.type)}をコピーしました。`); - this.log?.(`${toolLabel(target.type)}をコピーした。`, "observe"); + showToast(`${toolLabel(target.type)}\u3092\u30b3\u30d4\u30fc\u3057\u307e\u3057\u305f\u3002`); + this.log?.(`${toolLabel(target.type)}\u3092\u30b3\u30d4\u30fc\u3057\u305f\u3002`, "observe"); return true; } const item = this.copyPreviewItemAt?.(x, y); @@ -960,17 +1090,12 @@ if (placed.type === "grass" || placed.type === "trace" || placed.type === "splat") this.markTerrainDirtyAt?.(placed.x, placed.y, Math.max(placed.r || 24, 36), "copy-paste"); forceImmediateToolVisualRefresh(this, "copy-paste"); const label = toolLabel(item.type); - this.log?.(`${label}を貼り付けた。`, "observe"); - showToast(`${label}を貼り付けました。`); + this.log?.(`${label}\u3092\u8cbc\u308a\u4ed8\u3051\u305f\u3002`, "observe"); + showToast(`${label}\u3092\u8cbc\u308a\u4ed8\u3051\u307e\u3057\u305f\u3002`); return true; } } - const target = copyableItemAt(this, x, y); - if (target) { - this.copyBuffer = makeCopyBufferForItem(target); - showToast(`${toolLabel(target.type)}をコピーしました。`); - this.log?.(`${toolLabel(target.type)}をコピーした。`, "observe"); - } + showToast("\u305d\u3053\u306b\u306f\u8cbc\u308a\u4ed8\u3051\u3067\u304d\u307e\u305b\u3093\u3002"); return true; }, @@ -1158,6 +1283,7 @@ if (item.type === "grass") showToast(`草はこのフィールドでは${this.grassLimit?.() ?? CONFIG.grassLimit ?? 99}本までです。`); return null; } + clearSoftItemsUnderPhysicsItem(this, item, `place:${item.type}:soft-overlap-clear`); this.emit("tool:place", { item, type: item.type, dropped }); this.emit("tool:placed", { item, type: item.type, dropped, tool: item.type }); this.drawListDirty = true; diff --git a/js/world_tool_actions.js b/js/world_tool_actions.js index 254aad5..3e59a10 100644 --- a/js/world_tool_actions.js +++ b/js/world_tool_actions.js @@ -178,6 +178,7 @@ if (!hit || d >= bestD) continue; const local = this.linkPointLocalForItem?.(it, x, y, isMechanism || isFence) || { localX: 0, localY: 0 }; best = { kind: "item", id: it.id, type: it.type, label: toolLabel(it.type), x: isMechanism || isFence ? x : it.x, y: isMechanism || isFence ? y : it.y, localX: local.localX, localY: local.localY, center: !(isMechanism || isFence) }; + if (isMechanism) global.TarinaiLinkRuntime?.snapEndpointToTarget?.(best, this); bestD = d; } return best; @@ -195,6 +196,8 @@ return true; } const first = this.pendingLinkEndpoint; + global.TarinaiLinkRuntime?.snapEndpointToTarget?.(first, this); + global.TarinaiLinkRuntime?.snapEndpointToTarget?.(endpoint, this); if (first.kind === endpoint.kind && first.id === endpoint.id && distXY(first.x, first.y, endpoint.x, endpoint.y) < 8) { showToast("別の位置または対象を選んでください。"); return true; @@ -217,6 +220,7 @@ const placed = this.addItem?.(item, constraintType); this.pendingLinkEndpoint = null; if (!placed) { showToast(`${toolName}を追加できませんでした。`); return true; } + global.TarinaiPhysicsSoftClear?.clearForItem?.(this, item, `${constraintType}-soft-overlap-clear`); forceImmediateToolVisualRefresh(this, `${constraintType}-create`); this.log?.(`${toolName}をつないだ。`, "observe"); showToast(`${toolName}をつなぎました。`); diff --git a/service-worker.js b/service-worker.js index 4c2783a..b0e832c 100644 --- a/service-worker.js +++ b/service-worker.js @@ -1,6 +1,6 @@ "use strict"; -const APP_VERSION = "38.00.00"; +const APP_VERSION = "39.00.00"; const CACHE_NAME = `tarinai-colony-${APP_VERSION}`; const v = `v=${APP_VERSION}`; // Generated from app_manifest.json. Run scripts/generate_app_files.py after changing static files. From 129c07183a0e84a4f18d7bf4c1603c326efe579d Mon Sep 17 00:00:00 2001 From: 33333-33333 Date: Sat, 27 Jun 2026 19:22:38 +0900 Subject: [PATCH 2/2] stable --- app_manifest.json | 3 +- index.html | 281 ++--- js/collision_footprint_system.js | 12 +- js/constraint_system.js | 886 +++++++++++++-- js/debug_tools.js | 181 +++- js/health.js | 2 + js/item_dynamic_pin_system.js | 50 +- js/item_dynamic_system.js | 1 + js/item_dynamic_tool_system.js | 6 + js/item_lifecycle_support.js | 4 +- js/item_registry.js | 51 +- js/item_render_runtime.js | 205 +++- js/item_type_initializers.js | 92 +- js/item_update_policy.js | 8 +- js/item_update_scheduler.js | 47 +- js/mechanical_system.js | 1548 ++++++++++++++++++++++++--- js/perf_profiler.js | 83 +- js/physics_world_system.js | 589 ++++++++++ js/render.js | 201 +++- js/restore_coordinator.js | 1 - js/save_codec.js | 2 +- js/save_schema.js | 12 +- js/sim_core.js | 149 ++- js/simulation_creature_system.js | 103 +- js/simulation_item_ant_system.js | 37 +- js/snapshot_system.js | 117 +- js/system_order.js | 10 +- js/tarinai.js | 32 +- js/tarinai_building_behavior.js | 32 +- js/tarinai_disease_nest.js | 69 +- js/tarinai_needs_items.js | 32 +- js/tarinai_runtime.js | 18 +- js/tarinai_social_action_runtime.js | 32 +- js/tarinai_social_move_life.js | 189 +++- js/tarinai_update_legacy_system.js | 52 +- js/tarinai_update_policy.js | 124 ++- js/tarinai_update_step_frame.js | 52 +- js/tarinai_update_step_movement.js | 202 +++- js/ui_bind.js | 1 - js/ui_input_shared.js | 19 +- js/version.js | 4 +- js/world.js | 1 - js/world_combat_effects.js | 355 +++--- js/world_environment.js | 423 +++++++- js/world_family_social.js | 143 ++- js/world_placement_log.js | 224 ++-- js/world_spatial_budget.js | 46 +- js/world_tool_actions.js | 36 +- js/world_view.js | 41 +- scripts/regression_check.py | 21 + service-worker.js | 4 +- 51 files changed, 5711 insertions(+), 1122 deletions(-) create mode 100644 js/physics_world_system.js diff --git a/app_manifest.json b/app_manifest.json index e030626..caa7202 100644 --- a/app_manifest.json +++ b/app_manifest.json @@ -1,5 +1,5 @@ { - "version": "39.00.00", + "version": "39.15.18", "css": [ "css/base.css", "css/layout.css", @@ -22,6 +22,7 @@ "js/collision_footprint_system.js", "js/mechanical_system.js", "js/constraint_system.js", + "js/physics_world_system.js", "js/assets.js", "js/audio.js", "js/perf_profiler.js", diff --git a/index.html b/index.html index 84efbfd..11c5b1f 100644 --- a/index.html +++ b/index.html @@ -7,11 +7,11 @@ - - - - - + + + + +
@@ -189,140 +189,141 @@
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/js/collision_footprint_system.js b/js/collision_footprint_system.js index 5a620a5..bc7881a 100644 --- a/js/collision_footprint_system.js +++ b/js/collision_footprint_system.js @@ -164,7 +164,8 @@ function hitTestItem(worldRef, item, x, y, opts = {}) { if (!item || item.dead) return { hit: false, distance: Infinity }; const padding = Math.max(0, num(opts.padding, 0)); - const rects = opts.rects || worldRef?.solidObstacleRects?.(item) || []; + let rects = opts.rects || worldRef?.solidObstacleRects?.(item) || []; + if ((!rects || !rects.length) && item?.type === "poison_block") rects = global.TarinaiMechanicalSystem?.poisonHazardRects?.(item) || []; let bestDistance = Math.hypot(num(x) - num(item.x), num(y) - num(item.y)); let hit = false; if (rects.length) { @@ -175,7 +176,7 @@ } const mechHit = global.TarinaiMechanicalSystem?.isMechanicalType?.(item.type); if (mechHit) { - const hub = Math.max(18, Math.min(40, num(item.rotatorThickness, 12) * 2.3)); + const hub = Math.max(18, Math.min(40, (global.TarinaiPhysicsBodySystem?.scalar?.(item, "thickness", 12) ?? 12) * 2.3)); if (Math.hypot(num(x) - num(item.x), num(y) - num(item.y)) <= hub + padding) hit = true; } else if (!rects.length) { const mul = num(opts.radiusMultiplier, item.type === "ball" ? 4.0 : 2.2); @@ -187,7 +188,7 @@ function itemPlacementOverlapBlocked(worldRef, item, opts = {}) { if (!worldRef || !item || item.dead) return true; - const important = opts.important || new Set(["grass", "fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "reciprocator", "nest_box", "water"]); + const important = opts.important || new Set(["grass", "fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "poison_block", "reciprocator", "nest_box", "water"]); const isPhysicsItem = (global.TarinaiMechanicalSystem?.isMechanicalType?.(item.type) || item.type === "rope" || item.type === "rod" || (typeof isFenceItemType === "function" && isFenceItemType(item.type))); if (!important.has(item.type) && !isPhysicsItem) return false; const placingGrass = item.type === "grass"; @@ -197,6 +198,11 @@ for (const other of worldRef.nearbyItems?.(item.x, item.y, search) || []) { if (!other || other === item || other.dead) continue; if (isPhysicsItem && (other.type === "water" || other.type === "grass_bed")) continue; + if (other.type === "poison_block") { + const body = global.TarinaiPhysicsBodySystem?.ensureBody?.(other, worldRef, { syncFromLegacy: false }); + const solid = body?.collision ? body.collision.solid !== false : true; + if (!solid) continue; + } if (!important.has(other.type)) continue; if (!placingGrass && other.type === "grass") continue; const otherRects = worldRef.solidObstacleRects?.(other) || []; diff --git a/js/constraint_system.js b/js/constraint_system.js index b549762..8e0b7cd 100644 --- a/js/constraint_system.js +++ b/js/constraint_system.js @@ -3,55 +3,169 @@ // Constraint solver for link-like tools. // Owns attachment resolution and distance constraints for flexible ropes and rigid rods. (function (global) { + + function bodyApi() { return global.TarinaiPhysicsBodySystem || null; } + function ps(item, key, fallback = 0) { return bodyApi()?.scalar?.(item, key, fallback) ?? fallback; } + function pset(item, key, value, reason = "constraint-body-write") { return bodyApi()?.setScalar?.(item, key, value, reason) || false; } + function ls(item, key, fallback = 0) { return bodyApi()?.linkScalar?.(item, key, fallback) ?? fallback; } + function lset(item, key, value) { return bodyApi()?.setLinkScalar?.(item, key, value) || false; } + function ep(item, index) { return bodyApi()?.endpoint?.(item, index) || null; } + function setEp(item, index, value) { return bodyApi()?.setEndpoint?.(item, index, value) || false; } + function parts(item) { return bodyApi()?.particles?.(item) || null; } + function setParts(item, list) { return bodyApi()?.setParticles?.(item, list) || false; } + function ensureConstraint(item, worldRef = null, opts = {}) { + return bodyApi()?.ensureConstraint?.(item, worldRef || item?.world || null, { syncFromLegacy: opts.syncFromLegacy === true }) || item?.physicsConstraint || null; + } + function applyConstraintState(item) { return bodyApi()?.applyConstraintState?.(item, item?.physicsConstraint) || false; } + function normalizeConstraintState(item) { return bodyApi()?.normalizeConstraintState?.(item, item?.physicsConstraint) || item?.physicsConstraint || null; } + function commitConstraint(item) { + if (!item || (item.type !== "rope" && item.type !== "rod")) return null; + const c = ensureConstraint(item, item.world || null, { syncFromLegacy: false }); + if (!c) return null; + normalizeConstraintState(item); + return c; + } + function commitMechanicalBody(item, reason = "constraint-moved-body") { + if (!item || !global.TarinaiMechanicalSystem?.isMechanicalType?.(item.type)) return false; + // Constraint pulls sometimes mutate item.x/y as a scratch pose. Preserve + // that pose in physicsBody before normalizing, otherwise normalizeBody() + // can restore the old body pose and silently cancel the correction. + bodyApi()?.syncPoseFromItem?.(item); + bodyApi()?.normalizeBodyState?.(item, item.physicsBody); + bodyApi()?.markBodyChanged?.(item, reason); + return true; + } + + function constraintKick(move, dt, scale = 0.055, limit = 90) { + // Constraint projection is primarily positional. Convert only a small, + // capped part of the correction back to velocity so links do not inject + // energy when the frame time spikes or endpoints start deeply overlapped. + const step = Math.max(0.028, Math.min(0.055, Number(dt || 0.016) || 0.016)); + return clamp(move / step * scale, -limit, limit); + } + + function rectNearPointAabb(rect, x, y, padding = 0) { + if (!rect) return false; + const pad = Math.max(0, Number(padding || 0) || 0); + return x >= (Number(rect.left || 0) - pad) + && x <= (Number(rect.right || 0) + pad) + && y >= (Number(rect.top || 0) - pad) + && y <= (Number(rect.bottom || 0) + pad); + } + + function rectNearSegmentAabb(rect, x1, y1, x2, y2, padding = 0) { + if (!rect) return false; + const pad = Math.max(0, Number(padding || 0) || 0); + const left = Math.min(x1, x2) - pad; + const right = Math.max(x1, x2) + pad; + const top = Math.min(y1, y2) - pad; + const bottom = Math.max(y1, y2) + pad; + return !(right < Number(rect.left || 0) || left > Number(rect.right || 0) || bottom < Number(rect.top || 0) || top > Number(rect.bottom || 0)); + } + + function markConstraintSpatialDirty(worldRef, item, reason = "constraint-projection", threshold = 0.35, minInterval = 0.065) { + const world = worldRef || item?.world || null; + if (!world || !item) return false; + const now = Number(world.time || 0) || 0; + const x = Number(item.x || 0) || 0; + const y = Number(item.y || 0) || 0; + const r = Number(item.r || item.radius || 0) || 0; + const lastX = Number(item._lastConstraintSpatialDirtyX); + const lastY = Number(item._lastConstraintSpatialDirtyY); + const lastR = Number(item._lastConstraintSpatialDirtyR); + const missing = !Number.isFinite(lastX) || !Number.isFinite(lastY) || !Number.isFinite(lastR); + const moved = missing || Math.hypot(x - lastX, y - lastY) > threshold || Math.abs(r - lastR) > threshold; + const due = now >= Number(item._nextConstraintSpatialDirtyAt || 0); + if (moved || due) { + item._lastConstraintSpatialDirtyX = x; + item._lastConstraintSpatialDirtyY = y; + item._lastConstraintSpatialDirtyR = r; + item._nextConstraintSpatialDirtyAt = now + Math.max(0.016, Number(minInterval || 0.065) || 0.065); + world.markSpatialDirty?.(reason); + if (world.constraintDirtyStatsThisFrame) world.constraintDirtyStatsThisFrame.marked = (world.constraintDirtyStatsThisFrame.marked || 0) + 1; + return true; + } + if (world.constraintDirtyStatsThisFrame) world.constraintDirtyStatsThisFrame.coalesced = (world.constraintDirtyStatsThisFrame.coalesced || 0) + 1; + return false; + } + + function mechanicalPowered(item) { + return Boolean(global.TarinaiMechanicalSystem?.isPowered?.(item)); + } + + function commitConstraintMovedMechanical(item, reason = "constraint-projection") { + if (!item || !global.TarinaiMechanicalSystem?.isMechanicalType?.(item.type)) return false; + bodyApi()?.syncPoseFromItem?.(item); + bodyApi()?.normalizeBodyState?.(item, item.physicsBody); + bodyApi()?.markBodyChanged?.(item, reason); + markConstraintSpatialDirty(item.world, item, reason, 0.22, 0.045); + item.world && (item.world.drawListDirty = true); + return true; + } function reciprocatorAxis(item) { if (global.TarinaiMechanicalSystem?.axis) return global.TarinaiMechanicalSystem.axis(item); const fallback = typeof itemAngleFor === "function" ? itemAngleFor(item) : (Number(item?.angle) || 0); - const a = typeof normalizedItemAngle === "function" ? normalizedItemAngle(item?.reciprocatorAxisAngle, fallback) : (Number.isFinite(Number(item?.reciprocatorAxisAngle)) ? Number(item.reciprocatorAxisAngle) : fallback); + const a = typeof normalizedItemAngle === "function" ? normalizedItemAngle(ps(item, "railAxis", fallback), fallback) : ps(item, "railAxis", fallback); return { x: Math.cos(a), y: Math.sin(a), angle: a }; } function reciprocatorHalfTravel(item) { - return Math.max(24, Number(item?.reciprocatorTravel || 150) || 150) * 0.5; + return Math.max(24, ps(item, "railTravel", 150)) * 0.5; } function resetReciprocatorAnchorIfMissing(item) { - if (!Number.isFinite(Number(item.reciprocatorAnchorX))) item.reciprocatorAnchorX = item.x || 0; - if (!Number.isFinite(Number(item.reciprocatorAnchorY))) item.reciprocatorAnchorY = item.y || 0; + if (!Number.isFinite(Number(ps(item, "railAnchorX", NaN)))) pset(item, "railAnchorX", item.x || 0, "constraint-anchor-init"); + if (!Number.isFinite(Number(ps(item, "railAnchorY", NaN)))) pset(item, "railAnchorY", item.y || 0, "constraint-anchor-init"); } - function reciprocatorPhaseFromPosition(item) { + function railPhaseFromPosition(item) { resetReciprocatorAnchorIfMissing(item); const axis = reciprocatorAxis(item); const travel = reciprocatorHalfTravel(item); - const dx = (Number(item.x || 0) || 0) - (Number(item.reciprocatorAnchorX || 0) || 0); - const dy = (Number(item.y || 0) || 0) - (Number(item.reciprocatorAnchorY || 0) || 0); + const dx = (Number(item.x || 0) || 0) - ps(item, "railAnchorX", 0); + const dy = (Number(item.y || 0) || 0) - ps(item, "railAnchorY", 0); return clamp((dx * axis.x + dy * axis.y) / Math.max(10, travel), -1, 1); } function resolveEndpointObject(endpoint, worldRef) { if (!endpoint || !worldRef) return null; - const allowFuzzy = endpoint.fuzzyResolve === true || endpoint.legacyFuzzy === true; + const allowFuzzy = endpoint.fuzzyResolve === true || endpoint.fuzzyFallback === true; + const cached = endpoint._ref; + if (cached && !cached.dead && (cached.id === endpoint.id || endpoint.kind === "tarinai")) return cached; if (endpoint.kind === "tarinai") { - let found = (worldRef.tarinai || []).find(t => t && !t.dead && t.id === endpoint.id); + let found = null; + if (worldRef.liveTarinaiById) found = worldRef.liveTarinaiById(endpoint.id, endpoint.liveToken ?? null) || null; + if (!found && worldRef.tarinaiById?.get) found = worldRef.tarinaiById.get(endpoint.id) || null; + if (!found && worldRef.liveTarinai?.get) { + const entry = worldRef.liveTarinai.get(endpoint.id) || null; + found = entry?.target || entry || null; + } + if (!found && endpoint.id) found = (worldRef.tarinai || []).find(t => t && !t.dead && t.id === endpoint.id); + if (!found && cached && !cached.dead && cached.radius && cached.name) found = cached; if (!found && allowFuzzy && Number.isFinite(endpoint.x) && Number.isFinite(endpoint.y)) { - found = (worldRef.tarinai || []).find(t => t && !t.dead && distXY(t.x, t.y, endpoint.x, endpoint.y) <= Math.max(40, (t.radius || 20) * 1.6)); + const source = worldRef.nearbyTarinai?.(endpoint.x, endpoint.y, 80, true) || worldRef.tarinai || []; + found = source.find(t => t && !t.dead && distXY(t.x, t.y, endpoint.x, endpoint.y) <= Math.max(40, (t.radius || 20) * 1.6)); if (found) { endpoint.id = found.id; endpoint.fuzzyResolve = false; - endpoint.legacyFuzzy = false; + endpoint.fuzzyFallback = false; } } + endpoint._ref = found || null; return found || null; } - let found = (worldRef.items || []).find(it => it && !it.dead && it.id === endpoint.id); + let found = worldRef.itemById?.(endpoint.id) || null; + if (!found && endpoint.id != null) found = (worldRef.items || []).find(it => it && !it.dead && it.id === endpoint.id); if (!found && allowFuzzy && Number.isFinite(endpoint.x) && Number.isFinite(endpoint.y)) { - found = (worldRef.items || []).find(it => it && !it.dead && it.type === endpoint.type && distXY(it.x, it.y, endpoint.x, endpoint.y) <= Math.max(44, (it.r || 20) * 1.8)); + const source = worldRef.nearbyItems?.(endpoint.x, endpoint.y, 120, true) || worldRef.items || []; + found = source.find(it => it && !it.dead && it.type === endpoint.type && distXY(it.x, it.y, endpoint.x, endpoint.y) <= Math.max(44, (it.r || 20) * 1.8)); if (found) { endpoint.id = found.id; endpoint.fuzzyResolve = false; - endpoint.legacyFuzzy = false; + endpoint.fuzzyFallback = false; } } + endpoint._ref = found || null; return found || null; } @@ -100,6 +214,25 @@ return best; } + function mechanicalSupportTolerance(item) { + const thick = Math.max(4, Math.min(34, ps(item, "thickness", 12))); + return Math.max(14, thick * 0.9 + 7); + } + + function mechanicalLocalPointSupported(item, lx, ly, endpoint = null) { + if (!isMechanicalLinkTarget(item)) return true; + const version = Number(item?._mechanicalShapeVersion || item?._segmentsVersion || 0) || 0; + if (endpoint && endpoint._supportItemId === item.id && endpoint._supportCheckedVersion === version) return endpoint._supportAlive !== false; + const nearest = nearestMechanicalLocalPoint(item, lx, ly); + const alive = Boolean(nearest && nearest.d <= mechanicalSupportTolerance(item)); + if (endpoint) { + endpoint._supportItemId = item.id; + endpoint._supportCheckedVersion = version; + endpoint._supportAlive = alive; + } + return alive; + } + function snapEndpointToTarget(endpoint, worldRef) { const obj = resolveEndpointObject(endpoint, worldRef); if (!endpoint || !isMechanicalLinkTarget(obj)) return endpoint; @@ -112,13 +245,27 @@ local = { x: 0, y: 0 }; } const snapped = nearestMechanicalLocalPoint(obj, local.x, local.y); - if (!snapped) return endpoint; + if (!snapped || snapped.d > mechanicalSupportTolerance(obj)) { + const hub = Math.max(18, Math.min(40, ps(obj, "thickness", 12) * 2.3)); + if (Math.hypot(local.x, local.y) <= hub) { + endpoint.localX = 0; + endpoint.localY = 0; + endpoint.x = obj.x || 0; + endpoint.y = obj.y || 0; + endpoint.center = true; + endpoint.supportLost = false; + return endpoint; + } + endpoint.supportLost = true; + return endpoint; + } const world = localToWorld(obj, snapped.x, snapped.y); endpoint.localX = snapped.x; endpoint.localY = snapped.y; endpoint.x = world.x; endpoint.y = world.y; endpoint.center = false; + endpoint.supportLost = false; return endpoint; } @@ -138,8 +285,8 @@ }; for (const item of worldRef.items || []) { if (!item || item.dead || (item.type !== "rope" && item.type !== "rod")) continue; - const a = remap(item.linkA); - const b = remap(item.linkB); + const a = remap(ep(item, 0)); + const b = remap(ep(item, 1)); if (a || b) { item.world = worldRef; changed += 1; @@ -162,17 +309,19 @@ const guard = seen || new Set(); if (guard.has(obj.id)) return { x: obj.x || 0, y: obj.y || 0, obj }; guard.add(obj.id); - const a = endpointWorld(obj.linkA, worldRef, depth + 1, guard); - const b = endpointWorld(obj.linkB, worldRef, depth + 1, guard); + const a = endpointWorld(ep(obj, 0), worldRef, depth + 1, guard); + const b = endpointWorld(ep(obj, 1), worldRef, depth + 1, guard); guard.delete(obj.id); if (a && b) { const t = clamp(Number(endpoint.attachT || 0), 0, 1); return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t, obj }; } } + if (endpoint.supportLost) return null; const lx = Number(endpoint.localX || 0) || 0; const ly = Number(endpoint.localY || 0) || 0; - if (endpoint.center || (!lx && !ly)) return { x: obj.x || 0, y: obj.y || 0, obj }; + if (isMechanicalLinkTarget(obj) && !endpoint.center && (Number.isFinite(Number(endpoint.localX)) || Number.isFinite(Number(endpoint.localY))) && !mechanicalLocalPointSupported(obj, lx, ly, endpoint)) return null; + if (endpoint.center || (!lx && !ly)) return { x: Number.isFinite(Number(obj.x)) ? Number(obj.x) : 0, y: Number.isFinite(Number(obj.y)) ? Number(obj.y) : 0, obj }; const a = itemAngle(obj); const c = Math.cos(a), s = Math.sin(a); return { x: (obj.x || 0) + lx * c - ly * s, y: (obj.y || 0) + lx * s + ly * c, obj }; @@ -185,12 +334,17 @@ if (obj.type === "ball") return 0.7; if (obj.type === "zunchi") return 0.55; if (obj.type === "rope" || obj.type === "rod") return 1.6; - if (global.TarinaiMechanicalSystem?.isMechanicalType?.(obj.type)) return 3.8; + if (global.TarinaiMechanicalSystem?.isMechanicalType?.(obj.type)) { + if (obj.type === "rotator") return Infinity; // anchored pivot: links may torque it, not translate it + if (obj.type === "reciprocator") return mechanicalPowered(obj) ? 18.0 : 5.8; + if (obj.type === "poison_block") return Math.max(2.4, ps(obj, "mass", 4.8)); + return 8.0; + } if (obj.type && (obj.type.includes("fence") || obj.type === "bed" || obj.type === "nest_box")) return obj.type.includes("fence") ? Infinity : 5.5; return 2.2; } - function moveEndpointObject(obj, dx, dy, endpoint, dt, strength = 1, depth = 0) { + function moveEndpointObject(obj, dx, dy, endpoint, dt, strength = 1, depth = 0, mode = "rope") { if (!obj || !Number.isFinite(dx) || !Number.isFinite(dy)) return false; if (obj._heldByPlayer || obj.playerHeld) return false; if (depth > 5) return false; @@ -202,35 +356,99 @@ let changed = false; const wA = 1 - t; const wB = t; - if (obj.linkA) changed = moveEndpointObject(resolveEndpointObject(obj.linkA, obj.world || null), moveX * wA, moveY * wA, obj.linkA, dt, 0.72, depth + 1) || changed; - if (obj.linkB) changed = moveEndpointObject(resolveEndpointObject(obj.linkB, obj.world || null), moveX * wB, moveY * wB, obj.linkB, dt, 0.72, depth + 1) || changed; + if (ep(obj, 0)) changed = moveEndpointObject(resolveEndpointObject(ep(obj, 0), obj.world || null), moveX * wA, moveY * wA, ep(obj, 0), dt, mode === "rod" ? 0.92 : 0.72, depth + 1, mode) || changed; + if (ep(obj, 1)) changed = moveEndpointObject(resolveEndpointObject(ep(obj, 1), obj.world || null), moveX * wB, moveY * wB, ep(obj, 1), dt, mode === "rod" ? 0.92 : 0.72, depth + 1, mode) || changed; return changed; } if (obj.radius && obj.name) { obj.x += moveX; obj.y += moveY; - obj.vx = clamp((obj.vx || 0) + moveX / Math.max(0.016, dt || 0.016) * 0.18, -180, 180); - obj.vy = clamp((obj.vy || 0) + moveY / Math.max(0.016, dt || 0.016) * 0.18, -180, 180); + const kickScale = mode === "rod" ? 0.038 : 0.052; + const kickLimit = mode === "rod" ? 88 : 105; + 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) { + global.TarinaiMovementUpdateStep?.markSleepExternalMotion?.(obj, dt, `constraint-${mode}`); + obj._sleepExternalMotionUntil = Math.max(Number(obj._sleepExternalMotionUntil || 0) || 0, (Number(obj.world?.time || 0) || 0) + (mode === "rod" ? 1.1 : 0.6)); + } obj.target = null; return true; } if (obj.type === "reciprocator") { const mech = global.TarinaiMechanicalSystem; mech?.resetAnchor?.(obj) || resetReciprocatorAnchorIfMissing(obj); - const p = endpointWorld(endpoint, obj.world || null) || { x: obj.x, y: obj.y }; - if (mech?.applyImpulse?.(obj, p.x || obj.x, p.y || obj.y, moveX * 34, moveY * 34, 0.55)) return true; const axis = mech?.axis?.(obj) || reciprocatorAxis(obj); - const along = moveX * axis.x + moveY * axis.y; - obj.reciprocatorVelocity = clamp((obj.reciprocatorVelocity || 0) + along / Math.max(0.016, dt || 0.016) * 0.035, -150, 150); - return Math.abs(along) > 0.001; + const alongRaw = moveX * axis.x + moveY * axis.y; + if (!Number.isFinite(alongRaw) || Math.abs(alongRaw) < 0.001) return false; + const powered = mechanicalPowered(obj); + // 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 along = clamp(alongRaw * railScale, -railCap, railCap); + if (Math.abs(along) < 0.001) return false; + const beforePhase = ps(obj, "railPhase", 0); + pset(obj, "railPhase", clamp(beforePhase + along / Math.max(10, reciprocatorHalfTravel(obj)), -1, 1), "constraint-rail-project"); + const next = mech?.positionFromPhase?.(obj) || null; + if (next) { + obj.prevX = Number(obj.x || 0) || 0; + obj.prevY = Number(obj.y || 0) || 0; + obj.x = next.x; + obj.y = next.y; + } + 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"); + 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 px = clamp(moveX * blockScale, -blockCap, blockCap); + const py = clamp(moveY * blockScale, -blockCap, blockCap); + if (Math.hypot(px, py) < 0.001) return false; + obj.prevX = beforeX; + obj.prevY = beforeY; + obj.x = beforeX + px; + obj.y = beforeY + py; + const len = Math.max(0.001, Math.hypot(px, py)); + const nx = px / len; + const ny = py / len; + const vx = ps(obj, "xv", obj.vx || 0); + const vy = ps(obj, "yv", obj.vy || 0); + const intoCorrection = vx * nx + vy * ny; + let nextVx = vx; + let nextVy = vy; + if (intoCorrection < 0) { + nextVx -= nx * intoCorrection * 0.50; + nextVy -= ny * intoCorrection * 0.50; + } + nextVx += constraintKick(px, dt, 0.012, 36); + nextVy += constraintKick(py, dt, 0.012, 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); + obj.vy = ps(obj, "yv", obj.vy || 0); + commitConstraintMovedMechanical(obj, "constraint-poison-project"); + global.TarinaiMechanicalSystem?.wakeItem?.(obj, "constraint-poison-project"); + return true; } if (obj.type === "rotator") { + const powered = mechanicalPowered(obj); + if (powered) return false; const p = endpointWorld(endpoint, obj.world || null) || { x: obj.x, y: obj.y }; - if (global.TarinaiMechanicalSystem?.applyImpulse?.(obj, p.x || obj.x, p.y || obj.y, moveX * 38, moveY * 38, 0.58)) return true; const rx = (p.x || obj.x) - obj.x; const ry = (p.y || obj.y) - obj.y; const torque = rx * moveY - ry * moveX; - obj.rotatorAngularVelocity = clamp((obj.rotatorAngularVelocity || 0) + torque * 0.0009, -2.0, 2.0); + const delta = clamp(torque * 0.00018, -0.11, 0.11); + if (!Number.isFinite(delta) || Math.abs(delta) < 0.0015) return false; + pset(obj, "spin", clamp(ps(obj, "spin", 0) * 0.82 + delta, -1.45, 1.45), "constraint-passive-rotator-project"); + commitMechanicalBody(obj, "constraint-passive-rotator-project"); return true; } if (obj.type && obj.type.includes("fence")) return false; @@ -238,8 +456,8 @@ obj.prevY = obj.y; obj.x += moveX; obj.y += moveY; - obj.vx = clamp((obj.vx || 0) + moveX / Math.max(0.016, dt || 0.016) * 0.10, -140, 140); - obj.vy = clamp((obj.vy || 0) + moveY / Math.max(0.016, dt || 0.016) * 0.10, -140, 140); + 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); return true; } @@ -248,71 +466,83 @@ const ax = a.x, ay = a.y, bx = b.x, by = b.y; const dx = bx - ax, dy = by - ay; const d = Math.max(0.001, Math.hypot(dx, dy)); - if (!Number.isFinite(item.linkMidX) || !Number.isFinite(item.linkMidY)) { - item.linkMidX = (ax + bx) * 0.5; - item.linkMidY = (ay + by) * 0.5 + Math.max(4, (length - d) * 0.25); - item.linkMidVX = 0; - item.linkMidVY = 0; + if (!Number.isFinite(ls(item, "midX", NaN)) || !Number.isFinite(ls(item, "midY", NaN))) { + lset(item, "midX", (ax + bx) * 0.5); + lset(item, "midY", (ay + by) * 0.5 + Math.max(4, (length - d) * 0.25)); + lset(item, "midVx", 0); + lset(item, "midVy", 0); } - const prevX = item.linkMidX; - const prevY = item.linkMidY; + const prevX = ls(item, "midX", item.x || 0); + const prevY = ls(item, "midY", item.y || 0); const step = Math.max(0.008, Math.min(0.05, Number(dt || 0.016) || 0.016)); - item.linkMidVX = (Number(item.linkMidVX || 0) || 0) * Math.pow(0.62, step * 60); - item.linkMidVY = (Number(item.linkMidVY || 0) || 0) * Math.pow(0.62, step * 60) + 90 * step; - item.linkMidX += item.linkMidVX * step; - item.linkMidY += item.linkMidVY * step; + lset(item, "midVx", ls(item, "midVx", 0) * Math.pow(0.62, step * 60)); + lset(item, "midVy", ls(item, "midVy", 0) * Math.pow(0.62, step * 60) + 90 * step); + lset(item, "midX", ls(item, "midX", 0) + ls(item, "midVx", 0) * step); + lset(item, "midY", ls(item, "midY", 0) + ls(item, "midVy", 0) * step); const half = Math.max(12, length * 0.5); if (d >= length * 0.985) { - item.linkMidX = (ax + bx) * 0.5; - item.linkMidY = (ay + by) * 0.5; - item.linkMidVX *= 0.25; - item.linkMidVY *= 0.25; + lset(item, "midX", (ax + bx) * 0.5); + lset(item, "midY", (ay + by) * 0.5); + lset(item, "midVx", ls(item, "midVx", 0) * 0.25); + lset(item, "midVy", ls(item, "midVy", 0) * 0.25); } else { for (let i = 0; i < 4; i += 1) { for (const pnt of [[ax, ay], [bx, by]]) { - const vx = item.linkMidX - pnt[0]; - const vy = item.linkMidY - pnt[1]; + const vx = ls(item, "midX", 0) - pnt[0]; + const vy = ls(item, "midY", 0) - pnt[1]; const dd = Math.max(0.001, Math.hypot(vx, vy)); if (dd > half) { - item.linkMidX = pnt[0] + vx / dd * half; - item.linkMidY = pnt[1] + vy / dd * half; + lset(item, "midX", pnt[0] + vx / dd * half); + lset(item, "midY", pnt[1] + vy / dd * half); } } } const minY = Math.min(ay, by) - length * 0.35; const maxY = Math.max(ay, by) + length * 0.70; - item.linkMidY = clamp(item.linkMidY, minY, maxY); + lset(item, "midY", clamp(ls(item, "midY", 0), minY, maxY)); } - item.linkMidVX = (item.linkMidX - prevX) / step; - item.linkMidVY = (item.linkMidY - prevY) / step; - return Math.hypot(item.linkMidX - prevX, item.linkMidY - prevY) > 0.01; + lset(item, "midVx", clamp((ls(item, "midX", 0) - prevX) / step, -220, 220)); + lset(item, "midVy", clamp((ls(item, "midY", 0) - prevY) / step, -220, 220)); + return Math.hypot(ls(item, "midX", 0) - prevX, ls(item, "midY", 0) - prevY) > 0.01; } function applyDistanceConstraint(item, a, b, dt, length, mode) { - const dx = b.x - a.x; - const dy = b.y - a.y; - 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; - const nx = dx / d; - const ny = dy / d; - const ma = endpointMass(a.obj); - const mb = endpointMass(b.obj); - const ia = Number.isFinite(ma) ? 1 / Math.max(0.1, ma) : 0; - const ib = Number.isFinite(mb) ? 1 / Math.max(0.1, mb) : 0; - const sum = ia + ib || 1; + const solveOnce = (pa, pb, strength, maxFraction) => { + if (!pa || !pb || !pa.obj || !pb.obj) return false; + const dx = pb.x - pa.x; + const dy = pb.y - pa.y; + 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; + const nx = dx / d; + const ny = dy / d; + const ma = endpointMass(pa.obj); + const mb = endpointMass(pb.obj); + const ia = Number.isFinite(ma) ? 1 / Math.max(0.1, ma) : 0; + 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 target = delta / (1 + compliance); + const cap = mode === "rod" + ? Math.max(6.0, Math.min(46, length * maxFraction)) + : Math.max(1.5, length * maxFraction); + const corr = clamp(target, -cap, cap); + let changed = false; + changed = moveEndpointObject(pa.obj, nx * corr * (ia / sum), ny * corr * (ia / sum), ep(item, 0), dt, strength, 0, mode) || changed; + changed = moveEndpointObject(pb.obj, -nx * corr * (ib / sum), -ny * corr * (ib / sum), ep(item, 1), dt, strength, 0, mode) || changed; + return changed; + }; let changed = false; if (mode === "rope") { - const corr = Math.min(delta, Math.max(2, length * 0.08)); - changed = moveEndpointObject(a.obj, nx * corr * (ia / sum), ny * corr * (ia / sum), item.linkA, dt, 0.85) || changed; - changed = moveEndpointObject(b.obj, -nx * corr * (ib / sum), -ny * corr * (ib / sum), item.linkB, dt, 0.85) || changed; + changed = solveOnce(a, b, 0.58, 0.044) || changed; } else { - const iterations = 3; + const iterations = 7; for (let i = 0; i < iterations; i += 1) { - const corr = clamp(delta / iterations, -Math.max(3, length * 0.18), Math.max(3, length * 0.18)); - changed = moveEndpointObject(a.obj, nx * corr * (ia / sum), ny * corr * (ia / sum), item.linkA, dt, 1.05) || changed; - changed = moveEndpointObject(b.obj, -nx * corr * (ib / sum), -ny * corr * (ib / sum), item.linkB, dt, 1.05) || changed; + 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; } } return changed; @@ -320,33 +550,336 @@ function primeLinkState(item, a, b) { const d = Math.max(0.001, distXY(a.x, a.y, b.x, b.y)); - const length = Math.max(24, Number(item.linkLength || d) || d); + const length = Math.max(24, Number(ls(item, "len", 80) || d) || d); item.x = (a.x + b.x) * 0.5; item.y = (a.y + b.y) * 0.5; item.r = Math.max(18, length * 0.5); - item.linkLength = length; - item.linkA.x = a.x; item.linkA.y = a.y; - item.linkB.x = b.x; item.linkB.y = b.y; + lset(item, "len", length); + ep(item, 0).x = a.x; ep(item, 0).y = a.y; + ep(item, 1).x = b.x; ep(item, 1).y = b.y; return length; } + function ropeLodSettings(worldRef, length) { + const tier = global.TarinaiPerf?.renderQualityTier?.() || "high"; + const ropeCount = Number(worldRef?.itemCounts?.rope || 0) || 0; + let spacing = 34; + let maxParticles = 24; + let iterations = 6; + let particleStride = 1; + if (tier === "mid") { spacing = 42; maxParticles = 20; iterations = 5; } + else if (tier === "low") { spacing = 56; maxParticles = 15; iterations = 4; particleStride = 2; } + if (ropeCount > 12) { spacing *= 1.18; maxParticles = Math.max(10, maxParticles - 4); iterations = Math.max(3, iterations - 1); } + if (ropeCount > 24) { spacing *= 1.28; maxParticles = Math.max(8, maxParticles - 4); iterations = Math.max(3, iterations - 1); particleStride = 2; } + const targetCount = Math.max(4, Math.min(maxParticles, Math.ceil(Math.max(24, length) / spacing) + 1)); + return { tier, ropeCount, spacing, maxParticles, iterations, particleStride, targetCount }; + } + + function ensureRopeParticles(item, a, b, length, worldRef = null) { + const lod = ropeLodSettings(worldRef, length); + const targetCount = lod.targetCount; + const oldParticles = Array.isArray(parts(item)) ? parts(item) : null; + if (!oldParticles || oldParticles.length !== targetCount || item._ropeLodTargetCount !== targetCount) { + const previous = oldParticles && oldParticles.length >= 2 ? oldParticles.slice() : null; + setParts(item, []); + for (let i = 0; i < targetCount; i += 1) { + const u = targetCount <= 1 ? 0 : i / (targetCount - 1); + let x, y, px, py; + if (previous) { + const pos = u * (previous.length - 1); + const j = Math.max(0, Math.min(previous.length - 2, Math.floor(pos))); + const f = pos - j; + const p1 = previous[j], p2 = previous[j + 1]; + x = (Number(p1.x) || 0) + ((Number(p2.x) || 0) - (Number(p1.x) || 0)) * f; + y = (Number(p1.y) || 0) + ((Number(p2.y) || 0) - (Number(p1.y) || 0)) * f; + px = (Number(p1.px) || x) + ((Number(p2.px) || (Number(p2.x) || x)) - (Number(p1.px) || (Number(p1.x) || x))) * f; + py = (Number(p1.py) || y) + ((Number(p2.py) || (Number(p2.y) || y)) - (Number(p1.py) || (Number(p1.y) || y))) * f; + } else { + const sag = Math.sin(u * Math.PI) * Math.max(0, Math.min(48, (length - distXY(a.x, a.y, b.x, b.y)) * 0.35 + length * 0.035)); + x = a.x + (b.x - a.x) * u; + y = a.y + (b.y - a.y) * u + sag; + px = x; py = y; + } + parts(item).push({ x, y, px, py }); + } + item._ropeLodTargetCount = targetCount; + } + const ps = parts(item); + if (ps.length) { + ps[0].x = a.x; ps[0].y = a.y; ps[0].px = a.x; ps[0].py = a.y; + const last = ps[ps.length - 1]; + last.x = b.x; last.y = b.y; last.px = b.x; last.py = b.y; + } + return ps; + } + + function rectLocalPointForConstraint(r, x, y) { + const dx = x - (r.cx ?? 0); + const dy = y - (r.cy ?? 0); + const c = Number.isFinite(r.cos) ? r.cos : Math.cos(r.angle || 0); + const s = Number.isFinite(r.sin) ? r.sin : Math.sin(r.angle || 0); + return { x: dx * c + dy * s, y: -dx * s + dy * c }; + } + + function rectWorldNormalForConstraint(r, nx, ny) { + const c = Number.isFinite(r.cos) ? r.cos : Math.cos(r.angle || 0); + const s = Number.isFinite(r.sin) ? r.sin : Math.sin(r.angle || 0); + return { x: nx * c - ny * s, y: nx * s + ny * c }; + } + + function pushRopeParticleOutOfRect(p, rect, radius) { + if (!p || !rect) return false; + let nx = 0, ny = 0, overlap = 0; + if (rect.oriented) { + const local = rectLocalPointForConstraint(rect, p.x, p.y); + const clx = clamp(local.x, -(rect.halfW || 0), rect.halfW || 0); + const cly = clamp(local.y, -(rect.halfH || 0), rect.halfH || 0); + let dx = local.x - clx; + let dy = local.y - cly; + let d = Math.hypot(dx, dy); + if (d >= radius) return false; + if (d < 0.001) { + const left = Math.abs(local.x + (rect.halfW || 0)); + const right = Math.abs((rect.halfW || 0) - local.x); + const top = Math.abs(local.y + (rect.halfH || 0)); + const bottom = Math.abs((rect.halfH || 0) - local.y); + const m = Math.min(left, right, top, bottom); + if (m === left) { dx = -1; dy = 0; d = 1; } + else if (m === right) { dx = 1; dy = 0; d = 1; } + else if (m === top) { dx = 0; dy = -1; d = 1; } + else { dx = 0; dy = 1; d = 1; } + } + const n = rectWorldNormalForConstraint(rect, dx / d, dy / d); + nx = n.x; ny = n.y; overlap = radius - d; + } else { + const cx = clamp(p.x, rect.left, rect.right); + const cy = clamp(p.y, rect.top, rect.bottom); + let dx = p.x - cx; + let dy = p.y - cy; + let d = Math.hypot(dx, dy); + if (d >= radius) return false; + if (d < 0.001) { dx = 0; dy = -1; d = 1; } + nx = dx / d; ny = dy / d; overlap = radius - d; + } + const push = Math.min(Math.max(0, overlap) + 0.35, Math.max(4, radius * 0.9)); + p.x += nx * push; + p.y += ny * push; + // Do not let collision projection become a full Verlet velocity on the next frame. + if (Number.isFinite(Number(p.px))) p.px += nx * push * 0.72; + if (Number.isFinite(Number(p.py))) p.py += ny * push * 0.72; + return push > 0.001; + } + + function pushRopeSegmentOutOfRect(p1, p2, rect, radius) { + if (!p1 || !p2 || !rect) return false; + const samples = [ + { t: 0.25, p: { x: p1.x + (p2.x - p1.x) * 0.25, y: p1.y + (p2.y - p1.y) * 0.25 } }, + { t: 0.50, p: { x: p1.x + (p2.x - p1.x) * 0.50, y: p1.y + (p2.y - p1.y) * 0.50 } }, + { t: 0.75, p: { x: p1.x + (p2.x - p1.x) * 0.75, y: p1.y + (p2.y - p1.y) * 0.75 } }, + ]; + let changed = false; + for (const sample of samples) { + const beforeX = sample.p.x; + const beforeY = sample.p.y; + if (!pushRopeParticleOutOfRect(sample.p, rect, radius)) continue; + const dx = sample.p.x - beforeX; + const dy = sample.p.y - beforeY; + const w1 = 1 - sample.t; + const w2 = sample.t; + p1.x += dx * w1; + p1.y += dy * w1; + p2.x += dx * w2; + p2.y += dy * w2; + if (Number.isFinite(Number(p1.px))) p1.px += dx * w1 * 0.70; + if (Number.isFinite(Number(p1.py))) p1.py += dy * w1 * 0.70; + if (Number.isFinite(Number(p2.px))) p2.px += dx * w2 * 0.70; + if (Number.isFinite(Number(p2.py))) p2.py += dy * w2 * 0.70; + changed = true; + } + return changed; + } + + function resolveRigidLinkObstacleContacts(item, a, b, dt, worldRef) { + if (!item || item.type !== "rod" || !a || !b || !worldRef) return false; + const cx = (a.x + b.x) * 0.5; + const cy = (a.y + b.y) * 0.5; + const queryRadius = Math.max(44, distXY(a.x, a.y, b.x, b.y) * 0.5 + 48); + const endpointIds = ropeEndpointObjectIds(item); + const rects = worldRef.nearbySolidObstacleRects?.(cx, cy, queryRadius, { maxChecks: 18 }) || []; + if (!rects.length) return false; + let changed = false; + const samples = [0.25, 0.50, 0.75]; + for (const rect of rects) { + if (rect?.item?.id && endpointIds.has(rect.item.id)) continue; + for (const t of samples) { + const p = { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t }; + const beforeX = p.x, beforeY = p.y; + if (!pushRopeParticleOutOfRect(p, rect, 7.0)) continue; + const dx = p.x - beforeX; + const dy = p.y - beforeY; + changed = moveEndpointObject(a.obj, dx * (1 - t), dy * (1 - t), ep(item, 0), dt, 0.78, 0, "rod") || changed; + changed = moveEndpointObject(b.obj, dx * t, dy * t, ep(item, 1), dt, 0.78, 0, "rod") || changed; + } + } + return changed; + } + + function ropeEndpointObjectIds(item) { + return { + a: ep(item, 0)?.kind === "item" ? ep(item, 0).id : null, + b: ep(item, 1)?.kind === "item" ? ep(item, 1).id : null, + has(id) { return id != null && (id === this.a || id === this.b); }, + }; + } + + function solveRopeParticleChain(item, a, b, dt, length, worldRef) { + const lod = ropeLodSettings(worldRef, length); + const ps = ensureRopeParticles(item, a, b, length, worldRef); + if (!ps || ps.length < 2) return false; + const step = Math.max(0.008, Math.min(0.05, Number(dt || 0.016) || 0.016)); + const rest = Math.max(4, length / (ps.length - 1)); + const maxParticleStep = Math.max(10, Math.min(28, rest * 0.62)); + let changed = false; + for (let i = 1; i < ps.length - 1; i += 1) { + const p = ps[i]; + const ox = p.x, oy = p.y; + let vx = (p.x - (Number.isFinite(Number(p.px)) ? p.px : p.x)) * Math.pow(0.975, step * 60); + let vy = (p.y - (Number.isFinite(Number(p.py)) ? p.py : p.y)) * Math.pow(0.975, step * 60) + 120 * step * step; + const vLen = Math.hypot(vx, vy); + if (vLen > maxParticleStep) { + const k = maxParticleStep / Math.max(0.001, vLen); + vx *= k; + vy *= k; + } + p.px = p.x; p.py = p.y; + p.x += vx; p.y += vy; + changed = changed || Math.hypot(p.x - ox, p.y - oy) > 0.01; + } + const endpointIds = ropeEndpointObjectIds(item); + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const p of ps) { minX = Math.min(minX, p.x); minY = Math.min(minY, p.y); maxX = Math.max(maxX, p.x); maxY = Math.max(maxY, p.y); } + const ropeCx = (minX + maxX) * 0.5; + const ropeCy = (minY + maxY) * 0.5; + const ropeQueryRadius = Math.max(48, Math.hypot(maxX - minX, maxY - minY) * 0.5 + 72); + const tarinaiCandidates = worldRef?.nearbyTarinai?.(ropeCx, ropeCy, ropeQueryRadius, true) || worldRef?.tarinai || []; + let ropeObstacleRects = null; + const getRopeObstacleRects = () => { + if (ropeObstacleRects) return ropeObstacleRects; + if (!worldRef?.nearbySolidObstacleRects) return (ropeObstacleRects = []); + const maxChecks = lod.tier === "low" ? 18 : (lod.tier === "mid" ? 28 : 38); + ropeObstacleRects = worldRef.nearbySolidObstacleRects(ropeCx, ropeCy, ropeQueryRadius + 64, { maxChecks }) || []; + return ropeObstacleRects; + }; + const collideParticles = () => { + let c = false; + for (let i = 1; i < ps.length - 1; i += lod.particleStride) { + const p = ps[i]; + for (const t of tarinaiCandidates) { + if (!t || t.dead || worldRef?.isTarinaiHiddenInNestBox?.(t)) continue; + const minD = Math.max(8, (t.radius || 20) * 0.72 + 4.8); + const dx = p.x - t.x, dy = p.y - t.y; + let d = Math.hypot(dx, dy); + if (d >= minD) continue; + if (d < 0.001) { d = 1; } + const nx = dx / d || 0, ny = dy / d || -1; + const push = Math.min(minD - d + 0.4, Math.max(6, minD * 0.56)); + p.x += nx * push * 0.82; + p.y += ny * push * 0.82; + t.x -= nx * push * 0.10; + t.y -= ny * push * 0.10; + t.vx = clamp((t.vx || 0) - nx * 16, -160, 160); + t.vy = clamp((t.vy || 0) - ny * 16, -160, 160); + t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.08); + c = true; + } + for (const rect of getRopeObstacleRects()) { + if (rect?.item?.id && endpointIds.has(rect.item.id)) continue; + const radius = rect?.mechanical ? 8.2 : 6.2; + if (!rectNearPointAabb(rect, p.x, p.y, radius + 5.5)) continue; + c = pushRopeParticleOutOfRect(p, rect, radius) || c; + } + } + return c; + }; + for (let iter = 0; iter < lod.iterations; iter += 1) { + ps[0].x = a.x; ps[0].y = a.y; + ps[ps.length - 1].x = b.x; ps[ps.length - 1].y = b.y; + const shouldCollide = lod.tier === "low" ? iter === 1 : (lod.tier === "mid" ? iter === 2 : (iter === 2 || iter === lod.iterations - 1)); + if (shouldCollide) changed = collideParticles() || changed; + for (let i = 0; i < ps.length - 1; i += 1) { + const p1 = ps[i], p2 = ps[i + 1]; + const dx = p2.x - p1.x, dy = p2.y - p1.y; + const d = Math.max(0.001, Math.hypot(dx, dy)); + const diff = (d - rest) / d; + const corrX = dx * diff; + const corrY = dy * diff; + if (i === 0) { p2.x -= corrX; p2.y -= corrY; } + else if (i + 1 === ps.length - 1) { p1.x += corrX; p1.y += corrY; } + else { p1.x += corrX * 0.5; p1.y += corrY * 0.5; p2.x -= corrX * 0.5; p2.y -= corrY * 0.5; } + } + if (shouldCollide && worldRef?.nearbySolidObstacleRects) { + for (let i = 0; i < ps.length - 1; i += Math.max(1, lod.particleStride)) { + const p1 = ps[i], p2 = ps[i + 1]; + if (!p1 || !p2) continue; + const mx = (p1.x + p2.x) * 0.5; + const my = (p1.y + p2.y) * 0.5; + for (const rect of getRopeObstacleRects()) { + if (rect?.item?.id && endpointIds.has(rect.item.id)) continue; + const pad = rect?.mechanical ? 8.8 : 7.0; + if (!rectNearSegmentAabb(rect, p1.x, p1.y, p2.x, p2.y, pad + 4.0)) continue; + changed = pushRopeSegmentOutOfRect(p1, p2, rect, rect?.mechanical ? 7.6 : 6.0) || changed; + } + } + } + } + ps[0].x = a.x; ps[0].y = a.y; + ps[ps.length - 1].x = b.x; ps[ps.length - 1].y = b.y; + const mid = ps[Math.floor(ps.length * 0.5)] || ps[0]; + lset(item, "midX", mid.x); + lset(item, "midY", mid.y); + lset(item, "midVx", clamp((mid.x - (Number(item._ropePrevMidX) || mid.x)) / step, -260, 260)); + lset(item, "midVy", clamp((mid.y - (Number(item._ropePrevMidY) || mid.y)) / step, -260, 260)); + item._ropePrevMidX = mid.x; + item._ropePrevMidY = mid.y; + minX = Infinity; minY = Infinity; maxX = -Infinity; maxY = -Infinity; + for (const p of ps) { minX = Math.min(minX, p.x); minY = Math.min(minY, p.y); maxX = Math.max(maxX, p.x); maxY = Math.max(maxY, p.y); } + item.x = (minX + maxX) * 0.5; + item.y = (minY + maxY) * 0.5; + item.r = Math.max(18, Math.hypot(maxX - minX, maxY - minY) * 0.5 + 8); + return changed; + } + function updateFlexibleLink(item, dt, worldRef) { if (!item || item.dead || item.type !== "rope") return false; item.amount = 999; item.world = worldRef || item.world || null; - const a = endpointWorld(item.linkA, worldRef); - const b = endpointWorld(item.linkB, worldRef); + const a = endpointWorld(ep(item, 0), worldRef); + const b = endpointWorld(ep(item, 1), worldRef); if (!a || !b || !a.obj || !b.obj) { item.amount = 0; if (worldRef) worldRef.drawListDirty = true; return true; } - const length = primeLinkState(item, a, b); - let changed = updateFlexibleMidParticle(item, a, b, dt, length); - changed = applyDistanceConstraint(item, a, b, dt, length, "rope") || changed; + const beforeSpatialX = Number(item.x || 0) || 0; + const beforeSpatialY = Number(item.y || 0) || 0; + const beforeSpatialR = Number(item.r || item.radius || 0) || 0; + let length = primeLinkState(item, a, b); + let changed = applyDistanceConstraint(item, a, b, dt, length, "rope"); + const aa = changed ? endpointWorld(ep(item, 0), worldRef) : a; + const bb = changed ? endpointWorld(ep(item, 1), worldRef) : b; + if (!aa || !bb || !aa.obj || !bb.obj) { + item.amount = 0; + if (worldRef) worldRef.drawListDirty = true; + return true; + } + length = primeLinkState(item, aa, bb); + changed = solveRopeParticleChain(item, aa, bb, dt, length, worldRef) || changed; if (changed && worldRef) { worldRef.drawListDirty = true; - worldRef.markSpatialDirty?.("rope-tension"); + const movedForSpatial = Math.hypot((Number(item.x || 0) || 0) - beforeSpatialX, (Number(item.y || 0) || 0) - beforeSpatialY) > 0.38 + || Math.abs((Number(item.r || item.radius || 0) || 0) - beforeSpatialR) > 0.38; + if (movedForSpatial) markConstraintSpatialDirty(worldRef, item, "rope-tension", 0.38, 0.085); + else if (worldRef.constraintDirtyStatsThisFrame) worldRef.constraintDirtyStatsThisFrame.coalesced = (worldRef.constraintDirtyStatsThisFrame.coalesced || 0) + 1; } return changed; } @@ -355,28 +888,192 @@ if (!item || item.dead || item.type !== "rod") return false; item.amount = 999; item.world = worldRef || item.world || null; - const a = endpointWorld(item.linkA, worldRef); - const b = endpointWorld(item.linkB, worldRef); + const a = endpointWorld(ep(item, 0), worldRef); + const b = endpointWorld(ep(item, 1), worldRef); if (!a || !b || !a.obj || !b.obj) { item.amount = 0; if (worldRef) worldRef.drawListDirty = true; return true; } + const beforeSpatialX = Number(item.x || 0) || 0; + const beforeSpatialY = Number(item.y || 0) || 0; + const beforeSpatialR = Number(item.r || item.radius || 0) || 0; const length = primeLinkState(item, a, b); - const changed = applyDistanceConstraint(item, a, b, dt, length, "rod"); + let changed = applyDistanceConstraint(item, a, b, dt, length, "rod"); + const aa = endpointWorld(ep(item, 0), worldRef) || a; + const bb = endpointWorld(ep(item, 1), worldRef) || b; + changed = resolveRigidLinkObstacleContacts(item, aa, bb, dt, worldRef) || changed; + 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)) { + item._rodStretchCorrectionCount = (item._rodStretchCorrectionCount || 0) + 1; + item._rodLastStretch = stretch; + changed = applyDistanceConstraint(item, ca, cb, Math.min(0.033, dt || 0.016), length, "rod") || changed; + } else { + item._rodLastStretch = stretch; + } + const fa = endpointWorld(ep(item, 0), worldRef) || ca; + const fb = endpointWorld(ep(item, 1), worldRef) || cb; + if (fa && fb) { + item.x = (fa.x + fb.x) * 0.5; + item.y = (fa.y + fb.y) * 0.5; + item.r = Math.max(18, length * 0.5); + ep(item, 0).x = fa.x; ep(item, 0).y = fa.y; + ep(item, 1).x = fb.x; ep(item, 1).y = fb.y; + } if (changed && worldRef) { worldRef.drawListDirty = true; - worldRef.markSpatialDirty?.("rod-constraint"); + const movedForSpatial = Math.hypot((Number(item.x || 0) || 0) - beforeSpatialX, (Number(item.y || 0) || 0) - beforeSpatialY) > 0.30 + || Math.abs((Number(item.r || item.radius || 0) || 0) - beforeSpatialR) > 0.30; + if (movedForSpatial) markConstraintSpatialDirty(worldRef, item, "rod-constraint", 0.30, 0.070); + else if (worldRef.constraintDirtyStatsThisFrame) worldRef.constraintDirtyStatsThisFrame.coalesced = (worldRef.constraintDirtyStatsThisFrame.coalesced || 0) + 1; } return changed; } function updateLink(item, dt, worldRef) { - if (item?.type === "rope") return updateFlexibleLink(item, dt, worldRef); - if (item?.type === "rod") return updateRigidLink(item, dt, worldRef); + if (!item || item.dead || (item.type !== "rope" && item.type !== "rod")) return false; + item.world = worldRef || item.world || null; + applyConstraintState(item); + let changed = false; + if (item.type === "rope") changed = updateFlexibleLink(item, dt, worldRef); + else if (item.type === "rod") changed = updateRigidLink(item, dt, worldRef); + commitConstraint(item); + return changed; + } + + + function endpointRuntimeStamp(endpoint, worldRef) { + const obj = resolveEndpointObject(endpoint, worldRef); + if (!obj || obj.dead) return { stamp: "missing", missing: true, obj: null }; + const q = (v, scale = 10) => Math.round((Number(v) || 0) * scale); + let stamp = `${endpoint?.kind || "item"}:${obj.id || "?"}:${obj.type || (obj.name ? "tarinai" : "entity")}:${q(obj.x)}:${q(obj.y)}`; + if (obj.radius && obj.name) stamp += `:${q(obj.vx, 4)}:${q(obj.vy, 4)}:${obj.state || ""}`; + else if (global.TarinaiMechanicalSystem?.isMechanicalType?.(obj.type)) { + const a = typeof itemAngleFor === "function" ? itemAngleFor(obj) : (Number(obj.angle) || 0); + const shape = Number(obj._mechanicalShapeVersion || obj._segmentsVersion || 0) || 0; + stamp += `:${q(a, 1000)}:${shape}:${ps(obj, "motorOn", true) !== false ? 1 : 0}:${ps(obj, "railOn", true) !== false ? 1 : 0}:${q(obj.vx, 4)}:${q(obj.vy, 4)}:${q(ps(obj, "spin", 0), 1000)}:${q(ps(obj, "slideSpeed", 0), 4)}`; + } else stamp += `:${q(obj.vx, 4)}:${q(obj.vy, 4)}:${Number(obj.amount || 0) > 0 ? 1 : 0}`; + if (Number.isFinite(Number(endpoint?.localX)) || Number.isFinite(Number(endpoint?.localY))) stamp += `:l${q(endpoint.localX)}:${q(endpoint.localY)}`; + if (Number.isFinite(Number(endpoint?.attachT))) stamp += `:t${q(endpoint.attachT, 1000)}`; + return { stamp, missing: false, obj }; + } + + function linkRuntimeStamp(item, worldRef) { + const a = endpointRuntimeStamp(ep(item, 0), worldRef); + const b = endpointRuntimeStamp(ep(item, 1), worldRef); + const q = (v, scale = 10) => Math.round((Number(v) || 0) * scale); + return { + stamp: `${item?.type || "link"}:${q(ls(item, "len", 80))}|${a.stamp}|${b.stamp}`, + missing: a.missing || b.missing, + a: a.obj, + b: b.obj, + }; + } + + function linkNeedsUpdate(item, worldRef) { + if (!item || item.dead) return false; + const now = Number(worldRef?.time || 0) || 0; + const state = linkRuntimeStamp(item, worldRef); + item._linkPendingStamp = state.stamp; + if (state.missing) return true; + if (!item._linkLastStamp || item._linkLastStamp !== state.stamp) { + lset(item, "awakeUntil", Math.max(ls(item, "awakeUntil", 0), now + 0.08)); + return true; + } + if (ls(item, "awakeUntil", 0) > now) return true; + if (item.type === "rope") { + if (Math.hypot(ls(item, "midVx", 0), ls(item, "midVy", 0)) > 4.0) return true; + if ((Number(item._nextRopeNearbyProbeAt || 0) || 0) <= now) { + item._nextRopeNearbyProbeAt = now + 0.12; + const radius = Math.max(42, Number(item.r || 0) || Math.max(24, Number(ls(item, "len", 80) || 60) * 0.5)) + 50; + const near = worldRef?.nearbyTarinai?.(item.x || 0, item.y || 0, radius, true) || []; + for (const t of near) { + if (!t || t.dead || worldRef?.isTarinaiHiddenInNestBox?.(t)) continue; + lset(item, "awakeUntil", now + 0.18); + return true; + } + } + } + const interval = item.type === "rope" ? 0.32 : 0.55; + if ((Number(item._nextPassiveLinkUpdateAt || 0) || 0) <= now) { + item._nextPassiveLinkUpdateAt = now + interval; + return true; + } return false; } + function noteLinkUpdated(item) { + if (!item) return; + if (item._linkPendingStamp) item._linkLastStamp = item._linkPendingStamp; + item._linkPendingStamp = ""; + } + + function linkScheduleScore(item, worldRef) { + const now = Number(worldRef?.time || 0) || 0; + let score = item?.type === "rod" ? 2.0 : 1.0; + if (ls(item, "awakeUntil", 0) > now) score += 4.0; + if (item?._linkLastStamp && item._linkPendingStamp && item._linkLastStamp !== item._linkPendingStamp) score += 2.5; + score += Math.min(3, Math.hypot(ls(item, "midVx", 0), ls(item, "midVy", 0)) / 40); + return score; + } + + function updateWorld(worldRef, dt = 0.016, opts = {}) { + if (!worldRef?.itemsOfType) return 0; + const profiler = global.TarinaiPerf; + const end = profiler?.begin?.("update.constraints") || null; + try { + worldRef.ensureItemBuckets?.("constraint-world"); + const maxLinks = Math.max(8, Number(opts.maxLinks || 160) || 160); + const links = []; + for (const item of worldRef.itemsOfType("rope") || []) if (item && !item.dead) links.push(item); + for (const item of worldRef.itemsOfType("rod") || []) if (item && !item.dead) links.push(item); + if (!links.length) { + worldRef._constraintWorldStats = { visited: 0, ran: 0, skipped: 0, changed: 0, maxLinks, totalLinks: 0 }; + return 0; + } + const cursor = Math.max(0, Math.min(links.length - 1, Number(worldRef._constraintBudgetCursor || 0) || 0)); + const ordered = links.slice(cursor).concat(links.slice(0, cursor)); + let ran = 0; + let visited = 0; + let skipped = 0; + let changed = 0; + let rodStretchMax = 0; + let rodCorrections = 0; + const pending = []; + for (const item of ordered) { + ensureConstraint(item, worldRef, { syncFromLegacy: false }); + applyConstraintState(item); + visited += 1; + if (!linkNeedsUpdate(item, worldRef)) { + skipped += 1; + continue; + } + pending.push({ item, score: linkScheduleScore(item, worldRef) }); + } + pending.sort((a, b) => (b.score - a.score) || ((a.item.id || 0) - (b.item.id || 0))); + for (const entry of pending) { + if (ran >= maxLinks) break; + const beforeRodCorrections = Number(entry.item?._rodStretchCorrectionCount || 0) || 0; + const didChange = updateLink(entry.item, dt, worldRef); + if (entry.item?.type === "rod") { + rodStretchMax = Math.max(rodStretchMax, Number(entry.item._rodLastStretch || 0) || 0); + rodCorrections += Math.max(0, (Number(entry.item._rodStretchCorrectionCount || 0) || 0) - beforeRodCorrections); + } + noteLinkUpdated(entry.item); + ran += 1; + if (didChange) changed += 1; + } + for (let i = ran; i < pending.length; i += 1) pending[i].item._linkPendingStamp = ""; + worldRef._constraintBudgetCursor = links.length ? (cursor + Math.max(1, ran || Math.min(maxLinks, links.length))) % links.length : 0; + worldRef._constraintWorldStats = { visited, ran, skipped, changed, maxLinks, totalLinks: links.length, pending: pending.length, cursor: worldRef._constraintBudgetCursor, rodStretchMax, rodCorrections }; + return ran; + } finally { + if (end) end(); + } + } + function pointSegmentDistance(px, py, ax, ay, bx, by) { const vx = bx - ax; const vy = by - ay; @@ -395,6 +1092,7 @@ updateFlexibleLink, updateRigidLink, updateLink, + updateWorld, pointSegmentDistance, }); diff --git a/js/debug_tools.js b/js/debug_tools.js index fd39c72..5bd1f95 100644 --- a/js/debug_tools.js +++ b/js/debug_tools.js @@ -4,47 +4,174 @@ const params = new URLSearchParams(location.search || ""); const enabled = params.get("debug") === "1" || params.get("debug") === "true"; if (!enabled) return; - const panel = document.createElement("div"); - panel.id = "debugOverlay"; - panel.style.cssText = [ - "position:fixed", "left:8px", "top:76px", "z-index:99999", "min-width:230px", "max-width:360px", - "font:11px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace", "color:#f7fbff", - "background:rgba(18,24,30,.78)", "border:1px solid rgba(255,255,255,.18)", "border-radius:10px", - "padding:8px 10px", "pointer-events:none", "white-space:pre-wrap", "box-shadow:0 8px 24px rgba(0,0,0,.22)" + + const host = document.createElement("div"); + host.id = "debugOverlay"; + host.style.cssText = [ + "position:fixed", "left:8px", "top:76px", "z-index:99999", "width:min(620px, calc(100vw - 16px))", "max-height:calc(100vh - 92px)", + "overflow:hidden", "font:11px/1.42 ui-monospace,SFMono-Regular,Menlo,monospace", "color:#f7fbff", + "background:rgba(18,24,30,.86)", "border:1px solid rgba(255,255,255,.18)", "border-radius:10px", + "pointer-events:auto", "box-shadow:0 8px 24px rgba(0,0,0,.22)" ].join(";"); - document.addEventListener("DOMContentLoaded", () => document.body.appendChild(panel)); + + const bar = document.createElement("div"); + bar.style.cssText = [ + "display:flex", "gap:6px", "align-items:center", "padding:6px 8px", "border-bottom:1px solid rgba(255,255,255,.14)", + "background:rgba(255,255,255,.06)", "user-select:none", "-webkit-user-select:none" + ].join(";"); + const title = document.createElement("span"); + title.style.cssText = "flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;"; + const copyBtn = document.createElement("button"); + copyBtn.type = "button"; + copyBtn.textContent = "Copy"; + const minBtn = document.createElement("button"); + minBtn.type = "button"; + minBtn.textContent = "Min"; + for (const btn of [copyBtn, minBtn]) { + btn.style.cssText = [ + "font:11px/1.2 ui-monospace,SFMono-Regular,Menlo,monospace", "color:#f7fbff", "background:rgba(255,255,255,.12)", + "border:1px solid rgba(255,255,255,.22)", "border-radius:6px", "padding:3px 7px", "cursor:pointer" + ].join(";"); + } + bar.append(title, copyBtn, minBtn); + + const body = document.createElement("pre"); + body.style.cssText = [ + "margin:0", "padding:8px 10px", "max-height:calc(100vh - 128px)", "overflow:auto", "white-space:pre-wrap", + "user-select:text", "-webkit-user-select:text", "cursor:text" + ].join(";"); + body.tabIndex = 0; + host.append(bar, body); + document.addEventListener("DOMContentLoaded", () => document.body.appendChild(host)); + + let copiedMessageUntil = 0; + let minimized = false; + let lastText = ""; + + function selectBodyText() { + const range = document.createRange(); + range.selectNodeContents(body); + const selection = window.getSelection?.(); + if (selection) { + selection.removeAllRanges(); + selection.addRange(range); + } + } + + copyBtn.addEventListener("click", async (ev) => { + ev.preventDefault(); + const text = lastText || body.textContent || ""; + try { + await navigator.clipboard?.writeText?.(text); + copiedMessageUntil = Date.now() + 1400; + copyBtn.textContent = "Copied"; + } catch (_) { + selectBodyText(); + copiedMessageUntil = Date.now() + 1400; + copyBtn.textContent = "Select"; + } + }); + + minBtn.addEventListener("click", (ev) => { + ev.preventDefault(); + minimized = !minimized; + body.style.display = minimized ? "none" : "block"; + host.style.width = minimized ? "min(360px, calc(100vw - 16px))" : "min(620px, calc(100vw - 16px))"; + minBtn.textContent = minimized ? "Open" : "Min"; + }); + let lastEvents = []; let lastAudio = []; window.TarinaiEvents?.on("*", ev => { lastEvents.unshift(ev.type); if (lastEvents.length > 10) lastEvents.length = 10; }); window.TarinaiEvents?.on("audio:play", ev => { lastAudio.unshift(`${ev.detail?.id || "?"}:${ev.detail?.category || "?"}`); if (lastAudio.length > 8) lastAudio.length = 8; }); + + function fmtMs(v) { + const n = Number(v || 0); + return n >= 10 ? n.toFixed(1) : n.toFixed(2); + } + + function shortLabel(label) { + return String(label || "?") + .replace(/^update\.phase\./, "phase.") + .replace(/^update\./, "u.") + .replace(/^render\./, "r.") + .replace(/^frame\./, "f."); + } + + function topMap(map, limit = 4) { + return Object.entries(map || {}).sort((a, b) => b[1] - a[1]).slice(0, limit).map(([k, v]) => `${k}:${v}`).join(", ") || "-"; + } + + function perfLines(perf) { + const self = (perf.topSelf || perf.entries || []).filter(e => e.label !== "frame.total").slice(0, 10); + const incl = (perf.topInclusive || perf.entries || []).filter(e => e.label !== "frame.total").slice(0, 6); + const selfLines = self.map((e, i) => `${String(i + 1).padStart(2, " ")}. ${shortLabel(e.label).padEnd(28, " ")} self ${fmtMs(e.selfAvg)}ms total ${fmtMs(e.avg)}ms ${e.selfPct || 0}%`); + const inclLine = incl.map(e => `${shortLabel(e.label)}:${fmtMs(e.avg)}`).join(" "); + return [ + `heavy self avg`, + ...(selfLines.length ? selfLines : [" -"]), + `heavy inclusive ${inclLine || "-"}`, + ]; + } + + function diagnosticHints(diag, perf) { + const hints = []; + const entries = perf.topSelf || perf.entries || []; + const top = entries[0]; + if (top && top.selfAvg >= 4) hints.push(`hot:${shortLabel(top.label)} ${fmtMs(top.selfAvg)}ms self`); + if ((diag.spatial?.rebuildsThisFrame || 0) > 1) hints.push(`spatial rebuild/frame ${diag.spatial.rebuildsThisFrame}`); + if ((diag.creatures?.smooth || 0) > 0) hints.push(`smooth motion ${diag.creatures.smooth}`); + if ((diag.scheduler?.activeRealtime || 0) > 120) hints.push(`realtime items ${diag.scheduler.activeRealtime}`); + if ((diag.scheduler?.heap || 0) > 700) hints.push(`scheduled heap ${diag.scheduler.heap}`); + if ((diag.items?.bucketRebuildsTotal || 0) > 0 && diag.items?.bucketsDirty) hints.push("item buckets dirty"); + const m = diag.scheduler?.mechanicalStats || diag.items?.mechanicalStats || diag.scheduler?.physics?.mechanical || null; + if (m?.candidates > 250 || m?.pairs > 180) hints.push(`mechanical candidates ${m.candidates || "?"} pairs ${m.pairs || m.solved || "?"}`); + const c = diag.scheduler?.constraintStats || null; + if (c?.totalLinks > 80) hints.push(`constraints links ${c.totalLinks} ran ${c.ran}`); + return hints.join(" ") || "-"; + } + window.setInterval(() => { const w = window.world; const input = window.TarinaiInputMode?.snapshot?.() || {}; const diag = w?.lastRuntimeDiagnostics || w?.runtimeDiagnostics?.() || {}; const behaviorCounts = diag.behavior?.counts || {}; - const topBehaviors = Object.entries(behaviorCounts).sort((a, b) => b[1] - a[1]).slice(0, 5).map(([id, n]) => `${id}:${n}`).join(", "); + const topBehaviors = topMap(behaviorCounts, 5); const perf = diag.perf || window.TarinaiPerf?.snapshot?.() || {}; - const perfTop = (perf.entries || []).slice(0, 5).map(e => `${e.label}:${e.avg}ms`).join(", "); - panel.textContent = [ - `tarinai v${window.TARINAI_VERSION || "?"}`, - `fps ${window.__tarinaiFps ?? "?"}`, - `live ${diag.runtime?.liveTarinai ?? (w?.tarinai || []).length} dpr ${diag.runtime?.dpr || uiCache?.canvasDpr || "?"}`, - `items ${(w?.items || []).length} tarinai ${(w?.tarinai || []).length} ants ${(w?.ants || []).length}`, - `effects ${(w?.effects || []).length}`, - `terrain v${w?.terrainVersion ?? 0} spatial v${w?.spatialVersion ?? 0}`, - `spatial rebuild ${diag.spatial?.rebuildsThisFrame ?? 0}/frame total:${diag.spatial?.rebuildsTotal ?? 0} dirty:${diag.spatial?.dirtyMarksThisFrame ?? 0} reason:${diag.spatial?.dirtyReason || diag.spatial?.lastRebuildReason || "-"}`, - `drawList ${(w?.drawList || []).length} dirty:${Boolean(diag.render?.drawListDirty)} logs ${(w?.logs || []).length}`, - `item buckets:${diag.items?.bucketTypes ?? 0} dirty:${Boolean(diag.items?.bucketsDirty)} id-map:${diag.items?.idMapSize ?? 0} rebuilds:${diag.items?.bucketRebuildsTotal ?? 0}`, + const metrics = perf.metrics || {}; + const currentDpr = (typeof uiCache !== "undefined" && uiCache) ? uiCache.canvasDpr : "?"; + const creature = diag.creatures || {}; + const scheduler = diag.scheduler || {}; + const terrain = diag.terrain || {}; + const lines = [ + `tarinai v${window.TARINAI_VERSION || "?"} debug profiler:${perf.enabled ? "on" : "off"}${Date.now() < copiedMessageUntil ? " copied" : " copy:button/select"}`, + `fps ${window.__tarinaiFps ?? "?"} frame ${fmtMs(perf.frameAvg || 0)}ms quality ${perf.tier || "?"} dprScale:${perf.dprScale || 1}`, + `live ${diag.runtime?.liveTarinai ?? (w?.tarinai || []).length} items ${(w?.items || []).length} ants ${(w?.ants || []).length} effects ${(w?.effects || []).length}`, + `visible layered:${metrics["render.visibleLayered"] ?? "?"} back:${metrics["render.visibleBackItems"] ?? "?"} effects:${metrics["render.visibleEffects"] ?? "?"}`, + `creatures full:${creature.full ?? 0} realtime:${creature.realtime ?? 0} smooth:${creature.smooth ?? 0} skipped:${creature.skipped ?? 0} selected:${creature.selected ?? 0} urgent:${creature.urgent ?? 0} visible:${creature.visible ?? 0} sleepPhys:${creature.sleepPhysical ?? 0} passive:${creature.passivePhysical ?? 0}`, + `creature lanes ${topMap(creature.lanes, 6)} smoothStates ${topMap(creature.smoothStates, 4)}`, + `spatial rebuild ${diag.spatial?.rebuildsThisFrame ?? 0}/frame total:${diag.spatial?.rebuildsTotal ?? 0} partial:${diag.spatial?.partialRebuildsTotal ?? 0} dirty:${diag.spatial?.dirtyMarksThisFrame ?? 0} deferred:${diag.spatial?.deferredReads ?? 0} reason:${diag.spatial?.dirtyReason || diag.spatial?.lastRebuildReason || "-"}`, + `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 ?? "?"}`, + `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}`, - `scheduler heap:${diag.scheduler?.heap ?? 0} realtime:${diag.scheduler?.realtime ?? 0} ran:${diag.scheduler?.lastRan ?? 0} rebuilds:${diag.scheduler?.rebuilds ?? 0}`, - `perf ${perf.tier || "?"} dprScale:${perf.dprScale || 1} ${perfTop || "-"}`, + `hints ${diagnosticHints(diag, perf)}`, + ...perfLines(perf), `behavior forced:${diag.behavior?.forced ?? 0} locked:${diag.behavior?.locked ?? 0} top ${topBehaviors || "-"}`, - `dpr ${uiCache?.canvasDpr || "?"} cache ${window.TARINAI_APP?.cacheName || "?"}`, - `sw ${navigator.serviceWorker?.controller ? "controlled" : "uncontrolled"} soundPack ${window.TarinaiAudio?.soundPackVersion || "?"}`, - `input ${input.currentMode || "?"} touchFirst:${Boolean(input.touchFirst)} coarse:${Boolean(input.pointerCoarse)} hover:${Boolean(input.hasHover)} compact:${Boolean(input.isCompactViewport)} uaMobile:${Boolean(input.userAgentMobile)}`, + `terrain v${w?.terrainVersion ?? 0} spatial v${w?.spatialVersion ?? 0} drawList ${(w?.drawList || []).length} dirty:${Boolean(diag.render?.drawListDirty)}`, + `item buckets:${diag.items?.bucketTypes ?? 0} dirty:${Boolean(diag.items?.bucketsDirty)} id-map:${diag.items?.idMapSize ?? 0} bucketRebuilds:${diag.items?.bucketRebuildsTotal ?? 0}`, + `dpr ${currentDpr} cache ${window.TARINAI_APP?.cacheName || "?"} sw ${navigator.serviceWorker?.controller ? "controlled" : "uncontrolled"}`, + `input ${input.currentMode || "?"} touchFirst:${Boolean(input.touchFirst)} coarse:${Boolean(input.pointerCoarse)} hover:${Boolean(input.hasHover)} compact:${Boolean(input.isCompactViewport)}`, `audio ${lastAudio.join(", ") || window.TarinaiAudio?.lastPlayedId || "-"}`, - `effects ${window.TarinaiItemRegistry?.effect?.debugSnapshot?.().length || 0} registered`, `events ${lastEvents.join(", ")}`, - ].join("\n"); + ]; + lastText = lines.join("\n"); + title.textContent = minimized + ? `tarinai v${window.TARINAI_VERSION || "?"} fps ${window.__tarinaiFps ?? "?"} frame ${fmtMs(perf.frameAvg || 0)}ms` + : `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); })(); diff --git a/js/health.js b/js/health.js index 74724da..24bb899 100644 --- a/js/health.js +++ b/js/health.js @@ -11,6 +11,7 @@ const HEALTH = (() => { poke: "\u3064\u3064\u304b\u308c\u3059\u304e", firecracker: "\u7206\u7af9", genkotsu: "\u3052\u3093\u3053\u3064", + poisonBlock: "毒ブロック", accident: "\u4e8b\u6545", collision: "\u885d\u7a81", outOfBounds: "\u4ed5\u69d8\u306b\u3088\u308a\u753b\u9762\u5916\u3067\u524a\u9664", @@ -31,6 +32,7 @@ const HEALTH = (() => { if (text.includes(WEAKENED_SUFFIX)) return text.replace(/\u3067\u8870\u5f31.*$/, ""); 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; if (/\u5de8\u5927\u85ac|giant/.test(text)) return CAUSES.giantDrug; if (/\u77ee\u5c0f\u85ac|dwarf/.test(text)) return CAUSES.dwarfDrug; if (/\u7206\u7af9|firecracker|explosion/.test(text)) return CAUSES.firecracker; diff --git a/js/item_dynamic_pin_system.js b/js/item_dynamic_pin_system.js index f8ea8b1..ba96071 100644 --- a/js/item_dynamic_pin_system.js +++ b/js/item_dynamic_pin_system.js @@ -1,4 +1,34 @@ "use strict"; +(function ensureDeterministicHelpers(global) { + if (typeof global.deterministicChance === "function" && typeof global.deterministicRange === "function") return; + const clamp01 = v => Math.max(0, Math.min(1, Number(v) || 0)); + const keyPart = value => { + if (value == null) return "null"; + if (typeof value === "number") return Number.isFinite(value) ? value.toFixed(3) : "nan"; + if (typeof value === "string" || typeof value === "boolean") return String(value); + if (typeof value === "object") { + const id = value.familyKey || value.id || value.seed || value.liveToken || value.name; + if (id) return `${value.type || value.constructor?.name || "entity"}:${id}`; + const x = Number.isFinite(value.x) ? value.x.toFixed(1) : "x"; + const y = Number.isFinite(value.y) ? value.y.toFixed(1) : "y"; + return `${value.type || value.constructor?.name || "entity"}@${x},${y}`; + } + return String(value); + }; + const hashUnit = (seed, salt = "") => { + const str = `${seed}:${salt}`; + let h = 2166136261; + for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); } + return ((h >>> 0) % 100000) / 100000; + }; + global.deterministicFrame = global.deterministicFrame || ((worldRef = null, hz = 60) => Math.floor((Number(worldRef?.time || 0) || 0) * Math.max(1, Number(hz) || 60) + 1e-6)); + global.deterministicUnit = global.deterministicUnit || ((worldRef = null, salt = "", ...parts) => hashUnit(String(worldRef?.worldSeed || "tarinai-world"), [salt, global.deterministicFrame(worldRef, 60), Number(worldRef?._deterministicEpoch || 0) || 0, ...parts.map(keyPart)].join("|"))); + global.deterministicRange = global.deterministicRange || ((worldRef = null, salt = "", min = 0, max = 1, ...parts) => (Number(min) || 0) + global.deterministicUnit(worldRef, salt, ...parts) * ((Number(max) || 0) - (Number(min) || 0))); + global.deterministicSigned = global.deterministicSigned || ((worldRef = null, salt = "", ...parts) => global.deterministicUnit(worldRef, salt, ...parts) < 0.5 ? -1 : 1); + global.deterministicChance = global.deterministicChance || ((worldRef = null, salt = "", chance = 0, ...parts) => { const p = clamp01(chance); return p >= 1 || (p > 0 && global.deterministicUnit(worldRef, salt, ...parts) < p); }); + global.deterministicAngle = global.deterministicAngle || ((worldRef = null, salt = "", ...parts) => global.deterministicRange(worldRef, salt, 0, Math.PI * 2, ...parts)); +})(typeof window !== "undefined" ? window : globalThis); + // Layer: entity-runtime/items/dynamic-system // Owns pushpin/oshibyo item motion, attachment, lodged damage, and detach @@ -85,7 +115,7 @@ } else { t.hurtTimer = Math.max(t.hurtTimer || 0, 1.2); t.fearTimer = Math.max(t.fearTimer || 0, 1.3); - t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination(item) : { x: t.x + rand(-80, 80), y: t.y + rand(-80, 80) }, reason: "\u753b\u92f2\u304c\u523a\u3055\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", wake: true }); + t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination(item) : { x: t.x + deterministicRange(worldRef, "pin-panic-target-x", -80, 80, item, t), y: t.y + deterministicRange(worldRef, "pin-panic-target-y", -80, 80, item, t) }, reason: "\u753b\u92f2\u304c\u523a\u3055\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", wake: true }); if (t.addStress) t.addStress(behavior?.stressOnAttach ?? 18, { threshold: 8 }); } if (t.damage && (behavior?.damageOnAttach ?? 6) > 0) t.damage(behavior?.damageOnAttach ?? 6, "\u753b\u92f2"); @@ -127,12 +157,12 @@ if (t.damage && (behavior?.damagePerTick ?? 1.4) > 0) t.damage(behavior?.damagePerTick ?? 1.4, "\u753b\u92f2"); t.hurtTimer = Math.max(t.hurtTimer || 0, 0.50); if (t.addStress) t.addStress(behavior?.stressPerTick ?? 2.6, { threshold: 8, duration: 3.4 }); - if (Math.random() < 0.22) t.bubble?.("!!", 0.6, "rgba(168,72,72,0.82)"); + if (deterministicChance(worldRef, "pin-pain-bubble", 0.22, item, t)) t.bubble?.("!!", 0.6, "rgba(168,72,72,0.82)"); } t.sleeping = false; if (t.enterPanic) t.enterPanic({ threat: item, reason: "\u753b\u92f2\u304c\u523a\u3055\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", fear: 0.65, surpriseTimer: 0.22, awakeLockTimer: 2.4, cause: "pushpin_lodged" }); else { - t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination(item) : { x: t.x + rand(-80, 80), y: t.y + rand(-80, 80) }, reason: "\u753b\u92f2\u304c\u523a\u3055\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", wake: true }); + t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination(item) : { x: t.x + deterministicRange(worldRef, "pin-panic-target-x", -80, 80, item, t), y: t.y + deterministicRange(worldRef, "pin-panic-target-y", -80, 80, item, t) }, reason: "\u753b\u92f2\u304c\u523a\u3055\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", wake: true }); t.fearTimer = Math.max(t.fearTimer || 0, 0.65); t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.22); t.awakeLockTimer = Math.max(t.awakeLockTimer || 0, 2.4); @@ -140,7 +170,7 @@ item.pinFallCheckTimer = (item.pinFallCheckTimer || 0) + dt; while (item.pinFallCheckTimer >= 1.0) { item.pinFallCheckTimer -= 1.0; - if (Math.random() < 0.10) { + if (deterministicChance(worldRef, "pin-fall-out", 0.10, item, t)) { detach(item, worldRef, "fall"); return; } @@ -174,13 +204,13 @@ item.vy = 0; item.spinVelocity = 0; } else { - item.vx = rand(-24, 24); - item.vy = rand(-10, 18); - item.spinVelocity = rand(-1.6, 1.6); - item.spin += rand(-0.25, 0.25); + item.vx = deterministicRange(worldRef, "pin-detach-vx", -24, 24, item, t, reason); + item.vy = deterministicRange(worldRef, "pin-detach-vy", -10, 18, item, t, reason); + item.spinVelocity = deterministicRange(worldRef, "pin-detach-spin-velocity", -1.6, 1.6, item, t, reason); + item.spin += deterministicRange(worldRef, "pin-detach-spin", -0.25, 0.25, item, t, reason); if (t) { - item.x = clamp(t.x + rand(-(t.radius || 16) * 0.75, (t.radius || 16) * 0.75), CONFIG.worldPadding || 30, (worldRef?.w || item.x) - (CONFIG.worldPadding || 30)); - item.y = clamp(t.y + rand((t.radius || 16) * 0.12, (t.radius || 16) * 0.72), CONFIG.worldPadding || 30, (worldRef?.h || item.y) - (CONFIG.worldPadding || 30)); + item.x = clamp(t.x + deterministicRange(worldRef, "pin-detach-x", -(t.radius || 16) * 0.75, (t.radius || 16) * 0.75, item, t, reason), CONFIG.worldPadding || 30, (worldRef?.w || item.x) - (CONFIG.worldPadding || 30)); + item.y = clamp(t.y + deterministicRange(worldRef, "pin-detach-y", (t.radius || 16) * 0.12, (t.radius || 16) * 0.72, item, t, reason), CONFIG.worldPadding || 30, (worldRef?.h || item.y) - (CONFIG.worldPadding || 30)); } worldRef?.effects?.push(new Effect("ring", item.x, item.y, { size: Math.max(14, (item.r || 16) * 0.92), life: 0.16, color: "rgba(214,112,112,0.40)" })); if (reason === "fall" && t) worldRef?.log?.(`${t.name}\u306e\u753b\u92f2\u304c\u629c\u3051\u843d\u3061\u305f\u3002`, "observe", { participants: [t] }); diff --git a/js/item_dynamic_system.js b/js/item_dynamic_system.js index b2272ef..f6d08d1 100644 --- a/js/item_dynamic_system.js +++ b/js/item_dynamic_system.js @@ -10,6 +10,7 @@ case "firecracker": case "genkotsu": case "rotator": + case "poison_block": case "reciprocator": case "gate_fence": case "rope": diff --git a/js/item_dynamic_tool_system.js b/js/item_dynamic_tool_system.js index d80e10b..e2d2f16 100644 --- a/js/item_dynamic_tool_system.js +++ b/js/item_dynamic_tool_system.js @@ -35,6 +35,10 @@ return Boolean(global.TarinaiMechanicalSystem?.updateReciprocator?.(item, dt, worldRef)); } + function updatePoisonBlock(item, dt, worldRef) { + return Boolean(global.TarinaiMechanicalSystem?.updatePoisonBlock?.(item, dt, worldRef)); + } + function resolveMechanicalInteractions(item, dt, worldRef) { return Boolean(global.TarinaiMechanicalSystem?.resolveInteractions?.(item, dt, worldRef)); } @@ -60,6 +64,7 @@ if (item.type === "firecracker") return updateFirecracker(item, dt, worldRef); if (item.type === "genkotsu") return updateGenkotsu(item, dt, worldRef); if (item.type === "rotator") return updateRotator(item, dt, worldRef); + if (item.type === "poison_block") return updatePoisonBlock(item, dt, worldRef); if (item.type === "reciprocator") return updateReciprocator(item, dt, worldRef); if (item.type === "gate_fence") return updateGateFence(item, dt, worldRef); if (item.type === "rope") return updateFlexibleLink(item, dt, worldRef); @@ -73,6 +78,7 @@ updateGenkotsu, updateRotator, updateReciprocator, + updatePoisonBlock, updateFlexibleLink, updateRigidLink, resolveMechanicalInteractions, diff --git a/js/item_lifecycle_support.js b/js/item_lifecycle_support.js index e5b64a7..4acad1b 100644 --- a/js/item_lifecycle_support.js +++ b/js/item_lifecycle_support.js @@ -8,13 +8,13 @@ const DUPLICATOR_STORABLE_TYPES = new Set([ "food", "sweet", "love_mochi", "fight_mochi", "sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", - "zunda_juice", "grass", "water", "pushpin", "oshibyo" + "zunda_juice", "water", "pushpin", "oshibyo" ]); function duplicatorLoadTypeForItem(item) { if (!item || item.dead || item.type === "duplicator") return ""; const type = String(item.type || ""); - if (!type || type === "water_bowl") return ""; + if (!type || type === "water_bowl" || type === "grass") return ""; if ((typeof isPinType === "function" && isPinType(type)) && item.pinState === "lodged") return ""; // Explicit list first. これで「ずんだ餅」「へこ餅」「けんか餅」が // serving-food 判定や medicine role の揺れに左右されず複製機へ入る。 diff --git a/js/item_registry.js b/js/item_registry.js index 6088c38..5b1f399 100644 --- a/js/item_registry.js +++ b/js/item_registry.js @@ -1,4 +1,34 @@ "use strict"; +(function ensureDeterministicHelpers(global) { + if (typeof global.deterministicChance === "function" && typeof global.deterministicRange === "function") return; + const clamp01 = v => Math.max(0, Math.min(1, Number(v) || 0)); + const keyPart = value => { + if (value == null) return "null"; + if (typeof value === "number") return Number.isFinite(value) ? value.toFixed(3) : "nan"; + if (typeof value === "string" || typeof value === "boolean") return String(value); + if (typeof value === "object") { + const id = value.familyKey || value.id || value.seed || value.liveToken || value.name; + if (id) return `${value.type || value.constructor?.name || "entity"}:${id}`; + const x = Number.isFinite(value.x) ? value.x.toFixed(1) : "x"; + const y = Number.isFinite(value.y) ? value.y.toFixed(1) : "y"; + return `${value.type || value.constructor?.name || "entity"}@${x},${y}`; + } + return String(value); + }; + const hashUnit = (seed, salt = "") => { + const str = `${seed}:${salt}`; + let h = 2166136261; + for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); } + return ((h >>> 0) % 100000) / 100000; + }; + global.deterministicFrame = global.deterministicFrame || ((worldRef = null, hz = 60) => Math.floor((Number(worldRef?.time || 0) || 0) * Math.max(1, Number(hz) || 60) + 1e-6)); + global.deterministicUnit = global.deterministicUnit || ((worldRef = null, salt = "", ...parts) => hashUnit(String(worldRef?.worldSeed || "tarinai-world"), [salt, global.deterministicFrame(worldRef, 60), Number(worldRef?._deterministicEpoch || 0) || 0, ...parts.map(keyPart)].join("|"))); + global.deterministicRange = global.deterministicRange || ((worldRef = null, salt = "", min = 0, max = 1, ...parts) => (Number(min) || 0) + global.deterministicUnit(worldRef, salt, ...parts) * ((Number(max) || 0) - (Number(min) || 0))); + global.deterministicSigned = global.deterministicSigned || ((worldRef = null, salt = "", ...parts) => global.deterministicUnit(worldRef, salt, ...parts) < 0.5 ? -1 : 1); + global.deterministicChance = global.deterministicChance || ((worldRef = null, salt = "", chance = 0, ...parts) => { const p = clamp01(chance); return p >= 1 || (p > 0 && global.deterministicUnit(worldRef, salt, ...parts) < p); }); + global.deterministicAngle = global.deterministicAngle || ((worldRef = null, salt = "", ...parts) => global.deterministicRange(worldRef, salt, 0, Math.PI * 2, ...parts)); +})(typeof window !== "undefined" ? window : globalThis); + // Unified item definition source: tools, item traits, visuals, food metadata, and item effects. @@ -10,11 +40,11 @@ const TOOL_DEFINITIONS = Object.freeze({ undo: { id: "undo", label: "戻す", placeable: false, scalable: false, iconText: "↶", tooltip: "直前の操作を取り消す。" }, redo: { id: "redo", label: "進む", placeable: false, scalable: false, iconText: "↷", tooltip: "取り消した操作をやり直す。" }, poke: { id: "poke", label: "\u3064\u3064\u304f", placeable: false, scalable: false, iconText: "\u{1F448}", tooltip: "\u305f\u308a\u306a\u3044\u3092\u3064\u3064\u304f\u3002\u30dc\u30fc\u30eb\u3082\u3064\u3064\u3044\u3066\u8ee2\u304c\u305b\u308b\u3002" }, - pinch: { id: "pinch", label: "\u3064\u307e\u3080", placeable: false, scalable: false, iconText: "\u{1F90F}", tooltip: "\u305f\u308a\u306a\u3044\u3084\u7269\u3092\u79fb\u52d5\u3055\u305b\u308b\u3002\u51b7\u51cd\u5eab\u306b\u3082\u904b\u3079\u308b\u3002" }, + 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つの対象を順にクリックして、伸び縮みしないヒモで接続する。機械体・柵・ヒモ・棒はクリック地点、それ以外は中心につなぐ。" }, - rod: { id: "rod", label: "棒", placeable: false, scalable: false, iconText: "━", tooltip: "2つの対象を順にクリックして、曲がらない棒で接続する。距離を常に一定に保つ。" }, + rod: { id: "rod", label: "棒", placeable: false, scalable: false, iconText: "━", tooltip: "2つの対象を順にクリックして、曲がらない棒で接続する。" }, 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" }, @@ -48,6 +78,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を切り替えられる。" }, 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 }, @@ -58,20 +89,20 @@ const TOOL_DEFINITIONS = Object.freeze({ const ITEM_TRAITS = Object.freeze({ - obstacle: new Set(["stone", "ball", "nest_box", "bed", "duplicator", "fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "reciprocator"]), + obstacle: new Set(["stone", "ball", "nest_box", "bed", "duplicator", "fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "poison_block", "reciprocator"]), food_interest: new Set(["sweet", "love_mochi", "fight_mochi", "grass", "zunchi", "water", "ant_corpse", "duplicator", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice", "sleep_drug"]), - hazard: new Set(["firecracker", "genkotsu", "pushpin", "oshibyo", "sleep_drug", "zunchi", "splat", "mystery_drug", "laxative", "niteropu", "mercury", "giant_drug", "dwarf_drug"]), + hazard: new Set(["firecracker", "genkotsu", "pushpin", "oshibyo", "poison_block", "sleep_drug", "zunchi", "splat", "mystery_drug", "laxative", "niteropu", "mercury", "giant_drug", "dwarf_drug"]), pin: new Set(["pushpin", "oshibyo"]), kinematic: new Set(["ball", "pushpin", "oshibyo", "zunchi"]), sleepFurniture: new Set(["bed", "nest_box"]), - draggable: new Set(["ball", "stone", "bed", "nest_box", "signboard", "duplicator", "pushpin", "oshibyo", "fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "reciprocator"]), + draggable: new Set(["ball", "stone", "bed", "nest_box", "signboard", "duplicator", "pushpin", "oshibyo", "fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "poison_block", "reciprocator"]), }); const FENCE_ITEM_TYPES = Object.freeze(new Set(["fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence"])); -const MECHANICAL_ITEM_TYPES = Object.freeze(new Set(["rotator", "reciprocator"])); +const MECHANICAL_ITEM_TYPES = Object.freeze(new Set(["rotator", "poison_block", "reciprocator"])); const LINK_ITEM_TYPES = Object.freeze(new Set(["rope", "rod"])); -const PHYSICS_TOOL_IDS = Object.freeze(["rope", "rod", "fence_h", "bounce_fence", "gate_fence", "rotator", "reciprocator"]); +const PHYSICS_TOOL_IDS = Object.freeze(["rope", "rod", "fence_h", "bounce_fence", "gate_fence", "rotator", "poison_block", "reciprocator"]); const ROTATABLE_ITEM_TYPES = Object.freeze(new Set([...FENCE_ITEM_TYPES, ...MECHANICAL_ITEM_TYPES])); function isFenceItemType(type = "") { return FENCE_ITEM_TYPES.has(String(type || "")); } function isMechanicalItemType(type = "") { return MECHANICAL_ITEM_TYPES.has(String(type || "")); } @@ -493,7 +524,7 @@ function toolTipsFromDefinitions() { Object.assign(rawDefinitions.love_mochi, { onApply(tarinai, context = {}) { const scale = context.scale ?? 1; - tarinai.loveMochiTimer = Math.max(tarinai.loveMochiTimer || 0, 42 * scale + rand(4, 12)); + tarinai.loveMochiTimer = Math.max(tarinai.loveMochiTimer || 0, 42 * scale + deterministicRange(tarinai.world, "love-mochi-duration", 4, 12, tarinai, context.item)); const nutrition = Number(context.nutrition) || 0; if (typeof applyNeedRelief === "function") applyNeedRelief(tarinai, { fulfill: -(context.source === "eating" ? nutrition * 0.5 : 12), social: -4 }); if (context.source === "eating") { @@ -506,7 +537,7 @@ function toolTipsFromDefinitions() { Object.assign(rawDefinitions.fight_mochi, { onApply(tarinai, context = {}) { const scale = context.scale ?? 1; - tarinai.fightMochiTimer = Math.max(tarinai.fightMochiTimer || 0, 52 * scale + rand(10, 18)); + tarinai.fightMochiTimer = Math.max(tarinai.fightMochiTimer || 0, 52 * scale + deterministicRange(tarinai.world, "fight-mochi-duration", 10, 18, tarinai, context.item)); const nutrition = Number(context.nutrition) || 0; if (typeof applyNeedShock === "function") applyNeedShock(tarinai, { safety: context.source === "eating" ? nutrition * 0.5 : 16 }); if (context.source === "eating") { @@ -529,7 +560,7 @@ function toolTipsFromDefinitions() { const sleepChance = tarinai.diseaseChance ? tarinai.diseaseChance(baseChance) : baseChance; if (tarinai.forceBehavior) tarinai.forceBehavior("sleep_anywhere", { source: "sleep_drug", priority: 145, ttl: 14, duration: 8, minDuration: 8, causeText: "\u306d\u3080\u308a\u85ac\u306e\u52b9\u679c" }); else if (typeof queueForcedTarinaiBehavior === "function") queueForcedTarinaiBehavior(tarinai, "sleep_anywhere", { source: "sleep_drug", priority: 145, ttl: 14, duration: 8, minDuration: 8, causeText: "\u306d\u3080\u308a\u85ac\u306e\u52b9\u679c" }); - if (!tarinai.sleepDisease && Math.random() < Math.min(0.98, sleepChance)) return tarinai.infectSleepDisease?.(context.item || null) || true; + if (!tarinai.sleepDisease && deterministicChance(tarinai.world, "sleep-drug-disease", Math.min(0.98, sleepChance), tarinai, context.item)) return tarinai.infectSleepDisease?.(context.item || null) || true; return true; } return tarinai.infectSleepDisease?.(context.item || null) || true; diff --git a/js/item_render_runtime.js b/js/item_render_runtime.js index fc2b5d1..eba3de7 100644 --- a/js/item_render_runtime.js +++ b/js/item_render_runtime.js @@ -2,6 +2,72 @@ // Item field rendering. Kept separate from construction and lifecycle logic. +function segmentPathCacheKey(item, segments, extra = "") { + const version = Number(item?._mechanicalShapeVersion || item?._segmentsVersion || 0) || 0; + const len = Array.isArray(segments) ? segments.length : 0; + let h = len * 2654435761; + if (Array.isArray(segments)) { + const step = Math.max(1, Math.floor(len / 24)); + for (let i = 0; i < len; i += step) { + const seg = segments[i]; + if (!Array.isArray(seg)) continue; + for (let j = 0; j < 4; j += 1) { + h ^= Math.round((Number(seg[j]) || 0) * 10) & 0xffff; + h = Math.imul(h, 16777619); + } + } + } + return `${item?.type || ""}|${version}|${len}|${h >>> 0}|${extra}`; +} + +function getSegmentPath(item, segments, extra = "") { + if (typeof Path2D === "undefined" || !Array.isArray(segments)) return null; + const key = segmentPathCacheKey(item, segments, extra); + const cache = item._segmentRenderPathCache || (item._segmentRenderPathCache = Object.create(null)); + if (cache.key === key && cache.path) return cache.path; + const path = new Path2D(); + for (const seg of segments) { + if (!Array.isArray(seg) || seg.length < 4) continue; + path.moveTo(Number(seg[0]) || 0, Number(seg[1]) || 0); + path.lineTo(Number(seg[2]) || 0, Number(seg[3]) || 0); + } + cache.key = key; + cache.path = path; + return path; +} + +function strokeSegments(ctx, item, segments, extra = "") { + const path = getSegmentPath(item, segments, extra); + if (path) { + ctx.stroke(path); + return; + } + ctx.beginPath(); + for (const seg of segments || []) { + if (!Array.isArray(seg) || seg.length < 4) continue; + ctx.moveTo(Number(seg[0]) || 0, Number(seg[1]) || 0); + ctx.lineTo(Number(seg[2]) || 0, Number(seg[3]) || 0); + } + ctx.stroke(); +} + +function mechanicalRenderTier() { + return window.TarinaiPerf?.renderQualityTier?.() || "high"; +} + +function drawRopeParticlePath(ctx, particles, ox, oy) { + if (!Array.isArray(particles) || particles.length < 2) return false; + const tier = mechanicalRenderTier(); + const stride = tier === "low" && particles.length > 10 ? 2 : 1; + ctx.moveTo((Number(particles[0].x) || 0) - ox, (Number(particles[0].y) || 0) - oy); + for (let i = stride; i < particles.length - 1; i += stride) { + ctx.lineTo((Number(particles[i].x) || 0) - ox, (Number(particles[i].y) || 0) - oy); + } + const last = particles[particles.length - 1]; + ctx.lineTo((Number(last.x) || 0) - ox, (Number(last.y) || 0) - oy); + return true; +} + Object.assign(Item.prototype, { draw(ctx, t, lighting = null) { @@ -549,10 +615,12 @@ draw(ctx, t, lighting = null) { ctx.restore(); } else if (this.type === "rotator") { const angle = typeof itemAngleFor === "function" ? itemAngleFor(this) : (Number(this.angle) || 0); - const powered = this.rotatorPowered !== false; - const speed = Number((powered ? this.rotatorSpeed : this.rotatorAngularVelocity) || 0) || 0; - const segments = (world?.rotatorSegments?.(this) || (Array.isArray(this.rotatorSegments) ? this.rotatorSegments : [[-78, 0, 78, 0], [0, -52, 0, 52]])); - const thick = Math.max(4, Math.min(34, Number(this.rotatorThickness || 12) || 12)); + const pb = window.TarinaiPhysicsBodySystem; + const powered = pb?.scalar?.(this, "motorOn", true) !== false; + const speed = Number((powered ? pb?.scalar?.(this, "motorSpeed", 0) : pb?.scalar?.(this, "spin", 0)) || 0) || 0; + const segments = (pb?.segments?.(this) || [[-78, 0, 78, 0], [0, -52, 0, 52]]); + const thick = Math.max(4, Math.min(34, Number(pb?.scalar?.(this, "thickness", 12) || 12) || 12)); + const tier = mechanicalRenderTier(); ctx.save(); ctx.rotate(angle); ctx.lineCap = "round"; @@ -561,24 +629,14 @@ draw(ctx, t, lighting = null) { ctx.strokeStyle = styleNight ? "#6e5f89" : "#8b6cc5"; ctx.lineWidth = thick; ctx.shadowColor = powered ? (styleNight ? "rgba(116,92,170,0.38)" : "rgba(126,82,190,0.30)") : "rgba(88,80,96,0.20)"; - ctx.shadowBlur = Math.max(4, thick * 0.28); - ctx.beginPath(); - for (const seg of segments) { - if (!Array.isArray(seg) || seg.length < 4) continue; - ctx.moveTo(Number(seg[0]) || 0, Number(seg[1]) || 0); - ctx.lineTo(Number(seg[2]) || 0, Number(seg[3]) || 0); - } - ctx.stroke(); + ctx.shadowBlur = tier === "low" ? 0 : (tier === "mid" ? Math.max(2, thick * 0.16) : Math.max(4, thick * 0.28)); + strokeSegments(ctx, this, segments, "body"); ctx.shadowBlur = 0; - ctx.strokeStyle = powered ? (styleNight ? "rgba(236,220,255,0.44)" : "rgba(255,255,255,0.46)") : "rgba(255,255,255,0.28)"; - ctx.lineWidth = Math.max(1.4, thick * 0.22); - ctx.beginPath(); - for (const seg of segments) { - if (!Array.isArray(seg) || seg.length < 4) continue; - ctx.moveTo(Number(seg[0]) || 0, Number(seg[1]) || 0); - ctx.lineTo(Number(seg[2]) || 0, Number(seg[3]) || 0); + if (tier !== "low") { + ctx.strokeStyle = powered ? (styleNight ? "rgba(236,220,255,0.44)" : "rgba(255,255,255,0.46)") : "rgba(255,255,255,0.28)"; + ctx.lineWidth = Math.max(1.4, thick * 0.22); + strokeSegments(ctx, this, segments, "body"); } - ctx.stroke(); ctx.restore(); ctx.fillStyle = powered ? (styleNight ? "#3c324e" : "#f4eaff") : (styleNight ? "#34343a" : "#ece8ef"); ctx.strokeStyle = powered ? (styleNight ? "#a28cc8" : "#6e4fa4") : "rgba(92,88,98,0.82)"; @@ -591,20 +649,59 @@ draw(ctx, t, lighting = null) { ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(powered ? (speed >= 0 ? "↻" : "↺") : "○", 0, 0); + } else if (this.type === "poison_block") { + const angle = typeof itemAngleFor === "function" ? itemAngleFor(this) : (Number(this.angle) || 0); + const pb = window.TarinaiPhysicsBodySystem; + const enabled = pb?.scalar?.(this, "solid", true) !== false; + const segments = (pb?.segments?.(this) || [[-82, -28, 82, -28], [82, -28, 82, 28], [82, 28, -82, 28], [-82, 28, -82, -28]]); + const thick = Math.max(4, Math.min(34, Number(pb?.scalar?.(this, "thickness", 14) || 14) || 14)); + const tier = mechanicalRenderTier(); + ctx.save(); + ctx.rotate(angle); + 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.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.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.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.font = `bold ${Math.max(10, thick * 0.84)}px ui-rounded, sans-serif`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(enabled ? "毒" : "透", 0, 0); } else if (this.type === "reciprocator") { const bodyAngle = typeof itemAngleFor === "function" ? itemAngleFor(this) : (Number(this.angle) || 0); - const axisAngle = window.TarinaiMechanicalSystem?.reciprocatorAxisAngle?.(this) ?? (Number.isFinite(Number(this.reciprocatorAxisAngle)) ? Number(this.reciprocatorAxisAngle) : bodyAngle); - const powered = this.reciprocatorPowered !== false; - const thick = Math.max(4, Math.min(34, Number(this.rotatorThickness || 12) || 12)); - const travel = Math.max(24, Number(this.reciprocatorTravel || 150) || 150); + const pb = window.TarinaiPhysicsBodySystem; + const axisAngle = window.TarinaiMechanicalSystem?.railAxisAngle?.(this) ?? pb?.scalar?.(this, "railAxis", bodyAngle) ?? bodyAngle; + const powered = pb?.scalar?.(this, "railOn", true) !== false; + const thick = Math.max(4, Math.min(34, Number(pb?.scalar?.(this, "thickness", 12) || 12) || 12)); + const tier = mechanicalRenderTier(); + const travel = Math.max(24, Number(pb?.scalar?.(this, "railTravel", 150) || 150) || 150); const halfTravel = travel * 0.5; - const phase = Math.max(-1, Math.min(1, Number(this.reciprocatorPhase || 0) || 0)); - const reciprocatorDir = Math.sign(Number(this.reciprocatorDirection || 1) || 1) || 1; - const passiveVelocity = Number(this.reciprocatorVelocity || 0) || 0; - const motorVelocity = powered ? reciprocatorDir * Math.max(0, Number(this.reciprocatorSpeed || 0) || 0) : 0; + const phase = Math.max(-1, Math.min(1, Number(pb?.scalar?.(this, "railPhase", 0) || 0) || 0)); + const reciprocatorDir = Math.sign(Number(pb?.scalar?.(this, "railDir", 1) || 1) || 1) || 1; + const passiveVelocity = Number(pb?.scalar?.(this, "slideSpeed", 0) || 0) || 0; + const motorVelocity = powered ? reciprocatorDir * Math.max(0, Number(pb?.scalar?.(this, "railMotorSpeed", 0) || 0) || 0) : 0; const visualVelocity = motorVelocity + passiveVelocity; const visualDirection = Math.abs(visualVelocity) > 0.8 ? Math.sign(visualVelocity) : reciprocatorDir; - const segments = (world?.rotatorSegments?.(this) || (Array.isArray(this.rotatorSegments) ? this.rotatorSegments : [[-78, 0, 78, 0]])); + const segments = (pb?.segments?.(this) || [[-78, 0, 78, 0]]); ctx.save(); ctx.rotate(axisAngle); ctx.save(); @@ -631,24 +728,14 @@ draw(ctx, t, lighting = null) { ctx.strokeStyle = styleNight ? "#4f7192" : "#6da6cf"; ctx.lineWidth = thick; ctx.shadowColor = powered ? (styleNight ? "rgba(86,130,180,0.34)" : "rgba(80,150,205,0.28)") : "rgba(74,82,90,0.20)"; - ctx.shadowBlur = Math.max(4, thick * 0.28); - ctx.beginPath(); - for (const seg of segments) { - if (!Array.isArray(seg) || seg.length < 4) continue; - ctx.moveTo(Number(seg[0]) || 0, Number(seg[1]) || 0); - ctx.lineTo(Number(seg[2]) || 0, Number(seg[3]) || 0); - } - ctx.stroke(); + ctx.shadowBlur = tier === "low" ? 0 : (tier === "mid" ? Math.max(2, thick * 0.16) : Math.max(4, thick * 0.28)); + strokeSegments(ctx, this, segments, "body"); ctx.shadowBlur = 0; - ctx.strokeStyle = powered ? (styleNight ? "rgba(220,238,255,0.44)" : "rgba(255,255,255,0.48)") : "rgba(255,255,255,0.28)"; - ctx.lineWidth = Math.max(1.4, thick * 0.22); - ctx.beginPath(); - for (const seg of segments) { - if (!Array.isArray(seg) || seg.length < 4) continue; - ctx.moveTo(Number(seg[0]) || 0, Number(seg[1]) || 0); - ctx.lineTo(Number(seg[2]) || 0, Number(seg[3]) || 0); + if (tier !== "low") { + ctx.strokeStyle = powered ? (styleNight ? "rgba(220,238,255,0.44)" : "rgba(255,255,255,0.48)") : "rgba(255,255,255,0.28)"; + ctx.lineWidth = Math.max(1.4, thick * 0.22); + strokeSegments(ctx, this, segments, "body"); } - ctx.stroke(); ctx.restore(); ctx.fillStyle = powered ? (styleNight ? "#2d3c4d" : "#eaf6ff") : (styleNight ? "#34383e" : "#e9edf0"); ctx.strokeStyle = powered ? (styleNight ? "#7faed0" : "#4d8cba") : "rgba(82,88,94,0.82)"; @@ -670,8 +757,9 @@ draw(ctx, t, lighting = null) { } } else if (this.type === "rope" || this.type === "rod") { const runtime = window.TarinaiLinkRuntime; - const a = runtime?.endpointWorld?.(this.linkA, world); - const b = runtime?.endpointWorld?.(this.linkB, world); + const pb = window.TarinaiPhysicsBodySystem; + const a = runtime?.endpointWorld?.(pb?.endpoint?.(this, 0), world); + const b = runtime?.endpointWorld?.(pb?.endpoint?.(this, 1), world); if (a && b) { const ax = a.x - this.x, ay = a.y - this.y; const bx = b.x - this.x, by = b.y - this.y; @@ -692,19 +780,30 @@ draw(ctx, t, lighting = null) { ctx.lineTo(bx, by); ctx.stroke(); } else { - const mx = Number.isFinite(Number(this.linkMidX)) ? Number(this.linkMidX) - this.x : (ax + bx) * 0.5; - const my = Number.isFinite(Number(this.linkMidY)) ? Number(this.linkMidY) - this.y : (ay + by) * 0.5; + const particles = Array.isArray(pb?.particles?.(this)) && pb.particles(this).length >= 2 ? pb.particles(this) : null; ctx.strokeStyle = styleNight ? "rgba(180,150,116,0.88)" : "rgba(132,94,54,0.92)"; ctx.lineWidth = 4.2; ctx.beginPath(); - ctx.moveTo(ax, ay); - ctx.quadraticCurveTo(mx, my, bx, by); + if (particles) { + drawRopeParticlePath(ctx, particles, this.x, this.y); + } else { + const mx = Number.isFinite(Number(pb?.linkScalar?.(this, "midX", NaN))) ? Number(pb.linkScalar(this, "midX", 0)) - this.x : (ax + bx) * 0.5; + const my = Number.isFinite(Number(pb?.linkScalar?.(this, "midY", NaN))) ? Number(pb.linkScalar(this, "midY", 0)) - this.y : (ay + by) * 0.5; + ctx.moveTo(ax, ay); + ctx.quadraticCurveTo(mx, my, bx, by); + } ctx.stroke(); ctx.strokeStyle = styleNight ? "rgba(236,214,180,0.56)" : "rgba(255,236,190,0.52)"; ctx.lineWidth = 1.5; ctx.beginPath(); - ctx.moveTo(ax, ay); - ctx.quadraticCurveTo(mx, my, bx, by); + if (particles) { + drawRopeParticlePath(ctx, particles, this.x, this.y); + } else { + const mx = Number.isFinite(Number(pb?.linkScalar?.(this, "midX", NaN))) ? Number(pb.linkScalar(this, "midX", 0)) - this.x : (ax + bx) * 0.5; + const my = Number.isFinite(Number(pb?.linkScalar?.(this, "midY", NaN))) ? Number(pb.linkScalar(this, "midY", 0)) - this.y : (ay + by) * 0.5; + ctx.moveTo(ax, ay); + ctx.quadraticCurveTo(mx, my, bx, by); + } ctx.stroke(); } ctx.fillStyle = this.type === "rod" ? (styleNight ? "rgba(120,112,98,0.96)" : "rgba(114,96,72,0.96)") : (styleNight ? "rgba(112,84,54,0.95)" : "rgba(162,112,63,0.95)"); diff --git a/js/item_type_initializers.js b/js/item_type_initializers.js index 810f216..3f40446 100644 --- a/js/item_type_initializers.js +++ b/js/item_type_initializers.js @@ -4,11 +4,13 @@ function initializeItemTypeState(item, type, x, y) { if (!item) return item; + 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 === "bed" || type === "nest_box") item.roles.sleepPlace = true; - if (["firecracker", "genkotsu", "pushpin", "oshibyo", "splat"].includes(type)) item.roles.danger = true; + if (["firecracker", "genkotsu", "pushpin", "oshibyo", "poison_block", "splat"].includes(type)) item.roles.danger = true; if (type === "grass") item.roles.grassMaterial = true; if (type === "grass") { item.grassStage = Math.floor(rand(0, 2.999)); @@ -110,12 +112,45 @@ function initializeItemTypeState(item, type, x, y) { item.gateOpen = false; item.gateLastToggleAt = -999; } + + if (type === "poison_block") { + item.physicsBody = { + schema: bodySchemaVersion, + type: "poison_block", + kind: "passive", + pose: { x, y, angle: Number(item.angle) || 0 }, + velocity: { x: 0, y: 0, angular: 0, linear: 0 }, + motor: { powered: false, speed: 0, direction: 1 }, + rail: null, + shape: { model: "segments", thickness: 14, segments: [[-82, -28, 82, -28], [82, -28, 82, 28], [82, 28, -82, 28], [-82, 28, -82, -28]], version: 0 }, + collision: { solid: true, hazard: true }, + hazard: { kind: "poison", damage: 7 }, + sleep: { awakeUntil: 0 }, + mass: 4.8, + inertia: 36000, + }; + item.prevX = x; + item.prevY = y; + item.prevAngle = item.angle || 0; + item.amount = 999; + item.deletable = true; + item.roles.danger = true; + } + if (type === "rotator") { - item.rotatorPowered = true; - item.rotatorSpeed = Math.PI * 0.65; - item.rotatorAngularVelocity = 0; - item.rotatorThickness = 12; - item.rotatorSegments = [[-78, 0, 78, 0], [0, -52, 0, 52]]; + item.physicsBody = { + schema: bodySchemaVersion, + type: "rotator", + kind: "rotational", + pose: { x, y, angle: Number(item.angle) || 0 }, + velocity: { x: 0, y: 0, angular: 0, linear: 0 }, + motor: { powered: true, speed: Math.PI * 0.65, direction: 1 }, + rail: null, + shape: { model: "segments", thickness: 12, segments: [[-78, 0, 78, 0], [0, -52, 0, 52]], version: 0 }, + collision: { solid: true, hazard: false }, + hazard: null, + sleep: { awakeUntil: 0 }, + }; item.rotatorEditorOpenAt = -999; item.amount = 999; } @@ -123,30 +158,39 @@ function initializeItemTypeState(item, type, x, y) { item.amount = 999; item.r = 48; item.deletable = true; - item.linkA = null; - item.linkB = null; - item.linkLength = 80; - item.linkMidX = x; - item.linkMidY = y + 10; - item.linkMidVX = 0; - item.linkMidVY = 0; + item.physicsConstraint = { + schema: constraintSchemaVersion, + type, + kind: type === "rope" ? "flexible-distance" : "rigid-distance", + endpoints: [null, null], + length: 80, + mid: { x, y: y + 10, vx: 0, vy: 0 }, + sleep: { awakeUntil: 0 }, + }; } if (type === "reciprocator") { - item.reciprocatorPowered = true; - item.reciprocatorSpeed = 92; - item.reciprocatorTravel = 150; - item.reciprocatorPhase = 0; - item.reciprocatorDirection = 1; - item.reciprocatorAxisAngle = Number.isFinite(Number(item.angle)) ? item.angle : 0; - item.reciprocatorVelocity = 0; - item.reciprocatorAnchorX = x; - item.reciprocatorAnchorY = y; - item.rotatorThickness = 12; - item.rotatorSegments = [[-78, 0, 78, 0]]; + item.physicsBody = { + schema: bodySchemaVersion, + type: "reciprocator", + kind: "linear", + pose: { x, y, angle: Number(item.angle) || 0 }, + velocity: { x: 0, y: 0, angular: 0, linear: 0 }, + motor: { powered: true, speed: 92, direction: 1 }, + rail: { axisAngle: Number.isFinite(Number(item.angle)) ? Number(item.angle) : 0, travel: 150, phase: 0, anchorX: x, anchorY: y }, + shape: { model: "segments", thickness: 12, segments: [[-78, 0, 78, 0]], version: 0 }, + collision: { solid: true, hazard: false }, + hazard: null, + sleep: { awakeUntil: 0 }, + }; item.amount = 999; } + if (globalThis.TarinaiPhysicsBodySystem?.isPhysicsType?.(type)) { + globalThis.TarinaiPhysicsBodySystem.purgeLegacyPhysicsStorage?.(item); + globalThis.TarinaiPhysicsBodySystem.invalidateItem?.(item, "item-initialized"); + } + if (isServingFoodType(type)) { item.toolSize = "medium"; item.foodServingScale = 1; diff --git a/js/item_update_policy.js b/js/item_update_policy.js index ccdb0b1..80a2780 100644 --- a/js/item_update_policy.js +++ b/js/item_update_policy.js @@ -5,17 +5,21 @@ // helpers and the item scheduler both read this single policy instead of // duplicating item-type timing rules. (function (global) { - const REALTIME_ITEM_TYPES = new Set(["ball", "genkotsu", "firecracker", "rotator", "reciprocator", "rope", "rod"]); + const REALTIME_ITEM_TYPES = new Set(["ball", "genkotsu", "firecracker"]); const PIN_ITEM_TYPES = new Set(["pushpin", "oshibyo"]); const SCHEDULED_ITEM_TYPES = Object.freeze([ "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", "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", "water_bowl", ]); function itemUpdateInterval(item) { if (!item || item.dead) return Infinity; + // Mechanical bodies and links are owned by the central physics step. + // Keeping them out of the per-item scheduler prevents N independent + // collision passes and makes future physics-format changes cheaper. + if (item.type === "rotator" || item.type === "reciprocator" || item.type === "poison_block" || item.type === "rope" || item.type === "rod") return Infinity; if (REALTIME_ITEM_TYPES.has(item.type)) return 0; if (PIN_ITEM_TYPES.has(item.type)) return 0; if (item.isStructure && item.type === "plushie") return 0; diff --git a/js/item_update_scheduler.js b/js/item_update_scheduler.js index 7488fd4..80fae3d 100644 --- a/js/item_update_scheduler.js +++ b/js/item_update_scheduler.js @@ -153,12 +153,43 @@ return ran; } + function physicsBudgetFor(worldRef) { + const tier = global.TarinaiPerf?.renderQualityTier?.() || "high"; + const itemCount = (worldRef?.items || []).length || 0; + const activeHint = worldRef?._itemUpdateRealtime?.length || 0; + if (tier === "low") return { maxPairs: 140, maxLinks: 90, postMaxPairs: 36, maxSubsteps: 2 }; + if (tier === "medium" || tier === "mid") { + const crowded = itemCount > 64 && activeHint <= 2; + return crowded + ? { maxPairs: 150, maxLinks: 105, postMaxPairs: 44, maxSubsteps: 2 } + : { maxPairs: 190, maxLinks: 130, postMaxPairs: 60, maxSubsteps: 3 }; + } + return itemCount > 80 + ? { maxPairs: 240, maxLinks: 170, postMaxPairs: 78, maxSubsteps: 4 } + : { maxPairs: 300, maxLinks: 210, postMaxPairs: 100, maxSubsteps: 5 }; + } + function run(worldRef, dt) { const end = global.TarinaiPerf?.begin?.("update.items") || null; try { const state = ensure(worldRef); - let ran = runRealtime(worldRef, state, dt); - ran += runDue(worldRef, state); + const realtimeEnd = global.TarinaiPerf?.begin?.("update.items.realtime") || null; + let realtimeRan = runRealtime(worldRef, state, dt); + let ran = realtimeRan; + if (realtimeEnd) realtimeEnd(); + const dueEnd = global.TarinaiPerf?.begin?.("update.items.scheduled") || null; + const dueRan = runDue(worldRef, state); + ran += dueRan; + if (dueEnd) dueEnd(); + const physicsEnd = global.TarinaiPerf?.begin?.("update.physicsWorld") || null; + const physicsBudget = physicsBudgetFor(worldRef); + const physicsWorld = global.TarinaiPhysicsWorldSystem?.updateWorld + ? global.TarinaiPhysicsWorldSystem.updateWorld(worldRef, dt, physicsBudget) + : null; + const mechanicalWorld = physicsWorld?.mechanical || (global.TarinaiMechanicalSystem?.updateWorld?.(worldRef, dt, { maxPairs: physicsBudget.maxPairs, maxLinks: 0, skipLinks: true, maxSubsteps: physicsBudget.maxSubsteps }) || null); + const constraintWorld = physicsWorld ? physicsWorld.constraintRan : (global.TarinaiConstraintSystem?.updateWorld?.(worldRef, dt, { maxLinks: Math.min(180, physicsBudget.maxLinks) }) || 0); + const postConstraintPairs = physicsWorld ? physicsWorld.postConstraintPairs : (constraintWorld ? (global.TarinaiMechanicalSystem?.resolveMechanicalPairs?.(worldRef, dt, { maxPairs: Math.min(80, physicsBudget.postMaxPairs) }) || 0) : 0); + if (physicsEnd) physicsEnd(); state.signature = schedulerSignature(worldRef); worldRef._itemUpdateSchedulerStats = { heap: state.heap.length, @@ -166,8 +197,18 @@ rebuilds: state.rebuilds, lastReason: state.reason, lastRan: ran, + realtimeRan, + dueRan, + sleeping: state.heap.length, + activeRealtime: state.realtime.length, + physicsBudget, + physics: physicsWorld || mechanicalWorld, + constraints: constraintWorld, + postConstraintPairs, + mechanicalStats: worldRef._mechanicalWorldStats || null, + constraintStats: worldRef._constraintWorldStats || null, }; - return ran; + return ran + (physicsWorld?.ran || mechanicalWorld?.ran || 0) + constraintWorld; } finally { if (end) end(); } diff --git a/js/mechanical_system.js b/js/mechanical_system.js index 839dbf3..30bf84f 100644 --- a/js/mechanical_system.js +++ b/js/mechanical_system.js @@ -1,7 +1,7 @@ "use strict"; // Layer: physics/mechanical-body -// Common body, footprint, motion, and contact runtime for 回転体 and 往復体. +// Common body, footprint, motion, and contact runtime for 回転体, 毒ブロック, and 往復体. (function (global) { const FP = () => global.TarinaiCollisionFootprints || {}; @@ -10,18 +10,142 @@ return Number.isFinite(n) ? n : fallback; } function typeOf(itemOrType) { return typeof itemOrType === "string" ? itemOrType : String(itemOrType?.type || ""); } - function isMechanicalType(itemOrType) { return typeOf(itemOrType) === "rotator" || typeOf(itemOrType) === "reciprocator"; } - function motionType(itemOrType) { const t = typeOf(itemOrType); return t === "rotator" ? "rotate" : (t === "reciprocator" ? "reciprocate" : "none"); } - function isPowered(item) { return item?.type === "rotator" ? item.rotatorPowered !== false : item?.reciprocatorPowered !== false; } - function itemAngle(item) { return typeof global.itemAngleFor === "function" ? global.itemAngleFor(item) : num(item?.angle, 0); } + function isMechanicalType(itemOrType) { const t = typeOf(itemOrType); return t === "rotator" || t === "poison_block" || t === "reciprocator"; } + function motionType(itemOrType) { const t = typeOf(itemOrType); return t === "rotator" ? "rotate" : (t === "reciprocator" ? "reciprocate" : (t === "poison_block" ? "passive" : "none")); } function normalizeAngle(angle = 0) { return typeof global.normalizedItemAngle === "function" ? global.normalizedItemAngle(angle, 0) : angle; } - function reciprocatorAxisAngle(item) { - const fallback = itemAngle(item); - return normalizeAngle(num(item?.reciprocatorAxisAngle, fallback)); + + function bodySystem() { return global.TarinaiPhysicsBodySystem || null; } + function ps(item, key, fallback = 0) { return bodySystem()?.scalar?.(item, key, fallback) ?? fallback; } + function pset(item, key, value, reason = "mechanical-write") { return bodySystem()?.setScalar?.(item, key, value, reason) || false; } + function ensureBody(item, opts = {}) { + if (!item || item.dead || !isMechanicalType(item)) return null; + const api = bodySystem(); + return api?.ensureBody?.(item, item.world || null, { syncFromLegacy: opts.syncFromLegacy === true }) || item.physicsBody || null; + } + function bodyOf(item) { return ensureBody(item, { syncFromLegacy: false }); } + function usingStepScratch(item) { return item?._physicsStepScratch === true; } + function bodyPose(item) { return usingStepScratch(item) ? null : (bodyOf(item)?.pose || null); } + function bodyVelocity(item) { return bodyOf(item)?.velocity || null; } + function bodyMotor(item) { return bodyOf(item)?.motor || null; } + function bodyRail(item) { return bodyOf(item)?.rail || null; } + function bodyShape(item) { return bodyOf(item)?.shape || null; } + function applyBodyState(item, opts = {}) { return bodySystem()?.applyBodyState?.(item, item?.physicsBody, opts) || false; } + function normalizeBodyState(item) { return bodySystem()?.normalizeBodyState?.(item, item?.physicsBody) || item?.physicsBody || null; } + function commitBody(item, reason = "mechanical-body-mutated") { + if (!item || !isMechanicalType(item)) return null; + // Capture before ensureBody()/normalizeBodyState() can restore the old body + // pose to item.x/y/angle. + const sx = num(item.x); + const sy = num(item.y); + const sa = num(item.angle); + let body = item.physicsBody && item.physicsBody.type === item.type ? item.physicsBody : null; + if (!body) body = ensureBody(item, { syncFromLegacy: false }); + if (!body) return null; + body.pose = body.pose || { x: sx, y: sy, angle: sa }; + body.pose.x = sx; + body.pose.y = sy; + body.pose.angle = sa; + item.x = sx; + item.y = sy; + item.angle = sa; + normalizeBodyState(item); + bodySystem()?.markBodyChanged?.(item, reason); + return body; + } + function isPowered(item) { + const motor = bodyMotor(item); + if (motor) return item?.type === "poison_block" ? false : motor.powered !== false; + return item?.type === "rotator" ? ps(item, "motorOn", true) !== false : (item?.type === "reciprocator" ? ps(item, "railOn", true) !== false : false); + } + function itemAngle(item) { + const pose = bodyPose(item); + if (pose && Number.isFinite(Number(pose.angle))) return num(pose.angle); + return typeof global.itemAngleFor === "function" ? global.itemAngleFor(item) : num(item?.angle, 0); } - function sanitizeSegments(item) { - const raw = Array.isArray(item?.rotatorSegments) ? item.rotatorSegments : []; + function geomCache(item) { + if (!item) return null; + return item._mechanicalGeomCache || (item._mechanicalGeomCache = Object.create(null)); + } + + function shapeVersion(item) { + const shape = bodyShape(item); + return Number(shape?.version ?? item?._mechanicalShapeVersion ?? item?._segmentsVersion ?? 0) || 0; + } + + function invalidateGeometry(item) { + if (!item) return false; + const nextVersion = (Number(shapeVersion(item) || 0) || 0) + 1; + const body = ensureBody(item, { syncFromLegacy: true }); + if (body) { + body.shape = body.shape || {}; + body.shape.version = nextVersion; + bodySystem()?.markBodyChanged?.(item, "mechanical-geometry-edited"); + } + item._mechanicalShapeVersion = nextVersion; + item._mechanicalGeomCache = null; + item._mechanicalSupportVersion = nextVersion; + item.world?.markSpatialDirty?.("mechanical-geometry-edited"); + item.world?.markItemBucketsDirty?.("mechanical-geometry-edited"); + return true; + } + + function wakeItem(item, reason = "mechanical-wake") { + if (!item || item.dead) return false; + const now = Number(item.world?.time || 0) || 0; + const awakeUntil = Math.max(ps(item, "awakeUntil", 0), now + 0.45); + pset(item, "awakeUntil", awakeUntil, reason); + const body = ensureBody(item, { syncFromLegacy: false }); + if (body) { + body.sleep = body.sleep || {}; + body.sleep.awakeUntil = Math.max(num(body.sleep.awakeUntil), awakeUntil); + bodySystem()?.markBodyChanged?.(item, reason); + } + if (item.world) { + item.world._itemUpdateScheduler = null; + item.world.markSpatialDirty?.(reason); + } + return true; + } + + function passiveItemAwake(item) { + if (!item || item.dead) return false; + if (item.playerHeld || item._heldByPlayer) return true; + const body = bodyOf(item); + const sleep = body?.sleep || null; + const vel = body?.velocity || null; + const awakeUntil = Math.max(num(sleep?.awakeUntil), ps(item, "awakeUntil", 0)); + if (awakeUntil > (Number(item.world?.time || 0) || 0)) return true; + if (Math.hypot(num(vel?.x, ps(item, "xv", 0)), num(vel?.y, ps(item, "yv", 0))) > 0.035) return true; + if (Math.abs(num(vel?.angular, ps(item, "spin", 0))) > 0.0015) return true; + return false; + } + + function railAxisAngle(item) { + const fallback = itemAngle(item); + return normalizeAngle(num(bodyRail(item)?.axisAngle, ps(item, "railAxis", fallback))); + } + + function rawSegmentSignature(raw) { + if (!Array.isArray(raw) || !raw.length) return "0"; + // Cheap edit detection for older save/editor paths that do not bump the + // mechanical shape version. This runs only until the local cache is valid. + let h = raw.length * 2166136261; + const step = Math.max(1, Math.floor(raw.length / 24)); + for (let i = 0; i < raw.length; i += step) { + const seg = raw[i]; + if (!Array.isArray(seg)) continue; + for (let j = 0; j < 4; j += 1) { + h ^= Math.round(num(seg[j]) * 10) & 0xffff; + h = Math.imul(h, 16777619); + } + } + return String(h >>> 0); + } + + function normalizeRawSegments(item) { + const shape = bodyShape(item); + const raw = Array.isArray(shape?.segments) ? shape.segments : []; const out = []; const clampCoord = (v) => Math.max(-420, Math.min(420, num(v))); for (const seg of raw) { @@ -29,18 +153,90 @@ const x1 = clampCoord(seg[0]), y1 = clampCoord(seg[1]), x2 = clampCoord(seg[2]), y2 = clampCoord(seg[3]); if (Math.hypot(x2 - x1, y2 - y1) < 4) continue; out.push([x1, y1, x2, y2]); - if (out.length >= 96) break; + if (out.length >= 128) break; + } + if (!out.length) { + if (item?.type === "poison_block") out.push([-82, -28, 82, -28], [82, -28, 82, 28], [82, 28, -82, 28], [-82, 28, -82, -28]); + else out.push(item?.type === "rotator" ? [-78, 0, 78, 0] : [-78, 0, 78, 0], ...(item?.type === "rotator" ? [[0, -52, 0, 52]] : [])); } - if (!out.length) out.push(item?.type === "rotator" ? [-78, 0, 78, 0] : [-78, 0, 78, 0], ...(item?.type === "rotator" ? [[0, -52, 0, 52]] : [])); return out; } - function thickness(item) { return Math.max(4, Math.min(34, num(item?.rotatorThickness, 12))); } + function physicsSegmentLimit(item, normalizedCount = 0) { + const base = item?.type === "reciprocator" ? 34 : (item?.type === "poison_block" ? 48 : 58); + // Keep small hand-made shapes exact. Simplification is only for dense free-draw shapes. + if (normalizedCount <= base) return normalizedCount; + return base; + } + + function simplifySegmentsForPhysics(item, segments) { + const limit = physicsSegmentLimit(item, segments.length); + if (segments.length <= limit) return segments; + const minLen = item?.type === "poison_block" ? 5.2 : 5.8; + const merged = []; + const angleEps = 0.15; + const joinEps = 7.5; + for (const seg of segments) { + const x1 = seg[0], y1 = seg[1], x2 = seg[2], y2 = seg[3]; + const len = Math.hypot(x2 - x1, y2 - y1); + if (len < minLen) continue; + const last = merged[merged.length - 1]; + if (last) { + const ldx = last[2] - last[0], ldy = last[3] - last[1]; + const dx = x2 - x1, dy = y2 - y1; + const llen = Math.max(0.001, Math.hypot(ldx, ldy)); + const dlen = Math.max(0.001, Math.hypot(dx, dy)); + const dot = (ldx / llen) * (dx / dlen) + (ldy / llen) * (dy / dlen); + if (Math.hypot(last[2] - x1, last[3] - y1) <= joinEps && dot > 1 - angleEps) { + last[2] = x2; + last[3] = y2; + continue; + } + } + merged.push([x1, y1, x2, y2]); + } + const source = merged.length ? merged : segments; + if (source.length <= limit) return source; + // Preserve coverage rather than perfect ordering: choose the longest segments, + // then restore original order so free-drawn outlines remain visually coherent to physics. + const ranked = source.map((seg, index) => ({ + index, + seg, + score: Math.hypot(seg[2] - seg[0], seg[3] - seg[1]) + Math.hypot(seg[0], seg[1]) * 0.015 + Math.hypot(seg[2], seg[3]) * 0.015, + })).sort((a, b) => b.score - a.score).slice(0, limit).sort((a, b) => a.index - b.index); + return ranked.map(e => e.seg); + } + + function sanitizeSegments(item) { + const cache = geomCache(item); + const shape = bodyShape(item); + const raw = Array.isArray(shape?.segments) ? shape.segments : []; + const thickKey = Number.isFinite(Number(shape?.thickness)) ? shape.thickness : ps(item, "thickness", item?.type === "poison_block" ? 14 : 12); + const key = `${item?.type || ""}|${shapeVersion(item)}|${raw.length}|${rawSegmentSignature(raw)}|${thickKey || ""}`; + if (cache?.localKey === key && cache.localSegments) return cache.localSegments; + const normalized = normalizeRawSegments(item); + const out = simplifySegmentsForPhysics(item, normalized); + if (cache) { + cache.localKey = key; + cache.localSegments = out; + cache.displaySegmentCount = normalized.length; + cache.physicsSegmentCount = out.length; + } + return out; + } + + function thickness(item) { return Math.max(4, Math.min(34, num(bodyShape(item)?.thickness, ps(item, "thickness", item?.type === "poison_block" ? 14 : 12)))); } function extent(item) { + const cache = geomCache(item); + const local = sanitizeSegments(item); + const key = `${cache?.localKey || ""}|${shapeVersion(item)}|${item?.type || ""}|${thickness(item)}`; + if (cache?.extentKey === key && Number.isFinite(cache.extent)) return cache.extent; let maxD = 48; - for (const seg of sanitizeSegments(item)) maxD = Math.max(maxD, Math.hypot(seg[0], seg[1]), Math.hypot(seg[2], seg[3])); - return Math.min(460, maxD + Math.max(8, thickness(item)) + 8); + for (const seg of local) maxD = Math.max(maxD, Math.hypot(seg[0], seg[1]), Math.hypot(seg[2], seg[3])); + const value = Math.min(460, maxD + Math.max(8, thickness(item)) + 8); + if (cache) { cache.extentKey = key; cache.extent = value; } + return value; } function orientedRectAabb(cx, cy, halfW, halfH, angle) { @@ -52,64 +248,91 @@ function worldSegments(item) { if (!item || item.dead || !isMechanicalType(item)) return []; + const cache = geomCache(item); + const local = sanitizeSegments(item); const a = itemAngle(item); + const key = `${cache?.localKey || ""}|${shapeVersion(item)}|${num(item.x).toFixed(3)}|${num(item.y).toFixed(3)}|${a.toFixed(5)}`; + if (cache?.worldKey === key && cache.worldSegments) return cache.worldSegments; const c = Math.cos(a), s = Math.sin(a); - const cx = num(item.x), cy = num(item.y); - return sanitizeSegments(item).map(([x1, y1, x2, y2]) => [ + const pose = bodyPose(item); + const cx = num(pose?.x, item.x), cy = num(pose?.y, item.y); + const segments = local.map(([x1, y1, x2, y2]) => [ cx + x1 * c - y1 * s, cy + x1 * s + y1 * c, cx + x2 * c - y2 * s, cy + x2 * s + y2 * c, ]); + if (cache) { cache.worldKey = key; cache.worldSegments = segments; } + return segments; } function axis(item) { - const a = item?.type === "reciprocator" ? reciprocatorAxisAngle(item) : itemAngle(item); + const a = item?.type === "reciprocator" ? railAxisAngle(item) : itemAngle(item); return { x: Math.cos(a), y: Math.sin(a), angle: a }; } - function halfTravel(item) { return Math.max(24, num(item?.reciprocatorTravel, 150)) * 0.5; } + function halfTravel(item) { return Math.max(24, num(bodyRail(item)?.travel, ps(item, "railTravel", 150))) * 0.5; } function resetAnchor(item) { if (!item) return; - if (!Number.isFinite(Number(item.reciprocatorAnchorX))) item.reciprocatorAnchorX = num(item.x); - if (!Number.isFinite(Number(item.reciprocatorAnchorY))) item.reciprocatorAnchorY = num(item.y); + const body = ensureBody(item, { syncFromLegacy: false }); + const rail = body?.rail || null; + const pose = body?.pose || null; + if (rail) { + if (!Number.isFinite(Number(rail.anchorX))) rail.anchorX = num(pose?.x, item.x); + if (!Number.isFinite(Number(rail.anchorY))) rail.anchorY = num(pose?.y, item.y); + pset(item, "railAnchorX", rail.anchorX, "rail-anchor-init"); + pset(item, "railAnchorY", rail.anchorY, "rail-anchor-init"); + return; + } + if (!Number.isFinite(Number(rail?.anchorX))) pset(item, "railAnchorX", num(item.x), "rail-anchor-init"); + if (!Number.isFinite(Number(rail?.anchorY))) pset(item, "railAnchorY", num(item.y), "rail-anchor-init"); } function positionFromPhase(item) { resetAnchor(item); const a = axis(item); const travel = halfTravel(item); - const phase = Math.max(-1, Math.min(1, num(item.reciprocatorPhase, 0))); - return { x: num(item.reciprocatorAnchorX) + a.x * travel * phase, y: num(item.reciprocatorAnchorY) + a.y * travel * phase }; + const rail = bodyRail(item); + const phase = Math.max(-1, Math.min(1, num(rail?.phase, ps(item, "railPhase", 0)))); + return { x: num(rail?.anchorX, ps(item, "railAnchorX", item.x)) + a.x * travel * phase, y: num(rail?.anchorY, ps(item, "railAnchorY", item.y)) + a.y * travel * phase }; } function phaseFromPosition(item) { resetAnchor(item); const a = axis(item); const travel = halfTravel(item); - const dx = num(item.x) - num(item.reciprocatorAnchorX); - const dy = num(item.y) - num(item.reciprocatorAnchorY); + const pose = bodyPose(item); + const rail = bodyRail(item); + const dx = num(pose?.x, item.x) - num(rail?.anchorX, ps(item, "railAnchorX", item.x)); + const dy = num(pose?.y, item.y) - num(rail?.anchorY, ps(item, "railAnchorY", item.y)); return Math.max(-1, Math.min(1, (dx * a.x + dy * a.y) / Math.max(10, travel))); } function signedDrive(item) { if (!item) return 0; - if (item.type === "rotator") return (isPowered(item) ? num(item.rotatorSpeed) : 0) + num(item.rotatorAngularVelocity); + const vel = bodyVelocity(item); + const motor = bodyMotor(item); + if (item.type === "rotator") return (isPowered(item) ? num(motor?.speed, ps(item, "motorSpeed", 0)) : 0) + num(vel?.angular, ps(item, "spin", 0)); + if (item.type === "poison_block") return num(vel?.angular, ps(item, "spin", 0)); if (item.type === "reciprocator") { - const dir = Math.sign(num(item.reciprocatorDirection, 1)) || 1; - return (isPowered(item) ? dir * Math.max(0, num(item.reciprocatorSpeed, 0)) : 0) + num(item.reciprocatorVelocity); + const dir = Math.sign(num(motor?.direction, ps(item, "railDir", 1))) || 1; + return (isPowered(item) ? dir * Math.max(0, num(motor?.speed, ps(item, "railMotorSpeed", 0))) : 0) + num(vel?.linear, ps(item, "slideSpeed", 0)); } return 0; } function pointVelocity(item, x, y) { if (!item) return { x: 0, y: 0 }; - if (item.type === "rotator") { + if (item.type === "rotator" || item.type === "poison_block") { const omega = signedDrive(item); - const rx = num(x) - num(item.x); - const ry = num(y) - num(item.y); - return { x: -ry * omega, y: rx * omega }; + const pose = bodyPose(item); + const vel = bodyVelocity(item); + const rx = num(x) - num(pose?.x, item.x); + const ry = num(y) - num(pose?.y, item.y); + const baseX = item.type === "poison_block" ? num(vel?.x, ps(item, "xv", 0)) : 0; + const baseY = item.type === "poison_block" ? num(vel?.y, ps(item, "yv", 0)) : 0; + return { x: baseX - ry * omega, y: baseY + rx * omega }; } if (item.type === "reciprocator") { const a = axis(item); @@ -123,7 +346,12 @@ const len = Math.max(4, Math.hypot(x2 - x1, y2 - y1)); const angle = Math.atan2(y2 - y1, x2 - x1); const halfW = len / 2; - const halfH = thickness(item) / 2; + // Keep mechanical collision close to the drawn stroke. Earlier large + // skins made rotators/reciprocators feel visually offset; tunneling is + // handled by the central substep pair pass instead of over-thick shapes. + const visualThickness = thickness(item); + const collisionSkin = item.type === "poison_block" ? 1.5 : 1.0; + const halfH = visualThickness / 2 + collisionSkin; const cx = (x1 + x2) / 2; const cy = (y1 + y2) / 2; const aabb = orientedRectAabb(cx, cy, halfW, halfH, angle); @@ -137,7 +365,19 @@ ...base, rotator: true, angularVelocity: signedDrive(item), - centerX: num(item.x), centerY: num(item.y), + centerX: num(bodyPose(item)?.x, item.x), centerY: num(bodyPose(item)?.y, item.y), + }; + } + if (item.type === "poison_block") { + return { + ...base, + poisonBlock: true, + passiveBlock: true, + restitution: 0.62, + motionVelocityX: num(bodyVelocity(item)?.x, item.vx), + motionVelocityY: num(bodyVelocity(item)?.y, item.vy), + angularVelocity: signedDrive(item), + centerX: num(bodyPose(item)?.x, item.x), centerY: num(bodyPose(item)?.y, item.y), }; } const a = axis(item); @@ -145,31 +385,128 @@ return { ...base, reciprocator: true, motionVelocityX: a.x * v, motionVelocityY: a.y * v, axisX: a.x, axisY: a.y }; } + function rectCacheKey(item, kind = "obstacle") { + const cache = geomCache(item); + const body = bodyOf(item); + const pose = body?.pose || {}; + const vel = body?.velocity || {}; + const motor = body?.motor || {}; + const rail = body?.rail || {}; + const collision = body?.collision || {}; + sanitizeSegments(item); + return [ + kind, + cache?.localKey || "", + item?.type || "", + shapeVersion(item), + num(pose.x, item?.x).toFixed(3), + num(pose.y, item?.y).toFixed(3), + itemAngle(item).toFixed(5), + thickness(item).toFixed(2), + collision.solid === false ? 0 : 1, + motor.powered === false ? 0 : 1, + num(motor.speed, ps(item, "motorSpeed", 0)).toFixed(4), + num(vel.angular, ps(item, "spin", 0)).toFixed(5), + motor.powered === false ? 0 : 1, + num(rail.axisAngle, itemAngle(item)).toFixed(5), + num(motor.speed, ps(item, "railMotorSpeed", 92)).toFixed(3), + num(motor.direction, 1), + num(vel.linear, ps(item, "slideSpeed", 0)).toFixed(3), + num(vel.x, item?.vx).toFixed(3), + num(vel.y, item?.vy).toFixed(3), + ].join("|"); + } + + function rectsFor(item, kind) { + const cache = geomCache(item); + const key = rectCacheKey(item, kind); + const prop = kind === "hazard" ? "hazardRects" : "obstacleRects"; + const keyProp = `${prop}Key`; + if (cache?.[keyProp] === key && cache[prop]) return cache[prop]; + const rects = worldSegments(item).map(seg => segmentRect(item, seg[0], seg[1], seg[2], seg[3])); + if (cache) { cache[keyProp] = key; cache[prop] = rects; } + return rects; + } + function obstacleRects(item) { if (!item || item.dead || !isMechanicalType(item)) return []; - return worldSegments(item).map(seg => segmentRect(item, seg[0], seg[1], seg[2], seg[3])); + if (item.type === "poison_block" && bodyOf(item)?.collision?.solid === false) return []; + return rectsFor(item, "obstacle"); + } + + function poisonHazardRects(item) { + if (!item || item.dead || item.type !== "poison_block") return []; + return rectsFor(item, "hazard"); + } + + function aabbFromRects(item, rects, kind = "obstacle") { + if (!rects || !rects.length) return null; + const cache = geomCache(item); + const key = `${kind}|${rectCacheKey(item, kind)}|aabb`; + const prop = kind === "hazard" ? "hazardAabb" : "obstacleAabb"; + const keyProp = `${prop}Key`; + if (cache?.[keyProp] === key && cache[prop]) return cache[prop]; + const out = { + left: Infinity, right: -Infinity, top: Infinity, bottom: -Infinity, + type: item.type, item, + }; + for (const r of rects) { + out.left = Math.min(out.left, r.left); + out.right = Math.max(out.right, r.right); + out.top = Math.min(out.top, r.top); + out.bottom = Math.max(out.bottom, r.bottom); + } + if (!Number.isFinite(out.left) || !Number.isFinite(out.right) || !Number.isFinite(out.top) || !Number.isFinite(out.bottom)) return null; + if (cache) { cache[keyProp] = key; cache[prop] = out; } + return out; } function boundsAabb(item) { - const rects = obstacleRects(item); - if (!rects.length) return null; - return { - left: Math.min(...rects.map(r => r.left)), right: Math.max(...rects.map(r => r.right)), - top: Math.min(...rects.map(r => r.top)), bottom: Math.max(...rects.map(r => r.bottom)), - type: item.type, item, - }; + return aabbFromRects(item, obstacleRects(item), "obstacle"); + } + + function hazardBoundsAabb(item) { + if (!item || item.dead) return null; + if (item.type === "poison_block") return aabbFromRects(item, poisonHazardRects(item), "hazard"); + return boundsAabb(item); } function reach(item) { if (!item) return 64; const base = Math.max(num(item.r, 64), extent(item)); - if (item.type === "reciprocator") return Math.max(90, base + num(item.reciprocatorTravel, 150) * 0.55); + if (item.type === "reciprocator") return Math.max(90, base + num(bodyRail(item)?.travel, ps(item, "railTravel", 150)) * 0.55); return base; } function applyImpulse(item, contactX, contactY, fx, fy, scale = 1) { if (!item || item.dead || !isMechanicalType(item)) return false; const powered = isPowered(item); + if (item.type === "poison_block") { + const body = ensureBody(item, { syncFromLegacy: false }); + if (!body) return false; + body.velocity = body.velocity || { x: 0, y: 0, angular: 0, linear: 0 }; + const vel = body.velocity; + const pose = body.pose || {}; + const mass = Math.max(1.2, ps(item, "mass", 4.8)); + const prevVx = num(vel.x, item.vx); + const prevVy = num(vel.y, item.vy); + const nextVx = clamp(prevVx + num(fx) * 0.42 * scale / mass, -260, 260); + const nextVy = clamp(prevVy + num(fy) * 0.42 * scale / mass, -260, 260); + vel.x = nextVx; + vel.y = nextVy; + // Keep the legacy Canvas scratch velocity in sync for non-physics helpers + // that still render effects from item.vx/vy, but physics reads body.velocity. + item.vx = nextVx; + item.vy = nextVy; + const rx = num(contactX) - num(pose.x, item.x); + const ry = num(contactY) - num(pose.y, item.y); + const torque = rx * num(fy) - ry * num(fx); + const inertia = Math.max(12000, ps(item, "inertia", 36000)); + vel.angular = clamp(num(vel.angular, ps(item, "spin", 0)) + torque / inertia * scale, -2.2, 2.2); + const changed = Math.hypot(nextVx - prevVx, nextVy - prevVy) > 0.002 || Math.abs(torque) > 0.001; + if (changed) { commitBody(item, "poison-block-impulse"); wakeItem(item, "poison-block-impulse"); } + return changed; + } if (item.type === "rotator") { const rx = num(contactX) - num(item.x); const ry = num(contactY) - num(item.y); @@ -178,20 +515,36 @@ const delta = clamp(torque / denom * scale, -0.46, 0.46); if (!Number.isFinite(delta) || Math.abs(delta) < 0.0008) return false; const limit = powered ? 3.2 : 2.25; - item.rotatorAngularVelocity = clamp(num(item.rotatorAngularVelocity) + delta, -limit, limit); + pset(item, "spin", clamp(num(bodyVelocity(item)?.angular, ps(item, "spin", 0)) + delta, -limit, limit), "rotator-torque"); + commitBody(item, "rotator-impulse"); return true; } const a = axis(item); const along = num(fx) * a.x + num(fy) * a.y; - const delta = clamp(along * (powered ? 0.28 : 0.52) * scale, -52, 52); - if (!Number.isFinite(delta) || Math.abs(delta) < 0.05) return false; - const limit = powered ? 260 : 150; - item.reciprocatorVelocity = clamp(num(item.reciprocatorVelocity) + delta, -limit, limit); + let delta = along * (powered ? 0.28 : 0.52) * scale; + if (powered) { + // A powered reciprocator should not lose its motor drive the instant it + // touches something. Contact impulses are kept as a small secondary + // slide component; hard physical blocking is handled by immediate contact reversal. + const driveDir = Math.sign(ps(item, "railDir", 1)) || 1; + const motorSpeed = Math.max(0, ps(item, "railMotorSpeed", 92)); + const current = num(bodyVelocity(item)?.linear, ps(item, "slideSpeed", 0)); + const opposing = Math.sign(delta || 0) === -driveDir && Math.abs(delta) > motorSpeed * 0.18; + delta = clamp(delta * (opposing ? 0.10 : 0.18), -14, 14); + const next = clamp(current * 0.62 + delta, -Math.max(18, motorSpeed * 0.32), Math.max(18, motorSpeed * 0.32)); + if (!Number.isFinite(next) || Math.abs(next - current) < 0.03) return false; + pset(item, "slideSpeed", next, "slide-contact-trim"); + } else { + delta = clamp(delta, -52, 52); + if (!Number.isFinite(delta) || Math.abs(delta) < 0.05) return false; + pset(item, "slideSpeed", clamp(num(bodyVelocity(item)?.linear, ps(item, "slideSpeed", 0)) + delta, -150, 150), "slide-impulse"); + } + commitBody(item, "reciprocator-impulse"); return true; } function applyPassiveReciprocatorImpulse(item, axisX, axisY, nx, ny, vx, vy, scale = 1) { - if (!item || item.reciprocatorPowered !== false) return false; + if (!item || isPowered(item)) return false; const ax = num(axisX, 1), ay = num(axisY, 0), nX = num(nx), nY = num(ny); const hitVx = num(vx), hitVy = num(vy); const incomingNormal = Math.max(0, -(hitVx * nX + hitVy * nY)); @@ -200,12 +553,13 @@ const axisDrive = Math.abs(axisVel) >= 3 ? axisVel : 0; const delta = clamp((axisDrive * 0.030 + normalDrive * 0.080) * scale, -24, 24); if (!Number.isFinite(delta) || Math.abs(delta) < 0.08) return false; - item.reciprocatorVelocity = clamp(num(item.reciprocatorVelocity) * 0.84 + delta, -125, 125); + pset(item, "slideSpeed", clamp(num(bodyVelocity(item)?.linear, ps(item, "slideSpeed", 0)) * 0.84 + delta, -125, 125), "passive-slide-impulse"); + commitBody(item, "passive-reciprocator-impulse"); return true; } function applyPassiveRotatorImpulse(item, rx, ry, nx, ny, vx, vy, scale = 1) { - if (!item || item.rotatorPowered !== false) return false; + if (!item || isPowered(item)) return false; const hitVx = num(vx), hitVy = num(vy), nX = num(nx), nY = num(ny); const arm2 = Math.max(4200, rx * rx + ry * ry); const tangential = (hitVx * -ry + hitVy * rx) / arm2; @@ -213,7 +567,8 @@ const normalTorque = ((-nX) * -ry + (-nY) * rx) / Math.max(28, Math.hypot(rx, ry)); const delta = clamp((tangential * 0.14 + normalTorque * incomingNormal * 0.0016) * scale, -0.18, 0.18); if (!Number.isFinite(delta) || Math.abs(delta) < 0.002) return false; - item.rotatorAngularVelocity = clamp(num(item.rotatorAngularVelocity) * 0.86 + delta, -1.75, 1.75); + pset(item, "spin", clamp(num(bodyVelocity(item)?.angular, ps(item, "spin", 0)) * 0.86 + delta, -1.75, 1.75), "passive-spin-impulse"); + commitBody(item, "passive-rotator-impulse"); return true; } @@ -253,6 +608,21 @@ obj.impulseVy = clamp(num(obj.impulseVy) + mvy * num(opts.impulseVScale), -num(opts.impulseMax, 320), num(opts.impulseMax, 320)); } applyPassiveReciprocatorImpulse(rect.item, rect.axisX || 1, rect.axisY || 0, nX, nY, vx, vy, num(opts.passiveImpulseScale, 1)); + reverseReciprocatorOnCircleContact(rect.item, obj, nX, nY, 0.016, obj.world || rect.item?.world || null, "circle"); + applied = true; + } else if (rect.poisonBlock) { + const omega = clamp(num(rect.angularVelocity), -5.5, 5.5); + const rx = num(obj.x) - num(rect.centerX, num(rect.item?.x, num(rect.cx))); + const ry = num(obj.y) - num(rect.centerY, num(rect.item?.y, num(rect.cy))); + const tvx = clamp(num(rect.motionVelocityX) - ry * omega, -420, 420); + const tvy = clamp(num(rect.motionVelocityY) + rx * omega, -420, 420); + obj.vx = clamp(num(obj.vx) * damping + tvx * surfaceScale + nX * normalBoost, -maxSpeed, maxSpeed); + obj.vy = clamp(num(obj.vy) * damping + tvy * surfaceScale + nY * normalBoost, -maxSpeed, maxSpeed); + if (opts.impulseVScale) { + obj.impulseVx = clamp(num(obj.impulseVx) + tvx * num(opts.impulseVScale), -num(opts.impulseMax, 320), num(opts.impulseMax, 320)); + obj.impulseVy = clamp(num(obj.impulseVy) + tvy * num(opts.impulseVScale), -num(opts.impulseMax, 320), num(opts.impulseMax, 320)); + } + applyImpulse(rect.item, obj.x, obj.y, -nX * (28 + Math.max(0, -(vx * nX + vy * nY)) * 0.42), -nY * (28 + Math.max(0, -(vx * nX + vy * nY)) * 0.42), num(opts.passiveImpulseScale, 1)); applied = true; } if (applied && Number.isFinite(obj.spinVelocity)) obj.spinVelocity = clamp(num(obj.spinVelocity) + (nX >= 0 ? -1 : 1) * num(opts.spinKick, 7.0), -46, 46); @@ -266,12 +636,302 @@ const push = Math.max(0, num(overlap)) * Math.max(0, num(scale)); const along = (-num(nx) * a.x + -num(ny) * a.y) * push; if (!Number.isFinite(along) || Math.abs(along) < 0.01) return false; - const before = num(item.reciprocatorPhase); - item.reciprocatorPhase = clamp(before + along / Math.max(10, halfTravel(item)), -1, 1); + const before = ps(item, "railPhase", 0); + pset(item, "railPhase", clamp(before + along / Math.max(10, halfTravel(item)), -1, 1), "rail-correction"); const p = positionFromPhase(item); item.x = p.x; item.y = p.y; - return Math.abs(item.reciprocatorPhase - before) > 0.0001; + const changed = Math.abs(ps(item, "railPhase", 0) - before) > 0.0001; + if (changed) commitBody(item, "rail-correction"); + return changed; + } + + function mechanicalSeparationWeight(item, nx, ny) { + if (!item || item.dead || item.playerHeld || item._heldByPlayer) return 0; + if (item.type === "poison_block") return 1 / Math.max(1.2, ps(item, "mass", 4.8)); + if (item.type === "reciprocator") { + const a = axis(item); + const projection = Math.abs(num(nx) * a.x + num(ny) * a.y); + // Reciprocators can only be separated along their rail. Side contacts are + // handled by impulse/brake, not by teleporting the rail body sideways. + return projection < 0.10 ? 0 : projection * projection * 0.72; + } + // Rotators are anchored motors. Translating them would make edited drawings + // drift away from their intended pivot, so they are solved via impulse/brake. + return 0; + } + + function moveMechanicalBodyForSeparation(item, dx, dy, dt = 0.016, reason = "mechanical-separation") { + if (!item || item.dead || item.playerHeld || item._heldByPlayer) return false; + if (!Number.isFinite(dx) || !Number.isFinite(dy) || Math.hypot(dx, dy) < 0.001) return false; + if (item.type === "poison_block") { + const beforeX = num(item.x), beforeY = num(item.y); + item.prevX = beforeX; + item.prevY = beforeY; + item.x = beforeX + dx; + item.y = beforeY + dy; + item._physicsExternalPoseDirty = true; + const len = Math.max(0.001, Math.hypot(dx, dy)); + const nx = dx / len, ny = dy / len; + const vx = ps(item, "xv", num(item.vx)); + const vy = ps(item, "yv", num(item.vy)); + const inward = vx * nx + vy * ny; + if (inward < 0) { + pset(item, "xv", vx - nx * inward * 0.58, `${reason}-normal-damp`); + pset(item, "yv", vy - ny * inward * 0.58, `${reason}-normal-damp`); + } + commitBody(item, reason); + wakeItem(item, reason); + return true; + } + if (item.type === "reciprocator") { + resetAnchor(item); + const a = axis(item); + const along = dx * a.x + dy * a.y; + if (!Number.isFinite(along) || Math.abs(along) < 0.001) return false; + const before = ps(item, "railPhase", 0); + pset(item, "railPhase", clamp(before + along / Math.max(10, halfTravel(item)), -1, 1), reason); + const p = positionFromPhase(item); + item.prevX = num(item.x); + item.prevY = num(item.y); + item.x = p.x; + item.y = p.y; + item._physicsExternalPoseDirty = true; + const current = ps(item, "slideSpeed", 0); + if (current * along < 0) pset(item, "slideSpeed", current * 0.38, `${reason}-slide-damp`); + const changed = Math.abs(ps(item, "railPhase", 0) - before) > 0.0001; + if (changed) commitBody(item, reason); + return changed; + } + return false; + } + + function applyMechanicalPairSeparation(a, b, info, dt, worldRef) { + if (!a || !b || !info) return false; + const overlap = Math.max(0, num(info.overlap)); + if (overlap <= 0.001) return false; + const nx = num(info.nx), ny = num(info.ny); + const wa = mechanicalSeparationWeight(a, -nx, -ny); + const wb = mechanicalSeparationWeight(b, nx, ny); + const sum = wa + wb; + if (sum <= 0.0001) return false; + // Solve most of the penetration as position, not velocity. Capping avoids + // large teleports when old saves spawn bodies deeply overlapped. + const correction = Math.min(overlap + 0.35, Math.max(2.2, Math.min(18, overlap * 0.82))); + const ax = -nx * correction * (wa / sum); + const ay = -ny * correction * (wa / sum); + const bx = nx * correction * (wb / sum); + const by = ny * correction * (wb / sum); + const movedA = moveMechanicalBodyForSeparation(a, ax, ay, dt, "pair-separation"); + const movedB = moveMechanicalBodyForSeparation(b, bx, by, dt, "pair-separation"); + if (movedA || movedB) { + worldRef?.markSpatialDirty?.("mechanical-pair-separation"); + worldRef && (worldRef.drawListDirty = true); + } + return movedA || movedB; + } + + function applyMechanicalFenceSeparation(item, info, dt, worldRef) { + if (!item || !info) return false; + const overlap = Math.max(0, num(info.overlap)); + if (overlap <= 0.001) return false; + const correction = Math.min(overlap + 0.4, Math.max(2.5, Math.min(20, overlap * 0.88))); + const moved = moveMechanicalBodyForSeparation(item, -num(info.nx) * correction, -num(info.ny) * correction, dt, "fence-separation"); + if (moved) { + worldRef?.markSpatialDirty?.("mechanical-fence-separation"); + worldRef && (worldRef.drawListDirty = true); + } + return moved; + } + + + function noteReciprocatorBlocked(item, dt = 0.016, worldRef = null, reason = "obstacle") { + if (!item || item.dead || item.type !== "reciprocator" || !isPowered(item)) return false; + const now = Number(worldRef?.time || item.world?.time || 0) || 0; + const step = Math.max(0.012, Math.min(0.08, num(dt, 0.016))); + if ((item._reciprocatorBlockedAt || -999) + 0.18 < now) item._reciprocatorBlockedTimer = 0; + item._reciprocatorBlockedAt = now; + item._reciprocatorBlockedTimer = Math.min(1.2, num(item._reciprocatorBlockedTimer) + step); + const cooldownOk = (item._reciprocatorAutoReverseAt || -999) + 0.42 <= now; + if (!cooldownOk || item._reciprocatorBlockedTimer < 0.34) return false; + const before = Math.sign(ps(item, "railDir", 1)) || 1; + pset(item, "railDir", -before, "slide-reverse"); + const body = ensureBody(item, { syncFromLegacy: false }); + if (body) { + body.motor = body.motor || {}; + body.motor.direction = ps(item, "railDir", 1); + body.velocity = body.velocity || {}; + body.velocity.linear = -Math.abs(num(body.velocity.linear, ps(item, "slideSpeed", 0))) * before * 0.20; + } + pset(item, "slideSpeed", -Math.abs(ps(item, "slideSpeed", 0)) * before * 0.20, "slide-reverse-passive"); + item._reciprocatorBlockedTimer = 0; + item._reciprocatorAutoReverseAt = now; + commitBody(item, `reciprocator-auto-reverse:${reason}`); + worldRef?.markSpatialDirty?.("reciprocator-auto-reverse"); + worldRef && (worldRef.drawListDirty = true); + return true; + } + + function reverseReciprocatorOnMechanicalContact(item, nx, ny, dt = 0.016, worldRef = null, reason = "mechanical-contact") { + if (!item || item.dead || item.type !== "reciprocator" || !isPowered(item)) return false; + const now = Number(worldRef?.time || item.world?.time || 0) || 0; + // Only physical body contacts should interrupt the rail. Static fences and + // ordinary obstacles never call this function. + if ((item._reciprocatorContactReverseAt || -999) + 0.12 > now) return false; + const a = axis(item); + const dir = Math.sign(ps(item, "railDir", 1)) || 1; + const driveX = a.x * dir; + const driveY = a.y * dir; + const ahead = driveX * num(nx) + driveY * num(ny); + // Side brushes are allowed to slide. A direct or moderately diagonal + // contact means the powered block has hit a physical object in its path. + if (ahead < -0.18) return false; + pset(item, "railDir", -dir, "slide-contact-reverse"); + pset(item, "slideSpeed", 0, "slide-contact-clear-slide"); + const body = ensureBody(item, { syncFromLegacy: false }); + if (body) { + body.motor = body.motor || {}; + body.motor.direction = -dir; + body.velocity = body.velocity || {}; + body.velocity.linear = 0; + } + item._reciprocatorBlockedTimer = 0; + item._reciprocatorContactReverseAt = now; + item._reciprocatorAutoReverseAt = now; + commitBody(item, `reciprocator-contact-reverse:${reason}`); + if (worldRef) { + worldRef.drawListDirty = true; + worldRef.markSpatialDirty?.("reciprocator-contact-reverse"); + } + return true; + } + + function brakePoweredRotatorOnMechanicalContact(item, dt = 0.016, worldRef = null, reason = "mechanical-contact") { + if (!item || item.dead || item.type !== "rotator" || !isPowered(item)) return false; + const now = Number(worldRef?.time || item.world?.time || 0) || 0; + if ((item._rotatorContactBrakeAt || -999) + 0.045 > now) return false; + const motorSpeed = Math.max(0, ps(item, "motorSpeed", 0)); + if (motorSpeed <= 0) return false; + const drive = signedDrive(item); + const sign = Math.sign(drive || motorSpeed) || 1; + const current = num(bodyVelocity(item)?.angular, ps(item, "spin", 0)); + // A powered rotator has a motor, so a contact must produce a temporary + // counter-spin; otherwise two powered rotators visually pass through each + // other while the motor keeps driving at full speed. + const brake = Math.max(0.32, motorSpeed * 0.82); + pset(item, "spin", clamp(current - sign * brake, -3.4, 3.4), "rotator-contact-brake"); + item._rotatorContactBrakeAt = now; + commitBody(item, `rotator-contact-brake:${reason}`); + if (worldRef) { + worldRef.drawListDirty = true; + worldRef.markSpatialDirty?.("rotator-contact-brake"); + } + return true; + } + + function isPhysicalCircleContactObject(obj) { + if (!obj || obj.dead) return false; + const t = String(obj.type || ""); + // Tarinai also collide as circles, but a walking creature should not flip a + // powered rail. Keep contact reversal to item-like physical circles. + if (t === "ball" || t === "stone" || t === "genkotsu" || t === "firecracker" || t === "pushpin" || t === "oshibyo") return true; + return Boolean(obj.physicsBody && !isMechanicalType(t)); + } + + function itemCircleRadius(obj) { + if (!obj) return 12; + const fromHelper = typeof global.itemRadiusFor === "function" ? global.itemRadiusFor(obj.type, obj.r || obj.radius || 12) : null; + return Math.max(4, num(obj.radius, num(obj.r, Number.isFinite(Number(fromHelper)) ? Number(fromHelper) : 12))); + } + + function orientedRectCircleContactInfo(rect, obj, radius, margin = 0.5) { + if (!rect || !obj) return null; + const r = Math.max(0, num(radius)); + const m = Math.max(0, num(margin)); + const cx = num(obj.x); + const cy = num(obj.y); + if (!rect.oriented) { + const px = Math.max(num(rect.left) - m, Math.min(num(rect.right) + m, cx)); + const py = Math.max(num(rect.top) - m, Math.min(num(rect.bottom) + m, cy)); + let dx = cx - px; + let dy = cy - py; + let d = Math.hypot(dx, dy); + if (d >= r + m) return null; + if (d < 0.001) { dx = cx - num(rect.cx, (num(rect.left) + num(rect.right)) * 0.5); dy = cy - num(rect.cy, (num(rect.top) + num(rect.bottom)) * 0.5); d = Math.hypot(dx, dy) || 1; } + return { nx: dx / d, ny: dy / d, x: px, y: py, overlap: Math.max(0, r + m - d) }; + } + const c = Number.isFinite(rect.cos) ? rect.cos : Math.cos(num(rect.angle)); + const ss = Number.isFinite(rect.sin) ? rect.sin : Math.sin(num(rect.angle)); + const dxw = cx - num(rect.cx); + const dyw = cy - num(rect.cy); + const lx = dxw * c + dyw * ss; + const ly = -dxw * ss + dyw * c; + const qx = Math.max(-num(rect.halfW) - m, Math.min(num(rect.halfW) + m, lx)); + const qy = Math.max(-num(rect.halfH) - m, Math.min(num(rect.halfH) + m, ly)); + let dx = lx - qx; + let dy = ly - qy; + let d = Math.hypot(dx, dy); + if (d >= r + m) return null; + if (d < 0.001) { + const left = Math.abs(lx + num(rect.halfW)); + const right = Math.abs(num(rect.halfW) - lx); + const top = Math.abs(ly + num(rect.halfH)); + const bottom = Math.abs(num(rect.halfH) - ly); + const minSide = Math.min(left, right, top, bottom); + if (minSide === left) { dx = -1; dy = 0; } + else if (minSide === right) { dx = 1; dy = 0; } + else if (minSide === top) { dx = 0; dy = -1; } + else { dx = 0; dy = 1; } + d = 1; + } + const nx = dx / d * c - dy / d * ss; + const ny = dx / d * ss + dy / d * c; + const wx = num(rect.cx) + qx * c - qy * ss; + const wy = num(rect.cy) + qx * ss + qy * c; + return { nx, ny, x: wx, y: wy, overlap: Math.max(0, r + m - d) }; + } + + function resolveReciprocatorPhysicalContacts(item, dt = 0.016, worldRef = null) { + if (!item || item.dead || item.type !== "reciprocator" || !isPowered(item) || !worldRef?.items) return false; + const rects = obstacleRects(item); + if (!rects.length) return false; + const radius = reach(item) + 90; + const source = worldRef.nearbyItems?.(item.x, item.y, radius, true) || worldRef.nearbyObstacles?.(item.x, item.y, radius, false) || worldRef.items || []; + const a = axis(item); + const dir = Math.sign(ps(item, "railDir", 1)) || 1; + const driveX = a.x * dir; + const driveY = a.y * dir; + let best = null; + for (const other of source) { + if (!other || other === item || other.dead || !isPhysicalCircleContactObject(other)) continue; + const rr = itemCircleRadius(other); + for (const rect of rects) { + const info = orientedRectCircleContactInfo(rect, other, rr, 1.25); + if (!info) continue; + const aheadNormal = driveX * info.nx + driveY * info.ny; + const aheadCenter = (num(other.x) - num(item.x)) * driveX + (num(other.y) - num(item.y)) * driveY; + // A reciprocator is a moving bar. When a circle is centered on the + // bar stroke, the geometric normal often points sideways, so use the + // rail-direction center test as the authoritative "hit the front" test. + if (aheadNormal < 0.16 && aheadCenter < -rr * 0.35) continue; + const contactInfo = aheadNormal >= 0.16 ? info : { ...info, nx: driveX, ny: driveY }; + const score = Math.max(aheadNormal, 0) * 1000 + Math.max(0, aheadCenter) + num(info.overlap); + if (!best || score > best.score) best = { other, info: contactInfo, score }; + } + } + if (!best) return false; + const changed = reverseReciprocatorOnMechanicalContact(item, best.info.nx, best.info.ny, dt, worldRef, "physical-item"); + if (changed) { + item._reciprocatorBlockedTimer = 0; + worldRef.markSpatialDirty?.("reciprocator-physical-contact"); + worldRef.drawListDirty = true; + } + return changed; + } + + function reverseReciprocatorOnCircleContact(item, obj, nx, ny, dt = 0.016, worldRef = null, reason = "circle-contact") { + if (!isPhysicalCircleContactObject(obj)) return false; + return reverseReciprocatorOnMechanicalContact(item, nx, ny, dt, worldRef, reason); } function strongestRectContact(rectsA, rectsB, padding = 0) { @@ -288,9 +948,24 @@ return best; } + function pairFrameGuard(a, b, worldRef, budget = 6) { + if (!worldRef || !a || !b) return false; + const frame = Number(worldRef.frameCount || worldRef.tickCount || worldRef._frameId || 0) || Math.floor((Number(worldRef.time || 0) || 0) * 60); + if (worldRef._mechanicalPairGuardFrame !== frame) { + worldRef._mechanicalPairGuardFrame = frame; + worldRef._mechanicalPairFrameAt = Object.create(null); + } + const key = a.id < b.id ? `${a.id}:${b.id}` : `${b.id}:${a.id}`; + const packed = worldRef._mechanicalPairFrameAt[key]; + if (packed && packed.count >= budget) return true; + worldRef._mechanicalPairFrameAt[key] = { count: packed ? packed.count + 1 : 1 }; + return false; + } + function resolvePair(a, b, dt, worldRef) { if (!a || !b || a.dead || b.dead || !isMechanicalType(a) || !isMechanicalType(b)) return false; - const contact = strongestRectContact(obstacleRects(a), obstacleRects(b), 0.75); + if (pairFrameGuard(a, b, worldRef, 6)) return false; + let contact = strongestRectContact(obstacleRects(a), obstacleRects(b), 0.75); if (!contact) return false; const now = worldRef?.time || 0; const key = a.id < b.id ? `${a.id}:${b.id}` : `${b.id}:${a.id}`; @@ -298,18 +973,33 @@ const recentlyHandled = (worldRef._mechanicalContactAt[key] || -999) + 0.045 > now; worldRef._mechanicalContactAt[key] = now; - const { info } = contact; + let info = contact.info; + const separated = applyMechanicalPairSeparation(a, b, info, dt, worldRef); + if (separated) { + // Position projection may fully clear the overlap. Re-query before adding + // impulses; otherwise the solver turns a successful separation into a new kick. + contact = strongestRectContact(obstacleRects(a), obstacleRects(b), 0.25); + if (!contact) { + if (worldRef) { worldRef.drawListDirty = true; worldRef.markSpatialDirty?.("mechanical-contact-separated"); } + return true; + } + info = contact.info; + } const va = pointVelocity(a, info.x, info.y); const vb = pointVelocity(b, info.x, info.y); const relClosing = (va.x - vb.x) * info.nx + (va.y - vb.y) * info.ny; const closing = Math.max(0, relClosing); - const powerMul = isPowered(a) && isPowered(b) ? 1.35 : (isPowered(a) || isPowered(b) ? 1.12 : 0.82); - const impulse = clamp((closing * 0.18 + info.overlap * 6.4 + 5.0) * powerMul, 4, 82); + const powerMul = isPowered(a) && isPowered(b) ? 1.18 : (isPowered(a) || isPowered(b) ? 1.02 : 0.72); + const impulse = clamp((closing * 0.14 + info.overlap * 4.7 + 3.2) * powerMul, 2.5, separated ? 42 : 68); - applyImpulse(a, info.x, info.y, -info.nx * impulse, -info.ny * impulse, 1.0); - applyImpulse(b, info.x, info.y, info.nx * impulse, info.ny * impulse, 1.0); + applyImpulse(a, info.x, info.y, -info.nx * impulse, -info.ny * impulse, separated ? 0.78 : 1.0); + applyImpulse(b, info.x, info.y, info.nx * impulse, info.ny * impulse, separated ? 0.78 : 1.0); applyRailCorrection(a, info.nx, info.ny, info.overlap, b.type === "reciprocator" ? 0.42 : 0.72); applyRailCorrection(b, -info.nx, -info.ny, info.overlap, a.type === "reciprocator" ? 0.42 : 0.72); + if (a.type === "reciprocator") reverseReciprocatorOnMechanicalContact(a, info.nx, info.ny, dt, worldRef, "mechanical"); + if (b.type === "reciprocator") reverseReciprocatorOnMechanicalContact(b, -info.nx, -info.ny, dt, worldRef, "mechanical"); + if (a.type === "rotator") brakePoweredRotatorOnMechanicalContact(a, dt, worldRef, "mechanical"); + if (b.type === "rotator") brakePoweredRotatorOnMechanicalContact(b, dt, worldRef, "mechanical"); if (!recentlyHandled) worldRef?.effects?.push(new Effect("ring", info.x, info.y, { size: Math.max(14, Math.min(28, 12 + info.overlap)), life: 0.14, color: "rgba(184,132,230,0.34)" })); if (worldRef) { worldRef.drawListDirty = true; worldRef.markSpatialDirty?.("mechanical-contact"); } @@ -330,19 +1020,28 @@ function resolveFenceContacts(item, dt, worldRef, radius = null) { if (!item || !worldRef || !isMechanicalType(item)) return false; + // 往復体の停止・反転対象は物理アイテムに限定する。 + // 静的な柵/障害物はレール駆動を殺しやすいため、往復体側ではここで扱わない。 + if (item.type === "reciprocator") return false; const rects = obstacleRects(item); const fences = fenceRectsNear(item, worldRef, radius || reach(item) + 80); let changed = false; const now = worldRef.time || 0; for (const mechRect of rects) { for (const fenceRect of fences) { - const info = FP().rectOverlapInfo?.(mechRect, fenceRect, 0.5); + let info = FP().rectOverlapInfo?.(mechRect, fenceRect, 0.5); if (!info) continue; + const separated = applyMechanicalFenceSeparation(item, info, dt, worldRef); + if (separated) { + const post = strongestRectContact(obstacleRects(item), [fenceRect], 0.2); + if (!post) { changed = true; continue; } + info = post.info; + } const v = pointVelocity(item, info.x, info.y); const closing = Math.max(0, v.x * info.nx + v.y * info.ny); - const impulse = clamp(closing * 0.26 + info.overlap * 9.5 + 8.0, 6, 112); - applyImpulse(item, info.x, info.y, -info.nx * impulse, -info.ny * impulse, 1.25); - applyRailCorrection(item, info.nx, info.ny, info.overlap, 0.95); + 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); const key = `f:${item.id}:${fenceRect.item?.id || "?"}`; worldRef._mechanicalContactAt = worldRef._mechanicalContactAt || Object.create(null); if ((worldRef._mechanicalContactAt[key] || -999) + 0.08 <= now) { @@ -356,11 +1055,27 @@ return changed; } + function hasInteractionCandidates(item, worldRef, radius = null) { + if (!item || !worldRef) return false; + const r = radius || reach(item) + 80; + const source = worldRef.nearbyObstacles?.(item.x, item.y, r, false) || worldRef.nearbyItems?.(item.x, item.y, r, true) || worldRef.items || []; + for (const other of source) { + if (!other || other === item || other.dead) continue; + if (item.type === "reciprocator") { + if (isMechanicalType(other)) return true; + continue; + } + if (isMechanicalType(other) || worldRef.isFenceType?.(other.type)) return true; + } + return false; + } + function resolveInteractions(item, dt, worldRef) { if (!item || !worldRef?.items || !isMechanicalType(item)) return false; let changed = false; const radius = reach(item) + 80; - for (const other of worldRef.nearbyItems?.(item.x, item.y, radius, true) || worldRef.items) { + const source = worldRef.nearbyObstacles?.(item.x, item.y, radius, false) || worldRef.nearbyItems?.(item.x, item.y, radius, true) || worldRef.items; + for (const other of source) { if (!other || other === item || other.dead || !isMechanicalType(other)) continue; changed = resolvePair(item, other, dt, worldRef) || changed; } @@ -368,90 +1083,643 @@ return changed; } - function updateRotator(item, dt, worldRef) { + function updateRotator(item, dt, worldRef, opts = {}) { if (!item || item.dead || item.type !== "rotator") return false; - item.amount = 999; - if (item.playerHeld || item._heldByPlayer) return false; - const powered = isPowered(item); - const passiveSpeed = num(item.rotatorAngularVelocity); - const speed = (powered ? num(item.rotatorSpeed) : 0) + passiveSpeed; - item.rotatorAngularVelocity = passiveSpeed * Math.pow(powered ? 0.18 : 0.88, Math.max(0.016, dt || 0.016)); - if (Math.abs(item.rotatorAngularVelocity) < 0.004) item.rotatorAngularVelocity = 0; - const ext = extent(item); - item.r = Math.max(item.r || 64, Math.min(460, ext)); - if (!speed || !Number.isFinite(speed)) return resolveInteractions(item, dt, worldRef); - const totalDelta = speed * dt; - const maxAngleStep = clamp(thickness(item) / Math.max(90, ext) * 0.75, 0.018, 0.055); - const steps = Math.max(1, Math.min(36, Math.ceil(Math.abs(totalDelta) / maxAngleStep))); - const stepDelta = totalDelta / steps; - const stepDt = Math.max(0.001, (dt || 0.016) / steps); - for (let i = 0; i < steps; i += 1) { - item.prevAngle = num(item.angle); - item.angle = normalizeAngle(num(item.angle) + stepDelta); - resolveInteractions(item, stepDt, worldRef); + applyBodyState(item); + const prevScratch = item._physicsStepScratch === true; + item._physicsStepScratch = true; + try { + item.amount = 999; + if (item.playerHeld || item._heldByPlayer) return false; + const powered = isPowered(item); + const passiveSpeed = ps(item, "spin", 0); + const speed = (powered ? ps(item, "motorSpeed", 0) : 0) + passiveSpeed; + pset(item, "spin", passiveSpeed * Math.pow(powered ? 0.18 : 0.88, Math.max(0.016, dt || 0.016)), "spin-friction"); + if (Math.abs(ps(item, "spin", 0)) < 0.004) pset(item, "spin", 0, "spin-stop"); + const ext = extent(item); + item.r = Math.max(item.r || 64, Math.min(460, ext)); + const skipInteractions = opts.skipInteractions === true || opts.centralStep === true; + if (!speed || !Number.isFinite(speed)) return (!skipInteractions && hasInteractionCandidates(item, worldRef, ext + 90)) ? resolveInteractions(item, dt, worldRef) : false; + const totalDelta = speed * dt; + if (skipInteractions || !hasInteractionCandidates(item, worldRef, ext + 100)) { + item.prevAngle = num(item.angle); + item.angle = normalizeAngle(num(item.angle) + totalDelta); + item.rotatorSpinPhase = num(item.rotatorSpinPhase) + Math.abs(speed) * dt; + if (worldRef && opts.deferDirty !== true) { worldRef.drawListDirty = true; worldRef.markSpatialDirty?.("rotator-spin-fast"); } + return true; + } + const maxAngleStep = clamp(thickness(item) / Math.max(90, ext) * 0.75, 0.018, 0.055); + const steps = Math.max(1, Math.min(36, Math.ceil(Math.abs(totalDelta) / maxAngleStep))); + const stepDelta = totalDelta / steps; + const stepDt = Math.max(0.001, (dt || 0.016) / steps); + for (let i = 0; i < steps; i += 1) { + item.prevAngle = num(item.angle); + item.angle = normalizeAngle(num(item.angle) + stepDelta); + if (!skipInteractions) resolveInteractions(item, stepDt, worldRef); + } + item.rotatorSpinPhase = num(item.rotatorSpinPhase) + Math.abs(speed) * dt; + if (worldRef && opts.deferDirty !== true) { worldRef.drawListDirty = true; worldRef.markSpatialDirty?.("rotator-spin"); } + return true; + } finally { + item._physicsStepScratch = prevScratch; + commitBody(item, "rotator-step"); } - item.rotatorSpinPhase = num(item.rotatorSpinPhase) + Math.abs(speed) * dt; - if (worldRef) { worldRef.drawListDirty = true; worldRef.markSpatialDirty?.("rotator-spin"); } + } + + function integrateReciprocatorPhase(item, total, stepDt, powered, reason = "rail-integrate") { + if (!item || item.dead || item.type !== "reciprocator") return false; + const travel = Math.max(10, halfTravel(item)); + const before = ps(item, "railPhase", 0); + const step = Math.max(0.001, num(stepDt, 0.016)); + const drive = num(total); + const raw = before + drive * step / travel; + if (!Number.isFinite(raw)) return false; + const eps = 0.000001; + if (powered && ((before >= 1 - eps && drive > 0) || raw >= 1)) { + pset(item, "railPhase", 1, "rail-limit"); + pset(item, "railDir", -1, "rail-limit-reverse"); + pset(item, "slideSpeed", 0, "rail-limit-clear-slide"); + return true; + } + if (powered && ((before <= -1 + eps && drive < 0) || raw <= -1)) { + pset(item, "railPhase", -1, "rail-limit"); + pset(item, "railDir", 1, "rail-limit-reverse"); + pset(item, "slideSpeed", 0, "rail-limit-clear-slide"); + return true; + } + if (raw > 1) { + pset(item, "railPhase", 1, "rail-limit"); + if (!powered) pset(item, "slideSpeed", -Math.abs(drive) * 0.20, "rail-limit-bounce"); + return Math.abs(before - 1) > 0.0001; + } + if (raw < -1) { + pset(item, "railPhase", -1, "rail-limit"); + if (!powered) pset(item, "slideSpeed", Math.abs(drive) * 0.20, "rail-limit-bounce"); + return Math.abs(before + 1) > 0.0001; + } + pset(item, "railPhase", raw, reason); + return Math.abs(raw - before) > 0.0001; + } + + function repairPoweredReciprocatorStall(item, dt = 0.016, moved = 0, worldRef = null) { + if (!item || item.dead || item.type !== "reciprocator" || !isPowered(item)) return false; + const motorSpeed = Math.max(0, ps(item, "railMotorSpeed", 92)); + if (motorSpeed <= 0.001) return false; + const phase = ps(item, "railPhase", 0); + const dir = Math.sign(ps(item, "railDir", 1)) || 1; + let changed = false; + if (phase >= 1 - 0.000001 && dir > 0) { pset(item, "railDir", -1, "rail-stall-limit-repair"); changed = true; } + else if (phase <= -1 + 0.000001 && dir < 0) { pset(item, "railDir", 1, "rail-stall-limit-repair"); changed = true; } + if (Math.abs(num(moved)) <= 0.001) { + const now = Number(worldRef?.time || item.world?.time || 0) || 0; + if ((item._reciprocatorMovedAt || -999) + 0.18 < now) { + // The motor is powered but the pose did not change for several frames. + // Reset only rail-local transient state; do not touch user speed/travel. + pset(item, "slideSpeed", 0, "rail-stall-slide-clear"); + if (!changed && Math.abs(phase) < 1 - 0.000001) { + integrateReciprocatorPhase(item, dir * motorSpeed, dt, true, "rail-stall-nudge"); + changed = true; + } + } + } else { + item._reciprocatorMovedAt = Number(worldRef?.time || item.world?.time || 0) || 0; + } + if (!changed) return false; + const p = positionFromPhase(item); + item.x = p.x; + item.y = p.y; + commitBody(item, "reciprocator-stall-repair"); + worldRef?.markSpatialDirty?.("reciprocator-stall-repair"); + if (worldRef) worldRef.drawListDirty = true; return true; } - function updateReciprocator(item, dt, worldRef) { + function updateReciprocator(item, dt, worldRef, opts = {}) { if (!item || item.dead || item.type !== "reciprocator") return false; - item.amount = 999; - resetAnchor(item); - if (item.playerHeld || item._heldByPlayer) { item.prevX = item.x; item.prevY = item.y; return false; } - const powered = isPowered(item); - const travel = halfTravel(item); - const prevX = num(item.x, item.reciprocatorAnchorX || 0), prevY = num(item.y, item.reciprocatorAnchorY || 0); - item.prevX = prevX; item.prevY = prevY; - const baseSpeed = Math.max(0, num(item.reciprocatorSpeed, 92)); - const estimateVelocity = (powered ? Math.sign(num(item.reciprocatorDirection, 1)) * baseSpeed : 0) + num(item.reciprocatorVelocity); - const steps = Math.max(1, Math.min(32, Math.ceil(Math.abs(estimateVelocity * dt) / 8))); - const stepDt = Math.max(0.001, (dt || 0.016) / steps); - for (let i = 0; i < steps; i += 1) { - const dir = Math.sign(num(item.reciprocatorDirection, 1)) || 1; - const passive = num(item.reciprocatorVelocity); - const total = (powered ? dir * baseSpeed : 0) + passive; - if (Number.isFinite(total) && Math.abs(total) > 0.001) { - item.reciprocatorPhase = num(item.reciprocatorPhase) + total * stepDt / Math.max(10, travel); - if (item.reciprocatorPhase > 1) { - item.reciprocatorPhase = 1; - if (powered && total > 0) item.reciprocatorDirection = -1; - item.reciprocatorVelocity = passive > 0 ? -Math.abs(passive) * 0.28 : (!powered ? -Math.abs(total) * 0.20 : item.reciprocatorVelocity); - } - if (item.reciprocatorPhase < -1) { - item.reciprocatorPhase = -1; - if (powered && total < 0) item.reciprocatorDirection = 1; - item.reciprocatorVelocity = passive < 0 ? Math.abs(passive) * 0.28 : (!powered ? Math.abs(total) * 0.20 : item.reciprocatorVelocity); + applyBodyState(item); + const prevScratch = item._physicsStepScratch === true; + item._physicsStepScratch = true; + try { + item.amount = 999; + resetAnchor(item); + if (item.playerHeld || item._heldByPlayer) { item.prevX = item.x; item.prevY = item.y; return false; } + const powered = isPowered(item); + const travel = halfTravel(item); + const prevX = num(item.x, ps(item, "railAnchorX", 0)), prevY = num(item.y, ps(item, "railAnchorY", 0)); + item.prevX = prevX; item.prevY = prevY; + const baseSpeed = Math.max(0, ps(item, "railMotorSpeed", 92)); + const estimateVelocity = (powered ? Math.sign(ps(item, "railDir", 1)) * baseSpeed : 0) + ps(item, "slideSpeed", 0); + const skipInteractions = opts.skipInteractions === true || opts.centralStep === true; + const steps = Math.max(1, Math.min(32, Math.ceil(Math.abs(estimateVelocity * dt) / 8))); + const stepDt = Math.max(0.001, (dt || 0.016) / steps); + if (Math.abs(estimateVelocity) > 0.001 && (skipInteractions || !hasInteractionCandidates(item, worldRef, reach(item) + 110))) { + const dir = Math.sign(ps(item, "railDir", 1)) || 1; + const passive = ps(item, "slideSpeed", 0); + const total = (powered ? dir * baseSpeed : 0) + passive; + integrateReciprocatorPhase(item, total, dt || 0.016, powered, "rail-integrate"); + const p = positionFromPhase(item); + item.x = p.x; item.y = p.y; + if (powered) resolveReciprocatorPhysicalContacts(item, dt || 0.016, worldRef); + } else for (let i = 0; i < steps; i += 1) { + const dir = Math.sign(ps(item, "railDir", 1)) || 1; + const passive = ps(item, "slideSpeed", 0); + const total = (powered ? dir * baseSpeed : 0) + passive; + if (Number.isFinite(total) && Math.abs(total) > 0.001) { + integrateReciprocatorPhase(item, total, stepDt, powered, "rail-integrate"); } + const p = positionFromPhase(item); + item.x = p.x; item.y = p.y; + if (powered) resolveReciprocatorPhysicalContacts(item, stepDt, worldRef); + if (!skipInteractions) resolveInteractions(item, stepDt, worldRef); } - const p = positionFromPhase(item); - item.x = p.x; item.y = p.y; - resolveInteractions(item, stepDt, worldRef); + pset(item, "slideSpeed", ps(item, "slideSpeed", 0) * Math.pow(powered ? 0.22 : 0.90, Math.max(0.016, dt || 0.016)), "slide-friction"); + if (Math.abs(ps(item, "slideSpeed", 0)) < 0.08) pset(item, "slideSpeed", 0, "slide-stop"); + let moved = Math.hypot(num(item.x) - prevX, num(item.y) - prevY); + if (repairPoweredReciprocatorStall(item, dt || 0.016, moved, worldRef)) moved = Math.hypot(num(item.x) - prevX, num(item.y) - prevY); + if (moved > 0.001 || Math.abs(ps(item, "slideSpeed", 0)) > 0.001 || powered) { + if (worldRef && opts.deferDirty !== true) { worldRef.drawListDirty = true; worldRef.markSpatialDirty?.("reciprocator-motion"); } + return true; + } + return false; + } finally { + item._physicsStepScratch = prevScratch; + commitBody(item, "reciprocator-step"); } - item.reciprocatorVelocity = num(item.reciprocatorVelocity) * Math.pow(powered ? 0.22 : 0.90, Math.max(0.016, dt || 0.016)); - if (Math.abs(item.reciprocatorVelocity) < 0.08) item.reciprocatorVelocity = 0; - const moved = Math.hypot(num(item.x) - prevX, num(item.y) - prevY); - if (moved > 0.001 || Math.abs(item.reciprocatorVelocity) > 0.001) { - if (worldRef) { worldRef.drawListDirty = true; worldRef.markSpatialDirty?.("reciprocator-motion"); } - return true; + } + + function constrainPassiveBlockInsideWorld(item, worldRef) { + if (!item || !worldRef) return false; + const pad = Number(global.CONFIG?.worldPadding || 16) || 16; + const reachValue = Math.max(24, reach(item)); + let changed = false; + if (num(item.x) < pad + reachValue * 0.25) { item.x = pad + reachValue * 0.25; pset(item, "xv", Math.abs(ps(item, "xv", 0)) * 0.38, "world-boundary"); changed = true; } + if (num(item.y) < pad + reachValue * 0.25) { item.y = pad + reachValue * 0.25; pset(item, "yv", Math.abs(ps(item, "yv", 0)) * 0.38, "world-boundary"); changed = true; } + if (num(item.x) > num(worldRef.w, 1000) - pad - reachValue * 0.25) { item.x = num(worldRef.w, 1000) - pad - reachValue * 0.25; pset(item, "xv", -Math.abs(ps(item, "xv", 0)) * 0.38, "world-boundary"); changed = true; } + if (num(item.y) > num(worldRef.h, 800) - pad - reachValue * 0.25) { item.y = num(worldRef.h, 800) - pad - reachValue * 0.25; pset(item, "yv", -Math.abs(ps(item, "yv", 0)) * 0.38, "world-boundary"); changed = true; } + return changed; + } + + function updatePoisonBlock(item, dt, worldRef, opts = {}) { + if (!item || item.dead || item.type !== "poison_block") return false; + applyBodyState(item); + const prevScratch = item._physicsStepScratch === true; + item._physicsStepScratch = true; + try { + item.amount = 999; + item.r = Math.max(item.r || 64, Math.min(460, extent(item))); + if (item.playerHeld || item._heldByPlayer) { + item.prevX = item.x; + item.prevY = item.y; + item.prevAngle = num(item.angle); + return false; + } + const stepTime = Math.max(0.001, Math.min(0.05, num(dt, 0.016))); + if (!passiveItemAwake(item)) return false; + const vx = ps(item, "xv", 0), vy = ps(item, "yv", 0), omega = ps(item, "spin", 0); + const moveEstimate = Math.hypot(vx, vy) * stepTime; + const rotEstimate = Math.abs(omega) * stepTime * Math.max(48, item.r || 64); + const skipInteractions = opts.skipInteractions === true || opts.centralStep === true; + const hasContacts = !skipInteractions && ps(item, "solid", true) !== false && hasInteractionCandidates(item, worldRef, reach(item) + 110); + const steps = hasContacts ? Math.max(1, Math.min(28, Math.ceil(Math.max(moveEstimate, rotEstimate) / 8))) : 1; + const subDt = stepTime / steps; + let changed = false; + for (let i = 0; i < steps; i += 1) { + const beforeX = num(item.x), beforeY = num(item.y), beforeA = num(item.angle); + item.prevX = beforeX; + item.prevY = beforeY; + item.prevAngle = beforeA; + item.x = beforeX + ps(item, "xv", 0) * subDt; + item.y = beforeY + ps(item, "yv", 0) * subDt; + item.angle = normalizeAngle(beforeA + ps(item, "spin", 0) * subDt); + changed = constrainPassiveBlockInsideWorld(item, worldRef) || changed; + if (hasContacts) changed = resolveInteractions(item, subDt, worldRef) || changed; + changed = changed || Math.hypot(num(item.x) - beforeX, num(item.y) - beforeY) > 0.001 || Math.abs(num(item.angle) - beforeA) > 0.0001; + } + const friction = Math.pow(0.42, stepTime); + const spinFriction = Math.pow(0.36, stepTime); + pset(item, "xv", ps(item, "xv", 0) * friction, "poison-friction"); + pset(item, "yv", ps(item, "yv", 0) * friction, "poison-friction"); + pset(item, "spin", ps(item, "spin", 0) * spinFriction, "poison-spin-friction"); + if (Math.abs(ps(item, "xv", 0)) < 0.035) pset(item, "xv", 0, "poison-stop"); + if (Math.abs(ps(item, "yv", 0)) < 0.035) pset(item, "yv", 0, "poison-stop"); + if (Math.abs(ps(item, "spin", 0)) < 0.0015) pset(item, "spin", 0, "poison-spin-stop"); + if (item.physicsBody?.velocity) { + item.vx = num(item.physicsBody.velocity.x); + item.vy = num(item.physicsBody.velocity.y); + } + if (changed && worldRef && opts.deferDirty !== true) { worldRef.drawListDirty = true; worldRef.markSpatialDirty?.("poison-block-motion"); } + return changed; + } finally { + item._physicsStepScratch = prevScratch; + commitBody(item, "poison-block-step"); } + } + + + + 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; + return obstacleRects(item).length > 0; + } + + function isMechanicallyActive(item) { + if (!item || item.dead || !isMechanicalType(item)) return false; + if (item.playerHeld || item._heldByPlayer) return false; + if (item.type === "rotator") return isPowered(item) || Math.abs(num(bodyVelocity(item)?.angular, ps(item, "spin", 0))) > 0.004; + if (item.type === "reciprocator") return isPowered(item) || Math.abs(num(bodyVelocity(item)?.linear, ps(item, "slideSpeed", 0))) > 0.08; + if (item.type === "poison_block") return passiveItemAwake(item); return false; } + function collectMechanicalBodies(worldRef, opts = {}) { + if (!worldRef?.itemsOfType) return []; + worldRef.ensureItemBuckets?.("mechanical-world"); + const out = []; + const seen = new Set(); + const maxItems = Math.max(24, Number(opts.maxItems || 220) || 220); + const add = (item) => { + if (!item || item.dead || seen.has(item)) return false; + item.world = worldRef; + ensureBody(item, { syncFromLegacy: false }); + item.amount = 999; + seen.add(item); + out.push(item); + return true; + }; + // Powered reciprocators are autonomous motors. They must not be starved by + // the broad mechanical body cap, otherwise they appear to stop in empty + // space and only move again after another item touches them. + for (const item of worldRef.itemsOfType("reciprocator") || []) { + if (!item || item.dead) continue; + ensureBody(item, { syncFromLegacy: false }); + if (isPowered(item) || Math.abs(num(bodyVelocity(item)?.linear, ps(item, "slideSpeed", 0))) > 0.08) add(item); + } + for (const type of ["rotator", "poison_block", "reciprocator"]) { + for (const item of worldRef.itemsOfType(type) || []) { + if (!item || item.dead || seen.has(item)) continue; + if (out.length >= maxItems) return out; + add(item); + } + } + return out; + } + + function bodyAabb(item, pad = 0) { + const b = boundsAabb(item); + if (!b) return null; + return { + left: b.left - pad, + right: b.right + pad, + top: b.top - pad, + bottom: b.bottom + pad, + item, + }; + } + + function aabbOverlap(a, b, pad = 0) { + return Boolean(a && b && a.left <= b.right + pad && a.right >= b.left - pad && a.top <= b.bottom + pad && a.bottom >= b.top - pad); + } + + function pairKey(a, b) { + const ai = a?.id ?? "a"; + const bi = b?.id ?? "b"; + return ai < bi ? `${ai}:${bi}` : `${bi}:${ai}`; + } + + function physicsFrameId(worldRef) { + if (!worldRef) return 0; + const frame = Number(worldRef.frameCount || worldRef.tickCount || worldRef._frameId || 0); + if (Number.isFinite(frame) && frame > 0) return frame; + return Math.floor((Number(worldRef.time || 0) || 0) * 60); + } + + function makeBodyRecord(item, activeSet = null, pad = 6) { + if (!item || item.dead || !isMechanicalType(item)) return null; + const collidable = shouldCollideMechanical(item); + const active = activeSet ? activeSet.has(item) : isMechanicallyActive(item); + return { + item, + active, + collidable, + aabb: collidable ? bodyAabb(item, pad) : null, + reach: reach(item), + }; + } + + function buildMechanicalFrame(worldRef, opts = {}) { + const bodies = Array.isArray(opts.bodies) ? opts.bodies : collectMechanicalBodies(worldRef, opts); + const activeSet = opts.activeSet || new Set(); + if (!opts.activeSet) { + for (const item of bodies) if (isMechanicallyActive(item)) activeSet.add(item); + } + const focus = opts.focus !== false && worldRef?.nearbyObstacles && bodies.length > Math.max(36, Number(opts.focusThreshold || 48) || 48) && activeSet.size > 0 && activeSet.size < bodies.length * 0.65; + let source = bodies; + if (focus) { + const chosen = new Set(activeSet); + for (const item of activeSet) { + const radius = Math.max(90, reach(item) + 128); + const candidates = worldRef.nearbyObstacles?.(item.x || 0, item.y || 0, radius, false) || []; + for (const other of candidates) { + if (!other || other === item || other.dead || !isMechanicalType(other.type)) continue; + chosen.add(other); + } + } + source = Array.from(chosen); + } + const pad = Number.isFinite(Number(opts.aabbPad)) ? Number(opts.aabbPad) : 6; + const records = []; + const byItem = new Map(); + let active = activeSet.size; + let collidable = 0; + for (const item of source) { + const rec = makeBodyRecord(item, activeSet, pad); + if (!rec) continue; + records.push(rec); + byItem.set(item, rec); + if (rec.collidable && rec.aabb) collidable += 1; + } + return { + frame: physicsFrameId(worldRef), + time: Number(worldRef?.time || 0) || 0, + bodies, + activeSet, + records, + byItem, + active, + collidable, + focused: focus, + sourceCount: source.length, + }; + } + + function broadphaseCandidateRecords(worldRef, frame, opts = {}) { + if (!frame?.records?.length) return []; + const all = frame.records.filter(r => r && r.collidable && r.aabb); + const active = all.filter(r => r.active); + if (!active.length) return []; + const minUseFocused = Number(opts.focusThreshold || 48) || 48; + if (!worldRef?.nearbyObstacles || all.length <= minUseFocused || active.length > Math.max(10, all.length * 0.45)) return all; + const chosen = new Set(active.map(r => r.item)); + const mech = global.TarinaiMechanicalSystem; + for (const r of active) { + const item = r.item; + const radius = Math.max(90, (r.reach || reach(item)) + 128); + const candidates = worldRef.nearbyObstacles?.(item.x || 0, item.y || 0, radius, false) || []; + for (const other of candidates) { + if (!other || other === item || other.dead || !mech?.isMechanicalType?.(other.type)) continue; + chosen.add(other); + } + } + const out = []; + for (const item of chosen) { + const cached = frame.byItem.get(item); + const rec = cached || makeBodyRecord(item, frame.activeSet, Number.isFinite(Number(opts.aabbPad)) ? Number(opts.aabbPad) : 6); + if (rec?.collidable && rec.aabb) out.push(rec); + } + return out; + } + + function buildColliderGrid(records, opts = {}) { + const cellSize = Math.max(96, Number(opts.cellSize || 156) || 156); + const grid = new Map(); + const maxCellsPerBody = Math.max(4, Number(opts.maxCellsPerBody || 64) || 64); + let inserted = 0; + for (const rec of records || []) { + if (!rec?.aabb) continue; + const a = rec.aabb; + const minX = Math.floor(a.left / cellSize); + const maxX = Math.floor(a.right / cellSize); + const minY = Math.floor(a.top / cellSize); + const maxY = Math.floor(a.bottom / cellSize); + let cells = (maxX - minX + 1) * (maxY - minY + 1); + if (!Number.isFinite(cells) || cells <= 0) cells = 1; + if (cells > maxCellsPerBody) { + const key = `${Math.floor(((a.left + a.right) * 0.5) / cellSize)}:${Math.floor(((a.top + a.bottom) * 0.5) / cellSize)}`; + let bucket = grid.get(key); + if (!bucket) { bucket = []; grid.set(key, bucket); } + bucket.push(rec); + inserted += 1; + continue; + } + for (let cy = minY; cy <= maxY; cy += 1) { + for (let cx = minX; cx <= maxX; cx += 1) { + const key = `${cx}:${cy}`; + let bucket = grid.get(key); + if (!bucket) { bucket = []; grid.set(key, bucket); } + bucket.push(rec); + inserted += 1; + } + } + } + return { grid, cellSize, inserted }; + } + + function contactCandidateScore(a, b) { + if (!a?.aabb || !b?.aabb) return -Infinity; + const overlapX = Math.min(a.aabb.right, b.aabb.right) - Math.max(a.aabb.left, b.aabb.left); + const overlapY = Math.min(a.aabb.bottom, b.aabb.bottom) - Math.max(a.aabb.top, b.aabb.top); + if (overlapX < -8 || overlapY < -8) return -Infinity; + let score = 0; + if (a.active) score += 4; + if (b.active) score += 4; + score += Math.max(0, Math.min(overlapX, overlapY) + 8) * 0.22; + score += Math.min(18, Math.max(0, overlapX + 8) * Math.max(0, overlapY + 8) / 900); + const ax = (a.aabb.left + a.aabb.right) * 0.5; + const ay = (a.aabb.top + a.aabb.bottom) * 0.5; + const bx = (b.aabb.left + b.aabb.right) * 0.5; + const by = (b.aabb.top + b.aabb.bottom) * 0.5; + score -= Math.min(5, Math.hypot(ax - bx, ay - by) / 180); + return score; + } + + function resolveMechanicalPairsBroadphase(worldRef, dt = 0.016, opts = {}) { + const frame = opts.frame || buildMechanicalFrame(worldRef, opts); + if (!frame || frame.collidable < 2 || frame.active <= 0) return { solved: 0, checked: 0, pairs: 0, candidates: 0, gridCells: 0 }; + const records = Array.isArray(opts.records) ? opts.records : broadphaseCandidateRecords(worldRef, frame, opts); + if (records.length < 2) return { solved: 0, checked: 0, pairs: 0, candidates: records.length, gridCells: 0 }; + const gridInfo = buildColliderGrid(records, opts); + const grid = gridInfo.grid; + const seen = Object.create(null); + const maxPairs = Math.max(24, Number(opts.maxPairs || 240) || 240); + const maxCandidates = Math.max(maxPairs + 48, Math.min(900, maxPairs * 3)); + const pairCandidates = []; + let pairs = 0; + for (const bucket of grid.values()) { + for (let i = 0; i < bucket.length; i += 1) { + const a = bucket[i]; + for (let j = i + 1; j < bucket.length; j += 1) { + const b = bucket[j]; + if (!a || !b || a.item === b.item) continue; + if (!a.active && !b.active) continue; + const key = pairKey(a.item, b.item); + if (seen[key]) continue; + seen[key] = true; + if (!aabbOverlap(a.aabb, b.aabb, 8)) continue; + const score = contactCandidateScore(a, b); + if (!Number.isFinite(score)) continue; + pairs += 1; + if (pairCandidates.length < maxCandidates) pairCandidates.push({ a, b, key, score }); + else { + let worst = 0; + for (let k = 1; k < pairCandidates.length; k += 1) if (pairCandidates[k].score < pairCandidates[worst].score) worst = k; + if (score > pairCandidates[worst].score) pairCandidates[worst] = { a, b, key, score }; + } + } + } + } + pairCandidates.sort((p, q) => q.score - p.score); + let checked = 0; + let solved = 0; + for (const p of pairCandidates) { + if (checked >= maxPairs) break; + checked += 1; + if (resolvePair(p.a.item, p.b.item, dt, worldRef)) solved += 1; + } + return { solved, checked, pairs, candidates: records.length, gridCells: grid.size, queuedPairs: pairCandidates.length }; + } + + function resolveMechanicalFences(worldRef, dt, bodies, activeSet, opts = {}) { + let solved = 0; + const maxItems = Math.max(12, Number(opts.maxFenceItems || 96) || 96); + let count = 0; + for (const item of bodies || []) { + if (!item || item.dead) continue; + // Fences are static in this runtime. A sleeping poison block or passive body + // cannot create a new fence contact by itself, so skip it before expensive geometry work. + if (activeSet && !activeSet.has(item)) continue; + // Reciprocators are rail-driven and should only reverse/stop against + // other physical items, never against static fences. + if (item.type === "reciprocator") continue; + if (!shouldCollideMechanical(item)) continue; + if (resolveFenceContacts(item, dt, worldRef, reach(item) + 80)) solved += 1; + count += 1; + if (count >= maxItems) break; + } + return solved; + } + + + function estimatedBodyMotion(item, dt = 0.016) { + if (!item || item.dead) return 0; + const step = Math.max(0.001, Math.min(0.05, Number(dt || 0.016) || 0.016)); + if (item.type === "rotator") { + const speed = signedDrive(item); + return Math.abs(speed) * step * Math.max(48, extent(item)); + } + if (item.type === "reciprocator") { + const speed = signedDrive(item); + return Math.abs(speed) * step; + } + if (item.type === "poison_block") { + const vel = bodyVelocity(item); + return Math.hypot(num(vel?.x, ps(item, "xv", 0)), num(vel?.y, ps(item, "yv", 0))) * step + Math.abs(num(vel?.angular, ps(item, "spin", 0))) * step * Math.max(48, extent(item)); + } + return 0; + } + + function updateMechanicalWorld(worldRef, dt = 0.016, opts = {}) { + if (!worldRef?.itemsOfType) return { ran: 0, active: 0, solved: 0, fenceSolved: 0, links: 0 }; + const profiler = global.TarinaiPerf; + const end = profiler?.begin?.("update.mechanicalWorld") || null; + try { + const rawDt = Number(dt || 0.016) || 0.016; + const clampedDt = Math.max(0.001, Math.min(0.05, rawDt)); + const bodies = collectMechanicalBodies(worldRef, opts); + if (!bodies.length) return { ran: 0, active: 0, solved: 0, fenceSolved: 0, links: 0, candidates: 0 }; + const active = []; + for (const item of bodies) if (isMechanicallyActive(item)) active.push(item); + 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)))); + const subDt = clampedDt / substeps; + let ran = 0; + let moved = false; + let stepSolved = 0; + let stepChecked = 0; + let stepPairs = 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. + for (let step = 0; step < substeps; step += 1) { + let subMoved = false; + for (const item of active) { + if (!item || item.dead || item.playerHeld || item._heldByPlayer) continue; + let changed = false; + if (item.type === "rotator") changed = updateRotator(item, subDt, worldRef, { centralStep: true, skipInteractions: true, deferDirty: true }); + else if (item.type === "reciprocator") changed = updateReciprocator(item, subDt, worldRef, { centralStep: true, skipInteractions: true, deferDirty: true }); + 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 (moved) { + worldRef.drawListDirty = true; + worldRef.markSpatialDirty?.("mechanical-world-motion"); + } + // Resolve links after all motors/passive bodies have moved, so constraints see + // one coherent world-state rather than each item solving in isolation. + const links = opts.skipLinks === true ? 0 : (global.TarinaiConstraintSystem?.updateWorld?.(worldRef, clampedDt, { maxLinks: opts.maxLinks || 160 }) || 0); + // Build a single physics frame after motion and reuse it for pair and fence passes. + // This is intentionally save-incompatible: transient body records are runtime only. + const frame = buildMechanicalFrame(worldRef, { bodies }); + const pairStats = resolveMechanicalPairsBroadphase(worldRef, clampedDt, { frame, maxPairs: opts.maxPairs || 260, focusThreshold: opts.focusThreshold || 48 }); + 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); + 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 }; + 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(); + } + } + + function resolveMechanicalPairs(worldRef, dt = 0.016, opts = {}) { + const stats = resolveMechanicalPairsBroadphase(worldRef, dt, opts); + return stats.solved || 0; + } + + function applyPokeImpulse(item, x, y, worldRef) { if (!item || !isMechanicalType(item)) return false; - if (item.type === "rotator" && item.rotatorPowered === false) { + if (item.type === "poison_block") { + const dx = num(item.x) - num(x); + const dy = num(item.y) - num(y); + const d = Math.max(12, Math.hypot(dx, dy)); + applyImpulse(item, x, y, dx / d * 72, dy / d * 72, 1.0); + wakeItem(item, "poke-poison-block"); + worldRef?.markSpatialDirty?.("poke-poison-block"); + return true; + } + if (item.type === "rotator" && !isPowered(item)) { const a = itemAngle(item); - const side = Math.sign((num(x) - num(item.x)) * Math.cos(a + Math.PI / 2) + (num(y) - num(item.y)) * Math.sin(a + Math.PI / 2)) || (Math.random() < 0.5 ? -1 : 1); - item.rotatorAngularVelocity = clamp(num(item.rotatorAngularVelocity) * 0.82 + side * 0.32, -1.6, 1.6); + const pose = bodyPose(item); + const side = Math.sign((num(x) - num(pose?.x, item.x)) * Math.cos(a + Math.PI / 2) + (num(y) - num(pose?.y, item.y)) * Math.sin(a + Math.PI / 2)) || ((typeof stableUnit === "function" ? stableUnit(item.id || item.seed || "rotator", "poke-side") : 0.35) < 0.5 ? -1 : 1); + pset(item, "spin", clamp(num(bodyVelocity(item)?.angular, ps(item, "spin", 0)) * 0.82 + side * 0.32, -1.6, 1.6), "pair-spin-response"); + commitBody(item, "poke-passive-rotator"); worldRef?.markSpatialDirty?.("poke-passive-rotator"); return true; } - if (item.type === "reciprocator" && item.reciprocatorPowered === false) { + if (item.type === "reciprocator" && !isPowered(item)) { const a = axis(item); - const side = Math.sign((num(x) - num(item.x)) * a.x + (num(y) - num(item.y)) * a.y) || (Math.random() < 0.5 ? -1 : 1); - item.reciprocatorVelocity = clamp(num(item.reciprocatorVelocity) * 0.76 + side * 34, -115, 115); + const pose = bodyPose(item); + const side = Math.sign((num(x) - num(pose?.x, item.x)) * a.x + (num(y) - num(pose?.y, item.y)) * a.y) || ((typeof stableUnit === "function" ? stableUnit(item.id || item.seed || "reciprocator", "poke-side") : 0.65) < 0.5 ? -1 : 1); + pset(item, "slideSpeed", clamp(num(bodyVelocity(item)?.linear, ps(item, "slideSpeed", 0)) * 0.76 + side * 34, -115, 115), "pair-slide-response"); + commitBody(item, "poke-passive-reciprocator"); worldRef?.markSpatialDirty?.("poke-passive-reciprocator"); return true; } @@ -472,9 +1740,11 @@ extent, worldSegments, obstacleRects, + poisonHazardRects, boundsAabb, + hazardBoundsAabb, axis, - reciprocatorAxisAngle, + railAxisAngle, halfTravel, resetAnchor, positionFromPhase, @@ -485,15 +1755,31 @@ applyPassiveReciprocatorImpulse, applyPassiveRotatorImpulse, applyRailCorrection, + reverseReciprocatorOnMechanicalContact, + resolveReciprocatorPhysicalContacts, + repairPoweredReciprocatorStall, + brakePoweredRotatorOnMechanicalContact, applySurfaceVelocityToCircle, strongestRectContact, resolvePair, resolveFenceContacts, resolveInteractions, + resolveMechanicalPairs, + resolveMechanicalPairsBroadphase, + updateWorld: updateMechanicalWorld, + collectMechanicalBodies, + isMechanicallyActive, + estimatedBodyMotion, + shouldCollideMechanical, + hasInteractionCandidates, updateRotator, updateReciprocator, + updatePoisonBlock, applyPokeImpulse, hitTest, reach, + invalidateGeometry, + wakeItem, + passiveItemAwake, }); })(typeof window !== "undefined" ? window : globalThis); diff --git a/js/perf_profiler.js b/js/perf_profiler.js index 23beb02..41d7510 100644 --- a/js/perf_profiler.js +++ b/js/perf_profiler.js @@ -2,7 +2,18 @@ (function (global) { const nowMs = () => (global.performance?.now ? global.performance.now() : Date.now()); + const debugEnabled = (() => { + try { + const params = new URLSearchParams(global.location?.search || ""); + return params.get("debug") === "1" || params.get("debug") === "true" || params.get("perf") === "1"; + } catch (_) { + return false; + } + })(); + const buckets = new Map(); + const stack = []; + const metrics = new Map(); let frameOpen = null; let frameCount = 0; let lastQualityCheckAt = 0; @@ -13,20 +24,33 @@ function bucketFor(label) { let b = buckets.get(label); if (!b) { - b = { label, ms: 0, avg: 0, max: 0, calls: 0, last: 0 }; + b = { label, ms: 0, selfMs: 0, avg: 0, selfAvg: 0, max: 0, calls: 0, last: 0, lastSelf: 0 }; buckets.set(label, b); } return b; } function begin(label) { - const start = nowMs(); + if (!debugEnabled) return null; + const frame = { label: String(label || "unknown"), start: nowMs(), childMs: 0 }; + stack.push(frame); return () => { - const elapsed = Math.max(0, nowMs() - start); - const b = bucketFor(label); + const top = stack.pop(); + const active = top === frame ? frame : top || frame; + // If calls ended out of order, keep the profiler alive rather than throwing. + if (top !== frame) { + const idx = stack.lastIndexOf(frame); + if (idx >= 0) stack.splice(idx, 1); + } + const elapsed = Math.max(0, nowMs() - active.start); + const selfElapsed = Math.max(0, elapsed - (active.childMs || 0)); + if (stack.length) stack[stack.length - 1].childMs += elapsed; + const b = bucketFor(active.label); b.ms += elapsed; + b.selfMs += selfElapsed; b.calls += 1; b.last = elapsed; + b.lastSelf = selfElapsed; if (elapsed > b.max) b.max = elapsed; return elapsed; }; @@ -34,13 +58,17 @@ function beginFrame() { frameOpen = nowMs(); + if (debugEnabled) stack.length = 0; return frameOpen; } function smoothBuckets() { + if (!debugEnabled) return; for (const b of buckets.values()) { b.avg = b.avg ? b.avg * 0.82 + b.ms * 0.18 : b.ms; + b.selfAvg = b.selfAvg ? b.selfAvg * 0.82 + b.selfMs * 0.18 : b.selfMs; b.ms = 0; + b.selfMs = 0; b.calls = 0; } } @@ -55,15 +83,18 @@ function endFrame(rawDt = 0, fps = global.__tarinaiFps || 0) { frameCount += 1; - if (frameOpen != null) { - const b = bucketFor("frame.total"); + if (frameOpen != null && debugEnabled) { const elapsed = Math.max(0, nowMs() - frameOpen); + const b = bucketFor("frame.total"); b.ms += elapsed; + b.selfMs += Math.max(0, elapsed - stack.reduce((sum, f) => sum + (f.childMs || 0), 0)); b.calls += 1; b.last = elapsed; + b.lastSelf = b.last; if (elapsed > b.max) b.max = elapsed; - frameOpen = null; } + frameOpen = null; + if (debugEnabled) stack.length = 0; const t = nowMs(); if (t - lastQualityCheckAt >= 750) { lastQualityCheckAt = t; @@ -78,38 +109,56 @@ } } - function renderQualityTier() { - return tier; - } - - function dprScaleValue() { - return dprScale; - } - + function renderQualityTier() { return tier; } + function dprScaleValue() { return dprScale; } function consumeResizeRequest() { const v = resizeRequested; resizeRequested = false; return v; } + function setMetric(name, value) { + if (!debugEnabled) return; + metrics.set(String(name || "metric"), value); + } + function snapshot() { + const frameAvg = buckets.get("frame.total")?.avg || 0; const entries = [...buckets.values()].map(b => ({ label: b.label, avg: Number((b.avg || 0).toFixed(2)), + selfAvg: Number((b.selfAvg || 0).toFixed(2)), max: Number((b.max || 0).toFixed(2)), last: Number((b.last || 0).toFixed(2)), + lastSelf: Number((b.lastSelf || 0).toFixed(2)), calls: b.calls || 0, - })).sort((a, b) => b.avg - a.avg); - return { tier, dprScale, frameCount, entries }; + pct: frameAvg > 0 ? Number((((b.avg || 0) / frameAvg) * 100).toFixed(0)) : 0, + selfPct: frameAvg > 0 ? Number((((b.selfAvg || 0) / frameAvg) * 100).toFixed(0)) : 0, + })); + const topInclusive = entries.slice().sort((a, b) => b.avg - a.avg).slice(0, 12); + const topSelf = entries.slice().sort((a, b) => b.selfAvg - a.selfAvg).slice(0, 12); + return { + enabled: debugEnabled, + tier, + dprScale, + frameCount, + frameAvg: Number(frameAvg.toFixed(2)), + entries: topInclusive, + topInclusive, + topSelf, + metrics: Object.fromEntries(metrics.entries()), + }; } global.TarinaiPerf = Object.freeze({ + enabled: debugEnabled, begin, beginFrame, endFrame, renderQualityTier, dprScale: dprScaleValue, consumeResizeRequest, + setMetric, snapshot, }); })(typeof window !== "undefined" ? window : globalThis); diff --git a/js/physics_world_system.js b/js/physics_world_system.js new file mode 100644 index 0000000..4b6e9fa --- /dev/null +++ b/js/physics_world_system.js @@ -0,0 +1,589 @@ +"use strict"; + +// Layer: physics/world +// Central schema, serialization, and orchestration layer for physical tools. +// Physical state lives only in item.physicsBody / item.physicsConstraint. +(function (global) { + const BODY_SCHEMA_VERSION = 4; + const CONSTRAINT_SCHEMA_VERSION = 4; + const BODY_TYPES = new Set(["rotator", "poison_block", "reciprocator"]); + const LINK_TYPES = new Set(["rope", "rod"]); + + function num(value, fallback = 0) { + const n = Number(value); + return Number.isFinite(n) ? n : fallback; + } + function bool(value, fallback = true) { + if (value == null) return !!fallback; + return !!value; + } + function clamp(value, min, max) { + return Math.max(min, Math.min(max, num(value, min))); + } + function isBodyType(typeOrItem) { + const type = typeof typeOrItem === "string" ? typeOrItem : String(typeOrItem?.type || ""); + return BODY_TYPES.has(type); + } + function isConstraintType(typeOrItem) { + const type = typeof typeOrItem === "string" ? typeOrItem : String(typeOrItem?.type || ""); + return LINK_TYPES.has(type); + } + function isPhysicsType(typeOrItem) { return isBodyType(typeOrItem) || isConstraintType(typeOrItem); } + + function defaultSegments(type) { + if (type === "poison_block") return [[-82, -28, 82, -28], [82, -28, 82, 28], [82, 28, -82, 28], [-82, 28, -82, -28]]; + if (type === "rotator") return [[-78, 0, 78, 0], [0, -52, 0, 52]]; + return [[-78, 0, 78, 0]]; + } + function defaultKind(type) { + if (type === "rotator") return "rotational"; + if (type === "reciprocator") return "linear"; + if (type === "poison_block") return "passive"; + return "none"; + } + function cloneSegments(segments, fallback) { + const source = Array.isArray(segments) && segments.length ? segments : fallback; + const out = []; + for (const seg of source || []) { + if (!Array.isArray(seg) || seg.length < 4) continue; + const x1 = clamp(seg[0], -420, 420), y1 = clamp(seg[1], -420, 420); + const x2 = clamp(seg[2], -420, 420), y2 = clamp(seg[3], -420, 420); + if (Math.hypot(x2 - x1, y2 - y1) < 4) continue; + out.push([x1, y1, x2, y2]); + if (out.length >= 128) break; + } + return out.length ? out : (fallback || [[-78, 0, 78, 0]]).map(seg => seg.slice()); + } + function q(value, scale = 1000) { return Math.round(num(value) * scale); } + + function defaultBody(item) { + const type = String(item?.type || ""); + const x = num(item?.x), y = num(item?.y), angle = num(item?.angle); + return { + schema: BODY_SCHEMA_VERSION, + type, + kind: defaultKind(type), + pose: { x, y, angle }, + velocity: { x: 0, y: 0, angular: 0, linear: 0 }, + motor: { powered: type !== "poison_block", speed: type === "rotator" ? Math.PI * 0.65 : (type === "reciprocator" ? 92 : 0), direction: 1 }, + rail: type === "reciprocator" ? { axisAngle: angle, travel: 150, phase: 0, anchorX: x, anchorY: y } : null, + shape: { model: "segments", thickness: type === "poison_block" ? 14 : 12, segments: cloneSegments(null, defaultSegments(type)), version: 0 }, + collision: { solid: true, hazard: type === "poison_block" }, + hazard: type === "poison_block" ? { kind: "poison", damage: 7 } : null, + sleep: { awakeUntil: 0 }, + mass: type === "poison_block" ? 4.8 : 1, + inertia: type === "poison_block" ? 36000 : 22000, + }; + } + + function normalizeBody(body, item) { + const type = String(item?.type || body?.type || ""); + const base = defaultBody({ type, x: item?.x, y: item?.y, angle: item?.angle }); + const out = body && typeof body === "object" ? body : base; + out.schema = BODY_SCHEMA_VERSION; + out.type = type; + out.kind = out.kind || base.kind; + out.pose = out.pose && typeof out.pose === "object" ? out.pose : base.pose; + out.velocity = out.velocity && typeof out.velocity === "object" ? out.velocity : base.velocity; + out.motor = out.motor && typeof out.motor === "object" ? out.motor : base.motor; + out.rail = type === "reciprocator" ? ((out.rail && typeof out.rail === "object") ? out.rail : base.rail) : null; + out.shape = out.shape && typeof out.shape === "object" ? out.shape : base.shape; + out.collision = out.collision && typeof out.collision === "object" ? out.collision : base.collision; + out.sleep = out.sleep && typeof out.sleep === "object" ? out.sleep : base.sleep; + out.hazard = type === "poison_block" ? ((out.hazard && typeof out.hazard === "object") ? out.hazard : base.hazard) : null; + out.mass = Math.max(0.2, num(out.mass, base.mass)); + out.inertia = Math.max(1, num(out.inertia, base.inertia)); + out.pose.x = num(out.pose.x, item?.x); + out.pose.y = num(out.pose.y, item?.y); + out.pose.angle = num(out.pose.angle, item?.angle); + out.velocity.x = num(out.velocity.x); + out.velocity.y = num(out.velocity.y); + out.velocity.angular = num(out.velocity.angular); + out.velocity.linear = num(out.velocity.linear); + out.motor.powered = type === "poison_block" ? false : out.motor.powered !== false; + out.motor.speed = Math.max(0, num(out.motor.speed, base.motor.speed)); + out.motor.direction = Math.sign(num(out.motor.direction, 1)) || 1; + if (out.rail) { + out.rail.axisAngle = num(out.rail.axisAngle, out.pose.angle); + out.rail.travel = Math.max(24, num(out.rail.travel, 150)); + out.rail.phase = clamp(out.rail.phase, -1, 1); + out.rail.anchorX = num(out.rail.anchorX, out.pose.x); + out.rail.anchorY = num(out.rail.anchorY, out.pose.y); + } + out.shape.model = "segments"; + out.shape.thickness = Math.max(4, Math.min(34, num(out.shape.thickness, base.shape.thickness))); + out.shape.segments = cloneSegments(out.shape.segments, defaultSegments(type)); + out.shape.version = Number(out.shape.version || 0) || 0; + out.collision.solid = out.collision.solid !== false; + out.collision.hazard = type === "poison_block" ? out.collision.hazard !== false : false; + if (out.hazard) { + out.hazard.kind = out.hazard.kind || "poison"; + out.hazard.damage = Math.max(1, num(out.hazard.damage, 7)); + } + out.sleep.awakeUntil = num(out.sleep.awakeUntil); + // During the mechanical solver we intentionally mutate item.x/y/angle as + // a scratch pose, then commit that pose back to physicsBody at the end of + // the step. Do not let scalar()/setScalar() normalization overwrite the + // scratch pose with the old body pose while the step is in progress. + if (item._physicsStepScratch !== true) { + item.x = out.pose.x; + item.y = out.pose.y; + item.angle = out.pose.angle; + } + return out; + } + + function itemPoseShouldFeedBody(item, opts = {}) { + return opts.syncFromLegacy === true || item?._physicsStepScratch === true || item?.playerHeld === true || item?._heldByPlayer === true || item?._physicsExternalPoseDirty === true; + } + + function ensureBody(item, worldRef = null, opts = {}) { + if (!item || item.dead || !isBodyType(item)) return null; + item.world = worldRef || item.world || null; + if (!item.physicsBody || typeof item.physicsBody !== "object" || item.physicsBody.type !== item.type || opts.rebuild === true) { + item.physicsBody = defaultBody(item); + markBodyChanged(item, opts.rebuild === true ? "rebuild" : "init"); + } else if (itemPoseShouldFeedBody(item, opts)) { + const sx = num(item.x); + const sy = num(item.y); + const sa = num(item.angle); + item.physicsBody.pose = item.physicsBody.pose || { x: sx, y: sy, angle: sa }; + item.physicsBody.pose.x = sx; + item.physicsBody.pose.y = sy; + item.physicsBody.pose.angle = sa; + item._physicsExternalPoseDirty = false; + } + const body = normalizeBody(item.physicsBody, item); + item.physicsBody = body; + return body; + } + + function markBodyChanged(item, reason = "physics-body") { + if (!item) return; + item._physicsBodyVersion = (Number(item._physicsBodyVersion || 0) || 0) + 1; + item._physicsBodyDirtyReason = reason; + } + + function bodySignature(item) { + const b = ensureBody(item, item?.world || null, { syncFromLegacy: false }); + if (!b) return ""; + const s = b.shape || {}; + const v = b.velocity || {}; + const m = b.motor || {}; + const r = b.rail || {}; + const c = b.collision || {}; + const sl = b.sleep || {}; + return [ + b.type, q(b.pose?.x, 10), q(b.pose?.y, 10), q(b.pose?.angle, 10000), + q(s.thickness, 10), Number(s.version || 0) || 0, Array.isArray(s.segments) ? s.segments.length : 0, + c.solid === false ? 0 : 1, c.hazard === false ? 0 : 1, + m.powered === false ? 0 : 1, q(m.speed, 1000), q(m.direction, 10), + q(v.x, 1000), q(v.y, 1000), q(v.angular, 10000), q(v.linear, 1000), + q(r.axisAngle, 10000), q(r.travel, 10), q(r.phase, 10000), q(r.anchorX, 10), q(r.anchorY, 10), + q(sl.awakeUntil, 1000), q(b.hazard?.damage, 10), q(b.mass, 10), q(b.inertia, 1), + ].join(":"); + } + + function scalar(item, key, fallback = 0) { + const b = ensureBody(item, item?.world || null, { syncFromLegacy: false }); + if (!b) return fallback; + switch (key) { + case "thickness": return Math.max(4, Math.min(34, num(b.shape?.thickness, fallback))); + case "motorOn": return b.motor?.powered !== false; + case "motorSpeed": return num(b.motor?.speed, fallback); + case "spin": return num(b.velocity?.angular, fallback); + case "xv": return num(b.velocity?.x, fallback); + case "yv": return num(b.velocity?.y, fallback); + case "slideSpeed": return num(b.velocity?.linear, fallback); + case "solid": return b.collision?.solid !== false; + case "damage": return Math.max(1, num(b.hazard?.damage, fallback)); + case "mass": return Math.max(0.2, num(b.mass, fallback)); + case "inertia": return Math.max(1, num(b.inertia, fallback)); + case "railOn": return b.motor?.powered !== false; + case "railMotorSpeed": return Math.max(0, num(b.motor?.speed, fallback)); + case "railTravel": return Math.max(24, num(b.rail?.travel, fallback)); + case "railPhase": return clamp(b.rail?.phase, -1, 1); + case "railDir": return Math.sign(num(b.motor?.direction, fallback || 1)) || 1; + case "railAxis": return num(b.rail?.axisAngle, fallback); + case "railAnchorX": return num(b.rail?.anchorX, fallback); + case "railAnchorY": return num(b.rail?.anchorY, fallback); + case "awakeUntil": return num(b.sleep?.awakeUntil, fallback); + default: return fallback; + } + } + + function setScalar(item, key, value, reason = "physics-set") { + const b = ensureBody(item, item?.world || null, { syncFromLegacy: false }); + if (!b) return false; + b.velocity = b.velocity || {}; + b.motor = b.motor || {}; + b.rail = b.rail || (item.type === "reciprocator" ? { axisAngle: num(item.angle), travel: 150, phase: 0, anchorX: num(item.x), anchorY: num(item.y) } : null); + b.shape = b.shape || { model: "segments", thickness: item.type === "poison_block" ? 14 : 12, segments: defaultSegments(item.type), version: 0 }; + b.collision = b.collision || { solid: true, hazard: item.type === "poison_block" }; + b.sleep = b.sleep || { awakeUntil: 0 }; + if (item.type === "poison_block") b.hazard = b.hazard || { kind: "poison", damage: 7 }; + switch (key) { + case "thickness": b.shape.thickness = Math.max(4, Math.min(34, num(value, item.type === "poison_block" ? 14 : 12))); b.shape.version = (Number(b.shape.version || 0) || 0) + 1; break; + case "motorOn": b.motor.powered = item.type === "poison_block" ? false : !!value; break; + case "motorSpeed": b.motor.speed = Math.max(0, num(value)); break; + case "spin": b.velocity.angular = num(value); break; + case "xv": b.velocity.x = num(value); break; + case "yv": b.velocity.y = num(value); break; + case "slideSpeed": b.velocity.linear = num(value); break; + case "solid": b.collision.solid = !!value; break; + case "damage": b.hazard = b.hazard || { kind: "poison", damage: 7 }; b.hazard.damage = Math.max(1, num(value, 7)); break; + case "mass": b.mass = Math.max(0.2, num(value, 4.8)); break; + case "inertia": b.inertia = Math.max(1, num(value, 36000)); break; + case "railOn": b.motor.powered = !!value; break; + case "railMotorSpeed": b.motor.speed = Math.max(0, num(value, 92)); break; + case "railTravel": if (b.rail) b.rail.travel = Math.max(24, num(value, 150)); break; + case "railPhase": if (b.rail) b.rail.phase = clamp(value, -1, 1); break; + case "railDir": b.motor.direction = Math.sign(num(value, 1)) || 1; break; + case "railAxis": if (b.rail) b.rail.axisAngle = num(value, b.pose?.angle); break; + case "railAnchorX": if (b.rail) b.rail.anchorX = num(value, b.pose?.x); break; + case "railAnchorY": if (b.rail) b.rail.anchorY = num(value, b.pose?.y); break; + case "awakeUntil": b.sleep.awakeUntil = num(value); break; + default: return false; + } + normalizeBody(b, item); + markBodyChanged(item, reason); + if (key === "thickness" || key === "solid") global.TarinaiMechanicalSystem?.invalidateGeometry?.(item); + return true; + } + + function segments(item) { return cloneSegments(ensureBody(item, item?.world || null, { syncFromLegacy: false })?.shape?.segments, defaultSegments(item?.type)); } + function setSegments(item, next, reason = "shape-edited") { + const b = ensureBody(item, item?.world || null, { syncFromLegacy: false }); + if (!b) return false; + b.shape = b.shape || {}; + b.shape.model = "segments"; + b.shape.segments = cloneSegments(next, defaultSegments(item.type)); + b.shape.version = (Number(b.shape.version || 0) || 0) + 1; + markBodyChanged(item, reason); + global.TarinaiMechanicalSystem?.invalidateGeometry?.(item); + return true; + } + function pose(item) { return ensureBody(item, item?.world || null, { syncFromLegacy: false })?.pose || null; } + function velocity(item) { return ensureBody(item, item?.world || null, { syncFromLegacy: false })?.velocity || null; } + function motor(item) { return ensureBody(item, item?.world || null, { syncFromLegacy: false })?.motor || null; } + function rail(item) { return ensureBody(item, item?.world || null, { syncFromLegacy: false })?.rail || null; } + function shape(item) { return ensureBody(item, item?.world || null, { syncFromLegacy: false })?.shape || null; } + function collision(item) { return ensureBody(item, item?.world || null, { syncFromLegacy: false })?.collision || null; } + function hazard(item) { return ensureBody(item, item?.world || null, { syncFromLegacy: false })?.hazard || null; } + function sleep(item) { return ensureBody(item, item?.world || null, { syncFromLegacy: false })?.sleep || null; } + + function syncPoseFromItem(item) { + if (!item) return null; + // Capture the item pose before ensureBody()/normalizeBody() can write the + // current body pose back to the item. This is required for scratch-solver + // movement and direct player grabbing. + const sx = num(item.x); + const sy = num(item.y); + const sa = num(item.angle); + const b = ensureBody(item, item?.world || null, { syncFromLegacy: false }); + if (!b) return null; + b.pose = b.pose || { x: sx, y: sy, angle: sa }; + b.pose.x = sx; + b.pose.y = sy; + b.pose.angle = sa; + item.x = sx; + item.y = sy; + item.angle = sa; + return b; + } + function applyPoseToItem(item) { + const b = ensureBody(item, item?.world || null, { syncFromLegacy: false }); + if (!b) return false; + const before = `${num(item.x).toFixed(3)}:${num(item.y).toFixed(3)}:${num(item.angle).toFixed(5)}`; + item.x = num(b.pose?.x, item.x); + item.y = num(b.pose?.y, item.y); + item.angle = num(b.pose?.angle, item.angle); + const after = `${num(item.x).toFixed(3)}:${num(item.y).toFixed(3)}:${num(item.angle).toFixed(5)}`; + return before !== after; + } + + function endpointToPlain(endpoint) { + if (!endpoint) return null; + return { + kind: endpoint.kind || "item", + id: endpoint.id || "", + type: endpoint.type || "", + label: endpoint.label || "", + liveToken: endpoint.liveToken ?? null, + localX: num(endpoint.localX), + localY: num(endpoint.localY), + center: !!endpoint.center, + x: num(endpoint.x), + y: num(endpoint.y), + attachT: Number.isFinite(Number(endpoint.attachT)) ? num(endpoint.attachT) : null, + fuzzyResolve: endpoint.fuzzyResolve === true, + supportLost: endpoint.supportLost === true, + _ref: endpoint._ref || null, + _supportItemId: endpoint._supportItemId || "", + _supportCheckedVersion: Number(endpoint._supportCheckedVersion || -1) || -1, + _supportAlive: endpoint._supportAlive !== false, + }; + } + function defaultConstraint(item) { + const x = num(item?.x), y = num(item?.y); + return { + schema: CONSTRAINT_SCHEMA_VERSION, + type: item?.type, + kind: item?.type === "rope" ? "flexible-distance" : "rigid-distance", + endpoints: [null, null], + length: 80, + mid: { x, y: y + 10, vx: 0, vy: 0 }, + sleep: { awakeUntil: 0 }, + particles: null, + }; + } + function normalizeConstraint(c, item) { + const base = defaultConstraint(item); + const out = c && typeof c === "object" ? c : base; + out.schema = CONSTRAINT_SCHEMA_VERSION; + out.type = item?.type; + out.kind = out.kind || base.kind; + out.endpoints = Array.isArray(out.endpoints) ? out.endpoints : [null, null]; + out.endpoints[0] = endpointToPlain(out.endpoints[0]); + out.endpoints[1] = endpointToPlain(out.endpoints[1]); + out.length = Math.max(24, num(out.length, 80)); + out.mid = out.mid && typeof out.mid === "object" ? out.mid : base.mid; + out.mid.x = num(out.mid.x, item?.x); + out.mid.y = num(out.mid.y, item?.y); + out.mid.vx = num(out.mid.vx); + out.mid.vy = num(out.mid.vy); + out.sleep = out.sleep && typeof out.sleep === "object" ? out.sleep : base.sleep; + out.sleep.awakeUntil = num(out.sleep.awakeUntil); + out.particles = Array.isArray(out.particles) ? out.particles : null; + item.r = Math.max(18, item.type === "rod" ? out.length * 0.5 : (num(item.r, out.length * 0.5) || out.length * 0.5)); + return out; + } + function ensureConstraint(item, worldRef = null, opts = {}) { + if (!item || item.dead || !isConstraintType(item)) return null; + item.world = worldRef || item.world || null; + if (!item.physicsConstraint || typeof item.physicsConstraint !== "object" || item.physicsConstraint.type !== item.type || opts.rebuild === true) { + item.physicsConstraint = defaultConstraint(item); + } + item.physicsConstraint = normalizeConstraint(item.physicsConstraint, item); + return item.physicsConstraint; + } + function endpoint(item, index = 0) { return ensureConstraint(item, item?.world || null, { syncFromLegacy: false })?.endpoints?.[index] || null; } + function setEndpoint(item, index, value) { + const c = ensureConstraint(item, item?.world || null, { syncFromLegacy: false }); + if (!c) return false; + c.endpoints[index] = endpointToPlain(value); + return true; + } + function linkScalar(item, key, fallback = 0) { + const c = ensureConstraint(item, item?.world || null, { syncFromLegacy: false }); + if (!c) return fallback; + switch (key) { + case "len": return Math.max(24, num(c.length, fallback)); + case "midX": return num(c.mid?.x, fallback); + case "midY": return num(c.mid?.y, fallback); + case "midVx": return num(c.mid?.vx, fallback); + case "midVy": return num(c.mid?.vy, fallback); + case "awakeUntil": return num(c.sleep?.awakeUntil, fallback); + default: return fallback; + } + } + function setLinkScalar(item, key, value) { + const c = ensureConstraint(item, item?.world || null, { syncFromLegacy: false }); + if (!c) return false; + c.mid = c.mid || { x: num(item.x), y: num(item.y), vx: 0, vy: 0 }; + c.sleep = c.sleep || { awakeUntil: 0 }; + switch (key) { + case "len": c.length = Math.max(24, num(value, 80)); item.r = Math.max(18, c.length * 0.5); break; + case "midX": c.mid.x = num(value, item.x); break; + case "midY": c.mid.y = num(value, item.y); break; + case "midVx": c.mid.vx = num(value); break; + case "midVy": c.mid.vy = num(value); break; + case "awakeUntil": c.sleep.awakeUntil = num(value); break; + default: return false; + } + return true; + } + function particles(item) { + const c = ensureConstraint(item, item?.world || null, { syncFromLegacy: false }); + if (!c) return null; + return c.particles; + } + function setParticles(item, list) { + const c = ensureConstraint(item, item?.world || null, { syncFromLegacy: false }); + if (!c) return false; + c.particles = Array.isArray(list) ? list : null; + return true; + } + function constraintSignature(item) { + const c = ensureConstraint(item, item?.world || null, { syncFromLegacy: false }); + if (!c) return ""; + const epSig = ep => !ep ? "null" : [ep.kind || "item", ep.id || "", ep.type || "", q(ep.localX, 10), q(ep.localY, 10), ep.center ? 1 : 0, q(ep.x, 10), q(ep.y, 10), ep.attachT == null ? "" : q(ep.attachT, 1000)].join("/"); + return [item.type || "", q(c.length, 10), q(c.mid?.x, 10), q(c.mid?.y, 10), q(c.mid?.vx, 1000), q(c.mid?.vy, 1000), q(c.sleep?.awakeUntil, 1000), epSig(c.endpoints?.[0]), epSig(c.endpoints?.[1])].join(":"); + } + + function collectPhysicsItems(worldRef) { + const bodies = []; + const constraints = []; + if (!worldRef?.itemsOfType) return { bodies, constraints }; + worldRef.ensureItemBuckets?.("physics-world-system"); + for (const type of BODY_TYPES) for (const item of worldRef.itemsOfType(type) || []) if (item && !item.dead) bodies.push(item); + for (const type of LINK_TYPES) for (const item of worldRef.itemsOfType(type) || []) if (item && !item.dead) constraints.push(item); + return { bodies, constraints }; + } + function prepareWorld(worldRef, opts = {}) { + const { bodies, constraints } = collectPhysicsItems(worldRef); + for (const item of bodies) ensureBody(item, worldRef, { syncFromLegacy: item?.playerHeld === true || item?._heldByPlayer === true || item?._physicsExternalPoseDirty === true }); + for (const item of constraints) ensureConstraint(item, worldRef, { syncFromLegacy: false }); + const frame = { id: (worldRef._physicsFrameSeq = (Number(worldRef._physicsFrameSeq || 0) || 0) + 1), time: num(worldRef?.time), bodies, constraints, bodyCount: bodies.length, constraintCount: constraints.length, sync: { bodyReads: 0, bodyWrites: 0, constraintReads: 0, constraintWrites: 0 } }; + worldRef._physicsWorldFrame = frame; + return frame; + } + function commitWorld(worldRef, frame = worldRef?._physicsWorldFrame, opts = {}) { + if (!frame) return { bodies: 0, constraints: 0, moved: 0 }; + let moved = 0; + for (const item of frame.bodies || []) { + const before = `${num(item.x).toFixed(3)}:${num(item.y).toFixed(3)}:${num(item.angle).toFixed(5)}`; + ensureBody(item, worldRef, { syncFromLegacy: false }); + applyPoseToItem(item); + const after = `${num(item.x).toFixed(3)}:${num(item.y).toFixed(3)}:${num(item.angle).toFixed(5)}`; + if (before !== after) moved += 1; + } + for (const item of frame.constraints || []) ensureConstraint(item, worldRef, { syncFromLegacy: false }); + if (opts.markDirty && moved) worldRef?.markSpatialDirty?.("physics-world-commit"); + return { bodies: frame.bodies?.length || 0, constraints: frame.constraints?.length || 0, moved }; + } + function updateWorld(worldRef, dt = 0.016, opts = {}) { + if (!worldRef?.itemsOfType) return { ran: 0, bodies: 0, constraints: 0 }; + const profiler = global.TarinaiPerf; + const end = profiler?.begin?.("update.physicsWorld.central") || null; + try { + const frame = prepareWorld(worldRef, opts); + const mechanicalStats = global.TarinaiMechanicalSystem?.updateWorld?.(worldRef, dt, { maxPairs: opts.maxPairs || 320, maxLinks: 0, skipLinks: true, maxSubsteps: opts.maxSubsteps || 5, focusThreshold: opts.focusThreshold || 44 }) || { ran: 0, active: 0 }; + const constraintRan = global.TarinaiConstraintSystem?.updateWorld?.(worldRef, dt, { maxLinks: opts.maxLinks || 220 }) || 0; + const postConstraintPairs = constraintRan ? (global.TarinaiMechanicalSystem?.resolveMechanicalPairs?.(worldRef, dt, { maxPairs: opts.postMaxPairs || 110 }) || 0) : 0; + const committed = commitWorld(worldRef, frame); + const stats = { ran: (mechanicalStats?.ran || 0) + constraintRan, bodies: frame.bodyCount, constraints: frame.constraintCount, mechanical: mechanicalStats, constraintRan, postConstraintPairs, committed, sync: frame.sync || null, schema: BODY_SCHEMA_VERSION }; + worldRef._physicsWorldStats = stats; + return stats; + } finally { if (end) end(); } + } + + function compactItemExtra(item, tarinaiIndex = new Map(), itemIndex = new Map()) { + if (!item || !isPhysicsType(item)) return null; + if (isBodyType(item)) { + const b = ensureBody(item, item.world || null); + return { pf: 2, body: { + type: b.type, + kind: b.kind, + pose: [Math.round(num(b.pose.x)), Math.round(num(b.pose.y)), Math.round(num(b.pose.angle) * 1000)], + vel: [Math.round(num(b.velocity.x) * 10), Math.round(num(b.velocity.y) * 10), Math.round(num(b.velocity.angular) * 1000), Math.round(num(b.velocity.linear) * 10)], + motor: [b.motor.powered ? 1 : 0, Math.round(num(b.motor.speed) * 10), Math.round(num(b.motor.direction || 1))], + rail: b.rail ? [Math.round(num(b.rail.axisAngle) * 1000), Math.round(num(b.rail.travel) * 10), Math.round(num(b.rail.phase) * 1000), Math.round(num(b.rail.anchorX)), Math.round(num(b.rail.anchorY))] : null, + shape: [Math.round(num(b.shape.thickness) * 10), cloneSegments(b.shape.segments, defaultSegments(item.type)).map(seg => seg.map(v => Math.round(num(v))))], + collision: [b.collision.solid ? 1 : 0, b.collision.hazard ? 1 : 0], + hazard: b.hazard ? [b.hazard.kind || "", Math.round(num(b.hazard.damage, 7) * 10)] : null, + mass: Math.round(num(b.mass, 1) * 10), + inertia: Math.round(num(b.inertia, 1)), + } }; + } + const c = ensureConstraint(item, item.world || null); + const epToSave = ep => { + if (!ep) return null; + const kind = ep.kind || "item"; + const refIdx = kind === "tarinai" ? (tarinaiIndex.get(ep.id) ?? -1) : (itemIndex.get(ep.id) ?? -1); + return { kind, refIdx, id: ep.id || "", type: ep.type || "", label: ep.label || "", token: ep.liveToken ?? null, lx: Math.round(num(ep.localX)), ly: Math.round(num(ep.localY)), center: ep.center ? 1 : 0, x: Math.round(num(ep.x)), y: Math.round(num(ep.y)), t: Number.isFinite(Number(ep.attachT)) ? Math.round(num(ep.attachT) * 1000) : null }; + }; + return { pf: 2, constraint: { type: c.type, kind: c.kind, endpoints: [epToSave(c.endpoints?.[0]), epToSave(c.endpoints?.[1])], length: Math.round(num(c.length, 80)), mid: [Math.round(num(c.mid?.x)), Math.round(num(c.mid?.y)), Math.round(num(c.mid?.vx) * 10), Math.round(num(c.mid?.vy) * 10)] } }; + } + function restoreBodyExtra(item, extra) { + const p = extra?.body; + if (!item || !p || !isBodyType(item)) return false; + const poseArr = Array.isArray(p.pose) ? p.pose : []; + const velArr = Array.isArray(p.vel) ? p.vel : []; + const motorArr = Array.isArray(p.motor) ? p.motor : []; + const railArr = Array.isArray(p.rail) ? p.rail : null; + const shapeArr = Array.isArray(p.shape) ? p.shape : []; + const collArr = Array.isArray(p.collision) ? p.collision : []; + const hazardArr = Array.isArray(p.hazard) ? p.hazard : null; + const body = defaultBody(item); + body.kind = p.kind || body.kind; + body.pose = { x: num(poseArr[0], item.x), y: num(poseArr[1], item.y), angle: num(poseArr[2]) / 1000 }; + body.velocity = { x: num(velArr[0]) / 10, y: num(velArr[1]) / 10, angular: num(velArr[2]) / 1000, linear: num(velArr[3]) / 10 }; + body.motor = { powered: bool(motorArr[0], true), speed: num(motorArr[1]) / 10, direction: Math.sign(num(motorArr[2], 1)) || 1 }; + body.rail = railArr ? { axisAngle: num(railArr[0]) / 1000, travel: Math.max(24, num(railArr[1], 1500) / 10), phase: clamp(num(railArr[2]) / 1000, -1, 1), anchorX: num(railArr[3], item.x), anchorY: num(railArr[4], item.y) } : null; + body.shape = { model: "segments", thickness: Math.max(4, Math.min(34, num(shapeArr[0], item.type === "poison_block" ? 140 : 120) / 10)), segments: cloneSegments(shapeArr[1], defaultSegments(item.type)), version: 0 }; + body.collision = { solid: collArr[0] !== 0, hazard: collArr[1] !== 0 }; + body.hazard = hazardArr ? { kind: hazardArr[0] || "poison", damage: Math.max(1, num(hazardArr[1], 70) / 10) } : null; + body.mass = Math.max(0.2, num(p.mass, Math.round(num(body.mass, 1) * 10)) / 10); + body.inertia = Math.max(1, num(p.inertia, body.inertia)); + item.physicsBody = normalizeBody(body, item); + applyPoseToItem(item); + return true; + } + function restoreConstraintExtra(item, extra, tarinaiList = [], itemList = []) { + const p = extra?.constraint; + if (!item || !p || !isConstraintType(item)) return false; + const epFromSave = ep => { + if (!ep) return null; + const kind = ep.kind || "item"; + const refIdx = Number(ep.refIdx); + const indexedTarget = Number.isInteger(refIdx) && refIdx >= 0 ? (kind === "tarinai" ? tarinaiList[refIdx] : itemList[refIdx]) : null; + return endpointToPlain({ kind, id: indexedTarget?.id || ep.id || "", type: ep.type || indexedTarget?.type || "", label: ep.label || indexedTarget?.name || "", liveToken: ep.token ?? indexedTarget?.liveToken ?? null, _ref: indexedTarget || null, localX: num(ep.lx), localY: num(ep.ly), center: !!ep.center, x: num(ep.x, item.x || 0), y: num(ep.y, item.y || 0), attachT: ep.t == null ? null : clamp(num(ep.t) / 1000, 0, 1), fuzzyResolve: !indexedTarget && !(Number.isInteger(refIdx) && refIdx >= 0) }); + }; + const midArr = Array.isArray(p.mid) ? p.mid : []; + item.physicsConstraint = normalizeConstraint({ schema: CONSTRAINT_SCHEMA_VERSION, type: item.type, kind: p.kind || (item.type === "rope" ? "flexible-distance" : "rigid-distance"), endpoints: [epFromSave(p.endpoints?.[0]), epFromSave(p.endpoints?.[1])], length: Math.max(24, num(p.length, 80)), mid: { x: num(midArr[0], item.x || 0), y: num(midArr[1], item.y || 0), vx: num(midArr[2]) / 10, vy: num(midArr[3]) / 10 }, sleep: { awakeUntil: 0 }, particles: null }, item); + return true; + } + function applyCompactExtra(item, extra, tarinaiList = [], itemList = []) { + if (!item || !extra || typeof extra !== "object" || extra.pf !== 2) return false; + if (isBodyType(item)) return restoreBodyExtra(item, extra); + if (isConstraintType(item)) return restoreConstraintExtra(item, extra, tarinaiList, itemList); + return false; + } + function invalidateItem(item, reason = "physics-edited") { + if (!item) return false; + if (isBodyType(item)) { + ensureBody(item, item.world || null, { rebuild: false, syncFromLegacy: false }); + global.TarinaiMechanicalSystem?.invalidateGeometry?.(item); + markBodyChanged(item, reason); + return true; + } + if (isConstraintType(item)) { + ensureConstraint(item, item.world || null, { rebuild: false, syncFromLegacy: false }); + item._linkLastStamp = ""; + return true; + } + return false; + } + function purgeLegacyPhysicsStorage(item) { + if (!item || typeof item !== "object") return false; + // Physical state is schema-only now; remove any data fields left by older experiments. + const bodyKeys = ["rotator" + "Thickness", "rotator" + "Segments", "rotator" + "Powered", "rotator" + "Speed", "rotator" + "AngularVelocity", "poison" + "CollisionEnabled", "poison" + "Damage", "poison" + "Mass", "poison" + "Inertia", "reciprocator" + "Powered", "reciprocator" + "Speed", "reciprocator" + "Travel", "reciprocator" + "Phase", "reciprocator" + "Direction", "reciprocator" + "AxisAngle", "reciprocator" + "Velocity", "reciprocator" + "AnchorX", "reciprocator" + "AnchorY", "_physics" + "AwakeUntil"]; + const linkKeys = ["link" + "A", "link" + "B", "link" + "Length", "link" + "MidX", "link" + "MidY", "link" + "MidVX", "link" + "MidVY", "_link" + "AwakeUntil", "rope" + "Particles"]; + for (const key of bodyKeys.concat(linkKeys)) { try { delete item[key]; } catch (_) {} } + if (isBodyType(item)) ensureBody(item, item.world || null, { syncFromLegacy: false }); + if (isConstraintType(item)) ensureConstraint(item, item.world || null, { syncFromLegacy: false }); + return true; + } + + // Schema normalization entry points. + const normalizeBodyState = (item, body = item?.physicsBody) => { if (item && body) { item.physicsBody = normalizeBody(body, item); return item.physicsBody; } return null; }; + const applyBodyState = (item, body = item?.physicsBody, opts = {}) => { if (item && body) { item.physicsBody = normalizeBody(body, item); if (opts.invalidateShape === true) global.TarinaiMechanicalSystem?.invalidateGeometry?.(item); return applyPoseToItem(item); } return false; }; + const normalizeConstraintState = (item, c = item?.physicsConstraint) => { if (item && c) { item.physicsConstraint = normalizeConstraint(c, item); return item.physicsConstraint; } return null; }; + const applyConstraintState = (item, c = item?.physicsConstraint) => { if (item && c) { item.physicsConstraint = normalizeConstraint(c, item); return true; } return false; }; + + const bodyApi = Object.freeze({ + BODY_SCHEMA_VERSION, CONSTRAINT_SCHEMA_VERSION, BODY_TYPES, LINK_TYPES, + isBodyType, isConstraintType, isPhysicsType, + bodySignature, constraintSignature, + ensureBody, ensureConstraint, + normalizeBodyState, applyBodyState, markBodyChanged, normalizeConstraintState, applyConstraintState, + prepareWorld, commitWorld, compactItemExtra, applyCompactExtra, purgeLegacyPhysicsStorage, + defaultSegments, cloneSegments, invalidateItem, + pose, velocity, motor, rail, shape, collision, hazard, sleep, + scalar, setScalar, segments, setSegments, syncPoseFromItem, applyPoseToItem, + endpoint, setEndpoint, linkScalar, setLinkScalar, particles, setParticles, endpointToPlain, + }); + global.TarinaiPhysicsBodySystem = bodyApi; + global.TarinaiPhysicsWorldSystem = Object.freeze({ updateWorld, prepareWorld, commitWorld, collectPhysicsItems, bodyApi }); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/render.js b/js/render.js index e4a3a81..32fa9d6 100644 --- a/js/render.js +++ b/js/render.js @@ -1,5 +1,125 @@ "use strict"; const TARINAI_RENDER_GLOBAL = typeof globalThis !== "undefined" ? globalThis : (typeof window !== "undefined" ? window : {}); + +const toolCursorIconCache = new Map(); +const toolCursorIconImages = new Map(); + +function toolCursorIconDefinition(world) { + const tool = String(world?.tool || ""); + if (!tool || tool === "observe" || tool === "undo" || tool === "redo") return null; + const def = typeof toolDefinition === "function" ? toolDefinition(tool) : null; + if (!def) return null; + return { tool, def, type: def.itemType || (def.placeable ? tool : "") }; +} + +function staticVersionedAssetPath(path) { + const raw = String(path || ""); + if (!raw) return ""; + const app = TARINAI_RENDER_GLOBAL.TARINAI_APP; + if (app?.withVersion) return app.withVersion(raw); + return raw; +} + +function cachedToolCursorIconImage(path) { + const src = staticVersionedAssetPath(path); + if (!src) return null; + let img = toolCursorIconImages.get(src); + if (!img && typeof Image !== "undefined") { + img = new Image(); + img.decoding = "async"; + img.src = src; + toolCursorIconImages.set(src, img); + } + return img || null; +} + +function drawToolCursorIconFallback(ctx, icon, size) { + const value = String(icon || ""); + if (!value) return false; + ctx.save(); + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.font = `${Math.round(size * 0.58)}px ui-rounded, sans-serif`; + ctx.fillStyle = "rgba(44, 36, 28, 0.78)"; + ctx.fillText(value, size / 2, size / 2 + size * 0.02); + ctx.restore(); + return true; +} + +function buildToolCursorIconCanvas(tool, def, type, size) { + const key = `${tool}:${type || "-"}:${size}`; + const cached = toolCursorIconCache.get(key); + if (cached) return cached; + const ownerDocument = typeof document !== "undefined" ? document : null; + const canvas = ownerDocument?.createElement?.("canvas") || null; + if (!canvas) return null; + canvas.width = size; + canvas.height = size; + const c = canvas.getContext("2d"); + if (!c) return null; + c.clearRect(0, 0, size, size); + let drawn = false; + if (type && typeof TARINAI_RENDER_GLOBAL.drawToolItemPreview === "function") { + try { drawn = TARINAI_RENDER_GLOBAL.drawToolItemPreview(c, type, { width: size, height: size, compact: true, watermark: false }) !== false; } + catch (_) { drawn = false; } + } + if (!drawn && def?.icon) { + const img = cachedToolCursorIconImage(def.icon); + if (img && img.complete && (img.naturalWidth || img.width)) { + const pad = Math.max(4, size * 0.12); + c.drawImage(img, pad, pad, size - pad * 2, size - pad * 2); + drawn = true; + } else { + // Do not cache a label fallback while the real icon is still loading. + // Otherwise tools such as "new" and "delete" can remain as kanji text + // until a hard reload even after the image becomes available. + return null; + } + } + if (!drawn) drawn = drawToolCursorIconFallback(c, def?.iconText || def?.label || tool, size); + if (!drawn) return null; + toolCursorIconCache.set(key, canvas); + while (toolCursorIconCache.size > 64) toolCursorIconCache.delete(toolCursorIconCache.keys().next().value); + return canvas; +} + +function drawSelectedToolCursorIcon(ctx, world) { + const p = world?.pointer; + if (!p?.inside) return; + const info = toolCursorIconDefinition(world); + if (!info) return; + const activeUi = typeof uiCache !== "undefined" ? uiCache : null; + if (activeUi?.panning || activeUi?.grabbing || activeUi?.hosing || activeUi?.areaDeleting) return; + + const screen = world.worldToScreen ? world.worldToScreen(p.x, p.y) : { x: p.x, y: p.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(30, Math.min(46, Math.round(Math.min(canvasW, canvasH) * 0.055))); + const offset = Math.max(6, Math.round(size * 0.16)); + let x = screen.x + offset; + let y = screen.y + offset; + x = clamp(x, 8, canvasW - size - 8); + y = clamp(y, 8, canvasH - size - 8); + + let icon = buildToolCursorIconCanvas(info.tool, info.def, info.type, 96); + if (!icon && info.def?.icon) { + const img = cachedToolCursorIconImage(info.def.icon); + if (img && img.complete && (img.naturalWidth || img.width)) icon = img; + } + if (!icon) { + // Icon image may still be loading; request one more frame once it arrives. + cachedToolCursorIconImage(info.def?.icon || ""); + return; + } + + ctx.save(); + ctx.globalAlpha = 0.52; + ctx.shadowColor = "rgba(255,255,255,0.36)"; + ctx.shadowBlur = 5; + ctx.drawImage(icon, x, y, size, 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); @@ -150,31 +270,49 @@ function placementPreviewFor(world) { let tmp = { type, x, y, r, toolSize: sizeName, foodServingScale: sizeScale, dead: false }; if (typeof isRotatableItemType === "function" && isRotatableItemType(type)) tmp.angle = world?.toolAngleFor ? world.toolAngleFor(type) : (typeof defaultItemAngle === "function" ? defaultItemAngle(type) : 0); if (type === "reciprocator") { - tmp.reciprocatorAxisAngle = tmp.angle; + const pb = TARINAI_RENDER_GLOBAL.TarinaiPhysicsBodySystem; + pb?.ensureBody?.(tmp, world, { syncFromLegacy: false }); + pb?.setScalar?.(tmp, "railAxis", tmp.angle, "preview-axis"); tmp.angle = typeof defaultItemAngle === "function" ? defaultItemAngle(type) : 0; + pb?.syncPoseFromItem?.(tmp); } 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) }; } if (type === "rotator" && TARINAI_RENDER_GLOBAL.TarinaiMechanicalSystem) { - tmp.rotatorSpeed = Math.PI * 0.65; - tmp.rotatorPowered = true; - tmp.rotatorThickness = 12; - tmp.rotatorSegments = [[-78, 0, 78, 0], [0, -52, 0, 52]]; + const pb = TARINAI_RENDER_GLOBAL.TarinaiPhysicsBodySystem; + pb?.ensureBody?.(tmp, world, { syncFromLegacy: false }); + pb?.setScalar?.(tmp, "motorSpeed", Math.PI * 0.65, "preview"); + pb?.setScalar?.(tmp, "motorOn", true, "preview"); + pb?.setScalar?.(tmp, "thickness", 12, "preview"); + pb?.setSegments?.(tmp, [[-78, 0, 78, 0], [0, -52, 0, 52]], "preview"); + const rects = TARINAI_RENDER_GLOBAL.TarinaiMechanicalSystem.obstacleRects(tmp) || []; + const bounds = TARINAI_RENDER_GLOBAL.TarinaiMechanicalSystem.boundsAabb(tmp) || null; + return { type, x, y, r, rects, blocked: (bounds ? rectOutside(bounds) : circleOutside(x, y, r)) || world.placementBlocked?.(tmp) }; + } + if (type === "poison_block" && TARINAI_RENDER_GLOBAL.TarinaiMechanicalSystem) { + const pb = TARINAI_RENDER_GLOBAL.TarinaiPhysicsBodySystem; + // Use the actual default poison-block physics footprint for placement + // feedback. Previously it fell through to the generic oval preview, which + // did not match the placed shape. + pb?.ensureBody?.(tmp, null, { syncFromLegacy: false }); + tmp.world = null; const rects = TARINAI_RENDER_GLOBAL.TarinaiMechanicalSystem.obstacleRects(tmp) || []; const bounds = TARINAI_RENDER_GLOBAL.TarinaiMechanicalSystem.boundsAabb(tmp) || null; return { type, x, y, r, rects, blocked: (bounds ? rectOutside(bounds) : circleOutside(x, y, r)) || world.placementBlocked?.(tmp) }; } if (type === "reciprocator" && TARINAI_RENDER_GLOBAL.TarinaiMechanicalSystem) { - tmp.reciprocatorPowered = true; - tmp.reciprocatorSpeed = 92; - tmp.reciprocatorTravel = 150; - tmp.reciprocatorPhase = 0; - tmp.reciprocatorAnchorX = x; - tmp.reciprocatorAnchorY = y; - tmp.rotatorThickness = 12; - tmp.rotatorSegments = [[-78, 0, 78, 0]]; + const pb = TARINAI_RENDER_GLOBAL.TarinaiPhysicsBodySystem; + pb?.ensureBody?.(tmp, world, { syncFromLegacy: false }); + pb?.setScalar?.(tmp, "railOn", true, "preview"); + pb?.setScalar?.(tmp, "railMotorSpeed", 92, "preview"); + pb?.setScalar?.(tmp, "railTravel", 150, "preview"); + pb?.setScalar?.(tmp, "railPhase", 0, "preview"); + pb?.setScalar?.(tmp, "railAnchorX", x, "preview"); + pb?.setScalar?.(tmp, "railAnchorY", y, "preview"); + pb?.setScalar?.(tmp, "thickness", 12, "preview"); + pb?.setSegments?.(tmp, [[-78, 0, 78, 0]], "preview"); const rects = world.reciprocatorObstacleRects(tmp) || []; const bounds = rects.length ? { left: Math.min(...rects.map(rr => rr.left)), right: Math.max(...rects.map(rr => rr.right)), top: Math.min(...rects.map(rr => rr.top)), bottom: Math.max(...rects.map(rr => rr.bottom)) } : null; return { type, x, y, r, rects, blocked: (bounds ? rectOutside(bounds) : circleOutside(x, y, r)) || world.placementBlocked?.(tmp) }; @@ -603,7 +741,7 @@ function renderRadiusForEntity(entity) { if (!entity) return 40; if (entity.type === "genkotsu") return Math.max(420, (entity.r || 88) * 6.2); if (entity.type === "fence_v" || entity.type === "fence_h" || entity.type === "bounce_fence" || entity.type === "bounce_fence_v" || entity.type === "gate_fence") return Math.max(96, (entity.r || 24) * 4.2); - if (entity.type === "reciprocator") return Math.max(140, TARINAI_RENDER_GLOBAL.TarinaiMechanicalSystem?.reach?.(entity) || ((entity.r || 64) * 2.8 + (entity.reciprocatorTravel || 150))); + if (entity.type === "reciprocator") return Math.max(140, TARINAI_RENDER_GLOBAL.TarinaiMechanicalSystem?.reach?.(entity) || ((entity.r || 64) * 2.8 + (TARINAI_RENDER_GLOBAL.TarinaiPhysicsBodySystem?.scalar?.(entity, "railTravel", 150) || 150))); if (entity.type === "nest_box") return Math.max(86, (entity.r || 34) * 3.2); if (entity.type === "ant_nest") return Math.max(72, (entity.r || 28) * 3.0); if (entity.kind === "queen") return 42; @@ -1175,6 +1313,14 @@ function weatherLabel(weather) { return { sunny: "\u6674\u308c", cloudy: "\u66c7\u308a", light_rain: "\u5c0f\u96e8" }[weather] || "\u6674\u308c"; } +function renderPerfBegin(label) { + return window.TarinaiPerf?.begin?.(label) || null; +} + +function renderPerfEnd(end) { + if (end) end(); +} + function fillScreenFallback(ctx, w, h, lighting) { const light = lighting?.light ?? 0.7; ctx.save(); @@ -1203,10 +1349,12 @@ function render() { const cameraY = world.cameraY || 0; const viewScale = world.viewScale ? world.viewScale() : 1; + const endSetup = renderPerfBegin("render.setup"); const lighting = getLightingState(world); fillScreenFallback(ctx, screenW, screenH, lighting); const light = lighting.light; const cachedBackground = ensureBackgroundCache(sceneW, sceneH, lighting); + renderPerfEnd(endSetup); const beginFieldTransform = () => { ctx.save(); @@ -1215,6 +1363,7 @@ function render() { ctx.translate(-cameraX, -cameraY); }; + const endBackground = renderPerfBegin("render.background"); beginFieldTransform(); if (cachedBackground) { ctx.drawImage(cachedBackground, 0, 0, sceneW, sceneH); @@ -1223,28 +1372,39 @@ function render() { drawGardenBed(sceneW, sceneH, lighting); } ctx.restore(); - drawLightRays(ctx, screenW, screenH, lighting); + renderPerfEnd(endBackground); const visibleRect = visibleWorldRect(world, 180); beginFieldTransform(); + const endPreviews = renderPerfBegin("render.previews"); drawPlacementPreview(ctx, world); drawOperationToolPreview(ctx, world); + renderPerfEnd(endPreviews); drawTerrainLayer(ctx, world, lighting, visibleRect); // Render only entities that live in visible spatial cells; this keeps large offscreen colonies cheap. const renderStack = collectVisibleRenderStack(world, visibleRect); + window.TarinaiPerf?.setMetric?.("render.visibleBackItems", renderStack.backItems.length); + window.TarinaiPerf?.setMetric?.("render.visibleLayered", renderStack.layered.length); + window.TarinaiPerf?.setMetric?.("render.visibleEffects", (world.effects || []).length); + + const endBackItems = renderPerfBegin("render.draw.backItems"); for (const it of renderStack.backItems) { if (isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting); } + renderPerfEnd(endBackItems); // Parent-follow guide lines intentionally disabled; they looked like stray lines between Tarinai. + const endLayered = renderPerfBegin("render.draw.layered"); for (const entry of renderStack.layered) { if (isEntityVisibleInRect(entry.entity, visibleRect)) entry.entity.draw(ctx, world.time, lighting); } + renderPerfEnd(endLayered); + const endAttachments = renderPerfBegin("render.draw.attachments"); for (const it of renderStack.carriedPlushies) { syncCarriedPlushieToOwner(it, world); if (isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting); @@ -1252,14 +1412,20 @@ function render() { for (const it of renderStack.lodgedPins) { if (isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting); } + renderPerfEnd(endAttachments); + + const endEffects = renderPerfBegin("render.draw.effects"); for (const ef of world.effects) { if (isEntityVisibleInRect(ef, visibleRect)) ef.draw(ctx); } + renderPerfEnd(endEffects); ctx.restore(); // Light affecting the whole visible scene, including characters and ground. + const endLighting = renderPerfBegin("render.lightingWeather"); drawAtmosphericLighting(ctx, screenW, screenH, lighting); if (world.weather === "light_rain") drawRain(ctx, screenW, screenH, world.time); + renderPerfEnd(endLighting); const drawAtScreenPosition = (entity, drawFn) => { if (!entity) return; @@ -1275,9 +1441,13 @@ function render() { entity.y = oy; } }; + const endUiOverlay = renderPerfBegin("render.uiOverlay"); drawAtScreenPosition(world.selected, () => drawSelectedCard(ctx, world, lighting)); drawPointerItemTooltip(ctx, world); + drawSelectedToolCursorIcon(ctx, world); + renderPerfEnd(endUiOverlay); + const endHud = renderPerfBegin("render.hud"); // Time HUD ctx.save(); const hudW = 150; @@ -1305,6 +1475,7 @@ function render() { ctx.fillStyle = light > 0.42 ? "rgba(84,72,55,0.42)" : "rgba(230,235,255,0.48)"; ctx.fillText(`${window.__tarinaiFps || 0} fps`, w - 14, h - 12); ctx.restore(); + renderPerfEnd(endHud); } diff --git a/js/restore_coordinator.js b/js/restore_coordinator.js index 4948989..4e3701c 100644 --- a/js/restore_coordinator.js +++ b/js/restore_coordinator.js @@ -16,7 +16,6 @@ } function syncRestoreDependents(worldRef = global.world, context = {}) { - global.TarinaiFreezeSystem?.afterWorldRestored?.(worldRef, context); worldRef?.emit?.("ui:restore-sync", { source: context.source || "restore" }); renderWorldAfterRestore(worldRef); } diff --git a/js/save_codec.js b/js/save_codec.js index e960c89..bd1152a 100644 --- a/js/save_codec.js +++ b/js/save_codec.js @@ -571,7 +571,7 @@ if (JSON_EXTRA_ITEM_TYPE_IDS.has(typeId)) { try { const parsed = JSON.parse(reader.str() || "[]"); - return Array.isArray(parsed) ? parsed : []; + return (Array.isArray(parsed) || (parsed && typeof parsed === "object")) ? parsed : []; } catch (_) { return []; } diff --git a/js/save_schema.js b/js/save_schema.js index 2784d13..77cabd4 100644 --- a/js/save_schema.js +++ b/js/save_schema.js @@ -4,8 +4,8 @@ // Owns stable save identifiers shared by snapshot_system.js and save_codec.js. // Current-format save identifiers only. (function (global) { - const SNAPSHOT_VERSION = 7; - const BINARY_SCHEMA_VERSION = 10; + const SNAPSHOT_VERSION = 11; + const BINARY_SCHEMA_VERSION = 14; const FIELD_IDS = Object.freeze(["garden", "cage", "park"]); const GROUND_IDS = Object.freeze(["soil", "dirt", "concrete", "blanket", "foot_massage", "ice", "laboratory"]); @@ -21,7 +21,7 @@ "grass", "zunchi", "water", "grass_bed", "plushie", "nest_box", "duplicator", "sweet", "love_mochi", "fight_mochi", "sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice", "stone", "ball", "signboard", "ant_nest", "ant_corpse", "firecracker", "pushpin", "oshibyo", "fence_v", "fence_h", - "splat", "food", "bed", "genkotsu", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "reciprocator", "rope", "rod" + "splat", "food", "bed", "genkotsu", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "reciprocator", "rope", "rod", "poison_block" ]); function typeIds(names) { @@ -32,10 +32,10 @@ const SERVING_FOOD_TYPE_IDS = typeIds(["sweet", "love_mochi", "fight_mochi", "sleep_drug", "laxative", "protein", "niteropu", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice", "food"]); const PIN_TYPE_IDS = typeIds(["pushpin", "oshibyo"]); const FENCE_ITEM_TYPE_IDS = typeIds(["fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence"]); - const MECHANICAL_ITEM_TYPE_IDS = typeIds(["rotator", "reciprocator"]); + const MECHANICAL_ITEM_TYPE_IDS = typeIds(["rotator", "poison_block", "reciprocator"]); const LINK_ITEM_TYPE_IDS = typeIds(["rope", "rod"]); - const JSON_EXTRA_ITEM_TYPE_IDS = typeIds(["rotator", "reciprocator", "rope", "rod"]); - const ROTATABLE_ITEM_TYPE_IDS = typeIds(["fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "reciprocator"]); + const JSON_EXTRA_ITEM_TYPE_IDS = typeIds(["rotator", "poison_block", "reciprocator", "rope", "rod"]); + const ROTATABLE_ITEM_TYPE_IDS = typeIds(["fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "poison_block", "reciprocator"]); function enumIndex(list, value, fallback = 0) { const i = list.indexOf(String(value ?? "")); diff --git a/js/sim_core.js b/js/sim_core.js index ad60244..27d5d79 100644 --- a/js/sim_core.js +++ b/js/sim_core.js @@ -64,6 +64,58 @@ function stableUnit(seed, salt = "") { return ((h >>> 0) % 100000) / 100000; } +function stableKeyPart(value) { + if (value == null) return "null"; + if (typeof value === "number") return Number.isFinite(value) ? value.toFixed(3) : "nan"; + if (typeof value === "string" || typeof value === "boolean") return String(value); + if (typeof value === "object") { + const id = value.familyKey || value.id || value.seed || value.liveToken || value.name; + if (id) return `${value.type || value.constructor?.name || "entity"}:${id}`; + const x = Number.isFinite(value.x) ? value.x.toFixed(1) : "x"; + const y = Number.isFinite(value.y) ? value.y.toFixed(1) : "y"; + return `${value.type || value.constructor?.name || "entity"}@${x},${y}`; + } + return String(value); +} + +function deterministicFrame(worldRef = null, hz = 60) { + const t = Number(worldRef?.time || 0) || 0; + return Math.floor(t * Math.max(1, Number(hz) || 60) + 1e-6); +} + +function deterministicUnit(worldRef = null, salt = "", ...parts) { + const seed = String(worldRef?.worldSeed || "tarinai-world"); + const frame = deterministicFrame(worldRef, 60); + const serial = Number(worldRef?._deterministicEpoch || 0) || 0; + const key = [salt, frame, serial, ...parts.map(stableKeyPart)].join("|"); + return stableUnit(seed, key); +} + +function deterministicRange(worldRef = null, salt = "", min = 0, max = 1, ...parts) { + const a = Number(min) || 0; + const b = Number(max) || 0; + return a + deterministicUnit(worldRef, salt, ...parts) * (b - a); +} + +function deterministicSigned(worldRef = null, salt = "", ...parts) { + return deterministicUnit(worldRef, salt, ...parts) < 0.5 ? -1 : 1; +} + +function deterministicChance(worldRef = null, salt = "", chance = 0, ...parts) { + const p = clamp(Number(chance) || 0, 0, 1); + if (p <= 0) return false; + if (p >= 1) return true; + return deterministicUnit(worldRef, salt, ...parts) < p; +} + +function deterministicAngle(worldRef = null, salt = "", ...parts) { + return deterministicRange(worldRef, salt, 0, Math.PI * 2, ...parts); +} + +if (typeof window !== "undefined") { + window.TarinaiDeterminism = { stableKeyPart, deterministicFrame, deterministicUnit, deterministicRange, deterministicSigned, deterministicChance, deterministicAngle }; +} + const GENETIC_KEYS = ["lifeSpanMul", "attackMul", "speedMul", "sizeMul"]; const GENETIC_LIMITS = Object.freeze({ lifeSpanMul: [0.82, 1.24], @@ -653,10 +705,79 @@ class SpatialGrid { bucket.push(entity); } + addAabb(map, entity, bounds) { + if (!entity || !bounds) return this.add(map, entity); + const left = Number(bounds.left); + const right = Number(bounds.right); + const top = Number(bounds.top); + const bottom = Number(bounds.bottom); + if (!Number.isFinite(left) || !Number.isFinite(right) || !Number.isFinite(top) || !Number.isFinite(bottom)) return this.add(map, entity); + const minX = Math.floor(Math.min(left, right) / this.cellSize); + const maxX = Math.floor(Math.max(left, right) / this.cellSize); + const minY = Math.floor(Math.min(top, bottom) / this.cellSize); + const maxY = Math.floor(Math.max(top, bottom) / this.cellSize); + const cellCount = Math.max(1, (maxX - minX + 1) * (maxY - minY + 1)); + if (cellCount > 144) return this.add(map, entity); + for (let cy = minY; cy <= maxY; cy += 1) { + for (let cx = minX; cx <= maxX; cx += 1) { + const key = cx + cy * 100000; + let bucket = map.get(key); + if (!bucket) { + bucket = []; + map.set(key, bucket); + } + bucket.push(entity); + } + } + } + + boundsForTrait(it, trait) { + const mech = (typeof window !== "undefined" ? window : globalThis).TarinaiMechanicalSystem; + if (mech?.isMechanicalType?.(it?.type)) { + if (trait === "hazard" && it.type === "poison_block") return mech.hazardBoundsAabb?.(it) || mech.boundsAabb?.(it); + return mech.boundsAabb?.(it); + } + const ropePath = it?.type === "rope" ? ((typeof window !== "undefined" ? window : globalThis).TarinaiPhysicsBodySystem?.particles?.(it) || null) : null; + if (ropePath && ropePath.length) { + let left = Infinity, right = -Infinity, top = Infinity, bottom = -Infinity; + for (const p of ropePath) { + left = Math.min(left, (Number(p.x) || 0) - 8); + right = Math.max(right, (Number(p.x) || 0) + 8); + top = Math.min(top, (Number(p.y) || 0) - 8); + bottom = Math.max(bottom, (Number(p.y) || 0) + 8); + } + if (Number.isFinite(left)) return { left, right, top, bottom, item: it, type: it.type }; + } + const r = Math.max(Number(it?.r || it?.radius || 0) || 0, 12); + if (r > this.cellSize * 0.72) return { left: (it.x || 0) - r, right: (it.x || 0) + r, top: (it.y || 0) - r, bottom: (it.y || 0) + r, item: it, type: it?.type }; + return null; + } + + addTrait(map, it, trait) { + const bounds = this.boundsForTrait(it, trait); + if (bounds) this.addAabb(map, it, bounds); + else this.add(map, it); + } + isDynamicItem(it) { if (!it || it.dead) return false; const type = it.type || ""; - if (type === "ball" || type === "genkotsu" || type === "firecracker" || type === "pushpin" || type === "oshibyo" || (type === "rotator" && Math.abs(Number(it.rotatorSpeed || 0)) > 0.001)) return true; + if (type === "ball" || type === "genkotsu" || type === "firecracker" || type === "pushpin" || type === "oshibyo") return true; + if (type === "rope" || type === "rod") return true; + const globalRef = (typeof window !== "undefined" ? window : globalThis); + const physics = globalRef.TarinaiPhysicsBodySystem; + const body = physics?.isBodyType?.(it) ? physics.ensureBody?.(it, null, { syncFromLegacy: false }) : null; + if (type === "rotator") { + const motor = body?.motor || {}; + const velocity = body?.velocity || {}; + return Math.abs(Number(motor.speed || 0)) > 0.001 || Math.abs(Number(velocity.angular || 0)) > 0.001 || motor.powered !== false; + } + if (type === "reciprocator") { + const motor = body?.motor || {}; + const velocity = body?.velocity || {}; + return motor.powered !== false || Math.abs(Number(velocity.linear || 0)) > 0.05; + } + if (type === "poison_block") return Boolean(globalRef.TarinaiMechanicalSystem?.passiveItemAwake?.(it)); if ((it.dropTimer || 0) > 0) return true; if (Math.hypot(it.vx || 0, it.vy || 0) > 0.05) return true; if (it.isStructure && it.type === "plushie" && it.carriedById) return true; @@ -668,9 +789,19 @@ class SpatialGrid { const obstacleMap = dynamic ? this.dynamicObstacleCells : this.staticObstacleCells; const foodMap = dynamic ? this.dynamicFoodCells : this.staticFoodCells; const hazardMap = dynamic ? this.dynamicHazardCells : this.staticHazardCells; - if (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")) this.add(obstacleMap, it); - if (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)))) this.add(foodMap, it); - if (typeof itemHasTrait === "function" && itemHasTrait(type, "hazard")) this.add(hazardMap, it); + 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 hasHazard = typeof itemHasTrait === "function" && itemHasTrait(type, "hazard"); + if (type === "poison_block") { + const body = (typeof window !== "undefined" ? window : globalThis).TarinaiPhysicsBodySystem?.ensureBody?.(it, null, { syncFromLegacy: false }); + const solid = body?.collision ? body.collision.solid !== false : true; + if (solid && hasObstacle) this.addTrait(obstacleMap, it, "obstacle"); + if (hasHazard) this.addTrait(hazardMap, it, "hazard"); + return; + } + if (hasObstacle) this.addTrait(obstacleMap, it, "obstacle"); + if (hasFood) this.addTrait(foodMap, it, "food_interest"); + if (hasHazard) this.addTrait(hazardMap, it, "hazard"); } classifyItem(it) { @@ -763,6 +894,7 @@ class SpatialGrid { nearbyInto(map, x, y, radius, out = [], filterDistance = false) { const r = Number.isFinite(radius) ? radius : Math.max(1200, this.cellSize * 12); + const stamp = (this._spatialQueryStamp = (this._spatialQueryStamp || 0) + 1); const minX = Math.floor((x - r) / this.cellSize); const maxX = Math.floor((x + r) / this.cellSize); const minY = Math.floor((y - r) / this.cellSize); @@ -772,6 +904,8 @@ class SpatialGrid { const bucket = map.get(cx + cy * 100000); if (!bucket) continue; for (const entity of bucket) { + if (!entity || entity._spatialQueryStamp === stamp) continue; + entity._spatialQueryStamp = stamp; if (filterDistance) { const dx = (entity.x || 0) - x; const dy = (entity.y || 0) - y; @@ -794,6 +928,7 @@ class SpatialGrid { nearbyRectInto(map, rect, out = []) { if (!rect) return out; + const stamp = (this._spatialQueryStamp = (this._spatialQueryStamp || 0) + 1); const minX = Math.floor((rect.left || 0) / this.cellSize); const maxX = Math.floor((rect.right || 0) / this.cellSize); const minY = Math.floor((rect.top || 0) / this.cellSize); @@ -802,7 +937,11 @@ class SpatialGrid { for (let cx = minX; cx <= maxX; cx++) { const bucket = map.get(cx + cy * 100000); if (!bucket) continue; - for (const entity of bucket) out.push(entity); + for (const entity of bucket) { + if (!entity || entity._spatialQueryStamp === stamp) continue; + entity._spatialQueryStamp = stamp; + out.push(entity); + } } } return out; diff --git a/js/simulation_creature_system.js b/js/simulation_creature_system.js index d448f31..7e802b8 100644 --- a/js/simulation_creature_system.js +++ b/js/simulation_creature_system.js @@ -7,36 +7,99 @@ const helpers = () => global.TarinaiSimulationRuntime?.helpers || {}; const updatePolicy = () => global.TarinaiCreatureUpdatePolicy || {}; + function inc(map, key, n = 1) { + const k = String(key || "none"); + map[k] = (map[k] || 0) + n; + } + function updateCreatures(worldRef, dt) { const h = helpers(); - const end = global.TarinaiPerf?.begin?.("update.tarinai") || null; + const end = global.TarinaiPerf?.begin?.("update.tarinai.individual") || null; const visibleRect = h.updateVisibleWorldRect?.(worldRef, 160) || { left: -Infinity, top: -Infinity, right: Infinity, bottom: Infinity }; + const now = worldRef.time || 0; + const stats = { + total: 0, + full: 0, + realtime: 0, + smooth: 0, + skipped: 0, + selected: 0, + urgent: 0, + visible: 0, + near: 0, + far: 0, + lanes: {}, + states: {}, + smoothStates: {}, + skippedStates: {}, + sleepPhysical: 0, + passivePhysical: 0, + }; + const prevSpatialDeferMode = worldRef.deferSpatialRebuildMode || ""; + worldRef.deferSpatialRebuildDepth = (worldRef.deferSpatialRebuildDepth || 0) + 1; + // Smooth-motion Tarinai pass may read obstacle/item grids after Tarinai and + // constraint movement have dirtied mobile buckets. Use the last coherent + // grid during this phase and collapse mobile invalidations into one rebuild + // after all visible bodies have been advanced. + worldRef.deferSpatialRebuildMode = "mobile"; try { for (const t of worldRef.tarinai) { if (!t || t.dead) continue; - const interval = updatePolicy().updateInterval?.(worldRef, t, visibleRect) ?? 0; + stats.total += 1; + const behaviorId = updatePolicy().behaviorId?.(t) || t.state || "none"; + inc(stats.states, behaviorId); + const cadence = updatePolicy().updateCadence?.(worldRef, t, visibleRect) || { interval: 0, lane: "legacy", smoothMotion: false, visible: true, urgent: false }; + const interval = Number.isFinite(cadence.interval) ? cadence.interval : Infinity; + inc(stats.lanes, cadence.lane || "unknown"); + if (worldRef.selected === t) stats.selected += 1; + if (cadence.urgent) stats.urgent += 1; + if (cadence.sleepPhysical) stats.sleepPhysical += 1; + if (cadence.passivePhysicsNeed) stats.passivePhysical += 1; + if (cadence.visible) stats.visible += 1; + else if (interval < 0.75) stats.near += 1; + else stats.far += 1; if (!Number.isFinite(interval)) continue; + + const context = { cadence, motionDt: dt }; if (interval <= 0) { const runDt = Math.min(1.4, (t._updateAccum || 0) + dt); t._updateAccum = 0; - if (global.TarinaiCreatureRuntime?.updateOne) global.TarinaiCreatureRuntime.updateOne(t, runDt); + stats.full += 1; + stats.realtime += 1; + if (global.TarinaiCreatureRuntime?.updateOne) global.TarinaiCreatureRuntime.updateOne(t, runDt, { context }); else t.update(runDt); continue; } + t._updateAccum = Math.min(2.4, (t._updateAccum || 0) + dt); - t._nextLowFreqUpdateAt = Number.isFinite(t._nextLowFreqUpdateAt) ? t._nextLowFreqUpdateAt : (worldRef.time || 0) + interval * (0.65 + Math.random() * 0.7); - if ((worldRef.time || 0) >= t._nextLowFreqUpdateAt) { + if (!Number.isFinite(t._nextLowFreqUpdateAt)) { + const jitter = typeof stableUnit === "function" ? stableUnit(t.id || t.familyKey || Math.random(), "creature-cadence") : Math.random(); + t._nextLowFreqUpdateAt = now + interval * (0.65 + jitter * 0.70); + } + if (now >= t._nextLowFreqUpdateAt) { const runDt = Math.max(dt, t._updateAccum || dt); + const jitter = typeof stableUnit === "function" ? stableUnit(t.id || t.familyKey || Math.random(), `creature-cadence:${Math.floor(now * 2)}`) : Math.random(); t._updateAccum = 0; - t._nextLowFreqUpdateAt = (worldRef.time || 0) + interval; - if (global.TarinaiCreatureRuntime?.updateOne) global.TarinaiCreatureRuntime.updateOne(t, runDt); + t._nextLowFreqUpdateAt = now + interval * (0.92 + jitter * 0.22); + stats.full += 1; + if (global.TarinaiCreatureRuntime?.updateOne) global.TarinaiCreatureRuntime.updateOne(t, runDt, { context }); else t.update(runDt); + } else if (cadence.smoothMotion && global.TarinaiCreatureRuntime?.updateMotionOnly) { + if (global.TarinaiCreatureRuntime.updateMotionOnly(t, dt, { context })) { + stats.smooth += 1; + inc(stats.smoothStates, behaviorId); + } + } else { + stats.skipped += 1; + inc(stats.skippedStates, behaviorId); } } } finally { + worldRef.deferSpatialRebuildDepth = Math.max(0, (worldRef.deferSpatialRebuildDepth || 1) - 1); + worldRef.deferSpatialRebuildMode = prevSpatialDeferMode; + worldRef._creatureUpdateStats = stats; if (end) end(); } - const now = worldRef.time || 0; const bedConflictInterval = 3.2; if (now >= (worldRef.nextBedConflictCheckAt || 0)) { worldRef.nextBedConflictCheckAt = now + bedConflictInterval; @@ -45,15 +108,29 @@ } else { worldRef.workStats && (worldRef.workStats.bedConflictSkips = (worldRef.workStats.bedConflictSkips || 0) + 1); } - h.markSpatialDirtyIfMoved?.(worldRef, worldRef.tarinai, "tarinai-moved", 0.25); - worldRef.ensureSpatial?.("post-tarinai-update"); + const endSpatial = global.TarinaiPerf?.begin?.("update.tarinai.spatial") || null; + const tarinaiMoved = h.markSpatialDirtyIfMoved?.(worldRef, worldRef.tarinai, "tarinai-moved", 0.25) || false; + if (tarinaiMoved || worldRef.spatialDirty) { + if (worldRef.rebuildSpatial) worldRef.rebuildSpatial(true, worldRef.deferredSpatialDirtyReason || "post-tarinai-update"); + else worldRef.ensureSpatial?.("post-tarinai-update"); + worldRef.deferredSpatialDirtyReason = ""; + } + if (endSpatial) endSpatial(); + const endBallInteractions = global.TarinaiPerf?.begin?.("update.ballInteractions") || null; worldRef.limitBallChasers(); worldRef.resolveBallInteractions(dt); - const collisionInterval = 1.8; - if (now >= (worldRef.nextTarinaiCollisionCheckAt || 0)) { + if (endBallInteractions) endBallInteractions(); + const hasFastTarinai = (worldRef.tarinai || []).some(t => { + if (!t || t.dead || worldRef.isTarinaiHiddenInNestBox?.(t)) return false; + return Math.hypot(t.vx || 0, t.vy || 0) >= 150; + }); + const collisionInterval = hasFastTarinai ? 0 : 0.55; + if (hasFastTarinai || now >= (worldRef.nextTarinaiCollisionCheckAt || 0)) { worldRef.nextTarinaiCollisionCheckAt = now + collisionInterval; worldRef.workStats && (worldRef.workStats.collisionRuns = (worldRef.workStats.collisionRuns || 0) + 1); - worldRef.resolveTarinaiHighSpeedCollisions(dt); + const endCollision = global.TarinaiPerf?.begin?.("update.tarinai.collisions") || null; + try { worldRef.resolveTarinaiHighSpeedCollisions(dt); } + finally { if (endCollision) endCollision(); } } else { worldRef.workStats && (worldRef.workStats.collisionSkips = (worldRef.workStats.collisionSkips || 0) + 1); } diff --git a/js/simulation_item_ant_system.js b/js/simulation_item_ant_system.js index 61c7426..634db89 100644 --- a/js/simulation_item_ant_system.js +++ b/js/simulation_item_ant_system.js @@ -42,17 +42,34 @@ function updateItemsAndAnts(worldRef, dt) { const h = helpers(); const itemCountBefore = worldRef.items.length; - h.updateItemsScheduled?.(worldRef, dt); - updateRandomGrassGrowth(worldRef, dt); - const itemsMoved = h.markScheduledItemsMoved?.(worldRef, "items-moved", 0.25) || false; - if (worldRef.itemCounts?.ball) { - worldRef.ensureSpatial?.("ball-ball-collisions"); - worldRef.resolveBallBallCollisions(dt); - worldRef.markSpatialDirty?.("ball-ball-collisions"); + const prevMode = worldRef.deferSpatialRebuildMode || ""; + worldRef.deferSpatialRebuildDepth = (worldRef.deferSpatialRebuildDepth || 0) + 1; + worldRef.deferSpatialRebuildMode = "mobile"; + let itemsMoved = false; + let antsMoved = false; + try { + h.updateItemsScheduled?.(worldRef, dt); + updateRandomGrassGrowth(worldRef, dt); + itemsMoved = h.markScheduledItemsMoved?.(worldRef, "items-moved", 0.25) || false; + if (worldRef.itemCounts?.ball) { + worldRef.ensureSpatial?.("ball-ball-collisions"); + worldRef.resolveBallBallCollisions(dt); + worldRef.markSpatialDirty?.("ball-ball-collisions"); + } + worldRef.updateAnts?.(dt); + antsMoved = h.markSpatialDirtyIfMoved?.(worldRef, worldRef.ants || [], "ants-moved", 0.25) || false; + } finally { + worldRef.deferSpatialRebuildDepth = Math.max(0, (worldRef.deferSpatialRebuildDepth || 1) - 1); + worldRef.deferSpatialRebuildMode = prevMode; + } + if (itemsMoved || antsMoved || worldRef.spatialDirty) { + // Mobile items/ants are obstacle and contact sources for the next phase. + // Force one fresh grid after the mobile phase; reads inside the phase are + // allowed to use the frame-start grid to avoid rebuild ping-pong. + if (worldRef.rebuildSpatial) worldRef.rebuildSpatial(true, worldRef.deferredSpatialDirtyReason || "post-mobile-item-update"); + else worldRef.ensureSpatial?.("post-mobile-item-update"); + worldRef.deferredSpatialDirtyReason = ""; } - worldRef.updateAnts?.(dt); - const antsMoved = h.markSpatialDirtyIfMoved?.(worldRef, worldRef.ants || [], "ants-moved", 0.25) || false; - if (itemsMoved || antsMoved || worldRef.spatialDirty) worldRef.ensureSpatial?.("post-mobile-item-update"); return { itemCountBefore, itemsMoved, antsMoved }; } diff --git a/js/snapshot_system.js b/js/snapshot_system.js index d76bb81..682f828 100644 --- a/js/snapshot_system.js +++ b/js/snapshot_system.js @@ -322,50 +322,11 @@ if (type === "grass") return [q(item.growth, 100), q(item.health, 100), q(item.fertilityBoost, 100), item.manualGrass || item.placedByPlayer ? 1 : 0]; if (type === "zunchi") return [enumIndex(STAGE_IDS, item.stage || "fresh"), q(item.freshness, 100), q(item.fertility, 100)]; if (type === "signboard") return [item.text || ""]; - if (type === "rotator") return [ - q(item.angle, 1000, q(typeof global.defaultItemAngle === "function" ? global.defaultItemAngle(type) : 0, 1000)), - q(item.rotatorSpeed, 1000), - q(item.rotatorThickness || 12, 10), - (Array.isArray(item.rotatorSegments) ? item.rotatorSegments : [[-78,0,78,0],[0,-52,0,52]]).map(seg => [q(seg[0], 1), q(seg[1], 1), q(seg[2], 1), q(seg[3], 1)]), - item.rotatorPowered === false ? 0 : 1, - q(item.rotatorAngularVelocity || 0, 1000) - ]; - if (type === "reciprocator") return [ - q(item.angle, 1000, q(typeof global.defaultItemAngle === "function" ? global.defaultItemAngle(type) : 0, 1000)), - q(Number.isFinite(Number(item.reciprocatorAxisAngle)) ? item.reciprocatorAxisAngle : item.angle, 1000, q(typeof global.defaultItemAngle === "function" ? global.defaultItemAngle(type) : 0, 1000)), - item.reciprocatorPowered === false ? 0 : 1, - q(item.reciprocatorSpeed || 92, 10), - q(item.reciprocatorTravel || 150, 10), - q(item.reciprocatorPhase || 0, 1000), - q(item.reciprocatorVelocity || 0, 10), - q(item.reciprocatorAnchorX || item.x || 0, 1), - q(item.reciprocatorAnchorY || item.y || 0, 1), - q(item.reciprocatorDirection || 1, 1), - q(item.rotatorThickness || 12, 10), - (Array.isArray(item.rotatorSegments) ? item.rotatorSegments : [[-78,0,78,0]]).map(seg => [q(seg[0], 1), q(seg[1], 1), q(seg[2], 1), q(seg[3], 1)]) - ]; - - if (type === "rope" || type === "rod") { - const cloneEndpoint = ep => { - if (!ep) return null; - const kind = ep.kind || "item"; - const refIdx = kind === "tarinai" ? (tarinaiIndex.get(ep.id) ?? -1) : (itemIndex.get(ep.id) ?? -1); - return { - kind, - id: ep.id || "", - refIdx, - type: ep.type || "", - label: ep.label || "", - localX: q(ep.localX || 0, 1), - localY: q(ep.localY || 0, 1), - center: ep.center ? 1 : 0, - x: q(ep.x || 0, 1), - y: q(ep.y || 0, 1), - attachT: Number.isFinite(Number(ep.attachT)) ? q(ep.attachT, 1000) : null, - }; - }; - return [cloneEndpoint(item.linkA), cloneEndpoint(item.linkB), q(item.linkLength || item.r * 2 || 80, 1), q(item.linkMidX || item.x || 0, 1), q(item.linkMidY || item.y || 0, 1), q(item.linkMidVX || 0, 10), q(item.linkMidVY || 0, 10)]; + if (global.TarinaiPhysicsBodySystem?.isPhysicsType?.(type)) { + const packed = global.TarinaiPhysicsBodySystem.compactItemExtra?.(item, tarinaiIndex, itemIndex); + if (packed) return packed; } + if (global.TarinaiPhysicsBodySystem?.isPhysicsType?.(type)) return { pf: 2, missing: true }; if (type === "duplicator") return [enumIndex(ITEM_TYPE_IDS, item.storedFoodType || "", -1)]; if (type === "ball") return [q(item.vx, 10), q(item.vy, 10)]; if (type === "ant_nest") return [q(item.antCount, 1), q(item.queenSpawnAt, 10)]; @@ -411,67 +372,13 @@ item.stage = enumValue(STAGE_IDS, extra[0], "fresh"); item.freshness = u(extra[1], 100, item.freshness || 1); item.fertility = u(extra[2], 100, item.fertility || 1); } else if (type === "signboard") { item.text = String(extra[0] || ""); - } else if (type === "rotator") { - const e = Array.isArray(extra) ? extra : []; - const fallback = typeof global.defaultItemAngle === "function" ? global.defaultItemAngle(type) : 0; - item.angle = typeof global.normalizedItemAngle === "function" ? global.normalizedItemAngle(u(e[0], 1000, fallback), fallback) : u(e[0], 1000, fallback); - item.rotatorSpeed = u(e[1], 1000, item.rotatorSpeed || 0); - item.rotatorThickness = Math.max(4, Math.min(34, u(e[2], 10, item.rotatorThickness || 12))); - item.rotatorSegments = (Array.isArray(e[3]) ? e[3] : [[-78,0,78,0],[0,-52,0,52]]).map(seg => [u(seg[0],1), u(seg[1],1), u(seg[2],1), u(seg[3],1)]).filter(seg => Math.hypot(seg[2]-seg[0], seg[3]-seg[1]) >= 4); - item.rotatorPowered = e.length >= 5 ? !!e[4] : item.rotatorPowered !== false; - item.rotatorAngularVelocity = u(e[5], 1000, item.rotatorAngularVelocity || 0); - if (!item.rotatorSegments.length) item.rotatorSegments = [[-78,0,78,0]]; - const extent = Math.max(...item.rotatorSegments.flatMap(seg => [Math.hypot(seg[0], seg[1]), Math.hypot(seg[2], seg[3])]), 64) + item.rotatorThickness + 8; - item.r = Math.max(item.r || 64, Math.min(460, extent)); - } else if (type === "reciprocator") { - const e = Array.isArray(extra) ? extra : []; - const fallback = typeof global.defaultItemAngle === "function" ? global.defaultItemAngle(type) : 0; - item.angle = typeof global.normalizedItemAngle === "function" ? global.normalizedItemAngle(u(e[0], 1000, fallback), fallback) : u(e[0], 1000, fallback); - item.reciprocatorAxisAngle = typeof global.normalizedItemAngle === "function" ? global.normalizedItemAngle(u(e[1], 1000, item.angle), item.angle) : u(e[1], 1000, item.angle); - item.reciprocatorPowered = e.length >= 3 ? !!e[2] : item.reciprocatorPowered !== false; - item.reciprocatorSpeed = Math.max(0, u(e[3], 10, item.reciprocatorSpeed || 92)); - item.reciprocatorTravel = Math.max(24, u(e[4], 10, item.reciprocatorTravel || 150)); - item.reciprocatorPhase = clamp(u(e[5], 1000, item.reciprocatorPhase || 0), -1, 1); - item.reciprocatorVelocity = u(e[6], 10, item.reciprocatorVelocity || 0); - item.reciprocatorAnchorX = u(e[7], 1, item.reciprocatorAnchorX || item.x || 0); - item.reciprocatorAnchorY = u(e[8], 1, item.reciprocatorAnchorY || item.y || 0); - item.reciprocatorDirection = u(e[9], 1, item.reciprocatorDirection || 1) || 1; - item.rotatorThickness = Math.max(4, Math.min(34, u(e[10], 10, item.rotatorThickness || 12))); - item.rotatorSegments = (Array.isArray(e[11]) ? e[11] : (Array.isArray(item.rotatorSegments) ? item.rotatorSegments : [[-78,0,78,0]])).map(seg => [u(seg[0],1), u(seg[1],1), u(seg[2],1), u(seg[3],1)]).filter(seg => Math.hypot(seg[2]-seg[0], seg[3]-seg[1]) >= 4); - if (!item.rotatorSegments.length) item.rotatorSegments = [[-78,0,78,0]]; - const extent = Math.max(...item.rotatorSegments.flatMap(seg => [Math.hypot(seg[0], seg[1]), Math.hypot(seg[2], seg[3])]), 64) + item.rotatorThickness + 8; - item.r = Math.max(item.r || 64, Math.min(460, extent)); - } else if (type === "rope" || type === "rod") { - const e = Array.isArray(extra) ? extra : []; - const normalizeEndpoint = ep => { - if (!ep) return null; - const kind = ep.kind || "item"; - const refIdx = Number(ep.refIdx); - const indexedTarget = Number.isInteger(refIdx) && refIdx >= 0 - ? (kind === "tarinai" ? tarinaiList[refIdx] : itemList[refIdx]) - : null; - return { - kind, - id: indexedTarget?.id || ep.id || "", - type: ep.type || indexedTarget?.type || "", - label: ep.label || indexedTarget?.name || "", - localX: u(ep.localX, 1, 0), - localY: u(ep.localY, 1, 0), - center: !!ep.center, - x: u(ep.x, 1, item.x || 0), - y: u(ep.y, 1, item.y || 0), - attachT: ep.attachT == null ? null : clamp(u(ep.attachT, 1000, 0.5), 0, 1), - fuzzyResolve: !indexedTarget && !Number.isInteger(refIdx), - }; - }; - item.linkA = normalizeEndpoint(e[0]); - item.linkB = normalizeEndpoint(e[1]); - item.linkLength = Math.max(24, u(e[2], 1, item.linkLength || 80)); - item.linkMidX = u(e[3], 1, item.linkMidX || item.x || 0); - item.linkMidY = u(e[4], 1, item.linkMidY || item.y || 0); - item.linkMidVX = u(e[5], 10, item.linkMidVX || 0); - item.linkMidVY = u(e[6], 10, item.linkMidVY || 0); - item.r = Math.max(18, item.linkLength * 0.5); + } else if (global.TarinaiPhysicsBodySystem?.applyCompactExtra?.(item, extra, tarinaiList, itemList)) { + return; + } else if (global.TarinaiPhysicsBodySystem?.isPhysicsType?.(type)) { + // Physics items only accept the normalized pf:2 payload from this schema. + // Old array payloads are intentionally unsupported. + global.TarinaiPhysicsBodySystem?.invalidateItem?.(item, "physics-extra-missing"); + return; } else if (type === "duplicator") { item.storedFoodType = enumValue(ITEM_TYPE_IDS, extra[0], ""); item.storedFoodLabel = global.foodLabel ? global.foodLabel(item.storedFoodType) : item.storedFoodType; if (item.roles) item.roles.food = Boolean(item.storedFoodType); } else if (type === "ball") { @@ -564,7 +471,7 @@ const row = itemRows[idx] || []; const type = enumValue(ITEM_TYPE_IDS, row[0], ""); if (!type) continue; - const extra = Array.isArray(row[4]) ? row[4] : []; + const extra = (Array.isArray(row[4]) || (row[4] && typeof row[4] === "object")) ? row[4] : []; const owner = global.StructureRegistry?.get?.(type) ? worldRef.tarinai[extra[2]] || null : null; const item = global.StructureRegistry?.get?.(type) ? global.StructureRegistry.create(type, owner, u(row[1], 1), u(row[2], 1), worldRef) diff --git a/js/system_order.js b/js/system_order.js index 286579b..e25ec75 100644 --- a/js/system_order.js +++ b/js/system_order.js @@ -30,7 +30,15 @@ const frameDt = normalize(worldRef, dt); if (frameDt === null) return; const ctx = { dt: frameDt, mobile: null }; - for (const system of SYSTEM_ORDER) system.run(worldRef, ctx); + const profiler = global.TarinaiPerf; + for (const system of SYSTEM_ORDER) { + const end = profiler?.begin?.(`update.phase.${system.id}`) || null; + try { + system.run(worldRef, ctx); + } finally { + if (end) end(); + } + } } global.TarinaiSystemOrder = Object.freeze({ diff --git a/js/tarinai.js b/js/tarinai.js index 0c44ccc..baf2415 100644 --- a/js/tarinai.js +++ b/js/tarinai.js @@ -1,4 +1,34 @@ "use strict"; +(function ensureDeterministicHelpers(global) { + if (typeof global.deterministicChance === "function" && typeof global.deterministicRange === "function") return; + const clamp01 = v => Math.max(0, Math.min(1, Number(v) || 0)); + const keyPart = value => { + if (value == null) return "null"; + if (typeof value === "number") return Number.isFinite(value) ? value.toFixed(3) : "nan"; + if (typeof value === "string" || typeof value === "boolean") return String(value); + if (typeof value === "object") { + const id = value.familyKey || value.id || value.seed || value.liveToken || value.name; + if (id) return `${value.type || value.constructor?.name || "entity"}:${id}`; + const x = Number.isFinite(value.x) ? value.x.toFixed(1) : "x"; + const y = Number.isFinite(value.y) ? value.y.toFixed(1) : "y"; + return `${value.type || value.constructor?.name || "entity"}@${x},${y}`; + } + return String(value); + }; + const hashUnit = (seed, salt = "") => { + const str = `${seed}:${salt}`; + let h = 2166136261; + for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); } + return ((h >>> 0) % 100000) / 100000; + }; + global.deterministicFrame = global.deterministicFrame || ((worldRef = null, hz = 60) => Math.floor((Number(worldRef?.time || 0) || 0) * Math.max(1, Number(hz) || 60) + 1e-6)); + global.deterministicUnit = global.deterministicUnit || ((worldRef = null, salt = "", ...parts) => hashUnit(String(worldRef?.worldSeed || "tarinai-world"), [salt, global.deterministicFrame(worldRef, 60), Number(worldRef?._deterministicEpoch || 0) || 0, ...parts.map(keyPart)].join("|"))); + global.deterministicRange = global.deterministicRange || ((worldRef = null, salt = "", min = 0, max = 1, ...parts) => (Number(min) || 0) + global.deterministicUnit(worldRef, salt, ...parts) * ((Number(max) || 0) - (Number(min) || 0))); + global.deterministicSigned = global.deterministicSigned || ((worldRef = null, salt = "", ...parts) => global.deterministicUnit(worldRef, salt, ...parts) < 0.5 ? -1 : 1); + global.deterministicChance = global.deterministicChance || ((worldRef = null, salt = "", chance = 0, ...parts) => { const p = clamp01(chance); return p >= 1 || (p > 0 && global.deterministicUnit(worldRef, salt, ...parts) < p); }); + global.deterministicAngle = global.deterministicAngle || ((worldRef = null, salt = "", ...parts) => global.deterministicRange(worldRef, salt, 0, Math.PI * 2, ...parts)); +})(typeof window !== "undefined" ? window : globalThis); + function tarinaiRoundRectPath(ctx, x, y, w, h, r) { const rr = Math.max(0, Math.min(r || 0, Math.abs(w) / 2, Math.abs(h) / 2)); @@ -182,7 +212,7 @@ class Tarinai { constructor(world, opts = {}) { this.children = opts.children || []; this.nextEatSound = opts.nextEatSound ?? 0; this.fightTimer = opts.fightTimer ?? 0; - this.fightCooldown = opts.fightCooldown ?? rand(0, CONFIG.fightCooldown); + this.fightCooldown = opts.fightCooldown ?? deterministicRange(world, "initial-fight-cooldown", 0, CONFIG.fightCooldown, this.familyKey || this.id || opts.birthSeed); this.fightTargetId = opts.fightTargetId || null; this.fightTargetIds = Array.isArray(opts.fightTargetIds) ? [...opts.fightTargetIds] : (this.fightTargetId ? [this.fightTargetId] : []); this.nextHeadbutt = opts.nextHeadbutt ?? 0; diff --git a/js/tarinai_building_behavior.js b/js/tarinai_building_behavior.js index d65131b..30e5751 100644 --- a/js/tarinai_building_behavior.js +++ b/js/tarinai_building_behavior.js @@ -37,6 +37,31 @@ function stopFailedBuildPlan(t, world, type = "", reason = "") { return false; } + +function forceBuiltStructureVisualRefresh(world, structure, reason = "structure-built") { + if (!world) return; + world.drawListDirty = true; + if (world._renderStack) world._renderStack.signature = ""; + if (world._visibleRenderStack) { + world._visibleRenderStack.backItems = []; + world._visibleRenderStack.layered = []; + world._visibleRenderStack.carriedPlushies = []; + world._visibleRenderStack.lodgedPins = []; + } + world.markItemBucketsDirty?.(reason); + world.markSpatialDirty?.(reason); + world.ensureItemBuckets?.(reason); + world.ensureSpatial?.(reason); + if (structure) { + structure.world = world; + structure._builtVisibleAt = Number(world.time || 0) || 0; + } + if (typeof globalThis.render === "function") { + globalThis.render(); + globalThis.requestAnimationFrame?.(() => globalThis.render?.()); + } +} + function findStructureBuildSpot(t, world, type) { const baseX = Number.isFinite(t?.x) ? t.x : 0; const baseY = Number.isFinite(t?.y) ? t.y : 0; @@ -114,9 +139,12 @@ function continueBuildPlan(t, world, dt) { structure.x = t.x; structure.y = t.y - Math.max(28, (t.radius || 24) * 1.28); } - world.addItem?.(structure, `build:${plan.type}`) || world.items.push(structure); - world.drawListDirty = true; + const addedStructure = world.addItem?.(structure, `build:${plan.type}`) || (world.items.push(structure), structure); + if (!addedStructure) { + return stopFailedBuildPlan(t, world, plan.type, "作る場所が見つからない"); + } structure.use?.(t, world); + forceBuiltStructureVisualRefresh(world, structure, `build:${plan.type}:visual-refresh`); t.setActionState?.("idle", { target: structure, reason: `${label}\u3092\u4f5c\u3063\u305f`, sleeping: false }); applyNeedSatisfaction(t, { fulfill: plan.type === "plushie" ? 50 : 36, safety: plan.type === "plushie" ? 4 : 8 }, plan.type); t.buildPlan = null; diff --git a/js/tarinai_disease_nest.js b/js/tarinai_disease_nest.js index c917e82..64652a4 100644 --- a/js/tarinai_disease_nest.js +++ b/js/tarinai_disease_nest.js @@ -1,4 +1,34 @@ "use strict"; +(function ensureDeterministicHelpers(global) { + if (typeof global.deterministicChance === "function" && typeof global.deterministicRange === "function") return; + const clamp01 = v => Math.max(0, Math.min(1, Number(v) || 0)); + const keyPart = value => { + if (value == null) return "null"; + if (typeof value === "number") return Number.isFinite(value) ? value.toFixed(3) : "nan"; + if (typeof value === "string" || typeof value === "boolean") return String(value); + if (typeof value === "object") { + const id = value.familyKey || value.id || value.seed || value.liveToken || value.name; + if (id) return `${value.type || value.constructor?.name || "entity"}:${id}`; + const x = Number.isFinite(value.x) ? value.x.toFixed(1) : "x"; + const y = Number.isFinite(value.y) ? value.y.toFixed(1) : "y"; + return `${value.type || value.constructor?.name || "entity"}@${x},${y}`; + } + return String(value); + }; + const hashUnit = (seed, salt = "") => { + const str = `${seed}:${salt}`; + let h = 2166136261; + for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); } + return ((h >>> 0) % 100000) / 100000; + }; + global.deterministicFrame = global.deterministicFrame || ((worldRef = null, hz = 60) => Math.floor((Number(worldRef?.time || 0) || 0) * Math.max(1, Number(hz) || 60) + 1e-6)); + global.deterministicUnit = global.deterministicUnit || ((worldRef = null, salt = "", ...parts) => hashUnit(String(worldRef?.worldSeed || "tarinai-world"), [salt, global.deterministicFrame(worldRef, 60), Number(worldRef?._deterministicEpoch || 0) || 0, ...parts.map(keyPart)].join("|"))); + global.deterministicRange = global.deterministicRange || ((worldRef = null, salt = "", min = 0, max = 1, ...parts) => (Number(min) || 0) + global.deterministicUnit(worldRef, salt, ...parts) * ((Number(max) || 0) - (Number(min) || 0))); + global.deterministicSigned = global.deterministicSigned || ((worldRef = null, salt = "", ...parts) => global.deterministicUnit(worldRef, salt, ...parts) < 0.5 ? -1 : 1); + global.deterministicChance = global.deterministicChance || ((worldRef = null, salt = "", chance = 0, ...parts) => { const p = clamp01(chance); return p >= 1 || (p > 0 && global.deterministicUnit(worldRef, salt, ...parts) < p); }); + global.deterministicAngle = global.deterministicAngle || ((worldRef = null, salt = "", ...parts) => global.deterministicRange(worldRef, salt, 0, Math.PI * 2, ...parts)); +})(typeof window !== "undefined" ? window : globalThis); + (function (global) { const Tarinai = global.Tarinai; @@ -37,12 +67,12 @@ emitZunchiDiseaseEffect(dt) { if (!this.zunchiDisease || !this.world?.effects) return; - if (Math.random() < dt * (1.2 + (this.zunchiDiseaseSeverity || 0) * 1.4)) { - this.world.effects.push(new Effect("zunchi_miasma", this.x + rand(-this.radius * 0.45, this.radius * 0.45), this.y - this.radius * rand(0.05, 0.72), { - vx: rand(-5, 5), - vy: rand(-10, -3), - life: rand(1.4, 2.4), - size: rand(8, 18) * (0.8 + (this.zunchiDiseaseSeverity || 0) * 0.45), + if (deterministicChance(this.world, "zunchi-disease-miasma", dt * (1.2 + (this.zunchiDiseaseSeverity || 0) * 1.4), this)) { + this.world.effects.push(new Effect("zunchi_miasma", this.x + deterministicRange(this.world, "zunchi-miasma-x", -this.radius * 0.45, this.radius * 0.45, this), this.y - this.radius * deterministicRange(this.world, "zunchi-miasma-y", 0.05, 0.72, this), { + vx: deterministicRange(this.world, "zunchi-miasma-vx", -5, 5, this), + vy: deterministicRange(this.world, "zunchi-miasma-vy", -10, -3, this), + life: deterministicRange(this.world, "zunchi-miasma-life", 1.4, 2.4, this), + size: deterministicRange(this.world, "zunchi-miasma-size", 8, 18, this) * (0.8 + (this.zunchiDiseaseSeverity || 0) * 0.45), color: "rgba(16,78,28,0.70)", })); } @@ -55,7 +85,8 @@ this.zunchiStain = clamp(this.zunchiStain - dt * 2.4, 0, 100); if (this.zunchiDisease) this.zunchiDiseaseSeverity = Math.max(0, (this.zunchiDiseaseSeverity || 1) - dt * 0.006); } - if (!this.zunchiDisease && this.zunchiStain > 82 && Math.random() < (this.diseaseChance ? this.diseaseChance(dt * clamp((this.zunchiStain - 82) / 18, 0, 1) * 0.045) : dt * clamp((this.zunchiStain - 82) / 18, 0, 1) * 0.045)) { + const zunchiSelfInfectChance = this.diseaseChance ? this.diseaseChance(dt * clamp((this.zunchiStain - 82) / 18, 0, 1) * 0.045) : dt * clamp((this.zunchiStain - 82) / 18, 0, 1) * 0.045; + if (!this.zunchiDisease && this.zunchiStain > 82 && deterministicChance(this.world, "zunchi-self-infect", zunchiSelfInfectChance, this)) { this.infectZunchiDisease(null, true); this.world?.log?.(`${this.name}\u306f\u305a\u3093\u3061\u75c5\u3092\u767a\u75c7\u3057\u305f\u3002`, "accident", { participants: [this] }); } @@ -238,8 +269,8 @@ const entry = this.world.nestBoxExitPoint ? this.world.nestBoxExitPoint(b, this) : this.world.nestBoxEntryPoint(b); this.x = clamp(entry.x, CONFIG.worldPadding, this.world.w - CONFIG.worldPadding); this.y = clamp(entry.y, CONFIG.worldPadding, this.world.h - CONFIG.worldPadding); - this.vx = rand(-8, 8); - this.vy = rand(6, 18); + this.vx = deterministicRange(this.world, "sleep-disease-shiver-vx", -8, 8, this); + this.vy = deterministicRange(this.world, "sleep-disease-shiver-vy", 6, 18, this); } this.insideNestBoxId = null; this.nestFade = 0; @@ -316,32 +347,32 @@ if (this.explosionDisease) { this.explosionDiseaseTimer = Math.max(0, (this.explosionDiseaseTimer || 0) - dt); if (this.addStress) this.addStress(dt * 0.22, { threshold: 9, duration: 3.6 }); - if (Math.random() < dt * 2.1) { - const a = rand(0, Math.PI * 2); - this.world?.effects?.push(new Effect("explosion", this.x + Math.cos(a) * this.radius * rand(0.2, 0.9), this.y + Math.sin(a) * this.radius * rand(0.1, 0.8), { - vx: Math.cos(a) * rand(10, 38), vy: Math.sin(a) * rand(10, 38) - rand(8, 22), size: rand(5, 12), life: rand(0.18, 0.42), color: "rgba(255,64,48,0.86)" + if (deterministicChance(this.world, "explosion-disease-spark", dt * 2.1, this)) { + const a = deterministicAngle(this.world, "explosion-disease-spark-angle", this); + this.world?.effects?.push(new Effect("explosion", this.x + Math.cos(a) * this.radius * deterministicRange(this.world, "explosion-disease-spark-rx", 0.2, 0.9, this), this.y + Math.sin(a) * this.radius * deterministicRange(this.world, "explosion-disease-spark-ry", 0.1, 0.8, this), { + vx: Math.cos(a) * deterministicRange(this.world, "explosion-disease-spark-vx", 10, 38, this), vy: Math.sin(a) * deterministicRange(this.world, "explosion-disease-spark-vy", 10, 38, this) - deterministicRange(this.world, "explosion-disease-spark-up", 8, 22, this), size: deterministicRange(this.world, "explosion-disease-spark-size", 5, 12, this), life: deterministicRange(this.world, "explosion-disease-spark-life", 0.18, 0.42, this), color: "rgba(255,64,48,0.86)" })); } if (this.explosionDiseaseTimer <= 0.01) { this.world?.explodeDiseaseTarinai?.(this); return; } - if (this.explosionDiseaseTimer < 8 && Math.random() < dt * 0.45) this.bubble("!", 1.0, "rgba(178,78,42,0.82)"); + if (this.explosionDiseaseTimer < 8 && deterministicChance(this.world, "explosion-disease-bubble", dt * 0.45, this)) this.bubble("!", 1.0, "rgba(178,78,42,0.82)"); } const bleedAge = (this.world?.time || 0) - (this.lastBleedAt || -999); if (!this.fightDisease && this.fightDiseaseCooldown <= 0 && bleedAge >= (CONFIG.dayLength || 120) * 5 / 24 && bleedAge < (CONFIG.dayLength || 120) * 0.85) { - if (Math.random() < (this.diseaseChance ? this.diseaseChance(dt * 0.000375) : dt * 0.000375)) this.infectFightDisease(); + if (deterministicChance(this.world, "fight-disease-from-bleed", this.diseaseChance ? this.diseaseChance(dt * 0.000375) : dt * 0.000375, this)) this.infectFightDisease(); } - if (this.zunchiDisease && Math.random() < dt * 0.00055 * (this.energy > 66 ? 1.8 : 1.0)) { + if (this.zunchiDisease && deterministicChance(this.world, "zunchi-natural-recover", dt * 0.00055 * (this.energy > 66 ? 1.8 : 1.0), this)) { if (this.recoverZunchiDisease("\u81ea\u7136\u306b\u305a\u3093\u3061\u75c5\u304c\u6cbb\u307e\u3063\u305f")) this.world?.log?.(`${this.name}\u306e\u305a\u3093\u3061\u75c5\u304c\u81ea\u7136\u306b\u6cbb\u3063\u305f\u3002`, "accident", { participants: [this] }); } - if (this.fightDisease && Math.random() < dt * 0.00070 * (this.energy > 58 ? 1.4 : 1.0)) { + if (this.fightDisease && deterministicChance(this.world, "fight-disease-natural-recover", dt * 0.00070 * (this.energy > 58 ? 1.4 : 1.0), this)) { this.recoverFightDisease("\u81ea\u7136\u306b\u304d\u305a\u3064\u304d\u75c5\u304c\u6cbb\u3063\u305f"); } if (this.fightDisease) { if (typeof applyNeedShock === "function") applyNeedShock(this, { health: dt * 3, safety: dt * 2 }); - if (Math.random() < dt * 0.30) this.wanderAngle += rand(-2.8, 2.8); - if (Math.random() < dt * 0.08) this.bubble("?", 2.6, "rgba(150,78,44,0.80)"); + if (deterministicChance(this.world, "fight-disease-wander", dt * 0.30, this)) this.wanderAngle += deterministicRange(this.world, "fight-disease-wander-angle", -2.8, 2.8, this); + if (deterministicChance(this.world, "fight-disease-bubble", dt * 0.08, this)) this.bubble("?", 2.6, "rgba(150,78,44,0.80)"); } } })); diff --git a/js/tarinai_needs_items.js b/js/tarinai_needs_items.js index 9ae76a3..e5fa49c 100644 --- a/js/tarinai_needs_items.js +++ b/js/tarinai_needs_items.js @@ -1,4 +1,34 @@ "use strict"; +(function ensureDeterministicHelpers(global) { + if (typeof global.deterministicChance === "function" && typeof global.deterministicRange === "function") return; + const clamp01 = v => Math.max(0, Math.min(1, Number(v) || 0)); + const keyPart = value => { + if (value == null) return "null"; + if (typeof value === "number") return Number.isFinite(value) ? value.toFixed(3) : "nan"; + if (typeof value === "string" || typeof value === "boolean") return String(value); + if (typeof value === "object") { + const id = value.familyKey || value.id || value.seed || value.liveToken || value.name; + if (id) return `${value.type || value.constructor?.name || "entity"}:${id}`; + const x = Number.isFinite(value.x) ? value.x.toFixed(1) : "x"; + const y = Number.isFinite(value.y) ? value.y.toFixed(1) : "y"; + return `${value.type || value.constructor?.name || "entity"}@${x},${y}`; + } + return String(value); + }; + const hashUnit = (seed, salt = "") => { + const str = `${seed}:${salt}`; + let h = 2166136261; + for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); } + return ((h >>> 0) % 100000) / 100000; + }; + global.deterministicFrame = global.deterministicFrame || ((worldRef = null, hz = 60) => Math.floor((Number(worldRef?.time || 0) || 0) * Math.max(1, Number(hz) || 60) + 1e-6)); + global.deterministicUnit = global.deterministicUnit || ((worldRef = null, salt = "", ...parts) => hashUnit(String(worldRef?.worldSeed || "tarinai-world"), [salt, global.deterministicFrame(worldRef, 60), Number(worldRef?._deterministicEpoch || 0) || 0, ...parts.map(keyPart)].join("|"))); + global.deterministicRange = global.deterministicRange || ((worldRef = null, salt = "", min = 0, max = 1, ...parts) => (Number(min) || 0) + global.deterministicUnit(worldRef, salt, ...parts) * ((Number(max) || 0) - (Number(min) || 0))); + global.deterministicSigned = global.deterministicSigned || ((worldRef = null, salt = "", ...parts) => global.deterministicUnit(worldRef, salt, ...parts) < 0.5 ? -1 : 1); + global.deterministicChance = global.deterministicChance || ((worldRef = null, salt = "", chance = 0, ...parts) => { const p = clamp01(chance); return p >= 1 || (p > 0 && global.deterministicUnit(worldRef, salt, ...parts) < p); }); + global.deterministicAngle = global.deterministicAngle || ((worldRef = null, salt = "", ...parts) => global.deterministicRange(worldRef, salt, 0, Math.PI * 2, ...parts)); +})(typeof window !== "undefined" ? window : globalThis); + // Need planning runtime and Tarinai prototype extensions. // Targeting, ActionSpec definitions, consumables, social ticks, and building are split into dedicated modules. @@ -600,7 +630,7 @@ function resolveNeedsCore(dt) { this.fightTargetIds = []; this.fightTargetId = null; this.counterAttackFromId = null; - this.fightCooldown = Math.max(this.fightCooldown || 0, CONFIG.fightCooldown + rand(4, 8)); + this.fightCooldown = Math.max(this.fightCooldown || 0, CONFIG.fightCooldown + deterministicRange(this.world, "fight-lost-target-cooldown", 4, 8, this, rival)); if (rival) this.world?.markFightPairCooldown?.(this, rival, CONFIG.fightCooldown + 6); if (typeof clearForcedBehaviorQueue === "function") clearForcedBehaviorQueue(this, e => e && e.id === "fight_rival"); this.setActionState?.("idle", { target: null, reason: "相手を見失った。", sleeping: false, clearTarget: true }); diff --git a/js/tarinai_runtime.js b/js/tarinai_runtime.js index 0255f9c..791825f 100644 --- a/js/tarinai_runtime.js +++ b/js/tarinai_runtime.js @@ -18,9 +18,21 @@ return true; } - function updateOne(tarinai, dt) { + function updateOne(tarinai, dt, options = {}) { if (!tarinai || tarinai.dead || !global.TarinaiUpdatePipeline?.updateOne) return false; - return global.TarinaiUpdatePipeline.updateOne(tarinai, dt) !== false; + return global.TarinaiUpdatePipeline.updateOne(tarinai, dt, options) !== false; + } + + function updateMotionOnly(tarinai, dt, options = {}) { + if (!tarinai || tarinai.dead || !global.TarinaiMovementUpdateStep?.update) return false; + tarinai.prevX = tarinai.x; + tarinai.prevY = tarinai.y; + const motionDt = Math.max(0.001, Math.min(0.05, Number(options.context?.motionDt ?? dt) || 0.016)); + return global.TarinaiMovementUpdateStep.update(tarinai, motionDt, { + ...(options.context || {}), + motionOnly: true, + motionDt, + })?.done !== true || !tarinai.dead; } function updateBatch(tarinaiList, dt) { @@ -30,6 +42,6 @@ return ran; } - global.TarinaiCreatureRuntime = Object.freeze({ installPrototypeBoundary, updateOne, updateBatch }); + global.TarinaiCreatureRuntime = Object.freeze({ installPrototypeBoundary, updateOne, updateMotionOnly, updateBatch }); installPrototypeBoundary(); })(typeof window !== "undefined" ? window : globalThis); diff --git a/js/tarinai_social_action_runtime.js b/js/tarinai_social_action_runtime.js index 2ac559d..3677492 100644 --- a/js/tarinai_social_action_runtime.js +++ b/js/tarinai_social_action_runtime.js @@ -1,4 +1,34 @@ "use strict"; +(function ensureDeterministicHelpers(global) { + if (typeof global.deterministicChance === "function" && typeof global.deterministicRange === "function") return; + const clamp01 = v => Math.max(0, Math.min(1, Number(v) || 0)); + const keyPart = value => { + if (value == null) return "null"; + if (typeof value === "number") return Number.isFinite(value) ? value.toFixed(3) : "nan"; + if (typeof value === "string" || typeof value === "boolean") return String(value); + if (typeof value === "object") { + const id = value.familyKey || value.id || value.seed || value.liveToken || value.name; + if (id) return `${value.type || value.constructor?.name || "entity"}:${id}`; + const x = Number.isFinite(value.x) ? value.x.toFixed(1) : "x"; + const y = Number.isFinite(value.y) ? value.y.toFixed(1) : "y"; + return `${value.type || value.constructor?.name || "entity"}@${x},${y}`; + } + return String(value); + }; + const hashUnit = (seed, salt = "") => { + const str = `${seed}:${salt}`; + let h = 2166136261; + for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); } + return ((h >>> 0) % 100000) / 100000; + }; + global.deterministicFrame = global.deterministicFrame || ((worldRef = null, hz = 60) => Math.floor((Number(worldRef?.time || 0) || 0) * Math.max(1, Number(hz) || 60) + 1e-6)); + global.deterministicUnit = global.deterministicUnit || ((worldRef = null, salt = "", ...parts) => hashUnit(String(worldRef?.worldSeed || "tarinai-world"), [salt, global.deterministicFrame(worldRef, 60), Number(worldRef?._deterministicEpoch || 0) || 0, ...parts.map(keyPart)].join("|"))); + global.deterministicRange = global.deterministicRange || ((worldRef = null, salt = "", min = 0, max = 1, ...parts) => (Number(min) || 0) + global.deterministicUnit(worldRef, salt, ...parts) * ((Number(max) || 0) - (Number(min) || 0))); + global.deterministicSigned = global.deterministicSigned || ((worldRef = null, salt = "", ...parts) => global.deterministicUnit(worldRef, salt, ...parts) < 0.5 ? -1 : 1); + global.deterministicChance = global.deterministicChance || ((worldRef = null, salt = "", chance = 0, ...parts) => { const p = clamp01(chance); return p >= 1 || (p > 0 && global.deterministicUnit(worldRef, salt, ...parts) < p); }); + global.deterministicAngle = global.deterministicAngle || ((worldRef = null, salt = "", ...parts) => global.deterministicRange(worldRef, salt, 0, Math.PI * 2, ...parts)); +})(typeof window !== "undefined" ? window : globalThis); + // Social, panic, fight, mate behavior runtime ticks. @@ -77,7 +107,7 @@ function updateFightBehavior(t, world, dt) { t.fightTargetIds = []; t.fightTargetId = null; t.counterAttackFromId = null; - t.fightCooldown = Math.max(t.fightCooldown || 0, CONFIG.fightCooldown + rand(4, 8)); + t.fightCooldown = Math.max(t.fightCooldown || 0, CONFIG.fightCooldown + deterministicRange(world, "fight-target-lost-cooldown", 4, 8, t, rival)); if (isLiveTarinaiEntity(rival)) world.markFightPairCooldown?.(t, rival, CONFIG.fightCooldown + 6); if (typeof clearForcedBehaviorQueue === "function") clearForcedBehaviorQueue(t, e => e && e.id === "fight_rival"); t.setActionState?.("idle", { target: null, reason: "相手を見失った。", sleeping: false, clearTarget: true }); diff --git a/js/tarinai_social_move_life.js b/js/tarinai_social_move_life.js index 987ea28..109e048 100644 --- a/js/tarinai_social_move_life.js +++ b/js/tarinai_social_move_life.js @@ -1,4 +1,34 @@ "use strict"; +(function ensureDeterministicHelpers(global) { + if (typeof global.deterministicChance === "function" && typeof global.deterministicRange === "function") return; + const clamp01 = v => Math.max(0, Math.min(1, Number(v) || 0)); + const keyPart = value => { + if (value == null) return "null"; + if (typeof value === "number") return Number.isFinite(value) ? value.toFixed(3) : "nan"; + if (typeof value === "string" || typeof value === "boolean") return String(value); + if (typeof value === "object") { + const id = value.familyKey || value.id || value.seed || value.liveToken || value.name; + if (id) return `${value.type || value.constructor?.name || "entity"}:${id}`; + const x = Number.isFinite(value.x) ? value.x.toFixed(1) : "x"; + const y = Number.isFinite(value.y) ? value.y.toFixed(1) : "y"; + return `${value.type || value.constructor?.name || "entity"}@${x},${y}`; + } + return String(value); + }; + const hashUnit = (seed, salt = "") => { + const str = `${seed}:${salt}`; + let h = 2166136261; + for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); } + return ((h >>> 0) % 100000) / 100000; + }; + global.deterministicFrame = global.deterministicFrame || ((worldRef = null, hz = 60) => Math.floor((Number(worldRef?.time || 0) || 0) * Math.max(1, Number(hz) || 60) + 1e-6)); + global.deterministicUnit = global.deterministicUnit || ((worldRef = null, salt = "", ...parts) => hashUnit(String(worldRef?.worldSeed || "tarinai-world"), [salt, global.deterministicFrame(worldRef, 60), Number(worldRef?._deterministicEpoch || 0) || 0, ...parts.map(keyPart)].join("|"))); + global.deterministicRange = global.deterministicRange || ((worldRef = null, salt = "", min = 0, max = 1, ...parts) => (Number(min) || 0) + global.deterministicUnit(worldRef, salt, ...parts) * ((Number(max) || 0) - (Number(min) || 0))); + global.deterministicSigned = global.deterministicSigned || ((worldRef = null, salt = "", ...parts) => global.deterministicUnit(worldRef, salt, ...parts) < 0.5 ? -1 : 1); + global.deterministicChance = global.deterministicChance || ((worldRef = null, salt = "", chance = 0, ...parts) => { const p = clamp01(chance); return p >= 1 || (p > 0 && global.deterministicUnit(worldRef, salt, ...parts) < p); }); + global.deterministicAngle = global.deterministicAngle || ((worldRef = null, salt = "", ...parts) => global.deterministicRange(worldRef, salt, 0, Math.PI * 2, ...parts)); +})(typeof window !== "undefined" ? window : globalThis); + (function (global) { const Tarinai = global.Tarinai; @@ -35,10 +65,12 @@ } } - if (this.zunchiDisease && !o.zunchiDisease && (o.zunchiDiseaseCooldown || 0) <= 0 && d < 54 && Math.random() < (o.diseaseChance ? o.diseaseChance(dt * 0.065 * clamp(1 - d / 58, 0.12, 1)) : dt * 0.065 * clamp(1 - d / 58, 0.12, 1))) { + const zunchiSpreadChanceA = o.diseaseChance ? o.diseaseChance(dt * 0.065 * clamp(1 - d / 58, 0.12, 1)) : dt * 0.065 * clamp(1 - d / 58, 0.12, 1); + if (this.zunchiDisease && !o.zunchiDisease && (o.zunchiDiseaseCooldown || 0) <= 0 && d < 54 && deterministicChance(this.world, "social-zunchi-spread-a", zunchiSpreadChanceA, this, o)) { if (o.infectZunchiDisease(this)) this.world.log(`${this.name}\u306e\u305a\u3093\u3061\u75c5\u304c${o.name}\u306b\u3046\u3064\u3063\u305f\u3002`, "accident", { participants: [o, this] }); } - if (o.zunchiDisease && !this.zunchiDisease && (this.zunchiDiseaseCooldown || 0) <= 0 && d < 54 && Math.random() < (this.diseaseChance ? this.diseaseChance(dt * 0.065 * clamp(1 - d / 58, 0.12, 1)) : dt * 0.065 * clamp(1 - d / 58, 0.12, 1))) { + const zunchiSpreadChanceB = this.diseaseChance ? this.diseaseChance(dt * 0.065 * clamp(1 - d / 58, 0.12, 1)) : dt * 0.065 * clamp(1 - d / 58, 0.12, 1); + if (o.zunchiDisease && !this.zunchiDisease && (this.zunchiDiseaseCooldown || 0) <= 0 && d < 54 && deterministicChance(this.world, "social-zunchi-spread-b", zunchiSpreadChanceB, this, o)) { if (this.infectZunchiDisease(o)) this.world.log(`${o.name}\u306e\u305a\u3093\u3061\u75c5\u304c${this.name}\u306b\u3046\u3064\u3063\u305f\u3002`, "accident", { participants: [o, this] }); } @@ -58,7 +90,7 @@ nonSlave.thought = "\u305a\u3093\u3061\u3069\u308c\u3044\u304c\u8fd1\u3044"; nonSlave.adjustRelation?.(slave, -dt * 0.08, dt * 0.24, "zunchi_slave_avoid"); slave.adjustRelation?.(nonSlave, -dt * 0.02, 0, "zunchi_slave_avoid"); - if (Math.random() < dt * 0.35) nonSlave.surpriseTimer = Math.max(nonSlave.surpriseTimer || 0, 0.18); + if (deterministicChance(this.world, "zunchi-slave-surprise", dt * 0.35, nonSlave, slave)) nonSlave.surpriseTimer = Math.max(nonSlave.surpriseTimer || 0, 0.18); continue; } const overlap = this.radius + o.radius - d; @@ -66,7 +98,7 @@ const push = (overlap + 6) * (pairBond ? 0.08 : 0.17); this.vx -= (dx / d) * push; this.vy -= (dy / d) * push; - if (!pairBond && Math.random() < dt * 1.2) this.surpriseTimer = Math.max(this.surpriseTimer, 0.12); + if (!pairBond && deterministicChance(this.world, "body-overlap-surprise", dt * 1.2, this, o)) this.surpriseTimer = Math.max(this.surpriseTimer, 0.12); } if (pairBond && d > 26 && d < 120) { this.vx += (dx / d) * dt * 4.8; @@ -96,10 +128,10 @@ const pulse = Math.sin(this.world.time * 18 + this.age * 0.37) > 0.35; if (pulse && this.world.time > this.nextHeadbutt) { const nx = dx / d, ny = dy / d; - this.vx -= nx * rand(24, 44); - this.vy -= ny * rand(24, 44); - o.vx += nx * rand(34, 64); - o.vy += ny * rand(34, 64); + this.vx -= nx * deterministicRange(this.world, "headbutt-self-x", 24, 44, this, o); + this.vy -= ny * deterministicRange(this.world, "headbutt-self-y", 24, 44, this, o); + o.vx += nx * deterministicRange(this.world, "headbutt-other-x", 34, 64, this, o); + o.vy += ny * deterministicRange(this.world, "headbutt-other-y", 34, 64, this, o); if (typeof applyNeedShock === "function") applyNeedShock(this, { safety: 8, health: 4 }); if (typeof applyNeedShock === "function") applyNeedShock(o, { safety: 12, health: 6 }); const damageToThis = o.outgoingDamage ? o.outgoingDamage(1.2) : 1.2; @@ -114,7 +146,7 @@ o.hurtTimer = Math.max(o.hurtTimer, 1.8); this.adjustRelation(o, -0.16, 0.12, "fight"); o.adjustRelation(this, -0.22, 0.28 * o.personalityProfile().fear, "fight"); - if (Math.random() < 0.22 || o.energy < 24) { + if (deterministicChance(this.world, "headbutt-fall", 0.22, this, o) || o.energy < 24) { o.fallTimer = Math.max(o.fallTimer, 0.58); o.fallMax = Math.max(o.fallMax || 0.58, o.fallTimer); o.fallDir = nx > 0 ? 1 : -1; @@ -124,7 +156,7 @@ this.world.maybeDefeatFromFightDamage?.(o, this, Math.max(damageToOther, 2.4)); } this.world.spawnHeadbuttEffect((this.x + o.x) / 2, (this.y + o.y) / 2 - 5); - this.nextHeadbutt = this.world.time + rand(0.34, 0.55); + this.nextHeadbutt = this.world.time + deterministicRange(this.world, "next-headbutt", 0.34, 0.55, this, o); } } @@ -144,7 +176,8 @@ const birthPeace = (this.postBirthPeaceTimer || 0) > 0 || (o.postBirthPeaceTimer || 0) > 0; const familyFightBlocked = this.world.areParentChild ? this.world.areParentChild(this, o) : false; const pairFightBlocked = this.world.areCoParents ? this.world.areCoParents(this, o) : false; - if (this.world.canFightPair?.(this, o) && d < 56 && !familyFightBlocked && !pairFightBlocked && !birthPeace && fightRisk && this.fightCooldown <= 0 && o.fightCooldown <= 0 && Math.random() < dt * (battleDrug ? 0.23 : 0.085) * personalityRisk * fearBrake * friendBrake * conflictBias) { + const fightStartChance = dt * (battleDrug ? 0.23 : 0.085) * personalityRisk * fearBrake * friendBrake * conflictBias; + if (this.world.canFightPair?.(this, o) && d < 56 && !familyFightBlocked && !pairFightBlocked && !birthPeace && fightRisk && this.fightCooldown <= 0 && o.fightCooldown <= 0 && deterministicChance(this.world, "social-fight-start", fightStartChance, this, o)) { this.conflictTargetId = o.id; o.conflictTargetId = this.id; const urge = 34 + (battleDrug ? 34 : 0) + (mixedZunchiSlave ? 10 : 0); @@ -170,7 +203,7 @@ const selfBehaviorId = typeof getTarinaiBehaviorId === "function" ? getTarinaiBehaviorId(this) : this.behavior?.actionId; const otherBehaviorId = typeof getTarinaiBehaviorId === "function" ? getTarinaiBehaviorId(o) : o.behavior?.actionId; const mateIntent = selfBehaviorId === "approach_mate" || otherBehaviorId === "approach_mate" || loveDrug; - if (mateIntent && Math.random() < dt * (loveDrug ? 0.78 : 0.30)) { + if (mateIntent && deterministicChance(this.world, "birth-ritual-start", dt * (loveDrug ? 0.78 : 0.30), this, o)) { this.world.startBirthRitual(this, o); } } @@ -183,7 +216,7 @@ this.fearTimer = Math.max(this.fearTimer, 0.18); } - if (this.loneliness < 12 && Math.random() < dt * 0.002) { + if (this.loneliness < 12 && deterministicChance(this.world, "nearby-calm-log", dt * 0.002, this)) { this.world.log(`${this.name}\u306f\u8ab0\u304b\u306e\u8fd1\u304f\u3067\u3001\u305f\u308a\u306a\u3044\u3053\u3068\u3092\u5c11\u3057\u5fd8\u308c\u305f\u3002`, null, { participants: [this] }); } }, @@ -221,49 +254,97 @@ this.updateNestBoxPresence(dt); this.vx = 0; this.vy = 0; + this.impulseVx = 0; + this.impulseVy = 0; return; } + + const marker = window.TarinaiMovementUpdateStep; + const externalActive = marker?.markSleepExternalMotion?.(this, dt, "sleep-full-update") || marker?.sleepExternalMotionActive?.(this); if (this.target && !this.target.dead && this.isSleepFurniture(this.target)) { const spot = this.sleepSpotFor(this.target); const dx = spot.x - this.x; const dy = spot.y - this.y; const d = Math.hypot(dx, dy); if (d > 2.2) { - const step = Math.min(d, dt * (12 + bedComfort * 14)); - this.x += dx / d * step; - this.y += dy / d * step; - } else { + const restore = externalActive ? (2.0 + bedComfort * 2.0) : (12 + bedComfort * 14); + const maxRestore = externalActive ? Math.min(d, 3.2) : d; + const step = Math.min(maxRestore, dt * restore); + const sx = dx / d * step; + const sy = dy / d * step; + if (this.world?.moveTarinaiWithCollision) this.world.moveTarinaiWithCollision(this, sx, sy, { dt, reason: externalActive ? "tarinai-sleep-restore-weak" : "tarinai-sleep-move" }); + else { this.x += sx; this.y += sy; } + } else if (!externalActive && Math.hypot((this.vx || 0) + (this.impulseVx || 0), (this.vy || 0) + (this.impulseVy || 0)) < 0.35) { this.x = spot.x; this.y = spot.y; } } + + const impulseVx = Number.isFinite(this.impulseVx) ? this.impulseVx : 0; + const impulseVy = Number.isFinite(this.impulseVy) ? this.impulseVy : 0; + const moveDx = ((Number.isFinite(this.vx) ? this.vx : 0) + impulseVx) * dt; + const moveDy = ((Number.isFinite(this.vy) ? this.vy : 0) + impulseVy) * dt; + if (Math.hypot(moveDx, moveDy) > 0.001) { + if (this.world?.moveTarinaiWithCollision) this.world.moveTarinaiWithCollision(this, moveDx, moveDy, { dt, reason: "tarinai-sleep-physical-full", maxChecks: 18, maxStep: 10 }); + else { this.x += moveDx; this.y += moveDy; } + } + if (this.world?.resolveSolidObstacleCollision) { + const beforeContactX = Number(this.x || 0) || 0; + const beforeContactY = Number(this.y || 0) || 0; + const pushed = this.world.resolveSolidObstacleCollision(this, { maxPasses: 2, searchRadius: Math.max(72, (Number(this.radius) || 22) + 120), maxChecks: 18, slop: 0.28, reason: "tarinai-sleep-full-contact" }); + if (pushed) { + this.lastSolidObstacleCollisionAt = this.world.time || 0; + 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) > 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); + } + } + } this.updateNestBoxPresence(dt); - this.vx *= Math.pow(0.55, dt * 60); - this.vy *= Math.pow(0.55, dt * 60); + const bodyDrag = externalActive ? 0.86 : 0.55; + this.vx *= Math.pow(bodyDrag, dt * 60); + this.vy *= Math.pow(bodyDrag, dt * 60); + const impulseDrag = Math.pow(0.58, dt * 3.2); + this.impulseVx = Math.abs(impulseVx) < 0.35 ? 0 : impulseVx * impulseDrag; + this.impulseVy = Math.abs(impulseVy) < 0.35 ? 0 : impulseVy * impulseDrag; return; } if (this.birthRitualTimer > 0.04 || this.state === "birth_ritual") { - const partner = this.world.liveTarinaiById?.(this.birthPartnerId); - if (partner) { - const midX = (this.x + partner.x) / 2; - const midY = (this.y + partner.y) / 2; - const pdx = partner.x - this.x; - const pdy = partner.y - this.y; - const plen = Math.max(0.001, Math.hypot(pdx, pdy)); - const nx = -pdy / plen; - const ny = pdx / plen; - const desiredGap = this.radius * 0.36; - const side = this.birthRitualRole || 1; - const tx = midX + nx * desiredGap * side; - const ty = midY + ny * desiredGap * side; - this.vx += (tx - this.x) * dt * 3.8; - this.vy += (ty - this.y) * dt * 3.8; + const pendingImpulse = Math.hypot(Number.isFinite(this.impulseVx) ? this.impulseVx : 0, Number.isFinite(this.impulseVy) ? this.impulseVy : 0); + const canceledByForce = this.world?.cancelBirthRitualOnForce?.(this, pendingImpulse, { + reason: "強い衝撃で繁殖が中断された", + target: this.target || null, + fear: 1.0, + cause: "stored_impulse", + panic: true, + silentLog: true, + }); + if (!canceledByForce) { + const partner = this.world.liveTarinaiById?.(this.birthPartnerId); + if (partner) { + const midX = (this.x + partner.x) / 2; + const midY = (this.y + partner.y) / 2; + const pdx = partner.x - this.x; + const pdy = partner.y - this.y; + const plen = Math.max(0.001, Math.hypot(pdx, pdy)); + const nx = -pdy / plen; + const ny = pdx / plen; + const desiredGap = this.radius * 0.36; + const side = this.birthRitualRole || 1; + const tx = midX + nx * desiredGap * side; + const ty = midY + ny * desiredGap * side; + this.vx += (tx - this.x) * dt * 3.8; + this.vy += (ty - this.y) * dt * 3.8; + } + this.vx *= Math.pow(0.48, dt * 60); + this.vy *= Math.pow(0.48, dt * 60); + this.x += Math.sin(this.world.time * 26 + this.age) * 0.20; + return; } - this.vx *= Math.pow(0.48, dt * 60); - this.vy *= Math.pow(0.48, dt * 60); - this.x += Math.sin(this.world.time * 26 + this.age) * 0.20; - return; } if (this.intimidateTimer > 0.04 || this.state === "intimidate" || this.state === "ant_intimidate") { @@ -349,22 +430,22 @@ if (Number.isFinite(this.target.hp)) this.target.hp = Math.max(0, this.target.hp - (this.outgoingDamage ? this.outgoingDamage(4.6 + aggression * 3.4) : (4.6 + aggression * 3.4))); this.fearTimer = Math.max(this.fearTimer, 0.12); this.thought = "\u30a2\u30ea\u3092\u653b\u6483\u3057\u3066\u3044\u308b"; - if (Math.random() < 0.55) this.bubble("!", 0.8, "rgba(145,68,52,0.76)"); + if (deterministicChance(this.world, "ant-attack-bubble", 0.55, this, this.target)) this.bubble("!", 0.8, "rgba(145,68,52,0.76)"); } } } ax += (dx / d) * baseSpeed * sign * strength; ay += (dy / d) * baseSpeed * sign * strength; } else if (this.state === "panic") { - this.wanderAngle += rand(-3.8, 3.8) * dt; + this.wanderAngle += deterministicRange(this.world, "panic-wander", -3.8, 3.8, this) * dt; ax += Math.cos(this.wanderAngle) * baseSpeed * 1.5; ay += Math.sin(this.wanderAngle) * baseSpeed * 1.5; } else if (this.state === "zunchi_sick") { - this.wanderAngle += rand(-2.4, 2.4) * dt + Math.sin(this.world.time * 1.7 + this.age) * dt * 0.8; + this.wanderAngle += deterministicRange(this.world, "zunchi-sick-wander", -2.4, 2.4, this) * dt + Math.sin(this.world.time * 1.7 + this.age) * dt * 0.8; ax += Math.cos(this.wanderAngle) * baseSpeed * 0.72; ay += Math.sin(this.wanderAngle) * baseSpeed * 0.72; } else if (this.state === "fight_sick") { - this.wanderAngle += rand(-4.2, 4.2) * dt + Math.sin(this.world.time * 3.2 + this.age) * dt * 1.6; + this.wanderAngle += deterministicRange(this.world, "fight-sick-wander", -4.2, 4.2, this) * dt + Math.sin(this.world.time * 3.2 + this.age) * dt * 1.6; ax += Math.cos(this.wanderAngle) * baseSpeed * 0.88; ay += Math.sin(this.wanderAngle) * baseSpeed * 0.88; } else { @@ -388,8 +469,18 @@ const impulseVx = Number.isFinite(this.impulseVx) ? this.impulseVx : 0; const impulseVy = Number.isFinite(this.impulseVy) ? this.impulseVy : 0; - this.x += (this.vx + impulseVx) * dt; - this.y += (this.vy + impulseVy) * dt; + const moveDx = (this.vx + impulseVx) * dt; + const moveDy = (this.vy + impulseVy) * dt; + if (this.world?.moveTarinaiWithCollision) { + const motionOnlyPass = Boolean(this._motionOnlyPhysicsPass); + this.world.moveTarinaiWithCollision(this, moveDx, moveDy, motionOnlyPass + ? { dt, reason: "tarinai-move-smooth", light: true, maxChecks: this.state === "panic" ? 18 : 14, maxStep: this.state === "panic" ? 12 : 14 } + : { dt, reason: "tarinai-move" }); + } + else { + this.x += moveDx; + this.y += moveDy; + } const impulseDrag = Math.pow(0.58, dt * 3.2); this.impulseVx = Math.abs(impulseVx) < 0.35 ? 0 : impulseVx * impulseDrag; this.impulseVy = Math.abs(impulseVy) < 0.35 ? 0 : impulseVy * impulseDrag; @@ -403,10 +494,10 @@ const p = this.entryTimer > 0 ? -this.radius * 2.4 : CONFIG.worldPadding; const maxX = this.entryTimer > 0 ? this.world.w + this.radius * 2.4 : this.world.w - CONFIG.worldPadding; const maxY = this.entryTimer > 0 ? this.world.h + this.radius * 2.4 : this.world.h - CONFIG.worldPadding; - if (this.x < p) { this.x = p; this.vx = Math.abs(this.vx) * 0.5; this.wanderAngle = rand(-0.8, 0.8); this.surpriseTimer = 0.15; } - if (this.y < p) { this.y = p; this.vy = Math.abs(this.vy) * 0.5; this.wanderAngle = rand(0.3, 1.8); this.surpriseTimer = 0.15; } - if (this.x > maxX) { this.x = maxX; this.vx = -Math.abs(this.vx) * 0.5; this.wanderAngle = rand(2.5, 3.8); this.surpriseTimer = 0.15; } - if (this.y > maxY) { this.y = maxY; this.vy = -Math.abs(this.vy) * 0.5; this.wanderAngle = rand(-2.2, -0.7); this.surpriseTimer = 0.15; } + if (this.x < p) { this.x = p; this.vx = Math.abs(this.vx) * 0.5; this.wanderAngle = deterministicRange(this.world, "wall-bounce-left", -0.8, 0.8, this); this.surpriseTimer = 0.15; } + if (this.y < p) { this.y = p; this.vy = Math.abs(this.vy) * 0.5; this.wanderAngle = deterministicRange(this.world, "wall-bounce-top", 0.3, 1.8, this); this.surpriseTimer = 0.15; } + if (this.x > maxX) { this.x = maxX; this.vx = -Math.abs(this.vx) * 0.5; this.wanderAngle = deterministicRange(this.world, "wall-bounce-right", 2.5, 3.8, this); this.surpriseTimer = 0.15; } + if (this.y > maxY) { this.y = maxY; this.vy = -Math.abs(this.vy) * 0.5; this.wanderAngle = deterministicRange(this.world, "wall-bounce-bottom", -2.2, -0.7, this); this.surpriseTimer = 0.15; } }, checkLife(dt = 0.016) { @@ -441,7 +532,7 @@ this.world.markSpatialDirty?.("tarinai-death-plushie-cleanup"); this.world.addItem?.(new Item("trace", this.x, this.y), "tarinai-death-trace") || this.world.items.push(new Item("trace", this.x, this.y)); this.world.addItem?.(new Item("splat", this.x, this.y), "tarinai-death-splat") || this.world.items.push(new Item("splat", this.x, this.y)); - if (Math.random() < 0.55) { + if (deterministicChance(this.world, "death-grass-spawn", 0.55, this, finalReason)) { const spot = this.world.findGrassPlantingSpot(this.x, this.y, { allowOriginal: false, minRadius: 26, maxRadius: 76 }); if (spot) { const deathGrass = new Item("grass", spot.x, spot.y); diff --git a/js/tarinai_update_legacy_system.js b/js/tarinai_update_legacy_system.js index 4147160..3ec1826 100644 --- a/js/tarinai_update_legacy_system.js +++ b/js/tarinai_update_legacy_system.js @@ -1,4 +1,34 @@ "use strict"; +(function ensureDeterministicHelpers(global) { + if (typeof global.deterministicChance === "function" && typeof global.deterministicRange === "function") return; + const clamp01 = v => Math.max(0, Math.min(1, Number(v) || 0)); + const keyPart = value => { + if (value == null) return "null"; + if (typeof value === "number") return Number.isFinite(value) ? value.toFixed(3) : "nan"; + if (typeof value === "string" || typeof value === "boolean") return String(value); + if (typeof value === "object") { + const id = value.familyKey || value.id || value.seed || value.liveToken || value.name; + if (id) return `${value.type || value.constructor?.name || "entity"}:${id}`; + const x = Number.isFinite(value.x) ? value.x.toFixed(1) : "x"; + const y = Number.isFinite(value.y) ? value.y.toFixed(1) : "y"; + return `${value.type || value.constructor?.name || "entity"}@${x},${y}`; + } + return String(value); + }; + const hashUnit = (seed, salt = "") => { + const str = `${seed}:${salt}`; + let h = 2166136261; + for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); } + return ((h >>> 0) % 100000) / 100000; + }; + global.deterministicFrame = global.deterministicFrame || ((worldRef = null, hz = 60) => Math.floor((Number(worldRef?.time || 0) || 0) * Math.max(1, Number(hz) || 60) + 1e-6)); + global.deterministicUnit = global.deterministicUnit || ((worldRef = null, salt = "", ...parts) => hashUnit(String(worldRef?.worldSeed || "tarinai-world"), [salt, global.deterministicFrame(worldRef, 60), Number(worldRef?._deterministicEpoch || 0) || 0, ...parts.map(keyPart)].join("|"))); + global.deterministicRange = global.deterministicRange || ((worldRef = null, salt = "", min = 0, max = 1, ...parts) => (Number(min) || 0) + global.deterministicUnit(worldRef, salt, ...parts) * ((Number(max) || 0) - (Number(min) || 0))); + global.deterministicSigned = global.deterministicSigned || ((worldRef = null, salt = "", ...parts) => global.deterministicUnit(worldRef, salt, ...parts) < 0.5 ? -1 : 1); + global.deterministicChance = global.deterministicChance || ((worldRef = null, salt = "", chance = 0, ...parts) => { const p = clamp01(chance); return p >= 1 || (p > 0 && global.deterministicUnit(worldRef, salt, ...parts) < p); }); + global.deterministicAngle = global.deterministicAngle || ((worldRef = null, salt = "", ...parts) => global.deterministicRange(worldRef, salt, 0, Math.PI * 2, ...parts)); +})(typeof window !== "undefined" ? window : globalThis); + // Layer: entity-runtime/tarinai/legacy // Compatibility owner for the former monolithic Tarinai.prototype.update body. @@ -75,7 +105,7 @@ this.defeatedTimer = Math.max(0, this.defeatedTimer - dt); if (wasFighting && this.fightTimer <= 0.04 && this.defeatedById) { const winner = this.world.liveTarinaiById?.(this.defeatedById) || null; - this.defeatedTimer = Math.max(this.defeatedTimer, rand(3.4, 5.2)); + this.defeatedTimer = Math.max(this.defeatedTimer, deterministicRange(this.world, "defeated-recover-timer", 3.4, 5.2, this)); this.fearTimer = Math.max(this.fearTimer, 1.0 * this.personalityProfile().fear); this.hurtTimer = Math.max(this.hurtTimer, 1.8); this.fallTimer = Math.max(this.fallTimer, 1.15); @@ -95,27 +125,27 @@ && (this.fallTimer || 0) <= 0.04 && !this.dead && !["sleep", "fight", "panic", "birth_ritual", "frozen"].includes(String(this.state || "")) - && Math.random() < dt * 0.055) { - const dir = Math.random() < 0.5 ? -1 : 1; - this.groundStumbleCooldown = rand(8.5, 16.0); - this.fallTimer = Math.max(this.fallTimer || 0, rand(0.86, 1.18)); + && deterministicChance(this.world, "foot-massage-stumble", dt * 0.055, this)) { + const dir = deterministicSigned(this.world, "foot-massage-stumble-dir", this); + this.groundStumbleCooldown = deterministicRange(this.world, "foot-massage-stumble-cooldown", 8.5, 16.0, this); + this.fallTimer = Math.max(this.fallTimer || 0, deterministicRange(this.world, "foot-massage-stumble-fall", 0.86, 1.18, this)); this.fallMax = Math.max(this.fallMax || 1.05, this.fallTimer); this.fallDir = dir; - this.vx += dir * rand(26, 52); - this.vy += rand(-18, 18); + this.vx += dir * deterministicRange(this.world, "foot-massage-stumble-vx", 26, 52, this); + this.vy += deterministicRange(this.world, "foot-massage-stumble-vy", -18, 18, this); this.world?.spawnFallEffect?.(this.x, this.y + this.radius * 0.45); this.setActionState?.("idle", { target: null, reason: "足つぼの突起につまづいた。", sleeping: false, clearTarget: false }); if ((this.world?.time || 0) >= (this.nextBubbleAt || 0)) this.bubble?.("うっ", 1.6, "rgba(86,70,96,0.78)"); } - if (this.stress > 92 && this.state !== "sleep" && this.state !== "fight" && this.state !== "eat" && Math.random() < dt * 0.050) { + if (this.stress > 92 && this.state !== "sleep" && this.state !== "fight" && this.state !== "eat" && deterministicChance(this.world, "stress-breath-bubble", dt * 0.050, this)) { this.bubble("\u306f\u3063...\u306f\u3063...", 3.4, "rgba(58,48,63,0.80)"); } - if (this.state === "idle" && this.stress < 78 && Math.random() < dt * 0.018) { + if (this.state === "idle" && this.stress < 78 && deterministicChance(this.world, "idle-bubble", dt * 0.018, this)) { this.bubble(pick(["\u306f\u3046", "\u306f\u3046\u3045", "\u306f\u3046\u3063", "\u306f\u3045"]), 4.2, "rgba(62,84,45,0.76)"); } - if (this.hurtTimer > 0 && Math.random() < dt * 5.2) { + if (this.hurtTimer > 0 && deterministicChance(this.world, "hurt-bleed-effect", dt * 5.2, this)) { const side = this.facingDir(); - this.world.spawnBleedEffect(this.x - side * this.radius * rand(0.15, 0.42), this.y - this.radius * rand(0.08, 0.34)); + this.world.spawnBleedEffect(this.x - side * this.radius * deterministicRange(this.world, "hurt-bleed-x", 0.15, 0.42, this), this.y - this.radius * deterministicRange(this.world, "hurt-bleed-y", 0.08, 0.34, this)); } this.tempTimer += dt; diff --git a/js/tarinai_update_policy.js b/js/tarinai_update_policy.js index 57c6f00..2bbbeee 100644 --- a/js/tarinai_update_policy.js +++ b/js/tarinai_update_policy.js @@ -1,21 +1,121 @@ "use strict"; // Layer: entity-runtime/tarinai/policy -// Owns Tarinai update cadence decisions. The simulation creature system asks -// this policy when an individual should run at realtime vs low-frequency cadence. +// Owns Tarinai update cadence decisions. The cadence throttles expensive +// thinking/environment work, while visible movement can still be integrated +// every frame via the smooth-motion pass. (function (global) { const helpers = () => global.TarinaiSimulationRuntime?.helpers || {}; - function updateInterval(worldRef, tarinai, visibleRect) { - if (!tarinai || tarinai.dead) return Infinity; - if (worldRef.selected === tarinai) return 0; - if ((tarinai.hurtTimer || 0) > 0.04 || (tarinai.pokeFlashTimer || 0) > 0.03 || (tarinai.entryTimer || 0) > 0.01) return 0; - if (helpers().pointInRect?.(tarinai.x || 0, tarinai.y || 0, visibleRect)) return 0; - const count = (worldRef.tarinai || []).length; + function behaviorOf(tarinai) { + return typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai?.behavior; + } + + function behaviorId(tarinai) { + const behavior = behaviorOf(tarinai); + return String(behavior?.actionId || tarinai?.state || "none"); + } + + function visible(worldRef, tarinai, visibleRect) { + return Boolean(helpers().pointInRect?.(tarinai.x || 0, tarinai.y || 0, visibleRect)); + } + + function hasMotionNeed(tarinai) { + if (!tarinai || tarinai.dead) return false; + if (tarinai.target && !tarinai.target.dead) return true; + const speed = Math.hypot((tarinai.vx || 0) + (tarinai.impulseVx || 0), (tarinai.vy || 0) + (tarinai.impulseVy || 0)); + if (speed > 1.8) return true; + const id = behaviorId(tarinai); + return /idle|wander|approach|follow|seek|panic|fight|birth_ritual|sunbath|zunchi_sick|fight_sick|cursor_/i.test(id); + } + + function hasPassivePhysicsNeed(tarinai, worldRef = null, isVisible = false) { + if (!tarinai || tarinai.dead) return false; + const vx = Number.isFinite(tarinai.vx) ? tarinai.vx : 0; + const vy = Number.isFinite(tarinai.vy) ? tarinai.vy : 0; + const ivx = Number.isFinite(tarinai.impulseVx) ? tarinai.impulseVx : 0; + const ivy = Number.isFinite(tarinai.impulseVy) ? tarinai.impulseVy : 0; + if (Math.hypot(vx + ivx, vy + ivy) > 0.35) return true; + const sleeping = tarinai.state === "sleep" || tarinai.sleeping; + if (sleeping && isVisible && !worldRef?.isTarinaiHiddenInNestBox?.(tarinai)) return true; + if (sleeping && tarinai.target && !tarinai.target.dead && tarinai.isSleepFurniture?.(tarinai.target) && tarinai.target.type !== "nest_box") return true; + return false; + } + + function cadence(worldRef, tarinai, visibleRect) { + if (!tarinai || tarinai.dead) return { interval: Infinity, lane: "dead", smoothMotion: false, visible: false, urgent: false }; + const isSelected = worldRef.selected === tarinai; + if (isSelected) return { interval: 0, lane: "selected", smoothMotion: false, visible: true, urgent: true }; + + const id = behaviorId(tarinai); + const state = String(tarinai.state || id || ""); const tier = global.TarinaiPerf?.renderQualityTier?.() || "high"; - if (tier === "high" && count <= 36) return 0; - const farRect = helpers().updateVisibleWorldRect?.(worldRef, 520) || visibleRect; - return helpers().pointInRect?.(tarinai.x || 0, tarinai.y || 0, farRect) ? 0.22 : 0.70; + const count = (worldRef.tarinai || []).length; + const isVisible = visible(worldRef, tarinai, visibleRect); + const farRect = isVisible ? visibleRect : (helpers().updateVisibleWorldRect?.(worldRef, 520) || visibleRect); + const isNear = isVisible || Boolean(helpers().pointInRect?.(tarinai.x || 0, tarinai.y || 0, farRect)); + const motionNeed = hasMotionNeed(tarinai); + + const hardRealtimeUrgent = + (tarinai.hurtTimer || 0) > 0.04 || + (tarinai.pokeFlashTimer || 0) > 0.03 || + (tarinai.entryTimer || 0) > 0.01 || + (tarinai.fallTimer || 0) > 0.01 || + /fight|knockback|danger|hurt|ant_attack|cursor_enemy|explosion/i.test(id); + if (hardRealtimeUrgent) return { interval: 0, lane: "urgent", smoothMotion: false, visible: isVisible, urgent: true }; + + // Panic movement must stay visually continuous, but the full creature + // pipeline does not need to run every rendered frame. Treat panic/flee as + // a high-priority motion lane: AI/timers are quantized, motion-only still + // runs on visible skipped frames. + const panicUrgent = (tarinai.fearTimer || 0) > 0.20 || /panic|flee/i.test(id); + if (panicUrgent) { + const interval = isVisible ? (tier === "low" ? 0.12 : 0.08) : (isNear ? 0.18 : 0.36); + return { interval, lane: "panic", smoothMotion: Boolean(isVisible && !worldRef.isTarinaiHiddenInNestBox?.(tarinai)), visible: isVisible, urgent: true }; + } + + let interval; + let lane = "calm"; + + if (/birth_ritual/i.test(id) || state === "birth_ritual" || (tarinai.birthRitualTimer || 0) > 0.04) { + lane = "birth"; + interval = isVisible ? (tier === "low" ? 0.16 : 0.12) : 0.24; + } else if (/approach_parent_or_child|follow_parent|approach_mate|seek_bed|play_ball|seek_material|build/i.test(id) || /follow_parent|seek_bed|play_ball/.test(state)) { + lane = "moving"; + interval = isVisible ? (tier === "low" ? 0.14 : 0.10) : (isNear ? 0.22 : 0.55); + } else if (/eat_food|eat/i.test(id) || state === "eat" || (tarinai.eatTimer || 0) > 0.04) { + lane = "eat"; + interval = isVisible ? 0.22 : 0.48; + } else if (/sleep|sleep_in_bed/.test(id) || state === "sleep" || tarinai.sleeping) { + lane = "sleep"; + interval = isVisible ? 0.42 : (isNear ? 0.70 : 1.10); + } else if (/idle|none/.test(id) || state === "idle") { + lane = "idle"; + if (isVisible) interval = tier === "low" ? 0.24 : (tier === "medium" || tier === "mid" ? 0.20 : (count > 28 ? 0.16 : 0.12)); + else interval = isNear ? 0.55 : 1.10; + } else if (isVisible) { + lane = "visible"; + interval = tier === "low" ? (count > 24 ? 0.18 : 0.14) : (tier === "medium" || tier === "mid" ? (count > 28 ? 0.14 : 0.10) : (count > 36 ? 0.11 : (count > 24 ? 0.08 : 0.05))); + } else { + lane = isNear ? "near" : "far"; + interval = isNear ? (tier === "low" ? 0.40 : 0.30) : (tier === "low" ? 1.20 : 0.90); + } + + const passivePhysicsNeed = hasPassivePhysicsNeed(tarinai, worldRef, isVisible); + const sleepPhysical = lane === "sleep" && passivePhysicsNeed; + const eatPhysical = lane === "eat" && passivePhysicsNeed; + const smoothAllowed = lane !== "eat" && lane !== "sleep"; + const smoothMotion = Boolean(isVisible + && Number.isFinite(interval) + && interval > 0 + && interval <= 0.75 + && !worldRef.isTarinaiHiddenInNestBox?.(tarinai) + && ((smoothAllowed && motionNeed) || sleepPhysical || eatPhysical)); + return { interval, lane, smoothMotion, visible: isVisible, urgent: false, sleepPhysical, passivePhysicsNeed }; + } + + function updateInterval(worldRef, tarinai, visibleRect) { + return cadence(worldRef, tarinai, visibleRect).interval; } function shouldRunRealtime(worldRef, tarinai, visibleRect) { @@ -24,6 +124,8 @@ global.TarinaiCreatureUpdatePolicy = Object.freeze({ updateInterval, + updateCadence: cadence, + behaviorId, shouldRunRealtime, }); })(typeof window !== "undefined" ? window : globalThis); diff --git a/js/tarinai_update_step_frame.js b/js/tarinai_update_step_frame.js index d949d29..f792513 100644 --- a/js/tarinai_update_step_frame.js +++ b/js/tarinai_update_step_frame.js @@ -1,4 +1,34 @@ "use strict"; +(function ensureDeterministicHelpers(global) { + if (typeof global.deterministicChance === "function" && typeof global.deterministicRange === "function") return; + const clamp01 = v => Math.max(0, Math.min(1, Number(v) || 0)); + const keyPart = value => { + if (value == null) return "null"; + if (typeof value === "number") return Number.isFinite(value) ? value.toFixed(3) : "nan"; + if (typeof value === "string" || typeof value === "boolean") return String(value); + if (typeof value === "object") { + const id = value.familyKey || value.id || value.seed || value.liveToken || value.name; + if (id) return `${value.type || value.constructor?.name || "entity"}:${id}`; + const x = Number.isFinite(value.x) ? value.x.toFixed(1) : "x"; + const y = Number.isFinite(value.y) ? value.y.toFixed(1) : "y"; + return `${value.type || value.constructor?.name || "entity"}@${x},${y}`; + } + return String(value); + }; + const hashUnit = (seed, salt = "") => { + const str = `${seed}:${salt}`; + let h = 2166136261; + for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); } + return ((h >>> 0) % 100000) / 100000; + }; + global.deterministicFrame = global.deterministicFrame || ((worldRef = null, hz = 60) => Math.floor((Number(worldRef?.time || 0) || 0) * Math.max(1, Number(hz) || 60) + 1e-6)); + global.deterministicUnit = global.deterministicUnit || ((worldRef = null, salt = "", ...parts) => hashUnit(String(worldRef?.worldSeed || "tarinai-world"), [salt, global.deterministicFrame(worldRef, 60), Number(worldRef?._deterministicEpoch || 0) || 0, ...parts.map(keyPart)].join("|"))); + global.deterministicRange = global.deterministicRange || ((worldRef = null, salt = "", min = 0, max = 1, ...parts) => (Number(min) || 0) + global.deterministicUnit(worldRef, salt, ...parts) * ((Number(max) || 0) - (Number(min) || 0))); + global.deterministicSigned = global.deterministicSigned || ((worldRef = null, salt = "", ...parts) => global.deterministicUnit(worldRef, salt, ...parts) < 0.5 ? -1 : 1); + global.deterministicChance = global.deterministicChance || ((worldRef = null, salt = "", chance = 0, ...parts) => { const p = clamp01(chance); return p >= 1 || (p > 0 && global.deterministicUnit(worldRef, salt, ...parts) < p); }); + global.deterministicAngle = global.deterministicAngle || ((worldRef = null, salt = "", ...parts) => global.deterministicRange(worldRef, salt, 0, Math.PI * 2, ...parts)); +})(typeof window !== "undefined" ? window : globalThis); + // Layer: entity-runtime/tarinai/update-step // Owns per-frame timers, passive metabolism, disease/effect ticks, colony mood @@ -76,7 +106,7 @@ t.defeatedTimer = Math.max(0, t.defeatedTimer - dt); if (wasFighting && t.fightTimer <= 0.04 && t.defeatedById) { const winner = t.world.liveTarinaiById?.(t.defeatedById) || null; - t.defeatedTimer = Math.max(t.defeatedTimer, rand(3.4, 5.2)); + t.defeatedTimer = Math.max(t.defeatedTimer, deterministicRange(t.world, "defeated-recover-timer", 3.4, 5.2, t)); t.fearTimer = Math.max(t.fearTimer, 1.0 * t.personalityProfile().fear); t.hurtTimer = Math.max(t.hurtTimer, 1.8); t.fallTimer = Math.max(t.fallTimer, 1.15); @@ -96,27 +126,27 @@ && (t.fallTimer || 0) <= 0.04 && !t.dead && !["sleep", "fight", "panic", "birth_ritual", "frozen"].includes(String(t.state || "")) - && Math.random() < dt * 0.055) { - const dir = Math.random() < 0.5 ? -1 : 1; - t.groundStumbleCooldown = rand(8.5, 16.0); - t.fallTimer = Math.max(t.fallTimer || 0, rand(0.86, 1.18)); + && deterministicChance(t.world, "foot-massage-stumble", dt * 0.055, t)) { + const dir = deterministicSigned(t.world, "foot-massage-stumble-dir", t); + t.groundStumbleCooldown = deterministicRange(t.world, "foot-massage-stumble-cooldown", 8.5, 16.0, t); + t.fallTimer = Math.max(t.fallTimer || 0, deterministicRange(t.world, "foot-massage-stumble-fall", 0.86, 1.18, t)); t.fallMax = Math.max(t.fallMax || 1.05, t.fallTimer); t.fallDir = dir; - t.vx += dir * rand(26, 52); - t.vy += rand(-18, 18); + t.vx += dir * deterministicRange(t.world, "foot-massage-stumble-vx", 26, 52, t); + t.vy += deterministicRange(t.world, "foot-massage-stumble-vy", -18, 18, t); t.world?.spawnFallEffect?.(t.x, t.y + t.radius * 0.45); t.setActionState?.("idle", { target: null, reason: "足つぼの突起につまづいた。", sleeping: false, clearTarget: false }); if ((t.world?.time || 0) >= (t.nextBubbleAt || 0)) t.bubble?.("うっ", 1.6, "rgba(86,70,96,0.78)"); } - if (t.stress > 92 && t.state !== "sleep" && t.state !== "fight" && t.state !== "eat" && Math.random() < dt * 0.050) { + if (t.stress > 92 && t.state !== "sleep" && t.state !== "fight" && t.state !== "eat" && deterministicChance(t.world, "stress-breath-bubble", dt * 0.050, t)) { t.bubble("はっ...はっ...", 3.4, "rgba(58,48,63,0.80)"); } - if (t.state === "idle" && t.stress < 78 && Math.random() < dt * 0.018) { + if (t.state === "idle" && t.stress < 78 && deterministicChance(t.world, "idle-bubble", dt * 0.018, t)) { t.bubble(pick(["はう", "はうぅ", "はうっ", "はぅ"]), 4.2, "rgba(62,84,45,0.76)"); } - if (t.hurtTimer > 0 && Math.random() < dt * 5.2) { + if (t.hurtTimer > 0 && deterministicChance(t.world, "hurt-bleed-effect", dt * 5.2, t)) { const side = t.facingDir(); - t.world.spawnBleedEffect(t.x - side * t.radius * rand(0.15, 0.42), t.y - t.radius * rand(0.08, 0.34)); + t.world.spawnBleedEffect(t.x - side * t.radius * deterministicRange(t.world, "hurt-bleed-x", 0.15, 0.42, t), t.y - t.radius * deterministicRange(t.world, "hurt-bleed-y", 0.08, 0.34, t)); } t.tempTimer += dt; diff --git a/js/tarinai_update_step_movement.js b/js/tarinai_update_step_movement.js index 42304af..b1ac843 100644 --- a/js/tarinai_update_step_movement.js +++ b/js/tarinai_update_step_movement.js @@ -37,22 +37,204 @@ return true; } - function update(t, dt) { + + function numeric(v, fallback = 0) { + return Number.isFinite(Number(v)) ? Number(v) : fallback; + } + + function markSleepExternalMotion(t, dt, reason = "sleep-external-motion") { + if (!t || !(t.state === "sleep" || t.sleeping)) return false; + const world = t.world || null; + const now = Number(world?.time || 0) || 0; + const vx = numeric(t.vx); + const vy = numeric(t.vy); + const ivx = numeric(t.impulseVx); + const ivy = numeric(t.impulseVy); + const speed = Math.hypot(vx + ivx, vy + ivy); + const recentlyHit = now - Number(t.lastSolidObstacleCollisionAt || t.lastPhysicalCollisionDamageAt || -999) < 0.35; + if (speed > 0.55 || recentlyHit) { + const hold = speed > 18 ? 1.15 : (speed > 3 ? 0.75 : 0.42); + t._sleepExternalMotionUntil = Math.max(Number(t._sleepExternalMotionUntil || 0) || 0, now + hold); + t._sleepExternalMotionReason = reason; + return true; + } + return now < Number(t._sleepExternalMotionUntil || 0); + } + + function sleepExternalMotionActive(t) { + const now = Number(t?.world?.time || 0) || 0; + return Boolean(t && (t.state === "sleep" || t.sleeping) && now < Number(t._sleepExternalMotionUntil || 0)); + } + + function passivePhysicalMotion(t, dt, context = {}) { if (!t || t.dead) return { done: true }; + const world = t.world; + const beforeX = numeric(t.x); + const beforeY = numeric(t.y); + const beforeVelocity = Number.isFinite(t._lastPhysicalVelocityX) && Number.isFinite(t._lastPhysicalVelocityY) + ? { vx: t._lastPhysicalVelocityX, vy: t._lastPhysicalVelocityY } + : combinedVelocity(t); + + // Nest boxes intentionally hide and pin sleepers. Visible beds, eating, and + // other locked states still need a passive physics shell so external + // impulses/mechanical contacts are not delayed until the next low-frequency + // full update. + if (world?.isTarinaiHiddenInNestBox?.(t)) { + t.vx = 0; + t.vy = 0; + t.impulseVx = 0; + t.impulseVy = 0; + t.updateNestBoxPresence?.(dt); + return { done: false, passive: true }; + } + + let moveX = 0; + let moveY = 0; + const state = String(t.state || ""); + + const sleepExternal = markSleepExternalMotion(t, dt, "passive-physical"); + if ((state === "sleep" || t.sleeping) && t.target && !t.target.dead && t.isSleepFurniture?.(t.target) && t.target.type !== "nest_box") { + const comfort = world?.bedComfort ? world.bedComfort(t.target) : 0.72; + const spot = t.sleepSpotFor ? t.sleepSpotFor(t.target) : t.target; + const dx = numeric(spot?.x, beforeX) - beforeX; + const dy = numeric(spot?.y, beforeY) - beforeY; + const d = Math.hypot(dx, dy); + const strongExternal = sleepExternal && Math.hypot(numeric(t.vx) + numeric(t.impulseVx), numeric(t.vy) + numeric(t.impulseVy)) > 1.2; + // Beds should gently keep a sleeper nearby, but must not pin the body. + // Under external force the restoring pull is deliberately weak so a + // sleeping Tarinai behaves like a physical object instead of snapping back + // to the sleep spot every smooth/full update. + if (d > 1.2) { + const restore = strongExternal ? (2.0 + comfort * 2.0) : (12 + comfort * 14); + const maxRestore = strongExternal ? Math.min(d, 2.8) : d; + const step = Math.min(maxRestore, dt * restore); + moveX += dx / d * step; + moveY += dy / d * step; + } else if (!strongExternal && d > 0.05 && Math.hypot(t.vx || 0, t.vy || 0, t.impulseVx || 0, t.impulseVy || 0) < 0.6) { + moveX += dx; + moveY += dy; + } + } + + const vx = numeric(t.vx); + const vy = numeric(t.vy); + const impulseVx = numeric(t.impulseVx); + const impulseVy = numeric(t.impulseVy); + moveX += (vx + impulseVx) * dt; + moveY += (vy + impulseVy) * dt; + + const movedDistance = Math.hypot(moveX, moveY); + if (movedDistance > 0.001) { + if (world?.moveTarinaiWithCollision) { + world.moveTarinaiWithCollision(t, moveX, moveY, { + dt, + reason: state === "sleep" ? "tarinai-sleep-physical" : "tarinai-passive-physical", + maxChecks: context.cadence?.lane === "sleep" ? 18 : 22, + maxStep: 10, + }); + } else { + t.x = beforeX + moveX; + t.y = beforeY + moveY; + } + } + + const bodyDrag = state === "sleep" ? (sleepExternalMotionActive(t) ? 0.86 : 0.55) : (state === "eat" ? 0.42 : 0.66); + t.vx = Math.abs(numeric(t.vx) * Math.pow(bodyDrag, dt * 60)) < 0.025 ? 0 : numeric(t.vx) * Math.pow(bodyDrag, dt * 60); + t.vy = Math.abs(numeric(t.vy) * Math.pow(bodyDrag, dt * 60)) < 0.025 ? 0 : numeric(t.vy) * Math.pow(bodyDrag, dt * 60); + const impulseDrag = Math.pow(0.58, dt * 3.2); + t.impulseVx = Math.abs(impulseVx) < 0.35 ? 0 : impulseVx * impulseDrag; + t.impulseVy = Math.abs(impulseVy) < 0.35 ? 0 : impulseVy * impulseDrag; + + applyVelocityShockDamage(t, beforeVelocity, dt); + t.updateNestBoxPresence?.(dt); + + const dx = numeric(t.x) - beforeX; + const dy = numeric(t.y) - beforeY; + if (world?.resolveSolidObstacleCollision && !world.isTarinaiHiddenInNestBox?.(t) && (state === "sleep" || sleepExternalMotionActive(t))) { + // A moving mechanical item can hit a sleeping Tarinai while the Tarinai's + // own velocity is zero. In that case swept movement will never run, so + // do a small contact pass every passive-physics tick. + const beforeContactX = numeric(t.x); + const beforeContactY = numeric(t.y); + const pushed = world.resolveSolidObstacleCollision(t, { + maxPasses: 2, + searchRadius: Math.max(72, (numeric(t.radius, 22) || 22) + 120), + maxChecks: context.cadence?.lane === "sleep" ? 18 : 22, + slop: 0.28, + reason: "tarinai-sleep-passive-contact", + }); + if (pushed) { + t.lastSolidObstacleCollisionAt = world.time || 0; + markSleepExternalMotion(t, dt, "sleep-contact-push"); + const cdx = numeric(t.x) - beforeContactX; + const cdy = numeric(t.y) - beforeContactY; + if (Math.hypot(cdx, cdy) > 0.05) { + const pushCarry = state === "sleep" ? 0.085 : 0.040; + t.vx = clamp(numeric(t.vx) + cdx / Math.max(0.016, dt) * pushCarry, -170, 170); + t.vy = clamp(numeric(t.vy) + cdy / Math.max(0.016, dt) * pushCarry, -170, 170); + } + } + } else if (dx * dx + dy * dy <= 0.25 && world?.resolveFenceCollision && !world.isTarinaiHiddenInNestBox?.(t)) { + const now = world.time || 0; + if (now >= (t._nextPassiveSolidRelaxAt || 0)) { + const jitter = typeof stableUnit === "function" ? stableUnit(t.id || t.familyKey || 0, "passive-solid-relax") * 0.55 : Math.random() * 0.55; + t._nextPassiveSolidRelaxAt = now + 1.20 + jitter; + world.resolveFenceCollision(t); + } + } + return { done: false, passive: true }; + } + + function update(t, dt, context = {}) { + if (!t || t.dead) return { done: true }; + const motionDt = Math.max(0.001, Math.min(0.05, Number(context.motionDt ?? dt) || 0.016)); + if (context.motionOnly && /^(sleep|eat|intimidate|ant_intimidate)$/.test(String(t.state || ""))) { + return passivePhysicalMotion(t, motionDt, context); + } + const beforeX = Number.isFinite(t.x) ? t.x : 0; + const beforeY = Number.isFinite(t.y) ? t.y : 0; const current = combinedVelocity(t); const before = Number.isFinite(t._lastPhysicalVelocityX) && Number.isFinite(t._lastPhysicalVelocityY) ? { vx: t._lastPhysicalVelocityX, vy: t._lastPhysicalVelocityY } : current; - if (global.TarinaiSunbathSystem?.updateOne) global.TarinaiSunbathSystem.updateOne(t, dt); - else t.updateSunbath?.(dt); - t.maintainTargetProgress(dt); - t.move(dt); - if (t.ammoCollisionKnockback) t.ammoCollisionKnockback(dt); - applyVelocityShockDamage(t, before, dt); - t.updateNestBoxPresence(dt); - if (t.world.resolveFenceCollision) t.world.resolveFenceCollision(t); + if (global.TarinaiSunbathSystem?.updateOne) global.TarinaiSunbathSystem.updateOne(t, motionDt); + else t.updateSunbath?.(motionDt); + t.maintainTargetProgress(motionDt); + const prevMotionOnlyFlag = t._motionOnlyPhysicsPass; + if (context.motionOnly) t._motionOnlyPhysicsPass = true; + try { + t.move(motionDt); + } finally { + if (context.motionOnly) t._motionOnlyPhysicsPass = prevMotionOnlyFlag; + } + if (t.ammoCollisionKnockback) t.ammoCollisionKnockback(motionDt); + applyVelocityShockDamage(t, before, motionDt); + t.updateNestBoxPresence(motionDt); + + // t.move() normally calls world.moveTarinaiWithCollision(), which already + // performs swept solid-obstacle resolution. Running resolveFenceCollision() + // again for every creature made stationary/sleeping crowds pay the full + // obstacle query cost twice per update. Keep the legacy fallback for worlds + // without swept movement, and run a staggered low-frequency relax pass for + // near-stationary bodies so old overlaps still self-heal. + const world = t.world; + if (world?.resolveFenceCollision) { + const dx = (Number.isFinite(t.x) ? t.x : 0) - beforeX; + const dy = (Number.isFinite(t.y) ? t.y : 0) - beforeY; + const movedSq = dx * dx + dy * dy; + if (!world.moveTarinaiWithCollision && movedSq > 0.25) { + world.resolveFenceCollision(t); + } else if (movedSq <= 0.25 && !world.isTarinaiHiddenInNestBox?.(t)) { + const now = world.time || 0; + if (now >= (t._nextSolidRelaxAt || 0)) { + const jitter = typeof stableUnit === "function" ? stableUnit(t.id || t.familyKey || 0, "solid-relax") * 0.45 : Math.random() * 0.45; + t._nextSolidRelaxAt = now + 0.85 + jitter; + world.resolveFenceCollision(t); + } + } + } return { done: false }; } - global.TarinaiMovementUpdateStep = Object.freeze({ update, applyVelocityShockDamage }); + global.TarinaiMovementUpdateStep = Object.freeze({ update, applyVelocityShockDamage, passivePhysicalMotion, markSleepExternalMotion, sleepExternalMotionActive }); })(typeof window !== "undefined" ? window : globalThis); diff --git a/js/ui_bind.js b/js/ui_bind.js index eda439e..529b336 100644 --- a/js/ui_bind.js +++ b/js/ui_bind.js @@ -9,7 +9,6 @@ function bindUI() { window.bindGroundUI?.(world); renderEcologyCards(); window.TarinaiSaveSystem?.bindSaveSystem?.(); - window.TarinaiFreezeSystem?.bindFreezeSystem?.(); uiCache.toolButtons = Array.from(ui.toolPalette?.querySelectorAll(".tool") || []); function isActiveToolMode() { return Boolean(world?.tool && world.tool !== "observe"); diff --git a/js/ui_input_shared.js b/js/ui_input_shared.js index 9930008..06d39cc 100644 --- a/js/ui_input_shared.js +++ b/js/ui_input_shared.js @@ -131,6 +131,17 @@ return false; } + function syncGrabbedPhysicsItem(target, reason = "pinch-move") { + if (!target || target.dead) return false; + const pb = (typeof window !== "undefined" ? window : globalThis).TarinaiPhysicsBodySystem; + if (!pb?.isBodyType?.(target.type)) return false; + pb.syncPoseFromItem?.(target); + pb.setScalar?.(target, "awakeUntil", Math.max(Number(pb.scalar?.(target, "awakeUntil", 0) || 0), (world.time || 0) + 0.65), reason); + pb.markBodyChanged?.(target, reason); + (typeof window !== "undefined" ? window : globalThis).TarinaiMechanicalSystem?.invalidateGeometry?.(target); + return true; + } + function prepareGrabTarget(target) { if (uiCache.grabKind === "tarinai") { const lodged = target.currentLodgedPin?.() || (world.items || []).find(it => it && isPinType(it.type) && it.pinState === "lodged" && it.pinTargetId === target.id); @@ -168,6 +179,7 @@ target.playerHeld = true; target._heldByPlayer = true; target._playerGrabStartedAt = world.time || 0; + if (uiCache.grabKind === "item") syncGrabbedPhysicsItem(target, "pinch-start"); if (target.type === "duplicator") { target.duplicatorLoadSuppressedUntil = Math.max(target.duplicatorLoadSuppressedUntil || 0, (world.time || 0) + 1.0); target._duplicatorCandidateKey = ""; @@ -206,11 +218,13 @@ const movedX = (Number(target.x || 0) || 0) - beforeX; const movedY = (Number(target.y || 0) || 0) - beforeY; if (uiCache.grabKind === "item" && target.type === "reciprocator") { - target.reciprocatorAnchorX = (Number(target.reciprocatorAnchorX || beforeX) || beforeX) + movedX; - target.reciprocatorAnchorY = (Number(target.reciprocatorAnchorY || beforeY) || beforeY) + movedY; + const pb = (typeof window !== "undefined" ? window : globalThis).TarinaiPhysicsBodySystem; + pb?.setScalar?.(target, "railAnchorX", (Number(pb?.scalar?.(target, "railAnchorX", beforeX)) || beforeX) + movedX, "grab-move"); + pb?.setScalar?.(target, "railAnchorY", (Number(pb?.scalar?.(target, "railAnchorY", beforeY)) || beforeY) + movedY, "grab-move"); target.prevX = target.x; target.prevY = target.y; } + if (uiCache.grabKind === "item") syncGrabbedPhysicsItem(target, "pinch-move"); target.vx = clamp(dx * 16, -160, 160); target.vy = clamp(dy * 16, -160, 160); if (uiCache.grabKind === "tarinai") { @@ -245,6 +259,7 @@ target.prevX = target.x; target.prevY = target.y; } + if (uiCache.grabKind === "item") syncGrabbedPhysicsItem(target, "pinch-release"); target.playerHeld = false; target._heldByPlayer = false; target._lastPlayerReleasedAt = world.time || 0; diff --git a/js/version.js b/js/version.js index 51ab968..20aa6af 100644 --- a/js/version.js +++ b/js/version.js @@ -1,8 +1,8 @@ "use strict"; (function () { - const APP_VERSION = "39.00.00"; - const APP_BUILD = "lab-ground-wash-and-freezer-removal-v38"; + const APP_VERSION = "39.15.18"; + const APP_BUILD = "tarinai-nearby-constraint-reduce-v39-15-18"; 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 c1ae783..17572cc 100644 --- a/js/world.js +++ b/js/world.js @@ -14,7 +14,6 @@ class World { constructor() { this.toolSizes = {}; this.toolAngles = {}; this.tarinai = []; - this.frozenTarinai = []; this.items = []; this.ants = []; this.effects = []; diff --git a/js/world_combat_effects.js b/js/world_combat_effects.js index 01dc0a4..594f374 100644 --- a/js/world_combat_effects.js +++ b/js/world_combat_effects.js @@ -1,4 +1,34 @@ "use strict"; +(function ensureDeterministicHelpers(global) { + if (typeof global.deterministicChance === "function" && typeof global.deterministicRange === "function") return; + const clamp01 = v => Math.max(0, Math.min(1, Number(v) || 0)); + const keyPart = value => { + if (value == null) return "null"; + if (typeof value === "number") return Number.isFinite(value) ? value.toFixed(3) : "nan"; + if (typeof value === "string" || typeof value === "boolean") return String(value); + if (typeof value === "object") { + const id = value.familyKey || value.id || value.seed || value.liveToken || value.name; + if (id) return `${value.type || value.constructor?.name || "entity"}:${id}`; + const x = Number.isFinite(value.x) ? value.x.toFixed(1) : "x"; + const y = Number.isFinite(value.y) ? value.y.toFixed(1) : "y"; + return `${value.type || value.constructor?.name || "entity"}@${x},${y}`; + } + return String(value); + }; + const hashUnit = (seed, salt = "") => { + const str = `${seed}:${salt}`; + let h = 2166136261; + for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); } + return ((h >>> 0) % 100000) / 100000; + }; + global.deterministicFrame = global.deterministicFrame || ((worldRef = null, hz = 60) => Math.floor((Number(worldRef?.time || 0) || 0) * Math.max(1, Number(hz) || 60) + 1e-6)); + global.deterministicUnit = global.deterministicUnit || ((worldRef = null, salt = "", ...parts) => hashUnit(String(worldRef?.worldSeed || "tarinai-world"), [salt, global.deterministicFrame(worldRef, 60), Number(worldRef?._deterministicEpoch || 0) || 0, ...parts.map(keyPart)].join("|"))); + global.deterministicRange = global.deterministicRange || ((worldRef = null, salt = "", min = 0, max = 1, ...parts) => (Number(min) || 0) + global.deterministicUnit(worldRef, salt, ...parts) * ((Number(max) || 0) - (Number(min) || 0))); + global.deterministicSigned = global.deterministicSigned || ((worldRef = null, salt = "", ...parts) => global.deterministicUnit(worldRef, salt, ...parts) < 0.5 ? -1 : 1); + global.deterministicChance = global.deterministicChance || ((worldRef = null, salt = "", chance = 0, ...parts) => { const p = clamp01(chance); return p >= 1 || (p > 0 && global.deterministicUnit(worldRef, salt, ...parts) < p); }); + global.deterministicAngle = global.deterministicAngle || ((worldRef = null, salt = "", ...parts) => global.deterministicRange(worldRef, salt, 0, Math.PI * 2, ...parts)); +})(typeof window !== "undefined" ? window : globalThis); + (function (global) { const World = global.World; @@ -29,7 +59,7 @@ let nx = (ant.x - x) / d; let ny = (ant.y - y) / d; if (!Number.isFinite(nx) || !Number.isFinite(ny) || Math.abs(nx) + Math.abs(ny) < 0.001) { - const a = rand(0, Math.PI * 2); + const a = deterministicAngle(this, "ant-push-overlap", ant, x, y); nx = Math.cos(a); ny = Math.sin(a); } @@ -58,13 +88,13 @@ } this.blastZunchiFrom(x, y, blastRadius * 0.92, it.blastScale || 1); for (let i = 0; i < 34; i++) { - const a = Math.PI * 2 * i / 34 + rand(-0.10, 0.10); - const speed = rand(90, 260); - this.effects.push(new Effect(i % 3 === 0 ? "explosion" : "fight", x + Math.cos(a) * rand(2, 22), y + Math.sin(a) * rand(2, 22), { + const a = Math.PI * 2 * i / 34 + deterministicRange(this, "firecracker-spark-angle", -0.10, 0.10, it, i); + const speed = deterministicRange(this, "firecracker-spark-speed", 90, 260, it, i); + this.effects.push(new Effect(i % 3 === 0 ? "explosion" : "fight", x + Math.cos(a) * deterministicRange(this, "firecracker-spark-x", 2, 22, it, i), y + Math.sin(a) * deterministicRange(this, "firecracker-spark-y", 2, 22, it, i), { vx: Math.cos(a) * speed, vy: Math.sin(a) * speed, - size: rand(8, i % 3 === 0 ? 24 : 20), - life: rand(0.26, 0.72), + size: deterministicRange(this, "firecracker-spark-size", 8, i % 3 === 0 ? 24 : 20, it, i), + life: deterministicRange(this, "firecracker-spark-life", 0.26, 0.72, it, i), color: i % 3 === 0 ? "rgba(255,218,70,0.78)" : "rgba(112,72,42,0.72)", })); } @@ -76,7 +106,7 @@ if (t.enterPanic) t.enterPanic({ target: { x, y, dead: false }, reason: "\u7206\u7af9\u3067\u305f\u305f\u304d\u8d77\u3053\u3055\u308c\u305f", fear: 1.2, surpriseTimer: 0.95, wake: true, cause: "firecracker_wakeup" }); else { t.setActionState?.("panic", { target: { x, y, dead: false }, reason: "\u7206\u7af9\u3067\u305f\u305f\u304d\u8d77\u3053\u3055\u308c\u305f", wake: true }); t.surpriseTimer = Math.max(t.surpriseTimer, 0.95); t.fearTimer = Math.max(t.fearTimer, 1.2); } } - if (t.addStress) t.addStress(rand(8, 15), { threshold: 8 }); + if (t.addStress) t.addStress(deterministicRange(this, "firecracker-startle-stress", 8, 15, it, t), { threshold: 8 }); t.fearTimer = Math.max(t.fearTimer, 0.58); const dx = t.x - x; const dy = t.y - y; @@ -92,13 +122,13 @@ let nx = dx / d; let ny = dy / d; if (!Number.isFinite(nx) || !Number.isFinite(ny) || Math.abs(nx) + Math.abs(ny) < 0.001) { - const a = rand(0, Math.PI * 2); + const a = deterministicAngle(this, "drop-impact-overlap-normal", it, t); nx = Math.cos(a); ny = Math.sin(a); } const distanceBoost = p * p * 0.55 + p * 0.45; - const power = (260 + distanceBoost * 920) * rand(0.78, 1.24) * (it.blastScale || 1); - this.applyImpulse(t, nx * power + rand(-95, 95) * (0.6 + p), ny * power + rand(-95, 95) * (0.6 + p), { + const power = (260 + distanceBoost * 920) * deterministicRange(this, "firecracker-tarinai-power", 0.78, 1.24, it, t) * (it.blastScale || 1); + this.applyImpulse(t, nx * power + deterministicRange(this, "firecracker-tarinai-jitter-x", -95, 95, it, t) * (0.6 + p), ny * power + deterministicRange(this, "firecracker-tarinai-jitter-y", -95, 95, it, t) * (0.6 + p), { panic: true, target: { x, y }, fearTimer: 1.7 + p, @@ -106,13 +136,13 @@ }); t.fallTimer = Math.max(t.fallTimer, 1.45 + p * 1.85); t.fallMax = Math.max(t.fallMax || 0, t.fallTimer); - t.fallDir = (dx >= 0 ? 1 : -1) * (Math.random() < 0.5 ? 1 : -1); + t.fallDir = (dx >= 0 ? 1 : -1) * deterministicSigned(this, "firecracker-fall-dir", it, t); t.blastSpinTimer = Math.max(t.blastSpinTimer || 0, 1.35 + p * 0.70); t.blastSpinMax = Math.max(t.blastSpinMax || 0, t.blastSpinTimer); this.spawnFallEffect(t.x, t.y + t.radius * 0.45, 1.1 + p); const explosionChance = window.TarinaiDiseaseRegistry?.infectionChance?.("disease_explosion", 0.15) ?? 0.15; - if (!t.dead && p > 0.22 && Math.random() < (t.diseaseChance ? t.diseaseChance(explosionChance) : explosionChance)) t.infectExplosionDisease?.(it); - if (!t.dead && Math.random() < 0.45) this.spawnBubble(t.x, t.y - t.radius * 1.35, "\u306f\u3041\u3041\u3041\uff01", "rgba(84,66,86,0.80)"); + if (!t.dead && p > 0.22 && deterministicChance(this, "firecracker-explosion-disease", t.diseaseChance ? t.diseaseChance(explosionChance) : explosionChance, it, t)) t.infectExplosionDisease?.(it); + if (!t.dead && deterministicChance(this, "firecracker-panic-bubble", 0.45, it, t)) this.spawnBubble(t.x, t.y - t.radius * 1.35, "\u306f\u3041\u3041\u3041\uff01", "rgba(84,66,86,0.80)"); } if (typeof damageNearbyStructures === "function") damageNearbyStructures(this, x, y, blastRadius, 34 * (it.blastScale || 1), it); this.damageAntsInRadius(x, y, blastRadius, p => 5 + p * 18, "\u7206\u7af9", { @@ -127,13 +157,13 @@ if (d > blastRadius * 1.18) continue; const p = clamp(1 - d / (blastRadius * 1.18), 0, 1); if (d < 0.001) { - const a = rand(0, Math.PI * 2); + const a = deterministicAngle(this, "firecracker-ball-overlap", it, ball); dx = Math.cos(a); dy = Math.sin(a); d = 1; } const blast = 430 + p * 960; - this.applyImpulse(ball, dx / d * blast + rand(-80, 80), dy / d * blast + rand(-80, 80)); + this.applyImpulse(ball, dx / d * blast + deterministicRange(this, "firecracker-ball-jitter-x", -80, 80, it, ball), dy / d * blast + deterministicRange(this, "firecracker-ball-jitter-y", -80, 80, it, ball)); const speed = Math.hypot(ball.vx || 0, ball.vy || 0); - ball.spinVelocity = clamp((ball.spinVelocity || 0) + rand(-24, 24), -38, 38); + ball.spinVelocity = clamp((ball.spinVelocity || 0) + deterministicRange(this, "disease-explosion-ball-spin", -24, 24, t, ball), -38, 38); ball.lastPokedAt = this.time || 0; ball.pokeCombo = Math.max(ball.pokeCombo || 0, 5); this.effects.push(new Effect("ring", ball.x, ball.y, { size: Math.max(18, ball.r * 1.2), life: 0.24, color: "rgba(255,230,116,0.58)" })); @@ -157,13 +187,13 @@ } this.blastZunchiFrom(x, y, blastRadius * 0.92, blastScale); for (let i = 0; i < 34; i++) { - const a = Math.PI * 2 * i / 34 + rand(-0.10, 0.10); - const speed = rand(90, 260) * blastScale; - this.effects.push(new Effect(i % 3 === 0 ? "explosion" : "fight", x + Math.cos(a) * rand(2, 22) * blastScale, y + Math.sin(a) * rand(2, 22) * blastScale, { + const a = Math.PI * 2 * i / 34 + deterministicRange(this, "disease-explosion-spark-angle", -0.10, 0.10, t, i); + const speed = deterministicRange(this, "disease-explosion-spark-speed", 90, 260, t, i) * blastScale; + this.effects.push(new Effect(i % 3 === 0 ? "explosion" : "fight", x + Math.cos(a) * deterministicRange(this, "disease-explosion-spark-x", 2, 22, t, i) * blastScale, y + Math.sin(a) * deterministicRange(this, "disease-explosion-spark-y", 2, 22, t, i) * blastScale, { vx: Math.cos(a) * speed, vy: Math.sin(a) * speed, - size: rand(8, i % 3 === 0 ? 24 : 20) * blastScale, - life: rand(0.26, 0.72), + size: deterministicRange(this, "disease-explosion-spark-size", 8, i % 3 === 0 ? 24 : 20, t, i) * blastScale, + life: deterministicRange(this, "disease-explosion-spark-life", 0.26, 0.72, t, i), color: i % 3 === 0 ? "rgba(255,218,70,0.78)" : "rgba(112,72,42,0.72)", })); } @@ -179,13 +209,13 @@ let nx = dx / d; let ny = dy / d; if (!Number.isFinite(nx) || !Number.isFinite(ny) || Math.abs(nx) + Math.abs(ny) < 0.001) { - const a = rand(0, Math.PI * 2); + const a = deterministicAngle(this, "disease-explosion-overlap-normal", t, o); nx = Math.cos(a); ny = Math.sin(a); } const distanceBoost = q * q * 0.55 + q * 0.45; - const power = (260 + distanceBoost * 920) * rand(0.78, 1.24) * blastScale; - this.applyImpulse(o, nx * power + rand(-95, 95) * (0.6 + q), ny * power + rand(-95, 95) * (0.6 + q), { + const power = (260 + distanceBoost * 920) * deterministicRange(this, "disease-explosion-tarinai-power", 0.78, 1.24, t, o) * blastScale; + this.applyImpulse(o, nx * power + deterministicRange(this, "disease-explosion-tarinai-jitter-x", -95, 95, t, o) * (0.6 + q), ny * power + deterministicRange(this, "disease-explosion-tarinai-jitter-y", -95, 95, t, o) * (0.6 + q), { panic: true, target: { x, y }, fearTimer: 1.7 + q, @@ -197,13 +227,13 @@ else { o.fearTimer = Math.max(o.fearTimer || 0, 1.7 + q); o.setActionState?.("panic", { target: { x, y }, reason: "\u7206\u767a\u75c5\u306e\u7206\u767a\u306b\u5dfb\u304d\u8fbc\u307e\u308c\u3066\u3044\u308b", wake: true }); } o.fallTimer = Math.max(o.fallTimer || 0, 1.45 + q * 1.85); o.fallMax = Math.max(o.fallMax || 0, o.fallTimer); - o.fallDir = (dx >= 0 ? 1 : -1) * (Math.random() < 0.5 ? 1 : -1); + o.fallDir = (dx >= 0 ? 1 : -1) * deterministicSigned(this, "disease-explosion-fall-dir", t, o); o.blastSpinTimer = Math.max(o.blastSpinTimer || 0, 1.35 + q * 0.70); o.blastSpinMax = Math.max(o.blastSpinMax || 0, o.blastSpinTimer); this.spawnFallEffect(o.x, o.y + o.radius * 0.45, 1.1 + q); const explosionChance = window.TarinaiDiseaseRegistry?.infectionChance?.("disease_explosion", 0.15) ?? 0.15; - if (!o.dead && q > 0.22 && Math.random() < (o.diseaseChance ? o.diseaseChance(explosionChance) : explosionChance)) o.infectExplosionDisease?.(t); - if (!o.dead && Math.random() < 0.45) this.spawnBubble(o.x, o.y - o.radius * 1.35, "\u306f\u3041\u3041\u3041\uff01", "rgba(84,66,86,0.80)"); + if (!o.dead && q > 0.22 && deterministicChance(this, "disease-explosion-spread", o.diseaseChance ? o.diseaseChance(explosionChance) : explosionChance, t, o)) o.infectExplosionDisease?.(t); + if (!o.dead && deterministicChance(this, "disease-explosion-panic-bubble", 0.45, t, o)) this.spawnBubble(o.x, o.y - o.radius * 1.35, "\u306f\u3041\u3041\u3041\uff01", "rgba(84,66,86,0.80)"); } this.damageAntsInRadius(x, y, blastRadius, p => 5 + p * 18, "\u7206\u767a\u75c5", { push: p => (260 + (p * p * 0.55 + p * 0.45) * 920) * 0.42 * blastScale, @@ -216,12 +246,12 @@ if (d > blastRadius * 1.18) continue; const q = clamp(1 - d / (blastRadius * 1.18), 0, 1); if (d < 0.001) { - const a = rand(0, Math.PI * 2); + const a = deterministicAngle(this, "disease-explosion-ball-overlap", t, ball); dx = Math.cos(a); dy = Math.sin(a); d = 1; } const blast = 430 + q * 960; - this.applyImpulse(ball, dx / d * blast * blastScale + rand(-80, 80), dy / d * blast * blastScale + rand(-80, 80)); - ball.spinVelocity = clamp((ball.spinVelocity || 0) + rand(-24, 24), -38, 38); + this.applyImpulse(ball, dx / d * blast * blastScale + deterministicRange(this, "disease-explosion-ball-jitter-x", -80, 80, t, ball), dy / d * blast * blastScale + deterministicRange(this, "disease-explosion-ball-jitter-y", -80, 80, t, ball)); + ball.spinVelocity = clamp((ball.spinVelocity || 0) + deterministicRange(this, "disease-explosion-ball-spin", -24, 24, t, ball), -38, 38); ball.lastPokedAt = this.time || 0; ball.pokeCombo = Math.max(ball.pokeCombo || 0, 5); this.effects.push(new Effect("ring", ball.x, ball.y, { size: Math.max(18, ball.r * 1.2), life: 0.24, color: "rgba(255,230,116,0.58)" })); @@ -271,13 +301,13 @@ const d = distXY(t.x, t.y, x, y); if (d > radius + t.radius) continue; const p = clamp(1 - d / (radius + t.radius), 0, 1); - t.vx += nx * (95 + 165 * p) + rand(-10, 10); - t.vy += ny * (95 + 165 * p) + rand(-10, 10); + t.vx += nx * (95 + 165 * p) + deterministicRange(this, "water-hose-jitter-x", -10, 10, t, x, y); + t.vy += ny * (95 + 165 * p) + deterministicRange(this, "water-hose-jitter-y", -10, 10, t, x, y); t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.24); t.applyWaterEffect?.(dt * (1.0 + p * 1.8), "hose"); } - if (Math.random() < 0.72) { - this.effects.push(new Effect("ring", x + rand(-18, 18), y + rand(-12, 12), { size: rand(8, 22), life: rand(0.18, 0.32), color: "rgba(128,204,238,0.60)" })); + if (deterministicChance(this, "water-hose-ring", 0.72, x, y)) { + this.effects.push(new Effect("ring", x + deterministicRange(this, "water-hose-ring-x", -18, 18, x, y), y + deterministicRange(this, "water-hose-ring-y", -12, 12, x, y), { size: deterministicRange(this, "water-hose-ring-size", 8, 22, x, y), life: deterministicRange(this, "water-hose-ring-life", 0.18, 0.32, x, y), color: "rgba(128,204,238,0.60)" })); } if (cleaned > 0.25) { this.drawListDirty = true; @@ -293,11 +323,11 @@ const sx = Number.isFinite(source?.x) ? source.x : (pin?.x || this.w * 0.5); const sy = Number.isFinite(source?.y) ? source.y : (pin?.y || this.h * 0.5); for (let i = 0; i < n; i++) { - const a = -Math.PI * 0.15 + rand(-0.92, 0.92) + (i / Math.max(1, n - 1) - 0.5) * 1.2; - const it = new Item("zunchi", clamp(sx + rand(-8, 8), 40, this.w - 40), clamp(sy + rand(-8, 8), 40, this.h - 40)); + const a = -Math.PI * 0.15 + deterministicRange(this, "zunchi-burst-angle", -0.92, 0.92, source, pin, i) + (i / Math.max(1, n - 1) - 0.5) * 1.2; + const it = new Item("zunchi", clamp(sx + deterministicRange(this, "zunchi-burst-x", -8, 8, source, pin, i), 40, this.w - 40), clamp(sy + deterministicRange(this, "zunchi-burst-y", -8, 8, source, pin, i), 40, this.h - 40)); it.amount = 240; - it.vx = Math.cos(a) * rand(260, 560) + rand(-40, 40); - it.vy = Math.sin(a) * rand(200, 480) - rand(30, 160); + it.vx = Math.cos(a) * deterministicRange(this, "zunchi-burst-speed-x", 260, 560, source, pin, i) + deterministicRange(this, "zunchi-burst-jitter-x", -40, 40, source, pin, i); + it.vy = Math.sin(a) * deterministicRange(this, "zunchi-burst-speed-y", 200, 480, source, pin, i) - deterministicRange(this, "zunchi-burst-up", 30, 160, source, pin, i); it.stage = "fresh"; it.burstFromOshibyo = true; this.addItem?.(it, "zunchi-burst") || this.items.push(it); @@ -325,9 +355,9 @@ if (text === "z") text = "Zzz..."; audio.bubble(text); this.effects.push(new Effect("bubble", x, y, { - vx: rand(-2, 2), - vy: rand(-5, -2), - life: rand(2.2, 3.2), + vx: deterministicRange(this, "bubble-vx", -2, 2, x, y, text), + vy: deterministicRange(this, "bubble-vy", -5, -2, x, y, text), + life: deterministicRange(this, "bubble-life", 2.2, 3.2, x, y, text), size: 8, text, color, @@ -337,45 +367,45 @@ spawnHeadbuttEffect(x, y) { this.effects.push(new Effect("fight", x, y, { - size: rand(10, 16), - life: rand(0.20, 0.34), + size: deterministicRange(this, "headbutt-effect-size", 10, 16, x, y), + life: deterministicRange(this, "headbutt-effect-life", 0.20, 0.34, x, y), color: "rgba(105, 62, 34, 0.86)", })); - if (Math.random() < 0.5) audio.fight(); + if (deterministicChance(this, "headbutt-audio", 0.5, x, y)) audio.fight(); }, spawnFallEffect(x, y, scale = 1) { if ((this.effectCounts.fall || 0) > 18) return; this.effects.push(new Effect("fall", x, y, { - vx: rand(-12, 12), - vy: rand(-4, 3), - size: rand(20, 32) * scale, - life: rand(0.48, 0.72), + vx: deterministicRange(this, "fall-effect-vx", -12, 12, x, y), + vy: deterministicRange(this, "fall-effect-vy", -4, 3, x, y), + size: deterministicRange(this, "fall-effect-size", 20, 32, x, y) * scale, + life: deterministicRange(this, "fall-effect-life", 0.48, 0.72, x, y), color: "rgba(154, 124, 80, 0.58)", })); }, spawnEatEffect(x, y, color = "#f1dfb7") { - if (Math.random() < 0.70) { - this.effects.push(new Effect("eat", x + rand(-4, 4), y + rand(-4, 4), { - vx: rand(-10, 10), - vy: rand(-18, -3), - size: rand(2.2, 4.2), - life: rand(0.18, 0.32), + if (deterministicChance(this, "eat-effect", 0.70, x, y, color)) { + this.effects.push(new Effect("eat", x + deterministicRange(this, "eat-effect-x", -4, 4, x, y, color), y + deterministicRange(this, "eat-effect-y", -4, 4, x, y, color), { + vx: deterministicRange(this, "eat-effect-vx", -10, 10, x, y, color), + vy: deterministicRange(this, "eat-effect-vy", -18, -3, x, y, color), + size: deterministicRange(this, "eat-effect-size", 2.2, 4.2, x, y, color), + life: deterministicRange(this, "eat-effect-life", 0.18, 0.32, x, y, color), color, })); } - if (Math.random() < 0.08) { + if (deterministicChance(this, "eat-ring", 0.08, x, y, color)) { this.effects.push(new Effect("ring", x, y, { size: 5, life: 0.20, color })); } }, spawnBleedEffect(x, y) { - this.effects.push(new Effect("bleed", x + rand(-3, 3), y + rand(-2, 3), { - vx: rand(-8, 8), - vy: rand(3, 14), - size: rand(2.0, 3.8), - life: rand(0.34, 0.62), + this.effects.push(new Effect("bleed", x + deterministicRange(this, "bleed-effect-x", -3, 3, x, y), y + deterministicRange(this, "bleed-effect-y", -2, 3, x, y), { + vx: deterministicRange(this, "bleed-effect-vx", -8, 8, x, y), + vy: deterministicRange(this, "bleed-effect-vy", 3, 14, x, y), + size: deterministicRange(this, "bleed-effect-size", 2.0, 3.8, x, y), + life: deterministicRange(this, "bleed-effect-life", 0.34, 0.62, x, y), color: "rgba(80, 172, 55, 0.78)", })); }, @@ -419,6 +449,17 @@ applyImpulse(entity, vx = 0, vy = 0, options = {}) { if (!entity || entity.dead) return false; if (!Number.isFinite(vx) || !Number.isFinite(vy)) return false; + const impulseMag = Math.hypot(vx, vy); + if (entity instanceof Tarinai && (entity.birthRitualTimer || 0) > 0.04) { + this.cancelBirthRitualOnForce?.(entity, impulseMag, { + reason: options.birthCancelReason || options.thought || "強い衝撃で繁殖が中断された", + target: options.target || null, + fear: options.fearTimer ?? 1.0, + cause: options.cause || "impulse", + panic: options.panic !== false, + silentLog: !!options.silentBirthCancel, + }); + } if (entity instanceof Tarinai || "impulseVx" in entity || "impulseVy" in entity) { entity.impulseVx = (Number.isFinite(entity.impulseVx) ? entity.impulseVx : 0) + vx; entity.impulseVy = (Number.isFinite(entity.impulseVy) ? entity.impulseVy : 0) + vy; @@ -435,8 +476,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("落ちてきた"); + if (target instanceof Tarinai && (target.birthRitualTimer || 0) > 0.04 && physicalHit && amount >= (options.birthCancelDamageThreshold ?? 8)) { + this.cancelBirthRitualOnForce?.(target, Math.max(180, amount * 18), { + reason: "強い衝撃で繁殖が中断された", + target: options.source || null, + fear: 1.0, + cause: "impact_damage", + panic: true, + silentLog: !!options.silentBirthCancel, + }); + } this.emit?.(String(normalized).includes("\u55a7\u5629") ? "fight:hit" : "impact:hit", { target, amount, cause: normalized, source: options.source || null }); - if (String(normalized).includes("衝突") || String(normalized).includes("collision") || String(cause || "").includes("急激な速度変化")) { + if (physicalHit) { target.lastPhysicalCollisionDamageAt = this.time || 0; } if (target.damage) target.damage(amount, normalized); @@ -454,21 +506,21 @@ const damageRisk = clamp((damage || 0) / Math.max(2.5, 9 + (victim.energy || 0) * 0.045), 0, 1.4); const stressRisk = clamp(((victim.stress || 0) - 70) / 70, 0, 0.8); const chance = clamp(0.025 + damageRisk * 0.23 + healthRisk * 0.34 + stressRisk * 0.10 - aggressionHold * 0.12, 0.015, 0.78); - if (Math.random() >= chance) return false; + if (!deterministicChance(this, "fight-defeat", chance, victim, attacker, damage)) return false; victim.defeatedById = attacker.id; victim.fightWinnerId = attacker.id; attacker.fightWinnerId = attacker.id; attacker.defeatedById = null; - victim.fightTimer = Math.min(victim.fightTimer || 0, rand(0.20, 0.42)); - attacker.fightTimer = Math.min(attacker.fightTimer || 0, rand(0.20, 0.42)); + victim.fightTimer = Math.min(victim.fightTimer || 0, deterministicRange(this, "fight-defeat-victim-timer", 0.20, 0.42, victim, attacker)); + attacker.fightTimer = Math.min(attacker.fightTimer || 0, deterministicRange(this, "fight-defeat-attacker-timer", 0.20, 0.42, victim, attacker)); this.emit?.("fight:lost", { winner: attacker, loser: victim, damage }); if (victim.enterPanic) victim.enterPanic({ target: attacker, threat: attacker, reason: "\u55a7\u5629\u306b\u8ca0\u3051\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", fear: 1.1, wake: true, cause: "fight_lost" }); else { victim.setActionState?.("panic", { target: attacker, reason: "\u55a7\u5629\u306b\u8ca0\u3051\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", wake: true }); victim.fearTimer = Math.max(victim.fearTimer || 0, 1.1 * (profile.fear || 1)); } const dx = victim.x - attacker.x; const dy = victim.y - attacker.y; const d = Math.hypot(dx, dy) || 1; - victim.vx += dx / d * rand(38, 76); - victim.vy += dy / d * rand(22, 56); + victim.vx += dx / d * deterministicRange(this, "fight-defeat-knockback-x", 38, 76, victim, attacker); + victim.vy += dy / d * deterministicRange(this, "fight-defeat-knockback-y", 22, 56, victim, attacker); this.spawnBubble?.(victim.x, victim.y - victim.radius * 1.25, "\u306f\u3041\u3041\u3041\uff01", "rgba(84,66,86,0.80)"); return true; }, @@ -493,7 +545,7 @@ this.emit?.("fight:ended", { winner, loser }); this.markFightPairCooldown?.(winner, loser, CONFIG.fightCooldown + 8); for (const t of [winner, loser]) { - t.fightCooldown = Math.max(t.fightCooldown || 0, CONFIG.fightCooldown + rand(4, 8)); + t.fightCooldown = Math.max(t.fightCooldown || 0, CONFIG.fightCooldown + deterministicRange(this, "fight-outcome-cooldown", 4, 8, t, winner, loser)); if (typeof clearForcedBehaviorQueue === "function") clearForcedBehaviorQueue(t, e => e && e.id === "fight_rival"); } winner.adjustPersonality?.("aggression", 0.018, "after winning fights."); @@ -503,7 +555,7 @@ loser.fearTimer = Math.max(loser.fearTimer, 1.2 * loser.personalityProfile().fear); this.spawnBubble(loser.x, loser.y - loser.radius * 1.28, "\u306f\u3041\u3041\u3041\uff01", "rgba(84,66,86,0.80)"); winner.affection = clamp(winner.affection + 0.8, 0, 80); - if (typeof applyNeedRelief === "function") applyNeedRelief(winner, { safety: -rand(12, 22), fulfill: -rand(8, 16) }); + if (typeof applyNeedRelief === "function") applyNeedRelief(winner, { safety: -deterministicRange(this, "fight-win-safety-relief", 12, 22, winner, loser), fulfill: -deterministicRange(this, "fight-win-fulfill-relief", 8, 16, winner, loser) }); if (this.relationNotice(winner.id, loser.id, "fight-result", 8)) { this.log(`${loser.name}\u306f${winner.name}\u306b\u8ca0\u3051\u3001\u305d\u306e\u3053\u3068\u3092\u899a\u3048\u305f\u3002`, "fight", { participants: [loser, winner] }); } @@ -545,7 +597,7 @@ let nx = (t.x - it.x) / d; let ny = (t.y - it.y) / d; if (!Number.isFinite(nx) || !Number.isFinite(ny) || Math.abs(nx) + Math.abs(ny) < 0.001) { - const a = rand(0, Math.PI * 2); + const a = deterministicAngle(this, "drop-impact-overlap-normal", it, t); nx = Math.cos(a); ny = Math.sin(a); } @@ -574,7 +626,7 @@ if (isGenkotsu) { this.blastZunchiFrom?.(it.x, it.y, radius + 92, 2.75); for (let r = 0; r < 4; r++) this.effects.push(new Effect("ring", it.x, it.y, { size: 32 + r * 34, life: 0.34 + r * 0.07, color: r % 2 ? "rgba(255,203,54,0.72)" : "rgba(74,57,42,0.42)" })); - for (let i = 0; i < 12; i++) this.effects.push(new Effect("fight", it.x + rand(-38, 38), it.y + rand(-22, 28), { size: rand(12, 22), life: rand(0.25, 0.48), color: "rgba(255,205,46,0.82)" })); + for (let i = 0; i < 12; i++) this.effects.push(new Effect("fight", it.x + deterministicRange(this, "genkotsu-impact-effect-x", -38, 38, it, i), it.y + deterministicRange(this, "genkotsu-impact-effect-y", -22, 28, it, i), { size: deterministicRange(this, "genkotsu-impact-effect-size", 12, 22, it, i), life: deterministicRange(this, "genkotsu-impact-effect-life", 0.25, 0.48, it, i), color: "rgba(255,205,46,0.82)" })); } else { this.effects.push(new Effect("ring", it.x, it.y, { size: isStone ? 32 : 18, life: 0.34, color: isStone ? "rgba(128,96,58,0.76)" : "rgba(174,128,70,0.52)" })); } @@ -609,7 +661,7 @@ }); for (const t of arr.slice(5)) { t.goIdle("\u30dc\u30fc\u30eb\u304c\u6df7\u307f\u5408\u3063\u3066\u3044\u308b\u306e\u3067\u773a\u3081\u3066\u3044\u308b"); - t.wanderAngle = Math.atan2(t.y - ball.y, t.x - ball.x) + rand(-0.45, 0.45); + t.wanderAngle = Math.atan2(t.y - ball.y, t.x - ball.x) + deterministicRange(this, "ball-chaser-release-angle", -0.45, 0.45, t, ball); } } }, @@ -621,7 +673,7 @@ let dy = ball.y - y; let d = Math.hypot(dx, dy); if (d < 0.001) { - const a = (ball.lastPokeAngle ?? rand(0, Math.PI * 2)) + rand(-0.55, 0.55); + const a = (ball.lastPokeAngle ?? deterministicAngle(this, "ball-poke-overlap-base", ball, x, y)) + deterministicRange(this, "ball-poke-overlap-jitter", -0.55, 0.55, ball, x, y); dx = Math.cos(a); dy = Math.sin(a); d = 1; @@ -703,7 +755,7 @@ const rvy = (b.vy || 0) - (a.vy || 0); const rs = Math.hypot(rvx, rvy); if (rs > 0.001) { dx = rvx / rs; dy = rvy / rs; d = 1; } - else { const ang = rand(0, Math.PI * 2); dx = Math.cos(ang); dy = Math.sin(ang); d = 1; } + else { const ang = deterministicAngle(this, "ball-ball-overlap-normal", a, b); dx = Math.cos(ang); dy = Math.sin(ang); d = 1; } } const nx = dx / d; const ny = dy / d; @@ -770,7 +822,7 @@ ny = (ball.vy || 0) / ballSpeed; } else { nx = t.facingDir ? t.facingDir() : 1; - ny = rand(-0.22, 0.22); + ny = deterministicRange(this, "ball-interaction-fallback-ny", -0.22, 0.22, ball, t); } } @@ -834,7 +886,7 @@ } t.goodMode = playIntent || playful ? "smile" : t.goodMode; if (playIntent && now > (t.nextPlayBubbleAt || 0)) { - t.nextPlayBubbleAt = now + rand(2.0, 3.8); + t.nextPlayBubbleAt = now + deterministicRange(this, "ball-play-bubble-delay", 2.0, 3.8, ball, t); this.spawnBubble(t.x, t.y - t.radius * 1.15, pick(["!", "\u306f\u3046", "?" ]), "rgba(70,96,50,0.76)"); } if (playful && this.relationNotice(t.id, ball.id || "ball", "ball-kick", 16)) this.log(`${t.name}\u306f\u30dc\u30fc\u30eb\u3092\u8ffd\u3044\u304b\u3051\u3066\u5f3e\u3044\u305f\u3002`, "observe", { participants: [t] }); @@ -847,20 +899,26 @@ const threshold = PHYSICAL_DAMAGE_SPEED_THRESHOLD; const now = this.time || 0; if (!this.tarinaiCollisionMemo) this.tarinaiCollisionMemo = new Map(); - for (const a of this.tarinai || []) { - if (!a || a.dead || this.isTarinaiHiddenInNestBox(a)) continue; + const live = Array.from(this.tarinai || []) + .filter(t => t && !t.dead && !this.isTarinaiHiddenInNestBox(t)) + .sort((a, b) => String(a.id || "").localeCompare(String(b.id || ""))); + const candidates = []; + const maxCandidates = Math.max(24, Math.min(320, live.length * 6)); + for (let i = 0; i < live.length; i += 1) { + const a = live[i]; const avx = a.vx || 0; const avy = a.vy || 0; const aSpeed = Math.hypot(avx, avy); if (aSpeed < threshold) continue; - const px = Number.isFinite(a.prevX) ? a.prevX : a.x; - const py = Number.isFinite(a.prevY) ? a.prevY : a.y; - const sx = a.x - px; - const sy = a.y - py; - const segLenSq = sx * sx + sy * sy; - const range = (a.radius || 22) * 2.5 + aSpeed * Math.max(0.016, dt || 0.016) + 64; - for (const b of this.nearbyTarinai(a.x, a.y, range)) { - if (!b || b === a || b.dead || this.isTarinaiHiddenInNestBox(b)) continue; + const ax0 = Number.isFinite(a.prevX) ? a.prevX : a.x; + const ay0 = Number.isFinite(a.prevY) ? a.prevY : a.y; + const ax1 = Number.isFinite(a.x) ? a.x : ax0; + const ay1 = Number.isFinite(a.y) ? a.y : ay0; + const asx = ax1 - ax0; + const asy = ay1 - ay0; + for (let j = 0; j < live.length; j += 1) { + const b = live[j]; + if (!b || b === a) continue; const key = a.id < b.id ? `${a.id}:${b.id}` : `${b.id}:${a.id}`; if (now - (this.tarinaiCollisionMemo.get(key) || -999) < 0.42) continue; const bvx = b.vx || 0; @@ -869,54 +927,93 @@ const relVy = avy - bvy; const relSpeed = Math.hypot(relVx, relVy); if (relSpeed < threshold) continue; - let hitX = a.x; - let hitY = a.y; - if (segLenSq > 1) { - const u = clamp(((b.x - px) * sx + (b.y - py) * sy) / segLenSq, 0, 1); - hitX = px + sx * u; - hitY = py + sy * u; - } - const d = Math.max(0.001, distXY(hitX, hitY, b.x, b.y)); + const bx0 = Number.isFinite(b.prevX) ? b.prevX : b.x; + const by0 = Number.isFinite(b.prevY) ? b.prevY : b.y; + const bx1 = Number.isFinite(b.x) ? b.x : bx0; + const by1 = Number.isFinite(b.y) ? b.y : by0; + const bsx = bx1 - bx0; + const bsy = by1 - by0; + const rx0 = ax0 - bx0; + const ry0 = ay0 - by0; + const rvx = asx - bsx; + const rvy = asy - bsy; + const relMoveSq = rvx * rvx + rvy * rvy; + const u = relMoveSq > 0.001 ? clamp(-(rx0 * rvx + ry0 * rvy) / relMoveSq, 0, 1) : 1; + const cx = rx0 + rvx * u; + const cy = ry0 + rvy * u; + const d = Math.max(0.001, Math.hypot(cx, cy)); const hitDistance = (a.radius || 22) * 0.74 + (b.radius || 22) * 0.74; if (d > hitDistance) continue; - this.tarinaiCollisionMemo.set(key, now); - const impactor = aSpeed >= Math.hypot(bvx, bvy) ? a : b; - const target = impactor === a ? b : a; - const ivx = impactor.vx || 0; - const ivy = impactor.vy || 0; - const impactSpeed = Math.hypot(ivx, ivy) || relSpeed || 1; - let nx = impactSpeed > 0.001 ? ivx / impactSpeed : (target.x - impactor.x) / Math.max(1, distXY(target.x, target.y, impactor.x, impactor.y)); - let ny = impactSpeed > 0.001 ? ivy / impactSpeed : (target.y - impactor.y) / Math.max(1, distXY(target.x, target.y, impactor.x, impactor.y)); - if (!Number.isFinite(nx) || !Number.isFinite(ny) || Math.abs(nx) + Math.abs(ny) < 0.001) { - const aa = rand(0, Math.PI * 2); - nx = Math.cos(aa); - ny = Math.sin(aa); - } - const damage = physicalDamageFromImpactSpeed(relSpeed, { min: 1.2, max: 22, scale: 26 }); - this.applyImpulse(target, ivx * 0.20 + nx * 28, ivy * 0.20 + ny * 28, { - panic: true, - target: { x: impactor.x, y: impactor.y }, - fearTimer: 0.55, - thought: "\u9ad8\u901f\u3067\u3076\u3064\u304b\u3063\u3066\u6df7\u4e71\u3057\u3066\u3044\u308b", - }); - 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, "\u885d\u7a81"); - if (!impactor.dead) this.applyImpactDamage(impactor, damage * 0.35, "\u885d\u7a81"); - for (const t of [target, impactor]) { - if (!t || t.dead) continue; - if (t.enterPanic) t.enterPanic({ target: { x: impactor.x, y: impactor.y }, reason: "\u9ad8\u901f\u3067\u3076\u3064\u304b\u3063\u3066\u6df7\u4e71\u3057\u3066\u3044\u308b", fear: 0.75, wake: true, cause: "tarinai_highspeed_collision" }); - else t.setActionState?.("panic", { target: { x: impactor.x, y: impactor.y }, reason: "\u9ad8\u901f\u3067\u3076\u3064\u304b\u3063\u3066\u6df7\u4e71\u3057\u3066\u3044\u308b", wake: true }); - t.hurtTimer = Math.max(t.hurtTimer || 0, 0.9 + damage * 0.035); - t.fallTimer = Math.max(t.fallTimer || 0, 0.36 + damage * 0.025); - t.fallMax = Math.max(t.fallMax || 0, t.fallTimer); - t.fallDir = nx >= 0 ? 1 : -1; - t.fearTimer = Math.max(t.fearTimer || 0, 0.55); - } - this.effects.push(new Effect("ring", (a.x + b.x) * 0.5, (a.y + b.y) * 0.5, { size: Math.max(16, hitDistance * 0.45), life: 0.20, color: "rgba(210,75,65,0.40)" })); - if (this.relationNotice(target.id, impactor.id, "tarinai-highspeed-collision", 2.6)) this.log(`${target.name}\u306f\u9ad8\u901f\u306e${impactor.name}\u306b\u885d\u7a81\u3057\u3066\u5f3e\u304d\u98db\u3070\u3055\u308c\u305f\u3002`, "accident", { participants: [target, impactor] }); + const score = (1 - u) * 4 + Math.max(0, hitDistance - d) * 0.12 + Math.min(5, relSpeed / 120); + candidates.push({ a, b, key, relSpeed, u, cx, cy, d, hitDistance, score }); + if (candidates.length >= maxCandidates) break; } + if (candidates.length >= maxCandidates) break; } + candidates.sort((p, q) => (p.u - q.u) || (q.score - p.score) || String(p.key).localeCompare(String(q.key))); + const maxImpacts = Math.max(8, Math.min(64, Number(this.maxTarinaiCollisionImpacts || 32) || 32)); + let solved = 0; + for (const c of candidates) { + if (solved >= maxImpacts) break; + const { a, b, key, relSpeed, hitDistance } = c; + if (!a || !b || a.dead || b.dead) continue; + if (now - (this.tarinaiCollisionMemo.get(key) || -999) < 0.42) continue; + this.tarinaiCollisionMemo.set(key, now); + solved += 1; + const as = Math.hypot(a.vx || 0, a.vy || 0); + const bs = Math.hypot(b.vx || 0, b.vy || 0); + const impactor = as >= bs ? a : b; + const target = impactor === a ? b : a; + const ivx = impactor.vx || 0; + const ivy = impactor.vy || 0; + const impactSpeed = Math.hypot(ivx, ivy) || relSpeed || 1; + let nx = impactSpeed > 0.001 ? ivx / impactSpeed : 0; + let ny = impactSpeed > 0.001 ? ivy / impactSpeed : 0; + if (!Number.isFinite(nx) || !Number.isFinite(ny) || Math.abs(nx) + Math.abs(ny) < 0.001) { + const dx = target.x - impactor.x; + const dy = target.y - impactor.y; + const dd = Math.hypot(dx, dy); + if (dd > 0.001) { nx = dx / dd; ny = dy / dd; } + else { + const angle = (typeof stableUnit === "function" ? stableUnit(key, "tarinai-collision-normal") : 0.5) * Math.PI * 2; + nx = Math.cos(angle); + ny = Math.sin(angle); + } + } + const overlap = Math.max(0, hitDistance - Math.hypot((a.x || 0) - (b.x || 0), (a.y || 0) - (b.y || 0))); + if (overlap > 0.5) { + const sep = Math.min(18, overlap * 0.45 + 0.8); + impactor.x = clamp((impactor.x || 0) - nx * sep * 0.45, CONFIG.worldPadding, this.w - CONFIG.worldPadding); + impactor.y = clamp((impactor.y || 0) - ny * sep * 0.45, CONFIG.worldPadding, this.h - CONFIG.worldPadding); + target.x = clamp((target.x || 0) + nx * sep * 0.55, CONFIG.worldPadding, this.w - CONFIG.worldPadding); + target.y = clamp((target.y || 0) + ny * sep * 0.55, CONFIG.worldPadding, this.h - CONFIG.worldPadding); + this.markSpatialDirty?.("tarinai-highspeed-separation"); + } + const damage = physicalDamageFromImpactSpeed(relSpeed, { min: 1.2, max: 22, scale: 26 }); + this.applyImpulse(target, ivx * 0.20 + nx * 28, ivy * 0.20 + ny * 28, { + panic: true, + target: { x: impactor.x, y: impactor.y }, + fearTimer: 0.55, + thought: "高速でぶつかって混乱している", + }); + 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, "衝突"); + 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" }); + else t.setActionState?.("panic", { target: { x: impactor.x, y: impactor.y }, reason: "高速でぶつかって混乱している", wake: true }); + t.hurtTimer = Math.max(t.hurtTimer || 0, 0.9 + damage * 0.035); + t.fallTimer = Math.max(t.fallTimer || 0, 0.36 + damage * 0.025); + t.fallMax = Math.max(t.fallMax || 0, t.fallTimer); + t.fallDir = nx >= 0 ? 1 : -1; + t.fearTimer = Math.max(t.fearTimer || 0, 0.55); + } + this.effects.push(new Effect("ring", (a.x + b.x) * 0.5, (a.y + b.y) * 0.5, { size: Math.max(16, hitDistance * 0.45), life: 0.20, color: "rgba(210,75,65,0.40)" })); + if (this.relationNotice(target.id, impactor.id, "tarinai-highspeed-collision", 2.6)) this.log(`${target.name}は高速の${impactor.name}に衝突して弾き飛ばされた。`, "accident", { participants: [target, impactor] }); + } + if (solved && this.spatialDirty) this.rebuildSpatial?.(true, "post-tarinai-highspeed-collision"); } })); })(typeof window !== "undefined" ? window : globalThis); diff --git a/js/world_environment.js b/js/world_environment.js index 244ad49..2c79e91 100644 --- a/js/world_environment.js +++ b/js/world_environment.js @@ -28,10 +28,60 @@ return { left: cx - ex, right: cx + ex, top: cy - ey, bottom: cy + ey, cos: c, sin: s }; } + function obstacleQueryDistanceLocal(x, y, it) { + if (!it) return Infinity; + const type = String(it.type || ""); + let reach = Math.max(32, Number(it.r || it.radius || 32) || 32); + if (type === "fence" || type === "fence_v" || type === "bounce_fence" || type === "bounce_fence_v" || type === "gate_fence") reach = Math.max(120, reach * 3.9); + else if (type === "nest_box") reach = Math.max(92, reach * 1.9); + else reach = global.TarinaiMechanicalSystem?.reach?.(it) || Math.max(60, reach * 1.6); + return Math.max(0, distXY(x, y, Number(it.x) || 0, Number(it.y) || 0) - reach); + } + + function visitNearestObstaclesLocal(x, y, candidates, limit, visitor, opts = {}) { + if (!candidates || typeof visitor !== "function") return 0; + const source = candidates; + const n = Number(source.length || 0) || 0; + if (n <= 0) return 0; + const keepMax = Math.max(1, Math.min(n, Math.max(limit + 6, limit * 3))); + const kept = []; + const dists = []; + const include = opts.include || null; + const exclude = opts.exclude || null; + const stamp = opts.stamp; + + // Hot path: candidate counts are usually small. Keep a bounded sorted + // prefix without allocating intermediate Array.from()/sort() output. For + // large candidate sets, insertion is limited to the top keepMax entries. + for (let i = 0; i < n; i++) { + const it = source[i]; + if (!it || it === exclude || it.dead || it._obstacleRectQueryStamp === stamp) continue; + if (include && !include(it)) continue; + const d = obstacleQueryDistanceLocal(x, y, it); + if (kept.length >= keepMax && d >= dists[dists.length - 1]) continue; + let pos = kept.length; + while (pos > 0 && d < dists[pos - 1]) pos -= 1; + if (pos >= keepMax) continue; + kept.splice(pos, 0, it); + dists.splice(pos, 0, d); + if (kept.length > keepMax) { + kept.length = keepMax; + dists.length = keepMax; + } + } + + let visited = 0; + for (let i = 0; i < kept.length; i++) { + visited += 1; + if (visitor(kept[i]) === true) break; + } + return visited; + } + function sanitizeRotatorSegmentsLocal(item) { - const raw = Array.isArray(item?.rotatorSegments) ? item.rotatorSegments : []; + const raw = global.TarinaiPhysicsBodySystem?.segments?.(item) || []; const out = []; const clampCoord = (v) => Math.max(-420, Math.min(420, Number(v) || 0)); for (const seg of raw) { @@ -53,7 +103,7 @@ for (const seg of sanitizeRotatorSegmentsLocal(item)) { maxD = Math.max(maxD, Math.hypot(seg[0], seg[1]), Math.hypot(seg[2], seg[3])); } - return Math.min(460, maxD + Math.max(8, Number(item?.rotatorThickness || 12) || 12) + 8); + return Math.min(460, maxD + Math.max(8, global.TarinaiPhysicsBodySystem?.scalar?.(item, "thickness", 12) || 12) + 8); } function rotatorWorldSegmentsLocal(item) { @@ -74,7 +124,7 @@ const len = Math.max(4, Math.hypot(x2 - x1, y2 - y1)); const angle = Math.atan2(y2 - y1, x2 - x1); const halfW = len / 2; - const halfH = Math.max(4, Math.min(34, Number(item?.rotatorThickness || 12) || 12)) / 2; + const halfH = Math.max(4, Math.min(34, global.TarinaiPhysicsBodySystem?.scalar?.(item, "thickness", 12) || 12)) / 2; const cx = (x1 + x2) / 2; const cy = (y1 + y2) / 2; const aabb = orientedRectAabb(cx, cy, halfW, halfH, angle); @@ -82,7 +132,7 @@ left: aabb.left, right: aabb.right, top: aabb.top, bottom: aabb.bottom, cx, cy, halfW, halfH, angle, cos: aabb.cos, sin: aabb.sin, oriented: true, type: "rotator", item, rotator: true, restitution: 0.72, - angularVelocity: (item?.rotatorPowered === false ? 0 : (Number(item?.rotatorSpeed || 0) || 0)) + (Number(item?.rotatorAngularVelocity || 0) || 0), + angularVelocity: (global.TarinaiPhysicsBodySystem?.scalar?.(item, "motorOn", true) === false ? 0 : (Number(global.TarinaiPhysicsBodySystem?.scalar?.(item, "motorSpeed", 0) || 0) || 0)) + (Number(global.TarinaiPhysicsBodySystem?.scalar?.(item, "spin", 0) || 0) || 0), centerX: Number(item?.x) || 0, centerY: Number(item?.y) || 0, }; } @@ -96,7 +146,7 @@ function reciprocatorAxisLocal(item) { const fallback = angleForItemLocal(item); - const angle = typeof normalizedItemAngle === "function" ? normalizedItemAngle(item?.reciprocatorAxisAngle, fallback) : (Number.isFinite(Number(item?.reciprocatorAxisAngle)) ? Number(item.reciprocatorAxisAngle) : fallback); + const angle = typeof normalizedItemAngle === "function" ? normalizedItemAngle(global.TarinaiPhysicsBodySystem?.scalar?.(item, "railAxis", fallback), fallback) : (global.TarinaiPhysicsBodySystem?.scalar?.(item, "railAxis", fallback) || fallback); return { x: Math.cos(angle), y: Math.sin(angle), angle }; } @@ -104,16 +154,16 @@ const len = Math.max(4, Math.hypot(x2 - x1, y2 - y1)); const segAngle = Math.atan2(y2 - y1, x2 - x1); const halfW = len / 2; - const halfH = Math.max(4, Math.min(34, Number(item?.rotatorThickness || 12) || 12)) / 2; + const halfH = Math.max(4, Math.min(34, global.TarinaiPhysicsBodySystem?.scalar?.(item, "thickness", 12) || 12)) / 2; const cx = (x1 + x2) / 2; const cy = (y1 + y2) / 2; const aabb = orientedRectAabb(cx, cy, halfW, halfH, segAngle); const axis = reciprocatorAxisLocal(item); const itemX = Number(item?.x || 0) || 0; const itemY = Number(item?.y || 0) || 0; - const reciprocatorDirection = Math.sign(Number(item?.reciprocatorDirection || 1) || 1) || 1; - const motorVelocity = item?.reciprocatorPowered === false ? 0 : reciprocatorDirection * Math.max(0, Number(item?.reciprocatorSpeed || 0) || 0); - const totalVelocity = motorVelocity + (Number(item?.reciprocatorVelocity || 0) || 0); + const railDirection = Math.sign(Number(global.TarinaiPhysicsBodySystem?.scalar?.(item, "railDir", 1) || 1) || 1) || 1; + const motorVelocity = global.TarinaiPhysicsBodySystem?.scalar?.(item, "railOn", true) === false ? 0 : railDirection * Math.max(0, Number(global.TarinaiPhysicsBodySystem?.scalar?.(item, "railMotorSpeed", 0) || 0) || 0); + const totalVelocity = motorVelocity + (Number(global.TarinaiPhysicsBodySystem?.scalar?.(item, "slideSpeed", 0) || 0) || 0); const vx = axis.x * totalVelocity; const vy = axis.y * totalVelocity; return { @@ -145,6 +195,74 @@ return { x: nx * c - ny * s, y: nx * s + ny * c }; } + + + function segmentAabbHitLocal(x1, y1, x2, y2, left, top, right, bottom) { + let t0 = 0; + let t1 = 1; + const dx = x2 - x1; + const dy = y2 - y1; + const clip = (p, q) => { + if (Math.abs(p) < 1e-9) return q >= 0; + const t = q / p; + if (p < 0) { if (t > t1) return false; if (t > t0) t0 = t; } + else { if (t < t0) return false; if (t < t1) t1 = t; } + return true; + }; + if (!clip(-dx, x1 - left)) return null; + if (!clip(dx, right - x1)) return null; + if (!clip(-dy, y1 - top)) return null; + if (!clip(dy, bottom - y1)) return null; + if (t1 < 0 || t0 > 1) return null; + const t = clamp(t0, 0, 1); + const hx = x1 + dx * t; + const hy = y1 + dy * t; + const dl = Math.abs(hx - left); + const dr = Math.abs(right - hx); + const dt = Math.abs(hy - top); + const db = Math.abs(bottom - hy); + const m = Math.min(dl, dr, dt, db); + let nx = 0, ny = 0; + if (m === dl) nx = -1; + else if (m === dr) nx = 1; + else if (m === dt) ny = -1; + else ny = 1; + return { t, nx, ny }; + } + + function sweptCircleRectHitLocal(x1, y1, x2, y2, radius, r, padding = 0) { + if (!r) return null; + const rr = Math.max(0, Number(radius || 0) || 0) + Math.max(0, Number(padding || 0) || 0); + if (r.oriented) { + const a = rectLocalPoint(r, x1, y1); + const b = rectLocalPoint(r, x2, y2); + const hit = segmentAabbHitLocal(a.x, a.y, b.x, b.y, -(r.halfW || 0) - rr, -(r.halfH || 0) - rr, (r.halfW || 0) + rr, (r.halfH || 0) + rr); + if (!hit) return null; + const n = rectWorldNormal(r, hit.nx, hit.ny); + return { t: hit.t, nx: n.x, ny: n.y, rect: r }; + } + const hit = segmentAabbHitLocal(x1, y1, x2, y2, (r.left || 0) - rr, (r.top || 0) - rr, (r.right || 0) + rr, (r.bottom || 0) + rr); + return hit ? { t: hit.t, nx: hit.nx, ny: hit.ny, rect: r } : null; + } + + function firstSweptCircleObstacleHitLocal(worldRef, t, x1, y1, x2, y2, radius, opts = {}) { + if (!worldRef?.nearbySolidObstacleRects) return null; + const midX = (x1 + x2) * 0.5; + const midY = (y1 + y2) * 0.5; + const travel = Math.hypot(x2 - x1, y2 - y1); + const searchRadius = Math.max(Number(opts.searchRadius || 0) || 0, radius + travel * 0.5 + 180); + const maxChecks = Math.max(4, Number(opts.maxChecks || 0) || 48); + let best = null; + for (const rect of worldRef.nearbySolidObstacleRects(midX, midY, searchRadius, { maxChecks })) { + if (rect?.type === "nest_box" && worldRef.shouldIgnoreNestBoxCollisionFor?.(t, rect.item)) continue; + const hit = sweptCircleRectHitLocal(x1, y1, x2, y2, radius, rect, 0.5); + if (!hit) continue; + if (!best || hit.t < best.t) best = hit; + } + return best; + } + + function segmentIntersectsOrientedRectLocal(x1, y1, x2, y2, r, padding = 0) { const a = rectLocalPoint(r, x1, y1); const b = rectLocalPoint(r, x2, y2); @@ -296,7 +414,7 @@ }, - rotatorSegments(it) { + mechanicalSegments(it) { return global.TarinaiMechanicalSystem?.sanitizeSegments?.(it) || sanitizeRotatorSegmentsLocal(it); }, @@ -359,33 +477,77 @@ }, nearbySolidObstacleRects(x, y, radius, { include = null, exclude = null, maxChecks = CONFIG.maxFenceCollisionChecks ?? 24 } = {}) { + const limit = Math.max(1, Number(maxChecks || 0) || 24); + const cacheable = !include && !exclude; + const stats = this.nearbyQueryStatsThisFrame || null; + const q = 48; + const qx = Math.floor((Number(x) || 0) / q); + const qy = Math.floor((Number(y) || 0) / q); + const qcx = (qx + 0.5) * q; + 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 cacheKey = cacheable + ? `${this.spatialVersion || 0}:${this.spatialDirtyMarksTotal || 0}:${qx},${qy},${qr},${cacheLimit}` + : ""; + if (cacheable) { + if (!this._solidObstacleRectQueryCache) this._solidObstacleRectQueryCache = new Map(); + const cached = this._solidObstacleRectQueryCache.get(cacheKey); + if (cached) { + if (stats) stats.obstacleRectHits = (stats.obstacleRectHits || 0) + 1; + return cached; + } + } else if (stats) { + stats.obstacleRectBypass = (stats.obstacleRectBypass || 0) + 1; + } + + const queryX = cacheable ? qcx : x; + const queryY = cacheable ? qcy : y; + const queryRadius = cacheable ? qr * q : radius; + const queryLimit = cacheable ? cacheLimit : limit; const rects = []; - const seen = new Set(); + const stamp = (this._obstacleRectQueryStamp = (this._obstacleRectQueryStamp || 0) + 1); let checked = 0; const addRectsFor = (it) => { - if (!it || it === exclude || it.dead || seen.has(it)) return false; + if (!it || it === exclude || it.dead || it._obstacleRectQueryStamp === stamp) return false; if (include && !include(it)) return false; const partRects = this.solidObstacleRects(it); if (!partRects.length) return false; - seen.add(it); + it._obstacleRectQueryStamp = stamp; checked += 1; for (const rect of partRects) rects.push(rect); return true; }; - for (const it of this.nearbyItems(x, y, radius)) { + const candidates = this.nearbyObstacles?.(queryX, queryY, queryRadius, false) || this.nearbyItems?.(queryX, queryY, queryRadius, false) || []; + visitNearestObstaclesLocal(queryX, queryY, candidates, queryLimit, (it) => { addRectsFor(it); - if (checked >= maxChecks) break; - } - // Large custom rotators can extend well beyond their center cell; scan them - // explicitly so their blades keep colliding even when the hub is far away. - if (checked < maxChecks) { - for (const it of this.items || []) { - if (!it || !global.TarinaiMechanicalSystem?.isMechanicalType?.(it.type) || it.dead || seen.has(it)) continue; - const reach = global.TarinaiMechanicalSystem?.reach?.(it) || Math.max(60, it.r || 64); - if (distXY(x, y, it.x, it.y) > radius + reach + 36) continue; - addRectsFor(it); - if (checked >= maxChecks) break; + return checked >= queryLimit; + }, { include, exclude, stamp }); + if (checked < queryLimit && !this.nearbyObstacles) { + const mech = global.TarinaiMechanicalSystem; + const fallback = []; + for (const type of ["rotator", "poison_block", "reciprocator"]) { + for (const it of this.itemsOfType?.(type) || []) { + if (!it || it.dead || it._obstacleRectQueryStamp === stamp) continue; + const reach = mech?.reach?.(it) || Math.max(60, it.r || 64); + if (distXY(queryX, queryY, it.x, it.y) > queryRadius + reach + 36) continue; + fallback.push(it); + } } + visitNearestObstaclesLocal(queryX, queryY, fallback, queryLimit - checked, (it) => { + addRectsFor(it); + return checked >= queryLimit; + }, { include, exclude, stamp }); + } + if (stats) { + stats.obstacleRectMisses = (stats.obstacleRectMisses || 0) + 1; + stats.obstacleRectBuilt = (stats.obstacleRectBuilt || 0) + rects.length; + } + if (cacheable) { + const cache = this._solidObstacleRectQueryCache || (this._solidObstacleRectQueryCache = new Map()); + if (cache.size > 160) cache.clear(); + cache.set(cacheKey, rects); } return rects; }, @@ -394,7 +556,101 @@ return global.TarinaiCollisionFootprints?.pointInRect?.(x, y, r, padding) ?? false; }, - pushTarinaiOutOfRect(t, r, rr) { + + nearbyPoisonBlockHazardRects(x, y, radius, { maxChecks = 24 } = {}) { + if ((this.itemCounts?.poison_block || 0) <= 0) return []; + const rects = []; + const stamp = (this._poisonHazardQueryStamp = (this._poisonHazardQueryStamp || 0) + 1); + let checked = 0; + const addRectsFor = (it) => { + if (!it || it.dead || it.type !== "poison_block" || it._poisonHazardQueryStamp === stamp) return false; + const reach = global.TarinaiMechanicalSystem?.reach?.(it) || Math.max(60, it.r || 64); + if (distXY(x, y, it.x, it.y) > radius + reach + 36) return false; + const partRects = global.TarinaiMechanicalSystem?.poisonHazardRects?.(it) || []; + if (!partRects.length) return false; + it._poisonHazardQueryStamp = stamp; + checked += 1; + for (const rect of partRects) rects.push(rect); + return true; + }; + const candidates = this.nearbyHazards?.(x, y, radius + 48, false) || []; + for (const it of candidates) { + addRectsFor(it); + if (checked >= maxChecks) break; + } + if (checked < maxChecks && !this.nearbyHazards) { + for (const it of this.itemsOfType?.("poison_block") || []) { + if (addRectsFor(it) && checked >= maxChecks) break; + } + } + return rects; + }, + + circleOverlapsObstacleRect(x, y, radius, r, padding = 0) { + if (!r) return false; + const rr = Math.max(0, Number(radius || 0) || 0) + Math.max(0, Number(padding || 0) || 0); + if (r.oriented) { + const local = rectLocalPoint(r, x, y); + const clx = clamp(local.x, -(r.halfW || 0), r.halfW || 0); + const cly = clamp(local.y, -(r.halfH || 0), r.halfH || 0); + return Math.hypot(local.x - clx, local.y - cly) < rr; + } + const cx = clamp(x, r.left, r.right); + const cy = clamp(y, r.top, r.bottom); + return Math.hypot(x - cx, y - cy) < rr; + }, + + applyPoisonBlockContactDamage(t, rect, rr) { + if (!t || t.dead || !rect?.poisonBlock || !rect.item || rect.item.dead) return false; + if (!this.circleOverlapsObstacleRect(t.x, t.y, rr, rect, 1.0)) return false; + const now = this.time || 0; + const key = rect.item.id || "poison"; + t._poisonBlockHitAt = t._poisonBlockHitAt || Object.create(null); + const body = global.TarinaiPhysicsBodySystem?.ensureBody?.(rect.item, this, { syncFromLegacy: false }); + const solid = body?.collision ? body.collision.solid !== false : true; + const cooldown = solid ? 0.30 : 0.42; + if ((t._poisonBlockHitAt[key] || -999) + cooldown > now) return false; + t._poisonBlockHitAt[key] = now; + const velocity = body?.velocity || {}; + const vx = Number((velocity.x ?? rect.item.vx) || 0) || 0; + const vy = Number((velocity.y ?? rect.item.vy) || 0) || 0; + const omega = Number((velocity.angular ?? global.TarinaiPhysicsBodySystem?.scalar?.(rect.item, "spin", 0)) || 0) || 0; + const speed = Math.hypot(vx, vy) + Math.abs(omega) * Math.max(28, rect.item.r || 64) * 0.35; + const base = Math.max(1, Number((body?.hazard?.damage ?? global.TarinaiPhysicsBodySystem?.scalar?.(rect.item, "damage", 7)) || 7) || 7); + const damage = clamp(base * (solid ? 1.0 : 0.72) + speed * 0.018, 2.0, 18.0); + if (typeof this.applyImpactDamage === "function") this.applyImpactDamage(t, damage, "毒ブロック", { source: rect.item, x: t.x, y: t.y }); + else if (typeof t.damage === "function") t.damage(damage, "毒ブロック"); + // HPバーは描画側で「ダメージ中」の表示条件を見るため、毒ブロックでも明示的に傷み状態を立てる。 + t.showHpBar?.(4.8); + t.hurtTimer = Math.max(t.hurtTimer || 0, 0.82); + t.pokeFlashTimer = Math.max(t.pokeFlashTimer || 0, 0.28); + t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.24); + if (typeof t.bubble === "function" && now >= (t.lastPoisonBlockBubbleAt || -999) + 0.85) { + t.lastPoisonBlockBubbleAt = now; + t.bubble("!", 0.48, "rgba(94,166,76,0.78)"); + } + if (now >= (t.lastPoisonBlockEffectAt || -999) + 0.10) { + t.lastPoisonBlockEffectAt = now; + this.effects?.push(new Effect("ring", t.x, t.y, { size: Math.max(16, rr * 1.05), life: 0.18, color: "rgba(92,188,68,0.45)" })); + } + return true; + }, + + resolvePoisonBlockContactDamage(t, rr) { + if (!t || t.dead || this.isTarinaiHiddenInNestBox(t)) return false; + if ((this.itemCounts?.poison_block || 0) <= 0) return false; + const now = this.time || 0; + const interval = Math.max(0.045, Math.min(0.10, Number(CONFIG?.poisonBlockProbeInterval || 0.075) || 0.075)); + if ((t._nextPoisonBlockProbeAt || 0) > now) return false; + t._nextPoisonBlockProbeAt = now + interval; + let hit = false; + for (const rect of this.nearbyPoisonBlockHazardRects(t.x, t.y, rr + 150, { maxChecks: 32 })) { + hit = this.applyPoisonBlockContactDamage(t, rect, rr) || hit; + } + return hit; + }, + + pushTarinaiOutOfRect(t, r, rr, opts = {}) { if (!t || !r) return false; const preVx = Number(t.vx || 0) || 0; const preVy = Number(t.vy || 0) || 0; @@ -444,9 +700,12 @@ nx = dx / d; ny = dy / d; } + const slop = Number.isFinite(opts.slop) ? opts.slop : 0.8; + const defaultMaxPush = r.oriented && insidePenetration > 0 ? Math.max(18, rr * 2.8) : Math.max(10, rr * 0.92); + const maxPush = Math.max(1, Number.isFinite(opts.maxPush) ? opts.maxPush : defaultMaxPush); const push = r.oriented && insidePenetration > 0 - ? Math.min(insidePenetration + rr + 0.8, Math.max(18, rr * 2.8)) - : Math.min(rr - d + 0.8, Math.max(10, rr * 0.92)); + ? Math.min(insidePenetration + rr + slop, maxPush) + : Math.min(rr - d + slop, maxPush); t.x += nx * push; t.y += ny * push; if (r.bounce) { @@ -486,50 +745,122 @@ } } else if (r.mechanical) { global.TarinaiMechanicalSystem?.applySurfaceVelocityToCircle?.(t, r, nx, ny, preVx, preVy, { - damping: r.rotator ? 0.78 : 0.80, - surfaceScale: r.rotator ? 0.52 : 0.64, - normalBoost: r.rotator ? 42 : 36, - maxSpeed: r.rotator ? 680 : 620, - impulseVScale: r.rotator ? 0.10 : 0.08, - impulseMax: r.rotator ? 360 : 320, + damping: r.rotator ? 0.78 : (r.poisonBlock ? 0.74 : 0.80), + surfaceScale: r.rotator ? 0.52 : (r.poisonBlock ? 0.38 : 0.64), + normalBoost: r.rotator ? 42 : (r.poisonBlock ? 30 : 36), + maxSpeed: r.rotator ? 680 : (r.poisonBlock ? 560 : 620), + impulseVScale: r.rotator ? 0.10 : (r.poisonBlock ? 0.05 : 0.08), + impulseMax: r.rotator ? 360 : (r.poisonBlock ? 260 : 320), passiveImpulseScale: 0.82, spinKick: 0, }); t.surpriseTimer = Math.max(t.surpriseTimer || 0, r.rotator ? 0.12 : 0.10); - const field = r.rotator ? "lastRotatorHitEffectAt" : "lastReciprocatorHitEffectAt"; + const field = r.rotator ? "lastRotatorHitEffectAt" : (r.poisonBlock ? "lastPoisonBlockPushEffectAt" : "lastReciprocatorHitEffectAt"); if ((this.time || 0) >= (t[field] || -999) + 0.10) { t[field] = this.time || 0; - this.effects?.push(new Effect("ring", t.x, t.y, { size: Math.max(14, rr * 0.95), life: 0.16, color: r.rotator ? "rgba(175,116,230,0.42)" : "rgba(90,150,214,0.40)" })); + this.effects?.push(new Effect("ring", t.x, t.y, { size: Math.max(14, rr * 0.95), life: 0.16, color: r.rotator ? "rgba(175,116,230,0.42)" : (r.poisonBlock ? "rgba(92,188,68,0.42)" : "rgba(90,150,214,0.40)") })); } } else if (Math.abs(dx) > Math.abs(dy)) t.vx *= -0.18; else t.vy *= -0.18; return true; }, - resolveSolidObstacleCollision(t) { - if (!t || t.dead || this.isTarinaiHiddenInNestBox(t)) return; - const rr = Math.max(8, t.radius * 0.74); + moveTarinaiWithCollision(t, dx, dy, opts = {}) { + if (!t || t.dead) return false; + const moveX = Number(dx || 0) || 0; + const moveY = Number(dy || 0) || 0; + const dist = Math.hypot(moveX, moveY); + if (dist <= 0.0001) return this.resolveSolidObstacleCollision(t, { maxPasses: 2, reason: opts.reason || "tarinai-stationary" }); + if (this.isTarinaiHiddenInNestBox?.(t)) { + t.x += moveX; + t.y += moveY; + return false; + } + const rr = Math.max(8, (Number(t.radius) || 22) * 0.74); + const maxStep = Math.max(5, Math.min(12, Number(opts.maxStep || rr * 0.48) || 8)); + const steps = Math.max(1, Math.min(48, Math.ceil(dist / maxStep))); + const stepX = moveX / steps; + const stepY = moveY / steps; + let collided = false; + for (let i = 0; i < steps; i++) { + const sx = t.x; + const sy = t.y; + const ex = sx + stepX; + const ey = sy + stepY; + const hit = firstSweptCircleObstacleHitLocal(this, t, sx, sy, ex, ey, rr, { + searchRadius: Math.max(rr + 110, rr + Math.hypot(stepX, stepY) + 170), + maxChecks: Math.max(opts.light ? 10 : 32, Number(opts.maxChecks || 0) || CONFIG.maxFenceCollisionChecks || 24), + }); + if (hit && hit.t <= 1) { + const safeT = Math.max(0, hit.t - 0.035); + t.x = sx + stepX * safeT; + t.y = sy + stepY * safeT; + const toward = (t.vx || 0) * hit.nx + (t.vy || 0) * hit.ny; + if (toward < 0) { + t.vx -= toward * hit.nx; + t.vy -= toward * hit.ny; + } + collided = this.resolveSolidObstacleCollision(t, { + maxPasses: 3, + searchRadius: Math.max(rr + 110, rr + Math.hypot(stepX, stepY) + 170), + maxChecks: Math.max(opts.light ? 10 : 32, Number(opts.maxChecks || 0) || CONFIG.maxFenceCollisionChecks || 24), + slop: 0.30, + reason: opts.reason || "tarinai-swept-hit", + }) || true; + continue; + } + t.x = ex; + t.y = ey; + const passHit = this.resolveSolidObstacleCollision(t, { + maxPasses: 2, + searchRadius: Math.max(rr + 96, rr + Math.hypot(stepX, stepY) + 140), + maxChecks: Math.max(opts.light ? 10 : 28, Number(opts.maxChecks || 0) || CONFIG.maxFenceCollisionChecks || 24), + slop: 0.35, + reason: opts.reason || "tarinai-swept-move", + }); + collided = passHit || collided; + } + if (collided) { + t.lastSolidObstacleCollisionAt = this.time || 0; + this.markSpatialDirty?.("tarinai-moved-collision"); + } + return collided; + }, + + resolveSolidObstacleCollision(t, opts = {}) { + if (!t || t.dead || this.isTarinaiHiddenInNestBox(t)) return false; + const rr = Math.max(8, (Number(t.radius) || 22) * 0.74); + this.resolvePoisonBlockContactDamage(t, rr); let pushed = false; let bounced = false; - for (const rect of this.nearbySolidObstacleRects(t.x, t.y, rr + 150)) { - if (rect?.type === "nest_box" && this.shouldIgnoreNestBoxCollisionFor(t, rect.item)) continue; - if (this.pushTarinaiOutOfRect(t, rect, rr)) { - pushed = true; - if (rect?.bounce) bounced = true; + const maxPasses = Math.max(1, Math.min(4, Number(opts.maxPasses ?? 2) || 2)); + const searchRadius = Math.max(rr + 40, Number(opts.searchRadius || rr + 150) || (rr + 150)); + const maxChecks = Math.max(1, Number(opts.maxChecks || CONFIG.maxFenceCollisionChecks || 24) || 24); + for (let pass = 0; pass < maxPasses; pass++) { + let passPushed = false; + for (const rect of this.nearbySolidObstacleRects(t.x, t.y, searchRadius, { maxChecks })) { + if (rect?.type === "nest_box" && this.shouldIgnoreNestBoxCollisionFor(t, rect.item)) continue; + if (this.pushTarinaiOutOfRect(t, rect, rr, opts)) { + pushed = true; + passPushed = true; + if (rect?.bounce) bounced = true; + } } + if (!passPushed) break; } if (pushed) { - const pad = CONFIG.worldPadding + Math.max(2, t.radius * 0.18); + const pad = CONFIG.worldPadding + Math.max(2, (Number(t.radius) || 22) * 0.18); t.x = clamp(t.x, pad, this.w - pad); t.y = clamp(t.y, pad, this.h - pad); const maxV = bounced ? 520 : 130; t.vx = clamp(t.vx || 0, -maxV, maxV); t.vy = clamp(t.vy || 0, -maxV, maxV); } + return pushed; }, resolveFenceCollision(t) { - this.resolveSolidObstacleCollision(t); + return this.resolveSolidObstacleCollision(t, { maxPasses: 2, reason: "resolve-fence" }); }, segmentIntersectsRect(x1, y1, x2, y2, r) { diff --git a/js/world_family_social.js b/js/world_family_social.js index cac1708..cd5aa81 100644 --- a/js/world_family_social.js +++ b/js/world_family_social.js @@ -1,4 +1,34 @@ "use strict"; +(function ensureDeterministicHelpers(global) { + if (typeof global.deterministicChance === "function" && typeof global.deterministicRange === "function") return; + const clamp01 = v => Math.max(0, Math.min(1, Number(v) || 0)); + const keyPart = value => { + if (value == null) return "null"; + if (typeof value === "number") return Number.isFinite(value) ? value.toFixed(3) : "nan"; + if (typeof value === "string" || typeof value === "boolean") return String(value); + if (typeof value === "object") { + const id = value.familyKey || value.id || value.seed || value.liveToken || value.name; + if (id) return `${value.type || value.constructor?.name || "entity"}:${id}`; + const x = Number.isFinite(value.x) ? value.x.toFixed(1) : "x"; + const y = Number.isFinite(value.y) ? value.y.toFixed(1) : "y"; + return `${value.type || value.constructor?.name || "entity"}@${x},${y}`; + } + return String(value); + }; + const hashUnit = (seed, salt = "") => { + const str = `${seed}:${salt}`; + let h = 2166136261; + for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); } + return ((h >>> 0) % 100000) / 100000; + }; + global.deterministicFrame = global.deterministicFrame || ((worldRef = null, hz = 60) => Math.floor((Number(worldRef?.time || 0) || 0) * Math.max(1, Number(hz) || 60) + 1e-6)); + global.deterministicUnit = global.deterministicUnit || ((worldRef = null, salt = "", ...parts) => hashUnit(String(worldRef?.worldSeed || "tarinai-world"), [salt, global.deterministicFrame(worldRef, 60), Number(worldRef?._deterministicEpoch || 0) || 0, ...parts.map(keyPart)].join("|"))); + global.deterministicRange = global.deterministicRange || ((worldRef = null, salt = "", min = 0, max = 1, ...parts) => (Number(min) || 0) + global.deterministicUnit(worldRef, salt, ...parts) * ((Number(max) || 0) - (Number(min) || 0))); + global.deterministicSigned = global.deterministicSigned || ((worldRef = null, salt = "", ...parts) => global.deterministicUnit(worldRef, salt, ...parts) < 0.5 ? -1 : 1); + global.deterministicChance = global.deterministicChance || ((worldRef = null, salt = "", chance = 0, ...parts) => { const p = clamp01(chance); return p >= 1 || (p > 0 && global.deterministicUnit(worldRef, salt, ...parts) < p); }); + global.deterministicAngle = global.deterministicAngle || ((worldRef = null, salt = "", ...parts) => global.deterministicRange(worldRef, salt, 0, Math.PI * 2, ...parts)); +})(typeof window !== "undefined" ? window : globalThis); + (function (global) { const World = global.World; @@ -539,6 +569,61 @@ return true; }, + cancelBirthRitual(actor, options = {}) { + const source = actor || null; + const partnerId = source?.birthPartnerId || null; + const partner = partnerId ? this.liveTarinaiById?.(partnerId) : null; + const participants = []; + if (source) participants.push(source); + if (partner && partner !== source) participants.push(partner); + if (!participants.length) return false; + const reason = options.reason || "繁殖が中断された"; + let canceled = false; + for (const t of participants) { + if (!t || t.dead) continue; + if ((t.birthRitualTimer || 0) <= 0.04 && t.state !== "birth_ritual") continue; + t.birthRitualTimer = 0; + t.birthRitualMax = 0; + t.birthPartnerId = null; + t.birthRitualLeader = false; + t.birthRitualRole = 0; + t.postBirthPeaceTimer = Math.max(t.postBirthPeaceTimer || 0, 1.4); + t.behaviorLockTimer = Math.max(t.behaviorLockTimer || 0, 0.25); + if (typeof clearTarinaiBehavior === "function") clearTarinaiBehavior(t, { reason }); + if (typeof clearForcedBehaviorQueue === "function") clearForcedBehaviorQueue(t, e => e && (e.id === "approach_mate" || e.id === "birth_ritual")); + if (options.panic && t === source) { + if (t.enterPanic) t.enterPanic({ target: options.target || null, reason, fear: options.fear ?? 0.95, wake: true, cause: options.cause || "birth_ritual_interrupted" }); + else t.setActionState?.("panic", { target: options.target || null, reason, wake: true }); + t.fearTimer = Math.max(t.fearTimer || 0, options.fear ?? 0.95); + } else { + t.goIdle?.(reason); + t.thought = reason; + } + canceled = true; + } + if (canceled && !options.silentLog && this.time - (this.lastBirthCancelLogAt || -999) > 1.2) { + this.log?.(reason, "birth", { participants }); + this.lastBirthCancelLogAt = this.time || 0; + } + return canceled; + }, + + cancelBirthRitualOnForce(tarinai, force = 0, options = {}) { + if (!tarinai || tarinai.dead) return false; + if ((tarinai.birthRitualTimer || 0) <= 0.04 && tarinai.state !== "birth_ritual") return false; + const amount = Number(force) || 0; + const threshold = Number(options.threshold || 180) || 180; + if (amount < threshold) return false; + return this.cancelBirthRitual(tarinai, { + reason: options.reason || "強い衝撃で繁殖が中断された", + target: options.target || null, + fear: options.fear ?? 1.0, + cause: options.cause || "external_force", + panic: options.panic !== false, + silentLog: !!options.silentLog, + }); + }, + finishBirthRitual(a, b) { if (!a || !b || a.dead || b.dead) return null; const reproductionBlockedByDisease = a.sleepDisease || b.sleepDisease || a.fightDisease || b.fightDisease; @@ -725,10 +810,10 @@ const actor = aDrug && !bDrug ? a : (!aDrug && bDrug ? b : (a.energy + a.stress * 0.36 >= b.energy + b.stress * 0.36 ? a : b)); const target = actor === a ? b : a; if (!this.canFightPair(actor, target)) return false; - actor.intimidateTimer = Math.max(actor.intimidateTimer || 0, rand(0.85, 1.35)); + actor.intimidateTimer = Math.max(actor.intimidateTimer || 0, deterministicRange(this, "fight-mochi-intimidate-actor-timer", 0.85, 1.35, actor, target)); actor.intimidateTargetId = target.id; actor.surpriseTimer = Math.max(actor.surpriseTimer || 0, 0.28); - target.intimidatedTimer = Math.max(target.intimidatedTimer || 0, rand(0.75, 1.25)); + target.intimidatedTimer = Math.max(target.intimidatedTimer || 0, deterministicRange(this, "fight-mochi-intimidate-target-timer", 0.75, 1.25, actor, target)); target.fearTimer = Math.max(target.fearTimer || 0, 0.72 * target.personalityProfile().fear); actor.adjustRelation(target, -0.05, 0.06, "intimidate"); target.adjustRelation(actor, -0.12, 0.30 * target.personalityProfile().fear, "intimidate"); @@ -751,8 +836,8 @@ if (a.fightTimer > 0.04 || b.fightTimer > 0.04 || a.intimidateTimer > 0.04 || b.intimidateTimer > 0.04) return false; const aAggressiveIntent = a.shouldApplyPersonalityBehavior?.("aggression", 1) ? 1 : 0; const bAggressiveIntent = b.shouldApplyPersonalityBehavior?.("aggression", 1) ? 1 : 0; - const scoreA = a.energy * 0.38 + a.mood * 0.18 + a.stress * 0.12 + (a.type === "angry" ? 12 : 0) + aAggressiveIntent * 8 + ((typeof b.relationTo === "function" ? (b.relationTo(a.id).fear || 0) : 0) * 0.65) + (a.isZunchiSlave ? -10 : 0) + ((a.fightMochiTimer || 0) > 0.04 ? 9 : 0) + rand(-5, 5); - const scoreB = b.energy * 0.38 + b.mood * 0.18 + b.stress * 0.12 + (b.type === "angry" ? 12 : 0) + bAggressiveIntent * 8 + ((typeof a.relationTo === "function" ? (a.relationTo(b.id).fear || 0) : 0) * 0.65) + (b.isZunchiSlave ? -10 : 0) + ((b.fightMochiTimer || 0) > 0.04 ? 9 : 0) + rand(-5, 5); + const scoreA = a.energy * 0.38 + a.mood * 0.18 + a.stress * 0.12 + (a.type === "angry" ? 12 : 0) + aAggressiveIntent * 8 + ((typeof b.relationTo === "function" ? (b.relationTo(a.id).fear || 0) : 0) * 0.65) + (a.isZunchiSlave ? -10 : 0) + ((a.fightMochiTimer || 0) > 0.04 ? 9 : 0) + deterministicRange(this, "conflict-score-a", -5, 5, a, b); + const scoreB = b.energy * 0.38 + b.mood * 0.18 + b.stress * 0.12 + (b.type === "angry" ? 12 : 0) + bAggressiveIntent * 8 + ((typeof a.relationTo === "function" ? (a.relationTo(b.id).fear || 0) : 0) * 0.65) + (b.isZunchiSlave ? -10 : 0) + ((b.fightMochiTimer || 0) > 0.04 ? 9 : 0) + deterministicRange(this, "conflict-score-b", -5, 5, a, b); const actor = scoreA >= scoreB ? a : b; const target = actor === a ? b : a; if (!this.canFightPair(actor, target)) return false; @@ -761,7 +846,7 @@ const canIntimidate = !actor.lowHealthSprite || !actor.lowHealthSprite(); const battleDrug = (actor.fightMochiTimer || 0) > 0.04 || (target.fightMochiTimer || 0) > 0.04; const intimidationChance = clamp(0.40 + (battleDrug ? 0.24 : 0) + (target.isZunchiSlave ? 0.18 : 0), 0.24, 0.90); - if (canIntimidate && Math.random() < intimidationChance) { + if (canIntimidate && deterministicChance(this, "conflict-intimidation-choice", intimidationChance, actor, target)) { return this.startIntimidation(actor, target, actorScore, targetScore); } return this.startFight(actor, target, { initiator: actor }); @@ -780,13 +865,13 @@ const targetSize = target.effectiveScale ? target.effectiveScale() : (target.scale || 0.28); const sizeEdge = clamp((actorSize - targetSize) / 0.16, -1, 1); const aggressionEdge = clamp(actorAggression - targetAggression * 0.55, -1, 1); - const resistance = target.energy * 0.30 + target.mood * 0.16 + targetAggression * 8 + (target.shouldApplyPersonalityBehavior?.("aggression", 1) ? 5 : 0) + rand(-5, 5); + const resistance = target.energy * 0.30 + target.mood * 0.16 + targetAggression * 8 + (target.shouldApplyPersonalityBehavior?.("aggression", 1) ? 5 : 0) + deterministicRange(this, "intimidation-resistance", -5, 5, actor, target); const successChance = clamp(0.56 + (actorScore - targetScore + fearLoad - resistance) / 82 + aggressionEdge * 0.13 + sizeEdge * 0.16, 0.30, 0.96); actor.surpriseTimer = Math.max(actor.surpriseTimer, 0.20); target.surpriseTimer = Math.max(target.surpriseTimer, 0.32); - actor.fightCooldown = CONFIG.fightCooldown + rand(1, 4); - target.fightCooldown = CONFIG.fightCooldown + rand(1, 4); - actor.intimidateTimer = Math.max(actor.intimidateTimer, rand(1.05, 1.65)); + actor.fightCooldown = CONFIG.fightCooldown + deterministicRange(this, "intimidation-actor-cooldown", 1, 4, actor, target); + target.fightCooldown = CONFIG.fightCooldown + deterministicRange(this, "intimidation-target-cooldown", 1, 4, actor, target); + actor.intimidateTimer = Math.max(actor.intimidateTimer, deterministicRange(this, "intimidation-actor-timer", 1.05, 1.65, actor, target)); actor.intimidateTargetId = target.id; actor.setActionState("intimidate", { target, reason: "\u76f8\u624b\u3092\u5a01\u5687\u3057\u3066\u3044\u308b" }); if (typeof setBehaviorText === "function") setBehaviorText(actor, { need: "social", subNeed: "conflict", actionId: "intimidate_enemy", actionLabel: "\u5a01\u5687\u3057\u3066\u3044\u308b", reasonText: `${target.name || "\u76f8\u624b"}\u3092\u5a01\u5687\u3057\u3066\u3044\u308b`, target, phase: "perform", source: "behavior" }); @@ -796,9 +881,9 @@ color: "rgba(145, 106, 55, 0.70)", })); this.spawnBubble(actor.x, actor.y - actor.radius * 1.30, "\u306f\u3046\u30fc\uff01", "rgba(92,62,34,0.82)"); - target.intimidatedTimer = Math.max(target.intimidatedTimer || 0, rand(0.95, 1.55)); + target.intimidatedTimer = Math.max(target.intimidatedTimer || 0, deterministicRange(this, "intimidation-target-prep-timer", 0.95, 1.55, actor, target)); target.fearTimer = Math.max(target.fearTimer, 0.65 * target.personalityProfile().fear); - if (Math.random() >= successChance) { + if (!deterministicChance(this, "intimidation-success", successChance, actor, target)) { actor.adjustPersonality?.("aggression", -0.018, "after failed intimidation"); target.adjustRelation(actor, -0.08, 0.22 * target.personalityProfile().fear, "intimidate"); actor.intimidateTimer = 0; @@ -814,13 +899,13 @@ actor.adjustPersonality?.("aggression", 0.018, "after successful intimidation"); target.defeatedById = actor.id; target.fightWinnerId = actor.id; - target.defeatedTimer = Math.max(target.defeatedTimer, rand(1.8, 2.8)); - target.intimidatedTimer = Math.max(target.intimidatedTimer || 0, rand(1.2, 2.0)); + target.defeatedTimer = Math.max(target.defeatedTimer, deterministicRange(this, "intimidation-defeated-timer", 1.8, 2.8, actor, target)); + target.intimidatedTimer = Math.max(target.intimidatedTimer || 0, deterministicRange(this, "intimidation-success-timer", 1.2, 2.0, actor, target)); if (target.enterPanic) target.enterPanic({ threat: actor, target: actor, reason: "\u5a01\u5687\u3055\u308c\u3066\u9003\u3052\u3066\u3044\u308b", fear: 1.35, wake: true, cause: "intimidated" }); else { target.fearTimer = Math.max(target.fearTimer, 1.35 * target.personalityProfile().fear); target.setActionState?.("panic", { target: actor, reason: "\u5a01\u5687\u3055\u308c\u3066\u9003\u3052\u3066\u3044\u308b", wake: true }); } this.spawnBubble(target.x, target.y - target.radius * 1.28, "\u306f\u3041\u3041\u3041\uff01", "rgba(84,66,86,0.80)"); - target.vx += (target.x < actor.x ? -1 : 1) * rand(26, 54); - target.vy += rand(-18, 18); + target.vx += (target.x < actor.x ? -1 : 1) * deterministicRange(this, "intimidation-knockback-x", 26, 54, actor, target); + target.vy += deterministicRange(this, "intimidation-knockback-y", -18, 18, actor, target); actor.adjustRelation(target, -0.08, 0.06, "intimidate"); target.adjustRelation(actor, -0.22, 0.85 * target.personalityProfile().fear, "intimidate"); if (this.time - Math.max(actor.lastLog, target.lastLog) > 5.5) { @@ -862,12 +947,12 @@ setBehaviorText(a, { need: "social", subNeed: "conflict", actionId: "fight_rival", actionLabel: a === receiver ? "\u53cd\u6483\u3057\u3066\u3044\u308b" : "\u55a7\u5629\u3057\u3066\u3044\u308b", reasonText: reasonA, causeText: causeFor(a, b), target: b, phase: "perform", source: "behavior" }); setBehaviorText(b, { need: "social", subNeed: "conflict", actionId: "fight_rival", actionLabel: b === receiver ? "\u53cd\u6483\u3057\u3066\u3044\u308b" : "\u55a7\u5629\u3057\u3066\u3044\u308b", reasonText: reasonB, causeText: causeFor(b, a), target: a, phase: "perform", source: "behavior" }); } - a.fightTimer = Math.max(a.fightTimer, rand(3.2, 4.8)); - b.fightTimer = Math.max(b.fightTimer, rand(3.2, 4.8)); - a.nextHeadbutt = this.time + rand(0.08, 0.18); + a.fightTimer = Math.max(a.fightTimer, deterministicRange(this, "fight-start-timer-a", 3.2, 4.8, a, b)); + b.fightTimer = Math.max(b.fightTimer, deterministicRange(this, "fight-start-timer-b", 3.2, 4.8, a, b)); + a.nextHeadbutt = this.time + deterministicRange(this, "fight-start-headbutt", 0.08, 0.18, a, b); b.nextHeadbutt = a.nextHeadbutt; - a.fightCooldown = CONFIG.fightCooldown + rand(1, 5); - b.fightCooldown = CONFIG.fightCooldown + rand(1, 5); + a.fightCooldown = CONFIG.fightCooldown + deterministicRange(this, "fight-start-cooldown-a", 1, 5, a, b); + b.fightCooldown = CONFIG.fightCooldown + deterministicRange(this, "fight-start-cooldown-b", 1, 5, a, b); if (!a.fightTargetIds) a.fightTargetIds = []; if (!b.fightTargetIds) b.fightTargetIds = []; if (!a.fightTargetIds.includes(b.id)) a.fightTargetIds.push(b.id); @@ -876,22 +961,24 @@ b.fightTargetIds = b.fightTargetIds.slice(-4); a.fightTargetId = a.fightTargetIds[0]; b.fightTargetId = b.fightTargetIds[0]; - if (a.addStress) a.addStress(rand(8, 18), { threshold: 8 }); - if (b.addStress) b.addStress(rand(8, 18), { threshold: 8 }); - const damageToA = b.outgoingDamage ? b.outgoingDamage(rand(2, 7)) : rand(2, 7); - const damageToB = a.outgoingDamage ? a.outgoingDamage(rand(2, 7)) : rand(2, 7); + if (a.addStress) a.addStress(deterministicRange(this, "fight-start-stress-a", 8, 18, a, b), { threshold: 8 }); + if (b.addStress) b.addStress(deterministicRange(this, "fight-start-stress-b", 8, 18, a, b), { threshold: 8 }); + const rawDamageToA = deterministicRange(this, "fight-pulse-damage-a", 2, 7, a, b); + const rawDamageToB = deterministicRange(this, "fight-pulse-damage-b", 2, 7, a, b); + const damageToA = b.outgoingDamage ? b.outgoingDamage(rawDamageToA) : rawDamageToA; + const damageToB = a.outgoingDamage ? a.outgoingDamage(rawDamageToB) : rawDamageToB; a.damage(damageToA, "\u55a7\u5629"); b.damage(damageToB, "\u55a7\u5629"); this.maybeDefeatFromFightDamage?.(a, b, damageToA); this.maybeDefeatFromFightDamage?.(b, a, damageToB); const dx = b.x - a.x, dy = b.y - a.y; const d = Math.hypot(dx, dy) || 1; - a.vx -= dx / d * rand(32, 62); a.vy -= dy / d * rand(32, 62); - b.vx += dx / d * rand(32, 62); b.vy += dy / d * rand(32, 62); + a.vx -= dx / d * deterministicRange(this, "fight-pulse-knockback-ax", 32, 62, a, b); a.vy -= dy / d * deterministicRange(this, "fight-pulse-knockback-ay", 32, 62, a, b); + b.vx += dx / d * deterministicRange(this, "fight-pulse-knockback-bx", 32, 62, a, b); b.vy += dy / d * deterministicRange(this, "fight-pulse-knockback-by", 32, 62, a, b); a.surpriseTimer = 0.24; b.surpriseTimer = 0.24; this.effects.push(new Effect("fight", (a.x + b.x) / 2, (a.y + b.y) / 2 - 8, { - size: rand(12, 20), - life: rand(0.32, 0.52), + size: deterministicRange(this, "fight-pulse-effect-size", 12, 20, a, b), + life: deterministicRange(this, "fight-pulse-effect-life", 0.32, 0.52, a, b), color: "rgba(116, 73, 38, 0.82)", })); audio.fight(); diff --git a/js/world_placement_log.js b/js/world_placement_log.js index ce97607..4f4a77c 100644 --- a/js/world_placement_log.js +++ b/js/world_placement_log.js @@ -161,9 +161,10 @@
- - - + + + +
- - + + - +