مقدّمة حول Flame مع Flutter

1. مقدمة

‫Flame هو محرك ألعاب ثنائي الأبعاد يستند إلى Flutter. في هذا الدرس التطبيقي حول الترميز، ستنشئ لعبة مستوحاة من إحدى ألعاب الفيديو الكلاسيكية في السبعينيات، وهي لعبة Breakout التي صمّمها "ستيف وزنياك". ستستخدم "مكوّنات Flame" لرسم المضرب والكرة والطوب. ستستفيد من تأثيرات Flame لتحريك حركة الخفاش، وستتعرّف على كيفية دمج Flame مع نظام إدارة الحالة في Flutter.

عند الانتهاء، من المفترض أن تبدو لعبتك مثل ملف GIF المتحرّك هذا، ولكن أبطأ قليلاً.

يعرض تسجيل الشاشة لعبة يتم لعبها. تم تسريع اللعبة بشكل كبير.

أهداف الدورة التعليمية

  • كيفية عمل أساسيات Flame، بدءًا من GameWidget
  • كيفية استخدام حلقة الألعاب
  • طريقة عمل Component في Flame وهي تشبه Widget في Flutter.
  • كيفية التعامل مع التصادمات
  • كيفية استخدام Effects لتحريك Components
  • كيفية عرض عناصر 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.

VS Code مع بعض رموز Flutter البرمجية

اختيار هدف تطوير

تنتج Flutter تطبيقات لأنظمة تشغيل متعددة. يمكن تشغيل تطبيقك على أي من أنظمة التشغيل التالية:

  • iOS
  • Android
  • Windows
  • macOS
  • Linux
  • الويب

من الممارسات الشائعة اختيار نظام تشغيل واحد كهدف للتطوير. هذا هو نظام التشغيل الذي يعمل عليه تطبيقك أثناء التطوير.

رسم يوضّح كمبيوترًا محمولاً وهاتفًا متصلاً بالكمبيوتر المحمول بواسطة كابل تم تصنيف الكمبيوتر المحمول على أنّه

على سبيل المثال، لنفترض أنّك تستخدم جهاز كمبيوتر محمول يعمل بنظام التشغيل Windows لتطوير تطبيق Flutter. ثم تختار Android كهدف للتطوير. لمعاينة تطبيقك، عليك توصيل جهاز Android بجهاز الكمبيوتر المحمول الذي يعمل بنظام التشغيل Windows باستخدام كابل USB، وسيتم تشغيل تطبيقك قيد التطوير على جهاز Android المتصل أو في محاكي Android. كان بإمكانك اختيار Windows كهدف التطوير، ما يؤدي إلى تشغيل تطبيقك قيد التطوير كتطبيق Windows إلى جانب المحرّر.

يُرجى تحديد خيارك قبل المتابعة. يمكنك دائمًا تشغيل تطبيقك على أنظمة تشغيل أخرى لاحقًا. يؤدي اختيار هدف تطوير إلى تسهيل الخطوة التالية.

تثبيت Flutter

يمكنك الاطّلاع على أحدث التعليمات حول تثبيت حزمة تطوير البرامج (SDK) من Flutter على docs.flutter.dev.

تتضمّن التعليمات الواردة على موقع Flutter الإلكتروني خطوات تثبيت حزمة تطوير البرامج (SDK) والأدوات ذات الصلة بهدف التطوير وإضافات المحرّر. في هذا الدرس التطبيقي حول الترميز، عليك تثبيت البرامج التالية:

  1. حزمة تطوير البرامج (SDK) من Flutter
  2. محرِّر Visual Studio Code مع المكوّن الإضافي Flutter
  3. برنامج مترجم للغة البرمجة التي اخترتها. (يجب استخدام Visual Studio لاستهداف Windows أو Xcode لاستهداف macOS أو iOS)

في القسم التالي، ستنشئ مشروع Flutter الأول.

إذا كنت بحاجة إلى تحديد المشاكل وحلّها، قد تجد بعض هذه الأسئلة والأجوبة (من StackOverflow) مفيدة في تحديد المشاكل وحلّها.

الأسئلة الشائعة

3- إنشاء مشروع

إنشاء مشروع Flutter الأول

يتضمّن ذلك فتح VS Code وإنشاء نموذج تطبيق Flutter في دليل تختاره.

  1. افتح محرِّر Visual Studio Code.
  2. افتح لوحة الأوامر (F1 أو Ctrl+Shift+P أو Shift+Cmd+P) ثم اكتب "flutter new". عندما يظهر، اختَر الأمر Flutter: New Project.

‫VS Code مع

  1. انقر على تطبيق فارغ (Empty Application). اختَر دليلًا لإنشاء مشروعك فيه. يجب أن يكون هذا أي دليل لا يتطلب أذونات مميزة وعالية المستوى أو يحتوي على مسافة في مساره. وتشمل الأمثلة الدليل الرئيسي أو C:\src\.

VS Code مع عرض تطبيق فارغ على أنّه محدّد كجزء من مسار التطبيق الجديد

  1. حدِّد اسمًا لمشروعك brick_breaker. يفترض الجزء المتبقي من هذا الدرس التطبيقي العملي أنّك سمّيت تطبيقك brick_breaker.

‫VS Code مع

ينشئ Flutter الآن مجلد مشروعك ويفتحه VS Code. ستستبدل الآن محتوى ملفَين بهيكل أساسي للتطبيق.

نسخ التطبيق الأوّلي ولصقه

يؤدي ذلك إلى إضافة الرمز البرمجي النموذجي المقدَّم في هذا الدرس العملي إلى تطبيقك.

  1. في اللوحة اليمنى من VS Code، انقر على المستكشف (Explorer) وافتح الملف pubspec.yaml.

لقطة شاشة جزئية لـ VS Code مع أسهم تسلّط الضوء على موقع ملف pubspec.yaml

  1. استبدِل محتوى هذا الملف بما يلي:

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 المعلومات الأساسية عن تطبيقك، مثل الإصدار الحالي والتبعيات والأصول التي سيتم تضمينها في الحزمة.

  1. افتح الملف main.dart في الدليل lib/.

لقطة شاشة جزئية لـ VS Code مع سهم يوضّح مكان ملف main.dart

  1. استبدِل محتوى هذا الملف بما يلي:

lib/main.dart

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

void main() {
  final game = FlameGame();
  runApp(GameWidget(game: game));
}
  1. نفِّذ هذه التعليمات البرمجية للتأكّد من أنّ كل شيء يعمل بشكل سليم. من المفترض أن تظهر نافذة جديدة بخلفية سوداء فارغة فقط. أصبحت الآن لعبة الفيديو الأسوأ في العالم تعمل بمعدّل 60 لقطة في الثانية!

لقطة شاشة تعرض نافذة تطبيق brick_breaker سوداء بالكامل.

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.

  1. أنشئ ملفًا باسم play_area.dart في دليل جديد باسم lib/src/components.
  2. أضِف ما يلي إلى هذا الملف.

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 للتعبير عن آليات اللعب. سيبدأ هذا الدرس العملي بأساسيات حلقة اللعبة، كما هو موضّح في الخطوة التالية.

  1. للتحكّم في الفوضى، أضِف ملفًا يحتوي على جميع المكوّنات في هذا المشروع. أنشئ ملف components.dart في lib/src/components وأضِف المحتوى التالي.

lib/src/components/components.dart

export 'play_area.dart';

يؤدي التوجيه export الدور المعاكس للتوجيه import. يحدّد هذا الملف الوظائف التي يعرضها عند استيراده إلى ملف آخر. سيحتوي هذا الملف على المزيد من الإدخالات عند إضافة مكوّنات جديدة في الخطوات التالية.

إنشاء لعبة Flame

لإزالة الخطوط الحمراء المتعرّجة من الخطوة السابقة، أنشئ فئة فرعية جديدة لـ FlameGame في Flame.

  1. أنشئ ملفًا باسم 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 الذي تمّت الكتابة فوقه، ينفّذ الرمز إجراءَين.

  1. تضبط هذه السمة الركن العلوي الأيسر كنقطة تثبيت لعدسة الكاميرا. بشكلٍ تلقائي، تستخدم viewfinder منتصف المنطقة كنقطة ارتساء (0,0).
  2. تضيف هذه السمة PlayArea إلى world. يمثّل العالم عالم اللعبة. تعرض هذه السمة جميع العناصر التابعة لها من خلال عملية تحويل العرض CameraComponents.

عرض المباراة على الشاشة

للاطّلاع على جميع التغييرات التي أجريتها في هذه الخطوة، عدِّل ملف 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));
}

بعد إجراء هذه التغييرات، أعِد تشغيل اللعبة. يجب أن تشبه اللعبة الشكل التالي.

لقطة شاشة تعرض نافذة تطبيق brick_breaker مع مستطيل بلون رملي في منتصف نافذة التطبيق

في الخطوة التالية، ستضيف كرة إلى العالم، وستجعلها تتحرّك.

5- عرض الكرة

إنشاء مكوّن الكرة

يتطلّب وضع كرة متحرّكة على الشاشة إنشاء مكوّن آخر وإضافته إلى عالم اللعبة.

  1. عدِّل محتوى ملف 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 لاستكشاف كيف يتغيّر شكل اللعبة ومظهرها نتيجةً لذلك.

  1. أنشئ مكوّن 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. إليك كيفية تنفيذ تعديل محاكاة منفصلة للحركة بمرور الوقت.

  1. لتضمين مكوّن 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. يضمن ذلك الحفاظ على سرعة الكرة ثابتة بغض النظر عن الاتجاه الذي تتجه إليه. بعد ذلك، يتم رفع سرعة الكرة لتصبح ربع ارتفاع اللعبة.

يتطلّب الحصول على هذه القيم المختلفة بعض التكرار، وهو ما يُعرف أيضًا باسم اختبار اللعب في المجال.

يؤدي السطر الأخير إلى تفعيل عرض تصحيح الأخطاء، ما يضيف معلومات إضافية إلى الشاشة للمساعدة في تصحيح الأخطاء.

عند تشغيل اللعبة الآن، من المفترض أن تظهر على النحو التالي.

لقطة شاشة تعرض نافذة تطبيق &quot;كسر الطوب&quot; مع دائرة زرقاء فوق مستطيل بلون رملي يتم إضافة تعليقات توضيحية إلى الدائرة الزرقاء تتضمّن أرقامًا تشير إلى حجمها وموقعها على الشاشة

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

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

6. التنقّل بين النوافذ

إضافة ميزة رصد التصادم

تضيف ميزة "رصد التصادم" سلوكًا يتعرّف من خلاله تطبيق لعبتك على الحالات التي يتلامس فيها عنصران.

لإضافة ميزة رصد التصادم إلى اللعبة، أضِف HasCollisionDetection mixin إلى اللعبة 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

إنشاء الخفاش

لإضافة مضرب لإبقاء الكرة في اللعب داخل اللعبة، اتّبِع الخطوات التالية:

  1. أدرِج بعض الثوابت في ملف 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 مقدار الخطوات التي تخطوها الخفّاش عند الضغط على مفتاح السهم المتّجه لليسار أو لليمين.

  1. حدِّد فئة المكوّن 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: Effects. من خلال إضافة الكائن MoveToEffect كعنصر ثانوي لهذا المكوّن، يرى اللاعب الخفاش وهو يتحرّك إلى موضع جديد. تتوفّر مجموعة من Effect في Flame لتنفيذ مجموعة متنوعة من المؤثرات.

تتضمّن وسيطات الدالة الإنشائية الخاصة بـ Effect مرجعًا إلى أداة الجلب game. لهذا السبب، عليك تضمين mixin HasGameReference في هذه الفئة. تضيف هذه الفئة المختلطة أداة وصول game آمنة من حيث النوع إلى هذا المكوّن للوصول إلى مثيل BrickBreaker في أعلى شجرة المكوّنات.

  1. لإتاحة 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. هدم الجدار

إنشاء الطوب

لإضافة مكعبات إلى اللعبة، اتّبِع الخطوات التالية:

  1. أدرِج بعض الثوابت في ملف 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.
  1. أدرِج المكوّن 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;
  }
}

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

لقطة شاشة تعرض لعبة brick_breaker مع كرة ومضرب ومعظم الطوب في منطقة اللعب تحتوي كلّ من المكوّنات على تصنيفات تصحيح الأخطاء

ما رأيك في إضافة شاشة ترحيب وشاشة "انتهت اللعبة" وربما نتيجة؟ يمكن أن تضيف Flutter هذه الميزات إلى اللعبة، وهذا ما ستركز عليه في الخطوة التالية.

9- الفوز بالمباراة

إضافة حالات التشغيل

في هذه الخطوة، عليك تضمين لعبة Flame داخل برنامج تضمين Flutter، ثم إضافة تراكبات Flutter لشاشات الترحيب وانتهاء اللعبة والفوز.

أولاً، عليك تعديل ملفات اللعبة والمكوّنات لتنفيذ حالة تشغيل تحدّد ما إذا كان سيتم عرض تراكب أم لا، وإذا كان سيتم عرضه، فسيتم تحديد نوعه.

  1. عدِّل اللعبة 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 جديدة. قبل هذا التغيير، كان بإمكانك بدء لعبة جديدة فقط من خلال إعادة تشغيل اللعبة. وبفضل هذه الإضافات الجديدة، يمكن للاعب الآن بدء مباراة جديدة بدون اتّخاذ مثل هذه الإجراءات الصارمة.

للسماح للاعب ببدء لعبة جديدة، عليك ضبط معالجَين جديدَين للعبة. أضفتَ معالجًا للنقر ووسّعتَ نطاق معالج لوحة المفاتيح لتمكين المستخدم من بدء لعبة جديدة في أوضاع متعددة. بعد تصميم حالة التشغيل، من المنطقي تعديل المكوّنات لتفعيل عمليات الانتقال إلى حالة التشغيل عندما يفوز اللاعب أو يخسر.

  1. عدِّل المكوّن 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. يجب أن يكون هذا الإعداد مناسبًا إذا سمح اللاعب للكرة بالخروج من أسفل الشاشة.

  1. عدِّل المكوّن 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.

  1. أنشئ الدليل widgets ضمن lib/src.
  2. أضِف ملف 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. محاولة ضبط تراكب غير موجود في هذه الخريطة تؤدي إلى ظهور وجوه حزينة في كل مكان.

  1. للحصول على هذه الوظيفة الجديدة على الشاشة، استبدِل الملف 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، عليك تعديل ملفَين.

  1. عدِّل الملف 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>
  1. عدِّل الملف 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. يتيح ذلك لرمز اللعبة تعديل النتيجة في كل مرة يكسر فيها اللاعب طوبة.

  1. عدِّل اللعبة 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.

  1. عدِّل الفئة 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، حان الوقت لتجميع الأدوات لجعلها تبدو رائعة.

  1. أنشئ 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!,
          ),
        );
      },
    );
  }
}
  1. أنشئ ملف 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 الست المستهدَفة. يجب أن تبدو اللعبة على النحو التالي.

لقطة شاشة من لعبة brick_breaker تعرض شاشة ما قبل اللعبة تدعو المستخدم إلى النقر على الشاشة لتشغيل اللعبة

لقطة شاشة للعبة brick_breaker تعرض شاشة &quot;انتهت اللعبة&quot; فوق مضرب وبعض المكعبات

11. تهانينا

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

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

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

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

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