@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/index.ts CHANGED
@@ -15,7 +15,7 @@ import type { OfficeScene } from "./types.js";
15
15
  export { createOffice } from "./office.js";
16
16
  export type { OfficeHandle, OfficeOptions } from "./office.js";
17
17
  export * from "./types.js";
18
- export { agentAt, applyUpdate, createWorld, findPath, step } from "./world.js";
18
+ export { FURNITURE, agentAt, applyLayout, applyUpdate, createWorld, findPath, step } from "./world.js";
19
19
  export type { Agent, World } from "./world.js";
20
20
 
21
21
  export interface MountedBlock {
package/src/office.ts CHANGED
@@ -1,24 +1,37 @@
1
- /** Puts a world on a canvas: sizing, the frame loop, and pointer and keyboard input. */
1
+ /** Puts a world on a canvas: sizing, the camera, the frame loop, and input. */
2
2
 
3
- import { drawWorld, TILE, type Palette } from "./draw.js";
4
- import type { OfficeScene } from "./types.js";
5
- import { agentAt, applyUpdate, createWorld, step, type World } from "./world.js";
3
+ import { daylightAt, drawBuilding, drawWorld, TILE, type Daylight, type Palette } from "./draw.js";
4
+ import type { OfficeLayout, OfficeScene } from "./types.js";
5
+ import { agentAt, applyLayout, applyUpdate, createWorld, step, type World } from "./world.js";
6
6
 
7
7
  export interface OfficeHandle {
8
8
  world: World;
9
9
  /** Change agents or tasks without restarting anyone. */
10
10
  update(scene: Pick<OfficeScene, "agents" | "tasks">): void;
11
+ /** Change the building while everyone is in it. */
12
+ setLayout(layout: OfficeLayout): void;
11
13
  /** Pick an agent by id, or pass nothing to clear. */
12
14
  select(id?: string): void;
15
+ /** The tile under a pointer event, allowing for the camera. */
16
+ tileAt(event: { clientX: number; clientY: number }): { x: number; y: number };
17
+ /** Draw something over the office each frame, in pixels at 16 a tile. For build mode. */
18
+ setOverlay(draw: ((c: CanvasRenderingContext2D) => void) | undefined): void;
19
+ /** Stop clicks picking agents and dragging the camera, while something else handles them. */
20
+ setInteractive(on: boolean): void;
21
+ /** Zoom by a factor around a point on the canvas, or reset with no arguments. */
22
+ zoom(factor?: number, event?: { clientX: number; clientY: number }): void;
13
23
  destroy(): void;
14
24
  }
15
25
 
16
26
  export interface OfficeOptions {
17
27
  /** Called when the selection changes, by click or by select(). */
18
28
  onSelect?: (id: string | undefined) => void;
29
+ /** Wheel to zoom and drag to pan. For a page that is mainly the office: it takes the wheel. */
30
+ camera?: boolean;
19
31
  }
20
32
 
21
33
  const SAFE_COLOUR = /^(#[0-9a-f]{3,8}|rgba?\([\d\s.,%/]+\)|hsla?\([\d\s.,%/a-z]+\))$/i;
34
+ const MAX_ZOOM = 4;
22
35
 
23
36
  function readPalette(canvas: HTMLCanvasElement): Palette {
24
37
  const styles = getComputedStyle(canvas);
@@ -40,8 +53,6 @@ export function createOffice(canvas: HTMLCanvasElement, scene: OfficeScene = {},
40
53
  const context = canvas.getContext("2d");
41
54
  if (!context) throw new Error("This browser can't draw to a canvas.");
42
55
  const c = context;
43
- const width = world.cols * TILE;
44
- const height = world.rows * TILE;
45
56
  const palette = readPalette(canvas);
46
57
  const still = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
47
58
 
@@ -52,22 +63,55 @@ export function createOffice(canvas: HTMLCanvasElement, scene: OfficeScene = {},
52
63
  let frame = 0;
53
64
  let visible = true;
54
65
  let destroyed = false;
66
+ let interactive = true;
67
+ let overlay: ((c: CanvasRenderingContext2D) => void) | undefined;
68
+ const view = { x: 0, y: 0, zoom: 1 };
69
+ let hour = new Date().getHours();
70
+ let daylight: Daylight = daylightAt(hour);
71
+ let building: HTMLCanvasElement | undefined;
72
+ let buildingDirty = true;
73
+
74
+ const width = (): number => world.cols * TILE;
75
+ const height = (): number => world.rows * TILE;
76
+
77
+ const clampView = (): void => {
78
+ view.zoom = Math.min(MAX_ZOOM, Math.max(1, view.zoom));
79
+ view.x = Math.min(width() - width() / view.zoom, Math.max(0, view.x));
80
+ view.y = Math.min(height() - height() / view.zoom, Math.max(0, view.y));
81
+ };
82
+
83
+ const refreshBuilding = (): void => {
84
+ if (typeof document === "undefined") return;
85
+ building ??= document.createElement("canvas");
86
+ building.width = width() * scale;
87
+ building.height = height() * scale;
88
+ const layer = building.getContext("2d");
89
+ if (!layer) return;
90
+ layer.setTransform(scale, 0, 0, scale, 0, 0);
91
+ layer.imageSmoothingEnabled = false;
92
+ drawBuilding(layer, world, palette, daylight);
93
+ buildingDirty = false;
94
+ };
55
95
 
56
96
  const paint = (): void => {
57
- c.setTransform(scale, 0, 0, scale, 0, 0);
97
+ if (buildingDirty) refreshBuilding();
98
+ const zoomed = scale * view.zoom;
99
+ c.setTransform(zoomed, 0, 0, zoomed, -view.x * zoomed, -view.y * zoomed);
58
100
  c.imageSmoothingEnabled = false;
59
- drawWorld(c, world, time, palette, selected);
101
+ drawWorld(c, world, time, palette, daylight, building, selected);
102
+ overlay?.(c);
60
103
  };
61
104
 
62
105
  // The backing store is a whole multiple of the art, so every pixel stays square and text stays sharp.
63
106
  const resize = (): void => {
64
- const shown = canvas.getBoundingClientRect().width || width;
107
+ const shown = canvas.getBoundingClientRect().width || width();
65
108
  const ratio = typeof devicePixelRatio === "number" ? devicePixelRatio : 1;
66
- const next = Math.max(1, Math.ceil((shown * ratio) / width));
67
- if (next === scale) return;
109
+ const next = Math.max(1, Math.ceil((shown * ratio) / width()));
110
+ if (next === scale && canvas.width === width() * scale && canvas.height === height() * scale) return;
68
111
  scale = next;
69
- canvas.width = width * scale;
70
- canvas.height = height * scale;
112
+ canvas.width = width() * scale;
113
+ canvas.height = height() * scale;
114
+ buildingDirty = true;
71
115
  paint();
72
116
  };
73
117
 
@@ -82,30 +126,90 @@ export function createOffice(canvas: HTMLCanvasElement, scene: OfficeScene = {},
82
126
  last = now;
83
127
  time += dt;
84
128
  step(world, dt);
129
+ const nowHour = new Date().getHours();
130
+ if (nowHour !== hour) {
131
+ hour = nowHour;
132
+ daylight = daylightAt(hour);
133
+ buildingDirty = true;
134
+ }
85
135
  paint();
86
136
  };
87
137
 
88
138
  const select = (id?: string): void => {
89
- const next = id === selected ? undefined : id;
90
- selected = next;
139
+ selected = id === selected ? undefined : id;
91
140
  options.onSelect?.(selected);
92
141
  if (still) paint();
93
142
  };
94
143
 
95
- const pointerTile = (event: PointerEvent): { x: number; y: number } => {
144
+ const tileAt = (event: { clientX: number; clientY: number }): { x: number; y: number } => {
96
145
  const box = canvas.getBoundingClientRect();
97
- return { x: ((event.clientX - box.left) / box.width) * world.cols, y: ((event.clientY - box.top) / box.height) * world.rows };
146
+ const px = ((event.clientX - box.left) / box.width) * (width() / view.zoom) + view.x;
147
+ const py = ((event.clientY - box.top) / box.height) * (height() / view.zoom) + view.y;
148
+ return { x: px / TILE, y: py / TILE };
98
149
  };
150
+
151
+ let drag: { x: number; y: number; moved: boolean } | undefined;
99
152
  const onDown = (event: PointerEvent): void => {
100
- const { x, y } = pointerTile(event);
101
- select(agentAt(world, x, y)?.id);
153
+ if (!interactive) return;
154
+ drag = { x: event.clientX, y: event.clientY, moved: false };
102
155
  };
103
156
  const onMove = (event: PointerEvent): void => {
104
- const { x, y } = pointerTile(event);
105
- canvas.style.cursor = agentAt(world, x, y) ? "pointer" : "default";
157
+ if (!interactive) return;
158
+ if (drag && options.camera && view.zoom > 1) {
159
+ const box = canvas.getBoundingClientRect();
160
+ const dx = event.clientX - drag.x;
161
+ const dy = event.clientY - drag.y;
162
+ if (drag.moved || Math.abs(dx) + Math.abs(dy) > 3) {
163
+ drag.moved = true;
164
+ view.x -= (dx / box.width) * (width() / view.zoom);
165
+ view.y -= (dy / box.height) * (height() / view.zoom);
166
+ drag.x = event.clientX;
167
+ drag.y = event.clientY;
168
+ clampView();
169
+ canvas.style.cursor = "grabbing";
170
+ return;
171
+ }
172
+ }
173
+ const { x, y } = tileAt(event);
174
+ canvas.style.cursor = agentAt(world, x, y) ? "pointer" : options.camera && view.zoom > 1 ? "grab" : "default";
106
175
  };
176
+ const onUp = (event: PointerEvent): void => {
177
+ if (!interactive) return;
178
+ const wasDrag = drag?.moved;
179
+ drag = undefined;
180
+ if (wasDrag) return;
181
+ const { x, y } = tileAt(event);
182
+ const agent = agentAt(world, x, y);
183
+ if (agent || selected) select(agent?.id ?? selected);
184
+ };
185
+ const zoom = (factor?: number, event?: { clientX: number; clientY: number }): void => {
186
+ if (factor === undefined) {
187
+ view.zoom = 1;
188
+ view.x = 0;
189
+ view.y = 0;
190
+ return;
191
+ }
192
+ 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));
194
+ if (event) {
195
+ const after = tileAt(event);
196
+ view.x += (before.x - after.x) * TILE;
197
+ view.y += (before.y - after.y) * TILE;
198
+ } else {
199
+ view.x = before.x * TILE - width() / view.zoom / 2;
200
+ view.y = before.y * TILE - height() / view.zoom / 2;
201
+ }
202
+ clampView();
203
+ };
204
+ const onWheel = (event: WheelEvent): void => {
205
+ event.preventDefault();
206
+ zoom(event.deltaY < 0 ? 1.2 : 1 / 1.2, event);
207
+ };
208
+
107
209
  canvas.addEventListener("pointerdown", onDown);
108
210
  canvas.addEventListener("pointermove", onMove);
211
+ canvas.addEventListener("pointerup", onUp);
212
+ if (options.camera) canvas.addEventListener("wheel", onWheel, { passive: false });
109
213
 
110
214
  const resizer = typeof ResizeObserver === "function" ? new ResizeObserver(resize) : undefined;
111
215
  resizer?.observe(canvas);
@@ -124,7 +228,19 @@ export function createOffice(canvas: HTMLCanvasElement, scene: OfficeScene = {},
124
228
  applyUpdate(world, next);
125
229
  if (still) paint();
126
230
  },
231
+ setLayout(layout) {
232
+ applyLayout(world, layout);
233
+ buildingDirty = true;
234
+ view.zoom = Math.min(view.zoom, MAX_ZOOM);
235
+ clampView();
236
+ scale = 0;
237
+ resize();
238
+ },
127
239
  select,
240
+ tileAt,
241
+ setOverlay(draw) { overlay = draw; },
242
+ setInteractive(on) { interactive = on; drag = undefined; if (!on) canvas.style.cursor = "crosshair"; },
243
+ zoom,
128
244
  destroy() {
129
245
  destroyed = true;
130
246
  cancelAnimationFrame(frame);
@@ -132,6 +248,8 @@ export function createOffice(canvas: HTMLCanvasElement, scene: OfficeScene = {},
132
248
  viewer?.disconnect();
133
249
  canvas.removeEventListener("pointerdown", onDown);
134
250
  canvas.removeEventListener("pointermove", onMove);
251
+ canvas.removeEventListener("pointerup", onUp);
252
+ canvas.removeEventListener("wheel", onWheel);
135
253
  },
136
254
  };
137
255
  }
package/src/runtime.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  /** The script tag version: starts every canvas Block on the page by itself. */
2
- import { mountCanvasBlocks } from "./index.js";
2
+ import { FURNITURE, createOffice, findPath, mountCanvasBlocks } from "./index.js";
3
+
4
+ // For pages that drive an office themselves, such as the blkc dev app.
5
+ (globalThis as unknown as { BLKCCanvas: unknown }).BLKCCanvas = { FURNITURE, createOffice, findPath, mountCanvasBlocks };
3
6
 
4
7
  if (document.readyState === "loading") {
5
8
  document.addEventListener("DOMContentLoaded", () => mountCanvasBlocks());
package/src/types.ts CHANGED
@@ -2,11 +2,22 @@
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";
5
+ export type FloorStyle = "wood" | "carpet" | "tile" | "stone" | "concrete" | "grass";
6
6
  export type FurnitureType =
7
- | "desk" | "coffee" | "sofa" | "plant" | "board" | "shelf" | "server" | "table" | "rug" | "water";
7
+ | "desk" | "coffee" | "sofa" | "plant" | "fern" | "board" | "whiteboard" | "shelf" | "server" | "table" | "rug"
8
+ | "water" | "arcade" | "fridge" | "printer" | "lamp" | "beanbag" | "tv" | "vending" | "clock" | "poster" | "trophy";
8
9
 
9
- export interface OfficeRoom { id?: string; name?: string; x: number; y: number; w: number; h: number; floor?: FloorStyle }
10
+ export interface OfficeRoom {
11
+ id?: string;
12
+ name?: string;
13
+ x: number;
14
+ y: number;
15
+ w: number;
16
+ h: number;
17
+ floor?: FloorStyle;
18
+ /** Project folders this room stands for, so agents go there when they work in them. */
19
+ paths?: string[];
20
+ }
10
21
  export interface OfficeDoor { x: number; y: number }
11
22
  export interface OfficeFurniture { type: FurnitureType; x: number; y: number }
12
23
  export interface OfficeAgent {
@@ -15,6 +26,8 @@ export interface OfficeAgent {
15
26
  role?: string;
16
27
  state?: AgentState;
17
28
  task?: string;
29
+ /** The id of the room this agent is working in. */
30
+ area?: string;
18
31
  /** Character colours. Anything left out is picked from the id. */
19
32
  hair?: string;
20
33
  skin?: string;
@@ -22,12 +35,15 @@ export interface OfficeAgent {
22
35
  }
23
36
  export interface OfficeTask { title: string; state?: TaskState; agent?: string }
24
37
 
25
- export interface OfficeScene {
38
+ export interface OfficeLayout {
26
39
  cols?: number;
27
40
  rows?: number;
28
41
  rooms?: OfficeRoom[];
29
42
  doors?: OfficeDoor[];
30
43
  furniture?: OfficeFurniture[];
44
+ }
45
+
46
+ export interface OfficeScene extends OfficeLayout {
31
47
  agents?: OfficeAgent[];
32
48
  tasks?: OfficeTask[];
33
49
  }
package/src/world.ts CHANGED
@@ -3,32 +3,44 @@
3
3
  * heading. Nothing in here touches a canvas, so all of it can be tested.
4
4
  */
5
5
 
6
- import type { AgentState, FloorStyle, FurnitureType, OfficeAgent, OfficeDoor, OfficeFurniture, OfficeRoom, OfficeScene, OfficeTask, TaskState } from "./types.js";
6
+ import type { AgentState, FloorStyle, FurnitureType, OfficeAgent, OfficeDoor, OfficeFurniture, OfficeLayout, OfficeRoom, OfficeScene, OfficeTask, TaskState } from "./types.js";
7
7
 
8
8
  export const MAX_COLS = 64;
9
9
  export const MAX_ROWS = 40;
10
10
  const MAX_AGENTS = 40;
11
11
  const MAX_FURNITURE = 300;
12
12
  const MAX_TASKS = 60;
13
- export const WALK_SPEED = 3.2; // tiles a second
13
+ export const WALK_SPEED = 4.5; // tiles a second
14
14
 
15
15
  /** Footprint in tiles, and where someone stands to use it, relative to its corner. */
16
- export const FURNITURE: Record<FurnitureType, { w: number; h: number; solid?: boolean; spot?: { x: number; y: number } }> = {
16
+ export const FURNITURE: Record<FurnitureType, { w: number; h: number; solid?: boolean; wall?: boolean; spot?: { x: number; y: number } }> = {
17
17
  desk: { w: 3, h: 2, spot: { x: 1, y: 2 } },
18
18
  coffee: { w: 2, h: 1, spot: { x: 0, y: 1 } },
19
19
  water: { w: 1, h: 1, spot: { x: 0, y: 1 } },
20
20
  sofa: { w: 3, h: 1, spot: { x: 1, y: 1 } },
21
21
  plant: { w: 1, h: 1 },
22
+ fern: { w: 1, h: 1 },
22
23
  board: { w: 4, h: 1, spot: { x: 1, y: 1 } },
24
+ whiteboard: { w: 3, h: 1, spot: { x: 1, y: 1 } },
23
25
  shelf: { w: 2, h: 1, spot: { x: 0, y: 1 } },
24
26
  server: { w: 1, h: 1 },
25
27
  table: { w: 3, h: 2, spot: { x: 1, y: 2 } },
26
28
  rug: { w: 4, h: 3, solid: false },
29
+ arcade: { w: 1, h: 1, spot: { x: 0, y: 1 } },
30
+ fridge: { w: 1, h: 1, spot: { x: 0, y: 1 } },
31
+ printer: { w: 1, h: 1, spot: { x: 0, y: 1 } },
32
+ lamp: { w: 1, h: 1 },
33
+ beanbag: { w: 1, h: 1, spot: { x: 0, y: 1 } },
34
+ tv: { w: 3, h: 1, spot: { x: 1, y: 2 } },
35
+ vending: { w: 1, h: 1, spot: { x: 0, y: 1 } },
36
+ trophy: { w: 1, h: 1 },
37
+ clock: { w: 1, h: 1, solid: false, wall: true },
38
+ poster: { w: 1, h: 1, solid: false, wall: true },
27
39
  };
28
40
 
29
41
  const AGENT_STATES: readonly string[] = ["working", "thinking", "blocked", "reviewing", "idle", "done", "away"];
30
42
  const TASK_STATES: readonly string[] = ["todo", "doing", "done"];
31
- const FLOORS: readonly string[] = ["wood", "carpet", "tile"];
43
+ const FLOORS: readonly string[] = ["wood", "carpet", "tile", "stone", "concrete", "grass"];
32
44
 
33
45
  export const DEFAULT_LAYOUT = {
34
46
  cols: 26,
@@ -53,7 +65,7 @@ const int = (value: unknown, fallback: number): number => (typeof value === "num
53
65
  const str = (value: unknown, max = 120): string => (typeof value === "string" ? value.slice(0, max) : "");
54
66
  const list = <T>(value: unknown, max: number): T[] => (Array.isArray(value) ? (value.slice(0, max) as T[]) : []);
55
67
 
56
- export interface Room { name: string; x: number; y: number; w: number; h: number; floor: FloorStyle }
68
+ export interface Room { id: string; name: string; x: number; y: number; w: number; h: number; floor: FloorStyle; paths: string[] }
57
69
  export interface Item { type: FurnitureType; x: number; y: number }
58
70
  export interface Task { title: string; state: TaskState; agent: string }
59
71
 
@@ -63,6 +75,9 @@ export interface Agent {
63
75
  role: string;
64
76
  state: AgentState;
65
77
  task: string;
78
+ area: string;
79
+ /** Seconds of celebration left after finishing something. */
80
+ party: number;
66
81
  hair: string;
67
82
  skin: string;
68
83
  shirt: string;
@@ -88,6 +103,7 @@ export interface World {
88
103
  tasks: Task[];
89
104
  walkable: boolean[];
90
105
  spots: { lounge: { x: number; y: number }[]; board: { x: number; y: number }[] };
106
+ seats: { x: number; y: number }[];
91
107
  }
92
108
 
93
109
  export function hash(text: string): number {
@@ -152,6 +168,8 @@ function toAgent(raw: OfficeAgent, index: number): Agent {
152
168
  role: str(raw?.role, 40),
153
169
  state,
154
170
  task: str(raw?.task, 120),
171
+ area: str(raw?.area, 60),
172
+ party: 0,
155
173
  hair: colour(raw?.hair, HAIR[seed % HAIR.length] as string),
156
174
  skin: colour(raw?.skin, SKIN[(seed >>> 4) % SKIN.length] as string),
157
175
  shirt: colour(raw?.shirt, SHIRT[(seed >>> 8) % SHIRT.length] as string),
@@ -169,8 +187,7 @@ function toTask(raw: OfficeTask): Task {
169
187
  return { title: str(raw?.title, 80), state: TASK_STATES.includes(raw?.state as string) ? (raw.state as TaskState) : "todo", agent: str(raw?.agent, 60) };
170
188
  }
171
189
 
172
- /** Build a world from a scene. Bad values are dropped or clamped, so it never throws. */
173
- export function createWorld(scene: OfficeScene = {}): World {
190
+ function buildLayout(scene: OfficeLayout): Pick<World, "cols" | "rows" | "rooms" | "doors" | "furniture" | "walkable" | "spots" | "seats"> {
174
191
  const custom = Array.isArray(scene?.rooms) && scene.rooms.length > 0;
175
192
  const cols = Math.min(Math.max(int(scene?.cols, custom ? 20 : DEFAULT_LAYOUT.cols), 6), MAX_COLS);
176
193
  const rows = Math.min(Math.max(int(scene?.rows, custom ? 14 : DEFAULT_LAYOUT.rows), 6), MAX_ROWS);
@@ -179,15 +196,17 @@ export function createWorld(scene: OfficeScene = {}): World {
179
196
  const rawItems = Array.isArray(scene?.furniture) ? list<OfficeFurniture>(scene.furniture, MAX_FURNITURE) : custom ? [] : DEFAULT_LAYOUT.furniture;
180
197
 
181
198
  // Clamped to the grid: everything later loops over these, so a huge room must not hang the page.
182
- const rooms: Room[] = rawRooms.map((room) => {
199
+ const rooms: Room[] = rawRooms.map((room, index) => {
183
200
  const x = Math.min(Math.max(int(room?.x, 0), 0), cols);
184
201
  const y = Math.min(Math.max(int(room?.y, 0), 0), rows);
185
202
  return {
203
+ id: str(room?.id, 60) || `room-${index + 1}`,
186
204
  name: str(room?.name, 24),
187
205
  x, y,
188
206
  w: Math.min(Math.max(int(room?.w, 0), 0), cols - x),
189
207
  h: Math.min(Math.max(int(room?.h, 0), 0), rows - y),
190
208
  floor: FLOORS.includes(room?.floor as string) ? (room.floor as FloorStyle) : "wood",
209
+ paths: list<string>(room?.paths, 20).filter((p) => typeof p === "string").map((p) => p.slice(0, 200)),
191
210
  };
192
211
  });
193
212
  const walkable = new Array<boolean>(cols * rows).fill(false);
@@ -209,48 +228,116 @@ export function createWorld(scene: OfficeScene = {}): World {
209
228
  for (let y = item.y; y < item.y + spec.h; y++) for (let x = item.x; x < item.x + spec.w; x++) mark(x, y, false);
210
229
  }
211
230
 
212
- const world: World = {
213
- cols, rows, rooms, doors, furniture, walkable,
214
- agents: list<OfficeAgent>(scene?.agents, MAX_AGENTS).map(toAgent),
215
- tasks: list<OfficeTask>(scene?.tasks, MAX_TASKS).map(toTask),
216
- spots: { lounge: [], board: [] },
217
- };
218
-
231
+ const open = (x: number, y: number): boolean => x >= 0 && y >= 0 && x < cols && y < rows && walkable[y * cols + x] === true;
219
232
  const spot = (item: Item): { x: number; y: number } | undefined => {
220
233
  const offset = FURNITURE[item.type].spot;
221
234
  if (!offset) return undefined;
222
235
  const point = { x: item.x + offset.x, y: item.y + offset.y };
223
- return isWalkable(world, point.x, point.y) ? point : undefined;
236
+ return open(point.x, point.y) ? point : undefined;
224
237
  };
225
238
  const seats = furniture.filter((item) => item.type === "desk").map(spot).filter((p): p is { x: number; y: number } => Boolean(p));
239
+ const spots: World["spots"] = { lounge: [], board: [] };
226
240
  for (const item of furniture) {
227
241
  const point = spot(item);
228
242
  if (!point) continue;
229
- if (item.type === "coffee" || item.type === "sofa" || item.type === "water" || item.type === "shelf") world.spots.lounge.push(point);
230
- if (item.type === "board" || item.type === "table") world.spots.board.push(point);
243
+ if (["coffee", "sofa", "water", "shelf", "arcade", "fridge", "beanbag", "vending", "tv"].includes(item.type)) spots.lounge.push(point);
244
+ if (["board", "table", "whiteboard"].includes(item.type)) spots.board.push(point);
231
245
  }
246
+ return { cols, rows, rooms, doors, furniture, walkable, seats, spots };
247
+ }
232
248
 
233
- world.agents.forEach((agent, index) => {
234
- agent.seat = seats[index];
235
- placeAgent(world, agent);
236
- });
249
+ /** Build a world from a scene. Bad values are dropped or clamped, so it never throws. */
250
+ export function createWorld(scene: OfficeScene = {}): World {
251
+ const world: World = {
252
+ ...buildLayout(scene),
253
+ agents: list<OfficeAgent>(scene?.agents, MAX_AGENTS).map(toAgent),
254
+ tasks: list<OfficeTask>(scene?.tasks, MAX_TASKS).map(toTask),
255
+ };
256
+ for (const agent of world.agents) placeAgent(world, agent);
237
257
  return world;
238
258
  }
239
259
 
260
+ /** Change the building while everyone is in it. Anyone left inside a wall or a desk moves. */
261
+ export function applyLayout(world: World, layout: OfficeLayout): void {
262
+ Object.assign(world, buildLayout(layout));
263
+ for (const agent of world.agents) {
264
+ agent.seat = undefined;
265
+ agent.path = [];
266
+ agent.x = Math.round(agent.x);
267
+ agent.y = Math.round(agent.y);
268
+ if (!isWalkable(world, agent.x, agent.y)) {
269
+ placeAgent(world, agent);
270
+ } else {
271
+ updateSeat(world, agent);
272
+ routeTo(world, agent, goalFor(world, agent));
273
+ }
274
+ }
275
+ }
276
+
240
277
  function firstWalkable(world: World): { x: number; y: number } {
241
278
  for (let y = 0; y < world.rows; y++) for (let x = 0; x < world.cols; x++) if (isWalkable(world, x, y)) return { x, y };
242
279
  return { x: 0, y: 0 };
243
280
  }
244
281
 
245
- /** Where this agent's current state wants them to be. */
282
+ const NEEDS_DESK: readonly AgentState[] = ["working", "thinking", "blocked"];
283
+
284
+ /** Desks belong to whoever is working at one: claimed when an agent starts, freed when it stops. */
285
+ function updateSeat(world: World, agent: Agent): void {
286
+ if (!NEEDS_DESK.includes(agent.state)) {
287
+ agent.seat = undefined;
288
+ return;
289
+ }
290
+ const room = world.rooms.find((candidate) => candidate.id === agent.area);
291
+ const inRoom = (seat: { x: number; y: number }): boolean =>
292
+ Boolean(room) && seat.x >= (room as Room).x && seat.x < (room as Room).x + (room as Room).w && seat.y >= (room as Room).y && seat.y < (room as Room).y + (room as Room).h;
293
+ // Moved to another room: this desk is no longer the right one.
294
+ if (agent.seat && (!room || inRoom(agent.seat))) return;
295
+ agent.seat = undefined;
296
+ const taken = new Set(world.agents.filter((other) => other !== agent && other.seat).map((other) => `${other.seat?.x},${other.seat?.y}`));
297
+ const free = world.seats.filter((seat) => !taken.has(`${seat.x},${seat.y}`));
298
+ agent.seat = room ? free.find(inRoom) : free[0];
299
+ }
300
+
301
+ /** Where an agent is standing, or heading to if it is walking. */
302
+ function standing(agent: Agent): { x: number; y: number } {
303
+ const end = agent.path[agent.path.length - 1];
304
+ return end ?? { x: Math.round(agent.x), y: Math.round(agent.y) };
305
+ }
306
+
307
+ /** The closest tile to a spot that no other agent is on, so they don't stand inside each other. */
308
+ function freeTileNear(world: World, agent: Agent, spot: { x: number; y: number }): { x: number; y: number } {
309
+ const others = new Set(world.agents.filter((other) => other !== agent && other.state !== "away").map((other) => {
310
+ const at = standing(other);
311
+ return `${at.x},${at.y}`;
312
+ }));
313
+ for (let radius = 0; radius <= 4; radius++) {
314
+ for (let dy = -radius; dy <= radius; dy++) {
315
+ for (let dx = -radius; dx <= radius; dx++) {
316
+ if (Math.abs(dx) + Math.abs(dy) !== radius) continue;
317
+ const tile = { x: spot.x + dx, y: spot.y + dy };
318
+ if (isWalkable(world, tile.x, tile.y) && !others.has(`${tile.x},${tile.y}`) && findPath(world, spot, tile)) return tile;
319
+ }
320
+ }
321
+ }
322
+ return spot;
323
+ }
324
+
325
+ /** Where this agent's current state wants it to be. */
246
326
  export function goalFor(world: World, agent: Agent): { x: number; y: number } | undefined {
247
- const pick = (spots: { x: number; y: number }[]): { x: number; y: number } | undefined =>
248
- spots.length > 0 ? spots[(agent.seed + Math.floor(agent.rest * 1000)) % spots.length] : undefined;
327
+ const pick = (spots: { x: number; y: number }[]): { x: number; y: number } | undefined => {
328
+ const spot = spots.length > 0 ? spots[(agent.seed + Math.floor(agent.rest * 1000)) % spots.length] : undefined;
329
+ return spot && freeTileNear(world, agent, spot);
330
+ };
249
331
  switch (agent.state) {
250
332
  case "working":
251
333
  case "thinking":
252
- case "blocked":
253
- return agent.seat ?? pick(world.spots.lounge);
334
+ case "blocked": {
335
+ if (agent.seat) return agent.seat;
336
+ // No free desk in the room they are working in: they stand in it anyway.
337
+ const room = world.rooms.find((candidate) => candidate.id === agent.area);
338
+ if (room && room.w > 0 && room.h > 0) return freeTileNear(world, agent, { x: room.x + (room.w >> 1), y: room.y + (room.h >> 1) });
339
+ return pick(world.spots.lounge);
340
+ }
254
341
  case "reviewing":
255
342
  return pick(world.spots.board) ?? agent.seat;
256
343
  case "idle":
@@ -262,6 +349,7 @@ export function goalFor(world: World, agent: Agent): { x: number; y: number } |
262
349
  }
263
350
 
264
351
  function placeAgent(world: World, agent: Agent): void {
352
+ updateSeat(world, agent);
265
353
  const goal = goalFor(world, agent) ?? agent.seat ?? firstWalkable(world);
266
354
  agent.x = goal.x;
267
355
  agent.y = goal.y;
@@ -307,6 +395,7 @@ export function step(world: World, dt: number): void {
307
395
  }
308
396
 
309
397
  agent.walking = false;
398
+ if (agent.party > 0) agent.party = Math.max(0, agent.party - dt);
310
399
  if (agent.state === "idle") {
311
400
  agent.rest -= dt;
312
401
  if (agent.rest <= 0) {
@@ -324,25 +413,39 @@ export function applyUpdate(world: World, scene: Pick<OfficeScene, "agents" | "t
324
413
  if (Array.isArray(scene.tasks)) world.tasks = list<OfficeTask>(scene.tasks, MAX_TASKS).map(toTask);
325
414
  if (!Array.isArray(scene.agents)) return;
326
415
  const existing = new Map(world.agents.map((agent) => [agent.id, agent]));
327
- const seats = world.furniture
328
- .filter((item) => item.type === "desk")
329
- .map((item) => ({ x: item.x + 1, y: item.y + 2 }))
330
- .filter((point) => isWalkable(world, point.x, point.y));
331
- world.agents = list<OfficeAgent>(scene.agents, MAX_AGENTS).map((raw, index) => {
332
- const next = toAgent(raw, index);
416
+ const incoming = list<OfficeAgent>(scene.agents, MAX_AGENTS).map(toAgent);
417
+ world.agents = incoming.map((next) => existing.get(next.id)).filter((agent): agent is Agent => Boolean(agent));
418
+ const updated = incoming.map((next) => {
333
419
  const current = existing.get(next.id);
334
- next.seat = seats[index];
335
420
  if (!current) {
336
421
  placeAgent(world, next);
422
+ world.agents = [...world.agents, next];
423
+ // New arrivals come in through a door and walk to where they are going.
424
+ const door = world.doors[0];
425
+ const goal = { x: next.x, y: next.y };
426
+ if (door && next.state !== "away") {
427
+ next.x = door.x;
428
+ next.y = door.y;
429
+ routeTo(world, next, goal);
430
+ }
337
431
  return next;
338
432
  }
339
- Object.assign(current, { name: next.name, role: next.role, task: next.task, hair: next.hair, skin: next.skin, shirt: next.shirt, seat: next.seat });
340
- if (current.state !== next.state) {
433
+ Object.assign(current, { name: next.name, role: next.role, task: next.task, hair: next.hair, skin: next.skin, shirt: next.shirt });
434
+ const moved = current.area !== next.area;
435
+ current.area = next.area;
436
+ if (current.state !== next.state || moved) {
437
+ if (next.state === "done" && current.state !== "done") current.party = 3;
341
438
  current.state = next.state;
439
+ updateSeat(world, current);
342
440
  if (next.state !== "away") routeTo(world, current, goalFor(world, current));
441
+ } else if (NEEDS_DESK.includes(current.state) && !current.seat) {
442
+ // No desk was free before. One may be now.
443
+ updateSeat(world, current);
444
+ if (current.seat) routeTo(world, current, current.seat);
343
445
  }
344
446
  return current;
345
447
  });
448
+ world.agents = updated;
346
449
  }
347
450
 
348
451
  /** Agent under a point given in tiles, nearest first. */