إنشاء لعبة ثنائية الأبعاد تستند إلى قوانين الفيزياء باستخدام Flutter وFlame
لمحة عن هذا الدرس التطبيقي حول الترميز
1. قبل البدء
Flame هو محرك ألعاب ثنائي الأبعاد يستند إلى Flutter. في هذا الدليل التعليمي حول البرمجة، يمكنك إنشاء لعبة تستخدِم محاكاة ثنائية الأبعاد لقوانين الفيزياء على غرار Box2D وتُسمّى Forge2D. يمكنك استخدام مكوّنات Flame لرسم الواقع المادي المحاكي على الشاشة ليلعب به المستخدمون. عند اكتمال اللعبة، من المفترض أن تظهر على النحو التالي:
المتطلبات الأساسية
- إكمال الدرس التطبيقي مقدمة حول محرّك الألعاب Flame المدمج في برنامج Flutter
ما ستتعرّف عليه
- طريقة عمل أساسيات Forge2D، بدءًا من الأنواع المختلفة للأجسام المادية
- كيفية إعداد محاكاة جسدية في رسومات ثنائية الأبعاد
ما تحتاج إليه
برنامج المُجمِّع لهدف التطوير الذي اخترته يعمل هذا الدليل التعليمي على جميع المنصات الستة التي تتوافق مع Flutter. ستحتاج إلى Visual Studio لاستهداف نظام التشغيل Windows، وXcode لاستهداف نظام التشغيل macOS أو iOS، وAndroid Studio لاستهداف نظام التشغيل Android.
2. إنشاء مشروع
إنشاء مشروع Flutter
هناك العديد من الطرق لإنشاء مشروع Flutter. في هذا القسم، ستستخدم سطر الأوامر لتوفير الوقت.
للبدء في ذلك، اتبع الخطوات التالية:
- في سطر الأوامر، أنشئ مشروع Flutter:
$ flutter create --empty forge2d_game Creating project forge2d_game... Resolving dependencies in forge2d_game... (4.7s) Got dependencies in forge2d_game. Wrote 128 files. All done! You can find general documentation for Flutter at: https://docs.flutter.dev/ Detailed API documentation is available at: https://api.flutter.dev/ If you prefer video documentation, consider: https://www.youtube.com/c/flutterdev In order to run your empty application, type: $ cd forge2d_game $ flutter run Your empty application code is in forge2d_game/lib/main.dart.
- عدِّل تبعيات المشروع لإضافة 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 لإخفاء شريط العنوان.
ولإجراء ذلك، اتبع الخطوات التالية:
- أنشئ ملف
bin/modify_macos_config.dart
وأضِف المحتوى التالي:
bin/modify_macos_config.dart
import 'dart:io';
import 'package:xml/xml.dart';
import 'package:xml/xpath.dart';
void main() {
final file = File('macos/Runner/Base.lproj/MainMenu.xib');
var document = XmlDocument.parse(file.readAsStringSync());
document.xpath('//document/objects/window').first
..setAttribute('titlebarAppearsTransparent', 'YES')
..setAttribute('titleVisibility', 'hidden');
document
.xpath('//document/objects/window/windowStyleMask')
.first
.setAttribute('fullSizeContentView', 'YES');
file.writeAsStringSync(document.toString());
}
لا يتوفّر هذا الملف في الدليل lib
لأنّه ليس جزءًا من قاعدة بيانات التشغيل الخاصة باللعبة. وهي أداة سطر أوامر تُستخدَم لتعديل المشروع.
- من الدليل الأساسي للمشروع، شغِّل الأداة على النحو التالي:
dart bin/modify_macos_config.dart
إذا سارت الأمور على ما يرام، لن يُنشئ البرنامج أيّ مخرجات على سطر الأوامر. ومع ذلك، سيتم تعديل ملف الإعداد macos/Runner/Base.lproj/MainMenu.xib
لتشغيل اللعبة بدون شريط عنوان مرئي وشغل لعبة Flame للنافذة بأكملها.
شغِّل اللعبة للتأكّد من أنّ كل شيء يعمل على ما يرام. من المفترض أن تظهر نافذة جديدة تتضمّن خلفية سوداء فارغة فقط.
3. إضافة مواد عرض الصور
إضافة الصور
تحتاج أي لعبة إلى مواد عرض فنية لتتمكّن من طلاء الشاشة بطريقة تُضفي عليها طابعًا ممتعًا. سيستخدم هذا الدليل التعليمي حزمة مواد عرض الفيزياء من 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
الذي نزّلته، ومن المفترض أن يظهر لك ما يلي:
من الدليل PNG/Backgrounds
، انسخ الملفات colored_desert.png
وcolored_grass.png
وcolored_land.png
وcolored_shroom.png
إلى الدليل assets/images
في مشروعك.
تتوفّر أيضًا أوراق صور متحركة. هذه هي مجموعة من صورة بتنسيق PNG وملف XML يصف مكان العثور على الصور الأصغر حجمًا في صورة لوحة الشرائح. تُعدّ جداول الصور المتحركة تقنية لتقليل وقت التحميل من خلال تحميل ملف واحد فقط بدلاً من عشرات، إن لم يكن مئات، من ملفات الصور الفردية.
انسخ spritesheet_aliens.png
وspritesheet_elements.png
وspritesheet_tiles.png
إلى دليل assets/images
في مشروعك. أثناء تواجدك هنا، يمكنك أيضًا نسخ ملفات spritesheet_aliens.xml
وspritesheet_elements.xml
وspritesheet_tiles.xml
إلى دليل assets
في مشروعك. من المفترض أن يبدو مشروعك على النحو التالي.
رسم الخلفية
بعد إضافة مواد عرض الصور إلى مشروعك، حان وقت عرضها على الشاشة. حسنًا، صورة واحدة على الشاشة. ستتوفّر المزيد من المعلومات في الخطوات التالية.
أنشئ ملفًا باسم background.dart
في دليل جديد باسم lib/components
وأضِف المحتوى التالي.
lib/components/background.dart
import 'dart:math';
import 'package:flame/components.dart';
import 'game.dart';
class Background extends SpriteComponent with HasGameReference<MyPhysicsGame> {
Background({required super.sprite})
: super(anchor: Anchor.center, position: Vector2(0, 0));
@override
void onMount() {
super.onMount();
size = Vector2.all(
max(
game.camera.visibleWorldRect.width,
game.camera.visibleWorldRect.height,
),
);
}
}
هذا المكوّن هو SpriteComponent
متخصّص. وهو مسؤول عن عرض إحدى صور الخلفية الأربعة لموقع Kenney.nl الإلكتروني. هناك بعض الافتراضات المبسّطة في هذا الرمز. أولاً، يجب أن تكون الصور مربّعة، مثل الصور الأربعة التي استخدمها "كيني" في الخلفية. السبب الثاني هو أنّ حجم العالم المرئي لن يتغيّر أبدًا، وإلا سيحتاج هذا المكوّن إلى معالجة أحداث تغيير حجم اللعبة. الافتراض الثالث هو أنّ الموضع (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
في مجموعة عشوائية بمرور الوقت. هناك جزءان من هذا الإجراء، الأول هو أنّ الطريقة التي تضيف Brick
s 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.
}
}
يتمثل التحدي الذي عليك قبوله في تشغيل اللعبة والوصول إلى هذه الشاشة.
8. تهانينا
تهانينا، لقد نجحت في إنشاء لعبة باستخدام Flutter وFlame.
لقد أنشأت لعبة باستخدام محرك الألعاب Flame 2D وأدرجتها في حزمة Flutter. لقد استخدمت تأثيرات Flame لإضافة مؤثرات حركة إلى المكونات وإزالتها. لقد استخدمت حِزم Google Fonts وFlutter Animate لجعل اللعبة بأكملها تبدو مصمّمة بشكل جيد.
ما هي الخطوات التالية؟
اطّلِع على بعض هذه الدروس التطبيقية حول الترميز...
- إنشاء واجهات مستخدم من الجيل التالي في Flutter
- تحويل تطبيقك المطوَّر باستخدام Flutter من تطبيق ممل إلى تطبيق جميل
- إضافة ميزة الشراء داخل التطبيق إلى تطبيق Flutter