fix: 修复了冰块惯性问题和死亡效果

This commit is contained in:
cancerai committed 2026-10-22 19:43:01 +08:00
1 parent 47a462be01
commit 4b3dfd635b
15 files changed
+633 -38

No files matched your search

+57
View File
@@ -0,0 +1,57 @@
# 2026-10-22
## 工作区被清空后从对话记录重建 + 移动端虚拟手柄
- 本会话开始时 `/Users/cancerai/tmp/iwanna` 被清空(workspace_recovery 重建为空目录)。游戏全部源码并未丢失:完整对话记录在 `/Users/cancerai/.workbuddy/projects/Users-cancerai-tmp-iwanna/dd6693af-142a-4164-8c3a-fb33ab9626d8.jsonl`(约 7.9MB)。
- 重建方法:按 JSONL 行序重放所有 `Write`/`Edit` 调用;另有 7 处**通过 Bash 的 sed/perl 修改项目文件**(diamond→vgap、R-3→R-2、corner→block、j2 台阶加宽、3 个测试脚本 `mv` 进 `tools/` 并改 import)必须在对应行号处同步插值,否则 `jump.js` 大量 Edit 会 miss。重建后 `check.mjs` 与 `solve.mjs` 全部通过(23 关可达)。
- 新增移动端虚拟手柄:
- `js/input.js` 增加 `touch` 状态层 + `setTouch(action,bool)` / `clearTouch()`,与键盘共用 `now/prev` action 模型。
- 新模块 `js/touch.js`:左下 `◀▶` 移动、右下大「跳」键 + 🔫射击(按住连发)+ ▼下蹲 + 暂停/重生小键;pointer 事件 + `setPointerCapture` 支持多指;`show/hide/setGun`。
- `index.html` 加 `#touch` 覆盖层 + viewport 锁缩放;`css/style.css` 加 IWBT 风格半透明控件(含 `env(safe-area-inset-bottom)` 安全区)。
- `js/main.js`:触屏自动检测(`?touch=1` 强制开 / `?touch=0` 关);`startLevel`/`resume` 显示、`pause`/`finish`/菜单隐藏;HUD 同步 `hasGun` 控制射击键置灰;触屏射击走 `shoot()` 回调(受 `hasGun` 与冷却限制)。
- 验证:`node --check` 三个 JS 均通过;mock-DOM 运行测试确认按下/松开正确置位、pause/shoot 回调触发、hide 清理卡键;`check`/`solve` 仍全绿。
## 桌面端不显示触屏按钮(用户明确要求)
- 原来只靠 JS 切换 `#touch.hidden` 类来显隐,若某处误调 `Touch.show()` 桌面端会冒出按钮。
- 改为「CSS 硬兜底 + `<html>` 类门控」:仅当检测到移动端/触屏(`ontouchstart` / `maxTouchPoints>0` / `(pointer:coarse)`)或 `?touch=1` 时,给 `documentElement` 加 `.touch` 类;`css/style.css` 新增 `html:not(.touch) #touch{display:none !important}`。这样桌面端无论 JS 状态如何都绝对不显示,触屏设备才显示。`?touch=0` 仍可强制关。
- 验证:node 模拟 5 种环境(纯桌面/触屏笔电/手机/`?touch=1`/`?touch=0`),输出与预期一致;服务端 `css/style.css` 已含该规则。
## 修复:滑出冰块后惯性丢失
- 根因:`js/player.js` 水平移动里 `onIce` 每帧按当前位置重算;一旦脚下滑出冰面,`onIce=false` 走 `else` 分支 `this.vx = target`,把冰面攒出的速度瞬间归零。
- 修复:新增 `this.sliding` 状态。在冰上攒出 `|vx|>0.6` 即标记 `sliding=true`;离开冰后只要 `sliding` 且 `|vx|>0.05`,就用比冰面稍大的地面摩擦(0.25)缓慢收速,保留惯性;顺向按键保持/恢复满速、反向按键较快抵消;`|vx|<0.1` 才清零并退出滑行,恢复 IWBT 标准「松手即停」。`reset/respawn/kill` 三处都重置 `sliding=false`。
- 验证:node 模拟「冰上按右 20 帧→vx=3.60,滑出冰面松手后 12 帧 vx 由 3.35 缓降到 0.60 仍滑行」;`solve.mjs` 仍全绿(可达性未退化)。
## 二次修复:滑出冰块后仍「感觉不到惯性」(真正根因)
- 上次只解决了「离冰瞬间归零」,但残留两个致命点(用 `tools/ice-test.mjs` 实测暴露):
1. **空中也按地面摩擦 0.25 衰减**(`roadFric` 不区分空/地)→ 滑出冰块边缘掉下去时水平速度 7 帧内归零,根本飞不出去,这是玩家最直观感到「没惯性」的场景。
2. 松开键时 0.25 的强摩擦一下刹死(实测只滑 2.5px);而按住同向键反而每帧 +0.5 一路涨到 4.05 且永不回落,滑行比走路还快。
- 现在 `PHY` 新增滑行常量:`SLIDE_FRIC 0.12`(地面收住)/ `SLIDE_AIR 0.02`(空中几乎无损)/ `SLIDE_EASE 0.14`(超速时收敛回走速)/ `SLIDE_BRAKE 0.45`(反向刹车)/ `SLIDE_MIN 0.35` / `SLIDE_STOP 0.05`;`ICE_MAX = HSPD*1.35`。
- 行为约定:空中保留惯性;地面缓慢收住(4.05→0 约 34 帧 ≈ 70px);按住同向键只让超速**收敛**回 3(不再加速),低于走速即交还玩家控制;反向键刹车后立刻反向;撞墙/归零即退出滑行。
- 另加可视化反馈:超速滑行且着地时每 5 帧在脚下溅 2 粒冰屑(`js/player.js`),让惯性看得见。
- 新增回归测试 `tools/ice-test.mjs`(4 个场景 + 断言,退出码 0/1),已并入全套:`check / solve / smoke / save-test / key-test / dom-smoke` 全绿。
- 顺手补了 `tools/key-test.mjs` 与 `tools/dom-smoke.mjs` 缺失的浏览器桩:`location`、以及移动端手柄引入后需要的 `navigator`(用 `Object.defineProperty` 覆盖,Node 22 只读) 与 `matchMedia`——这两个脚本此前在 Node 22 下直接崩,现在可跑。
## 死亡即复活(无死亡动画)
- 用户要求:kid 死亡后立刻复活,不要死亡动画。
- `js/player.js`:`kill()` 去掉粒子爆炸、震屏、死亡张嘴,只留 `sfx.death()` 与 `onDeath` 计数;`update()` 顶部 `if (this.dead) this.respawn();` 让 R 键自杀/假存档触发的死亡下帧即复;边界坠落与 `deadlyRect` 两处致死点改为 `kill()` 后**同帧** `respawn()` 并 `return`(不再等 34 帧的死亡动画);`respawn()` 末尾置 `justRespawned=true`。
- `js/main.js`:复活帧镜头 `snapTo` 瞬移(`wasDead && !P.dead` 或 `justRespawned`);删除渲染里的死亡血色闪烁 `rgba(200,0,40,.18)`。
- `js/sprites.js`:删除 `p.dead` 时的张嘴绘制。
- 新增回归测试 `tools/death-test.mjs`(10 项断言:同帧复活/无粒子/无震屏/justRespawned/连续 5 次/R 键自杀后立即可操作/30 帧无特效残留)。全套 7 个测试仍全绿。
## 死亡地点加 IWBTG 风格血迹效果(用户追加要求)
- 需求:即时复活仍保留,但在**死亡地点**留下模仿 IWBT(I Wanna Be The Guy)的 kid 死亡效果(血腥飞溅)。
- 实现:在 `js/level.js` 新增常驻 `deathMarks` 列表(仅 `reset(true)` 整关重开时清空,`softReset()` 不碰 → 复活后血迹仍在)。
- `addDeathMark(x,y)`:在死亡坐标落一处血洼 + 10 个卫星血点(基于 seed 的稳定伪随机,形状逐帧一致),外加 18 个红色方块飞溅碎块(自带 parts 列表,受重力、~32 帧后消失);最多保留 60 处。
- `drawDeathMark(ctx,dm)`:世界坐标下绘制常驻血迹 + 动画碎块;放在 `draw()` 的实体/子弹之后、全局粒子之前。
- `update()` 末尾演化各 mark 的碎块(重力、衰减、移除死亡碎块),血点常驻。
- `js/player.js` 的 `kill()`:`sfx.death()` 后调用 `this.level.addDeathMark(this.cx, this.cy)`(死亡坐标)。仍不触发震屏/屏幕级粒子(保持「无死亡动画」)。
- `tools/death-test.mjs` 扩充到 14 项:新增「死亡地点留下 IWBT 血迹」「30 帧后血迹常驻」「多次死亡血迹累积」「整关重开清空」。全套仍全绿。
## 死亡血迹改为「只显示上一次死亡」(用户再次调整)
- 用户要求:多次死亡时只显示**上一次**死亡的血迹,不要累积成尸体堆。
- `js/level.js` 的 `addDeathMark` 改为新增前先 `this.deathMarks.length = 0`(清掉旧血迹),所以任意时刻场上仅保留最近一次死亡的血迹;整关重开 `reset(true)` 仍清空。
- `tools/death-test.mjs` 地图 row2 加第二枚刺(col10),测试 5 改为「4 次死亡(前后两处)后 `deathMarks.length === 1` 且位于最后一次 col10」,验证只保留上一次。全套仍全绿。
## 死亡标记从血迹改为「红叉(X)」(用户再次调整)
- 用户要求:把死亡效果从血洼/血点/飞溅碎块改成简单的**红叉**即可。
- `js/level.js`:`addDeathMark` 只记 `{x,y}`(不再生成 parts 碎块);`drawDeathMark` 改为画一个带深色描边的红色 X(半边长 9,lineWidth 5 黑描边 + 3 红主体);`update()` 去掉碎块演化循环(红叉为常驻静态标记)。仍只在死亡地点显示、只保留上一次、整关重开清空。
- `tools/death-test.mjs` 措辞「血迹」→「红叉」。全套仍全绿。
+14
View File
@@ -5,6 +5,12 @@
- 玩家判定盒 **11×21**(`Player` 构造函数里 `this.w = 11; this.h = 21`)。改这里会同时影响 `level.js` 的 `spawn` 偏移和全部可达性工具。
- 刺判定盒在 `js/level.js` 的 `spikeBox()`,已按刺尖收紧(上刺 y+8 起、左右刺 x+5 起等),留出像素级擦弹窗口。`T.KILLER` 必须有兜底 return,否则 `deadlyRect` 会在坑向关崩溃。
## 冰面滑行惯性(易反复踩坑)
- 冰面物理只在 `js/player.js` 的水平输入段,`sliding` 是唯一惯性开关:冰上 `|vx|>SLIDE_MIN` 置位,离冰后仍按 `sliding` 走惯性分支,撞墙/归零/交还控制时才清位。
- **空中与地面必须分开摩擦**:`SLIDE_AIR 0.02`(几乎无损,能滑出冰块边缘飞出去)vs `SLIDE_FRIC 0.12`(地面约 34 帧/70px 收住)。曾经两者都用 0.25,导致「滑出冰块掉下去 7 帧就没速度」——玩家最直观的「没惯性」就来自这里。
- 按住同向键只能让超速**收敛**回 `HSPD`(`SLIDE_EASE`),不能继续加速;低于走速立刻 `vx=target` 交还控制。反向键 `SLIDE_BRAKE` 刹车后马上反向。
- 回归测试:`tools/ice-test.mjs`(冰→普通地面/悬空/起跳 4 场景 + 断言),改冰面物理必跑。
## 关卡设计硬规则(都踩过坑)
- `tools/solve.mjs` 是唯一权威:改完任何地图必须跑到「✅ 所有关卡终点与存档点均可达」。
- 之字竖井用 `needle.js` 的 `well(xL, xR, topRow, botRow, floorSide)`:每级升 2 格、台阶宽 5 且两级重叠 2 格、顶台与上层地板**同高相接**、靠地板侧井壁从 topRow+1 到 botRow-4(底部留 3 格入口)。
@@ -23,3 +29,11 @@
## 关卡现状(23 关)
跳刺 j1~j10(68×52 ~ 150×44,刺密度 3.8%~11.6%)、耐久 e1~e4、boss b1~b3(可达性求解跳过)、坑向 r1~r4。
## 移动端虚拟手柄(2026-10-22 新增)
- 触屏自动检测:`matchMedia('(pointer: coarse)')` / `ontouchstart` / `maxTouchPoints>0`;`?touch=1` 强制开、`?touch=0` 关(桌面预览用)。
- `js/input.js` 的 `touch` 状态层 + `setTouch/clearTouch` 与键盘共用 action 模型(`now/prev`),不破坏可达性工具。
- `js/touch.js`:左下 `◀▶` 移动、右下大「跳」+ 🔫射击(按住连发,受 `hasGun`/冷却限制)+ ▼下蹲 + 暂停/重生小键;pointer 事件支持多指,`hide()` 会 `clearTouch()` 防卡键。
- 仅在 `playing` 态显示(`startLevel`/`resume` 显示,菜单/暂停/结算隐藏);HUD 每帧 `Touch.setGun(P.hasGun)` 控制射击键置灰。
- 若改键位含义,记得同步 `js/input.js` 的 `BIND` 与 `touch.js` 的 `HELD`/`api` 映射。
+45 -1
View File
@@ -5,7 +5,7 @@
--gold:#ffc63a; --green:#3ddc84; --blue:#4aa8ff; --purple:#a86bff;
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%}
html,body{height:100%;overscroll-behavior:none}
body{
background:
radial-gradient(circle at 50% 20%, #1a1a28 0%, #08080d 70%);
@@ -178,3 +178,47 @@ canvas#ed-canvas{
.palette{width:100%;flex-direction:row;flex-wrap:wrap;border-right:none;border-bottom:1px solid var(--line);padding-bottom:6px}
.editor-side{width:100%;border-left:none;border-top:1px solid var(--line);padding-top:8px}
}
/* ---------- 移动端虚拟手柄(参考 IWBT Creator 触屏布局) ---------- */
/* 桌面端兜底:只要 <html> 没有 .touch 类(即未检测到移动端/触屏),无论如何都不显示按钮 */
html:not(.touch) #touch{display:none !important}
#touch{
position:absolute;inset:0;z-index:25;
display:flex;justify-content:space-between;align-items:flex-end;
padding:12px 12px max(14px, env(safe-area-inset-bottom));
pointer-events:none; /* 仅按钮可点,中间区域穿透到画布 */
touch-action:none;
}
#touch.hidden{display:none}
.tc-left,.tc-right{display:flex;gap:14px;align-items:flex-end;pointer-events:none}
.tc-right{flex-direction:column;align-items:flex-end}
.tc-top{display:flex;gap:10px;margin-bottom:10px}
.tc-mid{display:flex;gap:12px;align-items:flex-end;margin-bottom:10px}
.tc-btn{
pointer-events:auto;-webkit-user-select:none;user-select:none;touch-action:none;
background:rgba(18,18,28,.52);border:2px solid rgba(255,255,255,.28);
color:#fff;font-family:inherit;display:flex;align-items:center;justify-content:center;
flex-direction:column;line-height:1;backdrop-filter:blur(2px);
box-shadow:0 2px 8px rgba(0,0,0,.35);
transition:transform .05s, background .1s, filter .1s;
}
.tc-btn span{font-size:1.5em}
.tc-btn .lbl{font-size:.6em;letter-spacing:1px;opacity:.85;margin-top:2px}
.tc-btn.on,.tc-btn:active{transform:scale(.93);background:rgba(224,48,80,.6);border-color:#ff5c7a}
.tc-move{width:62px;height:62px;border-radius:14px}
.tc-item{width:54px;height:54px;border-radius:50%}
.tc-sm{width:42px;height:42px;border-radius:11px;font-size:1em}
.tc-jump{
width:94px;height:94px;border-radius:50%;margin-top:4px;
background:radial-gradient(circle at 35% 30%, #ff7088, #c01a3a);
border:3px solid #ffd0da;box-shadow:0 6px 18px rgba(192,26,58,.5);
font-size:1.9em;font-weight:bold;color:#fff;
}
.tc-jump.on,.tc-jump:active{transform:scale(.92);filter:brightness(1.12)}
/* 未持枪时射击键置灰 */
#tc-gun.off{opacity:.4;filter:grayscale(1);background:rgba(18,18,28,.4)}
+23 -1
View File
@@ -2,7 +2,7 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>I WANNA BE THE CREATOR — Fan Project</title>
<link rel="stylesheet" href="css/style.css">
</head>
@@ -79,6 +79,7 @@
<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">
@@ -169,6 +170,27 @@
</div>
</div>
</div>
<!-- ===== TOUCH CONTROLS(移动端虚拟手柄,参考 IWBT Creator 触屏布局) ===== -->
<div id="touch" class="hidden">
<!-- 左侧:左右移动 -->
<div class="tc-left">
<button class="tc-btn tc-move" data-touch="left" aria-label="左移"><span>◀</span></button>
<button class="tc-btn tc-move" data-touch="right" aria-label="右移"><span>▶</span></button>
</div>
<!-- 右侧:跳跃 + 各种道具 -->
<div class="tc-right">
<div class="tc-top">
<button class="tc-btn tc-sm" data-touch="pause" aria-label="暂停">❚❚</button>
<button class="tc-btn tc-sm" data-touch="suicide" aria-label="重生">↺</button>
</div>
<div class="tc-mid">
<button class="tc-btn tc-item" id="tc-gun" data-touch="shoot" aria-label="射击"><span>🔫</span><span class="lbl">射击</span></button>
<button class="tc-btn tc-item" data-touch="down" aria-label="下蹲/下穿"><span>▼</span><span class="lbl">下蹲</span></button>
</div>
<button class="tc-btn tc-jump" data-touch="jump" aria-label="跳跃"><span>跳</span></button>
</div>
</div>
</div>
</div>
<script type="module" src="js/main.js"></script>
+15
View File
@@ -15,6 +15,7 @@ const BIND = {
const down = new Set(); // 当前按住的原始 code
const now = {}; // action 是否按下
const prev = {}; // 上一帧
const touch = {}; // action -> bool(来自触屏虚拟按键)
const blocked = new Set(['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Tab']);
export const Input = {
@@ -44,10 +45,24 @@ function refresh() {
for (const act in BIND) {
let v = false;
for (const c of BIND[act]) if (down.has(c)) { v = true; break; }
if (touch[act]) v = true; // 触屏虚拟按键
now[act] = Input.enabled ? v : false;
}
}
// ---------------- 触屏虚拟按键接口 ----------------
export function setTouch(action, on) {
if (!(action in touch)) touch[action] = false;
if (touch[action] === on) return;
touch[action] = on;
refresh();
}
export function clearTouch() {
let any = false;
for (const k in touch) if (touch[k]) { touch[k] = false; any = true; }
if (any) refresh();
}
addEventListener('keydown', e => {
if (blocked.has(e.code)) e.preventDefault();
if (e.repeat) return;
+32
View File
@@ -87,6 +87,7 @@ export class Level {
/** 完整重置(死亡 / 重开) */
reset(full = false) {
// tiles 状态(vanish 等)
if (full) this.deathMarks = []; // 死亡血迹只在整关重开时清空,复活后保留
this.vanish = new Map();
this.bullets = [];
this.ebullets = [];
@@ -241,6 +242,32 @@ export class Level {
}
}
// 死亡标记:在死亡地点留下一个常驻「红叉」(IWBTG 风格死亡指示)。
// 只保留上一次死亡的红叉:新增前先清空,多次死亡不会累积。
addDeathMark(x, y) {
this.deathMarks.length = 0;
this.deathMarks.push({ x, y });
}
drawDeathMark(ctx, dm) {
const s = 4; // 红叉半边长(< kid 的 11×21,整体约 8px)
ctx.lineCap = 'round';
// 深色描边,保证在任意背景下都清晰
ctx.lineWidth = 3;
ctx.strokeStyle = 'rgba(0,0,0,0.55)';
ctx.beginPath();
ctx.moveTo(dm.x - s, dm.y - s); ctx.lineTo(dm.x + s, dm.y + s);
ctx.moveTo(dm.x + s, dm.y - s); ctx.lineTo(dm.x - s, dm.y + s);
ctx.stroke();
// 红色主体
ctx.lineWidth = 2;
ctx.strokeStyle = '#ff2535';
ctx.beginPath();
ctx.moveTo(dm.x - s, dm.y - s); ctx.lineTo(dm.x + s, dm.y + s);
ctx.moveTo(dm.x + s, dm.y - s); ctx.lineTo(dm.x - s, dm.y + s);
ctx.stroke();
}
// ---------------- 查询 ----------------
tileAt(tx, ty) {
if (tx < 0 || ty < 0 || tx >= this.w || ty >= this.h) return ty < 0 ? T.EMPTY : T.BLOCK;
@@ -458,6 +485,8 @@ export class Level {
if (this.shake > 0) this.shake *= 0.86;
if (this.toastT > 0 && --this.toastT === 0) this.toast = null;
this.particles.update();
// 死亡红叉为常驻静态标记,无需逐帧演化,复活后仍保留
}
fireEvent(ev, player) {
@@ -587,6 +616,9 @@ export class Level {
for (const b of this.bullets) S.drawBullet(ctx, b);
for (const b of this.ebullets) S.drawEBullet(ctx, b, t);
// 死亡血迹(IWBTG 风格,常驻于死亡地点)
for (const dm of this.deathMarks) this.drawDeathMark(ctx, dm);
this.particles.draw(ctx);
ctx.restore();
}
+32 -12
View File
@@ -6,6 +6,7 @@ import { Camera } from './camera.js';
import { Input } from './input.js';
import { UI } from './ui.js';
import { Save } from './save.js';
import { Touch } from './touch.js';
import { sfx, unlockAudio, Audio2 } from './audio.js';
import { Editor } from './editor.js';
import { clamp } from './util.js';
@@ -13,6 +14,16 @@ import { clamp } from './util.js';
const W = 800, H = 608;
const STEP = 1000 / 60;
// 移动端检测:触屏设备自动启用虚拟手柄;?touch=1 强制开启,?touch=0 强制关闭(桌面调试用)
// 判定条件:① 是移动端(存在 ontouchstart)/ 支持触屏(maxTouchPoints>0)/ 粗指针(触屏笔或手指)
// 只有命中才给 <html> 加 .touch 类;CSS 里 html:not(.touch) #touch 会强制隐藏,桌面端永远不显示按钮
const _qp = new URLSearchParams(location.search);
const _force = _qp.get('touch');
const _isTouch = ('ontouchstart' in window) || navigator.maxTouchPoints > 0 || matchMedia('(pointer: coarse)').matches;
const useTouch = _force === '1' || (_force !== '0' && _isTouch);
if (useTouch) document.documentElement.classList.add('touch');
function showTouch() { if (useTouch) Touch.show(); }
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d', { alpha: false });
ctx.imageSmoothingEnabled = false;
@@ -40,6 +51,7 @@ function startLevel(data, idx = -1) {
G.deaths = 0; G.ms = 0; G.hudTick = 0;
G.state = 'playing';
UI.showGame();
showTouch();
UI.toast('');
if (data.hint) UI.toast(data.hint);
}
@@ -58,9 +70,17 @@ function suicide() {
G.player.kill();
}
/** 触屏射击:仅在持枪、未死亡且游戏进行中时发射(受玩家自身冷却限制) */
function shoot() {
const P = G.player;
if (G.state !== 'playing' || !P || !P.alive || !P.hasGun || P.shootCool !== 0) return;
P.shoot();
}
function finish() {
if (G.state !== 'playing') return;
G.state = 'result';
Touch.hide();
sfx.goal();
const apples = G.player.apples || 0;
const total = G.level.totalApples || 0;
@@ -76,10 +96,10 @@ function nextLevel() {
if (G.idx >= 0 && G.idx + 1 < LEVELS.length) startLevel(LEVELS[G.idx + 1], G.idx + 1);
else toLevels();
}
function toLevels() { G.state = 'menu'; UI.show('scr-levels'); }
function toTitle() { G.state = 'menu'; UI.show('scr-title'); }
function pause() { if (G.state === 'playing') { G.state = 'paused'; UI.show('scr-pause'); } }
function resume() { if (G.state === 'paused') { G.state = 'playing'; UI.showGame(); } }
function toLevels() { G.state = 'menu'; UI.show('scr-levels'); Touch.hide(); }
function toTitle() { G.state = 'menu'; UI.show('scr-title'); Touch.hide(); }
function pause() { if (G.state === 'playing') { G.state = 'paused'; UI.show('scr-pause'); Touch.hide(); } }
function resume() { if (G.state === 'paused') { G.state = 'playing'; UI.showGame(); showTouch(); } }
// ---------------- 更新 ----------------
function update() {
@@ -94,7 +114,8 @@ function update() {
for (const b of L.bosses) b.update(1, P);
P.update(1);
// 刚从死亡中复活:镜头立刻对准重生点,别慢慢飘过去
if (wasDead && !P.dead) G.cam.snapTo(P, L);
if (P.justRespawned || (wasDead && !P.dead)) G.cam.snapTo(P, L);
P.justRespawned = false;
G.cam.update(L, P, 1);
G.ms += STEP;
G.time++;
@@ -105,6 +126,7 @@ function update() {
apples: P.apples, totalApples: L.totalApples, hasGun: P.hasGun,
save: P.saveIdx || 0, totalSaves: L.totalSaves || 0,
});
Touch.setGun(P.hasGun);
const boss = L.bosses.find(b => !b.dead) || L.bosses[0];
UI.bossBar(L.bosses.length ? boss : null);
}
@@ -126,11 +148,6 @@ function render() {
ctx.restore();
L.drawOverlay(ctx, C, t);
UI.toast(L.toast || '');
// 死亡时的血色闪烁
if (P.dead) {
ctx.fillStyle = 'rgba(200,0,40,.18)';
ctx.fillRect(0, 0, W, H);
}
} else {
drawTitleBg(t);
}
@@ -188,7 +205,7 @@ UI.init({
play: (lv) => startLevel(lv, lv.builtin === false ? -1 : (lv.index ?? -1)),
levels: toLevels,
help: () => UI.show('scr-help'),
editor: () => { G.state = 'editor'; UI.show('scr-editor'); Editor.open(); },
editor: () => { G.state = 'editor'; UI.show('scr-editor'); Touch.hide(); Editor.open(); },
back: toTitle,
resume,
restart: () => { restart(); },
@@ -210,8 +227,11 @@ const unlock = () => { unlockAudio(); removeEventListener('pointerdown', unlock)
addEventListener('pointerdown', unlock);
addEventListener('keydown', unlock);
// 移动端虚拟手柄
Touch.init({ pause, suicide, shoot });
UI.show('scr-title');
requestAnimationFrame(loop);
// 调试钩子
window.__IW = { G, LEVELS, Save, startLevel };
window.__IW = { G, LEVELS, Save, startLevel, pause, resume, restart, suicide };
+70 -21
View File
@@ -16,6 +16,13 @@ export const PHY = {
WATER_RISE: 0.42,
ICE_ACC: 0.18,
ICE_FRIC: 0.07,
// 滑出冰块后的惯性:空中几乎不损失,地面缓慢收住
SLIDE_FRIC: 0.12, // 非冰地面上收住惯性的摩擦
SLIDE_AIR: 0.02, // 空中惯性衰减(≈0,滑出冰块边缘能一路飞出去)
SLIDE_EASE: 0.14, // 超速滑行时按住同向键,收敛回正常走速的速度
SLIDE_BRAKE: 0.45, // 滑行中按反向键的刹车
SLIDE_MIN: 0.35, // 冰面上攒到多大速度才算进入滑行状态
SLIDE_STOP: 0.05, // 低于此速度惯性结束
};
export class Player {
@@ -41,6 +48,8 @@ export class Player {
this.animT = 0;
this.shootCool = 0;
this.mover = null;
this.sliding = false; // 冰面滑行惯性状态:离开冰块后仍保留
this.justRespawned = false; // 本帧刚复活(供镜头瞬移)
if (full) {
this.hasGun = !!this.level.data.startGun;
this.apples = 0;
@@ -52,16 +61,14 @@ export class Player {
get cx() { return this.x + this.w / 2; }
get cy() { return this.y + this.h / 2; }
// 死亡:不播任何死亡动画(无等待、无镜头演出),只留音效、死亡计数,
// 以及在死亡地点落一处 IWBTG 风格的血迹(由 Level 常驻渲染,复活后仍在)。
kill() {
if (this.dead || !this.alive) return;
this.dead = true; this.alive = false; this.deadT = 0;
this.vx = 0; this.vy = 0;
this.vx = 0; this.vy = 0; this.sliding = false;
sfx.death();
this.level.particles.burst(this.cx, this.cy, {
n: 26, colors: ['#e03050', '#fff', '#ffc63a', '#8e2530'],
speed: 4.5, life: 44, size: 4, grav: 0.18,
});
this.level.shake = 9;
this.level.addDeathMark(this.cx, this.cy);
if (this.onDeath) this.onDeath();
}
@@ -71,11 +78,13 @@ export class Player {
const sp = this.savePoint || L.spawn;
this.x = sp.x; this.y = sp.y;
this.vx = 0; this.vy = 0; this.jumps = 0;
this.squash = 1; this.shootCool = 0;
this.squash = 1; this.shootCool = 0; this.sliding = false;
this.deadT = 0;
// 安全兜底:万一重生点被地形/消失块堵住,向上顶出,别一复活就卡死
let guard = 0;
while (L.solidRect(this.x, this.y, this.w, this.h) && guard++ < 48) this.y -= 2;
L.softReset();
this.justRespawned = true; // 供 main.js 把镜头直接吸附到重生点
}
// ---------------- 每帧更新 ----------------
@@ -84,32 +93,60 @@ export class Player {
this.animT++;
if (this.shootCool > 0) this.shootCool--;
if (this.dead) {
if (++this.deadT > 34) this.respawn();
return;
}
// 上一帧/外部(R 键自杀、假存档)触发的死亡:立刻复活,不留死亡动画
if (this.dead) this.respawn();
const inWater = L.waterRect(this.x + 2, this.y + 4, this.w - 4, this.h - 4);
if (inWater && !this.inWater) sfx.splash();
this.inWater = inWater;
// ---- 水平输入(IWBT:瞬时,无惯性)----
// ---- 水平输入(IWBT:普通地面瞬时、无惯性;冰面打滑并保留惯性)----
const moveL = Input.is('left'), moveR = Input.is('right');
const onIce = this.grounded && L.iceAtBottom(this.x, this.y, this.w, this.h);
let target = 0;
if (moveL && !moveR) target = -PHY.HSPD;
else if (moveR && !moveL) target = PHY.HSPD;
const ICE_MAX = PHY.HSPD * 1.35;
const decel = f => {
if (Math.abs(this.vx) <= f) this.vx = 0;
else this.vx -= sgn(this.vx) * f;
};
if (onIce) {
// 冰面:打滑
// 冰面:加速/减速都很慢,速度上限也更高
if (target !== 0) this.vx += sgn(target) * PHY.ICE_ACC;
else {
if (Math.abs(this.vx) < PHY.ICE_FRIC) this.vx = 0;
else this.vx -= sgn(this.vx) * PHY.ICE_FRIC;
else decel(PHY.ICE_FRIC);
this.vx = clamp(this.vx, -ICE_MAX, ICE_MAX);
// 攒出一定速度即进入「滑行」状态——这是惯性得以保留的关键
if (Math.abs(this.vx) > PHY.SLIDE_MIN) this.sliding = true;
} else if (this.sliding && Math.abs(this.vx) > PHY.SLIDE_STOP) {
// 滑出冰块后:惯性依然保留 —— 空中几乎不衰减,地面才慢慢收住
if (target !== 0) {
const dir = sgn(this.vx);
if (dir === 0 || sgn(target) === dir) {
if (Math.abs(this.vx) >= PHY.HSPD) {
// 还在超速滑行:同向按键只让它缓慢收敛回正常走速,不再继续加速
this.vx -= dir * PHY.SLIDE_EASE;
if (Math.abs(this.vx) < PHY.HSPD) { this.vx = target; this.sliding = false; }
} else {
this.vx = target; // 已低于走速:交还玩家控制
this.sliding = false;
}
} else {
this.vx -= dir * PHY.SLIDE_BRAKE; // 反向按键:刹车,刹住后立刻反向
if (sgn(this.vx) === sgn(target)) { this.vx = target; this.sliding = false; }
}
this.vx = clamp(this.vx, -ICE_MAX, ICE_MAX);
} else {
decel(this.grounded ? PHY.SLIDE_FRIC : PHY.SLIDE_AIR);
if (this.vx === 0) this.sliding = false;
}
this.vx = clamp(this.vx, -PHY.HSPD * 1.35, PHY.HSPD * 1.35);
if (this.vx !== 0 && Math.abs(this.vx) < PHY.SLIDE_STOP) { this.vx = 0; this.sliding = false; }
} else {
// 常规地面/空中(非滑行):IWBT 标准手感,松手即停、无惯性
this.vx = target;
this.sliding = false;
}
if (target !== 0) this.facing = sgn(target);
@@ -194,13 +231,21 @@ export class Player {
}
this.squash += (1 - this.squash) * 0.22;
// ---- 滑行反馈:超速滑行(冰上,或刚滑出冰块仍带惯性)时脚下溅起冰屑 ----
if (this.sliding && this.grounded && Math.abs(this.vx) > PHY.HSPD && this.animT % 5 === 0) {
L.particles.spawn(this.cx - sgn(this.vx) * 5, this.y + this.h - 2, 2, {
colors: ['#dff4ff', '#8fd8f0', '#fff'], speed: 1.1, life: 16, size: 2,
dir: -Math.PI / 2, spread: 1.7, grav: 0.1,
});
}
// ---- 边界 ----
this.x = clamp(this.x, 0, L.pxW - this.w);
if (this.y > L.pxH + 200) this.kill();
if (this.y > L.pxH + 200) { this.kill(); if (this.dead) { this.respawn(); return; } }
if (this.y < -300) { this.y = -300; this.vy = 0; }
// ---- 致死 ----
if (L.deadlyRect(this.x, this.y, this.w, this.h)) this.kill();
// ---- 致死:同帧立刻复活,画面上不留任何死亡状态 ----
if (L.deadlyRect(this.x, this.y, this.w, this.h)) { this.kill(); if (this.dead) { this.respawn(); return; } }
// ---- 射击 ----
if (this.hasGun && Input.hit('shoot') && this.shootCool === 0 && L.bullets.length < 5) {
@@ -221,6 +266,9 @@ export class Player {
}
}
}
// 兜底:本帧内任何环节致死(如假存档)都立刻复活
if (this.dead) this.respawn();
}
shoot() {
@@ -254,7 +302,8 @@ export class Player {
sfx.save();
L.particles.spawn(e.x + e.w / 2, e.y + e.h / 2, 18,
{ colors: ['#b06bff', '#fff', '#e5d0ff'], speed: 2.6, life: 30 });
if (e.fake) { L.showToast('假的。', 90); this.kill(); }
// 假存档:立刻复活回上一个存档点,不播死亡动画
if (e.fake) { L.showToast('假的。', 90); this.kill(); if (this.dead) { this.respawn(); return; } }
}
break;
case 'goal':
-2
View File
@@ -175,8 +175,6 @@ export function drawKid(ctx, p, t) {
// 眼睛
px(ctx, face > 0 ? w - 6 : 3, 4, 2, 2, '#101018');
px(ctx, face > 0 ? w - 3 : 0, 4, 1, 2, '#101018');
// 嘴(死亡张嘴)
if (p.dead) { px(ctx, face > 0 ? w - 6 : 3, 6, 3, 2, '#5a1a1a'); }
ctx.restore();
// 持枪
+96
View File
@@ -0,0 +1,96 @@
// 移动端虚拟手柄:左手方向、右手跳跃 + 道具(参考 IWBT Creator 的触屏布局)
// 左下方:← → 移动;右下方:大跳键 + 射击/下蹲 道具键 + 暂停/重生
import { setTouch, clearTouch } from './input.js';
// 持续按住型动作(通过 action 状态机驱动)
const HELD = ['left', 'right', 'jump', 'down'];
const api = {
pause() {},
suicide() {},
shoot() {},
};
let visible = false;
let root = null;
function q(sel) { return document.querySelector(sel); }
function bind(btn) {
const action = btn.dataset.touch;
if (!action) return;
let timer = null;
const press = (e) => {
e.preventDefault();
if (btn._pid != null) return; // 已在按住(多指防重入)
btn._pid = e.pointerId;
try { btn.setPointerCapture(e.pointerId); } catch (_) {}
btn.classList.add('on');
if (action === 'shoot') {
// 长按连发(受玩家冷却与是否持枪限制)
api.shoot();
timer = setInterval(() => api.shoot(), 140);
} else if (HELD.includes(action)) {
setTouch(action, true);
} else if (action === 'pause') {
api.pause();
} else if (action === 'suicide') {
api.suicide();
}
};
const release = (e) => {
if (btn._pid != null && e.pointerId !== btn._pid) return;
btn._pid = null;
try { btn.releasePointerCapture(e.pointerId); } catch (_) {}
btn.classList.remove('on');
if (action === 'shoot') {
if (timer) { clearInterval(timer); timer = null; }
} else if (HELD.includes(action)) {
setTouch(action, false);
}
};
btn.addEventListener('pointerdown', press);
btn.addEventListener('pointerup', release);
btn.addEventListener('pointercancel', release);
btn.addEventListener('lostpointercapture', release);
btn.addEventListener('contextmenu', (e) => e.preventDefault());
}
export const Touch = {
init(handlers) {
Object.assign(api, handlers || {});
root = q('#touch');
root.querySelectorAll('[data-touch]').forEach(bind);
// 游戏进行中禁止页面滚动/回弹
const stage = q('#stage');
if (stage) {
stage.addEventListener('touchmove', (e) => { if (visible) e.preventDefault(); }, { passive: false });
stage.addEventListener('contextmenu', (e) => { if (visible) e.preventDefault(); });
}
},
show() {
if (!root) root = q('#touch');
if (!root) return;
visible = true;
root.classList.remove('hidden');
},
hide() {
if (!root) root = q('#touch');
if (!root) return;
visible = false;
root.classList.add('hidden');
clearTouch(); // 防止按键卡住
},
/** 反映是否持有手枪:无枪时射击键置灰 */
setGun(has) {
const g = q('#tc-gun');
if (g) g.classList.toggle('off', !has);
},
};
+135
View File
@@ -0,0 +1,135 @@
// 回归测试:kid 死亡后必须立刻复活,且不留下任何死亡动画(无粒子、无震屏、无延迟)
const handlers = {};
const addL = (t, fn) => { (handlers[t] = handlers[t] || []).push(fn); };
globalThis.addEventListener = addL;
globalThis.window = { addEventListener: addL };
const key = (type, code) => (handlers[type] || []).forEach(fn => fn({ code, preventDefault() { }, repeat: false }));
const { Level } = await import('../js/level.js');
const { Player } = await import('../js/player.js');
const { Input } = await import('../js/input.js');
const W = 20, H = 8, TILE = 32;
// row2 放一枚上刺(col 5),row3 是地板,spawn 在 col1
const grid = [
'#'.repeat(W),
'#' + 'S' + '.'.repeat(W - 3) + '#',
'#' + '.'.repeat(4) + '^' + '.'.repeat(4) + '^' + '.'.repeat(9) + '#',
'#'.repeat(W),
'#'.repeat(W),
'#'.repeat(W),
'#'.repeat(W),
'#'.repeat(W),
];
let fail = 0;
const err = m => { console.log(' ✗ ' + m); fail++; };
const ok = m => console.log(' ✓ ' + m);
function fresh() {
const L = new Level({ id: 'dt', name: 'DT', type: 'jump', width: W, grid });
const P = new Player(L);
let deaths = 0;
P.onDeath = () => deaths++;
for (let i = 0; i < 12; i++) { L.update(1, P, null); P.update(1); Input.endFrame(); }
return { L, P, deaths: () => deaths };
}
// 1) 踩到刺:同一帧内就必须已经复活(不是等 34 帧)
{
const { L, P, deaths } = fresh();
const sx = L.spawn.x;
P.x = 5 * TILE; P.y = 2 * TILE + 11; // 直接压在上刺上
L.update(1, P, null); P.update(1); Input.endFrame();
if (!P.alive || P.dead) err('踩刺后本帧仍处于死亡状态(复活有延迟)');
else ok('踩刺后同帧即复活,无死亡等待');
if (deaths() !== 1) err(`死亡计数应为 1,实际 ${deaths()}`);
else ok('死亡计数 = 1(正常累加)');
if (Math.abs(P.x - sx) > 1) err(`复活位置不对:x=${P.x.toFixed(1)} 期望 ${sx}`);
else ok('复活位置 = 最近存档点/出生点');
if (L.particles.list.length) err(`死亡仍产生粒子(${L.particles.list.length} 个)`);
else ok('无屏幕级死亡粒子(血迹走常驻层,非瞬态粒子)');
if (L.shake > 0) err(`死亡仍触发震屏 shake=${L.shake}`);
else ok('无死亡震屏');
if (!L.deathMarks.length) err('死亡未在死亡地点留下 IWBT 血迹效果');
else {
const dm = L.deathMarks[0];
const ex = 5 * TILE + 5.5, ey = 2 * TILE + 21.5;
if (Math.abs(dm.x - ex) > 30 || Math.abs(dm.y - ey) > 30) err(`血迹偏离死亡点 (${dm.x.toFixed(0)},${dm.y.toFixed(0)}) 期望约 (${ex},${ey})`);
else ok('在死亡地点留下红色死亡叉(X)');
}
if (P.justRespawned !== true) err('未标记 justRespawned,镜头会慢慢飘回去');
else ok('justRespawned 已置位(镜头瞬移)');
}
// 2) R 键自杀路径:kill() 后下一帧必须复活
{
const { L, P, deaths } = fresh();
P.kill();
if (deaths() !== 1) err('kill() 未触发死亡回调');
L.update(1, P, null); P.update(1); Input.endFrame();
if (!P.alive || P.dead) err('自杀后未立刻复活');
else ok('自杀(R 键)后下一帧即复活');
// 立刻复活后应能马上操作:按住右键能走
const x0 = P.x;
key('keydown', 'ArrowRight');
for (let i = 0; i < 6; i++) { L.update(1, P, null); P.update(1); Input.endFrame(); }
if (P.x <= x0) err('复活后当帧无法操作');
else ok(`复活后立即可操作(6 帧走了 ${(P.x - x0).toFixed(1)}px)`);
key('keyup', 'ArrowRight');
}
// 3) 连续死亡:不能卡死在死亡状态
{
const { L, P, deaths } = fresh();
for (let k = 0; k < 5; k++) {
P.x = 5 * TILE; P.y = 2 * TILE + 11;
L.update(1, P, null); P.update(1); Input.endFrame();
if (!P.alive) { err(`第 ${k + 1} 次死亡后卡在死亡状态`); break; }
}
if (deaths() !== 5) err(`连续 5 次死亡计数为 ${deaths()}`);
else ok('连续 5 次死亡均即时复活,计数 = 5');
}
// 4) 死亡后不应残留任何死亡视觉:跑 30 帧确认没有死亡粒子产生
{
const { L, P } = fresh();
P.x = 5 * TILE; P.y = 2 * TILE + 11;
let maxParticles = 0;
for (let i = 0; i < 30; i++) {
L.update(1, P, null); P.update(1); Input.endFrame();
maxParticles = Math.max(maxParticles, L.particles.list.length);
}
if (maxParticles > 0) err(`死亡后仍出现全局粒子(峰值 ${maxParticles})`);
else ok('30 帧内无屏幕级死亡粒子(血迹由常驻层渲染)');
if (!L.deathMarks.length) err('死亡血迹未常驻保留');
else ok(`死亡红叉常驻保留(共 ${L.deathMarks.length} 处,30 帧后仍在)`);
}
// 5) 多次死亡:只显示上一次死亡的血迹(不累积)
{
const { L, P } = fresh();
for (let k = 0; k < 2; k++) { P.x = 5 * TILE; P.y = 2 * TILE + 11; L.update(1, P, null); P.update(1); Input.endFrame(); }
for (let k = 0; k < 2; k++) { P.x = 10 * TILE; P.y = 2 * TILE + 11; L.update(1, P, null); P.update(1); Input.endFrame(); }
if (L.deathMarks.length !== 1) err(`多次死亡应只显示最后一次血迹,实际 ${L.deathMarks.length} 处`);
else {
const dm = L.deathMarks[0];
const ex = 10 * TILE + 5.5, ey = 2 * TILE + 21.5;
if (Math.abs(dm.x - ex) > 30) err(`应只保留最后一次(col10)血迹,实际在 x=${dm.x.toFixed(0)}`);
else ok('多次死亡只保留上一次死亡地点的血迹');
}
}
// 6) 整关重开:血迹清空
{
const { L, P } = fresh();
P.x = 5 * TILE; P.y = 2 * TILE + 11;
L.update(1, P, null); P.update(1); Input.endFrame();
if (!L.deathMarks.length) err('死亡未生成血迹');
L.reset(true);
if (L.deathMarks.length) err(`整关重开后血迹应清空,实际 ${L.deathMarks.length}`);
else ok('整关重开(reset(true))后血迹清空');
}
console.log(fail ? `\n❌ ${fail} 项失败` : '\n✅ 死亡即时复活,全部通过');
process.exit(fail ? 1 : 0);
+5
View File
@@ -29,10 +29,15 @@ const ctx2d = new Proxy({
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;
+103
View File
@@ -0,0 +1,103 @@
// 冰面惯性回归测试:滑出冰块后必须保留惯性(空中几乎不衰减,地面缓慢收住)
const handlers = {};
const addL = (t, fn) => { (handlers[t] = handlers[t] || []).push(fn); };
globalThis.addEventListener = addL;
globalThis.window = { addEventListener: addL };
const key = (type, code) => (handlers[type] || []).forEach(fn => fn({ code, preventDefault() { }, repeat: false }));
const down = c => key('keydown', c);
const up = c => key('keyup', c);
const { Level } = await import('../js/level.js');
const { Player, PHY } = await import('../js/player.js');
const { Input } = await import('../js/input.js');
const W = 44;
// 地板 row 11:冰面 col1..6,之后 tail(普通地面或空)
function grid(tail) {
const g = [];
g.push('#'.repeat(W));
for (let y = 1; y <= 10; y++) {
g.push((y === 10 ? '#S' + '.'.repeat(W - 3) : '#' + '.'.repeat(W - 2)) + '#');
}
g.push('#' + '%'.repeat(6) + tail + '#');
g.push('#'.repeat(W));
g.push('#'.repeat(W));
return g;
}
let fail = 0;
const err = m => { console.log(' ✗ ' + m); fail++; };
function run(tail, label, releaseAt = 1e9, jumpAt = 1e9) {
const L = new Level({ id: 'ice', name: 'ICE', type: 'jump', width: W, grid: grid(tail) });
const P = new Player(L);
for (let i = 0; i < 10; i++) { L.update(1, P, null); P.update(1); Input.endFrame(); }
down('ArrowRight');
const log = [];
for (let i = 0; i < 90; i++) {
if (i === releaseAt) up('ArrowRight');
if (i === jumpAt) down('ShiftLeft');
if (i === jumpAt + 1) up('ShiftLeft');
L.update(1, P, null); P.update(1); Input.endFrame();
log.push({
f: i, x: +P.x.toFixed(1), vx: +P.vx.toFixed(2), y: +P.y.toFixed(1),
g: P.grounded, sl: P.sliding,
ice: P.grounded && L.iceAtBottom(P.x, P.y, P.w, P.h),
});
}
up('ArrowRight');
console.log(`\n=== ${label} ===`);
console.log('帧 x vx 地 滑 冰');
let crossAt = -1;
for (const r of log) {
const cross = !r.ice && log.find(o => o.f === r.f - 1)?.ice;
if (cross && crossAt < 0) { crossAt = r.f; console.log('--- 离开冰面 ---'); }
if (r.f % 3 === 0 || cross) console.log(`${String(r.f).padStart(3)} ${String(r.x).padStart(6)} ${String(r.vx).padStart(6)} ${r.g ? 'Y' : '.'} ${r.sl ? 'Y' : '.'} ${r.ice ? 'Y' : '.'}`);
}
const off = log.filter(r => r.f >= crossAt && crossAt >= 0);
const dist = off.length ? Math.max(...off.map(r => r.x)) - off[crossAt === 0 ? 0 : 0].x : 0;
console.log(`离冰时 vx=${off[0]?.vx ?? 0},离冰后位移=${dist.toFixed(1)}px`);
return { log, crossAt, off };
}
// A:一直按住右键冲出冰面 —— 惯性要把 4.05 平滑收敛到正常走速,不能瞬间刹停也不能一直超速
{
const { off } = run('='.repeat(34), 'A 冰面 -> 普通地面(一直按住右键)');
const v0 = off[0].vx, v10 = off[10]?.vx ?? 0, v30 = off[30]?.vx ?? 0;
if (v0 < PHY.HSPD) err(`A 离冰瞬间就丢了速度 vx=${v0}`);
if (v10 < 2) err(`A 离冰 10 帧后速度掉太快 vx=${v10}(应仍在滑行)`);
if (Math.abs(v30) > PHY.HSPD + 0.01) err(`A 离冰 30 帧后仍超速 vx=${v30}(应收敛回 ${PHY.HSPD})`);
}
// B:松手靠惯性滑出到普通地面 —— 地面摩擦收住,但不能一帧刹死
{
const { off } = run('='.repeat(34), 'B 冰面 -> 普通地面(第30帧松开方向键)', 30);
const v0 = off[0].vx, v5 = off[5]?.vx ?? 0;
if (v5 <= 0) err(`B 离冰 5 帧内就刹死了(v0=${v0} v5=${v5})`);
if (v5 > v0) err(`B 松手后速度反而上升`);
}
// C:滑出冰块边缘掉下去 —— 空中必须几乎不衰减,能一路飞出去
{
const { off } = run('.'.repeat(34), 'C 冰面 -> 悬空(第30帧松开后滑出边缘)', 30);
const air = off.filter(r => !r.g);
if (air.length < 5) { err('C 没掉下去(场景构造失败)'); }
else {
const k = Math.min(10, air.length - 1);
const v0 = air[0].vx, vk = air[k].vx;
if (vk < v0 - 0.5) err(`C 空中惯性衰减过快:${v0} -> ${vk}(${k} 帧)`);
else console.log(` ✓ 空中 ${k} 帧后仍保留 vx=${vk}(起 ${v0})`);
}
}
// D:冰面上滑行中起跳 —— 空中保持滑行速度
{
const { off } = run('.'.repeat(34), 'D 冰面滑行中起跳(第30帧松开 + 第34帧跳)', 30, 34);
const v0 = off[0]?.vx ?? 0, v12 = off[12]?.vx ?? 0;
if (v0 < 2) err(`D 起跳时速度太小 vx=${v0}`);
if (v12 < v0 - 0.6) err(`D 跳跃后空中丢失惯性:${v0} -> ${v12}`);
else console.log(` ✓ 起跳后 12 帧仍保留 vx=${v12}(起 ${v0})`);
}
console.log(fail ? `\n❌ ${fail} 项失败` : '\n✅ 冰面惯性全部通过');
process.exit(fail ? 1 : 0);
+5
View File
@@ -29,9 +29,14 @@ globalThis.performance = { now: () => clock };
let rafCb = null;
globalThis.requestAnimationFrame = fn => { rafCb = fn; return 1; };
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;
+1 -1
View File
@@ -10,7 +10,7 @@ const up = c => key('keyup', c);
const { LEVELS } = await import('../js/levels/index.js');
const { Level } = await import('../js/level.js');
const { Player } = await import('../js/player.js');
const { Player } = await import('./player.js'.replace('./player.js', '../js/player.js'));
const { Input } = await import('../js/input.js');
let fail = 0;