@blkc-code/render-canvas 0.3.0 → 0.7.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 +82 -306
  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 +7 -3
  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 +14 -2
  28. package/dist/types.d.ts.map +1 -1
  29. package/dist/world.d.ts +18 -13
  30. package/dist/world.d.ts.map +1 -1
  31. package/dist/world.js +42 -64
  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 +53 -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 +300 -0
  40. package/dist/worlds/office-paint.js.map +1 -0
  41. package/package.json +7 -2
  42. package/src/draw.ts +80 -289
  43. package/src/index.ts +3 -0
  44. package/src/links.ts +76 -0
  45. package/src/office.ts +8 -3
  46. package/src/pack.ts +36 -0
  47. package/src/paint.ts +24 -0
  48. package/src/types.ts +13 -3
  49. package/src/world.ts +53 -66
  50. package/src/worlds/office-data.ts +58 -0
  51. package/src/worlds/office-paint.ts +284 -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);
@@ -310,6 +351,13 @@ function drawWalls(c: Ctx, world: World, palette: Palette, daylight: Daylight):
310
351
  function drawLabels(c: Ctx, world: World): void {
311
352
  c.font = LABEL_FONT;
312
353
  c.textBaseline = "top";
354
+ for (const item of world.furniture) {
355
+ if (!item.label) continue;
356
+ const width = Math.ceil(c.measureText(item.label).width) + 4;
357
+ rect(c, "#f4ecd8", item.x * TILE + 17, item.y * TILE + 3, width, 10);
358
+ c.fillStyle = "#1c1408";
359
+ c.fillText(item.label, item.x * TILE + 19, item.y * TILE + 4.5);
360
+ }
313
361
  for (const room of world.rooms) {
314
362
  if (!room.name) continue;
315
363
  const label = room.name.toUpperCase();
@@ -328,8 +376,8 @@ export function drawBuilding(c: Ctx, world: World, palette: Palette, daylight: D
328
376
  rect(c, WALL_TOP, 0, 0, world.cols * TILE, world.rows * TILE);
329
377
  for (const room of world.rooms) drawFloor(c, room);
330
378
  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);
379
+ for (const item of world.furniture) if (item.type === "rug" || item.type === "pile") painterFor(world).furniture(c, item, world, 0, palette);
380
+ for (const item of world.furniture) if (specOf(world, item).wall) painterFor(world).furniture(c, item, world, 0, palette);
333
381
  // Light from the windows falling across the floor.
334
382
  if (daylight.sun !== "rgba(0,0,0,0)") {
335
383
  c.fillStyle = daylight.sun;
@@ -342,264 +390,6 @@ export function drawBuilding(c: Ctx, world: World, palette: Palette, daylight: D
342
390
  }
343
391
  }
344
392
 
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
393
  function nameTag(c: Ctx, agent: Agent, selected: boolean, width: number): void {
604
394
  c.font = LABEL_FONT;
605
395
  c.textBaseline = "top";
@@ -661,7 +451,7 @@ function drawProgress(c: Ctx, world: World, palette: Palette): void {
661
451
  const w = 96;
662
452
  rect(c, "rgba(17,24,39,0.85)", 4, 4, w, 20);
663
453
  c.fillStyle = "#ffffff";
664
- c.fillText(`QUESTS ${done}/${world.tasks.length}`, 8, 6.5);
454
+ c.fillText(`${world.pack.words.quests} ${done}/${world.tasks.length}`, 8, 6.5);
665
455
  rect(c, "#374151", 8, 16, w - 8, 4);
666
456
  rect(c, palette.primary, 8, 16, Math.round(((w - 8) * done) / world.tasks.length), 4);
667
457
  }
@@ -681,11 +471,18 @@ export function drawWorld(
681
471
  if (building) c.drawImage(building, 0, 0, width, height);
682
472
  else drawBuilding(c, world, palette, daylight);
683
473
 
474
+ // On the floor, so people and furniture stay in front of the lines.
475
+ if (world.showLinks && world.links.length > 0) {
476
+ world.routes ??= linkRoutes(world);
477
+ drawLinks(c, world.routes, time);
478
+ }
479
+
480
+ const painter = painterFor(world);
684
481
  const scene: { depth: number; draw: () => void }[] = [];
685
482
  for (const item of world.furniture) {
686
- const spec = FURNITURE[item.type];
483
+ const spec = specOf(world, item);
687
484
  if (spec.solid === false) continue;
688
- scene.push({ depth: (item.y + spec.h) * TILE - 2, draw: () => drawFurniture(c, item, world, time, palette) });
485
+ scene.push({ depth: (item.y + spec.h) * TILE - 2, draw: () => painterFor(world).furniture(c, item, world, time, palette) });
689
486
  }
690
487
  const shown = world.agents.filter((agent) => agent.state !== "away");
691
488
  for (const agent of shown) scene.push({ depth: agent.y * TILE + 14, draw: () => drawAgent(c, agent, time, agent.id === selected) });
@@ -705,13 +502,7 @@ export function drawWorld(
705
502
  c.fillStyle = gradient;
706
503
  c.fillRect(x - radius, y - radius, radius * 2, radius * 2);
707
504
  };
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
- }
505
+ for (const item of world.furniture) painter.glow(item, world, glow);
715
506
  c.restore();
716
507
  }
717
508
 
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. */
@@ -74,8 +76,10 @@ export function createOffice(canvas: HTMLCanvasElement, scene: OfficeScene = {},
74
76
  const width = (): number => world.cols * TILE;
75
77
  const height = (): number => world.rows * TILE;
76
78
 
79
+ // A big map starts small on screen, so it can be zoomed further: as far as 4x true size.
80
+ const zoomLimit = (): number => Math.max(MAX_ZOOM, scale > 0 ? 4 / scale : MAX_ZOOM);
77
81
  const clampView = (): void => {
78
- view.zoom = Math.min(MAX_ZOOM, Math.max(1, view.zoom));
82
+ view.zoom = Math.min(zoomLimit(), Math.max(1, view.zoom));
79
83
  view.x = Math.min(width() - width() / view.zoom, Math.max(0, view.x));
80
84
  view.y = Math.min(height() - height() / view.zoom, Math.max(0, view.y));
81
85
  };
@@ -190,7 +194,7 @@ export function createOffice(canvas: HTMLCanvasElement, scene: OfficeScene = {},
190
194
  return;
191
195
  }
192
196
  const before = event ? tileAt(event) : { x: view.x / TILE + width() / view.zoom / TILE / 2, y: view.y / TILE + height() / view.zoom / TILE / 2 };
193
- view.zoom = Math.min(MAX_ZOOM, Math.max(1, view.zoom * factor));
197
+ view.zoom = Math.min(zoomLimit(), Math.max(1, view.zoom * factor));
194
198
  if (event) {
195
199
  const after = tileAt(event);
196
200
  view.x += (before.x - after.x) * TILE;
@@ -231,7 +235,7 @@ export function createOffice(canvas: HTMLCanvasElement, scene: OfficeScene = {},
231
235
  setLayout(layout) {
232
236
  applyLayout(world, layout);
233
237
  buildingDirty = true;
234
- view.zoom = Math.min(view.zoom, MAX_ZOOM);
238
+ view.zoom = Math.min(view.zoom, zoomLimit());
235
239
  clampView();
236
240
  scale = 0;
237
241
  resize();
@@ -239,6 +243,7 @@ export function createOffice(canvas: HTMLCanvasElement, scene: OfficeScene = {},
239
243
  select,
240
244
  tileAt,
241
245
  setOverlay(draw) { overlay = draw; },
246
+ setLinksVisible(on) { world.showLinks = on; if (still) paint(); },
242
247
  setInteractive(on) { interactive = on; drag = undefined; if (!on) canvas.style.cursor = "crosshair"; },
243
248
  zoom,
244
249
  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
+ }