สร้างเกมฟิสิกส์แบบ 2 มิติด้วย Flutter และ Flame

1. ก่อนเริ่มต้น

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

ภาพเคลื่อนไหวของเกมเพลย์ในเกมฟิสิกส์ 2 มิตินี้

ข้อกำหนดเบื้องต้น

สิ่งที่คุณเรียนรู้

  • วิธีการทํางานพื้นฐานของ Forge2D โดยเริ่มจากวัตถุประเภทต่างๆ
  • วิธีตั้งค่าการจำลองฟิสิกส์ใน 2 มิติ

สิ่งที่ต้องมี

ซอฟต์แวร์คอมไพเลอร์สำหรับเป้าหมายการพัฒนาที่เลือก โค้ดแล็บนี้ใช้ได้กับแพลตฟอร์มทั้ง 6 แพลตฟอร์มที่ Flutter รองรับ คุณต้องใช้ Visual Studio เพื่อกำหนดเป้าหมายเป็น Windows, Xcode เพื่อกำหนดเป้าหมายเป็น macOS หรือ iOS และ Android Studio เพื่อกำหนดเป้าหมายเป็น Android

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

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

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

ในการเริ่มต้น โปรดปฏิบัติตามขั้นตอนต่อไปนี้:

  1. สร้างโปรเจ็กต์ Flutter ในบรรทัดคำสั่งโดยทำดังนี้
    $ flutter create --empty forge2d_game
    Creating project forge2d_game...
    Resolving dependencies in forge2d_game... (4.7s)
    Got dependencies in forge2d_game.
    Wrote 128 files.
    
    All done!
    You can find general documentation for Flutter at: https://docs.flutter.dev/
    Detailed API documentation is available at: https://api.flutter.dev/
    If you prefer video documentation, consider: https://www.youtube.com/c/flutterdev
    
    In order to run your empty application, type:
    
      $ cd forge2d_game
      $ flutter run
    
    Your empty application code is in forge2d_game/lib/main.dart.
    
  2. แก้ไขทรัพยากร Dependency ของโปรเจ็กต์เพื่อเพิ่ม Flame และ Forge2D
    $ cd forge2d_game
    $ flutter pub add characters flame flame_forge2d flame_kenney_xml xml
    Resolving dependencies...
    Downloading packages...
      characters 1.4.0 (from transitive dependency to direct dependency)
    + flame 1.29.0
    + flame_forge2d 0.19.0+2
    + flame_kenney_xml 0.1.1+12
      flutter_lints 5.0.0 (6.0.0 available)
    + forge2d 0.14.0
      leak_tracker 10.0.9 (11.0.1 available)
      leak_tracker_flutter_testing 3.0.9 (3.0.10 available)
      leak_tracker_testing 3.0.1 (3.0.2 available)
      lints 5.1.1 (6.0.0 available)
      material_color_utilities 0.11.1 (0.13.0 available)
      meta 1.16.0 (1.17.0 available)
    + ordered_set 8.0.0
    + petitparser 6.1.0 (7.0.0 available)
      test_api 0.7.4 (0.7.6 available)
      vector_math 2.1.4 (2.2.0 available)
      vm_service 15.0.0 (15.0.2 available)
    + xml 6.5.0 (6.6.0 available)
    Changed 8 dependencies!
    12 packages have newer versions incompatible with dependency constraints.
    Try `flutter pub outdated` for more information.
    

แพ็กเกจ flame เป็นสิ่งที่คุณคุ้นเคย แต่อีก 3 แพ็กเกจอาจต้องมีคำอธิบายเพิ่มเติม แพ็กเกจ characters ใช้สำหรับการจัดการเส้นทางในลักษณะที่เป็นไปตาม UTF8 แพ็กเกจ flame_forge2d จะแสดงฟังก์ชันการทำงานของ Forge2D ในลักษณะที่ทำงานร่วมกับ Flame ได้ดี สุดท้าย ระบบจะใช้แพ็กเกจ xml ในตำแหน่งต่างๆ เพื่อใช้และแก้ไขเนื้อหา XML

เปิดโปรเจ็กต์ แล้วแทนที่เนื้อหาของไฟล์ lib/main.dart ด้วยข้อมูลต่อไปนี้

lib/main.dart

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

void main() {
  runApp(GameWidget.controlled(gameFactory: FlameGame.new));
}

ซึ่งจะเริ่มต้นแอปด้วย GameWidget ที่สร้างอินสแตนซ์ FlameGame ไม่มีโค้ด Flutter ในโค้ดแล็บนี้ที่ใช้สถานะของอินสแตนซ์เกมเพื่อแสดงข้อมูลเกี่ยวกับเกมที่ทำงานอยู่ Bootstrap ที่เรียบง่ายนี้จึงทํางานได้ดี

ไม่บังคับ: ดำเนินการภารกิจย่อยสำหรับ macOS เท่านั้น

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

โดยทำตามขั้นตอนต่อไปนี้

  1. สร้างไฟล์ bin/modify_macos_config.dart และเพิ่มเนื้อหาต่อไปนี้

bin/modify_macos_config.dart

import 'dart:io';
import 'package:xml/xml.dart';
import 'package:xml/xpath.dart';

void main() {
  final file = File('macos/Runner/Base.lproj/MainMenu.xib');
  var document = XmlDocument.parse(file.readAsStringSync());
  document.xpath('//document/objects/window').first
    ..setAttribute('titlebarAppearsTransparent', 'YES')
    ..setAttribute('titleVisibility', 'hidden');
  document
      .xpath('//document/objects/window/windowStyleMask')
      .first
      .setAttribute('fullSizeContentView', 'YES');
  file.writeAsStringSync(document.toString());
}

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

  1. จากไดเรกทอรีฐานของโปรเจ็กต์ ให้เรียกใช้เครื่องมือดังนี้
dart bin/modify_macos_config.dart

หากทุกอย่างเป็นไปตามแผน โปรแกรมจะไม่สร้างเอาต์พุตในบรรทัดคำสั่ง อย่างไรก็ตาม โปรแกรมจะแก้ไขไฟล์การกําหนดค่า macos/Runner/Base.lproj/MainMenu.xib เพื่อเรียกใช้เกมโดยไม่มีแถบชื่อที่มองเห็นได้และเกม Flame จะกินพื้นที่ทั้งหน้าต่าง

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

หน้าต่างแอปที่มีพื้นหลังสีดําและไม่มีสิ่งใดอยู่เบื้องหน้า

3. เพิ่มชิ้นงานรูปภาพ

เพิ่มรูปภาพ

เกมทุกเกมต้องใช้ชิ้นงานศิลปะเพื่อวาดหน้าจอในลักษณะที่สนุก โค้ดแล็บนี้จะใช้แพ็กชิ้นงานฟิสิกส์จาก Kenney.nl ชิ้นงานเหล่านี้มีใบอนุญาต Creative Commons CC0 แต่เราขอแนะนำให้คุณบริจาคเงินให้ทีม Kenney เพื่อให้พวกเขาทำงานดีๆ ต่อไปได้ ใช่

คุณจะต้องแก้ไขไฟล์การกําหนดค่า pubspec.yaml เพื่อเปิดใช้ชิ้นงานของ Kenney แก้ไขดังนี้

pubspec.yaml

name: forge2d_game
description: "A new Flutter project."
publish_to: 'none'
version: 0.1.0

environment:
  sdk: ^3.8.1

dependencies:
  flutter:
    sdk: flutter
  characters: ^1.4.0
  flame: ^1.29.0
  flame_forge2d: ^0.19.0+2
  flame_kenney_xml: ^0.1.1+12
  xml: ^6.5.0

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^5.0.0

flutter:
  uses-material-design: true
  assets:                        # Add from here
    - assets/
    - assets/images/             # To here.

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

mkdir -p assets/images

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

ขยายไฟล์ kenney_physics-assets.zip ที่คุณดาวน์โหลด และคุณควรเห็นข้อมูลดังต่อไปนี้

รายการไฟล์ของแพ็กเกจ kenney_physics-assets ที่ขยายออก โดยไฮไลต์ไดเรกทอรี PNG/Backgrounds

จากไดเรกทอรี PNG/Backgrounds ให้คัดลอกไฟล์ colored_desert.png, colored_grass.png, colored_land.png และ colored_shroom.png ไปยังไดเรกทอรี assets/images ของโปรเจ็กต์

นอกจากนี้ยังมี Sprite Sheet ด้วย ไฟล์เหล่านี้ประกอบด้วยรูปภาพ PNG และไฟล์ XML ที่อธิบายตําแหน่งในรูปภาพ Sprite Sheet ที่อาจพบรูปภาพขนาดเล็ก สไปรต์ชีตเป็นเทคนิคในการลดเวลาในการโหลดโดยโหลดไฟล์เพียงไฟล์เดียวแทนที่จะโหลดไฟล์ภาพทีละหลายสิบหรือหลายร้อยไฟล์

รายการไฟล์ของแพ็ก kenney_physics-assets ที่ขยายออก โดยไฮไลต์ไดเรกทอรี Spritesheet

คัดลอก spritesheet_aliens.png, spritesheet_elements.png และ spritesheet_tiles.png ไปยังไดเรกทอรี assets/images ของโปรเจ็กต์ ขณะอยู่ที่นี่ ให้คัดลอกไฟล์ spritesheet_aliens.xml, spritesheet_elements.xml และ spritesheet_tiles.xml ไปยังไดเรกทอรี assets ของโปรเจ็กต์ด้วย โปรเจ็กต์ของคุณควรมีลักษณะดังต่อไปนี้

รายการไฟล์ของไดเรกทอรีโปรเจ็กต์ forge2d_game โดยไฮไลต์ไดเรกทอรีชิ้นงาน

ระบายสีพื้นหลัง

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

สร้างไฟล์ชื่อ background.dart ในไดเรกทอรีใหม่ชื่อ lib/components แล้วเพิ่มเนื้อหาต่อไปนี้

lib/components/background.dart

import 'dart:math';
import 'package:flame/components.dart';
import 'game.dart';

class Background extends SpriteComponent with HasGameReference<MyPhysicsGame> {
  Background({required super.sprite})
    : super(anchor: Anchor.center, position: Vector2(0, 0));

  @override
  void onMount() {
    super.onMount();

    size = Vector2.all(
      max(
        game.camera.visibleWorldRect.width,
        game.camera.visibleWorldRect.height,
      ),
    );
  }
}

คอมโพเนนต์นี้เป็น SpriteComponent เฉพาะทาง มีหน้าที่แสดงภาพพื้นหลัง 1 ใน 4 ของ Kenney.nl โค้ดนี้มีสมมติฐานที่ง่ายขึ้น 2-3 ข้อ ข้อแรกคือรูปภาพต้องเป็นสี่เหลี่ยมจัตุรัส ซึ่งภาพพื้นหลังทั้ง 4 ภาพจาก Kenney เป็นสี่เหลี่ยมจัตุรัส ข้อที่ 2 คือขนาดของโลกที่มองเห็นจะไม่มีการเปลี่ยนแปลง มิเช่นนั้นคอมโพเนนต์นี้จะต้องจัดการเหตุการณ์การปรับขนาดเกม สมมติฐานที่ 3 คือตําแหน่ง (0,0) จะอยู่ที่กึ่งกลางของหน้าจอ สมมติฐานเหล่านี้ต้องมีการกําหนดค่า CameraComponent ของเกมโดยเฉพาะ

สร้างไฟล์ใหม่อีกไฟล์หนึ่งชื่อ game.dart ในไดเรกทอรี lib/components อีกครั้ง

lib/components/game.dart

import 'dart:async';

import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flame_kenney_xml/flame_kenney_xml.dart';

import 'background.dart';

class MyPhysicsGame extends Forge2DGame {
  MyPhysicsGame()
    : super(
        gravity: Vector2(0, 10),
        camera: CameraComponent.withFixedResolution(width: 800, height: 600),
      );

  late final XmlSpriteSheet aliens;
  late final XmlSpriteSheet elements;
  late final XmlSpriteSheet tiles;

  @override
  FutureOr<void> onLoad() async {
    final backgroundImage = await images.load('colored_grass.png');
    final spriteSheets = await Future.wait([
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_aliens.png',
        xmlPath: 'spritesheet_aliens.xml',
      ),
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_elements.png',
        xmlPath: 'spritesheet_elements.xml',
      ),
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_tiles.png',
        xmlPath: 'spritesheet_tiles.xml',
      ),
    ]);

    aliens = spriteSheets[0];
    elements = spriteSheets[1];
    tiles = spriteSheets[2];

    await world.add(Background(sprite: Sprite(backgroundImage)));

    return super.onLoad();
  }
}

มีเรื่องเกิดขึ้นมากมาย เริ่มด้วยชั้นเรียน MyPhysicsGame ซึ่งแตกต่างจากโค้ดแล็บก่อนหน้านี้ตรงที่โค้ดนี้ขยาย Forge2DGame ไม่ใช่ FlameGame Forge2DGame เองก็ขยายความสามารถของ FlameGame ด้วยการปรับแต่งที่น่าสนใจบางอย่าง ประการแรกคือ zoom จะตั้งค่าเป็น 10 โดยค่าเริ่มต้น การตั้งค่า zoom นี้เกี่ยวข้องกับช่วงของค่าที่มีประโยชน์ซึ่งเครื่องมือจำลองฟิสิกส์สไตล์ Box2D ทำงานได้ดี เครื่องยนต์เขียนโดยใช้ระบบ MKS ซึ่งจะถือว่าหน่วยเป็นเมตร กิโลกรัม และวินาที ระยะที่คุณไม่เห็นข้อผิดพลาดทางคณิตศาสตร์ที่เห็นได้ชัดสำหรับวัตถุคือ 0.1 เมตรถึง 10 เมตร การป้อนขนาดพิกเซลโดยตรงโดยไม่ลดขนาดลงในระดับหนึ่งจะทำให้ Forge2D ทำงานนอกขอบเขตที่มีประโยชน์ สรุปที่มีประโยชน์คือให้ลองจินตนาการถึงการจำลองวัตถุที่มีขนาดตั้งแต่กระป๋องน้ำอัดลมไปจนถึงรถบัส

สมมติฐานที่สร้างขึ้นในส่วนประกอบพื้นหลังได้รับการตอบสนองแล้วที่นี่ด้วยการแก้ไขความละเอียดของ CameraComponent เป็นพิกเซลเสมือน 800 x 600 ซึ่งหมายความว่าพื้นที่เกมจะกว้าง 80 หน่วยและสูง 60 หน่วย โดยอยู่กึ่งกลางที่ (0,0) ซึ่งจะไม่มีผลต่อความละเอียดที่แสดง แต่จะมีผลต่อตําแหน่งที่เราวางวัตถุในฉากเกม

นอกเหนือจากอาร์กิวเมนต์คอนสตรัคเตอร์ camera แล้ว ยังมีอาร์กิวเมนต์อีกรายการหนึ่งที่สอดคล้องกับฟิสิกส์มากกว่า ซึ่งเรียกว่า gravity กำหนดแรงโน้มถ่วงเป็น Vector2 โดยมี x เป็น 0 และ y เป็น 10 10 เป็นค่าแรงโน้มถ่วงที่ยอมรับกันโดยทั่วไปซึ่งเท่ากับ 9.81 เมตรต่อวินาทีต่อวินาที การตั้งค่าแรงโน้มถ่วงเป็นบวก 10 แสดงให้เห็นว่าในระบบนี้ ทิศทางของแกน Y จะเป็นลง ซึ่งแตกต่างจาก Box2D โดยทั่วไป แต่สอดคล้องกับวิธีกำหนดค่า Flame โดยทั่วไป

ถัดไปคือวิธีการ onLoad เมธอดนี้เป็นแบบไม่พร้อมกัน ซึ่งเหมาะสมเนื่องจากมีหน้าที่โหลดชิ้นงานรูปภาพจากดิสก์ การเรียก images.load จะแสดงผลเป็น Future<Image> และผลข้างเคียงคือแคชรูปภาพที่โหลดไว้ในออบเจ็กต์เกม ระบบจะรวบรวม Future เหล่านี้เข้าด้วยกันและรอให้เสร็จสมบูรณ์เป็นหน่วยเดียวโดยใช้Futures.waitเมธอดแบบคงที่ จากนั้นระบบจะจับคู่รายการรูปภาพที่แสดงผลเป็นรูปแบบเป็นชื่อแต่ละรายการ

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

เมื่อดำเนินการเสร็จแล้ว คุณก็แค่ต้องทำการแก้ไขเล็กน้อยใน lib/main.dart เพื่อแสดงรูปภาพบนหน้าจอ

lib/main.dart

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

import 'components/game.dart';                                    // Add this import

void main() {
  runApp(GameWidget.controlled(gameFactory: MyPhysicsGame.new));  // Modify this line
}

การเปลี่ยนแปลงนี้จะช่วยให้คุณเรียกใช้เกมอีกครั้งเพื่อดูพื้นหลังบนหน้าจอได้ โปรดทราบว่าอินสแตนซ์กล้อง CameraComponent.withFixedResolution() จะเพิ่มแถบดำด้านบนและล่างตามที่จำเป็นเพื่อให้เกมมีสัดส่วน 800 x 600

แอปที่มีเนินเขาสีเขียวและต้นไม้นามธรรมแปลกๆ

4. เพิ่มพื้น

สิ่งที่นำมาต่อยอดได้

หากมีแรงโน้มถ่วง เราจะต้องมีอะไรสักอย่างเพื่อจับวัตถุในเกมก่อนที่วัตถุจะตกลงจากด้านล่างของหน้าจอ เว้นแต่การตกนอกหน้าจอจะเป็นส่วนหนึ่งของการออกแบบเกม สร้างไฟล์ ground.dart ใหม่ในไดเรกทอรี lib/components แล้วเพิ่มข้อมูลต่อไปนี้ลงในไฟล์

lib/components/ground.dart

import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';

const groundSize = 7.0;

class Ground extends BodyComponent {
  Ground(Vector2 position, Sprite sprite)
    : super(
        renderBody: false,
        bodyDef: BodyDef()
          ..position = position
          ..type = BodyType.static,
        fixtureDefs: [
          FixtureDef(
            PolygonShape()..setAsBoxXY(groundSize / 2, groundSize / 2),
            friction: 0.3,
          ),
        ],
        children: [
          SpriteComponent(
            anchor: Anchor.center,
            sprite: sprite,
            size: Vector2.all(groundSize),
            position: Vector2(0, 0),
          ),
        ],
      );
}

คอมโพเนนต์ Ground นี้มาจาก BodyComponent ใน Forge2D วัตถุ (Body) มีความสำคัญ เนื่องจากเป็นวัตถุที่เป็นส่วนหนึ่งของการจำลองทางกายภาพ 2 มิติ มีการระบุ BodyDef สำหรับคอมโพเนนต์นี้ให้มี BodyType.static

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

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

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

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

หากต้องการเพิ่มคอมโพเนนต์ Ground ลงในเกม ให้แก้ไขไฟล์ game.dart ดังนี้

lib/components/game.dart

import 'dart:async';

import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flame_kenney_xml/flame_kenney_xml.dart';

import 'background.dart';
import 'ground.dart';                                      // Add this import

class MyPhysicsGame extends Forge2DGame {
  MyPhysicsGame()
    : super(
        gravity: Vector2(0, 10),
        camera: CameraComponent.withFixedResolution(width: 800, height: 600),
      );

  late final XmlSpriteSheet aliens;
  late final XmlSpriteSheet elements;
  late final XmlSpriteSheet tiles;

  @override
  FutureOr<void> onLoad() async {
    final backgroundImage = await images.load('colored_grass.png');
    final spriteSheets = await Future.wait([
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_aliens.png',
        xmlPath: 'spritesheet_aliens.xml',
      ),
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_elements.png',
        xmlPath: 'spritesheet_elements.xml',
      ),
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_tiles.png',
        xmlPath: 'spritesheet_tiles.xml',
      ),
    ]);

    aliens = spriteSheets[0];
    elements = spriteSheets[1];
    tiles = spriteSheets[2];

    await world.add(Background(sprite: Sprite(backgroundImage)));
    await addGround();                                     // Add this line

    return super.onLoad();
  }

  Future<void> addGround() {                               // Add from here...
    return world.addAll([
      for (
        var x = camera.visibleWorldRect.left;
        x < camera.visibleWorldRect.right + groundSize;
        x += groundSize
      )
        Ground(
          Vector2(x, (camera.visibleWorldRect.height - groundSize) / 2),
          tiles.getSprite('grass.png'),
        ),
    ]);
  }                                                        // To here.
}

การแก้ไขนี้จะเพิ่มชุดคอมโพเนนต์ Ground ไปยังเวิร์กลิสต์โดยใช้ลูป for ภายในบริบท List และส่งรายการคอมโพเนนต์ Ground ที่ได้ไปยังเมธอด addAll ของ world

ตอนนี้การเล่นเกมจะแสดงพื้นหลังและพื้นดิน

หน้าต่างแอปพลิเคชันที่มีพื้นหลังและเลเยอร์พื้น

5. เพิ่มอิฐ

สร้างกำแพง

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

ระบบจะสร้างบล็อกจากสไปรท์ชีตขององค์ประกอบ หากคุณดูคำอธิบายของ Sprite Sheet ใน assets/spritesheet_elements.xml จะเห็นว่าเรามีปัญหาที่น่าสนใจ ชื่อดูเหมือนจะไม่มีประโยชน์มากนัก สิ่งที่มีประโยชน์คือการเลือกอิฐตามประเภทวัสดุ ขนาด และระดับความเสียหาย โชคดีที่เอลฟ์ผู้ช่วยเหลือได้ใช้เวลาสักครู่เพื่อหารูปแบบการตั้งชื่อไฟล์และสร้างเครื่องมือเพื่อให้คุณดำเนินการได้ง่ายขึ้น สร้างไฟล์ใหม่ generate_brick_file_names.dart ในไดเรกทอรี bin แล้วเพิ่มเนื้อหาต่อไปนี้

bin/generate_brick_file_names.dart

import 'dart:io';
import 'package:equatable/equatable.dart';
import 'package:xml/xml.dart';
import 'package:xml/xpath.dart';

void main() {
  final file = File('assets/spritesheet_elements.xml');
  final rects = <String, Rect>{};
  final document = XmlDocument.parse(file.readAsStringSync());
  for (final node in document.xpath('//TextureAtlas/SubTexture')) {
    final name = node.getAttribute('name')!;
    rects[name] = Rect(
      x: int.parse(node.getAttribute('x')!),
      y: int.parse(node.getAttribute('y')!),
      width: int.parse(node.getAttribute('width')!),
      height: int.parse(node.getAttribute('height')!),
    );
  }
  print(generateBrickFileNames(rects));
}

class Rect extends Equatable {
  final int x;
  final int y;
  final int width;
  final int height;
  const Rect({
    required this.x,
    required this.y,
    required this.width,
    required this.height,
  });

  Size get size => Size(width, height);

  @override
  List<Object?> get props => [x, y, width, height];

  @override
  bool get stringify => true;
}

class Size extends Equatable {
  final int width;
  final int height;
  const Size(this.width, this.height);

  @override
  List<Object?> get props => [width, height];

  @override
  bool get stringify => true;
}

String generateBrickFileNames(Map<String, Rect> rects) {
  final groups = <Size, List<String>>{};
  for (final entry in rects.entries) {
    groups.putIfAbsent(entry.value.size, () => []).add(entry.key);
  }
  final buff = StringBuffer();
  buff.writeln('''
Map<BrickDamage, String> brickFileNames(BrickType type, BrickSize size) {
  return switch ((type, size)) {''');
  for (final entry in groups.entries) {
    final size = entry.key;
    final entries = entry.value;
    entries.sort();
    for (final type in ['Explosive', 'Glass', 'Metal', 'Stone', 'Wood']) {
      var filtered = entries.where((element) => element.contains(type));
      if (filtered.length == 5) {
        buff.writeln('''
    (BrickType.${type.toLowerCase()}, BrickSize.size${size.width}x${size.height}) => {
        BrickDamage.none: '${filtered.elementAt(0)}',
        BrickDamage.some: '${filtered.elementAt(1)}',
        BrickDamage.lots: '${filtered.elementAt(4)}',
      },''');
      } else if (filtered.length == 10) {
        buff.writeln('''
    (BrickType.${type.toLowerCase()}, BrickSize.size${size.width}x${size.height}) => {
        BrickDamage.none: '${filtered.elementAt(3)}',
        BrickDamage.some: '${filtered.elementAt(4)}',
        BrickDamage.lots: '${filtered.elementAt(9)}',
      },''');
      } else if (filtered.length == 15) {
        buff.writeln('''
    (BrickType.${type.toLowerCase()}, BrickSize.size${size.width}x${size.height}) => {
        BrickDamage.none: '${filtered.elementAt(7)}',
        BrickDamage.some: '${filtered.elementAt(8)}',
        BrickDamage.lots: '${filtered.elementAt(13)}',
      },''');
      }
    }
  }
  buff.writeln('''
  };
}''');
  return buff.toString();
}

เครื่องมือแก้ไขควรแสดงคำเตือนหรือข้อผิดพลาดเกี่ยวกับทรัพยากร Dependency ที่ขาดหายไป เพิ่มโดยใช้คําสั่งต่อไปนี้

flutter pub add equatable

ตอนนี้คุณควรเรียกใช้โปรแกรมนี้ได้ ดังนี้

$ dart run bin/generate_brick_file_names.dart
Map<BrickDamage, String> brickFileNames(BrickType type, BrickSize size) {
  return switch ((type, size)) {
    (BrickType.explosive, BrickSize.size140x70) => {
        BrickDamage.none: 'elementExplosive009.png',
        BrickDamage.some: 'elementExplosive012.png',
        BrickDamage.lots: 'elementExplosive050.png',
      },
    (BrickType.glass, BrickSize.size140x70) => {
        BrickDamage.none: 'elementGlass010.png',
        BrickDamage.some: 'elementGlass013.png',
        BrickDamage.lots: 'elementGlass048.png',
      },
[Content elided...]
    (BrickType.wood, BrickSize.size140x220) => {
        BrickDamage.none: 'elementWood020.png',
        BrickDamage.some: 'elementWood025.png',
        BrickDamage.lots: 'elementWood052.png',
      },
  };
}

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

สร้างไฟล์ brick.dart ที่มีเนื้อหาต่อไปนี้

lib/components/brick.dart

import 'dart:math';
import 'dart:ui' as ui;

import 'package:flame/components.dart';
import 'package:flame/extensions.dart';
import 'package:flame_forge2d/flame_forge2d.dart';

const brickScale = 0.5;

enum BrickType {
  explosive(density: 1, friction: 0.5),
  glass(density: 0.5, friction: 0.2),
  metal(density: 1, friction: 0.4),
  stone(density: 2, friction: 1),
  wood(density: 0.25, friction: 0.6);

  final double density;
  final double friction;

  const BrickType({required this.density, required this.friction});
  static BrickType get randomType => values[Random().nextInt(values.length)];
}

enum BrickSize {
  size70x70(ui.Size(70, 70)),
  size140x70(ui.Size(140, 70)),
  size220x70(ui.Size(220, 70)),
  size70x140(ui.Size(70, 140)),
  size140x140(ui.Size(140, 140)),
  size220x140(ui.Size(220, 140)),
  size140x220(ui.Size(140, 220)),
  size70x220(ui.Size(70, 220));

  final ui.Size size;

  const BrickSize(this.size);

  static BrickSize get randomSize => values[Random().nextInt(values.length)];
}

enum BrickDamage { none, some, lots }

Map<BrickDamage, String> brickFileNames(BrickType type, BrickSize size) {
  return switch ((type, size)) {
    (BrickType.explosive, BrickSize.size140x70) => {
      BrickDamage.none: 'elementExplosive009.png',
      BrickDamage.some: 'elementExplosive012.png',
      BrickDamage.lots: 'elementExplosive050.png',
    },
    (BrickType.glass, BrickSize.size140x70) => {
      BrickDamage.none: 'elementGlass010.png',
      BrickDamage.some: 'elementGlass013.png',
      BrickDamage.lots: 'elementGlass048.png',
    },
    (BrickType.metal, BrickSize.size140x70) => {
      BrickDamage.none: 'elementMetal009.png',
      BrickDamage.some: 'elementMetal012.png',
      BrickDamage.lots: 'elementMetal050.png',
    },
    (BrickType.stone, BrickSize.size140x70) => {
      BrickDamage.none: 'elementStone009.png',
      BrickDamage.some: 'elementStone012.png',
      BrickDamage.lots: 'elementStone047.png',
    },
    (BrickType.wood, BrickSize.size140x70) => {
      BrickDamage.none: 'elementWood011.png',
      BrickDamage.some: 'elementWood014.png',
      BrickDamage.lots: 'elementWood054.png',
    },
    (BrickType.explosive, BrickSize.size70x70) => {
      BrickDamage.none: 'elementExplosive011.png',
      BrickDamage.some: 'elementExplosive014.png',
      BrickDamage.lots: 'elementExplosive049.png',
    },
    (BrickType.glass, BrickSize.size70x70) => {
      BrickDamage.none: 'elementGlass011.png',
      BrickDamage.some: 'elementGlass012.png',
      BrickDamage.lots: 'elementGlass046.png',
    },
    (BrickType.metal, BrickSize.size70x70) => {
      BrickDamage.none: 'elementMetal011.png',
      BrickDamage.some: 'elementMetal014.png',
      BrickDamage.lots: 'elementMetal049.png',
    },
    (BrickType.stone, BrickSize.size70x70) => {
      BrickDamage.none: 'elementStone011.png',
      BrickDamage.some: 'elementStone014.png',
      BrickDamage.lots: 'elementStone046.png',
    },
    (BrickType.wood, BrickSize.size70x70) => {
      BrickDamage.none: 'elementWood010.png',
      BrickDamage.some: 'elementWood013.png',
      BrickDamage.lots: 'elementWood045.png',
    },
    (BrickType.explosive, BrickSize.size220x70) => {
      BrickDamage.none: 'elementExplosive013.png',
      BrickDamage.some: 'elementExplosive016.png',
      BrickDamage.lots: 'elementExplosive051.png',
    },
    (BrickType.glass, BrickSize.size220x70) => {
      BrickDamage.none: 'elementGlass014.png',
      BrickDamage.some: 'elementGlass017.png',
      BrickDamage.lots: 'elementGlass049.png',
    },
    (BrickType.metal, BrickSize.size220x70) => {
      BrickDamage.none: 'elementMetal013.png',
      BrickDamage.some: 'elementMetal016.png',
      BrickDamage.lots: 'elementMetal051.png',
    },
    (BrickType.stone, BrickSize.size220x70) => {
      BrickDamage.none: 'elementStone013.png',
      BrickDamage.some: 'elementStone016.png',
      BrickDamage.lots: 'elementStone048.png',
    },
    (BrickType.wood, BrickSize.size220x70) => {
      BrickDamage.none: 'elementWood012.png',
      BrickDamage.some: 'elementWood015.png',
      BrickDamage.lots: 'elementWood047.png',
    },
    (BrickType.explosive, BrickSize.size70x140) => {
      BrickDamage.none: 'elementExplosive017.png',
      BrickDamage.some: 'elementExplosive022.png',
      BrickDamage.lots: 'elementExplosive052.png',
    },
    (BrickType.glass, BrickSize.size70x140) => {
      BrickDamage.none: 'elementGlass018.png',
      BrickDamage.some: 'elementGlass023.png',
      BrickDamage.lots: 'elementGlass050.png',
    },
    (BrickType.metal, BrickSize.size70x140) => {
      BrickDamage.none: 'elementMetal017.png',
      BrickDamage.some: 'elementMetal022.png',
      BrickDamage.lots: 'elementMetal052.png',
    },
    (BrickType.stone, BrickSize.size70x140) => {
      BrickDamage.none: 'elementStone017.png',
      BrickDamage.some: 'elementStone022.png',
      BrickDamage.lots: 'elementStone049.png',
    },
    (BrickType.wood, BrickSize.size70x140) => {
      BrickDamage.none: 'elementWood016.png',
      BrickDamage.some: 'elementWood021.png',
      BrickDamage.lots: 'elementWood048.png',
    },
    (BrickType.explosive, BrickSize.size140x140) => {
      BrickDamage.none: 'elementExplosive018.png',
      BrickDamage.some: 'elementExplosive023.png',
      BrickDamage.lots: 'elementExplosive053.png',
    },
    (BrickType.glass, BrickSize.size140x140) => {
      BrickDamage.none: 'elementGlass019.png',
      BrickDamage.some: 'elementGlass024.png',
      BrickDamage.lots: 'elementGlass051.png',
    },
    (BrickType.metal, BrickSize.size140x140) => {
      BrickDamage.none: 'elementMetal018.png',
      BrickDamage.some: 'elementMetal023.png',
      BrickDamage.lots: 'elementMetal053.png',
    },
    (BrickType.stone, BrickSize.size140x140) => {
      BrickDamage.none: 'elementStone018.png',
      BrickDamage.some: 'elementStone023.png',
      BrickDamage.lots: 'elementStone050.png',
    },
    (BrickType.wood, BrickSize.size140x140) => {
      BrickDamage.none: 'elementWood017.png',
      BrickDamage.some: 'elementWood022.png',
      BrickDamage.lots: 'elementWood049.png',
    },
    (BrickType.explosive, BrickSize.size220x140) => {
      BrickDamage.none: 'elementExplosive019.png',
      BrickDamage.some: 'elementExplosive024.png',
      BrickDamage.lots: 'elementExplosive054.png',
    },
    (BrickType.glass, BrickSize.size220x140) => {
      BrickDamage.none: 'elementGlass020.png',
      BrickDamage.some: 'elementGlass025.png',
      BrickDamage.lots: 'elementGlass052.png',
    },
    (BrickType.metal, BrickSize.size220x140) => {
      BrickDamage.none: 'elementMetal019.png',
      BrickDamage.some: 'elementMetal024.png',
      BrickDamage.lots: 'elementMetal054.png',
    },
    (BrickType.stone, BrickSize.size220x140) => {
      BrickDamage.none: 'elementStone019.png',
      BrickDamage.some: 'elementStone024.png',
      BrickDamage.lots: 'elementStone051.png',
    },
    (BrickType.wood, BrickSize.size220x140) => {
      BrickDamage.none: 'elementWood018.png',
      BrickDamage.some: 'elementWood023.png',
      BrickDamage.lots: 'elementWood050.png',
    },
    (BrickType.explosive, BrickSize.size70x220) => {
      BrickDamage.none: 'elementExplosive020.png',
      BrickDamage.some: 'elementExplosive025.png',
      BrickDamage.lots: 'elementExplosive055.png',
    },
    (BrickType.glass, BrickSize.size70x220) => {
      BrickDamage.none: 'elementGlass021.png',
      BrickDamage.some: 'elementGlass026.png',
      BrickDamage.lots: 'elementGlass053.png',
    },
    (BrickType.metal, BrickSize.size70x220) => {
      BrickDamage.none: 'elementMetal020.png',
      BrickDamage.some: 'elementMetal025.png',
      BrickDamage.lots: 'elementMetal055.png',
    },
    (BrickType.stone, BrickSize.size70x220) => {
      BrickDamage.none: 'elementStone020.png',
      BrickDamage.some: 'elementStone025.png',
      BrickDamage.lots: 'elementStone052.png',
    },
    (BrickType.wood, BrickSize.size70x220) => {
      BrickDamage.none: 'elementWood019.png',
      BrickDamage.some: 'elementWood024.png',
      BrickDamage.lots: 'elementWood051.png',
    },
    (BrickType.explosive, BrickSize.size140x220) => {
      BrickDamage.none: 'elementExplosive021.png',
      BrickDamage.some: 'elementExplosive026.png',
      BrickDamage.lots: 'elementExplosive056.png',
    },
    (BrickType.glass, BrickSize.size140x220) => {
      BrickDamage.none: 'elementGlass022.png',
      BrickDamage.some: 'elementGlass027.png',
      BrickDamage.lots: 'elementGlass054.png',
    },
    (BrickType.metal, BrickSize.size140x220) => {
      BrickDamage.none: 'elementMetal021.png',
      BrickDamage.some: 'elementMetal026.png',
      BrickDamage.lots: 'elementMetal056.png',
    },
    (BrickType.stone, BrickSize.size140x220) => {
      BrickDamage.none: 'elementStone021.png',
      BrickDamage.some: 'elementStone026.png',
      BrickDamage.lots: 'elementStone053.png',
    },
    (BrickType.wood, BrickSize.size140x220) => {
      BrickDamage.none: 'elementWood020.png',
      BrickDamage.some: 'elementWood025.png',
      BrickDamage.lots: 'elementWood052.png',
    },
  };
}

class Brick extends BodyComponent {
  Brick({
    required this.type,
    required this.size,
    required BrickDamage damage,
    required Vector2 position,
    required Map<BrickDamage, Sprite> sprites,
  }) : _damage = damage,
       _sprites = sprites,
       super(
         renderBody: false,
         bodyDef: BodyDef()
           ..position = position
           ..type = BodyType.dynamic,
         fixtureDefs: [
           FixtureDef(
               PolygonShape()..setAsBoxXY(
                 size.size.width / 20 * brickScale,
                 size.size.height / 20 * brickScale,
               ),
             )
             ..restitution = 0.4
             ..density = type.density
             ..friction = type.friction,
         ],
       );

  late final SpriteComponent _spriteComponent;

  final BrickType type;
  final BrickSize size;
  final Map<BrickDamage, Sprite> _sprites;

  BrickDamage _damage;
  BrickDamage get damage => _damage;
  set damage(BrickDamage value) {
    _damage = value;
    _spriteComponent.sprite = _sprites[value];
  }

  @override
  Future<void> onLoad() {
    _spriteComponent = SpriteComponent(
      anchor: Anchor.center,
      scale: Vector2.all(1),
      sprite: _sprites[_damage],
      size: size.size.toVector2() / 10 * brickScale,
      position: Vector2(0, 0),
    );
    add(_spriteComponent);
    return super.onLoad();
  }
}

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

ได้เวลาวางอิฐบนหน้าจอ แก้ไขไฟล์ game.dart ดังนี้

lib/components/game.dart

import 'dart:async';
import 'dart:math';                                        // Add this import

import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flame_kenney_xml/flame_kenney_xml.dart';

import 'background.dart';
import 'brick.dart';                                       // Add this import
import 'ground.dart';

class MyPhysicsGame extends Forge2DGame {
  MyPhysicsGame()
    : super(
        gravity: Vector2(0, 10),
        camera: CameraComponent.withFixedResolution(width: 800, height: 600),
      );

  late final XmlSpriteSheet aliens;
  late final XmlSpriteSheet elements;
  late final XmlSpriteSheet tiles;

  @override
  FutureOr<void> onLoad() async {
    final backgroundImage = await images.load('colored_grass.png');
    final spriteSheets = await Future.wait([
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_aliens.png',
        xmlPath: 'spritesheet_aliens.xml',
      ),
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_elements.png',
        xmlPath: 'spritesheet_elements.xml',
      ),
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_tiles.png',
        xmlPath: 'spritesheet_tiles.xml',
      ),
    ]);

    aliens = spriteSheets[0];
    elements = spriteSheets[1];
    tiles = spriteSheets[2];

    await world.add(Background(sprite: Sprite(backgroundImage)));
    await addGround();
    unawaited(addBricks());                                // Add this line

    return super.onLoad();
  }

  Future<void> addGround() {
    return world.addAll([
      for (
        var x = camera.visibleWorldRect.left;
        x < camera.visibleWorldRect.right + groundSize;
        x += groundSize
      )
        Ground(
          Vector2(x, (camera.visibleWorldRect.height - groundSize) / 2),
          tiles.getSprite('grass.png'),
        ),
    ]);
  }

  final _random = Random();                                // Add from here...

  Future<void> addBricks() async {
    for (var i = 0; i < 5; i++) {
      final type = BrickType.randomType;
      final size = BrickSize.randomSize;
      await world.add(
        Brick(
          type: type,
          size: size,
          damage: BrickDamage.some,
          position: Vector2(
            camera.visibleWorldRect.right / 3 +
                (_random.nextDouble() * 5 - 2.5),
            0,
          ),
          sprites: brickFileNames(
            type,
            size,
          ).map((key, filename) => MapEntry(key, elements.getSprite(filename))),
        ),
      );
      await Future<void>.delayed(const Duration(milliseconds: 500));
    }
  }                                                        // To here.
}

การเพิ่มโค้ดนี้แตกต่างจากโค้ดที่คุณใช้เพิ่มคอมโพเนนต์ Ground เล็กน้อย ในครั้งนี้ ระบบจะเพิ่ม Brick ลงในคลัสเตอร์แบบสุ่มเมื่อเวลาผ่านไป การดำเนินการนี้แบ่งออกเป็น 2 ส่วน ส่วนแรกคือเมธอดที่เพิ่ม Bricks await เป็น Future.delayed ซึ่งเทียบเท่ากับการเรียก sleep() แบบแอซิงโครนัส อย่างไรก็ตาม ยังมีส่วนที่ 2 ในการทําให้วิธีนี้ทํางานได้ นั่นคือการเรียกใช้ addBricks ในเมธอด onLoad ไม่ได้ถูก await ไว้ หากเป็นเช่นนั้น เมธอด onLoad จะไม่ทํางานจนกว่าจะมีอิฐทั้งหมดบนหน้าจอ การรวมการเรียก addBricks ไว้ในการเรียก unawaited จะทำให้โปรแกรมตรวจสอบโค้ดทำงานได้อย่างราบรื่น และทำให้โปรแกรมเมอร์ในอนาคตเข้าใจเจตนาของเรา การไม่รอให้เมธอดนี้แสดงผลเป็นการดำเนินการโดยเจตนา

เมื่อเรียกใช้เกม คุณจะเห็นอิฐปรากฏขึ้น ชนกันและกระจัดกระจายบนพื้น

หน้าต่างแอปที่มีเนินเขาสีเขียวเป็นพื้นหลัง เลเยอร์พื้นดิน และบล็อกที่วางอยู่บนพื้น

6. เพิ่มโปรแกรมเล่น

ขว้างเอเลี่ยนใส่อิฐ

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

สร้างไฟล์ player.dart ใหม่ในไดเรกทอรี lib/components แล้วเพิ่มข้อมูลต่อไปนี้ลงในไฟล์

lib/components/player.dart

import 'dart:math';

import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flame/events.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flutter/material.dart';

const playerSize = 5.0;

enum PlayerColor {
  pink,
  blue,
  green,
  yellow;

  static PlayerColor get randomColor =>
      PlayerColor.values[Random().nextInt(PlayerColor.values.length)];

  String get fileName =>
      'alien${toString().split('.').last.capitalize}_round.png';
}

class Player extends BodyComponent with DragCallbacks {
  Player(Vector2 position, Sprite sprite)
    : _sprite = sprite,
      super(
        renderBody: false,
        bodyDef: BodyDef()
          ..position = position
          ..type = BodyType.static
          ..angularDamping = 0.1
          ..linearDamping = 0.1,
        fixtureDefs: [
          FixtureDef(CircleShape()..radius = playerSize / 2)
            ..restitution = 0.4
            ..density = 0.75
            ..friction = 0.5,
        ],
      );

  final Sprite _sprite;

  @override
  Future<void> onLoad() {
    addAll([
      CustomPainterComponent(
        painter: _DragPainter(this),
        anchor: Anchor.center,
        size: Vector2(playerSize, playerSize),
        position: Vector2(0, 0),
      ),
      SpriteComponent(
        anchor: Anchor.center,
        sprite: _sprite,
        size: Vector2(playerSize, playerSize),
        position: Vector2(0, 0),
      ),
    ]);
    return super.onLoad();
  }

  @override
  void update(double dt) {
    super.update(dt);

    if (!body.isAwake) {
      removeFromParent();
    }

    if (position.x > camera.visibleWorldRect.right + 10 ||
        position.x < camera.visibleWorldRect.left - 10) {
      removeFromParent();
    }
  }

  Vector2 _dragStart = Vector2.zero();
  Vector2 _dragDelta = Vector2.zero();
  Vector2 get dragDelta => _dragDelta;

  @override
  void onDragStart(DragStartEvent event) {
    super.onDragStart(event);
    if (body.bodyType == BodyType.static) {
      _dragStart = event.localPosition;
    }
  }

  @override
  void onDragUpdate(DragUpdateEvent event) {
    if (body.bodyType == BodyType.static) {
      _dragDelta = event.localEndPosition - _dragStart;
    }
  }

  @override
  void onDragEnd(DragEndEvent event) {
    super.onDragEnd(event);
    if (body.bodyType == BodyType.static) {
      children
          .whereType<CustomPainterComponent>()
          .firstOrNull
          ?.removeFromParent();
      body.setType(BodyType.dynamic);
      body.applyLinearImpulse(_dragDelta * -50);
      add(RemoveEffect(delay: 5.0));
    }
  }
}

extension on String {
  String get capitalize =>
      characters.first.toUpperCase() + characters.skip(1).toLowerCase().join();
}

class _DragPainter extends CustomPainter {
  _DragPainter(this.player);

  final Player player;

  @override
  void paint(Canvas canvas, Size size) {
    if (player.dragDelta != Vector2.zero()) {
      var center = size.center(Offset.zero);
      canvas.drawLine(
        center,
        center + (player.dragDelta * -1).toOffset(),
        Paint()
          ..color = Colors.orange.withAlpha(180)
          ..strokeWidth = 0.4
          ..strokeCap = StrokeCap.round,
      );
    }
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}

การดำเนินการนี้ถือเป็นขั้นที่สูงขึ้นจากคอมโพเนนต์ Brick ในขั้นตอนก่อนหน้า คอมโพเนนต์ Player นี้มีคอมโพเนนต์ย่อย 2 รายการ ได้แก่ SpriteComponent ที่คุณควรรู้จัก และ CustomPainterComponent ซึ่งเป็นคอมโพเนนต์ใหม่ แนวคิด CustomPainter มาจาก Flutter ซึ่งช่วยให้คุณวาดภาพบนผืนผ้าใบได้ ฟีเจอร์นี้ใช้เพื่อให้ความคิดเห็นแก่ผู้เล่นเกี่ยวกับตําแหน่งที่มนุษย์ต่างดาวกลมๆ จะบินไปเมื่อถูกเหวี่ยง

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

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

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

ผสานรวมคอมโพเนนต์ Player ลงในเกมโดยการแก้ไข game.dart ดังนี้

lib/components/game.dart

import 'dart:async';
import 'dart:math';

import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flame_kenney_xml/flame_kenney_xml.dart';

import 'background.dart';
import 'brick.dart';
import 'ground.dart';
import 'player.dart';                                      // Add this import

class MyPhysicsGame extends Forge2DGame {
  MyPhysicsGame()
    : super(
        gravity: Vector2(0, 10),
        camera: CameraComponent.withFixedResolution(width: 800, height: 600),
      );

  late final XmlSpriteSheet aliens;
  late final XmlSpriteSheet elements;
  late final XmlSpriteSheet tiles;

  @override
  FutureOr<void> onLoad() async {
    final backgroundImage = await images.load('colored_grass.png');
    final spriteSheets = await Future.wait([
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_aliens.png',
        xmlPath: 'spritesheet_aliens.xml',
      ),
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_elements.png',
        xmlPath: 'spritesheet_elements.xml',
      ),
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_tiles.png',
        xmlPath: 'spritesheet_tiles.xml',
      ),
    ]);

    aliens = spriteSheets[0];
    elements = spriteSheets[1];
    tiles = spriteSheets[2];

    await world.add(Background(sprite: Sprite(backgroundImage)));
    await addGround();
    unawaited(addBricks());
    await addPlayer();                                     // Add this line

    return super.onLoad();
  }

  Future<void> addGround() {
    return world.addAll([
      for (
        var x = camera.visibleWorldRect.left;
        x < camera.visibleWorldRect.right + groundSize;
        x += groundSize
      )
        Ground(
          Vector2(x, (camera.visibleWorldRect.height - groundSize) / 2),
          tiles.getSprite('grass.png'),
        ),
    ]);
  }

  final _random = Random();

  Future<void> addBricks() async {
    for (var i = 0; i < 5; i++) {
      final type = BrickType.randomType;
      final size = BrickSize.randomSize;
      await world.add(
        Brick(
          type: type,
          size: size,
          damage: BrickDamage.some,
          position: Vector2(
            camera.visibleWorldRect.right / 3 +
                (_random.nextDouble() * 5 - 2.5),
            0,
          ),
          sprites: brickFileNames(
            type,
            size,
          ).map((key, filename) => MapEntry(key, elements.getSprite(filename))),
        ),
      );
      await Future<void>.delayed(const Duration(milliseconds: 500));
    }
  }

  Future<void> addPlayer() async => world.add(             // Add from here...
    Player(
      Vector2(camera.visibleWorldRect.left * 2 / 3, 0),
      aliens.getSprite(PlayerColor.randomColor.fileName),
    ),
  );

  @override
  void update(double dt) {
    super.update(dt);
    if (isMounted && world.children.whereType<Player>().isEmpty) {
      addPlayer();
    }
  }                                                        // To here.
}

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

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

7. ตอบสนองต่อผลลัพธ์

เพิ่มศัตรู

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

สร้างไฟล์ enemy.dart ในไดเรกทอรี lib/components แล้วเพิ่มข้อมูลต่อไปนี้

lib/components/enemy.dart

import 'dart:math';

import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flutter/material.dart';

import 'body_component_with_user_data.dart';

const enemySize = 5.0;

enum EnemyColor {
  pink(color: 'pink', boss: false),
  blue(color: 'blue', boss: false),
  green(color: 'green', boss: false),
  yellow(color: 'yellow', boss: false),
  pinkBoss(color: 'pink', boss: true),
  blueBoss(color: 'blue', boss: true),
  greenBoss(color: 'green', boss: true),
  yellowBoss(color: 'yellow', boss: true);

  final bool boss;
  final String color;

  const EnemyColor({required this.color, required this.boss});

  static EnemyColor get randomColor =>
      EnemyColor.values[Random().nextInt(EnemyColor.values.length)];

  String get fileName =>
      'alien${color.capitalize}_${boss ? 'suit' : 'square'}.png';
}

class Enemy extends BodyComponentWithUserData with ContactCallbacks {
  Enemy(Vector2 position, Sprite sprite)
    : super(
        renderBody: false,
        bodyDef: BodyDef()
          ..position = position
          ..type = BodyType.dynamic,
        fixtureDefs: [
          FixtureDef(
            PolygonShape()..setAsBoxXY(enemySize / 2, enemySize / 2),
            friction: 0.3,
          ),
        ],
        children: [
          SpriteComponent(
            anchor: Anchor.center,
            sprite: sprite,
            size: Vector2.all(enemySize),
            position: Vector2(0, 0),
          ),
        ],
      );

  @override
  void beginContact(Object other, Contact contact) {
    var interceptVelocity =
        (contact.bodyA.linearVelocity - contact.bodyB.linearVelocity).length
            .abs();
    if (interceptVelocity > 35) {
      removeFromParent();
    }

    super.beginContact(other, contact);
  }

  @override
  void update(double dt) {
    super.update(dt);

    if (position.x > camera.visibleWorldRect.right + 10 ||
        position.x < camera.visibleWorldRect.left - 10) {
      removeFromParent();
    }
  }
}

extension on String {
  String get capitalize =>
      characters.first.toUpperCase() + characters.skip(1).toLowerCase().join();
}

คุณน่าจะคุ้นเคยกับไฟล์ส่วนใหญ่นี้จากการโต้ตอบกับคอมโพเนนต์ Player และ Brick ก่อนหน้านี้ อย่างไรก็ตาม จะมีขีดล่างสีแดง 2-3 เส้นในเครื่องมือแก้ไขเนื่องจากคลาสพื้นฐานใหม่ที่ไม่รู้จัก เพิ่มคลาสนี้เลยโดยเพิ่มไฟล์ชื่อ body_component_with_user_data.dart ไปยัง lib/components ที่มีเนื้อหาต่อไปนี้

lib/components/body_component_with_user_data.dart

import 'package:flame_forge2d/flame_forge2d.dart';

class BodyComponentWithUserData extends BodyComponent {
  BodyComponentWithUserData({
    super.key,
    super.bodyDef,
    super.children,
    super.fixtureDefs,
    super.paint,
    super.priority,
    super.renderBody,
  });

  @override
  Body createBody() {
    final body = world.createBody(super.bodyDef!)..userData = this;
    fixtureDefs?.forEach(body.createFixture);
    return body;
  }
}

คลาสพื้นฐานนี้รวมกับ beginContact callback ใหม่ในคอมโพเนนต์ Enemy จะเป็นพื้นฐานของการได้รับการแจ้งเตือนแบบเป็นโปรแกรมเกี่ยวกับผลกระทบระหว่างวัตถุ คุณต้องแก้ไขคอมโพเนนต์ที่ต้องการรับการแจ้งเตือนผลกระทบระหว่างกัน ดังนั้น ให้แก้ไขคอมโพเนนต์ Brick, Ground และ Player เพื่อใช้ BodyComponentWithUserData นี้แทนคลาสพื้นฐาน BodyComponent ที่คอมโพเนนต์เหล่านั้นใช้ ตัวอย่างเช่น วิธีแก้ไขคอมโพเนนต์ Ground มีดังนี้

lib/components/ground.dart

import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';

import 'body_component_with_user_data.dart';               // Add this import

const groundSize = 7.0;

class Ground extends BodyComponentWithUserData {           // Edit this line
  Ground(Vector2 position, Sprite sprite)
    : super(
        renderBody: false,
        bodyDef: BodyDef()
          ..position = position
          ..type = BodyType.static,
        fixtureDefs: [
          FixtureDef(
            PolygonShape()..setAsBoxXY(groundSize / 2, groundSize / 2),
            friction: 0.3,
          ),
        ],
        children: [
          SpriteComponent(
            anchor: Anchor.center,
            sprite: sprite,
            size: Vector2.all(groundSize),
            position: Vector2(0, 0),
          ),
        ],
      );
}

ดูข้อมูลเพิ่มเติมเกี่ยวกับวิธีที่ Forge2d จัดการการติดต่อได้ที่เอกสารประกอบของ Forge2D เกี่ยวกับการติดต่อกลับ

ชนะการแข่งขัน

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

lib/components/game.dart

import 'dart:async';
import 'dart:math';

import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flame_kenney_xml/flame_kenney_xml.dart';
import 'package:flutter/material.dart';                    // Add this import

import 'background.dart';
import 'brick.dart';
import 'enemy.dart';                                       // Add this import
import 'ground.dart';
import 'player.dart';

class MyPhysicsGame extends Forge2DGame {
  MyPhysicsGame()
    : super(
        gravity: Vector2(0, 10),
        camera: CameraComponent.withFixedResolution(width: 800, height: 600),
      );

  late final XmlSpriteSheet aliens;
  late final XmlSpriteSheet elements;
  late final XmlSpriteSheet tiles;

  @override
  FutureOr<void> onLoad() async {
    final backgroundImage = await images.load('colored_grass.png');
    final spriteSheets = await Future.wait([
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_aliens.png',
        xmlPath: 'spritesheet_aliens.xml',
      ),
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_elements.png',
        xmlPath: 'spritesheet_elements.xml',
      ),
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_tiles.png',
        xmlPath: 'spritesheet_tiles.xml',
      ),
    ]);

    aliens = spriteSheets[0];
    elements = spriteSheets[1];
    tiles = spriteSheets[2];

    await world.add(Background(sprite: Sprite(backgroundImage)));
    await addGround();
    unawaited(addBricks().then((_) => addEnemies()));      // Modify this line
    await addPlayer();

    return super.onLoad();
  }

  Future<void> addGround() {
    return world.addAll([
      for (
        var x = camera.visibleWorldRect.left;
        x < camera.visibleWorldRect.right + groundSize;
        x += groundSize
      )
        Ground(
          Vector2(x, (camera.visibleWorldRect.height - groundSize) / 2),
          tiles.getSprite('grass.png'),
        ),
    ]);
  }

  final _random = Random();

  Future<void> addBricks() async {
    for (var i = 0; i < 5; i++) {
      final type = BrickType.randomType;
      final size = BrickSize.randomSize;
      await world.add(
        Brick(
          type: type,
          size: size,
          damage: BrickDamage.some,
          position: Vector2(
            camera.visibleWorldRect.right / 3 +
                (_random.nextDouble() * 5 - 2.5),
            0,
          ),
          sprites: brickFileNames(
            type,
            size,
          ).map((key, filename) => MapEntry(key, elements.getSprite(filename))),
        ),
      );
      await Future<void>.delayed(const Duration(milliseconds: 500));
    }
  }

  Future<void> addPlayer() async => world.add(
    Player(
      Vector2(camera.visibleWorldRect.left * 2 / 3, 0),
      aliens.getSprite(PlayerColor.randomColor.fileName),
    ),
  );

  @override
  void update(double dt) {
    super.update(dt);
    if (isMounted &&                                       // Modify from here...
        world.children.whereType<Player>().isEmpty &&
        world.children.whereType<Enemy>().isNotEmpty) {
      addPlayer();
    }
    if (isMounted &&
        enemiesFullyAdded &&
        world.children.whereType<Enemy>().isEmpty &&
        world.children.whereType<TextComponent>().isEmpty) {
      world.addAll(
        [
          (position: Vector2(0.5, 0.5), color: Colors.white),
          (position: Vector2.zero(), color: Colors.orangeAccent),
        ].map(
          (e) => TextComponent(
            text: 'You win!',
            anchor: Anchor.center,
            position: e.position,
            textRenderer: TextPaint(
              style: TextStyle(color: e.color, fontSize: 16),
            ),
          ),
        ),
      );
    }
  }

  var enemiesFullyAdded = false;

  Future<void> addEnemies() async {
    await Future<void>.delayed(const Duration(seconds: 2));
    for (var i = 0; i < 3; i++) {
      await world.add(
        Enemy(
          Vector2(
            camera.visibleWorldRect.right / 3 +
                (_random.nextDouble() * 7 - 3.5),
            (_random.nextDouble() * 3),
          ),
          aliens.getSprite(EnemyColor.randomColor.fileName),
        ),
      );
      await Future<void>.delayed(const Duration(seconds: 1));
    }
    enemiesFullyAdded = true;                              // To here.
  }
}

ภารกิจของคุณ (หากเลือกรับ) คือเปิดเกมและไปยังหน้าจอนี้

หน้าต่างแอปที่มีเนินเขาสีเขียวเป็นพื้นหลัง ชั้นพื้นดิน บล็อกบนพื้นดิน และข้อความซ้อนทับ &quot;คุณชนะ&quot;

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

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

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

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

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

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