refactor: extract retro office collision navigation system (#102)

Co-authored-by: Luke The Dev <252071647+iamlukethedev@users.noreply.github.com>
This commit is contained in:
Gordo
2026-04-25 14:39:29 -05:00
committed by GitHub
co-authored by Luke The Dev
parent 51cd3a1e94
commit 06e2cbde94
2 changed files with 120 additions and 97 deletions
+4 -97
View File
@@ -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<string, number[]>();
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);
}
};
@@ -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<string, number[]>();
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;
}