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.
- package/LICENSE +21 -0
- package/README.md +171 -0
- package/bin/clawhouse.js +29 -0
- package/lib/usage.js +568 -0
- package/package.json +30 -0
- package/public/furniture.json +128 -0
- package/public/furniture.png +0 -0
- package/public/icon-180.png +0 -0
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/icon-maskable-512.png +0 -0
- package/public/index.html +3335 -0
- package/public/manifest.webmanifest +16 -0
- package/public/monitors.json +20 -0
- package/public/monitors.png +0 -0
- package/public/props.json +20 -0
- package/public/props.png +0 -0
- package/public/sprites.json +27 -0
- package/public/sprites.png +0 -0
- package/scripts/build-furniture.js +762 -0
- package/scripts/build-monitors.js +496 -0
- package/scripts/build-props.js +675 -0
- package/scripts/build-sprites.js +1065 -0
- package/scripts/export-profile-pics.js +180 -0
- package/scripts/generate-icons.js +124 -0
- package/server.js +944 -0
|
@@ -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 };
|