refactor: 使用flutter对原先的web项目重构,正在进行中
This commit is contained in:
1 parent
273224f5cf
commit
18017f819a
205 files changed
+13563
No files matched your search
@@ -104,3 +104,77 @@
|
||||
- 测试: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 触屏检测仍可拓宽。
|
||||
@@ -60,3 +60,14 @@
|
||||
- 仅在 `playing` 态显示(`startLevel`/`resume` 显示,菜单/暂停/结算隐藏);HUD 每帧 `Touch.setGun(P.hasGun)` 控制射击键置灰。
|
||||
- 若改键位含义,记得同步 `js/input.js` 的 `BIND` 与 `touch.js` 的 `HELD`/`api` 映射。
|
||||
|
||||
## Flutter 移植版(`app/`,脱胎于本 web 源码)
|
||||
目标是 android+macos+linux+windows 四平台 100% 复刻 UI/功能/手感。核心约定:
|
||||
- **渲染**:自定义 `Ticker` + `CustomPaint`,信箱缩放 800×608 → 适配屏幕;`GameCanvas` 用 `SizedBox.expand`(不要用 `Size.infinite`,零尺寸会让 scale=0 整屏消失)。
|
||||
- **循环**:`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 永不出现)。
|
||||
- **音频不能拖垮游戏**:`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/死亡重生断言)。
|
||||
|
||||
Reference in new issue
Block a user