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

29 KiB
Raw Blame History

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.6240px(原 120231px)。
  • 破坏 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.57,二段实际提升仅 515px 甚至归零。
  • 修复: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:
    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 触屏检测仍可拓宽。