This commit is contained in:
2025-12-02 11:06:23 +09:00
parent bf40c42c2c
commit 22caf64855
34 changed files with 4594 additions and 505 deletions
+194 -153
View File
@@ -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();
}
}