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

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

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

แอนิเมชันการเล่นเกมเกี่ยวกับเกมฟิสิกส์ 2 มิตินี้

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

สิ่งที่ได้เรียนรู้

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

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

ซอฟต์แวร์คอมไพเลอร์สำหรับเป้าหมายการพัฒนาที่คุณเลือก Codelab นี้ใช้งานได้กับทั้ง 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.
  1. แก้ไขทรัพยากร Dependency ของโปรเจ็กต์เพื่อเพิ่ม Flame และ Forge2D โดยทำดังนี้
$ cd forge2d_game
$ flutter pub add characters flame flame_forge2d flame_kenney_xml xml
Resolving dependencies... 
Downloading packages... 
  characters 1.3.0 (from transitive dependency to direct dependency)
  collection 1.18.0 (1.19.0 available)
+ flame 1.18.0
+ flame_forge2d 0.18.1
+ flame_kenney_xml 0.1.0
  flutter_lints 3.0.2 (4.0.0 available)
+ forge2d 0.13.0
  leak_tracker 10.0.4 (10.0.5 available)
  leak_tracker_flutter_testing 3.0.3 (3.0.5 available)
  lints 3.0.0 (4.0.0 available)
  material_color_utilities 0.8.0 (0.12.0 available)
  meta 1.12.0 (1.15.0 available)
+ ordered_set 5.0.3 (6.0.1 available)
+ petitparser 6.0.2
  test_api 0.7.0 (0.7.3 available)
  vm_service 14.2.1 (14.2.4 available)
+ xml 6.5.0
Changed 8 dependencies!
10 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 ใน Codelab นี้ที่ใช้สถานะของอินสแตนซ์เกมเพื่อแสดงข้อมูลเกี่ยวกับเกมที่กำลังวิ่งอยู่ ดังนั้น Bootstrapped ที่เรียบง่ายนี้จึงทำงานได้ดี

ไม่บังคับ: ทำภารกิจด้านข้างสำหรับ 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. เพิ่มชิ้นงานรูปภาพ

เพิ่มรูปภาพ

ทุกเกมต้องใช้เนื้อหาด้านศิลปะเพื่อให้ลงสีบนหน้าจอในลักษณะที่ใช้การค้นหาความสนุกสนานได้ Codelab นี้จะใช้แพ็ก Physics Assets จาก 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.3.3 <4.0.0'

dependencies:
  characters: ^1.3.0
  flame: ^1.17.0
  flame_forge2d: ^0.18.0
  flutter:
    sdk: flutter
  xml: ^6.5.0

dev_dependencies:
  flutter_lints: ^3.0.0
  flutter_test:
    sdk: flutter

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 Pack ที่ขยายออกมา โดยไฮไลต์ไดเรกทอรี PNG/Backgrounds

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

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

รายชื่อไฟล์ของแพ็ก 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 ซึ่งมีการไฮไลต์ไดเรกทอรี Asset

ทาสีพื้นหลัง

เมื่อโปรเจ็กต์ของคุณเพิ่มชิ้นงานรูปภาพแล้ว ก็ถึงเวลาวางชิ้นงานรูปภาพบนหน้าจอ เอ่อ รูปบนหน้าจอ 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 แบบพิเศษ ซึ่งมีหน้าที่แสดงภาพพื้นหลังภาพใดภาพหนึ่งใน 4 ของ Kenney.nl ในโค้ดนี้มีสมมติฐานที่ง่ายขึ้นอยู่ 2-3 อย่าง อย่างแรกคือรูปภาพเป็นสี่เหลี่ยมจัตุรัส โดยมีภาพพื้นหลังทั้ง 4 ภาพของเคนนีย์ ประการที่ 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 ซึ่งต่างจาก Codelab ก่อนหน้านี้ 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> และให้เป็นผลข้างเคียงแคชรูปภาพที่โหลดในออบเจ็กต์เกม อนาคตเหล่านี้มารวมกันและรอเป็นหน่วยเดียวโดยใช้เมธอด 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 นั้นสำคัญ เพราะเป็นวัตถุที่เป็นส่วนหนึ่งของการจำลองทางกายภาพสองมิติ มีการระบุ BodyDef สำหรับคอมโพเนนต์นี้ให้มี BodyType.static

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

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

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

อิฐจะทำจากภาพต่อเรียงขององค์ประกอบ หากดูที่คำอธิบายภาพต่อเรียงใน 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',
      },
  };
}

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

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

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

6. เพิ่มผู้เล่น

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

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

สร้างไฟล์ 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.withOpacity(0.7)
            ..strokeWidth = 0.4
            ..strokeCap = StrokeCap.round);
    }
  }

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

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

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

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

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

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

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

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