Files

16 KiB
Raw Permalink Blame History

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。