@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/dist/draw.d.ts +13 -3
- package/dist/draw.d.ts.map +1 -1
- package/dist/draw.js +529 -196
- package/dist/draw.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/office.d.ts +23 -2
- package/dist/office.d.ts.map +1 -1
- package/dist/office.js +132 -20
- package/dist/office.js.map +1 -1
- package/dist/runtime.js +1 -1
- package/dist/runtime.js.map +1 -1
- package/dist/runtime.txt +1 -1
- package/dist/types.d.ts +9 -3
- package/dist/types.d.ts.map +1 -1
- package/dist/world.d.ts +15 -3
- package/dist/world.d.ts.map +1 -1
- package/dist/world.js +138 -32
- package/dist/world.js.map +1 -1
- package/package.json +1 -1
- package/src/draw.ts +489 -193
- package/src/index.ts +1 -1
- package/src/office.ts +139 -21
- package/src/runtime.ts +4 -1
- package/src/types.ts +20 -4
- package/src/world.ts +139 -36
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
144
|
+
const tileAt = (event: { clientX: number; clientY: number }): { x: number; y: number } => {
|
|
96
145
|
const box = canvas.getBoundingClientRect();
|
|
97
|
-
|
|
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
|
-
|
|
101
|
-
|
|
153
|
+
if (!interactive) return;
|
|
154
|
+
drag = { x: event.clientX, y: event.clientY, moved: false };
|
|
102
155
|
};
|
|
103
156
|
const onMove = (event: PointerEvent): void => {
|
|
104
|
-
|
|
105
|
-
|
|
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" | "
|
|
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 {
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
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 (
|
|
230
|
-
if (
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
328
|
-
|
|
329
|
-
|
|
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
|
|
340
|
-
|
|
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. */
|