ModuleMind / web /game.js
Quazim0t0's picture
Add files using upload-large-folder tool
45e7dfb verified
Raw
History Blame Contribute Delete
30.1 kB
/* 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;
})();