← back to Model Wars
Composite Stable Diffusion sprites into every bout
bdc08621a66b59ec452705db35400d0cb0cbfaf4 · 2026-08-24 09:18:13 -0700 · Steve Abrams
The combatants are now SDXL art, not vector shapes. gen-sprites.mjs generates
5 transparent-PNG sprites (SDXL → 851-labs background-remover, ~$0.09):
mounted knight, archer, swordsman, dragon boss, trebuchet.
battles.js rewritten to composite the sprites over the scene backdrops with a
drawSprite() transform helper (scale/mirror/rotate/alpha/glow):
- Joust: two knights charge and one is unhorsed (rotate + fall + fade).
- Archery: real archers loose arrows at a scoring target.
- Siege: two trebuchets trade flaming volleys; castle-integrity health bars.
- Duel: two swordsmen clash at center with health bars.
- Dragon: the SDXL dragon boss shakes/reddens/fades as both race to slay it.
Champion-colored auras + pennants carry identity on the neutral sprites;
vector fallback if a sprite hasn't loaded. All 5 modes verified, zero errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Files touched
A gen-sprites.mjsA public/assets/sprite-archer.pngA public/assets/sprite-dragon.pngA public/assets/sprite-knight.pngA public/assets/sprite-swordsman.pngA public/assets/sprite-trebuchet.pngM public/battles.js
Diff
commit bdc08621a66b59ec452705db35400d0cb0cbfaf4
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon Aug 24 09:18:13 2026 -0700
Composite Stable Diffusion sprites into every bout
The combatants are now SDXL art, not vector shapes. gen-sprites.mjs generates
5 transparent-PNG sprites (SDXL → 851-labs background-remover, ~$0.09):
mounted knight, archer, swordsman, dragon boss, trebuchet.
battles.js rewritten to composite the sprites over the scene backdrops with a
drawSprite() transform helper (scale/mirror/rotate/alpha/glow):
- Joust: two knights charge and one is unhorsed (rotate + fall + fade).
- Archery: real archers loose arrows at a scoring target.
- Siege: two trebuchets trade flaming volleys; castle-integrity health bars.
- Duel: two swordsmen clash at center with health bars.
- Dragon: the SDXL dragon boss shakes/reddens/fades as both race to slay it.
Champion-colored auras + pennants carry identity on the neutral sprites;
vector fallback if a sprite hasn't loaded. All 5 modes verified, zero errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
gen-sprites.mjs | 65 ++++++++
public/assets/sprite-archer.png | Bin 0 -> 407299 bytes
public/assets/sprite-dragon.png | Bin 0 -> 616998 bytes
public/assets/sprite-knight.png | Bin 0 -> 528260 bytes
public/assets/sprite-swordsman.png | Bin 0 -> 610638 bytes
public/assets/sprite-trebuchet.png | Bin 0 -> 521381 bytes
public/battles.js | 306 +++++++++++++++----------------------
7 files changed, 189 insertions(+), 182 deletions(-)
diff --git a/gen-sprites.mjs b/gen-sprites.mjs
new file mode 100644
index 0000000..4d7132a
--- /dev/null
+++ b/gen-sprites.mjs
@@ -0,0 +1,65 @@
+// gen-sprites.mjs — SDXL combatant sprites → transparent PNGs (bg removed).
+// Usage: REPLICATE_API_TOKEN=... node gen-sprites.mjs [only=name]
+import { writeFile, mkdir } from 'node:fs/promises';
+import { existsSync } from 'node:fs';
+
+const TOKEN = process.env.REPLICATE_API_TOKEN;
+if (!TOKEN) { console.error('REPLICATE_API_TOKEN required'); process.exit(1); }
+const SDXL = '7762fd07cf82c948538e41f63f77d685e02b063e37e496e96eefd46c929f9bdc';
+const RMBG = 'a029dff38972b5fda4ec5d75d7d1cd25aeff621d2cf4946a41055d7db66b80bc'; // 851-labs/background-remover
+const OUT = new URL('./public/assets/', import.meta.url);
+const onlyFilter = (process.argv.find((a) => a.startsWith('only=')) || '').slice(5);
+
+const STYLE = 'AAA fantasy game sprite, highly detailed, dramatic rim lighting, clean flat pure white background, centered, single subject, isolated, no ground shadow';
+const NEG = 'multiple characters, crowd, text, watermark, busy background, scenery, landscape, frame, border, cropped, extra limbs, blurry, lowres';
+
+const JOBS = [
+ { name: 'sprite-knight', w: 832, h: 640, prompt: `a single armored medieval knight riding a barded warhorse and charging with a couched lance, full body strict side profile facing right, gleaming silver steel plate armor, ${STYLE}` },
+ { name: 'sprite-archer', w: 640, h: 768, prompt: `a single medieval archer at full draw aiming a longbow to the right, full body strict side profile facing right, brown leather armor and hood, ${STYLE}` },
+ { name: 'sprite-swordsman', w: 640, h: 768, prompt: `a single armored medieval knight lunging forward with a raised longsword to the right, full body strict side profile facing right, steel plate armor, ${STYLE}` },
+ { name: 'sprite-dragon', w: 832, h: 704, prompt: `a colossal fearsome dragon with wings spread wide and jaws open, front three-quarter view, dark red and black scales, glowing eyes, epic boss creature, ${STYLE}` },
+ { name: 'sprite-trebuchet', w: 768, h: 640, prompt: `a wooden medieval trebuchet siege engine loaded with a boulder, strict side profile facing right, ${STYLE}` },
+];
+
+async function run(version, input) {
+ const start = await fetch('https://api.replicate.com/v1/predictions', {
+ method: 'POST',
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ version, input }),
+ });
+ let pred = await start.json();
+ if (pred.error) throw new Error(pred.error);
+ while (!['succeeded', 'failed', 'canceled'].includes(pred.status)) {
+ await new Promise((r) => setTimeout(r, 1500));
+ pred = await fetch(pred.urls.get, { headers: { Authorization: `Bearer ${TOKEN}` } }).then((x) => x.json());
+ }
+ if (pred.status !== 'succeeded') throw new Error(`${pred.status} ${pred.error || ''}`);
+ return Array.isArray(pred.output) ? pred.output[0] : pred.output;
+}
+
+async function gen(job) {
+ const raw = await run(SDXL, {
+ prompt: job.prompt, negative_prompt: NEG, width: job.w, height: job.h,
+ num_outputs: 1, num_inference_steps: 35, guidance_scale: 7.5,
+ scheduler: 'K_EULER', refine: 'expert_ensemble_refiner', apply_watermark: false,
+ });
+ const cut = await run(RMBG, { image: raw }); // transparent PNG
+ const buf = Buffer.from(await (await fetch(cut)).arrayBuffer());
+ await writeFile(new URL(`${job.name}.png`, OUT), buf);
+ return buf.length;
+}
+
+if (!existsSync(OUT)) await mkdir(OUT, { recursive: true });
+const jobs = onlyFilter ? JOBS.filter((j) => j.name.includes(onlyFilter)) : JOBS;
+const COST_EACH = 0.017; // SDXL + rmbg
+let done = 0;
+console.log(`Generating ${jobs.length} sprites (SDXL + bg-remove) · est $${(jobs.length * COST_EACH).toFixed(2)}`);
+for (const job of jobs) {
+ try {
+ const t = Date.now();
+ const bytes = await gen(job);
+ done++;
+ console.log(`✓ ${job.name}.png ${(bytes / 1024).toFixed(0)}KB ${((Date.now() - t) / 1000).toFixed(1)}s ($${(done * COST_EACH).toFixed(2)} spent)`);
+ } catch (e) { console.error(`✗ ${job.name}: ${e.message}`); }
+}
+console.log(`Done. ${done}/${jobs.length} sprites · ~$${(done * COST_EACH).toFixed(2)}.`);
diff --git a/public/assets/sprite-archer.png b/public/assets/sprite-archer.png
new file mode 100644
index 0000000..7c8fbff
Binary files /dev/null and b/public/assets/sprite-archer.png differ
diff --git a/public/assets/sprite-dragon.png b/public/assets/sprite-dragon.png
new file mode 100644
index 0000000..d44cd13
Binary files /dev/null and b/public/assets/sprite-dragon.png differ
diff --git a/public/assets/sprite-knight.png b/public/assets/sprite-knight.png
new file mode 100644
index 0000000..fc36bd7
Binary files /dev/null and b/public/assets/sprite-knight.png differ
diff --git a/public/assets/sprite-swordsman.png b/public/assets/sprite-swordsman.png
new file mode 100644
index 0000000..f02c959
Binary files /dev/null and b/public/assets/sprite-swordsman.png differ
diff --git a/public/assets/sprite-trebuchet.png b/public/assets/sprite-trebuchet.png
new file mode 100644
index 0000000..7eaa983
Binary files /dev/null and b/public/assets/sprite-trebuchet.png differ
diff --git a/public/battles.js b/public/battles.js
index aa85b7c..aab6535 100644
--- a/public/battles.js
+++ b/public/battles.js
@@ -1,4 +1,4 @@
-// battles.js — cinematic canvas bouts for each tournament mode.
+// 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 () {
@@ -20,41 +20,70 @@ window.Stage = (function () {
const lerp = (a, b, t) => a + (b - a) * t;
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
- // Preloaded SDXL cinematic backdrops, one per mode.
- const sceneImg = {};
- ['joust', 'archery', 'siege', 'duel', 'dragon'].forEach((m) => {
- const im = new Image(); im.src = `/assets/scene-${m}.png`; sceneImg[m] = im;
- });
+ // ── 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) { // draw image covering WxH, centered
+ 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 (im && im.complete && im.naturalWidth) {
+ if (ready(im)) {
coverDraw(im);
- // darken for figure/UI contrast + a bottom vignette the action sits on
- ctx.fillStyle = 'rgba(6,7,12,.42)'; ctx.fillRect(0, 0, W, H);
- const v = ctx.createLinearGradient(0, H - 150, 0, H);
- v.addColorStop(0, 'rgba(5,5,11,0)'); v.addColorStop(1, 'rgba(5,5,11,.75)');
- ctx.fillStyle = v; ctx.fillRect(0, H - 150, W, 150);
+ 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);
- ctx.fillStyle = 'rgba(255,255,255,.03)'; ctx.fillRect(0, H - 60, W, 60);
- ctx.strokeStyle = 'rgba(232,197,106,.12)'; ctx.beginPath();
- ctx.moveTo(0, H - 60); ctx.lineTo(W, H - 60); ctx.stroke();
}
}
- // particles pool (sparks / embers / smoke)
+ // 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++) {
@@ -66,7 +95,7 @@ window.Stage = (function () {
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; }
+ 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();
}
@@ -76,93 +105,22 @@ window.Stage = (function () {
function banner(txt) { document.getElementById('stageBanner').textContent = txt; }
- // ── figure primitives ────────────────────────────────
- function knight(x, y, color, facing, lance, lean) {
- ctx.save(); ctx.translate(x, y); ctx.scale(facing, 1); ctx.rotate(lean || 0);
- // horse
- ctx.fillStyle = '#1a1a22';
- ctx.beginPath(); ctx.ellipse(0, 34, 46, 18, 0, 0, 7); ctx.fill();
- ctx.fillRect(30, 8, 12, 30); ctx.fillRect(-38, 10, 10, 28); ctx.fillRect(-20, 12, 10, 26); ctx.fillRect(14, 12, 10, 26);
- ctx.beginPath(); ctx.moveTo(40, 20); ctx.lineTo(58, 2); ctx.lineTo(60, 16); ctx.lineTo(46, 26); ctx.fill();
- // barding (banner cloth) in champion color
- ctx.fillStyle = color; ctx.globalAlpha = .8; ctx.fillRect(-26, 24, 40, 20); ctx.globalAlpha = 1;
- // rider armour (metallic)
- const mg = ctx.createLinearGradient(-8, -30, 12, 4);
- mg.addColorStop(0, '#eef1f6'); mg.addColorStop(.5, '#aab0bd'); mg.addColorStop(1, '#5a5f6b');
- ctx.fillStyle = mg;
- ctx.beginPath(); ctx.roundRect(-8, -18, 20, 30, 6); ctx.fill(); // torso
- ctx.beginPath(); ctx.arc(2, -26, 9, 0, 7); ctx.fill(); // helm
- ctx.fillStyle = color; ctx.fillRect(-2, -34, 4, 8); // plume
- // lance
- if (lance) {
- ctx.strokeStyle = '#d8b26a'; ctx.lineWidth = 4; ctx.beginPath();
- ctx.moveTo(8, -8); ctx.lineTo(90, -14); ctx.stroke();
- ctx.fillStyle = '#e8e8ee'; ctx.beginPath(); ctx.moveTo(90, -14); ctx.lineTo(102, -16); ctx.lineTo(90, -10); ctx.fill();
- }
- ctx.restore();
- }
-
- function castle(x, y, color, damage) { // damage 0..1
- ctx.save(); ctx.translate(x, y);
- const stone = '#2b2b36';
- const parts = [ // [w,h, threshold] gate/walls/towers/keep collapse as damage rises
- { x: -50, y: -40, w: 100, h: 40, th: 0.0 }, // wall base
- { x: -60, y: -80, w: 24, h: 44, th: 0.75 }, // left tower
- { x: 36, y: -80, w: 24, h: 44, th: 0.75 }, // right tower
- { x: -14, y: -70, w: 28, h: 30, th: 0.25 }, // gate
- { x: -18, y: -110, w: 36, h: 40, th: 0.9 }, // keep
- ];
- for (const p of parts) {
- if (damage >= p.th && p.th > 0) { // rubble
- ctx.fillStyle = '#20202a';
- for (let i = 0; i < 5; i++) ctx.fillRect(p.x + i * (p.w / 5), -2 - Math.random() * 4, p.w / 5 - 2, 4 + Math.random() * 4);
- continue;
- }
- ctx.fillStyle = stone; ctx.fillRect(p.x, p.y, p.w, p.h);
- ctx.fillStyle = 'rgba(0,0,0,.3)';
- for (let gx = p.x; gx < p.x + p.w; gx += 12) ctx.fillRect(gx, p.y, 1, p.h);
- }
- // banner
- ctx.fillStyle = color; ctx.fillRect(-2, -150, 4, 40);
- ctx.beginPath(); ctx.moveTo(2, -150); ctx.lineTo(26, -144); ctx.lineTo(2, -134); ctx.fill();
- ctx.restore();
- }
-
- function dragon(x, y, hp) {
- ctx.save(); ctx.translate(x, y);
- const wing = Math.sin(performance.now() * 0.006) * 0.3;
- ctx.fillStyle = hp > 0 ? '#3a1f4a' : '#241528';
- // wings
- ctx.save(); ctx.rotate(-0.4 + wing); ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(-70, -40); ctx.lineTo(-50, 20); ctx.fill(); ctx.restore();
- ctx.save(); ctx.rotate(0.4 - wing); ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(70, -40); ctx.lineTo(50, 20); ctx.fill(); ctx.restore();
- // body
- const bg = ctx.createLinearGradient(0, -20, 0, 40); bg.addColorStop(0, '#6b3fa0'); bg.addColorStop(1, '#2a1636');
- ctx.fillStyle = bg; ctx.beginPath(); ctx.ellipse(0, 6, 30, 40, 0, 0, 7); ctx.fill();
- // head
- ctx.beginPath(); ctx.ellipse(0, -34, 18, 15, 0, 0, 7); ctx.fill();
- // eyes
- ctx.fillStyle = hp > 0 ? '#ff5a2a' : '#555'; ctx.beginPath(); ctx.arc(-7, -36, 3, 0, 7); ctx.arc(7, -36, 3, 0, 7); ctx.fill();
- ctx.restore();
- }
-
- function healthBars(cfg, aFrac, bFrac) {
- const pad = 24, bw = W * 0.28, bh = 12;
+ 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,.5)'; ctx.fillRect(x, 20, bw, bh);
+ 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,.4)'; ctx.strokeRect(x, 20, bw, 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, 16);
+ ctx.fillText(name, right ? x + bw : x, 15);
}
- bar(pad, aFrac, cfg.aColor, cfg.aName, false);
- bar(W - pad - bw, bFrac, cfg.bColor, cfg.bName, true);
+ bar(pad, aFrac, cfg.aColor, (cfg.aName) + (tagA || ''), false);
+ bar(W - pad - bw, bFrac, cfg.bColor, (cfg.bName) + (tagB || ''), true);
}
- // ── animation runner ─────────────────────────────────
- // A single active run at a time; cancelling one always resolves its promise
- // so awaiters never hang, and a frame error resolves rather than stalls.
+ // ── animation runner (single active; always resolves) ──
let rafId = null, pendingResolve = null;
function run(duration, frame) {
if (rafId) { cancelAnimationFrame(rafId); if (pendingResolve) pendingResolve(); }
@@ -183,51 +141,49 @@ window.Stage = (function () {
const MODES = {
async joust(cfg) {
banner('⚔ THE JOUST — Champions charge!');
- const midY = H - 96;
+ const midY = H - 40, size = 150;
const aWins = cfg.outcome === 1, draw = cfg.outcome === 0.5;
await run(2600, (t) => {
- const meet = 0.62;
- let ax = t < meet ? lerp(70, W / 2 - 60, t / meet) : W / 2 - 60;
- let bx = t < meet ? lerp(W - 70, W / 2 + 60, t / meet) : W / 2 + 60;
- // impact
- let aLean = 0, bLean = 0, ay = midY, by = midY;
+ 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) { aLean = -0.2 * k; bLean = 0.2 * k; }
- else if (aWins) { bLean = 0.9 * k; by = midY - 30 * Math.sin(k * 3) + 60 * k * k; bx += 40 * k; }
- else { aLean = -0.9 * k; ay = midY - 30 * Math.sin(k * 3) + 60 * k * k; ax -= 40 * k; }
- if (Math.abs(t - meet) < 0.03) burst(W / 2, midY - 14, '#ffd27a', 40, 6, 'spark');
+ 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');
}
- knight(ax, ay, cfg.aColor, 1, true, aLean);
- knight(bx, by, cfg.bColor, -1, true, bLean);
+ 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 - 120, cyT = H / 2;
- const aErr = (1 - cfg.aPower), bErr = (1 - cfg.bPower);
- const 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
+ // 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, 70 - i * 14, 0, 7); ctx.fill(); });
- // archers
- ctx.fillStyle = cfg.aColor; ctx.fillRect(50, cyT - 40, 8, 80);
- ctx.fillStyle = cfg.bColor; ctx.fillRect(50, cyT + 50, 8, 60);
- // volley: 3 arrows each, staggered
+ 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 [who, err, col, yoff] of [['a', aErr, cfg.aColor, -1], ['b', bErr, cfg.bColor, 1]]) {
+ 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 * 46 + 4;
- const ty = cyT + yoff * (8 + i * 6) + (Math.sin(i * 3 + (who === 'a' ? 0 : 2)) * spread);
- const x = lerp(60, cxT - 4, ease(lt));
- const y = lerp(cyT + yoff * 20, ty, ease(lt));
- ctx.strokeStyle = col; ctx.lineWidth = 2.5; ctx.beginPath();
- ctx.moveTo(x - 16, y); ctx.lineTo(x, y); ctx.stroke();
+ 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(); }
}
}
@@ -238,88 +194,74 @@ window.Stage = (function () {
async siege(cfg) {
banner('🏰 CASTLE SIEGE — Reasoning topples stone!');
- const stages = cfg.stages || 4;
- const aWins = cfg.outcome === 1, draw = cfg.outcome === 0.5;
- // damage each castle takes ∝ opponent power
- const aDmg = draw ? 0.5 : (aWins ? 0.2 : 0.95);
- const bDmg = draw ? 0.5 : (aWins ? 0.95 : 0.2);
+ 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 step = Math.floor(t * stages);
- const aD = clamp((step / stages) * bDmg * (cfg.bPower + .4), 0, 1);
- const bD = clamp((step / stages) * aDmg * (cfg.aPower + .4), 0, 1);
- castle(120, H - 40, cfg.aColor, aD);
- castle(W - 120, H - 40, cfg.bColor, bD);
- // trebuchet projectiles arcing between, flaming
- const p = (t * 3) % 1;
- const dir = Math.floor(t * 3) % 2 === 0; // a->b then b->a
- const sx = dir ? 160 : W - 160, ex2 = dir ? W - 160 : 160;
- const x = lerp(sx, ex2, p), y = (H - 90) - Math.sin(p * Math.PI) * 150;
- ctx.fillStyle = '#ff7a2a'; ctx.beginPath(); ctx.arc(x, y, 6, 0, 7); ctx.fill();
+ 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.96) burst(ex2, H - 90, '#ffb347', 20, 5, 'spark');
+ if (p > 0.95) burst(ex, H - 120, '#ffb347', 24, 6, 'spark');
});
banner(draw ? '🏰 Both keeps stand scarred!' : `🏰 ${aWins ? cfg.aName : cfg.bName} breaches the keep!`);
},
async duel(cfg) {
banner('⚔ THE DUEL — Steel meets logic!');
- let aHP = 1, bHP = 1;
- const aw = cfg.outcome === 1, dr = cfg.outcome === 0.5;
+ const aw = cfg.outcome === 1, dr = cfg.outcome === 0.5, size = 168;
await run(3000, (t) => {
- const beat = Math.floor(t * 8);
- // each beat, stronger side lands the blow
- aHP = clamp(1 - (dr ? 0.5 * t : (aw ? 0.25 : 0.9) * t), 0, 1);
- bHP = clamp(1 - (dr ? 0.5 * t : (aw ? 0.9 : 0.25) * t), 0, 1);
+ 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;
- const ax = lerp(W / 2 - 90, W / 2 - 30, Math.sin(clash * Math.PI));
- const bx = lerp(W / 2 + 90, W / 2 + 30, Math.sin(clash * Math.PI));
- swordsman(ax, H - 80, cfg.aColor, 1);
- swordsman(bx, H - 80, cfg.bColor, -1);
- if (clash > 0.45 && clash < 0.55) burst(W / 2, H - 96, '#fff2c0', 10, 4, 'spark');
+ 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');
});
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!');
- let dhpA = 1, dhpB = 1; // each champion's progress against the SAME dragon
- const aw = cfg.outcome === 1, dr = cfg.outcome === 0.5;
+ const aw = cfg.outcome === 1, dr = cfg.outcome === 0.5, size = 210;
await run(3200, (t) => {
- dhpA = clamp(1 - (aw || dr ? cfg.aPower : cfg.aPower * 0.7) * t * (aw ? 1.1 : 0.9), 0, 1);
- dhpB = clamp(1 - (!aw || dr ? cfg.bPower : cfg.bPower * 0.7) * t * (aw ? 0.9 : 1.1), 0, 1);
- // shared dragon health = min progress remaining shown center; two race bars
- dragon(W / 2, H / 2 - 6, Math.min(dhpA, dhpB));
- healthBars({ ...cfg, aName: cfg.aName + ' ⚔', bName: cfg.bName + ' ⚔' }, dhpA, dhpB);
- // magic bolts toward dragon
+ 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(70, W / 2 - 34, ease(p)), bx = lerp(W - 70, W / 2 + 34, ease(p));
- ctx.fillStyle = cfg.aColor; ctx.beginPath(); ctx.arc(ax, H / 2 + 30, 4, 0, 7); ctx.fill();
- ctx.fillStyle = cfg.bColor; ctx.beginPath(); ctx.arc(bx, H / 2 + 30, 4, 0, 7); ctx.fill();
- if (p > 0.95) burst(W / 2, H / 2, '#c58bff', 12, 4, 'spark');
+ 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;
+ }
+ if (p > 0.94) burst(W / 2, H / 2, '#c58bff', 14, 5, 'spark');
});
- const winner = dhpA < dhpB ? cfg.aName : cfg.bName;
banner(dr ? '🐉 Both fell the dragon as one!' : `🐉 ${aw ? cfg.aName : cfg.bName} slays the dragon first!`);
},
};
- function swordsman(x, y, color, facing) {
- ctx.save(); ctx.translate(x, y); ctx.scale(facing, 1);
- const mg = ctx.createLinearGradient(-6, -40, 8, 0);
- mg.addColorStop(0, '#eef1f6'); mg.addColorStop(1, '#5a5f6b');
- ctx.fillStyle = mg; ctx.beginPath(); ctx.roundRect(-7, -36, 16, 34, 5); ctx.fill();
- ctx.beginPath(); ctx.arc(1, -44, 8, 0, 7); ctx.fill();
- ctx.fillStyle = color; ctx.fillRect(-3, -52, 4, 8);
- ctx.fillStyle = color; ctx.globalAlpha = .8; ctx.fillRect(-9, -20, 6, 24); ctx.globalAlpha = 1; // shield-side cloak
- ctx.strokeStyle = '#dfe3ea'; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(6, -30); ctx.lineTo(34, -46); ctx.stroke();
- ctx.restore();
- }
-
fit();
- // idle attract — show the joust backdrop once it loads
function idle() { currentScene = 'joust'; bg(); banner('Choose your champions and the battle'); }
idle();
- Object.values(sceneImg).forEach((im) => { im.onload = () => { if (!rafId) idle(); }; });
+ [...Object.values(sceneImg), ...Object.values(sprite)].forEach((im) => { im.onload = () => { if (!rafId) idle(); }; });
return {
fit,
← a979f43 Redo graphics with Stable Diffusion (SDXL) + fix contrarian
·
back to Model Wars
·
5x verification: six-way core 6/6 PASS; add sweep report cb1a8df →