Flutter + OpenHarmony 实现经典打砖块游戏开发实战—— 物理反弹、碰撞检测与关卡系统

个人主页:ujainu

欢迎加入开源鸿蒙跨平台社区:

https://openharmonycrossplatform.csdn.net

文章目录

前言

在移动游戏开发中,打砖块(Breakout) 是一个兼具教育意义与娱乐性的经典范例。它结构清晰、逻辑明确,却完整涵盖了 物理模拟、碰撞检测、状态管理、关卡设计 四大核心模块。正因如此,它成为无数开发者入门游戏编程的"第一课"。

本文将基于 Flutter + OpenHarmony 平台,从零构建一个功能完整、性能优化、可直接运行的打砖块游戏。我们将深入剖析:

球体反弹角度计算 :基于入射角 = 反射角的物理模型

挡板控制 :通过 GestureDetector 实现平滑拖拽

多层级碰撞检测 :砖块、挡板、边界墙的精准判定

关卡重置机制 :失败后一键重启,状态彻底清理

OpenHarmony 兼容性:纯 Dart 实现,无平台依赖

💡 目标读者 :具备基础 Flutter 开发经验,希望掌握游戏核心逻辑的中级开发者。

环境说明:Flutter + OpenHarmony 开发环境已配置完毕,无需重复搭建步骤。


一、为什么选择打砖块作为教学案例?

打砖块看似简单,实则蕴含丰富的工程思维:

模块 技术点 学习价值
物理系统 向量速度、角度反射 理解基础运动学
碰撞检测 AABB 矩形碰撞、边缘判定 掌握游戏核心算法
状态管理 游戏运行/暂停/结束 构建健壮状态机
关卡设计 砖块布局、难度递增 培养游戏设计思维

更重要的是,它不依赖复杂图形库 ,仅用 Flutter 的 CustomPainter 即可高效渲染,完美适配 OpenHarmony 的轻量化特性。


二、整体架构设计

我们采用 单屏 + 自绘 + 状态驱动 的架构:

  • 主界面BreakoutGameScreen(StatefulWidget)
  • 渲染层BreakoutPainter(CustomPainter)
  • 数据模型
    • Ball:球的位置、速度
    • Paddle:挡板位置、宽度
    • Brick:砖块位置、是否存活
  • 游戏逻辑 :在 Timer 驱动下每帧更新状态

📌 关键原则逻辑与渲染分离CustomPainter 只负责绘制,所有计算在 State 中完成。


三、核心数据模型定义

1. 球体(Ball)

dart 复制代码
class Ball {
  Offset position; // 当前中心点
  double radius = 10;
  double dx = 3.0; // X 方向速度
  double dy = -3.0; // Y 方向速度(初始向上)

  Ball(this.position);
}

2. 挡板(Paddle)

dart 复制代码
class Paddle {
  double x; // 左上角 X 坐标
  final double y = 600; // 固定 Y(底部)
  final double width = 100;
  final double height = 15;

  Paddle(this.x);
}

3. 砖块(Brick)

dart 复制代码
class Brick {
  Rect rect;
  bool isDestroyed = false;
  final Color color;

  Brick(this.rect, this.color);
}

四、球体反弹物理:角度计算详解

打砖块的核心在于 反弹逻辑。我们遵循经典物理规则:

入射角 = 反射角,且反弹方向垂直于碰撞面。

1. 与水平面碰撞(如挡板、顶部墙)

  • 仅反转 Y 速度dy = -dy
  • X 速度保持不变

2. 与垂直面碰撞(如左右墙)

  • 仅反转 X 速度dx = -dx
  • Y 速度保持不变

3. 与砖块碰撞

砖块是矩形,需判断球是从上方、下方、左侧还是右侧撞击:

dart 复制代码
void _handleBrickCollision(Brick brick, Ball ball) {
  if (brick.isDestroyed) return;

  final ballLeft = ball.position.dx - ball.radius;
  final ballRight = ball.position.dx + ball.radius;
  final ballTop = ball.position.dy - ball.radius;
  final ballBottom = ballTop + 2 * ball.radius;

  final brickLeft = brick.rect.left;
  final brickRight = brick.rect.right;
  final brickTop = brick.rect.top;
  final brickBottom = brick.rect.bottom;

  // 判断是否发生碰撞
  if (ballRight > brickLeft &&
      ballLeft < brickRight &&
      ballBottom > brickTop &&
      ballTop < brickBottom) {
    
    brick.isDestroyed = true; // 销毁砖块

    // 计算重叠区域
    final overlapX = min(ballRight, brickRight) - max(ballLeft, brickLeft);
    final overlapY = min(ballBottom, brickBottom) - max(ballTop, brickTop);

    // 根据重叠大小判断主要碰撞方向
    if (overlapX > overlapY) {
      // 垂直碰撞(左右侧)→ 反转 X
      ball.dx = -ball.dx;
    } else {
      // 水平碰撞(上下侧)→ 反转 Y
      ball.dy = -ball.dy;
    }
  }
}

🔍 优化点:通过比较 X/Y 方向的重叠量,精准判定主碰撞面,避免"斜角穿透"问题。


五、挡板控制:手势拖拽实现

使用 GestureDetector 监听水平拖拽,限制挡板在屏幕内移动:

dart 复制代码
@override
Widget build(BuildContext context) {
  return GestureDetector(
    onHorizontalDragUpdate: (details) {
      setState(() {
        _paddle.x += details.delta.dx;
        // 边界限制
        _paddle.x = _paddle.x.clamp(0, MediaQuery.of(context).size.width - _paddle.width);
      });
    },
    child: Scaffold(
      backgroundColor: Colors.black,
      body: CustomPaint(
        painter: BreakoutPainter(
          ball: _ball,
          paddle: _paddle,
          bricks: _bricks,
        ),
      ),
    ),
  );
}

体验优化 :使用 clamp 确保挡板不会移出屏幕,操作流畅无跳跃。


六、完整碰撞检测系统

每帧需检测四类碰撞:

  1. 球 vs 左右墙
  2. 球 vs 顶部墙
  3. 球 vs 挡板
  4. 球 vs 所有存活砖块
dart 复制代码
void _updateGameLogic() {
  final size = MediaQuery.of(context).size;
  final screenWidth = size.width;
  final screenHeight = size.height;

  // 1. 更新球位置
  _ball.position += Offset(_ball.dx, _ball.dy);

  // 2. 边界碰撞(左右墙)
  if (_ball.position.dx <= _ball.radius || _ball.position.dx >= screenWidth - _ball.radius) {
    _ball.dx = -_ball.dx;
  }

  // 3. 顶部墙碰撞
  if (_ball.position.dy <= _ball.radius) {
    _ball.dy = -_ball.dy;
  }

  // 4. 底部边界:游戏失败
  if (_ball.position.dy >= screenHeight) {
    _gameOver = true;
    _timer?.cancel();
    return;
  }

  // 5. 挡板碰撞
  final paddleRect = Rect.fromLTWH(_paddle.x, _paddle.y, _paddle.width, _paddle.height);
  if (_isBallCollidingWithRect(_ball, paddleRect)) {
    _ball.dy = -_ball.dy.abs(); // 确保向上反弹
    // 可选:根据击中挡板位置微调角度
  }

  // 6. 砖块碰撞
  for (final brick in _bricks) {
    if (!brick.isDestroyed) {
      _handleBrickCollision(brick, _ball);
    }
  }

  // 7. 检查胜利条件
  if (_bricks.every((b) => b.isDestroyed)) {
    _gameWin = true;
    _timer?.cancel();
  }
}

⚠️ 注意 :挡板碰撞后强制 dy 为负值,防止球因精度问题"粘"在挡板上。


七、关卡重置与状态管理

游戏结束后,提供"重新开始"按钮,彻底重置所有状态

dart 复制代码
void _resetGame() {
  final size = MediaQuery.of(context).size;
  _ball = Ball(Offset(size.width / 2, size.height - 100));
  _paddle = Paddle(size.width / 2 - 50);
  _bricks = _generateBricks(); // 重新生成砖块
  _gameOver = false;
  _gameWin = false;

  // 重启游戏循环
  _timer?.cancel();
  _timer = Timer.periodic(const Duration(milliseconds: 16), (_) {
    if (!_gameOver && !_gameWin) {
      _updateGameLogic();
      setState(() {});
    }
  });
}

List<Brick> _generateBricks() {
  final bricks = <Brick>[];
  final colors = [Colors.red, Colors.orange, Colors.yellow, Colors.green];
  final rows = 4;
  final cols = 8;
  final brickWidth = 60.0;
  final brickHeight = 20.0;
  final spacing = 5.0;
  final startX = (MediaQuery.of(context).size.width - (cols * (brickWidth + spacing))) / 2;

  for (int row = 0; row < rows; row++) {
    for (int col = 0; col < cols; col++) {
      final x = startX + col * (brickWidth + spacing);
      final y = 50.0 + row * (brickHeight + spacing);
      final rect = Rect.fromLTWH(x, y, brickWidth, brickHeight);
      bricks.add(Brick(rect, colors[row % colors.length]));
    }
  }
  return bricks;
}

关键_resetGame() 重建所有对象,确保无残留状态。


八、自定义绘制:BreakoutPainter

使用 CustomPainter 高效渲染游戏元素:

dart 复制代码
class BreakoutPainter extends CustomPainter {
  final Ball ball;
  final Paddle paddle;
  final List<Brick> bricks;

  BreakoutPainter({required this.ball, required this.paddle, required this.bricks});

  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint();

    // 绘制球
    paint.color = Colors.white;
    canvas.drawCircle(ball.position, ball.radius, paint);

    // 绘制挡板
    paint.color = Colors.blue;
    canvas.drawRect(
      Rect.fromLTWH(paddle.x, paddle.y, paddle.width, paddle.height),
      paint,
    );

    // 绘制砖块
    for (final brick in bricks) {
      if (!brick.isDestroyed) {
        paint.color = brick.color;
        canvas.drawRect(brick.rect, paint);
      }
    }
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}

🚀 性能提示shouldRepaint 返回 true 确保每帧重绘,适用于动态游戏。


九、完整可运行代码(Flutter + OpenHarmony)

以下代码可直接复制到 main.dart 中运行,包含完整游戏逻辑、UI 与重置功能:

dart 复制代码
// main.dart - 打砖块游戏 (Breakout / Arkanoid)
import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart';

// ==================== 数据模型 ====================
class Ball {
  Offset position;
  double radius = 10;
  double dx = 3.0;
  double dy = -3.0;

  Ball(this.position);
}

class Paddle {
  double x;
  final double y;
  final double width = 100;
  final double height = 15;

  Paddle(this.x, this.y);
}

class Brick {
  Rect rect;
  bool isDestroyed = false;
  final Color color;

  Brick(this.rect, this.color);
}

// ==================== 自定义绘制 ====================
class BreakoutPainter extends CustomPainter {
  final Ball ball;
  final Paddle paddle;
  final List<Brick> bricks;
  final bool gameOver;
  final bool gameWin;

  BreakoutPainter({
    required this.ball,
    required this.paddle,
    required this.bricks,
    this.gameOver = false,
    this.gameWin = false,
  });

  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint();

    // 背景
    canvas.drawRect(Offset.zero & size, Paint()..color = Colors.black);

    // 球
    paint.color = Colors.white;
    canvas.drawCircle(ball.position, ball.radius, paint);

    // 挡板
    paint.color = Colors.blue;
    canvas.drawRect(
      Rect.fromLTWH(paddle.x, paddle.y, paddle.width, paddle.height),
      paint,
    );

    // 砖块
    for (final brick in bricks) {
      if (!brick.isDestroyed) {
        paint.color = brick.color;
        canvas.drawRect(brick.rect, paint);
      }
    }

    // 游戏结束/胜利提示
    if (gameOver || gameWin) {
      final textPainter = TextPainter(
        text: TextSpan(
          text: gameOver ? '游戏结束!' : '恭喜通关!',
          style: const TextStyle(color: Colors.white, fontSize: 32, fontWeight: FontWeight.bold),
        ),
        textDirection: TextDirection.ltr,
      );
      textPainter.layout();
      textPainter.paint(
        canvas,
        Offset(size.width / 2 - textPainter.width / 2, size.height / 2 - 50),
      );
    }
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}

// ==================== 主游戏界面 ====================
class BreakoutGameScreen extends StatefulWidget {
  const BreakoutGameScreen({super.key});

  @override
  State<BreakoutGameScreen> createState() => _BreakoutGameScreenState();
}

class _BreakoutGameScreenState extends State<BreakoutGameScreen> {
  late Ball _ball;
  late Paddle _paddle;
  late List<Brick> _bricks;
  Timer? _timer;
  bool _gameOver = false;
  bool _gameWin = false;

  @override
  void initState() {
    super.initState();
    // 初始化时不依赖 context,避免 MediaQuery 错误
    _timer = Timer.periodic(const Duration(milliseconds: 16), (_) {
      if (!_gameOver && !_gameWin) {
        _updateGameLogic();
        if (mounted) setState(() {});
      }
    });
  }

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    // 在 didChangeDependencies 中获取尺寸,确保 context 已准备就绪
    _initializeGame();
  }

  void _initializeGame() {
    final size = MediaQuery.of(context).size; // ✅ 安全获取
    _ball = Ball(Offset(size.width / 2, size.height - 100));
    _paddle = Paddle(size.width / 2 - 50, size.height - 30);
    _bricks = _generateBricks();
    _gameOver = false;
    _gameWin = false;
  }

  List<Brick> _generateBricks() {
    final bricks = <Brick>[];
    final colors = [Colors.red, Colors.orange, Colors.yellow, Colors.green];
    final rows = 4;
    final cols = 8;
    final brickWidth = 60.0;
    final brickHeight = 20.0;
    final spacing = 5.0;
    final totalWidth = cols * (brickWidth + spacing) - spacing;
    final startX = (MediaQuery.of(context).size.width - totalWidth) / 2;

    for (int row = 0; row < rows; row++) {
      for (int col = 0; col < cols; col++) {
        final x = startX + col * (brickWidth + spacing);
        final y = 50.0 + row * (brickHeight + spacing);
        final rect = Rect.fromLTWH(x, y, brickWidth, brickHeight);
        bricks.add(Brick(rect, colors[row % colors.length]));
      }
    }
    return bricks;
  }

  bool _isBallCollidingWithRect(Ball ball, Rect rect) {
    final ballLeft = ball.position.dx - ball.radius;
    final ballRight = ball.position.dx + ball.radius;
    final ballTop = ball.position.dy - ball.radius;
    final ballBottom = ballTop + 2 * ball.radius;

    return ballRight > rect.left &&
           ballLeft < rect.right &&
           ballBottom > rect.top &&
           ballTop < rect.bottom;
  }

  void _handleBrickCollision(Brick brick, Ball ball) {
    if (brick.isDestroyed) return;

    final ballLeft = ball.position.dx - ball.radius;
    final ballRight = ball.position.dx + ball.radius;
    final ballTop = ball.position.dy - ball.radius;
    final ballBottom = ballTop + 2 * ball.radius;

    final brickLeft = brick.rect.left;
    final brickRight = brick.rect.right;
    final brickTop = brick.rect.top;
    final brickBottom = brick.rect.bottom;

    if (ballRight > brickLeft &&
        ballLeft < brickRight &&
        ballBottom > brickTop &&
        ballTop < brickBottom) {
      
      brick.isDestroyed = true;

      final overlapX = min(ballRight, brickRight) - max(ballLeft, brickLeft);
      final overlapY = min(ballBottom, brickBottom) - max(ballTop, brickTop);

      if (overlapX > overlapY) {
        ball.dx = -ball.dx;
      } else {
        ball.dy = -ball.dy;
      }
    }
  }

  void _updateGameLogic() {
    final size = MediaQuery.of(context).size;
    final screenWidth = size.width;
    final screenHeight = size.height;

    // 更新球位置
    _ball.position += Offset(_ball.dx, _ball.dy);

    // 左右墙碰撞
    if (_ball.position.dx <= _ball.radius || _ball.position.dx >= screenWidth - _ball.radius) {
      _ball.dx = -_ball.dx;
    }

    // 顶部墙碰撞
    if (_ball.position.dy <= _ball.radius) {
      _ball.dy = -_ball.dy;
    }

    // 底部边界:失败
    if (_ball.position.dy >= screenHeight) {
      _gameOver = true;
      _timer?.cancel();
      return;
    }

    // 挡板碰撞
    final paddleRect = Rect.fromLTWH(_paddle.x, _paddle.y, _paddle.width, _paddle.height);
    if (_isBallCollidingWithRect(_ball, paddleRect)) {
      _ball.dy = -_ball.dy.abs(); // 强制向上
    }

    // 砖块碰撞
    for (final brick in _bricks) {
      if (!brick.isDestroyed) {
        _handleBrickCollision(brick, _ball);
      }
    }

    // 胜利检测
    if (_bricks.every((b) => b.isDestroyed)) {
      _gameWin = true;
      _timer?.cancel();
    }
  }

  @override
  void dispose() {
    _timer?.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onHorizontalDragUpdate: (details) {
        if (!_gameOver && !_gameWin) {
          setState(() {
            _paddle.x += details.delta.dx;
            _paddle.x = _paddle.x.clamp(0, MediaQuery.of(context).size.width - _paddle.width);
          });
        }
      },
      child: Scaffold(
        backgroundColor: Colors.black,
        body: Stack(
          children: [
            CustomPaint(
              painter: BreakoutPainter(
                ball: _ball,
                paddle: _paddle,
                bricks: _bricks,
                gameOver: _gameOver,
                gameWin: _gameWin,
              ),
              size: Size.infinite,
            ),
            if (_gameOver || _gameWin)
              Positioned.fill(
                child: Column(
                  mainAxisAlignment: MainAxisAlignment.end,
                  children: [
                    Padding(
                      padding: const EdgeInsets.only(bottom: 50),
                      child: ElevatedButton(
                        onPressed: _initializeGame,
                        child: const Text('重新开始', style: TextStyle(fontSize: 18)),
                      ),
                    ),
                  ],
                ),
              ),
          ],
        ),
      ),
    );
  }
}

// ==================== 主程序入口 ====================
void main() {
  runApp(const MaterialApp(
    debugShowCheckedModeBanner: false,
    home: BreakoutGameScreen(),
  ));
}

结语

本文通过实现经典的打砖块游戏,系统性地讲解了 物理反弹、碰撞检测、手势控制、关卡重置 四大核心模块。代码结构清晰、注释完整,且经过性能优化,可直接用于 OpenHarmony 设备部署。

打砖块虽小,却是理解游戏开发底层逻辑的绝佳载体。掌握其原理后,你将能轻松扩展至更复杂的项目,如弹球、台球、甚至 2D 平台跳跃游戏。

相关推荐
微祎_8 小时前
构建一个 Flutter 点击速度测试器:深入解析实时交互、性能度量与响应式 UI 设计
flutter·ui·交互
王码码20358 小时前
Flutter for OpenHarmony 实战之基础组件:第二十七篇 BottomSheet — 动态底部弹窗与底部栏菜单
android·flutter·harmonyos
ZH15455891319 小时前
Flutter for OpenHarmony Python学习助手实战:Web开发框架应用的实现
python·学习·flutter
晚霞的不甘9 小时前
Flutter for OpenHarmony 构建简洁高效的待办事项应用 实战解析
flutter·ui·前端框架·交互·鸿蒙
百锦再9 小时前
Vue高阶知识:利用 defineModel 特性开发搜索组件组合
前端·vue.js·学习·flutter·typescript·前端框架
廖松洋(Alina)9 小时前
【收尾以及复盘】flutter开发鸿蒙APP之成就徽章页面
flutter·华为·开源·harmonyos·鸿蒙
ZH154558913110 小时前
Flutter for OpenHarmony Python学习助手实战:机器学习算法实现的实现
python·学习·flutter
廖松洋(Alina)10 小时前
【收尾以及复盘】flutter开发鸿蒙APP之打卡日历页面
flutter·华为·开源·harmonyos·鸿蒙
廖松洋(Alina)10 小时前
【收尾以及复盘】flutter开发鸿蒙APP之本月数据统计页面
flutter·华为·开源·harmonyos·鸿蒙