31 KiB
31 KiB
2026-10-24
HSPD 2.7→2.6 走路再慢一点点(用户:「kid走路速度再稍微慢一点点」)
js/player.jsPHY +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.jsPHY +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 L1carpet(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.jsPHY +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.jsPHY +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.jsPHY +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.jsPHY +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 与 webKeyboardEvent.code字符串一致:ArrowLeft/KeyA/ShiftLeft/KeyR/Backspace/Escape…);9 个 GameHost 方法接 UI。game_canvas.dart信箱缩放(800×608 虚拟→适配保留比例);overlays.dartHUD/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 虚拟窗口真实 pumpAppRoot,断言标题三按钮与"23 个内置关卡"文案可见、不卡加载态、无错误界面 → 全部通过。证明启动逻辑与 build 本身没问题(flutter test全 3 项绿)。
- 已排除:
- 结论:黑屏属设备级渲染问题,最可能是 Android 默认 Impeller(Vulkan)后端处理「每帧 scale+clipRect+渐变 shader 的 CustomPaint」时整帧丢失,会连普通 widget 一起黑掉。
- 修复/加固:
AndroidManifest.xml的<application>下加EnableImpeller=false(Flutter 官方关闭方式,置「逐帧重绘自定义 Canvas」避免 OEM ROM/Impeller 黑屏;确认正常后可改回 true)。game_canvas.dart:去掉isComplex:true(我们每帧重绘,缓存有害)——最可能致设备 blank 的元凶;去掉CustomPaint(size: Size.infinite)改SizedBox.expand(零/无穷尺寸会算出 scale=0 让整屏消失);paint 里加size/scale有限性与正数校验。- 新增
core/app_error.dart:全局错误收口。main.dart装FlutterError.onError+ErrorWidget.builder+runZonedGuarded,任何异常都切到可读错误详情界面(release 包原本静默吞异常只剩黑屏)。区分report(致命,占屏)与warn(非致命,仅 logcat)。 app_root.dart:_init()改分阶段 try/catch——存档/关卡失败用warn降级继续(不再导致_ready永假、永久停黑屏);世界装配失败才report;加 12s 看门狗超时报错;加载态换成带 CircularProgressIndicator 的_BootScreen(原为近黑空 Scaffold);build 三级优先级:错误详情 → 加载界面 →_buildGame()。game_canvas的world.render包 try/catch 上报渲染异常。
- 验证:
flutter analyze0 error / 0 warning(余 206 条为 info 级 lint);flutter test3/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 祖先会出错"。
- SDK 源码(
- 不要改回
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.dartHudOverlay 的 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)。
- web 版顺序是「本帧
- 把"布局塌缩"固化成回归测试(
test/title_render_test.dart,5 个用例):断言tester.getSize()的可见尺寸(Width > 0)而不是"widget 存在";另加端到端「标题 → 关卡选择 → 点卡进关 → HUD 显示关卡名」。注意游戏有常驻 Ticker,测试里不能用pumpAndSettle(会超时)。 - 当前状态:
flutter analyze0 error / 0 warning;flutter test10/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 analyze0 error(约 201 条 info 级 lint,主要是 web 常量大写命名 style,不影响构建);flutter test13/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 触屏检测仍可拓宽。
Flutter 真机首跑反馈修复:标题溢出 + Build scheduled during frame(同日晚)
- 用户真机(安卓横屏,锁屏生效——截图相机孔在左侧短边)反馈:① 标题菜单
BOTTOM OVERFLOWED BY 125 PIXELS;② 控制台刷Build scheduled during frame。 - ② 根因:
world.render()(paint 阶段)里调_pushToast(L.toast)→toast.value变化,而toast被 HudOverlay 的 ValueListenableBuilder 监听 → paint 阶段 markNeedsBuild。修复:把 toast 推送从render()移到tick()(Ticker 回调 = transientCallbacks 阶段,合法),并顺带从 update() 的 hudTick%5 块改为每帧推送(更贴 web 逐帧行为)。通则:paint/build 阶段绝不能写被 ValueListenableBuilder 监听的 notifier;一切 host 回调只允许在 tick() 里发。 - ① 根因:横屏真机逻辑高度只有 ~360-400px(1080 物理 ÷ DPR),标题 Column 内容 ~455px → 底部溢出 125px。之前测试没抓到是因为
setSurfaceSize(2340×1080)设的是逻辑像素,比真机大得多。修复:screens.dart新增_FitScroll(LayoutBuilder + SingleChildScrollView + ConstrainedBox(minHeight) + 保底高度):空间够时按原布局、不够时整体可滚动。应用于 TitleScreen(Column 已 center)。- SettingsScreen 同样包
_FitScroll,纵向Spacer()换SizedBox(height:16)(Spacer 在无界高度滚动容器里会抛异常;底部 Row 里的横向 Spacer 保留)。 - LevelSelectScreen 不动(含 Expanded 的列表布局,ListView 自滚)。
- 通则:
_FitScroll内部禁止 Spacer/Expanded/Flexible。
- 回归:
title_render_test.dart新增「横屏小逻辑高度(800×360)下标题不溢出」用例(pump 即可,overflow 异常会直接判失败;另 scrollUntilVisible 验证底部版权行滚动可达)。当前flutter analyze0 error、flutter test14/14 通过。