Flutter 氣球爆破遊戲物理引擎開發:Happy Balloon Pop 實作

前言

嗨,我是 Steven!今天要來聊聊我的第一款正式上架的 App — Happy Balloon Pop。這是一款面向兒童的氣球爆破遊戲,玩家需要點擊螢幕上飄動的氣球來得分。聽起來很簡單,但為了讓遊戲體驗足夠好玩、視覺效果足夠吸引人,我在物理模擬和動畫效果上下了不少功夫。

遊戲核心概念

Happy Balloon Pop 的基本玩法是這樣的:氣球從螢幕底部不斷浮上來,玩家要在氣球飄出螢幕頂部之前點擊爆破它。隨著分數增加,氣球的速度會加快、數量會增多,遊戲也會越來越刺激。

氣球的物理模型

要讓氣球看起來自然,不能只是簡單地讓它等速往上移動。真正的氣球會受到浮力、空氣阻力和風的影響,還會有輕微的左右搖擺。我設計了一個簡化的物理模型:

class Balloon {
  Offset position;
  Offset velocity;
  double radius;
  Color color;
  double wobblePhase;
  double wobbleSpeed;
  bool isPopped = false;

  Balloon({
    required this.position,
    required this.radius,
    required this.color,
  }) : velocity = Offset(0, -(2.0 + Random().nextDouble() * 1.5)),
       wobblePhase = Random().nextDouble() * 2 * pi,
       wobbleSpeed = 1.5 + Random().nextDouble() * 1.0;

  void update(double dt) {
    // 基本上升運動
    position += velocity * dt * 60;

    // 水平搖擺效果
    wobblePhase += wobbleSpeed * dt;
    final wobbleOffset = sin(wobblePhase) * 1.2;
    position = Offset(position.dx + wobbleOffset, position.dy);
  }
}

這裡的 wobblePhasewobbleSpeed 給每個氣球不同的搖擺頻率,讓畫面看起來更自然而不是所有氣球都同步擺動。

氣球的繪製

氣球的視覺效果對遊戲體驗影響很大。一個簡單的圓形太無聊了,我用 CustomPainter 加上漸層和高光來讓氣球看起來有立體感:

class BalloonPainter extends CustomPainter {
  final List<Balloon> balloons;

  BalloonPainter(this.balloons);

  @override
  void paint(Canvas canvas, Size size) {
    for (final balloon in balloons) {
      if (balloon.isPopped) continue;

      final center = balloon.position;
      final r = balloon.radius;

      // 氣球本體漸層
      final bodyPaint = Paint()
        ..shader = RadialGradient(
          center: const Alignment(-0.3, -0.3),
          radius: 1.0,
          colors: [
            balloon.color.withOpacity(0.9),
            balloon.color,
            HSLColor.fromColor(balloon.color)
                .withLightness(0.3)
                .toColor(),
          ],
          stops: const [0.0, 0.5, 1.0],
        ).createShader(Rect.fromCircle(center: center, radius: r));

      canvas.drawCircle(center, r, bodyPaint);

      // 高光效果
      final highlightPaint = Paint()
        ..shader = RadialGradient(
          center: const Alignment(-0.5, -0.5),
          radius: 0.5,
          colors: [
            Colors.white.withOpacity(0.5),
            Colors.white.withOpacity(0.0),
          ],
        ).createShader(
          Rect.fromCircle(center: Offset(center.dx - r * 0.25, center.dy - r * 0.25), radius: r * 0.5),
        );

      canvas.drawCircle(
        Offset(center.dx - r * 0.25, center.dy - r * 0.25),
        r * 0.4,
        highlightPaint,
      );

      // 氣球繫繩
      _drawString(canvas, center, r);
    }
  }

  void _drawString(Canvas canvas, Offset center, double radius) {
    final path = Path();
    path.moveTo(center.dx, center.dy + radius);
    path.cubicTo(
      center.dx - 5, center.dy + radius + 15,
      center.dx + 5, center.dy + radius + 25,
      center.dx, center.dy + radius + 35,
    );

    canvas.drawPath(
      path,
      Paint()
        ..color = Colors.grey.shade600
        ..style = PaintingStyle.stroke
        ..strokeWidth = 1.5,
    );
  }
}

漸層加高光這個組合效果很好,能讓平面的圓形看起來像個真正有光澤的氣球。底下的繫繩用貝茲曲線畫出微微彎曲的線條,整體感覺就到位了。

爆破效果:粒子動畫

玩家點擊氣球時的爆破效果是整個遊戲最令人滿足的時刻。我用粒子系統來模擬爆破:

class PopEffect {
  final Offset origin;
  final Color color;
  final List<PopParticle> particles;
  double lifetime = 1.0;

  PopEffect({required this.origin, required this.color})
      : particles = List.generate(12, (i) {
          final angle = (i / 12) * 2 * pi + Random().nextDouble() * 0.3;
          final speed = 3.0 + Random().nextDouble() * 4.0;
          return PopParticle(
            position: origin,
            velocity: Offset(cos(angle) * speed, sin(angle) * speed),
            size: 3.0 + Random().nextDouble() * 5.0,
            color: color,
          );
        });

  void update(double dt) {
    lifetime -= dt * 2;
    for (final p in particles) {
      p.position += p.velocity * dt * 60;
      p.velocity = Offset(p.velocity.dx * 0.96, p.velocity.dy * 0.96 + 0.15);
      p.opacity = lifetime.clamp(0.0, 1.0);
    }
  }
}

爆破粒子會從氣球中心往四面八方射出,並逐漸受重力影響往下掉,同時透明度也會漸漸降低直到消失。搭配震動回饋(HapticFeedback.lightImpact()),讓爆破的觸感非常過癮。

難度遞增系統

遊戲如果始終維持同樣的難度,很快就會讓人無聊。我設計了一個基於分數的漸進式難度系統:

class DifficultyManager {
  double spawnInterval;
  double balloonSpeed;
  int maxBalloons;

  DifficultyManager()
      : spawnInterval = 1.5,
        balloonSpeed = 2.0,
        maxBalloons = 5;

  void updateDifficulty(int score) {
    // 每 10 分提升一次難度
    final level = (score / 10).floor();

    spawnInterval = max(0.4, 1.5 - level * 0.1);
    balloonSpeed = min(6.0, 2.0 + level * 0.3);
    maxBalloons = min(15, 5 + level);
  }
}

這個設計讓遊戲從輕鬆的節奏開始,隨著分數增加慢慢提升壓力。maxmin 的限制很重要,確保難度不會無限上升到玩家根本反應不過來的程度。

遊戲迴圈:Ticker 的運用

Flutter 的 Ticker 非常適合做遊戲迴圈,它會在每一幀呼叫回調,提供精確的時間差:

class _GameScreenState extends State<GameScreen> with SingleTickerProviderStateMixin {
  late Ticker _ticker;
  final List<Balloon> _balloons = [];
  final List<PopEffect> _popEffects = [];
  double _spawnTimer = 0;
  int _score = 0;

  @override
  void initState() {
    super.initState();
    _ticker = createTicker(_onTick)..start();
  }

  void _onTick(Duration elapsed) {
    final dt = 1 / 60; // 固定時間步長

    _spawnTimer -= dt;
    if (_spawnTimer <= 0) {
      _spawnBalloon();
      _spawnTimer = _difficulty.spawnInterval;
    }

    for (final balloon in _balloons) {
      balloon.update(dt);
    }

    for (final effect in _popEffects) {
      effect.update(dt);
    }

    // 清除已消失的效果和飛出螢幕的氣球
    _popEffects.removeWhere((e) => e.lifetime <= 0);
    _balloons.removeWhere((b) => b.position.dy < -b.radius * 2);

    setState(() {});
  }
}

我使用固定時間步長(1/60 秒)而不是實際的 delta time,這樣可以確保物理模擬在不同幀率的裝置上表現一致。

音效與觸覺回饋

一個好的遊戲體驗不只是視覺。爆破時的音效和手機震動回饋能大幅提升遊玩的爽快感。因為 Happy Balloon Pop 的目標用戶是兒童,音效的選擇特別重要——需要歡快、有趣但不刺耳。

void _popBalloon(Balloon balloon) {
  balloon.isPopped = true;
  _score++;
  _difficulty.updateDifficulty(_score);

  _popEffects.add(PopEffect(origin: balloon.position, color: balloon.color));

  // 音效
  _audioPlayer.play(AssetSource('sounds/pop.mp3'));

  // 觸覺回饋
  HapticFeedback.lightImpact();
}

兒童友善的設計考量

因為目標用戶是小朋友,我在設計時特別注意了幾點:

  • 色彩鮮豔:氣球使用飽和度高的原色系配色
  • 沒有懲罰機制:漏掉氣球不會扣分,只是少得分而已
  • 大觸控範圍:氣球的點擊判定範圍比視覺大小稍大,讓小朋友的小手指也能輕鬆點到
  • 正向回饋:每次爆破都有誇張的動畫和音效鼓勵

同時,因為面向兒童,隱私政策也特別重要。Happy Balloon Pop 不收集任何個人資料,廣告也使用了 AdMob 的兒童導向設定。

結語

Happy Balloon Pop 是我作為獨立開發者的起點。從一個簡單的點擊遊戲出發,我學到了 Flutter 動畫系統、CustomPainter、遊戲迴圈設計等等核心技能,這些經驗也直接幫助了後來 SandTrace 和 JigsawEase 的開發。

如果你正在考慮用 Flutter 做遊戲開發,我的建議是從小做起。一個簡單但完整的遊戲,遠比一個做到一半的大專案來得有價值。歡迎到各大商店搜尋 Happy Balloon Pop 試玩!