Використання Flutter та Supabase для розробки карткової онлайн-гри

Привіт. Мене звати Вадим Хохлов. Я працюю мобільним розробником і викладаю в університеті. Мобільною розробкою я почав займатися ще в далекому 1989 році, коли батьки подарували мені калькулятор МК-61. Я колись навіть писав статтю про складність і проблеми цього процесу в СРСР (Про мобільну розробку в СРСР).

В якості хобі і пет-проєктів я, як правило, розробляю невеличкі ігри. Роблю я це з кількох причин:

  1. спробувати якусь нову технологію;
  2. зробити свою версію гри, оскільки в існуючих чогось не вистачає, як це було в іграх сімейства Манкала;
  3. вирішити цікаву алгоритмічну задачу, наприклад, реалізувати функцію оцінки позиції в грі Сіджа.

Сьогодні я хочу розказати про один з таких пет-проєктів: гру Картковий гольф. Працювати над нею я почав, коли вивчав Flutter (а тепер це мій основний інструмент). Нещодавно я вирішив поекспериментувати з Supabase і додав підтримку гри по мережі.

Правила гри

Існує кілька варіантів Карткового гольфа. Спільним є те, що гра складається з кількох раундів (лунок, або англійською holes), а виграє той, хто набере найменшу кількість очок.

В варіанті з шістьма картами на початку раунду кожен гравець отримує шість карт рубашкою догори, всі карти, що залишилися, кладуться в колоду. Одну карту кладуть у відбій лицьовою стороною вгору.

Спочатку гравець повинен відкрити дві свої карти. Після цього він/вона може зменшити значення своїх карт, або помінявши їх на карти меншої вартості, або об’єднавши їх у пари у стовпці з картками однакового рангу.

Гравці по черзі витягують одну карту або з колоди, або з відбою. Витягнуту карту можна або замінити на одну з своїх карт, або просто скинути. Якщо карта замінюється на одну з карт рубашкою догори, то вона залишається лицьовою стороною вгору. Якщо витягнута карта відкидається, хід гравця закінчується. Раунд завершується, коли всі карти гравця розкриються.

Підрахунок виконується наступним чином:

  • будь-які пари карток у стовпці приносять 0 балів;
  • джокери коштують −2 очка;
  • королі коштують 0 очок;
  • дами і валети оцінюються в 10 очок;
  • кожна інша карта варта свого рангу.

Тут можна подивитись коротеньке відео гри:

Ігрова модель

Реалізація логічної частини гри доволі проста. Є базовий клас BoardViewModel, який містить необхідні властивості, наприклад, список гральних карт, і визначення загальних методів для всіх варіантів ігор (в додатку я реалізував три гри: Гольф з 6 карт, Гольф з 4 карт, Скат):

typedef Deck = List<PlayingCard>;
abstract class BoardViewModel extends ChangeNotifier {
  ...
  @protected
  late Deck deck;
  List<CardInfo> get gameCards;
  ...
  CardInfo playerCard(int playerNum, int row, int col);
  Future<bool> onPlayerCardTapWithKey(String cardKey);
  Future<bool> onDeckTap();
  Future<bool> onDiscardTap();
  ...
}

і відповідна реалізація вже конкретної моделі:

class BoardViewModel6 extends BoardViewModel {
  @override
  Future<bool> onDeckTap() async {
    // обробка тапу на колоді карт
  }
  @override
  Future<bool> onPlayerCardTapWithKey(String cardKey) async {
    // обробка тапу на якійсь карті
  }
}

Для state management я використовую Provider і ChangeNotifier для повідомлення UI про необхідність оновлення.

Анімація карт

Я не використовую в проєкті ні Flame, ні якісь спеціальні пакети для анімації, оскільки хотів розібратися безпосередньо з можливостями самого Flutter (див. п.1 зі списку вище).

Для відображення гральних карт я взяв пакет playing_cards

Віджет ігрового столу «слухає» стан ігрових карт і перебудовує необхідні частини інтерфейсу при будь-яких змінах:

class GameTable6 extends StatelessWidget {
...
@override
Widget build(BuildContext context) {
final Tuple2<List<CardInfo>, DateTime> visibleCards = context.select(
  (BoardViewModel6 board) =>Tuple2(board.gameCards, board.gameCardsTimeStamp));



return SizedBox(
  width: boardWidth,
  child: Column(
    children: [
      ...
      SizedBox(
        height: boardHeight,
        child: Stack(
          children: [
            Positioned(
              left: colX(0),
              top: rowY(2),
              width: cardWidth,
              height: cardHeight,
              child: const DiscardWidget<BoardViewModel6>()
            ),
            ...visibleCards.item1.map(
              (cardInfo) => GameCard<BoardViewModel6>(
                  cardKey: cardInfo.card.asKey(),
                 ),
            ),
          ],
       ),
     ),
     ...
   ]),
  );
 }
}

Найцікавіше в цьому коді — віджет GameCard. Він використовує AnimatedPositioned для анімації переміщення гральних карт:

class GameCard<T extends BoardViewModel> extends StatefulWidget {
  const GameCard({super.key, required this.cardKey});
  final String cardKey;
  @override
  State<GameCard<T>> createState() => _GameCardState();
}



class _GameCardState<T extends BoardViewModel> extends State<GameCard<T>> {
  bool _showBackSide = true;
  @override
  Widget build(BuildContext context) {
    final Tuple5<CardInfo, bool, int, int, bool> card =
       context.select((T board) {
         final CardInfo cardInfo = board.cardWithKey(widget.cardKey);
         return Tuple5(cardInfo, cardInfo.isBack, cardInfo.row, cardInfo.col,
           cardInfo.isVisible,
         );
       });
    _showBackSide = card.item2;
    ...
    return AnimatedPositioned(
      left: colX(card.item1.col),
      top: rowY(card.item1.row),
      duration: duration,
      child: !card.item5
        ? SizedBox(width: cardWidth, height: cardHeight)
        : GestureDetector(
            key: Key(card.item1.card.asKey()),
            onTap: () {
              final model = context.read<T>();
              if (model.isInteractionAllowed) {
                model.onPlayerCardTapWithKey(widget.cardKey);
              }
            },
            child: SizedBox(
              height: cardHeight,
              child: AnimatedSwitcher(
                duration: const Duration(milliseconds: 400),
                transitionBuilder: __transitionBuilder,
                layoutBuilder: (widget, list) =>
                  Stack(children: [widget!, ...list]),
                child: Container(
                  key: ValueKey(card.item2),
                  child: PlayingCardView(
                     showBack: card.item2,
                     card: card.item1.card,
                     style: cardStyles,
                  ),
                 ),
                ),
              ),
           ),
        );
   }
}

Для анімації перевертання карти використовується віджет AnimatedSwitcher і метод __transitionBuilder:

Widget __transitionBuilder(Widget widget, Animation<double> animation) {
  final rotateAnim = Tween(begin: pi, end: 0.0).animate(animation);
  return AnimatedBuilder(
    animation: rotateAnim,
    child: widget,
    builder: (context, widget) {
      final isUnder = (ValueKey(_showBackSide) != widget!.key);
      final value = isUnder ? min(rotateAnim.value, pi / 2) : rotateAnim.value;
      return Transform(
        transform: (Matrix4.rotationY(value)),
        alignment: Alignment.center,
        child: widget,
      );
    },
  );
}

Як бачимо, навіть стандартних засобів Flutter достатньо для цікавої анімації.

Supabase і гра по Інтернет

Supabase надає такий сервіс, як Realtime. Як пише офіційна документація:
«Supabase provides a globally distributed cluster of Realtime servers that enable the following functionality:

  • Broadcast: Send ephemeral messages from client to clients with low latency.
  • Presence: Track and synchronize shared state between clients.
  • Postgres Changes: Listen to Postgres database changes and send them to authorized clients.»

Функціонал Presence дозволяє дуже просто реалізувати ігрове лоббі, в якому гравці можуть обирати собі суперників, а Broadcast — передавати інформацію про ігрові події.

Для взаємодії з Supabase використовується спеціальний клас:

class OnlineTransport  {
  Future<void> init() async {
    await Supabase.initialize(url: _supabaseUrl, anonKey: _supabaseAnonKey);
  }



  RealtimeChannel createChannel({
    required String name,
    void Function(RealtimePresenceSyncPayload payload)? onPresence,
    required 
      void Function(RealtimeSubscribeStatus status, Object? error) onSubscribe,
  }) {
    final channel = _supabase.channel(
      name,
      opts: const RealtimeChannelConfig(self: false)).subscribe(onSubscribe);
    if (onPresence != null) {
      channel.onPresenceSync(onPresence);
    }
    return channel;
  }
...
}

Метод createChannel() викликає відповідний метод supabes для створення комунікаційного каналу між клієнтами. Зокрема, цей метод використовується LobbyViewModel для створення каналу ігрового лоббі:

class LobbyViewModel extends ChangeNotifier {
  LobbyViewModel(
   this._ownerId,
   this._transport,
   GameVariants game, {
     required JoinCallBack onJoin,
   }) {
     _lobbyChannel = _transport.createChannel(
       name: _channelName(game),
       onPresence: (payload) async {
         final presenceStates = _lobbyChannel.presenceState();
         _opponents = presenceStates.map(
          (pState) => OnlinePlayerInfo.fromJson(pState.presences.first.payload),
         ).toList();
         notifyListeners();
       },
       onSubscribe: (status, _) async {},
     )..onBroadcast(
       event: GameCommands.joinGame.name,
       callback: (payload) {
         final String opponentId = payload[_keyOpponentId];
         if (_ownerId == opponentId) {
           // someone has selected me as an opponent
           onJoin(
             gameId: payload[_keyGameId],
             reward: payload[_keyReward],
             roundsCount: payload[_keyRoundsCount] ?? 9,
             gameOwner: payload[_keyOpponentId],
             opponentInfo: OnlinePlayerInfo.fromJson(payload[_keyMe]),
           );
          }
       },
     );
  ...
  Future<void> enter(OnlinePlayerInfo playerInfo) async {
    _lobbyChannel.track(playerInfo.toJson());
  }
  ...
  List<OnlinePlayerInfo> get opponents => _opponents;
  ...
}

Коли гравець хоче увійти в лоббі, викликається метод enter з інформацією про гравця: ім’я, рейтинг, кількість перемог, ставка на гру. В цей час спрацьовує callback onPresence, і модель повідомляє UI про необхідність відобразити появу нового гравця в лоббі.

Коли гравець вибирає собі опонента, викликається наступний метод:

Future<void> join({
  required String gameId,
  required String opponentId,
  required int reward,
  required int roundsCount,
  required OnlinePlayerInfo playerInfo,
}) async {
  _lobbyChannel.sendBroadcastMessage(
    event: GameCommands.joinGame.name,
    payload: {
      _keyOpponentId: opponentId,
      _keyReward: reward,
      _keyMe: playerInfo.toJson(),
      _keyGameId: gameId,
      _keyRoundsCount: roundsCount,
    },
  );
}

Спрацьовує callback onBroadcast і створюється об’єкт, який містить всю необхідну для гри інформацію.

Врешті-решт аналогічним чином створюється канал для цієї гри, але на відміну від каналу-лоббі, тут присутні лише два гравця. За допомогою методу sendBroadcastMessage і callback’а onBroadcast гравці обмінюються такою інформацією, як: роздача карт, тап на колоді чи карті, завершення раунду або завершення гри.

Спробувати гру можна, або встановивши Android-версію, або в браузері. Браузерна версія доволі стара, оскільки я зосереджений на розвитку мобільного варіанту.

Висновки

Мені цікаво спостерігати за розвитком Flutter. Колись я зацікавився їм, як інструментом для кросплатформної розробки. Зараз я розробляю і мобільні додатки, і десктопні, і навіть ігри.

Підписуйтеся на Telegram-канал @gamedev_dou, щоб не пропустити найважливіші статті і новини

👍ПодобаєтьсяСподобалось7
До обраногоВ обраному6
LinkedIn


Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Для інформації:
зворотній бік карт — сорочка.
Карти кладуться долілиць — тобто вниз лицем і горілиць — вгору лицем.
А стаття цікава

Дякую. мені самому ці слова муляли, але не придумав як правильно. Оновлю доку в наступній версії

Підписатись на коментарі