paint-brush
Phát triển trò chơi MVP với Flutter và Flametừ tác giả@leobit
361 lượt đọc
361 lượt đọc

Phát triển trò chơi MVP với Flutter và Flame

từ tác giả Leobit28m2024/06/06
Read on Terminal Reader

dài quá đọc không nổi

MVP (sản phẩm khả thi tối thiểu) đang ngày càng trở nên phổ biến trong ngành công nghiệp game. Với MVP, bạn có thể xây dựng một ứng dụng có chức năng cơ bản trong thời gian ngắn và với ngân sách hạn chế. Với sự trợ giúp của Flame, một công cụ trò chơi mã nguồn mở và mạnh mẽ được xây dựng dựa trên Flutter, bạn có thể tạo ra các trò chơi 2D tuyệt đẹp.
featured image - Phát triển trò chơi MVP với Flutter và Flame
Leobit HackerNoon profile picture
0-item

Theo một cuộc khảo sát gần đây , chỉ có 2 trong 5 công ty khởi nghiệp có lãi. MVP (sản phẩm khả thi tối thiểu) làm tăng đáng kể cơ hội sinh lời của công ty khởi nghiệp vì nó cho phép các doanh nghiệp đó thu thập phản hồi sớm của người dùng mà không cần chi toàn bộ ngân sách cho một ứng dụng có đầy đủ chức năng.


Với MVP, bạn có thể xây dựng một ứng dụng có chức năng cơ bản trong thời gian ngắn và với ngân sách hạn chế, thu thập phản hồi của người dùng và tiếp tục mở rộng giải pháp với nhóm phát triển của mình theo phản hồi này.


MVP đang ngày càng trở nên phổ biến trong ngành công nghiệp game. Hôm nay, chúng ta sẽ khám phá chi tiết về quá trình phát triển MVP trò chơi nhanh chóng với Flutter và Flame, một sự kết hợp tuyệt vời để xây dựng các sản phẩm có khả năng tồn tại tối thiểu trên nhiều nền tảng.

Tại sao chọn Flutter và Flame?

Flutter, một nền tảng an toàn và có nhiều tính năng để phát triển đa nền tảng , đã làm mưa làm gió trong thế giới ứng dụng di động và phạm vi tiếp cận của nó vượt xa giao diện người dùng. Với sự trợ giúp của Flame, một công cụ trò chơi mã nguồn mở và mạnh mẽ được xây dựng dựa trên Flutter , bạn có thể tạo các trò chơi 2D tuyệt đẹp chạy mượt mà trên các thiết bị Android, iOS, Web và Máy tính để bàn.


Flutter cũng đã trở thành một giải pháp phổ biến để xây dựng MVP trò chơi nhờ các tính năng tích hợp của nó giúp phát triển nhanh chóng các giải pháp cung cấp chức năng cơ bản trên các thiết bị khác nhau. Đặc biệt, nhiều lợi ích Flutter và chức năng tích hợp khác nhau cho phép:


  • Tạo một sản phẩm có cơ sở mã chung cho các nền tảng khác nhau, bao gồm Android và iOS, nhanh hơn và tiết kiệm chi phí hơn nhiều so với việc xây dựng các ứng dụng gốc riêng biệt cho các nền tảng khác nhau. Ngoài ra còn có một số phương pháp nhất định để xây dựng ứng dụng web Flutter với cùng một cơ sở mã.


  • Xây dựng giao diện người dùng linh hoạt với các tiện ích dựng sẵn và hình động mặc định, giúp tăng tốc độ phát triển, một trong những yếu tố quan trọng nhất trong quá trình phát triển MVP.


  • Flutter cung cấp chức năng tải lại nóng cho phép các nhà phát triển xem đồng thời các thay đổi được thực hiện trong mã trong ứng dụng xuất hiện trên màn hình, đảm bảo tính linh hoạt cao hơn trong quá trình phát triển MVP. Tính năng này làm cho việc lặp lại và thử nghiệm trở nên đơn giản hơn nhiều, cho phép các nhà phát triển nhanh chóng thử các cơ chế và hình ảnh khác nhau.


  • Việc phát triển một sản phẩm khả thi tối thiểu thường đòi hỏi số lượng tài nguyên tối thiểu và Flutter hoàn toàn đáp ứng yêu cầu này, vì việc tích hợp mặc định của Flutter với Firebase giúp giảm đáng kể độ phức tạp của việc lập trình phía máy chủ.


Flutter không tiêu tốn nhiều tài nguyên máy tính và tạo điều kiện cho việc thiết lập đơn giản các ứng dụng đa nền tảng.


Ứng dụng MVP dựa trên sự kết hợp Flutter và Flame là một giải pháp đáng tin cậy nhưng tương đối đơn giản để phát triển. Nó biên dịch trực tiếp thành mã gốc, đảm bảo khả năng phản hồi và chơi game mượt mà. Bạn có thể phát triển MVP trò chơi của mình một lần và triển khai nó trên các nền tảng khác nhau, tiết kiệm thời gian và tài nguyên. Flutter và Flame xử lý những khác biệt về nền tảng.


Ngoài ra, cả hai công nghệ đều có cộng đồng sôi động với tài liệu, hướng dẫn và ví dụ mã phong phú. Điều này có nghĩa là bạn sẽ không bao giờ bị mắc kẹt trong câu trả lời hoặc nguồn cảm hứng.

Vậy ngọn lửa có thể làm được gì?

Flame cung cấp toàn bộ bộ công cụ để tạo các tính năng trò chơi MVP trong thời gian ngắn và không tiêu tốn quá nhiều tài nguyên. Khung mô hình hóa đa nền tảng này cung cấp các công cụ cho nhiều trường hợp sử dụng khác nhau:


  • Hình ảnh và Hoạt ảnh: Bạn có thể nhanh chóng tạo các hình ảnh của mình hoặc sử dụng chúng từ nhiều thư viện trực tuyến khác nhau. Flame cũng hỗ trợ hoạt ảnh bộ xương, cho phép bạn tạo các hoạt ảnh phức tạp và chân thực hơn.


  • Phát hiện va chạm: Nó có hệ thống phát hiện va chạm tích hợp giúp bạn dễ dàng tạo trò chơi bằng vật lý của riêng mình. Bạn có thể sử dụng tính năng phát hiện va chạm để xây dựng nền tảng, tường, đồ sưu tầm và những thứ khác mà nhân vật trong trò chơi của bạn có thể tương tác.


  • Mô phỏng vật lý: Flame cũng hỗ trợ mô phỏng vật lý, cho phép bạn tạo ra cơ chế chơi năng động và hấp dẫn hơn. Bạn có thể sử dụng mô phỏng vật lý để tạo ra những thứ như trọng lực, nhảy và nảy.


  • Hiệu ứng âm thanh và âm thanh: Bạn có thể sử dụng âm thanh để tạo nhạc nền, hiệu ứng âm thanh (như đánh, nhảy, v.v.) và thậm chí cả lồng tiếng.


  • Quản lý trạng thái: Flame cung cấp một số tính năng để quản lý trạng thái trò chơi của bạn. Điều này bao gồm những thứ như ghi điểm, quản lý cấp độ và dữ liệu người chơi.


  • Thiết bị đầu vào: Flame hỗ trợ nhiều thiết bị đầu vào khác nhau, chẳng hạn như màn hình cảm ứng, bàn phím và bộ điều khiển trò chơi. Điều này làm cho nó trở thành một lựa chọn tuyệt vời để phát triển trò chơi cho nhiều nền tảng khác nhau.


  • Cuộn thị sai: Nó hỗ trợ cuộn thị sai, có thể tăng thêm chiều sâu và sự đắm chìm cho thế giới trò chơi của bạn. Cuộn thị sai tạo ra ảo giác về chiều sâu bằng cách di chuyển các lớp nền khác nhau ở tốc độ khác nhau.


  • Hệ thống hạt: Ngọn lửa cũng hỗ trợ các hệ thống hạt, có thể được sử dụng để tạo ra nhiều hiệu ứng hình ảnh khác nhau, chẳng hạn như vụ nổ, khói và mưa.


  • Trò chơi nhiều người chơi: Điều này cho phép người chơi cạnh tranh hoặc cộng tác với nhau trong thời gian thực.


Hầu hết các tính năng nêu trên đều cần thiết cho nhiều trò chơi và không nên bỏ qua ngay cả ở giai đoạn phát triển MVP. Điều thực sự quan trọng là Flame tăng tốc đáng kể tốc độ phát triển chức năng được đề cập ở trên, cho phép bạn phát hành các tính năng đó ngay cả trong các phiên bản sản phẩm đầu tiên.

Hãy thử nó

Bây giờ, thay vì nói về Flame, hãy tạo một MVP chứa các tính năng cơ bản của trò chơi của chúng ta bằng framework này. Trước khi chúng tôi bắt đầu, bạn phải cài đặt Flutter 3.13 trở lên, IDE và thiết bị yêu thích của bạn để thử nghiệm.

Một ý tưởng

Trò chơi này được lấy cảm hứng từ Chrome Dino. À, Dino Run nổi tiếng! Nó không chỉ là một trò chơi từ Chrome. Đó là một quả trứng Phục sinh được yêu thích ẩn trong chế độ ngoại tuyến của trình duyệt.


Dự án của chúng tôi sẽ có lối chơi sau:

  • Bạn vào vai Jack, một anh chàng thích phiêu lưu, chạy vô tận trong một khu rừng tối tăm.
  • Điều khiển ở mức tối thiểu: chạm vào phím cách hoặc nhấp vào màn hình để nhảy.
  • Trò chơi bắt đầu chậm nhưng dần dần tăng tốc độ, khiến bạn phải luôn cảnh giác.
  • Mục tiêu của bạn rất đơn giản: tránh các chướng ngại vật và chạy càng xa càng tốt, ghi điểm trên đường đi.


Và nó sẽ được gọi là “Forest Run!”

Chuẩn bị cho mình

Tạo một dự án Flutter trống giống như bạn làm mỗi khi khởi động một ứng dụng mới. Để bắt đầu, chúng ta cần đặt các phần phụ thuộc trong pubspec.yaml cho dự án của mình. Khi viết bài này, phiên bản mới nhất của Flame là 1.14.0. Ngoài ra, hãy xác định tất cả các đường dẫn nội dung ngay bây giờ để không cần phải quay lại tệp này sau này. Và đưa hình ảnh vào thư mục assets/images/. Chúng ta cần đặt nó ở đây vì Flame sẽ quét chính xác đường dẫn này:


 environment: sdk: '>=3.2.3 <4.0.0' flutter: '>=3.13.0' dependencies: flutter: sdk: flutter flame: ^1.14.0 flutter: uses-material-design: true assets: - assets/images/ - assets/images/character/ - assets/images/background/ - assets/images/forest/ - assets/images/font/


Hãy nhớ đặt tất cả hình ảnh dưới assets/images/ vì Flame sẽ không phân tích các thư mục khác.


Bạn sẽ cần rất nhiều hình ảnh cho bất kỳ trò chơi nào. Nhưng nếu bạn không giỏi thiết kế thì sao? Rất may, có rất nhiều tài sản nguồn mở mà bạn có thể sử dụng cho các dự án của mình. Nội dung của trò chơi này được lấy từ itch.io. Chúng tôi sẽ sử dụng các tài nguyên này cho dự án của mình:



Bạn có thể truy cập các liên kết đó hoặc chỉ cần tải xuống các tài sản đã chuẩn bị sẵn (LINK TO ASSETS ARCHIVE) cho dự án này và sao chép tất cả nội dung vào dự án của bạn.


Flame có triết lý tương tự như Flutter. Trong Flutter, mọi thứ đều là Widget; trong Flame, mọi thứ đều là Thành phần, thậm chí là toàn bộ Trò chơi. Mỗi Component có thể ghi đè 2 phương thức: onLoad() và update(). onLoad() chỉ được gọi một lần khi Thành phần được gắn vào ComponentTree và update() được kích hoạt trên mỗi khung. Rất giống với initState() và build() từ StatefulWidget trong Flutter.


Bây giờ, hãy viết một số mã. Tạo một lớp mở rộng FlameGame và tải tất cả nội dung của chúng tôi vào bộ đệm.


 class ForestRunGame extends FlameGame { @override Future<void> onLoad() async { await super.onLoad(); await images.loadAllImages(); } }


Tiếp theo, sử dụng ForestRunGame trong main.dart. Ngoài ra, bạn có thể sử dụng các phương thức từ Flame.device để định cấu hình hướng thiết bị. Và có GameWidget, đóng vai trò là cầu nối giữa các vật dụng và thành phần.


 Future<void> main() async { WidgetsFlutterBinding.ensureInitialized(); await Flame.device.fullScreen(); await Flame.device.setLandscape(); runApp(GameWidget(game: ForestRunGame())); }


Tại thời điểm này, chúng ta đã có thể bắt đầu trò chơi nhưng sẽ chỉ có một màn hình đen. Vì vậy, chúng ta cần thêm các thành phần của mình.

Rừng tối

Chúng ta sẽ chia khu rừng thành hai thành phần: hậu cảnh và tiền cảnh. Đầu tiên, chúng ta sẽ xử lý nền. Bạn đã bao giờ cuộn qua một trang có cảm giác sống động chưa? Như thể bạn đang cuộn qua nhiều chế độ xem cùng một lúc? Đó là hiệu ứng thị sai và nó xảy ra khi các phần tử khác nhau của trang di chuyển với tốc độ khác nhau, tạo ra hiệu ứng chiều sâu 3D.


Như bạn có thể nghĩ, chúng tôi sẽ sử dụng thị sai cho nền của mình. Mở rộng ParallaxComponent và thiết lập chồng hình ảnh bằng ParallaxImageData. Ngoài ra, còn có baseVelocity cho tốc độ và vận tốcMultiplierDelta của các lớp ban đầu, đại diện cho sự khác biệt tương đối về tốc độ giữa các lớp. Và điều cuối cùng, hãy định cấu hình trường ưu tiên (chỉ mục z) để di chuyển nó ra sau các thành phần khác.


 class ForestBackground extends ParallaxComponent<ForestRunGame> { @override Future<void> onLoad() async { priority = -10; parallax = await game.loadParallax( [ ParallaxImageData('background/plx-1.png'), ParallaxImageData('background/plx-2.png'), ParallaxImageData('background/plx-3.png'), ParallaxImageData('background/plx-4.png'), ParallaxImageData('background/plx-5.png'), ], baseVelocity: Vector2.zero(), velocityMultiplierDelta: Vector2(1.4, 1.0), ); } }


Nền đã xong; bây giờ là lúc để thêm tiền cảnh. Mở rộng LocationComponent để chúng ta có thể căn chỉnh mặt đất xuống dưới cùng của màn hình. Chúng tôi cũng cần có mixin HasGameReference để truy cập vào bộ nhớ đệm của trò chơi.


Để tạo mặt bằng, bạn chỉ cần xếp hình khối mặt đất vào hàng nhiều lần. Trong Flame, các thành phần hình ảnh được gọi là sprite. Sprite là một vùng của hình ảnh có thể được hiển thị trong Canvas. Nó có thể đại diện cho toàn bộ hình ảnh hoặc là một trong những phần của một tấm sprite.


Ngoài ra, hãy nhớ rằng trục X hướng sang phải và trục Y hướng xuống dưới. Tâm của các trục được đặt ở góc trên bên trái của màn hình.



 class ForestForeground extends PositionComponent with HasGameReference<ForestRunGame> { static final blockSize = Vector2(480, 96); late final Sprite groundBlock; late final Queue<SpriteComponent> ground; @override void onLoad() { super.onLoad(); groundBlock = Sprite(game.images.fromCache('forest/ground.png')); ground = Queue(); } @override void onGameResize(Vector2 size) { super.onGameResize(size); final newBlocks = _generateBlocks(); ground.addAll(newBlocks); addAll(newBlocks); y = size.y - blockSize.y; } List<SpriteComponent> _generateBlocks() { final number = 1 + (game.size.x / blockSize.x).ceil() - ground.length; final lastBlock = ground.lastOrNull; final lastX = lastBlock == null ? 0 : lastBlock.x + lastBlock.width; return List.generate( max(number, 0), (i) => SpriteComponent( sprite: groundBlock, size: blockSize, position: Vector2(lastX + blockSize.x * i, y), priority: -5, ), growable: false, ); } }


Và điều cuối cùng, hãy thêm các thành phần này vào ForestRunGame của chúng ta.


 class ForestRunGame extends FlameGame { late final foreground = ForestForeground(); late final background = ForestBackground(); @override Future<void> onLoad() async { await super.onLoad(); await images.loadAllImages(); add(foreground); add(background); } }


Bây giờ hãy thử khởi động trò chơi. Tại thời điểm này, chúng tôi đã có khu rừng của mình.


Người lạ trong rừng

Khu rừng trông rất đẹp nhưng lúc này đây chỉ là một bức tranh. Vì vậy, chúng ta sẽ tạo ra Jack, người sẽ chạy xuyên khu rừng này dưới sự hướng dẫn của người chơi. Không giống như cây cối và mặt đất, người chơi cần hình ảnh động để cảm thấy sống động. Chúng tôi đã sử dụng Sprite cho khối nền nhưng chúng tôi sẽ sử dụng SpriteAnimation cho Jack. Cái này hoạt động ra sao? Chà, tất cả đều dễ dàng, bạn chỉ cần lặp một chuỗi các họa tiết. Ví dụ: hoạt ảnh chạy của chúng tôi có 8 họa tiết, chúng thay thế nhau bằng một khoảng thời gian nhỏ.



Jack có thể chạy, nhảy và không hoạt động. Để thể hiện các trạng thái của anh ấy, chúng ta có thể thêm một enum PlayerState. Sau đó, tạo Trình phát mở rộng SpriteAnimationGroupComponent và chuyển PlayerState làm đối số chung. Thành phần này có trường hoạt ảnh nơi lưu trữ hoạt ảnh cho mọi PlayerState và trường hiện tại, đại diện cho trạng thái hiện tại của trình phát, cần được tạo hoạt ảnh.


 enum PlayerState { jumping, running, idle } class Player extends SpriteAnimationGroupComponent<PlayerState> { @override void onLoad() { super.onLoad(); animations = { PlayerState.running: SpriteAnimation.fromFrameData( game.images.fromCache('character/run.png'), SpriteAnimationData.sequenced( amount: 8, amountPerRow: 5, stepTime: 0.1, textureSize: Vector2(23, 34), ), ), PlayerState.idle: SpriteAnimation.fromFrameData( game.images.fromCache('character/idle.png'), SpriteAnimationData.sequenced( amount: 12, amountPerRow: 5, stepTime: 0.1, textureSize: Vector2(21, 35), ), ), PlayerState.jumping: SpriteAnimation.spriteList( [ Sprite(game.images.fromCache('character/jump.png')), Sprite(game.images.fromCache('character/land.png')), ], stepTime: 0.4, loop: false, ), }; current = PlayerState.idle; } }


Các trạng thái của người chơi đã sẵn sàng. Bây giờ, chúng ta cần cung cấp cho người chơi kích thước và vị trí trên màn hình. Tôi sẽ đặt kích thước của anh ấy thành 69x102 pixel, nhưng bạn có thể thoải mái thay đổi nó theo ý muốn. Muốn biết vị trí thì phải biết tọa độ của mặt đất. Bằng cách thêm mixin HasGameReference, chúng ta có thể truy cập vào trường nền trước và lấy tọa độ của nó. Bây giờ, hãy ghi đè phương thức onGameResize, phương thức này được gọi mỗi khi kích thước của ứng dụng thay đổi và đặt vị trí của Jack ở đó.


 class Player extends SpriteAnimationGroupComponent<PlayerState> with HasGameReference<ForestRunGame> { static const startXPosition = 80.0; Player() : super(size: Vector2(69, 102)); double get groundYPosition => game.foreground.y - height + 20; // onLoad() {...} with animation setup @override void onGameResize(Vector2 size) { super.onGameResize(size); x = startXPosition; y = groundYPosition; } }


Như đã làm trước đó, hãy thêm người chơi vào Trò chơi của chúng tôi.


 class ForestRunGame extends FlameGame { // Earlier written code here... late final player = Player(); @override Future<void> onLoad() async { // Earlier written code here... add(player); } }


Nếu bạn bắt đầu trò chơi, bạn sẽ thấy Jack đã ở trong rừng!


Chạy đi Jack, chạy đi!

Trò chơi của chúng tôi có ba trạng thái: giới thiệu, chơi và kết thúc trò chơi. Vì vậy, chúng tôi sẽ thêm enum GameState đại diện cho những cái đó. Để Jack chạy, chúng ta cần các biến tốc độ và gia tốc. Ngoài ra, chúng ta cần tính quãng đường đã đi (sẽ được sử dụng sau).


Như đã đề cập trước đó, Component có hai phương thức chính: onLoad() và update(). Chúng tôi đã sử dụng phương thức onLoad một vài lần. Bây giờ hãy nói về cập nhật(). Phương thức này có một tham số gọi là dt. Nó đại diện cho thời gian đã trôi qua kể từ lần cuối cùng update() được gọi.


Để tính tốc độ hiện tại và quãng đường đã đi, chúng ta sẽ sử dụng phương thức update() và một số công thức động học cơ bản:

  • Khoảng cách = tốc độ * thời gian;
  • Tốc độ = gia tốc * thời gian;


 enum GameState { intro, playing, gameOver } class ForestRunGame extends FlameGame { static const acceleration = 10.0; static const maxSpeed = 2000.0; static const startSpeed = 400.0; GameState state = GameState.intro; double currentSpeed = 0; double traveledDistance = 0; // Earlier written code here... @override void update(double dt) { super.update(dt); if (state == GameState.playing) { traveledDistance += currentSpeed * dt; if (currentSpeed < maxSpeed) { currentSpeed += acceleration * dt; } } } }


Thực ra chúng ta sẽ sử dụng một thủ thuật để việc phát triển trở nên đơn giản hơn: Jack sẽ đứng vững, nhưng khu rừng sẽ tiến về phía Jack. Vì vậy, chúng ta cần khu rừng của mình để áp dụng tốc độ trò chơi.


Đối với nền thị sai, chúng ta chỉ cần vượt qua tốc độ trò chơi. Và nó sẽ tự động xử lý phần còn lại.


 class ForestBackground extends ParallaxComponent<ForestRunGame> { // Earlier written code here... @override void update(double dt) { super.update(dt); parallax?.baseVelocity = Vector2(game.currentSpeed / 10, 0); } }


Đối với tiền cảnh, chúng ta cần dịch chuyển mọi khối nền. Ngoài ra, chúng ta cần kiểm tra xem khối đầu tiên trong hàng đợi đã rời khỏi màn hình hay chưa. Nếu vậy thì hãy xóa nó và đặt nó ở cuối hàng đợi;


 class ForestForeground extends PositionComponent with HasGameReference<ForestRunGame> { // Earlier written code here... @override void update(double dt) { super.update(dt); final shift = game.currentSpeed * dt; for (final block in ground) { block.x -= shift; } final firstBlock = ground.first; if (firstBlock.x <= -firstBlock.width) { firstBlock.x = ground.last.x + ground.last.width; ground.remove(firstBlock); ground.add(firstBlock); } } }


Mọi thứ đã sẵn sàng, ngoại trừ cò súng. Chúng tôi muốn bắt đầu chạy khi nhấp chuột. Mục tiêu của chúng tôi là cả thiết bị di động và máy tính để bàn, vì vậy chúng tôi muốn xử lý các sự kiện chạm vào màn hình và bàn phím.


May mắn thay, Flame có cách để làm điều đó. Chỉ cần thêm một mixin cho loại đầu vào của bạn. Đối với bàn phím, đó là KeyboardEvents và TapCallbacks để chạm vào màn hình. Những mixin đó cung cấp cho bạn khả năng ghi đè các phương thức liên quan và cung cấp logic của bạn.


Trò chơi phải bắt đầu nếu người dùng nhấn phím cách hoặc chạm vào màn hình.


 class ForestRunGame extends FlameGame with KeyboardEvents, TapCallbacks { // Earlier written code here... @override KeyEventResult onKeyEvent( RawKeyEvent event, Set<LogicalKeyboardKey> keysPressed, ) { if (keysPressed.contains(LogicalKeyboardKey.space)) { start(); } return KeyEventResult.handled; } @override void onTapDown(TapDownEvent event) { start(); } void start() { state = GameState.playing; player.current = PlayerState.running; currentSpeed = startSpeed; traveledDistance = 0; } }


Kết quả là Jack có thể chạy ngay sau khi nhấp vào.


Ôi không, một bụi cây!

Bây giờ, chúng tôi muốn có chướng ngại vật trên đường. Trong trường hợp của chúng tôi, chúng sẽ được thể hiện dưới dạng bụi cây độc. Bush không có hoạt ảnh nên chúng ta có thể sử dụng SpriteComponent. Ngoài ra, chúng tôi cần một tài liệu tham khảo trò chơi để truy cập tốc độ của nó. Và một điều nữa; chúng tôi không muốn sinh ra từng bụi cây một, bởi vì cách tiếp cận này có thể gây ra tình huống khi Jack đơn giản là không thể vượt qua hàng bụi cây bằng một cú nhảy. Đó là một số ngẫu nhiên trong phạm vi, phụ thuộc vào tốc độ trò chơi hiện tại.


 class Bush extends SpriteComponent with HasGameReference<ForestRunGame> { late double gap; Bush() : super(size: Vector2(200, 84)); bool get isVisible => x + width > 0; @override Future<void> onLoad() async { x = game.size.x + width; y = -height + 20; gap = _computeRandomGap(); sprite = Sprite(game.images.fromCache('forest/bush.png')); } double _computeRandomGap() { final minGap = width * game.currentSpeed * 100; final maxGap = minGap * 5; return (Random().nextDouble() * (maxGap - minGap + 1)).floor() + minGap; } @override void update(double dt) { super.update(dt); x -= game.currentSpeed * dt; if (!isVisible) { removeFromParent(); } } }


Ai đang trồng bụi cây? Tất nhiên là tự nhiên. Hãy tạo ra Thiên nhiên để quản lý thế hệ cây bụi của chúng ta.


 class Nature extends Component with HasGameReference<ForestRunGame> { @override void update(double dt) { super.update(dt); if (game.currentSpeed > 0) { final plant = children.query<Bush>().lastOrNull; if (plant == null || (plant.x + plant.width + plant.gap) < game.size.x) { add(Bush()); } } } }


Bây giờ, hãy thêm Thiên nhiên vào ForestForeground của chúng ta.


 class ForestForeground extends PositionComponent with HasGameReference<ForestRunGame> { // Earlier written code here... late final Nature nature; @override void onLoad() { // Earlier written code here... nature = Nature(); add(nature); }


Bây giờ, rừng của chúng tôi có bụi rậm. Nhưng chờ đã, Jack chỉ đang chạy qua họ thôi. Tại sao chuyện này đang xảy ra? Đó là do chúng ta chưa triển khai đánh.


Ở đây Hitbox sẽ giúp chúng ta. Hitbox là một thành phần khác trong kho thành phần của Flame. Nó gói gọn khả năng phát hiện va chạm và cung cấp cho bạn khả năng xử lý nó bằng logic tùy chỉnh.


Thêm một cái cho Jack. Hãy nhớ rằng vị trí của thành phần sẽ là góc trái-phải chứ không phải ở giữa. Và với kích thước, bạn xử lý phần còn lại.


 class Player extends SpriteAnimationGroupComponent<PlayerState> with HasGameReference<ForestRunGame> { // Earlier written code here... @override void onLoad() { // Earlier written code here... add( RectangleHitbox( position: Vector2(2, 2), size: Vector2(60, 100), ), ); } }


Và một cho bụi cây. Ở đây, chúng tôi sẽ đặt loại va chạm thành thụ động để tối ưu hóa một số. Theo mặc định, loại này đang hoạt động, có nghĩa là Flame sẽ kiểm tra xem hitbox này có va chạm với mọi hitbox khác hay không. Chúng tôi chỉ có một người chơi và bụi cây. Vì người chơi đã có loại va chạm chủ động và các bụi cây không thể va chạm với nhau nên chúng ta có thể đặt loại va chạm này thành bị động.


 class Bush extends SpriteComponent with HasGameReference<ForestRunGame> { // Earlier written code here... @override void onLoad() { // Earlier written code here... add( RectangleHitbox( position: Vector2(30, 30), size: Vector2(150, 54), collisionType: CollisionType.passive, ), ); } }


Thật tuyệt, nhưng tôi không biết vị trí của hitbox đã được điều chỉnh đúng chưa. Làm thế nào tôi có thể kiểm tra nó?


Chà, bạn có thể đặt trường debugMode của Player và Bush thành true. Nó sẽ cho phép bạn xem các hộp sát thương của bạn được định vị như thế nào. Màu tím xác định kích thước của thành phần và màu vàng biểu thị hitbox.


Bây giờ, chúng tôi muốn phát hiện khi có va chạm giữa người chơi và bụi cây. Để làm được điều này, bạn cần thêm mixin HasCollisionDetection vào Trò chơi, sau đó thêm CollisionCallbacks cho các thành phần cần xử lý xung đột.


 class ForestRunGame extends FlameGame with KeyboardEvents, TapCallbacks, HasCollisionDetection { // Earlier written code here... }


Hiện tại, chỉ cần tạm dừng trò chơi khi phát hiện va chạm.


 class Player extends SpriteAnimationGroupComponent<PlayerState> with HasGameReference<ForestRunGame>, CollisionCallbacks { // Earlier written code here... @override void onCollisionStart( Set<Vector2> intersectionPoints, PositionComponent other, ) { super.onCollisionStart(intersectionPoints, other); game.paused = true; } }


Nhảy hoặc chết

Nếu Jack muốn tránh những bụi cây đó, anh ấy cần phải nhảy. Hãy dạy anh ta. Đối với đặc điểm này, chúng ta cần hằng số trọng lực và tốc độ ban đầu theo phương thẳng đứng khi Jack nhảy. Những giá trị đó được chọn bằng mắt, vì vậy hãy thoải mái điều chỉnh chúng.


Vậy trọng lực hoạt động như thế nào? Về cơ bản, nó có cùng gia tốc nhưng hướng về mặt đất. Vì vậy, chúng ta có thể sử dụng các công thức tương tự cho vị trí và tốc độ thẳng đứng. Như vậy bước nhảy của chúng ta sẽ có 3 bước:

  1. Bước nhảy được kích hoạt và tốc độ dọc của Jack thay đổi từ 0 sang giá trị ban đầu.
  2. Anh ta đang di chuyển lên và trọng lực dần dần thay đổi tốc độ của anh ta. Tại một thời điểm, Jack sẽ ngừng di chuyển lên và bắt đầu di chuyển xuống.
  3. Khi Jack chạm đất, chúng ta cần ngừng tác dụng trọng lực lên anh ấy và đặt lại trạng thái của anh ấy trở lại trạng thái chạy.


 class Player extends SpriteAnimationGroupComponent<PlayerState> with HasGameReference<ForestRunGame>, CollisionCallbacks { static const gravity = 1400.0; static const initialJumpVelocity = -700.0; double jumpSpeed = 0; // Earlier written code here... void jump() { if (current != PlayerState.jumping) { current = PlayerState.jumping; jumpSpeed = initialJumpVelocity - (game.currentSpeed / 500); } } void reset() { y = groundYPos; jumpSpeed = 0; current = PlayerState.running; } @override void update(double dt) { super.update(dt); if (current == PlayerState.jumping) { y += jumpSpeed * dt; jumpSpeed += gravity * dt; if (y > groundYPos) { reset(); } } else { y = groundYPos; } } }


Và bây giờ hãy kích hoạt nhảy bằng cách nhấp chuột từ ForestRunGame


 class ForestRunGame extends FlameGame with KeyboardEvents, TapCallbacks, HasCollisionDetection { // Earlier written code here... @override KeyEventResult onKeyEvent( RawKeyEvent event, Set<LogicalKeyboardKey> keysPressed, ) { if (keysPressed.contains(LogicalKeyboardKey.space)) { onAction(); } return KeyEventResult.handled; } @override void onTapDown(TapDownEvent event) { onAction(); } void onAction() { switch (state) { case GameState.intro: case GameState.gameOver: start(); break; case GameState.playing: player.jump(); break; } } }


Bây giờ Jack có thể xử lý bụi cây.

Trò chơi kết thúc

Khi trò chơi kết thúc, chúng tôi muốn hiển thị văn bản trên màn hình. Văn bản trong Flame hoạt động khác với Flutter. Đầu tiên bạn phải tạo phông chữ. Bên trong nó chỉ là một bản đồ, trong đó char là khóa và sprite là giá trị. Hầu như luôn luôn, phông chữ của trò chơi là một hình ảnh tập hợp tất cả các ký hiệu cần thiết.


Đối với trò chơi này, chúng ta chỉ cần chữ số và chữ in hoa. Vì vậy, hãy tạo phông chữ của chúng tôi. Để làm như vậy, bạn phải chuyển hình ảnh nguồn và glyphs. Hình tượng là gì? Glyph là tập hợp thông tin về char, kích thước và vị trí của nó trong ảnh nguồn.


 class StoneText extends TextBoxComponent { static const digits = '123456789'; static const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; StoneText({ required Image source, required super.position, super.text = '', }) : super( textRenderer: SpriteFontRenderer.fromFont( SpriteFont( source: source, size: 32, ascent: 32, glyphs: [ _buildGlyph(char: '0', left: 480, top: 0), for (var i = 0; i < digits.length; i++) _buildGlyph(char: digits[i], left: 32.0 * i, top: 32), for (var i = 0; i < letters.length; i++) _buildGlyph( char: letters[i], left: 32.0 * (i % 16), top: 64.0 + 32 * (i ~/ 16), ), ], ), letterSpacing: 2, ), ); static Glyph _buildGlyph({ required String char, required double left, required double top, }) => Glyph(char, left: left, top: top, height: 32, width: 32); }


Bây giờ, chúng ta có thể tạo trò chơi trên bảng điều khiển và sử dụng nó trong trò chơi.


 class GameOverPanel extends PositionComponent with HasGameReference<ForestRunGame> { bool visible = false; @override Future<void> onLoad() async { final source = game.images.fromCache('font/keypound.png'); add(StoneText(text: 'GAME', source: source, position: Vector2(-144, -16))); add(StoneText(text: 'OVER', source: source, position: Vector2(16, -16))); } @override void renderTree(Canvas canvas) { if (visible) { super.renderTree(canvas); } } @override void onGameResize(Vector2 size) { super.onGameResize(size); x = size.x / 2; y = size.y / 2; } }


Bây giờ, chúng ta có thể hiển thị bảng điều khiển của mình khi Jack đi vào bụi rậm. Ngoài ra, hãy sửa đổi phương thức start() để chúng ta có thể khởi động lại trò chơi khi nhấp chuột. Ngoài ra, chúng ta cần phải dọn sạch tất cả các bụi cây trong rừng.


 class ForestRunGame extends FlameGame with KeyboardEvents, TapCallbacks, HasCollisionDetection { // Earlier written code here... late final gameOverPanel = GameOverPanel(); @override Future<void> onLoad() async { // Earlier written code here... add(gameOverPanel); } void gameOver() { paused = true; gameOverPanel.visible = true; state = GameState.gameOver; currentSpeed = 0; } void start() { paused = false; state = GameState.playing; currentSpeed = startSpeed; traveledDistance = 0; player.reset(); foreground.nature.removeAll(foreground.nature.children); gameOverPanel.visible = false; } }


Và bây giờ, chúng ta cần cập nhật lệnh gọi lại va chạm trong trình phát.


 class Player extends SpriteAnimationGroupComponent<PlayerState> with HasGameReference<ForestRunGame>, CollisionCallbacks { // Earlier written code here... @override void onCollisionStart( Set<Vector2> intersectionPoints, PositionComponent other, ) { super.onCollisionStart(intersectionPoints, other); game.gameOver(); } }


Bây giờ, bạn có thể thấy Game Over khi Jack đâm vào bụi rậm. Và khởi động lại trò chơi chỉ bằng cách nhấp lại.


Điểm của tôi thì sao?

Và tính toán điểm chạm cuối cùng.


 class ForestRunGame extends FlameGame with KeyboardEvents, TapCallbacks, HasCollisionDetection { late final StoneText scoreText; late final StoneText highText; late final StoneText highScoreText; int _score = 0; int _highScore = 0; // Earlier written code here... @override Future<void> onLoad() async { // Earlier written code here... final font = images.fromCache('font/keypound.png'); scoreText = StoneText(source: font, position: Vector2(20, 20)); highText = StoneText(text: 'HI', source: font, position: Vector2(256, 20)); highScoreText = StoneText( text: '00000', source: font, position: Vector2(332, 20), ); add(scoreText); add(highScoreText); add(highText); setScore(0); } void start() { // Earlier written code here... if (_score > _highScore) { _highScore = _score; highScoreText.text = _highScore.toString().padLeft(5, '0'); } _score = 0; } @override void update(double dt) { super.update(dt); if (state == GameState.playing) { traveledDistance += currentSpeed * dt; setScore(traveledDistance ~/ 50); if (currentSpeed < maxSpeed) { currentSpeed += acceleration * dt; } } } void setScore(int score) { _score = score; scoreText.text = _score.toString().padLeft(5, '0'); } }


Đó là tất cả mọi người!


Bây giờ, hãy thử và cố gắng đánh bại số điểm cao của tôi. Đó là 2537 điểm!

Kết luận

Đó là rất nhiều, nhưng chúng tôi đã làm được. Chúng tôi đã tạo ra một sản phẩm khả thi tối thiểu cho một trò chơi di động có tính chất vật lý, hoạt ảnh, tính điểm, v.v. Luôn có chỗ để cải tiến và giống như bất kỳ MVP nào khác, sản phẩm của chúng tôi có thể được mong đợi với các tính năng, cơ chế và chế độ trò chơi mới trong tương lai.


Ngoài ra, còn có gói flame_audio, bạn có thể sử dụng gói này để thêm một số nhạc nền, âm thanh nhảy hoặc đánh, v.v.


Hiện tại, mục tiêu chính của chúng tôi là tạo ra chức năng cơ bản của sản phẩm trong thời gian ngắn và với mức phân bổ nguồn lực hạn chế. Sự kết hợp giữa Flutter và Flame được chứng minh là hoàn toàn phù hợp để xây dựng MVP trò chơi có thể được sử dụng để thu thập phản hồi của người dùng và tiếp tục nâng cấp ứng dụng trong tương lai.


Bạn có thể kiểm tra kết quả nỗ lực của chúng tôi ở đây .


Với các tính năng mạnh mẽ, dễ sử dụng và cộng đồng phát triển mạnh mẽ, Flutter and Flame là sự lựa chọn hấp dẫn cho các nhà phát triển trò chơi đầy tham vọng. Cho dù bạn là một chuyên gia dày dạn kinh nghiệm hay mới bắt đầu, sự kết hợp này sẽ cung cấp các công cụ và tiềm năng để biến ý tưởng trò chơi của bạn thành hiện thực. Vì vậy, hãy phát huy khả năng sáng tạo của bạn, hòa mình vào thế giới Flutter và Flame và bắt đầu xây dựng cảm giác chơi game trên thiết bị di động tiếp theo!


Chúng tôi hy vọng bạn thấy bài viết này thú vị và mang tính thông tin. Nếu bạn muốn hiểu rõ hơn về phát triển phần mềm hoặc muốn thảo luận về dự án MVP của riêng mình, đừng ngần ngại khám phá Leobit hoặc liên hệ với nhóm kỹ thuật của chúng tôi!