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

1. مقدمة

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

عند اكتمال اللعبة، من المفترض أن تبدو لعبتك مثل هذه الصورة المتحركة بتنسيق GIF، حتى لو كانت أبطأ.

تسجيل شاشة للعبة يتم لعبها تمت تسريع اللعبة بشكل ملحوظ.

ما ستتعرَّف عليه

  • آلية عمل أساسيات تطبيق Flame بدءًا من GameWidget
  • كيفية استخدام حلقة الألعاب
  • آلية عمل Component's Flame وهي تشبه عناصر Widget في Flutter.
  • كيفية التعامل مع التصادمات.
  • طريقة استخدام Effect لإضافة تأثيرات متحركة إلى رموز Component
  • كيفية تركيب Flutter Widget في أعلى لعبة Flame
  • كيفية دمج Flame مع إدارة حالات Flutter

ما الذي ستقوم ببنائه

في هذا الدرس التطبيقي حول الترميز، ستنشئ لعبة ثنائية الأبعاد باستخدام Flutter وFlutter. عند اكتمال اللعبة، يجب أن تستوفي اللعبة المتطلبات التالية

  • تعمل على جميع الأنظمة الأساسية الست المتوافقة مع 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 IDE أخرى أو Emacs أو Vim أو Notepad++. وتعمل جميعها مع Flutter.

لقطة شاشة لـ VS Code مع بعض رموز Flutter

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

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

  • iOS
  • Android
  • Windows
  • نظام التشغيل Mac
  • Linux
  • الويب

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

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

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

قد تميل إلى اختيار الويب كهدف للتطوير. ويمثّل ذلك جانبًا سلبيًا أثناء عملية التطوير، حيث ستفقد إمكانية إعادة التحميل باستخدام الحالة (Stateful Hot Reload) في Flutter. يتعذّر على Flutter حاليًا إعادة تحميل تطبيقات الويب على الفور.

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

تثبيت Flutter

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

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

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

في القسم التالي، عليك إنشاء مشروعك الأول على Flutter.

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

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

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

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

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

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

لقطة شاشة لـ VS Code مع

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

لقطة شاشة لـ VS Code يظهر فيها Blank Application (تطبيق فارغ) على أنّه محدّد كجزء من مسار الطلب الجديد

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

لقطة شاشة لـ VS Code مع

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

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

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

  1. في الجزء الأيمن من رمز VS، انقر على 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.3.0 <4.0.0'

dependencies:
  flame: ^1.16.0
  flutter:
    sdk: flutter
  flutter_animate: ^4.5.0
  google_fonts: ^6.1.0

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^3.0.1

flutter:
  uses-material-design: true

يحدِّد ملف pubspec.yaml المعلومات الأساسية عن تطبيقك، مثل إصداره الحالي وتبعياته ومواد العرض التي سيتم شحنه بها.

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

لقطة شاشة جزئية لرمز VS مع سهم يوضّح موقع ملف 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)في منتصف الشاشة، وتنقل القيم الموجبة العناصر إلى اليمين على طول المحور "س" وأعلى على طول المحور "ص". ينطبق هذا المعيار على معظم الألعاب الحالية في الوقت الحالي، خاصةً عند الألعاب التي تتضمّن ثلاثة سمات.

كان التنسيق المتعارف عليه عند إنشاء لعبة Breakout الأصلية هو تحديد المصدر في أعلى يمين الشاشة. ظلّ اتجاه x الموجب كما هو، بينما قلب y. كان اتجاه 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 وFloome. شجرة التطبيقات المصغّرة في 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. لضبط الجزء العلوي الأيسر كنقطة ارتساء لعدسة الكاميرا. وفقًا للإعدادات التلقائية، تستخدم عدسة الكاميرا منتصف المنطقة كنقطة ارتساء لـ (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. يحافظ هذا على سرعة الكرة ثابتة بغض النظر عن اتجاه الكرة. يتم بعد ذلك زيادة سرعة الكرة لتكون 1/4 ارتفاع اللعبة.

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

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

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

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

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

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

6- ارتداد

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

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

لإضافة ميزة "رصد الاصطدام" إلى اللعبة، أضِف تشكيلة "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's Effects.

بعد أن اصطدمت الكرة بجدران اللعبة، سيكون من المفيد منح اللاعب مضربًا لضرب الكرة...

7. اضرب الكرة

اربح الخفافيش

لإضافة مضرب لإبقاء الكرة في اللعب داخل اللعبة،

  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),
    ));
  }
}

يقدم هذا المكون بعض الإمكانات الجديدة.

أولاً، مكوِّن "خفاش" هو PositionComponent، وليس RectangleComponent أو CircleComponent. يعني هذا أنّ هذا الرمز يحتاج إلى عرض Bat على الشاشة. لتنفيذ هذا الإجراء، يتم إلغاء طلب معاودة الاتصال "render".

وعند النظر عن كثب إلى مكالمة canvas.drawRRect (رسم مستطيل مستدير)، وقد تسأل نفسك، "أين المستطيل؟" يستفيد Offset.zero & size.toSize() من حِمل operator & الزائد على الفئة Offset dart:ui التي تنشئ Rect. قد يؤدّي هذا الاختصار إلى إرباكك في البداية، ولكنّه ستراه بشكلٍ متكرّر عند استخدام رمزَي Flutter وFlutter ضمن المستوى الأدنى.

ثانيًا، يمكن سحب مكوّن Bat هذا باستخدام الإصبع أو الماوس حسب النظام الأساسي. لتنفيذ هذه الوظيفة، يمكنك إضافة تشكيلة "DragCallbacks" وإلغاء الحدث onDragUpdate.

وأخيرًا، يحتاج المكوِّن Bat للاستجابة للتحكّم بلوحة المفاتيح. تتيح الوظيفة moveBy لرموز برمجية أخرى توجيه هذا المضرب إلى اليمين أو اليسار بمقدار عدد معيّن من وحدات البكسل الافتراضية. تقدّم هذه الدالة ميزة جديدة في محرّك ألعاب Flame: Effects. من خلال إضافة الكائن MoveToEffect كعنصر ثانوي لهذا المكوِّن، يرى اللاعب المضرب متحركًا في موضع جديد. تتوفّر مجموعة من Effect في تطبيق Flame لتنفيذ مجموعة متنوّعة من التأثيرات.

تتضمن وسيطات الدالة الإنشائية للتأثير إشارة إلى دالة getter، game. ولهذا السبب، يتم تضمين تشكيلة "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(Bat(                                              // Add from here...
        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
}

تؤدي إضافة مزيج 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(                                       // Modify from here...
          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 {                                                    // 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;
  }
}

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

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

هل يمكنك عرض شاشة ترحيب وعرض مباراة على الشاشة أو عرض نتيجة؟ يمكن أن يضيف 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 الكثير من العمل. يشير هذا إلى الموضع الذي يدخل فيه اللاعب إلى اللعبة أو يلعبها أو يخسرها أو يفوز بها. في أعلى الملف، يمكنك تحديد التعداد، ثم إنشاء مثيل له كحالة مخفية باستخدام قيم get وsets مطابقة. تتيح هذه الأدوات والإعدادات إمكانية تعديل العناصر المركّبة عندما يتم تبديل حالة اللعب في أجزاء مختلفة من اللعبة.

بعد ذلك، قسَّمت الرمز في 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

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.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(
        useMaterial3: true,
        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(
        useMaterial3: true,
        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 وFlutter

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

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

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

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