Files
kidgo/js/player.js
T
2026-10-24 02:35:03 +08:00

397 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 玩家:还原 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);
}
}