..
This commit is contained in:
@@ -12,7 +12,7 @@ import '../model/user_info.dart';
|
||||
import '../model/play_packet.dart';
|
||||
import '../manager/global_chat_manager.dart';
|
||||
import '../manager/media_manager.dart';
|
||||
import '../manager/notification_manager.dart';
|
||||
import '../database/ephemeral_database.dart';
|
||||
|
||||
enum NetworkRole { none, host, guest }
|
||||
|
||||
@@ -24,17 +24,13 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 상수 설정
|
||||
// ------------------------------------------------------------------------
|
||||
static const String PACKET_DELIMITER = "|||EOP|||";
|
||||
static const int HEARTBEAT_INTERVAL_SEC = 3;
|
||||
static const int TIMEOUT_SEC = 10;
|
||||
static const int RECONNECT_WAIT_SEC = 5;
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 상태 변수
|
||||
// ------------------------------------------------------------------------
|
||||
late UserInfo me;
|
||||
NetworkRole role = NetworkRole.none;
|
||||
|
||||
@@ -44,14 +40,15 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
|
||||
ServerSocket? _serverSocket;
|
||||
Socket? _clientSocket;
|
||||
|
||||
final Map<Socket, UserInfo?> _connectedGuests = {};
|
||||
final List<UserInfo> guestList = [];
|
||||
final Map<Socket, UserInfo?> _connectedGuests = {};
|
||||
final Map<Socket, String> _packetBuffers = {};
|
||||
|
||||
|
||||
BonsoirService? _bonsoirService;
|
||||
BonsoirBroadcast? _bonsoirBroadcast;
|
||||
BonsoirDiscovery? _bonsoirDiscovery;
|
||||
|
||||
final List<UserInfo> guestList = [];
|
||||
|
||||
final _messageController = StreamController<Map<String, dynamic>>.broadcast();
|
||||
Stream<Map<String, dynamic>> get messageStream => _messageController.stream;
|
||||
|
||||
@@ -63,9 +60,13 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
|
||||
DateTime? _lastPongTime;
|
||||
bool _isReconnecting = false;
|
||||
|
||||
// [추가] 현재 선택된 게임 ID 및 설정
|
||||
String selectedGameId = 'quiz_mix';
|
||||
Map<String, dynamic> selectedGameConfig = {}; // 난이도 등 저장
|
||||
Map<String, dynamic> selectedGameConfig = {};
|
||||
|
||||
int _sendSeq = 0;
|
||||
int _recvSeq = 0;
|
||||
|
||||
EphemeralDatabase? get _database => MediaManager().db;
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 초기화
|
||||
@@ -98,82 +99,24 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 레디 시스템
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
// [수정] 게임 선택 함수 (Config 추가)
|
||||
void selectGame(String gameId, {Map<String, dynamic>? config}) {
|
||||
selectedGameId = gameId;
|
||||
selectedGameConfig = config ?? {};
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void toggleReady() {
|
||||
me = me.copyWith(isReady: !me.isReady);
|
||||
notifyListeners();
|
||||
|
||||
final payload = {
|
||||
'type': 'TOGGLE_READY',
|
||||
'userId': me.id,
|
||||
'isReady': me.isReady,
|
||||
};
|
||||
sendMessage(payload);
|
||||
|
||||
if (role == NetworkRole.host) {
|
||||
_checkAllReadyAndStart();
|
||||
}
|
||||
}
|
||||
|
||||
void _checkAllReadyAndStart() {
|
||||
if (guestList.isEmpty) return;
|
||||
if (!me.isReady) return;
|
||||
|
||||
bool allGuestsReady = guestList.every((u) => u.isReady);
|
||||
|
||||
if (allGuestsReady) {
|
||||
_log("🚀 전원 준비 완료! 3초 후 게임 시작...");
|
||||
Future.delayed(const Duration(seconds: 1), () {
|
||||
// [수정] 게임 시작 패킷에 Config 포함
|
||||
final startPayload = {
|
||||
'type': 'GAME_START',
|
||||
'gameId': selectedGameId,
|
||||
'config': selectedGameConfig
|
||||
};
|
||||
sendMessage(startPayload);
|
||||
_messageController.add(startPayload);
|
||||
_resetAllReadyState();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _resetAllReadyState() {
|
||||
me = me.copyWith(isReady: false);
|
||||
for (int i = 0; i < guestList.length; i++) {
|
||||
guestList[i] = guestList[i].copyWith(isReady: false);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// Host Logic
|
||||
// [Socket] 호스팅 로직 (WiFi/Hotspot)
|
||||
// ------------------------------------------------------------------------
|
||||
Future<void> startHosting(String roomName) async {
|
||||
stopNetwork(force: true);
|
||||
await stopNetwork(force: true);
|
||||
role = NetworkRole.host;
|
||||
_sendSeq = 0; _recvSeq = 0;
|
||||
|
||||
try {
|
||||
_serverSocket = await ServerSocket.bind(InternetAddress.anyIPv4, 0);
|
||||
int port = _serverSocket!.port;
|
||||
this.hostPort = port;
|
||||
|
||||
String? myIp = await _getWifiIp();
|
||||
this.hostIp = myIp ?? '127.0.0.1';
|
||||
_log("✅ 방 생성: $hostIp : $port");
|
||||
|
||||
|
||||
_serverSocket!.listen((Socket client) {
|
||||
_handleNewGuest(client);
|
||||
});
|
||||
|
||||
_bonsoirService = BonsoirService(
|
||||
name: '$roomName#${me.id}',
|
||||
type: '_playwith._tcp',
|
||||
@@ -182,11 +125,9 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
|
||||
);
|
||||
_bonsoirBroadcast = BonsoirBroadcast(service: _bonsoirService!);
|
||||
await _bonsoirBroadcast!.start();
|
||||
|
||||
await MediaManager().initialize(roomName);
|
||||
_startHeartbeat();
|
||||
notifyListeners();
|
||||
|
||||
} catch (e) {
|
||||
_log("❌ 방 생성 실패: $e");
|
||||
stopNetwork(force: true);
|
||||
@@ -199,8 +140,16 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
|
||||
_packetBuffers[client] = "";
|
||||
|
||||
final myHandshake = {'type': 'HANDSHAKE', 'payload': me.toJson()};
|
||||
final jsonString = jsonEncode(myHandshake);
|
||||
client.add(utf8.encode('$jsonString$PACKET_DELIMITER'));
|
||||
client.add(utf8.encode('${jsonEncode(myHandshake)}$PACKET_DELIMITER'));
|
||||
|
||||
Future.delayed(const Duration(milliseconds: 500), () {
|
||||
final gameSync = {
|
||||
'type': 'GAME_CHANGED',
|
||||
'gameId': selectedGameId,
|
||||
'config': selectedGameConfig
|
||||
};
|
||||
client.add(utf8.encode('${jsonEncode(gameSync)}$PACKET_DELIMITER'));
|
||||
});
|
||||
|
||||
client.listen(
|
||||
(Uint8List data) => _onDataReceived(client, data),
|
||||
@@ -221,9 +170,7 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// Guest Logic
|
||||
// ------------------------------------------------------------------------
|
||||
// [Socket] 게스트 로직
|
||||
Stream<List<BonsoirService>> discoverRooms() {
|
||||
final controller = StreamController<List<BonsoirService>>();
|
||||
final List<BonsoirService> foundServices = [];
|
||||
@@ -233,7 +180,6 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
|
||||
try {
|
||||
_bonsoirDiscovery = BonsoirDiscovery(type: '_playwith._tcp');
|
||||
await _bonsoirDiscovery!.start();
|
||||
|
||||
if (_bonsoirDiscovery?.eventStream != null) {
|
||||
_bonsoirDiscovery!.eventStream!.listen((dynamic event) {
|
||||
final String type = event.type.toString();
|
||||
@@ -256,60 +202,30 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
|
||||
return controller.stream;
|
||||
}
|
||||
|
||||
// [수정] 싱글 모드 시작 시 Config 추가
|
||||
Future<void> startSoloMode(String gameId, {Map<String, dynamic>? config}) async {
|
||||
stopNetwork(force: true);
|
||||
|
||||
role = NetworkRole.host;
|
||||
hostIp = "Solo Mode";
|
||||
hostPort = 0;
|
||||
selectedGameId = gameId;
|
||||
selectedGameConfig = config ?? {}; // Config 저장
|
||||
|
||||
await MediaManager().initialize("solo_session");
|
||||
|
||||
_log("👤 싱글 플레이 모드 시작: $gameId");
|
||||
notifyListeners();
|
||||
|
||||
// 바로 시작 신호
|
||||
Future.delayed(const Duration(milliseconds: 100), () {
|
||||
_messageController.add({
|
||||
'type': 'GAME_START',
|
||||
'gameId': gameId,
|
||||
'config': selectedGameConfig
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Future<void> joinRoom(String ip, int port) async {
|
||||
if (role != NetworkRole.guest) stopNetwork(force: true);
|
||||
if (role != NetworkRole.guest) await stopNetwork(force: true);
|
||||
role = NetworkRole.guest;
|
||||
hostIp = ip;
|
||||
hostPort = port;
|
||||
_sendSeq = 0; _recvSeq = 0;
|
||||
|
||||
try {
|
||||
_log("🚀 접속 시도: $ip:$port");
|
||||
_clientSocket = await Socket.connect(ip, port, timeout: const Duration(seconds: 5));
|
||||
_log("✅ 접속 성공!");
|
||||
|
||||
_packetBuffers[_clientSocket!] = "";
|
||||
|
||||
sendMessage({'type': 'HANDSHAKE', 'payload': me.toJson()});
|
||||
|
||||
final myHandshake = {'type': 'HANDSHAKE', 'payload': me.toJson()};
|
||||
_clientSocket!.add(utf8.encode('${jsonEncode(myHandshake)}$PACKET_DELIMITER'));
|
||||
|
||||
await MediaManager().initialize("guest_${ip.replaceAll('.', '_')}");
|
||||
|
||||
_lastPongTime = DateTime.now();
|
||||
_startHeartbeat();
|
||||
_cancelDisconnectTimer();
|
||||
|
||||
_clientSocket!.listen(
|
||||
(Uint8List data) => _onDataReceived(_clientSocket!, data),
|
||||
onError: (e) => _handleConnectionLost(e),
|
||||
onDone: () => _handleConnectionLost("Socket Closed"),
|
||||
onError: (e) => stopNetwork(force: true),
|
||||
onDone: () => stopNetwork(force: true),
|
||||
);
|
||||
notifyListeners();
|
||||
|
||||
} catch (e) {
|
||||
_log("❌ 접속 실패: $e");
|
||||
if (!_isReconnecting) stopNetwork(force: true);
|
||||
@@ -317,6 +233,25 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
|
||||
}
|
||||
}
|
||||
|
||||
// 싱글 모드
|
||||
Future<void> startSoloMode(String gameId, {Map<String, dynamic>? config}) async {
|
||||
await stopNetwork(force: true);
|
||||
role = NetworkRole.host;
|
||||
hostIp = "Solo Mode";
|
||||
hostPort = 0;
|
||||
selectedGameId = gameId;
|
||||
selectedGameConfig = config ?? {};
|
||||
_sendSeq = 0; _recvSeq = 0;
|
||||
|
||||
await MediaManager().initialize("solo_session");
|
||||
_log("👤 싱글 플레이 모드: $gameId");
|
||||
notifyListeners();
|
||||
|
||||
Future.delayed(const Duration(milliseconds: 100), () {
|
||||
_messageController.add({'type': 'GAME_START', 'gameId': gameId, 'config': selectedGameConfig});
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 데이터 송수신
|
||||
// ------------------------------------------------------------------------
|
||||
@@ -327,15 +262,20 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
|
||||
void sendMessage(Map<String, dynamic> messageMap) {
|
||||
if (role == NetworkRole.guest && _clientSocket == null) return;
|
||||
|
||||
final String type = messageMap['type'] ?? '';
|
||||
bool isSystem = ['PING', 'PONG', 'HANDSHAKE', 'REQ_RESEND', 'RESEND_DATA'].contains(type);
|
||||
|
||||
if (!isSystem) {
|
||||
_sendSeq++;
|
||||
messageMap['seq'] = _sendSeq;
|
||||
if (_database != null) _database!.logPacket(_sendSeq, jsonEncode(messageMap));
|
||||
}
|
||||
|
||||
final jsonString = jsonEncode(messageMap);
|
||||
if (messageMap['type'] != 'PING' && messageMap['type'] != 'PONG') {
|
||||
if (messageMap['type'] == 'chat') {
|
||||
_log("📤 전송: [CHAT]");
|
||||
} else if (messageMap['type'] == 'media') {
|
||||
_log("📤 전송: [MEDIA]");
|
||||
} else {
|
||||
_log("📤 전송: $jsonString");
|
||||
}
|
||||
if (!isSystem) {
|
||||
if (type == 'chat') _log("📤 전송(#$_sendSeq): [CHAT]");
|
||||
else if (type == 'media') _log("📤 전송(#$_sendSeq): [MEDIA]");
|
||||
else _log("📤 전송(#$_sendSeq): $jsonString");
|
||||
}
|
||||
|
||||
final fullMessage = '$jsonString$PACKET_DELIMITER';
|
||||
@@ -354,12 +294,10 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
|
||||
try {
|
||||
String buffer = _packetBuffers[socket] ?? "";
|
||||
buffer += utf8.decode(data, allowMalformed: true);
|
||||
|
||||
while (buffer.contains(PACKET_DELIMITER)) {
|
||||
final int delimiterIndex = buffer.indexOf(PACKET_DELIMITER);
|
||||
final String msg = buffer.substring(0, delimiterIndex);
|
||||
buffer = buffer.substring(delimiterIndex + PACKET_DELIMITER.length);
|
||||
|
||||
if (msg.trim().isNotEmpty) _processMessage(socket, msg);
|
||||
}
|
||||
_packetBuffers[socket] = buffer;
|
||||
@@ -368,35 +306,63 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
|
||||
}
|
||||
}
|
||||
|
||||
void _processMessage(Socket socket, String msg) {
|
||||
void _processMessage(Socket? socket, String msg) {
|
||||
try {
|
||||
final Map<String, dynamic> jsonMap = jsonDecode(msg);
|
||||
final String type = jsonMap['type'] ?? '';
|
||||
|
||||
if (jsonMap['type'] == 'PING') {
|
||||
if (type == 'PING') {
|
||||
sendMessage({'type': 'PONG'});
|
||||
_lastPongTime = DateTime.now();
|
||||
return;
|
||||
}
|
||||
if (jsonMap['type'] == 'PONG') {
|
||||
if (type == 'PONG') {
|
||||
_lastPongTime = DateTime.now();
|
||||
return;
|
||||
}
|
||||
|
||||
if (jsonMap['type'] == 'HANDSHAKE') {
|
||||
if (type == 'HANDSHAKE') {
|
||||
final guestInfo = UserInfo.fromJson(jsonMap['payload']);
|
||||
|
||||
if (role == NetworkRole.host) {
|
||||
if (role == NetworkRole.host && socket != null) {
|
||||
_connectedGuests[socket] = guestInfo;
|
||||
}
|
||||
|
||||
guestList.removeWhere((u) => u.id == guestInfo.id);
|
||||
guestList.add(guestInfo);
|
||||
notifyListeners();
|
||||
_messageController.add(jsonMap);
|
||||
return;
|
||||
}
|
||||
|
||||
if (jsonMap['type'] == 'TOGGLE_READY') {
|
||||
if (type == 'GAME_CHANGED') {
|
||||
if (jsonMap['gameId'] != null) {
|
||||
selectedGameId = jsonMap['gameId'];
|
||||
selectedGameConfig = jsonMap['config'] ?? {};
|
||||
notifyListeners();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (type == 'REQ_RESEND') {
|
||||
_handleResendRequest(jsonMap['from'], jsonMap['to']);
|
||||
return;
|
||||
}
|
||||
if (type == 'RESEND_DATA') {
|
||||
_processMessage(socket, jsonMap['data']);
|
||||
return;
|
||||
}
|
||||
|
||||
if (jsonMap.containsKey('seq')) {
|
||||
int seq = jsonMap['seq'];
|
||||
if (seq > _recvSeq + 1) {
|
||||
_log("⚠️ 패킷 유실 감지! (기대: ${_recvSeq + 1}, 수신: $seq)");
|
||||
sendMessage({
|
||||
'type': 'REQ_RESEND',
|
||||
'from': _recvSeq + 1,
|
||||
'to': seq - 1
|
||||
});
|
||||
}
|
||||
if (seq > _recvSeq) _recvSeq = seq;
|
||||
}
|
||||
|
||||
if (type == 'TOGGLE_READY') {
|
||||
final String userId = jsonMap['userId'];
|
||||
final bool isReady = jsonMap['isReady'];
|
||||
final index = guestList.indexWhere((u) => u.id == userId);
|
||||
@@ -412,14 +378,9 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
|
||||
return;
|
||||
}
|
||||
|
||||
if (jsonMap['type'] == 'GAME_START') {
|
||||
if (jsonMap['gameId'] != null) {
|
||||
selectedGameId = jsonMap['gameId'];
|
||||
}
|
||||
// [추가] Config 동기화
|
||||
if (jsonMap['config'] != null) {
|
||||
selectedGameConfig = jsonMap['config'];
|
||||
}
|
||||
if (type == 'GAME_START') {
|
||||
if (jsonMap['gameId'] != null) selectedGameId = jsonMap['gameId'];
|
||||
if (jsonMap['config'] != null) selectedGameConfig = jsonMap['config'];
|
||||
_resetAllReadyState();
|
||||
_messageController.add(jsonMap);
|
||||
return;
|
||||
@@ -444,6 +405,22 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleResendRequest(int fromSeq, int toSeq) async {
|
||||
if (_database == null) return;
|
||||
final packets = await _database!.getPacketsInRange(fromSeq, toSeq);
|
||||
for (var p in packets) {
|
||||
final resendPacket = {'type': 'RESEND_DATA', 'data': p.payload};
|
||||
final jsonString = jsonEncode(resendPacket);
|
||||
final data = utf8.encode('$jsonString$PACKET_DELIMITER');
|
||||
|
||||
if (role == NetworkRole.host) {
|
||||
for (var s in _connectedGuests.keys) s.add(data);
|
||||
} else {
|
||||
_clientSocket?.add(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _handleConnectionLost(dynamic reason) {
|
||||
if (role != NetworkRole.guest) return;
|
||||
_clientSocket?.destroy();
|
||||
@@ -502,24 +479,88 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
|
||||
return null;
|
||||
}
|
||||
|
||||
void stopNetwork({bool force = false}) {
|
||||
Future<void> stopNetwork({bool force = false}) async {
|
||||
if (!force && _disconnectWaitTimer != null) return;
|
||||
MediaManager().cleanup();
|
||||
_heartbeatTimer?.cancel();
|
||||
_disconnectWaitTimer?.cancel();
|
||||
_disconnectWaitTimer = null;
|
||||
_bonsoirBroadcast?.stop();
|
||||
_bonsoirDiscovery?.stop();
|
||||
|
||||
_serverSocket?.close();
|
||||
_clientSocket?.close();
|
||||
for (var s in _connectedGuests.keys) s.close();
|
||||
_connectedGuests.clear();
|
||||
|
||||
_bonsoirBroadcast?.stop();
|
||||
_bonsoirDiscovery?.stop();
|
||||
|
||||
MediaManager().cleanup();
|
||||
_heartbeatTimer?.cancel();
|
||||
_disconnectWaitTimer?.cancel();
|
||||
_disconnectWaitTimer = null;
|
||||
_packetBuffers.clear();
|
||||
guestList.clear();
|
||||
role = NetworkRole.none;
|
||||
_serverSocket = null;
|
||||
_clientSocket = null;
|
||||
if (force) { hostIp = null; hostPort = null; }
|
||||
|
||||
if (force) {
|
||||
hostIp = null;
|
||||
hostPort = null;
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 게임 관리
|
||||
// ------------------------------------------------------------------------
|
||||
void selectGame(String gameId, {Map<String, dynamic>? config}) {
|
||||
selectedGameId = gameId;
|
||||
selectedGameConfig = config ?? {};
|
||||
notifyListeners();
|
||||
|
||||
if (role == NetworkRole.host) {
|
||||
sendMessage({
|
||||
'type': 'GAME_CHANGED',
|
||||
'gameId': gameId,
|
||||
'config': selectedGameConfig
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void toggleReady() {
|
||||
me = me.copyWith(isReady: !me.isReady);
|
||||
notifyListeners();
|
||||
sendMessage({
|
||||
'type': 'TOGGLE_READY',
|
||||
'userId': me.id,
|
||||
'isReady': me.isReady,
|
||||
});
|
||||
if (role == NetworkRole.host) _checkAllReadyAndStart();
|
||||
}
|
||||
|
||||
void _checkAllReadyAndStart() {
|
||||
if (guestList.isEmpty && role != NetworkRole.host) return;
|
||||
if (!me.isReady) return;
|
||||
|
||||
bool allGuestsReady = guestList.every((u) => u.isReady);
|
||||
|
||||
if (allGuestsReady) {
|
||||
_log("🚀 전원 준비 완료! 3초 후 게임 시작...");
|
||||
Future.delayed(const Duration(seconds: 1), () {
|
||||
final startPayload = {
|
||||
'type': 'GAME_START',
|
||||
'gameId': selectedGameId,
|
||||
'config': selectedGameConfig
|
||||
};
|
||||
sendMessage(startPayload);
|
||||
_messageController.add(startPayload);
|
||||
_resetAllReadyState();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _resetAllReadyState() {
|
||||
me = me.copyWith(isReady: false);
|
||||
for (int i = 0; i < guestList.length; i++) {
|
||||
guestList[i] = guestList[i].copyWith(isReady: false);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user