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:
1 parent
e55b8b85b0
commit
c3924af9ac
10 files changed
+206
-36
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user