@bill10/agent-007 0.6.2
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/LICENSE +21 -0
- package/README.md +222 -0
- package/VERSION +1 -0
- package/bin/adduser.js +69 -0
- package/bin/agent-007.js +88 -0
- package/lib/cron.js +189 -0
- package/lib/helpers.js +541 -0
- package/lib/jobs.js +965 -0
- package/package.json +63 -0
- package/public/app.js +650 -0
- package/public/assets/characters/LICENSE +21 -0
- package/public/assets/characters/char_0.png +0 -0
- package/public/assets/characters/char_1.png +0 -0
- package/public/assets/characters/char_2.png +0 -0
- package/public/assets/characters/char_3.png +0 -0
- package/public/assets/characters/char_4.png +0 -0
- package/public/assets/characters/char_5.png +0 -0
- package/public/assets/furniture/bookshelf.png +0 -0
- package/public/assets/furniture/cactus.png +0 -0
- package/public/assets/furniture/chair_back.png +0 -0
- package/public/assets/furniture/chair_front.png +0 -0
- package/public/assets/furniture/chair_side.png +0 -0
- package/public/assets/furniture/coffee.png +0 -0
- package/public/assets/furniture/coffee_table.png +0 -0
- package/public/assets/furniture/desk.png +0 -0
- package/public/assets/furniture/desk2.png +0 -0
- package/public/assets/furniture/plant_2.png +0 -0
- package/public/assets/furniture/sofa_front.png +0 -0
- package/public/assets/furniture/sofa_side.png +0 -0
- package/public/assets/furniture/table_front.png +0 -0
- package/public/index.html +245 -0
- package/public/modules/auth.js +83 -0
- package/public/modules/explorer.js +760 -0
- package/public/modules/jobs.js +971 -0
- package/public/modules/office.js +2154 -0
- package/public/modules/paths.js +20 -0
- package/public/modules/shortcuts.js +54 -0
- package/public/modules/state.js +75 -0
- package/public/modules/terminal.js +651 -0
- package/public/modules/voice.js +393 -0
- package/public/modules/ws.js +56 -0
- package/public/style.css +1843 -0
- package/server/agent-mcp-bridge.js +45 -0
- package/server/agent-mcp.js +184 -0
- package/server/agent-transcripts.js +195 -0
- package/server/approvals.js +155 -0
- package/server/auth.js +162 -0
- package/server/billion.js +176 -0
- package/server/claude-trust.js +66 -0
- package/server/command-path.js +102 -0
- package/server/config.js +184 -0
- package/server/direct-run.js +33 -0
- package/server/git.js +630 -0
- package/server/http.js +276 -0
- package/server/jobs.js +2044 -0
- package/server/mcp.js +596 -0
- package/server/messages.js +319 -0
- package/server/permission-hook.js +47 -0
- package/server/pty.js +360 -0
- package/server/state.js +104 -0
- package/server/ws.js +583 -0
- package/server.js +306 -0
- package/templates/billion/COMPANY.md +14 -0
- package/templates/billion/STATE.md +17 -0
- package/templates/billion/charter.md +232 -0
- package/templates/billion/owner.md +11 -0
|
@@ -0,0 +1,2154 @@
|
|
|
1
|
+
// Pixel office rendering — canvas drawing, click handling, animation
|
|
2
|
+
import { agents, jobs, activeSessionId, canControlAgent, setView } from './state.js';
|
|
3
|
+
import { switchToSession } from './terminal.js';
|
|
4
|
+
import { showJobBoard } from './jobs.js';
|
|
5
|
+
|
|
6
|
+
const Z = 3;
|
|
7
|
+
|
|
8
|
+
const WALL_H = 36 * Z;
|
|
9
|
+
const WALL_BOTTOM = WALL_H + 2 * Z;
|
|
10
|
+
const WS_W = 32, WS_H = 36, WS_GAP_X = 12, WS_GAP_Y = 18;
|
|
11
|
+
// Shared by the agent name labels and the pod rug labels so they stay in step.
|
|
12
|
+
const LABEL_FONT = 'bold 11px monospace';
|
|
13
|
+
|
|
14
|
+
// Persistent dust mote positions (stable across frames)
|
|
15
|
+
const DUST_MOTES = [
|
|
16
|
+
{ baseX: 0.15, baseY: 0.35, phaseX: 0, phaseY: 0.5 },
|
|
17
|
+
{ baseX: 0.45, baseY: 0.25, phaseX: 1.2, phaseY: 2.1 },
|
|
18
|
+
{ baseX: 0.70, baseY: 0.40, phaseX: 2.5, phaseY: 0.8 },
|
|
19
|
+
{ baseX: 0.30, baseY: 0.30, phaseX: 3.8, phaseY: 3.2 },
|
|
20
|
+
{ baseX: 0.85, baseY: 0.20, phaseX: 5.0, phaseY: 1.5 },
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
const prefersReducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches;
|
|
24
|
+
|
|
25
|
+
// --- Furniture + character sprites ---
|
|
26
|
+
// Desks/bookshelf: Free Furniture Office Equipment Set by Antea (CC-BY 4.0)
|
|
27
|
+
// Characters: pixel-agents (MIT), based on JIK-A-4 "Metro City" pack (CC0).
|
|
28
|
+
// Each char sheet is 112×96: 7 columns × 3 rows of 16×32 frames.
|
|
29
|
+
// Rows: 0 = facing down (viewer), 1 = facing up (back), 2 = facing right.
|
|
30
|
+
// Columns: 0-2 walk cycle (1 = standing), 3-4 seated typing, 5-6 reading.
|
|
31
|
+
export const SPRITE_PATHS = {
|
|
32
|
+
desk: 'assets/furniture/desk.png', // 32×32 computer desk with monitor
|
|
33
|
+
desk2: 'assets/furniture/desk2.png', // 32×32 alt desk layout
|
|
34
|
+
bookshelf: 'assets/furniture/bookshelf.png', // 16×16 short bookcase, art in x 2–14 (Antea CC-BY 4.0)
|
|
35
|
+
// Ambient decor from pixel-agents (MIT). 16px-tile art, drawn at CHAR_SCALE
|
|
36
|
+
// like the characters.
|
|
37
|
+
cactus: 'assets/furniture/cactus.png', // 16×32
|
|
38
|
+
plant2: 'assets/furniture/plant_2.png', // 16×32
|
|
39
|
+
sofa: 'assets/furniture/sofa_side.png', // 16×32, side view facing right, art in x 0–12, y 1–31
|
|
40
|
+
sofafront: 'assets/furniture/sofa_front.png', // 32×16, front view facing down, art fills the box
|
|
41
|
+
table: 'assets/furniture/coffee_table.png', // 32×32, art in x 2–29, y 6–30
|
|
42
|
+
coffee: 'assets/furniture/coffee.png', // 16×16, art in the top-right 8×7
|
|
43
|
+
conftable: 'assets/furniture/table_front.png', // 48×64 wooden table, art in rows 11–63
|
|
44
|
+
confchair: 'assets/furniture/chair_side.png', // 16×16 cushioned chair facing right, art in x 0–11
|
|
45
|
+
confchairback: 'assets/furniture/chair_back.png', // 16×16 cushioned chair seen from behind, art in x 2–13
|
|
46
|
+
confchairfront: 'assets/furniture/chair_front.png', // 16×16 the same chair from the front: lit seat below the back, art in x 2–13
|
|
47
|
+
char0: 'assets/characters/char_0.png',
|
|
48
|
+
char1: 'assets/characters/char_1.png',
|
|
49
|
+
char2: 'assets/characters/char_2.png',
|
|
50
|
+
char3: 'assets/characters/char_3.png',
|
|
51
|
+
char4: 'assets/characters/char_4.png',
|
|
52
|
+
char5: 'assets/characters/char_5.png',
|
|
53
|
+
};
|
|
54
|
+
// Derived from the charN keys above; exported for the character-variant test.
|
|
55
|
+
export const CHAR_VARIANTS = Object.keys(SPRITE_PATHS).filter((k) => k.startsWith('char')).length;
|
|
56
|
+
export const CHAR_FRAME_W = 16;
|
|
57
|
+
const CHAR_FRAME_H = 32;
|
|
58
|
+
const CHAR_SCALE = 2; // 16px art in a 32px-tile office — 2x keeps pixels square
|
|
59
|
+
const CHAR_ROW_DOWN = 0, CHAR_ROW_UP = 1, CHAR_ROW_RIGHT = 2;
|
|
60
|
+
// Sprite sheet columns: 1 is the standing frame (legs and feet), 3-6 are the
|
|
61
|
+
// seated frames (torso only, flat bottom) — the desk pass animates 3/4 as
|
|
62
|
+
// typing, and a sitter on any furniture uses 3 for the same reason: a standing
|
|
63
|
+
// frame on a chair reads as standing ON it.
|
|
64
|
+
const CHAR_COL_STAND = 1;
|
|
65
|
+
export const CHAR_COL_SIT = 3;
|
|
66
|
+
const CHAR_W = CHAR_FRAME_W * CHAR_SCALE, CHAR_H = CHAR_FRAME_H * CHAR_SCALE;
|
|
67
|
+
// The left-edge lane the walk in/out entrance and its strip occupy: furniture
|
|
68
|
+
// that can yield (spare desks, the conference set) keeps out of it.
|
|
69
|
+
const ENTRY_STRIP_W = Z + CHAR_W;
|
|
70
|
+
|
|
71
|
+
// Deterministic variant per agent id, stable across renders and reorders.
|
|
72
|
+
export function charVariant(id) {
|
|
73
|
+
let h = 0;
|
|
74
|
+
const s = String(id);
|
|
75
|
+
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0;
|
|
76
|
+
return Math.abs(h) % CHAR_VARIANTS;
|
|
77
|
+
}
|
|
78
|
+
// Desk sprite: monitor part is in top 12 rows — we skip it and draw our own
|
|
79
|
+
const DESK_CROP_Y = 13;
|
|
80
|
+
// Per-desk-variant positions (in sprite pixels)
|
|
81
|
+
const DESK_MON_X = 3; // Desk.png: monitor at x=3-16, center ~9
|
|
82
|
+
const DESK2_MON_X = 13; // Desk-2.png: monitor at x=13-26, center ~19
|
|
83
|
+
const DESK_CHAR_X = 5; // character X offset for Desk.png (centered on keyboard)
|
|
84
|
+
const DESK2_CHAR_X = 15; // character X offset for Desk-2.png
|
|
85
|
+
const DESK_CHAR_Y = 13; // character Y offset below the workstation top (both desks)
|
|
86
|
+
const SPRITES = {};
|
|
87
|
+
let spritesLoaded = false;
|
|
88
|
+
|
|
89
|
+
function loadSprites() {
|
|
90
|
+
if (spritesLoaded) return Promise.resolve();
|
|
91
|
+
const promises = Object.entries(SPRITE_PATHS).map(([key, path]) => {
|
|
92
|
+
return new Promise((resolve) => {
|
|
93
|
+
const img = new Image();
|
|
94
|
+
img.onload = () => { SPRITES[key] = img; resolve(); };
|
|
95
|
+
img.onerror = () => { console.warn(`office: sprite failed to load: ${path}`); resolve(); };
|
|
96
|
+
img.src = path;
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
return Promise.all(promises).then(() => { spritesLoaded = true; });
|
|
100
|
+
}
|
|
101
|
+
loadSprites();
|
|
102
|
+
|
|
103
|
+
function getThemeColors() {
|
|
104
|
+
const style = getComputedStyle(document.documentElement);
|
|
105
|
+
const get = (prop) => style.getPropertyValue(prop).trim();
|
|
106
|
+
const accent = get('--accent') || '#b8bfcc';
|
|
107
|
+
const bgOffice = get('--bg-office') || '#131519';
|
|
108
|
+
const bgDark = get('--bg-dark') || '#090a0c';
|
|
109
|
+
const border = get('--border') || '#1f2228';
|
|
110
|
+
const textDim = get('--text-dim') || '#6b7280';
|
|
111
|
+
const text = get('--text') || '#d8dce4';
|
|
112
|
+
const ar = parseInt(accent.slice(1, 3), 16);
|
|
113
|
+
const ag = parseInt(accent.slice(3, 5), 16);
|
|
114
|
+
const ab = parseInt(accent.slice(5, 7), 16);
|
|
115
|
+
const tr = parseInt(text.slice(1, 3), 16);
|
|
116
|
+
const tg = parseInt(text.slice(3, 5), 16);
|
|
117
|
+
const tb = parseInt(text.slice(5, 7), 16);
|
|
118
|
+
return { accent, text, bgOffice, bgDark, border, textDim, ar, ag, ab, tr, tg, tb };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// --- Day/night cycle ---
|
|
122
|
+
function getTimeOfDay() {
|
|
123
|
+
const h = new Date().getHours();
|
|
124
|
+
if (h >= 6 && h < 8) return 'dawn';
|
|
125
|
+
if (h >= 8 && h < 17) return 'day';
|
|
126
|
+
if (h >= 17 && h < 19) return 'dusk';
|
|
127
|
+
return 'night';
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function getWindowColors(tod) {
|
|
131
|
+
switch (tod) {
|
|
132
|
+
case 'dawn': return { glass1: '#5a4060', glass2: '#7a5080', beam: 'rgba(255, 200, 150, 0.06)', hasStars: false, hasClouds: false };
|
|
133
|
+
case 'day': return { glass1: '#7ab5e5', glass2: '#a0d0f5', beam: 'rgba(255, 240, 200, 0.08)', hasStars: false, hasClouds: true };
|
|
134
|
+
case 'dusk': return { glass1: '#5a3060', glass2: '#3a1a50', beam: 'rgba(255, 180, 100, 0.06)', hasStars: false, hasClouds: false };
|
|
135
|
+
default: return { glass1: '#1a2a4a', glass2: '#2a3a5a', beam: 'rgba(180, 200, 230, 0.06)', hasStars: true, hasClouds: false };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// --- Pod layout: per-repo desk clusters anchored near the top of the open
|
|
140
|
+
// floor (below the windows + job board zone). Agents on the same repo sit
|
|
141
|
+
// together in a pod on a shared rug; different repos are separated by extra
|
|
142
|
+
// gap. A single repo yields exactly the old uniform grid. ---
|
|
143
|
+
// The boards end well short of FLOOR_TOP (their shadows reach 8 Z below the
|
|
144
|
+
// baseboard); the rest is walking room before the first row of desks.
|
|
145
|
+
const FLOOR_TOP = WALL_BOTTOM + 26 * Z;
|
|
146
|
+
const POD_GAP_X = 20; // Z units between pods in a row (vs WS_GAP_X=12 within)
|
|
147
|
+
const POD_GAP_Y = 26; // Z units between pod rows (vs WS_GAP_Y=18 within)
|
|
148
|
+
const RUG_PAD_X = 4; // Z units the rug extends past the desks each side
|
|
149
|
+
const RUG_PAD_TOP = 7; // Z units above the desks — the repo label sits here
|
|
150
|
+
const RUG_PAD_BOTTOM = 12; // Z units below — covers the agent name labels
|
|
151
|
+
const POD_TOP_MARGIN = 8; // Z units of bare floor between FLOOR_TOP and the first rug
|
|
152
|
+
|
|
153
|
+
// Pure layout: agentInfos is [{ id, repoPath, slug, isBillion }] in spawn (map) order.
|
|
154
|
+
// Billion sits alone at the top center, on a row of its own above every pod.
|
|
155
|
+
// Returns { pods, positions } — positions maps id -> { x, y } (top-left px of
|
|
156
|
+
// its desk); pods carry the desk-block rect in px plus the repo label.
|
|
157
|
+
// Agents with no repo land in a final unlabeled pod. Within a pod agents keep
|
|
158
|
+
// their input order, so an unrelated spawn/exit never reshuffles desks inside
|
|
159
|
+
// a pod.
|
|
160
|
+
// Desks sit in a plain classroom grid, all facing front: every screen stays
|
|
161
|
+
// visible to the viewer and the character's facing reads as state (back to
|
|
162
|
+
// viewer = working). Agent i takes col i % cols, row floor(i / cols); the row
|
|
163
|
+
// pitch keeps a full aisle so a back row never occludes the one in front.
|
|
164
|
+
// The rug-padding allowance mirrors panelZ in computePodLayout, so a
|
|
165
|
+
// max-width pod's rug borders don't clip at the panel edges.
|
|
166
|
+
function maxColsFor(panelWidth) {
|
|
167
|
+
return Math.max(1, Math.min(4, Math.floor((panelWidth / Z - 2 * RUG_PAD_X + WS_GAP_X) / (WS_W + WS_GAP_X))));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function computePodLayout(agentInfos, panelWidth, panelHeight) {
|
|
171
|
+
const maxCols = maxColsFor(panelWidth);
|
|
172
|
+
|
|
173
|
+
// Group by repo, first-appearance order; no-repo pod goes last.
|
|
174
|
+
const BILLION = '\0billion'; // cannot collide with a repo path
|
|
175
|
+
const groups = new Map();
|
|
176
|
+
for (const a of agentInfos) {
|
|
177
|
+
const key = a.isBillion ? BILLION : (a.repoPath || '');
|
|
178
|
+
// filter(Boolean) survives trailing slashes; slice caps fillText re-shaping cost
|
|
179
|
+
if (!groups.has(key)) groups.set(key, { label: String(a.slug || (key ? key.split('/').filter(Boolean).pop() : '') || '').slice(0, 40) || null, ids: [] });
|
|
180
|
+
groups.get(key).ids.push(a.id);
|
|
181
|
+
}
|
|
182
|
+
const keys = [...groups.keys()].filter(k => k !== '' && k !== BILLION);
|
|
183
|
+
if (groups.has('')) keys.push('');
|
|
184
|
+
if (groups.has(BILLION)) keys.unshift(BILLION);
|
|
185
|
+
|
|
186
|
+
const pods = keys.map(key => {
|
|
187
|
+
const g = groups.get(key);
|
|
188
|
+
const cols = Math.min(g.ids.length, maxCols);
|
|
189
|
+
const rows = Math.ceil(g.ids.length / cols);
|
|
190
|
+
const own = key === BILLION;
|
|
191
|
+
return {
|
|
192
|
+
repoPath: own ? null : key || null, label: key && !own ? g.label : null, ids: g.ids, cols, own,
|
|
193
|
+
w: cols * WS_W + (cols - 1) * WS_GAP_X,
|
|
194
|
+
h: rows * WS_H + (rows - 1) * WS_GAP_Y,
|
|
195
|
+
};
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// Flow pods left-to-right, wrap when the row (rug padding included) would
|
|
199
|
+
// overflow the panel — without the padding allowance the outermost rug
|
|
200
|
+
// borders clip at the panel edges on an exactly-full row.
|
|
201
|
+
const panelZ = Math.floor(panelWidth / Z) - 2 * RUG_PAD_X;
|
|
202
|
+
const podRows = [[]];
|
|
203
|
+
let rowW = 0;
|
|
204
|
+
for (const pod of pods) {
|
|
205
|
+
const need = rowW === 0 ? pod.w : rowW + POD_GAP_X + pod.w;
|
|
206
|
+
const afterBillion = podRows[podRows.length - 1].some(p => p.own);
|
|
207
|
+
if (rowW > 0 && (need > panelZ || afterBillion)) { podRows.push([pod]); rowW = pod.w; }
|
|
208
|
+
else { podRows[podRows.length - 1].push(pod); rowW = need; }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Anchor the arrangement near the top of the floor: centering is capped so a
|
|
212
|
+
// sparse office leaves its empty wood at the bottom (decor lives there)
|
|
213
|
+
// instead of as a dead band under the job boards. A nearly-full floor still
|
|
214
|
+
// centers; never start above the floor.
|
|
215
|
+
const rowHs = podRows.map(r => Math.max(0, ...r.map(p => p.h)));
|
|
216
|
+
const totalH = (rowHs.reduce((a, b) => a + b, 0) + (podRows.length - 1) * POD_GAP_Y) * Z;
|
|
217
|
+
const centerOff = Math.floor((panelHeight - FLOOR_TOP - totalH) / 2);
|
|
218
|
+
let y = Math.max(FLOOR_TOP, FLOOR_TOP + Math.min((POD_TOP_MARGIN + RUG_PAD_TOP) * Z, centerOff));
|
|
219
|
+
|
|
220
|
+
const outPods = [];
|
|
221
|
+
const positions = new Map();
|
|
222
|
+
for (let ri = 0; ri < podRows.length; ri++) {
|
|
223
|
+
const rowWpx = (podRows[ri].reduce((a, p) => a + p.w, 0) + (podRows[ri].length - 1) * POD_GAP_X) * Z;
|
|
224
|
+
let x = Math.floor((panelWidth - rowWpx) / 2);
|
|
225
|
+
for (const pod of podRows[ri]) {
|
|
226
|
+
pod.ids.forEach((id, i) => {
|
|
227
|
+
positions.set(id, {
|
|
228
|
+
x: x + (i % pod.cols) * (WS_W + WS_GAP_X) * Z,
|
|
229
|
+
y: y + Math.floor(i / pod.cols) * (WS_H + WS_GAP_Y) * Z,
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
outPods.push({ repoPath: pod.repoPath, label: pod.label, x, y, w: pod.w * Z, h: pod.h * Z });
|
|
233
|
+
x += pod.w * Z + POD_GAP_X * Z;
|
|
234
|
+
}
|
|
235
|
+
y += (rowHs[ri] + POD_GAP_Y) * Z;
|
|
236
|
+
}
|
|
237
|
+
return { pods: outPods, positions };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// The rug drawn under a pod: desk block plus padding for the repo label above
|
|
241
|
+
// and the agent name labels below. Also the keep-out zone for ambient decor.
|
|
242
|
+
export function podRugRect(pod) {
|
|
243
|
+
return {
|
|
244
|
+
x: pod.x - RUG_PAD_X * Z,
|
|
245
|
+
y: pod.y - RUG_PAD_TOP * Z,
|
|
246
|
+
w: pod.w + 2 * RUG_PAD_X * Z,
|
|
247
|
+
h: pod.h + (RUG_PAD_TOP + RUG_PAD_BOTTOM) * Z,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// --- Ambient decor along the bottom of the floor: a chat area in each bottom
|
|
252
|
+
// corner with the two plants stacked between them as a divider. A spot is kept
|
|
253
|
+
// only if it clears every rug by a margin wide enough to also clear the
|
|
254
|
+
// monitor-glow halos (shadowBlur 15px < 6 Z), so the decor simply disappears
|
|
255
|
+
// as pod rows grow downward. Pure function of the rug rects + panel size, so
|
|
256
|
+
// placement is deterministic for a given agent set. ---
|
|
257
|
+
const DECOR_MARGIN = 6 * Z;
|
|
258
|
+
// True when rect c clears rect r by DECOR_MARGIN on some side.
|
|
259
|
+
const apart = (c, r) =>
|
|
260
|
+
c.x >= r.x + r.w + DECOR_MARGIN || c.x + c.w <= r.x - DECOR_MARGIN ||
|
|
261
|
+
c.y >= r.y + r.h + DECOR_MARGIN || c.y + c.h <= r.y - DECOR_MARGIN;
|
|
262
|
+
// All decor sizes below are in px: 16px-tile pixel-agents art drawn at
|
|
263
|
+
// DECOR_SCALE like the characters.
|
|
264
|
+
const DECOR_SCALE = CHAR_SCALE;
|
|
265
|
+
const SOFA_W = 16 * DECOR_SCALE, SOFA_H = 32 * DECOR_SCALE; // side view, as tall as the table
|
|
266
|
+
const TABLE_W = 32 * DECOR_SCALE, TABLE_H = 32 * DECOR_SCALE;
|
|
267
|
+
const SOFA_FRONT_H = 16 * DECOR_SCALE;
|
|
268
|
+
// Chat area: a pod-style rug holding a side sofa each side of the coffee
|
|
269
|
+
// table (the right one mirrored to face it) and a front-facing sofa above it.
|
|
270
|
+
const CHAT_PAD_X = 13, CHAT_PAD_TOP = 14, CHAT_PAD_BOTTOM = 12, CHAT_GAP = 8;
|
|
271
|
+
const CHAT_W = 2 * CHAT_PAD_X + 2 * SOFA_W + 2 * CHAT_GAP + TABLE_W; // 170
|
|
272
|
+
const CHAT_H = CHAT_PAD_TOP + SOFA_FRONT_H + CHAT_GAP + TABLE_H + CHAT_PAD_BOTTOM; // 130
|
|
273
|
+
const CHAT_MARGIN_X = 13 * Z; // chat areas sit in from the panel edges, leaving the entry corner clear
|
|
274
|
+
// How far the front sofa lifts its occupant. See chatSeats for why the two side
|
|
275
|
+
// sofas need none.
|
|
276
|
+
const CHAT_FRONT_SIT_RISE = 10;
|
|
277
|
+
// The divider plants between the chat areas draw smaller than the 16px decor
|
|
278
|
+
// grid so they read as a low hedge, not sentinels towering over the sofas.
|
|
279
|
+
const PLANT_MID_SCALE = 1.25;
|
|
280
|
+
const PLANT_MID_W = Math.round(16 * PLANT_MID_SCALE), PLANT_MID_H = Math.round(32 * PLANT_MID_SCALE);
|
|
281
|
+
const PLANT_MID_GAP = DECOR_MARGIN; // floor between the leafy plant's pot and the cactus
|
|
282
|
+
export function computeDecorPlacement(rugRects, panelWidth, panelHeight) {
|
|
283
|
+
const z = Z;
|
|
284
|
+
const midX = Math.floor((panelWidth - PLANT_MID_W) / 2);
|
|
285
|
+
const cactusY = panelHeight - PLANT_MID_H - 3 * z;
|
|
286
|
+
const candidates = [
|
|
287
|
+
{ kind: 'chat', x: CHAT_MARGIN_X, y: panelHeight - CHAT_H - 3 * z, w: CHAT_W, h: CHAT_H },
|
|
288
|
+
{ kind: 'chat', x: panelWidth - CHAT_W - CHAT_MARGIN_X, y: panelHeight - CHAT_H - 3 * z, w: CHAT_W, h: CHAT_H },
|
|
289
|
+
{ kind: 'cactus', x: midX, y: cactusY, w: PLANT_MID_W, h: PLANT_MID_H, scale: PLANT_MID_SCALE },
|
|
290
|
+
{ kind: 'plant2', x: midX, y: cactusY - PLANT_MID_GAP - PLANT_MID_H, w: PLANT_MID_W, h: PLANT_MID_H, scale: PLANT_MID_SCALE },
|
|
291
|
+
];
|
|
292
|
+
const placed = [];
|
|
293
|
+
for (const c of candidates) {
|
|
294
|
+
if (c.x >= 0 && c.y >= FLOOR_TOP && c.x + c.w <= panelWidth && c.y + c.h <= panelHeight &&
|
|
295
|
+
rugRects.every(r => apart(c, r)) && placed.every(p => apart(c, p))) {
|
|
296
|
+
placed.push(c);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return placed;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Sofa seats in a chat area, derived from the same math drawDecor lays the
|
|
303
|
+
// furniture out with: the front sofa above the coffee table (its sitter faces
|
|
304
|
+
// down over it, like the conference head seat) and a side sofa each side
|
|
305
|
+
// facing in. Each seat carries its own approach, because unlike the
|
|
306
|
+
// conference set these sit at the panel's bottom edge: the lane is the seat's
|
|
307
|
+
// own column and the crossing row is the one just above the rug, so wanderRoute
|
|
308
|
+
// needs nothing from the conference set to route here. Both are preferences —
|
|
309
|
+
// wanderRoute vets them through approachX and corridorY like any other seat's.
|
|
310
|
+
export function chatSeats(spot) {
|
|
311
|
+
const tx = spot.x + CHAT_PAD_X + SOFA_W + CHAT_GAP;
|
|
312
|
+
const ty = spot.y + CHAT_PAD_TOP + SOFA_FRONT_H + CHAT_GAP;
|
|
313
|
+
const sy = ty + TABLE_H - SOFA_H - DECOR_SCALE; // drawDecor's side-sofa floor line
|
|
314
|
+
// A whole character above the rug, not the conference set's 24px: a walker
|
|
315
|
+
// standing 24px up still overlaps by 40 of its 64, so the crossing leg would
|
|
316
|
+
// sweep the sofas it is heading for -- and the other chat area on the way.
|
|
317
|
+
// Just above the rug, the same 24px idiom the conference set uses; corridorY
|
|
318
|
+
// is what turns the preference into a row a walker actually fits in.
|
|
319
|
+
const yPref = spot.y - 24;
|
|
320
|
+
const seat = (x, y, row, mirror) =>
|
|
321
|
+
({ x, y, row, mirror, kind: 'chat', lane: x, yPref, col: CHAR_COL_SIT });
|
|
322
|
+
return [
|
|
323
|
+
// The front sofa lifts its occupant; the two side sofas do not, and that is
|
|
324
|
+
// deliberate: they are drawn at the same 2x as the character, so the seated
|
|
325
|
+
// art already lands on the cushion. The front sofa is only 16px of art tall,
|
|
326
|
+
// and the seated frame starts 4px lower in its box than the standing frame
|
|
327
|
+
// (bbox y7 vs y3), so without the lift the body fell past the sofa's front
|
|
328
|
+
// edge onto the coffee table and read as sitting on the floor.
|
|
329
|
+
seat(tx + Math.floor((TABLE_W - CHAR_W) / 2), spot.y + CHAT_PAD_TOP - 18 - CHAT_FRONT_SIT_RISE, CHAR_ROW_DOWN, false),
|
|
330
|
+
seat(spot.x + CHAT_PAD_X, sy - 6, CHAR_ROW_RIGHT, false),
|
|
331
|
+
seat(tx + TABLE_W + CHAT_GAP, sy - 6, CHAR_ROW_RIGHT, true),
|
|
332
|
+
];
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Every seat an idle agent can wander to, conference set and sofas pooled into
|
|
336
|
+
// one list. The set is null below ~972px of canvas height (the band between the
|
|
337
|
+
// desks and the chat areas is not tall enough), which is most laptop windows --
|
|
338
|
+
// the sofas are always there, so the wander works at any size.
|
|
339
|
+
export function wanderSeats(conf, decorSpots) {
|
|
340
|
+
return [
|
|
341
|
+
...(conf ? conf.seats.map((s, i) => ({ ...s, kind: 'conf', confIndex: i })) : []),
|
|
342
|
+
...decorSpots.filter(d => d.kind === 'chat').flatMap(chatSeats),
|
|
343
|
+
];
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// --- Conference table: a vertical boardroom table centred in the open band
|
|
347
|
+
// between the desks (rugs + spare desks) and the chat areas, with cushioned
|
|
348
|
+
// chairs down both sides and one at each end. Idle agents wander over and sit
|
|
349
|
+
// (see the wander section below). Pure function of the obstructions + panel
|
|
350
|
+
// size; null when the band is too small. ---
|
|
351
|
+
const CONF_TABLE_W = 48 * Z; // sprite width at the desk scale
|
|
352
|
+
const CONF_TABLE_H = 240; // the 48×64 sprite stretched taller so it reads as a boardroom table
|
|
353
|
+
const CONF_CHAIR = 16 * Z;
|
|
354
|
+
// The table sprite's top 11 of 64 rows are empty, so the tabletop's painted edge
|
|
355
|
+
// is this far below the set's y. The head chair sits against that edge, not
|
|
356
|
+
// against the sprite box, or it floats in the open floor above it.
|
|
357
|
+
const CONF_TABLE_ART_TOP = Math.round(11 / 64 * CONF_TABLE_H);
|
|
358
|
+
// Set bbox: side chairs overhang the table 42px each side; the head sitter's
|
|
359
|
+
// own 18px is the tallest thing above the set's y — the head chair below them
|
|
360
|
+
// clears the tabletop without reaching it — and the foot chair hangs 32px
|
|
361
|
+
// below.
|
|
362
|
+
const CONF_HEAD_RISE = 18;
|
|
363
|
+
// A side sitter draws the seated frame, which has no legs, and sits 14px above
|
|
364
|
+
// its chair's anchor. The lift goes into the SEAT, not into the draw call, on
|
|
365
|
+
// purpose: seat.y is where the character is, and drawConference, the
|
|
366
|
+
// setupOfficeClick hit box and wanderRoute's endpoint all read it, so moving it
|
|
367
|
+
// keeps them aligned. Offsetting only the draw would leave a clickable box
|
|
368
|
+
// where nobody is standing. (Forcing the rise to 0 changes no other test, so
|
|
369
|
+
// nothing behavioural rides on the exact value.) Why 14 and not the sofa's 10: the chairs draw at Z (3x) while characters draw at
|
|
370
|
+
// CHAR_SCALE (2x), so the chair's seat line sits high relative to the body.
|
|
371
|
+
// The head and foot seats keep the standing frame — the tabletop and the
|
|
372
|
+
// chair-back are already drawn over their legs.
|
|
373
|
+
const CONF_SIDE_SIT_RISE = 14;
|
|
374
|
+
const CONF_SET_W = CONF_TABLE_W + 2 * 42;
|
|
375
|
+
const CONF_SET_H = CONF_TABLE_H + CONF_HEAD_RISE + 32;
|
|
376
|
+
export function computeConference(rugRects, spareDesks, decorSpots, panelWidth, panelHeight) {
|
|
377
|
+
const bandTop = Math.max(FLOOR_TOP,
|
|
378
|
+
...rugRects.map(r => r.y + r.h),
|
|
379
|
+
...spareDesks.map(d => d.y + WS_H * Z));
|
|
380
|
+
// Every decor spot bounds the band, not just the chats: today the divider
|
|
381
|
+
// plants sit below the chat tops so this changes nothing, but if the chats
|
|
382
|
+
// ever yield while the plants place, the set must not land on the plants.
|
|
383
|
+
const bandBottom = Math.min(panelHeight - 3 * Z, ...decorSpots.map(s => s.y));
|
|
384
|
+
// The chair overhang must also clear the entry strip on the left.
|
|
385
|
+
if (panelWidth < CONF_SET_W + 2 * ENTRY_STRIP_W || bandBottom - bandTop < CONF_SET_H + 2 * DECOR_MARGIN) return null;
|
|
386
|
+
const tx = Math.floor((panelWidth - CONF_TABLE_W) / 2);
|
|
387
|
+
const ty = bandTop + Math.floor((bandBottom - bandTop - CONF_SET_H) / 2) + CONF_HEAD_RISE;
|
|
388
|
+
const chairs = [
|
|
389
|
+
{ x: tx - 42, y: ty + 70, kind: 'confchair' },
|
|
390
|
+
{ x: tx - 42, y: ty + 145, kind: 'confchair' },
|
|
391
|
+
{ x: tx + CONF_TABLE_W - 6, y: ty + 70, kind: 'confchair', mirror: true },
|
|
392
|
+
{ x: tx + CONF_TABLE_W - 6, y: ty + 145, kind: 'confchair', mirror: true },
|
|
393
|
+
{ x: tx + Math.floor((CONF_TABLE_W - CONF_CHAIR) / 2), y: ty + CONF_TABLE_H - 16, kind: 'confchairback' },
|
|
394
|
+
// Head chair, last so the indices above stay put. Its sitter faces us, so
|
|
395
|
+
// this is the chair's front, and it stands clear of the tabletop rather
|
|
396
|
+
// than tucking under it: the foot chair draws over the table and keeps its
|
|
397
|
+
// whole body, while this one draws behind it, so any overlap is swallowed
|
|
398
|
+
// and what is left reads as a stub instead of a chair. Bottom flush with
|
|
399
|
+
// the painted edge shows all of it and still sits at the table.
|
|
400
|
+
{ x: tx + Math.floor((CONF_TABLE_W - CONF_CHAIR) / 2), y: ty + CONF_TABLE_ART_TOP - CONF_CHAIR, kind: 'confchairfront' },
|
|
401
|
+
];
|
|
402
|
+
// Seats in fill order: head, then alternating sides top-down, foot last.
|
|
403
|
+
// Head and foot sit on the table's centreline; side sitters centre on their
|
|
404
|
+
// chairs. drawConference picks its draw passes by seat index (0 = head,
|
|
405
|
+
// 5 = foot), so this order is load-bearing.
|
|
406
|
+
const cx = tx + Math.floor((CONF_TABLE_W - CHAR_W) / 2);
|
|
407
|
+
const seats = [
|
|
408
|
+
{ x: cx, y: ty - 18, row: CHAR_ROW_DOWN },
|
|
409
|
+
{ x: chairs[0].x + 8, y: chairs[0].y - 12 - CONF_SIDE_SIT_RISE, row: CHAR_ROW_RIGHT, col: CHAR_COL_SIT },
|
|
410
|
+
{ x: chairs[2].x + 8, y: chairs[2].y - 12 - CONF_SIDE_SIT_RISE, row: CHAR_ROW_RIGHT, mirror: true, col: CHAR_COL_SIT },
|
|
411
|
+
{ x: chairs[1].x + 8, y: chairs[1].y - 12 - CONF_SIDE_SIT_RISE, row: CHAR_ROW_RIGHT, col: CHAR_COL_SIT },
|
|
412
|
+
{ x: chairs[3].x + 8, y: chairs[3].y - 12 - CONF_SIDE_SIT_RISE, row: CHAR_ROW_RIGHT, mirror: true, col: CHAR_COL_SIT },
|
|
413
|
+
{ x: cx, y: ty + CONF_TABLE_H - 38, row: CHAR_ROW_UP },
|
|
414
|
+
];
|
|
415
|
+
return { table: { x: tx, y: ty, w: CONF_TABLE_W, h: CONF_TABLE_H }, chairs, seats };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// --- Spare desks: when the office has a single row of real pods, one row of
|
|
419
|
+
// empty workstations sits on the pod grid one row-pitch below it, so a sparse
|
|
420
|
+
// office reads as unassigned seats rather than bare floor. Like the decor they
|
|
421
|
+
// vanish as the office fills: only while every pod is on one row and the spare
|
|
422
|
+
// row clears the placed decor and the panel bottom by DECOR_MARGIN. Pure;
|
|
423
|
+
// returns [{ x, y, variant }] top-left px per desk. ---
|
|
424
|
+
const SPARE_DESKS = 3;
|
|
425
|
+
export function computeSpareDesks(pods, decorSpots, panelWidth, panelHeight) {
|
|
426
|
+
if (!pods.length || pods.some(p => p.y !== pods[0].y || p.h !== WS_H * Z)) return [];
|
|
427
|
+
const pitch = (WS_W + WS_GAP_X) * Z;
|
|
428
|
+
const cols = Math.round((pods[0].w + WS_GAP_X * Z) / pitch);
|
|
429
|
+
// On the real row's column grid when it is one pod (centred on it, to the
|
|
430
|
+
// nearest column); a multi-pod row has no single grid, so centre on the panel.
|
|
431
|
+
// Fall back to centring when the aligned row would run off the panel. A row
|
|
432
|
+
// that would reach into the entry strip drops a desk until it clears.
|
|
433
|
+
let n = Math.min(SPARE_DESKS, maxColsFor(panelWidth)), x, rowW;
|
|
434
|
+
for (; n > 0; n--) {
|
|
435
|
+
rowW = (n * WS_W + (n - 1) * WS_GAP_X) * Z;
|
|
436
|
+
x = pods.length === 1 ? pods[0].x + Math.round((cols - n) / 2) * pitch : Math.floor((panelWidth - rowW) / 2);
|
|
437
|
+
if (x < 0 || x + rowW > panelWidth) x = Math.floor((panelWidth - rowW) / 2);
|
|
438
|
+
if (x >= ENTRY_STRIP_W) break;
|
|
439
|
+
}
|
|
440
|
+
if (n === 0) return [];
|
|
441
|
+
const row = { x, y: pods[0].y + (WS_H + WS_GAP_Y) * Z, w: rowW, h: WS_H * Z };
|
|
442
|
+
if (row.y + row.h + DECOR_MARGIN > panelHeight || !decorSpots.every(s => apart(row, s))) return [];
|
|
443
|
+
return Array.from({ length: n }, (_, i) => ({ x: row.x + i * pitch, y: row.y, variant: i % 2 }));
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// The live agents map (spawn/tab order) → pod layout for this panel size.
|
|
447
|
+
function computeOfficeLayout(panelWidth, panelHeight) {
|
|
448
|
+
const infos = [...agents].map(([id, a]) => ({ id, repoPath: a.repoPath, slug: a.repoSlug, isBillion: !!a.isBillion }));
|
|
449
|
+
return computePodLayout(infos, panelWidth, panelHeight);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// --- Wall layout: 2 windows divide wall into 3 equal bookshelf sections ---
|
|
453
|
+
function computeWallLayout(panelWidth) {
|
|
454
|
+
const totalW = Math.floor(panelWidth / Z);
|
|
455
|
+
const windowW = 28, windowH = 18, gap = 1;
|
|
456
|
+
const shelfW = Math.max(4, Math.floor((totalW - 2 * windowW - 4 * gap) / 3));
|
|
457
|
+
|
|
458
|
+
const shelf1X = 0;
|
|
459
|
+
const window1X = shelfW + gap;
|
|
460
|
+
const shelf2X = window1X + windowW + gap;
|
|
461
|
+
const window2X = shelf2X + shelfW + gap;
|
|
462
|
+
const shelf3X = window2X + windowW + gap;
|
|
463
|
+
|
|
464
|
+
return { totalW, shelfW, windowW, windowH, gap, window1X, window2X, shelf1X, shelf2X, shelf3X };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// --- Floor (warm wood planks — fixed palette, independent of UI theme) ---
|
|
468
|
+
function drawFloor(ctx, w, h) {
|
|
469
|
+
ctx.fillStyle = '#2a1e14';
|
|
470
|
+
ctx.fillRect(0, 0, w, h);
|
|
471
|
+
const plankH = 4 * Z;
|
|
472
|
+
// Fixed warm wood base: #4a3525 = rgb(74, 53, 37)
|
|
473
|
+
const br = 74, bg = 53, bb = 37;
|
|
474
|
+
const offsets = [32, 24, 30, 22, 28];
|
|
475
|
+
for (let py = WALL_BOTTOM; py < h; py += plankH) {
|
|
476
|
+
const ci = Math.floor((py - WALL_BOTTOM) / plankH) % offsets.length;
|
|
477
|
+
const o = offsets[ci];
|
|
478
|
+
ctx.fillStyle = `rgb(${Math.min(255, br + Math.round(o * 1.5))}, ${Math.min(255, bg + Math.round(o * 1.15))}, ${Math.min(255, bb + Math.round(o * 0.55))})`;
|
|
479
|
+
ctx.fillRect(0, py, w, plankH);
|
|
480
|
+
ctx.fillStyle = 'rgba(0,0,0,0.05)';
|
|
481
|
+
ctx.fillRect(0, py, w, 1);
|
|
482
|
+
const stagger = (ci % 2) * 18 * Z;
|
|
483
|
+
ctx.fillStyle = 'rgba(0,0,0,0.04)';
|
|
484
|
+
for (let px = stagger; px < w; px += 36 * Z) {
|
|
485
|
+
ctx.fillRect(px, py + 1, 1, plankH - 1);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// --- Wall texture (warm plaster — fixed palette, independent of UI theme) ---
|
|
491
|
+
function drawWalls(ctx, w) {
|
|
492
|
+
const wallH = WALL_H;
|
|
493
|
+
const topBand = Math.floor(wallH * 0.35);
|
|
494
|
+
const midBand = Math.floor(wallH * 0.35);
|
|
495
|
+
ctx.fillStyle = '#c8bfb0'; // warm cream plaster — top
|
|
496
|
+
ctx.fillRect(0, 0, w, topBand);
|
|
497
|
+
ctx.fillStyle = '#beb5a5'; // mid
|
|
498
|
+
ctx.fillRect(0, topBand, w, midBand);
|
|
499
|
+
ctx.fillStyle = '#b0a898'; // bottom
|
|
500
|
+
ctx.fillRect(0, topBand + midBand, w, wallH - topBand - midBand);
|
|
501
|
+
|
|
502
|
+
ctx.fillStyle = '#a89888'; // ceiling edge
|
|
503
|
+
ctx.fillRect(0, 0, w, 2 * Z);
|
|
504
|
+
|
|
505
|
+
const railY = Math.floor(wallH * 0.6);
|
|
506
|
+
ctx.fillStyle = '#d4c8b8'; // wainscoting rail
|
|
507
|
+
ctx.fillRect(0, railY, w, 2);
|
|
508
|
+
ctx.fillStyle = 'rgba(0,0,0,0.04)';
|
|
509
|
+
ctx.fillRect(0, railY + 2, w, wallH - railY - 2);
|
|
510
|
+
|
|
511
|
+
ctx.fillStyle = '#c0b090';
|
|
512
|
+
ctx.fillRect(0, wallH, w, Z);
|
|
513
|
+
ctx.fillStyle = '#a09070';
|
|
514
|
+
ctx.fillRect(0, wallH + Z, w, Z);
|
|
515
|
+
ctx.fillStyle = 'rgba(0,0,0,0.15)';
|
|
516
|
+
ctx.fillRect(0, wallH + 2 * Z, w, 1);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// --- Two windows (B) ---
|
|
520
|
+
function drawWindows(ctx, w, theme) {
|
|
521
|
+
const z = Z;
|
|
522
|
+
const layout = computeWallLayout(w);
|
|
523
|
+
const tod = getTimeOfDay();
|
|
524
|
+
const wc = getWindowColors(tod);
|
|
525
|
+
const ww = layout.windowW * z, wh = layout.windowH * z;
|
|
526
|
+
const wy = 3 * z; // 3Z from top
|
|
527
|
+
|
|
528
|
+
const windowXs = [layout.window1X * z, layout.window2X * z];
|
|
529
|
+
|
|
530
|
+
for (const wx of windowXs) {
|
|
531
|
+
// Sill
|
|
532
|
+
ctx.fillStyle = '#d4c8b0';
|
|
533
|
+
ctx.fillRect(wx - z, wy + wh, ww + 2 * z, z);
|
|
534
|
+
|
|
535
|
+
// Frame
|
|
536
|
+
ctx.fillStyle = '#d4c8b0';
|
|
537
|
+
ctx.fillRect(wx, wy, ww, wh);
|
|
538
|
+
|
|
539
|
+
// Glass
|
|
540
|
+
const glassX = wx + z, glassY = wy + z;
|
|
541
|
+
const glassW = ww - 2 * z, glassH = wh - 2 * z;
|
|
542
|
+
const grad = ctx.createLinearGradient(glassX, glassY, glassX, glassY + glassH);
|
|
543
|
+
grad.addColorStop(0, wc.glass1);
|
|
544
|
+
grad.addColorStop(1, wc.glass2);
|
|
545
|
+
ctx.fillStyle = grad;
|
|
546
|
+
ctx.fillRect(glassX, glassY, glassW, glassH);
|
|
547
|
+
|
|
548
|
+
// Stars (night)
|
|
549
|
+
if (wc.hasStars) {
|
|
550
|
+
ctx.fillStyle = 'rgba(255,255,255,0.8)';
|
|
551
|
+
ctx.fillRect(glassX + 2 * z, glassY + z, z, z);
|
|
552
|
+
ctx.fillRect(glassX + 6 * z, glassY + 2 * z, z, z);
|
|
553
|
+
ctx.fillRect(glassX + 10 * z, glassY + z, z, z);
|
|
554
|
+
ctx.fillStyle = 'rgba(255,255,255,0.5)';
|
|
555
|
+
ctx.fillRect(glassX + 4 * z, glassY + 3 * z, z, z);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// Clouds (day)
|
|
559
|
+
if (wc.hasClouds) {
|
|
560
|
+
ctx.fillStyle = 'rgba(255,255,255,0.35)';
|
|
561
|
+
ctx.fillRect(glassX + 2 * z, glassY + 3 * z, 3 * z, z);
|
|
562
|
+
ctx.fillRect(glassX + 3 * z, glassY + 2 * z, 2 * z, z);
|
|
563
|
+
ctx.fillRect(glassX + 8 * z, glassY + 4 * z, 3 * z, z);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// Cross-bars (3w × 2h = 6 pane)
|
|
567
|
+
ctx.fillStyle = '#d4c8b0';
|
|
568
|
+
const colW = Math.floor(glassW / 3);
|
|
569
|
+
ctx.fillRect(glassX + colW, glassY, 1, glassH); // vertical 1/3
|
|
570
|
+
ctx.fillRect(glassX + 2 * colW, glassY, 1, glassH); // vertical 2/3
|
|
571
|
+
ctx.fillRect(glassX, wy + Math.floor(wh / 2) - 1, glassW, 1); // horizontal center
|
|
572
|
+
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// --- Job boards (3 freestanding whiteboards on stands, replacing the old
|
|
577
|
+
// seating nooks). Drawn with primitives, not sprites: the boards stand on the
|
|
578
|
+
// floor in front of the wall, never hang on it. ---
|
|
579
|
+
const BOARD_W = 26; // Z units — outer frame width (capped to the wall section)
|
|
580
|
+
const BOARD_H = 19; // Z units — outer frame height
|
|
581
|
+
const BOARD_LEG_H = 9; // Z units — frame bottom down to the feet
|
|
582
|
+
const BOARD_FRAME = 1; // Z units — frame thickness around the writable surface
|
|
583
|
+
// Z units below the baseboard where the feet land: almost against the wall,
|
|
584
|
+
// level with the fronts of the wall plants' pots.
|
|
585
|
+
const BOARD_FOOT_OFFSET = 6;
|
|
586
|
+
const BOARD_MIN_W = 8; // Z units — below this the section is too narrow to draw into
|
|
587
|
+
const BOARD_BRACE_H = 3; // Z units — brace (and centre-post foot) above the feet
|
|
588
|
+
|
|
589
|
+
// The real board's columns: COLUMNS in jobs.js, upper-cased, which a test
|
|
590
|
+
// keeps in step with lib/jobs.js JOB_STATES. Not imported from jobs.js, which
|
|
591
|
+
// would drag the socket and auth modules into the canvas. 'IN PROGRESS' only
|
|
592
|
+
// fits a full-width board (panel >= 432px); narrower than that fillText's
|
|
593
|
+
// maxWidth condenses it, harder the narrower the panel gets.
|
|
594
|
+
export const BOARD_TITLES = ['TO DO', 'IN PROGRESS', 'REVIEW'];
|
|
595
|
+
// The job states behind each board, in the same order as BOARD_TITLES.
|
|
596
|
+
export const BOARD_STATES = ['todo', 'in-progress', 'review'];
|
|
597
|
+
const BOARD_TITLE_INK = ['#2f6fb0', '#b0392b', '#2f7a4a'];
|
|
598
|
+
const BOARD_TITLE_FONT = `bold ${3 * Z}px monospace`;
|
|
599
|
+
|
|
600
|
+
// Pin slots for job posts, filled left-to-right top-to-bottom, one per real
|
|
601
|
+
// job on that board's column. Positions are fractions of the writable surface
|
|
602
|
+
// so the layout survives the board being narrowed on a small panel. Slightly
|
|
603
|
+
// jittered so a full board reads as pinned paper, not a spreadsheet.
|
|
604
|
+
const POST_SLOTS = [
|
|
605
|
+
{ x: 0.05, y: 0.33, w: 0.28, h: 0.28, lines: 3 },
|
|
606
|
+
{ x: 0.37, y: 0.31, w: 0.27, h: 0.31, lines: 4 },
|
|
607
|
+
{ x: 0.68, y: 0.34, w: 0.27, h: 0.27, lines: 3 },
|
|
608
|
+
{ x: 0.06, y: 0.66, w: 0.27, h: 0.27, lines: 2 },
|
|
609
|
+
{ x: 0.36, y: 0.68, w: 0.28, h: 0.25, lines: 3 },
|
|
610
|
+
{ x: 0.67, y: 0.66, w: 0.27, h: 0.28, lines: 2 },
|
|
611
|
+
];
|
|
612
|
+
|
|
613
|
+
// How many posts each board shows for a given job count: one per job, capped
|
|
614
|
+
// at the slots that fit. The overflow beyond the cap is drawn as a "+N" note.
|
|
615
|
+
// Exported for the board-sync test.
|
|
616
|
+
export function boardPostPlan(count) {
|
|
617
|
+
const shown = Math.min(count, POST_SLOTS.length);
|
|
618
|
+
return { shown, overflow: count - shown };
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// Jobs per board column, in BOARD_STATES order. `done` jobs have left the
|
|
622
|
+
// board and are not counted. Exported for the board-sync test.
|
|
623
|
+
export function countBoardJobs(jobList) {
|
|
624
|
+
const counts = BOARD_STATES.map(() => 0);
|
|
625
|
+
for (const job of jobList) {
|
|
626
|
+
const i = BOARD_STATES.indexOf(job && job.state);
|
|
627
|
+
if (i >= 0) counts[i]++;
|
|
628
|
+
}
|
|
629
|
+
return counts;
|
|
630
|
+
}
|
|
631
|
+
const PAPER_TINTS = ['#f6f3ea', '#eef1f7', '#fbf6e4', '#f2f4ef'];
|
|
632
|
+
const PIN_COLORS = ['#c0392b', '#2980b9', '#27ae60', '#d4a847'];
|
|
633
|
+
|
|
634
|
+
// Geometry for the three boards, in screen pixels. Exported so the placement
|
|
635
|
+
// rules (inside its own wall section, almost against the wall, clear of the
|
|
636
|
+
// desk grid) can be asserted in tests without a canvas.
|
|
637
|
+
export function computeBoardLayout(panelWidth) {
|
|
638
|
+
const z = Z;
|
|
639
|
+
const layout = computeWallLayout(panelWidth);
|
|
640
|
+
|
|
641
|
+
// Cap the board to its wall section so it can never spill under a window. On
|
|
642
|
+
// a panel too narrow for even a stub board, draw nothing rather than overlap.
|
|
643
|
+
// Even Z-width keeps the frame's centre exactly on the stand's centre line.
|
|
644
|
+
const boardZ = Math.min(BOARD_W, layout.shelfW - 2) & ~1;
|
|
645
|
+
const boardW = boardZ * z;
|
|
646
|
+
const boardH = BOARD_H * z;
|
|
647
|
+
const footY = WALL_BOTTOM + BOARD_FOOT_OFFSET * z; // feet on the floor, just off the baseboard
|
|
648
|
+
const frameBottom = footY - BOARD_LEG_H * z;
|
|
649
|
+
const boardTop = frameBottom - boardH;
|
|
650
|
+
|
|
651
|
+
const sections = [layout.shelf1X, layout.shelf2X, layout.shelf3X].map((secX) => {
|
|
652
|
+
const centerX = Math.floor((secX + layout.shelfW / 2) * z);
|
|
653
|
+
return { centerX, left: secX * z, right: (secX + layout.shelfW) * z };
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
return {
|
|
657
|
+
visible: boardZ >= BOARD_MIN_W,
|
|
658
|
+
boardW, boardH, boardTop, frameBottom, footY, sections,
|
|
659
|
+
wallBottom: WALL_BOTTOM,
|
|
660
|
+
floorTop: FLOOR_TOP,
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function drawJobBoardEasels(ctx, w) {
|
|
665
|
+
const board = computeBoardLayout(w);
|
|
666
|
+
if (!board.visible) return;
|
|
667
|
+
|
|
668
|
+
const counts = countBoardJobs(jobs.values());
|
|
669
|
+
board.sections.forEach(({ centerX }, i) => {
|
|
670
|
+
drawBoardStand(ctx, centerX, board.frameBottom, board.footY, board.boardW);
|
|
671
|
+
drawWhiteboard(ctx, centerX - Math.floor(board.boardW / 2), board.boardTop, board.boardW, board.boardH, i, counts[i]);
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// A-frame metal stand: two splayed legs, a brace, and a contact shadow.
|
|
676
|
+
function drawBoardStand(ctx, centerX, topY, footY, boardW) {
|
|
677
|
+
const z = Z;
|
|
678
|
+
const legTop = Math.round(boardW * 0.18);
|
|
679
|
+
const legFoot = Math.round(boardW * 0.42);
|
|
680
|
+
const lw = Math.max(2, Math.round(z * 0.8));
|
|
681
|
+
|
|
682
|
+
// Contact shadow on the floor — stacked rects, not an ellipse: everything
|
|
683
|
+
// else in the room is hard-edged and a path fill would be antialiased.
|
|
684
|
+
ctx.fillStyle = 'rgba(0,0,0,0.13)';
|
|
685
|
+
ctx.fillRect(centerX - Math.round(boardW * 0.36), footY, Math.round(boardW * 0.72), z);
|
|
686
|
+
ctx.fillStyle = 'rgba(0,0,0,0.09)';
|
|
687
|
+
ctx.fillRect(centerX - Math.round(boardW * 0.22), footY + z, Math.round(boardW * 0.44), z);
|
|
688
|
+
|
|
689
|
+
const leg = (dir) => {
|
|
690
|
+
ctx.beginPath();
|
|
691
|
+
ctx.moveTo(centerX + dir * legTop - lw / 2, topY);
|
|
692
|
+
ctx.lineTo(centerX + dir * legTop + lw / 2, topY);
|
|
693
|
+
ctx.lineTo(centerX + dir * legFoot + lw / 2, footY);
|
|
694
|
+
ctx.lineTo(centerX + dir * legFoot - lw / 2, footY);
|
|
695
|
+
ctx.closePath();
|
|
696
|
+
ctx.fill();
|
|
697
|
+
};
|
|
698
|
+
|
|
699
|
+
ctx.fillStyle = '#9aa1ab';
|
|
700
|
+
leg(-1);
|
|
701
|
+
leg(1);
|
|
702
|
+
|
|
703
|
+
// Centre post from the frame down to the brace
|
|
704
|
+
ctx.fillRect(centerX - lw / 2, topY, lw, Math.max(0, footY - BOARD_BRACE_H * z - topY));
|
|
705
|
+
|
|
706
|
+
// Shaded inner edge of each leg
|
|
707
|
+
ctx.fillStyle = 'rgba(0,0,0,0.18)';
|
|
708
|
+
ctx.fillRect(centerX - legFoot - lw / 2, footY - z, lw, z);
|
|
709
|
+
ctx.fillRect(centerX + legFoot - lw / 2, footY - z, lw, z);
|
|
710
|
+
|
|
711
|
+
// Cross brace
|
|
712
|
+
const braceY = footY - BOARD_BRACE_H * z;
|
|
713
|
+
ctx.fillStyle = '#8b929c';
|
|
714
|
+
ctx.fillRect(centerX - Math.round(boardW * 0.34), braceY, Math.round(boardW * 0.68), Math.max(1, Math.round(z * 0.6)));
|
|
715
|
+
|
|
716
|
+
// Feet caps
|
|
717
|
+
ctx.fillStyle = '#5a6068';
|
|
718
|
+
ctx.fillRect(centerX - legFoot - lw, footY - 1, lw * 2, Math.max(1, Math.round(z * 0.7)));
|
|
719
|
+
ctx.fillRect(centerX + legFoot - lw, footY - 1, lw * 2, Math.max(1, Math.round(z * 0.7)));
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function drawWhiteboard(ctx, x, y, w, h, idx, jobCount) {
|
|
723
|
+
const z = Z;
|
|
724
|
+
const fb = BOARD_FRAME * z;
|
|
725
|
+
|
|
726
|
+
// Drop shadow against the wall
|
|
727
|
+
ctx.fillStyle = 'rgba(0,0,0,0.16)';
|
|
728
|
+
ctx.fillRect(x + z, y + z, w, h);
|
|
729
|
+
|
|
730
|
+
// Aluminium frame
|
|
731
|
+
ctx.fillStyle = '#b9c0c9';
|
|
732
|
+
ctx.fillRect(x, y, w, h);
|
|
733
|
+
ctx.fillStyle = '#d5dae1';
|
|
734
|
+
ctx.fillRect(x, y, w, Math.max(1, Math.round(fb / 2)));
|
|
735
|
+
ctx.fillStyle = '#8d949d';
|
|
736
|
+
ctx.fillRect(x, y + h - Math.max(1, Math.round(fb / 2)), w, Math.max(1, Math.round(fb / 2)));
|
|
737
|
+
|
|
738
|
+
// Writable surface
|
|
739
|
+
const sx = x + fb, sy = y + fb;
|
|
740
|
+
const sw = w - 2 * fb, sh = h - 2 * fb;
|
|
741
|
+
ctx.fillStyle = '#f7f8f5';
|
|
742
|
+
ctx.fillRect(sx, sy, sw, sh);
|
|
743
|
+
ctx.fillStyle = 'rgba(0,0,0,0.05)';
|
|
744
|
+
ctx.fillRect(sx, sy + sh - z, sw, z); // soft shading along the bottom
|
|
745
|
+
ctx.fillStyle = 'rgba(255,255,255,0.55)';
|
|
746
|
+
ctx.fillRect(sx, sy, sw, Math.max(1, Math.round(z / 2)));
|
|
747
|
+
|
|
748
|
+
// Marker-written heading
|
|
749
|
+
const title = BOARD_TITLES[idx % BOARD_TITLES.length];
|
|
750
|
+
ctx.save();
|
|
751
|
+
ctx.fillStyle = BOARD_TITLE_INK[idx % BOARD_TITLE_INK.length];
|
|
752
|
+
ctx.font = BOARD_TITLE_FONT;
|
|
753
|
+
ctx.textAlign = 'center';
|
|
754
|
+
const maxW = sw - 2 * z;
|
|
755
|
+
ctx.fillText(title, sx + sw / 2, sy + 3 * z, maxW);
|
|
756
|
+
// Underline as wide as the heading actually came out (condensed or not).
|
|
757
|
+
const titleW = Math.min(maxW, Math.round(ctx.measureText(title).width));
|
|
758
|
+
ctx.fillRect(Math.round(sx + (sw - titleW) / 2), sy + 4 * z, titleW, 1);
|
|
759
|
+
ctx.restore();
|
|
760
|
+
|
|
761
|
+
// Pinned job posts — one per real job in this board's column
|
|
762
|
+
const { shown, overflow } = boardPostPlan(jobCount);
|
|
763
|
+
for (let pi = 0; pi < shown; pi++) {
|
|
764
|
+
const post = POST_SLOTS[pi];
|
|
765
|
+
const px = Math.round(sx + post.x * sw);
|
|
766
|
+
const py = Math.round(sy + post.y * sh);
|
|
767
|
+
const pw = Math.max(4, Math.round(post.w * sw));
|
|
768
|
+
const ph = Math.max(4, Math.round(post.h * sh));
|
|
769
|
+
// Offset by board so the three boards don't repeat the same colour run
|
|
770
|
+
// (stride 3 stays coprime-ish with the 4-colour palettes; a stride of
|
|
771
|
+
// POST_SLOTS.length would give boards 0 and 2 identical runs).
|
|
772
|
+
drawJobPost(ctx, px, py, pw, ph, post.lines, idx * 3 + pi);
|
|
773
|
+
}
|
|
774
|
+
if (overflow > 0) {
|
|
775
|
+
// Marker note in the bottom-right corner for the jobs that don't fit,
|
|
776
|
+
// on a paper chip so it stays legible over the post pinned beneath it.
|
|
777
|
+
ctx.save();
|
|
778
|
+
ctx.font = BOARD_TITLE_FONT;
|
|
779
|
+
ctx.textAlign = 'right';
|
|
780
|
+
const label = `+${overflow}`;
|
|
781
|
+
const maxW = Math.round(sw / 2);
|
|
782
|
+
const labelW = Math.min(maxW, Math.ceil(ctx.measureText(label).width));
|
|
783
|
+
ctx.fillStyle = '#f7f8f5';
|
|
784
|
+
ctx.fillRect(sx + sw - labelW - 2 * z, sy + sh - 4 * z, labelW + 2 * z, 4 * z);
|
|
785
|
+
ctx.fillStyle = BOARD_TITLE_INK[idx % BOARD_TITLE_INK.length];
|
|
786
|
+
ctx.fillText(label, sx + sw - z, sy + sh - z, maxW);
|
|
787
|
+
ctx.restore();
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// Marker tray with two markers
|
|
791
|
+
const trayW = Math.round(w * 0.5);
|
|
792
|
+
const trayX = x + Math.round((w - trayW) / 2);
|
|
793
|
+
ctx.fillStyle = '#a7aeb8';
|
|
794
|
+
ctx.fillRect(trayX, y + h, trayW, z);
|
|
795
|
+
ctx.fillStyle = 'rgba(0,0,0,0.2)';
|
|
796
|
+
ctx.fillRect(trayX, y + h + z, trayW, 1);
|
|
797
|
+
ctx.fillStyle = '#c0392b';
|
|
798
|
+
ctx.fillRect(trayX + z, y + h, Math.round(trayW * 0.28), Math.max(1, Math.round(z * 0.7)));
|
|
799
|
+
ctx.fillStyle = '#2980b9';
|
|
800
|
+
ctx.fillRect(trayX + Math.round(trayW * 0.5), y + h, Math.round(trayW * 0.28), Math.max(1, Math.round(z * 0.7)));
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// One sheet of paper: drop shadow, tinted stock, ruled "text", and a pin.
|
|
804
|
+
function drawJobPost(ctx, x, y, w, h, lines, idx) {
|
|
805
|
+
const z = Z;
|
|
806
|
+
ctx.fillStyle = 'rgba(0,0,0,0.14)';
|
|
807
|
+
ctx.fillRect(x + 1, y + 1, w, h);
|
|
808
|
+
ctx.fillStyle = PAPER_TINTS[idx % PAPER_TINTS.length];
|
|
809
|
+
ctx.fillRect(x, y, w, h);
|
|
810
|
+
ctx.fillStyle = 'rgba(0,0,0,0.10)';
|
|
811
|
+
ctx.fillRect(x, y + h - 1, w, 1);
|
|
812
|
+
|
|
813
|
+
// Headline bar, then thinner body lines
|
|
814
|
+
const pad = Math.max(2, Math.round(w * 0.12));
|
|
815
|
+
let ly = y + Math.max(2, Math.round(h * 0.22));
|
|
816
|
+
ctx.fillStyle = 'rgba(45, 50, 58, 0.75)';
|
|
817
|
+
ctx.fillRect(x + pad, ly, Math.max(2, Math.round((w - 2 * pad) * 0.7)), 2);
|
|
818
|
+
|
|
819
|
+
ctx.fillStyle = 'rgba(70, 76, 86, 0.45)';
|
|
820
|
+
const gap = Math.max(2, Math.floor((h - (ly - y) - 4) / Math.max(1, lines - 1)));
|
|
821
|
+
for (let i = 1; i < lines; i++) {
|
|
822
|
+
ly += gap;
|
|
823
|
+
if (ly + 1 > y + h - 2) break;
|
|
824
|
+
const lw = Math.round((w - 2 * pad) * (i === lines - 1 ? 0.55 : 0.9));
|
|
825
|
+
ctx.fillRect(x + pad, ly, Math.max(2, lw), 1);
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// Push pin
|
|
829
|
+
ctx.fillStyle = PIN_COLORS[idx % PIN_COLORS.length];
|
|
830
|
+
const pinS = Math.max(2, Math.round(z * 0.7));
|
|
831
|
+
ctx.fillRect(x + Math.round(w / 2) - Math.round(pinS / 2), y - Math.round(pinS / 2), pinS, pinS);
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// Draw a sprite at an integer scale; no-op if its PNG failed to load.
|
|
835
|
+
// mirror flips it horizontally in place (same bounding box).
|
|
836
|
+
function drawSprite(ctx, key, x, y, scale = DECOR_SCALE, mirror = false) {
|
|
837
|
+
const img = SPRITES[key];
|
|
838
|
+
if (!img) return;
|
|
839
|
+
const w = img.naturalWidth * scale, h = img.naturalHeight * scale;
|
|
840
|
+
if (!mirror) { ctx.drawImage(img, x, y, w, h); return; }
|
|
841
|
+
ctx.save();
|
|
842
|
+
ctx.translate(x + w, y);
|
|
843
|
+
ctx.scale(-1, 1);
|
|
844
|
+
ctx.drawImage(img, 0, 0, w, h);
|
|
845
|
+
ctx.restore();
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// --- A low bookshelf run under each window: three short bookcase units
|
|
849
|
+
// tiled edge-to-edge, centred on the window and narrower than it, backs tight
|
|
850
|
+
// against the wall. The 16×16 frame's art spans x 2–14, so units step by the
|
|
851
|
+
// art width and the first is pulled left by the transparent margin. No
|
|
852
|
+
// boards (panel too narrow), no shelves. Pure; exported for the placement test. ---
|
|
853
|
+
const SHELF_SCALE = DECOR_SCALE;
|
|
854
|
+
const SHELF_UNITS = 3, SHELF_ART_X = 2, SHELF_ART_W = 12, SHELF_FRAME = 16;
|
|
855
|
+
const SHELF_UNIT_W = SHELF_ART_W * SHELF_SCALE, SHELF_H = SHELF_FRAME * SHELF_SCALE;
|
|
856
|
+
export function computeBookshelfRuns(panelWidth) {
|
|
857
|
+
if (!computeBoardLayout(panelWidth).visible) return [];
|
|
858
|
+
const layout = computeWallLayout(panelWidth);
|
|
859
|
+
const w = SHELF_UNITS * SHELF_UNIT_W;
|
|
860
|
+
return [layout.window1X, layout.window2X].map((wx) => ({
|
|
861
|
+
x: Math.floor((wx + layout.windowW / 2) * Z - w / 2),
|
|
862
|
+
y: WALL_BOTTOM + Z - SHELF_H,
|
|
863
|
+
w, h: SHELF_H,
|
|
864
|
+
}));
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
function drawBookshelves(ctx, w) {
|
|
868
|
+
if (!SPRITES.bookshelf) return; // no orphan shadows under a missing sprite
|
|
869
|
+
for (const run of computeBookshelfRuns(w)) {
|
|
870
|
+
ctx.fillStyle = 'rgba(0,0,0,0.18)';
|
|
871
|
+
ctx.fillRect(run.x, run.y + run.h, run.w, Z); // contact shadow under the feet
|
|
872
|
+
for (let i = 0; i < SHELF_UNITS; i++) {
|
|
873
|
+
drawSprite(ctx, 'bookshelf', run.x + i * SHELF_UNIT_W - SHELF_ART_X * SHELF_SCALE, run.y, SHELF_SCALE);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// Brand gold (#d4a847) as an rgb triple for rgba() tints on the canvas.
|
|
879
|
+
const GOLD_RGB = '212, 168, 71';
|
|
880
|
+
|
|
881
|
+
// --- Per-pod rug + repo label ---
|
|
882
|
+
// The rug is a fixed gold, independent of the UI theme like the wood floor,
|
|
883
|
+
// desks and sofa it sits among: the light-theme accent is nearly the same
|
|
884
|
+
// colour as the floor, so a theme-tinted rug vanished in light mode. The
|
|
885
|
+
// repo label keeps the theme text colour.
|
|
886
|
+
function drawPodRug(ctx, pod, theme) {
|
|
887
|
+
drawRug(ctx, podRugRect(pod), pod.label, theme);
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
function drawRug(ctx, r, label, theme) {
|
|
891
|
+
const z = Z;
|
|
892
|
+
|
|
893
|
+
ctx.fillStyle = `rgba(${GOLD_RGB}, 0.10)`;
|
|
894
|
+
roundRect(ctx, r.x, r.y, r.w, r.h, 3 * z);
|
|
895
|
+
ctx.fill();
|
|
896
|
+
ctx.strokeStyle = `rgba(${GOLD_RGB}, 0.32)`;
|
|
897
|
+
ctx.lineWidth = 1;
|
|
898
|
+
roundRect(ctx, r.x, r.y, r.w, r.h, 3 * z);
|
|
899
|
+
ctx.stroke();
|
|
900
|
+
// Inner border (double-border rug pattern)
|
|
901
|
+
ctx.strokeStyle = `rgba(${GOLD_RGB}, 0.16)`;
|
|
902
|
+
roundRect(ctx, r.x + 2 * z, r.y + 2 * z, r.w - 4 * z, r.h - 4 * z, 2 * z);
|
|
903
|
+
ctx.stroke();
|
|
904
|
+
|
|
905
|
+
if (label) {
|
|
906
|
+
// Styled like the agent name labels below the desks.
|
|
907
|
+
ctx.font = LABEL_FONT;
|
|
908
|
+
ctx.textAlign = 'center';
|
|
909
|
+
ctx.fillStyle = `rgba(${theme.tr}, ${theme.tg}, ${theme.tb}, 0.7)`;
|
|
910
|
+
ctx.fillText(label, r.x + r.w / 2, r.y + 5 * z, r.w - 4 * z);
|
|
911
|
+
ctx.textAlign = 'start';
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
function drawDecor(ctx, spots, theme) {
|
|
916
|
+
for (const spot of spots) {
|
|
917
|
+
if (spot.kind === 'chat') {
|
|
918
|
+
// Skip the whole group rather than draw a rug and pot around missing furniture.
|
|
919
|
+
if (!SPRITES.sofa || !SPRITES.sofafront || !SPRITES.table) continue;
|
|
920
|
+
drawRug(ctx, spot, null, theme);
|
|
921
|
+
const tx = spot.x + CHAT_PAD_X + SOFA_W + CHAT_GAP;
|
|
922
|
+
const ty = spot.y + CHAT_PAD_TOP + SOFA_FRONT_H + CHAT_GAP;
|
|
923
|
+
drawSprite(ctx, 'sofafront', tx, spot.y + CHAT_PAD_TOP);
|
|
924
|
+
// The side sofas' feet fill their last art row while the table's stop one
|
|
925
|
+
// row short, so lift them one art pixel to share the table's floor line.
|
|
926
|
+
const sy = ty + TABLE_H - SOFA_H - DECOR_SCALE;
|
|
927
|
+
drawSprite(ctx, 'sofa', spot.x + CHAT_PAD_X, sy);
|
|
928
|
+
drawSprite(ctx, 'table', tx, ty);
|
|
929
|
+
drawSprite(ctx, 'coffee', tx + 8 * DECOR_SCALE, ty + 7 * DECOR_SCALE);
|
|
930
|
+
drawSprite(ctx, 'sofa', tx + TABLE_W + CHAT_GAP, sy, DECOR_SCALE, true);
|
|
931
|
+
} else {
|
|
932
|
+
drawSprite(ctx, spot.kind, spot.x, spot.y, spot.scale || DECOR_SCALE); // 'plant2' or 'cactus'
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
// One character in a given sprite-sheet frame, facing a given row; mirror turns
|
|
938
|
+
// the right-facing row into a left-facing one (the sheet has no left row).
|
|
939
|
+
// Defaults to the standing column, which is what a seat without its own `col`
|
|
940
|
+
// (the conference head and foot) wants.
|
|
941
|
+
function drawCharFrame(ctx, variant, x, y, row, mirror, col = CHAR_COL_STAND) {
|
|
942
|
+
const sheet = SPRITES['char' + variant] || SPRITES.char0;
|
|
943
|
+
if (!sheet) return;
|
|
944
|
+
ctx.save();
|
|
945
|
+
if (mirror) { ctx.translate(x + CHAR_W, y); ctx.scale(-1, 1); x = 0; y = 0; }
|
|
946
|
+
ctx.drawImage(sheet, col * CHAR_FRAME_W, row * CHAR_FRAME_H, CHAR_FRAME_W, CHAR_FRAME_H, x, y, CHAR_W, CHAR_H);
|
|
947
|
+
ctx.restore();
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
// The conference set plus whoever is currently sitting at it. Sitters draw
|
|
951
|
+
// interleaved with the furniture so the head seat's feet tuck under the
|
|
952
|
+
// table's top rim, the head chair sits behind its sitter, and the foot chair's
|
|
953
|
+
// back covers its sitter's legs.
|
|
954
|
+
function drawConference(ctx, conf) {
|
|
955
|
+
// Furniture is skipped when its sprites failed to load (like drawDecor),
|
|
956
|
+
// but sitters ALWAYS draw: seats are claimed in updateWander regardless,
|
|
957
|
+
// and a missing chair PNG must never make a character invisible.
|
|
958
|
+
const furniture = SPRITES.conftable && SPRITES.confchair && SPRITES.confchairback && SPRITES.confchairfront;
|
|
959
|
+
// Keyed by CONFERENCE seat index, not the pooled one: a claim indexes
|
|
960
|
+
// currentSeats, and a sofa claim would otherwise alias onto a chair here.
|
|
961
|
+
const sitters = new Map(); // conf seat index -> variant, arrived sitters only
|
|
962
|
+
for (const [sid, i] of seatClaims) {
|
|
963
|
+
const seat = currentSeats[i];
|
|
964
|
+
if (seat?.kind === 'conf' && !wanderAnims.has(sid)) sitters.set(seat.confIndex, charVariant(sid));
|
|
965
|
+
}
|
|
966
|
+
const sitter = (i) => {
|
|
967
|
+
if (!sitters.has(i)) return;
|
|
968
|
+
const s = conf.seats[i];
|
|
969
|
+
// The seat carries its own frame: a side seat is lifted AND drawn seated,
|
|
970
|
+
// two halves of one decision, so they live together in computeConference.
|
|
971
|
+
// Head and foot carry none and fall through to standing — the tabletop
|
|
972
|
+
// covers one and the chair-back the other, and both compositions rely on it.
|
|
973
|
+
drawCharFrame(ctx, sitters.get(i), s.x, s.y, s.row, s.mirror, s.col);
|
|
974
|
+
};
|
|
975
|
+
if (furniture) for (const c of conf.chairs) if (c.kind === 'confchair') drawSprite(ctx, c.kind, c.x, c.y, Z, c.mirror);
|
|
976
|
+
// The head chair goes down before its sitter — they face us, so the chair is
|
|
977
|
+
// behind them — and the table then covers its lower half.
|
|
978
|
+
if (furniture) drawSprite(ctx, conf.chairs[5].kind, conf.chairs[5].x, conf.chairs[5].y, Z);
|
|
979
|
+
sitter(0); // head, before the table so their feet sit behind the top rim
|
|
980
|
+
if (furniture) {
|
|
981
|
+
const t = conf.table;
|
|
982
|
+
ctx.drawImage(SPRITES.conftable, t.x, t.y, t.w, t.h);
|
|
983
|
+
drawSprite(ctx, 'coffee', t.x + Math.floor(t.w / 2) - 12 * DECOR_SCALE, t.y + 50);
|
|
984
|
+
}
|
|
985
|
+
for (let i = 1; i <= 4; i++) sitter(i); // side sitters over their chairs
|
|
986
|
+
sitter(5); // foot, before the chair-back so it covers their legs
|
|
987
|
+
if (furniture) drawSprite(ctx, conf.chairs[4].kind, conf.chairs[4].x, conf.chairs[4].y, Z);
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
// Whoever has arrived at a sofa. Unlike drawConference this needs no
|
|
991
|
+
// interleaving with the furniture: the sofas went down in drawDecor and a
|
|
992
|
+
// sitter simply covers the one they are on. Sitters draw even when the sofa
|
|
993
|
+
// sprites failed to load, for the same reason the conference ones do.
|
|
994
|
+
function drawChatSitters(ctx) {
|
|
995
|
+
for (const [sid, i] of seatClaims) {
|
|
996
|
+
const seat = currentSeats[i];
|
|
997
|
+
if (!seat || seat.kind !== 'chat' || wanderAnims.has(sid)) continue;
|
|
998
|
+
drawCharFrame(ctx, charVariant(sid), seat.x, seat.y, seat.row, seat.mirror, seat.col);
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
// --- Ambient dust motes (E) ---
|
|
1003
|
+
function drawParticles(ctx, w, h) {
|
|
1004
|
+
if (prefersReducedMotion) return;
|
|
1005
|
+
const t = Date.now();
|
|
1006
|
+
for (const mote of DUST_MOTES) {
|
|
1007
|
+
const x = mote.baseX * w + Math.sin(t * 0.0003 + mote.phaseX) * 15;
|
|
1008
|
+
const y = WALL_BOTTOM * 0.5 + mote.baseY * (WALL_BOTTOM * 1.5) + Math.cos(t * 0.0005 + mote.phaseY) * 8;
|
|
1009
|
+
if (y > WALL_BOTTOM + 20 * Z) continue;
|
|
1010
|
+
const alpha = 0.06 + Math.sin(t * 0.001 + mote.phaseX) * 0.04;
|
|
1011
|
+
ctx.fillStyle = `rgba(255, 255, 255, ${alpha})`;
|
|
1012
|
+
ctx.fillRect(Math.floor(x), Math.floor(y), Z, Z);
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
// --- Desk items (mug, papers, lamp) ---
|
|
1017
|
+
function drawDeskItems(ctx, sx, sy) {
|
|
1018
|
+
const z = Z;
|
|
1019
|
+
const deskY = sy + 12 * z;
|
|
1020
|
+
|
|
1021
|
+
// Coffee mug
|
|
1022
|
+
const mugX = sx + 4 * z, mugY = deskY + z;
|
|
1023
|
+
ctx.fillStyle = '#c0a080';
|
|
1024
|
+
ctx.fillRect(mugX, mugY - z, 2 * z, 2 * z);
|
|
1025
|
+
ctx.fillStyle = '#d4b890';
|
|
1026
|
+
ctx.fillRect(mugX, mugY - z, 2 * z, z);
|
|
1027
|
+
ctx.fillStyle = '#3a2a1a';
|
|
1028
|
+
ctx.fillRect(mugX, mugY - z, z, z);
|
|
1029
|
+
|
|
1030
|
+
// Paper stack
|
|
1031
|
+
const papX = sx + 23 * z, papY = deskY;
|
|
1032
|
+
ctx.fillStyle = '#e8e4dc';
|
|
1033
|
+
ctx.fillRect(papX, papY, 4 * z, z);
|
|
1034
|
+
ctx.fillStyle = '#ddd8d0';
|
|
1035
|
+
ctx.fillRect(papX, papY + z, 4 * z, z);
|
|
1036
|
+
ctx.fillStyle = '#c44040';
|
|
1037
|
+
ctx.fillRect(papX + z, papY, 2 * z, z);
|
|
1038
|
+
|
|
1039
|
+
// Desk lamp
|
|
1040
|
+
const lampX = sx + z, lampY = deskY - 3 * z;
|
|
1041
|
+
ctx.fillStyle = '#808890';
|
|
1042
|
+
ctx.fillRect(lampX, deskY, 2 * z, z);
|
|
1043
|
+
ctx.fillStyle = '#909090';
|
|
1044
|
+
ctx.fillRect(lampX + z, lampY + z, z, deskY - lampY - z);
|
|
1045
|
+
ctx.fillStyle = '#d4c8b0';
|
|
1046
|
+
ctx.fillRect(lampX, lampY, 3 * z, z);
|
|
1047
|
+
ctx.fillStyle = 'rgba(255, 220, 180, 0.06)';
|
|
1048
|
+
ctx.fillRect(lampX - z, deskY - z, 5 * z, 2 * z);
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
// --- Sprite-based workstation (edgeless monitor + desk sprite bottom half) ---
|
|
1052
|
+
function drawWorkstation(ctx, sx, sy, state, theme, variant, agent) {
|
|
1053
|
+
const z = Z;
|
|
1054
|
+
const deskSprite = variant === 0 ? SPRITES.desk : SPRITES.desk2;
|
|
1055
|
+
|
|
1056
|
+
if (!deskSprite) {
|
|
1057
|
+
drawMonitor(ctx, sx + 9 * z, sy, state, theme);
|
|
1058
|
+
drawDesk(ctx, sx + z, sy + 12 * z);
|
|
1059
|
+
drawDeskItems(ctx, sx, sy);
|
|
1060
|
+
return;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
// --- Desk sprite (bottom half only — skip baked-in monitor) ---
|
|
1064
|
+
const cropH = 32 - DESK_CROP_Y; // sprite rows to draw
|
|
1065
|
+
ctx.drawImage(
|
|
1066
|
+
deskSprite,
|
|
1067
|
+
0, DESK_CROP_Y, 32, cropH, // source: skip top 12 rows
|
|
1068
|
+
sx, sy + DESK_CROP_Y * z, 32 * z, cropH * z // dest: position below monitor
|
|
1069
|
+
);
|
|
1070
|
+
|
|
1071
|
+
const monX = variant === 0 ? DESK_MON_X : DESK2_MON_X;
|
|
1072
|
+
|
|
1073
|
+
// --- Modern edgeless monitor (procedural, positioned where sprite monitor was) ---
|
|
1074
|
+
const mw = 14, mh = 9; // monitor outer size in Z-units
|
|
1075
|
+
const mx = sx + monX * z; // align with sprite's original monitor position
|
|
1076
|
+
const my = sy + 3 * z; // above desk surface
|
|
1077
|
+
|
|
1078
|
+
// Thin dark frame (1px bezel)
|
|
1079
|
+
ctx.fillStyle = '#1a1a1a';
|
|
1080
|
+
ctx.fillRect(mx, my, mw * z, mh * z);
|
|
1081
|
+
|
|
1082
|
+
// Screen (fills almost entire monitor — 1Z bezel all around)
|
|
1083
|
+
const scrX = mx + z, scrY = my + z;
|
|
1084
|
+
const scrW = (mw - 2) * z, scrH = (mh - 2) * z;
|
|
1085
|
+
|
|
1086
|
+
if (state === 'DISCONNECTED') {
|
|
1087
|
+
ctx.fillStyle = '#111';
|
|
1088
|
+
ctx.fillRect(scrX, scrY, scrW, scrH);
|
|
1089
|
+
} else if (state === 'WORKING') {
|
|
1090
|
+
ctx.fillStyle = '#0d1117';
|
|
1091
|
+
ctx.fillRect(scrX, scrY, scrW, scrH);
|
|
1092
|
+
const t = Math.floor(Date.now() / 400);
|
|
1093
|
+
const lc = [theme.accent, '#f0c040', '#4a90d9', '#e06c75', '#98c379'];
|
|
1094
|
+
for (let line = 0; line < Math.min(5, mh - 2); line++) {
|
|
1095
|
+
ctx.fillStyle = lc[(line + t) % lc.length] + '90';
|
|
1096
|
+
const lw = (2 + ((line + t) % 4)) * z;
|
|
1097
|
+
ctx.fillRect(scrX + z, scrY + line * z, Math.min(lw, scrW - z), z - 1);
|
|
1098
|
+
}
|
|
1099
|
+
ctx.shadowColor = `rgba(${theme.ar}, ${theme.ag}, ${theme.ab}, 0.35)`;
|
|
1100
|
+
ctx.shadowBlur = 10;
|
|
1101
|
+
ctx.fillStyle = `rgba(${theme.ar}, ${theme.ag}, ${theme.ab}, 0.03)`;
|
|
1102
|
+
ctx.fillRect(scrX, scrY, scrW, scrH);
|
|
1103
|
+
ctx.shadowBlur = 0;
|
|
1104
|
+
} else if (state === 'MESSAGE') {
|
|
1105
|
+
ctx.fillStyle = '#0d1117';
|
|
1106
|
+
ctx.fillRect(scrX, scrY, scrW, scrH);
|
|
1107
|
+
ctx.fillStyle = `rgba(${theme.ar}, ${theme.ag}, ${theme.ab}, 0.5)`;
|
|
1108
|
+
for (let line = 0; line < 3; line++) {
|
|
1109
|
+
const lw = (3 + (line % 3)) * z;
|
|
1110
|
+
ctx.fillRect(scrX + z, scrY + line * z, Math.min(lw, scrW - z), z - 1);
|
|
1111
|
+
}
|
|
1112
|
+
ctx.shadowColor = `rgba(${theme.ar}, ${theme.ag}, ${theme.ab}, 0.3)`;
|
|
1113
|
+
ctx.shadowBlur = 6;
|
|
1114
|
+
ctx.fillStyle = `rgba(${theme.ar}, ${theme.ag}, ${theme.ab}, 0.03)`;
|
|
1115
|
+
ctx.fillRect(scrX, scrY, scrW, scrH);
|
|
1116
|
+
ctx.shadowBlur = 0;
|
|
1117
|
+
} else if (state === 'WAITING') {
|
|
1118
|
+
ctx.fillStyle = '#0d1117';
|
|
1119
|
+
ctx.fillRect(scrX, scrY, scrW, scrH);
|
|
1120
|
+
const adds = agent && agent.additions || 0;
|
|
1121
|
+
const dels = agent && agent.removals || 0;
|
|
1122
|
+
if (adds > 0 || dels > 0) {
|
|
1123
|
+
// Show +additions / -removals
|
|
1124
|
+
ctx.font = `bold ${z * 2.5}px monospace`;
|
|
1125
|
+
ctx.textAlign = 'center';
|
|
1126
|
+
const centerX = scrX + scrW / 2;
|
|
1127
|
+
if (adds > 0) {
|
|
1128
|
+
ctx.fillStyle = '#3fb950';
|
|
1129
|
+
ctx.fillText(`+${adds}`, centerX, scrY + 3 * z);
|
|
1130
|
+
}
|
|
1131
|
+
if (dels > 0) {
|
|
1132
|
+
ctx.fillStyle = '#f85149';
|
|
1133
|
+
ctx.fillText(`-${dels}`, centerX, scrY + 5.5 * z);
|
|
1134
|
+
}
|
|
1135
|
+
ctx.textAlign = 'start';
|
|
1136
|
+
} else {
|
|
1137
|
+
// No changes: show prompt chevron with blinking cursor
|
|
1138
|
+
ctx.fillStyle = `rgba(${theme.ar}, ${theme.ag}, ${theme.ab}, 0.7)`;
|
|
1139
|
+
ctx.fillRect(scrX + z, scrY + 2 * z, z, z - 1);
|
|
1140
|
+
ctx.fillRect(scrX + 2 * z, scrY + 3 * z, z, z - 1);
|
|
1141
|
+
ctx.fillRect(scrX + z, scrY + 4 * z, z, z - 1);
|
|
1142
|
+
const blink = Math.floor(Date.now() / 600) % 2;
|
|
1143
|
+
if (blink) {
|
|
1144
|
+
ctx.fillStyle = theme.accent;
|
|
1145
|
+
ctx.fillRect(scrX + 4 * z, scrY + 3 * z, 2 * z, z);
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
ctx.shadowColor = `rgba(${theme.ar}, ${theme.ag}, ${theme.ab}, 0.3)`;
|
|
1149
|
+
ctx.shadowBlur = 8;
|
|
1150
|
+
ctx.fillStyle = `rgba(${theme.ar}, ${theme.ag}, ${theme.ab}, 0.03)`;
|
|
1151
|
+
ctx.fillRect(scrX, scrY, scrW, scrH);
|
|
1152
|
+
ctx.shadowBlur = 0;
|
|
1153
|
+
} else {
|
|
1154
|
+
ctx.fillStyle = '#0a0a0a';
|
|
1155
|
+
ctx.fillRect(scrX, scrY, scrW, scrH);
|
|
1156
|
+
const adds = agent && agent.additions || 0;
|
|
1157
|
+
const dels = agent && agent.removals || 0;
|
|
1158
|
+
if (adds > 0 || dels > 0) {
|
|
1159
|
+
// Show +additions / -removals as pixel text
|
|
1160
|
+
ctx.font = `bold ${z * 2.5}px monospace`;
|
|
1161
|
+
ctx.textAlign = 'center';
|
|
1162
|
+
const centerX = scrX + scrW / 2;
|
|
1163
|
+
if (adds > 0) {
|
|
1164
|
+
ctx.fillStyle = '#3fb950';
|
|
1165
|
+
ctx.fillText(`+${adds}`, centerX, scrY + 3 * z);
|
|
1166
|
+
}
|
|
1167
|
+
if (dels > 0) {
|
|
1168
|
+
ctx.fillStyle = '#f85149';
|
|
1169
|
+
ctx.fillText(`-${dels}`, centerX, scrY + 5.5 * z);
|
|
1170
|
+
}
|
|
1171
|
+
ctx.textAlign = 'start';
|
|
1172
|
+
} else {
|
|
1173
|
+
// No changes: dim dot
|
|
1174
|
+
const t = Date.now() / 2000;
|
|
1175
|
+
const dotX = scrX + z + Math.floor((Math.sin(t) * 0.5 + 0.5) * (scrW - 3 * z));
|
|
1176
|
+
const dotY = scrY + z + Math.floor((Math.cos(t * 0.7) * 0.5 + 0.5) * (scrH - 3 * z));
|
|
1177
|
+
ctx.fillStyle = `rgba(${theme.ar}, ${theme.ag}, ${theme.ab}, 0.3)`;
|
|
1178
|
+
ctx.fillRect(dotX, dotY, z, z);
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
// Monitor stand — thin neck + slim base
|
|
1183
|
+
const standX = mx + Math.floor(mw / 2) * z - z;
|
|
1184
|
+
ctx.fillStyle = '#2a2a2a';
|
|
1185
|
+
ctx.fillRect(standX, my + mh * z, 2 * z, z); // neck (1Z tall)
|
|
1186
|
+
ctx.fillStyle = '#222';
|
|
1187
|
+
ctx.fillRect(standX - z, my + mh * z + z, 4 * z, z); // base
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
// --- Monitor glow (A) ---
|
|
1191
|
+
function drawMonitorGlow(ctx, sx, sy, state, theme) {
|
|
1192
|
+
if (state === 'IDLE' || state === 'DISCONNECTED') return;
|
|
1193
|
+
const z = Z;
|
|
1194
|
+
const deskY = sy + 12 * z;
|
|
1195
|
+
const floorY = deskY + 10 * z;
|
|
1196
|
+
const glowX = sx + 5 * z;
|
|
1197
|
+
const glowW = 22 * z;
|
|
1198
|
+
|
|
1199
|
+
let glowR, glowG, glowB, deskAlpha, floorAlpha;
|
|
1200
|
+
if (state === 'WORKING') {
|
|
1201
|
+
glowR = theme.ar; glowG = theme.ag; glowB = theme.ab;
|
|
1202
|
+
const pulse = prefersReducedMotion ? 0 : Math.sin(Date.now() / 800) * 0.02;
|
|
1203
|
+
deskAlpha = 0.10 + pulse;
|
|
1204
|
+
floorAlpha = 0.04 + pulse * 0.5;
|
|
1205
|
+
} else if (state === 'MESSAGE') {
|
|
1206
|
+
glowR = theme.ar; glowG = theme.ag; glowB = theme.ab;
|
|
1207
|
+
deskAlpha = 0.08;
|
|
1208
|
+
floorAlpha = 0.03;
|
|
1209
|
+
} else {
|
|
1210
|
+
glowR = theme.ar; glowG = theme.ag; glowB = theme.ab;
|
|
1211
|
+
deskAlpha = 0.05;
|
|
1212
|
+
floorAlpha = 0.02;
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
ctx.shadowColor = `rgba(${glowR}, ${glowG}, ${glowB}, ${deskAlpha * 2})`;
|
|
1216
|
+
ctx.shadowBlur = 15;
|
|
1217
|
+
ctx.fillStyle = `rgba(${glowR}, ${glowG}, ${glowB}, ${deskAlpha})`;
|
|
1218
|
+
ctx.fillRect(glowX, deskY, glowW, 3 * z);
|
|
1219
|
+
ctx.shadowBlur = 0;
|
|
1220
|
+
|
|
1221
|
+
ctx.fillStyle = `rgba(${glowR}, ${glowG}, ${glowB}, ${floorAlpha})`;
|
|
1222
|
+
ctx.beginPath();
|
|
1223
|
+
ctx.ellipse(sx + 16 * z, floorY + 3 * z, glowW / 2.2, 4 * z, 0, 0, Math.PI * 2);
|
|
1224
|
+
ctx.fill();
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
function drawMonitor(ctx, x, y, state, theme) {
|
|
1228
|
+
const z = Z, mw = 14, mh = 10;
|
|
1229
|
+
ctx.fillStyle = '#c0c0c0';
|
|
1230
|
+
ctx.fillRect(x, y, mw * z, mh * z);
|
|
1231
|
+
const sx = x + z, sy = y + z, sw = (mw - 2) * z, sh = (mh - 2) * z;
|
|
1232
|
+
|
|
1233
|
+
if (state === 'DISCONNECTED') {
|
|
1234
|
+
ctx.fillStyle = '#111';
|
|
1235
|
+
ctx.fillRect(sx, sy, sw, sh);
|
|
1236
|
+
} else if (state === 'WORKING') {
|
|
1237
|
+
ctx.fillStyle = '#0d1117';
|
|
1238
|
+
ctx.fillRect(sx, sy, sw, sh);
|
|
1239
|
+
const t = Math.floor(Date.now() / 400);
|
|
1240
|
+
const lc = [theme.accent, '#f0c040', '#4a90d9', '#e06c75', '#98c379'];
|
|
1241
|
+
for (let line = 0; line < 4; line++) {
|
|
1242
|
+
ctx.fillStyle = lc[(line + t) % lc.length] + '90';
|
|
1243
|
+
const lw = (3 + ((line + t) % 5)) * z;
|
|
1244
|
+
ctx.fillRect(sx + z, sy + line * z, Math.min(lw, sw - z), z - 1);
|
|
1245
|
+
}
|
|
1246
|
+
ctx.shadowColor = `rgba(${theme.ar}, ${theme.ag}, ${theme.ab}, 0.35)`;
|
|
1247
|
+
ctx.shadowBlur = 10;
|
|
1248
|
+
ctx.fillStyle = `rgba(${theme.ar}, ${theme.ag}, ${theme.ab}, 0.03)`;
|
|
1249
|
+
ctx.fillRect(sx, sy, sw, sh);
|
|
1250
|
+
ctx.shadowBlur = 0;
|
|
1251
|
+
} else if (state === 'MESSAGE') {
|
|
1252
|
+
ctx.fillStyle = '#0d1117';
|
|
1253
|
+
ctx.fillRect(sx, sy, sw, sh);
|
|
1254
|
+
ctx.fillStyle = `rgba(${theme.ar}, ${theme.ag}, ${theme.ab}, 0.5)`;
|
|
1255
|
+
for (let line = 0; line < 3; line++) {
|
|
1256
|
+
const lw = (4 + (line % 3)) * z;
|
|
1257
|
+
ctx.fillRect(sx + z, sy + line * z, Math.min(lw, sw - z), z - 1);
|
|
1258
|
+
}
|
|
1259
|
+
ctx.shadowColor = `rgba(${theme.ar}, ${theme.ag}, ${theme.ab}, 0.3)`;
|
|
1260
|
+
ctx.shadowBlur = 6;
|
|
1261
|
+
ctx.fillStyle = `rgba(${theme.ar}, ${theme.ag}, ${theme.ab}, 0.03)`;
|
|
1262
|
+
ctx.fillRect(sx, sy, sw, sh);
|
|
1263
|
+
ctx.shadowBlur = 0;
|
|
1264
|
+
} else if (state === 'WAITING') {
|
|
1265
|
+
ctx.fillStyle = '#0d1117';
|
|
1266
|
+
ctx.fillRect(sx, sy, sw, sh);
|
|
1267
|
+
ctx.fillStyle = `rgba(${theme.ar}, ${theme.ag}, ${theme.ab}, 0.6)`;
|
|
1268
|
+
ctx.fillRect(sx + z, sy + z, 2 * z, z - 1);
|
|
1269
|
+
if (Math.floor(Date.now() / 500) % 2) {
|
|
1270
|
+
ctx.fillStyle = theme.accent;
|
|
1271
|
+
ctx.fillRect(sx + 4 * z, sy + z, z, z);
|
|
1272
|
+
}
|
|
1273
|
+
ctx.shadowColor = `rgba(${theme.ar}, ${theme.ag}, ${theme.ab}, 0.2)`;
|
|
1274
|
+
ctx.shadowBlur = 6;
|
|
1275
|
+
ctx.fillStyle = `rgba(${theme.ar}, ${theme.ag}, ${theme.ab}, 0.02)`;
|
|
1276
|
+
ctx.fillRect(sx, sy, sw, sh);
|
|
1277
|
+
ctx.shadowBlur = 0;
|
|
1278
|
+
} else {
|
|
1279
|
+
ctx.fillStyle = '#0a0a0a';
|
|
1280
|
+
ctx.fillRect(sx, sy, sw, sh);
|
|
1281
|
+
const t = Date.now() / 2000;
|
|
1282
|
+
const dotX = sx + z + Math.floor((Math.sin(t) * 0.5 + 0.5) * (sw - 3 * z));
|
|
1283
|
+
const dotY = sy + z + Math.floor((Math.cos(t * 0.7) * 0.5 + 0.5) * (sh - 3 * z));
|
|
1284
|
+
ctx.fillStyle = `rgba(${theme.ar}, ${theme.ag}, ${theme.ab}, 0.3)`;
|
|
1285
|
+
ctx.fillRect(dotX, dotY, z, z);
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
// Monitor stand
|
|
1289
|
+
ctx.fillStyle = '#aaa';
|
|
1290
|
+
ctx.fillRect(x + 5 * z, y + mh * z, 4 * z, 2 * z);
|
|
1291
|
+
ctx.fillRect(x + 4 * z, y + (mh + 2) * z, 6 * z, z);
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
function drawDesk(ctx, x, y) {
|
|
1295
|
+
const z = Z, dw = 30;
|
|
1296
|
+
ctx.fillStyle = '#7a6550';
|
|
1297
|
+
ctx.fillRect(x, y, dw * z, 3 * z);
|
|
1298
|
+
ctx.fillStyle = 'rgba(255,255,255,0.08)';
|
|
1299
|
+
ctx.fillRect(x + z, y + z, (dw - 2) * z, z);
|
|
1300
|
+
ctx.fillStyle = '#4a3828';
|
|
1301
|
+
ctx.fillRect(x, y, z, 3 * z);
|
|
1302
|
+
ctx.fillRect(x + (dw - 1) * z, y, z, 3 * z);
|
|
1303
|
+
ctx.fillStyle = '#5a4838';
|
|
1304
|
+
ctx.fillRect(x, y + 3 * z, dw * z, 4 * z);
|
|
1305
|
+
ctx.fillStyle = '#4a3828';
|
|
1306
|
+
ctx.fillRect(x, y + 3 * z, dw * z, z);
|
|
1307
|
+
ctx.fillStyle = '#8a7a68';
|
|
1308
|
+
ctx.fillRect(x + 4 * z, y + 5 * z, 3 * z, z);
|
|
1309
|
+
ctx.fillRect(x + (dw - 7) * z, y + 5 * z, 3 * z, z);
|
|
1310
|
+
ctx.fillStyle = '#4a3828';
|
|
1311
|
+
ctx.fillRect(x + z, y + 7 * z, 2 * z, 3 * z);
|
|
1312
|
+
ctx.fillRect(x + (dw - 3) * z, y + 7 * z, 2 * z, 3 * z);
|
|
1313
|
+
ctx.fillStyle = '#d0d0d0';
|
|
1314
|
+
ctx.fillRect(x + 11 * z, y + z, 8 * z, 2 * z);
|
|
1315
|
+
ctx.fillStyle = '#eee';
|
|
1316
|
+
for (let k = 0; k < 3; k++) {
|
|
1317
|
+
for (let r = 0; r < 2; r++) {
|
|
1318
|
+
ctx.fillRect(x + (12 + k * 2) * z, y + (z + r * z), z, z - 1);
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
|
|
1324
|
+
function drawMessageBubble(ctx, x, y, theme) {
|
|
1325
|
+
const z = Z;
|
|
1326
|
+
const bw = 10 * z, bh = 7 * z;
|
|
1327
|
+
ctx.fillStyle = 'rgba(255, 255, 255, 0.85)';
|
|
1328
|
+
roundRect(ctx, x, y, bw, bh, 4);
|
|
1329
|
+
ctx.fill();
|
|
1330
|
+
// Tail dots pointing down toward the character
|
|
1331
|
+
ctx.beginPath();
|
|
1332
|
+
ctx.arc(x + 2 * z, y + bh + 2 * z, z * 1.2, 0, Math.PI * 2);
|
|
1333
|
+
ctx.fill();
|
|
1334
|
+
ctx.beginPath();
|
|
1335
|
+
ctx.arc(x + z, y + bh + 4 * z, z * 0.8, 0, Math.PI * 2);
|
|
1336
|
+
ctx.fill();
|
|
1337
|
+
// Three dots in accent color
|
|
1338
|
+
ctx.fillStyle = theme.accent;
|
|
1339
|
+
for (let i = 0; i < 3; i++) {
|
|
1340
|
+
ctx.beginPath();
|
|
1341
|
+
ctx.arc(x + (3 + i * 2) * z, y + bh / 2, z, 0, Math.PI * 2);
|
|
1342
|
+
ctx.fill();
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
function drawWaitingBubble(ctx, x, y, theme) {
|
|
1347
|
+
const z = Z;
|
|
1348
|
+
const bw = 10 * z, bh = 8 * z;
|
|
1349
|
+
const pulse = 0.85 + Math.sin(Date.now() / 400) * 0.15;
|
|
1350
|
+
ctx.fillStyle = `rgba(255, 255, 255, ${pulse})`;
|
|
1351
|
+
roundRect(ctx, x, y, bw, bh, 4);
|
|
1352
|
+
ctx.fill();
|
|
1353
|
+
ctx.beginPath();
|
|
1354
|
+
ctx.arc(x + 2 * z, y + bh + 2 * z, z * 1.2, 0, Math.PI * 2);
|
|
1355
|
+
ctx.fill();
|
|
1356
|
+
ctx.beginPath();
|
|
1357
|
+
ctx.arc(x + z, y + bh + 4 * z, z * 0.8, 0, Math.PI * 2);
|
|
1358
|
+
ctx.fill();
|
|
1359
|
+
ctx.fillStyle = `rgba(${theme.ar}, ${theme.ag}, ${theme.ab}, ${pulse})`;
|
|
1360
|
+
ctx.font = `bold ${z * 5}px monospace`;
|
|
1361
|
+
ctx.textAlign = 'center';
|
|
1362
|
+
ctx.fillText('?', x + bw / 2, y + bh - 2 * z);
|
|
1363
|
+
ctx.textAlign = 'start';
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
function drawChangeBadge(ctx, x, y, count, color) {
|
|
1367
|
+
if (count <= 0) return;
|
|
1368
|
+
const z = Z;
|
|
1369
|
+
const text = String(count);
|
|
1370
|
+
const bw = Math.max(4, text.length * 3 + 2) * z;
|
|
1371
|
+
const bh = 4 * z;
|
|
1372
|
+
const bx = x + WS_W * z - bw - z;
|
|
1373
|
+
const by = y;
|
|
1374
|
+
ctx.fillStyle = color;
|
|
1375
|
+
roundRect(ctx, bx, by, bw, bh, 3);
|
|
1376
|
+
ctx.fill();
|
|
1377
|
+
ctx.fillStyle = '#fff';
|
|
1378
|
+
ctx.font = `bold ${z * 3}px monospace`;
|
|
1379
|
+
ctx.textAlign = 'center';
|
|
1380
|
+
ctx.fillText(text, bx + bw / 2, by + bh - z);
|
|
1381
|
+
ctx.textAlign = 'start';
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
function drawConflictBadge(ctx, x, y) {
|
|
1385
|
+
const z = Z;
|
|
1386
|
+
const bx = x + WS_W * z - 5 * z;
|
|
1387
|
+
const by = y + 5 * z;
|
|
1388
|
+
ctx.fillStyle = '#f0c040';
|
|
1389
|
+
ctx.font = `bold ${z * 4}px monospace`;
|
|
1390
|
+
ctx.fillText('\u26a0', bx, by + 3 * z);
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
function roundRect(ctx, x, y, w, h, r) {
|
|
1394
|
+
ctx.beginPath();
|
|
1395
|
+
ctx.moveTo(x + r, y);
|
|
1396
|
+
ctx.lineTo(x + w - r, y);
|
|
1397
|
+
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
|
|
1398
|
+
ctx.lineTo(x + w, y + h - r);
|
|
1399
|
+
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
|
|
1400
|
+
ctx.lineTo(x + r, y + h);
|
|
1401
|
+
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
|
|
1402
|
+
ctx.lineTo(x, y + r);
|
|
1403
|
+
ctx.quadraticCurveTo(x, y, x + r, y);
|
|
1404
|
+
ctx.closePath();
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
// --- Transient motion: dispatch papers and walk in/out ---
|
|
1408
|
+
// Purely client-side, time-based state. Every animation self-expires on its
|
|
1409
|
+
// start timestamp, so a backgrounded tab (where rAF pauses) wakes up to find
|
|
1410
|
+
// them finished rather than replaying a queued burst.
|
|
1411
|
+
const WALK_SPEED = 130; // px/s along the walk path
|
|
1412
|
+
// Mirrors STALLED_AFTER_MS in lib/jobs.js, like public/modules/jobs.js does.
|
|
1413
|
+
const STALLED_AFTER_MS = 3 * 60 * 1000;
|
|
1414
|
+
const WALK_FRAME_MS = 150; // walk-cycle frame time
|
|
1415
|
+
const PAPER_MS = 900; // paper flight time
|
|
1416
|
+
const PAPER_FADE_MS = 250; // fade-out after landing
|
|
1417
|
+
|
|
1418
|
+
// null until the first jobs-list, which the server sends last in the initial
|
|
1419
|
+
// connect sync — session-created replays before it are pre-existing agents
|
|
1420
|
+
// and must render seated, not walk in.
|
|
1421
|
+
let prevJobStates = null; // jobId -> { state, agentSessionId }
|
|
1422
|
+
const paperAnims = []; // { sessionId, col, start }
|
|
1423
|
+
const walkAnims = new Map(); // sessionId -> { dir: 'in'|'out', start, queuedAt, fromX, fromY, variant }
|
|
1424
|
+
|
|
1425
|
+
// --- Idle wander: an agent at rest (IDLE, or WAITING — which is where every
|
|
1426
|
+
// TUI agent actually sits) leaves its desk, walks to a free seat and sits there
|
|
1427
|
+
// until its state changes, then walks back. Seats come from wanderSeats: the
|
|
1428
|
+
// conference set when it places, plus the chat-area sofas, which are the seats
|
|
1429
|
+
// that exist at every canvas size. Client-side and time-based like the walk
|
|
1430
|
+
// in/out anims. The claim is held from the moment the walk starts; the
|
|
1431
|
+
// character renders seated once its wander anim finishes. Claims clear whenever
|
|
1432
|
+
// the pool changes shape — characters simply render at their desks again.
|
|
1433
|
+
const seatClaims = new Map(); // sessionId -> index into currentSeats (NOT conf.seats)
|
|
1434
|
+
const wanderAnims = new Map(); // sessionId -> { dir: 'seat'|'desk', start, variant, fromX, fromY }
|
|
1435
|
+
let currentConf = null; // this frame's computeConference result, for drawMotion + departures
|
|
1436
|
+
let currentSeats = []; // this frame's wanderSeats() pool, what a claim indexes
|
|
1437
|
+
// The pool's shape, to drop stale claims when it changes. Length alone is not
|
|
1438
|
+
// the shape: a conference set with both chat areas suppressed is 6 seats, and
|
|
1439
|
+
// no conference set with both chat areas placed is also 6 — crossing between
|
|
1440
|
+
// them would leave every claim pointing at unrelated furniture. Counting the
|
|
1441
|
+
// conference seats separates them without depending on the set being exactly
|
|
1442
|
+
// six, which is not this function's invariant to rely on.
|
|
1443
|
+
let lastSeatCount = 0;
|
|
1444
|
+
let lastConfCount = -1;
|
|
1445
|
+
let currentObstacles = []; // this frame's furniture rects, for the walk routes
|
|
1446
|
+
|
|
1447
|
+
function updateWander(seats) {
|
|
1448
|
+
// Seat indices address this list, so a change to its shape (the conference
|
|
1449
|
+
// set placing or vanishing on resize) invalidates every claim in flight.
|
|
1450
|
+
let confN = 0;
|
|
1451
|
+
while (confN < seats.length && seats[confN].kind === 'conf') confN++;
|
|
1452
|
+
if (seats.length !== lastSeatCount || confN !== lastConfCount) {
|
|
1453
|
+
seatClaims.clear(); wanderAnims.clear();
|
|
1454
|
+
lastSeatCount = seats.length; lastConfCount = confN;
|
|
1455
|
+
}
|
|
1456
|
+
if (!seats.length) return;
|
|
1457
|
+
const taken = new Set(seatClaims.values());
|
|
1458
|
+
for (const [sid, agent] of agents) {
|
|
1459
|
+
// Read-only colleagues keep their desks: the desk pass carries their
|
|
1460
|
+
// dimming and owner label, which the conference pass has no room for.
|
|
1461
|
+
// WAITING, not just IDLE: every TUI agent (claude, aider, codex, gemini)
|
|
1462
|
+
// short-circuits to WAITING in detectState, so IDLE is unreachable for the
|
|
1463
|
+
// agents this board actually spawns and the wander never fired for them.
|
|
1464
|
+
// Quiet for a while, not merely quiet: detectState flips WORKING -> the
|
|
1465
|
+
// rest state after 3s of silence, but a walk to a seat takes 7-11s at
|
|
1466
|
+
// WALK_SPEED, so an agent that just paused would start a walk, get output,
|
|
1467
|
+
// and teleport back to its desk -- jittering on a 3s cycle and never
|
|
1468
|
+
// arriving. The window covers IDLE as well as WAITING: which of the two a
|
|
1469
|
+
// quiet agent reads as is a property of its command, not of how long it
|
|
1470
|
+
// has been resting. A plain shell (not a TUI, prompt not matching
|
|
1471
|
+
// PROMPT_PATTERNS) lands on IDLE, so guarding WAITING alone left every
|
|
1472
|
+
// bash terminal pacing between its desk and the sofas.
|
|
1473
|
+
// Same window the board already uses to call a quiet WAITING "stalled".
|
|
1474
|
+
const resting = (agent.state === 'IDLE' || agent.state === 'WAITING')
|
|
1475
|
+
&& Date.now() - (agent.lastOutputAt || 0) > STALLED_AFTER_MS;
|
|
1476
|
+
const idle = resting && canControlAgent(agent);
|
|
1477
|
+
if (idle && !seatClaims.has(sid) && !wanderAnims.has(sid) && !walkAnims.has(sid)) {
|
|
1478
|
+
if (taken.size >= seats.length) continue; // every seat taken — stay at the desk
|
|
1479
|
+
const seat = seats.findIndex((_, i) => !taken.has(i));
|
|
1480
|
+
if (seat === -1) continue;
|
|
1481
|
+
seatClaims.set(sid, seat);
|
|
1482
|
+
taken.add(seat);
|
|
1483
|
+
// No walk during the connect replay (prevJobStates === null): like the
|
|
1484
|
+
// walk-ins, pre-existing idle agents render seated, not mid-commute.
|
|
1485
|
+
if (motionEnabled() && prevJobStates !== null) {
|
|
1486
|
+
wanderAnims.set(sid, { dir: 'seat', start: performance.now(), variant: charVariant(sid) });
|
|
1487
|
+
}
|
|
1488
|
+
} else if (!idle && seatClaims.has(sid)) {
|
|
1489
|
+
const seatPos = seats[seatClaims.get(sid)];
|
|
1490
|
+
const midWalk = wanderAnims.get(sid)?.dir === 'seat';
|
|
1491
|
+
seatClaims.delete(sid);
|
|
1492
|
+
wanderAnims.delete(sid);
|
|
1493
|
+
// Fully seated: walk back to the desk. Still walking over: snap back
|
|
1494
|
+
// rather than teleport to the seat first.
|
|
1495
|
+
if (!midWalk && motionEnabled()) {
|
|
1496
|
+
wanderAnims.set(sid, { dir: 'desk', start: performance.now(), variant: charVariant(sid), fromX: seatPos.x, fromY: seatPos.y, fromRow: seatPos.row, fromLane: seatPos.lane, fromYPref: seatPos.yPref });
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
// Deleting the current entry during Map iteration is well-defined.
|
|
1501
|
+
for (const sid of seatClaims.keys()) if (!agents.has(sid)) seatClaims.delete(sid);
|
|
1502
|
+
for (const sid of wanderAnims.keys()) if (!agents.has(sid)) wanderAnims.delete(sid);
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
function motionEnabled() {
|
|
1506
|
+
// Live query (not the module-load const): flipping the OS reduce-motion
|
|
1507
|
+
// setting mid-session should affect the next animation, not the next reload.
|
|
1508
|
+
const reduced = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches;
|
|
1509
|
+
return !reduced && !document.hidden;
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
export function hasMotion() {
|
|
1513
|
+
return paperAnims.length > 0 || walkAnims.size > 0 || wanderAnims.size > 0;
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
// Entrance: the left edge of the floor. There is no door — the walker appears
|
|
1517
|
+
// at whatever height its corridor row runs at (entryRoute), so the entrance
|
|
1518
|
+
// moves with the route instead of the route bending to reach a fixed point.
|
|
1519
|
+
const ENTRY_X = Z;
|
|
1520
|
+
|
|
1521
|
+
// Multi-leg route between a desk and a conference seat that stays off the
|
|
1522
|
+
// furniture: down the desk column to the corridor row above the set, across,
|
|
1523
|
+
// then down the seat's own clear column (side and head columns are beside or
|
|
1524
|
+
// above the table; the foot seat detours via the left lane, its centre column
|
|
1525
|
+
// being the table itself, and only brushes the table's leg row on the last
|
|
1526
|
+
// step). Both verticals run down approachX's clear column and the crossing
|
|
1527
|
+
// runs along corridorY's clear row, like the walk in/out legs. Reversed for
|
|
1528
|
+
// the walk back.
|
|
1529
|
+
// The vertical lanes just outside the chair columns — descending a seat's
|
|
1530
|
+
// own column would clip through the chair above it, so side and foot seats
|
|
1531
|
+
// are approached down the nearest outer lane instead. Clamped for the
|
|
1532
|
+
// narrowest panel the set can place on.
|
|
1533
|
+
function confLanes(conf) {
|
|
1534
|
+
return { left: Math.max(2, conf.table.x - 78), right: conf.table.x + conf.table.w + 46 };
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
export function wanderRoute(desk, seat, conf, toSeat, obstacles = [], panelWidth = Infinity, panelHeight = Infinity) {
|
|
1538
|
+
// Where the crossing wants to run and which column it comes down: a sofa
|
|
1539
|
+
// seat carries both (it sits at the panel's bottom edge, nowhere near the
|
|
1540
|
+
// conference set), a conference seat derives them from the table. Only the
|
|
1541
|
+
// head seat (the sole down-facing one) prefers its own column — it sits
|
|
1542
|
+
// above the table. Both are preferences; the vetting below is what decides.
|
|
1543
|
+
// conf-guarded: drawMotion no longer bails on a null currentConf, and a
|
|
1544
|
+
// walk-back anim from a conference seat carries no yPref of its own.
|
|
1545
|
+
const yPref = seat.yPref ?? (conf ? conf.table.y - 24 : 0);
|
|
1546
|
+
const lane = seat.lane ?? (!conf || seat.row === CHAR_ROW_DOWN ? seat.x
|
|
1547
|
+
: seat.x < conf.table.x + conf.table.w / 2 ? confLanes(conf).left : confLanes(conf).right);
|
|
1548
|
+
// The row just above the furniture, but a row a walker can stand in: the
|
|
1549
|
+
// crossing leg runs its whole width, so it takes the same scan the walk
|
|
1550
|
+
// in/out corridor does rather than a fixed offset off the tabletop.
|
|
1551
|
+
const yMid = corridorY(obstacles, panelHeight, yPref);
|
|
1552
|
+
// The lane knows only the furniture's geometry, so it can point straight at
|
|
1553
|
+
// whatever else stands between it and the corridor. approachX vets it from
|
|
1554
|
+
// the lane rather than from the seat — measured from the seat the furniture
|
|
1555
|
+
// is the walker's own floor and every column reads clear — and steps wider
|
|
1556
|
+
// when the lane's column is not.
|
|
1557
|
+
const laneX = approachX(obstacles, { x: lane, y: seat.y }, yMid, panelWidth, desk.x);
|
|
1558
|
+
const deskX = approachX(obstacles, desk, yMid, panelWidth, laneX);
|
|
1559
|
+
const pts = [desk, { x: deskX, y: desk.y }, { x: deskX, y: yMid }, { x: laneX, y: yMid }];
|
|
1560
|
+
if (laneX !== seat.x) pts.push({ x: laneX, y: seat.y });
|
|
1561
|
+
pts.push(seat);
|
|
1562
|
+
if (!toSeat) pts.reverse();
|
|
1563
|
+
return pathThrough(pts);
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
// Every rect a walk route must not cross, in one list: pod rugs, spare desks,
|
|
1567
|
+
// decor (chat areas + plants) and the conference set with its chair overhang.
|
|
1568
|
+
// Occupied desks go in too, though they sit inside a rug that already rules
|
|
1569
|
+
// their rows out: the rug is the walker's OWN floor on the leg along its desk
|
|
1570
|
+
// row, and without the desks in the list that leg would sweep the whole pod,
|
|
1571
|
+
// straight over the colleagues seated in it.
|
|
1572
|
+
export function walkObstacles(rugs, decor, spares, conf, desks = []) {
|
|
1573
|
+
const cell = d => ({ x: d.x, y: d.y, w: WS_W * Z, h: WS_H * Z });
|
|
1574
|
+
const rects = [...rugs, ...decor, ...spares.map(cell), ...desks.map(cell)];
|
|
1575
|
+
if (conf) {
|
|
1576
|
+
const t = conf.table;
|
|
1577
|
+
// The set bbox, not the tabletop: the chair overhang on every side is what
|
|
1578
|
+
// a walker has to clear, so this reads CONF_HEAD_RISE rather than repeating
|
|
1579
|
+
// it — a taller head chair must push the route out with it.
|
|
1580
|
+
rects.push({ x: t.x - 42, y: t.y - CONF_HEAD_RISE, w: t.w + 84, h: t.h + CONF_HEAD_RISE + 32 });
|
|
1581
|
+
}
|
|
1582
|
+
return rects;
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
// The corridor row nearest y: the obstacles' vertical spans, inverted into the
|
|
1586
|
+
// gaps between them, ranked by how close to y a walker can stand in them. Spans
|
|
1587
|
+
// count full width — a rug half-way across still rules its rows out, which is
|
|
1588
|
+
// what keeps this a scan rather than a search.
|
|
1589
|
+
//
|
|
1590
|
+
// This office is dense: between the pod rugs, the spare row and the chat areas
|
|
1591
|
+
// the gaps are usually 18-54px against a 64px character, so a gap wide enough
|
|
1592
|
+
// to stand in clear of everything often does not exist. Rather than give up
|
|
1593
|
+
// (the old null sent the route to the bottom edge, straight through the sofas)
|
|
1594
|
+
// the widest gap wins when none fits, and the walker's feet ride its bottom —
|
|
1595
|
+
// overlapping the furniture above it, drawn in front of it, never the chats.
|
|
1596
|
+
export function corridorY(obstacles, panelHeight, y) {
|
|
1597
|
+
const spans = obstacles.map(r => [r.y, r.y + r.h]).sort((a, b) => a[0] - b[0]);
|
|
1598
|
+
spans.push([panelHeight, panelHeight]);
|
|
1599
|
+
const gaps = [];
|
|
1600
|
+
let top = FLOOR_TOP;
|
|
1601
|
+
for (const [s, e] of spans) {
|
|
1602
|
+
if (s > top) gaps.push([top, s]);
|
|
1603
|
+
top = Math.max(top, e);
|
|
1604
|
+
}
|
|
1605
|
+
// Where a walker stands in a gap: as near y as it reaches, feet on the floor
|
|
1606
|
+
// the gap ends at when the gap is shorter than the character.
|
|
1607
|
+
const row = ([a, b]) => Math.round(Math.min(Math.max(y, a), b - CHAR_H));
|
|
1608
|
+
const fitting = gaps.filter(([a, b]) => b - a >= CHAR_H);
|
|
1609
|
+
if (fitting.length) return fitting.map(row).reduce((p, c) => Math.abs(c - y) < Math.abs(p - y) ? c : p);
|
|
1610
|
+
if (gaps.length) return row(gaps.reduce((p, c) => (c[1] - c[0] > p[1] - p[0] ? c : p)));
|
|
1611
|
+
// Nothing but furniture from FLOOR_TOP down: stand on the desk's own row.
|
|
1612
|
+
return Math.round(Math.min(Math.max(y, FLOOR_TOP), panelHeight - CHAR_H));
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
// The column a vertical approach runs down. The point's own is the default;
|
|
1616
|
+
// when the band between it and the corridor is blocked, the walker drops down
|
|
1617
|
+
// the nearest column clear of everything but the rects it is standing in (its
|
|
1618
|
+
// own pod rug and its own desk), then comes in along its own row. Candidates
|
|
1619
|
+
// are the blocking rects' edges, so this stays a scan rather than a search --
|
|
1620
|
+
// no candidate clear on both legs means falling back to the old
|
|
1621
|
+
// straight-over-everything column.
|
|
1622
|
+
//
|
|
1623
|
+
// `toward` is where the crossing leg goes on from here (the entrance, or the
|
|
1624
|
+
// conference lane): of two columns the same distance away, the one on that
|
|
1625
|
+
// side is the one the walker does not have to double back over.
|
|
1626
|
+
export function approachX(obstacles, point, yMid, panelWidth = Infinity, toward = point.x) {
|
|
1627
|
+
const cx = point.x + CHAR_W / 2, cy = point.y + CHAR_H / 2;
|
|
1628
|
+
// Every rect the walker is standing in, not just the first: it sits on its
|
|
1629
|
+
// pod rug AND at its own desk, and a route cannot be clear of the floor it
|
|
1630
|
+
// starts on. Excluding only one of them makes the other unroutable.
|
|
1631
|
+
const mine = obstacles.filter(o => cx >= o.x && cx <= o.x + o.w && cy >= o.y && cy <= o.y + o.h);
|
|
1632
|
+
const top = Math.min(yMid, point.y), bot = Math.max(yMid, point.y) + CHAR_H;
|
|
1633
|
+
const blockers = obstacles.filter(o => !mine.includes(o) && o.y < bot && o.y + o.h > top);
|
|
1634
|
+
const colClear = x => blockers.every(o => o.x >= x + CHAR_W || o.x + o.w <= x);
|
|
1635
|
+
if (colClear(point.x)) return point.x;
|
|
1636
|
+
// The sidestep along the point's own row must be as clear as the descent.
|
|
1637
|
+
const rowClear = x => blockers.every(o => o.y >= point.y + CHAR_H || o.y + o.h <= point.y ||
|
|
1638
|
+
o.x >= Math.max(x, point.x) + CHAR_W || o.x + o.w <= Math.min(x, point.x));
|
|
1639
|
+
// Detour cost: out to the column and on to wherever the crossing is headed,
|
|
1640
|
+
// so a column between the two is free and one past the point is not.
|
|
1641
|
+
const cost = x => Math.abs(x - point.x) + Math.abs(x - toward);
|
|
1642
|
+
return [...blockers, ...mine]
|
|
1643
|
+
.flatMap(o => [o.x - CHAR_W, o.x + o.w])
|
|
1644
|
+
.filter(x => x >= ENTRY_X && x + CHAR_W <= panelWidth)
|
|
1645
|
+
.sort((a, b) => cost(a) - cost(b) || Math.abs(a - point.x) - Math.abs(b - point.x))
|
|
1646
|
+
.find(x => colClear(x) && rowClear(x)) ?? point.x;
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
// Walk in/out routes: in at the left edge on the empty floor row nearest the
|
|
1650
|
+
// desk, straight across that row, then up or down the desk column — and the
|
|
1651
|
+
// reverse walking out. The entrance rides on the corridor, so there is no
|
|
1652
|
+
// vertical leg along the left strip and no fixed door height. A walk-out
|
|
1653
|
+
// starting inside the conference set (a foot-seat sitter) steps out via the
|
|
1654
|
+
// nearest outer lane first. The vertical leg runs down approachX's column:
|
|
1655
|
+
// the desk's own when that descent is clear, else the nearest aisle beside
|
|
1656
|
+
// whatever blocks it, with a sidestep along the desk's own row at the end.
|
|
1657
|
+
export function entryRoute(point, conf, inbound, panelHeight, obstacles = [], panelWidth = Infinity) {
|
|
1658
|
+
const yMid = corridorY(obstacles, panelHeight, point.y);
|
|
1659
|
+
// A start inside the set's footprint — chair overhang included — steps out
|
|
1660
|
+
// via the nearest outer lane; anywhere else approachX picks the column.
|
|
1661
|
+
let climb = null;
|
|
1662
|
+
if (conf) {
|
|
1663
|
+
const t = conf.table;
|
|
1664
|
+
if (point.x + CHAR_W > t.x - 42 && point.x < t.x + t.w + 42 &&
|
|
1665
|
+
point.y + CHAR_H > t.y - 18 && point.y < t.y + t.h + 32) {
|
|
1666
|
+
const lane = point.x < t.x + t.w / 2 ? confLanes(conf).left : confLanes(conf).right;
|
|
1667
|
+
// The lane only knows the table's geometry, so it can point straight at
|
|
1668
|
+
// whatever else stands between the set and the corridor. approachX vets
|
|
1669
|
+
// it from the lane rather than from the seat — measured from the seat
|
|
1670
|
+
// the set is the walker's own floor and every column reads clear — and
|
|
1671
|
+
// steps wider when the lane's column is not.
|
|
1672
|
+
const laneX = approachX(obstacles, { x: lane, y: point.y }, yMid, panelWidth, ENTRY_X);
|
|
1673
|
+
climb = [{ x: laneX, y: point.y }, { x: laneX, y: yMid }];
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
if (climb === null) {
|
|
1677
|
+
const laneX = approachX(obstacles, point, yMid, panelWidth, ENTRY_X);
|
|
1678
|
+
climb = laneX === point.x ? [{ x: point.x, y: yMid }]
|
|
1679
|
+
: [{ x: laneX, y: point.y }, { x: laneX, y: yMid }];
|
|
1680
|
+
}
|
|
1681
|
+
const pts = [{ x: point.x, y: point.y }, ...climb, { x: ENTRY_X, y: yMid }];
|
|
1682
|
+
if (inbound) pts.reverse();
|
|
1683
|
+
return pathThrough(pts);
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
function pathThrough(pts) {
|
|
1687
|
+
const legs = [];
|
|
1688
|
+
for (let i = 1; i < pts.length; i++) {
|
|
1689
|
+
const a = pts[i - 1], b = pts[i];
|
|
1690
|
+
if (a.x !== b.x || a.y !== b.y) legs.push({ x0: a.x, y0: a.y, x1: b.x, y1: b.y });
|
|
1691
|
+
}
|
|
1692
|
+
const total = legs.reduce((s, l) => s + Math.abs(l.x1 - l.x0) + Math.abs(l.y1 - l.y0), 0);
|
|
1693
|
+
return { from: { x: pts[0].x, y: pts[0].y }, legs, total };
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
// Point and heading a given distance along the path. Clamps to the endpoints.
|
|
1697
|
+
export function pointAlongPath(path, dist) {
|
|
1698
|
+
let d = Math.max(0, Math.min(dist, path.total));
|
|
1699
|
+
for (let i = 0; i < path.legs.length; i++) {
|
|
1700
|
+
const l = path.legs[i];
|
|
1701
|
+
const len = Math.abs(l.x1 - l.x0) + Math.abs(l.y1 - l.y0);
|
|
1702
|
+
if (d <= len || i === path.legs.length - 1) {
|
|
1703
|
+
const t = len ? Math.min(1, d / len) : 1;
|
|
1704
|
+
return {
|
|
1705
|
+
x: Math.round(l.x0 + (l.x1 - l.x0) * t),
|
|
1706
|
+
y: Math.round(l.y0 + (l.y1 - l.y0) * t),
|
|
1707
|
+
dx: Math.sign(l.x1 - l.x0),
|
|
1708
|
+
dy: Math.sign(l.y1 - l.y0),
|
|
1709
|
+
};
|
|
1710
|
+
}
|
|
1711
|
+
d -= len;
|
|
1712
|
+
}
|
|
1713
|
+
return { x: path.from.x, y: path.from.y, dx: 0, dy: 0 };
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
// Jobs that just moved into in-progress under a fresh agent — the dispatches.
|
|
1717
|
+
// A manual review -> in-progress move keeps its agentSessionId, so it does not
|
|
1718
|
+
// register. fromState names the column the paper launches from.
|
|
1719
|
+
export function detectDispatches(prev, jobList) {
|
|
1720
|
+
const out = [];
|
|
1721
|
+
for (const job of jobList) {
|
|
1722
|
+
if (!job || job.state !== 'in-progress' || !job.agentSessionId) continue;
|
|
1723
|
+
const p = prev.get(job.id);
|
|
1724
|
+
if (p && p.agentSessionId === job.agentSessionId) continue;
|
|
1725
|
+
out.push({ jobId: job.id, sessionId: job.agentSessionId, fromState: p ? p.state : 'todo' });
|
|
1726
|
+
}
|
|
1727
|
+
return out;
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
// Called after every jobs-list broadcast. The first call is the baseline (and
|
|
1731
|
+
// marks the connect sync complete); later calls diff against it for dispatches.
|
|
1732
|
+
export function noteJobsUpdate() {
|
|
1733
|
+
const list = [...jobs.values()];
|
|
1734
|
+
if (prevJobStates !== null && motionEnabled()) {
|
|
1735
|
+
for (const d of detectDispatches(prevJobStates, list)) {
|
|
1736
|
+
const col = Math.max(0, BOARD_STATES.indexOf(d.fromState));
|
|
1737
|
+
paperAnims.push({ sessionId: d.sessionId, col, start: performance.now() });
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
prevJobStates = new Map(list.map(j => [j.id, { state: j.state, agentSessionId: j.agentSessionId || null }]));
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
// A session-created for a session not yet in the agent map. Ignored during the
|
|
1744
|
+
// initial connect sync: those agents already existed and render seated.
|
|
1745
|
+
export function noteAgentArrival(sessionId) {
|
|
1746
|
+
if (prevJobStates === null || !motionEnabled()) return;
|
|
1747
|
+
walkAnims.set(sessionId, {
|
|
1748
|
+
dir: 'in', start: null, queuedAt: performance.now(), variant: charVariant(sessionId),
|
|
1749
|
+
});
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
// An agent is about to leave (session-ended, or the user closing the tab).
|
|
1753
|
+
// Captures the desk position now — the tile may be gone next frame.
|
|
1754
|
+
export function noteAgentDeparture(sessionId) {
|
|
1755
|
+
if (!motionEnabled()) return;
|
|
1756
|
+
const agent = agents.get(sessionId);
|
|
1757
|
+
if (!agent || agent.state === 'DISCONNECTED') return;
|
|
1758
|
+
const canvas = document.getElementById('office-canvas');
|
|
1759
|
+
if (!canvas) return;
|
|
1760
|
+
const { w, h } = canvasSize(canvas);
|
|
1761
|
+
if (!w || !h) return;
|
|
1762
|
+
const layout = computeOfficeLayout(w, h);
|
|
1763
|
+
// A sitter leaves from wherever it is seated — conference table or sofa —
|
|
1764
|
+
// and a mid-wander walker from its current point along the walk, never a
|
|
1765
|
+
// snap to a spot it isn't at.
|
|
1766
|
+
const desk = deskCharPos(sessionId, layout);
|
|
1767
|
+
const seat = seatClaims.has(sessionId) ? currentSeats[seatClaims.get(sessionId)] ?? null : null;
|
|
1768
|
+
const anim = wanderAnims.get(sessionId);
|
|
1769
|
+
let pos = seat || desk;
|
|
1770
|
+
// The frozen route the wander is actually walking, so the walk-out starts
|
|
1771
|
+
// where the character is drawn rather than on a freshly recomputed path.
|
|
1772
|
+
if (anim && anim.path) pos = pointAlongPath(anim.path, ((performance.now() - anim.start) / 1000) * WALK_SPEED);
|
|
1773
|
+
seatClaims.delete(sessionId);
|
|
1774
|
+
wanderAnims.delete(sessionId);
|
|
1775
|
+
if (!pos) return;
|
|
1776
|
+
walkAnims.set(sessionId, {
|
|
1777
|
+
dir: 'out', start: performance.now(),
|
|
1778
|
+
fromX: pos.x, fromY: pos.y,
|
|
1779
|
+
variant: charVariant(sessionId),
|
|
1780
|
+
});
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
function deskCharPos(sessionId, layout) {
|
|
1784
|
+
const pos = layout.positions.get(sessionId);
|
|
1785
|
+
if (!pos) return null;
|
|
1786
|
+
// Same variant rule as the seated pass: desk style keys on the agent id.
|
|
1787
|
+
const deskVariant = charVariant(sessionId) % 2;
|
|
1788
|
+
return {
|
|
1789
|
+
x: pos.x + (deskVariant === 0 ? DESK_CHAR_X : DESK2_CHAR_X) * Z - 1,
|
|
1790
|
+
y: pos.y + DESK_CHAR_Y * Z,
|
|
1791
|
+
};
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
function drawWalker(ctx, variant, pt, now) {
|
|
1795
|
+
const sheet = SPRITES['char' + variant] || SPRITES.char0;
|
|
1796
|
+
if (!sheet) return;
|
|
1797
|
+
const col = Math.floor(now / WALK_FRAME_MS) % 3;
|
|
1798
|
+
let row = CHAR_ROW_RIGHT, mirror = false;
|
|
1799
|
+
if (pt.dy < 0) row = CHAR_ROW_UP;
|
|
1800
|
+
else if (pt.dy > 0) row = CHAR_ROW_DOWN;
|
|
1801
|
+
else if (pt.dx < 0) mirror = true; // sheet has no left row — mirror the right one
|
|
1802
|
+
ctx.save();
|
|
1803
|
+
if (mirror) {
|
|
1804
|
+
ctx.translate(pt.x + CHAR_W, pt.y);
|
|
1805
|
+
ctx.scale(-1, 1);
|
|
1806
|
+
ctx.drawImage(sheet, col * CHAR_FRAME_W, row * CHAR_FRAME_H, CHAR_FRAME_W, CHAR_FRAME_H, 0, 0, CHAR_W, CHAR_H);
|
|
1807
|
+
} else {
|
|
1808
|
+
ctx.drawImage(sheet, col * CHAR_FRAME_W, row * CHAR_FRAME_H, CHAR_FRAME_W, CHAR_FRAME_H, pt.x, pt.y, CHAR_W, CHAR_H);
|
|
1809
|
+
}
|
|
1810
|
+
ctx.restore();
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
// A small flying job sheet, drawn like the pinned posts on the boards.
|
|
1814
|
+
function drawFlyingPaper(ctx, x, y, alpha) {
|
|
1815
|
+
const z = Z;
|
|
1816
|
+
const pw = 4 * z, ph = 5 * z;
|
|
1817
|
+
ctx.save();
|
|
1818
|
+
ctx.globalAlpha = alpha;
|
|
1819
|
+
ctx.fillStyle = 'rgba(0,0,0,0.14)';
|
|
1820
|
+
ctx.fillRect(x + 1, y + 1, pw, ph);
|
|
1821
|
+
ctx.fillStyle = '#f6f3ea';
|
|
1822
|
+
ctx.fillRect(x, y, pw, ph);
|
|
1823
|
+
ctx.fillStyle = 'rgba(45, 50, 58, 0.75)';
|
|
1824
|
+
ctx.fillRect(x + z, y + z, 2 * z, 2);
|
|
1825
|
+
ctx.fillStyle = 'rgba(70, 76, 86, 0.45)';
|
|
1826
|
+
ctx.fillRect(x + z, y + Math.round(2.5 * z), Math.round(2.5 * z), 1);
|
|
1827
|
+
ctx.fillRect(x + z, y + Math.round(3.5 * z), 2 * z, 1);
|
|
1828
|
+
ctx.restore();
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
// Drawn on top of everything each frame. Paper targets resolve fresh from the
|
|
1832
|
+
// current layout, so a grid reflow retargets a flying sheet mid-flight instead
|
|
1833
|
+
// of leaving it aimed at a stale desk. Walk routes do not — see routeOf.
|
|
1834
|
+
function drawMotion(ctx, w, h, layout) {
|
|
1835
|
+
if (!hasMotion()) return;
|
|
1836
|
+
const now = performance.now();
|
|
1837
|
+
|
|
1838
|
+
// A walker's route is built once and kept on the anim. Rebuilding it from
|
|
1839
|
+
// live obstacles every frame re-projected the walker along a different path
|
|
1840
|
+
// whenever an unrelated spawn or exit changed the layout mid-stride — the
|
|
1841
|
+
// distance advances on wall-clock time, so a longer `total` moves it a desk
|
|
1842
|
+
// over. Only a canvas resize rebuilds: the room genuinely changes then.
|
|
1843
|
+
const routeOf = (anim, build) => {
|
|
1844
|
+
if (!anim.path || anim.pathW !== w || anim.pathH !== h) {
|
|
1845
|
+
anim.path = build();
|
|
1846
|
+
anim.pathW = w;
|
|
1847
|
+
anim.pathH = h;
|
|
1848
|
+
}
|
|
1849
|
+
return anim.path;
|
|
1850
|
+
};
|
|
1851
|
+
|
|
1852
|
+
const board = paperAnims.length ? computeBoardLayout(w) : null;
|
|
1853
|
+
for (let i = paperAnims.length - 1; i >= 0; i--) {
|
|
1854
|
+
const p = paperAnims[i];
|
|
1855
|
+
const elapsed = now - p.start;
|
|
1856
|
+
if (elapsed > PAPER_MS + PAPER_FADE_MS || !board.visible) { paperAnims.splice(i, 1); continue; }
|
|
1857
|
+
const sec = board.sections[p.col] || board.sections[0];
|
|
1858
|
+
const from = { x: sec.centerX, y: board.boardTop + Math.round(board.boardH / 2) };
|
|
1859
|
+
const to = deskCharPos(p.sessionId, layout)
|
|
1860
|
+
|| { x: Math.round(w / 2), y: Math.round((FLOOR_TOP + h) / 2) };
|
|
1861
|
+
const t = Math.min(1, elapsed / PAPER_MS);
|
|
1862
|
+
const e = t * t * (3 - 2 * t); // smoothstep ease
|
|
1863
|
+
// Quadratic arc with the control point lifted above the launch
|
|
1864
|
+
const cx = (from.x + to.x) / 2, cy = Math.min(from.y, to.y) - 12 * Z;
|
|
1865
|
+
const u = 1 - e;
|
|
1866
|
+
const x = u * u * from.x + 2 * u * e * cx + e * e * to.x;
|
|
1867
|
+
const y = u * u * from.y + 2 * u * e * cy + e * e * to.y;
|
|
1868
|
+
const alpha = elapsed <= PAPER_MS ? 1 : 1 - (elapsed - PAPER_MS) / PAPER_FADE_MS;
|
|
1869
|
+
drawFlyingPaper(ctx, Math.round(x), Math.round(y), alpha);
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
for (const [sid, anim] of walkAnims) {
|
|
1873
|
+
let path;
|
|
1874
|
+
if (anim.dir === 'in') {
|
|
1875
|
+
const to = deskCharPos(sid, layout);
|
|
1876
|
+
// session-created seen, but the async terminal handler hasn't put the
|
|
1877
|
+
// agent in the map yet — wait briefly, then give up. The same cutoff
|
|
1878
|
+
// drops a walk-in queued while the canvas was hidden (a phone on another
|
|
1879
|
+
// panel): the loop draws no frames then, so the walk would otherwise
|
|
1880
|
+
// start from the door whenever the office is next shown.
|
|
1881
|
+
if (anim.start === null && now - anim.queuedAt > 4000) { walkAnims.delete(sid); continue; }
|
|
1882
|
+
if (!to) { if (anim.start !== null) walkAnims.delete(sid); continue; }
|
|
1883
|
+
if (anim.start === null) anim.start = now;
|
|
1884
|
+
// Built on the first frame the desk resolves, not the first frame of
|
|
1885
|
+
// the anim — the walk-in waits for the async terminal handler.
|
|
1886
|
+
path = routeOf(anim, () => entryRoute(to, currentConf, true, h, currentObstacles, w));
|
|
1887
|
+
} else {
|
|
1888
|
+
path = routeOf(anim, () => entryRoute({ x: anim.fromX, y: anim.fromY }, currentConf, false, h, currentObstacles, w));
|
|
1889
|
+
}
|
|
1890
|
+
const dist = ((now - anim.start) / 1000) * WALK_SPEED;
|
|
1891
|
+
if (dist >= path.total) {
|
|
1892
|
+
// The seated pass already skipped this frame, so hold the walker on its
|
|
1893
|
+
// endpoint once more or the character blinks out for a frame on arrival.
|
|
1894
|
+
if (anim.dir === 'in') drawWalker(ctx, anim.variant, pointAlongPath(path, path.total), now);
|
|
1895
|
+
walkAnims.delete(sid);
|
|
1896
|
+
continue;
|
|
1897
|
+
}
|
|
1898
|
+
drawWalker(ctx, anim.variant, pointAlongPath(path, dist), now);
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
// Idle wanderers between desk and conference seat. Routes freeze like the
|
|
1902
|
+
// walk anims; wanderRoute keeps the crossing legs off the conference
|
|
1903
|
+
// furniture.
|
|
1904
|
+
for (const [sid, anim] of wanderAnims) {
|
|
1905
|
+
let path;
|
|
1906
|
+
const desk = deskCharPos(sid, layout);
|
|
1907
|
+
if (!desk) { wanderAnims.delete(sid); continue; }
|
|
1908
|
+
if (anim.dir === 'seat') {
|
|
1909
|
+
const seat = currentSeats[seatClaims.get(sid)];
|
|
1910
|
+
if (!seat) { wanderAnims.delete(sid); continue; }
|
|
1911
|
+
path = routeOf(anim, () => wanderRoute(desk, seat, currentConf, true, currentObstacles, w, h));
|
|
1912
|
+
} else {
|
|
1913
|
+
const from = { x: anim.fromX, y: anim.fromY, row: anim.fromRow, lane: anim.fromLane, yPref: anim.fromYPref };
|
|
1914
|
+
path = routeOf(anim, () => wanderRoute(desk, from, currentConf, false, currentObstacles, w, h));
|
|
1915
|
+
}
|
|
1916
|
+
const dist = ((now - anim.start) / 1000) * WALK_SPEED;
|
|
1917
|
+
if (dist >= path.total) {
|
|
1918
|
+
// Hold the endpoint one frame: the seated/desk pass skipped this agent
|
|
1919
|
+
// before this anim was seen finishing, so it would blink out otherwise.
|
|
1920
|
+
drawWalker(ctx, anim.variant, pointAlongPath(path, path.total), now);
|
|
1921
|
+
wanderAnims.delete(sid);
|
|
1922
|
+
continue;
|
|
1923
|
+
}
|
|
1924
|
+
drawWalker(ctx, anim.variant, pointAlongPath(path, dist), now);
|
|
1925
|
+
}
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
// Size from the canvas's own CSS box (flex:1 below .office-header), not the
|
|
1929
|
+
// panel's — the panel height includes the header, so a panel-sized canvas
|
|
1930
|
+
// overflowed the bottom and clipped the sofa and corner plants. The diff
|
|
1931
|
+
// viewer hides the canvas (display:none → 0x0 client box), so remember the
|
|
1932
|
+
// last real size: a walk-out captured while hidden still lays out against
|
|
1933
|
+
// the room the user will see when it comes back.
|
|
1934
|
+
let lastCanvasSize = { w: 0, h: 0 };
|
|
1935
|
+
function canvasSize(canvas) {
|
|
1936
|
+
const w = canvas.clientWidth, h = canvas.clientHeight;
|
|
1937
|
+
if (w && h) lastCanvasSize = { w, h };
|
|
1938
|
+
return lastCanvasSize;
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
export function renderOffice() {
|
|
1942
|
+
const canvas = document.getElementById('office-canvas');
|
|
1943
|
+
const { w, h } = canvasSize(canvas);
|
|
1944
|
+
if (!w || !h) return; // no layout yet — nothing to draw into
|
|
1945
|
+
const dpr = window.devicePixelRatio || 1;
|
|
1946
|
+
canvas.width = w * dpr;
|
|
1947
|
+
canvas.height = h * dpr;
|
|
1948
|
+
const ctx = canvas.getContext('2d');
|
|
1949
|
+
ctx.scale(dpr, dpr);
|
|
1950
|
+
ctx.imageSmoothingEnabled = false;
|
|
1951
|
+
const theme = getThemeColors();
|
|
1952
|
+
|
|
1953
|
+
// Layer 1: Floor (warm wood planks)
|
|
1954
|
+
drawFloor(ctx, w, h);
|
|
1955
|
+
// Layer 2: Walls (warm plaster)
|
|
1956
|
+
drawWalls(ctx, w);
|
|
1957
|
+
// Layer 3: Windows (2 windows with day/night + light beams)
|
|
1958
|
+
drawWindows(ctx, w, theme);
|
|
1959
|
+
// Layer 4: Bookshelf runs under the windows (before the boards so easels overlap shelves)
|
|
1960
|
+
drawBookshelves(ctx, w);
|
|
1961
|
+
// Layer 5: Job boards
|
|
1962
|
+
drawJobBoardEasels(ctx, w);
|
|
1963
|
+
// Layer 6: Ambient particles
|
|
1964
|
+
drawParticles(ctx, w, h);
|
|
1965
|
+
|
|
1966
|
+
// Per-repo pod layout, then ambient decor in whatever floor is left over
|
|
1967
|
+
const layout = computeOfficeLayout(w, h);
|
|
1968
|
+
const rugs = layout.pods.map(podRugRect);
|
|
1969
|
+
const decor = computeDecorPlacement(rugs, w, h);
|
|
1970
|
+
drawDecor(ctx, decor, theme);
|
|
1971
|
+
for (const pod of layout.pods) drawPodRug(ctx, pod, theme);
|
|
1972
|
+
// Empty workstations on the row below a sparse office: dark screens, no one seated
|
|
1973
|
+
const spares = computeSpareDesks(layout.pods, decor, w, h);
|
|
1974
|
+
for (const d of spares) drawWorkstation(ctx, d.x, d.y, 'DISCONNECTED', theme, d.variant, null);
|
|
1975
|
+
// Conference table in the open band below them; idle agents wander over
|
|
1976
|
+
currentConf = computeConference(rugs, spares, decor, w, h);
|
|
1977
|
+
currentObstacles = walkObstacles(rugs, decor, spares, currentConf, [...layout.positions.values()]);
|
|
1978
|
+
currentSeats = wanderSeats(currentConf, decor);
|
|
1979
|
+
updateWander(currentSeats);
|
|
1980
|
+
if (currentConf) drawConference(ctx, currentConf);
|
|
1981
|
+
// Sofa sitters go down after drawDecor laid the sofas out above.
|
|
1982
|
+
drawChatSitters(ctx);
|
|
1983
|
+
|
|
1984
|
+
for (const [sessionId, agent] of agents) {
|
|
1985
|
+
const pos = layout.positions.get(sessionId);
|
|
1986
|
+
if (!pos) continue; // never let one miss blank the office every frame
|
|
1987
|
+
const { x: sx, y: sy } = pos;
|
|
1988
|
+
const isActive = sessionId === activeSessionId;
|
|
1989
|
+
const state = agent.state;
|
|
1990
|
+
const alive = state !== 'DISCONNECTED';
|
|
1991
|
+
// Colleague agents (you don't own them) render dimmed with an owner label (phase 3).
|
|
1992
|
+
const readOnly = !canControlAgent(agent);
|
|
1993
|
+
if (readOnly) ctx.globalAlpha = 0.5;
|
|
1994
|
+
|
|
1995
|
+
// Desk variant keys on the agent id (like the character sprite), not the
|
|
1996
|
+
// iteration index, so an unrelated exit never restyles later desks.
|
|
1997
|
+
const deskVariant = charVariant(sessionId) % 2;
|
|
1998
|
+
const charX = sx + (deskVariant === 0 ? DESK_CHAR_X : DESK2_CHAR_X) * Z;
|
|
1999
|
+
// Fall back to sheet 0 if this variant's PNG failed to load
|
|
2000
|
+
const sheet = SPRITES['char' + charVariant(sessionId)] || SPRITES.char0;
|
|
2001
|
+
// While a walk/wander anim exists the overlay pass draws the character,
|
|
2002
|
+
// and a conference sitter renders at the table — the desk stays, empty.
|
|
2003
|
+
const away = walkAnims.has(sessionId) || wanderAnims.has(sessionId) || seatClaims.has(sessionId);
|
|
2004
|
+
drawWorkstation(ctx, sx, sy, state, theme, deskVariant, agent);
|
|
2005
|
+
drawMonitorGlow(ctx, sx, sy, state, theme);
|
|
2006
|
+
if (alive && sheet && !away) {
|
|
2007
|
+
let row, col;
|
|
2008
|
+
if (state === 'WORKING') {
|
|
2009
|
+
// Seated at the keyboard, typing (2-frame cycle), back to the viewer.
|
|
2010
|
+
row = CHAR_ROW_UP;
|
|
2011
|
+
col = CHAR_COL_SIT + (prefersReducedMotion ? 0 : Math.floor(Date.now() / 300) % 2);
|
|
2012
|
+
} else if (state === 'IDLE') {
|
|
2013
|
+
row = CHAR_ROW_DOWN;
|
|
2014
|
+
col = CHAR_COL_STAND;
|
|
2015
|
+
} else {
|
|
2016
|
+
// WAITING / MESSAGE: chair turned around, facing the viewer
|
|
2017
|
+
row = CHAR_ROW_DOWN;
|
|
2018
|
+
col = CHAR_COL_SIT;
|
|
2019
|
+
}
|
|
2020
|
+
ctx.drawImage(
|
|
2021
|
+
sheet,
|
|
2022
|
+
col * CHAR_FRAME_W, row * CHAR_FRAME_H, CHAR_FRAME_W, CHAR_FRAME_H,
|
|
2023
|
+
charX - 1, sy + DESK_CHAR_Y * Z,
|
|
2024
|
+
CHAR_FRAME_W * CHAR_SCALE, CHAR_FRAME_H * CHAR_SCALE
|
|
2025
|
+
);
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
if (agent.name) {
|
|
2029
|
+
const nameY = sy + 37 * Z;
|
|
2030
|
+
const nameX = sx + (WS_W / 2) * Z;
|
|
2031
|
+
ctx.textAlign = 'center';
|
|
2032
|
+
|
|
2033
|
+
if (!isActive) {
|
|
2034
|
+
ctx.font = LABEL_FONT;
|
|
2035
|
+
ctx.fillStyle = `rgba(${theme.tr}, ${theme.tg}, ${theme.tb}, 0.7)`;
|
|
2036
|
+
// maxWidth: a long (renamed) label squeezes into the tile instead of overflowing onto neighbors.
|
|
2037
|
+
ctx.fillText(agent.name, nameX, nameY, WS_W * Z);
|
|
2038
|
+
} else {
|
|
2039
|
+
// Pulsing glow
|
|
2040
|
+
const pulse = Math.sin(Date.now() / 600) * 0.5 + 0.5;
|
|
2041
|
+
ctx.font = LABEL_FONT;
|
|
2042
|
+
ctx.shadowColor = `rgba(${GOLD_RGB}, ${0.4 + pulse * 0.5})`;
|
|
2043
|
+
ctx.shadowBlur = 6 + pulse * 8;
|
|
2044
|
+
ctx.fillStyle = '#d4a847';
|
|
2045
|
+
ctx.fillText(agent.name, nameX, nameY, WS_W * Z);
|
|
2046
|
+
ctx.fillText(agent.name, nameX, nameY, WS_W * Z);
|
|
2047
|
+
ctx.shadowBlur = 0;
|
|
2048
|
+
}
|
|
2049
|
+
ctx.textAlign = 'start';
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
if (state === 'MESSAGE' && !away) {
|
|
2053
|
+
// Beside the character's head — only while the character is actually
|
|
2054
|
+
// at the desk; a walker's bubble would float over an empty chair.
|
|
2055
|
+
drawMessageBubble(ctx, charX + 5 * Z, sy + 5 * Z, theme);
|
|
2056
|
+
}
|
|
2057
|
+
if (state === 'DISCONNECTED') {
|
|
2058
|
+
ctx.fillStyle = 'rgba(0,0,0,0.35)';
|
|
2059
|
+
ctx.fillRect(sx, sy, WS_W * Z, WS_H * Z);
|
|
2060
|
+
const cx = sx + Math.floor(WS_W / 2) * Z;
|
|
2061
|
+
const cy = sy + Math.floor(WS_H / 2) * Z - 2 * Z;
|
|
2062
|
+
ctx.fillStyle = '#ff4444';
|
|
2063
|
+
for (let i = -3; i <= 3; i++) {
|
|
2064
|
+
ctx.fillRect(cx + i * Z, cy + i * Z, Z, Z);
|
|
2065
|
+
ctx.fillRect(cx + i * Z, cy - i * Z, Z, Z);
|
|
2066
|
+
}
|
|
2067
|
+
}
|
|
2068
|
+
|
|
2069
|
+
if (agent.conflicts && agent.conflicts.length > 0) {
|
|
2070
|
+
drawConflictBadge(ctx, sx, sy);
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
// Owner label (full opacity) under the dimmed colleague tile.
|
|
2074
|
+
if (readOnly && agent.ownerName) {
|
|
2075
|
+
ctx.globalAlpha = 1;
|
|
2076
|
+
ctx.textAlign = 'center';
|
|
2077
|
+
ctx.font = '9px monospace';
|
|
2078
|
+
// Concrete fallback (canvas fillStyle ignores invalid colors like CSS vars).
|
|
2079
|
+
ctx.fillStyle = /^#[0-9a-fA-F]{3,8}$/.test(String(agent.ownerColor)) ? agent.ownerColor : '#9ca3af';
|
|
2080
|
+
// maxWidth clamps a long owner name to the tile so it can't overflow onto neighbors.
|
|
2081
|
+
ctx.fillText(agent.ownerName, sx + (WS_W / 2) * Z, sy + 46 * Z, WS_W * Z);
|
|
2082
|
+
ctx.textAlign = 'start';
|
|
2083
|
+
}
|
|
2084
|
+
ctx.globalAlpha = 1;
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
// Transient overlays: flying dispatch papers, characters walking in/out
|
|
2088
|
+
drawMotion(ctx, w, h, layout);
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2091
|
+
// True when (x, y) is on one of the painted job boards — the frame or its
|
|
2092
|
+
// stand. Exported so the hit box can be checked against computeBoardLayout
|
|
2093
|
+
// without a canvas.
|
|
2094
|
+
export function hitJobBoard(x, y, panelWidth) {
|
|
2095
|
+
const board = computeBoardLayout(panelWidth);
|
|
2096
|
+
if (!board.visible) return false;
|
|
2097
|
+
if (y < board.boardTop || y > board.footY) return false;
|
|
2098
|
+
const half = Math.floor(board.boardW / 2);
|
|
2099
|
+
return board.sections.some(({ centerX }) => x >= centerX - half && x <= centerX + half);
|
|
2100
|
+
}
|
|
2101
|
+
|
|
2102
|
+
export function setupOfficeClick() {
|
|
2103
|
+
const canvas = document.getElementById('office-canvas');
|
|
2104
|
+
canvas.addEventListener('click', (e) => {
|
|
2105
|
+
const rect = canvas.getBoundingClientRect();
|
|
2106
|
+
const x = e.clientX - rect.left;
|
|
2107
|
+
const y = e.clientY - rect.top;
|
|
2108
|
+
const layout = computeOfficeLayout(rect.width, rect.height);
|
|
2109
|
+
// The painted boards are the office's view of the Jobs tab, so clicking one
|
|
2110
|
+
// opens it (showJobBoard repaints the tab strip itself). Checked first: the
|
|
2111
|
+
// boards sit against the wall, above every desk and seat.
|
|
2112
|
+
// Only a tap here moves a phone to the terminal panel: switchToSession
|
|
2113
|
+
// and showJobBoard are also reached by spawns, exits and the reconnect
|
|
2114
|
+
// replay, none of which should yank a phone off the view it is on.
|
|
2115
|
+
if (hitJobBoard(x, y, rect.width)) { showJobBoard(); setView('terminal'); return; }
|
|
2116
|
+
// A seated agent's visible character is at the conference table or on a
|
|
2117
|
+
// sofa, so click-to-focus follows it there (the empty desk still works).
|
|
2118
|
+
// The claim indexes the pooled seat list, NOT conf.seats — a sofa claim is
|
|
2119
|
+
// past the end of conf.seats, which read undefined and threw here.
|
|
2120
|
+
for (const [sid, seatIdx] of seatClaims) {
|
|
2121
|
+
const s = currentSeats[seatIdx];
|
|
2122
|
+
if (!s) continue;
|
|
2123
|
+
if (x >= s.x - Z && x <= s.x + CHAR_W + Z && y >= s.y - Z && y <= s.y + CHAR_H + Z) {
|
|
2124
|
+
switchToSession(sid);
|
|
2125
|
+
setView('terminal');
|
|
2126
|
+
return;
|
|
2127
|
+
}
|
|
2128
|
+
}
|
|
2129
|
+
for (const [sessionId] of agents) {
|
|
2130
|
+
const pos = layout.positions.get(sessionId);
|
|
2131
|
+
if (!pos) continue;
|
|
2132
|
+
const sw = WS_W * Z, sh = (WS_H + 6) * Z;
|
|
2133
|
+
if (x >= pos.x - Z && x <= pos.x + sw + Z &&
|
|
2134
|
+
y >= pos.y - Z && y <= pos.y + sh + Z) {
|
|
2135
|
+
switchToSession(sessionId);
|
|
2136
|
+
setView('terminal');
|
|
2137
|
+
return;
|
|
2138
|
+
}
|
|
2139
|
+
}
|
|
2140
|
+
});
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2143
|
+
export function startAnimationLoop() {
|
|
2144
|
+
function loop() {
|
|
2145
|
+
const hasLiving = [...agents.values()].some(a => a.state !== 'DISCONNECTED');
|
|
2146
|
+
// hasMotion keeps the walk-out of the last departing agent rendering.
|
|
2147
|
+
// A hidden canvas (phone on another view, or the diff viewer open) has no
|
|
2148
|
+
// client box: skip the redraw rather than paint 60fps into nothing. Every
|
|
2149
|
+
// animation runs on wall-clock time, so frames skipped here are not owed.
|
|
2150
|
+
if ((hasLiving || hasMotion()) && document.getElementById('office-canvas').clientWidth) renderOffice();
|
|
2151
|
+
requestAnimationFrame(loop);
|
|
2152
|
+
}
|
|
2153
|
+
loop();
|
|
2154
|
+
}
|