@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.
Files changed (41) hide show
  1. package/README.md +106 -0
  2. package/lib/codex.js +211 -0
  3. package/lib/control.js +168 -0
  4. package/lib/live.js +493 -0
  5. package/lib/opencode.js +447 -0
  6. package/lib/paths.js +12 -0
  7. package/lib/roster.js +204 -0
  8. package/lib/transcript.js +361 -0
  9. package/lib/usage.js +346 -0
  10. package/package.json +27 -0
  11. package/public/app.js +1021 -0
  12. package/public/audio-controls.js +165 -0
  13. package/public/avatar.js +467 -0
  14. package/public/characters/dev-auburn.json +32 -0
  15. package/public/characters/dev-auburn.png +0 -0
  16. package/public/characters/dev-beanie.json +32 -0
  17. package/public/characters/dev-beanie.png +0 -0
  18. package/public/characters/dev-glasses.json +32 -0
  19. package/public/characters/dev-glasses.png +0 -0
  20. package/public/chat-panel.css +514 -0
  21. package/public/chat-panel.js +815 -0
  22. package/public/index.html +190 -0
  23. package/public/lab.html +129 -0
  24. package/public/leaderboard.js +222 -0
  25. package/public/metric.js +34 -0
  26. package/public/mock-agents.js +70 -0
  27. package/public/mock.js +277 -0
  28. package/public/music/Console_Morning.mp3 +0 -0
  29. package/public/music/Midnight_Desk.mp3 +0 -0
  30. package/public/music/The_Plant_Beside_the_Door.mp3 +0 -0
  31. package/public/music/Three_AM_Window.mp3 +0 -0
  32. package/public/office.js +1484 -0
  33. package/public/sound.js +382 -0
  34. package/public/sprites.js +983 -0
  35. package/public/style.css +506 -0
  36. package/public/ui.js +50 -0
  37. package/scripts/_pixpng.mjs +104 -0
  38. package/scripts/animsheet.mjs +60 -0
  39. package/scripts/charsheet.mjs +61 -0
  40. package/scripts/install-hook.mjs +120 -0
  41. package/server.js +370 -0
@@ -0,0 +1,165 @@
1
+ // audio-controls.js — self-contained topbar UI for the two independent audio
2
+ // channels exposed by sound.js: MUSIC (lo-fi playlist) and SFX (keyboard
3
+ // clatter). Renders two toggle buttons and a now-playing track label.
4
+ //
5
+ // This file owns its own DOM and CSS (injected from JS) so it touches no shared
6
+ // file: app.js calls initAudioControls() once, and that's the only seam. It is
7
+ // the SOLE audio UI — the old single #soundToggle button is removed by app.js.
8
+ //
9
+ // Mirrors the look of the old .sound-toggle / .panel-toggle buttons (38x34 dark
10
+ // chips that glow green/cyan when on) using the project's CSS custom properties.
11
+
12
+ import {
13
+ initSoundPref,
14
+ toggleSfx,
15
+ toggleMusic,
16
+ isSfxEnabled,
17
+ isMusicEnabled,
18
+ currentTrackName,
19
+ onTrackChange,
20
+ } from './sound.js';
21
+
22
+ const STYLE_ID = 'audio-controls-style';
23
+
24
+ // Scoped CSS for the controls. Reuses the project palette via var(--…) so it
25
+ // stays consistent if the theme changes. Kept small and tidy for the top bar.
26
+ const CSS = `
27
+ .audio-controls {
28
+ display: inline-flex; align-items: center; gap: 6px;
29
+ }
30
+ .audio-controls .ac-btn {
31
+ font-size: 15px; line-height: 1; cursor: pointer;
32
+ width: 38px; height: 34px; padding: 0;
33
+ display: inline-flex; align-items: center; justify-content: center;
34
+ border: 1px solid var(--line, #222c40); border-radius: 4px;
35
+ background: #0c111b; color: var(--ink, #cfd9ea);
36
+ transition: border-color 0.15s, box-shadow 0.15s, transform 0.05s, opacity 0.15s;
37
+ }
38
+ .audio-controls .ac-btn:hover { border-color: var(--cyan, #5cd0ff); }
39
+ .audio-controls .ac-btn:active { transform: translateY(1px); }
40
+ .audio-controls .ac-btn[aria-pressed="false"] { opacity: 0.62; }
41
+ .audio-controls .ac-btn.ac-music[aria-pressed="true"] {
42
+ border-color: var(--pink, #ff5cba); box-shadow: 0 0 10px rgba(255,92,186,0.35); opacity: 1;
43
+ }
44
+ .audio-controls .ac-btn.ac-sfx[aria-pressed="true"] {
45
+ border-color: var(--green, #39d98a); box-shadow: 0 0 10px rgba(57,217,138,0.35); opacity: 1;
46
+ }
47
+ .audio-controls .ac-now {
48
+ display: none; align-items: center; gap: 6px; max-width: 150px;
49
+ padding: 5px 9px; border: 1px solid var(--line2, #1a2336); border-radius: 4px;
50
+ background: #11161f; color: var(--muted, #6b7689);
51
+ font-family: var(--term, ui-monospace, monospace); font-size: var(--t-fine, 11px);
52
+ letter-spacing: 0.3px; white-space: nowrap; overflow: hidden;
53
+ }
54
+ .audio-controls .ac-now.show { display: inline-flex; }
55
+ .audio-controls .ac-now .ac-eq { color: var(--pink, #ff5cba); flex: none; }
56
+ .audio-controls .ac-now .ac-track {
57
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink, #cfd9ea);
58
+ }
59
+ `;
60
+
61
+ function injectStyle() {
62
+ if (document.getElementById(STYLE_ID)) return;
63
+ const el = document.createElement('style');
64
+ el.id = STYLE_ID;
65
+ el.textContent = CSS;
66
+ document.head.appendChild(el);
67
+ }
68
+
69
+ // Pick a sensible mount point: the existing topbar controls cluster, falling
70
+ // back to the header or body so we never silently render nothing.
71
+ function findAnchor() {
72
+ return (
73
+ document.querySelector('.topctrls') ||
74
+ document.querySelector('.topright') ||
75
+ document.querySelector('.topbar') ||
76
+ document.body
77
+ );
78
+ }
79
+
80
+ // Reflect a button's pressed state into aria + glyph.
81
+ function paintBtn(btn, on, onGlyph, offGlyph) {
82
+ btn.setAttribute('aria-pressed', on ? 'true' : 'false');
83
+ btn.textContent = on ? onGlyph : offGlyph;
84
+ }
85
+
86
+ // Mount the controls once and wire them to sound.js. Idempotent: a second call
87
+ // is a no-op if the controls already exist.
88
+ export function initAudioControls() {
89
+ if (document.querySelector('.audio-controls')) return;
90
+
91
+ injectStyle();
92
+ const pref = initSoundPref(); // { sfx, music } — desired pre-gesture state
93
+
94
+ const wrap = document.createElement('div');
95
+ wrap.className = 'audio-controls';
96
+
97
+ // --- MUSIC toggle ---------------------------------------------------------
98
+ const musicBtn = document.createElement('button');
99
+ musicBtn.type = 'button';
100
+ musicBtn.className = 'ac-btn ac-music';
101
+ musicBtn.title = 'Toggle lo-fi music';
102
+ musicBtn.setAttribute('aria-label', 'Toggle lo-fi music');
103
+
104
+ // --- SFX toggle -----------------------------------------------------------
105
+ const sfxBtn = document.createElement('button');
106
+ sfxBtn.type = 'button';
107
+ sfxBtn.className = 'ac-btn ac-sfx';
108
+ sfxBtn.title = 'Toggle keyboard clatter';
109
+ sfxBtn.setAttribute('aria-label', 'Toggle keyboard clatter sound effects');
110
+
111
+ // --- now-playing label ----------------------------------------------------
112
+ const now = document.createElement('div');
113
+ now.className = 'ac-now';
114
+ now.setAttribute('aria-live', 'polite');
115
+ const eq = document.createElement('span');
116
+ eq.className = 'ac-eq';
117
+ eq.textContent = '♫';
118
+ const trackSpan = document.createElement('span');
119
+ trackSpan.className = 'ac-track';
120
+ now.appendChild(eq);
121
+ now.appendChild(trackSpan);
122
+
123
+ // Reflect the persisted prefs onto the buttons (engines stay off until the
124
+ // first user gesture; these calls just paint the buttons).
125
+ paintBtn(musicBtn, pref.music, '🎵', '🎵');
126
+ paintBtn(sfxBtn, pref.sfx, '⌨️', '⌨️');
127
+
128
+ function refreshNow() {
129
+ const name = currentTrackName();
130
+ if (isMusicEnabled() && name) {
131
+ trackSpan.textContent = name;
132
+ now.classList.add('show');
133
+ } else {
134
+ trackSpan.textContent = '';
135
+ now.classList.remove('show');
136
+ }
137
+ }
138
+
139
+ musicBtn.addEventListener('click', () => {
140
+ const on = toggleMusic(); // user gesture → music start/stop allowed
141
+ paintBtn(musicBtn, on, '🎵', '🎵');
142
+ refreshNow(); // track name arrives async via onTrackChange; this clears it on stop
143
+ });
144
+
145
+ sfxBtn.addEventListener('click', () => {
146
+ const on = toggleSfx(); // user gesture → AudioContext create/resume allowed
147
+ paintBtn(sfxBtn, on, '⌨️', '⌨️');
148
+ });
149
+
150
+ // Track changes update the label live (fires '' when music stops).
151
+ onTrackChange(refreshNow);
152
+
153
+ wrap.appendChild(musicBtn);
154
+ wrap.appendChild(sfxBtn);
155
+ wrap.appendChild(now);
156
+
157
+ // Insert before the panel toggle if present so the audio cluster sits with the
158
+ // other right-hand controls; otherwise just append.
159
+ const anchor = findAnchor();
160
+ const panelToggle = anchor.querySelector && anchor.querySelector('.panel-toggle');
161
+ if (panelToggle) anchor.insertBefore(wrap, panelToggle);
162
+ else anchor.appendChild(wrap);
163
+
164
+ refreshNow();
165
+ }
@@ -0,0 +1,467 @@
1
+ // avatar.js — a controllable USER AVATAR for the pixel office. The user drives a
2
+ // "player" character around the floor with WASD / arrow keys, walks up to an
3
+ // agent's desk, and the existing chat panel surfaces what that agent is doing.
4
+ //
5
+ // DORMANT: this module is PRESERVED for a future idle-wander / walkable-avatar
6
+ // feature but is not wired into the live office today (its former caller,
7
+ // render.js, was deleted when proc became the sole renderer). The walk-cycle
8
+ // code below is intentionally kept intact + self-contained.
9
+ //
10
+ // SELF-CONTAINED: this module owns the avatar's position, input, animation and
11
+ // proximity logic. It draws in the SAME buffer coordinates a worker uses (the
12
+ // office camera transform is applied by CSS to the whole world, so a buffer-
13
+ // space draw lands correctly), reusing its own inlined character pipeline (the
14
+ // dev-auburn walk atlas) so the avatar matches the art.
15
+ //
16
+ // SEAM CONTRACT (how a renderer would drive it):
17
+ // const avatar = createAvatar(); // once, after initOffice
18
+ // avatar.update(dtMs, { podPositions, bounds }); // each frame, before draw
19
+ // avatar.draw(ctx); // each frame, after workers
20
+ // podPositions: [{ x, y, i, agent }] — desk buffer coords + the agent
21
+ // bounds: { minX, maxX, minY, maxY } — floor extents (clamp box)
22
+ // getters: avatar.pos {x,y} · avatar.nearestAgentIndex (int|null) · avatar.enabled (settable)
23
+ //
24
+ // PROXIMITY → INSPECT: when the avatar walks into a desk's radius it dispatches
25
+ // the EXISTING `agency:select` CustomEvent (detail `{ agent }`) so the chat panel
26
+ // reacts exactly as it does for a click. Debounced: it fires only when ENTERING a
27
+ // new desk's zone, and clears (agent:null) on exit.
28
+ //
29
+ // CAMERA-FOLLOW is intentionally NOT here — a host renderer owns the camera. This
30
+ // module just exposes `pos` so a renderer can optionally center on it.
31
+
32
+ // The LIVE avatar draws as a front-facing proc worker (drawPerson) so it matches
33
+ // the proc office art, with striding legs + a USER tag added on top (see draw()).
34
+ // The animated-atlas pipeline below is the older side-profile path — kept DORMANT
35
+ // (intentionally preserved) but no longer fed; drawPerson is the active body.
36
+ import { drawPerson, px } from './sprites.js';
37
+
38
+ // Walk-cycle character pipeline — inlined here (formerly characters.js) so the
39
+ // avatar's animated walk atlas survives independently. Only the ANIMATED path is
40
+ // needed: the avatar always loads a generated atlas JSON (dev-auburn.json), never
41
+ // a static office-sheet sprite, so the sheet-blit path doesn't come along.
42
+
43
+ // Default cadences for any anim a sheet doesn't specify an fps for.
44
+ const DEFAULT_FPS = { idle: 2, type: 6, walk: 8 };
45
+
46
+ // Load an animated character: fetch its JSON atlas, normalize to the contract,
47
+ // and load its PNG. Resolves to { kind:'animated', atlas, img } or null on any
48
+ // failure (fail-soft so a missing/half-generated atlas never throws).
49
+ async function loadCharacter(jsonUrl) {
50
+ try {
51
+ const res = await fetch(jsonUrl);
52
+ if (!res.ok) return null;
53
+ const raw = await res.json();
54
+ const atlas = normalizeAtlas(raw, jsonUrl);
55
+ if (!atlas) return null;
56
+ const img = await loadImage(resolveImageUrl(jsonUrl, atlas.image));
57
+ if (!img) return null;
58
+ return { kind: 'animated', atlas, img };
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ // Resolve the sheet path relative to the JSON's directory so an atlas can name
65
+ // its image as a bare filename ("auburn-walk.png").
66
+ function resolveImageUrl(jsonUrl, image) {
67
+ if (!image) return null;
68
+ if (/^(https?:)?\//.test(image)) return image;
69
+ const dir = jsonUrl.replace(/[^/]*$/, '');
70
+ return dir + image;
71
+ }
72
+
73
+ function loadImage(src) {
74
+ return new Promise((resolve) => {
75
+ if (!src) { resolve(null); return; }
76
+ const img = new Image();
77
+ img.onload = () => resolve(img);
78
+ img.onerror = () => resolve(null);
79
+ img.src = src;
80
+ });
81
+ }
82
+
83
+ // Bring an emitted atlas into the canonical shape. Tolerates the dev fixture
84
+ // (no name/fps, has rows/frameCount, only a walk anim) and fills sane defaults.
85
+ function normalizeAtlas(raw, jsonUrl) {
86
+ if (!raw || !raw.cellW || !raw.cellH) return null;
87
+ const cols = raw.cols || raw.columns || 1;
88
+ const anims = { ...(raw.anims || {}) };
89
+ // A sheet with no named anims at all (just a frame strip) → treat the whole
90
+ // strip as a single looping anim usable for every state.
91
+ if (!anims.idle && !anims.type && !anims.walk) {
92
+ const count = raw.frameCount || cols * (raw.rows || 1);
93
+ const all = Array.from({ length: count }, (_, i) => i);
94
+ anims.idle = anims.type = anims.walk = all;
95
+ }
96
+ // Fall back any missing state to whatever the sheet does have.
97
+ const have = anims.idle || anims.type || anims.walk || [0];
98
+ anims.idle = anims.idle && anims.idle.length ? anims.idle : have;
99
+ anims.type = anims.type && anims.type.length ? anims.type : (anims.idle || have);
100
+ anims.walk = anims.walk && anims.walk.length ? anims.walk : have;
101
+ const fps = { ...DEFAULT_FPS, ...(raw.fps || {}) };
102
+ return {
103
+ name: raw.name || jsonUrl.replace(/.*\//, '').replace(/\.json$/, ''),
104
+ image: raw.image,
105
+ cellW: raw.cellW,
106
+ cellH: raw.cellH,
107
+ cols,
108
+ anchorX: raw.anchorX != null ? raw.anchorX : Math.floor(raw.cellW / 2),
109
+ anchorY: raw.anchorY != null ? raw.anchorY : raw.cellH - 1,
110
+ anims,
111
+ fps,
112
+ };
113
+ }
114
+
115
+ // Pick the frame index for an animated state from a shared clock. `clockMs` is a
116
+ // monotonic time; we advance through anims[state] at the state's fps. `phase`
117
+ // desyncs identical characters so a row doesn't keystroke in lockstep.
118
+ function frameForState({ atlas, state = 'idle', clockMs = 0, phase = 0 }) {
119
+ const a = atlas.anims[state] || atlas.anims.idle || [0];
120
+ if (a.length <= 1) return a[0] || 0;
121
+ const fps = atlas.fps[state] || DEFAULT_FPS[state] || 4;
122
+ const step = Math.floor(clockMs / (1000 / fps)) + (phase | 0);
123
+ return a[((step % a.length) + a.length) % a.length];
124
+ }
125
+
126
+ // Draw one animated character cell so its anchor lands at (screenX, baselineY).
127
+ function drawCharacter(ctx, screenX, baselineY, atlas, img, state, frame) {
128
+ if (!atlas || !img || !img.complete || !img.naturalWidth) return;
129
+ const { cellW, cellH, cols, anchorX, anchorY } = atlas;
130
+ const f = frame | 0;
131
+ const col = ((f % cols) + cols) % cols;
132
+ const row = Math.floor(f / cols);
133
+ const sx = col * cellW;
134
+ const sy = row * cellH;
135
+ const dx = Math.round(screenX - anchorX);
136
+ const dy = Math.round(baselineY - anchorY);
137
+ const prevSmooth = ctx.imageSmoothingEnabled;
138
+ ctx.imageSmoothingEnabled = false;
139
+ ctx.drawImage(img, sx, sy, cellW, cellH, dx, dy, cellW, cellH);
140
+ ctx.imageSmoothingEnabled = prevSmooth;
141
+ }
142
+
143
+ // Draw any character with its anchor at (screenX, baselineY). The avatar only
144
+ // ever feeds an animated atlas; static sheet sprites are not supported here.
145
+ function drawCharacterState(ctx, screenX, baselineY, char, { state = 'idle', clockMs = 0, phase = 0 } = {}) {
146
+ if (!char) return;
147
+ if (char.atlas && char.img) {
148
+ const frame = frameForState({ atlas: char.atlas, state, clockMs, phase });
149
+ drawCharacter(ctx, screenX, baselineY, char.atlas, char.img, state, frame);
150
+ }
151
+ }
152
+
153
+ // The avatar borrows the office's own generated walk atlas (side-profile walk
154
+ // cycle + idle frames). Path is relative to /public so it works under the static
155
+ // server. Loads async + fail-soft; until it's ready draw() falls back to a tiny
156
+ // procedural marker so the avatar is never invisible.
157
+ const AVATAR_ATLAS_URL = '/characters/dev-auburn.json';
158
+
159
+ // Movement feel. SPEED is buffer-px per second (the buffer is the low-res office;
160
+ // a worker pod is POD_W=64 wide, so ~90px/s crosses a couple of desks a second —
161
+ // brisk but controllable). The atlas anchor is the feet, so (x,y) is a FLOOR
162
+ // point and we draw the sprite standing on it.
163
+ const SPEED = 92; // buffer px / second
164
+ const AVATAR_SEED = 7; // fixed appearance for the player's proc body
165
+ const PROXIMITY_R = 46; // enter this radius of a desk centre → inspect it
166
+ const PROXIMITY_EXIT_R = 60; // leave beyond this (hysteresis so it doesn't flap)
167
+
168
+ // Desk hit geometry mirrors the office: buildPods() gives a cell's top-left and
169
+ // the worker/desk sit at its centre, so we measure proximity to the cell centre.
170
+ // Proc cells are CELL_W=58 x CELL_H=78 (NOT the old hybrid 64x92 pod), so the
171
+ // centre offset is 29/39 — using the old 32/56 put the hot-spot ~17px too low.
172
+ const POD_W = 64, POD_H = 92; // legacy hybrid metrics, kept for the walk-speed note above
173
+ const DESK_CX = 29; // proc CELL_W/2 — desk-centre x offset from the cell top-left
174
+ const DESK_CY = 39; // proc CELL_H/2 — desk-centre y offset
175
+
176
+ export function createAvatar(opts = {}) {
177
+ return new Avatar(opts);
178
+ }
179
+
180
+ class Avatar {
181
+ constructor(opts) {
182
+ // Floor position (buffer coords) = the avatar's FEET. Seeded to opts.start or
183
+ // a sensible spot; clamped into bounds on the first update once we know them.
184
+ this.x = opts.start?.x ?? 80;
185
+ this.y = opts.start?.y ?? 140;
186
+ this._enabled = opts.enabled !== false;
187
+
188
+ // Facing: +1 right, -1 left. Default right (the atlas walk faces right).
189
+ this.facing = 1;
190
+ this.moving = false;
191
+ this.animClock = 0; // ms accumulator driving the walk/idle frame
192
+
193
+ // Nearest desk currently being inspected (index into podPositions' agent
194
+ // index `i`). `_nearestIndex` IS the inspection identity (the pod's `i`, or
195
+ // null when not inspecting any desk) — we (re)fire agency:select only when it
196
+ // CHANGES, so the chat panel updates on a real desk-to-desk handoff but not
197
+ // every frame. Using the always-present pod index as the key (rather than the
198
+ // agent's hash) avoids any "keyless agent" ambiguity in enter/exit.
199
+ this._nearestIndex = null;
200
+
201
+ // Held keys → movement vector. Tracked as a set of logical directions so
202
+ // multiple keys combine (diagonals) and key-repeat doesn't matter.
203
+ this._held = { up: false, down: false, left: false, right: false };
204
+
205
+ // The avatar draws via the proc worker sprite (drawPerson) — no atlas to load.
206
+ this._onKeyDown = this._onKeyDown.bind(this);
207
+ this._onKeyUp = this._onKeyUp.bind(this);
208
+ window.addEventListener('keydown', this._onKeyDown);
209
+ window.addEventListener('keyup', this._onKeyUp);
210
+ }
211
+
212
+ // ---- public getters / setters -------------------------------------------
213
+
214
+ get pos() { return { x: this.x, y: this.y }; }
215
+ get nearestAgentIndex() { return this._nearestIndex; }
216
+ // Place the avatar (buffer coords) — the host seeds it at the view centre on enable.
217
+ setPos(x, y) { this.x = x; this.y = y; }
218
+ get enabled() { return this._enabled; }
219
+ set enabled(v) {
220
+ this._enabled = !!v;
221
+ if (!this._enabled) {
222
+ // Drop held keys + stop so a disable mid-stride doesn't leave the avatar
223
+ // gliding, and release any inspection so the panel isn't pinned.
224
+ this._held = { up: false, down: false, left: false, right: false };
225
+ this.moving = false;
226
+ this._clearInspection();
227
+ }
228
+ }
229
+
230
+ // Tear-down for completeness (nothing wires it up today, but the harness can).
231
+ destroy() {
232
+ window.removeEventListener('keydown', this._onKeyDown);
233
+ window.removeEventListener('keyup', this._onKeyUp);
234
+ }
235
+
236
+ // ---- input ---------------------------------------------------------------
237
+
238
+ // Map a KeyboardEvent to one of our logical directions (WASD + arrows). Returns
239
+ // null for any other key.
240
+ _dirFor(e) {
241
+ switch (e.key) {
242
+ case 'ArrowUp': case 'w': case 'W': return 'up';
243
+ case 'ArrowDown': case 's': case 'S': return 'down';
244
+ case 'ArrowLeft': case 'a': case 'A': return 'left';
245
+ case 'ArrowRight': case 'd': case 'D': return 'right';
246
+ default: return null;
247
+ }
248
+ }
249
+
250
+ // Ignore input while typing into a field (so WASD in the chat box doesn't walk
251
+ // the avatar) or while disabled.
252
+ _shouldIgnore(e) {
253
+ if (!this._enabled) return true;
254
+ const t = e.target;
255
+ if (!t) return false;
256
+ const tag = t.tagName;
257
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
258
+ if (t.isContentEditable) return true;
259
+ return false;
260
+ }
261
+
262
+ _onKeyDown(e) {
263
+ const dir = this._dirFor(e);
264
+ if (!dir) return;
265
+ if (this._shouldIgnore(e)) return;
266
+ // Arrow keys scroll the page by default — suppress that so the floor doesn't
267
+ // jump while driving. (WASD don't scroll, so no need.)
268
+ if (e.key.startsWith('Arrow')) e.preventDefault();
269
+ this._held[dir] = true;
270
+ }
271
+
272
+ _onKeyUp(e) {
273
+ const dir = this._dirFor(e);
274
+ if (!dir) return;
275
+ // Always honor key-UP even if disabled / focus moved, so a key can't stick.
276
+ this._held[dir] = false;
277
+ }
278
+
279
+ // ---- per-frame update ----------------------------------------------------
280
+
281
+ // Advance position from held keys, clamp to bounds, animate, and resolve the
282
+ // nearest desk (firing agency:select on entering a new one).
283
+ update(dtMs, { podPositions = [], bounds } = {}) {
284
+ // Disabled (toggled off / non-hybrid): do nothing — no movement, and crucially
285
+ // no _resolveNearest, so toggling off while standing on a desk doesn't re-latch
286
+ // and re-fire agency:select right after the enabled-setter already cleared it.
287
+ if (!this._enabled) return;
288
+ const dt = Math.max(0, Math.min(dtMs || 0, 100)) / 1000; // clamp big tab-out gaps
289
+
290
+ // Movement vector from held keys.
291
+ let vx = (this._held.right ? 1 : 0) - (this._held.left ? 1 : 0);
292
+ let vy = (this._held.down ? 1 : 0) - (this._held.up ? 1 : 0);
293
+ if (!this._enabled) { vx = 0; vy = 0; }
294
+
295
+ this.moving = (vx !== 0 || vy !== 0);
296
+ if (this.moving) {
297
+ // Normalize so diagonals aren't faster than cardinals.
298
+ const len = Math.hypot(vx, vy) || 1;
299
+ this.x += (vx / len) * SPEED * dt;
300
+ this.y += (vy / len) * SPEED * dt;
301
+ // Face the horizontal direction of travel; keep the last facing on pure-
302
+ // vertical moves so the sprite doesn't snap to a default.
303
+ if (vx > 0) this.facing = 1;
304
+ else if (vx < 0) this.facing = -1;
305
+ this.animClock += dtMs || 0;
306
+ } else {
307
+ // Idle: keep a slow idle cadence ticking (drawCharacterState reads a clock).
308
+ this.animClock += dtMs || 0;
309
+ }
310
+
311
+ // Clamp to the floor extents so the avatar can't walk off the room.
312
+ if (bounds) {
313
+ this.x = clamp(this.x, bounds.minX, bounds.maxX);
314
+ this.y = clamp(this.y, bounds.minY, bounds.maxY);
315
+ }
316
+
317
+ this._resolveNearest(podPositions);
318
+ }
319
+
320
+ // Find the closest desk and reconcile the inspection with hysteresis. Two radii
321
+ // (enter < exit) keep it from flapping at a zone edge, while still allowing a
322
+ // crisp desk-to-desk HANDOFF: a different desk inside the enter radius wins even
323
+ // when the current one is still within the exit band. agency:select fires only
324
+ // when the inspected desk actually changes.
325
+ _resolveNearest(podPositions) {
326
+ let best = null, bestD = Infinity;
327
+ let current = null, currentD = Infinity; // the desk we're currently inspecting
328
+ for (const p of podPositions) {
329
+ if (!p || !p.agent) continue;
330
+ const dx = this.x - (p.x + DESK_CX);
331
+ const dy = this.y - (p.y + DESK_CY);
332
+ const d = Math.hypot(dx, dy);
333
+ if (d < bestD) { bestD = d; best = p; }
334
+ if (p.i === this._nearestIndex) { current = p; currentD = d; }
335
+ }
336
+
337
+ if (this._nearestIndex == null) {
338
+ // Not inspecting: latch onto the nearest desk once inside the enter radius.
339
+ if (best && bestD <= PROXIMITY_R) this._setInspection(best);
340
+ } else if (best && best.i !== this._nearestIndex && bestD <= PROXIMITY_R) {
341
+ // Handoff: a DIFFERENT desk is now within the enter radius → switch to it.
342
+ this._setInspection(best);
343
+ } else if (!current || currentD > PROXIMITY_EXIT_R) {
344
+ // The inspected desk is gone or we've walked past its (larger) exit radius.
345
+ this._setInspection(null);
346
+ }
347
+ // else: still loosely near the same desk — hold it, no re-fire.
348
+ }
349
+
350
+ // Set (or clear) the inspected desk. Fires agency:select with the new desk's
351
+ // agent (the same event a desk click uses) — or agent:null on clear —
352
+ // only on an actual change, so the chat panel tracks the avatar without churn.
353
+ _setInspection(p) {
354
+ const nextIndex = p ? p.i : null;
355
+ if (nextIndex === this._nearestIndex) return;
356
+ this._nearestIndex = nextIndex;
357
+ dispatchInspect(p ? p.agent : null);
358
+ }
359
+
360
+ _clearInspection() { this._setInspection(null); }
361
+
362
+ // ---- draw ----------------------------------------------------------------
363
+
364
+ // Draw the avatar at its buffer (x,y) — the camera is applied by the office via
365
+ // CSS, so we draw in buffer coords exactly like a worker. Layers: a soft floor
366
+ // shadow, the character sprite (flipped when facing left), then a "YOU" marker
367
+ // (bobbing arrow + label) above the head so the player reads as the user.
368
+ draw(ctx) {
369
+ const prevSmooth = ctx.imageSmoothingEnabled;
370
+ ctx.imageSmoothingEnabled = false;
371
+
372
+ const cx = Math.round(this.x);
373
+ const fy = Math.round(this.y); // feet / floor point
374
+ // Walk stride: -1 = standing (both legs planted), 0/1 alternate a step.
375
+ const stride = this.moving ? (Math.floor(this.animClock / 90) % 2) : -1;
376
+ // a 1px walk bounce: the body bobs up on alternating steps while the feet plant.
377
+ const bob = stride === 0 ? 1 : 0;
378
+
379
+ // ground shadow + a magenta USER ring so the player reads as "you" at a glance
380
+ ctx.save();
381
+ ctx.globalAlpha = 0.22;
382
+ ctx.fillStyle = '#000';
383
+ ctx.beginPath();
384
+ ctx.ellipse(this.x, this.y, 9, 3, 0, 0, Math.PI * 2);
385
+ ctx.fill();
386
+ ctx.globalAlpha = 0.55;
387
+ ctx.strokeStyle = '#ff2e88';
388
+ ctx.lineWidth = 1;
389
+ ctx.beginPath();
390
+ ctx.ellipse(this.x, this.y, 8.5, 3, 0, 0, Math.PI * 2);
391
+ ctx.stroke();
392
+ ctx.restore();
393
+
394
+ // Legs first (the torso overlaps their tops). drawPerson is a SEATED worker
395
+ // with no legs, so the avatar grows its own — two that alternate a step while
396
+ // walking, both planted when standing.
397
+ const pants = '#2b2f3a';
398
+ const lLift = stride === 0 ? 1 : 0;
399
+ const rLift = stride === 1 ? 1 : 0;
400
+ px(ctx, cx - 4, fy - 4 + lLift, 3, 4 - lLift, pants);
401
+ px(ctx, cx + 1, fy - 4 + rLift, 3, 4 - rLift, pants);
402
+
403
+ // Front-facing proc body (matches the office workers). drawPerson's torso
404
+ // bottom lands ~29px below headTop, i.e. fy-4, right where the legs begin.
405
+ drawPerson(ctx, cx, fy - 33 - bob, {
406
+ seed: AVATAR_SEED,
407
+ activity: 'idle', // never "typing" — the avatar walks the floor, isn't at a desk
408
+ frame: Math.floor(this.animClock / 130),
409
+ });
410
+
411
+ this._drawYouMarker(ctx);
412
+
413
+ ctx.imageSmoothingEnabled = prevSmooth;
414
+ }
415
+
416
+ // The "YOU" tag: a bobbing magenta down-arrow + label floating above the head,
417
+ // distinct from a worker's PM-crown / selection-plumbob so the player avatar is
418
+ // unmistakable. Drawn in buffer px (scales with the world).
419
+ _drawYouMarker(ctx) {
420
+ const bob = (Math.floor(this.animClock / 250) % 2) ? 1 : 0;
421
+ const headTop = this.y - 34; // ~atlas cell height above the feet
422
+ const ax = Math.round(this.x);
423
+ const ay = Math.round(headTop - 8 - bob);
424
+
425
+ // "YOU" as 3x5 pixel glyphs (fillRect), NOT canvas text — the world canvas is
426
+ // CSS-upscaled several×, and anti-aliased fillText blurs when magnified; these
427
+ // integer rects stay crisp like the rest of the procedural art.
428
+ const GLYPHS = { Y: ['101', '101', '010', '010', '010'], O: ['111', '101', '101', '101', '111'], U: ['101', '101', '101', '101', '111'] };
429
+ const letters = 'YOU', lw = 3, lh = 5, gap = 1;
430
+ const textW = letters.length * lw + (letters.length - 1) * gap; // 11
431
+ const padX = 3, padY = 2;
432
+ const w = textW + padX * 2, h = lh + padY * 2; // 17 x 9
433
+ const lx = ax - Math.round(w / 2), ly = ay - h - 2;
434
+ ctx.fillStyle = '#ff2e88'; // hot magenta pill
435
+ ctx.fillRect(lx, ly, w, h);
436
+ ctx.fillStyle = 'rgba(255,255,255,0.30)'; // top sheen
437
+ ctx.fillRect(lx, ly, w, 1);
438
+ ctx.fillStyle = '#fff';
439
+ let gx = lx + padX;
440
+ for (const ch of letters) {
441
+ const g = GLYPHS[ch];
442
+ for (let r = 0; r < lh; r++) for (let c = 0; c < lw; c++) {
443
+ if (g[r][c] === '1') ctx.fillRect(gx + c, ly + padY + r, 1, 1);
444
+ }
445
+ gx += lw + gap;
446
+ }
447
+
448
+ // down-arrow pointing at the head — pixel rows (crisp), not an AA triangle
449
+ ctx.fillStyle = '#ff2e88';
450
+ ctx.fillRect(ax - 3, ay - 1, 7, 1);
451
+ ctx.fillRect(ax - 2, ay, 5, 1);
452
+ ctx.fillRect(ax - 1, ay + 1, 3, 1);
453
+ ctx.fillRect(ax, ay + 2, 1, 1);
454
+ }
455
+ }
456
+
457
+ // ---- helpers ---------------------------------------------------------------
458
+
459
+ function clamp(v, lo, hi) { return v < lo ? lo : v > hi ? hi : v; }
460
+
461
+ // Dispatch a LIGHT "inspect" event as the avatar walks past desks — office.js
462
+ // turns this into a hover tooltip beside the agent (NOT the heavy detail card, so
463
+ // the floor isn't blocked while you wander). Pressing E (or clicking) opens the
464
+ // full card for the inspected agent. Clears (agent:null) on walk-exit.
465
+ function dispatchInspect(agent) {
466
+ window.dispatchEvent(new CustomEvent('agency:inspect', { detail: { agent: agent || null } }));
467
+ }
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "dev-auburn",
3
+ "image": "dev-auburn.png",
4
+ "cellW": 27,
5
+ "cellH": 34,
6
+ "cols": 4,
7
+ "anchorX": 13,
8
+ "anchorY": 33,
9
+ "anims": {
10
+ "idle": [
11
+ 0,
12
+ 1
13
+ ],
14
+ "type": [
15
+ 4,
16
+ 5,
17
+ 6,
18
+ 7
19
+ ],
20
+ "walk": [
21
+ 8,
22
+ 9,
23
+ 10,
24
+ 11
25
+ ]
26
+ },
27
+ "fps": {
28
+ "idle": 2,
29
+ "type": 6,
30
+ "walk": 8
31
+ }
32
+ }
Binary file