From 23c7d55b1d83f9c6f15c541cba9578eef3d1224a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 9 Oct 2026 13:03:04 -0400 Subject: [PATCH] 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 --- src/renderer/src/pages/OfficePage.tsx | 57 ++- src/renderer/src/pages/office/iso.ts | 93 ++++ .../src/pages/office/office-renderer.ts | 194 +++++--- .../src/pages/office/scene-renderer.ts | 467 +++++++----------- src/renderer/src/pages/office/simulation.ts | 16 - tests/e2e/office.spec.ts | 27 +- tests/unit/office-iso.test.ts | 80 +++ tests/unit/office-simulation.test.ts | 12 - 8 files changed, 514 insertions(+), 432 deletions(-) create mode 100644 src/renderer/src/pages/office/iso.ts create mode 100644 tests/unit/office-iso.test.ts diff --git a/src/renderer/src/pages/OfficePage.tsx b/src/renderer/src/pages/OfficePage.tsx index bf6d2394..526909d7 100644 --- a/src/renderer/src/pages/OfficePage.tsx +++ b/src/renderer/src/pages/OfficePage.tsx @@ -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(null); const keysRef = useRef({ up: false, down: false, left: false, right: false }); const cameraRef = useRef({ 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(null); const cardBoxRef = useRef(null); const [selectedId, setSelectedId] = useState(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): Vec | null => { + /** The agent drawn under the pointer, picked in the same iso pixels the frame was drawn in. */ + const agentAt = (event: MouseEvent) => { 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) => { - 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) => { - 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 ( diff --git a/src/renderer/src/pages/office/iso.ts b/src/renderer/src/pages/office/iso.ts new file mode 100644 index 00000000..cbf43726 --- /dev/null +++ b/src/renderer/src/pages/office/iso.ts @@ -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(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; +} diff --git a/src/renderer/src/pages/office/office-renderer.ts b/src/renderer/src/pages/office/office-renderer.ts index 183f41d9..921258b6 100644 --- a/src/renderer/src/pages/office/office-renderer.ts +++ b/src/renderer/src/pages/office/office-renderer.ts @@ -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> = { 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 = { + 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)); } diff --git a/src/renderer/src/pages/office/scene-renderer.ts b/src/renderer/src/pages/office/scene-renderer.ts index 1a7124cb..b3a6beef 100644 --- a/src/renderer/src/pages/office/scene-renderer.ts +++ b/src/renderer/src/pages/office/scene-renderer.ts @@ -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 = { + 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): 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)})`; } diff --git a/src/renderer/src/pages/office/simulation.ts b/src/renderer/src/pages/office/simulation.ts index bb02c79f..ee6f954f 100644 --- a/src/renderer/src/pages/office/simulation.ts +++ b/src/renderer/src/pages/office/simulation.ts @@ -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); diff --git a/tests/e2e/office.spec.ts b/tests/e2e/office.spec.ts index ec57d6a1..6d29eb9e 100644 --- a/tests/e2e/office.spec.ts +++ b/tests/e2e/office.spec.ts @@ -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 }; +} diff --git a/tests/unit/office-iso.test.ts b/tests/unit/office-iso.test.ts new file mode 100644 index 00000000..86db5112 --- /dev/null +++ b/tests/unit/office-iso.test.ts @@ -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); + }); +}); diff --git a/tests/unit/office-simulation.test.ts b/tests/unit/office-simulation.test.ts index 65f7e3f7..86fa850e 100644 --- a/tests/unit/office-simulation.test.ts +++ b/tests/unit/office-simulation.test.ts @@ -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}`)));