Flutter और Flame के साथ 2D फ़िज़िक्स गेम बनाएं

1. शुरू करने से पहले

Flame, Flutter पर आधारित 2D गेम इंजन है. इस कोडलैब में, आपको एक ऐसा गेम बनाना है जो Box2D की तरह 2D फ़िज़िक्स सिम्युलेशन का इस्तेमाल करता है. इस गेम को Forge2D कहा जाता है. Flame के कॉम्पोनेंट का इस्तेमाल करके, स्क्रीन पर सिम्युलेट की गई असल चीज़ों को दिखाया जा सकता है, ताकि उपयोगकर्ता उनसे खेल सकें. पूरा होने के बाद, आपका गेम इस ऐनिमेशन वाले GIF की तरह दिखेगा:

फ़िज़िक्स पर आधारित इस 2D गेम के गेमप्ले का ऐनिमेशन

ज़रूरी शर्तें

आपको ये सब सीखने को मिलेगा

  • अलग-अलग तरह के फ़िज़िकल बॉडी से शुरू करके, Forge2D के बुनियादी काम करने के तरीके के बारे में जानकारी.
  • 2D में फ़िज़िकल सिम्युलेशन सेट अप करने का तरीका.

आपको इन चीज़ों की ज़रूरत पड़ेगी

आपके चुने गए डेवलपमेंट टारगेट के लिए कंपाइलर सॉफ़्टवेयर. यह कोडलैब, Flutter के साथ काम करने वाले उन सभी छह प्लैटफ़ॉर्म के लिए काम करता है. Windows को टारगेट करने के लिए, आपको Visual Studio की ज़रूरत होगी. macOS या iOS को टारगेट करने के लिए, Xcode की ज़रूरत होगी. वहीं, Android को टारगेट करने के लिए, Android Studio की ज़रूरत होगी.

2. प्रोजेक्ट बनाना

अपना Flutter प्रोजेक्ट बनाना

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.
    
  2. Flame और Forge2D जोड़ने के लिए, प्रोजेक्ट की डिपेंडेंसी में बदलाव करें:
    $ cd forge2d_game
    $ flutter pub add characters flame flame_forge2d flame_kenney_xml xml
    Resolving dependencies...
    Downloading packages...
      characters 1.4.0 (from transitive dependency to direct dependency)
    + flame 1.29.0
    + flame_forge2d 0.19.0+2
    + flame_kenney_xml 0.1.1+12
      flutter_lints 5.0.0 (6.0.0 available)
    + forge2d 0.14.0
      leak_tracker 10.0.9 (11.0.1 available)
      leak_tracker_flutter_testing 3.0.9 (3.0.10 available)
      leak_tracker_testing 3.0.1 (3.0.2 available)
      lints 5.1.1 (6.0.0 available)
      material_color_utilities 0.11.1 (0.13.0 available)
      meta 1.16.0 (1.17.0 available)
    + ordered_set 8.0.0
    + petitparser 6.1.0 (7.0.0 available)
      test_api 0.7.4 (0.7.6 available)
      vector_math 2.1.4 (2.2.0 available)
      vm_service 15.0.0 (15.0.2 available)
    + xml 6.5.0 (6.6.0 available)
    Changed 8 dependencies!
    12 packages have newer versions incompatible with dependency constraints.
    Try `flutter pub outdated` for more information.
    

flame पैकेज के बारे में आपको पता है, लेकिन बाकी तीन पैकेज के बारे में कुछ जानकारी की ज़रूरत हो सकती है. characters पैकेज का इस्तेमाल, UTF8 के मुताबिक पाथ में बदलाव करने के लिए किया जाता है. flame_forge2d पैकेज, Forge2D की सुविधाओं को इस तरह से दिखाता है कि वे Flame के साथ अच्छी तरह से काम कर सकें. आखिर में, एक्सएमएल कॉन्टेंट का इस्तेमाल करने और उसमें बदलाव करने के लिए, 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 कोड नहीं है जो चल रहे गेम की जानकारी दिखाने के लिए, गेम इंस्टेंस की स्थिति का इस्तेमाल करता हो. इसलिए, यह आसानी से इस्तेमाल किया जा सकने वाला बूटस्ट्रैप अच्छी तरह से काम करता है.

ज़रूरी नहीं: सिर्फ़ macOS के लिए उपलब्ध साइड क्वेस्ट पूरा करें

इस प्रोजेक्ट में दिए गए स्क्रीनशॉट, macOS डेस्कटॉप ऐप्लिकेशन के तौर पर गेम के हैं. ऐप्लिकेशन के टाइटल बार से गेम के अनुभव पर असर न पड़े, इसके लिए macOS रनर के प्रोजेक्ट कॉन्फ़िगरेशन में बदलाव करके, टाइटल बार को हटाया जा सकता है.

ऐसा करने के लिए, इन चरणों का अनुसरण करें:

  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 से फिज़िक्स ऐसेट पैक का इस्तेमाल किया जाएगा. इन एसेट का लाइसेंस क्रिएटिव कॉमंस CC0 है. इसके बावजूद, हमारा सुझाव है कि आप Kenney की टीम को दान दें, ताकि वे अपना अच्छा काम जारी रख सकें. मैंने मदद की थी.

Kenney की एसेट का इस्तेमाल करने के लिए, आपको pubspec.yaml कॉन्फ़िगरेशन फ़ाइल में बदलाव करना होगा. इसमें इस तरह बदलाव करें:

pubspec.yaml

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

environment:
  sdk: ^3.8.1

dependencies:
  flutter:
    sdk: flutter
  characters: ^1.4.0
  flame: ^1.29.0
  flame_forge2d: ^0.19.0+2
  flame_kenney_xml: ^0.1.1+12
  xml: ^6.5.0

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^5.0.0

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 इमेज और एक्सएमएल फ़ाइल के कॉम्बिनेशन होते हैं. इनसे पता चलता है कि स्प्राइटशीट इमेज में छोटी इमेज कहां मिल सकती हैं. स्प्राइटशीट, एक ऐसी तकनीक है जिससे लोड होने में लगने वाले समय को कम किया जा सकता है. इसके लिए, एक ही फ़ाइल को लोड किया जाता है, न कि सैकड़ों अलग-अलग इमेज फ़ाइलों को.

kenney_physics-assets पैक की फ़ाइल की सूची को बड़ा करके दिखाया गया है. इसमें स्प्राइटशीट डायरेक्ट्री को हाइलाइट किया गया है

spritesheet_aliens.png, spritesheet_elements.png, और spritesheet_tiles.png को अपने प्रोजेक्ट की assets/images डायरेक्ट्री में कॉपी करें. यहां मौजूद रहते हुए, अपने प्रोजेक्ट की assets डायरेक्ट्री में spritesheet_aliens.xml, spritesheet_elements.xml, और spritesheet_tiles.xml फ़ाइलें भी कॉपी करें. आपका प्रोजेक्ट कुछ ऐसा दिखेगा.

forge2d_game प्रोजेक्ट डायरेक्ट्री की फ़ाइल लिस्टिंग, जिसमें ऐसेट डायरेक्ट्री को हाइलाइट किया गया है

बैकग्राउंड को पेंट करना

अब आपके प्रोजेक्ट में इमेज एसेट जोड़ दी गई हैं. अब उन्हें स्क्रीन पर दिखाने का समय आ गया है. स्क्रीन पर एक इमेज. आगे की प्रोसेस में, आपको और जानकारी मिलेगी.

lib/components नाम की नई डायरेक्ट्री में, background.dart नाम की फ़ाइल बनाएं और उसमें यह कॉन्टेंट जोड़ें.

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 को खास तरीके से कॉन्फ़िगर करना ज़रूरी है.

lib/components डायरेक्ट्री में फिर से एक नई फ़ाइल बनाएं. इस फ़ाइल का नाम 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';

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 क्लास से शुरू करें. पिछले कोडलैब के विपरीत, यह FlameGame के बजाय Forge2DGame को एक्सटेंड करता है. Forge2DGame, FlameGame को कुछ दिलचस्प बदलावों के साथ बेहतर बनाता है. पहला, डिफ़ॉल्ट रूप से zoom 10 पर सेट होता है. zoom सेटिंग, काम की वैल्यू की रेंज से जुड़ी होती है. Box2D स्टाइल के फ़िज़िक्स सिम्युलेशन इंजन, इन वैल्यू के साथ बेहतर तरीके से काम करते हैं. इंजन को MKS सिस्टम का इस्तेमाल करके लिखा जाता है. इसमें इकाइयों को मीटर, किलोग्राम, और सेकंड में माना जाता है. ऑब्जेक्ट के लिए, गणित से जुड़ी गड़बड़ियां न दिखने की सीमा 0.1 मीटर से लेकर 10 मीटर तक होती है. पिक्सल डाइमेंशन को सीधे तौर पर डालने पर, Forge2D को उसके काम के एनवलप से बाहर ले जाया जाएगा. इसका मतलब है कि सोडा कैन से लेकर बस तक के आइटम को सिम्युलेट किया जा सकता है.

बैकग्राउंड कॉम्पोनेंट में की गई मान्यताओं को यहां पूरा किया गया है. इसके लिए, CameraComponent के रिज़ॉल्यूशन को 800 x 600 वर्चुअल पिक्सल पर सेट किया गया है. इसका मतलब है कि गेम का क्षेत्र 80 यूनिट चौड़ा और 60 यूनिट लंबा होगा. साथ ही, इसका केंद्र (0,0) पर होगा. इससे, डिसप्ले किए गए रिज़ॉल्यूशन पर कोई असर नहीं पड़ता. हालांकि, इससे इस बात पर असर पड़ेगा कि हम गेम के सीन में ऑब्जेक्ट कहां रखते हैं.

camera कंस्ट्रक्टर आर्ग्युमेंट के साथ-साथ, gravity नाम का एक और आर्ग्युमेंट होता है, जो भौतिकी के हिसाब से ज़्यादा अलाइन होता है. गुरुत्वाकर्षण को Vector2 पर सेट किया गया है, जिसमें x 0 और y 10 है. 10, गुरुत्वाकर्षण के लिए आम तौर पर स्वीकार की जाने वाली 9.81 मीटर प्रति सेकंड की वैल्यू के करीब है. गुरुत्वाकर्षण को 10 पर सेट करने का मतलब है कि इस सिस्टम में Y ऐक्सिस की दिशा नीचे की ओर है. यह आम तौर पर Box2D से अलग होता है, लेकिन Flame को आम तौर पर कॉन्फ़िगर करने के तरीके के हिसाब से होता है.

अगला तरीका onLoad है. यह तरीका असाइनोक्रोनस है, जो सही है, क्योंकि यह डिस्क से इमेज एसेट लोड करने के लिए ज़िम्मेदार है. images.load को कॉल करने पर Future<Image> दिखता है. साथ ही, गेम ऑब्जेक्ट में लोड की गई इमेज को कैश मेमोरी में सेव कर दिया जाता है. इन फ़्यूचर को एक साथ इकट्ठा किया जाता है और Futures.wait स्टैटिक तरीके का इस्तेमाल करके, एक यूनिट के तौर पर इंतज़ार किया जाता है. इसके बाद, खोज के नतीजों में मिली इमेज की सूची को पैटर्न के हिसाब से अलग-अलग नामों से मैच किया जाता है.

इसके बाद, स्प्राइटशीट की इमेज को XmlSpriteSheet ऑब्जेक्ट की सीरीज़ में फ़ीड किया जाता है. ये ऑब्जेक्ट, स्प्राइटशीट में मौजूद अलग-अलग नाम वाले स्प्राइट को वापस लाने के लिए ज़िम्मेदार होते हैं. 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 x 600 के अनुपात को काम करने के लिए, ज़रूरत के हिसाब से लेटरबॉक्सिंग जोड़ेगा.

हरी-भरी पहाड़ियों और अजीब तरह के पेड़ों वाला ऐप्लिकेशन.

4. ग्राउंड जोड़ना

आगे बढ़ने के लिए कुछ

अगर गेम में गुरुत्वाकर्षण की सुविधा है, तो हमें गेम में मौजूद ऑब्जेक्ट को स्क्रीन के सबसे नीचे गिरने से पहले पकड़ने के लिए कुछ चाहिए. हालांकि, अगर स्क्रीन से बाहर गिरना आपके गेम के डिज़ाइन का हिस्सा है, तो ऐसा किया जा सकता है. अपनी lib/components डायरेक्ट्री में एक नई ground.dart फ़ाइल बनाएं और उसमें ये चीज़ें जोड़ें:

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 में, बॉडी तीन तरह की होती हैं. स्टैटिक बॉडी नहीं चलती हैं. इनका द्रव्यमान शून्य होता है - ये गुरुत्वाकर्षण का असर नहीं डालते - और इनका द्रव्यमान अनंत होता है - इन पर किसी भी भारी चीज़ के टकराने पर, ये हिलते नहीं हैं. इस वजह से, स्टैटिक बॉडी, ग्राउंड के लिए सबसे सही होती हैं, क्योंकि ये हिलती-डुलती नहीं हैं.

बाकी दो तरह के बॉडी, किनेमैटिक और डाइनैमिक होते हैं. डाइनैमिक बॉडी, पूरी तरह से सिम्युलेट की गई बॉडी होती हैं. ये गुरुत्वाकर्षण और उन ऑब्जेक्ट के हिसाब से प्रतिक्रिया देती हैं जिनसे वे टकरती हैं. आपको इस कोडलैब के बाकी हिस्से में कई डाइनैमिक बॉडी दिखेंगी. किनेमैटिक बॉडी, स्टैटिक और डाइनैमिक के बीच का विकल्प है. ये ऑब्जेक्ट हिलते-डुलते हैं, लेकिन गुरुत्वाकर्षण या उनसे टकराने वाले अन्य ऑब्जेक्ट से इन पर कोई असर नहीं पड़ता. यह काम का है, लेकिन इस कोडलैब के दायरे से बाहर है.

शरीर में अपने-आप बहुत कुछ नहीं होता. किसी बॉडी को सटीक बनाने के लिए, उससे जुड़ी आकृतियों की ज़रूरत होती है. इस मामले में, इस बॉडी में एक आकार है, जो BoxXY के तौर पर सेट किया गया PolygonShape है. इस तरह के बॉक्स का अक्ष, दुनिया के साथ अलाइन होता है. यह BoxXY के तौर पर सेट किए गए PolygonShape से अलग होता है, जिसे रोटेशन पॉइंट के आस-पास घुमाया जा सकता है. यह भी काम का है, लेकिन इस कोडलैब के दायरे से बाहर है. आकार और बॉडी को एक फ़िक्सचर से जोड़ा जाता है. यह सिस्टम में 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.
}

इस बदलाव से, List कॉन्टेक्स्ट में for लूप का इस्तेमाल करके, दुनिया में Ground कॉम्पोनेंट की सीरीज़ जोड़ी जाती है. साथ ही, Ground कॉम्पोनेंट की बनाई गई सूची को world के addAll तरीके में पास किया जाता है.

गेम चलाने पर, अब बैकग्राउंड और ग्राउंड दिखता है.

बैकग्राउंड और ग्राउंड लेयर वाली ऐप्लिकेशन विंडो.

5. ब्रिक जोड़ना

दीवार बनाना

ग्राउंड ने हमें स्टैटिक बॉडी का उदाहरण दिया. अब आपके पहले डाइनैमिक कॉम्पोनेंट का समय आ गया है. Forge2D में डाइनैमिक कॉम्पोनेंट, प्लेयर के अनुभव का मुख्य हिस्सा होते हैं. ये ऐसी चीज़ें होती हैं जो अपने आस-पास की दुनिया के साथ इंटरैक्ट करती हैं और चलती-फिरती हैं. इस चरण में, आपको ब्रिक जोड़ने होंगे. इन्हें ब्रिक के क्लस्टर में स्क्रीन पर दिखाने के लिए, रैंडम तौर पर चुना जाएगा. ऐसा करने पर, आपको उन्हें गिरते हुए और एक-दूसरे से टकराते हुए दिखेगा.

ईंट, एलिमेंट स्प्राइट शीट से बनाई जाएंगी. assets/spritesheet_elements.xml में स्प्रेइट शीट के ब्यौरे को देखने पर, आपको पता चलेगा कि हमारे पास एक दिलचस्प समस्या है. ऐसा लगता है कि इन नामों से ज़्यादा मदद नहीं मिलती. अगर ब्रिक को मटीरियल के टाइप, साइज़, और खराब होने की डिग्री के हिसाब से चुना जा सके, तो यह बहुत मददगार होगा. हालांकि, एक मददगार एल्फ़ ने फ़ाइल के नाम रखने के पैटर्न का पता लगाने के लिए कुछ समय बिताया और आप सभी के लिए एक टूल बनाया है. bin डायरेक्ट्री में नई फ़ाइल generate_brick_file_names.dart बनाएं और इसमें यह कॉन्टेंट जोड़ें:

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

इस टूल ने स्प्राइट शीट की ब्यौरा फ़ाइल को पार्स करके, उसे Dart कोड में बदल दिया है. इसका इस्तेमाल करके, स्क्रीन पर दिखाए जाने वाले हर ब्रिक के लिए सही इमेज फ़ाइल चुनी जा सकती है. काम का है!

यहां दिए गए कॉन्टेंट के साथ 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();
  }
}

अब यह देखा जा सकता है कि पहले जनरेट किया गया Dart कोड, इस कोडबेस में कैसे इंटिग्रेट किया गया है, ताकि ब्रिक की इमेज को मटीरियल, साइज़, और स्थिति के आधार पर तुरंत चुना जा सके. enum के बाद 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 को समय के साथ किसी भी क्लस्टर में जोड़ा जा रहा है. इसमें दो हिस्से होते हैं. पहला, Bricks awaits a Future.delayed जोड़ने का तरीका, जो sleep() कॉल के असाइनोक्रोनस वर्शन के बराबर होता है. हालांकि, इसे काम करने के लिए एक दूसरा हिस्सा भी है. onLoad तरीके में addBricks को कॉल नहीं किया गया है. अगर ऐसा किया जाता, तो onLoad तरीका तब तक पूरा नहीं होगा, जब तक सभी ब्रिक स्क्रीन पर नहीं आ जाते.await addBricks को unawaited कॉल में रैप करने से, लिंटर खुश होते हैं. साथ ही, आने वाले समय में प्रोग्रामर को हमारा मकसद साफ़ तौर पर पता चलता है. इस तरीके के वापस आने का इंतज़ार नहीं किया जा रहा है.

गेम चलाने पर, आपको ब्रिक दिखेंगी. ये ब्रिक एक-दूसरे से टकराकर, ज़मीन पर गिरेंगी.

ऐप्लिकेशन की विंडो, जिसमें बैकग्राउंड में हरी पहाड़ियां, ग्राउंड लेयर, और ज़मीन पर लैंडिंग ब्लॉक दिख रहे हैं.

6. प्लेयर जोड़ना

ईंटों पर एलियन फेंकना

ब्रिक टंबल देखने में पहली बार मज़ा आता है, लेकिन मुझे लगता है कि अगर हम खिलाड़ी को एक अवतार देते हैं, तो यह गेम ज़्यादा मज़ेदार हो जाएगा. इस अवतार का इस्तेमाल करके, खिलाड़ी दुनिया के साथ इंटरैक्ट कर सकता है. क्या बच्चे को एलियन के खिलौने देकर, उसे ईंटों पर फेंकने के लिए कहा जा सकता है?

lib/components डायरेक्ट्री में एक नई player.dart फ़ाइल बनाएं और उसमें यह कॉन्टेंट जोड़ें:

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.withAlpha(180)
          ..strokeWidth = 0.4
          ..strokeCap = StrokeCap.round,
      );
    }
  }

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

यह पिछले चरण के Brick कॉम्पोनेंट से बेहतर है. इस Player कॉम्पोनेंट में दो चाइल्ड कॉम्पोनेंट हैं. पहला SpriteComponent, जो आपको पता होना चाहिए और दूसरा CustomPainterComponent, जो नया है. CustomPainter कॉन्सेप्ट, Flutter से लिया गया है. इसकी मदद से, कैनवस पर पेंट किया जा सकता है. इसका इस्तेमाल यहां खिलाड़ी को यह बताने के लिए किया जाता है कि गोल आकार का एलियन फ़्लिंग होने पर कहां जाएगा.

खिलाड़ी, एलियन को कैसे फ़्लिंग करता है? खींचने और छोड़ने के जेस्चर का इस्तेमाल करके. प्लेयर कॉम्पोनेंट, DragCallbacks कॉलबैक की मदद से इस जेस्चर का पता लगाता है. ध्यान से देखने पर, आपको यहां कुछ और भी दिखेगा.

Ground कॉम्पोनेंट स्टैटिक बॉडी थे, जबकि ब्रिक कॉम्पोनेंट डाइनैमिक बॉडी थे. यहां दिए गए प्लेयर में दोनों का कॉम्बिनेशन है. प्लेयर शुरू में स्टैटिक होता है और खिलाड़ी के उसे खींचने का इंतज़ार करता है. खींचने के बाद उसे छोड़ने पर, वह स्टैटिक से डाइनैमिक में बदल जाता है. साथ ही, खींचने के अनुपात में लीनियर इंपल्स जोड़ता है और एलियन अवतार को उड़ने देता है!

Player कॉम्पोनेंट में यह कोड भी होता है कि अगर यह कॉम्पोनेंट तय सीमा से बाहर चला जाता है, बंद हो जाता है या टाइम आउट हो जाता है, तो उसे स्क्रीन से हटा दिया जाए. यहां खिलाड़ी को यह देखने का मौका मिलता है कि एलियन को फ़्लिंग करने पर क्या होता है. इसके बाद, वह फिर से कोशिश कर सकता है.

game.dart में बदलाव करके, Player कॉम्पोनेंट को गेम में इंटिग्रेट करें. इसके लिए, यह तरीका अपनाएं:

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 कॉम्पोनेंट के साथ आपके पिछले इंटरैक्शन से, आपको इस फ़ाइल के ज़्यादातर हिस्से के बारे में पता होना चाहिए. हालांकि, किसी नई बेस क्लास की वजह से, आपके एडिटर में कुछ चीज़ें लाल रंग से अंडरलाइन होंगी. इस क्लास को जोड़ने के लिए, lib/components में body_component_with_user_data.dart नाम की फ़ाइल जोड़ें. इसमें यह कॉन्टेंट शामिल करें:

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

Enemy कॉम्पोनेंट में नए beginContact कॉलबैक के साथ, यह बेस क्लास, बॉडी के बीच होने वाले असर के बारे में प्रोग्राम के हिसाब से सूचना पाने का आधार बनाती है. असल में, आपको उन सभी कॉम्पोनेंट में बदलाव करना होगा जिनके बीच आपको असर की सूचनाएं चाहिए. इसलिए, Brick, Ground, और Player कॉम्पोनेंट में बदलाव करें, ताकि इन कॉम्पोनेंट में इस्तेमाल की जाने वाली BodyComponent बेस क्लास के बजाय, इस BodyComponentWithUserData का इस्तेमाल किया जा सके. उदाहरण के लिए, 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.
  }
}

इस चैलेंज में आपको गेम चलाकर, इस स्क्रीन पर पहुंचना है.

ऐप्लिकेशन की विंडो, जिसमें बैकग्राउंड में हरी पहाड़ियां, ग्राउंड लेयर, और ग्राउंड पर ब्लॉक दिख रहे हैं. साथ ही, &#39;आप जीत गए!&#39; टेक्स्ट ओवरले भी दिख रहा है

8. बधाई हो

बधाई हो, आपने Flutter और Flame का इस्तेमाल करके गेम बना लिया है!

आपने Flame 2D गेम इंजन का इस्तेमाल करके कोई गेम बनाया हो और उसे Flutter रैपर में जोड़ा हो. आपने कॉम्पोनेंट को ऐनिमेट करने और हटाने के लिए, Flame के इफ़ेक्ट का इस्तेमाल किया है. आपने पूरे गेम को बेहतर तरीके से डिज़ाइन करने के लिए, Google Fonts और Flutter Animate पैकेज का इस्तेमाल किया है.

आगे क्या करना है?

इनमें से कुछ कोडलैब देखें...

इसके बारे में और पढ़ें