/* Modular Mind: Boss Fight -- HTML5 canvas duel. * Rendering / physics / animation run at 60fps in the browser. The boss's NEXT * ACTION is chosen by the trained Modular Mind (Python) via window.MM_DECIDE, * called only at decision points (when the boss is free to commit) -- exactly how * a souls boss commits to a move. Combat balance mirrors duel_sim.py so the boss * behaves the way it was trained. */ (function () { "use strict"; // ---- world / balance (world units; speeds in units/sec) ---------------- const W = 900, Hgt = 420, GROUND = 372; const CFG = { BOSS_HP: 100, PLAYER_HP: 100, BOSS_MOVE: 190, PLAYER_MOVE: 188, ROLL_SPEED: 230, MIN_GAP: 46, CLEAVE_REACH: 170, CLEAVE_DMG: 22, CLEAVE_LUNGE: 270, CLEAVE_WINDUP: 0.30, CLEAVE_ACTIVE: 0.17, CLEAVE_RECOVER: 0.42, CLEAVE_COOLDOWN: 0.34, PATK_REACH: 120, PATK_DMG: 6, PATK_WINDUP: 0.13, PATK_ACTIVE: 0.10, PATK_RECOVER: 0.30, ROLL_DUR: 0.53, ROLL_IFR_A: 0.10, ROLL_IFR_B: 0.37, BLOCK_MIT: 0.2, STAGGER: 0.5, BOSS_BLOCK_TIME: 0.4, BOSS_BLOCK_MIT: 0.1, // boss guard: duration + dmg taken multiplier STAM_MAX: 100, STAM_ROLL: 28, STAM_ATK: 18, STAM_BLOCK: 24, STAM_REGEN: 26, COMMIT_MOVE: 0.30, COMMIT_IDLE: 0.34, THINK_MIN: 0.10, SHIELD_TIME: 5.0, // one-time "learning grace": absorbs all damage at the // start of the fight, then it's gone for good. }; // ---- assets ------------------------------------------------------------ const A = window.GAME_ASSETS; const imgs = {}; function loadImg(src) { return new Promise(r => { const i = new Image(); i.onload = () => r(i); i.src = src; }); } // ---- audio (served statically; base injected by the server) ------------ const AUDIO_BASE = window.MM_AUDIO_BASE || "audio/"; const SFX = { p_attack: "sfx/attack_knight.wav", p_hurt: "sfx/hurt_knight.wav", p_roll: "sfx/jump_knight.wav", p_die: "sfx/die_knight.wav", shield: "sfx/gem.wav", ui: "sfx/coin.wav", b_cleave: "sfx/axe_boss.wav", b_hurt: "sfx/hurt_monster.wav", b_die: "sfx/die_boss.wav", roar: "sfx/roar_monster.wav", b_block: "sfx/bit_monster.wav", }; const MUSIC = Array.from({ length: 12 }, (_, i) => `mp3/Pixel ${i + 1}.mp3`); const sfxBuf = {}; let musicEl = null, muted = false, audioReady = false; const aurl = p => AUDIO_BASE + encodeURI(p); function initAudio() { if (audioReady) return; audioReady = true; for (const k in SFX) { const a = new Audio(aurl(SFX[k])); a.preload = "auto"; sfxBuf[k] = a; } } function playSfx(k, vol = 0.6) { if (muted || !sfxBuf[k]) return; const a = sfxBuf[k].cloneNode(); a.volume = vol; a.play().catch(() => {}); } function startMusic() { if (muted) return; const track = MUSIC[Math.floor(Math.random() * MUSIC.length)]; if (musicEl) musicEl.pause(); musicEl = new Audio(aurl(track)); musicEl.volume = 0.3; musicEl.addEventListener("ended", () => { if (!muted) startMusic(); }); musicEl.play().catch(() => {}); } function stopMusic() { if (musicEl) { musicEl.pause(); musicEl = null; } } function toggleMute() { muted = !muted; if (muted) { if (musicEl) musicEl.pause(); } else if (musicEl) musicEl.play().catch(() => {}); const b = document.getElementById("mm-mute"); if (b) b.textContent = muted ? "š" : "š"; } // ---- animation ----------------------------------------------------------- class Anim { constructor(who) { this.man = A[who].manifest; this.img = imgs[who]; this.who = who; // native facing of the source art: the knight faces RIGHT, the demon faces LEFT. // We flip when the desired world-facing differs from the art's native facing. this.native = who === "boss" ? -1 : 1; this.name = "idle"; this.t = 0; this.idx = 0; this.done = false; this.loop = true; this.speed = 1; this.rev = false; } set(name, loop = true, speed = 1, rev = false) { // don't restart an animation that is already playing (it is re-requested // every frame by the state machine); a real change of name restarts it. if (this.name === name) { this.loop = loop; this.speed = speed; this.rev = rev; return; } this.name = name; this.loop = loop; this.speed = speed; this.rev = rev; this.t = 0; this.done = false; this.idx = rev ? this.man.anims[name].frames - 1 : 0; } update(dt) { const a = this.man.anims[this.name]; if (!a) return; this.t += dt * a.fps * this.speed; if (this.t >= 1) { const steps = Math.floor(this.t); this.t -= steps; this.idx += steps * (this.rev ? -1 : 1); if (this.rev) { if (this.idx < 0) { if (this.loop) this.idx = ((this.idx % a.frames) + a.frames) % a.frames; else { this.idx = 0; this.done = true; } } } else if (this.idx >= a.frames) { if (this.loop) this.idx %= a.frames; else { this.idx = a.frames - 1; this.done = true; } } } } progress() { const a = this.man.anims[this.name]; return (this.idx + this.t) / a.frames; } draw(ctx, x, facing, scale) { const m = this.man, a = m.anims[this.name]; const sx = this.idx * m.frameW, sy = a.row * m.frameH; const dw = m.frameW * scale, dh = m.frameH * scale; const dy = GROUND - m.footY * scale; // translate to the entity's feet/centre, mirror by facing, draw so the // frame's centreX maps to local 0 and footY maps to GROUND. ctx.save(); ctx.translate(x, dy); // flip when the wanted world-facing differs from the art's native facing ctx.scale(facing * this.native < 0 ? -1 : 1, 1); ctx.drawImage(this.img, sx, sy, m.frameW, m.frameH, -m.centerX * scale, 0, dw, dh); ctx.restore(); } } // ---- entities ---------------------------------------------------------- const boss = { x: 250, hp: CFG.BOSS_HP, facing: 1, anim: null, state: "idle", t: 0, cd: 0, think: 0, pending: false, action: "IDLE", actT: 0, cleavePhase: "", cleaveHit: false, scale: 1.7, telemetry: null, blockFlash: 0 }; const player = { x: 650, hp: CFG.PLAYER_HP, stam: CFG.STAM_MAX, facing: -1, anim: null, state: "idle", t: 0, atkHit: false, scale: 2.4, shield: 0, shieldFlash: 0 }; const keys = {}; let game = "menu"; // menu | fight | dead | win let lastDecision = null; let difficulty = "hard"; // easy | normal | hard (which trained brain runs the boss) let fightLog = []; // per-decision log sent to the online learner at fight end // ---- helpers ----------------------------------------------------------- const dist = () => Math.abs(player.x - boss.x); // Characters face the way they MOVE while walking/running/rolling, and face // their opponent when idle or attacking (so swings connect & telegraphs aim // correctly). Called once per frame after the updates, before rendering. function updateFacings() { const towardBossFromPlayer = boss.x >= player.x ? 1 : -1; if (player.state === "run") { const left = keys["ArrowLeft"] || keys["a"] || keys["A"]; const right = keys["ArrowRight"] || keys["d"] || keys["D"]; if (right && !left) player.facing = 1; else if (left && !right) player.facing = -1; // both/neither -> keep } else if (player.state === "roll") { player.facing = player.rollDir; // roll in the direction it travels } else if (player.state !== "dead") { player.facing = towardBossFromPlayer; // idle/attack/block/hit face the boss } // the boss is locked onto the player: it ALWAYS faces you (it never turns its // back). Retreat is rendered as a backstep (reversed walk) instead of a turn. if (boss.state !== "dead") boss.facing = player.x >= boss.x ? 1 : -1; } function clampX(o) { o.x = Math.max(40, Math.min(W - 40, o.x)); } function enforceGap() { // a roll lets the player dodge THROUGH the boss to the other side; the boss's // always-face-the-player logic then spins it around. Otherwise bodies don't overlap. if (player.state === "roll") return; if (dist() < CFG.MIN_GAP) { if (player.x < boss.x) player.x = boss.x - CFG.MIN_GAP; else player.x = boss.x + CFG.MIN_GAP; clampX(player); } } const phase = () => (boss.hp < CFG.BOSS_HP * 0.5 ? 2 : 1); // ===================== PLAYER ===================== function playerUpdate(dt) { if (player.shield > 0) player.shield = Math.max(0, player.shield - dt); if (player.shieldFlash > 0) player.shieldFlash = Math.max(0, player.shieldFlash - dt); if (player.state === "dead") { player.anim.set("death", false); return; } player.stam = Math.min(CFG.STAM_MAX, player.stam + CFG.STAM_REGEN * dt); // committed states if (player.state === "roll") { player.t += dt; player.x += player.rollDir * CFG.ROLL_SPEED * dt; clampX(player); enforceGap(); if (player.t >= CFG.ROLL_DUR) { player.state = "idle"; } return; } if (player.state === "attack") { player.t += dt; const a0 = CFG.PATK_WINDUP, a1 = CFG.PATK_WINDUP + CFG.PATK_ACTIVE; if (player.t >= a0 && player.t < a1 && !player.atkHit) { if (dist() <= CFG.PATK_REACH) { hitBoss(CFG.PATK_DMG); player.atkHit = true; } } if (player.t >= a1 + CFG.PATK_RECOVER) player.state = "idle"; return; } if (player.state === "stagger") { player.t += dt; if (player.t >= CFG.STAGGER) player.state = "idle"; return; } if (player.state === "hit") { player.t += dt; if (player.anim.done) player.state = "idle"; } // free: read input let moving = false; const left = keys["ArrowLeft"] || keys["a"] || keys["A"]; const right = keys["ArrowRight"] || keys["d"] || keys["D"]; const blocking = keys["k"] || keys["K"]; if (blocking) { player.state = "block"; } else if (player.state === "block") { player.state = "idle"; } if (player.state !== "block") { if (left) { player.x -= CFG.PLAYER_MOVE * dt; moving = true; } if (right) { player.x += CFG.PLAYER_MOVE * dt; moving = true; } clampX(player); enforceGap(); player.state = moving ? "run" : "idle"; } } function playerAttack() { if (["attack", "roll", "stagger", "hit", "dead"].includes(player.state)) return; if (player.stam < CFG.STAM_ATK) return; player.state = "attack"; player.t = 0; player.atkHit = false; player.stam -= CFG.STAM_ATK; playSfx("p_attack", 0.5); } function playerRoll() { if (["roll", "stagger", "hit", "dead"].includes(player.state)) return; if (player.stam < CFG.STAM_ROLL) return; player.state = "roll"; player.t = 0; player.stam -= CFG.STAM_ROLL; playSfx("p_roll", 0.5); player.rollDir = -player.facing; // roll backward (away from boss) by default if (keys["ArrowLeft"] || keys["a"]) player.rollDir = -1; if (keys["ArrowRight"] || keys["d"]) player.rollDir = 1; } function playerIframe() { return player.state === "roll" && player.t >= CFG.ROLL_IFR_A && player.t <= CFG.ROLL_IFR_B; } function playerRecovering() { if (player.state === "stagger") return true; if (player.state === "attack" && player.t >= CFG.PATK_WINDUP + CFG.PATK_ACTIVE) return true; if (player.state === "roll" && player.t > CFG.ROLL_IFR_B) return true; return false; } function setPlayerAnim() { const s = player.state; if (s === "dead") player.anim.set("death", false); else if (s === "hit") player.anim.set("take_hit", false); else if (s === "stagger") player.anim.set("take_hit", false); else if (s === "attack") player.anim.set("attack", false, 1.25); else if (s === "roll") player.anim.set("roll", false, 1.1); else if (s === "block") player.anim.set("defend", true); else if (s === "run") player.anim.set("run", true); else player.anim.set("idle", true); } // ===================== BOSS ===================== function hitBoss(dmg) { if (boss.state === "dead") return; // the boss's guard negates most of an incoming melee (no flinch either) if (boss.state === "block") { boss.blockFlash = 0.18; playSfx("b_block", 0.5); boss.hp = Math.max(0, boss.hp - dmg * CFG.BOSS_BLOCK_MIT); return; } const wasPhase1 = boss.hp >= CFG.BOSS_HP * 0.5; boss.hp = Math.max(0, boss.hp - dmg); if (boss.hp <= 0) { boss.state = "dead"; boss.anim.set("death", false); playSfx("b_die", 0.8); return; } playSfx("b_hurt", 0.5); if (wasPhase1 && boss.hp < CFG.BOSS_HP * 0.5) playSfx("roar", 0.8); // phase-2 enrage roar // hyperarmor during a cleave: damage lands but no flinch if (boss.state !== "cleave") { boss.state = "hit"; boss.t = 0; boss.anim.set("take_hit", false); } } function hitPlayer(dmg) { if (player.state === "dead" || playerIframe()) return; if (player.shield > 0) { player.shieldFlash = 0.22; playSfx("shield", 0.5); return; } // grace shield absorbs it let d = dmg; if (player.state === "block") { player.stam -= CFG.STAM_BLOCK; if (player.stam <= 0) { player.stam = 0; player.state = "stagger"; player.t = 0; } else { d *= CFG.BLOCK_MIT; } } player.hp = Math.max(0, player.hp - d); if (player.hp <= 0) { player.state = "dead"; player.t = 0; player.anim.set("death", false); playSfx("p_die", 0.8); return; } playSfx("p_hurt", 0.5); if (player.state !== "stagger" && player.state !== "block") { player.state = "hit"; player.t = 0; } } function bossState() { return { arenaW: W, cleaveReach: CFG.CLEAVE_REACH, bossX: boss.x, playerX: player.x, bossHP: boss.hp / CFG.BOSS_HP, playerHP: player.hp / CFG.PLAYER_HP, bossCooldown: boss.cd > 0 ? boss.cd : 0, difficulty: difficulty, playerAttacking: player.state === "attack", playerApproaching: (player.state === "run") && (Math.sign(player.facing) === Math.sign(boss.x - player.x)), playerRecovering: playerRecovering(), playerBlocking: player.state === "block", playerThreat: player.state === "attack" && dist() <= CFG.PATK_REACH + 12, }; } async function requestDecision() { boss.pending = true; const st = bossState(); let tel; try { tel = await window.MM_DECIDE(st); } catch (e) { tel = fallbackBrain(st); } boss.pending = false; if (boss.state === "dead" || game !== "fight") return; boss.telemetry = tel; lastDecision = tel; applyBossAction(tel.action); // log this decision for the online learner (state + action + HP at decision time) fightLog.push({ state: st, action: tel.action, bossHP: st.bossHP, playerHP: st.playerHP }); updatePanel(tel); } function applyBossAction(action) { boss.action = action; boss.actT = 0; if (action === "CLEAVE" && boss.cd <= 0) { startCleave(); } else if (action === "APPROACH") { boss.state = "approach"; } else if (action === "RETREAT") { boss.state = "retreat"; } else if (action === "BLOCK") { startBlock(); } else { boss.state = "idle"; } } function startCleave() { boss.state = "cleave"; boss.cleavePhase = "windup"; boss.t = 0; boss.cleaveHit = false; const sp = phase() === 2 ? 1.25 : 1.0; boss.anim.set("cleave", false, sp); playSfx("b_cleave", 0.6); } function startBlock() { boss.state = "block"; boss.t = 0; boss.blockFlash = 0; boss.anim.set("idle", true); // no block frame in the sheet -> braced idle + guard FX playSfx("b_block", 0.35); } function bossUpdate(dt) { if (boss.cd > 0) boss.cd -= dt; if (boss.state === "dead") { boss.anim.set("death", false); return; } const ph = phase(); const moveMul = ph === 2 ? 1.18 : 1.0; if (boss.state === "hit") { boss.t += dt; if (boss.anim.done) { boss.state = "idle"; boss.think = CFG.THINK_MIN; } boss.anim.set("take_hit", false); return; } if (boss.state === "block") { boss.t += dt; if (boss.blockFlash > 0) boss.blockFlash -= dt; boss.anim.set("idle", true); if (boss.t >= CFG.BOSS_BLOCK_TIME) { boss.state = "idle"; boss.actT = 0; boss.think = CFG.THINK_MIN; } return; } if (boss.state === "cleave") { cleaveUpdate(dt, ph); return; } if (boss.state === "approach" || boss.state === "retreat") { boss.actT += dt; const sign = boss.state === "approach" ? 1 : -1; const toward = player.x > boss.x ? 1 : -1; boss.x += sign * toward * CFG.BOSS_MOVE * moveMul * dt; clampX(boss); enforceGap(); // approach = walk forward; retreat = reversed walk so it backsteps (feet move // backward) while still facing the player, instead of moonwalking. const backstep = boss.state === "retreat"; boss.anim.set("walk", true, backstep ? 1.0 : 1.1, backstep); if (boss.actT >= CFG.COMMIT_MOVE) tryDecide(); return; } // idle boss.anim.set("idle", true); boss.actT += dt; if (boss.actT >= CFG.COMMIT_IDLE) tryDecide(); } function tryDecide() { if (boss.pending) return; if (boss.think > 0) { boss.think -= 1 / 60; return; } requestDecision(); boss.think = CFG.THINK_MIN; } function cleaveUpdate(dt, ph) { boss.t += dt; const spMul = ph === 2 ? 1.25 : 1.0; const wu = CFG.CLEAVE_WINDUP / spMul, ac = CFG.CLEAVE_ACTIVE / spMul, rc = CFG.CLEAVE_RECOVER / spMul; if (boss.t < wu) { boss.cleavePhase = "windup"; } else if (boss.t < wu + ac) { boss.cleavePhase = "active"; const toward = player.x > boss.x ? 1 : -1; boss.x += toward * CFG.CLEAVE_LUNGE * spMul * dt; clampX(boss); enforceGap(); if (!boss.cleaveHit && dist() <= CFG.CLEAVE_REACH && !playerIframe()) { hitPlayer(CFG.CLEAVE_DMG); boss.cleaveHit = true; } } else if (boss.t < wu + ac + rc) { boss.cleavePhase = "recover"; } else { boss.cd = CFG.CLEAVE_COOLDOWN; boss.state = "idle"; boss.actT = 0; boss.think = CFG.THINK_MIN; } } // ---- JS fallback brain (only if the Python call fails) ----------------- function fallbackBrain(s) { const inRange = Math.abs(s.playerX - s.bossX) <= s.cleaveReach; const ready = !(s.bossCooldown > 0); let action = "IDLE"; if (inRange && ready) action = "CLEAVE"; else if (!inRange) action = "APPROACH"; else if (inRange && !ready) action = "RETREAT"; const mk = a => ({ name: a, owns: null, color: "#888", drive: 0, latent_norm: 0 }); return { action, phase: s.bossHP < 0.5 ? 2 : 1, trained: false, fallback: true, specialists: [], base_drive: {}, modulation: {}, final_drive: {}, probs: {}, legal: {}, shared_latent: [] }; } // ===================== RENDER ===================== function drawArena(ctx) { // sky const g = ctx.createLinearGradient(0, 0, 0, Hgt); g.addColorStop(0, "#241726"); g.addColorStop(0.6, "#160f1c"); g.addColorStop(1, "#0a0710"); ctx.fillStyle = g; ctx.fillRect(0, 0, W, Hgt); // distant pillars ctx.fillStyle = "rgba(40,30,52,.6)"; for (let i = 0; i < 6; i++) { const x = 60 + i * 150; ctx.fillRect(x, 120, 46, GROUND - 120); } // ground ctx.fillStyle = "#0c0a12"; ctx.fillRect(0, GROUND, W, Hgt - GROUND); ctx.fillStyle = "rgba(202,161,90,.10)"; ctx.fillRect(0, GROUND, W, 3); // fog ctx.fillStyle = "rgba(160,27,27,.05)"; ctx.fillRect(0, GROUND - 40, W, 40); } function drawShadow(ctx, x, w) { ctx.save(); ctx.globalAlpha = .35; ctx.fillStyle = "#000"; ctx.beginPath(); ctx.ellipse(x, GROUND + 2, w, 7, 0, 0, Math.PI * 2); ctx.fill(); ctx.restore(); } function drawTelegraph(ctx) { if (boss.state === "cleave" && boss.cleavePhase === "windup") { const reach = CFG.CLEAVE_REACH; ctx.save(); ctx.fillStyle = "rgba(210,59,47,.18)"; const x0 = boss.facing > 0 ? boss.x : boss.x - reach; ctx.fillRect(x0, GROUND - 90, reach, 90); ctx.strokeStyle = "rgba(210,59,47,.55)"; ctx.lineWidth = 2; ctx.strokeRect(x0, GROUND - 90, reach, 90); ctx.restore(); } } function drawShield(ctx) { if (player.shield <= 0) return; const frac = player.shield / CFG.SHIELD_TIME; const cx = player.x, cy = GROUND - 52; const pulse = 1 + 0.05 * Math.sin(performance.now() / 140); const rx = 46 * pulse, ry = 64 * pulse; ctx.save(); // soft fill ctx.globalAlpha = 0.10 + 0.16 * frac + (player.shieldFlash > 0 ? 0.4 : 0); ctx.fillStyle = "#5fd9ff"; ctx.beginPath(); ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2); ctx.fill(); // ring ctx.globalAlpha = 0.5 + 0.4 * frac + (player.shieldFlash > 0 ? 0.4 : 0); ctx.lineWidth = 2; ctx.strokeStyle = "#bff0ff"; ctx.beginPath(); ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2); ctx.stroke(); ctx.restore(); } function drawBossGuard(ctx) { if (boss.state !== "block") return; const cx = boss.x + boss.facing * 52, cy = GROUND - 70; const flash = boss.blockFlash > 0 ? 0.5 : 0; ctx.save(); // a hexagonal guard ward in front of the boss, facing the player ctx.translate(cx, cy); ctx.strokeStyle = "#ff7a3c"; ctx.fillStyle = "rgba(255,120,60," + (0.12 + flash) + ")"; ctx.lineWidth = 2.5; ctx.beginPath(); for (let i = 0; i < 6; i++) { const a = Math.PI / 2 + i * Math.PI / 3; const px = Math.cos(a) * 26 * boss.facing, py = Math.sin(a) * 56; i ? ctx.lineTo(px, py) : ctx.moveTo(px, py); } ctx.closePath(); ctx.fill(); ctx.globalAlpha = 0.7 + flash; ctx.stroke(); ctx.restore(); } function render() { const ctx = window.__mmctx; drawArena(ctx); drawTelegraph(ctx); drawShadow(ctx, boss.x, 64); drawShadow(ctx, player.x, 34); // draw order by x depth feel: boss behind if further; simple: boss first boss.anim.draw(ctx, boss.x, boss.facing, boss.scale); drawBossGuard(ctx); player.anim.draw(ctx, player.x, player.facing, player.scale); drawShield(ctx); } // ===================== HUD / PANEL ===================== function setW(id, frac) { const e = document.getElementById(id); if (e) e.style.width = (Math.max(0, Math.min(1, frac)) * 100) + "%"; } function updateHUD() { setW("mm-hp-boss", boss.hp / CFG.BOSS_HP); setW("mm-hp-php", player.hp / CFG.PLAYER_HP); setW("mm-stam", player.stam / CFG.STAM_MAX); const row = document.getElementById("mm-shield-row"); if (row) { setW("mm-shield", player.shield / CFG.SHIELD_TIME); row.style.opacity = player.shield > 0 ? "1" : "0.25"; } } const ACTIONS = ["IDLE", "APPROACH", "RETREAT", "CLEAVE", "BLOCK"]; function updatePanel(t) { const dec = document.getElementById("mm-act"); if (dec) dec.textContent = t.action; const ph = document.getElementById("mm-phase"); if (ph) { ph.textContent = "PHASE " + t.phase; ph.className = "mm-phase p" + t.phase; } // specialists const wrap = document.getElementById("mm-specs"); if (!wrap) return; let maxDrive = 0.001; (t.specialists || []).forEach(s => { if (s.drive != null) maxDrive = Math.max(maxDrive, Math.abs(s.drive)); }); let html = ""; (t.specialists || []).forEach(s => { const isMod = s.owns === null; const winning = (s.owns === t.action); const val = isMod ? s.latent_norm : s.drive; const norm = isMod ? Math.min(1, (s.latent_norm || 0) / 3) : Math.max(0, (s.drive || 0) / maxDrive); html += `