mirror of
https://github.com/iamlukethedev/Claw3D.git
synced 2026-08-14 00:58:04 +00:00
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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user