@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,1361 @@
1
+ // kbot AAA Rendering Engine — Dynamic lighting, bloom, particles, procedural sky, post-processing
2
+ //
3
+ // Exports rendering functions for the stream-renderer. Does NOT register tools.
4
+ // Uses full Canvas 2D API: gradients, compositing, bezier curves, shadows, alpha blending.
5
+ /** Parse hex to [r,g,b] */
6
+ function hexToRgb(hex) {
7
+ const h = hex.startsWith('#') ? hex.slice(1) : hex;
8
+ return [
9
+ parseInt(h.slice(0, 2), 16),
10
+ parseInt(h.slice(2, 4), 16),
11
+ parseInt(h.slice(4, 6), 16),
12
+ ];
13
+ }
14
+ /**
15
+ * Render dynamic lighting over the scene.
16
+ * First draws a dark ambient overlay, then additively blends each light source.
17
+ */
18
+ export function renderLighting(ctx, lights, width, height, ambientLight) {
19
+ // Dark overlay: the lower the ambient, the darker the scene
20
+ ctx.save();
21
+ ctx.fillStyle = `rgba(0,0,0,${Math.max(0, Math.min(1, 1 - ambientLight))})`;
22
+ ctx.fillRect(0, 0, width, height);
23
+ // Additive blending for light sources
24
+ ctx.globalCompositeOperation = 'lighter';
25
+ for (const light of lights) {
26
+ let intensity = light.intensity;
27
+ if (light.flicker) {
28
+ intensity *= 0.85 + Math.random() * 0.15;
29
+ }
30
+ const [r, g, b] = hexToRgb(light.color);
31
+ const grad = ctx.createRadialGradient(light.x, light.y, 0, light.x, light.y, light.radius);
32
+ grad.addColorStop(0, `rgba(${r},${g},${b},${intensity})`);
33
+ grad.addColorStop(0.3, `rgba(${r},${g},${b},${intensity * 0.6})`);
34
+ grad.addColorStop(1, `rgba(${r},${g},${b},0)`);
35
+ ctx.fillStyle = grad;
36
+ ctx.fillRect(light.x - light.radius, light.y - light.radius, light.radius * 2, light.radius * 2);
37
+ }
38
+ ctx.globalCompositeOperation = 'source-over';
39
+ ctx.restore();
40
+ }
41
+ /**
42
+ * Get ambient light level for a given time of day.
43
+ */
44
+ export function getAmbientForTime(timeOfDay) {
45
+ switch (timeOfDay) {
46
+ case 'night': return 0.15;
47
+ case 'day': return 0.6;
48
+ case 'sunset': return 0.35;
49
+ case 'dawn': return 0.25;
50
+ default: return 0.3;
51
+ }
52
+ }
53
+ /**
54
+ * Build default light sources for the kbot stream character.
55
+ */
56
+ export function buildCharacterLights(robotX, robotY, scale, moodColor, frame, hasLightning, worldItems) {
57
+ const lights = [];
58
+ // Antenna ball glow
59
+ const sway = Math.round(Math.sin(frame * 0.5));
60
+ lights.push({
61
+ x: robotX + (15 + sway) * scale,
62
+ y: robotY - 3 * scale,
63
+ radius: 80,
64
+ color: moodColor,
65
+ intensity: 0.6,
66
+ flicker: true,
67
+ });
68
+ // Chest core glow
69
+ lights.push({
70
+ x: robotX + 16 * scale,
71
+ y: robotY + 23 * scale,
72
+ radius: 60,
73
+ color: moodColor,
74
+ intensity: 0.4,
75
+ flicker: true,
76
+ });
77
+ // Lightning flash — massive white light
78
+ if (hasLightning) {
79
+ lights.push({
80
+ x: 640,
81
+ y: 0,
82
+ radius: 2000,
83
+ color: '#ffffff',
84
+ intensity: 1.0,
85
+ });
86
+ }
87
+ // Item-based light emission
88
+ if (worldItems) {
89
+ for (const item of worldItems) {
90
+ const name = item.name.toLowerCase();
91
+ const emoji = item.emoji;
92
+ if (name.includes('fire') || emoji === '🔥') {
93
+ lights.push({ x: item.x, y: item.y, radius: 50, color: '#ff6600', intensity: 0.35, flicker: true });
94
+ }
95
+ else if (name.includes('star') || emoji === '⭐' || emoji === '🌟') {
96
+ lights.push({ x: item.x, y: item.y, radius: 40, color: '#f0c040', intensity: 0.3, flicker: true });
97
+ }
98
+ else if (name.includes('lamp') || emoji === '💡') {
99
+ lights.push({ x: item.x, y: item.y, radius: 55, color: '#ffe4a0', intensity: 0.4, flicker: false });
100
+ }
101
+ else if (name.includes('crystal') || emoji === '💎') {
102
+ lights.push({ x: item.x, y: item.y, radius: 35, color: '#58a6ff', intensity: 0.25, flicker: true });
103
+ }
104
+ }
105
+ }
106
+ return lights;
107
+ }
108
+ /**
109
+ * Render soft glowing halos around bright elements.
110
+ */
111
+ export function renderBloom(ctx, brightSpots) {
112
+ ctx.save();
113
+ ctx.globalCompositeOperation = 'lighter';
114
+ for (const spot of brightSpots) {
115
+ const bloomRadius = spot.radius * 2.5;
116
+ const [r, g, b] = hexToRgb(spot.color);
117
+ const grad = ctx.createRadialGradient(spot.x, spot.y, 0, spot.x, spot.y, bloomRadius);
118
+ grad.addColorStop(0, `rgba(${r},${g},${b},0.2)`);
119
+ grad.addColorStop(0.4, `rgba(${r},${g},${b},0.1)`);
120
+ grad.addColorStop(1, `rgba(${r},${g},${b},0)`);
121
+ ctx.fillStyle = grad;
122
+ ctx.fillRect(spot.x - bloomRadius, spot.y - bloomRadius, bloomRadius * 2, bloomRadius * 2);
123
+ }
124
+ ctx.globalCompositeOperation = 'source-over';
125
+ ctx.restore();
126
+ }
127
+ /**
128
+ * Build default bloom spots for the kbot character.
129
+ */
130
+ export function buildCharacterBloom(robotX, robotY, scale, moodColor, frame) {
131
+ const spots = [];
132
+ const sway = Math.round(Math.sin(frame * 0.5));
133
+ // Antenna ball bloom
134
+ spots.push({
135
+ x: robotX + (15 + sway) * scale,
136
+ y: robotY - 3 * scale,
137
+ radius: 12,
138
+ color: moodColor,
139
+ });
140
+ // Eyes bloom (subtle)
141
+ const headX = robotX + 9 * scale;
142
+ const headY = robotY + 5 * scale;
143
+ spots.push({ x: headX + 4 * scale, y: headY + 4 * scale, radius: 6, color: moodColor });
144
+ spots.push({ x: headX + 10 * scale, y: headY + 4 * scale, radius: 6, color: moodColor });
145
+ // Chest core bloom
146
+ spots.push({
147
+ x: robotX + 16 * scale,
148
+ y: robotY + 23 * scale,
149
+ radius: 10,
150
+ color: moodColor,
151
+ });
152
+ return spots;
153
+ }
154
+ /**
155
+ * Create particles of a given type at a position.
156
+ */
157
+ export function createParticleEmitter(type, x, y, count) {
158
+ const particles = [];
159
+ for (let i = 0; i < count; i++) {
160
+ switch (type) {
161
+ case 'spark':
162
+ particles.push({
163
+ x, y,
164
+ vx: (Math.random() - 0.5) * 6,
165
+ vy: -3 - Math.random() * 3,
166
+ life: 20 + Math.floor(Math.random() * 15),
167
+ maxLife: 35,
168
+ size: 2,
169
+ color: Math.random() > 0.5 ? '#f0c040' : '#ff8800',
170
+ type: 'spark',
171
+ trail: [],
172
+ gravity: 0.3,
173
+ });
174
+ break;
175
+ case 'fire':
176
+ particles.push({
177
+ x: x + (Math.random() - 0.5) * 10,
178
+ y,
179
+ vx: (Math.random() - 0.5) * 0.5,
180
+ vy: -0.5 - Math.random() * 1,
181
+ life: 30 + Math.floor(Math.random() * 20),
182
+ maxLife: 50,
183
+ size: 3 + Math.floor(Math.random() * 2),
184
+ color: '#f0c040', // starts yellow
185
+ type: 'fire',
186
+ gravity: -0.2,
187
+ });
188
+ break;
189
+ case 'magic': {
190
+ const angle = (i / count) * Math.PI * 2;
191
+ const radius = 20 + Math.random() * 10;
192
+ particles.push({
193
+ x: x + Math.cos(angle) * radius,
194
+ y: y + Math.sin(angle) * radius,
195
+ vx: 0, vy: 0,
196
+ life: 40 + Math.floor(Math.random() * 20),
197
+ maxLife: 60,
198
+ size: 2,
199
+ color: '#bc8cff',
200
+ type: 'magic',
201
+ cx: x,
202
+ cy: y,
203
+ orbitRadius: radius,
204
+ orbitPhase: angle,
205
+ });
206
+ break;
207
+ }
208
+ case 'electricity':
209
+ particles.push({
210
+ x, y,
211
+ vx: 0, vy: 0,
212
+ life: 8 + Math.floor(Math.random() * 8),
213
+ maxLife: 16,
214
+ size: 2,
215
+ color: '#88ccff',
216
+ type: 'electricity',
217
+ startX: x,
218
+ startY: y,
219
+ endX: x + (Math.random() - 0.5) * 40,
220
+ endY: y + (Math.random() - 0.5) * 40,
221
+ midpoints: [],
222
+ lastMidpointFrame: -999,
223
+ });
224
+ break;
225
+ case 'trail':
226
+ particles.push({
227
+ x, y,
228
+ vx: 0, vy: 0,
229
+ life: 12,
230
+ maxLife: 12,
231
+ size: 1,
232
+ color: '#3fb950',
233
+ type: 'trail',
234
+ trail: [{ x, y }],
235
+ });
236
+ break;
237
+ case 'smoke':
238
+ particles.push({
239
+ x: x + (Math.random() - 0.5) * 8,
240
+ y,
241
+ vx: (Math.random() - 0.5) * 0.4,
242
+ vy: -0.8 - Math.random() * 0.5,
243
+ life: 30 + Math.floor(Math.random() * 20),
244
+ maxLife: 50,
245
+ size: 4 + Math.floor(Math.random() * 4),
246
+ color: '#666666',
247
+ type: 'smoke',
248
+ gravity: -0.05,
249
+ });
250
+ break;
251
+ case 'aura':
252
+ particles.push({
253
+ x, y,
254
+ vx: 0, vy: 0,
255
+ life: 60 + Math.floor(Math.random() * 30),
256
+ maxLife: 90,
257
+ size: 60,
258
+ color: '#3fb950',
259
+ type: 'aura',
260
+ pulsePhase: Math.random() * Math.PI * 2,
261
+ });
262
+ break;
263
+ }
264
+ }
265
+ return particles;
266
+ }
267
+ /**
268
+ * Tick all particles: apply physics, age, return survivors.
269
+ */
270
+ export function tickParticles(particles) {
271
+ return particles.filter(p => {
272
+ p.life--;
273
+ if (p.life <= 0)
274
+ return false;
275
+ switch (p.type) {
276
+ case 'spark':
277
+ // Store trail
278
+ if (p.trail) {
279
+ p.trail.push({ x: p.x, y: p.y });
280
+ if (p.trail.length > 3)
281
+ p.trail.shift();
282
+ }
283
+ p.vy += p.gravity ?? 0.3;
284
+ p.x += p.vx;
285
+ p.y += p.vy;
286
+ // Bounce once on ground
287
+ if (p.y > 480 && p.vy > 0) {
288
+ p.vy = -p.vy * 0.3;
289
+ p.y = 480;
290
+ if (Math.abs(p.vy) < 0.5)
291
+ p.vy = 0;
292
+ }
293
+ break;
294
+ case 'fire':
295
+ p.vy += p.gravity ?? -0.2;
296
+ p.vx = Math.sin(p.life * 0.3) * 0.5;
297
+ p.x += p.vx;
298
+ p.y += p.vy;
299
+ p.size = Math.max(1, p.size - 0.05);
300
+ // Color shifts: yellow -> orange -> red -> dark
301
+ {
302
+ const ratio = p.life / p.maxLife;
303
+ if (ratio > 0.7)
304
+ p.color = '#f0c040';
305
+ else if (ratio > 0.4)
306
+ p.color = '#ff6600';
307
+ else if (ratio > 0.2)
308
+ p.color = '#cc2200';
309
+ else
310
+ p.color = '#661100';
311
+ }
312
+ break;
313
+ case 'magic':
314
+ if (p.cx !== undefined && p.cy !== undefined && p.orbitRadius !== undefined && p.orbitPhase !== undefined) {
315
+ p.orbitPhase += 0.15;
316
+ p.x = p.cx + Math.cos(p.orbitPhase) * p.orbitRadius;
317
+ p.y = p.cy + Math.sin(p.orbitPhase) * p.orbitRadius;
318
+ }
319
+ // Rainbow cycle
320
+ {
321
+ const rainbow = ['#f85149', '#f0c040', '#3fb950', '#58a6ff', '#bc8cff', '#ff6ec7'];
322
+ p.color = rainbow[Math.floor((p.maxLife - p.life) * 0.3) % rainbow.length];
323
+ }
324
+ break;
325
+ case 'electricity':
326
+ // Regenerate midpoints every 3 frames for crackling
327
+ if (p.startX !== undefined && p.endX !== undefined && p.startY !== undefined && p.endY !== undefined) {
328
+ if (!p.lastMidpointFrame || (p.maxLife - p.life) - (p.lastMidpointFrame ?? 0) >= 3) {
329
+ const segCount = 5 + Math.floor(Math.random() * 3);
330
+ p.midpoints = [];
331
+ for (let s = 1; s < segCount; s++) {
332
+ const t = s / segCount;
333
+ const mx = p.startX + (p.endX - p.startX) * t;
334
+ const my = p.startY + (p.endY - p.startY) * t;
335
+ // Perpendicular offset
336
+ const dx = p.endX - p.startX;
337
+ const dy = p.endY - p.startY;
338
+ const len = Math.sqrt(dx * dx + dy * dy) || 1;
339
+ const nx = -dy / len;
340
+ const ny = dx / len;
341
+ const offset = (Math.random() - 0.5) * 10;
342
+ p.midpoints.push({ x: mx + nx * offset, y: my + ny * offset });
343
+ }
344
+ p.lastMidpointFrame = p.maxLife - p.life;
345
+ }
346
+ }
347
+ break;
348
+ case 'trail':
349
+ // Just fade
350
+ break;
351
+ case 'smoke':
352
+ p.vy += p.gravity ?? -0.05;
353
+ p.vx += (Math.random() - 0.5) * 0.1;
354
+ p.x += p.vx;
355
+ p.y += p.vy;
356
+ p.size += 0.15; // expand over lifetime
357
+ break;
358
+ case 'aura':
359
+ // Pulses on sine wave, doesn't move
360
+ break;
361
+ }
362
+ return true;
363
+ });
364
+ }
365
+ /**
366
+ * Render all particles to the canvas.
367
+ */
368
+ export function renderParticles(ctx, particles) {
369
+ ctx.save();
370
+ for (const p of particles) {
371
+ const alpha = Math.max(0, p.life / p.maxLife);
372
+ switch (p.type) {
373
+ case 'spark': {
374
+ // Trail
375
+ if (p.trail && p.trail.length > 1) {
376
+ ctx.strokeStyle = p.color;
377
+ ctx.lineWidth = 1;
378
+ for (let i = 1; i < p.trail.length; i++) {
379
+ ctx.globalAlpha = alpha * (i / p.trail.length) * 0.5;
380
+ ctx.beginPath();
381
+ ctx.moveTo(p.trail[i - 1].x, p.trail[i - 1].y);
382
+ ctx.lineTo(p.trail[i].x, p.trail[i].y);
383
+ ctx.stroke();
384
+ }
385
+ }
386
+ // Dot
387
+ ctx.globalAlpha = alpha;
388
+ ctx.fillStyle = p.color;
389
+ ctx.fillRect(p.x, p.y, p.size, p.size);
390
+ break;
391
+ }
392
+ case 'fire': {
393
+ ctx.globalAlpha = alpha * 0.8;
394
+ ctx.fillStyle = p.color;
395
+ ctx.beginPath();
396
+ ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
397
+ ctx.fill();
398
+ break;
399
+ }
400
+ case 'magic': {
401
+ ctx.globalAlpha = alpha * 0.7;
402
+ ctx.fillStyle = p.color;
403
+ ctx.beginPath();
404
+ ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
405
+ ctx.fill();
406
+ // Glow around magic particle
407
+ const [r, g, b] = hexToRgb(p.color);
408
+ const glow = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.size * 3);
409
+ glow.addColorStop(0, `rgba(${r},${g},${b},${alpha * 0.3})`);
410
+ glow.addColorStop(1, `rgba(${r},${g},${b},0)`);
411
+ ctx.fillStyle = glow;
412
+ ctx.fillRect(p.x - p.size * 3, p.y - p.size * 3, p.size * 6, p.size * 6);
413
+ break;
414
+ }
415
+ case 'electricity': {
416
+ if (!p.midpoints || !p.startX || !p.endX || p.startY === undefined || p.endY === undefined)
417
+ break;
418
+ ctx.globalAlpha = alpha;
419
+ ctx.strokeStyle = '#88ddff';
420
+ ctx.lineWidth = 2;
421
+ ctx.shadowColor = '#4488ff';
422
+ ctx.shadowBlur = 6;
423
+ ctx.beginPath();
424
+ ctx.moveTo(p.startX, p.startY);
425
+ for (const mp of p.midpoints) {
426
+ ctx.lineTo(mp.x, mp.y);
427
+ }
428
+ ctx.lineTo(p.endX, p.endY);
429
+ ctx.stroke();
430
+ // Bright white core
431
+ ctx.strokeStyle = '#ffffff';
432
+ ctx.lineWidth = 1;
433
+ ctx.beginPath();
434
+ ctx.moveTo(p.startX, p.startY);
435
+ for (const mp of p.midpoints) {
436
+ ctx.lineTo(mp.x, mp.y);
437
+ }
438
+ ctx.lineTo(p.endX, p.endY);
439
+ ctx.stroke();
440
+ ctx.shadowBlur = 0;
441
+ ctx.shadowColor = 'transparent';
442
+ break;
443
+ }
444
+ case 'trail': {
445
+ if (p.trail) {
446
+ for (let i = 0; i < p.trail.length; i++) {
447
+ const trailAlpha = alpha * (0.5 - i * 0.1);
448
+ if (trailAlpha <= 0)
449
+ continue;
450
+ ctx.globalAlpha = trailAlpha;
451
+ ctx.fillStyle = p.color;
452
+ ctx.fillRect(p.trail[i].x, p.trail[i].y, 4, 4);
453
+ }
454
+ }
455
+ break;
456
+ }
457
+ case 'smoke': {
458
+ ctx.globalAlpha = alpha * 0.4;
459
+ ctx.fillStyle = p.color;
460
+ ctx.beginPath();
461
+ ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
462
+ ctx.fill();
463
+ break;
464
+ }
465
+ case 'aura': {
466
+ const pulseAlpha = 0.08 + Math.sin((p.pulsePhase ?? 0) + (p.maxLife - p.life) * 0.1) * 0.07;
467
+ const [r, g, b] = hexToRgb(p.color);
468
+ const grad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.size);
469
+ grad.addColorStop(0, `rgba(${r},${g},${b},${pulseAlpha})`);
470
+ grad.addColorStop(1, `rgba(${r},${g},${b},0)`);
471
+ ctx.globalAlpha = 1;
472
+ ctx.fillStyle = grad;
473
+ ctx.fillRect(p.x - p.size, p.y - p.size, p.size * 2, p.size * 2);
474
+ break;
475
+ }
476
+ }
477
+ }
478
+ ctx.globalAlpha = 1;
479
+ ctx.restore();
480
+ }
481
+ // ─── 4. PROCEDURAL SKY ──────────────────────────────────────────
482
+ // Stable star positions (seeded pseudorandom)
483
+ const STAR_POSITIONS = [];
484
+ const CONSTELLATION_GROUPS = [];
485
+ const CLOUD_POSITIONS = [];
486
+ function ensureStarsGenerated(width, height) {
487
+ if (STAR_POSITIONS.length > 0)
488
+ return;
489
+ // Generate 60 stable stars
490
+ for (let i = 0; i < 60; i++) {
491
+ const seed = (i * 97 + 31) % 10000;
492
+ STAR_POSITIONS.push({
493
+ x: (seed * 3.7) % width,
494
+ y: 70 + (seed * 7.3) % (height * 0.55),
495
+ brightness: 0.3 + (seed % 70) / 100,
496
+ size: i < 8 ? 2.5 : i < 20 ? 1.5 : 1,
497
+ phase: (seed * 0.17) % (Math.PI * 2),
498
+ });
499
+ }
500
+ // Generate 4 constellations (groups of 3-4 nearby stars)
501
+ const used = new Set();
502
+ for (let c = 0; c < 4; c++) {
503
+ const group = [];
504
+ const anchor = c * 12 + 5;
505
+ if (anchor < STAR_POSITIONS.length && !used.has(anchor)) {
506
+ group.push(anchor);
507
+ used.add(anchor);
508
+ // Find 2-3 nearby stars
509
+ for (let j = anchor + 1; j < Math.min(anchor + 8, STAR_POSITIONS.length); j++) {
510
+ if (!used.has(j) && group.length < 4) {
511
+ const dx = STAR_POSITIONS[anchor].x - STAR_POSITIONS[j].x;
512
+ const dy = STAR_POSITIONS[anchor].y - STAR_POSITIONS[j].y;
513
+ if (Math.sqrt(dx * dx + dy * dy) < 200) {
514
+ group.push(j);
515
+ used.add(j);
516
+ }
517
+ }
518
+ }
519
+ if (group.length >= 2)
520
+ CONSTELLATION_GROUPS.push(group);
521
+ }
522
+ }
523
+ // Generate 7 clouds
524
+ for (let i = 0; i < 7; i++) {
525
+ CLOUD_POSITIONS.push({
526
+ x: (i * 137 + 50) % width,
527
+ y: 100 + (i * 67) % 150,
528
+ w: 60 + (i * 23) % 60,
529
+ h: 20 + (i * 11) % 15,
530
+ opacity: 0.3 + (i % 4) * 0.1,
531
+ });
532
+ }
533
+ }
534
+ /**
535
+ * Render a full procedural sky based on time of day, weather, and frame.
536
+ */
537
+ export function renderSky(ctx, width, height, timeOfDay, weather, frame, dividerX = 580) {
538
+ ensureStarsGenerated(dividerX, height);
539
+ const skyTop = 60;
540
+ const skyBottom = 490;
541
+ const skyHeight = skyBottom - skyTop;
542
+ switch (timeOfDay) {
543
+ case 'night': {
544
+ // Deep dark gradient
545
+ const grad = ctx.createLinearGradient(0, skyTop, 0, skyBottom);
546
+ grad.addColorStop(0, '#050510');
547
+ grad.addColorStop(1, '#0a1628');
548
+ ctx.fillStyle = grad;
549
+ ctx.fillRect(0, skyTop, dividerX, skyHeight);
550
+ // Stars with individual brightness pulses
551
+ for (const star of STAR_POSITIONS) {
552
+ if (star.x > dividerX)
553
+ continue;
554
+ const pulse = star.brightness + Math.sin(frame * 0.08 + star.phase) * 0.2;
555
+ const alpha = Math.max(0.1, Math.min(1, pulse));
556
+ ctx.fillStyle = `rgba(255, 255, ${220 + Math.floor(star.phase * 10) % 35}, ${alpha})`;
557
+ ctx.beginPath();
558
+ ctx.arc(star.x, star.y, star.size, 0, Math.PI * 2);
559
+ ctx.fill();
560
+ }
561
+ // Constellation lines
562
+ ctx.strokeStyle = 'rgba(100, 130, 180, 0.15)';
563
+ ctx.lineWidth = 1;
564
+ for (const group of CONSTELLATION_GROUPS) {
565
+ for (let i = 1; i < group.length; i++) {
566
+ const a = STAR_POSITIONS[group[i - 1]];
567
+ const b = STAR_POSITIONS[group[i]];
568
+ if (a && b && a.x < dividerX && b.x < dividerX) {
569
+ ctx.beginPath();
570
+ ctx.moveTo(a.x, a.y);
571
+ ctx.lineTo(b.x, b.y);
572
+ ctx.stroke();
573
+ }
574
+ }
575
+ }
576
+ // Moon with craters and corona
577
+ const moonX = 100;
578
+ const moonY = 150;
579
+ // Corona glow
580
+ const corona = ctx.createRadialGradient(moonX, moonY, 15, moonX, moonY, 40);
581
+ corona.addColorStop(0, 'rgba(200, 210, 240, 0.12)');
582
+ corona.addColorStop(1, 'rgba(200, 210, 240, 0)');
583
+ ctx.fillStyle = corona;
584
+ ctx.fillRect(moonX - 40, moonY - 40, 80, 80);
585
+ // Moon body
586
+ ctx.fillStyle = '#c8d0e0';
587
+ ctx.beginPath();
588
+ ctx.arc(moonX, moonY, 18, 0, Math.PI * 2);
589
+ ctx.fill();
590
+ // Craters
591
+ ctx.fillStyle = '#a0a8b8';
592
+ ctx.beginPath();
593
+ ctx.arc(moonX - 5, moonY - 4, 4, 0, Math.PI * 2);
594
+ ctx.fill();
595
+ ctx.beginPath();
596
+ ctx.arc(moonX + 6, moonY + 5, 3, 0, Math.PI * 2);
597
+ ctx.fill();
598
+ ctx.beginPath();
599
+ ctx.arc(moonX - 2, moonY + 7, 2, 0, Math.PI * 2);
600
+ ctx.fill();
601
+ // Highlight
602
+ ctx.fillStyle = 'rgba(255,255,255,0.15)';
603
+ ctx.beginPath();
604
+ ctx.arc(moonX - 4, moonY - 5, 10, 0, Math.PI * 2);
605
+ ctx.fill();
606
+ // Milky Way band — horizontal band of faint dots across middle third
607
+ const bandY = skyTop + skyHeight * 0.3;
608
+ const bandH = skyHeight * 0.25;
609
+ for (let i = 0; i < 80; i++) {
610
+ const seed = (i * 73 + 17) % 10000;
611
+ const mx = (seed * 3) % dividerX;
612
+ const my = bandY + (seed * 7) % bandH;
613
+ const size = (seed % 3 === 0) ? 1.5 : 1;
614
+ ctx.fillStyle = `rgba(180, 190, 220, ${0.04 + (seed % 50) / 1000})`;
615
+ ctx.beginPath();
616
+ ctx.arc(mx, my, size, 0, Math.PI * 2);
617
+ ctx.fill();
618
+ }
619
+ break;
620
+ }
621
+ case 'day': {
622
+ // Blue sky gradient
623
+ const grad = ctx.createLinearGradient(0, skyTop, 0, skyBottom);
624
+ grad.addColorStop(0, '#1a3a5c');
625
+ grad.addColorStop(0.5, '#4a8ab5');
626
+ grad.addColorStop(1, '#87ceeb');
627
+ ctx.fillStyle = grad;
628
+ ctx.fillRect(0, skyTop, dividerX, skyHeight);
629
+ // Sun with rotating corona rays
630
+ const sunX = 400;
631
+ const sunY = 130;
632
+ // Corona rays
633
+ ctx.save();
634
+ ctx.translate(sunX, sunY);
635
+ ctx.rotate(frame * 0.02);
636
+ ctx.strokeStyle = 'rgba(255, 220, 100, 0.25)';
637
+ ctx.lineWidth = 2;
638
+ for (let i = 0; i < 8; i++) {
639
+ const angle = (i / 8) * Math.PI * 2;
640
+ ctx.beginPath();
641
+ ctx.moveTo(Math.cos(angle) * 18, Math.sin(angle) * 18);
642
+ ctx.lineTo(Math.cos(angle) * 35, Math.sin(angle) * 35);
643
+ ctx.stroke();
644
+ }
645
+ ctx.restore();
646
+ // Sun body
647
+ ctx.fillStyle = '#ffe44d';
648
+ ctx.beginPath();
649
+ ctx.arc(sunX, sunY, 15, 0, Math.PI * 2);
650
+ ctx.fill();
651
+ // Sun glow
652
+ const sunGlow = ctx.createRadialGradient(sunX, sunY, 10, sunX, sunY, 50);
653
+ sunGlow.addColorStop(0, 'rgba(255, 230, 100, 0.2)');
654
+ sunGlow.addColorStop(1, 'rgba(255, 230, 100, 0)');
655
+ ctx.fillStyle = sunGlow;
656
+ ctx.fillRect(sunX - 50, sunY - 50, 100, 100);
657
+ // Drifting clouds
658
+ for (const cloud of CLOUD_POSITIONS) {
659
+ const cx = (cloud.x + frame * 0.2) % (dividerX + cloud.w) - cloud.w / 2;
660
+ drawCloud(ctx, cx, cloud.y, cloud.w, cloud.h, cloud.opacity);
661
+ }
662
+ break;
663
+ }
664
+ case 'sunset': {
665
+ const grad = ctx.createLinearGradient(0, skyTop, 0, skyBottom);
666
+ grad.addColorStop(0, '#1a0a2e');
667
+ grad.addColorStop(0.35, '#6b2f5f');
668
+ grad.addColorStop(0.65, '#e85d3a');
669
+ grad.addColorStop(1, '#f4a460');
670
+ ctx.fillStyle = grad;
671
+ ctx.fillRect(0, skyTop, dividerX, skyHeight);
672
+ // Setting sun near horizon
673
+ const sunX = dividerX / 2;
674
+ const sunY = skyBottom - 30;
675
+ ctx.fillStyle = '#e85d3a';
676
+ ctx.beginPath();
677
+ ctx.arc(sunX, sunY, 25, 0, Math.PI * 2);
678
+ ctx.fill();
679
+ // Sun glow
680
+ const sunGlow = ctx.createRadialGradient(sunX, sunY, 20, sunX, sunY, 80);
681
+ sunGlow.addColorStop(0, 'rgba(232, 93, 58, 0.3)');
682
+ sunGlow.addColorStop(1, 'rgba(232, 93, 58, 0)');
683
+ ctx.fillStyle = sunGlow;
684
+ ctx.fillRect(sunX - 80, sunY - 80, 160, 160);
685
+ // Silhouetted clouds (dark purple)
686
+ for (let i = 0; i < 4; i++) {
687
+ const cx = 50 + i * 140;
688
+ const cy = skyBottom - 80 - i * 15;
689
+ drawCloud(ctx, cx, cy, 70, 18, 0.6, '#2a1040');
690
+ }
691
+ break;
692
+ }
693
+ case 'dawn': {
694
+ const grad = ctx.createLinearGradient(0, skyTop, 0, skyBottom);
695
+ grad.addColorStop(0, '#0a1628');
696
+ grad.addColorStop(0.5, '#3a2a5c');
697
+ grad.addColorStop(1, '#d4926b');
698
+ ctx.fillStyle = grad;
699
+ ctx.fillRect(0, skyTop, dividerX, skyHeight);
700
+ // Fading stars (only brightest ones visible)
701
+ for (let i = 0; i < 15; i++) {
702
+ const star = STAR_POSITIONS[i];
703
+ if (!star || star.x > dividerX)
704
+ continue;
705
+ const alpha = 0.15 + Math.sin(frame * 0.1 + star.phase) * 0.08;
706
+ ctx.fillStyle = `rgba(255, 255, 240, ${alpha})`;
707
+ ctx.beginPath();
708
+ ctx.arc(star.x, star.y, star.size * 0.8, 0, Math.PI * 2);
709
+ ctx.fill();
710
+ }
711
+ // Thin pink/orange cloud wisps
712
+ ctx.strokeStyle = 'rgba(220, 160, 130, 0.25)';
713
+ ctx.lineWidth = 3;
714
+ for (let i = 0; i < 3; i++) {
715
+ const cy = skyBottom - 100 + i * 25;
716
+ ctx.beginPath();
717
+ ctx.moveTo(0, cy);
718
+ ctx.bezierCurveTo(dividerX * 0.3, cy - 10 + Math.sin(frame * 0.01 + i) * 5, dividerX * 0.6, cy + 8 - Math.sin(frame * 0.015 + i * 2) * 5, dividerX, cy - 5);
719
+ ctx.stroke();
720
+ }
721
+ break;
722
+ }
723
+ default: {
724
+ // Fallback: dark background
725
+ ctx.fillStyle = '#0d1117';
726
+ ctx.fillRect(0, skyTop, dividerX, skyHeight);
727
+ break;
728
+ }
729
+ }
730
+ // Aurora effect for space biome at night
731
+ if (weather === 'stars' && timeOfDay === 'night') {
732
+ renderAurora(ctx, dividerX, skyTop, skyHeight, frame);
733
+ }
734
+ }
735
+ /** Draw a soft cloud using overlapping circles */
736
+ function drawCloud(ctx, x, y, w, h, opacity, color = '#ffffff') {
737
+ const [r, g, b] = hexToRgb(color);
738
+ ctx.fillStyle = `rgba(${r},${g},${b},${opacity})`;
739
+ // Main body
740
+ ctx.beginPath();
741
+ ctx.arc(x, y, h, 0, Math.PI * 2);
742
+ ctx.fill();
743
+ ctx.beginPath();
744
+ ctx.arc(x + w * 0.3, y - h * 0.3, h * 1.1, 0, Math.PI * 2);
745
+ ctx.fill();
746
+ ctx.beginPath();
747
+ ctx.arc(x + w * 0.6, y, h * 0.9, 0, Math.PI * 2);
748
+ ctx.fill();
749
+ ctx.beginPath();
750
+ ctx.arc(x + w * 0.2, y + h * 0.2, h * 0.7, 0, Math.PI * 2);
751
+ ctx.fill();
752
+ ctx.beginPath();
753
+ ctx.arc(x + w * 0.5, y + h * 0.1, h * 0.8, 0, Math.PI * 2);
754
+ ctx.fill();
755
+ }
756
+ /** Aurora borealis effect — flowing curtains of green/purple light */
757
+ function renderAurora(ctx, width, skyTop, skyHeight, frame) {
758
+ const auroraColors = [
759
+ [80, 200, 120], // green
760
+ [130, 80, 200], // purple
761
+ [80, 180, 180], // teal
762
+ ];
763
+ ctx.save();
764
+ for (let i = 0; i < 3; i++) {
765
+ const [r, g, b] = auroraColors[i];
766
+ const baseY = skyTop + skyHeight * (0.15 + i * 0.08);
767
+ const amplitude = 30 + Math.sin(frame * 0.005 + i * 2) * 15;
768
+ ctx.beginPath();
769
+ ctx.moveTo(0, baseY);
770
+ // Bezier curves flowing across the sky
771
+ const cp1x = width * 0.25 + Math.sin(frame * 0.008 + i) * 30;
772
+ const cp1y = baseY - amplitude + Math.sin(frame * 0.006 + i * 1.5) * 20;
773
+ const cp2x = width * 0.75 + Math.cos(frame * 0.007 + i * 0.8) * 30;
774
+ const cp2y = baseY + amplitude * 0.5 + Math.cos(frame * 0.009 + i) * 15;
775
+ ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, width, baseY + 10);
776
+ // Close path downward for fill area
777
+ ctx.lineTo(width, baseY + 50);
778
+ const cp3x = width * 0.65 + Math.sin(frame * 0.006 + i + 1) * 20;
779
+ const cp3y = baseY + 50 + amplitude * 0.3;
780
+ const cp4x = width * 0.35 + Math.cos(frame * 0.008 + i + 1) * 20;
781
+ const cp4y = baseY + 40;
782
+ ctx.bezierCurveTo(cp3x, cp3y, cp4x, cp4y, 0, baseY + 30);
783
+ ctx.closePath();
784
+ // Gradient fill
785
+ const auroraGrad = ctx.createLinearGradient(0, baseY - amplitude, 0, baseY + 50);
786
+ auroraGrad.addColorStop(0, `rgba(${r},${g},${b},0)`);
787
+ auroraGrad.addColorStop(0.3, `rgba(${r},${g},${b},0.08)`);
788
+ auroraGrad.addColorStop(0.6, `rgba(${r},${g},${b},0.1)`);
789
+ auroraGrad.addColorStop(1, `rgba(${r},${g},${b},0)`);
790
+ ctx.fillStyle = auroraGrad;
791
+ ctx.fill();
792
+ }
793
+ ctx.restore();
794
+ }
795
+ /**
796
+ * Build parallax layers for a given biome.
797
+ */
798
+ export function buildParallaxLayers(biome, dividerX) {
799
+ const layers = [];
800
+ if (biome === 'grass') {
801
+ // Far: distant mountains
802
+ layers.push({
803
+ type: 'far',
804
+ factor: 0.1,
805
+ draw: (ctx, offsetX, _frame) => {
806
+ ctx.fillStyle = '#0d2d0d';
807
+ ctx.beginPath();
808
+ ctx.moveTo(0, 490);
809
+ for (let x = 0; x <= dividerX; x += 3) {
810
+ const px = x + offsetX * 0.1;
811
+ const y = 440 + Math.sin(px * 0.005) * 20 + Math.sin(px * 0.012) * 10;
812
+ ctx.lineTo(x, y);
813
+ }
814
+ ctx.lineTo(dividerX, 490);
815
+ ctx.closePath();
816
+ ctx.fill();
817
+ },
818
+ });
819
+ // Mid: rolling hills
820
+ layers.push({
821
+ type: 'mid',
822
+ factor: 0.3,
823
+ draw: (ctx, offsetX, _frame) => {
824
+ ctx.fillStyle = '#153a15';
825
+ ctx.beginPath();
826
+ ctx.moveTo(0, 490);
827
+ for (let x = 0; x <= dividerX; x += 2) {
828
+ const px = x + offsetX * 0.3;
829
+ const y = 455 + Math.sin(px * 0.008 + 1.2) * 15 + Math.sin(px * 0.015) * 8;
830
+ ctx.lineTo(x, y);
831
+ }
832
+ ctx.lineTo(dividerX, 490);
833
+ ctx.closePath();
834
+ ctx.fill();
835
+ },
836
+ });
837
+ // Near: foreground vegetation
838
+ layers.push({
839
+ type: 'near',
840
+ factor: 0.6,
841
+ draw: (ctx, offsetX, frame) => {
842
+ ctx.fillStyle = '#1a4d1a';
843
+ ctx.beginPath();
844
+ ctx.moveTo(0, 490);
845
+ for (let x = 0; x <= dividerX; x += 2) {
846
+ const px = x + offsetX * 0.6;
847
+ const y = 470 + Math.sin(px * 0.01 + 0.8) * 8 + Math.sin(px * 0.025) * 4;
848
+ ctx.lineTo(x, y);
849
+ }
850
+ ctx.lineTo(dividerX, 490);
851
+ ctx.closePath();
852
+ ctx.fill();
853
+ // Foreground flowers
854
+ for (let i = 0; i < 10; i++) {
855
+ const seed = (i * 137 + 42) % 1000;
856
+ const fx = ((seed * 3 + offsetX * 0.6) % (dividerX + 40)) - 20;
857
+ const fy = 472 + (seed * 7) % 15;
858
+ const colors = ['#ff6ec7', '#f0c040', '#f85149', '#58a6ff'];
859
+ ctx.fillStyle = colors[i % colors.length];
860
+ ctx.fillRect(fx, fy, 3, 3);
861
+ ctx.fillStyle = '#3fb950';
862
+ ctx.fillRect(fx + 1, fy + 3, 1, 2);
863
+ }
864
+ },
865
+ });
866
+ }
867
+ else if (biome === 'city') {
868
+ // Far: distant skyscrapers
869
+ layers.push({
870
+ type: 'far',
871
+ factor: 0.1,
872
+ draw: (ctx, offsetX, _frame) => {
873
+ const buildingsFar = [
874
+ { x: 30, w: 25, h: 80 }, { x: 80, w: 20, h: 60 }, { x: 130, w: 30, h: 100 },
875
+ { x: 180, w: 20, h: 70 }, { x: 240, w: 35, h: 90 }, { x: 300, w: 25, h: 110 },
876
+ { x: 350, w: 20, h: 65 }, { x: 400, w: 30, h: 85 }, { x: 460, w: 25, h: 95 },
877
+ { x: 510, w: 20, h: 75 },
878
+ ];
879
+ ctx.fillStyle = '#12121e';
880
+ for (const b of buildingsFar) {
881
+ const bx = ((b.x + offsetX * 0.1) % (dividerX + 60)) - 30;
882
+ ctx.fillRect(bx, 490 - b.h, b.w, b.h);
883
+ }
884
+ },
885
+ });
886
+ // Mid: mid buildings with windows
887
+ layers.push({
888
+ type: 'mid',
889
+ factor: 0.3,
890
+ draw: (ctx, offsetX, frame) => {
891
+ const buildingsMid = [
892
+ { x: 20, w: 40, h: 120 }, { x: 110, w: 50, h: 150 },
893
+ { x: 220, w: 45, h: 130 }, { x: 320, w: 55, h: 170 },
894
+ { x: 440, w: 50, h: 140 },
895
+ ];
896
+ for (const b of buildingsMid) {
897
+ const bx = ((b.x + offsetX * 0.3) % (dividerX + 80)) - 40;
898
+ ctx.fillStyle = '#1a1a25';
899
+ ctx.fillRect(bx, 490 - b.h, b.w, b.h);
900
+ // Lit windows
901
+ const windowSeed = Math.floor(frame / 30);
902
+ for (let wy = 490 - b.h + 8; wy < 485; wy += 12) {
903
+ for (let wx = bx + 4; wx < bx + b.w - 4; wx += 8) {
904
+ const lit = ((Math.floor(wx) * 7 + wy * 13 + windowSeed) % 5) < 2;
905
+ if (lit) {
906
+ ctx.fillStyle = '#f0c040';
907
+ ctx.fillRect(wx, wy, 4, 4);
908
+ }
909
+ }
910
+ }
911
+ }
912
+ },
913
+ });
914
+ // Near: street-level elements
915
+ layers.push({
916
+ type: 'near',
917
+ factor: 0.6,
918
+ draw: (ctx, offsetX, frame) => {
919
+ // Street lamps
920
+ for (let i = 0; i < 6; i++) {
921
+ const lx = ((i * 100 + offsetX * 0.6) % (dividerX + 100)) - 50;
922
+ ctx.fillStyle = '#333340';
923
+ ctx.fillRect(lx, 460, 3, 30);
924
+ // Lamp glow
925
+ ctx.fillStyle = 'rgba(255, 220, 100, 0.3)';
926
+ ctx.beginPath();
927
+ ctx.arc(lx + 1.5, 458, 8, 0, Math.PI * 2);
928
+ ctx.fill();
929
+ }
930
+ // Moving car
931
+ const carX = ((frame * 3 + offsetX * 0.6) % (dividerX + 100)) - 50;
932
+ ctx.fillStyle = '#f0c040';
933
+ ctx.fillRect(carX, 482, 6, 3);
934
+ ctx.fillRect(carX + 20, 482, 6, 3);
935
+ },
936
+ });
937
+ }
938
+ return layers;
939
+ }
940
+ /**
941
+ * Render parallax layers relative to robot position.
942
+ */
943
+ export function renderParallaxLayers(ctx, layers, robotX, frame) {
944
+ for (const layer of layers) {
945
+ layer.draw(ctx, robotX * layer.factor, frame);
946
+ }
947
+ }
948
+ /**
949
+ * Tick and render growing vegetation.
950
+ */
951
+ export function tickGrowingPlants(plants) {
952
+ for (const plant of plants) {
953
+ if (plant.growthStage < 1) {
954
+ plant.growthStage = Math.min(1, plant.growthStage + 0.0006); // ~30 seconds at 6fps
955
+ }
956
+ }
957
+ }
958
+ export function renderGrowingPlants(ctx, plants) {
959
+ for (const plant of plants) {
960
+ const g = plant.growthStage;
961
+ const h = plant.maxHeight * g;
962
+ switch (plant.type) {
963
+ case 'tree': {
964
+ if (g < 0.1) {
965
+ // Seed: 1px dot
966
+ ctx.fillStyle = '#553311';
967
+ ctx.fillRect(plant.x, plant.y, 2, 2);
968
+ }
969
+ else if (g < 0.4) {
970
+ // Trunk growing
971
+ const trunkH = h * 0.6;
972
+ ctx.fillStyle = '#553311';
973
+ ctx.fillRect(plant.x, plant.y - trunkH, 3, trunkH);
974
+ }
975
+ else {
976
+ // Full tree: trunk + branches + leaves
977
+ const trunkH = h * 0.5;
978
+ ctx.fillStyle = '#553311';
979
+ ctx.fillRect(plant.x, plant.y - trunkH, 3, trunkH);
980
+ // Branches
981
+ ctx.fillStyle = '#443322';
982
+ ctx.fillRect(plant.x - 4, plant.y - trunkH + 4, 11, 2);
983
+ // Leaves (crown)
984
+ const leafSize = h * 0.4 * Math.min(1, (g - 0.4) / 0.3);
985
+ ctx.fillStyle = plant.color;
986
+ ctx.beginPath();
987
+ ctx.arc(plant.x + 1, plant.y - trunkH - leafSize * 0.3, leafSize, 0, Math.PI * 2);
988
+ ctx.fill();
989
+ // Highlight
990
+ ctx.fillStyle = '#4dff7a';
991
+ ctx.beginPath();
992
+ ctx.arc(plant.x - 1, plant.y - trunkH - leafSize * 0.5, leafSize * 0.3, 0, Math.PI * 2);
993
+ ctx.fill();
994
+ }
995
+ break;
996
+ }
997
+ case 'flower': {
998
+ if (g < 0.15) {
999
+ ctx.fillStyle = '#2a5a2a';
1000
+ ctx.fillRect(plant.x, plant.y, 1, 1);
1001
+ }
1002
+ else {
1003
+ // Stem
1004
+ const stemH = Math.min(h * 0.7, h * g);
1005
+ ctx.fillStyle = '#2a7a2a';
1006
+ ctx.fillRect(plant.x, plant.y - stemH, 1, stemH);
1007
+ // Bud/petals
1008
+ if (g > 0.5) {
1009
+ const petalSize = 2 + (g - 0.5) * 4;
1010
+ ctx.fillStyle = plant.color;
1011
+ // 4 petals
1012
+ ctx.beginPath();
1013
+ ctx.arc(plant.x - petalSize * 0.4, plant.y - stemH, petalSize * 0.5, 0, Math.PI * 2);
1014
+ ctx.fill();
1015
+ ctx.beginPath();
1016
+ ctx.arc(plant.x + petalSize * 0.4, plant.y - stemH, petalSize * 0.5, 0, Math.PI * 2);
1017
+ ctx.fill();
1018
+ ctx.beginPath();
1019
+ ctx.arc(plant.x, plant.y - stemH - petalSize * 0.4, petalSize * 0.5, 0, Math.PI * 2);
1020
+ ctx.fill();
1021
+ ctx.beginPath();
1022
+ ctx.arc(plant.x, plant.y - stemH + petalSize * 0.4, petalSize * 0.5, 0, Math.PI * 2);
1023
+ ctx.fill();
1024
+ // Center
1025
+ ctx.fillStyle = '#f0c040';
1026
+ ctx.beginPath();
1027
+ ctx.arc(plant.x, plant.y - stemH, petalSize * 0.25, 0, Math.PI * 2);
1028
+ ctx.fill();
1029
+ }
1030
+ }
1031
+ break;
1032
+ }
1033
+ case 'mushroom': {
1034
+ if (g < 0.2) {
1035
+ ctx.fillStyle = '#8b6914';
1036
+ ctx.fillRect(plant.x, plant.y, 2, 1);
1037
+ }
1038
+ else {
1039
+ const stemH = h * 0.4 * g;
1040
+ ctx.fillStyle = '#d4c4a0';
1041
+ ctx.fillRect(plant.x, plant.y - stemH, 2, stemH);
1042
+ // Cap
1043
+ const capW = 4 + g * 6;
1044
+ ctx.fillStyle = plant.color;
1045
+ ctx.beginPath();
1046
+ ctx.arc(plant.x + 1, plant.y - stemH, capW / 2, Math.PI, 0);
1047
+ ctx.fill();
1048
+ // Spots
1049
+ if (g > 0.6) {
1050
+ ctx.fillStyle = '#ffffff';
1051
+ ctx.beginPath();
1052
+ ctx.arc(plant.x - 1, plant.y - stemH - 1, 1, 0, Math.PI * 2);
1053
+ ctx.fill();
1054
+ ctx.beginPath();
1055
+ ctx.arc(plant.x + 3, plant.y - stemH - 2, 1, 0, Math.PI * 2);
1056
+ ctx.fill();
1057
+ }
1058
+ }
1059
+ break;
1060
+ }
1061
+ case 'crystal': {
1062
+ if (g < 0.1) {
1063
+ ctx.fillStyle = plant.color;
1064
+ ctx.fillRect(plant.x, plant.y, 1, 1);
1065
+ }
1066
+ else {
1067
+ // Crystal shard
1068
+ const crystalH = h * g;
1069
+ ctx.fillStyle = plant.color;
1070
+ ctx.beginPath();
1071
+ ctx.moveTo(plant.x, plant.y);
1072
+ ctx.lineTo(plant.x - 3, plant.y);
1073
+ ctx.lineTo(plant.x - 1, plant.y - crystalH);
1074
+ ctx.lineTo(plant.x + 1, plant.y - crystalH * 0.8);
1075
+ ctx.lineTo(plant.x + 3, plant.y);
1076
+ ctx.closePath();
1077
+ ctx.fill();
1078
+ // Highlight edge
1079
+ ctx.fillStyle = 'rgba(255,255,255,0.3)';
1080
+ ctx.beginPath();
1081
+ ctx.moveTo(plant.x - 1, plant.y - crystalH);
1082
+ ctx.lineTo(plant.x, plant.y - crystalH * 0.3);
1083
+ ctx.lineTo(plant.x + 1, plant.y - crystalH * 0.8);
1084
+ ctx.closePath();
1085
+ ctx.fill();
1086
+ }
1087
+ break;
1088
+ }
1089
+ }
1090
+ }
1091
+ }
1092
+ // ─── Animated Water ──────────────────────────────────────────────
1093
+ /**
1094
+ * Render animated water with multiple sine waves, reflections, and foam.
1095
+ */
1096
+ export function renderAnimatedWater(ctx, dividerX, frame) {
1097
+ // Multiple wave layers with different frequencies
1098
+ const waveLayers = [
1099
+ { color: '#0d4a7a', freq1: 0.02, freq2: 0.04, amp1: 8, amp2: 4, speed1: 3, speed2: 5, baseY: 460 },
1100
+ { color: '#0a5a8e', freq1: 0.025, freq2: 0.05, amp1: 6, amp2: 3, speed1: 4, speed2: 2, baseY: 470 },
1101
+ { color: '#0e6aaa', freq1: 0.03, freq2: 0.06, amp1: 4, amp2: 2, speed1: 2, speed2: 6, baseY: 478 },
1102
+ ];
1103
+ for (const wave of waveLayers) {
1104
+ ctx.fillStyle = wave.color;
1105
+ ctx.beginPath();
1106
+ ctx.moveTo(0, 490);
1107
+ for (let x = 0; x <= dividerX; x += 2) {
1108
+ const y = wave.baseY +
1109
+ Math.sin((x + frame * wave.speed1) * wave.freq1) * wave.amp1 +
1110
+ Math.sin((x + frame * wave.speed2) * wave.freq2 + 2) * wave.amp2;
1111
+ ctx.lineTo(x, y);
1112
+ }
1113
+ ctx.lineTo(dividerX, 490);
1114
+ ctx.closePath();
1115
+ ctx.fill();
1116
+ }
1117
+ // Reflective highlights on wave peaks
1118
+ for (let x = 0; x < dividerX; x += 15) {
1119
+ const waveY = 460 + Math.sin((x + frame * 3) * 0.02) * 8;
1120
+ const isHighPoint = Math.sin((x + frame * 3) * 0.02) > 0.6;
1121
+ if (isHighPoint) {
1122
+ ctx.fillStyle = `rgba(255, 255, 255, ${0.2 + Math.random() * 0.1})`;
1123
+ ctx.fillRect(x, waveY, 3, 1);
1124
+ }
1125
+ }
1126
+ // Foam strip at wave tops
1127
+ for (let x = 0; x < dividerX; x += 4) {
1128
+ const foamY = 458 + Math.sin((x + frame * 3) * 0.02) * 8;
1129
+ if (Math.random() > 0.6) {
1130
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.15)';
1131
+ ctx.fillRect(x, foamY, 2 + Math.floor(Math.random() * 3), 1);
1132
+ }
1133
+ }
1134
+ }
1135
+ // ─── Lava Flow ───────────────────────────────────────────────────
1136
+ /**
1137
+ * Render lava with organic flow patterns and popping bubbles.
1138
+ */
1139
+ export function renderLavaFlow(ctx, dividerX, frame) {
1140
+ // Organic lava flow using overlapping sine waves at different scales
1141
+ for (let layer = 0; layer < 4; layer++) {
1142
+ const layerY = 450 + layer * 10;
1143
+ ctx.beginPath();
1144
+ ctx.moveTo(0, 490);
1145
+ for (let x = 0; x <= dividerX; x += 2) {
1146
+ const n1 = Math.sin((x + frame * (4 - layer)) * 0.03 + layer * 1.5);
1147
+ const n2 = Math.sin((x * 0.07 + frame * 0.5) * 0.5 + layer);
1148
+ const n3 = Math.sin((x * 0.02 + frame * 0.8) * 0.8 + layer * 2.1);
1149
+ const y = layerY + (n1 * 4 + n2 * 3 + n3 * 2);
1150
+ ctx.lineTo(x, y);
1151
+ }
1152
+ ctx.lineTo(dividerX, 490);
1153
+ ctx.closePath();
1154
+ // Color layers: bright yellow core → orange → red → dark
1155
+ const layerColors = ['#ff4400', '#ff6600', '#f0a030', '#ffe040'];
1156
+ ctx.fillStyle = layerColors[layer];
1157
+ ctx.fill();
1158
+ }
1159
+ // Lava bubbles
1160
+ const bubbleCount = 3;
1161
+ for (let i = 0; i < bubbleCount; i++) {
1162
+ const seed = (i * 137 + 42);
1163
+ const bubbleX = (seed * 3 + frame * 0.5) % dividerX;
1164
+ const cycleLen = 60 + (seed % 30);
1165
+ const bubblePhase = (frame + seed) % cycleLen;
1166
+ const bubbleProgress = bubblePhase / cycleLen;
1167
+ if (bubbleProgress < 0.8) {
1168
+ // Rising bubble
1169
+ const bubbleY = 480 - bubbleProgress * 30;
1170
+ const radius = 2 + bubbleProgress * 3;
1171
+ ctx.fillStyle = '#ffe080';
1172
+ ctx.beginPath();
1173
+ ctx.arc(bubbleX, bubbleY, radius, 0, Math.PI * 2);
1174
+ ctx.fill();
1175
+ // Highlight
1176
+ ctx.fillStyle = 'rgba(255, 255, 200, 0.5)';
1177
+ ctx.beginPath();
1178
+ ctx.arc(bubbleX - 1, bubbleY - 1, radius * 0.3, 0, Math.PI * 2);
1179
+ ctx.fill();
1180
+ }
1181
+ else {
1182
+ // Pop: small splash particles
1183
+ const popY = 480 - 0.8 * 30;
1184
+ const popProgress = (bubbleProgress - 0.8) / 0.2;
1185
+ for (let s = 0; s < 4; s++) {
1186
+ const angle = (s / 4) * Math.PI * 2 + seed;
1187
+ const dist = popProgress * 8;
1188
+ const sx = bubbleX + Math.cos(angle) * dist;
1189
+ const sy = popY + Math.sin(angle) * dist - popProgress * 4;
1190
+ const alpha = 1 - popProgress;
1191
+ ctx.fillStyle = `rgba(255, 180, 60, ${alpha})`;
1192
+ ctx.fillRect(sx, sy, 2, 2);
1193
+ }
1194
+ }
1195
+ }
1196
+ }
1197
+ // ─── 6. CHARACTER EFFECTS ────────────────────────────────────────
1198
+ /**
1199
+ * Draw AAA character effects: eye glow bleed, power-up aura, walk trail, etc.
1200
+ */
1201
+ export function drawCharacterEffects(ctx, x, y, scale, mood, frame, isExecutingTool, walkSpeed, moodColor) {
1202
+ const [r, g, b] = hexToRgb(moodColor);
1203
+ // Eye glow bleed: radial gradient extending 3px beyond each eye
1204
+ const headX = x + 9 * scale;
1205
+ const headY = y + 5 * scale;
1206
+ const eyeY = headY + 4 * scale;
1207
+ const eyeGlowRadius = 5 * scale;
1208
+ ctx.save();
1209
+ // Left eye glow
1210
+ const leftEyeX = headX + 4 * scale;
1211
+ const eyeGlow1 = ctx.createRadialGradient(leftEyeX, eyeY, 0, leftEyeX, eyeY, eyeGlowRadius);
1212
+ eyeGlow1.addColorStop(0, `rgba(${r},${g},${b},0.2)`);
1213
+ eyeGlow1.addColorStop(1, `rgba(${r},${g},${b},0)`);
1214
+ ctx.fillStyle = eyeGlow1;
1215
+ ctx.fillRect(leftEyeX - eyeGlowRadius, eyeY - eyeGlowRadius, eyeGlowRadius * 2, eyeGlowRadius * 2);
1216
+ // Right eye glow
1217
+ const rightEyeX = headX + 10 * scale;
1218
+ const eyeGlow2 = ctx.createRadialGradient(rightEyeX, eyeY, 0, rightEyeX, eyeY, eyeGlowRadius);
1219
+ eyeGlow2.addColorStop(0, `rgba(${r},${g},${b},0.2)`);
1220
+ eyeGlow2.addColorStop(1, `rgba(${r},${g},${b},0)`);
1221
+ ctx.fillStyle = eyeGlow2;
1222
+ ctx.fillRect(rightEyeX - eyeGlowRadius, eyeY - eyeGlowRadius, eyeGlowRadius * 2, eyeGlowRadius * 2);
1223
+ ctx.restore();
1224
+ // Power-up aura: rotating hexagon during tool execution
1225
+ if (isExecutingTool) {
1226
+ const cx = x + 16 * scale;
1227
+ const cy = y + 22 * scale;
1228
+ const baseRadius = 25 * scale / 10;
1229
+ const pulseRadius = baseRadius + Math.sin(frame * 0.15) * 5;
1230
+ const rotation = frame * 0.05;
1231
+ ctx.save();
1232
+ ctx.strokeStyle = '#f0c040';
1233
+ ctx.lineWidth = 2;
1234
+ ctx.globalAlpha = 0.6 + Math.sin(frame * 0.2) * 0.2;
1235
+ ctx.beginPath();
1236
+ for (let i = 0; i < 6; i++) {
1237
+ const angle = rotation + (i / 6) * Math.PI * 2;
1238
+ const px = cx + Math.cos(angle) * pulseRadius;
1239
+ const py = cy + Math.sin(angle) * pulseRadius;
1240
+ if (i === 0)
1241
+ ctx.moveTo(px, py);
1242
+ else
1243
+ ctx.lineTo(px, py);
1244
+ }
1245
+ ctx.closePath();
1246
+ ctx.stroke();
1247
+ ctx.restore();
1248
+ }
1249
+ // Mood aura — persistent subtle glow
1250
+ if (mood !== 'idle') {
1251
+ const cx = x + 16 * scale;
1252
+ const cy = y + 20 * scale;
1253
+ const auraRadius = 40 * scale / 10;
1254
+ const auraAlpha = 0.08 + Math.sin(frame * 0.08) * 0.04;
1255
+ const auraGrad = ctx.createRadialGradient(cx, cy, 0, cx, cy, auraRadius);
1256
+ auraGrad.addColorStop(0, `rgba(${r},${g},${b},${auraAlpha})`);
1257
+ auraGrad.addColorStop(1, `rgba(${r},${g},${b},0)`);
1258
+ ctx.fillStyle = auraGrad;
1259
+ ctx.fillRect(cx - auraRadius, cy - auraRadius, auraRadius * 2, auraRadius * 2);
1260
+ }
1261
+ }
1262
+ // Mood transition state
1263
+ let _moodTransitionFramesRemaining = 0;
1264
+ let _moodTransitionColor = '#3fb950';
1265
+ let _lastMoodForTransition = '';
1266
+ /**
1267
+ * Track mood changes and return chromatic aberration offset if transitioning.
1268
+ * Returns {active, framesLeft} — caller draws character 3x with RGB offsets.
1269
+ */
1270
+ export function checkMoodTransition(mood, moodColor) {
1271
+ if (mood !== _lastMoodForTransition) {
1272
+ _lastMoodForTransition = mood;
1273
+ _moodTransitionFramesRemaining = 6;
1274
+ _moodTransitionColor = moodColor;
1275
+ }
1276
+ if (_moodTransitionFramesRemaining > 0) {
1277
+ _moodTransitionFramesRemaining--;
1278
+ return { active: true, framesLeft: _moodTransitionFramesRemaining };
1279
+ }
1280
+ return { active: false, framesLeft: 0 };
1281
+ }
1282
+ // Damage flash state
1283
+ let _flashWhiteFrames = 0;
1284
+ /**
1285
+ * Trigger a white damage flash for 2 frames.
1286
+ */
1287
+ export function triggerDamageFlash() {
1288
+ _flashWhiteFrames = 2;
1289
+ }
1290
+ /**
1291
+ * Render damage flash overlay on the character.
1292
+ * Returns true if flash is active (caller should skip normal character rendering logic).
1293
+ */
1294
+ export function renderDamageFlash(ctx, x, y, scale) {
1295
+ if (_flashWhiteFrames > 0) {
1296
+ _flashWhiteFrames--;
1297
+ ctx.save();
1298
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.6)';
1299
+ ctx.fillRect(x - 2 * scale, y - 4 * scale, 38 * scale, 52 * scale);
1300
+ ctx.restore();
1301
+ return true;
1302
+ }
1303
+ return false;
1304
+ }
1305
+ /**
1306
+ * Apply screen-space post-processing effects.
1307
+ */
1308
+ export function renderPostProcessing(ctx, width, height, frame, options) {
1309
+ ctx.save();
1310
+ // Film grain — subtle noise overlay
1311
+ if (options.filmGrain) {
1312
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.03)';
1313
+ for (let i = 0; i < 200; i++) {
1314
+ const gx = Math.random() * width;
1315
+ const gy = Math.random() * height;
1316
+ ctx.fillRect(gx, gy, 1, 1);
1317
+ }
1318
+ // Occasional darker grain
1319
+ ctx.fillStyle = 'rgba(0, 0, 0, 0.04)';
1320
+ for (let i = 0; i < 80; i++) {
1321
+ ctx.fillRect(Math.random() * width, Math.random() * height, 1, 1);
1322
+ }
1323
+ }
1324
+ // Improved scanlines — very subtle CRT feel
1325
+ if (options.scanlines) {
1326
+ ctx.fillStyle = 'rgba(0, 0, 0, 0.08)';
1327
+ for (let y = 0; y < height; y += 3) {
1328
+ ctx.fillRect(0, y, width, 1);
1329
+ }
1330
+ }
1331
+ // Improved vignette — radial darkening at corners
1332
+ if (options.vignette) {
1333
+ const vignetteGrad = ctx.createRadialGradient(width / 2, height / 2, width * 0.3, width / 2, height / 2, width * 0.8);
1334
+ vignetteGrad.addColorStop(0, 'rgba(0,0,0,0)');
1335
+ vignetteGrad.addColorStop(0.7, 'rgba(0,0,0,0.15)');
1336
+ vignetteGrad.addColorStop(1, 'rgba(0,0,0,0.4)');
1337
+ ctx.fillStyle = vignetteGrad;
1338
+ ctx.fillRect(0, 0, width, height);
1339
+ }
1340
+ // Focus pulse — spotlight effect
1341
+ if (options.focusPulse) {
1342
+ const fp = options.focusPulse;
1343
+ // Darken everything
1344
+ ctx.fillStyle = 'rgba(0,0,0,0.35)';
1345
+ ctx.fillRect(0, 0, width, height);
1346
+ // Clear the spotlight area using a radial gradient that goes from transparent to dark
1347
+ const spotGrad = ctx.createRadialGradient(fp.x, fp.y, 0, fp.x, fp.y, fp.radius);
1348
+ spotGrad.addColorStop(0, 'rgba(0,0,0,0)');
1349
+ spotGrad.addColorStop(0.7, 'rgba(0,0,0,0)');
1350
+ spotGrad.addColorStop(1, 'rgba(0,0,0,0.35)');
1351
+ // Use destination-out to punch a hole through the darkening
1352
+ ctx.globalCompositeOperation = 'destination-out';
1353
+ ctx.fillStyle = spotGrad;
1354
+ ctx.beginPath();
1355
+ ctx.arc(fp.x, fp.y, fp.radius, 0, Math.PI * 2);
1356
+ ctx.fill();
1357
+ ctx.globalCompositeOperation = 'source-over';
1358
+ }
1359
+ ctx.restore();
1360
+ }
1361
+ //# sourceMappingURL=render-engine.js.map