Files

96 lines
16 KiB
Markdown
Raw Permalink 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-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。
## 跳跃手感「稍微飘一点」,但高度与二段高度不变(PHY 微调)
用户:"跳跃手感再稍微飘一点,但是跳跃高度和其他都不能变"。
- **原理**:飘 = 更低重力换更长滞空;但顶点高度由 v²/(2g) 决定,同比例降重力 + 提初速可精确保持高度。物理常量在 `app/lib/src/game/player.dart` 的 `PHY`(web 的 `js/player.js`/`tools/lib-reach.mjs` 在当前仓库已不存在,移植版是唯一定义处,无需再同步求解器)。
- **改后值**:GRAV 0.25→0.23、GRAV_FALL 0.32→0.30、GRAV_DJ 0.35→0.322、JUMP 6.6→6.326、DJUMP 6.7→6.420;HSPD/MAXFALL/DJ_INERTIA/WATER_*/ICE_*/SLIDE_* 全未动。
- **验证(Python 复刻引擎积分 `vy=min(vy+g,MAXFALL);y+=vy`)**:单跳顶点 83.850px、二段自身 60.800px、总高仍 4.5 格 —— 三者 Δ 均 ≈0;滞空 49→51 帧(+2,≈4%,即"稍微飘")。
- **代价(须告知用户)**:滞空变长使单次跳跃的水平飞行距离增加约 HSPD×2 ≈ 5px,关卡跳跃可达性会略变"更宽松",这是"飘"的固有副作用;用户明确要求不重调地图,故未动关卡。
- **回归测试**:`test/gameplay_test.dart` 新增 `满力单跳顶点高度保持 ≈83.85px(飘但不改高度)`;并把旧的 `JUMP 6.6 / GRAV 0.25` 断言同步改为新值(松键截止量硬编码 -3 未变,只把 0.25→0.23)。
- 验证:`flutter test` 23/23、`flutter analyze` 0 error。