Spaces:
Running on Zero
Running on Zero
| /* 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 += `<div class="mm-spec ${winning ? "win" : ""}"> | |
| <div class="top"><span class="nm"><span class="dot" style="background:${s.color}"></span>${s.name}` | |
| + (isMod ? `<span class="mod-tag">latent-only</span>` : `<span class="owns">→ ${s.owns}</span>`) | |
| + `</span><span class="val">${val == null ? "" : (isMod ? "‖z‖ " : "") + val.toFixed(2)}</span></div> | |
| <div class="track"><i style="width:${(norm * 100).toFixed(0)}%;background:${s.color}"></i></div></div>`; | |
| }); | |
| wrap.innerHTML = html; | |
| // shared latent bars | |
| const lat = document.getElementById("mm-latent"); | |
| if (lat && t.shared_latent) { | |
| const mx = Math.max(0.5, ...t.shared_latent.map(Math.abs)); | |
| lat.innerHTML = t.shared_latent.map(v => | |
| `<i style="height:${(Math.abs(v) / mx * 100).toFixed(0)}%;opacity:${v < 0 ? .45 : 1}"></i>`).join(""); | |
| } | |
| const flow = document.getElementById("mm-flow"); | |
| if (flow) { | |
| const mod = t.modulation || {}; | |
| const cl = mod.CLEAVE || 0, ap = mod.APPROACH || 0; | |
| flow.innerHTML = t.fallback | |
| ? `<b>offline fallback</b> — Python brain unreachable, using heuristic.` | |
| : `RecursiveLink → coordinator modulation: <b>CLEAVE ${cl >= 0 ? "+" : ""}${cl.toFixed(2)}</b>, ` | |
| + `APPROACH ${ap >= 0 ? "+" : ""}${ap.toFixed(2)}. Modulators (Punisher/Enrage) act only here.`; | |
| } | |
| } | |
| // ===================== LOOP ===================== | |
| let last = 0; | |
| function loop(ts) { | |
| const dt = Math.min(0.05, (ts - last) / 1000 || 0); last = ts; | |
| if (game === "fight") { | |
| playerUpdate(dt); bossUpdate(dt); | |
| updateFacings(); | |
| setPlayerAnim(); | |
| player.anim.update(dt); boss.anim.update(dt); | |
| updateHUD(); | |
| if (player.hp <= 0 && game === "fight") endGame(false); | |
| else if (boss.hp <= 0 && game === "fight" && boss.anim.name === "death" && boss.anim.done) endGame(true); | |
| else if (boss.hp <= 0 && game === "fight") { /* play death anim out */ } | |
| } else { | |
| player.anim.update(dt); boss.anim.update(dt); | |
| } | |
| render(); | |
| requestAnimationFrame(loop); | |
| } | |
| function endGame(win) { | |
| if (win) { game = "win"; } | |
| else { game = "dead"; } | |
| stopMusic(); | |
| // hand the fight to the online learner (it only trains on HARD-tier fights) | |
| if (window.MM_LEARN && fightLog.length > 1) { | |
| try { window.MM_LEARN({ difficulty, steps: fightLog, result: { bossDied: win, playerDied: !win } }); } | |
| catch (e) { /* best-effort */ } | |
| } | |
| showOverlay(win ? "win" : "dead"); | |
| } | |
| // ===================== OVERLAY / FLOW ===================== | |
| function showOverlay(kind) { | |
| const ov = document.getElementById("mm-overlay"); | |
| const big = document.getElementById("mm-big"); | |
| const sub = document.getElementById("mm-sub"); | |
| const btn = document.getElementById("mm-start-btn"); | |
| ov.classList.remove("hidden"); | |
| if (kind === "menu") { | |
| big.className = ""; big.textContent = "BOSS FIGHT"; | |
| sub.innerHTML = `A Modular Mind controls the <b>Demon Slime</b>. Six tiny specialists vote through a shared latent; the trained brain picks each move. You start with a brief <b style="color:#bff0ff">Aegis shield</b> — a few seconds to learn the controls before it fades. Defeat the boss.`; | |
| btn.textContent = "Enter the Fog"; | |
| } else if (kind === "dead") { | |
| big.className = "died"; big.textContent = "YOU DIED"; | |
| sub.innerHTML = `The Modular Mind read you. Watch the panel — punish its <b>recovery</b>, dodge the <b>red telegraph</b>.`; | |
| btn.textContent = "Try Again"; | |
| } else { | |
| big.className = "win"; big.textContent = "VICTORY"; | |
| sub.innerHTML = `Demon Slime felled. You out-played a brain trained by self-play reinforcement learning.`; | |
| btn.textContent = "Fight Again"; | |
| } | |
| } | |
| function startFight() { | |
| boss.x = 250; boss.hp = CFG.BOSS_HP; boss.state = "idle"; boss.cd = 0; boss.t = 0; | |
| boss.actT = 0; boss.pending = false; boss.action = "IDLE"; boss.think = 0; | |
| player.x = 650; player.hp = CFG.PLAYER_HP; player.stam = CFG.STAM_MAX; player.state = "idle"; player.t = 0; | |
| player.shield = CFG.SHIELD_TIME; player.shieldFlash = 0; | |
| fightLog = []; | |
| boss.anim.set("idle", true); player.anim.set("idle", true); | |
| document.getElementById("mm-overlay").classList.add("hidden"); | |
| game = "fight"; | |
| initAudio(); playSfx("ui", 0.4); playSfx("roar", 0.7); startMusic(); | |
| } | |
| // ===================== INPUT ===================== | |
| function bindInput() { | |
| document.addEventListener("keydown", e => { | |
| if (["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", " "].includes(e.key)) e.preventDefault(); | |
| keys[e.key] = true; | |
| if (e.key === "m" || e.key === "M") toggleMute(); | |
| if (game !== "fight") return; | |
| if (e.key === " ") playerRoll(); | |
| if (e.key === "j" || e.key === "J") playerAttack(); | |
| }, { passive: false }); | |
| document.addEventListener("keyup", e => { keys[e.key] = false; }); | |
| document.getElementById("mm-start-btn").addEventListener("click", startFight); | |
| document.getElementById("mm-overlay").addEventListener("click", e => { | |
| if (e.target.closest(".mm-diff")) return; // clicking the selector isn't "start" | |
| if (e.target.id === "mm-start-btn") return; | |
| if (game !== "fight") startFight(); | |
| }); | |
| const mute = document.getElementById("mm-mute"); | |
| if (mute) mute.addEventListener("click", e => { e.stopPropagation(); toggleMute(); }); | |
| // difficulty selector | |
| document.querySelectorAll(".mm-diff-btn").forEach(btn => { | |
| btn.addEventListener("click", e => { | |
| e.stopPropagation(); | |
| difficulty = btn.dataset.diff; | |
| document.querySelectorAll(".mm-diff-btn").forEach(b => b.classList.toggle("mm-diff-on", b === btn)); | |
| const tag = document.getElementById("mm-difftag"); | |
| if (tag) tag.textContent = difficulty.toUpperCase(); | |
| }); | |
| }); | |
| } | |
| // ===================== BOOT ===================== | |
| async function boot() { | |
| imgs.boss = await loadImg(A.boss.image); | |
| imgs.knight = await loadImg(A.knight.image); | |
| boss.anim = new Anim("boss"); player.anim = new Anim("knight"); | |
| const cv = document.getElementById("mm-canvas"); | |
| cv.width = W; cv.height = Hgt; window.__mmctx = cv.getContext("2d"); | |
| window.__mmctx.imageSmoothingEnabled = false; | |
| bindInput(); | |
| showOverlay("menu"); | |
| // lightweight debug hook (handy for testing; harmless in normal play) | |
| window.__mmDebug = { | |
| get boss() { return boss; }, get player() { return player; }, | |
| get game() { return game; }, get keys() { return keys; }, | |
| hurtBoss: d => hitBoss(d), hurtPlayer: d => hitPlayer(d), faces: () => updateFacings(), | |
| }; | |
| requestAnimationFrame(loop); | |
| } | |
| window.__mmBoot = boot; | |
| })(); | |