@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,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
|