fix: 优化跳跃和下落手感

This commit is contained in:
cancerai committed 2026-10-24 01:54:39 +08:00
1 parent ada64fe4be
commit afe2f64b9b
25 files changed
+1197 -188

No files matched your search

+117
View File
@@ -22,3 +22,120 @@
- 根因:`js/ui.js` 的 `SCREENS` 数组没加 `'scr-settings'`,`UI.show('scr-settings')` 会把所有已知屏 hidden、却从不取消设置屏的 hidden——只剩画布标题背景(用户截图即此症状)。 - 根因:`js/ui.js` 的 `SCREENS` 数组没加 `'scr-settings'`,`UI.show('scr-settings')` 会把所有已知屏 hidden、却从不取消设置屏的 hidden——只剩画布标题背景(用户截图即此症状)。
- 修复:SCREENS 加入 `scr-settings`(一行)。教训:**新增 screen 必须同步 SCREENS 数组**。 - 修复:SCREENS 加入 `scr-settings`(一行)。教训:**新增 screen 必须同步 SCREENS 数组**。
- 回归:`tools/settings-test.mjs` 增加「SCREENS 含 scr-settings / 标题有设置入口」两条源码断言,防复发;settings-test 与 dom-smoke 全绿。 - 回归:`tools/settings-test.mjs` 增加「SCREENS 含 scr-settings / 标题有设置入口」两条源码断言,防复发;settings-test 与 dom-smoke 全绿。
## 设置界面:确定返回上一页 + PC/移动端分设
- 需求:① 点「确定」后返回进入设置前的界面(上一页);② 电脑端设置「键盘映射」,移动端设置「手机操作按钮」(按设备二选一显示)。
- `js/input.js`:新增 `DEFAULT_BIND`(出厂 BIND 快照)、`export defaultBindings()`(深拷贝)、`export applyBindings(map)`(用 `{action:[code]}` 覆盖生效键位)。
- `js/settings.js`:
- 新增 `KEY_ACTIONS`(left/right/up/down/jump/shoot/suicide/restart/pause 共 9 项可重绑)、`keyLabel(code)`(←/→/Shift/空格等可读名)。
- `pcMode = !isTouch`(由 `Settings.init(useTouch)` 传入);`enter()` 末尾按 `pcMode` 切换 `#set-keys-row`/`#set-touch-row` 的 hidden:PC 显键盘映射、移动端显手柄按钮。
- `renderKeys()` 渲染每行「动作名 + 按键 chip(点×删除)+ +添加」;`wireKeys()` 点击委托 + `document` keydown 捕获下一次按键写入 `work.keys`(Esc 取消),同一动作支持多键。
- `confirm()` 保存用户名/touch/keys 并 `applyBindings(s.keys)`;`reset()` 把 keys 也回出厂;`makeDefaults()`/`meta()` 保证 `keys` 字段齐全。
- `wireKeys()` 加 `keysWired` 幂等保护(重复 init 不会重复绑监听)。
- `js/main.js`:模块级 `let settingsFrom='scr-title'`;`settings` action 里 `settingsFrom = UI.cur` 后 `Settings.enter()`;`set-ok` 改为 `Settings.confirm(); UI.show(settingsFrom)`(确定后回上一页);`Settings.init(useTouch)` 传入设备检测结果。
- `index.html`:设置屏新增 `#set-keys`(键盘映射容器)+ 两行 `#set-keys-row`/`#set-touch-row` 供切换显隐。
- CSS:`.set-keys/.krow/.kact/.kkeys/.kchip/.krm/.kadd/.klisten` 等键盘映射样式(`blink` 动画提示监听中)。
- 验证:`tools/settings-test.mjs` 扩到 30 断言(input API / PC·移动端面板显隐切换 / 点击 chip→keydown 重绑 / 添加 / 删除 / 确定落盘 / reset 回出厂 / main 里 set-ok 返回上一页源码断言)全过;dom-smoke、check、smoke、map、save、key、ice、solve 全绿;预览 8126 返回 200 且含 set-keys/set-keys-row/set-touch-row。
## 设置·键盘重绑加冲突检测(用户要求)
- 需求:电脑端在设置里改键时,要能「检测冲突」。
- `js/settings.js`:
- 新增 `let conflict = null`(warning 状态)与 `conflictInfo(code, act, idx)`:遍历 `KEY_ACTIONS` 的 `work.keys`,若 `code` 已被**其它**动作(或同动作不同槽)占用则返回该动作标签;并额外检查不可重绑的系统动作 `BIND.menu`(如 KeyQ→「菜单」),避免把键绑到系统功能上。
- `onCaptureKey()`:捕获到非 Esc 的键后先查冲突;若冲突则**拒绝写入**、置 `conflict={code,label,act}` 并 `renderKeys()`(停留在监听态,等换键或 Esc);无冲突才写入并清 `conflict`。
- `renderKeys()`:面板顶部加 `.kwarb` 红色横幅「「X」已被【Y】占用,请换一个键(Esc 取消)」;监听行 tip 在冲突时变红显示「该键冲突,请换一个键」。
- `enter()/reset()` 重置 `conflict=null`;`Settings` 暴露 `_conflict()/_listening()` 供测试/调试。
- `js/input.js`:`const BIND` → `export const BIND`(供冲突检测引用 `menu` 键)。
- CSS:`.kwarb`(红底警示横幅)、`.klisten.bad`(红字警告)。
- 验证:`tools/settings-test.mjs` 扩到 36 断言,新增「冲突:jump 不会绑到被左移占用的 KeyA / 记录到被左移占用 / 顶部警告横幅 / 改用空闲键 KeyK 成功 / 同动作重复键不重复写入 / Esc 退出无残留 / KeyQ 被系统菜单占用」全过;dom-smoke、key、smoke、check、map、save、ice、solve 全绿。
## 刺改为「单三角形 + 碰撞与形状完全对应」(用户纠正)
- 用户纠正:不要 3 个小齿刺,要**单个三角形占一格**,且**碰撞必须和三角形形状完全对应**(不是矩形近似)。
- `js/sprites.js` `drawSpike`:由「3 个小三角」改为**一个占满整格的三角**(尖端朝 dir,底边贴相反侧),并沿底边画一条暗色基座(正好落在三角底边,不扩大判定)。`TILE=32`,故每刺格 32×32。
- `js/level.js`:删除 `spikeBox`(旧「收窄矩形近似」),新增 `spikeTri(t,x,y)`(顶点与 `drawSpike` 完全一致)与 `triRectOverlap`(分离轴定理,凸三角×AABB 充要条件)。`deadlyRect` 改用 `spikeTri`+`triRectOverlap`(网格刺 + dropspike 都用真三角;dropspike 用 `SPIKE_L` 与其 `drawDropSpike` 的 dir=2 一致)。
- 关键性质:底宽尖窄——底部两角现在**会致死**(旧矩形近似把它们当安全),尖顶外侧安全,与真实三角形状一致。
- 一致性利好:game 的 `player.js`、`tools/lib-reach.mjs`(求解器)、`save-diag.mjs`、`bot.mjs` 全部直接调用 `L.deadlyRect`,改碰撞后自然统一,无需另改求解器模型。
- 验证:新增 `tools/spike-test.mjs`(16 断言:spikeTri 四向顶点 / triRectOverlap 内-外-AABB / deadlyRect 集成上刺尖顶安全·底中致死·底左角致死·空地安全 / 与旧矩形近似在底左角结论相反的精确性对比)全过;`solve.mjs` 仍「所有关卡终点与存档点均可达」;editor-wysiwyg、dom-smoke、check、smoke、map、save、key、ice、settings 九套全绿。
## 编辑器所见即所得(WYSIWYG)+ 刺显示真实三角形
- 需求:编辑器里所有方块要显示游戏内真实样子(原来用「彩色块 + 字母/箭头」表示);刺要显示成三角形(游戏里 `drawSpike` 本来就是三角,编辑器之前却画成 `↑↓←→` 箭头字符)。
- `js/editor.js`:
- `import * as S from './sprites.js'`;新增 `drawTileReal(ctx, ch, px, py, props, t)` 把每个字符分派到 `S.drawX`(`#/@/=/M`→drawBlock 各 variant、`%/~/-\/V/^/*/D/X/G/P/F/J/W/T/O/C` 各对应函数,`^` 按 `props.dir` 转 0/1/2/3 画三角,`S` 出生点画小人 `drawKid`);`B`/`!` 等无独立美术的保留「色块+字母」兜底 `drawMarker`。
- `draw()` 重写:每格调用 `drawTileReal` 替代「填色块+画字母」;保留淡网格线、选中高亮;传送门额外叠加 `W{id}` 与连接状态(编辑器元数据)。
- `buildPalette()`:`?` 工具用 ⌖ 字形,其余每个方块用 `drawTileReal` 渲染成 32×32 缩略 canvas(`class="pal-cv"`),调色板即真实预览。
- `PALETTE` 改 `export const`(便于测试);删除不再使用的 `SPIKE_ARROW`。
- CSS:`.pal-cv{width:26px;height:26px;...image-rendering:pixelated}`。
- 测试:新增 `tools/editor-wysiwyg-test.mjs`(init 渲染全部调色板方块无异常 / 刺以三角形图元绘制 lineTo=6 / 方块真实纹理 fillRect / 23 种方块在画布绘制不抛异常);dom-smoke(init 跑 buildPalette 覆盖全部 25 字符分发)、check/smoke/map/save/key/ice/solve 全绿。
## 设置·键盘重绑面板移除「下蹲」(用户要求)
- 需求:把「按键设置」里的下蹲(down)项去掉。
- `js/settings.js`:`KEY_ACTIONS` 删除 `['down','下(下蹲)']`(由 9 项变 8 项)。仅影响键盘重绑面板与冲突检测范围;游戏内 `down` 逻辑(`player.js` 下蹲/下穿)与移动端触屏 `down` 按钮(`BUTTONS`/`touch.js`/`index.html` 的 `data-touch="down"`)保持不变——用户只要求移除「按键设置」里的项。
- 测试:`tools/settings-test.mjs` 的跳跃行索引 4→3(down 移除后 jump 成为第 4 项),36 断言仍全过;dom-smoke、key、check、solve 全绿。
## 刺外观对齐参考图(冰蓝白渐变大三角)
- 用户反馈「这也不是三角形呀」并给参考图:经典 I wanna 的淡蓝白渐变单三角。此前刺是灰色 `#c8c8d8` + 深色基座条,观感不像三角。
- `js/sprites.js` `drawSpike` 重写外观:顶点不变(仍与 level.js `spikeTri` 完全一致,碰撞不动),改为冰蓝白线性渐变(尖端 `#f4f9fd` → 底边 `#a8bccf`,沿刺方向渐变)+ 淡蓝描边 `rgba(150,175,200,.9)` + 尖端附近白色高光片;删除深色基座条。
- 删除已无引用的 `tri()` 助手;`C.spike/spikeDark` 更新为淡蓝(`#cfdde9`/`#a8bccf`)。
- 新增 `spike-preview.html` 预览页:用真实 drawSpike/drawBlock 渲染地面刺/天花板刺/贴墙刺,供用户对照参考图。
- 测试:spike-test(16 断言)、editor-wysiwyg、dom-smoke、solve(23 关可达)及全部回归绿。
- 修复:上一轮 drawSpike 的「尖端高光」坐标计算错误(ax + (bx+cx)/2 把顶点当标量相加,多边形飞出格子),游戏中刺后面拉出灰色长条。已整体删除高光,只留冰蓝白渐变三角 + 淡蓝描边(与参考图一致)。全部回归绿。
## 冰面提速 + 删除首页操作说明 + 物理手感下调(用户要求三连)
- **冰面最大速度阈值调大**:`js/player.js` 新增 `PHY.ICE_TOP: 1.6`(原内联 `HSPD*1.35`),冰面/滑行惯性速度上限 = HSPD×1.6;`ICE_MAX` 局部变量改由 `PHY.ICE_TOP` 计算。ice-test 注释数字同步为 4.32。
- **删除首页操作说明**:`index.html` 去掉 scr-title 的「操作说明」按钮和整个 `#scr-help` 屏;`js/main.js` 删 `help` action;`js/ui.js` 的 `SCREENS` 删 `'scr-help'`;`css/style.css` 删 `.help-*` 规则。
- **物理手感下调(用户:「kid移动速度减慢点,跳跃重力小一点」)**:`js/player.js` 与 `tools/lib-reach.mjs` 同步 GRAV 0.4→0.34、HSPD 3→2.7。扫描确认仅 j1/j9 终点不可达,按「调手感+重调关卡」方案修图:
- j1 回廊:`carpet(m, 81, R-11, 15, [0, 8])` —— x89 加缺口。原因:新物理下从 x88 跳台落进 x90 时落点偏右(fx≈2895),满跳第 3 帧就蹭 x91 的「>」刺尖;有 x89 就能走进 x90 从左侧起跳越过对刺门。
- j9 L0:`carpet(m, 5, 27, 116, [54])` —— x59 加缺口。原因:矮洞天花板(row 24, x60-75)挡住 x57→x61 四格跳的横向漂移,玩家撞顶后下坠砸原点刺;拆成 57→59→61 两个 2 格跳(短跳 apex≈43px,碰不到洞顶)。
- j9 L1:`carpet(m, 9, 13, 108, [27])` —— x36 加缺口。原因:hgap(36,13) 的通道列 36 被 carpet 填了 '^',整门是 3 连刺,从右往左回跳被 x37 的「<」刺别住;补缺口后恢复两段式穿越。
- 全部回归绿:solve(23 关终点+存档+苹果全可达)、check/map/smoke/dom-smoke/save/key/spike/settings/editor-wysiwyg/ice/death/jumpdist/save-diag/bot 共 15 项。
- 教训(已入 MEMORY.md):① 4 格缺口跳在新物理下能跳但起拍位必须够靠左,跳台落点偏右是隐形杀手;② 实心天花板会挡高跳的横向漂移;③ hgap 通道列必须本身为空,carpet 缺口没对齐就会留一根 '^' 把门变成 3 连刺。
## 编辑器:去掉「查看属性」工具,改为任意工具点击即看属性(用户要求)
- `js/editor.js`:`PALETTE` 删除 `['?', '查看属性', ...]`(调色板 25→24 项,全是真实方块);`buildPalette()` 删掉 '?' 的放大镜字形特例;`act()` 删掉 `sel === '?'` 分支——现在所有点击都先 `selTile = {tx,ty}` 再 `renderProps()`,配合原有「放置不覆盖」规则,拿任何非橡皮方块点击已有格子都只查看不覆盖;空格子则放置新块并显示其属性;橡皮擦与右键保持擦除语义。
- 新增回归 `tools/editor-pick-test.mjs`(18 断言):假 DOM 的 addEventListener 真实收句柄,可模拟画布 mousedown;验证 PALETTE/UI 无 '?'、拿 ^/#/% 点 C/T 只查看不覆盖且面板渲染对应字段、拿 O 点空格放置并显属性、橡皮/右键擦除、空态引导文案。注意 renderProps 的字段是 appendChild 子行,断言要拼 `box.innerHTML + children.map(c=>c.innerHTML)`。
- `tools/editor-wysiwyg-test.mjs` 清理 '?' 死过滤。
- 全套 16 项回归绿(solve 23 关可达不受影响,本次只改编辑器交互)。
## 重力再降一档 + 去掉跳跃时 kid 身体晃动(用户要求)
- **GRAV 0.34 → 0.33**(`js/player.js` PHY + `tools/lib-reach.mjs` 同步)。"稍微调低一点点":单跳 apex≈109px、滞空更久。扫参发现 0.32 断 j2、0.33 断 j6(非单调——6px 状态桶对齐决定窄窗存不存在),选最小步长 0.33。
- **j6 修图**:`m.set(44, 15, '.')` 清掉 hgap(43,15) 的 '<' 刺。根因:该对刺门原本只靠「tile45.3(fx≥1448)满跳→tile41.3」这条 2px 宽起拍窗通过;'<' 刺尖会别住起跳(fx<1448 第 3 帧即死),0.33 下 hop 落点整体左移 2px 窗口够不着。清掉后留 ^(43)+>(42) 双刺门,整格起拍窗 + 左右两个方向都稳。修后 j6 状态数 535→934。
- **去掉跳跃晃动**:删 `player.js` 起跳/二段的 `squash=1.28/1.22` 拉伸;`sprites.js` 的 `drawKid` 改 `const sq = air ? 1 : p.squash`——空中恒定原始体型,落地挤压(0.72)残余也不得带入空中。落地挤压保留(在地面,不属"跳跃时")。
- 教训(已入 MEMORY.md):① 对刺门的侧刺('<'/'>')会把起跳/落点窗切成 2px 级窄窗,求解器 6px 状态桶的对齐就能翻转整关可解性——改物理后遇到"某一跳全灭",先查是不是侧刺别住起拍;② 追这类问题用逐帧 trace(复刻 sim.jump 主循环打印每帧 x/y/vy + deadlyRect),比手算几何可靠得多。
- 16 项回归全绿;solve 23 关终点+存档+苹果全可达。
## JUMP 8.5→8.0 降低跳跃高度(用户:「跳跃的高度太高了,调低点」)
- `js/player.js` PHY + `tools/lib-reach.mjs` 同步:JUMP 8.5→8.0。单跳 apex 109.5→97px、水平 139→131px。只降 JUMP 不动 GRAV——保留用户之前要的「小重力飘」手感。
- 扫参定位只破坏 j8(存档#4/#5+终点不可达)。逐帧 trace 实证:j8 x83-84 台(row18)→x88-89 台(row16) 这一跳的航线正对 vgap(86,14);`spikeTri` 的 '^' 顶点在格顶 y=480(无收紧),JUMP 8.0 单跳 apex 盒底 479——只差 1px,顶点 (2768,480) 在起跳后 t=21~25 共 4 帧落在玩家盒内,SAT 铁死。JUMP 8.5 时 apex 盒底 466 从容擦过。
- 修图(j8 两处,其余 22 关零改动):
- `vgap(m, 86, R-8)` → `m.set(86, R-9, 'v')`:去掉 x86 地刺 '^'、保留压顶刺 'v'。试过「x88-89 台加宽到 x87-89」无效(上升段照样死,落点加宽治标不治本)。
- x87 加 '-' 台(配合上面修完后的落点 2802 能上台)。
- 修后 j8 状态数 307→469,23 关终点+存档+苹果全可达。
- 16 项回归全绿。教训已入 MEMORY.md:① apex 盒底与航线 '^' 顶点差 1px 时顶点会连续 4 帧在盒内,改 JUMP/GRAV 后遇到「某一跳全灭」先算 apex 盒底对比航线刺顶点;② 逐帧 trace 复刻 sim.jump 主循环是定位这类问题的唯一可靠手段(手算 SAT 截面连错三次)。
## JUMP 再降一档 + 下落分手重力 + 零形变(用户三连要求)+ 求解器加固(本次会话主体)
用户原话:「跳跃的高度太高了,调低点」→「跳跃高度再调低一点,下落时的重力加速度再明显点,而且kid落地时候依然有拉伸效果,还是不行」。
**物理落地**(`js/player.js` + `tools/lib-reach.mjs` 同步):
- JUMP 8.0→7.5:单跳 apex 97→85px、水平 131→118.8px(jumpdist 实测)。
- 新增 `GRAV_FALL: 0.55`:上升 0.33 轻、下落 0.55 重(分手重力)。落地前一帧竖直速度 8.9px/帧。
- **squash 形变全部删除**:`player.js` 去掉 squash 字段/起跳赋值/落地挤压逻辑;`sprites.js` 的 `drawKid` 按原始 11×21 画;`editor.js` 预览桩同步删字段。用户两次强调落地拉伸也不要。
**求解器三个加固**(`tools/lib-reach.mjs`,solve/reach-diag 默认值同步):
- RELSET [0,4,8,14] → [0,4,5,6,8,14]:vgap 夹缝需要"第 5/6 帧松键"的短跳(apex 32~43px 窗口)。实证:rel=4 t=7 盒右沿蹭地刺左坡 2.6px;rel=6 t=11 盒顶擦垂刺尖 0.6px;只有 rel=5 两边都躲开(j2 x16);j7 顶层 x52 夹缝则要 rel=6。
- **fy 规范化(canon)**:落点常比地面高零点几 px(1535.5 vs 1536),与精确落点同 3px 桶——先占桶者赢,走链被错位几 px 的跳链顶掉。加 rel=6 后 j2 状态数从 1572 暴跌到 80 就是这么来的。push() 里按 canStand 门控吸附到 32 倍数。
- 状态桶 3px(上轮已改)。
**关卡重调**(`js/levels/jump.js`,核心矛盾:JUMP 7.5 + GRAV_FALL 0.55 下 3 连刺=96px 的起拍窗只有 0~1 帧,实测不可玩):
- **carpet 缺口间距 4→3**(3 连刺→2 连刺=64px,射程余量 13→55px):一行改动全局生效。
- j2 五处显式 gaps(井口/左端出口的 3 连刺必须拆,walk 限制使起拍位比所需位窄 1~2px):
- L0:`carpet(m, 5, 47, 50, [1, 18])` 保持(x25-27 已被间距 3 拆成 2 连刺)。
- L2:`[1, 11, 31]` = x12 拆井口 3 连刺;x22/x42 补 hgap 夹缝通道列(原来被 carpet 填 '^',整门变 3 连刺且左侧落点是 4 格跳 128px 超射程)。
- L3:`[0]` = x10 拆左端 3 连刺(左行跳落点盒右沿差 0.8px 蹭 x10 左坡;L1 同位置能过是因 vgap(12) 清空了 x10-14,L3 没有)。
- L4:`[1, 41]` = x12 拆井口 3 连刺;x52 补 hgap 夹缝通道列。
- 修后 j2 状态数 80→2421(10/10 存档+终点),23 关全可达。
**验证**:solve 23 关终点+存档+苹果全可达;check/map/smoke/dom-smoke/save/key/spike/settings/editor-wysiwyg/editor-pick/ice/death/jumpdist/save-diag/bot 共 15 项全绿。
## GRAV 0.33→0.29 上升再飘一档(用户:「跳跃的重力参数再飘点」)
- `js/player.js` PHY + `tools/lib-reach.mjs` 同步:GRAV 0.33→0.29(仅上升段,GRAV_FALL 0.55 不变)。单跳 apex 85→91px、水平 118.8→122px。
- 本轮零修图:23 关全可达(状态数与上轮持平微增)。原因:上轮已把 carpet 缺口间距改 3 格(2 连刺=64px),射程余量充裕,上升更飘不触任何窄窗。
- 16 项回归全绿。至此手感链:移速 2.7 / 上升重力 0.29 / 下落重力 0.55 / JUMP 7.5。
+73
View File
@@ -0,0 +1,73 @@
# 2026-10-24
## HSPD 2.7→2.6 走路再慢一点点(用户:「kid走路速度再稍微慢一点点」)
- `js/player.js` PHY + `tools/lib-reach.mjs` 同步:HSPD 2.7→2.6。扫参:2.5 断 j7+j10 两关,2.6 只断 j10 一个存档,选 2.6。
- **j10 修图**:L3 钢丝区 x55 加过渡柱 `m.vline(55, 5, 7, '#')`。根因(逐帧 trace + 组合探针实证):x58→x52 是 6 格=192px,2.7 下唯一生路是"晚二段 dj=30"(先走完单跳全程再二段,滞空 83 帧水平 224px)落 tile 52.94;2.6 下同一组合水平少 8px,轨迹到达柱区时已掉过柱顶 8.7px,盒左沿蹭柱右壁后摔进 x53 刺毯。dj=12 的轨迹在 3 格处 feet 恰回 160、4 格处 feet 恰回 192,故 x55 拆成 3+3 格两跳,两跳都精确接住。save/终点/steps 数组全未动。
- 验证:solve 23 关终点+存档+苹果全可达(j10 11/11,状态 3796→4003);16 项回归全绿。
- 教训(已入 MEMORY.md):GRAV_FALL 0.55 下"高等抛物线接远处柱顶"类路线全部失效(落地高度时水平只飞 113~130px,够不到 160px+ 柱距);HSPD 每降 0.1 让晚二段落点整体平移 8px 级,钢丝柱/跳台类窄窗关卡首当其冲——修法优先"拆间距加过渡柱"而非加宽(轨迹低于柱顶时加宽接不住)。
## GRAV_FALL 0.55→0.40 下落变飘(用户:「下落速度还是不够飘」)
- 注意这是用户对下落的**反向修正**:早些时候要求「下落重力加速度再明显点」(当时 0.33→分出 0.55),现在要飘,落 0.40。跳跃高度/JUMP/GRAV 均不动。
- `js/player.js` PHY + `tools/lib-reach.mjs` 同步。滞空变久:二段跳射程 132.6~240px(原 120~231px)。
- 破坏 j6(缺 5 存档+终点)、j9(缺 1 存档 1 苹果),断点都是**从右往左过 hgap 对刺门**(j6 @x95、j9 @x68):GRAV_FALL 0.40 让满跳轨迹在门区下降变慢,正好嵌进通道列里留的 '^'(carpet 缺口间距 3 对齐不到门列)。trace 实证 t=43 盒底 416.1 已擦 x69 '>' 刺左缘、t=45 嵌入 x68 '^' 顶点区。
- 修图(两处 gaps,纯补通道列净空,不动对刺):j6 L1 `carpet(m,9,15,112,[86])`;j9 L1 `carpet(m,9,13,108,[27,59])`。修后 23 关全可达(j6 状态 852→1875、j9 1149→1460)。
- 教训(已入 MEMORY.md):**GRAV_FALL 变小会让"回跳过对刺门"的轨迹在门区滞留更久**,门芯 '^'(hgap 通道列未净空)从"能勉强擦过"变成"必嵌进去"——改下落重力后重点复查所有从右往左的 hgap 门。
- 16 项回归全绿。
## GRAV/GRAV_FALL 再各降一档 + 工作流变更(用户:「跳跃和下落再稍微飘一点点,你以后不用测试自带地图是否能通关,直接改就行」)
- `js/player.js` PHY + `tools/lib-reach.mjs` 同步:GRAV 0.29→0.27、GRAV_FALL 0.40→0.37。
- **用户免除地图通关验证流程**:今后改物理直接落地,不跑 solve、不为可达性修图(已入 MEMORY.md)。本次仅跑 smoke/dom-smoke 确认代码无坏。
- 预计影响:单跳 apex 91→94px、二段滞空更久;按用户偏好未验证/未修图。
## GRAV/GRAV_FALL 再降一档(用户:「再飘一点」)
- `js/player.js` PHY + `tools/lib-reach.mjs` 同步:GRAV 0.27→0.25、GRAV_FALL 0.37→0.35。
- 按用户豁免的工作流直接落地,未跑 solve/未修图;smoke/dom-smoke 通过。
## JUMP 7.5→6.6 满跳高度定为 2.8 格(用户:「一个满力跳的高度应该大约是2.8个方块」)
- `js/player.js` PHY + `tools/lib-reach.mjs` 同步:JUMP 7.5→6.6(GRAV 0.25 不变)。实测满跳顶点 90.4px = 2.83 格(目标 2.80 格=89.6px,偏差 0.8px;JUMP 6.5 会偏浅到 2.74 格)。二段跳顶点 209.8px = 6.56 格(未按用户要求调整)。
- 用户豁免地图通关验证,仅跑 smoke/dom-smoke/jumpdist(jumpdist 是纯物理测量,保留)。
## DJUMP 7.6→5.0 + 二段跳惯性叠加(用户:「满力二段跳的最高高度应该约为4.5个砖块,且如果在下落时二段,需要考虑下落的惯性的速度叠加跳跃的速度」)
- `js/player.js` PHY + `tools/lib-reach.mjs` 同步:DJUMP 7.6→5.0。二段实现从直接覆盖 `vy = -DJUMP` 改为叠减 `vy = Math.max(vy, 0) - DJUMP`(上升中按等价覆盖,与原版语义一致;下落中按下落惯性抵消冲量)。
- 实测(探针复刻积分):顶点接二段(满力最高)142.9px = 4.47 格 ≈ 目标 4.5 格;起跳瞬间接二段 52.5px = 1.64 格(比单跳低,与原版一致);下落 vy=+2 时二段回升 19.5px、vy≥+5 时完全失效(vy_new≥0 升不起来)——惯性叠加的直接后果,生效窗口为顶点后约 14 帧(GRAV_FALL 0.35)。
- 用户豁免地图通关验证;smoke/dom-smoke/jumpdist/ice/death 5 项通过。
## 二段跳改同款飘:GRAV_DJ 0.15 + DJUMP 4.0(用户:「二段跳的高度和一段差的也太明显了,二段也要和一段一样飘,但是高度在4.5格」)
- 问题:DJUMP 5.0 + 通用 GRAV 0.25 时二段上升只有 20 帧/52.5px,与一段的 27 帧/90.4px 观感差距大("短冲"vs"慢飘")。
- 方案:新增 `PHY.GRAV_DJ: 0.15`(二段上升段专属轻重力)+ `DJUMP 5.0→4.0`。二段初速小、重力更轻 → 上升帧数与一段持平。
- `js/player.js`:二段时置 `this.djUp = this.vy < 0`;积分行改 `vy + (vy<0 ? (djUp ? GRAV_DJ : GRAV) : GRAV_FALL)`,到顶(vy>=0)交回普通重力;`djUp` 在构造/reset/水中/落地四处清零。惯性叠保留:`vy = Math.max(vy,0) - DJUMP`。
- `tools/lib-reach.mjs` 同步 GRAV_DJ 常量 + jump() 内 djUp 标志同款逻辑。
- 实测:一段 27 帧/90.4px=2.83 格;顶点接二段 27 帧慢飘、总高 145.7px=4.55 格≈4.5 ✓;下落惯性叠加保留(vy=+1 回升 0.98 格、+2→0.45、+3→0.12,均为慢飘)。
- smoke/dom-smoke/jumpdist/ice/death 5 项通过;按用户豁免未跑 solve。
## 二段跳去悬停:DJUMP 4.0→6.0 / GRAV_DJ 0.15→0.35(用户:「现在二段就像在空中停了一段时间,请你参考视频里的完美复刻」)
- 用户发了参考视频(微信录屏,某 IWanna 系手游)。cv2 抽帧分析:该游戏 TILE≈44.75px、kid 高 35px、一段约 30 逻辑帧(60fps)升 2.9 格(平均 3.3px/帧)、上升干脆顶点不拖沓;kid 沿砖柱贴墙反复跳。
- 病根:上轮 GRAV_DJ 0.15 + DJUMP 4.0 使二段平均上升速度仅 2px/帧、耗时 27 帧——又慢又长=悬停感。
- 修正:DJUMP 6.0 / GRAV_DJ 0.35。二段 18 帧、平均 3.02px/帧(一段 3.35,速度感一致)、总高 144.8px=4.52 格 ✓。下落惯性叠加保留(vy=max(vy,0)-DJUMP),窗口加宽:vy=+1/+2/+3/+5 接二段分别回升 38.3/24.9/14.4/1.9px。
- 5 项冒烟通过;按用户豁免未跑 solve。
- 方法论备忘:无 ffmpeg 时用 `pip install opencv-python-headless`(装进 /tmp venv,全局 pip 会被 SIGTERM)抽帧;定位游戏内物体先量参照物(砖柱网格)反推 TILE 像素,再用特征色(kid 蓝衣/棕发)追踪。
## 二段跳「非常短」bug 修复(用户报告)
- 病根:`player.js` 二段公式 `vy = max(vy,0) - DJUMP` 把当前下落速度**全额**抵消冲量。满力一段到顶点后,人类反应(松手再按)天然晚 10+ 帧,vy 已 +3.5~7,二段实际提升仅 5~15px 甚至归零。
- 修复:`PHY.DJ_INERTIA = 0.5`,公式改 `vy = max(vy,0) * DJ_INERTIA - DJUMP`;`tools/lib-reach.mjs` 同步同值同式。
- 实测(`tools/dj-test.mjs`,真实引擎,二段自身提升高度):顶点 46→47px、+3帧 30.6→39px、+7帧 14.9→29.3px、+11帧 4.8→20.9px、+15帧 0.3→13.9px、+19帧 -1.1→8.4px。顶点附近保持满力,摔得快仍按惯性变弱(满足用户此前「下落惯性叠加」的要求)。
- 回归:dj-test / smoke / ice-test / spike-test 全过;solve 对照实验(DJ_INERTIA 临时置 1.0)证实 **j8 不可达是更早物理轮次的既有问题**(状态数 158 两次一致),非本次引入,按用户豁免未修图。
- 教训:抄 jumpdist 的 harness 时 `addL` 漏了 `handlers[t] =` 赋值,监听全丢、测试全 0——先跑最小实验(dbg3:裸 addEventListener 替换生效)再怀疑业务代码。
## 二段"好沉+高度短"二连修(用户报告)+ 工作流禁令收紧
- 用户新规矩(原话):「以后没有明确说明,你不要给我自动测试地图的可达性」——即使用户要求改物理也不主动跑 solve.mjs,已写入 MEMORY.md 工作流偏好节。
- 病根①「沉」:GRAV_DJ 0.35 比 GRAV 0.25 重 40%,二段上升减速快、发闷。修复:GRAV_DJ 0.35→0.25(与一段完全一致,"二段和一段一样飘")。
- 病根②「短」:DJ_INERTIA 0.5 对下落接二段的冲量抵消过多。修复:0.5→0.35。
- 配套定标:DJUMP 6.7→5.4 时顶点总高仅 4.32 格(引擎积分是**先加重力再移动**,此前预估公式高估 ~6px);微调 5.65 后顶点二段提升 59.9px、总高 4.49 格 ✓。
- 最终曲线(dj-test 实测,二段自身提升):顶点 59.9px / +3帧 52.7 / +7帧 44.0 / +11帧 36.0 / +15帧 28.8 / +19帧 22.4。
- `tools/lib-reach.mjs` 常量同步:GRAV_DJ 0.25 / DJUMP 5.65 / DJ_INERTIA 0.35。
- 回归:dj-test / smoke / ice / spike / save / key 全过(未跑 solve,遵守用户禁令)。
- 踩坑记录:Edit 工具对 player.js 含中文注释的多行块连续匹配失败(原因不明),单行 Edit 正常;php/python 改文件可行。以后改 player.js 优先单行 Edit。
## 二段"好沉+短"根治(用户二次反馈"还是和上一个问题一样")
- 诊断翻案:前两轮误把"沉"归因于 GRAV_DJ 0.35,实际根源是**起跳初速被下落速度吃掉**——一段起跳初速 -6.6,而下落接二段时 `vy = max(vy,0)*k - DJUMP` 使初速只剩 -3.3~-4.4(k=0.5/0.35),起跳那一下就没劲;初速小→上升自然短。调重力治标不治本。
- 修复:DJ_INERTIA 0.35→**0**(覆盖式二段,同 IWBT 原版,任何时机接二段初速恒 -DJUMP);GRAV_DJ 回 0.35(平均上升 3.18px/帧 与一段 3.23 一致;0.25 时只有 2.72 偏"肉");DJUMP 回 6.7。
- 实测(dj-test):任意时机(顶点~顶点后19帧)二段自身提升全部 60.8px,曲线完全持平;顶点总高 4.52 格 ✓。
- 用户最初提的"下落惯性叠加"经三次实践证明给不出好手感,放弃;参数保留 DJ_INERTIA(现值 0)供回退。
- 回归:dj-test/smoke/ice/spike/save/key 全过;未跑 solve(用户禁令)。
- dj-test 断言已随 INERTIA=0 语义重写(各时机 ≥55px 且与顶点差 ≤3px)。
+20 -5
View File
@@ -1,9 +1,13 @@
# iwanna(IWBT C 同人网页游戏)项目长期笔记 # iwanna(IWBT C 同人网页游戏)项目长期笔记
## 物理与判定(像素级跳刺的核心) ## 物理与判定(像素级跳刺的核心)
- 物理常量写在 `js/player.js` 的 `PHY`,全部对齐 IWBT 官方:GRAV 0.4 / MAXFALL 9 / JUMP 8.5 / DJUMP 7.6 / HSPD 3,松手即停、无惯性、可变跳跃高度。 - **用户工作流偏好(2026-10-24 起,二次收紧)**:用户明确说「以后没有明确说明,你不要给我自动测试地图的可达性」——改物理/手感参数时直接落地 `player.js` + 同步 `lib-reach.mjs`,**不主动跑 `solve.mjs`、不为重调地图修图**(用户另行要求才跑/才修)。求解器常量仍要保持同步(供用户日后自行诊断用)。其他非通关类回归(smoke/dj-test/ice-test 等)可轻量跑。
- 物理常量写在 `js/player.js` 的 `PHY`(2026-10-24 四调定格):GRAV 0.25(上升)/ GRAV_FALL 0.32(下落,分手重力;0.35→0.32 用户反馈"下落有点沉")/ GRAV_DJ 0.35(二段上升专属;试过 0.25,平均上升速度掉到 2.72px/帧 反而"肉")/ MAXFALL 9 / JUMP 6.6(满力一段 ≈84px=2.6 格,起跳初速 -6.6)/ DJUMP 6.7(起跳初速 -6.7 与一段几乎一致)/ HSPD 2.6 / DJ_INERTIA 0。手感调整链见日报;`tools/lib-reach.mjs` 有同名常量必须同步,且 jump/fall 的积分行是 `vy + (vy<0 ? (djUp?GRAV_DJ:GRAV) : GRAV_FALL)`。松手即停、无惯性、可变跳跃高度不变。
- **满力二段总高 = 4.5 格定标(144px)**:一段 83.9px + 二段 60.8px。帧量化使 DJUMP 取值有跳变,且引擎积分顺序是**先加重力再移动**(预估必须按此算,否则高估 ~6px):GRAV_DJ 0.35 下 DJUMP 6.0→47px(总 4.09 格)、6.5→57px(4.40)、6.7→60.8px(4.52 ✓)。定标流程:改参数→跑 `tools/dj-test.mjs` 看"二段顶点离平台"列→微调 DJUMP 到 4.5 格。
- **二段跳惯性规则(2026-10-24 三轮反馈后定型:INERTIA=0,覆盖式二段)**:`vy = max(vy,0) * DJ_INERTIA - DJUMP`,当前 DJ_INERTIA=0 即任何时机接二段初速恒为 -DJUMP(同 IWBT 原版)。**"沉"的根源是起跳初速被下落速度吃掉,不是上升重力**:用户三次反馈链路——全额抵消(顶点后 11 帧二段仅 4.8px,"非常短")→ 0.5(仍"短")→ 0.35 且误把 GRAV_DJ 降到 0.25(平均上升速度 3.18→2.72px/帧,更"肉",用户报"还是好沉+短")→ 0(初速恒 6.7 与一段 6.6 一致,起跳"嘭"感同款)。教训:用户说"沉"先查起跳初速(vy 被 max(vy,0)*k 吃掉),别急着调重力。现行曲线(dj-test):任意时机二段自身提升均 60.8px、顶点总高 4.52 格。回归:`tools/dj-test.mjs`。
- 玩家判定盒 **11×21**(`Player` 构造函数里 `this.w = 11; this.h = 21`)。改这里会同时影响 `level.js` 的 `spawn` 偏移和全部可达性工具。 - 玩家判定盒 **11×21**(`Player` 构造函数里 `this.w = 11; this.h = 21`)。改这里会同时影响 `level.js` 的 `spawn` 偏移和全部可达性工具。
- 刺判定盒在 `js/level.js` 的 `spikeBox()`,已按刺尖收紧(上刺 y+8 起、左右刺 x+5 起等),留出像素级擦弹窗口。`T.KILLER` 必须有兜底 return,否则 `deadlyRect` 会在坑向关崩溃。 - 刺碰撞是**三角×AABB(SAT)**:`js/level.js` 的 `spikeTri(t,x,y)` 顶点与 `drawSpike` 一致(上刺尖在格顶 y、左右刺尖在格缘中点),`triRectOverlap` 做精确判定;`deadlyRect` 网格刺+掉落刺都走它。旧的 `spikeBox` 矩形盒已删除。`T.KILLER` 必须有兜底 return。
- **kid 绘制零形变**:起跳/落地 squash 已全部删除(`player.js` 无 `squash` 字段,`sprites.js` 的 `drawKid` 直接按原始 11×21 画)。用户明确要求跳跃/落地都不要身体晃动。
## 冰面滑行惯性(易反复踩坑) ## 冰面滑行惯性(易反复踩坑)
- 冰面物理只在 `js/player.js` 的水平输入段,`sliding` 是唯一惯性开关:冰上 `|vx|>SLIDE_MIN` 置位,离冰后仍按 `sliding` 走惯性分支,撞墙/归零/交还控制时才清位。 - 冰面物理只在 `js/player.js` 的水平输入段,`sliding` 是唯一惯性开关:冰上 `|vx|>SLIDE_MIN` 置位,离冰后仍按 `sliding` 走惯性分支,撞墙/归零/交还控制时才清位。
@@ -17,13 +21,24 @@
- 台阶**不能铺满井宽**(会变成 1 格高封闭走廊,跳不起来)。 - 台阶**不能铺满井宽**(会变成 1 格高封闭走廊,跳不起来)。
- 顶台**不能压在地板洞口正上方**(会把井封死)——曾因此 j2 整关不可达。 - 顶台**不能压在地板洞口正上方**(会把井封死)——曾因此 j2 整关不可达。
- 完整四向钻石(上下左右刺齐全)在矩形判定盒下横向**无解**,只能当装饰;要「跳穿门」必须用 `vgap`(上下夹缝)或 `hgap`(左右夹缝)。 - 完整四向钻石(上下左右刺齐全)在矩形判定盒下横向**无解**,只能当装饰;要「跳穿门」必须用 `vgap`(上下夹缝)或 `hgap`(左右夹缝)。
- 垂刺(压顶)与落脚点的关系:单跳最高 ~86px、二段跳 ~158px。压顶刺放在落脚点上方 5 行 = 单跳安全/二段必死;放太近(3~4 行)会让单跳也撞死。冰面上**不要**加压顶垂刺(滑行无法控制跳跃高度,实测 j8 直接不可达)。 - 垂刺(压顶)与落脚点的关系:单跳最高 ~85px、二段跳 ~172px。压顶刺放在落脚点上方 5 行 = 单跳安全/二段必死;放太近(3~4 行)会让单跳也撞死。冰面上**不要**加压顶垂刺(滑行无法控制跳跃高度,实测 j8 直接不可达)。
- 刺毯缺口最远只能 3 连刺(跳 4 格 ≈128px,已是单跳极限);需要更远必须用二段跳,但压顶垂刺会禁止二段跳。 - **刺毯缺口间距统一 3 格(跳 2 连刺 = 64px)**:JUMP 7.5 + GRAV_FALL 0.55 下落地前一帧竖直速度 8.9px/帧,3 连刺(96px)起拍窗只有 0~1 帧(实测 j1/j2 第一跑窗口=单点或负值,不可玩);2 连刺留 55px 射程余量。2026-10-23 从 4/3 交替 → 统一 4 → 统一 3,配合 `carpet(m, x0, y, n, gaps)` 的显式 gaps 拆断特殊位置的 3 连刺。
- **井口/左端出口的 3 连刺必须显式拆断**(walk 限制使起拍位比所需位窄 1~2px,右行必卡):j2 L0 x25-27、L2/L4 井口 x11-13、L3 左端 x10-12(左行跳落点差 0.8px 蹭刺左坡)都是这么拆的。判据:任何"出口紧接着 3 连刺"的段,不管求解器当时过不过,加 gap 拆成 2+1。
- **hgap 夹缝的两段式穿越**:通道列 x 必须为空(carpet gap 对齐),且左右接近列 x±2 也要是缺口/已清——否则从左侧跳过来落在 '>' 刺上,变成 4 格跳(128px > 单跳 118.8px)必死。j2 L2 的 hgap(22)/(42)、j2 L4 的 hgap(52)、j7 顶层的 vgap(52) 都靠补通道列缺口修复。
- **天花板(实心顶/垂刺)会挡跳的横向漂移**:高跳(apex 97px)撞到洞顶后横向停滞,下坠时砸在原点刺上——j9 矮洞入口 x57→x61 四格跳就是这么死的,拆成两个 2 格跳(x59 加缺口)即解。
- **apex 与刺顶点的 1px 竞争是隐形杀手**:`spikeTri` 的 '^' 顶点就在格顶 y(无收紧)。apex 盒底与航线上下方 '^' 顶点只差 1px 时,顶点会在起跳后约 4 帧整段落在玩家盒内(SAT 铁死)——JUMP 8.5 apex 盒底 466 vs 顶点 480 从容擦过,8.0 时 479 就死。修法:去掉该格地刺只留压顶刺(j8 x86),或给落点台加宽(治标不治本,上升段照样死)。
- `hgap(x,y)` 的通道列 x **必须本身是空的**(carpet 缺口或显式 clear):`hgap` 只清 x±2、在 x±1 放对刺。若 carpet 缺口没对齐 x(如 j9 L1 的 x36、j1 回廊的 x92),通道里会留一根 '^',整门变成 3 连刺——往回跳必被 '<' 刺别住。修法:给 carpet 加对齐的 gap。
- **对刺门的侧刺('<'/'>')会把起跳窗切成 2px 级窄窗**:从门前起跳时,侧刺尖在起跳后第 3~5 帧别住盒子(上升不够),必须从门前偏右 ≥半个身位起跳。求解器 `reach()` 的 6px 状态桶对齐与否能直接翻转整关可解性——改物理后若某跳「全灭」,先逐帧 trace 看是不是侧刺别住起拍,而不是先怀疑跳距。j6 hgap(43) 曾只靠 fx≥1448 的 2px 窗可解,GRAV 0.34→0.33 时 hop 落点左移 2px 即全灭;修法=清掉一侧侧刺留双刺门(j6 x44)。
- **调试利器:逐帧 trace**。复刻 `lib-reach.mjs` 里 `sim.jump` 的主循环(vy 积分→x 移动→y 分段→每帧打印 x/y/feet/vy + `L.deadlyRect` + 涉及格子),比手推几何可靠得多;手算 SAT 三角形截面极易错(连错三次的教训:盒**右沿**压刺左坡、盒顶压垂刺尖,都要用精确坐标复算)。
- **kid 绘制零形变**(见物理节):起跳/落地 squash 已删,别再加回"落地挤压"——用户明确说过落地拉伸也不要。
## 工具链 ## 工具链
- `tools/lib-reach.mjs` 是 solve / reach-diag 的**共享 BFS 核心**,改建模只改这一处。 - `tools/lib-reach.mjs` 是 solve / reach-diag 的**共享 BFS 核心**,改建模只改这一处。
- 关键修正:跳跃水平方向被墙挡住时**不能把 vx 永久清零**(真实 `player.js` 里按住方向键下一帧会恢复)。清零会丢失「贴墙上升→越过台面→横移」这条经典路线,曾导致整个 j2 竖井判为不可达。 - 关键修正:跳跃水平方向被墙挡住时**不能把 vx 永久清零**(真实 `player.js` 里按住方向键下一帧会恢复)。清零会丢失「贴墙上升→越过台面→横移」这条经典路线,曾导致整个 j2 竖井判为不可达。
- `reach()` 默认 DJSET/RELSET 必须与 `solve.mjs` 的 `DJ`/`REL` 环境变量一致,否则诊断和求解结果对不上。 - `reach()` 默认 DJSET/RELSET 必须与 `solve.mjs` 的 `DJ`/`REL` 环境变量一致,否则诊断和求解结果对不上。现值:DJ=0,6,12,18,24,30 / REL=0,4,5,6,8,14。
- **RELSET 必须有 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。松键短跳是玩家的连续技巧,采样必须覆盖这两帧。
- **fy 规范化(canon)**:jump/fall 返回的落点常比地面高零点几 px(如 1535.5),与精确落点 1536 落进同一个 3px 桶——先占桶者赢,走链被错位几 px 的跳链顶掉,芝麻窗口翻转(加 rel=6 后 j2 状态数反而从 1572 暴跌到 80 的根因)。`push()` 里按 canStand 门控把 fy 吸附到 32 倍数(真实游戏重力下一帧即按到底)。
- 状态桶 3px:6px 桶会把"贴右腰 1~3px"的起拍/落点窗量化吞掉(j2 连续三段因此误判不可达)。
- 其余:`check.mjs` 格式校验、`save-test.mjs` 存档点、`key-test.mjs` 按键语义(R=回存档点 / Backspace=整关重来)、`smoke.mjs`、`dom-smoke.mjs`。 - 其余:`check.mjs` 格式校验、`save-test.mjs` 存档点、`key-test.mjs` 按键语义(R=回存档点 / Backspace=整关重来)、`smoke.mjs`、`dom-smoke.mjs`。
- 本地预览:`python3 -m http.server 8123`(ES module 必须走 HTTP,file:// 打不开)。 - 本地预览:`python3 -m http.server 8123`(ES module 必须走 HTTP,file:// 打不开)。
+11
View File
@@ -3,3 +3,14 @@
该项目由 “腾讯 WorkBuddy" 制作 该项目由 “腾讯 WorkBuddy" 制作
## TODO list
v1.0:
- 所有已有功能基本稳定可用
- 实现基本音效,动效,UI美化
- 编辑器基本可用,可发布地图
- 玩家可以制作地图和发布,其他玩家可以下载游玩
- 地图有排行榜功能
- 管理员账号可以修改自带地图
- 支持web,android,macos,linux, windows
- 支持移动端和桌面端
+30 -8
View File
@@ -137,13 +137,6 @@ canvas#game{
.t-custom{color:#ffc63a;border-color:#ffc63a} .t-custom{color:#ffc63a;border-color:#ffc63a}
.legend{display:flex;gap:8px;flex-wrap:wrap;border-top:1px solid var(--line);padding-top:7px;color:var(--dim);font-size:.85em;align-items:center} .legend{display:flex;gap:8px;flex-wrap:wrap;border-top:1px solid var(--line);padding-top:7px;color:var(--dim);font-size:.85em;align-items:center}
.help-body{display:flex;gap:18px;flex-wrap:wrap;overflow-y:auto}
.help-col{flex:1;min-width:240px}
.help-col ul{list-style:none;line-height:1.75}
.help-col li{color:var(--dim);font-size:.92em}
.help-col li b{color:var(--fg)}
.help-tip{border-top:1px solid var(--line);padding-top:8px;color:var(--gold);font-size:.85em}
.clear-title{text-align:center;font-size:2.4em;color:var(--gold);letter-spacing:6px;text-shadow:3px 3px 0 #000} .clear-title{text-align:center;font-size:2.4em;color:var(--gold);letter-spacing:6px;text-shadow:3px 3px 0 #000}
.result-body{display:flex;flex-direction:column;gap:5px} .result-body{display:flex;flex-direction:column;gap:5px}
.rline{display:flex;justify-content:space-between;border-bottom:1px dotted var(--line);padding:4px 2px} .rline{display:flex;justify-content:space-between;border-bottom:1px dotted var(--line);padding:4px 2px}
@@ -159,7 +152,7 @@ canvas#game{
} }
.pal:hover{border-color:#888} .pal:hover{border-color:#888}
.pal.on{border-color:var(--gold);color:#fff;background:#2b2b3c} .pal.on{border-color:var(--gold);color:#fff;background:#2b2b3c}
.pal .sw{width:14px;height:14px;border:1px solid #000;flex:none} .pal-cv{width:26px;height:26px;flex:none;border:1px solid #000;background:#101018;image-rendering:pixelated}
.pal .ch{font-weight:bold;color:var(--fg);width:12px;text-align:center} .pal .ch{font-weight:bold;color:var(--fg);width:12px;text-align:center}
.editor-main{flex:1;display:flex;flex-direction:column;gap:6px;min-width:0;overflow:auto} .editor-main{flex:1;display:flex;flex-direction:column;gap:6px;min-width:0;overflow:auto}
.editor-bar{display:flex;gap:5px;align-items:center;flex-wrap:wrap} .editor-bar{display:flex;gap:5px;align-items:center;flex-wrap:wrap}
@@ -228,6 +221,35 @@ canvas#ed-canvas{
.set-in:focus{outline:none;border-color:var(--gold)} .set-in:focus{outline:none;border-color:var(--gold)}
.set-hint{color:var(--dim);font-size:.78em} .set-hint{color:var(--dim);font-size:.78em}
.set-btns{margin-top:2px} .set-btns{margin-top:2px}
/* 键盘按键映射面板(电脑端) */
.set-keys{display:flex;flex-direction:column;gap:8px}
.krow{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
.kact{color:var(--fg);min-width:104px;font-size:.92em}
.kkeys{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
.kchip{
position:relative;background:#1a1a26;border:1px solid var(--line);color:var(--fg);
font-family:inherit;font-size:.88em;padding:5px 22px 5px 10px;border-radius:7px;cursor:pointer;
transition:border-color .1s,background .1s;
}
.kchip:hover{border-color:var(--gold)}
.kchip.listen{border-color:var(--gold);background:rgba(255,198,58,.16);box-shadow:0 0 0 2px rgba(255,198,58,.25)}
.kchip .krm{
position:absolute;top:-7px;right:-7px;width:16px;height:16px;border-radius:50%;
background:#c01a3a;color:#fff;font-size:.62em;line-height:16px;text-align:center;
border:1px solid #000;cursor:pointer;
}
.kadd{
background:transparent;border:1px dashed var(--line);color:var(--dim);
font-family:inherit;font-size:.82em;padding:5px 9px;border-radius:7px;cursor:pointer;
}
.kadd:hover{color:var(--gold);border-color:var(--gold)}
.klisten{color:var(--gold);font-size:.8em;animation:blink 1s infinite}
.klisten.bad{color:#ff6b6b;animation:none;font-weight:bold}
.kwarb{
width:100%;box-sizing:border-box;margin:0 0 10px;padding:8px 12px;border-radius:8px;
background:rgba(192,26,58,.16);border:1px solid #c01a3a;color:#ff8a8a;font-size:.86em;
}
@keyframes blink{50%{opacity:.4}}
/* 手机手柄预览区(与舞台同比例),按钮绝对定位可拖拽 */ /* 手机手柄预览区(与舞台同比例),按钮绝对定位可拖拽 */
.touch-preview{ .touch-preview{
position:relative;width:100%;aspect-ratio:800/608; position:relative;width:100%;aspect-ratio:800/608;
+6 -40
View File
@@ -37,7 +37,6 @@
<div class="menu"> <div class="menu">
<button class="btn big" data-act="levels">关卡选择</button> <button class="btn big" data-act="levels">关卡选择</button>
<button class="btn" data-act="editor">自制关卡</button> <button class="btn" data-act="editor">自制关卡</button>
<button class="btn" data-act="help">操作说明</button>
<button class="btn" data-act="settings">设置</button> <button class="btn" data-act="settings">设置</button>
</div> </div>
<div id="title-user" class="title-user"></div> <div id="title-user" class="title-user"></div>
@@ -66,44 +65,6 @@
</div> </div>
</div> </div>
<!-- ===== HELP ===== -->
<div class="screen hidden" id="scr-help">
<div class="panel">
<div class="panel-head">
<h2>操作说明</h2>
<button class="btn tiny" data-act="back">返回</button>
</div>
<div class="help-body">
<div class="help-col">
<h3>按键</h3>
<ul>
<li><b>← →</b> / <b>A D</b> — 移动(IWBT 手感:松手即停,无惯性)</li>
<li><b>Shift</b> / <b>空格</b> / <b>Z</b> — 跳跃(空中再按一次=二段跳)</li>
<li><b>X</b> / <b>J</b> — 射击(需先拾取手枪)</li>
<li><b>R</b> — 自杀,回到<b>最近的存档点</b>(死亡数 +1)</li>
<li><b>Backspace</b> — 整关从头再来(会清空存档点进度)</li>
<li><b>Esc</b> — 暂停 <b>↑ ↓</b> — 水中上浮 / 下潜</li>
<li><b>触屏</b> — 移动端自动显示:左下 <b>◀ ▶</b> 移动,右下 <b>跳</b> 与 🔫/▼ 道具键(桌面可加 <b>?touch=1</b> 预览)</li>
</ul>
</div>
<div class="help-col">
<h3>规则与道具</h3>
<ul>
<li><b>刺</b>(灰白三角)碰到即死,回最近存档点</li>
<li><b>存档点</b>(紫色果实)触碰后在此复活</li>
<li><b>手枪</b>(灰色枪)拾取后获得射击能力,子弹可击杀敌人与 BOSS</li>
<li><b>摄像头</b>(黑色镜头)进入区域会锁定 / 缩放视角——坑向关卡的元凶</li>
<li><b>美味水果</b>(红苹果)看着像补给,其实是即死陷阱</li>
<li><b>传送门</b>(紫色漩涡)成对传送,坑向关卡会把你送回起点</li>
<li><b>跳跃刷新器</b>(绿色箭头)空中触碰可重置二段跳</li>
<li><b>消失方块</b>踩上后会塌,<b>冰面</b>会打滑,<b>水</b>中可游泳</li>
</ul>
</div>
</div>
<div class="help-tip">提示:死亡是这个游戏的一部分。别放弃,IWBT 玩家的平均死亡数是三位数。</div>
</div>
</div>
<!-- ===== PAUSE ===== --> <!-- ===== PAUSE ===== -->
<div class="screen hidden" id="scr-pause"> <div class="screen hidden" id="scr-pause">
<div class="panel narrow"> <div class="panel narrow">
@@ -148,7 +109,12 @@
<input id="set-username" class="set-in" maxlength="16" placeholder="无名氏"> <input id="set-username" class="set-in" maxlength="16" placeholder="无名氏">
<span class="set-hint">显示在标题界面</span> <span class="set-hint">显示在标题界面</span>
</div> </div>
<div class="set-row col"> <div class="set-row col" id="set-keys-row">
<label class="set-lab">键盘按键</label>
<span class="set-hint">点击按键后按下新键即可重绑;同一动作可绑定多个键,点 × 删除</span>
<div id="set-keys" class="set-keys"></div>
</div>
<div class="set-row col" id="set-touch-row">
<label class="set-lab">手机操作按钮</label> <label class="set-lab">手机操作按钮</label>
<span class="set-hint">拖动按钮调整位置,拖动右下角圆点调整大小(仅移动端生效)</span> <span class="set-hint">拖动按钮调整位置,拖动右下角圆点调整大小(仅移动端生效)</span>
<div id="touch-preview" class="touch-preview"> <div id="touch-preview" class="touch-preview">
+76 -32
View File
@@ -2,12 +2,13 @@
import { TILE } from './util.js'; import { TILE } from './util.js';
import { Save } from './save.js'; import { Save } from './save.js';
import { makeCustomLevel } from './levels/index.js'; import { makeCustomLevel } from './levels/index.js';
import * as S from './sprites.js';
const $ = s => document.querySelector(s); const $ = s => document.querySelector(s);
const clamp = (v, a, b) => Math.max(a, Math.min(b, v)); const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
const PALETTE = [ export const PALETTE = [
['?', '查看属性', '#8a8a9a'], ['#', '方块', '#6b6b7e'], ['@', '草地', '#4f9e46'], ['=', '砖块', '#a8543c'], ['M', '金属', '#5a6478'], ['#', '方块', '#6b6b7e'], ['@', '草地', '#4f9e46'], ['=', '砖块', '#a8543c'], ['M', '金属', '#5a6478'],
['%', '冰面', '#8fd8f0'], ['~', '水', '#2a6fd8'], ['-', '单向台', '#9aa6bd'], ['V', '消失块', '#7a7a8c'], ['%', '冰面', '#8fd8f0'], ['~', '水', '#2a6fd8'], ['-', '单向台', '#9aa6bd'], ['V', '消失块', '#7a7a8c'],
['^', '刺', '#c8c8d8'], ['^', '刺', '#c8c8d8'],
['*', '美味水果', '#e03050'], ['S', '出生点', '#3ddc84'], ['G', '存档点', '#b06bff'], ['X', '终点', '#ffc63a'], ['*', '美味水果', '#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; } 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;导出时翻译成引擎的四个字符 // 编辑器中刺统一用 '^',方向存在 props.dir;导出时翻译成引擎的四个字符
const SPIKE_CHAR = { up: '^', down: 'v', left: '<', right: '>' }; const SPIKE_CHAR = { up: '^', down: 'v', left: '<', right: '>' };
const SPIKE_DIR = { '^': 'up', 'v': 'down', '<': 'left', '>': 'right' }; const SPIKE_DIR = { '^': 'up', 'v': 'down', '<': 'left', '>': 'right' };
const SPIKE_ARROW = { up: '↑', down: '↓', left: '←', right: '→' };
export const Editor = { export const Editor = {
api: {}, api: {},
@@ -70,10 +119,17 @@ export const Editor = {
buildPalette() { buildPalette() {
const p = $('#palette'); const p = $('#palette');
p.innerHTML = ''; p.innerHTML = '';
PALETTE.forEach(([ch, label, col]) => { PALETTE.forEach(([ch, label]) => {
const d = document.createElement('div'); const d = document.createElement('div');
d.className = 'pal' + (ch === this.sel ? ' on' : ''); d.className = 'pal' + (ch === this.sel ? ' on' : '');
d.innerHTML = `<span class="sw" style="background:${col}"></span><span class="ch">${ch === '?' ? '⌖' : ch}</span><span>${label}</span>`; // 用真实绘制函数渲染缩略图,所见即所得
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 = () => { d.onclick = () => {
this.sel = ch; this.sel = ch;
Array.from(p.children).forEach(c => c.classList.remove('on')); Array.from(p.children).forEach(c => c.classList.remove('on'));
@@ -95,13 +151,11 @@ export const Editor = {
const f = this.curFace(); const f = this.curFace();
const { tx, ty } = pos(e); const { tx, ty } = pos(e);
if (tx < 0 || ty < 0 || tx >= f.w || ty >= f.h) return; if (tx < 0 || ty < 0 || tx >= f.w || ty >= f.h) return;
// 选中该格(属性面板依据它渲染) // 无论手上拿的是什么方块,点击格子都选中它,属性面板立即显示该方块的属性
this.selTile = { tx, ty }; this.selTile = { tx, ty };
// 「查看属性」工具:只选中,不修改地图
if (this.sel === '?') { this.renderProps(); return; }
const prev = f.grid[ty][tx]; const prev = f.grid[ty][tx];
const ch = erase ? '.' : (this.sel === '.' ? '.' : this.sel); const ch = erase ? '.' : (this.sel === '.' ? '.' : this.sel);
// 放置不覆盖:放实方块时若该格已有方块则跳过(仍选中以便查看属性) // 放置不覆盖:放实方块时若该格已有方块则跳过,仅选中查看属性
if (!erase && ch !== '.' && prev !== '.') { this.renderProps(); return; } if (!erase && ch !== '.' && prev !== '.') { this.renderProps(); return; }
// 门的增删:放上传送门自动登记一扇门;擦掉或覆盖传送门则移除对应门 // 门的增删:放上传送门自动登记一扇门;擦掉或覆盖传送门则移除对应门
if (prev === 'W' && ch !== 'W') this.removeDoor(f, tx, ty); 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.fillStyle = '#101018'; ctx.fillRect(0, 0, c.width, c.height);
ctx.save(); ctx.save();
ctx.translate(-this.scroll, 0); ctx.translate(-this.scroll, 0);
const t = 0; // 编辑器为静态预览,不跑动画
for (let y = 0; y < f.h; y++) { for (let y = 0; y < f.h; y++) {
for (let x = 0; x < f.w; x++) { for (let x = 0; x < f.w; x++) {
const ch = f.grid[y][x]; const ch = f.grid[y][x];
const px = x * TILE, py = y * TILE; const px = x * TILE, py = y * TILE;
if (ch !== '.') { // 真实外观绘制(刺→三角形,方块→纹理,实体→对应精灵)
let col = '#555'; if (ch !== '.') drawTileReal(ctx, ch, px, py, f.props[y][x] || null, t);
const item = PALETTE.find(p => p[0] === ch); // 淡网格线(编辑对位用)
if (item) col = item[2]; ctx.strokeStyle = 'rgba(255,255,255,.06)';
if (ch === 'W') { const d = f.doors.find(o => o.tx === x && o.ty === y); col = (d && d.to) ? '#3ddc84' : '#8a8a9a'; } ctx.lineWidth = 1;
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)';
ctx.strokeRect(px + .5, py + .5, TILE - 1, TILE - 1); ctx.strokeRect(px + .5, py + .5, TILE - 1, TILE - 1);
if (ch !== '.') { // 传送门:叠加门编号与连接状态(编辑器元数据,游戏内无此显示)
ctx.fillStyle = '#fff'; if (ch === 'W') {
ctx.font = 'bold 14px monospace'; const d = f.doors.find(o => o.tx === x && o.ty === y);
ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = (d && d.to) ? '#bfffd0' : '#ffe6a0';
if (ch === 'W') { ctx.font = 'bold 9px monospace';
const d = f.doors.find(o => o.tx === x && o.ty === y); ctx.textAlign = 'left'; ctx.textBaseline = 'top';
ctx.fillText('W' + (d ? d.id : ''), px + TILE / 2, py + TILE / 2); ctx.fillText('W' + (d ? d.id : ''), px + 2, py + 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);
} }
} }
} }
+13 -1
View File
@@ -1,5 +1,5 @@
// 输入:IWBT 经典键位(Shift 跳 / Z 跳 / X 射击 / R 重来) // 输入:IWBT 经典键位(Shift 跳 / Z 跳 / X 射击 / R 重来)
const BIND = { export const BIND = {
left: ['ArrowLeft', 'KeyA'], left: ['ArrowLeft', 'KeyA'],
right: ['ArrowRight', 'KeyD'], right: ['ArrowRight', 'KeyD'],
up: ['ArrowUp', 'KeyW'], up: ['ArrowUp', 'KeyW'],
@@ -12,6 +12,9 @@ const BIND = {
menu: ['KeyQ'], menu: ['KeyQ'],
}; };
// 出厂键位快照(供设置里的「恢复默认」使用)
const DEFAULT_BIND = JSON.parse(JSON.stringify(BIND));
const down = new Set(); // 当前按住的原始 code const down = new Set(); // 当前按住的原始 code
const now = {}; // action 是否按下 const now = {}; // action 是否按下
const prev = {}; // 上一帧 const prev = {}; // 上一帧
@@ -75,6 +78,15 @@ addEventListener('keyup', e => {
}); });
addEventListener('blur', () => { down.clear(); refresh(); }); 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; } for (const act in BIND) { now[act] = false; prev[act] = false; }
export const ACTIONS = Object.keys(BIND); export const ACTIONS = Object.keys(BIND);
+33 -14
View File
@@ -328,14 +328,14 @@ export class Level {
for (let tx = x0; tx <= x1; tx++) { for (let tx = x0; tx <= x1; tx++) {
const t = this.tileAt(tx, ty); const t = this.tileAt(tx, ty);
if (!DEADLY[t]) continue; if (!DEADLY[t]) continue;
const r = spikeBox(t, tx, ty); const tr = spikeTri(t, tx * TILE, ty * TILE);
if (x < r.x + r.w && x + w > r.x && y < r.y + r.h && y + h > r.y) return true; 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) { for (const e of this.entities) {
if (e.type === 'dropspike' && e.falling) { if (e.type === 'dropspike' && e.falling) {
const r = spikeBox(T.SPIKE_U, e.x / TILE, e.y / TILE); const tr = spikeTri(T.SPIKE_L, e.x, e.y); // 与 drawDropSpike 的 dir=2(朝左)一致
if (x < r.x + r.w && x + w > r.x && y < r.y + r.h && y + h > r.y) return true; 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) { for (const b of this.ebullets) {
@@ -646,14 +646,33 @@ export class Level {
} }
} }
// 刺的判定盒:对齐齿尖、收窄到齿根之间(IWBT 三角形判定的矩形近似)。 // 单三角形刺的顶点(与 sprites.drawSpike 完全一致)。
// 配合 11×21 的玩家判定盒,可以像素级贴刺、穿对刺缝。 // 尖端指向刺的方向,底边贴在相反一侧(地面 / 天花板 / 墙面)。
function spikeBox(t, tx, ty) { function spikeTri(t, x, y) {
const x = tx * TILE, y = ty * TILE; const s = 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_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 { x: x + 6, y: y + 1, w: TILE - 12, h: TILE - 9 }; // 齿尖 y+27 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 { x: x + 5, y: y + 6, w: TILE - 10, h: TILE - 12 }; // 齿尖 x+5,缝宽 22 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 { x: x + 5, y: y + 6, w: TILE - 10, h: TILE - 12 }; // 齿尖 x+27 if (t === T.SPIKE_R) return { ax: x + s, ay: y + s / 2, bx: x, by: y, cx: x, cy: y + s };
return { x: x + 6, y: y + 6, w: TILE - 12, h: TILE - 10 }; // KILLER(美味水果) 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 };
+24 -17
View File
@@ -1,18 +1,20 @@
// 跳刺(Needle)10 关 —— IWBT 经典密集针刺风格 // 跳刺(Needle)10 关 —— IWBT 经典密集针刺风格
// 设计语法(已经 tools/solve.mjs 验证): // 设计语法(已经 tools/solve.mjs 验证):
// 刺毯缺口每 4 格一个(单跳节奏);钻石中心 = 地板上方 2~3 格; // 刺毯缺口每 3 格一个(跳 2 连刺 = 64px 单跳节奏);钻石中心 = 地板上方 2~3 格;
// 垂刺离最低落脚点 5 格(单跳安全、二段跳必死);2 格高矮洞可用撞头跳; // 垂刺离最低落脚点 5 格(单跳安全、二段跳必死);2 格高矮洞可用撞头跳;
// 竖井台阶每级升 2~3 格横移 3 格内;钢丝柱间距 ≤4(单跳)/ ≤6(二段)。 // 竖井台阶每级升 2~3 格横移 3 格内;钢丝柱间距 ≤4(单跳)/ ≤6(二段)。
import { Grid } from './kit.js'; import { Grid } from './kit.js';
import { vgap, hgap, diamond, spikeRun, block, corner, ceil, wall, well } from './needle.js'; import { vgap, hgap, diamond, spikeRun, block, corner, ceil, wall, well } from './needle.js';
// 刺毯:从 x0 起 n 格留落脚缺口。 // 刺毯:从 x0 起 n 格留落脚缺口。
// 缺口间距在 4/3 格之间交替(跳 3 格 / 跳 4 格轮换),避免整关都是"每 4 格一跳"的单一套路; // 缺口间距统一 3 格(跳 2 连刺 = 64px)。
// 最远仍是 3 连刺(单跳极限),配合压顶垂刺时二段跳必死。 // 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 carpet = (m, x0, y, n, gaps = []) => {
const g = []; const g = [];
let i = 3, k = 0; let i = 3;
while (i < n) { g.push(i); i += (k++ % 2 === 0 ? 4 : 3); } while (i < n) { g.push(i); i += 3; }
return spikeRun(m, x0, y, n, '^', g.concat(gaps)); 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, '>'); wall(m, 79, R - 5, R - 5, '<'); wall(m, 74, R - 7, R - 7, '>');
// --- 上层回廊(地板 row 12)x80-97 --- // --- 上层回廊(地板 row 12)x80-97 ---
m.rect(80, R - 10, 18, 1, '#'); 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 垂刺 ceil(m, 81, 95, R - 16); // row 6 垂刺
hgap(m, 92, R - 11); // 对刺门 hgap(m, 92, R - 11); // 对刺门
m.set(82, R - 11, 'G'); m.set(94, R - 11, 'G'); m.set(82, R - 11, 'G'); m.set(94, R - 11, 'G');
@@ -82,7 +84,7 @@ export const JUMP_LEVELS = [
// ---- L0 地板 row 48(向右:刺毯 + 三道上下夹缝)---- // ---- L0 地板 row 48(向右:刺毯 + 三道上下夹缝)----
m.rect(1, 48, 66, 1, '#'); m.rect(1, 48, 66, 1, '#');
m.set(3, 47, 'S'); 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); ceil(m, 5, 54, 43);
vgap(m, 16, 46); vgap(m, 30, 46); vgap(m, 46, 46); vgap(m, 16, 46); vgap(m, 30, 46); vgap(m, 46, 46);
m.set(11, 47, 'G'); m.set(39, 47, 'G'); 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, '^'); m.set(2, 37, '^'); m.set(8, 35, '^');
// ---- L2 地板 row 32(向右,井底延伸到右井下方)---- // ---- L2 地板 row 32(向右,井底延伸到右井下方)----
m.rect(9, 32, 57, 1, '#'); 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); ceil(m, 11, 54, 27);
hgap(m, 22, 31); hgap(m, 42, 31); hgap(m, 22, 31); hgap(m, 42, 31);
vgap(m, 32, 30); vgap(m, 50, 30); vgap(m, 32, 30); vgap(m, 50, 30);
@@ -110,7 +112,7 @@ export const JUMP_LEVELS = [
m.set(65, 29, '^'); m.set(58, 27, '^'); m.set(65, 29, '^'); m.set(58, 27, '^');
// ---- L3 地板 row 24(向左,掉落刺;垂刺避开左井 x2..9)---- // ---- L3 地板 row 24(向左,掉落刺;垂刺避开左井 x2..9)----
m.rect(2, 24, 56, 1, '#'); 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); ceil(m, 10, 54, 19);
[14, 24, 34, 44].forEach(x => m.set(x, 18, 'D')); [14, 24, 34, 44].forEach(x => m.set(x, 18, 'D'));
vgap(m, 40, 22); vgap(m, 22, 22); vgap(m, 40, 22); vgap(m, 22, 22);
@@ -120,7 +122,7 @@ export const JUMP_LEVELS = [
m.set(2, 21, '^'); m.set(8, 19, '^'); m.set(2, 21, '^'); m.set(8, 19, '^');
// ---- L4 地板 row 16(向右到终点)---- // ---- L4 地板 row 16(向右到终点)----
m.rect(9, 16, 57, 1, '#'); 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); ceil(m, 11, 60, 11);
hgap(m, 26, 15); vgap(m, 40, 14); hgap(m, 52, 15); hgap(m, 26, 15); vgap(m, 40, 14); hgap(m, 52, 15);
[18, 34, 56].forEach(x => m.set(x, 10, 'D')); [18, 34, 56].forEach(x => m.set(x, 10, 'D'));
@@ -175,8 +177,8 @@ export const JUMP_LEVELS = [
m.border('#'); m.border('#');
m.rect(1, R, W - 2, 1, '%'); // 冰地板 m.rect(1, R, W - 2, 1, '%'); // 冰地板
m.set(3, R - 1, 'S'); m.set(3, R - 1, 'S');
// --- x6-30 冰面刺坑 --- // --- x6-30 冰面刺坑(3 格宽:新物理单跳 108.8px > 96px;旧 4~5 格坑跳不过)---
for (const [a, b] of [[7, 10], [13, 16], [19, 22], [25, 29]]) 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, '^'); } for (let x = a; x <= b; x++) { m.set(x, R, '.'); m.set(x, R + 1, '^'); }
ceil(m, 6, 24, R - 5); // 压顶垂刺:冰面上禁二段跳 ceil(m, 6, 24, R - 5); // 压顶垂刺:冰面上禁二段跳
m.set(12, R - 1, 'G'); 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, '<'); wall(m, 129, 26, 26, '<'); wall(m, 122, 23, 23, '>'); wall(m, 129, 20, 20, '<');
// ============ L1 中层(地板 row 16,向左折返,延伸到两侧井底)============ // ============ L1 中层(地板 row 16,向左折返,延伸到两侧井底)============
m.rect(1, 16, 122, 1, '#'); 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); ceil(m, 9, 118, 10);
vgap(m, 108, 14); vgap(m, 82, 14); vgap(m, 56, 14); vgap(m, 30, 14); vgap(m, 108, 14); vgap(m, 82, 14); vgap(m, 56, 14); vgap(m, 30, 14);
hgap(m, 95, 15); hgap(m, 43, 15); 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); 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'); m.set(113, 15, 'G'); m.set(63, 15, 'G'); m.set(21, 15, 'G');
// ============ 左井 x2-7 → L2(右墙底部留 3 格入口,桥接块接 L2 地板边)============ // ============ 左井 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, '^'); } 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(78, R - 2, '-'); m.set(79, R - 2, '-');
m.set(83, R - 4, '-'); m.set(84, R - 4, '-'); 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(93, R - 4, '-'); m.set(94, R - 4, '-');
m.set(97, R - 2, '-'); m.set(98, R - 2, '-'); 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'); m.set(101, R - 1, 'G');
// --- x102-126 钻石收尾 --- // --- x102-126 钻石收尾 ---
carpet(m, 102, R - 1, 24, [0, 1, 20, 21]); carpet(m, 102, R - 1, 24, [0, 1, 20, 21]);
@@ -405,7 +411,7 @@ export const JUMP_LEVELS = [
// ============ L0 下层(地板 row 28)============ // ============ L0 下层(地板 row 28)============
m.rect(1, 28, W - 2, 1, '#'); m.rect(1, 28, W - 2, 1, '#');
m.set(3, 27, 'S'); m.set(3, 27, 'S');
carpet(m, 5, 27, 116); carpet(m, 5, 27, 116, [54]); // 54=x59:拆开矮洞入口的四格跳(洞顶 row24 会挡跳的横向漂移)
ceil(m, 5, 120, 22); ceil(m, 5, 120, 22);
vgap(m, 14, 26); vgap(m, 38, 26); vgap(m, 14, 26); vgap(m, 38, 26);
vgap(m, 86, 26); vgap(m, 110, 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, '<'); wall(m, 126, 24, 24, '<'); wall(m, 119, 21, 21, '>'); wall(m, 126, 18, 18, '<');
// ============ L1 上层(地板 row 14,向左折返,延伸到井底)============ // ============ L1 上层(地板 row 14,向左折返,延伸到井底)============
m.rect(2, 14, 118, 1, '#'); 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); ceil(m, 9, 116, 8);
hgap(m, 100, 13); hgap(m, 68, 13); hgap(m, 36, 13); hgap(m, 100, 13); hgap(m, 68, 13); hgap(m, 36, 13);
vgap(m, 84, 12); vgap(m, 52, 12); vgap(m, 20, 12); vgap(m, 84, 12); vgap(m, 52, 12); vgap(m, 20, 12);
@@ -498,6 +504,7 @@ export const JUMP_LEVELS = [
wires.push(wx); wires.push(wx);
for (const s of steps) { wx -= s; if (wx < 14) break; 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, '#')); 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 g1 = wires[6], g2 = wires[Math.floor(wires.length / 2) + 4];
const topOf = i => [6, 5, 7, 6, 7, 5][i % 6]; const topOf = i => [6, 5, 7, 6, 7, 5][i % 6];
+4 -4
View File
@@ -290,6 +290,7 @@ function loop(now) {
} }
// ---------------- 启动 ---------------- // ---------------- 启动 ----------------
let settingsFrom = 'scr-title'; // 进入设置前的界面,确定后返回
Save.load(); Save.load();
Audio2.enabled = Save.data.audio !== false; 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)); }, play: (lv) => { G.fromEditor = false; startLevel(lv, lv.builtin === false ? -1 : (lv.index ?? -1)); },
levels: toLevels, levels: toLevels,
manage: () => UI.setManage(!UI.manage), manage: () => UI.setManage(!UI.manage),
help: () => UI.show('scr-help'),
editor: toEditor, editor: toEditor,
settings: () => { Settings.enter(); UI.show('scr-settings'); }, settings: () => { settingsFrom = UI.cur; Settings.enter(); UI.show('scr-settings'); },
'set-ok': () => Settings.confirm(), 'set-ok': () => { Settings.confirm(); UI.show(settingsFrom); },
'set-reset': () => Settings.reset(), 'set-reset': () => Settings.reset(),
back: toTitle, back: toTitle,
resume, resume,
@@ -325,7 +325,7 @@ addEventListener('keydown', unlock);
// 移动端虚拟手柄 // 移动端虚拟手柄
Touch.init({ pause, suicide, shoot }); Touch.init({ pause, suicide, shoot });
Settings.init(); Settings.init(useTouch);
UI.show('scr-title'); UI.show('scr-title');
requestAnimationFrame(loop); requestAnimationFrame(loop);
+26 -15
View File
@@ -6,11 +6,16 @@ import * as S from './sprites.js';
import { T } from './level.js'; import { T } from './level.js';
export const PHY = { 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, MAXFALL: 9,
JUMP: 8.5, JUMP: 6.6,
DJUMP: 7.6, DJUMP: 6.7, // 二段跳冲量:起跳初速与一段 JUMP 6.6 几乎一致(起跳"嘭"感同款);顶点接二段总高 = 4.51 格
HSPD: 3, DJ_INERTIA: 0, // 二段跳时下落速度对冲量的抵消比例:0=任何时机接二段都满力起跳(初速恒 -DJUMP,同 IWBT 原版),
// 1=全额抵消(旧 bug:顶点晚几帧接二段直接归零)。0.35/0.5 都被用户反馈"好沉+短"——起跳初速被下落速度
// 吃掉才是"沉"的根源(初速从 6.7 掉到 3~4,起跳那一下没劲),与上升重力无关。
HSPD: 2.6,
WATER_GRAV: 0.16, WATER_GRAV: 0.16,
WATER_FALL: 2.6, WATER_FALL: 2.6,
WATER_RISE: 0.42, WATER_RISE: 0.42,
@@ -23,6 +28,7 @@ export const PHY = {
SLIDE_BRAKE: 0.45, // 滑行中按反向键的刹车 SLIDE_BRAKE: 0.45, // 滑行中按反向键的刹车
SLIDE_MIN: 0.35, // 冰面上攒到多大速度才算进入滑行状态 SLIDE_MIN: 0.35, // 冰面上攒到多大速度才算进入滑行状态
SLIDE_STOP: 0.05, // 低于此速度惯性结束 SLIDE_STOP: 0.05, // 低于此速度惯性结束
ICE_TOP: 1.6, // 冰面最高速度 = HSPD × ICE_TOP(调大=冰上能滑得更快,留更猛的惯性)
}; };
export class Player { export class Player {
@@ -38,12 +44,12 @@ export class Player {
this.x = sp.x; this.y = sp.y; this.x = sp.x; this.y = sp.y;
this.vx = 0; this.vy = 0; this.vx = 0; this.vy = 0;
this.jumps = 0; this.jumps = 0;
this.djUp = false;
this.grounded = false; this.grounded = false;
this.facing = 1; this.facing = 1;
this.alive = true; this.alive = true;
this.dead = false; this.dead = false;
this.deadT = 0; this.deadT = 0;
this.squash = 1;
this.inWater = false; this.inWater = false;
this.animT = 0; this.animT = 0;
this.shootCool = 0; this.shootCool = 0;
@@ -78,7 +84,8 @@ export class Player {
const sp = this.savePoint || L.spawn; const sp = this.savePoint || L.spawn;
this.x = sp.x; this.y = sp.y; this.x = sp.x; this.y = sp.y;
this.vx = 0; this.vy = 0; this.jumps = 0; 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; this.deadT = 0;
// 安全兜底:万一重生点被地形/消失块堵住,向上顶出,别一复活就卡死 // 安全兜底:万一重生点被地形/消失块堵住,向上顶出,别一复活就卡死
let guard = 0; let guard = 0;
@@ -107,7 +114,7 @@ export class Player {
if (moveL && !moveR) target = -PHY.HSPD; if (moveL && !moveR) target = -PHY.HSPD;
else if (moveR && !moveL) 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 => { const decel = f => {
if (Math.abs(this.vx) <= f) this.vx = 0; if (Math.abs(this.vx) <= f) this.vx = 0;
else this.vx -= sgn(this.vx) * f; 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('jump')) this.vy -= PHY.WATER_RISE;
if (Input.is('down')) this.vy += 0.25; if (Input.is('down')) this.vy += 0.25;
this.vy = clamp(this.vy, -3.2, PHY.WATER_FALL); this.vy = clamp(this.vy, -3.2, PHY.WATER_FALL);
this.jumps = 0; this.jumps = 0; this.djUp = false;
} else { } else {
if (Input.hit('jump')) { if (Input.hit('jump')) {
if (this.grounded || this.jumps === 0) { if (this.grounded || this.jumps === 0) {
this.vy = -PHY.JUMP; this.jumps = 1; this.grounded = false; this.vy = -PHY.JUMP; this.jumps = 1; this.grounded = false;
this.squash = 1.28; // 不再做起跳拉伸:空中保持身体比例,避免跳跃时视觉上下晃动
sfx.jump(); sfx.jump();
L.particles.spawn(this.cx, this.y + this.h, 5, 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 }); { colors: ['#fff', '#cfcfe0'], speed: 1.6, life: 16, size: 2, dir: -Math.PI / 2, spread: 2 });
} else if (this.jumps === 1) { } else if (this.jumps === 1) {
this.vy = -PHY.DJUMP; this.jumps = 2; // 二段跳冲量叠减当前速度:上升中按 = 原版语义(直接获得 -DJUMP);
this.squash = 1.22; // 下落中按 = 下落惯性只抵消一部分冲量(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(); sfx.djump();
L.particles.spawn(this.cx, this.cy, 10, L.particles.spawn(this.cx, this.cy, 10,
{ colors: ['#fff', '#9ad8ff'], speed: 2.2, life: 20, size: 2, spread: Math.PI * 2 }); { 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; 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; const wasAir = !this.grounded;
this.grounded = hitGround || (this.vy === 0 && L.solidRect(this.x, this.y + 1, this.w, this.h)); this.grounded = hitGround || (this.vy === 0 && L.solidRect(this.x, this.y + 1, this.w, this.h));
if (this.grounded) { if (this.grounded) {
if (wasAir) { this.squash = 0.72; } this.jumps = 0; this.djUp = false;
this.jumps = 0;
} }
this.squash += (1 - this.squash) * 0.22;
// ---- 滑行反馈:超速滑行(冰上,或刚滑出冰块仍带惯性)时脚下溅起冰屑 ---- // ---- 滑行反馈:超速滑行(冰上,或刚滑出冰块仍带惯性)时脚下溅起冰屑 ----
if (this.sliding && this.grounded && Math.abs(this.vx) > PHY.HSPD && this.animT % 5 === 0) { if (this.sliding && this.grounded && Math.abs(this.vx) > PHY.HSPD && this.animT % 5 === 0) {
+168 -11
View File
@@ -1,5 +1,8 @@
// 设置:用户名 + 手机虚拟手柄按钮的自定义(位置/大小,拖动调整) // 设置:用户名 + 设备相关自定义
// · PC(电脑端)→ 键盘按键映射
// · 移动端 → 手机虚拟手柄按钮的位置/大小(拖动调整)
import { Save } from './save.js'; import { Save } from './save.js';
import { applyBindings, defaultBindings, BIND } from './input.js';
// 每个可自定义的手柄按钮:key 对应 touch.js 里的 data-touch, // 每个可自定义的手柄按钮:key 对应 touch.js 里的 data-touch,
// def 为默认布局(cx/cy 为相对舞台中心的百分比,size 为按钮边长 px) // def 为默认布局(cx/cy 为相对舞台中心的百分比,size 为按钮边长 px)
@@ -13,21 +16,37 @@ const BUTTONS = [
{ key: 'suicide', label: '↺', shape: 'sm', def: { cx: 90, cy: 58, size: 42 } }, { 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 clone = (o) => JSON.parse(JSON.stringify(o));
const DEFAULT_TOUCH = {}; const DEFAULT_TOUCH = {};
BUTTONS.forEach(b => { DEFAULT_TOUCH[b.key] = clone(b.def); }); 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 work = makeDefaults();
let wrap = null; let wrap = null;
let pcMode = false; // true=电脑端(键盘映射),false=移动端(手柄按钮)
let listening = null; // 重绑监听:{act, idx}
let conflict = null; // 冲突警告:{code, label, act}
const els = {}; const els = {};
let keysBox = null; // 键盘映射容器
/** 读取/补全存档里的设置对象(保证 touch 字段齐全) */ /** 读取/补全存档里的设置对象(保证 touch / keys 字段齐全) */
function meta() { function meta() {
if (!Save.data.settings) Save.data.settings = { username: '' }; if (!Save.data.settings) Save.data.settings = {};
if (!Save.data.settings.touch) Save.data.settings.touch = clone(DEFAULT_TOUCH); const s = Save.data.settings;
return 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} 绝对定位(百分比定位 + 像素边长) */ /** 把某个按钮按 p={cx,cy,size} 绝对定位(百分比定位 + 像素边长) */
@@ -59,13 +78,135 @@ function showUsername() {
if (el) el.textContent = '玩家:' + (meta().username || '无名氏'); 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 = { export const Settings = {
init() { /** isTouch:是否移动端(由 main.js 依据 ?touch 覆盖与设备检测传入) */
init(isTouch) {
pcMode = !isTouch;
wrap = document.getElementById('touch-preview'); wrap = document.getElementById('touch-preview');
keysBox = document.getElementById('set-keys');
work = clone(meta()); work = clone(meta());
this.build(); this.build();
renderKeys();
wireKeys();
showUsername(); showUsername();
apply(); apply();
applyBindings(meta().keys);
document.addEventListener('keydown', onCaptureKey);
}, },
/** 构建设置界面里的可拖拽手柄预览(克隆 #touch 的按钮形状) */ /** 构建设置界面里的可拖拽手柄预览(克隆 #touch 的按钮形状) */
@@ -86,38 +227,54 @@ export const Settings = {
} }
}, },
/** 打开设置界面:从存档同步工作副本与输入框 */ /** 打开设置界面:从存档同步工作副本与输入框,并按设备切换面板 */
enter() { enter() {
const s = meta(); 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'); const inp = document.getElementById('set-username');
if (inp) inp.value = work.username; if (inp) inp.value = work.username;
listening = null;
conflict = null;
this.build(); 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() { confirm() {
const s = meta(); const s = meta();
const inp = document.getElementById('set-username'); const inp = document.getElementById('set-username');
work.username = (inp ? inp.value : '').trim().slice(0, 16); work.username = (inp ? inp.value : '').trim().slice(0, 16);
s.username = work.username; s.username = work.username;
s.touch = clone(work.touch); s.touch = clone(work.touch);
s.keys = clone(work.keys);
Save.flush(); Save.flush();
showUsername(); showUsername();
apply(); apply();
applyBindings(s.keys);
}, },
/** 恢复默认:布局与用户名全部回到出厂值(仅影响工作副本,需点确定才落盘) */ /** 恢复默认:用户名/手柄布局/键位全部回到出厂值(仅影响工作副本,需点确定才落盘) */
reset() { reset() {
work = makeDefaults(); work = makeDefaults();
listening = null;
conflict = null;
const inp = document.getElementById('set-username'); const inp = document.getElementById('set-username');
if (inp) inp.value = ''; if (inp) inp.value = '';
this.build(); this.build();
renderKeys();
}, },
/** 供调试 / 测试使用 */ /** 供调试 / 测试使用 */
_state() { return work; }, _state() { return work; },
_els() { return els; }, _els() { return els; },
_pcMode() { return pcMode; },
_conflict() { return conflict; },
_listening() { return listening; },
}; };
/** 给单个预览按钮绑定「拖动移动」与「右下角缩放」 */ /** 给单个预览按钮绑定「拖动移动」与「右下角缩放」 */
+28 -28
View File
@@ -8,7 +8,7 @@ const C = {
metal: '#5a6478', metalDark: '#333a4a', metal: '#5a6478', metalDark: '#333a4a',
ice: '#8fd8f0', iceDark: '#4a90b8', ice: '#8fd8f0', iceDark: '#4a90b8',
water: '#2a6fd8', water: '#2a6fd8',
spike: '#c8c8d8', spikeDark: '#5a5a6a', spike: '#cfdde9', spikeDark: '#a8bccf',
kid: '#d94a55', kidDark: '#8e2530', skin: '#f5cfa8', hair: '#5a3a22', pant: '#2c3a7a', kid: '#d94a55', kidDark: '#8e2530', skin: '#f5cfa8', hair: '#5a3a22', pant: '#2c3a7a',
save: '#b06bff', goal: '#ffc63a', gun: '#b8b8c8', apple: '#e03050', 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); 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) { export function drawSpike(ctx, x, y, dir, t) {
const s = TILE; const s = TILE;
const g = ctx.createLinearGradient(x, y, x, y + s); let ax, ay, bx, by, cx, cy;
// 底座 if (dir === 0) { // 朝上(地面上)
if (dir === 0) { // 朝上(在地面上) ax = x + s / 2; ay = y; bx = x; by = y + s; cx = x + s; cy = y + s;
px(ctx, x + 1, y + s - 7, s - 2, 7, C.spikeDark); } else if (dir === 1) { // 朝下(天花板)
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); ax = x + s / 2; ay = y + s; bx = x; by = y; cx = x + s; cy = y;
px(ctx, x + 1, y + s - 2, s - 2, 2, '#2a2a34'); } else if (dir === 2) { // 朝左(贴右墙)
} else if (dir === 1) { // 朝下(天花板) ax = x; ay = y + s / 2; bx = x + s; by = y; cx = x + s; cy = y + s;
px(ctx, x + 1, y, s - 2, 7, C.spikeDark); } else { // 朝右(贴左墙)
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); ax = x + s; ay = y + s / 2; bx = x; by = y; cx = x; cy = y + s;
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');
} }
} // 冰蓝白渐变:尖端最亮,往底边过渡到淡蓝
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.beginPath();
ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.lineTo(x3, y3); ctx.closePath(); ctx.moveTo(ax, ay); ctx.lineTo(bx, by); ctx.lineTo(cx, cy); ctx.closePath();
ctx.fillStyle = C.spike; ctx.fill(); ctx.fillStyle = g; ctx.fill();
ctx.strokeStyle = '#8d8da0'; ctx.lineWidth = 1; ctx.stroke(); // 淡蓝描边(参考图的柔和轮廓)
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 x = Math.round(p.x), y = Math.round(p.y);
const w = p.w, h = p.h; const w = p.w, h = p.h;
const air = !p.grounded; const air = !p.grounded;
const sq = p.squash; // 1 正常 // 不做任何 squash/stretch 形变:起跳与落地都保持原始体型,
// 避免身体忽高忽低被感知为"上下晃动"(2026-10-23 用户两次反馈后彻底移除)
ctx.save(); ctx.save();
ctx.translate(x + w / 2, y + h); ctx.translate(x, y);
ctx.scale(1 / sq, sq);
ctx.translate(-w / 2, -h);
const face = p.facing; // 1 右 -1 左 const face = p.facing; // 1 右 -1 左
// 身体(红衣) // 身体(红衣)
+1 -1
View File
@@ -6,7 +6,7 @@ import { fmtTime } from './util.js';
const $ = s => document.querySelector(s); const $ = s => document.querySelector(s);
const $$ = s => Array.from(document.querySelectorAll(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 = [ const TABS = [
['all', '全部'], ['jump', '跳刺'], ['endur', '耐久'], ['boss', 'BOSS'], ['troll', '坑向'], ['custom', '自制'], ['all', '全部'], ['jump', '跳刺'], ['endur', '耐久'], ['boss', 'BOSS'], ['troll', '坑向'], ['custom', '自制'],
]; ];
+37
View File
@@ -0,0 +1,37 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>刺外观预览(对齐参考图)</title>
<style>
body { background:#20242c; color:#dde; font-family:monospace; text-align:center; padding:16px; }
canvas { image-rendering:pixelated; border:1px solid #445; max-width:100%; }
p { font-size:13px; color:#99a; }
</style>
</head>
<body>
<h3>单三角形刺 · 冰蓝白渐变(游戏真实代码渲染)</h3>
<canvas id="cv" width="768" height="384"></canvas>
<p>上排:地面刺(朝上) · 中间:天花板刺(朝下) · 左右:贴墙刺(朝左/朝右)</p>
<script type="module">
import { drawSpike, drawBlock } from './js/sprites.js';
const TILE = 32;
const cv = document.getElementById('cv');
const ctx = cv.getContext('2d');
// 地面与天花板(参照截图的格子地面)
for (let x = 0; x < 24; x++) {
drawBlock(ctx, x * TILE, 8 * TILE, 0); // 下半地面
drawBlock(ctx, x * TILE, 0, 0); // 顶部天花板
}
// 地面刺一排(朝上)
for (let x = 3; x <= 9; x++) drawSpike(ctx, x * TILE, 7 * TILE, 0, 0);
// 天花板刺(朝下)
for (let x = 13; x <= 17; x++) drawSpike(ctx, x * TILE, TILE, 1, 0);
// 贴墙刺:左墙(朝右)+ 右墙(朝左)
drawSpike(ctx, 0, 5 * TILE, 3, 0);
drawSpike(ctx, 23 * TILE, 5 * TILE, 2, 0);
// 中间孤刺
drawSpike(ctx, 11 * TILE, 7 * TILE, 0, 0);
</script>
</body>
</html>
+95
View File
@@ -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 格');
+136
View File
@@ -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);
+97
View File
@@ -0,0 +1,97 @@
// 编辑器所见即所得测试:刺以三角形绘制 + 每种方块都能正常渲染
const noop = () => { };
const els = new Map();
// 记录关键绘制调用的计数器(证明刺用三角形图元、方块用真实纹理)
const C = { lineTo: 0, fillRect: 0, beginPath: 0, moveTo: 0, arc: 0 };
const ctx2d = new Proxy({
canvas: { width: 800, height: 608 },
measureText: () => ({ width: 12 }),
createLinearGradient: () => ({ addColorStop: noop }),
createRadialGradient: () => ({ addColorStop: noop }),
getImageData: () => ({ data: [] }),
}, {
get(t, k) { return k in t ? t[k] : noop; },
set(t, k, v) { t[k] = v; return true; },
});
// 装上计数器
ctx2d.lineTo = () => C.lineTo++;
ctx2d.fillRect = () => C.fillRect++;
ctx2d.beginPath = () => C.beginPath++;
ctx2d.moveTo = () => C.moveTo++;
ctx2d.arc = () => C.arc++;
function mkEl(id = '') {
return {
id, tagName: 'DIV', value: '', textContent: '', innerHTML: '',
style: {}, dataset: {}, children: [], width: 800, height: 608,
classList: { add: noop, remove: noop, toggle: noop, contains: () => false },
addEventListener: noop, removeEventListener: noop, appendChild: noop,
querySelector: () => mkEl(), querySelectorAll: () => [],
getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 608 }),
getContext: () => ctx2d, focus: noop, click: noop,
};
}
const handlers = {};
const addL = (t, fn) => { (handlers[t] = handlers[t] || []).push(fn); };
globalThis.location = { search: '', href: 'http://localhost/', pathname: '/', reload: noop };
Object.defineProperty(globalThis, 'navigator', { value: { maxTouchPoints: 0, userAgent: 'node' }, configurable: true });
globalThis.matchMedia = q => ({ matches: false, media: q, addEventListener: noop, removeEventListener: noop });
globalThis.window = {
addEventListener: addL, removeEventListener: noop, AudioContext: null, webkitAudioContext: null,
innerWidth: 1280, innerHeight: 800, location: globalThis.location, navigator: globalThis.navigator, matchMedia: globalThis.matchMedia,
};
globalThis.addEventListener = addL;
globalThis.removeEventListener = noop;
globalThis.confirm = () => true;
globalThis.alert = noop;
globalThis.localStorage = { _d: {}, getItem(k) { return this._d[k] ?? null; }, setItem(k, v) { this._d[k] = String(v); }, removeItem(k) { delete this._d[k]; } };
globalThis.document = {
getElementById(id) { if (!els.has(id)) els.set(id, mkEl(id)); return els.get(id); },
querySelector(s) { return mkEl(s); },
querySelectorAll() { return []; },
createElement(t) { const e = mkEl(); e.tagName = t.toUpperCase(); return e; },
addEventListener: addL, body: mkEl('body'),
};
let fail = 0;
const ok = (c, m) => { console.log((c ? ' ✔ ' : ' ✗ ') + m); if (!c) fail++; };
const resetC = () => { for (const k in C) C[k] = 0; };
const { Editor } = await import('../js/editor.js');
const { PALETTE } = await import('../js/editor.js');
console.log('=== 编辑器初始化(会经 buildPalette 渲染全部调色板方块)===');
try {
Editor.init({ startLevel: noop });
ok(true, 'Editor.init 成功(所有调色板方块缩略图渲染无异常)');
} catch (e) { ok(false, 'Editor.init 失败: ' + e.message); console.log(e.stack.split('\n').slice(0, 4).join('\n')); }
console.log('=== 打开空白地图并绘制 ===');
Editor.open();
const f = Editor.curFace();
ok(f && f.w > 0 && f.h > 0, 'open 生成可编辑面(' + f.w + '×' + f.h + ')');
console.log('=== 刺在编辑器里以三角形绘制 ===');
f.grid[1][1] = '^';
resetC();
Editor.draw();
ok(C.lineTo > 0 && C.beginPath > 0, '刺:用三角形图元绘制(lineTo=' + C.lineTo + ', beginPath=' + C.beginPath + ')');
console.log('=== 方块在编辑器里以真实纹理绘制 ===');
f.grid[1][1] = '#';
resetC();
Editor.draw();
ok(C.fillRect > 0, '方块:实心纹理绘制(fillRect=' + C.fillRect + ')');
console.log('=== 每种方块类型在编辑器画布都能正常绘制不抛异常 ===');
let bad = 0;
for (const [ch] of PALETTE) {
if (ch === '.') continue;
try { f.grid[2][2] = ch; Editor.draw(); }
catch (e) { console.log(' ✗ ' + ch + ': ' + e.message); bad++; }
}
ok(bad === 0, '全部 ' + PALETTE.filter(p => p[0] !== '.').length + ' 种方块绘制无异常');
console.log(fail === 0 ? '\n✅ 编辑器所见即所得 全部断言通过' : `\n❌ 失败 ${fail} 项`);
process.exit(fail === 0 ? 0 : 1);
+1 -1
View File
@@ -60,7 +60,7 @@ function run(tail, label, releaseAt = 1e9, jumpAt = 1e9) {
return { log, crossAt, off }; return { log, crossAt, off };
} }
// A:一直按住右键冲出冰面 —— 惯性要把 4.05 平滑收敛到正常走速,不能瞬间刹停也不能一直超速 // A:一直按住右键冲出冰面 —— 惯性要把 4.32 平滑收敛到正常走速,不能瞬间刹停也不能一直超速
{ {
const { off } = run('='.repeat(34), 'A 冰面 -> 普通地面(一直按住右键)'); const { off } = run('='.repeat(34), 'A 冰面 -> 普通地面(一直按住右键)');
const v0 = off[0].vx, v10 = off[10]?.vx ?? 0, v30 = off[30]?.vx ?? 0; const v0 = off[0].vx, v10 = off[10]?.vx ?? 0, v30 = off[30]?.vx ?? 0;
+22 -7
View File
@@ -1,7 +1,7 @@
// 共享可达性 BFS 核心:solve.mjs 与 reach-diag.mjs 共用 // 共享可达性 BFS 核心:solve.mjs 与 reach-diag.mjs 共用
// 用真实物理常量模拟走/落/跳(含二段跳时机、空中转向、松键短跳) // 用真实物理常量模拟走/落/跳(含二段跳时机、空中转向、松键短跳)
export const TILE = 32, PW = 11, PH = 21; 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() { export function setupGlobals() {
const handlers = {}; 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; 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) { 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) { 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 (flip && t === flip) vx = -vx;
if (rel && t === rel && vy < -3) vy = -3; 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。 // 真实行为(player.js):本帧被墙挡住只是没推动,按住方向键下一帧 vx 仍为 ±3。
// 绝不能把 vx 永久清零,否则"贴着墙上升、越过台面边缘后再横移"这条经典路线会丢失。 // 绝不能把 vx 永久清零,否则"贴着墙上升、越过台面边缘后再横移"这条经典路线会丢失。
if (vx !== 0) { if (vx !== 0) {
@@ -83,7 +85,7 @@ export function makeSim(L) {
let x = fx + dir * 3, y = fy - PH, vy = 0, t = 0; let x = fx + dir * 3, y = fy - PH, vy = 0, t = 0;
if (solidAt(x, y, PW, PH, false)) return null; if (solidAt(x, y, PW, PH, false)) return null;
while (t++ < 400) { while (t++ < 400) {
vy = Math.min(vy + GRAV, MAXFALL); vy = Math.min(vy + (vy < 0 ? GRAV : GRAV_FALL), MAXFALL);
const prevBottom = y + PH; const prevBottom = y + PH;
let rem = Math.abs(vy), landed = false; let rem = Math.abs(vy), landed = false;
while (rem > 0.0001) { while (rem > 0.0001) {
@@ -107,7 +109,10 @@ export function makeSim(L) {
/** BFS 可达性:targets=[{rect,type,...}],onState 回调每个新状态 */ /** BFS 可达性:targets=[{rect,type,...}],onState 回调每个新状态 */
export const DEF_DJ = [0, 6, 12, 18, 24, 30]; 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 } = {}) { export function reach(L, { DJSET = DEF_DJ, RELSET = DEF_REL, targets = [], onState = null, maxStates = 40000 } = {}) {
const sim = makeSim(L); 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 }; 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 seen = new Set([key(start)]);
const q = [start]; const q = [start];
let n = 0; 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; for (const tg of targets) if (!tg.hit && sim.overlap(box, tg.rect)) tg.hit = true;
const push = r => { const push = r => {
if (!r || !sim.canStand(r.fx, r.fy)) return; if (!r || !sim.canStand(r.fx, r.fy)) return;
r.fy = canon(r.fx, r.fy);
const k = key(r); const k = key(r);
if (seen.has(k)) return; if (seen.has(k)) return;
seen.add(k); q.push(r); seen.add(k); q.push(r);
+1 -1
View File
@@ -12,7 +12,7 @@ const L = new Level(d);
const byRow = new Map(); const byRow = new Map();
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 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, { const r = reach(L, {
DJSET, RELSET, DJSET, RELSET,
onState: s => { onState: s => {
+91 -2
View File
@@ -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); } } remove() { if (this.parent) { const i = this.parent.children.indexOf(this); if (i >= 0) this.parent.children.splice(i, 1); } }
querySelector() { return new El(); } querySelector() { return new El(); }
querySelectorAll(sel) { return sel === '.tc-btn' ? this.children.filter(c => c._classes.has('tc-btn')) : []; } 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); } addEventListener(type, fn) { (this._ev[type] ||= []).push(fn); }
removeEventListener() { } removeEventListener() { }
dispatch(type, ev) { (this._ev[type] || []).forEach(fn => fn(ev)); } dispatch(type, ev) { (this._ev[type] || []).forEach(fn => fn(ev)); }
@@ -40,6 +41,7 @@ class El {
const byId = new Map(); const byId = new Map();
const touchMap = new Map(); const touchMap = new Map();
const docListeners = {};
globalThis.localStorage = { globalThis.localStorage = {
_d: {}, _d: {},
getItem(k) { return k in this._d ? this._d[k] : null; }, getItem(k) { return k in this._d ? this._d[k] : null; },
@@ -55,8 +57,13 @@ globalThis.document = {
return new El(); return new El();
}, },
querySelectorAll() { return []; }, 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.window = { addEventListener: noop, removeEventListener: noop };
globalThis.addEventListener = (t, fn) => { (docListeners[t] ||= []).push(fn); };
globalThis.removeEventListener = noop;
globalThis.confirm = () => true; globalThis.confirm = () => true;
let fail = 0; 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 { Save } = await import('../js/save.js');
const { Settings } = await import('../js/settings.js'); const { Settings } = await import('../js/settings.js');
const { applyBindings, defaultBindings } = await import('../js/input.js');
console.log('=== 初始化 / 默认布局 ==='); console.log('=== input 键位 API ===');
Settings.init(); 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; const s0 = Save.data.settings;
ok(!!s0 && !!s0.touch, '存档含 settings.touch'); ok(!!s0 && !!s0.touch, '存档含 settings.touch');
const keys = ['left', 'right', 'jump', 'shoot', 'down', 'pause', 'suicide']; 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)); handle.dispatch('pointerup', ev(handle, 176, 566));
ok(wt.left.cx === cx0 && wt.left.cy === cy0, '缩放:不改变按钮位置'); 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} 项`); console.log(fail === 0 ? '\n✅ settings 全部断言通过' : `\n❌ 失败 ${fail} 项`);
process.exit(fail === 0 ? 0 : 1); process.exit(fail === 0 ? 0 : 1);
+1 -1
View File
@@ -6,7 +6,7 @@ setupGlobals();
const { LEVELS } = await import('../js/levels/index.js'); const { LEVELS } = await import('../js/levels/index.js');
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 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; let fail = 0;
console.log(`=== 可达性求解 (DJ=${DJSET} REL=${RELSET}) ===`); console.log(`=== 可达性求解 (DJ=${DJSET} REL=${RELSET}) ===`);
+86
View File
@@ -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);