From cab4e4ba3307fe1f96739a1cf43d1f29f1adf733 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 6 Jul 2026 21:39:16 -0400 Subject: [PATCH] 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. --- .../src/renderer/src/pages/OfficePage.tsx | 169 ++++---- .../renderer/src/pages/office/floor-plan.ts | 310 +++++++++++++++ .../src/pages/office/office-renderer.ts | 311 +++++---------- .../renderer/src/pages/office/office-types.ts | 81 ++++ .../src/pages/office/scene-renderer.ts | 374 ++++++++++++++++++ .../renderer/src/pages/office/simulation.ts | 74 ++++ .../tests/e2e/screenshots.spec.ts | 1 + .../tests/unit/office-floor-plan.test.ts | 85 ++++ 8 files changed, 1098 insertions(+), 307 deletions(-) create mode 100644 AgentCompany-New/src/renderer/src/pages/office/floor-plan.ts create mode 100644 AgentCompany-New/src/renderer/src/pages/office/office-types.ts create mode 100644 AgentCompany-New/src/renderer/src/pages/office/scene-renderer.ts create mode 100644 AgentCompany-New/src/renderer/src/pages/office/simulation.ts create mode 100644 AgentCompany-New/tests/unit/office-floor-plan.test.ts diff --git a/AgentCompany-New/src/renderer/src/pages/OfficePage.tsx b/AgentCompany-New/src/renderer/src/pages/OfficePage.tsx index ab1f06dc..d48b678b 100644 --- a/AgentCompany-New/src/renderer/src/pages/OfficePage.tsx +++ b/AgentCompany-New/src/renderer/src/pages/OfficePage.tsx @@ -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(null); - const agentsRef = useRef([]); - const animRef = useRef(0); - const lastFrameRef = useRef(0); + const simRef = useRef(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(() => { + 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"; } diff --git a/AgentCompany-New/src/renderer/src/pages/office/floor-plan.ts b/AgentCompany-New/src/renderer/src/pages/office/floor-plan.ts new file mode 100644 index 00000000..cefb207b --- /dev/null +++ b/AgentCompany-New/src/renderer/src/pages/office/floor-plan.ts @@ -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(); + const edges = new Set(); + + 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(); + 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(); + 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; +} diff --git a/AgentCompany-New/src/renderer/src/pages/office/office-renderer.ts b/AgentCompany-New/src/renderer/src/pages/office/office-renderer.ts index 498e211e..4543cd3d 100644 --- a/AgentCompany-New/src/renderer/src/pages/office/office-renderer.ts +++ b/AgentCompany-New/src/renderer/src/pages/office/office-renderer.ts @@ -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> = { + 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(); } diff --git a/AgentCompany-New/src/renderer/src/pages/office/office-types.ts b/AgentCompany-New/src/renderer/src/pages/office/office-types.ts new file mode 100644 index 00000000..a4ffe1db --- /dev/null +++ b/AgentCompany-New/src/renderer/src/pages/office/office-types.ts @@ -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; +} diff --git a/AgentCompany-New/src/renderer/src/pages/office/scene-renderer.ts b/AgentCompany-New/src/renderer/src/pages/office/scene-renderer.ts new file mode 100644 index 00000000..06d1b9d2 --- /dev/null +++ b/AgentCompany-New/src/renderer/src/pages/office/scene-renderer.ts @@ -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 }; +} diff --git a/AgentCompany-New/src/renderer/src/pages/office/simulation.ts b/AgentCompany-New/src/renderer/src/pages/office/simulation.ts new file mode 100644 index 00000000..0f976214 --- /dev/null +++ b/AgentCompany-New/src/renderer/src/pages/office/simulation.ts @@ -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); +} diff --git a/AgentCompany-New/tests/e2e/screenshots.spec.ts b/AgentCompany-New/tests/e2e/screenshots.spec.ts index e6b1872e..6eb8af44 100644 --- a/AgentCompany-New/tests/e2e/screenshots.spec.ts +++ b/AgentCompany-New/tests/e2e/screenshots.spec.ts @@ -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(); diff --git a/AgentCompany-New/tests/unit/office-floor-plan.test.ts b/AgentCompany-New/tests/unit/office-floor-plan.test.ts new file mode 100644 index 00000000..fcca49ca --- /dev/null +++ b/AgentCompany-New/tests/unit/office-floor-plan.test.ts @@ -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)); + }); +});