Tworzenie gier mobilnych w Flutter - praktyczne wykorzystanie szablonu

Utworzony przez Łukasz Sujkowski, dnia 31.12.2022
 11081    0
Tworzenie gier mobilnych w Flutter - praktyczne wykorzystanie szablonu
W tym artykule przedstawię praktyczne wykorzystanie szablonu, w tym celu napiszemy prostą grę, podobną do gry memory. Po wybraniu poziomu, wygenerowana zostanie plansza z różnymi kartami, zadaniem gracza będzie zapamiętanie układu.  Po wciśnięciu przycisku Start zostaną one zasłonięte, a wyświetlona zostanie karta którą mamy oznaczyć. Następnie po wciśnięciu przycisku Check zaznaczona karta zostanie odsłonięta, a gracz zobaczy komunikat o wygranej lub przegranej.

Zanim przystąpimy do pisania kodu, musimy zaimportować przygotowane przeze mnie ikony w formacie SVG (do ich utworzenia użyłem darmowego programu Figma) oraz zainstalować pakiet umożliwiający ich załadowanie (https://pub.dev/packages/flutter_svg). Przechodzimy do konsoli w głównym katalogu projektu i wykonujemy komendę flutter pub add flutter_svg. Po zakończeniu instalacji biblioteka jest gotowa do użycia. Pobieramy archiwum shapes.zip załączone na końcu artykułu, pobieramy, rozpakowujemy i wrzucamy do folderu assets/images/:

Poprawna struktura projektu po zaimportowaniu ikon

Następnie w pliku pubspec.yaml, sekcji assets dodajemy kolejną pozycję:

Uwzględnienie nowego folderu z zawartością w pubspec.yaml

Jest to istotne, inaczej ikony nie zostaną załadowane. Gotowe, obrazki zaimportowane, teraz możemy przejść do pisania kodu.

Pisanie kodu zaczniemy od pliku lib/src/level_selection/levels.dart, w którym znajduje się konfiguracja poziomów gry. W naszym przypadku potrzebujemy usunąć z klasy GameLevel pole difficulty oraz dodać pola typu int:
  • grid - określający siatkę rozgrywki (2x2, 3x3, 5x5 itd.)
  • selections - określającą ilość poprawnych kart w wygenerowanym zestawie
  • goal - minimalna ilość poprawnych kart które musimy zaznaczyć żeby zaliczyć poziom
  • point - ilość punktów za poprawne zaznaczenie
Wyżej wymienione pola należy dodać także do domyślnego konstruktora klasy. Po zmianach powinno wyglądać to tak:

Klasa GameLevel po modyfikacjach

Teraz wystarczy poprawić tablicę gameLevels znajdującą się wyżej. Koniecznie upewnij się, że wartość pola number nie powtarza się - jest to identyfikator poziomu:

Poprawny wpis poziomu do tablicy gameLevels

Przejdźmy dalej, tworzymy nowy plik lib/src/game_internals/level_constants.dart, będzie on zawierał wszystkie stałe dane rozgrywki. W pierwszej kolejności importujemy flutter/material i definiujemy nowy enumerator reprezentujący kształty na kartach:

import 'package:flutter/material.dart';

enum Shape {
  circle,
  square,
  triangle,
}
Dalej tworzymy stałą zmienną, która będzie zawierała ścieżki obrazków danych kształtów:

const String blankSvg = 'assets/images/shapes/blank.svg';

const Map<Shape, String> levelShapes = {
  Shape.circle: 'assets/images/shapes/circle.svg',
  Shape.square: 'assets/images/shapes/square.svg',
  Shape.triangle: 'assets/images/shapes/triangle.svg',
};
Jak widzicie, osobno mamy blankSvg - będzie to ikona wyświetlana na odwrocie karty (nazwa troszkę nie pasuje, ale nic nie szkodzi). 

Tworzymy kolejny plik w tym samym katalogu lib/src/game_internals/level_card.dart, w nim zdefiniujemy klasę reprezentującą kartę w naszej grze:

import 'package:flutter/material.dart';

class LevelCard {
  final Shape shape;
  final Color color;

  const LevelCard({
    required this.shape,
    required this.color,
  });

  String getShape() {
    return levelShapes[shape]!;
  }
}
Po wklejeniu kodu prawdopodobnie wyskoczy błąd, w tym przypadku należy wcisnąć nad podkreślonym typem Shape prawy przycisk myszy, a następnie ShowContextActions i zaimportować plik level_constants.dart, ja korzystam z wersji poprzedzonej słowem “package:”:

Akcja szybkiego importu biblioteki Android Studio

Jeszcze raz wchodzimy do pliku lib/src/game_internals/level_constants.dart i uzupełnimy go o tablicę kart dostępnych podczas rozgrywki korzystając z klasy LevelCard:

const List<LevelCard> levelCards = [
  const LevelCard(shape: Shape.circle, color: Colors.red),
  const LevelCard(shape: Shape.square, color: Colors.red),
  const LevelCard(shape: Shape.triangle, color: Colors.red),
  const LevelCard(shape: Shape.circle, color: Colors.green),
  const LevelCard(shape: Shape.square, color: Colors.green),
  const LevelCard(shape: Shape.triangle, color: Colors.green),
  const LevelCard(shape: Shape.circle, color: Colors.blue),
  const LevelCard(shape: Shape.square, color: Colors.blue),
  const LevelCard(shape: Shape.triangle, color: Colors.blue),
];
W tym przypadku tak samo jak wcześniej importujemy brakujące pliki. 

Kolejnym utworzonym przez nas plikiem będzie lib/src/game_internals/level_state.php, w którym umieścimy klasę LevelState zawierającą stan gry. Dla uproszczenia będzie ona zawierała również logikę wpływającą na stan gry, normalnie zalecam jednak wydzielenie jej osobnej klasy np. GameMode lub LevelMode. Standardowo, importujemy pakiet flutter/material, tworzymy enumerator LevelStatus zawierający możliwe statusy gry oraz klasę LevelState rozszerzającą ChangeNotifier, który umożliwia informowanie widgetów nasłuchujących o zmianach stanu:

import 'package:flutter/material.dart';

enum LevelStatus {
  initial,
  loading,
  showing,
  choosing,
  win,
  lose,
}

class LevelState extends ChangeNotifier {
  
}
W kolejnych krokach będziemy działać wewnątrz LevelState, dlatego nie będę powielał powyższego kodu. Wypełnianie klasy zaczniemy od parametrów:

final GameLevel level;
final Function() onWin;

LevelState({
  required this.level,
  required this.onWin,
});

Zmienna level będzie zawierała konfigurację obecnego poziomu, a funkcja onWin zostanie wywołana po wygranej. Dalej zdefiniujemy kilka zmiennych określających stan gry:

LevelStatus status = LevelStatus.initial;
LevelCard correctSelectionCard = levelCards[0];
List<LevelCard> currentLevelCards = [];
List<int> selectedCards = [];
int correctSelections = 0;
int receivedPoints = 0;
Analizując od góry, pierwsza zmienna odpowiada za status gry i jest zbudowana na enumeratorze zdefiniowanym na początku. Dalej mamy correctSelectionCard, jest to karta, którą gracz będzie musiał zaznaczyć żeby wygrać. Karta ta oczywiście zostanie wylosowana przy uruchomieniu poziomu, ale o tym zaraz. Lista currentLevelCards, będzie zawierała karty wylosowane dla obecnego poziomu, a selectedCards konkretne wybory gracza. Ostatnie dwie zmienne correctSelections oraz receivedPoints będą wyliczane na samym końcu rozgrywki, na potrzeby wyświetlania wyniku. Idąc dalej, dodajmy metodę losującą, będzie ona używana zarówno do generowania listy currentLevelCards jak i zmiennej correctSelectionCard:

LevelCard getRandomLevelCard({LevelCard? exclude}) {
  List<LevelCard> availableCards = List.from(levelCards);
  if (exclude != null) {
    availableCards.remove(exclude);
  }
  return availableCards[Random().nextInt(availableCards.length)];
}
Zasada działania jest prosta, wykonujemy kopię listy kart dostępnych w grze (określonej w pliku level_constants.dart). Jeśli jakaś karta została przekazana parametrem excluded usuwamy ją z kopii listy, a z tego co pozostanie losujemy jedną. Taki mechanizm będzie bardzo przydatny w kolejnej metodzie: 

void generateLevel() {
  correctSelectionCard = getRandomLevelCard();
  final int itemsCount = level.grid * level.grid; // 2x2=4, 3x3=9 etc.
  
  List<int> availablePositions = [];
  for (int index = 0; index < itemsCount; index++) {
    availablePositions.add(index);
  }

  List<int> correctShapePositions = [];
  for (int index = 0; index < level.selections; index++) {
    int randomPosition = availablePositions[Random().nextInt(availablePositions.length)];
    availablePositions.remove(randomPosition);

    correctShapePositions.add(randomPosition);
  }

  currentLevelCards = [];
  for (int index = 0; index < itemsCount; index++) {
    if (correctShapePositions.contains(index)) {
      currentLevelCards.add(correctSelectionCard);
    } else {
      currentLevelCards.add(getRandomLevelCard(exclude: correctSelectionCard));
    }
  }
}
Jest to najbardziej rozbudowana metoda tego pliku. Na samym początku korzystając z metody getRandomLevelCard losujemy kartę, której zaznaczenie będzie skutkowało wygraną, a zaraz po tym wyliczamy ilość kart na planszy itemsCount bazując na parametrze grid pochodzącym z konfiguracji poziomu. Kolejna operacja wypełnia tablicę availablePositions liczbami od 0 do itemsCount. W kolejnym kroku losujemy pozycje i usuwamy ją z listy availablePositions oraz dodajemy do correctShapePositions, dzięki temu zabezpieczymy się przed wielokrotnym losowaniem tej samej pozycji - poprawnych kart na planszy może być tyle na ile wskazuje zmienna w konfiguracji poziomu level.selections. Ostatni fragment metody generuje właściwe karty, dodając je do globalnej listy currentLevelCards. Dla każdej pozycji zawartej w tablicy correctShapePositions dodajemy kartę wygrywającą correctSelectionCard, a dla pozostałych losujemy dowolną za wyjątkiem tej poprawnej (korzystając z parametru exclude). Najgorsze za nami, możemy przejść do dodania kilku mniej skomplikowanych metod zarządzających stanem rozgrywki:

void reset() {
  status = LevelStatus.initial;
  correctSelectionCard = levelCards[0];
  currentLevelCards = [];
  selectedCards = [];
  correctSelections = 0;
  receivedPoints = 0;

  notifyListeners();
}

void init() {
  status = LevelStatus.loading;
  notifyListeners();

  generateLevel();

  status = LevelStatus.showing;
  notifyListeners();
}

void run() {
  status = LevelStatus.choosing;
  notifyListeners();
}
Metody te modyfikują pola klasy LevelState, a następnie korzystając z funkcji notifyListeners powiadamiają wszystkie widgety nasłuchujące zmiany stanu.

void toggleCard(int index) {
  if (selectedCards.contains(index)) {
    selectedCards.remove(index);
  } else if (selectedCards.length < level.selections) {
    selectedCards.add(index);
  }
  notifyListeners();
}
Kolejna metoda służy do zaznaczania lub odznaczania karty. Jak widać, gracz maksymalnie może zaznaczyć tyle kart ile zdefiniowano w konfiguracji poziomu poprzez parametr selections.

void check() {
  correctSelections = 0;
  for (int index = 0; index < selectedCards.length; index++) {
    if (currentLevelCards[selectedCards[index]] == correctSelectionCard) {
      correctSelections++;
    }
  }
  receivedPoints = correctSelections * level.points;
  if (correctSelections >= level.goal) {
    onWin();
    status = LevelStatus.win;
  } else {
    status = LevelStatus.lose;
  }
  notifyListeners();
}
Ostatnia metoda odpowiada za weryfikację wyniku. Iterując po wszystkich zaznaczonych kartach wyliczamy ilość poprawnych (correctSelections), a następnie na jej podstawie obliczamy ilość zdobytych punktów (receivedPoints). Jeśli zaznaczono minimalną ilość poprawnych kart (goal), wyzwalamy funkcję onWin i zmieniamy status na win. W przeciwnym wypadku zmieniamy status na lose.

To by było wszystko jeśli chodzi o pliki bazowe. Teraz możemy przejść do pisania widoku rozgrywki. Przechodzimy do pliku lib/src/play_session/play_session_screen.dart i usuwamy jego zawartość. Następnie importujemy bibliotekę flutter/material i generujemy widget PlaySessionScreen korzystając z makra "stful". Dodajmy też parametr level oraz zmienną globalną _duringCelebration, która będzie kontrolowała animacje konfetti w przypadku wygranej.

import 'package:flutter/material.dart';
import 'package:tutorial/src/level_selection/levels.dart';

class PlaySessionScreen extends StatefulWidget {
  final GameLevel level;
  
  const PlaySessionScreen({Key? key, required this.level}) : super(key: key);

  @override
  State createState() => _PlaySessionScreenState();
}

class _PlaySessionScreenState extends State {

  bool _duringCelebration = false;
  
  @override
  Widget build(BuildContext context) {
    return Container();
  }
}
Po tej operacji plik lib/main.dart zgłasza błąd w definicji ścieżki "session/:level". Wynika to z faktu, że wcześniej widget PlaySessionScreen  przyjmował dane poziomu jako parametr pozycyjny, a nie nazwany. Aby to naprawić zamieniamy "level," na "level: level,":

child: PlaySessionScreen(
  level: level,
  key: const Key('play session'),
),
Gotowe, teraz możemy wrócić do pliku widgetu PlaySessionScreen i kontynuować rozszerzanie metody build:

@override
Widget build(BuildContext context) {
  return MultiProvider(
    providers: [
      ChangeNotifierProvider(
        create: (context) => LevelState(
            level: widget.level,
            onWin: () {}
        ),
      ),
    ],
    child: Scaffold(
      body: Stack(
        children: [
          Text('Test 1'),
          SizedBox.expand(
            child: Visibility(
              visible: _duringCelebration,
              child: IgnorePointer(
                child: Confetti(
                  isStopped: !_duringCelebration,
                ),
              ),
            ),
          ),
        ],
      ),
    ),
  );
}
Korzystając z widgetu MultiProvider, dostarczamy stan LevelState widgetom znajdującym się niżej w drzewie (child). Parametrem level przekazujemy do wewnątrz informacje o obecnym poziomie (widget.level), a parametrem onWin pustą funkcję ("() {}"). Dalej, w widgecie układu Scaffold, zagnieżdżamy widget Stack (umożliwiający tworzenie stosu, czyli widgetów ułożonych jeden na drugim), a w nim Text('Test 1'), który docelowo zastąpimy innym kodem oraz SizedBox.expand który wypełni cały ekran widgetem Confetti w przypadku, gdy zmienna _duringCelebration będzie ustawiona na true. Widget IgnorePointer odpowiada za ignorowanie kliknięć w confetti. W tym momencie możemy zweryfikować działanie konfetti, tymczasowo ustawiając zmienną _duringCelebration na true. Po przetestowaniu gry, cofamy ostatnią zmianę i przechodzimy dalej.  Na miejsce widgetu Text('Test 1'), wprowadzamy kod:

Builder(
    builder: (context) {
      final levelState = context.watch();
      return WillPopScope(
        onWillPop: () async {
          levelState.reset();
          return true;
        },
        child: SafeArea(
          child: Padding(
            padding: const EdgeInsets.all(10.0),
            child: Text('Builder 1'),
          ),
        ),
      );
    }
),
Za pomocą widgetu Builder rozpoczynamy nasłuchiwanie zmian w  LevelState, a następnie zwracamy widget WillPopScope, który umożliwia reagowanie na systemowy przycisk powrotu za pomocą parametru onWillPop. W naszym przypadku po jego wciśnięciu, resetujemy stan gry korzystając z funkcji levelState.reset(), a następnie zwracamy true co spowoduje powrót do poprzedniego ekranu. Tak samo jak wcześniej, widget Text('Builder 1') podmieniamy z poniższym kodem:

Builder(
    builder: (context) {
      if (levelState.status == LevelStatus.initial) {
        levelState.init();
      }

      if (levelState.status == LevelStatus.showing ||
          levelState.status == LevelStatus.choosing ||
          levelState.status == LevelStatus.win ||
          levelState.status == LevelStatus.lose
      ) {
        return Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            Text('Kolumna 1'),
           ],
        );
      }

      return Center(
        child: Text('Loading...'),
      );
    }
),
Kolejny Builder, tym razem posłuży nam on do obsługi konkretnych statusów LevelState. W przypadku, gdy pole status ustawione jest na LevelStatus.initial wywołujemy inicjalizację za pomocą metody levelState.init(). Jeśli poziom jest już zainicjalizowany, a status jest równy LevelStatus.showing, LevelStatus.choosingLevelStatus.win lub LevelStatus.lose, wyświetlamy kolumnę z parametrem crossAxisAlignment: CrossAxisAlignment.stretch, umożliwiającym wypełnienie przez zawarte w niej elementy całej szerokości ekranu. Dla wszystkich innych stanów, zwrócimy wyśrodkowany tekst o treści “Loading…”. Przejdźmy do uzupełniania pozycji w kolumnie. Poniżej widgetu Text(‘Kolumna 1’) wprowadzimy kilka przycisków:

if (levelState.status == LevelStatus.showing) Container(
  padding: const EdgeInsets.only(top: 10.0),
  height: 80.0,
  child: ElevatedButton(
    style: ElevatedButton.styleFrom(
      backgroundColor: Colors.blue.shade600,
    ),
    onPressed: () => levelState.run(),
    child: Text(
      'Graj',
      style: TextStyle(
        fontSize: 16.0,
      ),
    ),
  ),
),
Pierwszy wyświetlany tylko w przypadku, gdy status to LevelStatus.showing, umożliwia uruchomienie gry poprzez wywołanie metody levelState.run().

if (levelState.status == LevelStatus.choosing) Container(
  padding: const EdgeInsets.only(top: 10.0),
  height: 80.0,
  child: ElevatedButton(
    style: ElevatedButton.styleFrom(
      backgroundColor: levelState.level.selections == levelState.selectedCards.length
          ? Colors.green.shade600 : Colors.green.shade200,
    ),
    onPressed: () {
      if (levelState.level.selections == levelState.selectedCards.length) {
        levelState.check();
      }
    },
    child: Text(
      'Sprawdź',
      style: TextStyle(
        fontSize: 16.0,
      ),
    ),
  ),
),
Kolejny dla statusu równego LevelStatus.choosing, umożliwia wykonanie sprawdzenia funkcją levelState.check() ale tylko w wypadku, gdy zaznaczono wymaganą ilość kart (levelState.level.selections == levelState.selectedCards.length).

if (levelState.status == LevelStatus.win || levelState.status == LevelStatus.lose) Flex(
  direction: Axis.horizontal,
  children: [
    Expanded(
      child: Container(
        padding: const EdgeInsets.only(top: 10.0),
        height: 80.0,
        child: ElevatedButton(
          style: ElevatedButton.styleFrom(
            backgroundColor: levelState.level.selections == levelState.selectedCards.length
                ? Colors.green.shade600 : Colors.green.shade200,
          ),
          onPressed: () {
            GoRouter.of(context).pop();
          },
          child: Text(
            'Wróć do wyboru poziomu',
            style: TextStyle(
              fontSize: 16.0,
            ),
          ),
        ),
      ),
    ),
  ],
),
Ostatni to przycisk wyświetlany po wygranej lub przegranej, umożliwia on przejście do poprzedniego ekranu (listy poziomów). Idąc dalej, znowu podmieniamy widget Text('Kolumna 1'), na poniższy kod:

Expanded(
  child: Column(
    mainAxisAlignment: MainAxisAlignment.spaceEvenly,
    crossAxisAlignment: CrossAxisAlignment.stretch,
    children: [
      Text('Kolumna 2'),
    ],
  ),
),
Widget Expanded spowoduje, że kolumna wypełni całą dostępną przestrzeń. Dzięki temu przyciski które dodaliśmy przed chwilą, zostaną przesunięte do dołu ekranu. Parametr mainAxisAlignment ustawiony na MainAxisAlignment.spaceEvenly spowoduje rozmieszczenie elementów w kolumnie w równomiernych odstępach. Okej, dodajmy kilka elementów do kolumny zastępując jednocześnie widget Text('Kolumna 2'):

Row(
  mainAxisAlignment: MainAxisAlignment.center,
  children: [
    Container(
      height: 150.0,
      width: 150.0,
      color: Colors.yellow.shade200,
      padding: const EdgeInsets.all(10.0),
      child: PlaySessionItem(
        levelCard: levelState.correctSelectionCard,
        facedUp: levelState.status == LevelStatus.choosing ||
            levelState.status == LevelStatus.win ||
            levelState.status == LevelStatus.lose,
      ),
    ),
  ],
),
Widget Row utworzy wiersz, który dzięki parametrowi mainAxisAlignment: MainAxisAlignment.center wyśrodkuje swoją zawartość. Wewnątrz mamy kontener zawierający widget wyświetlający kartę, której znalezienie jest celem gry. Jak pewnie zauważyliście, widget PlayerSessionItem nie istnieje i musimy go utworzyć. Zanim do tego przystąpimy, zwróćmy jeszcze uwagę na parametr faceUp który jest odpowiedzialny za wyświetlanie zawartości karty.  Jak widać karta odkryta jest w przypadku zaznaczania zakrytych kart oraz wyświetlania informacji o wygranej lub przegranej. Pora na PlayerSessionItem, utwórzmy plik lib/src/play_session/play_session_item.dart i wypełnijmy go kodem:

import 'package:flutter/material.dart';

class PlaySessionItem extends StatelessWidget {

  final LevelCard levelCard;
  final bool readOnly;
  final bool facedUp;
  final bool selected;
  final Function()? onPressed;

  const PlaySessionItem({
    Key? key,
    required this.levelCard,
    this.readOnly = false,
    this.facedUp = false,
    this.selected = false,
    this.onPressed,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(10.0),
      child: TextButton(
        style: TextButton.styleFrom(
          backgroundColor: Colors.yellow.shade200,
          padding: EdgeInsets.all(5.0),
        ),
        onPressed: !readOnly && onPressed != null ? onPressed : null,
        child: Builder(
            builder: (context) {
              if (facedUp) {
                return SvgPicture.asset(
                  levelCard.getShape(),
                  color: levelCard.color,
                );
              } else if (selected) {
                return SvgPicture.asset(
                  blankSvg,
                  color: Colors.green.shade600,
                );
              }
              return SvgPicture.asset(
                blankSvg,
                color: Colors.yellow.shade700,
              );
            }
        ),
      ),
    );
  }
}
Jest to widget bezstanowy, który tworzy prosty przycisk który po kliknięciu wywołuje przekazaną z zewnątrz funkcję onPressed. Wracamy do poprzedniego pliku i kontynuujemy dodawanie kolejnych elementów do kolumny:

if (levelState.status == LevelStatus.choosing) Center(
  child: Text(
    '${levelState.selectedCards.length}/${levelState.level.selections}',
    textAlign: TextAlign.center,
    style: TextStyle(
      fontSize: 20.0,
      fontWeight: FontWeight.bold,
      color: Colors.green.shade600,
    ),
  ),
),
if (levelState.status == LevelStatus.win) Center(
  child: Text(
    'Gratulacje! Zaznaczyłeś ${levelState.correctSelections} z ${levelState.level.selections} poprawnych pól.'
        + 'Otrzymujesz ${levelState.receivedPoints} punktów.',
    textAlign: TextAlign.center,
    style: TextStyle(
      fontSize: 20.0,
      fontWeight: FontWeight.bold,
      color: Colors.green.shade600,
    ),
  ),
),
if (levelState.status == LevelStatus.lose) Center(
  child: Text(
    'Porażka! Zaznaczyłeś ${levelState.correctSelections} poprawnych pól. '
        + 'Aby wygrać trzeba zaznaczyć minimum ${levelState.level.goal} z ${levelState.level.selections} poprawnych pól.',
    textAlign: TextAlign.center,
    style: TextStyle(
      fontSize: 20.0,
      fontWeight: FontWeight.bold,
      color: Colors.red.shade600,
    ),
  ),
),
Są to trzy proste teksty, wyświetlane w zależności od statusu gry. Ostatnim elementem kolumny, a zarazem ostatnim fragmentem kodu w tym artykule będzie:

LayoutBuilder(
  builder: (BuildContext context, BoxConstraints constraints) {
    return Container(
      width: constraints.maxWidth,
      height: constraints.maxWidth,
      child: Column(
        children: [
          for (int row = 0; row < levelState.level.grid; row++) Expanded(
            child: Row(
              children: [
                for (int item = row * levelState.level.grid; 
                  item < row * levelState.level.grid + levelState.level.grid; 
                  item++
                ) Flexible(
                  child: PlaySessionItem(
                    levelCard: levelState.currentLevelCards[item],
                    facedUp: levelState.status == LevelStatus.showing || 
                        (levelState.selectedCards.contains(item) && 
                            (levelState.status == LevelStatus.win || levelState.status == LevelStatus.lose)
                        ),
                    selected: levelState.selectedCards.contains(item),
                    onPressed: () {
                      if (levelState.status == LevelStatus.choosing) {
                        levelState.toggleCard(item);
                      }
                    },
                  ),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  },
),
Ostatni fragment odpowiedzialny jest za wygenerowanie planszy z kartami. Widget LayoutBuilder pozwala określić ile ekranu mamy dostępnego w danym miejscu w drzewie, informacja ta zostaje przekazana obiektem BoxConstraints. W różnych miejscach dostępna szerokość oraz wysokość może być inna, w zależności czy korzystaliśmy z parametrów typu padding. Dalej w kontenerze rozciągniętym na całej dostępnej szerokości i wysokości mamy kolumnę (Column), a w niej wygenerowanie wiersze (Row). W każdym wierszu generujemy widgety karty PlaySessionItem. Karty wyświetlane są w przypadku status gry jest równy LevelStatus.showing, LevelStatus.win lub LevelStatus.lose. Parametr selected ustawiamy w zależności czy karta znajduje się na liście wybranych kart. Zaznaczenie karty jest możliwe tylko w wypadku, gdy status jest równy LevelStatus.choosing, aby tego dokonać korzystamy z metody levelState.toggleCard(item). Gotowe, teraz możemy uruchomić i przetestować aplikację:

Widok rozgrywki aplikacji flutter wykonanej w tym poradniku

Jak widać, dzięki szablonowi możemy skupić się na tym co najważniejsze czyli programowaniu rozgrywki. Inne elementy potrzebują tylko drobnej poprawki kolorystyczno-językowej po czym gra mogłaby zostać opublikowania. Poniżej załączam archiwum z wszystkimi plikami tworzonymi i modyfikowanymi w tym artykule.

Komentarze

This site is protected by ReCaptcha and the Google Privacy Policy and Terms of Service apply.

Brak komentarzy...

Kategorie

Quixel  0
Blender  3
Unity  0
Flutter  5
Ogólne  3