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à trò chơi Breakout của Steve Wozniak. Bạn sẽ sử dụng các Thành phần của Flame để vẽ gậy, bóng và gạch. Bạn sẽ sử dụng Hiệu ứng của Flame để tạo hiệu ứng cho chuyển động của con dơi và xem cách tích hợp Flame với hệ thống quản lý trạng thái của Flutter.
Sau khi hoàn tất, trò chơi của bạn sẽ có dạ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
- Kiến thức cơ bản về cách hoạt động của Flame, bắt đầu từ
GameWidget
. - Cách sử dụng vòng lặp trò chơi.
- Cách hoạt động của
Component
trong Flame. Chúng tương tự nhưWidget
của Flutter. - Cách xử lý xung đột.
- Cách sử dụng
Effect
để tạo hiệu ứng chuyển động choComponent
. - Cách phủ các
Widget
của Flutter 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ẽ tạo một trò chơi 2D bằng Flutter và Flame. 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 độ khung hình tối thiểu là 60 fps bằng vòng lặp trò chơi của Flame.
- Sử dụng các chức năng của Flutter như gói
google_fonts
vàflutter_animate
để tái tạo cảm giác chơi game arcade của 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 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à phần mềm miễn phí và hoạt động trên tất cả các nền tảng chính. Chúng tôi sử dụng VS Code cho lớp học lập trình này vì hướng dẫn mặc định là các phím tắt dành riêng cho VS Code. Các tác 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 thao tác thích hợp trong trình chỉnh sửa để thực hiện X".
Bạn có thể sử dụng bất kỳ trình chỉnh sửa nào bạn muốn: Android Studio, các IDE IntelliJ khác, Emacs, Vim hoặc Notepad++. Tất cả đều hoạt động với Flutter.
Chọn mục tiêu phát triển
Flutter tạo ra 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. Đâ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 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. Để xem trước ứng dụng, bạn kết nối thiết bị Android với máy tính xách tay chạy Windows bằng cáp USB và ứng dụng đang phát triển của bạn sẽ chạy trên thiết bị Android đã kết nối đó 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, mục tiêu này sẽ chạy ứng dụng đang phát triển dưới dạng một ứng dụng Windows cùng với trình chỉnh sửa của bạn.
Hãy chọn trước khi tiếp tục. Bạn luôn có thể chạy ứng dụng của mình trên các hệ điều hành khác sau này. Việc chọn mục tiêu phát triển sẽ giúp bước tiếp theo diễn ra suôn sẻ hơn.
Cài đặt Flutter
Bạn có thể xem hướng dẫn mới nhất về cách cài đặt Flutter SDK trên docs.flutter.dev.
Hướng dẫn trên trang web Flutter bao gồm việc 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ợ của 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:
- Flutter SDK
- Visual Studio Code có trình bổ trợ Flutter
- Phần mềm trình 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 đến Windows hoặc Xcode để nhắm đến macOS hoặc iOS)
Trong phần tiếp theo, bạn sẽ tạo dự án Flutter đầu tiên.
Nếu cần khắc phục sự cố, bạn có thể thấy một số câu hỏi và câu trả lời (trên StackOverflow) này 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 đường dẫn SDK Flutter?
- Tôi phải làm gì khi không tìm thấy lệnh Flutter?
- Làm cách nào để khắc phục vấn đề "Đang chờ một lệnh flutter khác giải phóng khoá khởi độ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ý lỗi không tìm thấy công cụ Android
sdkmanager
? - Làm cách nào để xử lý lỗi "Thiếu thành phần
cmdline-tools
"? - 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 trong VS Code?
3. Tạo một dự án
Tạo dự án Flutter đầu tiên
Thao tác này bao gồm việc mở VS Code và tạo mẫu ứng dụng Flutter trong một thư mục mà bạn chọn.
- Khởi chạy Visual Studio Code.
- Mở bảng lệnh (
F1
hoặcCtrl+Shift+P
hoặcShift+Cmd+P
), sau đó nhập "flutter new". Khi lệnh 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à thư mục không yêu cầu đặc quyền nâng cao hoặc có khoảng trắng trong đường dẫn. Ví dụ: thư mục chính hoặc
C:\src\
.
- Đặt tên cho dự án
brick_breaker
. Phần còn lại của lớp học lập trình này giả định rằng bạn đã đặt tên cho ứng dụng của mình làbrick_breaker
.
Giờ đây, Flutter sẽ tạo thư mục dự á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 cấu trúc 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ã ví dụ được cung cấp trong lớp học lập trình này vào ứng dụng của bạn.
- Trong ngăn bên trái của VS Code, hãy nhấp vào Explorer (Trình khám phá) rồi mở tệp
pubspec.yaml
.
- Thay thế nội dung của tệp này bằng nội dung sau:
pubspec.yaml
name: brick_breaker
description: "Re-implementing Woz's Breakout"
publish_to: "none"
version: 0.1.0
environment:
sdk: ^3.8.0
dependencies:
flutter:
sdk: flutter
flame: ^1.28.1
flutter_animate: ^4.5.2
google_fonts: ^6.2.1
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^5.0.0
flutter:
uses-material-design: true
Tệp pubspec.yaml
chỉ định thông tin cơ bản về ứng dụng của bạn, chẳng hạn như phiên bản hiện tại, các phần phụ thuộc và những thành phần mà ứng dụng sẽ đi kèm.
- Mở tệp
main.dart
trong thư mụclib/
.
- Thay thế nội dung của tệp này bằng nội dung sau:
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 mọi thứ đều hoạt động. Một cửa sổ mới sẽ xuất hiện, chỉ có nền đen trống. Giờ đây, trò chơi điện tử tệ nhất thế giới đang hiển thị ở tốc độ 60 khung hình/giây!
4. Tạo trò chơi
Đánh giá trò chơi
Một trò chơi được chơi ở hai chiều (2D) cần có một khu vực chơi. Bạn sẽ tạo một vùng có kích thước cụ thể, sau đó sử dụng các kích thước này để điều chỉnh kích thước của các khía cạnh khác trong 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 với gốc (0,0)
ở tâm 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 dọc theo trục y. Tiêu chuẩn này áp dụng cho hầu hết các trò chơi hiện nay, đặc biệt là những trò chơi có 3 chiều.
Quy ước khi tạo trò chơi Breakout ban đầu là đặt điểm gốc ở góc trên cùng bên trái. Hướng x dương vẫn giữ nguyên, nhưng y bị đảo ngược. Hướng x dương là sang phải và y là xuống dưới. Để phù hợp với thời đại, trò chơi này đặt gốc ở góc trên cùng bên trái.
Tạo một tệp có tên config.dart
trong một thư mục mới có tên lib/src
. Tệp này sẽ có thêm nhiều hằng số 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 820 pixel và chiều cao 1.600 pixel. Khu vực trò chơi sẽ điều chỉnh tỷ lệ cho phù hợp với cửa sổ mà khu vực đó hiển thị, nhưng tất cả các thành phần được thêm vào màn hình đều tuân theo chiều cao và chiều rộng này.
Tạo PlayArea
Trong trò chơi Breakout, quả bóng sẽ nảy ra khỏi các bức tường của khu vực chơi. Để xử lý các va chạm, trước tiên, bạn cần có thành phần PlayArea
.
- Tạo một tệp có tên
play_area.dart
trong một thư mục mới có tênlib/src/components
. - Thêm nội dung sau 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);
}
}
Flutter có Widget
, còn Flame có Component
. Trong khi các ứng dụng Flutter bao gồm việc tạo các cây tiện ích, thì các trò chơi Flame bao gồm việc duy trì các cây thành phần.
Đó là đ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 tạo để dùng cho việc cập nhật lớp RenderObject
liên tục và có thể thay đổi. Các thành phần của Flame có tính bền vững và có thể thay đổi, với kỳ vọng rằng nhà phát triển sẽ sử dụng các 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 bằng vòng lặp trò chơi, có trong bước tiếp theo.
- Để kiểm soát tình trạng lộn xộn, hãy thêm một tệp chứa tất cả các thành phần trong dự án này. Tạo tệp
components.dart
tronglib/src/components
rồi thêm nội dung sau.
lib/src/components/components.dart
export 'play_area.dart';
Chỉ thị export
đóng vai trò ngược lại với import
. Tệp này khai báo chức năng mà tệp này hiển thị khi được nhập vào một tệp khác. Tệp này sẽ có thêm nhiều mục khi bạn thêm các thành phần mới trong những bước tiếp theo.
Tạo trò chơi Flame
Để loại bỏ các đường gợn sóng màu đỏ ở bước trước, hãy tạo 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
rồi 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 tạo thực thể trò chơi, mã này sẽ định cấu hình trò chơi để sử dụng tính năng kết xuất đồ hoạ có độ 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ư khi cần.
Bạ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 được ghi đè onLoad
, mã của bạn sẽ thực hiện 2 thao tác.
- Định cấu hình góc trên cùng bên trái làm điểm neo cho kính ngắm. Theo mặc định,
viewfinder
sử dụng phần giữa của vùng làm điểm neo cho(0,0)
. - Thêm
PlayArea
vàoworld
. The world represents the game world. Nó chiếu tất cả các thành phần con thông qua quá trình biến đổi khung hiển thịCameraComponent
.
Đưa trò chơi lên màn hình
Để xem tất cả các thay đổi mà bạn đã thực hiện trong bước này, hãy cập nhật tệp lib/main.dart
bằng các thay đổi sau.
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 bạn thực hiện các thay đổi này, hãy khởi động lại trò chơi. Trò chơi sẽ có dạ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à làm cho quả bóng đó chuyển động!
5. Hiển thị quả bóng
Tạo thành phần quả bóng
Để đặt một quả bóng chuyển động lê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 các hằng số được đặt tên dưới dạng các giá trị phái sinh sẽ xuất hiện nhiều lần trong lớp học lập trình này. Điều này cho phép bạn sửa đổi gameWidth
và gameHeight
ở cấp cao nhất để khám phá cách giao diện của trò chơi thay đổi theo kết quả.
- Tạo thành phần
Ball
trong một tệp có tênball.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 đã xác định PlayArea
bằng RectangleComponent
, vì vậy, có lý do để tin rằng sẽ có nhiều hình dạng hơn. CircleComponent
, chẳng hạn như RectangleComponent
, bắt nguồn từ PositionedComponent
, vì vậy, bạn có thể đặt quả bóng trên màn hình. Quan trọng hơn, bạn có thể cập nhật vị trí của phần này.
Thành phần này giới thiệu khái niệm về velocity
, hay sự thay đổi về vị trí theo thời gian. Vận tốc là một đối tượng Vector2
vì vận tốc vừa là tốc độ vừa là hướng. Để cập nhật vị trí, hãy ghi đè phương thức update
mà công cụ trò chơi gọi cho mọi khung hình. dt
là khoảng thời gian giữa khung hình trước và khung hình này. Điều này giúp bạn thích ứng với các yếu tố như tốc độ khung hình khác nhau (60 Hz hoặc 120 Hz) hoặc khung hình dài do tính toán quá mức.
Hãy đặc biệt chú ý đến nội dung cập nhật position += velocity * dt
. Đây là cách bạn triển khai việc cập nhật một mô phỏng rời rạc về chuyển động theo thời gian.
- Để thêm 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 quả bóng vào thế giới
Bạn có một quả bóng. Đặt đối tượng đó vào thế giới thực và thiết lập để đối tượng di chuyển xung quanh khu vực 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à /
) để chia tỷ lệ Vector2
theo giá trị vô hướng.
Việc đặt velocity
cho quả bóng sẽ phức tạp hơn. Mục đích là di chuyển quả bóng xuống màn hình theo hướng ngẫu nhiên với tốc độ hợp lý. Lệnh gọi đến phương thức normalized
sẽ tạo một đối tượng Vector2
được đặt cùng hướng với Vector2
ban đầu, nhưng được thu nhỏ xuống khoảng cách là 1. Điều này giúp tốc độ của quả bóng luôn nhất quán, bất kể quả bóng đi theo hướng nào. Sau đó, vận tốc của quả bóng sẽ được tăng lên thành 1/4 chiều cao của trò chơi.
Để có được những giá trị phù hợp, bạn cần thực hiện một số lần lặp lại, còn được gọi là kiểm thử trong ngành.
Dòng cuối cùng bật chế độ hiển thị gỡ lỗi, chế độ này sẽ thêm thông tin bổ sung vào màn hình để hỗ trợ gỡ lỗi.
Khi bạn chạy trò chơi, trò chơi sẽ có dạng như màn hình sau.
Cả thành phần PlayArea
và thành phần Ball
đều có thông tin gỡ lỗi, nhưng các lớp nền sẽ cắt các số của PlayArea
. Lý do mọi thứ đều có thông tin gỡ lỗi được hiển thị là vì bạn đã bật debugMode
cho toàn bộ cây thành phần. Bạn cũng có thể chỉ bật chế độ gỡ lỗi cho các thành phần đã chọn nếu điều đó 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 quả bóng không tương tác với các bức tường như mong đợi. Để đạt được hiệu ứng đó, bạn cần thêm tính năng phát hiện va chạm. Bạn sẽ thực hiện việc này trong bước tiếp theo.
6. Di chuyển xung quanh
Thêm tính năng phát hiện va chạm
Tính năng phát hiện va chạm bổ sung một hành vi mà 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 thành phần kết hợp HasCollisionDetection
vào trò chơi BrickBreaker
như minh hoạ 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 va chạm của thành phần và kích hoạt lệnh gọi lại va chạm trên mọi dấu thời gian của trò chơi.
Để bắt đầu điền vào các hộp nhấn của trò chơi, hãy sửa đổi thành phần PlayArea
như minh hoạ:
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 thành phần RectangleHitbox
làm thành phần con của RectangleComponent
sẽ tạo ra một hộp nhấn để phát hiện va chạm khớp với kích thước của thành phần mẹ. Có một hàm khởi tạo nhà máy cho RectangleHitbox
có tên là relative
cho những trường hợp bạn muốn một hitbox nhỏ hơn hoặc lớn hơn thành phần gốc.
Nảy bóng
Cho đến nay, việc thêm tính năng phát hiện va chạm không ảnh hưởng gì đến lối chơi. Giá trị này sẽ thay đổi khi bạn sửa đổi thành phần Ball
. Đó là 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 thực hiện một thay đổi lớn bằng cách thêm lệnh gọi lại onCollisionStart
. Hệ thống phát hiện va chạm được thêm vào BrickBreaker
trong ví dụ trước sẽ gọi lệnh gọi lại này.
Trước tiên, mã này sẽ kiểm tra xem Ball
có va chạm với PlayArea
hay không. Hiện tại, điều này có vẻ dư thừa vì không có thành phần nào khác trong thế giới trò chơi. Điều này 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 đó, nó cũng thêm một điều kiện else
để xử lý khi quả bóng va chạm với những thứ không phải là gậy. Đây là một lời nhắc nhẹ nhàng để bạn triển khai logic còn lại (nếu có).
Khi quả bóng va vào bức tường dưới cùng, quả bóng sẽ biến mất khỏi bề mặt chơi trong khi vẫn còn trong tầm nhìn. Bạn sẽ xử lý thành phần này trong một bước sau, bằng cách sử dụng sức mạnh của Hiệu ứng trong Flame.
Giờ đây, khi quả bóng va chạm với các bức tường của trò chơi, chắc chắn sẽ rất hữu ích nếu bạn cho người chơi một cây gậy để đánh bóng...
7. Get bat on ball
Tạo gậy
Để thêm một cây gậy giúp bóng tiếp tục lăn trong trò chơi,
- Chèn một số hằng số vào 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.
Hằng số batHeight
và batWidth
đã tự nói lên nhiều điều. Mặt khác, hằng số batStep
cần có một lời giải thích. Để tương tác với quả 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ỳ thuộc vào nền tảng) hoặc dùng bàn phím. Hằng số batStep
định cấu hình khoảng cách mà con dơi di chuyển 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ố chức năng mới.
Trước tiên, thành phần Bat là một PositionComponent
, không phải là RectangleComponent
hay CircleComponent
. Điều này có nghĩa 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, nó sẽ ghi đè lệnh gọi lại render
.
Nếu 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 ở đâu?" Offset.zero & size.toSize()
tận dụng một phương thức nạp chồng operator &
trên lớp dart:ui
Offset
để tạo Rect
. Cách viết tắt này có thể khiến bạn bối rối lúc đầu, nhưng bạn sẽ thấy cách viết tắt này thường xuyên trong mã Flutter và Flame ở cấp thấp hơn.
Thứ hai, thành phần Bat
này có thể kéo bằng ngón tay hoặc chuột, tuỳ thuộc vào nền tảng. Để triển khai chức năng này, bạn hãy thêm thành phần 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ằng bàn phím. Hàm moveBy
cho phép mã khác yêu cầu con dơi này di chuyển sang trái hoặc phải 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ụ trò chơi Flame: Effect
. Bằng cách 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 con dơi được chuyển động đến một vị trí mới. Flame có một bộ sưu tập Effect
để thực hiện nhiều hiệu ứng.
Đối số của hàm khởi tạo Effect bao gồm một thông tin tham chiếu đến phương thức getter game
. Đó là lý do bạn đưa mixin HasGameReference
vào lớp này. Mixin này thêm một 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.
- Để cung cấp
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';
Thêm con 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( // Add from here...
Bat(
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 bổ sung thành phần kết hợp KeyboardEvents
và phương thức onKeyEvent
bị ghi đè sẽ xử lý thao tác nhập bằng bàn phím. Nhớ lại đoạn mã bạn đã thêm trước đó để di chuyển con dơi theo số bước thích hợp.
Phần còn lại của đoạn mã được thêm 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 hiển thị tất cả các chế độ cài đặt này trong tệp này giúp bạn dễ dàng điều chỉnh kích thước tương đối của gậy và bóng để có 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 rằng bạn 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 đề đó. 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(delay: 0.35)); // Modify from 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 { // To here.
debugPrint('collision with $other');
}
}
}
Những thay đổi về mã này sẽ khắc phục 2 vấn đề riêng biệt.
Trước tiên, nó khắc phục lỗi bóng biến mất ngay khi chạm vào cuối màn hình. Để khắc phục vấn đề này, bạn hãy thay thế lệnh gọi removeFromParent
bằng RemoveEffect
. RemoveEffect
sẽ loại bỏ quả bóng khỏi thế giới trò chơi sau khi để quả bóng thoát ra khỏi khu vực chơi có thể xem được.
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 rất có lợi cho người chơi. Miễn là người chơi chạm vào bóng bằng gậy, bóng sẽ quay trở lại đầu màn hình. Nếu bạn cảm thấy điều này quá dễ dàng và muốn một thứ gì đó thực tế hơn, hãy thay đổi cách xử lý này để phù hợp hơn với cảm giác mà bạn muốn trò chơi của mình mang lại.
Bạn nên lưu ý đến độ phức tạp của việc cập nhật velocity
. Nó không chỉ đảo ngược thành phần y
của vận tốc, như đã thực hiện đối với các va chạm với tường. Thành phần 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 kiểm soát nhiều hơn đối với những gì quả bóng làm, nhưng chính xác là như thế nào thì không được truyền đạt cho người chơi theo bất kỳ cách nào, ngoại trừ thông qua trò chơi.
Giờ bạn đã có một cây gậy để đánh bóng, sẽ thật tuyệt nếu có một số viên gạch để phá bằng quả bóng!
8. Phá vỡ bức tường
Tạo các viên gạch
Cách thêm các khối vào trò chơi:
- Chèn một số hằng số vào 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. Đoạn mã này sử dụng RectangleComponent
, vừa phát hiện va chạm vừa có một 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. Truy vấn kiểm tra điều kiện chiến thắng sẽ truy vấn thế giới để tìm các viên gạch và xác nhận rằng chỉ còn lại một viên gạch. Điều này có thể gây nhầm lẫn một chút, vì dòng trước đó sẽ xoá khối này khỏi khối gốc.
Điểm mấu chốt cần hiểu là việc xoá thành phần là một lệnh được đưa vào hàng đợi. Thao tác này sẽ xoá khối sau khi mã này chạy, nhưng trước lần đánh dấu tiếp theo của thế giới trò chơi.
Để thành phần Brick
có thể truy cập vào BrickBreaker
, 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 các khối 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.
}
}
}
Điều này giới thiệu khía cạnh mới duy nhất, một đối tượng sửa đổi độ khó giúp tăng vận tốc của quả bóng sau mỗi lần va chạm với viên gạch. Bạn cần kiểm thử tham số có thể điều chỉnh 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, 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 chế độ gỡ lỗi và coi như đã hoàn tất, nhưng có vẻ như vẫn còn thiếu sót.
Bạn nghĩ sao về màn hình chào mừng, màn hình kết thúc trò chơi và có thể là điểm số? Flutter có thể thêm những tính năng này vào trò chơi và đó là nơi bạn sẽ tập trung vào tiếp theo.
9. Thắng trò chơi
Thêm trạng thái phát
Trong bước này, bạn sẽ nhúng trò chơi Flame vào 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 trò chơi và màn hì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 việc có hiển thị lớp phủ hay không và nếu có thì hiển thị lớp phủ 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 khá nhiều trong trò chơi BrickBreaker
. Việc thêm giá trị liệt kê playState
tốn rất nhiều công sức. Điều này ghi lại thời điểm người chơi bắt đầu, chơi và thua hoặc thắng trò chơi. Ở đầu tệp, bạn xác định quá trình liệt kê, sau đó tạo thực thể cho quá trình này dưới dạng một trạng thái ẩn có các phương thức truy xuất và thiết lập phù hợp. Các phương thức getter và setter này cho phép sửa đổi lớp phủ khi các phần khác nhau của trò chơi kích hoạt quá trình chuyển đổi trạng thái phát.
Tiếp theo, bạn sẽ chia mã trong onLoad
thành onLoad và một phương thức startGame
mới. Trước khi có thay đổi này, bạn chỉ có thể bắt đầu một trò chơi mới bằng cách khởi động lại trò chơi. Với những điểm mới này, giờ đây, người chơi có thể bắt đầu một trò chơi mới mà không cần phải thực hiện các biện pháp quyết liệt như vậy.
Để cho phép người chơi bắt đầu một trò chơi mới, bạn đã định cấu hình 2 trình xử lý mới cho trò chơi. Bạn đã thêm một trình xử lý thao tác nhấn và mở rộng trình xử lý bàn phím để cho phép người dùng bắt đầu một trò chơi mới ở nhiều phương thức. Với trạng thái phát được mô hình hoá, bạn nên cập nhật các thành phần để kích hoạt các quá trình chuyển đổi trạng thái phát 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ẽ có vẻ hợp lý nếu người chơi để quả 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
impimport '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 phá được tất cả các viên gạch, người chơi sẽ nhận được màn hình "thắng trò chơi". Bạn chơi rất giỏi!
Thêm trình bao bọc Flutter
Để cung cấp một nơi nhúng trò chơi và thêm lớp phủ trạng thái phát, hãy thêm shell 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 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(
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 quy trình tạo cây tiện ích chuẩn của Flutter. Các phần dành riêng cho Flame bao gồm việc sử dụng GameWidget.controlled
để tạo và quản lý phiên bản trò chơi BrickBreaker
và đối số overlayBuilderMap
mới cho GameWidget
.
Các khoá của overlayBuilderMap
phải phù hợp với các lớp phủ mà phương thức thiết lập 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ẽ khiến PAC-MAN thất vọng.
- Để có được 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.
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, thì đầu ra dự kiến sẽ xuất hiện trong trò chơi. Nếu nhắm đến macOS hoặc Android, bạn cần thực hiện một tinh chỉnh cuối cùng để cho phép google_fonts
hiển thị.
Bật quyền truy cập vào 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. Chỉnh sửa AndroidManifest.xml
của bạn 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ó 2 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
sao cho 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>
Việc chạy chương trình này như hiện tại sẽ hiển thị màn hình chào mừng và màn hình kết thúc trò chơi hoặc màn hình chiến thắng trên tất cả các nền tảng. Những màn hình đó có thể hơi đơn giản và sẽ rất hay nếu có điểm số. Vậy bạn đoán xem mình sẽ làm gì trong bước tiếp theo nhé!
10. Ghi điểm
Thêm điểm vào trò chơi
Trong bước này, bạn sẽ hiển thị điểm số của trò chơi cho ngữ cảnh Flutter xung quanh. Trong bước này, bạn sẽ hiển thị trạng thái từ trò chơi Flame cho hoạt động quản lý trạng thái Flutter xung quanh. Thao tác này cho phép mã trò chơi cập nhật điểm số mỗi khi người chơi phá vỡ một 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 sẽ 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
để thêm điểm vào điểm số khi người chơi phá 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 trò chơi bắt mắt
Giờ đây, bạn có thể lưu điểm số trong Flutter. Đã đến lúc kết hợp các tiện ích để tạo giao diện đẹp mắt.
- Tạo
score_card.dart
tronglib/src/widgets
rồi thêm nội dung 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 đoạn mã sau.
Điều này giúp các lớp phủ trông đẹp mắt 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 vào 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 kỹ hơn về sức mạnh của flutter_animate
, hãy xem lớp học lập trình Tạo giao diện người dùng thế hệ tiếp theo 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 chuyển đổi ScoreCard
từ StatelessWidget
thành StatefulWidget
. Để thêm thẻ điểm, bạn cần thêm một Column
để xếp điểm lên 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à 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(
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.
),
),
),
),
),
);
}
}
Sau khi hoàn tất tất cả các bước trên, giờ đây, bạn có thể chạy trò chơi này trên bất kỳ nền tảng mục tiêu nào trong số 6 nền tảng của Flutter. Trò chơi sẽ 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 một trò chơi bằng Flutter và Flame!
Bạn đã tạo một trò chơi bằng công cụ phát triển trò chơi 2D Flame và nhúng trò chơi đó vào một trình bao bọc Flutter. Bạn đã sử dụng Hiệu ứng của Flame để tạo ảnh động và xoá các thành phần. Bạn đã sử dụng Google Fonts và các gói Flutter Animate để thiết kế trò chơi một cách chuyên nghiệp.
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ệ tiếp theo trong Flutter
- Biến ứng dụng Flutter của bạn từ nhàm chán thành đẹp mắt
- Thêm giao dịch mua hàng trong ứng dụng vào ứng dụng Flutter