fix: 修复迁移后的基础UI显示问题

This commit is contained in:
cancerai committed 2026-10-25 16:03:05 +08:00
1 parent 18017f819a
commit a93e06f60c
68 files changed
+2293 -8404

No files matched your search

+10
View File
@@ -178,3 +178,13 @@
- **`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 触屏检测仍可拓宽。
### 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 analyze` 0 error、`flutter test` **14/14 通过**。
+86
View File
@@ -0,0 +1,86 @@
# 2026-10-25
## 移动端沉浸式全屏(用户诉求:手机端整个游戏应沉浸式全屏显示)
之前只锁了横屏,但系统状态栏/导航栏仍可见,不是真正的沉浸。
改动文件:
- `app/lib/main.dart`:`_configureMobile()` 加 `SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky)`(隐藏状态栏+导航栏;滑出后自动收回,最适合游戏)。
- `app/android/app/src/main/res/values/styles.xml` 与 `values-night/styles.xml`:LaunchTheme/NormalTheme 加 `android:windowFullscreen=true`(冷启动即隐藏系统 UI,避免首帧闪现)。
- 新建 `values-v28/styles.xml` 与 `values-night-v28/styles.xml`:两主题再加 `android:windowLayoutInDisplayCutoutMode=shortEdges`,让画面延伸到刘海/挖孔区真正铺满(该属性仅 API28+,必须放 v28 限定目录否则低版本 aapt 编译报找不到资源)。
- `app/ios/Runner/Info.plist`:加 `UIViewControllerBasedStatusBarAppearance=false` + `UIStatusBarHidden=true`(launch 阶段即隐藏状态栏);并把 `UISupportedInterfaceOrientations` 与 `~ipad` 收紧为只横屏(与安卓一致)。
- `app/lib/src/app_root.dart`:`_AppRootState` 加 `WidgetsBindingObserver`,`didChangeAppLifecycleState` 在 `resumed` 时重设 `immersiveSticky`(切后台再回来不会丢沉浸,常规游戏标准做法)。
验证:`flutter analyze` 0 error;`flutter test` 14/14 通过。
说明:信箱(letterbox)黑边保留——游戏按比例完整显示在画面矩形内、外围黑边,这正是常规横版游戏的表现;沉浸仅指隐藏系统 UI、游戏铺满屏幕。iOS 底部 Home 指示条无法用公开 API 完全隐藏,属平台限制。沙箱仍无法跑原生 `flutter build`,需在真机 `flutter pub get && flutter run` 验证。
## 全屏铺满(去信箱黑边)+ 系统返回 = 游戏内上一页(用户二次诉求)
用户不接受信箱黑边("游戏内部显示也要全屏"),且系统返回手势不应退出游戏。
改动:
- `app/lib/src/ui/game_canvas.dart`:`letterboxRect` 改为 `stageMetrics(Size)` —— 视口保证 ≥800×608 完整可见,另一维随屏幕比例加宽铺满全屏(横屏手机:高度定标 608、vw=屏宽/scale≈1318;比游戏窄的窗口则宽度定标 800)。Painter 每帧 paint 前 `world.setViewport(vw,vh)`(只改纯数值,paint 阶段禁止 notifier)。
- `app/lib/src/game/world.dart`:新增 `viewW/viewH` 字段 + `setViewport()`;`startLevel` 用 `Camera(viewW,viewH)`;render/drawTitleBg 全部改用 viewW/viewH(含死亡提示居中、标题星空/刺地带)。摄像机自带关卡边界钳制,关卡比视口窄时自动居中 —— 横向看得多是"PC 游戏支持任意宽高比"的同款做法,不裁内容、不变形。
- `app/lib/src/ui/touch_controls.dart`:按钮定位从 letterboxRect 改为整屏 Rect(画面已铺满,无黑边可飘)。
- `app/lib/src/app_root.dart`:`MaterialApp.home` 包 `PopScope(canPop:false, onPopInvokedWithResult)` → `_handleSystemBack()`:playing→pause;paused→resume;result→toLevels;editor→toTitle;scr-levels/scr-settings→toTitle;标题页忽略(防误退)。
- 测试:`title_render_test.dart` 重写画布断言(铺满全屏、视口≥800×608)、手柄断言改整屏锚定;新增 2 个返回导航用例(关卡选择→标题、游戏中→暂停→继续,用 `tester.binding.handlePopRoute()` 模拟)。
验证:analyze 0 error;`flutter test` 16/16 通过。
## 修复「跳跃键经常失灵」(用户反馈)
根因有两处叠加:
1. **虚拟键卡住常按(主因)**:`touch_controls.dart` 的按钮用 `Listener`(不捕获指针)。手指在键上滑动后于键外松手时,松手事件落到别的控件 → `touch['jump']` 永久卡在 `true`;之后每次按下都因 `Input.setTouch` 的「值未变则 return」短路,触发不了新边沿 → `hit('jump')` 恒 false,跳不起来、时好时坏。`_TouchButton._release` 还有 `if(!_pressed) return` 进一步放任卡键。
- 修:`GestureDetector(behavior: HitTestBehavior.opaque, onPanDown/onPanEnd/onPanCancel)` 替代 `Listener`(Pan 识别器会认领指针,键外抬起/取消也回传本按钮);`_release` 改为**无论如何都强制 `setTouch(a,false)` 复位**(去掉 `_pressed` 早退)。
2. **按钮重叠误触**:旧默认布局大号跳跃键(94px)与射击/下蹲键在纵向像素重叠,重叠区按下会落到上层(射击)键。`BUTTONS` 重排让 `jump` 居末(绘制最上层,重叠区优先归跳跃键),并微调坐标消除重叠。
3. **正确性问题(顺带)**:`world.tick` 的 `Input.endFrame()` 原本整个 tick 调一次;合帧时某帧跑 0 次更新、下一帧跑 2 次,会让一次按键被当两帧 hit → 误触二段跳。改为**每个固定步长子步都调一次 `endFrame()`**(now 在两子步间保持,下一子步才清 prev,不丢按键)。
新增回归测试:模拟「跳跃键上按下→滑到屏幕外松手」,断言 `Input.held('jump')` 最终为 false(验证不再卡键)。
验证:analyze 0 error;`flutter test` 17/17 通过。注:用户设备已保存旧触屏布局(首跑写入),默认布局改动只影响新装/「恢复默认」;卡键修复与布局无关,对其现设备直接生效。
## 首页去滚动(改等比缩小)+ 编辑器溢出修复 + 关卡选择去色块(用户反馈)
1. **首页滚动被用户否决**:`screens.dart` TitleScreen 的 `_FitScroll` 改为 `FittedBox(fit: BoxFit.scaleDown)` + `Column(mainAxisSize: min)` —— 放得下按原尺寸、放不下整体等比缩小居中,既不滚动也不溢出。设置屏保留 `_FitScroll`(内容 ~600px 缩到 310px 会小到不可读,滚动是合理方案)。
2. **编辑器 RIGHT OVERFLOWED BY 48 PIXELS**:根因是 `ElevatedButton` 默认 `minimumSize(64,36)` + `MaterialTapTargetSize.padded`,属性栏 200px 宽塞不下三个 `_SmallButton`。修复:两个文件的 `_SmallButton` 加 `minimumSize: Size.zero` + `tapTargetSize: shrinkWrap` + `visualDensity: compact`;顶部工具行 Row→`Wrap(spacing:8, runSpacing:6)`(窄屏自动换行);面操作行 Row→Wrap;`_portalRow` Row→Wrap(双下拉可能超宽)。
3. **关卡选择去掉蓝绿黄色块**:删掉底部 `_Tag` 图例 Wrap 和卡片内的类型 `_Tag`(用户:没什么用;分类已有 Tab 承担);`_Tag`/`_tagColor`/`typeLabel`/`typeClass` 一并删除,import 清理为仅 LEVELS。卡片保留编号/名称/难度星星/通关状态。
4. 测试:小高度标题用例从 `scrollUntilVisible` 改为断言 footer 一屏可见;新增「编辑器 800×360 不溢出」回归(EditorController.open(25,19) 真渲染)。`flutter test` 18/18、analyze 0 error。
## 「自定义按键」独立全屏页(用户需求)
- **新文件 `lib/src/ui/touch_editor.dart`**:`TouchLayoutEditor` 全屏编辑页,作为 AppRoot 的浮层 `scr-touchedit`(透出下方实时游戏画布=标题星空动画,与玩游戏同等沉浸)。按钮用与 `_TouchButton` 完全一致的渲染与 `k=(w/W).clamp(0.6,2.6)` 缩放(所见即所得);点选后出现右下角黄点手柄拖动改大小(30~180);拖动/缩放**即改即存**(writeTouchLayout→metaSettings+Save.flush),返回无需确认;顶部「恢复默认/返回」,底部提示。
- **入口**:设置页触摸区去掉内嵌 `_TouchPreview`(连同 `_DragBtn`/`_btnLabel`/`_cloneTouch`/`_touch` 工作副本一并删除,touch 布局不再由设置页代管,_confirm 不再写 s['touch']),改为「自定义按键」按钮 → `screen.value='scr-touchedit'`;系统返回手势映射为回设置页。
- **默认布局仿用户截图**(touch_layout.dart BUTTONS):◀(12.5,81,62) ▶(22,81,62) 🔫(78.5,90,56) ▼(94.5,89,56) ❚❚(96.5,8,40) ↺(91,56,52) 跳(85,73,78)。注意:老设备存档里已有旧布局,默认值只影响新装/恢复默认。
- **血泪**:macOS 沙箱里 `sed -i ''` 静默成功但参数被吞 → python 二次删除同一区间,误删 screens.dart 的 `_FitScroll/_MenuBg/_MenuButton/_SmallButton/_Tab/_Modal`;已从本会话早前读过的完整内容精确重建(_SmallButton 带紧凑样式)。教训:批量删行一律用 python/专用工具,别混用 sed -i。
- **测试坑**:widget 测试里 `startGesture+单次 moveBy(60)` 不触发 pan(单大步位移不产出 update),改用 `tester.dragFrom` 或小步 move+pump 才有效。新测试:设置→自定义按键→全屏 7 键齐全→拖动跳跃键位置变化→返回设置。
- 验证:analyze 0 error;`flutter test` 18/18。
## 结算界面 BOTTOM OVERFLOWED BY 43px + 编辑器调键盘溢出 5.8px(用户反馈)
- **结算 `_Modal`**(暂停/结算共用):外层 Column(min) 里内容区包 `Flexible(SingleChildScrollView)`(min Column + Flexible 的标准做法:内容少时按原尺寸居中、超出时内部滚动)。横屏 ~360 逻辑高下「标题+4 行数据+新纪录+3 按钮」约 403px 必然超。
- **编辑器调键盘溢出**:`app_root` Scaffold 加 `resizeToAvoidBottomInset: false` —— 游戏类 App 不让软键盘压缩界面(一压缩固定头部就溢出);所有输入框(地图名/宽/高/用户名)都在上半区,键盘不遮挡。
- 顺带:编辑器 `_ImportModal` 地图列表同样包 Flexible+滚动(地图多时也会溢出)。
- 回归测试:800×360 下直泵 `ResultScreen`(better=true + hasNext=true 最长内容),断言无异常、三按钮可见。`flutter test` 19/19、analyze 0 error。
## 结算弹窗改回「紧凑固定、不可滚动、不撑满」(用户推翻上一轮方案)
用户明确反对上一轮的可滚动方案:「地图通关不能设计成可滚动的,而且高度不要撑满,太丑了」。
- **根因**:`Flexible(SingleChildScrollView)` 在 `Center` 下会把内容区撑到可用全高 → 弹窗高度=全屏高("撑满"),用户觉得丑。
- **新方案**:删掉 `Flexible`/`SingleChildScrollView`,`_Modal` 改 `Column(mainAxisSize.min)` + `Center`,高度只包住内容、不撑满。同时整体压紧凑以在 ~360 逻辑高横屏内不溢出:
- `lib/src/ui/screens.dart`:`_Modal` width 320→300、padding 20→14、标题 26→20、标题后间距 16→10;`_ResultLine` 行距 3→1、字号 15→14;`_MenuButton` 外距 5→3、内 padding 12→8、字号 16→14;`ResultScreen` 按钮前间距 12→8、新纪录上行距 6→4/字号16→15。
- `lib/src/app_root.dart`:三处 `MaterialApp` 全部加 `debugShowCheckedModeBanner:false` —— 去掉用户所说的「app右上角的bug标」(即 Flutter 调试模式默认右上角 DEBUG 角标)。
- **验证**:800×360 直泵 `ResultScreen`(better+hasNext 最长内容)不再 OVERFLOW;`flutter test` 19/19、`flutter analyze` 0 error。
- 教训:弹窗类 UI 在横屏小高度下,宁可用「紧凑固定尺寸」而非「可滚动」,用户对滚动版接受度低(觉得丑/撑满)。
## 关卡选择界面滑动报 Incorrect use of ParentDataWidget(修复)
- **根因**:`_LevelCard` 的「已通关 ✔ 角标」原来是 `Positioned` 直接放在**内层 `Column`** 里。但 `Positioned` 必须是 `Stack` 的直接子级(它外层的 Stack 隔着 `GestureDetector→Container→Column`),一旦某张已通关卡片被构建/滚入视图就抛 `Incorrect use of ParentDataWidget`。GridView 懒构建 → 表现为「滑动到某张卡片时才报错」。
- **修复**:把那枚 `Positioned` 角标从内层 Column 删掉,改为**外层 Stack 的直接子级**(与删除按钮的 `Positioned` 并列),只对 `rec != null` 显示。
- **回归测试**:新增 `关卡选择:已通关卡片的 ✔ 角标不报 ParentDataWidget 错误`——`Save.clear` 标记首关已通关后直泵 `LevelSelectScreen`,断言无异常且 `✔` 正常渲染。
- 验证:`flutter test` 20/20、`flutter analyze` 0 error。
## 离开编辑器后再进入报 setState() called after dispose()(修复)
- **根因**:`_EditorPanelState.initState` 把 `setState` 闭包直接挂到**共享** `EditorController.onChange`(闭包捕获当前 State);但 State 没有 `dispose()` 清掉它。`world.toEditor()` 在 `host.onShowScreen` 设屏之后**同步**调 `host.editorShow()` → `editor.showOrOpen()` → `onChange?.call()`(见 editor_panel.dart `showOrOpen`/`open` 等)。离开编辑器时旧 State 已 dispose、`onChange` 仍指向它,再次进入的那次同步 `onChange?.call()` 就对死 State 调 `setState` → 报错。
- **修复**(`lib/src/ui/editor_panel.dart`):① 把闭包存为 `_onChange` 字段并加 `if (mounted)` 守卫;② 新增 `dispose()`——若 `onChange == _onChange` 则置 `null`,并 `dispose()` 三个 `TextEditingController`(此前一直没 dispose,轻微泄漏);③ `initState` 改为赋值 `_onChange`。
- **回归测试**:新增 `进出编辑器多次不报 setState-after-dispose`——`_boot` 后进编辑器→点「返回」离开→再进,断言 `AppError.current` 为 null 且 `EditorPanel` 正常重建。
- 验证:`flutter test` 21/21、`flutter analyze` 0 error。
## 点开第二个地图报 type 'int' is not a subtype of type 'String?'(修复)
- **崩溃栈**:`Level.reset (level.dart:257) ← new Level ← World.startLevel ← LevelSelectScreen._play`。报错发生在构造含 `warp` 实体、但 `data.doors` 里找不到对应门的关卡时。
- **根因**:web 原版 `tmp/js/level.js:133` 是 `e.doorId = door ? door.id : e.idx;`——JS 弱类型下 `doorId` 既可是字符串也可是数字(int),`===` 比较同名类型即匹配。Flutter 移植把 `Entity.doorId` 声明成 `String?`(level.dart:62),无匹配门时兜底 `idx`(int)赋给 `String?` 即抛 TypeError。内置 23 关无 `doors/warps` 故不触发;自制关卡含 warp 且无匹配门即崩("先玩一个再开另一个"只是第二个关卡恰好有该内容)。
- **修复**(`lib/src/game/level.dart`):
1. `doorId` 字段由 `String?` 改为 `Object?`(容纳 String 或 int,与 web 一致);`world.dart` 里两处 `o.doorId == to['door']` 比较对 String/String、int/int 走值相等,语义不变。
2. 顺手加固同行的 null 兜底:`(data['doors'] as List?)?.cast<Map>()` 在 `doors` 为 null 时会因 `.firstWhere` 调在 null 上抛 `NoSuchMethodError`,改为 `?? const <Map>[]`,消除同类崩溃。
- **回归测试**:新增 `含 warp 但无匹配门的关卡可正常构建(doorId 类型回归)`——构造含 `W` 且 `doors:[]` 的关卡,`Level(data)` 不再抛 TypeError,且 warp 实体的 `doorId` 为 int(回退序号)。
- 验证:`flutter test` 22/22、`flutter analyze` 0 error。
+5 -1
View File
@@ -62,11 +62,15 @@
## Flutter 移植版(`app/`,脱胎于本 web 源码)
目标是 android+macos+linux+windows 四平台 100% 复刻 UI/功能/手感。核心约定:
- **渲染**:自定义 `Ticker` + `CustomPaint`,信箱缩放 800×608 → 适配屏幕;`GameCanvas` 用 `SizedBox.expand`(不要用 `Size.infinite`,零尺寸会让 scale=0 整屏消失)。
- **渲染**:自定义 `Ticker` + `CustomPaint`;**全屏铺满视口**(用户明确不要信箱黑边):`stageMetrics(Size)` 保证 800×608 完整可见、另一维随屏幕比例加宽(横屏手机=高度定标 608、vw≈1318),paint 前 `world.setViewport(vw,vh)` 同步给 Camera;render/drawTitleBg 一律用 `viewW/viewH`,别写回常量 W/H。`GameCanvas` 用 `SizedBox.expand`(不要用 `Size.infinite`,零尺寸会让 scale=0 整屏消失)。
- **系统返回手势 = 游戏内上一页**(用户明确不要退出 App):`PopScope(canPop:false)` → `_handleSystemBack()`(playing→暂停、paused→继续、result→关卡选择、settings/levels/editor→标题、标题页忽略)。
- **循环**:`World.tick(dt)` 固定 60Hz 累加器(`STEP=1000/60`),`Player.update(1)` 定步长 —— 手感 1:1 的关键,别改成 dt 缩放。
- **输入**:Flutter 按键经 `LogicalKeyboardKey.debugName` 去空格 → 喂给 `Input.onKey()`,得到与 web `KeyboardEvent.code` 完全相同的字符串(`ShiftLeft/KeyZ/KeyR/Backspace/Escape`)。
- **`Positioned` 必须是 `Stack` 的直接子级**(血泪):把它写在 `ValueListenableBuilder.builder` 的返回值里会丢弃定位数据 → 子树横向塌缩为 0 宽 → 表现为"黑屏"。一律写成 `Positioned(child: ValueListenableBuilder(...))`。
- **凡是"某状态决定某子树显隐",该状态必须可监听**:`World.state` 已改由 `ValueNotifier<String> stateN` 承载(曾在 `build()` 里读普通字段导致 HUD 永不出现)。
- **paint/build 阶段绝不能写被监听的 notifier**(血泪):曾在 `world.render()`(paint 阶段)里推 toast → 控制台刷 "Build scheduled during frame"。通则:一切 host 回调(onToast/onHud/onShowScreen…)只允许在 `World.tick()`(Ticker 回调 = transientCallbacks 阶段)里发,render() 只画不通知。
- **菜单屏用 `_FitScroll` 防小逻辑高度溢出**(screens.dart):横屏真机逻辑高度仅 ~360-400px,静态 Column 必溢出;`_FitScroll` 内部**禁止 Spacer/Expanded/Flexible**(无界高度会抛异常),列表屏用 ListView 自滚。测试注意:`setSurfaceSize` 设的是**逻辑**像素,模拟真机要设 ~800×360 而不是物理分辨率。
- **横屏锁定**:AndroidManifest `screenOrientation="sensorLandscape"`(冷启动首帧即横屏)+ main.dart 对 android/iOS `SystemChrome.setPreferredOrientations`;manifest 改动必须重装 App 才生效(hot reload 无效)。
- **音频不能拖垮游戏**:`Sfx` 有 `broken` 永久静音开关;`AppError.reportZone` 会把 audioplayers 的插件噪声降级为日志。
- **测试**:widget 测试永远覆盖不到 `main()` 的 bootstrap;断言要用 `tester.getSize()` 查**可见尺寸**(`find` 不查可见性)。游戏有常驻 Ticker → **禁用 `pumpAndSettle`**。原生构建需真实机器;本沙箱 Flutter 写不了自己的 `/opt/flutter/bin/cache` 缓存。
- 手感回归请跑 `flutter test`(含 `test/gameplay_test.dart` 的跳初速/重力/松手-guard/死亡重生断言)。