أنشِئ لعبة فيزياء ثنائية الأبعاد باستخدام Flutter وFlutter

1. قبل البدء

Flame هو محرّك ألعاب ثنائي الأبعاد يستند إلى Flutter. في هذا الدرس التطبيقي حول الترميز، ستنشئ لعبة تستخدم محاكاة فيزياء ثنائية الأبعاد على غرار Box2D باسم Forge2D. يمكنك استخدام مكونات Flame لرسم محاكاة للواقع الفعلي على الشاشة ليتمكّن المستخدمون من اللعب بها. عند الانتهاء، يجب أن تبدو لعبتك على النحو التالي المتحرّك GIF:

صورة متحركة لطريقة لعبها في لعبة الفيزياء الثنائية الأبعاد

المتطلبات الأساسية

المعلومات التي تطّلع عليها

  • كيف تعمل أساسيات Forge2D، بدءًا من الأنواع المختلفة من الأجسام المادية.
  • كيفية إعداد محاكاة فعلية في الوضع الثنائي الأبعاد.

ما تحتاج إليه

برنامج التحويل البرمجي لهدف التطوير الذي اخترته يتوافق هذا الدرس التطبيقي حول الترميز مع جميع الأنظمة الأساسية الست المتوافقة مع Flutter. ويجب استخدام Visual Studio لاستهداف أنظمة التشغيل Windows وXcode لاستهداف أنظمة التشغيل macOS أو iOS، وكذلك استهداف Android Studio.

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. لا يتضمّن هذا الدرس التطبيقي حول الترميز أي رموز 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 لأنّه ليس جزءًا من قاعدة الرموز الخاصة باللعبة في بيئة التشغيل. وهو أداة سطر أوامر تُستخدم لتعديل المشروع.

  1. من الدليل الأساسي للمشروع، شغِّل الأداة على النحو التالي:
$ dart bin/modify_macos_config.dart

وإذا سارت الأمور على النحو المطلوب، فلن ينشئ البرنامج أي ناتج في سطر الأوامر. في المقابل، سيتم تعديل ملف الإعداد "macos/Runner/Base.lproj/MainMenu.xib" لتشغيل اللعبة بدون شريط عنوان مرئي، وشغل لعبة Flame بالنافذة بأكملها.

شغِّل اللعبة للتأكّد من أنّ كل شيء يعمل على ما يرام. من المفترض أن تعرض نافذة جديدة بخلفية سوداء فارغة فقط.

نافذة تطبيق بخلفية سوداء بدون أي شاشة في المقدّمة

3- إضافة مواد عرض صور

إضافة الصور

تحتاج أي لعبة إلى مواد عرض فنية كي تتمكن من رسم شاشة بطريقة مرحة. سيستخدم هذا الدرس التطبيقي حول الترميز حزمة Physics Assets من Kenney.nl. إنّ مواد العرض هذه حاصلة على ترخيص من مؤسسة المشاع الإبداعي CC0، إلا أنّني ما زلت أقترح على الفريق في "كيني" تبرّعه ليتمكن من مواصلة العمل الرائع الذي يؤديه. لقد فعلتها.

وستحتاج إلى تعديل ملف الإعداد 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 الخاص بمشروعك.

تتوفر أيضًا أوراق رموز متحركة. وهي عبارة عن تركيبة من صورة PNG وملف XML يصف المكان الذي يمكن العثور فيه على صور أصغر في صورة ورقة الرموز المتحركة. جداول الرموز التعبيرية هي أسلوب يهدف إلى تقليل وقت التحميل عن طريق تحميل ملف واحد فقط على عكس عشرات، إن لم يكن مئات، من ملفات الصور الفردية.

تم توسيع قائمة ملف لحزمة 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 متخصص. إنه مسؤول عن عرض إحدى صور الخلفية الأربع لـ Kinney.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. على عكس الدرس التطبيقي السابق حول الترميز، يمتد نطاق هذا المسار إلى Forge2DGame وليس FlameGame. يعمل Forge2DGame بحد ذاته على توسيع FlameGame من خلال بعض التعديلات المثيرة للاهتمام. الطريقة الأولى هي ضبط قيمة zoom تلقائيًا على 10. يرتبط إعداد zoom هذا بمجموعة القيم المفيدة التي تتوافق معها محرّكات محاكاة الفيزياء في نمط Box2D بشكل جيد. تتم كتابة المحرك باستخدام نظام MKS حيث يتم الافتراض أن الوحدات بالأمتار والكيلوجرامات والثواني. يتراوح النطاق الذي لا ترى فيه أخطاء رياضية ملحوظة للأجسام من 0.1 متر إلى 10 أمتار. قد يؤدي تطبيق أبعاد البكسل مباشرةً بدون أي مستوى من الضبط الأدنى إلى جعل Forge2D خارج نطاق البيانات المفيدة. الملخص المفيد هو التفكير في محاكاة الأغراض الموجودة في نطاق المياه الغازية التي يمكن أن تصل إلى حافلة.

يتمّ تطبيق الافتراضات الواردة في مكوِّن الخلفية هنا من خلال تثبيت درجة دقة CameraComponent على 800 × 600 بكسل افتراضي. وهذا يعني أن منطقة الألعاب ستكون بعرض 80 وحدة وطول 60 وحدة وتوسيطها عند (0,0). ولا يؤثّر ذلك في درجة الدقة المعروضة، ولكنّه سيؤثّر في مكان وضع العناصر في مشهد اللعبة.

إلى جانب وسيطة الدالة الإنشائية camera، توجد وسيطة أخرى متوافقة مع الفيزياء باسم gravity. تم ضبط قيمة الجاذبية على Vector2 مع قيمة x على 0 وy من 10. الرقم 10 هو تقريب تقريبي لقيمة الجاذبية المقبولة عمومًا وهي 9.81 متر في الثانية. حقيقة تعيين الجاذبية على الموجب 10 يدل على أن اتجاه المحور ص في هذا النظام منخفض. وهو ما يختلف عن Box2D بشكل عام، ولكنه يتسق مع كيفية ضبط Flame عادةً.

الطريقة التالية هي طريقة onLoad. هذه الطريقة غير متزامنة، وهي مناسبة لأنها مسؤولة عن تحميل مواد عرض الصور من القرص. في طلبات استدعاء images.load، يتم عرض Future<Image>، وكنتيجة جانبية، يتم عرض الصورة التي تم تحميلها في كائن "اللعبة" في ذاكرة التخزين المؤقت. يتم جمع هذه العقود الآجلة معًا وينتظرها كوحدة واحدة باستخدام طريقة Futures.wait الثابتة. ثم تتم مطابقة قائمة الصور المعروضة مع الأسماء الفردية.

يتم بعد ذلك تغذية صور ورقة الرموز المتحركة في سلسلة من كائنات XmlSpriteSheet المسؤولة عن استرداد الرموز التعبيرية المُسماة بشكل فردي والواردة في ورقة الرموز. يتم تحديد الفئة XmlSpriteSheet في الحزمة flame_kenney_xml.

مع كل هذه التفاصيل، ما عليك سوى إجراء تعديلات طفيفة على "lib/main.dart" لعرض الصورة على الشاشة.

lib/main.dart

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

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

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

من خلال إجراء هذا التغيير البسيط، يمكنك الآن تشغيل اللعبة مرة أخرى لرؤية الخلفية على الشاشة. جدير بالذكر أنّ مثيل الكاميرا في CameraComponent.withFixedResolution() سيضيف أشرطة أفقية على النحو المطلوب لضمان عرض محتوى اللعبة بنسبة عرض إلى ارتفاع 800 x 600.

نافذة تطبيق تعرض صورة خلفية لتلال خضراء متموّجة وأشجار تجريدية غريبة.

4. إضافة الأرض

شيء يمكن البناء عليه

إذا كانت هناك قوة الجاذبية، نحتاج إلى شيء لالتقاط الأشياء في اللعبة قبل أن تسقط من أسفل الشاشة. بالطبع، ما لم يكن السقوط عن الشاشة جزءًا من تصميم لعبتك. أنشئ ملف ground.dart جديدًا في دليل lib/components وأضِف ما يلي إليه:

lib/components/ground.dart

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

const groundSize = 7.0;

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

يشتق المكوِّن Ground من BodyComponent. تكون الأجسام في Forge2D مهمة، فهي الكائنات التي تشكل جزءًا من المحاكاة المادية الثنائية الأبعاد. تم تحديد BodyDef لهذا المكوِّن على BodyType.static.

في Forge2D، تحتوي النصوص على ثلاثة أنواع مختلفة. لا تتحرك النصوص الثابتة. وكلاهما يرمز إلى الكتلة الصفرية - لا يستجيبان للجاذبية - والكتلة اللانهائية - ولا تتحرك عند اصطدامها بأشياء أخرى، مهما كانت وزنها. وهذا يجعل الأجسام الثابتة مثالية لسطح الأرض، لأنها لا تتحرك.

النوعان الآخران من الأجسام هما الحركة والديناميكية. الأجسام الديناميكية هي أجسام تمت محاكاتها بالكامل، وتتفاعل مع الجاذبية والأجسام التي تصطدم بها. ستظهر لك العديد من النصوص الديناميكية في باقي هذا الدرس التطبيقي حول الترميز. الأجسام الحركية هي في منتصف الطريق بين الثابتة والديناميكية. وهي تتحرك، لكنها لا تستجيب للجاذبية أو الأجسام الأخرى التي تضربها. هذا الأمر مفيد، ولكن خارج نطاق هذا الدرس التطبيقي حول الترميز.

الجسم نفسه لا يفعل الكثير. يحتاج الجسم إلى أشكال مرتبطة لامتلاك مادة. في هذه الحالة، يكون لهذا النص الأساسي شكل واحد مرتبط، وتم ضبط PolygonShape على أنّه BoxXY. هذا النوع من الصناديق هو محور تتم محاذاته مع العالم، على عكس PolygonShape الذي يتم ضبطه كـ BoxXY والذي يمكن تدويره حول نقطة دوران. وهي مفيدة أيضًا، ولكنها أيضًا خارج نطاق هذا الدرس التطبيقي حول الترميز. يتم ربط الشكل والهيكل معًا بتركيب، وهو أمر مفيد لإضافة أشياء مثل friction إلى النظام.

بشكل تلقائي، يعرض الجسم الأشكال المرفقة به بطريقة مفيدة في تصحيح الأخطاء، إلا أنّها لا تساعد على تقديم أسلوب لعب رائع. ويؤدي ضبط الوسيطة super renderBody على false إلى إيقاف عرض تصحيح الأخطاء هذا. تقع مسؤولية عرض المحتوى داخل اللعبة على عاتق الطفل "SpriteComponent".

لإضافة المكوِّن Ground إلى اللعبة، عدِّل ملف game.dart على النحو التالي.

lib/components/game.dart

import 'dart:async';

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

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

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

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

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

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

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

    return super.onLoad();
  }

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

يضيف هذا التعديل سلسلة من مكونات Ground إلى العالم من خلال استخدام حلقة for داخل سياق List وتمرير القائمة الناتجة لمكونات Ground إلى طريقة addAll في world.

تعرض اللعبة الآن الخلفية والأرض.

نافذة تطبيق مع خلفية وطبقة أرض.

5- إضافة الطوب

بناء جدار

أعطتنا الأرض مثالاً على جسم ثابت. الآن حان الوقت للمكون الديناميكي الأول. وتشكّل المكونات الديناميكية في لعبة Forge2D حجر الأساس في تجربة اللعب، فهي الأشياء التي تتحرك وتتفاعل مع العالم من حوله. في هذه الخطوة، ستتعرّف على ميزة "الطوب"، والتي سيتم اختيارها عشوائيًا لتظهر على الشاشة في مجموعة من الطوب. ستراهما يسقطان ويلتقيان ببعضهما البعض أثناء حدوث ذلك.

سيتم صنع الطوب من ورقة الرموز المتحركة للعناصر. إذا نظرت إلى وصف ورقة الرموز المتحركة في 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',
      },
  };
}

قامت هذه الأداة بتحليل ملف وصف ورقة الرموز المتحركة بشكل مفيد وتحويله إلى رمز 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 في مجموعة عشوائية بمرور الوقت. هناك جزآن، الأول هو: الطريقة التي تضيف 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، حيث يتيح لك الرسم على لوحة. ويتم استخدام هذه البيانات هنا لتقديم ملاحظات للّاعبين حول الموقع الذي سينتقل إليه المخلوق الغريب المستدير عند سقوطه.

كيف يبدأ اللاعب في القفز بين الكائنات الفضائية؟ استخدام إيماءة سحب، يرصدها مكوِّن المشغّل من خلال استدعاءات 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.
}

تشبه إضافة اللاعب إلى اللعبة المكونات السابقة، مع تجعّد إضافي واحد. تم تصميم الكائن الفضائي الخاص باللاعب لكي يزيل نفسه من اللعبة في ظل ظروف معيّنة، لذلك يتوفّر معالِج تحديث يتحقّق من عدم احتواء اللعبة على مكوّن 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();
}

من تفاعلاتك السابقة مع مكوّنات المشغّل والطوب، يجب أن يكون معظم هذا الملف مألوفًا. ومع ذلك، سيكون هناك تسطير بلون أحمر في المحرر بسبب فئة أساسية جديدة غير معروفة. أضِف هذا الصف الآن عن طريق إضافة ملف باسم "body_component_with_user_data.dart" إلى "lib/components" يتضمّن المحتوى التالي:

lib/components/body_component_with_user_data.dart

import 'package:flame_forge2d/flame_forge2d.dart';

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

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

تشكّل هذه الفئة الأساسية، بالإضافة إلى استدعاء beginContact الجديد في المكوِّن Enemy، أساس تلقّي الإشعارات آليًا بشأن التأثيرات بين النصوص. في الواقع، ستحتاج إلى تعديل أي مكونات تريد الحصول على إشعارات التأثير بينها. لذا، يمكنك المتابعة وتعديل المكوّنات Brick وGround وPlayer لاستخدام BodyComponentWithUserData هذه بدلاً من الفئة الأساسية BodyComponent التي تستخدمها هذه المكوّنات حاليًا. على سبيل المثال، إليك كيفية تعديل المكوِّن Ground:

lib/components/ground.dart

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

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

const groundSize = 7.0;

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

لمزيد من المعلومات عن كيفية تعامل Forge2d مع جهة الاتصال، يُرجى الاطّلاع على مستندات Forge2D حول طلبات معاودة الاتصال.

الفوز في اللعبة

الآن بعد أن أصبح لديك الأعداء وطريقة التخلص من الأعداء من العالم، أصبحت هناك طريقة مبسَّطة لتحويل هذه المحاكاة إلى لعبة. اسعَ إلى التخلّص من كل الأعداء. حان الوقت لتعديل ملف game.dart على النحو التالي:

lib/components/game.dart

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

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

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

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

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

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

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

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

    return super.onLoad();
  }

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

  final _random = Random();

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

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

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

  var enemiesFullyAdded = false;

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

وإذا اخترت قبولها، سيكون التحدي الذي تواجهه هو تشغيل اللعبة والانتقال إلى هذه الشاشة.

نافذة تطبيق مع تلال خضراء في الخلفية، وطبقة أرضية، وقوالب على الأرض، مع عبارة &quot;لقد فزت&quot;

8. تهانينا

تهانينا، لقد نجحت في إنشاء لعبة باستخدام Flutter وFlutter

صمّمت لعبة باستخدام محرّك الألعاب Flame الثنائي الأبعاد وضمّنتها في برنامج تضمين Flutter. لقد استخدمت تأثيرات Flame لتحريك المكوّنات وإزالتها. لقد استخدمت حزمتَي Google Fonts وFlutter Animate لإنشاء اللعبة بالكامل.

ما هي الخطوات التالية؟

اطّلع على بعض هذه الدروس التطبيقية حول الترميز...

محتوى إضافي للقراءة