مقدّمة حول 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
  • نظام التشغيل Mac
  • 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. انقر على تطبيق فارغ. اختَر دليلاً لإنشاء مشروعك فيه. يجب أن يكون هذا أي دليل لا يتطلب امتيازات مرتفعة أو يحتوي على مسافة في مساره. وتشمل الأمثلة الدليل الرئيسي أو 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. يمثّل العالم عالم اللعبة. تعرض جميع العناصر الفرعية من خلال عملية تحويل العرض 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));
}

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

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

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

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

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

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

يحتوي كلّ من المكوّن 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

إنشاء ملف الدفعات

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

  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 تعرض شاشة انتهاء اللعبة فوق مضرب وبعض المكعبات

11. تهانينا

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

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

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

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

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