// 玩家:还原 IWBT 的硬核手感 —— 松手即停、无加速、可变跳跃高度、二段跳 import { TILE, clamp, sgn, overlap } from './util.js'; import { Input } from './input.js'; import { sfx } from './audio.js'; import * as S from './sprites.js'; import { T } from './level.js'; export const PHY = { GRAV: 0.4, MAXFALL: 9, JUMP: 8.5, DJUMP: 7.6, HSPD: 3, WATER_GRAV: 0.16, WATER_FALL: 2.6, WATER_RISE: 0.42, ICE_ACC: 0.18, ICE_FRIC: 0.07, }; export class Player { constructor(level) { this.level = level; // IWBT 官方判定盒:11×21 像素(比精灵视觉略小,支持像素级擦弹/贴刺) this.w = 11; this.h = 21; this.reset(true); } reset(full) { const sp = full ? this.level.spawn : (this.savePoint || this.level.spawn); this.x = sp.x; this.y = sp.y; this.vx = 0; this.vy = 0; this.jumps = 0; this.grounded = false; this.facing = 1; this.alive = true; this.dead = false; this.deadT = 0; this.squash = 1; this.inWater = false; this.animT = 0; this.shootCool = 0; this.mover = null; if (full) { this.hasGun = !!this.level.data.startGun; this.apples = 0; this.savePoint = null; this.saveIdx = 0; } } get cx() { return this.x + this.w / 2; } get cy() { return this.y + this.h / 2; } kill() { if (this.dead || !this.alive) return; this.dead = true; this.alive = false; this.deadT = 0; this.vx = 0; this.vy = 0; sfx.death(); this.level.particles.burst(this.cx, this.cy, { n: 26, colors: ['#e03050', '#fff', '#ffc63a', '#8e2530'], speed: 4.5, life: 44, size: 4, grav: 0.18, }); this.level.shake = 9; if (this.onDeath) this.onDeath(); } respawn() { this.dead = false; this.alive = true; const L = this.level; const sp = this.savePoint || L.spawn; this.x = sp.x; this.y = sp.y; this.vx = 0; this.vy = 0; this.jumps = 0; this.squash = 1; this.shootCool = 0; // 安全兜底:万一重生点被地形/消失块堵住,向上顶出,别一复活就卡死 let guard = 0; while (L.solidRect(this.x, this.y, this.w, this.h) && guard++ < 48) this.y -= 2; L.softReset(); } // ---------------- 每帧更新 ---------------- update(dt) { const L = this.level; this.animT++; if (this.shootCool > 0) this.shootCool--; if (this.dead) { if (++this.deadT > 34) this.respawn(); return; } const inWater = L.waterRect(this.x + 2, this.y + 4, this.w - 4, this.h - 4); if (inWater && !this.inWater) sfx.splash(); this.inWater = inWater; // ---- 水平输入(IWBT:瞬时,无惯性)---- const moveL = Input.is('left'), moveR = Input.is('right'); const onIce = this.grounded && L.iceAtBottom(this.x, this.y, this.w, this.h); let target = 0; if (moveL && !moveR) target = -PHY.HSPD; else if (moveR && !moveL) target = PHY.HSPD; if (onIce) { // 冰面:打滑 if (target !== 0) this.vx += sgn(target) * PHY.ICE_ACC; else { if (Math.abs(this.vx) < PHY.ICE_FRIC) this.vx = 0; else this.vx -= sgn(this.vx) * PHY.ICE_FRIC; } this.vx = clamp(this.vx, -PHY.HSPD * 1.35, PHY.HSPD * 1.35); } else { this.vx = target; } if (target !== 0) this.facing = sgn(target); // ---- 跳跃 ---- if (inWater) { if (Input.is('jump')) this.vy -= PHY.WATER_RISE; if (Input.is('down')) this.vy += 0.25; this.vy = clamp(this.vy, -3.2, PHY.WATER_FALL); this.jumps = 0; } else { if (Input.hit('jump')) { if (this.grounded || this.jumps === 0) { this.vy = -PHY.JUMP; this.jumps = 1; this.grounded = false; this.squash = 1.28; sfx.jump(); L.particles.spawn(this.cx, this.y + this.h, 5, { colors: ['#fff', '#cfcfe0'], speed: 1.6, life: 16, size: 2, dir: -Math.PI / 2, spread: 2 }); } else if (this.jumps === 1) { this.vy = -PHY.DJUMP; this.jumps = 2; this.squash = 1.22; sfx.djump(); L.particles.spawn(this.cx, this.cy, 10, { colors: ['#fff', '#9ad8ff'], speed: 2.2, life: 20, size: 2, spread: Math.PI * 2 }); } } // 可变跳跃高度 if (!Input.is('jump') && this.vy < -3) this.vy = -3; this.vy = Math.min(this.vy + PHY.GRAV, PHY.MAXFALL); } // ---- 站在移动平台上则跟随 ---- if (this.mover && !this.mover.gone) { this.x += this.mover.dx; this.y += this.mover.dy; } // ---- X 轴移动 ---- const prevBottom = this.y + this.h; this.mover = null; let steps = Math.abs(this.vx); const dx = sgn(this.vx); while (steps > 0) { const m = Math.min(1, steps); if (!L.solidRect(this.x + dx * m, this.y, this.w, this.h)) { this.x += dx * m; steps -= m; } else break; } if (L.solidRect(this.x + dx, this.y, this.w, this.h) && Math.abs(this.vx) > 0) this.vx = 0; // ---- Y 轴移动 ---- const dropThrough = Input.is('down'); steps = Math.abs(this.vy); const dy = sgn(this.vy); let hitGround = false; while (steps > 0) { const m = Math.min(1, steps); const ny = this.y + dy * m; const solidHere = L.solidRect(this.x, ny, this.w, this.h, { prevBottom, dropThrough, }); if (!solidHere) { this.y = ny; steps -= m; } else { if (dy > 0) hitGround = true; this.vy = 0; break; } } // 检查是否踩到移动平台 for (const m of L.movers) { if (m.gone) continue; if (this.x + this.w > m.x && this.x < m.x + m.w) { if (this.y + this.h >= m.y - 2 && this.y + this.h <= m.y + m.h) { this.mover = m; hitGround = true; } } } const wasAir = !this.grounded; this.grounded = hitGround || (this.vy === 0 && L.solidRect(this.x, this.y + 1, this.w, this.h)); if (this.grounded) { if (wasAir) { this.squash = 0.72; } this.jumps = 0; } this.squash += (1 - this.squash) * 0.22; // ---- 边界 ---- this.x = clamp(this.x, 0, L.pxW - this.w); if (this.y > L.pxH + 200) this.kill(); if (this.y < -300) { this.y = -300; this.vy = 0; } // ---- 致死 ---- if (L.deadlyRect(this.x, this.y, this.w, this.h)) this.kill(); // ---- 射击 ---- if (this.hasGun && Input.hit('shoot') && this.shootCool === 0 && L.bullets.length < 5) { this.shoot(); } // ---- 实体交互 ---- this.touchEntities(); // ---- 消失方块踩踏 ---- if (this.grounded) { const ty = Math.floor((this.y + this.h + 2) / TILE); for (let tx = Math.floor(this.x / TILE); tx <= Math.floor((this.x + this.w - 1) / TILE); tx++) { if (L.tiles[ty] && L.tiles[ty][tx] === T.VANISH && L.vanishState(tx, ty) === 0) { L.setVanish(tx, ty, 1); L.vanishT = L.vanishT || new Map(); L.vanishT.set(ty * 1000 + tx, 0); } } } } shoot() { const L = this.level; this.shootCool = 9; L.bullets.push({ x: this.facing > 0 ? this.x + this.w - 2 : this.x - 6, y: this.y + 8, w: 10, h: 5, vx: 9 * this.facing, vy: 0, life: 130, dead: false, }); sfx.shoot(); L.particles.spawn(this.facing > 0 ? this.x + this.w : this.x, this.y + 10, 3, { colors: ['#fff6a0'], speed: 1.5, life: 10, size: 2 }); } touchEntities() { const L = this.level; for (const e of L.entities) { if (e.dead) continue; // 存档点判定放宽 6px:跑得太快或从旁边擦过去也能吃到 const hit = e.type === 'save' ? overlap({ x: this.x - 6, y: this.y - 6, w: this.w + 12, h: this.h + 12 }, e) : overlap(this, e); if (!hit) continue; switch (e.type) { case 'save': if (!e.active) { e.active = true; this.savePoint = { x: e.x + e.w / 2 - this.w / 2, y: e.y + e.h - this.h }; this.saveIdx = e.no || 1; sfx.save(); L.particles.spawn(e.x + e.w / 2, e.y + e.h / 2, 18, { colors: ['#b06bff', '#fff', '#e5d0ff'], speed: 2.6, life: 30 }); if (e.fake) { L.showToast('假的。', 90); this.kill(); } } break; case 'goal': if (!this.clearing) { this.clearing = true; if (this.onClear) this.onClear(); } break; case 'gun': if (!e.taken) { e.taken = true; this.hasGun = true; L.particles.spawn(e.x + e.w / 2, e.y + e.h / 2, 12, { colors: ['#dfe0f0', '#fff'], speed: 2.2, life: 24 }); L.showToast('获得手枪!按 X 射击', 120); sfx.save(); } break; case 'apple': if (!e.taken) { e.taken = true; this.apples++; sfx.save(); } break; case 'jumpref': if (this.animT % 4 === 0) this.jumps = 0; break; case 'warp': { if (e.cool > 0) break; e.cool = 40; const tgt = e.to === 'spawn' ? L.spawn : e.to ? { x: e.to[0] * TILE, y: e.to[1] * TILE } : (() => { const p = L.entities.find(o => o.type === 'warp' && o.idx === e.pair); return p ? { x: p.x + p.w / 2 - this.w / 2, y: p.y + p.h - this.h } : null; })(); if (tgt) { this.x = tgt.x; this.y = tgt.y; this.vx = 0; this.vy = 0; const partner = L.entities.find(o => o.type === 'warp' && o.idx === e.pair); if (partner) partner.cool = 40; sfx.warp(); L.particles.spawn(this.cx, this.cy, 12, { colors: ['#a86bff', '#fff'], speed: 2.5, life: 24 }); } break; } case 'sign': if (this.animT % 30 === 0) L.showToast(e.text, 90); break; } } } draw(ctx) { S.drawKid(ctx, this, this.animT); } }