refactor: 使用flutter对原先的web项目重构,正在进行中
This commit is contained in:
1 parent
273224f5cf
commit
18017f819a
205 files changed
+13563
No files matched your search
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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() {}
|
||||
}
|
||||
@@ -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>());
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user