1. לפני שמתחילים
Flame הוא מנוע משחקים דו-ממדי שמבוסס על Flutter. בקודלאב הזה תלמדו ליצור משחק שמשתמש בסימולציית פיזיקה דו-ממדית בסגנון Box2D שנקרא Forge2D. אתם משתמשים ברכיבים של Flame כדי לצייר את המציאות הפיזית המדומה על המסך, כדי שהמשתמשים יוכלו לשחק בה. בסיום, המשחק אמור להיראות כמו קובץ ה-GIF האנימציה הזה:
דרישות מוקדמות
- השלמת הקודלה מבוא ל-Flame עם Flutter
מה תלמדו
- הסבר על העקרונות הבסיסיים של Forge2D, החל מהסוגים השונים של גופים פיזיים.
- איך מגדירים סימולציה פיזית ב-2D.
מה צריך
- Flutter SDK
- Visual Studio Code (VS Code) עם התוספים של Flutter ו-Dart
תוכנת מהדר ליעד הפיתוח שבחרתם. הקודלהב הזה מתאים לכל שש הפלטפורמות שנתמכות ב-Flutter. כדי לטרגט את Windows צריך להשתמש ב-Visual Studio, כדי לטרגט את macOS או iOS צריך להשתמש ב-Xcode, וכדי לטרגט את Android צריך להשתמש ב-Android Studio.
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.
- משנים את יחסי התלות של הפרויקט כדי להוסיף את Flame ו-Forge2D:
$ cd forge2d_game $ flutter pub add characters flame flame_forge2d flame_kenney_xml xml Resolving dependencies... Downloading packages... characters 1.4.0 (from transitive dependency to direct dependency) + flame 1.29.0 + flame_forge2d 0.19.0+2 + flame_kenney_xml 0.1.1+12 flutter_lints 5.0.0 (6.0.0 available) + forge2d 0.14.0 leak_tracker 10.0.9 (11.0.1 available) leak_tracker_flutter_testing 3.0.9 (3.0.10 available) leak_tracker_testing 3.0.1 (3.0.2 available) lints 5.1.1 (6.0.0 available) material_color_utilities 0.11.1 (0.13.0 available) meta 1.16.0 (1.17.0 available) + ordered_set 8.0.0 + petitparser 6.1.0 (7.0.0 available) test_api 0.7.4 (0.7.6 available) vector_math 2.1.4 (2.2.0 available) vm_service 15.0.0 (15.0.2 available) + xml 6.5.0 (6.6.0 available) Changed 8 dependencies! 12 packages have newer versions incompatible with dependency constraints. Try `flutter pub outdated` for more information.
החבילה flame
מוכרת לכם, אבל יכול להיות שתצטרכו הסבר לגבי שלוש החבילות האחרות. החבילה characters
משמשת לטיפול בנתיב באופן תואם ל-UTF8. החבילה flame_forge2d
חושפת את הפונקציונליות של Forge2D באופן שמתאים ל-Flame. לבסוף, החבילה xml
משמשת במקומות שונים כדי לצרוך ולשנות תוכן XML.
פותחים את הפרויקט ומחליפים את התוכן של הקובץ lib/main.dart
בקוד הבא:
lib/main.dart
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
void main() {
runApp(GameWidget.controlled(gameFactory: FlameGame.new));
}
הפקודה הזו מפעילה את האפליקציה עם GameWidget
שיוצר את המופע של FlameGame
. בקוד ה-Flutter שבקודלאב הזה אין שימוש במצב של מופע המשחק כדי להציג מידע על המשחק שפועל, ולכן ה-bootstrap המפושט הזה מתאים.
אופציונלי: משימה משנית ל-macOS בלבד
צילומי המסך בפרויקט הזה הם מהמשחק כאפליקציית מחשב ל-macOS. כדי שהסרגל הכותרת של האפליקציה לא יפגע בחוויה הכללית, אפשר לשנות את הגדרת הפרויקט של ה-Runner ל-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. הוספת נכסי תמונות
הוספת התמונות
כל משחק צריך נכסי גרפיקה כדי שאפשר יהיה לצבוע מסך בצורה מהנה. בקודלאב הזה נשתמש בחבילת Physics Assets מ-Kenney.nl. הנכסים האלה ברישיון Creative Commons CC0, אבל עדיין מומלץ מאוד לתרום לצוות של Kenney כדי שימשיכו לעשות את העבודה המדהימה שהם עושים. כן.
כדי להשתמש בנכסים של Kenney, תצטרכו לשנות את קובץ התצורה pubspec.yaml
. משנים אותו באופן הבא:
pubspec.yaml
name: forge2d_game
description: "A new Flutter project."
publish_to: 'none'
version: 0.1.0
environment:
sdk: ^3.8.1
dependencies:
flutter:
sdk: flutter
characters: ^1.4.0
flame: ^1.29.0
flame_forge2d: ^0.19.0+2
flame_kenney_xml: ^0.1.1+12
xml: ^6.5.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^5.0.0
flutter:
uses-material-design: true
assets: # Add from here
- assets/
- assets/images/ # To here.
מערכת Flame מצפה שנכסי התמונות יהיו ב-assets/images
, אבל אפשר להגדיר זאת אחרת. פרטים נוספים זמינים במסמכי התיעוד של Flame בנושא תמונות. עכשיו, אחרי שהגדרתם את הנתיבים, עליכם להוסיף אותם לפרויקט עצמו. אחת מהדרכים לעשות זאת היא להשתמש בשורת הפקודה באופן הבא:
mkdir -p assets/images
לא אמור להיות פלט מהפקודה mkdir
, אבל הספרייה החדשה אמורה להיות גלויה בכלי העריכה או בסייר הקבצים.
מרחיבים את קובץ ה-kenney_physics-assets.zip
שהורדתם, והוא אמור להיראות כך:
מעתיקים את הקבצים colored_desert.png
, colored_grass.png
, colored_land.png
ו-colored_shroom.png
מהספרייה PNG/Backgrounds
לספרייה assets/images
של הפרויקט.
יש גם גיליונות של ספריות פריימים. אלה שילוב של קובץ PNG וקובץ XML שמתאר איפה בתמונה של גיליון ה-Sprite אפשר למצוא תמונות קטנות יותר. גיליונות פריימים הם טכניקה שמאפשרת לקצר את זמן הטעינה על ידי טעינת קובץ אחד בלבד, במקום עשרות, אם לא מאות, קובצי תמונות נפרדים.
מעתיקים את spritesheet_aliens.png
, את spritesheet_elements.png
ואת spritesheet_tiles.png
לספרייה assets/images
של הפרויקט. באותו מקום, מעתיקים גם את הקבצים spritesheet_aliens.xml
, spritesheet_elements.xml
ו-spritesheet_tiles.xml
לתיקייה assets
של הפרויקט. הפרויקט אמור להיראות כך.
ציור הרקע
עכשיו, אחרי שהוספתם נכסי תמונות לפרויקט, הגיע הזמן להציג אותם במסך. טוב, תמונה אחת במסך. בשלבים הבאים נסביר על עוד תכונות.
יוצרים קובץ בשם 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
מיוחד. הוא אחראי להצגת אחת מארבע תמונות הרקע של Kenney.nl. יש כמה הנחות פשטניות בקוד הזה. הקריטריון הראשון הוא שהתמונות הן בפורמט ריבועי, וזה המצב של כל ארבע תמונות הרקע של Kenney. השני הוא שגודל העולם הגלוי לעולם לא ישתנה, אחרת הרכיב הזה יצטרך לטפל באירועים של שינוי גודל המשחק. ההנחה השלישית היא שהמיקום (0,0)
יהיה במרכז המסך. ההנחות האלה מחייבות הגדרה ספציפית של CameraComponent
של המשחק.
יוצרים קובץ חדש נוסף, בשם game.dart
, שוב בספרייה lib/components
.
lib/components/game.dart
import 'dart:async';
import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flame_kenney_xml/flame_kenney_xml.dart';
import 'background.dart';
class MyPhysicsGame extends Forge2DGame {
MyPhysicsGame()
: super(
gravity: Vector2(0, 10),
camera: CameraComponent.withFixedResolution(width: 800, height: 600),
);
late final XmlSpriteSheet aliens;
late final XmlSpriteSheet elements;
late final XmlSpriteSheet tiles;
@override
FutureOr<void> onLoad() async {
final backgroundImage = await images.load('colored_grass.png');
final spriteSheets = await Future.wait([
XmlSpriteSheet.load(
imagePath: 'spritesheet_aliens.png',
xmlPath: 'spritesheet_aliens.xml',
),
XmlSpriteSheet.load(
imagePath: 'spritesheet_elements.png',
xmlPath: 'spritesheet_elements.xml',
),
XmlSpriteSheet.load(
imagePath: 'spritesheet_tiles.png',
xmlPath: 'spritesheet_tiles.xml',
),
]);
aliens = spriteSheets[0];
elements = spriteSheets[1];
tiles = spriteSheets[2];
await world.add(Background(sprite: Sprite(backgroundImage)));
return super.onLoad();
}
}
יש כאן הרבה דברים. מתחילים בכיתה MyPhysicsGame
. בניגוד לסדנת הקוד הקודמת, כאן מרחיב את Forge2DGame
ולא את FlameGame
. Forge2DGame
עצמו מרחיב את FlameGame
עם כמה שינויים מעניינים. הראשונה היא שכברירת מחדל, הערך של zoom
מוגדר כ-10. ההגדרה zoom
קשורה לטווח הערכים השימושיים שמנועי סימולציית הפיזיקה בסגנון Box2D
פועלים איתם בצורה טובה. המנוע נכתב באמצעות מערכת MKS, שבה היחידות הן מטרים, קילוגרמים ושניות. המרחק שבו לא רואים שגיאות מתמטיות משמעותיות באובייקטים הוא מ-0.1 מטרים ועד עשרות מטרים. אם תספקו את המימדים בפיקסלים ישירות, בלי שינוי מסוים של התמונה, Forge2D לא יוכל להשתמש בהם. סיכום שימושי: כדאי לחשוב על סימולציה של אובייקטים בטווח של פחית שתייה עד אוטובוס.
ההנחות שהוצגו ברכיב הרקע מתקיימות כאן על ידי קביעת הרזולוציה של CameraComponent
ל-800 על 600 פיקסלים וירטואליים. כלומר, אזור המשחק יהיה ברוחב 80 יחידות ובגובה 60 יחידות, עם מרכז ב-(0,0)
. לכך אין השפעה על הרזולוציה שמוצגת, אבל זה ישפיע על המיקום שבו נציב את האובייקטים בסצנה של המשחק.
לצד הארגומנט של ה-constructor camera
יש ארגומנט נוסף, שמותאם יותר לפיזיקה, שנקרא gravity
. כוח הכבידה מוגדר כ-Vector2
עם x
של 0
ו-y
של 10
. הערך של 10
הוא משוער קרוב לערך המקובץ של כוח הכבידה, שהוא 9.81 מטר לשנייה בריבוע. העובדה שכבידה מוגדרת כ-10 חיובי מראה שבמערכת הזו הכיוון של ציר Y הוא למטה. זה שונה מ-Box2D באופן כללי, אבל תואם לאופן שבו Flame מוגדר בדרך כלל.
השלב הבא הוא השיטה onLoad
. השיטה הזו היא אסינכרונית, וזה מתאים כי היא אחראית לטעינה של נכסי תמונות מהדיסק. הקריאות ל-images.load
מחזירות Future<Image>
, וכתוצאה מכך התמונה הטעונה מאוחסנת במטמון באובייקט Game. ה-futures האלה נאספים יחד וממתינים להם כיחידה אחת באמצעות השיטה הסטטית Futures.wait
. לאחר מכן, רשימת התמונות שחוזרות מותאמת לדפוס של שמות ספציפיים.
לאחר מכן, התמונות של גיליון ה-Sprite מועברות לסדרה של אובייקטים מסוג XmlSpriteSheet
שאחראים לאחזור ה-Sprites עם השמות הנפרדים שמכיל גיליון ה-Sprite. הכיתה 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()
יוסיף פורמט letterbox לפי הצורך כדי שהמשחק יפעל ביחס גובה-רוחב של 800 על 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, לגוף יש שלושה סוגים שונים. גופים סטטיים לא זזים. למעשה, יש להם גם מסה אפס – הם לא מגיבים לכוח המשיכה – וגם מסה אינסופית – הם לא זזים כשהם נתקלים באובייקטים אחרים, לא משנה כמה הם כבדים. לכן גופים סטטיים מתאימים במיוחד ליצירת משטח קרקע, כי הם לא זזים.
שני הסוגים האחרים של גופים הם גופים תנועתיים וגופים דינמיים. גופים דינמיים הם גופים שמתבצעת להם סימולציה מלאה, והם מגיבים לכוח הכבידה ולעצמים שהם נתקלים בהם. בהמשך הסדנה נראה גוף דינמי נוסף. גופים קינטיים הם שלב ביניים בין סטטי לדינמי. הם זזים, אבל הם לא מגיבים לכוח הכבידה או לאובייקטים אחרים שמכים בהם. שימושי, אבל לא נכלל בקודלאב הזה.
הגוף עצמו לא עושה הרבה. כדי שיהיה גוף, צריך צורות משויכות. במקרה הזה, לגוף הזה יש צורה אחת משויכת, 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 הם אבן היסוד של חוויית המשתמש, הם הדברים שזזים ומקיימים אינטראקציה עם העולם שסביבם. בשלב הזה תוסיפו לבנים, שייבחרו באופן אקראי ויוצגו במסך באשכול של לבנים. הם יפלו ויידחפו זה לזה בזמן הנסיעה.
הלבנים ייווצרו מגיליון ה-sprite של האלמנטים. אם תעיינו בתיאור של גיליון ה-sprite ב-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();
}
בעורך אמורה להופיע אזהרה או שגיאה לגבי יחסי תלות חסרים. מוסיפים אותו באמצעות הפקודה הבאה:
flutter pub add equatable
עכשיו אמורה להיות לך אפשרות להריץ את התוכנית באופן הבא:
$ dart run bin/generate_brick_file_names.dart Map<BrickDamage, String> brickFileNames(BrickType type, BrickSize size) { return switch ((type, size)) { (BrickType.explosive, BrickSize.size140x70) => { BrickDamage.none: 'elementExplosive009.png', BrickDamage.some: 'elementExplosive012.png', BrickDamage.lots: 'elementExplosive050.png', }, (BrickType.glass, BrickSize.size140x70) => { BrickDamage.none: 'elementGlass010.png', BrickDamage.some: 'elementGlass013.png', BrickDamage.lots: 'elementGlass048.png', }, [Content elided...] (BrickType.wood, BrickSize.size140x220) => { BrickDamage.none: 'elementWood020.png', BrickDamage.some: 'elementWood025.png', BrickDamage.lots: 'elementWood052.png', }, }; }
הכלי הזה עזר לנו לנתח את קובץ התיאור של גיליון ה-sprite ולהמיר אותו לקוד Dart, שבעזרתו אנחנו יכולים לבחור את קובץ התמונה הנכון לכל לבנה שרוצים להציג במסך. מועילה!
יוצרים את הקובץ brick.dart
עם התוכן הבא:
lib/components/brick.dart
import 'dart:math';
import 'dart:ui' as ui;
import 'package:flame/components.dart';
import 'package:flame/extensions.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
const brickScale = 0.5;
enum BrickType {
explosive(density: 1, friction: 0.5),
glass(density: 0.5, friction: 0.2),
metal(density: 1, friction: 0.4),
stone(density: 2, friction: 1),
wood(density: 0.25, friction: 0.6);
final double density;
final double friction;
const BrickType({required this.density, required this.friction});
static BrickType get randomType => values[Random().nextInt(values.length)];
}
enum BrickSize {
size70x70(ui.Size(70, 70)),
size140x70(ui.Size(140, 70)),
size220x70(ui.Size(220, 70)),
size70x140(ui.Size(70, 140)),
size140x140(ui.Size(140, 140)),
size220x140(ui.Size(220, 140)),
size140x220(ui.Size(140, 220)),
size70x220(ui.Size(70, 220));
final ui.Size size;
const BrickSize(this.size);
static BrickSize get randomSize => values[Random().nextInt(values.length)];
}
enum BrickDamage { none, some, lots }
Map<BrickDamage, String> brickFileNames(BrickType type, BrickSize size) {
return switch ((type, size)) {
(BrickType.explosive, BrickSize.size140x70) => {
BrickDamage.none: 'elementExplosive009.png',
BrickDamage.some: 'elementExplosive012.png',
BrickDamage.lots: 'elementExplosive050.png',
},
(BrickType.glass, BrickSize.size140x70) => {
BrickDamage.none: 'elementGlass010.png',
BrickDamage.some: 'elementGlass013.png',
BrickDamage.lots: 'elementGlass048.png',
},
(BrickType.metal, BrickSize.size140x70) => {
BrickDamage.none: 'elementMetal009.png',
BrickDamage.some: 'elementMetal012.png',
BrickDamage.lots: 'elementMetal050.png',
},
(BrickType.stone, BrickSize.size140x70) => {
BrickDamage.none: 'elementStone009.png',
BrickDamage.some: 'elementStone012.png',
BrickDamage.lots: 'elementStone047.png',
},
(BrickType.wood, BrickSize.size140x70) => {
BrickDamage.none: 'elementWood011.png',
BrickDamage.some: 'elementWood014.png',
BrickDamage.lots: 'elementWood054.png',
},
(BrickType.explosive, BrickSize.size70x70) => {
BrickDamage.none: 'elementExplosive011.png',
BrickDamage.some: 'elementExplosive014.png',
BrickDamage.lots: 'elementExplosive049.png',
},
(BrickType.glass, BrickSize.size70x70) => {
BrickDamage.none: 'elementGlass011.png',
BrickDamage.some: 'elementGlass012.png',
BrickDamage.lots: 'elementGlass046.png',
},
(BrickType.metal, BrickSize.size70x70) => {
BrickDamage.none: 'elementMetal011.png',
BrickDamage.some: 'elementMetal014.png',
BrickDamage.lots: 'elementMetal049.png',
},
(BrickType.stone, BrickSize.size70x70) => {
BrickDamage.none: 'elementStone011.png',
BrickDamage.some: 'elementStone014.png',
BrickDamage.lots: 'elementStone046.png',
},
(BrickType.wood, BrickSize.size70x70) => {
BrickDamage.none: 'elementWood010.png',
BrickDamage.some: 'elementWood013.png',
BrickDamage.lots: 'elementWood045.png',
},
(BrickType.explosive, BrickSize.size220x70) => {
BrickDamage.none: 'elementExplosive013.png',
BrickDamage.some: 'elementExplosive016.png',
BrickDamage.lots: 'elementExplosive051.png',
},
(BrickType.glass, BrickSize.size220x70) => {
BrickDamage.none: 'elementGlass014.png',
BrickDamage.some: 'elementGlass017.png',
BrickDamage.lots: 'elementGlass049.png',
},
(BrickType.metal, BrickSize.size220x70) => {
BrickDamage.none: 'elementMetal013.png',
BrickDamage.some: 'elementMetal016.png',
BrickDamage.lots: 'elementMetal051.png',
},
(BrickType.stone, BrickSize.size220x70) => {
BrickDamage.none: 'elementStone013.png',
BrickDamage.some: 'elementStone016.png',
BrickDamage.lots: 'elementStone048.png',
},
(BrickType.wood, BrickSize.size220x70) => {
BrickDamage.none: 'elementWood012.png',
BrickDamage.some: 'elementWood015.png',
BrickDamage.lots: 'elementWood047.png',
},
(BrickType.explosive, BrickSize.size70x140) => {
BrickDamage.none: 'elementExplosive017.png',
BrickDamage.some: 'elementExplosive022.png',
BrickDamage.lots: 'elementExplosive052.png',
},
(BrickType.glass, BrickSize.size70x140) => {
BrickDamage.none: 'elementGlass018.png',
BrickDamage.some: 'elementGlass023.png',
BrickDamage.lots: 'elementGlass050.png',
},
(BrickType.metal, BrickSize.size70x140) => {
BrickDamage.none: 'elementMetal017.png',
BrickDamage.some: 'elementMetal022.png',
BrickDamage.lots: 'elementMetal052.png',
},
(BrickType.stone, BrickSize.size70x140) => {
BrickDamage.none: 'elementStone017.png',
BrickDamage.some: 'elementStone022.png',
BrickDamage.lots: 'elementStone049.png',
},
(BrickType.wood, BrickSize.size70x140) => {
BrickDamage.none: 'elementWood016.png',
BrickDamage.some: 'elementWood021.png',
BrickDamage.lots: 'elementWood048.png',
},
(BrickType.explosive, BrickSize.size140x140) => {
BrickDamage.none: 'elementExplosive018.png',
BrickDamage.some: 'elementExplosive023.png',
BrickDamage.lots: 'elementExplosive053.png',
},
(BrickType.glass, BrickSize.size140x140) => {
BrickDamage.none: 'elementGlass019.png',
BrickDamage.some: 'elementGlass024.png',
BrickDamage.lots: 'elementGlass051.png',
},
(BrickType.metal, BrickSize.size140x140) => {
BrickDamage.none: 'elementMetal018.png',
BrickDamage.some: 'elementMetal023.png',
BrickDamage.lots: 'elementMetal053.png',
},
(BrickType.stone, BrickSize.size140x140) => {
BrickDamage.none: 'elementStone018.png',
BrickDamage.some: 'elementStone023.png',
BrickDamage.lots: 'elementStone050.png',
},
(BrickType.wood, BrickSize.size140x140) => {
BrickDamage.none: 'elementWood017.png',
BrickDamage.some: 'elementWood022.png',
BrickDamage.lots: 'elementWood049.png',
},
(BrickType.explosive, BrickSize.size220x140) => {
BrickDamage.none: 'elementExplosive019.png',
BrickDamage.some: 'elementExplosive024.png',
BrickDamage.lots: 'elementExplosive054.png',
},
(BrickType.glass, BrickSize.size220x140) => {
BrickDamage.none: 'elementGlass020.png',
BrickDamage.some: 'elementGlass025.png',
BrickDamage.lots: 'elementGlass052.png',
},
(BrickType.metal, BrickSize.size220x140) => {
BrickDamage.none: 'elementMetal019.png',
BrickDamage.some: 'elementMetal024.png',
BrickDamage.lots: 'elementMetal054.png',
},
(BrickType.stone, BrickSize.size220x140) => {
BrickDamage.none: 'elementStone019.png',
BrickDamage.some: 'elementStone024.png',
BrickDamage.lots: 'elementStone051.png',
},
(BrickType.wood, BrickSize.size220x140) => {
BrickDamage.none: 'elementWood018.png',
BrickDamage.some: 'elementWood023.png',
BrickDamage.lots: 'elementWood050.png',
},
(BrickType.explosive, BrickSize.size70x220) => {
BrickDamage.none: 'elementExplosive020.png',
BrickDamage.some: 'elementExplosive025.png',
BrickDamage.lots: 'elementExplosive055.png',
},
(BrickType.glass, BrickSize.size70x220) => {
BrickDamage.none: 'elementGlass021.png',
BrickDamage.some: 'elementGlass026.png',
BrickDamage.lots: 'elementGlass053.png',
},
(BrickType.metal, BrickSize.size70x220) => {
BrickDamage.none: 'elementMetal020.png',
BrickDamage.some: 'elementMetal025.png',
BrickDamage.lots: 'elementMetal055.png',
},
(BrickType.stone, BrickSize.size70x220) => {
BrickDamage.none: 'elementStone020.png',
BrickDamage.some: 'elementStone025.png',
BrickDamage.lots: 'elementStone052.png',
},
(BrickType.wood, BrickSize.size70x220) => {
BrickDamage.none: 'elementWood019.png',
BrickDamage.some: 'elementWood024.png',
BrickDamage.lots: 'elementWood051.png',
},
(BrickType.explosive, BrickSize.size140x220) => {
BrickDamage.none: 'elementExplosive021.png',
BrickDamage.some: 'elementExplosive026.png',
BrickDamage.lots: 'elementExplosive056.png',
},
(BrickType.glass, BrickSize.size140x220) => {
BrickDamage.none: 'elementGlass022.png',
BrickDamage.some: 'elementGlass027.png',
BrickDamage.lots: 'elementGlass054.png',
},
(BrickType.metal, BrickSize.size140x220) => {
BrickDamage.none: 'elementMetal021.png',
BrickDamage.some: 'elementMetal026.png',
BrickDamage.lots: 'elementMetal056.png',
},
(BrickType.stone, BrickSize.size140x220) => {
BrickDamage.none: 'elementStone021.png',
BrickDamage.some: 'elementStone026.png',
BrickDamage.lots: 'elementStone053.png',
},
(BrickType.wood, BrickSize.size140x220) => {
BrickDamage.none: 'elementWood020.png',
BrickDamage.some: 'elementWood025.png',
BrickDamage.lots: 'elementWood052.png',
},
};
}
class Brick extends BodyComponent {
Brick({
required this.type,
required this.size,
required BrickDamage damage,
required Vector2 position,
required Map<BrickDamage, Sprite> sprites,
}) : _damage = damage,
_sprites = sprites,
super(
renderBody: false,
bodyDef: BodyDef()
..position = position
..type = BodyType.dynamic,
fixtureDefs: [
FixtureDef(
PolygonShape()..setAsBoxXY(
size.size.width / 20 * brickScale,
size.size.height / 20 * brickScale,
),
)
..restitution = 0.4
..density = type.density
..friction = type.friction,
],
);
late final SpriteComponent _spriteComponent;
final BrickType type;
final BrickSize size;
final Map<BrickDamage, Sprite> _sprites;
BrickDamage _damage;
BrickDamage get damage => _damage;
set damage(BrickDamage value) {
_damage = value;
_spriteComponent.sprite = _sprites[value];
}
@override
Future<void> onLoad() {
_spriteComponent = SpriteComponent(
anchor: Anchor.center,
scale: Vector2.all(1),
sprite: _sprites[_damage],
size: size.size.toVector2() / 10 * brickScale,
position: Vector2(0, 0),
);
add(_spriteComponent);
return super.onLoad();
}
}
עכשיו אפשר לראות איך קוד Dart שנוצר קודם משולב בקוד הבסיס הזה, כדי לאפשר בחירת תמונות של לבנים במהירות על סמך החומר, הגודל והמצב שלהן. אם תתעלמו מה-enum
ותתמקדו ברכיב Brick
עצמו, תגלו שרוב הקוד הזה נראה מוכר מרכיב Ground
בשלב הקודם. כאן יש מצב שניתן לשינוי כדי לאפשר לבולק להיות פגום, אבל השימוש בו נשאר כתרגיל לקורא.
הגיע הזמן להציג את הלבנים במסך. עורכים את הקובץ game.dart
באופן הבא:
lib/components/game.dart
import 'dart:async';
import 'dart:math'; // Add this import
import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flame_kenney_xml/flame_kenney_xml.dart';
import 'background.dart';
import 'brick.dart'; // Add this import
import 'ground.dart';
class MyPhysicsGame extends Forge2DGame {
MyPhysicsGame()
: super(
gravity: Vector2(0, 10),
camera: CameraComponent.withFixedResolution(width: 800, height: 600),
);
late final XmlSpriteSheet aliens;
late final XmlSpriteSheet elements;
late final XmlSpriteSheet tiles;
@override
FutureOr<void> onLoad() async {
final backgroundImage = await images.load('colored_grass.png');
final spriteSheets = await Future.wait([
XmlSpriteSheet.load(
imagePath: 'spritesheet_aliens.png',
xmlPath: 'spritesheet_aliens.xml',
),
XmlSpriteSheet.load(
imagePath: 'spritesheet_elements.png',
xmlPath: 'spritesheet_elements.xml',
),
XmlSpriteSheet.load(
imagePath: 'spritesheet_tiles.png',
xmlPath: 'spritesheet_tiles.xml',
),
]);
aliens = spriteSheets[0];
elements = spriteSheets[1];
tiles = spriteSheets[2];
await world.add(Background(sprite: Sprite(backgroundImage)));
await addGround();
unawaited(addBricks()); // Add this line
return super.onLoad();
}
Future<void> addGround() {
return world.addAll([
for (
var x = camera.visibleWorldRect.left;
x < camera.visibleWorldRect.right + groundSize;
x += groundSize
)
Ground(
Vector2(x, (camera.visibleWorldRect.height - groundSize) / 2),
tiles.getSprite('grass.png'),
),
]);
}
final _random = Random(); // Add from here...
Future<void> addBricks() async {
for (var i = 0; i < 5; i++) {
final type = BrickType.randomType;
final size = BrickSize.randomSize;
await world.add(
Brick(
type: type,
size: size,
damage: BrickDamage.some,
position: Vector2(
camera.visibleWorldRect.right / 3 +
(_random.nextDouble() * 5 - 2.5),
0,
),
sprites: brickFileNames(
type,
size,
).map((key, filename) => MapEntry(key, elements.getSprite(filename))),
),
);
await Future<void>.delayed(const Duration(milliseconds: 500));
}
} // To here.
}
הוספת הקוד הזו שונה במקצת מהקוד שבו השתמשתם כדי להוסיף את הרכיבים של Ground
. הפעם, הערכים של Brick
מתווספים באשכול אקראי, לאורך זמן. יש לכך שני חלקים: הראשון הוא שהשיטה שמוסיפה את Brick
s await
s היא Future.delayed
, שהיא המקבילה האסינכרונית של קריאה ל-sleep()
. עם זאת, יש חלק שני שצריך כדי שהפעולה הזו תפעל: הקריאה ל-addBricks
בשיטה onLoad
לא await
ת. אם היא הייתה await
ת, השיטה onLoad
לא תסתיים עד שכל הלבנים יוצגו במסך. כשעוטפים את הקריאה ל-addBricks
בקריאה ל-unawaited
, ה-linters מרוצים והכוונה שלנו ברורה למתכנתים עתידיים. לא ממתינים להחזרת השיטה הזו באופן מכוון.
מפעילים את המשחק ורואים לבנים מופיעות, מתנגשות זו בזו ונשפכות על הקרקע.
6. הוספת הנגן
לזרוק חייזרים על לבנים
כיף לראות לבנים נופלות כמה פעמים, אבל אני מניח שהמשחק יהיה מהנה יותר אם נעניק לשחקן דמות וירטואלית שבאמצעותה הוא יוכל לקיים אינטראקציה עם העולם. מה דעתך על חייזר שהילדים יוכלו לזרוק על הלבנים?
יוצרים קובץ player.dart
חדש בספרייה lib/components
ומוסיפים אליו את הקטע הבא:
lib/components/player.dart
import 'dart:math';
import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flame/events.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flutter/material.dart';
const playerSize = 5.0;
enum PlayerColor {
pink,
blue,
green,
yellow;
static PlayerColor get randomColor =>
PlayerColor.values[Random().nextInt(PlayerColor.values.length)];
String get fileName =>
'alien${toString().split('.').last.capitalize}_round.png';
}
class Player extends BodyComponent with DragCallbacks {
Player(Vector2 position, Sprite sprite)
: _sprite = sprite,
super(
renderBody: false,
bodyDef: BodyDef()
..position = position
..type = BodyType.static
..angularDamping = 0.1
..linearDamping = 0.1,
fixtureDefs: [
FixtureDef(CircleShape()..radius = playerSize / 2)
..restitution = 0.4
..density = 0.75
..friction = 0.5,
],
);
final Sprite _sprite;
@override
Future<void> onLoad() {
addAll([
CustomPainterComponent(
painter: _DragPainter(this),
anchor: Anchor.center,
size: Vector2(playerSize, playerSize),
position: Vector2(0, 0),
),
SpriteComponent(
anchor: Anchor.center,
sprite: _sprite,
size: Vector2(playerSize, playerSize),
position: Vector2(0, 0),
),
]);
return super.onLoad();
}
@override
void update(double dt) {
super.update(dt);
if (!body.isAwake) {
removeFromParent();
}
if (position.x > camera.visibleWorldRect.right + 10 ||
position.x < camera.visibleWorldRect.left - 10) {
removeFromParent();
}
}
Vector2 _dragStart = Vector2.zero();
Vector2 _dragDelta = Vector2.zero();
Vector2 get dragDelta => _dragDelta;
@override
void onDragStart(DragStartEvent event) {
super.onDragStart(event);
if (body.bodyType == BodyType.static) {
_dragStart = event.localPosition;
}
}
@override
void onDragUpdate(DragUpdateEvent event) {
if (body.bodyType == BodyType.static) {
_dragDelta = event.localEndPosition - _dragStart;
}
}
@override
void onDragEnd(DragEndEvent event) {
super.onDragEnd(event);
if (body.bodyType == BodyType.static) {
children
.whereType<CustomPainterComponent>()
.firstOrNull
?.removeFromParent();
body.setType(BodyType.dynamic);
body.applyLinearImpulse(_dragDelta * -50);
add(RemoveEffect(delay: 5.0));
}
}
}
extension on String {
String get capitalize =>
characters.first.toUpperCase() + characters.skip(1).toLowerCase().join();
}
class _DragPainter extends CustomPainter {
_DragPainter(this.player);
final Player player;
@override
void paint(Canvas canvas, Size size) {
if (player.dragDelta != Vector2.zero()) {
var center = size.center(Offset.zero);
canvas.drawLine(
center,
center + (player.dragDelta * -1).toOffset(),
Paint()
..color = Colors.orange.withAlpha(180)
..strokeWidth = 0.4
..strokeCap = StrokeCap.round,
);
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}
זהו שדרוג מרכיבי Brick
בשלב הקודם. לרכיב Player
יש שני רכיבי צאצא: רכיב SpriteComponent
שכבר מוכר לכם, ורכיב CustomPainterComponent
חדש. הקונספט של CustomPainter
מגיע מ-Flutter, והוא מאפשר לצייר על לוח. כאן הוא משמש כדי לתת לשחקן משוב על המקום שאליו החייזר העגול יטוס כשישליכו אותו.
איך השחקן מתחיל את ההשלכה של החייזר? באמצעות תנועת גרירה, שרכיב הנגן מזהה באמצעות פונקציות ה-callbacks של DragCallbacks
. מי שעיניו חדות שם לב למשהו נוסף.
בעוד שרכיבי Ground
היו גופים סטטיים, רכיבי ה-Brick היו גופים דינמיים. הנגן כאן הוא שילוב של שניהם. השחקן מתחיל כסטטי, וממתין לשחקן שיגרור אותו. כשמשחררים את הגרירה, הוא הופך מסטטי לדינמי, מוסיף דחף לינארי ביחס לגרירה ומאפשר לדמות החייזר לעוף!
ברכיב 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.
}
הוספת השחקן למשחק דומה להוספת הרכיבים הקודמים, עם ניואנסים נוספים. החייזר של השחקן נועד להסיר את עצמו מהמשחק בתנאים מסוימים, לכן יש כאן טיפול בעדכון שבודק אם אין רכיב 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, רוב הקובץ הזה אמור להיות מוכר לכם. עם זאת, יופיעו כמה קווים אדומים מתחת לקוד בעורך בגלל סוג בסיס חדש לא ידוע. מוסיפים את הכיתה הזו עכשיו על ידי הוספת קובץ בשם 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;
}
}
הכיתה הבסיסית הזו, בשילוב עם פונקציית ה-callback החדשה 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 בנושא קריאות חזרה (callbacks) ליצירת קשר.
לנצח במשחק
עכשיו יש לכם אויבים ודרך להסיר אותם מהעולם, ויש דרך פשוטה להפוך את ההדמיה הזו למשחק. היעד הוא להסיר את כל האויבים! עכשיו עורכים את הקובץ 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…
- פיתוח ממשקי משתמש מהדור הבא ב-Flutter
- איך הופכים אפליקציה ב-Flutter משעממת ליפה
- הוספת רכישות מתוך האפליקציה לאפליקציה ב-Flutter