Files
kidgo/.workbuddy/memory/2026-10-24.md
T

181 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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)。
## 死亡流程改造:手动重生 + 小死亡动画(用户需求)
- 需求原话:「当角色死亡后不要立马重生,需要用户手动按重生键重生,并且死亡时除了原先的红叉,还要附加一个小的死亡动画」。推翻 2026-10-23 的"立即复活、无动画"旧需求。
- player.js:kill() 重写(定格 + 粒子爆发 16 个 + shake=6 + deathX/deathY clamp 到关卡内,红叉位置改用 clamp 后坐标);update() 开头 `if (this.dead) { this.deadT++; return; }` 早退;移除 4 处"kill 后当帧 respawn"(掉坑/刺/update 末尾兜底/假存档);draw() dead 分支画扩散冲击环(deadT<=14,半径 4→29px 淡出,kid 不画);reset() 初始化 deathX/deathY。
- main.js:suicide() 分流(alive→kill;dead→respawn),R 键两段式语义;render 的 playing 分支加死亡提示("按 R 重生"呼吸闪烁 + "Backspace 整关重来",触屏文案"按右下角重生键")。
- 触屏重生键(touch.js→api.suicide)自动获得死亡中重生能力,无需改。
- 测试:death-test.mjs 全面重写为 23 断言(定格/输入无效/手动 respawn 回存档点/连续 5 次死亡/红叉常驻+去重+reset 清空/掉坑 clamp/渲染冒烟)全过;key-test(R 自杀→定格、死亡中 R→重生)、save-test(kill 后手动 respawn)、smoke(死亡段改新语义)同步更新;dj/ice/spike/dom-smoke/editor-pick 全过;未跑 solve(禁令)。
- 踩坑:测试 grid 里 'S' 是出生点、存档点字符是 'G'(level.js:24),写测试地图别搞错。
## 重生键语义简化:任何状态按 R 都直接重生(用户反馈)
- 用户原话:「如果没死,由玩家手动按了重生键就直接重生」。此前 R 是两段式(活着=kill 定格→再按一次才回存档点),用户嫌按一次没反应。
- main.js suicide() 改为无条件 `G.player.respawn()`:活着按 = 放弃当前进度秒回存档点(不死亡、不计死亡数——主动重生不是失误);死亡中按 = 复活。触屏重生键走同一入口自动生效。
- key-test R 段重写:先站到出生点→按 R→验证回到存档点且 alive/dead/死亡数不变;再 kill→死亡中按 R→验证复活。全过。
- 回归:key/death/save/smoke/dj/ice/spike/dom-smoke/editor-pick 全过;未跑 solve(禁令)。
## 重生键最终形态:按一次 = 死亡动画 + 自动重生(用户二次反馈)
- 用户先要求「没死按重生键直接重生」,AI 做成无动画秒回;用户再反馈「不是,直接按重生键有死亡动画的」——要的是**一次按键=播死亡动画→演完自动重生**。
- 最终语义(main.js suicide):死亡中按 R = 立即复活;活着按 R = kill({mark:false, count:false, autoRespawn:30})——粒子+轻震+冲击环照播,不留红叉、不计死亡,30 帧(0.5s)后自动回存档点。
- player.js:kill() 参数化 opts{mark,count,autoRespawn};update 死亡早退里 autoRespawn 到期自动 respawn;reset/respawn 清理该字段。软重置(softReset 清粒子+shake)会收走动画残余粒子,属预期(爆发与冲击环已看完)。
- 测试:death-test 新增场景 9(主动重生键 7 断言)全过;key-test 活着按 R 段重写(演出中 dead/零计数/零红叉→33 帧后回存档点);save/smoke/dj/ice/spike/editor-pick/dom-smoke 全过。
- 补充(用户三次反馈):主动重生键演出期间**不显示**「按 R 重生」提示(main.js render 条件改为 `P.dead && !P.autoRespawn`)——演完自动重生,提示多余;真死亡时提示保留。
- 补充(用户四次反馈):主动重生键原定 autoRespawn=30 帧,冲击环 14 帧就播完,中间 16 帧只剩稀疏粒子=「空档」。改为 **autoRespawn=15**(冲击环 deadT<=14 播完的下一帧重生,零空档零腰斩)+ kill() 新增 opts.life 覆盖粒子寿命(主动模式 life=15 与冲击环同步收尾,避免被 softReset 腰斩满 alpha 粒子)。shake=6 每帧 ×0.86 衰减,15 帧时≈0.56px 无感,不会被重生清场掐断。
## 主动重生键终版:当帧重生 + 特效留在死亡位置播完(用户五次反馈)
- 用户原话:「不用等死亡动画结束再重生,直接重生就行了,动画在死亡位置放完」。此前 autoRespawn=15 帧的"演完再重生"方案废弃,autoRespawn 机制整个删除。
- 架构调整:冲击环从 player.draw 的 dead 分支**搬到 Level 层**(`deathFX` 数组 + addDeathFX + drawDeathRing + update 推进 14 帧移除)——否则玩家一重生环就消失,无法"在死亡位置放完"。
- player.js:新增 `deathBurst()`(纯演出:粒子+shake+addDeathFX,不进死亡状态);kill() 改为调 deathBurst + 红叉/计数(opts 仅剩 mark/count);respawn(opts) 透传 `softReset(opts.keepFX)`;draw() dead 分支简化为不画 kid。
- level.js:`softReset(keepFX=false)`——keepFX=true 时不清粒子/震屏(主动重生专用,让特效自然播完);reset(true) 清 deathFX。
- main.js suicide:活着按 = `deathBurst() + respawn({keepFX:true})` 当帧完成,零等待;死亡中按 = respawn()。
- 真死亡流程不变(定格+手动+红叉+计数);粒子 life 34 / 冲击环 14 帧 / shake ×0.86 全部自然播完(重生不清场)。
- 测试:death-test 场景 8/9 重写(当帧重生、纯演出不进死亡态、粒子/环/震屏全程自然播完断言),key-test 活着按 R 段重写;全套回归过。
- 补充(用户六次反馈):「kid 被动死亡的,死亡动画也应该放完,尽管玩家很快按了重生键」。根因:`softReset()` 无脑清 particles+shake——重生即清场,特效被腰斩。修复:softReset 永久不清粒子/震屏("演出层"语义:重生只重置关卡逻辑状态,不掐断演出;仅 reset(true) 整关重开才清空)。keepFX 参数随之删除,respawn() 恢复无参。踩坑:python 替换时把 `softReset(keepFX=false){` 函数头和 vanish/bullets 清逻辑一起吃进 old 串,文件语法损坏——已修复(补回函数头+4 行清逻辑,node import 验证)。教训:rep() 的 old 串跨函数签名行时,new 必须原样带回签名与后续语句。
- 测试:death-test 新增场景 10(真死亡 3 帧秒按 R:粒子/环/震屏全程保留并自然播完,7 断言);key-test 死亡中按 R 段加"残余粒子"断言;全套回归过。
## Flutter 全平台复刻(app/ 目录)——集成层收口,`flutter analyze` 0 error
- 目标:把 `tmp/` 下 web 源码 100% 复刻成 Flutter(android+macos+linux+windows+ios),手感/UI/功能全保留。
- 集成层全部落地并通过静态分析:
- `world.dart` = main.js 的状态机+固定 60Hz 时间步长循环(STEP=1000/60,guard<6,P.update(1) 定步长保手感);GameHost 回调接口 + LevelTestRunner。
- `app_root.dart`:`SingleTickerProviderStateMixin`+`Ticker` 驱动 `world.tick`;`RawKeyboard` 监听 → `Input.onKey(logicalKey.debugName 去空格, isDown)`(debugName 与 web `KeyboardEvent.code` 字符串一致:ArrowLeft/KeyA/ShiftLeft/KeyR/Backspace/Escape…);9 个 GameHost 方法接 UI。
- `game_canvas.dart` 信箱缩放(800×608 虚拟→适配保留比例);`overlays.dart` HUD/BOSS 条/吐司;`screens.dart` 标题/选关/暂停/结算/设置(含键位重绑 Focus.onKeyEvent 捕获 + 用户名);`touch_layout.dart`+`touch_controls.dart` 虚拟手柄(android 默认开);`editor_panel.dart` 关卡编辑器(多面地图+属性面板,1:1 editor.js)。
- `save.dart` 加 settings 槽 + flush;`levels.dart` 从 `assets/levels.json`(23 关,已注册 pubspec)加载;`main.dart` = `runApp(AppRoot)`。
- 修复的编译错误:缺 `material.dart`/`scheduler.dart`/`foundation.dart` 导入(Ticker/MaterialApp/ValueListenable/defaultTargetPlatform/KeyDownEvent);`_onKeyCapture` 改 `(FocusNode, KeyEvent)→KeyEventResult`;`debugName` 可空用 `?.replaceAll() ?? ''`;`levelsReload` 改 `ValueNotifier<int>` 避免越权调 notifyListeners;清掉未用导入/变量。
- **环境限制**:本沙箱拦截 Flutter 写 `/opt/flutter/bin/cache/engine.stamp.tmp`,故 `flutter build`(任何原生平台)无法在此跑通——仅能做静态 `flutter analyze`(已 0 error)。原生构建需用户在自有机器上跑(SDK 可写)。
- 待办(用户机器):`flutter pub get` → `flutter build <平台>`;若需 iOS 触屏检测可拓宽 `_useTouch`(现仅 android);`withOpacity` 弃用警告可后续换 `withValues`。
## Flutter 安卓「打开后黑屏」排障与加固(同日)
- 报告:安卓打开后是黑屏。排查路径(静态读代码 + 实证 widget 测试):
- **已排除**:`levels.json` 顶层是合法 `{"levels":[...]}`(非数组,decode 不抛);`EditorController()` 是空构造;`Save.load()`/`loadLevels()`/`Input.endFrame()`/`metaSettings()['touch'] as Map` 均安全;`MainActivity` 是标准 `FlutterActivity`、manifest/styles 无改动无资源引用;`_MenuBg` 只是渐变 Container,`TouchControls` 在 `visible=false` 时不渲染。
- **实证**:新增 `test/title_render_test.dart`,在 1080×2340 虚拟窗口真实 pump `AppRoot`,断言标题三按钮与"23 个内置关卡"文案可见、不卡加载态、无错误界面 → **全部通过**。证明启动逻辑与 build 本身没问题(`flutter test` 全 3 项绿)。
- 结论:黑屏属**设备级渲染问题**,最可能是 Android 默认 Impeller(Vulkan)后端处理「每帧 scale+clipRect+渐变 shader 的 CustomPaint」时整帧丢失,会连普通 widget 一起黑掉。
- 修复/加固:
1. **`AndroidManifest.xml` 的 `<application>` 下加 `EnableImpeller=false`**(Flutter 官方关闭方式,置「逐帧重绘自定义 Canvas」避免 OEM ROM/Impeller 黑屏;确认正常后可改回 true)。
2. **`game_canvas.dart`**:去掉 `isComplex:true`(我们每帧重绘,缓存有害)——最可能致设备 blank 的元凶;去掉 `CustomPaint(size: Size.infinite)` 改 `SizedBox.expand`(零/无穷尺寸会算出 scale=0 让整屏消失);paint 里加 `size/scale` 有限性与正数校验。
3. **新增 `core/app_error.dart`**:全局错误收口。`main.dart` 装 `FlutterError.onError` + `ErrorWidget.builder` + `runZonedGuarded`,任何异常都切到可读错误详情界面(release 包原本静默吞异常只剩黑屏)。区分 `report`(致命,占屏)与 `warn`(非致命,仅 logcat)。
4. **`app_root.dart`**:`_init()` 改分阶段 try/catch——存档/关卡失败用 `warn` 降级继续(不再导致 `_ready` 永假、永久停黑屏);世界装配失败才 `report`;加 12s 看门狗超时报错;加载态换成带 CircularProgressIndicator 的 `_BootScreen`(原为近黑空 Scaffold);build 三级优先级:错误详情 → 加载界面 → `_buildGame()`。
5. `game_canvas` 的 `world.render` 包 try/catch 上报渲染异常。
- 验证:`flutter analyze` **0 error / 0 warning**(余 206 条为 info 级 lint);`flutter test` 3/3 通过;manifest XML 通过 python ElementTree 校验。
### 安卓黑屏 → 真凶定位(第二轮,同日)
- 用户二次反馈:**改后仍打不开,启动循环报 `View.of() was called with a context that does not contain a View widget.`**
- **根因确认(我上一轮自己引入的回归)**:为套 `runZonedGuarded` 我把 `main.dart` 的 `runApp(AppRoot())` 换成了底层 `binding.scheduleAttachRootWidget(const AppRoot())`,**漏掉了 `binding.wrapWithDefaultView(...)`**。
- SDK 源码(`packages/flutter/lib/src/widgets/binding.dart`)为证:
`void runApp(Widget app) { final b = WidgetsFlutterBinding.ensureInitialized(); _runWidget(b.wrapWithDefaultView(app), b, 'runApp'); }`
`void _runWidget(app, b, entry) { assert(b.debugCheckZone(entry)); b..scheduleAttachRootWidget(app)..scheduleWarmUpFrame(); }`
- `wrapWithDefaultView` 负责注入 `View` 祖先 + 提供隐式 `FlutterView`;缺了它,`MaterialApp` 内部一调 `View.of(context)` 就抛上述错误。官方 `runWidget` 的文档也明写"缺 View 祖先会出错"。
- **不要改回 `runApp` 塞进 `runZonedGuarded`**:`debugCheckZone` 会比较 `Zone.current` 与 binding 初始化时的 `_debugBindingZone`,不一致会抛 **"Zone mismatch"**(`foundation/binding.dart` 有实现)。故保留"复刻 runApp 但不带 assert"的写法最稳。
- 修复后 `main.dart`:
```dart
final binding = WidgetsFlutterBinding.ensureInitialized();
FlutterError.onError = ...; ErrorWidget.builder = ...;
runZonedGuarded(() {
binding
..scheduleAttachRootWidget(binding.wrapWithDefaultView(const AppRoot()))
..scheduleWarmUpFrame();
}, (e, st) => AppError.reportZone(e, st));
```
- 加固:`AppError.buildScreen` 外层包 `Directionality`。**ErrorWidget 在很早期替换出错 widget 时树里可能还没有 Directionality**,缺了会引发二次报错盖住真正原因(血泪点)。
- **方法论教训**:widget 测试(`tester.pumpWidget`)由测试框架自己注入 View,**永远无法覆盖 `main()` 里的 bootstrap 问题**。改动启动流程必须真机构建验证,不能只看 `flutter analyze` + widget 测试全绿。
### 安卓黑屏 → 真凶终于抓到(第三轮,同日,已收敛)
- **真凶:`Positioned` 被 `ValueListenableBuilder` 包裹**,不是 Stack 的直接子级 → 定位数据被丢弃 → 整棵 Stack 布局横向塌缩成 0 宽。
- 实证(探针测试量的真实尺寸,不是靠猜):修复前 `button size=Size(0.0, 92.0)`、`title text found: 0`、画布 `0.0×0.0`;修复后 `canvas rendered: size=1080.0x2340.0 scale=1.35`、按钮 `64.4×23` 居中 (507.8, 1151.5)。
- 界面上只剩画布里那条近黑渐变 `#181828→#08080f`,看着就是"黑屏"。这也解释了为什么 widget 测试能 `find` 到按钮却依然黑:`find` **不检查可见性**。
- 涉及位置:`app_root._buildGame` 的四个屏幕分支(已改为 Positioned 在外、监听在内);**`overlays.dart` HudOverlay 的 HUD 行 / BOSS 血条 / Toast 三处同样踩坑**(HUD 塌缩),`_BossBar` 改为只返回内容(`Column`),定位交给外层 `Positioned`。
- 回退前两轮的推测性改动(已证明无关,留着只会有副作用):`MainActivity` 恢复默认 `FlutterActivity`(SurfaceView,不要强制 Texture);**删除 manifest 里的 `EnableImpeller=false`**(官方已弃用,且本作与此无关)。
- **顺带挖出的真功能 bug:进关卡后 HUD 永远不显示**。原因:`showHud` 在 `_buildGame()` 里读 `world.state`,而 `startLevel()` 只改 World 字段、不触发 AppRoot 重建。
- 修法:`World.state` 改为 `ValueNotifier<String> stateN` 的 getter/setter(`menu|playing|paused|result|editor`),HUD 用 `ValueListenableBuilder<String>(valueListenable: world.stateN, ...)` 驱动。
- 教训:web 版主循环每帧直接改 DOM 所以没这个问题;**Flutter 侧凡是"某状态决定某子树显隐",该状态就必须是可监听的**。
- **音频异常会整死 App**:audioplayers 的 `MissingPluginException` 会在别的 Zone 上下文冒出来,`Sfx._play` 内的 try/catch 抓不到,冒泡到全局钩子后整个界面被切成错误详情。
- 加固:`Sfx.broken` 一旦出错永久静音;`init()`/`unlock()` 加 try/catch 且 `unlock()` 在 `!enabled` 时直接 return;`AppError.reportZone` 对 `MissingPluginException + audioplayers/xyz.luan` 降级为 warn。**原则:音频坏掉 ≠ 游戏坏掉。**
- 渲染容错策略调整:`world.render` 单帧异常只 warn(连续 60 帧才升级占屏),避免偶发一帧就把整局判死。
- **手感 1:1 已用测试锁死**(`test/gameplay_test.dart`,无头、固定步长,直接调 `world.tick`):
- web 版顺序是「本帧 `vy = -JUMP` 后**立刻**加一次重力」,所以第一帧读到 `-6.35` 而非 `-6.6`;Dart 侧完全一致(别再写错断言)。
- 松键可变跳跃:`!is('jump') && vy < -3` 收紧到 -3,再加一帧重力 → `-2.75`。
- 死亡后不自动重生、按 R 才回存档点(`deaths` +1)。
- **把"布局塌缩"固化成回归测试**(`test/title_render_test.dart`,5 个用例):断言 `tester.getSize()` 的**可见尺寸**(Width > 0)而不是"widget 存在";另加端到端「标题 → 关卡选择 → 点卡进关 → HUD 显示关卡名」。注意游戏有常驻 Ticker,**测试里不能用 `pumpAndSettle`**(会超时)。
- 当前状态:`flutter analyze` **0 error / 0 warning**;`flutter test` **10/10 通过**。原生构建仍被沙箱拦(同前:Flutter 无法写 `/opt/flutter/bin/cache/engine.stamp.tmp`),需用户在自己机器上 `flutter build apk`。
### Flutter 收尾:安卓横屏锁定 + 弃用清理(同日,最终态)
- **安卓仅横屏(用户:「安卓平台不能竖屏,只能横屏玩,你重新适应一下」)**:
- `android/app/src/main/AndroidManifest.xml` 的 `<activity>` 加 `android:screenOrientation="sensorLandscape"` + `android:resizeableActivity="false"`(冷启动首帧即横屏,避免先竖后横闪烁)。
- `lib/main.dart` 在 `runZonedGuarded` 内对 android/iOS 调 `SystemChrome.setPreferredOrientations([landscapeLeft, landscapeRight])`(桌面端不干预)。
- `game_canvas.dart` 提取 `static Rect letterboxRect(Size)` 供画布与触屏共用;`touch_controls.dart` 用 `letterboxRect(MediaQuery.size)` 把虚拟手柄按钮锚定在画面区内(不再漂到黑边)。
- 新增 `test/title_render_test.dart` 横屏用例(2340×1080):画布非 0 宽、手柄按钮落在 `stage` 内。
- **`withOpacity` 弃用清理**:5 处换成 `Color.withValues(alpha: o*255)`(本 SDK Dart 3.12 的 `withValues` 只有 `alpha` 参数、无 `opacity`;`opacity` 写法会编译失败,已实测回退)。`RawKeyboard` 等其余弃用属输入层 API、仍可用、保持不动(重写风险大收益小)。
- **最终确认**:`flutter analyze` **0 error**(约 201 条 info 级 lint,主要是 web 常量大写命名 style,不影响构建);`flutter test` **13/13 通过**(gameplay 物理保真 4 项 + title 渲染/横屏/手柄 8 项 + AppRoot smoke 1 项)。物理保真:JUMP 6.6/GRAV 0.25、可变跳高、死亡不自动重生按 R 回存档点均与 web 一致。
- **用户侧交付**:本沙箱无法跑原生构建,需用户在本地 `app/` 执行 `flutter pub get` → `flutter build apk`(安卓)/ `flutter build macos` / `flutter build linux` / `flutter build windows`;iOS 触屏检测仍可拓宽。