// 入口:游戏循环 / 状态机 import { LEVELS, findLevel } from './levels/index.js'; import { Level } from './level.js'; import { Player } from './player.js'; import { Camera } from './camera.js'; import { Input } from './input.js'; import { UI } from './ui.js'; import { Save } from './save.js'; import { sfx, unlockAudio, Audio2 } from './audio.js'; import { Editor } from './editor.js'; import { clamp } from './util.js'; const W = 800, H = 608; const STEP = 1000 / 60; const canvas = document.getElementById('game'); const ctx = canvas.getContext('2d', { alpha: false }); ctx.imageSmoothingEnabled = false; const G = { state: 'menu', // menu | playing | paused | result | editor level: null, player: null, cam: null, data: null, idx: -1, deaths: 0, ms: 0, hudTick: 0, time: 0, lastLevelId: null, }; // ---------------- 关卡流程 ---------------- function startLevel(data, idx = -1) { unlockAudio(); G.data = data; G.idx = idx; G.lastLevelId = data.id; G.level = new Level(data); G.player = new Player(G.level); G.cam = new Camera(W, H); G.cam.reset(G.level, G.player); G.player.onDeath = () => { G.deaths++; }; G.player.onClear = () => finish(); G.level.bosses.forEach(b => { b.onDefeat = () => finish(); }); G.deaths = 0; G.ms = 0; G.hudTick = 0; G.state = 'playing'; UI.showGame(); UI.toast(''); if (data.hint) UI.toast(data.hint); } /** 整关从头再来:保留死亡数,丢弃所有存档点进度 */ function restart() { if (!G.data) return; const d = G.deaths; startLevel(G.data, G.idx); G.deaths = d; // 死亡数不重置 } /** 自杀(IWBT 的 R 键):死后自动回到最近的存档点 */ function suicide() { if (!G.player || G.state !== 'playing') return; G.player.kill(); } function finish() { if (G.state !== 'playing') return; G.state = 'result'; sfx.goal(); const apples = G.player.apples || 0; const total = G.level.totalApples || 0; const better = Save.clear(G.data.id, G.deaths, G.ms, apples); const hasNext = G.idx >= 0 && G.idx + 1 < LEVELS.length; UI.result({ name: G.data.name, ms: G.ms, deaths: G.deaths, apples, totalApples: total, better, hasNext, }); } function nextLevel() { if (G.idx >= 0 && G.idx + 1 < LEVELS.length) startLevel(LEVELS[G.idx + 1], G.idx + 1); else toLevels(); } function toLevels() { G.state = 'menu'; UI.show('scr-levels'); } function toTitle() { G.state = 'menu'; UI.show('scr-title'); } function pause() { if (G.state === 'playing') { G.state = 'paused'; UI.show('scr-pause'); } } function resume() { if (G.state === 'paused') { G.state = 'playing'; UI.showGame(); } } // ---------------- 更新 ---------------- function update() { const L = G.level, P = G.player; // R = 自杀回存档点;Backspace = 整关重开 if (Input.hit('suicide')) { suicide(); return; } if (Input.hit('restart')) { restart(); return; } if (Input.hit('pause')) { pause(); return; } const wasDead = P.dead; L.update(1, P, G.cam); for (const b of L.bosses) b.update(1, P); P.update(1); // 刚从死亡中复活:镜头立刻对准重生点,别慢慢飘过去 if (wasDead && !P.dead) G.cam.snapTo(P, L); G.cam.update(L, P, 1); G.ms += STEP; G.time++; if (++G.hudTick % 5 === 0) { UI.hud({ name: G.data.name, deaths: G.deaths, ms: G.ms, apples: P.apples, totalApples: L.totalApples, hasGun: P.hasGun, save: P.saveIdx || 0, totalSaves: L.totalSaves || 0, }); const boss = L.bosses.find(b => !b.dead) || L.bosses[0]; UI.bossBar(L.bosses.length ? boss : null); } } // ---------------- 渲染 ---------------- function render() { const t = G.time; if (G.state === 'playing' || G.state === 'paused' || G.state === 'result') { const L = G.level, P = G.player, C = G.cam; ctx.fillStyle = '#0a0a14'; ctx.fillRect(0, 0, W, H); L.drawBg(ctx, C, t); ctx.save(); C.apply(ctx); L.draw(ctx, C, t); for (const b of L.bosses) b.draw(ctx, t); P.draw(ctx); ctx.restore(); L.drawOverlay(ctx, C, t); UI.toast(L.toast || ''); // 死亡时的血色闪烁 if (P.dead) { ctx.fillStyle = 'rgba(200,0,40,.18)'; ctx.fillRect(0, 0, W, H); } } else { drawTitleBg(t); } } function drawTitleBg(t) { const g = ctx.createLinearGradient(0, 0, 0, H); g.addColorStop(0, '#181828'); g.addColorStop(1, '#08080f'); ctx.fillStyle = g; ctx.fillRect(0, 0, W, H); // 漂浮的刺 ctx.globalAlpha = 0.5; for (let i = 0; i < 26; i++) { const x = ((i * 137 + t * 0.15) % (W + 60)) - 30; const y = 120 + ((i * 71) % (H - 160)); ctx.fillStyle = i % 3 === 0 ? '#3a3a4c' : '#2a2a38'; ctx.beginPath(); ctx.moveTo(x, y + 18); ctx.lineTo(x + 11, y); ctx.lineTo(x + 22, y + 18); ctx.closePath(); ctx.fill(); } ctx.globalAlpha = 1; // 底部刺海 ctx.fillStyle = '#1a1a26'; ctx.fillRect(0, H - 30, W, 30); ctx.fillStyle = '#c8c8d8'; for (let x = 0; x < W; x += 22) { ctx.beginPath(); ctx.moveTo(x, H - 4); ctx.lineTo(x + 11, H - 26); ctx.lineTo(x + 22, H - 4); ctx.closePath(); ctx.fill(); } } // ---------------- 主循环 ---------------- let last = performance.now(), acc = 0; function loop(now) { requestAnimationFrame(loop); let dt = now - last; last = now; if (dt > 200) dt = 200; acc += dt; let guard = 0; while (acc >= STEP && guard++ < 6) { acc -= STEP; if (G.state === 'playing') update(); else if (G.state === 'paused' && Input.hit('pause')) resume(); Input.endFrame(); } if (G.state !== 'playing') G.time++; render(); } // ---------------- 启动 ---------------- Save.load(); Audio2.enabled = Save.data.audio !== false; UI.init({ play: (lv) => startLevel(lv, lv.builtin === false ? -1 : (lv.index ?? -1)), levels: toLevels, help: () => UI.show('scr-help'), editor: () => { G.state = 'editor'; UI.show('scr-editor'); Editor.open(); }, back: toTitle, resume, restart: () => { restart(); }, quit: toLevels, retry: () => startLevel(G.data, G.idx), next: nextLevel, wipe: () => { if (confirm('确定清除所有通关记录与自制关卡?')) { Save.wipe(); UI.renderLevels(); } }, }); Editor.init({ startLevel: (data) => startLevel(data, -1), refresh: () => UI.renderLevels(), }); // 首次交互解锁音频 const unlock = () => { unlockAudio(); removeEventListener('pointerdown', unlock); removeEventListener('keydown', unlock); }; addEventListener('pointerdown', unlock); addEventListener('keydown', unlock); UI.show('scr-title'); requestAnimationFrame(loop); // 调试钩子 window.__IW = { G, LEVELS, Save, startLevel };