fix: 修复了冰块惯性问题和死亡效果
This commit is contained in:
1 parent
47a462be01
commit
4b3dfd635b
15 files changed
+633
-38
No files matched your search
@@ -0,0 +1,135 @@
|
||||
// 回归测试:kid 死亡后必须立刻复活,且不留下任何死亡动画(无粒子、无震屏、无延迟)
|
||||
const handlers = {};
|
||||
const addL = (t, fn) => { (handlers[t] = handlers[t] || []).push(fn); };
|
||||
globalThis.addEventListener = addL;
|
||||
globalThis.window = { addEventListener: addL };
|
||||
const key = (type, code) => (handlers[type] || []).forEach(fn => fn({ code, preventDefault() { }, repeat: false }));
|
||||
|
||||
const { Level } = await import('../js/level.js');
|
||||
const { Player } = await import('../js/player.js');
|
||||
const { Input } = await import('../js/input.js');
|
||||
|
||||
const W = 20, H = 8, TILE = 32;
|
||||
// row2 放一枚上刺(col 5),row3 是地板,spawn 在 col1
|
||||
const grid = [
|
||||
'#'.repeat(W),
|
||||
'#' + 'S' + '.'.repeat(W - 3) + '#',
|
||||
'#' + '.'.repeat(4) + '^' + '.'.repeat(4) + '^' + '.'.repeat(9) + '#',
|
||||
'#'.repeat(W),
|
||||
'#'.repeat(W),
|
||||
'#'.repeat(W),
|
||||
'#'.repeat(W),
|
||||
'#'.repeat(W),
|
||||
];
|
||||
|
||||
let fail = 0;
|
||||
const err = m => { console.log(' ✗ ' + m); fail++; };
|
||||
const ok = m => console.log(' ✓ ' + m);
|
||||
|
||||
function fresh() {
|
||||
const L = new Level({ id: 'dt', name: 'DT', type: 'jump', width: W, grid });
|
||||
const P = new Player(L);
|
||||
let deaths = 0;
|
||||
P.onDeath = () => deaths++;
|
||||
for (let i = 0; i < 12; i++) { L.update(1, P, null); P.update(1); Input.endFrame(); }
|
||||
return { L, P, deaths: () => deaths };
|
||||
}
|
||||
|
||||
// 1) 踩到刺:同一帧内就必须已经复活(不是等 34 帧)
|
||||
{
|
||||
const { L, P, deaths } = fresh();
|
||||
const sx = L.spawn.x;
|
||||
P.x = 5 * TILE; P.y = 2 * TILE + 11; // 直接压在上刺上
|
||||
L.update(1, P, null); P.update(1); Input.endFrame();
|
||||
if (!P.alive || P.dead) err('踩刺后本帧仍处于死亡状态(复活有延迟)');
|
||||
else ok('踩刺后同帧即复活,无死亡等待');
|
||||
if (deaths() !== 1) err(`死亡计数应为 1,实际 ${deaths()}`);
|
||||
else ok('死亡计数 = 1(正常累加)');
|
||||
if (Math.abs(P.x - sx) > 1) err(`复活位置不对:x=${P.x.toFixed(1)} 期望 ${sx}`);
|
||||
else ok('复活位置 = 最近存档点/出生点');
|
||||
if (L.particles.list.length) err(`死亡仍产生粒子(${L.particles.list.length} 个)`);
|
||||
else ok('无屏幕级死亡粒子(血迹走常驻层,非瞬态粒子)');
|
||||
if (L.shake > 0) err(`死亡仍触发震屏 shake=${L.shake}`);
|
||||
else ok('无死亡震屏');
|
||||
if (!L.deathMarks.length) err('死亡未在死亡地点留下 IWBT 血迹效果');
|
||||
else {
|
||||
const dm = L.deathMarks[0];
|
||||
const ex = 5 * TILE + 5.5, ey = 2 * TILE + 21.5;
|
||||
if (Math.abs(dm.x - ex) > 30 || Math.abs(dm.y - ey) > 30) err(`血迹偏离死亡点 (${dm.x.toFixed(0)},${dm.y.toFixed(0)}) 期望约 (${ex},${ey})`);
|
||||
else ok('在死亡地点留下红色死亡叉(X)');
|
||||
}
|
||||
if (P.justRespawned !== true) err('未标记 justRespawned,镜头会慢慢飘回去');
|
||||
else ok('justRespawned 已置位(镜头瞬移)');
|
||||
}
|
||||
|
||||
// 2) R 键自杀路径:kill() 后下一帧必须复活
|
||||
{
|
||||
const { L, P, deaths } = fresh();
|
||||
P.kill();
|
||||
if (deaths() !== 1) err('kill() 未触发死亡回调');
|
||||
L.update(1, P, null); P.update(1); Input.endFrame();
|
||||
if (!P.alive || P.dead) err('自杀后未立刻复活');
|
||||
else ok('自杀(R 键)后下一帧即复活');
|
||||
// 立刻复活后应能马上操作:按住右键能走
|
||||
const x0 = P.x;
|
||||
key('keydown', 'ArrowRight');
|
||||
for (let i = 0; i < 6; i++) { L.update(1, P, null); P.update(1); Input.endFrame(); }
|
||||
if (P.x <= x0) err('复活后当帧无法操作');
|
||||
else ok(`复活后立即可操作(6 帧走了 ${(P.x - x0).toFixed(1)}px)`);
|
||||
key('keyup', 'ArrowRight');
|
||||
}
|
||||
|
||||
// 3) 连续死亡:不能卡死在死亡状态
|
||||
{
|
||||
const { L, P, deaths } = fresh();
|
||||
for (let k = 0; k < 5; k++) {
|
||||
P.x = 5 * TILE; P.y = 2 * TILE + 11;
|
||||
L.update(1, P, null); P.update(1); Input.endFrame();
|
||||
if (!P.alive) { err(`第 ${k + 1} 次死亡后卡在死亡状态`); break; }
|
||||
}
|
||||
if (deaths() !== 5) err(`连续 5 次死亡计数为 ${deaths()}`);
|
||||
else ok('连续 5 次死亡均即时复活,计数 = 5');
|
||||
}
|
||||
|
||||
// 4) 死亡后不应残留任何死亡视觉:跑 30 帧确认没有死亡粒子产生
|
||||
{
|
||||
const { L, P } = fresh();
|
||||
P.x = 5 * TILE; P.y = 2 * TILE + 11;
|
||||
let maxParticles = 0;
|
||||
for (let i = 0; i < 30; i++) {
|
||||
L.update(1, P, null); P.update(1); Input.endFrame();
|
||||
maxParticles = Math.max(maxParticles, L.particles.list.length);
|
||||
}
|
||||
if (maxParticles > 0) err(`死亡后仍出现全局粒子(峰值 ${maxParticles})`);
|
||||
else ok('30 帧内无屏幕级死亡粒子(血迹由常驻层渲染)');
|
||||
if (!L.deathMarks.length) err('死亡血迹未常驻保留');
|
||||
else ok(`死亡红叉常驻保留(共 ${L.deathMarks.length} 处,30 帧后仍在)`);
|
||||
}
|
||||
|
||||
// 5) 多次死亡:只显示上一次死亡的血迹(不累积)
|
||||
{
|
||||
const { L, P } = fresh();
|
||||
for (let k = 0; k < 2; k++) { P.x = 5 * TILE; P.y = 2 * TILE + 11; L.update(1, P, null); P.update(1); Input.endFrame(); }
|
||||
for (let k = 0; k < 2; k++) { P.x = 10 * TILE; P.y = 2 * TILE + 11; L.update(1, P, null); P.update(1); Input.endFrame(); }
|
||||
if (L.deathMarks.length !== 1) err(`多次死亡应只显示最后一次血迹,实际 ${L.deathMarks.length} 处`);
|
||||
else {
|
||||
const dm = L.deathMarks[0];
|
||||
const ex = 10 * TILE + 5.5, ey = 2 * TILE + 21.5;
|
||||
if (Math.abs(dm.x - ex) > 30) err(`应只保留最后一次(col10)血迹,实际在 x=${dm.x.toFixed(0)}`);
|
||||
else ok('多次死亡只保留上一次死亡地点的血迹');
|
||||
}
|
||||
}
|
||||
|
||||
// 6) 整关重开:血迹清空
|
||||
{
|
||||
const { L, P } = fresh();
|
||||
P.x = 5 * TILE; P.y = 2 * TILE + 11;
|
||||
L.update(1, P, null); P.update(1); Input.endFrame();
|
||||
if (!L.deathMarks.length) err('死亡未生成血迹');
|
||||
L.reset(true);
|
||||
if (L.deathMarks.length) err(`整关重开后血迹应清空,实际 ${L.deathMarks.length}`);
|
||||
else ok('整关重开(reset(true))后血迹清空');
|
||||
}
|
||||
|
||||
console.log(fail ? `\n❌ ${fail} 项失败` : '\n✅ 死亡即时复活,全部通过');
|
||||
process.exit(fail ? 1 : 0);
|
||||
@@ -29,10 +29,15 @@ const ctx2d = new Proxy({
|
||||
const handlers = {};
|
||||
const addL = (t, fn) => { (handlers[t] = handlers[t] || []).push(fn); };
|
||||
|
||||
globalThis.location = { search: '', href: 'http://localhost/', pathname: '/', reload: noop };
|
||||
Object.defineProperty(globalThis, 'navigator', { value: { maxTouchPoints: 0, userAgent: 'node' }, configurable: true });
|
||||
globalThis.matchMedia = q => ({ matches: false, media: q, addEventListener: noop, removeEventListener: noop });
|
||||
globalThis.window = {
|
||||
addEventListener: addL, removeEventListener: noop,
|
||||
AudioContext: null, webkitAudioContext: null,
|
||||
innerWidth: 1280, innerHeight: 800,
|
||||
location: globalThis.location, navigator: globalThis.navigator,
|
||||
matchMedia: globalThis.matchMedia,
|
||||
};
|
||||
globalThis.addEventListener = addL;
|
||||
globalThis.removeEventListener = noop;
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
// 冰面惯性回归测试:滑出冰块后必须保留惯性(空中几乎不衰减,地面缓慢收住)
|
||||
const handlers = {};
|
||||
const addL = (t, fn) => { (handlers[t] = handlers[t] || []).push(fn); };
|
||||
globalThis.addEventListener = addL;
|
||||
globalThis.window = { addEventListener: addL };
|
||||
const key = (type, code) => (handlers[type] || []).forEach(fn => fn({ code, preventDefault() { }, repeat: false }));
|
||||
const down = c => key('keydown', c);
|
||||
const up = c => key('keyup', c);
|
||||
|
||||
const { Level } = await import('../js/level.js');
|
||||
const { Player, PHY } = await import('../js/player.js');
|
||||
const { Input } = await import('../js/input.js');
|
||||
|
||||
const W = 44;
|
||||
// 地板 row 11:冰面 col1..6,之后 tail(普通地面或空)
|
||||
function grid(tail) {
|
||||
const g = [];
|
||||
g.push('#'.repeat(W));
|
||||
for (let y = 1; y <= 10; y++) {
|
||||
g.push((y === 10 ? '#S' + '.'.repeat(W - 3) : '#' + '.'.repeat(W - 2)) + '#');
|
||||
}
|
||||
g.push('#' + '%'.repeat(6) + tail + '#');
|
||||
g.push('#'.repeat(W));
|
||||
g.push('#'.repeat(W));
|
||||
return g;
|
||||
}
|
||||
|
||||
let fail = 0;
|
||||
const err = m => { console.log(' ✗ ' + m); fail++; };
|
||||
|
||||
function run(tail, label, releaseAt = 1e9, jumpAt = 1e9) {
|
||||
const L = new Level({ id: 'ice', name: 'ICE', type: 'jump', width: W, grid: grid(tail) });
|
||||
const P = new Player(L);
|
||||
for (let i = 0; i < 10; i++) { L.update(1, P, null); P.update(1); Input.endFrame(); }
|
||||
down('ArrowRight');
|
||||
const log = [];
|
||||
for (let i = 0; i < 90; i++) {
|
||||
if (i === releaseAt) up('ArrowRight');
|
||||
if (i === jumpAt) down('ShiftLeft');
|
||||
if (i === jumpAt + 1) up('ShiftLeft');
|
||||
L.update(1, P, null); P.update(1); Input.endFrame();
|
||||
log.push({
|
||||
f: i, x: +P.x.toFixed(1), vx: +P.vx.toFixed(2), y: +P.y.toFixed(1),
|
||||
g: P.grounded, sl: P.sliding,
|
||||
ice: P.grounded && L.iceAtBottom(P.x, P.y, P.w, P.h),
|
||||
});
|
||||
}
|
||||
up('ArrowRight');
|
||||
console.log(`\n=== ${label} ===`);
|
||||
console.log('帧 x vx 地 滑 冰');
|
||||
let crossAt = -1;
|
||||
for (const r of log) {
|
||||
const cross = !r.ice && log.find(o => o.f === r.f - 1)?.ice;
|
||||
if (cross && crossAt < 0) { crossAt = r.f; console.log('--- 离开冰面 ---'); }
|
||||
if (r.f % 3 === 0 || cross) console.log(`${String(r.f).padStart(3)} ${String(r.x).padStart(6)} ${String(r.vx).padStart(6)} ${r.g ? 'Y' : '.'} ${r.sl ? 'Y' : '.'} ${r.ice ? 'Y' : '.'}`);
|
||||
}
|
||||
const off = log.filter(r => r.f >= crossAt && crossAt >= 0);
|
||||
const dist = off.length ? Math.max(...off.map(r => r.x)) - off[crossAt === 0 ? 0 : 0].x : 0;
|
||||
console.log(`离冰时 vx=${off[0]?.vx ?? 0},离冰后位移=${dist.toFixed(1)}px`);
|
||||
return { log, crossAt, off };
|
||||
}
|
||||
|
||||
// A:一直按住右键冲出冰面 —— 惯性要把 4.05 平滑收敛到正常走速,不能瞬间刹停也不能一直超速
|
||||
{
|
||||
const { off } = run('='.repeat(34), 'A 冰面 -> 普通地面(一直按住右键)');
|
||||
const v0 = off[0].vx, v10 = off[10]?.vx ?? 0, v30 = off[30]?.vx ?? 0;
|
||||
if (v0 < PHY.HSPD) err(`A 离冰瞬间就丢了速度 vx=${v0}`);
|
||||
if (v10 < 2) err(`A 离冰 10 帧后速度掉太快 vx=${v10}(应仍在滑行)`);
|
||||
if (Math.abs(v30) > PHY.HSPD + 0.01) err(`A 离冰 30 帧后仍超速 vx=${v30}(应收敛回 ${PHY.HSPD})`);
|
||||
}
|
||||
|
||||
// B:松手靠惯性滑出到普通地面 —— 地面摩擦收住,但不能一帧刹死
|
||||
{
|
||||
const { off } = run('='.repeat(34), 'B 冰面 -> 普通地面(第30帧松开方向键)', 30);
|
||||
const v0 = off[0].vx, v5 = off[5]?.vx ?? 0;
|
||||
if (v5 <= 0) err(`B 离冰 5 帧内就刹死了(v0=${v0} v5=${v5})`);
|
||||
if (v5 > v0) err(`B 松手后速度反而上升`);
|
||||
}
|
||||
|
||||
// C:滑出冰块边缘掉下去 —— 空中必须几乎不衰减,能一路飞出去
|
||||
{
|
||||
const { off } = run('.'.repeat(34), 'C 冰面 -> 悬空(第30帧松开后滑出边缘)', 30);
|
||||
const air = off.filter(r => !r.g);
|
||||
if (air.length < 5) { err('C 没掉下去(场景构造失败)'); }
|
||||
else {
|
||||
const k = Math.min(10, air.length - 1);
|
||||
const v0 = air[0].vx, vk = air[k].vx;
|
||||
if (vk < v0 - 0.5) err(`C 空中惯性衰减过快:${v0} -> ${vk}(${k} 帧)`);
|
||||
else console.log(` ✓ 空中 ${k} 帧后仍保留 vx=${vk}(起 ${v0})`);
|
||||
}
|
||||
}
|
||||
|
||||
// D:冰面上滑行中起跳 —— 空中保持滑行速度
|
||||
{
|
||||
const { off } = run('.'.repeat(34), 'D 冰面滑行中起跳(第30帧松开 + 第34帧跳)', 30, 34);
|
||||
const v0 = off[0]?.vx ?? 0, v12 = off[12]?.vx ?? 0;
|
||||
if (v0 < 2) err(`D 起跳时速度太小 vx=${v0}`);
|
||||
if (v12 < v0 - 0.6) err(`D 跳跃后空中丢失惯性:${v0} -> ${v12}`);
|
||||
else console.log(` ✓ 起跳后 12 帧仍保留 vx=${v12}(起 ${v0})`);
|
||||
}
|
||||
|
||||
console.log(fail ? `\n❌ ${fail} 项失败` : '\n✅ 冰面惯性全部通过');
|
||||
process.exit(fail ? 1 : 0);
|
||||
@@ -29,9 +29,14 @@ globalThis.performance = { now: () => clock };
|
||||
let rafCb = null;
|
||||
globalThis.requestAnimationFrame = fn => { rafCb = fn; return 1; };
|
||||
|
||||
globalThis.location = { search: '', href: 'http://localhost/', pathname: '/', reload: noop };
|
||||
Object.defineProperty(globalThis, 'navigator', { value: { maxTouchPoints: 0, userAgent: 'node' }, configurable: true });
|
||||
globalThis.matchMedia = q => ({ matches: false, media: q, addEventListener: noop, removeEventListener: noop });
|
||||
globalThis.window = {
|
||||
addEventListener: addL, removeEventListener: noop,
|
||||
AudioContext: null, webkitAudioContext: null, innerWidth: 1280, innerHeight: 800,
|
||||
location: globalThis.location, navigator: globalThis.navigator,
|
||||
matchMedia: globalThis.matchMedia,
|
||||
};
|
||||
globalThis.addEventListener = addL;
|
||||
globalThis.removeEventListener = noop;
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ const up = c => key('keyup', c);
|
||||
|
||||
const { LEVELS } = await import('../js/levels/index.js');
|
||||
const { Level } = await import('../js/level.js');
|
||||
const { Player } = await import('../js/player.js');
|
||||
const { Player } = await import('./player.js'.replace('./player.js', '../js/player.js'));
|
||||
const { Input } = await import('../js/input.js');
|
||||
|
||||
let fail = 0;
|
||||
|
||||
Reference in new issue
Block a user