Використання Flutter та Supabase для розробки карткової онлайн-гри
Привіт. Мене звати Вадим Хохлов. Я працюю мобільним розробником і викладаю в університеті. Мобільною розробкою я почав займатися ще в далекому 1989 році, коли батьки подарували мені калькулятор МК-61. Я колись навіть писав статтю про складність і проблеми цього процесу в СРСР (Про мобільну розробку в СРСР).
В якості хобі і пет-проєктів я, як правило, розробляю невеличкі ігри. Роблю я це з кількох причин:
- спробувати якусь нову технологію;
- зробити свою версію гри, оскільки в існуючих чогось не вистачає, як це було в іграх сімейства Манкала;
- вирішити цікаву алгоритмічну задачу, наприклад, реалізувати функцію оцінки позиції в грі Сіджа.
Сьогодні я хочу розказати про один з таких пет-проєктів: гру Картковий гольф. Працювати над нею я почав, коли вивчав 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. Колись я зацікавився їм, як інструментом для кросплатформної розробки. Зараз я розробляю і мобільні додатки, і десктопні, і навіть ігри.
3 коментарі
Додати коментар Підписатись на коментаріВідписатись від коментарів