v0.02
This commit is contained in:
1 parent
234e769f4d
commit
e6a0aa94de
10 files changed
+871
-582
No files matched your search
@@ -0,0 +1,24 @@
|
|||||||
|
# 2026-10-21
|
||||||
|
|
||||||
|
## 修完 j2 VERTICAL HELL(最后一个不可达关卡)
|
||||||
|
- 新增 `js/levels/needle.js` 的 `well()`:之字踏石竖井生成器(每级升 2 格、台阶宽 5 且相邻两级重叠 2 格、顶台与上层地板同高相接、靠地板侧井壁底部留 3 格入口)。
|
||||||
|
- 用 `well()` 重写 j2 的四段竖井(右-左-右-左),并修正:
|
||||||
|
- L1 地板改成 cols 2..57(不覆盖右井内腔),L2/L4 地板改成 cols 9..65(盖住右井井底)。
|
||||||
|
- L1/L3 的压顶垂刺改成 cols 10..54,避开左井 x2..9(之前垂刺正好挡住井内起跳)。
|
||||||
|
- L2 刺毯缩短到 col 56,让右井底部 cols 57..65 净空可以站位起跳。
|
||||||
|
|
||||||
|
## 找到可达性 BFS 的建模 bug(关键)
|
||||||
|
`tools/lib-reach.mjs` 的 `jump()` 里水平方向被墙挡住时把 `vx` 永久清零了,而真实 `js/player.js` 是「本帧没推动、下一帧按住方向键 vx 仍是 ±3」。
|
||||||
|
这导致所有「贴着墙上升、越过台面边缘后再横移」的路线全部丢失——j2 的竖井就是靠这条路线过的。改成不清零后 j2 状态数 451 → 1268,全关通过。
|
||||||
|
|
||||||
|
## 统一诊断工具参数
|
||||||
|
`reach()` 的 `RELSET` 默认值 `[0,8,14]` 与 `solve.mjs` 的 `[0,4,8,14]` 不一致,`reach-diag.mjs` 会输出误导性结果。提取 `DEF_DJ`/`DEF_REL` 并让 `reach-diag.mjs` 也读 `DJ`/`REL` 环境变量。
|
||||||
|
|
||||||
|
## 降低「重复跳跃套路」+ 加密
|
||||||
|
- `jump.js` 顶部的 `carpet()` 从固定「每 4 格一缺口」改成 **4/3 格交替**(跳 3 格 / 跳 4 格轮换),所有复用它的关卡一起变节奏,全关仍可达。
|
||||||
|
- 加密低密度关卡:j1 加上下夹缝+对刺门+垂刺;j5 加夹缝/对刺门/浮块区压顶;j7 每层改用交替刺毯+两端留落脚净空+压顶垂刺;j4 加宽冰坑+压顶垂刺;j8 收尾段加垂刺。
|
||||||
|
- 踩坑:j7 层两端必须留 2 格净空(上一层是二段跳跃入的),否则落点正好在刺上;j5 浮块区压顶只能放在 R-9(R-7 会让高块单跳就撞死)。
|
||||||
|
- 刺密度:j1 3.8% / j2 10.7% / j3 6.2% / j4 4.2% / j5 4.1% / j6 11.6% / j7 6.2% / j8 4.7% / j9 8.2% / j10 9.2%。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
check / solve / save-test / key-test / smoke / dom-smoke 全部通过;`http://localhost:8123/` 上 index.html 与各模块均 200。
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# iwanna(IWBT C 同人网页游戏)项目长期笔记
|
||||||
|
|
||||||
|
## 物理与判定(像素级跳刺的核心)
|
||||||
|
- 物理常量写在 `js/player.js` 的 `PHY`,全部对齐 IWBT 官方:GRAV 0.4 / MAXFALL 9 / JUMP 8.5 / DJUMP 7.6 / HSPD 3,松手即停、无惯性、可变跳跃高度。
|
||||||
|
- 玩家判定盒 **11×21**(`Player` 构造函数里 `this.w = 11; this.h = 21`)。改这里会同时影响 `level.js` 的 `spawn` 偏移和全部可达性工具。
|
||||||
|
- 刺判定盒在 `js/level.js` 的 `spikeBox()`,已按刺尖收紧(上刺 y+8 起、左右刺 x+5 起等),留出像素级擦弹窗口。`T.KILLER` 必须有兜底 return,否则 `deadlyRect` 会在坑向关崩溃。
|
||||||
|
|
||||||
|
## 关卡设计硬规则(都踩过坑)
|
||||||
|
- `tools/solve.mjs` 是唯一权威:改完任何地图必须跑到「✅ 所有关卡终点与存档点均可达」。
|
||||||
|
- 之字竖井用 `needle.js` 的 `well(xL, xR, topRow, botRow, floorSide)`:每级升 2 格、台阶宽 5 且两级重叠 2 格、顶台与上层地板**同高相接**、靠地板侧井壁从 topRow+1 到 botRow-4(底部留 3 格入口)。
|
||||||
|
- 台阶**不能铺满井宽**(会变成 1 格高封闭走廊,跳不起来)。
|
||||||
|
- 顶台**不能压在地板洞口正上方**(会把井封死)——曾因此 j2 整关不可达。
|
||||||
|
- 完整四向钻石(上下左右刺齐全)在矩形判定盒下横向**无解**,只能当装饰;要「跳穿门」必须用 `vgap`(上下夹缝)或 `hgap`(左右夹缝)。
|
||||||
|
- 垂刺(压顶)与落脚点的关系:单跳最高 ~86px、二段跳 ~158px。压顶刺放在落脚点上方 5 行 = 单跳安全/二段必死;放太近(3~4 行)会让单跳也撞死。冰面上**不要**加压顶垂刺(滑行无法控制跳跃高度,实测 j8 直接不可达)。
|
||||||
|
- 刺毯缺口最远只能 3 连刺(跳 4 格 ≈128px,已是单跳极限);需要更远必须用二段跳,但压顶垂刺会禁止二段跳。
|
||||||
|
|
||||||
|
## 工具链
|
||||||
|
- `tools/lib-reach.mjs` 是 solve / reach-diag 的**共享 BFS 核心**,改建模只改这一处。
|
||||||
|
- 关键修正:跳跃水平方向被墙挡住时**不能把 vx 永久清零**(真实 `player.js` 里按住方向键下一帧会恢复)。清零会丢失「贴墙上升→越过台面→横移」这条经典路线,曾导致整个 j2 竖井判为不可达。
|
||||||
|
- `reach()` 默认 DJSET/RELSET 必须与 `solve.mjs` 的 `DJ`/`REL` 环境变量一致,否则诊断和求解结果对不上。
|
||||||
|
- 其余:`check.mjs` 格式校验、`save-test.mjs` 存档点、`key-test.mjs` 按键语义(R=回存档点 / Backspace=整关重来)、`smoke.mjs`、`dom-smoke.mjs`。
|
||||||
|
- 本地预览:`python3 -m http.server 8123`(ES module 必须走 HTTP,file:// 打不开)。
|
||||||
|
|
||||||
|
## 关卡现状(23 关)
|
||||||
|
跳刺 j1~j10(68×52 ~ 150×44,刺密度 3.8%~11.6%)、耐久 e1~e4、boss b1~b3(可达性求解跳过)、坑向 r1~r4。
|
||||||
+8
-5
@@ -203,7 +203,7 @@ export class Level {
|
|||||||
// 出生点
|
// 出生点
|
||||||
const sp = this.entities.find(e => e.type === 'spawn');
|
const sp = this.entities.find(e => e.type === 'spawn');
|
||||||
this.spawn = sp
|
this.spawn = sp
|
||||||
? { x: sp.x + sp.w / 2 - 7, y: sp.y + sp.h - 18 }
|
? { x: sp.x + sp.w / 2 - 5.5, y: sp.y + sp.h - 21 }
|
||||||
: (this.spawnExtra ? { x: this.spawnExtra[0] * TILE, y: this.spawnExtra[1] * TILE } : { x: 40, y: 40 });
|
: (this.spawnExtra ? { x: this.spawnExtra[0] * TILE, y: this.spawnExtra[1] * TILE } : { x: 40, y: 40 });
|
||||||
// 总苹果数 / 存档点数
|
// 总苹果数 / 存档点数
|
||||||
this.totalApples = this.entities.filter(e => e.type === 'apple').length;
|
this.totalApples = this.entities.filter(e => e.type === 'apple').length;
|
||||||
@@ -610,11 +610,14 @@ export class Level {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 刺的判定盒:对齐齿尖、收窄到齿根之间(IWBT 三角形判定的矩形近似)。
|
||||||
|
// 配合 11×21 的玩家判定盒,可以像素级贴刺、穿对刺缝。
|
||||||
function spikeBox(t, tx, ty) {
|
function spikeBox(t, tx, ty) {
|
||||||
const x = tx * TILE, y = ty * TILE;
|
const x = tx * TILE, y = ty * TILE;
|
||||||
if (t === T.SPIKE_U) return { x: x + 4, y: y + 11, w: TILE - 8, h: TILE - 12 };
|
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 + 4, y: y + 2, w: TILE - 8, h: TILE - 11 };
|
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 + 3, y: y + 4, w: TILE - 12, h: TILE - 8 };
|
if (t === T.SPIKE_L) return { x: x + 5, y: y + 6, w: TILE - 10, h: TILE - 12 }; // 齿尖 x+5,缝宽 22
|
||||||
return { x: x + 9, y: y + 4, w: TILE - 12, h: TILE - 8 };
|
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(美味水果)
|
||||||
}
|
}
|
||||||
export { spikeBox };
|
export { spikeBox };
|
||||||
+436
-292
@@ -1,368 +1,512 @@
|
|||||||
// 跳刺(Needle)关卡 —— 10 关
|
// 跳刺(Needle)10 关 —— IWBT 经典密集针刺风格
|
||||||
// 设计基调:地图尽可能大、落脚点尽可能小、存档点尽可能稀疏。
|
// 设计语法(已经 tools/solve.mjs 验证):
|
||||||
// 物理上限(改动前务必记住):
|
// 刺毯缺口每 4 格一个(单跳节奏);钻石中心 = 地板上方 2~3 格;
|
||||||
// 平跳水平 ≈ 4 格(127px),二段跳 ≈ 6 格(206px)
|
// 垂刺离最低落脚点 5 格(单跳安全、二段跳必死);2 格高矮洞可用撞头跳;
|
||||||
// 单跳上升 ≈ 2.8 格(90px),二段跳上升 ≈ 5 格(162px)
|
// 竖井台阶每级升 2~3 格横移 3 格内;钢丝柱间距 ≤4(单跳)/ ≤6(二段)。
|
||||||
// 天花板刺放在「平台行 -5」:允许完整单跳、二段跳必死 → 只能走 4 格以内的坑
|
|
||||||
import { Grid } from './kit.js';
|
import { Grid } from './kit.js';
|
||||||
|
import { vgap, hgap, diamond, spikeRun, block, corner, ceil, wall, well } from './needle.js';
|
||||||
|
|
||||||
/** 落脚台序列:run(起始列, 起始宽, [[间隙, 台宽], ...]) => [{x,w}, ...] */
|
// 刺毯:从 x0 起 n 格留落脚缺口。
|
||||||
function run(x0, w0, specs) {
|
// 缺口间距在 4/3 格之间交替(跳 3 格 / 跳 4 格轮换),避免整关都是"每 4 格一跳"的单一套路;
|
||||||
const out = [{ x: x0, w: w0 }];
|
// 最远仍是 3 连刺(单跳极限),配合压顶垂刺时二段跳必死。
|
||||||
let x = x0 + w0;
|
const carpet = (m, x0, y, n, gaps = []) => {
|
||||||
for (const [g, w] of specs) { x += g; out.push({ x, w }); x += w; }
|
const g = [];
|
||||||
return out;
|
let i = 3, k = 0;
|
||||||
}
|
while (i < n) { g.push(i); i += (k++ % 2 === 0 ? 4 : 3); }
|
||||||
const putAll = (m, row, pads, ch = '#') => pads.forEach(p => m.rect(p.x, row, p.w, 1, ch));
|
return spikeRun(m, x0, y, n, '^', g.concat(gaps));
|
||||||
|
};
|
||||||
|
|
||||||
/** 竖井:在 [x0,x1] 列范围内交错上行,每级上升 2 格、横移 step 列 */
|
export const JUMP_LEVELS = [
|
||||||
function shaft(m, rowBottom, rowTop, x0, x1, step = 6, w = 2) {
|
// ============================================================ j1 刺巷
|
||||||
const out = [];
|
|
||||||
let row = rowBottom, x = x0, dir = 1;
|
|
||||||
while (row >= rowTop) {
|
|
||||||
m.rect(x, row, w, 1, '#');
|
|
||||||
out.push({ x, row });
|
|
||||||
row -= 2;
|
|
||||||
const nx = x + dir * step;
|
|
||||||
if (nx < x0 || nx + w - 1 > x1) dir = -dir;
|
|
||||||
else x = nx;
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default [
|
|
||||||
|
|
||||||
// ================= 1 =================
|
|
||||||
{
|
{
|
||||||
id: 'j1', name: 'SPIKE ALLEY', type: 'jump', diff: 2, bg: 'jump',
|
id: 'j1', name: 'SPIKE ALLEY', cn: '刺巷', type: 'jump', diff: 2,
|
||||||
width: 104, hint: '刺海长廊。落脚点越来越小,坑越来越宽。104 格,只有三个存档点。',
|
hint: '针刺入门:刺毯单跳节奏、钻石缝、矮洞撞头跳、竖井、对刺门。',
|
||||||
|
width: 104,
|
||||||
grid: (() => {
|
grid: (() => {
|
||||||
const W = 104, H = 20, R = 17;
|
const W = 104, H = 26, R = 22;
|
||||||
const m = new Grid(W, H);
|
const m = new Grid(W, H);
|
||||||
m.border();
|
m.border('#');
|
||||||
m.hline(18, 1, W - 2, '^'); // 刺海
|
m.rect(1, R, W - 2, 1, '#'); // 主地板
|
||||||
const pads = run(2, 6, [
|
// --- x1-9 起步 ---
|
||||||
[4, 2], [4, 2], [4, 2], [5, 2], [5, 1],
|
|
||||||
[4, 1], [5, 1], [6, 1], [5, 2], [4, 1],
|
|
||||||
]);
|
|
||||||
putAll(m, R, pads);
|
|
||||||
// 尽头接竖井,终点在半空
|
|
||||||
const sp = shaft(m, 15, 5, 72, 96, 6, 2);
|
|
||||||
for (let r = 7; r < 16; r += 3) { m.set(70, r, '>'); m.set(98, r, '<'); }
|
|
||||||
const top = sp[sp.length - 1];
|
|
||||||
m.set(3, R - 1, 'S');
|
m.set(3, R - 1, 'S');
|
||||||
m.set(pads[3].x, R - 1, 'G');
|
m.set(8, R - 1, 'G');
|
||||||
m.set(pads[7].x, R - 1, 'G');
|
// --- x10-26 刺毯(单跳节奏,垂刺压顶禁二段,两道上下夹缝)---
|
||||||
m.set(sp[1].x, sp[1].row - 1, 'G');
|
carpet(m, 10, R - 1, 17);
|
||||||
m.set(sp[3].x, sp[3].row - 1, 'G');
|
ceil(m, 10, 26, R - 6);
|
||||||
m.set(top.x, top.row - 1, 'X');
|
vgap(m, 15, R - 2); vgap(m, 21, R - 2);
|
||||||
|
// --- x28-43 钻石门三连(中心 R-2,单跳穿缝)---
|
||||||
|
carpet(m, 28, R - 1, 16, [0, 4, 8, 12, 15]); // 加密缺口便于站位
|
||||||
|
ceil(m, 28, 43, R - 6);
|
||||||
|
vgap(m, 30, R - 2); vgap(m, 34, R - 2); vgap(m, 38, R - 2);
|
||||||
|
m.set(28, R - 1, 'G');
|
||||||
|
// --- x44-56 矮洞(顶 row 18,3 格高,撞头跳 + 对刺门)---
|
||||||
|
m.rect(44, R - 4, 13, 1, '#');
|
||||||
|
carpet(m, 45, R - 1, 11);
|
||||||
|
hgap(m, 48, R - 1); hgap(m, 53, R - 1);
|
||||||
|
m.set(45, R - 1, 'G');
|
||||||
|
// --- x57-71 刺坑上的钢丝柱 ---
|
||||||
|
for (let x = 57; x <= 70; x++) { m.set(x, R, '.'); m.set(x, R + 1, '^'); }
|
||||||
|
const wires = [[58, 20], [61, 19], [64, 20], [67, 18], [70, 20]];
|
||||||
|
for (const [x, top] of wires) m.vline(x, top, R - 1, '#');
|
||||||
|
m.set(72, R - 1, 'G');
|
||||||
|
// --- x74-79 竖井上上层(井底留 3 格入口)---
|
||||||
|
m.vline(74, R - 10, R - 4, '#');
|
||||||
|
m.vline(79, R - 12, R - 1, '#');
|
||||||
|
block(m, 75, R - 3, 2); // 19
|
||||||
|
block(m, 77, R - 6, 2); // 16
|
||||||
|
block(m, 75, R - 9, 2); // 13
|
||||||
|
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]);
|
||||||
|
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');
|
||||||
|
// --- x98-102 下落井 + 终点 ---
|
||||||
|
wall(m, 99, R - 7, R - 3, '>');
|
||||||
|
wall(m, 102, R - 5, R - 2, '<');
|
||||||
|
m.set(100, R - 1, 'X');
|
||||||
return m.out();
|
return m.out();
|
||||||
})(),
|
})(),
|
||||||
},
|
},
|
||||||
|
|
||||||
// ================= 2 =================
|
// ============================================================ j2 垂直地狱
|
||||||
{
|
{
|
||||||
id: 'j2', name: 'VERTICAL HELL', type: 'jump', diff: 3, bg: 'jump',
|
id: 'j2', name: 'VERTICAL HELL', cn: '垂直地狱', type: 'jump', diff: 3,
|
||||||
width: 68, hint: '68×52 的高塔。每级上升 2 格、横移 6 格,头顶还悬着掉刺。',
|
hint: '52 格高塔:四段之字竖井,每层刺毯+上下夹缝,垂刺压顶、掉落刺追身。',
|
||||||
|
width: 68,
|
||||||
grid: (() => {
|
grid: (() => {
|
||||||
const W = 68, H = 52;
|
const W = 68, H = 52;
|
||||||
const m = new Grid(W, H);
|
const m = new Grid(W, H);
|
||||||
m.border();
|
m.border('#');
|
||||||
m.hline(H - 2, 1, W - 2, '^');
|
// ---- L0 地板 row 48(向右:刺毯 + 三道上下夹缝)----
|
||||||
let row = H - 3, x = 4, dir = 1;
|
m.rect(1, 48, 66, 1, '#');
|
||||||
const pads = [];
|
m.set(3, 47, 'S');
|
||||||
while (row >= 5) {
|
carpet(m, 5, 47, 50);
|
||||||
m.rect(x, row, 2, 1, '#');
|
ceil(m, 5, 54, 43);
|
||||||
pads.push({ x, row });
|
vgap(m, 16, 46); vgap(m, 30, 46); vgap(m, 46, 46);
|
||||||
row -= 2;
|
m.set(11, 47, 'G'); m.set(39, 47, 'G');
|
||||||
const nx = x + dir * 6;
|
// ---- 右井 x58..65:L0 → L1(井底 3 格敞口,之字每级升 2 格)----
|
||||||
if (nx + 1 > W - 8) dir = -1; else if (nx < 4) dir = 1; else x = nx;
|
well(m, 58, 65, 40, 48, 'l');
|
||||||
}
|
m.set(65, 45, '^'); m.set(58, 43, '^'); // 踏石外沿刺,逼迫精准落点
|
||||||
for (let r = 10; r < H - 4; r += 3) { m.set(1, r, '>'); m.set(W - 2, r, '<'); }
|
// ---- L1 地板 row 40(向左;垂刺避开左井 x2..9)----
|
||||||
pads.forEach((p, i) => { if (i % 3 === 1 && p.row - 6 > 2) m.set(p.x, p.row - 6, 'D'); });
|
m.rect(2, 40, 56, 1, '#');
|
||||||
pads.forEach((p, i) => { if (i > 0 && i % 6 === 0) m.set(p.x, p.row - 1, 'G'); });
|
carpet(m, 10, 39, 44);
|
||||||
m.set(5, H - 4, 'S');
|
ceil(m, 10, 54, 35);
|
||||||
const top = pads[pads.length - 1];
|
vgap(m, 42, 38); vgap(m, 26, 38); vgap(m, 12, 38);
|
||||||
m.set(top.x, top.row - 1, 'X');
|
m.set(47, 39, 'G'); m.set(19, 39, 'G');
|
||||||
|
// ---- 左井 x2..8:L1 → L2 ----
|
||||||
|
well(m, 2, 8, 32, 40, 'r');
|
||||||
|
m.set(2, 37, '^'); m.set(8, 35, '^');
|
||||||
|
// ---- L2 地板 row 32(向右,井底延伸到右井下方)----
|
||||||
|
m.rect(9, 32, 57, 1, '#');
|
||||||
|
carpet(m, 11, 31, 46);
|
||||||
|
ceil(m, 11, 54, 27);
|
||||||
|
hgap(m, 22, 31); hgap(m, 42, 31);
|
||||||
|
vgap(m, 32, 30); vgap(m, 50, 30);
|
||||||
|
m.set(19, 31, 'G'); m.set(47, 31, 'G');
|
||||||
|
// ---- 右井 x58..65:L2 → L3 ----
|
||||||
|
well(m, 58, 65, 24, 32, 'l');
|
||||||
|
m.set(65, 29, '^'); m.set(58, 27, '^');
|
||||||
|
// ---- L3 地板 row 24(向左,掉落刺;垂刺避开左井 x2..9)----
|
||||||
|
m.rect(2, 24, 56, 1, '#');
|
||||||
|
carpet(m, 10, 23, 44);
|
||||||
|
ceil(m, 10, 54, 19);
|
||||||
|
[14, 24, 34, 44].forEach(x => m.set(x, 18, 'D'));
|
||||||
|
vgap(m, 40, 22); vgap(m, 22, 22);
|
||||||
|
m.set(47, 23, 'G'); m.set(19, 23, 'G');
|
||||||
|
// ---- 左井 x2..8:L3 → L4 ----
|
||||||
|
well(m, 2, 8, 16, 24, 'r');
|
||||||
|
m.set(2, 21, '^'); m.set(8, 19, '^');
|
||||||
|
// ---- L4 地板 row 16(向右到终点)----
|
||||||
|
m.rect(9, 16, 57, 1, '#');
|
||||||
|
carpet(m, 11, 15, 52);
|
||||||
|
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'));
|
||||||
|
m.set(27, 15, 'G'); m.set(51, 15, 'G');
|
||||||
|
m.set(62, 15, 'X');
|
||||||
return m.out();
|
return m.out();
|
||||||
})(),
|
})(),
|
||||||
},
|
},
|
||||||
|
|
||||||
// ================= 3 =================
|
// ============================================================ j3 钢丝
|
||||||
{
|
{
|
||||||
id: 'j3', name: 'TIGHTROPE', type: 'jump', diff: 4, bg: 'jump',
|
id: 'j3', name: 'TIGHTROPE', cn: '钢丝', type: 'jump', diff: 3,
|
||||||
width: 140, hint: '140 格的一格宽钢丝,头顶全是刺:只能单跳,二段跳必死。三个存档点。',
|
hint: '一格宽的柱子立在刺海上,高矮间距都不同,垂刺压着头顶。',
|
||||||
|
width: 132,
|
||||||
grid: (() => {
|
grid: (() => {
|
||||||
const W = 140, H = 22, R = 17;
|
const W = 132, H = 24, R = 20;
|
||||||
const m = new Grid(W, H);
|
const m = new Grid(W, H);
|
||||||
m.border();
|
m.border('#');
|
||||||
m.hline(18, 1, W - 2, '^');
|
m.rect(1, R, W - 2, 1, '#'); // 基座
|
||||||
const pads = run(2, 5, Array.from({ length: 14 }, () => [4, 1]));
|
spikeRun(m, 4, R - 1, W - 6, '^'); // 刺海
|
||||||
putAll(m, R, pads);
|
// 钢丝柱(顶可站 1 格),高矮错落、间距 3~5
|
||||||
m.hline(R - 5, 8, 78, 'v'); // 天花板刺
|
const cols = [
|
||||||
const sp = shaft(m, 15, 5, 80, 108, 6, 2);
|
[4, 18], [8, 17], [12, 19], [15, 17], [19, 18], [23, 16], [27, 19],
|
||||||
[7, 10].forEach(r => m.set(78, r, '>'));
|
[30, 17], [34, 18], [38, 16], [42, 19], [46, 17], [50, 15], [54, 18],
|
||||||
[7, 10, 13].forEach(r => m.set(110, r, '<'));
|
[58, 17], [62, 19], [66, 16], [70, 18], [74, 17], [78, 19], [82, 15],
|
||||||
const top = sp[sp.length - 1];
|
[86, 18], [90, 17], [94, 19], [98, 16], [102, 18], [106, 17], [110, 19],
|
||||||
|
[114, 16], [118, 18], [122, 17],
|
||||||
|
];
|
||||||
|
for (const [x, top] of cols) m.vline(x, top, R - 1, '#');
|
||||||
|
// 起点/终点平台
|
||||||
|
m.rect(1, R - 1, 3, 1, '#'); m.set(2, R - 2, 'S');
|
||||||
|
m.rect(126, R - 1, 4, 1, '#'); m.set(128, R - 2, 'X');
|
||||||
|
// 垂刺段(离最低柱顶 5 格:单跳安全,二段跳死)
|
||||||
|
ceil(m, 6, 28, 11); // 段内最低顶 16
|
||||||
|
ceil(m, 32, 56, 10); // 最低顶 15
|
||||||
|
ceil(m, 60, 88, 10);
|
||||||
|
ceil(m, 92, 120, 11);
|
||||||
|
// 存档(柱顶)
|
||||||
|
m.set(23, 15, 'G'); m.set(58, 16, 'G'); m.set(90, 16, 'G'); m.set(122, 16, 'G');
|
||||||
|
return m.out();
|
||||||
|
})(),
|
||||||
|
},
|
||||||
|
|
||||||
|
// ============================================================ j4 冰刺
|
||||||
|
{
|
||||||
|
id: 'j4', name: 'ICE NEEDLES', cn: '冰刺', type: 'jump', diff: 4,
|
||||||
|
hint: '冰面刹不住车:滑坑、冰上钻石、冰柱钢丝,最后还有一段冰顶矮洞。',
|
||||||
|
width: 132,
|
||||||
|
grid: (() => {
|
||||||
|
const W = 132, H = 26, R = 22;
|
||||||
|
const m = new Grid(W, H);
|
||||||
|
m.border('#');
|
||||||
|
m.rect(1, R, W - 2, 1, '%'); // 冰地板
|
||||||
m.set(3, R - 1, 'S');
|
m.set(3, R - 1, 'S');
|
||||||
m.set(pads[4].x, R - 1, 'G');
|
// --- x6-30 冰面刺坑 ---
|
||||||
m.set(pads[10].x, R - 1, 'G');
|
for (const [a, b] of [[7, 10], [13, 16], [19, 22], [25, 29]])
|
||||||
m.set(sp[1].x, sp[1].row - 1, 'G');
|
for (let x = a; x <= b; x++) { m.set(x, R, '.'); m.set(x, R + 1, '^'); }
|
||||||
m.set(sp[3].x, sp[3].row - 1, 'G');
|
ceil(m, 6, 24, R - 5); // 压顶垂刺:冰面上禁二段跳
|
||||||
m.set(top.x, top.row - 1, 'X');
|
m.set(12, R - 1, 'G');
|
||||||
|
// --- x32-52 冰台阶 + 角刺 + 钻石 ---
|
||||||
|
block(m, 33, R - 2, 2, '%');
|
||||||
|
block(m, 38, R - 2, 2);
|
||||||
|
block(m, 43, R - 4, 2, '%');
|
||||||
|
ceil(m, 31, 52, R - 5);
|
||||||
|
vgap(m, 48, R - 2);
|
||||||
|
m.set(41, R - 1, 'G');
|
||||||
|
// --- x54-78 冰柱钢丝 ---
|
||||||
|
spikeRun(m, 54, R - 1, 25, '^');
|
||||||
|
const ice = [[56, 19], [60, 18], [64, 20], [68, 17], [72, 19], [76, 18]];
|
||||||
|
for (const [x, top] of ice) m.vline(x, top, R - 1, '%');
|
||||||
|
ceil(m, 55, 77, 12); // 最低柱顶 17 → 12
|
||||||
|
m.set(64, 19, 'G');
|
||||||
|
// --- x80-100 冰顶矮洞(2 格高,撞头跳越刺)---
|
||||||
|
m.rect(80, R - 3, 21, 1, '%'); // 冰顶 row 19
|
||||||
|
spikeRun(m, 83, R - 1, 15, '^', [0, 4, 8, 12, 14]);
|
||||||
|
m.set(81, R - 1, 'G'); m.set(99, R - 1, 'G');
|
||||||
|
// --- x102-124 冰上钻石阵 ---
|
||||||
|
carpet(m, 102, R - 1, 23, '%'.length ? [] : []); // 冰面刺毯
|
||||||
|
ceil(m, 102, 124, R - 5);
|
||||||
|
vgap(m, 106, R - 2); vgap(m, 111, R - 2);
|
||||||
|
vgap(m, 116, R - 2); vgap(m, 121, R - 2);
|
||||||
|
m.set(103, R - 1, 'G');
|
||||||
|
m.set(126, R - 1, 'G');
|
||||||
|
m.set(129, R - 1, 'X');
|
||||||
return m.out();
|
return m.out();
|
||||||
})(),
|
})(),
|
||||||
},
|
},
|
||||||
|
|
||||||
// ================= 4 =================
|
// ============================================================ j5 关口
|
||||||
{
|
{
|
||||||
id: 'j4', name: 'ICE NEEDLES', type: 'jump', diff: 4, bg: 'jump',
|
id: 'j5', name: 'THE GAUNTLET', cn: '关口', type: 'jump', diff: 4,
|
||||||
width: 130, hint: '前半段冰面打滑、坑有五格宽;后半段一格宽冰柱悬在刺海上——头顶有刺,只能单跳。',
|
hint: '移动平台跨刺海,炮台点名,矮洞与钻石阵穿插。',
|
||||||
|
width: 122,
|
||||||
grid: (() => {
|
grid: (() => {
|
||||||
const W = 130, H = 24, R = 19;
|
const W = 122, H = 28, R = 24;
|
||||||
const m = new Grid(W, H);
|
const m = new Grid(W, H);
|
||||||
m.border();
|
m.border('#');
|
||||||
m.hline(20, 1, W - 2, '^');
|
m.rect(1, R, W - 2, 1, '#');
|
||||||
m.rect(2, R, 6, 1, '%');
|
m.set(3, R - 1, 'S');
|
||||||
[{ x: 13, w: 6 }, { x: 24, w: 6 }, { x: 35, w: 6 }, { x: 46, w: 5 }]
|
// --- x5-22 热身刺毯(三道上下夹缝)---
|
||||||
.forEach(s => m.rect(s.x, R, s.w, 1, '%'));
|
carpet(m, 5, R - 1, 17);
|
||||||
const pads = run(56, 4, Array.from({ length: 8 }, () => [4, 1]));
|
ceil(m, 5, 21, R - 6);
|
||||||
putAll(m, R, pads);
|
vgap(m, 9, R - 2); vgap(m, 15, R - 2); vgap(m, 21, R - 2);
|
||||||
m.hline(R - 5, 56, 100, 'v');
|
m.set(7, R - 1, 'G');
|
||||||
const sp = shaft(m, 17, 5, 103, 126, 6, 2);
|
// --- x23-49 移动平台跨海 ---
|
||||||
[8, 11].forEach(r => m.set(101, r, '>'));
|
for (let x = 23; x <= 49; x++) { m.set(x, R, '.'); m.set(x, R + 1, '^'); }
|
||||||
[8, 11, 14, 17].forEach(r => m.set(128, r, '<'));
|
m.set(50, R - 1, 'G');
|
||||||
const top = sp[sp.length - 1];
|
// --- x51-72 矮洞 + 炮台 ---
|
||||||
m.set(4, R - 1, 'S');
|
m.rect(51, R - 4, 22, 1, '#'); // 顶 row 20
|
||||||
m.set(14, R - 1, 'G');
|
carpet(m, 52, R - 1, 20, [0]);
|
||||||
m.set(36, R - 1, 'G');
|
hgap(m, 57, R - 1); hgap(m, 64, R - 1); hgap(m, 70, R - 1);
|
||||||
m.set(pads[4].x, R - 1, 'G');
|
m.set(56, R - 5, 'O');
|
||||||
m.set(sp[2].x, sp[2].row - 1, 'G');
|
m.set(52, R - 1, 'G');
|
||||||
m.set(top.x, top.row - 1, 'X');
|
// --- x73-97 浮块跨刺海 + 交叉火力 ---
|
||||||
return m.out();
|
spikeRun(m, 73, R - 1, 24, '^');
|
||||||
})(),
|
block(m, 76, R - 2, 2); block(m, 82, R - 4, 2); block(m, 88, R - 2, 2); block(m, 93, R - 3, 2);
|
||||||
},
|
ceil(m, 75, 96, R - 9); // 浮块区压顶:高块上二段跳必死
|
||||||
|
m.set(79, R - 6, 'O'); m.set(91, R - 6, 'O');
|
||||||
// ================= 5 =================
|
m.set(95, R - 1, 'G');
|
||||||
{
|
// --- x98-118 钻石收尾 ---
|
||||||
id: 'j5', name: 'THE GAUNTLET', type: 'jump', diff: 5, bg: 'jump',
|
carpet(m, 98, R - 1, 20, [0, 1, 19]);
|
||||||
width: 120, hint: '两格宽的移动平台横跨刺海,旁边还有炮台点名。等它靠岸,别抢。',
|
ceil(m, 98, 117, R - 6);
|
||||||
grid: (() => {
|
vgap(m, 101, R - 2); vgap(m, 106, R - 2); vgap(m, 111, R - 2); vgap(m, 116, R - 2);
|
||||||
const W = 120, H = 24, R = 19;
|
m.set(98, R - 1, 'G');
|
||||||
const m = new Grid(W, H);
|
m.set(119, R - 1, 'G');
|
||||||
m.border();
|
m.set(120, R - 1, 'X');
|
||||||
m.hline(20, 1, W - 2, '^');
|
|
||||||
[{ x: 2, w: 6 }, { x: 24, w: 3 }, { x: 46, w: 3 }, { x: 68, w: 3 }, { x: 90, w: 3 }]
|
|
||||||
.forEach(s => m.rect(s.x, R, s.w, 1, '#'));
|
|
||||||
[35, 57, 79].forEach(x => m.rect(x, R, 1, 1, '#'));
|
|
||||||
[30, 52, 74, 86].forEach(x => m.set(x, R - 3, 'O'));
|
|
||||||
const sp = shaft(m, 17, 5, 96, 116, 6, 2);
|
|
||||||
for (let r = 9; r < 18; r += 3) { m.set(94, r, '>'); m.set(118, r, '<'); }
|
|
||||||
const top = sp[sp.length - 1];
|
|
||||||
m.set(4, R - 1, 'S');
|
|
||||||
m.set(25, R - 1, 'G');
|
|
||||||
m.set(47, R - 1, 'G');
|
|
||||||
m.set(69, R - 1, 'G');
|
|
||||||
m.set(sp[2].x, sp[2].row - 1, 'G');
|
|
||||||
m.set(top.x, top.row - 1, 'X');
|
|
||||||
return m.out();
|
return m.out();
|
||||||
})(),
|
})(),
|
||||||
movers: [
|
movers: [
|
||||||
{ x: 10, y: 19, w: 2, h: 1, path: [[10, 19], [22, 19]], speed: 1.5 },
|
{ x: 25, y: 22, w: 2, h: 1, path: [[25, 22], [35, 22]], speed: 1.6 },
|
||||||
{ x: 29, y: 19, w: 2, h: 1, path: [[29, 19], [44, 19]], speed: 1.7 },
|
{ x: 39, y: 20, w: 2, h: 1, path: [[39, 20], [47, 20]], speed: 1.9 },
|
||||||
{ x: 51, y: 19, w: 2, h: 1, path: [[51, 19], [66, 19]], speed: 1.9 },
|
|
||||||
{ x: 73, y: 19, w: 2, h: 1, path: [[73, 19], [88, 19]], speed: 2.1 },
|
|
||||||
],
|
],
|
||||||
turrets: [
|
turrets: [
|
||||||
{ dir: 'left', period: 70, speed: 2.6, offset: 0 },
|
{ dir: 'left', period: 70, speed: 2.8, offset: 0 },
|
||||||
{ dir: 'left', period: 62, speed: 2.8, offset: 20 },
|
{ dir: 'left', period: 62, speed: 3.0, offset: 24 },
|
||||||
{ dir: 'left', period: 58, speed: 3.0, offset: 40 },
|
{ dir: 'left', period: 66, speed: 2.7, offset: 40 },
|
||||||
{ dir: 'left', period: 54, speed: 3.2, offset: 10 },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
// ================= 6 =================
|
// ============================================================ j6 针刺教堂
|
||||||
{
|
{
|
||||||
id: 'j6', name: 'NEEDLE CATHEDRAL', type: 'jump', diff: 5, bg: 'jump',
|
id: 'j6', name: 'NEEDLE CATHEDRAL', cn: '针刺教堂', type: 'jump', diff: 5,
|
||||||
width: 132, hint: '下层长廊 → 竖井爬 9 层 → 上层长廊横穿回去。132×34,五个存档点。',
|
hint: '三层殿堂:底层向右,竖井爬升,中层折返向左,再爬升,顶层冲刺。',
|
||||||
|
width: 132,
|
||||||
grid: (() => {
|
grid: (() => {
|
||||||
const W = 132, H = 34;
|
const W = 132, H = 36;
|
||||||
const m = new Grid(W, H);
|
const m = new Grid(W, H);
|
||||||
m.border();
|
m.border('#');
|
||||||
m.hline(H - 2, 1, W - 2, '^');
|
// ============ L0 底层(地板 row 30)============
|
||||||
// --- 下层长廊 row 29(列 2..86)---
|
m.rect(1, 30, W - 2, 1, '#');
|
||||||
const low = run(2, 6, [
|
m.set(3, 29, 'S');
|
||||||
[4, 2], [4, 2], [4, 1], [5, 1], [4, 2], [5, 1],
|
carpet(m, 5, 29, 116);
|
||||||
[4, 1], [5, 2], [4, 1], [5, 1], [4, 2], [5, 1],
|
ceil(m, 5, 120, 24);
|
||||||
[4, 3],
|
vgap(m, 18, 28); vgap(m, 46, 28); vgap(m, 74, 28); vgap(m, 102, 28);
|
||||||
]);
|
block(m, 32, 29, 2); block(m, 60, 29, 2);
|
||||||
putAll(m, 29, low);
|
block(m, 88, 29, 2); block(m, 112, 29, 2);
|
||||||
m.hline(30, 1, W - 2, '^');
|
m.set(11, 29, 'G'); m.set(55, 29, 'G'); m.set(95, 29, 'G');
|
||||||
// --- 竖井:列 88..120,row 27 → 11 ---
|
// ============ 右井 x122-129 → L1(左墙底部留 3 格入口)============
|
||||||
const sp = shaft(m, 27, 11, 88, 120, 6, 2);
|
m.vline(122, 17, 26, '#'); m.vline(129, 16, 29, '#');
|
||||||
for (let r = 12; r < 28; r += 4) { m.set(86, r, '>'); m.set(122, r, '<'); }
|
block(m, 124, 27, 2); block(m, 126, 24, 2); block(m, 124, 21, 2); block(m, 126, 18, 2);
|
||||||
// --- 上层长廊 row 11,从竖井顶往左横穿 ---
|
wall(m, 129, 26, 26, '<'); wall(m, 122, 23, 23, '>'); wall(m, 129, 20, 20, '<');
|
||||||
const up = [];
|
// ============ L1 中层(地板 row 16,向左折返,延伸到两侧井底)============
|
||||||
let x = sp[sp.length - 1].x;
|
m.rect(1, 16, 122, 1, '#');
|
||||||
while (x > 6) { m.rect(x, 11, 2, 1, '#'); up.push(x); x -= 6; }
|
carpet(m, 9, 15, 112);
|
||||||
m.hline(12, 6, 84, '^'); // 上层地板刺(避开竖井列)
|
ceil(m, 9, 118, 10);
|
||||||
m.hline(6, 6, 84, 'v'); // 上层天花板刺
|
vgap(m, 108, 14); vgap(m, 82, 14); vgap(m, 56, 14); vgap(m, 30, 14);
|
||||||
m.rect(2, 11, 6, 1, '#');
|
hgap(m, 95, 15); hgap(m, 43, 15);
|
||||||
m.set(4, 10, 'X');
|
block(m, 69, 15, 2); block(m, 17, 15, 2);
|
||||||
m.set(4, 28, 'S');
|
m.set(113, 15, 'G'); m.set(63, 15, 'G'); m.set(21, 15, 'G');
|
||||||
m.set(low[3].x, 28, 'G');
|
// ============ 左井 x2-7 → L2(右墙底部留 3 格入口,桥接块接 L2 地板边)============
|
||||||
m.set(low[7].x, 28, 'G');
|
m.vline(8, 9, 12, '#');
|
||||||
m.set(low[11].x, 28, 'G');
|
block(m, 4, 13, 2); block(m, 2, 10, 2); block(m, 4, 7, 2);
|
||||||
m.set(sp[3].x, sp[3].row - 1, 'G');
|
block(m, 6, 7, 2); // 桥
|
||||||
m.set(up[3], 10, 'G');
|
wall(m, 8, 11, 11, '<'); wall(m, 2, 10, 10, '>');
|
||||||
m.set(up[8], 10, 'G');
|
// ============ L2 顶层(地板 row 8,向右到终点)============
|
||||||
|
m.rect(8, 8, 118, 1, '#');
|
||||||
|
carpet(m, 9, 7, 114);
|
||||||
|
m.set(8, 7, '.'); m.set(9, 7, '.'); m.set(10, 7, '.'); m.set(11, 7, '.'); // 井口净空
|
||||||
|
ceil(m, 9, 122, 2);
|
||||||
|
vgap(m, 24, 6); vgap(m, 60, 6); vgap(m, 96, 6);
|
||||||
|
hgap(m, 36, 7); hgap(m, 84, 7);
|
||||||
|
m.set(15, 7, 'G'); m.set(51, 7, 'G'); m.set(87, 7, 'G');
|
||||||
|
m.set(122, 7, 'X');
|
||||||
return m.out();
|
return m.out();
|
||||||
})(),
|
})(),
|
||||||
},
|
},
|
||||||
|
|
||||||
// ================= 7 =================
|
// ============================================================ j7 天降刺
|
||||||
{
|
{
|
||||||
id: 'j7', name: 'SKY SPIKES', type: 'jump', diff: 5, bg: 'jump',
|
id: 'j7', name: 'SKY SPIKES', cn: '天降刺', type: 'jump', diff: 4,
|
||||||
width: 72, hint: '72×52。两格宽的平台、六格横移,每两级就有一个平台只有一格宽,两边墙上全是刺。',
|
hint: '之字爬塔,每层头顶悬着掉落刺。走近就掉。别停。',
|
||||||
|
width: 72,
|
||||||
grid: (() => {
|
grid: (() => {
|
||||||
const W = 72, H = 52;
|
const W = 72, H = 52;
|
||||||
const m = new Grid(W, H);
|
const m = new Grid(W, H);
|
||||||
m.border();
|
m.border('#');
|
||||||
m.hline(H - 2, 1, W - 2, '^');
|
m.rect(1, 48, W - 2, 1, '#');
|
||||||
let row = H - 3, x = 4, dir = 1;
|
m.set(3, 47, 'S');
|
||||||
const pads = [];
|
// 6 层之字平台:每层刺毯 + 钻石 + 掉落刺
|
||||||
while (row >= 5) {
|
const tiers = [
|
||||||
const w = (pads.length % 4 === 3) ? 1 : 2;
|
{ y: 44, x0: 4, x1: 30 }, { y: 38, x0: 34, x1: 62 },
|
||||||
m.rect(x, row, w, 1, '#');
|
{ y: 32, x0: 6, x1: 34 }, { y: 26, x0: 38, x1: 64 },
|
||||||
pads.push({ x, row });
|
{ y: 20, x0: 8, x1: 36 }, { y: 14, x0: 40, x1: 64 },
|
||||||
row -= 2;
|
];
|
||||||
const nx = x + dir * 6;
|
for (const t of tiers) {
|
||||||
if (nx + 1 > W - 8) dir = -1; else if (nx < 4) dir = 1; else x = nx;
|
m.rect(t.x0, t.y, t.x1 - t.x0 + 1, 1, '#');
|
||||||
|
// 3/4 格交替刺毯;两端各留 2 格净空作为上一层跃入 / 下一层起跳的落脚点
|
||||||
|
const n = t.x1 - t.x0 + 1;
|
||||||
|
carpet(m, t.x0, t.y - 1, n, [0, 1, n - 2, n - 1]);
|
||||||
|
for (let x = t.x0 + 5; x <= t.x1 - 3; x += 7) m.set(x, t.y - 8, 'D');
|
||||||
|
ceil(m, t.x0 + 2, t.x1 - 2, t.y - 5); // 压顶垂刺:层内二段跳必死
|
||||||
|
vgap(m, Math.floor((t.x0 + t.x1) / 2), t.y - 2);
|
||||||
}
|
}
|
||||||
for (let r = 8; r < H - 4; r += 2) { m.set(1, r, '>'); m.set(W - 2, r, '<'); }
|
// 层间连接块(升 2 格,落点对准缺口)
|
||||||
pads.forEach((p, i) => { if (i % 2 === 0 && p.row - 5 > 2) m.set(p.x, p.row - 5, 'D'); });
|
block(m, 32, 46, 2);
|
||||||
pads.forEach((p, i) => { if (i > 0 && i % 5 === 0) m.set(p.x, p.row - 1, 'G'); });
|
block(m, 32, 42, 2); block(m, 34, 40, 2);
|
||||||
m.set(5, H - 4, 'S');
|
block(m, 32, 36, 2); block(m, 30, 34, 2);
|
||||||
const top = pads[pads.length - 1];
|
block(m, 36, 30, 2); block(m, 38, 28, 2);
|
||||||
m.set(top.x, top.row - 1, 'X');
|
block(m, 36, 24, 2); block(m, 34, 22, 2);
|
||||||
|
block(m, 38, 18, 2); block(m, 40, 16, 2);
|
||||||
|
// 存档(各层缺口上)
|
||||||
|
m.set(7, 43, 'G'); m.set(37, 37, 'G'); m.set(9, 31, 'G');
|
||||||
|
m.set(41, 25, 'G'); m.set(11, 19, 'G'); m.set(43, 13, 'G');
|
||||||
|
m.set(60, 13, 'X');
|
||||||
return m.out();
|
return m.out();
|
||||||
})(),
|
})(),
|
||||||
},
|
},
|
||||||
|
|
||||||
// ================= 8 =================
|
// ============================================================ j8 三重威胁
|
||||||
{
|
{
|
||||||
id: 'j8', name: 'TRIPLE THREAT', type: 'jump', diff: 5, bg: 'jump',
|
id: 'j8', name: 'TRIPLE THREAT', cn: '三重威胁', type: 'jump', diff: 4,
|
||||||
width: 130, hint: '冰 → 24 格长的塌陷浮桥 → 一格宽钢丝 → 单向平台直上。四连击。',
|
hint: '冰面滑行、塌陷浮桥、单向台攀爬三连考,中间不许喘气。',
|
||||||
|
width: 130,
|
||||||
grid: (() => {
|
grid: (() => {
|
||||||
const W = 130, H = 26, R = 21;
|
const W = 130, H = 26, R = 22;
|
||||||
const m = new Grid(W, H);
|
const m = new Grid(W, H);
|
||||||
m.border();
|
m.border('#');
|
||||||
m.hline(22, 1, W - 2, '^');
|
m.rect(1, R, W - 2, 1, '#');
|
||||||
// 1) 冰面
|
m.set(3, R - 1, 'S');
|
||||||
m.rect(2, R, 6, 1, '%');
|
// --- x1-34 冰面段 ---
|
||||||
[{ x: 13, w: 6 }, { x: 24, w: 6 }, { x: 35, w: 5 }].forEach(s => m.rect(s.x, R, s.w, 1, '%'));
|
m.rect(1, R, 36, 1, '%');
|
||||||
// 2) 消失浮桥
|
for (const [a, b] of [[7, 9], [13, 15], [19, 21], [26, 29]])
|
||||||
m.rect(41, R, 24, 1, 'V');
|
for (let x = a; x <= b; x++) { m.set(x, R, '.'); m.set(x, R + 1, '^'); }
|
||||||
// 3) 一格宽钢丝(顶刺,只能单跳)
|
vgap(m, 11, R - 2); vgap(m, 17, R - 2); vgap(m, 24, R - 2); vgap(m, 30, R - 2);
|
||||||
const pads = run(69, 3, [[4, 1], [4, 1], [4, 1], [4, 1], [4, 1]]);
|
m.set(11, R - 1, 'G'); m.set(32, R - 1, 'G');
|
||||||
putAll(m, R, pads);
|
// --- x38-74 塌陷浮桥(桥下刺海)---
|
||||||
m.hline(R - 5, 69, 92, 'v');
|
for (let x = 38; x <= 74; x++) { m.set(x, R, '.'); m.set(x, R + 1, '^'); }
|
||||||
// 4) 单向平台直上
|
for (let x = 38; x <= 74; x++) m.set(x, R - 3, 'V');
|
||||||
const sp = shaft(m, 19, 7, 96, 124, 6, 2);
|
m.set(46, R - 4, '^'); m.set(50, R - 4, '^'); m.set(54, R - 4, '^');
|
||||||
const top = sp[sp.length - 1];
|
m.set(58, R - 4, '^'); m.set(62, R - 4, '^'); m.set(66, R - 4, '^'); m.set(70, R - 4, '^');
|
||||||
m.set(4, R - 1, 'S');
|
ceil(m, 40, 72, R - 9); // 垂刺限高
|
||||||
m.set(14, R - 1, 'G');
|
m.set(51, R - 4, 'G');
|
||||||
m.set(36, R - 1, 'G');
|
// --- x76-100 单向台攀爬 ---
|
||||||
m.set(pads[2].x, R - 1, 'G');
|
for (let x = 76; x <= 99; x++) { m.set(x, R, '.'); m.set(x, R + 1, '^'); }
|
||||||
m.set(sp[2].x, sp[2].row - 1, 'G');
|
m.set(78, R - 2, '-'); m.set(79, R - 2, '-');
|
||||||
m.set(top.x, top.row - 1, 'X');
|
m.set(83, R - 4, '-'); m.set(84, R - 4, '-');
|
||||||
|
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);
|
||||||
|
m.set(101, R - 1, 'G');
|
||||||
|
// --- x102-126 钻石收尾 ---
|
||||||
|
carpet(m, 102, R - 1, 24, [0, 1, 20, 21]);
|
||||||
|
ceil(m, 102, 125, R - 5);
|
||||||
|
vgap(m, 105, R - 2); vgap(m, 110, R - 2);
|
||||||
|
vgap(m, 115, R - 2); vgap(m, 120, R - 2);
|
||||||
|
m.set(102, R - 1, 'G');
|
||||||
|
m.set(126, R - 1, 'X');
|
||||||
return m.out();
|
return m.out();
|
||||||
})(),
|
})(),
|
||||||
},
|
},
|
||||||
|
|
||||||
// ================= 9 =================
|
// ============================================================ j9 樱花
|
||||||
{
|
{
|
||||||
id: 'j9', name: 'CHERRY BLOSSOM', type: 'jump', diff: 5, bg: 'jump',
|
id: 'j9', name: 'CHERRY BLOSSOM', cn: '樱花', type: 'jump', diff: 5,
|
||||||
width: 128, hint: '苹果挂在钢丝之间的空中。不拿能通关,拿了才算完整——你会为它死很多次。',
|
hint: '双层结构收 3 个苹果:钻石缝中央一个,矮洞里一个,中层对刺门后一个。',
|
||||||
|
width: 128,
|
||||||
grid: (() => {
|
grid: (() => {
|
||||||
const W = 128, H = 34;
|
const W = 128, H = 34;
|
||||||
const m = new Grid(W, H);
|
const m = new Grid(W, H);
|
||||||
m.border();
|
m.border('#');
|
||||||
m.hline(H - 2, 1, W - 2, '^');
|
// ============ L0 下层(地板 row 28)============
|
||||||
// 下层长廊 row 29(列 2..84)
|
m.rect(1, 28, W - 2, 1, '#');
|
||||||
const low = run(2, 6, [
|
m.set(3, 27, 'S');
|
||||||
[4, 2], [5, 1], [4, 1], [5, 2], [4, 1], [5, 1], [4, 2], [5, 1], [4, 1],
|
carpet(m, 5, 27, 116);
|
||||||
[5, 2], [4, 1], [5, 2], [4, 1],
|
ceil(m, 5, 120, 22);
|
||||||
]);
|
vgap(m, 14, 26); vgap(m, 38, 26);
|
||||||
putAll(m, 29, low);
|
vgap(m, 86, 26); vgap(m, 110, 26);
|
||||||
m.hline(30, 1, W - 2, '^');
|
block(m, 26, 27, 2); block(m, 50, 27, 2);
|
||||||
// 竖井:列 88..118,row 27 → 13
|
block(m, 74, 27, 2); block(m, 98, 27, 2);
|
||||||
const sp = shaft(m, 27, 13, 88, 118, 6, 2);
|
m.set(11, 27, 'G'); m.set(55, 27, 'G'); m.set(95, 27, 'G');
|
||||||
for (let r = 14; r < 28; r += 4) { m.set(86, r, '>'); m.set(120, r, '<'); }
|
// 井口前净空(否则刺毯把竖井入口封死)
|
||||||
// 上层钢丝 row 11,从竖井顶往左
|
m.set(117, 27, '.'); m.set(118, 27, '.'); m.set(119, 27, '.'); m.set(120, 27, '.');
|
||||||
const up = [];
|
// 苹果 1:钻石缝中央
|
||||||
let x = sp[sp.length - 1].x;
|
m.set(62, 26, 'F');
|
||||||
while (x > 8) { m.rect(x, 11, 1, 1, '#'); up.push(x); x -= 4; }
|
// ============ L0 中段矮洞(顶 row 24,3 格高;撞头跳射程≈3 格,洞内缺口加密到 3)============
|
||||||
m.hline(12, 8, 84, '^');
|
m.rect(60, 24, 16, 1, '#');
|
||||||
m.hline(6, 8, 84, 'v');
|
[63, 66, 69, 72, 75].forEach(x => m.set(x, 27, '.'));
|
||||||
m.rect(2, 11, 6, 1, '#');
|
// 苹果 2:矮洞里(跳跃途中拾取)
|
||||||
// 苹果挂在钢丝之间的空中(要跳过去才拿得到)
|
m.set(66, 26, 'F');
|
||||||
[3, 7, 11].forEach(i => { if (up[i]) m.set(up[i] - 2, 8, 'F'); });
|
// ============ 右井 x120-126 → L1(左墙底部留 3 格入口)============
|
||||||
m.set(4, 10, 'X');
|
m.vline(119, 15, 24, '#'); m.vline(126, 14, 27, '#');
|
||||||
m.set(4, 28, 'S');
|
block(m, 121, 25, 2); block(m, 124, 22, 2); block(m, 121, 19, 2); block(m, 124, 16, 2);
|
||||||
m.set(low[3].x, 28, 'G');
|
wall(m, 126, 24, 24, '<'); wall(m, 119, 21, 21, '>'); wall(m, 126, 18, 18, '<');
|
||||||
m.set(low[6].x, 28, 'G');
|
// ============ L1 上层(地板 row 14,向左折返,延伸到井底)============
|
||||||
m.set(sp[3].x, sp[3].row - 1, 'G');
|
m.rect(2, 14, 118, 1, '#');
|
||||||
m.set(up[4], 10, 'G');
|
carpet(m, 9, 13, 108);
|
||||||
m.set(up[9], 10, 'G');
|
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);
|
||||||
|
m.set(105, 13, 'G'); m.set(59, 13, 'G');
|
||||||
|
// 苹果 3:对刺门后
|
||||||
|
m.set(44, 12, 'F');
|
||||||
|
// ============ 终点(上层左端)============
|
||||||
|
m.set(10, 13, 'X');
|
||||||
return m.out();
|
return m.out();
|
||||||
})(),
|
})(),
|
||||||
},
|
},
|
||||||
|
|
||||||
// ================= 10 =================
|
// ============================================================ j10 毕业
|
||||||
{
|
{
|
||||||
id: 'j10', name: 'FINAL NEEDLE', type: 'jump', diff: 5, bg: 'jump',
|
id: 'j10', name: 'FINAL NEEDLE', cn: '毕业针刺', type: 'jump', diff: 5,
|
||||||
width: 150, hint: '150×40。冰 → 钢丝 → 移动平台 → 竖井 → 钢丝。跳刺的毕业考,祝你活着出去。',
|
hint: '四层大考场:刺毯长廊 → 冰面折返 → 矮洞 → 高空钢丝。全程无尿点。',
|
||||||
|
width: 150,
|
||||||
grid: (() => {
|
grid: (() => {
|
||||||
const W = 150, H = 40;
|
const W = 150, H = 44;
|
||||||
const m = new Grid(W, H);
|
const m = new Grid(W, H);
|
||||||
m.border();
|
m.border('#');
|
||||||
m.hline(H - 2, 1, W - 2, '^'); // row 38 刺海
|
// ============ L0 刺毯长廊(地板 row 38,向右)============
|
||||||
// 1) 冰面 row 35
|
m.rect(1, 38, W - 2, 1, '#');
|
||||||
m.rect(2, 35, 6, 1, '%');
|
m.set(3, 37, 'S');
|
||||||
[{ x: 13, w: 6 }, { x: 24, w: 6 }, { x: 35, w: 5 }, { x: 45, w: 5 }]
|
carpet(m, 5, 37, 134);
|
||||||
.forEach(s => m.rect(s.x, 35, s.w, 1, '%'));
|
ceil(m, 5, 138, 32);
|
||||||
m.hline(36, 1, W - 2, '^');
|
vgap(m, 20, 36); vgap(m, 48, 36); vgap(m, 76, 36);
|
||||||
// 2) 钢丝 row 35(顶刺,只能单跳)
|
vgap(m, 104, 36); vgap(m, 128, 36);
|
||||||
const w1 = run(54, 4, [[4, 1], [4, 1], [4, 1], [4, 1], [4, 1], [4, 1], [4, 1]]);
|
block(m, 34, 37, 2); block(m, 62, 37, 2);
|
||||||
putAll(m, 35, w1);
|
block(m, 90, 37, 2); block(m, 118, 37, 2);
|
||||||
m.hline(30, 54, 92, 'v');
|
m.set(11, 37, 'G'); m.set(59, 37, 'G'); m.set(107, 37, 'G');
|
||||||
// 3) 移动平台跨刺海
|
// ============ 右井 A x140-147 → L1(左墙底部留 3 格入口)============
|
||||||
m.rect(97, 35, 4, 1, '#');
|
m.vline(139, 29, 34, '#'); m.vline(147, 28, 37, '#');
|
||||||
m.rect(118, 35, 4, 1, '#');
|
block(m, 141, 35, 2); block(m, 144, 32, 2); block(m, 141, 29, 2); block(m, 144, 26, 2);
|
||||||
// 4) 竖井 列 118..144,row 33 → 9
|
wall(m, 147, 34, 34, '<'); wall(m, 139, 31, 31, '>');
|
||||||
const sp = shaft(m, 33, 9, 118, 144, 6, 2);
|
// ============ L1 冰面层(地板 row 28,向左,延伸到井底)============
|
||||||
for (let r = 10; r < 34; r += 4) { m.set(116, r, '>'); m.set(146, r, '<'); }
|
m.rect(2, 28, 138, 1, '%');
|
||||||
// 5) 上层钢丝 row 7,往左到终点
|
carpet(m, 11, 27, 126);
|
||||||
const w2 = [];
|
m.set(136, 27, '.'); m.set(137, 27, '.'); m.set(138, 27, '.'); // 井 A 出口落点净空
|
||||||
let x = sp[sp.length - 1].x - 2;
|
ceil(m, 11, 136, 22);
|
||||||
while (x > 8) { m.rect(x, 7, 1, 1, '#'); w2.push(x); x -= 4; }
|
vgap(m, 116, 26); vgap(m, 88, 26); vgap(m, 60, 26); vgap(m, 32, 26);
|
||||||
m.hline(8, 6, 114, '^');
|
block(m, 102, 27, 2); block(m, 74, 27, 2); block(m, 46, 27, 2);
|
||||||
m.hline(2, 6, 114, 'v');
|
m.set(121, 27, 'G'); m.set(69, 27, 'G'); m.set(23, 27, 'G');
|
||||||
m.rect(2, 7, 6, 1, '#');
|
// ============ 左井 B x2-8 → L2(右墙底部留 3 格入口)============
|
||||||
m.set(4, 6, 'X');
|
m.vline(8, 17, 24, '#');
|
||||||
m.set(4, 34, 'S');
|
block(m, 4, 25, 2); block(m, 2, 22, 2); block(m, 4, 19, 2); block(m, 5, 17, 2);
|
||||||
m.set(14, 34, 'G');
|
wall(m, 8, 21, 21, '<'); wall(m, 2, 20, 20, '>'); wall(m, 8, 18, 18, '<');
|
||||||
m.set(36, 34, 'G');
|
// ============ L2 矮洞层(地板 row 16,向右,延伸到井 C 底)============
|
||||||
m.set(w1[3].x, 34, 'G');
|
m.rect(8, 16, 138, 1, '#');
|
||||||
m.set(98, 34, 'G');
|
m.rect(10, 12, 128, 1, '#'); // 矮洞顶(3 格高洞)
|
||||||
m.set(sp[4].x, sp[4].row - 1, 'G');
|
carpet(m, 10, 15, 128);
|
||||||
m.set(w2[5], 6, 'G');
|
m.set(8, 15, '.'); m.set(9, 15, '.'); // 井 B 出口净空
|
||||||
m.set(w2[12], 6, 'G');
|
hgap(m, 72, 15);
|
||||||
|
m.set(19, 15, 'G'); m.set(67, 15, 'G'); m.set(115, 15, 'G');
|
||||||
|
// ============ 右井 C x140-147 → L3(左墙底部留 3 格入口,桥接块接 L3)============
|
||||||
|
m.vline(139, 9, 12, '#'); m.vline(147, 8, 15, '#');
|
||||||
|
block(m, 141, 13, 2); block(m, 144, 10, 2); block(m, 141, 7, 2);
|
||||||
|
block(m, 137, 7, 4); // 桥:向左落到 L3 基座
|
||||||
|
wall(m, 147, 12, 12, '<'); wall(m, 139, 10, 10, '>');
|
||||||
|
// ============ L3 高空钢丝(基座 row 8 + 刺毯 row 7,开放高空,向左到终点)============
|
||||||
|
m.rect(8, 8, 130, 1, '#');
|
||||||
|
spikeRun(m, 9, 7, 128, '^'); // 刺毯基底
|
||||||
|
for (let x = 133; x <= 137; x++) m.set(x, 7, '.'); // 井口落脚区
|
||||||
|
// 钢丝柱:间距 3~6 不规则,柱顶 row 5~7 错落
|
||||||
|
const wires = [];
|
||||||
|
const steps = [4, 5, 3, 6, 4, 3, 5, 4, 6, 3, 5, 4, 4, 6, 3, 5, 4, 6, 4, 3, 5, 4, 6, 3, 5, 4, 4, 3];
|
||||||
|
let wx = 132;
|
||||||
|
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, '#'));
|
||||||
|
// 存档放在两根钢丝顶上
|
||||||
|
const g1 = wires[6], g2 = wires[Math.floor(wires.length / 2) + 4];
|
||||||
|
const topOf = i => [6, 5, 7, 6, 7, 5][i % 6];
|
||||||
|
m.set(g1, topOf(6) - 1, 'G'); m.set(g2, topOf(wires.indexOf(g2)) - 1, 'G');
|
||||||
|
m.rect(8, 7, 4, 1, '#'); // 终点台
|
||||||
|
m.set(9, 6, 'X');
|
||||||
return m.out();
|
return m.out();
|
||||||
})(),
|
})(),
|
||||||
movers: [
|
|
||||||
{ x: 104, y: 35, w: 2, h: 1, path: [[104, 35], [116, 35]], speed: 2.0 },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export default JUMP_LEVELS;
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
// 经典针刺阵型库(IWBT 风格):所有函数基于 Grid,坐标单位 = 格
|
||||||
|
import { Grid } from './kit.js';
|
||||||
|
|
||||||
|
// 上下对刺夹缝(横向穿越 1 格高的缝):缝在行 y。
|
||||||
|
// 自动清理两侧地毯刺作起跳/落点(否则有效跳距翻倍,无解)。
|
||||||
|
export function vgap(m, x, y) {
|
||||||
|
m.set(x, y - 1, 'v'); m.set(x, y + 1, '^');
|
||||||
|
m.set(x - 1, y + 1, '.'); m.set(x + 1, y + 1, '.');
|
||||||
|
m.set(x - 2, y + 1, '.'); m.set(x + 2, y + 1, '.');
|
||||||
|
}
|
||||||
|
// 左右对刺夹缝(竖向穿越 1 格宽的缝):缝在列 x,自动清理两侧落点。
|
||||||
|
export function hgap(m, x, y) {
|
||||||
|
m.set(x - 1, y, '>'); m.set(x + 1, y, '<');
|
||||||
|
m.set(x - 2, y, '.'); m.set(x + 2, y, '.');
|
||||||
|
}
|
||||||
|
// 钻石阵:中心空格 + 四向刺(经典 diamond jump)
|
||||||
|
// 注意:矩形判定盒下,完整钻石的横向穿越窗口只有几像素——
|
||||||
|
// 作为"跳穿门"请用 vgap/hgap;diamond 只用作翻越/绕行的障碍或装饰。
|
||||||
|
export function diamond(m, x, y) { vgap(m, x, y); hgap(m, x, y); }
|
||||||
|
// 一排刺,gaps 为留空偏移(相对 x0)
|
||||||
|
export function spikeRun(m, x0, y, n, ch = '^', gaps = []) {
|
||||||
|
for (let i = 0; i < n; i++) if (!gaps.includes(i)) m.set(x0 + i, y, ch);
|
||||||
|
}
|
||||||
|
// 悬浮块,顶面铺刺(不可站)/ 或纯块
|
||||||
|
export function block(m, x, y, w = 1, h = 1, ch = '#') { m.rect(x, y, w, h, ch); }
|
||||||
|
// 悬浮块 + 顶刺
|
||||||
|
export function spikedBlock(m, x, y, w = 1) { m.rect(x, y, w, 1, '#'); spikeRun(m, x, y - 1, w, '^'); }
|
||||||
|
// 天花板垂刺
|
||||||
|
export function ceil(m, x0, x1, y) { m.hline(y, x0, x1, 'v'); }
|
||||||
|
// 墙刺竖段
|
||||||
|
export function wall(m, x, y0, y1, ch) { m.vline(x, y0, y1, ch); }
|
||||||
|
// 角刺块:w×h 实心块,某个外角带 L 形双刺(经典 corner)
|
||||||
|
// dir: 'tl' 左上角 / 'tr' 右上 / 'bl' 左下 / 'br' 右下
|
||||||
|
export function corner(m, x, y, w, h, dir) {
|
||||||
|
m.rect(x, y, w, h, '#');
|
||||||
|
if (dir === 'tl') { m.set(x, y - 1, '^'); m.set(x - 1, y, '>'); }
|
||||||
|
if (dir === 'tr') { m.set(x + w - 1, y - 1, '^'); m.set(x + w, y, '<'); }
|
||||||
|
if (dir === 'bl') { m.set(x, y + h, 'v'); m.set(x - 1, y + h - 1, '>'); }
|
||||||
|
if (dir === 'br') { m.set(x + w - 1, y + h, 'v'); m.set(x + w, y + h - 1, '<'); }
|
||||||
|
}
|
||||||
|
// 之字踏石竖井(可解性已用 tools/solve.mjs 验证)
|
||||||
|
// xL..xR 为井内列;topRow = 上层地板行,botRow = 下层地板行(差须为偶数且 ≥4)
|
||||||
|
// floorSide: 'l' 上层地板在井的左侧(井在右侧)/ 'r' 反之
|
||||||
|
// 规则:
|
||||||
|
// 1) 踏石每级升 2 格(单跳 90px > 64px),左右交替,宽度 w 使两级有 2 格重叠
|
||||||
|
// —— overlap 让"上升后横移"只需 1 格左右,避免横向距离超过滞空距离。
|
||||||
|
// 2) 靠地板一侧的井壁从 topRow+1 起到 botRow-4,底部留 3 格入口。
|
||||||
|
// 3) 顶台位于 topRow 且与上层地板紧邻(同高相接),不存在"洞口被顶台封死"。
|
||||||
|
export function well(m, xL, xR, topRow, botRow, floorSide = 'l', opts = {}) {
|
||||||
|
const w = opts.w || 5;
|
||||||
|
let i = 0;
|
||||||
|
for (let r = topRow; r <= botRow - 2; r += 2, i++) {
|
||||||
|
const onLeft = (i % 2 === 0) === (floorSide === 'l');
|
||||||
|
m.rect(onLeft ? xL : xR - w + 1, r, w, 1, '#');
|
||||||
|
}
|
||||||
|
const floorX = floorSide === 'l' ? xL - 1 : xR + 1;
|
||||||
|
const backX = floorSide === 'l' ? xR + 1 : xL - 1;
|
||||||
|
m.vline(backX, topRow, botRow, '#');
|
||||||
|
m.vline(floorX, topRow + 1, botRow - 4, '#');
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 竖井:在 (x0..x1) 之间开竖直通道,交替放小平台,两侧可布墙刺
|
||||||
|
// plats: [{y, side:'l'|'r', w}],spikeEvery: 每隔几行在对面墙上放刺
|
||||||
|
export function shaft(m, x0, x1, yTop, yBot, plats, opts = {}) {
|
||||||
|
for (const p of plats) {
|
||||||
|
const px = p.side === 'l' ? x0 : x1 - p.w + 1;
|
||||||
|
m.rect(px, p.y, p.w, 1, '#');
|
||||||
|
if (p.spike) spikeRun(m, px, p.y - 1, p.w, '^', p.spikeGaps || []);
|
||||||
|
}
|
||||||
|
if (opts.wallSpikes) {
|
||||||
|
const { side = 'r', every = 3, from = yTop + 1, to = yBot - 1 } = opts.wallSpikes;
|
||||||
|
for (let y = from; y <= to; y += every) {
|
||||||
|
if (plats.some(p => Math.abs(p.y - y) <= 1)) continue;
|
||||||
|
m.set(side === 'r' ? x1 : x0, y, side === 'r' ? '<' : '>');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export { Grid };
|
||||||
+2
-1
@@ -21,7 +21,8 @@ export const PHY = {
|
|||||||
export class Player {
|
export class Player {
|
||||||
constructor(level) {
|
constructor(level) {
|
||||||
this.level = level;
|
this.level = level;
|
||||||
this.w = 14; this.h = 18;
|
// IWBT 官方判定盒:11×21 像素(比精灵视觉略小,支持像素级擦弹/贴刺)
|
||||||
|
this.w = 11; this.h = 21;
|
||||||
this.reset(true);
|
this.reset(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+108
@@ -0,0 +1,108 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>关卡地图预览 · IWBT C 同人</title>
|
||||||
|
<style>
|
||||||
|
:root { --bg:#0e1018; --panel:#171b26; --line:#2a3040; --fg:#dfe3ee; --dim:#8b93a7; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin:0; background:var(--bg); color:var(--fg); font:14px/1.6 ui-sans-serif,system-ui,"PingFang SC","Microsoft YaHei",sans-serif; }
|
||||||
|
header { padding:14px 18px; border-bottom:1px solid var(--line); display:flex; gap:14px; align-items:center; flex-wrap:wrap; }
|
||||||
|
h1 { font-size:15px; font-weight:500; margin:0 14px 0 0; }
|
||||||
|
select, button { background:var(--panel); color:var(--fg); border:1px solid var(--line); border-radius:6px; padding:5px 10px; font:inherit; }
|
||||||
|
.stat { color:var(--dim); font-size:13px; }
|
||||||
|
.stat b { color:var(--fg); font-weight:500; }
|
||||||
|
main { padding:14px 18px 30px; overflow:auto; }
|
||||||
|
canvas { display:block; background:var(--panel); border:1px solid var(--line); border-radius:6px; image-rendering:pixelated; }
|
||||||
|
.legend { margin-top:12px; display:flex; gap:18px; flex-wrap:wrap; color:var(--dim); font-size:13px; }
|
||||||
|
.legend i { display:inline-block; width:12px; height:12px; border-radius:2px; vertical-align:-2px; margin-right:5px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>关卡地图预览</h1>
|
||||||
|
<select id="sel"></select>
|
||||||
|
<span class="stat" id="stat"></span>
|
||||||
|
</header>
|
||||||
|
<main><canvas id="cv"></canvas></main>
|
||||||
|
<div class="legend">
|
||||||
|
<span><i style="background:#3a4056"></i>实心块</span>
|
||||||
|
<span><i style="background:#5b7fa6"></i>冰面</span>
|
||||||
|
<span><i style="background:#6b5b8a"></i>单向台</span>
|
||||||
|
<span><i style="background:#2d5a7a"></i>水</span>
|
||||||
|
<span><i style="background:#e04b6a"></i>四向刺</span>
|
||||||
|
<span><i style="background:#8b3ad0"></i>即死块</span>
|
||||||
|
<span><i style="background:#3ad07a"></i>出生点</span>
|
||||||
|
<span><i style="background:#4bc8ff"></i>存档点</span>
|
||||||
|
<span><i style="background:#ffd24b"></i>终点 / 苹果</span>
|
||||||
|
<span><i style="background:#c48bff"></i>手枪·跳台·炮台·BOSS·掉落刺</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="module">
|
||||||
|
import { LEVELS } from './js/levels/index.js';
|
||||||
|
|
||||||
|
const COLORS = {
|
||||||
|
'#': '#3a4056', '@': '#3a4056', '=': '#3a4056', 'M': '#4a4f63',
|
||||||
|
'%': '#5b7fa6', '-': '#6b5b8a', '~': '#2d5a7a', '*': '#8b3ad0', 'V': '#4a4f63',
|
||||||
|
'^': '#e04b6a', 'v': '#e04b6a', '<': '#e04b6a', '>': '#e04b6a',
|
||||||
|
S: '#3ad07a', G: '#4bc8ff', X: '#ffd24b', F: '#ffd24b',
|
||||||
|
P: '#c48bff', J: '#c48bff', O: '#c48bff', B: '#c48bff', D: '#c48bff',
|
||||||
|
W: '#c48bff', T: '#8b93a7', C: '#8b93a7', '!': '#8b93a7',
|
||||||
|
};
|
||||||
|
|
||||||
|
const sel = document.getElementById('sel');
|
||||||
|
const stat = document.getElementById('stat');
|
||||||
|
const cv = document.getElementById('cv');
|
||||||
|
const ctx = cv.getContext('2d');
|
||||||
|
|
||||||
|
LEVELS.forEach((l, i) => {
|
||||||
|
const o = document.createElement('option');
|
||||||
|
o.value = i;
|
||||||
|
o.textContent = `${l.id} ${l.name} ${l.cn || ''}`;
|
||||||
|
sel.appendChild(o);
|
||||||
|
});
|
||||||
|
|
||||||
|
function draw(d) {
|
||||||
|
const rows = d.grid, h = rows.length, w = d.width;
|
||||||
|
const s = Math.max(4, Math.min(16, Math.floor(Math.min(1500 / w, 820 / h))));
|
||||||
|
cv.width = w * s; cv.height = h * s;
|
||||||
|
cv.style.width = cv.width + 'px';
|
||||||
|
cv.style.height = cv.height + 'px';
|
||||||
|
ctx.clearRect(0, 0, cv.width, cv.height);
|
||||||
|
let spike = 0;
|
||||||
|
for (let y = 0; y < h; y++) {
|
||||||
|
const line = rows[y] || '';
|
||||||
|
for (let x = 0; x < w; x++) {
|
||||||
|
const ch = line[x] || '.';
|
||||||
|
if (ch === '.') continue;
|
||||||
|
if ('^v<>'.includes(ch)) spike++;
|
||||||
|
const c = COLORS[ch];
|
||||||
|
if (!c) continue;
|
||||||
|
ctx.fillStyle = c;
|
||||||
|
if ('^v<>'.includes(ch)) { // 刺画成三角形,方向和判定一致
|
||||||
|
ctx.beginPath();
|
||||||
|
const px = x * s, py = y * s;
|
||||||
|
if (ch === '^') { ctx.moveTo(px + s / 2, py + 2); ctx.lineTo(px + s - 2, py + s - 1); ctx.lineTo(px + 2, py + s - 1); }
|
||||||
|
if (ch === 'v') { ctx.moveTo(px + 2, py + 1); ctx.lineTo(px + s - 2, py + 1); ctx.lineTo(px + s / 2, py + s - 2); }
|
||||||
|
if (ch === '<') { ctx.moveTo(px + 1, py + s / 2); ctx.lineTo(px + s - 1, py + 2); ctx.lineTo(px + s - 1, py + s - 2); }
|
||||||
|
if (ch === '>') { ctx.moveTo(px + 1, py + 2); ctx.lineTo(px + s - 1, py + 1); ctx.lineTo(px + 1, py + s - 2); }
|
||||||
|
ctx.closePath(); ctx.fill();
|
||||||
|
} else if ('SGXFPJODBW'.includes(ch)) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x * s + s / 2, y * s + s / 2, s * 0.3, 0, 7);
|
||||||
|
ctx.fill();
|
||||||
|
} else {
|
||||||
|
ctx.fillRect(x * s, y * s, s, s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const total = w * h;
|
||||||
|
const saves = rows.join('').split('').filter(c => c === 'G').length;
|
||||||
|
stat.innerHTML = `<b>${d.name}</b> ${d.cn || ''} · ${w}×${h} 格 · 类型 ${d.type} · 难度 ${d.diff} · 刺 ${spike} 格(${(spike / total * 100).toFixed(1)}%)· 存档点 ${saves}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
sel.addEventListener('change', () => draw(LEVELS[+sel.value]));
|
||||||
|
draw(LEVELS[0]);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
// 共享可达性 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 function setupGlobals() {
|
||||||
|
const handlers = {};
|
||||||
|
const addL = (t, fn) => { (handlers[t] = handlers[t] || []).push(fn); };
|
||||||
|
globalThis.addEventListener = addL;
|
||||||
|
globalThis.window = { addEventListener: addL };
|
||||||
|
return handlers;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeSim(L) {
|
||||||
|
// 水:近似为可站立
|
||||||
|
for (let y = 0; y < L.h; y++)
|
||||||
|
for (let x = 0; x < L.w; x++)
|
||||||
|
if (L.tiles[y][x] === 6) L.tiles[y][x] = 1;
|
||||||
|
// 移动平台:整条路径当桥(乐观近似)
|
||||||
|
const br = [];
|
||||||
|
for (const m of L.movers) {
|
||||||
|
const xs = m.path.map(p => p.x).concat([m.x]);
|
||||||
|
const ys = m.path.map(p => p.y).concat([m.y]);
|
||||||
|
const x0 = Math.min(...xs), x1 = Math.max(...xs) + m.w;
|
||||||
|
const y0 = Math.min(...ys), y1 = Math.max(...ys) + m.h;
|
||||||
|
br.push({ x: x0, y: y0, w: x1 - x0, h: y1 - y0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function solidAt(x, y, w, h, down, prevBottom) {
|
||||||
|
const x0 = Math.floor(x / TILE), x1 = Math.floor((x + w - 0.001) / TILE);
|
||||||
|
const y0 = Math.floor(y / TILE), y1 = Math.floor((y + h - 0.001) / TILE);
|
||||||
|
for (let ty = y0; ty <= y1; ty++) for (let tx = x0; tx <= x1; tx++) {
|
||||||
|
const t = L.tileAt(tx, ty);
|
||||||
|
if (t === 7) { if (down && prevBottom !== undefined && prevBottom <= ty * TILE + 6) return true; continue; }
|
||||||
|
if (L.isSolidTile(t, tx, ty)) return true;
|
||||||
|
}
|
||||||
|
for (const b of br) if (x < b.x + b.w && x + w > b.x && y < b.y + b.h && y + h > b.y) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canStand(fx, fy) {
|
||||||
|
if (fx < 0 || fx + PW > L.pxW) return false;
|
||||||
|
if (solidAt(fx, fy - PH, PW, PH, false)) return false;
|
||||||
|
if (!solidAt(fx, fy - PH + 1, PW, PH, true, fy - 1)) return false;
|
||||||
|
if (L.deadlyRect(fx, fy - PH, PW, PH)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
while (t++ < 240) {
|
||||||
|
if (djump && !used && t === djump) { vy = -DJUMP; used = true; }
|
||||||
|
if (flip && t === flip) vx = -vx;
|
||||||
|
if (rel && t === rel && vy < -3) vy = -3;
|
||||||
|
vy = Math.min(vy + GRAV, MAXFALL);
|
||||||
|
// 真实行为(player.js):本帧被墙挡住只是没推动,按住方向键下一帧 vx 仍为 ±3。
|
||||||
|
// 绝不能把 vx 永久清零,否则"贴着墙上升、越过台面边缘后再横移"这条经典路线会丢失。
|
||||||
|
if (vx !== 0) {
|
||||||
|
const nx = x + vx;
|
||||||
|
if (!solidAt(nx, y, PW, PH, false)) x = nx;
|
||||||
|
}
|
||||||
|
const prevBottom = y + PH;
|
||||||
|
let rem = Math.abs(vy), dy = Math.sign(vy), landed = false;
|
||||||
|
while (rem > 0.0001) {
|
||||||
|
const m = Math.min(1, rem), ny = y + dy * m;
|
||||||
|
if (!solidAt(x, ny, PW, PH, dy > 0, prevBottom)) { y = ny; rem -= m; }
|
||||||
|
else { if (dy > 0) landed = true; vy = 0; break; }
|
||||||
|
}
|
||||||
|
if (L.deadlyRect(x, y, PW, PH)) return null;
|
||||||
|
if (targets) {
|
||||||
|
const box = { x, y, w: PW, h: PH };
|
||||||
|
for (const tg of targets) if (!tg.hit && overlap(box, tg.rect)) tg.hit = true;
|
||||||
|
}
|
||||||
|
if (y > L.pxH + 120 || y < -240) return null;
|
||||||
|
if (landed) return { fx: x, fy: y + PH };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fall(fx, fy, dir, targets) {
|
||||||
|
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);
|
||||||
|
const prevBottom = y + PH;
|
||||||
|
let rem = Math.abs(vy), landed = false;
|
||||||
|
while (rem > 0.0001) {
|
||||||
|
const m = Math.min(1, rem), ny = y + m;
|
||||||
|
if (!solidAt(x, ny, PW, PH, true, prevBottom)) { y = ny; rem -= m; }
|
||||||
|
else { landed = true; break; }
|
||||||
|
}
|
||||||
|
if (L.deadlyRect(x, y, PW, PH)) return null;
|
||||||
|
if (targets) {
|
||||||
|
const box = { x, y, w: PW, h: PH };
|
||||||
|
for (const tg of targets) if (!tg.hit && overlap(box, tg.rect)) tg.hit = true;
|
||||||
|
}
|
||||||
|
if (y > L.pxH + 120) return null;
|
||||||
|
if (landed) return { fx: x, fy: y + PH };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { solidAt, canStand, jump, fall, overlap };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** BFS 可达性:targets=[{rect,type,...}],onState 回调每个新状态 */
|
||||||
|
export const DEF_DJ = [0, 6, 12, 18, 24, 30];
|
||||||
|
export const DEF_REL = [0, 4, 8, 14];
|
||||||
|
|
||||||
|
export function reach(L, { DJSET = DEF_DJ, RELSET = DEF_REL, targets = [], onState = null, maxStates = 40000 } = {}) {
|
||||||
|
const sim = makeSim(L);
|
||||||
|
let sx = L.spawn.x, sy = L.spawn.y + PH, start = null;
|
||||||
|
for (let k = 0; k < 400; k++) {
|
||||||
|
if (sim.canStand(sx, sy + k)) { start = { fx: sx, fy: sy + k }; break; }
|
||||||
|
if (sy + k > L.pxH) break;
|
||||||
|
}
|
||||||
|
if (!start) return { err: '出生点下方找不到落脚点', states: 0, targets };
|
||||||
|
|
||||||
|
const key = s => `${Math.round(s.fx / 6)},${Math.round(s.fy / 6)}`;
|
||||||
|
const seen = new Set([key(start)]);
|
||||||
|
const q = [start];
|
||||||
|
let n = 0;
|
||||||
|
while (q.length && n < maxStates) {
|
||||||
|
const s = q.shift(); n++;
|
||||||
|
const box = { x: s.fx, y: s.fy - PH, w: PW, h: PH };
|
||||||
|
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;
|
||||||
|
const k = key(r);
|
||||||
|
if (seen.has(k)) return;
|
||||||
|
seen.add(k); q.push(r);
|
||||||
|
if (onState) onState(r);
|
||||||
|
};
|
||||||
|
for (const d of [-HSPD, HSPD]) push(sim.canStand(s.fx + d, s.fy) ? { fx: s.fx + d, fy: s.fy } : null);
|
||||||
|
for (const d of [-1, 1]) push(sim.fall(s.fx, s.fy, d, targets));
|
||||||
|
for (const vx of [-HSPD, 0, HSPD]) for (const dj of DJSET) for (const rel of RELSET) {
|
||||||
|
if (dj && rel) continue;
|
||||||
|
push(sim.jump(s.fx, s.fy, vx, dj, 0, rel, targets));
|
||||||
|
if (vx !== 0) push(sim.jump(s.fx, s.fy, vx, dj, 22, rel, targets));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { states: seen.size, targets };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeTargets(L) {
|
||||||
|
const targets = [];
|
||||||
|
for (const e of L.entities) {
|
||||||
|
if (e.type === 'goal' || e.type === 'save' || e.type === 'apple')
|
||||||
|
targets.push({ type: e.type, idx: e.idx, no: e.no, rect: { x: e.x, y: e.y, w: e.w, h: e.h }, hit: false });
|
||||||
|
}
|
||||||
|
return targets;
|
||||||
|
}
|
||||||
+20
-99
@@ -1,110 +1,31 @@
|
|||||||
// 诊断:打印可达站立点的最大 x(按行),找出断连位置
|
// 诊断:打印可达站立点范围(按行),找出断连位置
|
||||||
const handlers = {};
|
// 用法: node reach-diag.mjs <levelId>
|
||||||
const addL = (t, fn) => { (handlers[t] = handlers[t] || []).push(fn); };
|
import { setupGlobals, reach, TILE } from './lib-reach.mjs';
|
||||||
globalThis.addEventListener = addL;
|
setupGlobals();
|
||||||
globalThis.window = { addEventListener: addL };
|
|
||||||
|
|
||||||
const { LEVELS } = await import('../js/levels/index.js');
|
const { LEVELS } = await import('../js/levels/index.js');
|
||||||
const { Level } = await import('../js/level.js');
|
const { Level } = await import('../js/level.js');
|
||||||
|
|
||||||
const TILE = 32, PW = 14, PH = 18;
|
|
||||||
const GRAV = 0.4, MAXFALL = 9, JUMP = 8.5, DJUMP = 7.6, HSPD = 3;
|
|
||||||
|
|
||||||
function solidAt(L, br, x, y, w, h, down, prevBottom) {
|
|
||||||
const x0 = Math.floor(x / TILE), x1 = Math.floor((x + w - 0.001) / TILE);
|
|
||||||
const y0 = Math.floor(y / TILE), y1 = Math.floor((y + h - 0.001) / TILE);
|
|
||||||
for (let ty = y0; ty <= y1; ty++) for (let tx = x0; tx <= x1; tx++) {
|
|
||||||
const t = L.tileAt(tx, ty);
|
|
||||||
if (t === 7) { if (down && prevBottom !== undefined && prevBottom <= ty * TILE + 6) return true; continue; }
|
|
||||||
if (L.isSolidTile(t, tx, ty)) return true;
|
|
||||||
}
|
|
||||||
for (const b of br) if (x < b.x + b.w && x + w > b.x && y < b.y + b.h && y + h > b.y) return true;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
function canStand(L, br, fx, fy) {
|
|
||||||
if (fx < 0 || fx + PW > L.pxW) return false;
|
|
||||||
if (solidAt(L, br, fx, fy - PH, PW, PH, false)) return false;
|
|
||||||
if (!solidAt(L, br, fx, fy - PH + 1, PW, PH, true, fy - 1)) return false;
|
|
||||||
if (L.deadlyRect(fx, fy - PH, PW, PH)) return false;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
function jump(L, br, fx, fy, vx0, djump, flip) {
|
|
||||||
let x = fx, y = fy - PH, vy = -JUMP, vx = vx0, t = 0, used = false;
|
|
||||||
while (t++ < 240) {
|
|
||||||
if (djump && !used && t === djump) { vy = -DJUMP; used = true; }
|
|
||||||
if (flip && t === flip) vx = -vx;
|
|
||||||
vy = Math.min(vy + GRAV, MAXFALL);
|
|
||||||
if (vx !== 0) { const nx = x + vx; if (!solidAt(L, br, nx, y, PW, PH, false)) x = nx; else vx = 0; }
|
|
||||||
const prevBottom = y + PH;
|
|
||||||
let rem = Math.abs(vy), dy = Math.sign(vy), landed = false;
|
|
||||||
while (rem > 0.0001) {
|
|
||||||
const m = Math.min(1, rem); const ny = y + dy * m;
|
|
||||||
if (!solidAt(L, br, x, ny, PW, PH, dy > 0, prevBottom)) { y = ny; rem -= m; }
|
|
||||||
else { if (dy > 0) landed = true; vy = 0; break; }
|
|
||||||
}
|
|
||||||
if (L.deadlyRect(x, y, PW, PH)) return null;
|
|
||||||
if (y > L.pxH + 120 || y < -240) return null;
|
|
||||||
if (landed) return { fx: x, fy: y + PH };
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
function fall(L, br, fx, fy, dir) {
|
|
||||||
let x = fx + dir * 3, y = fy - PH, vy = 0, t = 0;
|
|
||||||
if (solidAt(L, br, x, y, PW, PH, false)) return null;
|
|
||||||
while (t++ < 400) {
|
|
||||||
vy = Math.min(vy + GRAV, MAXFALL);
|
|
||||||
let rem = Math.abs(vy), landed = false;
|
|
||||||
while (rem > 0.0001) {
|
|
||||||
const m = Math.min(1, rem); const ny = y + m;
|
|
||||||
if (!solidAt(L, br, x, ny, PW, PH, true, y + PH)) { y = ny; rem -= m; }
|
|
||||||
else { landed = true; break; }
|
|
||||||
}
|
|
||||||
if (L.deadlyRect(x, y, PW, PH)) return null;
|
|
||||||
if (y > L.pxH + 120) return null;
|
|
||||||
if (landed) return { fx: x, fy: y + PH };
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = process.argv[2] || 'j6';
|
const id = process.argv[2] || 'j6';
|
||||||
const d = LEVELS.find(l => l.id === id);
|
const d = LEVELS.find(l => l.id === id);
|
||||||
const L = new Level(d);
|
const L = new Level(d);
|
||||||
const br = [];
|
|
||||||
for (let y = 0; y < L.h; y++) for (let x = 0; x < L.w; x++) if (L.tiles[y][x] === 6) L.tiles[y][x] = 1;
|
|
||||||
for (const m of L.movers) {
|
|
||||||
const xs = m.path.map(p => p.x).concat([m.x]), ys = m.path.map(p => p.y).concat([m.y]);
|
|
||||||
const x0 = Math.min(...xs), x1 = Math.max(...xs) + m.w, y0 = Math.min(...ys), y1 = Math.max(...ys) + m.h;
|
|
||||||
br.push({ x: x0, y: y0, w: x1 - x0, h: y1 - y0 });
|
|
||||||
}
|
|
||||||
let sx = L.spawn.x, sy = L.spawn.y + PH, start = null;
|
|
||||||
for (let k = 0; k < 400; k++) { if (canStand(L, br, sx, sy + k)) { start = { fx: sx, fy: sy + k }; break; } if (sy + k > L.pxH) break; }
|
|
||||||
const key = s => `${Math.round(s.fx / 6)},${Math.round(s.fy / 6)}`;
|
|
||||||
const seen = new Set([key(start)]); const q = [start]; let n = 0;
|
|
||||||
const byRow = new Map();
|
const byRow = new Map();
|
||||||
const note = s => {
|
const DJSET = (process.env.DJ || '0,6,12,18,24,30').split(',').map(Number);
|
||||||
const r = Math.round(s.fy / TILE);
|
const RELSET = (process.env.REL || '0,4,8,14').split(',').map(Number);
|
||||||
const cur = byRow.get(r) || { max: -1, min: 1e9 };
|
const r = reach(L, {
|
||||||
byRow.set(r, { max: Math.max(cur.max, s.fx), min: Math.min(cur.min, s.fx) });
|
DJSET, RELSET,
|
||||||
};
|
onState: s => {
|
||||||
note(start);
|
const row = Math.round(s.fy / TILE);
|
||||||
while (q.length && n < 40000) {
|
const cur = byRow.get(row) || { max: -1, min: 1e9 };
|
||||||
const s = q.shift(); n++;
|
byRow.set(row, { max: Math.max(cur.max, s.fx), min: Math.min(cur.min, s.fx) });
|
||||||
for (const d2 of [-HSPD, HSPD]) {
|
},
|
||||||
const nx = s.fx + d2;
|
});
|
||||||
if (canStand(L, br, nx, s.fy)) { const ns = { fx: nx, fy: s.fy }; const k = key(ns); if (!seen.has(k)) { seen.add(k); q.push(ns); note(ns); } }
|
|
||||||
}
|
console.log(`${id} ${d.name} ${L.w}x${L.h} 状态=${r.states}${r.err ? ' ERR:' + r.err : ''}`);
|
||||||
for (const d2 of [-1, 1]) { const r = fall(L, br, s.fx, s.fy, d2); if (r && canStand(L, br, r.fx, r.fy)) { const k = key(r); if (!seen.has(k)) { seen.add(k); q.push(r); note(r); } } }
|
[...byRow.keys()].sort((a, b) => b - a).forEach(row => {
|
||||||
for (const vx of [-HSPD, 0, HSPD]) for (const dj of [0, 6, 12, 18, 24, 30]) {
|
const v = byRow.get(row);
|
||||||
const r = jump(L, br, s.fx, s.fy, vx, dj, 0);
|
console.log(` row ${String(row).padStart(2)} (y=${row * TILE}): x ${(v.min / TILE).toFixed(1)} .. ${(v.max / TILE).toFixed(1)} 格`);
|
||||||
if (r && canStand(L, br, r.fx, r.fy)) { const k = key(r); if (!seen.has(k)) { seen.add(k); q.push(r); note(r); } }
|
|
||||||
if (vx !== 0) { const r2 = jump(L, br, s.fx, s.fy, vx, dj, 22); if (r2 && canStand(L, br, r2.fx, r2.fy)) { const k = key(r2); if (!seen.has(k)) { seen.add(k); q.push(r2); note(r2); } } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log(`${id} ${d.name} ${L.w}x${L.h} 可达站立行数=${byRow.size} 状态=${seen.size}`);
|
|
||||||
[...byRow.keys()].sort((a, b) => b - a).forEach(r => {
|
|
||||||
const v = byRow.get(r);
|
|
||||||
console.log(` row ${String(r).padStart(2)} (y=${r * TILE}): x ${(v.min / TILE).toFixed(1)} .. ${(v.max / TILE).toFixed(1)} 格`);
|
|
||||||
});
|
});
|
||||||
// 打印目标
|
|
||||||
for (const e of L.entities) if (e.type === 'goal' || e.type === 'save')
|
for (const e of L.entities) if (e.type === 'goal' || e.type === 'save')
|
||||||
console.log(` ${e.type}#${e.no ?? ''} at col ${e.tx}, row ${e.ty}`);
|
console.log(` ${e.type}#${e.no ?? ''} at col ${e.tx}, row ${e.ty}`);
|
||||||
+15
-185
@@ -1,195 +1,25 @@
|
|||||||
// 可达性求解器:用真实物理常量做 BFS,验证终点 / 存档点能否到达
|
// 可达性求解器:验证每关终点 / 存档点 / 苹果能否到达
|
||||||
const handlers = {};
|
// DJ=0,12,24 收紧二段跳时机;REL=0 禁用短跳 —— 检验是否只有"某一帧"能过
|
||||||
const addL = (t, fn) => { (handlers[t] = handlers[t] || []).push(fn); };
|
import { setupGlobals, reach, makeTargets } from './lib-reach.mjs';
|
||||||
globalThis.addEventListener = addL;
|
setupGlobals();
|
||||||
globalThis.window = { addEventListener: addL };
|
|
||||||
|
|
||||||
const { LEVELS } = await import('../js/levels/index.js');
|
const { LEVELS } = await import('../js/levels/index.js');
|
||||||
const { Level } = await import('../js/level.js');
|
|
||||||
|
|
||||||
// 可用 DJ=0,12,24 收紧二段跳时机选项,检验关卡是不是只有"某一帧"能过
|
|
||||||
const DJSET = (process.env.DJ || '0,6,12,18,24,30').split(',').map(Number);
|
const DJSET = (process.env.DJ || '0,6,12,18,24,30').split(',').map(Number);
|
||||||
const TILE = 32, PW = 14, PH = 18;
|
const RELSET = (process.env.REL || '0,4,8,14').split(',').map(Number);
|
||||||
const GRAV = 0.4, MAXFALL = 9, JUMP = 8.5, DJUMP = 7.6, HSPD = 3;
|
|
||||||
|
|
||||||
// ---- 碰撞(单向平台:只有下落且上一帧还在平台面之上才挡)----
|
|
||||||
function solidAt(L, br, x, y, w, h, down, prevBottom) {
|
|
||||||
const x0 = Math.floor(x / TILE), x1 = Math.floor((x + w - 0.001) / TILE);
|
|
||||||
const y0 = Math.floor(y / TILE), y1 = Math.floor((y + h - 0.001) / TILE);
|
|
||||||
for (let ty = y0; ty <= y1; ty++) {
|
|
||||||
for (let tx = x0; tx <= x1; tx++) {
|
|
||||||
const t = L.tileAt(tx, ty);
|
|
||||||
if (t === 7) {
|
|
||||||
if (down && prevBottom !== undefined && prevBottom <= ty * TILE + 6) return true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (L.isSolidTile(t, tx, ty)) return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const b of br)
|
|
||||||
if (x < b.x + b.w && x + w > b.x && y < b.y + b.h && y + h > b.y) return true;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
/** 站立位置 (fx,fy)=脚底中心左端;返回是否合法 */
|
|
||||||
function canStand(L, br, fx, fy) {
|
|
||||||
if (fx < 0 || fx + PW > L.pxW) return false;
|
|
||||||
if (solidAt(L, br, fx, fy - PH, PW, PH, false)) return false;
|
|
||||||
if (!solidAt(L, br, fx, fy - PH + 1, PW, PH, true, fy - 1)) return false;
|
|
||||||
if (L.deadlyRect(fx, fy - PH, PW, PH)) return false;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 模拟一次跳跃:返回 {fx,fy} 落点 或 null;途中记录是否碰到目标 */
|
|
||||||
function jump(L, br, fx, fy, vx0, djump, flip, targets) {
|
|
||||||
let x = fx, y = fy - PH;
|
|
||||||
let vy = -JUMP, vx = vx0, t = 0, used = false;
|
|
||||||
while (t++ < 240) {
|
|
||||||
if (djump && !used && t === djump) { vy = -DJUMP; used = true; }
|
|
||||||
if (flip && t === flip) vx = -vx;
|
|
||||||
vy = Math.min(vy + GRAV, MAXFALL);
|
|
||||||
|
|
||||||
if (vx !== 0) {
|
|
||||||
const nx = x + vx;
|
|
||||||
if (!solidAt(L, br, nx, y, PW, PH, false)) x = nx; else vx = 0;
|
|
||||||
}
|
|
||||||
const prevBottom = y + PH;
|
|
||||||
let rem = Math.abs(vy), dy = Math.sign(vy), landed = false;
|
|
||||||
while (rem > 0.0001) {
|
|
||||||
const m = Math.min(1, rem);
|
|
||||||
const ny = y + dy * m;
|
|
||||||
if (!solidAt(L, br, x, ny, PW, PH, dy > 0, prevBottom)) { y = ny; rem -= m; }
|
|
||||||
else { if (dy > 0) landed = true; vy = 0; break; }
|
|
||||||
}
|
|
||||||
if (L.deadlyRect(x, y, PW, PH)) return null;
|
|
||||||
if (targets) {
|
|
||||||
const box = { x, y, w: PW, h: PH };
|
|
||||||
for (const tg of targets) if (!tg.hit && overlap(box, tg.rect)) tg.hit = true;
|
|
||||||
}
|
|
||||||
if (y > L.pxH + 120 || y < -240) return null;
|
|
||||||
if (landed) return { fx: x, fy: y + PH };
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 走下平台:原地自由落体 */
|
|
||||||
function fall(L, br, fx, fy, dir, targets) {
|
|
||||||
let x = fx + dir * 3, y = fy - PH, vy = 0, t = 0;
|
|
||||||
if (solidAt(L, br, x, y, PW, PH, false)) return null;
|
|
||||||
while (t++ < 400) {
|
|
||||||
vy = Math.min(vy + GRAV, MAXFALL);
|
|
||||||
const prevBottom = y + PH;
|
|
||||||
let rem = Math.abs(vy), landed = false;
|
|
||||||
while (rem > 0.0001) {
|
|
||||||
const m = Math.min(1, rem);
|
|
||||||
const ny = y + m;
|
|
||||||
if (!solidAt(L, br, x, ny, PW, PH, true, prevBottom)) { y = ny; rem -= m; }
|
|
||||||
else { landed = true; break; }
|
|
||||||
}
|
|
||||||
if (L.deadlyRect(x, y, PW, PH)) return null;
|
|
||||||
if (targets) {
|
|
||||||
const box = { x, y, w: PW, h: PH };
|
|
||||||
for (const tg of targets) if (!tg.hit && overlap(box, tg.rect)) tg.hit = true;
|
|
||||||
}
|
|
||||||
if (y > L.pxH + 120) return null;
|
|
||||||
if (landed) return { fx: x, fy: y + PH };
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function solve(d) {
|
|
||||||
const L = new Level(d);
|
|
||||||
const br = [];
|
|
||||||
// 水:视为可站立的表面(游泳近似)
|
|
||||||
for (let y = 0; y < L.h; y++)
|
|
||||||
for (let x = 0; x < L.w; x++)
|
|
||||||
if (L.tiles[y][x] === 6) L.tiles[y][x] = 1;
|
|
||||||
// 移动平台:整条路径当作桥(乐观)
|
|
||||||
for (const m of L.movers) {
|
|
||||||
const xs = m.path.map(p => p.x).concat([m.x]);
|
|
||||||
const ys = m.path.map(p => p.y).concat([m.y]);
|
|
||||||
const x0 = Math.min(...xs), x1 = Math.max(...xs) + m.w;
|
|
||||||
const y0 = Math.min(...ys), y1 = Math.max(...ys) + m.h;
|
|
||||||
br.push({ x: x0, y: y0, w: x1 - x0, h: y1 - y0 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const targets = [];
|
|
||||||
for (const e of L.entities) {
|
|
||||||
if (e.type === 'goal' || e.type === 'save' || e.type === 'apple')
|
|
||||||
targets.push({ type: e.type, idx: e.idx, rect: { x: e.x, y: e.y, w: e.w, h: e.h }, hit: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 起点:从出生点往下找落脚点
|
|
||||||
let sx = L.spawn.x, sy = L.spawn.y + PH;
|
|
||||||
let start = null;
|
|
||||||
for (let k = 0; k < 400; k++) {
|
|
||||||
if (canStand(L, br, sx, sy + k)) { start = { fx: sx, fy: sy + k }; break; }
|
|
||||||
if (sy + k > L.pxH) break;
|
|
||||||
}
|
|
||||||
if (!start) return { err: '出生点下方 400px 内找不到落脚点', targets };
|
|
||||||
|
|
||||||
const key = s => `${Math.round(s.fx / 6)},${Math.round(s.fy / 6)}`;
|
|
||||||
const seen = new Set([key(start)]);
|
|
||||||
const q = [start];
|
|
||||||
let n = 0, steps = 0;
|
|
||||||
|
|
||||||
// 起跳时就检查一次(原地也算到过)
|
|
||||||
while (q.length && n < 40000) {
|
|
||||||
const s = q.shift(); n++;
|
|
||||||
const box = { x: s.fx, y: s.fy - PH, w: PW, h: PH };
|
|
||||||
for (const tg of targets) if (!tg.hit && overlap(box, tg.rect)) tg.hit = true;
|
|
||||||
|
|
||||||
// 走路
|
|
||||||
for (const d2 of [-HSPD, HSPD]) {
|
|
||||||
const nx = s.fx + d2;
|
|
||||||
if (canStand(L, br, nx, s.fy)) {
|
|
||||||
const ns = { fx: nx, fy: s.fy };
|
|
||||||
const k = key(ns);
|
|
||||||
if (!seen.has(k)) { seen.add(k); q.push(ns); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 走下悬崖
|
|
||||||
for (const d2 of [-1, 1]) {
|
|
||||||
const r = fall(L, br, s.fx, s.fy, d2, targets);
|
|
||||||
steps++;
|
|
||||||
if (r && canStand(L, br, r.fx, r.fy)) {
|
|
||||||
const k = key(r);
|
|
||||||
if (!seen.has(k)) { seen.add(k); q.push(r); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 跳跃(含二段跳时机 / 空中转向)
|
|
||||||
for (const vx of [-HSPD, 0, HSPD]) {
|
|
||||||
for (const dj of DJSET) {
|
|
||||||
const r = jump(L, br, s.fx, s.fy, vx, dj, 0, targets);
|
|
||||||
steps++;
|
|
||||||
if (r && canStand(L, br, r.fx, r.fy)) {
|
|
||||||
const k = key(r);
|
|
||||||
if (!seen.has(k)) { seen.add(k); q.push(r); }
|
|
||||||
}
|
|
||||||
if (vx !== 0) {
|
|
||||||
const r2 = jump(L, br, s.fx, s.fy, vx, dj, 22, targets);
|
|
||||||
steps++;
|
|
||||||
if (r2 && canStand(L, br, r2.fx, r2.fy)) {
|
|
||||||
const k = key(r2);
|
|
||||||
if (!seen.has(k)) { seen.add(k); q.push(r2); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { states: seen.size, steps, targets };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------- 运行 ----------------
|
|
||||||
let fail = 0;
|
let fail = 0;
|
||||||
console.log('=== 可达性求解 ===');
|
console.log(`=== 可达性求解 (DJ=${DJSET} REL=${RELSET}) ===`);
|
||||||
for (const d of LEVELS) {
|
for (const d of LEVELS) {
|
||||||
if (d.type === 'boss') { console.log(` ${d.id.padEnd(3)} ${d.name.padEnd(17)} (BOSS 关跳过)`); continue; }
|
if (d.type === 'boss') { console.log(` ${d.id.padEnd(3)} ${d.name.padEnd(17)} (BOSS 关跳过)`); continue; }
|
||||||
let r;
|
let r;
|
||||||
try { r = solve(d); }
|
try {
|
||||||
catch (e) { console.log(` ✗ ${d.id} 求解异常: ${e.message}`); fail++; continue; }
|
const { Level } = await import('../js/level.js');
|
||||||
|
const L = new Level(d);
|
||||||
|
const targets = makeTargets(L);
|
||||||
|
r = reach(L, { DJSET, RELSET, targets });
|
||||||
|
r.targets = targets;
|
||||||
|
} catch (e) { console.log(` ✗ ${d.id} 求解异常: ${e.message}`); fail++; continue; }
|
||||||
if (r.err) { console.log(` ✗ ${d.id.padEnd(3)} ${d.name.padEnd(17)} ${r.err}`); fail++; continue; }
|
if (r.err) { console.log(` ✗ ${d.id.padEnd(3)} ${d.name.padEnd(17)} ${r.err}`); fail++; continue; }
|
||||||
|
|
||||||
const goal = r.targets.find(t => t.type === 'goal');
|
const goal = r.targets.find(t => t.type === 'goal');
|
||||||
@@ -197,11 +27,11 @@ for (const d of LEVELS) {
|
|||||||
const apples = r.targets.filter(t => t.type === 'apple');
|
const apples = r.targets.filter(t => t.type === 'apple');
|
||||||
const okSave = saves.filter(s => s.hit).length;
|
const okSave = saves.filter(s => s.hit).length;
|
||||||
const okApple = apples.filter(a => a.hit).length;
|
const okApple = apples.filter(a => a.hit).length;
|
||||||
const badSave = saves.filter(s => !s.hit).map(s => `#${s.idx}`);
|
const badSave = saves.filter(s => !s.hit).map(s => `#${s.no ?? s.idx}`);
|
||||||
const flag = goal && goal.hit ? ' ' : '✗';
|
const flag = goal && goal.hit ? ' ' : '✗';
|
||||||
if (!goal || !goal.hit) fail++;
|
if (!goal || !goal.hit) fail++;
|
||||||
if (badSave.length) fail++;
|
if (badSave.length) fail++;
|
||||||
console.log(` ${flag} ${d.id.padEnd(3)} ${d.name.padEnd(17)} 终点=${goal ? (goal.hit ? 'OK' : '不可达') : '无'} 存档=${okSave}/${saves.length}${badSave.length ? ' 缺:' + badSave.join(',') : ''}${apples.length ? ` 苹果=${okApple}/${apples.length}` : ''} (状态${r.states} 模拟${r.steps})`);
|
console.log(` ${flag} ${d.id.padEnd(3)} ${d.name.padEnd(17)} 终点=${goal ? (goal.hit ? 'OK' : '不可达') : '无'} 存档=${okSave}/${saves.length}${badSave.length ? ' 缺:' + badSave.join(',') : ''}${apples.length ? ` 苹果=${okApple}/${apples.length}` : ''} (状态${r.states})`);
|
||||||
}
|
}
|
||||||
console.log(fail ? `\n❌ ${fail} 项不可达` : '\n✅ 所有关卡终点与存档点均可达');
|
console.log(fail ? `\n❌ ${fail} 项不可达` : '\n✅ 所有关卡终点与存档点均可达');
|
||||||
process.exit(fail ? 1 : 0);
|
process.exit(fail ? 1 : 0);
|
||||||
Reference in new issue
Block a user