Tạo trò chơi vật lý 2D bằng Flutter và Flame

Xây dựng trò chơi vật lý 2D bằng Flutter và Flame

Thông tin về lớp học lập trình này

subjectLần cập nhật gần đây nhất: thg 6 23, 2025
account_circleTác giả: Brett Morgan

1. Trước khi bắt đầu

Flame là một công cụ phát triển trò chơi 2D dựa trên Flutter. Trong lớp học lập trình này, bạn sẽ xây dựng một trò chơi sử dụng mô phỏng vật lý 2D theo dòng Box2D có tên là Forge2D. Bạn sử dụng các thành phần của Flame để vẽ thực tế vật lý được mô phỏng trên màn hình để người dùng chơi. Khi hoàn tất, trò chơi của bạn sẽ có dạng như ảnh gif động sau:

Ảnh động của trò chơi với trò chơi vật lý 2D này

Điều kiện tiên quyết

Kiến thức bạn sẽ học được

  • Cách hoạt động cơ bản của Forge2D, bắt đầu với các loại vật thể thực tế.
  • Cách thiết lập mô phỏng vật lý ở dạng 2D.

Bạn cần có

Phần mềm biên dịch cho mục tiêu phát triển mà bạn đã chọn. Lớp học lập trình này hoạt động trên cả 6 nền tảng mà Flutter hỗ trợ. Bạn cần Visual Studio để nhắm đến Windows, Xcode để nhắm đến macOS hoặc iOS và Android Studio để nhắm đến Android.

2. Tạo một dự án

Tạo dự án Flutter

Có nhiều cách để tạo dự án Flutter. Trong phần này, bạn sẽ sử dụng dòng lệnh để viết ngắn gọn.

Để bắt đầu, hãy làm theo các bước sau:

  1. Trên dòng lệnh, hãy tạo một dự án Flutter:
    $ flutter create --empty forge2d_game
    Creating project forge2d_game...
    Resolving dependencies in forge2d_game... (4.7s)
    Got dependencies in forge2d_game.
    Wrote 128 files.
    
    All done!
    You can find general documentation for Flutter at: https://docs.flutter.dev/
    Detailed API documentation is available at: https://api.flutter.dev/
    If you prefer video documentation, consider: https://www.youtube.com/c/flutterdev
    
    In order to run your empty application, type:
    
      $ cd forge2d_game
      $ flutter run
    
    Your empty application code is in forge2d_game/lib/main.dart.
    
  2. Sửa đổi các phần phụ thuộc của dự án để thêm Flame và Forge2D:
    $ cd forge2d_game
    $ flutter pub add characters flame flame_forge2d flame_kenney_xml xml
    Resolving dependencies...
    Downloading packages...
      characters 1.4.0 (from transitive dependency to direct dependency)
    + flame 1.29.0
    + flame_forge2d 0.19.0+2
    + flame_kenney_xml 0.1.1+12
      flutter_lints 5.0.0 (6.0.0 available)
    + forge2d 0.14.0
      leak_tracker 10.0.9 (11.0.1 available)
      leak_tracker_flutter_testing 3.0.9 (3.0.10 available)
      leak_tracker_testing 3.0.1 (3.0.2 available)
      lints 5.1.1 (6.0.0 available)
      material_color_utilities 0.11.1 (0.13.0 available)
      meta 1.16.0 (1.17.0 available)
    + ordered_set 8.0.0
    + petitparser 6.1.0 (7.0.0 available)
      test_api 0.7.4 (0.7.6 available)
      vector_math 2.1.4 (2.2.0 available)
      vm_service 15.0.0 (15.0.2 available)
    + xml 6.5.0 (6.6.0 available)
    Changed 8 dependencies!
    12 packages have newer versions incompatible with dependency constraints.
    Try `flutter pub outdated` for more information.
    

Bạn đã quen thuộc với gói flame, nhưng ba gói còn lại có thể cần giải thích. Gói characters được dùng để thao tác với đường dẫn theo cách tuân thủ UTF8. Gói flame_forge2d hiển thị chức năng Forge2D theo cách hoạt động tốt với Flame. Cuối cùng, gói xml được dùng ở nhiều nơi để sử dụng và sửa đổi nội dung XML.

Mở dự án rồi thay thế nội dung của tệp lib/main.dart bằng nội dung sau:

lib/main.dart

import 'package:flame/game.dart';
import 'package:flutter/material.dart';

void main() {
 
runApp(GameWidget.controlled(gameFactory: FlameGame.new));
}

Thao tác này sẽ khởi động ứng dụng bằng GameWidget tạo bản sao của thực thể FlameGame. Không có mã Flutter nào trong lớp học lập trình này sử dụng trạng thái của thực thể trò chơi để hiển thị thông tin về trò chơi đang chạy, vì vậy, tính năng khởi động đơn giản này hoạt động tốt.

Không bắt buộc: Tham gia một nhiệm vụ phụ chỉ dành cho macOS

Ảnh chụp màn hình trong dự án này là của trò chơi dưới dạng ứng dụng dành cho máy tính macOS. Để tránh thanh tiêu đề của ứng dụng làm giảm trải nghiệm tổng thể, bạn có thể sửa đổi cấu hình dự án của trình chạy macOS để xoá thanh tiêu đề.

Để thực hiện việc này, hãy làm theo các bước sau:

  1. Tạo tệp bin/modify_macos_config.dart rồi thêm nội dung sau:

bin/modify_macos_config.dart

import 'dart:io';
import 'package:xml/xml.dart';
import 'package:xml/xpath.dart';

void main() {
 
final file = File('macos/Runner/Base.lproj/MainMenu.xib');
 
var document = XmlDocument.parse(file.readAsStringSync());
 
document.xpath('//document/objects/window').first
   
..setAttribute('titlebarAppearsTransparent', 'YES')
   
..setAttribute('titleVisibility', 'hidden');
 
document
     
.xpath('//document/objects/window/windowStyleMask')
     
.first
     
.setAttribute('fullSizeContentView', 'YES');
 
file.writeAsStringSync(document.toString());
}

Tệp này không nằm trong thư mục lib vì không phải là một phần của cơ sở mã thời gian chạy cho trò chơi. Đây là một công cụ dòng lệnh dùng để sửa đổi dự án.

  1. Từ thư mục cơ sở của dự án, hãy chạy công cụ như sau:
dart bin/modify_macos_config.dart

Nếu mọi thứ diễn ra theo kế hoạch, chương trình sẽ không tạo ra kết quả nào trên dòng lệnh. Tuy nhiên, tệp này sẽ sửa đổi tệp cấu hình macos/Runner/Base.lproj/MainMenu.xib để chạy trò chơi mà không có thanh tiêu đề hiển thị và trò chơi Flame chiếm toàn bộ cửa sổ.

Chạy trò chơi để xác minh mọi thứ đều hoạt động. Thao tác này sẽ hiển thị một cửa sổ mới chỉ có nền đen trống.

Cửa sổ ứng dụng có nền màu đen và không có gì ở nền trước

3. Thêm thành phần hình ảnh

Thêm hình ảnh

Mọi trò chơi đều cần có tài sản nghệ thuật để có thể vẽ màn hình theo cách thú vị. Lớp học lập trình này sẽ sử dụng gói Physics Assets (Tài sản vật lý) của Kenney.nl. Những tài sản này được cấp phép theo Creative Commons CC0, nhưng bạn vẫn nên quyên góp cho nhóm Kenney để họ có thể tiếp tục công việc tuyệt vời mà họ đang làm. Tôi đã làm.

Bạn sẽ cần sửa đổi tệp cấu hình pubspec.yaml để bật tính năng sử dụng các thành phần của Kenney. Sửa đổi như sau:

pubspec.yaml

name: forge2d_game
description: "A new Flutter project."
publish_to: 'none'
version: 0.1.0

environment:
  sdk: ^3.8.1

dependencies:
  flutter:
    sdk: flutter
  characters: ^1.4.0
  flame: ^1.29.0
  flame_forge2d: ^0.19.0+2
  flame_kenney_xml: ^0.1.1+12
  xml: ^6.5.0

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^5.0.0

flutter:
  uses-material-design: true
  assets:                        # Add from here
    - assets/
    - assets/images/             # To here.

Flame dự kiến các thành phần hình ảnh sẽ nằm trong assets/images, mặc dù bạn có thể định cấu hình khác. Hãy xem tài liệu về Hình ảnh của Flame để biết thêm thông tin chi tiết. Giờ đây, khi đã định cấu hình các đường dẫn, bạn cần thêm các đường dẫn đó vào chính dự án. Một cách để thực hiện việc này là sử dụng dòng lệnh như sau:

mkdir -p assets/images

Không có kết quả nào từ lệnh mkdir, nhưng thư mục mới sẽ hiển thị trong trình soạn thảo hoặc trình khám phá tệp.

Mở rộng tệp kenney_physics-assets.zip mà bạn đã tải xuống, bạn sẽ thấy nội dung như sau:

Danh sách tệp của gói tài sản kenney_physics-assets được mở rộng, với thư mục PNG/Backgrounds được làm nổi bật

Từ thư mục PNG/Backgrounds, hãy sao chép các tệp colored_desert.png, colored_grass.png, colored_land.pngcolored_shroom.png vào thư mục assets/images của dự án.

Ngoài ra còn có các trang tính sprite. Đây là sự kết hợp giữa một hình ảnh PNG và một tệp XML mô tả vị trí có thể tìm thấy hình ảnh nhỏ hơn trong hình ảnh của bảng ảnh động. Trangg sprite là một kỹ thuật giúp giảm thời gian tải bằng cách chỉ tải một tệp duy nhất thay vì hàng chục, nếu không phải hàng trăm tệp hình ảnh riêng lẻ.

Danh sách tệp của gói tài sản kenney_physics-assets được mở rộng, với thư mục Spritesheet được làm nổi bật

Sao chép spritesheet_aliens.png, spritesheet_elements.pngspritesheet_tiles.png vào thư mục assets/images của dự án. Trong khi ở đây, hãy sao chép các tệp spritesheet_aliens.xml, spritesheet_elements.xmlspritesheet_tiles.xml vào thư mục assets của dự án. Dự án của bạn sẽ có dạng như sau.

Danh sách tệp của thư mục dự án forge2d_game, trong đó thư mục tài sản được làm nổi bật

Vẽ nền

Giờ đây, dự án của bạn đã có các thành phần hình ảnh, đã đến lúc đặt các thành phần đó lên màn hình. Vâng, một hình ảnh trên màn hình. Chúng ta sẽ tìm hiểu thêm trong các bước sau.

Tạo một tệp có tên background.dart trong thư mục mới có tên lib/components rồi thêm nội dung sau.

lib/components/background.dart

import 'dart:math';
import 'package:flame/components.dart';
import 'game.dart';

class Background extends SpriteComponent with HasGameReference<MyPhysicsGame> {
 
Background({required super.sprite})
   
: super(anchor: Anchor.center, position: Vector2(0, 0));

 
@override
 
void onMount() {
   
super.onMount();

   
size = Vector2.all(
     
max(
       
game.camera.visibleWorldRect.width,
       
game.camera.visibleWorldRect.height,
     
),
   
);
 
}
}

Thành phần này là một SpriteComponent chuyên biệt. Thành phần này chịu trách nhiệm hiển thị một trong 4 hình nền của Kenney.nl. Có một vài giả định đơn giản trong mã này. Thứ nhất, hình ảnh phải có dạng hình vuông, cả 4 hình nền của Kenney đều có dạng hình vuông. Thứ hai là kích thước của thế giới hiển thị sẽ không bao giờ thay đổi, nếu không thành phần này sẽ cần xử lý các sự kiện đổi kích thước trò chơi. Giả định thứ ba là vị trí (0,0) sẽ ở giữa màn hình. Những giả định này yêu cầu cấu hình cụ thể của CameraComponent của trò chơi.

Tạo một tệp mới khác, tệp này có tên là game.dart, một lần nữa trong thư mục lib/components.

lib/components/game.dart

import 'dart:async';

import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flame_kenney_xml/flame_kenney_xml.dart';

import 'background.dart';

class MyPhysicsGame extends Forge2DGame {
 
MyPhysicsGame()
   
: super(
       
gravity: Vector2(0, 10),
       
camera: CameraComponent.withFixedResolution(width: 800, height: 600),
     
);

 
late final XmlSpriteSheet aliens;
 
late final XmlSpriteSheet elements;
 
late final XmlSpriteSheet tiles;

 
@override
 
FutureOr<void> onLoad() async {
   
final backgroundImage = await images.load('colored_grass.png');
   
final spriteSheets = await Future.wait([
     
XmlSpriteSheet.load(
       
imagePath: 'spritesheet_aliens.png',
       
xmlPath: 'spritesheet_aliens.xml',
     
),
     
XmlSpriteSheet.load(
       
imagePath: 'spritesheet_elements.png',
       
xmlPath: 'spritesheet_elements.xml',
     
),
     
XmlSpriteSheet.load(
       
imagePath: 'spritesheet_tiles.png',
       
xmlPath: 'spritesheet_tiles.xml',
     
),
   
]);

   
aliens = spriteSheets[0];
   
elements = spriteSheets[1];
   
tiles = spriteSheets[2];

   
await world.add(Background(sprite: Sprite(backgroundImage)));

   
return super.onLoad();
 
}
}

Có rất nhiều điều đang diễn ra ở đây. Bắt đầu với lớp MyPhysicsGame. Không giống như lớp học lập trình trước, lớp học này mở rộng Forge2DGame chứ không phải FlameGame. Bản thân Forge2DGame mở rộng FlameGame với một số điều chỉnh thú vị. Thứ nhất, theo mặc định, zoom được đặt thành 10. Chế độ cài đặt zoom này liên quan đến phạm vi giá trị hữu ích mà các công cụ mô phỏng vật lý kiểu Box2D hoạt động hiệu quả. Công cụ này được viết bằng hệ MKS, trong đó các đơn vị được giả định là mét, kilôgam và giây. Phạm vi mà bạn không thấy lỗi toán học đáng kể đối với các đối tượng là từ 0,1 mét đến hàng chục mét. Việc truyền trực tiếp kích thước pixel mà không có mức tỷ lệ giảm nào sẽ khiến Forge2D nằm ngoài phạm vi hữu ích. Tóm tắt hữu ích là hãy nghĩ đến việc mô phỏng các đối tượng trong phạm vi từ một lon nước ngọt đến một chiếc xe buýt.

Các giả định được đưa ra trong thành phần Nền được đáp ứng tại đây bằng cách cố định độ phân giải của CameraComponent thành 800x600 pixel ảo. Điều này có nghĩa là khu vực trò chơi sẽ có chiều rộng 80 đơn vị và chiều cao 60 đơn vị, được căn giữa tại (0,0). Điều này không ảnh hưởng đến độ phân giải hiển thị, nhưng sẽ ảnh hưởng đến vị trí chúng ta đặt các đối tượng trong cảnh trò chơi.

Bên cạnh đối số hàm khởi tạo camera, còn có một đối số khác phù hợp hơn với vật lý có tên là gravity. Trọng lực được đặt thành Vector2 với x0y10. 10 là giá trị gần đúng của giá trị 9,81 mét/giây/giây được chấp nhận chung cho trọng lực. Việc đặt trọng lực thành 10 dương cho biết rằng trong hệ thống này, hướng của trục Y là xuống. Khác với Box2D nói chung, nhưng phù hợp với cách Flame thường được định cấu hình.

Tiếp theo là phương thức onLoad. Phương thức này không đồng bộ, phù hợp vì chịu trách nhiệm tải các thành phần hình ảnh từ ổ đĩa. Các lệnh gọi đến images.load sẽ trả về một Future<Image> và lưu vào bộ nhớ đệm hình ảnh đã tải trong đối tượng Trò chơi dưới dạng hiệu ứng phụ. Các tương lai này được tập hợp lại và chờ đợi dưới dạng một đơn vị duy nhất bằng cách sử dụng phương thức tĩnh Futures.wait. Sau đó, danh sách hình ảnh được trả về sẽ được so khớp mẫu thành tên riêng lẻ.

Sau đó, hình ảnh trong trangg sprite được đưa vào một loạt đối tượng XmlSpriteSheet chịu trách nhiệm truy xuất các Sprite được đặt tên riêng lẻ có trong trangg sprite. Lớp XmlSpriteSheet được xác định trong gói flame_kenney_xml.

Sau khi hoàn tất tất cả những việc đó, bạn chỉ cần chỉnh sửa một vài điểm nhỏ đối với lib/main.dart để có hình ảnh trên màn hình.

lib/main.dart

import 'package:flame/game.dart';
import 'package:flutter/material.dart';

import 'components/game.dart';                                    // Add this import

void main() {
 
runApp(GameWidget.controlled(gameFactory: MyPhysicsGame.new));  // Modify this line
}

Với thay đổi này, bạn có thể chạy lại trò chơi để xem nền trên màn hình. Lưu ý: thực thể máy ảnh CameraComponent.withFixedResolution() sẽ thêm hiệu ứng hòm thư theo yêu cầu để tỷ lệ 800x600 của trò chơi hoạt động.

Một ứng dụng có những ngọn đồi xanh ngút ngàn và những cây trừu tượng kỳ lạ.

4. Thêm mặt đất

Một nền tảng để phát triển

Nếu có trọng lực, chúng ta cần một thứ gì đó để bắt các đối tượng trong trò chơi trước khi chúng rơi xuống cuối màn hình. Tất nhiên, trừ phi việc rơi khỏi màn hình là một phần trong thiết kế trò chơi của bạn. Tạo một tệp ground.dart mới trong thư mục lib/components rồi thêm nội dung sau vào tệp đó:

lib/components/ground.dart

import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';

const groundSize = 7.0;

class Ground extends BodyComponent {
 
Ground(Vector2 position, Sprite sprite)
   
: super(
       
renderBody: false,
       
bodyDef: BodyDef()
         
..position = position
         
..type = BodyType.static,
       
fixtureDefs: [
         
FixtureDef(
           
PolygonShape()..setAsBoxXY(groundSize / 2, groundSize / 2),
           
friction: 0.3,
         
),
       
],
       
children: [
         
SpriteComponent(
           
anchor: Anchor.center,
           
sprite: sprite,
           
size: Vector2.all(groundSize),
           
position: Vector2(0, 0),
         
),
       
],
     
);
}

Thành phần Ground này bắt nguồn từ BodyComponent. Trong Forge2D, các vật thể rất quan trọng, đó là các đối tượng thuộc quá trình mô phỏng vật lý hai chiều. BodyDef cho thành phần này được chỉ định là có BodyType.static.

Trong Forge2D, các phần tử có 3 loại khác nhau. Thân tĩnh không di chuyển. Các hạt này có cả khối lượng bằng 0 (không phản ứng với trọng lực) và khối lượng vô hạn (không di chuyển khi bị các vật thể khác va vào, bất kể các vật thể đó nặng như thế nào). Điều này khiến các vật thể tĩnh trở nên hoàn hảo cho bề mặt đất, vì nó không di chuyển.

Hai loại cơ thể khác là động học và động lực học. Cơ thể động là các cơ thể được mô phỏng hoàn toàn, chúng phản ứng với trọng lực và các vật thể mà chúng va vào. Bạn sẽ thấy nhiều phần thân động trong phần còn lại của lớp học lập trình này. Cơ thể động học là một phần trung gian giữa tĩnh và động. Các đối tượng này di chuyển nhưng không phản ứng với trọng lực hoặc các đối tượng khác va vào chúng. Rất hữu ích, nhưng nằm ngoài phạm vi của lớp học lập trình này.

Phần thân không làm được nhiều việc. Một cơ thể cần có các hình dạng liên kết để có chất. Trong trường hợp này, phần nội dung này có một hình dạng được liên kết, PolygonShape được đặt làm BoxXY. Loại hộp này được căn chỉnh theo trục với thế giới, không giống như PolygonShape được đặt làm BoxXY có thể xoay quanh một điểm xoay. Một lần nữa, đây là một tính năng hữu ích nhưng cũng nằm ngoài phạm vi của lớp học lập trình này. Hình dạng và phần thân được gắn với nhau bằng một cố định, rất hữu ích khi thêm các nội dung như friction vào hệ thống.

Theo mặc định, phần thân sẽ hiển thị các hình dạng đính kèm theo cách hữu ích cho việc gỡ lỗi, nhưng không tạo ra trải nghiệm chơi trò chơi tuyệt vời. Việc đặt đối số super renderBody thành false sẽ tắt tính năng kết xuất gỡ lỗi này. Việc cung cấp bản kết xuất trong trò chơi cho phần thân này là trách nhiệm của SpriteComponent con.

Để thêm thành phần Ground vào trò chơi, hãy chỉnh sửa tệp game.dart như sau.

lib/components/game.dart

import 'dart:async';

import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flame_kenney_xml/flame_kenney_xml.dart';

import 'background.dart';
import 'ground.dart';                                      // Add this import

class MyPhysicsGame extends Forge2DGame {
 
MyPhysicsGame()
   
: super(
       
gravity: Vector2(0, 10),
       
camera: CameraComponent.withFixedResolution(width: 800, height: 600),
     
);

 
late final XmlSpriteSheet aliens;
 
late final XmlSpriteSheet elements;
 
late final XmlSpriteSheet tiles;

 
@override
 
FutureOr<void> onLoad() async {
   
final backgroundImage = await images.load('colored_grass.png');
   
final spriteSheets = await Future.wait([
     
XmlSpriteSheet.load(
       
imagePath: 'spritesheet_aliens.png',
       
xmlPath: 'spritesheet_aliens.xml',
     
),
     
XmlSpriteSheet.load(
       
imagePath: 'spritesheet_elements.png',
       
xmlPath: 'spritesheet_elements.xml',
     
),
     
XmlSpriteSheet.load(
       
imagePath: 'spritesheet_tiles.png',
       
xmlPath: 'spritesheet_tiles.xml',
     
),
   
]);

   
aliens = spriteSheets[0];
   
elements = spriteSheets[1];
   
tiles = spriteSheets[2];

   
await world.add(Background(sprite: Sprite(backgroundImage)));
   
await addGround();                                     // Add this line

   
return super.onLoad();
 
}

 
Future<void> addGround() {                               // Add from here...
   
return world.addAll([
     
for (
       
var x = camera.visibleWorldRect.left;
       
x < camera.visibleWorldRect.right + groundSize;
       
x += groundSize
     
)
       
Ground(
         
Vector2(x, (camera.visibleWorldRect.height - groundSize) / 2),
         
tiles.getSprite('grass.png'),
       
),
   
]);
 
}                                                        // To here.
}

Nội dung chỉnh sửa này sẽ thêm một loạt thành phần Ground vào thế giới bằng cách sử dụng vòng lặp for bên trong ngữ cảnh List và truyền danh sách thành phần Ground thu được vào phương thức addAll của world.

Giờ đây, khi chạy trò chơi, bạn sẽ thấy nền và mặt đất.

Cửa sổ ứng dụng có nền và lớp nền.

5. Thêm các viên gạch

Xây tường

Mặt đất là một ví dụ về một vật thể tĩnh. Bây giờ, đã đến lúc tạo thành phần động đầu tiên. Các thành phần động trong Forge2D là nền tảng của trải nghiệm người chơi, đó là những thứ di chuyển và tương tác với thế giới xung quanh. Trong bước này, bạn sẽ giới thiệu các khối (brick) được chọn ngẫu nhiên để xuất hiện trên màn hình trong một cụm các khối. Bạn sẽ thấy các quả bóng rơi xuống và va vào nhau trong quá trình này.

Các viên gạch sẽ được tạo từ trang tính sprite của các phần tử. Nếu xem phần mô tả của trang sprite trong assets/spritesheet_elements.xml, bạn sẽ thấy chúng ta có một vấn đề thú vị. Tên này có vẻ không hữu ích lắm. Sẽ rất hữu ích nếu bạn có thể chọn một viên gạch theo loại vật liệu, kích thước và mức độ hư hỏng. Rất may, một chú lùn tốt bụng đã dành thời gian tìm hiểu mẫu trong cách đặt tên tệp và tạo một công cụ để giúp bạn dễ dàng hơn. Tạo tệp mới generate_brick_file_names.dart trong thư mục bin rồi thêm nội dung sau:

bin/generate_brick_file_names.dart

import 'dart:io';
import 'package:equatable/equatable.dart';
import 'package:xml/xml.dart';
import 'package:xml/xpath.dart';

void main() {
 
final file = File('assets/spritesheet_elements.xml');
 
final rects = <String, Rect>{};
 
final document = XmlDocument.parse(file.readAsStringSync());
 
for (final node in document.xpath('//TextureAtlas/SubTexture')) {
   
final name = node.getAttribute('name')!;
   
rects[name] = Rect(
     
x: int.parse(node.getAttribute('x')!),
     
y: int.parse(node.getAttribute('y')!),
     
width: int.parse(node.getAttribute('width')!),
     
height: int.parse(node.getAttribute('height')!),
   
);
 
}
 
print(generateBrickFileNames(rects));
}

class Rect extends Equatable {
 
final int x;
 
final int y;
 
final int width;
 
final int height;
 
const Rect({
   
required this.x,
   
required this.y,
   
required this.width,
   
required this.height,
 
});

 
Size get size => Size(width, height);

 
@override
 
List<Object?> get props => [x, y, width, height];

 
@override
 
bool get stringify => true;
}

class Size extends Equatable {
 
final int width;
 
final int height;
 
const Size(this.width, this.height);

 
@override
 
List<Object?> get props => [width, height];

 
@override
 
bool get stringify => true;
}

String generateBrickFileNames(Map<String, Rect> rects) {
 
final groups = <Size, List<String>>{};
 
for (final entry in rects.entries) {
   
groups.putIfAbsent(entry.value.size, () => []).add(entry.key);
 
}
 
final buff = StringBuffer();
 
buff.writeln('''
Map<BrickDamage, String> brickFileNames(BrickType type, BrickSize size) {
  return switch ((type, size)) {''');
 
for (final entry in groups.entries) {
   
final size = entry.key;
   
final entries = entry.value;
   
entries.sort();
   
for (final type in ['Explosive', 'Glass', 'Metal', 'Stone', 'Wood']) {
     
var filtered = entries.where((element) => element.contains(type));
     
if (filtered.length == 5) {
       
buff.writeln('''
    (BrickType.${type.toLowerCase()}, BrickSize.size${size.width}x${size.height}) => {
        BrickDamage.none: '${filtered.elementAt(0)}',
        BrickDamage.some: '${filtered.elementAt(1)}',
        BrickDamage.lots: '${filtered.elementAt(4)}',
      },''');
     
} else if (filtered.length == 10) {
       
buff.writeln('''
    (BrickType.${type.toLowerCase()}, BrickSize.size${size.width}x${size.height}) => {
        BrickDamage.none: '${filtered.elementAt(3)}',
        BrickDamage.some: '${filtered.elementAt(4)}',
        BrickDamage.lots: '${filtered.elementAt(9)}',
      },''');
     
} else if (filtered.length == 15) {
       
buff.writeln('''
    (BrickType.${type.toLowerCase()}, BrickSize.size${size.width}x${size.height}) => {
        BrickDamage.none: '${filtered.elementAt(7)}',
        BrickDamage.some: '${filtered.elementAt(8)}',
        BrickDamage.lots: '${filtered.elementAt(13)}',
      },''');
     
}
   
}
 
}
 
buff.writeln('''
  };
}''');
 
return buff.toString();
}

Trình chỉnh sửa sẽ đưa ra cảnh báo hoặc lỗi về phần phụ thuộc bị thiếu. Thêm bằng lệnh sau:

flutter pub add equatable

Bây giờ, bạn có thể chạy chương trình này như sau:

$ dart run bin/generate_brick_file_names.dart
Map<BrickDamage, String> brickFileNames(BrickType type, BrickSize size) {
  return switch ((type, size)) {
    (BrickType.explosive, BrickSize.size140x70) => {
        BrickDamage.none: 'elementExplosive009.png',
        BrickDamage.some: 'elementExplosive012.png',
        BrickDamage.lots: 'elementExplosive050.png',
      },
    (BrickType.glass, BrickSize.size140x70) => {
        BrickDamage.none: 'elementGlass010.png',
        BrickDamage.some: 'elementGlass013.png',
        BrickDamage.lots: 'elementGlass048.png',
      },
[Content elided...]
    (BrickType.wood, BrickSize.size140x220) => {
        BrickDamage.none: 'elementWood020.png',
        BrickDamage.some: 'elementWood025.png',
        BrickDamage.lots: 'elementWood052.png',
      },
  };
}

Công cụ này đã giúp phân tích cú pháp tệp mô tả trang ảnh động và chuyển đổi tệp đó thành mã Dart mà chúng ta có thể sử dụng để chọn tệp hình ảnh phù hợp cho từng viên gạch mà bạn muốn đặt trên màn hình. Rất hữu ích!

Tạo tệp brick.dart có nội dung sau:

lib/components/brick.dart

import 'dart:math';
import 'dart:ui' as ui;

import 'package:flame/components.dart';
import 'package:flame/extensions.dart';
import 'package:flame_forge2d/flame_forge2d.dart';

const brickScale = 0.5;

enum BrickType {
 
explosive(density: 1, friction: 0.5),
 
glass(density: 0.5, friction: 0.2),
 
metal(density: 1, friction: 0.4),
 
stone(density: 2, friction: 1),
 
wood(density: 0.25, friction: 0.6);

 
final double density;
 
final double friction;

 
const BrickType({required this.density, required this.friction});
 
static BrickType get randomType => values[Random().nextInt(values.length)];
}

enum BrickSize {
 
size70x70(ui.Size(70, 70)),
 
size140x70(ui.Size(140, 70)),
 
size220x70(ui.Size(220, 70)),
 
size70x140(ui.Size(70, 140)),
 
size140x140(ui.Size(140, 140)),
 
size220x140(ui.Size(220, 140)),
 
size140x220(ui.Size(140, 220)),
 
size70x220(ui.Size(70, 220));

 
final ui.Size size;

 
const BrickSize(this.size);

 
static BrickSize get randomSize => values[Random().nextInt(values.length)];
}

enum BrickDamage { none, some, lots }

Map<BrickDamage, String> brickFileNames(BrickType type, BrickSize size) {
 
return switch ((type, size)) {
   
(BrickType.explosive, BrickSize.size140x70) => {
     
BrickDamage.none: 'elementExplosive009.png',
     
BrickDamage.some: 'elementExplosive012.png',
     
BrickDamage.lots: 'elementExplosive050.png',
   
},
   
(BrickType.glass, BrickSize.size140x70) => {
     
BrickDamage.none: 'elementGlass010.png',
     
BrickDamage.some: 'elementGlass013.png',
     
BrickDamage.lots: 'elementGlass048.png',
   
},
   
(BrickType.metal, BrickSize.size140x70) => {
     
BrickDamage.none: 'elementMetal009.png',
     
BrickDamage.some: 'elementMetal012.png',
     
BrickDamage.lots: 'elementMetal050.png',
   
},
   
(BrickType.stone, BrickSize.size140x70) => {
     
BrickDamage.none: 'elementStone009.png',
     
BrickDamage.some: 'elementStone012.png',
     
BrickDamage.lots: 'elementStone047.png',
   
},
   
(BrickType.wood, BrickSize.size140x70) => {
     
BrickDamage.none: 'elementWood011.png',
     
BrickDamage.some: 'elementWood014.png',
     
BrickDamage.lots: 'elementWood054.png',
   
},
   
(BrickType.explosive, BrickSize.size70x70) => {
     
BrickDamage.none: 'elementExplosive011.png',
     
BrickDamage.some: 'elementExplosive014.png',
     
BrickDamage.lots: 'elementExplosive049.png',
   
},
   
(BrickType.glass, BrickSize.size70x70) => {
     
BrickDamage.none: 'elementGlass011.png',
     
BrickDamage.some: 'elementGlass012.png',
     
BrickDamage.lots: 'elementGlass046.png',
   
},
   
(BrickType.metal, BrickSize.size70x70) => {
     
BrickDamage.none: 'elementMetal011.png',
     
BrickDamage.some: 'elementMetal014.png',
     
BrickDamage.lots: 'elementMetal049.png',
   
},
   
(BrickType.stone, BrickSize.size70x70) => {
     
BrickDamage.none: 'elementStone011.png',
     
BrickDamage.some: 'elementStone014.png',
     
BrickDamage.lots: 'elementStone046.png',
   
},
   
(BrickType.wood, BrickSize.size70x70) => {
     
BrickDamage.none: 'elementWood010.png',
     
BrickDamage.some: 'elementWood013.png',
     
BrickDamage.lots: 'elementWood045.png',
   
},
   
(BrickType.explosive, BrickSize.size220x70) => {
     
BrickDamage.none: 'elementExplosive013.png',
     
BrickDamage.some: 'elementExplosive016.png',
     
BrickDamage.lots: 'elementExplosive051.png',
   
},
   
(BrickType.glass, BrickSize.size220x70) => {
     
BrickDamage.none: 'elementGlass014.png',
     
BrickDamage.some: 'elementGlass017.png',
     
BrickDamage.lots: 'elementGlass049.png',
   
},
   
(BrickType.metal, BrickSize.size220x70) => {
     
BrickDamage.none: 'elementMetal013.png',
     
BrickDamage.some: 'elementMetal016.png',
     
BrickDamage.lots: 'elementMetal051.png',
   
},
   
(BrickType.stone, BrickSize.size220x70) => {
     
BrickDamage.none: 'elementStone013.png',
     
BrickDamage.some: 'elementStone016.png',
     
BrickDamage.lots: 'elementStone048.png',
   
},
   
(BrickType.wood, BrickSize.size220x70) => {
     
BrickDamage.none: 'elementWood012.png',
     
BrickDamage.some: 'elementWood015.png',
     
BrickDamage.lots: 'elementWood047.png',
   
},
   
(BrickType.explosive, BrickSize.size70x140) => {
     
BrickDamage.none: 'elementExplosive017.png',
     
BrickDamage.some: 'elementExplosive022.png',
     
BrickDamage.lots: 'elementExplosive052.png',
   
},
   
(BrickType.glass, BrickSize.size70x140) => {
     
BrickDamage.none: 'elementGlass018.png',
     
BrickDamage.some: 'elementGlass023.png',
     
BrickDamage.lots: 'elementGlass050.png',
   
},
   
(BrickType.metal, BrickSize.size70x140) => {
     
BrickDamage.none: 'elementMetal017.png',
     
BrickDamage.some: 'elementMetal022.png',
     
BrickDamage.lots: 'elementMetal052.png',
   
},
   
(BrickType.stone, BrickSize.size70x140) => {
     
BrickDamage.none: 'elementStone017.png',
     
BrickDamage.some: 'elementStone022.png',
     
BrickDamage.lots: 'elementStone049.png',
   
},
   
(BrickType.wood, BrickSize.size70x140) => {
     
BrickDamage.none: 'elementWood016.png',
     
BrickDamage.some: 'elementWood021.png',
     
BrickDamage.lots: 'elementWood048.png',
   
},
   
(BrickType.explosive, BrickSize.size140x140) => {
     
BrickDamage.none: 'elementExplosive018.png',
     
BrickDamage.some: 'elementExplosive023.png',
     
BrickDamage.lots: 'elementExplosive053.png',
   
},
   
(BrickType.glass, BrickSize.size140x140) => {
     
BrickDamage.none: 'elementGlass019.png',
     
BrickDamage.some: 'elementGlass024.png',
     
BrickDamage.lots: 'elementGlass051.png',
   
},
   
(BrickType.metal, BrickSize.size140x140) => {
     
BrickDamage.none: 'elementMetal018.png',
     
BrickDamage.some: 'elementMetal023.png',
     
BrickDamage.lots: 'elementMetal053.png',
   
},
   
(BrickType.stone, BrickSize.size140x140) => {
     
BrickDamage.none: 'elementStone018.png',
     
BrickDamage.some: 'elementStone023.png',
     
BrickDamage.lots: 'elementStone050.png',
   
},
   
(BrickType.wood, BrickSize.size140x140) => {
     
BrickDamage.none: 'elementWood017.png',
     
BrickDamage.some: 'elementWood022.png',
     
BrickDamage.lots: 'elementWood049.png',
   
},
   
(BrickType.explosive, BrickSize.size220x140) => {
     
BrickDamage.none: 'elementExplosive019.png',
     
BrickDamage.some: 'elementExplosive024.png',
     
BrickDamage.lots: 'elementExplosive054.png',
   
},
   
(BrickType.glass, BrickSize.size220x140) => {
     
BrickDamage.none: 'elementGlass020.png',
     
BrickDamage.some: 'elementGlass025.png',
     
BrickDamage.lots: 'elementGlass052.png',
   
},
   
(BrickType.metal, BrickSize.size220x140) => {
     
BrickDamage.none: 'elementMetal019.png',
     
BrickDamage.some: 'elementMetal024.png',
     
BrickDamage.lots: 'elementMetal054.png',
   
},
   
(BrickType.stone, BrickSize.size220x140) => {
     
BrickDamage.none: 'elementStone019.png',
     
BrickDamage.some: 'elementStone024.png',
     
BrickDamage.lots: 'elementStone051.png',
   
},
   
(BrickType.wood, BrickSize.size220x140) => {
     
BrickDamage.none: 'elementWood018.png',
     
BrickDamage.some: 'elementWood023.png',
     
BrickDamage.lots: 'elementWood050.png',
   
},
   
(BrickType.explosive, BrickSize.size70x220) => {
     
BrickDamage.none: 'elementExplosive020.png',
     
BrickDamage.some: 'elementExplosive025.png',
     
BrickDamage.lots: 'elementExplosive055.png',
   
},
   
(BrickType.glass, BrickSize.size70x220) => {
     
BrickDamage.none: 'elementGlass021.png',
     
BrickDamage.some: 'elementGlass026.png',
     
BrickDamage.lots: 'elementGlass053.png',
   
},
   
(BrickType.metal, BrickSize.size70x220) => {
     
BrickDamage.none: 'elementMetal020.png',
     
BrickDamage.some: 'elementMetal025.png',
     
BrickDamage.lots: 'elementMetal055.png',
   
},
   
(BrickType.stone, BrickSize.size70x220) => {
     
BrickDamage.none: 'elementStone020.png',
     
BrickDamage.some: 'elementStone025.png',
     
BrickDamage.lots: 'elementStone052.png',
   
},
   
(BrickType.wood, BrickSize.size70x220) => {
     
BrickDamage.none: 'elementWood019.png',
     
BrickDamage.some: 'elementWood024.png',
     
BrickDamage.lots: 'elementWood051.png',
   
},
   
(BrickType.explosive, BrickSize.size140x220) => {
     
BrickDamage.none: 'elementExplosive021.png',
     
BrickDamage.some: 'elementExplosive026.png',
     
BrickDamage.lots: 'elementExplosive056.png',
   
},
   
(BrickType.glass, BrickSize.size140x220) => {
     
BrickDamage.none: 'elementGlass022.png',
     
BrickDamage.some: 'elementGlass027.png',
     
BrickDamage.lots: 'elementGlass054.png',
   
},
   
(BrickType.metal, BrickSize.size140x220) => {
     
BrickDamage.none: 'elementMetal021.png',
     
BrickDamage.some: 'elementMetal026.png',
     
BrickDamage.lots: 'elementMetal056.png',
   
},
   
(BrickType.stone, BrickSize.size140x220) => {
     
BrickDamage.none: 'elementStone021.png',
     
BrickDamage.some: 'elementStone026.png',
     
BrickDamage.lots: 'elementStone053.png',
   
},
   
(BrickType.wood, BrickSize.size140x220) => {
     
BrickDamage.none: 'elementWood020.png',
     
BrickDamage.some: 'elementWood025.png',
     
BrickDamage.lots: 'elementWood052.png',
   
},
 
};
}

class Brick extends BodyComponent {
 
Brick({
   
required this.type,
   
required this.size,
   
required BrickDamage damage,
   
required Vector2 position,
   
required Map<BrickDamage, Sprite> sprites,
 
}) : _damage = damage,
       
_sprites = sprites,
       
super(
         
renderBody: false,
         
bodyDef: BodyDef()
           
..position = position
           
..type = BodyType.dynamic,
         
fixtureDefs: [
           
FixtureDef(
               
PolygonShape()..setAsBoxXY(
                 
size.size.width / 20 * brickScale,
                 
size.size.height / 20 * brickScale,
               
),
             
)
             
..restitution = 0.4
             
..density = type.density
             
..friction = type.friction,
         
],
       
);

 
late final SpriteComponent _spriteComponent;

 
final BrickType type;
 
final BrickSize size;
 
final Map<BrickDamage, Sprite> _sprites;

 
BrickDamage _damage;
 
BrickDamage get damage => _damage;
 
set damage(BrickDamage value) {
   
_damage = value;
   
_spriteComponent.sprite = _sprites[value];
 
}

 
@override
 
Future<void> onLoad() {
   
_spriteComponent = SpriteComponent(
     
anchor: Anchor.center,
     
scale: Vector2.all(1),
     
sprite: _sprites[_damage],
     
size: size.size.toVector2() / 10 * brickScale,
     
position: Vector2(0, 0),
   
);
   
add(_spriteComponent);
   
return super.onLoad();
 
}
}

Giờ đây, bạn có thể xem cách mã Dart được tạo trước đó được tích hợp vào cơ sở mã này để nhanh chóng chọn hình ảnh gạch dựa trên chất liệu, kích thước và tình trạng. Khi xem xét các enum và thành phần Brick, bạn sẽ thấy hầu hết mã này khá quen thuộc với thành phần Ground ở bước trước. Có trạng thái có thể thay đổi ở đây để cho phép viên gạch bị hỏng, mặc dù việc sử dụng trạng thái này là một bài tập cho người đọc.

Đã đến lúc đưa các viên gạch lên màn hình. Chỉnh sửa tệp game.dart như sau:

lib/components/game.dart

import 'dart:async';
import 'dart:math';                                        // Add this import

import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flame_kenney_xml/flame_kenney_xml.dart';

import 'background.dart';
import 'brick.dart';                                       // Add this import
import 'ground.dart';

class MyPhysicsGame extends Forge2DGame {
 
MyPhysicsGame()
   
: super(
       
gravity: Vector2(0, 10),
       
camera: CameraComponent.withFixedResolution(width: 800, height: 600),
     
);

 
late final XmlSpriteSheet aliens;
 
late final XmlSpriteSheet elements;
 
late final XmlSpriteSheet tiles;

 
@override
 
FutureOr<void> onLoad() async {
   
final backgroundImage = await images.load('colored_grass.png');
   
final spriteSheets = await Future.wait([
     
XmlSpriteSheet.load(
       
imagePath: 'spritesheet_aliens.png',
       
xmlPath: 'spritesheet_aliens.xml',
     
),
     
XmlSpriteSheet.load(
       
imagePath: 'spritesheet_elements.png',
       
xmlPath: 'spritesheet_elements.xml',
     
),
     
XmlSpriteSheet.load(
       
imagePath: 'spritesheet_tiles.png',
       
xmlPath: 'spritesheet_tiles.xml',
     
),
   
]);

   
aliens = spriteSheets[0];
   
elements = spriteSheets[1];
   
tiles = spriteSheets[2];

   
await world.add(Background(sprite: Sprite(backgroundImage)));
   
await addGround();
   
unawaited(addBricks());                                // Add this line

   
return super.onLoad();
 
}

 
Future<void> addGround() {
   
return world.addAll([
     
for (
       
var x = camera.visibleWorldRect.left;
       
x < camera.visibleWorldRect.right + groundSize;
       
x += groundSize
     
)
       
Ground(
         
Vector2(x, (camera.visibleWorldRect.height - groundSize) / 2),
         
tiles.getSprite('grass.png'),
       
),
   
]);
 
}

 
final _random = Random();                                // Add from here...

 
Future<void> addBricks() async {
   
for (var i = 0; i < 5; i++) {
     
final type = BrickType.randomType;
     
final size = BrickSize.randomSize;
     
await world.add(
       
Brick(
         
type: type,
         
size: size,
         
damage: BrickDamage.some,
         
position: Vector2(
           
camera.visibleWorldRect.right / 3 +
               
(_random.nextDouble() * 5 - 2.5),
           
0,
         
),
         
sprites: brickFileNames(
           
type,
           
size,
         
).map((key, filename) => MapEntry(key, elements.getSprite(filename))),
       
),
     
);
     
await Future<void>.delayed(const Duration(milliseconds: 500));
   
}
 
}                                                        // To here.
}

Mã bổ sung này hơi khác với mã bạn dùng để thêm các thành phần Ground. Lần này, các Brick được thêm vào một cụm ngẫu nhiên theo thời gian. Có hai phần trong phương thức này, phần đầu tiên là phương thức thêm await của Brick vào Future.delayed, tương đương với lệnh gọi sleep() không đồng bộ. Tuy nhiên, có một phần thứ hai để làm cho việc này hoạt động, lệnh gọi đến addBricks trong phương thức onLoad không được await. Nếu có, phương thức onLoad sẽ không hoàn tất cho đến khi tất cả các khối đều xuất hiện trên màn hình. Việc gói lệnh gọi đến addBricks trong lệnh gọi unawaited sẽ giúp trình tìm lỗi mã nguồn hài lòng và giúp lập trình viên trong tương lai hiểu rõ ý định của chúng ta. Việc không chờ phương thức này trả về là có chủ ý.

Chạy trò chơi và bạn sẽ thấy các viên gạch xuất hiện, va vào nhau và rơi xuống đất.

Cửa sổ ứng dụng có đồi xanh ở nền sau, lớp mặt đất và các khối rơi xuống mặt đất.

6. Thêm trình phát

Ném người ngoài hành tinh vào gạch

Việc xem các khối gạch đổ xuống sẽ rất thú vị trong vài lần đầu tiên, nhưng tôi đoán trò chơi này sẽ thú vị hơn nếu chúng ta cung cấp cho người chơi một hình đại diện để họ có thể sử dụng để tương tác với thế giới. Bạn có thể thêm một người ngoài hành tinh để họ ném vào những viên gạch không?

Tạo tệp player.dart mới trong thư mục lib/components rồi thêm nội dung sau vào tệp đó:

lib/components/player.dart

import 'dart:math';

import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flame/events.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flutter/material.dart';

const playerSize = 5.0;

enum PlayerColor {
 
pink,
 
blue,
 
green,
 
yellow;

 
static PlayerColor get randomColor =>
     
PlayerColor.values[Random().nextInt(PlayerColor.values.length)];

 
String get fileName =>
     
'alien${toString().split('.').last.capitalize}_round.png';
}

class Player extends BodyComponent with DragCallbacks {
 
Player(Vector2 position, Sprite sprite)
   
: _sprite = sprite,
     
super(
       
renderBody: false,
       
bodyDef: BodyDef()
         
..position = position
         
..type = BodyType.static
         
..angularDamping = 0.1
         
..linearDamping = 0.1,
       
fixtureDefs: [
         
FixtureDef(CircleShape()..radius = playerSize / 2)
           
..restitution = 0.4
           
..density = 0.75
           
..friction = 0.5,
       
],
     
);

 
final Sprite _sprite;

 
@override
 
Future<void> onLoad() {
   
addAll([
     
CustomPainterComponent(
       
painter: _DragPainter(this),
       
anchor: Anchor.center,
       
size: Vector2(playerSize, playerSize),
       
position: Vector2(0, 0),
     
),
     
SpriteComponent(
       
anchor: Anchor.center,
       
sprite: _sprite,
       
size: Vector2(playerSize, playerSize),
       
position: Vector2(0, 0),
     
),
   
]);
   
return super.onLoad();
 
}

 
@override
 
void update(double dt) {
   
super.update(dt);

   
if (!body.isAwake) {
     
removeFromParent();
   
}

   
if (position.x > camera.visibleWorldRect.right + 10 ||
       
position.x < camera.visibleWorldRect.left - 10) {
     
removeFromParent();
   
}
 
}

 
Vector2 _dragStart = Vector2.zero();
 
Vector2 _dragDelta = Vector2.zero();
 
Vector2 get dragDelta => _dragDelta;

 
@override
 
void onDragStart(DragStartEvent event) {
   
super.onDragStart(event);
   
if (body.bodyType == BodyType.static) {
     
_dragStart = event.localPosition;
   
}
 
}

 
@override
 
void onDragUpdate(DragUpdateEvent event) {
   
if (body.bodyType == BodyType.static) {
     
_dragDelta = event.localEndPosition - _dragStart;
   
}
 
}

 
@override
 
void onDragEnd(DragEndEvent event) {
   
super.onDragEnd(event);
   
if (body.bodyType == BodyType.static) {
     
children
         
.whereType<CustomPainterComponent>()
         
.firstOrNull
         
?.removeFromParent();
     
body.setType(BodyType.dynamic);
     
body.applyLinearImpulse(_dragDelta * -50);
     
add(RemoveEffect(delay: 5.0));
   
}
 
}
}

extension on String {
 
String get capitalize =>
     
characters.first.toUpperCase() + characters.skip(1).toLowerCase().join();
}

class _DragPainter extends CustomPainter {
 
_DragPainter(this.player);

 
final Player player;

 
@override
 
void paint(Canvas canvas, Size size) {
   
if (player.dragDelta != Vector2.zero()) {
     
var center = size.center(Offset.zero);
     
canvas.drawLine(
       
center,
       
center + (player.dragDelta * -1).toOffset(),
       
Paint()
         
..color = Colors.orange.withAlpha(180)
         
..strokeWidth = 0.4
         
..strokeCap = StrokeCap.round,
     
);
   
}
 
}

 
@override
 
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}

Đây là một bước nâng cao so với các thành phần Brick ở bước trước. Thành phần Player này có hai thành phần con, một SpriteComponent mà bạn sẽ nhận ra và một CustomPainterComponent mới. Khái niệm CustomPainter là của Flutter và cho phép bạn vẽ trên canvas. Nó được dùng ở đây để cung cấp cho người chơi phản hồi về vị trí mà người ngoài hành tinh tròn sẽ bay khi bị hất tung.

Người chơi bắt đầu hất người ngoài hành tinh như thế nào? Sử dụng cử chỉ kéo mà thành phần Player phát hiện bằng lệnh gọi lại DragCallbacks. Những người có đôi mắt tinh tường sẽ nhận thấy điều khác ở đây.

Trong khi các thành phần Ground là các cơ thể tĩnh, thì các thành phần Brick là các cơ thể động. Trình phát ở đây là sự kết hợp của cả hai. Người chơi bắt đầu ở trạng thái tĩnh, chờ người chơi kéo và khi thả, người chơi sẽ tự chuyển đổi từ tĩnh sang động, thêm xung lực tuyến tính theo tỷ lệ với lực kéo và cho phép hình đại diện người ngoài hành tinh bay!

Ngoài ra, thành phần Player còn có mã để xoá thành phần đó khỏi màn hình nếu thành phần đó vượt quá giới hạn, chuyển sang trạng thái ngủ hoặc hết thời gian chờ. Mục đích ở đây là cho phép người chơi hất người ngoài hành tinh, xem điều gì sẽ xảy ra, sau đó thử lại.

Tích hợp thành phần Player vào trò chơi bằng cách chỉnh sửa game.dart như sau:

lib/components/game.dart

import 'dart:async';
import 'dart:math';

import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flame_kenney_xml/flame_kenney_xml.dart';

import 'background.dart';
import 'brick.dart';
import 'ground.dart';
import 'player.dart';                                      // Add this import

class MyPhysicsGame extends Forge2DGame {
 
MyPhysicsGame()
   
: super(
       
gravity: Vector2(0, 10),
       
camera: CameraComponent.withFixedResolution(width: 800, height: 600),
     
);

 
late final XmlSpriteSheet aliens;
 
late final XmlSpriteSheet elements;
 
late final XmlSpriteSheet tiles;

 
@override
 
FutureOr<void> onLoad() async {
   
final backgroundImage = await images.load('colored_grass.png');
   
final spriteSheets = await Future.wait([
     
XmlSpriteSheet.load(
       
imagePath: 'spritesheet_aliens.png',
       
xmlPath: 'spritesheet_aliens.xml',
     
),
     
XmlSpriteSheet.load(
       
imagePath: 'spritesheet_elements.png',
       
xmlPath: 'spritesheet_elements.xml',
     
),
     
XmlSpriteSheet.load(
       
imagePath: 'spritesheet_tiles.png',
       
xmlPath: 'spritesheet_tiles.xml',
     
),
   
]);

   
aliens = spriteSheets[0];
   
elements = spriteSheets[1];
   
tiles = spriteSheets[2];

   
await world.add(Background(sprite: Sprite(backgroundImage)));
   
await addGround();
   
unawaited(addBricks());
   
await addPlayer();                                     // Add this line

   
return super.onLoad();
 
}

 
Future<void> addGround() {
   
return world.addAll([
     
for (
       
var x = camera.visibleWorldRect.left;
       
x < camera.visibleWorldRect.right + groundSize;
       
x += groundSize
     
)
       
Ground(
         
Vector2(x, (camera.visibleWorldRect.height - groundSize) / 2),
         
tiles.getSprite('grass.png'),
       
),
   
]);
 
}

 
final _random = Random();

 
Future<void> addBricks() async {
   
for (var i = 0; i < 5; i++) {
     
final type = BrickType.randomType;
     
final size = BrickSize.randomSize;
     
await world.add(
       
Brick(
         
type: type,
         
size: size,
         
damage: BrickDamage.some,
         
position: Vector2(
           
camera.visibleWorldRect.right / 3 +
               
(_random.nextDouble() * 5 - 2.5),
           
0,
         
),
         
sprites: brickFileNames(
           
type,
           
size,
         
).map((key, filename) => MapEntry(key, elements.getSprite(filename))),
       
),
     
);
     
await Future<void>.delayed(const Duration(milliseconds: 500));
   
}
 
}

 
Future<void> addPlayer() async => world.add(             // Add from here...
   
Player(
     
Vector2(camera.visibleWorldRect.left * 2 / 3, 0),
     
aliens.getSprite(PlayerColor.randomColor.fileName),
   
),
 
);

 
@override
 
void update(double dt) {
   
super.update(dt);
   
if (isMounted && world.children.whereType<Player>().isEmpty) {
     
addPlayer();
   
}
 
}                                                        // To here.
}

Việc thêm người chơi vào trò chơi cũng tương tự như các thành phần trước, chỉ khác một chút. Người ngoài hành tinh của người chơi được thiết kế để tự loại bỏ khỏi trò chơi trong một số điều kiện nhất định. Vì vậy, có một trình xử lý cập nhật tại đây để kiểm tra xem có thành phần Player nào trong trò chơi hay không. Nếu có, hãy thêm một thành phần trở lại. Quá trình chạy trò chơi sẽ có dạng như sau.

Cửa sổ ứng dụng có đồi xanh ở nền, lớp mặt đất, các khối trên mặt đất và hình đại diện của người chơi đang bay.

7. Phản ứng với tác động

Thêm kẻ thù

Bạn đã thấy các đối tượng tĩnh và động tương tác với nhau. Tuy nhiên, để thực sự đạt được điều gì đó, bạn cần nhận lệnh gọi lại trong mã khi các đối tượng va chạm. Bạn sẽ giới thiệu một số kẻ thù để người chơi đối đầu. Điều này dẫn đến điều kiện chiến thắng – loại bỏ tất cả kẻ thù khỏi trò chơi!

Tạo tệp enemy.dart trong thư mục lib/components rồi thêm nội dung sau:

lib/components/enemy.dart

import 'dart:math';

import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flutter/material.dart';

import 'body_component_with_user_data.dart';

const enemySize = 5.0;

enum EnemyColor {
 
pink(color: 'pink', boss: false),
 
blue(color: 'blue', boss: false),
 
green(color: 'green', boss: false),
 
yellow(color: 'yellow', boss: false),
 
pinkBoss(color: 'pink', boss: true),
 
blueBoss(color: 'blue', boss: true),
 
greenBoss(color: 'green', boss: true),
 
yellowBoss(color: 'yellow', boss: true);

 
final bool boss;
 
final String color;

 
const EnemyColor({required this.color, required this.boss});

 
static EnemyColor get randomColor =>
     
EnemyColor.values[Random().nextInt(EnemyColor.values.length)];

 
String get fileName =>
     
'alien${color.capitalize}_${boss ? 'suit' : 'square'}.png';
}

class Enemy extends BodyComponentWithUserData with ContactCallbacks {
 
Enemy(Vector2 position, Sprite sprite)
   
: super(
       
renderBody: false,
       
bodyDef: BodyDef()
         
..position = position
         
..type = BodyType.dynamic,
       
fixtureDefs: [
         
FixtureDef(
           
PolygonShape()..setAsBoxXY(enemySize / 2, enemySize / 2),
           
friction: 0.3,
         
),
       
],
       
children: [
         
SpriteComponent(
           
anchor: Anchor.center,
           
sprite: sprite,
           
size: Vector2.all(enemySize),
           
position: Vector2(0, 0),
         
),
       
],
     
);

 
@override
 
void beginContact(Object other, Contact contact) {
   
var interceptVelocity =
       
(contact.bodyA.linearVelocity - contact.bodyB.linearVelocity).length
           
.abs();
   
if (interceptVelocity > 35) {
     
removeFromParent();
   
}

   
super.beginContact(other, contact);
 
}

 
@override
 
void update(double dt) {
   
super.update(dt);

   
if (position.x > camera.visibleWorldRect.right + 10 ||
       
position.x < camera.visibleWorldRect.left - 10) {
     
removeFromParent();
   
}
 
}
}

extension on String {
 
String get capitalize =>
     
characters.first.toUpperCase() + characters.skip(1).toLowerCase().join();
}

Từ các hoạt động tương tác trước đây với thành phần Player (Trình phát) và Brick (Khối), bạn sẽ thấy hầu hết tệp này đều quen thuộc. Tuy nhiên, sẽ có một vài dòng gạch dưới màu đỏ trong trình chỉnh sửa do một lớp cơ sở mới không xác định. Thêm lớp này ngay bằng cách thêm tệp có tên body_component_with_user_data.dart vào lib/components với nội dung sau:

lib/components/body_component_with_user_data.dart

import 'package:flame_forge2d/flame_forge2d.dart';

class BodyComponentWithUserData extends BodyComponent {
 
BodyComponentWithUserData({
   
super.key,
   
super.bodyDef,
   
super.children,
   
super.fixtureDefs,
   
super.paint,
   
super.priority,
   
super.renderBody,
 
});

 
@override
 
Body createBody() {
   
final body = world.createBody(super.bodyDef!)..userData = this;
   
fixtureDefs?.forEach(body.createFixture);
   
return body;
 
}
}

Lớp cơ sở này, kết hợp với lệnh gọi lại beginContact mới trong thành phần Enemy, tạo thành cơ sở để nhận thông báo theo phương thức lập trình về các tác động giữa các vật thể. Trên thực tế, bạn sẽ cần chỉnh sửa mọi thành phần mà bạn muốn nhận thông báo về mức tác động. Vì vậy, hãy tiếp tục chỉnh sửa các thành phần Brick, GroundPlayer để sử dụng BodyComponentWithUserData này thay cho lớp cơ sở BodyComponent mà các thành phần đó sử dụng. Ví dụ: sau đây là cách chỉnh sửa thành phần Ground:

lib/components/ground.dart

import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';

import 'body_component_with_user_data.dart';               // Add this import

const groundSize = 7.0;

class Ground extends BodyComponentWithUserData {           // Edit this line
 
Ground(Vector2 position, Sprite sprite)
   
: super(
       
renderBody: false,
       
bodyDef: BodyDef()
         
..position = position
         
..type = BodyType.static,
       
fixtureDefs: [
         
FixtureDef(
           
PolygonShape()..setAsBoxXY(groundSize / 2, groundSize / 2),
           
friction: 0.3,
         
),
       
],
       
children: [
         
SpriteComponent(
           
anchor: Anchor.center,
           
sprite: sprite,
           
size: Vector2.all(groundSize),
           
position: Vector2(0, 0),
         
),
       
],
     
);
}

Để biết thêm thông tin về cách Forge2d xử lý thông tin liên hệ, hãy xem tài liệu về lệnh gọi lại thông tin liên hệ của Forge2D.

Thắng trò chơi

Giờ đây, bạn đã có kẻ thù và cách loại bỏ kẻ thù khỏi thế giới. Có một cách đơn giản để biến mô phỏng này thành trò chơi. Hãy đặt mục tiêu loại bỏ tất cả kẻ thù! Bây giờ, hãy chỉnh sửa tệp game.dart như sau:

lib/components/game.dart

import 'dart:async';
import 'dart:math';

import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flame_kenney_xml/flame_kenney_xml.dart';
import 'package:flutter/material.dart';                    // Add this import

import 'background.dart';
import 'brick.dart';
import 'enemy.dart';                                       // Add this import
import 'ground.dart';
import 'player.dart';

class MyPhysicsGame extends Forge2DGame {
 
MyPhysicsGame()
   
: super(
       
gravity: Vector2(0, 10),
       
camera: CameraComponent.withFixedResolution(width: 800, height: 600),
     
);

 
late final XmlSpriteSheet aliens;
 
late final XmlSpriteSheet elements;
 
late final XmlSpriteSheet tiles;

 
@override
 
FutureOr<void> onLoad() async {
   
final backgroundImage = await images.load('colored_grass.png');
   
final spriteSheets = await Future.wait([
     
XmlSpriteSheet.load(
       
imagePath: 'spritesheet_aliens.png',
       
xmlPath: 'spritesheet_aliens.xml',
     
),
     
XmlSpriteSheet.load(
       
imagePath: 'spritesheet_elements.png',
       
xmlPath: 'spritesheet_elements.xml',
     
),
     
XmlSpriteSheet.load(
       
imagePath: 'spritesheet_tiles.png',
       
xmlPath: 'spritesheet_tiles.xml',
     
),
   
]);

   
aliens = spriteSheets[0];
   
elements = spriteSheets[1];
   
tiles = spriteSheets[2];

   
await world.add(Background(sprite: Sprite(backgroundImage)));
   
await addGround();
   
unawaited(addBricks().then((_) => addEnemies()));      // Modify this line
   
await addPlayer();

   
return super.onLoad();
 
}

 
Future<void> addGround() {
   
return world.addAll([
     
for (
       
var x = camera.visibleWorldRect.left;
       
x < camera.visibleWorldRect.right + groundSize;
       
x += groundSize
     
)
       
Ground(
         
Vector2(x, (camera.visibleWorldRect.height - groundSize) / 2),
         
tiles.getSprite('grass.png'),
       
),
   
]);
 
}

 
final _random = Random();

 
Future<void> addBricks() async {
   
for (var i = 0; i < 5; i++) {
     
final type = BrickType.randomType;
     
final size = BrickSize.randomSize;
     
await world.add(
       
Brick(
         
type: type,
         
size: size,
         
damage: BrickDamage.some,
         
position: Vector2(
           
camera.visibleWorldRect.right / 3 +
               
(_random.nextDouble() * 5 - 2.5),
           
0,
         
),
         
sprites: brickFileNames(
           
type,
           
size,
         
).map((key, filename) => MapEntry(key, elements.getSprite(filename))),
       
),
     
);
     
await Future<void>.delayed(const Duration(milliseconds: 500));
   
}
 
}

 
Future<void> addPlayer() async => world.add(
   
Player(
     
Vector2(camera.visibleWorldRect.left * 2 / 3, 0),
     
aliens.getSprite(PlayerColor.randomColor.fileName),
   
),
 
);

 
@override
 
void update(double dt) {
   
super.update(dt);
   
if (isMounted &&                                       // Modify from here...
       
world.children.whereType<Player>().isEmpty &&
       
world.children.whereType<Enemy>().isNotEmpty) {
     
addPlayer();
   
}
   
if (isMounted &&
       
enemiesFullyAdded &&
       
world.children.whereType<Enemy>().isEmpty &&
       
world.children.whereType<TextComponent>().isEmpty) {
     
world.addAll(
       
[
         
(position: Vector2(0.5, 0.5), color: Colors.white),
         
(position: Vector2.zero(), color: Colors.orangeAccent),
       
].map(
         
(e) => TextComponent(
           
text: 'You win!',
           
anchor: Anchor.center,
           
position: e.position,
           
textRenderer: TextPaint(
             
style: TextStyle(color: e.color, fontSize: 16),
           
),
         
),
       
),
     
);
   
}
 
}

 
var enemiesFullyAdded = false;

 
Future<void> addEnemies() async {
   
await Future<void>.delayed(const Duration(seconds: 2));
   
for (var i = 0; i < 3; i++) {
     
await world.add(
       
Enemy(
         
Vector2(
           
camera.visibleWorldRect.right / 3 +
               
(_random.nextDouble() * 7 - 3.5),
           
(_random.nextDouble() * 3),
         
),
         
aliens.getSprite(EnemyColor.randomColor.fileName),
       
),
     
);
     
await Future<void>.delayed(const Duration(seconds: 1));
   
}
   
enemiesFullyAdded = true;                              // To here.
 
}
}

Thách thức của bạn (nếu bạn chọn chấp nhận) là chạy trò chơi và chuyển đến màn hình này.

Cửa sổ ứng dụng có đồi núi màu xanh lục ở nền, lớp mặt đất, các khối trên mặt đất và lớp phủ văn bản &quot;Bạn thắng!&quot;

8. Xin chúc mừng

Xin chúc mừng! Bạn đã xây dựng thành công một trò chơi bằng Flutter và Flame!

Bạn đã tạo một trò chơi bằng công cụ phát triển trò chơi Flame 2D và nhúng trò chơi đó vào trình bao bọc Flutter. Bạn đã sử dụng Hiệu ứng của Flame để tạo ảnh động và xoá các thành phần. Bạn đã sử dụng các gói Google Fonts và Flutter Animate để thiết kế toàn bộ trò chơi một cách đẹp mắt.

Tiếp theo là gì?

Hãy xem một số lớp học lập trình này...

Tài liệu đọc thêm