← back to Model Wars
public/battles.js
284 lines
// battles.js — cinematic canvas bouts using SDXL scene backdrops + sprites.
// Stage.play(mode, cfg) animates the fight and resolves when finished.
// cfg: { aColor, bColor, aName, bName, aPower 0..1, bPower 0..1, outcome 1|0|0.5, stages }
window.Stage = (function () {
'use strict';
const canvas = document.getElementById('stage');
const ctx = canvas.getContext('2d');
let W = 0, H = 0, DPR = 1;
function fit() {
DPR = Math.min(2, window.devicePixelRatio || 1);
const r = canvas.getBoundingClientRect();
W = r.width; H = 340;
canvas.width = W * DPR; canvas.height = H * DPR;
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
}
window.addEventListener('resize', fit);
const ease = (t) => t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
const lerp = (a, b, t) => a + (b - a) * t;
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
// ── image assets (backdrops + transparent sprites) ────
function load(src) { const i = new Image(); i.src = src; return i; }
const sceneImg = {}; ['joust', 'archery', 'siege', 'duel', 'dragon'].forEach((m) => sceneImg[m] = load(`/assets/scene-${m}.png`));
const sprite = {
knight: load('/assets/sprite-knight.png'),
archer: load('/assets/sprite-archer.png'),
swordsman: load('/assets/sprite-swordsman.png'),
dragon: load('/assets/sprite-dragon.png'),
trebuchet: load('/assets/sprite-trebuchet.png'),
};
let currentScene = null;
const ready = (im) => im && im.complete && im.naturalWidth > 0;
function coverDraw(im) {
const ir = im.width / im.height, cr = W / H;
let dw, dh, dx, dy;
if (ir > cr) { dh = H; dw = H * ir; dx = (W - dw) / 2; dy = 0; }
else { dw = W; dh = W / ir; dx = 0; dy = (H - dh) / 2; }
ctx.drawImage(im, dx, dy, dw, dh);
}
function bg() {
const im = currentScene && sceneImg[currentScene];
if (ready(im)) {
coverDraw(im);
ctx.fillStyle = 'rgba(6,7,12,.44)'; ctx.fillRect(0, 0, W, H);
const v = ctx.createLinearGradient(0, H - 160, 0, H);
v.addColorStop(0, 'rgba(5,5,11,0)'); v.addColorStop(1, 'rgba(5,5,11,.8)');
ctx.fillStyle = v; ctx.fillRect(0, H - 160, W, 160);
} else {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, '#0b0c16'); g.addColorStop(1, '#05050b');
ctx.fillStyle = g; ctx.fillRect(0, 0, W, H);
}
}
// Draw a sprite scaled to targetH, anchored at (cx, anchorY) where anchorY is
// the vertical anchor fraction (1 = bottom sits at cy). facing -1 mirrors.
function drawSprite(im, cx, cy, targetH, { facing = 1, rot = 0, alpha = 1, anchor = 1, glow = null } = {}) {
if (!ready(im)) return false;
const scale = targetH / im.naturalHeight;
const w = im.naturalWidth * scale, h = targetH;
ctx.save();
ctx.translate(cx, cy - h * (1 - anchor));
ctx.rotate(rot); ctx.scale(facing, 1); ctx.globalAlpha = clamp(alpha, 0, 1);
if (glow) { ctx.shadowColor = glow; ctx.shadowBlur = 26; }
ctx.drawImage(im, -w / 2, -h, w, h);
ctx.restore();
return true;
}
// Colored aura + banner so each champion's identity reads on a neutral sprite.
function aura(cx, cy, color, r) {
const g = ctx.createRadialGradient(cx, cy, 0, cx, cy, r);
g.addColorStop(0, color + '66'); g.addColorStop(1, color + '00');
ctx.fillStyle = g; ctx.beginPath(); ctx.arc(cx, cy, r, 0, 7); ctx.fill();
}
function pennant(x, y, color, h = 40, facing = 1) {
ctx.save(); ctx.translate(x, y); ctx.scale(facing, 1);
ctx.strokeStyle = 'rgba(0,0,0,.5)'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(0, -h); ctx.stroke();
ctx.fillStyle = color; ctx.beginPath(); ctx.moveTo(0, -h); ctx.lineTo(24, -h + 8); ctx.lineTo(0, -h + 16); ctx.fill();
ctx.restore();
}
// particles
let parts = [];
function burst(x, y, color, n, spread, kind) {
for (let i = 0; i < n; i++) {
const a = Math.random() * Math.PI * 2, s = Math.random() * spread;
parts.push({ x, y, vx: Math.cos(a) * s, vy: Math.sin(a) * s - (kind === 'smoke' ? 1 : 0),
life: 1, color, r: kind === 'smoke' ? 6 + Math.random() * 8 : 1 + Math.random() * 2, kind });
}
}
function drawParts() {
for (const p of parts) {
p.x += p.vx; p.y += p.vy; p.vy += p.kind === 'spark' ? 0.12 : 0.02; p.life -= 0.02;
if (p.kind === 'smoke') p.r += 0.3;
ctx.globalAlpha = clamp(p.life, 0, 1) * (p.kind === 'smoke' ? 0.4 : 1);
ctx.fillStyle = p.color; ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, 7); ctx.fill();
}
ctx.globalAlpha = 1;
parts = parts.filter((p) => p.life > 0);
}
function banner(txt) { document.getElementById('stageBanner').textContent = txt; }
// Fire a named SFX at most once per unique key per bout (frames repeat).
let played = new Set();
function sfx(key, name) {
if (played.has(key)) return; played.add(key);
try { window.SFX && window.SFX[name] && window.SFX[name](); } catch { /* audio best-effort */ }
}
function healthBars(cfg, aFrac, bFrac, tagA, tagB) {
const pad = 24, bw = W * 0.3, bh = 12;
function bar(x, frac, color, name, right) {
ctx.fillStyle = 'rgba(0,0,0,.55)'; ctx.fillRect(x, 20, bw, bh);
ctx.fillStyle = color; const w = bw * clamp(frac, 0, 1);
ctx.fillRect(right ? x + bw - w : x, 20, w, bh);
ctx.strokeStyle = 'rgba(232,197,106,.5)'; ctx.strokeRect(x, 20, bw, bh);
ctx.fillStyle = '#f3ecdc'; ctx.font = '700 12px Cinzel, serif';
ctx.textAlign = right ? 'right' : 'left';
ctx.fillText(name, right ? x + bw : x, 15);
}
bar(pad, aFrac, cfg.aColor, (cfg.aName) + (tagA || ''), false);
bar(W - pad - bw, bFrac, cfg.bColor, (cfg.bName) + (tagB || ''), true);
}
// ── animation runner (single active; always resolves) ──
let rafId = null, pendingResolve = null;
function run(duration, frame) {
if (rafId) { cancelAnimationFrame(rafId); if (pendingResolve) pendingResolve(); }
return new Promise((resolve) => {
pendingResolve = resolve;
const t0 = performance.now();
(function loop(now) {
const t = clamp((now - t0) / duration, 0, 1);
try { bg(); frame(t); drawParts(); }
catch (err) { console.error('battle frame error', err); rafId = null; pendingResolve = null; resolve(); return; }
if (t < 1) rafId = requestAnimationFrame(loop);
else { rafId = null; pendingResolve = null; resolve(); }
})(t0);
});
}
// ── modes ────────────────────────────────────────────
const MODES = {
async joust(cfg) {
banner('⚔ THE JOUST — Champions charge!');
const midY = H - 40, size = 150;
const aWins = cfg.outcome === 1, draw = cfg.outcome === 0.5;
await run(2600, (t) => {
const meet = 0.6;
let ax = t < meet ? lerp(60, W / 2 - 70, t / meet) : W / 2 - 70;
let bx = t < meet ? lerp(W - 60, W / 2 + 70, t / meet) : W / 2 + 70;
let aRot = 0, bRot = 0, aY = midY, bY = midY, aAlpha = 1, bAlpha = 1;
if (t >= meet) {
const k = (t - meet) / (1 - meet);
if (!draw && aWins) { bRot = 0.5 * k; bY = midY + 40 * k * k; bx += 30 * k; bAlpha = 1 - 0.3 * k; }
else if (!draw) { aRot = -0.5 * k; aY = midY + 40 * k * k; ax -= 30 * k; aAlpha = 1 - 0.3 * k; }
if (Math.abs(t - meet) < 0.03) { burst(W / 2, midY - 60, '#ffd27a', 46, 6.5, 'spark'); sfx('clash', 'clash'); }
}
aura(ax, midY - 50, cfg.aColor, 90); aura(bx, midY - 50, cfg.bColor, 90);
drawSprite(sprite.knight, ax, aY, size, { facing: 1, rot: aRot, alpha: aAlpha, glow: cfg.aColor });
drawSprite(sprite.knight, bx, bY, size, { facing: -1, rot: bRot, alpha: bAlpha, glow: cfg.bColor });
pennant(ax - 40, aY - size + 20, cfg.aColor); pennant(bx + 40, bY - size + 20, cfg.bColor, 40, -1);
});
banner(draw ? '⚔ A draw of honour!' : `⚔ ${aWins ? cfg.aName : cfg.bName} unhorses the foe!`);
},
async archery(cfg) {
banner('🏹 ARCHERY — Loose your arrows!');
const cxT = W - 110, cyT = H / 2 - 6, size = 150;
const aErr = 1 - cfg.aPower, bErr = 1 - cfg.bPower;
await run(2600, (t) => {
// target (backdrop has one but draw a crisp scoring target)
const rings = ['#e6e6ea', '#2b2b36', '#6aa8ff', '#e05a5a', '#e8c56a'];
rings.forEach((c, i) => { ctx.fillStyle = c; ctx.beginPath(); ctx.arc(cxT, cyT, 62 - i * 12, 0, 7); ctx.fill(); });
// two archers stacked on the left
aura(70, H - 90, cfg.aColor, 70); aura(70, H - 40, cfg.bColor, 70);
drawSprite(sprite.archer, 74, H - 70, size, { facing: 1, glow: cfg.aColor });
pennant(30, H - 70 - size + 30, cfg.aColor);
for (let i = 0; i < 3; i++) {
const dl = i * 0.12;
for (const [err, col, sy] of [[aErr, cfg.aColor, H - 70 - size / 2], [bErr, cfg.bColor, H - 70 - size / 2]]) {
const lt = clamp((t - dl) / 0.7, 0, 1);
if (lt <= 0) continue;
const spread = err * 44 + 4;
const ty = cyT + (Math.sin(i * 3 + (col === cfg.aColor ? 0 : 2)) * spread);
const x = lerp(96, cxT - 4, ease(lt));
const y = lerp(sy, ty, ease(lt));
ctx.strokeStyle = col; ctx.lineWidth = 2.5; ctx.beginPath(); ctx.moveTo(x - 16, y); ctx.lineTo(x, y); ctx.stroke();
if (lt >= 1) { ctx.fillStyle = col; ctx.beginPath(); ctx.arc(x, y, 3, 0, 7); ctx.fill(); sfx('arrow' + i, 'arrow'); }
}
}
});
const aw = cfg.outcome === 1, dr = cfg.outcome === 0.5;
banner(dr ? '🏹 Even marksmanship!' : `🏹 ${aw ? cfg.aName : cfg.bName} strikes nearer the gold!`);
},
async siege(cfg) {
banner('🏰 CASTLE SIEGE — Reasoning topples stone!');
const aWins = cfg.outcome === 1, draw = cfg.outcome === 0.5, size = 130;
const aInteg0 = draw ? 0.5 : aWins ? 0.85 : 0.1;
const bInteg0 = draw ? 0.5 : aWins ? 0.1 : 0.85;
await run(3200, (t) => {
const aInteg = clamp(1 - (1 - aInteg0) * t, 0, 1);
const bInteg = clamp(1 - (1 - bInteg0) * t, 0, 1);
healthBars(cfg, aInteg, bInteg, ' 🏰', ' 🏰');
drawSprite(sprite.trebuchet, 90, H - 30, size, { facing: 1 });
drawSprite(sprite.trebuchet, W - 90, H - 30, size, { facing: -1 });
pennant(90, H - 30 - size, cfg.aColor); pennant(W - 90, H - 30 - size, cfg.bColor, 40, -1);
// flaming volleys back and forth
const p = (t * 3) % 1, dir = Math.floor(t * 3) % 2 === 0;
const sx = dir ? 150 : W - 150, ex = dir ? W - 150 : 150;
const x = lerp(sx, ex, p), y = (H - 120) - Math.sin(p * Math.PI) * 150;
ctx.fillStyle = '#ff7a2a'; ctx.shadowColor = '#ff7a2a'; ctx.shadowBlur = 16;
ctx.beginPath(); ctx.arc(x, y, 6, 0, 7); ctx.fill(); ctx.shadowBlur = 0;
burst(x, y, '#ff9a3a', 2, 1.5, 'smoke');
if (p > 0.95) { burst(ex, H - 120, '#ffb347', 24, 6, 'spark'); sfx('boom' + Math.floor(t * 3), 'thud'); }
});
banner(draw ? '🏰 Both keeps stand scarred!' : `🏰 ${aWins ? cfg.aName : cfg.bName} breaches the keep!`);
},
async duel(cfg) {
banner('⚔ THE DUEL — Steel meets logic!');
const aw = cfg.outcome === 1, dr = cfg.outcome === 0.5, size = 168;
await run(3000, (t) => {
const aHP = clamp(1 - (dr ? 0.5 * t : aw ? 0.25 * t : 0.9 * t), 0, 1);
const bHP = clamp(1 - (dr ? 0.5 * t : aw ? 0.9 * t : 0.25 * t), 0, 1);
healthBars(cfg, aHP, bHP);
const clash = (t * 8) % 1, lunge = Math.sin(clash * Math.PI);
const ax = lerp(W / 2 - 96, W / 2 - 44, lunge);
const bx = lerp(W / 2 + 96, W / 2 + 44, lunge);
aura(ax, H - 60, cfg.aColor, 70); aura(bx, H - 60, cfg.bColor, 70);
drawSprite(sprite.swordsman, ax, H - 20, size, { facing: 1, glow: cfg.aColor });
drawSprite(sprite.swordsman, bx, H - 20, size, { facing: -1, glow: cfg.bColor });
if (clash > 0.46 && clash < 0.56) { burst(W / 2, H - 110, '#fff2c0', 12, 4.5, 'spark'); sfx('clang' + Math.floor(t * 8), 'clang'); }
});
banner(dr ? '⚔ Blades locked — a draw!' : `⚔ ${aw ? cfg.aName : cfg.bName} lands the final blow!`);
},
async dragon(cfg) {
banner('🐉 DRAGON CHALLENGE — Race to slay the beast!');
const aw = cfg.outcome === 1, dr = cfg.outcome === 0.5, size = 210;
await run(3200, (t) => {
const dhpA = clamp(1 - (aw || dr ? cfg.aPower : cfg.aPower * 0.75) * t * (aw ? 1.1 : 0.9), 0, 1);
const dhpB = clamp(1 - (!aw || dr ? cfg.bPower : cfg.bPower * 0.75) * t * (aw ? 0.9 : 1.1), 0, 1);
const hp = Math.min(dhpA, dhpB);
const hit = (t * 4) % 1;
// dragon shakes + reddens as it takes damage
const shake = hit > 0.9 ? (Math.random() - 0.5) * 6 : 0;
drawSprite(sprite.dragon, W / 2 + shake, H - 24, size * (0.75 + hp * 0.25), { alpha: 0.35 + hp * 0.65, glow: hp < 0.4 ? '#ff3a2a' : null });
healthBars(cfg, dhpA, dhpB, ' ⚔', ' ⚔');
const p = (t * 4) % 1;
const ax = lerp(60, W / 2 - 40, ease(p)), bx = lerp(W - 60, W / 2 + 40, ease(p));
for (const [x, col] of [[ax, cfg.aColor], [bx, cfg.bColor]]) {
ctx.fillStyle = col; ctx.shadowColor = col; ctx.shadowBlur = 14;
ctx.beginPath(); ctx.arc(x, H / 2 + 30, 5, 0, 7); ctx.fill(); ctx.shadowBlur = 0;
}
sfx('dragon-roar', 'roar');
if (p > 0.94) { burst(W / 2, H / 2, '#c58bff', 14, 5, 'spark'); sfx('bolt' + Math.floor(t * 4), 'clang'); }
});
banner(dr ? '🐉 Both fell the dragon as one!' : `🐉 ${aw ? cfg.aName : cfg.bName} slays the dragon first!`);
},
};
fit();
function idle() { currentScene = 'joust'; bg(); banner('Choose your champions and the battle'); }
idle();
[...Object.values(sceneImg), ...Object.values(sprite)].forEach((im) => { im.onload = () => { if (!rafId) idle(); }; });
return {
fit,
async play(mode, cfg) {
fit(); parts = []; played = new Set(); currentScene = sceneImg[mode] ? mode : null;
if (mode === 'joust') sfx('start', 'charge'); // galloping hooves
const fn = MODES[mode] || MODES.joust;
await fn(cfg);
},
};
})();