欢迎光临
我们一直在努力

Flutter + OpenHarmony 实战:从零开发小游戏(三)——CustomPainter 实现拖尾与相机跟随

在这里插入图片描述 个人主页:ujainu

文章目录

    • 引言
    • 一、为什么选择 CustomPainter?
    • 二、轨迹系统:动态拖尾的实现
      • 1. 轨迹数据结构
      • 2. 轨迹更新策略
    • 三、相机跟随:平滑追踪小球
      • 1. 相机偏移计算
      • 2. Canvas 坐标系变换
    • 四、动态圆环颜色:增强视觉反馈
    • 五、性能优化:避免 CustomPainter 频繁 rebuild
      • 问题重现
      • ✅ 正确方案:复用 Painter 实例
    • 结语

引言

在前两篇中,我们完成了游戏的基础架构与核心物理逻辑。但一个优秀的游戏,不仅要有严谨的玩法,更需要沉浸式的视觉体验。本篇将聚焦于 Flutter 的 CustomPainter 高级用法,通过手绘方式实现三大关键视觉特效:

  • ✅ 动态拖尾(Trail):小球飞行时留下渐隐轨迹;
  • ✅ 相机跟随(Camera Follow):画面平滑追踪小球移动;
  • ✅ 随机圆环配色:增强关卡视觉丰富度。

更重要的是,我们将探讨 如何避免 CustomPainter 频繁重建导致的性能问题,确保在 OpenHarmony 等资源受限设备上依然保持 60fps 流畅运行。

💡 技术栈:纯 Dart + Flutter SDK,不依赖任何第三方图形库,轻量高效,适合嵌入式与分布式场景。


一、为什么选择 CustomPainter?

许多开发者误以为 Flutter 只适合 UI 应用,不适合游戏。实际上,CustomPainter + AnimationController 构成了一个极其高效的 2D 渲染管线:

  • 直接操作 Canvas:绕过 Widget 树,减少内存分配;
  • GPU 加速绘制:Skia 引擎底层优化;
  • 完全控制帧率:与 TickerProvider 同步,避免掉帧。

但若使用不当,CustomPainter 也会成为性能瓶颈。例如:

// ❌ 错误示范:每次 setState 都重建 painter
CustomPaint(painter: GamePainter(position: _ballPosition))

这会导致 painter 对象频繁创建,触发不必要的 shouldRepaint 计算。

✅ 正确姿势:复用同一个 CustomPainter 实例,仅更新其内部数据引用。


二、轨迹系统:动态拖尾的实现

1. 轨迹数据结构

我们用一个固定长度的 List<Offset> 存储小球历史位置,并配合透明度衰减:

class GamePainter extends CustomPainter {
final List<Offset> trail; // 轨迹点
final Offset ballPosition;
final List<Circle> circles;
final Offset cameraOffset;

GamePainter({
required this.trail,
required this.ballPosition,
required this.circles,
required this.cameraOffset,
});


void paint(Canvas canvas, Size size) {
// 先应用相机偏移
canvas.save();
canvas.translate(cameraOffset.dx, cameraOffset.dy);

// 绘制轨迹(从旧到新)
for (int i = 0; i < trail.length; i++) {
final alpha = (255 * (i / trail.length)).toInt(); // 越新越亮
final color = Color.fromARGB(alpha, 255, 255, 0); // 黄色渐隐
canvas.drawCircle(trail[i], 6, Paint()..color = color);
}

// 绘制小球(最亮)
canvas.drawCircle(ballPosition, 10, Paint()..color = Colors.yellow);

canvas.restore(); // 恢复坐标系
}
}

2. 轨迹更新策略

在 _gameLoop 中,每帧将当前位置加入轨迹队列,并限制最大长度:

List<Offset> _trail = [];

void _gameLoop() {
if (_isPlaying) {
// … 更新 _ballPosition

// 添加轨迹点
_trail.add(_ballPosition);
if (_trail.length > 20) _trail.removeAt(0); // 保留最近 20 帧
}

// 更新相机
_updateCamera();

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

🎨 视觉技巧:

  • 使用 Color.fromARGB(alpha, r, g, b) 动态控制透明度;
  • 轨迹半径略小于小球,形成“光晕”效果;
  • 队列 FIFO 管理,内存恒定。

三、相机跟随:平滑追踪小球

1. 相机偏移计算

理想情况下,相机应始终将小球置于屏幕中央。但若直接赋值,会显得生硬。我们使用 线性插值(Lerp) 实现平滑过渡:

Offset _cameraOffset = Offset.zero;

void _updateCamera() {
final target = _ballPosition MediaQuery.sizeOf(context) / 2;
_cameraOffset = Offset.lerp(_cameraOffset, target, 0.1)!; // 10% 追踪速度
}

  • Offset.lerp(a, b, t):返回 a 到 b 的插值点,t ∈ [0,1];
  • t = 0.1 表示每帧向目标靠近 10%,形成缓动效果;
  • ! 因为 lerp 可能返回 null(但此处不会)。

2. Canvas 坐标系变换

在 paint 方法开头应用相机偏移:

canvas.save();
canvas.translate(_cameraOffset.dx, _cameraOffset.dy);
// 所有绘制都在世界坐标系下进行
// …
canvas.restore();

📌 关键理解:

  • translate(-dx, -dy) 相当于“移动画布”,使物体看起来向 (dx, dy) 移动;
  • 必须用 save()/restore() 隔离变换,避免影响 UI 元素(如分数文本)。

四、动态圆环颜色:增强视觉反馈

静态白色圆环容易审美疲劳。我们为每个新生成的圆环赋予随机但协调的颜色:

Color _randomColor() {
final random = math.Random();
return Color.fromARGB(
255,
100 + random.nextInt(155),
100 + random.nextInt(155),
200 + random.nextInt(55), // 偏蓝紫,保持美感
);
}

// 在 Circle 类中增加 color 字段
class Circle {
final Offset center;
final double radius;
final Color color;
Circle({required this.center, required this.radius, required this.color});
}

// 生成时
_circles.add(Circle(
center: newCenter,
radius: 80,
color: _randomColor(),
));

并在绘制时使用:

for (final circle in circles) {
paint.color = circle.color;
canvas.drawCircle(circle.center, circle.radius, paint);
}

🎨 设计建议:

  • 避免使用纯红/绿(可能与 UI 冲突);
  • 限制饱和度,防止刺眼;
  • 当前圆环仍用红色高亮,确保玩法清晰。

五、性能优化:避免 CustomPainter 频繁 rebuild

这是本篇的核心工程实践。

问题重现

若这样写:


Widget build(BuildContext context) {
return CustomPaint(
painter: GamePainter(
trail: _trail,
ballPosition: _ballPosition,
circles: _circles,
cameraOffset: _cameraOffset,
),
);
}

每次 setState 都会创建新的 GamePainter 对象,即使数据未变。这会导致:

  • shouldRepaint 被频繁调用;
  • 对象分配增加 GC 压力;
  • 在低端设备上帧率下降。

✅ 正确方案:复用 Painter 实例

  • 将 GamePainter 作为 State 成员变量;
  • 仅更新其内部数据引用;
  • 重写 shouldRepaint 进行精准比较。
  • class _GameScreenState extends State<GameScreen> with TickerProviderStateMixin {
    late final GamePainter _painter = GamePainter(); // 单例


    Widget build(BuildContext context) {
    // 更新 painter 数据(非重建对象)
    _painter
    ..trail = _trail
    ..ballPosition = _ballPosition
    ..circles = _circles
    ..cameraOffset = _cameraOffset;

    return GestureDetector(
    onTapDown: _launchBall,
    child: Scaffold(
    backgroundColor: Colors.black,
    body: CustomPaint(painter: _painter),
    ),
    );
    }
    }

    同时,GamePainter 改为可变属性:

    import 'dart:math' as math;
    import 'dart:async';
    import 'package:flutter/material.dart';
    import 'package:flutter/services.dart';

    void main() {
    WidgetsFlutterBinding.ensureInitialized();
    SystemChrome.setPreferredOrientations([
    DeviceOrientation.landscapeLeft,
    DeviceOrientation.landscapeRight,
    ]);
    SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
    runApp(const MyApp());
    }

    class MyApp extends StatelessWidget {
    const MyApp({super.key});


    Widget build(BuildContext context) {
    return MaterialApp(
    title: '轨道跳跃 Pro',
    debugShowCheckedModeBanner: false,
    theme: ThemeData.dark(),
    home: GameScreen(),
    );
    }
    }

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

    class GamePainter extends CustomPainter {
    List<Offset> trail = [];
    Offset ballPosition = Offset.zero;
    List<Circle> circles = [];
    Offset cameraOffset = Offset.zero;
    int score = 0;


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

    // 应用相机偏移
    canvas.save();
    canvas.translate(cameraOffset.dx, cameraOffset.dy);

    // 绘制轨迹
    for (int i = 0; i < trail.length; i++) {
    final alpha = (255 * (i / (trail.length + 1))).toInt();
    final color = Color.fromARGB(alpha, 255, 255, 0);
    canvas.drawCircle(trail[i], 6, Paint()..color = color);
    }

    // 绘制小球
    canvas.drawCircle(ballPosition, 10, Paint()..color = Colors.yellow);

    // 绘制圆环
    for (final circle in circles) {
    paint.color = circle.color;
    canvas.drawCircle(circle.center, circle.radius, paint);
    }

    canvas.restore();

    // 绘制分数
    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));
    }


    bool shouldRepaint(covariant GamePainter oldDelegate) {
    return trail != oldDelegate.trail ||
    ballPosition != oldDelegate.ballPosition ||
    circles != oldDelegate.circles ||
    cameraOffset != oldDelegate.cameraOffset ||
    score != oldDelegate.score;
    }
    }

    class GameScreen extends StatefulWidget {

    State<GameScreen> createState() => _GameScreenState();
    }

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


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


    void didChangeDependencies() {
    super.didChangeDependencies();
    _initGame();
    }

    void _initGame() {
    final size = MediaQuery.sizeOf(context);
    final baseRadius = 80.0;
    final spacing = 150.0;
    final centerY = size.height * 0.7;

    _circles = List.generate(4, (i) {
    final x = size.width / 2 + (i 1) * spacing;
    return Circle(
    center: Offset(x, centerY),
    radius: baseRadius,
    color: _randomColor(),
    );
    });
    _ballPosition = _circles.first.center;
    _ballVelocity = Offset.zero;
    _cameraOffset = Offset.zero;
    _trail.clear();
    _isPlaying = false;
    _score = 0;
    }

    Color _randomColor() {
    final r = math.Random();
    return Color.fromARGB(255, 100 + r.nextInt(155), 100 + r.nextInt(155), 200 + r.nextInt(55));
    }

    void _addNextCircle(Size size) {
    final last = _circles.last;
    final newCenter = Offset(last.center.dx + 150, size.height * 0.7);
    _circles.add(Circle(center: newCenter, radius: 80, color: _randomColor()));
    }

    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 _updateCamera(Size size) {
    final target = _ballPosition Offset(size.width / 2, size.height / 2);
    _cameraOffset = Offset.lerp(_cameraOffset, target, 0.1)!;

    final maxOffsetX = size.width * 0.5;
    final maxOffsetY = size.height * 0.5;
    _cameraOffset = Offset(
    _cameraOffset.dx.clamp(maxOffsetX, maxOffsetX),
    _cameraOffset.dy.clamp(maxOffsetY, maxOffsetY),
    );
    }

    void _gameLoop() {
    final size = MediaQuery.sizeOf(context);

    if (_isPlaying) {
    final dt = 0.016;
    _ballPosition += _ballVelocity * dt;

    _trail.add(_ballPosition);
    if (_trail.length > 20) _trail.removeAt(0);

    if (_ballPosition.dx < 100 || _ballPosition.dx > size.width + 100 ||
    _ballPosition.dy < 100 || _ballPosition.dy > size.height + 100) {
    _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) {
    _circles.removeAt(0);
    _ballPosition = _circles.first.center;
    _ballVelocity = Offset.zero;
    _isPlaying = false;
    _score++;
    _addNextCircle(size);
    _startTimeout();
    }
    }
    }
    }

    _updateCamera(size);

    _painter
    ..trail = _trail
    ..ballPosition = _ballPosition
    ..circles = _circles
    ..cameraOffset = _cameraOffset
    ..score = _score;

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

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

    void _endGame() {
    _controller.stop();
    _timeoutTimer?.cancel();
    if (!mounted) return;

    showDialog(
    context: context,
    barrierDismissible: false,
    builder: (ctx) => AlertDialog(
    title: const Text('游戏结束'),
    content: Text('得分: $_score'),
    actions: [
    TextButton(
    onPressed: () {
    Navigator.of(ctx).pop();
    _initGame();
    },
    child: const Text('再玩一次'),
    ),
    ],
    ),
    );
    }


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


    Widget build(BuildContext context) {
    return GestureDetector(
    onTapDown: _launchBall,
    child: Scaffold(
    backgroundColor: Colors.black,
    body: LayoutBuilder(
    builder: (context, constraints) {
    final size = Size(constraints.maxWidth, constraints.maxHeight);
    return CustomPaint(
    painter: _painter,
    size: size,
    );
    },
    ),
    ),
    );
    }
    }

    运行界面: 在这里插入图片描述

    结语

    本文通过 CustomPainter 实现了三大核心视觉特效,并重点解决了性能优化这一工程痛点。这些技术不仅适用于小游戏,也可用于数据可视化、动画引导等场景。下一期,我们将集成 OpenHarmony 分布式能力,实现多设备协同游戏!

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

    赞(0)
    未经允许不得转载:171主机测评 » Flutter + OpenHarmony 实战:从零开发小游戏(三)——CustomPainter 实现拖尾与相机跟随
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址