This commit is contained in:
2025-11-25 16:34:13 +09:00
parent 92a4525091
commit bc57468aaa
29 changed files with 2206 additions and 641 deletions
+146 -94
View File
@@ -7,11 +7,11 @@ import 'package:bonsoir/bonsoir.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:uuid/uuid.dart';
import '../manager/notification_manager.dart';
import '../model/user_info.dart';
import '../model/play_packet.dart';
import '../manager/global_chat_manager.dart';
import '../manager/media_manager.dart'; // [New] 미디어 매니저
import '../manager/media_manager.dart';
enum NetworkRole { none, host, guest }
@@ -23,6 +23,16 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
WidgetsBinding.instance.addObserver(this);
}
// ------------------------------------------------------------------------
// 상수 설정
// ------------------------------------------------------------------------
// [핵심] 패킷 구분자 (End Of Packet) - Base64와 겹치지 않는 고유 문자열
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;
// ------------------------------------------------------------------------
// 상태 변수
// ------------------------------------------------------------------------
@@ -35,41 +45,43 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
ServerSocket? _serverSocket;
Socket? _clientSocket;
// 소켓과 유저 정보를 1:1 매핑
final Map<Socket, UserInfo?> _connectedGuests = {};
// UI 표시용 게스트 명단
final List<UserInfo> guestList = [];
// [버퍼] 소켓별로 들어오다 만 데이터를 저장
final Map<Socket, String> _packetBuffers = {};
BonsoirService? _bonsoirService;
BonsoirBroadcast? _bonsoirBroadcast;
BonsoirDiscovery? _bonsoirDiscovery;
// 게임 데이터 스트림
final _messageController = StreamController<Map<String, dynamic>>.broadcast();
Stream<Map<String, dynamic>> get messageStream => _messageController.stream;
// 로그 스트림
final _logController = StreamController<String>.broadcast();
Stream<String> get logStream => _logController.stream;
// 하트비트 & 재접속
Timer? _heartbeatTimer;
Timer? _disconnectWaitTimer;
DateTime? _lastPongTime;
bool _isReconnecting = false;
static const int HEARTBEAT_INTERVAL_SEC = 3;
static const int TIMEOUT_SEC = 10;
static const int RECONNECT_WAIT_SEC = 5;
// ------------------------------------------------------------------------
// 초기화
// ------------------------------------------------------------------------
void initialize({required String nickname}) {
void initialize({
required String nickname,
String? profileImage, // [추가] 선택적 파라미터
}) {
final uuid = const Uuid().v4().substring(0, 8);
final randomColor = 0xFF000000 | (nickname.hashCode & 0xFFFFFF);
me = UserInfo(id: uuid, nickname: nickname, colorValue: randomColor);
me = UserInfo(
id: uuid,
nickname: nickname,
colorValue: randomColor,
profileImageBase64: profileImage, // [적용]
);
_log("초기화 완료: ${me.nickname}");
}
@@ -161,7 +173,6 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
_bonsoirBroadcast = BonsoirBroadcast(service: _bonsoirService!);
await _bonsoirBroadcast!.start();
// [NEW] 미디어 DB 초기화 (Host)
await MediaManager().initialize(roomName);
_startHeartbeat();
@@ -176,6 +187,7 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
void _handleNewGuest(Socket client) {
_log("🎉 연결됨: ${client.remoteAddress.address}");
_connectedGuests[client] = null;
_packetBuffers[client] = ""; // 버퍼 초기화
client.listen(
(Uint8List data) => _onDataReceived(client, data),
@@ -191,6 +203,7 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
guestList.removeWhere((u) => u.id == user.id);
}
_connectedGuests.remove(client);
_packetBuffers.remove(client);
client.close();
notifyListeners();
}
@@ -240,10 +253,11 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
_log("🚀 접속 시도: $ip:$port");
_clientSocket = await Socket.connect(ip, port, timeout: const Duration(seconds: 5));
_log("✅ 접속 성공!");
_packetBuffers[_clientSocket!] = ""; // 내 버퍼 초기화
sendMessage({'type': 'HANDSHAKE', 'payload': me.toJson()});
// [NEW] 미디어 DB 초기화 (Guest는 임시 ID 사용)
await MediaManager().initialize("guest_${ip.replaceAll('.', '_')}");
_lastPongTime = DateTime.now();
@@ -265,7 +279,7 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
}
// ------------------------------------------------------------------------
// 데이터 송수신 & 라우팅 (핵심)
// 데이터 송수신 (버퍼링 및 구분자 로직 강화)
// ------------------------------------------------------------------------
void sendPacket(PlayPacket packet) {
sendMessage(packet.toJson());
@@ -275,6 +289,7 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
if (role == NetworkRole.guest && _clientSocket == null) return;
final jsonString = jsonEncode(messageMap);
// 로그 필터링
if (messageMap['type'] != 'PING' && messageMap['type'] != 'PONG') {
if (messageMap['type'] == 'chat') {
@@ -286,7 +301,10 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
}
}
final List<int> data = utf8.encode('$jsonString\n');
// [핵심] 메시지 뒤에 고유 구분자를 붙여서 전송
final fullMessage = '$jsonString$PACKET_DELIMITER';
final List<int> data = utf8.encode(fullMessage);
if (role == NetworkRole.host) {
for (var socket in _connectedGuests.keys) {
socket.add(data);
@@ -297,86 +315,98 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
}
void _onDataReceived(Socket socket, Uint8List data) {
final String rawString = utf8.decode(data);
final List<String> splitMessages = rawString.split('\n');
for (var msg in splitMessages) {
if (msg.trim().isEmpty) continue;
try {
final Map<String, dynamic> jsonMap = jsonDecode(msg);
try {
// 1. 기존 버퍼 가져오기
String buffer = _packetBuffers[socket] ?? "";
// 2. 새 데이터 추가
buffer += utf8.decode(data, allowMalformed: true);
// 1. 시스템 메시지 (Ping/Pong)
if (jsonMap['type'] == 'PING') {
sendMessage({'type': 'PONG'});
_lastPongTime = DateTime.now();
return;
}
if (jsonMap['type'] == 'PONG') {
_lastPongTime = DateTime.now();
return;
}
// 3. 구분자(PACKET_DELIMITER)를 기준으로 메시지 추출
while (buffer.contains(PACKET_DELIMITER)) {
final int delimiterIndex = buffer.indexOf(PACKET_DELIMITER);
// 2. 핸드셰이크
if (jsonMap['type'] == 'HANDSHAKE') {
final guestInfo = UserInfo.fromJson(jsonMap['payload']);
_connectedGuests[socket] = guestInfo;
guestList.removeWhere((u) => u.id == guestInfo.id);
guestList.add(guestInfo);
notifyListeners();
_messageController.add(jsonMap);
return;
}
// 3. 레디 토글
if (jsonMap['type'] == 'TOGGLE_READY') {
final String userId = jsonMap['userId'];
final bool isReady = jsonMap['isReady'];
final index = guestList.indexWhere((u) => u.id == userId);
if (index != -1) {
guestList[index] = guestList[index].copyWith(isReady: isReady);
notifyListeners();
}
if (role == NetworkRole.host) {
sendMessage(jsonMap);
_checkAllReadyAndStart();
}
_messageController.add(jsonMap);
return;
}
// 완성된 메시지 하나 추출
final String msg = buffer.substring(0, delimiterIndex);
// 4. 게임 시작
if (jsonMap['type'] == 'GAME_START') {
_resetAllReadyState();
_messageController.add(jsonMap);
return;
// 버퍼에서 추출한 부분과 구분자 제거
buffer = buffer.substring(delimiterIndex + PACKET_DELIMITER.length);
// 메시지 처리
if (msg.trim().isNotEmpty) {
_processMessage(socket, msg);
}
// 5. 패킷 라우팅 (Chat, Media, Game)
if (jsonMap.containsKey('payload') && jsonMap.containsKey('senderId')) {
final packet = PlayPacket.fromJson(jsonMap);
// [라우팅] 채팅 -> GlobalChatManager
if (packet.type == PacketType.chat) {
GlobalChatManager().onPacketReceived(packet);
return;
}
// [라우팅] 미디어 -> MediaManager
if (packet.type == PacketType.media) {
MediaManager().onMediaReceived(packet);
return;
}
}
// 6. 그 외 게임 데이터
_messageController.add(jsonMap);
} catch (e) {
_log("파싱 에러: $e");
}
// 4. 남은 찌꺼기(다음 패킷의 일부)를 다시 버퍼에 저장
_packetBuffers[socket] = buffer;
} catch (e) {
_log("데이터 수신 에러: $e");
}
}
void _processMessage(Socket socket, String msg) {
try {
final Map<String, dynamic> jsonMap = jsonDecode(msg);
if (jsonMap['type'] == 'PING') {
sendMessage({'type': 'PONG'});
_lastPongTime = DateTime.now();
return;
}
if (jsonMap['type'] == 'PONG') {
_lastPongTime = DateTime.now();
return;
}
if (jsonMap['type'] == 'HANDSHAKE') {
final guestInfo = UserInfo.fromJson(jsonMap['payload']);
_connectedGuests[socket] = guestInfo;
guestList.removeWhere((u) => u.id == guestInfo.id);
guestList.add(guestInfo);
notifyListeners();
_messageController.add(jsonMap);
return;
}
if (jsonMap['type'] == 'TOGGLE_READY') {
final String userId = jsonMap['userId'];
final bool isReady = jsonMap['isReady'];
final index = guestList.indexWhere((u) => u.id == userId);
if (index != -1) {
guestList[index] = guestList[index].copyWith(isReady: isReady);
notifyListeners();
}
if (role == NetworkRole.host) {
sendMessage(jsonMap);
_checkAllReadyAndStart();
}
_messageController.add(jsonMap);
return;
}
if (jsonMap['type'] == 'GAME_START') {
_resetAllReadyState();
_messageController.add(jsonMap);
return;
}
if (jsonMap.containsKey('payload') && jsonMap.containsKey('senderId')) {
final packet = PlayPacket.fromJson(jsonMap);
if (packet.type == PacketType.chat) {
GlobalChatManager().onPacketReceived(packet);
return;
}
if (packet.type == PacketType.media) {
MediaManager().onMediaReceived(packet);
return;
}
}
_messageController.add(jsonMap);
} catch (e) {
_log("JSON 파싱 실패: $e");
}
}
@@ -385,6 +415,7 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
// ------------------------------------------------------------------------
void _handleConnectionLost(dynamic reason) {
if (role != NetworkRole.guest) return;
_log("⚠️ 연결 끊김: $reason");
_clientSocket?.destroy();
@@ -392,13 +423,32 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
if (_disconnectWaitTimer != null && _disconnectWaitTimer!.isActive) return;
// 5초 카운트다운 시작
_disconnectWaitTimer = Timer(const Duration(seconds: RECONNECT_WAIT_SEC), () {
_log("💀 복구 실패. 종료.");
// [추가] 재접속 실패 알림 발송
_sendDisconnectionNotification();
stopNetwork(force: true);
});
_attemptReconnection();
}
// [신규] 연결 끊김 알림 메서드
void _sendDisconnectionNotification() {
// 앱이 현재 화면에 떠있지 않을 때만(백그라운드) 알림
final state = WidgetsBinding.instance.lifecycleState;
if (state == AppLifecycleState.paused || state == AppLifecycleState.inactive || state == AppLifecycleState.detached) {
NotificationManager().showNotification(
id: 9999, // 고정 ID (시스템 알림용)
title: "연결 끊김 ⚠️",
body: "방과의 연결이 종료되었습니다. 앱을 실행해 확인해주세요.",
);
}
}
Future<void> _attemptReconnection() async {
if (hostIp == null || hostPort == null) return;
_isReconnecting = true;
@@ -451,7 +501,6 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
if (!force && _disconnectWaitTimer != null) return;
_log("🛑 종료");
// [NEW] 미디어 DB 정리
MediaManager().cleanup();
_heartbeatTimer?.cancel();
@@ -461,9 +510,12 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
_bonsoirDiscovery?.stop();
_serverSocket?.close();
_clientSocket?.close();
for (var s in _connectedGuests.keys) s.close();
_connectedGuests.clear();
_packetBuffers.clear(); // 버퍼 정리
guestList.clear();
role = NetworkRole.none;
_serverSocket = null;
_clientSocket = null;