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.
This commit is contained in:
1 parent
e6532713b5
commit
d92e1e8b17
4 files changed
+273
-9
No files matched your search
@@ -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<string, keyof MoveInput> = {
|
||||
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<HTMLCanvasElement>(null);
|
||||
const keysRef = useRef<MoveInput>({ up: false, down: false, left: false, right: false });
|
||||
const simRef = useRef<OfficeSimulation | null>(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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<MoveInput>): 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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user