@kernel.chat/kbot 3.87.0 → 3.93.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,1208 @@
1
+ /**
2
+ * rom-engine.ts — ROM-hack-inspired rendering engine
3
+ *
4
+ * Core rendering engine that brings SNES/GBA-era visual techniques to Canvas 2D:
5
+ * 1. Indexed color palette system (256 entries, packed RGBA uint32)
6
+ * 2. Palette cycling (Mark Ferrari technique — loop & pingpong modes)
7
+ * 3. HDMA-style per-scanline sky gradient (night/day/sunset/dawn)
8
+ * 4. Parallax layer system (4-5 layers per biome)
9
+ * 5. Frame budget tracking with adaptive quality
10
+ *
11
+ * References:
12
+ * - SNES PPU: H-Blank DMA (HDMA) for per-scanline register writes
13
+ * - Mark Ferrari / CanvasCycle: palette rotation as animation primitive
14
+ * - GBA ROM hacks: HBlank parallax, hardware blending, 15-bit palettes
15
+ */
16
+ // ---------------------------------------------------------------------------
17
+ // 1. RGBA Pack / Unpack
18
+ // ---------------------------------------------------------------------------
19
+ export function packRGBA(r, g, b, a = 255) {
20
+ return ((a & 0xFF) << 24) | ((b & 0xFF) << 16) | ((g & 0xFF) << 8) | (r & 0xFF);
21
+ }
22
+ export function unpackRGBA(packed) {
23
+ return [
24
+ packed & 0xFF,
25
+ (packed >>> 8) & 0xFF,
26
+ (packed >>> 16) & 0xFF,
27
+ (packed >>> 24) & 0xFF,
28
+ ];
29
+ }
30
+ /** Convert hex string (#RRGGBB) to packed RGBA uint32 */
31
+ function hexToPackedRGBA(hex, a = 255) {
32
+ const h = hex.replace('#', '');
33
+ const r = parseInt(h.substring(0, 2), 16);
34
+ const g = parseInt(h.substring(2, 4), 16);
35
+ const b = parseInt(h.substring(4, 6), 16);
36
+ return packRGBA(r, g, b, a);
37
+ }
38
+ /** Linearly interpolate between two packed RGBA colors */
39
+ function lerpColor(a, b, t) {
40
+ const [ar, ag, ab, aa] = unpackRGBA(a);
41
+ const [br, bg, bb, ba] = unpackRGBA(b);
42
+ return packRGBA(Math.round(ar + (br - ar) * t), Math.round(ag + (bg - ag) * t), Math.round(ab + (bb - ab) * t), Math.round(aa + (ba - aa) * t));
43
+ }
44
+ /** Build a gradient ramp between two hex colors across N entries */
45
+ function buildGradient(hexA, hexB, count) {
46
+ const a = hexToPackedRGBA(hexA);
47
+ const b = hexToPackedRGBA(hexB);
48
+ const out = [];
49
+ for (let i = 0; i < count; i++) {
50
+ out.push(lerpColor(a, b, i / Math.max(count - 1, 1)));
51
+ }
52
+ return out;
53
+ }
54
+ /** Build a multi-stop gradient. stops: [hex, hex, ...], evenly spaced across count entries */
55
+ function buildMultiGradient(stops, count) {
56
+ if (stops.length < 2) {
57
+ const c = hexToPackedRGBA(stops[0] || '#000000');
58
+ return new Array(count).fill(c);
59
+ }
60
+ const out = [];
61
+ const segments = stops.length - 1;
62
+ for (let i = 0; i < count; i++) {
63
+ const t = i / Math.max(count - 1, 1);
64
+ const seg = Math.min(Math.floor(t * segments), segments - 1);
65
+ const localT = (t * segments) - seg;
66
+ const a = hexToPackedRGBA(stops[seg]);
67
+ const b = hexToPackedRGBA(stops[seg + 1]);
68
+ out.push(lerpColor(a, b, localT));
69
+ }
70
+ return out;
71
+ }
72
+ export function createPalette() {
73
+ const colors = new Uint32Array(256);
74
+ const base = new Uint32Array(256);
75
+ // 0: transparent
76
+ colors[0] = packRGBA(0, 0, 0, 0);
77
+ // 1-15: UI colors
78
+ const uiColors = [
79
+ '#FFFFFF', '#E0E0E0', '#C0C0C0', '#A0A0A0', '#808080',
80
+ '#606060', '#404040', '#202020', '#6B5B95', '#D4A574',
81
+ '#7BC67E', '#5BA3CF', '#CF5B5B', '#CFB85B', '#5BCFCF',
82
+ ];
83
+ for (let i = 0; i < 15; i++) {
84
+ colors[i + 1] = hexToPackedRGBA(uiColors[i]);
85
+ }
86
+ // 16-31: Grass (4 shades of green, hue-shifted across 16 entries)
87
+ const grassColors = buildMultiGradient(['#0A3A0A', '#1A5A1A', '#2A7A2A', '#4A9A3A', '#3A8A2A', '#1A6A1A', '#0A4A0A', '#1A5A1A'], 16);
88
+ for (let i = 0; i < 16; i++)
89
+ colors[16 + i] = grassColors[i];
90
+ // 32-47: Water (dark blue -> light blue -> white -> dark blue — cycling range)
91
+ const waterColors = buildMultiGradient(['#0A1628', '#1A3A5C', '#2E6090', '#4A8ABF', '#87CEEB', '#B0D8F0', '#D0E8F8', '#FFFFFF',
92
+ '#D0E8F8', '#B0D8F0', '#87CEEB', '#4A8ABF', '#2E6090', '#1A3A5C', '#0A1628', '#0F2040'], 16);
93
+ for (let i = 0; i < 16; i++)
94
+ colors[32 + i] = waterColors[i];
95
+ // 48-63: Stone (grays with subtle brown)
96
+ const stoneColors = buildMultiGradient(['#3A3530', '#4A4540', '#5A5550', '#6A6560', '#7A7570',
97
+ '#8A8580', '#9A9590', '#AAA5A0'], 16);
98
+ for (let i = 0; i < 16; i++)
99
+ colors[48 + i] = stoneColors[i];
100
+ // 64-79: Lava (black -> red -> orange -> yellow — cycling range)
101
+ const lavaColors = buildMultiGradient(['#100000', '#3A0000', '#6A0A00', '#A02000', '#D04010',
102
+ '#E06020', '#F09030', '#FFC040', '#FFE060', '#FFC040',
103
+ '#F09030', '#E06020', '#D04010', '#A02000', '#6A0A00', '#3A0000'], 16);
104
+ for (let i = 0; i < 16; i++)
105
+ colors[64 + i] = lavaColors[i];
106
+ // 80-95: Sand (warm yellows)
107
+ const sandColors = buildMultiGradient(['#8A7050', '#A08860', '#B8A070', '#D0B880', '#E0C890',
108
+ '#E8D0A0', '#F0D8B0', '#F8E0C0'], 16);
109
+ for (let i = 0; i < 16; i++)
110
+ colors[80 + i] = sandColors[i];
111
+ // 96-111: Snow/ice (whites and light blues)
112
+ const snowColors = buildMultiGradient(['#C0D0E0', '#D0E0F0', '#E0E8F8', '#F0F0FF', '#FFFFFF',
113
+ '#F0F8FF', '#E0F0FF', '#D0E8FF'], 16);
114
+ for (let i = 0; i < 16; i++)
115
+ colors[96 + i] = snowColors[i];
116
+ // 112-127: Sky (deep blue -> horizon colors — cycling during sunset)
117
+ const skyColors = buildMultiGradient(['#0A1628', '#1A2A4A', '#2A3A5A', '#3A4A6A', '#4A5A7A',
118
+ '#5A6A8A', '#6A7A9A', '#7A8AAA', '#8A9ABA', '#9AAACA',
119
+ '#AAB8DA', '#BAC8EA', '#C8D8F0', '#D0E0F8', '#D8E8FF', '#E0F0FF'], 16);
120
+ for (let i = 0; i < 16; i++)
121
+ colors[112 + i] = skyColors[i];
122
+ // 128-143: Ore highlights (gray -> white -> gray — cycling)
123
+ const oreColors = buildMultiGradient(['#606060', '#707070', '#808080', '#909090', '#A0A0A0',
124
+ '#B0B0B0', '#C0C0C0', '#D0D0D0', '#E0E0E0', '#F0F0F0',
125
+ '#FFFFFF', '#F0F0F0', '#E0E0E0', '#D0D0D0', '#C0C0C0', '#B0B0B0'], 16);
126
+ for (let i = 0; i < 16; i++)
127
+ colors[128 + i] = oreColors[i];
128
+ // 144-159: Fire (red -> orange -> yellow -> white — cycling)
129
+ const fireColors = buildMultiGradient(['#200000', '#500000', '#801000', '#A03000', '#C05000',
130
+ '#E07000', '#F09000', '#FFB000', '#FFD040', '#FFE070',
131
+ '#FFF0A0', '#FFFFD0', '#FFFFFF', '#FFE070', '#FFB000', '#C05000'], 16);
132
+ for (let i = 0; i < 16; i++)
133
+ colors[144 + i] = fireColors[i];
134
+ // 160-175: Aurora (green -> cyan -> purple — cycling, night only)
135
+ const auroraColors = buildMultiGradient(['#00FF60', '#00FFAA', '#00FFD0', '#00FFF0', '#00E0FF',
136
+ '#00C0FF', '#20A0FF', '#4080FF', '#6060FF', '#8040FF',
137
+ '#A020FF', '#C000E0', '#A020FF', '#6060FF', '#20A0FF', '#00E0FF'], 16);
138
+ for (let i = 0; i < 16; i++)
139
+ colors[160 + i] = auroraColors[i];
140
+ // 176-191: Wood/leaves (browns and greens)
141
+ const woodColors = buildMultiGradient(['#3A2A1A', '#4A3A2A', '#5A4A3A', '#6A5A4A', '#5A5030',
142
+ '#4A5A20', '#3A5A1A', '#2A5A10'], 16);
143
+ for (let i = 0; i < 16; i++)
144
+ colors[176 + i] = woodColors[i];
145
+ // 192-207: Brick/glass (reds and light blues)
146
+ const brickColors = buildMultiGradient(['#5A2020', '#7A3030', '#9A4040', '#BA5050', '#A06060',
147
+ '#8090B0', '#90A8C8', '#A0C0E0'], 16);
148
+ for (let i = 0; i < 16; i++)
149
+ colors[192 + i] = brickColors[i];
150
+ // 208-223: Decorative (flower colors, moss)
151
+ const decoColors = buildMultiGradient(['#FF6090', '#FF80A0', '#FFA0B0', '#D0A0FF', '#A090E0',
152
+ '#80D080', '#60B060', '#409040'], 16);
153
+ for (let i = 0; i < 16; i++)
154
+ colors[208 + i] = decoColors[i];
155
+ // 224-239: Star twinkle (dim -> bright — cycling)
156
+ const starColors = buildMultiGradient(['#101020', '#181828', '#202040', '#303060', '#404880',
157
+ '#5060A0', '#6878C0', '#8090E0', '#A0B0FF', '#C0D0FF',
158
+ '#E0E8FF', '#FFFFFF', '#E0E8FF', '#C0D0FF', '#A0B0FF', '#8090E0'], 16);
159
+ for (let i = 0; i < 16; i++)
160
+ colors[224 + i] = starColors[i];
161
+ // 240-255: Reserved (default to dark gray)
162
+ for (let i = 240; i < 256; i++)
163
+ colors[i] = packRGBA(32, 32, 32, 255);
164
+ // Copy to base for reset capability
165
+ base.set(colors);
166
+ return { colors, base };
167
+ }
168
+ /** Reset palette to base colors */
169
+ export function resetPalette(palette) {
170
+ palette.colors.set(palette.base);
171
+ }
172
+ const DEFAULT_CYCLES = [
173
+ { start: 32, end: 39, speed: 100, direction: 1, mode: 'loop' }, // water surface
174
+ { start: 40, end: 47, speed: 150, direction: 1, mode: 'loop' }, // water depth
175
+ { start: 64, end: 71, speed: 60, direction: 1, mode: 'pingpong' }, // lava
176
+ { start: 128, end: 131, speed: 80, direction: 1, mode: 'pingpong' }, // ore shimmer
177
+ { start: 144, end: 149, speed: 40, direction: 1, mode: 'loop' }, // fire
178
+ { start: 160, end: 169, speed: 120, direction: 1, mode: 'loop' }, // aurora
179
+ { start: 224, end: 225, speed: 300, direction: 1, mode: 'pingpong' }, // stars
180
+ ];
181
+ export function createDefaultCycles() {
182
+ return DEFAULT_CYCLES.map(c => ({
183
+ ...c,
184
+ accumulator: 0,
185
+ pingpongDir: c.direction,
186
+ }));
187
+ }
188
+ function rotateCycleRange(palette, start, end, dir) {
189
+ const len = end - start + 1;
190
+ if (len < 2)
191
+ return;
192
+ if (dir === 1) {
193
+ // Forward: save last, shift right, put saved at start
194
+ const saved = palette.colors[end];
195
+ for (let i = end; i > start; i--)
196
+ palette.colors[i] = palette.colors[i - 1];
197
+ palette.colors[start] = saved;
198
+ }
199
+ else {
200
+ // Reverse: save first, shift left, put saved at end
201
+ const saved = palette.colors[start];
202
+ for (let i = start; i < end; i++)
203
+ palette.colors[i] = palette.colors[i + 1];
204
+ palette.colors[end] = saved;
205
+ }
206
+ }
207
+ export function tickPaletteCycles(palette, cycles, deltaMs) {
208
+ let anyTicked = false;
209
+ for (const cycle of cycles) {
210
+ cycle.accumulator += deltaMs;
211
+ while (cycle.accumulator >= cycle.speed) {
212
+ cycle.accumulator -= cycle.speed;
213
+ if (cycle.mode === 'loop') {
214
+ rotateCycleRange(palette, cycle.start, cycle.end, cycle.direction);
215
+ }
216
+ else {
217
+ // pingpong mode
218
+ rotateCycleRange(palette, cycle.start, cycle.end, cycle.pingpongDir);
219
+ // Track position and reverse at boundaries
220
+ // We use a simple approach: reverse direction after (end - start) rotations
221
+ const rangeLen = cycle.end - cycle.start;
222
+ if (!('_bounceCount' in cycle)) {
223
+ cycle._bounceCount = 0;
224
+ }
225
+ const c = cycle;
226
+ c._bounceCount++;
227
+ if (c._bounceCount >= rangeLen) {
228
+ cycle.pingpongDir = (cycle.pingpongDir === 1 ? -1 : 1);
229
+ c._bounceCount = 0;
230
+ }
231
+ }
232
+ anyTicked = true;
233
+ }
234
+ }
235
+ return anyTicked;
236
+ }
237
+ /** Parse hex to RGB tuple */
238
+ function hexToRGB(hex) {
239
+ const h = hex.replace('#', '');
240
+ return [
241
+ parseInt(h.substring(0, 2), 16),
242
+ parseInt(h.substring(2, 4), 16),
243
+ parseInt(h.substring(4, 6), 16),
244
+ ];
245
+ }
246
+ /** Interpolate between two RGB triplets */
247
+ function lerpRGB(a, b, t) {
248
+ return [
249
+ Math.round(a[0] + (b[0] - a[0]) * t),
250
+ Math.round(a[1] + (b[1] - a[1]) * t),
251
+ Math.round(a[2] + (b[2] - a[2]) * t),
252
+ ];
253
+ }
254
+ function buildGradientTable(stops, totalLines) {
255
+ const table = [];
256
+ for (let y = 0; y < totalLines; y++) {
257
+ // Find the two stops that bracket this scanline
258
+ let lo = stops[0];
259
+ let hi = stops[stops.length - 1];
260
+ for (let s = 0; s < stops.length - 1; s++) {
261
+ if (y >= stops[s].line && y < stops[s + 1].line) {
262
+ lo = stops[s];
263
+ hi = stops[s + 1];
264
+ break;
265
+ }
266
+ }
267
+ const range = hi.line - lo.line;
268
+ const t = range > 0 ? Math.min(1, Math.max(0, (y - lo.line) / range)) : 0;
269
+ const loRGB = hexToRGB(lo.color);
270
+ const hiRGB = hexToRGB(hi.color);
271
+ const [r, g, b] = lerpRGB(loRGB, hiRGB, t);
272
+ const fog = lo.fog + (hi.fog - lo.fog) * t;
273
+ table.push({
274
+ r, g, b,
275
+ fogDensity: fog,
276
+ scrollOffset: 0,
277
+ });
278
+ }
279
+ return table;
280
+ }
281
+ export function buildHdmaTable(timeOfDay, _weather, height) {
282
+ const h = height;
283
+ // Scale stop positions proportionally to height
284
+ const s = (line, base) => Math.round((line / base) * h);
285
+ switch (timeOfDay) {
286
+ case 'night':
287
+ return buildGradientTable([
288
+ { line: 0, color: '#050510', fog: 0.0 },
289
+ { line: s(50, 720), color: '#050510', fog: 0.0 },
290
+ { line: s(200, 720), color: '#0a1628', fog: 0.05 },
291
+ { line: s(350, 720), color: '#1a2a4a', fog: 0.1 },
292
+ { line: s(720, 720), color: '#1a3050', fog: 0.15 },
293
+ ], h);
294
+ case 'day':
295
+ return buildGradientTable([
296
+ { line: 0, color: '#1a3a5c', fog: 0.0 },
297
+ { line: s(100, 720), color: '#1a3a5c', fog: 0.0 },
298
+ { line: s(300, 720), color: '#4a7aaa', fog: 0.02 },
299
+ { line: s(400, 720), color: '#87ceeb', fog: 0.05 },
300
+ { line: s(720, 720), color: '#b0d8f0', fog: 0.08 },
301
+ ], h);
302
+ case 'sunset':
303
+ return buildGradientTable([
304
+ { line: 0, color: '#1a0a2e', fog: 0.0 },
305
+ { line: s(80, 720), color: '#1a0a2e', fog: 0.0 },
306
+ { line: s(200, 720), color: '#6b2f5f', fog: 0.05 },
307
+ { line: s(300, 720), color: '#e85d3a', fog: 0.1 },
308
+ { line: s(400, 720), color: '#f4a460', fog: 0.12 },
309
+ { line: s(720, 720), color: '#ffd27f', fog: 0.15 },
310
+ ], h);
311
+ case 'dawn':
312
+ return buildGradientTable([
313
+ { line: 0, color: '#0a1628', fog: 0.0 },
314
+ { line: s(100, 720), color: '#0a1628', fog: 0.0 },
315
+ { line: s(250, 720), color: '#3a2a5c', fog: 0.05 },
316
+ { line: s(350, 720), color: '#d4926b', fog: 0.1 },
317
+ { line: s(720, 720), color: '#ffe0b0', fog: 0.12 },
318
+ ], h);
319
+ default:
320
+ // Fallback to day
321
+ return buildHdmaTable('day', _weather, height);
322
+ }
323
+ }
324
+ export function renderHdmaSky(ctx, hdma, width) {
325
+ if (hdma.length === 0)
326
+ return;
327
+ // Batch runs of identical colors into single rects for performance
328
+ let startLine = 0;
329
+ let prevR = hdma[0].r;
330
+ let prevG = hdma[0].g;
331
+ let prevB = hdma[0].b;
332
+ for (let y = 1; y <= hdma.length; y++) {
333
+ const entry = y < hdma.length ? hdma[y] : null;
334
+ if (!entry || entry.r !== prevR || entry.g !== prevG || entry.b !== prevB) {
335
+ // Flush the accumulated run
336
+ ctx.fillStyle = `rgb(${prevR},${prevG},${prevB})`;
337
+ ctx.fillRect(0, startLine, width, y - startLine);
338
+ if (entry) {
339
+ startLine = y;
340
+ prevR = entry.r;
341
+ prevG = entry.g;
342
+ prevB = entry.b;
343
+ }
344
+ }
345
+ }
346
+ }
347
+ // Parallax layer generator functions per biome
348
+ function drawMountainSilhouettes(ctx, w, h) {
349
+ ctx.fillStyle = '#1a2a3a';
350
+ // 4 mountain peaks at varied positions
351
+ const peaks = [
352
+ { x: w * 0.15, peakY: h * 0.2, baseW: w * 0.25 },
353
+ { x: w * 0.35, peakY: h * 0.1, baseW: w * 0.3 },
354
+ { x: w * 0.65, peakY: h * 0.15, baseW: w * 0.28 },
355
+ { x: w * 0.85, peakY: h * 0.25, baseW: w * 0.22 },
356
+ ];
357
+ for (const p of peaks) {
358
+ ctx.beginPath();
359
+ ctx.moveTo(p.x - p.baseW / 2, h);
360
+ ctx.lineTo(p.x, p.peakY);
361
+ ctx.lineTo(p.x + p.baseW / 2, h);
362
+ ctx.closePath();
363
+ ctx.fill();
364
+ }
365
+ }
366
+ function drawRollingHills(ctx, w, h) {
367
+ ctx.fillStyle = '#153a15';
368
+ // Sine-wave hills
369
+ ctx.beginPath();
370
+ ctx.moveTo(0, h);
371
+ for (let x = 0; x <= w; x += 2) {
372
+ const y = h * 0.4 + Math.sin(x * 0.008) * h * 0.15 + Math.sin(x * 0.003) * h * 0.1;
373
+ ctx.lineTo(x, y);
374
+ }
375
+ ctx.lineTo(w, h);
376
+ ctx.closePath();
377
+ ctx.fill();
378
+ // Small triangle trees on hilltops
379
+ ctx.fillStyle = '#0a2a0a';
380
+ for (let x = 30; x < w; x += 60 + Math.floor(pseudoRandom(x) * 40)) {
381
+ const hillY = h * 0.4 + Math.sin(x * 0.008) * h * 0.15 + Math.sin(x * 0.003) * h * 0.1;
382
+ const treeH = 12 + pseudoRandom(x + 1) * 10;
383
+ ctx.beginPath();
384
+ ctx.moveTo(x - 5, hillY);
385
+ ctx.lineTo(x, hillY - treeH);
386
+ ctx.lineTo(x + 5, hillY);
387
+ ctx.closePath();
388
+ ctx.fill();
389
+ }
390
+ }
391
+ function drawNearHills(ctx, w, h) {
392
+ ctx.fillStyle = '#1a4d1a';
393
+ // Taller hills
394
+ ctx.beginPath();
395
+ ctx.moveTo(0, h);
396
+ for (let x = 0; x <= w; x += 2) {
397
+ const y = h * 0.3 + Math.sin(x * 0.005 + 1.5) * h * 0.2 + Math.sin(x * 0.012) * h * 0.05;
398
+ ctx.lineTo(x, y);
399
+ }
400
+ ctx.lineTo(w, h);
401
+ ctx.closePath();
402
+ ctx.fill();
403
+ // Bigger trees and bushes
404
+ ctx.fillStyle = '#0f3f0f';
405
+ for (let x = 20; x < w; x += 40 + Math.floor(pseudoRandom(x + 100) * 30)) {
406
+ const hillY = h * 0.3 + Math.sin(x * 0.005 + 1.5) * h * 0.2 + Math.sin(x * 0.012) * h * 0.05;
407
+ const treeH = 18 + pseudoRandom(x + 101) * 14;
408
+ // Tree trunk
409
+ ctx.fillStyle = '#2a1a0a';
410
+ ctx.fillRect(x - 1, hillY - treeH * 0.4, 3, treeH * 0.4);
411
+ // Canopy
412
+ ctx.fillStyle = '#0f3f0f';
413
+ ctx.beginPath();
414
+ ctx.moveTo(x - 8, hillY - treeH * 0.3);
415
+ ctx.lineTo(x, hillY - treeH);
416
+ ctx.lineTo(x + 8, hillY - treeH * 0.3);
417
+ ctx.closePath();
418
+ ctx.fill();
419
+ // Bush beside tree
420
+ if (pseudoRandom(x + 200) > 0.5) {
421
+ ctx.beginPath();
422
+ ctx.arc(x + 12, hillY - 3, 5, 0, Math.PI * 2);
423
+ ctx.fill();
424
+ }
425
+ }
426
+ }
427
+ function drawForegroundGrass(ctx, w, h) {
428
+ // Small vertical green lines at random positions
429
+ ctx.strokeStyle = '#2a6a2a';
430
+ ctx.lineWidth = 1;
431
+ for (let x = 0; x < w; x += 3 + Math.floor(pseudoRandom(x + 300) * 4)) {
432
+ const baseY = h * 0.7 + pseudoRandom(x + 301) * h * 0.3;
433
+ const bladeH = 4 + pseudoRandom(x + 302) * 8;
434
+ ctx.beginPath();
435
+ ctx.moveTo(x, baseY);
436
+ ctx.lineTo(x + (pseudoRandom(x + 303) - 0.5) * 3, baseY - bladeH);
437
+ ctx.stroke();
438
+ }
439
+ // Fog wisps: semi-transparent horizontal streaks
440
+ for (let i = 0; i < 5; i++) {
441
+ const y = h * 0.3 + pseudoRandom(i + 400) * h * 0.5;
442
+ const xStart = pseudoRandom(i + 401) * w * 0.5;
443
+ const wispW = 80 + pseudoRandom(i + 402) * 120;
444
+ ctx.fillStyle = `rgba(200, 210, 220, 0.08)`;
445
+ ctx.fillRect(xStart, y, wispW, 2);
446
+ }
447
+ }
448
+ // Volcanic biome layers
449
+ function drawVolcanicMountains(ctx, w, h) {
450
+ ctx.fillStyle = '#1a0a05';
451
+ const peaks = [
452
+ { x: w * 0.2, peakY: h * 0.15, baseW: w * 0.3 },
453
+ { x: w * 0.5, peakY: h * 0.05, baseW: w * 0.35 },
454
+ { x: w * 0.8, peakY: h * 0.2, baseW: w * 0.25 },
455
+ ];
456
+ for (const p of peaks) {
457
+ ctx.beginPath();
458
+ ctx.moveTo(p.x - p.baseW / 2, h);
459
+ ctx.lineTo(p.x, p.peakY);
460
+ ctx.lineTo(p.x + p.baseW / 2, h);
461
+ ctx.closePath();
462
+ ctx.fill();
463
+ }
464
+ // Glow at crater
465
+ ctx.fillStyle = '#3a1000';
466
+ ctx.beginPath();
467
+ ctx.arc(w * 0.5, h * 0.05 + 8, 15, 0, Math.PI * 2);
468
+ ctx.fill();
469
+ }
470
+ function drawVolcanicRocks(ctx, w, h) {
471
+ ctx.fillStyle = '#2a1a10';
472
+ ctx.beginPath();
473
+ ctx.moveTo(0, h);
474
+ for (let x = 0; x <= w; x += 2) {
475
+ const y = h * 0.5 + Math.sin(x * 0.006) * h * 0.1 + Math.abs(Math.sin(x * 0.015)) * h * 0.08;
476
+ ctx.lineTo(x, y);
477
+ }
478
+ ctx.lineTo(w, h);
479
+ ctx.closePath();
480
+ ctx.fill();
481
+ }
482
+ // Desert biome layers
483
+ function drawDesertDunes(ctx, w, h) {
484
+ ctx.fillStyle = '#c0a060';
485
+ ctx.beginPath();
486
+ ctx.moveTo(0, h);
487
+ for (let x = 0; x <= w; x += 2) {
488
+ const y = h * 0.5 + Math.sin(x * 0.004) * h * 0.12 + Math.sin(x * 0.009 + 2) * h * 0.06;
489
+ ctx.lineTo(x, y);
490
+ }
491
+ ctx.lineTo(w, h);
492
+ ctx.closePath();
493
+ ctx.fill();
494
+ }
495
+ function drawDesertFarDunes(ctx, w, h) {
496
+ ctx.fillStyle = '#a08858';
497
+ ctx.beginPath();
498
+ ctx.moveTo(0, h);
499
+ for (let x = 0; x <= w; x += 2) {
500
+ const y = h * 0.4 + Math.sin(x * 0.003 + 1) * h * 0.15;
501
+ ctx.lineTo(x, y);
502
+ }
503
+ ctx.lineTo(w, h);
504
+ ctx.closePath();
505
+ ctx.fill();
506
+ }
507
+ // Tundra biome layers
508
+ function drawTundraMountains(ctx, w, h) {
509
+ ctx.fillStyle = '#a0b0c0';
510
+ const peaks = [
511
+ { x: w * 0.15, peakY: h * 0.2, baseW: w * 0.3 },
512
+ { x: w * 0.45, peakY: h * 0.08, baseW: w * 0.35 },
513
+ { x: w * 0.75, peakY: h * 0.18, baseW: w * 0.28 },
514
+ ];
515
+ for (const p of peaks) {
516
+ ctx.beginPath();
517
+ ctx.moveTo(p.x - p.baseW / 2, h);
518
+ ctx.lineTo(p.x, p.peakY);
519
+ ctx.lineTo(p.x + p.baseW / 2, h);
520
+ ctx.closePath();
521
+ ctx.fill();
522
+ // Snow caps
523
+ ctx.fillStyle = '#e0e8f0';
524
+ ctx.beginPath();
525
+ ctx.moveTo(p.x - p.baseW * 0.12, p.peakY + h * 0.08);
526
+ ctx.lineTo(p.x, p.peakY);
527
+ ctx.lineTo(p.x + p.baseW * 0.12, p.peakY + h * 0.08);
528
+ ctx.closePath();
529
+ ctx.fill();
530
+ ctx.fillStyle = '#a0b0c0';
531
+ }
532
+ }
533
+ function drawTundraSnowfield(ctx, w, h) {
534
+ ctx.fillStyle = '#d0d8e0';
535
+ ctx.beginPath();
536
+ ctx.moveTo(0, h);
537
+ for (let x = 0; x <= w; x += 2) {
538
+ const y = h * 0.5 + Math.sin(x * 0.005) * h * 0.05;
539
+ ctx.lineTo(x, y);
540
+ }
541
+ ctx.lineTo(w, h);
542
+ ctx.closePath();
543
+ ctx.fill();
544
+ }
545
+ // Forest biome layers
546
+ function drawForestFarTrees(ctx, w, h) {
547
+ ctx.fillStyle = '#0a1a08';
548
+ // Dense canopy silhouette
549
+ ctx.beginPath();
550
+ ctx.moveTo(0, h);
551
+ for (let x = 0; x <= w; x += 2) {
552
+ const y = h * 0.3 + Math.sin(x * 0.02) * h * 0.05 + Math.abs(Math.sin(x * 0.07)) * h * 0.08;
553
+ ctx.lineTo(x, y);
554
+ }
555
+ ctx.lineTo(w, h);
556
+ ctx.closePath();
557
+ ctx.fill();
558
+ }
559
+ function drawForestMidTrees(ctx, w, h) {
560
+ ctx.fillStyle = '#0f2a0a';
561
+ ctx.beginPath();
562
+ ctx.moveTo(0, h);
563
+ for (let x = 0; x <= w; x += 2) {
564
+ const y = h * 0.35 + Math.sin(x * 0.015 + 1) * h * 0.06 + Math.abs(Math.sin(x * 0.05 + 0.5)) * h * 0.1;
565
+ ctx.lineTo(x, y);
566
+ }
567
+ ctx.lineTo(w, h);
568
+ ctx.closePath();
569
+ ctx.fill();
570
+ // Individual tree tops
571
+ ctx.fillStyle = '#143a10';
572
+ for (let x = 15; x < w; x += 25 + Math.floor(pseudoRandom(x + 500) * 20)) {
573
+ const baseY = h * 0.35 + Math.sin(x * 0.015 + 1) * h * 0.06 + Math.abs(Math.sin(x * 0.05 + 0.5)) * h * 0.1;
574
+ const treeH = 20 + pseudoRandom(x + 501) * 15;
575
+ ctx.beginPath();
576
+ ctx.moveTo(x - 7, baseY);
577
+ ctx.lineTo(x, baseY - treeH);
578
+ ctx.lineTo(x + 7, baseY);
579
+ ctx.closePath();
580
+ ctx.fill();
581
+ }
582
+ }
583
+ // Ocean biome layers
584
+ function drawOceanWaves(ctx, w, h) {
585
+ // Far ocean
586
+ ctx.fillStyle = '#0a2a5a';
587
+ ctx.fillRect(0, 0, w, h);
588
+ // Wave bands
589
+ for (let band = 0; band < 8; band++) {
590
+ const y = h * 0.2 + band * h * 0.1;
591
+ ctx.fillStyle = `rgba(60, 120, 200, ${0.1 + band * 0.03})`;
592
+ ctx.beginPath();
593
+ ctx.moveTo(0, y);
594
+ for (let x = 0; x <= w; x += 2) {
595
+ const wy = y + Math.sin(x * 0.01 + band * 1.2) * 4;
596
+ ctx.lineTo(x, wy);
597
+ }
598
+ ctx.lineTo(w, h);
599
+ ctx.lineTo(0, h);
600
+ ctx.closePath();
601
+ ctx.fill();
602
+ }
603
+ }
604
+ /** Simple deterministic pseudo-random based on seed (for reproducible layer generation) */
605
+ function pseudoRandom(seed) {
606
+ const x = Math.sin(seed * 12.9898 + seed * 78.233) * 43758.5453;
607
+ return x - Math.floor(x);
608
+ }
609
+ function createLayerCanvas(width, height, drawFn, createCanvasFn) {
610
+ const { canvas, ctx } = createCanvasFn(width, height);
611
+ drawFn(ctx, width, height);
612
+ return { canvas, ctx };
613
+ }
614
+ /** Factory to create a canvas, works in both browser (OffscreenCanvas) and Node (canvas package) */
615
+ function defaultCanvasFactory(w, h) {
616
+ // Try node-canvas first (for Node.js / server-side rendering tests)
617
+ try {
618
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
619
+ const { createCanvas } = require('canvas');
620
+ const c = createCanvas(w, h);
621
+ return { canvas: c, ctx: c.getContext('2d') };
622
+ }
623
+ catch {
624
+ // No canvas package available — return a dummy
625
+ // In browser this would use OffscreenCanvas
626
+ if (typeof OffscreenCanvas !== 'undefined') {
627
+ const c = new OffscreenCanvas(w, h);
628
+ return { canvas: c, ctx: c.getContext('2d') };
629
+ }
630
+ // Absolute fallback — no rendering possible, return stubs
631
+ return {
632
+ canvas: null,
633
+ ctx: {
634
+ fillStyle: '',
635
+ strokeStyle: '',
636
+ lineWidth: 1,
637
+ fillRect: () => { },
638
+ strokeRect: () => { },
639
+ beginPath: () => { },
640
+ moveTo: () => { },
641
+ lineTo: () => { },
642
+ closePath: () => { },
643
+ fill: () => { },
644
+ stroke: () => { },
645
+ arc: () => { },
646
+ drawImage: () => { },
647
+ save: () => { },
648
+ restore: () => { },
649
+ globalAlpha: 1,
650
+ },
651
+ };
652
+ }
653
+ }
654
+ export function buildBiomeParallax(biome, width, height) {
655
+ const layers = [];
656
+ // We use 2x width for seamless scrolling (wrap-around)
657
+ const layerW = width * 2;
658
+ const layerDefs = [];
659
+ let defs;
660
+ switch (biome) {
661
+ case 'volcanic':
662
+ defs = [
663
+ { scrollFactor: 0.05, yOffset: height * 0.1, opacity: 0.5, heightFraction: 0.35, drawFn: drawVolcanicMountains },
664
+ { scrollFactor: 0.15, yOffset: height * 0.25, opacity: 0.7, heightFraction: 0.35, drawFn: drawVolcanicRocks },
665
+ { scrollFactor: 1.3, yOffset: height * 0.75, opacity: 0.3, heightFraction: 0.25, drawFn: drawForegroundGrass },
666
+ ];
667
+ break;
668
+ case 'desert':
669
+ defs = [
670
+ { scrollFactor: 0.08, yOffset: height * 0.15, opacity: 0.5, heightFraction: 0.35, drawFn: drawDesertFarDunes },
671
+ { scrollFactor: 0.25, yOffset: height * 0.3, opacity: 0.7, heightFraction: 0.35, drawFn: drawDesertDunes },
672
+ { scrollFactor: 1.3, yOffset: height * 0.75, opacity: 0.25, heightFraction: 0.25, drawFn: drawForegroundGrass },
673
+ ];
674
+ break;
675
+ case 'tundra':
676
+ defs = [
677
+ { scrollFactor: 0.05, yOffset: height * 0.05, opacity: 0.5, heightFraction: 0.35, drawFn: drawTundraMountains },
678
+ { scrollFactor: 0.2, yOffset: height * 0.3, opacity: 0.7, heightFraction: 0.3, drawFn: drawTundraSnowfield },
679
+ { scrollFactor: 1.3, yOffset: height * 0.75, opacity: 0.2, heightFraction: 0.25, drawFn: drawForegroundGrass },
680
+ ];
681
+ break;
682
+ case 'forest':
683
+ defs = [
684
+ { scrollFactor: 0.05, yOffset: height * 0.05, opacity: 0.4, heightFraction: 0.35, drawFn: drawMountainSilhouettes },
685
+ { scrollFactor: 0.12, yOffset: height * 0.15, opacity: 0.6, heightFraction: 0.35, drawFn: drawForestFarTrees },
686
+ { scrollFactor: 0.3, yOffset: height * 0.3, opacity: 0.8, heightFraction: 0.35, drawFn: drawForestMidTrees },
687
+ { scrollFactor: 1.3, yOffset: height * 0.75, opacity: 0.3, heightFraction: 0.25, drawFn: drawForegroundGrass },
688
+ ];
689
+ break;
690
+ case 'ocean':
691
+ defs = [
692
+ { scrollFactor: 0.03, yOffset: height * 0.2, opacity: 0.5, heightFraction: 0.8, drawFn: drawOceanWaves },
693
+ ];
694
+ break;
695
+ case 'plains':
696
+ default:
697
+ defs = [
698
+ { scrollFactor: 0.05, yOffset: height * 0.1, opacity: 0.4, heightFraction: 0.3, drawFn: drawMountainSilhouettes },
699
+ { scrollFactor: 0.15, yOffset: height * 0.25, opacity: 0.6, heightFraction: 0.3, drawFn: drawRollingHills },
700
+ { scrollFactor: 0.4, yOffset: height * 0.4, opacity: 0.8, heightFraction: 0.25, drawFn: drawNearHills },
701
+ { scrollFactor: 1.3, yOffset: height * 0.75, opacity: 0.3, heightFraction: 0.15, drawFn: drawForegroundGrass },
702
+ ];
703
+ break;
704
+ }
705
+ for (const def of defs) {
706
+ const layerH = Math.round(height * def.heightFraction);
707
+ const { canvas } = createLayerCanvas(layerW, layerH, def.drawFn, defaultCanvasFactory);
708
+ layers.push({
709
+ canvas: canvas,
710
+ imageData: null,
711
+ scrollFactor: def.scrollFactor,
712
+ yOffset: def.yOffset,
713
+ opacity: def.opacity,
714
+ width: layerW,
715
+ height: layerH,
716
+ renderFn: def.drawFn,
717
+ });
718
+ }
719
+ return { layers, biome };
720
+ }
721
+ export function renderParallax(ctx, parallax, cameraX, _frame) {
722
+ for (const layer of parallax.layers) {
723
+ if (!layer.canvas)
724
+ continue;
725
+ const savedAlpha = ctx.globalAlpha;
726
+ ctx.globalAlpha = layer.opacity;
727
+ // Calculate scroll offset, wrapping within layer width
728
+ const offset = (-cameraX * layer.scrollFactor) % layer.width;
729
+ const drawX = offset < 0 ? offset + layer.width : offset;
730
+ // Draw the layer (with wrap-around for seamless scrolling)
731
+ ctx.drawImage(layer.canvas, drawX - layer.width, layer.yOffset);
732
+ ctx.drawImage(layer.canvas, drawX, layer.yOffset);
733
+ ctx.globalAlpha = savedAlpha;
734
+ }
735
+ }
736
+ export function createFrameBudget(budgetMs = 150) {
737
+ return {
738
+ timings: [],
739
+ budget: budgetMs,
740
+ overBudget: false,
741
+ dropLevel: 0,
742
+ };
743
+ }
744
+ export function startFrameTiming() {
745
+ return typeof performance !== 'undefined' ? performance.now() : Date.now();
746
+ }
747
+ export function endFrameTiming(budget, startTime) {
748
+ const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
749
+ const elapsed = now - startTime;
750
+ budget.timings.push(elapsed);
751
+ if (budget.timings.length > 30) {
752
+ budget.timings.shift();
753
+ }
754
+ budget.dropLevel = shouldDropQuality(budget);
755
+ budget.overBudget = budget.dropLevel > 0;
756
+ }
757
+ export function shouldDropQuality(budget) {
758
+ if (budget.timings.length < 10)
759
+ return 0;
760
+ // Average of last 10 frames
761
+ const recent = budget.timings.slice(-10);
762
+ const avg = recent.reduce((a, b) => a + b, 0) / recent.length;
763
+ if (avg > budget.budget) {
764
+ // Already dropping? Escalate
765
+ if (budget.dropLevel >= 1 && avg > budget.budget * 1.2) {
766
+ return 2; // skip all parallax
767
+ }
768
+ return 1; // skip farthest parallax
769
+ }
770
+ // Recovery: drop back when average falls below 80% of budget
771
+ if (avg < budget.budget * 0.8 && budget.dropLevel > 0) {
772
+ return Math.max(0, budget.dropLevel - 1);
773
+ }
774
+ return budget.dropLevel;
775
+ }
776
+ export function initRomEngine(biome = 'plains', timeOfDay = 'day') {
777
+ const palette = createPalette();
778
+ const cycles = createDefaultCycles();
779
+ const hdmaTable = buildHdmaTable(timeOfDay, 'clear', 720);
780
+ let parallax = null;
781
+ try {
782
+ parallax = buildBiomeParallax(biome, 1280, 720);
783
+ }
784
+ catch {
785
+ // Canvas not available in this environment — parallax will be null
786
+ }
787
+ return {
788
+ palette,
789
+ cycles,
790
+ hdmaTable,
791
+ parallax,
792
+ frameBudget: createFrameBudget(),
793
+ currentBiome: biome,
794
+ currentTimeOfDay: timeOfDay,
795
+ };
796
+ }
797
+ export function tickRomEngine(state, deltaMs) {
798
+ tickPaletteCycles(state.palette, state.cycles, deltaMs);
799
+ }
800
+ export function renderRomBackground(ctx, state, cameraX, frame, width, height) {
801
+ const start = startFrameTiming();
802
+ // 1. Render HDMA sky gradient
803
+ // Scale HDMA table to current height if needed
804
+ let hdma = state.hdmaTable;
805
+ if (hdma.length !== height) {
806
+ hdma = buildHdmaTable(state.currentTimeOfDay, 'clear', height);
807
+ state.hdmaTable = hdma;
808
+ }
809
+ renderHdmaSky(ctx, hdma, width);
810
+ // 2. Render parallax layers (respecting dropLevel)
811
+ if (state.parallax && state.frameBudget.dropLevel < 2) {
812
+ const layers = state.parallax.layers;
813
+ const skipCount = state.frameBudget.dropLevel >= 1 ? 1 : 0;
814
+ // Render layers, skipping the farthest N based on dropLevel
815
+ for (let i = skipCount; i < layers.length; i++) {
816
+ const layer = layers[i];
817
+ if (!layer.canvas)
818
+ continue;
819
+ const savedAlpha = ctx.globalAlpha;
820
+ ctx.globalAlpha = layer.opacity;
821
+ const offset = (-cameraX * layer.scrollFactor) % layer.width;
822
+ const drawX = offset < 0 ? offset + layer.width : offset;
823
+ ctx.drawImage(layer.canvas, drawX - layer.width, layer.yOffset);
824
+ ctx.drawImage(layer.canvas, drawX, layer.yOffset);
825
+ ctx.globalAlpha = savedAlpha;
826
+ }
827
+ }
828
+ // 3. Record frame timing
829
+ endFrameTiming(state.frameBudget, start);
830
+ }
831
+ // ---------------------------------------------------------------------------
832
+ // 8. Phase 3 — Palette-Cycled Element Rendering
833
+ // ---------------------------------------------------------------------------
834
+ //
835
+ // These functions render water, lava, fire, and sky using the indexed palette
836
+ // colors directly. As tickPaletteCycles rotates the palette entries, the
837
+ // visuals animate with ZERO new frames — pure Mark Ferrari technique.
838
+ /**
839
+ * Convert a packed RGBA uint32 to a CSS rgba() string.
840
+ * Used by all palette-cycled renderers to set ctx.fillStyle.
841
+ */
842
+ function paletteToCSS(palette, index) {
843
+ const [r, g, b, a] = unpackRGBA(palette.colors[index]);
844
+ return `rgba(${r},${g},${b},${a / 255})`;
845
+ }
846
+ /**
847
+ * renderWaterSurface — Draws an animated water body using palette cycling colors.
848
+ *
849
+ * Draws horizontal strips where each strip uses a different palette index from
850
+ * the water range (32-47). As tickPaletteCycles rotates indices 32-39 and 40-47,
851
+ * the strips shift color — creating flowing water animation with ZERO new frames.
852
+ *
853
+ * The water surface includes sine-wave distortion for a ripple effect and
854
+ * highlight bands at the top for specular reflection.
855
+ */
856
+ export function renderWaterSurface(ctx, x, y, width, height, palette, frame) {
857
+ const stripCount = 16; // palette indices 32-47
858
+ const stripH = Math.max(1, Math.ceil(height / stripCount));
859
+ for (let i = 0; i < stripCount; i++) {
860
+ const paletteIdx = 32 + i;
861
+ const stripY = y + i * stripH;
862
+ // Skip strips that are off-screen
863
+ if (stripY + stripH < y || stripY > y + height)
864
+ continue;
865
+ // Sine-wave horizontal offset per strip — ripple effect
866
+ const waveOffset = Math.sin((frame * 0.15) + (i * 0.8)) * 3;
867
+ ctx.fillStyle = paletteToCSS(palette, paletteIdx);
868
+ // Clamp strip height to not exceed the water body bounds
869
+ const clampedH = Math.min(stripH, (y + height) - stripY);
870
+ ctx.fillRect(x + waveOffset, stripY, width, clampedH);
871
+ }
872
+ // Specular highlights: thin white-ish lines near the top using the brightest
873
+ // water palette entries (indices 38-39 are the bright end of the surface cycle)
874
+ const savedAlpha = ctx.globalAlpha;
875
+ ctx.globalAlpha = 0.25;
876
+ for (let h = 0; h < 3; h++) {
877
+ const highlightY = y + h * 2 + Math.sin(frame * 0.2 + h) * 1.5;
878
+ // Use surface-cycle bright indices
879
+ ctx.fillStyle = paletteToCSS(palette, 38 + (h % 2));
880
+ ctx.fillRect(x, highlightY, width, 1);
881
+ }
882
+ ctx.globalAlpha = savedAlpha;
883
+ }
884
+ /**
885
+ * renderLavaPool — Draws animated lava using palette cycling + dithering.
886
+ *
887
+ * Uses palette range 64-79 (lava). The dithered checkerboard pattern makes
888
+ * adjacent pixels use offset palette indices, so when the palette cycles in
889
+ * pingpong mode the lava appears to bubble and pulse.
890
+ */
891
+ export function renderLavaPool(ctx, x, y, width, height, palette, frame) {
892
+ // Pixel size for the dither grid — 4px blocks for performance at 1280x720
893
+ const blockSize = 4;
894
+ const cols = Math.ceil(width / blockSize);
895
+ const rows = Math.ceil(height / blockSize);
896
+ for (let row = 0; row < rows; row++) {
897
+ for (let col = 0; col < cols; col++) {
898
+ // Dithered checkerboard: offset palette index by 1 on alternate cells
899
+ const dither = ((row + col) & 1);
900
+ // Map row position to lava palette range (64-79, 16 entries)
901
+ // Add a slow vertical wave so the lava appears to churn
902
+ const waveShift = Math.sin((frame * 0.1) + (row * 0.3) + (col * 0.05)) * 2;
903
+ const baseIdx = Math.floor((row / rows) * 8); // 0-7 within lava range
904
+ const paletteIdx = 64 + ((baseIdx + dither + Math.round(Math.abs(waveShift))) % 16);
905
+ ctx.fillStyle = paletteToCSS(palette, paletteIdx);
906
+ const bx = x + col * blockSize;
907
+ const by = y + row * blockSize;
908
+ const bw = Math.min(blockSize, (x + width) - bx);
909
+ const bh = Math.min(blockSize, (y + height) - by);
910
+ ctx.fillRect(bx, by, bw, bh);
911
+ }
912
+ }
913
+ // Lava glow: bright emission along the top surface
914
+ const savedAlpha = ctx.globalAlpha;
915
+ ctx.globalAlpha = 0.4;
916
+ const glowH = Math.min(6, height);
917
+ for (let g = 0; g < glowH; g++) {
918
+ // Use the hot end of the lava palette (indices 71-73 are the bright orange/yellow)
919
+ const glowIdx = 64 + 7 + (g % 4); // 71-74
920
+ ctx.fillStyle = paletteToCSS(palette, Math.min(glowIdx, 79));
921
+ ctx.globalAlpha = 0.4 - (g * 0.06);
922
+ ctx.fillRect(x, y + g, width, 1);
923
+ }
924
+ ctx.globalAlpha = savedAlpha;
925
+ }
926
+ /**
927
+ * renderCycledSky — Draws animated sky using palette range 112-127 during
928
+ * sunset/dawn for color shifting. During day/night, renders a subtle gradient
929
+ * overlay using the sky palette.
930
+ *
931
+ * The sky palette has 16 entries from deep blue to light horizon. During
932
+ * sunset/dawn the cycling rotates these entries, creating a smooth color shift
933
+ * across the sky height.
934
+ */
935
+ export function renderCycledSky(ctx, width, skyHeight, palette, timeOfDay) {
936
+ // Number of palette entries in the sky range
937
+ const skyEntries = 16; // indices 112-127
938
+ const bandH = Math.max(1, Math.ceil(skyHeight / skyEntries));
939
+ for (let i = 0; i < skyEntries; i++) {
940
+ const paletteIdx = 112 + i;
941
+ const bandY = i * bandH;
942
+ if (bandY >= skyHeight)
943
+ break;
944
+ ctx.fillStyle = paletteToCSS(palette, paletteIdx);
945
+ const clampedH = Math.min(bandH, skyHeight - bandY);
946
+ ctx.fillRect(0, bandY, width, clampedH);
947
+ }
948
+ // For sunset/dawn, add a warm overlay tint that intensifies near the horizon
949
+ if (timeOfDay === 'sunset' || timeOfDay === 'dawn') {
950
+ const savedAlpha = ctx.globalAlpha;
951
+ for (let i = 0; i < skyEntries; i++) {
952
+ const t = i / (skyEntries - 1); // 0 at top, 1 at horizon
953
+ const warmth = t * t * 0.3; // quadratic fade — stronger near horizon
954
+ ctx.globalAlpha = warmth;
955
+ // Use the fire palette for warm tinting (144-159)
956
+ const fireIdx = 144 + Math.floor(t * 6); // pull from the warm end
957
+ ctx.fillStyle = paletteToCSS(palette, fireIdx);
958
+ const bandY = i * bandH;
959
+ const clampedH = Math.min(bandH, skyHeight - bandY);
960
+ ctx.fillRect(0, bandY, width, clampedH);
961
+ }
962
+ ctx.globalAlpha = savedAlpha;
963
+ }
964
+ }
965
+ /**
966
+ * renderFireColumn — Draws animated fire using palette range 144-159.
967
+ * Each column of the fire has a randomized height offset seeded by position,
968
+ * and the palette cycling creates the flickering animation.
969
+ */
970
+ export function renderFireColumn(ctx, x, y, width, height, palette, frame) {
971
+ const colWidth = 3; // 3px wide flame tongues
972
+ const cols = Math.ceil(width / colWidth);
973
+ for (let col = 0; col < cols; col++) {
974
+ const cx = x + col * colWidth;
975
+ // Flame height varies per column using pseudo-random + time
976
+ const seed = col * 7.13 + frame * 0.25;
977
+ const flameHeight = height * (0.5 + 0.5 * Math.abs(Math.sin(seed)));
978
+ const strips = Math.ceil(flameHeight / 3);
979
+ for (let s = 0; s < strips; s++) {
980
+ // Bottom = hot (high palette index), top = cool (low index)
981
+ const t = s / Math.max(strips - 1, 1);
982
+ const paletteIdx = 144 + Math.floor((1 - t) * 12); // 156 at base, 144 at tip
983
+ const sy = y + height - flameHeight + s * 3;
984
+ if (sy < y || sy >= y + height)
985
+ continue;
986
+ ctx.fillStyle = paletteToCSS(palette, Math.min(paletteIdx, 159));
987
+ ctx.fillRect(cx, sy, Math.min(colWidth, (x + width) - cx), 3);
988
+ }
989
+ }
990
+ }
991
+ /**
992
+ * renderPaletteCycledElement — Master dispatcher. Draws a named element type
993
+ * at the given position using the palette-cycled colors.
994
+ *
995
+ * Element types: 'water', 'lava', 'fire', 'sky'
996
+ */
997
+ export function renderPaletteCycledElement(ctx, element, x, y, width, height, palette, frame, timeOfDay = 'day') {
998
+ switch (element) {
999
+ case 'water':
1000
+ renderWaterSurface(ctx, x, y, width, height, palette, frame);
1001
+ break;
1002
+ case 'lava':
1003
+ renderLavaPool(ctx, x, y, width, height, palette, frame);
1004
+ break;
1005
+ case 'fire':
1006
+ renderFireColumn(ctx, x, y, width, height, palette, frame);
1007
+ break;
1008
+ case 'sky':
1009
+ renderCycledSky(ctx, width, height, palette, timeOfDay);
1010
+ break;
1011
+ }
1012
+ }
1013
+ // ---------------------------------------------------------------------------
1014
+ // 9. Tool Registration (kbot integration)
1015
+ // ---------------------------------------------------------------------------
1016
+ import { registerTool } from './index.js';
1017
+ registerTool({
1018
+ name: 'rom_engine_init',
1019
+ description: 'Initialize the ROM-hack rendering engine with a biome and time of day. Returns engine state summary.',
1020
+ parameters: {
1021
+ biome: {
1022
+ type: 'string',
1023
+ description: 'Biome type: plains, forest, volcanic, desert, tundra, ocean',
1024
+ required: false,
1025
+ default: 'plains',
1026
+ },
1027
+ timeOfDay: {
1028
+ type: 'string',
1029
+ description: 'Time of day: night, day, sunset, dawn',
1030
+ required: false,
1031
+ default: 'day',
1032
+ },
1033
+ },
1034
+ tier: 'free',
1035
+ execute: async (args) => {
1036
+ const biome = args.biome || 'plains';
1037
+ const timeOfDay = args.timeOfDay || 'day';
1038
+ const state = initRomEngine(biome, timeOfDay);
1039
+ return JSON.stringify({
1040
+ biome: state.currentBiome,
1041
+ timeOfDay: state.currentTimeOfDay,
1042
+ paletteEntries: 256,
1043
+ activeCycles: state.cycles.length,
1044
+ hdmaLines: state.hdmaTable.length,
1045
+ parallaxLayers: state.parallax?.layers.length ?? 0,
1046
+ frameBudgetMs: state.frameBudget.budget,
1047
+ }, null, 2);
1048
+ },
1049
+ });
1050
+ registerTool({
1051
+ name: 'rom_engine_palette_info',
1052
+ description: 'Get information about the ROM engine palette layout and cycling ranges.',
1053
+ parameters: {},
1054
+ tier: 'free',
1055
+ execute: async () => {
1056
+ const palette = createPalette();
1057
+ const cycles = createDefaultCycles();
1058
+ const layout = {
1059
+ '0': 'transparent',
1060
+ '1-15': 'UI colors',
1061
+ '16-31': 'Grass',
1062
+ '32-47': 'Water (cycling)',
1063
+ '48-63': 'Stone',
1064
+ '64-79': 'Lava (cycling)',
1065
+ '80-95': 'Sand',
1066
+ '96-111': 'Snow/ice',
1067
+ '112-127': 'Sky',
1068
+ '128-143': 'Ore highlights (cycling)',
1069
+ '144-159': 'Fire (cycling)',
1070
+ '160-175': 'Aurora (cycling)',
1071
+ '176-191': 'Wood/leaves',
1072
+ '192-207': 'Brick/glass',
1073
+ '208-223': 'Decorative',
1074
+ '224-239': 'Star twinkle (cycling)',
1075
+ '240-255': 'Reserved',
1076
+ };
1077
+ const cycleInfo = cycles.map(c => ({
1078
+ range: `${c.start}-${c.end}`,
1079
+ speed: `${c.speed}ms`,
1080
+ mode: c.mode,
1081
+ direction: c.direction === 1 ? 'forward' : 'reverse',
1082
+ }));
1083
+ // Sample some colors
1084
+ const sampleColors = {};
1085
+ for (const idx of [0, 1, 16, 32, 48, 64, 80, 96, 112, 128, 144, 160, 176, 192, 208, 224]) {
1086
+ const [r, g, b, a] = unpackRGBA(palette.colors[idx]);
1087
+ sampleColors[`[${idx}]`] = `rgba(${r},${g},${b},${a})`;
1088
+ }
1089
+ return JSON.stringify({ layout, cycles: cycleInfo, sampleColors }, null, 2);
1090
+ },
1091
+ });
1092
+ registerTool({
1093
+ name: 'rom_engine_cycle_render',
1094
+ description: 'Render palette-cycled elements (water, lava, fire, sky) to a test PNG. Generates 6 sequential frames showing the palette cycling in action.',
1095
+ parameters: {
1096
+ outputDir: {
1097
+ type: 'string',
1098
+ description: 'Directory to save PNGs (default: /tmp)',
1099
+ required: false,
1100
+ default: '/tmp',
1101
+ },
1102
+ prefix: {
1103
+ type: 'string',
1104
+ description: 'Filename prefix (default: kbot-cycle)',
1105
+ required: false,
1106
+ default: 'kbot-cycle',
1107
+ },
1108
+ },
1109
+ tier: 'free',
1110
+ execute: async (args) => {
1111
+ const outDir = args.outputDir || '/tmp';
1112
+ const prefix = args.prefix || 'kbot-cycle';
1113
+ return await renderCycleTestFrames(outDir, prefix);
1114
+ },
1115
+ });
1116
+ /**
1117
+ * Render 6 test frames showing palette cycling for water, lava, fire, and sky.
1118
+ * Each frame advances the palette cycles by 100ms, demonstrating the animation.
1119
+ */
1120
+ export async function renderCycleTestFrames(outDir, prefix) {
1121
+ try {
1122
+ const fs = await import('fs');
1123
+ const path = await import('path');
1124
+ // Dynamic import for optional 'canvas' dependency
1125
+ const canvasMod = await import('canvas');
1126
+ const createCanvas = canvasMod.createCanvas;
1127
+ const W = 1280;
1128
+ const H = 720;
1129
+ const palette = createPalette();
1130
+ const cycles = createDefaultCycles();
1131
+ const frameCount = 6;
1132
+ const deltaMs = 100; // ms between frames
1133
+ const files = [];
1134
+ for (let f = 0; f < frameCount; f++) {
1135
+ const canvas = createCanvas(W, H);
1136
+ const ctx = canvas.getContext('2d');
1137
+ // Clear to dark
1138
+ ctx.fillStyle = '#0a0a0a';
1139
+ ctx.fillRect(0, 0, W, H);
1140
+ // Layout: 4 quadrants
1141
+ // Top-left: Sky (sunset with cycling)
1142
+ // Top-right: Water surface
1143
+ // Bottom-left: Lava pool
1144
+ // Bottom-right: Fire columns
1145
+ const halfW = W / 2;
1146
+ const halfH = H / 2;
1147
+ const padding = 10;
1148
+ // Labels
1149
+ ctx.fillStyle = '#FFFFFF';
1150
+ ctx.font = '16px monospace';
1151
+ // --- Top-left: Cycled Sky (sunset) ---
1152
+ ctx.fillText(`SKY (sunset, frame ${f + 1})`, padding + 4, 20);
1153
+ renderCycledSky(ctx, halfW - padding * 2, halfH - padding * 2 - 24, palette, 'sunset');
1154
+ // Translate the sky down a bit for the label
1155
+ ctx.save();
1156
+ ctx.translate(padding, 28);
1157
+ renderCycledSky(ctx, halfW - padding * 2, halfH - padding * 2 - 28, palette, 'sunset');
1158
+ ctx.restore();
1159
+ // --- Top-right: Water ---
1160
+ ctx.fillStyle = '#FFFFFF';
1161
+ ctx.fillText(`WATER (frame ${f + 1})`, halfW + padding + 4, 20);
1162
+ renderWaterSurface(ctx, halfW + padding, 28, halfW - padding * 2, halfH - padding * 2 - 28, palette, f);
1163
+ // --- Bottom-left: Lava ---
1164
+ ctx.fillStyle = '#FFFFFF';
1165
+ ctx.fillText(`LAVA (frame ${f + 1})`, padding + 4, halfH + 20);
1166
+ renderLavaPool(ctx, padding, halfH + 28, halfW - padding * 2, halfH - padding * 2 - 28, palette, f);
1167
+ // --- Bottom-right: Fire ---
1168
+ ctx.fillStyle = '#FFFFFF';
1169
+ ctx.fillText(`FIRE (frame ${f + 1})`, halfW + padding + 4, halfH + 20);
1170
+ renderFireColumn(ctx, halfW + padding, halfH + 28, halfW - padding * 2, halfH - padding * 2 - 28, palette, f);
1171
+ // Divider lines
1172
+ ctx.strokeStyle = '#404040';
1173
+ ctx.lineWidth = 1;
1174
+ ctx.beginPath();
1175
+ ctx.moveTo(halfW, 0);
1176
+ ctx.lineTo(halfW, H);
1177
+ ctx.moveTo(0, halfH);
1178
+ ctx.lineTo(W, halfH);
1179
+ ctx.stroke();
1180
+ // Frame indicator
1181
+ ctx.fillStyle = '#6B5B95';
1182
+ ctx.font = 'bold 14px monospace';
1183
+ ctx.fillText(`PALETTE CYCLE FRAME ${f + 1}/${frameCount} (${(f + 1) * deltaMs}ms elapsed)`, W - 400, H - 10);
1184
+ // Save PNG
1185
+ const filename = `${prefix}-${String(f + 1).padStart(3, '0')}.png`;
1186
+ const filepath = path.join(outDir, filename);
1187
+ const buffer = canvas.toBuffer('image/png');
1188
+ fs.writeFileSync(filepath, buffer);
1189
+ files.push(filepath);
1190
+ // Advance palette cycles for next frame
1191
+ tickPaletteCycles(palette, cycles, deltaMs);
1192
+ }
1193
+ return JSON.stringify({
1194
+ success: true,
1195
+ frames: frameCount,
1196
+ deltaMs,
1197
+ files,
1198
+ message: `Rendered ${frameCount} palette cycle test frames. Water shimmers, lava pulses, fire flickers, sky shifts.`,
1199
+ }, null, 2);
1200
+ }
1201
+ catch (err) {
1202
+ return JSON.stringify({
1203
+ success: false,
1204
+ error: `Canvas rendering failed: ${err instanceof Error ? err.message : String(err)}. Install the "canvas" npm package for PNG output.`,
1205
+ }, null, 2);
1206
+ }
1207
+ }
1208
+ //# sourceMappingURL=rom-engine.js.map