dsh-mutipleagent 0.2.1 → 0.4.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.
@@ -1,1631 +1,1631 @@
1
- // Isometric office: agents that are working sit at cubicle desks; a few that
2
- // are off duty wander between the pantry, lounge, meeting room and ping-pong
3
- // table. Plain canvas 2D with local provider logos, no dependencies.
4
- (() => {
5
- 'use strict';
6
-
7
- const TW = 64; // tile width px
8
- const TH = 32; // tile height px; one z unit is TH px too
9
- const WALL_H = 2.4;
10
- const WORKING = new Set(['thinking', 'tool', 'typing', 'waiting', 'error']);
11
- const MAX_WANDERERS = 7;
12
- const SPEED = 2.1; // tiles / second
13
-
14
- const SKIN = ['#f3cfb0', '#e5b38f', '#c98d66', '#9a6646', '#6e4630', '#f7dcc7'];
15
- const HAIR = ['#2a2019', '#4b3020', '#8a4b2a', '#c99a5b', '#1c1c1c', '#8c8c8c', '#a33a2c', '#3d3550'];
16
- const SHIRT = {
17
- claude: ['#c96442', '#d9825f', '#b4553a', '#e09a72'],
18
- codex: ['#10a37f', '#2bb38f', '#0d8a6b', '#4cc3a0'],
19
- gemini: ['#4f7cf0', '#6b8ff5', '#3d66d6', '#8aa6f8'],
20
- copilot: ['#8f5cf5', '#a06af7', '#7a47d5', '#c5a4ff'],
21
- omp: ['#a96926', '#c78942', '#8d531e', '#dca45c'],
22
- pi: ['#4f8eb3', '#6ba5c7', '#3d7596', '#9cc7df'],
23
- antigravity: ['#0ea5e9', '#38bdf8', '#0284c7', '#7dd3fc'],
24
- opencode: ['#2f6fd0', '#4a86e0', '#2559b0', '#7aa5ee'],
25
- hermes: ['#b4622d', '#d07c44', '#96501f', '#e2a374'],
26
- };
27
- // Official product icons, shared by both renderers and the page UI (OMP: a neutral monogram).
28
- // Load once per page from the same origin so video capture stays readable.
29
- // Initials remain a fallback if an image cannot be loaded.
30
- const AGENT_BADGES = {
31
- claude: { label: 'Claude', short: 'Cl', color: '#c96442', logo: './assets/agents/claude.png' },
32
- codex: { label: 'Codex', short: 'Cx', color: '#10a37f', logo: './assets/agents/codex.png' },
33
- gemini: { label: 'Gemini', short: 'G', color: '#4f7cf0', logo: './assets/agents/gemini.png' },
34
- copilot: { label: 'Copilot', short: 'Co', color: '#8f5cf5', logo: './assets/agents/copilot.svg', mono: true },
35
- kimi: { label: 'Kimi Code', short: 'Ki', logo: './assets/agents/kimi.svg', mono: true },
36
- dsh: { label: 'DSH', short: 'Ds', logo: './assets/agents/dsh.svg' },
37
- grok: { label: 'Grok Build', short: 'Gr', logo: './assets/agents/grok.svg', mono: true },
38
- omp: { label: 'OMP', short: 'OMP', color: '#a96926', logo: './assets/agents/omp.svg' },
39
- pi: { label: 'Pi', short: 'Pi', color: '#4f8eb3', logo: './assets/agents/pi.svg' },
40
- antigravity: { label: 'Antigravity', short: 'Ag', color: '#0ea5e9', logo: './assets/agents/antigravity.png' },
41
- opencode: { label: 'OpenCode', short: 'Oc', color: '#2f6fd0', logo: './assets/agents/opencode.svg', mono: true },
42
- hermes: { label: 'Hermes', short: 'He', color: '#b4622d', logo: './assets/agents/hermes.svg', mono: true },
43
- };
44
- for (const badge of Object.values(AGENT_BADGES)) {
45
- badge.image = new Image();
46
- badge.image.src = badge.logo;
47
- }
48
- const PANTS = ['#34405a', '#2c2c34', '#4a3f35', '#3b4a3f'];
49
- const PANEL = ['#7d93c4', '#d98b6a', '#7fb08a', '#c9a24c'];
50
-
51
- const STATUS_COLOR = {
52
- thinking: '#8b6be6', tool: '#3b8ee0', typing: '#22a874', waiting: '#e09412',
53
- done: '#5f9a4c', error: '#de4b40', interrupted: '#9b9a94', idle: '#9b9a94',
54
- };
55
-
56
- const ACT_EMOJI = {
57
- coffee: '☕', water: '💧', fridge: '🍎', sink: '🫧', stool: '💬', couch: '💬', beanbag: '🎧',
58
- window: '☁️', read: '📖', whiteboard: '✏️', meeting: '💬', arcade: '🎮', pingpong: '🏓', plant: '🌱',
59
- vending: '🥤', foosball: '⚽', phone: '📞',
60
- };
61
-
62
- function hash(str) {
63
- let h = 2166136261;
64
- for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); }
65
- return h >>> 0;
66
- }
67
- const pick = (arr, h, salt) => arr[(Math.imul(h ^ Math.imul(salt, 2654435761), 2246822519) >>> 0) % arr.length];
68
- const rand = (a, b) => a + Math.random() * (b - a);
69
- const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
70
-
71
- function palette() {
72
- const dark = matchMedia('(prefers-color-scheme: dark)').matches;
73
- return dark ? {
74
- dark: true,
75
- wood1: '#4a3f35', wood2: '#453a31', woodLine: 'rgba(0,0,0,.22)',
76
- tileA: '#3d4044', tileB: '#35383c', carpet: '#33404d', carpetLine: 'rgba(255,255,255,.04)',
77
- wallL: '#4a4640', wallR: '#3f3b36', wallTop: '#5a554e', trim: '#2b2926',
78
- deskTop: '#8a6a4c', deskL: '#6e533b', deskR: '#5c4431',
79
- monitor: '#18181a', screenOff: '#26282c', chair: '#2d2d32',
80
- rug: '#47544f', rugEdge: '#3a4541', couch: '#5b6b8c', couchD: '#4a587a',
81
- counter: '#d9d4c9', counterL: '#a9a397', counterR: '#958f84', metal: '#8d939a',
82
- glass: 'rgba(160,200,230,.10)', glassEdge: 'rgba(190,220,240,.35)', mat: '#2f4a3a',
83
- text: '#ecebe6', tagBg: 'rgba(20,20,19,.78)', bubble: '#f4f3ee', bubbleInk: '#1c1c1a',
84
- plant: '#4f8a52', pot: '#9a5b3c', shadow: 'rgba(0,0,0,.28)', book: ['#c96442', '#3b8ee0', '#5f9a4c', '#c9a24c', '#8b6be6'],
85
- } : {
86
- dark: false,
87
- wood1: '#e6d3b6', wood2: '#dfcaab', woodLine: 'rgba(120,90,50,.12)',
88
- tileA: '#f4f4f1', tileB: '#e6e7e3', carpet: '#c9d3dc', carpetLine: 'rgba(0,0,0,.035)',
89
- wallL: '#f4efe6', wallR: '#e8e1d4', wallTop: '#fbf8f2', trim: '#cfc5b3',
90
- deskTop: '#c89f72', deskL: '#a8825a', deskR: '#946f4b',
91
- monitor: '#2a2a2e', screenOff: '#3a3d44', chair: '#4a4a52',
92
- rug: '#c4d4cb', rugEdge: '#a9bcb1', couch: '#7d93c4', couchD: '#6a80b0',
93
- counter: '#fbfaf7', counterL: '#d9d4c9', counterR: '#c9c3b6', metal: '#b5bcc4',
94
- glass: 'rgba(170,210,235,.16)', glassEdge: 'rgba(120,160,190,.55)', mat: '#bfd8c4',
95
- text: '#1c1c1a', tagBg: 'rgba(255,255,255,.9)', bubble: '#ffffff', bubbleInk: '#1c1c1a',
96
- plant: '#5a9b5d', pot: '#b86f4b', shadow: 'rgba(60,40,20,.18)', book: ['#c96442', '#3b8ee0', '#5f9a4c', '#c9a24c', '#8b6be6'],
97
- };
98
- }
99
-
100
- function skyColor(d = new Date()) {
101
- const h = d.getHours() + d.getMinutes() / 60;
102
- if (h >= 7 && h < 17) return { sky: '#a9d4f2', night: false };
103
- if (h >= 5 && h < 7) return { sky: '#f3c9a8', night: false };
104
- if (h >= 17 && h < 19) return { sky: '#f0a679', night: false };
105
- return { sky: '#1d2747', night: true };
106
- }
107
-
108
- // ---------------------------------------------------------------- A* grid
109
-
110
- class Grid {
111
- constructor(W, D, res = 4) {
112
- this.res = res;
113
- this.cw = Math.ceil(W * res);
114
- this.ch = Math.ceil(D * res);
115
- this.cost = new Uint8Array(this.cw * this.ch);
116
- }
117
- block(x, y, w, d, pad = 0.12) {
118
- const R = this.res;
119
- const i0 = clamp(Math.floor((x - pad) * R), 0, this.cw - 1);
120
- const i1 = clamp(Math.ceil((x + w + pad) * R) - 1, 0, this.cw - 1);
121
- const j0 = clamp(Math.floor((y - pad) * R), 0, this.ch - 1);
122
- const j1 = clamp(Math.ceil((y + d + pad) * R) - 1, 0, this.ch - 1);
123
- for (let j = j0; j <= j1; j++) for (let i = i0; i <= i1; i++) this.cost[j * this.cw + i] = 1;
124
- }
125
- blocked(x, y) {
126
- const i = Math.floor(x * this.res), j = Math.floor(y * this.res);
127
- if (i < 0 || j < 0 || i >= this.cw || j >= this.ch) return true;
128
- return !!this.cost[j * this.cw + i];
129
- }
130
- path(ax, ay, bx, by) {
131
- const R = this.res, cw = this.cw, ch = this.ch;
132
- const si = clamp(Math.floor(ax * R), 0, cw - 1), sj = clamp(Math.floor(ay * R), 0, ch - 1);
133
- const gi = clamp(Math.floor(bx * R), 0, cw - 1), gj = clamp(Math.floor(by * R), 0, ch - 1);
134
- const N = cw * ch;
135
- const g = new Float32Array(N).fill(Infinity);
136
- const came = new Int32Array(N).fill(-1);
137
- const closed = new Uint8Array(N);
138
- const heap = [];
139
- const push = (f, n) => {
140
- heap.push([f, n]);
141
- let k = heap.length - 1;
142
- while (k > 0) { const p = (k - 1) >> 1; if (heap[p][0] <= heap[k][0]) break; [heap[p], heap[k]] = [heap[k], heap[p]]; k = p; }
143
- };
144
- const pop = () => {
145
- const top = heap[0], last = heap.pop();
146
- if (heap.length) {
147
- heap[0] = last;
148
- let k = 0;
149
- for (;;) {
150
- const l = 2 * k + 1, r = l + 1;
151
- let m = k;
152
- if (l < heap.length && heap[l][0] < heap[m][0]) m = l;
153
- if (r < heap.length && heap[r][0] < heap[m][0]) m = r;
154
- if (m === k) break;
155
- [heap[m], heap[k]] = [heap[k], heap[m]]; k = m;
156
- }
157
- }
158
- return top;
159
- };
160
- const hfn = (i, j) => { const dx = Math.abs(i - gi), dy = Math.abs(j - gj); return Math.max(dx, dy) + 0.41 * Math.min(dx, dy); };
161
- // Blocked cells are expensive, not impossible, so a path always exists
162
- // (someone standing right against furniture can still step away).
163
- const stepCost = (n) => (this.cost[n] ? 25 : 1);
164
- const start = sj * cw + si, goal = gj * cw + gi;
165
- g[start] = 0;
166
- push(hfn(si, sj), start);
167
- const dirs = [[1, 0, 1], [-1, 0, 1], [0, 1, 1], [0, -1, 1], [1, 1, 1.41], [1, -1, 1.41], [-1, 1, 1.41], [-1, -1, 1.41]];
168
- while (heap.length) {
169
- const [, n] = pop();
170
- if (n === goal) break;
171
- if (closed[n]) continue;
172
- closed[n] = 1;
173
- const i = n % cw, j = (n / cw) | 0;
174
- for (const [di, dj, c] of dirs) {
175
- const ni = i + di, nj = j + dj;
176
- if (ni < 0 || nj < 0 || ni >= cw || nj >= ch) continue;
177
- if (di && dj && (this.cost[j * cw + ni] || this.cost[nj * cw + i])) continue;
178
- const nn = nj * cw + ni;
179
- const ng = g[n] + c * stepCost(nn);
180
- if (ng < g[nn]) { g[nn] = ng; came[nn] = n; push(ng + hfn(ni, nj), nn); }
181
- }
182
- }
183
- const cells = [];
184
- for (let n = goal; n !== -1 && n !== start; n = came[n]) cells.push(n);
185
- cells.reverse();
186
- const pts = [];
187
- let lastDir = null;
188
- for (let k = 0; k < cells.length; k++) {
189
- const n = cells[k];
190
- const dir = n - (k ? cells[k - 1] : start);
191
- const pt = { x: ((n % cw) + 0.5) / R, y: (((n / cw) | 0) + 0.5) / R };
192
- if (dir === lastDir && pts.length) pts[pts.length - 1] = pt;
193
- else pts.push(pt);
194
- lastDir = dir;
195
- }
196
- if (pts.length) pts[pts.length - 1] = { x: bx, y: by };
197
- else pts.push({ x: bx, y: by });
198
- return pts;
199
- }
200
- }
201
-
202
- // ---------------------------------------------------------------- office
203
-
204
- class Office {
205
- constructor(root, { onHover, label, onFrame, showAgent = true, compact = false } = {}) {
206
- this.root = root;
207
- this.onHover = onHover || (() => {});
208
- this.label = label || ((s) => s.project || s.agent);
209
- this.onFrame = onFrame;
210
- this.showAgent = showAgent;
211
- this.compact = compact;
212
- this.offDutyLabel = 'went home';
213
- this.canvas = document.createElement('canvas');
214
- this.canvas.className = 'office-canvas';
215
- root.appendChild(this.canvas);
216
- this.ctx = this.canvas.getContext('2d');
217
- this.people = new Map();
218
- this.deskOf = new Map();
219
- this.offDuty = 0;
220
- this.first = true;
221
- this.visible = true;
222
- this.hit = [];
223
- this.hoverKey = null;
224
- this.t = performance.now();
225
- this.pal = palette();
226
- this.plan = this.buildPlan(0);
227
- this.cat = { x: this.plan.W - 3, y: this.plan.D - 2, path: [], until: 0, face: 1 };
228
- this.theme = matchMedia('(prefers-color-scheme: dark)');
229
- this.onTheme = () => (this.pal = palette());
230
- this.theme.addEventListener('change', this.onTheme);
231
-
232
- this.ro = new ResizeObserver(() => this.resize());
233
- this.ro.observe(root);
234
- this.canvas.addEventListener('mousemove', (e) => this.pointer(e));
235
- this.canvas.addEventListener('click', (e) => this.pointer(e, true));
236
- this.canvas.addEventListener('mouseleave', () => { this.hoverKey = null; this.onHover(null); });
237
- this.resize();
238
- this.last = performance.now();
239
- // Resolves true once a few frames have been drawn with data in place,
240
- // false if the renderer gave up (3D load failure).
241
- this.ready = new Promise((ok) => (this.markReady = ok));
242
- this.framesShown = 0;
243
- const loop = (t) => { if (this.dead) return; this.frame(t); this.raf = requestAnimationFrame(loop); };
244
- this.raf = requestAnimationFrame(loop);
245
- }
246
-
247
- destroy() {
248
- this.dead = true;
249
- cancelAnimationFrame(this.raf);
250
- this.ro.disconnect();
251
- this.theme.removeEventListener('change', this.onTheme);
252
- this.canvas.remove();
253
- }
254
-
255
- // ------------------------------------------------------------ floor plan
256
-
257
- buildPlan(nWorking) {
258
- const pal = this.pal;
259
- const pods = Math.max(2, Math.ceil((nWorking + 2) / 4));
260
- const podCols = pods <= 4 ? 2 : 3;
261
- const podRows = Math.ceil(pods / podCols);
262
- const OW = 1.3 + (podCols - 1) * 4.2 + 3.9; // open office width
263
- const OD = podRows * 4.7 + 0.8; // open office depth (pods)
264
- const D = Math.max(OD + 3.6, 14.5);
265
- const RW = 7.2; // right column width
266
- const W = OW + RW;
267
- const MR = 5.2; // meeting room depth
268
- const PY = 9.9; // pantry / lounge boundary
269
- const grid = new Grid(W, D);
270
- const items = [];
271
- const pois = [];
272
- const desks = [];
273
- const add = (depth, draw) => items.push({ depth, draw });
274
- // Semantic list of what is where, for renderers that don't use the 2D
275
- // draw closures (the 3D view).
276
- const props = [];
277
- const prop = (t, o) => props.push({ t, ...o });
278
- const solid = (x, y, w, d, pad) => grid.block(x, y, w, d, pad);
279
- const poi = (x, y, act, face, opt = {}) => pois.push({ x, y, act, face, sit: 0, ...opt });
280
-
281
- // Long boxes are split into ~1 tile pieces so depth sorting against
282
- // people stays right along their length. `extra(x0, x1)` draws things on
283
- // the front face of each piece (books on a shelf, etc).
284
- const longBox = (x, y, z, w, d, h, top, left, right, opts = {}) => {
285
- const n = Math.max(1, Math.ceil(Math.max(w, d) / 0.9));
286
- for (let k = 0; k < n; k++) {
287
- const sx = w >= d ? x + (w / n) * k : x, sy = w >= d ? y : y + (d / n) * k;
288
- const sw = w >= d ? w / n : w, sd = w >= d ? d : d / n;
289
- add(sx + sw / 2 + sy + sd / 2 + (opts.bias || 0), () => {
290
- this.box(sx, sy, z, sw, sd, h, top, left, right);
291
- if (opts.extra) opts.extra(sx, sx + sw);
292
- });
293
- }
294
- };
295
-
296
- // ---- cubicle pods: 4 desks back to back with a divider
297
- for (let p = 0; p < pods; p++) {
298
- const c = p % podCols, r = Math.floor(p / podCols);
299
- const px = 1.3 + c * 4.2, py = 2.0 + r * 4.7;
300
- const panel = PANEL[p % PANEL.length];
301
- for (let k = 0; k < 2; k++) {
302
- const x = px + k * 1.5;
303
- desks.push({ x, y: py, side: 'n', seat: { x: x + 0.7, y: py - 0.45 } });
304
- desks.push({ x, y: py + 0.92, side: 's', seat: { x: x + 0.7, y: py + 0.92 + 1.25 } });
305
- solid(x, py, 1.4, 0.8, 0.08);
306
- solid(x, py + 0.92, 1.4, 0.8, 0.08);
307
- }
308
- longBox(px - 0.1, py + 0.82, 0, 3.15, 0.08, 1.15, this.shade(panel, 25), panel, this.shade(panel, -15), { bias: 0.05 });
309
- prop('panel', { x: px - 0.1, y: py + 0.82, w: 3.15, d: 0.08, h: 1.15, color: panel });
310
- prop('panel', { x: px - 0.18, y: py - 0.05, w: 0.08, d: 1.82, h: 1.15, color: panel });
311
- add(px - 0.1 + py + 0.86, () => this.box(px - 0.18, py - 0.05, 0, 0.08, 1.82, 1.15, this.shade(panel, 25), panel, this.shade(panel, -15)));
312
- solid(px - 0.18, py + 0.8, 3.25, 0.12, 0.05);
313
- }
314
-
315
- // ---- open office extras
316
- const rx = OW - 1.15;
317
- add(rx + 0.35 + 0.5, () => this.serverRack(rx, 0.2));
318
- prop('rack', { x: rx, y: 0.2 });
319
- prop('printer', { x: OW - 2.3, y: 0.25 });
320
- solid(rx, 0.2, 0.7, 0.6);
321
- add(OW - 2.3 + 0.3 + 0.5, () => this.printer(OW - 2.3, 0.25));
322
- solid(OW - 2.3, 0.25, 0.75, 0.55);
323
- for (const [x, y] of [[0.45, 0.45], [0.45, OD - 0.2]]) { add(x + y, () => this.plant(x, y)); prop('plant', { x, y }); solid(x - 0.2, y - 0.2, 0.4, 0.4); }
324
- const windows = [];
325
- for (let x = 1.0; x < OW - 2.6; x += 2.4) {
326
- windows.push(x);
327
- poi(x + 0.75, 0.75, 'window', 'back');
328
- }
329
- poi(0.95, OD - 0.2, 'plant', 'back');
330
-
331
- // ---- ping-pong corner
332
- const tx = 2.4, ty = OD + 1.0;
333
- add(tx + 1.3 + ty + 0.7, () => this.pingpong(tx, ty));
334
- prop('pingpong', { x: tx, y: ty });
335
- solid(tx, ty, 2.6, 1.4, 0.05);
336
- poi(tx - 0.55, ty + 0.7, 'pingpong', 'side', { pair: 'pp' });
337
- poi(tx + 3.15, ty + 0.7, 'pingpong', 'side', { pair: 'pp' });
338
- const pp = { x0: tx - 0.2, x1: tx + 2.8, y: ty + 0.7 };
339
-
340
- // ---- front corner: vending machines, foosball, phone booth
341
- const fy = OD + 3.7;
342
- if (D - fy >= 3.4) {
343
- add(0.5 + fy + 0.4, () => this.vending(0.12, fy, '#c0392b'));
344
- prop('vending', { x: 0.12, y: fy, color: '#c0392b' });
345
- prop('vending', { x: 0.12, y: fy + 0.8, color: '#2f6fb0' });
346
- add(0.5 + fy + 1.2, () => this.vending(0.12, fy + 0.8, '#2f6fb0'));
347
- solid(0.1, fy, 0.75, 1.55);
348
- poi(1.3, fy + 0.4, 'vending', 'back');
349
- poi(1.3, fy + 1.2, 'vending', 'back');
350
- }
351
- if (D - fy >= 2.2) {
352
- const fx = 3.0, fby = fy + 0.4;
353
- add(fx + 0.8 + fby + 0.4, () => this.foosball(fx, fby));
354
- prop('foosball', { x: fx, y: fby });
355
- solid(fx, fby, 1.6, 0.8, 0.05);
356
- poi(fx - 0.45, fby + 0.4, 'foosball', 'side', { pair: 'fb' });
357
- poi(fx + 2.05, fby + 0.4, 'foosball', 'side', { pair: 'fb' });
358
- const bx = OW - 2.0, by = fy;
359
- this.booth(add, bx, by);
360
- prop('booth', { x: bx, y: by });
361
- solid(bx - 0.05, by - 0.05, 1.4, 0.1, 0.03);
362
- solid(bx - 0.05, by - 0.05, 0.1, 1.4, 0.03);
363
- solid(bx + 1.25, by - 0.05, 0.1, 1.4, 0.03);
364
- solid(bx + 0.85, by + 1.25, 0.5, 0.1, 0.03);
365
- poi(bx + 0.65, by + 0.6, 'phone', 'front', { sit: 0.45 });
366
- add(OW - 0.45 + D - 0.6, () => this.plant(OW - 0.45, D - 0.6));
367
- prop('plant', { x: OW - 0.45, y: D - 0.6 });
368
- }
369
-
370
- // ---- meeting room behind glass, door at y 3.6–4.6
371
- const mx = OW;
372
- for (const [y, d] of [[0, 1], [1, 1], [2, 1], [3, 0.6], [4.6, MR - 4.6]]) this.glassX(add, mx, y, d);
373
- for (let x = mx; x < W - 0.01; x += 1) this.glassY(add, x, MR, Math.min(1, W - x));
374
- prop('glass', { x: mx, y: 0, w: 0, d: 3.6 });
375
- prop('glass', { x: mx, y: 4.6, w: 0, d: MR - 4.6 });
376
- prop('glass', { x: mx, y: MR, w: RW, d: 0 });
377
- solid(mx - 0.05, 0, 0.1, 3.6, 0.05);
378
- solid(mx - 0.05, 4.6, 0.1, MR - 4.6, 0.05);
379
- solid(mx, MR - 0.05, RW, 0.1, 0.05);
380
- const mtx = mx + 1.6, mty = 1.7;
381
- add(mtx + 1.5 + mty + 0.7, () => this.meetingTable(mtx, mty));
382
- prop('meetingTable', { x: mtx, y: mty, w: 3.0, d: 1.4 });
383
- solid(mtx, mty, 3.0, 1.4);
384
- for (let k = 0; k < 3; k++) {
385
- const cx = mtx + 0.5 + k * 1.0;
386
- add(cx + mty - 0.45, () => this.stool(cx, mty - 0.4, 0.4, '#555c66'));
387
- add(cx + mty + 1.78, () => this.stool(cx, mty + 1.8, 0.4, '#555c66'));
388
- poi(cx, mty - 0.4, 'meeting', 'front', { sit: 0.42 });
389
- prop('chair', { x: cx, y: mty - 0.4, face: 'front' });
390
- prop('chair', { x: cx, y: mty + 1.8, face: 'back' });
391
- poi(cx, mty + 1.8, 'meeting', 'back', { sit: 0.42 });
392
- }
393
- poi(mx + 3.6, 0.65, 'whiteboard', 'back');
394
- add(W - 0.45 + 0.45, () => this.plant(W - 0.45, 0.45));
395
- prop('plant', { x: W - 0.45, y: 0.45 });
396
- solid(W - 0.65, 0.25, 0.4, 0.4);
397
-
398
- // ---- pantry
399
- const cy = MR + 0.3;
400
- longBox(mx + 1.0, cy, 0, RW - 2.3, 0.7, 0.9, pal.counter, pal.counterL, pal.counterR, { bias: -0.5 });
401
- solid(mx + 1.0, cy, RW - 2.3, 0.7);
402
- prop('counter', { x: mx + 1.0, y: cy, w: RW - 2.3, d: 0.7 });
403
- prop('coffee', { x: mx + 2.4, y: cy + 0.12 });
404
- prop('sink', { x: mx + 3.9, y: cy + 0.12 });
405
- prop('fridge', { x: W - 1.15, y: cy });
406
- prop('cooler', { x: mx + 0.35, y: cy + 0.05 });
407
- add(mx + 2.65 + cy + 0.35, () => this.coffeeMachine(mx + 2.4, cy + 0.12));
408
- add(mx + 4.15 + cy + 0.35, () => this.sink(mx + 3.9, cy + 0.12));
409
- add(W - 0.75 + cy + 0.4, () => this.fridge(W - 1.15, cy));
410
- solid(W - 1.15, cy, 0.85, 0.75);
411
- add(mx + 0.55 + cy + 0.25, () => this.waterCooler(mx + 0.35, cy + 0.05));
412
- solid(mx + 0.3, cy, 0.5, 0.5);
413
- poi(mx + 2.65, cy + 1.2, 'coffee', 'back');
414
- poi(mx + 4.15, cy + 1.2, 'sink', 'back');
415
- poi(W - 0.75, cy + 1.3, 'fridge', 'back');
416
- poi(mx + 0.6, cy + 0.95, 'water', 'back');
417
- const rtx = mx + 3.6, rty = MR + 2.9;
418
- add(rtx + rty, () => this.roundTable(rtx, rty));
419
- prop('roundTable', { x: rtx, y: rty });
420
- solid(rtx - 0.45, rty - 0.45, 0.9, 0.9);
421
- for (const [sx, sy, face] of [[rtx - 1.0, rty, 'front'], [rtx + 1.0, rty, 'front'], [rtx, rty + 1.0, 'back'], [rtx, rty - 0.95, 'front']]) {
422
- add(sx + sy - 0.02, () => this.stool(sx, sy, 0.45, '#c9a24c'));
423
- poi(sx, sy, 'stool', face, { sit: 0.47 });
424
- prop('barStool', { x: sx, y: sy });
425
- }
426
-
427
- // ---- lounge
428
- const lx = mx + 0.6, ly = PY + 0.4;
429
- const shelfX = mx + 3.4, shelfW = RW - 3.6;
430
- longBox(shelfX, PY - 0.25, 0, shelfW, 0.45, 1.45, this.shade(pal.deskTop, -5), pal.deskL, pal.deskR, {
431
- extra: (x0, x1) => this.books(x0, x1, PY + 0.2),
432
- });
433
- solid(shelfX, PY - 0.25, shelfW, 0.45);
434
- prop('shelf', { x: shelfX, y: PY - 0.25, w: shelfW, d: 0.45 });
435
- poi(mx + 4.6, PY + 0.75, 'read', 'back');
436
- poi(mx + 5.8, PY + 0.75, 'read', 'back');
437
- add(lx + ly + 0.6, () => this.couch(lx, ly + 0.6));
438
- prop('couch', { x: lx, y: ly + 0.6, w: 2.8, d: 0.8 });
439
- prop('coffeeTable', { x: lx + 0.6, y: ly + 2.0, w: 1.6, d: 0.7 });
440
- prop('beanbag', { x: lx + 0.4, y: ly + 3.6, color: '#c96442' });
441
- prop('beanbag', { x: lx + 2.6, y: ly + 3.6, color: '#7d93c4' });
442
- solid(lx, ly + 0.6, 2.8, 0.8);
443
- for (let k = 0; k < 3; k++) poi(lx + 0.5 + k * 0.9, ly + 1.2, 'couch', 'front', { sit: 0.36 });
444
- add(lx + 1.4 + ly + 2.35, () => this.coffeeTable(lx + 0.6, ly + 2.0));
445
- solid(lx + 0.6, ly + 2.0, 1.6, 0.7);
446
- add(lx + 0.4 + ly + 3.6, () => this.beanbag(lx + 0.4, ly + 3.6, '#c96442'));
447
- add(lx + 2.6 + ly + 3.6, () => this.beanbag(lx + 2.6, ly + 3.6, '#7d93c4'));
448
- poi(lx + 0.4, ly + 3.6, 'beanbag', 'front', { sit: 0.18 });
449
- poi(lx + 2.6, ly + 3.6, 'beanbag', 'front', { sit: 0.18 });
450
- const ax = W - 1.2, ay = ly + 1.0;
451
- add(ax + 0.35 + ay + 0.35, () => this.arcade(ax, ay));
452
- prop('arcade', { x: ax, y: ay });
453
- prop('plant', { x: W - 0.5, y: D - 0.5 });
454
- prop('lamp', { x: lx - 0.2, y: D - 0.6 });
455
- prop('rug', { x: OW + 0.3, y: PY + 0.6, w: 4.0, d: D - PY - 1.0 });
456
- solid(ax, ay, 0.7, 0.7);
457
- poi(ax + 0.35, ay + 1.15, 'arcade', 'back');
458
- add(W - 0.5 + D - 0.5, () => this.plant(W - 0.5, D - 0.5));
459
- add(lx - 0.2 + D - 0.6, () => this.floorLamp(lx - 0.2, D - 0.6));
460
-
461
- const posters = [
462
- { y: 2.2, color: '#c96442', text: 'SHIP IT' },
463
- { y: 4.6, color: '#10a37f', text: 'TESTS ✓' },
464
- ];
465
- return { W, D, OW, OD, MR, PY, RW, grid, items, props, pois, desks, windows, posters, pp, door: { x: 0.4, y: D - 1.2 } };
466
- }
467
-
468
- glassX(add, x, y, d) {
469
- add(x + y + d / 2 + 0.02, () => {
470
- const P = (a, b, c) => this.iso(a, b, c);
471
- this.poly([P(x, y, 0), P(x, y + d, 0), P(x, y + d, 0.22), P(x, y, 0.22)], this.pal.trim);
472
- this.poly([P(x, y, 0.22), P(x, y + d, 0.22), P(x, y + d, 2.1), P(x, y, 2.1)], this.pal.glass, this.pal.glassEdge);
473
- });
474
- }
475
-
476
- glassY(add, x, y, w) {
477
- add(x + w / 2 + y + 0.02, () => {
478
- const P = (a, b, c) => this.iso(a, b, c);
479
- this.poly([P(x, y, 0), P(x + w, y, 0), P(x + w, y, 0.22), P(x, y, 0.22)], this.pal.trim);
480
- this.poly([P(x, y, 0.22), P(x + w, y, 0.22), P(x + w, y, 2.1), P(x, y, 2.1)], this.pal.glass, this.pal.glassEdge);
481
- });
482
- }
483
-
484
- // ------------------------------------------------------------ sessions → people
485
-
486
- update(sessions) {
487
- const working = sessions.filter((s) => WORKING.has(s.status));
488
- const off = sessions
489
- .filter((s) => !WORKING.has(s.status))
490
- .sort((a, b) => (b.lastEventAt || 0) - (a.lastEventAt || 0));
491
- const wanderers = off.slice(0, MAX_WANDERERS);
492
- this.offDuty = off.length - wanderers.length;
493
-
494
- const cap = this.plan.desks.length;
495
- // Scene packs have a fixed floor plan; the built-in office grows with the team.
496
- if (!this.plan.fixed && (working.length > cap - 1 || (cap > 8 && working.length < cap * 0.35))) {
497
- this.plan = this.buildPlan(working.length);
498
- this.deskOf.clear();
499
- for (const p of this.people.values()) { p.poi = null; p.napDesk = null; p.desk = null; p.until = 0; }
500
- }
501
-
502
- const workKeys = new Set(working.map((s) => s.key));
503
- for (const k of [...this.deskOf.keys()]) if (!workKeys.has(k)) this.deskOf.delete(k);
504
- const used = new Set(this.deskOf.values());
505
- // Fill the desks that face us first so more faces and screens show.
506
- const desks = this.plan.desks;
507
- const order = desks.map((d, i) => i).sort((a, b) => (desks[a].side === 's') - (desks[b].side === 's') || a - b);
508
- for (const s of [...working].sort((a, b) => (a.startedAt || 0) - (b.startedAt || 0))) {
509
- if (this.deskOf.has(s.key)) continue;
510
- const i = order.find((n) => !used.has(n));
511
- if (i == null) continue;
512
- this.deskOf.set(s.key, i);
513
- used.add(i);
514
- }
515
- const takenDesks = new Set([...this.deskOf.values()].map((i) => desks[i]));
516
-
517
- const shown = new Set();
518
- for (const s of [...working, ...wanderers]) {
519
- const isWorker = this.deskOf.has(s.key);
520
- if (!isWorker && WORKING.has(s.status)) continue; // no desk left
521
- shown.add(s.key);
522
- let p = this.people.get(s.key);
523
- if (!p) { p = this.newPerson(s); this.people.set(s.key, p); }
524
- p.s = s;
525
- p.leaving = false;
526
- if (isWorker) {
527
- this.release(p);
528
- const desk = desks[this.deskOf.get(s.key)];
529
- if (p.desk !== desk || p.mode !== 'work') { p.desk = desk; this.goTo(p, desk.seat); }
530
- p.mode = 'work';
531
- } else {
532
- if (p.mode !== 'wander') { p.mode = 'wander'; p.desk = null; p.poi = null; p.until = 0; }
533
- // Someone started working at the desk this one was napping on.
534
- if (p.napDesk && takenDesks.has(p.napDesk)) { this.release(p); p.path = []; }
535
- }
536
- }
537
- for (const [k, p] of this.people) {
538
- if (!shown.has(k) && !p.leaving) {
539
- p.leaving = true;
540
- p.mode = 'leave';
541
- this.release(p);
542
- p.desk = null;
543
- this.goTo(p, this.plan.door);
544
- }
545
- }
546
- this.first = false;
547
- }
548
-
549
- release(p) {
550
- if (p.poi) { p.poi.taken = null; p.poi = null; }
551
- if (p.napDesk) { p.napDesk.napper = null; p.napDesk = null; }
552
- }
553
-
554
- newPerson(s) {
555
- const h = hash(s.key);
556
- const door = this.plan.door;
557
- return {
558
- key: s.key, h, s,
559
- skin: pick(SKIN, h, 3), hair: pick(HAIR, h, 7), pants: pick(PANTS, h, 13),
560
- shirt: pick(SHIRT[s.agent] || SHIRT.claude, h, 11),
561
- hairStyle: h % 4,
562
- phase: (h % 1000) / 1000 * Math.PI * 2,
563
- x: door.x, y: door.y,
564
- path: [], walkFace: 'front', until: 0, mode: null,
565
- // On the first snapshot everyone is placed directly, no stampede.
566
- placeOnArrive: this.first,
567
- };
568
- }
569
-
570
- goTo(p, target) {
571
- if (p.placeOnArrive) {
572
- p.x = target.x; p.y = target.y; p.path = []; p.placeOnArrive = false;
573
- return;
574
- }
575
- p.path = this.plan.grid.path(p.x, p.y, target.x, target.y);
576
- }
577
-
578
- wander(p, now) {
579
- if (p.path.length) return;
580
- if (p.poi && now < p.until) return;
581
- this.release(p);
582
- const plan = this.plan;
583
- const workingDesks = new Set([...this.deskOf.values()].map((i) => plan.desks[i]));
584
- const sleepy = p.s.status === 'idle';
585
- const weighted = [];
586
- for (const q of plan.pois) {
587
- if (q.taken) continue;
588
- let w = 1;
589
- if (q.pair) w = plan.pois.some((o) => o.pair === q.pair && o !== q && o.taken) ? 7 : 1.2;
590
- if (q.act === 'couch' || q.act === 'beanbag' || q.sleepy) w = sleepy ? 3 : 1.4;
591
- weighted.push([q, w]);
592
- }
593
- for (const d of plan.noNap ? [] : plan.desks) {
594
- if (workingDesks.has(d) || d.napper) continue;
595
- weighted.push([{ x: d.seat.x, y: d.seat.y, act: 'nap', face: d.side === 'n' ? 'front' : 'back', sit: 0.34, desk: d }, sleepy ? 3 : 0.25]);
596
- }
597
- if (!weighted.length) return;
598
- let r = Math.random() * weighted.reduce((a, [, w]) => a + w, 0);
599
- let choice = weighted[0][0];
600
- for (const [q, w] of weighted) { r -= w; if (r <= 0) { choice = q; break; } }
601
- if (choice.desk) { choice.desk.napper = p.key; p.napDesk = choice.desk; }
602
- choice.taken = p.key;
603
- p.poi = choice;
604
- p.until = Infinity; // set on arrival
605
- p.stay = (choice.act === 'nap' ? rand(25, 60) : choice.act === 'pingpong' ? rand(15, 30) : rand(8, 20)) * 1000;
606
- this.goTo(p, choice);
607
- }
608
-
609
- setVisible(v) { this.visible = v; if (v) this.resize(); }
610
-
611
- // Room on the right covered by an overlay (the agent panel). The canvas
612
- // keeps its size; the view eases over so the room stays centred in what
613
- // is still visible, instead of the canvas resizing under the slide.
614
- setInset(px, animate = true) {
615
- const now = performance.now();
616
- this.insetFrom = animate ? this.inset(now) : px;
617
- this.insetTo = px;
618
- this.insetT0 = animate ? now : -Infinity;
619
- }
620
- inset(now = performance.now()) {
621
- if (this.insetTo == null) return 0;
622
- const k = Math.min(1, (now - this.insetT0) / 340);
623
- const e = 1 - Math.pow(1 - k, 3); // ease out, like the panel's slide
624
- return this.insetFrom + (this.insetTo - this.insetFrom) * e;
625
- }
626
- insetMoving(now = performance.now()) { return this.insetTo != null && now - this.insetT0 < 360; }
627
-
628
- // ------------------------------------------------------------ camera / input
629
-
630
- resize() {
631
- const r = this.root.getBoundingClientRect();
632
- const dpr = Math.min(2, window.devicePixelRatio || 1);
633
- this.cw = Math.max(this.compact ? 1 : 200, r.width);
634
- this.ch = Math.max(this.compact ? 1 : 240, r.height);
635
- this.canvas.width = Math.round(this.cw * dpr);
636
- this.canvas.height = Math.round(this.ch * dpr);
637
- this.canvas.style.width = this.cw + 'px';
638
- this.canvas.style.height = this.ch + 'px';
639
- this.dpr = dpr;
640
- }
641
-
642
- fitCamera() {
643
- const { W, D } = this.plan;
644
- const left = this.iso(0, D, 0).x, right = this.iso(W, 0, 0).x;
645
- const top = this.iso(0, 0, WALL_H).y - 20, bottom = this.iso(W, D, 0).y + 10;
646
- const inset = this.inset();
647
- const vw = Math.max(120, this.cw - inset);
648
- const zoom = Math.min(vw / (right - left + 30), this.ch / (bottom - top + 30), 2);
649
- const want = { zoom, ox: vw / 2 - ((left + right) / 2) * zoom, oy: this.ch / 2 - ((top + bottom) / 2) * zoom };
650
- // While the inset eases, follow it exactly; otherwise settle gently.
651
- if (!this.cam || this.insetMoving()) this.cam = want;
652
- else for (const k of ['zoom', 'ox', 'oy']) this.cam[k] += (want[k] - this.cam[k]) * 0.1;
653
- }
654
-
655
- pointer(e, click) {
656
- const r = this.canvas.getBoundingClientRect();
657
- const mx = e.clientX - r.left, my = e.clientY - r.top;
658
- let found = null;
659
- for (let i = this.hit.length - 1; i >= 0; i--) {
660
- const h = this.hit[i];
661
- if (mx >= h.x0 && mx <= h.x1 && my >= h.y0 && my <= h.y1) { found = h; break; }
662
- }
663
- const key = found ? found.key : null;
664
- this.canvas.style.cursor = key ? 'pointer' : 'default';
665
- if (key !== this.hoverKey || click) {
666
- this.hoverKey = key;
667
- const p = key && this.people.get(key);
668
- this.onHover(p ? p.s : null, { x: e.clientX, y: e.clientY }, !!click);
669
- }
670
- }
671
-
672
- iso(x, y, z = 0) {
673
- return { x: (x - y) * TW / 2, y: (x + y) * TH / 2 - z * TH };
674
- }
675
-
676
- // ------------------------------------------------------------ primitives
677
-
678
- poly(pts, fill, stroke) {
679
- const c = this.ctx;
680
- c.beginPath();
681
- pts.forEach((p, i) => (i ? c.lineTo(p.x, p.y) : c.moveTo(p.x, p.y)));
682
- c.closePath();
683
- if (fill) { c.fillStyle = fill; c.fill(); }
684
- if (stroke) { c.strokeStyle = stroke; c.lineWidth = 1; c.stroke(); }
685
- }
686
-
687
- box(x, y, z, w, d, h, top, left, right) {
688
- const P = (a, b, cc) => this.iso(a, b, cc);
689
- this.poly([P(x, y + d, z), P(x + w, y + d, z), P(x + w, y + d, z + h), P(x, y + d, z + h)], left);
690
- this.poly([P(x + w, y, z), P(x + w, y + d, z), P(x + w, y + d, z + h), P(x + w, y, z + h)], right);
691
- this.poly([P(x, y, z + h), P(x + w, y, z + h), P(x + w, y + d, z + h), P(x, y + d, z + h)], top);
692
- }
693
-
694
- shade(color, amt) {
695
- if (color[0] !== '#') return color;
696
- const n = parseInt(color.slice(1), 16);
697
- const f = (v) => clamp(Math.round(v + amt), 0, 255);
698
- return `rgb(${f(n >> 16)},${f((n >> 8) & 255)},${f(n & 255)})`;
699
- }
700
-
701
- roundRect(x, y, w, h, r, fill, stroke) {
702
- const c = this.ctx;
703
- c.beginPath();
704
- c.moveTo(x + r, y);
705
- c.arcTo(x + w, y, x + w, y + h, r);
706
- c.arcTo(x + w, y + h, x, y + h, r);
707
- c.arcTo(x, y + h, x, y, r);
708
- c.arcTo(x, y, x + w, y, r);
709
- c.closePath();
710
- if (fill) { c.fillStyle = fill; c.fill(); }
711
- if (stroke) { c.strokeStyle = stroke; c.lineWidth = 1.5; c.stroke(); }
712
- }
713
-
714
- // Draw flat things on a wall: 'y0' = back wall (runs along x),
715
- // 'x0' = left wall (runs along y).
716
- onWall(dir, a, z, fn) {
717
- const c = this.ctx;
718
- const o = dir === 'y0' ? this.iso(a, 0, z) : this.iso(0, a, z);
719
- c.save();
720
- c.translate(o.x, o.y);
721
- c.transform(1, dir === 'y0' ? 0.5 : -0.5, 0, 1, 0, 0);
722
- fn(c);
723
- c.restore();
724
- }
725
-
726
- // ------------------------------------------------------------ room shell
727
-
728
- drawRoom() {
729
- const { W, D, OW, MR, PY, pp } = this.plan;
730
- const pal = this.pal;
731
- const P = (x, y, z) => this.iso(x, y, z);
732
- const t = this.t / 1000;
733
-
734
- // floors: wood office, carpet meeting room, tiled pantry, wood lounge
735
- for (let x = 0; x < Math.ceil(OW); x++) {
736
- for (let y = 0; y < Math.ceil(D); y++) {
737
- const x1 = Math.min(OW, x + 1), y1 = Math.min(D, y + 1);
738
- this.poly([P(x, y), P(x1, y), P(x1, y1), P(x, y1)], (x + y) % 2 ? pal.wood1 : pal.wood2);
739
- }
740
- }
741
- for (let y = 0.5; y < D; y += 0.5) this.poly([P(0, y), P(OW, y), P(OW, y + 0.012), P(0, y + 0.012)], pal.woodLine);
742
- this.poly([P(OW, 0), P(W, 0), P(W, MR), P(OW, MR)], pal.carpet);
743
- for (let x = OW + 0.5; x < W; x += 0.5) this.poly([P(x, 0), P(x + 0.015, 0), P(x + 0.015, MR), P(x, MR)], pal.carpetLine);
744
- for (let i = 0; i < Math.ceil((W - OW) * 2); i++) {
745
- for (let j = 0; j < Math.ceil((PY - MR) * 2); j++) {
746
- const x = OW + i / 2, y = MR + j / 2;
747
- const x1 = Math.min(W, x + 0.5), y1 = Math.min(PY, y + 0.5);
748
- this.poly([P(x, y), P(x1, y), P(x1, y1), P(x, y1)], (i + j) % 2 ? pal.tileA : pal.tileB);
749
- }
750
- }
751
- for (let x = Math.floor(OW); x < W; x++) {
752
- for (let y = PY; y < D; y++) {
753
- const x0 = Math.max(OW, x), x1 = Math.min(W, x + 1), y1 = Math.min(D, y + 1);
754
- this.poly([P(x0, y), P(x1, y), P(x1, y1), P(x0, y1)], (x + Math.floor(y)) % 2 ? pal.wood1 : pal.wood2);
755
- }
756
- }
757
- this.poly([P(OW + 0.3, PY + 0.6), P(OW + 4.3, PY + 0.6), P(OW + 4.3, D - 0.4), P(OW + 0.3, D - 0.4)], pal.rug);
758
- this.poly([P(OW + 0.5, PY + 0.8), P(OW + 4.1, PY + 0.8), P(OW + 4.1, D - 0.6), P(OW + 0.5, D - 0.6)], null, pal.rugEdge);
759
- this.poly([P(pp.x0 - 0.6, pp.y - 1.4), P(pp.x1 + 0.6, pp.y - 1.4), P(pp.x1 + 0.6, pp.y + 1.3), P(pp.x0 - 0.6, pp.y + 1.3)], pal.mat);
760
-
761
- // walls
762
- this.poly([P(0, 0, 0), P(W, 0, 0), P(W, 0, WALL_H), P(0, 0, WALL_H)], pal.wallL);
763
- this.poly([P(0, 0, 0), P(0, D, 0), P(0, D, WALL_H), P(0, 0, WALL_H)], pal.wallR);
764
- this.poly([P(0, 0, WALL_H), P(W, 0, WALL_H), P(W, -0.15, WALL_H), P(-0.15, -0.15, WALL_H), P(-0.15, D, WALL_H), P(0, D, WALL_H)], pal.wallTop);
765
- this.poly([P(0, 0, 0), P(W, 0, 0), P(W, 0, 0.12), P(0, 0, 0.12)], pal.trim);
766
- this.poly([P(0, 0, 0), P(0, D, 0), P(0, D, 0.12), P(0, 0, 0.12)], pal.trim);
767
-
768
- // windows with the real sky outside
769
- const sky = skyColor();
770
- for (const x of this.plan.windows) {
771
- this.poly([P(x, 0, 0.85), P(x + 1.5, 0, 0.85), P(x + 1.5, 0, 2.0), P(x, 0, 2.0)], sky.sky);
772
- if (sky.night) {
773
- this.onWall('y0', x + 0.3, 1.75, (c) => { c.fillStyle = '#f4f1d0'; c.beginPath(); c.arc(0, 0, 3, 0, Math.PI * 2); c.fill(); });
774
- this.onWall('y0', x + 1.1, 1.55, (c) => { c.fillStyle = 'rgba(255,255,255,.7)'; c.fillRect(0, 0, 1.5, 1.5); });
775
- } else {
776
- const cx = x + 0.25 + ((t * 0.02 + x * 0.37) % 1) * 1.0;
777
- this.onWall('y0', cx, 1.65, (c) => {
778
- c.fillStyle = 'rgba(255,255,255,.85)';
779
- c.beginPath(); c.ellipse(0, 0, 7, 3, 0, 0, Math.PI * 2); c.ellipse(5, -2, 5, 3, 0, 0, Math.PI * 2); c.fill();
780
- });
781
- }
782
- this.poly([P(x + 0.74, 0, 0.85), P(x + 0.76, 0, 0.85), P(x + 0.76, 0, 2.0), P(x + 0.74, 0, 2.0)], pal.trim);
783
- this.poly([P(x, 0, 0.85), P(x + 1.5, 0, 0.85), P(x + 1.5, 0, 0.9), P(x, 0, 0.9)], pal.trim);
784
- this.poly([P(x, 0.05), P(x + 1.5, 0.05), P(x + 1.95, 1.2), P(x + 0.45, 1.2)], sky.night ? 'rgba(120,140,200,.06)' : 'rgba(255,250,230,.16)');
785
- }
786
- // meeting room whiteboard + sign
787
- const wx = OW + 2.4;
788
- this.poly([P(wx, 0, 0.8), P(wx + 2.6, 0, 0.8), P(wx + 2.6, 0, 1.95), P(wx, 0, 1.95)], '#fdfdfb', pal.trim);
789
- this.onWall('y0', wx + 0.25, 1.75, (c) => {
790
- c.lineWidth = 1.4;
791
- c.strokeStyle = '#3b8ee0';
792
- c.beginPath(); c.moveTo(0, 0); c.lineTo(22, 0); c.moveTo(0, 7); c.lineTo(36, 7); c.stroke();
793
- c.strokeStyle = '#c96442';
794
- c.strokeRect(46, -2, 20, 12); c.strokeRect(80, 6, 20, 12);
795
- c.beginPath(); c.moveTo(66, 4); c.lineTo(80, 12); c.stroke();
796
- c.strokeStyle = '#5f9a4c';
797
- c.beginPath(); c.moveTo(0, 22); for (let i = 0; i < 8; i++) c.lineTo(i * 8, 22 - ((i * 37) % 13)); c.stroke();
798
- });
799
- this.onWall('y0', OW + 0.5, 2.2, (c) => {
800
- c.font = '700 9px ui-sans-serif, system-ui, sans-serif'; c.fillStyle = this.shade(pal.trim, -60); c.fillText('MEETING', 0, 0);
801
- });
802
- // left wall: posters, clock, door
803
- for (const ps of this.plan.posters) {
804
- this.poly([P(0, ps.y, 0.95), P(0, ps.y + 0.9, 0.95), P(0, ps.y + 0.9, 1.95), P(0, ps.y, 1.95)], ps.color);
805
- this.onWall('x0', ps.y + 0.45, 1.4, (c) => {
806
- c.font = '800 8px ui-sans-serif, system-ui, sans-serif'; c.fillStyle = '#fff'; c.textAlign = 'center'; c.fillText(ps.text, 0, 0);
807
- });
808
- }
809
- this.onWall('x0', 1.0, 1.85, (c) => {
810
- c.beginPath(); c.arc(0, 0, 11, 0, Math.PI * 2); c.fillStyle = pal.bubble; c.fill();
811
- c.lineWidth = 2; c.strokeStyle = pal.trim; c.stroke();
812
- const d = new Date();
813
- const hand = (ang, len, w) => { c.beginPath(); c.moveTo(0, 0); c.lineTo(Math.sin(ang) * len, -Math.cos(ang) * len); c.lineWidth = w; c.strokeStyle = pal.bubbleInk; c.stroke(); };
814
- hand(((d.getHours() % 12) + d.getMinutes() / 60) / 12 * Math.PI * 2, 5.5, 2);
815
- hand((d.getMinutes() / 60) * Math.PI * 2, 8, 1.4);
816
- });
817
- const dy = this.plan.door.y;
818
- this.poly([P(0, dy - 0.6, 0), P(0, dy + 0.6, 0), P(0, dy + 0.6, 1.8), P(0, dy - 0.6, 1.8)], pal.trim);
819
- this.poly([P(0, dy - 0.52, 0), P(0, dy + 0.52, 0), P(0, dy + 0.52, 1.72), P(0, dy - 0.52, 1.72)], this.shade(pal.deskL, -10));
820
- this.onWall('x0', dy - 0.25, 0.85, (c) => { c.fillStyle = '#d8c48a'; c.beginPath(); c.arc(0, 0, 1.8, 0, Math.PI * 2); c.fill(); });
821
- this.poly([P(0.05, dy - 0.6), P(0.9, dy - 0.6), P(0.9, dy + 0.6), P(0.05, dy + 0.6)], pal.dark ? '#5a3b2e' : '#b9654a');
822
- }
823
-
824
- // ------------------------------------------------------------ furniture
825
-
826
- plant(x, y) {
827
- const pal = this.pal;
828
- this.box(x - 0.18, y - 0.18, 0, 0.36, 0.36, 0.3, this.shade(pal.pot, 10), pal.pot, this.shade(pal.pot, -15));
829
- const c = this.ctx;
830
- const b = this.iso(x, y, 0.3);
831
- const sway = Math.sin(this.t / 1400 + x) * 0.05;
832
- c.fillStyle = pal.plant;
833
- for (let i = 0; i < 6; i++) {
834
- const a = (i / 6) * Math.PI * 2;
835
- c.beginPath();
836
- c.ellipse(b.x + Math.cos(a) * 6, b.y - 13 + Math.sin(a) * 3, 5.5, 13, a / 3 + sway, 0, Math.PI * 2);
837
- c.fill();
838
- }
839
- c.fillStyle = this.shade(pal.plant, 22);
840
- c.beginPath(); c.ellipse(b.x, b.y - 20, 5, 10, sway, 0, Math.PI * 2); c.fill();
841
- }
842
-
843
- serverRack(x, y) {
844
- this.box(x, y, 0, 0.7, 0.6, 1.9, '#3a3d44', '#24262b', '#1b1d21');
845
- const c = this.ctx;
846
- const busy = this.deskOf.size;
847
- for (let r = 0; r < 7; r++) {
848
- for (let k = 0; k < 4; k++) {
849
- const on = Math.sin(this.t / (180 + r * 37 + k * 53) + r * k) > 0.2 && (r * 4 + k) < Math.max(4, busy * 3);
850
- const p = this.iso(x + 0.12 + k * 0.13, y + 0.6, 1.7 - r * 0.22);
851
- c.fillStyle = on ? (k % 3 === 0 ? '#ffb347' : '#5cf08a') : '#2f3a33';
852
- c.fillRect(p.x - 1, p.y - 1, 2.5, 2);
853
- }
854
- }
855
- }
856
-
857
- printer(x, y) {
858
- const pal = this.pal;
859
- this.box(x, y, 0, 0.75, 0.55, 0.55, pal.deskTop, pal.deskL, pal.deskR);
860
- this.box(x + 0.05, y + 0.05, 0.55, 0.65, 0.45, 0.28, '#e8e8e8', '#c8c8c8', '#b3b3b3');
861
- this.box(x + 0.15, y + 0.35, 0.83, 0.45, 0.15, 0.02, '#ffffff', '#eee', '#ddd');
862
- }
863
-
864
- pingpong(x, y) {
865
- for (const [lx, ly] of [[0.1, 0.1], [2.4, 0.1], [0.1, 1.2], [2.4, 1.2]]) this.box(x + lx, y + ly, 0, 0.08, 0.08, 0.7, '#333', '#333', '#222');
866
- this.box(x, y, 0.7, 2.6, 1.4, 0.06, '#2f7a52', '#245f40', '#1d4f35');
867
- const P = (a, b, c) => this.iso(a, b, c);
868
- this.poly([P(x + 0.04, y + 0.69, 0.765), P(x + 2.56, y + 0.69, 0.765), P(x + 2.56, y + 0.71, 0.765), P(x + 0.04, y + 0.71, 0.765)], '#ffffff');
869
- this.poly([P(x + 1.3, y - 0.05, 0.76), P(x + 1.3, y + 1.45, 0.76), P(x + 1.3, y + 1.45, 0.95), P(x + 1.3, y - 0.05, 0.95)], 'rgba(255,255,255,.55)', '#ddd');
870
- }
871
-
872
- meetingTable(x, y) {
873
- const pal = this.pal;
874
- this.box(x + 0.3, y + 0.5, 0, 0.12, 0.4, 0.7, pal.deskL, pal.deskL, pal.deskR);
875
- this.box(x + 2.6, y + 0.5, 0, 0.12, 0.4, 0.7, pal.deskL, pal.deskL, pal.deskR);
876
- this.box(x, y, 0.7, 3.0, 1.4, 0.08, this.shade(pal.deskTop, 12), pal.deskL, pal.deskR);
877
- this.box(x + 1.2, y + 0.45, 0.78, 0.5, 0.35, 0.03, '#2a2a2e', '#222', '#1a1a1a');
878
- this.box(x + 0.4, y + 0.3, 0.78, 0.2, 0.28, 0.02, '#fff', '#eee', '#ddd');
879
- this.box(x + 2.2, y + 0.8, 0.78, 0.12, 0.12, 0.12, '#f2f2f2', '#d0d0d0', '#bbb');
880
- }
881
-
882
- stool(x, y, h, color) {
883
- this.box(x - 0.04, y - 0.04, 0, 0.08, 0.08, h, '#555', '#444', '#333');
884
- this.box(x - 0.2, y - 0.2, h, 0.4, 0.4, 0.07, this.shade(color, 18), color, this.shade(color, -18));
885
- }
886
-
887
- roundTable(x, y) {
888
- const c = this.ctx;
889
- this.box(x - 0.05, y - 0.05, 0, 0.1, 0.1, 0.72, '#555', '#444', '#333');
890
- const p = this.iso(x, y, 0.75);
891
- c.fillStyle = this.shade(this.pal.deskTop, -15);
892
- c.beginPath(); c.ellipse(p.x, p.y + 3, 30, 15, 0, 0, Math.PI * 2); c.fill();
893
- c.fillStyle = this.shade(this.pal.deskTop, 15);
894
- c.beginPath(); c.ellipse(p.x, p.y, 30, 15, 0, 0, Math.PI * 2); c.fill();
895
- c.fillStyle = '#fff';
896
- c.beginPath(); c.ellipse(p.x + 8, p.y - 2, 5, 2.5, 0, 0, Math.PI * 2); c.fill();
897
- c.fillStyle = '#e6b35c';
898
- c.beginPath(); c.arc(p.x + 8, p.y - 3, 2, 0, Math.PI * 2); c.fill();
899
- }
900
-
901
- coffeeMachine(x, y) {
902
- this.box(x, y, 0.9, 0.5, 0.45, 0.6, '#555b63', '#3a3f45', '#2e3237');
903
- this.box(x + 0.15, y + 0.42, 1.0, 0.2, 0.05, 0.15, '#222', '#111', '#111');
904
- const p = this.iso(x + 0.4, y + 0.45, 1.35);
905
- this.ctx.fillStyle = Math.sin(this.t / 400) > 0 ? '#ff6b4a' : '#7a3a2a';
906
- this.ctx.fillRect(p.x, p.y, 2.5, 2.5);
907
- this.box(x + 0.6, y + 0.1, 0.9, 0.14, 0.14, 0.14, '#fff', '#ddd', '#ccc');
908
- }
909
-
910
- sink(x, y) {
911
- this.box(x, y + 0.05, 0.9, 0.55, 0.4, 0.02, '#9aa3ad', '#7d858e', '#6c737b');
912
- this.box(x + 0.22, y + 0.02, 0.92, 0.06, 0.06, 0.3, this.pal.metal, this.pal.metal, '#888');
913
- this.box(x + 0.22, y + 0.02, 1.2, 0.06, 0.2, 0.04, this.pal.metal, this.pal.metal, '#888');
914
- }
915
-
916
- fridge(x, y) {
917
- this.box(x, y, 0, 0.85, 0.75, 1.95, '#eef1f4', '#cfd5db', '#bcc3ca');
918
- const P = (a, b, c) => this.iso(a, b, c);
919
- this.poly([P(x, y + 0.75, 1.25), P(x + 0.85, y + 0.75, 1.25), P(x + 0.85, y + 0.75, 1.27), P(x, y + 0.75, 1.27)], '#9aa3ad');
920
- this.poly([P(x + 0.7, y + 0.75, 1.4), P(x + 0.73, y + 0.75, 1.4), P(x + 0.73, y + 0.75, 1.75), P(x + 0.7, y + 0.75, 1.75)], '#888');
921
- for (const [mx, mz, col] of [[0.2, 1.6, '#c96442'], [0.4, 1.45, '#3b8ee0'], [0.3, 0.9, '#5f9a4c']]) {
922
- const p = P(x + mx, y + 0.75, mz);
923
- this.ctx.fillStyle = col; this.ctx.fillRect(p.x, p.y, 4, 4);
924
- }
925
- }
926
-
927
- waterCooler(x, y) {
928
- this.box(x, y, 0, 0.42, 0.42, 0.95, '#e8ecef', '#cfd5db', '#bcc3ca');
929
- const c = this.ctx;
930
- const p = this.iso(x + 0.21, y + 0.21, 0.95);
931
- c.fillStyle = 'rgba(120,180,240,.6)';
932
- c.beginPath(); c.ellipse(p.x, p.y - 9, 8, 11, 0, 0, Math.PI * 2); c.fill();
933
- c.fillStyle = 'rgba(255,255,255,.5)';
934
- c.fillRect(p.x - 4, p.y - 16, 2, 10);
935
- const k = (this.t / 1800) % 1;
936
- c.fillStyle = 'rgba(255,255,255,.85)';
937
- c.beginPath(); c.arc(p.x + 2, p.y - 2 - k * 16, 1.2, 0, Math.PI * 2); c.fill();
938
- const tap = this.iso(x + 0.21, y + 0.42, 0.65);
939
- c.fillStyle = '#3b8ee0'; c.fillRect(tap.x - 1.5, tap.y, 3, 3);
940
- }
941
-
942
- couch(x, y) {
943
- const pal = this.pal;
944
- this.box(x, y - 0.05, 0.32, 2.8, 0.25, 0.42, this.shade(pal.couch, 10), pal.couchD, this.shade(pal.couchD, -12));
945
- this.box(x, y, 0, 2.8, 0.8, 0.32, pal.couch, pal.couchD, this.shade(pal.couchD, -12));
946
- this.box(x - 0.15, y - 0.05, 0, 0.18, 0.85, 0.52, this.shade(pal.couch, 6), pal.couchD, this.shade(pal.couchD, -12));
947
- this.box(x + 2.8, y - 0.05, 0, 0.18, 0.85, 0.52, this.shade(pal.couch, 6), pal.couchD, this.shade(pal.couchD, -12));
948
- this.box(x + 2.15, y + 0.12, 0.32, 0.35, 0.16, 0.28, '#e9c46a', '#d4ae52', '#c19c43');
949
- }
950
-
951
- coffeeTable(x, y) {
952
- const pal = this.pal;
953
- this.box(x, y, 0, 1.6, 0.7, 0.3, this.shade(pal.deskTop, -10), pal.deskL, pal.deskR);
954
- this.box(x + 0.2, y + 0.15, 0.3, 0.35, 0.25, 0.06, '#c96442', '#a85336', '#8f462e');
955
- this.box(x + 1.1, y + 0.3, 0.3, 0.12, 0.12, 0.12, '#fff', '#ddd', '#ccc');
956
- }
957
-
958
- beanbag(x, y, color) {
959
- const c = this.ctx;
960
- const p = this.iso(x, y, 0);
961
- c.fillStyle = this.shade(color, -25);
962
- c.beginPath(); c.ellipse(p.x, p.y - 4, 20, 11, 0, 0, Math.PI * 2); c.fill();
963
- c.fillStyle = color;
964
- c.beginPath(); c.ellipse(p.x, p.y - 8, 18, 10, 0, 0, Math.PI * 2); c.fill();
965
- c.fillStyle = this.shade(color, 20);
966
- c.beginPath(); c.ellipse(p.x - 3, p.y - 14, 10, 6, -0.2, 0, Math.PI * 2); c.fill();
967
- }
968
-
969
- books(x0, x1, y) {
970
- const P = (a, b, c) => this.iso(a, b, c);
971
- const cols = this.pal.book;
972
- for (let shelf = 0; shelf < 3; shelf++) {
973
- const z = 0.12 + shelf * 0.45;
974
- let bx = x0 + 0.05;
975
- let i = Math.round(x0 * 7) + shelf * 5;
976
- while (bx < x1 - 0.1) {
977
- const bw = 0.07 + ((i * 13) % 5) * 0.015;
978
- const bh = 0.25 + ((i * 7) % 4) * 0.03;
979
- if (i % 7 !== 3) this.poly([P(bx, y, z), P(bx + bw, y, z), P(bx + bw, y, z + bh), P(bx, y, z + bh)], cols[i % cols.length]);
980
- bx += bw + 0.015;
981
- i++;
982
- }
983
- this.poly([P(x0, y, z - 0.02), P(x1, y, z - 0.02), P(x1, y, z), P(x0, y, z)], this.pal.deskR);
984
- }
985
- }
986
-
987
- arcade(x, y) {
988
- this.box(x, y, 0, 0.7, 0.7, 1.75, '#3a2a6a', '#2b1f52', '#221843');
989
- const P = (a, b, c) => this.iso(a, b, c);
990
- const hue = (this.t / 20) % 360;
991
- this.poly([P(x + 0.1, y + 0.7, 1.0), P(x + 0.6, y + 0.7, 1.0), P(x + 0.6, y + 0.7, 1.45), P(x + 0.1, y + 0.7, 1.45)], `hsl(${hue},70%,55%)`);
992
- const blip = this.iso(x + 0.25 + 0.2 * Math.sin(this.t / 300), y + 0.7, 1.2);
993
- this.ctx.fillStyle = '#fff'; this.ctx.fillRect(blip.x, blip.y, 3, 3);
994
- this.poly([P(x, y + 0.7, 1.55), P(x + 0.7, y + 0.7, 1.55), P(x + 0.7, y + 0.7, 1.72), P(x, y + 0.7, 1.72)], '#ffcf4a');
995
- this.box(x, y + 0.7, 0.8, 0.7, 0.2, 0.08, '#4a3a8a', '#3a2a6a', '#2b1f52');
996
- const j = this.iso(x + 0.25, y + 0.8, 0.88);
997
- this.ctx.fillStyle = '#e04a4a'; this.ctx.beginPath(); this.ctx.arc(j.x, j.y - 3, 2.5, 0, Math.PI * 2); this.ctx.fill();
998
- }
999
-
1000
- floorLamp(x, y) {
1001
- const c = this.ctx;
1002
- const b = this.iso(x, y, 0), t = this.iso(x, y, 1.7);
1003
- if (skyColor().night) {
1004
- c.fillStyle = 'rgba(255,220,140,.14)';
1005
- c.beginPath(); c.ellipse(b.x, b.y, 50, 25, 0, 0, Math.PI * 2); c.fill();
1006
- }
1007
- c.strokeStyle = '#333'; c.lineWidth = 2;
1008
- c.beginPath(); c.moveTo(b.x, b.y); c.lineTo(t.x, t.y); c.stroke();
1009
- c.fillStyle = '#333'; c.beginPath(); c.ellipse(b.x, b.y, 7, 3.5, 0, 0, Math.PI * 2); c.fill();
1010
- c.fillStyle = '#f3e3b5';
1011
- c.beginPath(); c.moveTo(t.x - 9, t.y + 4); c.lineTo(t.x + 9, t.y + 4); c.lineTo(t.x + 5, t.y - 8); c.lineTo(t.x - 5, t.y - 8); c.closePath(); c.fill();
1012
- }
1013
-
1014
- vending(x, y, color) {
1015
- this.box(x, y, 0, 0.7, 0.75, 1.85, this.shade(color, 15), this.shade(color, -10), color);
1016
- const P = (a, b, c) => this.iso(a, b, c);
1017
- const fx = x + 0.7;
1018
- this.poly([P(fx, y + 0.08, 0.7), P(fx, y + 0.55, 0.7), P(fx, y + 0.55, 1.7), P(fx, y + 0.08, 1.7)], 'rgba(20,30,40,.75)');
1019
- const cols = ['#f2d16b', '#5cf08a', '#ff8a65', '#81d4fa', '#ce93d8'];
1020
- for (let r = 0; r < 4; r++) {
1021
- for (let k = 0; k < 3; k++) {
1022
- const q = P(fx, y + 0.15 + k * 0.14, 1.55 - r * 0.23);
1023
- this.ctx.fillStyle = cols[(r * 3 + k + Math.round(y)) % cols.length];
1024
- this.ctx.fillRect(q.x - 1.5, q.y - 4, 3, 5);
1025
- }
1026
- }
1027
- this.poly([P(fx, y + 0.1, 0.25), P(fx, y + 0.5, 0.25), P(fx, y + 0.5, 0.45), P(fx, y + 0.1, 0.45)], '#222');
1028
- const glow = P(fx, y + 0.65, 1.2);
1029
- this.ctx.fillStyle = Math.sin(this.t / 700 + y) > 0 ? '#9ef' : '#4ad';
1030
- this.ctx.fillRect(glow.x - 1, glow.y - 3, 2, 6);
1031
- }
1032
-
1033
- foosball(x, y) {
1034
- for (const [lx, ly] of [[0.1, 0.1], [1.42, 0.1], [0.1, 0.62], [1.42, 0.62]]) this.box(x + lx, y + ly, 0, 0.08, 0.08, 0.6, '#5a4030', '#5a4030', '#4a3428');
1035
- this.box(x, y, 0.6, 1.6, 0.8, 0.25, '#2f8a4a', '#6e533b', '#5c4431');
1036
- const P = (a, b, c) => this.iso(a, b, c);
1037
- const players = this.plan && this.plan.pois.filter((q) => q.pair === 'fb' && q.taken).length === 2;
1038
- for (let k = 0; k < 6; k++) {
1039
- const rx = x + 0.2 + k * 0.24;
1040
- const shift = players ? Math.sin(this.t / 120 + k) * 0.06 : 0;
1041
- const a = P(rx, y - 0.15, 0.82), b = P(rx, y + 0.95, 0.82);
1042
- this.ctx.strokeStyle = '#c8ccd0'; this.ctx.lineWidth = 1.2;
1043
- this.ctx.beginPath(); this.ctx.moveTo(a.x, a.y); this.ctx.lineTo(b.x, b.y); this.ctx.stroke();
1044
- for (let m = 0; m < 2; m++) {
1045
- const q = P(rx, y + 0.25 + m * 0.3 + shift, 0.82);
1046
- this.ctx.fillStyle = k % 2 ? '#c0392b' : '#2f6fb0';
1047
- this.ctx.fillRect(q.x - 1.5, q.y - 3, 3, 6);
1048
- }
1049
- }
1050
- }
1051
-
1052
- booth(add, x, y) {
1053
- const P = (a, b, c) => this.iso(a, b, c);
1054
- const pal = this.pal;
1055
- const H = 2.0;
1056
- // back walls (felt panels), then the stool, then glass front
1057
- add(x + 0.65 + y + 0.05, () => {
1058
- this.poly([P(x, y, 0), P(x + 1.3, y, 0), P(x + 1.3, y, H), P(x, y, H)], pal.dark ? '#3c4a5a' : '#9fb3c8');
1059
- this.poly([P(x, y, 0), P(x, y + 1.3, 0), P(x, y + 1.3, H), P(x, y, H)], pal.dark ? '#34404e' : '#8ea3b8');
1060
- this.poly([P(x + 0.02, y + 0.02), P(x + 1.28, y + 0.02), P(x + 1.28, y + 1.28), P(x + 0.02, y + 1.28)], pal.dark ? '#2a323b' : '#d8dee5');
1061
- const lamp = P(x + 0.65, y + 0.65, H - 0.05);
1062
- this.ctx.fillStyle = 'rgba(255,230,160,.9)';
1063
- this.ctx.beginPath(); this.ctx.ellipse(lamp.x, lamp.y, 8, 4, 0, 0, Math.PI * 2); this.ctx.fill();
1064
- this.stool(x + 0.65, y + 0.6, 0.43, '#c96442');
1065
- });
1066
- add(x + 1.3 + y + 0.65 + 0.3, () => {
1067
- this.poly([P(x + 1.3, y, 0), P(x + 1.3, y + 1.3, 0), P(x + 1.3, y + 1.3, H), P(x + 1.3, y, H)], pal.glass, pal.glassEdge);
1068
- this.poly([P(x, y + 1.3, 0), P(x + 1.3, y + 1.3, 0), P(x + 1.3, y + 1.3, H), P(x, y + 1.3, H)], pal.glass, pal.glassEdge);
1069
- this.poly([P(x + 0.85, y + 1.3, 0), P(x + 0.87, y + 1.3, 0), P(x + 0.87, y + 1.3, H), P(x + 0.85, y + 1.3, H)], pal.glassEdge);
1070
- this.box(x - 0.03, y - 0.03, H, 1.36, 1.36, 0.08, pal.dark ? '#4a5868' : '#b5c4d3', pal.trim, pal.trim);
1071
- });
1072
- }
1073
-
1074
- desk(dk, p) {
1075
- const pal = this.pal;
1076
- const { x, y } = dk;
1077
- for (const [lx, ly] of [[0.05, 0.05], [1.27, 0.05], [0.05, 0.67], [1.27, 0.67]]) {
1078
- this.box(x + lx, y + ly, 0, 0.08, 0.08, 0.72, pal.deskL, pal.deskL, pal.deskR);
1079
- }
1080
- this.box(x, y, 0.72, 1.4, 0.8, 0.07, pal.deskTop, pal.deskL, pal.deskR);
1081
- const worker = p && p.mode === 'work' ? p : null;
1082
- if (dk.side === 's') {
1083
- const mx = x + 0.42, my = y + 0.1, mz = 0.79;
1084
- this.box(mx + 0.22, my + 0.04, mz, 0.1, 0.1, 0.18, pal.monitor, pal.monitor, pal.monitor);
1085
- this.box(mx, my, mz + 0.18, 0.56, 0.08, 0.42, pal.monitor, pal.monitor, this.shade(pal.monitor, 10));
1086
- this.screen(mx + 0.04, my + 0.08, mz + 0.22, 0.48, 0.34, worker);
1087
- this.box(x + 0.48, y + 0.48, 0.79, 0.44, 0.16, 0.025, '#d8d8d8', '#aaa', '#999');
1088
- } else {
1089
- // We see this monitor from behind; a status LED on its back.
1090
- const mx = x + 0.42, my = y + 0.6, mz = 0.79;
1091
- this.box(x + 0.48, y + 0.16, 0.79, 0.44, 0.16, 0.025, '#d8d8d8', '#aaa', '#999');
1092
- this.box(mx + 0.22, my - 0.02, mz, 0.1, 0.1, 0.18, pal.monitor, pal.monitor, pal.monitor);
1093
- this.box(mx, my, mz + 0.18, 0.56, 0.08, 0.42, this.shade(pal.monitor, 15), this.shade(pal.monitor, 8), this.shade(pal.monitor, 12));
1094
- if (worker) {
1095
- const g = this.iso(mx + 0.28, my + 0.08, mz + 0.5);
1096
- this.ctx.fillStyle = STATUS_COLOR[worker.s.status] || '#888';
1097
- this.ctx.globalAlpha = 0.6 + 0.4 * Math.sin(this.t / 400 + worker.phase);
1098
- this.ctx.beginPath(); this.ctx.arc(g.x, g.y, 1.8, 0, Math.PI * 2); this.ctx.fill();
1099
- this.ctx.globalAlpha = 1;
1100
- }
1101
- }
1102
- // personal touches, stable per desk
1103
- const h = hash(`${x.toFixed(1)},${y.toFixed(1)}`);
1104
- if (h % 3 === 0) this.box(x + 1.12, y + 0.3, 0.79, 0.12, 0.12, 0.13, '#f2f2f2', '#d0d0d0', '#bbb');
1105
- if (h % 5 === 1) {
1106
- this.box(x + 0.14, y + 0.24, 0.79, 0.12, 0.12, 0.08, pal.pot, pal.pot, this.shade(pal.pot, -15));
1107
- const q = this.iso(x + 0.2, y + 0.3, 0.87);
1108
- this.ctx.fillStyle = pal.plant;
1109
- this.ctx.beginPath(); this.ctx.ellipse(q.x, q.y - 4, 4, 6, 0, 0, Math.PI * 2); this.ctx.fill();
1110
- }
1111
- if (h % 4 === 2) this.box(x + 0.12, y + 0.45, 0.79, 0.22, 0.15, 0.05, '#f2d16b', '#d9b84f', '#c4a33f');
1112
- }
1113
-
1114
- screen(x, y, z, w, h, p) {
1115
- const P = (a, b, cc) => this.iso(a, b, cc);
1116
- const st = p ? p.s.status : 'off';
1117
- const base = { thinking: '#3a3160', tool: '#14324f', typing: '#123d2e', waiting: '#4d3510', error: '#4a1612' }[st] || this.pal.screenOff;
1118
- const corners = [P(x, y, z), P(x + w, y, z), P(x + w, y, z + h), P(x, y, z + h)];
1119
- this.poly(corners, base);
1120
- if (!p) return;
1121
- const c = this.ctx;
1122
- c.save();
1123
- c.beginPath();
1124
- corners.forEach((q, i) => (i ? c.lineTo(q.x, q.y) : c.moveTo(q.x, q.y)));
1125
- c.closePath();
1126
- c.clip();
1127
- const t = this.t / 1000;
1128
- const col = STATUS_COLOR[st];
1129
- if (st === 'tool' || st === 'typing') {
1130
- const scroll = (t * (st === 'tool' ? 2.2 : 1.2) + p.phase) % 1;
1131
- for (let i = -1; i < 6; i++) {
1132
- const zz = z + h - 0.05 - ((i + scroll) * h) / 5;
1133
- const len = 0.12 + ((p.h >> ((i + 8) & 15)) % 7) / 20;
1134
- const a = P(x + 0.05, y, zz), b = P(x + 0.05 + Math.min(len, w - 0.1), y, zz);
1135
- c.strokeStyle = i % 3 === 0 ? col : 'rgba(255,255,255,.55)';
1136
- c.lineWidth = 1.5;
1137
- c.beginPath(); c.moveTo(a.x, a.y); c.lineTo(b.x, b.y); c.stroke();
1138
- }
1139
- } else {
1140
- const m = P(x + w / 2, y, z + h / 2);
1141
- c.fillStyle = col;
1142
- c.globalAlpha = st === 'thinking' ? 0.5 + 0.4 * Math.sin(t * 3 + p.phase) : 0.9;
1143
- c.font = '700 10px ui-sans-serif, system-ui, sans-serif';
1144
- c.textAlign = 'center';
1145
- c.textBaseline = 'middle';
1146
- c.fillText({ thinking: '…', waiting: '?', error: '!' }[st] || '', m.x, m.y);
1147
- }
1148
- c.restore();
1149
- c.save();
1150
- c.globalAlpha = 0.18;
1151
- const g = P(x + w / 2, y + 0.35, 0.8);
1152
- c.fillStyle = col;
1153
- c.beginPath(); c.ellipse(g.x, g.y, 18, 7, 0, 0, Math.PI * 2); c.fill();
1154
- c.restore();
1155
- }
1156
-
1157
- chair(dk, p) {
1158
- const pal = this.pal;
1159
- const sx = dk.seat.x - 0.25, sy = dk.seat.y - 0.22;
1160
- const seated = p && !p.path.length && p.pose === 'sit' && Math.hypot(p.x - dk.seat.x, p.y - dk.seat.y) < 0.05;
1161
- const ox = seated ? 0 : 0.12, oy = seated ? 0 : dk.side === 's' ? 0.18 : -0.18;
1162
- this.box(sx + 0.22 + ox, sy + 0.19 + oy, 0, 0.06, 0.06, 0.32, '#222', '#222', '#111');
1163
- if (dk.side === 'n') this.box(sx + ox, sy + oy - 0.05, 0.4, 0.5, 0.07, 0.55, this.shade(pal.chair, 15), pal.chair, this.shade(pal.chair, -10));
1164
- this.box(sx + ox, sy + oy, 0.32, 0.5, 0.45, 0.08, this.shade(pal.chair, 15), pal.chair, this.shade(pal.chair, -10));
1165
- if (dk.side === 's' && !seated) this.box(sx + ox, sy + oy + 0.4, 0.4, 0.5, 0.07, 0.5, this.shade(pal.chair, 15), pal.chair, this.shade(pal.chair, -10));
1166
- }
1167
-
1168
- // ------------------------------------------------------------ people
1169
-
1170
- stateOf(p) {
1171
- const s = p.s || {};
1172
- if (p.path.length) return { pose: 'walk', face: p.walkFace, act: null, sitZ: 0 };
1173
- if (p.mode === 'work' && p.desk) {
1174
- const face = p.desk.side === 'n' ? 'front' : 'back';
1175
- if (s.status === 'waiting' || s.status === 'error') return { pose: 'stand', face, act: 'wave', sitZ: 0 };
1176
- if (p.desk.pose === 'stand') return { pose: 'stand', face, act: 'station', sitZ: 0 };
1177
- return { pose: 'sit', face, act: s.status === 'tool' || s.status === 'typing' ? 'type' : 'think', sitZ: 0.34 };
1178
- }
1179
- if (p.mode === 'wander' && p.poi) {
1180
- const q = p.poi;
1181
- return { pose: q.sit ? 'sit' : 'stand', face: q.face === 'side' ? 'front' : q.face, act: q.act, sitZ: q.sit || 0 };
1182
- }
1183
- return { pose: 'stand', face: 'front', act: null, sitZ: 0 };
1184
- }
1185
-
1186
- person(p) {
1187
- const c = this.ctx;
1188
- const t = this.t / 1000;
1189
- const s = p.s || {};
1190
- const st = this.stateOf(p);
1191
- p.pose = st.pose;
1192
- const moving = st.pose === 'walk';
1193
- const seated = st.pose === 'sit';
1194
- const front = st.face !== 'back';
1195
- const b = this.iso(p.x, p.y, st.sitZ);
1196
- const step = moving ? Math.sin(t * 12 + p.phase) : 0;
1197
- const bob = moving ? Math.abs(step) * 1.5 : 0;
1198
-
1199
- const fb = this.iso(p.x, p.y, 0);
1200
- c.fillStyle = this.pal.shadow;
1201
- c.beginPath(); c.ellipse(fb.x, fb.y, 10, 5, 0, 0, Math.PI * 2); c.fill();
1202
- if (this.hoverKey === p.key) {
1203
- c.strokeStyle = STATUS_COLOR[s.status] || '#888';
1204
- c.lineWidth = 2;
1205
- c.beginPath(); c.ellipse(fb.x, fb.y, 14, 7, 0, 0, Math.PI * 2); c.stroke();
1206
- }
1207
-
1208
- let x = b.x;
1209
- const y = b.y - bob;
1210
- if (st.act === 'pingpong') x += Math.sin(t * 7 + p.phase) * 4;
1211
- if (st.act === 'foosball') x += Math.sin(t * 5 + p.phase) * 1.5;
1212
- const sc = s.subagent ? 0.8 : 1;
1213
- c.save();
1214
- c.translate(x, y);
1215
- c.scale(sc, sc);
1216
-
1217
- c.fillStyle = p.pants;
1218
- if (seated) {
1219
- c.fillRect(-5, -4, 4, 5);
1220
- c.fillRect(1, -4, 4, 5);
1221
- } else {
1222
- c.fillRect(-5, -10, 4, 10 + step * 2);
1223
- c.fillRect(1, -10, 4, 10 - step * 2);
1224
- c.fillStyle = '#2a2a2a';
1225
- c.fillRect(-5, -1 + step, 4, 2);
1226
- c.fillRect(1, -1 - step, 4, 2);
1227
- }
1228
- const hip = seated ? -4 : -10;
1229
- this.roundRect(-7, hip - 13, 14, 14, 3, p.shirt);
1230
- const arm = this.shade(p.shirt, -18);
1231
- const hand = (hx, hy) => { c.fillStyle = p.skin; c.beginPath(); c.arc(hx, hy, 2.2, 0, Math.PI * 2); c.fill(); };
1232
- c.fillStyle = arm;
1233
- const sip = Math.sin(t * 0.9 + p.phase) > 0.6;
1234
- switch (st.act) {
1235
- case 'wave': {
1236
- const wave = Math.sin(t * 8 + p.phase) * 2;
1237
- c.fillRect(6, hip - 26, 3, 14);
1238
- hand(7.5 + wave * 0.5, hip - 27);
1239
- c.fillStyle = arm; c.fillRect(-9, hip - 11, 3, 10);
1240
- break;
1241
- }
1242
- case 'type': {
1243
- const k1 = Math.sin(t * 18 + p.phase) > 0 ? 1 : 0;
1244
- c.fillRect(-9, hip - 11 - k1, 3, 8);
1245
- c.fillRect(6, hip - 11 - (1 - k1), 3, 8);
1246
- break;
1247
- }
1248
- case 'coffee':
1249
- case 'water': {
1250
- c.fillRect(-9, hip - 11, 3, 10);
1251
- c.fillRect(6, sip ? hip - 15 : hip - 11, 3, sip ? 7 : 10);
1252
- const cupY = sip && front ? hip - 19 : hip - 4;
1253
- c.fillStyle = st.act === 'water' ? 'rgba(170,215,255,.95)' : '#fafafa';
1254
- c.fillRect(6, cupY, 5, 6);
1255
- if (st.act === 'coffee') { c.fillStyle = '#6b3f22'; c.fillRect(7, cupY, 3, 1.5); }
1256
- break;
1257
- }
1258
- case 'read': {
1259
- c.fillRect(-8, hip - 11, 3, 7);
1260
- c.fillRect(5, hip - 11, 3, 7);
1261
- if (front) { c.fillStyle = pick(this.pal.book, p.h, 5); c.fillRect(-7, hip - 12, 14, 9); c.fillStyle = '#fff'; c.fillRect(-0.5, hip - 12, 1, 9); }
1262
- break;
1263
- }
1264
- case 'whiteboard':
1265
- case 'arcade': {
1266
- const k = Math.sin(t * (st.act === 'arcade' ? 14 : 3) + p.phase);
1267
- c.fillRect(-9, hip - 11, 3, 10);
1268
- c.fillRect(6, hip - 20 + k * 2, 3, 12);
1269
- hand(7.5, hip - 21 + k * 2);
1270
- break;
1271
- }
1272
- case 'pingpong': {
1273
- c.fillRect(-9, hip - 11, 3, 10);
1274
- c.fillRect(6, hip - 14, 3, 9);
1275
- c.fillStyle = '#c0392b';
1276
- c.beginPath(); c.arc(10, hip - 16, 4, 0, Math.PI * 2); c.fill();
1277
- break;
1278
- }
1279
- case 'foosball': {
1280
- const k1 = Math.sin(t * 16 + p.phase) * 1.5;
1281
- c.fillRect(-9, hip - 12 + k1, 3, 7);
1282
- c.fillRect(6, hip - 12 - k1, 3, 7);
1283
- break;
1284
- }
1285
- case 'phone': {
1286
- c.fillRect(-9, hip - 11, 3, 10);
1287
- c.fillRect(5, hip - 22, 3, 12);
1288
- c.fillStyle = '#222'; c.fillRect(5, hip - 25, 3, 6);
1289
- break;
1290
- }
1291
- case 'nap': {
1292
- c.fillRect(-9, hip - 13, 3, 6);
1293
- c.fillRect(6, hip - 13, 3, 6);
1294
- break;
1295
- }
1296
- default: {
1297
- const sw = moving ? step * 2 : 0;
1298
- c.fillRect(-9, hip - 11 + sw, 3, 10);
1299
- c.fillRect(6, hip - 11 - sw, 3, 10);
1300
- }
1301
- }
1302
-
1303
- const napping = st.act === 'nap';
1304
- const hy = hip - 20 + (napping ? 7 : 0) + (st.act === 'think' ? Math.sin(t * 1.5 + p.phase) * 0.6 : 0);
1305
- c.fillStyle = p.skin;
1306
- c.beginPath(); c.arc(0, hy, 7, 0, Math.PI * 2); c.fill();
1307
- c.fillStyle = p.hair;
1308
- if (front && !napping) {
1309
- c.beginPath(); c.arc(0, hy - 1.5, 7.3, Math.PI * 1.05, Math.PI * 1.95); c.fill();
1310
- if (p.hairStyle === 1) { c.fillRect(-7.3, hy - 2, 2.5, 8); c.fillRect(4.8, hy - 2, 2.5, 8); }
1311
- if (p.hairStyle === 2) { c.beginPath(); c.arc(0, hy - 8, 3.2, 0, Math.PI * 2); c.fill(); }
1312
- c.fillStyle = '#2a2018';
1313
- const sleepy = s.status === 'idle';
1314
- const blink = Math.sin(t * 0.7 + p.phase * 3) > 0.985;
1315
- c.fillRect(-3, hy - 0.5, 1.6, blink || sleepy ? 0.7 : 2);
1316
- c.fillRect(1.6, hy - 0.5, 1.6, blink || sleepy ? 0.7 : 2);
1317
- c.strokeStyle = '#2a2018'; c.lineWidth = 1;
1318
- if (p.mode === 'wander' && !sleepy) {
1319
- c.beginPath(); c.arc(0, hy + 2.2, 2, 0.15 * Math.PI, 0.85 * Math.PI); c.stroke();
1320
- } else if (s.status === 'error' && p.mode === 'work') {
1321
- c.beginPath(); c.arc(0, hy + 4.5, 2, 1.15 * Math.PI, 1.85 * Math.PI); c.stroke();
1322
- }
1323
- } else {
1324
- c.beginPath(); c.arc(0, hy - 0.5, 7.2, 0, Math.PI * 2); c.fill();
1325
- if (p.hairStyle === 2) { c.beginPath(); c.arc(0, hy - 7, 3.2, 0, Math.PI * 2); c.fill(); }
1326
- if (p.hairStyle === 1) c.fillRect(-6, hy, 12, 8);
1327
- }
1328
- if (st.act === 'arcade' || st.act === 'beanbag') {
1329
- c.strokeStyle = '#222'; c.lineWidth = 2;
1330
- c.beginPath(); c.arc(0, hy, 8, Math.PI * 1.1, Math.PI * 1.9); c.stroke();
1331
- }
1332
- c.restore();
1333
-
1334
- const headTop = y + (hy - 8) * sc;
1335
- this.hit.push({ key: p.key, x0: x - 13, x1: x + 13, y0: headTop - 22, y1: fb.y + 4 });
1336
- return { x, y: headTop, act: st.act };
1337
- }
1338
-
1339
- bubble(p, at) {
1340
- const c = this.ctx;
1341
- const t = this.t / 1000;
1342
- const s = p.s || {};
1343
- const st = s.status;
1344
- const pal = this.pal;
1345
- const x = at.x, y = at.y - 6;
1346
- c.save();
1347
- c.textAlign = 'center';
1348
- c.textBaseline = 'middle';
1349
- const font = (w, px) => `${w} ${px}px ui-sans-serif, system-ui, -apple-system, "PingFang TC", "Apple Color Emoji", sans-serif`;
1350
- c.font = font(600, 10);
1351
-
1352
- const badge = this.showAgent && AGENT_BADGES[s.agent];
1353
- const pill = (text, fg, bg, yy, alpha = 1, identify = false) => {
1354
- c.globalAlpha = alpha;
1355
- let tx = text;
1356
- while (c.measureText(tx).width > 118 && tx.length > 2) tx = tx.slice(0, -2) + '…';
1357
- const extra = badge && identify ? 26 : 0;
1358
- const w = c.measureText(tx).width + 12 + extra;
1359
- const height = extra ? 22 : 16;
1360
- this.roundRect(x - w / 2, yy - height / 2, w, height, 8, bg);
1361
- c.fillStyle = fg;
1362
- c.fillText(tx, x + extra / 2, yy + 0.5);
1363
- if (extra) this.agentBadge(badge, x - w / 2 + 13, yy);
1364
- c.globalAlpha = 1;
1365
- return yy - 8;
1366
- };
1367
-
1368
- let top = y;
1369
- if (p.mode === 'work' && !p.path.length) {
1370
- const col = STATUS_COLOR[st] || '#888';
1371
- if (st === 'thinking') {
1372
- c.fillStyle = pal.bubble;
1373
- c.beginPath(); c.arc(x + 6, y - 2, 2, 0, Math.PI * 2); c.fill();
1374
- c.beginPath(); c.arc(x + 10, y - 7, 3, 0, Math.PI * 2); c.fill();
1375
- this.roundRect(x - 2, y - 26, 32, 16, 8, pal.bubble);
1376
- for (let i = 0; i < 3; i++) {
1377
- c.fillStyle = col;
1378
- c.globalAlpha = 0.3 + 0.7 * Math.max(0, Math.sin(t * 5 - i * 0.7));
1379
- c.beginPath(); c.arc(x + 6 + i * 8, y - 18, 2.4, 0, Math.PI * 2); c.fill();
1380
- }
1381
- c.globalAlpha = 1;
1382
- top = y - 26;
1383
- } else if (st === 'typing') {
1384
- this.roundRect(x - 2, y - 24, 34, 16, 5, col);
1385
- c.beginPath(); c.moveTo(x + 2, y - 9); c.lineTo(x - 2, y - 3); c.lineTo(x + 8, y - 9); c.fillStyle = col; c.fill();
1386
- const n = Math.floor(t * 4) % 4;
1387
- c.fillStyle = '#fff';
1388
- for (let i = 0; i < n; i++) { c.beginPath(); c.arc(x + 7 + i * 8, y - 16, 2, 0, Math.PI * 2); c.fill(); }
1389
- top = y - 24;
1390
- } else if (st === 'tool') {
1391
- const tool = (s.activity || 'tool').split(/[:\s]/)[0];
1392
- top = pill('⚙ ' + tool, '#fff', col, y - 12);
1393
- } else if (st === 'waiting' || st === 'error') {
1394
- const jump = Math.abs(Math.sin(t * 4)) * 4;
1395
- c.fillStyle = col;
1396
- c.beginPath(); c.arc(x, y - 12 - jump, 9, 0, Math.PI * 2); c.fill();
1397
- c.fillStyle = '#fff';
1398
- c.font = font(800, 12);
1399
- c.fillText('!', x, y - 11.5 - jump);
1400
- c.font = font(600, 10);
1401
- top = y - 22 - jump;
1402
- }
1403
- } else if (p.mode === 'wander') {
1404
- const justDone = st === 'done' && Date.now() - (s.lastEventAt || 0) < 3 * 60_000;
1405
- const dozing = at.act === 'nap' || (st === 'idle' && (at.act === 'couch' || at.act === 'beanbag' || (p.poi && p.poi.sleepy)));
1406
- if (justDone) {
1407
- c.fillStyle = STATUS_COLOR.done;
1408
- c.beginPath(); c.arc(x, y - 10, 7, 0, Math.PI * 2); c.fill();
1409
- c.fillStyle = '#fff'; c.font = font(800, 9); c.fillText('✓', x, y - 9.5); c.font = font(600, 10);
1410
- top = y - 18;
1411
- } else if (dozing && !p.path.length) {
1412
- c.fillStyle = STATUS_COLOR.idle;
1413
- c.font = font(700, 10);
1414
- for (let i = 0; i < 3; i++) {
1415
- const k = (t * 0.6 + i / 3) % 1;
1416
- c.globalAlpha = 1 - k;
1417
- c.fillText('z', x + 6 + k * 10, y - 4 - k * 18);
1418
- }
1419
- c.globalAlpha = 1;
1420
- c.font = font(600, 10);
1421
- top = y - 20;
1422
- } else if (at.act && (ACT_EMOJI[at.act] || (p.poi && p.poi.emoji)) && !p.path.length) {
1423
- const cycle = (t + p.phase * 3) % 9;
1424
- if (cycle < 2.2) {
1425
- c.globalAlpha = Math.min(1, cycle * 3, (2.2 - cycle) * 3);
1426
- c.font = font(400, 13);
1427
- c.fillText((p.poi && p.poi.emoji) || ACT_EMOJI[at.act], x, y - 10);
1428
- c.globalAlpha = 1;
1429
- c.font = font(600, 10);
1430
- top = y - 18;
1431
- }
1432
- }
1433
- }
1434
-
1435
- const hover = this.hoverKey === p.key;
1436
- if (!s.subagent || hover) {
1437
- pill(this.label(s), pal.text, pal.tagBg, top - 11, p.mode === 'work' || hover ? 1 : 0.7, true);
1438
- } else if (badge) {
1439
- this.agentBadge(badge, x, top - 11);
1440
- }
1441
- c.restore();
1442
- }
1443
-
1444
- agentBadge(badge, x, y) {
1445
- const c = this.ctx;
1446
- c.save();
1447
- c.globalAlpha = 1;
1448
- if (badge.image.complete && badge.image.naturalWidth > 0) {
1449
- if (badge.mono && this.pal.dark) c.filter = 'invert(1)';
1450
- c.imageSmoothingEnabled = true;
1451
- c.imageSmoothingQuality = 'high';
1452
- c.drawImage(badge.image, x - 9, y - 9, 18, 18);
1453
- } else {
1454
- c.fillStyle = this.pal.text;
1455
- c.font = '800 10px ui-sans-serif, system-ui, sans-serif';
1456
- c.textAlign = 'center';
1457
- c.textBaseline = 'middle';
1458
- c.fillText(badge.short, x, y + 0.5);
1459
- }
1460
- c.restore();
1461
- }
1462
-
1463
- moveCat(dt, now) {
1464
- const k = this.cat;
1465
- if (!k.path.length && now > k.until) {
1466
- const { W, D, grid } = this.plan;
1467
- let tx, ty, tries = 0;
1468
- do { tx = rand(0.6, W - 0.6); ty = rand(1, D - 0.6); tries++; } while (grid.blocked(tx, ty) && tries < 30);
1469
- k.path = grid.path(k.x, k.y, tx, ty);
1470
- k.until = Infinity;
1471
- }
1472
- if (k.path.length) {
1473
- const n = k.path[0];
1474
- const dx = n.x - k.x, dy = n.y - k.y, d = Math.hypot(dx, dy), v = 1.2 * dt;
1475
- if (d > 0.001) k.face = dx - dy >= 0 ? 1 : -1;
1476
- if (d <= v) { k.x = n.x; k.y = n.y; k.path.shift(); if (!k.path.length) k.until = now + rand(4000, 12000); }
1477
- else { k.x += (dx / d) * v; k.y += (dy / d) * v; }
1478
- }
1479
- }
1480
-
1481
- drawCat() {
1482
- const k = this.cat;
1483
- const c = this.ctx;
1484
- const p = this.iso(k.x, k.y, 0);
1485
- const t = this.t / 1000;
1486
- const walking = k.path.length > 0;
1487
- c.save();
1488
- c.translate(p.x, p.y);
1489
- c.scale(k.face, 1);
1490
- c.fillStyle = this.pal.shadow;
1491
- c.beginPath(); c.ellipse(0, 0, 8, 3, 0, 0, Math.PI * 2); c.fill();
1492
- const col = '#e09a4a';
1493
- c.fillStyle = col;
1494
- if (walking) {
1495
- const s = Math.sin(t * 14) * 1.5;
1496
- c.fillRect(-5, -4, 2, 4 + s); c.fillRect(3, -4, 2, 4 - s);
1497
- }
1498
- c.beginPath(); c.ellipse(0, walking ? -6 : -4, 7, walking ? 4 : 5, 0, 0, Math.PI * 2); c.fill();
1499
- c.beginPath(); c.arc(6, walking ? -10 : -9, 3.6, 0, Math.PI * 2); c.fill();
1500
- c.beginPath(); c.moveTo(4, -12); c.lineTo(5, -16); c.lineTo(7, -12.5); c.fill();
1501
- c.beginPath(); c.moveTo(7, -12.5); c.lineTo(9, -15.5); c.lineTo(9.5, -11); c.fill();
1502
- c.strokeStyle = col; c.lineWidth = 2; c.lineCap = 'round';
1503
- c.beginPath(); c.moveTo(-6, -6); c.quadraticCurveTo(-12, -8 + Math.sin(t * 3) * 3, -10, -14); c.stroke();
1504
- c.fillStyle = '#2a2018';
1505
- c.fillRect(7, -10.5, 1.2, walking ? 1.2 : 0.6);
1506
- c.restore();
1507
- }
1508
-
1509
- pingPongBall() {
1510
- const players = this.plan.pois
1511
- .filter((q) => q.pair === 'pp' && q.taken)
1512
- .map((q) => this.people.get(q.taken))
1513
- .filter((p) => p && p.poi && p.poi.pair === 'pp' && !p.path.length);
1514
- if (players.length < 2) return;
1515
- const pp = this.plan.pp;
1516
- const k = (this.t / 900) % 2;
1517
- const f = k < 1 ? k : 2 - k;
1518
- const x = pp.x0 + 0.3 + f * (pp.x1 - pp.x0 - 0.6);
1519
- const z = 0.8 + Math.abs(Math.sin(f * Math.PI * 2)) * 0.45;
1520
- const c = this.ctx;
1521
- const sh = this.iso(x, pp.y, 0.77);
1522
- c.fillStyle = 'rgba(0,0,0,.25)';
1523
- c.beginPath(); c.ellipse(sh.x, sh.y, 2.5, 1.2, 0, 0, Math.PI * 2); c.fill();
1524
- const p = this.iso(x, pp.y, z);
1525
- c.fillStyle = '#fff';
1526
- c.beginPath(); c.arc(p.x, p.y, 2.2, 0, Math.PI * 2); c.fill();
1527
- }
1528
-
1529
- // ------------------------------------------------------------ frame
1530
-
1531
- frame(now) {
1532
- const dt = Math.min(0.05, (now - this.last) / 1000);
1533
- this.last = now;
1534
- this.t = now;
1535
- if (!this.visible) return;
1536
- this.step(dt);
1537
- if (this.draw() !== false) {
1538
- if (!this.first && ++this.framesShown === 3) this.markReady(true);
1539
- // Capture immediately: a WebGL drawing buffer may be cleared after
1540
- // this task. The overlay and the 3D canvas must be copied together.
1541
- if (this.onFrame) this.onFrame(this);
1542
- }
1543
- }
1544
-
1545
- // Advance people and the cat; shared by the 2D and 3D renderers.
1546
- step(dt) {
1547
- const wall = Date.now();
1548
- for (const [k, p] of this.people) {
1549
- if (p.mode === 'wander') {
1550
- if (p.poi && !p.path.length && p.until === Infinity) p.until = wall + p.stay;
1551
- this.wander(p, wall);
1552
- }
1553
- if (p.path.length) {
1554
- const n = p.path[0];
1555
- const dx = n.x - p.x, dy = n.y - p.y, d = Math.hypot(dx, dy), v = SPEED * dt;
1556
- if (d > 0.001) p.walkFace = dx + dy >= 0 ? 'front' : 'back';
1557
- if (d <= v) { p.x = n.x; p.y = n.y; p.path.shift(); }
1558
- else { p.x += (dx / d) * v; p.y += (dy / d) * v; }
1559
- } else if (p.leaving) {
1560
- this.people.delete(k);
1561
- }
1562
- }
1563
- this.moveCat(dt, wall);
1564
- }
1565
-
1566
- draw() {
1567
- const c = this.ctx;
1568
- c.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
1569
- c.clearRect(0, 0, this.cw, this.ch);
1570
- this.fitCamera();
1571
- c.translate(this.cam.ox, this.cam.oy);
1572
- c.scale(this.cam.zoom, this.cam.zoom);
1573
-
1574
- this.drawRoom();
1575
- const occupant = new Map();
1576
- for (const p of this.people.values()) {
1577
- if (p.mode === 'work' && p.desk) occupant.set(p.desk, p);
1578
- else if (p.napDesk) occupant.set(p.napDesk, p);
1579
- }
1580
- const items = [...this.plan.items];
1581
- for (const dk of this.plan.desks) {
1582
- const p = occupant.get(dk);
1583
- items.push({ depth: dk.x + 0.7 + dk.y + 0.4, draw: () => this.desk(dk, p) });
1584
- items.push({ depth: dk.seat.x + dk.seat.y - 0.06, draw: () => this.chair(dk, p) });
1585
- }
1586
- for (const p of this.people.values()) items.push({ depth: p.x + p.y + 0.01, person: p });
1587
- items.push({ depth: this.cat.x + this.cat.y, draw: () => this.drawCat() });
1588
- items.sort((a, b) => a.depth - b.depth);
1589
-
1590
- this.hit = [];
1591
- const heads = [];
1592
- for (const it of items) {
1593
- if (it.person) heads.push([it.person, this.person(it.person)]);
1594
- else it.draw();
1595
- }
1596
- this.pingPongBall();
1597
- if (this.compact) {
1598
- // Keep labels readable when the room is scaled down for PiP.
1599
- c.save();
1600
- c.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
1601
- for (const [p, at] of heads) this.bubble(p, {
1602
- ...at, x: at.x * this.cam.zoom + this.cam.ox, y: at.y * this.cam.zoom + this.cam.oy,
1603
- });
1604
- c.restore();
1605
- } else {
1606
- for (const [p, at] of heads) this.bubble(p, at);
1607
- }
1608
-
1609
- if (this.offDuty > 0) {
1610
- const d = this.iso(0.2, this.plan.door.y + 0.9, 0);
1611
- c.font = '600 10px ui-sans-serif, system-ui, -apple-system, "PingFang TC", sans-serif';
1612
- c.textAlign = 'center';
1613
- c.fillStyle = this.pal.text;
1614
- c.globalAlpha = 0.65;
1615
- c.fillText(`+${this.offDuty} ${this.offDutyLabel}`, d.x - 10, d.y + 14);
1616
- c.globalAlpha = 1;
1617
- }
1618
-
1619
- const z = this.cam.zoom;
1620
- this.hit = this.hit.map((h) => ({
1621
- key: h.key,
1622
- x0: h.x0 * z + this.cam.ox, x1: h.x1 * z + this.cam.ox,
1623
- y0: h.y0 * z + this.cam.oy, y1: h.y1 * z + this.cam.oy,
1624
- }));
1625
- }
1626
- }
1627
-
1628
- window.Office = Office;
1629
- window.OFFICE_AGENT_BADGES = AGENT_BADGES;
1630
- window.OfficeGrid = Grid;
1631
- })();
1
+ // Isometric office: agents that are working sit at cubicle desks; a few that
2
+ // are off duty wander between the pantry, lounge, meeting room and ping-pong
3
+ // table. Plain canvas 2D with local provider logos, no dependencies.
4
+ (() => {
5
+ 'use strict';
6
+
7
+ const TW = 64; // tile width px
8
+ const TH = 32; // tile height px; one z unit is TH px too
9
+ const WALL_H = 2.4;
10
+ const WORKING = new Set(['thinking', 'tool', 'typing', 'waiting', 'error']);
11
+ const MAX_WANDERERS = 7;
12
+ const SPEED = 2.1; // tiles / second
13
+
14
+ const SKIN = ['#f3cfb0', '#e5b38f', '#c98d66', '#9a6646', '#6e4630', '#f7dcc7'];
15
+ const HAIR = ['#2a2019', '#4b3020', '#8a4b2a', '#c99a5b', '#1c1c1c', '#8c8c8c', '#a33a2c', '#3d3550'];
16
+ const SHIRT = {
17
+ claude: ['#c96442', '#d9825f', '#b4553a', '#e09a72'],
18
+ codex: ['#10a37f', '#2bb38f', '#0d8a6b', '#4cc3a0'],
19
+ gemini: ['#4f7cf0', '#6b8ff5', '#3d66d6', '#8aa6f8'],
20
+ copilot: ['#8f5cf5', '#a06af7', '#7a47d5', '#c5a4ff'],
21
+ omp: ['#a96926', '#c78942', '#8d531e', '#dca45c'],
22
+ pi: ['#4f8eb3', '#6ba5c7', '#3d7596', '#9cc7df'],
23
+ antigravity: ['#0ea5e9', '#38bdf8', '#0284c7', '#7dd3fc'],
24
+ opencode: ['#2f6fd0', '#4a86e0', '#2559b0', '#7aa5ee'],
25
+ hermes: ['#b4622d', '#d07c44', '#96501f', '#e2a374'],
26
+ };
27
+ // Official product icons, shared by both renderers and the page UI (OMP: a neutral monogram).
28
+ // Load once per page from the same origin so video capture stays readable.
29
+ // Initials remain a fallback if an image cannot be loaded.
30
+ const AGENT_BADGES = {
31
+ claude: { label: 'Claude', short: 'Cl', color: '#c96442', logo: './assets/agents/claude.png' },
32
+ codex: { label: 'Codex', short: 'Cx', color: '#10a37f', logo: './assets/agents/codex.png' },
33
+ gemini: { label: 'Gemini', short: 'G', color: '#4f7cf0', logo: './assets/agents/gemini.png' },
34
+ copilot: { label: 'Copilot', short: 'Co', color: '#8f5cf5', logo: './assets/agents/copilot.svg', mono: true },
35
+ kimi: { label: 'Kimi Code', short: 'Ki', logo: './assets/agents/kimi.svg', mono: true },
36
+ dsh: { label: 'DSH', short: 'Ds', logo: './assets/agents/dsh.svg' },
37
+ grok: { label: 'Grok Build', short: 'Gr', logo: './assets/agents/grok.svg', mono: true },
38
+ omp: { label: 'OMP', short: 'OMP', color: '#a96926', logo: './assets/agents/omp.svg' },
39
+ pi: { label: 'Pi', short: 'Pi', color: '#4f8eb3', logo: './assets/agents/pi.svg' },
40
+ antigravity: { label: 'Antigravity', short: 'Ag', color: '#0ea5e9', logo: './assets/agents/antigravity.png' },
41
+ opencode: { label: 'OpenCode', short: 'Oc', color: '#2f6fd0', logo: './assets/agents/opencode.svg', mono: true },
42
+ hermes: { label: 'Hermes', short: 'He', color: '#b4622d', logo: './assets/agents/hermes.svg', mono: true },
43
+ };
44
+ for (const badge of Object.values(AGENT_BADGES)) {
45
+ badge.image = new Image();
46
+ badge.image.src = badge.logo;
47
+ }
48
+ const PANTS = ['#34405a', '#2c2c34', '#4a3f35', '#3b4a3f'];
49
+ const PANEL = ['#7d93c4', '#d98b6a', '#7fb08a', '#c9a24c'];
50
+
51
+ const STATUS_COLOR = {
52
+ thinking: '#8b6be6', tool: '#3b8ee0', typing: '#22a874', waiting: '#e09412',
53
+ done: '#5f9a4c', error: '#de4b40', interrupted: '#9b9a94', idle: '#9b9a94',
54
+ };
55
+
56
+ const ACT_EMOJI = {
57
+ coffee: '☕', water: '💧', fridge: '🍎', sink: '🫧', stool: '💬', couch: '💬', beanbag: '🎧',
58
+ window: '☁️', read: '📖', whiteboard: '✏️', meeting: '💬', arcade: '🎮', pingpong: '🏓', plant: '🌱',
59
+ vending: '🥤', foosball: '⚽', phone: '📞',
60
+ };
61
+
62
+ function hash(str) {
63
+ let h = 2166136261;
64
+ for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); }
65
+ return h >>> 0;
66
+ }
67
+ const pick = (arr, h, salt) => arr[(Math.imul(h ^ Math.imul(salt, 2654435761), 2246822519) >>> 0) % arr.length];
68
+ const rand = (a, b) => a + Math.random() * (b - a);
69
+ const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
70
+
71
+ function palette() {
72
+ const dark = matchMedia('(prefers-color-scheme: dark)').matches;
73
+ return dark ? {
74
+ dark: true,
75
+ wood1: '#4a3f35', wood2: '#453a31', woodLine: 'rgba(0,0,0,.22)',
76
+ tileA: '#3d4044', tileB: '#35383c', carpet: '#33404d', carpetLine: 'rgba(255,255,255,.04)',
77
+ wallL: '#4a4640', wallR: '#3f3b36', wallTop: '#5a554e', trim: '#2b2926',
78
+ deskTop: '#8a6a4c', deskL: '#6e533b', deskR: '#5c4431',
79
+ monitor: '#18181a', screenOff: '#26282c', chair: '#2d2d32',
80
+ rug: '#47544f', rugEdge: '#3a4541', couch: '#5b6b8c', couchD: '#4a587a',
81
+ counter: '#d9d4c9', counterL: '#a9a397', counterR: '#958f84', metal: '#8d939a',
82
+ glass: 'rgba(160,200,230,.10)', glassEdge: 'rgba(190,220,240,.35)', mat: '#2f4a3a',
83
+ text: '#ecebe6', tagBg: 'rgba(20,20,19,.78)', bubble: '#f4f3ee', bubbleInk: '#1c1c1a',
84
+ plant: '#4f8a52', pot: '#9a5b3c', shadow: 'rgba(0,0,0,.28)', book: ['#c96442', '#3b8ee0', '#5f9a4c', '#c9a24c', '#8b6be6'],
85
+ } : {
86
+ dark: false,
87
+ wood1: '#e6d3b6', wood2: '#dfcaab', woodLine: 'rgba(120,90,50,.12)',
88
+ tileA: '#f4f4f1', tileB: '#e6e7e3', carpet: '#c9d3dc', carpetLine: 'rgba(0,0,0,.035)',
89
+ wallL: '#f4efe6', wallR: '#e8e1d4', wallTop: '#fbf8f2', trim: '#cfc5b3',
90
+ deskTop: '#c89f72', deskL: '#a8825a', deskR: '#946f4b',
91
+ monitor: '#2a2a2e', screenOff: '#3a3d44', chair: '#4a4a52',
92
+ rug: '#c4d4cb', rugEdge: '#a9bcb1', couch: '#7d93c4', couchD: '#6a80b0',
93
+ counter: '#fbfaf7', counterL: '#d9d4c9', counterR: '#c9c3b6', metal: '#b5bcc4',
94
+ glass: 'rgba(170,210,235,.16)', glassEdge: 'rgba(120,160,190,.55)', mat: '#bfd8c4',
95
+ text: '#1c1c1a', tagBg: 'rgba(255,255,255,.9)', bubble: '#ffffff', bubbleInk: '#1c1c1a',
96
+ plant: '#5a9b5d', pot: '#b86f4b', shadow: 'rgba(60,40,20,.18)', book: ['#c96442', '#3b8ee0', '#5f9a4c', '#c9a24c', '#8b6be6'],
97
+ };
98
+ }
99
+
100
+ function skyColor(d = new Date()) {
101
+ const h = d.getHours() + d.getMinutes() / 60;
102
+ if (h >= 7 && h < 17) return { sky: '#a9d4f2', night: false };
103
+ if (h >= 5 && h < 7) return { sky: '#f3c9a8', night: false };
104
+ if (h >= 17 && h < 19) return { sky: '#f0a679', night: false };
105
+ return { sky: '#1d2747', night: true };
106
+ }
107
+
108
+ // ---------------------------------------------------------------- A* grid
109
+
110
+ class Grid {
111
+ constructor(W, D, res = 4) {
112
+ this.res = res;
113
+ this.cw = Math.ceil(W * res);
114
+ this.ch = Math.ceil(D * res);
115
+ this.cost = new Uint8Array(this.cw * this.ch);
116
+ }
117
+ block(x, y, w, d, pad = 0.12) {
118
+ const R = this.res;
119
+ const i0 = clamp(Math.floor((x - pad) * R), 0, this.cw - 1);
120
+ const i1 = clamp(Math.ceil((x + w + pad) * R) - 1, 0, this.cw - 1);
121
+ const j0 = clamp(Math.floor((y - pad) * R), 0, this.ch - 1);
122
+ const j1 = clamp(Math.ceil((y + d + pad) * R) - 1, 0, this.ch - 1);
123
+ for (let j = j0; j <= j1; j++) for (let i = i0; i <= i1; i++) this.cost[j * this.cw + i] = 1;
124
+ }
125
+ blocked(x, y) {
126
+ const i = Math.floor(x * this.res), j = Math.floor(y * this.res);
127
+ if (i < 0 || j < 0 || i >= this.cw || j >= this.ch) return true;
128
+ return !!this.cost[j * this.cw + i];
129
+ }
130
+ path(ax, ay, bx, by) {
131
+ const R = this.res, cw = this.cw, ch = this.ch;
132
+ const si = clamp(Math.floor(ax * R), 0, cw - 1), sj = clamp(Math.floor(ay * R), 0, ch - 1);
133
+ const gi = clamp(Math.floor(bx * R), 0, cw - 1), gj = clamp(Math.floor(by * R), 0, ch - 1);
134
+ const N = cw * ch;
135
+ const g = new Float32Array(N).fill(Infinity);
136
+ const came = new Int32Array(N).fill(-1);
137
+ const closed = new Uint8Array(N);
138
+ const heap = [];
139
+ const push = (f, n) => {
140
+ heap.push([f, n]);
141
+ let k = heap.length - 1;
142
+ while (k > 0) { const p = (k - 1) >> 1; if (heap[p][0] <= heap[k][0]) break; [heap[p], heap[k]] = [heap[k], heap[p]]; k = p; }
143
+ };
144
+ const pop = () => {
145
+ const top = heap[0], last = heap.pop();
146
+ if (heap.length) {
147
+ heap[0] = last;
148
+ let k = 0;
149
+ for (;;) {
150
+ const l = 2 * k + 1, r = l + 1;
151
+ let m = k;
152
+ if (l < heap.length && heap[l][0] < heap[m][0]) m = l;
153
+ if (r < heap.length && heap[r][0] < heap[m][0]) m = r;
154
+ if (m === k) break;
155
+ [heap[m], heap[k]] = [heap[k], heap[m]]; k = m;
156
+ }
157
+ }
158
+ return top;
159
+ };
160
+ const hfn = (i, j) => { const dx = Math.abs(i - gi), dy = Math.abs(j - gj); return Math.max(dx, dy) + 0.41 * Math.min(dx, dy); };
161
+ // Blocked cells are expensive, not impossible, so a path always exists
162
+ // (someone standing right against furniture can still step away).
163
+ const stepCost = (n) => (this.cost[n] ? 25 : 1);
164
+ const start = sj * cw + si, goal = gj * cw + gi;
165
+ g[start] = 0;
166
+ push(hfn(si, sj), start);
167
+ const dirs = [[1, 0, 1], [-1, 0, 1], [0, 1, 1], [0, -1, 1], [1, 1, 1.41], [1, -1, 1.41], [-1, 1, 1.41], [-1, -1, 1.41]];
168
+ while (heap.length) {
169
+ const [, n] = pop();
170
+ if (n === goal) break;
171
+ if (closed[n]) continue;
172
+ closed[n] = 1;
173
+ const i = n % cw, j = (n / cw) | 0;
174
+ for (const [di, dj, c] of dirs) {
175
+ const ni = i + di, nj = j + dj;
176
+ if (ni < 0 || nj < 0 || ni >= cw || nj >= ch) continue;
177
+ if (di && dj && (this.cost[j * cw + ni] || this.cost[nj * cw + i])) continue;
178
+ const nn = nj * cw + ni;
179
+ const ng = g[n] + c * stepCost(nn);
180
+ if (ng < g[nn]) { g[nn] = ng; came[nn] = n; push(ng + hfn(ni, nj), nn); }
181
+ }
182
+ }
183
+ const cells = [];
184
+ for (let n = goal; n !== -1 && n !== start; n = came[n]) cells.push(n);
185
+ cells.reverse();
186
+ const pts = [];
187
+ let lastDir = null;
188
+ for (let k = 0; k < cells.length; k++) {
189
+ const n = cells[k];
190
+ const dir = n - (k ? cells[k - 1] : start);
191
+ const pt = { x: ((n % cw) + 0.5) / R, y: (((n / cw) | 0) + 0.5) / R };
192
+ if (dir === lastDir && pts.length) pts[pts.length - 1] = pt;
193
+ else pts.push(pt);
194
+ lastDir = dir;
195
+ }
196
+ if (pts.length) pts[pts.length - 1] = { x: bx, y: by };
197
+ else pts.push({ x: bx, y: by });
198
+ return pts;
199
+ }
200
+ }
201
+
202
+ // ---------------------------------------------------------------- office
203
+
204
+ class Office {
205
+ constructor(root, { onHover, label, onFrame, showAgent = true, compact = false } = {}) {
206
+ this.root = root;
207
+ this.onHover = onHover || (() => {});
208
+ this.label = label || ((s) => s.project || s.agent);
209
+ this.onFrame = onFrame;
210
+ this.showAgent = showAgent;
211
+ this.compact = compact;
212
+ this.offDutyLabel = 'went home';
213
+ this.canvas = document.createElement('canvas');
214
+ this.canvas.className = 'office-canvas';
215
+ root.appendChild(this.canvas);
216
+ this.ctx = this.canvas.getContext('2d');
217
+ this.people = new Map();
218
+ this.deskOf = new Map();
219
+ this.offDuty = 0;
220
+ this.first = true;
221
+ this.visible = true;
222
+ this.hit = [];
223
+ this.hoverKey = null;
224
+ this.t = performance.now();
225
+ this.pal = palette();
226
+ this.plan = this.buildPlan(0);
227
+ this.cat = { x: this.plan.W - 3, y: this.plan.D - 2, path: [], until: 0, face: 1 };
228
+ this.theme = matchMedia('(prefers-color-scheme: dark)');
229
+ this.onTheme = () => (this.pal = palette());
230
+ this.theme.addEventListener('change', this.onTheme);
231
+
232
+ this.ro = new ResizeObserver(() => this.resize());
233
+ this.ro.observe(root);
234
+ this.canvas.addEventListener('mousemove', (e) => this.pointer(e));
235
+ this.canvas.addEventListener('click', (e) => this.pointer(e, true));
236
+ this.canvas.addEventListener('mouseleave', () => { this.hoverKey = null; this.onHover(null); });
237
+ this.resize();
238
+ this.last = performance.now();
239
+ // Resolves true once a few frames have been drawn with data in place,
240
+ // false if the renderer gave up (3D load failure).
241
+ this.ready = new Promise((ok) => (this.markReady = ok));
242
+ this.framesShown = 0;
243
+ const loop = (t) => { if (this.dead) return; this.frame(t); this.raf = requestAnimationFrame(loop); };
244
+ this.raf = requestAnimationFrame(loop);
245
+ }
246
+
247
+ destroy() {
248
+ this.dead = true;
249
+ cancelAnimationFrame(this.raf);
250
+ this.ro.disconnect();
251
+ this.theme.removeEventListener('change', this.onTheme);
252
+ this.canvas.remove();
253
+ }
254
+
255
+ // ------------------------------------------------------------ floor plan
256
+
257
+ buildPlan(nWorking) {
258
+ const pal = this.pal;
259
+ const pods = Math.max(2, Math.ceil((nWorking + 2) / 4));
260
+ const podCols = pods <= 4 ? 2 : 3;
261
+ const podRows = Math.ceil(pods / podCols);
262
+ const OW = 1.3 + (podCols - 1) * 4.2 + 3.9; // open office width
263
+ const OD = podRows * 4.7 + 0.8; // open office depth (pods)
264
+ const D = Math.max(OD + 3.6, 14.5);
265
+ const RW = 7.2; // right column width
266
+ const W = OW + RW;
267
+ const MR = 5.2; // meeting room depth
268
+ const PY = 9.9; // pantry / lounge boundary
269
+ const grid = new Grid(W, D);
270
+ const items = [];
271
+ const pois = [];
272
+ const desks = [];
273
+ const add = (depth, draw) => items.push({ depth, draw });
274
+ // Semantic list of what is where, for renderers that don't use the 2D
275
+ // draw closures (the 3D view).
276
+ const props = [];
277
+ const prop = (t, o) => props.push({ t, ...o });
278
+ const solid = (x, y, w, d, pad) => grid.block(x, y, w, d, pad);
279
+ const poi = (x, y, act, face, opt = {}) => pois.push({ x, y, act, face, sit: 0, ...opt });
280
+
281
+ // Long boxes are split into ~1 tile pieces so depth sorting against
282
+ // people stays right along their length. `extra(x0, x1)` draws things on
283
+ // the front face of each piece (books on a shelf, etc).
284
+ const longBox = (x, y, z, w, d, h, top, left, right, opts = {}) => {
285
+ const n = Math.max(1, Math.ceil(Math.max(w, d) / 0.9));
286
+ for (let k = 0; k < n; k++) {
287
+ const sx = w >= d ? x + (w / n) * k : x, sy = w >= d ? y : y + (d / n) * k;
288
+ const sw = w >= d ? w / n : w, sd = w >= d ? d : d / n;
289
+ add(sx + sw / 2 + sy + sd / 2 + (opts.bias || 0), () => {
290
+ this.box(sx, sy, z, sw, sd, h, top, left, right);
291
+ if (opts.extra) opts.extra(sx, sx + sw);
292
+ });
293
+ }
294
+ };
295
+
296
+ // ---- cubicle pods: 4 desks back to back with a divider
297
+ for (let p = 0; p < pods; p++) {
298
+ const c = p % podCols, r = Math.floor(p / podCols);
299
+ const px = 1.3 + c * 4.2, py = 2.0 + r * 4.7;
300
+ const panel = PANEL[p % PANEL.length];
301
+ for (let k = 0; k < 2; k++) {
302
+ const x = px + k * 1.5;
303
+ desks.push({ x, y: py, side: 'n', seat: { x: x + 0.7, y: py - 0.45 } });
304
+ desks.push({ x, y: py + 0.92, side: 's', seat: { x: x + 0.7, y: py + 0.92 + 1.25 } });
305
+ solid(x, py, 1.4, 0.8, 0.08);
306
+ solid(x, py + 0.92, 1.4, 0.8, 0.08);
307
+ }
308
+ longBox(px - 0.1, py + 0.82, 0, 3.15, 0.08, 1.15, this.shade(panel, 25), panel, this.shade(panel, -15), { bias: 0.05 });
309
+ prop('panel', { x: px - 0.1, y: py + 0.82, w: 3.15, d: 0.08, h: 1.15, color: panel });
310
+ prop('panel', { x: px - 0.18, y: py - 0.05, w: 0.08, d: 1.82, h: 1.15, color: panel });
311
+ add(px - 0.1 + py + 0.86, () => this.box(px - 0.18, py - 0.05, 0, 0.08, 1.82, 1.15, this.shade(panel, 25), panel, this.shade(panel, -15)));
312
+ solid(px - 0.18, py + 0.8, 3.25, 0.12, 0.05);
313
+ }
314
+
315
+ // ---- open office extras
316
+ const rx = OW - 1.15;
317
+ add(rx + 0.35 + 0.5, () => this.serverRack(rx, 0.2));
318
+ prop('rack', { x: rx, y: 0.2 });
319
+ prop('printer', { x: OW - 2.3, y: 0.25 });
320
+ solid(rx, 0.2, 0.7, 0.6);
321
+ add(OW - 2.3 + 0.3 + 0.5, () => this.printer(OW - 2.3, 0.25));
322
+ solid(OW - 2.3, 0.25, 0.75, 0.55);
323
+ for (const [x, y] of [[0.45, 0.45], [0.45, OD - 0.2]]) { add(x + y, () => this.plant(x, y)); prop('plant', { x, y }); solid(x - 0.2, y - 0.2, 0.4, 0.4); }
324
+ const windows = [];
325
+ for (let x = 1.0; x < OW - 2.6; x += 2.4) {
326
+ windows.push(x);
327
+ poi(x + 0.75, 0.75, 'window', 'back');
328
+ }
329
+ poi(0.95, OD - 0.2, 'plant', 'back');
330
+
331
+ // ---- ping-pong corner
332
+ const tx = 2.4, ty = OD + 1.0;
333
+ add(tx + 1.3 + ty + 0.7, () => this.pingpong(tx, ty));
334
+ prop('pingpong', { x: tx, y: ty });
335
+ solid(tx, ty, 2.6, 1.4, 0.05);
336
+ poi(tx - 0.55, ty + 0.7, 'pingpong', 'side', { pair: 'pp' });
337
+ poi(tx + 3.15, ty + 0.7, 'pingpong', 'side', { pair: 'pp' });
338
+ const pp = { x0: tx - 0.2, x1: tx + 2.8, y: ty + 0.7 };
339
+
340
+ // ---- front corner: vending machines, foosball, phone booth
341
+ const fy = OD + 3.7;
342
+ if (D - fy >= 3.4) {
343
+ add(0.5 + fy + 0.4, () => this.vending(0.12, fy, '#c0392b'));
344
+ prop('vending', { x: 0.12, y: fy, color: '#c0392b' });
345
+ prop('vending', { x: 0.12, y: fy + 0.8, color: '#2f6fb0' });
346
+ add(0.5 + fy + 1.2, () => this.vending(0.12, fy + 0.8, '#2f6fb0'));
347
+ solid(0.1, fy, 0.75, 1.55);
348
+ poi(1.3, fy + 0.4, 'vending', 'back');
349
+ poi(1.3, fy + 1.2, 'vending', 'back');
350
+ }
351
+ if (D - fy >= 2.2) {
352
+ const fx = 3.0, fby = fy + 0.4;
353
+ add(fx + 0.8 + fby + 0.4, () => this.foosball(fx, fby));
354
+ prop('foosball', { x: fx, y: fby });
355
+ solid(fx, fby, 1.6, 0.8, 0.05);
356
+ poi(fx - 0.45, fby + 0.4, 'foosball', 'side', { pair: 'fb' });
357
+ poi(fx + 2.05, fby + 0.4, 'foosball', 'side', { pair: 'fb' });
358
+ const bx = OW - 2.0, by = fy;
359
+ this.booth(add, bx, by);
360
+ prop('booth', { x: bx, y: by });
361
+ solid(bx - 0.05, by - 0.05, 1.4, 0.1, 0.03);
362
+ solid(bx - 0.05, by - 0.05, 0.1, 1.4, 0.03);
363
+ solid(bx + 1.25, by - 0.05, 0.1, 1.4, 0.03);
364
+ solid(bx + 0.85, by + 1.25, 0.5, 0.1, 0.03);
365
+ poi(bx + 0.65, by + 0.6, 'phone', 'front', { sit: 0.45 });
366
+ add(OW - 0.45 + D - 0.6, () => this.plant(OW - 0.45, D - 0.6));
367
+ prop('plant', { x: OW - 0.45, y: D - 0.6 });
368
+ }
369
+
370
+ // ---- meeting room behind glass, door at y 3.6–4.6
371
+ const mx = OW;
372
+ for (const [y, d] of [[0, 1], [1, 1], [2, 1], [3, 0.6], [4.6, MR - 4.6]]) this.glassX(add, mx, y, d);
373
+ for (let x = mx; x < W - 0.01; x += 1) this.glassY(add, x, MR, Math.min(1, W - x));
374
+ prop('glass', { x: mx, y: 0, w: 0, d: 3.6 });
375
+ prop('glass', { x: mx, y: 4.6, w: 0, d: MR - 4.6 });
376
+ prop('glass', { x: mx, y: MR, w: RW, d: 0 });
377
+ solid(mx - 0.05, 0, 0.1, 3.6, 0.05);
378
+ solid(mx - 0.05, 4.6, 0.1, MR - 4.6, 0.05);
379
+ solid(mx, MR - 0.05, RW, 0.1, 0.05);
380
+ const mtx = mx + 1.6, mty = 1.7;
381
+ add(mtx + 1.5 + mty + 0.7, () => this.meetingTable(mtx, mty));
382
+ prop('meetingTable', { x: mtx, y: mty, w: 3.0, d: 1.4 });
383
+ solid(mtx, mty, 3.0, 1.4);
384
+ for (let k = 0; k < 3; k++) {
385
+ const cx = mtx + 0.5 + k * 1.0;
386
+ add(cx + mty - 0.45, () => this.stool(cx, mty - 0.4, 0.4, '#555c66'));
387
+ add(cx + mty + 1.78, () => this.stool(cx, mty + 1.8, 0.4, '#555c66'));
388
+ poi(cx, mty - 0.4, 'meeting', 'front', { sit: 0.42 });
389
+ prop('chair', { x: cx, y: mty - 0.4, face: 'front' });
390
+ prop('chair', { x: cx, y: mty + 1.8, face: 'back' });
391
+ poi(cx, mty + 1.8, 'meeting', 'back', { sit: 0.42 });
392
+ }
393
+ poi(mx + 3.6, 0.65, 'whiteboard', 'back');
394
+ add(W - 0.45 + 0.45, () => this.plant(W - 0.45, 0.45));
395
+ prop('plant', { x: W - 0.45, y: 0.45 });
396
+ solid(W - 0.65, 0.25, 0.4, 0.4);
397
+
398
+ // ---- pantry
399
+ const cy = MR + 0.3;
400
+ longBox(mx + 1.0, cy, 0, RW - 2.3, 0.7, 0.9, pal.counter, pal.counterL, pal.counterR, { bias: -0.5 });
401
+ solid(mx + 1.0, cy, RW - 2.3, 0.7);
402
+ prop('counter', { x: mx + 1.0, y: cy, w: RW - 2.3, d: 0.7 });
403
+ prop('coffee', { x: mx + 2.4, y: cy + 0.12 });
404
+ prop('sink', { x: mx + 3.9, y: cy + 0.12 });
405
+ prop('fridge', { x: W - 1.15, y: cy });
406
+ prop('cooler', { x: mx + 0.35, y: cy + 0.05 });
407
+ add(mx + 2.65 + cy + 0.35, () => this.coffeeMachine(mx + 2.4, cy + 0.12));
408
+ add(mx + 4.15 + cy + 0.35, () => this.sink(mx + 3.9, cy + 0.12));
409
+ add(W - 0.75 + cy + 0.4, () => this.fridge(W - 1.15, cy));
410
+ solid(W - 1.15, cy, 0.85, 0.75);
411
+ add(mx + 0.55 + cy + 0.25, () => this.waterCooler(mx + 0.35, cy + 0.05));
412
+ solid(mx + 0.3, cy, 0.5, 0.5);
413
+ poi(mx + 2.65, cy + 1.2, 'coffee', 'back');
414
+ poi(mx + 4.15, cy + 1.2, 'sink', 'back');
415
+ poi(W - 0.75, cy + 1.3, 'fridge', 'back');
416
+ poi(mx + 0.6, cy + 0.95, 'water', 'back');
417
+ const rtx = mx + 3.6, rty = MR + 2.9;
418
+ add(rtx + rty, () => this.roundTable(rtx, rty));
419
+ prop('roundTable', { x: rtx, y: rty });
420
+ solid(rtx - 0.45, rty - 0.45, 0.9, 0.9);
421
+ for (const [sx, sy, face] of [[rtx - 1.0, rty, 'front'], [rtx + 1.0, rty, 'front'], [rtx, rty + 1.0, 'back'], [rtx, rty - 0.95, 'front']]) {
422
+ add(sx + sy - 0.02, () => this.stool(sx, sy, 0.45, '#c9a24c'));
423
+ poi(sx, sy, 'stool', face, { sit: 0.47 });
424
+ prop('barStool', { x: sx, y: sy });
425
+ }
426
+
427
+ // ---- lounge
428
+ const lx = mx + 0.6, ly = PY + 0.4;
429
+ const shelfX = mx + 3.4, shelfW = RW - 3.6;
430
+ longBox(shelfX, PY - 0.25, 0, shelfW, 0.45, 1.45, this.shade(pal.deskTop, -5), pal.deskL, pal.deskR, {
431
+ extra: (x0, x1) => this.books(x0, x1, PY + 0.2),
432
+ });
433
+ solid(shelfX, PY - 0.25, shelfW, 0.45);
434
+ prop('shelf', { x: shelfX, y: PY - 0.25, w: shelfW, d: 0.45 });
435
+ poi(mx + 4.6, PY + 0.75, 'read', 'back');
436
+ poi(mx + 5.8, PY + 0.75, 'read', 'back');
437
+ add(lx + ly + 0.6, () => this.couch(lx, ly + 0.6));
438
+ prop('couch', { x: lx, y: ly + 0.6, w: 2.8, d: 0.8 });
439
+ prop('coffeeTable', { x: lx + 0.6, y: ly + 2.0, w: 1.6, d: 0.7 });
440
+ prop('beanbag', { x: lx + 0.4, y: ly + 3.6, color: '#c96442' });
441
+ prop('beanbag', { x: lx + 2.6, y: ly + 3.6, color: '#7d93c4' });
442
+ solid(lx, ly + 0.6, 2.8, 0.8);
443
+ for (let k = 0; k < 3; k++) poi(lx + 0.5 + k * 0.9, ly + 1.2, 'couch', 'front', { sit: 0.36 });
444
+ add(lx + 1.4 + ly + 2.35, () => this.coffeeTable(lx + 0.6, ly + 2.0));
445
+ solid(lx + 0.6, ly + 2.0, 1.6, 0.7);
446
+ add(lx + 0.4 + ly + 3.6, () => this.beanbag(lx + 0.4, ly + 3.6, '#c96442'));
447
+ add(lx + 2.6 + ly + 3.6, () => this.beanbag(lx + 2.6, ly + 3.6, '#7d93c4'));
448
+ poi(lx + 0.4, ly + 3.6, 'beanbag', 'front', { sit: 0.18 });
449
+ poi(lx + 2.6, ly + 3.6, 'beanbag', 'front', { sit: 0.18 });
450
+ const ax = W - 1.2, ay = ly + 1.0;
451
+ add(ax + 0.35 + ay + 0.35, () => this.arcade(ax, ay));
452
+ prop('arcade', { x: ax, y: ay });
453
+ prop('plant', { x: W - 0.5, y: D - 0.5 });
454
+ prop('lamp', { x: lx - 0.2, y: D - 0.6 });
455
+ prop('rug', { x: OW + 0.3, y: PY + 0.6, w: 4.0, d: D - PY - 1.0 });
456
+ solid(ax, ay, 0.7, 0.7);
457
+ poi(ax + 0.35, ay + 1.15, 'arcade', 'back');
458
+ add(W - 0.5 + D - 0.5, () => this.plant(W - 0.5, D - 0.5));
459
+ add(lx - 0.2 + D - 0.6, () => this.floorLamp(lx - 0.2, D - 0.6));
460
+
461
+ const posters = [
462
+ { y: 2.2, color: '#c96442', text: 'SHIP IT' },
463
+ { y: 4.6, color: '#10a37f', text: 'TESTS ✓' },
464
+ ];
465
+ return { W, D, OW, OD, MR, PY, RW, grid, items, props, pois, desks, windows, posters, pp, door: { x: 0.4, y: D - 1.2 } };
466
+ }
467
+
468
+ glassX(add, x, y, d) {
469
+ add(x + y + d / 2 + 0.02, () => {
470
+ const P = (a, b, c) => this.iso(a, b, c);
471
+ this.poly([P(x, y, 0), P(x, y + d, 0), P(x, y + d, 0.22), P(x, y, 0.22)], this.pal.trim);
472
+ this.poly([P(x, y, 0.22), P(x, y + d, 0.22), P(x, y + d, 2.1), P(x, y, 2.1)], this.pal.glass, this.pal.glassEdge);
473
+ });
474
+ }
475
+
476
+ glassY(add, x, y, w) {
477
+ add(x + w / 2 + y + 0.02, () => {
478
+ const P = (a, b, c) => this.iso(a, b, c);
479
+ this.poly([P(x, y, 0), P(x + w, y, 0), P(x + w, y, 0.22), P(x, y, 0.22)], this.pal.trim);
480
+ this.poly([P(x, y, 0.22), P(x + w, y, 0.22), P(x + w, y, 2.1), P(x, y, 2.1)], this.pal.glass, this.pal.glassEdge);
481
+ });
482
+ }
483
+
484
+ // ------------------------------------------------------------ sessions → people
485
+
486
+ update(sessions) {
487
+ const working = sessions.filter((s) => WORKING.has(s.status));
488
+ const off = sessions
489
+ .filter((s) => !WORKING.has(s.status))
490
+ .sort((a, b) => (b.lastEventAt || 0) - (a.lastEventAt || 0));
491
+ const wanderers = off.slice(0, MAX_WANDERERS);
492
+ this.offDuty = off.length - wanderers.length;
493
+
494
+ const cap = this.plan.desks.length;
495
+ // Scene packs have a fixed floor plan; the built-in office grows with the team.
496
+ if (!this.plan.fixed && (working.length > cap - 1 || (cap > 8 && working.length < cap * 0.35))) {
497
+ this.plan = this.buildPlan(working.length);
498
+ this.deskOf.clear();
499
+ for (const p of this.people.values()) { p.poi = null; p.napDesk = null; p.desk = null; p.until = 0; }
500
+ }
501
+
502
+ const workKeys = new Set(working.map((s) => s.key));
503
+ for (const k of [...this.deskOf.keys()]) if (!workKeys.has(k)) this.deskOf.delete(k);
504
+ const used = new Set(this.deskOf.values());
505
+ // Fill the desks that face us first so more faces and screens show.
506
+ const desks = this.plan.desks;
507
+ const order = desks.map((d, i) => i).sort((a, b) => (desks[a].side === 's') - (desks[b].side === 's') || a - b);
508
+ for (const s of [...working].sort((a, b) => (a.startedAt || 0) - (b.startedAt || 0))) {
509
+ if (this.deskOf.has(s.key)) continue;
510
+ const i = order.find((n) => !used.has(n));
511
+ if (i == null) continue;
512
+ this.deskOf.set(s.key, i);
513
+ used.add(i);
514
+ }
515
+ const takenDesks = new Set([...this.deskOf.values()].map((i) => desks[i]));
516
+
517
+ const shown = new Set();
518
+ for (const s of [...working, ...wanderers]) {
519
+ const isWorker = this.deskOf.has(s.key);
520
+ if (!isWorker && WORKING.has(s.status)) continue; // no desk left
521
+ shown.add(s.key);
522
+ let p = this.people.get(s.key);
523
+ if (!p) { p = this.newPerson(s); this.people.set(s.key, p); }
524
+ p.s = s;
525
+ p.leaving = false;
526
+ if (isWorker) {
527
+ this.release(p);
528
+ const desk = desks[this.deskOf.get(s.key)];
529
+ if (p.desk !== desk || p.mode !== 'work') { p.desk = desk; this.goTo(p, desk.seat); }
530
+ p.mode = 'work';
531
+ } else {
532
+ if (p.mode !== 'wander') { p.mode = 'wander'; p.desk = null; p.poi = null; p.until = 0; }
533
+ // Someone started working at the desk this one was napping on.
534
+ if (p.napDesk && takenDesks.has(p.napDesk)) { this.release(p); p.path = []; }
535
+ }
536
+ }
537
+ for (const [k, p] of this.people) {
538
+ if (!shown.has(k) && !p.leaving) {
539
+ p.leaving = true;
540
+ p.mode = 'leave';
541
+ this.release(p);
542
+ p.desk = null;
543
+ this.goTo(p, this.plan.door);
544
+ }
545
+ }
546
+ this.first = false;
547
+ }
548
+
549
+ release(p) {
550
+ if (p.poi) { p.poi.taken = null; p.poi = null; }
551
+ if (p.napDesk) { p.napDesk.napper = null; p.napDesk = null; }
552
+ }
553
+
554
+ newPerson(s) {
555
+ const h = hash(s.key);
556
+ const door = this.plan.door;
557
+ return {
558
+ key: s.key, h, s,
559
+ skin: pick(SKIN, h, 3), hair: pick(HAIR, h, 7), pants: pick(PANTS, h, 13),
560
+ shirt: pick(SHIRT[s.agent] || SHIRT.claude, h, 11),
561
+ hairStyle: h % 4,
562
+ phase: (h % 1000) / 1000 * Math.PI * 2,
563
+ x: door.x, y: door.y,
564
+ path: [], walkFace: 'front', until: 0, mode: null,
565
+ // On the first snapshot everyone is placed directly, no stampede.
566
+ placeOnArrive: this.first,
567
+ };
568
+ }
569
+
570
+ goTo(p, target) {
571
+ if (p.placeOnArrive) {
572
+ p.x = target.x; p.y = target.y; p.path = []; p.placeOnArrive = false;
573
+ return;
574
+ }
575
+ p.path = this.plan.grid.path(p.x, p.y, target.x, target.y);
576
+ }
577
+
578
+ wander(p, now) {
579
+ if (p.path.length) return;
580
+ if (p.poi && now < p.until) return;
581
+ this.release(p);
582
+ const plan = this.plan;
583
+ const workingDesks = new Set([...this.deskOf.values()].map((i) => plan.desks[i]));
584
+ const sleepy = p.s.status === 'idle';
585
+ const weighted = [];
586
+ for (const q of plan.pois) {
587
+ if (q.taken) continue;
588
+ let w = 1;
589
+ if (q.pair) w = plan.pois.some((o) => o.pair === q.pair && o !== q && o.taken) ? 7 : 1.2;
590
+ if (q.act === 'couch' || q.act === 'beanbag' || q.sleepy) w = sleepy ? 3 : 1.4;
591
+ weighted.push([q, w]);
592
+ }
593
+ for (const d of plan.noNap ? [] : plan.desks) {
594
+ if (workingDesks.has(d) || d.napper) continue;
595
+ weighted.push([{ x: d.seat.x, y: d.seat.y, act: 'nap', face: d.side === 'n' ? 'front' : 'back', sit: 0.34, desk: d }, sleepy ? 3 : 0.25]);
596
+ }
597
+ if (!weighted.length) return;
598
+ let r = Math.random() * weighted.reduce((a, [, w]) => a + w, 0);
599
+ let choice = weighted[0][0];
600
+ for (const [q, w] of weighted) { r -= w; if (r <= 0) { choice = q; break; } }
601
+ if (choice.desk) { choice.desk.napper = p.key; p.napDesk = choice.desk; }
602
+ choice.taken = p.key;
603
+ p.poi = choice;
604
+ p.until = Infinity; // set on arrival
605
+ p.stay = (choice.act === 'nap' ? rand(25, 60) : choice.act === 'pingpong' ? rand(15, 30) : rand(8, 20)) * 1000;
606
+ this.goTo(p, choice);
607
+ }
608
+
609
+ setVisible(v) { this.visible = v; if (v) this.resize(); }
610
+
611
+ // Room on the right covered by an overlay (the agent panel). The canvas
612
+ // keeps its size; the view eases over so the room stays centred in what
613
+ // is still visible, instead of the canvas resizing under the slide.
614
+ setInset(px, animate = true) {
615
+ const now = performance.now();
616
+ this.insetFrom = animate ? this.inset(now) : px;
617
+ this.insetTo = px;
618
+ this.insetT0 = animate ? now : -Infinity;
619
+ }
620
+ inset(now = performance.now()) {
621
+ if (this.insetTo == null) return 0;
622
+ const k = Math.min(1, (now - this.insetT0) / 340);
623
+ const e = 1 - Math.pow(1 - k, 3); // ease out, like the panel's slide
624
+ return this.insetFrom + (this.insetTo - this.insetFrom) * e;
625
+ }
626
+ insetMoving(now = performance.now()) { return this.insetTo != null && now - this.insetT0 < 360; }
627
+
628
+ // ------------------------------------------------------------ camera / input
629
+
630
+ resize() {
631
+ const r = this.root.getBoundingClientRect();
632
+ const dpr = Math.min(2, window.devicePixelRatio || 1);
633
+ this.cw = Math.max(this.compact ? 1 : 200, r.width);
634
+ this.ch = Math.max(this.compact ? 1 : 240, r.height);
635
+ this.canvas.width = Math.round(this.cw * dpr);
636
+ this.canvas.height = Math.round(this.ch * dpr);
637
+ this.canvas.style.width = this.cw + 'px';
638
+ this.canvas.style.height = this.ch + 'px';
639
+ this.dpr = dpr;
640
+ }
641
+
642
+ fitCamera() {
643
+ const { W, D } = this.plan;
644
+ const left = this.iso(0, D, 0).x, right = this.iso(W, 0, 0).x;
645
+ const top = this.iso(0, 0, WALL_H).y - 20, bottom = this.iso(W, D, 0).y + 10;
646
+ const inset = this.inset();
647
+ const vw = Math.max(120, this.cw - inset);
648
+ const zoom = Math.min(vw / (right - left + 30), this.ch / (bottom - top + 30), 2);
649
+ const want = { zoom, ox: vw / 2 - ((left + right) / 2) * zoom, oy: this.ch / 2 - ((top + bottom) / 2) * zoom };
650
+ // While the inset eases, follow it exactly; otherwise settle gently.
651
+ if (!this.cam || this.insetMoving()) this.cam = want;
652
+ else for (const k of ['zoom', 'ox', 'oy']) this.cam[k] += (want[k] - this.cam[k]) * 0.1;
653
+ }
654
+
655
+ pointer(e, click) {
656
+ const r = this.canvas.getBoundingClientRect();
657
+ const mx = e.clientX - r.left, my = e.clientY - r.top;
658
+ let found = null;
659
+ for (let i = this.hit.length - 1; i >= 0; i--) {
660
+ const h = this.hit[i];
661
+ if (mx >= h.x0 && mx <= h.x1 && my >= h.y0 && my <= h.y1) { found = h; break; }
662
+ }
663
+ const key = found ? found.key : null;
664
+ this.canvas.style.cursor = key ? 'pointer' : 'default';
665
+ if (key !== this.hoverKey || click) {
666
+ this.hoverKey = key;
667
+ const p = key && this.people.get(key);
668
+ this.onHover(p ? p.s : null, { x: e.clientX, y: e.clientY }, !!click);
669
+ }
670
+ }
671
+
672
+ iso(x, y, z = 0) {
673
+ return { x: (x - y) * TW / 2, y: (x + y) * TH / 2 - z * TH };
674
+ }
675
+
676
+ // ------------------------------------------------------------ primitives
677
+
678
+ poly(pts, fill, stroke) {
679
+ const c = this.ctx;
680
+ c.beginPath();
681
+ pts.forEach((p, i) => (i ? c.lineTo(p.x, p.y) : c.moveTo(p.x, p.y)));
682
+ c.closePath();
683
+ if (fill) { c.fillStyle = fill; c.fill(); }
684
+ if (stroke) { c.strokeStyle = stroke; c.lineWidth = 1; c.stroke(); }
685
+ }
686
+
687
+ box(x, y, z, w, d, h, top, left, right) {
688
+ const P = (a, b, cc) => this.iso(a, b, cc);
689
+ this.poly([P(x, y + d, z), P(x + w, y + d, z), P(x + w, y + d, z + h), P(x, y + d, z + h)], left);
690
+ this.poly([P(x + w, y, z), P(x + w, y + d, z), P(x + w, y + d, z + h), P(x + w, y, z + h)], right);
691
+ this.poly([P(x, y, z + h), P(x + w, y, z + h), P(x + w, y + d, z + h), P(x, y + d, z + h)], top);
692
+ }
693
+
694
+ shade(color, amt) {
695
+ if (color[0] !== '#') return color;
696
+ const n = parseInt(color.slice(1), 16);
697
+ const f = (v) => clamp(Math.round(v + amt), 0, 255);
698
+ return `rgb(${f(n >> 16)},${f((n >> 8) & 255)},${f(n & 255)})`;
699
+ }
700
+
701
+ roundRect(x, y, w, h, r, fill, stroke) {
702
+ const c = this.ctx;
703
+ c.beginPath();
704
+ c.moveTo(x + r, y);
705
+ c.arcTo(x + w, y, x + w, y + h, r);
706
+ c.arcTo(x + w, y + h, x, y + h, r);
707
+ c.arcTo(x, y + h, x, y, r);
708
+ c.arcTo(x, y, x + w, y, r);
709
+ c.closePath();
710
+ if (fill) { c.fillStyle = fill; c.fill(); }
711
+ if (stroke) { c.strokeStyle = stroke; c.lineWidth = 1.5; c.stroke(); }
712
+ }
713
+
714
+ // Draw flat things on a wall: 'y0' = back wall (runs along x),
715
+ // 'x0' = left wall (runs along y).
716
+ onWall(dir, a, z, fn) {
717
+ const c = this.ctx;
718
+ const o = dir === 'y0' ? this.iso(a, 0, z) : this.iso(0, a, z);
719
+ c.save();
720
+ c.translate(o.x, o.y);
721
+ c.transform(1, dir === 'y0' ? 0.5 : -0.5, 0, 1, 0, 0);
722
+ fn(c);
723
+ c.restore();
724
+ }
725
+
726
+ // ------------------------------------------------------------ room shell
727
+
728
+ drawRoom() {
729
+ const { W, D, OW, MR, PY, pp } = this.plan;
730
+ const pal = this.pal;
731
+ const P = (x, y, z) => this.iso(x, y, z);
732
+ const t = this.t / 1000;
733
+
734
+ // floors: wood office, carpet meeting room, tiled pantry, wood lounge
735
+ for (let x = 0; x < Math.ceil(OW); x++) {
736
+ for (let y = 0; y < Math.ceil(D); y++) {
737
+ const x1 = Math.min(OW, x + 1), y1 = Math.min(D, y + 1);
738
+ this.poly([P(x, y), P(x1, y), P(x1, y1), P(x, y1)], (x + y) % 2 ? pal.wood1 : pal.wood2);
739
+ }
740
+ }
741
+ for (let y = 0.5; y < D; y += 0.5) this.poly([P(0, y), P(OW, y), P(OW, y + 0.012), P(0, y + 0.012)], pal.woodLine);
742
+ this.poly([P(OW, 0), P(W, 0), P(W, MR), P(OW, MR)], pal.carpet);
743
+ for (let x = OW + 0.5; x < W; x += 0.5) this.poly([P(x, 0), P(x + 0.015, 0), P(x + 0.015, MR), P(x, MR)], pal.carpetLine);
744
+ for (let i = 0; i < Math.ceil((W - OW) * 2); i++) {
745
+ for (let j = 0; j < Math.ceil((PY - MR) * 2); j++) {
746
+ const x = OW + i / 2, y = MR + j / 2;
747
+ const x1 = Math.min(W, x + 0.5), y1 = Math.min(PY, y + 0.5);
748
+ this.poly([P(x, y), P(x1, y), P(x1, y1), P(x, y1)], (i + j) % 2 ? pal.tileA : pal.tileB);
749
+ }
750
+ }
751
+ for (let x = Math.floor(OW); x < W; x++) {
752
+ for (let y = PY; y < D; y++) {
753
+ const x0 = Math.max(OW, x), x1 = Math.min(W, x + 1), y1 = Math.min(D, y + 1);
754
+ this.poly([P(x0, y), P(x1, y), P(x1, y1), P(x0, y1)], (x + Math.floor(y)) % 2 ? pal.wood1 : pal.wood2);
755
+ }
756
+ }
757
+ this.poly([P(OW + 0.3, PY + 0.6), P(OW + 4.3, PY + 0.6), P(OW + 4.3, D - 0.4), P(OW + 0.3, D - 0.4)], pal.rug);
758
+ this.poly([P(OW + 0.5, PY + 0.8), P(OW + 4.1, PY + 0.8), P(OW + 4.1, D - 0.6), P(OW + 0.5, D - 0.6)], null, pal.rugEdge);
759
+ this.poly([P(pp.x0 - 0.6, pp.y - 1.4), P(pp.x1 + 0.6, pp.y - 1.4), P(pp.x1 + 0.6, pp.y + 1.3), P(pp.x0 - 0.6, pp.y + 1.3)], pal.mat);
760
+
761
+ // walls
762
+ this.poly([P(0, 0, 0), P(W, 0, 0), P(W, 0, WALL_H), P(0, 0, WALL_H)], pal.wallL);
763
+ this.poly([P(0, 0, 0), P(0, D, 0), P(0, D, WALL_H), P(0, 0, WALL_H)], pal.wallR);
764
+ this.poly([P(0, 0, WALL_H), P(W, 0, WALL_H), P(W, -0.15, WALL_H), P(-0.15, -0.15, WALL_H), P(-0.15, D, WALL_H), P(0, D, WALL_H)], pal.wallTop);
765
+ this.poly([P(0, 0, 0), P(W, 0, 0), P(W, 0, 0.12), P(0, 0, 0.12)], pal.trim);
766
+ this.poly([P(0, 0, 0), P(0, D, 0), P(0, D, 0.12), P(0, 0, 0.12)], pal.trim);
767
+
768
+ // windows with the real sky outside
769
+ const sky = skyColor();
770
+ for (const x of this.plan.windows) {
771
+ this.poly([P(x, 0, 0.85), P(x + 1.5, 0, 0.85), P(x + 1.5, 0, 2.0), P(x, 0, 2.0)], sky.sky);
772
+ if (sky.night) {
773
+ this.onWall('y0', x + 0.3, 1.75, (c) => { c.fillStyle = '#f4f1d0'; c.beginPath(); c.arc(0, 0, 3, 0, Math.PI * 2); c.fill(); });
774
+ this.onWall('y0', x + 1.1, 1.55, (c) => { c.fillStyle = 'rgba(255,255,255,.7)'; c.fillRect(0, 0, 1.5, 1.5); });
775
+ } else {
776
+ const cx = x + 0.25 + ((t * 0.02 + x * 0.37) % 1) * 1.0;
777
+ this.onWall('y0', cx, 1.65, (c) => {
778
+ c.fillStyle = 'rgba(255,255,255,.85)';
779
+ c.beginPath(); c.ellipse(0, 0, 7, 3, 0, 0, Math.PI * 2); c.ellipse(5, -2, 5, 3, 0, 0, Math.PI * 2); c.fill();
780
+ });
781
+ }
782
+ this.poly([P(x + 0.74, 0, 0.85), P(x + 0.76, 0, 0.85), P(x + 0.76, 0, 2.0), P(x + 0.74, 0, 2.0)], pal.trim);
783
+ this.poly([P(x, 0, 0.85), P(x + 1.5, 0, 0.85), P(x + 1.5, 0, 0.9), P(x, 0, 0.9)], pal.trim);
784
+ this.poly([P(x, 0.05), P(x + 1.5, 0.05), P(x + 1.95, 1.2), P(x + 0.45, 1.2)], sky.night ? 'rgba(120,140,200,.06)' : 'rgba(255,250,230,.16)');
785
+ }
786
+ // meeting room whiteboard + sign
787
+ const wx = OW + 2.4;
788
+ this.poly([P(wx, 0, 0.8), P(wx + 2.6, 0, 0.8), P(wx + 2.6, 0, 1.95), P(wx, 0, 1.95)], '#fdfdfb', pal.trim);
789
+ this.onWall('y0', wx + 0.25, 1.75, (c) => {
790
+ c.lineWidth = 1.4;
791
+ c.strokeStyle = '#3b8ee0';
792
+ c.beginPath(); c.moveTo(0, 0); c.lineTo(22, 0); c.moveTo(0, 7); c.lineTo(36, 7); c.stroke();
793
+ c.strokeStyle = '#c96442';
794
+ c.strokeRect(46, -2, 20, 12); c.strokeRect(80, 6, 20, 12);
795
+ c.beginPath(); c.moveTo(66, 4); c.lineTo(80, 12); c.stroke();
796
+ c.strokeStyle = '#5f9a4c';
797
+ c.beginPath(); c.moveTo(0, 22); for (let i = 0; i < 8; i++) c.lineTo(i * 8, 22 - ((i * 37) % 13)); c.stroke();
798
+ });
799
+ this.onWall('y0', OW + 0.5, 2.2, (c) => {
800
+ c.font = '700 9px ui-sans-serif, system-ui, sans-serif'; c.fillStyle = this.shade(pal.trim, -60); c.fillText('MEETING', 0, 0);
801
+ });
802
+ // left wall: posters, clock, door
803
+ for (const ps of this.plan.posters) {
804
+ this.poly([P(0, ps.y, 0.95), P(0, ps.y + 0.9, 0.95), P(0, ps.y + 0.9, 1.95), P(0, ps.y, 1.95)], ps.color);
805
+ this.onWall('x0', ps.y + 0.45, 1.4, (c) => {
806
+ c.font = '800 8px ui-sans-serif, system-ui, sans-serif'; c.fillStyle = '#fff'; c.textAlign = 'center'; c.fillText(ps.text, 0, 0);
807
+ });
808
+ }
809
+ this.onWall('x0', 1.0, 1.85, (c) => {
810
+ c.beginPath(); c.arc(0, 0, 11, 0, Math.PI * 2); c.fillStyle = pal.bubble; c.fill();
811
+ c.lineWidth = 2; c.strokeStyle = pal.trim; c.stroke();
812
+ const d = new Date();
813
+ const hand = (ang, len, w) => { c.beginPath(); c.moveTo(0, 0); c.lineTo(Math.sin(ang) * len, -Math.cos(ang) * len); c.lineWidth = w; c.strokeStyle = pal.bubbleInk; c.stroke(); };
814
+ hand(((d.getHours() % 12) + d.getMinutes() / 60) / 12 * Math.PI * 2, 5.5, 2);
815
+ hand((d.getMinutes() / 60) * Math.PI * 2, 8, 1.4);
816
+ });
817
+ const dy = this.plan.door.y;
818
+ this.poly([P(0, dy - 0.6, 0), P(0, dy + 0.6, 0), P(0, dy + 0.6, 1.8), P(0, dy - 0.6, 1.8)], pal.trim);
819
+ this.poly([P(0, dy - 0.52, 0), P(0, dy + 0.52, 0), P(0, dy + 0.52, 1.72), P(0, dy - 0.52, 1.72)], this.shade(pal.deskL, -10));
820
+ this.onWall('x0', dy - 0.25, 0.85, (c) => { c.fillStyle = '#d8c48a'; c.beginPath(); c.arc(0, 0, 1.8, 0, Math.PI * 2); c.fill(); });
821
+ this.poly([P(0.05, dy - 0.6), P(0.9, dy - 0.6), P(0.9, dy + 0.6), P(0.05, dy + 0.6)], pal.dark ? '#5a3b2e' : '#b9654a');
822
+ }
823
+
824
+ // ------------------------------------------------------------ furniture
825
+
826
+ plant(x, y) {
827
+ const pal = this.pal;
828
+ this.box(x - 0.18, y - 0.18, 0, 0.36, 0.36, 0.3, this.shade(pal.pot, 10), pal.pot, this.shade(pal.pot, -15));
829
+ const c = this.ctx;
830
+ const b = this.iso(x, y, 0.3);
831
+ const sway = Math.sin(this.t / 1400 + x) * 0.05;
832
+ c.fillStyle = pal.plant;
833
+ for (let i = 0; i < 6; i++) {
834
+ const a = (i / 6) * Math.PI * 2;
835
+ c.beginPath();
836
+ c.ellipse(b.x + Math.cos(a) * 6, b.y - 13 + Math.sin(a) * 3, 5.5, 13, a / 3 + sway, 0, Math.PI * 2);
837
+ c.fill();
838
+ }
839
+ c.fillStyle = this.shade(pal.plant, 22);
840
+ c.beginPath(); c.ellipse(b.x, b.y - 20, 5, 10, sway, 0, Math.PI * 2); c.fill();
841
+ }
842
+
843
+ serverRack(x, y) {
844
+ this.box(x, y, 0, 0.7, 0.6, 1.9, '#3a3d44', '#24262b', '#1b1d21');
845
+ const c = this.ctx;
846
+ const busy = this.deskOf.size;
847
+ for (let r = 0; r < 7; r++) {
848
+ for (let k = 0; k < 4; k++) {
849
+ const on = Math.sin(this.t / (180 + r * 37 + k * 53) + r * k) > 0.2 && (r * 4 + k) < Math.max(4, busy * 3);
850
+ const p = this.iso(x + 0.12 + k * 0.13, y + 0.6, 1.7 - r * 0.22);
851
+ c.fillStyle = on ? (k % 3 === 0 ? '#ffb347' : '#5cf08a') : '#2f3a33';
852
+ c.fillRect(p.x - 1, p.y - 1, 2.5, 2);
853
+ }
854
+ }
855
+ }
856
+
857
+ printer(x, y) {
858
+ const pal = this.pal;
859
+ this.box(x, y, 0, 0.75, 0.55, 0.55, pal.deskTop, pal.deskL, pal.deskR);
860
+ this.box(x + 0.05, y + 0.05, 0.55, 0.65, 0.45, 0.28, '#e8e8e8', '#c8c8c8', '#b3b3b3');
861
+ this.box(x + 0.15, y + 0.35, 0.83, 0.45, 0.15, 0.02, '#ffffff', '#eee', '#ddd');
862
+ }
863
+
864
+ pingpong(x, y) {
865
+ for (const [lx, ly] of [[0.1, 0.1], [2.4, 0.1], [0.1, 1.2], [2.4, 1.2]]) this.box(x + lx, y + ly, 0, 0.08, 0.08, 0.7, '#333', '#333', '#222');
866
+ this.box(x, y, 0.7, 2.6, 1.4, 0.06, '#2f7a52', '#245f40', '#1d4f35');
867
+ const P = (a, b, c) => this.iso(a, b, c);
868
+ this.poly([P(x + 0.04, y + 0.69, 0.765), P(x + 2.56, y + 0.69, 0.765), P(x + 2.56, y + 0.71, 0.765), P(x + 0.04, y + 0.71, 0.765)], '#ffffff');
869
+ this.poly([P(x + 1.3, y - 0.05, 0.76), P(x + 1.3, y + 1.45, 0.76), P(x + 1.3, y + 1.45, 0.95), P(x + 1.3, y - 0.05, 0.95)], 'rgba(255,255,255,.55)', '#ddd');
870
+ }
871
+
872
+ meetingTable(x, y) {
873
+ const pal = this.pal;
874
+ this.box(x + 0.3, y + 0.5, 0, 0.12, 0.4, 0.7, pal.deskL, pal.deskL, pal.deskR);
875
+ this.box(x + 2.6, y + 0.5, 0, 0.12, 0.4, 0.7, pal.deskL, pal.deskL, pal.deskR);
876
+ this.box(x, y, 0.7, 3.0, 1.4, 0.08, this.shade(pal.deskTop, 12), pal.deskL, pal.deskR);
877
+ this.box(x + 1.2, y + 0.45, 0.78, 0.5, 0.35, 0.03, '#2a2a2e', '#222', '#1a1a1a');
878
+ this.box(x + 0.4, y + 0.3, 0.78, 0.2, 0.28, 0.02, '#fff', '#eee', '#ddd');
879
+ this.box(x + 2.2, y + 0.8, 0.78, 0.12, 0.12, 0.12, '#f2f2f2', '#d0d0d0', '#bbb');
880
+ }
881
+
882
+ stool(x, y, h, color) {
883
+ this.box(x - 0.04, y - 0.04, 0, 0.08, 0.08, h, '#555', '#444', '#333');
884
+ this.box(x - 0.2, y - 0.2, h, 0.4, 0.4, 0.07, this.shade(color, 18), color, this.shade(color, -18));
885
+ }
886
+
887
+ roundTable(x, y) {
888
+ const c = this.ctx;
889
+ this.box(x - 0.05, y - 0.05, 0, 0.1, 0.1, 0.72, '#555', '#444', '#333');
890
+ const p = this.iso(x, y, 0.75);
891
+ c.fillStyle = this.shade(this.pal.deskTop, -15);
892
+ c.beginPath(); c.ellipse(p.x, p.y + 3, 30, 15, 0, 0, Math.PI * 2); c.fill();
893
+ c.fillStyle = this.shade(this.pal.deskTop, 15);
894
+ c.beginPath(); c.ellipse(p.x, p.y, 30, 15, 0, 0, Math.PI * 2); c.fill();
895
+ c.fillStyle = '#fff';
896
+ c.beginPath(); c.ellipse(p.x + 8, p.y - 2, 5, 2.5, 0, 0, Math.PI * 2); c.fill();
897
+ c.fillStyle = '#e6b35c';
898
+ c.beginPath(); c.arc(p.x + 8, p.y - 3, 2, 0, Math.PI * 2); c.fill();
899
+ }
900
+
901
+ coffeeMachine(x, y) {
902
+ this.box(x, y, 0.9, 0.5, 0.45, 0.6, '#555b63', '#3a3f45', '#2e3237');
903
+ this.box(x + 0.15, y + 0.42, 1.0, 0.2, 0.05, 0.15, '#222', '#111', '#111');
904
+ const p = this.iso(x + 0.4, y + 0.45, 1.35);
905
+ this.ctx.fillStyle = Math.sin(this.t / 400) > 0 ? '#ff6b4a' : '#7a3a2a';
906
+ this.ctx.fillRect(p.x, p.y, 2.5, 2.5);
907
+ this.box(x + 0.6, y + 0.1, 0.9, 0.14, 0.14, 0.14, '#fff', '#ddd', '#ccc');
908
+ }
909
+
910
+ sink(x, y) {
911
+ this.box(x, y + 0.05, 0.9, 0.55, 0.4, 0.02, '#9aa3ad', '#7d858e', '#6c737b');
912
+ this.box(x + 0.22, y + 0.02, 0.92, 0.06, 0.06, 0.3, this.pal.metal, this.pal.metal, '#888');
913
+ this.box(x + 0.22, y + 0.02, 1.2, 0.06, 0.2, 0.04, this.pal.metal, this.pal.metal, '#888');
914
+ }
915
+
916
+ fridge(x, y) {
917
+ this.box(x, y, 0, 0.85, 0.75, 1.95, '#eef1f4', '#cfd5db', '#bcc3ca');
918
+ const P = (a, b, c) => this.iso(a, b, c);
919
+ this.poly([P(x, y + 0.75, 1.25), P(x + 0.85, y + 0.75, 1.25), P(x + 0.85, y + 0.75, 1.27), P(x, y + 0.75, 1.27)], '#9aa3ad');
920
+ this.poly([P(x + 0.7, y + 0.75, 1.4), P(x + 0.73, y + 0.75, 1.4), P(x + 0.73, y + 0.75, 1.75), P(x + 0.7, y + 0.75, 1.75)], '#888');
921
+ for (const [mx, mz, col] of [[0.2, 1.6, '#c96442'], [0.4, 1.45, '#3b8ee0'], [0.3, 0.9, '#5f9a4c']]) {
922
+ const p = P(x + mx, y + 0.75, mz);
923
+ this.ctx.fillStyle = col; this.ctx.fillRect(p.x, p.y, 4, 4);
924
+ }
925
+ }
926
+
927
+ waterCooler(x, y) {
928
+ this.box(x, y, 0, 0.42, 0.42, 0.95, '#e8ecef', '#cfd5db', '#bcc3ca');
929
+ const c = this.ctx;
930
+ const p = this.iso(x + 0.21, y + 0.21, 0.95);
931
+ c.fillStyle = 'rgba(120,180,240,.6)';
932
+ c.beginPath(); c.ellipse(p.x, p.y - 9, 8, 11, 0, 0, Math.PI * 2); c.fill();
933
+ c.fillStyle = 'rgba(255,255,255,.5)';
934
+ c.fillRect(p.x - 4, p.y - 16, 2, 10);
935
+ const k = (this.t / 1800) % 1;
936
+ c.fillStyle = 'rgba(255,255,255,.85)';
937
+ c.beginPath(); c.arc(p.x + 2, p.y - 2 - k * 16, 1.2, 0, Math.PI * 2); c.fill();
938
+ const tap = this.iso(x + 0.21, y + 0.42, 0.65);
939
+ c.fillStyle = '#3b8ee0'; c.fillRect(tap.x - 1.5, tap.y, 3, 3);
940
+ }
941
+
942
+ couch(x, y) {
943
+ const pal = this.pal;
944
+ this.box(x, y - 0.05, 0.32, 2.8, 0.25, 0.42, this.shade(pal.couch, 10), pal.couchD, this.shade(pal.couchD, -12));
945
+ this.box(x, y, 0, 2.8, 0.8, 0.32, pal.couch, pal.couchD, this.shade(pal.couchD, -12));
946
+ this.box(x - 0.15, y - 0.05, 0, 0.18, 0.85, 0.52, this.shade(pal.couch, 6), pal.couchD, this.shade(pal.couchD, -12));
947
+ this.box(x + 2.8, y - 0.05, 0, 0.18, 0.85, 0.52, this.shade(pal.couch, 6), pal.couchD, this.shade(pal.couchD, -12));
948
+ this.box(x + 2.15, y + 0.12, 0.32, 0.35, 0.16, 0.28, '#e9c46a', '#d4ae52', '#c19c43');
949
+ }
950
+
951
+ coffeeTable(x, y) {
952
+ const pal = this.pal;
953
+ this.box(x, y, 0, 1.6, 0.7, 0.3, this.shade(pal.deskTop, -10), pal.deskL, pal.deskR);
954
+ this.box(x + 0.2, y + 0.15, 0.3, 0.35, 0.25, 0.06, '#c96442', '#a85336', '#8f462e');
955
+ this.box(x + 1.1, y + 0.3, 0.3, 0.12, 0.12, 0.12, '#fff', '#ddd', '#ccc');
956
+ }
957
+
958
+ beanbag(x, y, color) {
959
+ const c = this.ctx;
960
+ const p = this.iso(x, y, 0);
961
+ c.fillStyle = this.shade(color, -25);
962
+ c.beginPath(); c.ellipse(p.x, p.y - 4, 20, 11, 0, 0, Math.PI * 2); c.fill();
963
+ c.fillStyle = color;
964
+ c.beginPath(); c.ellipse(p.x, p.y - 8, 18, 10, 0, 0, Math.PI * 2); c.fill();
965
+ c.fillStyle = this.shade(color, 20);
966
+ c.beginPath(); c.ellipse(p.x - 3, p.y - 14, 10, 6, -0.2, 0, Math.PI * 2); c.fill();
967
+ }
968
+
969
+ books(x0, x1, y) {
970
+ const P = (a, b, c) => this.iso(a, b, c);
971
+ const cols = this.pal.book;
972
+ for (let shelf = 0; shelf < 3; shelf++) {
973
+ const z = 0.12 + shelf * 0.45;
974
+ let bx = x0 + 0.05;
975
+ let i = Math.round(x0 * 7) + shelf * 5;
976
+ while (bx < x1 - 0.1) {
977
+ const bw = 0.07 + ((i * 13) % 5) * 0.015;
978
+ const bh = 0.25 + ((i * 7) % 4) * 0.03;
979
+ if (i % 7 !== 3) this.poly([P(bx, y, z), P(bx + bw, y, z), P(bx + bw, y, z + bh), P(bx, y, z + bh)], cols[i % cols.length]);
980
+ bx += bw + 0.015;
981
+ i++;
982
+ }
983
+ this.poly([P(x0, y, z - 0.02), P(x1, y, z - 0.02), P(x1, y, z), P(x0, y, z)], this.pal.deskR);
984
+ }
985
+ }
986
+
987
+ arcade(x, y) {
988
+ this.box(x, y, 0, 0.7, 0.7, 1.75, '#3a2a6a', '#2b1f52', '#221843');
989
+ const P = (a, b, c) => this.iso(a, b, c);
990
+ const hue = (this.t / 20) % 360;
991
+ this.poly([P(x + 0.1, y + 0.7, 1.0), P(x + 0.6, y + 0.7, 1.0), P(x + 0.6, y + 0.7, 1.45), P(x + 0.1, y + 0.7, 1.45)], `hsl(${hue},70%,55%)`);
992
+ const blip = this.iso(x + 0.25 + 0.2 * Math.sin(this.t / 300), y + 0.7, 1.2);
993
+ this.ctx.fillStyle = '#fff'; this.ctx.fillRect(blip.x, blip.y, 3, 3);
994
+ this.poly([P(x, y + 0.7, 1.55), P(x + 0.7, y + 0.7, 1.55), P(x + 0.7, y + 0.7, 1.72), P(x, y + 0.7, 1.72)], '#ffcf4a');
995
+ this.box(x, y + 0.7, 0.8, 0.7, 0.2, 0.08, '#4a3a8a', '#3a2a6a', '#2b1f52');
996
+ const j = this.iso(x + 0.25, y + 0.8, 0.88);
997
+ this.ctx.fillStyle = '#e04a4a'; this.ctx.beginPath(); this.ctx.arc(j.x, j.y - 3, 2.5, 0, Math.PI * 2); this.ctx.fill();
998
+ }
999
+
1000
+ floorLamp(x, y) {
1001
+ const c = this.ctx;
1002
+ const b = this.iso(x, y, 0), t = this.iso(x, y, 1.7);
1003
+ if (skyColor().night) {
1004
+ c.fillStyle = 'rgba(255,220,140,.14)';
1005
+ c.beginPath(); c.ellipse(b.x, b.y, 50, 25, 0, 0, Math.PI * 2); c.fill();
1006
+ }
1007
+ c.strokeStyle = '#333'; c.lineWidth = 2;
1008
+ c.beginPath(); c.moveTo(b.x, b.y); c.lineTo(t.x, t.y); c.stroke();
1009
+ c.fillStyle = '#333'; c.beginPath(); c.ellipse(b.x, b.y, 7, 3.5, 0, 0, Math.PI * 2); c.fill();
1010
+ c.fillStyle = '#f3e3b5';
1011
+ c.beginPath(); c.moveTo(t.x - 9, t.y + 4); c.lineTo(t.x + 9, t.y + 4); c.lineTo(t.x + 5, t.y - 8); c.lineTo(t.x - 5, t.y - 8); c.closePath(); c.fill();
1012
+ }
1013
+
1014
+ vending(x, y, color) {
1015
+ this.box(x, y, 0, 0.7, 0.75, 1.85, this.shade(color, 15), this.shade(color, -10), color);
1016
+ const P = (a, b, c) => this.iso(a, b, c);
1017
+ const fx = x + 0.7;
1018
+ this.poly([P(fx, y + 0.08, 0.7), P(fx, y + 0.55, 0.7), P(fx, y + 0.55, 1.7), P(fx, y + 0.08, 1.7)], 'rgba(20,30,40,.75)');
1019
+ const cols = ['#f2d16b', '#5cf08a', '#ff8a65', '#81d4fa', '#ce93d8'];
1020
+ for (let r = 0; r < 4; r++) {
1021
+ for (let k = 0; k < 3; k++) {
1022
+ const q = P(fx, y + 0.15 + k * 0.14, 1.55 - r * 0.23);
1023
+ this.ctx.fillStyle = cols[(r * 3 + k + Math.round(y)) % cols.length];
1024
+ this.ctx.fillRect(q.x - 1.5, q.y - 4, 3, 5);
1025
+ }
1026
+ }
1027
+ this.poly([P(fx, y + 0.1, 0.25), P(fx, y + 0.5, 0.25), P(fx, y + 0.5, 0.45), P(fx, y + 0.1, 0.45)], '#222');
1028
+ const glow = P(fx, y + 0.65, 1.2);
1029
+ this.ctx.fillStyle = Math.sin(this.t / 700 + y) > 0 ? '#9ef' : '#4ad';
1030
+ this.ctx.fillRect(glow.x - 1, glow.y - 3, 2, 6);
1031
+ }
1032
+
1033
+ foosball(x, y) {
1034
+ for (const [lx, ly] of [[0.1, 0.1], [1.42, 0.1], [0.1, 0.62], [1.42, 0.62]]) this.box(x + lx, y + ly, 0, 0.08, 0.08, 0.6, '#5a4030', '#5a4030', '#4a3428');
1035
+ this.box(x, y, 0.6, 1.6, 0.8, 0.25, '#2f8a4a', '#6e533b', '#5c4431');
1036
+ const P = (a, b, c) => this.iso(a, b, c);
1037
+ const players = this.plan && this.plan.pois.filter((q) => q.pair === 'fb' && q.taken).length === 2;
1038
+ for (let k = 0; k < 6; k++) {
1039
+ const rx = x + 0.2 + k * 0.24;
1040
+ const shift = players ? Math.sin(this.t / 120 + k) * 0.06 : 0;
1041
+ const a = P(rx, y - 0.15, 0.82), b = P(rx, y + 0.95, 0.82);
1042
+ this.ctx.strokeStyle = '#c8ccd0'; this.ctx.lineWidth = 1.2;
1043
+ this.ctx.beginPath(); this.ctx.moveTo(a.x, a.y); this.ctx.lineTo(b.x, b.y); this.ctx.stroke();
1044
+ for (let m = 0; m < 2; m++) {
1045
+ const q = P(rx, y + 0.25 + m * 0.3 + shift, 0.82);
1046
+ this.ctx.fillStyle = k % 2 ? '#c0392b' : '#2f6fb0';
1047
+ this.ctx.fillRect(q.x - 1.5, q.y - 3, 3, 6);
1048
+ }
1049
+ }
1050
+ }
1051
+
1052
+ booth(add, x, y) {
1053
+ const P = (a, b, c) => this.iso(a, b, c);
1054
+ const pal = this.pal;
1055
+ const H = 2.0;
1056
+ // back walls (felt panels), then the stool, then glass front
1057
+ add(x + 0.65 + y + 0.05, () => {
1058
+ this.poly([P(x, y, 0), P(x + 1.3, y, 0), P(x + 1.3, y, H), P(x, y, H)], pal.dark ? '#3c4a5a' : '#9fb3c8');
1059
+ this.poly([P(x, y, 0), P(x, y + 1.3, 0), P(x, y + 1.3, H), P(x, y, H)], pal.dark ? '#34404e' : '#8ea3b8');
1060
+ this.poly([P(x + 0.02, y + 0.02), P(x + 1.28, y + 0.02), P(x + 1.28, y + 1.28), P(x + 0.02, y + 1.28)], pal.dark ? '#2a323b' : '#d8dee5');
1061
+ const lamp = P(x + 0.65, y + 0.65, H - 0.05);
1062
+ this.ctx.fillStyle = 'rgba(255,230,160,.9)';
1063
+ this.ctx.beginPath(); this.ctx.ellipse(lamp.x, lamp.y, 8, 4, 0, 0, Math.PI * 2); this.ctx.fill();
1064
+ this.stool(x + 0.65, y + 0.6, 0.43, '#c96442');
1065
+ });
1066
+ add(x + 1.3 + y + 0.65 + 0.3, () => {
1067
+ this.poly([P(x + 1.3, y, 0), P(x + 1.3, y + 1.3, 0), P(x + 1.3, y + 1.3, H), P(x + 1.3, y, H)], pal.glass, pal.glassEdge);
1068
+ this.poly([P(x, y + 1.3, 0), P(x + 1.3, y + 1.3, 0), P(x + 1.3, y + 1.3, H), P(x, y + 1.3, H)], pal.glass, pal.glassEdge);
1069
+ this.poly([P(x + 0.85, y + 1.3, 0), P(x + 0.87, y + 1.3, 0), P(x + 0.87, y + 1.3, H), P(x + 0.85, y + 1.3, H)], pal.glassEdge);
1070
+ this.box(x - 0.03, y - 0.03, H, 1.36, 1.36, 0.08, pal.dark ? '#4a5868' : '#b5c4d3', pal.trim, pal.trim);
1071
+ });
1072
+ }
1073
+
1074
+ desk(dk, p) {
1075
+ const pal = this.pal;
1076
+ const { x, y } = dk;
1077
+ for (const [lx, ly] of [[0.05, 0.05], [1.27, 0.05], [0.05, 0.67], [1.27, 0.67]]) {
1078
+ this.box(x + lx, y + ly, 0, 0.08, 0.08, 0.72, pal.deskL, pal.deskL, pal.deskR);
1079
+ }
1080
+ this.box(x, y, 0.72, 1.4, 0.8, 0.07, pal.deskTop, pal.deskL, pal.deskR);
1081
+ const worker = p && p.mode === 'work' ? p : null;
1082
+ if (dk.side === 's') {
1083
+ const mx = x + 0.42, my = y + 0.1, mz = 0.79;
1084
+ this.box(mx + 0.22, my + 0.04, mz, 0.1, 0.1, 0.18, pal.monitor, pal.monitor, pal.monitor);
1085
+ this.box(mx, my, mz + 0.18, 0.56, 0.08, 0.42, pal.monitor, pal.monitor, this.shade(pal.monitor, 10));
1086
+ this.screen(mx + 0.04, my + 0.08, mz + 0.22, 0.48, 0.34, worker);
1087
+ this.box(x + 0.48, y + 0.48, 0.79, 0.44, 0.16, 0.025, '#d8d8d8', '#aaa', '#999');
1088
+ } else {
1089
+ // We see this monitor from behind; a status LED on its back.
1090
+ const mx = x + 0.42, my = y + 0.6, mz = 0.79;
1091
+ this.box(x + 0.48, y + 0.16, 0.79, 0.44, 0.16, 0.025, '#d8d8d8', '#aaa', '#999');
1092
+ this.box(mx + 0.22, my - 0.02, mz, 0.1, 0.1, 0.18, pal.monitor, pal.monitor, pal.monitor);
1093
+ this.box(mx, my, mz + 0.18, 0.56, 0.08, 0.42, this.shade(pal.monitor, 15), this.shade(pal.monitor, 8), this.shade(pal.monitor, 12));
1094
+ if (worker) {
1095
+ const g = this.iso(mx + 0.28, my + 0.08, mz + 0.5);
1096
+ this.ctx.fillStyle = STATUS_COLOR[worker.s.status] || '#888';
1097
+ this.ctx.globalAlpha = 0.6 + 0.4 * Math.sin(this.t / 400 + worker.phase);
1098
+ this.ctx.beginPath(); this.ctx.arc(g.x, g.y, 1.8, 0, Math.PI * 2); this.ctx.fill();
1099
+ this.ctx.globalAlpha = 1;
1100
+ }
1101
+ }
1102
+ // personal touches, stable per desk
1103
+ const h = hash(`${x.toFixed(1)},${y.toFixed(1)}`);
1104
+ if (h % 3 === 0) this.box(x + 1.12, y + 0.3, 0.79, 0.12, 0.12, 0.13, '#f2f2f2', '#d0d0d0', '#bbb');
1105
+ if (h % 5 === 1) {
1106
+ this.box(x + 0.14, y + 0.24, 0.79, 0.12, 0.12, 0.08, pal.pot, pal.pot, this.shade(pal.pot, -15));
1107
+ const q = this.iso(x + 0.2, y + 0.3, 0.87);
1108
+ this.ctx.fillStyle = pal.plant;
1109
+ this.ctx.beginPath(); this.ctx.ellipse(q.x, q.y - 4, 4, 6, 0, 0, Math.PI * 2); this.ctx.fill();
1110
+ }
1111
+ if (h % 4 === 2) this.box(x + 0.12, y + 0.45, 0.79, 0.22, 0.15, 0.05, '#f2d16b', '#d9b84f', '#c4a33f');
1112
+ }
1113
+
1114
+ screen(x, y, z, w, h, p) {
1115
+ const P = (a, b, cc) => this.iso(a, b, cc);
1116
+ const st = p ? p.s.status : 'off';
1117
+ const base = { thinking: '#3a3160', tool: '#14324f', typing: '#123d2e', waiting: '#4d3510', error: '#4a1612' }[st] || this.pal.screenOff;
1118
+ const corners = [P(x, y, z), P(x + w, y, z), P(x + w, y, z + h), P(x, y, z + h)];
1119
+ this.poly(corners, base);
1120
+ if (!p) return;
1121
+ const c = this.ctx;
1122
+ c.save();
1123
+ c.beginPath();
1124
+ corners.forEach((q, i) => (i ? c.lineTo(q.x, q.y) : c.moveTo(q.x, q.y)));
1125
+ c.closePath();
1126
+ c.clip();
1127
+ const t = this.t / 1000;
1128
+ const col = STATUS_COLOR[st];
1129
+ if (st === 'tool' || st === 'typing') {
1130
+ const scroll = (t * (st === 'tool' ? 2.2 : 1.2) + p.phase) % 1;
1131
+ for (let i = -1; i < 6; i++) {
1132
+ const zz = z + h - 0.05 - ((i + scroll) * h) / 5;
1133
+ const len = 0.12 + ((p.h >> ((i + 8) & 15)) % 7) / 20;
1134
+ const a = P(x + 0.05, y, zz), b = P(x + 0.05 + Math.min(len, w - 0.1), y, zz);
1135
+ c.strokeStyle = i % 3 === 0 ? col : 'rgba(255,255,255,.55)';
1136
+ c.lineWidth = 1.5;
1137
+ c.beginPath(); c.moveTo(a.x, a.y); c.lineTo(b.x, b.y); c.stroke();
1138
+ }
1139
+ } else {
1140
+ const m = P(x + w / 2, y, z + h / 2);
1141
+ c.fillStyle = col;
1142
+ c.globalAlpha = st === 'thinking' ? 0.5 + 0.4 * Math.sin(t * 3 + p.phase) : 0.9;
1143
+ c.font = '700 10px ui-sans-serif, system-ui, sans-serif';
1144
+ c.textAlign = 'center';
1145
+ c.textBaseline = 'middle';
1146
+ c.fillText({ thinking: '…', waiting: '?', error: '!' }[st] || '', m.x, m.y);
1147
+ }
1148
+ c.restore();
1149
+ c.save();
1150
+ c.globalAlpha = 0.18;
1151
+ const g = P(x + w / 2, y + 0.35, 0.8);
1152
+ c.fillStyle = col;
1153
+ c.beginPath(); c.ellipse(g.x, g.y, 18, 7, 0, 0, Math.PI * 2); c.fill();
1154
+ c.restore();
1155
+ }
1156
+
1157
+ chair(dk, p) {
1158
+ const pal = this.pal;
1159
+ const sx = dk.seat.x - 0.25, sy = dk.seat.y - 0.22;
1160
+ const seated = p && !p.path.length && p.pose === 'sit' && Math.hypot(p.x - dk.seat.x, p.y - dk.seat.y) < 0.05;
1161
+ const ox = seated ? 0 : 0.12, oy = seated ? 0 : dk.side === 's' ? 0.18 : -0.18;
1162
+ this.box(sx + 0.22 + ox, sy + 0.19 + oy, 0, 0.06, 0.06, 0.32, '#222', '#222', '#111');
1163
+ if (dk.side === 'n') this.box(sx + ox, sy + oy - 0.05, 0.4, 0.5, 0.07, 0.55, this.shade(pal.chair, 15), pal.chair, this.shade(pal.chair, -10));
1164
+ this.box(sx + ox, sy + oy, 0.32, 0.5, 0.45, 0.08, this.shade(pal.chair, 15), pal.chair, this.shade(pal.chair, -10));
1165
+ if (dk.side === 's' && !seated) this.box(sx + ox, sy + oy + 0.4, 0.4, 0.5, 0.07, 0.5, this.shade(pal.chair, 15), pal.chair, this.shade(pal.chair, -10));
1166
+ }
1167
+
1168
+ // ------------------------------------------------------------ people
1169
+
1170
+ stateOf(p) {
1171
+ const s = p.s || {};
1172
+ if (p.path.length) return { pose: 'walk', face: p.walkFace, act: null, sitZ: 0 };
1173
+ if (p.mode === 'work' && p.desk) {
1174
+ const face = p.desk.side === 'n' ? 'front' : 'back';
1175
+ if (s.status === 'waiting' || s.status === 'error') return { pose: 'stand', face, act: 'wave', sitZ: 0 };
1176
+ if (p.desk.pose === 'stand') return { pose: 'stand', face, act: 'station', sitZ: 0 };
1177
+ return { pose: 'sit', face, act: s.status === 'tool' || s.status === 'typing' ? 'type' : 'think', sitZ: 0.34 };
1178
+ }
1179
+ if (p.mode === 'wander' && p.poi) {
1180
+ const q = p.poi;
1181
+ return { pose: q.sit ? 'sit' : 'stand', face: q.face === 'side' ? 'front' : q.face, act: q.act, sitZ: q.sit || 0 };
1182
+ }
1183
+ return { pose: 'stand', face: 'front', act: null, sitZ: 0 };
1184
+ }
1185
+
1186
+ person(p) {
1187
+ const c = this.ctx;
1188
+ const t = this.t / 1000;
1189
+ const s = p.s || {};
1190
+ const st = this.stateOf(p);
1191
+ p.pose = st.pose;
1192
+ const moving = st.pose === 'walk';
1193
+ const seated = st.pose === 'sit';
1194
+ const front = st.face !== 'back';
1195
+ const b = this.iso(p.x, p.y, st.sitZ);
1196
+ const step = moving ? Math.sin(t * 12 + p.phase) : 0;
1197
+ const bob = moving ? Math.abs(step) * 1.5 : 0;
1198
+
1199
+ const fb = this.iso(p.x, p.y, 0);
1200
+ c.fillStyle = this.pal.shadow;
1201
+ c.beginPath(); c.ellipse(fb.x, fb.y, 10, 5, 0, 0, Math.PI * 2); c.fill();
1202
+ if (this.hoverKey === p.key) {
1203
+ c.strokeStyle = STATUS_COLOR[s.status] || '#888';
1204
+ c.lineWidth = 2;
1205
+ c.beginPath(); c.ellipse(fb.x, fb.y, 14, 7, 0, 0, Math.PI * 2); c.stroke();
1206
+ }
1207
+
1208
+ let x = b.x;
1209
+ const y = b.y - bob;
1210
+ if (st.act === 'pingpong') x += Math.sin(t * 7 + p.phase) * 4;
1211
+ if (st.act === 'foosball') x += Math.sin(t * 5 + p.phase) * 1.5;
1212
+ const sc = s.subagent ? 0.8 : 1;
1213
+ c.save();
1214
+ c.translate(x, y);
1215
+ c.scale(sc, sc);
1216
+
1217
+ c.fillStyle = p.pants;
1218
+ if (seated) {
1219
+ c.fillRect(-5, -4, 4, 5);
1220
+ c.fillRect(1, -4, 4, 5);
1221
+ } else {
1222
+ c.fillRect(-5, -10, 4, 10 + step * 2);
1223
+ c.fillRect(1, -10, 4, 10 - step * 2);
1224
+ c.fillStyle = '#2a2a2a';
1225
+ c.fillRect(-5, -1 + step, 4, 2);
1226
+ c.fillRect(1, -1 - step, 4, 2);
1227
+ }
1228
+ const hip = seated ? -4 : -10;
1229
+ this.roundRect(-7, hip - 13, 14, 14, 3, p.shirt);
1230
+ const arm = this.shade(p.shirt, -18);
1231
+ const hand = (hx, hy) => { c.fillStyle = p.skin; c.beginPath(); c.arc(hx, hy, 2.2, 0, Math.PI * 2); c.fill(); };
1232
+ c.fillStyle = arm;
1233
+ const sip = Math.sin(t * 0.9 + p.phase) > 0.6;
1234
+ switch (st.act) {
1235
+ case 'wave': {
1236
+ const wave = Math.sin(t * 8 + p.phase) * 2;
1237
+ c.fillRect(6, hip - 26, 3, 14);
1238
+ hand(7.5 + wave * 0.5, hip - 27);
1239
+ c.fillStyle = arm; c.fillRect(-9, hip - 11, 3, 10);
1240
+ break;
1241
+ }
1242
+ case 'type': {
1243
+ const k1 = Math.sin(t * 18 + p.phase) > 0 ? 1 : 0;
1244
+ c.fillRect(-9, hip - 11 - k1, 3, 8);
1245
+ c.fillRect(6, hip - 11 - (1 - k1), 3, 8);
1246
+ break;
1247
+ }
1248
+ case 'coffee':
1249
+ case 'water': {
1250
+ c.fillRect(-9, hip - 11, 3, 10);
1251
+ c.fillRect(6, sip ? hip - 15 : hip - 11, 3, sip ? 7 : 10);
1252
+ const cupY = sip && front ? hip - 19 : hip - 4;
1253
+ c.fillStyle = st.act === 'water' ? 'rgba(170,215,255,.95)' : '#fafafa';
1254
+ c.fillRect(6, cupY, 5, 6);
1255
+ if (st.act === 'coffee') { c.fillStyle = '#6b3f22'; c.fillRect(7, cupY, 3, 1.5); }
1256
+ break;
1257
+ }
1258
+ case 'read': {
1259
+ c.fillRect(-8, hip - 11, 3, 7);
1260
+ c.fillRect(5, hip - 11, 3, 7);
1261
+ if (front) { c.fillStyle = pick(this.pal.book, p.h, 5); c.fillRect(-7, hip - 12, 14, 9); c.fillStyle = '#fff'; c.fillRect(-0.5, hip - 12, 1, 9); }
1262
+ break;
1263
+ }
1264
+ case 'whiteboard':
1265
+ case 'arcade': {
1266
+ const k = Math.sin(t * (st.act === 'arcade' ? 14 : 3) + p.phase);
1267
+ c.fillRect(-9, hip - 11, 3, 10);
1268
+ c.fillRect(6, hip - 20 + k * 2, 3, 12);
1269
+ hand(7.5, hip - 21 + k * 2);
1270
+ break;
1271
+ }
1272
+ case 'pingpong': {
1273
+ c.fillRect(-9, hip - 11, 3, 10);
1274
+ c.fillRect(6, hip - 14, 3, 9);
1275
+ c.fillStyle = '#c0392b';
1276
+ c.beginPath(); c.arc(10, hip - 16, 4, 0, Math.PI * 2); c.fill();
1277
+ break;
1278
+ }
1279
+ case 'foosball': {
1280
+ const k1 = Math.sin(t * 16 + p.phase) * 1.5;
1281
+ c.fillRect(-9, hip - 12 + k1, 3, 7);
1282
+ c.fillRect(6, hip - 12 - k1, 3, 7);
1283
+ break;
1284
+ }
1285
+ case 'phone': {
1286
+ c.fillRect(-9, hip - 11, 3, 10);
1287
+ c.fillRect(5, hip - 22, 3, 12);
1288
+ c.fillStyle = '#222'; c.fillRect(5, hip - 25, 3, 6);
1289
+ break;
1290
+ }
1291
+ case 'nap': {
1292
+ c.fillRect(-9, hip - 13, 3, 6);
1293
+ c.fillRect(6, hip - 13, 3, 6);
1294
+ break;
1295
+ }
1296
+ default: {
1297
+ const sw = moving ? step * 2 : 0;
1298
+ c.fillRect(-9, hip - 11 + sw, 3, 10);
1299
+ c.fillRect(6, hip - 11 - sw, 3, 10);
1300
+ }
1301
+ }
1302
+
1303
+ const napping = st.act === 'nap';
1304
+ const hy = hip - 20 + (napping ? 7 : 0) + (st.act === 'think' ? Math.sin(t * 1.5 + p.phase) * 0.6 : 0);
1305
+ c.fillStyle = p.skin;
1306
+ c.beginPath(); c.arc(0, hy, 7, 0, Math.PI * 2); c.fill();
1307
+ c.fillStyle = p.hair;
1308
+ if (front && !napping) {
1309
+ c.beginPath(); c.arc(0, hy - 1.5, 7.3, Math.PI * 1.05, Math.PI * 1.95); c.fill();
1310
+ if (p.hairStyle === 1) { c.fillRect(-7.3, hy - 2, 2.5, 8); c.fillRect(4.8, hy - 2, 2.5, 8); }
1311
+ if (p.hairStyle === 2) { c.beginPath(); c.arc(0, hy - 8, 3.2, 0, Math.PI * 2); c.fill(); }
1312
+ c.fillStyle = '#2a2018';
1313
+ const sleepy = s.status === 'idle';
1314
+ const blink = Math.sin(t * 0.7 + p.phase * 3) > 0.985;
1315
+ c.fillRect(-3, hy - 0.5, 1.6, blink || sleepy ? 0.7 : 2);
1316
+ c.fillRect(1.6, hy - 0.5, 1.6, blink || sleepy ? 0.7 : 2);
1317
+ c.strokeStyle = '#2a2018'; c.lineWidth = 1;
1318
+ if (p.mode === 'wander' && !sleepy) {
1319
+ c.beginPath(); c.arc(0, hy + 2.2, 2, 0.15 * Math.PI, 0.85 * Math.PI); c.stroke();
1320
+ } else if (s.status === 'error' && p.mode === 'work') {
1321
+ c.beginPath(); c.arc(0, hy + 4.5, 2, 1.15 * Math.PI, 1.85 * Math.PI); c.stroke();
1322
+ }
1323
+ } else {
1324
+ c.beginPath(); c.arc(0, hy - 0.5, 7.2, 0, Math.PI * 2); c.fill();
1325
+ if (p.hairStyle === 2) { c.beginPath(); c.arc(0, hy - 7, 3.2, 0, Math.PI * 2); c.fill(); }
1326
+ if (p.hairStyle === 1) c.fillRect(-6, hy, 12, 8);
1327
+ }
1328
+ if (st.act === 'arcade' || st.act === 'beanbag') {
1329
+ c.strokeStyle = '#222'; c.lineWidth = 2;
1330
+ c.beginPath(); c.arc(0, hy, 8, Math.PI * 1.1, Math.PI * 1.9); c.stroke();
1331
+ }
1332
+ c.restore();
1333
+
1334
+ const headTop = y + (hy - 8) * sc;
1335
+ this.hit.push({ key: p.key, x0: x - 13, x1: x + 13, y0: headTop - 22, y1: fb.y + 4 });
1336
+ return { x, y: headTop, act: st.act };
1337
+ }
1338
+
1339
+ bubble(p, at) {
1340
+ const c = this.ctx;
1341
+ const t = this.t / 1000;
1342
+ const s = p.s || {};
1343
+ const st = s.status;
1344
+ const pal = this.pal;
1345
+ const x = at.x, y = at.y - 6;
1346
+ c.save();
1347
+ c.textAlign = 'center';
1348
+ c.textBaseline = 'middle';
1349
+ const font = (w, px) => `${w} ${px}px ui-sans-serif, system-ui, -apple-system, "PingFang TC", "Apple Color Emoji", sans-serif`;
1350
+ c.font = font(600, 10);
1351
+
1352
+ const badge = this.showAgent && AGENT_BADGES[s.agent];
1353
+ const pill = (text, fg, bg, yy, alpha = 1, identify = false) => {
1354
+ c.globalAlpha = alpha;
1355
+ let tx = text;
1356
+ while (c.measureText(tx).width > 118 && tx.length > 2) tx = tx.slice(0, -2) + '…';
1357
+ const extra = badge && identify ? 26 : 0;
1358
+ const w = c.measureText(tx).width + 12 + extra;
1359
+ const height = extra ? 22 : 16;
1360
+ this.roundRect(x - w / 2, yy - height / 2, w, height, 8, bg);
1361
+ c.fillStyle = fg;
1362
+ c.fillText(tx, x + extra / 2, yy + 0.5);
1363
+ if (extra) this.agentBadge(badge, x - w / 2 + 13, yy);
1364
+ c.globalAlpha = 1;
1365
+ return yy - 8;
1366
+ };
1367
+
1368
+ let top = y;
1369
+ if (p.mode === 'work' && !p.path.length) {
1370
+ const col = STATUS_COLOR[st] || '#888';
1371
+ if (st === 'thinking') {
1372
+ c.fillStyle = pal.bubble;
1373
+ c.beginPath(); c.arc(x + 6, y - 2, 2, 0, Math.PI * 2); c.fill();
1374
+ c.beginPath(); c.arc(x + 10, y - 7, 3, 0, Math.PI * 2); c.fill();
1375
+ this.roundRect(x - 2, y - 26, 32, 16, 8, pal.bubble);
1376
+ for (let i = 0; i < 3; i++) {
1377
+ c.fillStyle = col;
1378
+ c.globalAlpha = 0.3 + 0.7 * Math.max(0, Math.sin(t * 5 - i * 0.7));
1379
+ c.beginPath(); c.arc(x + 6 + i * 8, y - 18, 2.4, 0, Math.PI * 2); c.fill();
1380
+ }
1381
+ c.globalAlpha = 1;
1382
+ top = y - 26;
1383
+ } else if (st === 'typing') {
1384
+ this.roundRect(x - 2, y - 24, 34, 16, 5, col);
1385
+ c.beginPath(); c.moveTo(x + 2, y - 9); c.lineTo(x - 2, y - 3); c.lineTo(x + 8, y - 9); c.fillStyle = col; c.fill();
1386
+ const n = Math.floor(t * 4) % 4;
1387
+ c.fillStyle = '#fff';
1388
+ for (let i = 0; i < n; i++) { c.beginPath(); c.arc(x + 7 + i * 8, y - 16, 2, 0, Math.PI * 2); c.fill(); }
1389
+ top = y - 24;
1390
+ } else if (st === 'tool') {
1391
+ const tool = (s.activity || 'tool').split(/[:\s]/)[0];
1392
+ top = pill('⚙ ' + tool, '#fff', col, y - 12);
1393
+ } else if (st === 'waiting' || st === 'error') {
1394
+ const jump = Math.abs(Math.sin(t * 4)) * 4;
1395
+ c.fillStyle = col;
1396
+ c.beginPath(); c.arc(x, y - 12 - jump, 9, 0, Math.PI * 2); c.fill();
1397
+ c.fillStyle = '#fff';
1398
+ c.font = font(800, 12);
1399
+ c.fillText('!', x, y - 11.5 - jump);
1400
+ c.font = font(600, 10);
1401
+ top = y - 22 - jump;
1402
+ }
1403
+ } else if (p.mode === 'wander') {
1404
+ const justDone = st === 'done' && Date.now() - (s.lastEventAt || 0) < 3 * 60_000;
1405
+ const dozing = at.act === 'nap' || (st === 'idle' && (at.act === 'couch' || at.act === 'beanbag' || (p.poi && p.poi.sleepy)));
1406
+ if (justDone) {
1407
+ c.fillStyle = STATUS_COLOR.done;
1408
+ c.beginPath(); c.arc(x, y - 10, 7, 0, Math.PI * 2); c.fill();
1409
+ c.fillStyle = '#fff'; c.font = font(800, 9); c.fillText('✓', x, y - 9.5); c.font = font(600, 10);
1410
+ top = y - 18;
1411
+ } else if (dozing && !p.path.length) {
1412
+ c.fillStyle = STATUS_COLOR.idle;
1413
+ c.font = font(700, 10);
1414
+ for (let i = 0; i < 3; i++) {
1415
+ const k = (t * 0.6 + i / 3) % 1;
1416
+ c.globalAlpha = 1 - k;
1417
+ c.fillText('z', x + 6 + k * 10, y - 4 - k * 18);
1418
+ }
1419
+ c.globalAlpha = 1;
1420
+ c.font = font(600, 10);
1421
+ top = y - 20;
1422
+ } else if (at.act && (ACT_EMOJI[at.act] || (p.poi && p.poi.emoji)) && !p.path.length) {
1423
+ const cycle = (t + p.phase * 3) % 9;
1424
+ if (cycle < 2.2) {
1425
+ c.globalAlpha = Math.min(1, cycle * 3, (2.2 - cycle) * 3);
1426
+ c.font = font(400, 13);
1427
+ c.fillText((p.poi && p.poi.emoji) || ACT_EMOJI[at.act], x, y - 10);
1428
+ c.globalAlpha = 1;
1429
+ c.font = font(600, 10);
1430
+ top = y - 18;
1431
+ }
1432
+ }
1433
+ }
1434
+
1435
+ const hover = this.hoverKey === p.key;
1436
+ if (!s.subagent || hover) {
1437
+ pill(this.label(s), pal.text, pal.tagBg, top - 11, p.mode === 'work' || hover ? 1 : 0.7, true);
1438
+ } else if (badge) {
1439
+ this.agentBadge(badge, x, top - 11);
1440
+ }
1441
+ c.restore();
1442
+ }
1443
+
1444
+ agentBadge(badge, x, y) {
1445
+ const c = this.ctx;
1446
+ c.save();
1447
+ c.globalAlpha = 1;
1448
+ if (badge.image.complete && badge.image.naturalWidth > 0) {
1449
+ if (badge.mono && this.pal.dark) c.filter = 'invert(1)';
1450
+ c.imageSmoothingEnabled = true;
1451
+ c.imageSmoothingQuality = 'high';
1452
+ c.drawImage(badge.image, x - 9, y - 9, 18, 18);
1453
+ } else {
1454
+ c.fillStyle = this.pal.text;
1455
+ c.font = '800 10px ui-sans-serif, system-ui, sans-serif';
1456
+ c.textAlign = 'center';
1457
+ c.textBaseline = 'middle';
1458
+ c.fillText(badge.short, x, y + 0.5);
1459
+ }
1460
+ c.restore();
1461
+ }
1462
+
1463
+ moveCat(dt, now) {
1464
+ const k = this.cat;
1465
+ if (!k.path.length && now > k.until) {
1466
+ const { W, D, grid } = this.plan;
1467
+ let tx, ty, tries = 0;
1468
+ do { tx = rand(0.6, W - 0.6); ty = rand(1, D - 0.6); tries++; } while (grid.blocked(tx, ty) && tries < 30);
1469
+ k.path = grid.path(k.x, k.y, tx, ty);
1470
+ k.until = Infinity;
1471
+ }
1472
+ if (k.path.length) {
1473
+ const n = k.path[0];
1474
+ const dx = n.x - k.x, dy = n.y - k.y, d = Math.hypot(dx, dy), v = 1.2 * dt;
1475
+ if (d > 0.001) k.face = dx - dy >= 0 ? 1 : -1;
1476
+ if (d <= v) { k.x = n.x; k.y = n.y; k.path.shift(); if (!k.path.length) k.until = now + rand(4000, 12000); }
1477
+ else { k.x += (dx / d) * v; k.y += (dy / d) * v; }
1478
+ }
1479
+ }
1480
+
1481
+ drawCat() {
1482
+ const k = this.cat;
1483
+ const c = this.ctx;
1484
+ const p = this.iso(k.x, k.y, 0);
1485
+ const t = this.t / 1000;
1486
+ const walking = k.path.length > 0;
1487
+ c.save();
1488
+ c.translate(p.x, p.y);
1489
+ c.scale(k.face, 1);
1490
+ c.fillStyle = this.pal.shadow;
1491
+ c.beginPath(); c.ellipse(0, 0, 8, 3, 0, 0, Math.PI * 2); c.fill();
1492
+ const col = '#e09a4a';
1493
+ c.fillStyle = col;
1494
+ if (walking) {
1495
+ const s = Math.sin(t * 14) * 1.5;
1496
+ c.fillRect(-5, -4, 2, 4 + s); c.fillRect(3, -4, 2, 4 - s);
1497
+ }
1498
+ c.beginPath(); c.ellipse(0, walking ? -6 : -4, 7, walking ? 4 : 5, 0, 0, Math.PI * 2); c.fill();
1499
+ c.beginPath(); c.arc(6, walking ? -10 : -9, 3.6, 0, Math.PI * 2); c.fill();
1500
+ c.beginPath(); c.moveTo(4, -12); c.lineTo(5, -16); c.lineTo(7, -12.5); c.fill();
1501
+ c.beginPath(); c.moveTo(7, -12.5); c.lineTo(9, -15.5); c.lineTo(9.5, -11); c.fill();
1502
+ c.strokeStyle = col; c.lineWidth = 2; c.lineCap = 'round';
1503
+ c.beginPath(); c.moveTo(-6, -6); c.quadraticCurveTo(-12, -8 + Math.sin(t * 3) * 3, -10, -14); c.stroke();
1504
+ c.fillStyle = '#2a2018';
1505
+ c.fillRect(7, -10.5, 1.2, walking ? 1.2 : 0.6);
1506
+ c.restore();
1507
+ }
1508
+
1509
+ pingPongBall() {
1510
+ const players = this.plan.pois
1511
+ .filter((q) => q.pair === 'pp' && q.taken)
1512
+ .map((q) => this.people.get(q.taken))
1513
+ .filter((p) => p && p.poi && p.poi.pair === 'pp' && !p.path.length);
1514
+ if (players.length < 2) return;
1515
+ const pp = this.plan.pp;
1516
+ const k = (this.t / 900) % 2;
1517
+ const f = k < 1 ? k : 2 - k;
1518
+ const x = pp.x0 + 0.3 + f * (pp.x1 - pp.x0 - 0.6);
1519
+ const z = 0.8 + Math.abs(Math.sin(f * Math.PI * 2)) * 0.45;
1520
+ const c = this.ctx;
1521
+ const sh = this.iso(x, pp.y, 0.77);
1522
+ c.fillStyle = 'rgba(0,0,0,.25)';
1523
+ c.beginPath(); c.ellipse(sh.x, sh.y, 2.5, 1.2, 0, 0, Math.PI * 2); c.fill();
1524
+ const p = this.iso(x, pp.y, z);
1525
+ c.fillStyle = '#fff';
1526
+ c.beginPath(); c.arc(p.x, p.y, 2.2, 0, Math.PI * 2); c.fill();
1527
+ }
1528
+
1529
+ // ------------------------------------------------------------ frame
1530
+
1531
+ frame(now) {
1532
+ const dt = Math.min(0.05, (now - this.last) / 1000);
1533
+ this.last = now;
1534
+ this.t = now;
1535
+ if (!this.visible) return;
1536
+ this.step(dt);
1537
+ if (this.draw() !== false) {
1538
+ if (!this.first && ++this.framesShown === 3) this.markReady(true);
1539
+ // Capture immediately: a WebGL drawing buffer may be cleared after
1540
+ // this task. The overlay and the 3D canvas must be copied together.
1541
+ if (this.onFrame) this.onFrame(this);
1542
+ }
1543
+ }
1544
+
1545
+ // Advance people and the cat; shared by the 2D and 3D renderers.
1546
+ step(dt) {
1547
+ const wall = Date.now();
1548
+ for (const [k, p] of this.people) {
1549
+ if (p.mode === 'wander') {
1550
+ if (p.poi && !p.path.length && p.until === Infinity) p.until = wall + p.stay;
1551
+ this.wander(p, wall);
1552
+ }
1553
+ if (p.path.length) {
1554
+ const n = p.path[0];
1555
+ const dx = n.x - p.x, dy = n.y - p.y, d = Math.hypot(dx, dy), v = SPEED * dt;
1556
+ if (d > 0.001) p.walkFace = dx + dy >= 0 ? 'front' : 'back';
1557
+ if (d <= v) { p.x = n.x; p.y = n.y; p.path.shift(); }
1558
+ else { p.x += (dx / d) * v; p.y += (dy / d) * v; }
1559
+ } else if (p.leaving) {
1560
+ this.people.delete(k);
1561
+ }
1562
+ }
1563
+ this.moveCat(dt, wall);
1564
+ }
1565
+
1566
+ draw() {
1567
+ const c = this.ctx;
1568
+ c.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
1569
+ c.clearRect(0, 0, this.cw, this.ch);
1570
+ this.fitCamera();
1571
+ c.translate(this.cam.ox, this.cam.oy);
1572
+ c.scale(this.cam.zoom, this.cam.zoom);
1573
+
1574
+ this.drawRoom();
1575
+ const occupant = new Map();
1576
+ for (const p of this.people.values()) {
1577
+ if (p.mode === 'work' && p.desk) occupant.set(p.desk, p);
1578
+ else if (p.napDesk) occupant.set(p.napDesk, p);
1579
+ }
1580
+ const items = [...this.plan.items];
1581
+ for (const dk of this.plan.desks) {
1582
+ const p = occupant.get(dk);
1583
+ items.push({ depth: dk.x + 0.7 + dk.y + 0.4, draw: () => this.desk(dk, p) });
1584
+ items.push({ depth: dk.seat.x + dk.seat.y - 0.06, draw: () => this.chair(dk, p) });
1585
+ }
1586
+ for (const p of this.people.values()) items.push({ depth: p.x + p.y + 0.01, person: p });
1587
+ items.push({ depth: this.cat.x + this.cat.y, draw: () => this.drawCat() });
1588
+ items.sort((a, b) => a.depth - b.depth);
1589
+
1590
+ this.hit = [];
1591
+ const heads = [];
1592
+ for (const it of items) {
1593
+ if (it.person) heads.push([it.person, this.person(it.person)]);
1594
+ else it.draw();
1595
+ }
1596
+ this.pingPongBall();
1597
+ if (this.compact) {
1598
+ // Keep labels readable when the room is scaled down for PiP.
1599
+ c.save();
1600
+ c.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
1601
+ for (const [p, at] of heads) this.bubble(p, {
1602
+ ...at, x: at.x * this.cam.zoom + this.cam.ox, y: at.y * this.cam.zoom + this.cam.oy,
1603
+ });
1604
+ c.restore();
1605
+ } else {
1606
+ for (const [p, at] of heads) this.bubble(p, at);
1607
+ }
1608
+
1609
+ if (this.offDuty > 0) {
1610
+ const d = this.iso(0.2, this.plan.door.y + 0.9, 0);
1611
+ c.font = '600 10px ui-sans-serif, system-ui, -apple-system, "PingFang TC", sans-serif';
1612
+ c.textAlign = 'center';
1613
+ c.fillStyle = this.pal.text;
1614
+ c.globalAlpha = 0.65;
1615
+ c.fillText(`+${this.offDuty} ${this.offDutyLabel}`, d.x - 10, d.y + 14);
1616
+ c.globalAlpha = 1;
1617
+ }
1618
+
1619
+ const z = this.cam.zoom;
1620
+ this.hit = this.hit.map((h) => ({
1621
+ key: h.key,
1622
+ x0: h.x0 * z + this.cam.ox, x1: h.x1 * z + this.cam.ox,
1623
+ y0: h.y0 * z + this.cam.oy, y1: h.y1 * z + this.cam.oy,
1624
+ }));
1625
+ }
1626
+ }
1627
+
1628
+ window.Office = Office;
1629
+ window.OFFICE_AGENT_BADGES = AGENT_BADGES;
1630
+ window.OfficeGrid = Grid;
1631
+ })();