diff --git a/AgentCompany-New/src/renderer/src/pages/OfficePage.tsx b/AgentCompany-New/src/renderer/src/pages/OfficePage.tsx index b55cd5c8..6fca585c 100644 --- a/AgentCompany-New/src/renderer/src/pages/OfficePage.tsx +++ b/AgentCompany-New/src/renderer/src/pages/OfficePage.tsx @@ -1,15 +1,28 @@ import { useEffect, useMemo, useRef } from "react"; import type { PageProps } from "./types"; -import { activeOrgId } from "./types"; +import { activeOrgId, requireActiveEmployee } from "./types"; import { hashString, renderOffice, type RenderPerson } from "./office/office-renderer"; -import { OfficeSimulation, cameraFor } from "./office/simulation"; +import { OfficeSimulation, cameraFor, type MoveInput } from "./office/simulation"; import type { AgentActivity, OfficeAgentInput } from "./office/office-types"; const FPS_INTERVAL = 1000 / 24; +const KEY_BINDINGS: Record = { + KeyW: "up", + KeyS: "down", + KeyA: "left", + KeyD: "right", + ArrowUp: "up", + ArrowDown: "down", + ArrowLeft: "left", + ArrowRight: "right", +}; + export function OfficePage({ snapshot }: PageProps) { const orgId = activeOrgId(snapshot); + const employee = requireActiveEmployee(snapshot); const canvasRef = useRef(null); + const keysRef = useRef({ up: false, down: false, left: false, right: false }); const simRef = useRef(null); if (simRef.current === null) simRef.current = new OfficeSimulation(); @@ -34,6 +47,38 @@ export function OfficePage({ snapshot }: PageProps) { simRef.current!.sync(inputs); }, [inputs]); + useEffect(() => { + simRef.current!.syncPlayer(employee.id, employee.displayName); + }, [employee.id, employee.displayName]); + + useEffect(() => { + const isTypingTarget = (target: EventTarget | null): boolean => + target instanceof HTMLElement && + (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT" || target.isContentEditable); + const onKeyDown = (event: KeyboardEvent) => { + const key = KEY_BINDINGS[event.code]; + if (!key || event.metaKey || event.ctrlKey || event.altKey || isTypingTarget(event.target)) return; + event.preventDefault(); + keysRef.current[key] = true; + }; + const onKeyUp = (event: KeyboardEvent) => { + const key = KEY_BINDINGS[event.code]; + if (!key) return; + keysRef.current[key] = false; + }; + const releaseAll = () => { + keysRef.current = { up: false, down: false, left: false, right: false }; + }; + window.addEventListener("keydown", onKeyDown); + window.addEventListener("keyup", onKeyUp); + window.addEventListener("blur", releaseAll); + return () => { + window.removeEventListener("keydown", onKeyDown); + window.removeEventListener("keyup", onKeyUp); + window.removeEventListener("blur", releaseAll); + }; + }, []); + useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; @@ -47,7 +92,8 @@ export function OfficePage({ snapshot }: PageProps) { const tick = (now: number) => { if (!running) return; raf = requestAnimationFrame(tick); - if (now - lastFrame < FPS_INTERVAL) return; + const elapsed = now - lastFrame; + if (elapsed < FPS_INTERVAL) return; lastFrame = now; const viewW = canvas.clientWidth; @@ -63,10 +109,9 @@ export function OfficePage({ snapshot }: PageProps) { ctx.setTransform(dpr, 0, 0, dpr, 0, 0); const sim = simRef.current!; - const camera = cameraFor(sim.plan.world, viewW, viewH, { - x: sim.plan.world.w / 2, - y: viewH / 2, - }); + sim.step(elapsed, keysRef.current); + const focus = sim.player ? { x: sim.player.x, y: sim.player.y } : { x: sim.plan.world.w / 2, y: viewH / 2 }; + const camera = cameraFor(sim.plan.world, viewW, viewH, focus); const people: RenderPerson[] = sim.agents.map((agent) => ({ id: agent.input.id, name: agent.input.name, @@ -75,12 +120,30 @@ export function OfficePage({ snapshot }: PageProps) { y: agent.y, desk: agent.isCeo ? sim.plan.ceoDesk : sim.plan.desks[agent.deskIndex!].bounds, isCeo: agent.isCeo, + isPlayer: false, animSeed: hashString(agent.input.id) % 100, appearance: agent.appearance, facing: agent.facing, walkPhase: agent.walkPhase, carrying: agent.carrying, })); + if (sim.player) { + people.push({ + id: `board:${sim.player.employeeId}`, + name: sim.player.name, + activity: "idle", + x: sim.player.x, + y: sim.player.y, + desk: null, + isCeo: false, + isPlayer: true, + animSeed: hashString(sim.player.employeeId) % 100, + appearance: sim.player.appearance, + facing: sim.player.facing, + walkPhase: sim.player.walkPhase, + carrying: null, + }); + } renderOffice(ctx, viewW, viewH, sim.plan, camera, people, now); }; 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 f69c6cd4..205477dd 100644 --- a/AgentCompany-New/src/renderer/src/pages/office/office-renderer.ts +++ b/AgentCompany-New/src/renderer/src/pages/office/office-renderer.ts @@ -10,6 +10,7 @@ export interface RenderPerson { y: number; desk: Rect | null; isCeo: boolean; + isPlayer: boolean; animSeed: number; appearance: SpriteAppearance; facing: Facing; @@ -92,7 +93,7 @@ function drawNameLabel(ctx: CanvasRenderingContext2D, person: RenderPerson): voi 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)"; + ctx.fillStyle = person.isPlayer ? "rgba(94,106,210,0.92)" : "rgba(22,22,28,0.78)"; drawRoundedRect(ctx, x, y, width, 13, 4); ctx.fillStyle = "#f2f2f5"; ctx.textAlign = "center"; diff --git a/AgentCompany-New/src/renderer/src/pages/office/simulation.ts b/AgentCompany-New/src/renderer/src/pages/office/simulation.ts index 82427cc0..9391a154 100644 --- a/AgentCompany-New/src/renderer/src/pages/office/simulation.ts +++ b/AgentCompany-New/src/renderer/src/pages/office/simulation.ts @@ -1,5 +1,5 @@ import { buildFloorPlan } from "./floor-plan"; -import type { Camera, FloorPlan, OfficeAgentInput, Vec } from "./office-types"; +import type { Camera, FloorPlan, OfficeAgentInput, Rect, Vec } from "./office-types"; import { appearanceFor, type Carrying, type Facing, type SpriteAppearance } from "./sprites"; export interface AgentSim { @@ -15,6 +15,27 @@ export interface AgentSim { carrying: Carrying; } +export interface PlayerSim { + employeeId: string; + name: string; + x: number; + y: number; + facing: Facing; + walkPhase: number; + appearance: SpriteAppearance; +} + +export interface MoveInput { + up: boolean; + down: boolean; + left: boolean; + right: boolean; +} + +const PLAYER_SPEED = 0.14; +const MAX_STEP_MS = 100; +const WALK_CYCLE = 0.02; + /** * Holds the living state of the office: who sits where and where everyone * currently stands. Snapshot changes flow in through `sync`, which keeps @@ -23,12 +44,69 @@ export interface AgentSim { export class OfficeSimulation { plan: FloorPlan; agents: AgentSim[] = []; + player: PlayerSim | null = null; private openDeskDemand = -1; constructor() { this.plan = buildFloorPlan(0); } + /** + * Ensures the signed-in employee walks the floor as the board member. The + * avatar spawns at the entrance once and keeps its position across + * snapshot refreshes. + */ + syncPlayer(employeeId: string, displayName: string): void { + if (this.player && this.player.employeeId === employeeId) { + this.player.name = displayName; + return; + } + const boardLook = appearanceFor(employeeId, false); + this.player = { + employeeId, + name: displayName, + x: this.plan.entrance.x, + y: this.plan.entrance.y, + facing: "east", + walkPhase: 0, + appearance: { ...boardLook, outfit: "blazer", accent: "#e5c65a", hairStyle: boardLook.hairStyle === "bald" ? "short" : boardLook.hairStyle }, + }; + } + + /** Advances the world by one frame: board-member movement and collision. */ + step(dtMs: number, input: MoveInput): void { + const dt = Math.min(dtMs, MAX_STEP_MS); + if (this.player) this.stepPlayer(this.player, dt, input); + } + + private stepPlayer(player: PlayerSim, dt: number, input: MoveInput): void { + let dx = (input.right ? 1 : 0) - (input.left ? 1 : 0); + let dy = (input.down ? 1 : 0) - (input.up ? 1 : 0); + if (dx === 0 && dy === 0) { + player.walkPhase = 0; + return; + } + const norm = Math.hypot(dx, dy); + dx /= norm; + dy /= norm; + const distance = PLAYER_SPEED * dt; + player.x = this.slideAxis(player.x, player.y, dx * distance, 0).x; + player.y = this.slideAxis(player.x, player.y, 0, dy * distance).y; + player.x = clamp(player.x, 24, this.plan.world.w - 24); + player.y = clamp(player.y, 40, this.plan.world.h - 8); + player.facing = Math.abs(dx) >= Math.abs(dy) ? (dx < 0 ? "west" : "east") : (dy < 0 ? "north" : "south"); + player.walkPhase += dt * WALK_CYCLE; + } + + private slideAxis(x: number, y: number, dx: number, dy: number): Vec { + const next = { x: x + dx, y: y + dy }; + const body = footBox(next); + for (const collider of this.plan.colliders) { + if (boxesOverlap(body, collider)) return { x, y }; + } + return next; + } + /** * Reconciles simulation state with the latest snapshot. The first * `c_suite` agent (stable input order) occupies the CEO office; everyone @@ -81,3 +159,15 @@ 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); } + +function footBox(position: Vec): Rect { + return { x: position.x - 6, y: position.y - 8, w: 12, h: 10 }; +} + +function boxesOverlap(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 clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} diff --git a/AgentCompany-New/tests/unit/office-simulation.test.ts b/AgentCompany-New/tests/unit/office-simulation.test.ts new file mode 100644 index 00000000..986f5769 --- /dev/null +++ b/AgentCompany-New/tests/unit/office-simulation.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, test } from "vitest"; +import { OfficeSimulation, cameraFor, type MoveInput } from "../../src/renderer/src/pages/office/simulation"; +import type { OfficeAgentInput } from "../../src/renderer/src/pages/office/office-types"; + +const IDLE: MoveInput = { up: false, down: false, left: false, right: false }; + +function agentInput(id: string, roleLevel = "staff"): OfficeAgentInput { + return { + id, + name: `Agent ${id}`, + role: "Engineer", + title: "Engineer", + department: "Engineering", + roleLevel, + activity: "idle", + taskTitle: null, + }; +} + +function steps(sim: OfficeSimulation, ms: number, input: Partial): void { + const move = { ...IDLE, ...input }; + for (let i = 0; i < ms / 16; i++) sim.step(16, move); +} + +describe("office simulation", () => { + test("the CEO takes the CEO office and staff fill open desks in order", () => { + const sim = new OfficeSimulation(); + sim.sync([agentInput("a"), agentInput("ceo", "c_suite"), agentInput("b")]); + const ceo = sim.agents.find((agent) => agent.input.id === "ceo")!; + const staffA = sim.agents.find((agent) => agent.input.id === "a")!; + const staffB = sim.agents.find((agent) => agent.input.id === "b")!; + expect(ceo.isCeo).toBe(true); + expect(ceo.seat).toEqual(sim.plan.ceoSeat); + expect(staffA.deskIndex).toBe(0); + expect(staffB.deskIndex).toBe(1); + }); + + test("positions survive snapshot refreshes without teleporting", () => { + const sim = new OfficeSimulation(); + sim.sync([agentInput("a")]); + sim.agents[0].x = 500; + sim.agents[0].y = 320; + sim.sync([agentInput("a")]); + expect(sim.agents[0].x).toBe(500); + expect(sim.agents[0].y).toBe(320); + }); + + test("the board member spawns at the entrance and keeps position on re-sync", () => { + const sim = new OfficeSimulation(); + sim.syncPlayer("emp-1", "Dana Lee"); + expect(sim.player!.x).toBe(sim.plan.entrance.x); + expect(sim.player!.y).toBe(sim.plan.entrance.y); + steps(sim, 600, { right: true }); + const movedX = sim.player!.x; + sim.syncPlayer("emp-1", "Dana Lee"); + expect(sim.player!.x).toBe(movedX); + }); + + test("WASD input moves and faces the board member", () => { + const sim = new OfficeSimulation(); + sim.syncPlayer("emp-1", "Dana Lee"); + const startX = sim.player!.x; + steps(sim, 320, { right: true }); + expect(sim.player!.x).toBeGreaterThan(startX + 30); + expect(sim.player!.facing).toBe("east"); + expect(sim.player!.walkPhase).toBeGreaterThan(0); + sim.step(16, IDLE); + expect(sim.player!.walkPhase).toBe(0); + }); + + test("walls stop the board member", () => { + const sim = new OfficeSimulation(); + sim.syncPlayer("emp-1", "Dana Lee"); + steps(sim, 4000, { left: true }); + expect(sim.player!.x).toBeGreaterThanOrEqual(22); + const y = sim.player!.y; + steps(sim, 4000, { up: true }); + expect(sim.player!.y).toBeGreaterThan(288); + expect(sim.player!.y).toBeLessThanOrEqual(y); + }); + + test("diagonal movement is not faster than straight movement", () => { + const sim = new OfficeSimulation(); + sim.syncPlayer("emp-1", "Dana Lee"); + const start = { x: sim.player!.x, y: sim.player!.y }; + steps(sim, 160, { right: true, down: true }); + const diagonal = Math.hypot(sim.player!.x - start.x, sim.player!.y - start.y); + const sim2 = new OfficeSimulation(); + sim2.syncPlayer("emp-2", "Alex"); + const start2 = sim2.player!.x; + steps(sim2, 160, { right: true }); + const straight = sim2.player!.x - start2; + expect(diagonal).toBeLessThanOrEqual(straight + 0.5); + expect(diagonal).toBeGreaterThan(straight * 0.7); + }); + + test("camera follows the player and clamps at world edges", () => { + const sim = new OfficeSimulation(); + sim.sync(Array.from({ length: 30 }, (_, i) => agentInput(`a${i}`))); + const world = sim.plan.world; + const centered = cameraFor(world, 600, 400, { x: 700, y: 700 }); + expect(centered).toEqual({ x: 400, y: 500 }); + const topLeft = cameraFor(world, 600, 400, { x: 0, y: 0 }); + expect(topLeft).toEqual({ x: 0, y: 0 }); + const bottomRight = cameraFor(world, 600, 400, { x: world.w, y: world.h }); + expect(bottomRight).toEqual({ x: world.w - 600, y: world.h - 400 }); + const wide = cameraFor(world, world.w + 200, 400, { x: 0, y: 0 }); + expect(wide.x).toBe(-100); + }); +});