S3.6: isometric 2.5D office engine: projection, depth-sorted blocks and people, half-resolution pixel rendering, day-part and season palette

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-09 13:03:04 -04:00
1 parent 5dfc2a6dc6
commit 23c7d55b1d
8 files changed
+514 -432

No files matched your search

+40 -17
View File
@@ -7,7 +7,10 @@ import { OfficeSimulation, cameraFor, type MoveInput } from "./office/simulation
import { AgentDetailCard } from "./office/AgentDetailCard";
import { officeActivityLabel } from "../i18n/labels";
import { cardPositionFor, CARD_FALLBACK_H } from "./office/agent-card-position";
import type { AgentActivity, Camera, OfficeAgentInput, Vec } from "./office/office-types";
import { isoFrame, isoPixelSize, pickAt, toIso, type IsoFrame } from "./office/iso";
import { dayPart, season } from "../shared/palette-clock";
import { pixelScale } from "../shared/pixel-scale";
import type { AgentActivity, Camera, OfficeAgentInput } from "./office/office-types";
import { t } from "../i18n/i18n";
const FPS_INTERVAL = 1000 / 24;
@@ -29,6 +32,8 @@ export function OfficePage({ snapshot }: PageProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const keysRef = useRef<MoveInput>({ up: false, down: false, left: false, right: false });
const cameraRef = useRef<Camera>({ x: 0, y: 0 });
/** How the last frame mapped plan to screen, for turning clicks into picks. */
const viewRef = useRef<{ frame: IsoFrame; pixel: number } | null>(null);
const selectedIdRef = useRef<string | null>(null);
const cardBoxRef = useRef<HTMLDivElement>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
@@ -112,6 +117,7 @@ export function OfficePage({ snapshot }: PageProps) {
let running = true;
let raf = 0;
let lastFrame = 0;
const scene = document.createElement("canvas");
const tick = (now: number) => {
if (!running) return;
@@ -134,9 +140,12 @@ export function OfficePage({ snapshot }: PageProps) {
const sim = simRef.current!;
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 frame = isoFrame(sim.plan.world);
const pixel = isoPixelSize(dpr);
const focus = sim.player ? { x: sim.player.x, y: sim.player.y } : { x: sim.plan.world.w / 2, y: sim.plan.world.h / 2 };
const camera = cameraFor(frame.size, viewW / pixel, viewH / pixel, toIso(frame, focus));
cameraRef.current = camera;
viewRef.current = { frame, pixel };
const people: RenderPerson[] = sim.agents.map((agent) => ({
id: agent.input.id,
name: agent.input.name,
@@ -169,7 +178,22 @@ export function OfficePage({ snapshot }: PageProps) {
carrying: null,
});
}
renderOffice(ctx, viewW, viewH, sim.plan, camera, people, now, new Date(), selectedIdRef.current);
const clock = new Date();
renderOffice(ctx, scene, {
frame,
plan: sim.plan,
camera,
pixel,
fontScale: pixelScale(dpr),
viewW,
viewH,
people,
timeMs: now,
clock,
dayPart: dayPart(clock),
season: season(clock),
selectedId: selectedIdRef.current,
});
// The detail card rides beside its agent, even while they walk.
const cardBox = cardBoxRef.current;
@@ -178,7 +202,8 @@ export function OfficePage({ snapshot }: PageProps) {
: null;
if (cardBox && selected) {
const cardH = cardBox.offsetHeight || CARD_FALLBACK_H;
const position = cardPositionFor({ x: selected.x, y: selected.y }, camera, viewW, viewH, cardH);
const feet = toIso(frame, selected);
const position = cardPositionFor({ x: (feet.x - camera.x) * pixel, y: (feet.y - camera.y) * pixel }, { x: 0, y: 0 }, viewW, viewH, cardH);
cardBox.style.transform = `translate(${position.left}px, ${position.top}px)`;
cardBox.style.visibility = "visible";
}
@@ -191,28 +216,26 @@ export function OfficePage({ snapshot }: PageProps) {
};
}, []);
const worldPointFrom = (event: MouseEvent<HTMLCanvasElement>): Vec | null => {
/** The agent drawn under the pointer, picked in the same iso pixels the frame was drawn in. */
const agentAt = (event: MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
if (!canvas) return null;
const view = viewRef.current;
if (!canvas || !view) return null;
const bounds = canvas.getBoundingClientRect();
return {
x: event.clientX - bounds.left + cameraRef.current.x,
y: event.clientY - bounds.top + cameraRef.current.y,
const point = {
x: (event.clientX - bounds.left) / view.pixel + Math.round(cameraRef.current.x),
y: (event.clientY - bounds.top) / view.pixel + Math.round(cameraRef.current.y),
};
return pickAt(view.frame, simRef.current!.agents, point);
};
const onCanvasClick = (event: MouseEvent<HTMLCanvasElement>) => {
const point = worldPointFrom(event);
if (!point) return;
const hit = simRef.current!.hitTest(point);
const hit = agentAt(event);
setSelectedId(hit ? hit.input.id : null);
};
const onCanvasMouseMove = (event: MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
const point = worldPointFrom(event);
if (!canvas || !point) return;
canvas.style.cursor = simRef.current!.hitTest(point) ? "pointer" : "default";
event.currentTarget.style.cursor = agentAt(event) ? "pointer" : "default";
};
return (
+93
View File
@@ -0,0 +1,93 @@
import type { Rect, Vec } from "./office-types";
// 2:1 isometric projection of the plan; the simulation stays in plan units, depth grows with x + y.
const K = 0.5;
/** Head room above the plan's north edge for the back wall and signs. */
export const SKY_MARGIN = 64;
/** Height of the back (north and west) walls, in iso pixels. */
export const BACK_WALL_H = 48;
export interface IsoFrame {
originX: number;
size: { w: number; h: number };
}
export function isoFrame(world: { w: number; h: number }): IsoFrame {
return { originX: world.h * K, size: { w: (world.w + world.h) * K, h: ((world.w + world.h) * K) / 2 + SKY_MARGIN } };
}
export function toIso(frame: IsoFrame, p: Vec, z = 0): Vec {
return { x: frame.originX + (p.x - p.y) * K, y: SKY_MARGIN + ((p.x + p.y) * K) / 2 - z };
}
/** The ground-plane point under an iso pixel. */
export function fromIso(frame: IsoFrame, s: Vec): Vec {
const difference = (s.x - frame.originX) / K;
const sum = ((s.y - SKY_MARGIN) * 2) / K;
return { x: (sum + difference) / 2, y: (sum - difference) / 2 };
}
export function depthOf(p: Vec): number {
return p.x + p.y;
}
/** Draws plan-space art (rects in plan units) flat onto the iso ground. */
export function setGroundTransform(ctx: CanvasRenderingContext2D, frame: IsoFrame): void {
ctx.transform(K, K / 2, -K, K / 2, frame.originX, SKY_MARGIN);
}
/** Draws face-space art onto the south face of the back wall at plan y = `wallY`: x runs along the wall, y down from its top. */
export function setNorthWallTransform(ctx: CanvasRenderingContext2D, frame: IsoFrame, wallY: number): void {
ctx.transform(K, K / 2, 0, 1, frame.originX - wallY * K, SKY_MARGIN + (wallY * K) / 2 - BACK_WALL_H);
}
/** Draws face-space art onto the east face of the back wall at plan x = `wallX`: x runs south along the wall, y down from its top. */
export function setWestWallTransform(ctx: CanvasRenderingContext2D, frame: IsoFrame, wallX: number): void {
ctx.transform(-K, K / 2, 0, 1, frame.originX + wallX * K, SKY_MARGIN + (wallX * K) / 2 - BACK_WALL_H);
}
export interface BoxColors {
top: string;
south: string;
east: string;
}
/** An extruded block over a plan footprint, from `z` up `height` iso pixels; its top and two camera-facing sides. */
export function drawBox(ctx: CanvasRenderingContext2D, frame: IsoFrame, foot: Rect, z: number, height: number, colors: BoxColors): void {
const x0 = foot.x;
const y0 = foot.y;
const x1 = foot.x + foot.w;
const y1 = foot.y + foot.h;
const top = z + height;
polygon(ctx, colors.south, [toIso(frame, { x: x0, y: y1 }, z), toIso(frame, { x: x1, y: y1 }, z), toIso(frame, { x: x1, y: y1 }, top), toIso(frame, { x: x0, y: y1 }, top)]);
polygon(ctx, colors.east, [toIso(frame, { x: x1, y: y1 }, z), toIso(frame, { x: x1, y: y0 }, z), toIso(frame, { x: x1, y: y0 }, top), toIso(frame, { x: x1, y: y1 }, top)]);
polygon(ctx, colors.top, [toIso(frame, { x: x0, y: y0 }, top), toIso(frame, { x: x1, y: y0 }, top), toIso(frame, { x: x1, y: y1 }, top), toIso(frame, { x: x0, y: y1 }, top)]);
}
function polygon(ctx: CanvasRenderingContext2D, color: string, points: Vec[]): void {
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(Math.round(points[0]!.x), Math.round(points[0]!.y));
for (const point of points.slice(1)) ctx.lineTo(Math.round(point.x), Math.round(point.y));
ctx.closePath();
ctx.fill();
}
/** Sprite box around a person's feet in iso pixels, matching how sprites are drawn. */
const SPRITE = { halfW: 10, above: 40, below: 4 };
/** The person whose drawn sprite covers an iso point, frontmost first; at equal depth the later one, as it is drawn on top. */
export function pickAt<T extends Vec>(frame: IsoFrame, people: T[], point: Vec): T | null {
let hit: T | null = null;
for (const person of people) {
const feet = toIso(frame, person);
const inside = Math.abs(point.x - feet.x) <= SPRITE.halfW && point.y >= feet.y - SPRITE.above && point.y <= feet.y + SPRITE.below;
if (inside && (hit === null || depthOf(person) >= depthOf(hit))) hit = person;
}
return hit;
}
/** Screen size of one iso pixel in CSS px: two CSS px rounded to whole device pixels. */
export function isoPixelSize(devicePixelRatio: number): number {
return Math.max(1, Math.round(2 * devicePixelRatio)) / devicePixelRatio;
}
+114 -80
View File
@@ -1,7 +1,9 @@
import type { AgentActivity, Camera, FloorPlan, Rect } from "./office-types";
import { deskScreenRect, drawWorld } from "./scene-renderer";
import { drawBackWalls, drawGround, sceneDrawables, type Drawable } from "./scene-renderer";
import { depthOf, toIso, type IsoFrame } from "./iso";
import { skyScene } from "./sky";
import { drawPerson, type Carrying, type Facing, type SpriteAppearance } from "./sprites";
import type { DayPart, Season } from "../../shared/palette-clock";
export interface RenderPerson {
id: string;
@@ -19,106 +21,138 @@ export interface RenderPerson {
carrying: Carrying;
}
/** One frame's inputs: the scene in iso pixels, and how those pixels land on the screen. */
export interface OfficeFrame {
frame: IsoFrame;
plan: FloorPlan;
/** Camera top-left, in iso pixels. */
camera: Camera;
/** CSS px per iso pixel. */
pixel: number;
/** CSS px per pixel-font pixel, for the crisp text drawn over the scene. */
fontScale: number;
viewW: number;
viewH: number;
people: RenderPerson[];
timeMs: number;
clock: Date;
dayPart: DayPart;
season: Season;
selectedId: string | null;
}
const ACTIVITY_DOT: Partial<Record<AgentActivity, string>> = {
active: "#34d399",
thinking: "#818cf8",
busy: "#f59e0b",
};
/** The day part washes over the whole floor: warm at dawn and dusk, blue at night. */
const DAY_TINT: Record<DayPart, string | null> = {
dawn: "#f3d9d6",
day: null,
dusk: "#f0c6a0",
night: "#7d8bc0",
};
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);
}
/**
* 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,
clock: Date,
selectedId: string | null,
): void {
ctx.fillStyle = "#14161c";
ctx.fillRect(0, 0, viewW, viewH);
ctx.save();
ctx.translate(-camera.x, -camera.y);
/** Draws the scene at iso-pixel resolution on `scene`, depth-sorted, then scales it up unsmoothed onto `ctx` and writes labels in the pixel font. */
export function renderOffice(ctx: CanvasRenderingContext2D, scene: HTMLCanvasElement, input: OfficeFrame): void {
const sceneW = Math.ceil(input.viewW / input.pixel);
const sceneH = Math.ceil(input.viewH / input.pixel);
if (scene.width !== sceneW || scene.height !== sceneH) {
scene.width = sceneW;
scene.height = sceneH;
}
const sctx = scene.getContext("2d");
if (!sctx) throw new Error("The office scene canvas has no 2D context.");
sctx.setTransform(1, 0, 0, 1, 0, 0);
sctx.fillStyle = "#14161c";
sctx.fillRect(0, 0, sceneW, sceneH);
sctx.translate(-Math.round(input.camera.x), -Math.round(input.camera.y));
drawWorld(ctx, plan, { x: camera.x, y: camera.y, w: viewW, h: viewH }, skyScene(clock));
drawGround(sctx, input.frame, input.plan);
drawBackWalls(sctx, input.frame, input.plan, skyScene(input.clock));
const lit = new Set(input.people.filter((person) => person.desk && (person.activity === "active" || person.activity === "thinking")).map((person) => person.desk!));
const items: Drawable[] = [...sceneDrawables(input.frame, input.plan, input.season, lit), ...input.people.map((person) => personDrawable(input, person))];
items.sort((a, b) => a.depth - b.depth);
for (const item of items) item.draw(sctx);
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);
}
const tint = DAY_TINT[input.dayPart];
if (tint) {
sctx.setTransform(1, 0, 0, 1, 0, 0);
sctx.globalCompositeOperation = "multiply";
sctx.fillStyle = tint;
sctx.fillRect(0, 0, sceneW, sceneH);
sctx.globalCompositeOperation = "source-over";
}
const ordered = [...people].sort((a, b) => a.y - b.y);
for (const person of ordered) {
if (person.id === selectedId) {
ctx.strokeStyle = "rgba(94,106,210,0.9)";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.ellipse(Math.round(person.x), Math.round(person.y) + 2, 13, 6, 0, 0, Math.PI * 2);
ctx.stroke();
}
const working = (person.activity === "active" || person.activity === "thinking") && person.walkPhase === 0;
drawPerson(ctx, person.x, person.y, person.appearance, {
facing: person.facing,
walkPhase: person.walkPhase,
working,
carrying: person.carrying,
timeMs,
animSeed: person.animSeed,
}, person.isCeo);
const dot = ACTIVITY_DOT[person.activity];
if (dot) {
ctx.fillStyle = dot;
ctx.beginPath();
ctx.arc(Math.round(person.x) + 10, Math.round(person.y) - 36, 3, 0, Math.PI * 2);
ctx.fill();
}
}
for (const person of ordered) drawNameLabel(ctx, person);
ctx.restore();
ctx.imageSmoothingEnabled = false;
ctx.drawImage(scene, 0, 0, sceneW * input.pixel, sceneH * input.pixel);
drawLabels(ctx, input);
}
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 = person.isPlayer ? "rgba(94,106,210,0.92)" : "rgba(22,22,28,0.78)";
drawRoundedRect(ctx, x, y, width, 13, 4);
ctx.fillStyle = "#f2f2f5";
function personDrawable(input: OfficeFrame, person: RenderPerson): Drawable {
return {
// Just in front of anything sharing the same footprint centre.
depth: depthOf(person) + 0.5,
draw: (ctx) => {
const feet = toIso(input.frame, person);
if (person.id === input.selectedId) {
ctx.fillStyle = "rgba(94,106,210,0.9)";
ctx.fillRect(Math.round(feet.x) - 12, Math.round(feet.y), 24, 2);
ctx.fillRect(Math.round(feet.x) - 14, Math.round(feet.y) - 2, 2, 2);
ctx.fillRect(Math.round(feet.x) + 12, Math.round(feet.y) - 2, 2, 2);
}
const working = (person.activity === "active" || person.activity === "thinking") && person.walkPhase === 0;
drawPerson(ctx, feet.x, feet.y, person.appearance, {
facing: person.facing,
walkPhase: person.walkPhase,
working,
carrying: person.carrying,
timeMs: input.timeMs,
animSeed: person.animSeed,
}, person.isCeo);
const dot = ACTIVITY_DOT[person.activity];
if (dot) {
ctx.fillStyle = dot;
ctx.fillRect(Math.round(feet.x) + 8, Math.round(feet.y) - 40, 4, 4);
}
},
};
}
/** Names under people and room signs over doors, in the pixel font at screen resolution so they stay crisp. */
function drawLabels(ctx: CanvasRenderingContext2D, input: OfficeFrame): void {
const size = 12 * input.fontScale;
ctx.font = `${size}px "Fusion Pixel"`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(person.name, person.x, y + 7);
ctx.textBaseline = "top";
const screen = (x: number, y: number, z = 0) => {
const iso = toIso(input.frame, { x, y }, z);
return { x: Math.round((iso.x - Math.round(input.camera.x)) * input.pixel), y: Math.round((iso.y - Math.round(input.camera.y)) * input.pixel) };
};
for (const room of input.plan.rooms) {
const at = screen(room.doorCx, room.bounds.y + room.bounds.h, 22);
plate(ctx, room.label, at.x, at.y - size, size, "rgba(47,42,37,0.9)");
}
for (const person of [...input.people].sort((a, b) => depthOf(a) - depthOf(b))) {
const at = screen(person.x, person.y);
plate(ctx, person.name, at.x, at.y + 6 * input.pixel, size, person.isPlayer ? "rgba(94,106,210,0.92)" : "rgba(22,22,28,0.78)");
}
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();
function plate(ctx: CanvasRenderingContext2D, text: string, cx: number, top: number, size: number, background: string): void {
const width = Math.ceil(ctx.measureText(text).width) + size;
ctx.fillStyle = background;
ctx.fillRect(Math.round(cx - width / 2), top, width, Math.round(size * 1.5));
ctx.fillStyle = "#f2f2f5";
ctx.fillText(text, cx, top + Math.round(size * 0.25));
}
+170 -297
View File
@@ -1,24 +1,26 @@
import type { FloorPlan, Rect, RoomSpec } from "./office-types";
import { WALL } from "./floor-plan";
import { drawWindowSky, type SkyScene } from "./sky";
import { BACK_WALL_H, depthOf, drawBox, setGroundTransform, setNorthWallTransform, type BoxColors, type IsoFrame } from "./iso";
import type { Season } from "../../shared/palette-clock";
/**
* 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.
*/
/** One thing in the scene that people can stand in front of or behind; drawn in depth order with them. */
export interface Drawable {
depth: number;
draw: (ctx: CanvasRenderingContext2D) => void;
}
/** Height of the visible north-wall face inside each room. */
export const WALL_FACE_H = 48;
/** Interior walls are cut away to this height so rooms stay visible. */
const INNER_WALL_H = 14;
/** Longest stretch of a block that sorts as one piece. */
const PIECE = 32;
const PALETTE = {
wood: "#b08a62",
woodSeam: "#9c7852",
corridor: "#a8815c",
wallFill: "#4c4239",
wallEdge: "#3b332c",
wallFace: "#6b5f52",
wallFaceShadow: "#5d5246",
wall: "#6b5f52",
wallTop: "#4c4239",
baseboard: "#4f463c",
ceoCarpet: "#5c6a94",
ceoCarpetEdge: "#505d85",
@@ -28,50 +30,43 @@ const PALETTE = {
snackTileB: "#cbb891",
restTileA: "#adbfd2",
restTileB: "#9db0c5",
deskWood: "#8a6d4c",
deskWoodDark: "#75593b",
deskLeg: "#5f4832",
desk: "#8a6d4c",
deskExec: "#6a5238",
monitor: "#4b5058",
screenOff: "#20242c",
monitorShell: "#4b5058",
screenOn: "#37d38a",
chair: "#3d4148",
chairDark: "#33373d",
frame: "#3f3831",
signPlate: "#2f2a25",
signText: "#d8cfc2",
} as const;
export function drawWorld(ctx: CanvasRenderingContext2D, plan: FloorPlan, view: Rect, sky: SkyScene): void {
drawFloor(ctx, plan, view);
for (const room of plan.rooms) drawRoomFloor(ctx, room);
drawStructure(ctx, plan, sky);
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);
const LEAVES: Record<Season, { leaf: string; light: string }> = {
spring: { leaf: "#5f9a52", light: "#e9a3c2" },
summer: { leaf: "#3f7f3a", light: "#6fb25c" },
autumn: { leaf: "#b5652a", light: "#e0a23a" },
winter: { leaf: "#5a7466", light: "#e8eef2" },
};
/** Top, south and east faces of a block from one base colour: lit from above, the east side darkest. */
export function faces(hex: string): BoxColors {
return { top: hex, south: shade(hex, 0.82), east: shade(hex, 0.66) };
}
/** 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 {
/** Floor, rugs and tiles, flat on the ground plane. */
export function drawGround(ctx: CanvasRenderingContext2D, frame: IsoFrame, plan: FloorPlan): void {
ctx.save();
setGroundTransform(ctx, frame);
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) {
for (let y = 0; y < h; 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);
for (let x = (y / 16) % 2 === 0 ? 0 : 48; x < w; x += 96) ctx.fillRect(x, y, 1, 16);
}
ctx.fillStyle = PALETTE.corridor;
ctx.fillRect(WALL, 288, plan.world.w - 2 * WALL, 64);
ctx.fillRect(WALL, 288, w - 2 * WALL, 64);
for (const room of plan.rooms) drawRoomFloor(ctx, room);
ctx.restore();
}
function drawRoomFloor(ctx: CanvasRenderingContext2D, room: RoomSpec): void {
@@ -84,9 +79,7 @@ function drawRoomFloor(ctx: CanvasRenderingContext2D, room: RoomSpec): void {
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];
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;
@@ -95,280 +88,160 @@ function drawRoomFloor(ctx: CanvasRenderingContext2D, room: RoomSpec): void {
}
}
function drawStructure(ctx: CanvasRenderingContext2D, plan: FloorPlan, sky: SkyScene): void {
/** The tall north and west walls, which everything stands in front of, with windows and wall art on the north face. */
export function drawBackWalls(ctx: CanvasRenderingContext2D, frame: IsoFrame, plan: FloorPlan, sky: SkyScene): 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);
const wall = faces(PALETTE.wall);
drawBox(ctx, frame, { x: 0, y: WALL, w: WALL, h: 280 }, 0, BACK_WALL_H, wall);
drawBox(ctx, frame, { x: 0, y: 344, w: WALL, h: h - 344 - WALL }, 0, BACK_WALL_H, wall);
drawBox(ctx, frame, { x: 0, y: 0, w, h: WALL }, 0, BACK_WALL_H, { ...wall, top: PALETTE.wallTop });
ctx.save();
setNorthWallTransform(ctx, frame, WALL);
ctx.fillStyle = PALETTE.baseboard;
ctx.fillRect(0, BACK_WALL_H - 3, w, 3);
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, sky);
windowRects(room).forEach((rect, index) => {
ctx.fillStyle = PALETTE.frame;
ctx.fillRect(rect.x - 2, rect.y - 2, rect.w + 4, rect.h + 4);
drawWindowSky(ctx, rect, sky, Math.round(room.bounds.x) + index * 53);
ctx.fillStyle = PALETTE.frame;
ctx.fillRect(rect.x + rect.w / 2 - 1, rect.y, 2, rect.h);
});
}
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);
drawWallArt(ctx);
ctx.restore();
}
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, sky: SkyScene): void {
const rects = windowRects(room);
for (let i = 0; i < rects.length; i++) {
drawWindowFrame(ctx, rects[i], sky, Math.round(room.bounds.x) + i * 53);
}
}
/** Window glass rects on each room's north wall face (world coordinates). */
/** Window glass on each room's stretch of the north wall face: x along the wall, y down from its top. */
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 === "restroom") return [{ x: b.x + b.w - 190, y: 12, w: 44, h: 18 }];
if (room.kind === "snack") return [{ x: b.x + 104, y: 8, w: 72, h: 24 }];
return [{ x: b.x + 28, y: 8, w: 72, h: 24 }, { x: b.x + b.w - 100, y: 8, w: 72, h: 24 }];
}
function drawWallArt(ctx: CanvasRenderingContext2D): void {
// CEO office wall screen and meeting-room whiteboard, between their windows.
ctx.fillStyle = PALETTE.frame;
ctx.fillRect(143, 8, 34, 22);
ctx.fillStyle = "#7f96b8";
ctx.fillRect(145, 10, 30, 18);
ctx.fillStyle = "#f2efe8";
ctx.fillRect(424, 6, 96, 28);
ctx.fillStyle = "#b3543f";
ctx.fillRect(432, 12, 26, 3);
ctx.fillStyle = "#4f7fae";
ctx.fillRect(432, 19, 40, 3);
ctx.fillStyle = "#6d9c6b";
ctx.fillRect(432, 26, 33, 3);
}
/** A block sorted by its footprint centre; one resting on `support` sorts just after every piece of it underneath, so no part of that surface paints over it. */
export function blockDrawable(frame: IsoFrame, foot: Rect, height: number, colors: BoxColors, z = 0, support?: Rect): Drawable {
const centerDepth = (rect: Rect) => depthOf({ x: rect.x + rect.w / 2, y: rect.y + rect.h / 2 });
if (!support) return { depth: centerDepth(foot), draw: (ctx) => drawBox(ctx, frame, foot, z, height, colors) };
const under = split(support).filter((piece) => overlaps(piece, foot));
if (under.length === 0) throw new Error("A resting block must sit over its support.");
return {
depth: Math.max(...under.map(centerDepth)) + 0.25,
draw: (ctx) => drawBox(ctx, frame, foot, z, height, colors),
};
}
/** Interior walls, furniture and desks as blocks, each sorted by depth against the people; `lit` desks have their screen on. */
export function sceneDrawables(frame: IsoFrame, plan: FloorPlan, season: Season, lit: Set<Rect>): Drawable[] {
const items: Drawable[] = [];
// Long blocks go in pieces so each sorts against nearby people on its own.
const block = (foot: Rect, height: number, colors: BoxColors, z = 0, support?: Rect) => {
for (const piece of support ? [foot] : split(foot)) items.push(blockDrawable(frame, piece, height, colors, z, support));
};
for (const wall of innerWalls(plan)) block(wall, INNER_WALL_H, faces(PALETTE.wall));
const leaves = LEAVES[season];
const plant = (cx: number, cy: number) => {
const pot = { x: cx - 6, y: cy - 6, w: 12, h: 12 };
block(pot, 8, faces("#8a5a3b"));
block({ x: cx - 8, y: cy - 8, w: 16, h: 16 }, 12, faces(leaves.leaf), 8, pot);
block({ x: cx - 4, y: cy - 4, w: 8, h: 8 }, 6, faces(leaves.light), 20, pot);
};
// CEO office: rug, executive desk, sofa, plant.
block({ x: 32, y: 96, w: 24, h: 72 }, 10, faces("#7a4a41"));
plant(280, 40);
deskBlocks(plan.ceoDesk, true, lit.has(plan.ceoDesk)).forEach((item) => items.push(item(frame)));
// Meeting room: table and chairs.
block({ x: 392, y: 120, w: 160, h: 64 }, 12, faces("#75593b"));
for (let i = 0; i < 3; i++) {
block({ x: 414 + i * 52, y: 100, w: 14, h: 12 }, 8, faces(PALETTE.chair));
block({ x: 414 + i * 52, y: 192, w: 14, h: 12 }, 8, faces(PALETTE.chair));
}
if (room.kind === "snack") {
return [{ x: b.x + 24, y, w: 72, h }];
}
const w = 72;
// Snack bar: counter, fridge, coffee machine, table and stools.
const counter = { x: 656, y: 40, w: 224, h: 28 };
block(counter, 16, faces("#8f8578"));
block({ x: 700, y: 20, w: 24, h: 20 }, 22, faces("#d5dade"));
block({ x: 744, y: 44, w: 14, h: 14 }, 12, faces("#2e3238"), 16, counter);
block({ x: 806, y: 46, w: 12, h: 10 }, 8, faces("#c9a86a"), 16, counter);
block({ x: 826, y: 46, w: 12, h: 10 }, 10, faces("#b3543f"), 16, counter);
block({ x: 748, y: 170, w: 40, h: 40 }, 12, faces("#94897b"));
block({ x: 728, y: 182, w: 12, h: 12 }, 7, faces("#6d5334"));
block({ x: 796, y: 182, w: 12, h: 12 }, 7, faces("#6d5334"));
// Restroom: three stalls and a sink counter.
for (let i = 0; i < 3; i++) block({ x: 1064 + i * 40, y: WALL, w: 38, h: 72 }, 30, faces("#7d8a99"));
block({ x: 928, y: 32, w: 96, h: 32 }, 14, faces("#c6d2de"));
for (const [cx, cy] of [[312, 300], [632, 300], [904, 300]] as const) plant(cx, cy);
for (const desk of plan.desks) deskBlocks(desk.bounds, false, lit.has(desk.bounds)).forEach((item) => items.push(item(frame)));
return items;
}
/** A desk, its monitor and its chair, each a separate block so a seated person sorts between them. */
function deskBlocks(desk: Rect, executive: boolean, screenOn: boolean): Array<(frame: IsoFrame) => Drawable> {
const monitor = deskMonitor(desk);
return [
{ x: b.x + 28, y, w, h },
{ x: b.x + b.w - 28 - w, y, w, h },
...split(desk).map((piece) => (frame: IsoFrame) => blockDrawable(frame, piece, 14, faces(executive ? PALETTE.deskExec : PALETTE.desk))),
(frame) => blockDrawable(frame, monitor, 12, { top: PALETTE.monitor, south: screenOn ? PALETTE.screenOn : PALETTE.screenOff, east: shade(PALETTE.monitor, 0.7) }, 14, desk),
(frame) => blockDrawable(frame, { x: desk.x + desk.w / 2 - 8, y: desk.y + desk.h + 6, w: 16, h: 12 }, 9, faces(PALETTE.chair)),
];
}
function drawWindowFrame(ctx: CanvasRenderingContext2D, rect: Rect, sky: SkyScene, seed: number): void {
ctx.fillStyle = PALETTE.frame;
ctx.fillRect(rect.x - 3, rect.y - 3, rect.w + 6, rect.h + 6);
drawWindowSky(ctx, rect, sky, seed);
ctx.fillStyle = PALETTE.frame;
ctx.fillRect(rect.x + rect.w / 2 - 1, rect.y, 2, rect.h);
/** Footprint of a desk's monitor, at the back of the desktop with its screen facing south. */
export function deskMonitor(desk: Rect): Rect {
return { x: desk.x + desk.w / 2 - 14, y: desk.y + 4, w: 28, h: 6 };
}
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);
// Wall screen centered in the gap between the two windows (116..204).
ctx.fillStyle = PALETTE.frame;
ctx.fillRect(143, 24, 34, 22);
ctx.fillStyle = "#7f96b8";
ctx.fillRect(145, 26, 30, 18);
ctx.fillStyle = "#5c7a9c";
ctx.fillRect(145, 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);
/** Partitions, room fronts (with door gaps) and the low outer east and south walls. */
function innerWalls(plan: FloorPlan): Rect[] {
const { w, h } = plan.world;
const walls: Rect[] = [
{ x: 304, y: WALL, w: WALL, h: 272 },
{ x: 624, y: WALL, w: WALL, h: 272 },
{ x: 896, y: WALL, w: WALL, h: 272 },
{ x: w - WALL, y: WALL, w: WALL, h: h - 2 * WALL },
{ x: 0, y: h - WALL, w, h: WALL },
];
for (const room of plan.rooms) {
const b = room.bounds;
const y = b.y + b.h;
walls.push({ x: b.x - WALL, y, w: room.doorCx - 24 - b.x + WALL, h: WALL });
walls.push({ x: room.doorCx + 24, y, w: b.x + b.w - room.doorCx - 24 + WALL, h: WALL });
}
// Whiteboard centered in the gap between the two windows (420..524).
ctx.fillStyle = "#f2efe8";
ctx.fillRect(424, 22, 96, 30);
ctx.fillStyle = PALETTE.frame;
ctx.strokeStyle = PALETTE.frame;
ctx.lineWidth = 2;
ctx.strokeRect(424, 22, 96, 30);
ctx.fillStyle = "#b3543f";
ctx.fillRect(432, 30, 26, 3);
ctx.fillStyle = "#4f7fae";
ctx.fillRect(432, 38, 40, 3);
ctx.fillStyle = "#6d9c6b";
ctx.fillRect(432, 44, 33, 3);
return walls;
}
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);
function split(wall: Rect): Rect[] {
const horizontal = wall.w >= wall.h;
const length = horizontal ? wall.w : wall.h;
const pieces: Rect[] = [];
for (let start = 0; start < length; start += PIECE) {
const size = Math.min(PIECE, length - start);
pieces.push(horizontal ? { x: wall.x + start, y: wall.y, w: size, h: wall.h } : { x: wall.x, y: wall.y + start, w: wall.w, h: size });
}
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);
return pieces;
}
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 {
function overlaps(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 };
function shade(hex: string, factor: number): string {
const value = Number.parseInt(hex.slice(1), 16);
const channel = (shift: number) => Math.round(((value >> shift) & 0xff) * factor);
return `rgb(${channel(16)}, ${channel(8)}, ${channel(0)})`;
}
@@ -244,22 +244,6 @@ export class OfficeSimulation {
agent.behavior = { kind: "seated" };
}
/**
* Returns the agent whose drawn body contains the world point, preferring
* the visually frontmost (largest y). Tests against the same positions the
* renderer draws, so clicks always match what is on screen.
*/
hitTest(point: Vec): AgentSim | null {
let hit: AgentSim | null = null;
for (const agent of this.agents) {
const inBody =
point.x >= agent.x - 10 && point.x <= agent.x + 10 &&
point.y >= agent.y - 36 && point.y <= agent.y + 4;
if (inBody && (hit === null || agent.y > hit.y)) hit = agent;
}
return hit;
}
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);
+17 -10
View File
@@ -5,6 +5,8 @@ import { join, resolve } from "node:path";
import { randomUUID } from "node:crypto";
import { buildFloorPlan } from "../../src/renderer/src/pages/office/floor-plan";
import { cameraFor } from "../../src/renderer/src/pages/office/simulation";
import { isoFrame, isoPixelSize, toIso } from "../../src/renderer/src/pages/office/iso";
import type { FloorPlan, Vec } from "../../src/renderer/src/pages/office/office-types";
import { seedBuiltinSkillsFixture } from "./builtin-skills-fixture";
test("the office floor opens agent details on click", async () => {
@@ -53,9 +55,8 @@ test("the office floor opens agent details on click", async () => {
const box = await canvas.boundingBox();
expect(box).not.toBeNull();
const plan = buildFloorPlan(0);
const camera = cameraFor(plan.world, box!.width, box!.height, plan.entrance);
const clickX = box!.x + plan.ceoSeat.x - camera.x;
const clickY = box!.y + plan.ceoSeat.y - 20 - camera.y;
const ratio = await page.evaluate(() => window.devicePixelRatio);
const { x: clickX, y: clickY } = onScreen(plan, box!, ratio, plan.ceoSeat, 20);
await page.mouse.click(clickX, clickY);
const card = page.getByRole("dialog", { name: "Agent CEO" });
@@ -78,7 +79,8 @@ test("the office floor opens agent details on click", async () => {
await expect(card).not.toBeVisible();
// Clicking empty corridor floor selects nothing.
await page.mouse.click(box!.x + 500 - camera.x, box!.y + 320 - camera.y);
const corridor = onScreen(plan, box!, ratio, { x: 500, y: 320 }, 0);
await page.mouse.click(corridor.x, corridor.y);
await expect(page.getByRole("dialog", { name: /Agent / })).not.toBeVisible();
// WASD input is accepted without disturbing the page.
@@ -102,12 +104,8 @@ test("the office floor opens agent details on click", async () => {
const populated = buildFloorPlan(2);
const populatedBox = await canvas.boundingBox();
const populatedCamera = cameraFor(populated.world, populatedBox!.width, populatedBox!.height, populated.entrance);
const firstDesk = populated.desks[0].seat;
await page.mouse.click(
populatedBox!.x + firstDesk.x - populatedCamera.x,
populatedBox!.y + firstDesk.y - 20 - populatedCamera.y,
);
const firstDesk = onScreen(populated, populatedBox!, ratio, populated.desks[0].seat, 20);
await page.mouse.click(firstDesk.x, firstDesk.y);
const avaCard = page.getByRole("dialog", { name: "Agent Ava Patel" });
await expect(avaCard).toBeVisible();
await expect(avaCard.getByText("Design", { exact: true })).toBeVisible();
@@ -132,3 +130,12 @@ async function hireAgent(page: Page, name: string, role: string, department: str
await page.locator(".modal").getByRole("button", { name: "Hire" }).click();
await expect(page.getByText(name).first()).toBeVisible();
}
/** Where a plan point, lifted by `lift` iso pixels, is drawn: the same projection, camera and pixel size the page uses, with the player at the entrance. */
function onScreen(plan: FloorPlan, box: { x: number; y: number; width: number; height: number }, ratio: number, point: Vec, lift: number): Vec {
const pixel = isoPixelSize(ratio);
const frame = isoFrame(plan.world);
const camera = cameraFor(frame.size, box.width / pixel, box.height / pixel, toIso(frame, plan.entrance));
const at = toIso(frame, point, lift);
return { x: box.x + (at.x - Math.round(camera.x)) * pixel, y: box.y + (at.y - Math.round(camera.y)) * pixel };
}
+80
View File
@@ -0,0 +1,80 @@
import { describe, expect, test } from "vitest";
import { depthOf, fromIso, isoFrame, isoPixelSize, pickAt, toIso } from "../../src/renderer/src/pages/office/iso";
import { blockDrawable } from "../../src/renderer/src/pages/office/scene-renderer";
const frame = isoFrame({ w: 1216, h: 600 });
describe("office isometric projection", () => {
test("the plan's corners land on a 2:1 diamond inside the frame", () => {
const north = toIso(frame, { x: 0, y: 0 });
const east = toIso(frame, { x: 1216, y: 0 });
const west = toIso(frame, { x: 0, y: 600 });
const south = toIso(frame, { x: 1216, y: 600 });
expect(west.x).toBe(0);
expect(east.x).toBe(frame.size.w);
expect(south.y).toBe(frame.size.h);
expect(north.y).toBeLessThan(east.y);
expect(north.y).toBeLessThan(west.y);
// Moving along x goes twice as far across as down: the 2:1 slope.
expect((east.x - north.x) / (east.y - north.y)).toBe(2);
expect((north.x - west.x) / (west.y - north.y)).toBe(2);
});
test("height lifts a point straight up, and the ground point under any pixel round-trips", () => {
const point = { x: 432, y: 210 };
expect(toIso(frame, point, 30)).toEqual({ x: toIso(frame, point).x, y: toIso(frame, point).y - 30 });
for (const p of [point, { x: 0, y: 0 }, { x: 1216, y: 600 }, { x: 17.5, y: 403.25 }]) {
const back = fromIso(frame, toIso(frame, p));
expect(back.x).toBeCloseTo(p.x, 9);
expect(back.y).toBeCloseTo(p.y, 9);
}
});
test("a click on a drawn sprite picks that person, the one in front when sprites overlap", () => {
const back = { id: "back", x: 500, y: 300 };
const front = { id: "front", x: 506, y: 306 };
const feet = toIso(frame, back);
expect(pickAt(frame, [back], { x: feet.x, y: feet.y - 20 })?.id).toBe("back");
expect(pickAt(frame, [back], { x: feet.x + 30, y: feet.y - 20 })).toBeNull();
expect(pickAt(frame, [back], { x: feet.x, y: feet.y - 60 })).toBeNull();
expect(depthOf(front)).toBeGreaterThan(depthOf(back));
expect(pickAt(frame, [back, front], { x: feet.x, y: feet.y - 10 })?.id).toBe("front");
expect(pickAt(frame, [front, back], { x: feet.x, y: feet.y - 10 })?.id).toBe("front");
});
test("an iso pixel covers a whole number of device pixels", () => {
for (const ratio of [1, 1.25, 1.5, 2, 3]) {
const device = isoPixelSize(ratio) * ratio;
expect(device).toBe(Math.round(device));
expect(device).toBeGreaterThanOrEqual(2);
}
});
});
describe("office depth order", () => {
test("people at equal depth: the later one is drawn on top and is the one a click picks", () => {
const first = { id: "first", x: 500, y: 300 };
const second = { id: "second", x: 506, y: 294 };
expect(depthOf(first)).toBe(depthOf(second));
const feet = toIso(frame, first);
expect(pickAt(frame, [first, second], { x: feet.x + 3, y: feet.y - 10 })?.id).toBe("second");
});
test("something resting on a surface sorts after the surface, even when its own footprint is further back", () => {
const counter = { x: 656, y: 40, w: 224, h: 28 };
// A cup at the back corner of the third 32-unit counter piece.
const cup = { x: 722, y: 41, w: 6, h: 6 };
const colors = { top: "#000", south: "#000", east: "#000" };
const underneath = blockDrawable(frame, { x: 720, y: 40, w: 32, h: 28 }, 16, colors);
const loose = blockDrawable(frame, cup, 12, colors, 16);
const resting = blockDrawable(frame, cup, 12, colors, 16, counter);
expect(loose.depth).toBeLessThan(underneath.depth);
expect(resting.depth).toBeGreaterThan(underneath.depth);
expect(() => blockDrawable(frame, { x: 10, y: 10, w: 4, h: 4 }, 4, colors, 16, counter)).toThrow("A resting block must sit over its support.");
// The coffee machine straddles the pieces at 720..752 and 752..784: it sorts after both.
const machine = blockDrawable(frame, { x: 744, y: 44, w: 14, h: 14 }, 12, colors, 16, counter);
for (const x of [720, 752]) expect(machine.depth).toBeGreaterThan(blockDrawable(frame, { x, y: 40, w: 32, h: 28 }, 16, colors).depth);
// A person standing in front of the counter still draws over what rests on it.
expect(depthOf({ x: 704, y: 92 })).toBeGreaterThan(resting.depth);
});
});
-12
View File
@@ -193,18 +193,6 @@ describe("office simulation", () => {
expect(sim.agents[0].behavior.kind).toBe("seated");
});
test("clicks hit the agent drawn at that spot, frontmost first", () => {
const sim = new OfficeSimulation();
sim.sync([agentInput("a"), agentInput("b")]);
const first = sim.agents[0];
expect(sim.hitTest({ x: first.x, y: first.y - 10 })!.input.id).toBe("a");
expect(sim.hitTest({ x: first.x - 60, y: first.y - 10 })).toBeNull();
const second = sim.agents[1];
second.x = first.x;
second.y = first.y + 6;
expect(sim.hitTest({ x: first.x, y: first.y })!.input.id).toBe("b");
});
test("camera follows the player and clamps at world edges", () => {
const sim = new OfficeSimulation();
sim.sync(Array.from({ length: 30 }, (_, i) => agentInput(`a${i}`)));