Office life: everyone roams, meetings convene in pairs, card follows agent

- The detail card now rides beside the clicked agent (flipping and
  clamping at viewport edges via a pure cardPositionFor helper) instead
  of pinning to the top-right corner; it follows a walking agent
- Every agent - the CEO and busy agents included - periodically visits
  the coffee machine, snack bar, and restroom; work status no longer
  recalls a walker mid-break
- The office convenes meetings around the meeting table every few
  minutes: at least two settled attendees walk over, dwell together on
  distinct table spots, and head back; a solo office never meets
- Meeting spots ship as lane-wired amenities so pathing stays correct
  by construction; floor-plan invariants (collider-free, reachable)
  cover them automatically
- e2e asserts the card opens beside the sprite, not in a corner
This commit is contained in:
ZacharyZhang-NY committed 2026-07-08 21:40:53 -04:00
1 parent e55b8b85b0
commit c3924af9ac
10 files changed
+206 -36

No files matched your search

+19 -1
View File
@@ -4,6 +4,7 @@ import { activeOrgId, requireActiveEmployee } from "./types";
import { hashString, renderOffice, type RenderPerson } from "./office/office-renderer";
import { OfficeSimulation, cameraFor, type MoveInput } from "./office/simulation";
import { AgentDetailCard } from "./office/AgentDetailCard";
import { cardPositionFor, CARD_FALLBACK_H } from "./office/agent-card-position";
import type { AgentActivity, Camera, OfficeAgentInput, Vec } from "./office/office-types";
const FPS_INTERVAL = 1000 / 24;
@@ -26,6 +27,7 @@ export function OfficePage({ snapshot }: PageProps) {
const keysRef = useRef<MoveInput>({ up: false, down: false, left: false, right: false });
const cameraRef = useRef<Camera>({ x: 0, y: 0 });
const selectedIdRef = useRef<string | null>(null);
const cardBoxRef = useRef<HTMLDivElement>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
const simRef = useRef<OfficeSimulation | null>(null);
if (simRef.current === null) simRef.current = new OfficeSimulation();
@@ -164,6 +166,18 @@ export function OfficePage({ snapshot }: PageProps) {
});
}
renderOffice(ctx, viewW, viewH, sim.plan, camera, people, now, new Date(), selectedIdRef.current);
// The detail card rides beside its agent, even while they walk.
const cardBox = cardBoxRef.current;
const selected = selectedIdRef.current
? sim.agents.find((agent) => agent.input.id === selectedIdRef.current) ?? null
: null;
if (cardBox && selected) {
const cardH = cardBox.offsetHeight || CARD_FALLBACK_H;
const position = cardPositionFor({ x: selected.x, y: selected.y }, camera, viewW, viewH, cardH);
cardBox.style.transform = `translate(${position.left}px, ${position.top}px)`;
cardBox.style.visibility = "visible";
}
};
raf = requestAnimationFrame(tick);
@@ -205,7 +219,11 @@ export function OfficePage({ snapshot }: PageProps) {
onMouseMove={onCanvasMouseMove}
style={{ width: "100%", height: "100%", display: "block" }}
/>
{selectedAgent && <AgentDetailCard agent={selectedAgent} onClose={() => setSelectedId(null)} />}
{selectedAgent && (
<div ref={cardBoxRef} style={{ position: "absolute", left: 0, top: 0, willChange: "transform", visibility: "hidden" }}>
<AgentDetailCard agent={selectedAgent} onClose={() => setSelectedId(null)} />
</div>
)}
</div>
);
}
@@ -17,16 +17,16 @@ const ACTIVITY_TONE: Record<AgentActivity, "green" | "violet" | "amber" | undefi
idle: undefined,
};
/** Floating profile card for the agent selected on the office floor. */
/**
* Floating profile card for the agent selected on the office floor. The page
* owns its placement (it follows the sprite); the card only sizes itself.
*/
export function AgentDetailCard({ agent, onClose }: { agent: OfficeAgentInput; onClose: () => void }) {
return (
<div
role="dialog"
aria-label={t("Agent {name}", { name: agent.name })}
style={{
position: "absolute",
top: 16,
right: 16,
width: 264,
background: "var(--panel)",
border: "1px solid var(--border-strong)",
@@ -0,0 +1,34 @@
import type { Camera, Vec } from "./office-types";
export const CARD_W = 264;
export const CARD_FALLBACK_H = 170;
const GAP = 18;
const MARGIN = 8;
/** Vertical center of a drawn sprite relative to its anchor point. */
const SPRITE_CENTER_Y = -16;
/**
* Screen position for the detail card so it sits beside the clicked agent:
* to the right of the sprite, flipping to the left when the right edge would
* leave the viewport, and always clamped fully on screen. Pure, so tests can
* assert placement with the same math the page uses.
*/
export function cardPositionFor(
agent: Vec,
camera: Camera,
viewW: number,
viewH: number,
cardH: number = CARD_FALLBACK_H,
): { left: number; top: number } {
const screenX = agent.x - camera.x;
const screenY = agent.y - camera.y + SPRITE_CENTER_Y;
let left = screenX + GAP;
if (left + CARD_W > viewW - MARGIN) left = screenX - CARD_W - GAP;
left = clamp(left, MARGIN, Math.max(MARGIN, viewW - CARD_W - MARGIN));
const top = clamp(screenY - cardH / 2, MARGIN, Math.max(MARGIN, viewH - cardH - MARGIN));
return { left, top };
}
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
@@ -58,6 +58,12 @@ const AMENITIES: AmenitySpot[] = [
{ kind: "restroom", room: "restroom", stand: { x: 1084, y: 104 } },
{ kind: "restroom", room: "restroom", stand: { x: 1124, y: 104 } },
{ kind: "restroom", room: "restroom", stand: { x: 1164, y: 104 } },
// Standing spots along the south edge of the meeting table; meetings are
// group events the simulation convenes with at least two attendees.
{ kind: "meeting", room: "meeting", stand: { x: 412, y: 198 } },
{ kind: "meeting", room: "meeting", stand: { x: 452, y: 198 } },
{ kind: "meeting", room: "meeting", stand: { x: 492, y: 198 } },
{ kind: "meeting", room: "meeting", stand: { x: 532, y: 198 } },
];
interface Segment {
@@ -30,7 +30,7 @@ export interface DeskSlot {
seat: Vec;
}
export type AmenityKind = "coffee" | "snack" | "restroom";
export type AmenityKind = "coffee" | "snack" | "restroom" | "meeting";
export interface AmenitySpot {
kind: AmenityKind;
+45 -19
View File
@@ -44,12 +44,17 @@ const PLAYER_SPEED = 0.14;
const AGENT_SPEED = 0.085;
const MAX_STEP_MS = 100;
const WALK_CYCLE = 0.02;
/** An idle agent wanders off roughly every minute and a half. */
/** Everyone wanders off roughly every minute and a half, working or not. */
const MEAN_TRIP_INTERVAL_MS = 90_000;
/** Nobody leaves their desk in the first moments after settling in. */
const SETTLE_MS = 20_000;
const DWELL_MIN_MS = 6_000;
const DWELL_SPREAD_MS = 8_000;
/** Meetings run longer than a snack stop so attendees visibly overlap. */
const MEETING_MIN_MS = 18_000;
const MEETING_SPREAD_MS = 12_000;
/** A meeting is convened somewhere in the office every few minutes. */
const MEAN_MEETING_INTERVAL_MS = 180_000;
const CARRY_MIN_MS = 20_000;
const CARRY_SPREAD_MS = 20_000;
@@ -93,14 +98,16 @@ export class OfficeSimulation {
}
/**
* Advances the world by one frame: board-member movement plus agent life -
* idle agents wander to the restroom, snack bar, or coffee machine, dwell,
* and carry their coffee back; working agents head straight to their desk.
* Advances the world by one frame: board-member movement plus agent life.
* Everyone — the CEO and working agents included — periodically wanders to
* the restroom, snack bar, or coffee machine, and every few minutes the
* office convenes a meeting of at least two attendees around the table.
*/
step(dtMs: number, input: MoveInput): void {
const dt = Math.min(dtMs, MAX_STEP_MS);
if (this.player) this.stepPlayer(this.player, dt, input);
for (const agent of this.agents) this.stepAgent(agent, dt);
if (this.random() < dt / MEAN_MEETING_INTERVAL_MS) this.conveneMeeting();
}
private stepAgent(agent: AgentSim, dt: number): void {
@@ -111,13 +118,8 @@ export class OfficeSimulation {
agent.carrying = null;
}
}
const idle = agent.input.activity === "idle";
switch (agent.behavior.kind) {
case "seated": {
if (!idle) {
agent.settledMs = 0;
return;
}
agent.settledMs += dt;
if (agent.settledMs >= SETTLE_MS && this.random() < dt / MEAN_TRIP_INTERVAL_MS) {
agent.settledMs = 0;
@@ -126,18 +128,10 @@ export class OfficeSimulation {
return;
}
case "walking": {
if (!idle && agent.behavior.destination === "amenity") {
this.sendHome(agent);
return;
}
this.walkAlong(agent, dt);
return;
}
case "dwelling": {
if (!idle) {
this.finishDwell(agent);
return;
}
agent.behavior.remainingMs -= dt;
if (agent.behavior.remainingMs <= 0) this.finishDwell(agent);
return;
@@ -146,7 +140,10 @@ export class OfficeSimulation {
}
private startTrip(agent: AgentSim): void {
const spot = this.pickAmenity();
this.walkTo(agent, this.pickAmenity());
}
private walkTo(agent: AgentSim, spot: AmenitySpot): void {
agent.behavior = {
kind: "walking",
path: findPath(this.plan, { x: agent.x, y: agent.y }, spot.stand),
@@ -156,6 +153,7 @@ export class OfficeSimulation {
};
}
/** Solo breaks pick a personal amenity; meetings are convened separately. */
private pickAmenity(): AmenitySpot {
const roll = this.random();
const kind = roll < 0.4 ? "coffee" : roll < 0.65 ? "snack" : "restroom";
@@ -163,6 +161,31 @@ export class OfficeSimulation {
return options[Math.floor(this.random() * options.length) % options.length];
}
/**
* Gathers a group around the meeting table. A meeting needs at least two
* settled attendees; with fewer, nobody goes to sit alone in the room.
* Returns the number of attendees who set off.
*/
conveneMeeting(): number {
const spots = this.plan.amenities.filter((spot) => spot.kind === "meeting");
const candidates = this.agents.filter(
(agent) => agent.behavior.kind === "seated" && agent.settledMs >= SETTLE_MS,
);
if (candidates.length < 2 || spots.length < 2) return 0;
const size = Math.min(spots.length, candidates.length, 2 + Math.floor(this.random() * 3));
const pool = [...candidates];
const chosen: AgentSim[] = [];
while (chosen.length < size && pool.length > 0) {
const index = Math.floor(this.random() * pool.length) % pool.length;
chosen.push(pool.splice(index, 1)[0]);
}
chosen.forEach((agent, position) => {
agent.settledMs = 0;
this.walkTo(agent, spots[position]);
});
return chosen.length;
}
private finishDwell(agent: AgentSim): void {
if (agent.behavior.kind === "dwelling") {
const kind = agent.behavior.spot.kind;
@@ -215,11 +238,14 @@ export class OfficeSimulation {
if (agent.behavior.kind !== "walking") return;
agent.walkPhase = 0;
if (agent.behavior.destination === "amenity" && agent.behavior.spot) {
const meeting = agent.behavior.spot.kind === "meeting";
agent.facing = "north";
agent.behavior = {
kind: "dwelling",
spot: agent.behavior.spot,
remainingMs: DWELL_MIN_MS + this.random() * DWELL_SPREAD_MS,
remainingMs: meeting
? MEETING_MIN_MS + this.random() * MEETING_SPREAD_MS
: DWELL_MIN_MS + this.random() * DWELL_SPREAD_MS,
};
return;
}