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

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

لمحة عن هذا الدرس التطبيقي حول الترميز

subjectتاريخ التعديل الأخير: يونيو 23, 2025
account_circleتأليف: Brett Morgan

1. قبل البدء

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

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

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

ما ستتعرّف عليه

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

ما تحتاج إليه

برنامج المُجمِّع لهدف التطوير الذي اخترته يعمل هذا الدليل التعليمي على جميع المنصات الستة التي تتوافق مع 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.
    
  2. عدِّل تبعيات المشروع لإضافة Flame وForge2D:
    $ cd forge2d_game
    $ flutter pub add characters flame flame_forge2d flame_kenney_xml xml
    Resolving dependencies...
    Downloading packages...
      characters 1.4.0 (from transitive dependency to direct dependency)
    + flame 1.29.0
    + flame_forge2d 0.19.0+2
    + flame_kenney_xml 0.1.1+12
      flutter_lints 5.0.0 (6.0.0 available)
    + forge2d 0.14.0
      leak_tracker 10.0.9 (11.0.1 available)
      leak_tracker_flutter_testing 3.0.9 (3.0.10 available)
      leak_tracker_testing 3.0.1 (3.0.2 available)
      lints 5.1.1 (6.0.0 available)
      material_color_utilities 0.11.1 (0.13.0 available)
      meta 1.16.0 (1.17.0 available)
    + ordered_set 8.0.0
    + petitparser 6.1.0 (7.0.0 available)
      test_api 0.7.4 (0.7.6 available)
      vector_math 2.1.4 (2.2.0 available)
      vm_service 15.0.0 (15.0.2 available)
    + xml 6.5.0 (6.6.0 available)
    Changed 8 dependencies!
    12 packages have newer versions incompatible with dependency constraints.
    Try `flutter pub outdated` for more information.
    

إنّ حزمة flame مألوفة لك، ولكن قد تحتاج الحزم الثلاث الأخرى إلى بعض الشرح. تُستخدَم حزمة characters لمعالجة المسار بطريقة متوافقة مع UTF8. تعرض حزمة flame_forge2d وظيفة Forge2D بطريقة تتوافق بشكل جيد مع Flame. أخيرًا، يتم استخدام حزمة xml في أماكن مختلفة لاستخدام محتوى XML وتعديله.

افتح المشروع ثم استبدِل محتوى ملف lib/main.dart بما يلي:

lib/main.dart

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

void main() {
 
runApp(GameWidget.controlled(gameFactory: FlameGame.new));
}

يؤدي ذلك إلى بدء التطبيق باستخدام GameWidget الذي ينشئ مثيل FlameGame. لا يتضمّن هذا الدليل التعليمي عن الرموز البرمجية أي رمز Flutter يستخدم حالة مثيل اللعبة لعرض معلومات عن اللعبة التي يتم تشغيلها، لذا يعمل هذا الرمز البرمجي المبسّط بشكل جيد.

اختياري: تجربة مهمة جانبية على نظام التشغيل 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. إضافة مواد عرض الصور

إضافة الصور

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

ستحتاج إلى تعديل ملف الإعدادات pubspec.yaml لتفعيل استخدام مواد عرض Kenney. عدِّل القيمة على النحو التالي:

pubspec.yaml

name: forge2d_game
description: "A new Flutter project."
publish_to: 'none'
version: 0.1.0

environment:
  sdk: ^3.8.1

dependencies:
  flutter:
    sdk: flutter
  characters: ^1.4.0
  flame: ^1.29.0
  flame_forge2d: ^0.19.0+2
  flame_kenney_xml: ^0.1.1+12
  xml: ^6.5.0

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^5.0.0

flutter:
  uses-material-design: true
  assets:                        # Add from here
    - assets/
    - assets/images/             # To here.

تتوقّع أداة Flame أن تكون مواد عرض الصور متوفّرة في assets/images، على الرغم من أنّه يمكن ضبط هذا الإعداد بشكل مختلف. اطّلِع على مستندات الصور في Flame للحصول على مزيد من التفاصيل. بعد ضبط المسارات، عليك إضافتها إلى المشروع نفسه. وإحدى طرق إجراء ذلك هي استخدام سطر الأوامر على النحو التالي:

mkdir -p assets/images

من المفترض ألا تظهر أي نتائج من الأمر mkdir، ولكن من المفترض أن يظهر الدليل الجديد في المحرِّر أو في مستكشف الملفات.

وسِّع ملف kenney_physics-assets.zip الذي نزّلته، ومن المفترض أن يظهر لك ما يلي:

قائمة ملفات موسّعة لحزمة مواد عرض kenney_physics، مع تمييز الدليل PNG/خلفيات

من الدليل PNG/Backgrounds، انسخ الملفات colored_desert.png وcolored_grass.png وcolored_land.png وcolored_shroom.png إلى الدليل assets/images في مشروعك.

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

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

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

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

في ما يلي طريقة onLoad. هذه الطريقة غير متزامنة، وهو أمر مناسب لأنّها مسؤولة عن تحميل مواد عرض الصور من القرص. تؤدي طلبات images.load إلى عرض Future<Image>، ويكون التأثير الجانبي هو تخزين الصورة المحمَّلة في عنصر Game. يتم تجميع هذه القيم المستقبلية معًا والانتظار عليها كوحدة واحدة باستخدام الطريقة الثابتة 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 في مجموعة عشوائية بمرور الوقت. هناك جزءان من هذا الإجراء، الأول هو أنّ الطريقة التي تضيف Bricks await هي Future.delayed، وهي المكالمة غير المتزامنة المكافئ sleep(). ومع ذلك، هناك جزء ثانٍ لتنفيذ هذا الإجراء، وهو أنّه لم يتم await طلب addBricks في طريقة onLoad. وإذا تم ذلك، لن تكتمل طريقة 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.withAlpha(180)
         
..strokeWidth = 0.4
         
..strokeCap = StrokeCap.round,
     
);
   
}
 
}

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

وهذا تقدّم عن المكونات Brick في الخطوة السابقة. يحتوي مكوّن Player هذا على مكوّنَين ثانويَين، وهما SpriteComponent الذي من المفترض أن تعرفه وCustomPainterComponent الذي هو جديد. تم استلهام مفهوم CustomPainter من Flutter، وهو يتيح لك الرسم على لوحة. يتم استخدامه هنا لتقديم ملاحظات إلى اللاعب حول المكان الذي سيطير إليه الكائن الفضائي المستدير عند رميه.

كيف يبدأ اللاعب عملية رمي الكائن الفضائي؟ باستخدام إيماءة سحب يرصدها مكوّن "المشغّل" من خلال طلبات إعادة الاتصال DragCallbacks. سيلاحظ المتتبّعون الدقيقون شيئًا آخر هنا.

في حين كانت مكونات Ground أجسامًا ثابتة، كانت مكونات Brick أجسامًا ديناميكية. يجمع "المشغّل" بين الاثنين. يبدأ اللاعب بشكل ثابت، في انتظار أن يجرّه اللاعب، وعند رفع إصبع اللاعب عن الشاشة، يتحول اللاعب من ثابت إلى ديناميكي، ويضيف دفعة خطية بما يتناسب مع السحب، ويسمح للشخصية الرمزية الغريبة بالطيران.

هناك أيضًا رمز في مكوّن Player لإزالته من الشاشة إذا خرج عن الحدود أو توقّف عن العمل أو انتهت مهلته. والهدف من ذلك هو السماح للاعب برمي المخلوق الفضائي ومعرفة ما سيحدث، ثم إعادة المحاولة.

أدخِل مكوّن Player في اللعبة من خلال تعديل game.dart على النحو التالي:

lib/components/game.dart

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

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

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

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

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

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

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

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

   
return super.onLoad();
 
}

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

 
final _random = Random();

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

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

 
@override
 
void update(double dt) {
   
super.update(dt);
   
if (isMounted && world.children.whereType<Player>().isEmpty) {
     
addPlayer();
   
}
 
}                                                        // To here.
}

تشبه إضافة اللاعب إلى اللعبة المكونات السابقة، مع إضافة واحدة إضافية. تم تصميم شخصية اللاعب الغريبة لإزالة نفسها من اللعبة في ظل ظروف معيّنة، لذا هناك معالِج تعديل يتحقّق مما إذا كان هناك مكوّن Player في اللعبة، وإذا كان الأمر كذلك، يضيف مكوّنًا آخر. يظهر تشغيل اللعبة على النحو التالي.

نافذة تطبيق تظهر فيها تلال خضراء في الخلفية وطبقة أرضية وكتل على الأرض وصورة رمزية للاعب أثناء الطيران

7. التفاعل مع التأثير

إضافة الأعداء

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

أنشئ ملف enemy.dart في الدليل lib/components وأضِف ما يلي:

lib/components/enemy.dart

import 'dart:math';

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

import 'body_component_with_user_data.dart';

const enemySize = 5.0;

enum EnemyColor {
 
pink(color: 'pink', boss: false),
 
blue(color: 'blue', boss: false),
 
green(color: 'green', boss: false),
 
yellow(color: 'yellow', boss: false),
 
pinkBoss(color: 'pink', boss: true),
 
blueBoss(color: 'blue', boss: true),
 
greenBoss(color: 'green', boss: true),
 
yellowBoss(color: 'yellow', boss: true);

 
final bool boss;
 
final String color;

 
const EnemyColor({required this.color, required this.boss});

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

 
String get fileName =>
     
'alien${color.capitalize}_${boss ? 'suit' : 'square'}.png';
}

class Enemy extends BodyComponentWithUserData with ContactCallbacks {
 
Enemy(Vector2 position, Sprite sprite)
   
: super(
       
renderBody: false,
       
bodyDef: BodyDef()
         
..position = position
         
..type = BodyType.dynamic,
       
fixtureDefs: [
         
FixtureDef(
           
PolygonShape()..setAsBoxXY(enemySize / 2, enemySize / 2),
           
friction: 0.3,
         
),
       
],
       
children: [
         
SpriteComponent(
           
anchor: Anchor.center,
           
sprite: sprite,
           
size: Vector2.all(enemySize),
           
position: Vector2(0, 0),
         
),
       
],
     
);

 
@override
 
void beginContact(Object other, Contact contact) {
   
var interceptVelocity =
       
(contact.bodyA.linearVelocity - contact.bodyB.linearVelocity).length
           
.abs();
   
if (interceptVelocity > 35) {
     
removeFromParent();
   
}

   
super.beginContact(other, contact);
 
}

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

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

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

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

lib/components/body_component_with_user_data.dart

import 'package:flame_forge2d/flame_forge2d.dart';

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

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

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

lib/components/ground.dart

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

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

const groundSize = 7.0;

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

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

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

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

lib/components/game.dart

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

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

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

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

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

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

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

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

   
return super.onLoad();
 
}

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

 
final _random = Random();

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

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

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

 
var enemiesFullyAdded = false;

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

يتمثل التحدي الذي عليك قبوله في تشغيل اللعبة والوصول إلى هذه الشاشة.

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

8. تهانينا

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

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

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

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

مراجع إضافية