// 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) => `
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.raw_output.replace(/[<>&]/g, (c) => ({ "<": "<", ">": ">", "&": "&" }[c]))
}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 `