Giới thiệu về Ngọn lửa bằng Flutter

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.

Bản ghi màn hình một trận đấu đang diễn ra. Trò chơi đã được tăng tốc đáng kể.

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ới Widget của Flutter.
  • Cách xử lý xung đột.
  • Cách sử dụng Effect để tạo ảnh động Component.
  • 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_fontsflutter_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.

Ảnh chụp màn hình của VS Code có một số mã 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.

Hình vẽ mô tả máy tính xách tay và điện thoại được gắn vào máy tính xách tay bằng cáp. Máy tính xách tay này được gắn nhãn là

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:

  1. SDK Flutter
  2. Mã Visual Studio với trình bổ trợ Flutter
  3. 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

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.

  1. Chạy mã Visual Studio.
  2. Mở bảng lệnh (F1 hoặc Ctrl+Shift+P hoặc Shift+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).

Ảnh chụp màn hình của Mã VS với

  1. 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.

Ảnh chụp màn hình Mã VS có Ứng dụng trống (Empty Application) hiển thị là được chọn trong quy trình đăng ký mới

  1. Đặ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.

Ảnh chụp màn hình Mã VS với

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.

  1. 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.

Ảnh chụp một phần màn hình của Mã VS với các mũi tên làm nổi bật vị trí của tệp pubspec.yaml

  1. 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.

  1. Mở tệp main.dart trong thư mục lib/.

Ảnh chụp một phần màn hình của Mã VS có mũi tên cho thấy vị trí của tệp main.dart

  1. 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));
}
  1. 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!

Ảnh chụp màn hình cho thấy một cửa sổ ứng dụngbrick_breaker có màu đen hoàn toàn.

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.

  1. 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.
  2. 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.

  1. Để 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 trong lib/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.

  1. Tạo một tệp có tên brick_breaker.dart trong lib/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.

  1. Đị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).
  2. Thêm PlayArea vào world. 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.

Ảnh chụp màn hình hiển thị cửa sổ ứng dụng Gạch_breaker với một hình chữ nhật màu cát ở giữa cửa sổ ứng dụng

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.

  1. 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 gameWidthgameHeight ở cấp cao nhất để khám phá xem trò chơi sẽ thay đổi như thế nào.

  1. Tạo thành phần Ball trong tệp có tên là ball.dart trong lib/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 Vector2vậ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.

  1. Để đưa thành phần Ball vào danh sách thành phần, hãy chỉnh sửa tệp lib/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ử (*/) để 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.

Ảnh chụp màn hình hiển thị cửa sổ ứng dụngbrick_breaker với một vòng tròn màu xanh dương ở trên cùng của hình chữ nhật màu cát. Vòng tròn màu xanh dương được chú thích bằng các số cho biết kích thước và vị trí của vòng tròn trên màn hình

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,

  1. 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ố batHeightbatWidth 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.

  1. 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.

  1. Để có thể sử dụng Bat cho BrickBreaker, hãy cập nhật tệp lib/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,

  1. 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.
  1. 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.

Ảnh chụp màn hình cho thấy trò chơi đập gạch với bóng, gậy và hầu hết các viên gạch trên khu vực vui chơi. Mỗi thành phần đều có nhãn gỡ lỗi

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.

  1. 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.

  1. 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.

  1. 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.

  1. Tạo thư mục widgets trong lib/src.
  2. 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.

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

  1. 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>
  1. 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.

  1. 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.

  1. 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.

  1. Tạo score_card.dart trong lib/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!,
          ),
        );
      },
    );
  }
}
  1. Tạo overlay_screen.dart trong lib/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.

Ảnh chụp màn hình Gạch_breaker cho thấy màn hình trước khi chơi và mời người dùng nhấn vào màn hình để chơi trò chơi

Ảnh chụp màn hình Gạch_bẻ gạch cho thấy trò chơi trên màn hình phủ lên trên một con dơi và một số khối hình

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...

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