fix: 优化跳跃和下落手感
This commit is contained in:
1 parent
ada64fe4be
commit
afe2f64b9b
25 files changed
+1197
-188
No files matched your search
@@ -0,0 +1,95 @@
|
||||
// 二段跳时机回归(tools/dj-test.mjs)
|
||||
// 历史 bug 链:
|
||||
// ① 全额抵消(旧 `vy = max(vy,0) - DJUMP`):满力一段到顶点后晚几帧接二段,二段塌到接近 0(用户报"二段非常短")
|
||||
// ② 部分抵消(DJ_INERTIA 0.5→0.35):仍有"好沉+高度短"——起跳初速被下落速度从 6.7 吃到 3~4,
|
||||
// 起跳那一下没劲才是"沉"的根源,与上升重力无关
|
||||
// ③ 现行(DJ_INERTIA = 0,同 IWBT 原版):任何时机接二段都满力起跳,初速恒 -DJUMP
|
||||
// 本测试用真实引擎测量:不同时机接二段时,二段自身的提升高度(djRise)。
|
||||
// 通过标准:
|
||||
// · 任意时机接二段都 ≈ 满力(≈60px ≈ 1.9 格)——INERTIA=0 语义,曲线应基本持平
|
||||
// · 满力一段 ≈ 84px(≈2.6 格);顶点接二段总高 ≈ 4.5 格
|
||||
const handlers = {};
|
||||
const addL = (t, fn) => { (handlers[t] = handlers[t] || []).push(fn); };
|
||||
globalThis.addEventListener = addL;
|
||||
globalThis.window = { addEventListener: addL };
|
||||
const key = (t, c) => (handlers[t] || []).forEach(fn => fn({ code: c, preventDefault() { }, repeat: false }));
|
||||
|
||||
const { Level } = await import('../js/level.js');
|
||||
const { Player } = await import('../js/player.js');
|
||||
const { Input } = await import('../js/input.js');
|
||||
|
||||
// 与 jumpdist.mjs 同一块平地:row17 是实心平台
|
||||
const flat = {
|
||||
id: 'test', name: 'T', type: 'jump', width: 44,
|
||||
grid: (() => {
|
||||
const rows = [];
|
||||
rows.push('#'.repeat(44));
|
||||
for (let i = 0; i < 16; i++) rows.push('#' + '.'.repeat(42) + '#');
|
||||
rows.push('#' + '#'.repeat(42) + '#');
|
||||
rows.push('#'.repeat(44));
|
||||
return rows;
|
||||
})(),
|
||||
};
|
||||
|
||||
// 满力一段跳(起跳后一直按住);第 djFrame 帧松键、djFrame+1 帧接二段(之后保持按住)
|
||||
// 返回:firstRise 一段顶点提升 / djRise 二段自身提升(接二段位置→二段后最高点)/ total 二段顶点离平台高度
|
||||
function measure(djFrame) {
|
||||
const L = new Level(flat);
|
||||
const P = new Player(L);
|
||||
P.x = 8 * 32; P.y = 17 * 32 - P.h; // 站在 row17 实心平台上
|
||||
P.vx = 0; P.vy = 0; P.grounded = true; P.jumps = 0;
|
||||
const y0 = P.y;
|
||||
key('keydown', 'ShiftLeft'); // 起跳并按住 = 满力一段
|
||||
let apex1 = y0, apex2 = y0, djDone = false, vyAtDj = null, yAtDj = y0;
|
||||
for (let i = 0; i < 240; i++) {
|
||||
const f = i + 1;
|
||||
if (f === djFrame) key('keyup', 'ShiftLeft');
|
||||
if (f === djFrame + 1) { key('keydown', 'ShiftLeft'); vyAtDj = P.vy; yAtDj = P.y; }
|
||||
L.update(1, P, null);
|
||||
P.update(1);
|
||||
Input.endFrame();
|
||||
if (djFrame && f === djFrame + 1 && P.jumps === 2) djDone = true;
|
||||
if (djDone) apex2 = Math.min(apex2, P.y);
|
||||
else apex1 = Math.min(apex1, P.y);
|
||||
if (i > 2 && P.grounded) break;
|
||||
if (P.dead) break;
|
||||
}
|
||||
key('keyup', 'ShiftLeft');
|
||||
Input.endFrame();
|
||||
return { firstRise: y0 - apex1, djRise: djDone ? yAtDj - apex2 : 0, total: y0 - apex2, vyAtDj, djDone };
|
||||
}
|
||||
|
||||
// 满力一段顶点≈第 27 帧;落地≈第 50 帧。取 27~46 帧覆盖"顶点 → 落地前"整个二段窗口
|
||||
const FRAMES = [27, 30, 34, 38, 42, 46];
|
||||
const T = 32;
|
||||
let fail = 0;
|
||||
const rows = [];
|
||||
console.log('满力一段跳后,不同时机接二段:二段自身提升高度(真实引擎实测)');
|
||||
console.log(' 时机 接二段时vy 一段顶点 二段自身提升 二段顶点离平台');
|
||||
for (const f of FRAMES) {
|
||||
const r = measure(f);
|
||||
rows.push({ f, ...r });
|
||||
const dj = r.djDone
|
||||
? `${r.djRise.toFixed(1).padStart(6)}px = ${(r.djRise / T).toFixed(2)} 格`
|
||||
: ' —— 已落地,二段窗口已过 ——';
|
||||
console.log(` 第${String(f).padStart(2)}帧 ${(r.vyAtDj ?? 0).toFixed(2).padStart(5)} ${r.firstRise.toFixed(1).padStart(6)}px ${dj} ${(r.total / T).toFixed(2)} 格`);
|
||||
}
|
||||
|
||||
const at = f => rows.find(r => r.f === f);
|
||||
const need = (cond, msg) => { if (!cond) { fail++; console.log(' ❌ ' + msg); } };
|
||||
|
||||
for (const r of rows) need(r.djDone, `第 ${r.f} 帧应还在二段窗口内(未落地)`);
|
||||
const apex = at(27);
|
||||
need(apex.firstRise > 79 && apex.firstRise < 90, `满力一段高度应 ≈84px(2.6格),实测 ${apex.firstRise.toFixed(1)}`);
|
||||
need(apex.djRise > 52 && apex.djRise < 68, `顶点接二段自身提升应 ≈59px(1.85格),实测 ${apex.djRise.toFixed(1)}`);
|
||||
need(apex.total > 138 && apex.total < 150, `顶点接二段总高应 ≈4.5格,实测 ${(apex.total / T).toFixed(2)}`);
|
||||
// INERTIA=0:任何时机接二段都满力起跳,曲线应基本持平(与顶点差异 ≤3px)
|
||||
for (const r of rows) {
|
||||
if (!r.f) continue;
|
||||
need(r.djRise >= 55, `第 ${r.f} 帧接二段自身提升应 ≈满力(≥55px),实测 ${r.djRise.toFixed(1)}`);
|
||||
need(Math.abs(r.djRise - apex.djRise) <= 3, `第 ${r.f} 帧与顶点接二段差异应 ≤3px(INERTIA=0 曲线持平),实测差 ${Math.abs(r.djRise - apex.djRise).toFixed(1)}`);
|
||||
}
|
||||
need(at(46).djRise >= 55, `即使摔得快二段也不应衰减(INERTIA=0),实测 ${at(46).djRise.toFixed(1)}`);
|
||||
|
||||
if (fail) { console.log(`\n❌ dj-test 失败 ${fail} 项`); process.exit(1); }
|
||||
console.log('\n✅ dj-test 通过:任意时机接二段均满力起跳(INERTIA=0),顶点总高 ≈4.5 格');
|
||||
@@ -0,0 +1,136 @@
|
||||
// 编辑器交互回归:调色板无「查看属性」工具;无论手上拿什么方块(橡皮除外),
|
||||
// 点击画布上任意方块都选中该格并在属性面板显示其属性(不覆盖已有方块)。
|
||||
const noop = () => { };
|
||||
let fail = 0;
|
||||
const ok = (c, m) => { console.log((c ? ' ✔ ' : ' ✗ ') + m); if (!c) fail++; };
|
||||
|
||||
// ---------- 最小假 DOM(addEventListener 收句柄,children 真实入列)----------
|
||||
const els = new Map();
|
||||
function mkEl(id = '') {
|
||||
const e = {
|
||||
id, tagName: 'DIV', value: '', _html: '', textContent: '',
|
||||
style: {}, dataset: {}, children: [],
|
||||
width: 800, height: 608,
|
||||
classList: {
|
||||
_s: new Set(),
|
||||
add(...c) { c.forEach(x => this._s.add(x)); },
|
||||
remove(...c) { c.forEach(x => this._s.delete(x)); },
|
||||
toggle(c, on) { on ? this._s.add(c) : this._s.delete(c); },
|
||||
contains(c) { return this._s.has(c); },
|
||||
},
|
||||
_h: {},
|
||||
addEventListener(t, fn) { (this._h[t] = this._h[t] || []).push(fn); },
|
||||
removeEventListener: noop,
|
||||
appendChild(c) { this.children.push(c); return c; },
|
||||
querySelector: () => mkEl(), querySelectorAll: () => [],
|
||||
getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 608 }),
|
||||
getContext: () => ctx2d, focus: noop, click: noop,
|
||||
preventDefault: noop,
|
||||
};
|
||||
Object.defineProperty(e, 'innerHTML', {
|
||||
get() { return this._html; },
|
||||
set(v) { this._html = String(v); this.children = []; },
|
||||
});
|
||||
return e;
|
||||
}
|
||||
const ctx2d = new Proxy({
|
||||
canvas: { width: 800, height: 608 },
|
||||
measureText: () => ({ width: 12 }),
|
||||
createLinearGradient: () => ({ addColorStop: noop }),
|
||||
createRadialGradient: () => ({ addColorStop: noop }),
|
||||
getImageData: () => ({ data: [] }),
|
||||
}, {
|
||||
get(t, k) { return k in t ? t[k] : noop; },
|
||||
set(t, k, v) { t[k] = v; return true; },
|
||||
});
|
||||
const getEl = s => { if (!els.has(s)) els.set(s, mkEl(s)); return els.get(s); };
|
||||
|
||||
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: noop, removeEventListener: noop, AudioContext: null, webkitAudioContext: null,
|
||||
innerWidth: 1280, innerHeight: 800, location: globalThis.location, navigator: globalThis.navigator, matchMedia: globalThis.matchMedia,
|
||||
};
|
||||
globalThis.addEventListener = noop;
|
||||
globalThis.removeEventListener = noop;
|
||||
globalThis.confirm = () => true;
|
||||
globalThis.alert = noop;
|
||||
globalThis.localStorage = { _d: {}, getItem(k) { return this._d[k] ?? null; }, setItem(k, v) { this._d[k] = String(v); }, removeItem(k) { delete this._d[k]; } };
|
||||
globalThis.document = {
|
||||
getElementById: getEl, querySelector: getEl, querySelectorAll: () => [],
|
||||
createElement(t) { const e = mkEl(); e.tagName = t.toUpperCase(); return e; },
|
||||
addEventListener: noop, body: mkEl('body'),
|
||||
};
|
||||
|
||||
const { Editor, PALETTE } = await import('../js/editor.js');
|
||||
|
||||
// 画布坐标 → 格子:pos() 用 clientX/Y(scroll=0,缩放 1:1),格心 (tx*32+16, ty*32+16)
|
||||
const ev = (tx, ty, button = 0) => ({ clientX: tx * 32 + 16, clientY: ty * 32 + 16, button, preventDefault: noop });
|
||||
const clickTile = (tx, ty, button = 0) => {
|
||||
for (const fn of getEl('#ed-canvas')._h.mousedown) fn(ev(tx, ty, button));
|
||||
};
|
||||
// renderProps 先写面板 innerHTML(标题),再把每个属性字段 appendChild 成子行
|
||||
const propsHtml = () => {
|
||||
const box = getEl('#ed-props');
|
||||
return box.innerHTML + box.children.map(c => c.innerHTML || '').join('');
|
||||
};
|
||||
|
||||
console.log('=== 调色板不再有「查看属性」工具 ===');
|
||||
ok(!PALETTE.some(p => p[0] === '?'), "PALETTE 中已无 '?'(查看属性)");
|
||||
ok(!PALETTE.some(p => p[1] === '查看属性'), 'PALETTE 中已无「查看属性」条目');
|
||||
Editor.init({ startLevel: noop });
|
||||
const palLabels = getEl('#palette').children.map(c => c.textContent || '');
|
||||
ok(palLabels.length === PALETTE.length, `调色板渲染 ${PALETTE.length} 项(无工具项),实际 ${palLabels.length}`);
|
||||
ok(!palLabels.some(t => t.includes('查看属性')), '调色板 UI 无「查看属性」按钮');
|
||||
|
||||
console.log('=== 打开地图,构造一块带属性的摄像头 C ===');
|
||||
Editor.open();
|
||||
const f = Editor.curFace();
|
||||
f.grid[5][7] = 'C'; // 摄像头:SCHEMA 里有 4 个可编辑属性
|
||||
f.props[5][7] = { w: 4, h: 4, mode: 'zoom', zoom: 1.5 };
|
||||
|
||||
console.log('=== 手上拿刺(^),点击已有摄像头 → 只看属性,不覆盖 ===');
|
||||
Editor.sel = '^';
|
||||
clickTile(7, 5);
|
||||
ok(f.grid[5][7] === 'C', '格子内容未被覆盖(仍是 C)');
|
||||
ok(Editor.selTile && Editor.selTile.tx === 7 && Editor.selTile.ty === 5, 'selTile 指向被点方块 (7,5)');
|
||||
const html1 = propsHtml();
|
||||
ok(html1.includes('区域宽') && html1.includes('镜头模式'), '属性面板显示该方块的属性(区域宽/镜头模式)');
|
||||
|
||||
console.log('=== 手上拿方块(#),点击同一格 → 同样只查看,不覆盖 ===');
|
||||
Editor.sel = '#';
|
||||
clickTile(7, 5);
|
||||
ok(f.grid[5][7] === 'C', '拿 # 点击也不覆盖已有方块');
|
||||
ok(propsHtml().includes('区域宽'), '属性面板仍显示 C 的属性');
|
||||
|
||||
console.log('=== 手上拿炮台(O),点击空格子 → 放置并显示新方块属性 ===');
|
||||
Editor.sel = 'O';
|
||||
clickTile(3, 9);
|
||||
ok(f.grid[9][3] === 'O', '空格子正常放置 O');
|
||||
ok(propsHtml().includes('发射间隔'), '放置后属性面板显示 O 的属性');
|
||||
|
||||
console.log('=== 提示牌 T 的文本属性可随点击查看 ===');
|
||||
f.grid[9][4] = 'T'; f.props[9][4] = { msg: '小心刺' };
|
||||
Editor.sel = '%'; // 拿着冰面点提示牌
|
||||
clickTile(4, 9);
|
||||
ok(f.grid[9][4] === 'T' && propsHtml().includes('提示文字'), '非相关工具点击 T 也能查看文字属性');
|
||||
|
||||
console.log('=== 橡皮擦例外:选中橡皮点击 → 擦除 ===');
|
||||
Editor.sel = '.';
|
||||
clickTile(4, 9);
|
||||
ok(f.grid[9][4] === '.', '橡皮擦点击后 T 被擦除');
|
||||
Editor.sel = '^'; // 恢复非橡皮工具
|
||||
clickTile(7, 5); // 顺手确认 C 还在
|
||||
|
||||
console.log('=== 右键擦除与工具无关 ===');
|
||||
Editor.sel = '#';
|
||||
clickTile(3, 9, 2); // button=2 右键
|
||||
ok(f.grid[9][3] === '.', '右键擦除不受手上工具影响');
|
||||
|
||||
console.log('=== 属性面板空态提示仍存在 ===');
|
||||
Editor.show(); // 重置 selTile
|
||||
ok(propsHtml().includes('点击画布上的方块'), '未选中时显示引导文案');
|
||||
|
||||
console.log(fail === 0 ? '\n✅ 编辑器点击查看属性 全部断言通过' : `\n❌ 失败 ${fail} 项`);
|
||||
process.exit(fail === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,97 @@
|
||||
// 编辑器所见即所得测试:刺以三角形绘制 + 每种方块都能正常渲染
|
||||
const noop = () => { };
|
||||
const els = new Map();
|
||||
|
||||
// 记录关键绘制调用的计数器(证明刺用三角形图元、方块用真实纹理)
|
||||
const C = { lineTo: 0, fillRect: 0, beginPath: 0, moveTo: 0, arc: 0 };
|
||||
const ctx2d = new Proxy({
|
||||
canvas: { width: 800, height: 608 },
|
||||
measureText: () => ({ width: 12 }),
|
||||
createLinearGradient: () => ({ addColorStop: noop }),
|
||||
createRadialGradient: () => ({ addColorStop: noop }),
|
||||
getImageData: () => ({ data: [] }),
|
||||
}, {
|
||||
get(t, k) { return k in t ? t[k] : noop; },
|
||||
set(t, k, v) { t[k] = v; return true; },
|
||||
});
|
||||
// 装上计数器
|
||||
ctx2d.lineTo = () => C.lineTo++;
|
||||
ctx2d.fillRect = () => C.fillRect++;
|
||||
ctx2d.beginPath = () => C.beginPath++;
|
||||
ctx2d.moveTo = () => C.moveTo++;
|
||||
ctx2d.arc = () => C.arc++;
|
||||
|
||||
function mkEl(id = '') {
|
||||
return {
|
||||
id, tagName: 'DIV', value: '', textContent: '', innerHTML: '',
|
||||
style: {}, dataset: {}, children: [], width: 800, height: 608,
|
||||
classList: { add: noop, remove: noop, toggle: noop, contains: () => false },
|
||||
addEventListener: noop, removeEventListener: noop, appendChild: noop,
|
||||
querySelector: () => mkEl(), querySelectorAll: () => [],
|
||||
getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 608 }),
|
||||
getContext: () => ctx2d, focus: noop, click: noop,
|
||||
};
|
||||
}
|
||||
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;
|
||||
globalThis.confirm = () => true;
|
||||
globalThis.alert = noop;
|
||||
globalThis.localStorage = { _d: {}, getItem(k) { return this._d[k] ?? null; }, setItem(k, v) { this._d[k] = String(v); }, removeItem(k) { delete this._d[k]; } };
|
||||
globalThis.document = {
|
||||
getElementById(id) { if (!els.has(id)) els.set(id, mkEl(id)); return els.get(id); },
|
||||
querySelector(s) { return mkEl(s); },
|
||||
querySelectorAll() { return []; },
|
||||
createElement(t) { const e = mkEl(); e.tagName = t.toUpperCase(); return e; },
|
||||
addEventListener: addL, body: mkEl('body'),
|
||||
};
|
||||
|
||||
let fail = 0;
|
||||
const ok = (c, m) => { console.log((c ? ' ✔ ' : ' ✗ ') + m); if (!c) fail++; };
|
||||
const resetC = () => { for (const k in C) C[k] = 0; };
|
||||
|
||||
const { Editor } = await import('../js/editor.js');
|
||||
const { PALETTE } = await import('../js/editor.js');
|
||||
|
||||
console.log('=== 编辑器初始化(会经 buildPalette 渲染全部调色板方块)===');
|
||||
try {
|
||||
Editor.init({ startLevel: noop });
|
||||
ok(true, 'Editor.init 成功(所有调色板方块缩略图渲染无异常)');
|
||||
} catch (e) { ok(false, 'Editor.init 失败: ' + e.message); console.log(e.stack.split('\n').slice(0, 4).join('\n')); }
|
||||
|
||||
console.log('=== 打开空白地图并绘制 ===');
|
||||
Editor.open();
|
||||
const f = Editor.curFace();
|
||||
ok(f && f.w > 0 && f.h > 0, 'open 生成可编辑面(' + f.w + '×' + f.h + ')');
|
||||
|
||||
console.log('=== 刺在编辑器里以三角形绘制 ===');
|
||||
f.grid[1][1] = '^';
|
||||
resetC();
|
||||
Editor.draw();
|
||||
ok(C.lineTo > 0 && C.beginPath > 0, '刺:用三角形图元绘制(lineTo=' + C.lineTo + ', beginPath=' + C.beginPath + ')');
|
||||
|
||||
console.log('=== 方块在编辑器里以真实纹理绘制 ===');
|
||||
f.grid[1][1] = '#';
|
||||
resetC();
|
||||
Editor.draw();
|
||||
ok(C.fillRect > 0, '方块:实心纹理绘制(fillRect=' + C.fillRect + ')');
|
||||
|
||||
console.log('=== 每种方块类型在编辑器画布都能正常绘制不抛异常 ===');
|
||||
let bad = 0;
|
||||
for (const [ch] of PALETTE) {
|
||||
if (ch === '.') continue;
|
||||
try { f.grid[2][2] = ch; Editor.draw(); }
|
||||
catch (e) { console.log(' ✗ ' + ch + ': ' + e.message); bad++; }
|
||||
}
|
||||
ok(bad === 0, '全部 ' + PALETTE.filter(p => p[0] !== '.').length + ' 种方块绘制无异常');
|
||||
|
||||
console.log(fail === 0 ? '\n✅ 编辑器所见即所得 全部断言通过' : `\n❌ 失败 ${fail} 项`);
|
||||
process.exit(fail === 0 ? 0 : 1);
|
||||
+1
-1
@@ -60,7 +60,7 @@ function run(tail, label, releaseAt = 1e9, jumpAt = 1e9) {
|
||||
return { log, crossAt, off };
|
||||
}
|
||||
|
||||
// A:一直按住右键冲出冰面 —— 惯性要把 4.05 平滑收敛到正常走速,不能瞬间刹停也不能一直超速
|
||||
// A:一直按住右键冲出冰面 —— 惯性要把 4.32 平滑收敛到正常走速,不能瞬间刹停也不能一直超速
|
||||
{
|
||||
const { off } = run('='.repeat(34), 'A 冰面 -> 普通地面(一直按住右键)');
|
||||
const v0 = off[0].vx, v10 = off[10]?.vx ?? 0, v30 = off[30]?.vx ?? 0;
|
||||
|
||||
+22
-7
@@ -1,7 +1,7 @@
|
||||
// 共享可达性 BFS 核心:solve.mjs 与 reach-diag.mjs 共用
|
||||
// 用真实物理常量模拟走/落/跳(含二段跳时机、空中转向、松键短跳)
|
||||
export const TILE = 32, PW = 11, PH = 21;
|
||||
export const GRAV = 0.4, MAXFALL = 9, JUMP = 8.5, DJUMP = 7.6, HSPD = 3;
|
||||
export const GRAV = 0.25, GRAV_FALL = 0.32, GRAV_DJ = 0.35, MAXFALL = 9, JUMP = 6.6, DJUMP = 6.7, HSPD = 2.6, DJ_INERTIA = 0;
|
||||
|
||||
export function setupGlobals() {
|
||||
const handlers = {};
|
||||
@@ -49,12 +49,14 @@ export function makeSim(L) {
|
||||
const overlap = (a, b) => a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
|
||||
|
||||
function jump(fx, fy, vx0, djump, flip, rel, targets) {
|
||||
let x = fx, y = fy - PH, vy = -JUMP, vx = vx0, t = 0, used = false;
|
||||
let x = fx, y = fy - PH, vy = -JUMP, vx = vx0, t = 0, used = false, djUp = false;
|
||||
while (t++ < 240) {
|
||||
if (djump && !used && t === djump) { vy = -DJUMP; used = true; }
|
||||
// 与 player.js 一致:上升中按二段 = 直接 -DJUMP;下落中按只抵消 DJ_INERTIA 比例的下落速度
|
||||
if (djump && !used && t === djump) { vy = Math.max(vy, 0) * DJ_INERTIA - DJUMP; used = true; djUp = vy < 0; }
|
||||
if (flip && t === flip) vx = -vx;
|
||||
if (rel && t === rel && vy < -3) vy = -3;
|
||||
vy = Math.min(vy + GRAV, MAXFALL);
|
||||
vy = Math.min(vy + (vy < 0 ? (djUp ? GRAV_DJ : GRAV) : GRAV_FALL), MAXFALL);
|
||||
if (djUp && vy >= 0) djUp = false;
|
||||
// 真实行为(player.js):本帧被墙挡住只是没推动,按住方向键下一帧 vx 仍为 ±3。
|
||||
// 绝不能把 vx 永久清零,否则"贴着墙上升、越过台面边缘后再横移"这条经典路线会丢失。
|
||||
if (vx !== 0) {
|
||||
@@ -83,7 +85,7 @@ export function makeSim(L) {
|
||||
let x = fx + dir * 3, y = fy - PH, vy = 0, t = 0;
|
||||
if (solidAt(x, y, PW, PH, false)) return null;
|
||||
while (t++ < 400) {
|
||||
vy = Math.min(vy + GRAV, MAXFALL);
|
||||
vy = Math.min(vy + (vy < 0 ? GRAV : GRAV_FALL), MAXFALL);
|
||||
const prevBottom = y + PH;
|
||||
let rem = Math.abs(vy), landed = false;
|
||||
while (rem > 0.0001) {
|
||||
@@ -107,7 +109,10 @@ export function makeSim(L) {
|
||||
|
||||
/** BFS 可达性:targets=[{rect,type,...}],onState 回调每个新状态 */
|
||||
export const DEF_DJ = [0, 6, 12, 18, 24, 30];
|
||||
export const DEF_REL = [0, 4, 8, 14];
|
||||
// 2026-10-23:加 rel=5/6。vgap 夹缝(缝高 32px、盒高 21px)要求短跳 apex 精确落在 32~43px,
|
||||
// 且上升早期要躲开地刺左坡:rel=4 apex 35.7 但 t=7 蹭坡 2.6px,rel=6 apex 44.7 但 t=11 擦垂刺尖 0.6px,
|
||||
// 只有 rel=5(apex 38.9)同时满足(j2 x16 夹缝实证);j7 顶层 x52 夹缝则要 rel=6
|
||||
export const DEF_REL = [0, 4, 5, 6, 8, 14];
|
||||
|
||||
export function reach(L, { DJSET = DEF_DJ, RELSET = DEF_REL, targets = [], onState = null, maxStates = 40000 } = {}) {
|
||||
const sim = makeSim(L);
|
||||
@@ -118,7 +123,16 @@ export function reach(L, { DJSET = DEF_DJ, RELSET = DEF_REL, targets = [], onSta
|
||||
}
|
||||
if (!start) return { err: '出生点下方找不到落脚点', states: 0, targets };
|
||||
|
||||
const key = s => `${Math.round(s.fx / 6)},${Math.round(s.fy / 6)}`;
|
||||
// 3px 状态桶:6px 桶会把"贴右腰 1~3px"的起拍/落点窗量化吞掉(j2 连续三段因此误判不可达)
|
||||
// fy 规范化:jump/fall 返回的落点常比地面高零点几 px(如 1535.5),与精确落点 1536 同桶——
|
||||
// 先占桶者赢,导致走链被错位几 px 的跳链顶掉,芝麻距离的跳跃窗口翻转(加 rel=6 后 j2 状态数反而暴跌的根因)。
|
||||
// 真实游戏里重力下一帧即把玩家按到底(地面顶 = 32 的倍数),故按 canStand 门控 snapping
|
||||
const key = s => `${Math.round(s.fx / 3)},${Math.round(s.fy / 3)}`;
|
||||
const canon = (fx, fy) => {
|
||||
const r = Math.round(fy / TILE) * TILE;
|
||||
return (r !== fy && sim.canStand(fx, r)) ? r : fy;
|
||||
};
|
||||
start.fy = canon(start.fx, start.fy);
|
||||
const seen = new Set([key(start)]);
|
||||
const q = [start];
|
||||
let n = 0;
|
||||
@@ -128,6 +142,7 @@ export function reach(L, { DJSET = DEF_DJ, RELSET = DEF_REL, targets = [], onSta
|
||||
for (const tg of targets) if (!tg.hit && sim.overlap(box, tg.rect)) tg.hit = true;
|
||||
const push = r => {
|
||||
if (!r || !sim.canStand(r.fx, r.fy)) return;
|
||||
r.fy = canon(r.fx, r.fy);
|
||||
const k = key(r);
|
||||
if (seen.has(k)) return;
|
||||
seen.add(k); q.push(r);
|
||||
|
||||
@@ -12,7 +12,7 @@ const L = new Level(d);
|
||||
|
||||
const byRow = new Map();
|
||||
const DJSET = (process.env.DJ || '0,6,12,18,24,30').split(',').map(Number);
|
||||
const RELSET = (process.env.REL || '0,4,8,14').split(',').map(Number);
|
||||
const RELSET = (process.env.REL || '0,4,5,6,8,14').split(',').map(Number);
|
||||
const r = reach(L, {
|
||||
DJSET, RELSET,
|
||||
onState: s => {
|
||||
|
||||
+91
-2
@@ -30,6 +30,7 @@ class El {
|
||||
remove() { if (this.parent) { const i = this.parent.children.indexOf(this); if (i >= 0) this.parent.children.splice(i, 1); } }
|
||||
querySelector() { return new El(); }
|
||||
querySelectorAll(sel) { return sel === '.tc-btn' ? this.children.filter(c => c._classes.has('tc-btn')) : []; }
|
||||
closest(sel) { const cls = sel.replace(/^\./, ''); let n = this; while (n) { if (n._classes && n._classes.has(cls)) return n; n = n.parent; } return null; }
|
||||
addEventListener(type, fn) { (this._ev[type] ||= []).push(fn); }
|
||||
removeEventListener() { }
|
||||
dispatch(type, ev) { (this._ev[type] || []).forEach(fn => fn(ev)); }
|
||||
@@ -40,6 +41,7 @@ class El {
|
||||
|
||||
const byId = new Map();
|
||||
const touchMap = new Map();
|
||||
const docListeners = {};
|
||||
globalThis.localStorage = {
|
||||
_d: {},
|
||||
getItem(k) { return k in this._d ? this._d[k] : null; },
|
||||
@@ -55,8 +57,13 @@ globalThis.document = {
|
||||
return new El();
|
||||
},
|
||||
querySelectorAll() { return []; },
|
||||
addEventListener(type, fn) { (docListeners[type] ||= []).push(fn); },
|
||||
removeEventListener() { },
|
||||
dispatch(type, ev) { (docListeners[type] || []).forEach(fn => fn(ev)); },
|
||||
};
|
||||
globalThis.window = { addEventListener: noop, removeEventListener: noop };
|
||||
globalThis.addEventListener = (t, fn) => { (docListeners[t] ||= []).push(fn); };
|
||||
globalThis.removeEventListener = noop;
|
||||
globalThis.confirm = () => true;
|
||||
|
||||
let fail = 0;
|
||||
@@ -64,9 +71,15 @@ const ok = (c, m) => { console.log((c ? ' ✔ ' : ' ✗ ') + m); if (!c) fail+
|
||||
|
||||
const { Save } = await import('../js/save.js');
|
||||
const { Settings } = await import('../js/settings.js');
|
||||
const { applyBindings, defaultBindings } = await import('../js/input.js');
|
||||
|
||||
console.log('=== 初始化 / 默认布局 ===');
|
||||
Settings.init();
|
||||
console.log('=== input 键位 API ===');
|
||||
ok(defaultBindings().left.includes('ArrowLeft'), 'defaultBindings 含出厂左移键');
|
||||
applyBindings({ left: ['KeyA'], jump: ['Space'] });
|
||||
ok(true, 'applyBindings 调用不报错');
|
||||
|
||||
console.log('=== 初始化 / 默认布局(PC 模式)===');
|
||||
Settings.init(false);
|
||||
const s0 = Save.data.settings;
|
||||
ok(!!s0 && !!s0.touch, '存档含 settings.touch');
|
||||
const keys = ['left', 'right', 'jump', 'shoot', 'down', 'pause', 'suicide'];
|
||||
@@ -130,5 +143,81 @@ ok(wt.left.size > size0 && wt.left.size <= 160, `缩放:尺寸随距离放大
|
||||
handle.dispatch('pointerup', ev(handle, 176, 566));
|
||||
ok(wt.left.cx === cx0 && wt.left.cy === cy0, '缩放:不改变按钮位置');
|
||||
|
||||
console.log('=== 设备模式:PC 显示键盘映射,移动端显示手柄 ===');
|
||||
Settings.init(true); // isTouch=true → 移动端
|
||||
Settings.enter();
|
||||
ok(byId.get('set-keys-row')._classes.has('hidden'), '移动端:键盘映射面板隐藏');
|
||||
ok(!byId.get('set-touch-row')._classes.has('hidden'), '移动端:手柄面板显示');
|
||||
Settings.init(false); // PC
|
||||
Settings.enter();
|
||||
ok(!byId.get('set-keys-row')._classes.has('hidden'), 'PC:键盘映射面板显示');
|
||||
ok(byId.get('set-touch-row')._classes.has('hidden'), 'PC:手柄面板隐藏');
|
||||
|
||||
console.log('=== 键盘重绑:点击按键 → 按新键 ===');
|
||||
Settings.enter();
|
||||
const keysBox = byId.get('set-keys');
|
||||
const firstRow = keysBox.children[0]; // 左移行
|
||||
const kkeys = firstRow.children[1]; // kkeys 容器
|
||||
const chip = kkeys.children[0]; // 第一个按键 chip(left[0])
|
||||
ok(chip.dataset.kact === 'left' && String(chip.dataset.kidx) === '0', '首行首键为 left[0]');
|
||||
keysBox.dispatch('click', { target: chip }); // 进入重绑监听
|
||||
document.dispatch('keydown', { code: 'KeyB', preventDefault: noop }); // KeyB 未被任何动作占用
|
||||
ok(Settings._state().keys.left[0] === 'KeyB', '重绑:left[0] 变为空闲键 KeyB');
|
||||
// 添加第二个键
|
||||
const before = Settings._state().keys.left.length;
|
||||
const addBtn = kkeys.children[kkeys.children.length - 1]; // +添加
|
||||
keysBox.dispatch('click', { target: addBtn });
|
||||
document.dispatch('keydown', { code: 'KeyF', preventDefault: noop }); // KeyF 空闲
|
||||
ok(Settings._state().keys.left.length === before + 1 && Settings._state().keys.left.includes('KeyF'), '添加:left 多绑定了一个键 KeyF');
|
||||
// 删除第一个键(即刚重绑的 KeyB)
|
||||
const chip1 = kkeys.children[0];
|
||||
const rm = chip1.children.find(c => c._classes.has('krm'));
|
||||
keysBox.dispatch('click', { target: rm });
|
||||
ok(!Settings._state().keys.left.includes('KeyB') && Settings._state().keys.left.includes('KeyF'), '删除:left[0] 被移除(KeyB 消失,KeyF 仍在)');
|
||||
// 确定后落盘
|
||||
Settings.confirm();
|
||||
ok(Save.data.settings.keys.left.includes('KeyF'), '确定:键位变更已保存到存档');
|
||||
|
||||
console.log('=== 键盘冲突检测 ===');
|
||||
Settings.reset(); // 出厂:left=[ArrowLeft,KeyA]
|
||||
Settings.enter();
|
||||
const kb = byId.get('set-keys');
|
||||
const jumpRow = kb.children[3]; // 跳跃行(KEY_ACTIONS 第 4 项,down 已移除)
|
||||
const jumpKeys = jumpRow.children[1];
|
||||
const jumpChip = jumpKeys.children[0];
|
||||
ok(jumpChip.dataset.kact === 'jump', '找到跳跃行的按键');
|
||||
kb.dispatch('click', { target: jumpChip }); // 监听跳跃键
|
||||
document.dispatch('keydown', { code: 'KeyA', preventDefault: noop }); // KeyA 已被左移占用
|
||||
const st2 = Settings._state();
|
||||
ok(!st2.keys.jump.includes('KeyA'), '冲突:jump 不会绑定到被【左移】占用的 KeyA');
|
||||
ok(Settings._conflict() && Settings._conflict().act === 'jump' && Settings._conflict().label === '左移', '冲突:记录到被【左移】占用');
|
||||
ok(kb.children[0] && kb.children[0]._classes.has('kwarb'), '冲突:面板顶部显示警告横幅');
|
||||
document.dispatch('keydown', { code: 'KeyK', preventDefault: noop }); // 换空闲键
|
||||
ok(st2.keys.jump[0] === 'KeyK', '冲突解除:改用空闲键 KeyK 成功');
|
||||
ok(Settings._conflict() === null, '冲突:解除后警告清空');
|
||||
// 同一动作内重复键也算冲突(jump 已含 KeyK,再绑到第二个槽应被拒)
|
||||
kb.dispatch('click', { target: jumpKeys.children[jumpKeys.children.length - 1] }); // +添加
|
||||
document.dispatch('keydown', { code: 'KeyK', preventDefault: noop });
|
||||
ok(st2.keys.jump.filter(c => c === 'KeyK').length === 1, '冲突:同一动作内重复键不重复写入');
|
||||
// Esc 取消监听
|
||||
kb.dispatch('click', { target: jumpChip });
|
||||
document.dispatch('keydown', { code: 'Escape', preventDefault: noop });
|
||||
ok(Settings._listening() === null && Settings._conflict() === null, 'Esc:退出监听且无残留警告');
|
||||
// 系统动作(菜单 KeyQ)也算冲突
|
||||
kb.dispatch('click', { target: jumpChip });
|
||||
document.dispatch('keydown', { code: 'KeyQ', preventDefault: noop });
|
||||
ok(Settings._conflict() && Settings._conflict().label === '菜单', '冲突:KeyQ 已被系统「菜单」占用');
|
||||
|
||||
console.log('=== 恢复默认:键位回到出厂 ===');
|
||||
Settings.reset();
|
||||
ok(Settings._state().keys.left.includes('ArrowLeft'), '恢复默认:left 含出厂 ArrowLeft');
|
||||
ok(Settings._state().keys.jump.includes('ShiftLeft'), '恢复默认:jump 含出厂 ShiftLeft');
|
||||
|
||||
console.log('=== 回归:main.js 中「确定」应返回上一页 ===');
|
||||
const mainSrc = (await import('node:fs')).readFileSync(new URL('../js/main.js', import.meta.url), 'utf8');
|
||||
ok(/settings:\s*\(\)\s*=>\s*\{\s*settingsFrom\s*=\s*UI\.cur/.test(mainSrc), '进入设置前记录来源界面');
|
||||
ok(/'set-ok':\s*\(\)\s*=>\s*\{\s*Settings\.confirm\(\);\s*UI\.show\(settingsFrom\)/.test(mainSrc), '确定后 UI.show(settingsFrom) 返回上一页');
|
||||
ok(/Settings\.init\(useTouch\)/.test(mainSrc), 'Settings.init 传入设备检测结果');
|
||||
|
||||
console.log(fail === 0 ? '\n✅ settings 全部断言通过' : `\n❌ 失败 ${fail} 项`);
|
||||
process.exit(fail === 0 ? 0 : 1);
|
||||
+1
-1
@@ -6,7 +6,7 @@ setupGlobals();
|
||||
const { LEVELS } = await import('../js/levels/index.js');
|
||||
|
||||
const DJSET = (process.env.DJ || '0,6,12,18,24,30').split(',').map(Number);
|
||||
const RELSET = (process.env.REL || '0,4,8,14').split(',').map(Number);
|
||||
const RELSET = (process.env.REL || '0,4,5,6,8,14').split(',').map(Number);
|
||||
|
||||
let fail = 0;
|
||||
console.log(`=== 可达性求解 (DJ=${DJSET} REL=${RELSET}) ===`);
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
// 单三角形刺:视觉与碰撞形状完全对应的单元测试
|
||||
// 验证 spikeTri 顶点、triRectOverlap 的正确性,以及 deadlyRect 对刺的判定。
|
||||
const noop = () => { };
|
||||
const ctx2d = new Proxy({
|
||||
canvas: { width: 800, height: 608 },
|
||||
measureText: () => ({ width: 12 }),
|
||||
createLinearGradient: () => ({ addColorStop: noop }),
|
||||
createRadialGradient: () => ({ addColorStop: noop }),
|
||||
getImageData: () => ({ data: [] }),
|
||||
}, { get(t, k) { return k in t ? t[k] : noop; }, set(t, k, v) { t[k] = v; return true; } });
|
||||
|
||||
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: noop, removeEventListener: noop, AudioContext: null, webkitAudioContext: null, innerWidth: 1280, innerHeight: 800, location: globalThis.location, navigator: globalThis.navigator, matchMedia: globalThis.matchMedia };
|
||||
globalThis.document = { getElementById: () => ({ getContext: () => ctx2d, style: {}, classList: { add: noop, remove: noop, toggle: noop }, addEventListener: noop, appendChild: noop, querySelector: () => ({ getContext: () => ctx2d }), querySelectorAll: () => [] }), querySelector: () => ({ getContext: () => ctx2d }), querySelectorAll: () => [], createElement: () => ({ getContext: () => ctx2d, style: {}, classList: { add: noop, remove: noop } }), addEventListener: noop, body: {} };
|
||||
globalThis.localStorage = { _d: {}, getItem(k) { return this._d[k] ?? null; }, setItem(k, v) { this._d[k] = String(v); }, removeItem(k) { delete this._d[k]; } };
|
||||
globalThis.confirm = () => true;
|
||||
globalThis.alert = noop;
|
||||
|
||||
const { Level, T, spikeTri, triRectOverlap } = await import('../js/level.js');
|
||||
const { TILE } = await import('../js/util.js');
|
||||
|
||||
let fail = 0;
|
||||
const ok = (c, m) => { console.log((c ? ' ✔ ' : ' ✗ ') + m); if (!c) fail++; };
|
||||
const near = (a, b, e = 1e-6) => Math.abs(a - b) < e;
|
||||
|
||||
console.log('=== spikeTri 顶点(与 drawSpike 一致)===');
|
||||
const s = TILE;
|
||||
{
|
||||
const tr = spikeTri(T.SPIKE_U, 0, 0);
|
||||
ok(near(tr.ax, s / 2) && near(tr.ay, 0) && near(tr.bx, 0) && near(tr.by, s) && near(tr.cx, s) && near(tr.cy, s), 'SPIKE_U:尖在顶中,底在底部');
|
||||
const tr2 = spikeTri(T.SPIKE_D, 0, 0);
|
||||
ok(near(tr2.ax, s / 2) && near(tr2.ay, s) && near(tr2.bx, 0) && near(tr2.by, 0), 'SPIKE_D:尖在底中,底在顶部');
|
||||
const tr3 = spikeTri(T.SPIKE_L, 0, 0);
|
||||
ok(near(tr3.ax, 0) && near(tr3.ay, s / 2) && near(tr3.bx, s) && near(tr3.by, 0) && near(tr3.cx, s) && near(tr3.cy, s), 'SPIKE_L:尖在左中,底在右侧');
|
||||
const tr4 = spikeTri(T.SPIKE_R, 0, 0);
|
||||
ok(near(tr4.ax, s) && near(tr4.ay, s / 2) && near(tr4.bx, 0) && near(tr4.by, 0), 'SPIKE_R:尖在右中,底在左侧');
|
||||
}
|
||||
|
||||
console.log('=== triRectOverlap 基本几何 ===');
|
||||
{
|
||||
// 上刺三角形:A(16,0) B(0,32) C(32,32)(TILE=32)
|
||||
const A = spikeTri(T.SPIKE_U, 0, 0);
|
||||
// 完全在三角形内部的矩形
|
||||
ok(triRectOverlap(A.ax, A.ay, A.bx, A.by, A.cx, A.cy, 14, 20, 4, 4), '三角形内部矩形 → 相交');
|
||||
// 在三角形“上方窄尖之外”的矩形(顶部左侧,AABB 内但三角外)
|
||||
ok(!triRectOverlap(A.ax, A.ay, A.bx, A.by, A.cx, A.cy, 1, 1, 4, 4), '尖顶外侧矩形(AABB 内、三角外)→ 不相交');
|
||||
// 完全在三角形之外(远离)
|
||||
ok(!triRectOverlap(A.ax, A.ay, A.bx, A.by, A.cx, A.cy, 100, 100, 4, 4), '远处矩形 → 不相交');
|
||||
// AABB 都不重叠
|
||||
ok(!triRectOverlap(A.ax, A.ay, A.bx, A.by, A.cx, A.cy, 50, 0, 4, 4), 'AABB 不重叠 → 不相交');
|
||||
// 底边左角(上刺底部很宽)——应当相交
|
||||
ok(triRectOverlap(A.ax, A.ay, A.bx, A.by, A.cx, A.cy, 1, 28, 4, 4), '上刺底左角矩形 → 相交(形状精确:底宽尖窄)');
|
||||
}
|
||||
|
||||
console.log('=== deadlyRect 集成(上刺占一格)===');
|
||||
{
|
||||
const data = { name: 't', width: 3, grid: ['###', '#^#', '###'] };
|
||||
const L = new Level(data);
|
||||
ok(L.tiles[1][1] === T.SPIKE_U, '网格解析:中心格子为 SPIKE_U');
|
||||
const cx = 1 * s, cy = 1 * s; // 刺格像素 [32,32]-[64,64]
|
||||
// 顶部左侧(尖窄处,三角外)→ 不致死
|
||||
ok(!L.deadlyRect(cx + 1, cy + 1, 4, 4), '上刺尖顶外侧的碰撞盒 → 不致死');
|
||||
// 底部中央(基底宽处)→ 致死
|
||||
ok(L.deadlyRect(cx + 13, cy + 26, 8, 4), '上刺底部中央的碰撞盒 → 致死');
|
||||
// 底左角 → 致死
|
||||
ok(L.deadlyRect(cx + 1, cy + 26, 6, 4), '上刺底左角的碰撞盒 → 致死(形状精确)');
|
||||
// 相邻空格(格外)→ 不致死
|
||||
ok(!L.deadlyRect(0, 0, 11, 21), '完全在空地上的碰撞盒 → 不致死');
|
||||
}
|
||||
|
||||
console.log('=== 形状精确性对比:旧矩形近似会把底左角判为安全,现在判为致死 ===');
|
||||
{
|
||||
// 旧 spikeBox 对上刺 x+6..x+10, y+8.. :底左角 (cx+1,cy+26) 落在 x+6 之外,旧逻辑安全;
|
||||
// 新三角在底部很宽,应致死。下面断言新逻辑与“旧近似矩形”在该点结论相反(证明形状更精确)。
|
||||
const data = { name: 't', width: 3, grid: ['###', '#^#', '###'] };
|
||||
const L = new Level(data);
|
||||
const cx = s, cy = s;
|
||||
const oldSafe = (cx + 1 < cx + 6); // 旧矩形左界 x+6,该点在其左 → 旧逻辑“安全”
|
||||
const newDeadly = L.deadlyRect(cx + 1, cy + 26, 3, 3);
|
||||
ok(oldSafe && newDeadly, '底左角:旧矩形近似判安全,新三角判致死(符合“底宽尖窄”的真实形状)');
|
||||
}
|
||||
|
||||
console.log(fail === 0 ? '\n✅ 单三角形刺(视觉=碰撞)全部断言通过' : `\n❌ 失败 ${fail} 项`);
|
||||
process.exit(fail === 0 ? 0 : 1);
|
||||
Reference in new issue
Block a user