@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.
- package/README.md +9 -0
- package/dist/cli.js +34 -0
- package/dist/ide/mcp-server.js +58 -43
- package/dist/tools/index.js +3 -0
- package/dist/tools/render-engine.d.ts +158 -0
- package/dist/tools/render-engine.js +1361 -0
- package/dist/tools/sprite-engine.d.ts +41 -0
- package/dist/tools/sprite-engine.js +1601 -0
- package/dist/tools/stream-brain.d.ts +70 -0
- package/dist/tools/stream-brain.js +699 -0
- package/dist/tools/stream-character.d.ts +2 -0
- package/dist/tools/stream-character.js +619 -0
- package/dist/tools/stream-intelligence.d.ts +172 -0
- package/dist/tools/stream-intelligence.js +2237 -0
- package/dist/tools/stream-renderer.d.ts +2 -0
- package/dist/tools/stream-renderer.js +3473 -0
- package/dist/tools/streaming.d.ts +2 -0
- package/dist/tools/streaming.js +491 -0
- package/package.json +1 -1
|
@@ -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
|