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

个人主页:ujainu

文章目录

引言

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

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

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

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


一、为什么选择 CustomPainter?

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

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

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

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

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

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


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

1. 轨迹数据结构

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

dart 复制代码
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,
  });

  @override
  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 中,每帧将当前位置加入轨迹队列,并限制最大长度:

dart 复制代码
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) 实现平滑过渡:

dart 复制代码
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 方法开头应用相机偏移:

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

📌 关键理解:

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

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

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

dart 复制代码
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(),
));

并在绘制时使用:

dart 复制代码
for (final circle in circles) {
  paint.color = circle.color;
  canvas.drawCircle(circle.center, circle.radius, paint);
}

🎨 设计建议:

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

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

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

问题重现

若这样写:

dart 复制代码
@override
Widget build(BuildContext context) {
  return CustomPaint(
    painter: GamePainter(
      trail: _trail,
      ballPosition: _ballPosition,
      circles: _circles,
      cameraOffset: _cameraOffset,
    ),
  );
}

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

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

✅ 正确方案:复用 Painter 实例

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

  @override
  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 改为可变属性:

dart 复制代码
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});

  @override
  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;

  @override
  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));
  }

  @override
  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 {
  @override
  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;

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

  @override
  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('再玩一次'),
          ),
        ],
      ),
    );
  }

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

  @override
  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

相关推荐
用户29469405448164 小时前
DSH Desktop - Electron on HarmonyOS Edition
harmonyos·deepseek
好的佩奇4 小时前
Dart 之类型系统
flutter·dart
GoWjw5 小时前
RK3288 游戏盒子安卓 5.1 线刷包说明
游戏
IsITGirl5 小时前
HarmonyOS 鸿蒙 WuKong 稳定性测试报错 Failed to connect to AAMS(已解决)
华为·harmonyos
万物智能信息科技7 小时前
鸿蒙RS232驱动开发—【万物智能之开源鸿蒙OpenHarmony系统实战开发系列教程】
驱动开发·华为·开源·harmonyos·鸿蒙
2601_949695598 小时前
3DM游戏运行库合集安装教程,缺少DLL文件这样修复
windows·游戏·电脑
less_121389 小时前
鸿蒙 WPS Open SDK 接入指南:凭据申请、HAR 集成与 registerApp 校验
华为·harmonyos·wps
笨鸟先飞的橘猫10 小时前
skynet——RPC 超时机制优化方案
游戏·rpc·skynet
恋猫de小郭11 小时前
Dart 3.15 新特性:this 终于支持类型提升了
android·前端·flutter
威哥爱编程1 天前
HarmonyOS 7 Agent 开发实战 07:多 Agent 任务分解与反思
agent·harmonyos