335 lines
11 KiB
JavaScript
335 lines
11 KiB
JavaScript
// 入口:游戏循环 / 状态机
|
||
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 { Touch } from './touch.js';
|
||
import { sfx, unlockAudio, Audio2 } from './audio.js';
|
||
import { Editor } from './editor.js';
|
||
import { Settings } from './settings.js';
|
||
import { clamp } from './util.js';
|
||
|
||
const W = 800, H = 608;
|
||
const STEP = 1000 / 60;
|
||
|
||
// 移动端检测:触屏设备自动启用虚拟手柄;?touch=1 强制开启,?touch=0 强制关闭(桌面调试用)
|
||
// 判定条件:① 是移动端(存在 ontouchstart)/ 支持触屏(maxTouchPoints>0)/ 粗指针(触屏笔或手指)
|
||
// 只有命中才给 <html> 加 .touch 类;CSS 里 html:not(.touch) #touch 会强制隐藏,桌面端永远不显示按钮
|
||
const _qp = new URLSearchParams(location.search);
|
||
const _force = _qp.get('touch');
|
||
const _isTouch = ('ontouchstart' in window) || navigator.maxTouchPoints > 0 || matchMedia('(pointer: coarse)').matches;
|
||
const useTouch = _force === '1' || (_force !== '0' && _isTouch);
|
||
if (useTouch) document.documentElement.classList.add('touch');
|
||
function showTouch() { if (useTouch) Touch.show(); }
|
||
|
||
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,
|
||
map: null, face: 0, // 多面地图:map=各面 Level 数组,face=当前面下标
|
||
fromEditor: false, // 是否从编辑器「试玩」进入(决定暂停菜单末项去向)
|
||
deaths: 0, ms: 0, hudTick: 0,
|
||
time: 0,
|
||
lastLevelId: null,
|
||
};
|
||
|
||
// ---------------- 关卡流程 ----------------
|
||
// 把一张「面」的数据包装成 Level 可用的 data(门目标来自面自身的 doors)
|
||
function faceData(map, face, i) {
|
||
return {
|
||
id: (map.id || 'map') + '-' + i,
|
||
name: face.name || ('面' + (i + 1)),
|
||
type: 'custom',
|
||
width: face.width || (face.grid[0] ? face.grid[0].length : 25),
|
||
grid: face.grid,
|
||
doors: face.doors || [],
|
||
bg: 'custom', hint: '', messages: [],
|
||
};
|
||
}
|
||
|
||
function startLevel(data, idx = -1) {
|
||
unlockAudio();
|
||
G.data = data; G.idx = idx; G.lastLevelId = data.id;
|
||
// 自制多面地图:data.faces 存在则构建每面一个 Level;否则按传统单关处理
|
||
if (data.faces && data.faces.length) {
|
||
G.map = data.faces.map((f, i) => { const L = new Level(faceData(data, f, i)); L.faceIndex = i; return L; });
|
||
G.face = Math.max(0, Math.min(data.startFace || 0, G.map.length - 1));
|
||
} else {
|
||
G.map = [new Level(data)]; G.face = 0;
|
||
}
|
||
G.level = G.map[G.face];
|
||
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.player.onPortal = (e) => doPortal(e);
|
||
G.player.onRespawnFace = () => doRespawnFace();
|
||
G.level.bosses.forEach(b => { b.onDefeat = () => finish(); });
|
||
G.deaths = 0; G.ms = 0; G.hudTick = 0;
|
||
G.state = 'playing';
|
||
UI.showGame();
|
||
showTouch();
|
||
UI.toast('');
|
||
if (data.hint) UI.toast(data.hint);
|
||
}
|
||
|
||
// 把玩家放到某面某门上(不切面的同面传送也用)
|
||
function placeOnDoor(dest) {
|
||
const P = G.player;
|
||
P.x = dest.x + dest.w / 2 - P.w / 2;
|
||
P.y = dest.y + dest.h - P.h;
|
||
P.vx = 0; P.vy = 0; P.jumps = 0;
|
||
}
|
||
|
||
// 踩到门:e.to = {face, door}
|
||
function doPortal(e) {
|
||
const to = e.to;
|
||
const tp = Math.max(0, Math.min(G.map.length - 1, to.face | 0));
|
||
if (tp === G.face) {
|
||
const dest = G.map[tp].entities.find(o => o.type === 'warp' && o.doorId === to.door);
|
||
if (dest) { placeOnDoor(dest); dest.cool = 40; }
|
||
sfx.warp();
|
||
} else {
|
||
switchFace(tp, to.door);
|
||
}
|
||
}
|
||
|
||
// 切换到第 f 面,并落到该面的目标门上
|
||
function switchFace(f, doorId) {
|
||
G.face = f;
|
||
G.level = G.map[f];
|
||
G.level.reset(true); // 进入该面时重置其运行时
|
||
G.player.level = G.level;
|
||
const dest = G.level.entities.find(o => o.type === 'warp' && o.doorId === doorId);
|
||
if (dest) { placeOnDoor(dest); dest.cool = 40; }
|
||
else { const sp = G.level.spawn; G.player.x = sp.x; G.player.y = sp.y; G.player.vx = 0; G.player.vy = 0; }
|
||
G.cam.reset(G.level, G.player);
|
||
sfx.warp();
|
||
G.level.particles.spawn(G.player.cx, G.player.cy, 12, { colors: ['#a86bff', '#fff'], speed: 2.5, life: 24 });
|
||
}
|
||
|
||
// 复活回到存档点所在面(跨面)
|
||
function doRespawnFace() {
|
||
const sp = G.player.savePoint;
|
||
if (sp && typeof sp.face === 'number' && sp.face !== G.face) {
|
||
G.face = sp.face;
|
||
G.level = G.map[sp.face];
|
||
G.level.reset(true);
|
||
G.player.level = G.level;
|
||
G.cam.reset(G.level, G.player);
|
||
}
|
||
}
|
||
|
||
/** 整关从头再来:保留死亡数,丢弃所有存档点进度 */
|
||
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 shoot() {
|
||
const P = G.player;
|
||
if (G.state !== 'playing' || !P || !P.alive || !P.hasGun || P.shootCool !== 0) return;
|
||
P.shoot();
|
||
}
|
||
|
||
function finish() {
|
||
if (G.state !== 'playing') return;
|
||
G.state = 'result';
|
||
Touch.hide();
|
||
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'); Touch.hide(); }
|
||
function toTitle() { G.state = 'menu'; UI.show('scr-title'); Touch.hide(); }
|
||
function toEditor() {
|
||
G.state = 'editor';
|
||
UI.show('scr-editor');
|
||
Touch.hide();
|
||
// 试玩后返回:保留正在编辑的地图;从未进过编辑器时才新建
|
||
if (Editor.faces) Editor.show(); else Editor.open();
|
||
}
|
||
function pause() {
|
||
if (G.state !== 'playing') return;
|
||
G.state = 'paused';
|
||
// 暂停菜单末项:从编辑器试玩进来 → 「返回编辑器」;否则 → 「返回关卡选择」
|
||
const q = document.querySelector('#scr-pause [data-act="quit"]');
|
||
if (q) q.textContent = G.fromEditor ? '返回编辑器' : '返回关卡选择';
|
||
UI.show('scr-pause');
|
||
Touch.hide();
|
||
}
|
||
function resume() { if (G.state === 'paused') { G.state = 'playing'; UI.showGame(); showTouch(); } }
|
||
|
||
// ---------------- 更新 ----------------
|
||
function update() {
|
||
const 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;
|
||
G.level.update(1, P, G.cam);
|
||
for (const b of G.level.bosses) b.update(1, P);
|
||
P.update(1);
|
||
// 刚从死亡中复活:若存档点在别的面,先切回该面,再镜头瞬移
|
||
if (P.justRespawned) {
|
||
if (P.savePoint && typeof P.savePoint.face === 'number' && P.savePoint.face !== G.face) doRespawnFace();
|
||
G.cam.snapTo(P, G.level);
|
||
P.justRespawned = false;
|
||
}
|
||
G.cam.update(G.level, P, 1);
|
||
G.ms += STEP;
|
||
G.time++;
|
||
|
||
if (++G.hudTick % 5 === 0) {
|
||
const L = G.level;
|
||
UI.hud({
|
||
name: G.data.name + (G.map.length > 1 ? ` · 面${G.face + 1}/${G.map.length}` : ''),
|
||
deaths: G.deaths, ms: G.ms,
|
||
apples: P.apples, totalApples: L.totalApples, hasGun: P.hasGun,
|
||
save: P.saveIdx || 0, totalSaves: L.totalSaves || 0,
|
||
});
|
||
Touch.setGun(P.hasGun);
|
||
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 || '');
|
||
} 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) => { G.fromEditor = false; startLevel(lv, lv.builtin === false ? -1 : (lv.index ?? -1)); },
|
||
levels: toLevels,
|
||
manage: () => UI.setManage(!UI.manage),
|
||
help: () => UI.show('scr-help'),
|
||
editor: toEditor,
|
||
settings: () => { Settings.enter(); UI.show('scr-settings'); },
|
||
'set-ok': () => Settings.confirm(),
|
||
'set-reset': () => Settings.reset(),
|
||
back: toTitle,
|
||
resume,
|
||
restart: () => { restart(); },
|
||
quit: () => (G.fromEditor ? toEditor() : toLevels()),
|
||
retry: () => startLevel(G.data, G.idx),
|
||
next: nextLevel,
|
||
wipe: () => {
|
||
if (confirm('确定清除所有通关记录与自制关卡?')) { Save.wipe(); UI.renderLevels(); }
|
||
},
|
||
});
|
||
|
||
Editor.init({
|
||
startLevel: (data) => { G.fromEditor = true; startLevel(data, -1); },
|
||
refresh: () => UI.renderLevels(),
|
||
});
|
||
|
||
// 首次交互解锁音频
|
||
const unlock = () => { unlockAudio(); removeEventListener('pointerdown', unlock); removeEventListener('keydown', unlock); };
|
||
addEventListener('pointerdown', unlock);
|
||
addEventListener('keydown', unlock);
|
||
|
||
// 移动端虚拟手柄
|
||
Touch.init({ pause, suicide, shoot });
|
||
Settings.init();
|
||
|
||
UI.show('scr-title');
|
||
requestAnimationFrame(loop);
|
||
|
||
// 调试钩子
|
||
window.__IW = { G, LEVELS, Save, startLevel, pause, resume, restart, suicide };
|