1. Giới thiệu
Flame là một công cụ phát triển trò chơi 2D dựa trên Flutter. Trong lớp học lập trình này, bạn sẽ xây dựng một trò chơi lấy cảm hứng từ một trong những trò chơi điện tử kinh điển của thập niên 70, đó là Đột phá của Steve Wozniak. Bạn sẽ sử dụng Thành phần của ngọn lửa để vẽ gậy, bóng và gạch. Bạn sẽ sử dụng Hiệu ứng của ngọn lửa để tạo hiệu ứng cho chuyển động của dơi và xem cách tích hợp Ngọn lửa với hệ thống quản lý trạng thái của Flutter.
Khi hoàn tất, trò chơi của bạn sẽ trông giống như ảnh gif động này, mặc dù có chậm hơn một chút.
Kiến thức bạn sẽ học được
- Cách thức hoạt động của Flame cơ bản, bắt đầu bằng
GameWidget
. - Cách sử dụng vòng lặp trò chơi.
- Cách hoạt động của
Component
của Flame. Chúng giống vớiWidget
của Flutter. - Cách xử lý xung đột.
- Cách sử dụng
Effect
để tạo ảnh độngComponent
. - Cách phủ các mảnh
Widget
lên trên một trò chơi Flame. - Cách tích hợp Flame với tính năng quản lý trạng thái của Flutter.
Sản phẩm bạn sẽ tạo ra
Trong lớp học lập trình này, bạn sẽ xây dựng một trò chơi 2D bằng Flutter và Flame. Sau khi hoàn tất, trò chơi của bạn phải đáp ứng các yêu cầu sau
- Hoạt động trên cả 6 nền tảng mà Flutter hỗ trợ: Android, iOS, Linux, macOS, Windows và web
- Duy trì tốc độ ít nhất 60 khung hình/giây khi sử dụng vòng lặp trò chơi của Flame.
- Sử dụng các tính năng của Flutter như gói
google_fonts
vàflutter_animate
để tạo lại cảm giác như đang chơi trò chơi arcade những năm 80.
2. Thiết lập môi trường Flutter
Người chỉnh sửa
Để đơn giản hoá lớp học lập trình này, chúng tôi sẽ giả định rằng Visual Studio Code (VS Code) là môi trường phát triển của bạn. VS Code là miễn phí và hoạt động trên tất cả các nền tảng chính. Chúng ta sử dụng Mã VS cho lớp học lập trình này vì hướng dẫn mặc định sử dụng các phím tắt dành riêng cho Mã VS. Các nhiệm vụ trở nên đơn giản hơn: "nhấp vào nút này" hoặc "nhấn phím này để thực hiện X" thay vì "thực hiện hành động thích hợp trong trình chỉnh sửa để làm X".
Bạn có thể dùng bất cứ trình chỉnh sửa nào tuỳ thích: Android Studio, các IDE IntelliJ khác, Emacs, Vim hoặc Notepad++. Tất cả đều dùng được Flutter.
Chọn mục tiêu phát triển
Flutter sản xuất các ứng dụng cho nhiều nền tảng. Ứng dụng của bạn có thể chạy trên bất kỳ hệ điều hành nào sau đây:
- iOS
- Android
- Windows
- macOS
- Linux
- web
Thông thường, bạn nên chọn một hệ điều hành làm mục tiêu phát triển của mình. Đây là hệ điều hành mà ứng dụng của bạn chạy trong quá trình phát triển.
Ví dụ: giả sử bạn đang sử dụng máy tính xách tay Windows để phát triển ứng dụng Flutter. Sau đó, bạn chọn Android làm mục tiêu phát triển của mình. Để xem trước ứng dụng, bạn có thể kết nối thiết bị Android với máy tính xách tay Windows bằng cáp USB. Khi đó, ứng dụng đang trong quá trình phát triển sẽ chạy trên thiết bị Android đi kèm đó hoặc trong trình mô phỏng Android. Bạn có thể đã chọn Windows làm mục tiêu phát triển. Hệ thống này sẽ chạy quá trình phát triển ứng dụng dưới dạng một ứng dụng Windows cùng với trình chỉnh sửa.
Có thể bạn sẽ muốn chọn web làm mục tiêu phát triển của mình. Điều này có một nhược điểm trong quá trình phát triển: bạn sẽ mất tính năng Stateful Hot Loading (Tải lại nóng trạng thái) của Flutter. Flutter hiện không thể tải lại nhanh các ứng dụng web.
Hãy đưa ra lựa chọn trước khi tiếp tục. Sau này, bạn luôn có thể chạy ứng dụng trên các hệ điều hành khác. Khi bạn chọn mục tiêu phát triển, bước tiếp theo sẽ suôn sẻ hơn.
Cài đặt Flutter
Bạn có thể xem các hướng dẫn mới nhất về cách cài đặt Flutter SDK trên docs.flutter.dev.
Các hướng dẫn trên trang web Flutter trình bày cách cài đặt SDK và các công cụ liên quan đến mục tiêu phát triển cũng như các trình bổ trợ trình chỉnh sửa. Đối với lớp học lập trình này, hãy cài đặt phần mềm sau:
- SDK Flutter
- Mã Visual Studio với trình bổ trợ Flutter
- Phần mềm biên dịch cho mục tiêu phát triển mà bạn chọn. (Bạn cần Visual Studio để nhắm mục tiêu Windows hoặc Xcode để nhắm mục tiêu macOS hoặc iOS)
Trong phần tiếp theo, bạn sẽ tạo dự án Flutter đầu tiên của mình.
Nếu cần khắc phục bất kỳ vấn đề nào, bạn có thể thấy một số câu hỏi và câu trả lời sau đây (từ StackOverflow) hữu ích cho việc khắc phục sự cố.
Câu hỏi thường gặp
- Làm cách nào để tìm thấy lộ trình SDK Flutter?
- Tôi nên làm gì khi không tìm thấy lệnh Flutter?
- Làm cách nào để khắc phục lỗi "Đang đợi một lệnh Flutter khác để nhả khoá khởi động" có vấn đề gì không?
- Làm cách nào để cho Flutter biết vị trí cài đặt SDK Android của tôi?
- Làm cách nào để xử lý lỗi Java khi chạy
flutter doctor --android-licenses
? - Làm cách nào để xử lý khi không tìm thấy công cụ Android
sdkmanager
? - Làm thế nào để xử lý vấn đề "Thiếu thành phần
cmdline-tools
" lỗi? - Làm cách nào để chạy CocoaPods trên Apple Silicon (M1)?
- Làm cách nào để tắt tính năng tự động định dạng khi lưu vào Mã VS?
3. Tạo một dự án
Tạo dự án Flutter đầu tiên của bạn
Bước này bao gồm việc mở VS Code và tạo mẫu ứng dụng Flutter trong thư mục bạn chọn.
- Chạy mã Visual Studio.
- Mở bảng lệnh (
F1
hoặcCtrl+Shift+P
hoặcShift+Cmd+P
), sau đó nhập "flutter new". Khi cửa sổ này xuất hiện, hãy chọn lệnh Flutter: New Project (Flutter: Dự án mới).
- Chọn Empty Application (Ứng dụng trống). Chọn một thư mục để tạo dự án. Đây phải là bất kỳ thư mục nào không yêu cầu đặc quyền cấp cao hoặc có khoảng trắng trong đường dẫn. Các ví dụ bao gồm thư mục gốc hoặc
C:\src\
của bạn.
- Đặt tên cho dự án là
brick_breaker
. Trong phần còn lại của lớp học lập trình này, giả sử bạn đã đặt tên cho ứng dụng của mình làbrick_breaker
.
Bây giờ, Flutter sẽ tạo thư mục dự án của bạn và VS Code sẽ mở thư mục đó. Bây giờ, bạn sẽ ghi đè nội dung của 2 tệp bằng một scaffold cơ bản của ứng dụng.
Sao chép và Dán ứng dụng ban đầu
Thao tác này sẽ thêm mã mẫu được cung cấp trong lớp học lập trình này vào ứng dụng.
- Trong ngăn bên trái của Mã VS, hãy nhấp vào Explorer rồi mở tệp
pubspec.yaml
.
- Thay thế nội dung của tệp này bằng:
pubspec.yaml
name: brick_breaker
description: "Re-implementing Woz's Breakout"
publish_to: 'none'
version: 0.1.0
environment:
sdk: '>=3.3.0 <4.0.0'
dependencies:
flame: ^1.16.0
flutter:
sdk: flutter
flutter_animate: ^4.5.0
google_fonts: ^6.1.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^3.0.1
flutter:
uses-material-design: true
Tệp pubspec.yaml
chỉ định thông tin cơ bản về ứng dụng, chẳng hạn như phiên bản hiện tại của ứng dụng, các phần phụ thuộc của ứng dụng và tài sản mà ứng dụng sẽ mang theo.
- Mở tệp
main.dart
trong thư mụclib/
.
- Thay thế nội dung của tệp này bằng:
lib/main.dart
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
void main() {
final game = FlameGame();
runApp(GameWidget(game: game));
}
- Chạy mã này để 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. Trò chơi điện tử khủng nhất thế giới hiện đang kết xuất ở tốc độ 60 khung hình/giây!
4. Tạo trò chơi
Nâng cao chất lượng trò chơi
Trò chơi chơi ở hai chiều (2D) cần có khu vực chơi. Bạn sẽ xây dựng một khu vực có các phương diện cụ thể, sau đó sử dụng các phương diện này để xác định kích thước các khía cạnh khác của trò chơi.
Có nhiều cách để bố trí toạ độ trong khu vực chơi. Theo một quy ước, bạn có thể đo hướng từ tâm màn hình bằng điểm gốc (0,0)
ở giữa màn hình, các giá trị dương sẽ di chuyển các mục sang phải dọc theo trục x và lên trên trục y. Tiêu chuẩn này áp dụng cho hầu hết các trò chơi hiện tại ngày nay, đặc biệt là khi các trò chơi liên quan đến ba chiều.
Khi trò chơi Breakout ban đầu được tạo, quy ước là đặt điểm gốc ở góc trên cùng bên trái. Chiều dương x vẫn giữ nguyên, tuy nhiên y đã đảo ngược. Hướng x dương x hướng sang phải và y giảm. Để theo đúng thời đại, trò chơi này đặt gốc của trò chơi ở góc trên cùng bên trái.
Tạo một tệp có tên là config.dart
trong thư mục mới có tên là lib/src
. Tệp này sẽ nhận được nhiều hằng số hơn trong các bước sau.
lib/src/config.dart
const gameWidth = 820.0;
const gameHeight = 1600.0;
Trò chơi này sẽ có chiều rộng là 820 pixel và chiều cao là 1600 pixel. Khu vực trò chơi điều chỉnh theo tỷ lệ để vừa với cửa sổ hiển thị, nhưng tất cả các thành phần được thêm vào màn hình đều phải tuân theo chiều cao và chiều rộng này.
Tạo PlayArea
Trong trò chơi Breakout, quả bóng bật ra khỏi tường của khu vực chơi. Để thích ứng với các xung đột, trước tiên, bạn cần có thành phần PlayArea
.
- Tạo một tệp có tên là
play_area.dart
trong thư mục mới có tên làlib/src/components
. - Thêm phần sau đây vào tệp này.
lib/src/components/play_area.dart
import 'dart:async';
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
class PlayArea extends RectangleComponent with HasGameReference<BrickBreaker> {
PlayArea()
: super(
paint: Paint()..color = const Color(0xfff2e8cf),
);
@override
FutureOr<void> onLoad() async {
super.onLoad();
size = Vector2(game.width, game.height);
}
}
Trong khi Flutter có Widget
, Flame có Component
. Trong đó, ứng dụng Flutter bao gồm việc tạo cây tiện ích, còn trò chơi Ngọn lửa bao gồm các cây duy trì thành phần.
Có một điểm khác biệt thú vị giữa Flutter và Flame. Cây tiện ích của Flutter là một nội dung mô tả tạm thời, được xây dựng nhằm dùng để cập nhật lớp RenderObject
ổn định và có thể thay đổi. Các thành phần của Flame là ổn định và có thể thay đổi, với kỳ vọng rằng nhà phát triển sẽ sử dụng những thành phần này như một phần của hệ thống mô phỏng.
Các thành phần của Flame được tối ưu hoá để thể hiện cơ chế trò chơi. Lớp học lập trình này sẽ bắt đầu từ vòng lặp trò chơi, được nêu trong bước tiếp theo.
- Để kiểm soát sự lộn xộn, hãy thêm một tệp chứa tất cả thành phần trong dự án này. Tạo một tệp
components.dart
tronglib/src/components
rồi thêm nội dung sau.
lib/src/components/components.dart
export 'play_area.dart';
Lệnh export
đóng vai trò nghịch đảo của import
. Tệp này khai báo chức năng mà tệp này cho thấy khi được nhập vào một tệp khác. Tệp này sẽ thêm nhiều mục nhập hơn khi bạn thêm các thành phần mới ở các bước sau.
Tạo trò chơi Ngọn lửa
Để loại bỏ những đường cong màu đỏ ở bước trước, hãy lấy một lớp con mới cho FlameGame
của Flame.
- Tạo một tệp có tên
brick_breaker.dart
tronglib/src
và thêm đoạn mã sau.
lib/src/brick_breaker.dart
import 'dart:async';
import 'package:flame/components.dart';
import 'package:flame/game.dart';
import 'components/components.dart';
import 'config.dart';
class BrickBreaker extends FlameGame {
BrickBreaker()
: super(
camera: CameraComponent.withFixedResolution(
width: gameWidth,
height: gameHeight,
),
);
double get width => size.x;
double get height => size.y;
@override
FutureOr<void> onLoad() async {
super.onLoad();
camera.viewfinder.anchor = Anchor.topLeft;
world.add(PlayArea());
}
}
Tệp này điều phối các hành động của trò chơi. Trong quá trình xây dựng thực thể trò chơi, đoạn mã này sẽ định cấu hình trò chơi để sử dụng tính năng kết xuất độ phân giải cố định. Trò chơi sẽ đổi kích thước để lấp đầy màn hình chứa trò chơi và thêm hiệu ứng hòm thư theo yêu cầu.
Bạn nên hiển thị chiều rộng và chiều cao của trò chơi để các thành phần con, chẳng hạn như PlayArea
, có thể tự đặt ở kích thước phù hợp.
Trong phương thức bị ghi đè onLoad
, mã của bạn sẽ thực hiện 2 hành động.
- Định cấu hình phía trên cùng bên trái làm điểm neo cho kính ngắm. Theo mặc định, kính ngắm sử dụng phần giữa của vùng làm điểm neo cho
(0,0)
. - Thêm
PlayArea
vàoworld
. Thế giới đại diện cho thế giới trò chơi. Hàm này chiếu tất cả phần tử con thông qua phép biến đổi khung hiển thịCameraComponent
.
Xem trò chơi trên màn hình
Để xem tất cả nội dung thay đổi mà bạn đã thực hiện ở bước này, hãy cập nhật các thay đổi sau đây cho tệp lib/main.dart
.
lib/main.dart
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'src/brick_breaker.dart'; // Add this import
void main() {
final game = BrickBreaker(); // Modify this line
runApp(GameWidget(game: game));
}
Sau khi thực hiện những thay đổi này, hãy bắt đầu lại trò chơi. Trò chơi phải giống như hình sau.
Trong bước tiếp theo, bạn sẽ thêm một quả bóng vào thế giới và khiến nó di chuyển!
5. Hiển thị quả bóng
Tạo thành phần bóng
Để đặt một quả bóng di chuyển trên màn hình, bạn cần tạo một thành phần khác và thêm thành phần đó vào thế giới trò chơi.
- Chỉnh sửa nội dung của tệp
lib/src/config.dart
như sau.
lib/src/config.dart
const gameWidth = 820.0;
const gameHeight = 1600.0;
const ballRadius = gameWidth * 0.02; // Add this constant
Mẫu thiết kế để xác định hằng số được đặt tên làm các giá trị phát sinh sẽ trả về nhiều lần trong lớp học lập trình này. Nhờ vậy, bạn có thể chỉnh sửa gameWidth
và gameHeight
ở cấp cao nhất để khám phá xem trò chơi sẽ thay đổi như thế nào.
- Tạo thành phần
Ball
trong tệp có tên làball.dart
tronglib/src/components
.
lib/src/components/ball.dart
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
class Ball extends CircleComponent {
Ball({
required this.velocity,
required super.position,
required double radius,
}) : super(
radius: radius,
anchor: Anchor.center,
paint: Paint()
..color = const Color(0xff1e6091)
..style = PaintingStyle.fill);
final Vector2 velocity;
@override
void update(double dt) {
super.update(dt);
position += velocity * dt;
}
}
Trước đó, bạn đã định nghĩa PlayArea
bằng RectangleComponent
, vậy nên có nhiều hình dạng hơn tồn tại. CircleComponent
, giống như RectangleComponent
, được lấy từ PositionedComponent
, vì vậy, bạn có thể định vị quả bóng trên màn hình. Quan trọng hơn là vị trí của nó có thể được cập nhật.
Thành phần này giới thiệu khái niệm về velocity
hoặc thay đổi vị trí theo thời gian. Vận tốc là một đối tượng Vector2
vì vận tốc bao gồm cả tốc độ và hướng. Để cập nhật vị trí, hãy ghi đè phương thức update
mà công cụ phát triển trò chơi gọi cho mọi khung hình. dt
là thời lượng giữa khung hình trước và khung này. Điều này cho phép bạn thích ứng với các yếu tố như tốc độ khung hình khác nhau (60hz hoặc 120hz) hoặc khung hình dài do tính toán quá mức.
Hãy chú ý kỹ đến bản cập nhật của position += velocity * dt
. Đây là cách bạn triển khai việc cập nhật một mô phỏng chuyển động rời rạc theo thời gian.
- Để đưa thành phần
Ball
vào danh sách thành phần, hãy chỉnh sửa tệplib/src/components/components.dart
như sau.
lib/src/components/components.dart
export 'ball.dart'; // Add this export
export 'play_area.dart';
Thêm bóng vào lưới
Bạn có một quả bóng. Hãy đặt thiết bị vào thế giới và thiết lập để di chuyển xung quanh khu vực vui chơi.
Chỉnh sửa tệp lib/src/brick_breaker.dart
như sau.
lib/src/brick_breaker.dart
import 'dart:async';
import 'dart:math' as math; // Add this import
import 'package:flame/components.dart';
import 'package:flame/game.dart';
import 'components/components.dart';
import 'config.dart';
class BrickBreaker extends FlameGame {
BrickBreaker()
: super(
camera: CameraComponent.withFixedResolution(
width: gameWidth,
height: gameHeight,
),
);
final rand = math.Random(); // Add this variable
double get width => size.x;
double get height => size.y;
@override
FutureOr<void> onLoad() async {
super.onLoad();
camera.viewfinder.anchor = Anchor.topLeft;
world.add(PlayArea());
world.add(Ball( // Add from here...
radius: ballRadius,
position: size / 2,
velocity: Vector2((rand.nextDouble() - 0.5) * width, height * 0.2)
.normalized()
..scale(height / 4)));
debugMode = true; // To here.
}
}
Thay đổi này sẽ thêm thành phần Ball
vào world
. Để đặt position
của quả bóng ở giữa vùng hiển thị, trước tiên, mã sẽ giảm một nửa kích thước của trò chơi, vì Vector2
có các phương thức nạp chồng toán tử (*
và /
) để mở rộng Vector2
theo giá trị vô hướng.
Việc đặt velocity
của quả bóng phức tạp hơn. Mục đích là di chuyển quả bóng xuống dưới màn hình theo một hướng ngẫu nhiên với tốc độ hợp lý. Lệnh gọi đến phương thức normalized
tạo ra một đối tượng Vector2
được đặt theo cùng hướng với Vector2
ban đầu nhưng được thu nhỏ lại ở khoảng cách 1. Điều này giúp duy trì tốc độ của bóng nhất quán bất kể bóng đi theo hướng nào. Vận tốc của quả bóng sau đó được tăng lên bằng 1/4 chiều cao của trò chơi.
Để xác định đúng các giá trị này, bạn phải thực hiện một số bước lặp lại, còn gọi là kiểm thử chơi trò chơi trong ngành.
Dòng cuối cùng bật màn hình gỡ lỗi. Màn hình này bổ sung thông tin bổ sung vào màn hình để giúp gỡ lỗi.
Khi chạy trò chơi, bạn sẽ thấy như sau.
Cả thành phần PlayArea
và thành phần Ball
đều có thông tin gỡ lỗi, nhưng màu nền sau sẽ cắt các số của PlayArea
. Lý do mọi thứ đều hiển thị thông tin gỡ lỗi là vì bạn đã bật debugMode
cho toàn bộ cây thành phần. Bạn cũng có thể bật tính năng gỡ lỗi chỉ cho các thành phần đã chọn, nếu cách đó hữu ích hơn.
Nếu khởi động lại trò chơi vài lần, bạn có thể nhận thấy rằng quả bóng không tương tác với tường như mong đợi. Để tạo ra hiệu ứng đó, bạn cần thêm tính năng phát hiện va chạm trong bước tiếp theo.
6. Phiêu theo từng nốt nhạc
Thêm tính năng phát hiện xung đột
Tính năng phát hiện va chạm bổ sung một hành vi mà trong đó trò chơi của bạn nhận ra khi hai đối tượng tiếp xúc với nhau.
Để thêm tính năng phát hiện va chạm vào trò chơi, hãy thêm trình trộn HasCollisionDetection
vào trò chơi BrickBreaker
như trong đoạn mã sau.
lib/src/brick_breaker.dart
import 'dart:async';
import 'dart:math' as math;
import 'package:flame/components.dart';
import 'package:flame/game.dart';
import 'components/components.dart';
import 'config.dart';
class BrickBreaker extends FlameGame with HasCollisionDetection { // Modify this line
BrickBreaker()
: super(
camera: CameraComponent.withFixedResolution(
width: gameWidth,
height: gameHeight,
),
);
final rand = math.Random();
double get width => size.x;
double get height => size.y;
@override
FutureOr<void> onLoad() async {
super.onLoad();
camera.viewfinder.anchor = Anchor.topLeft;
world.add(PlayArea());
world.add(Ball(
radius: ballRadius,
position: size / 2,
velocity: Vector2((rand.nextDouble() - 0.5) * width, height * 0.2)
.normalized()
..scale(height / 4)));
debugMode = true;
}
}
Thao tác này theo dõi các hộp đánh dấu của các thành phần và kích hoạt lệnh gọi lại xung đột trên mỗi kim đánh dấu nhịp độ khung hình trong trò chơi.
Để bắt đầu điền sẵn các hộp đánh dấu của trò chơi, hãy sửa đổi thành phần PlayArea
như minh hoạ dưới đây.
lib/src/components/play_area.dart
import 'dart:async';
import 'package:flame/collisions.dart'; // Add this import
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
class PlayArea extends RectangleComponent with HasGameReference<BrickBreaker> {
PlayArea()
: super(
paint: Paint()..color = const Color(0xfff2e8cf),
children: [RectangleHitbox()], // Add this parameter
);
@override
FutureOr<void> onLoad() async {
super.onLoad();
size = Vector2(game.width, game.height);
}
}
Việc thêm một thành phần RectangleHitbox
làm thành phần con của RectangleComponent
sẽ tạo hộp truy cập để phát hiện xung đột khớp với kích thước của thành phần mẹ. Có một hàm khởi tạo factory cho RectangleHitbox
được gọi là relative
khi bạn muốn hộp truy cập nhỏ hơn hoặc lớn hơn thành phần mẹ.
Trả lại bóng
Cho đến nay, việc thêm tính năng phát hiện va chạm vẫn chưa có gì khác biệt trong lối chơi. Phần này sẽ thay đổi sau khi bạn sửa đổi thành phần Ball
. Hành vi của quả bóng phải thay đổi khi va chạm với PlayArea
.
Sửa đổi thành phần Ball
như sau.
lib/src/components/ball.dart
import 'package:flame/collisions.dart'; // Add this import
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart'; // And this import
import 'play_area.dart'; // And this one too
class Ball extends CircleComponent
with CollisionCallbacks, HasGameReference<BrickBreaker> { // Add these mixins
Ball({
required this.velocity,
required super.position,
required double radius,
}) : super(
radius: radius,
anchor: Anchor.center,
paint: Paint()
..color = const Color(0xff1e6091)
..style = PaintingStyle.fill,
children: [CircleHitbox()]); // Add this parameter
final Vector2 velocity;
@override
void update(double dt) {
super.update(dt);
position += velocity * dt;
}
@override // Add from here...
void onCollisionStart(
Set<Vector2> intersectionPoints, PositionComponent other) {
super.onCollisionStart(intersectionPoints, other);
if (other is PlayArea) {
if (intersectionPoints.first.y <= 0) {
velocity.y = -velocity.y;
} else if (intersectionPoints.first.x <= 0) {
velocity.x = -velocity.x;
} else if (intersectionPoints.first.x >= game.width) {
velocity.x = -velocity.x;
} else if (intersectionPoints.first.y >= game.height) {
removeFromParent();
}
} else {
debugPrint('collision with $other');
}
} // To here.
}
Ví dụ này tạo ra một thay đổi lớn khi có thêm lệnh gọi lại onCollisionStart
. Hệ thống phát hiện xung đột được thêm vào BrickBreaker
trong ví dụ trước gọi lệnh gọi lại này.
Trước tiên, mã này sẽ kiểm thử xem Ball
có va chạm với PlayArea
hay không. Điều này hiện có vẻ dư thừa vì không còn thành phần nào khác trong thế giới trò chơi. Điều đó sẽ thay đổi trong bước tiếp theo, khi bạn thêm một con dơi vào thế giới. Sau đó, mã này cũng thêm điều kiện else
để xử lý khi quả bóng va chạm với các thứ không phải là gậy. Lời nhắc nhẹ nhàng để triển khai logic còn lại, nếu bạn thực hiện.
Khi va chạm với tường dưới cùng, quả bóng chỉ biến mất khỏi mặt sân trong khi vẫn ở trong tầm nhìn. Bạn sẽ xử lý cấu phần phần mềm này ở bước trong tương lai, sử dụng sức mạnh của Hiệu ứng của Ngọn Lửa.
Giờ bạn đã va chạm bóng với tường của trò chơi, nên việc cho người chơi dùng gậy đánh bóng sẽ rất hữu ích...
7. Đánh bóng
Tạo dơi
Cách thêm gậy để giữ bóng tiếp tục chơi trong trò chơi,
- Chèn một số hằng số trong tệp
lib/src/config.dart
như sau.
lib/src/config.dart
const gameWidth = 820.0;
const gameHeight = 1600.0;
const ballRadius = gameWidth * 0.02;
const batWidth = gameWidth * 0.2; // Add from here...
const batHeight = ballRadius * 2;
const batStep = gameWidth * 0.05; // To here.
Các hằng số batHeight
và batWidth
là hằng số tự giải thích. Mặt khác, hằng số batStep
cần được giải thích một chút. Để tương tác với bóng trong trò chơi này, người chơi có thể kéo gậy bằng chuột hoặc ngón tay, tuỳ vào nền tảng hoặc sử dụng bàn phím. Hằng số batStep
định cấu hình quãng đường đi được của gậy cho mỗi lần nhấn phím mũi tên trái hoặc phải.
- Xác định lớp thành phần
Bat
như sau.
lib/src/components/bat.dart
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flame/events.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
class Bat extends PositionComponent
with DragCallbacks, HasGameReference<BrickBreaker> {
Bat({
required this.cornerRadius,
required super.position,
required super.size,
}) : super(
anchor: Anchor.center,
children: [RectangleHitbox()],
);
final Radius cornerRadius;
final _paint = Paint()
..color = const Color(0xff1e6091)
..style = PaintingStyle.fill;
@override
void render(Canvas canvas) {
super.render(canvas);
canvas.drawRRect(
RRect.fromRectAndRadius(
Offset.zero & size.toSize(),
cornerRadius,
),
_paint);
}
@override
void onDragUpdate(DragUpdateEvent event) {
super.onDragUpdate(event);
position.x = (position.x + event.localDelta.x).clamp(0, game.width);
}
void moveBy(double dx) {
add(MoveToEffect(
Vector2((position.x + dx).clamp(0, game.width), position.y),
EffectController(duration: 0.1),
));
}
}
Thành phần này giới thiệu một số tính năng mới.
Trước tiên, thành phần Bat là PositionComponent
, không phải là RectangleComponent
hay CircleComponent
. Tức là mã này cần kết xuất Bat
trên màn hình. Để thực hiện việc này, lệnh này sẽ ghi đè lệnh gọi lại render
.
Khi xem xét kỹ lệnh gọi canvas.drawRRect
(vẽ hình chữ nhật bo tròn), bạn có thể tự hỏi "hình chữ nhật nằm ở đâu?" Offset.zero & size.toSize()
tận dụng phương thức nạp chồng operator &
trên lớp dart:ui
Offset
để tạo Rect
. Ban đầu, cách viết tắt này có thể sẽ khiến bạn bối rối, nhưng bạn sẽ thường xuyên thấy nó trong mã Flutter và Flame cấp thấp hơn.
Thứ hai, tuỳ thuộc vào nền tảng, bạn có thể kéo thành phần Bat
này bằng ngón tay hoặc chuột. Để triển khai chức năng này, bạn thêm trình kết hợp DragCallbacks
và ghi đè sự kiện onDragUpdate
.
Cuối cùng, thành phần Bat
cần phản hồi chế độ điều khiển bàn phím. Hàm moveBy
cho phép mã khác yêu cầu dơi này di chuyển sang trái hoặc sang phải theo một số lượng pixel ảo nhất định. Hàm này giới thiệu một chức năng mới của công cụ phát triển trò chơi Flame: Effect
. Khi thêm đối tượng MoveToEffect
làm phần tử con của thành phần này, người chơi sẽ thấy ảnh động và con dơi ở một vị trí mới. Ngọn lửa có một bộ sưu tập Effect
để thực hiện nhiều hiệu ứng khác nhau.
Các đối số hàm khởi tạo của Hiệu ứng bao gồm một tham chiếu đến phương thức getter game
. Đây là lý do bạn đưa danh sách kết hợp HasGameReference
vào lớp này. Mixin này thêm trình truy cập game
an toàn về kiểu vào thành phần này để truy cập vào thực thể BrickBreaker
ở đầu cây thành phần.
- Để có thể sử dụng
Bat
choBrickBreaker
, hãy cập nhật tệplib/src/components/components.dart
như sau.
lib/src/components/components.dart
export 'ball.dart';
export 'bat.dart'; // Add this export
export 'play_area.dart';
Hãy đưa dơi vào thế giới
Để thêm thành phần Bat
vào thế giới trò chơi, hãy cập nhật BrickBreaker
như sau.
lib/src/brick_breaker.dart
import 'dart:async';
import 'dart:math' as math;
import 'package:flame/components.dart';
import 'package:flame/events.dart'; // Add this import
import 'package:flame/game.dart';
import 'package:flutter/material.dart'; // And this import
import 'package:flutter/services.dart'; // And this
import 'components/components.dart';
import 'config.dart';
class BrickBreaker extends FlameGame
with HasCollisionDetection, KeyboardEvents { // Modify this line
BrickBreaker()
: super(
camera: CameraComponent.withFixedResolution(
width: gameWidth,
height: gameHeight,
),
);
final rand = math.Random();
double get width => size.x;
double get height => size.y;
@override
FutureOr<void> onLoad() async {
super.onLoad();
camera.viewfinder.anchor = Anchor.topLeft;
world.add(PlayArea());
world.add(Ball(
radius: ballRadius,
position: size / 2,
velocity: Vector2((rand.nextDouble() - 0.5) * width, height * 0.2)
.normalized()
..scale(height / 4)));
world.add(Bat( // Add from here...
size: Vector2(batWidth, batHeight),
cornerRadius: const Radius.circular(ballRadius / 2),
position: Vector2(width / 2, height * 0.95))); // To here
debugMode = true;
}
@override // Add from here...
KeyEventResult onKeyEvent(
KeyEvent event, Set<LogicalKeyboardKey> keysPressed) {
super.onKeyEvent(event, keysPressed);
switch (event.logicalKey) {
case LogicalKeyboardKey.arrowLeft:
world.children.query<Bat>().first.moveBy(-batStep);
case LogicalKeyboardKey.arrowRight:
world.children.query<Bat>().first.moveBy(batStep);
}
return KeyEventResult.handled;
} // To here
}
Việc thêm trình trộn KeyboardEvents
và phương thức onKeyEvent
được ghi đè sẽ xử lý hoạt động nhập bằng bàn phím. Hãy nhớ lại mã bạn đã thêm trước đó để di chuyển gậy theo số bước thích hợp.
Đoạn mã được thêm vào còn lại sẽ thêm con dơi vào thế giới trò chơi ở vị trí thích hợp và với tỷ lệ phù hợp. Việc trình bày tất cả các chế độ cài đặt này trong tệp này sẽ đơn giản hoá khả năng điều chỉnh kích thước tương đối của gậy và bóng để tạo cảm giác phù hợp cho trò chơi.
Nếu chơi trò chơi ở thời điểm này, bạn sẽ thấy mình có thể di chuyển gậy để chặn bóng, nhưng không nhận được phản hồi nào, ngoài nhật ký gỡ lỗi mà bạn đã để lại trong mã phát hiện va chạm của Ball
.
Đã đến lúc khắc phục vấn đề đó ngay bây giờ. Chỉnh sửa thành phần Ball
như sau.
lib/src/components/ball.dart
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/effects.dart'; // Add this import
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
import 'bat.dart'; // And this import
import 'play_area.dart';
class Ball extends CircleComponent
with CollisionCallbacks, HasGameReference<BrickBreaker> {
Ball({
required this.velocity,
required super.position,
required double radius,
}) : super(
radius: radius,
anchor: Anchor.center,
paint: Paint()
..color = const Color(0xff1e6091)
..style = PaintingStyle.fill,
children: [CircleHitbox()]);
final Vector2 velocity;
@override
void update(double dt) {
super.update(dt);
position += velocity * dt;
}
@override
void onCollisionStart(
Set<Vector2> intersectionPoints, PositionComponent other) {
super.onCollisionStart(intersectionPoints, other);
if (other is PlayArea) {
if (intersectionPoints.first.y <= 0) {
velocity.y = -velocity.y;
} else if (intersectionPoints.first.x <= 0) {
velocity.x = -velocity.x;
} else if (intersectionPoints.first.x >= game.width) {
velocity.x = -velocity.x;
} else if (intersectionPoints.first.y >= game.height) {
add(RemoveEffect( // Modify from here...
delay: 0.35,
));
}
} else if (other is Bat) {
velocity.y = -velocity.y;
velocity.x = velocity.x +
(position.x - other.position.x) / other.size.x * game.width * 0.3;
} else { // To here.
debugPrint('collision with $other');
}
}
}
Những thay đổi về mã này giúp khắc phục hai vấn đề riêng biệt.
Đầu tiên, thao tác này cố định quả bóng nhảy ra khỏi sự tồn tại ngay khi quả bóng chạm vào cuối màn hình. Để khắc phục vấn đề này, bạn thay thế lệnh gọi removeFromParent
bằng RemoveEffect
. RemoveEffect
loại bỏ bóng khỏi thế giới trò chơi sau khi để bóng thoát khỏi khu vực chơi có thể xem.
Thứ hai, những thay đổi này khắc phục cách xử lý va chạm giữa gậy và bóng. Mã xử lý này hoạt động rất có lợi cho người chơi. Chỉ cần người chơi chạm bóng bằng gậy, bóng sẽ trở lại đầu màn hình. Nếu bạn cảm thấy quá dễ chịu và bạn muốn nội dung nào đó thực tế hơn, hãy thay đổi cách xử lý này cho phù hợp hơn với cảm nhận mà bạn muốn trò chơi của mình mang lại.
Hãy chỉ ra sự phức tạp của việc cập nhật velocity
. Tính năng này không chỉ đảo ngược thành phần y
của vận tốc, như đã thực hiện khi va chạm trên tường. Mã này cũng cập nhật thành phần x
theo cách phụ thuộc vào vị trí tương đối của gậy và bóng tại thời điểm tiếp xúc. Điều này giúp người chơi có nhiều quyền kiểm soát hơn đối với hành động của quả bóng, nhưng chính xác thì bóng không được thông báo cho người chơi theo bất kỳ cách nào ngoại trừ cách chơi.
Bây giờ, bạn đã có gậy để đánh bóng, sẽ thật dễ dàng nếu bạn có một số viên gạch để phá bóng với quả bóng!
8. Phá vỡ bức tường
Tạo khối hình
Để thêm khối hình vào trò chơi,
- Chèn một số hằng số trong tệp
lib/src/config.dart
như sau.
lib/src/config.dart
import 'package:flutter/material.dart'; // Add this import
const brickColors = [ // Add this const
Color(0xfff94144),
Color(0xfff3722c),
Color(0xfff8961e),
Color(0xfff9844a),
Color(0xfff9c74f),
Color(0xff90be6d),
Color(0xff43aa8b),
Color(0xff4d908e),
Color(0xff277da1),
Color(0xff577590),
];
const gameWidth = 820.0;
const gameHeight = 1600.0;
const ballRadius = gameWidth * 0.02;
const batWidth = gameWidth * 0.2;
const batHeight = ballRadius * 2;
const batStep = gameWidth * 0.05;
const brickGutter = gameWidth * 0.015; // Add from here...
final brickWidth =
(gameWidth - (brickGutter * (brickColors.length + 1)))
/ brickColors.length;
const brickHeight = gameHeight * 0.03;
const difficultyModifier = 1.03; // To here.
- Chèn thành phần
Brick
như sau.
lib/src/components/brick.dart
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
import '../config.dart';
import 'ball.dart';
import 'bat.dart';
class Brick extends RectangleComponent
with CollisionCallbacks, HasGameReference<BrickBreaker> {
Brick({required super.position, required Color color})
: super(
size: Vector2(brickWidth, brickHeight),
anchor: Anchor.center,
paint: Paint()
..color = color
..style = PaintingStyle.fill,
children: [RectangleHitbox()],
);
@override
void onCollisionStart(
Set<Vector2> intersectionPoints, PositionComponent other) {
super.onCollisionStart(intersectionPoints, other);
removeFromParent();
if (game.world.children.query<Brick>().length == 1) {
game.world.removeAll(game.world.children.query<Ball>());
game.world.removeAll(game.world.children.query<Bat>());
}
}
}
Đến đây, bạn đã quen thuộc với hầu hết mã này. Mã này sử dụng RectangleComponent
, với cả tính năng phát hiện xung đột và tham chiếu an toàn về kiểu đến trò chơi BrickBreaker
ở đầu cây thành phần.
Khái niệm mới quan trọng nhất mà mã này giới thiệu là cách người chơi đạt được điều kiện chiến thắng. Kiểm tra điều kiện chiến thắng sẽ truy vấn mọi người tìm kiếm khối hình và xác nhận rằng chỉ còn một khối hình. Điều này có thể hơi khó hiểu vì dòng trước xóa khối hình này khỏi thành phần mẹ.
Điểm quan trọng cần hiểu là xoá thành phần là một lệnh đã xếp hàng đợi. Thao tác này sẽ loại bỏ khối hình sau khi mã này chạy, nhưng trước lượt đánh dấu nhịp độ khung hình tiếp theo của thế giới trò chơi.
Để BrickBreaker
có thể truy cập vào thành phần Brick
, hãy chỉnh sửa lib/src/components/components.dart
như sau.
lib/src/components/components.dart
export 'ball.dart';
export 'bat.dart';
export 'brick.dart'; // Add this export
export 'play_area.dart';
Thêm những khối hình vào thế giới
Cập nhật thành phần Ball
như sau.
lib/src/components/ball.dart
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
import 'bat.dart';
import 'brick.dart'; // Add this import
import 'play_area.dart';
class Ball extends CircleComponent
with CollisionCallbacks, HasGameReference<BrickBreaker> {
Ball({
required this.velocity,
required super.position,
required double radius,
required this.difficultyModifier, // Add this parameter
}) : super(
radius: radius,
anchor: Anchor.center,
paint: Paint()
..color = const Color(0xff1e6091)
..style = PaintingStyle.fill,
children: [CircleHitbox()]);
final Vector2 velocity;
final double difficultyModifier; // Add this member
@override
void update(double dt) {
super.update(dt);
position += velocity * dt;
}
@override
void onCollisionStart(
Set<Vector2> intersectionPoints, PositionComponent other) {
super.onCollisionStart(intersectionPoints, other);
if (other is PlayArea) {
if (intersectionPoints.first.y <= 0) {
velocity.y = -velocity.y;
} else if (intersectionPoints.first.x <= 0) {
velocity.x = -velocity.x;
} else if (intersectionPoints.first.x >= game.width) {
velocity.x = -velocity.x;
} else if (intersectionPoints.first.y >= game.height) {
add(RemoveEffect(
delay: 0.35,
));
}
} else if (other is Bat) {
velocity.y = -velocity.y;
velocity.x = velocity.x +
(position.x - other.position.x) / other.size.x * game.width * 0.3;
} else if (other is Brick) { // Modify from here...
if (position.y < other.position.y - other.size.y / 2) {
velocity.y = -velocity.y;
} else if (position.y > other.position.y + other.size.y / 2) {
velocity.y = -velocity.y;
} else if (position.x < other.position.x) {
velocity.x = -velocity.x;
} else if (position.x > other.position.x) {
velocity.x = -velocity.x;
}
velocity.setFrom(velocity * difficultyModifier); // To here.
}
}
}
Phiên bản này giới thiệu khía cạnh mới duy nhất, một hệ số sửa đổi độ khó giúp tăng vận tốc của bóng sau mỗi va chạm khối gạch. Bạn cần thử nghiệm thông số có thể thay đổi này để tìm ra đường cong độ khó phù hợp cho trò chơi của mình.
Chỉnh sửa trò chơi BrickBreaker
như sau.
lib/src/brick_breaker.dart
import 'dart:async';
import 'dart:math' as math;
import 'package:flame/components.dart';
import 'package:flame/events.dart';
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'components/components.dart';
import 'config.dart';
class BrickBreaker extends FlameGame
with HasCollisionDetection, KeyboardEvents {
BrickBreaker()
: super(
camera: CameraComponent.withFixedResolution(
width: gameWidth,
height: gameHeight,
),
);
final rand = math.Random();
double get width => size.x;
double get height => size.y;
@override
FutureOr<void> onLoad() async {
super.onLoad();
camera.viewfinder.anchor = Anchor.topLeft;
world.add(PlayArea());
world.add(Ball(
difficultyModifier: difficultyModifier, // Add this argument
radius: ballRadius,
position: size / 2,
velocity: Vector2((rand.nextDouble() - 0.5) * width, height * 0.2)
.normalized()
..scale(height / 4)));
world.add(Bat(
size: Vector2(batWidth, batHeight),
cornerRadius: const Radius.circular(ballRadius / 2),
position: Vector2(width / 2, height * 0.95)));
await world.addAll([ // Add from here...
for (var i = 0; i < brickColors.length; i++)
for (var j = 1; j <= 5; j++)
Brick(
position: Vector2(
(i + 0.5) * brickWidth + (i + 1) * brickGutter,
(j + 2.0) * brickHeight + j * brickGutter,
),
color: brickColors[i],
),
]); // To here.
debugMode = true;
}
@override
KeyEventResult onKeyEvent(
KeyEvent event, Set<LogicalKeyboardKey> keysPressed) {
super.onKeyEvent(event, keysPressed);
switch (event.logicalKey) {
case LogicalKeyboardKey.arrowLeft:
world.children.query<Bat>().first.moveBy(-batStep);
case LogicalKeyboardKey.arrowRight:
world.children.query<Bat>().first.moveBy(batStep);
}
return KeyEventResult.handled;
}
}
Nếu bạn chạy trò chơi như hiện tại, trò chơi sẽ hiển thị tất cả cơ chế chính của trò chơi. Bạn có thể tắt gỡ lỗi và gọi là xong, nhưng có gì đó thiếu.
Thế còn màn hình chào mừng, trò chơi trên màn hình và có thể là điểm số thì sao? Flutter có thể thêm những tính năng này vào trò chơi, và đó là nơi tiếp theo bạn chú ý.
9. Thắng trò chơi
Thêm trạng thái phát
Ở bước này, bạn nhúng trò chơi Flame vào bên trong một trình bao bọc Flutter, sau đó thêm lớp phủ Flutter cho màn hình chào mừng, màn hình kết thúc và màn hình giành chiến thắng.
Trước tiên, bạn sửa đổi các tệp trò chơi và thành phần để triển khai trạng thái phát phản ánh liệu có hiển thị lớp phủ hay không và nếu có thì là lớp nào.
- Sửa đổi trò chơi
BrickBreaker
như sau.
lib/src/brick_breaker.dart
import 'dart:async';
import 'dart:math' as math;
import 'package:flame/components.dart';
import 'package:flame/events.dart';
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'components/components.dart';
import 'config.dart';
enum PlayState { welcome, playing, gameOver, won } // Add this enumeration
class BrickBreaker extends FlameGame
with HasCollisionDetection, KeyboardEvents, TapDetector { // Modify this line
BrickBreaker()
: super(
camera: CameraComponent.withFixedResolution(
width: gameWidth,
height: gameHeight,
),
);
final rand = math.Random();
double get width => size.x;
double get height => size.y;
late PlayState _playState; // Add from here...
PlayState get playState => _playState;
set playState(PlayState playState) {
_playState = playState;
switch (playState) {
case PlayState.welcome:
case PlayState.gameOver:
case PlayState.won:
overlays.add(playState.name);
case PlayState.playing:
overlays.remove(PlayState.welcome.name);
overlays.remove(PlayState.gameOver.name);
overlays.remove(PlayState.won.name);
}
} // To here.
@override
FutureOr<void> onLoad() async {
super.onLoad();
camera.viewfinder.anchor = Anchor.topLeft;
world.add(PlayArea());
playState = PlayState.welcome; // Add from here...
}
void startGame() {
if (playState == PlayState.playing) return;
world.removeAll(world.children.query<Ball>());
world.removeAll(world.children.query<Bat>());
world.removeAll(world.children.query<Brick>());
playState = PlayState.playing; // To here.
world.add(Ball(
difficultyModifier: difficultyModifier,
radius: ballRadius,
position: size / 2,
velocity: Vector2((rand.nextDouble() - 0.5) * width, height * 0.2)
.normalized()
..scale(height / 4)));
world.add(Bat(
size: Vector2(batWidth, batHeight),
cornerRadius: const Radius.circular(ballRadius / 2),
position: Vector2(width / 2, height * 0.95)));
world.addAll([ // Drop the await
for (var i = 0; i < brickColors.length; i++)
for (var j = 1; j <= 5; j++)
Brick(
position: Vector2(
(i + 0.5) * brickWidth + (i + 1) * brickGutter,
(j + 2.0) * brickHeight + j * brickGutter,
),
color: brickColors[i],
),
]);
} // Drop the debugMode
@override // Add from here...
void onTap() {
super.onTap();
startGame();
} // To here.
@override
KeyEventResult onKeyEvent(
KeyEvent event, Set<LogicalKeyboardKey> keysPressed) {
super.onKeyEvent(event, keysPressed);
switch (event.logicalKey) {
case LogicalKeyboardKey.arrowLeft:
world.children.query<Bat>().first.moveBy(-batStep);
case LogicalKeyboardKey.arrowRight:
world.children.query<Bat>().first.moveBy(batStep);
case LogicalKeyboardKey.space: // Add from here...
case LogicalKeyboardKey.enter:
startGame(); // To here.
}
return KeyEventResult.handled;
}
@override
Color backgroundColor() => const Color(0xfff2e8cf); // Add this override
}
Mã này thay đổi rất nhiều điểm của trò chơi BrickBreaker
. Việc thêm bản liệt kê playState
sẽ mất nhiều công sức. Phần này ghi lại vị trí mà người chơi đang vào, chơi và thua hoặc thắng trò chơi. Ở đầu tệp, bạn xác định giá trị liệt kê, sau đó tạo thực thể dưới dạng trạng thái ẩn bằng các phương thức getter và setter phù hợp. Các phương thức getter và setter này cho phép sửa đổi lớp phủ khi nhiều phần của trò chơi kích hoạt chuyển đổi trạng thái chơi.
Tiếp theo, bạn phân tách mã trong onLoad
thành onLoad và một phương thức startGame
mới. Trước khi có sự thay đổi này, bạn chỉ có thể bắt đầu trò chơi mới thông qua việc khởi động lại trò chơi. Với những tính năng bổ sung mới này, giờ đây, người chơi có thể bắt đầu trò chơi mới mà không cần áp dụng biện pháp quyết liệt nào như vậy.
Để cho phép người chơi bắt đầu trò chơi mới, bạn đã định cấu hình hai trình xử lý mới cho trò chơi. Bạn đã thêm một trình xử lý nhấn và mở rộng trình xử lý bàn phím để cho phép người dùng bắt đầu trò chơi mới theo nhiều cách thức. Với trạng thái chơi được mô hình hoá, bạn nên cập nhật các thành phần để kích hoạt quá trình chuyển đổi trạng thái chơi khi người chơi thắng hoặc thua.
- Sửa đổi thành phần
Ball
như sau.
lib/src/components/ball.dart
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
import 'bat.dart';
import 'brick.dart';
import 'play_area.dart';
class Ball extends CircleComponent
with CollisionCallbacks, HasGameReference<BrickBreaker> {
Ball({
required this.velocity,
required super.position,
required double radius,
required this.difficultyModifier,
}) : super(
radius: radius,
anchor: Anchor.center,
paint: Paint()
..color = const Color(0xff1e6091)
..style = PaintingStyle.fill,
children: [CircleHitbox()]);
final Vector2 velocity;
final double difficultyModifier;
@override
void update(double dt) {
super.update(dt);
position += velocity * dt;
}
@override
void onCollisionStart(
Set<Vector2> intersectionPoints, PositionComponent other) {
super.onCollisionStart(intersectionPoints, other);
if (other is PlayArea) {
if (intersectionPoints.first.y <= 0) {
velocity.y = -velocity.y;
} else if (intersectionPoints.first.x <= 0) {
velocity.x = -velocity.x;
} else if (intersectionPoints.first.x >= game.width) {
velocity.x = -velocity.x;
} else if (intersectionPoints.first.y >= game.height) {
add(RemoveEffect(
delay: 0.35,
onComplete: () { // Modify from here
game.playState = PlayState.gameOver;
})); // To here.
}
} else if (other is Bat) {
velocity.y = -velocity.y;
velocity.x = velocity.x +
(position.x - other.position.x) / other.size.x * game.width * 0.3;
} else if (other is Brick) {
if (position.y < other.position.y - other.size.y / 2) {
velocity.y = -velocity.y;
} else if (position.y > other.position.y + other.size.y / 2) {
velocity.y = -velocity.y;
} else if (position.x < other.position.x) {
velocity.x = -velocity.x;
} else if (position.x > other.position.x) {
velocity.x = -velocity.x;
}
velocity.setFrom(velocity * difficultyModifier);
}
}
}
Thay đổi nhỏ này sẽ thêm một lệnh gọi lại onComplete
vào RemoveEffect
để kích hoạt trạng thái phát gameOver
. Điều này sẽ đúng nếu người chơi cho phép bóng thoát ra khỏi cuối màn hình.
- Chỉnh sửa thành phần
Brick
như sau.
lib/src/components/brick.dart
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
import '../config.dart';
import 'ball.dart';
import 'bat.dart';
class Brick extends RectangleComponent
with CollisionCallbacks, HasGameReference<BrickBreaker> {
Brick({required super.position, required Color color})
: super(
size: Vector2(brickWidth, brickHeight),
anchor: Anchor.center,
paint: Paint()
..color = color
..style = PaintingStyle.fill,
children: [RectangleHitbox()],
);
@override
void onCollisionStart(
Set<Vector2> intersectionPoints, PositionComponent other) {
super.onCollisionStart(intersectionPoints, other);
removeFromParent();
if (game.world.children.query<Brick>().length == 1) {
game.playState = PlayState.won; // Add this line
game.world.removeAll(game.world.children.query<Ball>());
game.world.removeAll(game.world.children.query<Bat>());
}
}
}
Mặt khác, nếu người chơi có thể phá vỡ tất cả các viên gạch, họ đã kiếm được "trò chơi đã thắng" màn hình. Bạn chơi thật xuất sắc!
Thêm trình bao bọc Flutter
Để cung cấp vị trí nào đó cho phép nhúng trò chơi và thêm lớp phủ trạng thái chơi, hãy thêm giao diện Flutter.
- Tạo thư mục
widgets
tronglib/src
. - Thêm một tệp
game_app.dart
rồi chèn nội dung sau đây vào tệp đó.
lib/src/widgets/game_app.dart
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import '../brick_breaker.dart';
import '../config.dart';
class GameApp extends StatelessWidget {
const GameApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData(
useMaterial3: true,
textTheme: GoogleFonts.pressStart2pTextTheme().apply(
bodyColor: const Color(0xff184e77),
displayColor: const Color(0xff184e77),
),
),
home: Scaffold(
body: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0xffa9d6e5),
Color(0xfff2e8cf),
],
),
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Center(
child: FittedBox(
child: SizedBox(
width: gameWidth,
height: gameHeight,
child: GameWidget.controlled(
gameFactory: BrickBreaker.new,
overlayBuilderMap: {
PlayState.welcome.name: (context, game) => Center(
child: Text(
'TAP TO PLAY',
style:
Theme.of(context).textTheme.headlineLarge,
),
),
PlayState.gameOver.name: (context, game) => Center(
child: Text(
'G A M E O V E R',
style:
Theme.of(context).textTheme.headlineLarge,
),
),
PlayState.won.name: (context, game) => Center(
child: Text(
'Y O U W O N ! ! !',
style:
Theme.of(context).textTheme.headlineLarge,
),
),
},
),
),
),
),
),
),
),
),
);
}
}
Hầu hết nội dung trong tệp này đều tuân theo một bản dựng cây tiện ích Flutter tiêu chuẩn. Các phần cụ thể của Flame bao gồm việc sử dụng GameWidget.controlled
để tạo và quản lý thực thể trò chơi BrickBreaker
và đối số overlayBuilderMap
mới cho GameWidget
.
Các khoá của overlayBuilderMap
này phải khớp với các lớp phủ mà phương thức setter playState
trong BrickBreaker
đã thêm hoặc xoá. Việc cố gắng đặt một lớp phủ không có trong bản đồ này sẽ dẫn đến những khuôn mặt không vui xung quanh.
- Để hiển thị chức năng mới này trên màn hình, hãy thay thế tệp
lib/main.dart
bằng nội dung sau đây.
lib/main.dart
import 'package:flutter/material.dart';
import 'src/widgets/game_app.dart';
void main() {
runApp(const GameApp());
}
Nếu bạn chạy mã này trên iOS, Linux, Windows hoặc web, kết quả dự định sẽ hiển thị trong trò chơi. Nếu nhắm đến macOS hoặc Android, bạn cần điều chỉnh lần cuối để google_fonts
có thể hiển thị.
Bật quyền truy cập phông chữ
Thêm quyền truy cập Internet cho Android
Đối với Android, bạn phải thêm quyền truy cập Internet. Hãy chỉnh sửa AndroidManifest.xml
như sau.
android/app/src/main/AndroidManifest.xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Add the following line -->
<uses-permission android:name="android.permission.INTERNET" />
<application
android:label="brick_breaker"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
Chỉnh sửa tệp quyền cho macOS
Đối với macOS, bạn có hai tệp cần chỉnh sửa.
- Chỉnh sửa tệp
DebugProfile.entitlements
để khớp với mã sau.
macos/Runner/DebugProfile.entitlements
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
<!-- Add from here... -->
<key>com.apple.security.network.client</key>
<true/>
<!-- to here. -->
</dict>
</plist>
- Chỉnh sửa tệp
Release.entitlements
để khớp với mã sau
macos/Runner/Release.entitlements
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<!-- Add from here... -->
<key>com.apple.security.network.client</key>
<true/>
<!-- to here. -->
</dict>
</plist>
Khi chạy đồng thời, màn hình chào mừng sẽ hiển thị và màn hình trò chơi kết thúc hoặc thắng cuộc trên tất cả các nền tảng. Những màn hình đó có thể sẽ đơn giản một chút và sẽ rất tuyệt nếu có điểm số. Vì vậy, hãy đoán xem bạn sẽ làm gì trong bước tiếp theo!
10. Giữ điểm
Thêm điểm số vào trò chơi
Ở bước này, bạn hiển thị điểm số trò chơi cho bối cảnh Flutter xung quanh. Ở bước này, bạn hiển thị trạng thái từ trò chơi Flame cho trình quản lý trạng thái Flutter xung quanh. Điều này cho phép mã trò chơi cập nhật điểm số mỗi khi người chơi đập viên gạch.
- Sửa đổi trò chơi
BrickBreaker
như sau.
lib/src/brick_breaker.dart
import 'dart:async';
import 'dart:math' as math;
import 'package:flame/components.dart';
import 'package:flame/events.dart';
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'components/components.dart';
import 'config.dart';
enum PlayState { welcome, playing, gameOver, won }
class BrickBreaker extends FlameGame
with HasCollisionDetection, KeyboardEvents, TapDetector {
BrickBreaker()
: super(
camera: CameraComponent.withFixedResolution(
width: gameWidth,
height: gameHeight,
),
);
final ValueNotifier<int> score = ValueNotifier(0); // Add this line
final rand = math.Random();
double get width => size.x;
double get height => size.y;
late PlayState _playState;
PlayState get playState => _playState;
set playState(PlayState playState) {
_playState = playState;
switch (playState) {
case PlayState.welcome:
case PlayState.gameOver:
case PlayState.won:
overlays.add(playState.name);
case PlayState.playing:
overlays.remove(PlayState.welcome.name);
overlays.remove(PlayState.gameOver.name);
overlays.remove(PlayState.won.name);
}
}
@override
FutureOr<void> onLoad() async {
super.onLoad();
camera.viewfinder.anchor = Anchor.topLeft;
world.add(PlayArea());
playState = PlayState.welcome;
}
void startGame() {
if (playState == PlayState.playing) return;
world.removeAll(world.children.query<Ball>());
world.removeAll(world.children.query<Bat>());
world.removeAll(world.children.query<Brick>());
playState = PlayState.playing;
score.value = 0; // Add this line
world.add(Ball(
difficultyModifier: difficultyModifier,
radius: ballRadius,
position: size / 2,
velocity: Vector2((rand.nextDouble() - 0.5) * width, height * 0.2)
.normalized()
..scale(height / 4)));
world.add(Bat(
size: Vector2(batWidth, batHeight),
cornerRadius: const Radius.circular(ballRadius / 2),
position: Vector2(width / 2, height * 0.95)));
world.addAll([
for (var i = 0; i < brickColors.length; i++)
for (var j = 1; j <= 5; j++)
Brick(
position: Vector2(
(i + 0.5) * brickWidth + (i + 1) * brickGutter,
(j + 2.0) * brickHeight + j * brickGutter,
),
color: brickColors[i],
),
]);
}
@override
void onTap() {
super.onTap();
startGame();
}
@override
KeyEventResult onKeyEvent(
KeyEvent event, Set<LogicalKeyboardKey> keysPressed) {
super.onKeyEvent(event, keysPressed);
switch (event.logicalKey) {
case LogicalKeyboardKey.arrowLeft:
world.children.query<Bat>().first.moveBy(-batStep);
case LogicalKeyboardKey.arrowRight:
world.children.query<Bat>().first.moveBy(batStep);
case LogicalKeyboardKey.space:
case LogicalKeyboardKey.enter:
startGame();
}
return KeyEventResult.handled;
}
@override
Color backgroundColor() => const Color(0xfff2e8cf);
}
Bằng cách thêm score
vào trò chơi, bạn liên kết trạng thái của trò chơi với tính năng quản lý trạng thái của Flutter.
- Sửa đổi lớp
Brick
để cộng điểm vào điểm số khi người chơi đập gạch.
lib/src/components/brick.dart
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
import '../config.dart';
import 'ball.dart';
import 'bat.dart';
class Brick extends RectangleComponent
with CollisionCallbacks, HasGameReference<BrickBreaker> {
Brick({required super.position, required Color color})
: super(
size: Vector2(brickWidth, brickHeight),
anchor: Anchor.center,
paint: Paint()
..color = color
..style = PaintingStyle.fill,
children: [RectangleHitbox()],
);
@override
void onCollisionStart(
Set<Vector2> intersectionPoints, PositionComponent other) {
super.onCollisionStart(intersectionPoints, other);
removeFromParent();
game.score.value++; // Add this line
if (game.world.children.query<Brick>().length == 1) {
game.playState = PlayState.won;
game.world.removeAll(game.world.children.query<Ball>());
game.world.removeAll(game.world.children.query<Bat>());
}
}
}
Tạo một trò chơi đẹp mắt
Giờ đây, bạn có thể theo dõi điểm số trong Flutter, đã đến lúc tập hợp các tiện ích lại để trang trí trông đẹp mắt.
- Tạo
score_card.dart
tronglib/src/widgets
rồi thêm đoạn mã sau.
lib/src/widgets/score_card.dart
import 'package:flutter/material.dart';
class ScoreCard extends StatelessWidget {
const ScoreCard({
super.key,
required this.score,
});
final ValueNotifier<int> score;
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<int>(
valueListenable: score,
builder: (context, score, child) {
return Padding(
padding: const EdgeInsets.fromLTRB(12, 6, 12, 18),
child: Text(
'Score: $score'.toUpperCase(),
style: Theme.of(context).textTheme.titleLarge!,
),
);
},
);
}
}
- Tạo
overlay_screen.dart
tronglib/src/widgets
rồi thêm mã sau.
Điều này giúp lớp phủ đẹp hơn bằng cách sử dụng sức mạnh của gói flutter_animate
để thêm một số chuyển động và kiểu cho màn hình lớp phủ.
lib/src/widgets/overlay_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
class OverlayScreen extends StatelessWidget {
const OverlayScreen({
super.key,
required this.title,
required this.subtitle,
});
final String title;
final String subtitle;
@override
Widget build(BuildContext context) {
return Container(
alignment: const Alignment(0, -0.15),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
title,
style: Theme.of(context).textTheme.headlineLarge,
).animate().slideY(duration: 750.ms, begin: -3, end: 0),
const SizedBox(height: 16),
Text(
subtitle,
style: Theme.of(context).textTheme.headlineSmall,
)
.animate(onPlay: (controller) => controller.repeat())
.fadeIn(duration: 1.seconds)
.then()
.fadeOut(duration: 1.seconds),
],
),
);
}
}
Để tìm hiểu sâu hơn về sức mạnh của flutter_animate
, hãy tham khảo lớp học lập trình Xây dựng giao diện người dùng thế hệ mới trong Flutter.
Mã này đã thay đổi rất nhiều trong thành phần GameApp
. Trước tiên, để cho phép ScoreCard
truy cập vào score
, bạn hãy chuyển đổi lớp này từ StatelessWidget
thành StatefulWidget
. Để thêm thẻ điểm, bạn phải thêm Column
để xếp điểm số phía trên trò chơi.
Thứ hai, để nâng cao trải nghiệm chào mừng, kết thúc trò chơi và giành chiến thắng, bạn đã thêm tiện ích OverlayScreen
mới.
lib/src/widgets/game_app.dart
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import '../brick_breaker.dart';
import '../config.dart';
import 'overlay_screen.dart'; // Add this import
import 'score_card.dart'; // And this one too
class GameApp extends StatefulWidget { // Modify this line
const GameApp({super.key});
@override // Add from here...
State<GameApp> createState() => _GameAppState();
}
class _GameAppState extends State<GameApp> {
late final BrickBreaker game;
@override
void initState() {
super.initState();
game = BrickBreaker();
} // To here.
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData(
useMaterial3: true,
textTheme: GoogleFonts.pressStart2pTextTheme().apply(
bodyColor: const Color(0xff184e77),
displayColor: const Color(0xff184e77),
),
),
home: Scaffold(
body: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0xffa9d6e5),
Color(0xfff2e8cf),
],
),
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Center(
child: Column( // Modify from here...
children: [
ScoreCard(score: game.score),
Expanded(
child: FittedBox(
child: SizedBox(
width: gameWidth,
height: gameHeight,
child: GameWidget(
game: game,
overlayBuilderMap: {
PlayState.welcome.name: (context, game) =>
const OverlayScreen(
title: 'TAP TO PLAY',
subtitle: 'Use arrow keys or swipe',
),
PlayState.gameOver.name: (context, game) =>
const OverlayScreen(
title: 'G A M E O V E R',
subtitle: 'Tap to Play Again',
),
PlayState.won.name: (context, game) =>
const OverlayScreen(
title: 'Y O U W O N ! ! !',
subtitle: 'Tap to Play Again',
),
},
),
),
),
),
],
), // To here.
),
),
),
),
),
);
}
}
Với tất cả những gì có sẵn, giờ đây bạn có thể chạy trò chơi này trên bất kỳ nền tảng nào trong số 6 nền tảng đích Flutter. Trò chơi phải có dạng như sau.
11. 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