前言
嗨,我是 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);
}
}
這裡的 wobblePhase 和 wobbleSpeed 給每個氣球不同的搖擺頻率,讓畫面看起來更自然而不是所有氣球都同步擺動。
氣球的繪製
氣球的視覺效果對遊戲體驗影響很大。一個簡單的圓形太無聊了,我用 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);
}
}
這個設計讓遊戲從輕鬆的節奏開始,隨著分數增加慢慢提升壓力。max 和 min 的限制很重要,確保難度不會無限上升到玩家根本反應不過來的程度。
遊戲迴圈: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 試玩!