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:
Điều kiện tiên quyết
- Đã hoàn thành lớp học lập trình Giới thiệu về Ngọn lửa bằng Flutter
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ó
- SDK Flutter
- Visual Studio Code (VS Code) với các trình bổ trợ Flutter và Dart
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:
- 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.
- 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:
- 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.
- 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.
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:
Từ thư mục PNG/Backgrounds
, hãy sao chép các tệp colored_desert.png
, colored_grass.png
, colored_land.png
và colored_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ẻ.
Sao chép spritesheet_aliens.png
, spritesheet_elements.png
và spritesheet_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.xml
và spritesheet_tiles.xml
vào thư mục assets
của dự án. Dự án của bạn sẽ có dạng như sau.
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.
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.
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.
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.
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
, Ground
và Player
để 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.
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...
- Xây dựng giao diện người dùng thế hệ mới trong Flutter
- Biến ứng dụng Flutter của bạn từ nhàm chán thành đẹp mắt
- Thêm tính năng mua hàng trong ứng dụng vào ứng dụng Flutter