Quazim0t0's picture
Upload 127 files
7e5faa7 verified
Raw
History Blame Contribute Delete
44.6 kB
// HuggingWizards client: WebSocket sync + canvas rendering + input.
// The browser renders; the server owns the simulation.
"use strict";
const canvas = document.getElementById("game");
const ctx = canvas.getContext("2d");
ctx.imageSmoothingEnabled = false;
let ws = null;
let myPid = null;
let myRole = "spectator";
let snap = null; // latest server snapshot
const input = { up: false, down: false, left: false, right: false, attack: false };
// ---- asset loading -------------------------------------------------------
const FW = 100, FH = 100; // character source frame size
function loadStrip(src) {
const img = new Image();
img.src = src;
return { img, get frames() { return img.complete && img.height ? Math.max(1, Math.round(img.width / img.height)) : 1; } };
}
const SOL = "/static/assets/characters/soldier/";
const ORC = "/static/assets/characters/orc/";
const SPR = {
player: {
idle: loadStrip(SOL + "Soldier-Idle.png"),
walk: loadStrip(SOL + "Soldier-Walk.png"),
attack: loadStrip(SOL + "Soldier-Attack01.png"),
hurt: loadStrip(SOL + "Soldier-Hurt.png"),
death: loadStrip(SOL + "Soldier-Death.png"),
},
minion: {
idle: loadStrip(ORC + "Orc-Idle.png"),
walk: loadStrip(ORC + "Orc-Walk.png"),
attack: loadStrip(ORC + "Orc-Attack01.png"),
death: loadStrip(ORC + "Orc-Death.png"),
},
};
// Enemy units (Tiny Swords) for the per-theme boss/minion skins.
const ENM = "/static/assets/enemies/";
function uimg(src) { const i = new Image(); i.src = src; return i; }
const UNIT = {
warrior: uimg(ENM + "warrior_idle.png"), warriorRun: uimg(ENM + "warrior_run.png"),
lancer: uimg(ENM + "lancer_idle.png"),
archer: uimg(ENM + "archer_idle.png"), archerRun: uimg(ENM + "archer_run.png"),
pawn: uimg(ENM + "pawn_idle.png"), pawnRun: uimg(ENM + "pawn_run.png"),
};
// Tiny Swords buildings — per-theme map landmarks.
const BLD = "/static/assets/buildings/";
const BUILDINGS = {};
for (const n of ["blue_castle", "blue_tower", "blue_archery", "blue_monastery",
"red_castle", "red_tower", "red_barracks",
"black_castle", "black_tower", "black_house1"]) {
BUILDINGS[n] = uimg(BLD + n + ".png");
}
// Theme set, swapped every 5 waves. Theme 0 keeps the original orcs.
// Each theme is also a distinct MAP: ground tint, ring color, landmark
// buildings, and a seeded scenery mix (tree/bush/rock weights).
const THEMES = [
{ name: "Orc Horde", boss: SPR.minion.idle.img, minion: SPR.minion.walk.img,
tint: "rgba(110,140,40,.10)", ring: "rgba(120,90,40,.5)",
deco: { tree: 2, bush: 5, rock: 2 }, seed: 101,
landmarks: [{ b: "black_house1", x: 150, y: 150, s: 0.55 },
{ b: "black_tower", x: 1130, y: 165, s: 0.5 },
{ b: "black_house1", x: 1100, y: 640, s: 0.5 }] },
{ name: "Iron Legion", boss: UNIT.warrior, minion: UNIT.warriorRun,
tint: "rgba(90,120,190,.10)", ring: "rgba(120,150,220,.45)",
deco: { tree: 1, bush: 1, rock: 6 }, seed: 202,
landmarks: [{ b: "blue_castle", x: 640, y: 130, s: 0.62 },
{ b: "blue_tower", x: 170, y: 620, s: 0.5 },
{ b: "blue_tower", x: 1110, y: 620, s: 0.5 }] },
{ name: "Lancer Host", boss: UNIT.lancer, minion: UNIT.pawnRun,
tint: "rgba(200,90,50,.10)", ring: "rgba(220,110,80,.45)",
deco: { tree: 1, bush: 2, rock: 4 }, seed: 303,
landmarks: [{ b: "red_castle", x: 200, y: 150, s: 0.58 },
{ b: "red_barracks", x: 1110, y: 160, s: 0.55 },
{ b: "red_tower", x: 640, y: 680, s: 0.5 }] },
{ name: "Archer Coven", boss: UNIT.archer, minion: UNIT.archerRun,
tint: "rgba(30,100,55,.13)", ring: "rgba(80,180,110,.45)",
deco: { tree: 6, bush: 3, rock: 1 }, seed: 404,
landmarks: [{ b: "blue_archery", x: 1110, y: 165, s: 0.55 },
{ b: "blue_monastery", x: 165, y: 175, s: 0.5 },
{ b: "blue_archery", x: 640, y: 690, s: 0.5 }] },
];
const themeOf = (s) => THEMES[(s.theme || 0) % THEMES.length];
// Special boss sprites + their attack-effect sheets.
const BOSS_DIR = "/static/assets/bosses/";
const BOSS_SPRITES = { aegis: uimg(BOSS_DIR + "aegis.png"), toaster: uimg(BOSS_DIR + "toaster_boss.png") };
const HOLY = uimg("/static/assets/holy/00.png"); // 64px frames, 3 rows of effects
const GLITCH = uimg("/static/assets/glitch/portal.png"); // 64px frames, 60-frame strip
const HUE_RGBA = { red: "rgba(255,30,30,0.20)", green: "rgba(40,255,90,0.16)" };
// holy effect: pick a row (0 bloom / 1 cross / 2 blade), loop its 19 frames
function drawHoly(cx, cy, size, row, fps) {
if (!HOLY.complete || !HOLY.height) return;
const cols = 19, fs = 64;
const f = Math.floor(performance.now() / (1000 / (fps || 24))) % cols;
ctx.drawImage(HOLY, f * fs + 0.5, (row || 0) * fs + 0.5, fs - 1, fs - 1, cx - size / 2, cy - size / 2, size, size);
}
function drawGlitch(cx, cy, size, fps) {
if (!GLITCH.complete || !GLITCH.height) return;
const fs = 64, frames = Math.max(1, Math.round(GLITCH.width / fs));
const f = Math.floor(performance.now() / (1000 / (fps || 24))) % frames;
ctx.drawImage(GLITCH, f * fs + 0.5, 0.5, fs - 1, fs - 1, cx - size / 2, cy - size / 2, size, size);
}
// Playable characters (chosen on the name screen) — all Tiny Swords blue units.
// `fill` = how much of the (square) frame the character occupies; `foot` = the
// content's bottom as a fraction of the frame. These let drawChar() render every
// champion at the SAME on-screen size with feet on the ground, despite different
// sheet sizes / padding.
const CH = "/static/assets/chars/";
// `fill` = the BODY height (NOT incl. raised weapons like the lance) as a fraction
// of the native frame, so every champion's body renders the same size; `foot` =
// where the feet sit in the frame (for grounding).
const CHARACTERS = {
warrior: { label: "Warrior", idle: uimg(CH + "warrior_idle.png"), run: uimg(CH + "warrior_run.png"), fill: 0.46, foot: 0.71 },
archer: { label: "Archer", idle: uimg(CH + "archer_idle.png"), run: uimg(CH + "archer_run.png"), fill: 0.46, foot: 0.71 },
lancer: { label: "Lancer", idle: uimg(CH + "lancer_idle.png"), run: uimg(CH + "lancer_idle.png"), fill: 0.26, foot: 0.62 },
pawn: { label: "Pawn", idle: uimg(CH + "pawn_idle.png"), run: uimg(CH + "pawn_run.png"), fill: 0.38, foot: 0.70 },
};
const CHAR_IDS = Object.keys(CHARACTERS);
// Draw a character frame so its CONTENT is `contentH` px tall with feet at footY.
function drawChar(ch, cx, footY, contentH, moving, faceLeft) {
const img = (moving && ch.run.complete) ? ch.run : ch.idle;
if (!img.complete || !img.height) return;
const fs = img.height;
const frames = Math.max(1, Math.round(img.width / fs));
const f = Math.floor(performance.now() / (1000 / (moving ? 10 : 7))) % frames;
const frameH = contentH / ch.fill; // on-screen height of the whole frame
const topY = footY - ch.foot * frameH; // place feet on the ground
ctx.save();
if (faceLeft) { ctx.translate(cx, 0); ctx.scale(-1, 1); ctx.translate(-cx, 0); }
ctx.drawImage(img, f * fs + 0.5, 0.5, fs - 1, fs - 1, cx - frameH / 2, topY, frameH, frameH);
ctx.restore();
}
// Retro Impact Effect Pack sheets (3x10 frames of 192px) for timed auras.
const RETRO_DIR = "/static/assets/retro/";
const RETRO = {};
for (const s of ["a", "b", "c", "d", "e", "f"]) RETRO[s] = uimg(RETRO_DIR + "retro_" + s + ".png");
// aura type -> retro sheet (mirrors AURAS["sheet"] in engine.py)
const RETRO_SHEET = { inferno: "a", frost: "b", haste: "c", vampire: "d", warding: "e", fury: "f" };
// Draw a looping retro effect (row-major across the 3x10 grid) at a position.
function drawRetro(sheetId, cx, cy, size, fps) {
const img = RETRO[sheetId] || RETRO.a;
if (!img.complete || !img.height) return;
const cols = 3, rows = 10, fs = 192, total = cols * rows;
const f = Math.floor(performance.now() / (1000 / (fps || 18))) % total;
const sx = (f % cols) * fs, sy = Math.floor(f / cols) * fs;
ctx.drawImage(img, sx + 0.5, sy + 0.5, fs - 1, fs - 1, cx - size / 2, cy - size / 2, size, size);
}
// Draw one frame of a horizontal sprite-strip at a target on-screen HEIGHT,
// deriving the (square) frame size from the image height — works for any sheet.
function drawUnitH(img, cx, cy, targetH, faceLeft, fps) {
if (!img.complete || !img.height) return;
const fs = img.height;
const frames = Math.max(1, Math.round(img.width / fs));
const f = Math.floor(performance.now() / (1000 / (fps || 9))) % frames;
const dh = targetH, dw = targetH;
ctx.save();
ctx.translate(cx, cy);
if (faceLeft) ctx.scale(-1, 1);
// inset source rect ~0.5px to avoid neighbouring-frame bleed when scaling
ctx.drawImage(img, f * fs + 0.5, 0.5, fs - 1, fs - 1, -dw / 2, -dh / 2, dw, dh);
ctx.restore();
}
const PALETTE = ["#7ee0ff", "#ff8cc6", "#a6ff8c", "#ffd45e", "#c08cff", "#ff9d6e", "#6effd0", "#ff6e6e"];
// XP gem colors by tier 1..7 (dull green -> brilliant gold)
const GEM_COLORS = ["#6effd0", "#7ee0ff", "#9d8cff", "#ff8cc6", "#ff9d6e", "#ffd45e", "#fff3a0"];
const BOSS_SCALE = 4.2; // keep in sync with boss render scale
// ---- terrain (Tiny Swords) ----------------------------------------------
function loadImg(src) { const img = new Image(); img.src = src; return img; }
const TER = "/static/assets/terrain/";
const TILEMAP = loadImg(TER + "tilemap.png"); // 64px tiles; seamless grass fill at (64,64)
const TILE = 64;
// Decorations: 8-frame strips drawn as static frame 0. {fw, fh} = source frame
// size — trees are 192x256 (non-square!), the rest square.
const DECO = {
tree1: { img: loadImg(TER + "tree1.png"), fw: 192, fh: 256 },
tree2: { img: loadImg(TER + "tree2.png"), fw: 192, fh: 256 },
tree3: { img: loadImg(TER + "tree3.png"), fw: 192, fh: 192 },
tree4: { img: loadImg(TER + "tree4.png"), fw: 192, fh: 192 },
bush1: { img: loadImg(TER + "bush1.png"), fw: 128, fh: 128 },
bush2: { img: loadImg(TER + "bush2.png"), fw: 128, fh: 128 },
bush3: { img: loadImg(TER + "bush3.png"), fw: 128, fh: 128 },
bush4: { img: loadImg(TER + "bush4.png"), fw: 128, fh: 128 },
rock1: { img: loadImg(TER + "rock1.png"), fw: 64, fh: 64 },
rock2: { img: loadImg(TER + "rock2.png"), fw: 64, fh: 64 },
rock3: { img: loadImg(TER + "rock3.png"), fw: 64, fh: 64 },
rock4: { img: loadImg(TER + "rock4.png"), fw: 64, fh: 64 },
};
// Per-theme perimeter decorations, generated deterministically from the theme
// seed so every client sees the same map. The deco weights make each boss's
// territory feel different (orc bushland, legion rockfields, archer forest...).
function mulberry32(a) {
return function () {
a |= 0; a = a + 0x6D2B79F5 | 0;
let t = Math.imul(a ^ a >>> 15, 1 | a);
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
return ((t ^ t >>> 14) >>> 0) / 4294967296;
};
}
const _sceneryCache = {};
function sceneryFor(themeIdx) {
if (_sceneryCache[themeIdx]) return _sceneryCache[themeIdx];
const th = THEMES[themeIdx % THEMES.length];
const rnd = mulberry32(th.seed);
const kinds = [];
for (const [k, w] of Object.entries(th.deco))
for (let i = 0; i < w; i++) kinds.push(k);
const list = [];
let guard = 0;
while (list.length < 26 && guard++ < 400) {
const x = 60 + rnd() * 1160, y = 70 + rnd() * 600;
// keep the central fight area and landmark spots clear
if (Math.hypot(x - 640, y - 360) < 240) continue;
if (th.landmarks.some((L) => Math.hypot(x - L.x, y - L.y) < 110)) continue;
if (list.some((d) => Math.hypot(x - d.x, y - d.y) < 70)) continue;
const fam = kinds[Math.floor(rnd() * kinds.length)];
const k = fam + (1 + Math.floor(rnd() * 4)); // tree1..4 / bush1..4 / rock1..4
const s = fam === "tree" ? 0.34 + rnd() * 0.1
: fam === "bush" ? 0.45 + rnd() * 0.12 : 0.55 + rnd() * 0.2;
list.push({ k, x, y, s });
}
list.sort((a, b) => a.y - b.y); // painter's order
_sceneryCache[themeIdx] = list;
return list;
}
// ---- effects (Free Pixel Effects Pack) -----------------------------------
// Each sheet is a square grid of 100px frames, played row-major.
const FX_DIR = "/static/assets/effects/";
function fxSheet(file, side) {
const img = loadImg(FX_DIR + file);
const cols = side / 100;
return { img, cols, total: cols * cols };
}
const FX = {
cast: fxSheet("1_magicspell_spritesheet.png", 900),
hit: fxSheet("5_magickahit_spritesheet.png", 700),
smallhit: fxSheet("10_weaponhit_spritesheet.png", 600),
death: fxSheet("9_brightfire_spritesheet.png", 800),
bossdeath: fxSheet("11_fire_spritesheet.png", 800),
// skill / blessing effects (server-driven events)
aoe: fxSheet("17_felspell_spritesheet.png", 1000),
aoe_frost: fxSheet("19_freezing_spritesheet.png", 1000),
nova: fxSheet("2_magic8_spritesheet.png", 800),
heal: fxSheet("20_magicbubbles_spritesheet.png", 800),
shield: fxSheet("8_protectioncircle_spritesheet.png", 800),
summon: fxSheet("13_vortex_spritesheet.png", 800),
bless: fxSheet("16_sunburn_spritesheet.png", 800),
};
// Server skill events -> effect sheet + sizing. AoE events carry a radius.
function playEvent(ev) {
const sheet = FX[ev.fx];
if (!sheet) return;
const scale = ev.r ? Math.max(0.8, (ev.r * 2) / 100) : (ev.fx === "bless" ? 1.6 : 1.1);
spawnFx(sheet, ev.x, ev.y - 8, scale, 40);
}
// Live one-shot effects: {sheet, x, y, scale, fps, start}
let effects = [];
function spawnFx(sheet, x, y, scale = 0.7, fps = 45) {
effects.push({ sheet, x, y, scale, fps, start: performance.now() });
if (effects.length > 120) effects.shift();
}
function drawEffects() {
const now = performance.now();
const keep = [];
for (const e of effects) {
const f = Math.floor((now - e.start) / 1000 * e.fps);
if (f >= e.sheet.total) continue;
const img = e.sheet.img;
if (img.complete && img.height) {
const c = e.sheet.cols, sx = (f % c) * 100, sy = Math.floor(f / c) * 100;
const dw = 100 * e.scale;
ctx.drawImage(img, sx, sy, 100, 100, e.x - dw / 2, e.y - dw / 2, dw, dw);
}
keep.push(e);
}
effects = keep;
}
function drawSprite(strip, cx, cy, scale, faceLeft, frameOverride) {
const img = strip.img;
if (!img.complete || !img.height) return;
const frames = strip.frames;
const f = frameOverride != null ? Math.min(frames - 1, frameOverride)
: Math.floor(performance.now() / 110) % frames;
const dw = FW * scale, dh = FH * scale;
ctx.save();
ctx.translate(cx, cy);
if (faceLeft) ctx.scale(-1, 1);
// inset source rect ~0.5px to avoid neighbouring-frame bleed when scaling
ctx.drawImage(img, f * FH + 0.5, 0.5, FH - 1, FH - 1, -dw / 2, -dh / 2, dw, dh);
ctx.restore();
}
// ---- networking ----------------------------------------------------------
function connect() {
const proto = location.protocol === "https:" ? "wss" : "ws";
ws = new WebSocket(`${proto}://${location.host}/ws`);
ws.onopen = () => setConn("connected");
ws.onclose = () => { setConn("disconnected — retrying…"); setTimeout(connect, 1500); };
ws.onmessage = (e) => {
const m = JSON.parse(e.data);
if (m.type === "welcome") {
myPid = m.pid; myRole = m.role;
el("join-screen").classList.add("hidden");
el("roster").classList.remove("hidden");
if (m.role === "player") {
el("hud").classList.remove("hidden");
el("queue-banner").classList.add("hidden");
el("gameover").classList.add("hidden");
if (m.reason === "rotated_in" || m.reason === "from_queue") { startMusic(); flash("⚔ You're in — take the field!"); }
} else {
el("hud").classList.add("hidden");
el("levelup").classList.add("hidden");
if (m.reason === "queued") { showQueue(m.pos, m.size); flash(`Game full — you're #${m.pos} in queue.`); }
else if (m.reason === "lobby_full") flash("Lobby full (8) — you're spectating.");
else if (m.reason === "rotated_out") flash("You fell — spectating until a slot opens.");
}
} else if (m.type === "queue") {
showQueue(m.pos, m.size);
} else if (m.type === "eliminated") {
myRole = "spectator"; myPid = null;
lastScore = m.score || lastScore;
el("hud").classList.add("hidden"); el("levelup").classList.add("hidden");
el("go-score").textContent = `Wave ${lastScore.wave} · Level ${lastScore.level} · 🪙 ${lastScore.gold}`;
el("gameover").classList.remove("hidden");
} else if (m.type === "leaderboard") {
renderLeaderboard(m.top || []);
if (m.submitted) flash("Score submitted! 🏆");
} else if (m.type === "skill_result") {
const box = el("skill-msg");
if (m.ok && m.skill) { box.textContent = `✨ Granted: ${m.skill.name} (${m.skill.effect})`; box.style.color = "#a6ff8c"; }
else { box.textContent = m.reason || "Request failed."; box.style.color = "#ff8c8c"; }
el("skill-btn").disabled = m.ok;
} else {
snap = m;
onSnapshot();
}
};
}
function send(obj) { if (ws && ws.readyState === 1) ws.send(JSON.stringify(obj)); }
function setConn(t) { document.getElementById("conn-status").textContent = t; }
let _toastT = null;
function flash(text) {
const t = document.getElementById("toast");
t.textContent = text; t.classList.remove("hidden");
clearTimeout(_toastT);
_toastT = setTimeout(() => t.classList.add("hidden"), 4000);
}
// ---- input ---------------------------------------------------------------
const KEYMAP = { KeyW: "up", KeyA: "left", KeyS: "down", KeyD: "right", Space: "attack",
ArrowUp: "up", ArrowLeft: "left", ArrowDown: "down", ArrowRight: "right" };
function setKey(code, down) {
const k = KEYMAP[code];
if (!k) return false;
if (input[k] !== down) { input[k] = down; sendInput(); }
return true;
}
function sendInput() {
if (myRole !== "player") return;
send({ type: "input", up: input.up, down: input.down, left: input.left, right: input.right, attack: input.attack });
}
// Ignore movement/attack keys while typing in a text field (Wish box, name, etc.)
function typingInField(e) {
const t = e.target;
return t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable);
}
addEventListener("keydown", (e) => {
if (typingInField(e)) return;
// 1/2/3 pick the offered level-up card
if ((e.code === "Digit1" || e.code === "Digit2" || e.code === "Digit3" ||
e.code === "Numpad1" || e.code === "Numpad2" || e.code === "Numpad3")) {
const p = me();
if (p && p.pending_cards && p.pending_cards.length) {
const idx = (e.code.endsWith("1") ? 0 : e.code.endsWith("2") ? 1 : 2);
if (p.pending_cards[idx]) { send({ type: "choose_card", key: p.pending_cards[idx] }); lastCardSig = ""; }
e.preventDefault(); return;
}
}
if (setKey(e.code, true)) e.preventDefault();
});
addEventListener("keyup", (e) => { if (typingInField(e)) return; if (setKey(e.code, false)) e.preventDefault(); });
// ---- UI wiring -----------------------------------------------------------
const el = (id) => document.getElementById(id);
// ---- music: a shuffled playlist from the Pixel RPG Music Pack -------------
const bgm = el("bgm");
const TRACKS = Array.from({ length: 12 }, (_, i) => `/static/assets/music/Pixel ${i + 1}.ogg`);
const BOSS_TRACK = "/static/assets/music/Pixel 9.ogg"; // tense track for special bosses
let _playlist = [], _trackPos = 0, _bossMusic = false, _musicOn = false;
function _shuffle(a) { for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; }
function playTrack(url, loop) { bgm.loop = !!loop; bgm.src = url; bgm.play().catch(() => {}); }
function nextTrack() {
if (_trackPos >= _playlist.length) { _playlist = _shuffle(TRACKS.slice()); _trackPos = 0; }
playTrack(_playlist[_trackPos++], false);
}
bgm.addEventListener("ended", () => { if (_musicOn && !_bossMusic) nextTrack(); });
function startMusic() {
if (_musicOn) return;
_musicOn = true; bgm.volume = 0.32;
_playlist = _shuffle(TRACKS.slice()); _trackPos = 0;
nextTrack();
el("mute-btn").classList.remove("hidden");
}
// switch to the boss theme during special waves, resume the playlist after
function updateMusicForWave(s) {
if (!_musicOn) return;
const boss = !!s.hue;
if (boss && !_bossMusic) { _bossMusic = true; playTrack(BOSS_TRACK, true); }
else if (!boss && _bossMusic) { _bossMusic = false; nextTrack(); }
}
el("mute-btn").onclick = () => {
if (bgm.paused) { bgm.play().catch(() => {}); el("mute-btn").textContent = "🔊"; }
else { bgm.pause(); el("mute-btn").textContent = "🔇"; }
};
// ---- character picker ----------------------------------------------------
let chosenChar = "warrior";
function buildCharPicker() {
const wrap = el("char-picker");
wrap.innerHTML = "";
for (const id of CHAR_IDS) {
const ch = CHARACTERS[id];
const div = document.createElement("div");
div.className = "char-opt" + (id === chosenChar ? " sel" : "");
div.dataset.id = id;
const c = document.createElement("canvas"); c.width = 56; c.height = 56;
const cc = c.getContext("2d"); cc.imageSmoothingEnabled = false;
div._draw = () => {
cc.clearRect(0, 0, 56, 56);
const img = ch.idle;
if (!img.complete || !img.height) return;
const fs = img.height, frames = Math.max(1, Math.round(img.width / fs));
const f = Math.floor(performance.now() / 140) % frames;
// normalize preview so each champion shows at the same size
const draw = 48 / ch.fill;
cc.drawImage(img, f * fs + 0.5, 0.5, fs - 1, fs - 1, 28 - draw / 2, 54 - ch.foot * draw, draw, draw);
};
div.appendChild(c);
const nm = document.createElement("div"); nm.className = "cn"; nm.textContent = ch.label;
div.appendChild(nm);
div.onclick = () => { chosenChar = id; buildCharPicker(); };
wrap.appendChild(div);
}
}
buildCharPicker();
// keep previews animating
setInterval(() => document.querySelectorAll(".char-opt").forEach((d) => d._draw && d._draw()), 120);
el("join-btn").onclick = () => {
const name = el("name-input").value.trim() || "Wizard";
send({ type: "join", name, char: chosenChar });
el("join-screen").classList.add("hidden");
el("hud").classList.remove("hidden");
el("roster").classList.remove("hidden");
startMusic();
};
el("spectate-btn").onclick = () => {
myRole = "spectator";
const name = el("name-input").value.trim() || "Wizard";
send({ type: "spectate", name, char: chosenChar });
el("join-screen").classList.add("hidden");
el("roster").classList.remove("hidden");
startMusic();
flash("Spectating — you'll be rotated in when a wizard falls.");
};
el("start-btn").onclick = () => send({ type: "start" });
el("skill-btn").onclick = () => {
const prompt = el("skill-input").value.trim();
if (!prompt) { el("skill-msg").textContent = "Describe the power-up you want."; return; }
send({ type: "request_skill", prompt });
el("skill-btn").disabled = true;
el("skill-msg").style.color = "#c9bdf0";
el("skill-msg").textContent = "🧙 The Game Master is conjuring…";
};
// ---- queue / leaderboard / elimination -----------------------------------
let lastScore = { gold: 0, level: 1, wave: 0 };
function showQueue(pos, size) {
const b = el("queue-banner");
b.classList.remove("hidden");
b.textContent = `⏳ In queue — position ${pos}${size ? " of " + size : ""}. You'll join when a slot opens.`;
}
function renderLeaderboard(top) {
el("lb-list").innerHTML = top.length
? top.map((e, i) =>
`<div class="lb-row${e.name === (el('name-input').value.trim() || 'Wizard') ? ' me' : ''}">
<span class="rank">#${i + 1}</span>
<span class="nm">${e.name}</span>
<span>🪙 ${e.gold} · W${e.wave} · Lv${e.level}</span>
</div>`).join("")
: "<p class='hint'>No scores yet — be the first!</p>";
}
el("lb-btn").onclick = () => {
send({ type: "get_leaderboard" });
// show my current score for submission if I'm in a run
const p = me();
const score = p ? { gold: p.gold, level: p.level, wave: snap ? snap.round : 0 } : lastScore;
lastScore = score;
if (score.wave > 0 || score.gold > 0) {
el("lb-submit").classList.remove("hidden");
el("lb-score").textContent = `Your run: 🪙 ${score.gold} · Wave ${score.wave} · Lv ${score.level}`;
} else el("lb-submit").classList.add("hidden");
el("leaderboard").classList.remove("hidden");
};
el("lb-close").onclick = () => el("leaderboard").classList.add("hidden");
// ---- Game Master agent traces panel ---------------------------------------
function renderTraces(traces) {
el("trace-list").innerHTML = traces.length ? traces.map((t) => {
const d = t.decision || {};
const bless = d.blessings && Object.keys(d.blessings).length
? `🙏 ${Object.values(d.blessings).join(", ")}` : "";
const pat = (d.next_round && d.next_round.boss_pattern) ? `🗡 ${d.next_round.boss_pattern}` : "";
const m = t.mercy || {};
const mercy = m.applied != null
? `⏱ atk speed ${m.applied}${m.clamped ? ` (asked ${m.requested}, floor ${m.floor})` : ""}` : "";
const srcCls = String(t.source || "").startsWith("nemotron") ? "src-llm" : "src-fb";
return `<div class="trace">
<div class="t-head">
<span class="t-round">${t.kind === "skill_request" ? "✨ skill wish" : "wave " + (t.round ?? "?")}</span>
<span class="t-src ${srcCls}">${t.source}</span>
<span class="t-lat">${t.latency_sec}s</span>
<span class="t-ts">${t.ts || ""}</span>
</div>
${d.message ? `<div class="t-msg">“${d.message}”</div>` : ""}
${d.reasoning ? `<div class="t-why">${d.reasoning}</div>` : ""}
<div class="t-tags">${pat} ${mercy} ${bless}</div>
${t.raw_output ? `<details><summary>raw model output</summary><pre>${
t.raw_output.replace(/[<>&]/g, (c) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;" }[c]))
}</pre></details>` : ""}
</div>`;
}).join("") : "<p class='hint'>No decisions yet — finish a wave first.</p>";
}
el("traces-btn").onclick = async () => {
el("traces").classList.remove("hidden");
el("trace-list").innerHTML = "<p class='hint'>Loading…</p>";
try {
const r = await fetch("/traces");
renderTraces((await r.json()).traces || []);
} catch { el("trace-list").innerHTML = "<p class='hint'>Failed to load traces.</p>"; }
};
el("traces-close").onclick = () => el("traces").classList.add("hidden");
function submitScore() {
const name = el("name-input").value.trim() || "Wizard";
send({ type: "submit_score", name, gold: lastScore.gold, level: lastScore.level, wave: lastScore.wave });
}
el("lb-submit-btn").onclick = submitScore;
el("go-submit").onclick = () => { submitScore(); send({ type: "get_leaderboard" }); el("gameover").classList.add("hidden"); el("leaderboard").classList.remove("hidden"); };
el("go-rejoin").onclick = () => {
el("gameover").classList.add("hidden");
send({ type: "join", name: el("name-input").value.trim() || "Wizard", char: chosenChar });
};
// ---- effect triggers (derived from successive snapshots) -----------------
const fxState = { mhurt: new Set(), mpos: new Map(), bossHurt: false,
bossAlive: false, phurt: {}, lastCast: {} };
function attackInterval(p) { return Math.max(0.18, 0.55 - (p.upgrades.attack_speed || 0) * 0.05); }
function spawnEffectsFromSnapshot(s) {
if (s.status !== "active") {
fxState.mhurt.clear(); fxState.mpos.clear();
fxState.bossHurt = false; fxState.bossAlive = !!(s.boss && s.boss.hp > 0);
return;
}
const now = performance.now() / 1000;
// minion hits (rising edge of hurt) + deaths (id disappeared from the roster)
const pos = new Map(), hurtNow = new Set();
for (const m of s.minions) {
pos.set(m.id, [m.x, m.y]);
if (m.hurt) { hurtNow.add(m.id); if (!fxState.mhurt.has(m.id)) spawnFx(FX.smallhit, m.x, m.y - 8, 0.6, 50); }
}
for (const [id, p] of fxState.mpos)
if (!pos.has(id)) spawnFx(FX.death, p[0], p[1] - 8, 0.7, 45); // minion died
fxState.mhurt = hurtNow; fxState.mpos = pos;
// boss hit + death
if (s.boss) {
if (s.boss.hurt && !fxState.bossHurt) spawnFx(FX.hit, s.boss.x, s.boss.y - 20, 1.0, 50);
fxState.bossHurt = s.boss.hurt;
const aliveNow = s.boss.hp > 0;
if (fxState.bossAlive && !aliveNow) {
for (let i = 0; i < 6; i++)
spawnFx(FX.bossdeath, s.boss.x + (Math.random() - 0.5) * 120,
s.boss.y - 20 + (Math.random() - 0.5) * 120, 1.4, 40);
}
fxState.bossAlive = aliveNow;
}
// player hurt + cast muzzle
for (const p of s.players) {
const wasHurt = fxState.phurt[p.id];
if (p.hurt && !wasHurt) spawnFx(FX.smallhit, p.x, p.y - 10, 0.7, 50);
fxState.phurt[p.id] = p.hurt;
if (p.alive && p.attacking) {
const last = fxState.lastCast[p.id] || 0;
if (now - last >= attackInterval(p)) {
fxState.lastCast[p.id] = now;
spawnFx(FX.cast, p.x + Math.cos(p.facing) * 26, p.y + Math.sin(p.facing) * 26 - 6, 0.5, 60);
}
}
}
}
let _prevStatus = null, _prevTheme = 0, _lastFlash = "";
function onSnapshot() {
const s = snap;
// reset the skill-request UI each time a fresh intermission begins
if (s.status === "intermission" && _prevStatus !== "intermission") {
el("skill-msg").textContent = ""; el("skill-input").value = ""; el("skill-btn").disabled = false;
}
// announce when the enemy faction changes (every 5 waves)
const th = s.theme || 0;
if (th !== _prevTheme && s.status === "active") {
const prev = THEMES[_prevTheme % THEMES.length], next = THEMES[th % THEMES.length];
if (th > _prevTheme) flash(`⚔ The ${prev.name} is defeated — the ${next.name} rises!`);
_prevTheme = th;
}
_prevStatus = s.status;
// toast when I claim a floor power-up
const meP = me();
if (meP && meP.flash && meP.flash !== _lastFlash) { flash("✨ " + meP.flash); _lastFlash = meP.flash; }
else if (meP && !meP.flash) _lastFlash = "";
// server-driven skill / blessing effects (one-shot events)
for (const ev of (s.events || [])) playEvent(ev);
updateMusicForWave(s); // boss theme during special waves
spawnEffectsFromSnapshot(s);
updateHud(s);
updateRoster(s);
// overlays driven by game status
const lobby = el("lobby-start"), shop = el("shop");
if (s.status === "lobby" && myRole === "player") {
lobby.classList.remove("hidden"); shop.classList.add("hidden");
el("lobby-msg").textContent = s.status_msg;
} else if (s.status === "intermission") {
lobby.classList.add("hidden");
shop.classList.remove("hidden");
renderShop(s);
} else {
lobby.classList.add("hidden"); shop.classList.add("hidden");
}
// level-up card pick takes priority (can occur mid-wave)
renderLevelUp(s);
}
let lastCardSig = "";
function renderLevelUp(s) {
const p = me();
const pending = (p && p.pending_cards) || [];
const overlay = el("levelup");
if (!pending.length) { overlay.classList.add("hidden"); lastCardSig = ""; return; }
// live auto-pick countdown
el("lu-timer").textContent = (p.pending_left != null && p.pending_left > 0)
? `auto in ${Math.ceil(p.pending_left)}s` : "";
const sig = p.id + ":" + pending.join(",");
if (sig === lastCardSig) return; // avoid rebuilding (preserves hover)
lastCardSig = sig;
overlay.classList.remove("hidden");
el("card-list").innerHTML = pending.map((id, n) => {
const c = s.cards[id] || { label: id, desc: "", rarity: "common" };
const lvl = (p.upgrades[id] || 0);
return `<div class="card" data-key="${id}">
<div class="ckey">${n + 1}</div>
<div class="cname">${c.label}</div>
<div class="cdesc">${c.desc}</div>
<div class="crarity rarity-${c.rarity}">${c.rarity}</div>
<div class="lvltag">${lvl > 0 ? "owned ×" + lvl : "new"}</div>
</div>`;
}).join("");
el("card-list").querySelectorAll(".card").forEach((b) =>
b.onclick = () => { send({ type: "choose_card", key: b.dataset.key }); lastCardSig = ""; });
}
function me() { return snap ? snap.players.find((p) => p.id === myPid) : null; }
function updateHud(s) {
el("round-info").textContent = `Wave ${s.round} · ${s.status}`;
const p = me();
const xpbar = el("xp-bar"), hearts = el("hearts");
if (p) {
el("self-info").innerHTML =
`❤ ${Math.ceil(p.hp)}/${p.max_hp}` +
(p.shield > 0 ? ` <span style='color:#9db4c9'>🛡${Math.ceil(p.shield)}</span>` : "") +
` &nbsp; 🪙 ${p.gold} &nbsp; Lv.${p.level}` +
(p.alive ? "" : " &nbsp; <span style='color:#ff6e6e'>DOWN</span>");
xpbar.style.display = "";
const frac = p.xp_needed ? p.xp / p.xp_needed : 0;
el("xp-fill").style.width = Math.min(100, frac * 100) + "%";
el("xp-text").textContent = `XP ${p.xp}/${p.xp_needed}`;
// hearts (lives)
hearts.classList.remove("hidden");
const lives = p.lives != null ? p.lives : 3;
hearts.innerHTML = "❤️".repeat(Math.max(0, lives)) +
"<span style='opacity:.3'>" + "🖤".repeat(Math.max(0, 3 - lives)) + "</span>";
// aura chips
el("aura-bar").innerHTML = (p.auras || []).map((a) =>
`<span class="aura-chip" style="color:${a.color};border-color:${a.color}">${a.label} ${Math.ceil(a.t)}s</span>`).join("");
} else {
el("self-info").textContent = myRole === "spectator" ? "👁 spectating" : "";
xpbar.style.display = "none";
hearts.classList.add("hidden");
el("aura-bar").innerHTML = "";
}
}
function updateRoster(s) {
let html = `<h4>Arena ${s.players.length}/${s.max_players}</h4>`;
s.players.forEach((p, i) => {
const c = PALETTE[i % PALETTE.length];
html += `<div class="row"><span style="color:${c}">${p.alive ? "" : "💀 "}${p.name}</span>` +
`<span class="gold">${p.gold}</span></div>`;
});
if (s.gm_message) html += `<div style="margin-top:6px;color:#7ee0ff;font-style:italic">🧙 ${s.gm_message}</div>`;
el("roster").innerHTML = html;
}
function renderShop(s) {
el("shop-title").textContent = s.status_msg.startsWith("Victory") ? "Wave Cleared!" : "Wave Over";
el("gm-line").textContent = s.gm_message ? `🧙 ${s.gm_message}` : "🧙 The Game Master is directing…";
// skill request box — only for this wave's top damage dealer
const sr = s.skill_request;
const box = el("skill-box");
if (sr && sr.pid && sr.pid === myPid) {
box.classList.remove("hidden");
el("skill-btn").disabled = sr.used;
if (sr.used && !el("skill-msg").textContent) el("skill-msg").textContent = "Power-up already requested this wave.";
} else {
box.classList.add("hidden");
}
el("shop-timer").textContent = s.intermission_left
? `Next wave in ${s.intermission_left}s` : "Summoning the next wave…";
// Show the card pool the Director has curated for the coming wave.
const pool = s.card_pool || [];
el("shop-gold").innerHTML = pool.length
? `<span style="color:#9d92c0">Level up by collecting XP gems to draw from the Director's pool:</span>`
: "";
el("upgrade-list").innerHTML = pool.map((id) => {
const c = (s.cards && s.cards[id]) || { label: id, rarity: "common" };
return `<div class="upg"><span>${c.label}</span>` +
`<span class="lvl rarity-${c.rarity}">${c.rarity}</span></div>`;
}).join("");
}
// ---- rendering -----------------------------------------------------------
function drawScenery(d, x, y, s) {
const img = d.img;
if (!img.complete || !img.height) return;
const fw = d.fw, fh = d.fh, dw = fw * s, dh = fh * s;
// static frame 0; inset the source rect ~0.5px so the neighbouring frame
// on the strip can't bleed into the edges when downscaled.
ctx.drawImage(img, 0.5, 0.5, fw - 1, fh - 1, x - dw / 2, y - dh, dw, dh); // anchored at base
}
// Draw a Tiny Swords building anchored at its base (frame 0 of any strip).
function drawBuilding(img, x, y, s) {
if (!img.complete || !img.height) return;
const dw = img.width * s, dh = img.height * s;
ctx.drawImage(img, x - dw / 2, y - dh, dw, dh);
}
function drawBackground(arena, themeIdx) {
const th = THEMES[themeIdx % THEMES.length];
if (TILEMAP.complete && TILEMAP.height) {
// one continuous grass landmass: the single seamless interior tile (1,1),
// tiled across the whole arena (covers the partial bottom row too).
for (let y = 0; y < arena.h; y += TILE)
for (let x = 0; x < arena.w; x += TILE)
ctx.drawImage(TILEMAP, TILE, TILE, TILE, TILE, x, y, TILE, TILE);
} else {
ctx.fillStyle = "#5a8f3a"; ctx.fillRect(0, 0, arena.w, arena.h);
}
// theme ground tint — each boss's territory has its own cast
ctx.fillStyle = th.tint;
ctx.fillRect(0, 0, arena.w, arena.h);
// theme landmarks (Tiny Swords buildings), then seeded scenery
// (purely cosmetic — no effect on the server simulation)
for (const L of th.landmarks) drawBuilding(BUILDINGS[L.b], L.x, L.y, L.s);
for (const d of sceneryFor(themeIdx)) drawScenery(DECO[d.k], d.x, d.y, d.s);
// central arena ring where the boss stands (ring color follows the theme)
ctx.save();
ctx.strokeStyle = "rgba(40,28,60,.5)"; ctx.lineWidth = 8;
ctx.beginPath(); ctx.arc(arena.w / 2, arena.h / 2, 90, 0, Math.PI * 2); ctx.stroke();
ctx.strokeStyle = th.ring; ctx.lineWidth = 3;
ctx.beginPath(); ctx.arc(arena.w / 2, arena.h / 2, 90, 0, Math.PI * 2); ctx.stroke();
ctx.restore();
}
function hpBar(x, y, w, frac, color) {
ctx.fillStyle = "rgba(0,0,0,.6)"; ctx.fillRect(x - w / 2, y, w, 6);
ctx.fillStyle = color; ctx.fillRect(x - w / 2, y, w * Math.max(0, frac), 6);
}
function render() {
requestAnimationFrame(render);
if (!snap) return;
const s = snap;
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawBackground(s.arena, s.theme || 0);
// floor power-up pickups (rare minion drops)
for (const pk of (s.pickups || [])) {
const pulse = 0.6 + 0.4 * Math.sin(performance.now() / 300);
if (pk.kind === "aura") {
// timed aura: pulsing colored orb wreathed in a retro effect
drawRetro(pk.sheet || "a", pk.x, pk.y, 64, 18);
ctx.save(); ctx.translate(pk.x, pk.y);
ctx.shadowColor = pk.color || "#fff"; ctx.shadowBlur = 18 * pulse;
ctx.fillStyle = pk.color || "#fff";
ctx.beginPath(); ctx.arc(0, 0, 8, 0, Math.PI * 2); ctx.fill();
ctx.restore(); ctx.shadowBlur = 0;
continue;
}
ctx.save();
ctx.translate(pk.x, pk.y);
ctx.shadowColor = "#ffd45e"; ctx.shadowBlur = 16 * pulse;
ctx.fillStyle = "#fff3a0";
ctx.beginPath();
for (let i = 0; i < 10; i++) {
const ang = -Math.PI / 2 + i * Math.PI / 5;
const rad = i % 2 === 0 ? 11 : 5;
ctx[i ? "lineTo" : "moveTo"](Math.cos(ang) * rad, Math.sin(ang) * rad);
}
ctx.closePath(); ctx.fill();
ctx.restore(); ctx.shadowBlur = 0;
}
// XP gems (under everything else) — color & size by tier (1..7)
for (const g of (s.gems || [])) {
const tier = g.t || 1;
ctx.save();
ctx.translate(g.x, g.y);
ctx.rotate(Math.PI / 4);
ctx.fillStyle = GEM_COLORS[tier - 1] || "#6effd0";
ctx.shadowColor = ctx.fillStyle; ctx.shadowBlur = 8 + tier;
const r = 3 + tier * 0.9;
ctx.fillRect(-r, -r, r * 2, r * 2);
ctx.restore();
ctx.shadowBlur = 0;
}
// projectiles
for (const pr of s.projectiles) {
if (pr.s === "holy") { drawHoly(pr.x, pr.y, pr.r * 4.5, 0, 24); continue; }
if (pr.s === "glitch") { drawGlitch(pr.x, pr.y, pr.r * 4, 24); continue; }
ctx.beginPath();
ctx.fillStyle = pr.hostile ? "#ff5e7a" : "#7ee0ff";
ctx.shadowColor = ctx.fillStyle; ctx.shadowBlur = 12;
ctx.arc(pr.x, pr.y, pr.r, 0, Math.PI * 2); ctx.fill();
ctx.shadowBlur = 0;
}
// minions (archetype: scale + a colored ground ring to distinguish types)
const theme = themeOf(s);
const KIND_RING = { fast: "#6effd0", tank: "#ff6e6e" };
for (const m of s.minions) {
const ms = m.scale || 1;
const h = 116 * ms; // on-screen height
const ring = KIND_RING[m.kind];
if (ring) {
ctx.beginPath(); ctx.strokeStyle = ring; ctx.globalAlpha = 0.6; ctx.lineWidth = 2;
ctx.ellipse(m.x, m.y + 24 * ms, 26 * ms, 10 * ms, 0, 0, Math.PI * 2);
ctx.stroke(); ctx.globalAlpha = 1;
}
const faceLeft = (m.x % 2) < 1 ? false : false; // minions face target; keep upright
drawUnitH(theme.minion, m.x, m.y - 14, h, false);
if (m.hurt) { ctx.fillStyle = "rgba(255,80,80,.3)"; ctx.beginPath();
ctx.arc(m.x, m.y - 8, h * 0.4, 0, Math.PI * 2); ctx.fill(); }
hpBar(m.x, m.y - 62 * ms, 48 * ms, m.hp / m.max_hp, "#ff7a4a");
}
// allies (summoned spirits) — tinted soldier sprites
for (const a of (s.allies || [])) {
ctx.save();
ctx.globalAlpha = 0.9;
drawSprite(SPR.player.idle, a.x, a.y - 4, 0.6, false);
ctx.restore();
// colored aura ring
ctx.beginPath(); ctx.strokeStyle = a.color || "#a6ff8c"; ctx.globalAlpha = 0.7; ctx.lineWidth = 2;
ctx.ellipse(a.x, a.y + 14, 16, 6, 0, 0, Math.PI * 2); ctx.stroke(); ctx.globalAlpha = 1;
if (a.hp < a.max_hp) hpBar(a.x, a.y - 38, 26, a.hp / a.max_hp, a.color || "#a6ff8c");
}
// boss — themed unit, larger, with charge telegraph
if (s.boss) {
const b = s.boss;
const H = 360; // boss on-screen height
// telegraph: pulsing red warning ring before a charge
if (b.state === "telegraph") {
const pulse = 0.5 + 0.5 * Math.sin(performance.now() / 60);
ctx.beginPath(); ctx.strokeStyle = `rgba(255,60,60,${0.4 + pulse * 0.5})`;
ctx.lineWidth = 6; ctx.arc(b.x, b.y - 30, 90, 0, Math.PI * 2); ctx.stroke();
}
if (b.state === "charge") { // motion streak
ctx.fillStyle = "rgba(255,80,80,.18)";
ctx.beginPath(); ctx.arc(b.x, b.y - 30, 80, 0, Math.PI * 2); ctx.fill();
}
// special bosses use their own sprite; otherwise the themed enemy unit
if (b.special && BOSS_SPRITES[b.special]) {
const bh = b.special === "toaster" ? 300 : 380; // toaster is chunky-upscaled
const aura = b.special === "aegis" ? "rgba(255,60,90,.5)" : "rgba(60,255,120,.5)";
ctx.save(); ctx.shadowColor = aura; ctx.shadowBlur = 30;
drawUnitH(BOSS_SPRITES[b.special], b.x, b.y - 50, bh, false, b.special === "toaster" ? 4 : 10);
ctx.restore();
} else {
drawUnitH(theme.boss, b.x, b.y - 60, H, false, 7);
}
if (b.hurt) { ctx.fillStyle = "rgba(255,80,80,.22)"; ctx.beginPath();
ctx.arc(b.x, b.y - 50, H * 0.42, 0, Math.PI * 2); ctx.fill(); }
let label = (b.name || theme.name).toUpperCase();
if (b.pattern_label) label += " · " + b.pattern_label; // GM-chosen attack pattern
hpBar(b.x, b.y - 150, 260, b.hp / b.max_hp, b.special === "aegis" ? "#ff3030" : b.special === "toaster" ? "#5CFC30" : "#ff4a6a");
ctx.fillStyle = "#ffe0e8"; ctx.font = "bold 16px sans-serif"; ctx.textAlign = "center";
ctx.fillText(`${label} ${Math.ceil(b.hp)}/${b.max_hp}`
+ (b.phase === 2 ? " ⚡ENRAGED" : ""), b.x, b.y - 158);
}
// players
s.players.forEach((p, i) => {
const color = PALETTE[i % PALETTE.length];
const faceLeft = Math.cos(p.facing) < 0;
// size is server-authoritative (hitbox grows with it): wizards start
// minion-sized and cap out a little larger than the boss
const psc = Math.min(4.6, 1.55 * (p.size || 1));
const top = 66 * psc; // bar/name sit just above the character's head
// active timed auras: a retro effect wreathing the wizard
for (const au of (p.auras || [])) {
ctx.globalAlpha = 0.85;
drawRetro(RETRO_SHEET[au.type] || "a", p.x, p.y - 10 * psc, 120 * psc, 16);
ctx.globalAlpha = 1;
}
if (p.id === myPid) {
ctx.beginPath(); ctx.strokeStyle = color; ctx.lineWidth = 2;
ctx.arc(p.x, p.y + 26 * psc, 26 * psc, 0, Math.PI * 2); ctx.stroke();
}
ctx.globalAlpha = p.alive ? 1 : 0.4;
// every champion renders at the same content height (66px) * level scale
const ch = CHARACTERS[p.char] || CHARACTERS.warrior;
drawChar(ch, p.x, p.y + 6, 66 * psc, p.moving, faceLeft);
ctx.globalAlpha = 1;
if (p.alive) hpBar(p.x, p.y - top, 52, p.hp / p.max_hp, color);
ctx.fillStyle = color; ctx.font = "bold 13px sans-serif"; ctx.textAlign = "center";
ctx.fillText(p.name + " Lv" + p.level, p.x, p.y - top - 8);
});
// spell / impact / death effects on top
drawEffects();
// special-boss screen tint (pulsing) over everything
if (s.hue && HUE_RGBA[s.hue]) {
const pulse = 0.75 + 0.25 * Math.sin(performance.now() / 400);
ctx.save(); ctx.globalAlpha = pulse;
ctx.fillStyle = HUE_RGBA[s.hue];
ctx.fillRect(0, 0, s.arena.w, s.arena.h);
ctx.restore();
}
}
connect();
requestAnimationFrame(render);