Files
kidgo/js/level.js
T
2026-10-20 22:21:26 +08:00

621 lines
22 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 等)
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] || {};
e.pair = cfg.pair !== undefined ? cfg.pair : (e.idx % 2 === 0 ? e.idx + 1 : e.idx - 1);
e.to = cfg.to || null; // {x,y} 绝对像素 / 'spawn'
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 - 7, y: sp.y + sp.h - 18 }
: (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;
}
}
// ---------------- 查询 ----------------
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 r = spikeBox(t, tx, ty);
if (x < r.x + r.w && x + w > r.x && y < r.y + r.h && y + h > r.y) return true;
}
}
for (const e of this.entities) {
if (e.type === 'dropspike' && e.falling) {
const r = spikeBox(T.SPIKE_U, e.x / TILE, e.y / TILE);
if (x < r.x + r.w && x + w > r.x && y < r.y + r.h && y + h > r.y) 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);
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);
}
}
}
function spikeBox(t, tx, ty) {
const x = tx * TILE, y = ty * TILE;
if (t === T.SPIKE_U) return { x: x + 4, y: y + 11, w: TILE - 8, h: TILE - 12 };
if (t === T.SPIKE_D) return { x: x + 4, y: y + 2, w: TILE - 8, h: TILE - 11 };
if (t === T.SPIKE_L) return { x: x + 3, y: y + 4, w: TILE - 12, h: TILE - 8 };
return { x: x + 9, y: y + 4, w: TILE - 12, h: TILE - 8 };
}
export { spikeBox };