Flutter for OpenHarmony数独游戏App实战:游戏主界面
本文介绍了数独游戏主界面的设计与实现,主要包括:1. 使用Flutter框架构建模块化界面,包含棋盘、数字键盘和控制按钮;2. 通过状态管理处理游戏计时、暂停和胜利状态;3. 实现计时器功能并与页面生命周期同步;4. 设计难度选择和胜利对话框交互流程。文章展示了如何通过代码组织实现直观的数独游戏体验。
游戏主界面是玩家与数独游戏交互的核心场所。一个好的主界面需要将棋盘、控制按钮、数字键盘、信息栏等元素有机地组合在一起,同时还要处理计时器、暂停、胜利等游戏状态。今天我们来详细实现数独游戏的主界面。
在设计主界面之前,我们需要考虑几个关键问题。首先是布局结构,如何在有限的屏幕空间内合理安排各个组件。其次是状态管理,主界面需要响应多种游戏状态的变化。最后是生命周期管理,计时器的启动和停止需要与页面的生命周期同步。
让我们从创建GamePage组件开始。
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import '../../controllers/game_controller.dart';
import 'widgets/sudoku_board.dart';
import 'widgets/number_pad.dart';
import 'widgets/game_controls.dart';
这是游戏主界面的导入部分。dart:async用于Timer类,Material库提供UI组件,GetX用于状态管理,ScreenUtil用于屏幕适配。我们还导入了GameController和三个子组件:棋盘、数字键盘、游戏控制按钮。这种模块化的设计让代码结构清晰,每个组件负责自己的职责。
定义GamePage类和状态类。
class GamePage extends StatefulWidget {
const GamePage({super.key});
State<GamePage> createState() => _GamePageState();
}
class _GamePageState extends State<GamePage> {
final GameController controller = Get.put(GameController());
Timer? _timer;
GamePage使用StatefulWidget是因为需要管理计时器的生命周期。Get.put将GameController注册到GetX的依赖注入系统中,这样其他组件可以通过Get.find获取同一个实例。_timer用于存储计时器引用,方便后续取消。使用可空类型是因为计时器可能还没有创建。
初始化方法中启动计时器。
void initState() {
super.initState();
_startTimer();
}
void _startTimer() {
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
controller.incrementTimer();
});
}
initState在组件创建时调用,我们在这里启动计时器。Timer.periodic创建一个周期性定时器,每秒执行一次回调。回调中调用controller的incrementTimer方法来更新游戏时间。这种设计将计时逻辑与UI分离,计时器只负责触发更新,具体的时间管理由controller处理。
资源释放非常重要。
void dispose() {
_timer?.cancel();
super.dispose();
}
dispose在组件销毁时调用,我们在这里取消计时器。如果不取消,计时器会继续运行,导致内存泄漏和潜在的错误。使用?.操作符是因为_timer可能为null。这是Flutter开发中的基本规范,任何需要手动释放的资源都应该在dispose中处理。
新游戏对话框的实现。
void _showNewGameDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('新游戏'),
content: const Text('选择难度'),
actions: [
TextButton(
onPressed: () {
controller.generateNewGame('Easy');
Navigator.pop(context);
},
child: const Text('简单'),
),
showDialog显示一个模态对话框,让玩家选择新游戏的难度。AlertDialog是Material Design的标准对话框组件,包含标题、内容和操作按钮。每个难度按钮点击后调用controller的generateNewGame方法生成新谜题,然后关闭对话框。这种设计让难度选择变得直观简单。
继续添加其他难度选项。
TextButton(
onPressed: () {
controller.generateNewGame('Medium');
Navigator.pop(context);
},
child: const Text('中等'),
),
TextButton(
onPressed: () {
controller.generateNewGame('Hard');
Navigator.pop(context);
},
child: const Text('困难'),
),
TextButton(
onPressed: () {
controller.generateNewGame('Expert');
Navigator.pop(context);
},
child: const Text('专家'),
),
],
),
);
}
四个难度级别对应不同数量的初始数字:简单约43个、中等约33个、困难约28个、专家约23个。初始数字越少,谜题越难。TextButton是Material Design的文字按钮,适合在对话框中使用。Navigator.pop关闭当前对话框,返回游戏界面。
胜利对话框的实现。
void _showVictoryDialog() {
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => AlertDialog(
title: const Text('🎉 恭喜!'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('你完成了数独!'),
SizedBox(height: 10.h),
Text('用时: ${controller.formattedTime}'),
Text('提示次数: ${controller.hintsUsed}'),
],
),
胜利对话框在玩家完成数独时显示。barrierDismissible设为false防止玩家点击对话框外部关闭它,确保玩家看到自己的成绩。对话框内容包括祝贺文字、用时和提示次数。mainAxisSize设为min让Column只占用必要的空间。这些信息让玩家有成就感,也方便他们追踪自己的进步。
胜利对话框的操作按钮。
actions: [
TextButton(
onPressed: () {
Navigator.pop(context);
_showNewGameDialog();
},
child: const Text('新游戏'),
),
],
),
);
}
胜利后只提供一个"新游戏"按钮,点击后先关闭胜利对话框,再显示新游戏对话框让玩家选择难度。这种流程设计让玩家可以快速开始下一局游戏。如果需要,还可以添加"分享成绩"、"查看统计"等按钮。
主界面的build方法。
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('数独'),
actions: [
IconButton(
icon: const Icon(Icons.add),
onPressed: _showNewGameDialog,
),
],
),
Scaffold提供了Material Design的基本页面结构,包括AppBar和body。AppBar显示标题"数独",右侧有一个加号按钮用于开始新游戏。使用Icons.add图标直观地表示"新建"的含义。这种布局是移动应用的常见模式,用户很容易理解。
根据游戏状态显示不同的内容。
body: GetBuilder<GameController>(
builder: (ctrl) {
if (ctrl.isComplete) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_showVictoryDialog();
});
}
return ctrl.isPaused
? _buildPausedScreen()
: _buildGameScreen();
},
),
);
}
GetBuilder监听GameController的状态变化。当isComplete为true时,使用addPostFrameCallback在当前帧渲染完成后显示胜利对话框,避免在build过程中调用showDialog导致的错误。根据isPaused状态显示暂停界面或游戏界面。这种条件渲染让界面能够响应各种游戏状态。
暂停界面的实现。
Widget _buildPausedScreen() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.pause_circle_outline, size: 80.sp, color: Colors.grey),
SizedBox(height: 20.h),
Text('游戏已暂停', style: TextStyle(fontSize: 24.sp)),
SizedBox(height: 20.h),
ElevatedButton(
onPressed: controller.resumeGame,
child: const Text('继续游戏'),
),
],
),
);
}
暂停界面显示一个大的暂停图标、提示文字和继续按钮。Center和Column组合实现垂直居中布局。图标使用80sp的大尺寸,让暂停状态一目了然。ElevatedButton是Material Design的凸起按钮,点击后调用controller的resumeGame方法恢复游戏。暂停时隐藏棋盘可以防止玩家在暂停时继续思考。
游戏界面的实现。
Widget _buildGameScreen() {
return SafeArea(
child: Padding(
padding: EdgeInsets.all(16.w),
child: Column(
children: [
_buildInfoBar(),
SizedBox(height: 16.h),
const Expanded(child: SudokuBoard()),
SizedBox(height: 16.h),
const GameControls(),
SizedBox(height: 16.h),
const NumberPad(),
],
),
),
);
}
SafeArea确保内容不会被系统UI遮挡,比如刘海屏或底部导航条。Padding添加16像素的内边距,让内容与屏幕边缘保持距离。Column垂直排列各个组件:信息栏、棋盘、控制按钮、数字键盘。棋盘使用Expanded占据剩余空间,确保它尽可能大。各组件之间有16像素的间距,让布局更加舒适。
信息栏的实现。
Widget _buildInfoBar() {
return GetBuilder<GameController>(
builder: (ctrl) => Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 6.h),
decoration: BoxDecoration(
color: Colors.blue.shade100,
borderRadius: BorderRadius.circular(16.r),
),
child: Text(
ctrl.difficulty,
style: TextStyle(fontSize: 14.sp, fontWeight: FontWeight.bold),
),
),
信息栏使用Row水平排列三个元素:难度标签、计时器、暂停按钮。难度标签使用圆角容器包裹,蓝色背景让它更加醒目。GetBuilder确保当难度或时间变化时UI会更新。mainAxisAlignment设为spaceBetween让三个元素分布在两端和中间。
计时器和暂停按钮。
Row(
children: [
Icon(Icons.timer, size: 20.sp),
SizedBox(width: 4.w),
Text(
ctrl.formattedTime,
style: TextStyle(fontSize: 18.sp, fontWeight: FontWeight.bold),
),
],
),
IconButton(
icon: const Icon(Icons.pause),
onPressed: ctrl.pauseGame,
),
],
),
);
}
}
计时器显示一个时钟图标和格式化的时间。formattedTime是controller中的计算属性,返回"MM:SS"格式的字符串。暂停按钮使用IconButton,点击后调用pauseGame方法。这三个元素提供了游戏的关键信息和控制,让玩家随时了解游戏状态。
现在让我们看看GameController中相关方法的实现。
void pauseGame() {
isPaused = true;
update();
}
void resumeGame() {
isPaused = false;
update();
}
暂停和恢复方法非常简单,只需要切换isPaused状态并通知UI更新。当isPaused为true时,计时器的incrementTimer方法不会增加时间,游戏界面也会切换到暂停状态。这种设计将状态逻辑集中在controller中,UI只负责显示。
计时器增量方法。
void incrementTimer() {
if (!isPaused && !isComplete) {
elapsedSeconds++;
update();
}
}
String get formattedTime {
int minutes = elapsedSeconds ~/ 60;
int seconds = elapsedSeconds % 60;
return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
}
incrementTimer只在游戏进行中才增加时间,暂停或完成时不计时。formattedTime是一个getter,将秒数转换为"MM:SS"格式。padLeft确保分钟和秒数都是两位数,比如"05:09"而不是"5:9"。这种格式化让时间显示更加规范美观。
游戏完成检测。
void _checkCompletion() {
for (int i = 0; i < 9; i++) {
for (int j = 0; j < 9; j++) {
if (board[i][j] == 0 || board[i][j] != solution[i][j]) {
return;
}
}
}
isComplete = true;
update();
}
_checkCompletion遍历整个棋盘,检查每个单元格是否都填入了正确的数字。如果有任何空格或错误,直接返回。只有当所有单元格都正确时,才将isComplete设为true。这个方法在每次输入数字或使用提示后调用,确保及时检测到游戏完成。
让我们来优化主界面的用户体验。添加页面切换动画。
class AnimatedGamePage extends StatefulWidget {
const AnimatedGamePage({super.key});
State<AnimatedGamePage> createState() => _AnimatedGamePageState();
}
class _AnimatedGamePageState extends State<AnimatedGamePage>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late Animation<double> _fadeAnimation;
void initState() {
super.initState();
_animationController = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);
_fadeAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(parent: _animationController, curve: Curves.easeIn),
);
_animationController.forward();
}
添加淡入动画让页面切换更加平滑。AnimationController控制动画时长为300毫秒,_fadeAnimation定义从0到1的透明度变化。在initState中启动动画,页面会从透明渐变到完全显示。这种动画效果提升了应用的质感。
在build中应用动画。
Widget build(BuildContext context) {
return FadeTransition(
opacity: _fadeAnimation,
child: Scaffold(
// 页面内容
),
);
}
void dispose() {
_animationController.dispose();
super.dispose();
}
}
FadeTransition将动画应用到整个页面。dispose中释放AnimationController避免内存泄漏。这种简单的动画可以显著提升用户体验,让应用感觉更加流畅专业。
处理返回键的行为。
Widget build(BuildContext context) {
return WillPopScope(
onWillPop: () async {
if (controller.isPaused) {
controller.resumeGame();
return false;
}
return await _showExitConfirmDialog() ?? false;
},
child: Scaffold(
// 页面内容
),
);
}
Future<bool?> _showExitConfirmDialog() {
return showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('退出游戏'),
content: const Text('确定要退出吗?当前进度将会丢失。'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('取消'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('确定'),
),
],
),
);
}
WillPopScope拦截返回键事件。如果游戏处于暂停状态,按返回键会恢复游戏而不是退出。如果游戏进行中,会显示确认对话框,防止玩家误操作丢失进度。这种设计考虑到了用户的实际使用场景,提供了更好的体验。
添加键盘快捷键支持。
Widget build(BuildContext context) {
return RawKeyboardListener(
focusNode: FocusNode(),
autofocus: true,
onKey: (event) {
if (event is RawKeyDownEvent) {
final key = event.logicalKey;
if (key.keyId >= 49 && key.keyId <= 57) {
// 数字键1-9
controller.enterNumber(key.keyId - 48);
} else if (key == LogicalKeyboardKey.backspace) {
controller.eraseCell();
} else if (key == LogicalKeyboardKey.keyZ &&
event.isControlPressed) {
controller.undoMove();
}
}
},
child: Scaffold(
// 页面内容
),
);
}
RawKeyboardListener监听键盘事件,支持数字键输入、退格键擦除、Ctrl+Z撤销。这对于在平板或桌面设备上使用外接键盘的用户非常有用。autofocus确保页面加载后立即获得焦点,可以接收键盘输入。
主界面的响应式布局。
Widget _buildResponsiveLayout() {
return LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth > 600) {
// 平板或桌面布局
return _buildWideLayout();
} else {
// 手机布局
return _buildNarrowLayout();
}
},
);
}
Widget _buildWideLayout() {
return Row(
children: [
Expanded(
flex: 2,
child: Column(
children: [
_buildInfoBar(),
const Expanded(child: SudokuBoard()),
],
),
),
Expanded(
flex: 1,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const GameControls(),
SizedBox(height: 24.h),
const NumberPad(),
],
),
),
],
);
}
LayoutBuilder根据可用宽度选择不同的布局。宽屏设备使用左右布局,棋盘在左边占2/3,控制区在右边占1/3。窄屏设备使用上下布局,棋盘在上,控制区在下。这种响应式设计让应用在不同设备上都有良好的体验。
主界面的主题适配。
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
backgroundColor: isDark ? Colors.grey.shade900 : Colors.grey.shade100,
appBar: AppBar(
backgroundColor: isDark ? Colors.grey.shade800 : Colors.white,
foregroundColor: isDark ? Colors.white : Colors.black,
title: const Text('数独'),
actions: [
IconButton(
icon: const Icon(Icons.add),
onPressed: _showNewGameDialog,
),
],
),
body: _buildGameScreen(),
);
}
通过Theme.of获取当前主题的亮度,根据是否为深色模式设置不同的背景色和前景色。这种适配让应用能够跟随系统主题变化,在深色模式下也有良好的显示效果。
总结一下游戏主界面的关键设计要点。首先是清晰的布局结构,将信息栏、棋盘、控制区合理排列。其次是完善的状态管理,响应暂停、完成等游戏状态。然后是生命周期管理,正确处理计时器的启动和停止。最后是用户体验优化,包括动画、返回键处理、键盘支持等。
游戏主界面是整个数独应用的核心,它将各个组件整合在一起,提供完整的游戏体验。通过合理的布局和状态管理,玩家可以专注于解题本身,享受数独带来的乐趣。
在实际开发中,我们还需要处理一些边界情况和用户体验细节。比如应用进入后台时的处理。
class _GamePageState extends State<GamePage> with WidgetsBindingObserver {
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_startTimer();
}
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_timer?.cancel();
super.dispose();
}
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.paused) {
controller.pauseGame();
}
}
}
WidgetsBindingObserver可以监听应用的生命周期变化。当应用进入后台时,自动暂停游戏,防止计时器继续运行。这是一个重要的用户体验细节,确保计时的公平性。
游戏进度的自动保存功能也很重要。
void _autoSave() {
final gameState = {
'board': controller.board,
'solution': controller.solution,
'isFixed': controller.isFixed,
'notes': controller.notes.map((row) =>
row.map((set) => set.toList()).toList()).toList(),
'difficulty': controller.difficulty,
'elapsedSeconds': controller.elapsedSeconds,
'hintsUsed': controller.hintsUsed,
};
SharedPreferences.getInstance().then((prefs) {
prefs.setString('savedGame', jsonEncode(gameState));
});
}
_autoSave将当前游戏状态序列化为JSON并保存到本地存储。notes需要特殊处理,因为Set不能直接序列化。这个方法可以在每次操作后调用,或者使用定时器定期保存。
恢复保存的游戏进度。
Future<bool> _loadSavedGame() async {
final prefs = await SharedPreferences.getInstance();
final savedGame = prefs.getString('savedGame');
if (savedGame == null) return false;
try {
final gameState = jsonDecode(savedGame) as Map<String, dynamic>;
controller.board = (gameState['board'] as List)
.map((row) => (row as List).cast<int>().toList())
.toList();
controller.solution = (gameState['solution'] as List)
.map((row) => (row as List).cast<int>().toList())
.toList();
controller.difficulty = gameState['difficulty'];
controller.elapsedSeconds = gameState['elapsedSeconds'];
controller.hintsUsed = gameState['hintsUsed'];
controller.update();
return true;
} catch (e) {
return false;
}
}
_loadSavedGame从本地存储读取保存的游戏状态并恢复。使用try-catch处理可能的解析错误,确保应用不会因为损坏的数据而崩溃。返回值表示是否成功恢复。
启动时询问是否继续上次的游戏。
void _checkSavedGame() async {
bool hasSavedGame = await _loadSavedGame();
if (hasSavedGame && mounted) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('继续游戏'),
content: const Text('发现未完成的游戏,是否继续?'),
actions: [
TextButton(
onPressed: () {
Navigator.pop(context);
controller.generateNewGame('Easy');
},
child: const Text('新游戏'),
),
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('继续'),
),
],
),
);
}
}
在initState中调用_checkSavedGame,如果有保存的游戏就显示对话框让用户选择。mounted检查确保widget还在树中,避免在dispose后调用showDialog。
游戏的音效反馈可以增强沉浸感。
class GameSoundManager {
static final AudioPlayer _player = AudioPlayer();
static Future<void> playTap() async {
await _player.play(AssetSource('sounds/tap.mp3'));
}
static Future<void> playError() async {
await _player.play(AssetSource('sounds/error.mp3'));
}
static Future<void> playSuccess() async {
await _player.play(AssetSource('sounds/success.mp3'));
}
static Future<void> playVictory() async {
await _player.play(AssetSource('sounds/victory.mp3'));
}
}
GameSoundManager管理游戏中的各种音效。tap用于普通点击,error用于填入冲突数字,success用于正确填入,victory用于完成游戏。使用静态方法方便在任何地方调用。
在操作中添加音效。
void enterNumber(int number) {
if (selectedRow < 0 || selectedCol < 0) return;
if (isFixed[selectedRow][selectedCol]) return;
GameSoundManager.playTap();
board[selectedRow][selectedCol] = number;
if (hasConflict(selectedRow, selectedCol)) {
GameSoundManager.playError();
}
update();
_checkCompletion();
}
在enterNumber方法中添加音效调用。填入数字时播放tap音效,如果产生冲突则播放error音效。这种即时的声音反馈让用户更清楚地知道操作的结果。
游戏的震动反馈也是重要的触觉体验。
void _vibrate(VibrationType type) {
switch (type) {
case VibrationType.light:
HapticFeedback.lightImpact();
break;
case VibrationType.medium:
HapticFeedback.mediumImpact();
break;
case VibrationType.heavy:
HapticFeedback.heavyImpact();
break;
case VibrationType.error:
HapticFeedback.vibrate();
break;
}
}
enum VibrationType { light, medium, heavy, error }
HapticFeedback提供了不同强度的震动反馈。light用于普通点击,medium用于重要操作,heavy用于完成游戏,error用于错误提示。这种触觉反馈让用户在不看屏幕的情况下也能感知操作结果。
游戏的手势快捷操作可以提升效率。
GestureDetector(
onDoubleTap: () {
if (!controller.isFixed[row][col] && controller.board[row][col] != 0) {
controller.eraseCell();
}
},
onLongPress: () {
controller.toggleNotesMode();
controller.selectCell(row, col);
},
onTap: () => controller.selectCell(row, col),
child: _buildCellContent(row, col),
)
双击可以快速擦除单元格,长按可以切换到笔记模式并选中单元格。这些手势快捷操作让熟练的玩家可以更高效地操作,减少点击次数。
游戏的撤销确认可以防止误操作。
void undoMove() {
if (moveHistory.isEmpty) {
Get.snackbar('提示', '没有可撤销的操作');
return;
}
GameMove lastMove = moveHistory.removeLast();
if (lastMove.previousValue != null) {
board[lastMove.row][lastMove.col] = lastMove.previousValue!;
}
if (lastMove.previousNotes != null) {
notes[lastMove.row][lastMove.col] = lastMove.previousNotes!;
}
update();
}
当没有可撤销的操作时,显示提示信息而不是静默失败。Get.snackbar是GetX提供的便捷方法,可以快速显示底部提示条。这种反馈让用户知道为什么操作没有效果。
游戏的难度提示可以帮助新手。
Widget _buildDifficultyHint() {
String hint;
switch (controller.difficulty) {
case 'Easy':
hint = '简单难度:适合初学者,大部分数字已填入';
break;
case 'Medium':
hint = '中等难度:需要一些推理技巧';
break;
case 'Hard':
hint = '困难难度:需要高级解题技巧';
break;
case 'Expert':
hint = '专家难度:极具挑战性,需要多种技巧组合';
break;
default:
hint = '';
}
return Text(
hint,
style: TextStyle(fontSize: 12.sp, color: Colors.grey),
textAlign: TextAlign.center,
);
}
根据当前难度显示相应的提示信息,帮助玩家了解当前谜题的难度级别和所需技巧。这对于新手玩家特别有帮助。
总结一下游戏主界面的完整功能体系。核心是布局管理和状态响应,在此基础上我们添加了生命周期处理、进度保存恢复、音效震动反馈、手势快捷操作等功能。这些功能的组合让游戏主界面不仅功能完整,还具有良好的用户体验。通过合理的架构设计,代码保持清晰可维护,便于后续扩展新功能。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
更多推荐


所有评论(0)