이번 블로그에서는 Flutter Flame과 Supabase를 사용해 실시간 멀티플레이어 게임을 구현한 과정을 공유하려 합니다. 특히 Supabase의 Realtime 기능을 활용해 멀티플레이어 채널에서 각 플레이어의 움직임을 동기화하는 코드를 작성하고 디버깅한 경험을 다룹니다. Flame에 대한 설명과 게임 선정 과정은 이전 블로그에 있습니다.
1. 환경 설정
우선 Flutter 프로젝트에 Supabase 의존성을 추가합니다. 다음 명령어를 사용해 Flame 패키지를 설치합니다
$ flutter pub add supabase_flutter
혹은 pubspec.yaml에 의존성을 추가합니다
dependencies: supabase_flutter: ^2.8.0
2. Supabase 초기화
Firebase를 사용할 때처럼 Supabase를 사용할 때도
main()
함수에서 Supabase를 Initialize 해주어야 합니다.import 'package:supabase_flutter/supabase_flutter.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); await Supabase.initialize( url: SUPABASE_URL, anonKey: SUPABASE_ANON_KEY, realtimeClientOptions: const RealtimeClientOptions(eventsPerSecond: 10), ); runApp(MyApp()); } // It's handy to then extract the Supabase client in a variable for later uses final supabase = Supabase.instance.client;
url과 ananKey 부분에는 Supabase 콘솔에서 프로젝트 고유의 key값을 넣어주면 됩니다.
Supabase에서 제공하는
RealtimeClientOptions
를 통해 초당 이벤트 전송 빈도를 설정할 수 있습니다. 게임 내 실시간 업데이트 빈도를 고려해 적절한 값을 설정해야 합니다. 기본 값은 10입니다.3. GameScreen 만들기
@override Widget build(BuildContext context) { return const MaterialApp( home: GameScreen(), ); }
MaterialApp의 home으로
GameScreen()
을 전달합니다. 이 GameScreen은 build
함수에서 Scaffold()
를 return할 것이며 앞으로 빌드할 모든 위젯의 상위에 위치함으로서 fetch나 channel에 연결하는 등의 변수와 함수를 정의할 위치가 됩니다. GameScreen을 Stateful위젯으로 만들고
_GameScreenState
를 다음과 같이 작성합니다class _GameScreenState extends State<GameScreen> { late final MyGame _game; RealtimeChannel? _gameChannel; @override void initState() { super.initState(); _initializeGame(); } Future<void> _initializeGame() async { _game = MyGame(onPlayerMove: _sendPlayerPosition); // Supabase 채널을 통해 실시간 데이터를 주고받습니다. _gameChannel = supabase.channel('multiplayer-channel'); // 구독을 통해 실시간 데이터 수신 _gameChannel! .onBroadcast( event: 'player_move', // 이벤트 이름 callback: (payload) { // 상대방의 위치 데이터를 수신받아 게임에 반영 final x = payload['x'] as double; final y = payload['y'] as double; _game.updateOpponentPosition(Vector2(x, y)); }) .subscribe((status, error) { if (status == RealtimeSubscribeStatus.subscribed) { print('Successfully subscribed to multiplayer-channel!'); } else { print('Failed to subscribe: $status'); } }); } // 플레이어의 위치 데이터를 실시간으로 전송 void _sendPlayerPosition(Vector2 position) { _gameChannel!.sendBroadcastMessage( event: 'player_move', // 전송할 이벤트 이름 payload: {'x': position.x, 'y': position.y}, // 전송할 데이터 ); } @override Widget build(BuildContext context) { return Scaffold( body: GameWidget(game: _game), ); } }
_initializeGame()
설명- 후에 나올 MyGame class의 객체인
_game
을 선언합니다. 게임의 인스턴스를 초기화하는 과정이며 플레이어의 움직임을 서버로 전송할 콜백 함수를 설정합니다.
- Supabase의 채널명을 명시하고
_gameChannel
에 선언합니다.‘multiplayer-channel’
에 구독하여‘player_move’
이벤트를 통해 상대방의 위치 데이터를 받아옵니다. 수신된 좌표는 게임 내 상대 캐릭터 위치를 업데이트하는 데 사용됩니다.
_gameChannel
을onBroadCast()
메소드로 Supabase의 Broadcast를 사용해 실시간 전송을 가능케합니다.sendBroadcastMessage
는 채널에 연결된 모든 클라이언트에게 데이터를 전달합니다.
4. 캐릭터와 터치/드래그 이벤트 처리
class MyGame extends FlameGame with PanDetector { final Function(Vector2) onPlayerMove; late Player player; late Player opponent; MyGame({required this.onPlayerMove}); @override Future<void> onLoad() async { super.onLoad(); player = Player(Vector2(size.x / 2, size.y - 50)); opponent = Player(Vector2(size.x / 2, 50)); add(player); add(opponent); } @override void onPanUpdate(DragUpdateInfo info) { // 플레이어가 화면을 드래그할 때마다 위치 업데이트 player.move(info.delta.global); onPlayerMove(player.position); // 위치를 서버에 전송 } // 상대방의 위치를 업데이트 void updateOpponentPosition(Vector2 newPosition) { opponent.position = newPosition; } } class Player extends CircleComponent { Player(Vector2 position) : super( radius: 30, position: position, paint: Paint()..color = Colors.blue, ); void move(Vector2 delta) { position += delta; // 위치를 업데이트 } }
MyGame
클래스 설명MyGame
클래스는 FlameGame을 상속하며 PanDetector를 사용하여 화면에서의 터치/드래그 이벤트를 처리합니다. 이 클래스는 게임 내에서 플레이어와 상대방 캐릭터의 위치를 관리하고, 화면에서 캐릭터를 드래그하여 움직일 수 있도록 설계되었습니다.
- 플레이어와 상대 캐릭터 추가:
onLoad()
함수에서 게임 화면 중앙과 상단에 각각 플레이어와 상대 캐릭터를 배치합니다.
player = Player(Vector2(size.x / 2, size.y - 50)); opponent = Player(Vector2(size.x / 2, 50)); add(player); add(opponent);
- 드래그 이벤트 처리:
onPanUpdate()
함수는 사용자가 화면을 드래그할 때 호출됩니다. 드래그된 거리만큼 플레이어의 위치를 업데이트하고, 그 위치를 서버로 전송하여 상대 클라이언트에게 전달합니다.
player.move(info.delta.global); onPlayerMove(player.position); // 서버로 위치 전송
- 상대방 위치 업데이트:
updateOpponentPosition()
함수는 서버로부터 받은 상대방의 위치를 갱신합니다. 상대 클라이언트의 움직임을 실시간으로 반영하는 역할을 합니다.
void updateOpponentPosition(Vector2 newPosition) { opponent.position = newPosition; }
이 클래스는 게임의 중심 로직을 담당하며, 플레이어 간 실시간 상호작용을 가능하게 합니다.
Player
클래스 설명Player
클래스는 CircleComponent를 상속하여 원형의 캐릭터를 화면에 그리며, 이 캐릭터가 움직일 수 있도록 하는 역할을 합니다.
- 플레이어 초기화:
Player
클래스는CircleComponent
를 확장하여 원형 캐릭터를 화면에 렌더링합니다. 이때, 캐릭터의 초기 위치와 색상(여기서는 파란색)을 설정합니다.
Player(Vector2 position) : super( radius: 30, position: position, paint: Paint()..color = Colors.blue, );
- 캐릭터 이동:
move()
함수는 사용자의 드래그에 따라 캐릭터의 위치를 업데이트합니다.position += delta;
구문을 통해 캐릭터가 현재 위치에서 지정된 벡터 방향으로 이동합니다.
void move(Vector2 delta) { position += delta; }
5. 총 코드와 결과물
import 'package:flame/components.dart'; import 'package:flame/events.dart'; import 'package:flame/game.dart'; import 'package:flutter/material.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; void main() async { await Supabase.initialize( url: 'YOUR_URL', //실제 url으로 대체 anonKey: 'YOUR_ANONKEY'// 실제 anonKey로 대체 realtimeClientOptions: const RealtimeClientOptions(eventsPerSecond: 200), ); runApp(const MyApp()); } final supabase = Supabase.instance.client; class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return const MaterialApp( home: GameScreen(), ); } } class GameScreen extends StatefulWidget { const GameScreen({super.key}); @override State<GameScreen> createState() => _GameScreenState(); } class _GameScreenState extends State<GameScreen> { late final MyGame _game; RealtimeChannel? _gameChannel; @override void initState() { super.initState(); _initializeGame(); } Future<void> _initializeGame() async { _game = MyGame(onPlayerMove: _sendPlayerPosition); // Supabase 채널을 통해 실시간 데이터를 주고받습니다. _gameChannel = supabase.channel('multiplayer-channel'); // 구독을 통해 실시간 데이터 수신 _gameChannel! .onBroadcast( event: 'player_move', // 이벤트 이름 callback: (payload) { // 상대방의 위치 데이터를 수신받아 게임에 반영 final x = payload['x'] as double; final y = payload['y'] as double; _game.updateOpponentPosition(Vector2(x, y)); }) .subscribe((status, error) { if (status == RealtimeSubscribeStatus.subscribed) { print('Successfully subscribed to multiplayer-channel!'); } else { print('Failed to subscribe: $status'); } }); } // 플레이어의 위치 데이터를 실시간으로 전송 void _sendPlayerPosition(Vector2 position) { _gameChannel!.sendBroadcastMessage( event: 'player_move', // 전송할 이벤트 이름 payload: {'x': position.x, 'y': position.y}, // 전송할 데이터 ); } @override Widget build(BuildContext context) { return Scaffold( body: GameWidget(game: _game), ); } } class MyGame extends FlameGame with PanDetector { final Function(Vector2) onPlayerMove; late Player player; late Player opponent; MyGame({required this.onPlayerMove}); @override Future<void> onLoad() async { super.onLoad(); player = Player(Vector2(size.x / 2, size.y - 50)); opponent = Player(Vector2(size.x / 2, 50)); add(player); add(opponent); } @override void onPanUpdate(DragUpdateInfo info) { // 플레이어가 화면을 드래그할 때마다 위치 업데이트 player.move(info.delta.global); onPlayerMove(player.position); // 위치를 서버에 전송 } // 상대방의 위치를 업데이트 void updateOpponentPosition(Vector2 newPosition) { opponent.position = newPosition; } } class Player extends CircleComponent { Player(Vector2 position) : super( radius: 30, position: position, paint: Paint()..color = Colors.blue, ); void move(Vector2 delta) { position += delta; // 위치를 업데이트 } }
참고 자료
‣
‣
여담과 팁..
supabase는 강력하지만 아직은 최적화 및 안정화가 되어 있지 않은 것 같습니다.
channel 연결이 불안정하거나 공식문서가 적거나 하는 등의 문제가 있지만 가장 큰 문제점은…
사람들이 많이 사용하지 않습니다
→ 그래서 참고자료가 부족합니다
→ 그래서 chatGPT가 deprecated된 답변을 많이 뽑아냅니다!!!!!!
supabase 관련 계속 에러가 나는 코드만 뽑아냈습니다…
해결방법 - 최신화된 공식 문서들을 gpt에 전부 때려넣고 학습 시키면 양호한 코드가 나옵니다.