Spaces:
Running
Running
Download engine.js from CyberMax-tools/wreckwren: direct link, hf CLI and curl.
- Browser
- Download file 17.5 kB
-
https://huggingface.co/spaces/CyberMax-tools/wreckwren/resolve/main/engine.js
- Command line
-
hf download hf://spaces/CyberMax-tools/wreckwren/engine.js
-
curl -L -o engine.js https://huggingface.co/spaces/CyberMax-tools/wreckwren/resolve/main/engine.js
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, | |
| }; | |
| } | |