diff --git a/js/editor.js b/js/editor.js
index 8057572..fd2044b 100644
--- a/js/editor.js
+++ b/js/editor.js
@@ -2,12 +2,13 @@
import { TILE } from './util.js';
import { Save } from './save.js';
import { makeCustomLevel } from './levels/index.js';
+import * as S from './sprites.js';
const $ = s => document.querySelector(s);
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
-const PALETTE = [
- ['?', '查看属性', '#8a8a9a'], ['#', '方块', '#6b6b7e'], ['@', '草地', '#4f9e46'], ['=', '砖块', '#a8543c'], ['M', '金属', '#5a6478'],
+export const PALETTE = [
+ ['#', '方块', '#6b6b7e'], ['@', '草地', '#4f9e46'], ['=', '砖块', '#a8543c'], ['M', '金属', '#5a6478'],
['%', '冰面', '#8fd8f0'], ['~', '水', '#2a6fd8'], ['-', '单向台', '#9aa6bd'], ['V', '消失块', '#7a7a8c'],
['^', '刺', '#c8c8d8'],
['*', '美味水果', '#e03050'], ['S', '出生点', '#3ddc84'], ['G', '存档点', '#b06bff'], ['X', '终点', '#ffc63a'],
@@ -42,10 +43,58 @@ const SCHEMA = {
function charLabel(ch) { const it = PALETTE.find(p => p[0] === ch); return it ? it[1] : ch; }
+// 把某个方块按「游戏内真实外观」画到 ctx 的 (px,py) 处 —— 编辑器所见即所得
+function drawTileReal(ctx, ch, px, py, props, t) {
+ const P = props || {};
+ switch (ch) {
+ case '#': S.drawBlock(ctx, px, py, 0); break; // 方块
+ case '@': S.drawBlock(ctx, px, py, 1); break; // 草地
+ case '=': S.drawBlock(ctx, px, py, 2); break; // 砖块
+ case 'M': S.drawBlock(ctx, px, py, 3); break; // 金属
+ case '%': S.drawIce(ctx, px, py); break; // 冰面
+ case '~': S.drawWater(ctx, px, py, t); break; // 水
+ case '-': S.drawOneWay(ctx, px, py); break; // 单向台
+ case 'V': S.drawVanish(ctx, px, py, 0, t); break; // 消失块
+ case '^': { // 刺:真实三角形,按道具方向
+ const di = { up: 0, down: 1, left: 2, right: 3 }[P.dir || 'up'] || 0;
+ S.drawSpike(ctx, px, py, di, t);
+ break;
+ }
+ case '*': S.drawKiller(ctx, px, py, t); break; // 美味水果
+ case 'D': S.drawDropSpike(ctx, { x: px, y: py, falling: false }, t); break; // 掉落刺
+ case 'X': S.drawGoal(ctx, { x: px, y: py, w: TILE, h: TILE }, t); break; // 终点
+ case 'G': S.drawSave(ctx, { x: px, y: py, w: TILE, h: TILE, active: false }, t); break; // 存档点
+ case 'P': S.drawGun(ctx, { x: px, y: py }, t); break; // 手枪
+ case 'F': S.drawApple(ctx, { x: px, y: py, w: TILE, h: TILE }, t); break; // 苹果
+ case 'J': S.drawJumpRef(ctx, px, py, t); break; // 跳刷新
+ case 'W': S.drawWarp(ctx, { x: px, y: py, w: TILE, h: TILE }, t); break; // 传送门
+ case 'T': S.drawSign(ctx, { x: px, y: py, w: TILE, h: TILE }, t); break; // 提示牌
+ case 'O': S.drawTurret(ctx, { x: px, y: py, dir: P.dir || 'left' }, t); break; // 炮台
+ case 'C': S.drawCamera(ctx, { x: px, y: py, w: TILE, h: TILE }, t); break; // 摄像头
+ case 'S': S.drawKid(ctx, { // 出生点:画一个小人
+ x: px + (TILE - 11) / 2, y: py + (TILE - 21), w: 11, h: 21,
+ facing: 1, grounded: true, hasGun: false, dead: false,
+ }, t); break;
+ default: drawMarker(ctx, px, py, ch); break; // BOSS / 触发器等无独立美术:色块+字母兜底
+ }
+}
+
+// 没有独立 sprite 的方块(BOSS / 触发器等):保留「色块 + 字母」保证可见
+function drawMarker(ctx, px, py, ch) {
+ const item = PALETTE.find(p => p[0] === ch);
+ const col = item ? item[2] : '#555';
+ ctx.fillStyle = col; ctx.globalAlpha = 0.85;
+ ctx.fillRect(px + 1, py + 1, TILE - 2, TILE - 2);
+ ctx.globalAlpha = 1;
+ ctx.fillStyle = '#fff';
+ ctx.font = 'bold 14px monospace';
+ ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
+ ctx.fillText(ch, px + TILE / 2, py + TILE / 2);
+}
+
// 编辑器中刺统一用 '^',方向存在 props.dir;导出时翻译成引擎的四个字符
const SPIKE_CHAR = { up: '^', down: 'v', left: '<', right: '>' };
const SPIKE_DIR = { '^': 'up', 'v': 'down', '<': 'left', '>': 'right' };
-const SPIKE_ARROW = { up: '↑', down: '↓', left: '←', right: '→' };
export const Editor = {
api: {},
@@ -70,10 +119,17 @@ export const Editor = {
buildPalette() {
const p = $('#palette');
p.innerHTML = '';
- PALETTE.forEach(([ch, label, col]) => {
+ PALETTE.forEach(([ch, label]) => {
const d = document.createElement('div');
d.className = 'pal' + (ch === this.sel ? ' on' : '');
- d.innerHTML = `
${ch === '?' ? '⌖' : ch}${label}`;
+ // 用真实绘制函数渲染缩略图,所见即所得
+ const cv = document.createElement('canvas');
+ cv.className = 'pal-cv'; cv.width = TILE; cv.height = TILE;
+ drawTileReal(cv.getContext('2d'), ch, 0, 0, null, 0);
+ d.appendChild(cv);
+ const sp = document.createElement('span');
+ sp.textContent = label;
+ d.appendChild(sp);
d.onclick = () => {
this.sel = ch;
Array.from(p.children).forEach(c => c.classList.remove('on'));
@@ -95,13 +151,11 @@ export const Editor = {
const f = this.curFace();
const { tx, ty } = pos(e);
if (tx < 0 || ty < 0 || tx >= f.w || ty >= f.h) return;
- // 选中该格(属性面板依据它渲染)
+ // 无论手上拿的是什么方块,点击格子都选中它,属性面板立即显示该方块的属性
this.selTile = { tx, ty };
- // 「查看属性」工具:只选中,不修改地图
- if (this.sel === '?') { this.renderProps(); return; }
const prev = f.grid[ty][tx];
const ch = erase ? '.' : (this.sel === '.' ? '.' : this.sel);
- // 放置不覆盖:放实方块时若该格已有方块则跳过(仍选中以便查看属性)
+ // 放置不覆盖:放实方块时若该格已有方块则跳过,仅选中查看属性
if (!erase && ch !== '.' && prev !== '.') { this.renderProps(); return; }
// 门的增删:放上传送门自动登记一扇门;擦掉或覆盖传送门则移除对应门
if (prev === 'W' && ch !== 'W') this.removeDoor(f, tx, ty);
@@ -240,34 +294,24 @@ export const Editor = {
ctx.fillStyle = '#101018'; ctx.fillRect(0, 0, c.width, c.height);
ctx.save();
ctx.translate(-this.scroll, 0);
+ const t = 0; // 编辑器为静态预览,不跑动画
for (let y = 0; y < f.h; y++) {
for (let x = 0; x < f.w; x++) {
const ch = f.grid[y][x];
const px = x * TILE, py = y * TILE;
- if (ch !== '.') {
- let col = '#555';
- const item = PALETTE.find(p => p[0] === ch);
- if (item) col = item[2];
- if (ch === 'W') { const d = f.doors.find(o => o.tx === x && o.ty === y); col = (d && d.to) ? '#3ddc84' : '#8a8a9a'; }
- ctx.fillStyle = col;
- ctx.globalAlpha = 0.85;
- ctx.fillRect(px + 1, py + 1, TILE - 2, TILE - 2);
- ctx.globalAlpha = 1;
- }
- ctx.strokeStyle = 'rgba(255,255,255,.07)';
+ // 真实外观绘制(刺→三角形,方块→纹理,实体→对应精灵)
+ if (ch !== '.') drawTileReal(ctx, ch, px, py, f.props[y][x] || null, t);
+ // 淡网格线(编辑对位用)
+ ctx.strokeStyle = 'rgba(255,255,255,.06)';
+ ctx.lineWidth = 1;
ctx.strokeRect(px + .5, py + .5, TILE - 1, TILE - 1);
- if (ch !== '.') {
- ctx.fillStyle = '#fff';
- ctx.font = 'bold 14px monospace';
- ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
- if (ch === 'W') {
- const d = f.doors.find(o => o.tx === x && o.ty === y);
- ctx.fillText('W' + (d ? d.id : ''), px + TILE / 2, py + TILE / 2);
- } else if (ch === '^') {
- // 刺:用箭头直观显示方向
- const dir = ((f.props[y][x] || {}).dir) || 'up';
- ctx.fillText(SPIKE_ARROW[dir] || '↑', px + TILE / 2, py + TILE / 2);
- } else ctx.fillText(ch, px + TILE / 2, py + TILE / 2);
+ // 传送门:叠加门编号与连接状态(编辑器元数据,游戏内无此显示)
+ if (ch === 'W') {
+ const d = f.doors.find(o => o.tx === x && o.ty === y);
+ ctx.fillStyle = (d && d.to) ? '#bfffd0' : '#ffe6a0';
+ ctx.font = 'bold 9px monospace';
+ ctx.textAlign = 'left'; ctx.textBaseline = 'top';
+ ctx.fillText('W' + (d ? d.id : ''), px + 2, py + 2);
}
}
}
diff --git a/js/input.js b/js/input.js
index 22f8698..29c2e8a 100644
--- a/js/input.js
+++ b/js/input.js
@@ -1,5 +1,5 @@
// 输入:IWBT 经典键位(Shift 跳 / Z 跳 / X 射击 / R 重来)
-const BIND = {
+export const BIND = {
left: ['ArrowLeft', 'KeyA'],
right: ['ArrowRight', 'KeyD'],
up: ['ArrowUp', 'KeyW'],
@@ -12,6 +12,9 @@ const BIND = {
menu: ['KeyQ'],
};
+// 出厂键位快照(供设置里的「恢复默认」使用)
+const DEFAULT_BIND = JSON.parse(JSON.stringify(BIND));
+
const down = new Set(); // 当前按住的原始 code
const now = {}; // action 是否按下
const prev = {}; // 上一帧
@@ -75,6 +78,15 @@ addEventListener('keyup', e => {
});
addEventListener('blur', () => { down.clear(); refresh(); });
+// ---------------- 键位自定义(设置界面调用) ----------------
+/** 返回一份出厂键位的深拷贝 */
+export function defaultBindings() { return JSON.parse(JSON.stringify(DEFAULT_BIND)); }
+/** 用一份 {action: [code,...]} 映射覆盖当前生效键位(设置确定后调用) */
+export function applyBindings(map) {
+ if (!map) return;
+ for (const act in map) if (act in BIND) BIND[act] = map[act].slice();
+}
+
// 初始化
for (const act in BIND) { now[act] = false; prev[act] = false; }
export const ACTIONS = Object.keys(BIND);
diff --git a/js/level.js b/js/level.js
index c0d66ab..5ff490d 100644
--- a/js/level.js
+++ b/js/level.js
@@ -328,14 +328,14 @@ export class Level {
for (let tx = x0; tx <= x1; tx++) {
const t = this.tileAt(tx, ty);
if (!DEADLY[t]) continue;
- const r = spikeBox(t, tx, ty);
- if (x < r.x + r.w && x + w > r.x && y < r.y + r.h && y + h > r.y) return true;
+ const tr = spikeTri(t, tx * TILE, ty * TILE);
+ if (triRectOverlap(tr.ax, tr.ay, tr.bx, tr.by, tr.cx, tr.cy, x, y, w, h)) return true;
}
}
for (const e of this.entities) {
if (e.type === 'dropspike' && e.falling) {
- const r = spikeBox(T.SPIKE_U, e.x / TILE, e.y / TILE);
- if (x < r.x + r.w && x + w > r.x && y < r.y + r.h && y + h > r.y) return true;
+ const tr = spikeTri(T.SPIKE_L, e.x, e.y); // 与 drawDropSpike 的 dir=2(朝左)一致
+ if (triRectOverlap(tr.ax, tr.ay, tr.bx, tr.by, tr.cx, tr.cy, x, y, w, h)) return true;
}
}
for (const b of this.ebullets) {
@@ -646,14 +646,33 @@ export class Level {
}
}
-// 刺的判定盒:对齐齿尖、收窄到齿根之间(IWBT 三角形判定的矩形近似)。
-// 配合 11×21 的玩家判定盒,可以像素级贴刺、穿对刺缝。
-function spikeBox(t, tx, ty) {
- const x = tx * TILE, y = ty * TILE;
- if (t === T.SPIKE_U) return { x: x + 6, y: y + 8, w: TILE - 12, h: TILE - 8 }; // 齿尖 y+5,缝高 24
- if (t === T.SPIKE_D) return { x: x + 6, y: y + 1, w: TILE - 12, h: TILE - 9 }; // 齿尖 y+27
- if (t === T.SPIKE_L) return { x: x + 5, y: y + 6, w: TILE - 10, h: TILE - 12 }; // 齿尖 x+5,缝宽 22
- if (t === T.SPIKE_R) return { x: x + 5, y: y + 6, w: TILE - 10, h: TILE - 12 }; // 齿尖 x+27
- return { x: x + 6, y: y + 6, w: TILE - 12, h: TILE - 10 }; // KILLER(美味水果)
+// 单三角形刺的顶点(与 sprites.drawSpike 完全一致)。
+// 尖端指向刺的方向,底边贴在相反一侧(地面 / 天花板 / 墙面)。
+function spikeTri(t, x, y) {
+ const s = TILE;
+ if (t === T.SPIKE_U) return { ax: x + s / 2, ay: y, bx: x, by: y + s, cx: x + s, cy: y + s };
+ if (t === T.SPIKE_D) return { ax: x + s / 2, ay: y + s, bx: x, by: y, cx: x + s, cy: y };
+ if (t === T.SPIKE_L) return { ax: x, ay: y + s / 2, bx: x + s, by: y, cx: x + s, cy: y + s };
+ if (t === T.SPIKE_R) return { ax: x + s, ay: y + s / 2, bx: x, by: y, cx: x, cy: y + s };
+ return { ax: x, ay: y, bx: x + s, by: y, cx: x, cy: y + s }; // 兜底(不应到达)
}
-export { spikeBox };
+
+// 三角形与轴对齐矩形的相交判定(分离轴定理,凸多边形充要条件)。
+function triRectOverlap(ax, ay, bx, by, cx, cy, rx, ry, rw, rh) {
+ const rMinX = rx, rMaxX = rx + rw, rMinY = ry, rMaxY = ry + rh;
+ const tMinX = Math.min(ax, bx, cx), tMaxX = Math.max(ax, bx, cx);
+ const tMinY = Math.min(ay, by, cy), tMaxY = Math.max(ay, by, cy);
+ if (tMaxX < rMinX || tMinX > rMaxX || tMaxY < rMinY || tMinY > rMaxY) return false; // AABB 快速排除
+ const edges = [[bx - ax, by - ay], [cx - bx, cy - by], [ax - cx, ay - cy]];
+ for (const [ex, ey] of edges) {
+ const nx = -ey, ny = ex; // 边法线
+ const t0 = nx * ax + ny * ay, t1 = nx * bx + ny * by, t2 = nx * cx + ny * cy;
+ const tMin = Math.min(t0, t1, t2), tMax = Math.max(t0, t1, t2);
+ const p0 = nx * rMinX + ny * rMinY, p1 = nx * rMaxX + ny * rMinY,
+ p2 = nx * rMinX + ny * rMaxY, p3 = nx * rMaxX + ny * rMaxY;
+ const rMin = Math.min(p0, p1, p2, p3), rMax = Math.max(p0, p1, p2, p3);
+ if (tMax < rMin || rMax < tMin) return false; // 找到分离轴
+ }
+ return true;
+}
+export { spikeTri, triRectOverlap };
diff --git a/js/levels/jump.js b/js/levels/jump.js
index 62cbd71..e8c2361 100644
--- a/js/levels/jump.js
+++ b/js/levels/jump.js
@@ -1,18 +1,20 @@
// 跳刺(Needle)10 关 —— IWBT 经典密集针刺风格
// 设计语法(已经 tools/solve.mjs 验证):
-// 刺毯缺口每 4 格一个(单跳节奏);钻石中心 = 地板上方 2~3 格;
+// 刺毯缺口每 3 格一个(跳 2 连刺 = 64px 单跳节奏);钻石中心 = 地板上方 2~3 格;
// 垂刺离最低落脚点 5 格(单跳安全、二段跳必死);2 格高矮洞可用撞头跳;
// 竖井台阶每级升 2~3 格横移 3 格内;钢丝柱间距 ≤4(单跳)/ ≤6(二段)。
import { Grid } from './kit.js';
import { vgap, hgap, diamond, spikeRun, block, corner, ceil, wall, well } from './needle.js';
// 刺毯:从 x0 起 n 格留落脚缺口。
-// 缺口间距在 4/3 格之间交替(跳 3 格 / 跳 4 格轮换),避免整关都是"每 4 格一跳"的单一套路;
-// 最远仍是 3 连刺(单跳极限),配合压顶垂刺时二段跳必死。
+// 缺口间距统一 3 格(跳 2 连刺 = 64px)。
+// 2026-10-23:JUMP 7.5 + GRAV_FALL 0.55 下单跳水平实测 118.8px(tools/jumpdist.mjs),
+// 但落地前一帧竖直速度已达 8.9px/帧,3 连刺(96px)的起拍窗被压到 0~1 帧(不可玩);
+// 2 连刺留 55px 射程余量,落地窗恢复充裕。井口/左端右行出口处的 3 连刺须显式拆断(见各关 gaps 注释)
const carpet = (m, x0, y, n, gaps = []) => {
const g = [];
- let i = 3, k = 0;
- while (i < n) { g.push(i); i += (k++ % 2 === 0 ? 4 : 3); }
+ let i = 3;
+ while (i < n) { g.push(i); i += 3; }
return spikeRun(m, x0, y, n, '^', g.concat(gaps));
};
@@ -58,7 +60,7 @@ export const JUMP_LEVELS = [
wall(m, 79, R - 5, R - 5, '<'); wall(m, 74, R - 7, R - 7, '>');
// --- 上层回廊(地板 row 12)x80-97 ---
m.rect(80, R - 10, 18, 1, '#');
- carpet(m, 81, R - 11, 15, [0]);
+ carpet(m, 81, R - 11, 15, [0, 8]); // 8=x89:给对刺门留左侧起拍位(新物理下跳台落点偏右,须能走进 x90)
ceil(m, 81, 95, R - 16); // row 6 垂刺
hgap(m, 92, R - 11); // 对刺门
m.set(82, R - 11, 'G'); m.set(94, R - 11, 'G');
@@ -82,7 +84,7 @@ export const JUMP_LEVELS = [
// ---- L0 地板 row 48(向右:刺毯 + 三道上下夹缝)----
m.rect(1, 48, 66, 1, '#');
m.set(3, 47, 'S');
- carpet(m, 5, 47, 50);
+ carpet(m, 5, 47, 50, [1, 18]); // 1=x6 出生旁缺口;18=x23 放宽落点窗(3 连刺落点贴右腰会被 6px 状态桶卡死)
ceil(m, 5, 54, 43);
vgap(m, 16, 46); vgap(m, 30, 46); vgap(m, 46, 46);
m.set(11, 47, 'G'); m.set(39, 47, 'G');
@@ -100,7 +102,7 @@ export const JUMP_LEVELS = [
m.set(2, 37, '^'); m.set(8, 35, '^');
// ---- L2 地板 row 32(向右,井底延伸到右井下方)----
m.rect(9, 32, 57, 1, '#');
- carpet(m, 11, 31, 46);
+ carpet(m, 11, 31, 46, [1, 11, 31]); // 1=x12 拆井口 3 连刺;11=x22/31=x42 是 hgap 夹缝通道列(必须为空,否则整门变 3 连刺、4 格跳超射程)
ceil(m, 11, 54, 27);
hgap(m, 22, 31); hgap(m, 42, 31);
vgap(m, 32, 30); vgap(m, 50, 30);
@@ -110,7 +112,7 @@ export const JUMP_LEVELS = [
m.set(65, 29, '^'); m.set(58, 27, '^');
// ---- L3 地板 row 24(向左,掉落刺;垂刺避开左井 x2..9)----
m.rect(2, 24, 56, 1, '#');
- carpet(m, 10, 23, 44);
+ carpet(m, 10, 23, 44, [0]); // 0=x10:拆掉左端 3 连刺(左行跳落点差 0.8px 蹭 x10 左坡;L1 同位置能过是因 vgap(12) 清空了 x10-14,L3 没有)
ceil(m, 10, 54, 19);
[14, 24, 34, 44].forEach(x => m.set(x, 18, 'D'));
vgap(m, 40, 22); vgap(m, 22, 22);
@@ -120,7 +122,7 @@ export const JUMP_LEVELS = [
m.set(2, 21, '^'); m.set(8, 19, '^');
// ---- L4 地板 row 16(向右到终点)----
m.rect(9, 16, 57, 1, '#');
- carpet(m, 11, 15, 52);
+ carpet(m, 11, 15, 52, [1, 41]); // 1=x12 拆井口 3 连刺;41=x52 是 hgap 夹缝通道列(必须为空)
ceil(m, 11, 60, 11);
hgap(m, 26, 15); vgap(m, 40, 14); hgap(m, 52, 15);
[18, 34, 56].forEach(x => m.set(x, 10, 'D'));
@@ -175,8 +177,8 @@ export const JUMP_LEVELS = [
m.border('#');
m.rect(1, R, W - 2, 1, '%'); // 冰地板
m.set(3, R - 1, 'S');
- // --- x6-30 冰面刺坑 ---
- for (const [a, b] of [[7, 10], [13, 16], [19, 22], [25, 29]])
+ // --- x6-30 冰面刺坑(3 格宽:新物理单跳 108.8px > 96px;旧 4~5 格坑跳不过)---
+ for (const [a, b] of [[7, 9], [13, 15], [19, 21], [25, 27]])
for (let x = a; x <= b; x++) { m.set(x, R, '.'); m.set(x, R + 1, '^'); }
ceil(m, 6, 24, R - 5); // 压顶垂刺:冰面上禁二段跳
m.set(12, R - 1, 'G');
@@ -284,10 +286,11 @@ export const JUMP_LEVELS = [
wall(m, 129, 26, 26, '<'); wall(m, 122, 23, 23, '>'); wall(m, 129, 20, 20, '<');
// ============ L1 中层(地板 row 16,向左折返,延伸到两侧井底)============
m.rect(1, 16, 122, 1, '#');
- carpet(m, 9, 15, 112);
+ carpet(m, 9, 15, 112, [86]); // 86=x95:补 hgap(95) 通道列(否则门芯留一根 '^',GRAV_FALL 0.40 下回跳轨迹下降慢正好嵌进去)
ceil(m, 9, 118, 10);
vgap(m, 108, 14); vgap(m, 82, 14); vgap(m, 56, 14); vgap(m, 30, 14);
hgap(m, 95, 15); hgap(m, 43, 15);
+ m.set(44, 15, '.'); // 拆掉对刺门左刺'<':GRAV 0.33 下从 x45 起跳第 3 帧必被刺尖别住(起拍窗窄到 2px),清掉后留 ^+> 双刺门,整格起拍都能过
block(m, 69, 15, 2); block(m, 17, 15, 2);
m.set(113, 15, 'G'); m.set(63, 15, 'G'); m.set(21, 15, 'G');
// ============ 左井 x2-7 → L2(右墙底部留 3 格入口,桥接块接 L2 地板边)============
@@ -377,10 +380,13 @@ export const JUMP_LEVELS = [
for (let x = 76; x <= 99; x++) { m.set(x, R, '.'); m.set(x, R + 1, '^'); }
m.set(78, R - 2, '-'); m.set(79, R - 2, '-');
m.set(83, R - 4, '-'); m.set(84, R - 4, '-');
- m.set(88, R - 6, '-'); m.set(89, R - 6, '-');
+ m.set(87, R - 6, '-'); m.set(88, R - 6, '-'); m.set(89, R - 6, '-');
m.set(93, R - 4, '-'); m.set(94, R - 4, '-');
m.set(97, R - 2, '-'); m.set(98, R - 2, '-');
- vgap(m, 86, R - 8); vgap(m, 93, R - 6);
+ // x86 只留压顶刺:JUMP 8.0 下 apex 盒底 479 与 '^' 顶点 480 只差 1px,
+ // 上升航线有 4 帧顶点落在盒内必死(8.5 时 apex 466 可从容擦过),故去掉地刺
+ m.set(86, R - 9, 'v');
+ vgap(m, 93, R - 6);
m.set(101, R - 1, 'G');
// --- x102-126 钻石收尾 ---
carpet(m, 102, R - 1, 24, [0, 1, 20, 21]);
@@ -405,7 +411,7 @@ export const JUMP_LEVELS = [
// ============ L0 下层(地板 row 28)============
m.rect(1, 28, W - 2, 1, '#');
m.set(3, 27, 'S');
- carpet(m, 5, 27, 116);
+ carpet(m, 5, 27, 116, [54]); // 54=x59:拆开矮洞入口的四格跳(洞顶 row24 会挡跳的横向漂移)
ceil(m, 5, 120, 22);
vgap(m, 14, 26); vgap(m, 38, 26);
vgap(m, 86, 26); vgap(m, 110, 26);
@@ -427,7 +433,7 @@ export const JUMP_LEVELS = [
wall(m, 126, 24, 24, '<'); wall(m, 119, 21, 21, '>'); wall(m, 126, 18, 18, '<');
// ============ L1 上层(地板 row 14,向左折返,延伸到井底)============
m.rect(2, 14, 118, 1, '#');
- carpet(m, 9, 13, 108);
+ carpet(m, 9, 13, 108, [27, 59]); // 27=x36 / 59=x68:补对刺门通道列(否则整门是一面 3 连刺,往回跳必被 < 刺别住)
ceil(m, 9, 116, 8);
hgap(m, 100, 13); hgap(m, 68, 13); hgap(m, 36, 13);
vgap(m, 84, 12); vgap(m, 52, 12); vgap(m, 20, 12);
@@ -498,6 +504,7 @@ export const JUMP_LEVELS = [
wires.push(wx);
for (const s of steps) { wx -= s; if (wx < 14) break; wires.push(wx); }
wires.forEach((x, i) => m.vline(x, [6, 5, 7, 6, 7, 5][i % 6], 7, '#'));
+ m.vline(55, 5, 7, '#'); // 过渡柱:x58→x52 原为 6 格跳,HSPD 2.6 下"晚二段"落点整体左移 8px 掉过柱顶摔死;x55 拆成 3+3 格两跳(dj=12 轨迹实测两跳都能精确接住)
// 存档放在两根钢丝顶上
const g1 = wires[6], g2 = wires[Math.floor(wires.length / 2) + 4];
const topOf = i => [6, 5, 7, 6, 7, 5][i % 6];
diff --git a/js/main.js b/js/main.js
index 963a0ef..fe4e378 100644
--- a/js/main.js
+++ b/js/main.js
@@ -290,6 +290,7 @@ function loop(now) {
}
// ---------------- 启动 ----------------
+let settingsFrom = 'scr-title'; // 进入设置前的界面,确定后返回
Save.load();
Audio2.enabled = Save.data.audio !== false;
@@ -297,10 +298,9 @@ UI.init({
play: (lv) => { G.fromEditor = false; startLevel(lv, lv.builtin === false ? -1 : (lv.index ?? -1)); },
levels: toLevels,
manage: () => UI.setManage(!UI.manage),
- help: () => UI.show('scr-help'),
editor: toEditor,
- settings: () => { Settings.enter(); UI.show('scr-settings'); },
- 'set-ok': () => Settings.confirm(),
+ settings: () => { settingsFrom = UI.cur; Settings.enter(); UI.show('scr-settings'); },
+ 'set-ok': () => { Settings.confirm(); UI.show(settingsFrom); },
'set-reset': () => Settings.reset(),
back: toTitle,
resume,
@@ -325,7 +325,7 @@ addEventListener('keydown', unlock);
// 移动端虚拟手柄
Touch.init({ pause, suicide, shoot });
-Settings.init();
+Settings.init(useTouch);
UI.show('scr-title');
requestAnimationFrame(loop);
diff --git a/js/player.js b/js/player.js
index ce65f5c..0d23951 100644
--- a/js/player.js
+++ b/js/player.js
@@ -6,11 +6,16 @@ import * as S from './sprites.js';
import { T } from './level.js';
export const PHY = {
- GRAV: 0.4,
+ 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: 8.5,
- DJUMP: 7.6,
- HSPD: 3,
+ 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,
@@ -23,6 +28,7 @@ export const PHY = {
SLIDE_BRAKE: 0.45, // 滑行中按反向键的刹车
SLIDE_MIN: 0.35, // 冰面上攒到多大速度才算进入滑行状态
SLIDE_STOP: 0.05, // 低于此速度惯性结束
+ ICE_TOP: 1.6, // 冰面最高速度 = HSPD × ICE_TOP(调大=冰上能滑得更快,留更猛的惯性)
};
export class Player {
@@ -38,12 +44,12 @@ export class Player {
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.squash = 1;
this.inWater = false;
this.animT = 0;
this.shootCool = 0;
@@ -78,7 +84,8 @@ export class Player {
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; this.sliding = false;
+ this.djUp = false;
+ this.shootCool = 0; this.sliding = false;
this.deadT = 0;
// 安全兜底:万一重生点被地形/消失块堵住,向上顶出,别一复活就卡死
let guard = 0;
@@ -107,7 +114,7 @@ export class Player {
if (moveL && !moveR) target = -PHY.HSPD;
else if (moveR && !moveL) target = PHY.HSPD;
- const ICE_MAX = PHY.HSPD * 1.35;
+ 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;
@@ -155,18 +162,22 @@ export class Player {
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.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;
- 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;
+ // 二段跳冲量叠减当前速度:上升中按 = 原版语义(直接获得 -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 });
@@ -174,7 +185,9 @@ export class Player {
}
// 可变跳跃高度
if (!Input.is('jump') && this.vy < -3) this.vy = -3;
- this.vy = Math.min(this.vy + PHY.GRAV, PHY.MAXFALL);
+ // 上升轻、下落重:分手感。二段上升段用更轻的 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; // 二段到顶,交回普通重力
}
// ---- 站在移动平台上则跟随 ----
@@ -226,10 +239,8 @@ export class Player {
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.jumps = 0; this.djUp = false;
}
- this.squash += (1 - this.squash) * 0.22;
// ---- 滑行反馈:超速滑行(冰上,或刚滑出冰块仍带惯性)时脚下溅起冰屑 ----
if (this.sliding && this.grounded && Math.abs(this.vx) > PHY.HSPD && this.animT % 5 === 0) {
diff --git a/js/settings.js b/js/settings.js
index a6414f8..1406b4b 100644
--- a/js/settings.js
+++ b/js/settings.js
@@ -1,5 +1,8 @@
-// 设置:用户名 + 手机虚拟手柄按钮的自定义(位置/大小,拖动调整)
+// 设置:用户名 + 设备相关自定义
+// · PC(电脑端)→ 键盘按键映射
+// · 移动端 → 手机虚拟手柄按钮的位置/大小(拖动调整)
import { Save } from './save.js';
+import { applyBindings, defaultBindings, BIND } from './input.js';
// 每个可自定义的手柄按钮:key 对应 touch.js 里的 data-touch,
// def 为默认布局(cx/cy 为相对舞台中心的百分比,size 为按钮边长 px)
@@ -13,21 +16,37 @@ const BUTTONS = [
{ key: 'suicide', label: '↺', shape: 'sm', def: { cx: 90, cy: 58, size: 42 } },
];
+// 设置界面里可重绑的键盘动作(标签 + action)
+const KEY_ACTIONS = [
+ ['left', '左移'], ['right', '右移'],
+ ['up', '上(上浮)'],
+ ['jump', '跳跃'], ['shoot', '射击'],
+ ['suicide', '自杀 / 重生'], ['restart', '整关重来'],
+ ['pause', '暂停'],
+];
+
const clone = (o) => JSON.parse(JSON.stringify(o));
const DEFAULT_TOUCH = {};
BUTTONS.forEach(b => { DEFAULT_TOUCH[b.key] = clone(b.def); });
-const makeDefaults = () => ({ username: '', touch: clone(DEFAULT_TOUCH) });
+const makeDefaults = () => ({ username: '', touch: clone(DEFAULT_TOUCH), keys: defaultBindings() });
// 工作副本(未点「确定」前不落盘,点「返回」即丢弃)
let work = makeDefaults();
let wrap = null;
+let pcMode = false; // true=电脑端(键盘映射),false=移动端(手柄按钮)
+let listening = null; // 重绑监听:{act, idx}
+let conflict = null; // 冲突警告:{code, label, act}
const els = {};
+let keysBox = null; // 键盘映射容器
-/** 读取/补全存档里的设置对象(保证 touch 字段齐全) */
+/** 读取/补全存档里的设置对象(保证 touch / keys 字段齐全) */
function meta() {
- if (!Save.data.settings) Save.data.settings = { username: '' };
- if (!Save.data.settings.touch) Save.data.settings.touch = clone(DEFAULT_TOUCH);
- return Save.data.settings;
+ if (!Save.data.settings) Save.data.settings = {};
+ const s = Save.data.settings;
+ if (typeof s.username !== 'string') s.username = '';
+ if (!s.touch) s.touch = clone(DEFAULT_TOUCH);
+ if (!s.keys) s.keys = defaultBindings();
+ return s;
}
/** 把某个按钮按 p={cx,cy,size} 绝对定位(百分比定位 + 像素边长) */
@@ -59,13 +78,135 @@ function showUsername() {
if (el) el.textContent = '玩家:' + (meta().username || '无名氏');
}
+/** 把键盘 code 转成可读标签 */
+function keyLabel(code) {
+ const M = {
+ Space: '空格', ShiftLeft: 'Shift', ShiftRight: 'Shift', ControlLeft: 'Ctrl', ControlRight: 'Ctrl',
+ AltLeft: 'Alt', AltRight: 'Alt', Tab: 'Tab', Backspace: '⌫', Escape: 'Esc', Enter: 'Enter',
+ ArrowLeft: '←', ArrowRight: '→', ArrowUp: '↑', ArrowDown: '↓',
+ MetaLeft: 'Win', MetaRight: 'Win', ContextMenu: '菜单',
+ };
+ if (M[code]) return M[code];
+ if (code.startsWith('Key')) return code.slice(3);
+ if (code.startsWith('Digit')) return code.slice(5);
+ if (code.startsWith('Numpad')) return '小键' + code.slice(6);
+ if (/^F\d{1,2}$/.test(code)) return code;
+ return code;
+}
+
+/** 构建设置界面里的键盘映射行(点击按键后按新键重绑) */
+function renderKeys() {
+ if (!keysBox) return;
+ keysBox.innerHTML = '';
+ // 冲突警告横幅(始终置于面板顶部,醒目)
+ if (conflict) {
+ const w = document.createElement('div');
+ w.className = 'kwarb';
+ w.textContent = `「${keyLabel(conflict.code)}」已被【${conflict.label}】占用,请换一个键(Esc 取消)`;
+ keysBox.appendChild(w);
+ }
+ for (const [act, label] of KEY_ACTIONS) {
+ const row = document.createElement('div');
+ row.className = 'krow';
+ const nm = document.createElement('span');
+ nm.className = 'kact'; nm.textContent = label;
+ const keys = document.createElement('div');
+ keys.className = 'kkeys';
+ (work.keys[act] || []).forEach((code, idx) => {
+ const chip = document.createElement('button');
+ chip.type = 'button';
+ const isListen = listening && listening.act === act && listening.idx === idx;
+ chip.className = 'kchip' + (isListen ? ' listen' : '');
+ chip.dataset.kact = act; chip.dataset.kidx = idx;
+ chip.textContent = keyLabel(code);
+ const rm = document.createElement('b');
+ rm.className = 'krm'; rm.dataset.krm = act; rm.dataset.kidx = idx; rm.textContent = '×';
+ chip.appendChild(rm);
+ keys.appendChild(chip);
+ });
+ const add = document.createElement('button');
+ add.type = 'button';
+ add.className = 'kadd'; add.dataset.kadd = act; add.textContent = '+ 添加';
+ keys.appendChild(add);
+ if (listening && listening.act === act) {
+ const tip = document.createElement('span');
+ const isClash = conflict && conflict.act === act;
+ tip.className = 'klisten' + (isClash ? ' bad' : '');
+ tip.textContent = isClash ? '该键冲突,请换一个键(Esc 取消)' : '按下一个键…(Esc 取消)';
+ keys.appendChild(tip);
+ }
+ row.appendChild(nm); row.appendChild(keys);
+ keysBox.appendChild(row);
+ }
+}
+
+/** 检测 code 是否已绑定到其它动作(返回冲突动作的标签,否则 null) */
+function conflictInfo(code, act, idx) {
+ for (const [a, label] of KEY_ACTIONS) {
+ const arr = work.keys[a] || [];
+ for (let i = 0; i < arr.length; i++) {
+ if (arr[i] === code && !(a === act && i === idx)) return label;
+ }
+ }
+ // 不可重绑的系统动作也算冲突(如「菜单」用的 KeyQ)
+ if (BIND.menu && BIND.menu.includes(code)) return '菜单';
+ return null;
+}
+
+/** 重绑监听:捕获下一次按键并写入 work.keys(检测到冲突则拒绝并提示) */
+function onCaptureKey(e) {
+ if (!listening) return;
+ e.preventDefault();
+ if (e.code === 'Escape') { listening = null; conflict = null; renderKeys(); return; }
+ const { act, idx } = listening;
+ const clash = conflictInfo(e.code, act, idx);
+ if (clash) { // 该键已被其它动作占用 → 拒绝并提示
+ conflict = { code: e.code, label: clash, act };
+ renderKeys();
+ return; // 停留在监听态,等待换键或 Esc
+ }
+ if (idx >= (work.keys[act] || []).length) (work.keys[act] = work.keys[act] || []).push(e.code);
+ else work.keys[act][idx] = e.code;
+ conflict = null;
+ listening = null;
+ renderKeys();
+}
+
+/** 给键盘映射容器绑定点击委托(重绑 / 添加 / 删除) */
+let keysWired = false;
+function wireKeys() {
+ if (!keysBox || keysWired) return;
+ keysWired = true;
+ keysBox.addEventListener('click', (e) => {
+ const rm = e.target.closest('.krm');
+ if (rm) {
+ const a = rm.dataset.krm, i = +rm.dataset.kidx;
+ work.keys[a].splice(i, 1);
+ if (listening && listening.act === a) listening = null;
+ renderKeys();
+ return;
+ }
+ const chip = e.target.closest('.kchip');
+ if (chip) { listening = { act: chip.dataset.kact, idx: +chip.dataset.kidx }; renderKeys(); return; }
+ const add = e.target.closest('.kadd');
+ if (add) { listening = { act: add.dataset.kadd, idx: 1e9 }; renderKeys(); return; }
+ });
+}
+
export const Settings = {
- init() {
+ /** isTouch:是否移动端(由 main.js 依据 ?touch 覆盖与设备检测传入) */
+ init(isTouch) {
+ pcMode = !isTouch;
wrap = document.getElementById('touch-preview');
+ keysBox = document.getElementById('set-keys');
work = clone(meta());
this.build();
+ renderKeys();
+ wireKeys();
showUsername();
apply();
+ applyBindings(meta().keys);
+ document.addEventListener('keydown', onCaptureKey);
},
/** 构建设置界面里的可拖拽手柄预览(克隆 #touch 的按钮形状) */
@@ -86,38 +227,54 @@ export const Settings = {
}
},
- /** 打开设置界面:从存档同步工作副本与输入框 */
+ /** 打开设置界面:从存档同步工作副本与输入框,并按设备切换面板 */
enter() {
const s = meta();
- work = { username: s.username || '', touch: clone(s.touch) };
+ work = { username: s.username || '', touch: clone(s.touch), keys: clone(s.keys) };
const inp = document.getElementById('set-username');
if (inp) inp.value = work.username;
+ listening = null;
+ conflict = null;
this.build();
+ renderKeys();
+ // 按设备显示对应面板:PC→键盘映射;移动端→手柄按钮
+ const keysRow = document.getElementById('set-keys-row');
+ const touchRow = document.getElementById('set-touch-row');
+ if (keysRow) keysRow.classList.toggle('hidden', !pcMode);
+ if (touchRow) touchRow.classList.toggle('hidden', pcMode);
},
- /** 确定:保存用户名与手柄布局到存档 */
+ /** 确定:保存用户名 / 手柄布局 / 键位到存档,并应用,然后由调用方返回上一页 */
confirm() {
const s = meta();
const inp = document.getElementById('set-username');
work.username = (inp ? inp.value : '').trim().slice(0, 16);
s.username = work.username;
s.touch = clone(work.touch);
+ s.keys = clone(work.keys);
Save.flush();
showUsername();
apply();
+ applyBindings(s.keys);
},
- /** 恢复默认:布局与用户名全部回到出厂值(仅影响工作副本,需点确定才落盘) */
+ /** 恢复默认:用户名/手柄布局/键位全部回到出厂值(仅影响工作副本,需点确定才落盘) */
reset() {
work = makeDefaults();
+ listening = null;
+ conflict = null;
const inp = document.getElementById('set-username');
if (inp) inp.value = '';
this.build();
+ renderKeys();
},
/** 供调试 / 测试使用 */
_state() { return work; },
_els() { return els; },
+ _pcMode() { return pcMode; },
+ _conflict() { return conflict; },
+ _listening() { return listening; },
};
/** 给单个预览按钮绑定「拖动移动」与「右下角缩放」 */
diff --git a/js/sprites.js b/js/sprites.js
index 5dbfe5a..68096b9 100644
--- a/js/sprites.js
+++ b/js/sprites.js
@@ -8,7 +8,7 @@ const C = {
metal: '#5a6478', metalDark: '#333a4a',
ice: '#8fd8f0', iceDark: '#4a90b8',
water: '#2a6fd8',
- spike: '#c8c8d8', spikeDark: '#5a5a6a',
+ spike: '#cfdde9', spikeDark: '#a8bccf',
kid: '#d94a55', kidDark: '#8e2530', skin: '#f5cfa8', hair: '#5a3a22', pant: '#2c3a7a',
save: '#b06bff', goal: '#ffc63a', gun: '#b8b8c8', apple: '#e03050',
};
@@ -77,33 +77,34 @@ export function drawOneWay(ctx, x, y) {
for (let i = 0; i < 8; i++) px(ctx, x + i * 4, y + 6, 2, 3, C.metalDark);
}
+// 单个三角形刺:尖端指向 dir,底边贴在相反一侧(与 level.js 的 spikeTri 顶点完全一致)。
+// 外观对齐经典 I wanna 素材:冰蓝白渐变的干净大三角,占满整格。
export function drawSpike(ctx, x, y, dir, t) {
const s = TILE;
- const g = ctx.createLinearGradient(x, y, x, y + s);
- // 底座
- if (dir === 0) { // 朝上(在地面上)
- px(ctx, x + 1, y + s - 7, s - 2, 7, C.spikeDark);
- for (let i = 0; i < 3; i++) tri(ctx, x + 3 + i * 9, y + s - 6, x + 7.5 + i * 9, y + 5, x + 12 + i * 9, y + s - 6);
- px(ctx, x + 1, y + s - 2, s - 2, 2, '#2a2a34');
- } else if (dir === 1) { // 朝下(天花板)
- px(ctx, x + 1, y, s - 2, 7, C.spikeDark);
- for (let i = 0; i < 3; i++) tri(ctx, x + 3 + i * 9, y + 6, x + 7.5 + i * 9, y + s - 5, x + 12 + i * 9, y + 6);
- px(ctx, x + 1, y, s - 2, 2, '#2a2a34');
- } else if (dir === 2) { // 朝左(贴右墙)
- px(ctx, x + s - 7, y + 1, 7, s - 2, C.spikeDark);
- for (let i = 0; i < 3; i++) tri(ctx, x + s - 6, y + 3 + i * 9, x + 5, y + 7.5 + i * 9, x + s - 6, y + 12 + i * 9);
- px(ctx, x + s - 2, y + 1, 2, s - 2, '#2a2a34');
- } else { // 朝右(贴左墙)
- px(ctx, x, y + 1, 7, s - 2, C.spikeDark);
- for (let i = 0; i < 3; i++) tri(ctx, x + 6, y + 3 + i * 9, x + s - 5, y + 7.5 + i * 9, x + 6, y + 12 + i * 9);
- px(ctx, x, y + 1, 2, s - 2, '#2a2a34');
+ let ax, ay, bx, by, cx, cy;
+ if (dir === 0) { // 朝上(地面上)
+ ax = x + s / 2; ay = y; bx = x; by = y + s; cx = x + s; cy = y + s;
+ } else if (dir === 1) { // 朝下(天花板)
+ ax = x + s / 2; ay = y + s; bx = x; by = y; cx = x + s; cy = y;
+ } else if (dir === 2) { // 朝左(贴右墙)
+ ax = x; ay = y + s / 2; bx = x + s; by = y; cx = x + s; cy = y + s;
+ } else { // 朝右(贴左墙)
+ ax = x + s; ay = y + s / 2; bx = x; by = y; cx = x; cy = y + s;
}
-}
-function tri(ctx, x1, y1, x2, y2, x3, y3) {
+ // 冰蓝白渐变:尖端最亮,往底边过渡到淡蓝
+ let g;
+ if (dir === 0) { g = ctx.createLinearGradient(x, y, x, y + s); }
+ else if (dir === 1) { g = ctx.createLinearGradient(x, y + s, x, y); }
+ else if (dir === 2) { g = ctx.createLinearGradient(x, y, x + s, y); }
+ else { g = ctx.createLinearGradient(x + s, y, x, y); }
+ g.addColorStop(0, '#f4f9fd');
+ g.addColorStop(0.55, '#cfdde9');
+ g.addColorStop(1, '#a8bccf');
ctx.beginPath();
- ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.lineTo(x3, y3); ctx.closePath();
- ctx.fillStyle = C.spike; ctx.fill();
- ctx.strokeStyle = '#8d8da0'; ctx.lineWidth = 1; ctx.stroke();
+ ctx.moveTo(ax, ay); ctx.lineTo(bx, by); ctx.lineTo(cx, cy); ctx.closePath();
+ ctx.fillStyle = g; ctx.fill();
+ // 淡蓝描边(参考图的柔和轮廓)
+ ctx.strokeStyle = 'rgba(150,175,200,.9)'; ctx.lineWidth = 1; ctx.stroke();
}
/** 全致死方块(美味水果外观) */
@@ -152,11 +153,10 @@ export function drawKid(ctx, p, t) {
const x = Math.round(p.x), y = Math.round(p.y);
const w = p.w, h = p.h;
const air = !p.grounded;
- const sq = p.squash; // 1 正常
+ // 不做任何 squash/stretch 形变:起跳与落地都保持原始体型,
+ // 避免身体忽高忽低被感知为"上下晃动"(2026-10-23 用户两次反馈后彻底移除)
ctx.save();
- ctx.translate(x + w / 2, y + h);
- ctx.scale(1 / sq, sq);
- ctx.translate(-w / 2, -h);
+ ctx.translate(x, y);
const face = p.facing; // 1 右 -1 左
// 身体(红衣)
diff --git a/js/ui.js b/js/ui.js
index 17c4ff3..632d1a0 100644
--- a/js/ui.js
+++ b/js/ui.js
@@ -6,7 +6,7 @@ import { fmtTime } from './util.js';
const $ = s => document.querySelector(s);
const $$ = s => Array.from(document.querySelectorAll(s));
-const SCREENS = ['scr-title', 'scr-levels', 'scr-help', 'scr-settings', 'scr-pause', 'scr-result', 'scr-editor'];
+const SCREENS = ['scr-title', 'scr-levels', 'scr-settings', 'scr-pause', 'scr-result', 'scr-editor'];
const TABS = [
['all', '全部'], ['jump', '跳刺'], ['endur', '耐久'], ['boss', 'BOSS'], ['troll', '坑向'], ['custom', '自制'],
];
diff --git a/spike-preview.html b/spike-preview.html
new file mode 100644
index 0000000..c1dd1ce
--- /dev/null
+++ b/spike-preview.html
@@ -0,0 +1,37 @@
+
+
+
+
+
刺外观预览(对齐参考图)
+
+
+
+
单三角形刺 · 冰蓝白渐变(游戏真实代码渲染)
+
+
上排:地面刺(朝上) · 中间:天花板刺(朝下) · 左右:贴墙刺(朝左/朝右)
+
+
+
diff --git a/tools/dj-test.mjs b/tools/dj-test.mjs
new file mode 100644
index 0000000..4167112
--- /dev/null
+++ b/tools/dj-test.mjs
@@ -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 格');
diff --git a/tools/editor-pick-test.mjs b/tools/editor-pick-test.mjs
new file mode 100644
index 0000000..cc03b0c
--- /dev/null
+++ b/tools/editor-pick-test.mjs
@@ -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);
diff --git a/tools/editor-wysiwyg-test.mjs b/tools/editor-wysiwyg-test.mjs
new file mode 100644
index 0000000..b51c2ca
--- /dev/null
+++ b/tools/editor-wysiwyg-test.mjs
@@ -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);
diff --git a/tools/ice-test.mjs b/tools/ice-test.mjs
index aab81b4..998e9c5 100644
--- a/tools/ice-test.mjs
+++ b/tools/ice-test.mjs
@@ -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;
diff --git a/tools/lib-reach.mjs b/tools/lib-reach.mjs
index 8b46959..4ed20df 100644
--- a/tools/lib-reach.mjs
+++ b/tools/lib-reach.mjs
@@ -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);
diff --git a/tools/reach-diag.mjs b/tools/reach-diag.mjs
index e05587d..4fa2003 100644
--- a/tools/reach-diag.mjs
+++ b/tools/reach-diag.mjs
@@ -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 => {
diff --git a/tools/settings-test.mjs b/tools/settings-test.mjs
index f54371a..66f9886 100644
--- a/tools/settings-test.mjs
+++ b/tools/settings-test.mjs
@@ -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);
diff --git a/tools/solve.mjs b/tools/solve.mjs
index ca20aa2..4a23f21 100644
--- a/tools/solve.mjs
+++ b/tools/solve.mjs
@@ -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}) ===`);
diff --git a/tools/spike-test.mjs b/tools/spike-test.mjs
new file mode 100644
index 0000000..d60d3ef
--- /dev/null
+++ b/tools/spike-test.mjs
@@ -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);