1. قبل البدء
Flame هو محرّك ألعاب ثنائي الأبعاد يستند إلى Flutter. في هذا الدرس التطبيقي حول الترميز، ستنشئ لعبة تستخدم محاكاة فيزياء ثنائية الأبعاد على غرار Box2D باسم Forge2D. يمكنك استخدام مكونات Flame لرسم محاكاة للواقع الفعلي على الشاشة ليتمكّن المستخدمون من اللعب بها. عند الانتهاء، يجب أن تبدو لعبتك على النحو التالي المتحرّك GIF:
المتطلبات الأساسية
- إكمال الدرس التطبيقي حول الترميز Introduction to Flame with Flutter
المعلومات التي تطّلع عليها
- كيف تعمل أساسيات Forge2D، بدءًا من الأنواع المختلفة من الأجسام المادية.
- كيفية إعداد محاكاة فعلية في الوضع الثنائي الأبعاد.
ما تحتاج إليه
برنامج التحويل البرمجي لهدف التطوير الذي اخترته يتوافق هذا الدرس التطبيقي حول الترميز مع جميع الأنظمة الأساسية الست المتوافقة مع Flutter. ويجب استخدام Visual Studio لاستهداف أنظمة التشغيل Windows وXcode لاستهداف أنظمة التشغيل macOS أو iOS، وكذلك استهداف Android Studio.
2. إنشاء مشروع
إنشاء مشروعك على Flutter
تتوفّر عدة طرق لإنشاء مشروع Flutter. في هذا القسم، يمكنك استخدام سطر الأوامر للإيجاز.
للبدء في ذلك، اتبع الخطوات التالية:
- في سطر الأوامر، أنشِئ مشروعًا على Flutter:
$ flutter create --empty forge2d_game Creating project forge2d_game... Resolving dependencies in forge2d_game... (4.7s) Got dependencies in forge2d_game. Wrote 128 files. All done! You can find general documentation for Flutter at: https://docs.flutter.dev/ Detailed API documentation is available at: https://api.flutter.dev/ If you prefer video documentation, consider: https://www.youtube.com/c/flutterdev In order to run your empty application, type: $ cd forge2d_game $ flutter run Your empty application code is in forge2d_game/lib/main.dart.
- قم بتعديل تبعيات المشروع لإضافة Flame وForge2D:
$ cd forge2d_game $ flutter pub add characters flame flame_forge2d flame_kenney_xml xml Resolving dependencies... Downloading packages... characters 1.3.0 (from transitive dependency to direct dependency) collection 1.18.0 (1.19.0 available) + flame 1.18.0 + flame_forge2d 0.18.1 + flame_kenney_xml 0.1.0 flutter_lints 3.0.2 (4.0.0 available) + forge2d 0.13.0 leak_tracker 10.0.4 (10.0.5 available) leak_tracker_flutter_testing 3.0.3 (3.0.5 available) lints 3.0.0 (4.0.0 available) material_color_utilities 0.8.0 (0.12.0 available) meta 1.12.0 (1.15.0 available) + ordered_set 5.0.3 (6.0.1 available) + petitparser 6.0.2 test_api 0.7.0 (0.7.3 available) vm_service 14.2.1 (14.2.4 available) + xml 6.5.0 Changed 8 dependencies! 10 packages have newer versions incompatible with dependency constraints. Try `flutter pub outdated` for more information.
حزمة flame
مألوفة لك، ولكن قد تحتاج الحزم الثلاثة الأخرى إلى بعض التفسير. تُستخدم الحزمة characters
لمعالجة مسار الملف بطريقة تتوافق مع UTF8. تعرض حزمة flame_forge2d
وظيفة Forge2D بطريقة تعمل بشكلٍ جيد مع Flame. أخيرًا، يتم استخدام حزمة xml
في أماكن مختلفة لاستخدام محتوى XML وتعديله.
افتح المشروع، ثم استبدِل محتوى ملف lib/main.dart
بما يلي:
lib/main.dart
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
void main() {
runApp(
GameWidget.controlled(
gameFactory: FlameGame.new,
),
);
}
يؤدي ذلك إلى بدء التطبيق باستخدام GameWidget
الذي ينشئ مثيلاً من FlameGame
. لا يتضمّن هذا الدرس التطبيقي حول الترميز أي رموز Flutter تستخدم حالة مثيل اللعبة لعرض معلومات حول اللعبة قيد التشغيل، لذا فإنّ هذا التمرين المبسّط يعمل بشكل جيد.
اختياري: تنفيذ مَهمّة جانبية على نظام التشغيل macOS فقط
لقطات الشاشة في هذا المشروع مأخوذة من اللعبة كتطبيق سطح مكتب لنظام التشغيل macOS. لتجنّب تشتيت شريط عناوين التطبيق من التجربة الكلية، يمكنك تعديل إعدادات المشروع في متسابق macOS لإزالة شريط العناوين.
ولإجراء ذلك، اتبع الخطوات التالية:
- أنشئ ملف
bin/modify_macos_config.dart
وأضِف المحتوى التالي:
bin/modify_macos_config.dart
import 'dart:io';
import 'package:xml/xml.dart';
import 'package:xml/xpath.dart';
void main() {
final file = File('macos/Runner/Base.lproj/MainMenu.xib');
var document = XmlDocument.parse(file.readAsStringSync());
document.xpath('//document/objects/window').first
..setAttribute('titlebarAppearsTransparent', 'YES')
..setAttribute('titleVisibility', 'hidden');
document
.xpath('//document/objects/window/windowStyleMask')
.first
.setAttribute('fullSizeContentView', 'YES');
file.writeAsStringSync(document.toString());
}
هذا الملف غير متوفّر في دليل lib
لأنّه ليس جزءًا من قاعدة الرموز الخاصة باللعبة في بيئة التشغيل. وهو أداة سطر أوامر تُستخدم لتعديل المشروع.
- من الدليل الأساسي للمشروع، شغِّل الأداة على النحو التالي:
$ dart bin/modify_macos_config.dart
وإذا سارت الأمور على النحو المطلوب، فلن ينشئ البرنامج أي ناتج في سطر الأوامر. في المقابل، سيتم تعديل ملف الإعداد "macos/Runner/Base.lproj/MainMenu.xib
" لتشغيل اللعبة بدون شريط عنوان مرئي، وشغل لعبة Flame بالنافذة بأكملها.
شغِّل اللعبة للتأكّد من أنّ كل شيء يعمل على ما يرام. من المفترض أن تعرض نافذة جديدة بخلفية سوداء فارغة فقط.
3- إضافة مواد عرض صور
إضافة الصور
تحتاج أي لعبة إلى مواد عرض فنية كي تتمكن من رسم شاشة بطريقة مرحة. سيستخدم هذا الدرس التطبيقي حول الترميز حزمة Physics Assets من Kenney.nl. إنّ مواد العرض هذه حاصلة على ترخيص من مؤسسة المشاع الإبداعي CC0، إلا أنّني ما زلت أقترح على الفريق في "كيني" تبرّعه ليتمكن من مواصلة العمل الرائع الذي يؤديه. لقد فعلتها.
وستحتاج إلى تعديل ملف الإعداد pubspec.yaml
لتفعيل استخدام مواد العرض الخاصة بكيني. عدّله على النحو التالي:
pubspec.yaml
name: forge2d_game
description: "A new Flutter project."
publish_to: 'none'
version: 0.1.0
environment:
sdk: '>=3.3.3 <4.0.0'
dependencies:
characters: ^1.3.0
flame: ^1.17.0
flame_forge2d: ^0.18.0
flutter:
sdk: flutter
xml: ^6.5.0
dev_dependencies:
flutter_lints: ^3.0.0
flutter_test:
sdk: flutter
flutter:
uses-material-design: true
assets: # Add from here
- assets/
- assets/images/ # To here.
من المتوقّع أن تكون مواد عرض الصور في assets/images
، ولكن يمكن ضبطها بشكل مختلف. يمكنك الاطّلاع على مستندات الصور في موقع Flame للحصول على مزيد من التفاصيل. الآن بعد أن أعددت المسارات، عليك إضافتها إلى المشروع نفسه. ويتم ذلك عن طريق استخدام سطر الأوامر على النحو التالي:
$ mkdir -p assets/images
من المفترض ألا تظهر أي نتائج من الأمر mkdir
، ولكن يجب أن يكون الدليل الجديد مرئيًا في المحرّر أو مستكشف الملفات.
وسِّع ملف kenney_physics-assets.zip
الذي نزّلته، ومن المفترض أن يظهر لك على النحو التالي:
من الدليل 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
متخصص. إنه مسؤول عن عرض إحدى صور الخلفية الأربع لـ Kinney.nl. هناك بعض الافتراضات المبسّطة في هذه التعليمة البرمجية. الأول هو أن الصور مربعة، وهي صور الخلفية الأربع من كيني. والثاني هو أن حجم العالم المرئي لن يتغير أبدًا، وإلا سيحتاج هذا المكوِّن إلى التعامل مع أحداث تغيير حجم اللعبة. الافتراض الثالث هو أن الموضع (0,0) سيكون في وسط الشاشة. تتطلّب هذه الافتراضات ضبط CameraComponent
للعبة معيّنة.
أنشِئ ملفًا جديدًا آخر، باسم game.dart
، مرة أخرى في دليل lib/components
.
lib/components/game.dart
import 'dart:async';
import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flame_kenney_xml/flame_kenney_xml.dart';
import 'background.dart';
class MyPhysicsGame extends Forge2DGame {
MyPhysicsGame()
: super(
gravity: Vector2(0, 10),
camera: CameraComponent.withFixedResolution(width: 800, height: 600),
);
late final XmlSpriteSheet aliens;
late final XmlSpriteSheet elements;
late final XmlSpriteSheet tiles;
@override
FutureOr<void> onLoad() async {
final backgroundImage = await images.load('colored_grass.png');
final spriteSheets = await Future.wait([
XmlSpriteSheet.load(
imagePath: 'spritesheet_aliens.png',
xmlPath: 'spritesheet_aliens.xml',
),
XmlSpriteSheet.load(
imagePath: 'spritesheet_elements.png',
xmlPath: 'spritesheet_elements.xml',
),
XmlSpriteSheet.load(
imagePath: 'spritesheet_tiles.png',
xmlPath: 'spritesheet_tiles.xml',
),
]);
aliens = spriteSheets[0];
elements = spriteSheets[1];
tiles = spriteSheets[2];
await world.add(Background(sprite: Sprite(backgroundImage)));
return super.onLoad();
}
}
يحدث الكثير من الأحداث هنا. لنبدأ بالفئة MyPhysicsGame
. على عكس الدرس التطبيقي السابق حول الترميز، يمتد نطاق هذا المسار إلى Forge2DGame
وليس FlameGame
. يعمل Forge2DGame
بحد ذاته على توسيع FlameGame
من خلال بعض التعديلات المثيرة للاهتمام. الطريقة الأولى هي ضبط قيمة zoom
تلقائيًا على 10. يرتبط إعداد zoom
هذا بمجموعة القيم المفيدة التي تتوافق معها محرّكات محاكاة الفيزياء في نمط Box2D
بشكل جيد. تتم كتابة المحرك باستخدام نظام MKS حيث يتم الافتراض أن الوحدات بالأمتار والكيلوجرامات والثواني. يتراوح النطاق الذي لا ترى فيه أخطاء رياضية ملحوظة للأجسام من 0.1 متر إلى 10 أمتار. قد يؤدي تطبيق أبعاد البكسل مباشرةً بدون أي مستوى من الضبط الأدنى إلى جعل Forge2D خارج نطاق البيانات المفيدة. الملخص المفيد هو التفكير في محاكاة الأغراض الموجودة في نطاق المياه الغازية التي يمكن أن تصل إلى حافلة.
يتمّ تطبيق الافتراضات الواردة في مكوِّن الخلفية هنا من خلال تثبيت درجة دقة CameraComponent
على 800 × 600 بكسل افتراضي. وهذا يعني أن منطقة الألعاب ستكون بعرض 80 وحدة وطول 60 وحدة وتوسيطها عند (0,0). ولا يؤثّر ذلك في درجة الدقة المعروضة، ولكنّه سيؤثّر في مكان وضع العناصر في مشهد اللعبة.
إلى جانب وسيطة الدالة الإنشائية camera
، توجد وسيطة أخرى متوافقة مع الفيزياء باسم gravity
. تم ضبط قيمة الجاذبية على Vector2
مع قيمة x
على 0 وy
من 10. الرقم 10 هو تقريب تقريبي لقيمة الجاذبية المقبولة عمومًا وهي 9.81 متر في الثانية. حقيقة تعيين الجاذبية على الموجب 10 يدل على أن اتجاه المحور ص في هذا النظام منخفض. وهو ما يختلف عن Box2D بشكل عام، ولكنه يتسق مع كيفية ضبط Flame عادةً.
الطريقة التالية هي طريقة onLoad
. هذه الطريقة غير متزامنة، وهي مناسبة لأنها مسؤولة عن تحميل مواد عرض الصور من القرص. في طلبات استدعاء images.load
، يتم عرض Future<Image>
، وكنتيجة جانبية، يتم عرض الصورة التي تم تحميلها في كائن "اللعبة" في ذاكرة التخزين المؤقت. يتم جمع هذه العقود الآجلة معًا وينتظرها كوحدة واحدة باستخدام طريقة Futures.wait
الثابتة. ثم تتم مطابقة قائمة الصور المعروضة مع الأسماء الفردية.
يتم بعد ذلك تغذية صور ورقة الرموز المتحركة في سلسلة من كائنات XmlSpriteSheet
المسؤولة عن استرداد الرموز التعبيرية المُسماة بشكل فردي والواردة في ورقة الرموز. يتم تحديد الفئة XmlSpriteSheet
في الحزمة flame_kenney_xml
.
مع كل هذه التفاصيل، ما عليك سوى إجراء تعديلات طفيفة على "lib/main.dart
" لعرض الصورة على الشاشة.
lib/main.dart
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'components/game.dart'; // Add this import
void main() {
runApp(
GameWidget.controlled(
gameFactory: MyPhysicsGame.new, // Modify this line
),
);
}
من خلال إجراء هذا التغيير البسيط، يمكنك الآن تشغيل اللعبة مرة أخرى لرؤية الخلفية على الشاشة. جدير بالذكر أنّ مثيل الكاميرا في CameraComponent.withFixedResolution()
سيضيف أشرطة أفقية على النحو المطلوب لضمان عرض محتوى اللعبة بنسبة عرض إلى ارتفاع 800 x 600.
4. إضافة الأرض
شيء يمكن البناء عليه
إذا كانت هناك قوة الجاذبية، نحتاج إلى شيء لالتقاط الأشياء في اللعبة قبل أن تسقط من أسفل الشاشة. بالطبع، ما لم يكن السقوط عن الشاشة جزءًا من تصميم لعبتك. أنشئ ملف ground.dart
جديدًا في دليل lib/components
وأضِف ما يلي إليه:
lib/components/ground.dart
import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
const groundSize = 7.0;
class Ground extends BodyComponent {
Ground(Vector2 position, Sprite sprite)
: super(
renderBody: false,
bodyDef: BodyDef()
..position = position
..type = BodyType.static,
fixtureDefs: [
FixtureDef(
PolygonShape()..setAsBoxXY(groundSize / 2, groundSize / 2),
friction: 0.3,
)
],
children: [
SpriteComponent(
anchor: Anchor.center,
sprite: sprite,
size: Vector2.all(groundSize),
position: Vector2(0, 0),
),
],
);
}
يشتق المكوِّن Ground
من BodyComponent
. تكون الأجسام في Forge2D مهمة، فهي الكائنات التي تشكل جزءًا من المحاكاة المادية الثنائية الأبعاد. تم تحديد BodyDef
لهذا المكوِّن على BodyType.static
.
في Forge2D، تحتوي النصوص على ثلاثة أنواع مختلفة. لا تتحرك النصوص الثابتة. وكلاهما يرمز إلى الكتلة الصفرية - لا يستجيبان للجاذبية - والكتلة اللانهائية - ولا تتحرك عند اصطدامها بأشياء أخرى، مهما كانت وزنها. وهذا يجعل الأجسام الثابتة مثالية لسطح الأرض، لأنها لا تتحرك.
النوعان الآخران من الأجسام هما الحركة والديناميكية. الأجسام الديناميكية هي أجسام تمت محاكاتها بالكامل، وتتفاعل مع الجاذبية والأجسام التي تصطدم بها. ستظهر لك العديد من النصوص الديناميكية في باقي هذا الدرس التطبيقي حول الترميز. الأجسام الحركية هي في منتصف الطريق بين الثابتة والديناميكية. وهي تتحرك، لكنها لا تستجيب للجاذبية أو الأجسام الأخرى التي تضربها. هذا الأمر مفيد، ولكن خارج نطاق هذا الدرس التطبيقي حول الترميز.
الجسم نفسه لا يفعل الكثير. يحتاج الجسم إلى أشكال مرتبطة لامتلاك مادة. في هذه الحالة، يكون لهذا النص الأساسي شكل واحد مرتبط، وتم ضبط PolygonShape
على أنّه BoxXY
. هذا النوع من الصناديق هو محور تتم محاذاته مع العالم، على عكس PolygonShape
الذي يتم ضبطه كـ BoxXY
والذي يمكن تدويره حول نقطة دوران. وهي مفيدة أيضًا، ولكنها أيضًا خارج نطاق هذا الدرس التطبيقي حول الترميز. يتم ربط الشكل والهيكل معًا بتركيب، وهو أمر مفيد لإضافة أشياء مثل friction
إلى النظام.
بشكل تلقائي، يعرض الجسم الأشكال المرفقة به بطريقة مفيدة في تصحيح الأخطاء، إلا أنّها لا تساعد على تقديم أسلوب لعب رائع. ويؤدي ضبط الوسيطة super
renderBody
على false
إلى إيقاف عرض تصحيح الأخطاء هذا. تقع مسؤولية عرض المحتوى داخل اللعبة على عاتق الطفل "SpriteComponent
".
لإضافة المكوِّن Ground
إلى اللعبة، عدِّل ملف game.dart
على النحو التالي.
lib/components/game.dart
import 'dart:async';
import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flame_kenney_xml/flame_kenney_xml.dart';
import 'background.dart';
import 'ground.dart'; // Add this import
class MyPhysicsGame extends Forge2DGame {
MyPhysicsGame()
: super(
gravity: Vector2(0, 10),
camera: CameraComponent.withFixedResolution(width: 800, height: 600),
);
late final XmlSpriteSheet aliens;
late final XmlSpriteSheet elements;
late final XmlSpriteSheet tiles;
@override
FutureOr<void> onLoad() async {
final backgroundImage = await images.load('colored_grass.png');
final spriteSheets = await Future.wait([
XmlSpriteSheet.load(
imagePath: 'spritesheet_aliens.png',
xmlPath: 'spritesheet_aliens.xml',
),
XmlSpriteSheet.load(
imagePath: 'spritesheet_elements.png',
xmlPath: 'spritesheet_elements.xml',
),
XmlSpriteSheet.load(
imagePath: 'spritesheet_tiles.png',
xmlPath: 'spritesheet_tiles.xml',
),
]);
aliens = spriteSheets[0];
elements = spriteSheets[1];
tiles = spriteSheets[2];
await world.add(Background(sprite: Sprite(backgroundImage)));
await addGround(); // Add this line
return super.onLoad();
}
Future<void> addGround() { // Add from here...
return world.addAll([
for (var x = camera.visibleWorldRect.left;
x < camera.visibleWorldRect.right + groundSize;
x += groundSize)
Ground(
Vector2(x, (camera.visibleWorldRect.height - groundSize) / 2),
tiles.getSprite('grass.png'),
),
]);
} // To here.
}
يضيف هذا التعديل سلسلة من مكونات Ground
إلى العالم من خلال استخدام حلقة for
داخل سياق List
وتمرير القائمة الناتجة لمكونات Ground
إلى طريقة addAll
في world
.
تعرض اللعبة الآن الخلفية والأرض.
5- إضافة الطوب
بناء جدار
أعطتنا الأرض مثالاً على جسم ثابت. الآن حان الوقت للمكون الديناميكي الأول. وتشكّل المكونات الديناميكية في لعبة Forge2D حجر الأساس في تجربة اللعب، فهي الأشياء التي تتحرك وتتفاعل مع العالم من حوله. في هذه الخطوة، ستتعرّف على ميزة "الطوب"، والتي سيتم اختيارها عشوائيًا لتظهر على الشاشة في مجموعة من الطوب. ستراهما يسقطان ويلتقيان ببعضهما البعض أثناء حدوث ذلك.
سيتم صنع الطوب من ورقة الرموز المتحركة للعناصر. إذا نظرت إلى وصف ورقة الرموز المتحركة في assets/spritesheet_elements.xml
، فسترى أن لدينا مشكلة مثيرة للاهتمام. لا يبدو أن الأسماء مفيدة جدًا. ما قد يكون مفيدًا هو تحديد الطوب حسب نوع المادة وحجمها ومقدار التلف. لحسن الحظ، قضى قزم مفيد بعض الوقت لمعرفة النمط في تسمية الملفات وأنشأ أداة لتسهيل الأمر على الجميع. أنشِئ ملفًا جديدًا باسم "generate_brick_file_names.dart
" في الدليل bin
وأضِف المحتوى التالي:
bin/generate_brick_file_names.dart
import 'dart:io';
import 'package:equatable/equatable.dart';
import 'package:xml/xml.dart';
import 'package:xml/xpath.dart';
void main() {
final file = File('assets/spritesheet_elements.xml');
final rects = <String, Rect>{};
final document = XmlDocument.parse(file.readAsStringSync());
for (final node in document.xpath('//TextureAtlas/SubTexture')) {
final name = node.getAttribute('name')!;
rects[name] = Rect(
x: int.parse(node.getAttribute('x')!),
y: int.parse(node.getAttribute('y')!),
width: int.parse(node.getAttribute('width')!),
height: int.parse(node.getAttribute('height')!),
);
}
print(generateBrickFileNames(rects));
}
class Rect extends Equatable {
final int x;
final int y;
final int width;
final int height;
const Rect(
{required this.x,
required this.y,
required this.width,
required this.height});
Size get size => Size(width, height);
@override
List<Object?> get props => [x, y, width, height];
@override
bool get stringify => true;
}
class Size extends Equatable {
final int width;
final int height;
const Size(this.width, this.height);
@override
List<Object?> get props => [width, height];
@override
bool get stringify => true;
}
String generateBrickFileNames(Map<String, Rect> rects) {
final groups = <Size, List<String>>{};
for (final entry in rects.entries) {
groups.putIfAbsent(entry.value.size, () => []).add(entry.key);
}
final buff = StringBuffer();
buff.writeln('''
Map<BrickDamage, String> brickFileNames(BrickType type, BrickSize size) {
return switch ((type, size)) {''');
for (final entry in groups.entries) {
final size = entry.key;
final entries = entry.value;
entries.sort();
for (final type in ['Explosive', 'Glass', 'Metal', 'Stone', 'Wood']) {
var filtered = entries.where((element) => element.contains(type));
if (filtered.length == 5) {
buff.writeln('''
(BrickType.${type.toLowerCase()}, BrickSize.size${size.width}x${size.height}) => {
BrickDamage.none: '${filtered.elementAt(0)}',
BrickDamage.some: '${filtered.elementAt(1)}',
BrickDamage.lots: '${filtered.elementAt(4)}',
},''');
} else if (filtered.length == 10) {
buff.writeln('''
(BrickType.${type.toLowerCase()}, BrickSize.size${size.width}x${size.height}) => {
BrickDamage.none: '${filtered.elementAt(3)}',
BrickDamage.some: '${filtered.elementAt(4)}',
BrickDamage.lots: '${filtered.elementAt(9)}',
},''');
} else if (filtered.length == 15) {
buff.writeln('''
(BrickType.${type.toLowerCase()}, BrickSize.size${size.width}x${size.height}) => {
BrickDamage.none: '${filtered.elementAt(7)}',
BrickDamage.some: '${filtered.elementAt(8)}',
BrickDamage.lots: '${filtered.elementAt(13)}',
},''');
}
}
}
buff.writeln('''
};
}''');
return buff.toString();
}
من المفترض أن يعرض لك المحرِّر تحذيرًا أو خطأ بشأن عدم توفُّر إحدى التبعيات. يمكنك إضافته على النحو التالي:
$ flutter pub add equatable
ومن المفترض أن تتمكّن الآن من تشغيل هذا البرنامج على النحو التالي:
$ dart run bin/generate_brick_file_names.dart Map<BrickDamage, String> brickFileNames(BrickType type, BrickSize size) { return switch ((type, size)) { (BrickType.explosive, BrickSize.size140x70) => { BrickDamage.none: 'elementExplosive009.png', BrickDamage.some: 'elementExplosive012.png', BrickDamage.lots: 'elementExplosive050.png', }, (BrickType.glass, BrickSize.size140x70) => { BrickDamage.none: 'elementGlass010.png', BrickDamage.some: 'elementGlass013.png', BrickDamage.lots: 'elementGlass048.png', }, [Content elided...] (BrickType.wood, BrickSize.size140x220) => { BrickDamage.none: 'elementWood020.png', BrickDamage.some: 'elementWood025.png', BrickDamage.lots: 'elementWood052.png', }, }; }
قامت هذه الأداة بتحليل ملف وصف ورقة الرموز المتحركة بشكل مفيد وتحويله إلى رمز Dart يمكننا استخدامه لتحديد ملف الصورة المناسب لكل جزء من الطوب تريد عرضه على الشاشة. مفيد!
أنشئ ملف brick.dart
الذي يتضمّن المحتوى التالي:
lib/components/brick.dart
import 'dart:math';
import 'dart:ui' as ui;
import 'package:flame/components.dart';
import 'package:flame/extensions.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
const brickScale = 0.5;
enum BrickType {
explosive(density: 1, friction: 0.5),
glass(density: 0.5, friction: 0.2),
metal(density: 1, friction: 0.4),
stone(density: 2, friction: 1),
wood(density: 0.25, friction: 0.6);
final double density;
final double friction;
const BrickType({required this.density, required this.friction});
static BrickType get randomType => values[Random().nextInt(values.length)];
}
enum BrickSize {
size70x70(ui.Size(70, 70)),
size140x70(ui.Size(140, 70)),
size220x70(ui.Size(220, 70)),
size70x140(ui.Size(70, 140)),
size140x140(ui.Size(140, 140)),
size220x140(ui.Size(220, 140)),
size140x220(ui.Size(140, 220)),
size70x220(ui.Size(70, 220));
final ui.Size size;
const BrickSize(this.size);
static BrickSize get randomSize => values[Random().nextInt(values.length)];
}
enum BrickDamage { none, some, lots }
Map<BrickDamage, String> brickFileNames(BrickType type, BrickSize size) {
return switch ((type, size)) {
(BrickType.explosive, BrickSize.size140x70) => {
BrickDamage.none: 'elementExplosive009.png',
BrickDamage.some: 'elementExplosive012.png',
BrickDamage.lots: 'elementExplosive050.png',
},
(BrickType.glass, BrickSize.size140x70) => {
BrickDamage.none: 'elementGlass010.png',
BrickDamage.some: 'elementGlass013.png',
BrickDamage.lots: 'elementGlass048.png',
},
(BrickType.metal, BrickSize.size140x70) => {
BrickDamage.none: 'elementMetal009.png',
BrickDamage.some: 'elementMetal012.png',
BrickDamage.lots: 'elementMetal050.png',
},
(BrickType.stone, BrickSize.size140x70) => {
BrickDamage.none: 'elementStone009.png',
BrickDamage.some: 'elementStone012.png',
BrickDamage.lots: 'elementStone047.png',
},
(BrickType.wood, BrickSize.size140x70) => {
BrickDamage.none: 'elementWood011.png',
BrickDamage.some: 'elementWood014.png',
BrickDamage.lots: 'elementWood054.png',
},
(BrickType.explosive, BrickSize.size70x70) => {
BrickDamage.none: 'elementExplosive011.png',
BrickDamage.some: 'elementExplosive014.png',
BrickDamage.lots: 'elementExplosive049.png',
},
(BrickType.glass, BrickSize.size70x70) => {
BrickDamage.none: 'elementGlass011.png',
BrickDamage.some: 'elementGlass012.png',
BrickDamage.lots: 'elementGlass046.png',
},
(BrickType.metal, BrickSize.size70x70) => {
BrickDamage.none: 'elementMetal011.png',
BrickDamage.some: 'elementMetal014.png',
BrickDamage.lots: 'elementMetal049.png',
},
(BrickType.stone, BrickSize.size70x70) => {
BrickDamage.none: 'elementStone011.png',
BrickDamage.some: 'elementStone014.png',
BrickDamage.lots: 'elementStone046.png',
},
(BrickType.wood, BrickSize.size70x70) => {
BrickDamage.none: 'elementWood010.png',
BrickDamage.some: 'elementWood013.png',
BrickDamage.lots: 'elementWood045.png',
},
(BrickType.explosive, BrickSize.size220x70) => {
BrickDamage.none: 'elementExplosive013.png',
BrickDamage.some: 'elementExplosive016.png',
BrickDamage.lots: 'elementExplosive051.png',
},
(BrickType.glass, BrickSize.size220x70) => {
BrickDamage.none: 'elementGlass014.png',
BrickDamage.some: 'elementGlass017.png',
BrickDamage.lots: 'elementGlass049.png',
},
(BrickType.metal, BrickSize.size220x70) => {
BrickDamage.none: 'elementMetal013.png',
BrickDamage.some: 'elementMetal016.png',
BrickDamage.lots: 'elementMetal051.png',
},
(BrickType.stone, BrickSize.size220x70) => {
BrickDamage.none: 'elementStone013.png',
BrickDamage.some: 'elementStone016.png',
BrickDamage.lots: 'elementStone048.png',
},
(BrickType.wood, BrickSize.size220x70) => {
BrickDamage.none: 'elementWood012.png',
BrickDamage.some: 'elementWood015.png',
BrickDamage.lots: 'elementWood047.png',
},
(BrickType.explosive, BrickSize.size70x140) => {
BrickDamage.none: 'elementExplosive017.png',
BrickDamage.some: 'elementExplosive022.png',
BrickDamage.lots: 'elementExplosive052.png',
},
(BrickType.glass, BrickSize.size70x140) => {
BrickDamage.none: 'elementGlass018.png',
BrickDamage.some: 'elementGlass023.png',
BrickDamage.lots: 'elementGlass050.png',
},
(BrickType.metal, BrickSize.size70x140) => {
BrickDamage.none: 'elementMetal017.png',
BrickDamage.some: 'elementMetal022.png',
BrickDamage.lots: 'elementMetal052.png',
},
(BrickType.stone, BrickSize.size70x140) => {
BrickDamage.none: 'elementStone017.png',
BrickDamage.some: 'elementStone022.png',
BrickDamage.lots: 'elementStone049.png',
},
(BrickType.wood, BrickSize.size70x140) => {
BrickDamage.none: 'elementWood016.png',
BrickDamage.some: 'elementWood021.png',
BrickDamage.lots: 'elementWood048.png',
},
(BrickType.explosive, BrickSize.size140x140) => {
BrickDamage.none: 'elementExplosive018.png',
BrickDamage.some: 'elementExplosive023.png',
BrickDamage.lots: 'elementExplosive053.png',
},
(BrickType.glass, BrickSize.size140x140) => {
BrickDamage.none: 'elementGlass019.png',
BrickDamage.some: 'elementGlass024.png',
BrickDamage.lots: 'elementGlass051.png',
},
(BrickType.metal, BrickSize.size140x140) => {
BrickDamage.none: 'elementMetal018.png',
BrickDamage.some: 'elementMetal023.png',
BrickDamage.lots: 'elementMetal053.png',
},
(BrickType.stone, BrickSize.size140x140) => {
BrickDamage.none: 'elementStone018.png',
BrickDamage.some: 'elementStone023.png',
BrickDamage.lots: 'elementStone050.png',
},
(BrickType.wood, BrickSize.size140x140) => {
BrickDamage.none: 'elementWood017.png',
BrickDamage.some: 'elementWood022.png',
BrickDamage.lots: 'elementWood049.png',
},
(BrickType.explosive, BrickSize.size220x140) => {
BrickDamage.none: 'elementExplosive019.png',
BrickDamage.some: 'elementExplosive024.png',
BrickDamage.lots: 'elementExplosive054.png',
},
(BrickType.glass, BrickSize.size220x140) => {
BrickDamage.none: 'elementGlass020.png',
BrickDamage.some: 'elementGlass025.png',
BrickDamage.lots: 'elementGlass052.png',
},
(BrickType.metal, BrickSize.size220x140) => {
BrickDamage.none: 'elementMetal019.png',
BrickDamage.some: 'elementMetal024.png',
BrickDamage.lots: 'elementMetal054.png',
},
(BrickType.stone, BrickSize.size220x140) => {
BrickDamage.none: 'elementStone019.png',
BrickDamage.some: 'elementStone024.png',
BrickDamage.lots: 'elementStone051.png',
},
(BrickType.wood, BrickSize.size220x140) => {
BrickDamage.none: 'elementWood018.png',
BrickDamage.some: 'elementWood023.png',
BrickDamage.lots: 'elementWood050.png',
},
(BrickType.explosive, BrickSize.size70x220) => {
BrickDamage.none: 'elementExplosive020.png',
BrickDamage.some: 'elementExplosive025.png',
BrickDamage.lots: 'elementExplosive055.png',
},
(BrickType.glass, BrickSize.size70x220) => {
BrickDamage.none: 'elementGlass021.png',
BrickDamage.some: 'elementGlass026.png',
BrickDamage.lots: 'elementGlass053.png',
},
(BrickType.metal, BrickSize.size70x220) => {
BrickDamage.none: 'elementMetal020.png',
BrickDamage.some: 'elementMetal025.png',
BrickDamage.lots: 'elementMetal055.png',
},
(BrickType.stone, BrickSize.size70x220) => {
BrickDamage.none: 'elementStone020.png',
BrickDamage.some: 'elementStone025.png',
BrickDamage.lots: 'elementStone052.png',
},
(BrickType.wood, BrickSize.size70x220) => {
BrickDamage.none: 'elementWood019.png',
BrickDamage.some: 'elementWood024.png',
BrickDamage.lots: 'elementWood051.png',
},
(BrickType.explosive, BrickSize.size140x220) => {
BrickDamage.none: 'elementExplosive021.png',
BrickDamage.some: 'elementExplosive026.png',
BrickDamage.lots: 'elementExplosive056.png',
},
(BrickType.glass, BrickSize.size140x220) => {
BrickDamage.none: 'elementGlass022.png',
BrickDamage.some: 'elementGlass027.png',
BrickDamage.lots: 'elementGlass054.png',
},
(BrickType.metal, BrickSize.size140x220) => {
BrickDamage.none: 'elementMetal021.png',
BrickDamage.some: 'elementMetal026.png',
BrickDamage.lots: 'elementMetal056.png',
},
(BrickType.stone, BrickSize.size140x220) => {
BrickDamage.none: 'elementStone021.png',
BrickDamage.some: 'elementStone026.png',
BrickDamage.lots: 'elementStone053.png',
},
(BrickType.wood, BrickSize.size140x220) => {
BrickDamage.none: 'elementWood020.png',
BrickDamage.some: 'elementWood025.png',
BrickDamage.lots: 'elementWood052.png',
},
};
}
class Brick extends BodyComponent {
Brick({
required this.type,
required this.size,
required BrickDamage damage,
required Vector2 position,
required Map<BrickDamage, Sprite> sprites,
}) : _damage = damage,
_sprites = sprites,
super(
renderBody: false,
bodyDef: BodyDef()
..position = position
..type = BodyType.dynamic,
fixtureDefs: [
FixtureDef(
PolygonShape()
..setAsBoxXY(
size.size.width / 20 * brickScale,
size.size.height / 20 * brickScale,
),
)
..restitution = 0.4
..density = type.density
..friction = type.friction
]);
late final SpriteComponent _spriteComponent;
final BrickType type;
final BrickSize size;
final Map<BrickDamage, Sprite> _sprites;
BrickDamage _damage;
BrickDamage get damage => _damage;
set damage(BrickDamage value) {
_damage = value;
_spriteComponent.sprite = _sprites[value];
}
@override
Future<void> onLoad() {
_spriteComponent = SpriteComponent(
anchor: Anchor.center,
scale: Vector2.all(1),
sprite: _sprites[_damage],
size: size.size.toVector2() / 10 * brickScale,
position: Vector2(0, 0),
);
add(_spriteComponent);
return super.onLoad();
}
}
يمكنك الآن معرفة كيف تم دمج رمز Dart الذي تم إنشاؤه أعلاه في قاعدة الأكواد هذه لتسهيل اختيار الصور المبنية على الطوب استنادًا إلى المادة والحجم والحالة. بعد الاطّلاع على enum
إلى المكوِّن Brick
نفسه، يُفترض أن يكون معظم هذا الرمز مألوفًا إلى حد ما من المكوِّن Ground
في الخطوة السابقة. هناك حالة قابلة للتغيير تسمح بتلف الطوب، على الرغم من أن استخدام هذا يبقى بمثابة تمرين للقارئ.
حان وقت عرض الطوب على الشاشة. عدِّل ملف game.dart
على النحو التالي:
lib/components/game.dart
import 'dart:async';
import 'dart:math'; // Add this import
import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flame_kenney_xml/flame_kenney_xml.dart';
import 'background.dart';
import 'brick.dart'; // Add this import
import 'ground.dart';
class MyPhysicsGame extends Forge2DGame {
MyPhysicsGame()
: super(
gravity: Vector2(0, 10),
camera: CameraComponent.withFixedResolution(width: 800, height: 600),
);
late final XmlSpriteSheet aliens;
late final XmlSpriteSheet elements;
late final XmlSpriteSheet tiles;
@override
FutureOr<void> onLoad() async {
final backgroundImage = await images.load('colored_grass.png');
final spriteSheets = await Future.wait([
XmlSpriteSheet.load(
imagePath: 'spritesheet_aliens.png',
xmlPath: 'spritesheet_aliens.xml',
),
XmlSpriteSheet.load(
imagePath: 'spritesheet_elements.png',
xmlPath: 'spritesheet_elements.xml',
),
XmlSpriteSheet.load(
imagePath: 'spritesheet_tiles.png',
xmlPath: 'spritesheet_tiles.xml',
),
]);
aliens = spriteSheets[0];
elements = spriteSheets[1];
tiles = spriteSheets[2];
await world.add(Background(sprite: Sprite(backgroundImage)));
await addGround();
unawaited(addBricks()); // Add this line
return super.onLoad();
}
Future<void> addGround() {
return world.addAll([
for (var x = camera.visibleWorldRect.left;
x < camera.visibleWorldRect.right + groundSize;
x += groundSize)
Ground(
Vector2(x, (camera.visibleWorldRect.height - groundSize) / 2),
tiles.getSprite('grass.png'),
),
]);
}
final _random = Random(); // Add from here...
Future<void> addBricks() async {
for (var i = 0; i < 5; i++) {
final type = BrickType.randomType;
final size = BrickSize.randomSize;
await world.add(
Brick(
type: type,
size: size,
damage: BrickDamage.some,
position: Vector2(
camera.visibleWorldRect.right / 3 +
(_random.nextDouble() * 5 - 2.5),
0),
sprites: brickFileNames(type, size).map(
(key, filename) => MapEntry(
key,
elements.getSprite(filename),
),
),
),
);
await Future<void>.delayed(const Duration(milliseconds: 500));
}
} // To here.
}
تختلف إضافة الرمز هذه قليلاً عن الرمز الذي استخدمته لإضافة مكوّنات Ground
. هذه المرة تتم إضافة Brick
في مجموعة عشوائية بمرور الوقت. هناك جزآن، الأول هو: الطريقة التي تضيف await
الخاصة بـ Brick
إلى Future.delayed
، وهي المكافئة غير المتزامنة لاستدعاء sleep()
. ومع ذلك، هناك جزء ثانٍ لإكمال هذا الإجراء، وهو أن استدعاء الدالة addBricks
بطريقة onLoad
لا يؤدي إلى await
. إذا كان الأمر كذلك، فلن تكتمل طريقة onLoad
حتى تظهر جميع الطوب على الشاشة. إنّ إنهاء المكالمة إلى addBricks
في مكالمة unawaited
يُرضي المستخدمين ويوضّح نيّة المبرمجين المستقبليين بالغرض. إنّ عدم انتظار ظهور هذه الطريقة يكون مقصودًا.
شغِّل اللعبة، وسيظهر لك الطوب ويرتطم ببعضهما البعض وينهماب على الأرض.
6- إضافة اللاعب
قذف الكائنات الفضائية على الطوب
قد تكون مشاهدة رمي المكعبات ممتعة في أول مرّتَين، لكنني أعتقد أنّ هذه اللعبة ستكون أكثر إمتاعًا إذا منحنا اللاعب صورة رمزية يمكنه استخدامها للتفاعل مع العالم. ما رأيك في كائن فضائي يمكنه أن يقذف نحو الطوب؟
أنشِئ ملف player.dart
جديدًا في الدليل lib/components
وأضِف ما يلي إليه:
lib/components/player.dart
import 'dart:math';
import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flame/events.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flutter/material.dart';
const playerSize = 5.0;
enum PlayerColor {
pink,
blue,
green,
yellow;
static PlayerColor get randomColor =>
PlayerColor.values[Random().nextInt(PlayerColor.values.length)];
String get fileName =>
'alien${toString().split('.').last.capitalize}_round.png';
}
class Player extends BodyComponent with DragCallbacks {
Player(Vector2 position, Sprite sprite)
: _sprite = sprite,
super(
renderBody: false,
bodyDef: BodyDef()
..position = position
..type = BodyType.static
..angularDamping = 0.1
..linearDamping = 0.1,
fixtureDefs: [
FixtureDef(CircleShape()..radius = playerSize / 2)
..restitution = 0.4
..density = 0.75
..friction = 0.5
],
);
final Sprite _sprite;
@override
Future<void> onLoad() {
addAll([
CustomPainterComponent(
painter: _DragPainter(this),
anchor: Anchor.center,
size: Vector2(playerSize, playerSize),
position: Vector2(0, 0),
),
SpriteComponent(
anchor: Anchor.center,
sprite: _sprite,
size: Vector2(playerSize, playerSize),
position: Vector2(0, 0),
)
]);
return super.onLoad();
}
@override
void update(double dt) {
super.update(dt);
if (!body.isAwake) {
removeFromParent();
}
if (position.x > camera.visibleWorldRect.right + 10 ||
position.x < camera.visibleWorldRect.left - 10) {
removeFromParent();
}
}
Vector2 _dragStart = Vector2.zero();
Vector2 _dragDelta = Vector2.zero();
Vector2 get dragDelta => _dragDelta;
@override
void onDragStart(DragStartEvent event) {
super.onDragStart(event);
if (body.bodyType == BodyType.static) {
_dragStart = event.localPosition;
}
}
@override
void onDragUpdate(DragUpdateEvent event) {
if (body.bodyType == BodyType.static) {
_dragDelta = event.localEndPosition - _dragStart;
}
}
@override
void onDragEnd(DragEndEvent event) {
super.onDragEnd(event);
if (body.bodyType == BodyType.static) {
children
.whereType<CustomPainterComponent>()
.firstOrNull
?.removeFromParent();
body.setType(BodyType.dynamic);
body.applyLinearImpulse(_dragDelta * -50);
add(RemoveEffect(
delay: 5.0,
));
}
}
}
extension on String {
String get capitalize =>
characters.first.toUpperCase() + characters.skip(1).toLowerCase().join();
}
class _DragPainter extends CustomPainter {
_DragPainter(this.player);
final Player player;
@override
void paint(Canvas canvas, Size size) {
if (player.dragDelta != Vector2.zero()) {
var center = size.center(Offset.zero);
canvas.drawLine(
center,
center + (player.dragDelta * -1).toOffset(),
Paint()
..color = Colors.orange.withOpacity(0.7)
..strokeWidth = 0.4
..strokeCap = StrokeCap.round);
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}
هذه خطوة أعلى من مكوّنات Brick
في الخطوة السابقة. يحتوي مكوِّن Player
هذا على مكونَين فرعيَين، هما SpriteComponent
الذي يجب التعرّف عليه، وCustomPainterComponent
جديد. تم تصميم مفهوم CustomPainter
من Flutter، حيث يتيح لك الرسم على لوحة. ويتم استخدام هذه البيانات هنا لتقديم ملاحظات للّاعبين حول الموقع الذي سينتقل إليه المخلوق الغريب المستدير عند سقوطه.
كيف يبدأ اللاعب في القفز بين الكائنات الفضائية؟ استخدام إيماءة سحب، يرصدها مكوِّن المشغّل من خلال استدعاءات DragCallbacks
. لاحظ النسران بينكم أن هناك شيئًا آخر هنا.
في الأماكن التي كانت فيها مكوّنات Ground
هي أشكال ثابتة، كانت مكونات الطوب هي أشكال ديناميكية. أمّا اللاعب هنا، فهو مزيج من الاثنين. يبدأ اللاعب بشكل ثابت، وينتظر حتى أن يسحبه اللاعب، وعند سحبه يتحوّل من ثابت إلى ديناميكي، ويضيف دافعًا خطيًا يتناسب مع السحب ويتيح للكائنات الفضائية التحليق.
ويوجد أيضًا رمز في المكوِّن Player
يمكن إزالته من الشاشة في حال خروجه عن الحدود أو دخوله في حالة السكون أو انتهاء المهلة. والهدف من هذه الخطوة هو السماح للاعب بالهجوم على الكائنات الفضائية ومعرفة ما يحدث، ثم تسديدها مرة أخرى.
ادمج المكوِّن Player
في اللعبة من خلال تعديل game.dart
على النحو التالي:
lib/components/game.dart
import 'dart:async';
import 'dart:math';
import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flame_kenney_xml/flame_kenney_xml.dart';
import 'background.dart';
import 'brick.dart';
import 'ground.dart';
import 'player.dart'; // Add this import
class MyPhysicsGame extends Forge2DGame {
MyPhysicsGame()
: super(
gravity: Vector2(0, 10),
camera: CameraComponent.withFixedResolution(width: 800, height: 600),
);
late final XmlSpriteSheet aliens;
late final XmlSpriteSheet elements;
late final XmlSpriteSheet tiles;
@override
FutureOr<void> onLoad() async {
final backgroundImage = await images.load('colored_grass.png');
final spriteSheets = await Future.wait([
XmlSpriteSheet.load(
imagePath: 'spritesheet_aliens.png',
xmlPath: 'spritesheet_aliens.xml',
),
XmlSpriteSheet.load(
imagePath: 'spritesheet_elements.png',
xmlPath: 'spritesheet_elements.xml',
),
XmlSpriteSheet.load(
imagePath: 'spritesheet_tiles.png',
xmlPath: 'spritesheet_tiles.xml',
),
]);
aliens = spriteSheets[0];
elements = spriteSheets[1];
tiles = spriteSheets[2];
await world.add(Background(sprite: Sprite(backgroundImage)));
await addGround();
unawaited(addBricks());
await addPlayer(); // Add this line
return super.onLoad();
}
Future<void> addGround() {
return world.addAll([
for (var x = camera.visibleWorldRect.left;
x < camera.visibleWorldRect.right + groundSize;
x += groundSize)
Ground(
Vector2(x, (camera.visibleWorldRect.height - groundSize) / 2),
tiles.getSprite('grass.png'),
),
]);
}
final _random = Random();
Future<void> addBricks() async {
for (var i = 0; i < 5; i++) {
final type = BrickType.randomType;
final size = BrickSize.randomSize;
await world.add(
Brick(
type: type,
size: size,
damage: BrickDamage.some,
position: Vector2(
camera.visibleWorldRect.right / 3 +
(_random.nextDouble() * 5 - 2.5),
0),
sprites: brickFileNames(type, size).map(
(key, filename) => MapEntry(
key,
elements.getSprite(filename),
),
),
),
);
await Future<void>.delayed(const Duration(milliseconds: 500));
}
}
Future<void> addPlayer() async => world.add( // Add from here...
Player(
Vector2(camera.visibleWorldRect.left * 2 / 3, 0),
aliens.getSprite(PlayerColor.randomColor.fileName),
),
);
@override
void update(double dt) {
super.update(dt);
if (isMounted && world.children.whereType<Player>().isEmpty) {
addPlayer();
}
} // To here.
}
تشبه إضافة اللاعب إلى اللعبة المكونات السابقة، مع تجعّد إضافي واحد. تم تصميم الكائن الفضائي الخاص باللاعب لكي يزيل نفسه من اللعبة في ظل ظروف معيّنة، لذلك يتوفّر معالِج تحديث يتحقّق من عدم احتواء اللعبة على مكوّن Player
. وفي هذه الحالة، تتم إضافة مكوّن جديد إلى اللعبة. يبدو تشغيل اللعبة على النحو التالي.
7. التفاعل مع الآخرين
إضافة الأعداء
لقد رأيت كائنات ثابتة وديناميكية تتفاعل مع بعضها بعضًا. ومع ذلك، للوصول إلى مكان ما، تحتاج إلى استدعاء استدعاءات في الرمز عندما تتعارض الأشياء. لنرى كيف يتم ذلك. أنت بصدد تقديم بعض الأعداء ليواجههم لاعبك. وهذا يوفر مسارًا لتحقيق حالة الفوز - قم بإزالة جميع الأعداء من اللعبة!
أنشِئ ملف enemy.dart
في الدليل lib/components
وأضِف ما يلي:
lib/components/enemy.dart
import 'dart:math';
import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flutter/material.dart';
import 'body_component_with_user_data.dart';
const enemySize = 5.0;
enum EnemyColor {
pink(color: 'pink', boss: false),
blue(color: 'blue', boss: false),
green(color: 'green', boss: false),
yellow(color: 'yellow', boss: false),
pinkBoss(color: 'pink', boss: true),
blueBoss(color: 'blue', boss: true),
greenBoss(color: 'green', boss: true),
yellowBoss(color: 'yellow', boss: true);
final bool boss;
final String color;
const EnemyColor({required this.color, required this.boss});
static EnemyColor get randomColor =>
EnemyColor.values[Random().nextInt(EnemyColor.values.length)];
String get fileName =>
'alien${color.capitalize}_${boss ? 'suit' : 'square'}.png';
}
class Enemy extends BodyComponentWithUserData with ContactCallbacks {
Enemy(Vector2 position, Sprite sprite)
: super(
renderBody: false,
bodyDef: BodyDef()
..position = position
..type = BodyType.dynamic,
fixtureDefs: [
FixtureDef(
PolygonShape()..setAsBoxXY(enemySize / 2, enemySize / 2),
friction: 0.3,
)
],
children: [
SpriteComponent(
anchor: Anchor.center,
sprite: sprite,
size: Vector2.all(enemySize),
position: Vector2(0, 0),
),
],
);
@override
void beginContact(Object other, Contact contact) {
var interceptVelocity =
(contact.bodyA.linearVelocity - contact.bodyB.linearVelocity)
.length
.abs();
if (interceptVelocity > 35) {
removeFromParent();
}
super.beginContact(other, contact);
}
@override
void update(double dt) {
super.update(dt);
if (position.x > camera.visibleWorldRect.right + 10 ||
position.x < camera.visibleWorldRect.left - 10) {
removeFromParent();
}
}
}
extension on String {
String get capitalize =>
characters.first.toUpperCase() + characters.skip(1).toLowerCase().join();
}
من تفاعلاتك السابقة مع مكوّنات المشغّل والطوب، يجب أن يكون معظم هذا الملف مألوفًا. ومع ذلك، سيكون هناك تسطير بلون أحمر في المحرر بسبب فئة أساسية جديدة غير معروفة. أضِف هذا الصف الآن عن طريق إضافة ملف باسم "body_component_with_user_data.dart
" إلى "lib/components
" يتضمّن المحتوى التالي:
lib/components/body_component_with_user_data.dart
import 'package:flame_forge2d/flame_forge2d.dart';
class BodyComponentWithUserData extends BodyComponent {
BodyComponentWithUserData({
super.key,
super.bodyDef,
super.children,
super.fixtureDefs,
super.paint,
super.priority,
super.renderBody,
});
@override
Body createBody() {
final body = world.createBody(super.bodyDef!)..userData = this;
fixtureDefs?.forEach(body.createFixture);
return body;
}
}
تشكّل هذه الفئة الأساسية، بالإضافة إلى استدعاء beginContact
الجديد في المكوِّن Enemy
، أساس تلقّي الإشعارات آليًا بشأن التأثيرات بين النصوص. في الواقع، ستحتاج إلى تعديل أي مكونات تريد الحصول على إشعارات التأثير بينها. لذا، يمكنك المتابعة وتعديل المكوّنات Brick
وGround
وPlayer
لاستخدام BodyComponentWithUserData
هذه بدلاً من الفئة الأساسية BodyComponent
التي تستخدمها هذه المكوّنات حاليًا. على سبيل المثال، إليك كيفية تعديل المكوِّن Ground
:
lib/components/ground.dart
import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'body_component_with_user_data.dart'; // Add this import
const groundSize = 7.0;
class Ground extends BodyComponentWithUserData { // Edit this line
Ground(Vector2 position, Sprite sprite)
: super(
renderBody: false,
bodyDef: BodyDef()
..position = position
..type = BodyType.static,
fixtureDefs: [
FixtureDef(
PolygonShape()..setAsBoxXY(groundSize / 2, groundSize / 2),
friction: 0.3,
)
],
children: [
SpriteComponent(
anchor: Anchor.center,
sprite: sprite,
size: Vector2.all(groundSize),
position: Vector2(0, 0),
),
],
);
}
لمزيد من المعلومات عن كيفية تعامل Forge2d مع جهة الاتصال، يُرجى الاطّلاع على مستندات Forge2D حول طلبات معاودة الاتصال.
الفوز في اللعبة
الآن بعد أن أصبح لديك الأعداء وطريقة التخلص من الأعداء من العالم، أصبحت هناك طريقة مبسَّطة لتحويل هذه المحاكاة إلى لعبة. اسعَ إلى التخلّص من كل الأعداء. حان الوقت لتعديل ملف game.dart
على النحو التالي:
lib/components/game.dart
import 'dart:async';
import 'dart:math';
import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flame_kenney_xml/flame_kenney_xml.dart';
import 'package:flutter/material.dart'; // Add this import
import 'background.dart';
import 'brick.dart';
import 'enemy.dart'; // Add this import
import 'ground.dart';
import 'player.dart';
class MyPhysicsGame extends Forge2DGame {
MyPhysicsGame()
: super(
gravity: Vector2(0, 10),
camera: CameraComponent.withFixedResolution(width: 800, height: 600),
);
late final XmlSpriteSheet aliens;
late final XmlSpriteSheet elements;
late final XmlSpriteSheet tiles;
@override
FutureOr<void> onLoad() async {
final backgroundImage = await images.load('colored_grass.png');
final spriteSheets = await Future.wait([
XmlSpriteSheet.load(
imagePath: 'spritesheet_aliens.png',
xmlPath: 'spritesheet_aliens.xml',
),
XmlSpriteSheet.load(
imagePath: 'spritesheet_elements.png',
xmlPath: 'spritesheet_elements.xml',
),
XmlSpriteSheet.load(
imagePath: 'spritesheet_tiles.png',
xmlPath: 'spritesheet_tiles.xml',
),
]);
aliens = spriteSheets[0];
elements = spriteSheets[1];
tiles = spriteSheets[2];
await world.add(Background(sprite: Sprite(backgroundImage)));
await addGround();
unawaited(addBricks().then((_) => addEnemies())); // Modify this line
await addPlayer();
return super.onLoad();
}
Future<void> addGround() {
return world.addAll([
for (var x = camera.visibleWorldRect.left;
x < camera.visibleWorldRect.right + groundSize;
x += groundSize)
Ground(
Vector2(x, (camera.visibleWorldRect.height - groundSize) / 2),
tiles.getSprite('grass.png'),
),
]);
}
final _random = Random();
Future<void> addBricks() async {
for (var i = 0; i < 5; i++) {
final type = BrickType.randomType;
final size = BrickSize.randomSize;
await world.add(
Brick(
type: type,
size: size,
damage: BrickDamage.some,
position: Vector2(
camera.visibleWorldRect.right / 3 +
(_random.nextDouble() * 5 - 2.5),
0),
sprites: brickFileNames(type, size).map(
(key, filename) => MapEntry(
key,
elements.getSprite(filename),
),
),
),
);
await Future<void>.delayed(const Duration(milliseconds: 500));
}
}
Future<void> addPlayer() async => world.add(
Player(
Vector2(camera.visibleWorldRect.left * 2 / 3, 0),
aliens.getSprite(PlayerColor.randomColor.fileName),
),
);
@override
void update(double dt) {
super.update(dt);
if (isMounted && // Modify from here...
world.children.whereType<Player>().isEmpty &&
world.children.whereType<Enemy>().isNotEmpty) {
addPlayer();
}
if (isMounted &&
enemiesFullyAdded &&
world.children.whereType<Enemy>().isEmpty &&
world.children.whereType<TextComponent>().isEmpty) {
world.addAll(
[
(position: Vector2(0.5, 0.5), color: Colors.white),
(position: Vector2.zero(), color: Colors.orangeAccent),
].map(
(e) => TextComponent(
text: 'You win!',
anchor: Anchor.center,
position: e.position,
textRenderer: TextPaint(
style: TextStyle(color: e.color, fontSize: 16),
),
),
),
);
}
}
var enemiesFullyAdded = false;
Future<void> addEnemies() async {
await Future<void>.delayed(const Duration(seconds: 2));
for (var i = 0; i < 3; i++) {
await world.add(
Enemy(
Vector2(
camera.visibleWorldRect.right / 3 +
(_random.nextDouble() * 7 - 3.5),
(_random.nextDouble() * 3)),
aliens.getSprite(EnemyColor.randomColor.fileName),
),
);
await Future<void>.delayed(const Duration(seconds: 1));
}
enemiesFullyAdded = true; // To here.
}
}
وإذا اخترت قبولها، سيكون التحدي الذي تواجهه هو تشغيل اللعبة والانتقال إلى هذه الشاشة.
8. تهانينا
تهانينا، لقد نجحت في إنشاء لعبة باستخدام Flutter وFlutter
صمّمت لعبة باستخدام محرّك الألعاب Flame الثنائي الأبعاد وضمّنتها في برنامج تضمين Flutter. لقد استخدمت تأثيرات Flame لتحريك المكوّنات وإزالتها. لقد استخدمت حزمتَي Google Fonts وFlutter Animate لإنشاء اللعبة بالكامل.
ما هي الخطوات التالية؟
اطّلع على بعض هذه الدروس التطبيقية حول الترميز...
- إنشاء الجيل التالي من واجهات المستخدم في Flutter
- تحويل تطبيق Flutter إلى مظهر مملّ إلى رائع
- إضافة عمليات الشراء داخل التطبيق إلى تطبيق Flutter