679 lines
25 KiB
JavaScript
679 lines
25 KiB
JavaScript
// 关卡解析与运行时:ASCII 网格 -> tiles + 实体 + 事件
|
||
import { TILE, clamp, overlap, hashNoise } from './util.js';
|
||
import * as S from './sprites.js';
|
||
import { sfx } from './audio.js';
|
||
import { Particles } from './particles.js';
|
||
import { Boss } from './boss.js';
|
||
|
||
export const T = {
|
||
EMPTY: 0, BLOCK: 1, GRASS: 2, BRICK: 3, METAL: 4,
|
||
ICE: 5, WATER: 6, ONEWAY: 7, KILLER: 8, VANISH: 9,
|
||
SPIKE_U: 10, SPIKE_D: 11, SPIKE_L: 12, SPIKE_R: 13,
|
||
};
|
||
const SOLID = { 1: 1, 2: 1, 3: 1, 4: 1, 5: 1, 9: 1 };
|
||
const DEADLY = { 8: 1, 10: 1, 11: 1, 12: 1, 13: 1 };
|
||
|
||
/** ASCII 字符 -> tile */
|
||
export const CHAR2TILE = {
|
||
'#': T.BLOCK, '@': T.GRASS, '=': T.BRICK, 'M': T.METAL,
|
||
'%': T.ICE, '~': T.WATER, '-': T.ONEWAY, '*': T.KILLER, 'V': T.VANISH,
|
||
'^': T.SPIKE_U, 'v': T.SPIKE_D, '<': T.SPIKE_L, '>': T.SPIKE_R,
|
||
};
|
||
/** ASCII 字符 -> 实体类型 */
|
||
const CHAR2ENT = {
|
||
S: 'spawn', G: 'save', X: 'goal', P: 'gun', F: 'apple', J: 'jumpref',
|
||
W: 'warp', T: 'sign', O: 'turret', B: 'boss', D: 'dropspike',
|
||
C: 'camzone', '!': 'trigger',
|
||
};
|
||
const ESIZE = {
|
||
spawn: [16, 26], save: [20, 22], goal: [30, 42], gun: [28, 24], apple: [18, 20],
|
||
jumpref: [26, 26], warp: [28, 28], sign: [30, 26], turret: [32, 32],
|
||
boss: [64, 64], dropspike: [32, 32], camzone: [96, 96], trigger: [32, 32],
|
||
};
|
||
|
||
export class Level {
|
||
constructor(data) {
|
||
this.data = data;
|
||
this.name = data.name || 'STAGE';
|
||
this.type = data.type || 'jump';
|
||
this.parse(data);
|
||
this.particles = new Particles();
|
||
this.reset(true);
|
||
}
|
||
|
||
// ---------------- 解析 ----------------
|
||
parse(data) {
|
||
const rows = data.grid || [];
|
||
this.h = rows.length || 19;
|
||
this.w = data.width || Math.max(...rows.map(r => r.length), 25);
|
||
this.pxW = this.w * TILE;
|
||
this.pxH = this.h * TILE;
|
||
this.tiles = [];
|
||
const srcEntities = [];
|
||
const order = { warp: 0, boss: 0, sign: 0, turret: 0, camzone: 0, trigger: 0 };
|
||
|
||
for (let y = 0; y < this.h; y++) {
|
||
const line = rows[y] || '';
|
||
const row = [];
|
||
for (let x = 0; x < this.w; x++) {
|
||
const ch = line[x] || ' ';
|
||
if (CHAR2TILE[ch] !== undefined) {
|
||
row.push(CHAR2TILE[ch]);
|
||
} else if (CHAR2ENT[ch] !== undefined) {
|
||
const type = CHAR2ENT[ch];
|
||
const [ew, eh] = ESIZE[type];
|
||
const idx = order[type] !== undefined ? order[type]++ : 0;
|
||
srcEntities.push({ type, idx, tx: x, ty: y, ch });
|
||
row.push(T.EMPTY);
|
||
} else row.push(T.EMPTY);
|
||
}
|
||
this.tiles.push(row);
|
||
}
|
||
|
||
// 构建实体
|
||
this.srcEntities = srcEntities;
|
||
this.moversSrc = data.movers || [];
|
||
this.eventSrc = data.events || [];
|
||
this.msgs = data.messages || [];
|
||
this.bossSrc = data.bosses || [];
|
||
this.warpSrc = data.warps || [];
|
||
this.turretSrc = data.turrets || [];
|
||
this.camSrc = data.camzones || [];
|
||
this.spawnExtra = data.spawn || null;
|
||
this.hint = data.hint || '';
|
||
this.bgTheme = data.bg || (this.type === 'boss' ? 'boss' : this.type === 'troll' ? 'troll' : this.type === 'endur' ? 'endur' : 'jump');
|
||
}
|
||
|
||
/** 完整重置(死亡 / 重开) */
|
||
reset(full = false) {
|
||
// tiles 状态(vanish 等)
|
||
if (full) this.deathMarks = []; // 死亡血迹只在整关重开时清空,复活后保留
|
||
this.vanish = new Map();
|
||
this.bullets = [];
|
||
this.ebullets = [];
|
||
this.entities = [];
|
||
this.movers = [];
|
||
this.bosses = [];
|
||
this.events = [];
|
||
this.time = 0;
|
||
this.toast = null;
|
||
this.toastT = 0;
|
||
this.particles?.clear();
|
||
this.shake = 0;
|
||
this.beat = false;
|
||
|
||
// 实体实例化
|
||
let saveNo = 0;
|
||
for (const s of this.srcEntities) {
|
||
const [ew, eh] = ESIZE[s.type];
|
||
const e = {
|
||
type: s.type, idx: s.idx,
|
||
tx: s.tx, ty: s.ty,
|
||
x: s.tx * TILE + (TILE - ew) / 2,
|
||
y: s.ty * TILE + (TILE - eh),
|
||
w: ew, h: eh,
|
||
active: false, dead: false, t: 0,
|
||
};
|
||
if (s.type === 'goal') e.y = s.ty * TILE + TILE - eh;
|
||
if (s.type === 'save') { e.fake = false; e.no = ++saveNo; }
|
||
if (s.type === 'sign') e.text = this.msgs[e.idx] || '...';
|
||
if (s.type === 'turret') {
|
||
const cfg = this.turretSrc[e.idx] || {};
|
||
e.dir = cfg.dir || 'left';
|
||
e.period = cfg.period || 90;
|
||
e.speed = cfg.speed || 2.4;
|
||
e.timer = cfg.offset || 0;
|
||
e.kind = cfg.kind || 'ball';
|
||
}
|
||
if (s.type === 'warp') {
|
||
const cfg = this.warpSrc[e.idx] || {};
|
||
// 自制多面地图:门的目标直接挂在 door.to 上
|
||
// door.to 可以是跨面 {face, door}(自制门),或原有 'spawn' / {x,y}(内置同关传送)
|
||
const door = (this.data.doors || []).find(d => d.tx === s.tx && d.ty === s.ty);
|
||
e.doorId = door ? door.id : e.idx;
|
||
e.pair = cfg.pair !== undefined ? cfg.pair : (e.idx % 2 === 0 ? e.idx + 1 : e.idx - 1);
|
||
e.to = (door && door.to) ? door.to : (cfg.to || null);
|
||
e.cool = 0;
|
||
e.hidden = !!cfg.hidden;
|
||
}
|
||
if (s.type === 'camzone') {
|
||
const cfg = this.camSrc[e.idx] || {};
|
||
e.w = (cfg.w || 3) * TILE; e.h = (cfg.h || 3) * TILE;
|
||
e.x = s.tx * TILE; e.y = s.ty * TILE;
|
||
e.mode = cfg.mode || 'lock'; // lock | zoom | follow-off
|
||
e.zoom = cfg.zoom || 1;
|
||
e.lx = cfg.lx !== undefined ? cfg.lx * TILE : null;
|
||
e.ly = cfg.ly !== undefined ? cfg.ly * TILE : null;
|
||
}
|
||
if (s.type === 'dropspike') {
|
||
e.falling = false; e.vy = 0; e.homeY = e.y;
|
||
e.triggerX = (this.srcEntities.length ? 0 : 0);
|
||
}
|
||
if (s.type === 'boss') {
|
||
const cfg = this.bossSrc[e.idx] || { kind: 'guy' };
|
||
e.kind = cfg.kind || 'guy';
|
||
e.hp = cfg.hp || 40;
|
||
e.w = cfg.w || 64; e.h = cfg.h || 64;
|
||
e.x = s.tx * TILE + TILE / 2 - e.w / 2;
|
||
e.y = s.ty * TILE + TILE - e.h;
|
||
}
|
||
this.entities.push(e);
|
||
}
|
||
|
||
// 移动平台
|
||
for (const m of this.moversSrc) {
|
||
const path = (m.path || []).map(p => ({ x: p[0] * TILE, y: p[1] * TILE }));
|
||
const a = path[0] || { x: m.x * TILE, y: m.y * TILE };
|
||
this.movers.push({
|
||
x: a.x, y: a.y,
|
||
w: (m.w || 3) * TILE, h: (m.h || 1) * TILE,
|
||
path, i: 0, dir: 1, speed: m.speed || 1.2, t: 0,
|
||
mode: m.mode || 'loop', // loop | pingpong
|
||
dx: 0, dy: 0,
|
||
ax: a.x, ay: a.y, aw: (m.w || 3) * TILE, ah: (m.h || 1) * TILE,
|
||
bx: (path[1] || a).x, by: (path[1] || a).y,
|
||
});
|
||
}
|
||
|
||
// 事件(坑向触发器)
|
||
for (const ev of this.eventSrc) {
|
||
this.events.push({
|
||
x: ev.x * TILE, y: ev.y * TILE, w: (ev.w || 1) * TILE, h: (ev.h || 1) * TILE,
|
||
once: ev.once !== false, fired: false,
|
||
actions: ev.actions || [], hidden: ev.hidden !== false,
|
||
});
|
||
}
|
||
|
||
// 补齐未在 ASCII 中放置的摄像头区域(camzones 数组独立定义)
|
||
const camPlaced = this.srcEntities.filter(s => s.type === 'camzone').length;
|
||
for (let i = camPlaced; i < this.camSrc.length; i++) {
|
||
const cfg = this.camSrc[i];
|
||
this.entities.push({
|
||
type: 'camzone', idx: i, tx: cfg.x, ty: cfg.y,
|
||
x: cfg.x * TILE, y: cfg.y * TILE,
|
||
w: (cfg.w || 3) * TILE, h: (cfg.h || 3) * TILE,
|
||
mode: cfg.mode || 'lock', zoom: cfg.zoom || 1,
|
||
lx: cfg.lx !== undefined ? cfg.lx * TILE : null,
|
||
ly: cfg.ly !== undefined ? cfg.ly * TILE : null,
|
||
active: false, dead: false, t: 0,
|
||
});
|
||
}
|
||
|
||
// BOSS
|
||
for (const e of this.entities) {
|
||
if (e.type !== 'boss') continue;
|
||
this.bosses.push(new Boss(this, e));
|
||
}
|
||
|
||
// 出生点
|
||
const sp = this.entities.find(e => e.type === 'spawn');
|
||
this.spawn = sp
|
||
? { x: sp.x + sp.w / 2 - 5.5, y: sp.y + sp.h - 21 }
|
||
: (this.spawnExtra ? { x: this.spawnExtra[0] * TILE, y: this.spawnExtra[1] * TILE } : { x: 40, y: 40 });
|
||
// 总苹果数 / 存档点数
|
||
this.totalApples = this.entities.filter(e => e.type === 'apple').length;
|
||
this.totalSaves = saveNo;
|
||
}
|
||
|
||
/** 死亡后的软重置:保留存档点与已拾取道具,其余复位 */
|
||
softReset() {
|
||
this.vanish.clear();
|
||
if (this.vanishT) this.vanishT.clear();
|
||
this.bullets.length = 0;
|
||
this.ebullets.length = 0;
|
||
this.particles?.clear();
|
||
this.shake = 0;
|
||
for (const m of this.movers) {
|
||
const a = m.path[0];
|
||
if (a) { m.x = a.x; m.y = a.y; m.i = 0; }
|
||
m.dx = m.dy = 0;
|
||
const nx = m.path[1] || a;
|
||
m.ax = m.x; m.ay = m.y; m.bx = nx?.x ?? m.x; m.by = nx?.y ?? m.y;
|
||
}
|
||
for (const e of this.entities) {
|
||
if (e.type === 'dropspike') {
|
||
if (e.temp) { e.dead = true; continue; }
|
||
e.y = e.homeY; e.falling = false; e.vy = 0; e.landed = false; e.landT = 0;
|
||
}
|
||
if (e.type === 'warp') e.cool = 0;
|
||
}
|
||
this.entities = this.entities.filter(e => !e.dead);
|
||
for (const ev of this.events) ev.fired = false;
|
||
for (const b of this.bosses) {
|
||
b.hp = b.maxHp; b.dead = false; b.deathT = 0; b.bodyKill = true;
|
||
b.t = 0; b.state = 'idle'; b.stateT = 0; b.phase = 1;
|
||
b.x = b.homeX; b.y = b.homeY; b.vx = 0; b.vy = 0;
|
||
}
|
||
}
|
||
|
||
// 死亡标记:在死亡地点留下一个常驻「红叉」(IWBTG 风格死亡指示)。
|
||
// 只保留上一次死亡的红叉:新增前先清空,多次死亡不会累积。
|
||
addDeathMark(x, y) {
|
||
this.deathMarks.length = 0;
|
||
this.deathMarks.push({ x, y });
|
||
}
|
||
|
||
drawDeathMark(ctx, dm) {
|
||
const s = 4; // 红叉半边长(< kid 的 11×21,整体约 8px)
|
||
ctx.lineCap = 'round';
|
||
// 深色描边,保证在任意背景下都清晰
|
||
ctx.lineWidth = 3;
|
||
ctx.strokeStyle = 'rgba(0,0,0,0.55)';
|
||
ctx.beginPath();
|
||
ctx.moveTo(dm.x - s, dm.y - s); ctx.lineTo(dm.x + s, dm.y + s);
|
||
ctx.moveTo(dm.x + s, dm.y - s); ctx.lineTo(dm.x - s, dm.y + s);
|
||
ctx.stroke();
|
||
// 红色主体
|
||
ctx.lineWidth = 2;
|
||
ctx.strokeStyle = '#ff2535';
|
||
ctx.beginPath();
|
||
ctx.moveTo(dm.x - s, dm.y - s); ctx.lineTo(dm.x + s, dm.y + s);
|
||
ctx.moveTo(dm.x + s, dm.y - s); ctx.lineTo(dm.x - s, dm.y + s);
|
||
ctx.stroke();
|
||
}
|
||
|
||
// ---------------- 查询 ----------------
|
||
tileAt(tx, ty) {
|
||
if (tx < 0 || ty < 0 || tx >= this.w || ty >= this.h) return ty < 0 ? T.EMPTY : T.BLOCK;
|
||
return this.tiles[ty][tx];
|
||
}
|
||
pxTile(px, py) { return this.tileAt(Math.floor(px / TILE), Math.floor(py / TILE)); }
|
||
|
||
isSolidTile(t, tx, ty) {
|
||
if (!SOLID[t]) return false;
|
||
if (t === T.VANISH) {
|
||
const st = this.vanishState(tx, ty);
|
||
return st !== 2;
|
||
}
|
||
return true;
|
||
}
|
||
vanishState(tx, ty) { return this.vanish.get(ty * 1000 + tx) ?? 0; }
|
||
setVanish(tx, ty, st) { this.vanish.set(ty * 1000 + tx, st); }
|
||
|
||
/** 矩形是否与实心地形相交(可指定忽略单向平台的下落判定) */
|
||
solidRect(x, y, w, h, opts = {}) {
|
||
const x0 = Math.floor(x / TILE), x1 = Math.floor((x + w - 0.001) / TILE);
|
||
const y0 = Math.floor(y / TILE), y1 = Math.floor((y + h - 0.001) / TILE);
|
||
for (let ty = y0; ty <= y1; ty++) {
|
||
for (let tx = x0; tx <= x1; tx++) {
|
||
const t = this.tileAt(tx, ty);
|
||
if (t === T.ONEWAY) {
|
||
if (!opts.dropThrough && opts.prevBottom !== undefined && opts.prevBottom <= ty * TILE + 6) {
|
||
// 从上方落下:只有当前底部穿过平台面时才碰撞
|
||
if (y + h <= ty * TILE + 8) continue;
|
||
if (opts.prevBottom > ty * TILE + 2) continue;
|
||
return true;
|
||
}
|
||
continue;
|
||
}
|
||
if (this.isSolidTile(t, tx, ty)) return true;
|
||
}
|
||
}
|
||
// 移动平台
|
||
for (const m of this.movers) {
|
||
if (m.gone) continue;
|
||
if (x < m.x + m.w && x + w > m.x && y < m.y + m.h && y + h > m.y) {
|
||
if (opts.moverExclude === m) continue;
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/** 致死判定(刺 / 美味水果 / 敌人子弹) */
|
||
deadlyRect(x, y, w, h) {
|
||
const x0 = Math.floor(x / TILE), x1 = Math.floor((x + w - 0.001) / TILE);
|
||
const y0 = Math.floor(y / TILE), y1 = Math.floor((y + h - 0.001) / TILE);
|
||
for (let ty = y0; ty <= y1; ty++) {
|
||
for (let tx = x0; tx <= x1; tx++) {
|
||
const t = this.tileAt(tx, ty);
|
||
if (!DEADLY[t]) continue;
|
||
const tr = spikeTri(t, tx * TILE, ty * TILE);
|
||
if (triRectOverlap(tr.ax, tr.ay, tr.bx, tr.by, tr.cx, tr.cy, x, y, w, h)) return true;
|
||
}
|
||
}
|
||
for (const e of this.entities) {
|
||
if (e.type === 'dropspike' && e.falling) {
|
||
const tr = spikeTri(T.SPIKE_L, e.x, e.y); // 与 drawDropSpike 的 dir=2(朝左)一致
|
||
if (triRectOverlap(tr.ax, tr.ay, tr.bx, tr.by, tr.cx, tr.cy, x, y, w, h)) return true;
|
||
}
|
||
}
|
||
for (const b of this.ebullets) {
|
||
if (b.dead) continue;
|
||
const r = { x: b.x, y: b.y, w: b.w, h: b.h };
|
||
if (overlap({ x, y, w, h }, r)) return true;
|
||
}
|
||
for (const bs of this.bosses) {
|
||
if (bs.dead || !bs.bodyKill) continue;
|
||
if (overlap({ x, y, w, h }, bs)) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
waterRect(x, y, w, h) {
|
||
const x0 = Math.floor(x / TILE), x1 = Math.floor((x + w - 0.001) / TILE);
|
||
const y0 = Math.floor(y / TILE), y1 = Math.floor((y + h - 0.001) / TILE);
|
||
for (let ty = y0; ty <= y1; ty++)
|
||
for (let tx = x0; tx <= x1; tx++)
|
||
if (this.tileAt(tx, ty) === T.WATER) return true;
|
||
return false;
|
||
}
|
||
iceAtBottom(x, y, w, h) {
|
||
const tx0 = Math.floor(x / TILE), tx1 = Math.floor((x + w - 1) / TILE);
|
||
const ty = Math.floor((y + h + 1) / TILE);
|
||
for (let tx = tx0; tx <= tx1; tx++) if (this.tileAt(tx, ty) === T.ICE) return true;
|
||
return false;
|
||
}
|
||
|
||
// ---------------- 更新 ----------------
|
||
update(dt, player, cam) {
|
||
this.time += dt;
|
||
const t = this.time;
|
||
|
||
// 移动平台
|
||
for (const m of this.movers) {
|
||
if (!m.path.length) continue;
|
||
const tgt = m.path[(m.i + 1) % m.path.length];
|
||
const ox = m.x, oy = m.y;
|
||
const dx = tgt.x - m.x, dy = tgt.y - m.y;
|
||
const dist = Math.hypot(dx, dy);
|
||
if (dist <= m.speed) {
|
||
m.x = tgt.x; m.y = tgt.y;
|
||
m.i = (m.i + 1) % m.path.length;
|
||
} else {
|
||
m.x += dx / dist * m.speed;
|
||
m.y += dy / dist * m.speed;
|
||
}
|
||
m.dx = m.x - ox; m.dy = m.y - oy;
|
||
const next = m.path[(m.i + 1) % m.path.length];
|
||
m.ax = m.x; m.ay = m.y; m.bx = next.x; m.by = next.y;
|
||
}
|
||
|
||
// 消失方块计时
|
||
for (const [key, st] of this.vanish) {
|
||
if (st === 0) continue;
|
||
const tt = (this.vanishT?.get(key) || 0) + 1;
|
||
this.vanishT = this.vanishT || new Map();
|
||
this.vanishT.set(key, tt);
|
||
if (st === 1 && tt > 55) { this.vanish.set(key, 2); this.vanishT.set(key, 0); }
|
||
else if (st === 2 && tt > 110) { this.vanish.set(key, 0); this.vanishT.set(key, 0); }
|
||
}
|
||
|
||
// 掉落刺
|
||
for (const e of this.entities) {
|
||
if (e.type !== 'dropspike') continue;
|
||
if (!e.falling) {
|
||
const px = player.x + player.w / 2;
|
||
if (Math.abs(px - (e.x + e.w / 2)) < 26 && Math.abs(player.y - e.y) < 260 && player.y > e.y) {
|
||
e.falling = true; e.vy = 0;
|
||
sfx.vanish();
|
||
}
|
||
} else if (e.landed) {
|
||
// 落地后插在地上,80 帧后碎掉(否则会永久堵死落脚点)
|
||
e.landT = (e.landT || 0) + 1;
|
||
if (e.landT > 80) { e.dead = true; e.falling = false; }
|
||
} else {
|
||
e.vy = Math.min(e.vy + 0.5, 11);
|
||
e.y += e.vy;
|
||
const ty = Math.floor((e.y + e.h) / TILE);
|
||
const tx = Math.floor((e.x + e.w / 2) / TILE);
|
||
if (this.isSolidTile(this.tileAt(tx, ty), tx, ty)) {
|
||
e.y = ty * TILE - e.h; e.vy = 0; e.landed = true; e.landT = 0;
|
||
this.shake = Math.max(this.shake, 5);
|
||
}
|
||
if (e.y > this.pxH + 100) e.dead = true;
|
||
}
|
||
}
|
||
|
||
// 炮台
|
||
for (const e of this.entities) {
|
||
if (e.type !== 'turret') continue;
|
||
e.t++;
|
||
if (e.t % (e.period || 90) === 0) {
|
||
const cx = e.x + e.w / 2, cy = e.y + e.h / 2;
|
||
let vx = 0, vy = 0;
|
||
if (e.dir === 'left') vx = -e.speed;
|
||
else if (e.dir === 'right') vx = e.speed;
|
||
else if (e.dir === 'up') vy = -e.speed;
|
||
else vy = e.speed;
|
||
if (e.kind === 'aim' && player.alive) {
|
||
const dx = (player.x + player.w / 2) - cx, dy = (player.y + player.h / 2) - cy;
|
||
const d = Math.hypot(dx, dy) || 1;
|
||
vx = dx / d * e.speed; vy = dy / d * e.speed;
|
||
}
|
||
this.ebullets.push({ x: cx - 4, y: cy - 4, w: 8, h: 8, vx, vy, dead: false, col: '#ff5c7a', life: 400 });
|
||
sfx.shoot();
|
||
}
|
||
}
|
||
|
||
// 敌人子弹
|
||
for (const b of this.ebullets) {
|
||
b.x += b.vx; b.y += b.vy; b.life--;
|
||
if (b.life <= 0) b.dead = true;
|
||
const tx = Math.floor((b.x + b.w / 2) / TILE), ty = Math.floor((b.y + b.h / 2) / TILE);
|
||
if (this.isSolidTile(this.tileAt(tx, ty), tx, ty)) {
|
||
b.dead = true;
|
||
this.particles.spawn(b.x + 4, b.y + 4, 4, { colors: ['#ff5c7a', '#fff'], speed: 2, life: 14 });
|
||
}
|
||
}
|
||
this.ebullets = this.ebullets.filter(b => !b.dead);
|
||
|
||
// 玩家子弹
|
||
for (const b of this.bullets) {
|
||
b.x += b.vx; b.y += b.vy; b.life--;
|
||
if (b.life <= 0) b.dead = true;
|
||
const tx = Math.floor((b.x + b.w / 2) / TILE), ty = Math.floor((b.y + b.h / 2) / TILE);
|
||
const tt = this.tileAt(tx, ty);
|
||
if (tt === T.BLOCK || tt === T.GRASS || tt === T.BRICK || tt === T.METAL) {
|
||
b.dead = true;
|
||
this.particles.spawn(b.x + 3, b.y + 3, 5, { colors: ['#fff6a0', '#ffb040'], speed: 2.4, life: 16 });
|
||
}
|
||
}
|
||
this.bullets = this.bullets.filter(b => !b.dead);
|
||
|
||
// 事件触发
|
||
for (const ev of this.events) {
|
||
if (ev.fired && ev.once) continue;
|
||
if (overlap(player, ev)) this.fireEvent(ev, player);
|
||
}
|
||
|
||
// 摄像机区域
|
||
this.camZone = null;
|
||
for (const e of this.entities) {
|
||
if (e.type !== 'camzone') continue;
|
||
if (overlap({ x: player.x + player.w / 2 - 1, y: player.y + player.h / 2 - 1, w: 2, h: 2 }, e)) {
|
||
this.camZone = e; break;
|
||
}
|
||
}
|
||
|
||
if (this.shake > 0) this.shake *= 0.86;
|
||
if (this.toastT > 0 && --this.toastT === 0) this.toast = null;
|
||
this.particles.update();
|
||
|
||
// 死亡红叉为常驻静态标记,无需逐帧演化,复活后仍保留
|
||
}
|
||
|
||
fireEvent(ev, player) {
|
||
ev.fired = true;
|
||
for (const a of ev.actions) {
|
||
if (a.type === 'msg') { this.showToast(a.text, a.dur || 150); }
|
||
else if (a.type === 'spikes') {
|
||
// 生成一排下落刺
|
||
for (let i = 0; i < (a.count || 5); i++) {
|
||
const x = (a.x + i * (a.gap || 1)) * TILE;
|
||
this.entities.push({
|
||
type: 'dropspike', x, y: (a.y || 0) * TILE, w: TILE, h: TILE,
|
||
falling: true, vy: 0, homeY: (a.y || 0) * TILE, t: 0, temp: true,
|
||
});
|
||
}
|
||
sfx.troll();
|
||
} else if (a.type === 'vanish') {
|
||
for (let i = 0; i < (a.count || 3); i++) {
|
||
const tx = (a.x + i), ty = a.y;
|
||
if (this.tileAt(tx, ty) === T.EMPTY) continue;
|
||
this.setVanish(tx, ty, 1);
|
||
this.vanishT = this.vanishT || new Map();
|
||
this.vanishT.set(ty * 1000 + tx, 0);
|
||
}
|
||
sfx.vanish();
|
||
} else if (a.type === 'warp') {
|
||
player.x = a.x * TILE; player.y = a.y * TILE;
|
||
player.vx = 0; player.vy = 0;
|
||
sfx.warp();
|
||
} else if (a.type === 'kill') { player.kill(); }
|
||
else if (a.type === 'shake') { this.shake = a.v || 10; }
|
||
}
|
||
}
|
||
|
||
showToast(text, dur = 150) { this.toast = text; this.toastT = dur; }
|
||
|
||
// ---------------- 绘制 ----------------
|
||
drawBg(ctx, cam, t) {
|
||
const g = ctx.createLinearGradient(0, 0, 0, cam.h);
|
||
const th = {
|
||
jump: ['#1a1830', '#0a0a14'],
|
||
endur: ['#101f24', '#080c10'],
|
||
boss: ['#2a1018', '#0d0810'],
|
||
troll: ['#221432', '#0b0812'],
|
||
custom: ['#141428', '#08080f'],
|
||
}[this.bgTheme] || ['#1a1830', '#0a0a14'];
|
||
g.addColorStop(0, th[0]); g.addColorStop(1, th[1]);
|
||
ctx.fillStyle = g;
|
||
ctx.fillRect(0, 0, cam.w, cam.h);
|
||
|
||
// 远景装饰(视差)
|
||
const px = -cam.x * 0.25, py = -cam.y * 0.15;
|
||
ctx.globalAlpha = 0.16;
|
||
ctx.fillStyle = '#ffffff';
|
||
for (let i = 0; i < 60; i++) {
|
||
const nx = hashNoise(i, 7) * 2000, ny = hashNoise(i, 13) * 900;
|
||
const sx = ((nx + px) % (cam.w + 200)) - 100;
|
||
const sy = ((ny + py) % (cam.h + 120)) - 60;
|
||
ctx.fillRect(sx, sy, 2, 2);
|
||
}
|
||
ctx.globalAlpha = 1;
|
||
|
||
// 网格
|
||
ctx.strokeStyle = 'rgba(255,255,255,.035)';
|
||
ctx.lineWidth = 1;
|
||
ctx.beginPath();
|
||
const off = TILE * 2;
|
||
const sx0 = -((cam.x % off));
|
||
const sy0 = -((cam.y % off));
|
||
for (let x = sx0; x < cam.w; x += off) { ctx.moveTo(x + .5, 0); ctx.lineTo(x + .5, cam.h); }
|
||
for (let y = sy0; y < cam.h; y += off) { ctx.moveTo(0, y + .5); ctx.lineTo(cam.w, y + .5); }
|
||
ctx.stroke();
|
||
}
|
||
|
||
draw(ctx, cam, t) {
|
||
const vx0 = Math.max(0, Math.floor(cam.x / TILE) - 1);
|
||
const vx1 = Math.min(this.w - 1, Math.ceil((cam.x + cam.w / (cam.zoom || 1)) / TILE) + 1);
|
||
const vy0 = Math.max(0, Math.floor(cam.y / TILE) - 1);
|
||
const vy1 = Math.min(this.h - 1, Math.ceil((cam.y + cam.h / (cam.zoom || 1)) / TILE) + 1);
|
||
|
||
ctx.save();
|
||
// 摄像机变换由外部处理
|
||
for (let ty = vy0; ty <= vy1; ty++) {
|
||
for (let tx = vx0; tx <= vx1; tx++) {
|
||
const v = this.tiles[ty][tx];
|
||
if (v === T.EMPTY) continue;
|
||
const x = tx * TILE, y = ty * TILE;
|
||
switch (v) {
|
||
case T.BLOCK: S.drawBlock(ctx, x, y, 0); break;
|
||
case T.GRASS: S.drawBlock(ctx, x, y, 1); break;
|
||
case T.BRICK: S.drawBlock(ctx, x, y, 2); break;
|
||
case T.METAL: S.drawBlock(ctx, x, y, 3); break;
|
||
case T.ICE: S.drawIce(ctx, x, y); break;
|
||
case T.WATER: S.drawWater(ctx, x, y, t); break;
|
||
case T.ONEWAY: S.drawOneWay(ctx, x, y); break;
|
||
case T.KILLER: S.drawKiller(ctx, x, y, t); break;
|
||
case T.VANISH: S.drawVanish(ctx, x, y, this.vanishState(tx, ty), t); break;
|
||
case T.SPIKE_U: S.drawSpike(ctx, x, y, 0, t); break;
|
||
case T.SPIKE_D: S.drawSpike(ctx, x, y, 1, t); break;
|
||
case T.SPIKE_L: S.drawSpike(ctx, x, y, 2, t); break;
|
||
case T.SPIKE_R: S.drawSpike(ctx, x, y, 3, t); break;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 移动平台
|
||
for (const m of this.movers) S.drawMovingPlatform(ctx, m);
|
||
|
||
// 实体
|
||
for (const e of this.entities) {
|
||
if (e.dead) continue;
|
||
switch (e.type) {
|
||
case 'save': S.drawSave(ctx, e, t); break;
|
||
case 'goal': S.drawGoal(ctx, e, t); break;
|
||
case 'gun': if (!e.taken) S.drawGun(ctx, e, t); break;
|
||
case 'apple': if (!e.taken) S.drawApple(ctx, e, t); break;
|
||
case 'jumpref': S.drawJumpRef(ctx, e, t); break;
|
||
case 'warp': if (!e.hidden) S.drawWarp(ctx, e, t); break;
|
||
case 'sign': S.drawSign(ctx, e, t); break;
|
||
case 'turret': S.drawTurret(ctx, e, t); break;
|
||
case 'camzone': S.drawCamera(ctx, e, t); break;
|
||
case 'dropspike': S.drawDropSpike(ctx, e, t); break;
|
||
}
|
||
}
|
||
|
||
// 子弹
|
||
for (const b of this.bullets) S.drawBullet(ctx, b);
|
||
for (const b of this.ebullets) S.drawEBullet(ctx, b, t);
|
||
|
||
// 死亡血迹(IWBTG 风格,常驻于死亡地点)
|
||
for (const dm of this.deathMarks) this.drawDeathMark(ctx, dm);
|
||
|
||
this.particles.draw(ctx);
|
||
ctx.restore();
|
||
}
|
||
|
||
/** 屏幕空间 UI:提示牌文字 / toast */
|
||
drawOverlay(ctx, cam, t) {
|
||
for (const e of this.entities) {
|
||
if (e.type !== 'sign') continue;
|
||
const sx = (e.x + e.w / 2 - cam.x) * (cam.zoom || 1);
|
||
const sy = (e.y - 6 - cam.y) * (cam.zoom || 1);
|
||
if (sx < -80 || sx > cam.w + 80) continue;
|
||
ctx.font = 'bold 11px "Courier New",monospace';
|
||
ctx.textAlign = 'center';
|
||
const txt = e.text;
|
||
const wd = ctx.measureText(txt).width;
|
||
ctx.fillStyle = 'rgba(0,0,0,.72)';
|
||
ctx.fillRect(sx - wd / 2 - 5, sy - 13, wd + 10, 16);
|
||
ctx.fillStyle = '#ffe9a8';
|
||
ctx.fillText(txt, sx, sy);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 单三角形刺的顶点(与 sprites.drawSpike 完全一致)。
|
||
// 尖端指向刺的方向,底边贴在相反一侧(地面 / 天花板 / 墙面)。
|
||
function spikeTri(t, x, y) {
|
||
const s = TILE;
|
||
if (t === T.SPIKE_U) return { ax: x + s / 2, ay: y, bx: x, by: y + s, cx: x + s, cy: y + s };
|
||
if (t === T.SPIKE_D) return { ax: x + s / 2, ay: y + s, bx: x, by: y, cx: x + s, cy: y };
|
||
if (t === T.SPIKE_L) return { ax: x, ay: y + s / 2, bx: x + s, by: y, cx: x + s, cy: y + s };
|
||
if (t === T.SPIKE_R) return { ax: x + s, ay: y + s / 2, bx: x, by: y, cx: x, cy: y + s };
|
||
return { ax: x, ay: y, bx: x + s, by: y, cx: x, cy: y + s }; // 兜底(不应到达)
|
||
}
|
||
|
||
// 三角形与轴对齐矩形的相交判定(分离轴定理,凸多边形充要条件)。
|
||
function triRectOverlap(ax, ay, bx, by, cx, cy, rx, ry, rw, rh) {
|
||
const rMinX = rx, rMaxX = rx + rw, rMinY = ry, rMaxY = ry + rh;
|
||
const tMinX = Math.min(ax, bx, cx), tMaxX = Math.max(ax, bx, cx);
|
||
const tMinY = Math.min(ay, by, cy), tMaxY = Math.max(ay, by, cy);
|
||
if (tMaxX < rMinX || tMinX > rMaxX || tMaxY < rMinY || tMinY > rMaxY) return false; // AABB 快速排除
|
||
const edges = [[bx - ax, by - ay], [cx - bx, cy - by], [ax - cx, ay - cy]];
|
||
for (const [ex, ey] of edges) {
|
||
const nx = -ey, ny = ex; // 边法线
|
||
const t0 = nx * ax + ny * ay, t1 = nx * bx + ny * by, t2 = nx * cx + ny * cy;
|
||
const tMin = Math.min(t0, t1, t2), tMax = Math.max(t0, t1, t2);
|
||
const p0 = nx * rMinX + ny * rMinY, p1 = nx * rMaxX + ny * rMinY,
|
||
p2 = nx * rMinX + ny * rMaxY, p3 = nx * rMaxX + ny * rMaxY;
|
||
const rMin = Math.min(p0, p1, p2, p3), rMax = Math.max(p0, p1, p2, p3);
|
||
if (tMax < rMin || rMax < tMin) return false; // 找到分离轴
|
||
}
|
||
return true;
|
||
}
|
||
export { spikeTri, triRectOverlap };
|