From d92e1e8b17312694a99c7856ac9ea25696ef29d6 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:48:13 -0400 Subject: [PATCH] Walk the office as the board member with WASD The signed-in employee now appears automatically at the office entrance in a blazer with an accent name chip, and moves with WASD or arrow keys: per-axis wall sliding against the floor-plan colliders, normalized diagonal speed, facing-aware walk animation, and a camera that follows and clamps to the world so large teams scroll into view instead of being cut off. Key input ignores typing targets and releases on window blur. Movement, collision, spawn stability, and camera clamping are unit-tested. --- .../src/renderer/src/pages/OfficePage.tsx | 77 ++++++++++-- .../src/pages/office/office-renderer.ts | 3 +- .../renderer/src/pages/office/simulation.ts | 92 ++++++++++++++- .../tests/unit/office-simulation.test.ts | 110 ++++++++++++++++++ 4 files changed, 273 insertions(+), 9 deletions(-) create mode 100644 AgentCompany-New/tests/unit/office-simulation.test.ts 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); + }); +});