Flutter + OpenHarmony 实战:从零开发小游戏(二)——轨道跳跃与动态关卡生成

个人主页:ujainu

文章目录

引言

在上一篇《主菜单与最高分存储》中,我们搭建了游戏的基础架构。本篇将聚焦核心玩法实现------一个类似"球跳环"的休闲游戏:玩家点击屏幕,小球沿当前圆环切线方向发射,若成功落入下一个圆环,则继续前进;否则游戏结束。

与传统游戏不同,本项目不依赖任何物理引擎(如 Flame 或 Box2D) ,而是通过纯数学计算 + Flutter 动画系统实现轻量级、高帧率的轨道跳跃逻辑。我们将重点解决:

  • ✅ 如何用 AnimationController 构建稳定 60fps 游戏循环;
  • ✅ 如何通过向量运算(atan2, cos/sin, 点积)实现"切向发射"与"轨道吸附";
  • ✅ 如何动态生成无限关卡并保证间距合理;
  • ✅ 如何精准判断碰撞与入射方向,避免误判。

💡 目标平台:代码兼容 OpenHarmony(需 Flutter 运行时)、Android、iOS,性能优异,包体小巧。


一、整体架构:GameScreen 与数据模型

1. 核心数据模型:Circle

每个圆环由中心点 (x, y) 和半径 r 定义:

dart 复制代码
class Circle {
  final Offset center;
  final double radius;

  Circle({required this.center, required this.radius});
}

小球则简化为一个 Offset position,因其尺寸远小于圆环,可忽略半径。

2. GameScreen:StatefulWidget 管理全局状态

dart 复制代码
class GameScreen extends StatefulWidget {
  @override
  State<GameScreen> createState() => _GameScreenState();
}

class _GameScreenState extends State<GameScreen> with TickerProviderStateMixin {
  late AnimationController _controller;
  List<Circle> _circles = [];
  Offset _ballPosition = Offset.zero;
  Offset _ballVelocity = Offset.zero;
  bool _isPlaying = false;
  int _score = 0;

  @override
  void initState() {
    super.initState();
    _initGame();
    _controller = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 16), // ~60fps
    )..repeat();
    _controller.addListener(_gameLoop);
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }
}

🔑 关键设计

  • 使用 TickerProviderStateMixin 提供 vsync;
  • AnimationController 替代 Timer,确保帧率稳定且与屏幕刷新同步;
  • _gameLoop 在每一帧被调用,驱动游戏逻辑。

二、游戏初始化与动态关卡生成

1. _initGame():创建初始四连环

dart 复制代码
void _initGame() {
  final size = MediaQuery.of(context).size;
  final centerX = size.width / 2;
  final centerY = size.height / 2;
  final baseRadius = 80.0;
  final spacing = 120.0;

  _circles = List.generate(4, (i) {
    return Circle(
      center: Offset(centerX, centerY + i * spacing),
      radius: baseRadius,
    );
  });

  // 小球初始位于第一个圆环中心
  _ballPosition = _circles.first.center;
  _ballVelocity = Offset.zero;
  _isPlaying = false;
  _score = 0;
}

2. _addNextCircle():动态生成新圆环

为保持挑战性,新圆环在水平方向随机偏移,但限制最大偏移量:

dart 复制代码
void _addNextCircle() {
  final last = _circles.last;
  final maxSize = MediaQuery.of(context).size;
  final maxOffsetX = maxSize.width * 0.3; // 最大偏移 30%
  final randomX = (Random().nextDouble() - 0.5) * 2 * maxOffsetX;
  final newCenter = Offset(last.center.dx + randomX, last.center.dy + 120);

  // 防止超出屏幕
  final clampedX = newCenter.dx.clamp(100, maxSize.width - 100);
  _circles.add(Circle(center: Offset(clampedX, newCenter.dy), radius: 80));
}

设计优势

  • 关卡无限生成;
  • 间距固定(120px),难度可控;
  • 水平偏移随机但有界,避免不可达关卡。

三、发射逻辑:切向速度与向量数学

1. _launchBall():计算切向速度

当用户点击屏幕,小球应沿当前圆环的切线方向飞出。关键在于求切向单位向量。

dart 复制代码
void _launchBall(TapDownDetails details) {
  if (_isPlaying) return;

  final touch = details.globalPosition;
  final currentCircle = _circles.first;
  final toTouch = touch - currentCircle.center;

  // 计算角度 θ = atan2(dy, dx)
  final angle = math.atan2(toTouch.dy, toTouch.dx);

  // 切向方向:θ + π/2 (逆时针旋转90度)
  final tangentAngle = angle + math.pi / 2;

  // 速度大小固定,方向为切向
  const speed = 400.0; // pixels/second
  _ballVelocity = Offset(
    speed * math.cos(tangentAngle),
    speed * math.sin(tangentAngle),
  );

  _isPlaying = true;
}

📐 数学原理

  • 向量 (dx, dy) 的切向为 (-dy, dx)(dy, -dx)
  • 使用 atan2 可正确处理所有象限;
  • cos/sin 将角度转为单位向量。

四、游戏主循环:稳定帧率与位置更新

_gameLoop():每帧更新小球位置

dart 复制代码
void _gameLoop() {
  if (!_isPlaying) return;

  // dt = 16ms ≈ 0.016s
  final dt = 0.016;
  _ballPosition += _ballVelocity * dt;

  // 边界检测(可选)
  final size = MediaQuery.of(context).size;
  if (_ballPosition.dx < 0 || _ballPosition.dx > size.width ||
      _ballPosition.dy < 0 || _ballPosition.dy > size.height) {
    _endGame();
    return;
  }

  // 碰撞检测
  _checkCollisions();

  if (mounted) setState(() {});
}

⚙️ 为何用 AnimationController?

  • Timer.periodic 受系统调度影响,帧率不稳定;
  • AnimationController 与屏幕 VSync 同步,确保流畅动画;
  • 在低端 OpenHarmony 设备上表现更可靠。

五、碰撞检测:距离 + 点积双重验证

仅靠"小球进入圆环"不足以判定成功------还需检查是否从外向内入射

1. 距离检测

dart 复制代码
final nextCircle = _circles[1];
final distance = (_ballPosition - nextCircle.center).distance;
if (distance <= nextCircle.radius) {
  // 进入圆环范围
}

2. 点积判断入射方向

计算小球速度向量与"指向圆心"向量的夹角:

dart 复制代码
final toCenter = nextCircle.center - _ballPosition;
final dot = _ballVelocity.dx * toCenter.dx + _ballVelocity.dy * toCenter.dy;

// 若点积 < 0,说明速度方向与 toCenter 夹角 > 90°,即"背离圆心"
if (dot >= 0) {
  // 成功落入!
  _handleSuccess();
}

🎯 点积原理

  • a · b = |a||b|cosθ
  • θ < 90°cosθ > 0,点积为正 → 正对圆心运动;
  • 避免小球"擦边反弹"被误判为成功。

六、游戏结束与超时保护

  • 成功:移除首个圆环,小球重置到新首环,分数+1,生成新环;
  • 失败:小球飞出屏幕或未正确入射;
  • 超时保护:若 10 秒未操作,自动结束(防挂机)。
dart 复制代码
void _handleSuccess() {
  _circles.removeAt(0);
  _ballPosition = _circles.first.center;
  _ballVelocity = Offset.zero;
  _isPlaying = false;
  _score++;
  _addNextCircle();
}

游戏结束后,保存最高分并返回主菜单(略,复用第一篇逻辑)。


七、完整可运行代码

将以下代码保存为 lib/game_screen.dart 并在主菜单中跳转即可运行:

dart 复制代码
import 'dart:math' as math;
import 'dart:async';
import 'package:flutter/material.dart';

class GameScreen extends StatefulWidget {
  @override
  State<GameScreen> createState() => _GameScreenState();
}

class Circle {
  final Offset center;
  final double radius;
  Circle({required this.center, required this.radius});
}

class _GameScreenState extends State<GameScreen> with TickerProviderStateMixin {
  late AnimationController _controller;
  List<Circle> _circles = [];
  Offset _ballPosition = Offset.zero;
  Offset _ballVelocity = Offset.zero;
  bool _isPlaying = false;
  int _score = 0;
  Timer? _timeoutTimer;

  @override
  void initState() {
    super.initState();
    _initGame();
    _startTimeout();
    _controller = AnimationController(vsync: this, duration: const Duration(milliseconds: 16))
      ..repeat()
      ..addListener(_gameLoop);
  }

  void _startTimeout() {
    _timeoutTimer?.cancel();
    _timeoutTimer = Timer(const Duration(seconds: 10), _endGame);
  }

  void _initGame() {
    final size = MediaQuery.of(context).size;
    final centerX = size.width / 2;
    final baseRadius = 80.0;
    final spacing = 120.0;

    _circles = List.generate(4, (i) {
      return Circle(center: Offset(centerX, spacing * (i + 1)), radius: baseRadius);
    });
    _ballPosition = _circles.first.center;
    _ballVelocity = Offset.zero;
    _isPlaying = false;
    _score = 0;
  }

  void _addNextCircle() {
    final last = _circles.last;
    final size = MediaQuery.of(context).size;
    final maxOffsetX = size.width * 0.3;
    final randomX = (math.Random().nextDouble() - 0.5) * 2 * maxOffsetX;
    final newCenter = Offset((last.center.dx + randomX).clamp(100.0, size.width - 100.0), last.center.dy + 120);
    _circles.add(Circle(center: newCenter, radius: 80));
  }

  void _launchBall(TapDownDetails details) {
    if (_isPlaying) return;
    _timeoutTimer?.cancel();
    final touch = details.globalPosition;
    final current = _circles.first;
    final toTouch = touch - current.center;
    final angle = math.atan2(toTouch.dy, toTouch.dx);
    final tangentAngle = angle + math.pi / 2;
    const speed = 400.0;
    _ballVelocity = Offset(speed * math.cos(tangentAngle), speed * math.sin(tangentAngle));
    _isPlaying = true;
  }

  void _gameLoop() {
    if (!_isPlaying) return;
    final dt = 0.016;
    _ballPosition += _ballVelocity * dt;

    final size = MediaQuery.of(context).size;
    if (_ballPosition.dx < 0 || _ballPosition.dx > size.width || _ballPosition.dy < 0 || _ballPosition.dy > size.height) {
      _endGame();
      return;
    }

    if (_circles.length > 1) {
      final next = _circles[1];
      final distance = (_ballPosition - next.center).distance;
      if (distance <= next.radius) {
        final toCenter = next.center - _ballPosition;
        final dot = _ballVelocity.dx * toCenter.dx + _ballVelocity.dy * toCenter.dy;
        if (dot >= 0) {
          _handleSuccess();
          _startTimeout();
          return;
        }
      }
    }

    if (mounted) setState(() {});
  }

  void _handleSuccess() {
    _circles.removeAt(0);
    _ballPosition = _circles.first.center;
    _ballVelocity = Offset.zero;
    _isPlaying = false;
    _score++;
    _addNextCircle();
  }

  void _endGame() {
    _controller.stop();
    if (!mounted) return;
    Navigator.pushReplacementNamed(context, '/'); // 返回主菜单
  }

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

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTapDown: _launchBall,
      child: Scaffold(
        backgroundColor: Colors.black,
        body: CustomPaint(
          painter: _GamePainter(_circles, _ballPosition, _score),
        ),
      ),
    );
  }
}

class _GamePainter extends CustomPainter {
  final List<Circle> circles;
  final Offset ballPosition;
  final int score;

  _GamePainter(this.circles, this.ballPosition, this.score);

  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()..style = PaintingStyle.stroke..strokeWidth = 4;

    // 绘制圆环
    for (int i = 0; i < circles.length; i++) {
      paint.color = i == 0 ? Colors.red : Colors.white;
      canvas.drawCircle(circles[i].center, circles[i].radius, paint);
    }

    // 绘制小球
    paint.style = PaintingStyle.fill;
    paint.color = Colors.yellow;
    canvas.drawCircle(ballPosition, 10, paint);

    // 绘制分数
    final textPainter = TextPainter(
      text: TextSpan(text: 'Score: $score', style: const TextStyle(color: Colors.white, fontSize: 24)),
      textDirection: TextDirection.ltr,
    )..layout();
    textPainter.paint(canvas, const Offset(20, 50));
  }

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

结语

本文通过纯 Dart 代码实现了无物理引擎的轨道跳跃游戏,展示了 向量数学、稳定动画循环、动态关卡生成 等核心游戏开发技术。该方案轻量高效,特别适合 OpenHarmony 等资源受限环境。下一期,我们将加入粒子特效、音效反馈与皮肤系统,让游戏更具沉浸感!

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

相关推荐
大雷神7 小时前
HarmonyOS智慧农业管理应用开发教程--高高种地-- 第24篇:学习中心 - 课程体系设计
大数据·学习·harmonyos
一起养小猫8 小时前
Flutter for OpenHarmony 实战:打造天气预报应用
开发语言·网络·jvm·数据库·flutter·harmonyos
小白郭莫搞科技14 小时前
鸿蒙跨端框架Flutter学习:CustomTween自定义Tween详解
学习·flutter·harmonyos
撬动未来的支点14 小时前
【小游戏开发攻略】(二)游戏玩法设计模式
游戏
mocoding14 小时前
使用鸿蒙化flutter_fluttertoast替换Flutter原有的SnackBar提示弹窗
flutter·华为·harmonyos
中二病码农不会遇见C++学姐14 小时前
文明6-mod制作-游戏素材AI生成记录
人工智能·游戏
2501_9481201515 小时前
基于Flutter的跨平台社交APP开发
flutter
向哆哆16 小时前
构建健康档案管理系统:Flutter × OpenHarmony 跨端实现就医记录展示
flutter·开源·鸿蒙·openharmony·开源鸿蒙
2601_9498683617 小时前
Flutter for OpenHarmony 电子合同签署App实战 - 主入口实现
开发语言·javascript·flutter