clawhouse 0.1.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.
@@ -0,0 +1,1065 @@
1
+ #!/usr/bin/env node
2
+ // Deterministic pixel-art sprite sheet builder for Pixel Office.
3
+ // Pure Node (zlib + handcrafted PNG). No external deps.
4
+ //
5
+ // Output: pixel-office/public/sprites.png
6
+ // Layout: 16 columns x 9 rows of 32x48 sprites.
7
+ // Columns: state group (active, idle, sip, away) x 4 frames each.
8
+ // Rows: one per agent in AGENTS order.
9
+
10
+ const fs = require('fs');
11
+ const zlib = require('zlib');
12
+ const path = require('path');
13
+
14
+ // ---------- PNG encoder ----------
15
+
16
+ const CRC_TABLE = (() => {
17
+ const t = new Uint32Array(256);
18
+ for (let n = 0; n < 256; n++) {
19
+ let c = n;
20
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
21
+ t[n] = c >>> 0;
22
+ }
23
+ return t;
24
+ })();
25
+
26
+ function crc32(buf) {
27
+ let c = 0xffffffff;
28
+ for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
29
+ return (c ^ 0xffffffff) >>> 0;
30
+ }
31
+
32
+ function chunk(type, data) {
33
+ const len = Buffer.alloc(4);
34
+ len.writeUInt32BE(data.length, 0);
35
+ const typeBuf = Buffer.from(type, 'ascii');
36
+ const crcBuf = Buffer.alloc(4);
37
+ crcBuf.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0);
38
+ return Buffer.concat([len, typeBuf, data, crcBuf]);
39
+ }
40
+
41
+ function encodePNG(width, height, rgba) {
42
+ const sig = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
43
+ const ihdr = Buffer.alloc(13);
44
+ ihdr.writeUInt32BE(width, 0);
45
+ ihdr.writeUInt32BE(height, 4);
46
+ ihdr[8] = 8;
47
+ ihdr[9] = 6;
48
+ ihdr[10] = 0;
49
+ ihdr[11] = 0;
50
+ ihdr[12] = 0;
51
+ const stride = width * 4;
52
+ const raw = Buffer.alloc((stride + 1) * height);
53
+ for (let y = 0; y < height; y++) {
54
+ raw[y * (stride + 1)] = 0;
55
+ rgba.copy(raw, y * (stride + 1) + 1, y * stride, y * stride + stride);
56
+ }
57
+ const idat = zlib.deflateSync(raw, { level: 9 });
58
+ return Buffer.concat([
59
+ sig,
60
+ chunk('IHDR', ihdr),
61
+ chunk('IDAT', idat),
62
+ chunk('IEND', Buffer.alloc(0)),
63
+ ]);
64
+ }
65
+
66
+ // ---------- Canvas ----------
67
+
68
+ function hex(s) {
69
+ const h = s.replace('#', '');
70
+ return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16), 255];
71
+ }
72
+
73
+ function shade(c, mul) {
74
+ return [Math.max(0, Math.min(255, Math.round(c[0] * mul))),
75
+ Math.max(0, Math.min(255, Math.round(c[1] * mul))),
76
+ Math.max(0, Math.min(255, Math.round(c[2] * mul))),
77
+ c[3]];
78
+ }
79
+
80
+ class Canvas {
81
+ constructor(w, h) { this.w = w; this.h = h; this.buf = Buffer.alloc(w * h * 4); }
82
+ px(x, y, color) {
83
+ if (!color) return;
84
+ if (x < 0 || y < 0 || x >= this.w || y >= this.h) return;
85
+ const i = (y * this.w + x) * 4;
86
+ this.buf[i] = color[0];
87
+ this.buf[i + 1] = color[1];
88
+ this.buf[i + 2] = color[2];
89
+ this.buf[i + 3] = color[3] != null ? color[3] : 255;
90
+ }
91
+ rect(x, y, w, h, color) {
92
+ for (let yy = 0; yy < h; yy++) for (let xx = 0; xx < w; xx++) this.px(x + xx, y + yy, color);
93
+ }
94
+ hline(x, y, w, color) { for (let i = 0; i < w; i++) this.px(x + i, y, color); }
95
+ vline(x, y, h, color) { for (let i = 0; i < h; i++) this.px(x, y + i, color); }
96
+ outlineRect(x, y, w, h, color) {
97
+ this.hline(x, y, w, color);
98
+ this.hline(x, y + h - 1, w, color);
99
+ this.vline(x, y, h, color);
100
+ this.vline(x + w - 1, y, h, color);
101
+ }
102
+ }
103
+
104
+ // ---------- Agent specs ----------
105
+
106
+ const OUTLINE = [10, 14, 26, 255];
107
+
108
+ const AGENTS = [
109
+ { id: 'main', skin: '#f3c89c', hair: '#1a1f3a', hairStyle: 'plain', outfit: '#3a4a8c', outfit2: '#7f93ff', accessory: 'cape', drink: '#3a4a8c', face: 'stern' },
110
+ { id: 'markethunting', skin: '#e8b88a', hair: '#1f2920', hairStyle: 'slicked', outfit: '#3aa873', outfit2: '#1f6b48', accessory: 'tie', drink: '#0e3d27', face: 'focus' },
111
+ { id: 'sage', skin: '#dab48a', hair: '#3b2a1f', hairStyle: 'plain', outfit: '#7bbf9a', outfit2: '#5b9a7d', accessory: 'hood', drink: '#7c5438', face: 'kind' },
112
+ { id: 'senku', skin: '#f3c89c', hair: '#eef5ff', hairStyle: 'tallspiky', outfit: '#dde5f0', outfit2: '#77f6d8', accessory: 'goggles', drink: '#80e6f5', face: 'sharp' },
113
+ { id: 'shikamaru', skin: '#e0bb8a', hair: '#181b2c', hairStyle: 'ponytail', outfit: '#3a4471', outfit2: '#6b76b1', accessory: 'vest', drink: '#56527b', face: 'bored' },
114
+ { id: 'tyrion', skin: '#f3d3a4', hair: '#d4b074', hairStyle: 'plain', outfit: '#a87e3f', outfit2: '#5e4022', accessory: 'goblet', drink: '#7a1f2c', face: 'mismatched' },
115
+ { id: 'harvey', skin: '#e8c19a', hair: '#16101a', hairStyle: 'slicked', outfit: '#1f1a26', outfit2: '#6f5fa3', accessory: 'tie', drink: '#3a2c5e', face: 'smug' },
116
+ { id: 'l', skin: '#f5e7d3', hair: '#0d111f', hairStyle: 'messy', outfit: '#dadde3', outfit2: '#8d96a6', accessory: 'none', drink: '#f0e5b8', face: 'blank' },
117
+ { id: 'd', skin: '#e8b9a4', hair: '#221530', hairStyle: 'wavy', outfit: '#b85aa6', outfit2: '#ff8ccd', accessory: 'headphones', drink: '#ff8ccd', face: 'cheerful' },
118
+ { id: 'ephraim', skin: '#c98e5a', hair: '#15100a', hairStyle: 'crewcut', outfit: '#1d2230', outfit2: '#e84a3a', accessory: 'athlete', drink: '#cf8a40', face: 'coach' },
119
+ { id: 'house', skin: '#dcb59a', hair: '#7c7c7c', hairStyle: 'plain', outfit: '#5a8aa3', outfit2: '#2c3445', accessory: 'tie', drink: '#cfa55a', face: 'snark' },
120
+ ];
121
+
122
+ const STATES = ['active', 'idle', 'sip', 'away', 'walk'];
123
+ const FRAMES_PER_STATE = 4;
124
+ const SPRITE_W = 32;
125
+ const SPRITE_H = 48;
126
+
127
+ // ---------- Sprite painter ----------
128
+
129
+ function drawSprite(c, ox, oy, agent, state, frame) {
130
+ // The 'active' state is "sitting at the desk typing" — we always view it
131
+ // from BEHIND (camera is over the chair, looking past the character at the
132
+ // monitor). Dispatch to the back-view renderer; everything else stays
133
+ // front-facing as before.
134
+ if (state === 'active') {
135
+ drawSpriteBack(c, ox, oy, agent, frame);
136
+ return;
137
+ }
138
+
139
+ const skin = hex(agent.skin);
140
+ const skinDark = shade(skin, 0.78);
141
+ const hair = hex(agent.hair);
142
+ const shirt = hex(agent.outfit);
143
+ const shirtDark = shade(shirt, 0.78);
144
+ const accent = hex(agent.outfit2);
145
+ const drink = hex(agent.drink);
146
+ const pants = [29, 36, 64, 255];
147
+ const shoe = OUTLINE;
148
+
149
+ let bob = 0;
150
+ let dim = false;
151
+ let pinLegs = false;
152
+ let legDxL = 0;
153
+ let legDxR = 0;
154
+
155
+ if (state === 'idle') {
156
+ // Upright + slow breath
157
+ bob = [0, -1, -1, 0][frame];
158
+ } else if (state === 'sip') {
159
+ // Smooth cup raise/lower
160
+ bob = [0, -1, -1, 0][frame];
161
+ } else if (state === 'away') {
162
+ // Deep slump on desk — head drops far below shoulders
163
+ bob = [6, 6, 7, 6][frame];
164
+ dim = true;
165
+ pinLegs = true; // keep legs in their normal spot so the body folds, not slides
166
+ } else if (state === 'walk') {
167
+ // 4-frame walk cycle: contact, passing, contact, passing.
168
+ // Body dips on passing frames (weight on single leg compresses stance).
169
+ bob = [-1, 0, -1, 0][frame];
170
+ // Legs alternate forward/back. Sprite faces viewer; "forward" is just an
171
+ // x-shift to read as a stride. Positive = forward (out from body center).
172
+ legDxL = [-2, 0, 2, 0][frame];
173
+ legDxR = [2, 0, -2, 0][frame];
174
+ }
175
+
176
+ const cx = ox + 16;
177
+ const baseY = oy + bob;
178
+ const headY = baseY + 9;
179
+ const headW = 12;
180
+
181
+ // ---- Hair backing (drawn before head so head edges sit on top of hair sides) ----
182
+ drawHairBack(c, cx, headY, hair, agent.hairStyle);
183
+
184
+ // ---- Head ----
185
+ c.rect(cx - 6, headY, headW, 12, skin);
186
+ // chin shading
187
+ c.hline(cx - 6, headY + 11, headW, skinDark);
188
+ // outline
189
+ c.outlineRect(cx - 7, headY - 1, headW + 2, 14, OUTLINE);
190
+
191
+ // ---- Hair front (fringe on top of head) ----
192
+ drawHairFront(c, cx, headY, hair, agent.hairStyle);
193
+
194
+ // ---- Per-agent face (eyes + mouth + accents) ----
195
+ drawFace(c, cx, headY, agent, state, frame);
196
+
197
+ // ---- Head accessories (over hair) ----
198
+ if (agent.accessory === 'headphones') {
199
+ c.rect(cx - 9, headY + 1, 2, 7, accent);
200
+ c.outlineRect(cx - 9, headY + 1, 2, 7, OUTLINE);
201
+ c.rect(cx + 7, headY + 1, 2, 7, accent);
202
+ c.outlineRect(cx + 7, headY + 1, 2, 7, OUTLINE);
203
+ c.rect(cx - 6, headY - 3, 12, 2, accent);
204
+ c.outlineRect(cx - 6, headY - 3, 12, 2, OUTLINE);
205
+ } else if (agent.accessory === 'goggles') {
206
+ c.rect(cx - 6, headY + 2, 12, 2, shade(accent, 0.6));
207
+ c.rect(cx - 5, headY + 4, 4, 3, accent);
208
+ c.rect(cx + 1, headY + 4, 4, 3, accent);
209
+ c.outlineRect(cx - 5, headY + 4, 4, 3, OUTLINE);
210
+ c.outlineRect(cx + 1, headY + 4, 4, 3, OUTLINE);
211
+ } else if (agent.accessory === 'hood') {
212
+ c.rect(cx - 9, headY - 3, 18, 5, accent);
213
+ c.rect(cx - 8, headY + 2, 2, 6, accent);
214
+ c.rect(cx + 6, headY + 2, 2, 6, accent);
215
+ c.outlineRect(cx - 9, headY - 3, 18, 5, OUTLINE);
216
+ }
217
+
218
+ // ---- Per-agent extras (drawn after standard accessories) ----
219
+ if (agent.id === 'main') {
220
+ // Commander cap on top of hair
221
+ const capColor = shade(hex(agent.outfit), 0.7);
222
+ const visorColor = shade(hex(agent.outfit), 0.4);
223
+ const gold = [240, 200, 80, 255];
224
+ // crown
225
+ c.rect(cx - 7, headY - 4, 14, 4, capColor);
226
+ c.outlineRect(cx - 7, headY - 4, 14, 4, OUTLINE);
227
+ // visor (one-row, slightly wider)
228
+ c.rect(cx - 8, headY, 16, 1, visorColor);
229
+ c.hline(cx - 8, headY + 1, 16, OUTLINE);
230
+ c.px(cx - 8, headY, OUTLINE);
231
+ c.px(cx + 7, headY, OUTLINE);
232
+ // gold insignia (eagle dot)
233
+ c.rect(cx - 1, headY - 3, 2, 2, gold);
234
+ } else if (agent.id === 'ephraim') {
235
+ // Trainer headband — red sweatband across the forehead, just below the
236
+ // hairline and above the brows. A small knotted tail hangs off the left
237
+ // side so the silhouette reads as "tied bandana", not "stripe of paint".
238
+ const band = hex(agent.outfit2); // red
239
+ const bandD = shade(band, 0.55);
240
+ c.rect(cx - 7, headY + 1, 14, 2, band);
241
+ c.outlineRect(cx - 7, headY + 1, 14, 2, OUTLINE);
242
+ c.hline(cx - 6, headY + 2, 12, bandD);
243
+ // Knot + tail on the left temple
244
+ c.rect(cx - 8, headY + 1, 1, 3, band);
245
+ c.px(cx - 8, headY + 4, bandD);
246
+ // White sweat-pip detail (center of forehead)
247
+ c.px(cx, headY + 1, [255, 255, 255, 200]);
248
+ }
249
+
250
+ // ---- Body ----
251
+ const bodyY = headY + 13;
252
+ let bodyX = cx - 7;
253
+ // cape behind body
254
+ if (agent.accessory === 'cape') {
255
+ c.rect(bodyX - 3, bodyY, 4, 17, accent);
256
+ c.outlineRect(bodyX - 3, bodyY, 4, 17, OUTLINE);
257
+ }
258
+ // robe widens for sage
259
+ if (agent.accessory === 'hood') {
260
+ c.rect(bodyX, bodyY, 14, 14, shirt);
261
+ c.rect(bodyX - 1, bodyY + 12, 16, 4, shirt);
262
+ c.outlineRect(bodyX - 1, bodyY + 12, 16, 4, OUTLINE);
263
+ c.outlineRect(bodyX, bodyY, 14, 14, OUTLINE);
264
+ } else {
265
+ c.rect(bodyX, bodyY, 14, 14, shirt);
266
+ c.outlineRect(bodyX, bodyY, 14, 14, OUTLINE);
267
+ }
268
+ // collar/lapel shading
269
+ c.rect(bodyX + 1, bodyY, 12, 1, shirtDark);
270
+
271
+ if (agent.accessory === 'tie') {
272
+ c.rect(cx - 1, bodyY + 1, 2, 7, accent);
273
+ c.rect(cx - 2, bodyY + 7, 4, 4, accent);
274
+ c.outlineRect(cx - 2, bodyY + 7, 4, 4, OUTLINE);
275
+ } else if (agent.accessory === 'vest') {
276
+ c.rect(bodyX + 1, bodyY + 1, 3, 12, accent);
277
+ c.rect(bodyX + 10, bodyY + 1, 3, 12, accent);
278
+ } else if (agent.accessory === 'athlete') {
279
+ // Sleeveless compression top: skin showing at shoulders + V-neck.
280
+ // Pecs, abs (6-pack), and obliques shaded directly on the singlet so the
281
+ // physique reads even at 32x48.
282
+ const muscle = shade(skin, 0.78);
283
+ const muscleD = shade(skin, 0.62);
284
+ const armhole = skin;
285
+ // bare shoulders / armholes (carve out top corners of singlet to skin)
286
+ c.rect(bodyX, bodyY, 2, 4, armhole);
287
+ c.rect(bodyX+12, bodyY, 2, 4, armhole);
288
+ // V-neck — small inverted triangle of skin at top center
289
+ c.rect(cx - 2, bodyY, 4, 1, skin);
290
+ c.rect(cx - 1, bodyY + 1, 2, 1, skin);
291
+ // singlet straps (thin accent piping running over each shoulder)
292
+ c.vline(bodyX + 2, bodyY, 4, accent);
293
+ c.vline(bodyX + 11, bodyY, 4, accent);
294
+ // pec line (horizontal divide between chest and abs)
295
+ c.hline(bodyX + 3, bodyY + 5, 8, muscleD);
296
+ // chest cleft (vertical line down middle of pecs)
297
+ c.vline(cx, bodyY + 2, 4, muscleD);
298
+ // 6-pack abs — 3 horizontal grooves + 1 vertical groove on the abdominal block
299
+ c.vline(cx, bodyY + 6, 7, muscleD);
300
+ c.hline(bodyX + 3, bodyY + 7, 8, muscleD);
301
+ c.hline(bodyX + 3, bodyY + 9, 8, muscleD);
302
+ c.hline(bodyX + 3, bodyY + 11, 8, muscleD);
303
+ // ab highlights (lighter shade in each ab block to make them pop)
304
+ c.px(bodyX + 4, bodyY + 8, shade(shirt, 1.2));
305
+ c.px(bodyX + 9, bodyY + 8, shade(shirt, 1.2));
306
+ c.px(bodyX + 4, bodyY + 10, shade(shirt, 1.2));
307
+ c.px(bodyX + 9, bodyY + 10, shade(shirt, 1.2));
308
+ // champion belt across the waist (gold buckle in middle)
309
+ const beltDark = [40, 28, 18, 255];
310
+ const buckle = [240, 200, 80, 255];
311
+ c.rect(bodyX, bodyY + 12, 14, 2, beltDark);
312
+ c.rect(cx - 2, bodyY + 12, 4, 2, buckle);
313
+ c.outlineRect(cx - 2, bodyY + 12, 4, 2, OUTLINE);
314
+ } else if (agent.id === 'senku') {
315
+ // lab coat lapel
316
+ c.rect(cx - 2, bodyY + 1, 1, 12, shade(shirt, 0.5));
317
+ c.rect(cx + 1, bodyY + 1, 1, 12, shade(shirt, 0.5));
318
+ } else if (agent.id === 'l') {
319
+ // baggy white shirt vertical fold
320
+ c.rect(cx - 1, bodyY + 1, 1, 12, shade(shirt, 0.85));
321
+ }
322
+
323
+ // ---- Arms ---- (ephraim's accessory = 'athlete' uses bare arms + bracers)
324
+ if (agent.accessory === 'athlete') {
325
+ const skinDarkLocal = shade(skin, 0.78);
326
+ drawArms(c, bodyX, bodyY, skin, skinDarkLocal, skin, drink, state, frame);
327
+ drawAthleteArmDetail(c, bodyX, bodyY, agent, state, frame);
328
+ } else {
329
+ drawArms(c, bodyX, bodyY, shirt, shirtDark, skin, drink, state, frame);
330
+ }
331
+
332
+ // ---- Legs ---- (pinned during away so the body folds over hips, not slides)
333
+ const legY = pinLegs ? (oy + 36) : (bodyY + 14);
334
+ c.rect(bodyX + 1 + legDxL, legY, 5, 8, pants);
335
+ c.rect(bodyX + 8 + legDxR, legY, 5, 8, pants);
336
+ c.outlineRect(bodyX + 1 + legDxL, legY, 5, 8, OUTLINE);
337
+ c.outlineRect(bodyX + 8 + legDxR, legY, 5, 8, OUTLINE);
338
+ c.rect(bodyX + legDxL, legY + 7, 6, 2, shoe);
339
+ c.rect(bodyX + 8 + legDxR, legY + 7, 6, 2, shoe);
340
+
341
+ // ---- Sage's permanent tea cup (held in right hand, except while sipping/away) ----
342
+ if (agent.id === 'sage' && state !== 'sip' && state !== 'away') {
343
+ const drinkColor = hex(agent.drink);
344
+ const cupX = bodyX + 14;
345
+ const cupY = bodyY + 9;
346
+ c.rect(cupX, cupY, 5, 4, drinkColor);
347
+ c.outlineRect(cupX, cupY, 5, 4, OUTLINE);
348
+ // saucer rim
349
+ c.hline(cupX - 1, cupY + 3, 7, OUTLINE);
350
+ // wisp of steam
351
+ if (frame % 2 === 0) {
352
+ c.rect(cupX + 1, cupY - 2, 1, 2, [255, 255, 255, 170]);
353
+ } else {
354
+ c.rect(cupX + 3, cupY - 2, 1, 2, [255, 255, 255, 170]);
355
+ }
356
+ }
357
+
358
+ // ---- Z's for sleeping (drift up over frames 1..3) ----
359
+ if (state === 'away' && frame >= 1) {
360
+ // Z floats up & shifts right as it ages, fades on the last frame
361
+ const driftY = (frame - 1) * 2; // 0, 2, 4
362
+ const driftX = (frame - 1); // 0, 1, 2
363
+ const alpha = frame === 3 ? 140 : 220;
364
+ c.rect(cx + 8 + driftX, headY - 5 - driftY, 4, 4, [240, 240, 255, alpha]);
365
+ c.outlineRect(cx + 8 + driftX, headY - 5 - driftY, 4, 4, OUTLINE);
366
+ }
367
+
368
+ // ---- Dim overlay for away ----
369
+ if (dim) {
370
+ for (let yy = oy; yy < oy + SPRITE_H; yy++) {
371
+ for (let xx = ox; xx < ox + SPRITE_W; xx++) {
372
+ const i = (yy * c.w + xx) * 4;
373
+ if (c.buf[i + 3] > 0) {
374
+ c.buf[i] = Math.floor(c.buf[i] * 0.55);
375
+ c.buf[i + 1] = Math.floor(c.buf[i + 1] * 0.55);
376
+ c.buf[i + 2] = Math.floor(c.buf[i + 2] * 0.7);
377
+ }
378
+ }
379
+ }
380
+ }
381
+ }
382
+
383
+ // ---------- Back-view renderer (active state) ----------
384
+ // Camera is over the chair behind the seated character. We see: back of head
385
+ // (full hair coverage, no face), nape, shoulders/upper back, and arms reaching
386
+ // FORWARD toward the keyboard (with a 4-frame typing micro-animation). Most of
387
+ // the body is hidden by the chair backrest in-scene, but the sprite is fully
388
+ // painted so it still reads correctly if rendered without the chair.
389
+ function drawSpriteBack(c, ox, oy, agent, frame) {
390
+ const skin = hex(agent.skin);
391
+ const skinDark = shade(skin, 0.78);
392
+ const hair = hex(agent.hair);
393
+ const hairDark = shade(hair, 0.7);
394
+ const shirt = hex(agent.outfit);
395
+ const shirtDark = shade(shirt, 0.78);
396
+ const shirtMid = shade(shirt, 0.9);
397
+ const accent = hex(agent.outfit2);
398
+ const pants = [29, 36, 64, 255];
399
+ const pantsDark = shade(pants, 0.7);
400
+ const shoe = OUTLINE;
401
+
402
+ // Forward hunch over keyboard + typing pulse (matches old front-view feel)
403
+ const bob = [2, 1, 2, 1][frame];
404
+
405
+ const cx = ox + 16;
406
+ const baseY = oy + bob;
407
+ const headY = baseY + 9;
408
+ const headW = 12;
409
+
410
+ // ---- Head base (skin) — most of it gets covered by hair below ----
411
+ c.rect(cx - 6, headY, headW, 12, skin);
412
+ // nape shadow at neck transition
413
+ c.hline(cx - 6, headY + 11, headW, skinDark);
414
+ c.outlineRect(cx - 7, headY - 1, headW + 2, 14, OUTLINE);
415
+
416
+ // ---- Hair from behind (covers cranium fully) ----
417
+ drawHairBackView(c, cx, headY, hair, hairDark, agent.hairStyle);
418
+
419
+ // ---- Head accessories (back-view variants) ----
420
+ if (agent.accessory === 'headphones') {
421
+ // Band arches over the top; earcups visible on both sides
422
+ c.rect(cx - 6, headY - 3, 12, 2, accent);
423
+ c.outlineRect(cx - 6, headY - 3, 12, 2, OUTLINE);
424
+ c.rect(cx - 9, headY + 1, 2, 7, accent);
425
+ c.outlineRect(cx - 9, headY + 1, 2, 7, OUTLINE);
426
+ c.rect(cx + 7, headY + 1, 2, 7, accent);
427
+ c.outlineRect(cx + 7, headY + 1, 2, 7, OUTLINE);
428
+ } else if (agent.accessory === 'goggles') {
429
+ // Strap wraps around the back of the head (lens hidden — it's on the front)
430
+ c.rect(cx - 6, headY + 4, 12, 2, accent);
431
+ c.outlineRect(cx - 6, headY + 4, 12, 2, OUTLINE);
432
+ c.hline(cx - 6, headY + 4, 12, shade(accent, 0.6));
433
+ } else if (agent.accessory === 'hood') {
434
+ // Hood drape over head + shoulders
435
+ c.rect(cx - 9, headY - 3, 18, 11, accent);
436
+ c.outlineRect(cx - 9, headY - 3, 18, 11, OUTLINE);
437
+ // central fold seam
438
+ c.vline(cx, headY - 2, 9, shade(accent, 0.7));
439
+ }
440
+
441
+ // ---- Per-agent extras ----
442
+ if (agent.id === 'main') {
443
+ // Commander cap from behind — crown + back band, no insignia visible
444
+ const capColor = shade(hex(agent.outfit), 0.7);
445
+ const bandColor = shade(hex(agent.outfit), 0.4);
446
+ c.rect(cx - 7, headY - 4, 14, 4, capColor);
447
+ c.outlineRect(cx - 7, headY - 4, 14, 4, OUTLINE);
448
+ // back band where cap meets head
449
+ c.hline(cx - 7, headY, 14, bandColor);
450
+ c.hline(cx - 7, headY + 1, 14, OUTLINE);
451
+ } else if (agent.id === 'ephraim') {
452
+ // Headband wraps all the way around — paint the back side of the band
453
+ // across the nape area of the head box.
454
+ const band = hex(agent.outfit2);
455
+ const bandD = shade(band, 0.55);
456
+ c.rect(cx - 7, headY + 1, 14, 2, band);
457
+ c.outlineRect(cx - 7, headY + 1, 14, 2, OUTLINE);
458
+ c.hline(cx - 6, headY + 2, 12, bandD);
459
+ // Knot dangling on the left side (mirrors the front view)
460
+ c.rect(cx - 8, headY + 1, 1, 3, band);
461
+ c.px(cx - 8, headY + 4, bandD);
462
+ }
463
+
464
+ // ---- Body (back of shirt) ----
465
+ const bodyY = headY + 13;
466
+ const bodyX = cx - 7;
467
+
468
+ // Legs drawn FIRST so the shirt/coat below paints over their top — gives a
469
+ // proper "shirt hangs over waistband" silhouette instead of legs butting up
470
+ // against a hard body edge. Pants extend 2 rows up into the body region;
471
+ // those rows get covered when we paint the torso next.
472
+ const legY = bodyY + 12;
473
+ c.rect(bodyX + 1, legY, 5, 10, pants);
474
+ c.rect(bodyX + 8, legY, 5, 10, pants);
475
+ c.outlineRect(bodyX + 1, legY, 5, 10, OUTLINE);
476
+ c.outlineRect(bodyX + 8, legY, 5, 10, OUTLINE);
477
+ c.vline(bodyX + 3, legY + 1, 8, pantsDark);
478
+ c.vline(bodyX + 10, legY + 1, 8, pantsDark);
479
+ // Heel-only shoes — sits at the same floor row as before (legY + 9).
480
+ c.rect(bodyX + 1, legY + 9, 5, 1, shoe);
481
+ c.rect(bodyX + 8, legY + 9, 5, 1, shoe);
482
+
483
+ if (agent.accessory === 'cape') {
484
+ // Cape draped down the entire back, slightly wider than body
485
+ c.rect(bodyX - 2, bodyY, 18, 17, accent);
486
+ c.outlineRect(bodyX - 2, bodyY, 18, 17, OUTLINE);
487
+ // central fold
488
+ c.vline(cx, bodyY + 1, 15, shade(accent, 0.75));
489
+ // shoulder-line darken
490
+ c.hline(bodyX - 1, bodyY + 1, 16, shade(accent, 0.65));
491
+ } else if (agent.accessory === 'hood') {
492
+ // Robe back
493
+ c.rect(bodyX, bodyY, 14, 14, shirt);
494
+ c.rect(bodyX - 1, bodyY + 12, 16, 4, shirt);
495
+ c.outlineRect(bodyX - 1, bodyY + 12, 16, 4, OUTLINE);
496
+ c.outlineRect(bodyX, bodyY, 14, 14, OUTLINE);
497
+ c.vline(cx, bodyY + 1, 12, shirtDark);
498
+ } else {
499
+ // Plain shirt back: shoulder yoke darker, central back seam
500
+ c.rect(bodyX, bodyY, 14, 14, shirt);
501
+ c.outlineRect(bodyX, bodyY, 14, 14, OUTLINE);
502
+ c.hline(bodyX + 1, bodyY, 12, shirtDark); // yoke
503
+ c.vline(cx, bodyY + 1, 12, shirtMid); // back seam
504
+ if (agent.id === 'l') {
505
+ // baggy white shirt — extra slouch fold
506
+ c.vline(cx - 3, bodyY + 2, 10, shade(shirt, 0.85));
507
+ c.vline(cx + 3, bodyY + 2, 10, shade(shirt, 0.85));
508
+ }
509
+ }
510
+
511
+ // Shikamaru's vest still shows as straps over shoulders from behind
512
+ if (agent.accessory === 'vest') {
513
+ c.rect(bodyX + 1, bodyY, 3, 14, accent);
514
+ c.rect(bodyX + 10, bodyY, 3, 14, accent);
515
+ c.outlineRect(bodyX + 1, bodyY, 3, 14, OUTLINE);
516
+ c.outlineRect(bodyX + 10, bodyY, 3, 14, OUTLINE);
517
+ } else if (agent.accessory === 'athlete') {
518
+ // Singlet back: skin-tone shoulders/upper back + thin tank straps,
519
+ // trapezius shading running up to the nape, lat V-taper at the waist.
520
+ const traps = shade(skin, 0.78);
521
+ // bare shoulders + nape — overpaint top of the back panel with skin
522
+ c.rect(bodyX, bodyY, 14, 4, skin);
523
+ // tank-top straps (accent) running over each shoulder onto the back
524
+ c.vline(bodyX + 2, bodyY, 4, accent);
525
+ c.vline(bodyX + 11, bodyY, 4, accent);
526
+ // trapezius — darker triangle pinching toward the nape
527
+ c.hline(bodyX + 4, bodyY + 0, 6, traps);
528
+ c.hline(bodyX + 5, bodyY + 1, 4, traps);
529
+ c.hline(bodyX + 6, bodyY + 2, 2, traps);
530
+ c.outlineRect(bodyX, bodyY, 14, 4, OUTLINE);
531
+ // lat taper — vertical shading at the sides of the singlet
532
+ c.vline(bodyX + 1, bodyY + 5, 7, shade(shirt, 0.7));
533
+ c.vline(bodyX + 12, bodyY + 5, 7, shade(shirt, 0.7));
534
+ // spine groove
535
+ c.vline(cx, bodyY + 4, 9, shade(shirt, 0.6));
536
+ // champion belt across the waist (gold buckle on the back is just dark band)
537
+ const beltDark = [40, 28, 18, 255];
538
+ c.rect(bodyX, bodyY + 12, 14, 2, beltDark);
539
+ }
540
+
541
+ // ---- Shoulders only (arms reach FORWARD into the body silhouette and are
542
+ // hidden behind it from this angle — no forearms or hands visible). The
543
+ // shoulder caps still dip alternately to sell the typing motion through
544
+ // the back. ----
545
+ const dyL = [0, -1, 0, 0][frame];
546
+ const dyR = [0, 0, 0, -1][frame];
547
+ // Left shoulder cap (sits at the top-back corner of the torso)
548
+ c.rect(bodyX - 2, bodyY + 1 + dyL, 4, 5, shirt);
549
+ c.outlineRect(bodyX - 2, bodyY + 1 + dyL, 4, 5, OUTLINE);
550
+ c.hline(bodyX - 1, bodyY + 1 + dyL, 2, shirtDark);
551
+ // Right shoulder cap
552
+ c.rect(bodyX + 12, bodyY + 1 + dyR, 4, 5, shirt);
553
+ c.outlineRect(bodyX + 12, bodyY + 1 + dyR, 4, 5, OUTLINE);
554
+ c.hline(bodyX + 13, bodyY + 1 + dyR, 2, shirtDark);
555
+
556
+ }
557
+
558
+ // Full back-of-head hair coverage. Differs from drawHairBack (which is just a
559
+ // "backing" behind the head silhouette) — this paints the entire cranium.
560
+ function drawHairBackView(c, cx, headY, hair, hairDark, style) {
561
+ if (style === 'crewcut') {
562
+ // Tight buzz from behind — stippled scalp coverage, no volume. Alternating
563
+ // darker rows hint at a fresh clipper fade.
564
+ c.rect(cx - 6, headY - 1, 12, 4, hair);
565
+ c.hline(cx - 6, headY, 12, hairDark);
566
+ c.hline(cx - 6, headY + 2, 12, hairDark);
567
+ return;
568
+ }
569
+ // Top crown + most of cranium covered in hair
570
+ c.rect(cx - 7, headY - 2, 14, 4, hair);
571
+ c.rect(cx - 6, headY + 2, 12, 7, hair);
572
+ // central back-of-skull shading
573
+ c.vline(cx - 1, headY + 1, 7, hairDark);
574
+ c.vline(cx, headY + 1, 7, hairDark);
575
+
576
+ if (style === 'spiky') {
577
+ [-7, -4, -1, 2, 5].forEach((x) => c.rect(cx + x, headY - 5, 2, 4, hair));
578
+ } else if (style === 'tallspiky') {
579
+ // Senku — tall white spikes still pop from behind
580
+ [-7, -4, -1, 2, 5].forEach((x) => c.rect(cx + x, headY - 8, 2, 7, hair));
581
+ c.rect(cx - 1, headY - 10, 2, 9, hair);
582
+ } else if (style === 'ponytail') {
583
+ // Shikamaru — ponytail dangles down the back
584
+ c.rect(cx - 1, headY + 9, 2, 8, hair);
585
+ c.outlineRect(cx - 1, headY + 9, 2, 8, OUTLINE);
586
+ c.hline(cx - 2, headY + 9, 4, hairDark); // tie
587
+ } else if (style === 'slicked') {
588
+ // slicked-back hair extends slightly past nape
589
+ c.hline(cx - 5, headY + 9, 10, hair);
590
+ } else if (style === 'messy') {
591
+ // L — irregular tufts at top + sides
592
+ c.rect(cx - 8, headY - 3, 16, 3, hair);
593
+ c.px(cx - 5, headY - 4, hair);
594
+ c.px(cx - 2, headY - 4, hair);
595
+ c.px(cx + 2, headY - 4, hair);
596
+ c.px(cx + 5, headY - 4, hair);
597
+ // side tufts
598
+ c.rect(cx - 8, headY + 2, 2, 4, hair);
599
+ c.rect(cx + 6, headY + 2, 2, 4, hair);
600
+ } else if (style === 'wavy') {
601
+ // D — wavy strands cascade down the sides past the head edge
602
+ c.rect(cx - 8, headY - 1, 2, 9, hair);
603
+ c.rect(cx + 6, headY - 1, 2, 9, hair);
604
+ // wave detail
605
+ c.px(cx - 8, headY + 2, hairDark);
606
+ c.px(cx + 7, headY + 4, hairDark);
607
+ } else if (style === 'crewcut') {
608
+ // Ephraim — military buzz from behind: tight, dotted scalp coverage with
609
+ // a clear hairline at the nape. No volume, no fringe.
610
+ c.rect(cx - 6, headY - 1, 12, 5, hair);
611
+ // shaved fade — alternating darker rows give that "freshly clipped" look
612
+ c.hline(cx - 6, headY, 12, hairDark);
613
+ c.hline(cx - 6, headY + 2, 12, hairDark);
614
+ // sharp temples
615
+ c.px(cx - 7, headY + 1, hair);
616
+ c.px(cx + 6, headY + 1, hair);
617
+ }
618
+ // 'plain' needs nothing extra — base coverage above is enough.
619
+ }
620
+
621
+ function drawFace(c, cx, headY, agent, state, frame) {
622
+ const closed = state === 'away';
623
+ const face = agent.face || 'plain';
624
+ const WHITE = [255, 255, 255, 255];
625
+
626
+ // ---- Eyes ----
627
+ if (closed) {
628
+ // sleeping — horizontal slit eyelids; preserve under-circle on L
629
+ c.rect(cx - 4, headY + 5, 3, 1, OUTLINE);
630
+ c.rect(cx + 1, headY + 5, 3, 1, OUTLINE);
631
+ if (face === 'blank') {
632
+ c.rect(cx - 4, headY + 7, 2, 1, [60, 50, 70, 200]);
633
+ c.rect(cx + 2, headY + 7, 2, 1, [60, 50, 70, 200]);
634
+ }
635
+ } else if (face === 'stern') {
636
+ // Eagle: simple eyes — small dark pupils, no brow
637
+ c.rect(cx - 3, headY + 5, 1, 2, OUTLINE);
638
+ c.rect(cx + 3, headY + 5, 1, 2, OUTLINE);
639
+ } else if (face === 'focus') {
640
+ // MarketHunting: sharp focused eyes with brow tilt down to center
641
+ c.rect(cx - 4, headY + 4, 2, 2, OUTLINE);
642
+ c.rect(cx + 2, headY + 4, 2, 2, OUTLINE);
643
+ c.rect(cx - 3, headY + 4, 1, 1, WHITE);
644
+ c.rect(cx + 3, headY + 4, 1, 1, WHITE);
645
+ c.rect(cx - 2, headY + 3, 1, 1, OUTLINE);
646
+ c.rect(cx + 2, headY + 3, 1, 1, OUTLINE);
647
+ } else if (face === 'kind') {
648
+ // Sage: small calm dot eyes
649
+ c.rect(cx - 3, headY + 5, 1, 2, OUTLINE);
650
+ c.rect(cx + 3, headY + 5, 1, 2, OUTLINE);
651
+ } else if (face === 'sharp') {
652
+ // Senku: angular eyes (most will be hidden by goggles, but tail shows)
653
+ c.rect(cx - 4, headY + 4, 1, 1, OUTLINE);
654
+ c.rect(cx - 3, headY + 5, 2, 1, OUTLINE);
655
+ c.rect(cx + 4, headY + 4, 1, 1, OUTLINE);
656
+ c.rect(cx + 2, headY + 5, 2, 1, OUTLINE);
657
+ } else if (face === 'bored') {
658
+ // Shikamaru: half-lidded
659
+ c.rect(cx - 4, headY + 4, 3, 1, OUTLINE);
660
+ c.rect(cx + 1, headY + 4, 3, 1, OUTLINE);
661
+ c.rect(cx - 4, headY + 5, 3, 1, OUTLINE);
662
+ c.rect(cx + 1, headY + 5, 3, 1, OUTLINE);
663
+ } else if (face === 'mismatched') {
664
+ // Tyrion: intact left eye, half-closed scarred right eye, vertical scar through brow & cheek
665
+ c.rect(cx - 4, headY + 4, 2, 3, OUTLINE);
666
+ c.rect(cx - 3, headY + 5, 1, 1, WHITE);
667
+ c.rect(cx + 2, headY + 5, 2, 2, OUTLINE);
668
+ c.rect(cx + 3, headY + 5, 1, 1, WHITE);
669
+ c.rect(cx + 4, headY + 3, 1, 2, [140, 70, 50, 220]);
670
+ c.rect(cx + 4, headY + 7, 1, 2, [140, 70, 50, 220]);
671
+ } else if (face === 'smug') {
672
+ // Harvey: confident narrow eyes + arched brow
673
+ c.rect(cx - 4, headY + 5, 2, 1, OUTLINE);
674
+ c.rect(cx + 2, headY + 5, 2, 1, OUTLINE);
675
+ c.rect(cx - 3, headY + 4, 1, 1, OUTLINE);
676
+ c.rect(cx + 3, headY + 4, 1, 1, OUTLINE);
677
+ c.rect(cx + 2, headY + 3, 2, 1, OUTLINE);
678
+ } else if (face === 'blank') {
679
+ // L: wide hollow eyes + heavy under-circles
680
+ c.rect(cx - 4, headY + 4, 2, 3, OUTLINE);
681
+ c.rect(cx + 2, headY + 4, 2, 3, OUTLINE);
682
+ c.rect(cx - 3, headY + 5, 1, 1, WHITE);
683
+ c.rect(cx + 3, headY + 5, 1, 1, WHITE);
684
+ c.rect(cx - 4, headY + 7, 2, 1, [60, 50, 70, 220]);
685
+ c.rect(cx + 2, headY + 7, 2, 1, [60, 50, 70, 220]);
686
+ } else if (face === 'cheerful') {
687
+ // D: round bright eyes + blush
688
+ c.rect(cx - 4, headY + 4, 2, 3, OUTLINE);
689
+ c.rect(cx + 2, headY + 4, 2, 3, OUTLINE);
690
+ c.rect(cx - 3, headY + 4, 1, 1, WHITE);
691
+ c.rect(cx + 3, headY + 4, 1, 1, WHITE);
692
+ c.rect(cx - 6, headY + 8, 2, 1, [255, 160, 180, 200]);
693
+ c.rect(cx + 4, headY + 8, 2, 1, [255, 160, 180, 200]);
694
+ } else if (face === 'coach') {
695
+ // Ephraim: drill-instructor brows angled inward + narrowed eyes + jaw stubble.
696
+ // Brows angle DOWN toward center for an intense / focused gaze.
697
+ c.rect(cx - 5, headY + 3, 2, 1, OUTLINE);
698
+ c.rect(cx + 3, headY + 3, 2, 1, OUTLINE);
699
+ c.px(cx - 3, headY + 4, OUTLINE);
700
+ c.px(cx + 2, headY + 4, OUTLINE);
701
+ // Narrowed steely eyes — slit pupils
702
+ c.rect(cx - 4, headY + 5, 2, 1, OUTLINE);
703
+ c.rect(cx + 2, headY + 5, 2, 1, OUTLINE);
704
+ c.px(cx - 3, headY + 6, OUTLINE);
705
+ c.px(cx + 2, headY + 6, OUTLINE);
706
+ // Light jaw stubble — translucent shading along the jawline
707
+ const stubble = [40, 28, 24, 130];
708
+ c.px(cx - 5, headY + 9, stubble);
709
+ c.px(cx - 4, headY + 10, stubble);
710
+ c.px(cx + 4, headY + 9, stubble);
711
+ c.px(cx + 3, headY + 10, stubble);
712
+ c.px(cx, headY + 10, stubble);
713
+ } else if (face === 'snark') {
714
+ // House: piercing narrow-eyed sarcasm. SYMMETRIC flat brows with an
715
+ // inner-corner lift (skepticism — "really?"), narrow eye slits, and a
716
+ // 3-day stubble field along the jawline and chin. Distinct from Harvey's
717
+ // single-arch smug so the right and left sides match.
718
+ c.rect(cx - 5, headY + 3, 3, 1, OUTLINE);
719
+ c.rect(cx + 2, headY + 3, 3, 1, OUTLINE);
720
+ c.px(cx - 3, headY + 2, OUTLINE); // inner-left brow lift
721
+ c.px(cx + 2, headY + 2, OUTLINE); // inner-right brow lift
722
+ // Narrow horizontal eye slits — symmetric
723
+ c.rect(cx - 4, headY + 5, 2, 1, OUTLINE);
724
+ c.rect(cx + 2, headY + 5, 2, 1, OUTLINE);
725
+ // 3-day stubble — translucent shading along jawline and chin
726
+ const stubble = [60, 48, 44, 130];
727
+ c.px(cx - 5, headY + 9, stubble);
728
+ c.px(cx - 4, headY + 10, stubble);
729
+ c.px(cx + 4, headY + 9, stubble);
730
+ c.px(cx + 3, headY + 10, stubble);
731
+ c.px(cx - 1, headY + 10, stubble);
732
+ c.px(cx + 1, headY + 10, stubble);
733
+ } else {
734
+ // plain default
735
+ c.rect(cx - 4, headY + 4, 2, 3, OUTLINE);
736
+ c.rect(cx + 2, headY + 4, 2, 3, OUTLINE);
737
+ c.rect(cx - 3, headY + 5, 1, 1, WHITE);
738
+ c.rect(cx + 3, headY + 5, 1, 1, WHITE);
739
+ }
740
+
741
+ // ---- Mouth ----
742
+ // Sip overrides everything (open mouth O for drinking)
743
+ if (state === 'sip') {
744
+ c.rect(cx, headY + 8, 2, 2, OUTLINE);
745
+ return;
746
+ }
747
+ // Away → faint relaxed line
748
+ if (closed) {
749
+ c.rect(cx - 1, headY + 9, 3, 1, OUTLINE);
750
+ return;
751
+ }
752
+
753
+ if (face === 'stern') {
754
+ c.rect(cx - 2, headY + 9, 5, 1, OUTLINE);
755
+ } else if (face === 'focus') {
756
+ c.rect(cx - 1, headY + 9, 3, 1, OUTLINE);
757
+ c.rect(cx + 2, headY + 8, 1, 1, OUTLINE);
758
+ } else if (face === 'kind') {
759
+ c.rect(cx - 2, headY + 9, 1, 1, OUTLINE);
760
+ c.rect(cx - 1, headY + 10, 3, 1, OUTLINE);
761
+ c.rect(cx + 2, headY + 9, 1, 1, OUTLINE);
762
+ } else if (face === 'sharp') {
763
+ c.rect(cx - 2, headY + 9, 5, 1, OUTLINE);
764
+ c.rect(cx + 2, headY + 8, 1, 1, OUTLINE);
765
+ } else if (face === 'bored') {
766
+ c.rect(cx - 1, headY + 9, 2, 1, OUTLINE);
767
+ } else if (face === 'mismatched') {
768
+ c.rect(cx - 2, headY + 9, 3, 1, OUTLINE);
769
+ c.rect(cx + 1, headY + 8, 2, 1, OUTLINE);
770
+ } else if (face === 'smug') {
771
+ c.rect(cx - 1, headY + 9, 2, 1, OUTLINE);
772
+ c.rect(cx + 1, headY + 8, 2, 1, OUTLINE);
773
+ } else if (face === 'blank') {
774
+ c.rect(cx, headY + 9, 1, 1, OUTLINE);
775
+ // L's lollipop — stick out of the right side of the mouth + candy ball
776
+ c.rect(cx + 1, headY + 9, 4, 1, [200, 190, 170, 220]);
777
+ c.rect(cx + 5, headY + 7, 3, 3, [240, 90, 130, 255]);
778
+ c.outlineRect(cx + 5, headY + 7, 3, 3, OUTLINE);
779
+ c.px(cx + 6, headY + 7, [255, 200, 220, 255]);
780
+ } else if (face === 'cheerful') {
781
+ c.rect(cx - 2, headY + 10, 1, 1, OUTLINE);
782
+ c.rect(cx - 1, headY + 9, 3, 1, OUTLINE);
783
+ c.rect(cx + 2, headY + 10, 1, 1, OUTLINE);
784
+ } else if (face === 'coach') {
785
+ // Tight, determined line with corner pulls — neutral but resolute.
786
+ c.rect(cx - 1, headY + 9, 3, 1, OUTLINE);
787
+ c.px(cx - 2, headY + 9, OUTLINE);
788
+ c.px(cx + 2, headY + 9, OUTLINE);
789
+ } else if (face === 'snark') {
790
+ // House: tight asymmetric "I-already-know-you're-lying" smirk.
791
+ // Base flat line, right corner pulled up, left corner droops slightly.
792
+ c.rect(cx - 1, headY + 9, 3, 1, OUTLINE);
793
+ c.px(cx + 2, headY + 8, OUTLINE); // right corner up — smirk
794
+ c.px(cx - 2, headY + 10, OUTLINE); // left corner droops — sarcasm
795
+ } else {
796
+ c.rect(cx - 1, headY + 9, 3, 1, OUTLINE);
797
+ }
798
+ }
799
+
800
+ function drawHairBack(c, cx, headY, hair, style) {
801
+ // Backing fills behind head at top edges; lets hair "wrap" around skull silhouette.
802
+ if (style === 'spiky') {
803
+ c.rect(cx - 8, headY - 2, 16, 4, hair);
804
+ } else if (style === 'tallspiky') {
805
+ c.rect(cx - 8, headY - 3, 16, 5, hair);
806
+ } else if (style === 'ponytail') {
807
+ c.rect(cx - 7, headY - 2, 14, 4, hair);
808
+ c.rect(cx + 6, headY, 4, 9, hair);
809
+ } else if (style === 'slicked') {
810
+ c.rect(cx - 7, headY - 2, 14, 3, hair);
811
+ } else if (style === 'messy') {
812
+ c.rect(cx - 8, headY - 3, 16, 5, hair);
813
+ } else if (style === 'wavy') {
814
+ c.rect(cx - 7, headY - 2, 14, 4, hair);
815
+ c.rect(cx - 8, headY + 1, 2, 6, hair);
816
+ c.rect(cx + 6, headY + 1, 2, 6, hair);
817
+ } else if (style === 'crewcut') {
818
+ // Buzz cut backing — only 1px above scalp (no fluffy crown). Hairline sits
819
+ // tight to the head silhouette so the head reads bigger / shaved.
820
+ c.rect(cx - 7, headY - 1, 14, 2, hair);
821
+ } else { // plain
822
+ c.rect(cx - 7, headY - 2, 14, 4, hair);
823
+ }
824
+ }
825
+
826
+ function drawHairFront(c, cx, headY, hair, style) {
827
+ if (style === 'spiky') {
828
+ c.rect(cx - 6, headY, 12, 2, hair);
829
+ [-7, -4, -1, 2, 5].forEach((x) => c.rect(cx + x, headY - 5, 2, 4, hair));
830
+ } else if (style === 'tallspiky') {
831
+ c.rect(cx - 6, headY, 12, 2, hair);
832
+ // taller spikes — 7 rows tall instead of 4
833
+ [-7, -4, -1, 2, 5].forEach((x) => c.rect(cx + x, headY - 8, 2, 7, hair));
834
+ // a center spike that's even taller
835
+ c.rect(cx - 1, headY - 10, 2, 9, hair);
836
+ } else if (style === 'ponytail') {
837
+ c.rect(cx - 6, headY, 12, 2, hair);
838
+ c.rect(cx - 7, headY + 1, 2, 5, hair);
839
+ } else if (style === 'slicked') {
840
+ c.rect(cx - 6, headY, 12, 3, hair);
841
+ c.rect(cx + 4, headY + 1, 2, 3, hair);
842
+ } else if (style === 'messy') {
843
+ c.rect(cx - 6, headY, 12, 2, hair);
844
+ c.rect(cx - 5, headY + 1, 2, 1, hair);
845
+ c.rect(cx + 1, headY + 1, 3, 1, hair);
846
+ c.rect(cx + 5, headY + 1, 1, 2, hair);
847
+ } else if (style === 'wavy') {
848
+ c.rect(cx - 6, headY, 12, 2, hair);
849
+ c.rect(cx - 5, headY + 1, 1, 1, hair);
850
+ c.rect(cx - 2, headY + 1, 1, 1, hair);
851
+ c.rect(cx + 2, headY + 1, 1, 1, hair);
852
+ c.rect(cx + 5, headY + 1, 1, 1, hair);
853
+ } else if (style === 'crewcut') {
854
+ // Tight hairline across the top of the forehead — no fringe. Stippled
855
+ // pixels at the corners hint at a buzz fade rather than a hard line.
856
+ c.hline(cx - 5, headY, 10, hair);
857
+ c.px(cx - 6, headY + 1, hair);
858
+ c.px(cx + 5, headY + 1, hair);
859
+ } else { // plain
860
+ c.rect(cx - 6, headY, 12, 2, hair);
861
+ c.rect(cx + 3, headY + 2, 3, 1, hair); // side fringe
862
+ }
863
+ }
864
+
865
+ function drawArms(c, bodyX, bodyY, shirt, shirtDark, skin, drink, state, frame) {
866
+ // Default: arms hang at sides
867
+ const armTop = bodyY + 1;
868
+
869
+ if (state === 'active') {
870
+ // Typing cycle: alternating arm strikes.
871
+ // Left dy: 0, -1, 0, 0
872
+ // Right dy: 0, 0, 0, -1
873
+ const dyL = [0, -1, 0, 0][frame];
874
+ const dyR = [0, 0, 0, -1][frame];
875
+ // left (sprite's right, viewer's left)
876
+ c.rect(bodyX - 3, armTop + dyL, 3, 7, shirt);
877
+ c.outlineRect(bodyX - 3, armTop + dyL, 3, 7, OUTLINE);
878
+ c.rect(bodyX - 4, armTop + 7 + dyL, 4, 3, skin);
879
+ c.outlineRect(bodyX - 4, armTop + 7 + dyL, 4, 3, OUTLINE);
880
+ // right
881
+ c.rect(bodyX + 14, armTop + dyR, 3, 7, shirt);
882
+ c.outlineRect(bodyX + 14, armTop + dyR, 3, 7, OUTLINE);
883
+ c.rect(bodyX + 14, armTop + 7 + dyR, 4, 3, skin);
884
+ c.outlineRect(bodyX + 14, armTop + 7 + dyR, 4, 3, OUTLINE);
885
+ } else if (state === 'sip') {
886
+ // Approach / drink / drink / lower — cup height varies smoothly per frame.
887
+ // cupOffset: 0 (low), -2 (raised), -2 (drinking), 0 (lowered back)
888
+ const cupOffset = [0, -2, -2, 0][frame];
889
+ // Left arm hangs
890
+ c.rect(bodyX - 3, armTop, 3, 9, shirt);
891
+ c.outlineRect(bodyX - 3, armTop, 3, 9, OUTLINE);
892
+ c.rect(bodyX - 3, armTop + 9, 3, 3, skin);
893
+ c.outlineRect(bodyX - 3, armTop + 9, 3, 3, OUTLINE);
894
+ // Right arm raised — also lifts a touch when drinking
895
+ const armDy = cupOffset; // arm follows cup
896
+ c.rect(bodyX + 13, armTop - 1 + armDy, 3, 5, shirt);
897
+ c.outlineRect(bodyX + 13, armTop - 1 + armDy, 3, 5, OUTLINE);
898
+ c.rect(bodyX + 12, armTop - 4 + armDy, 4, 3, skin);
899
+ c.outlineRect(bodyX + 12, armTop - 4 + armDy, 4, 3, OUTLINE);
900
+ // Cup
901
+ const cupY = armTop - 8 + cupOffset;
902
+ c.rect(bodyX + 11, cupY, 6, 5, drink);
903
+ c.outlineRect(bodyX + 11, cupY, 6, 5, OUTLINE);
904
+ // Steam — alternates per frame for a wisp effect
905
+ const steamA = [255, 255, 255, 180];
906
+ if (frame === 0) {
907
+ c.rect(bodyX + 12, cupY - 3, 1, 2, steamA);
908
+ c.rect(bodyX + 15, cupY - 4, 1, 2, steamA);
909
+ } else if (frame === 1) {
910
+ c.rect(bodyX + 13, cupY - 4, 1, 2, steamA);
911
+ c.rect(bodyX + 14, cupY - 3, 1, 2, steamA);
912
+ } else if (frame === 2) {
913
+ c.rect(bodyX + 12, cupY - 4, 1, 2, steamA);
914
+ c.rect(bodyX + 15, cupY - 3, 1, 2, steamA);
915
+ } else {
916
+ c.rect(bodyX + 13, cupY - 3, 1, 2, steamA);
917
+ c.rect(bodyX + 14, cupY - 4, 1, 2, steamA);
918
+ }
919
+ } else if (state === 'idle') {
920
+ // Loose sway: 0, +1, 0, -1 lean (gentle pendulum)
921
+ const lean = [0, 1, 0, -1][frame];
922
+ c.rect(bodyX - 3 - lean, armTop, 3, 9, shirt);
923
+ c.outlineRect(bodyX - 3 - lean, armTop, 3, 9, OUTLINE);
924
+ c.rect(bodyX - 3 - lean, armTop + 9, 3, 3, skin);
925
+ c.outlineRect(bodyX - 3 - lean, armTop + 9, 3, 3, OUTLINE);
926
+ c.rect(bodyX + 14 + lean, armTop, 3, 9, shirt);
927
+ c.outlineRect(bodyX + 14 + lean, armTop, 3, 9, OUTLINE);
928
+ c.rect(bodyX + 14 + lean, armTop + 9, 3, 3, skin);
929
+ c.outlineRect(bodyX + 14 + lean, armTop + 9, 3, 3, OUTLINE);
930
+ } else if (state === 'away') {
931
+ // Slumped, arms drape on desk surface — tiny breath shift
932
+ const breath = [0, 0, 1, 0][frame];
933
+ c.rect(bodyX - 3, armTop + 4 + breath, 3, 8, shirt);
934
+ c.outlineRect(bodyX - 3, armTop + 4 + breath, 3, 8, OUTLINE);
935
+ c.rect(bodyX - 3, armTop + 12 + breath, 3, 2, skin);
936
+ c.rect(bodyX + 14, armTop + 4 + breath, 3, 8, shirt);
937
+ c.outlineRect(bodyX + 14, armTop + 4 + breath, 3, 8, OUTLINE);
938
+ c.rect(bodyX + 14, armTop + 12 + breath, 3, 2, skin);
939
+ } else { // walk
940
+ // Arms swing opposite to legs (left arm forward when right leg forward).
941
+ // Frame swings: 0 1 2 3
942
+ // legDxL pattern: -2 0 2 0 → left arm swings opposite: +1, 0, -1, 0
943
+ // legDxR pattern: +2 0 -2 0 → right arm swings opposite: -1, 0, +1, 0
944
+ const swingL = [1, 0, -1, 0][frame];
945
+ const swingR = [-1, 0, 1, 0][frame];
946
+ c.rect(bodyX - 3 + swingL, armTop, 3, 9, shirt);
947
+ c.outlineRect(bodyX - 3 + swingL, armTop, 3, 9, OUTLINE);
948
+ c.rect(bodyX - 3 + swingL, armTop + 9, 3, 3, skin);
949
+ c.outlineRect(bodyX - 3 + swingL, armTop + 9, 3, 3, OUTLINE);
950
+ c.rect(bodyX + 14 + swingR, armTop, 3, 9, shirt);
951
+ c.outlineRect(bodyX + 14 + swingR, armTop, 3, 9, OUTLINE);
952
+ c.rect(bodyX + 14 + swingR, armTop + 9, 3, 3, skin);
953
+ c.outlineRect(bodyX + 14 + swingR, armTop + 9, 3, 3, OUTLINE);
954
+ }
955
+ }
956
+
957
+ // Athlete-specific arm overlay: bicep bulge highlight + leather bracers on the
958
+ // forearm (the "armor"). Called AFTER drawArms paints bare-skin arms so the
959
+ // bracers sit on top of the wrist segment in every state/frame.
960
+ function drawAthleteArmDetail(c, bodyX, bodyY, agent, state, frame) {
961
+ const skin = hex(agent.skin);
962
+ const skinHi = shade(skin, 1.18);
963
+ const bracer = [40, 28, 18, 255]; // dark leather
964
+ const stud = [240, 200, 80, 255]; // gold stud on bracer
965
+ const armTop = bodyY + 1;
966
+
967
+ // Helper: paint a bracer (3-row dark band with one gold stud) at a given (x, y).
968
+ const paintBracer = (x, y) => {
969
+ c.rect(x, y, 3, 3, bracer);
970
+ c.outlineRect(x, y, 3, 3, OUTLINE);
971
+ c.px(x + 1, y + 1, stud);
972
+ };
973
+ // Helper: paint a bicep highlight (lighter shade strip on the upper arm) at (x, y).
974
+ // Two pixels wide gives the bicep a clear bulge instead of a thin line, and an
975
+ // extra dot near the elbow reads as a vein/peak detail.
976
+ const paintBicep = (x, y) => {
977
+ c.vline(x + 1, y + 1, 4, skinHi);
978
+ c.px(x + 2, y + 2, skinHi);
979
+ c.px(x + 1, y + 5, shade(skinHi, 0.85));
980
+ };
981
+
982
+ if (state === 'active') {
983
+ // Typing — bicep dy mirrors drawArms typing dy
984
+ const dyL = [0, -1, 0, 0][frame];
985
+ const dyR = [0, 0, 0, -1][frame];
986
+ paintBicep(bodyX - 3, armTop + dyL);
987
+ paintBicep(bodyX + 14, armTop + dyR);
988
+ // wrists are the skin block at armTop+7 — paint bracer there
989
+ paintBracer(bodyX - 4, armTop + 7 + dyL);
990
+ paintBracer(bodyX + 14, armTop + 7 + dyR);
991
+ } else if (state === 'sip') {
992
+ // Left arm hangs straight, right is raised holding the cup
993
+ paintBicep(bodyX - 3, armTop);
994
+ paintBracer(bodyX - 3, armTop + 9);
995
+ // raised right arm — bracer on the lifted forearm
996
+ const armDy = [0, -2, -2, 0][frame];
997
+ paintBicep(bodyX + 13, armTop - 1 + armDy);
998
+ paintBracer(bodyX + 12, armTop - 4 + armDy);
999
+ } else if (state === 'idle') {
1000
+ const lean = [0, 1, 0, -1][frame];
1001
+ paintBicep(bodyX - 3 - lean, armTop);
1002
+ paintBicep(bodyX + 14 + lean, armTop);
1003
+ paintBracer(bodyX - 3 - lean, armTop + 9);
1004
+ paintBracer(bodyX + 14 + lean, armTop + 9);
1005
+ } else if (state === 'away') {
1006
+ const breath = [0, 0, 1, 0][frame];
1007
+ paintBicep(bodyX - 3, armTop + 4 + breath);
1008
+ paintBicep(bodyX + 14, armTop + 4 + breath);
1009
+ paintBracer(bodyX - 3, armTop + 12 + breath);
1010
+ paintBracer(bodyX + 14, armTop + 12 + breath);
1011
+ } else { // walk
1012
+ const swingL = [1, 0, -1, 0][frame];
1013
+ const swingR = [-1, 0, 1, 0][frame];
1014
+ paintBicep(bodyX - 3 + swingL, armTop);
1015
+ paintBicep(bodyX + 14 + swingR, armTop);
1016
+ paintBracer(bodyX - 3 + swingL, armTop + 9);
1017
+ paintBracer(bodyX + 14 + swingR, armTop + 9);
1018
+ }
1019
+ }
1020
+
1021
+ // ---------- Build ----------
1022
+
1023
+ function build() {
1024
+ const W = SPRITE_W * STATES.length * FRAMES_PER_STATE;
1025
+ const H = SPRITE_H * AGENTS.length;
1026
+ const c = new Canvas(W, H);
1027
+
1028
+ AGENTS.forEach((agent, row) => {
1029
+ STATES.forEach((state, sIdx) => {
1030
+ for (let f = 0; f < FRAMES_PER_STATE; f++) {
1031
+ const col = sIdx * FRAMES_PER_STATE + f;
1032
+ const ox = col * SPRITE_W;
1033
+ const oy = row * SPRITE_H;
1034
+ drawSprite(c, ox, oy, agent, state, f);
1035
+ }
1036
+ });
1037
+ });
1038
+
1039
+ return { png: encodePNG(W, H, c.buf), W, H };
1040
+ }
1041
+
1042
+ if (require.main === module) {
1043
+ const { png, W, H } = build();
1044
+ const outPath = path.join(__dirname, '..', 'public', 'sprites.png');
1045
+ fs.writeFileSync(outPath, png);
1046
+
1047
+ const manifest = {
1048
+ spriteWidth: SPRITE_W,
1049
+ spriteHeight: SPRITE_H,
1050
+ states: STATES,
1051
+ framesPerState: FRAMES_PER_STATE,
1052
+ rows: AGENTS.map((a) => a.id),
1053
+ sheetWidth: W,
1054
+ sheetHeight: H,
1055
+ };
1056
+ fs.writeFileSync(
1057
+ path.join(__dirname, '..', 'public', 'sprites.json'),
1058
+ JSON.stringify(manifest, null, 2),
1059
+ );
1060
+
1061
+ console.log(`Wrote ${png.length} bytes -> ${outPath}`);
1062
+ console.log(`Sheet: ${W}x${H}, ${AGENTS.length} agents x ${STATES.length * FRAMES_PER_STATE} frames`);
1063
+ }
1064
+
1065
+ module.exports = { Canvas, encodePNG, drawSprite, AGENTS, STATES, FRAMES_PER_STATE, SPRITE_W, SPRITE_H };