@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
|
@@ -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
|
+
}
|
package/public/avatar.js
ADDED
|
@@ -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
|