@blkc-code/render-canvas 0.3.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 (51) hide show
  1. package/dist/draw.d.ts +9 -9
  2. package/dist/draw.d.ts.map +1 -1
  3. package/dist/draw.js +73 -305
  4. package/dist/draw.js.map +1 -1
  5. package/dist/index.d.ts +3 -0
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +3 -0
  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 +2 -0
  14. package/dist/office.d.ts.map +1 -1
  15. package/dist/office.js +2 -0
  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.txt +1 -1
  27. package/dist/types.d.ts +13 -2
  28. package/dist/types.d.ts.map +1 -1
  29. package/dist/world.d.ts +15 -11
  30. package/dist/world.d.ts.map +1 -1
  31. package/dist/world.js +31 -59
  32. package/dist/world.js.map +1 -1
  33. package/dist/worlds/office-data.d.ts +14 -0
  34. package/dist/worlds/office-data.d.ts.map +1 -0
  35. package/dist/worlds/office-data.js +52 -0
  36. package/dist/worlds/office-data.js.map +1 -0
  37. package/dist/worlds/office-paint.d.ts +13 -0
  38. package/dist/worlds/office-paint.d.ts.map +1 -0
  39. package/dist/worlds/office-paint.js +292 -0
  40. package/dist/worlds/office-paint.js.map +1 -0
  41. package/package.json +7 -2
  42. package/src/draw.ts +73 -289
  43. package/src/index.ts +3 -0
  44. package/src/links.ts +76 -0
  45. package/src/office.ts +3 -0
  46. package/src/pack.ts +36 -0
  47. package/src/paint.ts +24 -0
  48. package/src/types.ts +12 -2
  49. package/src/world.ts +41 -60
  50. package/src/worlds/office-data.ts +57 -0
  51. package/src/worlds/office-paint.ts +276 -0
package/src/draw.ts CHANGED
@@ -4,31 +4,30 @@
4
4
  * and anything else that moves are drawn on top each frame (drawWorld).
5
5
  */
6
6
 
7
- 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";
8
11
 
9
- export const TILE = 16;
12
+ export { TILE, type Palette } from "./paint.js";
10
13
 
11
- export interface Palette { primary: string; background: string; text: string; accent: string }
14
+ // --- Worlds ---------------------------------------------------------------------------------------
12
15
 
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";
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
+ }
18
22
 
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
+ const painters = new Map<string, Painter>([["office", OFFICE_PAINTER]]);
23
24
 
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)})`;
25
+ export function registerPainter(id: string, painter: Painter): void {
26
+ painters.set(id, painter);
30
27
  }
31
28
 
29
+ const painterFor = (world: World): Painter => painters.get(world.pack.id) ?? OFFICE_PAINTER;
30
+
32
31
  // --- Time of day, from the viewer's own clock ------------------------------------------------------------
33
32
 
34
33
  export interface Daylight { sky: string; sun: string; night: number }
@@ -238,6 +237,20 @@ function drawFloor(c: Ctx, room: Room): void {
238
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);
239
238
  if (noise === 3) rect(c, "#596070", x + 3, y + 8, 8, 1);
240
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
+ }
241
254
  case "grass":
242
255
  rect(c, "#3f8f4f", x, y, TILE, TILE);
243
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);
@@ -262,6 +275,23 @@ const WALL_FACE = "#5c628f";
262
275
  const WALL_DARK = "#4a4f78";
263
276
  const WALL_LIGHT = "#6d74a4";
264
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
+ }
294
+
265
295
  function drawWalls(c: Ctx, world: World, palette: Palette, daylight: Daylight): void {
266
296
  const open = new Set<string>();
267
297
  for (const room of world.rooms) {
@@ -275,12 +305,23 @@ function drawWalls(c: Ctx, world: World, palette: Palette, daylight: Daylight):
275
305
  if (open.has(key) || doors.has(key)) continue;
276
306
  const x = tx * TILE;
277
307
  const y = ty * TILE;
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
+ }
278
318
  if (open.has(`${tx},${ty + 1}`)) {
279
319
  rect(c, WALL_FACE, x, y, TILE, TILE);
280
320
  rect(c, WALL_LIGHT, x, y, TILE, 1);
281
321
  rect(c, WALL_DARK, x, y + 12, TILE, 4);
282
322
  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)) {
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)) {
284
325
  // A window, in the colour of the sky right now.
285
326
  rect(c, "#1f2937", x + 1, y + 1, 14, 9);
286
327
  rect(c, daylight.sky, x + 2, y + 2, 12, 7);
@@ -328,8 +369,8 @@ export function drawBuilding(c: Ctx, world: World, palette: Palette, daylight: D
328
369
  rect(c, WALL_TOP, 0, 0, world.cols * TILE, world.rows * TILE);
329
370
  for (const room of world.rooms) drawFloor(c, room);
330
371
  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);
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);
333
374
  // Light from the windows falling across the floor.
334
375
  if (daylight.sun !== "rgba(0,0,0,0)") {
335
376
  c.fillStyle = daylight.sun;
@@ -342,264 +383,6 @@ export function drawBuilding(c: Ctx, world: World, palette: Palette, daylight: D
342
383
  }
343
384
  }
344
385
 
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
386
  function nameTag(c: Ctx, agent: Agent, selected: boolean, width: number): void {
604
387
  c.font = LABEL_FONT;
605
388
  c.textBaseline = "top";
@@ -661,7 +444,7 @@ function drawProgress(c: Ctx, world: World, palette: Palette): void {
661
444
  const w = 96;
662
445
  rect(c, "rgba(17,24,39,0.85)", 4, 4, w, 20);
663
446
  c.fillStyle = "#ffffff";
664
- c.fillText(`QUESTS ${done}/${world.tasks.length}`, 8, 6.5);
447
+ c.fillText(`${world.pack.words.quests} ${done}/${world.tasks.length}`, 8, 6.5);
665
448
  rect(c, "#374151", 8, 16, w - 8, 4);
666
449
  rect(c, palette.primary, 8, 16, Math.round(((w - 8) * done) / world.tasks.length), 4);
667
450
  }
@@ -681,11 +464,18 @@ export function drawWorld(
681
464
  if (building) c.drawImage(building, 0, 0, width, height);
682
465
  else drawBuilding(c, world, palette, daylight);
683
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
+ }
472
+
473
+ const painter = painterFor(world);
684
474
  const scene: { depth: number; draw: () => void }[] = [];
685
475
  for (const item of world.furniture) {
686
- const spec = FURNITURE[item.type];
476
+ const spec = specOf(world, item);
687
477
  if (spec.solid === false) continue;
688
- scene.push({ depth: (item.y + spec.h) * TILE - 2, draw: () => drawFurniture(c, item, world, time, palette) });
478
+ scene.push({ depth: (item.y + spec.h) * TILE - 2, draw: () => painterFor(world).furniture(c, item, world, time, palette) });
689
479
  }
690
480
  const shown = world.agents.filter((agent) => agent.state !== "away");
691
481
  for (const agent of shown) scene.push({ depth: agent.y * TILE + 14, draw: () => drawAgent(c, agent, time, agent.id === selected) });
@@ -705,13 +495,7 @@ export function drawWorld(
705
495
  c.fillStyle = gradient;
706
496
  c.fillRect(x - radius, y - radius, radius * 2, radius * 2);
707
497
  };
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
- }
498
+ for (const item of world.furniture) painter.glow(item, world, glow);
715
499
  c.restore();
716
500
  }
717
501
 
package/src/index.ts CHANGED
@@ -13,6 +13,9 @@ import { createOffice, type OfficeHandle } from "./office.js";
13
13
  import type { OfficeScene } from "./types.js";
14
14
 
15
15
  export { createOffice } from "./office.js";
16
+ export { registerPainter, type Painter } from "./draw.js";
17
+ export { drawLinks, linkRoutes, type LinkRoute } from "./links.js";
18
+ export { registerWorld, type ItemSpec, type WorldData } from "./pack.js";
16
19
  export type { OfficeHandle, OfficeOptions } from "./office.js";
17
20
  export * from "./types.js";
18
21
  export { FURNITURE, agentAt, applyLayout, applyUpdate, createWorld, findPath, step } from "./world.js";
package/src/links.ts ADDED
@@ -0,0 +1,76 @@
1
+ /** How the code depends on itself, drawn as lines along the routes agents really walk. */
2
+
3
+ import { TILE, type Ctx } from "./paint.js";
4
+ import { findPath, isWalkable, type World } from "./world.js";
5
+
6
+ export interface LinkRoute { from: string; to: string; points: { x: number; y: number }[] }
7
+
8
+ const COLOURS = ["#38bdf8", "#f472b6", "#fbbf24", "#a78bfa", "#34d399", "#fb7185"];
9
+
10
+ /** A tile someone can stand on, as near the middle of the room as possible. */
11
+ function standingTile(world: World, roomId: string): { x: number; y: number } | undefined {
12
+ const room = world.rooms.find((candidate) => candidate.id === roomId);
13
+ if (!room) return undefined;
14
+ const cx = room.x + (room.w >> 1);
15
+ const cy = room.y + (room.h >> 1);
16
+ let best: { x: number; y: number } | undefined;
17
+ let bestDistance = Infinity;
18
+ for (let y = room.y; y < room.y + room.h; y++) {
19
+ for (let x = room.x; x < room.x + room.w; x++) {
20
+ const distance = Math.abs(x - cx) + Math.abs(y - cy);
21
+ if (distance < bestDistance && isWalkable(world, x, y)) {
22
+ best = { x, y };
23
+ bestDistance = distance;
24
+ }
25
+ }
26
+ }
27
+ return best;
28
+ }
29
+
30
+ /** Each link's walking route. A link whose rooms aren't on the map, or can't be reached, is left out. */
31
+ export function linkRoutes(world: World): LinkRoute[] {
32
+ const routes: LinkRoute[] = [];
33
+ for (const link of world.links) {
34
+ const start = standingTile(world, link.from);
35
+ const end = standingTile(world, link.to);
36
+ if (!start || !end) continue;
37
+ const path = findPath(world, start, end);
38
+ if (path && path.length > 0) routes.push({ from: link.from, to: link.to, points: [start, ...path] });
39
+ }
40
+ return routes;
41
+ }
42
+
43
+ /** Lines that move towards what is depended on. Shared stretches sit side by side. */
44
+ export function drawLinks(c: Ctx, routes: LinkRoute[], time: number): void {
45
+ routes.forEach((route, index) => {
46
+ const colour = COLOURS[index % COLOURS.length] as string;
47
+ const offset = ((index % 5) - 2) * 2;
48
+ const at = (point: { x: number; y: number }): [number, number] => [point.x * TILE + 8 + offset, point.y * TILE + 8 + offset];
49
+ c.save();
50
+ c.lineWidth = 2;
51
+ c.strokeStyle = colour;
52
+ c.globalAlpha = 0.85;
53
+ c.setLineDash([6, 5]);
54
+ c.lineDashOffset = -time * 14;
55
+ c.beginPath();
56
+ route.points.forEach((point, i) => {
57
+ const [x, y] = at(point);
58
+ if (i === 0) c.moveTo(x, y);
59
+ else c.lineTo(x, y);
60
+ });
61
+ c.stroke();
62
+ c.setLineDash([]);
63
+ const first = route.points[0];
64
+ const last = route.points[route.points.length - 1];
65
+ if (first && last) {
66
+ const [sx, sy] = at(first);
67
+ const [ex, ey] = at(last);
68
+ c.fillStyle = colour;
69
+ c.fillRect(sx - 2, sy - 2, 4, 4);
70
+ c.beginPath();
71
+ c.arc(ex, ey, 4, 0, Math.PI * 2);
72
+ c.fill();
73
+ }
74
+ c.restore();
75
+ });
76
+ }
package/src/office.ts CHANGED
@@ -10,6 +10,8 @@ export interface OfficeHandle {
10
10
  update(scene: Pick<OfficeScene, "agents" | "tasks">): void;
11
11
  /** Change the building while everyone is in it. */
12
12
  setLayout(layout: OfficeLayout): void;
13
+ /** Show or hide the lines between rooms whose code is linked. */
14
+ setLinksVisible(on: boolean): void;
13
15
  /** Pick an agent by id, or pass nothing to clear. */
14
16
  select(id?: string): void;
15
17
  /** The tile under a pointer event, allowing for the camera. */
@@ -239,6 +241,7 @@ export function createOffice(canvas: HTMLCanvasElement, scene: OfficeScene = {},
239
241
  select,
240
242
  tileAt,
241
243
  setOverlay(draw) { overlay = draw; },
244
+ setLinksVisible(on) { world.showLinks = on; if (still) paint(); },
242
245
  setInteractive(on) { interactive = on; drag = undefined; if (!on) canvas.style.cursor = "crosshair"; },
243
246
  zoom,
244
247
  destroy() {
package/src/pack.ts ADDED
@@ -0,0 +1,36 @@
1
+ /**
2
+ * What makes one world different from another. The engine handles walking, seats, time of day
3
+ * and agents, for any world. A world brings this data, plus a painter (see registerPainter).
4
+ */
5
+
6
+ import type { OfficeLayout } from "./types.js";
7
+
8
+ export interface ItemSpec {
9
+ w: number;
10
+ h: number;
11
+ solid?: boolean;
12
+ wall?: boolean;
13
+ /** Where someone stands to use it, relative to its corner. */
14
+ spot?: { x: number; y: number };
15
+ /** seat: where an agent works. lounge: where it rests. board: where it reviews. */
16
+ use?: "seat" | "lounge" | "board";
17
+ }
18
+
19
+ export interface WorldData {
20
+ id: string;
21
+ name: string;
22
+ words: {
23
+ /** The progress label over the map. */
24
+ quests: string;
25
+ };
26
+ items: Record<string, ItemSpec>;
27
+ layout: Required<Pick<OfficeLayout, "cols" | "rows" | "rooms" | "doors" | "furniture">>;
28
+ }
29
+
30
+ const worlds = new Map<string, WorldData>();
31
+
32
+ export function registerWorld(data: WorldData): void {
33
+ worlds.set(data.id, data);
34
+ }
35
+
36
+ export const worldData = (id: string | undefined, fallback: WorldData): WorldData => (id && worlds.get(id)) || fallback;
package/src/paint.ts ADDED
@@ -0,0 +1,24 @@
1
+ /** The small tools every painter uses. */
2
+
3
+ export const TILE = 16;
4
+
5
+ export interface Palette { primary: string; background: string; text: string; accent: string }
6
+
7
+ export type Ctx = CanvasRenderingContext2D;
8
+
9
+ export const LABEL_FONT = "bold 7px ui-monospace, Menlo, Consolas, monospace";
10
+ export const BODY_FONT = "7px ui-monospace, Menlo, Consolas, monospace";
11
+ export const OUTLINE = "#14141f";
12
+
13
+ export const rect = (c: Ctx, color: string, x: number, y: number, w: number, h: number): void => {
14
+ c.fillStyle = color;
15
+ c.fillRect(Math.round(x), Math.round(y), w, h);
16
+ };
17
+
18
+ export function shade(color: string, amount: number): string {
19
+ if (!color.startsWith("#")) return color;
20
+ const hex = color.length === 4 ? `#${color[1]}${color[1]}${color[2]}${color[2]}${color[3]}${color[3]}` : color;
21
+ const n = parseInt(hex.slice(1, 7), 16);
22
+ const channel = (shift: number): number => Math.max(0, Math.min(255, ((n >> shift) & 255) + amount));
23
+ return `rgb(${channel(16)},${channel(8)},${channel(0)})`;
24
+ }
package/src/types.ts CHANGED
@@ -2,11 +2,14 @@
2
2
 
3
3
  export type AgentState = "working" | "thinking" | "blocked" | "reviewing" | "idle" | "done" | "away";
4
4
  export type TaskState = "todo" | "doing" | "done";
5
- export type FloorStyle = "wood" | "carpet" | "tile" | "stone" | "concrete" | "grass";
6
- export type FurnitureType =
5
+ export type FloorStyle = "wood" | "carpet" | "tile" | "stone" | "concrete" | "grass" | "hall";
6
+ export type OfficeFurnitureType =
7
7
  | "desk" | "coffee" | "sofa" | "plant" | "fern" | "board" | "whiteboard" | "shelf" | "server" | "table" | "rug"
8
8
  | "water" | "arcade" | "fridge" | "printer" | "lamp" | "beanbag" | "tv" | "vending" | "clock" | "poster" | "trophy";
9
9
 
10
+ /** An office item, or whatever else the world defines. */
11
+ export type FurnitureType = OfficeFurnitureType | (string & {});
12
+
10
13
  export interface OfficeRoom {
11
14
  id?: string;
12
15
  name?: string;
@@ -35,15 +38,22 @@ export interface OfficeAgent {
35
38
  }
36
39
  export interface OfficeTask { title: string; state?: TaskState; agent?: string }
37
40
 
41
+ /** `from` depends on `to`. */
42
+ export interface OfficeLink { from: string; to: string }
43
+
38
44
  export interface OfficeLayout {
39
45
  cols?: number;
40
46
  rows?: number;
41
47
  rooms?: OfficeRoom[];
42
48
  doors?: OfficeDoor[];
43
49
  furniture?: OfficeFurniture[];
50
+ /** How the rooms' code depends on each other. Drawn as lines between them. */
51
+ links?: OfficeLink[];
44
52
  }
45
53
 
46
54
  export interface OfficeScene extends OfficeLayout {
55
+ /** Which world to draw. The office if left out or unknown. */
56
+ world?: string;
47
57
  agents?: OfficeAgent[];
48
58
  tasks?: OfficeTask[];
49
59
  }