Rebuild the pixel office as a real floor plan with rooms and lanes
Replace the viewport-derived desk grid with a fixed-world office: a north band of rooms (CEO office, meeting room, snack bar, restroom) with door signage opening onto a corridor, and an open desk field that grows by rows so any head-count fits the world instead of being cut off. Geometry lives in a pure floor-plan module that also builds an axis-aligned lane graph with BFS routing, unit-tested for reachability and collision invariants. A camera transform centers or clamps the view, the first c_suite agent takes the CEO office, name labels render the full name, and the mock TODO-board text is gone. Office screenshots join the visual-review capture set.
This commit is contained in:
1 parent
f61046b7a4
commit
cab4e4ba33
8 files changed
+1098
-307
No files matched your search
@@ -1,56 +1,38 @@
|
||||
import { useEffect, useRef, useMemo } from "react";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import type { PageProps } from "./types";
|
||||
import { activeOrgId } from "./types";
|
||||
import { drawScene, layoutPositions, type OfficeAgent } from "./office/office-renderer";
|
||||
import { hashString, renderOffice, type RenderPerson } from "./office/office-renderer";
|
||||
import { OfficeSimulation, cameraFor } from "./office/simulation";
|
||||
import type { AgentActivity, OfficeAgentInput } from "./office/office-types";
|
||||
|
||||
const FACE = ["#4a90d9", "#50b866", "#9b59b6", "#e67e22", "#e74c3c", "#1abc9c", "#f39c12", "#3498db"];
|
||||
const SHIRT = ["#5b8dd9", "#d95b7a", "#5bbf6f", "#d9a65b", "#9b6fd9", "#5bc4c4", "#d97b5b", "#7fd95b"];
|
||||
|
||||
function hash(s: string): number {
|
||||
let h = 0;
|
||||
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0;
|
||||
return Math.abs(h);
|
||||
}
|
||||
const FPS_INTERVAL = 1000 / 24;
|
||||
|
||||
export function OfficePage({ snapshot }: PageProps) {
|
||||
const orgId = activeOrgId(snapshot);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const agentsRef = useRef<OfficeAgent[]>([]);
|
||||
const animRef = useRef(0);
|
||||
const lastFrameRef = useRef(0);
|
||||
const simRef = useRef<OfficeSimulation | null>(null);
|
||||
if (simRef.current === null) simRef.current = new OfficeSimulation();
|
||||
|
||||
const orgAgents = useMemo(() => snapshot.agents.filter((a) => a.orgId === orgId), [snapshot.agents, orgId]);
|
||||
const orgTasks = useMemo(() => snapshot.tasks.filter((t) => t.orgId === orgId), [snapshot.tasks, orgId]);
|
||||
const inputs = useMemo<OfficeAgentInput[]>(() => {
|
||||
const orgAgents = snapshot.agents
|
||||
.filter((agent) => agent.orgId === orgId)
|
||||
.sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
|
||||
const orgTasks = snapshot.tasks.filter((task) => task.orgId === orgId);
|
||||
return orgAgents.map((agent) => ({
|
||||
id: agent.id,
|
||||
name: agent.name,
|
||||
role: agent.role,
|
||||
title: agent.title,
|
||||
department: agent.department,
|
||||
roleLevel: agent.roleLevel,
|
||||
activity: resolveActivity(snapshot.chatPresence, snapshot.taskRuns, agent.id),
|
||||
taskTitle: orgTasks.find((task) => task.assigneeAgentId === agent.id && task.status === "in_progress")?.title ?? null,
|
||||
}));
|
||||
}, [snapshot.agents, snapshot.tasks, snapshot.chatPresence, snapshot.taskRuns, orgId]);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const w = canvas.clientWidth;
|
||||
const positions = layoutPositions(orgAgents.length, w);
|
||||
const existing = agentsRef.current;
|
||||
|
||||
agentsRef.current = orgAgents.map((a, i) => {
|
||||
const prev = existing.find((e) => e.id === a.id);
|
||||
const pos = positions[i];
|
||||
const status = resolveStatus(snapshot.chatPresence, snapshot.taskRuns, a.id);
|
||||
const task = orgTasks.find((t) => t.assigneeAgentId === a.id && t.status === "in_progress")?.title ?? null;
|
||||
return {
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
role: a.role,
|
||||
department: a.department,
|
||||
status,
|
||||
task,
|
||||
x: prev?.x ?? pos.x,
|
||||
y: prev?.y ?? pos.y,
|
||||
targetX: pos.x,
|
||||
targetY: pos.y,
|
||||
faceColor: FACE[hash(a.name) % FACE.length],
|
||||
shirtColor: SHIRT[hash(a.name + a.role) % SHIRT.length],
|
||||
frame: hash(a.id) % 100,
|
||||
};
|
||||
});
|
||||
}, [orgAgents, orgTasks, snapshot.chatPresence, snapshot.taskRuns]);
|
||||
simRef.current!.sync(inputs);
|
||||
}, [inputs]);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
@@ -59,67 +41,50 @@ export function OfficePage({ snapshot }: PageProps) {
|
||||
if (!ctx) return;
|
||||
|
||||
let running = true;
|
||||
const FPS_INTERVAL = 1000 / 24;
|
||||
let raf = 0;
|
||||
let lastFrame = 0;
|
||||
|
||||
const tick = (now: number) => {
|
||||
if (!running) return;
|
||||
raf = requestAnimationFrame(tick);
|
||||
if (now - lastFrame < FPS_INTERVAL) return;
|
||||
lastFrame = now;
|
||||
|
||||
const elapsed = now - lastFrameRef.current;
|
||||
if (elapsed < FPS_INTERVAL) {
|
||||
animRef.current = requestAnimationFrame(tick);
|
||||
return;
|
||||
}
|
||||
lastFrameRef.current = now;
|
||||
|
||||
const viewW = canvas.clientWidth;
|
||||
const viewH = canvas.clientHeight;
|
||||
if (viewW === 0 || viewH === 0) return;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const w = canvas.clientWidth;
|
||||
const h = canvas.clientHeight;
|
||||
if (w === 0 || h === 0) { animRef.current = requestAnimationFrame(tick); return; }
|
||||
const pw = Math.round(w * dpr);
|
||||
const ph = Math.round(h * dpr);
|
||||
if (canvas.width !== pw || canvas.height !== ph) { canvas.width = pw; canvas.height = ph; }
|
||||
const backingW = Math.round(viewW * dpr);
|
||||
const backingH = Math.round(viewH * dpr);
|
||||
if (canvas.width !== backingW || canvas.height !== backingH) {
|
||||
canvas.width = backingW;
|
||||
canvas.height = backingH;
|
||||
}
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
for (const agent of agentsRef.current) {
|
||||
const dx = agent.targetX - agent.x;
|
||||
const dy = agent.targetY - agent.y;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
if (dist > 1) {
|
||||
agent.x += (dx / dist) * 0.7;
|
||||
agent.y += (dy / dist) * 0.7;
|
||||
agent.frame += 0.08;
|
||||
}
|
||||
}
|
||||
|
||||
drawScene(ctx, w, h, agentsRef.current, now);
|
||||
animRef.current = requestAnimationFrame(tick);
|
||||
const sim = simRef.current!;
|
||||
const camera = cameraFor(sim.plan.world, viewW, viewH, {
|
||||
x: sim.plan.world.w / 2,
|
||||
y: viewH / 2,
|
||||
});
|
||||
const people: RenderPerson[] = sim.agents.map((agent) => ({
|
||||
id: agent.input.id,
|
||||
name: agent.input.name,
|
||||
activity: agent.input.activity,
|
||||
x: agent.x,
|
||||
y: agent.y,
|
||||
desk: agent.isCeo ? sim.plan.ceoDesk : sim.plan.desks[agent.deskIndex!].bounds,
|
||||
isCeo: agent.isCeo,
|
||||
animSeed: hashString(agent.input.id) % 100,
|
||||
}));
|
||||
renderOffice(ctx, viewW, viewH, sim.plan, camera, people, now);
|
||||
};
|
||||
|
||||
animRef.current = requestAnimationFrame(tick);
|
||||
return () => { running = false; cancelAnimationFrame(animRef.current); };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const w = canvas.clientWidth;
|
||||
const positions = layoutPositions(agentsRef.current.length, w);
|
||||
|
||||
const wander = setInterval(() => {
|
||||
agentsRef.current.forEach((agent, i) => {
|
||||
const pos = positions[i];
|
||||
if (!pos) return;
|
||||
if (agent.status === "idle") {
|
||||
agent.targetX = pos.x + ((hash(agent.id + String(Date.now()).slice(0, -3)) % 60) - 30);
|
||||
agent.targetY = pos.y + ((hash(agent.name + String(Date.now()).slice(0, -3)) % 30) - 15);
|
||||
} else {
|
||||
agent.targetX = pos.x;
|
||||
agent.targetY = pos.y;
|
||||
}
|
||||
});
|
||||
}, 5000);
|
||||
|
||||
return () => clearInterval(wander);
|
||||
raf = requestAnimationFrame(tick);
|
||||
return () => {
|
||||
running = false;
|
||||
cancelAnimationFrame(raf);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
@@ -129,15 +94,15 @@ export function OfficePage({ snapshot }: PageProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function resolveStatus(
|
||||
function resolveActivity(
|
||||
presence: Array<{ principalType: string; principalId: string; status: string }>,
|
||||
runs: Array<{ agentId: string | null; state: string }>,
|
||||
agentId: string,
|
||||
): OfficeAgent["status"] {
|
||||
if (runs.some((r) => r.agentId === agentId && r.state === "running")) return "active";
|
||||
const p = presence.find((r) => r.principalType === "agent" && r.principalId === agentId);
|
||||
if (p?.status === "thinking") return "thinking";
|
||||
if (p?.status === "waiting_approval" || p?.status === "busy") return "busy";
|
||||
if (p?.status === "active" || p?.status === "online") return "active";
|
||||
): AgentActivity {
|
||||
if (runs.some((run) => run.agentId === agentId && run.state === "running")) return "active";
|
||||
const record = presence.find((row) => row.principalType === "agent" && row.principalId === agentId);
|
||||
if (record?.status === "thinking") return "thinking";
|
||||
if (record?.status === "waiting_approval" || record?.status === "busy") return "busy";
|
||||
if (record?.status === "active" || record?.status === "online") return "active";
|
||||
return "idle";
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
import type { AmenitySpot, DeskSlot, FloorPlan, LaneGraph, Rect, RoomSpec, Vec } from "./office-types";
|
||||
|
||||
/**
|
||||
* Fixed-size world geometry (logical px). The office is a north band of rooms
|
||||
* (CEO office, meeting room, snack bar, restroom) opening onto an east-west
|
||||
* corridor, with the open desk field growing downward row by row. All
|
||||
* movement rides an axis-aligned lane graph built from the same constants,
|
||||
* so walkable space is correct by construction.
|
||||
*/
|
||||
export const WORLD_W = 1216;
|
||||
export const WALL = 16;
|
||||
|
||||
const ROOM_TOP = WALL;
|
||||
const ROOM_BOTTOM = 272;
|
||||
const ROOM_WALL_BOTTOM = ROOM_BOTTOM + WALL;
|
||||
export const CORRIDOR_Y = 320;
|
||||
const CORRIDOR_MIN_X = 48;
|
||||
const CORRIDOR_MAX_X = 1176;
|
||||
const INTERIOR_LANE_Y = 232;
|
||||
const DOOR_HALF = 24;
|
||||
|
||||
const DESK_W = 80;
|
||||
const DESK_H = 40;
|
||||
const DESK_TOP0 = 384;
|
||||
const ROW_PITCH = 128;
|
||||
const DESK_COLS = [144, 368, 592, 816, 1040];
|
||||
const AISLE_XS = [104, 296, 520, 744, 968, 1160];
|
||||
const SEAT_DROP = 54;
|
||||
const ROW_LANE_DROP = 88;
|
||||
const MIN_DESK_ROWS = 2;
|
||||
|
||||
const CEO_DESK: Rect = { x: 100, y: 120, w: 120, h: 40 };
|
||||
const CEO_SEAT: Vec = { x: 160, y: 182 };
|
||||
const CEO_SOFA: Rect = { x: 32, y: 96, w: 24, h: 72 };
|
||||
const CEO_PLANT: Rect = { x: 268, y: 24, w: 24, h: 24 };
|
||||
const MEETING_TABLE: Rect = { x: 392, y: 120, w: 160, h: 64 };
|
||||
const SNACK_COUNTER: Rect = { x: 656, y: 40, w: 224, h: 28 };
|
||||
const SNACK_TABLE: Rect = { x: 748, y: 170, w: 40, h: 40 };
|
||||
const RESTROOM_STALLS: Rect = { x: 1064, y: WALL, w: 120, h: 72 };
|
||||
const RESTROOM_SINKS: Rect = { x: 928, y: 32, w: 96, h: 32 };
|
||||
const CORRIDOR_PLANTS: Rect[] = [
|
||||
{ x: 302, y: 290, w: 20, h: 20 },
|
||||
{ x: 622, y: 290, w: 20, h: 20 },
|
||||
{ x: 894, y: 290, w: 20, h: 20 },
|
||||
];
|
||||
|
||||
const ROOMS: RoomSpec[] = [
|
||||
{ kind: "ceo", label: "CEO OFFICE", bounds: { x: 16, y: ROOM_TOP, w: 288, h: 256 }, doorCx: 160, interiorLaneY: INTERIOR_LANE_Y },
|
||||
{ kind: "meeting", label: "MEETING ROOM", bounds: { x: 320, y: ROOM_TOP, w: 304, h: 256 }, doorCx: 472, interiorLaneY: INTERIOR_LANE_Y },
|
||||
{ kind: "snack", label: "SNACK BAR", bounds: { x: 640, y: ROOM_TOP, w: 256, h: 256 }, doorCx: 768, interiorLaneY: INTERIOR_LANE_Y },
|
||||
{ kind: "restroom", label: "RESTROOM", bounds: { x: 912, y: ROOM_TOP, w: 288, h: 256 }, doorCx: 1056, interiorLaneY: INTERIOR_LANE_Y },
|
||||
];
|
||||
|
||||
const AMENITIES: AmenitySpot[] = [
|
||||
{ kind: "coffee", room: "snack", stand: { x: 704, y: 92 } },
|
||||
{ kind: "snack", room: "snack", stand: { x: 840, y: 92 } },
|
||||
{ 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 } },
|
||||
];
|
||||
|
||||
interface Segment {
|
||||
a: Vec;
|
||||
b: Vec;
|
||||
}
|
||||
|
||||
export function buildFloorPlan(openDeskCount: number): FloorPlan {
|
||||
const rows = Math.max(MIN_DESK_ROWS, Math.ceil(Math.max(openDeskCount, 0) / DESK_COLS.length));
|
||||
const desks = buildDesks(rows);
|
||||
const lastRowLane = rowLaneY(rows - 1);
|
||||
const worldH = lastRowLane + 64;
|
||||
|
||||
const colliders: Rect[] = [
|
||||
{ x: 0, y: 0, w: WORLD_W, h: WALL },
|
||||
{ x: 0, y: WALL, w: WALL, h: 280 },
|
||||
{ x: 0, y: 344, w: WALL, h: worldH - 344 - WALL },
|
||||
{ x: WORLD_W - WALL, y: WALL, w: WALL, h: worldH - 2 * WALL },
|
||||
{ x: 0, y: worldH - WALL, w: WORLD_W, h: WALL },
|
||||
{ x: 304, y: ROOM_TOP, w: WALL, h: ROOM_WALL_BOTTOM - ROOM_TOP },
|
||||
{ x: 624, y: ROOM_TOP, w: WALL, h: ROOM_WALL_BOTTOM - ROOM_TOP },
|
||||
{ x: 896, y: ROOM_TOP, w: WALL, h: ROOM_WALL_BOTTOM - ROOM_TOP },
|
||||
...southWallsWithDoors(),
|
||||
CEO_DESK,
|
||||
CEO_SOFA,
|
||||
CEO_PLANT,
|
||||
MEETING_TABLE,
|
||||
SNACK_COUNTER,
|
||||
SNACK_TABLE,
|
||||
RESTROOM_STALLS,
|
||||
RESTROOM_SINKS,
|
||||
...CORRIDOR_PLANTS,
|
||||
...desks.map((desk) => desk.bounds),
|
||||
];
|
||||
|
||||
return {
|
||||
world: { w: WORLD_W, h: worldH },
|
||||
rooms: ROOMS,
|
||||
ceoDesk: CEO_DESK,
|
||||
ceoSeat: CEO_SEAT,
|
||||
desks,
|
||||
amenities: AMENITIES,
|
||||
colliders,
|
||||
lanes: buildLaneGraph(rows, desks),
|
||||
entrance: { x: CORRIDOR_MIN_X, y: CORRIDOR_Y },
|
||||
};
|
||||
}
|
||||
|
||||
function rowLaneY(row: number): number {
|
||||
return DESK_TOP0 + row * ROW_PITCH + ROW_LANE_DROP;
|
||||
}
|
||||
|
||||
function buildDesks(rows: number): DeskSlot[] {
|
||||
const desks: DeskSlot[] = [];
|
||||
for (let row = 0; row < rows; row++) {
|
||||
for (let col = 0; col < DESK_COLS.length; col++) {
|
||||
const x = DESK_COLS[col];
|
||||
const y = DESK_TOP0 + row * ROW_PITCH;
|
||||
desks.push({
|
||||
index: desks.length,
|
||||
bounds: { x, y, w: DESK_W, h: DESK_H },
|
||||
seat: { x: x + DESK_W / 2, y: y + SEAT_DROP },
|
||||
});
|
||||
}
|
||||
}
|
||||
return desks;
|
||||
}
|
||||
|
||||
function southWallsWithDoors(): Rect[] {
|
||||
const walls: Rect[] = [];
|
||||
for (const room of ROOMS) {
|
||||
const left = room.bounds.x;
|
||||
const right = room.bounds.x + room.bounds.w;
|
||||
const gapLeft = room.doorCx - DOOR_HALF;
|
||||
const gapRight = room.doorCx + DOOR_HALF;
|
||||
walls.push({ x: left, y: ROOM_BOTTOM, w: gapLeft - left, h: WALL });
|
||||
walls.push({ x: gapRight, y: ROOM_BOTTOM, w: right - gapRight, h: WALL });
|
||||
}
|
||||
return walls;
|
||||
}
|
||||
|
||||
function buildSegments(rows: number, desks: DeskSlot[]): Segment[] {
|
||||
const segments: Segment[] = [
|
||||
{ a: { x: CORRIDOR_MIN_X, y: CORRIDOR_Y }, b: { x: CORRIDOR_MAX_X, y: CORRIDOR_Y } },
|
||||
];
|
||||
for (let row = 0; row < rows; row++) {
|
||||
const y = rowLaneY(row);
|
||||
segments.push({ a: { x: AISLE_XS[0], y }, b: { x: AISLE_XS[AISLE_XS.length - 1], y } });
|
||||
}
|
||||
const lastLane = rowLaneY(rows - 1);
|
||||
for (const x of AISLE_XS) {
|
||||
segments.push({ a: { x, y: CORRIDOR_Y }, b: { x, y: lastLane } });
|
||||
}
|
||||
for (const room of ROOMS) {
|
||||
const laneMinX = room.bounds.x + 24;
|
||||
const laneMaxX = room.bounds.x + room.bounds.w - 24;
|
||||
segments.push({ a: { x: laneMinX, y: room.interiorLaneY }, b: { x: laneMaxX, y: room.interiorLaneY } });
|
||||
segments.push({ a: { x: room.doorCx, y: room.interiorLaneY }, b: { x: room.doorCx, y: CORRIDOR_Y } });
|
||||
}
|
||||
segments.push({ a: { x: CEO_SEAT.x, y: CEO_SEAT.y }, b: { x: CEO_SEAT.x, y: INTERIOR_LANE_Y } });
|
||||
for (const spot of AMENITIES) {
|
||||
segments.push({ a: { x: spot.stand.x, y: spot.stand.y }, b: { x: spot.stand.x, y: INTERIOR_LANE_Y } });
|
||||
}
|
||||
for (const desk of desks) {
|
||||
const lane = desk.bounds.y + ROW_LANE_DROP;
|
||||
segments.push({ a: { x: desk.seat.x, y: desk.seat.y }, b: { x: desk.seat.x, y: lane } });
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
function buildLaneGraph(rows: number, desks: DeskSlot[]): LaneGraph {
|
||||
const segments = buildSegments(rows, desks);
|
||||
const nodes: Vec[] = [];
|
||||
const nodeIndex = new Map<string, number>();
|
||||
const edges = new Set<string>();
|
||||
|
||||
const keyOf = (p: Vec) => `${p.x},${p.y}`;
|
||||
const addNode = (p: Vec): number => {
|
||||
const key = keyOf(p);
|
||||
const existing = nodeIndex.get(key);
|
||||
if (existing !== undefined) return existing;
|
||||
nodes.push({ x: p.x, y: p.y });
|
||||
nodeIndex.set(key, nodes.length - 1);
|
||||
return nodes.length - 1;
|
||||
};
|
||||
|
||||
for (const segment of segments) {
|
||||
const points = pointsOnSegment(segment, segments);
|
||||
for (let i = 0; i + 1 < points.length; i++) {
|
||||
const a = addNode(points[i]);
|
||||
const b = addNode(points[i + 1]);
|
||||
if (a === b) continue;
|
||||
edges.add(a < b ? `${a}|${b}` : `${b}|${a}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
nodes,
|
||||
edges: [...edges].map((pair) => {
|
||||
const [a, b] = pair.split("|");
|
||||
return [Number(a), Number(b)] as [number, number];
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function pointsOnSegment(segment: Segment, all: Segment[]): Vec[] {
|
||||
const points: Vec[] = [segment.a, segment.b];
|
||||
for (const other of all) {
|
||||
if (other === segment) continue;
|
||||
const hit = intersection(segment, other);
|
||||
if (hit) points.push(hit);
|
||||
}
|
||||
const horizontal = segment.a.y === segment.b.y;
|
||||
points.sort((p, q) => (horizontal ? p.x - q.x : p.y - q.y));
|
||||
return points.filter((p, i) => i === 0 || p.x !== points[i - 1].x || p.y !== points[i - 1].y);
|
||||
}
|
||||
|
||||
function intersection(s1: Segment, s2: Segment): Vec | null {
|
||||
const h1 = s1.a.y === s1.b.y;
|
||||
const h2 = s2.a.y === s2.b.y;
|
||||
if (h1 === h2) return null;
|
||||
const horizontal = h1 ? s1 : s2;
|
||||
const vertical = h1 ? s2 : s1;
|
||||
const [hx1, hx2] = [Math.min(horizontal.a.x, horizontal.b.x), Math.max(horizontal.a.x, horizontal.b.x)];
|
||||
const [vy1, vy2] = [Math.min(vertical.a.y, vertical.b.y), Math.max(vertical.a.y, vertical.b.y)];
|
||||
const x = vertical.a.x;
|
||||
const y = horizontal.a.y;
|
||||
if (x < hx1 || x > hx2 || y < vy1 || y > vy2) return null;
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
/**
|
||||
* Shortest lane path between two world points. Both endpoints snap to their
|
||||
* nearest lane node, so a walker interrupted mid-lane re-routes correctly.
|
||||
* Returns waypoints including `from` and `to`, with collinear points removed.
|
||||
*/
|
||||
export function findPath(plan: FloorPlan, from: Vec, to: Vec): Vec[] {
|
||||
const start = nearestNode(plan.lanes, from);
|
||||
const goal = nearestNode(plan.lanes, to);
|
||||
const nodePath = bfs(plan.lanes, start, goal);
|
||||
const waypoints = [from, ...nodePath.map((i) => plan.lanes.nodes[i]), to];
|
||||
return simplify(waypoints);
|
||||
}
|
||||
|
||||
function nearestNode(lanes: LaneGraph, p: Vec): number {
|
||||
let best = 0;
|
||||
let bestDist = Infinity;
|
||||
for (let i = 0; i < lanes.nodes.length; i++) {
|
||||
const node = lanes.nodes[i];
|
||||
const dist = Math.abs(node.x - p.x) + Math.abs(node.y - p.y);
|
||||
if (dist < bestDist) {
|
||||
bestDist = dist;
|
||||
best = i;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function bfs(lanes: LaneGraph, start: number, goal: number): number[] {
|
||||
if (start === goal) return [start];
|
||||
const adjacency = new Map<number, number[]>();
|
||||
for (const [a, b] of lanes.edges) {
|
||||
(adjacency.get(a) ?? adjacency.set(a, []).get(a)!).push(b);
|
||||
(adjacency.get(b) ?? adjacency.set(b, []).get(b)!).push(a);
|
||||
}
|
||||
const previous = new Map<number, number>();
|
||||
const queue = [start];
|
||||
previous.set(start, start);
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()!;
|
||||
if (current === goal) break;
|
||||
for (const next of adjacency.get(current) ?? []) {
|
||||
if (previous.has(next)) continue;
|
||||
previous.set(next, current);
|
||||
queue.push(next);
|
||||
}
|
||||
}
|
||||
if (!previous.has(goal)) {
|
||||
throw new Error(`Office lane graph is disconnected between nodes ${start} and ${goal}`);
|
||||
}
|
||||
const path: number[] = [];
|
||||
for (let node = goal; node !== start; node = previous.get(node)!) path.unshift(node);
|
||||
path.unshift(start);
|
||||
return path;
|
||||
}
|
||||
|
||||
function simplify(points: Vec[]): Vec[] {
|
||||
const result: Vec[] = [];
|
||||
for (const point of points) {
|
||||
const last = result[result.length - 1];
|
||||
if (last && last.x === point.x && last.y === point.y) continue;
|
||||
const beforeLast = result[result.length - 2];
|
||||
if (
|
||||
last && beforeLast &&
|
||||
((last.x === point.x && beforeLast.x === point.x) || (last.y === point.y && beforeLast.y === point.y))
|
||||
) {
|
||||
result[result.length - 1] = point;
|
||||
continue;
|
||||
}
|
||||
result.push(point);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function rectContains(rect: Rect, p: Vec): boolean {
|
||||
return p.x >= rect.x && p.x <= rect.x + rect.w && p.y >= rect.y && p.y <= rect.y + rect.h;
|
||||
}
|
||||
|
||||
export function rectsOverlap(a: Rect, b: Rect): boolean {
|
||||
return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
|
||||
}
|
||||
@@ -1,235 +1,136 @@
|
||||
export interface OfficeAgent {
|
||||
import type { AgentActivity, Camera, FloorPlan, Rect } from "./office-types";
|
||||
import { deskScreenRect, drawWorld } from "./scene-renderer";
|
||||
|
||||
export interface RenderPerson {
|
||||
id: string;
|
||||
name: string;
|
||||
role: string;
|
||||
department: string;
|
||||
status: "active" | "thinking" | "busy" | "idle";
|
||||
task: string | null;
|
||||
activity: AgentActivity;
|
||||
x: number;
|
||||
y: number;
|
||||
targetX: number;
|
||||
targetY: number;
|
||||
faceColor: string;
|
||||
shirtColor: string;
|
||||
frame: number;
|
||||
desk: Rect | null;
|
||||
isCeo: boolean;
|
||||
animSeed: number;
|
||||
}
|
||||
|
||||
const WALL_H = 110;
|
||||
const DESK_W = 90;
|
||||
const DESK_SPACING = 140;
|
||||
const ROW_H = 160;
|
||||
const ACTIVITY_DOT: Partial<Record<AgentActivity, string>> = {
|
||||
active: "#34d399",
|
||||
thinking: "#818cf8",
|
||||
busy: "#f59e0b",
|
||||
};
|
||||
|
||||
const WALL = "#3a3a4a";
|
||||
const FLOOR = "#484858";
|
||||
const FACE_TONES = ["#f3c99f", "#e8b184", "#d29b6e", "#b57e4f", "#95603a", "#7c4d2f"];
|
||||
const SHIRT_COLORS = ["#5b8dd9", "#d95b7a", "#5bbf6f", "#d9a65b", "#9b6fd9", "#5bc4c4", "#d97b5b", "#7fd95b"];
|
||||
|
||||
export function drawScene(ctx: CanvasRenderingContext2D, w: number, h: number, agents: OfficeAgent[], time: number) {
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
ctx.fillStyle = FLOOR;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
drawWall(ctx, w);
|
||||
drawWallDecor(ctx, w, time);
|
||||
drawFloorGrid(ctx, w, h);
|
||||
|
||||
const cols = Math.max(2, Math.floor((w - 40) / DESK_SPACING));
|
||||
|
||||
for (let i = 0; i < agents.length; i++) {
|
||||
const col = i % cols;
|
||||
const row = Math.floor(i / cols);
|
||||
const dx = 50 + col * DESK_SPACING;
|
||||
const dy = WALL_H + 30 + row * ROW_H;
|
||||
drawDesk(ctx, dx, dy, agents[i]);
|
||||
}
|
||||
|
||||
const sorted = [...agents].sort((a, b) => a.y - b.y);
|
||||
for (const agent of sorted) drawCharacter(ctx, agent, time);
|
||||
export function hashString(value: string): number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < value.length; i++) hash = (hash * 31 + value.charCodeAt(i)) | 0;
|
||||
return Math.abs(hash);
|
||||
}
|
||||
|
||||
export function layoutPositions(count: number, viewW: number): Array<{ x: number; y: number }> {
|
||||
const cols = Math.max(2, Math.floor((viewW - 40) / DESK_SPACING));
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
x: 50 + (i % cols) * DESK_SPACING + DESK_W / 2,
|
||||
y: WALL_H + 30 + Math.floor(i / cols) * ROW_H + 70,
|
||||
}));
|
||||
}
|
||||
/**
|
||||
* Composes one frame: static world under the camera transform, then desks'
|
||||
* activity glow, then people (painter's order by y) with full-name labels.
|
||||
*/
|
||||
export function renderOffice(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
viewW: number,
|
||||
viewH: number,
|
||||
plan: FloorPlan,
|
||||
camera: Camera,
|
||||
people: RenderPerson[],
|
||||
timeMs: number,
|
||||
): void {
|
||||
ctx.fillStyle = "#14161c";
|
||||
ctx.fillRect(0, 0, viewW, viewH);
|
||||
ctx.save();
|
||||
ctx.translate(-camera.x, -camera.y);
|
||||
|
||||
function drawWall(ctx: CanvasRenderingContext2D, w: number) {
|
||||
ctx.fillStyle = WALL;
|
||||
ctx.fillRect(0, 0, w, WALL_H);
|
||||
ctx.strokeStyle = "#42425a";
|
||||
ctx.lineWidth = 0.5;
|
||||
for (let x = 0; x < w; x += 24) {
|
||||
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, WALL_H); ctx.stroke();
|
||||
}
|
||||
for (let y = 0; y < WALL_H; y += 12) {
|
||||
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke();
|
||||
}
|
||||
ctx.fillStyle = "#333";
|
||||
ctx.fillRect(0, WALL_H - 3, w, 3);
|
||||
}
|
||||
drawWorld(ctx, plan, { x: camera.x, y: camera.y, w: viewW, h: viewH });
|
||||
|
||||
function drawFloorGrid(ctx: CanvasRenderingContext2D, w: number, h: number) {
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.03)";
|
||||
ctx.lineWidth = 0.5;
|
||||
for (let x = 0; x < w; x += 24) { ctx.beginPath(); ctx.moveTo(x, WALL_H); ctx.lineTo(x, h); ctx.stroke(); }
|
||||
for (let y = WALL_H; y < h; y += 24) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); }
|
||||
}
|
||||
|
||||
function drawWallDecor(ctx: CanvasRenderingContext2D, w: number, time: number) {
|
||||
drawWindow(ctx, 20, 10, 80, 60, time);
|
||||
if (w > 500) drawWindow(ctx, w - 110, 10, 80, 60, time);
|
||||
drawClock(ctx, Math.min(w / 2, 300), 45, 22, time);
|
||||
if (w > 400) drawBoard(ctx, Math.min(w / 2 + 60, w - 160), 12, 100, 70);
|
||||
}
|
||||
|
||||
function drawWindow(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, time: number) {
|
||||
ctx.fillStyle = "#1a1a3a";
|
||||
ctx.fillRect(x, y, w, h);
|
||||
ctx.strokeStyle = "#666";
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.strokeRect(x, y, w, h);
|
||||
ctx.beginPath(); ctx.moveTo(x + w / 2, y); ctx.lineTo(x + w / 2, y + h); ctx.stroke();
|
||||
const seed = Math.floor(time * 0.0001);
|
||||
for (let bx = x + 6; bx < x + w - 6; bx += 10) {
|
||||
for (let by = y + h - 40; by < y + h - 4; by += 7) {
|
||||
const on = ((bx * 7 + by * 13 + seed) % 5) > 1;
|
||||
ctx.fillStyle = on ? "#ffa" : "#334";
|
||||
ctx.fillRect(bx, by, 3, 3);
|
||||
for (const person of people) {
|
||||
if (!person.desk) continue;
|
||||
if (person.activity !== "active" && person.activity !== "thinking") continue;
|
||||
const screen = deskScreenRect(person.desk);
|
||||
ctx.fillStyle = "#1c3a2c";
|
||||
ctx.fillRect(screen.x, screen.y, screen.w, screen.h);
|
||||
ctx.fillStyle = "#37d38a";
|
||||
const lines = 3 + (hashString(person.id) % 3);
|
||||
for (let i = 0; i < lines; i++) {
|
||||
const width = 6 + ((hashString(person.id) >> (i * 3)) % 14);
|
||||
ctx.fillRect(screen.x + 3, screen.y + 3 + i * 3, width, 1);
|
||||
}
|
||||
}
|
||||
ctx.fillStyle = "#ff8";
|
||||
ctx.beginPath(); ctx.arc(x + w * 0.75, y + 12, 5, 0, Math.PI * 2); ctx.fill();
|
||||
|
||||
const ordered = [...people].sort((a, b) => a.y - b.y);
|
||||
for (const person of ordered) drawPerson(ctx, person, timeMs);
|
||||
for (const person of ordered) drawNameLabel(ctx, person);
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawClock(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number, time: number) {
|
||||
ctx.fillStyle = "#eee";
|
||||
ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.strokeStyle = "#444";
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
const min = (time * 0.0005) % (Math.PI * 2);
|
||||
ctx.strokeStyle = "#333";
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(cx + Math.cos(min / 12 - Math.PI / 2) * r * 0.5, cy + Math.sin(min / 12 - Math.PI / 2) * r * 0.5); ctx.stroke();
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(cx + Math.cos(min - Math.PI / 2) * r * 0.7, cy + Math.sin(min - Math.PI / 2) * r * 0.7); ctx.stroke();
|
||||
}
|
||||
function drawPerson(ctx: CanvasRenderingContext2D, person: RenderPerson, timeMs: number): void {
|
||||
const x = Math.round(person.x);
|
||||
const working = person.activity === "active" || person.activity === "thinking";
|
||||
const bob = working ? Math.sin(timeMs * 0.003 + person.animSeed) * 1.2 : 0;
|
||||
const y = Math.round(person.y + bob);
|
||||
|
||||
function drawBoard(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number) {
|
||||
ctx.fillStyle = "#f5f5f0";
|
||||
ctx.fillRect(x, y, w, h);
|
||||
ctx.strokeStyle = "#888";
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.strokeRect(x, y, w, h);
|
||||
ctx.fillStyle = "#333";
|
||||
ctx.font = "bold 7px monospace";
|
||||
ctx.fillText("TODO", x + 5, y + 12);
|
||||
ctx.font = "6px monospace";
|
||||
ctx.fillStyle = "#555";
|
||||
const items = ["Review PRD", "Ship v1.0", "Fix bug #42", "Deploy"];
|
||||
for (let i = 0; i < items.length; i++) ctx.fillText(`• ${items[i]}`, x + 5, y + 22 + i * 10);
|
||||
}
|
||||
const face = FACE_TONES[hashString(person.id) % FACE_TONES.length];
|
||||
const shirt = person.isCeo ? "#37415c" : SHIRT_COLORS[hashString(person.id + person.name) % SHIRT_COLORS.length];
|
||||
|
||||
function drawDesk(ctx: CanvasRenderingContext2D, x: number, y: number, agent: OfficeAgent) {
|
||||
const active = agent.status === "active" || agent.status === "thinking";
|
||||
const mx = x + DESK_W / 2 - 14;
|
||||
const my = y - 26;
|
||||
ctx.fillStyle = "#555";
|
||||
ctx.fillRect(mx, my, 28, 20);
|
||||
ctx.fillStyle = active ? "#1a3a2a" : "#222";
|
||||
ctx.fillRect(mx + 2, my + 2, 24, 14);
|
||||
if (active && agent.task) {
|
||||
ctx.fillStyle = "#4f8";
|
||||
ctx.font = "5px monospace";
|
||||
ctx.fillText(agent.task.slice(0, 16), mx + 3, my + 10);
|
||||
}
|
||||
ctx.fillStyle = "#555";
|
||||
ctx.fillRect(mx + 11, my + 20, 6, 3);
|
||||
ctx.fillRect(mx + 6, my + 23, 16, 2);
|
||||
ctx.fillStyle = "rgba(20,16,12,0.25)";
|
||||
ctx.fillRect(x - 8, y + 2, 16, 4);
|
||||
|
||||
ctx.fillStyle = "#8b7355";
|
||||
ctx.fillRect(x, y, DESK_W, 5);
|
||||
ctx.fillStyle = "#6b5335";
|
||||
ctx.fillRect(x + 4, y + 5, 3, 30);
|
||||
ctx.fillRect(x + DESK_W - 7, y + 5, 3, 30);
|
||||
ctx.fillStyle = "#3a3f47";
|
||||
ctx.fillRect(x - 5, y - 7, 4, 8);
|
||||
ctx.fillRect(x + 1, y - 7, 4, 8);
|
||||
|
||||
ctx.fillStyle = "#666";
|
||||
ctx.fillRect(x + DESK_W / 2 - 10, y - 2, 20, 3);
|
||||
ctx.fillStyle = shirt;
|
||||
ctx.fillRect(x - 8, y - 20, 16, 13);
|
||||
ctx.fillStyle = face;
|
||||
ctx.fillRect(x - 10, y - 18, 3, 7);
|
||||
ctx.fillRect(x + 7, y - 18, 3, 7);
|
||||
|
||||
if (active) {
|
||||
ctx.fillStyle = "#86654a";
|
||||
ctx.fillRect(x + DESK_W - 16, y - 7, 7, 7);
|
||||
ctx.fillStyle = "#f5f0e8";
|
||||
ctx.fillRect(x + DESK_W - 15, y - 6, 5, 3);
|
||||
}
|
||||
}
|
||||
ctx.fillStyle = face;
|
||||
ctx.fillRect(x - 7, y - 32, 14, 12);
|
||||
ctx.fillStyle = "#2c2320";
|
||||
ctx.fillRect(x - 8, y - 34, 16, 5);
|
||||
ctx.fillRect(x - 8, y - 31, 3, 4);
|
||||
ctx.fillRect(x + 5, y - 31, 3, 4);
|
||||
|
||||
function drawCharacter(ctx: CanvasRenderingContext2D, agent: OfficeAgent, time: number) {
|
||||
const x = Math.round(agent.x);
|
||||
const y = Math.round(agent.y);
|
||||
const active = agent.status === "active" || agent.status === "thinking";
|
||||
const bob = active ? Math.sin(time * 0.003 + agent.frame) * 1.5 : 0;
|
||||
ctx.fillStyle = "#1d1d22";
|
||||
ctx.fillRect(x - 4, y - 27, 2, 2);
|
||||
ctx.fillRect(x + 2, y - 27, 2, 2);
|
||||
|
||||
ctx.fillStyle = "#222";
|
||||
ctx.beginPath(); ctx.arc(x - 9, y - 36 + bob, 10, Math.PI, 0); ctx.fill();
|
||||
ctx.beginPath(); ctx.arc(x + 9, y - 36 + bob, 10, Math.PI, 0, true); ctx.fill();
|
||||
ctx.fillRect(x - 9, y - 36 + bob, 18, 5);
|
||||
ctx.beginPath(); ctx.arc(x - 12, y - 34 + bob, 4, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.beginPath(); ctx.arc(x + 12, y - 34 + bob, 4, 0, Math.PI * 2); ctx.fill();
|
||||
|
||||
ctx.fillStyle = agent.faceColor;
|
||||
ctx.beginPath(); ctx.arc(x, y - 30 + bob, 9, 0, Math.PI * 2); ctx.fill();
|
||||
|
||||
ctx.fillStyle = "#fff";
|
||||
ctx.fillRect(x - 4, y - 32 + bob, 3, 3);
|
||||
ctx.fillRect(x + 1, y - 32 + bob, 3, 3);
|
||||
ctx.fillStyle = "#111";
|
||||
ctx.fillRect(x - 3, y - 31 + bob, 2, 2);
|
||||
ctx.fillRect(x + 2, y - 31 + bob, 2, 2);
|
||||
|
||||
ctx.fillStyle = agent.shirtColor;
|
||||
ctx.fillRect(x - 9, y - 20 + bob, 18, 14);
|
||||
|
||||
ctx.fillStyle = agent.faceColor;
|
||||
ctx.fillRect(x - 11, y - 18 + bob, 3, 8);
|
||||
ctx.fillRect(x + 8, y - 18 + bob, 3, 8);
|
||||
|
||||
ctx.fillStyle = "#444";
|
||||
ctx.fillRect(x - 5, y - 6 + bob, 4, 7);
|
||||
ctx.fillRect(x + 1, y - 6 + bob, 4, 7);
|
||||
ctx.fillStyle = "#333";
|
||||
ctx.fillRect(x - 6, y + 1 + bob, 5, 3);
|
||||
ctx.fillRect(x + 1, y + 1 + bob, 5, 3);
|
||||
|
||||
if (active) {
|
||||
ctx.fillStyle = agent.status === "active" ? "#34d399" : "#818cf8";
|
||||
ctx.beginPath(); ctx.arc(x + 11, y - 40 + bob, 3, 0, Math.PI * 2); ctx.fill();
|
||||
}
|
||||
|
||||
if (agent.task && active) {
|
||||
const text = agent.task.slice(0, 20);
|
||||
ctx.font = "7px sans-serif";
|
||||
const tw = ctx.measureText(text).width + 10;
|
||||
const bx = x - tw / 2;
|
||||
const by = y - 50 + bob;
|
||||
ctx.fillStyle = "#fff";
|
||||
const dot = ACTIVITY_DOT[person.activity];
|
||||
if (dot) {
|
||||
ctx.fillStyle = dot;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(bx + 3, by); ctx.lineTo(bx + tw - 3, by); ctx.arcTo(bx + tw, by, bx + tw, by + 3, 3);
|
||||
ctx.lineTo(bx + tw, by + 13); ctx.arcTo(bx + tw, by + 16, bx + tw - 3, by + 16, 3);
|
||||
ctx.lineTo(bx + 3, by + 16); ctx.arcTo(bx, by + 16, bx, by + 13, 3);
|
||||
ctx.lineTo(bx, by + 3); ctx.arcTo(bx, by, bx + 3, by, 3);
|
||||
ctx.arc(x + 10, y - 36, 3, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.beginPath(); ctx.moveTo(x - 3, by + 16); ctx.lineTo(x, by + 20); ctx.lineTo(x + 3, by + 16); ctx.fill();
|
||||
ctx.fillStyle = "#333";
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText(text, x, by + 11);
|
||||
ctx.textAlign = "start";
|
||||
}
|
||||
}
|
||||
|
||||
ctx.fillStyle = "#bbb";
|
||||
ctx.font = "9px sans-serif";
|
||||
function drawNameLabel(ctx: CanvasRenderingContext2D, person: RenderPerson): void {
|
||||
ctx.font = "600 9px -apple-system, 'Segoe UI', sans-serif";
|
||||
const width = Math.ceil(ctx.measureText(person.name).width) + 10;
|
||||
const x = Math.round(person.x - width / 2);
|
||||
const y = Math.round(person.y + 7);
|
||||
ctx.fillStyle = "rgba(22,22,28,0.78)";
|
||||
drawRoundedRect(ctx, x, y, width, 13, 4);
|
||||
ctx.fillStyle = "#f2f2f5";
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText(agent.name.split(" ")[0], x, y + 10);
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText(person.name, person.x, y + 7);
|
||||
ctx.textAlign = "start";
|
||||
ctx.textBaseline = "alphabetic";
|
||||
}
|
||||
|
||||
function drawRoundedRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number): void {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + r, y);
|
||||
ctx.arcTo(x + w, y, x + w, y + h, r);
|
||||
ctx.arcTo(x + w, y + h, x, y + h, r);
|
||||
ctx.arcTo(x, y + h, x, y, r);
|
||||
ctx.arcTo(x, y, x + w, y, r);
|
||||
ctx.fill();
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
export interface Vec {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface Rect {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
export type RoomKind = "ceo" | "meeting" | "snack" | "restroom";
|
||||
|
||||
export interface RoomSpec {
|
||||
kind: RoomKind;
|
||||
label: string;
|
||||
/** Interior area, excluding surrounding walls. */
|
||||
bounds: Rect;
|
||||
/** Center x of the door opening on the corridor-facing south wall. */
|
||||
doorCx: number;
|
||||
/** Horizontal walking lane inside the room. */
|
||||
interiorLaneY: number;
|
||||
}
|
||||
|
||||
export interface DeskSlot {
|
||||
index: number;
|
||||
bounds: Rect;
|
||||
/** Where the assigned person stands/sits, south of the desk. */
|
||||
seat: Vec;
|
||||
}
|
||||
|
||||
export type AmenityKind = "coffee" | "snack" | "restroom";
|
||||
|
||||
export interface AmenitySpot {
|
||||
kind: AmenityKind;
|
||||
room: RoomKind;
|
||||
/** Where a visiting person stands while using the amenity. */
|
||||
stand: Vec;
|
||||
}
|
||||
|
||||
export interface LaneGraph {
|
||||
nodes: Vec[];
|
||||
/** Adjacency as index pairs into `nodes`; every edge is axis-aligned. */
|
||||
edges: Array<[number, number]>;
|
||||
}
|
||||
|
||||
export interface FloorPlan {
|
||||
world: { w: number; h: number };
|
||||
rooms: RoomSpec[];
|
||||
/** The CEO's dedicated desk inside the CEO office. */
|
||||
ceoDesk: Rect;
|
||||
ceoSeat: Vec;
|
||||
/** Open-floor desks, assigned to non-CEO agents in stable order. */
|
||||
desks: DeskSlot[];
|
||||
amenities: AmenitySpot[];
|
||||
/** Solid obstacles for player collision: walls and furniture. */
|
||||
colliders: Rect[];
|
||||
lanes: LaneGraph;
|
||||
/** Player spawn point, just inside the entrance. */
|
||||
entrance: Vec;
|
||||
}
|
||||
|
||||
export type AgentActivity = "active" | "thinking" | "busy" | "idle";
|
||||
|
||||
/** Snapshot-derived facts the office simulation needs about one agent. */
|
||||
export interface OfficeAgentInput {
|
||||
id: string;
|
||||
name: string;
|
||||
role: string;
|
||||
title: string;
|
||||
department: string;
|
||||
roleLevel: string;
|
||||
activity: AgentActivity;
|
||||
taskTitle: string | null;
|
||||
}
|
||||
|
||||
export interface Camera {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
import type { FloorPlan, Rect, RoomSpec } from "./office-types";
|
||||
import { WALL } from "./floor-plan";
|
||||
|
||||
/**
|
||||
* Draws the static office world in a 3/4 top-down pixel style: wood floor,
|
||||
* room interiors, wall faces with windows, furniture, and door signage.
|
||||
* Everything here is a pure function of the floor plan and the visible rect.
|
||||
*/
|
||||
|
||||
/** Height of the visible north-wall face inside each room. */
|
||||
export const WALL_FACE_H = 48;
|
||||
|
||||
const PALETTE = {
|
||||
wood: "#b08a62",
|
||||
woodSeam: "#9c7852",
|
||||
corridor: "#a8815c",
|
||||
wallFill: "#4c4239",
|
||||
wallEdge: "#3b332c",
|
||||
wallFace: "#6b5f52",
|
||||
wallFaceShadow: "#5d5246",
|
||||
baseboard: "#4f463c",
|
||||
ceoCarpet: "#5c6a94",
|
||||
ceoCarpetEdge: "#505d85",
|
||||
meetingCarpet: "#6c8f78",
|
||||
meetingCarpetEdge: "#5e8069",
|
||||
snackTileA: "#d8c6a6",
|
||||
snackTileB: "#cbb891",
|
||||
restTileA: "#adbfd2",
|
||||
restTileB: "#9db0c5",
|
||||
deskWood: "#8a6d4c",
|
||||
deskWoodDark: "#75593b",
|
||||
deskLeg: "#5f4832",
|
||||
screenOff: "#20242c",
|
||||
monitorShell: "#4b5058",
|
||||
chair: "#3d4148",
|
||||
chairDark: "#33373d",
|
||||
glassSky: "#8fc3e8",
|
||||
glassSkyLow: "#c3e0f2",
|
||||
frame: "#3f3831",
|
||||
signPlate: "#2f2a25",
|
||||
signText: "#d8cfc2",
|
||||
} as const;
|
||||
|
||||
export function drawWorld(ctx: CanvasRenderingContext2D, plan: FloorPlan, view: Rect): void {
|
||||
drawFloor(ctx, plan, view);
|
||||
for (const room of plan.rooms) drawRoomFloor(ctx, room);
|
||||
drawStructure(ctx, plan);
|
||||
for (const room of plan.rooms) drawRoomFurniture(ctx, plan, room);
|
||||
drawCorridorPlants(ctx, plan);
|
||||
for (const desk of plan.desks) {
|
||||
if (rectsTouch(view, expand(desk.bounds, 40))) drawDesk(ctx, desk.bounds, false);
|
||||
}
|
||||
for (const room of plan.rooms) drawDoorSign(ctx, room);
|
||||
drawEntrance(ctx, plan);
|
||||
}
|
||||
|
||||
/** The monitor screen glass for a desk; the activity glow overlay reuses it. */
|
||||
export function deskScreenRect(desk: Rect): Rect {
|
||||
return { x: desk.x + desk.w / 2 - 13, y: desk.y - 15, w: 26, h: 15 };
|
||||
}
|
||||
|
||||
function drawFloor(ctx: CanvasRenderingContext2D, plan: FloorPlan, view: Rect): void {
|
||||
const { w, h } = plan.world;
|
||||
ctx.fillStyle = PALETTE.wood;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
ctx.fillStyle = PALETTE.woodSeam;
|
||||
const top = Math.max(0, Math.floor(view.y / 16) * 16);
|
||||
const bottom = Math.min(h, view.y + view.h);
|
||||
for (let y = top; y < bottom; y += 16) {
|
||||
ctx.fillRect(0, y, w, 1);
|
||||
const offset = (y / 16) % 2 === 0 ? 0 : 48;
|
||||
for (let x = offset; x < w; x += 96) ctx.fillRect(x, y, 1, 16);
|
||||
}
|
||||
ctx.fillStyle = PALETTE.corridor;
|
||||
ctx.fillRect(WALL, 288, plan.world.w - 2 * WALL, 64);
|
||||
}
|
||||
|
||||
function drawRoomFloor(ctx: CanvasRenderingContext2D, room: RoomSpec): void {
|
||||
const b = room.bounds;
|
||||
if (room.kind === "ceo" || room.kind === "meeting") {
|
||||
ctx.fillStyle = room.kind === "ceo" ? PALETTE.ceoCarpet : PALETTE.meetingCarpet;
|
||||
ctx.fillRect(b.x, b.y, b.w, b.h);
|
||||
ctx.strokeStyle = room.kind === "ceo" ? PALETTE.ceoCarpetEdge : PALETTE.meetingCarpetEdge;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(b.x + 6, b.y + 6, b.w - 12, b.h - 12);
|
||||
return;
|
||||
}
|
||||
const [tileA, tileB] = room.kind === "snack"
|
||||
? [PALETTE.snackTileA, PALETTE.snackTileB]
|
||||
: [PALETTE.restTileA, PALETTE.restTileB];
|
||||
for (let ty = 0; ty < b.h; ty += 16) {
|
||||
for (let tx = 0; tx < b.w; tx += 16) {
|
||||
ctx.fillStyle = ((tx + ty) / 16) % 2 === 0 ? tileA : tileB;
|
||||
ctx.fillRect(b.x + tx, b.y + ty, Math.min(16, b.w - tx), Math.min(16, b.h - ty));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawStructure(ctx: CanvasRenderingContext2D, plan: FloorPlan): void {
|
||||
const { w, h } = plan.world;
|
||||
ctx.fillStyle = PALETTE.wallFill;
|
||||
ctx.fillRect(0, 0, w, WALL);
|
||||
ctx.fillRect(0, h - WALL, w, WALL);
|
||||
ctx.fillRect(0, WALL, WALL, 280);
|
||||
ctx.fillRect(0, 344, WALL, h - 344 - WALL);
|
||||
ctx.fillRect(w - WALL, WALL, WALL, h - 2 * WALL);
|
||||
|
||||
for (const room of plan.rooms) {
|
||||
const b = room.bounds;
|
||||
ctx.fillStyle = PALETTE.wallFace;
|
||||
ctx.fillRect(b.x, b.y, b.w, WALL_FACE_H);
|
||||
ctx.fillStyle = PALETTE.wallFaceShadow;
|
||||
ctx.fillRect(b.x, b.y + WALL_FACE_H - 6, b.w, 6);
|
||||
ctx.fillStyle = PALETTE.baseboard;
|
||||
ctx.fillRect(b.x, b.y + WALL_FACE_H, b.w, 2);
|
||||
drawRoomWindows(ctx, room);
|
||||
}
|
||||
|
||||
ctx.fillStyle = PALETTE.wallFill;
|
||||
ctx.fillRect(304, WALL, WALL, 272);
|
||||
ctx.fillRect(624, WALL, WALL, 272);
|
||||
ctx.fillRect(896, WALL, WALL, 272);
|
||||
for (const room of plan.rooms) drawSouthWall(ctx, room);
|
||||
ctx.fillStyle = PALETTE.wallEdge;
|
||||
ctx.fillRect(0, 0, w, 2);
|
||||
}
|
||||
|
||||
function drawSouthWall(ctx: CanvasRenderingContext2D, room: RoomSpec): void {
|
||||
const b = room.bounds;
|
||||
const y = b.y + b.h;
|
||||
const gapL = room.doorCx - 24;
|
||||
const gapR = room.doorCx + 24;
|
||||
ctx.fillStyle = PALETTE.wallFill;
|
||||
ctx.fillRect(b.x - WALL, y, gapL - b.x + WALL, WALL);
|
||||
ctx.fillRect(gapR, y, b.x + b.w - gapR + WALL, WALL);
|
||||
ctx.fillStyle = PALETTE.wallEdge;
|
||||
ctx.fillRect(b.x - WALL, y + WALL - 2, gapL - b.x + WALL, 2);
|
||||
ctx.fillRect(gapR, y + WALL - 2, b.x + b.w - gapR + WALL, 2);
|
||||
ctx.fillStyle = PALETTE.frame;
|
||||
ctx.fillRect(gapL - 3, y, 3, WALL);
|
||||
ctx.fillRect(gapR, y, 3, WALL);
|
||||
}
|
||||
|
||||
function drawRoomWindows(ctx: CanvasRenderingContext2D, room: RoomSpec): void {
|
||||
for (const rect of windowRects(room)) drawWindowFrame(ctx, rect);
|
||||
}
|
||||
|
||||
/** Window glass rects on each room's north wall face (world coordinates). */
|
||||
export function windowRects(room: RoomSpec): Rect[] {
|
||||
const b = room.bounds;
|
||||
const y = b.y + 8;
|
||||
const h = WALL_FACE_H - 20;
|
||||
if (room.kind === "restroom") {
|
||||
return [{ x: b.x + b.w - 190, y: y + 6, w: 44, h: h - 10 }];
|
||||
}
|
||||
if (room.kind === "snack") {
|
||||
return [{ x: b.x + 24, y, w: 72, h }];
|
||||
}
|
||||
const w = 72;
|
||||
return [
|
||||
{ x: b.x + 28, y, w, h },
|
||||
{ x: b.x + b.w - 28 - w, y, w, h },
|
||||
];
|
||||
}
|
||||
|
||||
function drawWindowFrame(ctx: CanvasRenderingContext2D, rect: Rect): void {
|
||||
ctx.fillStyle = PALETTE.frame;
|
||||
ctx.fillRect(rect.x - 3, rect.y - 3, rect.w + 6, rect.h + 6);
|
||||
const grad = ctx.createLinearGradient(0, rect.y, 0, rect.y + rect.h);
|
||||
grad.addColorStop(0, PALETTE.glassSky);
|
||||
grad.addColorStop(1, PALETTE.glassSkyLow);
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fillRect(rect.x, rect.y, rect.w, rect.h);
|
||||
ctx.fillStyle = PALETTE.frame;
|
||||
ctx.fillRect(rect.x + rect.w / 2 - 1, rect.y, 2, rect.h);
|
||||
}
|
||||
|
||||
function drawRoomFurniture(ctx: CanvasRenderingContext2D, plan: FloorPlan, room: RoomSpec): void {
|
||||
if (room.kind === "ceo") drawCeoFurniture(ctx, plan);
|
||||
if (room.kind === "meeting") drawMeetingFurniture(ctx);
|
||||
if (room.kind === "snack") drawSnackFurniture(ctx);
|
||||
if (room.kind === "restroom") drawRestroomFurniture(ctx);
|
||||
}
|
||||
|
||||
function drawCeoFurniture(ctx: CanvasRenderingContext2D, plan: FloorPlan): void {
|
||||
const desk = plan.ceoDesk;
|
||||
ctx.fillStyle = "#414b6e";
|
||||
ctx.fillRect(desk.x - 16, desk.y - 8, desk.w + 32, desk.h + 40);
|
||||
drawDesk(ctx, desk, true);
|
||||
drawSofa(ctx, 32, 96);
|
||||
drawPlant(ctx, 280, 48);
|
||||
ctx.fillStyle = PALETTE.frame;
|
||||
ctx.fillRect(190, 24, 34, 22);
|
||||
ctx.fillStyle = "#7f96b8";
|
||||
ctx.fillRect(192, 26, 30, 18);
|
||||
ctx.fillStyle = "#5c7a9c";
|
||||
ctx.fillRect(192, 36, 30, 8);
|
||||
}
|
||||
|
||||
function drawMeetingFurniture(ctx: CanvasRenderingContext2D): void {
|
||||
const table: Rect = { x: 392, y: 120, w: 160, h: 64 };
|
||||
ctx.fillStyle = PALETTE.deskWoodDark;
|
||||
ctx.fillRect(table.x, table.y, table.w, table.h);
|
||||
ctx.fillStyle = PALETTE.deskWood;
|
||||
ctx.fillRect(table.x + 3, table.y + 3, table.w - 6, table.h - 9);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
drawChair(ctx, table.x + 22 + i * 52, table.y - 16);
|
||||
drawChair(ctx, table.x + 22 + i * 52, table.y + table.h + 4);
|
||||
}
|
||||
ctx.fillStyle = "#f2efe8";
|
||||
ctx.fillRect(452, 22, 96, 30);
|
||||
ctx.fillStyle = PALETTE.frame;
|
||||
ctx.strokeStyle = PALETTE.frame;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(452, 22, 96, 30);
|
||||
ctx.fillStyle = "#b3543f";
|
||||
ctx.fillRect(460, 30, 26, 3);
|
||||
ctx.fillStyle = "#4f7fae";
|
||||
ctx.fillRect(460, 38, 40, 3);
|
||||
ctx.fillStyle = "#6d9c6b";
|
||||
ctx.fillRect(460, 44, 33, 3);
|
||||
}
|
||||
|
||||
function drawSnackFurniture(ctx: CanvasRenderingContext2D): void {
|
||||
ctx.fillStyle = "#8f8578";
|
||||
ctx.fillRect(656, 40, 224, 28);
|
||||
ctx.fillStyle = "#a99e8f";
|
||||
ctx.fillRect(656, 40, 224, 8);
|
||||
ctx.fillStyle = "#d5dade";
|
||||
ctx.fillRect(660, 8, 30, 48);
|
||||
ctx.fillStyle = "#b9c0c6";
|
||||
ctx.fillRect(660, 30, 30, 3);
|
||||
ctx.fillRect(682, 14, 4, 12);
|
||||
drawCoffeeMachine(ctx, 694, 26);
|
||||
ctx.fillStyle = "#c9a86a";
|
||||
ctx.fillRect(806, 30, 14, 12);
|
||||
ctx.fillStyle = "#b3543f";
|
||||
ctx.fillRect(826, 28, 12, 14);
|
||||
ctx.fillStyle = "#6d9c6b";
|
||||
ctx.fillRect(844, 30, 12, 12);
|
||||
ctx.fillStyle = "#94897b";
|
||||
ctx.fillRect(748, 170, 40, 40);
|
||||
ctx.fillStyle = "#a89d8e";
|
||||
ctx.fillRect(751, 173, 34, 34);
|
||||
drawStool(ctx, 738, 184);
|
||||
drawStool(ctx, 792, 184);
|
||||
}
|
||||
|
||||
function drawCoffeeMachine(ctx: CanvasRenderingContext2D, x: number, y: number): void {
|
||||
ctx.fillStyle = "#2e3238";
|
||||
ctx.fillRect(x, y, 22, 30);
|
||||
ctx.fillStyle = "#454b52";
|
||||
ctx.fillRect(x + 2, y + 2, 18, 8);
|
||||
ctx.fillStyle = "#c9553e";
|
||||
ctx.fillRect(x + 8, y + 12, 6, 4);
|
||||
ctx.fillStyle = "#d8d3c8";
|
||||
ctx.fillRect(x + 7, y + 20, 8, 7);
|
||||
}
|
||||
|
||||
function drawRestroomFurniture(ctx: CanvasRenderingContext2D): void {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const x = 1064 + i * 40;
|
||||
ctx.fillStyle = "#7d8a99";
|
||||
ctx.fillRect(x, 16, 40, 72);
|
||||
ctx.fillStyle = "#8f9dad";
|
||||
ctx.fillRect(x + 4, 28, 32, 58);
|
||||
ctx.fillStyle = "#6c7887";
|
||||
ctx.fillRect(x + 30, 54, 4, 8);
|
||||
}
|
||||
ctx.fillStyle = "#c6d2de";
|
||||
ctx.fillRect(928, 32, 96, 32);
|
||||
ctx.fillStyle = "#e4ebf2";
|
||||
for (let i = 0; i < 2; i++) ctx.fillRect(940 + i * 44, 38, 28, 14);
|
||||
ctx.fillStyle = "#aab8c6";
|
||||
ctx.fillRect(936, 18, 80, 10);
|
||||
}
|
||||
|
||||
function drawCorridorPlants(ctx: CanvasRenderingContext2D, plan: FloorPlan): void {
|
||||
void plan;
|
||||
drawPlant(ctx, 312, 300);
|
||||
drawPlant(ctx, 632, 300);
|
||||
drawPlant(ctx, 904, 300);
|
||||
}
|
||||
|
||||
function drawPlant(ctx: CanvasRenderingContext2D, cx: number, cy: number): void {
|
||||
ctx.fillStyle = "#8a5a3b";
|
||||
ctx.fillRect(cx - 7, cy, 14, 10);
|
||||
ctx.fillStyle = "#4e7a46";
|
||||
ctx.fillRect(cx - 9, cy - 12, 18, 12);
|
||||
ctx.fillStyle = "#659a58";
|
||||
ctx.fillRect(cx - 5, cy - 18, 10, 8);
|
||||
}
|
||||
|
||||
function drawSofa(ctx: CanvasRenderingContext2D, x: number, y: number): void {
|
||||
ctx.fillStyle = "#7a4a41";
|
||||
ctx.fillRect(x, y, 24, 72);
|
||||
ctx.fillStyle = "#94594e";
|
||||
ctx.fillRect(x + 8, y + 4, 16, 64);
|
||||
}
|
||||
|
||||
function drawChair(ctx: CanvasRenderingContext2D, x: number, y: number): void {
|
||||
ctx.fillStyle = PALETTE.chairDark;
|
||||
ctx.fillRect(x, y, 16, 12);
|
||||
ctx.fillStyle = PALETTE.chair;
|
||||
ctx.fillRect(x + 2, y + 2, 12, 8);
|
||||
}
|
||||
|
||||
function drawStool(ctx: CanvasRenderingContext2D, x: number, y: number): void {
|
||||
ctx.fillStyle = "#6d5334";
|
||||
ctx.fillRect(x, y, 10, 10);
|
||||
}
|
||||
|
||||
function drawDesk(ctx: CanvasRenderingContext2D, desk: Rect, executive: boolean): void {
|
||||
ctx.fillStyle = PALETTE.deskLeg;
|
||||
ctx.fillRect(desk.x + 4, desk.y + desk.h - 6, 5, 10);
|
||||
ctx.fillRect(desk.x + desk.w - 9, desk.y + desk.h - 6, 5, 10);
|
||||
ctx.fillStyle = executive ? "#5f4a33" : PALETTE.deskWoodDark;
|
||||
ctx.fillRect(desk.x, desk.y, desk.w, desk.h);
|
||||
ctx.fillStyle = executive ? "#75593b" : PALETTE.deskWood;
|
||||
ctx.fillRect(desk.x + 2, desk.y + 2, desk.w - 4, desk.h - 7);
|
||||
|
||||
const screen = deskScreenRect(desk);
|
||||
ctx.fillStyle = PALETTE.monitorShell;
|
||||
ctx.fillRect(screen.x - 2, screen.y - 2, screen.w + 4, screen.h + 4);
|
||||
ctx.fillStyle = PALETTE.screenOff;
|
||||
ctx.fillRect(screen.x, screen.y, screen.w, screen.h);
|
||||
ctx.fillStyle = PALETTE.monitorShell;
|
||||
ctx.fillRect(screen.x + screen.w / 2 - 3, screen.y + screen.h + 2, 6, 3);
|
||||
if (executive) {
|
||||
ctx.fillStyle = "#d8d3c8";
|
||||
ctx.fillRect(desk.x + desk.w - 22, desk.y + 8, 12, 8);
|
||||
ctx.fillStyle = "#4e7a46";
|
||||
ctx.fillRect(desk.x + 8, desk.y + 6, 8, 10);
|
||||
}
|
||||
ctx.fillStyle = "#23262b";
|
||||
ctx.fillRect(desk.x + desk.w / 2 - 8, desk.y + desk.h - 12, 16, 5);
|
||||
drawChair(ctx, desk.x + desk.w / 2 - 8, desk.y + desk.h + 4);
|
||||
}
|
||||
|
||||
function drawDoorSign(ctx: CanvasRenderingContext2D, room: RoomSpec): void {
|
||||
const label = room.label;
|
||||
ctx.font = "bold 8px monospace";
|
||||
const width = ctx.measureText(label).width + 10;
|
||||
const x = room.doorCx - width / 2;
|
||||
const y = room.bounds.y + room.bounds.h - 13;
|
||||
ctx.fillStyle = PALETTE.signPlate;
|
||||
ctx.fillRect(x, y, width, 12);
|
||||
ctx.fillStyle = PALETTE.signText;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText(label, room.doorCx, y + 7);
|
||||
ctx.textAlign = "start";
|
||||
ctx.textBaseline = "alphabetic";
|
||||
}
|
||||
|
||||
function drawEntrance(ctx: CanvasRenderingContext2D, plan: FloorPlan): void {
|
||||
void plan;
|
||||
ctx.fillStyle = PALETTE.frame;
|
||||
ctx.fillRect(0, 292, 4, 56);
|
||||
ctx.fillStyle = "#7fb3d9";
|
||||
ctx.fillRect(4, 296, 4, 48);
|
||||
ctx.fillStyle = "#8d6a45";
|
||||
ctx.fillRect(20, 300, 26, 40);
|
||||
ctx.fillStyle = "#9c7852";
|
||||
ctx.fillRect(22, 302, 22, 36);
|
||||
}
|
||||
|
||||
function rectsTouch(a: Rect, b: Rect): boolean {
|
||||
return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
|
||||
}
|
||||
|
||||
function expand(rect: Rect, margin: number): Rect {
|
||||
return { x: rect.x - margin, y: rect.y - margin, w: rect.w + margin * 2, h: rect.h + margin * 2 };
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { buildFloorPlan } from "./floor-plan";
|
||||
import type { Camera, FloorPlan, OfficeAgentInput, Vec } from "./office-types";
|
||||
|
||||
export interface AgentSim {
|
||||
input: OfficeAgentInput;
|
||||
x: number;
|
||||
y: number;
|
||||
seat: Vec;
|
||||
deskIndex: number | null;
|
||||
isCeo: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds the living state of the office: who sits where and where everyone
|
||||
* currently stands. Snapshot changes flow in through `sync`, which keeps
|
||||
* existing positions stable so people never teleport when data refreshes.
|
||||
*/
|
||||
export class OfficeSimulation {
|
||||
plan: FloorPlan;
|
||||
agents: AgentSim[] = [];
|
||||
private openDeskDemand = -1;
|
||||
|
||||
constructor() {
|
||||
this.plan = buildFloorPlan(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconciles simulation state with the latest snapshot. The first
|
||||
* `c_suite` agent (stable input order) occupies the CEO office; everyone
|
||||
* else takes open-floor desks in order.
|
||||
*/
|
||||
sync(inputs: OfficeAgentInput[]): void {
|
||||
const ceoId = inputs.find((input) => input.roleLevel === "c_suite")?.id ?? null;
|
||||
const openCount = inputs.filter((input) => input.id !== ceoId).length;
|
||||
if (openCount !== this.openDeskDemand) {
|
||||
this.openDeskDemand = openCount;
|
||||
this.plan = buildFloorPlan(openCount);
|
||||
}
|
||||
|
||||
const existing = new Map(this.agents.map((agent) => [agent.input.id, agent]));
|
||||
let openIndex = 0;
|
||||
this.agents = inputs.map((input) => {
|
||||
const isCeo = input.id === ceoId;
|
||||
const deskIndex = isCeo ? null : openIndex++;
|
||||
const seat = isCeo ? this.plan.ceoSeat : this.plan.desks[deskIndex!].seat;
|
||||
const prev = existing.get(input.id);
|
||||
return {
|
||||
input,
|
||||
x: prev?.x ?? seat.x,
|
||||
y: prev?.y ?? seat.y,
|
||||
seat,
|
||||
deskIndex,
|
||||
isCeo,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Camera top-left for a viewport centered on `focus`, clamped to the world.
|
||||
* When the world is smaller than the viewport on an axis, the world is
|
||||
* centered on that axis instead.
|
||||
*/
|
||||
export function cameraFor(world: { w: number; h: number }, viewW: number, viewH: number, focus: Vec): Camera {
|
||||
return {
|
||||
x: axisCamera(world.w, viewW, focus.x),
|
||||
y: axisCamera(world.h, viewH, focus.y),
|
||||
};
|
||||
}
|
||||
|
||||
function axisCamera(worldSize: number, viewSize: number, focus: number): number {
|
||||
if (worldSize <= viewSize) return -(viewSize - worldSize) / 2;
|
||||
return Math.min(Math.max(focus - viewSize / 2, 0), worldSize - viewSize);
|
||||
}
|
||||
@@ -48,6 +48,7 @@ test("captures every page for visual review", async () => {
|
||||
await go(page, "Approvals", outDir, "15-approvals");
|
||||
await go(page, "Social", outDir, "16-social");
|
||||
await go(page, "Settings", outDir, "17-settings");
|
||||
await go(page, "Office", outDir, "18-office");
|
||||
|
||||
for (const tab of ["Workspace", "Billing", "Skills", "Migration"]) {
|
||||
await page.getByRole("button", { name: "Settings" }).click();
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { buildFloorPlan, findPath } from "../../src/renderer/src/pages/office/floor-plan";
|
||||
import type { FloorPlan, Rect, Vec } from "../../src/renderer/src/pages/office/office-types";
|
||||
|
||||
function segmentIntersectsRect(a: Vec, b: Vec, rect: Rect): boolean {
|
||||
if (a.x === b.x) {
|
||||
const [y1, y2] = [Math.min(a.y, b.y), Math.max(a.y, b.y)];
|
||||
return a.x > rect.x && a.x < rect.x + rect.w && y2 > rect.y && y1 < rect.y + rect.h;
|
||||
}
|
||||
const [x1, x2] = [Math.min(a.x, b.x), Math.max(a.x, b.x)];
|
||||
return a.y > rect.y && a.y < rect.y + rect.h && x2 > rect.x && x1 < rect.x + rect.w;
|
||||
}
|
||||
|
||||
function expectWalkablePath(plan: FloorPlan, path: Vec[]): void {
|
||||
expect(path.length).toBeGreaterThanOrEqual(2);
|
||||
for (let i = 0; i + 1 < path.length; i++) {
|
||||
const from = path[i];
|
||||
const to = path[i + 1];
|
||||
expect(from.x === to.x || from.y === to.y).toBe(true);
|
||||
for (const collider of plan.colliders) {
|
||||
expect(segmentIntersectsRect(from, to, collider)).toBe(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("office floor plan", () => {
|
||||
test("provides the four rooms of a real office", () => {
|
||||
const plan = buildFloorPlan(5);
|
||||
expect(plan.rooms.map((room) => room.kind)).toEqual(["ceo", "meeting", "snack", "restroom"]);
|
||||
expect(plan.amenities.filter((a) => a.kind === "restroom").length).toBeGreaterThanOrEqual(2);
|
||||
expect(plan.amenities.some((a) => a.kind === "coffee")).toBe(true);
|
||||
expect(plan.amenities.some((a) => a.kind === "snack")).toBe(true);
|
||||
});
|
||||
|
||||
test("desk field grows by rows so no head-count is ever cut off", () => {
|
||||
const small = buildFloorPlan(3);
|
||||
const large = buildFloorPlan(63);
|
||||
expect(small.desks.length).toBeGreaterThanOrEqual(3);
|
||||
expect(large.desks.length).toBeGreaterThanOrEqual(63);
|
||||
expect(large.world.h).toBeGreaterThan(small.world.h);
|
||||
expect(large.world.w).toBe(small.world.w);
|
||||
});
|
||||
|
||||
test("desk seats and amenity stands never sit inside a collider", () => {
|
||||
const plan = buildFloorPlan(28);
|
||||
const standing = (p: Vec): Rect => ({ x: p.x - 7, y: p.y - 7, w: 14, h: 14 });
|
||||
const spots = [plan.ceoSeat, ...plan.desks.map((d) => d.seat), ...plan.amenities.map((a) => a.stand)];
|
||||
for (const spot of spots) {
|
||||
const body = standing(spot);
|
||||
for (const collider of plan.colliders) {
|
||||
const overlap =
|
||||
body.x < collider.x + collider.w &&
|
||||
body.x + body.w > collider.x &&
|
||||
body.y < collider.y + collider.h &&
|
||||
body.y + body.h > collider.y;
|
||||
expect(overlap).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("every desk, the CEO seat, and every amenity is reachable from the entrance", () => {
|
||||
const plan = buildFloorPlan(17);
|
||||
const targets = [plan.ceoSeat, ...plan.desks.map((d) => d.seat), ...plan.amenities.map((a) => a.stand)];
|
||||
for (const target of targets) {
|
||||
expectWalkablePath(plan, findPath(plan, plan.entrance, target));
|
||||
}
|
||||
});
|
||||
|
||||
test("routes between desks and amenities across the whole office", () => {
|
||||
const plan = buildFloorPlan(20);
|
||||
const lastDesk = plan.desks[plan.desks.length - 1];
|
||||
const coffee = plan.amenities.find((a) => a.kind === "coffee")!;
|
||||
const restroom = plan.amenities.find((a) => a.kind === "restroom")!;
|
||||
expectWalkablePath(plan, findPath(plan, lastDesk.seat, coffee.stand));
|
||||
expectWalkablePath(plan, findPath(plan, coffee.stand, restroom.stand));
|
||||
expectWalkablePath(plan, findPath(plan, restroom.stand, plan.ceoSeat));
|
||||
});
|
||||
|
||||
test("re-routes from an arbitrary mid-walk position", () => {
|
||||
const plan = buildFloorPlan(10);
|
||||
const midCorridor = { x: 500, y: 320 };
|
||||
const firstDesk = plan.desks[0];
|
||||
expectWalkablePath(plan, findPath(plan, midCorridor, firstDesk.seat));
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user