// 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) => `
#${i + 1} ${e.name} 🪙 ${e.gold} · W${e.wave} · Lv${e.level}
`).join("") : "

No scores yet — be the first!

"; } 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 `
${t.kind === "skill_request" ? "✨ skill wish" : "wave " + (t.round ?? "?")} ${t.source} ${t.latency_sec}s ${t.ts || ""}
${d.message ? `
“${d.message}”
` : ""} ${d.reasoning ? `
${d.reasoning}
` : ""}
${pat} ${mercy} ${bless}
${t.raw_output ? `
raw model output
${
        t.raw_output.replace(/[<>&]/g, (c) => ({ "<": "<", ">": ">", "&": "&" }[c]))
      }
` : ""}
`; }).join("") : "

No decisions yet — finish a wave first.

"; } el("traces-btn").onclick = async () => { el("traces").classList.remove("hidden"); el("trace-list").innerHTML = "

Loading…

"; try { const r = await fetch("/traces"); renderTraces((await r.json()).traces || []); } catch { el("trace-list").innerHTML = "

Failed to load traces.

"; } }; 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 `
${n + 1}
${c.label}
${c.desc}
${c.rarity}
${lvl > 0 ? "owned ×" + lvl : "new"}
`; }).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 ? ` 🛡${Math.ceil(p.shield)}` : "") + `   🪙 ${p.gold}   Lv.${p.level}` + (p.alive ? "" : "   DOWN"); 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)) + "" + "🖤".repeat(Math.max(0, 3 - lives)) + ""; // aura chips el("aura-bar").innerHTML = (p.auras || []).map((a) => `${a.label} ${Math.ceil(a.t)}s`).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 = `

Arena ${s.players.length}/${s.max_players}

`; s.players.forEach((p, i) => { const c = PALETTE[i % PALETTE.length]; html += `
${p.alive ? "" : "💀 "}${p.name}` + `${p.gold}
`; }); if (s.gm_message) html += `
🧙 ${s.gm_message}
`; 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 ? `Level up by collecting XP gems to draw from the Director's pool:` : ""; el("upgrade-list").innerHTML = pool.map((id) => { const c = (s.cards && s.cards[id]) || { label: id, rarity: "common" }; return `
${c.label}` + `${c.rarity}
`; }).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);