美文网首页@IT·互联网OpenGL ES图形处理
Flutter Flame实战 - 复刻经典游戏”割绳子“

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

作者: handyTOOL | 来源:发表于2023-12-15 00:24 被阅读0次

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

    ctr_preview.gif

    基本物品准备

    添加游戏背景

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

    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的大小以及偏移的位置,这些数据需要一些手段获取,我硬编码在了项目中。

    char_animations.png

    新建CTRPlayer类表示小怪兽,他继承自PositionComponent

    class CTRPlayer extends PositionComponent {
        ...
    }
    

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

    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设置为对应状态值快速切换动画

    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动画

    @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;
      }
    }
    
    ctr_player_anim.gif

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

    final charEffect = ScaleEffect.to(
        Vector2(1.1, 0.9),
        EffectController(
            duration: 0.6,
            reverseDuration: 0.3,
            infinite: true,
            curve: Curves.easeOutCubic));
    _animationComponent.add(charEffect);
    
    ctr_player_anim_duan.gif

    糖果

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

    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);
    
    
    ctr_candy.png

    固定点

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

    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);
    
    ctr_hook.png

    星星

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

    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);
    
    ctr_star.gif

    绳子模拟

    创建CTRRope类表示绳子

    物理模拟

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

    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类似,但是他不能动,用与将绳子的一头固定

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

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

    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,可以大致看出绳子的模拟效果

    ctr_rope_raw.gif

    绳子渲染

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

    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绘制绳子

    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);
    }
    
    ctr_rope_render.gif

    如何切断绳子

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

    如何判断哪一段被切到

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

    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

    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,直接不绘制

    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);
    }
    
    ctr_rope_cut.gif

    将糖果挂到绳子上

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

    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将绳子最后一段和挂载物连接起来

    if (attachComponent != null) {
      final jointDef = RopeJointDef()
        ..bodyA = lastRopeSeg?.body ?? pin.body
        ..bodyB = attachComponent!.body
        ..maxLength = ropeSegLen;
      game.world.createJoint(RopeJoint(jointDef));
    }
    
    ctr_rope_with_candy.gif

    采集星星

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

    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的位置

    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即可

    add(CircleHitbox(radius: 10)
      ..anchor = Anchor.center
      ..position = size * 0.5);
    

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

    disappear() {
      _animationComponent.current = CTRStarState.disappear;
      _animationComponent.animationTickers?[CTRStarState.disappear]?.onComplete =
            () {
        removeFromParent();
      };
    }
    
    ctr_star_disappear.gif

    怪兽吃到糖果

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

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

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

    beEaten() {
      _candyBgComponent.add(OpacityEffect.fadeOut(EffectController(duration: 0.3))
        ..removeOnFinish = false);
      _candyFgComponent.add(OpacityEffect.fadeOut(EffectController(duration: 0.3))
        ..removeOnFinish = false);
    }
    

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

    eat() {
        _animationComponent.animationTickers?[CTRPlayerAnimState.eat]?.reset();
        _animationComponent.current = CTRPlayerAnimState.eat;
    }
    
    ctr_player_eat.gif

    合在一起,布置一个关卡

    首先添加小怪兽

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

    然后布置小星星

    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));
    

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

    final candy = CTRCandy(images: images, pos: Offset(100, 200));
    

    最后布置绳子并添加糖果

    {
          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碰撞检测的使用

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

    相关文章

      网友评论

        本文标题:Flutter Flame实战 - 复刻经典游戏”割绳子“

        本文链接:https://www.haomeiwen.com/subject/qciwgdtx.html