แนะนำ Flame กับ Flutter

1. บทนำ

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

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

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

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

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

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

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

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

ภาพหน้าจอของ VS Code ที่มีโค้ด Flutter

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

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

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

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

ภาพวาดแสดงแล็ปท็อปและโทรศัพท์ที่ต่อกับแล็ปท็อปโดยใช้สาย แล็ปท็อปมีป้ายระบุว่า

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

คุณอาจอยากเลือกเว็บเป็นเป้าหมายการพัฒนา ซึ่งข้อเสียในระหว่างการพัฒนาก็คือคุณจะใช้ความสามารถ Stateful Hot Reload ของ Flutter ไม่ได้ Flutter โหลดเว็บแอปพลิเคชันซ้ำแบบ Hot-Re ไม่ได้

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

ติดตั้ง Flutter

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

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

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

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

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

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

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

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

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

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

ภาพหน้าจอของ VS Code กับ

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

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

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

ภาพหน้าจอของ VS Code กับ

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

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

การดำเนินการนี้จะเพิ่มโค้ดตัวอย่างที่ให้ไว้ใน Codelab นี้ลงในแอปของคุณ

  1. คลิก Explorer ในแผงด้านซ้ายของ VS Code แล้วเปิดไฟล์ 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.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

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

  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. เรียกใช้โค้ดนี้เพื่อยืนยันว่าทุกอย่างทำงานได้ดี จากนั้นระบบจะแสดงหน้าต่างใหม่ที่มีเฉพาะพื้นหลังว่างเปล่าสีดำ วิดีโอเกมที่แย่ที่สุดในโลกกำลังแสดงผลที่ 60fps อยู่!

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

4. สร้างเกม

ขยายขนาดเกม

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

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

กฎข้อเดิมเมื่อมีการสร้างเกมเบรกเอาต์เดิมขึ้นคือ การตั้งต้นทางที่มุมซ้ายบน ทิศทางแกน x ที่เป็นบวกยังคงเหมือนเดิม แต่ y กลับถูกกลับด้าน ทิศทาง x เป็นบวก ถูกต้อง และ Y ลดลง เกมนี้สร้างความแตกต่างให้กับยุคปัจจุบัน โดยเริ่มจากมุมซ้ายบน

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

lib/src/config.dart

const gameWidth = 820.0;
const gameHeight = 1600.0;

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

สร้าง PlayArea

ในเกม เบรกเอาต์ ลูกบอลจะกระเด้งกระดอนไปตามกำแพงของพื้นที่เล่น คุณต้องมีคอมโพเนนต์ 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 วินาที แสงจะมี 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 โดยจะประกาศว่าไฟล์นี้แสดงฟังก์ชันใดเมื่อนำเข้าไปยังไฟล์อื่น ไฟล์นี้จะมีรายการมากขึ้นเมื่อคุณเพิ่มคอมโพเนนต์ใหม่ในขั้นตอนต่อไปนี้

สร้างเกมเปลวไฟ

หากต้องการดับลายเส้นยึกยือสีแดงจากขั้นตอนก่อนหน้า ให้ดึงคลาสย่อยใหม่สำหรับ 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. กำหนดค่าด้านซ้ายบนเป็น Anchor สำหรับช่องมองภาพ โดยค่าเริ่มต้น ช่องมองภาพจะใช้ตรงกลางของพื้นที่เป็นจุดยึดสำหรับ (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 คือระยะเวลาระหว่างเฟรมก่อนหน้ากับเฟรมนี้ ซึ่งจะทำให้คุณสามารถปรับให้เข้ากับปัจจัยต่างๆ เช่น อัตราเฟรมที่แตกต่างกัน (60 hz หรือ 120 hz) หรือเฟรมที่ยาวเนื่องจากการคำนวณมากเกินไป

โปรดให้ความสำคัญกับการอัปเดต 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 ของความสูงเกม

การทำให้ค่าต่างๆ เหล่านี้ถูกต้อง จะต้องมีการปรับปรุงบางอย่าง หรือที่เรียกว่าการทดสอบการเล่นเกมในอุตสาหกรรม

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

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

ภาพหน้าจอแสดงหน้าต่างแอปพลิเคชัน BLOCK_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;
  }
}

วิธีนี้จะติดตาม Hit ของคอมโพเนนต์และทริกเกอร์ Callback ของการชนในทุกเกม

หากต้องการเริ่มสร้าง Hitbox ของเกม ให้แก้ไขคอมโพเนนต์ 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 จะสร้างกล่อง Hit สำหรับการตรวจหาการชนที่ตรงกับขนาดของคอมโพเนนต์หลัก มีตัวสร้างจากโรงงานสำหรับ RectangleHitbox ที่เรียกว่า relative สำหรับบางครั้งเมื่อคุณต้องการ Hitbox ที่เล็กกว่าหรือใหญ่กว่าคอมโพเนนต์หลัก

เด้งบอล

จนถึงตอนนี้ การเพิ่มการตรวจจับการชนไม่ได้ส่งผลใดๆ ต่อเกมเพลย์ และจะเปลี่ยนแปลงเมื่อคุณแก้ไขคอมโพเนนต์ 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.
}

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

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

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

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

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 บนหน้าจอ โดยจะลบล้าง Callback render เพื่อดำเนินการนี้

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

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

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

อาร์กิวเมนต์ตัวสร้างของเอฟเฟกต์มีการอ้างอิงถึง Getter 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(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
}

การเพิ่มการมิกซ์ KeyboardEvents และวิธี 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(                                       // 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');
    }
  }
}

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

ประการแรก เครื่องมือนี้จะแก้ไขลูกบอลที่เด้งออกมาเมื่อแตะด้านล่างของหน้าจอ หากต้องการแก้ไขปัญหานี้ ให้ใช้ RemoveEffect แทนการโทร removeFromParent 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 ที่ด้านบนสุดของคอมโพเนนต์ต้นไม้

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

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

หากต้องการทำให้ BrickBreaker เข้าถึงคอมโพเนนต์ Brick ได้ ให้แก้ไข 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;
  }
}

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

ภาพหน้าจอแสดง block_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 ต้องใช้ความพยายามมาก ซึ่งจะเก็บข้อมูลตำแหน่งที่ผู้เล่นเข้า เล่น และแพ้หรือชนะ ที่ด้านบนของไฟล์ ให้ระบุการแจงนับ จากนั้นจึงสร้างอินสแตนซ์เป็นสถานะที่ซ่อนอยู่โดยมี Getters และ Setter ที่ตรงกัน Getters และ 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

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

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

เพิ่ม Wrapper ของ Flutter

หากต้องการระบุตำแหน่งสำหรับฝังเกมและเพิ่มการวางซ้อนสถานะการเล่น ให้เพิ่ม Shell 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(
        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,
                              ),
                            ),
                      },
                    ),
                  ),
                ),
              ),
            ),
          ),
        ),
      ),
    );
  }
}

เนื้อหาส่วนใหญ่ในไฟล์นี้เป็นไปตามบิลด์ต้นไม้วิดเจ็ต 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 เพื่อเพิ่มคะแนนเมื่อผู้เล่นทำลายอิฐ

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 การสร้าง UI รุ่นถัดไปใน 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(
        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.
              ),
            ),
          ),
        ),
      ),
    );
  }
}

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

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

ภาพหน้าจอแสดงเกม Brick_breaker ที่แสดงเกมบนหน้าจอที่วางซ้อนบนไม้ตีและก้อนอิฐบางชิ้น

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

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

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

ขั้นตอนถัดไปคือ

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

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