wreckwren / engine.js
CyberMax-tools's picture
Deploy wreckwren
afef699 verified
Raw History Blame Contribute Delete
17.5 kB
// Wreckwren engine: draws the stickman, sparks, shards and flames on a canvas, animates the page elements the rules
// hit (Web Animations, so "Restore" or a reload puts every element back exactly as it was) and plays synth sounds.
// Pure presentation: every hit, burn and point comes from core.js. Nothing is sent anywhere. (c) 2026 CyberMax.
/** Skins: the free ones plus the Fun Pass ones. Skins change colours and effects only, never damage or speed. */
export const SKINS = {
ink: { name: 'Ink', body: '#15161c', glow: null, hat: null, pro: false },
chalk: { name: 'Chalk', body: '#f4f1e8', glow: null, hat: null, pro: false },
neon: { name: 'Neon', body: '#39ffb0', glow: '#39ffb0', hat: null, pro: true },
lava: { name: 'Lava', body: '#ff5a1f', glow: '#ffb000', hat: 'horns', pro: true },
royal: { name: 'Royal', body: '#ffd34d', glow: '#fff3b0', hat: 'crown', pro: true },
ghost: { name: 'Ghost', body: '#b9c6ff', glow: '#7f8cff', hat: 'halo', pro: true },
ninja: { name: 'Ninja', body: '#262a36', glow: null, hat: 'band', pro: true },
};
/** Weapon looks: same stats as the free weapon they replace (see core.js WEAPONS). */
export const LOOKS = {
kick: { classic: { name: 'Boots', pro: false, trail: null }, rocket: { name: 'Rocket boots', pro: true, trail: '#ff9d2e' } },
hammer: { classic: { name: 'Mallet', pro: false, head: '#6b4a2b' }, plasma: { name: 'Plasma mallet', pro: true, head: '#8a5cff' } },
fire: { classic: { name: 'Torch', pro: false, flame: ['#ffdf6b', '#ff7a1a', '#d42a0a'] }, blue: { name: 'Blue flame', pro: true, flame: ['#e3fbff', '#59c9ff', '#2f56ff'] } },
};
const TAU = Math.PI * 2;
const lerp = (a, b, t) => a + (b - a) * t;
export function createEngine({ canvas, skin = 'ink', looks = {}, elFor, toClient, reduced = false, onShake }) {
const ctx = canvas.getContext('2d');
let dpr = 1, vw = 0, vh = 0;
const parts = [];
const man = { x: 80, y: 0, vx: 0, vy: 0, face: 1, pose: 'idle', poseT: 0, tx: 0, ty: 0, dash: 0, onGround: false };
let SK = SKINS[skin] || SKINS.ink;
let LK = { kick: 'classic', hammer: 'classic', fire: 'classic', ...looks };
const burning = new Map(); // id -> animation
const touched = new Set();
function resize() {
dpr = Math.min(2, window.devicePixelRatio || 1);
vw = window.innerWidth; vh = window.innerHeight;
canvas.width = Math.round(vw * dpr); canvas.height = Math.round(vh * dpr);
canvas.style.width = vw + 'px'; canvas.style.height = vh + 'px';
if (!man.y) man.y = vh - 40;
}
resize();
window.addEventListener('resize', resize);
// ---------------------------------------------------------------- sound (synthesised, no files)
let ac = null, muted = false;
const audio = () => { try { ac = ac || new (window.AudioContext || window.webkitAudioContext)(); if (ac.state === 'suspended') ac.resume(); } catch { ac = null; } return ac; };
function noise(dur, f0, f1, gain = 0.4, type = 'lowpass') {
const a = audio(); if (!a || muted) return;
const n = Math.floor(a.sampleRate * dur), buf = a.createBuffer(1, n, a.sampleRate), d = buf.getChannelData(0);
for (let i = 0; i < n; i++) d[i] = (Math.random() * 2 - 1) * (1 - i / n) ** 2;
const src = a.createBufferSource(); src.buffer = buf;
const f = a.createBiquadFilter(); f.type = type; f.frequency.setValueAtTime(f0, a.currentTime); f.frequency.exponentialRampToValueAtTime(f1, a.currentTime + dur);
const g = a.createGain(); g.gain.value = gain;
src.connect(f).connect(g).connect(a.destination); src.start();
}
function tone(f0, f1, dur, gain = 0.5, type = 'sine') {
const a = audio(); if (!a || muted) return;
const o = a.createOscillator(), g = a.createGain();
o.type = type; o.frequency.setValueAtTime(f0, a.currentTime); o.frequency.exponentialRampToValueAtTime(f1, a.currentTime + dur);
g.gain.setValueAtTime(gain, a.currentTime); g.gain.exponentialRampToValueAtTime(0.001, a.currentTime + dur);
o.connect(g).connect(a.destination); o.start(); o.stop(a.currentTime + dur);
}
const SFX = {
kick: () => tone(160, 40, 0.18, 0.6),
hammer: () => { tone(90, 30, 0.25, 0.7, 'triangle'); noise(0.3, 3000, 200, 0.35); },
fire: () => noise(0.45, 600, 2400, 0.25, 'bandpass'),
kill: (combo) => tone(300 + combo * 90, 900 + combo * 120, 0.12, 0.18, 'square'),
clear: () => { tone(400, 1200, 0.4, 0.25, 'square'); setTimeout(() => tone(600, 1600, 0.5, 0.2, 'square'), 150); },
};
// ---------------------------------------------------------------- particles
function burst(x, y, n, colors, speed = 6, life = 50, size = 4, g = 0.25, shape = 'spark') {
if (reduced) n = Math.ceil(n / 3);
for (let i = 0; i < n && parts.length < 900; i++) {
const a = Math.random() * TAU, v = speed * (0.3 + Math.random());
parts.push({ x, y, vx: Math.cos(a) * v, vy: Math.sin(a) * v - speed * 0.4, life, max: life, size: size * (0.5 + Math.random()), c: colors[i % colors.length], g, shape, rot: Math.random() * TAU, vr: (Math.random() - 0.5) * 0.4 });
}
}
function shards(rect, color) {
const cols = Math.min(8, Math.max(2, Math.round(rect.width / 40))), rows = Math.min(6, Math.max(1, Math.round(rect.height / 30)));
for (let i = 0; i < cols; i++) for (let j = 0; j < rows && parts.length < 900; j++) {
const w = rect.width / cols, h = rect.height / rows;
parts.push({ x: rect.left + w * (i + 0.5), y: rect.top + h * (j + 0.5), vx: (Math.random() - 0.5) * 8, vy: -Math.random() * 7, life: 70, max: 70, size: Math.min(w, h, 26), c: color, g: 0.45, shape: 'shard', rot: 0, vr: (Math.random() - 0.5) * 0.3 });
}
}
const colorOf = (el) => {
try {
const cs = getComputedStyle(el);
const bg = cs.backgroundColor;
if (bg && !/rgba\(0, 0, 0, 0\)|transparent/.test(bg)) return bg;
return cs.color || '#888';
} catch { return '#888'; }
};
// ---------------------------------------------------------------- page element effects
function rectOf(id) { const el = elFor(id); return el ? el.getBoundingClientRect() : null; }
function animate(el, frames, opts) {
try { touched.add(el); return el.animate(frames, { fill: 'forwards', ...opts }); } catch { return null; }
}
function applyEvent(ev) {
if (ev.e === 'swing') {
const p = toClient(ev.x, ev.y);
swing(ev.w, p.x, p.y);
return;
}
const el = elFor(ev.id); if (!el) return;
const r = el.getBoundingClientRect(), cx = r.left + r.width / 2, cy = r.top + r.height / 2;
if (ev.e === 'hit') {
const k = reduced ? 2 : 6;
animate(el, [{ transform: 'translate(0,0)' }, { transform: `translate(${k}px,${-k / 2}px) rotate(1deg)` }, { transform: `translate(${-k}px,${k / 2}px) rotate(-1deg)` }, { transform: 'translate(0,0)' }], { duration: 180, fill: 'none' });
const cracks = Math.min(3, 4 - Math.min(3, ev.hp));
animate(el, [{ filter: `contrast(${1 + cracks * 0.25}) saturate(${1 - cracks * 0.2})` }], { duration: 120 });
burst(cx, cy, 8, ['#fff', colorOf(el)], 4, 25, 3);
} else if (ev.e === 'ignite') {
if (!burning.has(ev.id)) burning.set(ev.id, el);
} else if (ev.e === 'kill') {
burning.delete(ev.id);
const col = colorOf(el);
if (ev.how === 'kick') {
const dir = cx < man.x ? -1 : 1;
animate(el, [{ transform: 'translate(0,0) rotate(0)', opacity: 1 }, { transform: `translate(${dir * (vw * 0.6)}px, ${vh * 0.9}px) rotate(${dir * 540}deg)`, opacity: 0.0 }], { duration: reduced ? 300 : 900, easing: 'cubic-bezier(.2,-0.6,.7,1)' });
burst(cx, cy, 14, ['#fff', col], 7, 30, 3);
} else if (ev.how === 'hammer') {
animate(el, [{ transform: 'scale(1,1)', opacity: 1 }, { transform: 'scale(1.06,0.2) translateY(60%)', opacity: 0.6, offset: 0.4 }, { transform: 'scale(1.1,0) translateY(80%)', opacity: 0 }], { duration: reduced ? 200 : 420, easing: 'ease-in' });
shards(r, col);
} else {
animate(el, [{ filter: 'sepia(1) brightness(.6)', opacity: 1 }, { filter: 'brightness(0) blur(2px)', opacity: 0 }], { duration: reduced ? 250 : 700 });
burst(cx, cy, 26, (LOOKS.fire[LK.fire] || LOOKS.fire.classic).flame, 3, 60, 5, -0.05, 'ember');
}
if (ev.combo > 1) floater(cx, cy, `+${ev.pts} x${ev.combo}`);
else floater(cx, cy, `+${ev.pts}`);
SFX.kill(ev.combo || 1);
} else if (ev.e === 'clear') {
SFX.clear();
burst(vw / 2, vh / 2, 120, ['#ffd34d', '#ff5a1f', '#39ffb0', '#fff'], 10, 80, 5);
}
}
const floaters = [];
function floater(x, y, text) { floaters.push({ x, y, text, life: 50 }); }
// ---------------------------------------------------------------- the stickman
function swing(w, x, y) {
man.face = x >= man.x ? 1 : -1;
man.tx = x - man.face * (w === 'fire' ? 90 : w === 'hammer' ? 30 : 26);
man.ty = y + (w === 'kick' ? 34 : w === 'hammer' ? 50 : 20);
man.dash = 1; man.pose = w; man.poseT = 0; man.vy = 0; man.onGround = false;
if (onShake && w === 'hammer') onShake();
SFX[w] && SFX[w]();
if (w === 'hammer') burst(x, y, 18, ['#fff', '#ffd34d', (LOOKS.hammer[LK.hammer] || LOOKS.hammer.classic).head], 7, 30, 3);
if (w === 'kick') burst(x, y, 10, ['#fff', (LOOKS.kick[LK.kick] || {}).trail || '#ddd'], 5, 22, 3);
if (w === 'fire') for (let i = 0; i < 3; i++) setTimeout(() => flameJet(x, y), i * 60);
}
function flameJet(x, y) {
const fl = (LOOKS.fire[LK.fire] || LOOKS.fire.classic).flame;
const hx = man.x + man.face * 22, hy = man.y - 44;
for (let i = 0; i < 14 && parts.length < 900; i++) {
const t = Math.random();
parts.push({ x: lerp(hx, x, t * 0.3), y: lerp(hy, y, t * 0.3), vx: (x - hx) / 14 + (Math.random() - 0.5) * 2, vy: (y - hy) / 14 + (Math.random() - 0.5) * 2, life: 22, max: 22, size: 7 + Math.random() * 6, c: fl[i % 3], g: -0.05, shape: 'ember', rot: 0, vr: 0 });
}
}
function drawMan(dt) {
const s = 1;
if (man.dash > 0) {
man.dash = Math.max(0, man.dash - dt * 7);
const k = 1 - man.dash;
man.x = lerp(man.x, man.tx, Math.min(1, k * 0.9 + 0.1)); man.y = lerp(man.y, man.ty, Math.min(1, k * 0.9 + 0.1));
const LKk = LOOKS.kick[LK.kick] || {};
if (LKk.trail) parts.push({ x: man.x, y: man.y - 6, vx: -man.face * 2, vy: 1, life: 18, max: 18, size: 6, c: LKk.trail, g: 0, shape: 'ember', rot: 0, vr: 0 });
} else if (!man.onGround) {
man.vy += 0.9 * dt * 60; man.y += man.vy * dt * 60;
if (man.y >= vh - 6) { man.y = vh - 6; man.vy = 0; man.onGround = true; }
}
man.poseT += dt;
if (man.poseT > 0.35 && man.pose !== 'idle') man.pose = man.onGround ? 'idle' : 'fall';
if (man.pose === 'fall' && man.onGround) man.pose = 'idle';
const t = performance.now() / 1000, f = man.face, x = man.x, y = man.y;
ctx.save();
ctx.lineCap = 'round'; ctx.lineJoin = 'round';
ctx.strokeStyle = SK.body; ctx.fillStyle = SK.body; ctx.lineWidth = 5 * s;
if (SK.glow) { ctx.shadowColor = SK.glow; ctx.shadowBlur = 14; }
else { ctx.shadowColor = SK.body === '#f4f1e8' ? 'rgba(0,0,0,.55)' : 'rgba(255,255,255,.7)'; ctx.shadowBlur = 4; }
const hip = { x, y: y - 34 }, neck = { x: x + f * 2, y: y - 66 }, head = { x: x + f * 4, y: y - 80 };
const breathe = Math.sin(t * 3) * 1.5;
let la, ra, ll, rl; // limb end points
const swingK = Math.min(1, man.poseT / 0.12);
if (man.pose === 'kick') {
ll = { x: x - f * 10, y };
rl = { x: x + f * (14 + 30 * swingK), y: y - 34 - 6 * swingK };
la = { x: x - f * 24, y: y - 64 }; ra = { x: x + f * 10, y: y - 40 };
} else if (man.pose === 'hammer') {
const ang = lerp(-2.4, 0.5, swingK);
ra = { x: neck.x + f * Math.cos(ang) * 30, y: neck.y + Math.sin(ang) * 30 };
la = { x: ra.x - f * 4, y: ra.y + 2 };
ll = { x: x - f * 14, y }; rl = { x: x + f * 16, y };
const head2 = { x: ra.x + f * Math.cos(ang) * 34, y: ra.y + Math.sin(ang) * 34 };
ctx.save(); ctx.lineWidth = 4; ctx.beginPath(); ctx.moveTo(ra.x, ra.y); ctx.lineTo(head2.x, head2.y); ctx.stroke();
ctx.fillStyle = (LOOKS.hammer[LK.hammer] || LOOKS.hammer.classic).head; ctx.translate(head2.x, head2.y); ctx.rotate(ang);
ctx.fillRect(-10, -14, 20, 28); ctx.restore();
} else if (man.pose === 'fire') {
ra = { x: neck.x + f * 30, y: neck.y + 6 }; la = { x: neck.x + f * 24, y: neck.y + 10 };
ll = { x: x - f * 12, y }; rl = { x: x + f * 12, y };
} else if (man.pose === 'fall') {
la = { x: x - 26, y: y - 82 }; ra = { x: x + 26, y: y - 82 };
ll = { x: x - 16, y: y - 4 }; rl = { x: x + 18, y: y - 10 };
} else {
la = { x: x - 16, y: y - 38 + breathe }; ra = { x: x + 16, y: y - 38 + breathe };
ll = { x: x - 12, y }; rl = { x: x + 12, y };
}
const line = (a, b) => { ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke(); };
const limb = (from, to, bend) => { // two-segment limb with a knee/elbow
const mx = (from.x + to.x) / 2 + bend * f, my = (from.y + to.y) / 2 - Math.abs(bend) * 0.3;
ctx.beginPath(); ctx.moveTo(from.x, from.y); ctx.quadraticCurveTo(mx, my, to.x, to.y); ctx.stroke();
};
line(hip, neck);
limb(neck, la, -6); limb(neck, ra, 6); limb(hip, ll, 5); limb(hip, rl, 5);
ctx.beginPath(); ctx.arc(head.x, head.y, 12, 0, TAU); ctx.fill();
// face: an angry little eye and brow
ctx.shadowBlur = 0; ctx.fillStyle = SK.body === '#f4f1e8' || SK.body === '#ffd34d' ? '#15161c' : '#fff';
ctx.beginPath(); ctx.arc(head.x + f * 5, head.y - 1, 2.2, 0, TAU); ctx.fill();
ctx.strokeStyle = ctx.fillStyle; ctx.lineWidth = 2; line({ x: head.x + f * 1, y: head.y - 7 }, { x: head.x + f * 9, y: head.y - 4 });
ctx.strokeStyle = SK.body; ctx.fillStyle = SK.glow || SK.body;
if (SK.hat === 'crown') { ctx.beginPath(); ctx.moveTo(head.x - 10, head.y - 10); ctx.lineTo(head.x - 10, head.y - 24); ctx.lineTo(head.x - 4, head.y - 16); ctx.lineTo(head.x, head.y - 26); ctx.lineTo(head.x + 4, head.y - 16); ctx.lineTo(head.x + 10, head.y - 24); ctx.lineTo(head.x + 10, head.y - 10); ctx.fill(); }
if (SK.hat === 'horns') { ctx.lineWidth = 3; line({ x: head.x - 7, y: head.y - 9 }, { x: head.x - 12, y: head.y - 22 }); line({ x: head.x + 7, y: head.y - 9 }, { x: head.x + 12, y: head.y - 22 }); }
if (SK.hat === 'halo') { ctx.lineWidth = 2.5; ctx.beginPath(); ctx.ellipse(head.x, head.y - 20, 11, 4, 0, 0, TAU); ctx.stroke(); }
if (SK.hat === 'band') { ctx.strokeStyle = '#e8304a'; ctx.lineWidth = 4; line({ x: head.x - 12, y: head.y - 5 }, { x: head.x + 12, y: head.y - 5 }); line({ x: head.x - f * 12, y: head.y - 5 }, { x: head.x - f * 24, y: head.y - 1 + Math.sin(t * 9) * 3 }); }
ctx.restore();
}
// ---------------------------------------------------------------- frame
let last = performance.now();
function frame(now = performance.now()) {
const dt = Math.min(0.05, (now - last) / 1000); last = now;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, vw, vh);
// flames on burning elements
for (const [, el] of burning) {
const r = el.getBoundingClientRect();
if (r.bottom < 0 || r.top > vh) continue;
const fl = (LOOKS.fire[LK.fire] || LOOKS.fire.classic).flame;
for (let i = 0; i < (reduced ? 1 : 3) && parts.length < 900; i++) parts.push({ x: r.left + Math.random() * r.width, y: r.bottom - Math.random() * r.height * 0.3, vx: (Math.random() - 0.5), vy: -1.5 - Math.random() * 2, life: 30, max: 30, size: 6 + Math.random() * 8, c: fl[i % 3], g: -0.03, shape: 'ember', rot: 0, vr: 0 });
}
for (let i = parts.length - 1; i >= 0; i--) {
const p = parts[i];
p.vy += p.g; p.x += p.vx; p.y += p.vy; p.rot += p.vr; p.life--;
if (p.life <= 0 || p.y > vh + 40) { parts.splice(i, 1); continue; }
const a = p.life / p.max;
ctx.globalAlpha = Math.max(0, Math.min(1, a * 1.4));
ctx.fillStyle = p.c;
if (p.shape === 'ember') { ctx.beginPath(); ctx.arc(p.x, p.y, p.size * a, 0, TAU); ctx.fill(); }
else if (p.shape === 'shard') { ctx.save(); ctx.translate(p.x, p.y); ctx.rotate(p.rot); ctx.beginPath(); ctx.moveTo(-p.size / 2, -p.size / 2); ctx.lineTo(p.size / 2, -p.size / 3); ctx.lineTo(p.size / 4, p.size / 2); ctx.closePath(); ctx.fill(); ctx.restore(); }
else ctx.fillRect(p.x - p.size / 2, p.y - p.size / 2, p.size, p.size);
}
ctx.globalAlpha = 1;
for (let i = floaters.length - 1; i >= 0; i--) {
const fl = floaters[i]; fl.life--; fl.y -= 1.2;
if (fl.life <= 0) { floaters.splice(i, 1); continue; }
ctx.globalAlpha = Math.min(1, fl.life / 20);
ctx.font = '800 22px system-ui, sans-serif'; ctx.textAlign = 'center';
ctx.lineWidth = 4; ctx.strokeStyle = '#15161c'; ctx.strokeText(fl.text, fl.x, fl.y);
ctx.fillStyle = '#ffd34d'; ctx.fillText(fl.text, fl.x, fl.y);
}
ctx.globalAlpha = 1;
drawMan(dt);
}
return {
apply(events) { for (const e of events) applyEvent(e); },
frame,
setSkin(k) { SK = SKINS[k] || SKINS.ink; },
setLooks(l) { LK = { ...LK, ...l }; },
mute(v) { muted = v; },
get muted() { return muted; },
unlockAudio: audio,
/** Put every element back exactly as it was: cancel the animations the engine made. */
restore() {
for (const el of touched) { try { el.getAnimations().forEach((a) => a.cancel()); } catch { /* gone */ } }
touched.clear(); burning.clear(); parts.length = 0; floaters.length = 0;
},
destroy() { window.removeEventListener('resize', resize); },
man,
};
}