1. مقدمة
Flame هو محرّك ألعاب ثنائي الأبعاد يستند إلى Flutter. في هذا الدرس العملي، ستنشئ لعبة مستوحاة من إحدى ألعاب الفيديو الكلاسيكية في السبعينيات، وهي لعبة Breakout التي صمّمها "ستيف وزنياك". ستستخدم "مكوّنات Flame" لرسم المضرب والكرة والطوب. ستستفيد من تأثيرات Flame لتحريك حركة الخفاش، وستتعرّف على كيفية دمج Flame مع نظام إدارة الحالة في Flutter.
عند الانتهاء، من المفترض أن تبدو لعبتك مثل ملف GIF المتحرّك هذا، ولكن أبطأ قليلاً.
أهداف الدورة التعليمية
- كيفية عمل أساسيات Flame، بدءًا من
GameWidget
- كيفية استخدام حلقة ألعاب
- طريقة عمل
Component
في Flame وهي تشبهWidget
في Flutter. - كيفية التعامل مع حالات التعارض
- كيفية استخدام
Effect
s لتحريكComponent
s - كيفية عرض عناصر Flutter
Widget
فوق لعبة Flame - كيفية دمج Flame مع إدارة الحالة في Flutter
ما ستنشئه
في هذا الدرس التطبيقي، ستنشئ لعبة ثنائية الأبعاد باستخدام Flutter وFlame. عند اكتمال اللعبة، يجب أن تستوفي المتطلبات التالية:
- تعمل على جميع الأنظمة الأساسية الستة التي يتيحها Flutter: Android وiOS وLinux وmacOS وWindows والويب
- الحفاظ على 60 إطارًا في الثانية على الأقل باستخدام حلقة اللعبة في Flame
- استخدِم إمكانات Flutter، مثل حزمة
google_fonts
وflutter_animate
، لإعادة إحياء تجربة ألعاب الآركيد في الثمانينيات.
2. إعداد بيئة Flutter
محرِّر
لتبسيط هذا الدرس العملي، يفترض أنّ Visual Studio Code (VS Code) هي بيئة التطوير التي تستخدمها. تطبيق VS Code مجاني ويعمل على جميع الأنظمة الأساسية الرئيسية. نستخدم VS Code في هذا الدرس التطبيقي حول الترميز لأنّ التعليمات تستخدم تلقائيًا اختصارات خاصة بـ VS Code. تصبح المهام أكثر وضوحًا: "انقر على هذا الزر" أو "اضغط على هذا المفتاح لتنفيذ الإجراء X" بدلاً من "نفِّذ الإجراء المناسب في المحرّر لتنفيذ الإجراء X".
يمكنك استخدام أي محرّر تريده، مثل "استوديو Android" أو بيئات تطوير متكاملة أخرى من IntelliJ أو Emacs أو Vim أو Notepad++، فكلها تعمل مع Flutter.
اختيار هدف تطوير
تنتج Flutter تطبيقات لأنظمة تشغيل متعددة. يمكن تشغيل تطبيقك على أي من أنظمة التشغيل التالية:
- iOS
- Android
- Windows
- نظام التشغيل Mac
- Linux
- الويب
من الممارسات الشائعة اختيار نظام تشغيل واحد كهدف للتطوير. هذا هو نظام التشغيل الذي يعمل عليه تطبيقك أثناء التطوير.
على سبيل المثال، لنفترض أنّك تستخدم جهاز كمبيوتر محمول يعمل بنظام التشغيل Windows لتطوير تطبيق Flutter. ثم تختار Android كهدف للتطوير. لمعاينة تطبيقك، عليك توصيل جهاز Android بجهاز الكمبيوتر المحمول الذي يعمل بنظام التشغيل Windows باستخدام كابل USB، وسيتم تشغيل تطبيقك قيد التطوير على جهاز Android المتصل أو في محاكي Android. كان بإمكانك اختيار Windows كهدف التطوير، ما يؤدي إلى تشغيل تطبيقك قيد التطوير كتطبيق Windows إلى جانب المحرّر.
يُرجى تحديد خيار قبل المتابعة. يمكنك دائمًا تشغيل تطبيقك على أنظمة تشغيل أخرى لاحقًا. يؤدي اختيار هدف تطوير إلى تسهيل الخطوة التالية.
تثبيت Flutter
يمكنك الاطّلاع على أحدث التعليمات حول تثبيت حزمة تطوير البرامج (SDK) من Flutter على docs.flutter.dev.
تتضمّن التعليمات الواردة على موقع Flutter الإلكتروني خطوات تثبيت حزمة تطوير البرامج (SDK) والأدوات ذات الصلة بهدف التطوير ومكوّنات محرّر النصوص. في هذا الدرس العملي، عليك تثبيت البرامج التالية:
- حزمة تطوير البرامج (SDK) في Flutter
- محرِّر Visual Studio Code مع المكوّن الإضافي Flutter
- برنامج مترجم للغة البرمجة التي اخترتها. (يجب استخدام Visual Studio لاستهداف Windows أو Xcode لاستهداف macOS أو iOS)
في القسم التالي، ستنشئ مشروع Flutter الأول.
إذا كنت بحاجة إلى تحديد المشاكل وحلّها، قد تجد بعض هذه الأسئلة والأجوبة (من StackOverflow) مفيدة في تحديد المشاكل وحلّها.
الأسئلة الشائعة
- كيف يمكنني العثور على مسار حزمة تطوير البرامج (SDK) الخاصة بإطار عمل Flutter؟
- ماذا أفعل عندما لا يتم العثور على أمر Flutter؟
- كيف يمكنني حلّ مشكلة "في انتظار أمر Flutter آخر لإلغاء قفل بدء التشغيل"؟
- كيف يمكنني إخبار Flutter بمكان تثبيت حزمة تطوير البرامج (SDK) لنظام التشغيل Android؟
- كيف يمكنني التعامل مع خطأ Java عند تشغيل
flutter doctor --android-licenses
؟ - كيف يمكنني حلّ مشكلة عدم العثور على أداة Android
sdkmanager
؟ - كيف يمكنني التعامل مع رسالة الخطأ "المكوّن
cmdline-tools
غير متوفّر"؟ - كيف يمكنني تشغيل CocoaPods على أجهزة Apple Silicon (M1)؟
- كيف يمكنني إيقاف التنسيق التلقائي عند الحفظ في VS Code؟
3- إنشاء مشروع
إنشاء مشروع Flutter الأول
يتضمّن ذلك فتح VS Code وإنشاء نموذج تطبيق Flutter في دليل تختاره.
- افتح Visual Studio Code.
- افتح لوحة الأوامر (
F1
أوCtrl+Shift+P
أوShift+Cmd+P
)، ثم اكتب "flutter new". عندما يظهر، اختَر الأمر Flutter: New Project.
- انقر على تطبيق فارغ. اختَر دليلاً لإنشاء مشروعك فيه. يجب أن يكون هذا أي دليل لا يتطلب امتيازات مرتفعة أو يحتوي على مسافة في مساره. وتشمل الأمثلة الدليل الرئيسي أو
C:\src\
.
- أدخِل اسمًا لمشروعك
brick_breaker
. يفترض بقية هذا الدرس التطبيقي العملي أنّك سمّيت تطبيقكbrick_breaker
.
ينشئ Flutter الآن مجلد مشروعك ويفتحه VS Code. ستستبدل الآن محتوى ملفَين بهيكل أساسي للتطبيق.
نسخ التطبيق الأوّلي ولصقه
يؤدي ذلك إلى إضافة الرمز البرمجي النموذجي المقدَّم في هذا الدرس العملي إلى تطبيقك.
- في اللوحة اليمنى من VS Code، انقر على المستكشف (Explorer) وافتح الملف
pubspec.yaml
.
- استبدِل محتوى هذا الملف بما يلي:
pubspec.yaml
name: brick_breaker
description: "Re-implementing Woz's Breakout"
publish_to: "none"
version: 0.1.0
environment:
sdk: ^3.8.0
dependencies:
flutter:
sdk: flutter
flame: ^1.28.1
flutter_animate: ^4.5.2
google_fonts: ^6.2.1
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^5.0.0
flutter:
uses-material-design: true
يحدّد ملف pubspec.yaml
المعلومات الأساسية عن تطبيقك، مثل الإصدار الحالي والتبعيات والأصول التي سيتم تضمينها في الحزمة.
- افتح الملف
main.dart
في الدليلlib/
.
- استبدِل محتوى هذا الملف بما يلي:
lib/main.dart
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
void main() {
final game = FlameGame();
runApp(GameWidget(game: game));
}
- نفِّذ هذه التعليمات البرمجية للتأكّد من أنّ كل شيء يعمل بشكل سليم. من المفترض أن تظهر نافذة جديدة بخلفية سوداء فارغة فقط. أصبحت أسوأ لعبة فيديو في العالم تعمل الآن بمعدل 60 إطارًا في الثانية!
4. إنشاء اللعبة
التعرّف على اللعبة
تحتاج اللعبة التي يتم لعبها في بُعدين (ثنائية الأبعاد) إلى منطقة لعب. ستنشئ مساحة بأبعاد محدّدة، ثم تستخدم هذه الأبعاد لتحديد حجم جوانب أخرى من اللعبة.
تتوفّر طرق مختلفة لتحديد موضع الإحداثيات في منطقة اللعب. بموجب أحد الاصطلاحات، يمكنك قياس الاتجاه من مركز الشاشة باستخدام نقطة الأصل (0,0)
في مركز الشاشة، وتؤدي القيم الموجبة إلى تحريك العناصر إلى اليمين على طول المحور x وإلى الأعلى على طول المحور y. ينطبق هذا المعيار على معظم الألعاب الحالية في هذه الأيام، لا سيما الألعاب التي تتضمّن ثلاثة أبعاد.
عند إنشاء لعبة Breakout الأصلية، كان من المتعارف عليه ضبط نقطة البداية في أعلى يسار الشاشة. بقي اتجاه المحور x الموجب كما هو، ولكن تم عكس المحور y. كان الاتجاه الموجب لمحور السينات هو اليمين، وكان الاتجاه الموجب لمحور الصادات هو الأسفل. للحفاظ على الطابع الأصلي للعبة، تم ضبط نقطة البداية في أعلى يمين الشاشة.
أنشئ ملفًا باسم config.dart
في دليل جديد باسم lib/src
. سيكتسب هذا الملف المزيد من الثوابت في الخطوات التالية.
lib/src/config.dart
const gameWidth = 820.0;
const gameHeight = 1600.0;
سيكون عرض هذه اللعبة 820 بكسل وارتفاعها 1600 بكسل. يتم تغيير حجم مساحة اللعبة لتلائم النافذة التي يتم عرضها فيها، ولكن تتوافق جميع المكوّنات المُضافة إلى الشاشة مع هذا الارتفاع والعرض.
إنشاء PlayArea
في لعبة Breakout، ترتد الكرة عن جدران منطقة اللعب. لاستيعاب التصادمات، تحتاج أولاً إلى مكوّن PlayArea
.
- أنشئ ملفًا باسم
play_area.dart
في دليل جديد باسمlib/src/components
. - أضِف ما يلي إلى هذا الملف.
lib/src/components/play_area.dart
import 'dart:async';
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
class PlayArea extends RectangleComponent with HasGameReference<BrickBreaker> {
PlayArea() : super(paint: Paint()..color = const Color(0xfff2e8cf));
@override
FutureOr<void> onLoad() async {
super.onLoad();
size = Vector2(game.width, game.height);
}
}
في حين أنّ Flutter يتضمّن Widget
، يتضمّن Flame Component
. في حين أنّ تطبيقات Flutter تتضمّن إنشاء بنى على شكل شجرة من عناصر واجهة المستخدم، تتضمّن ألعاب Flame الحفاظ على بنى على شكل شجرة من المكوّنات.
وهنا يكمن الاختلاف المثير للاهتمام بين Flutter وFlame. شجرة عناصر واجهة المستخدم في Flutter هي وصف مؤقت تم إنشاؤه لاستخدامه في تعديل طبقة RenderObject
الدائمة والقابلة للتغيير. مكوّنات Flame ثابتة وقابلة للتغيير، مع توقّع أن يستخدم المطوّر هذه المكوّنات كجزء من نظام محاكاة.
تم تحسين مكونات Flame للتعبير عن آليات اللعب. سيبدأ هذا الدرس العملي بإنشاء حلقة اللعبة، كما هو موضّح في الخطوة التالية.
- للحدّ من الفوضى، أضِف ملفًا يحتوي على جميع المكوّنات في هذا المشروع. أنشئ ملف
components.dart
فيlib/src/components
وأضِف المحتوى التالي.
lib/src/components/components.dart
export 'play_area.dart';
يؤدي التوجيه export
الدور المعاكس للتوجيه import
. يحدّد هذا الملف الوظائف التي يعرضها عند استيراده إلى ملف آخر. سيحتوي هذا الملف على المزيد من الإدخالات عند إضافة مكونات جديدة في الخطوات التالية.
إنشاء لعبة Flame
لإزالة الخطوط الحمراء المتعرّجة من الخطوة السابقة، أنشئ فئة فرعية جديدة لـ FlameGame
في Flame.
- أنشئ ملفًا باسم
brick_breaker.dart
فيlib/src
وأضِف الرمز التالي.
lib/src/brick_breaker.dart
import 'dart:async';
import 'package:flame/components.dart';
import 'package:flame/game.dart';
import 'components/components.dart';
import 'config.dart';
class BrickBreaker extends FlameGame {
BrickBreaker()
: super(
camera: CameraComponent.withFixedResolution(
width: gameWidth,
height: gameHeight,
),
);
double get width => size.x;
double get height => size.y;
@override
FutureOr<void> onLoad() async {
super.onLoad();
camera.viewfinder.anchor = Anchor.topLeft;
world.add(PlayArea());
}
}
ينسّق هذا الملف إجراءات اللعبة. أثناء إنشاء مثيل اللعبة، يضبط هذا الرمز اللعبة لاستخدام عرض بدرجة دقة ثابتة. يتم تغيير حجم اللعبة لملء الشاشة التي تحتوي عليها، ويتم إضافة تأطير حسب الحاجة.
يمكنك عرض عرض اللعبة وارتفاعها حتى تتمكّن المكوّنات التابعة، مثل PlayArea
، من ضبط حجمها المناسب.
في الطريقة onLoad
التي تمّت إعادة تعريفها، ينفّذ الرمز إجراءَين.
- تضبط هذه السمة الركن العلوي الأيسر كنقطة تثبيت لعدسة الكاميرا. بشكلٍ تلقائي، تستخدم
viewfinder
منتصف المنطقة كنقطة ارتساء لـ(0,0)
. - تضيف هذه السمة
PlayArea
إلىworld
. يمثّل العالم عالم اللعبة. تعرض جميع العناصر الفرعية من خلال عملية تحويل العرضCameraComponent
.
عرض المباراة على الشاشة
للاطّلاع على جميع التغييرات التي أجريتها في هذه الخطوة، عدِّل ملف lib/main.dart
من خلال إجراء التغييرات التالية.
lib/main.dart
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'src/brick_breaker.dart'; // Add this import
void main() {
final game = BrickBreaker(); // Modify this line
runApp(GameWidget(game: game));
}
بعد إجراء هذه التغييرات، أعِد تشغيل اللعبة. يجب أن تكون اللعبة مشابهة للشكل التالي.
في الخطوة التالية، ستضيف كرة إلى العالم، وستجعلها تتحرك.
5- عرض الكرة
إنشاء مكوّن الكرة
يتضمّن وضع كرة متحرّكة على الشاشة إنشاء مكوّن آخر وإضافته إلى عالم اللعبة.
- عدِّل محتوى ملف
lib/src/config.dart
على النحو التالي.
lib/src/config.dart
const gameWidth = 820.0;
const gameHeight = 1600.0;
const ballRadius = gameWidth * 0.02; // Add this constant
سيتكرّر نمط التصميم الخاص بتحديد الثوابت المسماة كقيم مشتقة عدة مرات في هذا الدرس العملي. يتيح لك ذلك تعديل المستوى الأعلى gameWidth
وgameHeight
لاستكشاف كيف يتغيّر مظهر اللعبة وطريقة اللعب نتيجةً لذلك.
- أنشئ مكوّن
Ball
في ملف باسمball.dart
فيlib/src/components
.
lib/src/components/ball.dart
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
class Ball extends CircleComponent {
Ball({
required this.velocity,
required super.position,
required double radius,
}) : super(
radius: radius,
anchor: Anchor.center,
paint: Paint()
..color = const Color(0xff1e6091)
..style = PaintingStyle.fill,
);
final Vector2 velocity;
@override
void update(double dt) {
super.update(dt);
position += velocity * dt;
}
}
في السابق، حدّدت PlayArea
باستخدام RectangleComponent
، لذا من المنطقي أن تكون هناك أشكال أخرى. CircleComponent
، مثل RectangleComponent
، مشتق من PositionedComponent
، لذا يمكنك وضع الكرة على الشاشة. والأهم من ذلك، يمكن تعديل موضعه.
يقدّم هذا المكوّن مفهوم velocity
، أو التغيير في الموضع بمرور الوقت. السرعة المتّجهة هي عنصر Vector2
لأنّ السرعة المتّجهة هي السرعة والاتجاه. لتعديل الموضع، عليك إلغاء طريقة update
التي يستدعيها محرّك اللعبة لكل إطار. dt
هي المدة بين الإطار السابق وهذا الإطار. يتيح لك ذلك التكيّف مع عوامل مثل معدّلات اللقطات المختلفة (60 هرتز أو 120 هرتز) أو اللقطات الطويلة بسبب العمليات الحسابية المفرطة.
يُرجى الانتباه جيدًا إلى position += velocity * dt
. إليك كيفية تنفيذ تعديل محاكاة منفصلة للحركة بمرور الوقت.
- لتضمين المكوّن
Ball
في قائمة المكوّنات، عدِّل الملفlib/src/components/components.dart
على النحو التالي.
lib/src/components/components.dart
export 'ball.dart'; // Add this export
export 'play_area.dart';
إضافة الكرة إلى العالم
لديك كرة. ضَعها في العالم الحقيقي واضبطها لتتحرّك في منطقة اللعب.
عدِّل ملف lib/src/brick_breaker.dart
على النحو التالي.
lib/src/brick_breaker.dart
import 'dart:async';
import 'dart:math' as math; // Add this import
import 'package:flame/components.dart';
import 'package:flame/game.dart';
import 'components/components.dart';
import 'config.dart';
class BrickBreaker extends FlameGame {
BrickBreaker()
: super(
camera: CameraComponent.withFixedResolution(
width: gameWidth,
height: gameHeight,
),
);
final rand = math.Random(); // Add this variable
double get width => size.x;
double get height => size.y;
@override
FutureOr<void> onLoad() async {
super.onLoad();
camera.viewfinder.anchor = Anchor.topLeft;
world.add(PlayArea());
world.add(
Ball( // Add from here...
radius: ballRadius,
position: size / 2,
velocity: Vector2(
(rand.nextDouble() - 0.5) * width,
height * 0.2,
).normalized()..scale(height / 4),
),
);
debugMode = true; // To here.
}
}
يضيف هذا التغيير المكوّن Ball
إلى world
. لضبط position
الكرة في منتصف مساحة العرض، يقسّم الرمز البرمجي أولاً حجم اللعبة إلى نصفين، لأنّ Vector2
يتضمّن عمليات تحميل زائدة (*
و/
) لتغيير حجم Vector2
بقيمة قياسية.
يتطلّب ضبط الكرة velocity
المزيد من التعقيد. والهدف هو تحريك الكرة إلى أسفل الشاشة في اتجاه عشوائي وبسرعة معقولة. يؤدي استدعاء الطريقة normalized
إلى إنشاء مجموعة عناصر Vector2
مضبوطة على الاتجاه نفسه الذي تم ضبط Vector2
الأصلي عليه، ولكن تم تصغيرها إلى مسافة 1. يؤدي ذلك إلى الحفاظ على سرعة الكرة ثابتة بغض النظر عن الاتجاه الذي تتجه إليه. بعد ذلك، يتم زيادة سرعة الكرة لتصبح ربع ارتفاع اللعبة.
يتطلّب الحصول على هذه القيم المختلفة بشكل صحيح بعض التكرار، وهو ما يُعرف أيضًا باسم اختبار اللعب في المجال.
يؤدي السطر الأخير إلى تفعيل عرض تصحيح الأخطاء، ما يضيف معلومات إضافية إلى الشاشة للمساعدة في تصحيح الأخطاء.
عند تشغيل اللعبة الآن، من المفترض أن تظهر على النحو التالي.
يحتوي كلّ من المكوّن PlayArea
والمكوّن Ball
على معلومات تصحيح الأخطاء، ولكنّ الخلفيات غير الشفافة تقصّ أرقام PlayArea
. السبب في عرض معلومات تصحيح الأخطاء لكل شيء هو أنّك فعّلت debugMode
لشجرة المكوّنات بأكملها. يمكنك أيضًا تفعيل تصحيح الأخطاء للمكوّنات المحدّدة فقط، إذا كان ذلك أكثر فائدة.
إذا أعدت تشغيل لعبتك عدة مرات، قد تلاحظ أنّ الكرة لا تتفاعل مع الجدران كما هو متوقّع. لتحقيق هذا التأثير، عليك إضافة ميزة رصد التصادم، وهو ما ستفعله في الخطوة التالية.
6. التنقّل بين النوافذ
إضافة ميزة رصد التصادم
تضيف ميزة "رصد التصادم" سلوكًا يتعرّف من خلاله تطبيقك على الحالات التي يتلامس فيها عنصران.
لإضافة ميزة رصد التصادم إلى اللعبة، أضِف mixin HasCollisionDetection
إلى اللعبة BrickBreaker
كما هو موضّح في الرمز التالي.
lib/src/brick_breaker.dart
import 'dart:async';
import 'dart:math' as math;
import 'package:flame/components.dart';
import 'package:flame/game.dart';
import 'components/components.dart';
import 'config.dart';
class BrickBreaker extends FlameGame with HasCollisionDetection { // Modify this line
BrickBreaker()
: super(
camera: CameraComponent.withFixedResolution(
width: gameWidth,
height: gameHeight,
),
);
final rand = math.Random();
double get width => size.x;
double get height => size.y;
@override
FutureOr<void> onLoad() async {
super.onLoad();
camera.viewfinder.anchor = Anchor.topLeft;
world.add(PlayArea());
world.add(
Ball(
radius: ballRadius,
position: size / 2,
velocity: Vector2(
(rand.nextDouble() - 0.5) * width,
height * 0.2,
).normalized()..scale(height / 4),
),
);
debugMode = true;
}
}
يتتبّع هذا الإجراء مربّعات التصادم الخاصة بالمكوّنات ويؤدي إلى تشغيل عمليات معاودة الاتصال عند التصادم في كل دورة من دورات اللعبة.
لبدء ملء مربّعات الإصابة في اللعبة، عدِّل المكوّن PlayArea
كما هو موضّح:
lib/src/components/play_area.dart
import 'dart:async';
import 'package:flame/collisions.dart'; // Add this import
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
class PlayArea extends RectangleComponent with HasGameReference<BrickBreaker> {
PlayArea()
: super(
paint: Paint()..color = const Color(0xfff2e8cf),
children: [RectangleHitbox()], // Add this parameter
);
@override
FutureOr<void> onLoad() async {
super.onLoad();
size = Vector2(game.width, game.height);
}
}
ستؤدي إضافة مكوّن RectangleHitbox
كعنصر ثانوي للمكوّن RectangleComponent
إلى إنشاء مربّع إصابة لرصد التصادم يتطابق مع حجم المكوّن الرئيسي. يتوفّر منشئ المصنع RectangleHitbox
الذي يُطلق عليه relative
في الحالات التي تريد فيها إنشاء منطقة إصابة أصغر أو أكبر من المكوّن الرئيسي.
تلقّي الكرة
حتى الآن، لم يؤدِّت إضافة ميزة رصد التصادم إلى إحداث أي فرق في طريقة اللعب. ولكنّه يتغيّر عند تعديل المكوّن Ball
. يجب تغيير سلوك الكرة عند اصطدامها بـ PlayArea
.
عدِّل المكوّن Ball
على النحو التالي.
lib/src/components/ball.dart
import 'package:flame/collisions.dart'; // Add this import
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart'; // And this import
import 'play_area.dart'; // And this one too
class Ball extends CircleComponent
with CollisionCallbacks, HasGameReference<BrickBreaker> { // Add these mixins
Ball({
required this.velocity,
required super.position,
required double radius,
}) : super(
radius: radius,
anchor: Anchor.center,
paint: Paint()
..color = const Color(0xff1e6091)
..style = PaintingStyle.fill,
children: [CircleHitbox()], // Add this parameter
);
final Vector2 velocity;
@override
void update(double dt) {
super.update(dt);
position += velocity * dt;
}
@override // Add from here...
void onCollisionStart(
Set<Vector2> intersectionPoints,
PositionComponent other,
) {
super.onCollisionStart(intersectionPoints, other);
if (other is PlayArea) {
if (intersectionPoints.first.y <= 0) {
velocity.y = -velocity.y;
} else if (intersectionPoints.first.x <= 0) {
velocity.x = -velocity.x;
} else if (intersectionPoints.first.x >= game.width) {
velocity.x = -velocity.x;
} else if (intersectionPoints.first.y >= game.height) {
removeFromParent();
}
} else {
debugPrint('collision with $other');
}
} // To here.
}
يُجري هذا المثال تغييرًا كبيرًا من خلال إضافة وظيفة معاودة الاتصال onCollisionStart
. يستدعي نظام رصد التصادم الذي تمت إضافته إلى BrickBreaker
في المثال السابق رد الاتصال هذا.
أولاً، يختبر الرمز ما إذا كان Ball
قد اصطدم بـ PlayArea
. يبدو أنّ هذا الإجراء غير ضروري في الوقت الحالي، إذ لا توجد مكوّنات أخرى في عالم اللعبة. سيتغير ذلك في الخطوة التالية عند إضافة خفاش إلى العالم. بعد ذلك، يضيف أيضًا شرط else
للتعامل مع الحالات التي تصطدم فيها الكرة بأشياء غير المضرب. تذكير بسيط بتنفيذ المنطق المتبقي، إذا أمكن ذلك.
عندما تصطدم الكرة بالجدار السفلي، تختفي من سطح اللعب بينما تظل مرئية. يمكنك التعامل مع هذا العنصر في خطوة مستقبلية باستخدام ميزة "تأثيرات Flame".
بعد أن أصبحت الكرة تصطدم بجدران اللعبة، سيكون من المفيد بالتأكيد أن تمنح اللاعب مضربًا لضرب الكرة به...
7. Get bat on ball
إنشاء ملف الدفعات
لإضافة مضرب لإبقاء الكرة في اللعب داخل اللعبة، اتّبِع الخطوات التالية:
- أدرِج بعض الثوابت في الملف
lib/src/config.dart
على النحو التالي.
lib/src/config.dart
const gameWidth = 820.0;
const gameHeight = 1600.0;
const ballRadius = gameWidth * 0.02;
const batWidth = gameWidth * 0.2; // Add from here...
const batHeight = ballRadius * 2;
const batStep = gameWidth * 0.05; // To here.
الثابتان batHeight
وbatWidth
لا يحتاجان إلى شرح. من ناحية أخرى، يحتاج الثابت batStep
إلى بعض التوضيح. للتفاعل مع الكرة في هذه اللعبة، يمكن للاعب سحب المضرب باستخدام الماوس أو الإصبع، حسب النظام الأساسي، أو استخدام لوحة المفاتيح. يضبط الثابت batStep
مقدار الخطوات التي تخطوها الخفّاش عند الضغط على مفتاح السهم المتّجه لليسار أو لليمين.
- عرِّف فئة المكوّن
Bat
على النحو التالي.
lib/src/components/bat.dart
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flame/events.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
class Bat extends PositionComponent
with DragCallbacks, HasGameReference<BrickBreaker> {
Bat({
required this.cornerRadius,
required super.position,
required super.size,
}) : super(anchor: Anchor.center, children: [RectangleHitbox()]);
final Radius cornerRadius;
final _paint = Paint()
..color = const Color(0xff1e6091)
..style = PaintingStyle.fill;
@override
void render(Canvas canvas) {
super.render(canvas);
canvas.drawRRect(
RRect.fromRectAndRadius(Offset.zero & size.toSize(), cornerRadius),
_paint,
);
}
@override
void onDragUpdate(DragUpdateEvent event) {
super.onDragUpdate(event);
position.x = (position.x + event.localDelta.x).clamp(0, game.width);
}
void moveBy(double dx) {
add(
MoveToEffect(
Vector2((position.x + dx).clamp(0, game.width), position.y),
EffectController(duration: 0.1),
),
);
}
}
يقدّم هذا المكوّن بعض الإمكانات الجديدة.
أولاً، مكوّن Bat هو PositionComponent
وليس RectangleComponent
أو CircleComponent
. وهذا يعني أنّ هذا الرمز يجب أن يعرض Bat
على الشاشة. ولتحقيق ذلك، يتم تجاهل وظيفة الاستدعاء render
.
إذا نظرنا عن كثب إلى طلب canvas.drawRRect
(رسم مستطيل مدوّر)، قد نسأل أنفسنا "أين المستطيل؟". تستفيد Offset.zero & size.toSize()
من عملية تحميل زائدة operator &
على الفئة dart:ui
Offset
التي تنشئ Rect
. قد يربكك هذا الاختصار في البداية، ولكنك ستراه بشكل متكرر في رمز Flutter وFlame ذي المستوى الأدنى.
ثانيًا، يمكن سحب مكوّن Bat
هذا باستخدام الإصبع أو الماوس حسب النظام الأساسي. لتنفيذ هذه الوظيفة، عليك إضافة mixin DragCallbacks
وتجاوز الحدث onDragUpdate
.
أخيرًا، يجب أن يستجيب المكوّن Bat
للتحكّم باستخدام لوحة المفاتيح. تسمح الدالة moveBy
لرمز آخر بإخبار هذه الخفّاش بالتحرّك إلى اليسار أو اليمين بعدد معيّن من وحدات البكسل الافتراضية. تضيف هذه الدالة إمكانية جديدة إلى محرك ألعاب Flame: Effect
s. من خلال إضافة الكائن MoveToEffect
كعنصر ثانوي لهذا المكوّن، يرى اللاعب الخفاش وهو يتحرّك إلى موضع جديد. تتوفّر مجموعة من Effect
في Flame لتنفيذ مجموعة متنوعة من المؤثرات.
تتضمّن وسيطات الدالة الإنشائية الخاصة بفئة Effect مرجعًا إلى أداة الجلب game
. لهذا السبب، عليك تضمين mixin HasGameReference
في هذه الفئة. تضيف هذه الفئة المختلطة أداة وصول game
آمنة من حيث النوع إلى هذا المكوّن للوصول إلى مثيل BrickBreaker
في أعلى شجرة المكوّنات.
- لإتاحة
Bat
لـBrickBreaker
، عدِّل ملفlib/src/components/components.dart
على النحو التالي.
lib/src/components/components.dart
export 'ball.dart';
export 'bat.dart'; // Add this export
export 'play_area.dart';
إضافة الخفاش إلى العالم
لإضافة المكوّن Bat
إلى عالم اللعبة، عدِّل BrickBreaker
على النحو التالي.
lib/src/brick_breaker.dart
import 'dart:async';
import 'dart:math' as math;
import 'package:flame/components.dart';
import 'package:flame/events.dart'; // Add this import
import 'package:flame/game.dart';
import 'package:flutter/material.dart'; // And this import
import 'package:flutter/services.dart'; // And this
import 'components/components.dart';
import 'config.dart';
class BrickBreaker extends FlameGame
with HasCollisionDetection, KeyboardEvents { // Modify this line
BrickBreaker()
: super(
camera: CameraComponent.withFixedResolution(
width: gameWidth,
height: gameHeight,
),
);
final rand = math.Random();
double get width => size.x;
double get height => size.y;
@override
FutureOr<void> onLoad() async {
super.onLoad();
camera.viewfinder.anchor = Anchor.topLeft;
world.add(PlayArea());
world.add(
Ball(
radius: ballRadius,
position: size / 2,
velocity: Vector2(
(rand.nextDouble() - 0.5) * width,
height * 0.2,
).normalized()..scale(height / 4),
),
);
world.add( // Add from here...
Bat(
size: Vector2(batWidth, batHeight),
cornerRadius: const Radius.circular(ballRadius / 2),
position: Vector2(width / 2, height * 0.95),
),
); // To here.
debugMode = true;
}
@override // Add from here...
KeyEventResult onKeyEvent(
KeyEvent event,
Set<LogicalKeyboardKey> keysPressed,
) {
super.onKeyEvent(event, keysPressed);
switch (event.logicalKey) {
case LogicalKeyboardKey.arrowLeft:
world.children.query<Bat>().first.moveBy(-batStep);
case LogicalKeyboardKey.arrowRight:
world.children.query<Bat>().first.moveBy(batStep);
}
return KeyEventResult.handled;
} // To here.
}
تتعامل إضافة mixin KeyboardEvents
وطريقة onKeyEvent
التي تمّت إعادة تعريفها مع إدخال لوحة المفاتيح. تذكَّر الرمز الذي أضفته سابقًا لتحريك الخفاش بمقدار الخطوة المناسب.
يضيف الجزء المتبقي من الرمز المضاف الخفاش إلى عالم اللعبة في الموضع المناسب وبالنسب الصحيحة. يؤدي عرض كل هذه الإعدادات في هذا الملف إلى تسهيل قدرتك على تعديل الحجم النسبي للمضرب والكرة للحصول على الإحساس المناسب باللعبة.
إذا لعبت اللعبة في هذه المرحلة، ستلاحظ أنّه يمكنك تحريك المضرب لاعتراض الكرة، ولكن لن تحصل على أي استجابة مرئية، باستثناء تسجيل تصحيح الأخطاء الذي تركته في الرمز البرمجي لاكتشاف التصادم في Ball
.
حان الوقت لإصلاح ذلك. عدِّل المكوّن Ball
على النحو التالي.
lib/src/components/ball.dart
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/effects.dart'; // Add this import
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
import 'bat.dart'; // And this import
import 'play_area.dart';
class Ball extends CircleComponent
with CollisionCallbacks, HasGameReference<BrickBreaker> {
Ball({
required this.velocity,
required super.position,
required double radius,
}) : super(
radius: radius,
anchor: Anchor.center,
paint: Paint()
..color = const Color(0xff1e6091)
..style = PaintingStyle.fill,
children: [CircleHitbox()],
);
final Vector2 velocity;
@override
void update(double dt) {
super.update(dt);
position += velocity * dt;
}
@override
void onCollisionStart(
Set<Vector2> intersectionPoints,
PositionComponent other,
) {
super.onCollisionStart(intersectionPoints, other);
if (other is PlayArea) {
if (intersectionPoints.first.y <= 0) {
velocity.y = -velocity.y;
} else if (intersectionPoints.first.x <= 0) {
velocity.x = -velocity.x;
} else if (intersectionPoints.first.x >= game.width) {
velocity.x = -velocity.x;
} else if (intersectionPoints.first.y >= game.height) {
add(RemoveEffect(delay: 0.35)); // Modify from here...
}
} else if (other is Bat) {
velocity.y = -velocity.y;
velocity.x =
velocity.x +
(position.x - other.position.x) / other.size.x * game.width * 0.3;
} else { // To here.
debugPrint('collision with $other');
}
}
}
تؤدي تغييرات الرموز البرمجية هذه إلى إصلاح مشكلتَين منفصلتَين.
أولاً، يتم إصلاح مشكلة اختفاء الكرة عند ملامستها أسفل الشاشة. لحلّ هذه المشكلة، عليك استبدال طلب removeFromParent
بطلب RemoveEffect
. تؤدي RemoveEffect
إلى إزالة الكرة من عالم اللعبة بعد السماح لها بالخروج من منطقة اللعب القابلة للعرض.
ثانيًا، تعمل هذه التغييرات على إصلاح طريقة التعامل مع التصادم بين المضرب والكرة. ويعمل رمز المعالجة هذا لصالح اللاعب بشكل كبير. طالما أنّ اللاعب يلمس الكرة بالمضرب، تعود الكرة إلى أعلى الشاشة. إذا كان هذا الإعداد يبدو متساهلاً جدًا وأردت تجربة أكثر واقعية، يمكنك تغيير طريقة التعامل هذه لتناسب بشكل أفضل الأجواء التي تريد أن توفّرها لعبتك.
من الجدير بالذكر أنّ تحديث velocity
معقّد. ولا يقتصر الأمر على عكس اتجاه المكوّن y
من السرعة، كما كان يحدث عند الاصطدام بالجدار. ويعدّل أيضًا المكوّن x
بطريقة تعتمد على الموضع النسبي للمضرب والكرة في وقت التلامس. يمنح ذلك اللاعب تحكّمًا أكبر في ما تفعله الكرة، ولكن لا يتم إبلاغ اللاعب بكيفية ذلك بأي طريقة إلا من خلال اللعب.
الآن بعد أن أصبح لديك مضرب لتصويب الكرة، سيكون من الرائع أن يكون لديك بعض القوالب لتحطيمها بالكرة.
8. هدم الجدار
إنشاء الطوب
لإضافة مكعبات إلى اللعبة، اتّبِع الخطوات التالية:
- أدرِج بعض الثوابت في الملف
lib/src/config.dart
على النحو التالي.
lib/src/config.dart
import 'package:flutter/material.dart'; // Add this import
const brickColors = [ // Add this const
Color(0xfff94144),
Color(0xfff3722c),
Color(0xfff8961e),
Color(0xfff9844a),
Color(0xfff9c74f),
Color(0xff90be6d),
Color(0xff43aa8b),
Color(0xff4d908e),
Color(0xff277da1),
Color(0xff577590),
];
const gameWidth = 820.0;
const gameHeight = 1600.0;
const ballRadius = gameWidth * 0.02;
const batWidth = gameWidth * 0.2;
const batHeight = ballRadius * 2;
const batStep = gameWidth * 0.05;
const brickGutter = gameWidth * 0.015; // Add from here...
final brickWidth =
(gameWidth - (brickGutter * (brickColors.length + 1))) / brickColors.length;
const brickHeight = gameHeight * 0.03;
const difficultyModifier = 1.03; // To here.
- أدرِج مكوّن
Brick
على النحو التالي.
lib/src/components/brick.dart
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
import '../config.dart';
import 'ball.dart';
import 'bat.dart';
class Brick extends RectangleComponent
with CollisionCallbacks, HasGameReference<BrickBreaker> {
Brick({required super.position, required Color color})
: super(
size: Vector2(brickWidth, brickHeight),
anchor: Anchor.center,
paint: Paint()
..color = color
..style = PaintingStyle.fill,
children: [RectangleHitbox()],
);
@override
void onCollisionStart(
Set<Vector2> intersectionPoints,
PositionComponent other,
) {
super.onCollisionStart(intersectionPoints, other);
removeFromParent();
if (game.world.children.query<Brick>().length == 1) {
game.world.removeAll(game.world.children.query<Ball>());
game.world.removeAll(game.world.children.query<Bat>());
}
}
}
من المفترض أنّ معظم هذا الرمز البرمجي أصبح مألوفًا لديك الآن. يستخدم هذا الرمز RectangleComponent
، مع كلّ من رصد التصادم ومرجع آمن للنوع إلى لعبة BrickBreaker
في أعلى شجرة المكوّنات.
أهم مفهوم جديد يقدّمه هذا الرمز هو كيفية تحقيق اللاعب لشرط الفوز. يتحقّق شرط الفوز من وجود الطوب في العالم، ويتأكّد من عدم بقاء سوى طوبة واحدة. قد يكون هذا الأمر مربكًا بعض الشيء، لأنّ السطر السابق يزيل هذه اللبنة من عنصرها الرئيسي.
النقطة الأساسية التي يجب فهمها هي أنّ إزالة المكوّن هي أمر يتم تنفيذه في قائمة الانتظار. تؤدي هذه الطريقة إلى إزالة الطوبة بعد تنفيذ هذا الرمز، ولكن قبل الخطوة التالية في عالم اللعبة.
لإتاحة استخدام مكوّن Brick
من خلال BrickBreaker
، عدِّل lib/src/components/components.dart
على النحو التالي.
lib/src/components/components.dart
export 'ball.dart';
export 'bat.dart';
export 'brick.dart'; // Add this export
export 'play_area.dart';
إضافة مكعبات إلى العالم
عدِّل مكوّن Ball
على النحو التالي.
lib/src/components/ball.dart
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
import 'bat.dart';
import 'brick.dart'; // Add this import
import 'play_area.dart';
class Ball extends CircleComponent
with CollisionCallbacks, HasGameReference<BrickBreaker> {
Ball({
required this.velocity,
required super.position,
required double radius,
required this.difficultyModifier, // Add this parameter
}) : super(
radius: radius,
anchor: Anchor.center,
paint: Paint()
..color = const Color(0xff1e6091)
..style = PaintingStyle.fill,
children: [CircleHitbox()],
);
final Vector2 velocity;
final double difficultyModifier; // Add this member
@override
void update(double dt) {
super.update(dt);
position += velocity * dt;
}
@override
void onCollisionStart(
Set<Vector2> intersectionPoints,
PositionComponent other,
) {
super.onCollisionStart(intersectionPoints, other);
if (other is PlayArea) {
if (intersectionPoints.first.y <= 0) {
velocity.y = -velocity.y;
} else if (intersectionPoints.first.x <= 0) {
velocity.x = -velocity.x;
} else if (intersectionPoints.first.x >= game.width) {
velocity.x = -velocity.x;
} else if (intersectionPoints.first.y >= game.height) {
add(RemoveEffect(delay: 0.35));
}
} else if (other is Bat) {
velocity.y = -velocity.y;
velocity.x =
velocity.x +
(position.x - other.position.x) / other.size.x * game.width * 0.3;
} else if (other is Brick) { // Modify from here...
if (position.y < other.position.y - other.size.y / 2) {
velocity.y = -velocity.y;
} else if (position.y > other.position.y + other.size.y / 2) {
velocity.y = -velocity.y;
} else if (position.x < other.position.x) {
velocity.x = -velocity.x;
} else if (position.x > other.position.x) {
velocity.x = -velocity.x;
}
velocity.setFrom(velocity * difficultyModifier); // To here.
}
}
}
يقدّم هذا الرمز الجانب الجديد الوحيد، وهو معدِّل الصعوبة الذي يزيد من سرعة الكرة بعد كل تصادم مع الطوب. يجب اختبار هذه المَعلمة القابلة للضبط من خلال اللعب للعثور على منحنى الصعوبة المناسب للعبتك.
عدِّل لعبة BrickBreaker
على النحو التالي.
lib/src/brick_breaker.dart
import 'dart:async';
import 'dart:math' as math;
import 'package:flame/components.dart';
import 'package:flame/events.dart';
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'components/components.dart';
import 'config.dart';
class BrickBreaker extends FlameGame
with HasCollisionDetection, KeyboardEvents {
BrickBreaker()
: super(
camera: CameraComponent.withFixedResolution(
width: gameWidth,
height: gameHeight,
),
);
final rand = math.Random();
double get width => size.x;
double get height => size.y;
@override
FutureOr<void> onLoad() async {
super.onLoad();
camera.viewfinder.anchor = Anchor.topLeft;
world.add(PlayArea());
world.add(
Ball(
difficultyModifier: difficultyModifier, // Add this argument
radius: ballRadius,
position: size / 2,
velocity: Vector2(
(rand.nextDouble() - 0.5) * width,
height * 0.2,
).normalized()..scale(height / 4),
),
);
world.add(
Bat(
size: Vector2(batWidth, batHeight),
cornerRadius: const Radius.circular(ballRadius / 2),
position: Vector2(width / 2, height * 0.95),
),
);
await world.addAll([ // Add from here...
for (var i = 0; i < brickColors.length; i++)
for (var j = 1; j <= 5; j++)
Brick(
position: Vector2(
(i + 0.5) * brickWidth + (i + 1) * brickGutter,
(j + 2.0) * brickHeight + j * brickGutter,
),
color: brickColors[i],
),
]); // To here.
debugMode = true;
}
@override
KeyEventResult onKeyEvent(
KeyEvent event,
Set<LogicalKeyboardKey> keysPressed,
) {
super.onKeyEvent(event, keysPressed);
switch (event.logicalKey) {
case LogicalKeyboardKey.arrowLeft:
world.children.query<Bat>().first.moveBy(-batStep);
case LogicalKeyboardKey.arrowRight:
world.children.query<Bat>().first.moveBy(batStep);
}
return KeyEventResult.handled;
}
}
إذا شغّلت اللعبة، ستعرض جميع آليات اللعب الرئيسية. يمكنك إيقاف تصحيح الأخطاء والاعتقاد بأنّك انتهيت، ولكنّك ستشعر بأنّ هناك شيئًا ناقصًا.
ماذا عن شاشة ترحيب وشاشة انتهاء اللعبة وربما نتيجة؟ يمكن أن يضيف Flutter هذه الميزات إلى اللعبة، وهذا ما ستركز عليه في الخطوة التالية.
9- الفوز بالمباراة
إضافة حالات التشغيل
في هذه الخطوة، عليك تضمين لعبة Flame داخل برنامج تضمين Flutter، ثم إضافة تراكبات Flutter لشاشات الترحيب وانتهاء اللعبة والفوز.
أولاً، عليك تعديل ملفات اللعبة والمكوّنات لتنفيذ حالة تشغيل تحدّد ما إذا كان سيتم عرض تراكب أم لا، وإذا كان سيتم عرضه، فسيتم تحديد نوعه.
- عدِّل اللعبة
BrickBreaker
على النحو التالي.
lib/src/brick_breaker.dart
import 'dart:async';
import 'dart:math' as math;
import 'package:flame/components.dart';
import 'package:flame/events.dart';
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'components/components.dart';
import 'config.dart';
enum PlayState { welcome, playing, gameOver, won } // Add this enumeration
class BrickBreaker extends FlameGame
with HasCollisionDetection, KeyboardEvents, TapDetector { // Modify this line
BrickBreaker()
: super(
camera: CameraComponent.withFixedResolution(
width: gameWidth,
height: gameHeight,
),
);
final rand = math.Random();
double get width => size.x;
double get height => size.y;
late PlayState _playState; // Add from here...
PlayState get playState => _playState;
set playState(PlayState playState) {
_playState = playState;
switch (playState) {
case PlayState.welcome:
case PlayState.gameOver:
case PlayState.won:
overlays.add(playState.name);
case PlayState.playing:
overlays.remove(PlayState.welcome.name);
overlays.remove(PlayState.gameOver.name);
overlays.remove(PlayState.won.name);
}
} // To here.
@override
FutureOr<void> onLoad() async {
super.onLoad();
camera.viewfinder.anchor = Anchor.topLeft;
world.add(PlayArea());
playState = PlayState.welcome; // Add from here...
}
void startGame() {
if (playState == PlayState.playing) return;
world.removeAll(world.children.query<Ball>());
world.removeAll(world.children.query<Bat>());
world.removeAll(world.children.query<Brick>());
playState = PlayState.playing; // To here.
world.add(
Ball(
difficultyModifier: difficultyModifier,
radius: ballRadius,
position: size / 2,
velocity: Vector2(
(rand.nextDouble() - 0.5) * width,
height * 0.2,
).normalized()..scale(height / 4),
),
);
world.add(
Bat(
size: Vector2(batWidth, batHeight),
cornerRadius: const Radius.circular(ballRadius / 2),
position: Vector2(width / 2, height * 0.95),
),
);
world.addAll([ // Drop the await
for (var i = 0; i < brickColors.length; i++)
for (var j = 1; j <= 5; j++)
Brick(
position: Vector2(
(i + 0.5) * brickWidth + (i + 1) * brickGutter,
(j + 2.0) * brickHeight + j * brickGutter,
),
color: brickColors[i],
),
]);
} // Drop the debugMode
@override // Add from here...
void onTap() {
super.onTap();
startGame();
} // To here.
@override
KeyEventResult onKeyEvent(
KeyEvent event,
Set<LogicalKeyboardKey> keysPressed,
) {
super.onKeyEvent(event, keysPressed);
switch (event.logicalKey) {
case LogicalKeyboardKey.arrowLeft:
world.children.query<Bat>().first.moveBy(-batStep);
case LogicalKeyboardKey.arrowRight:
world.children.query<Bat>().first.moveBy(batStep);
case LogicalKeyboardKey.space: // Add from here...
case LogicalKeyboardKey.enter:
startGame(); // To here.
}
return KeyEventResult.handled;
}
@override
Color backgroundColor() => const Color(0xfff2e8cf); // Add this override
}
يغيّر هذا الرمز الكثير من عناصر اللعبة BrickBreaker
. تتطلّب إضافة التعداد playState
الكثير من الجهد. تسجّل هذه السمة المرحلة التي يمرّ بها اللاعب أثناء إدخال البيانات ولعب المباراة، سواء خسر أو فاز. في أعلى الملف، يمكنك تحديد التعداد، ثم إنشاء مثيل له كحالة مخفية باستخدام دوال الجلب والتعديل المطابقة. تتيح دوال الجلب والتعديل هذه تعديل التراكبات عندما تؤدي الأجزاء المختلفة من اللعبة إلى بدء عمليات انتقال حالة التشغيل.
بعد ذلك، قسِّم الرمز في onLoad
إلى onLoad وطريقة startGame
جديدة. قبل هذا التغيير، كان بإمكانك بدء مباراة جديدة فقط من خلال إعادة تشغيل اللعبة. وبفضل هذه الإضافات الجديدة، يمكن للاعب الآن بدء مباراة جديدة بدون اتّخاذ مثل هذه الإجراءات الصارمة.
للسماح للاعب ببدء لعبة جديدة، عليك ضبط معالجَين جديدَين للعبة. أضفتَ معالجًا للنقر ووسّعتَ نطاق معالج لوحة المفاتيح لتمكين المستخدم من بدء لعبة جديدة في أوضاع متعددة. بعد تصميم حالة التشغيل، من المنطقي تعديل المكوّنات لتفعيل عمليات الانتقال إلى حالة التشغيل عندما يفوز اللاعب أو يخسر.
- عدِّل المكوّن
Ball
على النحو التالي.
lib/src/components/ball.dart
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
import 'bat.dart';
import 'brick.dart';
import 'play_area.dart';
class Ball extends CircleComponent
with CollisionCallbacks, HasGameReference<BrickBreaker> {
Ball({
required this.velocity,
required super.position,
required double radius,
required this.difficultyModifier,
}) : super(
radius: radius,
anchor: Anchor.center,
paint: Paint()
..color = const Color(0xff1e6091)
..style = PaintingStyle.fill,
children: [CircleHitbox()],
);
final Vector2 velocity;
final double difficultyModifier;
@override
void update(double dt) {
super.update(dt);
position += velocity * dt;
}
@override
void onCollisionStart(
Set<Vector2> intersectionPoints,
PositionComponent other,
) {
super.onCollisionStart(intersectionPoints, other);
if (other is PlayArea) {
if (intersectionPoints.first.y <= 0) {
velocity.y = -velocity.y;
} else if (intersectionPoints.first.x <= 0) {
velocity.x = -velocity.x;
} else if (intersectionPoints.first.x >= game.width) {
velocity.x = -velocity.x;
} else if (intersectionPoints.first.y >= game.height) {
add(
RemoveEffect(
delay: 0.35,
onComplete: () { // Modify from here
game.playState = PlayState.gameOver;
},
),
); // To here.
}
} else if (other is Bat) {
velocity.y = -velocity.y;
velocity.x =
velocity.x +
(position.x - other.position.x) / other.size.x * game.width * 0.3;
} else if (other is Brick) {
if (position.y < other.position.y - other.size.y / 2) {
velocity.y = -velocity.y;
} else if (position.y > other.position.y + other.size.y / 2) {
velocity.y = -velocity.y;
} else if (position.x < other.position.x) {
velocity.x = -velocity.x;
} else if (position.x > other.position.x) {
velocity.x = -velocity.x;
}
velocity.setFrom(velocity * difficultyModifier);
}
}
}
يضيف هذا التغيير الصغير دالة رد اتصال onComplete
إلى RemoveEffect
التي تؤدي إلى تشغيل حالة gameOver
. يجب أن يكون هذا الإعداد مناسبًا إذا سمح اللاعب للكرة بالخروج من أسفل الشاشة.
- عدِّل المكوّن
Brick
على النحو التالي.
lib/src/components/brick.dart
impimport 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
import '../config.dart';
import 'ball.dart';
import 'bat.dart';
class Brick extends RectangleComponent
with CollisionCallbacks, HasGameReference<BrickBreaker> {
Brick({required super.position, required Color color})
: super(
size: Vector2(brickWidth, brickHeight),
anchor: Anchor.center,
paint: Paint()
..color = color
..style = PaintingStyle.fill,
children: [RectangleHitbox()],
);
@override
void onCollisionStart(
Set<Vector2> intersectionPoints,
PositionComponent other,
) {
super.onCollisionStart(intersectionPoints, other);
removeFromParent();
if (game.world.children.query<Brick>().length == 1) {
game.playState = PlayState.won; // Add this line
game.world.removeAll(game.world.children.query<Ball>());
game.world.removeAll(game.world.children.query<Bat>());
}
}
}
من ناحية أخرى، إذا تمكّن اللاعب من تحطيم كل الطوب، سيظهر له إشعار "لقد فزت باللعبة". أحسنت أيها اللاعب، أحسنت!
إضافة برنامج تضمين Flutter
لإتاحة مكان لتضمين اللعبة وإضافة تراكبات حالة التشغيل، أضِف واجهة Flutter.
- أنشئ الدليل
widgets
ضمنlib/src
. - أضِف ملف
game_app.dart
وأدرِج المحتوى التالي في هذا الملف.
lib/src/widgets/game_app.dart
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import '../brick_breaker.dart';
import '../config.dart';
class GameApp extends StatelessWidget {
const GameApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData(
textTheme: GoogleFonts.pressStart2pTextTheme().apply(
bodyColor: const Color(0xff184e77),
displayColor: const Color(0xff184e77),
),
),
home: Scaffold(
body: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xffa9d6e5), Color(0xfff2e8cf)],
),
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Center(
child: FittedBox(
child: SizedBox(
width: gameWidth,
height: gameHeight,
child: GameWidget.controlled(
gameFactory: BrickBreaker.new,
overlayBuilderMap: {
PlayState.welcome.name: (context, game) => Center(
child: Text(
'TAP TO PLAY',
style: Theme.of(context).textTheme.headlineLarge,
),
),
PlayState.gameOver.name: (context, game) => Center(
child: Text(
'G A M E O V E R',
style: Theme.of(context).textTheme.headlineLarge,
),
),
PlayState.won.name: (context, game) => Center(
child: Text(
'Y O U W O N ! ! !',
style: Theme.of(context).textTheme.headlineLarge,
),
),
},
),
),
),
),
),
),
),
),
);
}
}
يتبع معظم المحتوى في هذا الملف بنية شجرة عناصر واجهة مستخدم Flutter العادية. تتضمّن الأجزاء الخاصة بإطار عمل Flame استخدام GameWidget.controlled
لإنشاء وإدارة مثيل اللعبة BrickBreaker
والوسيط overlayBuilderMap
الجديد في GameWidget
.
يجب أن تتوافق مفاتيح overlayBuilderMap
مع التراكبات التي أضافها أو أزالها أداة ضبط playState
في BrickBreaker
. محاولة ضبط تراكب غير موجود في هذه الخريطة تؤدي إلى ظهور وجوه حزينة في كل مكان.
- للحصول على هذه الوظيفة الجديدة على الشاشة، استبدِل الملف
lib/main.dart
بالمحتوى التالي.
lib/main.dart
import 'package:flutter/material.dart';
import 'src/widgets/game_app.dart';
void main() {
runApp(const GameApp());
}
إذا شغّلت هذا الرمز على نظام التشغيل iOS أو Linux أو Windows أو على الويب، سيظهر الناتج المقصود في اللعبة. إذا كنت تستهدف نظام التشغيل macOS أو Android، عليك إجراء تعديل أخير لتفعيل عرض google_fonts
.
تفعيل إذن الوصول إلى الخطوط
إضافة إذن الاتصال بالإنترنت على Android
في نظام التشغيل Android، يجب إضافة إذن الوصول إلى الإنترنت. عدِّل AndroidManifest.xml
على النحو التالي.
android/app/src/main/AndroidManifest.xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Add the following line -->
<uses-permission android:name="android.permission.INTERNET" />
<application
android:label="brick_breaker"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
تعديل ملفات الأهلية على أجهزة macOS
في نظام التشغيل macOS، عليك تعديل ملفَين.
- عدِّل الملف
DebugProfile.entitlements
ليتطابق مع الرمز التالي.
macos/Runner/DebugProfile.entitlements
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
<!-- Add from here... -->
<key>com.apple.security.network.client</key>
<true/>
<!-- to here. -->
</dict>
</plist>
- عدِّل الملف
Release.entitlements
ليطابق الرمز التالي
macos/Runner/Release.entitlements
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<!-- Add from here... -->
<key>com.apple.security.network.client</key>
<true/>
<!-- to here. -->
</dict>
</plist>
من المفترض أن يؤدي تشغيل هذا الرمز كما هو إلى عرض شاشة ترحيب وشاشة انتهاء اللعبة أو الفوز بها على جميع المنصات. قد تكون هذه الشاشات بسيطة بعض الشيء، وسيكون من الجيد الحصول على نتيجة. إذًا، هل يمكنك تخمين ما ستفعله في الخطوة التالية؟
10. تتبُّع النتائج
إضافة نتيجة إلى المباراة
في هذه الخطوة، يمكنك عرض نتيجة اللعبة في سياق Flutter المحيط. في هذه الخطوة، يمكنك عرض الحالة من لعبة Flame إلى إدارة الحالة المحيطة في Flutter. يتيح ذلك لرمز اللعبة تعديل النتيجة في كل مرة يكسر فيها اللاعب طوبة.
- عدِّل اللعبة
BrickBreaker
على النحو التالي.
lib/src/brick_breaker.dart
import 'dart:async';
import 'dart:math' as math;
import 'package:flame/components.dart';
import 'package:flame/events.dart';
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'components/components.dart';
import 'config.dart';
enum PlayState { welcome, playing, gameOver, won }
class BrickBreaker extends FlameGame
with HasCollisionDetection, KeyboardEvents, TapDetector {
BrickBreaker()
: super(
camera: CameraComponent.withFixedResolution(
width: gameWidth,
height: gameHeight,
),
);
final ValueNotifier<int> score = ValueNotifier(0); // Add this line
final rand = math.Random();
double get width => size.x;
double get height => size.y;
late PlayState _playState;
PlayState get playState => _playState;
set playState(PlayState playState) {
_playState = playState;
switch (playState) {
case PlayState.welcome:
case PlayState.gameOver:
case PlayState.won:
overlays.add(playState.name);
case PlayState.playing:
overlays.remove(PlayState.welcome.name);
overlays.remove(PlayState.gameOver.name);
overlays.remove(PlayState.won.name);
}
}
@override
FutureOr<void> onLoad() async {
super.onLoad();
camera.viewfinder.anchor = Anchor.topLeft;
world.add(PlayArea());
playState = PlayState.welcome;
}
void startGame() {
if (playState == PlayState.playing) return;
world.removeAll(world.children.query<Ball>());
world.removeAll(world.children.query<Bat>());
world.removeAll(world.children.query<Brick>());
playState = PlayState.playing;
score.value = 0; // Add this line
world.add(
Ball(
difficultyModifier: difficultyModifier,
radius: ballRadius,
position: size / 2,
velocity: Vector2(
(rand.nextDouble() - 0.5) * width,
height * 0.2,
).normalized()..scale(height / 4),
),
);
world.add(
Bat(
size: Vector2(batWidth, batHeight),
cornerRadius: const Radius.circular(ballRadius / 2),
position: Vector2(width / 2, height * 0.95),
),
);
world.addAll([
for (var i = 0; i < brickColors.length; i++)
for (var j = 1; j <= 5; j++)
Brick(
position: Vector2(
(i + 0.5) * brickWidth + (i + 1) * brickGutter,
(j + 2.0) * brickHeight + j * brickGutter,
),
color: brickColors[i],
),
]);
}
@override
void onTap() {
super.onTap();
startGame();
}
@override
KeyEventResult onKeyEvent(
KeyEvent event,
Set<LogicalKeyboardKey> keysPressed,
) {
super.onKeyEvent(event, keysPressed);
switch (event.logicalKey) {
case LogicalKeyboardKey.arrowLeft:
world.children.query<Bat>().first.moveBy(-batStep);
case LogicalKeyboardKey.arrowRight:
world.children.query<Bat>().first.moveBy(batStep);
case LogicalKeyboardKey.space:
case LogicalKeyboardKey.enter:
startGame();
}
return KeyEventResult.handled;
}
@override
Color backgroundColor() => const Color(0xfff2e8cf);
}
من خلال إضافة score
إلى اللعبة، يمكنك ربط حالة اللعبة بإدارة حالة Flutter.
- عدِّل الفئة
Brick
لإضافة نقطة إلى النتيجة عندما يكسر اللاعب الطوب.
lib/src/components/brick.dart
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
import '../config.dart';
import 'ball.dart';
import 'bat.dart';
class Brick extends RectangleComponent
with CollisionCallbacks, HasGameReference<BrickBreaker> {
Brick({required super.position, required Color color})
: super(
size: Vector2(brickWidth, brickHeight),
anchor: Anchor.center,
paint: Paint()
..color = color
..style = PaintingStyle.fill,
children: [RectangleHitbox()],
);
@override
void onCollisionStart(
Set<Vector2> intersectionPoints,
PositionComponent other,
) {
super.onCollisionStart(intersectionPoints, other);
removeFromParent();
game.score.value++; // Add this line
if (game.world.children.query<Brick>().length == 1) {
game.playState = PlayState.won;
game.world.removeAll(game.world.children.query<Ball>());
game.world.removeAll(game.world.children.query<Bat>());
}
}
}
إنشاء لعبة جذابة
بعد أن أصبح بإمكانك تتبُّع النتائج في Flutter، حان الوقت لتجميع الأدوات لجعلها تبدو رائعة.
- أنشئ
score_card.dart
فيlib/src/widgets
وأضِف ما يلي.
lib/src/widgets/score_card.dart
import 'package:flutter/material.dart';
class ScoreCard extends StatelessWidget {
const ScoreCard({super.key, required this.score});
final ValueNotifier<int> score;
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<int>(
valueListenable: score,
builder: (context, score, child) {
return Padding(
padding: const EdgeInsets.fromLTRB(12, 6, 12, 18),
child: Text(
'Score: $score'.toUpperCase(),
style: Theme.of(context).textTheme.titleLarge!,
),
);
},
);
}
}
- أنشئ ملف
overlay_screen.dart
فيlib/src/widgets
وأضِف الرمز التالي.
يضيف ذلك المزيد من التحسينات إلى العناصر المركّبة باستخدام إمكانات حزمة flutter_animate
لإضافة بعض الحركة والأسلوب إلى شاشات العناصر المركّبة.
lib/src/widgets/overlay_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
class OverlayScreen extends StatelessWidget {
const OverlayScreen({super.key, required this.title, required this.subtitle});
final String title;
final String subtitle;
@override
Widget build(BuildContext context) {
return Container(
alignment: const Alignment(0, -0.15),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
title,
style: Theme.of(context).textTheme.headlineLarge,
).animate().slideY(duration: 750.ms, begin: -3, end: 0),
const SizedBox(height: 16),
Text(subtitle, style: Theme.of(context).textTheme.headlineSmall)
.animate(onPlay: (controller) => controller.repeat())
.fadeIn(duration: 1.seconds)
.then()
.fadeOut(duration: 1.seconds),
],
),
);
}
}
لإلقاء نظرة أكثر تفصيلاً على إمكانات flutter_animate
، يمكنك الاطّلاع على الدرس التطبيقي تصميم واجهات مستخدم من الجيل التالي في Flutter.
تغيّر هذا الرمز البرمجي كثيرًا في المكوّن GameApp
. أولاً، للسماح لـ ScoreCard
بالوصول إلى score
، عليك تحويله من StatelessWidget
إلى StatefulWidget
. تتطلّب إضافة بطاقة النتائج إضافة Column
لترتيب النتيجة فوق اللعبة.
ثانيًا، لتحسين تجارب الترحيب والانتهاء من اللعبة والفوز، أضفت الأداة الجديدة OverlayScreen
.
lib/src/widgets/game_app.dart
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import '../brick_breaker.dart';
import '../config.dart';
import 'overlay_screen.dart'; // Add this import
import 'score_card.dart'; // And this one too
class GameApp extends StatefulWidget { // Modify this line
const GameApp({super.key});
@override // Add from here...
State<GameApp> createState() => _GameAppState();
}
class _GameAppState extends State<GameApp> {
late final BrickBreaker game;
@override
void initState() {
super.initState();
game = BrickBreaker();
} // To here.
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData(
textTheme: GoogleFonts.pressStart2pTextTheme().apply(
bodyColor: const Color(0xff184e77),
displayColor: const Color(0xff184e77),
),
),
home: Scaffold(
body: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xffa9d6e5), Color(0xfff2e8cf)],
),
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Center(
child: Column( // Modify from here...
children: [
ScoreCard(score: game.score),
Expanded(
child: FittedBox(
child: SizedBox(
width: gameWidth,
height: gameHeight,
child: GameWidget(
game: game,
overlayBuilderMap: {
PlayState.welcome.name: (context, game) =>
const OverlayScreen(
title: 'TAP TO PLAY',
subtitle: 'Use arrow keys or swipe',
),
PlayState.gameOver.name: (context, game) =>
const OverlayScreen(
title: 'G A M E O V E R',
subtitle: 'Tap to Play Again',
),
PlayState.won.name: (context, game) =>
const OverlayScreen(
title: 'Y O U W O N ! ! !',
subtitle: 'Tap to Play Again',
),
},
),
),
),
),
],
), // To here.
),
),
),
),
),
);
}
}
بعد إعداد كل ذلك، يجب أن تتمكّن الآن من تشغيل هذه اللعبة على أي من منصات Flutter الست المستهدَفة. يجب أن تبدو اللعبة على النحو التالي.
11. تهانينا
تهانينا، لقد نجحت في إنشاء لعبة باستخدام Flutter وFlame.
لقد أنشأت لعبة باستخدام محرّك الألعاب Flame الثنائي الأبعاد وضمّنتها في برنامج تضمين Flutter. استخدمت "تأثيرات Flame" لتحريك المكوّنات وإزالتها. استخدمت حِزم Google Fonts وFlutter Animate لجعل تصميم اللعبة بأكملها يبدو جيدًا.
ما هي الخطوات التالية؟
يمكنك الاطّلاع على بعض دروس الترميز التطبيقية هذه...
- إنشاء الجيل التالي من واجهات المستخدم في Flutter
- تحويل تطبيقك المتوافق مع Flutter من تطبيق عادي إلى تطبيق جميل
- إضافة عمليات شراء داخل التطبيق إلى تطبيق Flutter