@blkc-code/render-canvas 0.1.0 → 0.6.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.
Files changed (53) hide show
  1. package/dist/draw.d.ts +22 -12
  2. package/dist/draw.d.ts.map +1 -1
  3. package/dist/draw.js +374 -273
  4. package/dist/draw.js.map +1 -1
  5. package/dist/index.d.ts +4 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +4 -1
  8. package/dist/index.js.map +1 -1
  9. package/dist/links.d.ts +16 -0
  10. package/dist/links.d.ts.map +1 -0
  11. package/dist/links.js +75 -0
  12. package/dist/links.js.map +1 -0
  13. package/dist/office.d.ts +25 -2
  14. package/dist/office.d.ts.map +1 -1
  15. package/dist/office.js +134 -20
  16. package/dist/office.js.map +1 -1
  17. package/dist/pack.d.ts +31 -0
  18. package/dist/pack.d.ts.map +1 -0
  19. package/dist/pack.js +10 -0
  20. package/dist/pack.js.map +1 -0
  21. package/dist/paint.d.ts +15 -0
  22. package/dist/paint.d.ts.map +1 -0
  23. package/dist/paint.js +18 -0
  24. package/dist/paint.js.map +1 -0
  25. package/dist/runtime.js +1 -1
  26. package/dist/runtime.js.map +1 -1
  27. package/dist/runtime.txt +1 -1
  28. package/dist/types.d.ts +20 -3
  29. package/dist/types.d.ts.map +1 -1
  30. package/dist/world.d.ts +29 -13
  31. package/dist/world.d.ts.map +1 -1
  32. package/dist/world.js +150 -72
  33. package/dist/world.js.map +1 -1
  34. package/dist/worlds/office-data.d.ts +14 -0
  35. package/dist/worlds/office-data.d.ts.map +1 -0
  36. package/dist/worlds/office-data.js +52 -0
  37. package/dist/worlds/office-data.js.map +1 -0
  38. package/dist/worlds/office-paint.d.ts +13 -0
  39. package/dist/worlds/office-paint.d.ts.map +1 -0
  40. package/dist/worlds/office-paint.js +292 -0
  41. package/dist/worlds/office-paint.js.map +1 -0
  42. package/package.json +7 -2
  43. package/src/draw.ts +352 -272
  44. package/src/index.ts +4 -1
  45. package/src/links.ts +76 -0
  46. package/src/office.ts +142 -21
  47. package/src/pack.ts +36 -0
  48. package/src/paint.ts +24 -0
  49. package/src/runtime.ts +4 -1
  50. package/src/types.ts +31 -5
  51. package/src/world.ts +161 -77
  52. package/src/worlds/office-data.ts +57 -0
  53. package/src/worlds/office-paint.ts +276 -0
package/src/draw.ts CHANGED
@@ -1,107 +1,184 @@
1
1
  /**
2
- * Draws the office. Every piece of art here is original to this project, made of
3
- * rectangles and the character maps below at 16 pixels a tile.
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).
4
5
  */
5
6
 
6
- import { FURNITURE, isSeated, type Agent, type Item, type Room, type World } from "./world.js";
7
+ import { drawLinks, linkRoutes } from "./links.js";
8
+ import { BODY_FONT, LABEL_FONT, OUTLINE, TILE, rect, shade, type Ctx, type Palette } from "./paint.js";
9
+ import { isSeated, specOf, type Agent, type Item, type Room, type World } from "./world.js";
10
+ import { OFFICE_PAINTER } from "./worlds/office-paint.js";
7
11
 
8
- export const TILE = 16;
12
+ export { TILE, type Palette } from "./paint.js";
9
13
 
10
- export interface Palette { primary: string; background: string; text: string; accent: string }
14
+ // --- Worlds ---------------------------------------------------------------------------------------
11
15
 
12
- type Ctx = CanvasRenderingContext2D;
16
+ /** How one world looks. The engine draws the building, characters and labels around it. */
17
+ export interface Painter {
18
+ furniture(c: Ctx, item: Item, world: World, time: number, palette: Palette): void;
19
+ /** What lights up at night. Call `glow` for anything that does. */
20
+ glow(item: Item, world: World, glow: (x: number, y: number, radius: number, color: string) => void): void;
21
+ }
22
+
23
+ const painters = new Map<string, Painter>([["office", OFFICE_PAINTER]]);
13
24
 
14
- const rect = (c: Ctx, color: string, x: number, y: number, w: number, h: number): void => {
15
- c.fillStyle = color;
16
- c.fillRect(Math.round(x), Math.round(y), w, h);
17
- };
25
+ export function registerPainter(id: string, painter: Painter): void {
26
+ painters.set(id, painter);
27
+ }
18
28
 
19
- // --- Characters: 12 wide. h hair, s skin, e eye, t shirt, T shirt shadow, l legs, b shoes -----------------
29
+ const painterFor = (world: World): Painter => painters.get(world.pack.id) ?? OFFICE_PAINTER;
20
30
 
21
- const HEAD_FRONT = ["...hhhhhh...", "..hhhhhhhh..", "..hhhhhhhh..", "..hssssssh..", "..ssessess..", "..ssssssss..", "...ssssss..."];
22
- const HEAD_BACK = ["...hhhhhh...", "..hhhhhhhh..", "..hhhhhhhh..", "..hhhhhhhh..", "..hhhhhhhh..", "..hhhhhhhh..", "...ssssss..."];
23
- const HEAD_SIDE = ["...hhhhhh...", "..hhhhhhhh..", "..hhhhhhhh..", "..hhssssss..", "..hhsssess..", "..hsssssss..", "...ssssss..."];
24
- const BODY = ["..tttttttt..", ".tttttttttt.", ".stttttttts.", ".stTTTTTTts."];
25
- const BODY_SIDE = ["...tttttt...", "...tttttt...", "...ttsttt...", "...TTsTTT..."];
26
- const LEGS_STAND = ["..llllllll..", "..lll..lll..", "..lll..lll..", "..bbb..bbb.."];
27
- const LEGS_A = ["..llllllll..", "..lll..lll..", "..bbb..lll..", ".......bbb.."];
28
- const LEGS_B = ["..llllllll..", "..lll..lll..", "..lll..bbb..", "..bbb......."];
29
- const LEGS_SIDE_STAND = ["...llllll...", "...lll.lll..", "...lll.lll..", "...bbb.bbb.."];
30
- const LEGS_SIDE_A = ["...llllll...", "..lll..lll..", ".lll....lll.", ".bb......bb."];
31
- const LEGS_SIDE_B = ["...llllll...", "...llllll...", "....llll....", "....bbbb...."];
31
+ // --- Time of day, from the viewer's own clock ------------------------------------------------------------
32
32
 
33
- function drawMap(c: Ctx, map: string[], x: number, y: number, colors: Record<string, string>, flip = false): void {
34
- map.forEach((row, ry) => {
35
- for (let rx = 0; rx < row.length; rx++) {
36
- const key = row[flip ? row.length - 1 - rx : rx] as string;
37
- const color = colors[key];
38
- if (color) rect(c, color, x + rx, y + ry, 1, 1);
39
- }
40
- });
33
+ export interface Daylight { sky: string; sun: string; night: number }
34
+
35
+ export function daylightAt(hour: number): Daylight {
36
+ if (hour >= 7 && hour < 17) return { sky: "#8ed0ff", sun: "rgba(255,244,214,0.10)", night: 0 };
37
+ if (hour >= 17 && hour < 20) return { sky: "#ffae70", sun: "rgba(255,170,110,0.13)", night: 0.14 };
38
+ if (hour >= 5 && hour < 7) return { sky: "#ffc9a3", sun: "rgba(255,200,170,0.08)", night: 0.16 };
39
+ return { sky: "#16204a", sun: "rgba(0,0,0,0)", night: 0.24 };
41
40
  }
42
41
 
43
- function shade(hex: string, amount: number): string {
44
- const n = parseInt(hex.slice(1).padEnd(6, hex.slice(-1)), 16);
45
- const channel = (shift: number): number => Math.max(0, Math.min(255, ((n >> shift) & 255) + amount));
46
- return `rgb(${channel(16)},${channel(8)},${channel(0)})`;
42
+ // --- Characters ------------------------------------------------------------------------------------------
43
+
44
+ type Part = [color: string, x: number, y: number, w: number, h: number];
45
+
46
+ /** Parts draw with one outline around the whole figure, so characters read clearly on any floor. */
47
+ function drawParts(c: Ctx, parts: Part[]): void {
48
+ c.fillStyle = OUTLINE;
49
+ for (const [, x, y, w, h] of parts) c.fillRect(Math.round(x) - 1, Math.round(y) - 1, w + 2, h + 2);
50
+ for (const [color, x, y, w, h] of parts) rect(c, color, x, y, w, h);
47
51
  }
48
52
 
49
53
  function drawAgent(c: Ctx, agent: Agent, time: number, selected: boolean): void {
50
- const px = agent.x * TILE + 2;
54
+ const left = agent.x * TILE + 2; // sprite is 12 wide inside a 16 tile
55
+ const feet = agent.y * TILE + 15;
51
56
  const seated = isSeated(agent);
52
- const colors = { h: agent.hair, s: agent.skin, e: "#1a1a24", t: agent.shirt, T: shade(agent.shirt, -36), l: "#39415a", b: "#1f2433" };
53
- const stride = Math.floor(time * 8 + agent.seed) % 2 === 0;
54
- const breathe = !agent.walking && Math.floor(time * 1.5 + agent.seed % 7) % 2 === 0 ? 1 : 0;
55
- const height = seated ? 11 : 15;
56
- // Seated people sit just in front of the desk, on a chair, with their heads clear of the keyboard.
57
- const py = agent.y * TILE + (seated ? 6 : -1) - (agent.walking && stride ? 1 : 0);
57
+ const walking = agent.walking;
58
+ const side = agent.facing === "left" || agent.facing === "right";
59
+ const back = agent.facing === "up";
60
+ const flip = agent.facing === "left";
61
+ const phase = Math.floor(time * 9 + (agent.seed % 5));
62
+ const step = walking ? phase % 4 : 0; // 0 pass, 1 left forward, 2 pass, 3 right forward
63
+ const bob = walking && step % 2 === 1 ? -1 : 0;
64
+ const breathe = !walking && !seated && Math.floor(time * 1.4 + (agent.seed % 7)) % 2 === 0 ? 1 : 0;
65
+ const party = agent.party > 0 ? -Math.abs(Math.round(Math.sin(time * 12) * 3)) : 0;
66
+
67
+ const skin = agent.skin;
68
+ const hair = agent.hair;
69
+ const shirt = agent.shirt;
70
+ const dark = shade(shirt, -40);
71
+ const light = shade(shirt, 24);
72
+ const legs = "#39415a";
73
+ const shoe = "#1f2433";
74
+ const style = agent.seed % 5; // short, long, bun, spiky, cap
75
+ const outfit = (agent.seed >>> 5) % 3; // tee, hoodie, shirt and tie
76
+
77
+ // Shadow, and a highlight under whoever is selected.
78
+ c.fillStyle = "rgba(0,0,0,0.28)";
79
+ c.fillRect(Math.round(left + 1), Math.round(feet - 1), 10, 2);
80
+ if (selected) {
81
+ c.fillStyle = "rgba(255,255,255,0.45)";
82
+ c.fillRect(Math.round(left - 2), Math.round(feet - 2), 16, 4);
83
+ }
84
+ if (agent.state === "blocked") {
85
+ c.fillStyle = Math.floor(time * 3) % 2 === 0 ? "rgba(239,68,68,0.55)" : "rgba(239,68,68,0.22)";
86
+ c.fillRect(Math.round(left - 2), Math.round(feet - 2), 16, 4);
87
+ }
88
+
89
+ const lift = bob + party;
90
+ const parts: Part[] = [];
91
+ const bodyTop = feet - (seated ? 13 : 13) + lift + breathe * 0;
92
+ const headTop = bodyTop - 8 + breathe;
58
93
 
94
+ // Chair behind someone sitting at a desk.
59
95
  if (seated) {
60
- rect(c, "#1f2937", px + 1, agent.y * TILE + 3, 10, 9);
61
- rect(c, "#374151", px + 2, agent.y * TILE + 11, 8, 3);
62
- rect(c, "#111827", px + 5, agent.y * TILE + 14, 2, 2);
96
+ rect(c, "#1f2937", left + 1, agent.y * TILE + 3, 10, 9);
97
+ rect(c, "#374151", left + 2, agent.y * TILE + 11, 8, 3);
98
+ rect(c, "#111827", left + 5, agent.y * TILE + 14, 2, 2);
63
99
  }
64
100
 
65
- // Shadow
66
- c.fillStyle = "rgba(0,0,0,0.25)";
67
- c.fillRect(Math.round(px + 2), Math.round(agent.y * TILE + 14), 8, 2);
68
-
69
- if (selected) {
70
- c.fillStyle = "rgba(255,255,255,0.28)";
71
- c.fillRect(Math.round(px - 1), Math.round(agent.y * TILE + 13), 14, 4);
101
+ // Legs
102
+ if (!seated) {
103
+ const legTop = bodyTop + 7;
104
+ if (side) {
105
+ const swing = step === 1 ? 2 : step === 3 ? -2 : 0;
106
+ parts.push([legs, left + 4 + swing, legTop, 3, 5], [shoe, left + 4 + swing + (flip ? -1 : 0), legTop + 5, 4, 1]);
107
+ parts.push([shade(legs, -12), left + 5 - swing, legTop, 3, 5], [shoe, left + 5 - swing + (flip ? -1 : 0), legTop + 5, 4, 1]);
108
+ } else {
109
+ const leftUp = step === 1 ? 1 : 0;
110
+ const rightUp = step === 3 ? 1 : 0;
111
+ parts.push([legs, left + 2, legTop, 3, 5 - leftUp], [shoe, left + 2, legTop + 5 - leftUp, 3, 1]);
112
+ parts.push([legs, left + 7, legTop, 3, 5 - rightUp], [shoe, left + 7, legTop + 5 - rightUp, 3, 1]);
113
+ }
72
114
  }
73
115
 
74
- const side = agent.facing === "left" || agent.facing === "right";
75
- const head = side ? HEAD_SIDE : agent.facing === "up" ? HEAD_BACK : HEAD_FRONT;
76
- const body = side ? BODY_SIDE : BODY;
77
- const legs = !agent.walking
78
- ? side ? LEGS_SIDE_STAND : LEGS_STAND
79
- : side ? (stride ? LEGS_SIDE_A : LEGS_SIDE_B) : stride ? LEGS_A : LEGS_B;
80
- const flip = agent.facing === "left";
116
+ // Body and arms
117
+ const swingArm = walking ? (step === 1 ? 1 : step === 3 ? -1 : 0) : 0;
118
+ if (side) {
119
+ parts.push([shirt, left + 3, bodyTop, 6, 7], [dark, left + 3, bodyTop + 6, 6, 1]);
120
+ parts.push([dark, left + 5 + swingArm, bodyTop + 1, 2, 5], [skin, left + 5 + swingArm, bodyTop + 6, 2, 1]);
121
+ } else {
122
+ parts.push([shirt, left + 1, bodyTop, 10, 7], [dark, left + 1, bodyTop + 6, 10, 1]);
123
+ const typing = seated && agent.state === "working";
124
+ const type = Math.floor(time * 10 + agent.seed) % 2;
125
+ parts.push([dark, left - 1, bodyTop + 1 + swingArm + (typing ? -type : 0), 2, 5]);
126
+ parts.push([dark, left + 11, bodyTop + 1 - swingArm + (typing ? type - 1 : 0), 2, 5]);
127
+ parts.push([skin, left - 1, bodyTop + 6 + swingArm + (typing ? -type : 0), 2, 1]);
128
+ parts.push([skin, left + 11, bodyTop + 6 - swingArm + (typing ? type - 1 : 0), 2, 1]);
129
+ if (!back && outfit === 2) parts.push(["#f3f4f6", left + 4, bodyTop, 4, 3], [agent.seed % 2 ? "#ef4444" : "#1d4ed8", left + 5, bodyTop, 2, 5]);
130
+ if (!back && outfit === 1) parts.push([light, left + 3, bodyTop, 6, 2], [dark, left + 5, bodyTop + 2, 2, 4]);
131
+ if (back && outfit === 1) parts.push([light, left + 2, bodyTop, 8, 3]);
132
+ }
81
133
 
82
- drawMap(c, head, px, py - height + breathe * 0, colors, flip);
83
- drawMap(c, body, px, py - height + head.length, colors, flip);
84
- if (!seated) drawMap(c, legs, px, py - height + head.length + body.length, colors, flip);
85
- else if (agent.state === "working") {
86
- // Hands on the keys.
87
- const typing = Math.floor(time * 9 + agent.seed) % 2;
88
- rect(c, agent.skin, px + 1 + typing, py - height + head.length + 3, 1, 1);
89
- rect(c, agent.skin, px + 10 - (1 - typing), py - height + head.length + 3, 1, 1);
134
+ // Head
135
+ parts.push([skin, left + 1, headTop, 10, 8]);
136
+ if (back) {
137
+ parts.push([hair, left + 1, headTop, 10, 7]);
138
+ } else if (side) {
139
+ const backX = flip ? left + 7 : left + 1;
140
+ parts.push([hair, left + 1, headTop, 10, 3], [hair, backX, headTop, 4, 6]);
141
+ const blink = Math.floor(time * 0.7 + agent.seed % 11) % 7 === 0;
142
+ parts.push([blink ? shade(skin, -40) : "#1a1a24", flip ? left + 3 : left + 8, headTop + 4, 1, blink ? 1 : 2]);
143
+ } else {
144
+ parts.push([hair, left + 1, headTop, 10, 3], [hair, left + 1, headTop + 3, 1, 2], [hair, left + 10, headTop + 3, 1, 2]);
145
+ const blink = Math.floor(time * 0.7 + agent.seed % 11) % 7 === 0;
146
+ parts.push([blink ? shade(skin, -40) : "#1a1a24", left + 3, headTop + 4, 2, blink ? 1 : 2]);
147
+ parts.push([blink ? shade(skin, -40) : "#1a1a24", left + 7, headTop + 4, 2, blink ? 1 : 2]);
148
+ parts.push([shade(skin, -28), left + 5, headTop + 6, 2, 1]);
149
+ }
150
+ if (style === 1) parts.push([hair, left, headTop + 2, 2, 8], [hair, left + 10, headTop + 2, 2, 8]); // long
151
+ if (style === 2) parts.push([hair, left + 4, headTop - 3, 4, 3]); // bun
152
+ 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
153
+ if (style === 4) { // cap
154
+ parts.push([light, left, headTop - 1, 12, 3]);
155
+ if (!back && !side) parts.push([dark, left + 1, headTop + 2, 10, 1]);
156
+ if (side) parts.push([dark, flip ? left - 2 : left + 9, headTop + 1, 5, 1]);
157
+ }
158
+ drawParts(c, parts);
159
+
160
+ if (agent.party > 0) {
161
+ const colors = ["#f59e0b", "#ec4899", "#22c55e", "#3b82f6", "#a855f7"];
162
+ for (let i = 0; i < 10; i++) {
163
+ const t = (time * 1.1 + i * 0.137) % 1;
164
+ const angle = i * 2.4 + agent.seed;
165
+ 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);
166
+ }
90
167
  }
91
168
  }
92
169
 
93
170
  function bubble(c: Ctx, x: number, y: number, draw: (x: number, y: number) => void): void {
94
- rect(c, "#111827", x - 1, y - 1, 13, 11);
171
+ rect(c, OUTLINE, x - 1, y - 1, 13, 11);
95
172
  rect(c, "#ffffff", x, y, 11, 9);
96
173
  rect(c, "#ffffff", x + 4, y + 9, 3, 1);
97
- rect(c, "#111827", x + 4, y + 10, 3, 1);
174
+ rect(c, OUTLINE, x + 4, y + 10, 3, 1);
98
175
  draw(x, y);
99
176
  }
100
177
 
101
178
  function drawStatus(c: Ctx, agent: Agent, time: number): void {
102
179
  const x = Math.round(agent.x * TILE + 2);
103
- const y = Math.round(agent.y * TILE - 26 + (isSeated(agent) ? 8 : 0));
104
- const pulse = Math.floor(time * 2 + agent.seed % 3) % 2 === 0;
180
+ const y = Math.round(agent.y * TILE - 22 + (isSeated(agent) ? 2 : -2));
181
+ const pulse = Math.floor(time * 2 + (agent.seed % 3)) % 2 === 0;
105
182
  if (agent.state === "blocked") {
106
183
  bubble(c, x, y, (bx, by) => {
107
184
  rect(c, pulse ? "#dc2626" : "#991b1b", bx + 5, by + 1, 2, 5);
@@ -122,232 +199,200 @@ function drawStatus(c: Ctx, agent: Agent, time: number): void {
122
199
  bubble(c, x, y, (bx, by) => {
123
200
  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);
124
201
  });
202
+ } else if (agent.state === "idle" && !agent.walking && agent.seed % 3 === 0) {
203
+ bubble(c, x, y, (bx, by) => {
204
+ 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);
205
+ if (pulse) rect(c, "#9ca3af", bx + 4, by + 1, 1, 1);
206
+ });
125
207
  }
126
208
  }
127
209
 
128
- // --- The room -------------------------------------------------------------------------------------------
210
+ // --- The building ----------------------------------------------------------------------------------------
129
211
 
130
- function drawFloor(c: Ctx, room: Room, palette: Palette): void {
212
+ function drawFloor(c: Ctx, room: Room): void {
131
213
  for (let ty = room.y; ty < room.y + room.h; ty++) {
132
214
  for (let tx = room.x; tx < room.x + room.w; tx++) {
133
215
  const x = tx * TILE;
134
216
  const y = ty * TILE;
135
- if (room.floor === "carpet") {
136
- rect(c, "#4c4f7a", x, y, TILE, TILE);
137
- for (let i = 0; i < 6; i++) rect(c, "#55588a", x + ((tx * 7 + i * 5) % 15), y + ((ty * 3 + i * 7) % 15), 1, 1);
138
- } else if (room.floor === "tile") {
139
- rect(c, (tx + ty) % 2 === 0 ? "#cfd8e3" : "#bcc7d4", x, y, TILE, TILE);
140
- rect(c, "#aab5c2", x, y + 15, TILE, 1);
141
- rect(c, "#aab5c2", x + 15, y, 1, TILE);
142
- } else {
143
- rect(c, "#a06f45", x, y, TILE, TILE);
144
- const offset = ty % 2 === 0 ? 0 : 8;
145
- rect(c, "#8f6039", x, y + 7, TILE, 1);
146
- rect(c, "#8f6039", x, y + 15, TILE, 1);
147
- rect(c, "#8f6039", x + ((offset + tx * 8) % 16), y, 1, 7);
148
- rect(c, "#ad7c52", x + 3, y + 3, 4, 1);
217
+ const noise = (tx * 73 + ty * 151) % 17;
218
+ switch (room.floor) {
219
+ case "carpet":
220
+ rect(c, "#4c4f7a", x, y, TILE, TILE);
221
+ 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);
222
+ break;
223
+ case "tile":
224
+ rect(c, (tx + ty) % 2 === 0 ? "#d6dee8" : "#c2ccd8", x, y, TILE, TILE);
225
+ rect(c, "#aab5c2", x, y + 15, TILE, 1);
226
+ rect(c, "#aab5c2", x + 15, y, 1, TILE);
227
+ rect(c, "#e8eef5", x + 1, y + 1, 5, 1);
228
+ break;
229
+ case "stone":
230
+ rect(c, noise % 3 === 0 ? "#8d93a3" : "#9aa0b0", x, y, TILE, TILE);
231
+ rect(c, "#7a8090", x, y + 15, TILE, 1);
232
+ rect(c, "#7a8090", x + (ty % 2 ? 7 : 15), y, 1, 15);
233
+ rect(c, "#adb3c2", x + 2, y + 2, 3, 1);
234
+ break;
235
+ case "concrete":
236
+ rect(c, "#6b7280", x, y, TILE, TILE);
237
+ 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);
238
+ if (noise === 3) rect(c, "#596070", x + 3, y + 8, 8, 1);
239
+ break;
240
+ case "hall": {
241
+ // Polished floor, with a runner down the middle of a corridor's length.
242
+ rect(c, "#cfc8b8", x, y, TILE, TILE);
243
+ rect(c, "#bdb6a6", x, y + 15, TILE, 1);
244
+ rect(c, "#bdb6a6", x + 15, y, 1, TILE);
245
+ const along = room.w >= room.h;
246
+ const offset = along ? ty - room.y : tx - room.x;
247
+ const size = along ? room.h : room.w;
248
+ if (along && offset === 0) rect(c, "#7f6a55", x, y + 12, TILE, 4);
249
+ if (along && offset === size - 1) rect(c, "#7f6a55", x, y, TILE, 4);
250
+ if (!along && offset === 0) rect(c, "#7f6a55", x + 12, y, 4, TILE);
251
+ if (!along && offset === size - 1) rect(c, "#7f6a55", x, y, 4, TILE);
252
+ break;
253
+ }
254
+ case "grass":
255
+ rect(c, "#3f8f4f", x, y, TILE, TILE);
256
+ 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);
257
+ break;
258
+ default: {
259
+ const plank = ty % 2 === 0 ? "#a57448" : "#9c6c42";
260
+ rect(c, plank, x, y, TILE, TILE);
261
+ rect(c, "#8a5d36", x, y + 7, TILE, 1);
262
+ rect(c, "#8a5d36", x, y + 15, TILE, 1);
263
+ rect(c, "#8a5d36", x + ((tx * 8 + (ty % 2 ? 8 : 0)) % 16), y, 1, 7);
264
+ rect(c, "#8a5d36", x + ((tx * 8 + (ty % 2 ? 0 : 8)) % 16), y + 8, 1, 7);
265
+ rect(c, "#b4835a", x + (noise % 9) + 2, y + 3, 4, 1);
266
+ rect(c, "#b4835a", x + (noise % 7) + 4, y + 11, 3, 1);
267
+ }
149
268
  }
150
269
  }
151
270
  }
152
- void palette;
153
271
  }
154
272
 
155
- const WALL_TOP = "#2f3350";
156
- const WALL_FACE = "#565c86";
157
- const WALL_LINE = "#454a70";
273
+ const WALL_TOP = "#2a2d48";
274
+ const WALL_FACE = "#5c628f";
275
+ const WALL_DARK = "#4a4f78";
276
+ const WALL_LIGHT = "#6d74a4";
277
+
278
+ /** Outside the building: lawn, with the odd shrub and tree. */
279
+ function drawGrounds(c: Ctx, tx: number, ty: number): void {
280
+ const x = tx * TILE;
281
+ const y = ty * TILE;
282
+ rect(c, (tx + ty) % 2 === 0 ? "#4b8a4f" : "#478549", x, y, TILE, TILE);
283
+ const noise = (tx * 73 + ty * 151) % 29;
284
+ for (let i = 0; i < 4; i++) rect(c, "#5a9b5c", x + ((tx * 5 + i * 7) % 15), y + ((ty * 3 + i * 5) % 15), 1, 2);
285
+ if (noise === 4) {
286
+ rect(c, "#2f6a38", x + 3, y + 7, 10, 7);
287
+ rect(c, "#3c7f45", x + 5, y + 8, 5, 3);
288
+ } else if (noise === 11) {
289
+ rect(c, "#5b3a22", x + 7, y + 10, 2, 5);
290
+ rect(c, "#24592e", x + 2, y + 1, 12, 10);
291
+ rect(c, "#2f6f3a", x + 4, y + 2, 7, 5);
292
+ }
293
+ }
158
294
 
159
- function drawWalls(c: Ctx, world: World, palette: Palette): void {
295
+ function drawWalls(c: Ctx, world: World, palette: Palette, daylight: Daylight): void {
160
296
  const open = new Set<string>();
161
297
  for (const room of world.rooms) {
162
298
  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}`);
163
299
  }
164
- for (const door of world.doors) open.add(`${door.x},${door.y}`);
300
+ const doors = new Set(world.doors.map((door) => `${door.x},${door.y}`));
165
301
 
166
302
  for (let ty = 0; ty < world.rows; ty++) {
167
303
  for (let tx = 0; tx < world.cols; tx++) {
168
- if (open.has(`${tx},${ty}`)) continue;
304
+ const key = `${tx},${ty}`;
305
+ if (open.has(key) || doors.has(key)) continue;
169
306
  const x = tx * TILE;
170
307
  const y = ty * TILE;
171
- const faceBelow = open.has(`${tx},${ty + 1}`);
172
- if (faceBelow) {
173
- // The wall's front, with a band in the Kit's colour and a window every few tiles.
308
+ const nearBuilding = (cx: number, cy: number): boolean => {
309
+ for (let ay = cy - 1; ay <= cy + 1; ay++) {
310
+ for (let ax = cx - 1; ax <= cx + 1; ax++) if (open.has(`${ax},${ay}`) || doors.has(`${ax},${ay}`)) return true;
311
+ }
312
+ return false;
313
+ };
314
+ if (!nearBuilding(tx, ty)) {
315
+ drawGrounds(c, tx, ty);
316
+ continue;
317
+ }
318
+ if (open.has(`${tx},${ty + 1}`)) {
174
319
  rect(c, WALL_FACE, x, y, TILE, TILE);
175
- rect(c, WALL_LINE, x, y + 13, TILE, 3);
176
- rect(c, palette.primary, x, y + 12, TILE, 1);
177
- if (tx % 4 === 3 && ty <= 3) {
320
+ rect(c, WALL_LIGHT, x, y, TILE, 1);
321
+ rect(c, WALL_DARK, x, y + 12, TILE, 4);
322
+ rect(c, palette.primary, x, y + 11, TILE, 1);
323
+ // Windows look outside, so only an outer wall has them.
324
+ if (tx % 4 === 3 && !nearBuilding(tx, ty - 1) && !world.furniture.some((item) => specOf(world, item).wall && item.x === tx && item.y === ty)) {
325
+ // A window, in the colour of the sky right now.
178
326
  rect(c, "#1f2937", x + 1, y + 1, 14, 9);
179
- rect(c, "#8ed0ff", x + 2, y + 2, 12, 7);
180
- rect(c, "#bfe6ff", x + 3, y + 3, 4, 2);
327
+ rect(c, daylight.sky, x + 2, y + 2, 12, 7);
328
+ rect(c, shade(daylight.sky, 40), x + 3, y + 3, 4, 2);
181
329
  rect(c, "#1f2937", x + 8, y + 2, 1, 7);
330
+ if (daylight.night > 0.3) { rect(c, "#fef3c7", x + 4, y + 3, 1, 1); rect(c, "#fef3c7", x + 11, y + 5, 1, 1); }
182
331
  }
183
332
  } else {
184
333
  rect(c, WALL_TOP, x, y, TILE, TILE);
334
+ if (open.has(`${tx},${ty - 1}`)) rect(c, "#20233a", x, y, TILE, 3);
335
+ if (open.has(`${tx - 1},${ty}`)) rect(c, "#20233a", x, y, 2, TILE);
336
+ if (open.has(`${tx + 1},${ty}`)) rect(c, "#20233a", x + 14, y, 2, TILE);
185
337
  }
186
338
  }
187
339
  }
188
340
  for (const door of world.doors) {
189
- rect(c, "#6b4a2f", door.x * TILE, door.y * TILE, TILE, TILE);
190
- rect(c, "#7c5637", door.x * TILE + 2, door.y * TILE, 12, TILE);
191
- }
192
- }
193
-
194
- function drawDesk(c: Ctx, x: number, y: number, on: boolean, time: number, seed: number, palette: Palette): void {
195
- rect(c, "#6b4427", x + 1, y + 13, 46, 17);
196
- rect(c, "#8a5a33", x + 1, y + 11, 46, 11);
197
- rect(c, "#a06c3f", x + 1, y + 11, 46, 2);
198
- rect(c, "#4a2e1a", x + 3, y + 22, 3, 9);
199
- rect(c, "#4a2e1a", x + 42, y + 22, 3, 9);
200
- // Monitor
201
- rect(c, "#111827", x + 15, y - 1, 18, 13);
202
- rect(c, "#374151", x + 22, y + 12, 4, 2);
203
- if (on) {
204
- rect(c, "#0f2a2e", x + 16, y, 16, 11);
205
- const scroll = Math.floor(time * 3 + seed % 13);
206
- for (let line = 0; line < 4; line++) {
207
- const width = 3 + ((seed >> line) + (scroll + line) * 7) % 11;
208
- rect(c, line % 2 === 0 ? palette.primary : "#7dd3fc", x + 17, y + 1 + line * 2.5, width, 1);
209
- }
210
- c.fillStyle = "rgba(125,211,252,0.10)";
211
- c.fillRect(x + 11, y + 11, 26, 8);
212
- } else {
213
- rect(c, "#1f2937", x + 16, y, 16, 11);
214
- rect(c, "#374151", x + 18, y + 2, 3, 1);
215
- }
216
- // Keyboard and mug
217
- rect(c, "#d1d5db", x + 17, y + 15, 14, 4);
218
- rect(c, "#9ca3af", x + 18, y + 16, 12, 1);
219
- rect(c, "#f3f4f6", x + 38, y + 14, 4, 4);
220
- rect(c, palette.primary, x + 38, y + 16, 4, 1);
221
- }
222
-
223
- function drawBoard(c: Ctx, x: number, y: number, world: World): void {
224
- rect(c, "#2b2118", x, y - 11, 64, 26);
225
- rect(c, "#d9c29c", x + 2, y - 9, 60, 22);
226
- const colors = { todo: "#fde68a", doing: "#93c5fd", done: "#86efac" };
227
- const per = 8;
228
- world.tasks.slice(0, per * 2).forEach((task, index) => {
229
- const col = index % per;
230
- const row = Math.floor(index / per);
231
- rect(c, "#00000033", x + 5 + col * 7, y - 6 + row * 9, 6, 7);
232
- rect(c, colors[task.state], x + 4 + col * 7, y - 7 + row * 9, 6, 7);
233
- rect(c, "#00000040", x + 5 + col * 7, y - 5 + row * 9, 4, 1);
234
- });
235
- }
236
-
237
- function drawFurniture(c: Ctx, item: Item, world: World, time: number, palette: Palette): void {
238
- const x = item.x * TILE;
239
- const y = item.y * TILE;
240
- switch (item.type) {
241
- case "desk": {
242
- const seatX = item.x + 1;
243
- const seatY = item.y + 2;
244
- const user = world.agents.find((a) => a.seat?.x === seatX && a.seat?.y === seatY && isSeated(a) && a.state !== "blocked");
245
- drawDesk(c, x, y, Boolean(user), time, user?.seed ?? 0, palette);
246
- break;
247
- }
248
- case "coffee":
249
- rect(c, "#374151", x, y + 2, 32, 14);
250
- rect(c, "#6b7280", x, y + 2, 32, 2);
251
- rect(c, "#111827", x + 4, y - 8, 14, 14);
252
- rect(c, "#ef4444", x + 15, y - 5, 2, 2);
253
- rect(c, "#f3f4f6", x + 8, y + 3, 5, 4);
254
- rect(c, "#f3f4f6", x + 22, y - 1, 5, 5);
255
- for (let i = 0; i < 3; i++) {
256
- c.fillStyle = "rgba(255,255,255,0.45)";
257
- c.fillRect(x + 9 + Math.round(Math.sin(time * 2 + i) * 1.5), y - 1 - ((time * 8 + i * 4) % 9), 1, 2);
258
- }
259
- break;
260
- case "water":
261
- rect(c, "#e5e7eb", x + 3, y + 3, 10, 13);
262
- rect(c, "#7dd3fc", x + 4, y - 7, 8, 10);
263
- rect(c, "#bae6fd", x + 5, y - 6, 2, 8);
264
- rect(c, "#3b82f6", x + 5, y + 6, 2, 2);
265
- break;
266
- case "sofa":
267
- rect(c, palette.primary, x + 1, y - 6, 46, 9);
268
- rect(c, "#00000030", x + 1, y + 1, 46, 2);
269
- rect(c, "#4b5563", x, y + 3, 48, 11);
270
- rect(c, "#6b7280", x + 3, y + 3, 20, 7);
271
- rect(c, "#6b7280", x + 25, y + 3, 20, 7);
272
- rect(c, "#374151", x, y + 12, 48, 3);
273
- break;
274
- case "plant":
275
- rect(c, "#92400e", x + 4, y + 9, 8, 7);
276
- rect(c, "#b45309", x + 3, y + 8, 10, 2);
277
- 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) {
278
- rect(c, "#15803d", x + lx, y + ly, lw, lh);
279
- }
280
- rect(c, "#22c55e", x + 7, y - 3, 2, 8);
281
- rect(c, "#22c55e", x + 3, y + 1, 2, 3);
282
- break;
283
- case "board":
284
- drawBoard(c, x, y, world);
285
- break;
286
- case "shelf":
287
- rect(c, "#5b3a21", x, y - 12, 32, 28);
288
- for (let shelf = 0; shelf < 2; shelf++) {
289
- rect(c, "#3b2413", x + 2, y - 10 + shelf * 13, 28, 11);
290
- for (let book = 0; book < 8; book++) {
291
- const color = ["#ef4444", "#3b82f6", "#10b981", "#f59e0b", "#8b5cf6"][(book * 3 + shelf) % 5] as string;
292
- rect(c, color, x + 3 + book * 3, y - 8 + shelf * 13 + (book % 3), 2, 9 - (book % 3));
293
- }
294
- }
295
- break;
296
- case "server":
297
- rect(c, "#111827", x + 1, y - 12, 14, 28);
298
- for (let i = 0; i < 5; i++) {
299
- rect(c, "#1f2937", x + 2, y - 11 + i * 5, 12, 4);
300
- const on = Math.floor(time * 3 + i * 2 + item.x) % 3 !== 0;
301
- rect(c, on ? "#22c55e" : "#14532d", x + 3, y - 10 + i * 5, 1, 1);
302
- rect(c, i % 2 === 0 ? "#f59e0b" : "#3b82f6", x + 5, y - 10 + i * 5, 1, 1);
303
- }
304
- break;
305
- case "table":
306
- rect(c, "#57534e", x + 2, y + 14, 44, 14);
307
- rect(c, "#78716c", x + 2, y + 3, 44, 14);
308
- rect(c, "#a8a29e", x + 2, y + 3, 44, 2);
309
- rect(c, "#f9fafb", x + 10, y + 6, 8, 6);
310
- rect(c, "#f9fafb", x + 28, y + 8, 8, 6);
311
- break;
312
- case "rug":
313
- rect(c, palette.primary, x, y, 64, 48);
314
- c.fillStyle = "rgba(0,0,0,0.35)";
315
- c.fillRect(x + 3, y + 3, 58, 42);
316
- rect(c, palette.primary, x + 6, y + 6, 52, 1);
317
- rect(c, palette.primary, x + 6, y + 41, 52, 1);
318
- break;
341
+ const x = door.x * TILE;
342
+ const y = door.y * TILE;
343
+ const horizontal = open.has(`${door.x},${door.y - 1}`) || open.has(`${door.x},${door.y + 1}`);
344
+ rect(c, "#7c5637", x, y, TILE, TILE);
345
+ 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);
346
+ if (horizontal) { rect(c, WALL_DARK, x, y, 2, TILE); rect(c, WALL_DARK, x + 14, y, 2, TILE); }
347
+ else { rect(c, WALL_DARK, x, y, TILE, 2); rect(c, WALL_DARK, x, y + 14, TILE, 2); }
319
348
  }
320
- void FURNITURE;
321
349
  }
322
350
 
323
351
  function drawLabels(c: Ctx, world: World): void {
324
- c.font = "bold 7px ui-monospace, Menlo, Consolas, monospace";
352
+ c.font = LABEL_FONT;
325
353
  c.textBaseline = "top";
326
354
  for (const room of world.rooms) {
327
355
  if (!room.name) continue;
328
356
  const label = room.name.toUpperCase();
329
- // On the wall above the room, clear of the furniture.
357
+ const width = Math.ceil(c.measureText(label).width) + 6;
330
358
  const x = room.x * TILE + 3;
331
- const y = room.y * TILE - 12;
332
- c.fillStyle = "rgba(0,0,0,0.45)";
333
- c.fillText(label, x + 1, y + 1);
334
- c.fillStyle = "rgba(255,255,255,0.85)";
335
- c.fillText(label, x, y);
359
+ const y = room.y * TILE - 13;
360
+ rect(c, "#1c1f33", x, y, width, 10);
361
+ rect(c, "#2f3350", x + 1, y + 1, width - 2, 8);
362
+ c.fillStyle = "#f8fafc";
363
+ c.fillText(label, x + 3, y + 1.5);
364
+ }
365
+ }
366
+
367
+ /** Everything that doesn't move. Redrawn only when the building or the hour changes. */
368
+ export function drawBuilding(c: Ctx, world: World, palette: Palette, daylight: Daylight): void {
369
+ rect(c, WALL_TOP, 0, 0, world.cols * TILE, world.rows * TILE);
370
+ for (const room of world.rooms) drawFloor(c, room);
371
+ drawWalls(c, world, palette, daylight);
372
+ for (const item of world.furniture) if (item.type === "rug") painterFor(world).furniture(c, item, world, 0, palette);
373
+ for (const item of world.furniture) if (specOf(world, item).wall) painterFor(world).furniture(c, item, world, 0, palette);
374
+ // Light from the windows falling across the floor.
375
+ if (daylight.sun !== "rgba(0,0,0,0)") {
376
+ c.fillStyle = daylight.sun;
377
+ for (const room of world.rooms) {
378
+ for (let tx = room.x; tx < room.x + room.w; tx++) {
379
+ if (tx % 4 !== 3) continue;
380
+ c.fillRect(tx * TILE + 2, room.y * TILE, 12, Math.min(room.h, 3) * TILE);
381
+ }
382
+ }
336
383
  }
337
384
  }
338
385
 
339
386
  function nameTag(c: Ctx, agent: Agent, selected: boolean, width: number): void {
340
- const name = agent.name;
341
- c.font = "bold 7px ui-monospace, Menlo, Consolas, monospace";
387
+ c.font = LABEL_FONT;
342
388
  c.textBaseline = "top";
343
- const w = Math.ceil(c.measureText(name).width) + 5;
344
- const cx = agent.x * TILE + 8;
345
- const x = Math.round(Math.max(1, Math.min(width - w - 1, cx - w / 2)));
346
- const y = Math.round(agent.y * TILE + 15);
389
+ const w = Math.ceil(c.measureText(agent.name).width) + 5;
390
+ const x = Math.round(Math.max(1, Math.min(width - w - 1, agent.x * TILE + 8 - w / 2)));
391
+ const y = Math.round(agent.y * TILE + 16);
347
392
  c.fillStyle = selected ? "#ffffff" : "rgba(17,24,39,0.82)";
348
393
  c.fillRect(x, y, w, 10);
349
394
  c.fillStyle = selected ? "#111827" : "#ffffff";
350
- c.fillText(name, x + 2.5, y + 1.5);
395
+ c.fillText(agent.name, x + 2.5, y + 1.5);
351
396
  }
352
397
 
353
398
  function wrap(c: Ctx, text: string, max: number): string[] {
@@ -368,62 +413,97 @@ function wrap(c: Ctx, text: string, max: number): string[] {
368
413
 
369
414
  const STATE_LABEL: Record<string, string> = { working: "Working", thinking: "Thinking", blocked: "Blocked", reviewing: "Reviewing", idle: "On a break", done: "Finished", away: "Away" };
370
415
 
371
- function drawCard(c: Ctx, agent: Agent, width: number): void {
372
- c.font = "7px ui-monospace, Menlo, Consolas, monospace";
416
+ function drawCard(c: Ctx, agent: Agent, world: World): void {
417
+ c.font = BODY_FONT;
373
418
  c.textBaseline = "top";
374
- const body = wrap(c, agent.task || STATE_LABEL[agent.state] || "", 104);
375
- const w = 114;
376
- const h = 18 + body.length * 9 + 3;
377
- const x = Math.round(Math.max(2, Math.min(width - w - 2, agent.x * TILE + 8 - w / 2)));
378
- const y = Math.round(Math.max(2, agent.y * TILE - h - 22));
379
- rect(c, "#111827", x - 1, y - 1, w + 2, h + 2);
419
+ const room = world.rooms.find((candidate) => candidate.id === agent.area);
420
+ const body = wrap(c, agent.task || STATE_LABEL[agent.state] || "", 110);
421
+ const w = 120;
422
+ const h = 18 + body.length * 9 + (room ? 9 : 0) + 3;
423
+ const x = Math.round(Math.max(2, Math.min(world.cols * TILE - w - 2, agent.x * TILE + 8 - w / 2)));
424
+ const y = Math.round(Math.max(2, agent.y * TILE - h - 24));
425
+ rect(c, OUTLINE, x - 1, y - 1, w + 2, h + 2);
380
426
  rect(c, "#ffffff", x, y, w, h);
381
427
  c.fillStyle = "#111827";
382
- c.font = "bold 7px ui-monospace, Menlo, Consolas, monospace";
383
- c.fillText(`${agent.name}: ${STATE_LABEL[agent.state] ?? ""}`.slice(0, 26), x + 4, y + 4);
428
+ c.font = LABEL_FONT;
429
+ c.fillText(`${agent.name}: ${STATE_LABEL[agent.state] ?? ""}`.slice(0, 28), x + 4, y + 4);
384
430
  c.fillStyle = "#374151";
385
- c.font = "7px ui-monospace, Menlo, Consolas, monospace";
431
+ c.font = BODY_FONT;
386
432
  body.forEach((line, index) => c.fillText(line, x + 4, y + 15 + index * 9));
433
+ if (room) {
434
+ c.fillStyle = "#6b7280";
435
+ c.fillText(`In ${room.name}`.slice(0, 28), x + 4, y + 15 + body.length * 9);
436
+ }
387
437
  }
388
438
 
389
439
  function drawProgress(c: Ctx, world: World, palette: Palette): void {
390
440
  if (world.tasks.length === 0) return;
391
441
  const done = world.tasks.filter((task) => task.state === "done").length;
392
- const label = `QUESTS ${done}/${world.tasks.length}`;
393
- c.font = "bold 7px ui-monospace, Menlo, Consolas, monospace";
442
+ c.font = LABEL_FONT;
394
443
  c.textBaseline = "top";
395
444
  const w = 96;
396
445
  rect(c, "rgba(17,24,39,0.85)", 4, 4, w, 20);
397
446
  c.fillStyle = "#ffffff";
398
- c.fillText(label, 8, 6.5);
447
+ c.fillText(`${world.pack.words.quests} ${done}/${world.tasks.length}`, 8, 6.5);
399
448
  rect(c, "#374151", 8, 16, w - 8, 4);
400
449
  rect(c, palette.primary, 8, 16, Math.round(((w - 8) * done) / world.tasks.length), 4);
401
450
  }
402
451
 
403
- export function drawWorld(c: Ctx, world: World, time: number, palette: Palette, selected?: string): void {
452
+ /** Everything that moves. `building` is the layer from drawBuilding. */
453
+ export function drawWorld(
454
+ c: Ctx,
455
+ world: World,
456
+ time: number,
457
+ palette: Palette,
458
+ daylight: Daylight,
459
+ building: CanvasImageSource | undefined,
460
+ selected?: string,
461
+ ): void {
404
462
  const width = world.cols * TILE;
405
- rect(c, WALL_TOP, 0, 0, width, world.rows * TILE);
406
- for (const room of world.rooms) drawFloor(c, room, palette);
407
- drawWalls(c, world, palette);
463
+ const height = world.rows * TILE;
464
+ if (building) c.drawImage(building, 0, 0, width, height);
465
+ else drawBuilding(c, world, palette, daylight);
466
+
467
+ // On the floor, so people and furniture stay in front of the lines.
468
+ if (world.showLinks && world.links.length > 0) {
469
+ world.routes ??= linkRoutes(world);
470
+ drawLinks(c, world.routes, time);
471
+ }
408
472
 
409
- // Flat things first, then everything else in depth order so people pass behind desks.
410
- for (const item of world.furniture) if (item.type === "rug") drawFurniture(c, item, world, time, palette);
473
+ const painter = painterFor(world);
411
474
  const scene: { depth: number; draw: () => void }[] = [];
412
475
  for (const item of world.furniture) {
413
- if (item.type === "rug") continue;
414
- scene.push({ depth: (item.y + FURNITURE[item.type].h) * TILE - 2, draw: () => drawFurniture(c, item, world, time, palette) });
476
+ const spec = specOf(world, item);
477
+ if (spec.solid === false) continue;
478
+ scene.push({ depth: (item.y + spec.h) * TILE - 2, draw: () => painterFor(world).furniture(c, item, world, time, palette) });
415
479
  }
416
480
  const shown = world.agents.filter((agent) => agent.state !== "away");
417
- for (const agent of shown) {
418
- scene.push({ depth: agent.y * TILE + 14, draw: () => drawAgent(c, agent, time, agent.id === selected) });
419
- }
481
+ for (const agent of shown) scene.push({ depth: agent.y * TILE + 14, draw: () => drawAgent(c, agent, time, agent.id === selected) });
420
482
  scene.sort((a, b) => a.depth - b.depth);
421
483
  for (const item of scene) item.draw();
422
484
 
485
+ // Night: the room dims, and screens and lamps light it.
486
+ if (daylight.night > 0) {
487
+ c.fillStyle = `rgba(12,14,48,${daylight.night})`;
488
+ c.fillRect(0, 0, width, height);
489
+ c.save();
490
+ c.globalCompositeOperation = "lighter";
491
+ const glow = (x: number, y: number, radius: number, color: string): void => {
492
+ const gradient = c.createRadialGradient(x, y, 1, x, y, radius);
493
+ gradient.addColorStop(0, color);
494
+ gradient.addColorStop(1, "rgba(0,0,0,0)");
495
+ c.fillStyle = gradient;
496
+ c.fillRect(x - radius, y - radius, radius * 2, radius * 2);
497
+ };
498
+ for (const item of world.furniture) painter.glow(item, world, glow);
499
+ c.restore();
500
+ }
501
+
502
+ // On top, so tall furniture against a wall can't hide a room's name.
423
503
  drawLabels(c, world);
424
504
  for (const agent of shown) nameTag(c, agent, agent.id === selected, width);
425
505
  for (const agent of shown) if (agent.id !== selected) drawStatus(c, agent, time);
426
506
  drawProgress(c, world, palette);
427
507
  const picked = shown.find((agent) => agent.id === selected);
428
- if (picked) drawCard(c, picked, width);
508
+ if (picked) drawCard(c, picked, world);
429
509
  }