diff --git a/src/features/retro-office/RetroOffice3D.tsx b/src/features/retro-office/RetroOffice3D.tsx index 54adcec..eb275b0 100644 --- a/src/features/retro-office/RetroOffice3D.tsx +++ b/src/features/retro-office/RetroOffice3D.tsx @@ -219,6 +219,7 @@ import { PingPongBall as ScenePingPongBall, SpotlightEffect as SceneSpotlightEffect, } from "@/features/retro-office/systems/sceneRuntime"; +import { applyAgentCollisionBumps } from "@/features/retro-office/systems/NavigationSystem"; import { HeatmapSystem as AgentHeatmapSystem, TrailSystem as AgentTrailSystem, @@ -2172,106 +2173,12 @@ function useAgentTick( // Collision bump — when agents overlap, stop them briefly and reroute them // in different directions without the old hard shove. - const collisionCellSize = AGENT_RADIUS * 4; - const collisionBuckets = new Map(); - for (let index = 0; index < moved.length; index += 1) { - const agent = moved[index]; - if ("role" in agent && agent.role === "janitor") continue; - const bucketKey = `${Math.floor(agent.x / collisionCellSize)}:${Math.floor( - agent.y / collisionCellSize, - )}`; - const bucket = collisionBuckets.get(bucketKey); - if (bucket) bucket.push(index); - else collisionBuckets.set(bucketKey, [index]); - } + const movedWithCollisions = applyAgentCollisionBumps({ agents: moved, now }); - for (let i = 0; i < moved.length; i++) { - const mi = moved[i]; - if ("role" in mi && mi.role === "janitor") continue; - if ( - moved[i].state === "sitting" || - moved[i].state === "working_out" || - moved[i].state === "dancing" - ) - continue; - if (moved[i].pingPongUntil !== undefined && moved[i].state !== "walking") - continue; - if (moved[i].bumpedUntil !== undefined) continue; - if ((moved[i].collisionCooldownUntil ?? 0) > now) continue; - let sx = 0, - sy = 0, - fx = 0, - fy = 0; - const bucketX = Math.floor(mi.x / collisionCellSize); - const bucketY = Math.floor(mi.y / collisionCellSize); - for (let offsetY = -1; offsetY <= 1; offsetY += 1) { - for (let offsetX = -1; offsetX <= 1; offsetX += 1) { - const bucket = collisionBuckets.get( - `${bucketX + offsetX}:${bucketY + offsetY}`, - ); - if (!bucket) continue; - for (const j of bucket) { - if (i === j) continue; - const mj = moved[j]; - if ("role" in mj && mj.role === "janitor") continue; - let ddx = moved[i].x - moved[j].x; - let ddy = moved[i].y - moved[j].y; - const d = Math.hypot(ddx, ddy); - const minDist = AGENT_RADIUS * 2; - if (d < minDist) { - // d=0 edge case: exact overlap — use a random direction to break symmetry. - if (d === 0) { - ddx = Math.random() - 0.5; - ddy = Math.random() - 0.5; - } - const effD = Math.max(d, 0.01); - const effNorm = Math.hypot(ddx, ddy) || 1; - const push = (1 - effD / minDist) * SEPARATION_STRENGTH; - sx += (ddx / effNorm) * push; - sy += (ddy / effNorm) * push; - fx += (-ddx / effNorm) * push; - fy += (-ddy / effNorm) * push; - } - } - } - } - if (sx === 0 && sy === 0) continue; - const pushMag = Math.hypot(sx, sy); - const norm = pushMag || 1; - // Pick the roam point most aligned with the push direction as the escape target. - let bestDot = -Infinity; - const roamCandidates = isRemoteOfficeAgentId(moved[i].id) - ? REMOTE_ROAM_POINTS - : ROAM_POINTS; - let escapeTarget = roamCandidates[0]; - for (const rp of roamCandidates) { - const rdx = rp.x - moved[i].x, - rdy = rp.y - moved[i].y; - const rdist = Math.hypot(rdx, rdy) || 1; - const dot = (rdx / rdist) * (sx / norm) + (rdy / rdist) * (sy / norm); - if (dot > bestDot) { - bestDot = dot; - escapeTarget = rp; - } - } - moved[i] = { - ...moved[i], - // Face the other agent during the pause so the bump reads like a brief chat. - facing: Math.atan2(fx || sx, fy || sy), - // Freeze legs and store the escape target — the tick's bump handler will - // route here when the timer expires. - state: "standing", - path: [], - targetX: escapeTarget.x, - targetY: escapeTarget.y, - bumpedUntil: now + BUMP_FREEZE_MS, - bumpTalkUntil: now + BUMP_FREEZE_MS, - }; - } - renderAgentsRef.current = moved; + renderAgentsRef.current = movedWithCollisions; const renderAgentLookup = renderAgentLookupRef.current; renderAgentLookup.clear(); - for (const agent of moved) { + for (const agent of movedWithCollisions) { renderAgentLookup.set(agent.id, agent); } }; diff --git a/src/features/retro-office/systems/NavigationSystem.tsx b/src/features/retro-office/systems/NavigationSystem.tsx new file mode 100644 index 0000000..14b8cc5 --- /dev/null +++ b/src/features/retro-office/systems/NavigationSystem.tsx @@ -0,0 +1,116 @@ +import { + AGENT_RADIUS, + BUMP_FREEZE_MS, + SEPARATION_STRENGTH, +} from "@/features/retro-office/core/constants"; +import { + isRemoteOfficeAgentId, + REMOTE_ROAM_POINTS, +} from "@/features/retro-office/core/district"; +import { ROAM_POINTS } from "@/features/retro-office/core/navigation"; +import type { RenderAgent } from "@/features/retro-office/core/types"; + +type ApplyAgentCollisionBumpsArgs = { + agents: RenderAgent[]; + now: number; +}; + +export function applyAgentCollisionBumps({ + agents, + now, +}: ApplyAgentCollisionBumpsArgs): RenderAgent[] { + const moved = [...agents]; + const collisionCellSize = AGENT_RADIUS * 4; + const collisionBuckets = new Map(); + for (let index = 0; index < moved.length; index += 1) { + const agent = moved[index]; + if ("role" in agent && agent.role === "janitor") continue; + const bucketKey = `${Math.floor(agent.x / collisionCellSize)}:${Math.floor( + agent.y / collisionCellSize, + )}`; + const bucket = collisionBuckets.get(bucketKey); + if (bucket) bucket.push(index); + else collisionBuckets.set(bucketKey, [index]); + } + + for (let i = 0; i < moved.length; i += 1) { + const mi = moved[i]; + if ("role" in mi && mi.role === "janitor") continue; + if ( + moved[i].state === "sitting" || + moved[i].state === "working_out" || + moved[i].state === "dancing" + ) + continue; + if (moved[i].pingPongUntil !== undefined && moved[i].state !== "walking") + continue; + if (moved[i].bumpedUntil !== undefined) continue; + if ((moved[i].collisionCooldownUntil ?? 0) > now) continue; + let sx = 0, + sy = 0, + fx = 0, + fy = 0; + const bucketX = Math.floor(mi.x / collisionCellSize); + const bucketY = Math.floor(mi.y / collisionCellSize); + for (let offsetY = -1; offsetY <= 1; offsetY += 1) { + for (let offsetX = -1; offsetX <= 1; offsetX += 1) { + const bucket = collisionBuckets.get( + `${bucketX + offsetX}:${bucketY + offsetY}`, + ); + if (!bucket) continue; + for (const j of bucket) { + if (i === j) continue; + const mj = moved[j]; + if ("role" in mj && mj.role === "janitor") continue; + let ddx = moved[i].x - moved[j].x; + let ddy = moved[i].y - moved[j].y; + const d = Math.hypot(ddx, ddy); + const minDist = AGENT_RADIUS * 2; + if (d < minDist) { + if (d === 0) { + ddx = Math.random() - 0.5; + ddy = Math.random() - 0.5; + } + const effD = Math.max(d, 0.01); + const effNorm = Math.hypot(ddx, ddy) || 1; + const push = (1 - effD / minDist) * SEPARATION_STRENGTH; + sx += (ddx / effNorm) * push; + sy += (ddy / effNorm) * push; + fx += (-ddx / effNorm) * push; + fy += (-ddy / effNorm) * push; + } + } + } + } + if (sx === 0 && sy === 0) continue; + const pushMag = Math.hypot(sx, sy); + const norm = pushMag || 1; + let bestDot = -Infinity; + const roamCandidates = isRemoteOfficeAgentId(moved[i].id) + ? REMOTE_ROAM_POINTS + : ROAM_POINTS; + let escapeTarget = roamCandidates[0]; + for (const rp of roamCandidates) { + const rdx = rp.x - moved[i].x, + rdy = rp.y - moved[i].y; + const rdist = Math.hypot(rdx, rdy) || 1; + const dot = (rdx / rdist) * (sx / norm) + (rdy / rdist) * (sy / norm); + if (dot > bestDot) { + bestDot = dot; + escapeTarget = rp; + } + } + moved[i] = { + ...moved[i], + facing: Math.atan2(fx || sx, fy || sy), + state: "standing", + path: [], + targetX: escapeTarget.x, + targetY: escapeTarget.y, + bumpedUntil: now + BUMP_FREEZE_MS, + bumpTalkUntil: now + BUMP_FREEZE_MS, + }; + } + + return moved; +}