با Flutter و Flame یک بازی فیزیک دو بعدی بسازید

1. قبل از شروع

Flame یک موتور بازی دو بعدی مبتنی بر فلاتر است. در این کد لبه، شما یک بازی می سازید که از یک شبیه سازی فیزیک دو بعدی در راستای خطوط Box2D به نام Forge2D استفاده می کند. شما از اجزای Flame برای ترسیم واقعیت فیزیکی شبیه سازی شده روی صفحه استفاده می کنید تا کاربران با آن بازی کنند. پس از تکمیل، بازی شما باید شبیه این گیف متحرک باشد:

انیمیشن بازی با این بازی فیزیک دو بعدی

پیش نیازها

آنچه یاد می گیرید

  • اصول اولیه Forge2D چگونه کار می کند، با انواع مختلف بدن های فیزیکی شروع می شود.
  • نحوه راه اندازی یک شبیه سازی فیزیکی به صورت دو بعدی

آنچه شما نیاز دارید

نرم افزار کامپایلر برای هدف توسعه انتخابی شما. این کد لبه برای هر شش پلت فرمی که Flutter پشتیبانی می کند کار می کند. برای هدف قرار دادن ویندوز به Visual Studio، Xcode برای هدف قرار دادن macOS یا iOS و Android Studio برای هدف قرار دادن اندروید نیاز دارید.

2. یک پروژه ایجاد کنید

پروژه Flutter خود را ایجاد کنید

راه های زیادی برای ایجاد پروژه فلاتر وجود دارد. در این قسمت از خط فرمان برای اختصار استفاده می کنید.

برای شروع، این مراحل را دنبال کنید:

  1. در خط فرمان، یک پروژه Flutter ایجاد کنید:
$ flutter create --empty forge2d_game
Creating project forge2d_game...
Resolving dependencies in forge2d_game... (4.7s)
Got dependencies in forge2d_game.
Wrote 128 files.

All done!
You can find general documentation for Flutter at: https://docs.flutter.dev/
Detailed API documentation is available at: https://api.flutter.dev/
If you prefer video documentation, consider: https://www.youtube.com/c/flutterdev

In order to run your empty application, type:

  $ cd forge2d_game
  $ flutter run

Your empty application code is in forge2d_game/lib/main.dart.
  1. وابستگی های پروژه را برای اضافه کردن Flame و Forge2D تغییر دهید:
$ cd forge2d_game
$ flutter pub add characters flame flame_forge2d flame_kenney_xml xml
Resolving dependencies... 
Downloading packages... 
  characters 1.3.0 (from transitive dependency to direct dependency)
  collection 1.18.0 (1.19.0 available)
+ flame 1.18.0
+ flame_forge2d 0.18.1
+ flame_kenney_xml 0.1.0
  flutter_lints 3.0.2 (4.0.0 available)
+ forge2d 0.13.0
  leak_tracker 10.0.4 (10.0.5 available)
  leak_tracker_flutter_testing 3.0.3 (3.0.5 available)
  lints 3.0.0 (4.0.0 available)
  material_color_utilities 0.8.0 (0.12.0 available)
  meta 1.12.0 (1.15.0 available)
+ ordered_set 5.0.3 (6.0.1 available)
+ petitparser 6.0.2
  test_api 0.7.0 (0.7.3 available)
  vm_service 14.2.1 (14.2.4 available)
+ xml 6.5.0
Changed 8 dependencies!
10 packages have newer versions incompatible with dependency constraints.
Try `flutter pub outdated` for more information.

بسته flame برای شما آشناست، اما سه مورد دیگر ممکن است نیاز به توضیح داشته باشند. بسته characters برای دستکاری مسیر فایل به روشی مطابق با UTF8 استفاده می شود. بسته flame_forge2d عملکرد Forge2D را به گونه ای نشان می دهد که به خوبی با Flame کار می کند. در نهایت، بسته xml در مکان های مختلف برای مصرف و اصلاح محتوای XML استفاده می شود.

پروژه را باز کنید و سپس محتوای فایل lib/main.dart را با موارد زیر جایگزین کنید:

lib/main.dart

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

void main() {
  runApp(
    GameWidget.controlled(
      gameFactory: FlameGame.new,
    ),
  );
}

این برنامه را با یک GameWidget شروع می کند که نمونه FlameGame نمونه سازی می کند. هیچ کد Flutter در این Codelab وجود ندارد که از وضعیت نمونه بازی برای نمایش اطلاعات مربوط به بازی در حال اجرا استفاده کند، بنابراین این بوت استرپ ساده شده به خوبی کار می کند.

اختیاری: یک جستجوی جانبی فقط macOS داشته باشید

اسکرین شات های این پروژه از بازی به عنوان یک برنامه دسکتاپ macOS است. برای اینکه نوار عنوان برنامه باعث کاهش تجربه کلی نشود، می‌توانید پیکربندی پروژه macOS runner را تغییر دهید تا نوار عنوان حذف شود.

برای انجام این کار، این مراحل را دنبال کنید:

  1. یک فایل bin/modify_macos_config.dart ایجاد کنید و محتوای زیر را اضافه کنید:

bin/modify_macos_config.dart

import 'dart:io';
import 'package:xml/xml.dart';
import 'package:xml/xpath.dart';

void main() {
  final file = File('macos/Runner/Base.lproj/MainMenu.xib');
  var document = XmlDocument.parse(file.readAsStringSync());
  document.xpath('//document/objects/window').first
    ..setAttribute('titlebarAppearsTransparent', 'YES')
    ..setAttribute('titleVisibility', 'hidden');
  document
      .xpath('//document/objects/window/windowStyleMask')
      .first
      .setAttribute('fullSizeContentView', 'YES');
  file.writeAsStringSync(document.toString());
}

این فایل در دایرکتوری lib نیست زیرا بخشی از پایگاه کد زمان اجرا برای بازی نیست. این یک ابزار خط فرمان است که برای اصلاح پروژه استفاده می شود.

  1. از دایرکتوری پایه پروژه، ابزار را به صورت زیر اجرا کنید:
$ dart bin/modify_macos_config.dart

اگر همه چیز طبق برنامه پیش برود، برنامه هیچ خروجی در خط فرمان تولید نخواهد کرد. با این حال، فایل پیکربندی macos/Runner/Base.lproj/MainMenu.xib را تغییر می‌دهد تا بازی را بدون نوار عنوان قابل مشاهده اجرا کند و بازی Flame کل پنجره را اشغال کند.

بازی را اجرا کنید تا مطمئن شوید همه چیز کار می کند. باید یک پنجره جدید فقط با یک پس زمینه سیاه خالی نمایش دهد.

یک پنجره برنامه با پس زمینه سیاه و هیچ چیز در پیش زمینه

3. دارایی های تصویر را اضافه کنید

تصاویر را اضافه کنید

هر بازی به دارایی های هنری نیاز دارد تا بتواند صفحه نمایش را به گونه ای نقاشی کند که از سرگرمی استفاده کند. این کد آزمایشگاه از بسته دارایی های فیزیک از Kenney.nl استفاده می کند. این دارایی‌ها دارای مجوز Creative Commons CC0 هستند، اما من همچنان اکیداً پیشنهاد می‌کنم که به تیم Kenney کمک مالی کنید تا بتوانند به کار بزرگی که انجام می‌دهند ادامه دهند. من انجام دادم.

شما باید فایل پیکربندی pubspec.yaml را تغییر دهید تا امکان استفاده از دارایی های Kenney فراهم شود. آن را به صورت زیر اصلاح کنید:

pubspec.yaml

name: forge2d_game
description: "A new Flutter project."
publish_to: 'none'
version: 0.1.0

environment:
  sdk: '>=3.3.3 <4.0.0'

dependencies:
  characters: ^1.3.0
  flame: ^1.17.0
  flame_forge2d: ^0.18.0
  flutter:
    sdk: flutter
  xml: ^6.5.0

dev_dependencies:
  flutter_lints: ^3.0.0
  flutter_test:
    sdk: flutter

flutter:
  uses-material-design: true
  assets:                        # Add from here
    - assets/
    - assets/images/             # To here.

Flame انتظار دارد که دارایی‌های تصویر در assets/images قرار گیرند، اگرچه می‌توان آن را متفاوت پیکربندی کرد. برای جزئیات بیشتر به مستندات تصاویر Flame مراجعه کنید. اکنون که مسیرها را پیکربندی کرده اید، باید آنها را به خود پروژه اضافه کنید. یکی از راه های انجام این کار استفاده از خط فرمان به صورت زیر است:

$ mkdir -p assets/images

هیچ خروجی از دستور mkdir نباید وجود داشته باشد، اما دایرکتوری جدید باید در ویرایشگر شما یا کاوشگر فایل قابل مشاهده باشد.

فایل kenney_physics-assets.zip را که دانلود کرده اید گسترش دهید، و باید چیزی شبیه به این را ببینید:

فهرستی از فایل‌های بسته kenney_physics-assets با فهرست PNG/Backgrounds برجسته شده است.

از پوشه PNG/Backgrounds ، فایل‌های colored_desert.png ، colored_grass.png ، colored_land.png و colored_shroom.png را در فهرست assets/images پروژه خود کپی کنید.

ورق های اسپرایت نیز وجود دارد. اینها ترکیبی از یک تصویر PNG و یک فایل XML هستند که توضیح می‌دهند در کجای تصویر صفحه اسپریت ممکن است تصاویر کوچک‌تری پیدا شوند. Spritesheets تکنیکی برای کاهش زمان بارگذاری تنها با بارگذاری یک فایل است در مقابل ده ها، اگر نه صدها فایل تصویری مجزا.

فهرستی از فایل‌های بسته kenney_physics-assets گسترش یافته است، با فهرست Spritesheet برجسته شده است.

از spritesheet_aliens.png ، spritesheet_elements.png و spritesheet_tiles.png در فهرست assets/images پروژه خود کپی کنید. وقتی اینجا هستید، فایل‌های spritesheet_aliens.xml ، spritesheet_elements.xml و spritesheet_tiles.xml را نیز در فهرست assets پروژه خود کپی کنید. پروژه شما باید شبیه زیر باشد.

فهرستی از فایل فهرست راهنمای پروژه forge2d_game، که فهرست دارایی‌ها برجسته شده است

پس زمینه را رنگ کنید

اکنون که پروژه شما دارایی های تصویری اضافه شده است، وقت آن است که آنها را روی صفحه نمایش دهید. خوب، یک تصویر روی صفحه نمایش. در مراحل زیر بیشتر خواهد آمد.

یک فایل به نام background.dart در دایرکتوری جدیدی به نام lib/components ایجاد کنید و محتوای زیر را اضافه کنید.

lib/components/background.dart

import 'dart:math';
import 'package:flame/components.dart';
import 'game.dart';

class Background extends SpriteComponent with HasGameReference<MyPhysicsGame> {
  Background({required super.sprite})
      : super(
          anchor: Anchor.center,
          position: Vector2(0, 0),
        );

  @override
  void onMount() {
    super.onMount();

    size = Vector2.all(max(
      game.camera.visibleWorldRect.width,
      game.camera.visibleWorldRect.height,
    ));
  }
}

این کامپوننت یک SpriteComponent تخصصی است. این مسئول نمایش یکی از چهار تصویر پس زمینه Kenney.nl است. چند فرض ساده کننده در این کد وجود دارد. اولین مورد این است که تصاویر مربع هستند، که هر چهار تصویر پس زمینه از Kenney هستند. دوم این است که اندازه جهان قابل مشاهده هرگز تغییر نخواهد کرد، در غیر این صورت این مؤلفه باید رویدادهای تغییر اندازه بازی را مدیریت کند. فرض سوم این است که موقعیت (0,0) در مرکز صفحه باشد. این مفروضات به پیکربندی خاصی از CameraComponent بازی نیاز دارند.

یک فایل جدید دیگر، به نام game.dart ، دوباره در دایرکتوری lib/components ایجاد کنید.

lib/components/game.dart

import 'dart:async';

import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flame_kenney_xml/flame_kenney_xml.dart';

import 'background.dart';

class MyPhysicsGame extends Forge2DGame {
  MyPhysicsGame()
      : super(
          gravity: Vector2(0, 10),
          camera: CameraComponent.withFixedResolution(width: 800, height: 600),
        );

  late final XmlSpriteSheet aliens;
  late final XmlSpriteSheet elements;
  late final XmlSpriteSheet tiles;

  @override
  FutureOr<void> onLoad() async {
    final backgroundImage = await images.load('colored_grass.png');
    final spriteSheets = await Future.wait([
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_aliens.png',
        xmlPath: 'spritesheet_aliens.xml',
      ),
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_elements.png',
        xmlPath: 'spritesheet_elements.xml',
      ),
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_tiles.png',
        xmlPath: 'spritesheet_tiles.xml',
      ),
    ]);

    aliens = spriteSheets[0];
    elements = spriteSheets[1];
    tiles = spriteSheets[2];

    await world.add(Background(sprite: Sprite(backgroundImage)));

    return super.onLoad();
  }
}

اینجا خیلی اتفاق می افتد. بیایید با کلاس MyPhysicsGame شروع کنیم. بر خلاف کدهای قبلی، این Forge2DGame گسترش می دهد نه FlameGame . خود Forge2DGame FlameGame با چند ترفند جالب گسترش می دهد. اولین مورد این است که به‌طور پیش‌فرض، zoom روی 10 تنظیم شده است. این تنظیم zoom مربوط به محدوده مقادیر مفیدی است که موتورهای شبیه‌سازی فیزیک سبک Box2D به خوبی با آن کار می‌کنند. موتور با استفاده از سیستم MKS نوشته شده است که در آن واحدها بر حسب متر، کیلوگرم و ثانیه فرض می شوند. محدوده ای که در آن خطاهای ریاضی قابل توجهی برای اشیا مشاهده نمی کنید از 0.1 متر تا 10 ثانیه متر است. تغذیه در ابعاد پیکسل به طور مستقیم بدون مقداری کاهش مقیاس، Forge2D را از پوشش مفید خود خارج می کند. خلاصه مفید این است که به شبیه سازی اجسام در محدوده قوطی نوشابه تا اتوبوس فکر کنید.

فرضیات ساخته شده در مولفه Background در اینجا با تثبیت وضوح CameraComponent روی 800 در 600 پیکسل مجازی برآورده می شود. این بدان معنی است که منطقه بازی 80 واحد عرض و 60 واحد ارتفاع خواهد داشت که در مرکز (0,0) قرار دارد. این هیچ تاثیری بر وضوح نمایش داده شده ندارد، اما در جایی که ما اشیاء را در صحنه بازی قرار می دهیم، تاثیر می گذارد.

در کنار آرگومان سازنده camera ، آرگومان دیگری هم تراز با فیزیک به نام gravity وجود دارد. گرانش بر روی یک Vector2 با x 0 و y از 10 تنظیم شده است. این واقعیت که گرانش روی مثبت 10 تنظیم شده است نشان می دهد که در این سیستم جهت محور Y پایین است. که به طور کلی با Box2D متفاوت است، اما مطابق با نحوه پیکربندی Flame است.

بعدی روش onLoad است. این روش ناهمزمان است، که مناسب است زیرا مسئول بارگذاری دارایی های تصویر از دیسک است. فراخوانی به images.load یک Future<Image> را برمی گرداند و به عنوان یک اثر جانبی تصویر بارگذاری شده را در شی Game ذخیره می کند. این قراردادهای آتی با هم جمع‌آوری می‌شوند و با استفاده از روش استاتیک Futures.wait به عنوان یک واحد در انتظار می‌روند. سپس لیست تصاویر برگشتی با نام های فردی مطابقت داده می شود.

سپس تصاویر spriteshet به یک سری از اشیاء XmlSpriteSheet وارد می شوند که مسئول بازیابی Sprites با نام جداگانه موجود در spriteshe هستند. کلاس XmlSpriteSheet در بسته flame_kenney_xml تعریف شده است.

با وجود تمام این موارد، شما فقط به چند ویرایش جزئی در lib/main.dart نیاز دارید تا تصویری را روی صفحه نمایش دهید.

lib/main.dart

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

import 'components/game.dart';                             // Add this import

void main() {
  runApp(
    GameWidget.controlled(
      gameFactory: MyPhysicsGame.new,                      // Modify this line
    ),
  );
}

با این تغییر ساده اکنون می توانید بازی را دوباره اجرا کنید تا پس زمینه را روی صفحه ببینید. توجه داشته باشید، نمونه دوربین CameraComponent.withFixedResolution() جعبه نامه را در صورت نیاز اضافه می کند تا نسبت 800 به 600 بازی را به کار گیرد.

یک پنجره برنامه با تصویر پس‌زمینه تپه‌های سبز و درختان انتزاعی عجیب.

4. زمین را اضافه کنید

چیزی برای ساختن

اگر جاذبه داشته باشیم، به چیزی نیاز داریم تا اشیا را در بازی قبل از افتادن از پایین صفحه بگیریم. البته، مگر اینکه افتادن از صفحه نمایش بخشی از طراحی بازی شما باشد. یک فایل ground.dart جدید در دایرکتوری lib/components خود ایجاد کنید و موارد زیر را به آن اضافه کنید:

lib/components/ground.dart

import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';

const groundSize = 7.0;

class Ground extends BodyComponent {
  Ground(Vector2 position, Sprite sprite)
      : super(
          renderBody: false,
          bodyDef: BodyDef()
            ..position = position
            ..type = BodyType.static,
          fixtureDefs: [
            FixtureDef(
              PolygonShape()..setAsBoxXY(groundSize / 2, groundSize / 2),
              friction: 0.3,
            )
          ],
          children: [
            SpriteComponent(
              anchor: Anchor.center,
              sprite: sprite,
              size: Vector2.all(groundSize),
              position: Vector2(0, 0),
            ),
          ],
        );
}

این مولفه Ground از BodyComponent مشتق شده است. در Forge2D اجسام مهم هستند، آنها اجسامی هستند که بخشی از شبیه سازی فیزیکی دو بعدی هستند. BodyDef برای این جزء مشخص شده است که BodyType.static داشته باشد.

در Forge2D بدن ها سه نوع مختلف دارند. اجسام ساکن حرکت نمی کنند. آنها عملاً هم جرم صفر دارند - به گرانش واکنش نشان نمی دهند - و هم جرم بی نهایت - در هنگام برخورد با اجسام دیگر حرکت نمی کنند، مهم نیست چقدر سنگین هستند. این اجسام ساکن را برای سطح زمین عالی می کند، زیرا حرکت نمی کند.

دو نوع دیگر بدن، سینماتیک و دینامیک هستند. اجسام پویا اجسامی هستند که به طور کامل شبیه سازی شده اند، به گرانش و اجسامی که به آنها برخورد می کنند واکنش نشان می دهند. بدنه های دینامیکی زیادی را در بقیه قسمت های این Codelab خواهید دید. اجسام سینماتیک یک خانه بین ایستا و دینامیک هستند. آنها حرکت می کنند، اما در برابر جاذبه یا اشیاء دیگر که به آنها برخورد می کنند واکنشی نشان نمی دهند. مفید است، اما فراتر از محدوده این نرم افزار.

خود بدن کار زیادی انجام نمی دهد. یک بدن برای داشتن ماده به اشکال مرتبط نیاز دارد. در این مورد، این بدنه یک شکل مرتبط دارد، یک مجموعه PolygonShape به عنوان یک BoxXY . این نوع جعبه برخلاف یک مجموعه PolygonShape به عنوان یک BoxXY که می تواند حول یک نقطه چرخش بچرخد، با جهان تراز است. باز هم مفید است، اما همچنین خارج از محدوده این Codelab. شکل و بدنه با یک فیکسچر به هم متصل شده اند که برای افزودن مواردی مانند friction به سیستم مفید است.

به‌طور پیش‌فرض، یک بدنه شکل‌های پیوست شده خود را به گونه‌ای ارائه می‌کند که برای اشکال‌زدایی مفید باشد، اما گیم‌پلی عالی را ایجاد نمی‌کند. تنظیم super آرگومان renderBody روی false این اشکال زدایی را غیرفعال می کند. دادن رندر درون بازی به این بدنه بر عهده فرزند SpriteComponent است.

برای افزودن کامپوننت Ground به بازی، فایل game.dart خود را به صورت زیر ویرایش کنید.

lib/components/game.dart

import 'dart:async';

import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flame_kenney_xml/flame_kenney_xml.dart';

import 'background.dart';
import 'ground.dart';                                      // Add this import

class MyPhysicsGame extends Forge2DGame {
  MyPhysicsGame()
      : super(
          gravity: Vector2(0, 10),
          camera: CameraComponent.withFixedResolution(width: 800, height: 600),
        );

  late final XmlSpriteSheet aliens;
  late final XmlSpriteSheet elements;
  late final XmlSpriteSheet tiles;

  @override
  FutureOr<void> onLoad() async {
    final backgroundImage = await images.load('colored_grass.png');
    final spriteSheets = await Future.wait([
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_aliens.png',
        xmlPath: 'spritesheet_aliens.xml',
      ),
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_elements.png',
        xmlPath: 'spritesheet_elements.xml',
      ),
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_tiles.png',
        xmlPath: 'spritesheet_tiles.xml',
      ),
    ]);

    aliens = spriteSheets[0];
    elements = spriteSheets[1];
    tiles = spriteSheets[2];

    await world.add(Background(sprite: Sprite(backgroundImage)));
    await addGround();                                     // Add this line

    return super.onLoad();
  }

  Future<void> addGround() {                               // Add from here...
    return world.addAll([
      for (var x = camera.visibleWorldRect.left;
          x < camera.visibleWorldRect.right + groundSize;
          x += groundSize)
        Ground(
          Vector2(x, (camera.visibleWorldRect.height - groundSize) / 2),
          tiles.getSprite('grass.png'),
        ),
    ]);
  }                                                        // To here.
}

این ویرایش با استفاده از یک حلقه for در داخل یک متن List ، و ارسال لیست حاصل از اجزای Ground به متد world addAll یک سری اجزای Ground را به جهان اضافه می کند.

اکنون اجرای بازی پس زمینه و زمین را نشان می دهد.

یک پنجره برنامه با پس زمینه و یک لایه زمین.

5. آجرها را اضافه کنید

ساختن دیوار

زمین نمونه ای از یک جسم ساکن را به ما داد. اکنون زمان اولین مؤلفه پویا است. اجزای پویا در Forge2D سنگ بنای تجربه بازیکن هستند، آنها چیزهایی هستند که حرکت می کنند و با دنیای اطراف خود تعامل دارند. در این مرحله آجرهایی را معرفی می‌کنید که به‌طور تصادفی انتخاب می‌شوند تا در دسته‌ای از آجرها روی صفحه ظاهر شوند. در حین انجام این کار، آنها را خواهید دید که می افتند و با یکدیگر برخورد می کنند.

آجرها از ورق اسپرایت عناصر ساخته می شوند. اگر به توضیحات صفحه sprite در assets/spritesheet_elements.xml نگاه کنید، می بینید که مشکل جالبی داریم. به نظر می رسد نام ها چندان مفید نیستند. آنچه مفید است می تواند یک آجر را بر اساس نوع مواد، اندازه آن و میزان آسیب انتخاب کند. خوشبختانه، یک جن مفید مدتی را صرف کشف الگوی نام‌گذاری فایل کرد و ابزاری برای آسان‌تر کردن آن برای همه شما ایجاد کرد. یک فایل جدید generate_brick_file_names.dart در پوشه bin ایجاد کنید و محتوای زیر را اضافه کنید:

bin/generate_brick_file_names.dart

import 'dart:io';
import 'package:equatable/equatable.dart';
import 'package:xml/xml.dart';
import 'package:xml/xpath.dart';

void main() {
  final file = File('assets/spritesheet_elements.xml');
  final rects = <String, Rect>{};
  final document = XmlDocument.parse(file.readAsStringSync());
  for (final node in document.xpath('//TextureAtlas/SubTexture')) {
    final name = node.getAttribute('name')!;
    rects[name] = Rect(
      x: int.parse(node.getAttribute('x')!),
      y: int.parse(node.getAttribute('y')!),
      width: int.parse(node.getAttribute('width')!),
      height: int.parse(node.getAttribute('height')!),
    );
  }
  print(generateBrickFileNames(rects));
}

class Rect extends Equatable {
  final int x;
  final int y;
  final int width;
  final int height;
  const Rect(
      {required this.x,
      required this.y,
      required this.width,
      required this.height});

  Size get size => Size(width, height);

  @override
  List<Object?> get props => [x, y, width, height];

  @override
  bool get stringify => true;
}

class Size extends Equatable {
  final int width;
  final int height;
  const Size(this.width, this.height);

  @override
  List<Object?> get props => [width, height];

  @override
  bool get stringify => true;
}

String generateBrickFileNames(Map<String, Rect> rects) {
  final groups = <Size, List<String>>{};
  for (final entry in rects.entries) {
    groups.putIfAbsent(entry.value.size, () => []).add(entry.key);
  }
  final buff = StringBuffer();
  buff.writeln('''
Map<BrickDamage, String> brickFileNames(BrickType type, BrickSize size) {
  return switch ((type, size)) {''');
  for (final entry in groups.entries) {
    final size = entry.key;
    final entries = entry.value;
    entries.sort();
    for (final type in ['Explosive', 'Glass', 'Metal', 'Stone', 'Wood']) {
      var filtered = entries.where((element) => element.contains(type));
      if (filtered.length == 5) {
        buff.writeln('''
    (BrickType.${type.toLowerCase()}, BrickSize.size${size.width}x${size.height}) => {
        BrickDamage.none: '${filtered.elementAt(0)}',
        BrickDamage.some: '${filtered.elementAt(1)}',
        BrickDamage.lots: '${filtered.elementAt(4)}',
      },''');
      } else if (filtered.length == 10) {
        buff.writeln('''
    (BrickType.${type.toLowerCase()}, BrickSize.size${size.width}x${size.height}) => {
        BrickDamage.none: '${filtered.elementAt(3)}',
        BrickDamage.some: '${filtered.elementAt(4)}',
        BrickDamage.lots: '${filtered.elementAt(9)}',
      },''');
      } else if (filtered.length == 15) {
        buff.writeln('''
    (BrickType.${type.toLowerCase()}, BrickSize.size${size.width}x${size.height}) => {
        BrickDamage.none: '${filtered.elementAt(7)}',
        BrickDamage.some: '${filtered.elementAt(8)}',
        BrickDamage.lots: '${filtered.elementAt(13)}',
      },''');
      }
    }
  }
  buff.writeln('''
  };
}''');
  return buff.toString();
}

ویرایشگر شما باید به شما هشدار یا خطایی در مورد یک وابستگی از دست رفته بدهد. به صورت زیر اضافه کنید:

$ flutter pub add equatable

اکنون باید بتوانید این برنامه را به صورت زیر اجرا کنید:

$ dart run bin/generate_brick_file_names.dart
Map<BrickDamage, String> brickFileNames(BrickType type, BrickSize size) {
  return switch ((type, size)) {
    (BrickType.explosive, BrickSize.size140x70) => {
        BrickDamage.none: 'elementExplosive009.png',
        BrickDamage.some: 'elementExplosive012.png',
        BrickDamage.lots: 'elementExplosive050.png',
      },
    (BrickType.glass, BrickSize.size140x70) => {
        BrickDamage.none: 'elementGlass010.png',
        BrickDamage.some: 'elementGlass013.png',
        BrickDamage.lots: 'elementGlass048.png',
      },
[Content elided...]
    (BrickType.wood, BrickSize.size140x220) => {
        BrickDamage.none: 'elementWood020.png',
        BrickDamage.some: 'elementWood025.png',
        BrickDamage.lots: 'elementWood052.png',
      },
  };
}

این ابزار به کمک فایل توضیحات صفحه sprite را تجزیه کرده و آن را به کد دارت تبدیل کرده است که می‌توانیم از آن برای انتخاب فایل تصویر مناسب برای هر آجری که می‌خواهید روی صفحه نمایش دهید استفاده کنیم. مفید!

فایل brick.dart را با محتوای زیر ایجاد کنید:

lib/components/brick.dart

import 'dart:math';
import 'dart:ui' as ui;

import 'package:flame/components.dart';
import 'package:flame/extensions.dart';
import 'package:flame_forge2d/flame_forge2d.dart';

const brickScale = 0.5;

enum BrickType {
  explosive(density: 1, friction: 0.5),
  glass(density: 0.5, friction: 0.2),
  metal(density: 1, friction: 0.4),
  stone(density: 2, friction: 1),
  wood(density: 0.25, friction: 0.6);

  final double density;
  final double friction;

  const BrickType({required this.density, required this.friction});
  static BrickType get randomType => values[Random().nextInt(values.length)];
}

enum BrickSize {
  size70x70(ui.Size(70, 70)),
  size140x70(ui.Size(140, 70)),
  size220x70(ui.Size(220, 70)),
  size70x140(ui.Size(70, 140)),
  size140x140(ui.Size(140, 140)),
  size220x140(ui.Size(220, 140)),
  size140x220(ui.Size(140, 220)),
  size70x220(ui.Size(70, 220));

  final ui.Size size;

  const BrickSize(this.size);
  static BrickSize get randomSize => values[Random().nextInt(values.length)];
}

enum BrickDamage { none, some, lots }

Map<BrickDamage, String> brickFileNames(BrickType type, BrickSize size) {
  return switch ((type, size)) {
    (BrickType.explosive, BrickSize.size140x70) => {
        BrickDamage.none: 'elementExplosive009.png',
        BrickDamage.some: 'elementExplosive012.png',
        BrickDamage.lots: 'elementExplosive050.png',
      },
    (BrickType.glass, BrickSize.size140x70) => {
        BrickDamage.none: 'elementGlass010.png',
        BrickDamage.some: 'elementGlass013.png',
        BrickDamage.lots: 'elementGlass048.png',
      },
    (BrickType.metal, BrickSize.size140x70) => {
        BrickDamage.none: 'elementMetal009.png',
        BrickDamage.some: 'elementMetal012.png',
        BrickDamage.lots: 'elementMetal050.png',
      },
    (BrickType.stone, BrickSize.size140x70) => {
        BrickDamage.none: 'elementStone009.png',
        BrickDamage.some: 'elementStone012.png',
        BrickDamage.lots: 'elementStone047.png',
      },
    (BrickType.wood, BrickSize.size140x70) => {
        BrickDamage.none: 'elementWood011.png',
        BrickDamage.some: 'elementWood014.png',
        BrickDamage.lots: 'elementWood054.png',
      },
    (BrickType.explosive, BrickSize.size70x70) => {
        BrickDamage.none: 'elementExplosive011.png',
        BrickDamage.some: 'elementExplosive014.png',
        BrickDamage.lots: 'elementExplosive049.png',
      },
    (BrickType.glass, BrickSize.size70x70) => {
        BrickDamage.none: 'elementGlass011.png',
        BrickDamage.some: 'elementGlass012.png',
        BrickDamage.lots: 'elementGlass046.png',
      },
    (BrickType.metal, BrickSize.size70x70) => {
        BrickDamage.none: 'elementMetal011.png',
        BrickDamage.some: 'elementMetal014.png',
        BrickDamage.lots: 'elementMetal049.png',
      },
    (BrickType.stone, BrickSize.size70x70) => {
        BrickDamage.none: 'elementStone011.png',
        BrickDamage.some: 'elementStone014.png',
        BrickDamage.lots: 'elementStone046.png',
      },
    (BrickType.wood, BrickSize.size70x70) => {
        BrickDamage.none: 'elementWood010.png',
        BrickDamage.some: 'elementWood013.png',
        BrickDamage.lots: 'elementWood045.png',
      },
    (BrickType.explosive, BrickSize.size220x70) => {
        BrickDamage.none: 'elementExplosive013.png',
        BrickDamage.some: 'elementExplosive016.png',
        BrickDamage.lots: 'elementExplosive051.png',
      },
    (BrickType.glass, BrickSize.size220x70) => {
        BrickDamage.none: 'elementGlass014.png',
        BrickDamage.some: 'elementGlass017.png',
        BrickDamage.lots: 'elementGlass049.png',
      },
    (BrickType.metal, BrickSize.size220x70) => {
        BrickDamage.none: 'elementMetal013.png',
        BrickDamage.some: 'elementMetal016.png',
        BrickDamage.lots: 'elementMetal051.png',
      },
    (BrickType.stone, BrickSize.size220x70) => {
        BrickDamage.none: 'elementStone013.png',
        BrickDamage.some: 'elementStone016.png',
        BrickDamage.lots: 'elementStone048.png',
      },
    (BrickType.wood, BrickSize.size220x70) => {
        BrickDamage.none: 'elementWood012.png',
        BrickDamage.some: 'elementWood015.png',
        BrickDamage.lots: 'elementWood047.png',
      },
    (BrickType.explosive, BrickSize.size70x140) => {
        BrickDamage.none: 'elementExplosive017.png',
        BrickDamage.some: 'elementExplosive022.png',
        BrickDamage.lots: 'elementExplosive052.png',
      },
    (BrickType.glass, BrickSize.size70x140) => {
        BrickDamage.none: 'elementGlass018.png',
        BrickDamage.some: 'elementGlass023.png',
        BrickDamage.lots: 'elementGlass050.png',
      },
    (BrickType.metal, BrickSize.size70x140) => {
        BrickDamage.none: 'elementMetal017.png',
        BrickDamage.some: 'elementMetal022.png',
        BrickDamage.lots: 'elementMetal052.png',
      },
    (BrickType.stone, BrickSize.size70x140) => {
        BrickDamage.none: 'elementStone017.png',
        BrickDamage.some: 'elementStone022.png',
        BrickDamage.lots: 'elementStone049.png',
      },
    (BrickType.wood, BrickSize.size70x140) => {
        BrickDamage.none: 'elementWood016.png',
        BrickDamage.some: 'elementWood021.png',
        BrickDamage.lots: 'elementWood048.png',
      },
    (BrickType.explosive, BrickSize.size140x140) => {
        BrickDamage.none: 'elementExplosive018.png',
        BrickDamage.some: 'elementExplosive023.png',
        BrickDamage.lots: 'elementExplosive053.png',
      },
    (BrickType.glass, BrickSize.size140x140) => {
        BrickDamage.none: 'elementGlass019.png',
        BrickDamage.some: 'elementGlass024.png',
        BrickDamage.lots: 'elementGlass051.png',
      },
    (BrickType.metal, BrickSize.size140x140) => {
        BrickDamage.none: 'elementMetal018.png',
        BrickDamage.some: 'elementMetal023.png',
        BrickDamage.lots: 'elementMetal053.png',
      },
    (BrickType.stone, BrickSize.size140x140) => {
        BrickDamage.none: 'elementStone018.png',
        BrickDamage.some: 'elementStone023.png',
        BrickDamage.lots: 'elementStone050.png',
      },
    (BrickType.wood, BrickSize.size140x140) => {
        BrickDamage.none: 'elementWood017.png',
        BrickDamage.some: 'elementWood022.png',
        BrickDamage.lots: 'elementWood049.png',
      },
    (BrickType.explosive, BrickSize.size220x140) => {
        BrickDamage.none: 'elementExplosive019.png',
        BrickDamage.some: 'elementExplosive024.png',
        BrickDamage.lots: 'elementExplosive054.png',
      },
    (BrickType.glass, BrickSize.size220x140) => {
        BrickDamage.none: 'elementGlass020.png',
        BrickDamage.some: 'elementGlass025.png',
        BrickDamage.lots: 'elementGlass052.png',
      },
    (BrickType.metal, BrickSize.size220x140) => {
        BrickDamage.none: 'elementMetal019.png',
        BrickDamage.some: 'elementMetal024.png',
        BrickDamage.lots: 'elementMetal054.png',
      },
    (BrickType.stone, BrickSize.size220x140) => {
        BrickDamage.none: 'elementStone019.png',
        BrickDamage.some: 'elementStone024.png',
        BrickDamage.lots: 'elementStone051.png',
      },
    (BrickType.wood, BrickSize.size220x140) => {
        BrickDamage.none: 'elementWood018.png',
        BrickDamage.some: 'elementWood023.png',
        BrickDamage.lots: 'elementWood050.png',
      },
    (BrickType.explosive, BrickSize.size70x220) => {
        BrickDamage.none: 'elementExplosive020.png',
        BrickDamage.some: 'elementExplosive025.png',
        BrickDamage.lots: 'elementExplosive055.png',
      },
    (BrickType.glass, BrickSize.size70x220) => {
        BrickDamage.none: 'elementGlass021.png',
        BrickDamage.some: 'elementGlass026.png',
        BrickDamage.lots: 'elementGlass053.png',
      },
    (BrickType.metal, BrickSize.size70x220) => {
        BrickDamage.none: 'elementMetal020.png',
        BrickDamage.some: 'elementMetal025.png',
        BrickDamage.lots: 'elementMetal055.png',
      },
    (BrickType.stone, BrickSize.size70x220) => {
        BrickDamage.none: 'elementStone020.png',
        BrickDamage.some: 'elementStone025.png',
        BrickDamage.lots: 'elementStone052.png',
      },
    (BrickType.wood, BrickSize.size70x220) => {
        BrickDamage.none: 'elementWood019.png',
        BrickDamage.some: 'elementWood024.png',
        BrickDamage.lots: 'elementWood051.png',
      },
    (BrickType.explosive, BrickSize.size140x220) => {
        BrickDamage.none: 'elementExplosive021.png',
        BrickDamage.some: 'elementExplosive026.png',
        BrickDamage.lots: 'elementExplosive056.png',
      },
    (BrickType.glass, BrickSize.size140x220) => {
        BrickDamage.none: 'elementGlass022.png',
        BrickDamage.some: 'elementGlass027.png',
        BrickDamage.lots: 'elementGlass054.png',
      },
    (BrickType.metal, BrickSize.size140x220) => {
        BrickDamage.none: 'elementMetal021.png',
        BrickDamage.some: 'elementMetal026.png',
        BrickDamage.lots: 'elementMetal056.png',
      },
    (BrickType.stone, BrickSize.size140x220) => {
        BrickDamage.none: 'elementStone021.png',
        BrickDamage.some: 'elementStone026.png',
        BrickDamage.lots: 'elementStone053.png',
      },
    (BrickType.wood, BrickSize.size140x220) => {
        BrickDamage.none: 'elementWood020.png',
        BrickDamage.some: 'elementWood025.png',
        BrickDamage.lots: 'elementWood052.png',
      },
  };
}

class Brick extends BodyComponent {
  Brick({
    required this.type,
    required this.size,
    required BrickDamage damage,
    required Vector2 position,
    required Map<BrickDamage, Sprite> sprites,
  })  : _damage = damage,
        _sprites = sprites,
        super(
            renderBody: false,
            bodyDef: BodyDef()
              ..position = position
              ..type = BodyType.dynamic,
            fixtureDefs: [
              FixtureDef(
                PolygonShape()
                  ..setAsBoxXY(
                    size.size.width / 20 * brickScale,
                    size.size.height / 20 * brickScale,
                  ),
              )
                ..restitution = 0.4
                ..density = type.density
                ..friction = type.friction
            ]);

  late final SpriteComponent _spriteComponent;

  final BrickType type;
  final BrickSize size;
  final Map<BrickDamage, Sprite> _sprites;

  BrickDamage _damage;
  BrickDamage get damage => _damage;
  set damage(BrickDamage value) {
    _damage = value;
    _spriteComponent.sprite = _sprites[value];
  }

  @override
  Future<void> onLoad() {
    _spriteComponent = SpriteComponent(
      anchor: Anchor.center,
      scale: Vector2.all(1),
      sprite: _sprites[_damage],
      size: size.size.toVector2() / 10 * brickScale,
      position: Vector2(0, 0),
    );
    add(_spriteComponent);
    return super.onLoad();
  }
}

اکنون می توانید ببینید که چگونه کد دارت تولید شده در بالا در این پایگاه کد یکپارچه شده است تا انتخاب تصاویر آجری بر اساس مواد، اندازه و شرایط آسان و سریع باشد. با نگاهی به enum s و خود مؤلفه Brick ، باید دریابید که بیشتر این کد از مولفه Ground در مرحله قبل نسبتاً آشنا به نظر می رسد. در اینجا حالتی قابل تغییر وجود دارد که اجازه می دهد آجر آسیب ببیند، اگرچه استفاده از آن به عنوان تمرینی برای خواننده باقی می ماند.

زمان نمایش آجرها بر روی صفحه نمایش است. فایل game.dart را به صورت زیر ویرایش کنید:

lib/components/game.dart

import 'dart:async';
import 'dart:math';                                        // Add this import

import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flame_kenney_xml/flame_kenney_xml.dart';

import 'background.dart';
import 'brick.dart';                                       // Add this import
import 'ground.dart';

class MyPhysicsGame extends Forge2DGame {
  MyPhysicsGame()
      : super(
          gravity: Vector2(0, 10),
          camera: CameraComponent.withFixedResolution(width: 800, height: 600),
        );

  late final XmlSpriteSheet aliens;
  late final XmlSpriteSheet elements;
  late final XmlSpriteSheet tiles;

  @override
  FutureOr<void> onLoad() async {
    final backgroundImage = await images.load('colored_grass.png');
    final spriteSheets = await Future.wait([
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_aliens.png',
        xmlPath: 'spritesheet_aliens.xml',
      ),
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_elements.png',
        xmlPath: 'spritesheet_elements.xml',
      ),
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_tiles.png',
        xmlPath: 'spritesheet_tiles.xml',
      ),
    ]);

    aliens = spriteSheets[0];
    elements = spriteSheets[1];
    tiles = spriteSheets[2];

    await world.add(Background(sprite: Sprite(backgroundImage)));
    await addGround();
    unawaited(addBricks());                                // Add this line

    return super.onLoad();
  }

  Future<void> addGround() {
    return world.addAll([
      for (var x = camera.visibleWorldRect.left;
          x < camera.visibleWorldRect.right + groundSize;
          x += groundSize)
        Ground(
          Vector2(x, (camera.visibleWorldRect.height - groundSize) / 2),
          tiles.getSprite('grass.png'),
        ),
    ]);
  }

  final _random = Random();                                // Add from here...

  Future<void> addBricks() async {
    for (var i = 0; i < 5; i++) {
      final type = BrickType.randomType;
      final size = BrickSize.randomSize;
      await world.add(
        Brick(
          type: type,
          size: size,
          damage: BrickDamage.some,
          position: Vector2(
              camera.visibleWorldRect.right / 3 +
                  (_random.nextDouble() * 5 - 2.5),
              0),
          sprites: brickFileNames(type, size).map(
            (key, filename) => MapEntry(
              key,
              elements.getSprite(filename),
            ),
          ),
        ),
      );
      await Future<void>.delayed(const Duration(milliseconds: 500));
    }
  }                                                        // To here.
}

این افزودن کد با کدی که برای اضافه کردن اجزای Ground استفاده کردید کمی متفاوت است. این بار Brick به مرور زمان در یک خوشه تصادفی اضافه می شوند. این دو بخش دارد، اولی این است که روشی که Brick s await sa Future.delayed را اضافه می کند، که معادل ناهمزمان یک فراخوانی sleep() است. با این حال، بخش دومی برای انجام این کار وجود دارد، فراخوانی به addBricks در روش onLoad await نیست. اگر اینطور بود، روش onLoad تا زمانی که همه آجرها روی صفحه نمایش داده نمی شدند، کامل نمی شد. قرار دادن تماس با addBricks در یک تماس unawaited باعث خوشحالی افراد می شود و هدف ما را برای برنامه نویسان آینده آشکار می کند. عدم انتظار برای بازگشت این روش عمدی است.

بازی را اجرا کنید، خواهید دید که آجرها ظاهر می شوند، به یکدیگر برخورد می کنند و روی زمین می ریزند.

یک پنجره برنامه با تپه‌های سبز در پس‌زمینه، لایه زمین و بلوک‌هایی که روی زمین فرود می‌آیند.

6. پخش کننده را اضافه کنید

پرتاب بیگانگان به سمت آجر

تماشای چرخیدن آجرها در چند بار اول سرگرم کننده است، اما من حدس می‌زنم این بازی سرگرم‌کننده‌تر خواهد بود اگر به بازیکن آواتاری بدهیم که بتواند از آن برای تعامل با جهان استفاده کند. در مورد یک بیگانه که می توانند به سمت آجرها پرت کنند چطور؟

یک فایل player.dart جدید در پوشه lib/components ایجاد کنید و موارد زیر را به آن اضافه کنید:

lib/components/player.dart

import 'dart:math';

import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flame/events.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flutter/material.dart';

const playerSize = 5.0;

enum PlayerColor {
  pink,
  blue,
  green,
  yellow;

  static PlayerColor get randomColor =>
      PlayerColor.values[Random().nextInt(PlayerColor.values.length)];

  String get fileName =>
      'alien${toString().split('.').last.capitalize}_round.png';
}

class Player extends BodyComponent with DragCallbacks {
  Player(Vector2 position, Sprite sprite)
      : _sprite = sprite,
        super(
          renderBody: false,
          bodyDef: BodyDef()
            ..position = position
            ..type = BodyType.static
            ..angularDamping = 0.1
            ..linearDamping = 0.1,
          fixtureDefs: [
            FixtureDef(CircleShape()..radius = playerSize / 2)
              ..restitution = 0.4
              ..density = 0.75
              ..friction = 0.5
          ],
        );

  final Sprite _sprite;

  @override
  Future<void> onLoad() {
    addAll([
      CustomPainterComponent(
        painter: _DragPainter(this),
        anchor: Anchor.center,
        size: Vector2(playerSize, playerSize),
        position: Vector2(0, 0),
      ),
      SpriteComponent(
        anchor: Anchor.center,
        sprite: _sprite,
        size: Vector2(playerSize, playerSize),
        position: Vector2(0, 0),
      )
    ]);
    return super.onLoad();
  }

  @override
  void update(double dt) {
    super.update(dt);

    if (!body.isAwake) {
      removeFromParent();
    }

    if (position.x > camera.visibleWorldRect.right + 10 ||
        position.x < camera.visibleWorldRect.left - 10) {
      removeFromParent();
    }
  }

  Vector2 _dragStart = Vector2.zero();
  Vector2 _dragDelta = Vector2.zero();
  Vector2 get dragDelta => _dragDelta;

  @override
  void onDragStart(DragStartEvent event) {
    super.onDragStart(event);
    if (body.bodyType == BodyType.static) {
      _dragStart = event.localPosition;
    }
  }

  @override
  void onDragUpdate(DragUpdateEvent event) {
    if (body.bodyType == BodyType.static) {
      _dragDelta = event.localEndPosition - _dragStart;
    }
  }

  @override
  void onDragEnd(DragEndEvent event) {
    super.onDragEnd(event);
    if (body.bodyType == BodyType.static) {
      children
          .whereType<CustomPainterComponent>()
          .firstOrNull
          ?.removeFromParent();
      body.setType(BodyType.dynamic);
      body.applyLinearImpulse(_dragDelta * -50);
      add(RemoveEffect(
        delay: 5.0,
      ));
    }
  }
}

extension on String {
  String get capitalize =>
      characters.first.toUpperCase() + characters.skip(1).toLowerCase().join();
}

class _DragPainter extends CustomPainter {
  _DragPainter(this.player);

  final Player player;

  @override
  void paint(Canvas canvas, Size size) {
    if (player.dragDelta != Vector2.zero()) {
      var center = size.center(Offset.zero);
      canvas.drawLine(
          center,
          center + (player.dragDelta * -1).toOffset(),
          Paint()
            ..color = Colors.orange.withOpacity(0.7)
            ..strokeWidth = 0.4
            ..strokeCap = StrokeCap.round);
    }
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}

این یک پله بالاتر از اجزای Brick در مرحله قبل است. این کامپوننت Player دارای دو جزء فرزند است، یک SpriteComponent که باید آن را بشناسید و یک CustomPainterComponent که جدید است. مفهوم CustomPainter از Flutter است و به شما امکان می دهد روی بوم نقاشی کنید. در اینجا برای بازخورد دادن به بازیکن در مورد اینکه بیگانه دور در هنگام پرتاب شدن به کجا پرواز می کند استفاده می شود.

چگونه بازیکن شروع به پرتاب بیگانه می کند؟ با استفاده از حرکت کشیدن، که مؤلفه Player با تماس‌های DragCallbacks تشخیص می‌دهد. عقابی که در بین شماست متوجه چیز دیگری در اینجا شده است.

در جایی که اجزای Ground اجسام ساکن بودند، اجزای آجر اجسام پویا بودند. Player در اینجا ترکیبی از هر دو است. بازیکن به صورت ایستا شروع به کار می کند و منتظر می ماند تا بازیکن آن را بکشد، و با رها کردن درگ، خود را از ایستا به پویا تبدیل می کند، متناسب با درگ، ضربه خطی اضافه می کند و به آواتار بیگانه اجازه پرواز می دهد!

همچنین کدی در کامپوننت Player وجود دارد که در صورت خارج شدن از محدوده، به خواب رفتن یا اتمام زمان، آن را از صفحه حذف می کند. هدف در اینجا این است که به بازیکن اجازه دهیم بیگانه را پرت کند، ببیند چه اتفاقی می‌افتد، و سپس یک بار دیگر حرکت کند.

مولفه Player را با ویرایش game.dart به صورت زیر در بازی ادغام کنید:

lib/components/game.dart

import 'dart:async';
import 'dart:math';

import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flame_kenney_xml/flame_kenney_xml.dart';

import 'background.dart';
import 'brick.dart';
import 'ground.dart';
import 'player.dart';                                      // Add this import

class MyPhysicsGame extends Forge2DGame {
  MyPhysicsGame()
      : super(
          gravity: Vector2(0, 10),
          camera: CameraComponent.withFixedResolution(width: 800, height: 600),
        );

  late final XmlSpriteSheet aliens;
  late final XmlSpriteSheet elements;
  late final XmlSpriteSheet tiles;

  @override
  FutureOr<void> onLoad() async {
    final backgroundImage = await images.load('colored_grass.png');
    final spriteSheets = await Future.wait([
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_aliens.png',
        xmlPath: 'spritesheet_aliens.xml',
      ),
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_elements.png',
        xmlPath: 'spritesheet_elements.xml',
      ),
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_tiles.png',
        xmlPath: 'spritesheet_tiles.xml',
      ),
    ]);

    aliens = spriteSheets[0];
    elements = spriteSheets[1];
    tiles = spriteSheets[2];

    await world.add(Background(sprite: Sprite(backgroundImage)));
    await addGround();
    unawaited(addBricks());
    await addPlayer();                                     // Add this line

    return super.onLoad();
  }

  Future<void> addGround() {
    return world.addAll([
      for (var x = camera.visibleWorldRect.left;
          x < camera.visibleWorldRect.right + groundSize;
          x += groundSize)
        Ground(
          Vector2(x, (camera.visibleWorldRect.height - groundSize) / 2),
          tiles.getSprite('grass.png'),
        ),
    ]);
  }

  final _random = Random();

  Future<void> addBricks() async {
    for (var i = 0; i < 5; i++) {
      final type = BrickType.randomType;
      final size = BrickSize.randomSize;
      await world.add(
        Brick(
          type: type,
          size: size,
          damage: BrickDamage.some,
          position: Vector2(
              camera.visibleWorldRect.right / 3 +
                  (_random.nextDouble() * 5 - 2.5),
              0),
          sprites: brickFileNames(type, size).map(
            (key, filename) => MapEntry(
              key,
              elements.getSprite(filename),
            ),
          ),
        ),
      );
      await Future<void>.delayed(const Duration(milliseconds: 500));
    }
  }

  Future<void> addPlayer() async => world.add(             // Add from here...
        Player(
          Vector2(camera.visibleWorldRect.left * 2 / 3, 0),
          aliens.getSprite(PlayerColor.randomColor.fileName),
        ),
      );

  @override
  void update(double dt) {
    super.update(dt);
    if (isMounted && world.children.whereType<Player>().isEmpty) {
      addPlayer();
    }
  }                                                        // To here.
}

اضافه کردن بازیکن به بازی مشابه اجزای قبلی است و یک چروک اضافی دارد. بیگانه بازیکن طوری طراحی شده است که تحت شرایط خاصی خود را از بازی حذف کند، بنابراین یک کنترل کننده به روز رسانی در اینجا وجود دارد که بررسی می کند آیا کامپوننت Player در بازی وجود ندارد یا خیر، و اگر چنین است، دوباره یکی را اضافه می کند. اجرای بازی به این صورت است.

یک پنجره برنامه با تپه های سبز در پس زمینه، لایه زمین، بلوک های روی زمین و آواتار بازیکن در حال پرواز.

7. به تاثیر واکنش نشان دهید

اضافه شدن دشمنان

شما اجسام ایستا و پویا را در تعامل با یکدیگر دیده اید. با این حال، برای اینکه واقعاً به جایی برسید، باید در هنگام برخورد همه چیز در کد پاسخ تماس بگیرید. بیایید ببینیم چگونه این کار انجام می شود. شما قصد دارید چند دشمن را برای بازیباز معرفی کنید. این یک مسیر به یک شرط برنده می دهد - همه دشمنان را از بازی حذف کنید!

در دایرکتوری lib/components یک فایل enemy.dart ایجاد کنید و موارد زیر را اضافه کنید:

lib/components/enemy.dart

import 'dart:math';

import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flutter/material.dart';

import 'body_component_with_user_data.dart';

const enemySize = 5.0;

enum EnemyColor {
  pink(color: 'pink', boss: false),
  blue(color: 'blue', boss: false),
  green(color: 'green', boss: false),
  yellow(color: 'yellow', boss: false),
  pinkBoss(color: 'pink', boss: true),
  blueBoss(color: 'blue', boss: true),
  greenBoss(color: 'green', boss: true),
  yellowBoss(color: 'yellow', boss: true);

  final bool boss;
  final String color;

  const EnemyColor({required this.color, required this.boss});

  static EnemyColor get randomColor =>
      EnemyColor.values[Random().nextInt(EnemyColor.values.length)];

  String get fileName =>
      'alien${color.capitalize}_${boss ? 'suit' : 'square'}.png';
}

class Enemy extends BodyComponentWithUserData with ContactCallbacks {
  Enemy(Vector2 position, Sprite sprite)
      : super(
          renderBody: false,
          bodyDef: BodyDef()
            ..position = position
            ..type = BodyType.dynamic,
          fixtureDefs: [
            FixtureDef(
              PolygonShape()..setAsBoxXY(enemySize / 2, enemySize / 2),
              friction: 0.3,
            )
          ],
          children: [
            SpriteComponent(
              anchor: Anchor.center,
              sprite: sprite,
              size: Vector2.all(enemySize),
              position: Vector2(0, 0),
            ),
          ],
        );

  @override
  void beginContact(Object other, Contact contact) {
    var interceptVelocity =
        (contact.bodyA.linearVelocity - contact.bodyB.linearVelocity)
            .length
            .abs();
    if (interceptVelocity > 35) {
      removeFromParent();
    }

    super.beginContact(other, contact);
  }

  @override
  void update(double dt) {
    super.update(dt);

    if (position.x > camera.visibleWorldRect.right + 10 ||
        position.x < camera.visibleWorldRect.left - 10) {
      removeFromParent();
    }
  }
}

extension on String {
  String get capitalize =>
      characters.first.toUpperCase() + characters.skip(1).toLowerCase().join();
}

از تعاملات قبلی شما با اجزای Player و Brick، بیشتر این فایل باید آشنا باشد. با این حال، به دلیل کلاس پایه ناشناخته جدید، چند زیر خط قرمز در ویرایشگر شما وجود خواهد داشت. اکنون این کلاس را با افزودن فایلی به نام body_component_with_user_data.dart به lib/components با محتوای زیر اضافه کنید:

lib/components/body_component_with_user_data.dart

import 'package:flame_forge2d/flame_forge2d.dart';

class BodyComponentWithUserData extends BodyComponent {
  BodyComponentWithUserData({
    super.key,
    super.bodyDef,
    super.children,
    super.fixtureDefs,
    super.paint,
    super.priority,
    super.renderBody,
  });

  @override
  Body createBody() {
    final body = world.createBody(super.bodyDef!)..userData = this;
    fixtureDefs?.forEach(body.createFixture);
    return body;
  }
}

این کلاس پایه، همراه با callback جدید beginContact در کامپوننت Enemy ، اساس اطلاع‌رسانی برنامه‌ای در مورد تأثیرات بین بدن‌ها را تشکیل می‌دهد. در واقع، شما باید هر مؤلفه‌ای را که می‌خواهید برای دریافت اعلان‌های تأثیر بین آن‌ها ویرایش کنید. بنابراین، پیش بروید و اجزای Brick ، Ground و Player را ویرایش کنید تا از این BodyComponentWithUserData به جای کلاس پایه BodyComponent که این اجزا در حال حاضر استفاده می کنند، استفاده کنید. به عنوان مثال، در اینجا نحوه ویرایش مولفه Ground آمده است:

lib/components/ground.dart

import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';

import 'body_component_with_user_data.dart';               // Add this import

const groundSize = 7.0;

class Ground extends BodyComponentWithUserData {           // Edit this line
  Ground(Vector2 position, Sprite sprite)
      : super(
          renderBody: false,
          bodyDef: BodyDef()
            ..position = position
            ..type = BodyType.static,
          fixtureDefs: [
            FixtureDef(
              PolygonShape()..setAsBoxXY(groundSize / 2, groundSize / 2),
              friction: 0.3,
            )
          ],
          children: [
            SpriteComponent(
              anchor: Anchor.center,
              sprite: sprite,
              size: Vector2.all(groundSize),
              position: Vector2(0, 0),
            ),
          ],
        );
}

برای اطلاعات بیشتر در مورد نحوه مدیریت تماس Forge2d، لطفاً به مستندات Forge2D در مورد تماس‌های تماس مخاطبین مراجعه کنید.

برنده شدن در بازی

اکنون که دشمنانی دارید و راهی برای حذف دشمنان از جهان دارید، یک راه ساده برای تبدیل این شبیه سازی به یک بازی وجود دارد. هدف خود را برای حذف همه دشمنان تعیین کنید! زمان ویرایش فایل game.dart به شرح زیر است:

lib/components/game.dart

import 'dart:async';
import 'dart:math';

import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flame_kenney_xml/flame_kenney_xml.dart';
import 'package:flutter/material.dart';                    // Add this import

import 'background.dart';
import 'brick.dart';
import 'enemy.dart';                                       // Add this import
import 'ground.dart';
import 'player.dart';

class MyPhysicsGame extends Forge2DGame {
  MyPhysicsGame()
      : super(
          gravity: Vector2(0, 10),
          camera: CameraComponent.withFixedResolution(width: 800, height: 600),
        );

  late final XmlSpriteSheet aliens;
  late final XmlSpriteSheet elements;
  late final XmlSpriteSheet tiles;

  @override
  FutureOr<void> onLoad() async {
    final backgroundImage = await images.load('colored_grass.png');
    final spriteSheets = await Future.wait([
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_aliens.png',
        xmlPath: 'spritesheet_aliens.xml',
      ),
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_elements.png',
        xmlPath: 'spritesheet_elements.xml',
      ),
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_tiles.png',
        xmlPath: 'spritesheet_tiles.xml',
      ),
    ]);

    aliens = spriteSheets[0];
    elements = spriteSheets[1];
    tiles = spriteSheets[2];

    await world.add(Background(sprite: Sprite(backgroundImage)));
    await addGround();
    unawaited(addBricks().then((_) => addEnemies()));      // Modify this line
    await addPlayer();

    return super.onLoad();
  }

  Future<void> addGround() {
    return world.addAll([
      for (var x = camera.visibleWorldRect.left;
          x < camera.visibleWorldRect.right + groundSize;
          x += groundSize)
        Ground(
          Vector2(x, (camera.visibleWorldRect.height - groundSize) / 2),
          tiles.getSprite('grass.png'),
        ),
    ]);
  }

  final _random = Random();

  Future<void> addBricks() async {
    for (var i = 0; i < 5; i++) {
      final type = BrickType.randomType;
      final size = BrickSize.randomSize;
      await world.add(
        Brick(
          type: type,
          size: size,
          damage: BrickDamage.some,
          position: Vector2(
              camera.visibleWorldRect.right / 3 +
                  (_random.nextDouble() * 5 - 2.5),
              0),
          sprites: brickFileNames(type, size).map(
            (key, filename) => MapEntry(
              key,
              elements.getSprite(filename),
            ),
          ),
        ),
      );
      await Future<void>.delayed(const Duration(milliseconds: 500));
    }
  }

  Future<void> addPlayer() async => world.add(
        Player(
          Vector2(camera.visibleWorldRect.left * 2 / 3, 0),
          aliens.getSprite(PlayerColor.randomColor.fileName),
        ),
      );

  @override
  void update(double dt) {
    super.update(dt);
    if (isMounted &&                                       // Modify from here...
        world.children.whereType<Player>().isEmpty &&
        world.children.whereType<Enemy>().isNotEmpty) {
      addPlayer();
    }
    if (isMounted &&
        enemiesFullyAdded &&
        world.children.whereType<Enemy>().isEmpty &&
        world.children.whereType<TextComponent>().isEmpty) {
      world.addAll(
        [
          (position: Vector2(0.5, 0.5), color: Colors.white),
          (position: Vector2.zero(), color: Colors.orangeAccent),
        ].map(
          (e) => TextComponent(
            text: 'You win!',
            anchor: Anchor.center,
            position: e.position,
            textRenderer: TextPaint(
              style: TextStyle(color: e.color, fontSize: 16),
            ),
          ),
        ),
      );
    }
  }

  var enemiesFullyAdded = false;

  Future<void> addEnemies() async {
    await Future<void>.delayed(const Duration(seconds: 2));
    for (var i = 0; i < 3; i++) {
      await world.add(
        Enemy(
          Vector2(
              camera.visibleWorldRect.right / 3 +
                  (_random.nextDouble() * 7 - 3.5),
              (_random.nextDouble() * 3)),
          aliens.getSprite(EnemyColor.randomColor.fileName),
        ),
      );
      await Future<void>.delayed(const Duration(seconds: 1));
    }
    enemiesFullyAdded = true;                              // To here.
  }
}

چالش شما، اگر بخواهید آن را بپذیرید، اجرای بازی و رساندن خود به این صفحه است.

یک پنجره برنامه با تپه‌های سبز در پس‌زمینه، لایه زمین، بلوک‌های روی زمین و پوشش متنی «شما برنده می‌شوید!»

8. تبریک می گویم

تبریک می گویم، شما موفق به ساخت بازی با فلاتر و شعله شدید!

شما یک بازی با استفاده از موتور بازی Flame 2D ساختید و آن را در یک بسته بندی فلاتر جاسازی کردید. شما از Flame's Effects برای متحرک سازی و حذف کامپوننت ها استفاده کردید. شما از فونت های Google و بسته های Flutter Animate استفاده کردید تا کل بازی به خوبی طراحی شده باشد.

بعدش چی؟

برخی از این کدها را بررسی کنید...

در ادامه مطلب