Flutter Flame实战 - 复刻经典游戏”割绳子“

Flame是一款基于Flutter的2D游戏引擎,今天我将使用它制作一款经典小游戏割绳子

基本物品准备

添加游戏背景

游戏的背景图片资源包含多个图片,这里通过Sprite的截取功能裁出来我们需要的部分,然后添加到游戏中作为背景

dart 复制代码
final bgSprite = await Sprite.load("bgr_01_hd.jpeg",
    images: images,
    srcSize: Vector2(770, 1036),
    srcPosition: Vector2(0, 0));
final bgComponent = SpriteComponent(sprite: bgSprite)
  ..anchor = Anchor.center
  ..position = Vector2(size.x * 0.5, size.y * 0.5);
add(bgComponent);

小怪兽

小怪兽的Sprite Sheet图如下,想要使用它构建动画,需要知道每一个Sprite的大小以及偏移的位置,这些数据需要一些手段获取,我硬编码在了项目中。 新建CTRPlayer类表示小怪兽,他继承自PositionComponent

dart 复制代码
class CTRPlayer extends PositionComponent {
    ...
}

通过Sprite大小和偏移数据构建小怪兽的Sprite列表,使用ImageComposition整合出符合统一规范的Sprite

dart 复制代码
Future<List<Sprite>> _genSpriteSlices() async {
    List<Sprite> sprites = [];
    final List<Map<String, double>> rects = [
      ...
    ];
    final List<Offset> offsets = spriteOffsets();
    for (int i = 0; i < rects.length; ++i) {
      final rect = rects[i];
      final pos = Vector2(rect["x"]!, rect["y"]!);
      final size = Vector2(rect["M"]!, rect["U"]!);
      final sprite = await Sprite.load("char_animations.png",
          srcPosition: pos, srcSize: size, images: images);
      final offset = offsets[i];
      final composition = ImageComposition()
        ..add(await sprite.toImage(), Vector2(offset.dx, offset.dy));
      final composeImage = await composition.compose();
      sprites.add(Sprite(composeImage));
    }
    return sprites;
    }
    
    List<Offset> spriteOffsets() {
    final List<Map<String, double>> raw = [
        ...
    ];
    List<Offset> offsets = [];
    for (final pos in raw) {
      offsets.add(Offset(pos["x"]! - 76, pos["y"]! - 83));
    }
    return offsets;
}

通过SpriteAnimationGroupComponent管理小怪兽的多个动画,SpriteAnimationGroupComponent可以将多组帧动画合并,并通过将current设置为对应状态值快速切换动画

dart 复制代码
final animMap = {
  CTRPlayerAnimState.reset: SpriteAnimation.spriteList(
      _sprites.sublist(0, 1),
      stepTime: 0.06,
      loop: true),
  CTRPlayerAnimState.eat: SpriteAnimation.spriteList(
      _sprites.sublist(27, 40),
      stepTime: 0.06,
      loop: false),
  CTRPlayerAnimState.idle1: SpriteAnimation.spriteList(
      _sprites.sublist(64, 83),
      stepTime: 0.06,
      loop: false),
  CTRPlayerAnimState.idle2: SpriteAnimation.spriteList(
      _sprites.sublist(53, 61),
      stepTime: 0.06,
      loop: false),
};
_animationComponent = SpriteAnimationGroupComponent(
    animations: animMap, current: CTRPlayerAnimState.reset);

接着我们让小怪兽在没吃到糖果前随机进行idle1idle2两种动画,通过_lastIdleAnimElapsedTime控制每8s尝试播放一次idle1或者idle2动画

dart 复制代码
@override
update(double dt) {
  super.update(dt);
  _lastIdleAnimElapsedTime += dt;
  if (_lastIdleAnimElapsedTime > 8 &&
      _animationComponent.current == CTRPlayerAnimState.reset &&
      _animationComponent.current != CTRPlayerAnimState.eat) {
    _lastIdleAnimElapsedTime = 0;
    final states = [CTRPlayerAnimState.idle1, CTRPlayerAnimState.idle2];
    final state = states[Random().nextInt(states.length)];
    _animationComponent.current = state;
  }
}

最后给小怪兽增加一个 duan~ duan~ duan~ 的效果,通过ScaleEffect反复缩放实现

dart 复制代码
final charEffect = ScaleEffect.to(
    Vector2(1.1, 0.9),
    EffectController(
        duration: 0.6,
        reverseDuration: 0.3,
        infinite: true,
        curve: Curves.easeOutCubic));
_animationComponent.add(charEffect);

糖果

新建CTRCandy类表示糖果,糖果只是简单的合并了前景图和背景图,原始素材上有闪光帧动画,想要添加的话,再增加一层SpriteAnimationComponent展示即可

dart 复制代码
final candyBg = await Sprite.load("obj_candy_01.png",
    images: images, srcPosition: Vector2(2, 2), srcSize: Vector2(87, 90));
_candyBgComponent = SpriteComponent(sprite: candyBg)
  ..anchor = Anchor.center
  ..position = Vector2(6, 13);
add(_candyBgComponent);

final candyFg = await Sprite.load("obj_candy_01.png",
    images: images, srcPosition: Vector2(2, 95), srcSize: Vector2(60, 60));
_candyFgComponent = SpriteComponent(sprite: candyFg)
  ..anchor = Anchor.center
  ..position = Vector2(0, 0);
add(_candyFgComponent);

固定点

固定点和糖果类似,也只是简单的合并了前景图和背景图,新建了CTRHook类表示

dart 复制代码
final hookBg = await Sprite.load("obj_hook_01.png",
    images: images, srcPosition: Vector2(2, 2), srcSize: Vector2(50, 50));
final hookBgComponent = SpriteComponent(sprite: hookBg)
  ..anchor = Anchor.topLeft
  ..position = Vector2(0, 0);
add(hookBgComponent);

final hookFg = await Sprite.load("obj_hook_01.png",
    images: images, srcPosition: Vector2(2, 55), srcSize: Vector2(15, 14));
final hookFgComponent = SpriteComponent(sprite: hookFg)
  ..anchor = Anchor.center
  ..position = Vector2(25, 25);
add(hookFgComponent);

星星

星星包含两组动画,旋转和消失,新建CTRStar类表示并通过SpriteAnimationGroupComponent管理动画

dart 复制代码
final idleSprites = await _idleSprites();
final disappearSprites = await _disappearSprites();

final animMap = {
  CTRStarState.idle: SpriteAnimation.spriteList(idleSprites.sublist(1, 18),
      stepTime: 0.05, loop: true),
  CTRStarState.disappear: SpriteAnimation.spriteList(
      disappearSprites.sublist(0, 12),
      stepTime: 0.05,
      loop: false),
};
_animationComponent = SpriteAnimationGroupComponent(
    animations: animMap, current: CTRStarState.idle);
_animationComponent.position = Vector2(0, 0);
_animationComponent.anchor = Anchor.topLeft;
add(_animationComponent);

绳子模拟

创建CTRRope类表示绳子

物理模拟

绳子的模拟采用了多个BodyComponent使用RopeJoint链接的方式实现,首先创建CTRRopeSegment表示绳子的一段,它继承自BodyComponent,主要支持物理模拟,不参与渲染

dart 复制代码
class CTRRopeSegment extends BodyComponent {
  final Offset pos;
  late Vector2 _size;
  bool isBreak = false;

  CTRRopeSegment({this.pos = const Offset(0, 0)}) {
    _size = Vector2(10, 10);
    renderBody = false;
  }

  @override
  Body createBody() {
    final bodyDef = BodyDef(
        type: BodyType.dynamic,
        userData: this,
        position: Vector2(pos.dx, pos.dy));
    return world.createBody(bodyDef)
      ..createFixtureFromShape(CircleShape()..radius = _size.x * 0.5,
          density: 1, friction: 0, restitution: 0);
  }
}

接着还需要创建另一个类CTRRopePin,它和CTRRopeSegment类似,但是他不能动,用与将绳子的一头固定

dart 复制代码
class CTRRopePin extends BodyComponent {
  double size;
  Offset pos;

  CTRRopePin({this.size = 20, this.pos = const Offset(0, 20)}) {
    renderBody = false;
  }

  @override
  Body createBody() {
    final bodyDef = BodyDef(
        type: BodyType.static,
        userData: this,
        position: Vector2(this.pos.dx, this.pos.dy));
    return world.createBody(bodyDef)
      ..createFixtureFromShape(CircleShape()..radius = size * 0.5,
          friction: 0.2, restitution: 0.5);
  }
}

万事俱备,将它们凑成一根绳子

dart 复制代码
final pin = CTRRopePin(pos: Offset(startPosition.dx, startPosition.dy));
add(pin);
await Future.wait([pin.loaded]);
const ropeSegLen = 8.0;
final ropeSegCount = length ~/ ropeSegLen;
final deltaOffset = (endPosition - startPosition);
final space = deltaOffset.distance / ropeSegCount;
final dirVec = deltaOffset / deltaOffset.distance;
CTRRopeSegment? lastRopeSeg;
for (int i = 0; i < ropeSegCount; ++i) {
  final seg =
      CTRRopeSegment(pos: dirVec * i.toDouble() * space + startPosition);
  add(seg);
  await Future.wait([seg.loaded]);
  final jointDef = RopeJointDef()
    ..bodyA = lastRopeSeg?.body ?? pin.body
    ..bodyB = seg.body
    ..maxLength = ropeSegLen;
  game.world.createJoint(RopeJoint(jointDef));
  lastRopeSeg = seg;
  _ropSegs.add(seg);
}

首先创建CTRRopePin作为绳子的开端,然后通过各种参数计算出需要多少个CTRRopeSegment,最后通过RopeJoint逐个相连。打开CTRRopeSegmentrenderBody,可以大致看出绳子的模拟效果

绳子渲染

游戏中绳子是两种颜色相间的,我们可以在CTRRopevoid render(Canvas canvas)中进行自定义绘制,首先准备好要绘制的点和两种颜色的Paint

dart 复制代码
List<Offset> points = [];
points.add(startPosition);
final paint1 = Paint()
  ..color = const Color(0xff815c3c)
  ..style = PaintingStyle.stroke
  ..strokeWidth = 5
  ..strokeJoin = StrokeJoin.round
  ..strokeCap = StrokeCap.round;
final paint2 = Paint()
  ..color = const Color.fromARGB(255, 65, 44, 27)
  ..style = PaintingStyle.stroke
  ..strokeWidth = 5
  ..strokeJoin = StrokeJoin.round
  ..strokeCap = StrokeCap.round;
for (int i = 0; i < _ropSegs.length; i++) {
  final currenPt = _ropSegs[i].position;
  points.add(Offset(currenPt.x, currenPt.y));
}

接着每隔4段更换一次颜色,并通过drawLine绘制绳子

dart 复制代码
final newPoints = points;
bool togglePaint = false;
for (int i = 0; i < newPoints.length - 1; i++) {
  if (i % 4 == 0) {
    togglePaint = !togglePaint;
  }
  final paint = togglePaint ? paint1 : paint2;
  canvas.drawLine(Offset(newPoints[i].dx, newPoints[i].dy),
      Offset(newPoints[i + 1].dx, newPoints[i + 1].dy), paint);
}

如何切断绳子

切断绳子需要解决2个问题,一个是如何判断哪一段被切到,还有就是切完之后渲染不正确的问题。

如何判断哪一段被切到

我采用了一个简单的方案,创建一个继承自BodyComponent的类CTRScissors,手指移动时控制它的位置,如果它和CTRRopeSegment发生的碰撞,则从被碰撞的CTRRopeSegment处切断绳子

dart 复制代码
class CTRScissors extends BodyComponent with ContactCallbacks {
  bool valid = true;

  CTRScissors() {
    renderBody = false;
  }

  updatePosition(Vector2 newPos) {
    body.setTransform(newPos, 0);
  }

  @override
  Body createBody() {
    const bodySize = 20.0;
    final bodyDef = BodyDef(
        type: BodyType.dynamic,
        gravityOverride: Vector2(0, 0),
        userData: this,
        bullet: true,
        position: Vector2(0, 0));
    return world.createBody(bodyDef)
      ..createFixtureFromShape(CircleShape()..radius = bodySize * 0.5,
          density: 1, friction: 0.2, restitution: 0.5);
  }

  @override
  void beginContact(Object other, Contact contact) {
    super.beginContact(other, contact);
    if (other is CTRRopeSegment) {
      if (valid && !other.isBreak) {
        other.removeFromParent();
        other.isBreak = true;
      }
    }
  }
}

other.removeFromParent();会直接让绳子变成2段,other.isBreak = true;则是用于防止多次移除和解决切断后渲染问题

切完之后如何渲染

只需要做一些小改动,首先如果CTRRopeSegmentisBreaktrue,添加一个-1,-1点到points

dart 复制代码
for (int i = 0; i < _ropSegs.length; i++) {
  final currenPt = _ropSegs[i].position;
  if (_ropSegs[i].isBreak) {
    points.add(const Offset(-1, -1));
  } else {
    points.add(Offset(currenPt.x, currenPt.y));
  }
}

接着绘制时发现当前点或者接下来一个点坐标都小于0,直接不绘制

dart 复制代码
for (int i = 0; i < newPoints.length - 1; i++) {
  if (i % 4 == 0) {
    togglePaint = !togglePaint;
  }
  final paint = togglePaint ? paint1 : paint2;
  if ((newPoints[i + 1].dx < 0 && newPoints[i + 1].dy < 0) ||
      (newPoints[i].dx < 0 && newPoints[i].dy < 0)) {
    continue;
  }
  canvas.drawLine(Offset(newPoints[i].dx, newPoints[i].dy),
      Offset(newPoints[i + 1].dx, newPoints[i + 1].dy), paint);
}

将糖果挂到绳子上

糖果想要挂载到绳子上,首先自己需要继承BodyComponent,然后将自身传递给CTRRopeCTRRope增加attachComponent用于接收挂载物

dart 复制代码
final Offset startPosition;
final BodyComponent? attachComponent;
final double length;

final List<CTRRopeSegment> _ropSegs = [];
CTRRope(
  {this.startPosition = const Offset(0, 0),
  this.length = 100,
  this.attachComponent});

CTRRope发现挂载物不为空时,创建RopeJoint将绳子最后一段和挂载物连接起来

dart 复制代码
if (attachComponent != null) {
  final jointDef = RopeJointDef()
    ..bodyA = lastRopeSeg?.body ?? pin.body
    ..bodyB = attachComponent!.body
    ..maxLength = ropeSegLen;
  game.world.createJoint(RopeJoint(jointDef));
}

采集星星

糖果和星星的碰撞检测,我使用了flame的CollisionCallbacks,但是我发现无法直接给继承自BodyComponentCTRCandy开启CollisionCallbacks,只能新建一个专门用于碰撞检测的组件

dart 复制代码
class CTRCandyCollisionComponent extends PositionComponent
    with CollisionCallbacks {
  final WeakReference<CTRCandy>? candy;
  CTRCandyCollisionComponent({this.candy});
  
  @override
  FutureOr<void> onLoad() {
    size = Vector2(40, 40);

    add(CircleHitbox(radius: 30)
      ..anchor = Anchor.center
      ..position = Vector2(0, 0));
    return super.onLoad();
  }

  @override
  void onCollisionStart(
      Set<Vector2> intersectionPoints, PositionComponent other) {
    super.onCollisionStart(intersectionPoints, other);
    if (other is CTRStar) {
      other.disappear();
    }
  }
}

但是这个组件如果直接addCTRCandy上,CollisionCallbacks依然无法生效,经过尝试,我将它挂载到了根节点上,并通过update同步CTRCandyCTRCandyCollisionComponent的位置

dart 复制代码
class CTRCandy extends BodyComponent {
...
  @override
  Future<void> onLoad() async {
    ...
    collisionComponent = CTRCandyCollisionComponent(candy: WeakReference(this));
    parent?.add(collisionComponent);
    ...
  }
  
  @override
  void update(double dt) {
    super.update(dt);
    collisionComponent.position = body.position;
  }
}

CTRStar继承自PositionComponent,所以直接加一个CircleHitbox即可

dart 复制代码
add(CircleHitbox(radius: 10)
  ..anchor = Anchor.center
  ..position = size * 0.5);

碰撞时,调用CTRStardisappear()触发消失动画,CTRStar通过对animationTickers的监控,在动画结束时销毁自己

dart 复制代码
disappear() {
  _animationComponent.current = CTRStarState.disappear;
  _animationComponent.animationTickers?[CTRStarState.disappear]?.onComplete =
        () {
    removeFromParent();
  };
}

怪兽吃到糖果

怪兽和糖果之间也是采用的CollisionCallbacks进行检测

dart 复制代码
@override
void onCollisionStart(
    Set<Vector2> intersectionPoints, PositionComponent other) {
  super.onCollisionStart(intersectionPoints, other);
  if (other is CTRCandyCollisionComponent) {
    other.candy?.target?.beEaten();
    eat();
  }
}

发现怪兽碰撞到了糖果,调用糖果的beEaten触发fadeOut效果,通过OpacityEffect实现

dart 复制代码
beEaten() {
  _candyBgComponent.add(OpacityEffect.fadeOut(EffectController(duration: 0.3))
    ..removeOnFinish = false);
  _candyFgComponent.add(OpacityEffect.fadeOut(EffectController(duration: 0.3))
    ..removeOnFinish = false);
}

eat();则是触发了小怪兽的干饭动画

dart 复制代码
eat() {
    _animationComponent.animationTickers?[CTRPlayerAnimState.eat]?.reset();
    _animationComponent.current = CTRPlayerAnimState.eat;
}

合在一起,布置一个关卡

首先添加小怪兽

dart 复制代码
_player = CTRPlayer(images: images)
  ..anchor = Anchor.center
  ..position = Vector2(size.x * 0.8, size.y * 0.8);
add(_player);

然后布置小星星

dart 复制代码
add(CTRStar(images: images)
      ..anchor = Anchor.center
      ..position = Vector2(100, 400));

add(CTRStar(images: images)
  ..anchor = Anchor.center
  ..position = Vector2(220, 430));

add(CTRStar(images: images)
  ..anchor = Anchor.center
  ..position = Vector2(320, 530));

接下来创建糖果但是不添加

dart 复制代码
final candy = CTRCandy(images: images, pos: Offset(100, 200));

最后布置绳子并添加糖果

dart 复制代码
{
      final hook = CTRHook(images: images)
        ..anchor = Anchor.center
        ..position = Vector2(100, 100);
      final rope = CTRRope(
          startPosition: hook.position.toOffset(),
          attachComponent: candy,
          length: 200);
      add(rope);
      add(hook);
}

{
  final hook = CTRHook(images: images)
    ..anchor = Anchor.center
    ..position = Vector2(250, 100);
  final rope = CTRRope(
      startPosition: hook.position.toOffset(),
      attachComponent: candy,
      length: 300);
  add(rope);
  add(hook);
}

add(candy);

就可以得到开头的游戏场景啦~

接下来...

简单总结一下,这个小游戏主要涉及以下技术点

  • SpriteAnimationComponentSpriteAnimationGroupComponent的使用
  • flame_forge2d的基础用法和RopeJoint的使用
  • flame碰撞检测的使用

访问 github.com/BuildMyGame... 可以获取完整代码,更多细节阅读代码就可以知道了哦~

相关推荐
红红大虾12 小时前
Defold引擎中关于CollectionFactory的使用
游戏开发
ALLIN14 小时前
Flutter 三种方式实现页面切换后保持原页面状态
flutter
Dabei14 小时前
Flutter 国际化
flutter
Dabei15 小时前
Flutter MQTT 通信文档
flutter
Dabei18 小时前
Flutter 中实现 TCP 通信
flutter
孤鸿玉18 小时前
ios flutter_echarts 不在当前屏幕 白屏修复
flutter
前端 贾公子20 小时前
《Vuejs设计与实现》第 16 章(解析器) 上
vue.js·flutter·ios
tangweiguo030519871 天前
Flutter 数据存储的四种核心方式 · 从 SharedPreferences 到 SQLite:Flutter 数据持久化终极整理
flutter
0wioiw01 天前
Flutter基础(②④事件回调与交互处理)
flutter
肥肥呀呀呀1 天前
flutter配置Android gradle kts 8.0 的打包名称
android·flutter