@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.
- package/dist/tools/audio-engine.d.ts +72 -0
- package/dist/tools/audio-engine.js +426 -0
- package/dist/tools/evolution-engine.d.ts +102 -0
- package/dist/tools/evolution-engine.js +746 -0
- package/dist/tools/index.js +7 -0
- package/dist/tools/living-world.d.ts +161 -0
- package/dist/tools/living-world.js +1054 -0
- package/dist/tools/narrative-engine.d.ts +58 -0
- package/dist/tools/narrative-engine.js +681 -0
- package/dist/tools/render-engine.js +5 -5
- package/dist/tools/rom-engine.d.ts +130 -0
- package/dist/tools/rom-engine.js +1208 -0
- package/dist/tools/social-engine.d.ts +100 -0
- package/dist/tools/social-engine.js +540 -0
- package/dist/tools/sprite-engine.js +40 -26
- package/dist/tools/stream-brain.js +4 -4
- package/dist/tools/stream-control.d.ts +2 -0
- package/dist/tools/stream-control.js +789 -0
- package/dist/tools/stream-intelligence.d.ts +6 -0
- package/dist/tools/stream-intelligence.js +239 -49
- package/dist/tools/stream-renderer.js +540 -519
- package/dist/tools/stream-self-eval.d.ts +96 -0
- package/dist/tools/stream-self-eval.js +764 -0
- package/dist/tools/tile-world.d.ts +40 -0
- package/dist/tools/tile-world.js +1070 -0
- package/package.json +1 -1
|
@@ -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
|