...
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
// lib/game/base_game.dart
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 모든 미니게임의 부모 클래스
|
||||
abstract class BaseGame {
|
||||
/// 게임 고유 ID (예: 'quiz', 'bomb') - 패킷 라우팅용
|
||||
String get id;
|
||||
|
||||
/// 게임 이름 (로비 표시용)
|
||||
String get name;
|
||||
|
||||
/// 게임 설명
|
||||
String get description;
|
||||
|
||||
/// [Host] 방장이 보는 게임 화면 빌드
|
||||
Widget buildHostView(BuildContext context);
|
||||
|
||||
/// [Guest] 참가자가 보는 게임 화면 빌드
|
||||
Widget buildGuestView(BuildContext context);
|
||||
|
||||
/// 게임이 시작될 때 초기화 로직 (변수 초기화 등)
|
||||
void onStart();
|
||||
|
||||
/// 네트워크 메시지 수신 처리
|
||||
/// [senderId]: 보낸 사람 ID
|
||||
/// [payload]: 수신된 데이터 (JSON)
|
||||
void onMessageReceived(String senderId, Map<String, dynamic> payload);
|
||||
|
||||
/// 게임 종료 및 메모리 정리
|
||||
void onDispose();
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
class UserInfo extends Equatable {
|
||||
final String id;
|
||||
final String nickname;
|
||||
final int avatarIndex; // 프로필 이미지 대신 사용할 아바타 번호 (0~9 등)
|
||||
final int colorValue; // 유저 고유 컬러 (ARGB int)
|
||||
|
||||
const UserInfo({
|
||||
required this.id,
|
||||
required this.nickname,
|
||||
this.avatarIndex = 0,
|
||||
this.colorValue = 0xFF2196F3, // 기본값 Blue
|
||||
});
|
||||
|
||||
/// JSON -> Object 변환 (네트워크 수신 시)
|
||||
factory UserInfo.fromJson(Map<String, dynamic> json) {
|
||||
return UserInfo(
|
||||
id: json['id'] as String,
|
||||
nickname: json['nickname'] as String,
|
||||
avatarIndex: json['avatarIndex'] as int? ?? 0,
|
||||
colorValue: json['colorValue'] as int? ?? 0xFF2196F3,
|
||||
);
|
||||
}
|
||||
|
||||
/// Object -> JSON 변환 (네트워크 전송 시)
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'nickname': nickname,
|
||||
'avatarIndex': avatarIndex,
|
||||
'colorValue': colorValue,
|
||||
};
|
||||
}
|
||||
|
||||
/// 복사본 생성 (불변 객체 수정용)
|
||||
UserInfo copyWith({
|
||||
String? id,
|
||||
String? nickname,
|
||||
int? avatarIndex,
|
||||
int? colorValue,
|
||||
}) {
|
||||
return UserInfo(
|
||||
id: id ?? this.id,
|
||||
nickname: nickname ?? this.nickname,
|
||||
avatarIndex: avatarIndex ?? this.avatarIndex,
|
||||
colorValue: colorValue ?? this.colorValue,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [id, nickname, avatarIndex, colorValue];
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:bonsoir/bonsoir.dart'; // mDNS 패키지
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../model/user_info.dart';
|
||||
|
||||
/// 현재 네트워크 상태 (역할)
|
||||
enum NetworkRole { none, host, guest }
|
||||
|
||||
/// P2P 네트워크 통신을 담당하는 싱글톤 매니저
|
||||
class NetworkManager extends ChangeNotifier {
|
||||
// ------------------------------------------------------------------------
|
||||
// 1. Singleton & Initialization
|
||||
// ------------------------------------------------------------------------
|
||||
static final NetworkManager _instance = NetworkManager._internal();
|
||||
factory NetworkManager() => _instance;
|
||||
NetworkManager._internal();
|
||||
|
||||
/// 내 정보 (앱 시작 시 initialize 호출 필수)
|
||||
late UserInfo me;
|
||||
|
||||
/// 현재 내 역할
|
||||
NetworkRole role = NetworkRole.none;
|
||||
|
||||
/// 초기화 메서드 (닉네임 설정 및 ID 생성)
|
||||
void initialize({required String nickname}) {
|
||||
// 8자리 랜덤 ID 생성
|
||||
final uuid = const Uuid().v4().substring(0, 8);
|
||||
// 랜덤 컬러 (간단하게 해시코드로 생성 예시)
|
||||
final randomColor = 0xFF000000 | (nickname.hashCode & 0xFFFFFF);
|
||||
|
||||
me = UserInfo(
|
||||
id: uuid,
|
||||
nickname: nickname,
|
||||
colorValue: randomColor,
|
||||
);
|
||||
print('[Network] Initialized User: ${me.nickname} (${me.id})');
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 2. Variables & Streams
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
// 소켓
|
||||
ServerSocket? _serverSocket; // (Host용)
|
||||
Socket? _clientSocket; // (Guest용)
|
||||
final List<Socket> _connectedGuests = []; // (Host가 관리하는 게스트 목록)
|
||||
|
||||
// mDNS (방 찾기/만들기)
|
||||
BonsoirService? _bonsoirService;
|
||||
BonsoirBroadcast? _bonsoirBroadcast;
|
||||
BonsoirDiscovery? _bonsoirDiscovery;
|
||||
|
||||
// 수신된 데이터를 앱(GameManager)으로 전달하는 스트림
|
||||
final _messageController = StreamController<Map<String, dynamic>>.broadcast();
|
||||
Stream<Map<String, dynamic>> get messageStream => _messageController.stream;
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 3. Host Logic (방장)
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 방 만들기
|
||||
Future<void> startHosting(String roomName) async {
|
||||
stopNetwork(); // 기존 연결 정리
|
||||
role = NetworkRole.host;
|
||||
|
||||
try {
|
||||
// A. TCP 서버 소켓 오픈 (Port 0 = 시스템 자동 할당)
|
||||
_serverSocket = await ServerSocket.bind(InternetAddress.anyIPv4, 0);
|
||||
int port = _serverSocket!.port;
|
||||
print('[Host] Server opened on port: $port');
|
||||
|
||||
// B. 게스트 접속 대기
|
||||
_serverSocket!.listen((Socket client) {
|
||||
_handleNewGuest(client);
|
||||
});
|
||||
|
||||
// C. mDNS로 방 광고 (Broadcast)
|
||||
// 서비스 타입은 고유해야 함 (_playwith._tcp)
|
||||
// 이름 포맷: "방이름#호스트ID" (중복 방지 및 식별용)
|
||||
_bonsoirService = BonsoirService(
|
||||
name: '$roomName#${me.id}',
|
||||
type: '_playwith._tcp',
|
||||
port: port,
|
||||
);
|
||||
|
||||
_bonsoirBroadcast = BonsoirBroadcast(service: _bonsoirService!);
|
||||
await _bonsoirBroadcast!.ready;
|
||||
await _bonsoirBroadcast!.start();
|
||||
|
||||
print('[Host] Start advertising room: $roomName');
|
||||
notifyListeners();
|
||||
|
||||
} catch (e) {
|
||||
print('[Host] Error starting host: $e');
|
||||
stopNetwork();
|
||||
}
|
||||
}
|
||||
|
||||
/// 새로운 게스트가 접속했을 때
|
||||
void _handleNewGuest(Socket client) {
|
||||
print('[Host] New guest connected: ${client.remoteAddress.address}');
|
||||
_connectedGuests.add(client);
|
||||
|
||||
// 데이터 수신 리스너 부착
|
||||
client.listen(
|
||||
(Uint8List data) => _onDataReceived(client, data),
|
||||
onError: (e) => _removeGuest(client),
|
||||
onDone: () => _removeGuest(client),
|
||||
);
|
||||
}
|
||||
|
||||
void _removeGuest(Socket client) {
|
||||
print('[Host] Guest disconnected');
|
||||
_connectedGuests.remove(client);
|
||||
client.close();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 4. Guest Logic (참가자)
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 주변 방 찾기 (mDNS Discovery)
|
||||
Stream<List<BonsoirService>> discoverRooms() {
|
||||
// 리스트를 계속 갱신해서 내보내기 위한 컨트롤러
|
||||
final controller = StreamController<List<BonsoirService>>();
|
||||
final List<BonsoirService> foundServices = [];
|
||||
|
||||
_bonsoirDiscovery = BonsoirDiscovery(type: '_playwith._tcp');
|
||||
|
||||
_bonsoirDiscovery!.ready.then((_) {
|
||||
_bonsoirDiscovery!.start();
|
||||
|
||||
_bonsoirDiscovery!.eventStream!.listen((event) {
|
||||
if (event.type == BonsoirDiscoveryEventType.discoveryServiceFound) {
|
||||
if (event.service != null) {
|
||||
foundServices.add(event.service!);
|
||||
controller.add(List.from(foundServices));
|
||||
}
|
||||
} else if (event.type == BonsoirDiscoveryEventType.discoveryServiceLost) {
|
||||
if (event.service != null) {
|
||||
foundServices.removeWhere((s) => s.name == event.service!.name);
|
||||
controller.add(List.from(foundServices));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return controller.stream;
|
||||
}
|
||||
|
||||
/// 방 접속하기
|
||||
Future<void> joinRoom(String ip, int port) async {
|
||||
stopNetwork(); // 기존 연결 정리
|
||||
role = NetworkRole.guest;
|
||||
|
||||
try {
|
||||
print('[Guest] Connecting to $ip:$port...');
|
||||
_clientSocket = await Socket.connect(ip, port);
|
||||
print('[Guest] Connected!');
|
||||
|
||||
// 접속 성공 시 즉시 내 정보 전송 (Handshake)
|
||||
sendMessage({
|
||||
'type': 'HANDSHAKE',
|
||||
'senderId': me.id,
|
||||
'payload': me.toJson(),
|
||||
});
|
||||
|
||||
// 데이터 수신 리스너
|
||||
_clientSocket!.listen(
|
||||
(Uint8List data) => _onDataReceived(_clientSocket!, data),
|
||||
onError: (e) {
|
||||
print('[Guest] Connection error: $e');
|
||||
stopNetwork();
|
||||
},
|
||||
onDone: () {
|
||||
print('[Guest] Disconnected by host');
|
||||
stopNetwork();
|
||||
},
|
||||
);
|
||||
|
||||
notifyListeners();
|
||||
|
||||
} catch (e) {
|
||||
print('[Guest] Failed to join: $e');
|
||||
role = NetworkRole.none;
|
||||
notifyListeners();
|
||||
rethrow; // UI에서 에러 처리할 수 있게 던짐
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 5. Common Logic (데이터 송수신)
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 메시지 전송
|
||||
void sendMessage(Map<String, dynamic> messageMap) {
|
||||
try {
|
||||
// JSON 변환
|
||||
final jsonString = jsonEncode(messageMap);
|
||||
// 패킷 경계 처리를 위해 끝에 줄바꿈(\n) 추가 (가장 간단한 delimiter)
|
||||
final List<int> data = utf8.encode('$jsonString\n');
|
||||
|
||||
if (role == NetworkRole.host) {
|
||||
// Host는 모든 Guest에게 브로드캐스트
|
||||
for (var socket in _connectedGuests) {
|
||||
socket.add(data);
|
||||
}
|
||||
} else if (role == NetworkRole.guest) {
|
||||
// Guest는 Host에게 전송
|
||||
_clientSocket?.add(data);
|
||||
}
|
||||
} catch (e) {
|
||||
print('[Network] Send Error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 데이터 수신 처리
|
||||
void _onDataReceived(Socket socket, Uint8List data) {
|
||||
// 들어온 데이터를 String으로 변환
|
||||
final String rawString = utf8.decode(data);
|
||||
|
||||
// TCP 패킷이 뭉쳐서 올 수 있으므로 \n으로 쪼갬
|
||||
final List<String> splitMessages = rawString.split('\n');
|
||||
|
||||
for (var msg in splitMessages) {
|
||||
if (msg.trim().isEmpty) continue;
|
||||
|
||||
try {
|
||||
final Map<String, dynamic> parsedData = jsonDecode(msg);
|
||||
|
||||
// 1. 앱 로직으로 전달
|
||||
_messageController.add(parsedData);
|
||||
|
||||
// 2. (옵션) Host라면, 받은 메시지를 다른 Guest들에게도 전달(Relay)해야 할 수 있음
|
||||
// 게임 로직에 따라 다르지만 보통 Host가 중계자 역할을 함
|
||||
// if (role == NetworkRole.host) { sendMessage(parsedData); }
|
||||
|
||||
} catch (e) {
|
||||
print('[Network] Parse Error: $e\nMessage: $msg');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 6. Cleanup
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 네트워크 종료 및 리소스 정리
|
||||
void stopNetwork() {
|
||||
print('[Network] Stopping network...');
|
||||
|
||||
// mDNS 중지
|
||||
_bonsoirBroadcast?.stop();
|
||||
_bonsoirDiscovery?.stop();
|
||||
|
||||
// 소켓 닫기
|
||||
_serverSocket?.close();
|
||||
_clientSocket?.close();
|
||||
|
||||
for (var socket in _connectedGuests) {
|
||||
socket.close();
|
||||
}
|
||||
_connectedGuests.clear();
|
||||
|
||||
role = NetworkRole.none;
|
||||
_serverSocket = null;
|
||||
_clientSocket = null;
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// lib/playwith_core.dart
|
||||
|
||||
library playwith_core;
|
||||
|
||||
export 'game/base_game.dart';
|
||||
export 'network/network_manager.dart';
|
||||
export 'model/user_info.dart';
|
||||
Reference in New Issue
Block a user