Initial commit

This commit is contained in:
Allen
2026-04-30 02:14:25 +00:00
commit 7090e6f01d
243 changed files with 115122 additions and 0 deletions
@@ -0,0 +1,745 @@
// backgroundEngine.js — Procedural background rendering engine
// Loaded by HTML5 games via <script> tag; uses canvas, SVG, and CSS
(function() {
'use strict';
// ─── Helpers ─────────────────────────────────────────────
function lerp(a, b, t) { return a + (b - a) * t; }
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function rand(min, max) { return Math.random() * (max - min) + min; }
function randInt(min, max) { return Math.floor(rand(min, max + 1)); }
function pick(arr) { return arr[Math.floor(Math.random() * arr.length)]; }
function hexToRgb(hex) {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return { r, g, b };
}
function rgbToHex(r, g, b) {
return '#' + [r, g, b].map(c => clamp(Math.round(c), 0, 255).toString(16).padStart(2, '0')).join('');
}
function blendColor(hex1, hex2, t) {
const a = hexToRgb(hex1), b = hexToRgb(hex2);
return rgbToHex(lerp(a.r, b.r, t), lerp(a.g, b.g, t), lerp(a.b, b.b, t));
}
function lighten(hex, amount) {
const c = hexToRgb(hex);
return rgbToHex(c.r + (255 - c.r) * amount, c.g + (255 - c.g) * amount, c.b + (255 - c.b) * amount);
}
function darken(hex, amount) {
const c = hexToRgb(hex);
return rgbToHex(c.r * (1 - amount), c.g * (1 - amount), c.b * (1 - amount));
}
function rgba(hex, alpha) {
const c = hexToRgb(hex);
return `rgba(${c.r},${c.g},${c.b},${alpha})`;
}
// Seeded pseudo-random for deterministic backgrounds
function seededRandom(seed) {
let s = seed;
return function() {
s = (s * 16807 + 0) % 2147483647;
return (s - 1) / 2147483646;
};
}
// ─── Drawing Primitives ──────────────────────────────────
const Draw = {
linearGradient(ctx, x1, y1, x2, y2, stops) {
const g = ctx.createLinearGradient(x1, y1, x2, y2);
stops.forEach(([pos, color]) => g.addColorStop(pos, color));
return g;
},
radialGradient(ctx, cx, cy, r, stops) {
const g = ctx.createRadialGradient(cx, cy, 0, cx, cy, r);
stops.forEach(([pos, color]) => g.addColorStop(pos, color));
return g;
},
fillGradient(ctx, w, h, colors, angle) {
angle = angle || 0;
const rad = angle * Math.PI / 180;
const dx = Math.cos(rad) * w;
const dy = Math.sin(rad) * h;
const cx = w / 2, cy = h / 2;
const stops = colors.map((c, i) => [i / (colors.length - 1), c]);
ctx.fillStyle = Draw.linearGradient(ctx, cx - dx / 2, cy - dy / 2, cx + dx / 2, cy + dy / 2, stops);
ctx.fillRect(0, 0, w, h);
},
mountains(ctx, w, h, baseY, peaks, color, seed) {
const rng = seededRandom(seed || 42);
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(0, h);
const step = w / peaks;
for (let i = 0; i <= peaks; i++) {
const x = i * step;
const peakH = baseY - rng() * (h * 0.3) - h * 0.05;
if (i === 0) { ctx.lineTo(0, peakH); }
else {
const cpx = x - step / 2;
const cpy = peakH - rng() * (h * 0.1);
ctx.quadraticCurveTo(cpx, cpy, x, baseY - rng() * (h * 0.15));
}
}
ctx.lineTo(w, h);
ctx.closePath();
ctx.fill();
},
hills(ctx, w, h, baseY, count, color, seed) {
const rng = seededRandom(seed || 99);
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(0, h);
ctx.lineTo(0, baseY);
for (let i = 0; i < count; i++) {
const x1 = (i / count) * w;
const x2 = ((i + 1) / count) * w;
const cpx = (x1 + x2) / 2;
const cpy = baseY - rng() * h * 0.15 - h * 0.03;
ctx.quadraticCurveTo(cpx, cpy, x2, baseY + rng() * h * 0.02);
}
ctx.lineTo(w, h);
ctx.closePath();
ctx.fill();
},
stars(ctx, w, h, count, color, seed, sizeRange) {
const rng = seededRandom(seed || 7);
const minS = (sizeRange && sizeRange[0]) || 0.5;
const maxS = (sizeRange && sizeRange[1]) || 2.5;
for (let i = 0; i < count; i++) {
const x = rng() * w;
const y = rng() * h;
const r = minS + rng() * (maxS - minS);
const alpha = 0.4 + rng() * 0.6;
ctx.fillStyle = rgba(color, alpha);
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fill();
}
},
clouds(ctx, w, h, y, count, color, seed) {
const rng = seededRandom(seed || 33);
for (let i = 0; i < count; i++) {
const cx = rng() * w;
const cy = y + rng() * h * 0.15 - h * 0.075;
const cw = 60 + rng() * 120;
const ch = 20 + rng() * 30;
ctx.fillStyle = rgba(color, 0.15 + rng() * 0.25);
ctx.beginPath();
ctx.ellipse(cx, cy, cw, ch, 0, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.ellipse(cx - cw * 0.3, cy + ch * 0.2, cw * 0.6, ch * 0.8, 0, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.ellipse(cx + cw * 0.3, cy + ch * 0.1, cw * 0.5, ch * 0.7, 0, 0, Math.PI * 2);
ctx.fill();
}
},
trees(ctx, w, h, baseY, count, trunkColor, leafColor, seed) {
const rng = seededRandom(seed || 55);
for (let i = 0; i < count; i++) {
const x = rng() * w;
const treeH = 30 + rng() * 60;
const trunkW = 3 + rng() * 5;
// Trunk
ctx.fillStyle = trunkColor;
ctx.fillRect(x - trunkW / 2, baseY - treeH, trunkW, treeH * 0.5);
// Canopy
ctx.fillStyle = leafColor;
ctx.beginPath();
ctx.arc(x, baseY - treeH * 0.7, treeH * 0.35, 0, Math.PI * 2);
ctx.fill();
}
},
buildings(ctx, w, h, baseY, count, colors, seed) {
const rng = seededRandom(seed || 88);
const step = w / count;
for (let i = 0; i < count; i++) {
const bw = step * (0.5 + rng() * 0.4);
const bh = 40 + rng() * 120;
const x = i * step + (step - bw) / 2;
const y = baseY - bh;
const color = colors[Math.floor(rng() * colors.length)];
ctx.fillStyle = color;
ctx.fillRect(x, y, bw, bh);
// Windows
const winColor = rgba('#FFFF88', 0.3 + rng() * 0.5);
const cols = Math.floor(bw / 12);
const rows = Math.floor(bh / 15);
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
if (rng() > 0.4) {
ctx.fillStyle = winColor;
ctx.fillRect(x + 4 + c * 12, y + 5 + r * 15, 6, 8);
}
}
}
}
},
water(ctx, w, h, y, color, waveAmp, seed) {
const rng = seededRandom(seed || 44);
ctx.fillStyle = color;
ctx.fillRect(0, y, w, h - y);
// Wave highlights
for (let i = 0; i < 8; i++) {
const wy = y + i * ((h - y) / 8);
ctx.strokeStyle = rgba('#FFFFFF', 0.05 + rng() * 0.08);
ctx.lineWidth = 1;
ctx.beginPath();
for (let x = 0; x < w; x += 4) {
const yy = wy + Math.sin(x * 0.02 + i) * waveAmp;
x === 0 ? ctx.moveTo(x, yy) : ctx.lineTo(x, yy);
}
ctx.stroke();
}
},
grid(ctx, w, h, spacing, color, lineWidth) {
ctx.strokeStyle = color;
ctx.lineWidth = lineWidth || 1;
for (let x = 0; x <= w; x += spacing) {
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke();
}
for (let y = 0; y <= h; y += spacing) {
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke();
}
},
circle(ctx, x, y, r, color) {
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fill();
},
rect(ctx, x, y, w, h, color) {
ctx.fillStyle = color;
ctx.fillRect(x, y, w, h);
},
polygon(ctx, cx, cy, r, sides, color, rotation) {
ctx.fillStyle = color;
ctx.beginPath();
for (let i = 0; i < sides; i++) {
const angle = (i / sides) * Math.PI * 2 + (rotation || 0);
const x = cx + Math.cos(angle) * r;
const y = cy + Math.sin(angle) * r;
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
}
ctx.closePath();
ctx.fill();
},
glow(ctx, x, y, r, color) {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(color, 0.6));
g.addColorStop(0.5, rgba(color, 0.2));
g.addColorStop(1, rgba(color, 0));
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fill();
},
scanlines(ctx, w, h, spacing, color) {
ctx.fillStyle = color;
for (let y = 0; y < h; y += spacing) {
ctx.fillRect(0, y, w, 1);
}
},
};
// ─── Effect System ───────────────────────────────────────
class EffectLayer {
constructor(type, config) {
this.type = type;
this.config = config;
this.particles = [];
this._init();
}
_init() {
const c = this.config;
const count = c.count || 50;
for (let i = 0; i < count; i++) {
this.particles.push(this._createParticle(true));
}
}
_createParticle(initial) {
const c = this.config;
const w = c.width || 800;
const h = c.height || 600;
switch (this.type) {
case 'stars': return {
x: Math.random() * w, y: Math.random() * h,
size: 0.5 + Math.random() * 2, alpha: Math.random(),
twinkleSpeed: 0.5 + Math.random() * 2, phase: Math.random() * Math.PI * 2,
};
case 'rain': return {
x: Math.random() * w, y: initial ? Math.random() * h : -10,
speed: 4 + Math.random() * 8, length: 8 + Math.random() * 15,
alpha: 0.2 + Math.random() * 0.4,
};
case 'snow': return {
x: Math.random() * w, y: initial ? Math.random() * h : -5,
speed: 0.5 + Math.random() * 2, size: 1 + Math.random() * 3,
wobble: Math.random() * Math.PI * 2, wobbleSpeed: 0.5 + Math.random(),
alpha: 0.4 + Math.random() * 0.5,
};
case 'particles': return {
x: Math.random() * w, y: Math.random() * h,
vx: (Math.random() - 0.5) * 1, vy: (Math.random() - 0.5) * 1,
size: 1 + Math.random() * 3, alpha: 0.2 + Math.random() * 0.5,
life: Math.random(),
};
case 'fog': return {
x: Math.random() * w * 1.5 - w * 0.25, y: Math.random() * h,
size: 80 + Math.random() * 160, alpha: 0.03 + Math.random() * 0.08,
speed: 0.2 + Math.random() * 0.5,
};
case 'bubbles': return {
x: Math.random() * w, y: initial ? Math.random() * h : h + 10,
speed: 0.3 + Math.random() * 1.5, size: 2 + Math.random() * 8,
wobble: Math.random() * Math.PI * 2, alpha: 0.2 + Math.random() * 0.4,
};
case 'leaves': return {
x: initial ? Math.random() * w : w + 20, y: Math.random() * h,
speed: 0.5 + Math.random() * 2, size: 4 + Math.random() * 8,
rotation: Math.random() * Math.PI * 2, rotSpeed: (Math.random() - 0.5) * 0.05,
vy: 0.3 + Math.random() * 1, alpha: 0.5 + Math.random() * 0.4,
};
case 'sparkles': return {
x: Math.random() * w, y: Math.random() * h,
size: 1 + Math.random() * 2, alpha: Math.random(),
life: Math.random(), speed: 0.01 + Math.random() * 0.03,
};
case 'fireflies': return {
x: Math.random() * w, y: Math.random() * h,
vx: (Math.random() - 0.5) * 0.5, vy: (Math.random() - 0.5) * 0.5,
size: 2 + Math.random() * 3, phase: Math.random() * Math.PI * 2,
pulseSpeed: 0.5 + Math.random() * 1.5,
};
case 'dust': return {
x: Math.random() * w, y: Math.random() * h,
vx: (Math.random() - 0.5) * 0.3, vy: -0.1 - Math.random() * 0.3,
size: 0.5 + Math.random() * 1.5, alpha: 0.1 + Math.random() * 0.3,
};
default: return { x: 0, y: 0 };
}
}
update(dt, t) {
const c = this.config;
const w = c.width || 800;
const h = c.height || 600;
const speed = c.speed || 1;
this.particles.forEach((p, i) => {
switch (this.type) {
case 'stars':
p.alpha = 0.3 + Math.sin(t * p.twinkleSpeed + p.phase) * 0.35 + 0.35;
break;
case 'rain':
p.y += p.speed * speed * dt * 60;
p.x -= 1 * speed * dt * 60;
if (p.y > h) { this.particles[i] = this._createParticle(false); }
break;
case 'snow':
p.y += p.speed * speed * dt * 60;
p.wobble += p.wobbleSpeed * dt;
p.x += Math.sin(p.wobble) * 0.5;
if (p.y > h + 5) { this.particles[i] = this._createParticle(false); }
break;
case 'particles':
p.x += p.vx * speed * dt * 60;
p.y += p.vy * speed * dt * 60;
p.life += 0.003 * dt * 60;
if (p.life > 1) { this.particles[i] = this._createParticle(false); }
if (p.x < 0 || p.x > w) p.vx *= -1;
if (p.y < 0 || p.y > h) p.vy *= -1;
break;
case 'fog':
p.x += p.speed * speed * dt * 60;
if (p.x > w + p.size) p.x = -p.size;
break;
case 'bubbles':
p.y -= p.speed * speed * dt * 60;
p.wobble += 0.02 * dt * 60;
p.x += Math.sin(p.wobble) * 0.3;
if (p.y < -10) { this.particles[i] = this._createParticle(false); }
break;
case 'leaves':
p.x -= p.speed * speed * dt * 60;
p.y += p.vy * speed * dt * 60;
p.rotation += p.rotSpeed * dt * 60;
if (p.x < -20 || p.y > h + 20) { this.particles[i] = this._createParticle(false); }
break;
case 'sparkles':
p.life += p.speed * dt * 60;
p.alpha = Math.sin(p.life * Math.PI);
if (p.life > 1) { this.particles[i] = this._createParticle(false); }
break;
case 'fireflies':
p.x += p.vx * speed * dt * 60;
p.y += p.vy * speed * dt * 60;
if (Math.random() < 0.01) { p.vx = (Math.random() - 0.5) * 0.5; p.vy = (Math.random() - 0.5) * 0.5; }
p.x = (p.x + w) % w;
p.y = (p.y + h) % h;
break;
case 'dust':
p.x += p.vx * speed * dt * 60;
p.y += p.vy * speed * dt * 60;
if (p.y < -5 || p.x < -5 || p.x > w + 5) { this.particles[i] = this._createParticle(false); }
break;
}
});
}
render(ctx, t) {
const c = this.config;
const color = c.color || '#FFFFFF';
this.particles.forEach(p => {
switch (this.type) {
case 'stars':
ctx.fillStyle = rgba(color, p.alpha);
ctx.beginPath(); ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); ctx.fill();
break;
case 'rain':
ctx.strokeStyle = rgba(color, p.alpha);
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(p.x - 1, p.y + p.length); ctx.stroke();
break;
case 'snow':
ctx.fillStyle = rgba(color, p.alpha);
ctx.beginPath(); ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); ctx.fill();
break;
case 'particles':
ctx.fillStyle = rgba(color, p.alpha * (1 - p.life));
ctx.beginPath(); ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); ctx.fill();
break;
case 'fog':
ctx.fillStyle = rgba(color, p.alpha);
ctx.beginPath(); ctx.ellipse(p.x, p.y, p.size, p.size * 0.4, 0, 0, Math.PI * 2); ctx.fill();
break;
case 'bubbles':
ctx.strokeStyle = rgba(color, p.alpha);
ctx.lineWidth = 1;
ctx.beginPath(); ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); ctx.stroke();
ctx.fillStyle = rgba(color, p.alpha * 0.2);
ctx.fill();
break;
case 'leaves':
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(p.rotation);
ctx.fillStyle = rgba(color, p.alpha);
ctx.beginPath(); ctx.ellipse(0, 0, p.size, p.size * 0.4, 0, 0, Math.PI * 2); ctx.fill();
ctx.restore();
break;
case 'sparkles':
if (p.alpha > 0.05) {
ctx.fillStyle = rgba(color, p.alpha);
const s = p.size * p.alpha;
ctx.fillRect(p.x - s, p.y - 0.5, s * 2, 1);
ctx.fillRect(p.x - 0.5, p.y - s, 1, s * 2);
}
break;
case 'fireflies':
const glow = 0.3 + Math.sin(t * p.pulseSpeed + p.phase) * 0.35 + 0.35;
Draw.glow(ctx, p.x, p.y, p.size * 3, color);
ctx.fillStyle = rgba(color, glow);
ctx.beginPath(); ctx.arc(p.x, p.y, p.size * 0.5, 0, Math.PI * 2); ctx.fill();
break;
case 'dust':
ctx.fillStyle = rgba(color, p.alpha);
ctx.beginPath(); ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); ctx.fill();
break;
}
});
}
}
// ─── BackgroundEngine ────────────────────────────────────
class BackgroundEngine {
constructor() {
this._themes = {};
this._canvas = null;
this._ctx = null;
this._running = false;
this._animId = null;
this._currentTheme = null;
this._currentVariant = null;
this._effects = [];
this._parallax = { enabled: false, layers: 1, offsetX: 0, offsetY: 0 };
this._colors = null;
this._animSpeed = 1;
this._lastTime = 0;
this._startTime = 0;
this._bgCache = null;
}
registerTheme(name, variants) {
this._themes[name] = variants;
}
load(theme, variant, options) {
options = options || {};
const themeData = this._themes[theme];
if (!themeData) { console.warn('BackgroundEngine: unknown theme "' + theme + '"'); return false; }
const varData = themeData[variant];
if (!varData) { console.warn('BackgroundEngine: unknown variant "' + variant + '" in theme "' + theme + '"'); return false; }
// Setup canvas
if (options.canvas) {
this._canvas = options.canvas;
} else if (!this._canvas) {
this._canvas = document.createElement('canvas');
this._canvas.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;z-index:-1;pointer-events:none;';
document.body.insertBefore(this._canvas, document.body.firstChild);
}
this._canvas.width = options.width || this._canvas.clientWidth || window.innerWidth;
this._canvas.height = options.height || this._canvas.clientHeight || window.innerHeight;
this._ctx = this._canvas.getContext('2d');
this._currentTheme = theme;
this._currentVariant = variant;
this._colors = options.colors || varData.colors || null;
this._effects = [];
this._bgCache = null;
this._startTime = performance.now() / 1000;
// Add default effects from theme
if (varData.particles) {
this.addEffect(varData.particles.type, varData.particles);
}
// Render initial static frame
this._renderStatic();
// Start animation loop
if (!this._running) {
this._running = true;
this._lastTime = performance.now() / 1000;
this._loop();
}
return true;
}
addEffect(effectType, config) {
config = config || {};
config.width = this._canvas ? this._canvas.width : 800;
config.height = this._canvas ? this._canvas.height : 600;
config.speed = config.speed || this._animSpeed;
this._effects.push(new EffectLayer(effectType, config));
}
setParallax(layers) {
this._parallax.enabled = true;
this._parallax.layers = layers || 3;
// Listen for mouse/touch movement
const self = this;
const handler = function(e) {
const x = e.clientX || (e.touches && e.touches[0] && e.touches[0].clientX) || 0;
const y = e.clientY || (e.touches && e.touches[0] && e.touches[0].clientY) || 0;
const w = window.innerWidth, h = window.innerHeight;
self._parallax.offsetX = (x / w - 0.5) * 30;
self._parallax.offsetY = (y / h - 0.5) * 15;
};
window.addEventListener('mousemove', handler);
window.addEventListener('touchmove', handler);
this._parallax._cleanup = function() {
window.removeEventListener('mousemove', handler);
window.removeEventListener('touchmove', handler);
};
this._bgCache = null;
}
updateColors(palette) {
this._colors = palette;
this._bgCache = null;
this._renderStatic();
}
animate(speed) {
this._animSpeed = clamp(speed, 0, 5);
this._effects.forEach(e => { e.config.speed = this._animSpeed; });
}
stop() {
this._running = false;
if (this._animId) cancelAnimationFrame(this._animId);
this._effects = [];
if (this._parallax._cleanup) this._parallax._cleanup();
this._parallax = { enabled: false, layers: 1, offsetX: 0, offsetY: 0 };
}
getCatalog() {
const catalog = [];
for (const [themeName, variants] of Object.entries(this._themes)) {
for (const [varName, varData] of Object.entries(variants)) {
if (varData.meta) {
catalog.push({ ...varData.meta, theme: themeName, variant: varName });
}
}
}
return catalog;
}
find(filters) {
filters = filters || {};
return this.getCatalog().filter(item => {
if (filters.theme && item.theme !== filters.theme) return false;
if (filters.mood && item.mood !== filters.mood) return false;
if (filters.tags && !filters.tags.some(t => item.tags && item.tags.includes(t))) return false;
return true;
});
}
getThemes() {
return Object.keys(this._themes);
}
getVariants(theme) {
return this._themes[theme] ? Object.keys(this._themes[theme]) : [];
}
// ─── Internal ────────────────────────────────────
_getVariantData() {
if (!this._currentTheme || !this._currentVariant) return null;
return this._themes[this._currentTheme] && this._themes[this._currentTheme][this._currentVariant];
}
_getColors() {
const varData = this._getVariantData();
return this._colors || (varData && varData.colors) || ['#1a1a2e', '#16213e', '#0f3460'];
}
_renderStatic() {
const varData = this._getVariantData();
if (!varData || !this._ctx) return;
const ctx = this._ctx;
const w = this._canvas.width;
const h = this._canvas.height;
const colors = this._getColors();
if (varData.renderBase) {
varData.renderBase(ctx, w, h, colors, Draw, 0);
}
if (varData.renderMid) {
varData.renderMid(ctx, w, h, colors, Draw, 0);
}
}
_loop() {
if (!this._running) return;
const now = performance.now() / 1000;
const dt = Math.min(now - this._lastTime, 0.1);
const t = now - this._startTime;
this._lastTime = now;
const ctx = this._ctx;
const w = this._canvas.width;
const h = this._canvas.height;
const varData = this._getVariantData();
const colors = this._getColors();
if (varData) {
// Re-render base (may be animated)
ctx.clearRect(0, 0, w, h);
if (this._parallax.enabled) {
const px = this._parallax.offsetX;
const py = this._parallax.offsetY;
// Base layer (no parallax)
if (varData.renderBase) varData.renderBase(ctx, w, h, colors, Draw, t);
// Mid layer (slight parallax)
ctx.save();
ctx.translate(px * 0.5, py * 0.5);
if (varData.renderMid) varData.renderMid(ctx, w, h, colors, Draw, t);
ctx.restore();
// Effects layer (full parallax)
ctx.save();
ctx.translate(px, py);
this._effects.forEach(e => { e.update(dt, t); e.render(ctx, t); });
ctx.restore();
} else {
if (varData.renderBase) varData.renderBase(ctx, w, h, colors, Draw, t);
if (varData.renderMid) varData.renderMid(ctx, w, h, colors, Draw, t);
this._effects.forEach(e => { e.update(dt, t); e.render(ctx, t); });
}
}
this._animId = requestAnimationFrame(() => this._loop());
}
}
// Expose globally
const engine = new BackgroundEngine();
// Expose Draw utilities for theme files
engine.Draw = Draw;
engine.helpers = { lerp, clamp, rand, randInt, pick, hexToRgb, rgbToHex, blendColor, lighten, darken, rgba, seededRandom };
/**
* Static-like render method for one-shot rendering to any canvas
* @param {CanvasRenderingContext2D} ctx - Canvas context to render to
* @param {string} theme - Theme name (e.g., 'space', 'nature')
* @param {string} variant - Variant name (e.g., 'nebula', 'forest')
* @param {number} w - Canvas width
* @param {number} h - Canvas height
* @param {number} time - Animation time in ms (0 for static)
* @param {string[]} [customColors] - Optional custom color palette
*/
engine.render = function(ctx, theme, variant, w, h, time, customColors) {
const varData = this._themes[theme] && this._themes[theme][variant];
if (!varData) {
// Fallback: draw a simple gradient
ctx.fillStyle = '#1a1a2e';
ctx.fillRect(0, 0, w, h);
return;
}
const colors = customColors || varData.colors || ['#1a1a2e', '#16213e', '#0f3460', '#e94560'];
const t = (time || 0) / 1000; // Convert ms to seconds
// Clear and render
ctx.clearRect(0, 0, w, h);
if (varData.renderBase) {
varData.renderBase(ctx, w, h, colors, Draw, t);
}
if (varData.renderMid) {
varData.renderMid(ctx, w, h, colors, Draw, t);
}
};
window.BackgroundEngine = engine;
})();