1. ก่อนเริ่มต้น
Flame เป็นเครื่องมือเกม 2D ที่ใช้ Flutter ใน Codelab นี้ คุณจะได้สร้างเกมที่ใช้การจำลองทางฟิสิกส์แบบ 2 มิติตามแนวบรรทัดของ Box2D ชื่อว่า Forge2D ใช้คอมโพเนนต์ของ Flame เพื่อวาดภาพความเป็นจริงเสมือนบนหน้าจอให้ผู้ใช้ได้ลองเล่น เมื่อเสร็จแล้ว เกมควรมีลักษณะคล้าย GIF แบบเคลื่อนไหวนี้
ข้อกำหนดเบื้องต้น
- การศึกษา Codelab เกี่ยวกับข้อมูลเบื้องต้นเกี่ยวกับ Flame with Flutter
สิ่งที่ได้เรียนรู้
- วิธีการทำงานของพื้นฐานของ Forge2D เริ่มจากร่างกายประเภทต่างๆ
- วิธีตั้งค่าการจำลองสถานการณ์จริงแบบ 2 มิติ
สิ่งที่ต้องมี
- Flutter SDK
- โค้ด Visual Studio (โค้ด VS) ที่มีปลั๊กอิน Flutter และ Dart
ซอฟต์แวร์คอมไพเลอร์สำหรับเป้าหมายการพัฒนาที่คุณเลือก Codelab นี้ใช้งานได้กับทั้ง 6 แพลตฟอร์มที่ Flutter รองรับ คุณต้องมี Visual Studio เพื่อกําหนดเป้าหมาย Windows, Xcode เพื่อกําหนดเป้าหมายเป็น macOS หรือ iOS และใช้ Android Studio เพื่อกําหนดเป้าหมายเป็น Android
2. สร้างโปรเจ็กต์
สร้างโปรเจ็กต์ Flutter
การสร้างโปรเจ็กต์ Flutter มีอยู่หลายวิธี ในส่วนนี้ คุณใช้บรรทัดคำสั่งเพื่อความกระชับ
ในการเริ่มต้น โปรดปฏิบัติตามขั้นตอนต่อไปนี้:
- สร้างโปรเจ็กต์ 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.
- แก้ไขทรัพยากร 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 เพื่อลบแถบชื่อเพื่อไม่ให้แถบชื่อของแอปรบกวนประสบการณ์โดยรวม
โดยทำตามขั้นตอนต่อไปนี้
- สร้างไฟล์
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
เนื่องจากไม่ได้เป็นส่วนหนึ่งของโค้ดเบสรันไทม์สำหรับเกม เป็นเครื่องมือบรรทัดคำสั่งที่ใช้สำหรับแก้ไขโปรเจ็กต์
- จากไดเรกทอรีฐานของโปรเจ็กต์ ให้เรียกใช้เครื่องมือดังนี้
$ 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
ที่คุณดาวน์โหลด ซึ่งควรมีลักษณะเช่นนี้
จากไดเรกทอรี PNG/Backgrounds
ให้คัดลอกไฟล์ colored_desert.png
, colored_grass.png
, colored_land.png
และ colored_shroom.png
ไปยังไดเรกทอรี assets/images
ของโปรเจ็กต์
นอกจากนี้ยังมีภาพต่อเรียง รูปภาพเหล่านี้เป็นภาพ PNG และไฟล์ XML ที่อธิบายตําแหน่งในรูปสไปรท์ชีตซึ่งจะพบรูปภาพที่มีขนาดเล็กกว่า ภาพต่อเรียงเป็นเทคนิคในการลดเวลาที่ใช้ในการโหลดด้วยการโหลดเพียงไฟล์เดียว แทนที่จะโหลดไฟล์ภาพเดี่ยวๆ ไฟล์จำนวน 10 ไฟล์หรือหลายร้อยไฟล์
คัดลอกจาก spritesheet_aliens.png
, spritesheet_elements.png
และ spritesheet_tiles.png
ไปยังไดเรกทอรี assets/images
ของโปรเจ็กต์ ในระหว่างที่คุณอยู่ที่นี่ ให้คัดลอกไฟล์ spritesheet_aliens.xml
, spritesheet_elements.xml
และ spritesheet_tiles.xml
ไปยังไดเรกทอรี assets
ของโปรเจ็กต์ด้วย โปรเจ็กต์ควรมีลักษณะดังนี้
ทาสีพื้นหลัง
เมื่อโปรเจ็กต์ของคุณเพิ่มชิ้นงานรูปภาพแล้ว ก็ถึงเวลาวางชิ้นงานรูปภาพบนหน้าจอ เอ่อ รูปบนหน้าจอ 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.
}
}
ความท้าทายของคุณหากคุณเลือกที่จะยอมรับ คือการเล่นเกมและเข้าสู่หน้าจอนี้
8. ขอแสดงความยินดี
ยินดีด้วย คุณสร้างเกมด้วย Flutter และ Flame ได้สำเร็จ!
คุณได้สร้างเกมโดยใช้เครื่องมือเกม Flame 2D และฝังเกมไว้ใน Wrapper ของ Flutter คุณใช้เอฟเฟกต์ Flame เพื่อสร้างภาพเคลื่อนไหวและนำคอมโพเนนต์ออก คุณใช้แพ็กเกจ Google Fonts และ Flutter Animate เพื่อทำให้ทั้งเกมดูออกแบบมาอย่างดี
ขั้นตอนถัดไปคือ
ลองดู Codelab เหล่านี้...
- การสร้าง UI รุ่นใหม่ใน Flutter
- เปลี่ยนแอป Flutter แต่ไม่น่าเบื่อให้กลายเป็นความสวยงาม
- การเพิ่มการซื้อในแอปไปยังแอป Flutter