@kernel.chat/kbot 3.74.0 → 3.82.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,1601 @@
1
+ // kbot Sprite Engine — Pixel art robot character renderer
2
+ //
3
+ // Draws a 34x50 pixel robot character programmatically using fillRect().
4
+ // Designed for the livestream renderer but usable anywhere with a Canvas2D context.
5
+ // AAA indie pixel art quality: hue-shifted shading, sub-pixel AA, dithering, rim lighting.
6
+ // ─── Color Palette (Hue-Shifted) ──────────────────────────────
7
+ // Professional pixel art: shadows shift cool (blue-green), highlights shift warm (yellow-green)
8
+ const PAL = {
9
+ bodyMain: '#00cc33', // mid terminal green
10
+ bodyDark: '#0a7035', // shadow — shifted toward blue-green
11
+ bodyDeepShadow: '#064d2a', // darkest shadow — almost blue-green
12
+ bodyMidLight: '#2de85c', // between main and light
13
+ bodyLight: '#4dff7a', // highlight — shifted toward yellow-green
14
+ bodySpecular: '#b0ffc8', // brightest specular — nearly white-green
15
+ bodyWarmGlow: '#7aff9a', // warm light bounce (bottom edges)
16
+ rimLight: '#40e8a0', // blue-tinted green rim light (right side)
17
+ outline: '#043820', // character outline — almost black-green
18
+ bodyAccent: '#6B5B95', // amethyst — chest panel frame
19
+ accentDark: '#4a3d6e', // dark amethyst for dithering
20
+ amber: '#ffb000', // secondary accent
21
+ black: '#1a1a2e',
22
+ white: '#e6edf3',
23
+ jetOrange: '#e8820c',
24
+ jetYellow: '#f0c040',
25
+ };
26
+ const MOOD_COLORS = {
27
+ idle: '#3fb950',
28
+ talking: '#58a6ff',
29
+ thinking: '#bc8cff',
30
+ excited: '#f0c040',
31
+ dancing: '#ff6ec7', // starting color, cycles through rainbow
32
+ wave: '#58a6ff',
33
+ error: '#f85149',
34
+ dreaming: '#4a6670', // muted teal — sleepy
35
+ };
36
+ const RAINBOW = ['#f85149', '#f0c040', '#3fb950', '#58a6ff', '#bc8cff', '#ff6ec7'];
37
+ // ─── Helpers ───────────────────────────────────────────────────
38
+ function px(ctx, x, y, w, h, color, scale, offsetX, offsetY) {
39
+ ctx.fillStyle = color;
40
+ ctx.fillRect(offsetX + x * scale, offsetY + y * scale, w * scale, h * scale);
41
+ }
42
+ function getMoodColor(mood, frame, moodColor) {
43
+ if (moodColor) {
44
+ return `rgb(${moodColor[0]},${moodColor[1]},${moodColor[2]})`;
45
+ }
46
+ if (mood === 'dancing') {
47
+ return RAINBOW[frame % RAINBOW.length];
48
+ }
49
+ return MOOD_COLORS[mood] ?? MOOD_COLORS.idle;
50
+ }
51
+ /** Dim a hex color by a factor (0-1, where 1 = full brightness) */
52
+ function dimColor(hex, factor) {
53
+ const r = parseInt(hex.slice(1, 3), 16);
54
+ const g = parseInt(hex.slice(3, 5), 16);
55
+ const b = parseInt(hex.slice(5, 7), 16);
56
+ const dr = Math.round(r * factor);
57
+ const dg = Math.round(g * factor);
58
+ const db = Math.round(b * factor);
59
+ return `rgb(${dr},${dg},${db})`;
60
+ }
61
+ /** Checkerboard dithering between two colors over a pixel region */
62
+ function dither(ctx, x, y, w, h, color1, color2, scale, ox, oy) {
63
+ for (let py = 0; py < h; py++) {
64
+ for (let dpx = 0; dpx < w; dpx++) {
65
+ const c = (dpx + py) % 2 === 0 ? color1 : color2;
66
+ ctx.fillStyle = c;
67
+ ctx.fillRect(ox + (x + dpx) * scale, oy + (y + py) * scale, scale, scale);
68
+ }
69
+ }
70
+ }
71
+ /** Draw a 1px outline silhouette for a rectangular region */
72
+ function outlineRect(ctx, x, y, w, h, color, scale, ox, oy) {
73
+ // Outline is 1px larger on all sides
74
+ px(ctx, x - 1, y - 1, w + 2, h + 2, color, scale, ox, oy);
75
+ }
76
+ // ─── Sub-drawers ───────────────────────────────────────────────
77
+ function drawAntenna(ctx, s, ox, oy, glowColor, frame, breathPhase = 0) {
78
+ // Antenna sway: pole shifts 1px left/right on sin(frame * 0.5)
79
+ // Breathing adds subtle forward/back sway
80
+ const baseSway = Math.round(Math.sin(frame * 0.5));
81
+ const breathSway = breathPhase === 1 || breathPhase === 2 ? 1 : breathPhase === 4 || breathPhase === 5 ? -1 : 0;
82
+ const sway = baseSway + breathSway;
83
+ // Outline for antenna pole
84
+ outlineRect(ctx, 14 + sway, 0, 3, 6, PAL.outline, s, ox, oy);
85
+ // Antenna pole: 3px wide, 6px tall, centered on head
86
+ px(ctx, 14 + sway, 0, 3, 6, PAL.bodyDark, s, ox, oy);
87
+ // Hue-shifted highlight on left edge of pole (facing light)
88
+ px(ctx, 14 + sway, 0, 1, 6, PAL.bodyMain, s, ox, oy);
89
+ // Outline for glowing ball
90
+ outlineRect(ctx, 13 + sway, 0, 5, 3, PAL.outline, s, ox, oy - 3 * s);
91
+ // Glowing ball on top — 5x3, uses mood color, pulses between bright and dim
92
+ const pulse = (Math.sin(frame * 1.2) + 1) / 2; // 0..1
93
+ const ballHex = glowColor.startsWith('rgb') ? '#3fb950' : glowColor;
94
+ const ballColor = dimColor(ballHex, 0.5 + pulse * 0.5);
95
+ px(ctx, 13 + sway, 0, 5, 3, ballColor, s, ox, oy - 3 * s);
96
+ // Dithered glow edge around antenna ball
97
+ dither(ctx, 12 + sway, 0, 1, 3, ballColor, 'transparent', s, ox, oy - 3 * s);
98
+ dither(ctx, 18 + sway, 0, 1, 3, ballColor, 'transparent', s, ox, oy - 3 * s);
99
+ // Specular highlight dot on antenna ball (technique 8)
100
+ px(ctx, 14 + sway, 0, 1, 1, PAL.bodySpecular, s, ox, oy - 3 * s);
101
+ }
102
+ function drawHead(ctx, s, ox, oy, eyeColor, mood, frame, headShiftX) {
103
+ // Chibi proportions: 14x11 head (was 12x10)
104
+ const hx = 9 + headShiftX;
105
+ const hy = 5;
106
+ // 1px outline silhouette (technique 4)
107
+ outlineRect(ctx, hx, hy, 14, 11, PAL.outline, s, ox, oy);
108
+ // Sub-pixel AA on rounded corners: deep shadow pixels for smoother curves (technique 2)
109
+ px(ctx, hx - 1, hy, 1, 1, PAL.bodyDeepShadow, s, ox, oy);
110
+ px(ctx, hx + 14, hy, 1, 1, PAL.bodyDeepShadow, s, ox, oy);
111
+ px(ctx, hx - 1, hy + 10, 1, 1, PAL.bodyDeepShadow, s, ox, oy);
112
+ px(ctx, hx + 14, hy + 10, 1, 1, PAL.bodyDeepShadow, s, ox, oy);
113
+ // Head base (shadow layer)
114
+ px(ctx, hx, hy, 14, 11, PAL.bodyDark, s, ox, oy);
115
+ // Cut corners for rounded look
116
+ px(ctx, hx, hy, 1, 1, 'transparent', s, ox, oy);
117
+ px(ctx, hx + 13, hy, 1, 1, 'transparent', s, ox, oy);
118
+ px(ctx, hx, hy + 10, 1, 1, 'transparent', s, ox, oy);
119
+ px(ctx, hx + 13, hy + 10, 1, 1, 'transparent', s, ox, oy);
120
+ // Head fill (inner area) — bodyMain
121
+ px(ctx, hx + 1, hy + 1, 12, 9, PAL.bodyMain, s, ox, oy);
122
+ // Top surface: bodyMidLight (facing light source)
123
+ px(ctx, hx + 2, hy + 1, 10, 2, PAL.bodyMidLight, s, ox, oy);
124
+ // Highlight strip across very top
125
+ px(ctx, hx + 2, hy + 1, 10, 1, PAL.bodyLight, s, ox, oy);
126
+ // (#3) Highlight outlines — top-left edges (light source: top-left)
127
+ px(ctx, hx + 1, hy, 12, 1, PAL.bodyLight, s, ox, oy); // top edge
128
+ px(ctx, hx, hy + 1, 1, 9, PAL.bodyMidLight, s, ox, oy); // left edge
129
+ // Deep shadow on underside of head
130
+ px(ctx, hx + 1, hy + 9, 12, 1, PAL.bodyDeepShadow, s, ox, oy);
131
+ // Rim light on right edge (technique 6)
132
+ px(ctx, hx + 13, hy + 1, 1, 9, PAL.rimLight, s, ox, oy);
133
+ // Specular highlight dot — top-left brightest point (technique 8)
134
+ px(ctx, hx + 2, hy + 1, 1, 1, PAL.bodySpecular, s, ox, oy);
135
+ // ── Eyes ──
136
+ const eyeY = hy + 4;
137
+ // (#5) Blink every 24 frames (4-second cycle at 6fps) instead of every 4 frames
138
+ const blinkCycle = frame % 24;
139
+ const fullBlink = mood === 'idle' && blinkCycle === 23;
140
+ const halfBlink = mood === 'idle' && blinkCycle === 22;
141
+ // (#9) Dreaming: eyes closed (flat line)
142
+ const eyesClosed = mood === 'dreaming';
143
+ const eyeH = fullBlink || eyesClosed ? 1 : halfBlink ? 2 : 3;
144
+ // Eye glow background — dimmed if dreaming
145
+ const eyeC = mood === 'dreaming' ? dimColor(eyeColor.startsWith('rgb') ? '#4a6670' : eyeColor, 0.5) : eyeColor;
146
+ px(ctx, hx + 2, eyeY, 4, eyeH, eyeC, s, ox, oy);
147
+ px(ctx, hx + 8, eyeY, 4, eyeH, eyeC, s, ox, oy);
148
+ // Specular highlights on eyes — makes them look glassy/alive (technique 8)
149
+ if (!fullBlink && !eyesClosed) {
150
+ px(ctx, hx + 2, eyeY, 1, 1, PAL.bodySpecular, s, ox, oy);
151
+ px(ctx, hx + 8, eyeY, 1, 1, PAL.bodySpecular, s, ox, oy);
152
+ }
153
+ if (!fullBlink && !eyesClosed) {
154
+ // Pupils — shift based on mood
155
+ // (#8) Eye pupil tracking: shift toward chat panel side (right)
156
+ let pupilOffX = 2; // default: look right toward chat panel
157
+ let pupilOffY = 1;
158
+ if (mood === 'idle') {
159
+ pupilOffX = 2;
160
+ pupilOffY = 1;
161
+ } // look toward chat
162
+ if (mood === 'thinking') {
163
+ pupilOffX = frame % 2 === 0 ? 0 : 2;
164
+ pupilOffY = 0;
165
+ }
166
+ if (mood === 'wave') {
167
+ pupilOffX = 2;
168
+ pupilOffY = 0;
169
+ }
170
+ if (mood === 'dancing') {
171
+ pupilOffX = frame % 3;
172
+ pupilOffY = frame % 2;
173
+ }
174
+ if (mood === 'talking') {
175
+ pupilOffX = 2;
176
+ pupilOffY = 1;
177
+ } // look at chat
178
+ px(ctx, hx + 2 + pupilOffX, eyeY + pupilOffY, 1, 1, PAL.black, s, ox, oy);
179
+ px(ctx, hx + 8 + pupilOffX, eyeY + pupilOffY, 1, 1, PAL.black, s, ox, oy);
180
+ }
181
+ // ── Mouth ──
182
+ const mouthY = hy + 8;
183
+ const mouthX = hx + 4;
184
+ drawMouth(ctx, s, ox, oy, mouthX, mouthY, mood, frame);
185
+ }
186
+ function drawMouth(ctx, s, ox, oy, mx, my, mood, frame) {
187
+ if (mood === 'talking') {
188
+ // Mouth animation: open/half/wide/closed
189
+ switch (frame % 4) {
190
+ case 0: // open rectangle
191
+ px(ctx, mx, my, 6, 2, PAL.black, s, ox, oy);
192
+ break;
193
+ case 1: // half open
194
+ px(ctx, mx + 1, my, 4, 1, PAL.black, s, ox, oy);
195
+ break;
196
+ case 2: // wide open — animation smear: 1px wider than closed (technique 9)
197
+ px(ctx, mx - 2, my, 10, 2, PAL.black, s, ox, oy);
198
+ px(ctx, mx - 1, my, 8, 2, '#f85149', s, ox, oy); // inner red
199
+ break;
200
+ case 3: // closed line
201
+ px(ctx, mx, my, 6, 1, PAL.black, s, ox, oy);
202
+ break;
203
+ }
204
+ }
205
+ else if (mood === 'excited') {
206
+ // Big smile / open mouth
207
+ if (frame % 4 === 2) {
208
+ // Wide open
209
+ px(ctx, mx, my, 6, 2, PAL.black, s, ox, oy);
210
+ px(ctx, mx + 1, my, 4, 1, '#f85149', s, ox, oy);
211
+ }
212
+ else if (frame % 2 === 1) {
213
+ // Neutral
214
+ px(ctx, mx, my, 6, 1, PAL.black, s, ox, oy);
215
+ }
216
+ else {
217
+ // Smile — line curving up at ends
218
+ px(ctx, mx, my, 6, 1, PAL.black, s, ox, oy);
219
+ px(ctx, mx - 1, my - 1, 1, 1, PAL.black, s, ox, oy); // left upturn
220
+ px(ctx, mx + 6, my - 1, 1, 1, PAL.black, s, ox, oy); // right upturn
221
+ }
222
+ }
223
+ else if (mood === 'error') {
224
+ // Frown — line curving down at ends
225
+ px(ctx, mx, my, 6, 1, '#f85149', s, ox, oy);
226
+ px(ctx, mx - 1, my + 1, 1, 1, '#f85149', s, ox, oy);
227
+ px(ctx, mx + 6, my + 1, 1, 1, '#f85149', s, ox, oy);
228
+ }
229
+ else if (mood === 'thinking') {
230
+ // Small O
231
+ px(ctx, mx + 1, my, 3, 2, PAL.black, s, ox, oy);
232
+ px(ctx, mx + 2, my, 1, 1, PAL.bodyMain, s, ox, oy); // hollow center top
233
+ }
234
+ else if (mood === 'dancing') {
235
+ // Wide smile
236
+ px(ctx, mx, my, 6, 1, PAL.black, s, ox, oy);
237
+ px(ctx, mx - 1, my - 1, 1, 1, PAL.black, s, ox, oy);
238
+ px(ctx, mx + 6, my - 1, 1, 1, PAL.black, s, ox, oy);
239
+ }
240
+ else if (mood === 'dreaming') {
241
+ // Slight frown / relaxed mouth — sleeping
242
+ px(ctx, mx + 1, my, 4, 1, dimColor('#4a6670', 0.6), s, ox, oy);
243
+ }
244
+ else {
245
+ // Default: neutral line
246
+ px(ctx, mx, my, 6, 1, PAL.black, s, ox, oy);
247
+ }
248
+ }
249
+ function drawNeck(ctx, s, ox, oy) {
250
+ // Neck: 4x2 connector at center, below head (head bottom at y=16)
251
+ outlineRect(ctx, 14, 16, 4, 2, PAL.outline, s, ox, oy);
252
+ px(ctx, 14, 16, 4, 2, PAL.bodyDark, s, ox, oy);
253
+ px(ctx, 14, 16, 4, 1, PAL.bodyMain, s, ox, oy);
254
+ // Rim light on right edge of neck
255
+ px(ctx, 17, 16, 1, 2, PAL.rimLight, s, ox, oy);
256
+ }
257
+ function drawTorso(ctx, s, ox, oy, accentColor, mood, frame, bodyShiftY, torsoWidthBonus = 0, torsoHeightPenalty = 0) {
258
+ // Wider torso (18px base) to accommodate larger chest display
259
+ const tx = 7 - Math.floor(torsoWidthBonus / 2);
260
+ const ty = 18 + bodyShiftY;
261
+ const tw = 18 + torsoWidthBonus;
262
+ const th = 14 - torsoHeightPenalty;
263
+ // 1px outline silhouette (technique 4)
264
+ outlineRect(ctx, tx, ty, tw, th, PAL.outline, s, ox, oy);
265
+ // Torso base — deep shadow
266
+ px(ctx, tx, ty, tw, th, PAL.bodyDark, s, ox, oy);
267
+ // Torso fill — main color
268
+ px(ctx, tx + 1, ty + 1, tw - 2, th - 2, PAL.bodyMain, s, ox, oy);
269
+ // Top surface: midLight (facing light)
270
+ px(ctx, tx + 2, ty + 1, tw - 4, 2, PAL.bodyMidLight, s, ox, oy);
271
+ // Highlight strip on very top
272
+ px(ctx, tx + 2, ty + 1, tw - 4, 1, PAL.bodyLight, s, ox, oy);
273
+ // Selective highlights — top and left edge only (light source: top-left)
274
+ px(ctx, tx, ty, tw, 1, PAL.bodyLight, s, ox, oy); // top edge
275
+ px(ctx, tx, ty, 1, th, PAL.bodyMidLight, s, ox, oy); // left edge (facing light)
276
+ // Dithered transition on left edge: bodyMain → bodyDark (technique 3)
277
+ dither(ctx, tx + 1, ty + 3, 2, th - 6, PAL.bodyMain, PAL.bodyMidLight, s, ox, oy);
278
+ // Right side in shadow — deep shadow band
279
+ px(ctx, tx + tw - 2, ty + 1, 1, th - 2, PAL.bodyDeepShadow, s, ox, oy);
280
+ // Rim light on right edge (technique 6)
281
+ px(ctx, tx + tw - 1, ty + 1, 1, th - 2, PAL.rimLight, s, ox, oy);
282
+ // Bottom underside shadow
283
+ px(ctx, tx + 1, ty + th - 3, tw - 2, 1, PAL.bodyDeepShadow, s, ox, oy);
284
+ // Warm light bounce on bottom edge (technique 1 — ground bounce)
285
+ px(ctx, tx + 2, ty + th - 2, tw - 4, 1, PAL.bodyWarmGlow, s, ox, oy);
286
+ // Belt line at bottom of torso (1px accent color line)
287
+ px(ctx, tx + 1, ty + th - 2, tw - 2, 1, PAL.bodyAccent, s, ox, oy);
288
+ // Dark gap between torso and legs (hip joint line)
289
+ px(ctx, tx + 1, ty + th - 1, tw - 2, 1, PAL.black, s, ox, oy);
290
+ // Shoulder rivets (1px dots at arm attachment points)
291
+ px(ctx, tx + 1, ty + 1, 1, 1, PAL.amber, s, ox, oy); // left shoulder rivet
292
+ px(ctx, tx + tw - 2, ty + 1, 1, 1, PAL.amber, s, ox, oy); // right shoulder rivet
293
+ // Serial number dots on lower torso (3 tiny dots in a row)
294
+ px(ctx, tx + 2, ty + th - 4, 1, 1, PAL.bodyMidLight, s, ox, oy);
295
+ px(ctx, tx + 4, ty + th - 4, 1, 1, PAL.bodyMidLight, s, ox, oy);
296
+ px(ctx, tx + 6, ty + th - 4, 1, 1, PAL.bodyMidLight, s, ox, oy);
297
+ // Bigger chest display panel frame (10x8 amethyst accent)
298
+ px(ctx, 11, ty + 2, 10, 8, PAL.bodyAccent, s, ox, oy);
299
+ // Dithered edge on chest panel frame (technique 3)
300
+ dither(ctx, 10, ty + 2, 1, 8, PAL.bodyAccent, PAL.bodyMain, s, ox, oy);
301
+ dither(ctx, 21, ty + 2, 1, 8, PAL.accentDark, PAL.bodyMain, s, ox, oy);
302
+ // Specular highlight on chest panel frame (technique 8)
303
+ px(ctx, 11, ty + 2, 1, 1, PAL.bodySpecular, s, ox, oy);
304
+ // Chest display inner (8x6 dark)
305
+ px(ctx, 12, ty + 3, 8, 6, PAL.black, s, ox, oy);
306
+ // Animated display content (now using 8x6 inner area)
307
+ drawChestDisplay(ctx, s, ox, oy, 12, ty + 3, accentColor, mood, frame);
308
+ }
309
+ function drawChestDisplay(ctx, s, ox, oy, dx, dy, color, mood, frame) {
310
+ // Inner display area: 8x6 pixels
311
+ const dimC = dimColor(color.startsWith('rgb') ? '#3fb950' : color, 0.3);
312
+ if (mood === 'idle') {
313
+ // Scrolling sine wave pattern across 8px width
314
+ for (let i = 0; i < 8; i++) {
315
+ const waveY = Math.round(Math.sin((i + frame) * 0.8) * 2) + 2;
316
+ px(ctx, dx + i, dy + waveY, 1, 1, color, s, ox, oy);
317
+ // Dimmer trail below
318
+ if (waveY + 1 < 6)
319
+ px(ctx, dx + i, dy + waveY + 1, 1, 1, dimC, s, ox, oy);
320
+ }
321
+ }
322
+ else if (mood === 'talking') {
323
+ // Proper audio equalizer bars (8 bars, varying heights)
324
+ const barHeights = [2, 4, 3, 5, 4, 3, 5, 2];
325
+ for (let i = 0; i < 8; i++) {
326
+ const h = Math.min(6, barHeights[(i + frame) % 8]);
327
+ px(ctx, dx + i, dy + (6 - h), 1, h, color, s, ox, oy);
328
+ }
329
+ }
330
+ else if (mood === 'dancing') {
331
+ // Animated music note that bounces
332
+ const bounceY = Math.round(Math.sin(frame * 1.2) * 2);
333
+ const nx = dx + 2 + (frame % 4);
334
+ const noteY = dy + 1 + bounceY;
335
+ if (noteY >= dy && noteY + 3 <= dy + 6) {
336
+ px(ctx, nx, noteY, 1, 3, color, s, ox, oy); // stem
337
+ px(ctx, nx, noteY, 2, 1, color, s, ox, oy); // flag
338
+ px(ctx, nx - 1, noteY + 2, 2, 1, color, s, ox, oy); // note head
339
+ }
340
+ // Background pulse dots
341
+ px(ctx, dx, dy + 5, 1, 1, dimC, s, ox, oy);
342
+ px(ctx, dx + 7, dy + 5, 1, 1, dimC, s, ox, oy);
343
+ }
344
+ else if (mood === 'thinking') {
345
+ // Rotating dots in a circle pattern
346
+ const cx = dx + 3;
347
+ const cy = dy + 2;
348
+ const positions = [
349
+ [0, -2], [2, -1], [2, 1], [0, 2], [-2, 1], [-2, -1],
350
+ ];
351
+ for (let i = 0; i < positions.length; i++) {
352
+ const active = (frame + i) % positions.length;
353
+ const [offX, offY] = positions[i];
354
+ const c = active < 3 ? color : dimC;
355
+ px(ctx, cx + offX, cy + offY, 1, 1, c, s, ox, oy);
356
+ }
357
+ // Center dot
358
+ px(ctx, cx, cy, 2, 2, color, s, ox, oy);
359
+ }
360
+ else if (mood === 'excited') {
361
+ // Pulsing exclamation with radiating lines
362
+ const pulse = frame % 4;
363
+ // Center exclamation mark
364
+ px(ctx, dx + 3, dy, 2, 3, color, s, ox, oy); // bar
365
+ px(ctx, dx + 3, dy + 4, 2, 1, color, s, ox, oy); // dot
366
+ // Radiating lines
367
+ if (pulse < 2) {
368
+ px(ctx, dx + 1, dy + 1, 1, 1, color, s, ox, oy); // left
369
+ px(ctx, dx + 6, dy + 1, 1, 1, color, s, ox, oy); // right
370
+ px(ctx, dx + 3, dy - 1 < dy ? dy : dy, 2, 1, dimC, s, ox, oy); // top
371
+ }
372
+ if (pulse >= 2) {
373
+ px(ctx, dx, dy + 2, 1, 1, dimC, s, ox, oy);
374
+ px(ctx, dx + 7, dy + 2, 1, 1, dimC, s, ox, oy);
375
+ }
376
+ }
377
+ else if (mood === 'error') {
378
+ // Animated X that flashes
379
+ const flash = frame % 4 < 2;
380
+ const errC = flash ? '#f85149' : '#a82020';
381
+ // X across 8x6
382
+ px(ctx, dx, dy, 1, 1, errC, s, ox, oy);
383
+ px(ctx, dx + 1, dy + 1, 1, 1, errC, s, ox, oy);
384
+ px(ctx, dx + 2, dy + 2, 1, 1, errC, s, ox, oy);
385
+ px(ctx, dx + 3, dy + 3, 1, 1, errC, s, ox, oy);
386
+ px(ctx, dx + 4, dy + 2, 1, 1, errC, s, ox, oy);
387
+ px(ctx, dx + 5, dy + 1, 1, 1, errC, s, ox, oy);
388
+ px(ctx, dx + 6, dy, 1, 1, errC, s, ox, oy);
389
+ // Second diagonal
390
+ px(ctx, dx + 6, dy + 5, 1, 1, errC, s, ox, oy);
391
+ px(ctx, dx + 5, dy + 4, 1, 1, errC, s, ox, oy);
392
+ px(ctx, dx + 4, dy + 3, 1, 1, errC, s, ox, oy);
393
+ px(ctx, dx + 2, dy + 3, 1, 1, errC, s, ox, oy);
394
+ px(ctx, dx + 1, dy + 4, 1, 1, errC, s, ox, oy);
395
+ px(ctx, dx, dy + 5, 1, 1, errC, s, ox, oy);
396
+ }
397
+ else if (mood === 'dreaming') {
398
+ // Gentle floating Z's
399
+ const floatOff = frame % 8;
400
+ const zColor = dimColor(color.startsWith('rgb') ? '#4a6670' : color, 0.6);
401
+ // Small Z
402
+ const zy = dy + 4 - Math.floor(floatOff / 2);
403
+ if (zy >= dy && zy + 2 <= dy + 6) {
404
+ px(ctx, dx + 1, zy, 2, 1, zColor, s, ox, oy);
405
+ px(ctx, dx + 2, zy + 1, 1, 1, zColor, s, ox, oy);
406
+ px(ctx, dx + 1, zy + 2, 2, 1, zColor, s, ox, oy);
407
+ }
408
+ // Larger Z offset
409
+ const zy2 = dy + 2 - Math.floor((floatOff + 4) % 8 / 2);
410
+ if (zy2 >= dy && zy2 + 2 <= dy + 6) {
411
+ px(ctx, dx + 5, zy2, 2, 1, zColor, s, ox, oy);
412
+ px(ctx, dx + 6, zy2 + 1, 1, 1, zColor, s, ox, oy);
413
+ px(ctx, dx + 5, zy2 + 2, 2, 1, zColor, s, ox, oy);
414
+ }
415
+ }
416
+ else {
417
+ // Default: pulsing center dot
418
+ const pulse = (Math.sin(frame * 0.8) + 1) / 2;
419
+ const coreHex = color.startsWith('rgb') ? '#3fb950' : color;
420
+ const coreColor = dimColor(coreHex, 0.6 + pulse * 0.4);
421
+ px(ctx, dx + 3, dy + 2, 2, 2, coreColor, s, ox, oy);
422
+ }
423
+ }
424
+ function getArmPose(mood, frame) {
425
+ if (mood === 'dancing') {
426
+ const poses = [
427
+ { leftAngle: 'up', rightAngle: 'down' },
428
+ { leftAngle: 'down', rightAngle: 'up' },
429
+ { leftAngle: 'out', rightAngle: 'out' },
430
+ { leftAngle: 'up', rightAngle: 'down' },
431
+ { leftAngle: 'down', rightAngle: 'down' },
432
+ { leftAngle: 'out', rightAngle: 'out' },
433
+ ];
434
+ return poses[frame % poses.length];
435
+ }
436
+ if (mood === 'wave') {
437
+ // (#7) Anticipation: frame 0 drops arm slightly (down) before raising
438
+ const rightAngles = ['down', 'up', 'up-high', 'up'];
439
+ return { leftAngle: 'down', rightAngle: rightAngles[frame % 4] };
440
+ }
441
+ if (mood === 'excited') {
442
+ if (frame % 4 === 0 || frame % 4 === 2) {
443
+ return { leftAngle: 'up', rightAngle: 'up' };
444
+ }
445
+ if (frame % 4 === 2) {
446
+ return { leftAngle: 'out', rightAngle: 'out' };
447
+ }
448
+ return { leftAngle: 'down', rightAngle: 'down' };
449
+ }
450
+ // Default: arms down
451
+ return { leftAngle: 'down', rightAngle: 'down' };
452
+ }
453
+ function drawArms(ctx, s, ox, oy, pose, bodyShiftY, mood = 'idle', frame = 0) {
454
+ const shoulderY = 19 + bodyShiftY;
455
+ // Breathing arm shift: arms shift 1px outward on inhale, inward on exhale (technique 7)
456
+ const breathArmShift = mood === 'idle' ? getBreathArmShift(frame) : 0;
457
+ // Left arm
458
+ drawSingleArm(ctx, s, ox, oy, 'left', 8 - breathArmShift, shoulderY, pose.leftAngle, mood, frame);
459
+ // Right arm
460
+ drawSingleArm(ctx, s, ox, oy, 'right', 24 + breathArmShift, shoulderY, pose.rightAngle, mood, frame);
461
+ }
462
+ /** Get breathing arm shift for the 12-frame breathing cycle */
463
+ function getBreathArmShift(frame) {
464
+ const breathFrame = frame % 12;
465
+ if (breathFrame >= 1 && breathFrame <= 3)
466
+ return 1; // inhale: arms outward
467
+ if (breathFrame >= 4 && breathFrame <= 5)
468
+ return 0; // exhale: back to normal
469
+ return 0;
470
+ }
471
+ function drawSingleArm(ctx, s, ox, oy, _side, startX, startY, angle, mood = 'idle', frame = 0) {
472
+ const isLeft = _side === 'left';
473
+ if (angle === 'down') {
474
+ // Arm hanging straight down: 3px wide, 12px tall
475
+ const ax = isLeft ? startX - 3 : startX;
476
+ // Outline
477
+ outlineRect(ctx, ax, startY, 3, 12, PAL.outline, s, ox, oy);
478
+ px(ctx, ax, startY, 3, 12, PAL.bodyDark, s, ox, oy);
479
+ px(ctx, ax, startY, 3, 1, PAL.bodyMidLight, s, ox, oy); // shoulder highlight
480
+ px(ctx, ax + (isLeft ? 0 : 2), startY + 1, 1, 10, isLeft ? PAL.bodyMidLight : PAL.rimLight, s, ox, oy); // edge highlight/rim
481
+ px(ctx, ax, startY + 5, 3, 1, PAL.bodyLight, s, ox, oy); // elbow joint
482
+ // Hand: 4x3
483
+ const hx = isLeft ? ax - 1 : ax;
484
+ outlineRect(ctx, hx, startY + 12, 4, 3, PAL.outline, s, ox, oy);
485
+ px(ctx, hx, startY + 12, 4, 3, PAL.bodyDark, s, ox, oy);
486
+ px(ctx, hx + 1, startY + 12, 2, 2, PAL.bodyMain, s, ox, oy);
487
+ }
488
+ else if (angle === 'out') {
489
+ // Arm stretched horizontally outward
490
+ const dir = isLeft ? -1 : 1;
491
+ const ax = isLeft ? startX - 3 : startX;
492
+ // Outline
493
+ outlineRect(ctx, ax + (isLeft ? -6 : 3), startY + 1, 9, 3, PAL.outline, s, ox, oy);
494
+ // Upper arm (3px tall, 6px wide going outward)
495
+ px(ctx, ax + (isLeft ? -6 : 3), startY + 1, 9, 3, PAL.bodyDark, s, ox, oy);
496
+ px(ctx, ax + (isLeft ? -5 : 3), startY + 1, 7, 2, PAL.bodyMain, s, ox, oy);
497
+ // Top highlight
498
+ px(ctx, ax + (isLeft ? -5 : 3), startY + 1, 7, 1, PAL.bodyMidLight, s, ox, oy);
499
+ // Animation smear trail for dancing fast movement (technique 9)
500
+ if (mood === 'dancing') {
501
+ const smearX = isLeft ? ax + (isLeft ? -8 : 12) + dir * 2 : ax + (isLeft ? -8 : 12) + dir * 2;
502
+ px(ctx, smearX, startY + 1, 2, 2, dimColor(PAL.bodyDark, 0.4), s, ox, oy);
503
+ }
504
+ // Hand at the end
505
+ const handX = isLeft ? ax - 8 : ax + 12;
506
+ outlineRect(ctx, handX, startY, 4, 3, PAL.outline, s, ox, oy);
507
+ px(ctx, handX, startY, 4, 3, PAL.bodyDark, s, ox, oy);
508
+ px(ctx, handX + 1, startY, 2, 2, PAL.bodyMain, s, ox, oy);
509
+ void dir;
510
+ }
511
+ else if (angle === 'up') {
512
+ // Arm raised at ~45 degrees — smooth 2px-wide pixel-art diagonal
513
+ const ax = isLeft ? startX - 3 : startX;
514
+ const dir = isLeft ? -1 : 1;
515
+ // Draw smooth diagonal with 2px wide segments
516
+ for (let i = 0; i < 6; i++) {
517
+ // Outline for diagonal segment
518
+ px(ctx, ax + dir * i - 1, startY - i - 1, 4, 3, PAL.outline, s, ox, oy);
519
+ }
520
+ for (let i = 0; i < 6; i++) {
521
+ px(ctx, ax + dir * i, startY - i, 2, 1, PAL.bodyDark, s, ox, oy);
522
+ px(ctx, ax + dir * i, startY - i - 1, 1, 1, PAL.bodyMidLight, s, ox, oy); // highlight edge
523
+ // Sub-pixel AA adjacent to diagonal (technique 2)
524
+ px(ctx, ax + dir * i + (dir > 0 ? 2 : -1), startY - i, 1, 1, dimColor(PAL.bodyMain, 0.4), s, ox, oy);
525
+ }
526
+ // Elbow joint
527
+ const elbowX = ax + dir * 5;
528
+ const elbowY = startY - 5;
529
+ px(ctx, elbowX, elbowY - 1, 2, 2, PAL.bodyLight, s, ox, oy);
530
+ // Hand at end
531
+ const handX = elbowX + dir * 2;
532
+ outlineRect(ctx, handX, elbowY - 3, 4, 3, PAL.outline, s, ox, oy);
533
+ px(ctx, handX, elbowY - 3, 4, 3, PAL.bodyDark, s, ox, oy);
534
+ px(ctx, handX + 1, elbowY - 3, 2, 2, PAL.bodyMain, s, ox, oy);
535
+ }
536
+ else if (angle === 'up-high') {
537
+ // Arm raised high — smooth 2px-wide pixel-art diagonal going steeper
538
+ const ax = isLeft ? startX - 3 : startX;
539
+ const dir = isLeft ? -1 : 1;
540
+ // Steep diagonal with 2px wide segments
541
+ for (let i = 0; i < 7; i++) {
542
+ const diagX = ax + dir * Math.floor(i * 0.7);
543
+ const diagY = startY - i * 2;
544
+ // Outline
545
+ px(ctx, diagX - 1, diagY - 1, 4, 4, PAL.outline, s, ox, oy);
546
+ }
547
+ for (let i = 0; i < 7; i++) {
548
+ const diagX = ax + dir * Math.floor(i * 0.7);
549
+ const diagY = startY - i * 2;
550
+ px(ctx, diagX, diagY, 2, 2, PAL.bodyDark, s, ox, oy);
551
+ if (i % 2 === 0)
552
+ px(ctx, diagX, diagY, 1, 1, PAL.bodyMidLight, s, ox, oy); // highlight
553
+ // Sub-pixel AA (technique 2)
554
+ px(ctx, diagX + (dir > 0 ? 2 : -1), diagY + 1, 1, 1, dimColor(PAL.bodyMain, 0.3), s, ox, oy);
555
+ }
556
+ // Hand at top
557
+ const handX = ax + dir * 4;
558
+ const handY = startY - 14;
559
+ outlineRect(ctx, handX, handY, 4, 3, PAL.outline, s, ox, oy);
560
+ px(ctx, handX, handY, 4, 3, PAL.bodyDark, s, ox, oy);
561
+ px(ctx, handX + 1, handY, 2, 2, PAL.bodyMain, s, ox, oy);
562
+ }
563
+ }
564
+ function drawLegs(ctx, s, ox, oy, bodyShiftY, mood, frame, landingSpread = 0) {
565
+ const ly = 32 + bodyShiftY;
566
+ const legSpread = (mood === 'dancing' && (frame === 2 || frame === 5) ? 1 : 0) + landingSpread;
567
+ const legH = 7; // Shortened from 8 for chibi proportions (technique 5)
568
+ // (#8) Foot tapping during thinking mood
569
+ const footTap = mood === 'thinking' && frame % 4 < 2 ? -1 : 0;
570
+ // Left leg (facing light — gets highlight)
571
+ outlineRect(ctx, 11 - legSpread, ly, 4, legH, PAL.outline, s, ox, oy);
572
+ px(ctx, 11 - legSpread, ly, 4, legH, PAL.bodyDark, s, ox, oy);
573
+ px(ctx, 12 - legSpread, ly, 2, legH - 1, PAL.bodyMain, s, ox, oy);
574
+ px(ctx, 11 - legSpread, ly, 4, 1, PAL.bodyLight, s, ox, oy); // top highlight
575
+ px(ctx, 11 - legSpread, ly, 1, legH, PAL.bodyMidLight, s, ox, oy); // left edge highlight
576
+ // Right leg (in shadow — rim light instead)
577
+ outlineRect(ctx, 17 + legSpread, ly, 4, legH, PAL.outline, s, ox, oy);
578
+ px(ctx, 17 + legSpread, ly, 4, legH, PAL.bodyDark, s, ox, oy);
579
+ px(ctx, 18 + legSpread, ly, 2, legH - 1, PAL.bodyMain, s, ox, oy);
580
+ // Rim light on right edge of right leg (technique 6)
581
+ px(ctx, 20 + legSpread, ly + 1, 1, legH - 2, PAL.rimLight, s, ox, oy);
582
+ // Feet: 6x3 each
583
+ const footY = ly + legH;
584
+ // Left foot (facing light — gets highlight)
585
+ outlineRect(ctx, 10 - legSpread, footY, 6, 3, PAL.outline, s, ox, oy);
586
+ px(ctx, 10 - legSpread, footY, 6, 3, PAL.bodyDark, s, ox, oy);
587
+ px(ctx, 10 - legSpread, footY, 6, 2, PAL.bodyMain, s, ox, oy);
588
+ px(ctx, 10 - legSpread, footY, 6, 1, PAL.bodyLight, s, ox, oy); // top highlight
589
+ // Warm glow on bottom edge (light bounce from ground)
590
+ px(ctx, 10 - legSpread, footY + 1, 1, 1, PAL.bodyWarmGlow, s, ox, oy);
591
+ // Jet boot thruster
592
+ px(ctx, 11 - legSpread, footY + 2, 4, 1, PAL.jetOrange, s, ox, oy);
593
+ px(ctx, 12 - legSpread, footY + 2, 2, 1, PAL.jetYellow, s, ox, oy);
594
+ // Right foot (in shadow — rim light, with optional tap)
595
+ outlineRect(ctx, 16 + legSpread, footY + footTap, 6, 3, PAL.outline, s, ox, oy);
596
+ px(ctx, 16 + legSpread, footY + footTap, 6, 3, PAL.bodyDark, s, ox, oy);
597
+ px(ctx, 16 + legSpread, footY + footTap, 6, 2, PAL.bodyMain, s, ox, oy);
598
+ // Rim light on right edge of right foot
599
+ px(ctx, 21 + legSpread, footY + footTap, 1, 2, PAL.rimLight, s, ox, oy);
600
+ // Jet boot thruster
601
+ px(ctx, 17 + legSpread, footY + footTap + 2, 4, 1, PAL.jetOrange, s, ox, oy);
602
+ px(ctx, 18 + legSpread, footY + footTap + 2, 2, 1, PAL.jetYellow, s, ox, oy);
603
+ }
604
+ // ─── Main Draw Function ───────────────────────────────────────
605
+ /**
606
+ * Draw the K:BOT pixel art robot character.
607
+ *
608
+ * @param ctx - Canvas 2D rendering context
609
+ * @param x - Top-left X position in canvas pixels
610
+ * @param y - Top-left Y position in canvas pixels
611
+ * @param scale - Pixel scale multiplier (6-8 recommended for ~200-380px)
612
+ * @param mood - Current mood: idle, talking, thinking, excited, dancing, wave, error
613
+ * @param frame - Animation frame counter (incrementing integer)
614
+ * @param moodColor - Optional RGB override for mood accent color
615
+ * @param weather - Optional current weather for weather-character interaction
616
+ * @param isWalking - Optional flag indicating the robot is walking
617
+ * @param walkPhase - Optional walk animation phase (0-3)
618
+ */
619
+ // (#7) Settle animation state — tracks bounce after mood change
620
+ let _prevMood = '';
621
+ let _settleFramesRemaining = 0;
622
+ // Snow accumulation counter (increases over time in snow weather)
623
+ let _snowAccumulation = 0;
624
+ export function drawRobot(ctx, x, y, scale, mood, frame, moodColor, weather, isWalking, walkPhase) {
625
+ const color = getMoodColor(mood, frame, moodColor);
626
+ const s = scale;
627
+ // (#7) Settle animation: 3 frames of bounce after mood change
628
+ if (mood !== _prevMood) {
629
+ _settleFramesRemaining = 3;
630
+ _prevMood = mood;
631
+ }
632
+ let settleShift = 0;
633
+ if (_settleFramesRemaining > 0) {
634
+ settleShift = _settleFramesRemaining === 3 ? -1 : _settleFramesRemaining === 2 ? 1 : 0;
635
+ _settleFramesRemaining--;
636
+ }
637
+ // ── Compute animation offsets ──
638
+ let bodyShiftY = settleShift;
639
+ let headShiftX = 0;
640
+ let torsoWidthBonus = 0;
641
+ let torsoHeightPenalty = 0;
642
+ let landingSpread = 0;
643
+ // 12-frame breathing cycle (technique 7): 2 seconds at 6fps
644
+ // Frame 0: neutral
645
+ // Frame 1-2: chest rises 1px (slow inhale, torso height +1)
646
+ // Frame 3: hold at top
647
+ // Frame 4-5: chest drops back (exhale)
648
+ // Frame 6-11: rest at neutral
649
+ const breathFrame = frame % 12;
650
+ let breathTorsoBonus = 0;
651
+ // Walking body bob (1px up/down per step)
652
+ if (isWalking && walkPhase !== undefined) {
653
+ bodyShiftY += (walkPhase % 2 === 0) ? -1 : 0;
654
+ }
655
+ if (mood === 'idle') {
656
+ // 12-frame breathing cycle (technique 7)
657
+ if (breathFrame >= 1 && breathFrame <= 3) {
658
+ // Inhale: torso grows 1px taller
659
+ breathTorsoBonus = 1;
660
+ }
661
+ else if (breathFrame >= 4 && breathFrame <= 5) {
662
+ // Exhale: returns to normal
663
+ bodyShiftY += 1;
664
+ }
665
+ // Resting frames 6-11: neutral (no shift)
666
+ }
667
+ else if (mood === 'excited') {
668
+ // (#6 + #7) Squash & stretch: crouch before launch, landing with leg spread
669
+ const jumpPhase = frame % 6;
670
+ if (jumpPhase === 0) {
671
+ // Crouch frame (anticipation): body down, wider
672
+ bodyShiftY += 1;
673
+ torsoWidthBonus = 2;
674
+ torsoHeightPenalty = 1;
675
+ }
676
+ else if (jumpPhase === 1 || jumpPhase === 2) {
677
+ // Launch / jump up
678
+ bodyShiftY += -3;
679
+ }
680
+ else if (jumpPhase === 3) {
681
+ // Peak
682
+ bodyShiftY += -2;
683
+ }
684
+ else if (jumpPhase === 4) {
685
+ // Landing frame — slight leg spread
686
+ bodyShiftY += 0;
687
+ landingSpread = 1;
688
+ }
689
+ else {
690
+ bodyShiftY += 0;
691
+ }
692
+ }
693
+ else if (mood === 'dancing') {
694
+ // (#6) Dancing squat: torso widens 2px and shortens 1px on certain frames
695
+ const f = frame % 6;
696
+ if (f === 1)
697
+ headShiftX = 1;
698
+ else if (f === 3) {
699
+ headShiftX = -1;
700
+ }
701
+ else if (f === 4) {
702
+ bodyShiftY += -2;
703
+ }
704
+ // Squat on frames 0 and 5
705
+ if (f === 0 || f === 5) {
706
+ torsoWidthBonus = 2;
707
+ torsoHeightPenalty = 1;
708
+ bodyShiftY += 1;
709
+ }
710
+ }
711
+ else if (mood === 'thinking') {
712
+ // Head tilt on frame 1
713
+ if (frame % 3 === 1)
714
+ headShiftX = 1;
715
+ }
716
+ else if (mood === 'dreaming') {
717
+ // (#9) Body slightly slumped
718
+ bodyShiftY += 1;
719
+ }
720
+ else if (mood === 'wave') {
721
+ // (#7) Anticipation: drop arm slightly before raising (frame 0 = anticipation)
722
+ // Handled in getArmPose
723
+ }
724
+ // Weather: snow shiver (1px horizontal shake every few frames)
725
+ let weatherShakeX = 0;
726
+ if (weather === 'snow' && frame % 8 < 2) {
727
+ weatherShakeX = frame % 2 === 0 ? 1 : -1;
728
+ }
729
+ // Weather: storm flinch on lightning frames
730
+ let stormFlinch = 0;
731
+ if (weather === 'storm' && frame % 30 < 2) {
732
+ stormFlinch = 1;
733
+ bodyShiftY += 1;
734
+ }
735
+ // Weather: rain — slight head tilt
736
+ if (weather === 'rain' && !isWalking) {
737
+ headShiftX += (frame % 6 < 3) ? 1 : 0;
738
+ }
739
+ const armPose = isWalking ? getWalkingArmPose(walkPhase || 0) : getArmPose(mood, frame);
740
+ // ── Clear the bounding area ──
741
+ ctx.clearRect(x - 12 * s + weatherShakeX * s, y - 4 * s, 56 * s, 56 * s);
742
+ // Also clear without shake to avoid artifacts
743
+ ctx.clearRect(x - 12 * s, y - 4 * s, 56 * s, 56 * s);
744
+ // Apply weather shake offset
745
+ const drawX = x + weatherShakeX * s;
746
+ // ── Drop Shadow (technique 10: dark blue-green, not pure black) ──
747
+ const shadowWidth = 20;
748
+ const shadowHeight = 4;
749
+ // Shadow smaller when jumping (bodyShiftY < 0), larger on ground
750
+ const jumpFactor = Math.max(0.4, 1 + bodyShiftY * 0.1);
751
+ const shadowW = shadowWidth * jumpFactor;
752
+ const shadowH = shadowHeight * jumpFactor;
753
+ const shadowCenterX = drawX + 16 * s;
754
+ const shadowBottomY = y + 44 * s; // feet bottom area
755
+ ctx.save();
756
+ // Dark blue-green shadow matches terminal green palette (technique 10)
757
+ ctx.fillStyle = 'rgba(6, 40, 30, 0.35)';
758
+ ctx.beginPath();
759
+ ctx.ellipse(shadowCenterX, shadowBottomY, shadowW * s / 2, shadowH * s / 2, 0, 0, Math.PI * 2);
760
+ ctx.fill();
761
+ // Dithered shadow edge for natural falloff (technique 3)
762
+ ctx.fillStyle = 'rgba(6, 40, 30, 0.15)';
763
+ ctx.beginPath();
764
+ ctx.ellipse(shadowCenterX, shadowBottomY, (shadowW + 2) * s / 2, (shadowH + 1) * s / 2, 0, 0, Math.PI * 2);
765
+ ctx.fill();
766
+ ctx.restore();
767
+ // ── Draw order: back to front ──
768
+ // 1. Arms (behind body for down pose)
769
+ if (armPose.leftAngle === 'down' && armPose.rightAngle === 'down') {
770
+ drawArms(ctx, s, drawX, y, armPose, bodyShiftY, mood, frame);
771
+ }
772
+ // 2. Legs and feet (with walking animation)
773
+ if (isWalking) {
774
+ drawWalkingLegs(ctx, s, drawX, y, bodyShiftY, walkPhase || 0);
775
+ }
776
+ else {
777
+ drawLegs(ctx, s, drawX, y, bodyShiftY, mood, frame, landingSpread);
778
+ }
779
+ // 3. Torso (body) — pass breathing torso bonus
780
+ drawTorso(ctx, s, drawX, y, color, mood, frame, bodyShiftY, torsoWidthBonus + breathTorsoBonus, torsoHeightPenalty);
781
+ // 4. Neck
782
+ drawNeck(ctx, s, drawX, y);
783
+ // 5. Arms (in front for non-down poses)
784
+ if (armPose.leftAngle !== 'down' || armPose.rightAngle !== 'down') {
785
+ drawArms(ctx, s, drawX, y, armPose, bodyShiftY, mood, frame);
786
+ }
787
+ // 6. Head — animation smear: jump apex stretches body 1px taller (technique 9)
788
+ const jumpSmear = mood === 'excited' && (frame % 6 === 2) ? -1 : 0;
789
+ drawHead(ctx, s, drawX, y + jumpSmear * s, color, mood, frame, headShiftX + (stormFlinch ? 1 : 0));
790
+ // 7. Antenna — pass breathFrame for subtle sway during breathing
791
+ drawAntenna(ctx, s, drawX, y + jumpSmear * s, color, frame, breathFrame);
792
+ // ── Weather effects on robot ──
793
+ if (weather === 'rain') {
794
+ // Small blue droplets on head/shoulders
795
+ const dropColor = '#6699cc';
796
+ px(ctx, 12 + headShiftX, 6, 1, 2, dropColor, s, drawX, y);
797
+ px(ctx, 18 + headShiftX, 7, 1, 2, dropColor, s, drawX, y);
798
+ if (frame % 3 === 0)
799
+ px(ctx, 9, 19, 1, 2, dropColor, s, drawX, y);
800
+ }
801
+ if (weather === 'snow') {
802
+ // White pixels accumulating on head and shoulders
803
+ _snowAccumulation = Math.min(6, _snowAccumulation + 0.05);
804
+ const accum = Math.floor(_snowAccumulation);
805
+ for (let i = 0; i < accum; i++) {
806
+ px(ctx, 11 + headShiftX + i * 2, 6, 2, 1, '#ffffff', s, drawX, y);
807
+ }
808
+ // Shoulder snow
809
+ if (accum > 2) {
810
+ px(ctx, 8, 18 + bodyShiftY, 3, 1, '#ffffff', s, drawX, y);
811
+ px(ctx, 21, 18 + bodyShiftY, 3, 1, '#ffffff', s, drawX, y);
812
+ }
813
+ }
814
+ else {
815
+ // Reset snow accumulation when not snowing
816
+ _snowAccumulation = Math.max(0, _snowAccumulation - 0.2);
817
+ }
818
+ if (weather === 'storm' && frame % 30 < 2) {
819
+ // Lightning flash — eyes go wide (already handled with stormFlinch for head position)
820
+ // Override eyes to full open white during flash
821
+ const hx = 10 + headShiftX + (stormFlinch ? 1 : 0);
822
+ px(ctx, hx + 2, 9, 3, 3, '#ffffff', s, drawX, y);
823
+ px(ctx, hx + 7, 9, 3, 3, '#ffffff', s, drawX, y);
824
+ }
825
+ if (weather === 'stars') {
826
+ // Twinkle highlights on antenna ball
827
+ const sway = Math.round(Math.sin(frame * 0.5));
828
+ if (frame % 4 < 2) {
829
+ px(ctx, 14 + sway, -1, 1, 1, '#ffffaa', s, drawX, y - 3 * s);
830
+ }
831
+ if (frame % 6 < 2) {
832
+ px(ctx, 17 + sway, 0, 1, 1, '#ffffcc', s, drawX, y - 3 * s);
833
+ }
834
+ }
835
+ }
836
+ // ─── Walking Animation ────────────────────────────────────────
837
+ function getWalkingArmPose(walkPhase) {
838
+ // Arms swing opposite to legs
839
+ const phase = walkPhase % 4;
840
+ if (phase === 0)
841
+ return { leftAngle: 'down', rightAngle: 'out' };
842
+ if (phase === 1)
843
+ return { leftAngle: 'down', rightAngle: 'down' };
844
+ if (phase === 2)
845
+ return { leftAngle: 'out', rightAngle: 'down' };
846
+ return { leftAngle: 'down', rightAngle: 'down' };
847
+ }
848
+ function drawWalkingLegs(ctx, s, ox, oy, bodyShiftY, walkPhase) {
849
+ const ly = 32 + bodyShiftY;
850
+ const phase = walkPhase % 4;
851
+ const legH = 7; // Shortened for chibi proportions (technique 5)
852
+ // Alternate leg positions: left leg forward/right back, then swap
853
+ const leftLegOffset = phase === 0 || phase === 1 ? -2 : 2;
854
+ const rightLegOffset = phase === 0 || phase === 1 ? 2 : -2;
855
+ // Left leg
856
+ outlineRect(ctx, 11 + leftLegOffset, ly, 4, legH, PAL.outline, s, ox, oy);
857
+ px(ctx, 11 + leftLegOffset, ly, 4, legH, PAL.bodyDark, s, ox, oy);
858
+ px(ctx, 12 + leftLegOffset, ly, 2, legH - 1, PAL.bodyMain, s, ox, oy);
859
+ // Left leg highlight (facing light)
860
+ px(ctx, 11 + leftLegOffset, ly, 4, 1, PAL.bodyLight, s, ox, oy);
861
+ px(ctx, 11 + leftLegOffset, ly, 1, legH, PAL.bodyMidLight, s, ox, oy);
862
+ // Right leg
863
+ outlineRect(ctx, 17 + rightLegOffset, ly, 4, legH, PAL.outline, s, ox, oy);
864
+ px(ctx, 17 + rightLegOffset, ly, 4, legH, PAL.bodyDark, s, ox, oy);
865
+ px(ctx, 18 + rightLegOffset, ly, 2, legH - 1, PAL.bodyMain, s, ox, oy);
866
+ // Rim light on right edge (technique 6)
867
+ px(ctx, 20 + rightLegOffset, ly + 1, 1, legH - 2, PAL.rimLight, s, ox, oy);
868
+ // Feet
869
+ const footY = ly + legH;
870
+ // Left foot
871
+ outlineRect(ctx, 10 + leftLegOffset, footY, 6, 3, PAL.outline, s, ox, oy);
872
+ px(ctx, 10 + leftLegOffset, footY, 6, 3, PAL.bodyDark, s, ox, oy);
873
+ px(ctx, 10 + leftLegOffset, footY, 6, 2, PAL.bodyMain, s, ox, oy);
874
+ px(ctx, 10 + leftLegOffset, footY, 6, 1, PAL.bodyLight, s, ox, oy);
875
+ px(ctx, 11 + leftLegOffset, footY + 2, 4, 1, PAL.jetOrange, s, ox, oy);
876
+ // Right foot
877
+ outlineRect(ctx, 16 + rightLegOffset, footY, 6, 3, PAL.outline, s, ox, oy);
878
+ px(ctx, 16 + rightLegOffset, footY, 6, 3, PAL.bodyDark, s, ox, oy);
879
+ px(ctx, 16 + rightLegOffset, footY, 6, 2, PAL.bodyMain, s, ox, oy);
880
+ // Rim light on right edge of right foot
881
+ px(ctx, 21 + rightLegOffset, footY, 1, 2, PAL.rimLight, s, ox, oy);
882
+ px(ctx, 17 + rightLegOffset, footY + 2, 4, 1, PAL.jetOrange, s, ox, oy);
883
+ }
884
+ // ─── Mood Particles ────────────────────────────────────────────
885
+ /**
886
+ * Draw animated mood particles around the robot.
887
+ *
888
+ * @param ctx - Canvas 2D rendering context
889
+ * @param x - Robot top-left X (same as drawRobot)
890
+ * @param y - Robot top-left Y (same as drawRobot)
891
+ * @param scale - Pixel scale (same as drawRobot)
892
+ * @param mood - Current mood
893
+ * @param frame - Animation frame counter
894
+ */
895
+ export function drawMoodParticles(ctx, x, y, scale, mood, frame) {
896
+ const s = scale;
897
+ const color = getMoodColor(mood, frame);
898
+ if (mood === 'dancing') {
899
+ drawMusicNotes(ctx, s, x, y, frame, color);
900
+ }
901
+ else if (mood === 'excited') {
902
+ drawSparkles(ctx, s, x, y, frame, color);
903
+ }
904
+ else if (mood === 'thinking') {
905
+ drawThoughtBubbles(ctx, s, x, y, frame, color);
906
+ }
907
+ else if (mood === 'talking') {
908
+ drawSoundWaves(ctx, s, x, y, frame, color);
909
+ }
910
+ else if (mood === 'wave') {
911
+ drawWaveArcs(ctx, s, x, y, frame, color);
912
+ }
913
+ else if (mood === 'error') {
914
+ drawErrorSparks(ctx, s, x, y, frame);
915
+ }
916
+ else if (mood === 'dreaming') {
917
+ drawDreamParticles(ctx, s, x, y, frame, color);
918
+ }
919
+ }
920
+ function drawMusicNotes(ctx, s, ox, oy, frame, color) {
921
+ // Two music notes floating upward at different positions
922
+ const notes = [
923
+ { baseX: 1, baseY: 4, phase: 0 },
924
+ { baseX: 28, baseY: 2, phase: 2 },
925
+ { baseX: 14, baseY: 0, phase: 4 },
926
+ ];
927
+ for (const note of notes) {
928
+ const floatY = ((frame + note.phase) % 8) * -2;
929
+ const nx = note.baseX;
930
+ const ny = note.baseY + floatY;
931
+ if (ny > -8) {
932
+ const c = RAINBOW[(frame + note.phase) % RAINBOW.length];
933
+ // Note head (2x2)
934
+ px(ctx, nx, ny + 3, 2, 2, c, s, ox, oy);
935
+ // Stem (1x3)
936
+ px(ctx, nx + 1, ny, 1, 3, c, s, ox, oy);
937
+ // Flag (2x1)
938
+ px(ctx, nx + 1, ny, 2, 1, c, s, ox, oy);
939
+ }
940
+ }
941
+ }
942
+ function drawSparkles(ctx, s, ox, oy, frame, color) {
943
+ // Small + shapes that appear at varying positions
944
+ const positions = [
945
+ { x: 0, y: 2 },
946
+ { x: 28, y: 0 },
947
+ { x: 4, y: -4 },
948
+ { x: 26, y: -2 },
949
+ ];
950
+ for (let i = 0; i < positions.length; i++) {
951
+ // Each sparkle appears for 2 frames then disappears for 2
952
+ const visible = ((frame + i * 2) % 4) < 2;
953
+ if (!visible)
954
+ continue;
955
+ const p = positions[i];
956
+ // + shape (cross)
957
+ px(ctx, p.x + 1, p.y, 1, 3, color, s, ox, oy); // vertical
958
+ px(ctx, p.x, p.y + 1, 3, 1, color, s, ox, oy); // horizontal
959
+ }
960
+ }
961
+ function drawThoughtBubbles(ctx, s, ox, oy, frame, color) {
962
+ const f = frame % 3;
963
+ if (f === 0) {
964
+ // Question mark above head
965
+ // Top curve of ?
966
+ px(ctx, 18, -4, 3, 1, color, s, ox, oy);
967
+ px(ctx, 20, -3, 1, 1, color, s, ox, oy);
968
+ px(ctx, 19, -2, 1, 1, color, s, ox, oy);
969
+ // Dot
970
+ px(ctx, 19, 0, 1, 1, color, s, ox, oy);
971
+ }
972
+ else if (f === 1) {
973
+ // Three dots (ellipsis) rising up
974
+ px(ctx, 17, -2, 1, 1, color, s, ox, oy);
975
+ px(ctx, 19, -3, 1, 1, color, s, ox, oy);
976
+ px(ctx, 21, -2, 1, 1, color, s, ox, oy);
977
+ }
978
+ else {
979
+ // Question mark shifted position
980
+ px(ctx, 14, -4, 3, 1, color, s, ox, oy);
981
+ px(ctx, 16, -3, 1, 1, color, s, ox, oy);
982
+ px(ctx, 15, -2, 1, 1, color, s, ox, oy);
983
+ px(ctx, 15, 0, 1, 1, color, s, ox, oy);
984
+ }
985
+ }
986
+ function drawSoundWaves(ctx, s, ox, oy, frame, color) {
987
+ // Small emanating lines from the right side of the mouth area
988
+ const baseX = 24;
989
+ const baseY = 13;
990
+ // 3 wave arcs radiating outward
991
+ for (let i = 0; i < 3; i++) {
992
+ const visible = ((frame + i) % 4) < 3;
993
+ if (!visible)
994
+ continue;
995
+ const dist = i * 2 + ((frame % 2) * 1);
996
+ const alpha = 1 - i * 0.3;
997
+ const c = dimColor(color.startsWith('rgb') ? '#58a6ff' : color, alpha);
998
+ px(ctx, baseX + dist, baseY - 1, 1, 1, c, s, ox, oy);
999
+ px(ctx, baseX + dist + 1, baseY, 1, 1, c, s, ox, oy);
1000
+ px(ctx, baseX + dist, baseY + 1, 1, 1, c, s, ox, oy);
1001
+ }
1002
+ }
1003
+ function drawWaveArcs(ctx, s, ox, oy, frame, color) {
1004
+ // Small arc lines from the waving hand (right side, upper area)
1005
+ const baseX = 30;
1006
+ const baseY = 8;
1007
+ for (let i = 0; i < 2; i++) {
1008
+ const visible = ((frame + i) % 4) < 3;
1009
+ if (!visible)
1010
+ continue;
1011
+ const dist = i * 3 + (frame % 2);
1012
+ const c = dimColor(color.startsWith('rgb') ? '#58a6ff' : color, 0.8 - i * 0.3);
1013
+ // Small curved line (3 pixels in an arc)
1014
+ px(ctx, baseX + dist, baseY - 1 - i, 1, 1, c, s, ox, oy);
1015
+ px(ctx, baseX + dist + 1, baseY - i, 1, 1, c, s, ox, oy);
1016
+ px(ctx, baseX + dist, baseY + 1 - i, 1, 1, c, s, ox, oy);
1017
+ }
1018
+ }
1019
+ function drawErrorSparks(ctx, s, ox, oy, frame) {
1020
+ const red = '#f85149';
1021
+ const darkRed = '#a82020';
1022
+ // Sparking pixels around the robot
1023
+ const sparks = [
1024
+ { x: 6, y: 4 },
1025
+ { x: 26, y: 8 },
1026
+ { x: 3, y: 20 },
1027
+ { x: 29, y: 18 },
1028
+ { x: 10, y: -2 },
1029
+ { x: 22, y: -1 },
1030
+ ];
1031
+ for (let i = 0; i < sparks.length; i++) {
1032
+ const visible = ((frame + i * 3) % 5) < 2;
1033
+ if (!visible)
1034
+ continue;
1035
+ const sp = sparks[i];
1036
+ const c = (frame + i) % 2 === 0 ? red : darkRed;
1037
+ px(ctx, sp.x, sp.y, 1, 1, c, s, ox, oy);
1038
+ // Small + pattern for some sparks
1039
+ if (i % 2 === 0) {
1040
+ px(ctx, sp.x - 1, sp.y, 1, 1, darkRed, s, ox, oy);
1041
+ px(ctx, sp.x + 1, sp.y, 1, 1, darkRed, s, ox, oy);
1042
+ }
1043
+ }
1044
+ }
1045
+ // (#9) Dream particles — "z z z" floating upward
1046
+ function drawDreamParticles(ctx, s, ox, oy, frame, color) {
1047
+ const mutedColor = dimColor(color.startsWith('rgb') ? '#4a6670' : color, 0.6);
1048
+ // Three "z" letters floating up at different phases
1049
+ const zees = [
1050
+ { baseX: 22, baseY: -2, phase: 0, size: 3 },
1051
+ { baseX: 26, baseY: -6, phase: 3, size: 2 },
1052
+ { baseX: 20, baseY: -10, phase: 6, size: 1 },
1053
+ ];
1054
+ for (const z of zees) {
1055
+ const floatY = ((frame + z.phase) % 12) * -1;
1056
+ const ny = z.baseY + floatY;
1057
+ if (ny > -16) {
1058
+ // Draw a simple "z" shape with pixels
1059
+ // Top horizontal
1060
+ px(ctx, z.baseX, ny, z.size, 1, mutedColor, s, ox, oy);
1061
+ // Diagonal (approximate)
1062
+ if (z.size > 1) {
1063
+ px(ctx, z.baseX + z.size - 1, ny + 1, 1, 1, mutedColor, s, ox, oy);
1064
+ if (z.size > 2)
1065
+ px(ctx, z.baseX + 1, ny + 2, 1, 1, mutedColor, s, ox, oy);
1066
+ }
1067
+ // Bottom horizontal
1068
+ px(ctx, z.baseX, ny + z.size, z.size, 1, mutedColor, s, ox, oy);
1069
+ }
1070
+ }
1071
+ // Thought bubble dots leading from head upward
1072
+ const dotPhase = frame % 6;
1073
+ if (dotPhase < 3) {
1074
+ px(ctx, 20, -1, 1, 1, mutedColor, s, ox, oy);
1075
+ }
1076
+ if (dotPhase < 4) {
1077
+ px(ctx, 22, -3, 2, 2, mutedColor, s, ox, oy);
1078
+ }
1079
+ }
1080
+ /**
1081
+ * Draw a hat on top of the robot's head.
1082
+ * Call AFTER drawRobot() so it layers on top.
1083
+ */
1084
+ export function drawHat(ctx, x, y, scale, hat, frame) {
1085
+ if (hat === 'none')
1086
+ return;
1087
+ const s = scale;
1088
+ // Head top-left is approximately at pixel (9, 5) in sprite coords
1089
+ // Hats sit on top of the head at y=4 (just above head top)
1090
+ const headCenterX = 16; // center of 34px sprite width
1091
+ const headTopY = 4;
1092
+ if (hat === 'crown') {
1093
+ // Golden crown with 3 points and gems
1094
+ const cx = headCenterX - 5;
1095
+ const cy = headTopY - 5;
1096
+ // Crown base
1097
+ px(ctx, cx, cy + 3, 10, 2, '#f0c040', s, x, y);
1098
+ // Three points
1099
+ px(ctx, cx + 1, cy, 2, 3, '#f0c040', s, x, y);
1100
+ px(ctx, cx + 4, cy - 1, 2, 4, '#f0c040', s, x, y);
1101
+ px(ctx, cx + 7, cy, 2, 3, '#f0c040', s, x, y);
1102
+ // Gems (colored dots at each point)
1103
+ px(ctx, cx + 1, cy + 1, 1, 1, '#f85149', s, x, y); // red gem left
1104
+ px(ctx, cx + 4, cy, 1, 1, '#58a6ff', s, x, y); // blue gem center
1105
+ px(ctx, cx + 8, cy + 1, 1, 1, '#3fb950', s, x, y); // green gem right
1106
+ // Gold shimmer on frame
1107
+ if (frame % 4 < 2) {
1108
+ px(ctx, cx + 2, cy + 3, 1, 1, '#ffffaa', s, x, y);
1109
+ }
1110
+ }
1111
+ else if (hat === 'sunglasses') {
1112
+ // Dark rectangle across eyes area
1113
+ const sx = 10;
1114
+ const sy = 9; // eye level
1115
+ // Frame
1116
+ px(ctx, sx, sy, 14, 3, '#1a1a2e', s, x, y);
1117
+ // Bridge
1118
+ px(ctx, sx + 5, sy, 4, 1, '#2d2d2d', s, x, y);
1119
+ // Lens shine
1120
+ if (frame % 6 < 3) {
1121
+ px(ctx, sx + 1, sy, 1, 1, '#ffffff', s, x, y);
1122
+ px(ctx, sx + 9, sy, 1, 1, '#ffffff', s, x, y);
1123
+ }
1124
+ // Earpieces
1125
+ px(ctx, sx - 1, sy + 1, 1, 2, '#1a1a2e', s, x, y);
1126
+ px(ctx, sx + 14, sy + 1, 1, 2, '#1a1a2e', s, x, y);
1127
+ }
1128
+ else if (hat === 'tophat') {
1129
+ // Black tall rectangle above head, 1px brim
1130
+ const tx = headCenterX - 5;
1131
+ const ty = headTopY - 9;
1132
+ // Hat body
1133
+ px(ctx, tx + 1, ty, 8, 7, '#1a1a2e', s, x, y);
1134
+ // Highlight stripe
1135
+ px(ctx, tx + 1, ty + 4, 8, 1, '#6B5B95', s, x, y);
1136
+ // Brim (wider)
1137
+ px(ctx, tx - 1, ty + 7, 12, 2, '#1a1a2e', s, x, y);
1138
+ // Top highlight
1139
+ px(ctx, tx + 2, ty, 6, 1, '#2d2d2d', s, x, y);
1140
+ }
1141
+ else if (hat === 'hardhat') {
1142
+ // Yellow dome on top of head
1143
+ const hx = headCenterX - 6;
1144
+ const hy = headTopY - 4;
1145
+ // Dome shape
1146
+ px(ctx, hx + 2, hy, 8, 1, '#d29922', s, x, y);
1147
+ px(ctx, hx + 1, hy + 1, 10, 1, '#f0c040', s, x, y);
1148
+ px(ctx, hx, hy + 2, 12, 2, '#f0c040', s, x, y);
1149
+ // Brim
1150
+ px(ctx, hx - 1, hy + 4, 14, 1, '#d29922', s, x, y);
1151
+ // Highlight
1152
+ px(ctx, hx + 3, hy, 4, 1, '#ffffaa', s, x, y);
1153
+ }
1154
+ else if (hat === 'party') {
1155
+ // Triangular cone with stripes and pom-pom
1156
+ const px0 = headCenterX;
1157
+ const py0 = headTopY - 8;
1158
+ // Cone (triangle approximation, narrow at top)
1159
+ px(ctx, px0, py0, 1, 1, '#f85149', s, x, y); // tip
1160
+ px(ctx, px0 - 1, py0 + 1, 3, 1, '#58a6ff', s, x, y);
1161
+ px(ctx, px0 - 2, py0 + 2, 5, 1, '#f85149', s, x, y);
1162
+ px(ctx, px0 - 2, py0 + 3, 5, 1, '#f0c040', s, x, y);
1163
+ px(ctx, px0 - 3, py0 + 4, 7, 1, '#58a6ff', s, x, y);
1164
+ px(ctx, px0 - 3, py0 + 5, 7, 1, '#f85149', s, x, y);
1165
+ px(ctx, px0 - 4, py0 + 6, 9, 2, '#3fb950', s, x, y);
1166
+ // Pom-pom on top
1167
+ const pomColor = ['#f85149', '#f0c040', '#58a6ff', '#3fb950'][frame % 4];
1168
+ px(ctx, px0 - 1, py0 - 2, 3, 2, pomColor, s, x, y);
1169
+ }
1170
+ else if (hat === 'antenna') {
1171
+ // Taller antenna with double ball
1172
+ const ax = headCenterX;
1173
+ const ay = headTopY - 12;
1174
+ // Pole
1175
+ px(ctx, ax, ay + 4, 1, 8, PAL.bodyDark, s, x, y);
1176
+ // Bottom ball
1177
+ px(ctx, ax - 1, ay + 3, 3, 2, '#58a6ff', s, x, y);
1178
+ // Top ball (pulsing)
1179
+ const pulse = (Math.sin(frame * 1.5) + 1) / 2;
1180
+ const ballColor = dimColor('#f85149', 0.5 + pulse * 0.5);
1181
+ px(ctx, ax - 2, ay, 5, 3, ballColor, s, x, y);
1182
+ // Specular
1183
+ px(ctx, ax - 1, ay, 1, 1, '#ffffaa', s, x, y);
1184
+ }
1185
+ }
1186
+ /**
1187
+ * Draw a pet companion sprite (8x8 pixel art).
1188
+ * Call AFTER drawRobot() and drawHat().
1189
+ */
1190
+ export function drawPet(ctx, pet, scale, frame) {
1191
+ const s = Math.max(1, Math.floor(scale * 0.6));
1192
+ const ox = Math.round(pet.x);
1193
+ const oy = Math.round(pet.y);
1194
+ if (pet.type === 'drone') {
1195
+ drawDronePet(ctx, s, ox, oy, frame, pet.mood);
1196
+ }
1197
+ else if (pet.type === 'cat') {
1198
+ drawCatPet(ctx, s, ox, oy, frame, pet.mood);
1199
+ }
1200
+ else if (pet.type === 'ghost') {
1201
+ drawGhostPet(ctx, s, ox, oy, frame, pet.mood);
1202
+ }
1203
+ else if (pet.type === 'orb') {
1204
+ drawOrbPet(ctx, s, ox, oy, frame, pet.mood);
1205
+ }
1206
+ }
1207
+ function drawDronePet(ctx, s, ox, oy, frame, mood) {
1208
+ // Small square body with propeller on top, single LED eye
1209
+ const bob = Math.round(Math.sin(frame * 0.8) * 1);
1210
+ const dy = bob;
1211
+ // Propeller (2px spinning line, alternates orientation)
1212
+ if (frame % 2 === 0) {
1213
+ px(ctx, 1, dy - 1, 6, 1, '#8b949e', s, ox, oy);
1214
+ }
1215
+ else {
1216
+ px(ctx, 3, dy - 2, 2, 2, '#8b949e', s, ox, oy);
1217
+ }
1218
+ // Body (6x4 square)
1219
+ px(ctx, 1, dy + 0, 6, 4, '#30363d', s, ox, oy);
1220
+ px(ctx, 2, dy + 1, 4, 2, '#161b22', s, ox, oy);
1221
+ // LED eye (pulses)
1222
+ const ledColor = mood === 'excited' ? '#f0c040' : '#3fb950';
1223
+ const pulse = (Math.sin(frame * 1.2) + 1) / 2;
1224
+ px(ctx, 3, dy + 1, 2, 2, dimColor(ledColor, 0.5 + pulse * 0.5), s, ox, oy);
1225
+ // Landing skids
1226
+ px(ctx, 1, dy + 4, 2, 1, '#8b949e', s, ox, oy);
1227
+ px(ctx, 5, dy + 4, 2, 1, '#8b949e', s, ox, oy);
1228
+ }
1229
+ function drawCatPet(ctx, s, ox, oy, frame, mood) {
1230
+ // Tiny cat face with ears and tail
1231
+ const bob = mood === 'excited' ? Math.round(Math.sin(frame * 1.5) * 1) : 0;
1232
+ const dy = bob;
1233
+ // Ears (triangles)
1234
+ px(ctx, 0, dy + 0, 2, 1, '#cd7f32', s, ox, oy); // left ear
1235
+ px(ctx, 6, dy + 0, 2, 1, '#cd7f32', s, ox, oy); // right ear
1236
+ px(ctx, 0, dy + 1, 1, 1, '#e8a050', s, ox, oy); // inner left
1237
+ px(ctx, 7, dy + 1, 1, 1, '#e8a050', s, ox, oy); // inner right
1238
+ // Head (6x4)
1239
+ px(ctx, 1, dy + 1, 6, 4, '#cd7f32', s, ox, oy);
1240
+ px(ctx, 2, dy + 2, 4, 2, '#e8a050', s, ox, oy); // face inner
1241
+ // Eyes (blink occasionally)
1242
+ const blink = frame % 20 === 0;
1243
+ if (!blink) {
1244
+ px(ctx, 2, dy + 2, 1, 1, '#1a1a2e', s, ox, oy); // left eye
1245
+ px(ctx, 5, dy + 2, 1, 1, '#1a1a2e', s, ox, oy); // right eye
1246
+ }
1247
+ else {
1248
+ px(ctx, 2, dy + 2, 1, 1, '#cd7f32', s, ox, oy);
1249
+ px(ctx, 5, dy + 2, 1, 1, '#cd7f32', s, ox, oy);
1250
+ }
1251
+ // Nose
1252
+ px(ctx, 3, dy + 3, 2, 1, '#f85149', s, ox, oy);
1253
+ // Body (small)
1254
+ px(ctx, 2, dy + 5, 4, 2, '#cd7f32', s, ox, oy);
1255
+ // Tail (animated wave)
1256
+ const tailSwing = Math.round(Math.sin(frame * 0.5) * 1);
1257
+ px(ctx, 6 + tailSwing, dy + 4, 1, 1, '#cd7f32', s, ox, oy);
1258
+ px(ctx, 7 + tailSwing, dy + 3, 1, 1, '#cd7f32', s, ox, oy);
1259
+ }
1260
+ function drawGhostPet(ctx, s, ox, oy, frame, mood) {
1261
+ // Small floating blob with eyes, bobs up and down
1262
+ const bob = Math.round(Math.sin(frame * 0.6) * 2);
1263
+ const dy = bob;
1264
+ // Ghost body (rounded blob)
1265
+ px(ctx, 2, dy + 0, 4, 1, 'rgba(200,200,255,0.7)', s, ox, oy); // top
1266
+ px(ctx, 1, dy + 1, 6, 4, 'rgba(200,200,255,0.6)', s, ox, oy); // body
1267
+ // Wavy bottom
1268
+ const wave = frame % 4;
1269
+ px(ctx, 1, dy + 5, 2, 1, 'rgba(200,200,255,0.5)', s, ox, oy);
1270
+ if (wave < 2)
1271
+ px(ctx, 3, dy + 6, 2, 1, 'rgba(200,200,255,0.4)', s, ox, oy);
1272
+ px(ctx, 5, dy + 5, 2, 1, 'rgba(200,200,255,0.5)', s, ox, oy);
1273
+ if (wave >= 2)
1274
+ px(ctx, 3, dy + 5, 2, 1, 'rgba(200,200,255,0.4)', s, ox, oy);
1275
+ // Eyes
1276
+ px(ctx, 2, dy + 2, 1, 1, '#1a1a2e', s, ox, oy); // left
1277
+ px(ctx, 5, dy + 2, 1, 1, '#1a1a2e', s, ox, oy); // right
1278
+ // Mood: hides behind robot during storm → smaller/transparent
1279
+ if (mood === 'hiding') {
1280
+ ctx.globalAlpha = 0.4;
1281
+ px(ctx, 2, dy + 3, 4, 2, 'rgba(200,200,255,0.3)', s, ox, oy);
1282
+ ctx.globalAlpha = 1;
1283
+ }
1284
+ }
1285
+ function drawOrbPet(ctx, s, ox, oy, frame, _mood) {
1286
+ // Glowing circle that pulses, leaves a trail
1287
+ const pulse = (Math.sin(frame * 0.8) + 1) / 2;
1288
+ const colors = ['#bc8cff', '#58a6ff', '#3fb950', '#f0c040'];
1289
+ const color = colors[Math.floor(frame / 6) % colors.length];
1290
+ // Trail (3 fading dots behind)
1291
+ for (let i = 1; i <= 3; i++) {
1292
+ const trailX = -i * 3;
1293
+ const alpha = 0.3 - i * 0.08;
1294
+ ctx.fillStyle = `rgba(188, 140, 255, ${alpha})`;
1295
+ ctx.beginPath();
1296
+ ctx.arc(ox + trailX * s / 2, oy + 3 * s, (3 - i) * s / 2, 0, Math.PI * 2);
1297
+ ctx.fill();
1298
+ }
1299
+ // Core orb
1300
+ const radius = (3 + pulse) * s / 2;
1301
+ ctx.fillStyle = color;
1302
+ ctx.beginPath();
1303
+ ctx.arc(ox + 3 * s, oy + 3 * s, radius, 0, Math.PI * 2);
1304
+ ctx.fill();
1305
+ // Inner glow
1306
+ ctx.fillStyle = dimColor(color, 1.3) || '#ffffff';
1307
+ ctx.beginPath();
1308
+ ctx.arc(ox + 3 * s, oy + 3 * s, radius * 0.5, 0, Math.PI * 2);
1309
+ ctx.fill();
1310
+ // Outer glow ring
1311
+ ctx.strokeStyle = `rgba(188, 140, 255, ${0.2 + pulse * 0.3})`;
1312
+ ctx.lineWidth = 1;
1313
+ ctx.beginPath();
1314
+ ctx.arc(ox + 3 * s, oy + 3 * s, radius + 2 * s / 2, 0, Math.PI * 2);
1315
+ ctx.stroke();
1316
+ }
1317
+ /**
1318
+ * Draw a buddy companion sprite (12x12 pixel art, scaled).
1319
+ * Called AFTER drawPet() in the render loop.
1320
+ */
1321
+ export function drawBuddyCompanion(ctx, x, y, scale, species, mood, frame) {
1322
+ const s = Math.max(1, Math.floor(scale * 0.5));
1323
+ const bob = Math.round(Math.sin(frame * 0.4) * 2); // gentle bobbing
1324
+ const ox = Math.round(x);
1325
+ const oy = Math.round(y) + bob;
1326
+ switch (species) {
1327
+ case 'fox':
1328
+ drawBuddyFox(ctx, s, ox, oy, frame, mood);
1329
+ break;
1330
+ case 'owl':
1331
+ drawBuddyOwl(ctx, s, ox, oy, frame, mood);
1332
+ break;
1333
+ case 'cat':
1334
+ drawBuddyCat(ctx, s, ox, oy, frame, mood);
1335
+ break;
1336
+ case 'robot':
1337
+ drawBuddyRobot(ctx, s, ox, oy, frame, mood);
1338
+ break;
1339
+ case 'ghost':
1340
+ drawBuddyGhost(ctx, s, ox, oy, frame, mood);
1341
+ break;
1342
+ case 'mushroom':
1343
+ drawBuddyMushroom(ctx, s, ox, oy, frame, mood);
1344
+ break;
1345
+ case 'octopus':
1346
+ drawBuddyOctopus(ctx, s, ox, oy, frame, mood);
1347
+ break;
1348
+ case 'dragon':
1349
+ drawBuddyDragon(ctx, s, ox, oy, frame, mood);
1350
+ break;
1351
+ default:
1352
+ drawBuddyRobot(ctx, s, ox, oy, frame, mood);
1353
+ break;
1354
+ }
1355
+ }
1356
+ function drawBuddyFox(ctx, s, ox, oy, frame, mood) {
1357
+ // Orange body, pointy ears, bushy tail
1358
+ // Ears
1359
+ px(ctx, 1, 0, 2, 2, '#e87020', s, ox, oy);
1360
+ px(ctx, 9, 0, 2, 2, '#e87020', s, ox, oy);
1361
+ // Head
1362
+ px(ctx, 2, 2, 8, 4, '#e87020', s, ox, oy);
1363
+ px(ctx, 3, 3, 6, 2, '#f0a050', s, ox, oy); // inner face
1364
+ // Eyes (mischievous — asymmetric)
1365
+ const blink = frame % 30 === 0;
1366
+ if (!blink) {
1367
+ px(ctx, 4, 3, 1, 1, '#1a1a2e', s, ox, oy);
1368
+ px(ctx, 7, 3, 1, 1, '#1a1a2e', s, ox, oy);
1369
+ // Mischievous eyebrow raise
1370
+ if (mood === 'excited' || mood === 'dancing') {
1371
+ px(ctx, 4, 2, 1, 1, '#f0a050', s, ox, oy);
1372
+ }
1373
+ }
1374
+ // Nose
1375
+ px(ctx, 5, 5, 2, 1, '#1a1a2e', s, ox, oy);
1376
+ // Body
1377
+ px(ctx, 3, 6, 6, 4, '#e87020', s, ox, oy);
1378
+ px(ctx, 4, 7, 4, 2, '#f0a050', s, ox, oy); // belly
1379
+ // Tail (animated wag)
1380
+ const tailWag = Math.round(Math.sin(frame * 0.6) * 2);
1381
+ px(ctx, 9 + tailWag, 7, 2, 1, '#e87020', s, ox, oy);
1382
+ px(ctx, 10 + tailWag, 6, 2, 2, '#e87020', s, ox, oy);
1383
+ px(ctx, 11 + tailWag, 5, 1, 1, '#f0a050', s, ox, oy); // tail tip
1384
+ // Feet
1385
+ px(ctx, 3, 10, 2, 1, '#1a1a2e', s, ox, oy);
1386
+ px(ctx, 7, 10, 2, 1, '#1a1a2e', s, ox, oy);
1387
+ // Dance animation
1388
+ if (mood === 'dancing') {
1389
+ const hop = Math.abs(Math.round(Math.sin(frame * 1.5) * 2));
1390
+ px(ctx, 3, 10 - hop, 2, 1, '#1a1a2e', s, ox, oy);
1391
+ px(ctx, 7, 10 - hop, 2, 1, '#1a1a2e', s, ox, oy);
1392
+ }
1393
+ }
1394
+ function drawBuddyOwl(ctx, s, ox, oy, frame, mood) {
1395
+ // Round body, big eyes, small beak, wing tufts
1396
+ // Head (round)
1397
+ px(ctx, 3, 0, 6, 2, '#8B6914', s, ox, oy);
1398
+ px(ctx, 2, 2, 8, 4, '#8B6914', s, ox, oy);
1399
+ // Big eyes (owl signature)
1400
+ px(ctx, 3, 2, 3, 3, '#f0e68c', s, ox, oy); // left eye ring
1401
+ px(ctx, 6, 2, 3, 3, '#f0e68c', s, ox, oy); // right eye ring
1402
+ const blink = frame % 25 === 0;
1403
+ if (!blink) {
1404
+ px(ctx, 4, 3, 1, 1, '#1a1a2e', s, ox, oy); // left pupil
1405
+ px(ctx, 7, 3, 1, 1, '#1a1a2e', s, ox, oy); // right pupil
1406
+ }
1407
+ // Beak
1408
+ px(ctx, 5, 5, 2, 1, '#f0c040', s, ox, oy);
1409
+ // Body
1410
+ px(ctx, 3, 6, 6, 4, '#8B6914', s, ox, oy);
1411
+ px(ctx, 4, 7, 4, 2, '#a07828', s, ox, oy); // belly
1412
+ // Wing tufts (animated flap)
1413
+ const flapPhase = Math.round(Math.sin(frame * 0.3) * 1);
1414
+ px(ctx, 1, 6 + flapPhase, 2, 3, '#6b5010', s, ox, oy); // left wing
1415
+ px(ctx, 9, 6 + flapPhase, 2, 3, '#6b5010', s, ox, oy); // right wing
1416
+ // Feet
1417
+ px(ctx, 4, 10, 1, 2, '#f0c040', s, ox, oy);
1418
+ px(ctx, 7, 10, 1, 2, '#f0c040', s, ox, oy);
1419
+ // Sleep mode
1420
+ if (mood === 'dreaming') {
1421
+ px(ctx, 3, 3, 2, 1, '#8B6914', s, ox, oy);
1422
+ px(ctx, 7, 3, 2, 1, '#8B6914', s, ox, oy);
1423
+ }
1424
+ }
1425
+ function drawBuddyCat(ctx, s, ox, oy, frame, mood) {
1426
+ // Sleek body, pointed ears, curved tail, slit eyes
1427
+ // Ears (pointed)
1428
+ px(ctx, 2, 0, 2, 2, '#505070', s, ox, oy);
1429
+ px(ctx, 8, 0, 2, 2, '#505070', s, ox, oy);
1430
+ px(ctx, 3, 0, 1, 1, '#706090', s, ox, oy); // inner ear
1431
+ px(ctx, 8, 0, 1, 1, '#706090', s, ox, oy);
1432
+ // Head
1433
+ px(ctx, 3, 2, 6, 3, '#505070', s, ox, oy);
1434
+ // Slit eyes
1435
+ const blink = frame % 20 === 0;
1436
+ if (!blink) {
1437
+ px(ctx, 4, 3, 1, 1, '#58ff58', s, ox, oy); // green slit eyes
1438
+ px(ctx, 7, 3, 1, 1, '#58ff58', s, ox, oy);
1439
+ }
1440
+ // Nose
1441
+ px(ctx, 5, 4, 2, 1, '#706090', s, ox, oy);
1442
+ // Body (sleek)
1443
+ px(ctx, 3, 5, 6, 4, '#505070', s, ox, oy);
1444
+ px(ctx, 4, 6, 4, 2, '#606080', s, ox, oy); // belly
1445
+ // Tail (curved, animated)
1446
+ const tailSwing = Math.round(Math.sin(frame * 0.4) * 1);
1447
+ px(ctx, 9, 7, 1, 1, '#505070', s, ox, oy);
1448
+ px(ctx, 10, 6 + tailSwing, 1, 2, '#505070', s, ox, oy);
1449
+ px(ctx, 11, 5 + tailSwing, 1, 1, '#505070', s, ox, oy);
1450
+ // Feet
1451
+ px(ctx, 3, 9, 2, 1, '#404060', s, ox, oy);
1452
+ px(ctx, 7, 9, 2, 1, '#404060', s, ox, oy);
1453
+ // Hide behind robot in storm
1454
+ if (mood === 'storm') {
1455
+ ctx.globalAlpha = 0.5;
1456
+ ctx.globalAlpha = 1;
1457
+ }
1458
+ }
1459
+ function drawBuddyRobot(ctx, s, ox, oy, frame, mood) {
1460
+ // Mini robot: square head, antenna, smaller version of main character
1461
+ // Antenna
1462
+ px(ctx, 5, 0, 2, 1, '#8b949e', s, ox, oy);
1463
+ px(ctx, 6, 0, 1, 1, mood === 'excited' ? '#f0c040' : '#3fb950', s, ox, oy); // LED
1464
+ // Head (square)
1465
+ px(ctx, 3, 1, 6, 4, '#30363d', s, ox, oy);
1466
+ px(ctx, 4, 2, 4, 2, '#0d1117', s, ox, oy); // visor
1467
+ // Eyes
1468
+ const pulse = (Math.sin(frame * 0.8) + 1) / 2;
1469
+ px(ctx, 4, 2, 1, 1, dimColor('#3fb950', 0.5 + pulse * 0.5), s, ox, oy);
1470
+ px(ctx, 7, 2, 1, 1, dimColor('#3fb950', 0.5 + pulse * 0.5), s, ox, oy);
1471
+ // Body
1472
+ px(ctx, 3, 5, 6, 4, '#30363d', s, ox, oy);
1473
+ px(ctx, 4, 6, 4, 2, '#0d1117', s, ox, oy); // chest panel
1474
+ // Chest LED
1475
+ px(ctx, 5, 6, 2, 1, mood === 'dancing' ? RAINBOW[Math.floor(frame / 3) % RAINBOW.length] : '#6B5B95', s, ox, oy);
1476
+ // Arms
1477
+ px(ctx, 1, 5, 2, 3, '#30363d', s, ox, oy);
1478
+ px(ctx, 9, 5, 2, 3, '#30363d', s, ox, oy);
1479
+ // Feet
1480
+ px(ctx, 3, 9, 2, 2, '#30363d', s, ox, oy);
1481
+ px(ctx, 7, 9, 2, 2, '#30363d', s, ox, oy);
1482
+ }
1483
+ function drawBuddyGhost(ctx, s, ox, oy, frame, _mood) {
1484
+ // Floating white blob, wavy bottom, simple dot eyes
1485
+ const floatBob = Math.round(Math.sin(frame * 0.5) * 2);
1486
+ const dy = floatBob;
1487
+ // Body (blob)
1488
+ px(ctx, 3, dy + 0, 6, 2, 'rgba(220,220,255,0.7)', s, ox, oy);
1489
+ px(ctx, 2, dy + 2, 8, 5, 'rgba(220,220,255,0.6)', s, ox, oy);
1490
+ // Wavy bottom
1491
+ const wave = frame % 4;
1492
+ px(ctx, 2, dy + 7, 2, 1, 'rgba(220,220,255,0.4)', s, ox, oy);
1493
+ if (wave < 2)
1494
+ px(ctx, 5, dy + 8, 2, 1, 'rgba(220,220,255,0.3)', s, ox, oy);
1495
+ px(ctx, 8, dy + 7, 2, 1, 'rgba(220,220,255,0.4)', s, ox, oy);
1496
+ if (wave >= 2)
1497
+ px(ctx, 5, dy + 7, 2, 1, 'rgba(220,220,255,0.3)', s, ox, oy);
1498
+ // Eyes (dot eyes)
1499
+ px(ctx, 4, dy + 3, 1, 1, '#1a1a2e', s, ox, oy);
1500
+ px(ctx, 7, dy + 3, 1, 1, '#1a1a2e', s, ox, oy);
1501
+ // Blush when excited
1502
+ if (_mood === 'excited' || _mood === 'dancing') {
1503
+ px(ctx, 3, dy + 4, 1, 1, 'rgba(255,150,150,0.4)', s, ox, oy);
1504
+ px(ctx, 8, dy + 4, 1, 1, 'rgba(255,150,150,0.4)', s, ox, oy);
1505
+ }
1506
+ }
1507
+ function drawBuddyMushroom(ctx, s, ox, oy, frame, _mood) {
1508
+ // Cap on top, small stem body, dots on cap
1509
+ // Cap
1510
+ px(ctx, 2, 0, 8, 2, '#e83050', s, ox, oy);
1511
+ px(ctx, 1, 2, 10, 3, '#e83050', s, ox, oy);
1512
+ // Dots on cap
1513
+ px(ctx, 3, 1, 2, 1, '#f0e0e0', s, ox, oy);
1514
+ px(ctx, 7, 2, 2, 1, '#f0e0e0', s, ox, oy);
1515
+ px(ctx, 5, 3, 1, 1, '#f0e0e0', s, ox, oy);
1516
+ // Stem body
1517
+ px(ctx, 4, 5, 4, 4, '#f0e0c0', s, ox, oy);
1518
+ px(ctx, 5, 6, 2, 2, '#e0d0b0', s, ox, oy); // face area
1519
+ // Eyes
1520
+ const blink = frame % 35 === 0;
1521
+ if (!blink) {
1522
+ px(ctx, 5, 6, 1, 1, '#1a1a2e', s, ox, oy);
1523
+ px(ctx, 6, 6, 1, 1, '#1a1a2e', s, ox, oy);
1524
+ }
1525
+ // Smile
1526
+ px(ctx, 5, 7, 2, 1, '#c0a080', s, ox, oy);
1527
+ // Ground roots
1528
+ px(ctx, 3, 9, 1, 1, '#a08060', s, ox, oy);
1529
+ px(ctx, 8, 9, 1, 1, '#a08060', s, ox, oy);
1530
+ // Spore particles (animated)
1531
+ if (frame % 12 < 6) {
1532
+ px(ctx, 0, 1, 1, 1, 'rgba(255,200,200,0.3)', s, ox, oy);
1533
+ px(ctx, 11, 0, 1, 1, 'rgba(255,200,200,0.3)', s, ox, oy);
1534
+ }
1535
+ }
1536
+ function drawBuddyOctopus(ctx, s, ox, oy, frame, _mood) {
1537
+ // Round head, wavy tentacles, big eyes
1538
+ // Head (round)
1539
+ px(ctx, 3, 0, 6, 2, '#7060b0', s, ox, oy);
1540
+ px(ctx, 2, 2, 8, 3, '#7060b0', s, ox, oy);
1541
+ // Big eyes
1542
+ px(ctx, 3, 2, 2, 2, '#f0f0ff', s, ox, oy);
1543
+ px(ctx, 7, 2, 2, 2, '#f0f0ff', s, ox, oy);
1544
+ px(ctx, 4, 3, 1, 1, '#1a1a2e', s, ox, oy);
1545
+ px(ctx, 7, 3, 1, 1, '#1a1a2e', s, ox, oy);
1546
+ // Body
1547
+ px(ctx, 3, 5, 6, 2, '#7060b0', s, ox, oy);
1548
+ // Tentacles (4 pairs, wavy)
1549
+ const wave1 = Math.round(Math.sin(frame * 0.5) * 1);
1550
+ const wave2 = Math.round(Math.sin(frame * 0.5 + 1) * 1);
1551
+ const wave3 = Math.round(Math.sin(frame * 0.5 + 2) * 1);
1552
+ const wave4 = Math.round(Math.sin(frame * 0.5 + 3) * 1);
1553
+ px(ctx, 2 + wave1, 7, 1, 3, '#6050a0', s, ox, oy);
1554
+ px(ctx, 4 + wave2, 7, 1, 3, '#6050a0', s, ox, oy);
1555
+ px(ctx, 7 + wave3, 7, 1, 3, '#6050a0', s, ox, oy);
1556
+ px(ctx, 9 + wave4, 7, 1, 3, '#6050a0', s, ox, oy);
1557
+ // Extra tentacle tips
1558
+ px(ctx, 1 + wave1, 10, 1, 1, '#5040a0', s, ox, oy);
1559
+ px(ctx, 5 + wave2, 10, 1, 1, '#5040a0', s, ox, oy);
1560
+ px(ctx, 8 + wave3, 10, 1, 1, '#5040a0', s, ox, oy);
1561
+ px(ctx, 10 + wave4, 10, 1, 1, '#5040a0', s, ox, oy);
1562
+ }
1563
+ function drawBuddyDragon(ctx, s, ox, oy, frame, mood) {
1564
+ // Spiky head, wings, tail with point
1565
+ // Head spikes
1566
+ px(ctx, 3, 0, 1, 1, '#40b050', s, ox, oy);
1567
+ px(ctx, 5, 0, 1, 1, '#40b050', s, ox, oy);
1568
+ px(ctx, 7, 0, 1, 1, '#40b050', s, ox, oy);
1569
+ // Head
1570
+ px(ctx, 2, 1, 8, 4, '#308030', s, ox, oy);
1571
+ // Eyes (fierce)
1572
+ px(ctx, 4, 2, 1, 1, '#f0c040', s, ox, oy); // amber eyes
1573
+ px(ctx, 7, 2, 1, 1, '#f0c040', s, ox, oy);
1574
+ // Nostrils (fire puff when excited)
1575
+ if (mood === 'excited' || mood === 'dancing') {
1576
+ px(ctx, 5, 4, 1, 1, '#ff4020', s, ox, oy); // fire!
1577
+ px(ctx, 6, 4, 1, 1, '#f0c040', s, ox, oy);
1578
+ }
1579
+ else {
1580
+ px(ctx, 5, 4, 2, 1, '#206020', s, ox, oy);
1581
+ }
1582
+ // Body
1583
+ px(ctx, 3, 5, 6, 4, '#308030', s, ox, oy);
1584
+ px(ctx, 4, 6, 4, 2, '#40a040', s, ox, oy); // belly
1585
+ // Wings (animated flap)
1586
+ const wingFlap = Math.round(Math.sin(frame * 0.4) * 1);
1587
+ px(ctx, 0, 4 + wingFlap, 3, 3, '#40a040', s, ox, oy);
1588
+ px(ctx, 9, 4 + wingFlap, 3, 3, '#40a040', s, ox, oy);
1589
+ // Wing membranes
1590
+ px(ctx, 0, 5 + wingFlap, 2, 1, '#60c060', s, ox, oy);
1591
+ px(ctx, 10, 5 + wingFlap, 2, 1, '#60c060', s, ox, oy);
1592
+ // Tail with point
1593
+ const tailSwing = Math.round(Math.sin(frame * 0.3) * 1);
1594
+ px(ctx, 9, 8, 2, 1, '#308030', s, ox, oy);
1595
+ px(ctx, 10, 7 + tailSwing, 1, 1, '#308030', s, ox, oy);
1596
+ px(ctx, 11, 6 + tailSwing, 1, 1, '#40a040', s, ox, oy); // tail tip
1597
+ // Feet
1598
+ px(ctx, 3, 9, 2, 1, '#206020', s, ox, oy);
1599
+ px(ctx, 7, 9, 2, 1, '#206020', s, ox, oy);
1600
+ }
1601
+ //# sourceMappingURL=sprite-engine.js.map