// 关卡解析与运行时: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 - 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; } } // ---------------- 查询 ---------------- 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); } } } // 刺的判定盒:对齐齿尖、收窄到齿根之间(IWBT 三角形判定的矩形近似)。 // 配合 11×21 的玩家判定盒,可以像素级贴刺、穿对刺缝。 function spikeBox(t, tx, ty) { const x = tx * TILE, y = ty * TILE; if (t === T.SPIKE_U) return { x: x + 6, y: y + 8, w: TILE - 12, h: TILE - 8 }; // 齿尖 y+5,缝高 24 if (t === T.SPIKE_D) return { x: x + 6, y: y + 1, w: TILE - 12, h: TILE - 9 }; // 齿尖 y+27 if (t === T.SPIKE_L) return { x: x + 5, y: y + 6, w: TILE - 10, h: TILE - 12 }; // 齿尖 x+5,缝宽 22 if (t === T.SPIKE_R) return { x: x + 5, y: y + 6, w: TILE - 10, h: TILE - 12 }; // 齿尖 x+27 return { x: x + 6, y: y + 6, w: TILE - 12, h: TILE - 10 }; // KILLER(美味水果) } export { spikeBox };