refactor: 使用flutter对原先的web项目重构,正在进行中

This commit is contained in:
cancerai committed 2026-10-25 00:06:51 +08:00
1 parent 273224f5cf
commit 18017f819a
205 files changed
+13563

No files matched your search

+115
View File
@@ -0,0 +1,115 @@
// 无头玩法测试:验证「输入 → World → 物理」链路真的在动(手感保真的核心)。
// 不依赖 widget 树,直接用固定步长推进,与游戏内 world.tick 完全一致。
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:iwbtc/src/audio/sfx.dart' show Sfx;
import 'package:iwbtc/src/core/input.dart' show Input;
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;
class _Host implements GameHost {
String screen = '';
String toast = '';
bool touchShown = false;
@override
void onBossBar(dynamic boss) {}
@override
void onHud(Map<String, dynamic> hud) {}
@override
void onPauseMenu(String label) {}
@override
void onResult(Map<String, dynamic> result) {}
@override
void onShowScreen(String s) => screen = s;
@override
void onToast(String text) => toast = text;
@override
void editorShow() {}
@override
void touchHide() => touchShown = false;
@override
void touchSetGun(bool has) {}
@override
void touchShow() => touchShown = true;
}
Future<World> _start() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
await Save.load();
await loadLevels();
Input.clear();
Sfx.enabled = false; // 测试环境没有音频插件,保持静音(Sfx 也不会因此报错)
final world = World(_Host());
world.startLevel(LEVELS.first, 0);
return world;
}
/// 推进 n 个固定 60Hz 逻辑帧。
void _step(World w, int n) {
for (int i = 0; i < n; i++) {
w.tick(17);
}
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test('进入关卡后按方向键玩家会移动', () async {
final world = await _start();
expect(world.state, 'playing');
expect(world.player, isNotNull);
final double x0 = world.player!.x;
Input.onKey('ArrowRight', true);
_step(world, 15);
Input.onKey('ArrowRight', false);
expect(world.player!.x, greaterThan(x0), reason: '按住右键后玩家没有向右移动');
// 水平速度与 web 版一致(HSPD = 2.6)
expect(world.player!.vx, closeTo(2.6, 0.001));
});
test('跳跃初速与重力与 web 版一致(JUMP 6.6 / GRAV 0.25)', () async {
final world = await _start();
// 先落地,避免出生即空中导致二段跳语义不同
_step(world, 30);
final P = world.player!;
Input.onKey('ShiftLeft', true);
world.tick(17);
world.tick(17);
// 按住不放时持续受 GRAV = 0.25:-6.6 + 0.25 × 2
expect(P.vy, closeTo(-6.6 + 0.5, 0.001), reason: '上升重力不符');
});
test('松键即停:可变跳跃高度(与 web 版一致)', () async {
final world = await _start();
_step(world, 30);
final P = world.player!;
Input.onKey('ShiftLeft', true);
world.tick(17);
// 中途松开:web 版 player.js 会把 vy 收紧到 -3,再叠加一帧重力 → -2.75
Input.onKey('ShiftLeft', false);
world.tick(17);
expect(P.vy, closeTo(-3 + 0.25, 0.001), reason: '松键后的上升速度未按 -3 收紧');
});
test('死亡后不自动重生,按 R 才回存档点', () async {
final world = await _start();
final P = world.player!;
P.kill();
expect(P.dead, isTrue);
final double deadX = P.x;
Input.onKey('KeyR', false);
_step(world, 30);
expect(P.dead, isTrue, reason: '死亡后不应自动重生');
expect(P.x, deadX, reason: '死亡后玩家应定格在死亡点');
Input.onKey('KeyR', true);
world.tick(17);
Input.onKey('KeyR', false);
world.tick(17);
expect(P.dead, isFalse, reason: '按 R 后应重回存档点');
expect(world.deaths, 1);
});
}
+337
View File
@@ -0,0 +1,337 @@
// 真机冒烟测试:在虚拟设备尺寸下真实渲染界面。
// 目的:验证 AppRoot 完成初始化后能正常出画面,而不是停在加载态/错误界面(黑屏)。
// 任何 build / layout / paint 阶段的异常都会被测试捕获成失败。
//
// 关键回归点:Positioned 必须是 Stack 的**直接子级**。若把它写在 ValueListenableBuilder
// 的返回值里(隔着一层),定位数据被丢弃、子树横向塌缩为 0 宽 —— 这正是真机黑屏的根因。
// 因此这里断言的是「可见尺寸」,而不是「widget 存在」(find 不检查可见性,测不出塌缩)。
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
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/ui/overlays.dart' show HudOverlay;
import 'package:iwbtc/src/ui/screens.dart' show SettingsScreen;
import 'package:iwbtc/src/ui/touch_controls.dart' show TouchControls;
/// 按典型**横屏手机**分辨率设定虚拟窗口(安卓端已锁定横屏)。
const Size kLandscapePhone = Size(2340, 1080);
/// 完成 AppRoot 初始化并出到稳定帧。
Future<void> _boot(WidgetTester tester) async {
await tester.binding.setSurfaceSize(kLandscapePhone);
await tester.runAsync(() async {
await Save.load();
await loadLevels();
});
await tester.pumpWidget(const AppRoot());
await tester.pump();
await tester.pump(const Duration(milliseconds: 200));
await tester.pump();
expect(AppError.current.value, isNull, reason: '初始化/渲染过程中抛出了异常');
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() async {
AppError.current.value = null;
SharedPreferences.setMockInitialValues(<String, Object>{});
});
testWidgets('标题界面能真实渲染出来(非黑屏)', (WidgetTester tester) async {
await _boot(tester);
expect(find.textContaining('启动失败'), findsNothing);
// 不得仍停留在加载态
expect(find.text('正在加载关卡…'), findsNothing, reason: '一直停在加载态,等同于黑屏');
// 标题菜单必须存在
expect(find.text('关卡选择'), findsOneWidget);
expect(find.text('自制关卡'), findsOneWidget);
expect(find.text('设置'), findsOneWidget);
expect(find.textContaining('23 个内置关卡'), findsOneWidget);
// 关键回归:按钮必须有可见尺寸且在屏内
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.height, greaterThan(0));
expect(btnPos.dx, greaterThanOrEqualTo(0), reason: '按钮被定位到屏外');
expect(btnPos.dy, greaterThanOrEqualTo(0));
});
testWidgets('横屏小逻辑高度下标题不溢出(真机 BOTTOM OVERFLOWED 回归)',
(WidgetTester tester) async {
// 真机横屏 1080 物理高 ÷ DPR ≈ 360~400 **逻辑**像素;此前测试用的
// 2340×1080 是逻辑尺寸、远高于真机,所以测不出溢出。
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();
});
// 布局溢出(RenderFlex overflow)在测试里会作为异常抛出并使测试失败。
await tester.pumpWidget(const AppRoot());
await tester.pump();
await tester.pump(const Duration(milliseconds: 200));
await tester.pump();
expect(AppError.current.value, isNull, reason: '小高度下渲染抛出了异常');
expect(find.text('关卡选择'), findsOneWidget);
expect(tester.getSize(find.text('关卡选择')).height, greaterThan(0));
// 一屏放不下的底部文案必须滚动可达
final footer = find.textContaining('非商业同人作品');
await tester.scrollUntilVisible(footer, 120);
expect(footer, findsOneWidget);
});
testWidgets('游戏画布拿到真实尺寸并按信箱缩放', (WidgetTester tester) async {
await _boot(tester);
final canvas = find.byType(GameCanvas);
expect(canvas, findsOneWidget);
final Size size = tester.getSize(canvas);
// 必须是全屏尺寸,而不是 0(之前整棵 Stack 布局塌缩时画布也是 0×0)
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: '横屏下画面高度应被占满');
});
testWidgets('HUD / Toast 有可见尺寸(不得横向塌缩)', (WidgetTester tester) async {
await tester.binding.setSurfaceSize(const Size(1080, 2340));
addTearDown(() => tester.binding.setSurfaceSize(null));
final hud = ValueNotifier<Map<String, dynamic>>(<String, dynamic>{
'name': '测试关卡',
'deaths': 3,
'ms': 12345,
'apples': 1,
'totalApples': 3,
'save': 0,
'totalSaves': 4,
});
final boss = ValueNotifier<dynamic>(null);
final toast = ValueNotifier<String>('测试提示');
addTearDown(() {
hud.dispose();
boss.dispose();
toast.dispose();
});
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.height, greaterThan(0));
// Toast 同样必须可见
final tip = find.text('测试提示');
expect(tip, findsOneWidget);
expect(tester.getSize(tip).width, greaterThan(0));
// 苹果 / 存档 文案与 web 版保持一致
expect(find.text('苹果 1/3'), findsOneWidget);
expect(find.text('存档 0/4'), findsOneWidget);
});
testWidgets('从标题点进关卡能真正开始游戏(端到端)',
(WidgetTester tester) async {
await _boot(tester);
// 进入关卡选择(注意:游戏 Ticker 常驻,不能用 pumpAndSettle)
await tester.tap(find.text('关卡选择'));
await tester.pump(const Duration(milliseconds: 100));
await tester.pump();
// 点第一张关卡卡片 → 应进入 playing 态并显示 HUD
final card = find.text(LEVELS.first['name'] as String);
expect(card, findsWidgets);
await tester.tap(card.first);
await tester.pump(const Duration(milliseconds: 200));
await tester.pump();
expect(AppError.current.value, isNull, reason: '进入关卡的过程中抛出了异常');
// 画布仍在正常出帧
expect(tester.getSize(find.byType(GameCanvas)).width, greaterThan(0));
// HUD 的关卡名已显示且有可见宽度
final hudName = find.text(LEVELS.first['name'] as String);
expect(hudName, findsWidgets);
});
testWidgets('横屏下虚拟手柄按钮落在画面区域内(不飘到黑边上)',
(WidgetTester tester) async {
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();
});
// 需要一个 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.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));
// 「跳」键同理,且必须完整在屏内
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 + js.width, lessThanOrEqualTo(screen.width));
expect(js.width, greaterThan(0));
});
testWidgets('设置界面能渲染,手柄预览可见', (WidgetTester tester) async {
await _boot(tester);
await tester.tap(find.text('设置'));
await tester.pump(const Duration(milliseconds: 100));
await tester.pump();
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);
});
testWidgets('桌面(非触屏)设置界面显示键位重绑', (WidgetTester tester) async {
await tester.binding.setSurfaceSize(const Size(1280, 720));
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: SettingsScreen(
world: World(_Host()),
isTouch: false,
onBack: () {},
),
),
));
await tester.pump();
expect(AppError.current.value, isNull, reason: '设置界面构建时抛出了异常');
// 键位行(web 版 settings.js 的 KEY_ACTIONS)
expect(find.text('键盘按键'), findsOneWidget);
expect(find.text('左移'), findsOneWidget);
expect(find.text('跳跃'), findsOneWidget);
expect(find.text('自杀 / 重生'), findsOneWidget);
expect(find.text('整关重来'), findsOneWidget);
// 已绑定键位会以标签形式出现
expect(find.text('←'), findsWidgets);
expect(find.text('Shift'), findsWidgets);
});
testWidgets('内置关卡清单能从 assets 正确加载', (WidgetTester tester) async {
await tester.runAsync(() async {
await Save.load();
await loadLevels();
});
expect(LEVELS.isNotEmpty, isTrue, reason: 'levels.json 未加载到任何关卡');
expect(LEVELS.length, 23);
});
}
/// 仅供测试用的空 GameHost 实现。
class _Host implements GameHost {
@override
void onBossBar(dynamic boss) {}
@override
void onHud(Map<String, dynamic> hud) {}
@override
void onPauseMenu(String label) {}
@override
void onResult(Map<String, dynamic> result) {}
@override
void onShowScreen(String screen) {}
@override
void onToast(String text) {}
@override
void editorShow() {}
@override
void touchHide() {}
@override
void touchSetGun(bool has) {}
@override
void touchShow() {}
}
+12
View File
@@ -0,0 +1,12 @@
// Smoke test for the IWBT-C Flutter port. The default counter-app test was
// removed because the app root is `AppRoot` (see lib/src/app_root.dart), not the
// boilerplate `MyApp`.
import 'package:flutter_test/flutter_test.dart';
import 'package:iwbtc/src/app_root.dart' show AppRoot;
void main() {
testWidgets('AppRoot instantiates', (WidgetTester tester) async {
// Smoke check: the game root widget constructs without throwing.
expect(const AppRoot(), isA<AppRoot>());
});
}