@henryz2004/agency 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +106 -0
- package/lib/codex.js +211 -0
- package/lib/control.js +168 -0
- package/lib/live.js +493 -0
- package/lib/opencode.js +447 -0
- package/lib/paths.js +12 -0
- package/lib/roster.js +204 -0
- package/lib/transcript.js +361 -0
- package/lib/usage.js +346 -0
- package/package.json +27 -0
- package/public/app.js +1021 -0
- package/public/audio-controls.js +165 -0
- package/public/avatar.js +467 -0
- package/public/characters/dev-auburn.json +32 -0
- package/public/characters/dev-auburn.png +0 -0
- package/public/characters/dev-beanie.json +32 -0
- package/public/characters/dev-beanie.png +0 -0
- package/public/characters/dev-glasses.json +32 -0
- package/public/characters/dev-glasses.png +0 -0
- package/public/chat-panel.css +514 -0
- package/public/chat-panel.js +815 -0
- package/public/index.html +190 -0
- package/public/lab.html +129 -0
- package/public/leaderboard.js +222 -0
- package/public/metric.js +34 -0
- package/public/mock-agents.js +70 -0
- package/public/mock.js +277 -0
- package/public/music/Console_Morning.mp3 +0 -0
- package/public/music/Midnight_Desk.mp3 +0 -0
- package/public/music/The_Plant_Beside_the_Door.mp3 +0 -0
- package/public/music/Three_AM_Window.mp3 +0 -0
- package/public/office.js +1484 -0
- package/public/sound.js +382 -0
- package/public/sprites.js +983 -0
- package/public/style.css +506 -0
- package/public/ui.js +50 -0
- package/scripts/_pixpng.mjs +104 -0
- package/scripts/animsheet.mjs +60 -0
- package/scripts/charsheet.mjs +61 -0
- package/scripts/install-hook.mjs +120 -0
- package/server.js +370 -0
|
@@ -0,0 +1,983 @@
|
|
|
1
|
+
// sprites.js — ALL procedural pixel-art primitives for the office. Nothing here
|
|
2
|
+
// blits a sprite sheet; every glyph is drawn with px() rects into a low-res
|
|
3
|
+
// buffer that CSS scales up crisply (image-rendering:pixelated).
|
|
4
|
+
//
|
|
5
|
+
// ONE consistent style: chunky 1px-grid pixel art, warm palette, soft top
|
|
6
|
+
// highlight + bottom shade on every solid so volumes read.
|
|
7
|
+
|
|
8
|
+
// ---- tiny helpers ----------------------------------------------------------
|
|
9
|
+
export function px(ctx, x, y, w, h, c) {
|
|
10
|
+
ctx.fillStyle = c;
|
|
11
|
+
ctx.fillRect(x | 0, y | 0, w | 0, h | 0);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function shade(hex, amt) {
|
|
15
|
+
const h = hex.replace('#', '');
|
|
16
|
+
const n = parseInt(h.length === 3 ? h.replace(/(.)/g, '$1$1') : h, 16);
|
|
17
|
+
let r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
|
|
18
|
+
r = Math.max(0, Math.min(255, r + amt));
|
|
19
|
+
g = Math.max(0, Math.min(255, g + amt));
|
|
20
|
+
b = Math.max(0, Math.min(255, b + amt));
|
|
21
|
+
return `#${((1 << 24) | (r << 16) | (g << 8) | b).toString(16).slice(1)}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function rng(seed) {
|
|
25
|
+
let s = seed >>> 0 || 1;
|
|
26
|
+
return () => {
|
|
27
|
+
s = (Math.imul(s, 1103515245) + 12345) & 0x7fffffff;
|
|
28
|
+
return s / 0x7fffffff;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function hashInt(s) {
|
|
33
|
+
let h = 2166136261 >>> 0;
|
|
34
|
+
s = String(s);
|
|
35
|
+
for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619) >>> 0; }
|
|
36
|
+
return h >>> 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ---- palette ---------------------------------------------------------------
|
|
40
|
+
// Warm, cozy, cohesive. Model tiers keep the established color language:
|
|
41
|
+
// opus=gold, sonnet=cyan, haiku=green, codex=orange.
|
|
42
|
+
export const TIER = {
|
|
43
|
+
opus: { screen: '#ffce5e', glow: 'rgba(255,206,94,0.30)', led: '#ffd166', code: '#fff0c0' },
|
|
44
|
+
sonnet: { screen: '#5cd0ff', glow: 'rgba(92,208,255,0.30)', led: '#5cd0ff', code: '#d0f4ff' },
|
|
45
|
+
haiku: { screen: '#6cff9a', glow: 'rgba(108,255,154,0.28)', led: '#6cff9a', code: '#d6ffe2' },
|
|
46
|
+
codex: { screen: '#ff9a4d', glow: 'rgba(255,138,61,0.30)', led: '#ff8a3d', code: '#ffe0c9' },
|
|
47
|
+
};
|
|
48
|
+
export function tierFor(model) {
|
|
49
|
+
const m = (model || '').toLowerCase();
|
|
50
|
+
if (m.includes('codex')) return TIER.codex;
|
|
51
|
+
if (m.includes('haiku')) return TIER.haiku;
|
|
52
|
+
if (m.includes('sonnet')) return TIER.sonnet;
|
|
53
|
+
return TIER.opus; // opus / fable / default
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ---- model color palette (salvaged from the old sprites.js) ----------------
|
|
57
|
+
// Used by app.js's model-mix bar/legend and the headcount comparison heads.
|
|
58
|
+
// Kept here (the sole sprites module) so app.js's `import { drawHead, colorFor }
|
|
59
|
+
// from './sprites.js'` resolves after proc graduation.
|
|
60
|
+
export const MODEL_COLORS = {
|
|
61
|
+
'opus': { screen: '#ffd166', glow: 'rgba(255,209,102,0.25)', code: '#fff0c0' },
|
|
62
|
+
'sonnet': { screen: '#5cd0ff', glow: 'rgba(92,208,255,0.25)', code: '#d0f4ff' },
|
|
63
|
+
'haiku': { screen: '#6cff9a', glow: 'rgba(108,255,154,0.22)', code: '#d6ffe2' },
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// Color for a model slug (used by the model-mix panel). Falls back to a stable
|
|
67
|
+
// hash-derived hue for any model not in MODEL_COLORS.
|
|
68
|
+
export function colorFor(model) {
|
|
69
|
+
if (!model) return MODEL_COLORS.opus;
|
|
70
|
+
const m = model.toLowerCase();
|
|
71
|
+
if (m.includes('codex')) return { screen: '#ff8a3d', glow: 'rgba(255,138,61,0.25)', code: '#ffe0c9' };
|
|
72
|
+
if (m.includes('opus') || m.includes('fable')) return MODEL_COLORS.opus;
|
|
73
|
+
if (m.includes('sonnet')) return MODEL_COLORS.sonnet;
|
|
74
|
+
if (m.includes('haiku')) return MODEL_COLORS.haiku;
|
|
75
|
+
let h = 2166136261 >>> 0;
|
|
76
|
+
for (let i = 0; i < m.length; i++) {
|
|
77
|
+
h ^= m.charCodeAt(i);
|
|
78
|
+
h = Math.imul(h, 16777619) >>> 0;
|
|
79
|
+
}
|
|
80
|
+
const hue = h % 360;
|
|
81
|
+
const screen = hslToHex(hue, 72, 68);
|
|
82
|
+
const glow = `hsla(${hue} 72% 68% / 0.24)`;
|
|
83
|
+
const code = hslToHex(hue, 60, 85);
|
|
84
|
+
return { screen, glow, code };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function hslToHex(h, s, l) {
|
|
88
|
+
s /= 100; l /= 100;
|
|
89
|
+
const k = n => (n + h / 30) % 12;
|
|
90
|
+
const a = s * Math.min(l, 1 - l);
|
|
91
|
+
const f = n => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
|
|
92
|
+
const toHex = v => Math.round(v * 255).toString(16).padStart(2, '0');
|
|
93
|
+
return `#${toHex(f(0))}${toHex(f(8))}${toHex(f(4))}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// A single pixel "head" used for the headcount comparison row in app.js.
|
|
97
|
+
export function drawHead(ctx, x, y, { skin = '#f0c8a0', hair = '#2b2233', shirt = '#5d9ce0' } = {}) {
|
|
98
|
+
px(ctx, x + 1, y, 6, 2, hair);
|
|
99
|
+
px(ctx, x, y + 2, 8, 2, hair);
|
|
100
|
+
px(ctx, x + 1, y + 2, 6, 4, skin);
|
|
101
|
+
px(ctx, x + 2, y + 4, 1, 1, '#1a1a22');
|
|
102
|
+
px(ctx, x + 5, y + 4, 1, 1, '#1a1a22');
|
|
103
|
+
px(ctx, x, y + 6, 8, 3, shirt);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Skin / hair / shirt variety palettes — warm and varied but harmonious.
|
|
107
|
+
// Exported so scripts/charsheet.mjs can enumerate the full appearance space.
|
|
108
|
+
export const SKINS = ['#ffe0bd', '#f2cda4', '#e8b88a', '#d49a6a', '#b87a4e', '#a86a44', '#8a5a3c', '#6b4426'];
|
|
109
|
+
export const HAIRS = ['#2b2233', '#4a3326', '#6b4a2e', '#8a5a30', '#c98a3a', '#9a9aa6', '#1d1d24', '#5a2d2d',
|
|
110
|
+
'#b5532a', '#e6c878', '#e07db0', '#5d7de0', '#3aa6a0', '#d8d8e0']; // + ginger, blonde, pink, blue, teal, silver
|
|
111
|
+
export const SHIRTS = ['#d9694f', '#5d9ce0', '#5dc98a', '#e0b05d', '#a87de0', '#5dc9c9', '#e07db0', '#c0584f', '#4f8fb0',
|
|
112
|
+
'#7d8a3a', '#3a6f8a', '#8a8f99', '#d97f3a', '#6f5dc9']; // + olive, steel, grey, orange, indigo
|
|
113
|
+
export const HAIR_STYLES = 7; // distinct hairStyle values — see drawHeadFace's switch
|
|
114
|
+
export const TOPS = 6; // distinct clothing styles — see drawTorso
|
|
115
|
+
|
|
116
|
+
// ---- the back wall ---------------------------------------------------------
|
|
117
|
+
// A textured cream plaster wall (no sky — wall fills the whole band from the top
|
|
118
|
+
// of the canvas) with a wood rail, windows looking out on blue sky, a wall clock,
|
|
119
|
+
// framed pictures, and a glass whiteboard — all procedural.
|
|
120
|
+
// `wallH` is the full wall band height (the floor begins at y = wallH).
|
|
121
|
+
// ---- time-of-day mood ------------------------------------------------------
|
|
122
|
+
// The office mood shifts with the user's LOCAL hour: soft dawn, bright midday,
|
|
123
|
+
// warm dusk, dark-blue night. Each mood tints the wall plaster, the window sky,
|
|
124
|
+
// the ambient floor wash, and how strongly the interior pendant lights read.
|
|
125
|
+
// Night is kept READABLE — only the empty floor + walls darken; the team rugs
|
|
126
|
+
// and desks are drawn ON TOP of the wash, so they stay full color. office.js
|
|
127
|
+
// reads the clock (new Date() — fine client-side) and passes the mood in.
|
|
128
|
+
export const MOODS = {
|
|
129
|
+
dawn: { wall: '#e7dde0', sky: ['#c7a8d8', '#f3ccc2'], wash: ['rgba(255,206,196,0.16)', 'rgba(255,222,206,0.05)', 'rgba(48,30,40,0.16)'], glow: 0.35 },
|
|
130
|
+
day: { wall: '#efe6d4', sky: ['#7fc0f0', '#a9d8f7'], wash: ['rgba(255,238,200,0.16)', 'rgba(255,238,200,0.03)', 'rgba(40,22,6,0.14)'], glow: 0.0 },
|
|
131
|
+
dusk: { wall: '#e4cdb2', sky: ['#f3a05e', '#f8cf94'], wash: ['rgba(255,165,85,0.20)', 'rgba(255,150,95,0.06)', 'rgba(58,24,12,0.24)'], glow: 0.6 },
|
|
132
|
+
night: { wall: '#3a3f55', sky: ['#16223f', '#283a5e'], wash: ['rgba(28,42,82,0.34)', 'rgba(22,32,62,0.22)', 'rgba(8,12,30,0.42)'], glow: 1.0 },
|
|
133
|
+
};
|
|
134
|
+
// Continuous time-of-day mood: interpolate between anchor moods so the lighting
|
|
135
|
+
// glides smoothly across the day instead of snapping between four states. Integer
|
|
136
|
+
// hours (the live app) land on sensible values; fractional hours (a time-lapse)
|
|
137
|
+
// get a smooth gradient.
|
|
138
|
+
const _hx = (c) => { c = c.replace('#', ''); return [parseInt(c.slice(0, 2), 16), parseInt(c.slice(2, 4), 16), parseInt(c.slice(4, 6), 16)]; };
|
|
139
|
+
const _lerp = (a, b, t) => a + (b - a) * t;
|
|
140
|
+
const _toHex = (r, g, b) => '#' + [r, g, b].map((v) => Math.round(Math.max(0, Math.min(255, v))).toString(16).padStart(2, '0')).join('');
|
|
141
|
+
const _lerpHex = (a, b, t) => { const x = _hx(a), y = _hx(b); return _toHex(_lerp(x[0], y[0], t), _lerp(x[1], y[1], t), _lerp(x[2], y[2], t)); };
|
|
142
|
+
const _rgba = (s) => (s.match(/[\d.]+/g) || [0, 0, 0, 0]).map(Number);
|
|
143
|
+
const _lerpRgba = (a, b, t) => { const x = _rgba(a), y = _rgba(b); return `rgba(${Math.round(_lerp(x[0], y[0], t))},${Math.round(_lerp(x[1], y[1], t))},${Math.round(_lerp(x[2], y[2], t))},${_lerp(x[3] || 0, y[3] || 0, t).toFixed(3)})`; };
|
|
144
|
+
const _lerpMood = (A, B, t) => ({
|
|
145
|
+
wall: _lerpHex(A.wall, B.wall, t),
|
|
146
|
+
sky: [_lerpHex(A.sky[0], B.sky[0], t), _lerpHex(A.sky[1], B.sky[1], t)],
|
|
147
|
+
wash: [_lerpRgba(A.wash[0], B.wash[0], t), _lerpRgba(A.wash[1], B.wash[1], t), _lerpRgba(A.wash[2], B.wash[2], t)],
|
|
148
|
+
glow: _lerp(A.glow, B.glow, t),
|
|
149
|
+
});
|
|
150
|
+
const _MOOD_KEYS = [[0, MOODS.night], [6, MOODS.night], [8, MOODS.dawn], [10.5, MOODS.day], [16, MOODS.day], [18.5, MOODS.dusk], [20.5, MOODS.night], [24, MOODS.night]];
|
|
151
|
+
export function moodForHour(h) {
|
|
152
|
+
h = ((h % 24) + 24) % 24;
|
|
153
|
+
for (let i = 0; i < _MOOD_KEYS.length - 1; i++) {
|
|
154
|
+
const [h0, m0] = _MOOD_KEYS[i], [h1, m1] = _MOOD_KEYS[i + 1];
|
|
155
|
+
if (h >= h0 && h <= h1) return _lerpMood(m0, m1, (h - h0) / (h1 - h0));
|
|
156
|
+
}
|
|
157
|
+
return MOODS.night;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function drawWall(ctx, bufW, wallH, frame, mood = MOODS.day) {
|
|
161
|
+
// --- plaster wall fills the band from the top ---
|
|
162
|
+
const wy = 0;
|
|
163
|
+
ctx.fillStyle = mood.wall;
|
|
164
|
+
ctx.fillRect(0, wy, bufW, wallH);
|
|
165
|
+
// soft horizontal paneling lines (very faint) for texture
|
|
166
|
+
ctx.fillStyle = 'rgba(150,120,80,0.05)';
|
|
167
|
+
for (let y = wy + 12; y < wy + wallH - 8; y += 10) ctx.fillRect(0, y, bufW, 1);
|
|
168
|
+
// a subtle ceiling shade along the very top so the wall reads as receding
|
|
169
|
+
ctx.fillStyle = 'rgba(80,60,40,0.12)';
|
|
170
|
+
ctx.fillRect(0, wy, bufW, 3);
|
|
171
|
+
ctx.fillStyle = 'rgba(80,60,40,0.05)';
|
|
172
|
+
ctx.fillRect(0, wy + 3, bufW, 2);
|
|
173
|
+
|
|
174
|
+
// --- wall hangings: clock, pictures, glass board, windows ---
|
|
175
|
+
// Reserve the FIXED features' x-ranges first, then tile windows ONLY where they
|
|
176
|
+
// don't collide — so a narrow wall naturally shows fewer windows (down to one on
|
|
177
|
+
// a 1-desk office) and the glass board never lands on top of a window.
|
|
178
|
+
const winY = wy + 14, winW = 26, winH = 32;
|
|
179
|
+
const clockX = Math.round(bufW * 0.46);
|
|
180
|
+
const boardX = Math.round(bufW * 0.5) + 8, boardW = 40;
|
|
181
|
+
const occupied = [
|
|
182
|
+
[8, 38], // framed picture + calendar (upper-left)
|
|
183
|
+
[clockX - 4, clockX + 14], // wall clock
|
|
184
|
+
[boardX - 3, boardX + boardW + 3], // glass board
|
|
185
|
+
];
|
|
186
|
+
for (let x = 40; x < bufW - 36; x += 64) {
|
|
187
|
+
if (occupied.some(([a, b]) => x + winW > a && x < b)) continue; // would overlap a feature
|
|
188
|
+
drawWindow(ctx, x, winY, winW, winH, frame, mood.sky);
|
|
189
|
+
}
|
|
190
|
+
drawClock(ctx, clockX, wy + 16, frame); // wall clock
|
|
191
|
+
drawPicture(ctx, 14, wy + 18); // framed landscape, upper-left
|
|
192
|
+
drawCalendar(ctx, 15, wy + 34); // calendar tucked beneath it
|
|
193
|
+
drawGlassBoard(ctx, boardX, wy + 16); // small green glass whiteboard
|
|
194
|
+
|
|
195
|
+
// --- wood rail + baseboard at the bottom of the wall ---
|
|
196
|
+
ctx.fillStyle = '#b07c44';
|
|
197
|
+
ctx.fillRect(0, wy + wallH - 7, bufW, 4); // wood rail
|
|
198
|
+
ctx.fillStyle = 'rgba(255,255,255,0.18)';
|
|
199
|
+
ctx.fillRect(0, wy + wallH - 7, bufW, 1); // rail highlight
|
|
200
|
+
ctx.fillStyle = '#7d5630';
|
|
201
|
+
ctx.fillRect(0, wy + wallH - 3, bufW, 3); // baseboard
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function drawWindow(ctx, x, y, w, h, frame, sky = ['#7fc0f0', '#a9d8f7']) {
|
|
205
|
+
// frame
|
|
206
|
+
px(ctx, x - 1, y - 1, w + 2, h + 2, '#3a4a63');
|
|
207
|
+
// sky glass with a soft gradient + a diagonal glare streak (tinted by time of day)
|
|
208
|
+
const g = ctx.createLinearGradient(x, y, x, y + h);
|
|
209
|
+
g.addColorStop(0, sky[0]);
|
|
210
|
+
g.addColorStop(1, sky[1]);
|
|
211
|
+
ctx.fillStyle = g;
|
|
212
|
+
ctx.fillRect(x, y, w, h);
|
|
213
|
+
// glare
|
|
214
|
+
ctx.fillStyle = 'rgba(255,255,255,0.35)';
|
|
215
|
+
ctx.fillRect(x + 3, y + 2, 2, h - 4);
|
|
216
|
+
ctx.fillRect(x + 6, y + 2, 1, h - 4);
|
|
217
|
+
// muntins (cross bars)
|
|
218
|
+
px(ctx, x + (w >> 1), y, 1, h, '#3a4a63');
|
|
219
|
+
px(ctx, x, y + (h >> 1), w, 1, '#3a4a63');
|
|
220
|
+
// a tiny potted plant on some sills (deterministic)
|
|
221
|
+
if ((hashInt('sill' + x) & 3) === 0) {
|
|
222
|
+
px(ctx, x + w - 7, y + h - 4, 4, 3, '#b5602e');
|
|
223
|
+
px(ctx, x + w - 6, y + h - 7, 2, 3, '#4cb364');
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function drawClock(ctx, x, y, frame) {
|
|
228
|
+
px(ctx, x - 1, y - 1, 12, 12, '#cdd6e6'); // rim
|
|
229
|
+
px(ctx, x, y, 10, 10, '#fbf7ee'); // face
|
|
230
|
+
px(ctx, x, y, 10, 1, '#e3ddd0');
|
|
231
|
+
// ticks
|
|
232
|
+
ctx.fillStyle = '#9a9488';
|
|
233
|
+
px(ctx, x + 4, y + 1, 1, 1, '#9a9488');
|
|
234
|
+
px(ctx, x + 4, y + 8, 1, 1, '#9a9488');
|
|
235
|
+
px(ctx, x + 1, y + 4, 1, 1, '#9a9488');
|
|
236
|
+
px(ctx, x + 8, y + 4, 1, 1, '#9a9488');
|
|
237
|
+
// hands (hour fixed-ish, minute sweeps slowly)
|
|
238
|
+
const min = (frame * 6) % 360;
|
|
239
|
+
const a = (min * Math.PI) / 180;
|
|
240
|
+
px(ctx, x + 5, y + 5, 1, 1, '#23262e');
|
|
241
|
+
px(ctx, x + 5, y + 2, 1, 3, '#23262e'); // hour up
|
|
242
|
+
px(ctx, x + 5 + Math.round(Math.sin(a) * 3), y + 5 - Math.round(Math.cos(a) * 3), 1, 1, '#c0473a');
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function drawPicture(ctx, x, y) {
|
|
246
|
+
px(ctx, x, y, 16, 12, '#6b4a2e'); // frame
|
|
247
|
+
px(ctx, x + 1, y + 1, 14, 10, '#bfe3f5'); // sky
|
|
248
|
+
px(ctx, x + 1, y + 7, 14, 4, '#e9c986'); // sandy ground
|
|
249
|
+
px(ctx, x + 3, y + 4, 4, 4, '#e7a23a'); // sun-ish
|
|
250
|
+
px(ctx, x + 1, y + 6, 14, 1, '#cda35e');
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function drawCalendar(ctx, x, y) {
|
|
254
|
+
px(ctx, x, y, 13, 12, '#f4f1ea');
|
|
255
|
+
px(ctx, x, y, 13, 3, '#c0473a'); // red header
|
|
256
|
+
ctx.fillStyle = '#b9c0cc';
|
|
257
|
+
for (let r = 0; r < 3; r++)
|
|
258
|
+
for (let c = 0; c < 4; c++) px(ctx, x + 1 + c * 3, y + 4 + r * 3, 2, 2, '#b9c0cc');
|
|
259
|
+
px(ctx, x + 7, y + 7, 2, 2, '#e0855d'); // a marked day
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function drawGlassBoard(ctx, x, y) {
|
|
263
|
+
px(ctx, x - 1, y - 1, 40, 22, '#2e5b4a'); // dark green board
|
|
264
|
+
px(ctx, x, y, 38, 20, '#356a56');
|
|
265
|
+
px(ctx, x, y, 38, 1, 'rgba(255,255,255,0.12)');
|
|
266
|
+
// faint scribbles + a tiny mountain logo (a la NORTHRIDGE STUDIO)
|
|
267
|
+
ctx.fillStyle = 'rgba(255,255,255,0.55)';
|
|
268
|
+
px(ctx, x + 4, y + 4, 6, 1, 'rgba(255,255,255,0.55)');
|
|
269
|
+
px(ctx, x + 4, y + 7, 10, 1, 'rgba(255,255,255,0.35)');
|
|
270
|
+
px(ctx, x + 4, y + 10, 7, 1, 'rgba(255,255,255,0.35)');
|
|
271
|
+
// little chart bars
|
|
272
|
+
ctx.fillStyle = '#8fdcb6';
|
|
273
|
+
px(ctx, x + 24, y + 13, 2, 4, '#8fdcb6');
|
|
274
|
+
px(ctx, x + 27, y + 11, 2, 6, '#8fdcb6');
|
|
275
|
+
px(ctx, x + 30, y + 8, 2, 9, '#8fdcb6');
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ---- the floor -------------------------------------------------------------
|
|
279
|
+
// Warm wood planks — the cozy WeWork / Stardew vibe rather than cold tile.
|
|
280
|
+
export function drawFloor(ctx, bufW, top, bufH) {
|
|
281
|
+
const plank = 14;
|
|
282
|
+
for (let y = top, row = 0; y < bufH; y += plank, row++) {
|
|
283
|
+
ctx.fillStyle = row % 2 ? '#c79a5e' : '#bb8e52'; // alternating board shade
|
|
284
|
+
ctx.fillRect(0, y, bufW, plank);
|
|
285
|
+
ctx.fillStyle = 'rgba(70,40,12,0.28)'; // seam below each board row
|
|
286
|
+
ctx.fillRect(0, y + plank - 1, bufW, 1);
|
|
287
|
+
ctx.fillStyle = 'rgba(255,240,210,0.10)'; // top sheen of each board
|
|
288
|
+
ctx.fillRect(0, y, bufW, 1);
|
|
289
|
+
ctx.fillStyle = 'rgba(70,40,12,0.16)'; // staggered board-end joints
|
|
290
|
+
for (let x = (row % 2 ? 0 : 44); x < bufW; x += 88) ctx.fillRect(x, y, 1, plank - 1);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Soft warm daylight wash from the windows fading toward the front.
|
|
295
|
+
export function drawDaylight(ctx, bufW, top, bufH, mood = MOODS.day) {
|
|
296
|
+
const g = ctx.createLinearGradient(0, top, 0, bufH);
|
|
297
|
+
g.addColorStop(0, mood.wash[0]);
|
|
298
|
+
g.addColorStop(0.5, mood.wash[1]);
|
|
299
|
+
g.addColorStop(1, mood.wash[2]);
|
|
300
|
+
ctx.fillStyle = g;
|
|
301
|
+
ctx.fillRect(0, top, bufW, bufH - top);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ---- a worker ("person at a desk") -----------------------------------------
|
|
305
|
+
// A little front-facing person: hair/skin/shirt vary per seed; hands type when
|
|
306
|
+
// working. Drawn so the head clears the cubicle divider and the body sits behind
|
|
307
|
+
// the desk. (cx, seatY) = centre-x, the chair seat line.
|
|
308
|
+
|
|
309
|
+
// Deterministic per-seed appearance (skin / hair / shirt / style / glasses /
|
|
310
|
+
// beard). Extracted so the seated worker and the standing walker derive the SAME
|
|
311
|
+
// look from a seed — keep the draw ORDER below so existing seeds are unchanged.
|
|
312
|
+
export function personLook(seed) {
|
|
313
|
+
const vr = rng(seed * 2654435761 + 7);
|
|
314
|
+
return {
|
|
315
|
+
skin: SKINS[Math.floor(vr() * SKINS.length)],
|
|
316
|
+
hair: HAIRS[Math.floor(vr() * HAIRS.length)],
|
|
317
|
+
shirt: SHIRTS[Math.floor(vr() * SHIRTS.length)],
|
|
318
|
+
hairStyle: Math.floor(vr() * HAIR_STYLES),
|
|
319
|
+
top: Math.floor(vr() * TOPS),
|
|
320
|
+
glasses: vr() < 0.3,
|
|
321
|
+
beard: vr() < 0.22,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Hair + head + face + neck for one worker, from a personLook(). Shared by
|
|
326
|
+
// drawPerson (seated) and drawWalker (standing) so a worker looks identical either way.
|
|
327
|
+
function drawHeadFace(ctx, cx, hy, look, blink) {
|
|
328
|
+
const { skin, hair, hairStyle, glasses, beard } = look;
|
|
329
|
+
const hx = cx - 6;
|
|
330
|
+
const fy = hy + 4;
|
|
331
|
+
// --- hair style (7 variants). `sides`/`sideH` control the temple hair that
|
|
332
|
+
// frames the face; bald shaves the sides (bare crown). All styles stay flush
|
|
333
|
+
// to the skull — no protruding tufts that read as detached nubs at this size. ---
|
|
334
|
+
let sides = true, sideH = 7;
|
|
335
|
+
switch (hairStyle) {
|
|
336
|
+
case 1: // tall quiff / pompadour
|
|
337
|
+
px(ctx, hx, hy - 2, 12, 8, hair); px(ctx, hx + 3, hy - 4, 6, 3, hair); break;
|
|
338
|
+
case 2: // side part — a parting line cut INTO the cap (not a raised tab)
|
|
339
|
+
px(ctx, hx, hy, 12, 6, hair); px(ctx, hx + 4, hy, 1, 5, shade(hair, -34)); break;
|
|
340
|
+
case 3: // cropped / buzz
|
|
341
|
+
px(ctx, hx + 1, hy + 1, 10, 4, hair); break;
|
|
342
|
+
case 4: // long — frames the face down past the jaw
|
|
343
|
+
px(ctx, hx, hy, 12, 6, hair); sideH = 14; break;
|
|
344
|
+
case 5: // bald — bare crown, no side hair
|
|
345
|
+
px(ctx, hx + 1, hy + 1, 10, 4, skin);
|
|
346
|
+
px(ctx, hx + 2, hy + 1, 6, 1, shade(skin, 18)); // pate shine
|
|
347
|
+
sides = false; break;
|
|
348
|
+
case 6: // afro / curls — a big rounded cap
|
|
349
|
+
px(ctx, hx - 1, hy - 2, 14, 8, hair); px(ctx, hx, hy - 3, 12, 2, hair); sideH = 9; break;
|
|
350
|
+
default: // 0 classic
|
|
351
|
+
px(ctx, hx, hy, 12, 6, hair);
|
|
352
|
+
}
|
|
353
|
+
if (sides) {
|
|
354
|
+
px(ctx, hx - 1, hy + 4, 2, sideH, hair); // sides
|
|
355
|
+
px(ctx, hx + 11, hy + 4, 2, sideH, hair);
|
|
356
|
+
}
|
|
357
|
+
px(ctx, cx - 5, fy, 11, 10, skin);
|
|
358
|
+
px(ctx, cx - 5, fy, 11, 2, shade(skin, 16)); // forehead light
|
|
359
|
+
if (blink) {
|
|
360
|
+
px(ctx, cx - 3, fy + 5, 2, 1, shade(skin, -40));
|
|
361
|
+
px(ctx, cx + 2, fy + 5, 2, 1, shade(skin, -40));
|
|
362
|
+
} else {
|
|
363
|
+
px(ctx, cx - 3, fy + 4, 2, 2, '#1b1b22');
|
|
364
|
+
px(ctx, cx + 2, fy + 4, 2, 2, '#1b1b22');
|
|
365
|
+
}
|
|
366
|
+
if (glasses) {
|
|
367
|
+
px(ctx, cx - 4, fy + 3, 4, 4, '#23262e');
|
|
368
|
+
px(ctx, cx + 1, fy + 3, 4, 4, '#23262e');
|
|
369
|
+
px(ctx, cx - 3, fy + 4, 2, 2, blink ? skin : '#1b1b22');
|
|
370
|
+
px(ctx, cx + 2, fy + 4, 2, 2, blink ? skin : '#1b1b22');
|
|
371
|
+
px(ctx, cx, fy + 4, 1, 1, '#23262e'); // bridge
|
|
372
|
+
}
|
|
373
|
+
if (beard) px(ctx, cx - 5, fy + 7, 11, 3, shade(skin, -48));
|
|
374
|
+
px(ctx, cx - 1, fy + 8, 3, 1, shade(skin, -30)); // mouth
|
|
375
|
+
px(ctx, cx - 1, fy + 10, 3, 2, shade(skin, -14)); // neck
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Torso + clothing for one worker, from a personLook(). Shared by drawPerson
|
|
379
|
+
// (seated) and drawWalker (standing) so the outfit matches in both. (cx, ty) =
|
|
380
|
+
// centre-x and the shoulder line. Arms/legs are drawn by the caller; this is
|
|
381
|
+
// just the shirt block + a per-`top` clothing detail.
|
|
382
|
+
function drawTorso(ctx, cx, ty, look) {
|
|
383
|
+
const s = look.shirt;
|
|
384
|
+
px(ctx, cx - 5, ty, 11, 3, s); // shoulders (tapered in)
|
|
385
|
+
px(ctx, cx - 6, ty + 3, 13, 8, s); // torso body
|
|
386
|
+
px(ctx, cx + 3, ty + 3, 4, 8, shade(s, -26)); // right-side shade
|
|
387
|
+
switch (look.top) {
|
|
388
|
+
case 1: // hoodie — neck roll, kangaroo pocket, drawstrings
|
|
389
|
+
px(ctx, cx - 5, ty, 11, 2, shade(s, -34));
|
|
390
|
+
px(ctx, cx - 3, ty + 6, 7, 3, shade(s, -30));
|
|
391
|
+
px(ctx, cx - 3, ty + 6, 7, 1, shade(s, -14));
|
|
392
|
+
px(ctx, cx - 1, ty + 2, 1, 4, '#eee8dc');
|
|
393
|
+
px(ctx, cx + 1, ty + 2, 1, 4, '#eee8dc');
|
|
394
|
+
break;
|
|
395
|
+
case 2: // button-up / collared shirt
|
|
396
|
+
px(ctx, cx - 3, ty, 3, 2, shade(s, 20)); // collar
|
|
397
|
+
px(ctx, cx + 1, ty, 3, 2, shade(s, 20));
|
|
398
|
+
px(ctx, cx - 1, ty, 2, 11, shade(s, 14)); // placket
|
|
399
|
+
for (let i = 0; i < 4; i++) px(ctx, cx, ty + 2 + i * 2, 1, 1, shade(s, -52)); // buttons
|
|
400
|
+
break;
|
|
401
|
+
case 3: // crewneck sweater — ribbed collar + contrast chest band
|
|
402
|
+
px(ctx, cx - 4, ty, 9, 2, shade(s, 24));
|
|
403
|
+
px(ctx, cx - 6, ty + 6, 13, 2, shade(s, -22));
|
|
404
|
+
break;
|
|
405
|
+
case 4: // blazer over a tee — lapels + a lighter inner panel
|
|
406
|
+
px(ctx, cx - 2, ty + 1, 5, 10, shade(s, 70));
|
|
407
|
+
px(ctx, cx - 5, ty, 3, 6, shade(s, -22));
|
|
408
|
+
px(ctx, cx + 3, ty, 3, 6, shade(s, -22));
|
|
409
|
+
px(ctx, cx + 3, ty + 3, 4, 8, shade(s, -36));
|
|
410
|
+
break;
|
|
411
|
+
case 5: // striped tee
|
|
412
|
+
px(ctx, cx - 6, ty + 4, 13, 1, shade(s, -40));
|
|
413
|
+
px(ctx, cx - 6, ty + 7, 13, 1, shade(s, -40));
|
|
414
|
+
px(ctx, cx - 6, ty + 10, 13, 1, shade(s, -40));
|
|
415
|
+
px(ctx, cx - 1, ty, 2, 4, shade(s, 18)); // small collar
|
|
416
|
+
break;
|
|
417
|
+
default: // 0 plain tee
|
|
418
|
+
px(ctx, cx - 1, ty, 2, 11, shade(s, 18)); // collar / placket
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// A staggered idle "fidget": every ~FIDGET_PERIOD frames an idling worker briefly
|
|
423
|
+
// performs one of three small actions, so a roomful never moves in lockstep.
|
|
424
|
+
// Returns { kind 0..2, t:0..1 progress } while one plays, else null. Deterministic
|
|
425
|
+
// from seed+frame (frame advances ~7.7×/s, so a fidget runs ~3s every ~15s).
|
|
426
|
+
const FIDGET_PERIOD = 116, FIDGET_LEN = 22;
|
|
427
|
+
function idleFidget(seed, frame) {
|
|
428
|
+
const ph = (((frame + seed * 37) % FIDGET_PERIOD) + FIDGET_PERIOD) % FIDGET_PERIOD;
|
|
429
|
+
if (ph >= FIDGET_LEN) return null;
|
|
430
|
+
const kind = Math.floor((frame + seed * 37) / FIDGET_PERIOD) % 3;
|
|
431
|
+
return { kind, t: ph / FIDGET_LEN };
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
export function drawPerson(ctx, cx, headTopY, opts) {
|
|
435
|
+
const { seed = 1, activity = 'idle', frame = 0, unread = false } = opts;
|
|
436
|
+
const look = opts.look || personLook(seed);
|
|
437
|
+
const { skin, shirt } = look;
|
|
438
|
+
|
|
439
|
+
const typing = activity === 'working';
|
|
440
|
+
const shellRunning = activity === 'shell';
|
|
441
|
+
const idle = !typing && !shellRunning;
|
|
442
|
+
const fp = frame + (seed % 6);
|
|
443
|
+
// Vertical body bob. WORKING dips DOWN on the typing cadence (leaning in);
|
|
444
|
+
// shell/just-finished "breathe" up on a quick cadence; settled-idle breathes
|
|
445
|
+
// calmly on a slow one AND throws an occasional fidget (sip / stretch / lean).
|
|
446
|
+
const fidget = idle && !unread ? idleFidget(seed, frame) : null;
|
|
447
|
+
const breath = (shellRunning || unread) && fp % 8 < 4 ? 1 : 0;
|
|
448
|
+
const idleBreath = idle && !unread && fp % 16 < 8 ? 1 : 0;
|
|
449
|
+
const typeBob = typing && frame % 4 < 2 ? 1 : 0; // dips DOWN, not up
|
|
450
|
+
const hy = headTopY - breath - idleBreath + typeBob;
|
|
451
|
+
const blink = (frame + (seed % 11)) % 13 === 0;
|
|
452
|
+
const fy = hy + 4;
|
|
453
|
+
|
|
454
|
+
// hair + head + face + neck (shared with the standing drawWalker)
|
|
455
|
+
drawHeadFace(ctx, cx, hy, look, blink);
|
|
456
|
+
|
|
457
|
+
// --- torso (shirt) — slimmed so the worker reads as a compact little person.
|
|
458
|
+
// (The desk hides the lower half on a seated worker.)
|
|
459
|
+
const ty = fy + 12; // carries the breath/typeBob shift via hy
|
|
460
|
+
drawTorso(ctx, cx, ty, look); // shirt + per-`top` clothing
|
|
461
|
+
const arm = shade(shirt, -12);
|
|
462
|
+
// arms. UNREAD waves; a settled-idle worker fidgets; a working one strikes the
|
|
463
|
+
// keys; otherwise the arms rest at the desk.
|
|
464
|
+
if (unread && !typing && !shellRunning) {
|
|
465
|
+
const wig = frame % 4 < 2 ? 0 : 2; // slow side-to-side hand wiggle (~2 Hz)
|
|
466
|
+
px(ctx, cx - 8, ty + 3, 2, 7, arm); // left arm down
|
|
467
|
+
px(ctx, cx - 8, ty + 10, 2, 2, skin); // left hand at desk
|
|
468
|
+
px(ctx, cx + 6, ty, 2, 5, arm); // right upper arm, raised
|
|
469
|
+
px(ctx, cx + 7, ty - 5, 2, 6, arm); // right forearm up
|
|
470
|
+
px(ctx, cx + 6 + wig, ty - 9, 3, 3, skin); // waving hand
|
|
471
|
+
} else if (fidget && fidget.kind === 0) { // sip a coffee — hand to the mouth
|
|
472
|
+
px(ctx, cx - 8, ty + 3, 2, 7, arm); px(ctx, cx - 8, ty + 10, 2, 2, skin); // left rests
|
|
473
|
+
px(ctx, cx + 7, ty + 2, 2, 4, arm); // right upper arm
|
|
474
|
+
px(ctx, cx + 3, ty - 1, 2, 4, arm); // forearm angled toward the face
|
|
475
|
+
px(ctx, cx + 1, fy + 7, 4, 4, '#e4ded3'); // mug at the mouth
|
|
476
|
+
px(ctx, cx + 1, fy + 7, 4, 1, '#f2ede4');
|
|
477
|
+
px(ctx, cx + 5, fy + 8, 1, 2, '#c4bdb2'); // handle
|
|
478
|
+
if (frame % 4 < 2) px(ctx, cx + 2, fy + 4, 1, 1, '#b8bdc8'); // steam
|
|
479
|
+
} else if (fidget && fidget.kind === 1) { // stretch — both arms reach up & ease back
|
|
480
|
+
const up = Math.round(Math.sin(fidget.t * Math.PI) * 5);
|
|
481
|
+
px(ctx, cx - 8, ty + 1 - up, 2, 6, arm); px(ctx, cx - 8, ty - 4 - up, 2, 2, skin);
|
|
482
|
+
px(ctx, cx + 7, ty + 1 - up, 2, 6, arm); px(ctx, cx + 7, ty - 4 - up, 2, 2, skin);
|
|
483
|
+
} else if (fidget && fidget.kind === 2) { // lean back — elbows winged out, hands behind head
|
|
484
|
+
px(ctx, cx - 9, ty + 1, 2, 4, arm); px(ctx, cx - 9, ty - 2, 4, 2, arm);
|
|
485
|
+
px(ctx, cx + 7, ty + 1, 2, 4, arm); px(ctx, cx + 6, ty - 2, 4, 2, arm);
|
|
486
|
+
} else {
|
|
487
|
+
// typing strikes alternate L/R; at rest the arms simply hang at the desk.
|
|
488
|
+
const lh = typing && frame % 2 ? 2 : 0;
|
|
489
|
+
const rh = typing && frame % 2 ? 0 : 2;
|
|
490
|
+
px(ctx, cx - 8, ty + 3, 2, 7 - lh, arm); // left arm (raises with its hand)
|
|
491
|
+
px(ctx, cx + 7, ty + 3, 2, 7 - rh, arm); // right arm (mirror about cx)
|
|
492
|
+
px(ctx, cx - 8, ty + 10 - lh, 2, 2, skin); // left hand
|
|
493
|
+
px(ctx, cx + 7, ty + 10 - rh, 2, 2, skin); // right hand
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// A STANDING / walking worker — the same person as drawPerson, on its feet, for
|
|
498
|
+
// the idle-wander floor life. (cx, feetY) = centre-x and the floor line at the
|
|
499
|
+
// feet. Front-facing (no flip); `walking` strides the legs and bobs the body.
|
|
500
|
+
export function drawWalker(ctx, cx, feetY, opts = {}) {
|
|
501
|
+
const { seed = 1, frame = 0, walking = false } = opts;
|
|
502
|
+
const look = opts.look || personLook(seed);
|
|
503
|
+
const { skin, shirt } = look;
|
|
504
|
+
const blink = (frame + (seed % 11)) % 13 === 0;
|
|
505
|
+
// soft pixel ground shadow for grounding
|
|
506
|
+
px(ctx, cx - 6, feetY, 12, 1, 'rgba(0,0,0,0.20)');
|
|
507
|
+
px(ctx, cx - 4, feetY + 1, 8, 1, 'rgba(0,0,0,0.12)');
|
|
508
|
+
const bob = walking && frame % 4 < 2 ? 1 : 0; // gentle walk bob
|
|
509
|
+
const idleRise = !walking && (frame + seed * 3) % 18 < 9 ? 1 : 0; // calm standing breath
|
|
510
|
+
const ty = feetY - 19 - bob - idleRise; // torso top
|
|
511
|
+
const hy = ty - 16; // head top (neck lands back at ty)
|
|
512
|
+
drawHeadFace(ctx, cx, hy, look, blink);
|
|
513
|
+
// torso (shirt) — shared with the seated worker so the outfit matches
|
|
514
|
+
drawTorso(ctx, cx, ty, look);
|
|
515
|
+
const arm = shade(shirt, -12);
|
|
516
|
+
// arms — swing opposite the legs while walking, fidget while idling (stretch /
|
|
517
|
+
// check a phone), else hang. Idle fidgets are seed-staggered so wanderers don't sync.
|
|
518
|
+
const fidget = !walking ? idleFidget(seed, frame) : null;
|
|
519
|
+
if (fidget && fidget.kind === 1) { // stretch up
|
|
520
|
+
const up = Math.round(Math.sin(fidget.t * Math.PI) * 5);
|
|
521
|
+
px(ctx, cx - 8, ty + 1 - up, 2, 6, arm); px(ctx, cx - 8, ty - 4 - up, 2, 2, skin);
|
|
522
|
+
px(ctx, cx + 7, ty + 1 - up, 2, 6, arm); px(ctx, cx + 7, ty - 4 - up, 2, 2, skin);
|
|
523
|
+
} else if (fidget && fidget.kind === 0) { // check a phone — hands meet in front, screen glows
|
|
524
|
+
px(ctx, cx - 6, ty + 4, 2, 4, arm); px(ctx, cx + 5, ty + 4, 2, 4, arm); // forearms angled in
|
|
525
|
+
px(ctx, cx - 4, ty + 7, 8, 2, skin); // hands at the belly
|
|
526
|
+
px(ctx, cx - 2, ty + 6, 5, 2, '#2a2f3a'); // the phone
|
|
527
|
+
px(ctx, cx - 2, ty + 6, 5, 1, '#5cd0ff'); // its glow
|
|
528
|
+
} else { // hang; hands swing ±1 opposite while walking
|
|
529
|
+
const sw = walking ? (frame % 4 < 2 ? 1 : -1) : 0;
|
|
530
|
+
px(ctx, cx - 8, ty + 3, 2, 7, arm); px(ctx, cx - 8, ty + 10 + sw, 2, 2, skin);
|
|
531
|
+
px(ctx, cx + 7, ty + 3, 2, 7, arm); px(ctx, cx + 7, ty + 10 - sw, 2, 2, skin);
|
|
532
|
+
}
|
|
533
|
+
// legs (pants, darker than the shirt) + an alternating walk stride
|
|
534
|
+
const pants = shade(shirt, -55);
|
|
535
|
+
const legTop = ty + 11, legH = feetY - legTop;
|
|
536
|
+
const lLift = walking && frame % 4 < 2 ? 1 : 0;
|
|
537
|
+
const rLift = walking && frame % 4 < 2 ? 0 : 1;
|
|
538
|
+
px(ctx, cx - 4, legTop + lLift, 3, legH - lLift, pants);
|
|
539
|
+
px(ctx, cx + 1, legTop + rLift, 3, legH - rLift, pants);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// ---- pets: a wandering cat + a lounging dog -------------------------------
|
|
543
|
+
// Pure procedural sprites (office.js owns their roaming STATE and passes it in as
|
|
544
|
+
// opts). Both animate off `frame`; the cat additionally flips to face its heading.
|
|
545
|
+
// (cx of the cat art is ~x+5; the dog faces right and doesn't flip.)
|
|
546
|
+
export function drawCat(ctx, x, y, frame, dir = 1, opts = {}) {
|
|
547
|
+
x = Math.round(x); y = Math.round(y);
|
|
548
|
+
const { sleeping = false, petted = false, walking = false } = opts;
|
|
549
|
+
const body = '#23252c';
|
|
550
|
+
const flip = dir < 0; // mirror about the body centre so it walks the way it heads
|
|
551
|
+
if (flip) { ctx.save(); ctx.translate((x + 5) * 2, 0); ctx.scale(-1, 1); }
|
|
552
|
+
if (sleeping) {
|
|
553
|
+
// curled up, eyes shut, slow breathing; a "z" drifts above.
|
|
554
|
+
const br = frame % 16 < 8 ? 0 : 1;
|
|
555
|
+
px(ctx, x, y - 4 - br, 11, 4 + br, body); // curled body
|
|
556
|
+
px(ctx, x + 7, y - 5 - br, 5, 4, body); // tucked head
|
|
557
|
+
px(ctx, x + 8, y - 7 - br, 2, 2, body); // folded ear
|
|
558
|
+
px(ctx, x + 8, y - 3 - br, 2, 1, '#3a3d45'); // closed eye
|
|
559
|
+
px(ctx, x - 1, y - 1, 6, 1, body); // tail wrapped round the front
|
|
560
|
+
const zz = '#9aa3b5', zy = y - 11 - (frame % 8 < 4 ? 0 : 1); // "z" bobs
|
|
561
|
+
px(ctx, x + 12, zy, 3, 1, zz); px(ctx, x + 13, zy + 1, 1, 1, zz); px(ctx, x + 12, zy + 2, 3, 1, zz);
|
|
562
|
+
} else {
|
|
563
|
+
// sitting / walking. Idle cats periodically groom (head dips, a pink tongue)
|
|
564
|
+
// and flick an ear; walking cats bob and shuffle their paws.
|
|
565
|
+
const grooming = !petted && !walking && (frame + 3) % 70 < 12;
|
|
566
|
+
const earTwitch = !petted && !grooming && frame % 44 < 2;
|
|
567
|
+
const bob = walking && frame % 4 < 2 ? 1 : 0, by = y - bob;
|
|
568
|
+
const hd = grooming ? 2 : 0; // head dips to groom
|
|
569
|
+
px(ctx, x, by - 6, 8, 6, body); // body
|
|
570
|
+
px(ctx, x + 6, by - 11 + hd, 6, 6, body); // head
|
|
571
|
+
px(ctx, x + 6, by - 13 + hd - (earTwitch ? 1 : 0), 2, 3 + (earTwitch ? 1 : 0), body); // left ear (twitches)
|
|
572
|
+
px(ctx, x + 10, by - 13 + hd, 2, 3, body); // right ear
|
|
573
|
+
if (grooming) {
|
|
574
|
+
px(ctx, x + 8, by - 8, 1, 1, '#3a3d45'); // eye lowered while licking
|
|
575
|
+
px(ctx, x + 8, by - 5, 1, 1, '#ff9db0'); // tongue at the paw
|
|
576
|
+
} else {
|
|
577
|
+
px(ctx, x + 8, by - 9, 1, 1, '#6cff9a'); px(ctx, x + 10, by - 9, 1, 1, '#6cff9a'); // eyes
|
|
578
|
+
}
|
|
579
|
+
const tail = frame % 8 < 4 ? 0 : 1;
|
|
580
|
+
if (petted) px(ctx, x - 2, by - 12, 2, 7, body); // happy upright tail
|
|
581
|
+
else if (walking) px(ctx, x - 3, by - 7 - tail, 3, 2, body); // tail trails behind
|
|
582
|
+
else px(ctx, x - 2, by - 8 - tail, 2, 6, body); // lazy tail flick
|
|
583
|
+
const step = walking && frame % 4 < 2 ? 1 : 0; // paw shuffle
|
|
584
|
+
px(ctx, x + 1, by - step, 1, 1, body); px(ctx, x + 5, by - (walking ? 1 - step : 0), 1, 1, body);
|
|
585
|
+
}
|
|
586
|
+
if (flip) ctx.restore();
|
|
587
|
+
if (petted) { // hearts float up (drawn UNflipped so they read upright)
|
|
588
|
+
const hx = x + 3, hy = y - 16 - (frame % 6), pink = '#ff6b9d';
|
|
589
|
+
px(ctx, hx, hy, 1, 1, pink); px(ctx, hx + 2, hy, 1, 1, pink);
|
|
590
|
+
px(ctx, hx - 1, hy + 1, 5, 1, pink); px(ctx, hx, hy + 2, 3, 1, pink); px(ctx, hx + 1, hy + 3, 1, 1, pink);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
export function drawDog(ctx, x, y, opts = {}) {
|
|
595
|
+
x = Math.round(x); y = Math.round(y);
|
|
596
|
+
const { frame = 0, petted = false } = opts;
|
|
597
|
+
const fur = '#d98a4a', dk = '#b5702e';
|
|
598
|
+
// periodic behaviours on offset cadences so they don't coincide
|
|
599
|
+
const yawning = !petted && (frame + 5) % 104 < 10; // a slow yawn
|
|
600
|
+
const panting = petted || frame % 64 < 20; // tongue out (always while petted)
|
|
601
|
+
const earPerk = (frame + 7) % 52 < 6; // ear pricks up, alert
|
|
602
|
+
const lookDn = (frame + 11) % 86 < 8 ? 1 : 0; // glances down/around
|
|
603
|
+
const headBob = petted && frame % 4 < 2 ? 1 : 0; // happy head bob when petted
|
|
604
|
+
px(ctx, x, y - 5, 12, 5, fur); // body
|
|
605
|
+
const hy = y - 9 - headBob;
|
|
606
|
+
px(ctx, x + 10, hy, 6, 6, fur); // head
|
|
607
|
+
px(ctx, x + 9, hy - 1 - (earPerk ? 1 : 0), 2, 4 + (earPerk ? 1 : 0), dk); // ear (perks)
|
|
608
|
+
px(ctx, x + 14, hy + 3 + lookDn, 1, 1, '#1b1b22'); // eye (drifts when looking around)
|
|
609
|
+
px(ctx, x + 16, hy + 4, 2, 1, '#1b1b22'); // snout
|
|
610
|
+
if (yawning) { // open mouth + a bit of tongue
|
|
611
|
+
px(ctx, x + 15, hy + 5, 3, 2, '#3a1f16'); px(ctx, x + 16, hy + 6, 1, 1, '#ff9db0');
|
|
612
|
+
} else if (panting) {
|
|
613
|
+
const t = frame % 8 < 4 ? 1 : 0; px(ctx, x + 16, hy + 5 + t, 2, 2 - t, '#ff7a93'); // lolling tongue
|
|
614
|
+
}
|
|
615
|
+
const wag = petted ? (frame % 4 < 2 ? 0 : 2) : (frame % 8 < 4 ? 0 : 1);
|
|
616
|
+
px(ctx, x - 3, y - 6 - wag, 4, 2, '#e09a5a'); // wagging tail
|
|
617
|
+
px(ctx, x + 8, y - 5, 4, 3, '#fff'); // white belly patch
|
|
618
|
+
px(ctx, x + 1, y, 1, 1, dk); px(ctx, x + 9, y, 1, 1, dk); // paws
|
|
619
|
+
if (petted) {
|
|
620
|
+
const hx = x + 12, hy2 = y - 16 - (frame % 6), pink = '#ff6b9d';
|
|
621
|
+
px(ctx, hx, hy2, 1, 1, pink); px(ctx, hx + 2, hy2, 1, 1, pink);
|
|
622
|
+
px(ctx, hx - 1, hy2 + 1, 5, 1, pink); px(ctx, hx, hy2 + 2, 3, 1, pink); px(ctx, hx + 1, hy2 + 3, 1, 1, pink);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// ---- a desk workstation ----------------------------------------------------
|
|
627
|
+
// A clean open desk (NO enclosing cubicle frame): a wood desk surface, a glowing
|
|
628
|
+
// tier-colored monitor + keyboard, a country/flag pin, a sticky-note cluster, a
|
|
629
|
+
// desk plant or mug, and the seated person. Everything procedural. The desk sits
|
|
630
|
+
// directly on its team rug (drawn by the office), reading like open team pods
|
|
631
|
+
// rather than boxed cubicles.
|
|
632
|
+
// (x, y) = top-left of the cell; CW/CH = cell size.
|
|
633
|
+
export const CELL_W = 58;
|
|
634
|
+
export const CELL_H = 78;
|
|
635
|
+
const DESK_TOP = 52; // desk surface y within the cell
|
|
636
|
+
const DIV_W = 4; // legacy inset used to position desk props/flag
|
|
637
|
+
|
|
638
|
+
export function drawCubicle(ctx, x, y, agent, frame, selected, hovered, unread = false, away = false) {
|
|
639
|
+
const tier = tierFor(agent.model);
|
|
640
|
+
const seed = (agent.pid | 0) || hashInt(agent.sessionId || 'a');
|
|
641
|
+
const cx = x + CELL_W / 2;
|
|
642
|
+
// --- the seated worker + their personal props. Idle no longer desaturates: the
|
|
643
|
+
// OFF (dark) monitor + grey status dot already read as idle, so the worker keeps
|
|
644
|
+
// full color. `away` (idle-wander): worker has left its desk → draw it empty.
|
|
645
|
+
if (!away) drawPerson(ctx, cx - 6, y + DESK_TOP - 27, { seed, activity: agent.activity, frame, unread });
|
|
646
|
+
// a pinned flag + sticky-note cluster (personalization)
|
|
647
|
+
drawPin(ctx, cx - 22, y + DESK_TOP - 20, seed);
|
|
648
|
+
drawStickies(ctx, cx + 14, y + DESK_TOP - 20, seed);
|
|
649
|
+
|
|
650
|
+
// --- wood desk surface across the cell ---
|
|
651
|
+
const dx = x + DIV_W, dw = CELL_W - DIV_W * 2;
|
|
652
|
+
px(ctx, dx, y + DESK_TOP, dw, 8, '#9c6f44'); // desk slab
|
|
653
|
+
px(ctx, dx, y + DESK_TOP, dw, 2, '#b3855a'); // top light
|
|
654
|
+
px(ctx, dx, y + DESK_TOP + 8, dw, 3, '#6f4a2a'); // front edge
|
|
655
|
+
|
|
656
|
+
// --- monitor (tier-colored screen + activity content) on the right ---
|
|
657
|
+
drawMonitor(ctx, x + CELL_W - 24, y + DESK_TOP - 18, tier, agent, seed, frame);
|
|
658
|
+
// keyboard in front of the worker
|
|
659
|
+
px(ctx, cx - 9, y + DESK_TOP + 1, 16, 3, '#1c2029');
|
|
660
|
+
px(ctx, cx - 8, y + DESK_TOP + 1, 14, 1, '#2a3340');
|
|
661
|
+
// a desk mug or tiny plant on the left
|
|
662
|
+
drawDeskProp(ctx, x + DIV_W + 3, y + DESK_TOP, seed, frame);
|
|
663
|
+
|
|
664
|
+
// --- status LED on the desk ---
|
|
665
|
+
const led = ledColor(agent.activity, frame);
|
|
666
|
+
px(ctx, dx + 2, y + DESK_TOP + 3, 3, 3, led);
|
|
667
|
+
// soft monitor glow over the cell while active
|
|
668
|
+
if (agent.activity === 'working' || agent.activity === 'shell') {
|
|
669
|
+
ctx.fillStyle = tier.glow;
|
|
670
|
+
ctx.fillRect(x + CELL_W - 30, y + DESK_TOP - 22, 28, 24);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// --- subagents: tiny helpers gathered in front of the desk. Kept ABOVE the
|
|
674
|
+
// name chip (its top sits at CELL_H-10) so the row never peeks out behind the
|
|
675
|
+
// nameplate as a clipped teal band. ---
|
|
676
|
+
const subs = agent.subagents || [];
|
|
677
|
+
if (subs.length) {
|
|
678
|
+
const n = Math.min(subs.length, 4), sp = 9;
|
|
679
|
+
const sx = Math.round(x + (CELL_W - n * sp) / 2);
|
|
680
|
+
for (let i = 0; i < n; i++) drawMinion(ctx, sx + i * sp, y + CELL_H - 13, (frame + i) % 2);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// --- selection / hover ring --- (selected = persistent tier-colored pulse;
|
|
684
|
+
// hover = cyan #5cd0ff)
|
|
685
|
+
if (selected || hovered) drawRing(ctx, x, y, CELL_W, CELL_H, selected ? tier.led : '#5cd0ff', frame, selected);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function ledColor(act, frame) {
|
|
689
|
+
if (act === 'working') return frame % 2 ? '#39d98a' : '#1f7d52';
|
|
690
|
+
if (act === 'shell') return frame % 2 ? '#ffb454' : '#9c6a1f';
|
|
691
|
+
return '#5a6478';
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function drawMonitor(ctx, x, y, tier, agent, seed, frame) {
|
|
695
|
+
const mw = 20, mh = 15;
|
|
696
|
+
px(ctx, x - 1, y - 1, mw + 2, mh + 2, '#15181f'); // bezel
|
|
697
|
+
px(ctx, x, y, mw, mh, '#0d0f14');
|
|
698
|
+
const typing = agent.activity === 'working';
|
|
699
|
+
const shellRunning = agent.activity === 'shell';
|
|
700
|
+
// The lit inner glass. (sx, sy) = top-left of usable pixels, sw × sh = its size.
|
|
701
|
+
const sx = x + 1, sy = y + 1, sw = mw - 2, sh = mh - 2;
|
|
702
|
+
if (typing) {
|
|
703
|
+
px(ctx, sx, sy, sw, sh, shade(tier.screen, -178)); // deep tier-tinted glass
|
|
704
|
+
// Deterministic content style per agent (a given agent always shows the same
|
|
705
|
+
// app): code editor, terminal, diff, or a tiny dashboard.
|
|
706
|
+
const style = shellRunning ? 1 : hashInt('mon' + seed) % 4;
|
|
707
|
+
if (style === 0) drawScreenEditor(ctx, sx, sy, sw, sh, tier, seed, frame);
|
|
708
|
+
else if (style === 1) drawScreenTerminal(ctx, sx, sy, sw, sh, tier, seed, frame);
|
|
709
|
+
else if (style === 2) drawScreenDiff(ctx, sx, sy, sw, sh, tier, seed, frame);
|
|
710
|
+
else drawScreenChart(ctx, sx, sy, sw, sh, tier, seed, frame);
|
|
711
|
+
} else if (shellRunning) {
|
|
712
|
+
px(ctx, sx, sy, sw, sh, '#0b130d'); // shell → dark green glass
|
|
713
|
+
drawScreenTerminal(ctx, sx, sy, sw, sh, tier, seed, frame);
|
|
714
|
+
} else { // idle: screen OFF — dark glass + a faint reflection so it reads "off"
|
|
715
|
+
px(ctx, sx, sy, sw, sh, '#0b0e14');
|
|
716
|
+
px(ctx, x + 2, y + 2, 1, mh - 4, 'rgba(255,255,255,0.06)');
|
|
717
|
+
px(ctx, x + 4, y + 3, 1, mh - 6, 'rgba(255,255,255,0.03)');
|
|
718
|
+
}
|
|
719
|
+
// stand
|
|
720
|
+
px(ctx, x + (mw >> 1) - 1, y + mh, 3, 3, '#3a4150');
|
|
721
|
+
px(ctx, x + (mw >> 1) - 4, y + mh + 3, 9, 2, '#2b313e');
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// --- monitor screen content -------------------------------------------------
|
|
725
|
+
// All four renderers fill the inner glass (sx, sy, sw, sh) and never spill past
|
|
726
|
+
// it. They lay out on a fixed 2px line grid so rows never overlap, vary length /
|
|
727
|
+
// color / indent deterministically from `seed`, and animate gently via `frame`.
|
|
728
|
+
// `LH` = line height (1px text + 1px gap); the syntax palette mixes tier.code
|
|
729
|
+
// with muted accents so a row of code reads as keywords/strings/idents.
|
|
730
|
+
|
|
731
|
+
const LH = 2; // px per text line (1px glyph + 1px gap)
|
|
732
|
+
|
|
733
|
+
// A 1px window-chrome title strip across the top of an app screen: a slightly
|
|
734
|
+
// lighter bar with two tiny "window dots", so editor/terminal/diff read as real
|
|
735
|
+
// app windows. Returns the y where content should begin (1px below the bar).
|
|
736
|
+
function drawScreenChrome(ctx, sx, sy, sw, tier) {
|
|
737
|
+
px(ctx, sx, sy, sw, 1, 'rgba(255,255,255,0.10)'); // title bar
|
|
738
|
+
px(ctx, sx + 1, sy, 1, 1, shade(tier.screen, 30)); // left "tab/dot"
|
|
739
|
+
px(ctx, sx + 3, sy, 1, 1, 'rgba(255,255,255,0.28)');
|
|
740
|
+
return sy + 1; // content starts on the next row
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// A believable code editor: a title bar, a gutter of line numbers, then indented
|
|
744
|
+
// code lines whose token runs alternate color (keyword / ident / string), plus a
|
|
745
|
+
// caret on the active line that blinks and steps down a line every few frames.
|
|
746
|
+
function drawScreenEditor(ctx, sx, sy, sw, sh, tier, seed, frame) {
|
|
747
|
+
const kw = shade(tier.screen, 50); // "keyword" — bright tier hue
|
|
748
|
+
const ident = tier.code; // identifiers — light text
|
|
749
|
+
const str = '#e0a35d'; // strings — warm
|
|
750
|
+
const gut = 'rgba(255,255,255,0.20)'; // gutter line numbers
|
|
751
|
+
const cy0 = drawScreenChrome(ctx, sx, sy, sw, tier);
|
|
752
|
+
const rows = Math.floor((sy + sh - cy0) / LH);
|
|
753
|
+
const gw = 3; // gutter width (room for a 2px digit)
|
|
754
|
+
// gutter background tick marks (one faint 1px "line number" per row)
|
|
755
|
+
for (let i = 0; i < rows; i++) px(ctx, sx + 1, cy0 + i * LH, 1, 1, gut);
|
|
756
|
+
// a slow vertical scroll so new code drifts up over time
|
|
757
|
+
const scroll = Math.floor(frame / 24);
|
|
758
|
+
const active = (frame >> 1) % rows; // the line the caret sits on
|
|
759
|
+
let caretX = sx + gw + 1;
|
|
760
|
+
for (let i = 0; i < rows; i++) {
|
|
761
|
+
const rr = rng(seed * 17 + (i + scroll) * 131 + 7);
|
|
762
|
+
const indent = (Math.floor(rr() * 3)) * 2; // 0 / 2 / 4 px indent
|
|
763
|
+
let cx = sx + gw + 1 + indent;
|
|
764
|
+
const lineRight = sx + sw - 1;
|
|
765
|
+
// 1–3 token runs of alternating color across the row
|
|
766
|
+
const tokens = 1 + Math.floor(rr() * 3);
|
|
767
|
+
const palette = [kw, ident, str];
|
|
768
|
+
for (let t = 0; t < tokens && cx < lineRight; t++) {
|
|
769
|
+
const tw = 2 + Math.floor(rr() * 4);
|
|
770
|
+
const w = Math.min(tw, lineRight - cx);
|
|
771
|
+
if (w > 0) px(ctx, cx, cy0 + i * LH, w, 1, palette[(t + i) % 3]);
|
|
772
|
+
cx += w + 1; // 1px space between tokens
|
|
773
|
+
}
|
|
774
|
+
if (i === active) caretX = Math.min(cx, lineRight); // caret follows the code
|
|
775
|
+
}
|
|
776
|
+
// blinking caret on the active line
|
|
777
|
+
if (frame % 2) px(ctx, caretX, cy0 + active * LH, 1, 1, '#ffffff');
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// A terminal: a title strip, "$" prompt lines + output that scroll up as a new
|
|
781
|
+
// line is appended, with a blinking block caret on the active prompt.
|
|
782
|
+
function drawScreenTerminal(ctx, sx, sy, sw, sh, tier, seed, frame) {
|
|
783
|
+
const green = '#8bd450', dim = 'rgba(139,212,80,0.55)', warn = '#e0a35d';
|
|
784
|
+
const cy0 = drawScreenChrome(ctx, sx, sy, sw, tier);
|
|
785
|
+
const rows = Math.floor((sy + sh - cy0) / LH);
|
|
786
|
+
const right = sx + sw - 1;
|
|
787
|
+
// which output line is the newest (it grows, mimicking a command printing)
|
|
788
|
+
const tick = Math.floor(frame / 16);
|
|
789
|
+
for (let i = 0; i < rows - 1; i++) {
|
|
790
|
+
const rr = rng(seed * 23 + (i + tick) * 97 + 3);
|
|
791
|
+
const isPrompt = rr() < 0.34;
|
|
792
|
+
let cx = sx + 1;
|
|
793
|
+
if (isPrompt) { px(ctx, cx, cy0 + i * LH, 1, 1, green); cx += 2; } // "$"
|
|
794
|
+
// output run: length varies; the bottom-most line "types in" with frame
|
|
795
|
+
let lw = 3 + Math.floor(rr() * 9);
|
|
796
|
+
if (i === rows - 2) lw = 2 + (Math.floor(frame / 4) % 11);
|
|
797
|
+
const w = Math.min(lw, right - cx);
|
|
798
|
+
if (w > 0) px(ctx, cx, cy0 + i * LH, w, 1, isPrompt ? green : (rr() < 0.3 ? warn : dim));
|
|
799
|
+
}
|
|
800
|
+
// active prompt + blinking block caret on the last row
|
|
801
|
+
const ly = cy0 + (rows - 1) * LH;
|
|
802
|
+
px(ctx, sx + 1, ly, 1, 1, green);
|
|
803
|
+
if (frame % 2) px(ctx, sx + 3, ly, 2, 1, green);
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// A diff view: a title strip, a red/green change gutter and corresponding +/-
|
|
807
|
+
// lines, like a review pane. Lines hold their color (add=green, del=red, ctx=dim).
|
|
808
|
+
function drawScreenDiff(ctx, sx, sy, sw, sh, tier, seed, frame) {
|
|
809
|
+
const add = '#4caf6a', addg = '#2e6b42', del = '#c0473a', delg = '#7a2e26';
|
|
810
|
+
const ctxt = 'rgba(255,255,255,0.28)';
|
|
811
|
+
const cy0 = drawScreenChrome(ctx, sx, sy, sw, tier);
|
|
812
|
+
const rows = Math.floor((sy + sh - cy0) / LH);
|
|
813
|
+
const scroll = Math.floor(frame / 30);
|
|
814
|
+
for (let i = 0; i < rows; i++) {
|
|
815
|
+
const rr = rng(seed * 29 + (i + scroll) * 113 + 11);
|
|
816
|
+
const k = rr();
|
|
817
|
+
const kind = k < 0.4 ? 'add' : (k < 0.7 ? 'del' : 'ctx');
|
|
818
|
+
const gutc = kind === 'add' ? addg : kind === 'del' ? delg : 'transparent';
|
|
819
|
+
const txtc = kind === 'add' ? add : kind === 'del' ? del : ctxt;
|
|
820
|
+
if (gutc !== 'transparent') px(ctx, sx + 1, cy0 + i * LH, 1, 1, gutc); // change bar
|
|
821
|
+
const indent = (Math.floor(rr() * 3)) * 2;
|
|
822
|
+
const cx = sx + 3 + indent;
|
|
823
|
+
const lw = 3 + Math.floor(rr() * 8);
|
|
824
|
+
const w = Math.min(lw, sx + sw - 1 - cx);
|
|
825
|
+
if (w > 0) px(ctx, cx, cy0 + i * LH, w, 1, txtc);
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// A tiny dashboard: a title strip, then a small live bar chart whose bars sway
|
|
830
|
+
// gently with frame — reads as a metrics/monitoring screen.
|
|
831
|
+
function drawScreenChart(ctx, sx, sy, sw, sh, tier, seed, frame) {
|
|
832
|
+
const bar = shade(tier.screen, 30), tip = tier.code;
|
|
833
|
+
const top = drawScreenChrome(ctx, sx, sy, sw, tier);
|
|
834
|
+
// baseline
|
|
835
|
+
const base = sy + sh - 1;
|
|
836
|
+
px(ctx, sx + 1, base, sw - 2, 1, 'rgba(255,255,255,0.18)');
|
|
837
|
+
const bw = 2, gap = 1;
|
|
838
|
+
const maxH = base - top - 1;
|
|
839
|
+
let bx = sx + 2;
|
|
840
|
+
let i = 0;
|
|
841
|
+
while (bx + bw <= sx + sw - 1) {
|
|
842
|
+
const phase = (frame * 0.18) + i * 1.1 + (hashInt('bar' + seed) % 7);
|
|
843
|
+
const norm = 0.35 + 0.55 * (0.5 - 0.5 * Math.cos(phase)); // 0.35..0.9, swaying
|
|
844
|
+
const h = Math.max(1, Math.round(maxH * norm));
|
|
845
|
+
const by = base - h;
|
|
846
|
+
px(ctx, bx, by, bw, h, bar);
|
|
847
|
+
px(ctx, bx, by, bw, 1, tip); // bright cap
|
|
848
|
+
bx += bw + gap;
|
|
849
|
+
i++;
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
function drawDeskProp(ctx, x, baseY, seed, frame) {
|
|
854
|
+
const kind = hashInt('prop' + seed) % 4;
|
|
855
|
+
if (kind === 0) { // coffee mug + steam
|
|
856
|
+
px(ctx, x, baseY - 5, 5, 5, '#e4ded3');
|
|
857
|
+
px(ctx, x, baseY - 5, 5, 1, '#f2ede4');
|
|
858
|
+
px(ctx, x + 1, baseY - 4, 3, 1, '#6f5240');
|
|
859
|
+
px(ctx, x + 5, baseY - 4, 1, 2, '#c4bdb2');
|
|
860
|
+
if (frame % 4 < 2) px(ctx, x + 2, baseY - 7, 1, 1, '#b8bdc8');
|
|
861
|
+
} else if (kind === 1) { // tiny plant
|
|
862
|
+
px(ctx, x + 1, baseY - 3, 4, 3, '#9c5a2e');
|
|
863
|
+
px(ctx, x, baseY - 6, 2, 3, '#3f9a55');
|
|
864
|
+
px(ctx, x + 2, baseY - 7, 2, 4, '#4cb364');
|
|
865
|
+
px(ctx, x + 4, baseY - 5, 2, 2, '#3f9a55');
|
|
866
|
+
} else if (kind === 2) { // a small stack of books
|
|
867
|
+
px(ctx, x, baseY - 2, 7, 2, '#c0473a');
|
|
868
|
+
px(ctx, x + 1, baseY - 4, 6, 2, '#5d9ce0');
|
|
869
|
+
px(ctx, x, baseY - 6, 7, 2, '#5dc98a');
|
|
870
|
+
} else { // framed photo
|
|
871
|
+
px(ctx, x, baseY - 6, 6, 6, '#6b4a2e');
|
|
872
|
+
px(ctx, x + 1, baseY - 5, 4, 4, '#bfe3f5');
|
|
873
|
+
px(ctx, x + 2, baseY - 3, 2, 2, '#e0855d');
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
function drawPin(ctx, x, y, seed) {
|
|
878
|
+
const flags = [
|
|
879
|
+
['#c0473a', '#ffffff', '#3a5bbf'], // tricolor-ish
|
|
880
|
+
['#3a5bbf', '#ffffff', '#3a5bbf'],
|
|
881
|
+
['#2e7d4f', '#ffffff', '#e0855d'],
|
|
882
|
+
['#e0b05d', '#c0473a', '#2e5b8a'],
|
|
883
|
+
];
|
|
884
|
+
const f = flags[hashInt('f' + seed) % flags.length];
|
|
885
|
+
px(ctx, x, y, 9, 6, '#ffffff');
|
|
886
|
+
px(ctx, x, y, 3, 6, f[0]);
|
|
887
|
+
px(ctx, x + 3, y, 3, 6, f[1]);
|
|
888
|
+
px(ctx, x + 6, y, 3, 6, f[2]);
|
|
889
|
+
px(ctx, x, y, 9, 1, 'rgba(255,255,255,0.3)');
|
|
890
|
+
px(ctx, x + 4, y - 1, 1, 1, '#888'); // pin
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
function drawStickies(ctx, x, y, seed) {
|
|
894
|
+
const cols = ['#f4d35e', '#9ad5e0', '#e8a0c0', '#a8e0a0'];
|
|
895
|
+
const n = 1 + (hashInt('st' + seed) % 3);
|
|
896
|
+
for (let i = 0; i < n; i++) {
|
|
897
|
+
const c = cols[(hashInt('sc' + seed + i)) % cols.length];
|
|
898
|
+
px(ctx, x + (i % 2) * 6, y + (i >> 1) * 6, 5, 5, c);
|
|
899
|
+
px(ctx, x + (i % 2) * 6, y + (i >> 1) * 6, 5, 1, shade(c, 20));
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
function drawMinion(ctx, x, feetY, bob) {
|
|
904
|
+
const y = feetY - bob;
|
|
905
|
+
px(ctx, x + 1, y - 9, 4, 2, '#1f2a33');
|
|
906
|
+
px(ctx, x + 1, y - 8, 4, 3, '#f0c8a0');
|
|
907
|
+
px(ctx, x + 2, y - 7, 1, 1, '#10141a');
|
|
908
|
+
px(ctx, x, y - 5, 6, 4, '#2bb0aa');
|
|
909
|
+
px(ctx, x + 4, y - 5, 2, 4, '#1f8a85');
|
|
910
|
+
px(ctx, x + 1, y - 1, 1, 1, '#171b22');
|
|
911
|
+
px(ctx, x + 4, y - 1, 1, 1, '#171b22');
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
function drawRing(ctx, x, y, w, h, color, frame, strong) {
|
|
915
|
+
const L = 7;
|
|
916
|
+
ctx.globalAlpha = strong ? 1 : 0.6;
|
|
917
|
+
ctx.fillStyle = color;
|
|
918
|
+
const x1 = x + w - 1, y1 = y + h - 1;
|
|
919
|
+
ctx.fillRect(x, y, L, 1); ctx.fillRect(x, y, 1, L);
|
|
920
|
+
ctx.fillRect(x1 - L + 1, y, L, 1); ctx.fillRect(x1, y, 1, L);
|
|
921
|
+
ctx.fillRect(x, y1, L, 1); ctx.fillRect(x, y1 - L + 1, 1, L);
|
|
922
|
+
ctx.fillRect(x1 - L + 1, y1, L, 1); ctx.fillRect(x1, y1 - L + 1, 1, L);
|
|
923
|
+
if (strong && frame % 4 < 2) {
|
|
924
|
+
ctx.globalAlpha = 0.14;
|
|
925
|
+
ctx.fillRect(x, y, w, 1); ctx.fillRect(x, y1, w, 1);
|
|
926
|
+
ctx.fillRect(x, y, 1, h); ctx.fillRect(x1, y, 1, h);
|
|
927
|
+
}
|
|
928
|
+
ctx.globalAlpha = 1;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// "Waiting on you" — the floor's HIGHEST-signal state (a background agent blocked
|
|
932
|
+
// on the user). Treats the WHOLE DESK as the alert, not just a small floating !:
|
|
933
|
+
// a soft pulsing amber glow pooled over the cell + a bold pulsing amber frame
|
|
934
|
+
// around it + a bobbing "!" bubble over the head. Reads come-help-me, and is
|
|
935
|
+
// deliberately unlike the green working LED, the gray idle desk, and the pink
|
|
936
|
+
// just-finished pip. (x, y) = the cell's TOP-LEFT (CELL_W × CELL_H).
|
|
937
|
+
export function drawNeedsYou(ctx, x, y, frame) {
|
|
938
|
+
const ph = (frame % 16) / 16;
|
|
939
|
+
const pulse = 0.5 - 0.5 * Math.cos(ph * 2 * Math.PI); // smooth 0→1→0 breathing
|
|
940
|
+
const amber = '#ffb454';
|
|
941
|
+
// soft amber glow pooling over the desk
|
|
942
|
+
const gx = x + CELL_W / 2, gy = y + CELL_H / 2;
|
|
943
|
+
const grad = ctx.createRadialGradient(gx, gy, 4, gx, gy, CELL_W * 0.72);
|
|
944
|
+
grad.addColorStop(0, `rgba(255,180,84,${(0.12 + pulse * 0.16).toFixed(3)})`);
|
|
945
|
+
grad.addColorStop(1, 'rgba(255,180,84,0)');
|
|
946
|
+
ctx.fillStyle = grad;
|
|
947
|
+
ctx.fillRect(x - 3, y, CELL_W + 6, CELL_H);
|
|
948
|
+
// bold pulsing amber frame around the whole cell (the come-help-me outline)
|
|
949
|
+
ctx.globalAlpha = 0.55 + pulse * 0.45;
|
|
950
|
+
const x1 = x + CELL_W;
|
|
951
|
+
px(ctx, x, y + 3, CELL_W, 2, amber); // top
|
|
952
|
+
px(ctx, x, y + CELL_H - 5, CELL_W, 2, amber); // bottom
|
|
953
|
+
px(ctx, x, y + 3, 2, CELL_H - 8, amber); // left
|
|
954
|
+
px(ctx, x1 - 2, y + 3, 2, CELL_H - 8, amber); // right
|
|
955
|
+
ctx.globalAlpha = 1;
|
|
956
|
+
// --- bobbing amber "!" bubble over the head ---
|
|
957
|
+
const bob = frame % 6 < 3 ? 0 : 1;
|
|
958
|
+
const bx = gx + 6, by = y + 16 - bob;
|
|
959
|
+
ctx.globalAlpha = 0.30 + pulse * 0.28; // halo breathes with the frame
|
|
960
|
+
px(ctx, bx - 2, by - 1, 16, 13, amber);
|
|
961
|
+
ctx.globalAlpha = 1;
|
|
962
|
+
px(ctx, bx - 1, by, 12, 9, amber); // bubble body
|
|
963
|
+
px(ctx, bx - 1, by, 12, 1, '#ffd793'); // top highlight
|
|
964
|
+
px(ctx, bx - 1, by + 8, 12, 1, '#e08a2a'); // bottom shade
|
|
965
|
+
px(ctx, bx, by + 9, 3, 2, amber); // tail toward the head
|
|
966
|
+
px(ctx, bx + 4, by + 2, 2, 4, '#3a2606'); // "!"
|
|
967
|
+
px(ctx, bx + 4, by + 7, 2, 1, '#3a2606');
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
// PM crown — a small gold crown bobbing above the lead's head.
|
|
971
|
+
export function drawCrown(ctx, cx, headTopY, frame) {
|
|
972
|
+
const bob = frame % 8 < 4 ? 0 : 1;
|
|
973
|
+
const y = headTopY - 7 - bob;
|
|
974
|
+
const g = '#ffd166', hl = '#fff0c0', dk = '#c79a2e';
|
|
975
|
+
px(ctx, cx - 4, y + 4, 9, 2, g);
|
|
976
|
+
px(ctx, cx - 4, y + 5, 9, 1, dk);
|
|
977
|
+
px(ctx, cx - 4, y + 1, 2, 3, g);
|
|
978
|
+
px(ctx, cx, y, 2, 4, g);
|
|
979
|
+
px(ctx, cx + 4, y + 1, 2, 3, g);
|
|
980
|
+
px(ctx, cx - 4, y + 1, 1, 1, hl);
|
|
981
|
+
px(ctx, cx, y, 1, 1, hl);
|
|
982
|
+
px(ctx, cx, y + 2, 1, 1, '#ff5cba');
|
|
983
|
+
}
|