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):返回 ab 的插值点,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

相关推荐
2601_949975082 小时前
flutter_for_openharmonyflutter小区门禁管理app实战+报修详情实现
flutter
程序员清洒2 小时前
Flutter for OpenHarmony:Scaffold 与 AppBar — 应用基础结构搭建
flutter·华为·鸿蒙
子春一2 小时前
Flutter for OpenHarmony:构建一个 Flutter 习惯打卡应用,深入解析周视图交互、连续打卡逻辑与状态驱动 UI
flutter·ui·交互
拉轰小郑郑3 小时前
鸿蒙ArkTS中Object类型与类型断言的理解
华为·harmonyos·arkts·openharmony·object·类型断言
2601_949593653 小时前
基础入门 React Native 鸿蒙跨平台开发:Animated 动画按钮组件 鸿蒙实战
react native·react.js·harmonyos
暮志未晚Webgl3 小时前
UE5使用CameraShake相机震动提升游戏体验
数码相机·游戏·ue5
菜鸟小芯3 小时前
【开源鸿蒙跨平台开发先锋训练营】DAY8~DAY13 底部选项卡&推荐功能实现
flutter·harmonyos
星辰徐哥3 小时前
鸿蒙APP开发从入门到精通:页面路由与组件跳转
华为·harmonyos
kirk_wang3 小时前
Flutter艺术探索-Repository模式:数据层抽象与复用
flutter·移动开发·flutter教程·移动开发教程