בונים משחק פיזיקה דו-ממדי עם Flutter and Flame

1. לפני שמתחילים

Flame הוא מנוע משחק דו-ממדי שמבוסס על Flutter. ב-Codelab הזה, אתם בונים משחק שנעשה בו שימוש בסימולציית פיזיקה דו-ממדית לפי המשחק של Box2D שנקרא Forge2D. אתם משתמשים ברכיבים של Flame כדי לצייר את המציאות הפיזית המדומה על המסך כדי שהמשתמשים יוכלו לשחק איתה. בסיום, המשחק ייראה כמו ה-GIF הזה עם האנימציה:

אנימציה של מהלך המשחק במשחק פיזיקה דו-ממדי

דרישות מוקדמות

מה לומדים

  • איך פועלים העקרונות הבסיסיים של Forge2D, מתחילים בסוגים השונים של הגוף הפיזי.
  • איך להגדיר סימולציה פיזית בדו-ממד.

מה צריך

תוכנת הידור עבור יעד הפיתוח שבחרתם. ה-Codelab הזה פועל בכל שש הפלטפורמות שנתמכות ב-Flutter. אתם צריכים את Visual Studio כדי לטרגט ל-Windows, ל-Xcode ול-macOS או ל-iOS, ו-Android Studio כדי לטרגט ל-Android.

2. יצירת פרויקט

יוצרים פרויקט Flutter

יש הרבה דרכים ליצור פרויקט של Flutter. בקטע הזה, תשתמשו בשורת הפקודה כדי לקצר את משך הזמן.

כדי להתחיל, בצע את הצעדים הבאים:

  1. יוצרים פרויקט של Flutter בשורת הפקודה:
$ flutter create --empty forge2d_game
Creating project forge2d_game...
Resolving dependencies in forge2d_game... (4.7s)
Got dependencies in forge2d_game.
Wrote 128 files.

All done!
You can find general documentation for Flutter at: https://docs.flutter.dev/
Detailed API documentation is available at: https://api.flutter.dev/
If you prefer video documentation, consider: https://www.youtube.com/c/flutterdev

In order to run your empty application, type:

  $ cd forge2d_game
  $ flutter run

Your empty application code is in forge2d_game/lib/main.dart.
  1. משנים את יחסי התלות של הפרויקט כדי להוסיף 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 כבר מוכרת לך, אבל יכול להיות שיידרש הסבר לגבי שלושת האחרים. החבילה 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. ב-Codelab הזה אין קוד Flutter שמשתמש במצב של מופע המשחק כדי להציג מידע על משחק הריצה, ולכן רצועת האתחול הפשוטה הזו עובדת כמו שצריך.

אופציונלי: שימוש בקווסט צדדי רק ל-macOS

צילומי המסך בפרויקט הזה הם מהמשחק בתור אפליקציה למחשב של macOS. כדי ששורת הכותרת של האפליקציה לא תפגע בחוויית השימוש הכוללת, אפשר לשנות את הגדרת הפרויקט של חלון ההרצה ב-macOS כך שמסירים את סרגל הכותרת.

לשם כך, בצע את הצעדים הבאים:

  1. יוצרים קובץ bin/modify_macos_config.dart ומוסיפים את התוכן הבא:

bin/modify_macos_config.dart

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

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

הקובץ הזה לא נמצא בספרייה lib כי הוא לא חלק מ-codebase של סביבת זמן הריצה של המשחק. זהו כלי שורת הפקודה שמשמש לשינוי הפרויקט.

  1. מספריית הבסיס של הפרויקט, מריצים את הכלי באופן הבא:
$ dart bin/modify_macos_config.dart

אם הכול מתוכנן כמתוכנן, התוכנה לא תפיק פלט בשורת הפקודה. עם זאת, הוא ישנה את קובץ התצורה של macos/Runner/Base.lproj/MainMenu.xib כך שיריץ את המשחק בלי סרגל כותרת גלוי, ומשחק הלהבות תופס את כל החלון.

צריך להריץ את המשחק כדי לוודא שהכול עובד. במקרה כזה, יוצג חלון חדש עם רקע שחור ריק בלבד.

חלון של אפליקציה עם רקע שחור ללא שום דבר בחזית

3. כדאי להוסיף נכסי תמונות

הוספת התמונות

כל משחק צריך לכלול נכסי אומנות כדי שיהיה אפשר לצבוע מסך באופן שמפיק כיף. ה-Codelab הזה ישתמש בחבילה נכסי פיזיקה של Kenney.nl. הנכסים האלה הם ברישיון Creative Commons CC0, אבל עדיין מומלץ מאוד לתת תרומה לצוות של Kenney כדי שיוכלו להמשיך לעשות את העבודה המצוינת. הרגשתי.

יהיה עליך לשנות את קובץ התצורה pubspec.yaml כדי לאפשר את השימוש בנכסים של קניי. שנה אותה באופן הבא:

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.

אש מצפה שנכסי תמונות ימוקמו ב-assets/images, אם כי אפשר להגדיר זאת באופן שונה. לפרטים נוספים, ניתן לעיין במסמכי התיעוד בנושא תמונות של Flame. עכשיו, אחרי שהגדרתם את הנתיבים, צריך להוסיף אותם לפרויקט עצמו. דרך אחת לעשות זאת היא להשתמש בשורת הפקודה באופן הבא:

$ mkdir -p assets/images

לא אמור להתקבל פלט מהפקודה mkdir, אבל הספרייה החדשה אמורה להיות גלויה בעורך או בכלי לבדיקת קבצים.

מרחיבים את הקובץ kenney_physics-assets.zip שהורדתם. אתם אמורים לראות משהו כזה:

רשימת הקבצים של החבילה kenney_physics-assets הורחבה, וספריית PNG/Backgrounds מודגשת

מהספרייה PNG/Backgrounds, מעתיקים את הקבצים colored_desert.png, colored_grass.png, colored_land.png ו-colored_shroom.png לספריית assets/images של הפרויקט.

יש גם גיליונות Sprite. אלה שילוב של תמונת PNG וקובץ XML שמתארים איפה אפשר למצוא תמונות קטנות יותר בתמונה של ה-Spritesheet. גיליונות אלקטרוניים הם שיטה לקיצור זמן הטעינה על ידי טעינה של קובץ אחד בלבד, בניגוד לעשרות, אם לא מאות, של קובצי תמונה בודדים.

רשימת הקבצים של החבילה kenney_physics-assets הורחבה, וספריית Spritesheet מודגשת

מעתיקים את spritesheet_aliens.png, spritesheet_elements.png ואת spritesheet_tiles.png לספריית assets/images של הפרויקט. בזמן שנמצאים כאן, צריך גם להעתיק את הקבצים spritesheet_aliens.xml, spritesheet_elements.xml ו-spritesheet_tiles.xml לספריית assets של הפרויקט. הפרויקט שלכם אמור להיראות כך.

רשימת קובץ של ספריית הפרויקט forge2d_game, עם הדגשה של ספריית הנכסים

צביעת הרקע

עכשיו, אחרי שהוסיפו נכסי תמונות לפרויקט, הגיע הזמן להוסיף אותם למסך. ובכן, תמונה אחת על המסך. פיתוחים נוספים יסופקו בשלבים הבאים.

יוצרים קובץ בשם 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. יש בקוד הזה כמה הנחות מפשטות יותר. הראשונה היא שהתמונות ריבועיות, וכל ארבע תמונות הרקע מקניני. הסיבה השנייה היא שגודל העולם הגלוי לעולם לא ישתנה, אחרת הרכיב הזה יצטרך לטפל באירועי שינוי גודל במשחק. ההנחה השלישית היא שהמיקום (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. בניגוד ל-Codelab הקודם, כאן הוא נמשך Forge2DGame ולא FlameGame. Forge2DGame עצמו להרחיב את FlameGame עם כמה שינויים מעניינים. הראשונה היא שכברירת מחדל, zoom מוגדר ל-10. ההגדרה של zoom קשורה לטווח הערכים השימושיים שמנועי הסימולציה של פיזיקה בסגנון Box2D פועלים איתם היטב. המנוע נכתב באמצעות מערכת MKS, שבה ההנחה היא שהיחידות הן במטרים, בקילוגרמים ובשניות. הטווח שבו לא מוצגות שגיאות מתמטיות בולטות לעצמים הוא בין 0.1 מטר ל-10 מטרים. הזנת מידות פיקסלים ישירות ללא רמה מסוימת של הקטנת קנה מידה תגרום לחריגה מהמעטפת השימושית של Forge2D. הסיכום השימושי הוא לחשוב על הדמיית עצמים בטווח של סודה עד לאוטובוס.

ההנחות שמתבססות על רכיב הרקע כאן מתקיימות על ידי תיקון הרזולוציה של CameraComponent ל-800 על 600 פיקסלים וירטואליים. פירוש הדבר שאזור המשחק יהיה ברוחב 80 יחידות ו-60 יחידות בגובה של (0,0). אין לכך השפעה על הרזולוציה שמוצגת, אבל היא תשפיע על המיקום שבו יציבו חפצים בסצנת המשחק.

לצד הארגומנט camera של ה-constructor, מופיע גם ארגומנט נוסף שמבוסס על פיזיקה, שנקרא gravity. כוח המשיכה מוגדר ל-Vector2 עם x של 0 ו-y של 10. הערך 10 קרוב לערך המקובל של 9.81 מטר לשנייה בשביל כוח הכבידה. העובדה שכוח הכבידה מוגדר ל-10 חיובי מוכיחה שבמערכת הזו הכיוון של ציר ה-Y הוא למטה. שיטה ששונה מ-Box2D באופן כללי, אבל תואמת לאופן שבו Flame מוגדרת בדרך כלל.

השיטה הבאה היא onLoad. השיטה הזו אסינכרונית, ולכן היא מתאימה כי היא אחראית לטעינת נכסי תמונות מהדיסק. הקריאות אל images.load מחזירות Future<Image>, כתופעת לוואי מאפשרת לשמור את התמונה שנטענה באובייקט המשחק. הנתונים העתידיים האלה נאספים וממתינים כיחידה אחת באמצעות השיטה הסטטית Futures.wait. לאחר מכן, רשימת התמונות שהוחזרו מותאמת לשמות שונים.

לאחר מכן, תמונות ה-Spritesheet מוזנים לסדרה של אובייקטים מסוג XmlSpriteSheet שאחראים לאחזור ה-Sprites שנקרא בנפרד על ה-Spritesheet. המחלקה 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 לפי הצורך כדי שיחס הגובה-רוחב של המשחק יעבוד, לפי הצורך.

חלון של אפליקציה עם תמונת רקע של גבעות ירוקות משתפלות ועצים מופשטים בצורה מוזרה.

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, לגוף יש שלושה סוגים שונים. גופים סטטיים לא זזים. למעשה, יש להם מסה אפסית – הם לא מגיבים לכוח הכבידה – וגם מסה אינסופית – הם לא זזים כשהם נפגעים מאובייקטים אחרים, גם אם הם כבדים. כך גופים סטטיים מושלמים למשטח קרקע, כי הם לא זזים.

שני סוגי הגוף האחרים הם קינמטיים ודינמיים. גוף דינמי הוא גוף שמבוסס על סימולציה מלאה, והוא מגיב לכוח הכבידה ולחפצים שהם מתנגשים בהם. בהמשך יוצגו גופים דינמיים רבים ב-Codelab הזה. גופים קינמטיים הם חצי בית בין סטטיות לדינמיות. הם נעים, אבל הם לא מגיבים לכוח הכבידה או לחפצים אחרים שפוגעים בהם. שימושי, אבל חורג מההיקף של ה-Codelab הזה.

הגוף עצמו לא עושה הרבה. גוף צריך צורות קשורות כדי לקבל חומר. במקרה הזה, לגוף הזה משויכת צורה אחת, PolygonShape שמוגדרת כ-BoxXY. תיבה מהסוג הזה מיושרת עם העולם, בניגוד ל-PolygonShape שמוגדרת ל-BoxXY שאפשר לסובב מסביב לנקודת סיבוב. שוב שימושי, אבל הוא לא נכלל במסגרת ה-Codelab הזה. הצורה והגוף מחוברים יחד עם מתקן תלייה, שעוזר להוסיף למערכת דברים כמו friction.

כברירת מחדל, גוף יציג את הצורות המצורפות שלו בדרך שימושית לניפוי באגים, אבל לא ליצירת גיימפליי מעולה. אם מגדירים את הארגומנט renderBody של super לערך 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 והמיר אותו לקוד של Drt, כדי שנוכל להשתמש בו כדי לבחור את קובץ התמונה המתאים לכל לבנה שרוצים להציג במסך. מועיל!

יוצרים את הקובץ 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();
  }
}

עכשיו אפשר לראות איך קוד Drt שנוצר למעלה משתלב ב-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 מתווספים באשכול אקראי, עם הזמן. יש שני חלקים. הראשון הוא שהשיטה שמוסיפה את ה-await של Brick, שהוא Future.delayed, שהוא מקבילה אסינכרונית של קריאה ל-sleep(). עם זאת, יש חלק שני שעוזר לבצע את הפעולה הזו. הקריאה ל-addBricks בשיטה onLoad לא בוצעה await. אם היו, השיטה onLoad לא הייתה מושלמת עד שכל הלבנים הוצגו במסך. השלמת הקריאה ל-addBricks בשיחה של unawaited משמחת את החוטים, ומבהירה את הכוונה שלנו למתכנתים עתידיים. לא צריך להמתין עד שהשיטה הזו תחזור לפעול.

הפעילו את המשחק ותוכלו לראות לבנים מופיעות, מתנגשות זו בזו ונשפכות על הקרקע.

חלון של אפליקציה עם גבעות ירוקות ברקע, שכבת קרקע ובלוקים שנחתכים על הקרקע.

6. הוספת השחקן

משליך חייזרים על לבנים

כיף לצפות באצבעות על לבנים בפעמים הראשונות, אבל נראה לי שהמשחק הזה יהיה כיף יותר אם נציג לשחקן דמות שיוכל לשמש אותו לאינטראקציה עם העולם. מה לגבי חייזר שהם יכולים לנוטף אל הלבנים?

יוצרים קובץ player.dart חדש בספרייה lib/components ומוסיפים אליו את הפרטים הבאים:

lib/components/player.dart

import 'dart:math';

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

const playerSize = 5.0;

enum PlayerColor {
  pink,
  blue,
  green,
  yellow;

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

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

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

  final Sprite _sprite;

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

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

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

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

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

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

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

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

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

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

  final Player player;

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

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

זהו שלב גבוה יותר ביחס לרכיבי Brick בשלב הקודם. לרכיב Player הזה יש שני רכיבי צאצא, SpriteComponent שצריך לזהות ו-CustomPainterComponent שהוא חדש. הקונספט CustomPainter מגיע מ-Flutter, והוא מאפשר לצייר על קנבס. משתמשים בו כאן כדי לתת לשחקן משוב על המיקום שבו החייזר העגול יטוס כשהוא מטיח אותו.

איך השחקן יוזם את הנביטה של החייזר? באמצעות תנועת גרירה, שרכיב הנגן מזהה באמצעות הקריאות החוזרות (callback) של DragCallbacks. העיט שעיינת בך יראה כאן משהו אחר.

כאשר רכיבי Ground היו גופים סטטיים, רכיבי הלבנים היו גופים דינמיים. הנגן כאן הוא שילוב של שניהם. הנגן מתחיל כסטטי, ממתין לשחקן לגרור אותו, ובשחרור גרירה הוא ממיר את עצמו מסטטי לדינמי, מוסיף דחף ליניארי ביחס לגרירה, ומאפשר לדמות החייזרים לעוף!

יש גם קוד ברכיב 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.
}

הוספת השחקן למשחק דומה לרכיבים הקודמים, עם קמט אחד נוסף. חייזר של השחקן נועד להסיר את עצמו מהמשחק בתנאים מסוימים, לכן יש כאן handler של עדכונים שבודק אם אין רכיב 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();
}

מהאינטראקציות הקודמות שלכם עם הרכיבים 'נגן' ו'לבנים', רוב הקובץ הזה אמור להיות מוכר. עם זאת, יהיו כמה קווים תחתונים אדומים בעורך בגלל סיווג בסיסי חדש לא ידוע. כדי להוסיף את הכיתה הזו עכשיו, צריך להוסיף אל lib/components קובץ בשם body_component_with_user_data.dart עם התוכן הבא:

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 לגבי קריאות חוזרות של אנשי קשר.

ניצחון

עכשיו, כשיש לכם אויבים ויש דרך להסיר אותם מהעולם, זו דרך פשוטה להפוך את הסימולציה למשחק. מציבים את היעד כדי להסיר את כל האויבים! זה הזמן לערוך את הקובץ 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.
  }
}

האתגר שלכם, אם תבחרו לקבל אותו, הוא להריץ את המשחק ולהביא את עצמכם למסך הזה.

חלון של אפליקציה עם גבעות ירוקות ברקע, שכבת קרקע, בלוקים על הקרקע ושכבת-על של טקסט עם הכיתוב &#39;אתם מנצחים!&#39;

8. מזל טוב

כל הכבוד, הצלחת לבנות משחק עם Flutter ו-Fflame!

יצרת משחק באמצעות מנוע המשחק Flame 2D והטמעת אותו ב-Flutter wrapper. השתמשת באפקטים של Flame כדי להוסיף אנימציה לרכיבים ולהסיר אותם. השתמשת בחבילות Google Fonts ו-Flutter Animate כדי לעצב את המשחק כולו בצורה טובה.

מה השלב הבא?

כדאי לנסות כמה מ-Codelabs האלה...

קריאה נוספת