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

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ẽ tạo một trò chơi sử dụng mô hình mô phỏng thực tế 2D dọc theo các đường của Box2D, có tên là Forge2D. Bạn sử dụng các thành phần của Ngọn lửa để vẽ môi trường thực tế thực tế mô phỏng lên màn hình để người dùng chơi cùng. Khi hoàn tất, trò chơi của bạn sẽ có dạng như ảnh gif động sau:

Ảnh động minh hoạ lối chơi trong 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 thức hoạt động cơ bản của Forge2D, bắt đầu từ các loại cơ thể khác nhau.
  • Cách thiết lập một hoạt động mô phỏng thực ở chế độ 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 cho cả 6 nền tảng mà Flutter hỗ trợ. Bạn cần Visual Studio để nhắm mục tiêu Windows, Xcode để nhắm mục tiêu đến macOS hoặc iOS và Android Studio để nhắm mục tiêu Android.

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

Tạo dự án Flutter

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

Để bắt đầu, hãy thực hiện theo các bước sau:

  1. Trên một 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.
  1. 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.3.0 (from transitive dependency to direct dependency)
  collection 1.18.0 (1.19.0 available)
+ flame 1.18.0
+ flame_forge2d 0.18.1
+ flame_kenney_xml 0.1.0
  flutter_lints 3.0.2 (4.0.0 available)
+ forge2d 0.13.0
  leak_tracker 10.0.4 (10.0.5 available)
  leak_tracker_flutter_testing 3.0.3 (3.0.5 available)
  lints 3.0.0 (4.0.0 available)
  material_color_utilities 0.8.0 (0.12.0 available)
  meta 1.12.0 (1.15.0 available)
+ ordered_set 5.0.3 (6.0.1 available)
+ petitparser 6.0.2
  test_api 0.7.0 (0.7.3 available)
  vm_service 14.2.1 (14.2.4 available)
+ xml 6.5.0
Changed 8 dependencies!
10 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 có thể bạn cần giải thích thêm về gói còn lại. Gói characters được dùng để thao tác với đường dẫn tệp 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 thực thể FlameGame. Không có mã Flutter nào trong lớp học lập trình này 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, quy trình khởi động được đơn giản hoá này sẽ hoạt động tốt.

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

Ảnh chụp màn hình trong dự án này được lấy từ trò chơi dưới dạng ứng dụng macOS dành cho máy tính. Để thanh tiêu đề của ứng dụng không làm giảm giá trị 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 để thanh tiêu đề.

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

  1. Tạo một 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ì tệp này không thuộc cơ sở mã thời gian chạy của trò chơi. Đây là một công cụ dòng lệnh được dùng để sửa đổi dự án.

  1. Trong 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ứ đều diễn ra theo đúng kế hoạch, chương trình sẽ không tạo kết quả nào trên dòng lệnh. Tuy nhiên, thao tác 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ần thanh tiêu đề hiển thị và trò chơi Ngọn lửa chiếm toàn bộ cửa sổ.

Chạy trò chơi để xác minh rằng mọi thứ đều hoạt động. Màn hình 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 đ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ẽ lên một màn hình theo cách tận hưởng những điều thú vị. Lớp học lập trình này sẽ sử dụng gói Thành phần vật lý của Kenney.nl. Những tài sản này được cấp phép theo giấy phép Creative Commons CC0, nhưng bạn vẫn nên quyên góp cho nhóm của Kenney để họ có thể tiếp tục công việc tuyệt vời mà mình đang làm. Tôi có làm vậy.

Bạn sẽ cần sửa đổi tệp cấu hình pubspec.yaml để có thể sử dụng các thành phần của Kenney. Hãy 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.3.3 <4.0.0'

dependencies:
  characters: ^1.3.0
  flame: ^1.17.0
  flame_forge2d: ^0.18.0
  flutter:
    sdk: flutter
  xml: ^6.5.0

dev_dependencies:
  flutter_lints: ^3.0.0
  flutter_test:
    sdk: flutter

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

Ngọn lửa yêu cầu các thành phần hình ảnh phải nằm trong assets/images, mặc dù bạn có thể định cấu hình thành phần này theo cách khác. Xem tài liệu về Hình ảnh của Flame để biết thêm thông tin. Bây giờ, bạn đã đị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. Bạn có thể thực hiện việc này bằng cách 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 chỉnh sửa 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 và bạn sẽ thấy giao diện như sau:

Mở rộng danh sách tệp của gói kenney_material-asset, trong đó 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 tấm sprite. Đây là sự kết hợp giữa hình ảnh PNG và tệp XML mô tả vị trí có thể tìm thấy các hình ảnh nhỏ hơn trong hình ảnh sprite. Trang tính 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 thay vì hàng chục, hoặc hàng trăm tệp hình ảnh riêng lẻ.

Mở rộng trang thông tin tệp của gói kenney_research-asset, trong đó 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 truy cập, hãy sao chép cả 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.

Trang thông tin 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

Sơn nền

Bây giờ, dự án của bạn đã được thêm các thành phần hình ảnh, đã đến lúc đưa các thành phần hình ảnh lên màn hình. Một hình ảnh trên màn hình. Bạn sẽ thấy những bước khác trong các bước sau.

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

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. chịu trách nhiệm hiển thị một trong bốn hình nền của Kenney.nl. Có một vài giả định đơn giản hoá trong mã này. Đầu tiên là hình ảnh có dạng hình vuông, trong đó có cả bốn hình nền của Kenney. 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ẽ nằm ở 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, 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ó nhiều điều đang diễn ra ở đây. Hãy bắt đầu từ lớp MyPhysicsGame. Không giống như lớp học lập trình trước, lớp này mở rộng Forge2DGame chứ không phải FlameGame. Forge2DGame tự mở rộng FlameGame với một số tinh chỉnh thú vị. Đầu tiên là theo mặc định, zoom được thiết lập thành 10. Chế độ cài đặt zoom này dùng để thực hiện phạm vi các giá trị hữu ích mà các công cụ mô phỏng thực tế kiểu Box2D hoạt động hiệu quả. Động cơ được viết bằng hệ thống MKS, trong đó các đơn vị được giả định là theo mét, kilogam và giây. Phạm vi mà qua đó bạn không nhìn thấy lỗi toán học đáng chú ý cho các đối tượng là từ 0,1 mét đến 10 giây. Việc cấp dữ liệu trực tiếp theo kích thước pixel mà không cần giảm tỷ lệ sẽ đưa Forge2D ra khỏi phạm vi hữu ích của nó. 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 một lon nước ngọt có ga lên đến một chiếc xe buýt.

Giả định được đưa ra trong thành phần Nền được đáp ứng ở đây bằng cách sửa độ phân giải của CameraComponent thành 800 x 600 pixel ảo. Điều này có nghĩa là khu vực trò chơi sẽ rộng 80 đơn vị và cao 60 đơn vị, tập trung vào (0,0). Việc 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 đối tượng trong cảnh trò chơi.

Bên cạnh đối số hàm khởi tạo camera là một đối số khác được căn chỉnh theo vật lý có tên là gravity. Gravity được đặt thành Vector2 với x là 0 và y là 10. 10 là một con số gần đúng của giá trị 9,81 mét mỗi giây mỗi giây được chấp nhận chung cho trọng lực. Thực tế là trọng lực được đặt thành dương 10 cho thấy trong hệ thống này, hướng của trục Y đi xuống. Điều này 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ộ và phù hợp vì nó 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 trả về Future<Image> và dưới dạng hiệu ứng phụ sẽ lưu hình ảnh đã tải trong đối tượng Trò chơi. Các tương lai này được tập hợp lại với nhau và chờ 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 trả về sẽ khớp mẫu với từng tên.

Sau đó, hình ảnh spritesheet được đưa vào một chuỗi đố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 spritesheet. Lớp XmlSpriteSheet được định nghĩa trong gói flame_kenney_xml.

Ngoài ra, bạn chỉ cần thực hiện một vài chỉnh sửa nhỏ đối với lib/main.dart để hiển thị 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 giản này, giờ đây bạn có thể chạy lại trò chơi để xem nền trên màn hình. Lưu ý: phiên bản camera CameraComponent.withFixedResolution() sẽ thêm hiệu ứng hòm thư theo yêu cầu để giúp trò chơi hoạt động theo tỷ lệ 800 x 600.

Một cửa sổ ứng dụng có hình nền là những ngọn đồi xanh thoai thoải và những cái cây trừu tượng kỳ lạ.

4. Thêm mặt đất

Nền tảng để phát huy

Nếu có trọng lực, chúng ta cần có thứ gì đó để giữ các vật thể trong trò chơi trước khi chúng rơi ra khỏi đáy màn hình. Tất nhiên, trừ phi việc rơi màn hình là một phần trong thiết kế trò chơi. Tạo một tệp ground.dart mới trong thư mục lib/components rồi thêm phần 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 nội dung rất quan trọng, chúng là các đối tượng thuộc mô phỏng thực tế hai chiều. BodyDef cho thành phần này được chỉ định để có BodyType.static.

Trong Forge2D, các phần tử có 3 loại khác nhau. Các vật thể tĩnh không di chuyển. Chúng 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 – chúng không di chuyển khi bị các vật khác va chạm, cho dù chúng nặng đến mức nào. Điều này giúp các vật thể tĩnh thích hợp trên mặt đất vì nó không di chuyển.

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

Bản thân cơ thể không làm được gì nhiều. Cơ thể cần có các hình dạng liên kết để tạo thành chất. Trong trường hợp này, phần nội dung này có một hình dạng liên kết là PolygonShape được đặt thành 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 thành BoxXY có thể xoay xung quanh một điểm xoay. Một lần nữa 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 giá trị cố định, rất hữu ích khi thêm những phần tử như friction vào hệ thống.

Theo mặc định, phần thân sẽ kết xuất 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 mang lại trải nghiệm chơi ấn tượng. 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 kết xuất trong trò chơi cho nội dung này là trách nhiệm của phần tử con SpriteComponent.

Để 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.
}

Bản 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 kết quả gồm các thành phần Ground vào phương thức addAll của world.

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

Cửa sổ ứng dụng có nền và lớp mặt đất.

5. Thêm khối hình

Xây tường

Mặt đất đã cho chúng ta một ví dụ về một vật thể tĩnh. Giờ là lúc bạn sử dụng 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 của người chơi, chúng là những thứ vận động 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 hình, chúng sẽ được chọn ngẫu nhiên để xuất hiện trên màn hình trong một cụm khối hình. Bạn sẽ thấy chúng rơi xuống và chạm vào nhau khi họ làm vậy.

Khối hình sẽ được tạo từ bảng sprite phần tử. Nếu nhìn vào phần mô tả trang tính sprite trong assets/spritesheet_elements.xml, bạn sẽ thấy chúng ta có một vấn đề thú vị. Những cái tên có vẻ không hữu ích cho lắm. Những gì sẽ hữu ích sẽ có thể chọn một viên gạch theo loại vật liệu, kích thước của nó và mức độ sát thương. Rất may là một trợ giúp đỡ đã dành thời gian tìm ra mẫu trong cách đặt tên tệp và tạo ra một công cụ giúp bạn dễ dàng hơn. Tạo một tệp mới generate_brick_file_names.dart trong thư mục bin và 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 của bạn sẽ cung cấp cho bạn cảnh báo hoặc lỗi về phần phụ thuộc bị thiếu. Hãy thêm mã 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 đã phân tích cú pháp hữu ích tệp mô tả trang hình sprite và chuyển đổi tệp đó thành mã Dart mà chúng tôi có thể sử dụng để chọn tệp hình ảnh phù hợp cho mỗi khối hình bạn muốn đặt trên màn hình. 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();
  }
}

Bây giờ bạn có thể thấy cách mã Dart được tạo ở trên được tích hợp vào cơ sở mã này để giúp bạn nhanh chóng và dễ dàng chọn hình ảnh khối gạch dựa trên chất liệu, kích thước và điều kiện. Khi nhìn qua các enum và vào chính thành phần Brick, bạn sẽ thấy hầu hết mã này có vẻ khá quen thuộc với thành phần Ground ở bước trướ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 chỉ là một bài tập cho người đọc.

Đã đến lúc đặt viên gạch trê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.
}

Thao tác thêm mã 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 sẽ được thêm vào một cụm ngẫu nhiên theo thời gian. Có hai phần, phần đầu tiên là phương thức thêm Future.delayed của Brick await, tương đương với lệnh gọi sleep() không đồng bộ. Tuy nhiên, còn phần thứ hai để thực hiện việc này là 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 thành cho đến khi tất cả các khối hình đề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ẽ khiến trình tìm lỗi mã nguồn hài lòng và giúp các lập trình viên trong tương lai thấy rõ ý định của chúng ta. Việc không đợi phương thức này trả về là có chủ đích.

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

Một cửa sổ ứng dụng với những ngọn đồi màu xanh lục ở phía 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

Trong vài lần đầu xem các khối hình sẽ rất thú vị, nhưng tôi đoán trò chơi này sẽ thú vị hơn nếu chúng ta cho người chơi một hình đại diện để họ có thể tương tác với thế giới. Thế còn một người ngoài hành tinh có thể hất vào những viên gạch thì sao?

Tạo một tệp player.dart mới trong thư mục lib/components và thêm phần 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.withOpacity(0.7)
            ..strokeWidth = 0.4
            ..strokeCap = StrokeCap.round);
    }
  }

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

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

Người chơi làm thế nào để kích hoạt cử chỉ hất người ngoài hành tinh? Sử dụng cử chỉ kéo mà thành phần Trình phát phát hiện bằng lệnh gọi lại DragCallbacks. Chim đại bàng trong số các bạn sẽ nhận thấy điều khác ở đây.

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

Thành phần Player cũng có một mã để xoá khỏi màn hình nếu nội dung vượt quá giới hạn, chìm vào giấc ngủ hoặc hết giờ. Mục đích ở đây là cho phép người chơi hất người ngoài hành tinh về phía họ, xem điều gì xảy ra, sau đó tiếp tục chơ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 trình phát vào trò chơi cũng tương tự như các thành phần trước, nhưng có thêm một nếp nhăn. Người chơi ngoài hành tinh được thiết kế để tự xoá chính mình 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 ở đây để kiểm tra xem có phải không có thành phần Player trong trò chơi hay không. Nếu có, sẽ thêm lại một thành phần như vậy. Chạy trò chơi sẽ có dạng như thế này.

Một cửa sổ ứng dụng với những ngọn đồi màu xanh lục ở phía sau, lớp mặt đất, các khối hình trên mặt đất và hình đại diện của người chơi đang bay lượn.

7. Phản ứng trước 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 một vị trí nào đó, bạn cần nhận lệnh gọi lại trong mã khi mọi thứ va chạm. Hãy xem cách thực hiện. Bạn sẽ giới thiệu một số kẻ thù để người chơi chống lại. Điều này mở ra cơ hội chiến thắng – loại bỏ tất cả kẻ thù khỏi trò chơi!

Tạo một tệp enemy.dart trong thư mục lib/components và thêm đoạn mã 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 của bạn với các thành phần Trình phát và Khối, hầu hết tệp này đều quen thuộc. Tuy nhiên, sẽ có một vài đường gạch chân màu đỏ trong trình chỉnh sửa của bạn do lớp cơ sở mới chưa xác định. Thêm lớp này ngay bây giờ bằng cách thêm một 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 nên cơ sở để nhận thông báo theo phương thức lập trình về tác động giữa các phần tử. 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 và 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 đó đang sử dụng. Ví dụ: dưới đâ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ý danh bạ, vui lòng xem tài liệu Forge2D về lệnh gọi lại danh bạ.

Thắng trận

Giờ đây, khi có kẻ thù và đã tìm ra cách loại bỏ kẻ thù khỏi thế giới, bạn có thể biến hoạt động mô phỏng này thành một trò chơi theo cách đơn giản. Hãy đạt mục tiêu loại bỏ tất cả kẻ thù! Bạn có thể 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ử thách 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 với những ngọn đồi màu xanh lục trong 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 đã chiế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 trò chơi bằng Flutter và Flame!

Bạn đã xây dựng 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 một trình bao bọc Flutter. Bạn đã dùng Hiệu ứng của ngọn lửa để 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