Flame:02、画布中创建背景图

农场种菜类型的游戏,背景图尺寸是1800*1624

需要新建2个文件,分别控制画布的背景图和相机视角

相机视角缩放0.5倍,用于看到更多内容,

默认背景图定位到正中间,可以米字形拖拽

game_background.dart 背景图

dart 复制代码
import 'package:flame/cache.dart';
import 'package:flame/components.dart';

/// 游戏背景图组件。
///
/// 只负责一件事:把 assets/images/bg.png 放进游戏世界。
class GameBackground extends SpriteComponent {
  GameBackground({
    required Images images,
  })  : _images = images,
        super(
          anchor: Anchor.topLeft,
          position: Vector2.zero(),
          size: worldSize,
          priority: -100,
        );

  /// Flame 默认图片目录是 assets/images/。
  ///
  /// 所以这里写 bg.png,实际加载路径就是 assets/images/bg.png。
  static const String imagePath = 'bg.png';

  /// 默认相机缩放。
  ///
  /// 背景保持 1800 x 1624 的设计稿坐标,相机用 0.5 倍视角观察,
  /// 这样一屏可以看到更多地图内容,后续摆放碰撞区和角色也仍然按设计稿坐标写。
  static const double defaultCameraZoom = 0.5;

  /// 背景图在游戏世界中的尺寸。
  ///
  /// 这里直接使用原设计稿尺寸,不再把图片尺寸 /2。
  static Vector2 get worldSize => Vector2(1800, 1624);

  final Images _images;

  @override
  Future<void> onLoad() async {
    await super.onLoad();

    final backgroundImage = await _images.load(imagePath);
    sprite = Sprite(backgroundImage);
  }
}

game_camera_controller.dart

dart 复制代码
import 'package:flame/components.dart';

/// 游戏相机控制器。
///
/// 只负责一件事:根据拖动手势移动相机,并限制视角不滑出游戏世界边界。
class GameCameraController {
  GameCameraController({
    required Vector2 worldSize,
    this.preferredZoom = 1,
  }) : worldSize = worldSize.clone();

  /// 游戏世界尺寸,这里对应背景图的设计稿尺寸。
  final Vector2 worldSize;

  /// 期望相机缩放。
  ///
  /// 小于 1 表示看得更远、同屏展示更多地图内容;如果屏幕比例导致会露出地图外区域,
  /// 实际 zoom 会被提高到刚好铺满屏幕。
  final double preferredZoom;

  Vector2 _viewportSize = Vector2.zero();

  /// 初始化相机的基本设置。
  ///
  /// Viewfinder 的 position 表示"当前视角左上角看到的世界坐标",这样更接近地图拖动的直觉。
  void configureCamera(CameraComponent camera, Vector2 viewportSize) {
    camera.viewfinder.anchor = Anchor.topLeft;
    updateViewportSize(camera, viewportSize);
    camera.viewfinder.position = _calculateCenteredPosition(
      viewportSize: _viewportSize,
      worldSize: worldSize,
      zoom: camera.viewfinder.zoom,
    );
  }

  /// 屏幕尺寸变化时重新限制相机位置。
  ///
  /// 例如横竖屏切换后,原来的相机位置可能会露出背景外区域,需要重新 clamp。
  void updateViewportSize(CameraComponent camera, Vector2 viewportSize) {
    _viewportSize = viewportSize.clone();
    final nextZoom = calculateCameraZoom(
      preferredZoom: preferredZoom,
      viewportSize: _viewportSize,
      worldSize: worldSize,
    );
    camera.viewfinder.zoom = nextZoom;
    camera.viewfinder.position = clampCameraPosition(
      requestedPosition: camera.viewfinder.position,
      viewportSize: _viewportSize,
      worldSize: worldSize,
      zoom: nextZoom,
    );
  }

  /// 根据拖动距离移动相机。
  ///
  /// 手指往左拖时,视角应该往右看,所以相机位置要减去 dragDelta。
  /// DragUpdateEvent 同时保留 x/y 两个方向的位移,因此斜向拖动会自然形成"米字形"视角移动。
  void moveByDragDelta(CameraComponent camera, Vector2 dragDelta) {
    camera.viewfinder.position = calculatePositionAfterDrag(
      currentPosition: camera.viewfinder.position,
      dragDelta: dragDelta,
      viewportSize: _viewportSize,
      worldSize: worldSize,
      zoom: camera.viewfinder.zoom,
    );
  }

  /// 根据拖动距离计算下一帧相机位置。
  static Vector2 calculatePositionAfterDrag({
    required Vector2 currentPosition,
    required Vector2 dragDelta,
    required Vector2 viewportSize,
    required Vector2 worldSize,
    double zoom = 1,
  }) {
    final safeZoom = zoom <= 0 ? 1.0 : zoom;
    final requestedPosition = currentPosition - dragDelta / safeZoom;

    return clampCameraPosition(
      requestedPosition: requestedPosition,
      viewportSize: viewportSize,
      worldSize: worldSize,
      zoom: safeZoom,
    );
  }

  /// 计算默认居中展示时的相机位置。
  ///
  /// 因为 viewfinder 使用 topLeft 作为锚点,所以居中不是把 position 放到世界中心,
  /// 而是把"可视区域左上角"放到居中后的起点。
  static Vector2 _calculateCenteredPosition({
    required Vector2 viewportSize,
    required Vector2 worldSize,
    required double zoom,
  }) {
    final safeZoom = zoom <= 0 ? 1.0 : zoom;
    final visibleSize = viewportSize / safeZoom;
    final requestedPosition = Vector2(
      (worldSize.x - visibleSize.x) / 2,
      (worldSize.y - visibleSize.y) / 2,
    );

    return clampCameraPosition(
      requestedPosition: requestedPosition,
      viewportSize: viewportSize,
      worldSize: worldSize,
      zoom: safeZoom,
    );
  }

  /// 计算相机最小铺满缩放。
  ///
  /// 当相机 zoom 过小时,可视区域会超过背景边界;这个值用于保证背景刚好能铺满屏幕。
  static double calculateCoverZoom({
    required Vector2 viewportSize,
    required Vector2 worldSize,
  }) {
    if (viewportSize.x <= 0 ||
        viewportSize.y <= 0 ||
        worldSize.x <= 0 ||
        worldSize.y <= 0) {
      return 1.0;
    }

    final widthZoom = viewportSize.x / worldSize.x;
    final heightZoom = viewportSize.y / worldSize.y;
    final coverZoom = widthZoom > heightZoom ? widthZoom : heightZoom;

    return coverZoom;
  }

  /// 计算实际相机缩放。
  ///
  /// preferredZoom 决定"想看多远",coverZoom 决定"不能露出地图外区域",最终取更大的值。
  static double calculateCameraZoom({
    required double preferredZoom,
    required Vector2 viewportSize,
    required Vector2 worldSize,
  }) {
    final safePreferredZoom = preferredZoom <= 0 ? 1.0 : preferredZoom;
    final coverZoom = calculateCoverZoom(
      viewportSize: viewportSize,
      worldSize: worldSize,
    );

    return safePreferredZoom > coverZoom ? safePreferredZoom : coverZoom;
  }

  /// 把相机位置限制在游戏世界内部。
  ///
  /// 因为 viewfinder.anchor 是 topLeft,所以 position 可以理解成当前屏幕左上角的世界坐标。
  static Vector2 clampCameraPosition({
    required Vector2 requestedPosition,
    required Vector2 viewportSize,
    required Vector2 worldSize,
    double zoom = 1,
  }) {
    final safeZoom = zoom <= 0 ? 1.0 : zoom;
    final visibleSize = viewportSize / safeZoom;

    final maxX = (worldSize.x - visibleSize.x).clamp(0, worldSize.x);
    final maxY = (worldSize.y - visibleSize.y).clamp(0, worldSize.y);

    return Vector2(
      requestedPosition.x.clamp(0, maxX).toDouble(),
      requestedPosition.y.clamp(0, maxY).toDouble(),
    );
  }
}

最后记得在GameHomeCanvasGame中添加游戏相机控制器和背景图

dart 复制代码
import 'package:flame/events.dart';
import 'package:flame/game.dart';
import 'package:flutter/material.dart';

import 'components/game_background.dart';
import 'components/game_camera_controller.dart';

/// 游戏首页的最基础 Flame 画布。
///
/// 当前只负责提供一个可渲染的游戏世界,后续玩家、地图、碰撞等组件都从这里添加。
class GameHomeCanvasGame extends FlameGame with DragCallbacks {
  final GameCameraController gameCameraController = GameCameraController(
    worldSize: GameBackground.worldSize,
    preferredZoom: GameBackground.defaultCameraZoom,
  );

  @override
  Color backgroundColor() => const Color(0xFF101828);

  @override
  Future<void> onLoad() async {
    await super.onLoad();
    // 相机控制器
    gameCameraController.configureCamera(camera, size);

    // 背景要先添加,后续玩家、敌人、按钮等组件再添加时会自然盖在背景上方。
    await world.add(GameBackground(images: images));
  }

  @override
  void onGameResize(Vector2 size) {
    super.onGameResize(size);

    gameCameraController.updateViewportSize(camera, size);
  }

  @override
  void onDragUpdate(DragUpdateEvent event) {
    super.onDragUpdate(event);

    gameCameraController.moveByDragDelta(camera, event.canvasDelta);
  }
}

代码理解:

bash 复制代码
GameBackground它可以理解成"地图组件"。
imagePath
表示 Flame 要加载哪张图。这里写 bg.png,实际对应 assets/images/bg.png。

worldSize
表示这张背景图在游戏世界里的尺寸。现在是:
Vector2(1800, 1624)
也就是按设计稿原尺寸来摆放。后面你放角色、建筑、碰撞区域,都可以按这个坐标系理解。

defaultCameraZoom
默认相机缩放。现在是 0.5,意思是相机看得更远,一屏能看到更多地图。

onLoad()
组件加载时执行。这里做的事很简单:加载 bg.png,然后把它变成 Flame 能渲染的 Sprite。

GameCameraController
它可以理解成"摄像机控制器",不管背景图怎么画,只管镜头位置、缩放、拖动边界。

worldSize
地图总尺寸,也就是 1800 x 1624。相机需要知道地图多大,才能避免拖出边界。

preferredZoom
你希望相机默认缩放多少。0.5 表示缩小视角、看更多内容。

configureCamera()
初始化相机。现在做三件事:
把相机锚点设成左上角。
根据屏幕尺寸计算实际 zoom。
把初始视角定位到地图中心。

updateViewportSize()
屏幕尺寸变化时重新计算相机,比如横竖屏切换、首次布局。它会重新算 zoom,并确保相机没有跑出地图外。

moveByDragDelta()
拖动时调用。它根据手指滑动距离移动相机。因为 dragDelta 同时有 x/y,所以天然支持上下左右和斜向拖动。

calculateCameraZoom()
计算最终相机缩放。逻辑是:
最终 zoom = max(你想要的 zoom, 不露边所需的最小 zoom)
比如你想用 0.5,但某个长屏手机如果 0.5 会露出地图外背景,那它会自动调大一点。

calculateCoverZoom()
计算"刚好不露出地图外区域"的最小 zoom。

_calculateCenteredPosition()
计算初始居中位置。因为相机锚点是左上角,所以不是把相机 position 设成地图中心,而是算:
相机左上角 = (地图尺寸 - 当前可视尺寸) / 2

clampCameraPosition()
限制相机不要拖出地图边界。比如你使劲往左上拖,最小只能到 (0, 0);往右下拖,也只能到地图右下边界。

一句话总结:GameBackground 决定"地图有多大、用哪张图";GameCameraController 决定"玩家现在从哪个位置、用多远的镜头看地图"。