
个人主页:ujainu
文章目录
-
- 引言
- [一、整体架构:GameScreen 与数据模型](#一、整体架构:GameScreen 与数据模型)
-
- [1. 核心数据模型:Circle](#1. 核心数据模型:Circle)
- [2. GameScreen:StatefulWidget 管理全局状态](#2. GameScreen:StatefulWidget 管理全局状态)
- 二、游戏初始化与动态关卡生成
-
- [1. `_initGame()`:创建初始四连环](#1.
_initGame():创建初始四连环) - [2. `_addNextCircle()`:动态生成新圆环](#2.
_addNextCircle():动态生成新圆环)
- [1. `_initGame()`:创建初始四连环](#1.
- 三、发射逻辑:切向速度与向量数学
-
- [1. `_launchBall()`:计算切向速度](#1.
_launchBall():计算切向速度)
- [1. `_launchBall()`:计算切向速度](#1.
- 四、游戏主循环:稳定帧率与位置更新
- [五、碰撞检测:距离 + 点积双重验证](#五、碰撞检测:距离 + 点积双重验证)
-
- [1. 距离检测](#1. 距离检测)
- [2. 点积判断入射方向](#2. 点积判断入射方向)
- 六、游戏结束与超时保护
- 七、完整可运行代码
- 结语
引言
在上一篇《主菜单与最高分存储》中,我们搭建了游戏的基础架构。本篇将聚焦核心玩法实现------一个类似"球跳环"的休闲游戏:玩家点击屏幕,小球沿当前圆环切线方向发射,若成功落入下一个圆环,则继续前进;否则游戏结束。
与传统游戏不同,本项目不依赖任何物理引擎(如 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