ทำ Animation ให้เจ๋งกว่าเดิม ด้วย Flutter Flame Engine — Ep.2 ฝนตกพรำๆ

Amorn Apichattanakul
KBTG Life
Published in
5 min readApr 17

--

Photo by Mike Kotsch on Unsplash

หลังจากที่เราได้พูดถึงการทำ Animation ด้วย SpriteSheet กันไปแล้วในอีพีแรก บทความนี้หลักๆ จะเป็นการนำเนื้อหาดังกล่าวมาต่อยอดด้วยการทำฝนตกในแอป โดยใช้ Sprite Sheet และ Collision Detection มาประกอบ สำหรับใครที่พลาดบทความที่แล้วไป สามารถไปดูได้ที่นี่นะครับ

ใครวัยรุ่นใจร้อนขี้เกียจอ่าน ไป Pull โค้ดมาเล่นแทนได้เลยครับ

ใครได้โค้ดมาแล้ว มาดูกันว่าแต่ละอย่างมาจากอะไร…

Game Widget

ขั้นแรกครับ GameWidget เป็นตัว Widget สำคัญที่จะทำให้ Flutter App เรามี Flame ได้ ซึ่งผมก็ได้สร้างตัว Game Widget ชื่อ RainEffect และนำเข้าไปใน Tree ของ Flutter App ซึ่งการที่เราจะนำ Flutter Widget ไปแสดงในส่วนของ Flame ได้นั้น จะต้องใช้ overlayBuilderMap API ในการทำนะครับ และจะต้องห้ามลืมนำ Key ไปใส่ใน initialActiveOverlays ด้วย ไม่งั้นตัว Widget ที่ทำไปจะไม่แสดงใน Flame ครับ ซึ่งในกรณีนี้ผมได้ใช้ Key ที่ชื่อว่า userArea และ container1 ในการแสดงผล

จะเห็นว่าส่วนของ KeyuserArea นั้น ผมสามารถใส่ Flutter Widget แบบปกติได้เลย พร้อมทำการใส่ TextField ลงไป ส่วน container1 ก็ใส่ปุ่มธรรมดาลงไปครับ ตรงนี้จะเห็นว่า TextField, Button ก็ใส่ได้หมดนะ กดได้ด้วยครับ สามารถมี Callback กดแล้วอยากให้ทำอะไร ก็นำมาใส่ตรงส่วนนี้ได้เลย

Expanded(
child: GameWidget<RainEffect>(game: game, overlayBuilderMap: {
'userArea': (ctx, game) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 80),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: const [
TextField(
decoration: InputDecoration(
filled: true,
fillColor: Colors.white,
hintText: 'Username')),
TextField(
decoration: InputDecoration(
filled: true,
fillColor: Colors.white,
hintText: 'Password'))
]));
},
'container1': (ctx, game) {
return ActionButtonWidget(
Colors.blueAccent, "Sign in", Alignment.bottomCenter, () {
print("=== This is Flutter widget inside Flutter Flame ===");
});
},
}, initialActiveOverlays: const [
'userArea',
'container1'
]))

ถามว่าทำไปทำไม?

เป้าหมายหลักคือผมอยากทำ Animation ที่ค่อนข้างซับซ้อน ซึ่ง Flutter Widget ธรรมดานั้นทำได้ยากถึงยากมาก แต่ Flame เนี่ยถูกเขียนมาสำหรับเป็นเกมอยู่แล้ว ผมจึงพยายามจะนำเกมกับแอปมารวมกันให้เนียนที่สุด โดยที่ User ไม่รู้ว่า ส่วนที่เล่นและใช้งานอยู่คือเกม หลักๆ ก็เพื่อทำฝนตกแบบมีน้ำกระเด็นนั่นเองครับ

ด้านใน Widget RainEffect ก็เป็น Class ที่ Extend FlameGame อีกที ซึ่งมี Mixin อยู่ 3 ตัว คือ HasGameRef, HasCollosionDetection, และ HasTappables

Flutter Flame Lifecycle จะเริ่มที่ onLoad ซึ่งเหมือนกับ initState ใน Widget ธรรมดาที่จะโหลดเพียงแค่ครั้งเดียว โดยปกติก็จะเป็นพวก Asset หรือ Add Component ต่างๆ ในนี้ ผมได้ทำการ Sprite Sheet ฝนตกไว้เป็น Background ของตัว Flame เพื่อให้ดูบรรยากาศฝนตกครับ โดยผมได้ทำการเพิ่ม ScreenHitbox() ที่เป็น Common Widget ของ Flame อยู่แล้ว ไว้สร้างพื้นที่ของเกม เพื่อให้ตัว Object รู้ว่าขอบเขตที่ตัว Flame จะเล่นได้นั้นมีพื้นที่ขนาดไหน ในกรณีนี้ผมไว้ใช้ทำ Collision Detection และก็ได้เพิ่ม DynamicIslandButton ส่วนเป็น Flame Button แต่จะทำหลอกให้เหมือนกับ Flutter Widget ให้มากที่สุด คนจะได้ไม่รู้ว่านี่คือส่วนของ Flame

class RainEffect extends FlameGame
with HasGameRef<FlameGame>, HasCollisionDetection, HasTappables {
late SpriteSheet rainSprite;
var isRaining = true;
@override
Color backgroundColor() => const Color(0x00000000);

@override
Future<void> onLoad() async {
rainSprite = SpriteSheet(
image: await images.load('rain_effect.png'),
srcSize: Vector2(1024.0, 60.0),
);
final spriteSize = Vector2(256.0, 240.0);
final animation = rainSprite.createAnimation(row: 0, stepTime: 0.1, to: 4);
final rainComponent = SpriteAnimationComponent(
animation: animation,
scale: Vector2(3.5, 3.5),
size: spriteSize,
);
add(rainComponent);
add(ScreenHitbox());
add(DynamicIslandButton()
..position = Vector2(size.x / 2, size.y / 2 + 80)
..size = Vector2(size.x - 160, 40)
..anchor = Anchor.center);
add(FakeArea(gameRef.size / 2, Vector2(gameRef.size.x - 160, 100)));
}

@override
Future<void> onTapDown(int pointerId, TapDownInfo info) async {
super.onTapDown(pointerId, info);
while (isRaining) {
final randomX = Random();
final xPos = randomX.nextDouble() * gameRef.size.x;
final position = Vector2(xPos, 0);
add(RainDrop(position));
await Future.delayed(const Duration(milliseconds: 200));
}
}
}
Rain Sprite Sheet

FakeArea เป็นส่วนสำคัญอีกส่วนที่ผมทำไว้หลอกให้เกิด Collisions ระหว่าง Flame กับแอป จริงๆ แล้วมันทำข้ามกันไม่ได้ เลยต้องหลอกด้วยวิธีนี้ โดยเอาตัว FakeArea แอบไว้หลัง overlayBuilderMap

HasTappables เป็น Mixin เอาไว้ทำ onTapDown ในตัว Flame ซึ่งถ้าไม่มีตัวนี้ ตัว Flame เราจะกดไม่ได้นะครับ ฉะนั้นต้องใส่ด้วย ในกรณีผมคือให้คนกด จากนั้นจะมีฝนตกลงมาเรื่อยๆ ทุก 200ms ครับ โดยใช้ gameRef ซึ่งเป็น Common Widget ที่เอาไว้เช็คขนาดของ Flame มันคือตัวเดียวกับ MediaQuery.of(context) ของ Flutter App เลย ซึ่งถ้าอยากจะใช้ gameRef ก็อย่าลืมใส่ HasGameRef ด้วยนะครับ

HasCollisionDetection ส่วนนี้ไว้บอกลูกๆ Component ทุกตัวที่ใส่ในแอปว่าเราจะเช็ค Collosion

ทีนี้มาดูที่ RainDrop กันครับ

class RainDrop extends PositionComponent
with HasGameRef<RainEffect>, CollisionCallbacks {
late Vector2 velocity;
late ShapeHitbox hitbox;
final _defaultColor = Colors.blueAccent.shade100;
final gravity = 9.8;
RainDrop(Vector2 position)
: super(
position: position,
size: Vector2.all(5),
anchor: Anchor.center,
);

@override
Future<void> onLoad() async {
super.onLoad();
final defaultPaint = Paint()
..color = _defaultColor
..style = PaintingStyle.fill;
hitbox = CircleHitbox()
..paint = defaultPaint
..renderShape = true;
add(hitbox);
final center = gameRef.size / 2;
velocity = (center - position);
}
@override
void update(double dt) {
super.update(dt);
velocity.y += gravity;
position.y += velocity.y * dt;
}
@override
void onCollisionStart(
Set<Vector2> intersectionPoints,
PositionComponent other,
) {
super.onCollisionStart(intersectionPoints, other);
if (other is ScreenHitbox) {
final collisionPoint = intersectionPoints.first;
if (collisionPoint.x == 0) {
velocity.x = -velocity.x;
velocity.y = velocity.y;
}
if (collisionPoint.x.floor() == gameRef.size.x.floor()) {
removeFromParent();
return;
}
if (collisionPoint.y == 0) {
removeFromParent();
return;
}
if (collisionPoint.y.floor() == gameRef.size.y.floor()) {
removeFromParent();
gameRef.add(DropSplash(collisionPoint));
return;
}
} else if (other is FakeArea) {
removeFromParent();
final collisionPoint = intersectionPoints.first;
gameRef.add(RainSplash(collisionPoint));
return;
}
}
}

RainDrop

RainDrop เป็นตัว PositionComponent ซึ่ง Component จริงๆ มีหลายตัว แต่ในกรณีนี้เราจะใช้ตัวนี้ครับ ต้องไม่ลืมใส่HasGameRef กับ CollisionCallbacks เพื่อที่จะให้มี Interaction กับ Parents ได้ นั่นก็คือ RainEffect

และอีกส่วนคือ update ตัวนี้จะเอาไว้ใช้ทำการเคลื่อนไหวต่างๆ ครับ ในที่นี่ผมใช้ Gravity ในการคำนวณค่า Y เพื่อสร้าง Animation หยดฝนที่ตกเร็วขึ้นเมื่ออยู่สูงจากพื้นดินมากๆ และใช้เวลาเดินทางนาน ปกติ Flame จะมีตัว Gravity อยู่แล้ว แต่ของผมไปไม่ถึงส่วนนั้นครับ จึงใช้การคำนวณแบบทั่วไป

ในตัว RainDop นั้น ที่ onLoad() ผมก็ได้ CircleHitBox เพื่อเป็นตัวแทนเม็ดฝน หรือเราจะใส่อันอื่นก็ได้ แล้วค่อยเอาพวก HitBox ไปครอบอีกที

onCollisionStart ตัวนี้จะเริ่มเรียกเมื่อวัตถุ 2 อันมาชนกัน ซึ่งในกรณีนี้จะมี 2 ส่วน คือ ตัว ScreenHitBox กับ RainDrop ซึ่งใน Logic ผมก็ได้เช็คไว้ว่าเมื่อใดที่เม็ดฝนไปชนกับขอบเกม ให้ลบตัวเองออก เพื่อไม่ให้ Object มีเยอะเกิน โดยใช้ removeFromParent() API เพื่อลบออกครับ ส่วนตรงที่ผมมี Logic เช็คไว้collisionPoint.y.floor() == gameRef.size.y.floor() เพราะเคยมีเคสที่ว่า ตอนฝนชนกับ HitBox นั้นมันมีตัวเลขคลาดเคลื่อนไป ไม่ตรงกัน เกินแบบระดับจุดทศนิยม 3 หลักเลยครับ ซึ่งถ้าเช็คขนาดนั้น มันจะมีบางกรณีไม่ตรง ไม่แน่ใจด้วยสาเหตุใด ผมจึงต้องทำ Floor เพื่อปัดเลขให้มันออกมาเท่ากันครับ

เมื่อฝนชนกับพื้น ผมจะเรียก gameRef.add(DropSplash(collisionPoint)); เพื่อให้เกิด Sprite Sheet Animation น้ำกระเด็นครับ ในกรณีนี้เราต้องใช้ gameRef.add เพราะถ้าใช้ Add อย่างเดียว มันจะมา Add ที่ตัว Raindrop ซึ่งตัว Raindrop ดังกล่าวถูกทำลายไปแล้วจากการเรียก removeFromParent()เราจึงต้องไป Add ที่ Parent ของ Object แทนครับ ก็คือตัว RainEffect

ส่วนตัว DropSplash นั้นก็เป็น FlameGame เหมือนกัน ซึ่งในนี้ผมจะให้ Load Sprite Sheet มา จากนั้นให้ทำลายตัวเองไปหลังจากที่เล่น Animation เสร็จแล้ว ซึ่งตรงส่วนนี้ผมไม่แน่ใจว่าทำไม removeOnFinish เป็น True แล้วก็ยังไม่ทำลาย อาจจะเพราะว่ามีการ Add เป็นชั้นๆ เข้ามามั้งครับ ก็เลยเล่นแร่แปรธาตุด้วยการให้ Delay ไว้ตามเวลาที่กำหนด แล้วค่อยลบตัวเองครับ

class DropSplash extends FlameGame {
final Vector2 position;
DropSplash(this.position) : super();
@override
Future<void> onLoad() async {
final spriteSheet = SpriteSheet(
image: await images.load('drop_splash.png'),
srcSize: Vector2(90.0, 90.0),
);
final spriteSize = Vector2(90.0, 90.0);
final animation =
spriteSheet.createAnimation(row: 0, stepTime: 0.1, to: 12);
final component1 = SpriteAnimationComponent(
scale: Vector2(0.5, 0.5),
animation: animation,
position: position - Vector2(20, 40),
size: spriteSize,
removeOnFinish: true);
add(component1);
Future.delayed(const Duration(milliseconds: 1000))
.then((value) => removeFromParent());
}
}

ซึ่งผมก็ทำแบบเดียวกันกับ RainSplash ครับ ที่แยกไว้ 2 อัน เพื่อให้เห็นว่า Animation ของตอนฝนตกจากที่สูง ฝนจะต้องกระจายมากกว่าจากที่ต่ำ จึงให้ไว้ว่าถ้าชนกับ TextField ที่อยู่ด้านบน ก็กระจายนิดเดียวพอ แต่ถ้าลงมาถึงพื้น Flame ให้กระจายเยอะๆ เลย

Rain drop splash SpirteSheet

สุดท้ายก็จะได้ออกมาตาม Demo ด้านล่างครับ ให้กดที่จอนะครับ แล้วฝนจะเริ่มตก

เท่าที่ดู มันก็ไม่ได้สวยมากครับ 😅 เป็นสาเหตุที่ผมมาเป็น Programmer นั่นเอง เพราะสกิลดีไซน์นั้นต่ำติดดิน ทั้งตัวฝนตกและน้ำฝนกระเด็นก็เอามาจากอินเทอร์เน็ตครับ แล้วใช้สกิล Photoshop ระดับ Programmer ทำขึ้นมา 😆

จริงๆ ยังรู้สึกว่า Animation ที่ทำไม่เนียนมากเท่าไร รู้สึกว่าขาด Particle และ Physics บางส่วนอยู่ เช่น น้ำกระเด็นเป็น Sprite Sheet แล้ว ก็ควรทำ Particle ละอองฝนกระเด็นออกมาด้วย ให้ไปชนกับขอบข้างๆ แล้วไหลลงมา และยังอยากให้มี Physics พวกลมในการพัดฝนให้แบบเอียงบ้างครับ ตอนนี้ผมทำฝนตกตรงลงมาดื้อๆ เลย คงต้องศึกษาเพิ่มเติมเกี่ยวกับส่วนนี้ก่อน แล้วนำมาพัฒนาต่อไป

ทีนี้บางคนอาจจะคิดว่า เอ้ย เกม ในแอปเนี่ยนะ หนักไป คนใช้งานกันไม่ไหวหรอก เปลืองทั้งแบต เปลืองทั้ง Memory เพื่อทดสอบว่าที่เขาพูดจริงมั้ย ผมได้หยิบเอา MAKE by KBank ที่ผมทำอยู่มาเป็นโปรเจคทดลองเล่น ดูว่าถ้าเราจะเอามาใช้กันจริงๆ จังๆ ในแอป มันจะทำได้จริงรึเปล่า ผลปรากฏว่า เอ้ยยย ทำไม FPS ดีกว่า Animation Widget ปกติอีกนะ!! เราอาจจะยัง Optimize มันไม่ดีมากล่ะมั้ง แต่อย่างน้อยก็ทำให้เห็นว่าการเอาเกมมาลงในแอปนั้นไม่ได้กิน Performance ต่างกับใช้ Widget ทั่วไปเลย ซึ่งเกมที่ผมเอามาลง ก็ไม่ได้ตั้งใจจะให้เป็นแบบเก๊ม เกม อะไรขนาดนั้น มันเลยไม่ค่อยเปลืองพลังงานมั้ง ผลลัพธ์ได้ออกมาตามด้านล่างเลยครับ

สุดท้ายแล้ว อย่างน้อยผมอยากให้บทความนี้เป็นไอเดียตั้งต้นให้กับคนที่อยากเอา Flame กับแอปมารวมกันครับ เพื่อที่แอปเราจะได้สนุกขึ้น

Enjoy the rain 🌧️

สำหรับใครที่สนใจเรื่องราวดีๆ หรืออยากเรียนรู้เกี่ยวกับ Product ใหม่ๆ จากชาว KBTG สามารถติดตามรายละเอียดกันได้ที่เว็บไซต์ www.kbtg.tech

--

--

Amorn Apichattanakul
KBTG Life

Google Developer Expert for Flutter & Dart | Senior Flutter/iOS Software Engineer @ KBTG