// 无头玩法测试:验证「输入 → 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 hud) {} @override void onPauseMenu(String label) {} @override void onResult(Map 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 _start() async { SharedPreferences.setMockInitialValues({}); 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('跳跃初速与重力(当前调校:JUMP 6.326 / GRAV 0.23)', () async { final world = await _start(); // 先落地,避免出生即空中导致二段跳语义不同 _step(world, 30); final P = world.player!; Input.onKey('ShiftLeft', true); world.tick(17); world.tick(17); // 按住不放时持续受 GRAV = 0.23:-6.326 + 0.23 × 2 expect(P.vy, closeTo(-6.326 + 0.46, 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.23, 0.001), reason: '松键后的上升速度未按 -3 收紧'); }); test('满力单跳顶点高度保持 ≈83.85px(飘但不改高度)', () async { // 回归:改手感「更飘」必须保持顶点高度不变——否则天花板/平台间距会错位。 // 本改法是「同比例降重力 + 提初速」,顶点由 v²/(2g) 决定故应零变化。 final world = await _start(); _step(world, 30); // 落地稳定 final P = world.player!; final double y0 = P.y; Input.onKey('ShiftLeft', true); // 满力起跳(按住不放) double minY = P.y; for (int i = 0; i < 240; i++) { world.tick(17); if (P.y < minY) minY = P.y; if (i > 5 && P.vy >= 0 && P.y >= y0) break; // 落回起跳高度 } final double apex = y0 - minY; expect(apex, closeTo(83.85, 0.5), reason: '飘的改法必须保持单跳顶点高度 ≈83.85px(约 2.6 格)'); }); 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); }); }