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

+382 -66
View File
@@ -12,13 +12,16 @@ import 'package:iwbtc/src/app_root.dart' show AppRoot;
import 'package:iwbtc/src/core/app_error.dart' show AppError;
import 'package:iwbtc/src/core/save.dart' show Save;
import 'package:iwbtc/src/game/levels.dart' show loadLevels, LEVELS;
import 'package:iwbtc/src/game/world.dart'
show GameHost, World, W, H;
import 'package:iwbtc/src/ui/game_canvas.dart'
show GameCanvas, letterboxRect;
import 'package:iwbtc/src/game/level.dart' show Level;
import 'package:iwbtc/src/game/world.dart' show GameHost, World, W, H;
import 'package:iwbtc/src/ui/game_canvas.dart' show GameCanvas, stageMetrics;
import 'package:iwbtc/src/ui/overlays.dart' show HudOverlay;
import 'package:iwbtc/src/ui/screens.dart' show SettingsScreen;
import 'package:iwbtc/src/ui/screens.dart'
show SettingsScreen, ResultScreen, LevelSelectScreen;
import 'package:iwbtc/src/ui/editor_panel.dart'
show EditorPanel, EditorController;
import 'package:iwbtc/src/ui/touch_controls.dart' show TouchControls;
import 'package:iwbtc/src/core/input.dart' show Input;
/// 按典型**横屏手机**分辨率设定虚拟窗口(安卓端已锁定横屏)。
const Size kLandscapePhone = Size(2340, 1080);
@@ -62,15 +65,19 @@ void main() {
final btn = find.text('关卡选择');
final btnSize = tester.getSize(btn);
final btnPos = tester.getTopLeft(btn);
expect(btnSize.width, greaterThan(0),
reason: '按钮宽度塌缩为 0,标题树布局被破坏(Positioned 必须是 Stack 直接子级)');
expect(
btnSize.width,
greaterThan(0),
reason: '按钮宽度塌缩为 0,标题树布局被破坏(Positioned 必须是 Stack 直接子级)',
);
expect(btnSize.height, greaterThan(0));
expect(btnPos.dx, greaterThanOrEqualTo(0), reason: '按钮被定位到屏外');
expect(btnPos.dy, greaterThanOrEqualTo(0));
});
testWidgets('横屏小逻辑高度下标题不溢出(真机 BOTTOM OVERFLOWED 回归)',
(WidgetTester tester) async {
testWidgets('横屏小逻辑高度下标题不溢出(真机 BOTTOM OVERFLOWED 回归)', (
WidgetTester tester,
) async {
// 真机横屏 1080 物理高 ÷ DPR ≈ 360~400 **逻辑**像素;此前测试用的
// 2340×1080 是逻辑尺寸、远高于真机,所以测不出溢出。
await tester.binding.setSurfaceSize(const Size(800, 360));
@@ -91,13 +98,15 @@ void main() {
expect(find.text('关卡选择'), findsOneWidget);
expect(tester.getSize(find.text('关卡选择')).height, greaterThan(0));
// 一屏放不下的底部文案必须滚动可达
// 整体等比缩小方案(FittedBox.scaleDown):不滚动、不溢出,
// 底部版权行在一屏内直接可见(只是整体缩小了)。
final footer = find.textContaining('非商业同人作品');
await tester.scrollUntilVisible(footer, 120);
expect(footer, findsOneWidget);
expect(footer, findsOneWidget, reason: '标题内容应等比缩小后完整可见,而不是被裁掉');
final btn = tester.getSize(find.text('关卡选择'));
expect(btn.height, greaterThan(0));
});
testWidgets('游戏画布拿到真实尺寸并按信箱缩放', (WidgetTester tester) async {
testWidgets('游戏画布铺满全屏(无信箱黑边)且虚拟视口完整包含 800×608', (WidgetTester tester) async {
await _boot(tester);
final canvas = find.byType(GameCanvas);
@@ -107,18 +116,14 @@ void main() {
expect(size.width, greaterThan(0), reason: '画布宽度为 0,界面不会显示任何内容');
expect(size.height, greaterThan(0));
expect(size.width, kLandscapePhone.width);
// 800×608 的画面按信箱缩放居中:横屏下高度占满、左右留黑边
final double s =
size.width / W < size.height / H ? size.width / W : size.height / H;
final stage = letterboxRect(size);
expect(s, greaterThan(0));
expect(stage.width, closeTo(800 * s, 0.001));
expect(stage.height, closeTo(608 * s, 0.001));
expect(stage.left, closeTo((size.width - 800 * s) / 2, 0.001));
expect(stage.width, lessThan(size.width),
reason: '横屏宽高比大于画面比例,应当左右留黑边');
expect(stage.height, closeTo(size.height, 0.001),
reason: '横屏下画面高度应被占满');
// 横屏比 800×608 宽 → 高度定标 608、视口横向加宽,铺满全屏无黑边
final m = stageMetrics(size);
expect(m.isValid, isTrue);
expect(m.scale, closeTo(size.height / H, 0.001));
expect(m.vh, closeTo(H, 0.001));
expect(m.vw, closeTo(size.width / m.scale, 0.001));
expect(m.vw, greaterThanOrEqualTo(W), reason: '视口必须完整包含原 800 宽的画面(不裁游戏内容)');
});
testWidgets('HUD / Toast 有可见尺寸(不得横向塌缩)', (WidgetTester tester) async {
@@ -142,23 +147,30 @@ void main() {
toast.dispose();
});
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: Stack(
children: <Widget>[
Positioned.fill(child: HudOverlay(hud: hud, boss: boss, toast: toast)),
],
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Stack(
children: <Widget>[
Positioned.fill(
child: HudOverlay(hud: hud, boss: boss, toast: toast),
),
],
),
),
),
));
);
await tester.pump();
// 关卡名必须被撑开到有宽度(Positioned 嵌套错误时会塌缩为 0)
final name = find.text('测试关卡');
expect(name, findsOneWidget);
final Size size = tester.getSize(name);
expect(size.width, greaterThan(0),
reason: 'HUD 宽度为 0:Positioned 未作为 Stack 直接子级,布局被丢弃');
expect(
size.width,
greaterThan(0),
reason: 'HUD 宽度为 0:Positioned 未作为 Stack 直接子级,布局被丢弃',
);
expect(size.height, greaterThan(0));
// Toast 同样必须可见
@@ -171,8 +183,7 @@ void main() {
expect(find.text('存档 0/4'), findsOneWidget);
});
testWidgets('从标题点进关卡能真正开始游戏(端到端)',
(WidgetTester tester) async {
testWidgets('从标题点进关卡能真正开始游戏(端到端)', (WidgetTester tester) async {
await _boot(tester);
// 进入关卡选择(注意:游戏 Ticker 常驻,不能用 pumpAndSettle)
@@ -195,8 +206,7 @@ void main() {
expect(hudName, findsWidgets);
});
testWidgets('横屏下虚拟手柄按钮落在画面区域内(不飘到黑边上)',
(WidgetTester tester) async {
testWidgets('横屏下虚拟手柄按钮锚定全屏且完整在屏内', (WidgetTester tester) async {
await tester.binding.setSurfaceSize(kLandscapePhone);
addTearDown(() => tester.binding.setSurfaceSize(null));
SharedPreferences.setMockInitialValues(<String, Object>{});
@@ -215,45 +225,176 @@ void main() {
// 需要一个 World 才有回调目标(构造不触发任何加载)。
final world = World(_Host());
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: Stack(
children: <Widget>[
Positioned.fill(
child: TouchControls(
world: world, visible: visible, hasGun: hasGun),
),
],
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Stack(
children: <Widget>[
Positioned.fill(
child: TouchControls(
world: world,
visible: visible,
hasGun: hasGun,
),
),
],
),
),
),
));
);
await tester.pump();
final Size screen = tester.getSize(find.byType(TouchControls));
final stage = letterboxRect(screen);
expect(stage.left, greaterThan(0), reason: '横屏下应存在左右黑边');
// 画面铺满全屏后,按钮直接按整屏百分比锚定(不再有黑边偏移)
// 「◀」左移键必须落在画面矩形内,而不是黑边上
// 「◀」左移键必须完整在屏内
final leftBtn = find.text('◀');
expect(leftBtn, findsOneWidget);
final Size btnSize = tester.getSize(leftBtn);
expect(btnSize.width, greaterThan(0));
final Offset pos = tester.getTopLeft(leftBtn);
expect(pos.dx, greaterThanOrEqualTo(stage.left),
reason: '左移键飘到了左侧黑边上(应锚定在画面区域内)');
expect(pos.dx + btnSize.width, lessThanOrEqualTo(stage.right));
expect(pos.dx, greaterThanOrEqualTo(0), reason: '左移键被定位到屏外');
expect(pos.dx + btnSize.width, lessThanOrEqualTo(screen.width));
// 「跳」键同理,且必须完整在屏内
final jumpBtn = find.text('跳');
expect(jumpBtn, findsOneWidget);
final Offset jp = tester.getTopLeft(jumpBtn);
final Size js = tester.getSize(jumpBtn);
expect(jp.dx, greaterThanOrEqualTo(stage.left));
expect(jp.dx, greaterThanOrEqualTo(0));
expect(jp.dx + js.width, lessThanOrEqualTo(screen.width));
expect(js.width, greaterThan(0));
});
testWidgets('设置界面能渲染,手柄预览可见', (WidgetTester tester) async {
testWidgets('虚拟跳跃键:在按键外松手也能复位(不卡住常按)', (WidgetTester tester) async {
// 回归:旧实现用 Listener(不捕获指针),手指在键上滑动后于键外松手时,
// 松手事件落到别的控件上 → touch['jump'] 永久卡在 true,之后每次按下都
// 因 setTouch 短路而不触发新边沿 → 跳跃键经常失灵。改用 GestureDetector(Pan)
// 后,抬起/取消事件必定回传本按钮,松手即复位。
await tester.binding.setSurfaceSize(kLandscapePhone);
addTearDown(() => tester.binding.setSurfaceSize(null));
SharedPreferences.setMockInitialValues(<String, Object>{});
await tester.runAsync(() async {
await Save.load();
await loadLevels();
});
final visible = ValueNotifier<bool>(true);
final hasGun = ValueNotifier<bool>(false);
addTearDown(() {
visible.dispose();
hasGun.dispose();
Input.clear();
});
final world = World(_Host());
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Stack(
children: <Widget>[
Positioned.fill(
child: TouchControls(
world: world,
visible: visible,
hasGun: hasGun,
),
),
],
),
),
),
);
await tester.pump();
final center = tester.getCenter(find.text('跳'));
final g = await tester.startGesture(center);
await tester.pump();
expect(Input.held('jump'), isTrue, reason: '按下后跳跃键应为按下态');
// 手指滑到屏幕左上角再松手(旧实现会丢失松手事件 → 卡住常按)
await g.moveTo(const Offset(5, 5));
await g.up();
await tester.pump();
expect(Input.held('jump'), isFalse, reason: '在键外松手后跳跃键必须复位,否则会卡住常按导致后续失灵');
});
testWidgets('系统返回手势:关卡选择 → 返回标题(不退出应用)', (WidgetTester tester) async {
await _boot(tester);
// 进入关卡选择
await tester.tap(find.text('关卡选择'));
await tester.pump(const Duration(milliseconds: 100));
await tester.pump();
expect(find.text('返回'), findsWidgets);
// 模拟系统返回手势/返回键
await tester.binding.handlePopRoute();
await tester.pump(const Duration(milliseconds: 100));
await tester.pump();
// 回到标题(标题菜单重新出现),应用未退出
expect(find.text('自制关卡'), findsOneWidget);
expect(find.textContaining('23 个内置关卡'), findsOneWidget);
});
testWidgets('系统返回手势:游戏中 → 暂停菜单;再按 → 继续游戏', (WidgetTester tester) async {
await _boot(tester);
await tester.tap(find.text('关卡选择'));
await tester.pump(const Duration(milliseconds: 100));
await tester.pump();
final card = find.text(LEVELS.first['name'] as String);
await tester.tap(card.first);
await tester.pump(const Duration(milliseconds: 200));
await tester.pump();
// 游戏中按返回 → 应弹出暂停菜单,而不是退出应用
await tester.binding.handlePopRoute();
await tester.pump(const Duration(milliseconds: 100));
await tester.pump();
expect(find.text('继续'), findsOneWidget, reason: '返回应打开暂停菜单');
// 暂停中再按返回 → 继续游戏(暂停菜单消失)
await tester.binding.handlePopRoute();
await tester.pump(const Duration(milliseconds: 100));
await tester.pump();
expect(find.text('继续'), findsNothing, reason: '返回应恢复游戏');
});
testWidgets('自制关卡编辑器在横屏小屏下不溢出(RIGHT OVERFLOWED 回归)', (
WidgetTester tester,
) async {
// 回归:属性栏 200px 宽里塞三个默认 64px 最小宽的按钮必然横向溢出;
// 顶部工具行在窄屏也可能溢出。修复后按钮紧凑化 + 工具行可换行。
await tester.binding.setSurfaceSize(const Size(800, 360));
addTearDown(() => tester.binding.setSurfaceSize(null));
SharedPreferences.setMockInitialValues(<String, Object>{});
await tester.runAsync(() async {
await Save.load();
await loadLevels();
});
final c = EditorController();
c.open(25, 19);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: EditorPanel(controller: c, onBack: () {}),
),
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(AppError.current.value, isNull, reason: '编辑器渲染时抛出了溢出/布局异常');
expect(find.text('删除'), findsOneWidget);
expect(find.text('+面'), findsOneWidget);
expect(find.text('保存'), findsOneWidget);
});
testWidgets('设置界面渲染,并能进入独立全屏的自定义按键页', (WidgetTester tester) async {
await _boot(tester);
await tester.tap(find.text('设置'));
@@ -262,12 +403,120 @@ void main() {
expect(AppError.current.value, isNull, reason: '设置界面渲染时抛出了异常');
expect(find.byType(SettingsScreen), findsOneWidget);
// 手柄预览区(无线端:安卓默认 isTouch=true,键位区按设计不显示)
expect(find.text('手机操作按钮'), findsOneWidget);
final Size btn = tester.getSize(find.text('跳').first);
expect(btn.width, greaterThan(0), reason: '手柄预览按钮塌缩为 0 宽');
// 触屏端入口:进入独立全屏编辑页
expect(find.text('自定义按键'), findsOneWidget);
expect(find.text('恢复默认'), findsOneWidget);
expect(find.text('确定'), findsOneWidget);
// 进入自定义按键页:应全屏显示,所有虚拟键可编辑
await tester.tap(find.text('自定义按键'));
await tester.pump(const Duration(milliseconds: 100));
await tester.pump();
expect(AppError.current.value, isNull, reason: '自定义按键页渲染时抛出了异常');
expect(find.text('返回'), findsWidgets); // 编辑页返回按钮
expect(find.text('恢复默认'), findsOneWidget);
// 所有默认虚拟键都在编辑页里
for (final label in <String>['◀', '▶', '跳', '🔫', '▼', '❚❚', '↺']) {
expect(find.text(label), findsOneWidget, reason: '缺少 $label 按钮');
}
// 跳跃键必须有可见尺寸(布局编辑器按钮不能塌缩)
expect(tester.getSize(find.text('跳')).width, greaterThan(0));
// 拖动跳跃键 → 布局即时保存;返回设置页
final before = tester.getTopLeft(find.text('跳'));
await tester.dragFrom(
tester.getCenter(find.text('跳')),
const Offset(60, -40),
);
await tester.pump();
final after = tester.getTopLeft(find.text('跳'));
expect(after.dx, greaterThan(before.dx), reason: '拖动后按钮应移动');
await tester.tap(find.text('返回').last);
await tester.pump(const Duration(milliseconds: 100));
await tester.pump();
expect(find.text('自定义按键'), findsOneWidget, reason: '应回到设置页');
});
testWidgets('结算界面在横屏小高度下不溢出(BOTTOM OVERFLOWED 回归)', (
WidgetTester tester,
) async {
// 回归:_Modal 固定内容(标题+4 行数据+新纪录+3 按钮)在 ~360 逻辑高的
// 横屏手机上曾超出屏高。修复后改为紧凑固定卡片(不滚动、不撑满全屏),
// 内容压到能在 360 逻辑高内完整放下。
await tester.binding.setSurfaceSize(const Size(800, 360));
addTearDown(() => tester.binding.setSurfaceSize(null));
SharedPreferences.setMockInitialValues(<String, Object>{});
await tester.runAsync(() async {
await Save.load();
await loadLevels();
});
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: ResultScreen(
world: World(_Host()),
result: <String, dynamic>{
'name': 'DOUBLE JUMP',
'ms': 13490,
'deaths': 0,
'apples': 0,
'totalApples': 0,
'better': true,
'hasNext': true,
},
),
),
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(AppError.current.value, isNull, reason: '结算界面渲染时抛出了溢出/布局异常');
expect(find.text('CLEAR!'), findsOneWidget);
// 三个按钮都必须构建且可见(在滚动区内)
expect(find.text('下一关'), findsOneWidget);
expect(find.text('再挑战'), findsOneWidget);
expect(tester.getSize(find.text('下一关')).height, greaterThan(0));
});
testWidgets('关卡选择:已通关卡片的 ✔ 角标不报 ParentDataWidget 错误', (
WidgetTester tester,
) async {
// 回归:_LevelCard 曾把「已通关 ✔ 角标」写成内层 Column 的直接子级
// Positioned(Positioned 必须是 Stack 直接子级)→ 一旦该卡片被构建就抛
// Incorrect use of ParentDataWidget。先标记一关为已通关,使其渲染角标。
await tester.binding.setSurfaceSize(const Size(800, 360));
addTearDown(() => tester.binding.setSurfaceSize(null));
SharedPreferences.setMockInitialValues(<String, Object>{});
await tester.runAsync(() async {
await Save.load();
await loadLevels();
});
final id = LEVELS.first['id'] as String? ?? '';
Save.clear(id, 3, 12345, 0);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: LevelSelectScreen(
world: World(_Host()),
reload: ValueNotifier<int>(0),
onChanged: () {},
),
),
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(AppError.current.value, isNull,
reason: '已通关卡片渲染时抛出了 ParentDataWidget 异常');
// 角标应作为外层 Stack 的直接子级正常渲染(不报错)。
expect(find.text('✔'), findsWidgets, reason: '已通关角标未正常渲染');
});
testWidgets('桌面(非触屏)设置界面显示键位重绑', (WidgetTester tester) async {
@@ -279,15 +528,18 @@ void main() {
await loadLevels();
});
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: SettingsScreen(
world: World(_Host()),
isTouch: false,
onBack: () {},
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SettingsScreen(
world: World(_Host()),
isTouch: false,
onBack: () {},
onTouchEditor: () {},
),
),
),
));
);
await tester.pump();
expect(AppError.current.value, isNull, reason: '设置界面构建时抛出了异常');
@@ -302,6 +554,70 @@ void main() {
expect(find.text('Shift'), findsWidgets);
});
testWidgets('进出编辑器多次不报 setState-after-dispose', (
WidgetTester tester,
) async {
// 回归:EditorPanel 在 initState 把 setState 闭包挂到共享的
// EditorController.onChange,离开时若不清掉,再次进入触发
// controller.showOrOpen() → onChange?.call() 会对已 dispose 的 State
// 调 setState(setState() called after dispose)。
await _boot(tester);
// 进编辑器
await tester.tap(find.text('自制关卡'));
await tester.pump(const Duration(milliseconds: 100));
await tester.pump();
expect(find.byType(EditorPanel), findsOneWidget, reason: '应进入编辑器');
expect(AppError.current.value, isNull, reason: '首次进入不应抛异常');
// 返回标题(EditorPanel 被 dispose)
await tester.tap(find.text('返回').first);
await tester.pump(const Duration(milliseconds: 100));
await tester.pump();
expect(find.byType(EditorPanel), findsNothing, reason: '应离开编辑器');
// 再次进入:此处同步触发 controller.showOrOpen() → onChange?.call()
await tester.tap(find.text('自制关卡'));
await tester.pump(const Duration(milliseconds: 100));
await tester.pump();
expect(AppError.current.value, isNull,
reason: '再次进入编辑器时不应抛 setState-after-dispose');
expect(find.byType(EditorPanel), findsOneWidget);
});
testWidgets('含 warp 但无匹配门的关卡可正常构建(doorId 类型回归)', (
WidgetTester tester,
) async {
// 回归:Level.reset 给 warp 实体的 doorId 赋值时,若 (tx,ty) 在 data.doors
// 里找不到对应门,会回退到实体序号 idx(int)。原字段声明为 String?,把
// int 赋给 String? 即抛 "type 'int' is not a subtype of type 'String?'",
// 在关卡选择里点开这类自制关卡便崩溃(崩溃栈:Level.reset → new Level →
// World.startLevel → LevelSelectScreen._play)。修复后 doorId 为 Object?。
final data = <String, dynamic>{
'id': 'test-warp',
'name': 'warp 关卡',
'type': 'custom',
'width': 12,
'grid': <String>[
' ',
' W ',
' ',
],
'doors': <dynamic>[],
};
late Level lv;
// 此前此处直接抛 TypeError,导致第二次点开地图即崩溃
expect(() {
lv = Level(data);
}, returnsNormally, reason: '含 warp 但无门的关卡不应抛类型错误');
final warp = lv.entities.firstWhere(
(e) => e.type == 'warp',
orElse: () => throw StateError('未生成 warp 实体'),
);
// 无匹配门时 doorId 回退为序号(int),与 web 原版弱类型语义一致
expect(warp.doorId, isA<int>(), reason: '无门时 doorId 应回退为 int 序号');
});
testWidgets('内置关卡清单能从 assets 正确加载', (WidgetTester tester) async {
await tester.runAsync(() async {
await Save.load();