@henryz2004/agency 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +106 -0
- package/lib/codex.js +211 -0
- package/lib/control.js +168 -0
- package/lib/live.js +493 -0
- package/lib/opencode.js +447 -0
- package/lib/paths.js +12 -0
- package/lib/roster.js +204 -0
- package/lib/transcript.js +361 -0
- package/lib/usage.js +346 -0
- package/package.json +27 -0
- package/public/app.js +1021 -0
- package/public/audio-controls.js +165 -0
- package/public/avatar.js +467 -0
- package/public/characters/dev-auburn.json +32 -0
- package/public/characters/dev-auburn.png +0 -0
- package/public/characters/dev-beanie.json +32 -0
- package/public/characters/dev-beanie.png +0 -0
- package/public/characters/dev-glasses.json +32 -0
- package/public/characters/dev-glasses.png +0 -0
- package/public/chat-panel.css +514 -0
- package/public/chat-panel.js +815 -0
- package/public/index.html +190 -0
- package/public/lab.html +129 -0
- package/public/leaderboard.js +222 -0
- package/public/metric.js +34 -0
- package/public/mock-agents.js +70 -0
- package/public/mock.js +277 -0
- package/public/music/Console_Morning.mp3 +0 -0
- package/public/music/Midnight_Desk.mp3 +0 -0
- package/public/music/The_Plant_Beside_the_Door.mp3 +0 -0
- package/public/music/Three_AM_Window.mp3 +0 -0
- package/public/office.js +1484 -0
- package/public/sound.js +382 -0
- package/public/sprites.js +983 -0
- package/public/style.css +506 -0
- package/public/ui.js +50 -0
- package/scripts/_pixpng.mjs +104 -0
- package/scripts/animsheet.mjs +60 -0
- package/scripts/charsheet.mjs +61 -0
- package/scripts/install-hook.mjs +120 -0
- package/server.js +370 -0
package/public/office.js
ADDED
|
@@ -0,0 +1,1484 @@
|
|
|
1
|
+
// office.js — the fully-procedural office (the app's sole renderer): lays mock
|
|
2
|
+
// agents into project desk clusters interleaved with cozy decor zones (lounge,
|
|
3
|
+
// meeting table, kitchen counter, plant beds, pets), then composites the whole
|
|
4
|
+
// scene. Renders into a buffer canvas that the page scales up crisply.
|
|
5
|
+
|
|
6
|
+
import { makeAgents } from './mock-agents.js';
|
|
7
|
+
import {
|
|
8
|
+
px, shade, hashInt, rng,
|
|
9
|
+
drawWall, drawFloor, drawDaylight, moodForHour,
|
|
10
|
+
drawCubicle, drawCrown, drawNeedsYou, drawWalker, drawCat, drawDog, CELL_W, CELL_H,
|
|
11
|
+
} from './sprites.js';
|
|
12
|
+
import { createAvatar } from './avatar.js';
|
|
13
|
+
|
|
14
|
+
const WALL_H = 70; // cream wall band; fills from the top of canvas
|
|
15
|
+
const TOP = WALL_H; // floor starts here (no sky)
|
|
16
|
+
const MARGIN = 16;
|
|
17
|
+
const CLUSTER_PAD = 6; // inset around a cluster's cells
|
|
18
|
+
const HEADER_H = 14; // repo-label clearance atop each cluster (DOM tab sits here)
|
|
19
|
+
const GAP_X = 16; // gap between blocks on a row
|
|
20
|
+
const GAP_Y = 20; // gap between rows
|
|
21
|
+
const UPSCALE = 3; // canvas is CSS-scaled this much; the DOM label layer matches
|
|
22
|
+
|
|
23
|
+
let canvas, ctx, bufW = 0, bufH = 0, frame = 0;
|
|
24
|
+
let agents = [];
|
|
25
|
+
let clusters = []; // {x, y, w, h, count, firstAgent, project, seed}
|
|
26
|
+
let decor = []; // {type, x, y, w, h, seed}
|
|
27
|
+
let cells = []; // {x, y, agent} — flattened, in draw order
|
|
28
|
+
|
|
29
|
+
// ---- floor entities: the walkable user avatar + a wandering cat -------------
|
|
30
|
+
let avatar = null; // the player (created in initOffice, off by default)
|
|
31
|
+
let lastT = 0; // last loop timestamp → real dt for motion
|
|
32
|
+
let walkBtn = null, walkHint = null; // on-screen walk-mode affordances (built in JS)
|
|
33
|
+
let nameBtn = null; // on-screen nametag-visibility toggle
|
|
34
|
+
let labelsHidden = false; // hide all agent name chips when true ('n' / button)
|
|
35
|
+
// roaming pet: dir +1 faces right; sit→sleep when it rests a while; `petted` is
|
|
36
|
+
// set while the walking avatar is right next to it (→ wakes + hearts in drawCat).
|
|
37
|
+
const cat = { x: 0, y: 0, tx: 0, ty: 0, sit: true, until: 0, init: false, dir: 1, sleeping: false, petted: false };
|
|
38
|
+
// the dog lounges in a fixed spot but is PETTABLE like the cat (walk up → hearts);
|
|
39
|
+
// a floor entity so it z-sorts + animates. Position set in layout().
|
|
40
|
+
const dog = { x: 0, y: 0, init: false, petted: false };
|
|
41
|
+
if (typeof window !== 'undefined') { window.__cat = cat; window.__dog = dog; } // reel hooks: frame/control the pets
|
|
42
|
+
// Idle-wander: settled-idle workers leave their desk and amble around the floor —
|
|
43
|
+
// to a JITTERED point inside a lounge/kitchen zone (so they spread out instead of
|
|
44
|
+
// stacking on a fixed spot), often hopping between spots before heading home, and
|
|
45
|
+
// rushing back fast when resumed. Per-agent state keyed by keyOf(agent); each tick
|
|
46
|
+
// re-syncs the agent's home to its (possibly re-laid) desk.
|
|
47
|
+
const wanderers = new Map(); // key -> { phase, x, y, tx, ty, until, homeX, homeY, speed, rush, legDist }
|
|
48
|
+
let wanderZones = []; // [{x, y, w, h}] destination zones (lounge / meeting / plants / kitchen)
|
|
49
|
+
|
|
50
|
+
// DOM overlay layer: real (crisp) HTML text for the name chips + repo labels,
|
|
51
|
+
// instead of canvas pixel text. One wrapper div over the canvas, scaled by
|
|
52
|
+
// UPSCALE so children are positioned in BUFFER coordinates (matching the canvas
|
|
53
|
+
// art); a future camera can transform this one wrapper as a group.
|
|
54
|
+
let labelWrap = null; // the single group wrapper
|
|
55
|
+
const nameNodes = []; // pooled name-chip elements, reused between frames
|
|
56
|
+
const repoNodes = []; // pooled repo-label elements, reused between frames
|
|
57
|
+
const leadNodes = []; // pooled "▸ leadName" captions tying teammates to a lead
|
|
58
|
+
|
|
59
|
+
// ---- layout ----------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
function clusterDims(count) {
|
|
62
|
+
// Lay a team's desks as a LANDSCAPE block (wider than tall) so a big team grows
|
|
63
|
+
// SIDEWAYS instead of into a tall column. A cell is 58w×78h (taller than wide),
|
|
64
|
+
// so to read landscape we need cols well above rows: cols ≈ √(count · 2.4).
|
|
65
|
+
const cols = Math.min(count, Math.max(1, Math.ceil(Math.sqrt(count * 2.4))));
|
|
66
|
+
const rows = Math.ceil(count / cols);
|
|
67
|
+
return {
|
|
68
|
+
cols, rows,
|
|
69
|
+
w: cols * CELL_W + CLUSTER_PAD * 2,
|
|
70
|
+
h: HEADER_H + rows * CELL_H + CLUSTER_PAD,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// One project = ONE team = ONE cluster (one rug, one label). The list arrives
|
|
75
|
+
// project-sorted (see setAgents), so each project is a single contiguous run.
|
|
76
|
+
// A big team is NOT split into pods — clusterDims() wraps it across rows WITHIN
|
|
77
|
+
// the one cluster, so it never reads as two separate teams.
|
|
78
|
+
function clusterRuns(list) {
|
|
79
|
+
const runs = [];
|
|
80
|
+
let i = 0;
|
|
81
|
+
while (i < list.length) {
|
|
82
|
+
const key = list[i].project;
|
|
83
|
+
let j = i;
|
|
84
|
+
while (j < list.length && list[j].project === key) j++;
|
|
85
|
+
runs.push({ project: key, start: i, count: j - i });
|
|
86
|
+
i = j;
|
|
87
|
+
}
|
|
88
|
+
return runs;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Pack the desk clusters into a TIGHT central grid (clusters directly adjacent,
|
|
92
|
+
// small gaps, wrapping into rows) — like the reference's cubicle block — then lay
|
|
93
|
+
// a dedicated cozy LOUNGE BAND of decor along the floor beneath the desks, plus
|
|
94
|
+
// fixed amenities against the back wall. Decor is NOT interleaved between desks
|
|
95
|
+
// (that's what made the floor read sparse); it lives in its own band so the
|
|
96
|
+
// bullpen stays dense.
|
|
97
|
+
const CLUSTER_GAP_X = 12; // tight gap between adjacent desk clusters on a row
|
|
98
|
+
const CLUSTER_GAP_Y = 10; // gap between rows of desk clusters (tight, like the ref)
|
|
99
|
+
const LOUNGE_BAND_H = 60; // height of the decor band beneath the bullpen
|
|
100
|
+
// Reserved band at the top of the floor for back-wall amenities (the kitchen
|
|
101
|
+
// counter ends at TOP+25) and the hanging ceiling pendants (end ~TOP+18). The
|
|
102
|
+
// bullpen starts below this so decor never clips a cluster rug — count-independent.
|
|
103
|
+
const AMENITY_BAND_H = 16;
|
|
104
|
+
// Target bullpen aspect (width:height). >1 packs the desk clusters LANDSCAPE so
|
|
105
|
+
// the floor fills a desktop viewport instead of a tall portrait column. Biased
|
|
106
|
+
// well above 1 because the fixed amenity + lounge bands add height the bullpen
|
|
107
|
+
// must out-widen. Tuned by eye against the floor-frame.
|
|
108
|
+
const LANDSCAPE_ASPECT = 2.6;
|
|
109
|
+
|
|
110
|
+
function plan() {
|
|
111
|
+
clusters = []; decor = []; cells = [];
|
|
112
|
+
const runs = clusterRuns(agents);
|
|
113
|
+
|
|
114
|
+
// --- size each cluster, then pick a columns-per-row that keeps the bullpen
|
|
115
|
+
// roughly landscape (wider than tall) without overflowing a sane width. ---
|
|
116
|
+
const sized = runs.map((run, ci) => {
|
|
117
|
+
const d = clusterDims(run.count);
|
|
118
|
+
return { count: run.count, project: run.project, firstAgent: run.start, w: d.w, h: d.h, dims: d, seed: 101 + ci * 7 };
|
|
119
|
+
});
|
|
120
|
+
// --- LANDSCAPE packing: wrap rows at a TARGET WIDTH derived from total cluster
|
|
121
|
+
// area × the aspect bias, so the bullpen reads wider-than-tall and fills a
|
|
122
|
+
// desktop viewport instead of a narrow column. Adapts to any team count: few
|
|
123
|
+
// teams → one wide row; many → a wide grid. The widest cluster sets a floor so
|
|
124
|
+
// a single big team never overflows its own row. ---
|
|
125
|
+
const bx0 = MARGIN, by0 = TOP + AMENITY_BAND_H;
|
|
126
|
+
const totalArea = sized.reduce((s, b) => s + (b.w + CLUSTER_GAP_X) * b.h, 0);
|
|
127
|
+
const widest = sized.reduce((m, b) => Math.max(m, b.w), 0);
|
|
128
|
+
const targetRowW = Math.max(widest, Math.sqrt(totalArea * LANDSCAPE_ASPECT));
|
|
129
|
+
|
|
130
|
+
let rowW = 0, rowTopH = 0;
|
|
131
|
+
let cx = bx0, cy = by0;
|
|
132
|
+
const rows = [[]];
|
|
133
|
+
sized.forEach((b) => {
|
|
134
|
+
// wrap once the current row has REACHED the target width (the triggering
|
|
135
|
+
// cluster overhangs, which packs rows wider → more landscape). Greedy by
|
|
136
|
+
// width, not by a fixed cluster count.
|
|
137
|
+
if (rows[rows.length - 1].length && (cx - bx0) >= targetRowW) {
|
|
138
|
+
cy += rowTopH + CLUSTER_GAP_Y;
|
|
139
|
+
cx = bx0; rowTopH = 0; rows.push([]);
|
|
140
|
+
}
|
|
141
|
+
b.x = cx; b.y = cy;
|
|
142
|
+
rows[rows.length - 1].push(b);
|
|
143
|
+
cx += b.w + CLUSTER_GAP_X;
|
|
144
|
+
rowTopH = Math.max(rowTopH, b.h);
|
|
145
|
+
rowW = Math.max(rowW, cx - CLUSTER_GAP_X - bx0);
|
|
146
|
+
});
|
|
147
|
+
const bullpenBottom = cy + rowTopH;
|
|
148
|
+
|
|
149
|
+
// centre each row within the widest row so ragged tails read balanced
|
|
150
|
+
rows.forEach((row) => {
|
|
151
|
+
if (!row.length) return;
|
|
152
|
+
const last = row[row.length - 1];
|
|
153
|
+
const used = last.x + last.w - bx0;
|
|
154
|
+
const shift = Math.round((rowW - used) / 2);
|
|
155
|
+
if (shift > 0) row.forEach((b) => { b.x += shift; });
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// --- realise clusters + cells ---
|
|
159
|
+
sized.forEach((b) => {
|
|
160
|
+
clusters.push({ x: b.x, y: b.y, w: b.w, h: b.h, count: b.count, firstAgent: b.firstAgent, project: b.project, seed: b.seed });
|
|
161
|
+
const { cols } = b.dims;
|
|
162
|
+
for (let k = 0; k < b.count; k++) {
|
|
163
|
+
const c = k % cols, r = Math.floor(k / cols);
|
|
164
|
+
cells.push({
|
|
165
|
+
x: b.x + CLUSTER_PAD + c * CELL_W,
|
|
166
|
+
y: b.y + HEADER_H + r * CELL_H,
|
|
167
|
+
agent: agents[b.firstAgent + k],
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// --- DECOR PLACEMENT: the room HUGS its content so there's no cavern of dead floor.
|
|
173
|
+
// Two regimes by floor size:
|
|
174
|
+
// SNUG (≤4 desks): desks high under the wall + a plant bed in each upper corner —
|
|
175
|
+
// a small office, not a hall.
|
|
176
|
+
// FULL (>4 desks): desks high + ONE coherent lounge band directly below them +
|
|
177
|
+
// a kitchen against the wall. Compact; no scattered fill.
|
|
178
|
+
// The camera (fitView) centres on the desks, so a compact/portrait room still frames
|
|
179
|
+
// them well, and the soft zoom-out lets you pull back to the whole floor.
|
|
180
|
+
const nAgents = cells.length;
|
|
181
|
+
const minRoomW = 320;
|
|
182
|
+
|
|
183
|
+
if (nAgents <= 4) {
|
|
184
|
+
// ---- SNUG: a cozy small office — desks high under the wall, a plant bed in each
|
|
185
|
+
// UPPER CORNER tucked against the back wall (head-on, so it overlaps the wall's
|
|
186
|
+
// base) instead of at desk height where it clipped the rug. ----
|
|
187
|
+
const flankW = nAgents <= 2 ? 40 : 52; // slim flanks for 1–2 desks
|
|
188
|
+
const gap = 10;
|
|
189
|
+
bufW = rowW + 2 * (flankW + gap) + MARGIN * 2;
|
|
190
|
+
bufH = bullpenBottom + 16 + MARGIN;
|
|
191
|
+
clusters.forEach((c) => { c.x += flankW + gap; });
|
|
192
|
+
cells.forEach((c) => { c.x += flankW + gap; });
|
|
193
|
+
// a plant bed in each upper corner, standing AGAINST the wall (overlaps its base)
|
|
194
|
+
decor.push({ type: 'plants', x: MARGIN, y: TOP - 6, w: flankW, h: 44, seed: hashInt('pl') });
|
|
195
|
+
decor.push({ type: 'plants', x: bufW - MARGIN - flankW, y: TOP - 6, w: flankW, h: 44, seed: hashInt('pr') });
|
|
196
|
+
dog.x = bufW - MARGIN - flankW / 2; dog.y = bullpenBottom - 4; dog.init = true;
|
|
197
|
+
} else {
|
|
198
|
+
// ---- FULL: desks high under the wall + ONE coherent lounge band directly below.
|
|
199
|
+
// The room HUGS (desks + lounge) — no cavern of dead floor, and the decor reads as
|
|
200
|
+
// a single sensible lounge instead of furniture scattered across an empty hall.
|
|
201
|
+
bufW = Math.max(minRoomW, rowW + MARGIN * 2);
|
|
202
|
+
// centre the bullpen horizontally
|
|
203
|
+
const bullShift = Math.round((bufW - MARGIN * 2 - rowW) / 2);
|
|
204
|
+
if (bullShift > 0) {
|
|
205
|
+
clusters.forEach((c) => { c.x += bullShift; });
|
|
206
|
+
cells.forEach((c) => { c.x += bullShift; });
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// one lounge band beneath the desks: grouped, coherent pieces (couch+table,
|
|
210
|
+
// meeting table, plant beds) scaled to the floor width.
|
|
211
|
+
const bandY = bullpenBottom + 10;
|
|
212
|
+
const floorW = bufW - MARGIN * 2;
|
|
213
|
+
const wOf = (t) => (t === 'lounge' ? 104 : t === 'meeting' ? 96 : 44);
|
|
214
|
+
const pieces = floorW > 560 ? ['plants', 'lounge', 'meeting', 'lounge', 'plants']
|
|
215
|
+
: floorW > 380 ? ['plants', 'lounge', 'meeting', 'plants']
|
|
216
|
+
: floorW > 230 ? ['lounge', 'plants']
|
|
217
|
+
: ['lounge'];
|
|
218
|
+
const used = pieces.reduce((s, t) => s + wOf(t), 0);
|
|
219
|
+
const gap = pieces.length > 1 ? Math.max(16, (floorW - used) / (pieces.length - 1)) : 0;
|
|
220
|
+
let lx = MARGIN + Math.max(0, (floorW - used - gap * (pieces.length - 1)) / 2);
|
|
221
|
+
pieces.forEach((t) => {
|
|
222
|
+
decor.push({ type: t, x: Math.round(lx), y: bandY, w: wOf(t), h: LOUNGE_BAND_H, seed: hashInt('b' + lx) });
|
|
223
|
+
lx += wOf(t) + gap;
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
bufH = bandY + LOUNGE_BAND_H + MARGIN;
|
|
227
|
+
// kitchen counter tucked UP against the back wall, far right (base above the desk
|
|
228
|
+
// line so it never dips into a team rug)
|
|
229
|
+
decor.push({ type: 'kitchen', x: bufW - 84, y: TOP - 12, w: 78, h: 26, seed: 999 });
|
|
230
|
+
// the dog naps at the end of the lounge band
|
|
231
|
+
dog.x = bufW - MARGIN - 30; dog.y = bandY + LOUNGE_BAND_H - 4; dog.init = true;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// idle-wander destination ZONES: the lounge spots (couch / meeting / plants) plus
|
|
235
|
+
// the kitchen/vending bar, so workers actually visit the amenities. Stored as
|
|
236
|
+
// zones — updateWanderers picks a JITTERED point inside one, so workers spread
|
|
237
|
+
// out instead of stacking on a fixed point. (Empty floor → none → everyone stays.)
|
|
238
|
+
wanderZones = decor
|
|
239
|
+
.filter((z) => z.type === 'lounge' || z.type === 'meeting' || z.type === 'plants' || z.type === 'kitchen')
|
|
240
|
+
.map((z) => ({ x: z.x, y: z.y, w: z.w, h: z.h, kind: z.type }));
|
|
241
|
+
|
|
242
|
+
// focal point = centre of the desk bullpen, so fitView() frames the desks on any
|
|
243
|
+
// room shape (not the empty back wall).
|
|
244
|
+
if (clusters.length) {
|
|
245
|
+
let minx = Infinity, maxx = -Infinity, miny = Infinity, maxy = -Infinity;
|
|
246
|
+
clusters.forEach((c) => {
|
|
247
|
+
minx = Math.min(minx, c.x); maxx = Math.max(maxx, c.x + c.w);
|
|
248
|
+
miny = Math.min(miny, c.y); maxy = Math.max(maxy, c.y + c.h);
|
|
249
|
+
});
|
|
250
|
+
focusBX = (minx + maxx) / 2; focusBY = (miny + maxy) / 2;
|
|
251
|
+
} else { focusBX = bufW / 2; focusBY = bufH / 2; }
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ---- decor drawing ---------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
function drawDecor() {
|
|
257
|
+
for (const z of decor) {
|
|
258
|
+
if (z.type === 'lounge') drawLounge(z);
|
|
259
|
+
else if (z.type === 'meeting') drawMeeting(z);
|
|
260
|
+
else if (z.type === 'plants') drawPlants(z);
|
|
261
|
+
else if (z.type === 'kitchen') drawKitchen(z);
|
|
262
|
+
// (the cat + dog are floor entities now — drawn in the actor loop, not as decor)
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function drawAreaRug(z, fill, edge) {
|
|
267
|
+
const rx = z.x + 4, ry = z.y + z.h - 40, rw = z.w - 8, rh = 36;
|
|
268
|
+
px(ctx, rx + 3, ry, rw - 6, rh, fill);
|
|
269
|
+
px(ctx, rx, ry + 3, rw, rh - 6, fill);
|
|
270
|
+
px(ctx, rx + 3, ry, rw - 6, 1, edge);
|
|
271
|
+
px(ctx, rx + 3, ry + rh - 1, rw - 6, 1, edge);
|
|
272
|
+
px(ctx, rx + 8, ry + 4, rw - 16, 1, 'rgba(255,255,255,0.08)');
|
|
273
|
+
return { rx, ry, rw, rh, floorY: ry + rh - 4 };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function drawLounge(z) {
|
|
277
|
+
const { floorY, rx } = drawAreaRug(z, '#caa46c', '#b58f56'); // warm sand rug
|
|
278
|
+
// two-seat couch on the left, FRONT-FACING (tall backrest at the back, seat
|
|
279
|
+
// cushions toward the viewer, arms framing the sides) so it reads as a sofa you
|
|
280
|
+
// sit into — not an ambiguous top-down slab.
|
|
281
|
+
const couchCols = ['#d9694f', '#5d9ce0', '#5dc98a', '#a87de0'];
|
|
282
|
+
const couch = couchCols[hashInt('couch' + z.seed) % couchCols.length];
|
|
283
|
+
const lite = shade(couch, 20), dark = shade(couch, -22), arm = shade(couch, -8);
|
|
284
|
+
const cxx = rx + 4, cyy = floorY - 16;
|
|
285
|
+
// backrest (tall block at the back)
|
|
286
|
+
px(ctx, cxx + 3, cyy - 8, 30, 9, couch);
|
|
287
|
+
px(ctx, cxx + 3, cyy - 8, 30, 1, lite); // top highlight
|
|
288
|
+
px(ctx, cxx + 17, cyy - 7, 1, 7, dark); // back split between the seats
|
|
289
|
+
// seat cushions in front of the backrest
|
|
290
|
+
px(ctx, cxx, cyy + 1, 36, 7, couch);
|
|
291
|
+
px(ctx, cxx, cyy + 1, 36, 1, lite); // cushion top edge
|
|
292
|
+
px(ctx, cxx + 17, cyy + 1, 1, 7, dark); // seam between the two seats
|
|
293
|
+
// front base / skirt + little feet
|
|
294
|
+
px(ctx, cxx + 2, cyy + 8, 32, 2, dark);
|
|
295
|
+
px(ctx, cxx + 3, cyy + 10, 2, 2, '#2c2018');
|
|
296
|
+
px(ctx, cxx + 31, cyy + 10, 2, 2, '#2c2018');
|
|
297
|
+
// arms framing the seat (a touch taller than the cushions)
|
|
298
|
+
px(ctx, cxx - 2, cyy - 5, 5, 14, arm);
|
|
299
|
+
px(ctx, cxx + 33, cyy - 5, 5, 14, arm);
|
|
300
|
+
px(ctx, cxx - 2, cyy - 5, 5, 1, lite);
|
|
301
|
+
px(ctx, cxx + 33, cyy - 5, 5, 1, lite);
|
|
302
|
+
// a throw pillow on the left cushion
|
|
303
|
+
px(ctx, cxx + 4, cyy + 2, 7, 5, '#f4d35e');
|
|
304
|
+
// coffee table in front
|
|
305
|
+
const tx = cxx + 40, ty = floorY - 8;
|
|
306
|
+
px(ctx, tx, ty, 18, 5, '#8a5e34');
|
|
307
|
+
px(ctx, tx, ty, 18, 1, '#a5743f');
|
|
308
|
+
px(ctx, tx + 1, ty + 5, 2, 3, '#5a3d22');
|
|
309
|
+
px(ctx, tx + 15, ty + 5, 2, 3, '#5a3d22');
|
|
310
|
+
px(ctx, tx + 6, ty - 2, 4, 2, '#d8d2c8'); // mug on the table
|
|
311
|
+
// a plant at the back-right
|
|
312
|
+
drawPlant(ctx, tx + 22, floorY - 3);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function drawMeeting(z) {
|
|
316
|
+
const { floorY, rx, rw } = drawAreaRug(z, '#54707b', '#46606a'); // slate task rug
|
|
317
|
+
// a long meeting table centred
|
|
318
|
+
const tw = rw - 24, tx = rx + 12, ty = floorY - 12;
|
|
319
|
+
px(ctx, tx, ty, tw, 7, '#a5743f');
|
|
320
|
+
px(ctx, tx, ty, tw, 2, '#c08a52');
|
|
321
|
+
px(ctx, tx, ty + 7, tw, 2, '#6f4a2a');
|
|
322
|
+
px(ctx, tx + 2, ty + 9, 2, 4, '#5a3d22'); // legs
|
|
323
|
+
px(ctx, tx + tw - 4, ty + 9, 2, 4, '#5a3d22');
|
|
324
|
+
// laptops / a coffee on top
|
|
325
|
+
px(ctx, tx + 6, ty - 3, 8, 4, '#1c2230');
|
|
326
|
+
px(ctx, tx + 7, ty - 2, 6, 2, '#5cd0ff');
|
|
327
|
+
px(ctx, tx + tw - 16, ty - 3, 8, 4, '#1c2230');
|
|
328
|
+
px(ctx, tx + tw - 15, ty - 2, 6, 2, '#ffd166');
|
|
329
|
+
px(ctx, tx + (tw >> 1) - 2, ty - 2, 3, 2, '#c0473a'); // mug
|
|
330
|
+
// accent chairs pulled up
|
|
331
|
+
const chairCols = ['#e0855d', '#5dc98a', '#e0b05d', '#5d9ce0'];
|
|
332
|
+
const c1 = chairCols[hashInt('ch' + z.seed) % chairCols.length];
|
|
333
|
+
const c2 = chairCols[hashInt('ch' + z.seed + 3) % chairCols.length];
|
|
334
|
+
px(ctx, tx - 8, floorY - 12, 6, 12, c1);
|
|
335
|
+
px(ctx, tx - 8, floorY - 12, 6, 2, shade(c1, 20));
|
|
336
|
+
px(ctx, tx + tw + 2, floorY - 12, 6, 12, c2);
|
|
337
|
+
px(ctx, tx + tw + 2, floorY - 12, 6, 2, shade(c2, 20));
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function drawPlants(z) {
|
|
341
|
+
const floorY = z.y + z.h - 4;
|
|
342
|
+
// a low wood planter box with plants TILED across it, so a wide bed reads as a
|
|
343
|
+
// full planter — not two lonely plants at the ends with an empty box between.
|
|
344
|
+
const bx = z.x + 2, bw = z.w - 4, by = floorY - 6;
|
|
345
|
+
px(ctx, bx, by, bw, 7, '#6f4d2b');
|
|
346
|
+
px(ctx, bx, by, bw, 2, '#8a6238');
|
|
347
|
+
px(ctx, bx, by + 6, bw, 1, '#5a3d22');
|
|
348
|
+
const slot = 14; // a single plant's footprint
|
|
349
|
+
const n = Math.max(1, Math.floor(bw / 18)); // ~one plant per 18px of box
|
|
350
|
+
const gap = n > 1 ? (bw - n * slot) / (n - 1) : 0;
|
|
351
|
+
const x0 = bx + (n > 1 ? 0 : (bw - slot) / 2); // centre a lone plant
|
|
352
|
+
for (let i = 0; i < n; i++) {
|
|
353
|
+
drawPlant(ctx, Math.round(x0 + i * (slot + gap)), floorY - (i % 2 ? 1 : 5));
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function drawPlant(ctx, x, baseY) {
|
|
358
|
+
// a leafy potted plant ~14 tall
|
|
359
|
+
px(ctx, x + 3, baseY - 5, 8, 6, '#b5602e'); // pot
|
|
360
|
+
px(ctx, x + 3, baseY - 5, 8, 1, '#cd7438'); // pot rim
|
|
361
|
+
px(ctx, x + 4, baseY - 11, 6, 7, '#3f9a55'); // foliage core
|
|
362
|
+
px(ctx, x + 2, baseY - 9, 3, 5, '#4cb364'); // left frond
|
|
363
|
+
px(ctx, x + 9, baseY - 9, 3, 5, '#4cb364'); // right frond
|
|
364
|
+
px(ctx, x + 5, baseY - 14, 4, 4, '#5cc873'); // top frond
|
|
365
|
+
px(ctx, x + 5, baseY - 13, 2, 2, '#74dd8c'); // highlight
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function drawKitchen(z) {
|
|
369
|
+
const x = z.x, y = z.y, w = z.w;
|
|
370
|
+
const floorY = y + 26;
|
|
371
|
+
// counter cabinet
|
|
372
|
+
px(ctx, x, y + 8, w, 18, '#cfd5de');
|
|
373
|
+
px(ctx, x, y + 8, w, 2, '#e2e7ee'); // counter top light
|
|
374
|
+
px(ctx, x, y + 24, w, 2, 'rgba(0,0,0,0.12)');
|
|
375
|
+
// cabinet doors
|
|
376
|
+
for (let dx = x + 3; dx < x + w - 6; dx += 16) {
|
|
377
|
+
px(ctx, dx, y + 12, 13, 11, '#bcc3cd');
|
|
378
|
+
px(ctx, dx + 11, y + 16, 1, 3, '#8a93a0'); // handle
|
|
379
|
+
}
|
|
380
|
+
// a coffee machine on the counter
|
|
381
|
+
px(ctx, x + 5, y + 1, 9, 8, '#2b2f38');
|
|
382
|
+
px(ctx, x + 6, y + 2, 7, 3, '#3a414c');
|
|
383
|
+
px(ctx, x + 7, y + 5, 5, 2, '#c0473a'); // hot plate glow
|
|
384
|
+
// two mugs
|
|
385
|
+
px(ctx, x + 17, y + 4, 4, 4, '#e4ded3');
|
|
386
|
+
px(ctx, x + 17, y + 4, 4, 1, '#f2ede4');
|
|
387
|
+
px(ctx, x + 23, y + 5, 4, 3, '#5dc98a');
|
|
388
|
+
// a vending machine on the right end
|
|
389
|
+
const vx = x + w - 18;
|
|
390
|
+
px(ctx, vx, y - 6, 16, 32, '#c0473a');
|
|
391
|
+
px(ctx, vx + 2, y - 4, 9, 24, '#1a2a3a'); // glass
|
|
392
|
+
ctx.fillStyle = '#5cd0ff';
|
|
393
|
+
for (let r = 0; r < 4; r++) for (let c = 0; c < 3; c++) px(ctx, vx + 3 + c * 3, y - 3 + r * 6, 2, 4, ['#5cd0ff', '#ffd166', '#6cff9a', '#e07db0'][(r + c) % 4]);
|
|
394
|
+
px(ctx, vx + 12, y + 4, 3, 6, '#0d1018'); // dispense slot
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// A muted area rug under each desk cluster — grounds the team neighbourhood and
|
|
398
|
+
// lets the warm wood read as the circulation path around it. Rug tone rotates
|
|
399
|
+
// across a small dusty palette so adjacent teams read as distinct zones.
|
|
400
|
+
const RUG_TONES = [
|
|
401
|
+
{ fill: '#4a6360', edge: '#3b504e' }, // dusty teal
|
|
402
|
+
{ fill: '#5c5168', edge: '#473f53' }, // muted plum
|
|
403
|
+
{ fill: '#6b5642', edge: '#564536' }, // warm taupe
|
|
404
|
+
{ fill: '#4a5a6e', edge: '#3c4a5b' }, // slate blue
|
|
405
|
+
];
|
|
406
|
+
function drawClusterRugs() {
|
|
407
|
+
clusters.forEach((c) => {
|
|
408
|
+
const tone = RUG_TONES[hashInt('rug' + c.seed) % RUG_TONES.length];
|
|
409
|
+
// Rug now starts near the top of the cluster (was below the header band) so
|
|
410
|
+
// it includes a top strip where the repo label is "sewn into" the carpet.
|
|
411
|
+
const rx = c.x - 4, ry = c.y + 2, rw = c.w + 8, rh = c.h + 2;
|
|
412
|
+
px(ctx, rx, ry, rw, rh, tone.fill);
|
|
413
|
+
px(ctx, rx, ry, rw, 2, tone.edge);
|
|
414
|
+
px(ctx, rx, ry + rh - 2, rw, 2, tone.edge);
|
|
415
|
+
px(ctx, rx, ry, 2, rh, tone.edge);
|
|
416
|
+
px(ctx, rx + rw - 2, ry, 2, rh, tone.edge);
|
|
417
|
+
px(ctx, rx + 4, ry + 3, rw - 8, 1, 'rgba(255,255,255,0.05)'); // faint sheen
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// ---- DOM label overlay (real crisp text) ------------------------------------
|
|
422
|
+
// Name chips + repo labels are HTML, not canvas pixels — sharp DOM text scaled
|
|
423
|
+
// by the camera, which is far more legible than any bitmap font at this buffer
|
|
424
|
+
// resolution. The wrapper is sized
|
|
425
|
+
// in BUFFER coords and scaled by UPSCALE, so node positions reuse the same
|
|
426
|
+
// cell/cluster coords the canvas art uses. Nodes are pooled and reused between
|
|
427
|
+
// updates so nothing leaks. A future camera can transform `labelWrap` as a group.
|
|
428
|
+
|
|
429
|
+
// Build (once) the group wrapper, parented to the canvas's container so it
|
|
430
|
+
// overlays the canvas exactly. Inline styles only — we don't own index.html/css.
|
|
431
|
+
function ensureLabelWrap() {
|
|
432
|
+
if (labelWrap || !canvas || !canvas.parentElement) return;
|
|
433
|
+
// inject the "just finished / unread" glow keyframe once (scoped <style>; we
|
|
434
|
+
// don't own css). A calm CYAN breathe on the whole name chip — matches the
|
|
435
|
+
// topbar's cyan "new for you" language and the wave the worker does; replaces
|
|
436
|
+
// the old hot-magenta corner pip.
|
|
437
|
+
if (!document.getElementById('proc-kf')) {
|
|
438
|
+
const s = document.createElement('style');
|
|
439
|
+
s.id = 'proc-kf';
|
|
440
|
+
s.textContent =
|
|
441
|
+
'@keyframes procUnreadGlow{0%,100%{box-shadow:0 0 4px 1px rgba(92,208,255,0.5)}' +
|
|
442
|
+
'50%{box-shadow:0 0 10px 3px rgba(92,208,255,0.85)}}';
|
|
443
|
+
document.head.appendChild(s);
|
|
444
|
+
}
|
|
445
|
+
labelWrap = document.createElement('div');
|
|
446
|
+
labelWrap.className = 'proc-label-layer';
|
|
447
|
+
labelWrap.style.cssText =
|
|
448
|
+
'position:absolute;left:0;top:0;transform-origin:0 0;pointer-events:none;z-index:5;';
|
|
449
|
+
canvas.parentElement.appendChild(labelWrap);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Floor label face: IBM Plex Mono (clean, readable mono) for names/teams, small —
|
|
453
|
+
// the pixel identity lives in the art + Press Start 2P headers/numbers, not body text.
|
|
454
|
+
const NAME_FONT = "7px 'IBM Plex Mono', ui-monospace, monospace";
|
|
455
|
+
const REPO_FONT = "9px 'IBM Plex Mono', ui-monospace, monospace";
|
|
456
|
+
|
|
457
|
+
function statusColor(act) {
|
|
458
|
+
return act === 'working' ? '#39d98a' : act === 'shell' ? '#ffb454' : '#5a6478';
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Position one name chip per agent (under its desk) and one repo label per
|
|
462
|
+
// cluster (in the clearance band above the pod). Reuses pooled nodes; hides any
|
|
463
|
+
// surplus from a previous, larger frame.
|
|
464
|
+
function syncLabels() {
|
|
465
|
+
ensureLabelWrap();
|
|
466
|
+
if (!labelWrap) return;
|
|
467
|
+
// labelWrap's size + scale are owned by applyCam() (it scales with the camera,
|
|
468
|
+
// matching the canvas). Here we only (re)position the child labels in buffer coords.
|
|
469
|
+
|
|
470
|
+
// --- name chips (one per agent/cell) ---
|
|
471
|
+
cells.forEach((cell, i) => {
|
|
472
|
+
const a = cell.agent;
|
|
473
|
+
const first = String(a.name || '').split(/\s+/)[0] || a.name || '—';
|
|
474
|
+
let el = nameNodes[i];
|
|
475
|
+
if (!el) {
|
|
476
|
+
el = document.createElement('div');
|
|
477
|
+
el.style.cssText =
|
|
478
|
+
'position:absolute;transform:translateX(-50%);display:inline-flex;' +
|
|
479
|
+
'align-items:center;gap:2px;max-width:58px;box-sizing:border-box;' +
|
|
480
|
+
'padding:0 3px 0 2px;height:8px;white-space:nowrap;' +
|
|
481
|
+
'background:rgba(14,20,32,0.9);border:1px solid rgba(255,255,255,0.16);' +
|
|
482
|
+
'border-radius:3px;line-height:1;';
|
|
483
|
+
const dot = document.createElement('span');
|
|
484
|
+
dot.dataset.role = 'dot';
|
|
485
|
+
dot.style.cssText = 'width:3px;height:3px;border-radius:50%;flex:0 0 auto;';
|
|
486
|
+
const name = document.createElement('span');
|
|
487
|
+
name.dataset.role = 'name';
|
|
488
|
+
name.style.cssText =
|
|
489
|
+
`font:${NAME_FONT};color:#eef3fb;line-height:1;` +
|
|
490
|
+
'overflow:hidden;text-overflow:ellipsis;';
|
|
491
|
+
// "just finished / unread" is shown as a cyan glow on the whole chip (see
|
|
492
|
+
// syncLabels below) + a wave from the worker — no separate corner badge.
|
|
493
|
+
el.appendChild(dot); el.appendChild(name);
|
|
494
|
+
labelWrap.appendChild(el);
|
|
495
|
+
nameNodes[i] = el;
|
|
496
|
+
}
|
|
497
|
+
el.style.display = (labelsHidden || a.placeholder) ? 'none' : 'inline-flex';
|
|
498
|
+
if (a.placeholder) return; // a vacant desk has no name chip
|
|
499
|
+
// Pin the chip: FOLLOW a wandering worker, else sit under the desk. This
|
|
500
|
+
// per-poll pass uses the SAME rule as the per-frame draw(), so the chip never
|
|
501
|
+
// flickers back to the desk for a frame when a poll lands mid-stroll.
|
|
502
|
+
const w = wanderers.get(keyOf(a));
|
|
503
|
+
if (w && w.phase !== 'seated') { el.style.left = Math.round(w.x) + 'px'; el.style.top = Math.round(w.y - 46) + 'px'; }
|
|
504
|
+
else { el.style.left = (cell.x + CELL_W / 2) + 'px'; el.style.top = (cell.y + CELL_H - 10) + 'px'; }
|
|
505
|
+
const dot = el.firstChild, name = el.children[1];
|
|
506
|
+
const col = statusColor(a.activity);
|
|
507
|
+
dot.style.background = col;
|
|
508
|
+
dot.style.boxShadow = a.activity === 'idle' ? 'none' : `0 0 4px ${col}`;
|
|
509
|
+
name.textContent = first;
|
|
510
|
+
// unread = agent just finished + not yet viewed (app.js owns the set). Shown
|
|
511
|
+
// as a cyan glow on the chip (the worker also waves, see drawCubicle/unread).
|
|
512
|
+
const unread = !!(a.sessionId && window.agencyUnread && window.agencyUnread.has(a.sessionId));
|
|
513
|
+
el.style.borderColor = unread ? 'rgba(92,208,255,0.85)' : 'rgba(255,255,255,0.18)';
|
|
514
|
+
el.style.animation = unread ? 'procUnreadGlow 1.8s ease-in-out infinite' : 'none';
|
|
515
|
+
});
|
|
516
|
+
for (let i = cells.length; i < nameNodes.length; i++) nameNodes[i].style.display = 'none';
|
|
517
|
+
|
|
518
|
+
// --- repo labels (one per cluster) ---
|
|
519
|
+
clusters.forEach((c, i) => {
|
|
520
|
+
let el = repoNodes[i];
|
|
521
|
+
if (!el) {
|
|
522
|
+
el = document.createElement('div');
|
|
523
|
+
// "Stamped INTO the carpet": a DEBOSSED label, not text floating on top. The
|
|
524
|
+
// glyphs are a dark recess (semi-transparent black, so it darkens whatever rug
|
|
525
|
+
// tone it sits on) with a 1px light lower lip — the classic engraved/letterpress
|
|
526
|
+
// cue, so the team name reads as pressed into the fabric. Lowercase + trailing
|
|
527
|
+
// slash so it reads plainly as the folder.
|
|
528
|
+
el.style.cssText =
|
|
529
|
+
'position:absolute;transform:translateX(-50%);white-space:nowrap;' +
|
|
530
|
+
"font:600 7px 'IBM Plex Mono', ui-monospace, monospace;letter-spacing:0.2px;" +
|
|
531
|
+
'color:rgba(232,224,205,0.66);text-align:center;' +
|
|
532
|
+
'text-shadow:0 1px 2px rgba(0,0,0,0.6);' +
|
|
533
|
+
'overflow:hidden;text-overflow:ellipsis;box-sizing:border-box;';
|
|
534
|
+
labelWrap.appendChild(el);
|
|
535
|
+
repoNodes[i] = el;
|
|
536
|
+
}
|
|
537
|
+
el.style.display = 'block';
|
|
538
|
+
// Allow the label to spill ~half the inter-cluster gap on each side so a
|
|
539
|
+
// tiny (1-agent) rug doesn't ellipsis a readable name; neighbours are also
|
|
540
|
+
// centred so the boxes meet at the gap midpoint without overlapping text.
|
|
541
|
+
el.style.maxWidth = (c.w + CLUSTER_GAP_X) + 'px';
|
|
542
|
+
el.style.left = (c.x + c.w / 2) + 'px';
|
|
543
|
+
el.style.top = (c.y + 2) + 'px'; // on the rug's top strip
|
|
544
|
+
const proj = String(c.project || '');
|
|
545
|
+
el.textContent = proj ? proj + '/' : '';
|
|
546
|
+
});
|
|
547
|
+
for (let i = clusters.length; i < repoNodes.length; i++) repoNodes[i].style.display = 'none';
|
|
548
|
+
|
|
549
|
+
// --- teammate → lead tie: a small "▸ leadName" caption just under a teammate's
|
|
550
|
+
// chip so a sub-agent reads as "X's helper", not a peer (pm1 sets kind/leadName). ---
|
|
551
|
+
cells.forEach((cell, i) => {
|
|
552
|
+
const a = cell.agent;
|
|
553
|
+
const tie = a.kind === 'teammate' && a.leadName ? '↳ ' + a.leadName + "'s helper" : '';
|
|
554
|
+
let el = leadNodes[i];
|
|
555
|
+
if (!el) {
|
|
556
|
+
el = document.createElement('div');
|
|
557
|
+
el.style.cssText =
|
|
558
|
+
'position:absolute;transform:translateX(-50%);white-space:nowrap;' +
|
|
559
|
+
"font:8px 'IBM Plex Mono', ui-monospace, monospace;color:#9fb3cf;" +
|
|
560
|
+
'text-shadow:0 1px 2px rgba(0,0,0,0.65);';
|
|
561
|
+
labelWrap.appendChild(el);
|
|
562
|
+
leadNodes[i] = el;
|
|
563
|
+
}
|
|
564
|
+
if (!tie) { el.style.display = 'none'; return; }
|
|
565
|
+
el.style.display = 'block';
|
|
566
|
+
el.textContent = tie;
|
|
567
|
+
el.style.left = (cell.x + CELL_W / 2) + 'px';
|
|
568
|
+
el.style.top = (cell.y + CELL_H + 1) + 'px'; // just beneath the name chip
|
|
569
|
+
});
|
|
570
|
+
for (let i = cells.length; i < leadNodes.length; i++) leadNodes[i].style.display = 'none';
|
|
571
|
+
|
|
572
|
+
syncHiddenChip();
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// A small floor control showing how many desks the user has hidden, toggling
|
|
576
|
+
// them back into view. The hidden STATE is owned by app.js (agent.hidden); proc
|
|
577
|
+
// just collapses them by default and offers a local reveal (no poll needed —
|
|
578
|
+
// rebuild() re-filters lastAll). Anchored to the floor-frame, not the scaled
|
|
579
|
+
// label layer, so it stays a crisp, fixed-size control.
|
|
580
|
+
let hiddenChip = null;
|
|
581
|
+
function syncHiddenChip() {
|
|
582
|
+
const host = world && world.parentElement;
|
|
583
|
+
if (!host) return;
|
|
584
|
+
if (!hiddenChip) {
|
|
585
|
+
hiddenChip = document.createElement('button');
|
|
586
|
+
hiddenChip.type = 'button';
|
|
587
|
+
hiddenChip.style.cssText =
|
|
588
|
+
'position:absolute;left:12px;top:12px;z-index:8;cursor:pointer;' +
|
|
589
|
+
'appearance:none;-webkit-appearance:none;outline:none;' +
|
|
590
|
+
'padding:3px 9px;border-radius:11px;white-space:nowrap;' +
|
|
591
|
+
'background:rgba(14,20,32,0.9);border:1px solid rgba(255,255,255,0.22);' +
|
|
592
|
+
"font:11px 'IBM Plex Mono', ui-monospace, monospace;color:#cdd8ea;letter-spacing:.3px;";
|
|
593
|
+
hiddenChip.addEventListener('click', () => { showHidden = !showHidden; rebuild(); });
|
|
594
|
+
host.appendChild(hiddenChip);
|
|
595
|
+
}
|
|
596
|
+
// count emptied → also drop the reveal flag, else a future hide would stay
|
|
597
|
+
// visible with no chip to re-collapse it (the chip is gone at count 0).
|
|
598
|
+
if (!hiddenCount) { showHidden = false; hiddenChip.style.display = 'none'; return; }
|
|
599
|
+
hiddenChip.style.display = 'inline-block';
|
|
600
|
+
hiddenChip.textContent = showHidden
|
|
601
|
+
? `▾ hide ${hiddenCount} hidden`
|
|
602
|
+
: `▸ ${hiddenCount} away · show`;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// ---- camera: pan / zoom / click-to-select -----------------------------------
|
|
606
|
+
// The office uses a `.world` wrapper (absolute, origin 0,0, the canvas's parent).
|
|
607
|
+
// Pan = a translate on `.world` so the canvas AND the DOM label group (both
|
|
608
|
+
// children of `.world`) move together. Zoom scales the canvas crisply via CSS
|
|
609
|
+
// width (a transform-scale on the canvas would blur it) and the label group by a
|
|
610
|
+
// matching factor. A click (a press with no drag) hit-tests the desk cells and
|
|
611
|
+
// dispatches the `agency:select` event the chat panel listens for.
|
|
612
|
+
let world = null;
|
|
613
|
+
let recenterBtn = null; // shared #recenter button (CSS hides it until .show)
|
|
614
|
+
const cam = { x: 0, y: 0, s: 1 }; // s multiplies the UPSCALE base
|
|
615
|
+
let focusBX = 0, focusBY = 0; // centre of the desk bullpen (the default camera focus)
|
|
616
|
+
let userMoved = false; // once the user pans/zooms, stop auto-fitting
|
|
617
|
+
let reservedRight = 0; // px reserved on the right for the hovering panel
|
|
618
|
+
const clampN = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
|
619
|
+
|
|
620
|
+
// ui.js calls this when the stats panel opens/closes/resizes so the office fits
|
|
621
|
+
// in the free area LEFT of the panel (0 when collapsed). fitView() subtracts it
|
|
622
|
+
// from the available width; re-fit unless the user has taken over the camera.
|
|
623
|
+
export function setReservedRight(px) {
|
|
624
|
+
reservedRight = Math.max(0, px || 0);
|
|
625
|
+
if (!canvas) return; // office not initialized yet
|
|
626
|
+
// Don't re-fit during a walk (it would reset cam.s and drop the immersive zoom);
|
|
627
|
+
// just re-confine to the new available width. Mirrors the guard in the loop.
|
|
628
|
+
if (!userMoved && !(avatar && avatar.enabled)) fitView();
|
|
629
|
+
else { clampPan(); applyCam(); }
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// Selection + hover, tracked by a STABLE key so the selection survives the 3s
|
|
633
|
+
// /api/state polls (the agent objects are replaced each poll).
|
|
634
|
+
let selectedKey = null; // the clicked desk (persistent highlight)
|
|
635
|
+
let hoverKey = null; // the desk under the cursor (transient)
|
|
636
|
+
let lastInspectAgent = null; // agent the avatar is walking past (E opens it)
|
|
637
|
+
const keyOf = (a) => a ? (a.sessionId || (a.pid != null ? `pid:${a.pid}` : null)) : null;
|
|
638
|
+
|
|
639
|
+
function viewportRect() {
|
|
640
|
+
const host = world && world.parentElement; // .floor-frame
|
|
641
|
+
return host ? host.getBoundingClientRect() : { left: 0, top: 0, width: 800, height: 600 };
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function applyCam() {
|
|
645
|
+
if (!canvas) return;
|
|
646
|
+
const eff = UPSCALE * cam.s;
|
|
647
|
+
canvas.style.position = 'absolute';
|
|
648
|
+
canvas.style.left = '0';
|
|
649
|
+
canvas.style.top = '0';
|
|
650
|
+
canvas.style.width = bufW * eff + 'px'; // crisp CSS-width zoom
|
|
651
|
+
canvas.style.height = bufH * eff + 'px';
|
|
652
|
+
if (labelWrap) {
|
|
653
|
+
labelWrap.style.width = bufW + 'px';
|
|
654
|
+
labelWrap.style.height = bufH + 'px';
|
|
655
|
+
labelWrap.style.transformOrigin = '0 0';
|
|
656
|
+
labelWrap.style.transform = `scale(${eff})`;
|
|
657
|
+
}
|
|
658
|
+
// translate3d (not translate) forces a GPU compositor layer so the camera
|
|
659
|
+
// follow glides; rounding keeps the pixel art crisp under the 3x CSS upscale.
|
|
660
|
+
if (world) world.style.transform = `translate3d(${Math.round(cam.x)}px, ${Math.round(cam.y)}px, 0)`;
|
|
661
|
+
// CSS hides #recenter until .show; reveal it once the user has panned/zoomed
|
|
662
|
+
// so the affordance isn't permanently invisible.
|
|
663
|
+
if (recenterBtn) recenterBtn.classList.toggle('show', userMoved);
|
|
664
|
+
positionOverlays(); // re-pin tooltip/card so they track pan/zoom/follow immediately
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// The camera always CONFINES the office to the visible floor (the area left of the
|
|
668
|
+
// hovering panel) so we never see void around it — in walk mode AND free pan/zoom.
|
|
669
|
+
// Centres the office on an axis where it's smaller than the frame.
|
|
670
|
+
function clampPan() {
|
|
671
|
+
const vp = viewportRect();
|
|
672
|
+
const availW = Math.max(40, vp.width - reservedRight);
|
|
673
|
+
const w = bufW * UPSCALE * cam.s, h = bufH * UPSCALE * cam.s;
|
|
674
|
+
cam.x = w >= availW ? clampN(cam.x, availW - w, 0) : Math.round((availW - w) / 2);
|
|
675
|
+
cam.y = h >= vp.height ? clampN(cam.y, vp.height - h, 0) : Math.round((vp.height - h) / 2);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// The COVER fit — the zoom at which the office FILLS the visible floor on BOTH axes,
|
|
679
|
+
// cropping the longer one. This is the DEFAULT framing (a full, no-void view).
|
|
680
|
+
// Capped at native 3x so a tiny floor isn't blown up absurdly.
|
|
681
|
+
function coverScale() {
|
|
682
|
+
const vp = viewportRect();
|
|
683
|
+
const availW = Math.max(40, vp.width - reservedRight);
|
|
684
|
+
return clampN(Math.max(availW / (bufW * UPSCALE), vp.height / (bufH * UPSCALE)), 0.05, 3);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// The CONTAIN fit — the zoom at which the WHOLE office is visible (the smaller of the
|
|
688
|
+
// two ratios → limited by the office's LARGER dimension), with void around the short
|
|
689
|
+
// axis. This is the soft zoom-OUT floor: from the cover default you can keep pulling
|
|
690
|
+
// back past it (×0.85 leaves a little breathing margin) until the entire floor fits,
|
|
691
|
+
// instead of being hard-stopped at cover. clampPan centres the office in the void.
|
|
692
|
+
function containScale() {
|
|
693
|
+
const vp = viewportRect();
|
|
694
|
+
const availW = Math.max(40, vp.width - reservedRight);
|
|
695
|
+
return clampN(Math.min(availW / (bufW * UPSCALE), vp.height / (bufH * UPSCALE)) * 0.85, 0.04, 3);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// Default view = the cover fit, CENTRED ON THE DESKS. Centering on the bullpen (not
|
|
699
|
+
// the buffer's top-left) means a tall/portrait small office frames the desks in the
|
|
700
|
+
// middle of the screen instead of pinning the empty back wall to the top and cropping
|
|
701
|
+
// the desks off the bottom. clampPan still prevents any void.
|
|
702
|
+
function fitView() {
|
|
703
|
+
cam.s = coverScale();
|
|
704
|
+
const vp = viewportRect();
|
|
705
|
+
const availW = Math.max(40, vp.width - reservedRight);
|
|
706
|
+
const eff = UPSCALE * cam.s;
|
|
707
|
+
cam.x = availW / 2 - focusBX * eff;
|
|
708
|
+
cam.y = vp.height / 2 - focusBY * eff;
|
|
709
|
+
clampPan();
|
|
710
|
+
applyCam();
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function zoomAt(clientX, clientY, factor) {
|
|
714
|
+
const vp = viewportRect();
|
|
715
|
+
const pxp = clientX - vp.left, pyp = clientY - vp.top;
|
|
716
|
+
const eff0 = UPSCALE * cam.s;
|
|
717
|
+
const bx = (pxp - cam.x) / eff0, by = (pyp - cam.y) / eff0;
|
|
718
|
+
// zoom-out floor is the CONTAIN fit (whole office visible), not cover — so you can
|
|
719
|
+
// pull back to see the entire floor instead of being hard-stopped at the fill view.
|
|
720
|
+
cam.s = clampN(cam.s * factor, containScale(), 3);
|
|
721
|
+
const eff1 = UPSCALE * cam.s;
|
|
722
|
+
cam.x = pxp - bx * eff1;
|
|
723
|
+
cam.y = pyp - by * eff1;
|
|
724
|
+
userMoved = true;
|
|
725
|
+
clampPan();
|
|
726
|
+
applyCam();
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// Map a screen point to a desk cell (or null). The canvas rect already reflects
|
|
730
|
+
// the CSS scale + world translate, so dividing by the effective scale gives
|
|
731
|
+
// buffer-space px.
|
|
732
|
+
function cellAt(clientX, clientY) {
|
|
733
|
+
if (!canvas) return null;
|
|
734
|
+
const rect = canvas.getBoundingClientRect();
|
|
735
|
+
const eff = UPSCALE * cam.s;
|
|
736
|
+
const bx = (clientX - rect.left) / eff, by = (clientY - rect.top) / eff;
|
|
737
|
+
// A wandering worker is away from its desk — hit-test the walker sprite (around
|
|
738
|
+
// its feet) FIRST, so you can hover/click the agent itself, not just the desk.
|
|
739
|
+
for (const cell of cells) {
|
|
740
|
+
const w = wanderers.get(keyOf(cell.agent));
|
|
741
|
+
if (w && w.phase !== 'seated' && bx >= w.x - 9 && bx <= w.x + 9 && by >= w.y - 38 && by <= w.y + 3) return cell;
|
|
742
|
+
}
|
|
743
|
+
for (const cell of cells) {
|
|
744
|
+
if (bx >= cell.x && bx <= cell.x + CELL_W && by >= cell.y && by <= cell.y + CELL_H) return cell;
|
|
745
|
+
}
|
|
746
|
+
return null;
|
|
747
|
+
}
|
|
748
|
+
const agentAt = (clientX, clientY) => { const c = cellAt(clientX, clientY); return c ? c.agent : null; };
|
|
749
|
+
|
|
750
|
+
// ---- hover affordance + tooltip --------------------------------------------
|
|
751
|
+
// Hovering a desk rings it (handled in draw via hoverKey) and pops a light DOM
|
|
752
|
+
// tooltip of what that agent is doing — a minimal hover-ring + info-card feel
|
|
753
|
+
// (name · project, activity, current task). All fields come from
|
|
754
|
+
// the agent object already in /api/state — no backend dependency.
|
|
755
|
+
let tooltip = null;
|
|
756
|
+
const ACTIVITY_TEXT = { working: 'shipping', shell: 'running a command', idle: 'idle' };
|
|
757
|
+
const escHtml = (s) => String(s == null ? '' : s).replace(/[&<>"]/g,
|
|
758
|
+
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
|
759
|
+
|
|
760
|
+
function ensureTooltip() {
|
|
761
|
+
if (tooltip) return;
|
|
762
|
+
// position:fixed on <body> → viewport coords straight from getBoundingClientRect,
|
|
763
|
+
// no offset-parent guesswork; pointer-events:none so it never eats clicks.
|
|
764
|
+
tooltip = document.createElement('div');
|
|
765
|
+
tooltip.className = 'proc-tooltip';
|
|
766
|
+
tooltip.style.cssText =
|
|
767
|
+
'position:fixed;z-index:30;pointer-events:none;transform:translateX(-50%);' +
|
|
768
|
+
'padding:4px 7px;border-radius:5px;white-space:nowrap;' +
|
|
769
|
+
'background:rgba(14,20,32,0.95);border:1px solid rgba(255,255,255,0.22);' +
|
|
770
|
+
"font:12px 'IBM Plex Mono', ui-monospace, monospace;color:#eef3fb;line-height:1.25;" +
|
|
771
|
+
'box-shadow:0 4px 14px rgba(0,0,0,0.45);display:none;';
|
|
772
|
+
document.body.appendChild(tooltip);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function hideTooltip() { if (tooltip) tooltip.style.display = 'none'; }
|
|
776
|
+
|
|
777
|
+
// The agent's LIVE buffer anchor: a wandering worker (its walker) when away from
|
|
778
|
+
// the desk, else the desk itself. This is what makes hover/inspect info appear
|
|
779
|
+
// beside the AGENT — following them as they roam — instead of over an empty desk.
|
|
780
|
+
function agentAnchor(cell) {
|
|
781
|
+
const w = wanderers.get(keyOf(cell.agent));
|
|
782
|
+
if (w && w.phase !== 'seated') return { cx: w.x, top: w.y - 36, bot: w.y + 3 };
|
|
783
|
+
return { cx: cell.x + CELL_W / 2, top: cell.y, bot: cell.y + CELL_H };
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// Pin the tooltip beside the agent's live anchor (above it, flipping below if
|
|
787
|
+
// cramped), clamped to the viewport. Split from showTooltip so the per-frame loop
|
|
788
|
+
// can re-pin it cheaply as a walker moves or the camera pans/zooms.
|
|
789
|
+
function positionTooltip(cell) {
|
|
790
|
+
if (!tooltip || tooltip.style.display === 'none' || !canvas) return;
|
|
791
|
+
const an = agentAnchor(cell);
|
|
792
|
+
const rect = canvas.getBoundingClientRect();
|
|
793
|
+
const eff = UPSCALE * cam.s;
|
|
794
|
+
const cx = rect.left + an.cx * eff;
|
|
795
|
+
const topY = rect.top + an.top * eff;
|
|
796
|
+
const botY = rect.top + an.bot * eff;
|
|
797
|
+
const tw = tooltip.offsetWidth, th = tooltip.offsetHeight;
|
|
798
|
+
const left = clampN(cx, tw / 2 + 4, window.innerWidth - tw / 2 - 4);
|
|
799
|
+
let top = topY - th - 8;
|
|
800
|
+
if (top < 4) top = botY + 8;
|
|
801
|
+
tooltip.style.left = Math.round(left) + 'px';
|
|
802
|
+
tooltip.style.top = Math.round(top) + 'px';
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
function showTooltip(cell) {
|
|
806
|
+
ensureTooltip();
|
|
807
|
+
const a = cell.agent;
|
|
808
|
+
if (a.placeholder) {
|
|
809
|
+
// a vacant desk → invite the user to hire their first agent
|
|
810
|
+
tooltip.innerHTML =
|
|
811
|
+
'<div style="font-weight:600;letter-spacing:.3px">An empty desk</div>' +
|
|
812
|
+
'<div style="opacity:.85;margin-top:2px;max-width:180px;white-space:normal">' +
|
|
813
|
+
'Start a Claude agent to see your first employee.</div>';
|
|
814
|
+
tooltip.style.display = 'block';
|
|
815
|
+
positionTooltip(cell);
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
const col = statusColor(a.activity);
|
|
819
|
+
const act = ACTIVITY_TEXT[a.activity] || a.activity || 'idle';
|
|
820
|
+
const task = a.task || a.chatName || a.lastPrompt || '';
|
|
821
|
+
tooltip.innerHTML =
|
|
822
|
+
`<div style="font-weight:600;letter-spacing:.3px">${escHtml(a.name)}` +
|
|
823
|
+
`<span style="opacity:.55;font-weight:400"> · ${escHtml(a.project)}</span></div>` +
|
|
824
|
+
'<div style="display:flex;align-items:center;gap:4px;margin-top:1px">' +
|
|
825
|
+
`<span style="width:6px;height:6px;border-radius:50%;background:${col};` +
|
|
826
|
+
`box-shadow:${a.activity === 'idle' ? 'none' : `0 0 5px ${col}`}"></span>` +
|
|
827
|
+
`<span style="color:${col}">${escHtml(act)}</span></div>` +
|
|
828
|
+
(a.model ? `<div style="opacity:.6;margin-top:1px;font-size:12px">` +
|
|
829
|
+
`${escHtml(String(a.model).replace(/^claude-/, ''))}</div>` : '') +
|
|
830
|
+
(task ? '<div style="opacity:.85;margin-top:1px;max-width:206px;overflow:hidden;' +
|
|
831
|
+
`text-overflow:ellipsis">“${escHtml(task)}”</div>` : '') +
|
|
832
|
+
(avatar && avatar.enabled ? '<div style="opacity:.5;margin-top:3px;font-size:11px">' +
|
|
833
|
+
'press E to open</div>' : '');
|
|
834
|
+
tooltip.style.display = 'block';
|
|
835
|
+
positionTooltip(cell);
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// The detail card (chat-panel #chatPanel) floats BESIDE the selected agent rather
|
|
839
|
+
// than as a screen-blocking rail. office.js owns its position (it knows the camera
|
|
840
|
+
// + the agent's live buffer pos); chat-panel.js owns its content. Re-pinned every
|
|
841
|
+
// frame so it tracks the agent + camera. Prefers the agent's right; flips left near
|
|
842
|
+
// the edge; clamps under the topbar.
|
|
843
|
+
function positionDetailCard() {
|
|
844
|
+
const card = document.getElementById('chatPanel');
|
|
845
|
+
if (!card || !card.classList.contains('open')) return;
|
|
846
|
+
const cell = cells.find((c) => keyOf(c.agent) === selectedKey);
|
|
847
|
+
if (!cell) return;
|
|
848
|
+
const an = agentAnchor(cell);
|
|
849
|
+
const rect = canvas.getBoundingClientRect();
|
|
850
|
+
const eff = UPSCALE * cam.s;
|
|
851
|
+
const ax = rect.left + an.cx * eff;
|
|
852
|
+
const atop = rect.top + an.top * eff;
|
|
853
|
+
const cw = card.offsetWidth || 300, ch = card.offsetHeight || 240;
|
|
854
|
+
const margin = 12, gap = 22;
|
|
855
|
+
let left = ax + gap;
|
|
856
|
+
if (left + cw > window.innerWidth - margin) left = ax - gap - cw; // flip to the left
|
|
857
|
+
left = clampN(left, margin, Math.max(margin, window.innerWidth - cw - margin));
|
|
858
|
+
let top = clampN(atop - 8, 80, Math.max(80, window.innerHeight - ch - margin));
|
|
859
|
+
card.style.left = Math.round(left) + 'px';
|
|
860
|
+
card.style.top = Math.round(top) + 'px';
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// Keep the hover tooltip + the selected detail card pinned to their agents as the
|
|
864
|
+
// camera moves and walkers roam. Called from draw() (walker motion) and applyCam()
|
|
865
|
+
// (pan/zoom/follow) so neither lags behind.
|
|
866
|
+
function positionOverlays() {
|
|
867
|
+
if (!cells.length) return;
|
|
868
|
+
if (hoverKey) {
|
|
869
|
+
const cell = cells.find((c) => keyOf(c.agent) === hoverKey);
|
|
870
|
+
if (cell) positionTooltip(cell); else hideTooltip();
|
|
871
|
+
}
|
|
872
|
+
if (selectedKey) positionDetailCard();
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// A highlight disc under a wandering worker who is hovered/selected — the walker
|
|
876
|
+
// analogue of the desk's hover/selection ring, so the AGENT lights up (not just
|
|
877
|
+
// their empty desk). Drawn before the walker so it stands on the disc.
|
|
878
|
+
function drawWalkerRing(x, y, color) {
|
|
879
|
+
ctx.save();
|
|
880
|
+
ctx.globalAlpha = 0.18; ctx.fillStyle = color;
|
|
881
|
+
ctx.beginPath(); ctx.ellipse(x, y + 1, 11, 4, 0, 0, Math.PI * 2); ctx.fill();
|
|
882
|
+
ctx.globalAlpha = 0.95; ctx.strokeStyle = color; ctx.lineWidth = 1;
|
|
883
|
+
ctx.beginPath(); ctx.ellipse(x, y + 1, 10, 3.4, 0, 0, Math.PI * 2); ctx.stroke();
|
|
884
|
+
ctx.restore();
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// Recompute the hovered desk from a cursor point: update the cursor, the tooltip,
|
|
888
|
+
// and (only on change) the hover key + an immediate repaint so the ring tracks
|
|
889
|
+
// the cursor without waiting for the slow (~7.5fps) animation tick.
|
|
890
|
+
function updateHover(clientX, clientY) {
|
|
891
|
+
if (avatar && avatar.enabled) return; // walk mode: avatar proximity drives the tooltip
|
|
892
|
+
const cell = cellAt(clientX, clientY);
|
|
893
|
+
const k = cell ? keyOf(cell.agent) : null;
|
|
894
|
+
if (canvas) canvas.style.cursor = cell ? 'pointer' : 'grab';
|
|
895
|
+
if (k === hoverKey) return;
|
|
896
|
+
hoverKey = k;
|
|
897
|
+
if (cell) showTooltip(cell); else hideTooltip();
|
|
898
|
+
draw();
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
// Attach pan/zoom/click once. A press that doesn't move is a click (select); a
|
|
902
|
+
// press that drags pans the floor. Trackpad: two-finger scroll pans, pinch (⌘
|
|
903
|
+
// /ctrl-wheel) zooms — matching the floor hint.
|
|
904
|
+
function attachInput() {
|
|
905
|
+
if (!canvas || canvas.dataset.procInput) return;
|
|
906
|
+
canvas.dataset.procInput = '1';
|
|
907
|
+
let down = false, moved = 0, sx = 0, sy = 0;
|
|
908
|
+
canvas.style.cursor = 'grab';
|
|
909
|
+
canvas.addEventListener('mousedown', (e) => {
|
|
910
|
+
down = true; moved = 0; sx = e.clientX; sy = e.clientY;
|
|
911
|
+
canvas.style.cursor = 'grabbing';
|
|
912
|
+
hoverKey = null; hideTooltip(); // dragging: drop the hover affordance
|
|
913
|
+
});
|
|
914
|
+
window.addEventListener('mousemove', (e) => {
|
|
915
|
+
if (!down) { updateHover(e.clientX, e.clientY); return; } // not dragging → hover
|
|
916
|
+
const dx = e.clientX - sx, dy = e.clientY - sy;
|
|
917
|
+
moved += Math.abs(dx) + Math.abs(dy);
|
|
918
|
+
cam.x += dx; cam.y += dy; sx = e.clientX; sy = e.clientY;
|
|
919
|
+
userMoved = true; clampPan(); applyCam();
|
|
920
|
+
});
|
|
921
|
+
window.addEventListener('mouseup', (e) => {
|
|
922
|
+
if (!down) return;
|
|
923
|
+
down = false; canvas.style.cursor = 'grab';
|
|
924
|
+
if (moved < 5) { // a clean tap selects; tapping the selected desk deselects
|
|
925
|
+
const agent = agentAt(e.clientX, e.clientY);
|
|
926
|
+
if (agent && agent.placeholder) {
|
|
927
|
+
// a vacant desk isn't a real agent — show its hover hint, don't open a card
|
|
928
|
+
const cell = cellAt(e.clientX, e.clientY);
|
|
929
|
+
if (cell) showTooltip(cell);
|
|
930
|
+
} else {
|
|
931
|
+
const k = keyOf(agent);
|
|
932
|
+
selectedKey = (k && k === selectedKey) ? null : k;
|
|
933
|
+
draw(); // paint the selection ring now — don't wait for the slow loop tick
|
|
934
|
+
window.dispatchEvent(new CustomEvent('agency:select', { detail: { agent: selectedKey ? agent : null } }));
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
updateHover(e.clientX, e.clientY); // re-evaluate hover at rest
|
|
938
|
+
});
|
|
939
|
+
canvas.addEventListener('mouseleave', () => {
|
|
940
|
+
if (hoverKey != null) { hoverKey = null; draw(); }
|
|
941
|
+
hideTooltip(); canvas.style.cursor = 'grab';
|
|
942
|
+
});
|
|
943
|
+
const host = (world && world.parentElement) || canvas;
|
|
944
|
+
host.addEventListener('wheel', (e) => {
|
|
945
|
+
e.preventDefault();
|
|
946
|
+
hideTooltip();
|
|
947
|
+
if (e.ctrlKey || e.metaKey) { // pinch / ⌘ → zoom around the cursor
|
|
948
|
+
// Magnitude-proportional zoom. A fixed per-event factor felt hair-trigger
|
|
949
|
+
// on trackpads, which fire many
|
|
950
|
+
// tiny wheel events; exp(-step·0.01) scales with gesture size. Normalize
|
|
951
|
+
// line-mode (mouse wheel) deltas to px first. zoomAt still clamps the scale.
|
|
952
|
+
const step = e.deltaMode !== 0 ? e.deltaY * 16 : e.deltaY;
|
|
953
|
+
zoomAt(e.clientX, e.clientY, Math.exp(-step * 0.01));
|
|
954
|
+
} else { // plain scroll → pan
|
|
955
|
+
cam.x -= e.deltaX; cam.y -= e.deltaY;
|
|
956
|
+
userMoved = true; clampPan(); applyCam();
|
|
957
|
+
}
|
|
958
|
+
}, { passive: false });
|
|
959
|
+
recenterBtn = document.getElementById('recenter');
|
|
960
|
+
if (recenterBtn) recenterBtn.addEventListener('click', () => { userMoved = false; fitView(); });
|
|
961
|
+
window.addEventListener('resize', () => { if (!userMoved) fitView(); else { clampPan(); applyCam(); } });
|
|
962
|
+
// app.js mutates window.agencyUnread between polls and fires this; re-sync the
|
|
963
|
+
// per-desk unread pips (syncLabels reads window.agencyUnread directly).
|
|
964
|
+
window.addEventListener('agency:unread', () => syncLabels());
|
|
965
|
+
// Selection can come from a click (here), the avatar's E key, or anywhere; keep
|
|
966
|
+
// selectedKey in sync and pin the detail card beside the agent once it has opened
|
|
967
|
+
// + measured (rAF), so it never flashes at its default corner.
|
|
968
|
+
window.addEventListener('agency:select', (e) => {
|
|
969
|
+
const a = e && e.detail && e.detail.agent;
|
|
970
|
+
selectedKey = keyOf(a);
|
|
971
|
+
requestAnimationFrame(positionDetailCard);
|
|
972
|
+
});
|
|
973
|
+
// Walk mode: the avatar fires agency:inspect as it passes desks. Surface a LIGHT
|
|
974
|
+
// tooltip beside that agent (NOT the heavy detail card) so the floor isn't blocked
|
|
975
|
+
// while wandering; E (see initOffice keydown) opens the full card for it.
|
|
976
|
+
window.addEventListener('agency:inspect', (e) => {
|
|
977
|
+
const a = e && e.detail && e.detail.agent;
|
|
978
|
+
lastInspectAgent = a || null;
|
|
979
|
+
const k = keyOf(a);
|
|
980
|
+
if (k === hoverKey) return;
|
|
981
|
+
hoverKey = k;
|
|
982
|
+
if (a) {
|
|
983
|
+
const cell = cells.find((c) => keyOf(c.agent) === k);
|
|
984
|
+
if (cell) showTooltip(cell); else hideTooltip();
|
|
985
|
+
} else hideTooltip();
|
|
986
|
+
draw();
|
|
987
|
+
});
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
// ---- floor entities: avatar (walkable player) + wandering cat ---------------
|
|
991
|
+
|
|
992
|
+
// Desks in the avatar's expected shape: pod top-left (cell x,y) + the agent.
|
|
993
|
+
function buildPods() {
|
|
994
|
+
return cells.map((c, i) => ({ x: c.x, y: c.y, i, agent: c.agent }));
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
// Walkable floor extents in buffer coords (inside the walls + margins).
|
|
998
|
+
function floorBounds() {
|
|
999
|
+
return { minX: MARGIN + 8, maxX: bufW - MARGIN - 8, minY: TOP + 14, maxY: bufH - MARGIN - 6 };
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
// Center the camera on the avatar (lerped catch-up) so it follows while walking.
|
|
1003
|
+
// Mutates cam WITHOUT setting userMoved, so toggling walk off restores fit/pan.
|
|
1004
|
+
function followAvatar(dt = 16) {
|
|
1005
|
+
if (!avatar) return;
|
|
1006
|
+
const vp = viewportRect();
|
|
1007
|
+
const availW = Math.max(40, vp.width - reservedRight);
|
|
1008
|
+
const eff = UPSCALE * cam.s;
|
|
1009
|
+
const tx = availW / 2 - avatar.pos.x * eff;
|
|
1010
|
+
const ty = vp.height / 2 - avatar.pos.y * eff;
|
|
1011
|
+
// Frame-rate-independent catch-up (~0.18 per frame at 60fps) so the follow
|
|
1012
|
+
// feels identical at 30 / 60 / 120 Hz instead of speeding up with frame rate.
|
|
1013
|
+
const k = 1 - Math.pow(0.82, dt / 16.67);
|
|
1014
|
+
cam.x += (tx - cam.x) * k;
|
|
1015
|
+
cam.y += (ty - cam.y) * k;
|
|
1016
|
+
clampPan();
|
|
1017
|
+
applyCam();
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
// Toggle walk mode (the 'g' key / the button). On ENTER: drop the avatar where
|
|
1021
|
+
// the user is looking, then zoom IN for an immersive first-person feel and snap
|
|
1022
|
+
// the camera onto it. On EXIT: restore the pre-walk camera.
|
|
1023
|
+
const WALK_ZOOM = 1.8; // cam.s in walk mode → eff ≈ 5.4x: a close, immersive view
|
|
1024
|
+
let preWalkCam = null; // camera saved on walk-enter, restored on walk-exit
|
|
1025
|
+
function toggleWalk(on) {
|
|
1026
|
+
if (!avatar) return;
|
|
1027
|
+
const was = avatar.enabled;
|
|
1028
|
+
avatar.enabled = on == null ? !avatar.enabled : !!on;
|
|
1029
|
+
if (avatar.enabled && !was) {
|
|
1030
|
+
const vp = viewportRect();
|
|
1031
|
+
const effOld = UPSCALE * cam.s;
|
|
1032
|
+
const b = floorBounds();
|
|
1033
|
+
// drop the avatar at the current view centre, in world coords at the OLD zoom
|
|
1034
|
+
avatar.setPos(
|
|
1035
|
+
clampN((vp.width / 2 - cam.x) / effOld, b.minX, b.maxX),
|
|
1036
|
+
clampN((vp.height / 2 - cam.y) / effOld, b.minY, b.maxY)
|
|
1037
|
+
);
|
|
1038
|
+
preWalkCam = { s: cam.s, x: cam.x, y: cam.y, userMoved };
|
|
1039
|
+
cam.s = Math.max(WALK_ZOOM, coverScale()); // zoom in for immersion, but never below cover (no void)
|
|
1040
|
+
// snap (no lerp) so the camera doesn't lurch from the old framing
|
|
1041
|
+
const effNew = UPSCALE * cam.s;
|
|
1042
|
+
const availW = Math.max(40, vp.width - reservedRight);
|
|
1043
|
+
cam.x = availW / 2 - avatar.pos.x * effNew;
|
|
1044
|
+
cam.y = vp.height / 2 - avatar.pos.y * effNew;
|
|
1045
|
+
clampPan();
|
|
1046
|
+
applyCam();
|
|
1047
|
+
} else if (!avatar.enabled && was && preWalkCam) {
|
|
1048
|
+
// restore the pre-walk zoom + pan + auto-fit state
|
|
1049
|
+
cam.s = preWalkCam.s; cam.x = preWalkCam.x; cam.y = preWalkCam.y;
|
|
1050
|
+
userMoved = preWalkCam.userMoved; preWalkCam = null;
|
|
1051
|
+
clampPan(); applyCam();
|
|
1052
|
+
}
|
|
1053
|
+
updateWalkUI();
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
function updateWalkUI() {
|
|
1057
|
+
const on = !!(avatar && avatar.enabled);
|
|
1058
|
+
if (walkBtn) {
|
|
1059
|
+
walkBtn.textContent = on ? '🚶 walking · G to exit' : '🚶 walk (G)';
|
|
1060
|
+
walkBtn.style.borderColor = on ? '#ff2e88' : 'rgba(255,255,255,0.14)';
|
|
1061
|
+
walkBtn.style.color = on ? '#ff8fc4' : '#cdd6e6';
|
|
1062
|
+
}
|
|
1063
|
+
if (walkHint) walkHint.style.display = on ? 'block' : 'none';
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
// Toggle agent name-tag visibility (the 'n' key / the button). syncLabels + draw()
|
|
1067
|
+
// both honor `labelsHidden`; we flip the active chips here too for an instant response.
|
|
1068
|
+
function toggleLabels(on) {
|
|
1069
|
+
labelsHidden = on == null ? !labelsHidden : !!on;
|
|
1070
|
+
for (let i = 0; i < cells.length; i++) if (nameNodes[i]) nameNodes[i].style.display = labelsHidden ? 'none' : 'inline-flex';
|
|
1071
|
+
updateLabelBtn();
|
|
1072
|
+
}
|
|
1073
|
+
function updateLabelBtn() {
|
|
1074
|
+
if (!nameBtn) return;
|
|
1075
|
+
nameBtn.textContent = labelsHidden ? '🏷 names off' : '🏷 names';
|
|
1076
|
+
nameBtn.style.opacity = labelsHidden ? '0.6' : '1';
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
// Build the small walk affordances (a toggle button + a hint) over the floor
|
|
1080
|
+
// frame, styled inline (index.html / style.css aren't in this lane).
|
|
1081
|
+
function ensureWalkUI() {
|
|
1082
|
+
const host = world && world.parentElement; // .floor-frame
|
|
1083
|
+
if (!host || walkBtn) return;
|
|
1084
|
+
walkBtn = document.createElement('button');
|
|
1085
|
+
walkBtn.type = 'button';
|
|
1086
|
+
walkBtn.id = 'walkToggle';
|
|
1087
|
+
Object.assign(walkBtn.style, {
|
|
1088
|
+
position: 'absolute', left: '12px', bottom: '12px', zIndex: '6',
|
|
1089
|
+
font: '11px ui-monospace, monospace', background: 'rgba(16,20,28,0.82)',
|
|
1090
|
+
border: '1px solid rgba(255,255,255,0.14)', borderRadius: '6px',
|
|
1091
|
+
padding: '5px 9px', cursor: 'pointer',
|
|
1092
|
+
});
|
|
1093
|
+
walkBtn.addEventListener('click', () => toggleWalk());
|
|
1094
|
+
host.appendChild(walkBtn);
|
|
1095
|
+
// nametag-visibility toggle (top-left, clear of the bottom controls)
|
|
1096
|
+
nameBtn = document.createElement('button');
|
|
1097
|
+
nameBtn.type = 'button';
|
|
1098
|
+
nameBtn.id = 'nameToggle';
|
|
1099
|
+
nameBtn.title = 'Show / hide agent name tags (N)';
|
|
1100
|
+
Object.assign(nameBtn.style, {
|
|
1101
|
+
// sits just below the top-left "N away · show" hidden-agents chip (which also
|
|
1102
|
+
// anchors top-left), so the two stack instead of overlapping.
|
|
1103
|
+
position: 'absolute', left: '12px', top: '40px', zIndex: '6',
|
|
1104
|
+
font: '11px ui-monospace, monospace', background: 'rgba(16,20,28,0.82)',
|
|
1105
|
+
border: '1px solid rgba(255,255,255,0.14)', borderRadius: '6px',
|
|
1106
|
+
padding: '5px 9px', cursor: 'pointer', color: '#cdd6e6',
|
|
1107
|
+
});
|
|
1108
|
+
nameBtn.addEventListener('click', () => toggleLabels());
|
|
1109
|
+
host.appendChild(nameBtn);
|
|
1110
|
+
updateLabelBtn();
|
|
1111
|
+
walkHint = document.createElement('div');
|
|
1112
|
+
Object.assign(walkHint.style, {
|
|
1113
|
+
position: 'absolute', left: '12px', bottom: '40px', zIndex: '6',
|
|
1114
|
+
font: '11px ui-monospace, monospace', color: '#9aa6ba',
|
|
1115
|
+
background: 'rgba(16,20,28,0.82)', border: '1px solid rgba(255,255,255,0.10)',
|
|
1116
|
+
borderRadius: '6px', padding: '4px 8px', display: 'none', maxWidth: '250px',
|
|
1117
|
+
});
|
|
1118
|
+
walkHint.textContent = 'WASD / arrows to move · walk up to a desk to open it · G to exit';
|
|
1119
|
+
host.appendChild(walkHint);
|
|
1120
|
+
updateWalkUI();
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
// Gently roam a cat across the lower floor (the lounge): pick a target, amble to
|
|
1124
|
+
// it, sit a while, repeat. Subtle ambient life; runs every loop tick.
|
|
1125
|
+
function updateCat(dt, now) {
|
|
1126
|
+
if (!bufW || !bufH) return;
|
|
1127
|
+
const minX = MARGIN + 12, maxX = bufW - MARGIN - 14;
|
|
1128
|
+
const minY = Math.round(bufH * 0.66), maxY = bufH - MARGIN - 6;
|
|
1129
|
+
if (!cat.init) {
|
|
1130
|
+
cat.x = minX + 8; cat.y = maxY - 2; cat.tx = cat.x; cat.ty = cat.y;
|
|
1131
|
+
cat.sit = true; cat.until = now + 1500; cat.init = true;
|
|
1132
|
+
}
|
|
1133
|
+
cat.x = clampN(cat.x, minX, maxX); cat.y = clampN(cat.y, minY, maxY); // re-clamp after a reshape
|
|
1134
|
+
|
|
1135
|
+
// Petting: while the walking avatar is right next to the cat, it wakes, sits
|
|
1136
|
+
// happily, and emits hearts (drawn in drawCat). Re-checked every tick so a
|
|
1137
|
+
// lingering pet keeps it awake; stepping away lets it drift back to napping.
|
|
1138
|
+
cat.petted = (typeof window !== 'undefined' && window.__pet) || false; // reel: force the happy/hearts state
|
|
1139
|
+
dog.petted = (typeof window !== 'undefined' && window.__pet) || false;
|
|
1140
|
+
if (avatar && avatar.enabled) {
|
|
1141
|
+
const pd = Math.hypot(avatar.pos.x - cat.x, avatar.pos.y - cat.y);
|
|
1142
|
+
if (pd < 22) {
|
|
1143
|
+
cat.petted = true;
|
|
1144
|
+
cat.sleeping = false;
|
|
1145
|
+
cat.sit = true;
|
|
1146
|
+
if (cat.until < now + 900) cat.until = now + 900; // stay put a beat to be petted
|
|
1147
|
+
}
|
|
1148
|
+
// the dog is stationary — just light up when the avatar is beside it
|
|
1149
|
+
if (dog.init && Math.hypot(avatar.pos.x - dog.x, avatar.pos.y - dog.y) < 22) dog.petted = true;
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
if (now >= cat.until) {
|
|
1153
|
+
if (cat.sleeping) {
|
|
1154
|
+
// wake from a nap → sit a moment, then resume roaming
|
|
1155
|
+
cat.sleeping = false; cat.sit = true; cat.until = now + 1400 + Math.random() * 2200;
|
|
1156
|
+
} else if (cat.sit) {
|
|
1157
|
+
// resting done → usually curl up for a nap, otherwise wander to a new spot
|
|
1158
|
+
if (!cat.petted && Math.random() < 0.45) {
|
|
1159
|
+
cat.sleeping = true; cat.until = now + 9000 + Math.random() * 9000; // a good nap
|
|
1160
|
+
} else {
|
|
1161
|
+
cat.sit = false;
|
|
1162
|
+
cat.tx = minX + Math.round(Math.random() * (maxX - minX));
|
|
1163
|
+
cat.ty = minY + Math.round(Math.random() * (maxY - minY));
|
|
1164
|
+
cat.until = now + 5000 + Math.random() * 4000; // safety cap before it must rest
|
|
1165
|
+
}
|
|
1166
|
+
} else {
|
|
1167
|
+
cat.sit = true; cat.until = now + 2200 + Math.random() * 4500; // arrived → rest a while
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
if (!cat.sit) {
|
|
1171
|
+
const dx = cat.tx - cat.x, dy = cat.ty - cat.y, d = Math.hypot(dx, dy);
|
|
1172
|
+
if (d > 1.2) {
|
|
1173
|
+
const step = Math.min(14 * dt / 1000, d); // ~14 buffer px/s amble
|
|
1174
|
+
// Face the direction of travel so it never moonwalks (deadzone avoids
|
|
1175
|
+
// flicker on near-vertical paths; keeps the last facing otherwise).
|
|
1176
|
+
if (dx < -0.4) cat.dir = -1; else if (dx > 0.4) cat.dir = 1;
|
|
1177
|
+
cat.x += (dx / d) * step; cat.y += (dy / d) * step;
|
|
1178
|
+
} else { cat.sit = true; cat.until = now + 1500 + Math.random() * 3000; }
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
// Idle-wander: settled-idle workers leave their desk and amble around the floor —
|
|
1183
|
+
// to a JITTERED point inside a random destination zone (so they spread out instead
|
|
1184
|
+
// of stacking on a fixed spot), usually hopping between spots a while before heading
|
|
1185
|
+
// home (they don't camp at the desk), and RUSHING home fast when resumed. Runs every
|
|
1186
|
+
// tick. ≤2 wander at once so the floor reads calm, and they cycle so it's not always
|
|
1187
|
+
// the same two.
|
|
1188
|
+
const WANDER_SPEED = 24; // fallback amble speed (buffer px/s); each wanderer varies its own
|
|
1189
|
+
const RETURN_RUSH = 64; // fast speed home when the agent is resumed / goes busy
|
|
1190
|
+
function updateWanderers(dt, now) {
|
|
1191
|
+
if (!cells.length) return;
|
|
1192
|
+
const live = new Set();
|
|
1193
|
+
let away = 0;
|
|
1194
|
+
for (const [, w] of wanderers) if (w.phase !== 'seated') away++;
|
|
1195
|
+
cells.forEach((cell) => {
|
|
1196
|
+
const a = cell.agent;
|
|
1197
|
+
if (a.placeholder) return; // a vacant desk never wanders
|
|
1198
|
+
const key = keyOf(a);
|
|
1199
|
+
if (key == null) return;
|
|
1200
|
+
live.add(key);
|
|
1201
|
+
const homeX = cell.x + CELL_W / 2, homeY = cell.y + CELL_H - 6;
|
|
1202
|
+
let w = wanderers.get(key);
|
|
1203
|
+
if (!w) {
|
|
1204
|
+
w = { phase: 'seated', x: homeX, y: homeY, tx: homeX, ty: homeY, legDist: 1, rush: false,
|
|
1205
|
+
until: now + 8000 + Math.random() * 30000, homeX, homeY,
|
|
1206
|
+
speed: 19 + Math.random() * 13 }; // its own amble speed → motion isn't uniform
|
|
1207
|
+
wanderers.set(key, w);
|
|
1208
|
+
} else { w.homeX = homeX; w.homeY = homeY; } // re-sync home to the (re-laid) desk
|
|
1209
|
+
const settled = a.activity === 'idle' && !a.needsYou && !a.awaitingReply
|
|
1210
|
+
&& !(a.sessionId && window.agencyUnread && window.agencyUnread.has(a.sessionId));
|
|
1211
|
+
// resumed / went busy while out → RUSH straight home, fast
|
|
1212
|
+
if (!settled && w.phase !== 'seated' && !(w.phase === 'returning' && w.rush)) {
|
|
1213
|
+
w.phase = 'returning'; w.rush = true; setTarget(w, w.homeX, w.homeY);
|
|
1214
|
+
}
|
|
1215
|
+
switch (w.phase) {
|
|
1216
|
+
case 'seated':
|
|
1217
|
+
if (now >= w.until) {
|
|
1218
|
+
// only SOMETIMES actually get up when the timer fires → irregular rhythm
|
|
1219
|
+
if (settled && away < 2 && wanderZones.length && Math.random() < 0.7) {
|
|
1220
|
+
w.x = w.homeX; w.y = w.homeY; w.rush = false; startStroll(w); away++;
|
|
1221
|
+
} else {
|
|
1222
|
+
w.until = now + 6000 + Math.random() * 18000;
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
break;
|
|
1226
|
+
case 'walking':
|
|
1227
|
+
if (stepToward(w, dt)) { w.phase = 'lingering'; w.until = now + 3000 + Math.random() * 9000; }
|
|
1228
|
+
break;
|
|
1229
|
+
case 'lingering':
|
|
1230
|
+
if (now >= w.until) {
|
|
1231
|
+
// keep wandering: usually hop to ANOTHER spot; only sometimes head home
|
|
1232
|
+
if (settled && wanderZones.length && Math.random() < 0.7) startStroll(w);
|
|
1233
|
+
else { w.phase = 'returning'; w.rush = false; setTarget(w, w.homeX, w.homeY); }
|
|
1234
|
+
}
|
|
1235
|
+
break;
|
|
1236
|
+
case 'returning':
|
|
1237
|
+
if (stepToward(w, dt)) {
|
|
1238
|
+
w.phase = 'seated'; w.rush = false;
|
|
1239
|
+
// if still idle, don't camp — head out again soon; if it came back because
|
|
1240
|
+
// it's busy, sit (it's working) until it goes idle again.
|
|
1241
|
+
w.until = now + (settled ? 4000 + Math.random() * 12000 : 25000 + Math.random() * 40000);
|
|
1242
|
+
}
|
|
1243
|
+
break;
|
|
1244
|
+
}
|
|
1245
|
+
});
|
|
1246
|
+
for (const k of wanderers.keys()) if (!live.has(k)) wanderers.delete(k); // agent left the floor
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
// Aim w at (tx,ty), recording the leg distance so stepToward can ease in + out.
|
|
1250
|
+
function setTarget(w, tx, ty) {
|
|
1251
|
+
w.tx = tx; w.ty = ty;
|
|
1252
|
+
w.legDist = Math.max(1, Math.hypot(tx - w.x, ty - w.y));
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
// Start ambling to a JITTERED point inside a random destination zone. The jitter
|
|
1256
|
+
// (a random spot in the lower part of the zone) keeps workers from stacking on the
|
|
1257
|
+
// same pixel and makes each visit look a little different.
|
|
1258
|
+
function startStroll(w) {
|
|
1259
|
+
const z = wanderZones[Math.floor(Math.random() * wanderZones.length)];
|
|
1260
|
+
const jx = z.x + 6 + Math.random() * Math.max(1, z.w - 12);
|
|
1261
|
+
const jy = z.y + Math.max(0, z.h - 14) + Math.random() * 10; // lower part — in front of the zone
|
|
1262
|
+
w.phase = 'walking';
|
|
1263
|
+
setTarget(w, Math.round(jx), Math.round(jy));
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
// Amble w toward its target. Eases IN at the start and OUT near arrival for natural
|
|
1267
|
+
// motion — unless RUSHING home (resumed), where it goes full speed straight back.
|
|
1268
|
+
function stepToward(w, dt) {
|
|
1269
|
+
const dx = w.tx - w.x, dy = w.ty - w.y, d = Math.hypot(dx, dy);
|
|
1270
|
+
if (d <= 1.2) return true;
|
|
1271
|
+
const sp = w.rush ? RETURN_RUSH : (w.speed || WANDER_SPEED);
|
|
1272
|
+
const ease = w.rush ? 1
|
|
1273
|
+
: Math.max(0.4, Math.min(Math.min(1, (w.legDist - d) / 12 + 0.5), Math.min(1, d / 16)));
|
|
1274
|
+
const step = Math.min(sp * ease * dt / 1000, d);
|
|
1275
|
+
w.x += (dx / d) * step; w.y += (dy / d) * step;
|
|
1276
|
+
return false;
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
// ---- scene -----------------------------------------------------------------
|
|
1280
|
+
function draw() {
|
|
1281
|
+
ctx.clearRect(0, 0, bufW, bufH);
|
|
1282
|
+
const mood = moodForHour(new Date().getHours()); // time-of-day mood (browser-local)
|
|
1283
|
+
drawWall(ctx, bufW, WALL_H, frame, mood);
|
|
1284
|
+
drawFloor(ctx, bufW, TOP, bufH);
|
|
1285
|
+
drawDaylight(ctx, bufW, TOP, bufH, mood);
|
|
1286
|
+
drawDecor();
|
|
1287
|
+
drawClusterRugs();
|
|
1288
|
+
// --- depth-sorted floor actors: every desk (the seated worker is HIDDEN while
|
|
1289
|
+
// its agent is away wandering), each wandering worker, the avatar, and the cat —
|
|
1290
|
+
// painted in baseline-y order so they pass correctly in front of / behind each
|
|
1291
|
+
// other. Names are DOM chips that FOLLOW a wandering worker (else sit at the desk).
|
|
1292
|
+
const actors = [];
|
|
1293
|
+
cells.forEach((cell, i) => {
|
|
1294
|
+
const a = cell.agent;
|
|
1295
|
+
const isEmpty = a.placeholder; // a vacant desk: draw the desk, no worker
|
|
1296
|
+
const k = keyOf(a);
|
|
1297
|
+
const selected = k != null && k === selectedKey;
|
|
1298
|
+
const hovered = k != null && k === hoverKey && !selected;
|
|
1299
|
+
// just-finished + unviewed → the worker waves to catch your eye (app.js owns the set)
|
|
1300
|
+
const unread = !!(a.sessionId && window.agencyUnread && window.agencyUnread.has(a.sessionId));
|
|
1301
|
+
const w = wanderers.get(k);
|
|
1302
|
+
const away = !!(w && w.phase !== 'seated');
|
|
1303
|
+
// sort the desk by its FLOOR-CONTACT line (front edge ≈ DESK_TOP+11 = CELL_H-15),
|
|
1304
|
+
// NOT the cell bottom — otherwise a walker whose feet are in front of the desk
|
|
1305
|
+
// front but above the cell bottom wrongly sorts behind it. Characters sort by
|
|
1306
|
+
// their feet (w.y / avatar.pos.y / cat.y), so this makes occlusion feet-based.
|
|
1307
|
+
actors.push({ y: cell.y + CELL_H - 15, fn: () => {
|
|
1308
|
+
// a vacant desk hides the worker (away=true) — an empty chair + dark monitor.
|
|
1309
|
+
drawCubicle(ctx, cell.x, cell.y, a, frame, selected, hovered, unread, away || isEmpty);
|
|
1310
|
+
if (!isEmpty && a.role === 'lead') drawCrown(ctx, cell.x + CELL_W / 2 - 6, cell.y + 22, frame);
|
|
1311
|
+
} });
|
|
1312
|
+
if (away) {
|
|
1313
|
+
const seed = (a.pid | 0) || hashInt(a.sessionId || 'a'); // SAME look as the seated worker
|
|
1314
|
+
const moving = w.phase === 'walking' || w.phase === 'returning';
|
|
1315
|
+
const ring = selected ? '#ffd166' : hovered ? '#5cd0ff' : null; // gold = selected, cyan = hover
|
|
1316
|
+
actors.push({ y: w.y, fn: () => {
|
|
1317
|
+
if (ring) drawWalkerRing(Math.round(w.x), Math.round(w.y), ring);
|
|
1318
|
+
drawWalker(ctx, Math.round(w.x), Math.round(w.y), { seed, frame, walking: moving });
|
|
1319
|
+
} });
|
|
1320
|
+
}
|
|
1321
|
+
// the name chip follows a wandering worker; otherwise it sits under the desk
|
|
1322
|
+
const node = nameNodes[i];
|
|
1323
|
+
if (node) {
|
|
1324
|
+
node.style.display = (labelsHidden || isEmpty) ? 'none' : 'inline-flex';
|
|
1325
|
+
if (!labelsHidden && !isEmpty) {
|
|
1326
|
+
if (away) { node.style.left = Math.round(w.x) + 'px'; node.style.top = Math.round(w.y - 46) + 'px'; }
|
|
1327
|
+
else { node.style.left = (cell.x + CELL_W / 2) + 'px'; node.style.top = (cell.y + CELL_H - 10) + 'px'; }
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
});
|
|
1331
|
+
if (avatar && avatar.enabled) actors.push({ y: avatar.pos.y, fn: () => avatar.draw(ctx) });
|
|
1332
|
+
if (cat.init) actors.push({ y: cat.y, fn: () => drawCat(ctx, cat.x, cat.y, frame, cat.dir, { sleeping: cat.sleeping, petted: cat.petted, walking: !cat.sit && !cat.sleeping }) });
|
|
1333
|
+
if (dog.init) actors.push({ y: dog.y, fn: () => drawDog(ctx, dog.x, dog.y, { frame, petted: dog.petted }) });
|
|
1334
|
+
actors.sort((p, q) => p.y - q.y);
|
|
1335
|
+
for (const act of actors) act.fn();
|
|
1336
|
+
|
|
1337
|
+
// "Waiting on you" — highest-signal state, painted LAST so it floats above every
|
|
1338
|
+
// desk. agent.needsYou is the live "blocked on you" field; awaitingReply is an
|
|
1339
|
+
// older alias for the same thing, kept as a harmless fallback. Falsy → not waiting.
|
|
1340
|
+
for (const cell of cells) {
|
|
1341
|
+
const a = cell.agent;
|
|
1342
|
+
if (a.needsYou || a.awaitingReply) drawNeedsYou(ctx, cell.x, cell.y, frame); // whole-desk amber treatment
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
positionOverlays(); // keep the hover tooltip + detail card pinned beside their agents
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
// ---- public API ------------------------------------------------------------
|
|
1349
|
+
// initOffice(canvas, labels) + setAgents(agents) are the entry points app.js
|
|
1350
|
+
// drives off the /api/state agent shape. Starts empty; the /api/state poll feeds
|
|
1351
|
+
// real agents via setAgents. (labels is an unused HTML overlay arg kept for the
|
|
1352
|
+
// call signature; this office draws its own DOM label group instead.)
|
|
1353
|
+
export function initOffice(canvasEl, _labels) {
|
|
1354
|
+
// Only wire up canvas/ctx — DON'T paint anything yet. Painting an empty,
|
|
1355
|
+
// furnished room before the first /api/state poll arrives caused a visible
|
|
1356
|
+
// "load flash" (a fully-built office with zero agents flickering in before the
|
|
1357
|
+
// real data). The animation loop starts on the first setAgents() call instead,
|
|
1358
|
+
// so the first painted frame already reflects real agent data (or an
|
|
1359
|
+
// intentionally-empty room when the poll genuinely reports zero agents).
|
|
1360
|
+
// (_labels is the legacy #labels overlay arg; this office owns its own DOM
|
|
1361
|
+
// label group instead — see ensureLabelWrap.)
|
|
1362
|
+
canvas = canvasEl;
|
|
1363
|
+
ctx = canvas.getContext('2d');
|
|
1364
|
+
ctx.imageSmoothingEnabled = false;
|
|
1365
|
+
world = canvas.parentElement; // the .world wrapper (absolute, transform-origin 0,0)
|
|
1366
|
+
attachInput(); // pan / zoom / click-to-open-chat
|
|
1367
|
+
|
|
1368
|
+
// Floor entities: the walkable player avatar (off by default) + its affordances.
|
|
1369
|
+
avatar = createAvatar({ enabled: false });
|
|
1370
|
+
ensureWalkUI();
|
|
1371
|
+
window.addEventListener('keydown', (e) => {
|
|
1372
|
+
const k = e.key.toLowerCase();
|
|
1373
|
+
const isOpen = k === 'e' || e.key === 'Enter';
|
|
1374
|
+
if (k !== 'g' && k !== 'n' && !isOpen) return;
|
|
1375
|
+
const t = e.target; // don't fire while typing in the chat panel
|
|
1376
|
+
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable)) return;
|
|
1377
|
+
if (k === 'g') toggleWalk();
|
|
1378
|
+
else if (k === 'n') toggleLabels();
|
|
1379
|
+
else if (isOpen && avatar && avatar.enabled && lastInspectAgent) {
|
|
1380
|
+
// open the full detail card for the agent we're standing next to
|
|
1381
|
+
selectedKey = keyOf(lastInspectAgent);
|
|
1382
|
+
draw();
|
|
1383
|
+
window.dispatchEvent(new CustomEvent('agency:select', { detail: { agent: lastInspectAgent } }));
|
|
1384
|
+
}
|
|
1385
|
+
});
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
export function init(canvasEl, n, seed) {
|
|
1389
|
+
canvas = canvasEl;
|
|
1390
|
+
ctx = canvas.getContext('2d');
|
|
1391
|
+
ctx.imageSmoothingEnabled = false;
|
|
1392
|
+
setAgents(makeAgents(n, seed));
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
let started = false;
|
|
1396
|
+
let lastAll = []; // last full agent set (pre hidden-filter), so the "show"
|
|
1397
|
+
let showHidden = false; // toggle can re-reveal hidden desks without a fresh poll
|
|
1398
|
+
// A single VACANT desk shown when the floor is empty (no agents). Flows through the
|
|
1399
|
+
// normal layout as one cell; the renderer draws an empty desk + a hover hint.
|
|
1400
|
+
const EMPTY_DESK = { placeholder: true, project: '', name: '', activity: 'idle', model: '', sessionId: '__vacant__', pid: null, subagents: [], role: null };
|
|
1401
|
+
let hiddenCount = 0;
|
|
1402
|
+
|
|
1403
|
+
const isActive = (a) => a.activity === 'working' || a.activity === 'shell';
|
|
1404
|
+
// Sort: group by project (one rug/label per team — clusterRuns needs each team
|
|
1405
|
+
// contiguous), then ACTIVE-first and LEAD-first WITHIN the team so the eye lands
|
|
1406
|
+
// on what's live. Stable + deterministic so teams don't reshuffle between polls.
|
|
1407
|
+
const agentOrder = (a, b) =>
|
|
1408
|
+
String(a.project || '').localeCompare(String(b.project || '')) ||
|
|
1409
|
+
(isActive(b) - isActive(a)) ||
|
|
1410
|
+
((b.role === 'lead') - (a.role === 'lead'));
|
|
1411
|
+
|
|
1412
|
+
// Rebuild the scene from lastAll, honoring the hidden filter + showHidden toggle.
|
|
1413
|
+
// Shared by setAgents (new poll) and the "N away · show" toggle (no poll).
|
|
1414
|
+
function rebuild() {
|
|
1415
|
+
hiddenCount = lastAll.reduce((n, a) => n + (a.hidden ? 1 : 0), 0);
|
|
1416
|
+
const visible = showHidden ? lastAll.slice() : lastAll.filter((a) => !a.hidden);
|
|
1417
|
+
// No agents → show ONE vacant desk (the snug 1-desk office, just empty) so an empty
|
|
1418
|
+
// floor reads as "your office, awaiting your first hire" instead of a bare room.
|
|
1419
|
+
// The placeholder flows through the normal layout; the draw loop + tooltip special-
|
|
1420
|
+
// case it (no worker, a hover hint).
|
|
1421
|
+
agents = visible.length ? visible : [EMPTY_DESK];
|
|
1422
|
+
plan();
|
|
1423
|
+
canvas.width = bufW;
|
|
1424
|
+
canvas.height = bufH;
|
|
1425
|
+
canvas.style.width = bufW * UPSCALE + 'px'; // crisp CSS upscale
|
|
1426
|
+
canvas.style.height = bufH * UPSCALE + 'px';
|
|
1427
|
+
ctx.imageSmoothingEnabled = false;
|
|
1428
|
+
draw(); // repaint now — setting canvas.width cleared it (avoids a blank frame)
|
|
1429
|
+
syncLabels(); // (re)position the DOM name + repo + lead-tie labels + hidden chip
|
|
1430
|
+
// fit-to-view until the user pans/zooms, then respect their view.
|
|
1431
|
+
// While walking, the camera is driven by followAvatar(); a poll-time re-fit
|
|
1432
|
+
// would snap it away for one frame, so skip the auto-fit in walk mode.
|
|
1433
|
+
if (!userMoved && !(avatar && avatar.enabled)) fitView(); else applyCam();
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
export function setAgents(next) {
|
|
1437
|
+
lastAll = (next || []).slice().sort(agentOrder);
|
|
1438
|
+
rebuild();
|
|
1439
|
+
if (!started) { started = true; loop(); } // first data → kick off the animation loop
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
let raf, timer;
|
|
1443
|
+
function loop() {
|
|
1444
|
+
if (!started) return; // cancelled (reset) between frames
|
|
1445
|
+
const now = (typeof performance !== 'undefined' ? performance.now() : Date.now());
|
|
1446
|
+
// Animation phase is derived from wall-clock at a FIXED ~7.5fps cadence, NOT
|
|
1447
|
+
// incremented per loop — so sprites animate at the same speed regardless of the
|
|
1448
|
+
// loop rate. (Walk mode runs the loop at native refresh for smooth avatar/camera
|
|
1449
|
+
// motion; without this, every typing/blink/tail-wag cycle would run ~8x faster.)
|
|
1450
|
+
frame = Math.floor(now / 130);
|
|
1451
|
+
const dt = lastT ? Math.min(now - lastT, 100) : 16; // clamp big tab-out gaps
|
|
1452
|
+
lastT = now;
|
|
1453
|
+
updateCat(dt, now); // ambient — roams whether or not walk mode is on
|
|
1454
|
+
updateWanderers(dt, now); // ambient — idle workers stroll the lower floor
|
|
1455
|
+
const walking = !!(avatar && avatar.enabled);
|
|
1456
|
+
if (walking) {
|
|
1457
|
+
avatar.update(dt, { podPositions: buildPods(), bounds: floorBounds() });
|
|
1458
|
+
followAvatar(dt);
|
|
1459
|
+
}
|
|
1460
|
+
draw();
|
|
1461
|
+
// Walking: glide at the display's native refresh — no setTimeout throttle, so
|
|
1462
|
+
// motion is smooth and frame-aligned (the old ~30fps setTimeout-in-rAF was the
|
|
1463
|
+
// choppiness). Idle: the calm ~7.5fps pixel cadence (cheap + the retro tick).
|
|
1464
|
+
if (walking) {
|
|
1465
|
+
raf = requestAnimationFrame(loop);
|
|
1466
|
+
} else {
|
|
1467
|
+
raf = requestAnimationFrame(() => {
|
|
1468
|
+
if (!started) return; // cancelled (reset) while the RAF was pending
|
|
1469
|
+
timer = setTimeout(loop, 130);
|
|
1470
|
+
});
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
export function reset(n, seed) {
|
|
1475
|
+
cancelAnimationFrame(raf);
|
|
1476
|
+
clearTimeout(timer); // kill the tick the RAF may have already scheduled
|
|
1477
|
+
started = false; // makes a pending RAF callback bail instead of rescheduling
|
|
1478
|
+
userMoved = false; // re-fit the fresh layout
|
|
1479
|
+
wanderers.clear(); // fresh floor → nobody mid-stroll
|
|
1480
|
+
setAgents(makeAgents(n, seed));
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
// expose for the harness
|
|
1484
|
+
window.__office = { init, reset };
|