แนะนำ Flame กับ Flutter

1. บทนำ

Flame เป็นเอนจินเกม 2 มิติที่สร้างขึ้นจาก Flutter ในโค้ดแล็บนี้ คุณจะได้สร้างเกมที่ได้แรงบันดาลใจจากวิดีโอเกมคลาสสิกยุค 70 อย่าง Breakout ของ Steve Wozniak คุณจะใช้คอมโพเนนต์ของ Flame เพื่อวาดค้างคาว ลูกบอล และก้อนอิฐ คุณจะใช้เอฟเฟกต์ของ Flame เพื่อเคลื่อนไหวการเคลื่อนที่ของค้างคาว และดูวิธีผสานรวม Flame กับระบบการจัดการสถานะของ Flutter

เมื่อเสร็จแล้ว เกมควรมีลักษณะเหมือน GIF แบบเคลื่อนไหวนี้ แม้ว่าจะช้ากว่าเล็กน้อยก็ตาม

การบันทึกหน้าจอขณะเล่นเกม เกมเร็วขึ้นอย่างมาก

สิ่งที่คุณจะได้เรียนรู้

  • วิธีการทำงานเบื้องต้นของ Flame โดยเริ่มจาก GameWidget
  • วิธีใช้ Game Loop
  • วิธีการทำงานของ Component ใน Flame ซึ่งคล้ายกับ Widget ของ Flutter
  • วิธีจัดการการชนกัน
  • วิธีใช้ Effect เพื่อเคลื่อนไหว Component
  • วิธีซ้อนทับ Flutter Widget บนเกม Flame
  • วิธีผสานรวม Flame กับการจัดการสถานะของ Flutter

สิ่งที่คุณจะสร้าง

ในโค้ดแล็บนี้ คุณจะได้สร้างเกม 2 มิติโดยใช้ Flutter และ Flame เมื่อเสร็จสมบูรณ์แล้ว เกมของคุณควรเป็นไปตามข้อกำหนดต่อไปนี้

  • ทำงานบนทั้ง 6 แพลตฟอร์มที่ Flutter รองรับ ได้แก่ Android, iOS, Linux, macOS, Windows และเว็บ
  • รักษาอัตราเฟรมอย่างน้อย 60 FPS โดยใช้ Game Loop ของ Flame
  • ใช้ความสามารถของ Flutter เช่น แพ็กเกจ google_fonts และ flutter_animate เพื่อจำลองความรู้สึกของการเล่นเกมอาร์เคดในยุค 80

2. ตั้งค่าสภาพแวดล้อม Flutter

ผู้แก้ไข

เพื่อลดความซับซ้อนของ Codelab นี้ เราจึงถือว่า Visual Studio Code (VS Code) เป็นสภาพแวดล้อมการพัฒนาของคุณ VS Code ใช้งานได้ฟรีและทำงานได้ในแพลตฟอร์มหลักทั้งหมด เราใช้ VS Code สำหรับ Codelab นี้เนื่องจากคำสั่งต่างๆ จะใช้แป้นพิมพ์ลัดเฉพาะของ VS Code โดยค่าเริ่มต้น งานจะตรงไปตรงมายิ่งขึ้น เช่น "คลิกปุ่มนี้" หรือ "กดปุ่มนี้เพื่อทำ X" แทนที่จะเป็น "ดำเนินการที่เหมาะสมในโปรแกรมแก้ไขเพื่อทำ X"

คุณสามารถใช้โปรแกรมแก้ไขใดก็ได้ที่ต้องการ เช่น Android Studio, IntelliJ IDE อื่นๆ, Emacs, Vim หรือ Notepad++ ซึ่งทั้งหมดนี้ใช้ได้กับ Flutter

VS Code ที่มีโค้ด Flutter บางส่วน

เลือกเป้าหมายการพัฒนา

Flutter สร้างแอปสำหรับหลายแพลตฟอร์ม แอปของคุณสามารถทำงานบนระบบปฏิบัติการต่อไปนี้

  • iOS
  • Android
  • Windows
  • macOS
  • Linux
  • เว็บ

แนวทางปฏิบัติทั่วไปคือการเลือกระบบปฏิบัติการ 1 ระบบเป็นเป้าหมายการพัฒนา นี่คือระบบปฏิบัติการที่แอปของคุณทำงานระหว่างการพัฒนา

ภาพวาดแสดงแล็ปท็อปและโทรศัพท์ที่เชื่อมต่อกับแล็ปท็อปด้วยสายเคเบิล แล็ปท็อปมีป้ายกำกับเป็น

ตัวอย่างเช่น สมมติว่าคุณใช้แล็ปท็อป Windows เพื่อพัฒนาแอป Flutter จากนั้นเลือก Android เป็นเป้าหมายการพัฒนา หากต้องการดูตัวอย่างแอป ให้เชื่อมต่ออุปกรณ์ Android กับแล็ปท็อป Windows ด้วยสาย USB แล้วแอปที่อยู่ระหว่างการพัฒนาจะทำงานบนอุปกรณ์ Android ที่เชื่อมต่อหรือในโปรแกรมจำลอง Android คุณอาจเลือก Windows เป็นเป้าหมายการพัฒนา ซึ่งจะเรียกใช้แอปที่อยู่ระหว่างการพัฒนาเป็นแอป Windows ควบคู่ไปกับโปรแกรมแก้ไข

โปรดเลือกก่อนดำเนินการต่อ คุณเรียกใช้แอปในระบบปฏิบัติการอื่นๆ ได้ทุกเมื่อ การเลือกเป้าหมายการพัฒนาจะช่วยให้ขั้นตอนถัดไปราบรื่นยิ่งขึ้น

ติดตั้ง Flutter

ดูวิธีการติดตั้ง Flutter SDK ที่อัปเดตล่าสุดได้ที่ docs.flutter.dev

วิธีการในเว็บไซต์ Flutter ครอบคลุมการติดตั้ง SDK และเครื่องมือที่เกี่ยวข้องกับเป้าหมายการพัฒนา รวมถึงปลั๊กอินของเอดิเตอร์ สำหรับ Codelab นี้ ให้ติดตั้งซอฟต์แวร์ต่อไปนี้

  1. Flutter SDK
  2. Visual Studio Code พร้อมปลั๊กอิน Flutter
  3. ซอฟต์แวร์คอมไพเลอร์สำหรับเป้าหมายการพัฒนาที่คุณเลือก (คุณต้องมี Visual Studio เพื่อกำหนดเป้าหมาย Windows หรือ Xcode เพื่อกำหนดเป้าหมาย macOS หรือ iOS)

ในส่วนถัดไป คุณจะสร้างโปรเจ็กต์ Flutter แรก

หากต้องการแก้ปัญหา คุณอาจพบว่าคำถามและคำตอบบางส่วนต่อไปนี้ (จาก StackOverflow) มีประโยชน์ในการแก้ปัญหา

คำถามที่พบบ่อย

3. สร้างโปรเจ็กต์

สร้างโปรเจ็กต์ Flutter แรก

ซึ่งเกี่ยวข้องกับการเปิด VS Code และการสร้างเทมเพลตแอป Flutter ในไดเรกทอรีที่คุณเลือก

  1. เปิด Visual Studio Code
  2. เปิด Command Palette (F1 หรือ Ctrl+Shift+P หรือ Shift+Cmd+P) แล้วพิมพ์ "flutter new" เมื่อปรากฏขึ้น ให้เลือกคำสั่ง Flutter: New Project

VS Code พร้อม

  1. เลือกล้างข้อมูลในแอปพลิเคชัน เลือกไดเรกทอรีที่จะสร้างโปรเจ็กต์ ซึ่งควรเป็นไดเรกทอรีที่ไม่ต้องใช้สิทธิ์ระดับสูงหรือมีช่องว่างในเส้นทาง เช่น ไดเรกทอรีบ้านหรือ C:\src\

VS Code ที่มีแอปพลิเคชันว่างเปล่าแสดงเป็นส่วนที่เลือกไว้ในขั้นตอนการสร้างแอปพลิเคชันใหม่

  1. ตั้งชื่อโปรเจ็กต์ brick_breaker ส่วนที่เหลือของโค้ดแล็บนี้จะถือว่าคุณตั้งชื่อแอปเป็น brick_breaker

VS Code พร้อม

ตอนนี้ Flutter จะสร้างโฟลเดอร์โปรเจ็กต์และ VS Code จะเปิดโฟลเดอร์ดังกล่าว ตอนนี้คุณจะเขียนทับเนื้อหาของ 2 ไฟล์ด้วยโครงสร้างพื้นฐานของแอป

คัดลอกและวางแอปเริ่มต้น

ซึ่งจะเพิ่มโค้ดตัวอย่างที่ระบุไว้ในโค้ดแล็บนี้ลงในแอป

  1. ในแผงด้านซ้ายของ VS Code ให้คลิกExplorer แล้วเปิดไฟล์ pubspec.yaml

ภาพหน้าจอบางส่วนของ VS Code พร้อมลูกศรที่ไฮไลต์ตำแหน่งของไฟล์ pubspec.yaml

  1. แทนที่เนื้อหาของไฟล์นี้ด้วยเนื้อหาต่อไปนี้

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

ไฟล์ pubspec.yaml จะระบุข้อมูลพื้นฐานเกี่ยวกับแอป เช่น เวอร์ชันปัจจุบัน การอ้างอิง และชิ้นงานที่จะจัดส่ง

  1. เปิดไฟล์ main.dart ในไดเรกทอรี lib/

ภาพหน้าจอบางส่วนของ VS Code พร้อมลูกศรที่แสดงตำแหน่งของไฟล์ main.dart

  1. แทนที่เนื้อหาของไฟล์นี้ด้วยเนื้อหาต่อไปนี้

lib/main.dart

import 'package:flame/game.dart';
import 'package:flutter/material.dart';

void main() {
  final game = FlameGame();
  runApp(GameWidget(game: game));
}
  1. เรียกใช้โค้ดนี้เพื่อยืนยันว่าทุกอย่างทำงานได้ อุปกรณ์ควรจะแสดงหน้าต่างใหม่ที่มีเฉพาะพื้นหลังสีดำเปล่า ตอนนี้วิดีโอเกมที่ห่วยที่สุดในโลกแสดงผลที่ 60 FPS แล้ว

ภาพหน้าจอแสดงหน้าต่างแอปพลิเคชัน brick_breaker ที่เป็นสีดำสนิท

4. สร้างเกม

ดูขนาดเกม

เกมที่เล่นใน 2 มิติ (2D) ต้องมีพื้นที่เล่น คุณจะสร้างพื้นที่ที่มีขนาดเฉพาะ แล้วใช้ขนาดเหล่านี้เพื่อกำหนดขนาดขององค์ประกอบอื่นๆ ในเกม

การวางเลย์เอาต์พิกัดในพื้นที่เล่นทำได้หลายวิธี ตามธรรมเนียมหนึ่ง คุณสามารถวัดทิศทางจากกึ่งกลางของหน้าจอโดยมีจุดเริ่มต้น (0,0)ที่กึ่งกลางของหน้าจอ ค่าบวกจะเลื่อนรายการไปทางขวาตามแกน x และขึ้นตามแกน y มาตรฐานนี้ใช้กับเกมส่วนใหญ่ในปัจจุบัน โดยเฉพาะเกมที่มี 3 มิติ

เมื่อสร้างเกม Breakout ต้นฉบับ เราได้ตั้งค่าต้นทางไว้ที่มุมซ้ายบน ทิศทาง x ที่เป็นบวกยังคงเหมือนเดิม แต่ y กลับด้าน ทิศทาง x เป็นบวกไปทางขวาและ y เป็นบวกไปทางด้านล่าง เกมนี้ตั้งค่าจุดเริ่มต้นไว้ที่มุมซ้ายบนเพื่อให้สอดคล้องกับยุคสมัย

สร้างไฟล์ชื่อ config.dart ในไดเรกทอรีใหม่ชื่อ lib/src ไฟล์นี้จะมีค่าคงที่มากขึ้นในขั้นตอนต่อไปนี้

lib/src/config.dart

const gameWidth = 820.0;
const gameHeight = 1600.0;

เกมนี้จะมีความกว้าง 820 พิกเซลและความสูง 1600 พิกเซล พื้นที่เกมจะปรับขนาดให้พอดีกับหน้าต่างที่แสดง แต่คอมโพเนนต์ทั้งหมดที่เพิ่มลงในหน้าจอจะสอดคล้องกับความสูงและความกว้างนี้

สร้าง PlayArea

ในเกม Breakout ลูกบอลจะกระดอนออกจากกำแพงของพื้นที่เล่น หากต้องการรองรับการชน คุณต้องมีPlayAreaก่อน

  1. สร้างไฟล์ชื่อ play_area.dart ในไดเรกทอรีใหม่ชื่อ lib/src/components
  2. เพิ่มข้อมูลต่อไปนี้ลงในไฟล์นี้

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 มี Widget Flame ก็มี Component ในขณะที่แอป Flutter ประกอบด้วยการสร้างโครงสร้างแบบต้นไม้ของวิดเจ็ต เกม Flame ประกอบด้วยการดูแลโครงสร้างแบบต้นไม้ของคอมโพเนนต์

ซึ่งเป็นความแตกต่างที่น่าสนใจระหว่าง Flutter กับ Flame แผนผังวิดเจ็ตของ Flutter เป็นคำอธิบายชั่วคราวที่สร้างขึ้นเพื่อใช้ในการอัปเดตRenderObjectเลเยอร์แบบถาวรและเปลี่ยนแปลงได้ คอมโพเนนต์ของ Flame จะคงอยู่และเปลี่ยนแปลงได้ โดยคาดว่านักพัฒนาซอฟต์แวร์จะใช้คอมโพเนนต์เหล่านี้เป็นส่วนหนึ่งของระบบการจำลอง

คอมโพเนนต์ของ Flame ได้รับการเพิ่มประสิทธิภาพเพื่อแสดงกลไกของเกม Codelab นี้จะเริ่มต้นด้วย Game Loop ซึ่งจะกล่าวถึงในขั้นตอนถัดไป

  1. หากต้องการควบคุมความรก ให้เพิ่มไฟล์ที่มีคอมโพเนนต์ทั้งหมดในโปรเจ็กต์นี้ สร้างไฟล์ components.dart ใน lib/src/components แล้วเพิ่มเนื้อหาต่อไปนี้

lib/src/components/components.dart

export 'play_area.dart';

คําสั่ง export มีบทบาทตรงกันข้ามกับ import ซึ่งจะประกาศฟังก์ชันการทำงานที่ไฟล์นี้แสดงเมื่อนำเข้าไปยังไฟล์อื่น ไฟล์นี้จะมีรายการมากขึ้นเมื่อคุณเพิ่มคอมโพเนนต์ใหม่ในขั้นตอนต่อไปนี้

สร้างเกม Flame

หากต้องการลบเส้นหยักสีแดงจากขั้นตอนก่อนหน้า ให้สร้างคลาสย่อยใหม่สำหรับ FlameGame ของ Flame

  1. สร้างไฟล์ชื่อ brick_breaker.dart ใน lib/src แล้วเพิ่มโค้ดต่อไปนี้

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());
  }
}

ไฟล์นี้จะประสานงานการดำเนินการของเกม ในระหว่างการสร้างอินสแตนซ์ของเกม โค้ดนี้จะกำหนดค่าเกมให้ใช้การแสดงผลความละเอียดคงที่ เกมจะปรับขนาดให้เต็มหน้าจอที่มีเกมนั้นอยู่ และเพิ่มแถบดำบนและล่างตามที่จำเป็น

คุณแสดงความกว้างและความสูงของเกมเพื่อให้คอมโพเนนต์ย่อย เช่น PlayArea สามารถตั้งค่าขนาดที่เหมาะสมได้

ในonLoadเมธอดที่ลบล้าง โค้ดจะดำเนินการ 2 อย่าง

  1. กำหนดค่าด้านซ้ายบนเป็นจุดยึดสำหรับช่องมองภาพ โดยค่าเริ่มต้น viewfinder จะใช้กึ่งกลางของพื้นที่เป็นจุดยึดสำหรับ (0,0)
  2. เพิ่ม PlayArea ไปยัง world โลกแสดงถึงโลกของเกม โดยจะฉายภาพลูกๆ ทั้งหมดผ่านCameraComponentการเปลี่ยนมุมมอง

ดูการแข่งขันบนหน้าจอ

หากต้องการดูการเปลี่ยนแปลงทั้งหมดที่คุณทำในขั้นตอนนี้ ให้อัปเดตไฟล์ 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));
}

หลังจากทำการเปลี่ยนแปลงเหล่านี้แล้ว ให้รีสตาร์ทเกม เกมควรมีลักษณะคล้ายกับรูปต่อไปนี้

ภาพหน้าจอที่แสดงหน้าต่างแอปพลิเคชัน brick_breaker ที่มีสี่เหลี่ยมผืนผ้าสีทรายอยู่ตรงกลางหน้าต่างแอป

ในขั้นตอนถัดไป คุณจะเพิ่มลูกบอลลงในโลกและทำให้ลูกบอลเคลื่อนที่

5. แสดงลูกบอล

สร้างคอมโพเนนต์ลูกบอล

การวางลูกบอลที่เคลื่อนไหวบนหน้าจอต้องสร้างคอมโพเนนต์อีกรายการหนึ่งและเพิ่มลงในโลกของเกม

  1. แก้ไขเนื้อหาของไฟล์ lib/src/config.dart ดังนี้

lib/src/config.dart

const gameWidth = 820.0;
const gameHeight = 1600.0;
const ballRadius = gameWidth * 0.02;                            // Add this constant

รูปแบบการออกแบบของการกำหนดค่าคงที่ที่มีชื่อเป็นค่าที่ได้จะปรากฏหลายครั้งใน Codelab นี้ ซึ่งจะช่วยให้คุณแก้ไขระดับบนสุดของ gameWidth และ gameHeight เพื่อดูว่ารูปลักษณ์ของเกมเปลี่ยนแปลงไปอย่างไร

  1. สร้างคอมโพเนนต์ Ball ในไฟล์ชื่อ ball.dart ใน 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;
  }
}

ก่อนหน้านี้คุณได้กำหนด PlayArea โดยใช้ RectangleComponent ดังนั้นจึงเป็นเหตุผลที่ว่ามีรูปร่างอื่นๆ อยู่ CircleComponent เช่น RectangleComponent มาจาก PositionedComponent คุณจึงวางลูกบอลบนหน้าจอได้ และที่สำคัญกว่านั้นคือคุณสามารถอัปเดตตำแหน่งของส่วนนี้ได้

คอมโพเนนต์นี้จะแนะนำแนวคิดของvelocityหรือการเปลี่ยนแปลงตำแหน่งเมื่อเวลาผ่านไป ความเร็วเป็นออบเจ็กต์ Vector2 เนื่องจากความเร็วคือทั้งอัตราเร็วและทิศทาง หากต้องการอัปเดตตำแหน่ง ให้ลบล้างเมธอด update ซึ่งเกมเอนจินจะเรียกใช้สำหรับทุกเฟรม dt คือระยะเวลาระหว่างเฟรมก่อนหน้ากับเฟรมนี้ ซึ่งจะช่วยให้คุณปรับตัวตามปัจจัยต่างๆ ได้ เช่น อัตราเฟรมที่แตกต่างกัน (60Hz หรือ 120Hz) หรือเฟรมยาวเนื่องจากการคำนวณมากเกินไป

โปรดอ่านposition += velocity * dtอย่างละเอียด นี่คือวิธีอัปเดตการจำลองการเคลื่อนไหวแบบไม่ต่อเนื่องเมื่อเวลาผ่านไป

  1. หากต้องการรวมคอมโพเนนต์ Ball ไว้ในรายการคอมโพเนนต์ ให้แก้ไขไฟล์ lib/src/components/components.dart ดังนี้

lib/src/components/components.dart

export 'ball.dart';                           // Add this export
export 'play_area.dart';

เพิ่มลูกบอลลงในโลก

คุณมีลูกบอล วางไว้ในโลกและตั้งค่าให้เคลื่อนที่ไปรอบๆ พื้นที่เล่น

แก้ไขไฟล์ lib/src/brick_breaker.dart ดังนี้

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

การเปลี่ยนแปลงนี้จะเพิ่มคอมโพเนนต์ Ball ลงใน world หากต้องการตั้งค่า position ของลูกบอลให้อยู่ตรงกลางพื้นที่แสดงผล โค้ดจะลดขนาดของเกมลงครึ่งหนึ่งก่อน เนื่องจาก Vector2 มีการโอเวอร์โหลดโอเปอเรเตอร์ (* และ /) เพื่อปรับขนาด Vector2 ตามค่าสเกลาร์

การตั้งค่าvelocityของลูกบอลมีความซับซ้อนมากขึ้น โดยมีจุดประสงค์เพื่อย้ายลูกบอลลงมาที่ด้านล่างของหน้าจอในทิศทางแบบสุ่มด้วยความเร็วที่เหมาะสม การเรียกใช้เมธอด normalized จะสร้างออบเจ็กต์ Vector2 ที่ตั้งค่าให้มีทิศทางเดียวกับ Vector2 เดิม แต่ลดขนาดลงเหลือระยะทาง 1 ซึ่งจะทำให้ความเร็วของลูกบอลคงที่ ไม่ว่าลูกบอลจะไปในทิศทางใดก็ตาม จากนั้นจะปรับความเร็วของลูกบอลให้เป็น 1/4 ของความสูงของเกม

การกำหนดค่าต่างๆ เหล่านี้ให้ถูกต้องต้องมีการทำซ้ำ ซึ่งในอุตสาหกรรมนี้เรียกว่าการทดสอบเกม

บรรทัดสุดท้ายจะเปิดการแสดงผลการแก้ไขข้อบกพร่อง ซึ่งจะเพิ่มข้อมูลเพิ่มเติมลงในการแสดงผลเพื่อช่วยในการแก้ไขข้อบกพร่อง

เมื่อคุณเรียกใช้เกมในตอนนี้ เกมควรมีลักษณะคล้ายกับการแสดงผลต่อไปนี้

ภาพหน้าจอแสดงหน้าต่างแอปพลิเคชัน brick_breaker ที่มีวงกลมสีน้ำเงินอยู่ด้านบนของสี่เหลี่ยมผืนผ้าสีทราย วงกลมสีน้ำเงินมีหมายเลขกำกับเพื่อระบุขนาดและตำแหน่งบนหน้าจอ

ทั้งคอมโพเนนต์ PlayArea และคอมโพเนนต์ Ball มีข้อมูลการแก้ไขข้อบกพร่อง แต่แมตต์พื้นหลังจะครอบตัดตัวเลขของ PlayArea สาเหตุที่ทุกอย่างแสดงข้อมูลการแก้ไขข้อบกพร่องเป็นเพราะคุณเปิดdebugModeสำหรับทั้งแผนผังคอมโพเนนต์ นอกจากนี้ คุณยังเปิดการแก้ไขข้อบกพร่องสำหรับคอมโพเนนต์ที่เลือกเท่านั้นได้ด้วย หากเป็นประโยชน์มากกว่า

หากรีสตาร์ทเกม 2-3 ครั้ง คุณอาจสังเกตเห็นว่าลูกบอลไม่โต้ตอบกับกำแพงตามที่คาดไว้ หากต้องการให้เกิดเอฟเฟกต์ดังกล่าว คุณต้องเพิ่มการตรวจหาการชน ซึ่งคุณจะทำในขั้นตอนถัดไป

6. ตีกลับ

เพิ่มการตรวจจับการชน

การตรวจหาการชนจะเพิ่มลักษณะการทำงานที่เกมจะรับรู้เมื่อวัตถุ 2 ชิ้นสัมผัสกัน

หากต้องการเพิ่มการตรวจหาการชนลงในเกม ให้เพิ่ม HasCollisionDetection มิกซ์อินลงใน BrickBreaker เกมตามที่แสดงในโค้ดต่อไปนี้

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;
  }
}

ซึ่งจะติดตามฮิตบ็อกซ์ของคอมโพเนนต์และเรียกใช้การเรียกกลับการชนกันในทุกๆ การอัปเดตเกม

หากต้องการเริ่มป้อนข้อมูลฮิตบ็อกซ์ของเกม ให้แก้ไขคอมโพเนนต์ PlayArea ดังที่แสดง

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);
  }
}

การเพิ่มคอมโพเนนต์ RectangleHitbox เป็นองค์ประกอบย่อยของ RectangleComponent จะสร้างช่องการตรวจหาการชนที่มีขนาดตรงกับขนาดของคอมโพเนนต์หลัก มีตัวสร้างจากโรงงานสำหรับ RectangleHitbox ที่ชื่อ relative ในกรณีที่คุณต้องการฮิตบ็อกซ์ที่มีขนาดเล็กหรือใหญ่กว่าคอมโพเนนต์หลัก

โยนลูกบอล

จนถึงตอนนี้ การเพิ่มการตรวจหาการชนไม่ได้ทำให้เกมเพลย์แตกต่างออกไป แต่จะเปลี่ยนเมื่อคุณแก้ไขคอมโพเนนต์ Ball พฤติกรรมของลูกบอลจะต้องเปลี่ยนเมื่อชนกับPlayArea

แก้ไขคอมโพเนนต์ Ball ดังนี้

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

ตัวอย่างนี้ทำการเปลี่ยนแปลงครั้งใหญ่ด้วยการเพิ่มonCollisionStartการเรียกกลับ ระบบตรวจหาการชนที่เพิ่มลงใน BrickBreaker ในตัวอย่างก่อนหน้าจะเรียกใช้การเรียกกลับนี้

ก่อนอื่น โค้ดจะทดสอบว่า Ball ชนกับ PlayArea หรือไม่ ซึ่งดูเหมือนจะซ้ำซ้อนในตอนนี้ เนื่องจากไม่มีคอมโพเนนต์อื่นๆ ในโลกของเกม ซึ่งจะเปลี่ยนไปในขั้นตอนถัดไปเมื่อคุณเพิ่มค้างคาวลงในโลก จากนั้นยังเพิ่มเงื่อนไข else เพื่อจัดการเมื่อลูกบอลชนกับสิ่งที่ไม่ใช่ไม้ ช่วยเตือนให้ใช้ตรรกะที่เหลืออยู่ หากคุณต้องการ

เมื่อลูกบอลชนกับกำแพงด้านล่าง ลูกบอลจะหายไปจากพื้นผิวการเล่น แต่ยังคงมองเห็นได้ชัดเจน คุณจะจัดการอาร์ติแฟกต์นี้ในขั้นตอนถัดไปโดยใช้พลังของเอฟเฟกต์ของ Flame

ตอนนี้คุณมีลูกบอลที่ชนกับกำแพงของเกมแล้ว การให้ผู้เล่นมีไม้ตีเพื่อตีลูกบอลก็คงจะเป็นประโยชน์...

7. ตีลูกให้โดน

สร้างค้างคาว

หากต้องการเพิ่มไม้ตีเพื่อตีลูกให้อยู่ในการเล่นภายในเกม ให้ทำดังนี้

  1. แทรกค่าคงที่บางอย่างในไฟล์ lib/src/config.dart ดังนี้

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.

ค่าคงที่ batHeight และ batWidth มีความชัดเจนในตัวอยู่แล้ว ส่วนbatStepค่าคงที่นั้นต้องมีคำอธิบายเพิ่มเติม หากต้องการโต้ตอบกับลูกบอลในเกมนี้ ผู้เล่นสามารถลากไม้ด้วยเมาส์หรือนิ้ว (ขึ้นอยู่กับแพลตฟอร์ม) หรือใช้แป้นพิมพ์ batStep ค่าคงที่จะกำหนดระยะทางที่ค้างคาวจะก้าวเมื่อกดปุ่มลูกศรซ้ายหรือขวาแต่ละครั้ง

  1. กำหนดคลาสคอมโพเนนต์ Bat ดังนี้

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),
      ),
    );
  }
}

คอมโพเนนต์นี้มาพร้อมความสามารถใหม่ๆ

ก่อนอื่น คอมโพเนนต์ Bat คือ PositionComponent ไม่ใช่ RectangleComponent หรือ CircleComponent ซึ่งหมายความว่าโค้ดนี้ต้องแสดง Bat บนหน้าจอ โดยจะลบล้างrender Callback เพื่อให้บรรลุเป้าหมายนี้

เมื่อดูการเรียก canvas.drawRRect (วาดสี่เหลี่ยมผืนผ้ามน) อย่างละเอียด คุณอาจสงสัยว่า "สี่เหลี่ยมผืนผ้าอยู่ตรงไหน" Offset.zero & size.toSize()ใช้operator &โอเวอร์โหลดในคลาส dart:ui Offset ที่สร้างRect ตัวย่อนี้อาจทำให้คุณสับสนในตอนแรก แต่คุณจะเห็นตัวย่อนี้บ่อยๆ ในโค้ด Flutter และ Flame ระดับล่าง

ประการที่สอง Bat คอมโพเนนต์นี้ลากได้โดยใช้นิ้วหรือเมาส์ ทั้งนี้ขึ้นอยู่กับแพลตฟอร์ม หากต้องการใช้ฟังก์ชันนี้ ให้เพิ่มมิกซ์อิน DragCallbacks และลบล้างเหตุการณ์ onDragUpdate

สุดท้าย คอมโพเนนต์ Bat ต้องตอบสนองต่อการควบคุมด้วยแป้นพิมพ์ ฟังก์ชัน moveBy ช่วยให้โค้ดอื่นๆ บอกให้ค้างคาวตัวนี้เคลื่อนที่ไปทางซ้ายหรือขวาตามจำนวนพิกเซลเสมือนที่กำหนด ฟังก์ชันนี้จะเปิดตัวความสามารถใหม่ของเอนจินเกม Flame นั่นคือ Effects การเพิ่มออบเจ็กต์ MoveToEffect เป็นองค์ประกอบย่อยของคอมโพเนนต์นี้จะทำให้ผู้เล่นเห็นค้างคาวเคลื่อนไหวไปยังตำแหน่งใหม่ Flame มีคอลเล็กชันEffectให้ใช้งานเพื่อสร้างเอฟเฟกต์ต่างๆ

อาร์กิวเมนต์ของตัวสร้างเอฟเฟกต์มีการอ้างอิงถึงตัวดึงข้อมูล game ด้วยเหตุนี้คุณจึงรวมมิกซ์อิน HasGameReference ไว้ในคลาสนี้ มิกซ์อินนี้จะเพิ่มตัวช่วยเข้าถึง game ที่ปลอดภัยต่อประเภทให้กับคอมโพเนนต์นี้เพื่อเข้าถึงอินสแตนซ์ BrickBreaker ที่ด้านบนของแผนผังคอมโพเนนต์

  1. หากต้องการให้ Bat พร้อมใช้งานสำหรับ BrickBreaker ให้อัปเดตไฟล์ lib/src/components/components.dart ดังนี้

lib/src/components/components.dart

export 'ball.dart';
export 'bat.dart';                                              // Add this export
export 'play_area.dart';

เพิ่มค้างคาวลงในโลก

หากต้องการเพิ่มคอมโพเนนต์ Bat ลงในโลกของเกม ให้อัปเดต BrickBreaker ดังนี้

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

การเพิ่ม KeyboardEvents mixin และเมธอด onKeyEvent ที่ลบล้างจะจัดการอินพุตจากแป้นพิมพ์ เรียกคืนโค้ดที่คุณเพิ่มไว้ก่อนหน้านี้เพื่อย้ายค้างคาวตามจำนวนก้าวที่เหมาะสม

โค้ดที่เหลือที่เพิ่มเข้ามาจะเพิ่มค้างคาวลงในโลกของเกมในตำแหน่งที่เหมาะสมและมีสัดส่วนที่ถูกต้อง การแสดงการตั้งค่าทั้งหมดเหล่านี้ในไฟล์นี้ช่วยให้คุณปรับขนาดสัมพัทธ์ของไม้และลูกบอลได้ง่ายขึ้นเพื่อให้ได้ความรู้สึกที่เหมาะสมกับเกม

หากเล่นเกมในตอนนี้ คุณจะเห็นว่าสามารถขยับไม้เพื่อสกัดลูกบอลได้ แต่จะไม่มีการตอบสนองที่มองเห็นได้ นอกเหนือจากการบันทึกการแก้ไขข้อบกพร่องที่คุณทิ้งไว้ในโค้ดการตรวจหาการชนของ Ball

ถึงเวลาแก้ไขแล้ว แก้ไขคอมโพเนนต์ Ball ดังนี้

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');
    }
  }
}

การเปลี่ยนแปลงโค้ดเหล่านี้จะแก้ไขปัญหา 2 อย่างที่แยกกัน

ก่อนอื่น ฟีเจอร์นี้จะแก้ไขปัญหาลูกบอลหายไปทันทีที่สัมผัสด้านล่างของหน้าจอ หากต้องการแก้ไขปัญหานี้ ให้แทนที่การเรียกใช้ removeFromParent ด้วย RemoveEffect RemoveEffect จะนำลูกบอลออกจากโลกของเกมหลังจากปล่อยให้ลูกบอลออกนอกพื้นที่เล่นที่มองเห็นได้

ประการที่สอง การเปลี่ยนแปลงเหล่านี้แก้ไขการจัดการการชนกันระหว่างไม้กับลูกบอล ซึ่งโค้ดการจัดการนี้จะเอื้อประโยชน์ต่อผู้เล่นเป็นอย่างมาก ตราบใดที่ผู้เล่นสัมผัสลูกบอลด้วยไม้ ลูกบอลจะกลับไปที่ด้านบนของหน้าจอ หากรู้สึกว่าการจัดการนี้ง่ายเกินไปและต้องการให้สมจริงมากขึ้น ให้เปลี่ยนการจัดการนี้เพื่อให้เหมาะกับความรู้สึกที่คุณต้องการให้เกมเป็น

เราควรชี้ให้เห็นถึงความซับซ้อนของการอัปเดต velocity โดยไม่ได้เพียงแค่กลับทิศทางyของความเร็วเท่านั้น เหมือนกับการชนกำแพง นอกจากนี้ ยังอัปเดตxในลักษณะที่ขึ้นอยู่กับตำแหน่งสัมพัทธ์ของไม้และลูกบอลในขณะที่สัมผัสกัน ซึ่งจะช่วยให้ผู้เล่นควบคุมสิ่งที่ลูกบอลทำได้มากขึ้น แต่ระบบจะไม่แจ้งให้ผู้เล่นทราบถึงวิธีการที่แน่นอนในทุกวิถีทาง ยกเว้นผ่านการเล่น

ตอนนี้คุณมีไม้ตีลูกบอลแล้ว ก็ควรจะมีอิฐให้ลูกบอลทุบด้วย

8. ทลายกำแพง

สร้างอิฐ

วิธีเพิ่มอิฐลงในเกม

  1. แทรกค่าคงที่บางอย่างในไฟล์ lib/src/config.dart ดังนี้

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. แทรกคอมโพเนนต์ Brick ดังนี้

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>());
    }
  }
}

ตอนนี้คุณน่าจะคุ้นเคยกับโค้ดส่วนใหญ่แล้ว โค้ดนี้ใช้ RectangleComponent ทั้งการตรวจหาการชนกันและการอ้างอิงที่ปลอดภัยตามประเภทไปยังเกม BrickBreaker ที่ด้านบนของแผนผังคอมโพเนนต์

แนวคิดใหม่ที่สำคัญที่สุดที่โค้ดนี้แนะนำคือวิธีที่ผู้เล่นบรรลุเงื่อนไขการชนะ การตรวจสอบเงื่อนไขการชนะจะค้นหาอิฐทั่วโลกและยืนยันว่าเหลือเพียงก้อนเดียว ซึ่งอาจทำให้สับสนเล็กน้อยเนื่องจากบรรทัดก่อนหน้าจะนำอิฐนี้ออกจากอิฐหลัก

ประเด็นสำคัญที่ควรทราบคือการนำคอมโพเนนต์ออกเป็นคำสั่งที่อยู่ในคิว โดยจะนำบล็อกออกหลังจากโค้ดนี้ทำงาน แต่ก่อนที่โลกของเกมจะอัปเดตครั้งถัดไป

หากต้องการทำให้Brickเข้าถึงได้ใน BrickBreaker ให้แก้ไข lib/src/components/components.dart ดังนี้

lib/src/components/components.dart

export 'ball.dart';
export 'bat.dart';
export 'brick.dart';                                            // Add this export
export 'play_area.dart';

เพิ่มอิฐลงในโลก

อัปเดตคอมโพเนนต์ Ball ดังนี้

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

ซึ่งเป็นองค์ประกอบใหม่เพียงอย่างเดียว นั่นคือตัวปรับความยากที่จะเพิ่มความเร็วของลูกบอลหลังจากชนบล็อกแต่ละครั้ง พารามิเตอร์ที่ปรับได้นี้ต้องได้รับการทดสอบการเล่นเพื่อค้นหาเส้นโค้งความยากที่เหมาะสมกับเกมของคุณ

แก้ไขเกม BrickBreaker ดังนี้

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;
  }
}

หากคุณเรียกใช้เกม ระบบจะแสดงกลไกหลักทั้งหมดของเกม คุณอาจปิดการแก้ไขข้อบกพร่องและถือว่าเสร็จสิ้นแล้ว แต่ก็ยังรู้สึกว่าขาดอะไรไป

ภาพหน้าจอแสดงเกม Brick Breaker พร้อมลูกบอล ไม้ตี และอิฐส่วนใหญ่ในพื้นที่เล่น คอมโพเนนต์แต่ละรายการมีป้ายกำกับการแก้ไขข้อบกพร่อง

ลองทำหน้าจอต้อนรับ หน้าจอเกมโอเวอร์ และอาจมีคะแนนด้วยไหม Flutter สามารถเพิ่มฟีเจอร์เหล่านี้ลงในเกมได้ และนั่นคือสิ่งที่คุณจะให้ความสนใจต่อไป

9. ชนะเกม

เพิ่มสถานะการเล่น

ในขั้นตอนนี้ คุณจะฝังเกม Flame ไว้ใน Wrapper ของ Flutter จากนั้นเพิ่มการซ้อนทับ Flutter สำหรับหน้าจอต้อนรับ เกมจบ และชนะ

ก่อนอื่น ให้แก้ไขไฟล์เกมและคอมโพเนนต์เพื่อใช้สถานะการเล่นที่ระบุว่าจะแสดงภาพซ้อนหรือไม่ และหากแสดง จะแสดงภาพซ้อนใด

  1. แก้ไขเกม BrickBreaker ดังนี้

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
}

โค้ดนี้จะเปลี่ยนแปลงเกม BrickBreaker ไปมาก การเพิ่มการแจงนับ playState ต้องใช้เวลามาก ซึ่งจะบันทึกตำแหน่งที่ผู้เล่นเข้า เล่น และแพ้หรือชนะเกม ที่ด้านบนของไฟล์ คุณจะกำหนดการแจงนับ จากนั้นสร้างอินสแตนซ์เป็นสถานะที่ซ่อนอยู่พร้อมกับตัวรับและตัวตั้งค่าที่ตรงกัน Getter และ Setter เหล่านี้ช่วยให้แก้ไขการวางซ้อนได้เมื่อส่วนต่างๆ ของเกมทริกเกอร์การเปลี่ยนสถานะการเล่น

จากนั้นแยกโค้ดใน onLoad ออกเป็น onLoad และเมธอด startGame ใหม่ ก่อนการเปลี่ยนแปลงนี้ คุณจะเริ่มเกมใหม่ได้โดยการรีสตาร์ทเกมเท่านั้น การเพิ่มฟีเจอร์ใหม่เหล่านี้จะช่วยให้ผู้เล่นเริ่มเกมใหม่ได้โดยไม่ต้องใช้มาตรการที่รุนแรงเช่นนี้

คุณได้กำหนดค่าแฮนเดิลใหม่ 2 รายการสำหรับเกมเพื่อให้ผู้เล่นเริ่มเกมใหม่ได้ คุณได้เพิ่มตัวแฮนเดิลการแตะและขยายตัวแฮนเดิลแป้นพิมพ์เพื่อให้ผู้ใช้เริ่มเกมใหม่ในหลายรูปแบบได้ เมื่อใช้สถานะการเล่นที่จำลองแล้ว การอัปเดตคอมโพเนนต์เพื่อทริกเกอร์การเปลี่ยนสถานะการเล่นเมื่อผู้เล่นชนะหรือแพ้ก็จะเป็นเรื่องสมเหตุสมผล

  1. แก้ไขคอมโพเนนต์ Ball ดังนี้

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);
    }
  }
}

การเปลี่ยนแปลงเล็กๆ นี้จะเพิ่มonCompleteการเรียกกลับไปยัง RemoveEffect ซึ่งจะทริกเกอร์gameOverสถานะการเล่น ซึ่งควรจะรู้สึกว่าถูกต้องหากผู้เล่นปล่อยให้ลูกบอลหลุดออกจากด้านล่างของหน้าจอ

  1. แก้ไขคอมโพเนนต์ Brick ดังนี้

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>());
    }
  }
}

ในทางกลับกัน หากผู้เล่นทำลายบล็อกกำแพงได้ทั้งหมด ก็จะได้รับหน้าจอ "ชนะเกม" ยอดเยี่ยมมาก

เพิ่ม Wrapper ของ Flutter

เพิ่มเชลล์ Flutter เพื่อให้มีที่ฝังเกมและเพิ่มภาพซ้อนทับสถานะการเล่น

  1. สร้างไดเรกทอรี widgets ภายใต้ lib/src
  2. เพิ่มไฟล์ game_app.dart แล้วแทรกเนื้อหาต่อไปนี้ลงในไฟล์นั้น

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,
                          ),
                        ),
                      },
                    ),
                  ),
                ),
              ),
            ),
          ),
        ),
      ),
    );
  }
}

เนื้อหาส่วนใหญ่ในไฟล์นี้เป็นไปตามการสร้างแผนผังวิดเจ็ต Flutter มาตรฐาน ส่วนที่เฉพาะเจาะจงสำหรับ Flame ได้แก่ การใช้ GameWidget.controlled เพื่อสร้างและจัดการอินสแตนซ์เกม BrickBreaker และอาร์กิวเมนต์ overlayBuilderMap ใหม่สำหรับ GameWidget

คีย์ของ overlayBuilderMap นี้ต้องสอดคล้องกับภาพซ้อนทับที่ผู้ตั้งค่า playState ใน BrickBreaker เพิ่มหรือนำออก การพยายามตั้งค่าการซ้อนทับที่ไม่ได้อยู่ในแผนที่นี้จะทำให้เกิดหน้าเศร้าไปทั่ว

  1. หากต้องการให้ฟังก์ชันการทำงานใหม่นี้ปรากฏบนหน้าจอ ให้แทนที่ไฟล์ lib/main.dart ด้วยเนื้อหาต่อไปนี้

lib/main.dart

import 'package:flutter/material.dart';

import 'src/widgets/game_app.dart';

void main() {
  runApp(const GameApp());
}

หากคุณเรียกใช้โค้ดนี้ใน iOS, Linux, Windows หรือเว็บ เอาต์พุตที่ต้องการจะแสดงในเกม หากกำหนดเป้าหมายเป็น macOS หรือ Android คุณต้องทำการปรับแต่งครั้งสุดท้ายเพื่อเปิดใช้ google_fonts ให้แสดง

เปิดใช้การเข้าถึงแบบอักษร

เพิ่มสิทธิ์อินเทอร์เน็ตสำหรับ Android

สำหรับ Android คุณต้องเพิ่มสิทธิ์เข้าถึงอินเทอร์เน็ต แก้ไข AndroidManifest.xml ดังนี้

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>

แก้ไขไฟล์การให้สิทธิ์สำหรับ macOS

สำหรับ macOS คุณมีไฟล์ 2 ไฟล์ที่ต้องแก้ไข

  1. แก้ไขไฟล์ DebugProfile.entitlements ให้ตรงกับโค้ดต่อไปนี้

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. แก้ไขไฟล์ Release.entitlements ให้ตรงกับโค้ดต่อไปนี้

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>

การเรียกใช้โค้ดนี้ตามที่ระบุควรแสดงหน้าจอต้อนรับและหน้าจอเกมโอเวอร์หรือหน้าจอชนะในทุกแพลตฟอร์ม หน้าจอเหล่านั้นอาจดูเรียบง่ายไปหน่อย และคงจะดีหากมีคะแนน ดังนั้น คุณจะทำอะไรในขั้นตอนถัดไป

10. จดคะแนน

เพิ่มคะแนนลงในเกม

ในขั้นตอนนี้ คุณจะแสดงคะแนนเกมต่อบริบท Flutter โดยรอบ ในขั้นตอนนี้ คุณจะเปิดเผยสถานะจากเกม Flame ไปยังการจัดการสถานะ Flutter โดยรอบ ซึ่งจะช่วยให้โค้ดเกมอัปเดตคะแนนทุกครั้งที่ผู้เล่นทำลายอิฐ

  1. แก้ไขเกม BrickBreaker ดังนี้

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);
}

การเพิ่ม score ลงในเกมจะทำให้สถานะของเกมเชื่อมโยงกับการจัดการสถานะของ Flutter

  1. แก้ไขBrick class เพื่อเพิ่มคะแนนเมื่อผู้เล่นทำลายอิฐ

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>());
    }
  }
}

สร้างเกมที่ดูดี

ตอนนี้คุณสามารถบันทึกคะแนนใน Flutter ได้แล้ว ก็ถึงเวลาประกอบวิดเจ็ตเพื่อให้ดูดี

  1. สร้าง score_card.dart ใน lib/src/widgets แล้วเพิ่มข้อมูลต่อไปนี้

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. สร้าง overlay_screen.dart ใน lib/src/widgets แล้วเพิ่มโค้ดต่อไปนี้

ซึ่งจะช่วยให้การวางซ้อนดูดีขึ้นด้วยการใช้พลังของflutter_animateแพ็กเกจเพื่อเพิ่มการเคลื่อนไหวและสไตล์ให้กับหน้าจอการวางซ้อน

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),
        ],
      ),
    );
  }
}

หากต้องการดูรายละเอียดเพิ่มเติมเกี่ยวกับประสิทธิภาพของ flutter_animate โปรดดู Codelab Building next generation UIs in Flutter

โค้ดนี้มีการเปลี่ยนแปลงมากมายในคอมโพเนนต์ GameApp ก่อนอื่น หากต้องการให้ ScoreCard เข้าถึง score ได้ คุณต้องแปลงจาก StatelessWidget เป็น StatefulWidget การเพิ่มตารางสรุปสถิติจะต้องเพิ่ม Column เพื่อซ้อนคะแนนเหนือเกม

ประการที่ 2 คุณได้เพิ่มวิดเจ็ต OverlayScreen ใหม่เพื่อปรับปรุงประสบการณ์การต้อนรับ เกมจบ และชนะ

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

เมื่อตั้งค่าทุกอย่างเรียบร้อยแล้ว คุณควรจะเรียกใช้เกมนี้บนแพลตฟอร์มเป้าหมาย Flutter ทั้ง 6 แพลตฟอร์มได้ เกมควรมีลักษณะคล้ายกับตัวอย่างต่อไปนี้

ภาพหน้าจอของ brick_breaker แสดงหน้าจอก่อนเริ่มเกมที่เชิญชวนให้ผู้ใช้แตะหน้าจอเพื่อเล่นเกม

ภาพหน้าจอของ brick_breaker แสดงหน้าจอเกมโอเวอร์ที่วางซ้อนอยู่บนค้างคาวและอิฐบางส่วน

11. ขอแสดงความยินดี

ขอแสดงความยินดี คุณสร้างเกมด้วย Flutter และ Flame ได้สำเร็จแล้ว

คุณสร้างเกมโดยใช้เครื่องมือเกม 2 มิติของ Flame และฝังไว้ใน Wrapper ของ Flutter คุณใช้เอฟเฟกต์ของ Flame เพื่อสร้างภาพเคลื่อนไหวและนำคอมโพเนนต์ออก คุณใช้แพ็กเกจ Google Fonts และ Flutter Animate เพื่อให้เกมทั้งเกมดูออกแบบมาอย่างดี

ขั้นตอนต่อไปคืออะไร

ลองดู Codelab เหล่านี้

อ่านเพิ่มเติม