397 lines
17 KiB
JavaScript
397 lines
17 KiB
JavaScript
// 玩家:还原 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.25, // 上升段重力:小 = 跳跃飘
|
||
GRAV_FALL: 0.32, // 下落段重力:小 = 下坠轻、飘落感(0.35→0.32,用户反馈"下落有点沉")
|
||
GRAV_DJ: 0.35, // 二段跳上升段专属重力:比 GRAV 稍重——初速 6.7 与一段 6.6 一致,配 0.35 才能又冲(不起悬停)又只有 4.5 格总高
|
||
MAXFALL: 9,
|
||
JUMP: 6.6,
|
||
DJUMP: 6.7, // 二段跳冲量:起跳初速与一段 JUMP 6.6 几乎一致(起跳"嘭"感同款);顶点接二段总高 = 4.51 格
|
||
DJ_INERTIA: 0, // 二段跳时下落速度对冲量的抵消比例:0=任何时机接二段都满力起跳(初速恒 -DJUMP,同 IWBT 原版),
|
||
// 1=全额抵消(旧 bug:顶点晚几帧接二段直接归零)。0.35/0.5 都被用户反馈"好沉+短"——起跳初速被下落速度
|
||
// 吃掉才是"沉"的根源(初速从 6.7 掉到 3~4,起跳那一下没劲),与上升重力无关。
|
||
HSPD: 2.6,
|
||
WATER_GRAV: 0.16,
|
||
WATER_FALL: 2.6,
|
||
WATER_RISE: 0.42,
|
||
ICE_ACC: 0.18,
|
||
ICE_FRIC: 0.07,
|
||
// 滑出冰块后的惯性:空中几乎不损失,地面缓慢收住
|
||
SLIDE_FRIC: 0.12, // 非冰地面上收住惯性的摩擦
|
||
SLIDE_AIR: 0.02, // 空中惯性衰减(≈0,滑出冰块边缘能一路飞出去)
|
||
SLIDE_EASE: 0.14, // 超速滑行时按住同向键,收敛回正常走速的速度
|
||
SLIDE_BRAKE: 0.45, // 滑行中按反向键的刹车
|
||
SLIDE_MIN: 0.35, // 冰面上攒到多大速度才算进入滑行状态
|
||
SLIDE_STOP: 0.05, // 低于此速度惯性结束
|
||
ICE_TOP: 1.6, // 冰面最高速度 = HSPD × ICE_TOP(调大=冰上能滑得更快,留更猛的惯性)
|
||
};
|
||
|
||
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.djUp = false;
|
||
this.grounded = false;
|
||
this.facing = 1;
|
||
this.alive = true;
|
||
this.dead = false;
|
||
this.deadT = 0;
|
||
this.deathX = 0; this.deathY = 0; // 死亡动画圆心(掉坑死亡时 clamp 到关卡内)
|
||
this.inWater = false;
|
||
this.animT = 0;
|
||
this.shootCool = 0;
|
||
this.mover = null;
|
||
this.sliding = false; // 冰面滑行惯性状态:离开冰块后仍保留
|
||
this.justRespawned = false; // 本帧刚复活(供镜头瞬移)
|
||
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; }
|
||
|
||
// 纯死亡演出(不进死亡状态):粒子爆开 + 屏幕轻震 + 死亡点扩散冲击环(Level 层,见 addDeathFX)。
|
||
// 主动按重生键时直接调它再当帧 respawn——特效留在死亡位置自然播完。
|
||
// 圆心 clamp 到关卡内:掉坑死亡时玩家已在屏幕外,保证红叉/粒子/圆环可见
|
||
deathBurst() {
|
||
const L = this.level;
|
||
const dx = clamp(this.cx, 10, Math.max(10, L.pxW - 10));
|
||
const dy = clamp(this.cy, 10, Math.max(10, L.pxH - 10));
|
||
sfx.death();
|
||
L.particles.spawn(dx, dy, 16, {
|
||
colors: ['#ff5c7a', '#ff8095', '#fff', '#ffc63a'], speed: 3.4, life: 34, size: 3, grav: 0.16,
|
||
});
|
||
L.shake = Math.max(L.shake || 0, 6);
|
||
L.addDeathFX(dx, dy);
|
||
return { dx, dy };
|
||
}
|
||
|
||
// 死亡:定格在原地,等玩家手动按重生键(R / 触屏重生键)。
|
||
// 视觉:① 死亡地点留下常驻红叉(IWBTG 血迹,复活后仍在;opts.mark=false 时不留)
|
||
// ② deathBurst() 的"小"死亡动画(粒子+轻震+冲击环)
|
||
// opts:
|
||
// mark=false → 不留红叉
|
||
// count=false → 不触发 onDeath 计数(不计入死亡数)
|
||
kill(opts = {}) {
|
||
if (this.dead || !this.alive) return;
|
||
this.dead = true; this.alive = false; this.deadT = 0;
|
||
this.vx = 0; this.vy = 0; this.sliding = false;
|
||
const { dx, dy } = this.deathBurst();
|
||
this.deathX = dx; this.deathY = dy;
|
||
if (opts.mark !== false) this.level.addDeathMark(dx, dy);
|
||
if (opts.count !== false && 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.djUp = false;
|
||
this.shootCool = 0; this.sliding = false;
|
||
this.deadT = 0;
|
||
// 安全兜底:万一重生点被地形/消失块堵住,向上顶出,别一复活就卡死
|
||
let guard = 0;
|
||
while (L.solidRect(this.x, this.y, this.w, this.h) && guard++ < 48) this.y -= 2;
|
||
L.softReset(); // 不清粒子/震屏:死亡特效留在死亡位置自然播完
|
||
this.justRespawned = true; // 供 main.js 把镜头直接吸附到重生点
|
||
}
|
||
|
||
// ---------------- 每帧更新 ----------------
|
||
update(dt) {
|
||
const L = this.level;
|
||
this.animT++;
|
||
if (this.shootCool > 0) this.shootCool--;
|
||
|
||
// 死亡:定格在原地等玩家手动重生(R 键 / 触屏重生键),
|
||
// 世界继续运转(敌人/子弹/粒子照走),但玩家不响应任何输入
|
||
if (this.dead) { this.deadT++; 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;
|
||
|
||
const ICE_MAX = PHY.HSPD * PHY.ICE_TOP;
|
||
const decel = f => {
|
||
if (Math.abs(this.vx) <= f) this.vx = 0;
|
||
else this.vx -= sgn(this.vx) * f;
|
||
};
|
||
|
||
if (onIce) {
|
||
// 冰面:加速/减速都很慢,速度上限也更高
|
||
if (target !== 0) this.vx += sgn(target) * PHY.ICE_ACC;
|
||
else decel(PHY.ICE_FRIC);
|
||
this.vx = clamp(this.vx, -ICE_MAX, ICE_MAX);
|
||
// 攒出一定速度即进入「滑行」状态——这是惯性得以保留的关键
|
||
if (Math.abs(this.vx) > PHY.SLIDE_MIN) this.sliding = true;
|
||
} else if (this.sliding && Math.abs(this.vx) > PHY.SLIDE_STOP) {
|
||
// 滑出冰块后:惯性依然保留 —— 空中几乎不衰减,地面才慢慢收住
|
||
if (target !== 0) {
|
||
const dir = sgn(this.vx);
|
||
if (dir === 0 || sgn(target) === dir) {
|
||
if (Math.abs(this.vx) >= PHY.HSPD) {
|
||
// 还在超速滑行:同向按键只让它缓慢收敛回正常走速,不再继续加速
|
||
this.vx -= dir * PHY.SLIDE_EASE;
|
||
if (Math.abs(this.vx) < PHY.HSPD) { this.vx = target; this.sliding = false; }
|
||
} else {
|
||
this.vx = target; // 已低于走速:交还玩家控制
|
||
this.sliding = false;
|
||
}
|
||
} else {
|
||
this.vx -= dir * PHY.SLIDE_BRAKE; // 反向按键:刹车,刹住后立刻反向
|
||
if (sgn(this.vx) === sgn(target)) { this.vx = target; this.sliding = false; }
|
||
}
|
||
this.vx = clamp(this.vx, -ICE_MAX, ICE_MAX);
|
||
} else {
|
||
decel(this.grounded ? PHY.SLIDE_FRIC : PHY.SLIDE_AIR);
|
||
if (this.vx === 0) this.sliding = false;
|
||
}
|
||
if (this.vx !== 0 && Math.abs(this.vx) < PHY.SLIDE_STOP) { this.vx = 0; this.sliding = false; }
|
||
} else {
|
||
// 常规地面/空中(非滑行):IWBT 标准手感,松手即停、无惯性
|
||
this.vx = target;
|
||
this.sliding = false;
|
||
}
|
||
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; this.djUp = false;
|
||
} else {
|
||
if (Input.hit('jump')) {
|
||
if (this.grounded || this.jumps === 0) {
|
||
this.vy = -PHY.JUMP; this.jumps = 1; this.grounded = false;
|
||
// 不再做起跳拉伸:空中保持身体比例,避免跳跃时视觉上下晃动
|
||
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) {
|
||
// 二段跳冲量叠减当前速度:上升中按 = 原版语义(直接获得 -DJUMP);
|
||
// 下落中按 = 下落惯性只抵消一部分冲量(DJUMP 前乘 DJ_INERTIA)——
|
||
// 全额抵消会让"满力一段跳到顶点、晚几帧接二段"的二段塌到接近 0(非常短),
|
||
// 只抵消一半则顶点附近接二段保持满力,摔得快时才明显变弱
|
||
this.vy = Math.max(this.vy, 0) * PHY.DJ_INERTIA - PHY.DJUMP; this.jumps = 2;
|
||
this.djUp = this.vy < 0; // 二段上升段启用专属轻重力(与一段同款飘)
|
||
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;
|
||
// 上升轻、下落重:分手感。二段上升段用更轻的 GRAV_DJ(初速小,同款飘)
|
||
this.vy = Math.min(this.vy + (this.vy < 0 ? (this.djUp ? PHY.GRAV_DJ : PHY.GRAV) : PHY.GRAV_FALL), PHY.MAXFALL);
|
||
if (this.djUp && this.vy >= 0) this.djUp = false; // 二段到顶,交回普通重力
|
||
}
|
||
|
||
// ---- 站在移动平台上则跟随 ----
|
||
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) {
|
||
this.jumps = 0; this.djUp = false;
|
||
}
|
||
|
||
// ---- 滑行反馈:超速滑行(冰上,或刚滑出冰块仍带惯性)时脚下溅起冰屑 ----
|
||
if (this.sliding && this.grounded && Math.abs(this.vx) > PHY.HSPD && this.animT % 5 === 0) {
|
||
L.particles.spawn(this.cx - sgn(this.vx) * 5, this.y + this.h - 2, 2, {
|
||
colors: ['#dff4ff', '#8fd8f0', '#fff'], speed: 1.1, life: 16, size: 2,
|
||
dir: -Math.PI / 2, spread: 1.7, grav: 0.1,
|
||
});
|
||
}
|
||
|
||
// ---- 边界 ----
|
||
this.x = clamp(this.x, 0, L.pxW - this.w);
|
||
if (this.y > L.pxH + 200) { this.kill(); return; }
|
||
if (this.y < -300) { this.y = -300; this.vy = 0; }
|
||
|
||
// ---- 致死:同帧立刻复活,画面上不留任何死亡状态 ----
|
||
if (L.deadlyRect(this.x, this.y, this.w, this.h)) { this.kill(); return; }
|
||
|
||
// ---- 射击 ----
|
||
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, face: (this.level.faceIndex ?? 0) };
|
||
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(); return; }
|
||
}
|
||
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 to = e.to;
|
||
// 自制多面地图的跨面传送门:to = {face, door} —— 交给 main 切换面
|
||
if (to && typeof to === 'object' && !('x' in to) && !('y' in to) && typeof to.face === 'number') {
|
||
if (this.onPortal) this.onPortal(e);
|
||
break;
|
||
}
|
||
// 原有逻辑(同关内传送 / 回出生点 / 绝对坐标 / pair 配对)
|
||
const tgt = to === 'spawn' ? L.spawn
|
||
: to && ('x' in to) ? { x: to.x * TILE, y: to.y * 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) {
|
||
// 死亡中:kid 已爆开,不画本体(粒子/冲击环由 Level 层渲染,重生后仍能播完)
|
||
if (this.dead) return;
|
||
S.drawKid(ctx, this, this.animT);
|
||
}
|
||
}
|