Flutter + OpenHarmony 游戏开发进阶:虚拟摄像机系统——平滑跟随与坐标偏移

个人主页:ujainu

文章目录

引言

在 2D 游戏中,虚拟摄像机(Virtual Camera) 是连接玩家与游戏世界的"眼睛"。它决定了玩家看到什么、如何移动、以及视觉体验是否流畅。若直接将角色绘制在固定位置,当角色靠近屏幕边缘时,玩家将失去对周围环境的感知;而若让 Canvas 原点始终对齐角色中心,则会导致画面剧烈抖动、缺乏缓冲感。

本文将带你从零构建一个高性能、低延迟、可扩展的虚拟摄像机系统,聚焦三大核心:

  1. canvas.translate(cameraOffset) 实现视口偏移
  2. 目标位置动态计算(基于角色位置)
  3. 使用 Offset.lerp 实现平滑插值跟随

同时,我们将:

  • 避免画面抖动:通过灵敏度参数控制跟随速度;
  • 支持未来扩展:预留缩放(Zoom)与旋转(Rotation)接口;
  • 适配 OpenHarmony 多端设备:自动处理不同屏幕尺寸与 DPI。

💡 适用场景 :平台跳跃、跑酷、RPG 地图探索等需要镜头跟随的游戏

前提:Flutter 与 OpenHarmony 开发环境已配置完成,无需额外说明


一、为什么需要虚拟摄像机?

1. 固定视角的局限性

若直接在 CustomPaint 中绘制角色于 (player.x, player.y),当角色移动到 (1000, 1000) 时,它会超出手机屏幕范围,玩家无法看到。

2. 粗暴居中的问题

若每帧执行:

dart 复制代码
canvas.translate(-player.x + screenWidth/2, -player.y + screenHeight/2);

虽然角色居中,但镜头完全锁死,没有任何缓冲,导致:

  • 快速转向时画面撕裂;
  • 缺乏"镜头惯性",体验生硬;
  • 无法实现"边缘触发式跟随"(如角色靠近边界才移动镜头)。

3. 虚拟摄像机的优势

  • 解耦渲染与逻辑:角色位置不变,仅改变 Canvas 原点;
  • 平滑过渡:通过插值实现缓动效果;
  • 灵活控制:可添加边界限制、震动、缩放等特效;
  • OpenHarmony 友好:与系统合成器协同工作,减少 GPU 提交压力。

二、核心原理:Canvas 坐标偏移

Flutter 的 Canvas 提供 translate(dx, dy) 方法,用于整体平移绘制坐标系。这是实现摄像机偏移的基础。

dart 复制代码
// 将 Canvas 原点从 (0,0) 移动到 (-cameraX, -cameraY)
canvas.translate(-cameraX, -cameraY);

// 此后所有绘制都相对于新原点
canvas.drawCircle(Offset(player.x, player.y), 10, paint); // 角色出现在世界坐标 (player.x, player.y)

📌 关键理解
摄像机位置 = 屏幕中心在世界坐标中的位置
Canvas 偏移量 = -摄像机位置

因此,若摄像机位于 (camX, camY),则 canvas.translate(-camX, -camY)


三、目标位置计算:智能跟随策略

我们不希望镜头每帧都跳到角色正中心,而是采用目标位置(Target Position) 机制:

dart 复制代码
Offset _calculateCameraTarget(Player player, Size screenSize) {
  return Offset(player.x, player.y);
}

但更高级的策略是边缘触发式跟随:仅当角色靠近屏幕边缘时,镜头才移动。

dart 复制代码
Offset _calculateCameraTarget(Player player, Size screenSize) {
  final margin = 100.0; // 触发边距
  double targetX = _cameraPosition.dx;
  double targetY = _cameraPosition.dy;

  if (player.x < _cameraPosition.dx - screenSize.width / 2 + margin) {
    targetX = player.x + screenSize.width / 2 - margin;
  } else if (player.x > _cameraPosition.dx + screenSize.width / 2 - margin) {
    targetX = player.x - screenSize.width / 2 + margin;
  }

  // Y 轴同理...
  return Offset(targetX, targetY);
}

本文为简化,采用直接居中策略,但保留扩展性。


四、平滑跟随:Offset.lerp 插值算法

Offset.lerp(a, b, t) 是线性插值函数,返回从 ab 的中间点,t ∈ [0,1]

  • t = 0 → 返回 a
  • t = 1 → 返回 b
  • t = 0.1 → 缓慢靠近 b

我们用它实现平滑跟随

dart 复制代码
final target = _calculateCameraTarget(player, size);
_cameraPosition = Offset.lerp(_cameraPosition, target, _followSpeed)!;

控制跟随灵敏度

_followSpeed 决定镜头响应速度:

  • 0.1:缓慢、有滞后感(适合探索类游戏);
  • 0.3:中等响应(通用);
  • 0.8:快速跟随(适合高速动作游戏)。

最佳实践 :将 _followSpeed 设为可配置参数,便于调试。


五、扩展性设计:预留缩放与旋转接口

虽然本文聚焦平移,但良好的架构应支持未来扩展。我们定义 Camera 类:

dart 复制代码
class VirtualCamera {
  Offset position = Offset.zero;
  double zoom = 1.0;
  double rotation = 0.0;

  void applyToCanvas(Canvas canvas, {bool enableZoom = false, bool enableRotation = false}) {
    // 先平移
    canvas.translate(-position.dx, -position.dy);
    
    // 预留:缩放(需围绕中心)
    if (enableZoom && zoom != 1.0) {
      final pivot = Offset.zero; // 实际应为屏幕中心
      canvas.translate(pivot.dx, pivot.dy);
      canvas.scale(zoom);
      canvas.translate(-pivot.dx, -pivot.dy);
    }

    // 预留:旋转
    if (enableRotation && rotation != 0.0) {
      canvas.rotate(rotation);
    }
  }
}

这样,未来只需开启 enableZoom 即可支持缩放。


六、性能优化:避免无效重绘

  • 摄像机更新在主循环中完成 ,不依赖 setState
  • CustomPainter 仅接收最终偏移量,不持有业务逻辑;
  • shouldRepaint 比较摄像机位置变化,而非整个对象。
dart 复制代码
@override
bool shouldRepaint(covariant GamePainter old) {
  return (old.cameraPosition - cameraPosition).distance > 0.1;
}

七、完整可运行代码:平滑相机跟随系统

以下是一个完整、可独立运行的 Flutter 示例,展示如何实现带平滑跟随、灵敏度控制、扩展预留的虚拟摄像机系统,完全适配 OpenHarmony 渲染模型。

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

void main() => runApp(const GameApp());

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter + OpenHarmony: 虚拟摄像机 + 参考小球',
      debugShowCheckedModeBanner: false,
      home: CameraWithReferenceBallScreen(),
    );
  }
}

// ===== 玩家:沿圆周运动 =====
class Player {
  double t = 0.0; // 时间参数(弧度)

  // 世界坐标:绕 (1000, 1000) 做半径 400 的圆周运动
  double get x => 1000 + 400 * cos(t);
  double get y => 1000 + 400 * sin(t);

  void update(double dt) {
    t += 2.0 * dt; // 角速度:2 rad/s(约 3 秒一圈)
  }
}

// ===== 虚拟摄像机 =====
class VirtualCamera {
  Offset position = Offset.zero;
  final double followSpeed;

  VirtualCamera({this.followSpeed = 0.08}); // 故意设慢,制造滞后

  void update(Offset target) {
    position = Offset.lerp(position, target, followSpeed)!;
  }

  void applyToCanvas(Canvas canvas) {
    // 将 Canvas 原点从屏幕左上角 → 世界坐标 (position.dx, position.dy)
    canvas.translate(-position.dx, -position.dy);
  }
}

// ===== 主界面 =====
class CameraWithReferenceBallScreen extends StatefulWidget {
  @override
  _CameraWithReferenceBallScreenState createState() =>
      _CameraWithReferenceBallScreenState();
}

class _CameraWithReferenceBallScreenState
    extends State<CameraWithReferenceBallScreen>
    with SingleTickerProviderStateMixin {

  late AnimationController _controller;
  late Player _player;
  late VirtualCamera _camera;

  @override
  void initState() {
    super.initState();
    _player = Player();
    _camera = VirtualCamera(followSpeed: 0.08);

    _controller = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 16), // ~60fps
    )..repeat()
      ..addListener(_gameLoop);
  }

  void _gameLoop() {
    _player.update(1 / 60);
    _camera.update(Offset(_player.x, _player.y));
  }

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: const Color(0xFF0D1B2A), // 深蓝灰背景
      body: AnimatedBuilder(
        animation: _controller,
        builder: (context, child) {
          return CustomPaint(
            painter: WorldAndHudPainter(
              player: _player,
              camera: _camera,
            ),
            size: Size.infinite,
          );
        },
      ),
    );
  }
}

// ===== 绘制器:分层绘制(HUD + 世界)=====
class WorldAndHudPainter extends CustomPainter {
  final Player player;
  final VirtualCamera camera;

  WorldAndHudPainter({
    required this.player,
    required this.camera,
  });

  @override
  void paint(Canvas canvas, Size screenSize) {
    // ────────────────────────────────
    // 🔴 第一层:屏幕固定 HUD(不受摄像机影响)
    // ────────────────────────────────
    final hudPaint = Paint()
      ..color = Colors.red.withOpacity(0.7)
      ..style = PaintingStyle.fill;

    // 在屏幕正中央绘制一个固定参考小球
    canvas.drawCircle(
      Offset(screenSize.width / 2, screenSize.height / 2),
      12,
      hudPaint,
    );

    // 可选:添加文字提示
    final textPainter = TextPainter(
      text: const TextSpan(
        text: '屏幕中心参考点',
        style: TextStyle(color: Colors.white, fontSize: 14),
      ),
      textDirection: TextDirection.ltr,
    );
    textPainter.layout();
    textPainter.paint(
      canvas,
      Offset(
        screenSize.width / 2 - textPainter.width / 2,
        screenSize.height / 2 + 20,
      ),
    );

    // ────────────────────────────────
    // 🌍 第二层:应用摄像机,绘制世界内容
    // ────────────────────────────────
    camera.applyToCanvas(canvas);

    // 1. 绘制运动轨迹(虚线圆)
    final pathPaint = Paint()
      ..color = Colors.white.withOpacity(0.25)
      ..style = PaintingStyle.stroke
      ..strokeWidth = 2;
    canvas.drawCircle(const Offset(1000, 1000), 400, pathPaint);

    // 2. 绘制世界边界
    final borderPaint = Paint()
      ..color = Colors.orange
      ..style = PaintingStyle.stroke
      ..strokeWidth = 2;
    canvas.drawRect(Rect.fromLTWH(0, 0, 2000, 2000), borderPaint);

    // 3. 绘制路径上的固定路标(强参照物)
    final markers = [
      [1400, 1000, Colors.red],
      [1000, 1400, Colors.green],
      [600, 1000, Colors.blue],
      [1000, 600, Colors.yellow],
    ];
    for (final m in markers) {
      final markerPaint = Paint()..color = m[2] as Color;
      canvas.drawCircle(
        Offset(m[0] as double, m[1] as double),
        20,
        markerPaint,
      );
    }

    // 4. 绘制动态小球(在世界中真实运动)
    final playerPaint = Paint()..color = Colors.cyanAccent;
    canvas.drawCircle(
      Offset(player.x, player.y),
      15,
      playerPaint,
    );

    // 5. 绘制世界中心点
    canvas.drawCircle(
      const Offset(1000, 1000),
      6,
      Paint()..color = Colors.purple,
    );
  }

  @override
  bool shouldRepaint(covariant WorldAndHudPainter oldDelegate) {
    // 只要摄像机或玩家位置变化就重绘
    return true;
  }
}

运行界面(实际是动图)

✅ 代码亮点说明:

特性 实现方式
平滑跟随 Offset.lerp(camera.position, target, speed)
灵敏度控制 followSpeed = 0.25 可调参数
Canvas 偏移 canvas.translate(-camX, -camY)
大世界支持 WORLD_WIDTH = 2000,角色可自由探索
扩展预留 VirtualCamera.applyToCanvas() 预留缩放/旋转
性能优化 AnimatedBuilder 驱动,无 setState
视觉反馈 绘制网格与边界,直观展示摄像机效果

结语

虚拟摄像机是 2D 游戏体验的"隐形导演"。通过 canvas.translate + Offset.lerp,我们实现了既平滑又可控的镜头系统。在 OpenHarmony 设备上,这种方案能充分利用其高效的图形合成能力,确保在手机、平板、智慧屏上均提供一致的流畅体验。

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

相关推荐
进击的小头2 小时前
设计模式落地的避坑指南(C语言版)
c语言·开发语言·设计模式
凤年徐2 小时前
容器适配器深度解析:从STL的stack、queue到优先队列的底层实现
开发语言·c++·算法
金书世界2 小时前
使用PHP+html+MySQL实现用户的注册和登录(源码)
开发语言·mysql·php
一起养小猫2 小时前
Flutter for OpenHarmony 实战:科学计算器完整开发指南
android·前端·flutter·游戏·harmonyos
Dxy12393102162 小时前
MySQL如何避免隐式转换
开发语言·mysql
历程里程碑2 小时前
Linux 18 进程控制
linux·运维·服务器·开发语言·数据结构·c++·笔记
一起养小猫2 小时前
Flutter for OpenHarmony 实战:独木桥问题完整开发指南
flutter·harmonyos
froginwe112 小时前
C# 预处理器指令
开发语言
爱装代码的小瓶子2 小时前
【c++与Linux基础】文件篇(5)- 文件管理系统:
linux·开发语言·c++