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:
1 parent
5dfc2a6dc6
commit
23c7d55b1d
8 files changed
+514
-432
No files matched your search
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}`)));
|
||||
|
||||
Reference in new issue
Block a user