@blkc-code/render-canvas 0.0.0-stage → 0.1.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 +16 -0
- package/dist/draw.d.ts.map +1 -0
- package/dist/draw.js +427 -0
- package/dist/draw.js.map +1 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +60 -0
- package/dist/index.js.map +1 -0
- package/dist/office.d.ts +18 -0
- package/dist/office.d.ts.map +1 -0
- package/dist/office.js +119 -0
- package/dist/office.js.map +1 -0
- package/dist/runtime.d.ts +2 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +11 -0
- package/dist/runtime.js.map +1 -0
- package/dist/runtime.txt +11 -0
- package/dist/types.d.ts +49 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/world.d.ts +117 -0
- package/dist/world.d.ts.map +1 -0
- package/dist/world.js +322 -0
- package/dist/world.js.map +1 -0
- package/package.json +43 -4
- package/src/draw.ts +429 -0
- package/src/index.ts +73 -0
- package/src/office.ts +137 -0
- package/src/runtime.ts +8 -0
- package/src/types.ts +33 -0
- package/src/world.ts +363 -0
- package/README.md +0 -3
package/src/world.ts
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The office as data: which tiles can be walked on, who is where, and where they are
|
|
3
|
+
* heading. Nothing in here touches a canvas, so all of it can be tested.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { AgentState, FloorStyle, FurnitureType, OfficeAgent, OfficeDoor, OfficeFurniture, OfficeRoom, OfficeScene, OfficeTask, TaskState } from "./types.js";
|
|
7
|
+
|
|
8
|
+
export const MAX_COLS = 64;
|
|
9
|
+
export const MAX_ROWS = 40;
|
|
10
|
+
const MAX_AGENTS = 40;
|
|
11
|
+
const MAX_FURNITURE = 300;
|
|
12
|
+
const MAX_TASKS = 60;
|
|
13
|
+
export const WALK_SPEED = 3.2; // tiles a second
|
|
14
|
+
|
|
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 } }> = {
|
|
17
|
+
desk: { w: 3, h: 2, spot: { x: 1, y: 2 } },
|
|
18
|
+
coffee: { w: 2, h: 1, spot: { x: 0, y: 1 } },
|
|
19
|
+
water: { w: 1, h: 1, spot: { x: 0, y: 1 } },
|
|
20
|
+
sofa: { w: 3, h: 1, spot: { x: 1, y: 1 } },
|
|
21
|
+
plant: { w: 1, h: 1 },
|
|
22
|
+
board: { w: 4, h: 1, spot: { x: 1, y: 1 } },
|
|
23
|
+
shelf: { w: 2, h: 1, spot: { x: 0, y: 1 } },
|
|
24
|
+
server: { w: 1, h: 1 },
|
|
25
|
+
table: { w: 3, h: 2, spot: { x: 1, y: 2 } },
|
|
26
|
+
rug: { w: 4, h: 3, solid: false },
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const AGENT_STATES: readonly string[] = ["working", "thinking", "blocked", "reviewing", "idle", "done", "away"];
|
|
30
|
+
const TASK_STATES: readonly string[] = ["todo", "doing", "done"];
|
|
31
|
+
const FLOORS: readonly string[] = ["wood", "carpet", "tile"];
|
|
32
|
+
|
|
33
|
+
export const DEFAULT_LAYOUT = {
|
|
34
|
+
cols: 26,
|
|
35
|
+
rows: 17,
|
|
36
|
+
rooms: [
|
|
37
|
+
{ id: "studio", name: "Studio", x: 1, y: 3, w: 15, h: 13, floor: "wood" },
|
|
38
|
+
{ id: "lounge", name: "Lounge", x: 17, y: 3, w: 8, h: 6, floor: "carpet" },
|
|
39
|
+
{ id: "quests", name: "Quests", x: 17, y: 10, w: 8, h: 6, floor: "tile" },
|
|
40
|
+
] as OfficeRoom[],
|
|
41
|
+
doors: [{ x: 16, y: 6 }, { x: 16, y: 13 }, { x: 23, y: 9 }] as OfficeDoor[],
|
|
42
|
+
furniture: [
|
|
43
|
+
{ type: "desk", x: 2, y: 4 }, { type: "desk", x: 7, y: 4 }, { type: "desk", x: 12, y: 4 },
|
|
44
|
+
{ type: "desk", x: 2, y: 10 }, { type: "desk", x: 7, y: 10 }, { type: "desk", x: 12, y: 10 },
|
|
45
|
+
{ type: "plant", x: 1, y: 8 }, { type: "plant", x: 15, y: 8 }, { type: "server", x: 15, y: 15 }, { type: "server", x: 14, y: 15 },
|
|
46
|
+
{ type: "shelf", x: 1, y: 15 },
|
|
47
|
+
{ type: "coffee", x: 19, y: 3 }, { type: "water", x: 21, y: 3 }, { type: "sofa", x: 22, y: 3 }, { type: "rug", x: 20, y: 5 }, { type: "plant", x: 24, y: 8 },
|
|
48
|
+
{ type: "board", x: 19, y: 10 }, { type: "table", x: 19, y: 13 }, { type: "plant", x: 24, y: 15 },
|
|
49
|
+
] as OfficeFurniture[],
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const int = (value: unknown, fallback: number): number => (typeof value === "number" && Number.isFinite(value) ? Math.floor(value) : fallback);
|
|
53
|
+
const str = (value: unknown, max = 120): string => (typeof value === "string" ? value.slice(0, max) : "");
|
|
54
|
+
const list = <T>(value: unknown, max: number): T[] => (Array.isArray(value) ? (value.slice(0, max) as T[]) : []);
|
|
55
|
+
|
|
56
|
+
export interface Room { name: string; x: number; y: number; w: number; h: number; floor: FloorStyle }
|
|
57
|
+
export interface Item { type: FurnitureType; x: number; y: number }
|
|
58
|
+
export interface Task { title: string; state: TaskState; agent: string }
|
|
59
|
+
|
|
60
|
+
export interface Agent {
|
|
61
|
+
id: string;
|
|
62
|
+
name: string;
|
|
63
|
+
role: string;
|
|
64
|
+
state: AgentState;
|
|
65
|
+
task: string;
|
|
66
|
+
hair: string;
|
|
67
|
+
skin: string;
|
|
68
|
+
shirt: string;
|
|
69
|
+
/** Position in tiles; x/y is the tile the feet are on. */
|
|
70
|
+
x: number;
|
|
71
|
+
y: number;
|
|
72
|
+
path: { x: number; y: number }[];
|
|
73
|
+
facing: "up" | "down" | "left" | "right";
|
|
74
|
+
walking: boolean;
|
|
75
|
+
seat?: { x: number; y: number };
|
|
76
|
+
/** Seconds until an idle agent picks somewhere new. */
|
|
77
|
+
rest: number;
|
|
78
|
+
seed: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface World {
|
|
82
|
+
cols: number;
|
|
83
|
+
rows: number;
|
|
84
|
+
rooms: Room[];
|
|
85
|
+
doors: OfficeDoor[];
|
|
86
|
+
furniture: Item[];
|
|
87
|
+
agents: Agent[];
|
|
88
|
+
tasks: Task[];
|
|
89
|
+
walkable: boolean[];
|
|
90
|
+
spots: { lounge: { x: number; y: number }[]; board: { x: number; y: number }[] };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function hash(text: string): number {
|
|
94
|
+
let value = 2166136261;
|
|
95
|
+
for (let i = 0; i < text.length; i++) value = Math.imul(value ^ text.charCodeAt(i), 16777619);
|
|
96
|
+
return value >>> 0;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const HAIR = ["#2b1b12", "#5a3a22", "#a8622d", "#d9b36c", "#20242e", "#7d2f3f", "#4a5568", "#e5e7eb"];
|
|
100
|
+
const SKIN = ["#f4d3b5", "#e8b98f", "#c98d62", "#9c6644", "#6f4530", "#4e2f20"];
|
|
101
|
+
const SHIRT = ["#3b82f6", "#ef4444", "#10b981", "#f59e0b", "#8b5cf6", "#ec4899", "#14b8a6", "#f97316"];
|
|
102
|
+
|
|
103
|
+
// A theme value becomes a canvas colour, so anything but a plain colour is refused.
|
|
104
|
+
const colour = (value: unknown, fallback: string): string =>
|
|
105
|
+
typeof value === "string" && /^#[0-9a-f]{3,8}$/i.test(value) ? value : fallback;
|
|
106
|
+
|
|
107
|
+
const at = (world: { cols: number }, x: number, y: number): number => y * world.cols + x;
|
|
108
|
+
|
|
109
|
+
export function isWalkable(world: World, x: number, y: number): boolean {
|
|
110
|
+
return x >= 0 && y >= 0 && x < world.cols && y < world.rows && world.walkable[at(world, x, y)] === true;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Shortest walk between two tiles, or null if they aren't connected. Excludes the start. */
|
|
114
|
+
export function findPath(world: World, from: { x: number; y: number }, to: { x: number; y: number }): { x: number; y: number }[] | null {
|
|
115
|
+
if (!isWalkable(world, to.x, to.y)) return null;
|
|
116
|
+
if (from.x === to.x && from.y === to.y) return [];
|
|
117
|
+
const previous = new Int32Array(world.cols * world.rows).fill(-1);
|
|
118
|
+
const start = at(world, from.x, from.y);
|
|
119
|
+
previous[start] = start;
|
|
120
|
+
const queue = [start];
|
|
121
|
+
for (let head = 0; head < queue.length; head++) {
|
|
122
|
+
const current = queue[head] as number;
|
|
123
|
+
const x = current % world.cols;
|
|
124
|
+
const y = Math.floor(current / world.cols);
|
|
125
|
+
if (x === to.x && y === to.y) {
|
|
126
|
+
const path: { x: number; y: number }[] = [];
|
|
127
|
+
for (let step = current; step !== start; step = previous[step] as number) {
|
|
128
|
+
path.unshift({ x: step % world.cols, y: Math.floor(step / world.cols) });
|
|
129
|
+
}
|
|
130
|
+
return path;
|
|
131
|
+
}
|
|
132
|
+
for (const [dx, dy] of [[0, 1], [0, -1], [1, 0], [-1, 0]] as const) {
|
|
133
|
+
const nx = x + dx;
|
|
134
|
+
const ny = y + dy;
|
|
135
|
+
if (!isWalkable(world, nx, ny)) continue;
|
|
136
|
+
const index = at(world, nx, ny);
|
|
137
|
+
if (previous[index] !== -1) continue;
|
|
138
|
+
previous[index] = current;
|
|
139
|
+
queue.push(index);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function toAgent(raw: OfficeAgent, index: number): Agent {
|
|
146
|
+
const id = str(raw?.id, 60) || `agent-${index + 1}`;
|
|
147
|
+
const seed = hash(id);
|
|
148
|
+
const state = AGENT_STATES.includes(raw?.state as string) ? (raw.state as AgentState) : "idle";
|
|
149
|
+
return {
|
|
150
|
+
id,
|
|
151
|
+
name: str(raw?.name, 24) || id,
|
|
152
|
+
role: str(raw?.role, 40),
|
|
153
|
+
state,
|
|
154
|
+
task: str(raw?.task, 120),
|
|
155
|
+
hair: colour(raw?.hair, HAIR[seed % HAIR.length] as string),
|
|
156
|
+
skin: colour(raw?.skin, SKIN[(seed >>> 4) % SKIN.length] as string),
|
|
157
|
+
shirt: colour(raw?.shirt, SHIRT[(seed >>> 8) % SHIRT.length] as string),
|
|
158
|
+
x: 0,
|
|
159
|
+
y: 0,
|
|
160
|
+
path: [],
|
|
161
|
+
facing: "down",
|
|
162
|
+
walking: false,
|
|
163
|
+
rest: 0,
|
|
164
|
+
seed,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function toTask(raw: OfficeTask): Task {
|
|
169
|
+
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
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Build a world from a scene. Bad values are dropped or clamped, so it never throws. */
|
|
173
|
+
export function createWorld(scene: OfficeScene = {}): World {
|
|
174
|
+
const custom = Array.isArray(scene?.rooms) && scene.rooms.length > 0;
|
|
175
|
+
const cols = Math.min(Math.max(int(scene?.cols, custom ? 20 : DEFAULT_LAYOUT.cols), 6), MAX_COLS);
|
|
176
|
+
const rows = Math.min(Math.max(int(scene?.rows, custom ? 14 : DEFAULT_LAYOUT.rows), 6), MAX_ROWS);
|
|
177
|
+
const rawRooms = custom ? list<OfficeRoom>(scene.rooms, 40) : DEFAULT_LAYOUT.rooms;
|
|
178
|
+
const rawDoors = custom ? list<OfficeDoor>(scene.doors, 60) : DEFAULT_LAYOUT.doors;
|
|
179
|
+
const rawItems = Array.isArray(scene?.furniture) ? list<OfficeFurniture>(scene.furniture, MAX_FURNITURE) : custom ? [] : DEFAULT_LAYOUT.furniture;
|
|
180
|
+
|
|
181
|
+
// 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) => {
|
|
183
|
+
const x = Math.min(Math.max(int(room?.x, 0), 0), cols);
|
|
184
|
+
const y = Math.min(Math.max(int(room?.y, 0), 0), rows);
|
|
185
|
+
return {
|
|
186
|
+
name: str(room?.name, 24),
|
|
187
|
+
x, y,
|
|
188
|
+
w: Math.min(Math.max(int(room?.w, 0), 0), cols - x),
|
|
189
|
+
h: Math.min(Math.max(int(room?.h, 0), 0), rows - y),
|
|
190
|
+
floor: FLOORS.includes(room?.floor as string) ? (room.floor as FloorStyle) : "wood",
|
|
191
|
+
};
|
|
192
|
+
});
|
|
193
|
+
const walkable = new Array<boolean>(cols * rows).fill(false);
|
|
194
|
+
const mark = (x: number, y: number, value: boolean): void => {
|
|
195
|
+
if (x >= 0 && y >= 0 && x < cols && y < rows) walkable[y * cols + x] = value;
|
|
196
|
+
};
|
|
197
|
+
for (const room of rooms) {
|
|
198
|
+
for (let y = room.y; y < room.y + room.h; y++) for (let x = room.x; x < room.x + room.w; x++) mark(x, y, true);
|
|
199
|
+
}
|
|
200
|
+
const doors = rawDoors.map((door) => ({ x: int(door?.x, -1), y: int(door?.y, -1) })).filter((door) => door.x >= 0 && door.y >= 0);
|
|
201
|
+
for (const door of doors) mark(door.x, door.y, true);
|
|
202
|
+
|
|
203
|
+
const furniture: Item[] = rawItems
|
|
204
|
+
.filter((item) => item && Object.hasOwn(FURNITURE, item.type))
|
|
205
|
+
.map((item) => ({ type: item.type, x: int(item.x, -99), y: int(item.y, -99) }));
|
|
206
|
+
for (const item of furniture) {
|
|
207
|
+
const spec = FURNITURE[item.type];
|
|
208
|
+
if (spec.solid === false) continue;
|
|
209
|
+
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
|
+
}
|
|
211
|
+
|
|
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
|
+
|
|
219
|
+
const spot = (item: Item): { x: number; y: number } | undefined => {
|
|
220
|
+
const offset = FURNITURE[item.type].spot;
|
|
221
|
+
if (!offset) return undefined;
|
|
222
|
+
const point = { x: item.x + offset.x, y: item.y + offset.y };
|
|
223
|
+
return isWalkable(world, point.x, point.y) ? point : undefined;
|
|
224
|
+
};
|
|
225
|
+
const seats = furniture.filter((item) => item.type === "desk").map(spot).filter((p): p is { x: number; y: number } => Boolean(p));
|
|
226
|
+
for (const item of furniture) {
|
|
227
|
+
const point = spot(item);
|
|
228
|
+
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);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
world.agents.forEach((agent, index) => {
|
|
234
|
+
agent.seat = seats[index];
|
|
235
|
+
placeAgent(world, agent);
|
|
236
|
+
});
|
|
237
|
+
return world;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function firstWalkable(world: World): { x: number; y: number } {
|
|
241
|
+
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
|
+
return { x: 0, y: 0 };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Where this agent's current state wants them to be. */
|
|
246
|
+
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;
|
|
249
|
+
switch (agent.state) {
|
|
250
|
+
case "working":
|
|
251
|
+
case "thinking":
|
|
252
|
+
case "blocked":
|
|
253
|
+
return agent.seat ?? pick(world.spots.lounge);
|
|
254
|
+
case "reviewing":
|
|
255
|
+
return pick(world.spots.board) ?? agent.seat;
|
|
256
|
+
case "idle":
|
|
257
|
+
case "done":
|
|
258
|
+
return pick(world.spots.lounge) ?? agent.seat;
|
|
259
|
+
default:
|
|
260
|
+
return undefined;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function placeAgent(world: World, agent: Agent): void {
|
|
265
|
+
const goal = goalFor(world, agent) ?? agent.seat ?? firstWalkable(world);
|
|
266
|
+
agent.x = goal.x;
|
|
267
|
+
agent.y = goal.y;
|
|
268
|
+
agent.path = [];
|
|
269
|
+
agent.walking = false;
|
|
270
|
+
agent.facing = isSeated(agent) ? "up" : "down";
|
|
271
|
+
agent.rest = 2 + (agent.seed % 50) / 10;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export const isSeated = (agent: Agent): boolean =>
|
|
275
|
+
(agent.state === "working" || agent.state === "thinking" || agent.state === "blocked") &&
|
|
276
|
+
agent.seat !== undefined && agent.seat.x === agent.x && agent.seat.y === agent.y && agent.path.length === 0;
|
|
277
|
+
|
|
278
|
+
function routeTo(world: World, agent: Agent, goal: { x: number; y: number } | undefined): void {
|
|
279
|
+
if (!goal) return;
|
|
280
|
+
const from = { x: Math.round(agent.x), y: Math.round(agent.y) };
|
|
281
|
+
agent.path = findPath(world, from, goal) ?? [];
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Advance the whole office by `dt` seconds. */
|
|
285
|
+
export function step(world: World, dt: number): void {
|
|
286
|
+
for (const agent of world.agents) {
|
|
287
|
+
if (agent.state === "away") continue;
|
|
288
|
+
|
|
289
|
+
if (agent.path.length > 0) {
|
|
290
|
+
const target = agent.path[0] as { x: number; y: number };
|
|
291
|
+
const dx = target.x - agent.x;
|
|
292
|
+
const dy = target.y - agent.y;
|
|
293
|
+
const distance = Math.hypot(dx, dy);
|
|
294
|
+
const move = WALK_SPEED * dt;
|
|
295
|
+
if (Math.abs(dx) > Math.abs(dy)) agent.facing = dx > 0 ? "right" : "left";
|
|
296
|
+
else if (dy !== 0) agent.facing = dy > 0 ? "down" : "up";
|
|
297
|
+
agent.walking = true;
|
|
298
|
+
if (distance <= move) {
|
|
299
|
+
agent.x = target.x;
|
|
300
|
+
agent.y = target.y;
|
|
301
|
+
agent.path.shift();
|
|
302
|
+
} else {
|
|
303
|
+
agent.x += (dx / distance) * move;
|
|
304
|
+
agent.y += (dy / distance) * move;
|
|
305
|
+
}
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
agent.walking = false;
|
|
310
|
+
if (agent.state === "idle") {
|
|
311
|
+
agent.rest -= dt;
|
|
312
|
+
if (agent.rest <= 0) {
|
|
313
|
+
agent.seed = hash(String(agent.seed)); // somewhere new, the same way each run
|
|
314
|
+
agent.rest = 3 + (agent.seed % 60) / 10;
|
|
315
|
+
routeTo(world, agent, goalFor(world, agent));
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
if (isSeated(agent)) agent.facing = "up";
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Change what people are doing without restarting them. New agents appear at their goal. */
|
|
323
|
+
export function applyUpdate(world: World, scene: Pick<OfficeScene, "agents" | "tasks">): void {
|
|
324
|
+
if (Array.isArray(scene.tasks)) world.tasks = list<OfficeTask>(scene.tasks, MAX_TASKS).map(toTask);
|
|
325
|
+
if (!Array.isArray(scene.agents)) return;
|
|
326
|
+
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);
|
|
333
|
+
const current = existing.get(next.id);
|
|
334
|
+
next.seat = seats[index];
|
|
335
|
+
if (!current) {
|
|
336
|
+
placeAgent(world, next);
|
|
337
|
+
return next;
|
|
338
|
+
}
|
|
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) {
|
|
341
|
+
current.state = next.state;
|
|
342
|
+
if (next.state !== "away") routeTo(world, current, goalFor(world, current));
|
|
343
|
+
}
|
|
344
|
+
return current;
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** Agent under a point given in tiles, nearest first. */
|
|
349
|
+
export function agentAt(world: World, x: number, y: number): Agent | undefined {
|
|
350
|
+
let best: Agent | undefined;
|
|
351
|
+
let bestDistance = Infinity;
|
|
352
|
+
for (const agent of world.agents) {
|
|
353
|
+
if (agent.state === "away") continue;
|
|
354
|
+
const dx = x - (agent.x + 0.5);
|
|
355
|
+
const dy = y - (agent.y + 0.1);
|
|
356
|
+
const distance = Math.hypot(dx, dy);
|
|
357
|
+
if (Math.abs(dx) < 0.8 && dy > -1.6 && dy < 1.1 && distance < bestDistance) {
|
|
358
|
+
best = agent;
|
|
359
|
+
bestDistance = distance;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return best;
|
|
363
|
+
}
|
package/README.md
DELETED