Spaces:
Running on Zero
Running on Zero
| // HuggingWizards client: WebSocket sync + canvas rendering + input. | |
| // The browser renders; the server owns the simulation. | |
| ; | |
| 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) => ({ "<": "<", ">": ">", "&": "&" }[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>` : "") + | |
| ` 🪙 ${p.gold} Lv.${p.level}` + | |
| (p.alive ? "" : " <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); | |