@blkc-code/render-canvas 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/draw.ts ADDED
@@ -0,0 +1,725 @@
1
+ /**
2
+ * Draws the office. Every piece of art here is original to this project, made in code at
3
+ * 16 pixels a tile. The building is drawn once to a layer (drawBuilding); people, screens
4
+ * and anything else that moves are drawn on top each frame (drawWorld).
5
+ */
6
+
7
+ import { FURNITURE, isSeated, type Agent, type Item, type Room, type World } from "./world.js";
8
+
9
+ export const TILE = 16;
10
+
11
+ export interface Palette { primary: string; background: string; text: string; accent: string }
12
+
13
+ type Ctx = CanvasRenderingContext2D;
14
+
15
+ const LABEL_FONT = "bold 7px ui-monospace, Menlo, Consolas, monospace";
16
+ const BODY_FONT = "7px ui-monospace, Menlo, Consolas, monospace";
17
+ const OUTLINE = "#14141f";
18
+
19
+ const rect = (c: Ctx, color: string, x: number, y: number, w: number, h: number): void => {
20
+ c.fillStyle = color;
21
+ c.fillRect(Math.round(x), Math.round(y), w, h);
22
+ };
23
+
24
+ function shade(color: string, amount: number): string {
25
+ if (!color.startsWith("#")) return color;
26
+ const hex = color.length === 4 ? `#${color[1]}${color[1]}${color[2]}${color[2]}${color[3]}${color[3]}` : color;
27
+ const n = parseInt(hex.slice(1, 7), 16);
28
+ const channel = (shift: number): number => Math.max(0, Math.min(255, ((n >> shift) & 255) + amount));
29
+ return `rgb(${channel(16)},${channel(8)},${channel(0)})`;
30
+ }
31
+
32
+ // --- Time of day, from the viewer's own clock ------------------------------------------------------------
33
+
34
+ export interface Daylight { sky: string; sun: string; night: number }
35
+
36
+ export function daylightAt(hour: number): Daylight {
37
+ if (hour >= 7 && hour < 17) return { sky: "#8ed0ff", sun: "rgba(255,244,214,0.10)", night: 0 };
38
+ if (hour >= 17 && hour < 20) return { sky: "#ffae70", sun: "rgba(255,170,110,0.13)", night: 0.14 };
39
+ if (hour >= 5 && hour < 7) return { sky: "#ffc9a3", sun: "rgba(255,200,170,0.08)", night: 0.16 };
40
+ return { sky: "#16204a", sun: "rgba(0,0,0,0)", night: 0.24 };
41
+ }
42
+
43
+ // --- Characters ------------------------------------------------------------------------------------------
44
+
45
+ type Part = [color: string, x: number, y: number, w: number, h: number];
46
+
47
+ /** Parts draw with one outline around the whole figure, so characters read clearly on any floor. */
48
+ function drawParts(c: Ctx, parts: Part[]): void {
49
+ c.fillStyle = OUTLINE;
50
+ for (const [, x, y, w, h] of parts) c.fillRect(Math.round(x) - 1, Math.round(y) - 1, w + 2, h + 2);
51
+ for (const [color, x, y, w, h] of parts) rect(c, color, x, y, w, h);
52
+ }
53
+
54
+ function drawAgent(c: Ctx, agent: Agent, time: number, selected: boolean): void {
55
+ const left = agent.x * TILE + 2; // sprite is 12 wide inside a 16 tile
56
+ const feet = agent.y * TILE + 15;
57
+ const seated = isSeated(agent);
58
+ const walking = agent.walking;
59
+ const side = agent.facing === "left" || agent.facing === "right";
60
+ const back = agent.facing === "up";
61
+ const flip = agent.facing === "left";
62
+ const phase = Math.floor(time * 9 + (agent.seed % 5));
63
+ const step = walking ? phase % 4 : 0; // 0 pass, 1 left forward, 2 pass, 3 right forward
64
+ const bob = walking && step % 2 === 1 ? -1 : 0;
65
+ const breathe = !walking && !seated && Math.floor(time * 1.4 + (agent.seed % 7)) % 2 === 0 ? 1 : 0;
66
+ const party = agent.party > 0 ? -Math.abs(Math.round(Math.sin(time * 12) * 3)) : 0;
67
+
68
+ const skin = agent.skin;
69
+ const hair = agent.hair;
70
+ const shirt = agent.shirt;
71
+ const dark = shade(shirt, -40);
72
+ const light = shade(shirt, 24);
73
+ const legs = "#39415a";
74
+ const shoe = "#1f2433";
75
+ const style = agent.seed % 5; // short, long, bun, spiky, cap
76
+ const outfit = (agent.seed >>> 5) % 3; // tee, hoodie, shirt and tie
77
+
78
+ // Shadow, and a highlight under whoever is selected.
79
+ c.fillStyle = "rgba(0,0,0,0.28)";
80
+ c.fillRect(Math.round(left + 1), Math.round(feet - 1), 10, 2);
81
+ if (selected) {
82
+ c.fillStyle = "rgba(255,255,255,0.45)";
83
+ c.fillRect(Math.round(left - 2), Math.round(feet - 2), 16, 4);
84
+ }
85
+ if (agent.state === "blocked") {
86
+ c.fillStyle = Math.floor(time * 3) % 2 === 0 ? "rgba(239,68,68,0.55)" : "rgba(239,68,68,0.22)";
87
+ c.fillRect(Math.round(left - 2), Math.round(feet - 2), 16, 4);
88
+ }
89
+
90
+ const lift = bob + party;
91
+ const parts: Part[] = [];
92
+ const bodyTop = feet - (seated ? 13 : 13) + lift + breathe * 0;
93
+ const headTop = bodyTop - 8 + breathe;
94
+
95
+ // Chair behind someone sitting at a desk.
96
+ if (seated) {
97
+ rect(c, "#1f2937", left + 1, agent.y * TILE + 3, 10, 9);
98
+ rect(c, "#374151", left + 2, agent.y * TILE + 11, 8, 3);
99
+ rect(c, "#111827", left + 5, agent.y * TILE + 14, 2, 2);
100
+ }
101
+
102
+ // Legs
103
+ if (!seated) {
104
+ const legTop = bodyTop + 7;
105
+ if (side) {
106
+ const swing = step === 1 ? 2 : step === 3 ? -2 : 0;
107
+ parts.push([legs, left + 4 + swing, legTop, 3, 5], [shoe, left + 4 + swing + (flip ? -1 : 0), legTop + 5, 4, 1]);
108
+ parts.push([shade(legs, -12), left + 5 - swing, legTop, 3, 5], [shoe, left + 5 - swing + (flip ? -1 : 0), legTop + 5, 4, 1]);
109
+ } else {
110
+ const leftUp = step === 1 ? 1 : 0;
111
+ const rightUp = step === 3 ? 1 : 0;
112
+ parts.push([legs, left + 2, legTop, 3, 5 - leftUp], [shoe, left + 2, legTop + 5 - leftUp, 3, 1]);
113
+ parts.push([legs, left + 7, legTop, 3, 5 - rightUp], [shoe, left + 7, legTop + 5 - rightUp, 3, 1]);
114
+ }
115
+ }
116
+
117
+ // Body and arms
118
+ const swingArm = walking ? (step === 1 ? 1 : step === 3 ? -1 : 0) : 0;
119
+ if (side) {
120
+ parts.push([shirt, left + 3, bodyTop, 6, 7], [dark, left + 3, bodyTop + 6, 6, 1]);
121
+ parts.push([dark, left + 5 + swingArm, bodyTop + 1, 2, 5], [skin, left + 5 + swingArm, bodyTop + 6, 2, 1]);
122
+ } else {
123
+ parts.push([shirt, left + 1, bodyTop, 10, 7], [dark, left + 1, bodyTop + 6, 10, 1]);
124
+ const typing = seated && agent.state === "working";
125
+ const type = Math.floor(time * 10 + agent.seed) % 2;
126
+ parts.push([dark, left - 1, bodyTop + 1 + swingArm + (typing ? -type : 0), 2, 5]);
127
+ parts.push([dark, left + 11, bodyTop + 1 - swingArm + (typing ? type - 1 : 0), 2, 5]);
128
+ parts.push([skin, left - 1, bodyTop + 6 + swingArm + (typing ? -type : 0), 2, 1]);
129
+ parts.push([skin, left + 11, bodyTop + 6 - swingArm + (typing ? type - 1 : 0), 2, 1]);
130
+ if (!back && outfit === 2) parts.push(["#f3f4f6", left + 4, bodyTop, 4, 3], [agent.seed % 2 ? "#ef4444" : "#1d4ed8", left + 5, bodyTop, 2, 5]);
131
+ if (!back && outfit === 1) parts.push([light, left + 3, bodyTop, 6, 2], [dark, left + 5, bodyTop + 2, 2, 4]);
132
+ if (back && outfit === 1) parts.push([light, left + 2, bodyTop, 8, 3]);
133
+ }
134
+
135
+ // Head
136
+ parts.push([skin, left + 1, headTop, 10, 8]);
137
+ if (back) {
138
+ parts.push([hair, left + 1, headTop, 10, 7]);
139
+ } else if (side) {
140
+ const backX = flip ? left + 7 : left + 1;
141
+ parts.push([hair, left + 1, headTop, 10, 3], [hair, backX, headTop, 4, 6]);
142
+ const blink = Math.floor(time * 0.7 + agent.seed % 11) % 7 === 0;
143
+ parts.push([blink ? shade(skin, -40) : "#1a1a24", flip ? left + 3 : left + 8, headTop + 4, 1, blink ? 1 : 2]);
144
+ } else {
145
+ parts.push([hair, left + 1, headTop, 10, 3], [hair, left + 1, headTop + 3, 1, 2], [hair, left + 10, headTop + 3, 1, 2]);
146
+ const blink = Math.floor(time * 0.7 + agent.seed % 11) % 7 === 0;
147
+ parts.push([blink ? shade(skin, -40) : "#1a1a24", left + 3, headTop + 4, 2, blink ? 1 : 2]);
148
+ parts.push([blink ? shade(skin, -40) : "#1a1a24", left + 7, headTop + 4, 2, blink ? 1 : 2]);
149
+ parts.push([shade(skin, -28), left + 5, headTop + 6, 2, 1]);
150
+ }
151
+ if (style === 1) parts.push([hair, left, headTop + 2, 2, 8], [hair, left + 10, headTop + 2, 2, 8]); // long
152
+ if (style === 2) parts.push([hair, left + 4, headTop - 3, 4, 3]); // bun
153
+ if (style === 3) parts.push([hair, left + 2, headTop - 2, 2, 2], [hair, left + 5, headTop - 3, 2, 3], [hair, left + 8, headTop - 2, 2, 2]); // spiky
154
+ if (style === 4) { // cap
155
+ parts.push([light, left, headTop - 1, 12, 3]);
156
+ if (!back && !side) parts.push([dark, left + 1, headTop + 2, 10, 1]);
157
+ if (side) parts.push([dark, flip ? left - 2 : left + 9, headTop + 1, 5, 1]);
158
+ }
159
+ drawParts(c, parts);
160
+
161
+ if (agent.party > 0) {
162
+ const colors = ["#f59e0b", "#ec4899", "#22c55e", "#3b82f6", "#a855f7"];
163
+ for (let i = 0; i < 10; i++) {
164
+ const t = (time * 1.1 + i * 0.137) % 1;
165
+ const angle = i * 2.4 + agent.seed;
166
+ rect(c, colors[i % colors.length] as string, left + 6 + Math.cos(angle) * (4 + t * 14), feet - 22 - t * 6 + t * t * 26, 2, 2);
167
+ }
168
+ }
169
+ }
170
+
171
+ function bubble(c: Ctx, x: number, y: number, draw: (x: number, y: number) => void): void {
172
+ rect(c, OUTLINE, x - 1, y - 1, 13, 11);
173
+ rect(c, "#ffffff", x, y, 11, 9);
174
+ rect(c, "#ffffff", x + 4, y + 9, 3, 1);
175
+ rect(c, OUTLINE, x + 4, y + 10, 3, 1);
176
+ draw(x, y);
177
+ }
178
+
179
+ function drawStatus(c: Ctx, agent: Agent, time: number): void {
180
+ const x = Math.round(agent.x * TILE + 2);
181
+ const y = Math.round(agent.y * TILE - 22 + (isSeated(agent) ? 2 : -2));
182
+ const pulse = Math.floor(time * 2 + (agent.seed % 3)) % 2 === 0;
183
+ if (agent.state === "blocked") {
184
+ bubble(c, x, y, (bx, by) => {
185
+ rect(c, pulse ? "#dc2626" : "#991b1b", bx + 5, by + 1, 2, 5);
186
+ rect(c, pulse ? "#dc2626" : "#991b1b", bx + 5, by + 7, 2, 1);
187
+ });
188
+ } else if (agent.state === "thinking") {
189
+ const dots = Math.floor(time * 3) % 4;
190
+ bubble(c, x, y, (bx, by) => {
191
+ for (let i = 0; i < 3; i++) rect(c, i < dots ? "#111827" : "#d1d5db", bx + 2 + i * 3, by + 4, 2, 2);
192
+ });
193
+ } else if (agent.state === "reviewing" && !agent.walking) {
194
+ bubble(c, x, y, (bx, by) => {
195
+ rect(c, "#2563eb", bx + 4, by + 1, 4, 1); rect(c, "#2563eb", bx + 7, by + 2, 1, 2);
196
+ rect(c, "#2563eb", bx + 5, by + 4, 2, 1); rect(c, "#2563eb", bx + 5, by + 5, 1, 1); rect(c, "#2563eb", bx + 5, by + 7, 1, 1);
197
+ });
198
+ } else if (agent.state === "done") {
199
+ const color = pulse ? "#f59e0b" : "#fbbf24";
200
+ bubble(c, x, y, (bx, by) => {
201
+ rect(c, color, bx + 5, by + 1, 1, 7); rect(c, color, bx + 2, by + 3, 7, 2); rect(c, color, bx + 4, by + 2, 3, 5);
202
+ });
203
+ } else if (agent.state === "idle" && !agent.walking && agent.seed % 3 === 0) {
204
+ bubble(c, x, y, (bx, by) => {
205
+ rect(c, "#92400e", bx + 3, by + 3, 4, 4); rect(c, "#d97706", bx + 3, by + 3, 4, 1); rect(c, "#92400e", bx + 7, by + 4, 1, 2);
206
+ if (pulse) rect(c, "#9ca3af", bx + 4, by + 1, 1, 1);
207
+ });
208
+ }
209
+ }
210
+
211
+ // --- The building ----------------------------------------------------------------------------------------
212
+
213
+ function drawFloor(c: Ctx, room: Room): void {
214
+ for (let ty = room.y; ty < room.y + room.h; ty++) {
215
+ for (let tx = room.x; tx < room.x + room.w; tx++) {
216
+ const x = tx * TILE;
217
+ const y = ty * TILE;
218
+ const noise = (tx * 73 + ty * 151) % 17;
219
+ switch (room.floor) {
220
+ case "carpet":
221
+ rect(c, "#4c4f7a", x, y, TILE, TILE);
222
+ for (let i = 0; i < 8; i++) rect(c, i % 2 ? "#55588a" : "#454874", x + ((tx * 7 + i * 5) % 15), y + ((ty * 3 + i * 7) % 15), 1, 1);
223
+ break;
224
+ case "tile":
225
+ rect(c, (tx + ty) % 2 === 0 ? "#d6dee8" : "#c2ccd8", x, y, TILE, TILE);
226
+ rect(c, "#aab5c2", x, y + 15, TILE, 1);
227
+ rect(c, "#aab5c2", x + 15, y, 1, TILE);
228
+ rect(c, "#e8eef5", x + 1, y + 1, 5, 1);
229
+ break;
230
+ case "stone":
231
+ rect(c, noise % 3 === 0 ? "#8d93a3" : "#9aa0b0", x, y, TILE, TILE);
232
+ rect(c, "#7a8090", x, y + 15, TILE, 1);
233
+ rect(c, "#7a8090", x + (ty % 2 ? 7 : 15), y, 1, 15);
234
+ rect(c, "#adb3c2", x + 2, y + 2, 3, 1);
235
+ break;
236
+ case "concrete":
237
+ rect(c, "#6b7280", x, y, TILE, TILE);
238
+ for (let i = 0; i < 5; i++) rect(c, i % 2 ? "#747b89" : "#636a78", x + ((tx * 11 + i * 3) % 15), y + ((ty * 5 + i * 5) % 15), 1, 1);
239
+ if (noise === 3) rect(c, "#596070", x + 3, y + 8, 8, 1);
240
+ break;
241
+ case "grass":
242
+ rect(c, "#3f8f4f", x, y, TILE, TILE);
243
+ for (let i = 0; i < 7; i++) rect(c, i % 3 ? "#4aa35c" : "#347a43", x + ((tx * 5 + i * 9) % 15), y + ((ty * 7 + i * 3) % 14), 1, 2);
244
+ break;
245
+ default: {
246
+ const plank = ty % 2 === 0 ? "#a57448" : "#9c6c42";
247
+ rect(c, plank, x, y, TILE, TILE);
248
+ rect(c, "#8a5d36", x, y + 7, TILE, 1);
249
+ rect(c, "#8a5d36", x, y + 15, TILE, 1);
250
+ rect(c, "#8a5d36", x + ((tx * 8 + (ty % 2 ? 8 : 0)) % 16), y, 1, 7);
251
+ rect(c, "#8a5d36", x + ((tx * 8 + (ty % 2 ? 0 : 8)) % 16), y + 8, 1, 7);
252
+ rect(c, "#b4835a", x + (noise % 9) + 2, y + 3, 4, 1);
253
+ rect(c, "#b4835a", x + (noise % 7) + 4, y + 11, 3, 1);
254
+ }
255
+ }
256
+ }
257
+ }
258
+ }
259
+
260
+ const WALL_TOP = "#2a2d48";
261
+ const WALL_FACE = "#5c628f";
262
+ const WALL_DARK = "#4a4f78";
263
+ const WALL_LIGHT = "#6d74a4";
264
+
265
+ function drawWalls(c: Ctx, world: World, palette: Palette, daylight: Daylight): void {
266
+ const open = new Set<string>();
267
+ for (const room of world.rooms) {
268
+ for (let y = room.y; y < room.y + room.h; y++) for (let x = room.x; x < room.x + room.w; x++) open.add(`${x},${y}`);
269
+ }
270
+ const doors = new Set(world.doors.map((door) => `${door.x},${door.y}`));
271
+
272
+ for (let ty = 0; ty < world.rows; ty++) {
273
+ for (let tx = 0; tx < world.cols; tx++) {
274
+ const key = `${tx},${ty}`;
275
+ if (open.has(key) || doors.has(key)) continue;
276
+ const x = tx * TILE;
277
+ const y = ty * TILE;
278
+ if (open.has(`${tx},${ty + 1}`)) {
279
+ rect(c, WALL_FACE, x, y, TILE, TILE);
280
+ rect(c, WALL_LIGHT, x, y, TILE, 1);
281
+ rect(c, WALL_DARK, x, y + 12, TILE, 4);
282
+ rect(c, palette.primary, x, y + 11, TILE, 1);
283
+ if (tx % 4 === 3 && !world.furniture.some((item) => FURNITURE[item.type].wall && item.x === tx && item.y === ty)) {
284
+ // A window, in the colour of the sky right now.
285
+ rect(c, "#1f2937", x + 1, y + 1, 14, 9);
286
+ rect(c, daylight.sky, x + 2, y + 2, 12, 7);
287
+ rect(c, shade(daylight.sky, 40), x + 3, y + 3, 4, 2);
288
+ rect(c, "#1f2937", x + 8, y + 2, 1, 7);
289
+ if (daylight.night > 0.3) { rect(c, "#fef3c7", x + 4, y + 3, 1, 1); rect(c, "#fef3c7", x + 11, y + 5, 1, 1); }
290
+ }
291
+ } else {
292
+ rect(c, WALL_TOP, x, y, TILE, TILE);
293
+ if (open.has(`${tx},${ty - 1}`)) rect(c, "#20233a", x, y, TILE, 3);
294
+ if (open.has(`${tx - 1},${ty}`)) rect(c, "#20233a", x, y, 2, TILE);
295
+ if (open.has(`${tx + 1},${ty}`)) rect(c, "#20233a", x + 14, y, 2, TILE);
296
+ }
297
+ }
298
+ }
299
+ for (const door of world.doors) {
300
+ const x = door.x * TILE;
301
+ const y = door.y * TILE;
302
+ const horizontal = open.has(`${door.x},${door.y - 1}`) || open.has(`${door.x},${door.y + 1}`);
303
+ rect(c, "#7c5637", x, y, TILE, TILE);
304
+ for (let i = 2; i < 16; i += 4) rect(c, "#6b4a2f", horizontal ? x : x + i, horizontal ? y + i : y, horizontal ? TILE : 1, horizontal ? 1 : TILE);
305
+ if (horizontal) { rect(c, WALL_DARK, x, y, 2, TILE); rect(c, WALL_DARK, x + 14, y, 2, TILE); }
306
+ else { rect(c, WALL_DARK, x, y, TILE, 2); rect(c, WALL_DARK, x, y + 14, TILE, 2); }
307
+ }
308
+ }
309
+
310
+ function drawLabels(c: Ctx, world: World): void {
311
+ c.font = LABEL_FONT;
312
+ c.textBaseline = "top";
313
+ for (const room of world.rooms) {
314
+ if (!room.name) continue;
315
+ const label = room.name.toUpperCase();
316
+ const width = Math.ceil(c.measureText(label).width) + 6;
317
+ const x = room.x * TILE + 3;
318
+ const y = room.y * TILE - 13;
319
+ rect(c, "#1c1f33", x, y, width, 10);
320
+ rect(c, "#2f3350", x + 1, y + 1, width - 2, 8);
321
+ c.fillStyle = "#f8fafc";
322
+ c.fillText(label, x + 3, y + 1.5);
323
+ }
324
+ }
325
+
326
+ /** Everything that doesn't move. Redrawn only when the building or the hour changes. */
327
+ export function drawBuilding(c: Ctx, world: World, palette: Palette, daylight: Daylight): void {
328
+ rect(c, WALL_TOP, 0, 0, world.cols * TILE, world.rows * TILE);
329
+ for (const room of world.rooms) drawFloor(c, room);
330
+ drawWalls(c, world, palette, daylight);
331
+ for (const item of world.furniture) if (item.type === "rug") drawFurniture(c, item, world, 0, palette);
332
+ for (const item of world.furniture) if (FURNITURE[item.type].wall) drawFurniture(c, item, world, 0, palette);
333
+ // Light from the windows falling across the floor.
334
+ if (daylight.sun !== "rgba(0,0,0,0)") {
335
+ c.fillStyle = daylight.sun;
336
+ for (const room of world.rooms) {
337
+ for (let tx = room.x; tx < room.x + room.w; tx++) {
338
+ if (tx % 4 !== 3) continue;
339
+ c.fillRect(tx * TILE + 2, room.y * TILE, 12, Math.min(room.h, 3) * TILE);
340
+ }
341
+ }
342
+ }
343
+ }
344
+
345
+ function drawDesk(c: Ctx, x: number, y: number, on: boolean, time: number, seed: number, palette: Palette, blocked: boolean): void {
346
+ rect(c, "#55351d", x + 1, y + 22, 46, 8);
347
+ rect(c, "#6b4427", x + 1, y + 13, 46, 10);
348
+ rect(c, "#8a5a33", x + 1, y + 10, 46, 12);
349
+ rect(c, "#a87545", x + 1, y + 10, 46, 2);
350
+ rect(c, "#3b2413", x + 3, y + 22, 3, 9);
351
+ rect(c, "#3b2413", x + 42, y + 22, 3, 9);
352
+ rect(c, "#55351d", x + 30, y + 23, 12, 7);
353
+ rect(c, "#7c5637", x + 31, y + 24, 10, 2);
354
+ rect(c, "#d4a24e", x + 35, y + 24, 2, 1);
355
+ rect(c, "#7c5637", x + 31, y + 27, 10, 2);
356
+ rect(c, "#d4a24e", x + 35, y + 27, 2, 1);
357
+ // Monitor
358
+ rect(c, OUTLINE, x + 14, y - 3, 20, 15);
359
+ rect(c, "#374151", x + 22, y + 12, 4, 2);
360
+ rect(c, "#4b5563", x + 19, y + 14, 10, 1);
361
+ if (blocked) {
362
+ rect(c, "#3f0f14", x + 15, y - 2, 18, 13);
363
+ if (Math.floor(time * 2) % 2 === 0) { rect(c, "#ef4444", x + 23, y, 2, 6); rect(c, "#ef4444", x + 23, y + 7, 2, 2); }
364
+ } else if (on) {
365
+ rect(c, "#0f2a2e", x + 15, y - 2, 18, 13);
366
+ const scroll = Math.floor(time * 3 + (seed % 13));
367
+ for (let line = 0; line < 5; line++) {
368
+ const width = 3 + (((seed >> line) & 7) + (scroll + line) * 7) % 12;
369
+ rect(c, line % 3 === 0 ? palette.primary : line % 3 === 1 ? "#7dd3fc" : "#fcd34d", x + 16 + (line % 2) * 2, y - 1 + line * 2.2, width, 1);
370
+ }
371
+ c.fillStyle = "rgba(125,211,252,0.12)";
372
+ c.fillRect(x + 10, y + 11, 28, 8);
373
+ } else {
374
+ rect(c, "#1f2937", x + 15, y - 2, 18, 13);
375
+ rect(c, "#374151", x + 17, y, 3, 1);
376
+ }
377
+ rect(c, "#d1d5db", x + 16, y + 15, 16, 5);
378
+ for (let row = 0; row < 2; row++) for (let key = 0; key < 7; key++) rect(c, "#9ca3af", x + 17 + key * 2, y + 16 + row * 2, 1, 1);
379
+ rect(c, "#e5e7eb", x + 34, y + 16, 3, 4);
380
+ rect(c, "#f3f4f6", x + 40, y + 13, 5, 5);
381
+ rect(c, palette.primary, x + 40, y + 15, 5, 1);
382
+ rect(c, "#fde68a", x + 4, y + 13, 7, 6);
383
+ rect(c, "#fbbf24", x + 5, y + 15, 5, 1);
384
+ }
385
+
386
+ function drawBoard(c: Ctx, x: number, y: number, world: World): void {
387
+ rect(c, "#3a2c1e", x, y - 12, 64, 27);
388
+ rect(c, "#d9c29c", x + 2, y - 10, 60, 23);
389
+ for (let i = 0; i < 40; i++) rect(c, "#c9b08a", x + 3 + (i * 37) % 58, y - 9 + (i * 13) % 21, 1, 1);
390
+ const colors = { todo: "#fde68a", doing: "#93c5fd", done: "#86efac" };
391
+ const per = 8;
392
+ world.tasks.slice(0, per * 2).forEach((task, index) => {
393
+ const col = index % per;
394
+ const row = Math.floor(index / per);
395
+ rect(c, "#00000033", x + 5 + col * 7, y - 7 + row * 10, 6, 8);
396
+ rect(c, colors[task.state], x + 4 + col * 7, y - 8 + row * 10, 6, 8);
397
+ rect(c, "#ef4444", x + 6 + col * 7, y - 8 + row * 10, 2, 1);
398
+ rect(c, "#00000040", x + 5 + col * 7, y - 5 + row * 10, 4, 1);
399
+ rect(c, "#00000040", x + 5 + col * 7, y - 3 + row * 10, 3, 1);
400
+ });
401
+ }
402
+
403
+ function drawFurniture(c: Ctx, item: Item, world: World, time: number, palette: Palette): void {
404
+ const x = item.x * TILE;
405
+ const y = item.y * TILE;
406
+ switch (item.type) {
407
+ case "desk": {
408
+ const seatX = item.x + 1;
409
+ const seatY = item.y + 2;
410
+ const user = world.agents.find((a) => a.seat?.x === seatX && a.seat?.y === seatY && isSeated(a));
411
+ drawDesk(c, x, y, Boolean(user), time, user?.seed ?? 0, palette, user?.state === "blocked");
412
+ break;
413
+ }
414
+ case "coffee":
415
+ rect(c, "#374151", x, y + 2, 32, 14);
416
+ rect(c, "#6b7280", x, y + 2, 32, 2);
417
+ rect(c, OUTLINE, x + 4, y - 9, 15, 15);
418
+ rect(c, "#27272a", x + 5, y - 8, 13, 13);
419
+ rect(c, Math.floor(time * 2) % 2 ? "#ef4444" : "#7f1d1d", x + 15, y - 6, 2, 2);
420
+ rect(c, "#52525b", x + 7, y - 6, 6, 4);
421
+ rect(c, "#f3f4f6", x + 8, y + 3, 5, 4);
422
+ rect(c, "#f3f4f6", x + 23, y - 1, 5, 5);
423
+ rect(c, "#92400e", x + 24, y, 3, 1);
424
+ for (let i = 0; i < 3; i++) {
425
+ c.fillStyle = "rgba(255,255,255,0.5)";
426
+ c.fillRect(x + 9 + Math.round(Math.sin(time * 2 + i) * 1.5), y - 1 - ((time * 8 + i * 4) % 9), 1, 2);
427
+ }
428
+ break;
429
+ case "water":
430
+ rect(c, "#d1d5db", x + 3, y + 3, 10, 13);
431
+ rect(c, "#9ca3af", x + 3, y + 14, 10, 2);
432
+ rect(c, "#60a5fa", x + 4, y - 8, 8, 11);
433
+ rect(c, "#bae6fd", x + 5, y - 7, 2, 9);
434
+ rect(c, "#3b82f6", x + 5, y + 6, 2, 2);
435
+ rect(c, "#ef4444", x + 9, y + 6, 2, 2);
436
+ if (Math.floor(time * 0.4 + item.x) % 5 === 0) rect(c, "#e0f2fe", x + 8, y - 2 - ((time * 6) % 7), 1, 1);
437
+ break;
438
+ case "sofa":
439
+ rect(c, shade(palette.primary, -60), x + 1, y - 7, 46, 11);
440
+ rect(c, shade(palette.primary, -30), x + 2, y - 6, 44, 3);
441
+ rect(c, "#4b5563", x, y + 3, 48, 11);
442
+ rect(c, "#6b7280", x + 3, y + 3, 20, 7);
443
+ rect(c, "#6b7280", x + 25, y + 3, 20, 7);
444
+ rect(c, "#374151", x, y + 12, 48, 3);
445
+ rect(c, "#374151", x - 1, y - 2, 4, 16);
446
+ rect(c, "#374151", x + 45, y - 2, 4, 16);
447
+ break;
448
+ case "plant":
449
+ rect(c, "#92400e", x + 4, y + 9, 8, 7);
450
+ rect(c, "#b45309", x + 3, y + 8, 10, 2);
451
+ for (const [lx, ly, lw, lh] of [[6, -3, 4, 11], [2, 0, 5, 6], [9, 1, 5, 6], [4, -6, 3, 6], [9, -5, 3, 5]] as const) rect(c, "#15803d", x + lx, y + ly, lw, lh);
452
+ rect(c, "#22c55e", x + 7, y - 3, 2, 8);
453
+ rect(c, "#22c55e", x + 3, y + 1, 2, 3);
454
+ rect(c, "#4ade80", x + 10, y - 4, 1, 3);
455
+ break;
456
+ case "fern":
457
+ rect(c, "#57534e", x + 3, y + 10, 10, 6);
458
+ rect(c, "#78716c", x + 2, y + 9, 12, 2);
459
+ for (let i = 0; i < 7; i++) {
460
+ const lean = i - 3;
461
+ for (let j = 0; j < 5; j++) rect(c, j % 2 ? "#16a34a" : "#15803d", x + 7 + lean * (j / 2 + 1), y + 8 - j * 2 + Math.abs(lean), 2, 2);
462
+ }
463
+ break;
464
+ case "board":
465
+ drawBoard(c, x, y, world);
466
+ break;
467
+ case "whiteboard":
468
+ rect(c, "#9ca3af", x, y - 10, 48, 22);
469
+ rect(c, "#f9fafb", x + 2, y - 8, 44, 18);
470
+ rect(c, "#3b82f6", x + 5, y - 5, 14, 1); rect(c, "#3b82f6", x + 5, y - 2, 9, 1);
471
+ rect(c, "#ef4444", x + 24, y - 6, 8, 6); rect(c, "#f9fafb", x + 25, y - 5, 6, 4);
472
+ rect(c, "#10b981", x + 8, y + 3, 20, 1); rect(c, "#10b981", x + 27, y + 2, 1, 3);
473
+ rect(c, "#6b7280", x + 2, y + 10, 44, 2);
474
+ break;
475
+ case "shelf":
476
+ rect(c, "#5b3a21", x, y - 12, 32, 28);
477
+ for (let shelf = 0; shelf < 2; shelf++) {
478
+ rect(c, "#3b2413", x + 2, y - 10 + shelf * 13, 28, 11);
479
+ for (let book = 0; book < 8; book++) {
480
+ const color = ["#ef4444", "#3b82f6", "#10b981", "#f59e0b", "#8b5cf6"][(book * 3 + shelf + item.x) % 5] as string;
481
+ rect(c, color, x + 3 + book * 3, y - 8 + shelf * 13 + (book % 3), 2, 9 - (book % 3));
482
+ rect(c, shade(color, 40), x + 3 + book * 3, y - 6 + shelf * 13, 2, 1);
483
+ }
484
+ }
485
+ break;
486
+ case "server":
487
+ rect(c, OUTLINE, x, y - 13, 16, 29);
488
+ rect(c, "#1f2937", x + 1, y - 12, 14, 27);
489
+ for (let i = 0; i < 5; i++) {
490
+ rect(c, "#111827", x + 2, y - 11 + i * 5, 12, 4);
491
+ const on = Math.floor(time * 3 + i * 2 + item.x) % 3 !== 0;
492
+ rect(c, on ? "#22c55e" : "#14532d", x + 3, y - 10 + i * 5, 1, 1);
493
+ rect(c, i % 2 === 0 ? "#f59e0b" : "#3b82f6", x + 5, y - 10 + i * 5, 1, 1);
494
+ rect(c, "#374151", x + 8, y - 10 + i * 5, 5, 1);
495
+ }
496
+ break;
497
+ case "table":
498
+ rect(c, "#44403c", x + 2, y + 16, 44, 12);
499
+ rect(c, "#78716c", x + 2, y + 3, 44, 14);
500
+ rect(c, "#a8a29e", x + 2, y + 3, 44, 2);
501
+ rect(c, "#f9fafb", x + 10, y + 6, 8, 6);
502
+ rect(c, "#f9fafb", x + 28, y + 8, 8, 6);
503
+ rect(c, "#1f2937", x + 12, y + 8, 4, 1);
504
+ rect(c, "#3b82f6", x + 30, y + 10, 4, 1);
505
+ break;
506
+ case "rug":
507
+ rect(c, palette.primary, x, y, 64, 48);
508
+ c.fillStyle = "rgba(0,0,0,0.42)";
509
+ c.fillRect(x + 3, y + 3, 58, 42);
510
+ rect(c, palette.primary, x + 6, y + 6, 52, 1);
511
+ rect(c, palette.primary, x + 6, y + 41, 52, 1);
512
+ rect(c, palette.primary, x + 6, y + 6, 1, 36);
513
+ rect(c, palette.primary, x + 57, y + 6, 1, 36);
514
+ for (let i = 0; i < 6; i++) rect(c, palette.primary, x + 12 + i * 8, y + 22, 4, 4);
515
+ break;
516
+ case "arcade":
517
+ rect(c, OUTLINE, x, y - 14, 16, 30);
518
+ rect(c, "#4c1d95", x + 1, y - 13, 14, 28);
519
+ rect(c, "#111827", x + 3, y - 10, 10, 9);
520
+ rect(c, ["#22c55e", "#ec4899", "#38bdf8"][Math.floor(time * 2) % 3] as string, x + 4 + (Math.floor(time * 4) % 7), y - 8 + (Math.floor(time * 3) % 5), 2, 2);
521
+ rect(c, "#f59e0b", x + 3, y - 13, 10, 2);
522
+ rect(c, "#27272a", x + 2, y + 1, 12, 4);
523
+ rect(c, "#ef4444", x + 4, y + 2, 2, 2);
524
+ rect(c, "#3b82f6", x + 9, y + 2, 2, 2);
525
+ break;
526
+ case "fridge":
527
+ rect(c, OUTLINE, x + 1, y - 12, 14, 28);
528
+ rect(c, "#e5e7eb", x + 2, y - 11, 12, 26);
529
+ rect(c, "#9ca3af", x + 2, y - 3, 12, 1);
530
+ rect(c, "#6b7280", x + 11, y - 8, 1, 3);
531
+ rect(c, "#6b7280", x + 11, y, 1, 5);
532
+ rect(c, "#fde68a", x + 4, y - 9, 3, 3);
533
+ rect(c, "#fca5a5", x + 5, y + 3, 3, 3);
534
+ break;
535
+ case "printer":
536
+ rect(c, "#4b5563", x + 2, y + 8, 12, 8);
537
+ rect(c, OUTLINE, x + 1, y - 2, 14, 11);
538
+ rect(c, "#d1d5db", x + 2, y - 1, 12, 9);
539
+ rect(c, "#f9fafb", x + 4, y - 5 - (Math.floor(time) % 4 === 0 ? 1 : 0), 8, 5);
540
+ rect(c, "#22c55e", x + 11, y + 1, 2, 1);
541
+ rect(c, "#9ca3af", x + 3, y + 5, 10, 1);
542
+ break;
543
+ case "lamp":
544
+ rect(c, "#374151", x + 5, y + 13, 6, 3);
545
+ rect(c, "#4b5563", x + 7, y - 4, 2, 17);
546
+ rect(c, OUTLINE, x + 2, y - 10, 12, 8);
547
+ rect(c, "#fde68a", x + 3, y - 9, 10, 6);
548
+ rect(c, "#fef3c7", x + 4, y - 8, 4, 2);
549
+ break;
550
+ case "beanbag":
551
+ rect(c, OUTLINE, x, y + 3, 16, 13);
552
+ rect(c, shade(palette.primary, -50), x + 1, y + 4, 14, 11);
553
+ rect(c, shade(palette.primary, -20), x + 3, y + 5, 8, 4);
554
+ rect(c, "#00000040", x + 1, y + 13, 14, 2);
555
+ break;
556
+ case "tv":
557
+ rect(c, "#3b2413", x + 2, y + 8, 44, 8);
558
+ rect(c, "#5b3a21", x + 2, y + 8, 44, 2);
559
+ rect(c, OUTLINE, x + 4, y - 14, 40, 22);
560
+ rect(c, "#0b1120", x + 5, y - 13, 38, 20);
561
+ for (let i = 0; i < 9; i++) {
562
+ const h = 2 + ((i * 5 + Math.floor(time * 3)) % 12);
563
+ rect(c, i % 3 === 0 ? palette.primary : i % 3 === 1 ? "#38bdf8" : "#f472b6", x + 8 + i * 4, y + 5 - h, 3, h);
564
+ }
565
+ break;
566
+ case "vending":
567
+ rect(c, OUTLINE, x, y - 14, 16, 30);
568
+ rect(c, "#b91c1c", x + 1, y - 13, 14, 28);
569
+ rect(c, "#0f172a", x + 3, y - 11, 8, 16);
570
+ for (let row = 0; row < 3; row++) for (let col = 0; col < 2; col++) rect(c, ["#fbbf24", "#34d399", "#60a5fa", "#f472b6"][(row + col * 2) % 4] as string, x + 4 + col * 3, y - 10 + row * 5, 2, 4);
571
+ rect(c, "#d1d5db", x + 12, y - 9, 2, 6);
572
+ rect(c, "#111827", x + 3, y + 8, 10, 4);
573
+ break;
574
+ case "trophy":
575
+ rect(c, "#6b4427", x + 2, y + 8, 12, 8);
576
+ rect(c, "#8a5a33", x + 2, y + 8, 12, 2);
577
+ rect(c, "#78350f", x + 5, y + 5, 6, 3);
578
+ rect(c, "#f59e0b", x + 7, y + 1, 2, 4);
579
+ rect(c, OUTLINE, x + 3, y - 8, 10, 10);
580
+ rect(c, "#fbbf24", x + 4, y - 7, 8, 8);
581
+ rect(c, "#fde68a", x + 5, y - 6, 2, 5);
582
+ rect(c, "#fbbf24", x + 1, y - 6, 3, 1); rect(c, "#fbbf24", x + 12, y - 6, 3, 1);
583
+ break;
584
+ case "clock": {
585
+ rect(c, OUTLINE, x + 3, y + 1, 10, 10);
586
+ rect(c, "#f9fafb", x + 4, y + 2, 8, 8);
587
+ rect(c, "#1f2937", x + 8, y + 3, 1, 3);
588
+ rect(c, "#1f2937", x + 8, y + 6, 3, 1);
589
+ break;
590
+ }
591
+ case "poster": {
592
+ const colors = ["#7c3aed", "#0ea5e9", "#db2777", "#059669"];
593
+ const color = colors[(item.x + item.y) % colors.length] as string;
594
+ rect(c, OUTLINE, x + 2, y, 12, 11);
595
+ rect(c, color, x + 3, y + 1, 10, 9);
596
+ rect(c, shade(color, 60), x + 5, y + 3, 6, 3);
597
+ rect(c, "#f9fafb", x + 5, y + 7, 6, 1);
598
+ break;
599
+ }
600
+ }
601
+ }
602
+
603
+ function nameTag(c: Ctx, agent: Agent, selected: boolean, width: number): void {
604
+ c.font = LABEL_FONT;
605
+ c.textBaseline = "top";
606
+ const w = Math.ceil(c.measureText(agent.name).width) + 5;
607
+ const x = Math.round(Math.max(1, Math.min(width - w - 1, agent.x * TILE + 8 - w / 2)));
608
+ const y = Math.round(agent.y * TILE + 16);
609
+ c.fillStyle = selected ? "#ffffff" : "rgba(17,24,39,0.82)";
610
+ c.fillRect(x, y, w, 10);
611
+ c.fillStyle = selected ? "#111827" : "#ffffff";
612
+ c.fillText(agent.name, x + 2.5, y + 1.5);
613
+ }
614
+
615
+ function wrap(c: Ctx, text: string, max: number): string[] {
616
+ const lines: string[] = [];
617
+ let line = "";
618
+ for (const word of text.split(/\s+/)) {
619
+ const next = line ? `${line} ${word}` : word;
620
+ if (c.measureText(next).width > max && line) {
621
+ lines.push(line);
622
+ line = word;
623
+ } else {
624
+ line = next;
625
+ }
626
+ }
627
+ if (line) lines.push(line);
628
+ return lines.slice(0, 3);
629
+ }
630
+
631
+ const STATE_LABEL: Record<string, string> = { working: "Working", thinking: "Thinking", blocked: "Blocked", reviewing: "Reviewing", idle: "On a break", done: "Finished", away: "Away" };
632
+
633
+ function drawCard(c: Ctx, agent: Agent, world: World): void {
634
+ c.font = BODY_FONT;
635
+ c.textBaseline = "top";
636
+ const room = world.rooms.find((candidate) => candidate.id === agent.area);
637
+ const body = wrap(c, agent.task || STATE_LABEL[agent.state] || "", 110);
638
+ const w = 120;
639
+ const h = 18 + body.length * 9 + (room ? 9 : 0) + 3;
640
+ const x = Math.round(Math.max(2, Math.min(world.cols * TILE - w - 2, agent.x * TILE + 8 - w / 2)));
641
+ const y = Math.round(Math.max(2, agent.y * TILE - h - 24));
642
+ rect(c, OUTLINE, x - 1, y - 1, w + 2, h + 2);
643
+ rect(c, "#ffffff", x, y, w, h);
644
+ c.fillStyle = "#111827";
645
+ c.font = LABEL_FONT;
646
+ c.fillText(`${agent.name}: ${STATE_LABEL[agent.state] ?? ""}`.slice(0, 28), x + 4, y + 4);
647
+ c.fillStyle = "#374151";
648
+ c.font = BODY_FONT;
649
+ body.forEach((line, index) => c.fillText(line, x + 4, y + 15 + index * 9));
650
+ if (room) {
651
+ c.fillStyle = "#6b7280";
652
+ c.fillText(`In ${room.name}`.slice(0, 28), x + 4, y + 15 + body.length * 9);
653
+ }
654
+ }
655
+
656
+ function drawProgress(c: Ctx, world: World, palette: Palette): void {
657
+ if (world.tasks.length === 0) return;
658
+ const done = world.tasks.filter((task) => task.state === "done").length;
659
+ c.font = LABEL_FONT;
660
+ c.textBaseline = "top";
661
+ const w = 96;
662
+ rect(c, "rgba(17,24,39,0.85)", 4, 4, w, 20);
663
+ c.fillStyle = "#ffffff";
664
+ c.fillText(`QUESTS ${done}/${world.tasks.length}`, 8, 6.5);
665
+ rect(c, "#374151", 8, 16, w - 8, 4);
666
+ rect(c, palette.primary, 8, 16, Math.round(((w - 8) * done) / world.tasks.length), 4);
667
+ }
668
+
669
+ /** Everything that moves. `building` is the layer from drawBuilding. */
670
+ export function drawWorld(
671
+ c: Ctx,
672
+ world: World,
673
+ time: number,
674
+ palette: Palette,
675
+ daylight: Daylight,
676
+ building: CanvasImageSource | undefined,
677
+ selected?: string,
678
+ ): void {
679
+ const width = world.cols * TILE;
680
+ const height = world.rows * TILE;
681
+ if (building) c.drawImage(building, 0, 0, width, height);
682
+ else drawBuilding(c, world, palette, daylight);
683
+
684
+ const scene: { depth: number; draw: () => void }[] = [];
685
+ for (const item of world.furniture) {
686
+ const spec = FURNITURE[item.type];
687
+ if (spec.solid === false) continue;
688
+ scene.push({ depth: (item.y + spec.h) * TILE - 2, draw: () => drawFurniture(c, item, world, time, palette) });
689
+ }
690
+ const shown = world.agents.filter((agent) => agent.state !== "away");
691
+ for (const agent of shown) scene.push({ depth: agent.y * TILE + 14, draw: () => drawAgent(c, agent, time, agent.id === selected) });
692
+ scene.sort((a, b) => a.depth - b.depth);
693
+ for (const item of scene) item.draw();
694
+
695
+ // Night: the room dims, and screens and lamps light it.
696
+ if (daylight.night > 0) {
697
+ c.fillStyle = `rgba(12,14,48,${daylight.night})`;
698
+ c.fillRect(0, 0, width, height);
699
+ c.save();
700
+ c.globalCompositeOperation = "lighter";
701
+ const glow = (x: number, y: number, radius: number, color: string): void => {
702
+ const gradient = c.createRadialGradient(x, y, 1, x, y, radius);
703
+ gradient.addColorStop(0, color);
704
+ gradient.addColorStop(1, "rgba(0,0,0,0)");
705
+ c.fillStyle = gradient;
706
+ c.fillRect(x - radius, y - radius, radius * 2, radius * 2);
707
+ };
708
+ for (const item of world.furniture) {
709
+ if (item.type === "lamp") glow(item.x * TILE + 8, item.y * TILE, 36, "rgba(253,224,139,0.30)");
710
+ if (item.type === "arcade" || item.type === "vending" || item.type === "tv") glow(item.x * TILE + 8, item.y * TILE, 26, "rgba(125,211,252,0.16)");
711
+ if (item.type === "desk" && world.agents.some((a) => a.seat?.x === item.x + 1 && a.seat?.y === item.y + 2 && isSeated(a))) {
712
+ glow(item.x * TILE + 24, item.y * TILE + 8, 30, "rgba(125,211,252,0.26)");
713
+ }
714
+ }
715
+ c.restore();
716
+ }
717
+
718
+ // On top, so tall furniture against a wall can't hide a room's name.
719
+ drawLabels(c, world);
720
+ for (const agent of shown) nameTag(c, agent, agent.id === selected, width);
721
+ for (const agent of shown) if (agent.id !== selected) drawStatus(c, agent, time);
722
+ drawProgress(c, world, palette);
723
+ const picked = shown.find((agent) => agent.id === selected);
724
+ if (picked) drawCard(c, picked, world);
725
+ }