fix: 优化跳跃和下落手感
This commit is contained in:
1 parent
ada64fe4be
commit
afe2f64b9b
25 files changed
+1197
-188
No files matched your search
@@ -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。
|
||||||
@@ -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)。
|
||||||
@@ -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:// 打不开)。
|
||||||
|
|
||||||
|
|||||||
@@ -3,3 +3,14 @@
|
|||||||
|
|
||||||
该项目由 “腾讯 WorkBuddy" 制作
|
该项目由 “腾讯 WorkBuddy" 制作
|
||||||
|
|
||||||
|
## TODO list
|
||||||
|
|
||||||
|
v1.0:
|
||||||
|
- 所有已有功能基本稳定可用
|
||||||
|
- 实现基本音效,动效,UI美化
|
||||||
|
- 编辑器基本可用,可发布地图
|
||||||
|
- 玩家可以制作地图和发布,其他玩家可以下载游玩
|
||||||
|
- 地图有排行榜功能
|
||||||
|
- 管理员账号可以修改自带地图
|
||||||
|
- 支持web,android,macos,linux, windows
|
||||||
|
- 支持移动端和桌面端
|
||||||
+30
-8
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 左
|
||||||
// 身体(红衣)
|
// 身体(红衣)
|
||||||
|
|||||||
@@ -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', '自制'],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
// 二段跳时机回归(tools/dj-test.mjs)
|
||||||
|
// 历史 bug 链:
|
||||||
|
// ① 全额抵消(旧 `vy = max(vy,0) - DJUMP`):满力一段到顶点后晚几帧接二段,二段塌到接近 0(用户报"二段非常短")
|
||||||
|
// ② 部分抵消(DJ_INERTIA 0.5→0.35):仍有"好沉+高度短"——起跳初速被下落速度从 6.7 吃到 3~4,
|
||||||
|
// 起跳那一下没劲才是"沉"的根源,与上升重力无关
|
||||||
|
// ③ 现行(DJ_INERTIA = 0,同 IWBT 原版):任何时机接二段都满力起跳,初速恒 -DJUMP
|
||||||
|
// 本测试用真实引擎测量:不同时机接二段时,二段自身的提升高度(djRise)。
|
||||||
|
// 通过标准:
|
||||||
|
// · 任意时机接二段都 ≈ 满力(≈60px ≈ 1.9 格)——INERTIA=0 语义,曲线应基本持平
|
||||||
|
// · 满力一段 ≈ 84px(≈2.6 格);顶点接二段总高 ≈ 4.5 格
|
||||||
|
const handlers = {};
|
||||||
|
const addL = (t, fn) => { (handlers[t] = handlers[t] || []).push(fn); };
|
||||||
|
globalThis.addEventListener = addL;
|
||||||
|
globalThis.window = { addEventListener: addL };
|
||||||
|
const key = (t, c) => (handlers[t] || []).forEach(fn => fn({ code: c, preventDefault() { }, repeat: false }));
|
||||||
|
|
||||||
|
const { Level } = await import('../js/level.js');
|
||||||
|
const { Player } = await import('../js/player.js');
|
||||||
|
const { Input } = await import('../js/input.js');
|
||||||
|
|
||||||
|
// 与 jumpdist.mjs 同一块平地:row17 是实心平台
|
||||||
|
const flat = {
|
||||||
|
id: 'test', name: 'T', type: 'jump', width: 44,
|
||||||
|
grid: (() => {
|
||||||
|
const rows = [];
|
||||||
|
rows.push('#'.repeat(44));
|
||||||
|
for (let i = 0; i < 16; i++) rows.push('#' + '.'.repeat(42) + '#');
|
||||||
|
rows.push('#' + '#'.repeat(42) + '#');
|
||||||
|
rows.push('#'.repeat(44));
|
||||||
|
return rows;
|
||||||
|
})(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 满力一段跳(起跳后一直按住);第 djFrame 帧松键、djFrame+1 帧接二段(之后保持按住)
|
||||||
|
// 返回:firstRise 一段顶点提升 / djRise 二段自身提升(接二段位置→二段后最高点)/ total 二段顶点离平台高度
|
||||||
|
function measure(djFrame) {
|
||||||
|
const L = new Level(flat);
|
||||||
|
const P = new Player(L);
|
||||||
|
P.x = 8 * 32; P.y = 17 * 32 - P.h; // 站在 row17 实心平台上
|
||||||
|
P.vx = 0; P.vy = 0; P.grounded = true; P.jumps = 0;
|
||||||
|
const y0 = P.y;
|
||||||
|
key('keydown', 'ShiftLeft'); // 起跳并按住 = 满力一段
|
||||||
|
let apex1 = y0, apex2 = y0, djDone = false, vyAtDj = null, yAtDj = y0;
|
||||||
|
for (let i = 0; i < 240; i++) {
|
||||||
|
const f = i + 1;
|
||||||
|
if (f === djFrame) key('keyup', 'ShiftLeft');
|
||||||
|
if (f === djFrame + 1) { key('keydown', 'ShiftLeft'); vyAtDj = P.vy; yAtDj = P.y; }
|
||||||
|
L.update(1, P, null);
|
||||||
|
P.update(1);
|
||||||
|
Input.endFrame();
|
||||||
|
if (djFrame && f === djFrame + 1 && P.jumps === 2) djDone = true;
|
||||||
|
if (djDone) apex2 = Math.min(apex2, P.y);
|
||||||
|
else apex1 = Math.min(apex1, P.y);
|
||||||
|
if (i > 2 && P.grounded) break;
|
||||||
|
if (P.dead) break;
|
||||||
|
}
|
||||||
|
key('keyup', 'ShiftLeft');
|
||||||
|
Input.endFrame();
|
||||||
|
return { firstRise: y0 - apex1, djRise: djDone ? yAtDj - apex2 : 0, total: y0 - apex2, vyAtDj, djDone };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 满力一段顶点≈第 27 帧;落地≈第 50 帧。取 27~46 帧覆盖"顶点 → 落地前"整个二段窗口
|
||||||
|
const FRAMES = [27, 30, 34, 38, 42, 46];
|
||||||
|
const T = 32;
|
||||||
|
let fail = 0;
|
||||||
|
const rows = [];
|
||||||
|
console.log('满力一段跳后,不同时机接二段:二段自身提升高度(真实引擎实测)');
|
||||||
|
console.log(' 时机 接二段时vy 一段顶点 二段自身提升 二段顶点离平台');
|
||||||
|
for (const f of FRAMES) {
|
||||||
|
const r = measure(f);
|
||||||
|
rows.push({ f, ...r });
|
||||||
|
const dj = r.djDone
|
||||||
|
? `${r.djRise.toFixed(1).padStart(6)}px = ${(r.djRise / T).toFixed(2)} 格`
|
||||||
|
: ' —— 已落地,二段窗口已过 ——';
|
||||||
|
console.log(` 第${String(f).padStart(2)}帧 ${(r.vyAtDj ?? 0).toFixed(2).padStart(5)} ${r.firstRise.toFixed(1).padStart(6)}px ${dj} ${(r.total / T).toFixed(2)} 格`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const at = f => rows.find(r => r.f === f);
|
||||||
|
const need = (cond, msg) => { if (!cond) { fail++; console.log(' ❌ ' + msg); } };
|
||||||
|
|
||||||
|
for (const r of rows) need(r.djDone, `第 ${r.f} 帧应还在二段窗口内(未落地)`);
|
||||||
|
const apex = at(27);
|
||||||
|
need(apex.firstRise > 79 && apex.firstRise < 90, `满力一段高度应 ≈84px(2.6格),实测 ${apex.firstRise.toFixed(1)}`);
|
||||||
|
need(apex.djRise > 52 && apex.djRise < 68, `顶点接二段自身提升应 ≈59px(1.85格),实测 ${apex.djRise.toFixed(1)}`);
|
||||||
|
need(apex.total > 138 && apex.total < 150, `顶点接二段总高应 ≈4.5格,实测 ${(apex.total / T).toFixed(2)}`);
|
||||||
|
// INERTIA=0:任何时机接二段都满力起跳,曲线应基本持平(与顶点差异 ≤3px)
|
||||||
|
for (const r of rows) {
|
||||||
|
if (!r.f) continue;
|
||||||
|
need(r.djRise >= 55, `第 ${r.f} 帧接二段自身提升应 ≈满力(≥55px),实测 ${r.djRise.toFixed(1)}`);
|
||||||
|
need(Math.abs(r.djRise - apex.djRise) <= 3, `第 ${r.f} 帧与顶点接二段差异应 ≤3px(INERTIA=0 曲线持平),实测差 ${Math.abs(r.djRise - apex.djRise).toFixed(1)}`);
|
||||||
|
}
|
||||||
|
need(at(46).djRise >= 55, `即使摔得快二段也不应衰减(INERTIA=0),实测 ${at(46).djRise.toFixed(1)}`);
|
||||||
|
|
||||||
|
if (fail) { console.log(`\n❌ dj-test 失败 ${fail} 项`); process.exit(1); }
|
||||||
|
console.log('\n✅ dj-test 通过:任意时机接二段均满力起跳(INERTIA=0),顶点总高 ≈4.5 格');
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
// 编辑器交互回归:调色板无「查看属性」工具;无论手上拿什么方块(橡皮除外),
|
||||||
|
// 点击画布上任意方块都选中该格并在属性面板显示其属性(不覆盖已有方块)。
|
||||||
|
const noop = () => { };
|
||||||
|
let fail = 0;
|
||||||
|
const ok = (c, m) => { console.log((c ? ' ✔ ' : ' ✗ ') + m); if (!c) fail++; };
|
||||||
|
|
||||||
|
// ---------- 最小假 DOM(addEventListener 收句柄,children 真实入列)----------
|
||||||
|
const els = new Map();
|
||||||
|
function mkEl(id = '') {
|
||||||
|
const e = {
|
||||||
|
id, tagName: 'DIV', value: '', _html: '', textContent: '',
|
||||||
|
style: {}, dataset: {}, children: [],
|
||||||
|
width: 800, height: 608,
|
||||||
|
classList: {
|
||||||
|
_s: new Set(),
|
||||||
|
add(...c) { c.forEach(x => this._s.add(x)); },
|
||||||
|
remove(...c) { c.forEach(x => this._s.delete(x)); },
|
||||||
|
toggle(c, on) { on ? this._s.add(c) : this._s.delete(c); },
|
||||||
|
contains(c) { return this._s.has(c); },
|
||||||
|
},
|
||||||
|
_h: {},
|
||||||
|
addEventListener(t, fn) { (this._h[t] = this._h[t] || []).push(fn); },
|
||||||
|
removeEventListener: noop,
|
||||||
|
appendChild(c) { this.children.push(c); return c; },
|
||||||
|
querySelector: () => mkEl(), querySelectorAll: () => [],
|
||||||
|
getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 608 }),
|
||||||
|
getContext: () => ctx2d, focus: noop, click: noop,
|
||||||
|
preventDefault: noop,
|
||||||
|
};
|
||||||
|
Object.defineProperty(e, 'innerHTML', {
|
||||||
|
get() { return this._html; },
|
||||||
|
set(v) { this._html = String(v); this.children = []; },
|
||||||
|
});
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
const ctx2d = new Proxy({
|
||||||
|
canvas: { width: 800, height: 608 },
|
||||||
|
measureText: () => ({ width: 12 }),
|
||||||
|
createLinearGradient: () => ({ addColorStop: noop }),
|
||||||
|
createRadialGradient: () => ({ addColorStop: noop }),
|
||||||
|
getImageData: () => ({ data: [] }),
|
||||||
|
}, {
|
||||||
|
get(t, k) { return k in t ? t[k] : noop; },
|
||||||
|
set(t, k, v) { t[k] = v; return true; },
|
||||||
|
});
|
||||||
|
const getEl = s => { if (!els.has(s)) els.set(s, mkEl(s)); return els.get(s); };
|
||||||
|
|
||||||
|
globalThis.location = { search: '', href: 'http://localhost/', pathname: '/', reload: noop };
|
||||||
|
Object.defineProperty(globalThis, 'navigator', { value: { maxTouchPoints: 0, userAgent: 'node' }, configurable: true });
|
||||||
|
globalThis.matchMedia = q => ({ matches: false, media: q, addEventListener: noop, removeEventListener: noop });
|
||||||
|
globalThis.window = {
|
||||||
|
addEventListener: noop, removeEventListener: noop, AudioContext: null, webkitAudioContext: null,
|
||||||
|
innerWidth: 1280, innerHeight: 800, location: globalThis.location, navigator: globalThis.navigator, matchMedia: globalThis.matchMedia,
|
||||||
|
};
|
||||||
|
globalThis.addEventListener = noop;
|
||||||
|
globalThis.removeEventListener = noop;
|
||||||
|
globalThis.confirm = () => true;
|
||||||
|
globalThis.alert = noop;
|
||||||
|
globalThis.localStorage = { _d: {}, getItem(k) { return this._d[k] ?? null; }, setItem(k, v) { this._d[k] = String(v); }, removeItem(k) { delete this._d[k]; } };
|
||||||
|
globalThis.document = {
|
||||||
|
getElementById: getEl, querySelector: getEl, querySelectorAll: () => [],
|
||||||
|
createElement(t) { const e = mkEl(); e.tagName = t.toUpperCase(); return e; },
|
||||||
|
addEventListener: noop, body: mkEl('body'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { Editor, PALETTE } = await import('../js/editor.js');
|
||||||
|
|
||||||
|
// 画布坐标 → 格子:pos() 用 clientX/Y(scroll=0,缩放 1:1),格心 (tx*32+16, ty*32+16)
|
||||||
|
const ev = (tx, ty, button = 0) => ({ clientX: tx * 32 + 16, clientY: ty * 32 + 16, button, preventDefault: noop });
|
||||||
|
const clickTile = (tx, ty, button = 0) => {
|
||||||
|
for (const fn of getEl('#ed-canvas')._h.mousedown) fn(ev(tx, ty, button));
|
||||||
|
};
|
||||||
|
// renderProps 先写面板 innerHTML(标题),再把每个属性字段 appendChild 成子行
|
||||||
|
const propsHtml = () => {
|
||||||
|
const box = getEl('#ed-props');
|
||||||
|
return box.innerHTML + box.children.map(c => c.innerHTML || '').join('');
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('=== 调色板不再有「查看属性」工具 ===');
|
||||||
|
ok(!PALETTE.some(p => p[0] === '?'), "PALETTE 中已无 '?'(查看属性)");
|
||||||
|
ok(!PALETTE.some(p => p[1] === '查看属性'), 'PALETTE 中已无「查看属性」条目');
|
||||||
|
Editor.init({ startLevel: noop });
|
||||||
|
const palLabels = getEl('#palette').children.map(c => c.textContent || '');
|
||||||
|
ok(palLabels.length === PALETTE.length, `调色板渲染 ${PALETTE.length} 项(无工具项),实际 ${palLabels.length}`);
|
||||||
|
ok(!palLabels.some(t => t.includes('查看属性')), '调色板 UI 无「查看属性」按钮');
|
||||||
|
|
||||||
|
console.log('=== 打开地图,构造一块带属性的摄像头 C ===');
|
||||||
|
Editor.open();
|
||||||
|
const f = Editor.curFace();
|
||||||
|
f.grid[5][7] = 'C'; // 摄像头:SCHEMA 里有 4 个可编辑属性
|
||||||
|
f.props[5][7] = { w: 4, h: 4, mode: 'zoom', zoom: 1.5 };
|
||||||
|
|
||||||
|
console.log('=== 手上拿刺(^),点击已有摄像头 → 只看属性,不覆盖 ===');
|
||||||
|
Editor.sel = '^';
|
||||||
|
clickTile(7, 5);
|
||||||
|
ok(f.grid[5][7] === 'C', '格子内容未被覆盖(仍是 C)');
|
||||||
|
ok(Editor.selTile && Editor.selTile.tx === 7 && Editor.selTile.ty === 5, 'selTile 指向被点方块 (7,5)');
|
||||||
|
const html1 = propsHtml();
|
||||||
|
ok(html1.includes('区域宽') && html1.includes('镜头模式'), '属性面板显示该方块的属性(区域宽/镜头模式)');
|
||||||
|
|
||||||
|
console.log('=== 手上拿方块(#),点击同一格 → 同样只查看,不覆盖 ===');
|
||||||
|
Editor.sel = '#';
|
||||||
|
clickTile(7, 5);
|
||||||
|
ok(f.grid[5][7] === 'C', '拿 # 点击也不覆盖已有方块');
|
||||||
|
ok(propsHtml().includes('区域宽'), '属性面板仍显示 C 的属性');
|
||||||
|
|
||||||
|
console.log('=== 手上拿炮台(O),点击空格子 → 放置并显示新方块属性 ===');
|
||||||
|
Editor.sel = 'O';
|
||||||
|
clickTile(3, 9);
|
||||||
|
ok(f.grid[9][3] === 'O', '空格子正常放置 O');
|
||||||
|
ok(propsHtml().includes('发射间隔'), '放置后属性面板显示 O 的属性');
|
||||||
|
|
||||||
|
console.log('=== 提示牌 T 的文本属性可随点击查看 ===');
|
||||||
|
f.grid[9][4] = 'T'; f.props[9][4] = { msg: '小心刺' };
|
||||||
|
Editor.sel = '%'; // 拿着冰面点提示牌
|
||||||
|
clickTile(4, 9);
|
||||||
|
ok(f.grid[9][4] === 'T' && propsHtml().includes('提示文字'), '非相关工具点击 T 也能查看文字属性');
|
||||||
|
|
||||||
|
console.log('=== 橡皮擦例外:选中橡皮点击 → 擦除 ===');
|
||||||
|
Editor.sel = '.';
|
||||||
|
clickTile(4, 9);
|
||||||
|
ok(f.grid[9][4] === '.', '橡皮擦点击后 T 被擦除');
|
||||||
|
Editor.sel = '^'; // 恢复非橡皮工具
|
||||||
|
clickTile(7, 5); // 顺手确认 C 还在
|
||||||
|
|
||||||
|
console.log('=== 右键擦除与工具无关 ===');
|
||||||
|
Editor.sel = '#';
|
||||||
|
clickTile(3, 9, 2); // button=2 右键
|
||||||
|
ok(f.grid[9][3] === '.', '右键擦除不受手上工具影响');
|
||||||
|
|
||||||
|
console.log('=== 属性面板空态提示仍存在 ===');
|
||||||
|
Editor.show(); // 重置 selTile
|
||||||
|
ok(propsHtml().includes('点击画布上的方块'), '未选中时显示引导文案');
|
||||||
|
|
||||||
|
console.log(fail === 0 ? '\n✅ 编辑器点击查看属性 全部断言通过' : `\n❌ 失败 ${fail} 项`);
|
||||||
|
process.exit(fail === 0 ? 0 : 1);
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
// 编辑器所见即所得测试:刺以三角形绘制 + 每种方块都能正常渲染
|
||||||
|
const noop = () => { };
|
||||||
|
const els = new Map();
|
||||||
|
|
||||||
|
// 记录关键绘制调用的计数器(证明刺用三角形图元、方块用真实纹理)
|
||||||
|
const C = { lineTo: 0, fillRect: 0, beginPath: 0, moveTo: 0, arc: 0 };
|
||||||
|
const ctx2d = new Proxy({
|
||||||
|
canvas: { width: 800, height: 608 },
|
||||||
|
measureText: () => ({ width: 12 }),
|
||||||
|
createLinearGradient: () => ({ addColorStop: noop }),
|
||||||
|
createRadialGradient: () => ({ addColorStop: noop }),
|
||||||
|
getImageData: () => ({ data: [] }),
|
||||||
|
}, {
|
||||||
|
get(t, k) { return k in t ? t[k] : noop; },
|
||||||
|
set(t, k, v) { t[k] = v; return true; },
|
||||||
|
});
|
||||||
|
// 装上计数器
|
||||||
|
ctx2d.lineTo = () => C.lineTo++;
|
||||||
|
ctx2d.fillRect = () => C.fillRect++;
|
||||||
|
ctx2d.beginPath = () => C.beginPath++;
|
||||||
|
ctx2d.moveTo = () => C.moveTo++;
|
||||||
|
ctx2d.arc = () => C.arc++;
|
||||||
|
|
||||||
|
function mkEl(id = '') {
|
||||||
|
return {
|
||||||
|
id, tagName: 'DIV', value: '', textContent: '', innerHTML: '',
|
||||||
|
style: {}, dataset: {}, children: [], width: 800, height: 608,
|
||||||
|
classList: { add: noop, remove: noop, toggle: noop, contains: () => false },
|
||||||
|
addEventListener: noop, removeEventListener: noop, appendChild: noop,
|
||||||
|
querySelector: () => mkEl(), querySelectorAll: () => [],
|
||||||
|
getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 608 }),
|
||||||
|
getContext: () => ctx2d, focus: noop, click: noop,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const handlers = {};
|
||||||
|
const addL = (t, fn) => { (handlers[t] = handlers[t] || []).push(fn); };
|
||||||
|
globalThis.location = { search: '', href: 'http://localhost/', pathname: '/', reload: noop };
|
||||||
|
Object.defineProperty(globalThis, 'navigator', { value: { maxTouchPoints: 0, userAgent: 'node' }, configurable: true });
|
||||||
|
globalThis.matchMedia = q => ({ matches: false, media: q, addEventListener: noop, removeEventListener: noop });
|
||||||
|
globalThis.window = {
|
||||||
|
addEventListener: addL, removeEventListener: noop, AudioContext: null, webkitAudioContext: null,
|
||||||
|
innerWidth: 1280, innerHeight: 800, location: globalThis.location, navigator: globalThis.navigator, matchMedia: globalThis.matchMedia,
|
||||||
|
};
|
||||||
|
globalThis.addEventListener = addL;
|
||||||
|
globalThis.removeEventListener = noop;
|
||||||
|
globalThis.confirm = () => true;
|
||||||
|
globalThis.alert = noop;
|
||||||
|
globalThis.localStorage = { _d: {}, getItem(k) { return this._d[k] ?? null; }, setItem(k, v) { this._d[k] = String(v); }, removeItem(k) { delete this._d[k]; } };
|
||||||
|
globalThis.document = {
|
||||||
|
getElementById(id) { if (!els.has(id)) els.set(id, mkEl(id)); return els.get(id); },
|
||||||
|
querySelector(s) { return mkEl(s); },
|
||||||
|
querySelectorAll() { return []; },
|
||||||
|
createElement(t) { const e = mkEl(); e.tagName = t.toUpperCase(); return e; },
|
||||||
|
addEventListener: addL, body: mkEl('body'),
|
||||||
|
};
|
||||||
|
|
||||||
|
let fail = 0;
|
||||||
|
const ok = (c, m) => { console.log((c ? ' ✔ ' : ' ✗ ') + m); if (!c) fail++; };
|
||||||
|
const resetC = () => { for (const k in C) C[k] = 0; };
|
||||||
|
|
||||||
|
const { Editor } = await import('../js/editor.js');
|
||||||
|
const { PALETTE } = await import('../js/editor.js');
|
||||||
|
|
||||||
|
console.log('=== 编辑器初始化(会经 buildPalette 渲染全部调色板方块)===');
|
||||||
|
try {
|
||||||
|
Editor.init({ startLevel: noop });
|
||||||
|
ok(true, 'Editor.init 成功(所有调色板方块缩略图渲染无异常)');
|
||||||
|
} catch (e) { ok(false, 'Editor.init 失败: ' + e.message); console.log(e.stack.split('\n').slice(0, 4).join('\n')); }
|
||||||
|
|
||||||
|
console.log('=== 打开空白地图并绘制 ===');
|
||||||
|
Editor.open();
|
||||||
|
const f = Editor.curFace();
|
||||||
|
ok(f && f.w > 0 && f.h > 0, 'open 生成可编辑面(' + f.w + '×' + f.h + ')');
|
||||||
|
|
||||||
|
console.log('=== 刺在编辑器里以三角形绘制 ===');
|
||||||
|
f.grid[1][1] = '^';
|
||||||
|
resetC();
|
||||||
|
Editor.draw();
|
||||||
|
ok(C.lineTo > 0 && C.beginPath > 0, '刺:用三角形图元绘制(lineTo=' + C.lineTo + ', beginPath=' + C.beginPath + ')');
|
||||||
|
|
||||||
|
console.log('=== 方块在编辑器里以真实纹理绘制 ===');
|
||||||
|
f.grid[1][1] = '#';
|
||||||
|
resetC();
|
||||||
|
Editor.draw();
|
||||||
|
ok(C.fillRect > 0, '方块:实心纹理绘制(fillRect=' + C.fillRect + ')');
|
||||||
|
|
||||||
|
console.log('=== 每种方块类型在编辑器画布都能正常绘制不抛异常 ===');
|
||||||
|
let bad = 0;
|
||||||
|
for (const [ch] of PALETTE) {
|
||||||
|
if (ch === '.') continue;
|
||||||
|
try { f.grid[2][2] = ch; Editor.draw(); }
|
||||||
|
catch (e) { console.log(' ✗ ' + ch + ': ' + e.message); bad++; }
|
||||||
|
}
|
||||||
|
ok(bad === 0, '全部 ' + PALETTE.filter(p => p[0] !== '.').length + ' 种方块绘制无异常');
|
||||||
|
|
||||||
|
console.log(fail === 0 ? '\n✅ 编辑器所见即所得 全部断言通过' : `\n❌ 失败 ${fail} 项`);
|
||||||
|
process.exit(fail === 0 ? 0 : 1);
|
||||||
+1
-1
@@ -60,7 +60,7 @@ function run(tail, label, releaseAt = 1e9, jumpAt = 1e9) {
|
|||||||
return { log, crossAt, off };
|
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
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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}) ===`);
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
// 单三角形刺:视觉与碰撞形状完全对应的单元测试
|
||||||
|
// 验证 spikeTri 顶点、triRectOverlap 的正确性,以及 deadlyRect 对刺的判定。
|
||||||
|
const noop = () => { };
|
||||||
|
const ctx2d = new Proxy({
|
||||||
|
canvas: { width: 800, height: 608 },
|
||||||
|
measureText: () => ({ width: 12 }),
|
||||||
|
createLinearGradient: () => ({ addColorStop: noop }),
|
||||||
|
createRadialGradient: () => ({ addColorStop: noop }),
|
||||||
|
getImageData: () => ({ data: [] }),
|
||||||
|
}, { get(t, k) { return k in t ? t[k] : noop; }, set(t, k, v) { t[k] = v; return true; } });
|
||||||
|
|
||||||
|
globalThis.location = { search: '', href: 'http://localhost/', pathname: '/', reload: noop };
|
||||||
|
Object.defineProperty(globalThis, 'navigator', { value: { maxTouchPoints: 0, userAgent: 'node' }, configurable: true });
|
||||||
|
globalThis.matchMedia = q => ({ matches: false, media: q, addEventListener: noop, removeEventListener: noop });
|
||||||
|
globalThis.window = { addEventListener: noop, removeEventListener: noop, AudioContext: null, webkitAudioContext: null, innerWidth: 1280, innerHeight: 800, location: globalThis.location, navigator: globalThis.navigator, matchMedia: globalThis.matchMedia };
|
||||||
|
globalThis.document = { getElementById: () => ({ getContext: () => ctx2d, style: {}, classList: { add: noop, remove: noop, toggle: noop }, addEventListener: noop, appendChild: noop, querySelector: () => ({ getContext: () => ctx2d }), querySelectorAll: () => [] }), querySelector: () => ({ getContext: () => ctx2d }), querySelectorAll: () => [], createElement: () => ({ getContext: () => ctx2d, style: {}, classList: { add: noop, remove: noop } }), addEventListener: noop, body: {} };
|
||||||
|
globalThis.localStorage = { _d: {}, getItem(k) { return this._d[k] ?? null; }, setItem(k, v) { this._d[k] = String(v); }, removeItem(k) { delete this._d[k]; } };
|
||||||
|
globalThis.confirm = () => true;
|
||||||
|
globalThis.alert = noop;
|
||||||
|
|
||||||
|
const { Level, T, spikeTri, triRectOverlap } = await import('../js/level.js');
|
||||||
|
const { TILE } = await import('../js/util.js');
|
||||||
|
|
||||||
|
let fail = 0;
|
||||||
|
const ok = (c, m) => { console.log((c ? ' ✔ ' : ' ✗ ') + m); if (!c) fail++; };
|
||||||
|
const near = (a, b, e = 1e-6) => Math.abs(a - b) < e;
|
||||||
|
|
||||||
|
console.log('=== spikeTri 顶点(与 drawSpike 一致)===');
|
||||||
|
const s = TILE;
|
||||||
|
{
|
||||||
|
const tr = spikeTri(T.SPIKE_U, 0, 0);
|
||||||
|
ok(near(tr.ax, s / 2) && near(tr.ay, 0) && near(tr.bx, 0) && near(tr.by, s) && near(tr.cx, s) && near(tr.cy, s), 'SPIKE_U:尖在顶中,底在底部');
|
||||||
|
const tr2 = spikeTri(T.SPIKE_D, 0, 0);
|
||||||
|
ok(near(tr2.ax, s / 2) && near(tr2.ay, s) && near(tr2.bx, 0) && near(tr2.by, 0), 'SPIKE_D:尖在底中,底在顶部');
|
||||||
|
const tr3 = spikeTri(T.SPIKE_L, 0, 0);
|
||||||
|
ok(near(tr3.ax, 0) && near(tr3.ay, s / 2) && near(tr3.bx, s) && near(tr3.by, 0) && near(tr3.cx, s) && near(tr3.cy, s), 'SPIKE_L:尖在左中,底在右侧');
|
||||||
|
const tr4 = spikeTri(T.SPIKE_R, 0, 0);
|
||||||
|
ok(near(tr4.ax, s) && near(tr4.ay, s / 2) && near(tr4.bx, 0) && near(tr4.by, 0), 'SPIKE_R:尖在右中,底在左侧');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('=== triRectOverlap 基本几何 ===');
|
||||||
|
{
|
||||||
|
// 上刺三角形:A(16,0) B(0,32) C(32,32)(TILE=32)
|
||||||
|
const A = spikeTri(T.SPIKE_U, 0, 0);
|
||||||
|
// 完全在三角形内部的矩形
|
||||||
|
ok(triRectOverlap(A.ax, A.ay, A.bx, A.by, A.cx, A.cy, 14, 20, 4, 4), '三角形内部矩形 → 相交');
|
||||||
|
// 在三角形“上方窄尖之外”的矩形(顶部左侧,AABB 内但三角外)
|
||||||
|
ok(!triRectOverlap(A.ax, A.ay, A.bx, A.by, A.cx, A.cy, 1, 1, 4, 4), '尖顶外侧矩形(AABB 内、三角外)→ 不相交');
|
||||||
|
// 完全在三角形之外(远离)
|
||||||
|
ok(!triRectOverlap(A.ax, A.ay, A.bx, A.by, A.cx, A.cy, 100, 100, 4, 4), '远处矩形 → 不相交');
|
||||||
|
// AABB 都不重叠
|
||||||
|
ok(!triRectOverlap(A.ax, A.ay, A.bx, A.by, A.cx, A.cy, 50, 0, 4, 4), 'AABB 不重叠 → 不相交');
|
||||||
|
// 底边左角(上刺底部很宽)——应当相交
|
||||||
|
ok(triRectOverlap(A.ax, A.ay, A.bx, A.by, A.cx, A.cy, 1, 28, 4, 4), '上刺底左角矩形 → 相交(形状精确:底宽尖窄)');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('=== deadlyRect 集成(上刺占一格)===');
|
||||||
|
{
|
||||||
|
const data = { name: 't', width: 3, grid: ['###', '#^#', '###'] };
|
||||||
|
const L = new Level(data);
|
||||||
|
ok(L.tiles[1][1] === T.SPIKE_U, '网格解析:中心格子为 SPIKE_U');
|
||||||
|
const cx = 1 * s, cy = 1 * s; // 刺格像素 [32,32]-[64,64]
|
||||||
|
// 顶部左侧(尖窄处,三角外)→ 不致死
|
||||||
|
ok(!L.deadlyRect(cx + 1, cy + 1, 4, 4), '上刺尖顶外侧的碰撞盒 → 不致死');
|
||||||
|
// 底部中央(基底宽处)→ 致死
|
||||||
|
ok(L.deadlyRect(cx + 13, cy + 26, 8, 4), '上刺底部中央的碰撞盒 → 致死');
|
||||||
|
// 底左角 → 致死
|
||||||
|
ok(L.deadlyRect(cx + 1, cy + 26, 6, 4), '上刺底左角的碰撞盒 → 致死(形状精确)');
|
||||||
|
// 相邻空格(格外)→ 不致死
|
||||||
|
ok(!L.deadlyRect(0, 0, 11, 21), '完全在空地上的碰撞盒 → 不致死');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('=== 形状精确性对比:旧矩形近似会把底左角判为安全,现在判为致死 ===');
|
||||||
|
{
|
||||||
|
// 旧 spikeBox 对上刺 x+6..x+10, y+8.. :底左角 (cx+1,cy+26) 落在 x+6 之外,旧逻辑安全;
|
||||||
|
// 新三角在底部很宽,应致死。下面断言新逻辑与“旧近似矩形”在该点结论相反(证明形状更精确)。
|
||||||
|
const data = { name: 't', width: 3, grid: ['###', '#^#', '###'] };
|
||||||
|
const L = new Level(data);
|
||||||
|
const cx = s, cy = s;
|
||||||
|
const oldSafe = (cx + 1 < cx + 6); // 旧矩形左界 x+6,该点在其左 → 旧逻辑“安全”
|
||||||
|
const newDeadly = L.deadlyRect(cx + 1, cy + 26, 3, 3);
|
||||||
|
ok(oldSafe && newDeadly, '底左角:旧矩形近似判安全,新三角判致死(符合“底宽尖窄”的真实形状)');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(fail === 0 ? '\n✅ 单三角形刺(视觉=碰撞)全部断言通过' : `\n❌ 失败 ${fail} 项`);
|
||||||
|
process.exit(fail === 0 ? 0 : 1);
|
||||||
Reference in new issue
Block a user