胜利弹窗是数独游戏完成时的重要反馈。当玩家成功解决谜题时,应该给予充分的肯定和庆祝。一个好的胜利弹窗不仅要展示成绩,还要激励玩家继续挑战。今天我们来详细实现数独游戏的胜利弹窗。

游戏完成检测

在显示胜利弹窗之前,我们需要准确检测游戏是否完成。

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并触发UI更新。

监听完成状态

在GamePage中监听完成状态,在适当时机显示胜利弹窗。


Widget build(BuildContext context) {
  return Scaffold(
    appBar: AppBar(title: const Text('数独')),
    body: GetBuilder<GameController>(
      builder: (ctrl) {
        if (ctrl.isComplete) {
          WidgetsBinding.instance.addPostFrameCallback((_) {
            _showVictoryDialog();
          });
        }

当isComplete变为true时,使用addPostFrameCallback在当前帧渲染完成后显示胜利弹窗。这避免了在build过程中调用showDialog导致的错误。

继续构建游戏界面

        return ctrl.isPaused
            ? _buildPausedScreen()
            : _buildGameScreen();
      },
    ),
  );
}

根据isPaused状态决定显示暂停界面还是游戏界面。GetBuilder会在controller调用update()时自动重建。

基本胜利弹窗

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防止玩家点击对话框外部关闭它,确保玩家看到自己的成绩。对话框内容包括祝贺文字、用时和提示次数。

弹窗操作按钮

      actions: [
        TextButton(
          onPressed: () {
            Navigator.pop(context);
            _showNewGameDialog();
          },
          child: const Text('新游戏'),
        ),
      ],
    ),
  );
}

点击新游戏按钮后关闭弹窗并显示难度选择对话框。Navigator.pop关闭当前对话框,然后调用_showNewGameDialog让玩家选择新游戏的难度。

增强版胜利弹窗

使用自定义Dialog替代AlertDialog,可以更灵活地控制样式。

void _showVictoryDialog() {
  showDialog(
    context: context,
    barrierDismissible: false,
    builder: (context) => Dialog(
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(16.r),
      ),
      child: Padding(
        padding: EdgeInsets.all(24.w),
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [

Dialog组件比AlertDialog更灵活,可以完全自定义内容布局。shape设置圆角,padding设置内边距。mainAxisSize.min让Column只占用必要的高度。

奖杯图标和标题

            Icon(
              Icons.emoji_events,
              size: 60.sp,
              color: Colors.amber,
            ),
            SizedBox(height: 16.h),
            Text(
              '恭喜完成!',
              style: TextStyle(
                fontSize: 24.sp,
                fontWeight: FontWeight.bold,
              ),
            ),

金色奖杯图标增强庆祝感。使用.sp单位确保字体大小适配不同屏幕。粗体标题让祝贺语更加醒目。

成绩信息展示

            SizedBox(height: 24.h),
            _buildStatRow('难度', controller.difficulty),
            _buildStatRow('用时', controller.formattedTime),
            _buildStatRow('提示', '${controller.hintsUsed}次'),

成绩信息使用整齐的行布局展示难度、用时和提示次数。_buildStatRow是一个辅助方法,用于构建统一样式的信息行。

操作按钮区域

            SizedBox(height: 24.h),
            Row(
              mainAxisAlignment: MainAxisAlignment.spaceEvenly,
              children: [
                TextButton(
                  onPressed: () {
                    Navigator.pop(context);
                  },
                  child: const Text('关闭'),
                ),
                ElevatedButton(
                  onPressed: () {
                    Navigator.pop(context);
                    _showNewGameDialog();
                  },
                  child: const Text('新游戏'),
                ),
              ],
            ),
          ],
        ),
      ),
    ),
  );
}

提供关闭和新游戏两个选项。ElevatedButton比TextButton更突出,引导玩家开始新游戏。spaceEvenly让两个按钮均匀分布。

成绩行构建方法

Widget _buildStatRow(String label, String value) {
  return Padding(
    padding: EdgeInsets.symmetric(vertical: 4.h),
    child: Row(
      mainAxisAlignment: MainAxisAlignment.spaceBetween,
      children: [
        Text(label, style: TextStyle(fontSize: 16.sp, color: Colors.grey)),
        Text(value, style: TextStyle(fontSize: 16.sp, fontWeight: FontWeight.bold)),
      ],
    ),
  );
}

_buildStatRow构建标签-值对的行布局。标签使用灰色,值使用粗体黑色,形成视觉对比。spaceBetween让标签和值分别靠左右对齐。

弹窗动画效果

使用showGeneralDialog可以自定义弹窗的过渡动画。

void _showVictoryDialog() {
  showGeneralDialog(
    context: context,
    barrierDismissible: false,
    barrierColor: Colors.black54,
    transitionDuration: const Duration(milliseconds: 300),
    transitionBuilder: (context, animation, secondaryAnimation, child) {
      return ScaleTransition(
        scale: CurvedAnimation(
          parent: animation,
          curve: Curves.elasticOut,
        ),
        child: child,
      );
    },

showGeneralDialog允许自定义过渡动画。使用elasticOut曲线产生弹跳效果,让弹窗出现更有活力。transitionDuration设置动画时长为300毫秒。

弹窗内容构建

    pageBuilder: (context, animation, secondaryAnimation) {
      return Center(
        child: Material(
          color: Colors.transparent,
          child: _buildVictoryContent(),
        ),
      );
    },
  );
}

pageBuilder构建弹窗的实际内容。Material组件确保子组件可以使用Material Design的效果。Center让弹窗居中显示。

胜利弹窗内容容器

Widget _buildVictoryContent() {
  return Container(
    width: 300.w,
    padding: EdgeInsets.all(24.w),
    decoration: BoxDecoration(
      color: Colors.white,
      borderRadius: BorderRadius.circular(16.r),
      boxShadow: [
        BoxShadow(
          color: Colors.black26,
          blurRadius: 10,
          offset: const Offset(0, 5),
        ),
      ],
    ),

Container定义弹窗的外观。固定宽度300确保在不同设备上大小一致。boxShadow添加阴影效果,让弹窗有悬浮感。

内容主体

    child: Column(
      mainAxisSize: MainAxisSize.min,
      children: [
        _buildConfetti(),
        Icon(Icons.emoji_events, size: 60.sp, color: Colors.amber),
        SizedBox(height: 16.h),
        Text(
          '恭喜完成!',
          style: TextStyle(fontSize: 24.sp, fontWeight: FontWeight.bold),
        ),

Column垂直排列所有内容。_buildConfetti添加彩带动画效果。奖杯图标和标题是弹窗的视觉焦点。

祝贺语和成绩

        SizedBox(height: 8.h),
        Text(
          _getCompletionMessage(),
          style: TextStyle(fontSize: 14.sp, color: Colors.grey),
          textAlign: TextAlign.center,
        ),
        SizedBox(height: 24.h),
        _buildStatRow('难度', controller.difficulty),
        _buildStatRow('用时', controller.formattedTime),
        _buildStatRow('提示', '${controller.hintsUsed}次'),

_getCompletionMessage根据成绩生成个性化的祝贺语。成绩信息紧随其后,让玩家一目了然地看到自己的表现。

新纪录标识

        if (_isNewRecord()) ...[
          SizedBox(height: 16.h),
          Container(
            padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 6.h),
            decoration: BoxDecoration(
              color: Colors.amber.shade100,
              borderRadius: BorderRadius.circular(16.r),
            ),
            child: Text(
              '🏆 新纪录!',
              style: TextStyle(
                fontSize: 14.sp,
                fontWeight: FontWeight.bold,
                color: Colors.amber.shade800,
              ),
            ),
          ),
        ],

如果创造了新纪录,显示特殊的金色标识。使用if配合展开运算符…[]条件性地添加组件。这可以激励玩家追求更好的成绩。

操作按钮

        SizedBox(height: 24.h),
        Row(
          mainAxisAlignment: MainAxisAlignment.spaceEvenly,
          children: [
            TextButton(
              onPressed: () => Navigator.pop(context),
              child: const Text('关闭'),
            ),
            ElevatedButton(
              onPressed: () {
                Navigator.pop(context);
                _showNewGameDialog();
              },
              child: const Text('新游戏'),
            ),
          ],
        ),
      ],
    ),
  );
}

底部的操作按钮让玩家可以关闭弹窗或开始新游戏。ElevatedButton更突出,引导玩家继续游戏。

生成祝贺语

根据成绩生成不同的祝贺语,让反馈更加个性化。

String _getCompletionMessage() {
  int seconds = controller.elapsedSeconds;
  int hints = controller.hintsUsed;
  
  if (hints == 0 && seconds < 300) {
    return '太厉害了!不用提示,5分钟内完成!';
  } else if (hints == 0) {
    return '完美!没有使用任何提示!';
  } else if (seconds < 300) {
    return '速度很快!继续保持!';
  } else {
    return '做得好!继续挑战更高难度吧!';
  }
}

根据用时和提示次数生成不同的祝贺语。不用提示且用时短的玩家获得最高评价。这种个性化反馈让玩家感到被认可。

检查新纪录

bool _isNewRecord() {
  String difficulty = controller.difficulty;
  int currentTime = controller.elapsedSeconds;
  int? bestTime = statsController.bestTimeByDifficulty[difficulty];
  
  return bestTime == null || currentTime < bestTime;
}

比较当前用时与该难度的最佳记录。如果是首次完成该难度或打破了之前的记录,返回true。这个检查用于决定是否显示新纪录标识。

分享功能

让玩家可以将成绩分享到社交媒体。

ElevatedButton.icon(
  onPressed: () {
    _shareResult();
  },
  icon: const Icon(Icons.share),
  label: const Text('分享'),
),

void _shareResult() {
  String text = '我在数独游戏中完成了${controller.difficulty}难度的谜题!\n'
                '用时: ${controller.formattedTime}\n'
                '提示: ${controller.hintsUsed}次';
  Share.share(text);
}

Share.share是share_plus包提供的方法,可以调用系统分享功能。分享文本包含难度、用时和提示次数,让朋友了解玩家的成绩。

彩带动画组件

class ConfettiWidget extends StatefulWidget {
  const ConfettiWidget({super.key});

  
  State<ConfettiWidget> createState() => _ConfettiWidgetState();
}

class _ConfettiWidgetState extends State<ConfettiWidget>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  List<Confetti> confettiList = [];

ConfettiWidget创建彩带下落的动画效果。使用SingleTickerProviderStateMixin提供动画的vsync。confettiList存储所有彩带的数据。

初始化彩带动画

  
  void initState() {
    super.initState();
    _controller = AnimationController(
      duration: const Duration(seconds: 3),
      vsync: this,
    );
    
    for (int i = 0; i < 50; i++) {
      confettiList.add(Confetti(
        x: Random().nextDouble() * 300,
        y: -Random().nextDouble() * 100,
        color: Colors.primaries[Random().nextInt(Colors.primaries.length)],
        size: Random().nextDouble() * 8 + 4,
        speed: Random().nextDouble() * 2 + 1,
      ));
    }
    
    _controller.forward();
  }

创建50个彩带,每个有随机的位置、颜色、大小和速度。动画时长3秒,调用forward()开始播放。彩带从屏幕上方随机位置开始下落。

彩带数据模型

class Confetti {
  double x;
  double y;
  Color color;
  double size;
  double speed;
  
  Confetti({
    required this.x,
    required this.y,
    required this.color,
    required this.size,
    required this.speed,
  });
}

Confetti类存储单个彩带的属性。x和y是位置,color是颜色,size是大小,speed是下落速度。这些属性在初始化时随机生成。

彩带绘制器

class ConfettiPainter extends CustomPainter {
  final List<Confetti> confettiList;
  final double progress;
  
  ConfettiPainter({required this.confettiList, required this.progress});
  
  
  void paint(Canvas canvas, Size size) {
    for (var confetti in confettiList) {
      double currentY = confetti.y + progress * size.height * confetti.speed;
      if (currentY > size.height) continue;

ConfettiPainter使用CustomPainter绘制彩带。progress是动画进度(0到1),用于计算彩带的当前位置。超出画布范围的彩带跳过不绘制。

绘制彩带

      Paint paint = Paint()
        ..color = confetti.color.withOpacity(1 - progress * 0.5)
        ..style = PaintingStyle.fill;
      
      canvas.drawCircle(
        Offset(confetti.x, currentY),
        confetti.size,
        paint,
      );
    }
  }
  
  
  bool shouldRepaint(ConfettiPainter oldDelegate) => true;
}

每个彩带绘制为圆形。透明度随动画进度降低,产生渐隐效果。shouldRepaint返回true确保每帧都重绘。这种庆祝效果让胜利时刻更加欢乐。

星星评级

根据成绩显示1-3颗星的评级。

Widget _buildStarRating() {
  int stars = _calculateStars();
  
  return Row(
    mainAxisAlignment: MainAxisAlignment.center,
    children: List.generate(3, (index) {
      bool isFilled = index < stars;
      return Padding(
        padding: EdgeInsets.symmetric(horizontal: 4.w),
        child: Icon(
          isFilled ? Icons.star : Icons.star_border,
          size: 32.sp,
          color: Colors.amber,
        ),
      );
    }),
  );
}

List.generate创建3个星星图标。根据计算出的星数决定显示实心还是空心星星。金色星星是游戏中常见的评级方式。

计算星级

int _calculateStars() {
  int seconds = controller.elapsedSeconds;
  int hints = controller.hintsUsed;
  String difficulty = controller.difficulty;
  
  int threeStarTime = _getThreeStarTime(difficulty);
  int twoStarTime = threeStarTime * 2;
  
  if (hints == 0 && seconds <= threeStarTime) {
    return 3;
  } else if (hints <= 2 && seconds <= twoStarTime) {
    return 2;
  } else {
    return 1;
  }
}

三星需要不用提示且在目标时间内完成,两星允许少量提示和较长时间,一星是基本完成。这种评级系统给玩家明确的目标。

获取三星时间标准

int _getThreeStarTime(String difficulty) {
  switch (difficulty) {
    case 'Easy': return 180;
    case 'Medium': return 300;
    case 'Hard': return 600;
    case 'Expert': return 900;
    default: return 180;
  }
}

不同难度有不同的三星时间标准。简单难度3分钟,中等5分钟,困难10分钟,专家15分钟。这些标准可以根据实际测试调整。

可展开的详细统计

class ExpandableStats extends StatefulWidget {
  final GameController controller;
  
  const ExpandableStats({super.key, required this.controller});

  
  State<ExpandableStats> createState() => _ExpandableStatsState();
}

class _ExpandableStatsState extends State<ExpandableStats> {
  bool _isExpanded = false;

ExpandableStats提供可展开的详细统计信息。默认只显示基本信息,点击可以展开查看更多细节。_isExpanded控制展开状态。

展开控制

  
  Widget build(BuildContext context) {
    return Column(
      children: [
        GestureDetector(
          onTap: () => setState(() => _isExpanded = !_isExpanded),
          child: Row(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Text(
                '详细统计',
                style: TextStyle(fontSize: 14.sp, color: Colors.grey),
              ),
              Icon(
                _isExpanded ? Icons.expand_less : Icons.expand_more,
                color: Colors.grey,
              ),
            ],
          ),
        ),

点击"详细统计"切换展开状态。图标根据状态显示向上或向下的箭头,提示用户可以展开或收起。

展开动画

        AnimatedCrossFade(
          firstChild: const SizedBox(),
          secondChild: _buildDetailedStats(),
          crossFadeState: _isExpanded 
              ? CrossFadeState.showSecond 
              : CrossFadeState.showFirst,
          duration: const Duration(milliseconds: 200),
        ),
      ],
    );
  }

AnimatedCrossFade提供平滑的展开动画。收起时显示空的SizedBox,展开时显示详细统计。200毫秒的动画时长让过渡自然流畅。

详细统计内容

  Widget _buildDetailedStats() {
    return Padding(
      padding: EdgeInsets.only(top: 12.h),
      child: Column(
        children: [
          _buildStatRow('填入数字', '${81 - _countInitialClues()}个'),
          _buildStatRow('平均每格', '${_calculateAverageTimePerCell()}秒'),
          _buildStatRow('错误次数', '${widget.controller.errorCount}次'),
          _buildStatRow('撤销次数', '${widget.controller.undoCount}次'),
        ],
      ),
    );
  }

详细统计包括填入的数字数量、平均每格用时、错误次数和撤销次数。这些数据帮助玩家分析自己的解题过程。

计算初始线索数

  int _countInitialClues() {
    int count = 0;
    for (var row in widget.controller.isFixed) {
      for (var cell in row) {
        if (cell) count++;
      }
    }
    return count;
  }

遍历isFixed数组统计初始给出的数字数量。isFixed为true的单元格是谜题初始就有的数字,玩家不能修改。

计算平均用时

  String _calculateAverageTimePerCell() {
    int filledCells = 81 - _countInitialClues();
    if (filledCells == 0) return '0';
    double average = widget.controller.elapsedSeconds / filledCells;
    return average.toStringAsFixed(1);
  }
}

用总时间除以玩家填入的数字数量得到平均每格用时。toStringAsFixed(1)保留一位小数。这个指标反映玩家的解题速度。

排行榜入口

Widget _buildLeaderboardEntry() {
  int rank = _calculateRank();
  
  return Container(
    margin: EdgeInsets.only(top: 16.h),
    padding: EdgeInsets.all(12.w),
    decoration: BoxDecoration(
      color: Colors.grey.shade100,
      borderRadius: BorderRadius.circular(8.r),
    ),
    child: Row(
      mainAxisAlignment: MainAxisAlignment.spaceBetween,
      children: [
        Row(
          children: [
            Icon(Icons.leaderboard, color: Colors.grey, size: 20.sp),
            SizedBox(width: 8.w),
            Text('排名', style: TextStyle(fontSize: 14.sp, color: Colors.grey)),
          ],
        ),

排行榜入口显示当前成绩在历史记录中的排名。使用灰色背景和圆角让它与其他内容区分开。

排名显示

        Text(
          '#$rank',
          style: TextStyle(
            fontSize: 16.sp,
            fontWeight: FontWeight.bold,
            color: rank <= 3 ? Colors.amber : Colors.black,
          ),
        ),
      ],
    ),
  );
}

前三名使用金色显示,其他名次使用黑色。#符号是排名的常见表示方式。这可以激励玩家追求更好的成绩。

计算排名

int _calculateRank() {
  String difficulty = controller.difficulty;
  int time = controller.elapsedSeconds;
  List<int> allTimes = statsController.getAllTimes(difficulty);
  allTimes.add(time);
  allTimes.sort();
  return allTimes.indexOf(time) + 1;
}

获取该难度的所有历史用时,加入当前用时后排序,找到当前用时的位置就是排名。indexOf返回的是从0开始的索引,所以加1得到实际排名。

再来一局按钮

Widget _buildPlayAgainButton() {
  return ElevatedButton.icon(
    onPressed: () {
      Navigator.pop(context);
      controller.generateNewGame(controller.difficulty);
    },
    icon: const Icon(Icons.replay),
    label: const Text('再来一局'),
    style: ElevatedButton.styleFrom(
      backgroundColor: Colors.green,
      foregroundColor: Colors.white,
      padding: EdgeInsets.symmetric(horizontal: 24.w, vertical: 12.h),
    ),
  );
}

再来一局按钮让玩家可以快速开始同难度的新游戏。绿色按钮表示积极的行动。ElevatedButton.icon同时显示图标和文字。

挑战更高难度

Widget _buildChallengeButton() {
  String? nextDifficulty = _getNextDifficulty();
  if (nextDifficulty == null) return const SizedBox();
  
  return TextButton.icon(
    onPressed: () {
      Navigator.pop(context);
      controller.generateNewGame(nextDifficulty);
    },
    icon: const Icon(Icons.trending_up),
    label: Text('挑战$nextDifficulty'),
  );
}

如果当前不是最高难度,显示挑战更高难度的按钮。这鼓励玩家不断进步,尝试更具挑战性的谜题。

获取下一难度

String? _getNextDifficulty() {
  switch (controller.difficulty) {
    case 'Easy': return '中等';
    case 'Medium': return '困难';
    case 'Hard': return '专家';
    default: return null;
  }
}

根据当前难度返回下一个难度级别。专家难度是最高级别,返回null表示没有更高难度。

总结

胜利弹窗的关键设计要点:准确检测游戏完成、丰富的成绩展示(用时、提示次数、星级评价)、动画效果增强庆祝感、详细统计帮助玩家分析、排行榜激励追求更好成绩、多种后续操作选项。

胜利弹窗是对玩家努力的肯定,一个好的胜利弹窗可以给玩家带来成就感,激励他们继续挑战。通过精心设计的视觉效果和丰富的信息展示,我们可以让这个时刻变得更加难忘。

欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐