fix: 修复迁移后的基础UI显示问题
This commit is contained in:
1 parent
18017f819a
commit
a93e06f60c
68 files changed
+2293
-8404
No files matched your search
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- API 28+ + 深色模式:让游戏画面延伸到刘海/挖孔区(shortEdges),
|
||||
真正铺满屏幕。沿用深色父主题。 -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
<item name="android:windowFullscreen">true</item>
|
||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||
</style>
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
<item name="android:windowFullscreen">true</item>
|
||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -5,6 +5,8 @@
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
<!-- 沉浸式全屏:冷启动阶段即隐藏状态栏/导航栏,避免首帧闪现系统 UI -->
|
||||
<item name="android:windowFullscreen">true</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
@@ -14,5 +16,6 @@
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
<item name="android:windowFullscreen">true</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- API 28+:让游戏画面延伸到刘海/挖孔区(shortEdges),真正铺满屏幕。
|
||||
该属性在 API<28 不存在,必须放在 values-v28 限定目录,否则低版本
|
||||
aapt 编译会报找不到资源。day 模式沿用浅色父主题。 -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
<item name="android:windowFullscreen">true</item>
|
||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||
</style>
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
<item name="android:windowFullscreen">true</item>
|
||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -5,6 +5,8 @@
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
<!-- 沉浸式全屏:冷启动阶段即隐藏状态栏/导航栏,避免首帧闪现系统 UI -->
|
||||
<item name="android:windowFullscreen">true</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
@@ -14,5 +16,6 @@
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
<item name="android:windowFullscreen">true</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -55,16 +55,19 @@
|
||||
<string>Main</string>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<!-- 沉浸式全屏:由 plist 全局控制状态栏(而非各 ViewController),
|
||||
启动即隐藏状态栏,与安卓沉浸模式一致。 -->
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<false/>
|
||||
<key>UIStatusBarHidden</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
+13
-4
@@ -8,7 +8,7 @@ import 'package:flutter/foundation.dart'
|
||||
show FlutterErrorDetails, defaultTargetPlatform, TargetPlatform;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart'
|
||||
show DeviceOrientation, SystemChrome;
|
||||
show DeviceOrientation, SystemChrome, SystemUiMode;
|
||||
import 'package:iwbtc/src/core/app_error.dart';
|
||||
import 'package:iwbtc/src/app_root.dart' show AppRoot;
|
||||
|
||||
@@ -36,12 +36,17 @@ Future<void> main() async {
|
||||
);
|
||||
}
|
||||
|
||||
/// 移动端专属的显示适配:锁定横屏。
|
||||
/// 移动端专属的显示适配:横屏锁定 + 沉浸式全屏。
|
||||
///
|
||||
/// 画面是 800×608 的横向虚拟舞台,横屏下信箱缩放才能拿到可用的可视比例;
|
||||
/// 竖屏时上下留白巨大、画面极小,虚拟手柄也会挤成一团。桌面端保持原样不干预。
|
||||
/// (安卓另有 AndroidManifest 的 `screenOrientation="sensorLandscape"`,
|
||||
/// 保证冷启动第一帧就是横屏,避免先竖后横的闪烁。)
|
||||
///
|
||||
/// 沉浸式:用 `SystemUiMode.immersiveSticky` 隐藏状态栏与导航栏,让游戏
|
||||
/// 铺满整个屏幕,与常规手机游戏一致。相比普通 `immersive`,
|
||||
/// `immersiveSticky` 在用户从屏幕边缘滑出系统栏后数秒会**自动重新隐藏**,
|
||||
/// 不会弹出"退出沉浸模式"的提示条,体验更顺。
|
||||
Future<void> _configureMobile() async {
|
||||
final t = defaultTargetPlatform;
|
||||
if (t != TargetPlatform.android && t != TargetPlatform.iOS) return;
|
||||
@@ -50,8 +55,12 @@ Future<void> _configureMobile() async {
|
||||
DeviceOrientation.landscapeLeft,
|
||||
DeviceOrientation.landscapeRight,
|
||||
]);
|
||||
// 沉浸式全屏:隐藏状态栏与导航栏,游戏铺满整个屏幕。
|
||||
await SystemChrome.setEnabledSystemUIMode(
|
||||
SystemUiMode.immersiveSticky,
|
||||
);
|
||||
} catch (e) {
|
||||
// 某些设备/系统策略不允许锁定方向,属可容忍情况,不影响游戏运行。
|
||||
AppError.warn('锁定横屏失败(保持系统默认方向)', '$e');
|
||||
// 某些设备/系统策略不允许锁定方向或沉浸,属可容忍情况,不影响游戏运行。
|
||||
AppError.warn('移动端显示适配失败(横屏/沉浸)', '$e');
|
||||
}
|
||||
}
|
||||
+169
-86
@@ -6,7 +6,7 @@ import 'package:flutter/foundation.dart'
|
||||
show debugPrint, defaultTargetPlatform, TargetPlatform;
|
||||
import 'package:flutter/scheduler.dart' show Ticker;
|
||||
import 'package:flutter/services.dart'
|
||||
show RawKeyboard, RawKeyEvent, RawKeyDownEvent;
|
||||
show RawKeyboard, RawKeyEvent, RawKeyDownEvent, SystemChrome, SystemUiMode;
|
||||
import 'package:iwbtc/src/core/app_error.dart' show AppError;
|
||||
import 'package:iwbtc/src/core/save.dart';
|
||||
import 'package:iwbtc/src/core/input.dart' show Input;
|
||||
@@ -16,9 +16,16 @@ import 'package:iwbtc/src/audio/sfx.dart' show Sfx;
|
||||
import 'package:iwbtc/src/ui/game_canvas.dart' show GameCanvas;
|
||||
import 'package:iwbtc/src/ui/overlays.dart' show HudOverlay;
|
||||
import 'package:iwbtc/src/ui/screens.dart'
|
||||
show TitleScreen, LevelSelectScreen, PauseScreen, ResultScreen, SettingsScreen;
|
||||
show
|
||||
TitleScreen,
|
||||
LevelSelectScreen,
|
||||
PauseScreen,
|
||||
ResultScreen,
|
||||
SettingsScreen;
|
||||
import 'package:iwbtc/src/ui/touch_controls.dart' show TouchControls;
|
||||
import 'package:iwbtc/src/ui/editor_panel.dart' show EditorPanel, EditorController;
|
||||
import 'package:iwbtc/src/ui/touch_editor.dart' show TouchLayoutEditor;
|
||||
import 'package:iwbtc/src/ui/editor_panel.dart'
|
||||
show EditorPanel, EditorController;
|
||||
|
||||
class AppRoot extends StatefulWidget {
|
||||
const AppRoot({super.key});
|
||||
@@ -27,7 +34,7 @@ class AppRoot extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _AppRootState extends State<AppRoot>
|
||||
with SingleTickerProviderStateMixin
|
||||
with SingleTickerProviderStateMixin, WidgetsBindingObserver
|
||||
implements GameHost {
|
||||
late World world;
|
||||
late EditorController editor;
|
||||
@@ -58,6 +65,8 @@ class _AppRootState extends State<AppRoot>
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 监听生命周期:切后台再回来时系统栏会重新出现,需重新进入沉浸模式。
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_useTouch = defaultTargetPlatform == TargetPlatform.android;
|
||||
// 看门狗:初始化迟迟不完成时报出可诊断信息,避免永远停在加载态。
|
||||
_watchdog = Timer(const Duration(seconds: 12), () {
|
||||
@@ -132,12 +141,25 @@ class _AppRootState extends State<AppRoot>
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_watchdog?.cancel();
|
||||
RawKeyboard.instance.removeListener(_rawKey);
|
||||
_ticker.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 切回前台时重新进入沉浸式全屏:系统 UI(状态栏/导航栏)在
|
||||
/// 用户从屏幕边缘滑出或切后台后会重新显示,常规游戏都会在此处复位。
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
final t = defaultTargetPlatform;
|
||||
if (t == TargetPlatform.android || t == TargetPlatform.iOS) {
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- GameHost ----------------
|
||||
@override
|
||||
void onShowScreen(String name) {
|
||||
@@ -187,105 +209,166 @@ class _AppRootState extends State<AppRoot>
|
||||
builder: (_, String? err, __) {
|
||||
// 致命错误优先展示详情 —— release 包里原本只会留下一片黑屏。
|
||||
if (err != null) {
|
||||
return MaterialApp(home: Scaffold(body: AppError.buildScreen(err)));
|
||||
return MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
home: Scaffold(body: AppError.buildScreen(err)),
|
||||
);
|
||||
}
|
||||
// 尚未就绪:显示可见的加载界面(不再是纯黑背景)。
|
||||
if (!_ready || !_worldOk) return const MaterialApp(home: _BootScreen());
|
||||
if (!_ready || !_worldOk) {
|
||||
return const MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
home: _BootScreen(),
|
||||
);
|
||||
}
|
||||
return _buildGame();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 系统返回手势 / 返回键 = 游戏内的「返回上一页」,而不是退出应用:
|
||||
/// - 游戏中 → 暂停菜单;暂停中 → 继续游戏
|
||||
/// - 结算界面 → 关卡选择;编辑器 → 标题
|
||||
/// - 关卡选择 / 设置 → 标题
|
||||
/// - 标题页不响应(已是「第一页」,忽略返回,防止误退游戏)
|
||||
void _handleSystemBack() {
|
||||
switch (world.state) {
|
||||
case 'playing':
|
||||
world.pause();
|
||||
return;
|
||||
case 'paused':
|
||||
world.resume();
|
||||
return;
|
||||
case 'result':
|
||||
world.toLevels();
|
||||
return;
|
||||
case 'editor':
|
||||
world.toTitle();
|
||||
return;
|
||||
}
|
||||
final s = screen.value;
|
||||
if (s == 'scr-touchedit') {
|
||||
// 自定义按键页:系统返回 = 返回设置(布局已即改即存,无需额外处理)
|
||||
screen.value = 'scr-settings';
|
||||
return;
|
||||
}
|
||||
if (s == 'scr-levels' || s == 'scr-settings') world.toTitle();
|
||||
}
|
||||
|
||||
/// 真正的游戏界面栈(画布 + 各屏幕 + HUD + 触屏手柄)。
|
||||
Widget _buildGame() {
|
||||
if (!_loggedBuild) {
|
||||
_loggedBuild = true;
|
||||
debugPrint(
|
||||
'[boot] buildGame: state=${world.state} screen=${screen.value}');
|
||||
'[boot] buildGame: state=${world.state} screen=${screen.value}',
|
||||
);
|
||||
}
|
||||
return MaterialApp(
|
||||
home: Scaffold(
|
||||
backgroundColor: const Color(0xff000000),
|
||||
body: Stack(
|
||||
children: <Widget>[
|
||||
Positioned.fill(
|
||||
child: GameCanvas(world: world, repaint: frame),
|
||||
),
|
||||
// 注意:Positioned 必须是 Stack 的直接子级。此前把 Positioned 放在
|
||||
// ValueListenableBuilder 的返回值里(隔着一层),定位数据被丢弃,
|
||||
// 导致整棵标题树横向塌缩为 0 宽 —— 界面上只剩近黑的画布,即"黑屏"。
|
||||
Positioned.fill(
|
||||
child: ValueListenableBuilder<String>(
|
||||
valueListenable: screen,
|
||||
builder: (_, String s, __) {
|
||||
if (s == 'scr-title') {
|
||||
return TitleScreen(
|
||||
world: world,
|
||||
onSettings: () => screen.value = 'scr-settings',
|
||||
onEditor: () => world.toEditor(),
|
||||
);
|
||||
}
|
||||
if (s == 'scr-levels') {
|
||||
return LevelSelectScreen(
|
||||
world: world,
|
||||
reload: levelsReload,
|
||||
onChanged: () => levelsReload.value++,
|
||||
);
|
||||
}
|
||||
if (s == 'scr-settings') {
|
||||
return SettingsScreen(
|
||||
world: world,
|
||||
isTouch: _useTouch,
|
||||
onBack: () => world.toTitle(),
|
||||
);
|
||||
}
|
||||
if (s == 'scr-editor') {
|
||||
return EditorPanel(
|
||||
controller: editor,
|
||||
onBack: () => world.toTitle(),
|
||||
);
|
||||
}
|
||||
return const SizedBox();
|
||||
},
|
||||
),
|
||||
),
|
||||
// 暂停 / 结算(半透明,覆盖在画布之上)
|
||||
Positioned.fill(
|
||||
child: ValueListenableBuilder<String>(
|
||||
valueListenable: screen,
|
||||
builder: (_, String s, __) {
|
||||
if (s == 'scr-pause') {
|
||||
return PauseScreen(world: world, quitLabel: pauseQuit.value);
|
||||
}
|
||||
if (s == 'scr-result') {
|
||||
return ResultScreen(world: world, result: result.value);
|
||||
}
|
||||
return const SizedBox();
|
||||
},
|
||||
),
|
||||
),
|
||||
// HUD:显隐必须随 world.state 变化而重建。
|
||||
// 早先把它写成 build() 里读 world.state 的 if 分支 —— 但 startLevel()
|
||||
// 只改 World 字段、不会触发 AppRoot 重建,于是进关卡后 HUD 永远不显示。
|
||||
// 这里改为监听 stateN。(Positioned 仍保持 Stack 直接子级,监听在内。)
|
||||
Positioned.fill(
|
||||
child: ValueListenableBuilder<String>(
|
||||
valueListenable: world.stateN,
|
||||
builder: (_, String s, __) =>
|
||||
(s == 'playing' || s == 'paused')
|
||||
? HudOverlay(hud: hud, boss: boss, toast: toast)
|
||||
: const SizedBox(),
|
||||
),
|
||||
),
|
||||
if (_useTouch)
|
||||
debugShowCheckedModeBanner: false,
|
||||
home: PopScope(
|
||||
// 拦截系统返回:交给 _handleSystemBack 映射为游戏内导航。
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (bool didPop, Object? result) {
|
||||
if (!didPop) _handleSystemBack();
|
||||
},
|
||||
child: Scaffold(
|
||||
backgroundColor: const Color(0xff000000),
|
||||
// 游戏类 App 不让软键盘压缩界面(否则编辑器/设置里一调起键盘,
|
||||
// 可用高度骤减,固定头部立刻 BOTTOM OVERFLOWED)。
|
||||
// 所有输入框都在屏幕上半区,键盘不会遮挡它们。
|
||||
resizeToAvoidBottomInset: false,
|
||||
body: Stack(
|
||||
children: <Widget>[
|
||||
Positioned.fill(
|
||||
child: TouchControls(
|
||||
world: world,
|
||||
visible: touchVisible,
|
||||
hasGun: touchGun,
|
||||
child: GameCanvas(world: world, repaint: frame),
|
||||
),
|
||||
// 注意:Positioned 必须是 Stack 的直接子级。此前把 Positioned 放在
|
||||
// ValueListenableBuilder 的返回值里(隔着一层),定位数据被丢弃,
|
||||
// 导致整棵标题树横向塌缩为 0 宽 —— 界面上只剩近黑的画布,即"黑屏"。
|
||||
Positioned.fill(
|
||||
child: ValueListenableBuilder<String>(
|
||||
valueListenable: screen,
|
||||
builder: (_, String s, __) {
|
||||
if (s == 'scr-title') {
|
||||
return TitleScreen(
|
||||
world: world,
|
||||
onSettings: () => screen.value = 'scr-settings',
|
||||
onEditor: () => world.toEditor(),
|
||||
);
|
||||
}
|
||||
if (s == 'scr-levels') {
|
||||
return LevelSelectScreen(
|
||||
world: world,
|
||||
reload: levelsReload,
|
||||
onChanged: () => levelsReload.value++,
|
||||
);
|
||||
}
|
||||
if (s == 'scr-settings') {
|
||||
return SettingsScreen(
|
||||
world: world,
|
||||
isTouch: _useTouch,
|
||||
onBack: () => world.toTitle(),
|
||||
onTouchEditor: () => screen.value = 'scr-touchedit',
|
||||
);
|
||||
}
|
||||
// 自定义按键:独立全屏页,透出下面的实时游戏画布,
|
||||
// 编辑体验与正常玩游戏一致。
|
||||
if (s == 'scr-touchedit') {
|
||||
return TouchLayoutEditor(
|
||||
onDone: () => screen.value = 'scr-settings',
|
||||
);
|
||||
}
|
||||
if (s == 'scr-editor') {
|
||||
return EditorPanel(
|
||||
controller: editor,
|
||||
onBack: () => world.toTitle(),
|
||||
);
|
||||
}
|
||||
return const SizedBox();
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
// 暂停 / 结算(半透明,覆盖在画布之上)
|
||||
Positioned.fill(
|
||||
child: ValueListenableBuilder<String>(
|
||||
valueListenable: screen,
|
||||
builder: (_, String s, __) {
|
||||
if (s == 'scr-pause') {
|
||||
return PauseScreen(
|
||||
world: world,
|
||||
quitLabel: pauseQuit.value,
|
||||
);
|
||||
}
|
||||
if (s == 'scr-result') {
|
||||
return ResultScreen(world: world, result: result.value);
|
||||
}
|
||||
return const SizedBox();
|
||||
},
|
||||
),
|
||||
),
|
||||
// HUD:显隐必须随 world.state 变化而重建。
|
||||
// 早先把它写成 build() 里读 world.state 的 if 分支 —— 但 startLevel()
|
||||
// 只改 World 字段、不会触发 AppRoot 重建,于是进关卡后 HUD 永远不显示。
|
||||
// 这里改为监听 stateN。(Positioned 仍保持 Stack 直接子级,监听在内。)
|
||||
Positioned.fill(
|
||||
child: ValueListenableBuilder<String>(
|
||||
valueListenable: world.stateN,
|
||||
builder: (_, String s, __) =>
|
||||
(s == 'playing' || s == 'paused')
|
||||
? HudOverlay(hud: hud, boss: boss, toast: toast)
|
||||
: const SizedBox(),
|
||||
),
|
||||
),
|
||||
if (_useTouch)
|
||||
Positioned.fill(
|
||||
child: TouchControls(
|
||||
world: world,
|
||||
visible: touchVisible,
|
||||
hasGun: touchGun,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -59,7 +59,12 @@ class Entity implements EntityView, CamZoneLike {
|
||||
double speed = 2.4;
|
||||
int timer = 0;
|
||||
String kind = 'ball';
|
||||
String? doorId;
|
||||
// 与 web 原版一致:doorId 既可来自 data.doors[].id(常为 String),
|
||||
// 也可在无匹配门时回退到实体序号 idx(int)。JS 弱类型下二者都能用 === 比较;
|
||||
// 移植到 Dart 后若声明为 String?,回退分支把 int 赋给 String? 会抛
|
||||
// "type 'int' is not a subtype of type 'String?'"。故用 Object? 容纳两种类型,
|
||||
// == 比较(world.dart)对 String/String、int/int 走值相等,语义与 web 一致。
|
||||
Object? doorId;
|
||||
int pair = 0;
|
||||
dynamic to;
|
||||
int cool = 0;
|
||||
@@ -252,7 +257,8 @@ class Level implements LevelLike {
|
||||
}
|
||||
if (type == 'warp') {
|
||||
final cfg = (idx < warpSrc.length ? warpSrc[idx] : {}) as Map? ?? {};
|
||||
final door = (data['doors'] as List?)?.cast<Map>()
|
||||
final doors = (data['doors'] as List?)?.cast<Map>() ?? const <Map>[];
|
||||
final door = doors
|
||||
.firstWhere((d) => d['tx'] == tx && d['ty'] == ty, orElse: () => <String, dynamic>{});
|
||||
e.doorId = (door != null && door.isNotEmpty) ? door['id'] : idx;
|
||||
e.pair = cfg['pair'] ?? (idx % 2 == 0 ? idx + 1 : idx - 1);
|
||||
|
||||
+82
-25
@@ -1,10 +1,18 @@
|
||||
// 游戏循环 / 状态机(对应 web 版 main.js)。
|
||||
// 用固定 60Hz 时间步长驱动已移植的纯 Canvas 游戏逻辑,保证手感 1:1。
|
||||
// 渲染在 800×608 虚拟分辨率下完成,由 GameCanvas 做信箱(letterbox)缩放。
|
||||
import 'dart:ui' show Canvas, Color, Paint, Path, Rect, Offset, TextAlign, TextDirection;
|
||||
// 渲染在虚拟视口下完成(≥800×608、随屏幕比例加宽),由 GameCanvas 铺满全屏。
|
||||
import 'dart:ui'
|
||||
show Canvas, Color, Paint, Path, Rect, Offset, TextAlign, TextDirection;
|
||||
import 'dart:math' show sin;
|
||||
import 'package:flutter/widgets.dart'
|
||||
show TextPainter, TextSpan, TextStyle, LinearGradient, Alignment, FontWeight, ValueNotifier;
|
||||
show
|
||||
TextPainter,
|
||||
TextSpan,
|
||||
TextStyle,
|
||||
LinearGradient,
|
||||
Alignment,
|
||||
FontWeight,
|
||||
ValueNotifier;
|
||||
import 'package:iwbtc/src/core/input.dart';
|
||||
import 'package:iwbtc/src/core/save.dart';
|
||||
import 'package:iwbtc/src/core/util.dart';
|
||||
@@ -32,8 +40,10 @@ abstract class GameHost {
|
||||
void onBossBar(dynamic boss);
|
||||
void onToast(String text);
|
||||
void onResult(Map<String, dynamic> r);
|
||||
|
||||
/// 暂停菜单「退出」按钮文案随来源变化。
|
||||
void onPauseMenu(String label);
|
||||
|
||||
/// 进入编辑器界面。
|
||||
void editorShow();
|
||||
void touchShow();
|
||||
@@ -75,10 +85,29 @@ class World implements LevelTestRunner {
|
||||
double _acc = 0;
|
||||
String _lastToast = '';
|
||||
|
||||
/// 当前虚拟视口(GameCanvas 每帧 paint 前同步)。
|
||||
/// 保证 ≥ 800×608;屏幕更宽/更高时另一维加宽,画面铺满全屏无黑边。
|
||||
double viewW = W;
|
||||
double viewH = H;
|
||||
|
||||
World(this.host);
|
||||
|
||||
/// 由 GameCanvas 在 paint 前调用:视口随屏幕比例变化。
|
||||
/// 只改纯数值字段,不触发任何通知(paint 阶段禁止 notifier)。
|
||||
void setViewport(double vw, double vh) {
|
||||
if (vw == viewW && vh == viewH) return;
|
||||
viewW = vw;
|
||||
viewH = vh;
|
||||
cam?.w = vw;
|
||||
cam?.h = vh;
|
||||
}
|
||||
|
||||
// ---------------- 关卡流程 ----------------
|
||||
Map<String, dynamic> faceData(Map<String, dynamic> map, Map<String, dynamic> f, int i) {
|
||||
Map<String, dynamic> faceData(
|
||||
Map<String, dynamic> map,
|
||||
Map<String, dynamic> f,
|
||||
int i,
|
||||
) {
|
||||
final grid = (f['grid'] as List?)?.cast<String>() ?? <String>[];
|
||||
return <String, dynamic>{
|
||||
'id': (map['id'] ?? 'map').toString() + '-' + i.toString(),
|
||||
@@ -115,7 +144,7 @@ class World implements LevelTestRunner {
|
||||
|
||||
level = map![face];
|
||||
player = Player(level!);
|
||||
cam = Camera(W, H);
|
||||
cam = Camera(viewW, viewH);
|
||||
cam!.reset(level!, player!);
|
||||
|
||||
player!.onDeath = () {
|
||||
@@ -194,8 +223,14 @@ class World implements LevelTestRunner {
|
||||
}
|
||||
cam!.reset(level!, player!);
|
||||
Sfx.instance.warp();
|
||||
level!.particles.spawn(player!.cx, player!.cy, 12,
|
||||
colors: const ['#a86bff', '#fff'], speed: 2.5, life: 24);
|
||||
level!.particles.spawn(
|
||||
player!.cx,
|
||||
player!.cy,
|
||||
12,
|
||||
colors: const ['#a86bff', '#fff'],
|
||||
speed: 2.5,
|
||||
life: 24,
|
||||
);
|
||||
}
|
||||
|
||||
void doRespawnFace() {
|
||||
@@ -229,7 +264,12 @@ class World implements LevelTestRunner {
|
||||
|
||||
void shoot() {
|
||||
final P = player;
|
||||
if (state != 'playing' || P == null || P.dead || !P.hasGun || P.shootCool != 0) return;
|
||||
if (state != 'playing' ||
|
||||
P == null ||
|
||||
P.dead ||
|
||||
!P.hasGun ||
|
||||
P.shootCool != 0)
|
||||
return;
|
||||
P.shoot();
|
||||
}
|
||||
|
||||
@@ -334,7 +374,8 @@ class World implements LevelTestRunner {
|
||||
if (++hudTick % 5 == 0) {
|
||||
final L = level!;
|
||||
host.onHud(<String, dynamic>{
|
||||
'name': (data!['name'] ?? 'STAGE') +
|
||||
'name':
|
||||
(data!['name'] ?? 'STAGE') +
|
||||
(map!.length > 1 ? ' · 面${face + 1}/${map!.length}' : ''),
|
||||
'deaths': deaths,
|
||||
'ms': ms,
|
||||
@@ -345,7 +386,8 @@ class World implements LevelTestRunner {
|
||||
'totalSaves': L.totalSaves,
|
||||
});
|
||||
host.touchSetGun(P.hasGun);
|
||||
final boss = L.bosses.where((b) => !b.dead).firstOrNull ?? L.bosses.firstOrNull;
|
||||
final boss =
|
||||
L.bosses.where((b) => !b.dead).firstOrNull ?? L.bosses.firstOrNull;
|
||||
host.onBossBar(L.bosses.isNotEmpty ? boss : null);
|
||||
_pushToast(L.toast ?? '');
|
||||
}
|
||||
@@ -371,8 +413,11 @@ class World implements LevelTestRunner {
|
||||
} else if (state == 'paused' && Input.hit('pause')) {
|
||||
resume();
|
||||
}
|
||||
// 每个固定步长都刷新输入边沿(prev=now)。否则某一帧跑 0 次更新、
|
||||
// 下一帧跑 2 次时,同一次按键会被当两帧的 hit —— 表现为误触二段跳;
|
||||
// 合帧亦不应丢失按键:now 在两次更新间保持为 true,下一子步才清 prev。
|
||||
Input.endFrame();
|
||||
}
|
||||
Input.endFrame();
|
||||
// toast 推送必须在 Ticker 回调阶段(transientCallbacks)完成,
|
||||
// 不能放进 render() —— paint 阶段改被 ValueListenableBuilder 监听的
|
||||
// notifier 会触发 "Build scheduled during frame"(晚一帧且刷警告)。
|
||||
@@ -380,14 +425,16 @@ class World implements LevelTestRunner {
|
||||
if (state != 'playing') time++;
|
||||
}
|
||||
|
||||
// ---------------- 渲染(800×608 虚拟空间) ----------------
|
||||
// ---------------- 渲染(虚拟视口空间,≥800×608 随屏幕比例加宽) ----------------
|
||||
void render(Canvas c) {
|
||||
final t = time;
|
||||
final vw = viewW;
|
||||
final vh = viewH;
|
||||
if (state == 'playing' || state == 'paused' || state == 'result') {
|
||||
final L = level!;
|
||||
final P = player!;
|
||||
final C = cam!;
|
||||
c.drawRect(Rect.fromLTWH(0, 0, W, H), Paint()..color = hex('#0a0a14'));
|
||||
c.drawRect(Rect.fromLTWH(0, 0, vw, vh), Paint()..color = hex('#0a0a14'));
|
||||
L.drawBg(c, C, t);
|
||||
c.save();
|
||||
C.apply(c);
|
||||
@@ -404,7 +451,9 @@ class World implements LevelTestRunner {
|
||||
text: TextSpan(
|
||||
text: useTouch ? '按右下角重生键' : '按 R 重生',
|
||||
style: TextStyle(
|
||||
color: const Color(0xffff8fa3).withValues(alpha: (blink ? 1.0 : 0.3) * 255),
|
||||
color: const Color(
|
||||
0xffff8fa3,
|
||||
).withValues(alpha: (blink ? 1.0 : 0.3) * 255),
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontFamily: 'monospace',
|
||||
@@ -414,7 +463,7 @@ class World implements LevelTestRunner {
|
||||
textDirection: TextDirection.ltr,
|
||||
);
|
||||
tp.layout();
|
||||
tp.paint(c, Offset(W / 2 - tp.width / 2, H / 2 - 36 - tp.height / 2));
|
||||
tp.paint(c, Offset(vw / 2 - tp.width / 2, vh / 2 - 36 - tp.height / 2));
|
||||
final sub = TextPainter(
|
||||
text: TextSpan(
|
||||
text: 'Backspace 整关重来',
|
||||
@@ -428,7 +477,10 @@ class World implements LevelTestRunner {
|
||||
textDirection: TextDirection.ltr,
|
||||
);
|
||||
sub.layout();
|
||||
sub.paint(c, Offset(W / 2 - sub.width / 2, H / 2 - 12 - sub.height / 2));
|
||||
sub.paint(
|
||||
c,
|
||||
Offset(vw / 2 - sub.width / 2, vh / 2 - 12 - sub.height / 2),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
drawTitleBg(c);
|
||||
@@ -436,16 +488,18 @@ class World implements LevelTestRunner {
|
||||
}
|
||||
|
||||
void drawTitleBg(Canvas c) {
|
||||
final vw = viewW;
|
||||
final vh = viewH;
|
||||
final g = LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: <Color>[hex('#181828'), hex('#08080f')],
|
||||
).createShader(Rect.fromLTWH(0, 0, W, H));
|
||||
c.drawRect(Rect.fromLTWH(0, 0, W, H), Paint()..shader = g);
|
||||
).createShader(Rect.fromLTWH(0, 0, vw, vh));
|
||||
c.drawRect(Rect.fromLTWH(0, 0, vw, vh), Paint()..shader = g);
|
||||
|
||||
for (int i = 0; i < 26; i++) {
|
||||
final double x = ((i * 137 + time * 0.15) % (W + 60)) - 30;
|
||||
final double y = 120 + ((i * 71) % (H - 160)).toDouble();
|
||||
final double x = ((i * 137 + time * 0.15) % (vw + 60)) - 30;
|
||||
final double y = 120 + ((i * 71) % (vh - 160)).toDouble();
|
||||
final col = i % 3 == 0 ? hex('#3a3a4c') : hex('#2a2a38');
|
||||
final p = Path()
|
||||
..moveTo(x, y + 18)
|
||||
@@ -455,13 +509,16 @@ class World implements LevelTestRunner {
|
||||
c.drawPath(p, Paint()..color = col.withValues(alpha: 0.5 * 255));
|
||||
}
|
||||
|
||||
c.drawRect(Rect.fromLTWH(0, H - 30, W, 30), Paint()..color = hex('#1a1a26'));
|
||||
c.drawRect(
|
||||
Rect.fromLTWH(0, vh - 30, vw, 30),
|
||||
Paint()..color = hex('#1a1a26'),
|
||||
);
|
||||
final spikePaint = Paint()..color = hex('#c8c8d8');
|
||||
for (double x = 0; x < W; x += 22) {
|
||||
for (double x = 0; x < vw; x += 22) {
|
||||
final p = Path()
|
||||
..moveTo(x, H - 4)
|
||||
..lineTo(x + 11, H - 26)
|
||||
..lineTo(x + 22, H - 4)
|
||||
..moveTo(x, vh - 4)
|
||||
..lineTo(x + 11, vh - 26)
|
||||
..lineTo(x + 22, vh - 4)
|
||||
..close();
|
||||
c.drawPath(p, spikePaint);
|
||||
}
|
||||
|
||||
+627
-304
File diff suppressed because it is too large.
Load diff
@@ -1,25 +1,45 @@
|
||||
// 游戏画布:把 World 在 800×608 虚拟分辨率下的渲染做信箱(letterbox)缩放。
|
||||
// 游戏画布:把 World 的虚拟视口渲染铺满整个屏幕(全屏、无信箱黑边)。
|
||||
// 虚拟视口保证 800×608 始终完整可见(不裁游戏内容),另一维随屏幕比例
|
||||
// 自动加宽:例如 2340×1080 的横屏手机 → 视口约 1318×608(横向看得更多),
|
||||
// 与 PC 游戏支持任意宽高比的做法一致。
|
||||
import 'dart:ui' show Canvas, Rect, Paint, Color;
|
||||
import 'dart:math' show min;
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter/foundation.dart' show debugPrint;
|
||||
import 'package:iwbtc/src/core/app_error.dart' show AppError;
|
||||
import 'package:iwbtc/src/game/world.dart' show World, W, H;
|
||||
|
||||
/// 计算 800×608 虚拟画面在给定屏幕尺寸下的信箱矩形(居中、等比缩放)。
|
||||
///
|
||||
/// 画布和虚拟手柄都用它:横屏手机宽高比远大于画面比例,两侧会出现黑边,
|
||||
/// 手柄按钮必须锚定在**画面区域**内,否则会漂到黑边上(手指按到的位置和
|
||||
/// 屏幕上看到的游戏内容对不上)。尺寸非法(尚未布局完成)时返回 Rect.zero。
|
||||
Rect letterboxRect(Size size) {
|
||||
if (!size.width.isFinite || !size.height.isFinite) return Rect.zero;
|
||||
if (size.width <= 0 || size.height <= 0) return Rect.zero;
|
||||
final s = min(size.width / W, size.height / H);
|
||||
if (!s.isFinite || s <= 0) return Rect.zero;
|
||||
final double w = W * s;
|
||||
final double h = H * s;
|
||||
return Rect.fromLTWH(
|
||||
(size.width - w) / 2, (size.height - h) / 2, w, h);
|
||||
/// 屏幕对应的虚拟视口:缩放系数 + 视口尺寸(虚拟像素)。
|
||||
class StageMetrics {
|
||||
final double scale;
|
||||
final double vw;
|
||||
final double vh;
|
||||
const StageMetrics(this.scale, this.vw, this.vh);
|
||||
|
||||
bool get isValid =>
|
||||
scale > 0 &&
|
||||
scale.isFinite &&
|
||||
vw.isFinite &&
|
||||
vh.isFinite &&
|
||||
vw >= W - 0.5 &&
|
||||
vh >= H - 0.5;
|
||||
}
|
||||
|
||||
/// 计算铺满全屏的虚拟视口(无黑边),且保证 800×608 完整可见:
|
||||
/// - 屏幕比游戏「更宽」(横屏手机几乎总是如此)→ 高度定标 608,
|
||||
/// 视口横向加宽到 vw = 屏宽 / scale,游戏内容横向看得更多;
|
||||
/// - 屏幕比游戏「更窄」→ 宽度定标 800,视口纵向加高到 vh = 屏高 / scale。
|
||||
/// 摄像机随后按视口尺寸取景(Camera 自带对关卡边界的钳制)。
|
||||
StageMetrics stageMetrics(Size size) {
|
||||
if (!size.width.isFinite || !size.height.isFinite) {
|
||||
return const StageMetrics(0, 0, 0);
|
||||
}
|
||||
if (size.width <= 0 || size.height <= 0) return const StageMetrics(0, 0, 0);
|
||||
if (size.width / size.height >= W / H) {
|
||||
final s = size.height / H;
|
||||
return StageMetrics(s, size.width / s, H);
|
||||
}
|
||||
final s = size.width / W;
|
||||
return StageMetrics(s, W, size.height / s);
|
||||
}
|
||||
|
||||
class GameCanvas extends StatelessWidget {
|
||||
@@ -32,9 +52,7 @@ class GameCanvas extends StatelessWidget {
|
||||
// 用 SizedBox.expand 明确占满父级,避免 CustomPaint 使用 Size.infinite
|
||||
// 时在部分平台上拿到零尺寸/无穷尺寸,算出 scale = 0 而整屏消失(表现为黑屏)。
|
||||
return SizedBox.expand(
|
||||
child: CustomPaint(
|
||||
painter: _GamePainter(world, repaint),
|
||||
),
|
||||
child: CustomPaint(painter: _GamePainter(world, repaint)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -48,21 +66,21 @@ class _GamePainter extends CustomPainter {
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
// 信箱外框(黑色)
|
||||
canvas.drawRect(
|
||||
Rect.fromLTWH(0, 0, size.width, size.height),
|
||||
Paint()..color = const Color(0xff000000));
|
||||
// 首帧可能尚未完成布局,尺寸为 0 时不要绘制内容(stageMetrics 无效)。
|
||||
final m = stageMetrics(size);
|
||||
if (!m.isValid) return;
|
||||
|
||||
// 首帧可能尚未完成布局,尺寸为 0 时不要绘制内容(letterboxRect 返回 zero)。
|
||||
final rect = letterboxRect(size);
|
||||
if (rect.isEmpty) return;
|
||||
// 同步虚拟视口(只改纯数值字段,不触发任何通知 —— paint 阶段禁止 notifier)。
|
||||
world.setViewport(m.vw, m.vh);
|
||||
|
||||
canvas.save();
|
||||
canvas.translate(rect.left, rect.top);
|
||||
canvas.scale(rect.width / W, rect.width / W);
|
||||
canvas.clipRect(Rect.fromLTWH(0, 0, W, H));
|
||||
canvas.scale(m.scale);
|
||||
canvas.clipRect(Rect.fromLTWH(0, 0, m.vw, m.vh));
|
||||
// 兜底底色:即使 world.render 抛异常也不会露出透明/白屏。
|
||||
canvas.drawRect(
|
||||
Rect.fromLTWH(0, 0, W, H), Paint()..color = const Color(0xff0a0a14));
|
||||
Rect.fromLTWH(0, 0, m.vw, m.vh),
|
||||
Paint()..color = const Color(0xff0a0a14),
|
||||
);
|
||||
// 渲染异常原本会被静默吞掉,画面变成纯黑。
|
||||
// 偶发的一帧异常不该让整局报废 —— 只记录日志;但若连续多帧都失败
|
||||
// (比如某张地图/某种渲染路径真有问题),才升级成占屏错误以便真机诊断。
|
||||
@@ -84,9 +102,10 @@ class _GamePainter extends CustomPainter {
|
||||
if (!_loggedFirstPaint) {
|
||||
_loggedFirstPaint = true;
|
||||
debugPrint(
|
||||
'[boot] canvas rendered: screen=${size.width}x${size.height} '
|
||||
'stage=${rect.width}x${rect.height} at (${rect.left},${rect.top}) '
|
||||
'state=${world.state}');
|
||||
'[boot] canvas rendered: screen=${size.width}x${size.height} '
|
||||
'viewport=${m.vw.toStringAsFixed(0)}x${m.vh.toStringAsFixed(0)} '
|
||||
'state=${world.state}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+428
-491
File diff suppressed because it is too large.
Load diff
@@ -2,20 +2,19 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:iwbtc/src/core/input.dart' show Input;
|
||||
import 'package:iwbtc/src/ui/touch_layout.dart'
|
||||
show BUTTONS, metaSettings;
|
||||
import 'package:iwbtc/src/ui/touch_layout.dart' show BUTTONS, metaSettings;
|
||||
import 'package:iwbtc/src/game/world.dart' show World, W;
|
||||
import 'package:iwbtc/src/ui/game_canvas.dart' show letterboxRect;
|
||||
|
||||
class TouchControls extends StatelessWidget {
|
||||
final World world;
|
||||
final ValueNotifier<bool> visible;
|
||||
final ValueNotifier<bool> hasGun;
|
||||
const TouchControls(
|
||||
{super.key,
|
||||
required this.world,
|
||||
required this.visible,
|
||||
required this.hasGun});
|
||||
const TouchControls({
|
||||
super.key,
|
||||
required this.world,
|
||||
required this.visible,
|
||||
required this.hasGun,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -24,11 +23,10 @@ class TouchControls extends StatelessWidget {
|
||||
valueListenable: visible,
|
||||
builder: (_, bool v, __) {
|
||||
if (!v) return const SizedBox();
|
||||
// 按钮锚定在「信箱缩放后的画面矩形」内,而不是整块屏幕:
|
||||
// 横屏时两侧是黑边,若按全屏百分比定位,左/右/跳跃键会飘到黑边上。
|
||||
// 画面已铺满全屏(无信箱黑边),按钮直接按整屏百分比定位。
|
||||
return LayoutBuilder(
|
||||
builder: (_, BoxConstraints cons) {
|
||||
final area = letterboxRect(Size(cons.maxWidth, cons.maxHeight));
|
||||
final area = Rect.fromLTWH(0, 0, cons.maxWidth, cons.maxHeight);
|
||||
return Stack(
|
||||
children: <Widget>[
|
||||
for (final b in BUTTONS)
|
||||
@@ -68,17 +66,18 @@ class _TouchButton extends StatefulWidget {
|
||||
/// 空 Rect 表示尚未完成布局,此时不渲染。
|
||||
final Rect area;
|
||||
|
||||
const _TouchButton(
|
||||
{required this.world,
|
||||
required this.keyName,
|
||||
required this.label,
|
||||
this.label2,
|
||||
required this.shape,
|
||||
required this.cx,
|
||||
required this.cy,
|
||||
required this.size,
|
||||
required this.hasGun,
|
||||
required this.area});
|
||||
const _TouchButton({
|
||||
required this.world,
|
||||
required this.keyName,
|
||||
required this.label,
|
||||
this.label2,
|
||||
required this.shape,
|
||||
required this.cx,
|
||||
required this.cy,
|
||||
required this.size,
|
||||
required this.hasGun,
|
||||
required this.area,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_TouchButton> createState() => _TouchButtonState();
|
||||
@@ -114,8 +113,14 @@ class _TouchButtonState extends State<_TouchButton> {
|
||||
}
|
||||
|
||||
void _release() {
|
||||
if (!_pressed) return;
|
||||
_pressed = false;
|
||||
// 注意:即便 _pressed 因事件丢失而处于异常状态,也要强制复位虚拟键,
|
||||
// 否则 touch['jump'] 会卡在 true(漏接松手事件时极易发生),
|
||||
// 之后每次按下都因 setTouch 的「值未变则直接 return」短路而触发不了
|
||||
// 新的边沿 —— 表现就是「跳跃键经常失灵、时好时坏」。
|
||||
if (_pressed) {
|
||||
_pressed = false;
|
||||
setState(() {});
|
||||
}
|
||||
_shootTimer?.cancel();
|
||||
_shootTimer = null;
|
||||
final a = widget.keyName;
|
||||
@@ -124,7 +129,6 @@ class _TouchButtonState extends State<_TouchButton> {
|
||||
} else {
|
||||
Input.setTouch(a, false);
|
||||
}
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -143,10 +147,15 @@ class _TouchButtonState extends State<_TouchButton> {
|
||||
top: top,
|
||||
width: sz,
|
||||
height: sz,
|
||||
child: Listener(
|
||||
onPointerDown: (_) => _press(),
|
||||
onPointerUp: (_) => _release(),
|
||||
onPointerCancel: (_) => _release(),
|
||||
// 用 GestureDetector(Pan 识别器会「认领」指针)替代 Listener:
|
||||
// Listener 不捕获指针,手指在按钮上滑动后于按钮外松手时,松手事件
|
||||
// 落到别的控件上 → 本按钮的虚拟键永久卡在按下态。Pan 会持续把
|
||||
// 抬起/取消事件回传给本按钮,松手必定复位。
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onPanDown: (_) => _press(),
|
||||
onPanEnd: (_) => _release(),
|
||||
onPanCancel: () => _release(),
|
||||
child: ValueListenableBuilder<bool>(
|
||||
valueListenable: widget.hasGun,
|
||||
builder: (_, __, ___) {
|
||||
@@ -160,23 +169,28 @@ class _TouchButtonState extends State<_TouchButton> {
|
||||
: const Color(0x444a5a7a),
|
||||
shape: isJump ? BoxShape.circle : BoxShape.rectangle,
|
||||
borderRadius: isJump ? null : BorderRadius.circular(sz * 0.3),
|
||||
border: Border.all(
|
||||
color: const Color(0xff7ae0ff), width: 2),
|
||||
border: Border.all(color: const Color(0xff7ae0ff), width: 2),
|
||||
),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Text(widget.label,
|
||||
style: TextStyle(
|
||||
color: const Color(0xffffffff),
|
||||
fontSize: sz * 0.32,
|
||||
fontWeight: FontWeight.bold)),
|
||||
Text(
|
||||
widget.label,
|
||||
style: TextStyle(
|
||||
color: const Color(0xffffffff),
|
||||
fontSize: sz * 0.32,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
if (widget.label2 != null)
|
||||
Text(widget.label2!,
|
||||
style: TextStyle(
|
||||
color: const Color(0xffcfe8ff),
|
||||
fontSize: sz * 0.16)),
|
||||
Text(
|
||||
widget.label2!,
|
||||
style: TextStyle(
|
||||
color: const Color(0xffcfe8ff),
|
||||
fontSize: sz * 0.16,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
// 触屏手柄布局编辑器:独立全屏页,从「设置 → 自定义按键」进入。
|
||||
// 背景保持透明,透出下面的实时游戏画布(标题星空 + 刺地带动画),
|
||||
// 编辑体验与正常玩游戏时完全一致(同样的全屏沉浸、同样的按钮渲染)。
|
||||
//
|
||||
// 布局即改即存(每次拖动/缩放都写回存档),返回时无需再点「确定」。
|
||||
// 位置/大小与真实手柄同一套换算(cx/cy 百分比 + size×k),所见即所得。
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:iwbtc/src/core/save.dart' show Save;
|
||||
import 'package:iwbtc/src/game/world.dart' show W;
|
||||
import 'package:iwbtc/src/ui/touch_layout.dart'
|
||||
show BUTTONS, TouchBtn, metaSettings;
|
||||
|
||||
/// 读取某按键当前布局(cx/cy/size)。
|
||||
Map<String, double> touchLayoutOf(String key) {
|
||||
final t = metaSettings()['touch'] as Map;
|
||||
final m = (t[key] as Map?) ?? const <String, dynamic>{};
|
||||
return <String, double>{
|
||||
'cx': double.tryParse(m['cx'].toString()) ?? 50,
|
||||
'cy': double.tryParse(m['cy'].toString()) ?? 50,
|
||||
'size': double.tryParse(m['size'].toString()) ?? 60,
|
||||
};
|
||||
}
|
||||
|
||||
void writeTouchLayout(String key, Map<String, double> p) {
|
||||
(metaSettings()['touch'] as Map)[key] = <String, dynamic>{
|
||||
'cx': p['cx'],
|
||||
'cy': p['cy'],
|
||||
'size': p['size'],
|
||||
};
|
||||
// 即改即存:布局写回存档,返回/切后台都不会丢。
|
||||
Save.flush();
|
||||
}
|
||||
|
||||
class TouchLayoutEditor extends StatefulWidget {
|
||||
final VoidCallback onDone; // 仅负责返回设置页(布局已即改即存)
|
||||
const TouchLayoutEditor({super.key, required this.onDone});
|
||||
@override
|
||||
State<TouchLayoutEditor> createState() => _TouchLayoutEditorState();
|
||||
}
|
||||
|
||||
class _TouchLayoutEditorState extends State<TouchLayoutEditor> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 背景保持透明:透出下面的实时游戏画布(标题星空+刺地),
|
||||
// 编辑体验与正常游戏完全一致(全屏沉浸)。
|
||||
return Stack(
|
||||
children: <Widget>[
|
||||
// 按钮编辑层(铺满全屏,与真实手柄同一坐标系)
|
||||
Positioned.fill(
|
||||
child: LayoutBuilder(
|
||||
builder: (BuildContext _, BoxConstraints cons) {
|
||||
final double w = cons.maxWidth;
|
||||
final double h = cons.maxHeight;
|
||||
// 与 _TouchButton 完全一致的缩放系数,保证编辑所见 = 游戏所得
|
||||
final double k = (w / W).clamp(0.6, 2.6);
|
||||
return Stack(
|
||||
children: <Widget>[
|
||||
for (final b in BUTTONS) ...<Widget>[
|
||||
_EditBtn(
|
||||
b: b,
|
||||
k: k,
|
||||
screenW: w,
|
||||
screenH: h,
|
||||
onChanged: () => setState(() {}),
|
||||
),
|
||||
_ResizeHandle(
|
||||
b: b,
|
||||
k: k,
|
||||
screenW: w,
|
||||
screenH: h,
|
||||
onChanged: () => setState(() {}),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
// 顶部操作行:恢复默认 / 返回
|
||||
Positioned(
|
||||
top: 10,
|
||||
right: 12,
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
final t = metaSettings()['touch'] as Map;
|
||||
t.clear();
|
||||
for (final b in BUTTONS) {
|
||||
t[b.key] = <String, dynamic>{
|
||||
'cx': b.cx,
|
||||
'cy': b.cy,
|
||||
'size': b.size,
|
||||
};
|
||||
}
|
||||
Save.flush();
|
||||
setState(() {});
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0x991a1a26),
|
||||
foregroundColor: const Color(0xffffffff),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
child: const Text('恢复默认', style: TextStyle(fontSize: 13)),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton(
|
||||
onPressed: widget.onDone,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0x995a3a7a),
|
||||
foregroundColor: const Color(0xffffffff),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: 6,
|
||||
),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
child: const Text('返回', style: TextStyle(fontSize: 13)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 底部提示
|
||||
const Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 8,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'点选按钮后可拖动位置 · 拖右下角黄点调整大小(改动即时保存)',
|
||||
style: TextStyle(color: Color(0x88ffffff), fontSize: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EditBtn extends StatefulWidget {
|
||||
final TouchBtn b;
|
||||
final double k; // 真实手柄同款缩放系数
|
||||
final double screenW;
|
||||
final double screenH;
|
||||
final VoidCallback onChanged;
|
||||
const _EditBtn({
|
||||
required this.b,
|
||||
required this.k,
|
||||
required this.screenW,
|
||||
required this.screenH,
|
||||
required this.onChanged,
|
||||
});
|
||||
@override
|
||||
State<_EditBtn> createState() => _EditBtnState();
|
||||
}
|
||||
|
||||
class _EditBtnState extends State<_EditBtn> {
|
||||
bool _selected = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final p = touchLayoutOf(widget.b.key);
|
||||
final double sz = p['size']! * widget.k;
|
||||
final double left = p['cx']! / 100 * widget.screenW - sz / 2;
|
||||
final double top = p['cy']! / 100 * widget.screenH - sz / 2;
|
||||
final isJump = widget.b.shape == 'jump';
|
||||
return Positioned(
|
||||
left: left,
|
||||
top: top,
|
||||
width: sz,
|
||||
height: sz,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => setState(() => _selected = !_selected),
|
||||
onPanStart: (_) => setState(() => _selected = true),
|
||||
onPanUpdate: (d) {
|
||||
final np = touchLayoutOf(widget.b.key);
|
||||
np['cx'] = (np['cx']! + d.delta.dx / widget.screenW * 100).clamp(
|
||||
3.0,
|
||||
97.0,
|
||||
);
|
||||
np['cy'] = (np['cy']! + d.delta.dy / widget.screenH * 100).clamp(
|
||||
4.0,
|
||||
96.0,
|
||||
);
|
||||
writeTouchLayout(widget.b.key, np);
|
||||
widget.onChanged();
|
||||
},
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: _selected
|
||||
? const Color(0x88ffffff)
|
||||
: const Color(0x444a5a7a),
|
||||
shape: isJump ? BoxShape.circle : BoxShape.rectangle,
|
||||
borderRadius: isJump ? null : BorderRadius.circular(sz * 0.3),
|
||||
border: Border.all(
|
||||
color: _selected
|
||||
? const Color(0xffffd23a)
|
||||
: const Color(0xff7ae0ff),
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
widget.b.label,
|
||||
style: TextStyle(
|
||||
color: const Color(0xffffffff),
|
||||
fontSize: sz * 0.3,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
if (widget.b.label2 != null)
|
||||
Text(
|
||||
widget.b.label2!,
|
||||
style: TextStyle(
|
||||
color: const Color(0xffcfe8ff),
|
||||
fontSize: sz * 0.15,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 缩放手柄:选中按钮后显示在其右下角的小黄点,拖动改变按钮大小。
|
||||
/// 单独一层 Positioned(在按钮之后 = 绘制在上层),不吃按钮本体的手势。
|
||||
class _ResizeHandle extends StatefulWidget {
|
||||
final TouchBtn b;
|
||||
final double k;
|
||||
final double screenW;
|
||||
final double screenH;
|
||||
final VoidCallback onChanged;
|
||||
const _ResizeHandle({
|
||||
required this.b,
|
||||
required this.k,
|
||||
required this.screenW,
|
||||
required this.screenH,
|
||||
required this.onChanged,
|
||||
});
|
||||
@override
|
||||
State<_ResizeHandle> createState() => _ResizeHandleState();
|
||||
}
|
||||
|
||||
class _ResizeHandleState extends State<_ResizeHandle> {
|
||||
bool _active = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final p = touchLayoutOf(widget.b.key);
|
||||
final double sz = p['size']! * widget.k;
|
||||
final double left = p['cx']! / 100 * widget.screenW + sz / 2 - 14;
|
||||
final double top = p['cy']! / 100 * widget.screenH + sz / 2 - 14;
|
||||
return Positioned(
|
||||
left: left,
|
||||
top: top,
|
||||
width: 26,
|
||||
height: 26,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onPanStart: (_) => setState(() => _active = true),
|
||||
onPanEnd: (_) => setState(() => _active = false),
|
||||
onPanCancel: () => setState(() => _active = false),
|
||||
onPanUpdate: (d) {
|
||||
final np = touchLayoutOf(widget.b.key);
|
||||
// 向右/向下拖 = 变大,换算回虚拟 size 单位
|
||||
final double dSize = (d.delta.dx + d.delta.dy) / widget.k;
|
||||
np['size'] = (np['size']! + dSize).clamp(30.0, 180.0);
|
||||
writeTouchLayout(widget.b.key, np);
|
||||
widget.onChanged();
|
||||
},
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: _active ? const Color(0xffffd23a) : const Color(0xccffd23a),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: const Color(0xff2a2a3a), width: 1.5),
|
||||
),
|
||||
child: const Center(
|
||||
child: Icon(Icons.open_in_full, size: 14, color: Colors.black87),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,18 +10,29 @@ class TouchBtn {
|
||||
final double cx;
|
||||
final double cy;
|
||||
final double size;
|
||||
const TouchBtn(this.key, this.label, this.shape, this.cx, this.cy, this.size,
|
||||
[this.label2]);
|
||||
const TouchBtn(
|
||||
this.key,
|
||||
this.label,
|
||||
this.shape,
|
||||
this.cx,
|
||||
this.cy,
|
||||
this.size, [
|
||||
this.label2,
|
||||
]);
|
||||
}
|
||||
|
||||
// 默认布局仿照主流 IWBT 手游截图:左下 ◀▶ 并排、右侧大「跳」、
|
||||
// 左下角🔫射击 / 右下角▼下蹲、右中上 ↺ 自杀、右上角 ❚❚ 暂停。
|
||||
const List<TouchBtn> BUTTONS = <TouchBtn>[
|
||||
TouchBtn('left', '◀', 'round', 12, 80, 62),
|
||||
TouchBtn('right', '▶', 'round', 24, 80, 62),
|
||||
TouchBtn('jump', '跳', 'jump', 88, 84, 94),
|
||||
TouchBtn('shoot', '🔫', 'item', 79, 72, 54, '射击'),
|
||||
TouchBtn('down', '▼', 'item', 90, 72, 54, '下蹲'),
|
||||
TouchBtn('pause', '❚❚', 'sm', 81, 58, 42),
|
||||
TouchBtn('suicide', '↺', 'sm', 90, 58, 42),
|
||||
TouchBtn('left', '◀', 'round', 12.5, 81, 62),
|
||||
TouchBtn('right', '▶', 'round', 22, 81, 62),
|
||||
TouchBtn('shoot', '🔫', 'item', 78.5, 90, 56, '射击'),
|
||||
TouchBtn('down', '▼', 'item', 94.5, 89, 56, '下蹲'),
|
||||
TouchBtn('pause', '❚❚', 'sm', 96.5, 8, 40),
|
||||
TouchBtn('suicide', '↺', 'sm', 91, 56, 52),
|
||||
// 跳跃键放最后(绘制在最上层):即便与其它键像素重叠,重叠区也优先归跳跃键,
|
||||
// 避免按下跳跃时落到射击/下蹲键上导致「跳跃键失灵」。
|
||||
TouchBtn('jump', '跳', 'jump', 85, 73, 78),
|
||||
];
|
||||
|
||||
const List<List<String>> KEY_ACTIONS = <List<String>>[
|
||||
@@ -38,32 +49,28 @@ const List<List<String>> KEY_ACTIONS = <List<String>>[
|
||||
Map<String, dynamic> defaultTouch() {
|
||||
final m = <String, dynamic>{};
|
||||
for (final b in BUTTONS) {
|
||||
m[b.key] = <String, dynamic>{
|
||||
'cx': b.cx,
|
||||
'cy': b.cy,
|
||||
'size': b.size,
|
||||
};
|
||||
m[b.key] = <String, dynamic>{'cx': b.cx, 'cy': b.cy, 'size': b.size};
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
Map<String, dynamic> defaultSettings() => <String, dynamic>{
|
||||
'username': '',
|
||||
'touch': defaultTouch(),
|
||||
'keys': defaultBindings(),
|
||||
};
|
||||
'username': '',
|
||||
'touch': defaultTouch(),
|
||||
'keys': defaultBindings(),
|
||||
};
|
||||
|
||||
Map<String, List<String>> defaultBindings() => <String, List<String>>{
|
||||
'left': <String>['ArrowLeft', 'KeyA'],
|
||||
'right': <String>['ArrowRight', 'KeyD'],
|
||||
'up': <String>['ArrowUp', 'KeyW'],
|
||||
'down': <String>['ArrowDown', 'KeyS'],
|
||||
'jump': <String>['ShiftLeft', 'ShiftRight', 'Space', 'KeyZ', 'KeyN'],
|
||||
'shoot': <String>['KeyX', 'KeyJ', 'KeyC', 'KeyM'],
|
||||
'suicide': <String>['KeyR'],
|
||||
'restart': <String>['Backspace'],
|
||||
'pause': <String>['Escape', 'KeyP'],
|
||||
};
|
||||
'left': <String>['ArrowLeft', 'KeyA'],
|
||||
'right': <String>['ArrowRight', 'KeyD'],
|
||||
'up': <String>['ArrowUp', 'KeyW'],
|
||||
'down': <String>['ArrowDown', 'KeyS'],
|
||||
'jump': <String>['ShiftLeft', 'ShiftRight', 'Space', 'KeyZ', 'KeyN'],
|
||||
'shoot': <String>['KeyX', 'KeyJ', 'KeyC', 'KeyM'],
|
||||
'suicide': <String>['KeyR'],
|
||||
'restart': <String>['Backspace'],
|
||||
'pause': <String>['Escape', 'KeyP'],
|
||||
};
|
||||
|
||||
/// 读取/补全存档里的设置对象(保证 touch / keys 字段齐全)。
|
||||
Map<String, dynamic> metaSettings() {
|
||||
|
||||
+382
-66
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user