@blkc-code/render-canvas 0.1.0 → 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 CHANGED
@@ -1,6 +1,7 @@
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
7
  import { FURNITURE, isSeated, type Agent, type Item, type Room, type World } from "./world.js";
@@ -11,97 +12,174 @@ export interface Palette { primary: string; background: string; text: string; ac
11
12
 
12
13
  type Ctx = CanvasRenderingContext2D;
13
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
+
14
19
  const rect = (c: Ctx, color: string, x: number, y: number, w: number, h: number): void => {
15
20
  c.fillStyle = color;
16
21
  c.fillRect(Math.round(x), Math.round(y), w, h);
17
22
  };
18
23
 
19
- // --- Characters: 12 wide. h hair, s skin, e eye, t shirt, T shirt shadow, l legs, b shoes -----------------
20
-
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...."];
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
- });
41
- }
42
-
43
- function shade(hex: string, amount: number): string {
44
- const n = parseInt(hex.slice(1).padEnd(6, hex.slice(-1)), 16);
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);
45
28
  const channel = (shift: number): number => Math.max(0, Math.min(255, ((n >> shift) & 255) + amount));
46
29
  return `rgb(${channel(16)},${channel(8)},${channel(0)})`;
47
30
  }
48
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
+
49
54
  function drawAgent(c: Ctx, agent: Agent, time: number, selected: boolean): void {
50
- const px = agent.x * TILE + 2;
55
+ const left = agent.x * TILE + 2; // sprite is 12 wide inside a 16 tile
56
+ const feet = agent.y * TILE + 15;
51
57
  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);
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
+ }
58
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.
59
96
  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);
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);
63
100
  }
64
101
 
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);
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
+ }
72
115
  }
73
116
 
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";
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
+ }
81
134
 
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);
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
+ }
90
168
  }
91
169
  }
92
170
 
93
171
  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);
172
+ rect(c, OUTLINE, x - 1, y - 1, 13, 11);
95
173
  rect(c, "#ffffff", x, y, 11, 9);
96
174
  rect(c, "#ffffff", x + 4, y + 9, 3, 1);
97
- rect(c, "#111827", x + 4, y + 10, 3, 1);
175
+ rect(c, OUTLINE, x + 4, y + 10, 3, 1);
98
176
  draw(x, y);
99
177
  }
100
178
 
101
179
  function drawStatus(c: Ctx, agent: Agent, time: number): void {
102
180
  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;
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;
105
183
  if (agent.state === "blocked") {
106
184
  bubble(c, x, y, (bx, by) => {
107
185
  rect(c, pulse ? "#dc2626" : "#991b1b", bx + 5, by + 1, 2, 5);
@@ -122,115 +200,203 @@ function drawStatus(c: Ctx, agent: Agent, time: number): void {
122
200
  bubble(c, x, y, (bx, by) => {
123
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);
124
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
+ });
125
208
  }
126
209
  }
127
210
 
128
- // --- The room -------------------------------------------------------------------------------------------
211
+ // --- The building ----------------------------------------------------------------------------------------
129
212
 
130
- function drawFloor(c: Ctx, room: Room, palette: Palette): void {
213
+ function drawFloor(c: Ctx, room: Room): void {
131
214
  for (let ty = room.y; ty < room.y + room.h; ty++) {
132
215
  for (let tx = room.x; tx < room.x + room.w; tx++) {
133
216
  const x = tx * TILE;
134
217
  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);
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
+ }
149
255
  }
150
256
  }
151
257
  }
152
- void palette;
153
258
  }
154
259
 
155
- const WALL_TOP = "#2f3350";
156
- const WALL_FACE = "#565c86";
157
- const WALL_LINE = "#454a70";
260
+ const WALL_TOP = "#2a2d48";
261
+ const WALL_FACE = "#5c628f";
262
+ const WALL_DARK = "#4a4f78";
263
+ const WALL_LIGHT = "#6d74a4";
158
264
 
159
- function drawWalls(c: Ctx, world: World, palette: Palette): void {
265
+ function drawWalls(c: Ctx, world: World, palette: Palette, daylight: Daylight): void {
160
266
  const open = new Set<string>();
161
267
  for (const room of world.rooms) {
162
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}`);
163
269
  }
164
- for (const door of world.doors) open.add(`${door.x},${door.y}`);
270
+ const doors = new Set(world.doors.map((door) => `${door.x},${door.y}`));
165
271
 
166
272
  for (let ty = 0; ty < world.rows; ty++) {
167
273
  for (let tx = 0; tx < world.cols; tx++) {
168
- if (open.has(`${tx},${ty}`)) continue;
274
+ const key = `${tx},${ty}`;
275
+ if (open.has(key) || doors.has(key)) continue;
169
276
  const x = tx * TILE;
170
277
  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.
278
+ if (open.has(`${tx},${ty + 1}`)) {
174
279
  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) {
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.
178
285
  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);
286
+ rect(c, daylight.sky, x + 2, y + 2, 12, 7);
287
+ rect(c, shade(daylight.sky, 40), x + 3, y + 3, 4, 2);
181
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); }
182
290
  }
183
291
  } else {
184
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);
185
296
  }
186
297
  }
187
298
  }
188
299
  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);
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);
191
323
  }
192
324
  }
193
325
 
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);
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);
200
357
  // Monitor
201
- rect(c, "#111827", x + 15, y - 1, 18, 13);
358
+ rect(c, OUTLINE, x + 14, y - 3, 20, 15);
202
359
  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);
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);
209
370
  }
210
- c.fillStyle = "rgba(125,211,252,0.10)";
211
- c.fillRect(x + 11, y + 11, 26, 8);
371
+ c.fillStyle = "rgba(125,211,252,0.12)";
372
+ c.fillRect(x + 10, y + 11, 28, 8);
212
373
  } else {
213
- rect(c, "#1f2937", x + 16, y, 16, 11);
214
- rect(c, "#374151", x + 18, y + 2, 3, 1);
374
+ rect(c, "#1f2937", x + 15, y - 2, 18, 13);
375
+ rect(c, "#374151", x + 17, y, 3, 1);
215
376
  }
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);
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);
221
384
  }
222
385
 
223
386
  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);
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);
226
390
  const colors = { todo: "#fde68a", doing: "#93c5fd", done: "#86efac" };
227
391
  const per = 8;
228
392
  world.tasks.slice(0, per * 2).forEach((task, index) => {
229
393
  const col = index % per;
230
394
  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);
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);
234
400
  });
235
401
  }
236
402
 
@@ -241,113 +407,209 @@ function drawFurniture(c: Ctx, item: Item, world: World, time: number, palette:
241
407
  case "desk": {
242
408
  const seatX = item.x + 1;
243
409
  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);
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");
246
412
  break;
247
413
  }
248
414
  case "coffee":
249
415
  rect(c, "#374151", x, y + 2, 32, 14);
250
416
  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);
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);
253
421
  rect(c, "#f3f4f6", x + 8, y + 3, 5, 4);
254
- rect(c, "#f3f4f6", x + 22, y - 1, 5, 5);
422
+ rect(c, "#f3f4f6", x + 23, y - 1, 5, 5);
423
+ rect(c, "#92400e", x + 24, y, 3, 1);
255
424
  for (let i = 0; i < 3; i++) {
256
- c.fillStyle = "rgba(255,255,255,0.45)";
425
+ c.fillStyle = "rgba(255,255,255,0.5)";
257
426
  c.fillRect(x + 9 + Math.round(Math.sin(time * 2 + i) * 1.5), y - 1 - ((time * 8 + i * 4) % 9), 1, 2);
258
427
  }
259
428
  break;
260
429
  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);
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);
264
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);
265
437
  break;
266
438
  case "sofa":
267
- rect(c, palette.primary, x + 1, y - 6, 46, 9);
268
- rect(c, "#00000030", x + 1, y + 1, 46, 2);
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);
269
441
  rect(c, "#4b5563", x, y + 3, 48, 11);
270
442
  rect(c, "#6b7280", x + 3, y + 3, 20, 7);
271
443
  rect(c, "#6b7280", x + 25, y + 3, 20, 7);
272
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);
273
447
  break;
274
448
  case "plant":
275
449
  rect(c, "#92400e", x + 4, y + 9, 8, 7);
276
450
  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
- }
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);
280
452
  rect(c, "#22c55e", x + 7, y - 3, 2, 8);
281
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
+ }
282
463
  break;
283
464
  case "board":
284
465
  drawBoard(c, x, y, world);
285
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;
286
475
  case "shelf":
287
476
  rect(c, "#5b3a21", x, y - 12, 32, 28);
288
477
  for (let shelf = 0; shelf < 2; shelf++) {
289
478
  rect(c, "#3b2413", x + 2, y - 10 + shelf * 13, 28, 11);
290
479
  for (let book = 0; book < 8; book++) {
291
- const color = ["#ef4444", "#3b82f6", "#10b981", "#f59e0b", "#8b5cf6"][(book * 3 + shelf) % 5] as string;
480
+ const color = ["#ef4444", "#3b82f6", "#10b981", "#f59e0b", "#8b5cf6"][(book * 3 + shelf + item.x) % 5] as string;
292
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);
293
483
  }
294
484
  }
295
485
  break;
296
486
  case "server":
297
- rect(c, "#111827", x + 1, y - 12, 14, 28);
487
+ rect(c, OUTLINE, x, y - 13, 16, 29);
488
+ rect(c, "#1f2937", x + 1, y - 12, 14, 27);
298
489
  for (let i = 0; i < 5; i++) {
299
- rect(c, "#1f2937", x + 2, y - 11 + i * 5, 12, 4);
490
+ rect(c, "#111827", x + 2, y - 11 + i * 5, 12, 4);
300
491
  const on = Math.floor(time * 3 + i * 2 + item.x) % 3 !== 0;
301
492
  rect(c, on ? "#22c55e" : "#14532d", x + 3, y - 10 + i * 5, 1, 1);
302
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);
303
495
  }
304
496
  break;
305
497
  case "table":
306
- rect(c, "#57534e", x + 2, y + 14, 44, 14);
498
+ rect(c, "#44403c", x + 2, y + 16, 44, 12);
307
499
  rect(c, "#78716c", x + 2, y + 3, 44, 14);
308
500
  rect(c, "#a8a29e", x + 2, y + 3, 44, 2);
309
501
  rect(c, "#f9fafb", x + 10, y + 6, 8, 6);
310
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);
311
505
  break;
312
506
  case "rug":
313
507
  rect(c, palette.primary, x, y, 64, 48);
314
- c.fillStyle = "rgba(0,0,0,0.35)";
508
+ c.fillStyle = "rgba(0,0,0,0.42)";
315
509
  c.fillRect(x + 3, y + 3, 58, 42);
316
510
  rect(c, palette.primary, x + 6, y + 6, 52, 1);
317
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);
318
515
  break;
319
- }
320
- void FURNITURE;
321
- }
322
-
323
- function drawLabels(c: Ctx, world: World): void {
324
- c.font = "bold 7px ui-monospace, Menlo, Consolas, monospace";
325
- c.textBaseline = "top";
326
- for (const room of world.rooms) {
327
- if (!room.name) continue;
328
- const label = room.name.toUpperCase();
329
- // On the wall above the room, clear of the furniture.
330
- 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);
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
+ }
336
600
  }
337
601
  }
338
602
 
339
603
  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";
604
+ c.font = LABEL_FONT;
342
605
  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);
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);
347
609
  c.fillStyle = selected ? "#ffffff" : "rgba(17,24,39,0.82)";
348
610
  c.fillRect(x, y, w, 10);
349
611
  c.fillStyle = selected ? "#111827" : "#ffffff";
350
- c.fillText(name, x + 2.5, y + 1.5);
612
+ c.fillText(agent.name, x + 2.5, y + 1.5);
351
613
  }
352
614
 
353
615
  function wrap(c: Ctx, text: string, max: number): string[] {
@@ -368,62 +630,96 @@ function wrap(c: Ctx, text: string, max: number): string[] {
368
630
 
369
631
  const STATE_LABEL: Record<string, string> = { working: "Working", thinking: "Thinking", blocked: "Blocked", reviewing: "Reviewing", idle: "On a break", done: "Finished", away: "Away" };
370
632
 
371
- function drawCard(c: Ctx, agent: Agent, width: number): void {
372
- c.font = "7px ui-monospace, Menlo, Consolas, monospace";
633
+ function drawCard(c: Ctx, agent: Agent, world: World): void {
634
+ c.font = BODY_FONT;
373
635
  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);
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);
380
643
  rect(c, "#ffffff", x, y, w, h);
381
644
  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);
645
+ c.font = LABEL_FONT;
646
+ c.fillText(`${agent.name}: ${STATE_LABEL[agent.state] ?? ""}`.slice(0, 28), x + 4, y + 4);
384
647
  c.fillStyle = "#374151";
385
- c.font = "7px ui-monospace, Menlo, Consolas, monospace";
648
+ c.font = BODY_FONT;
386
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
+ }
387
654
  }
388
655
 
389
656
  function drawProgress(c: Ctx, world: World, palette: Palette): void {
390
657
  if (world.tasks.length === 0) return;
391
658
  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";
659
+ c.font = LABEL_FONT;
394
660
  c.textBaseline = "top";
395
661
  const w = 96;
396
662
  rect(c, "rgba(17,24,39,0.85)", 4, 4, w, 20);
397
663
  c.fillStyle = "#ffffff";
398
- c.fillText(label, 8, 6.5);
664
+ c.fillText(`QUESTS ${done}/${world.tasks.length}`, 8, 6.5);
399
665
  rect(c, "#374151", 8, 16, w - 8, 4);
400
666
  rect(c, palette.primary, 8, 16, Math.round(((w - 8) * done) / world.tasks.length), 4);
401
667
  }
402
668
 
403
- export function drawWorld(c: Ctx, world: World, time: number, palette: Palette, selected?: string): void {
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 {
404
679
  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);
680
+ const height = world.rows * TILE;
681
+ if (building) c.drawImage(building, 0, 0, width, height);
682
+ else drawBuilding(c, world, palette, daylight);
408
683
 
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);
411
684
  const scene: { depth: number; draw: () => void }[] = [];
412
685
  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) });
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) });
415
689
  }
416
690
  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
- }
691
+ for (const agent of shown) scene.push({ depth: agent.y * TILE + 14, draw: () => drawAgent(c, agent, time, agent.id === selected) });
420
692
  scene.sort((a, b) => a.depth - b.depth);
421
693
  for (const item of scene) item.draw();
422
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.
423
719
  drawLabels(c, world);
424
720
  for (const agent of shown) nameTag(c, agent, agent.id === selected, width);
425
721
  for (const agent of shown) if (agent.id !== selected) drawStatus(c, agent, time);
426
722
  drawProgress(c, world, palette);
427
723
  const picked = shown.find((agent) => agent.id === selected);
428
- if (picked) drawCard(c, picked, width);
724
+ if (picked) drawCard(c, picked, world);
429
725
  }