This commit is contained in:
2025-11-24 17:53:00 +09:00
parent e992a5ca5e
commit dde81cab65
34 changed files with 3718 additions and 469 deletions
+350 -165
View File
@@ -3,121 +3,180 @@ import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:bonsoir/bonsoir.dart'; // mDNS 패키지
import 'package:bonsoir/bonsoir.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:uuid/uuid.dart';
import '../model/user_info.dart';
import '../model/play_packet.dart';
import '../manager/global_chat_manager.dart';
import '../manager/media_manager.dart'; // [New] 미디어 매니저
/// 현재 네트워크 상태 (역할)
enum NetworkRole { none, host, guest }
/// P2P 네트워크 통신을 담당하는 싱글톤 매니저
class NetworkManager extends ChangeNotifier {
// ------------------------------------------------------------------------
// 1. Singleton & Initialization
// ------------------------------------------------------------------------
class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
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})');
NetworkManager._internal() {
WidgetsBinding.instance.addObserver(this);
}
// ------------------------------------------------------------------------
// 2. Variables & Streams
// 상태 변수
// ------------------------------------------------------------------------
late UserInfo me;
NetworkRole role = NetworkRole.none;
String? hostIp;
int? hostPort;
ServerSocket? _serverSocket;
Socket? _clientSocket;
// 소켓
ServerSocket? _serverSocket; // (Host용)
Socket? _clientSocket; // (Guest용)
final List<Socket> _connectedGuests = []; // (Host가 관리하는 게스트 목록)
// 소켓과 유저 정보를 1:1 매핑
final Map<Socket, UserInfo?> _connectedGuests = {};
// UI 표시용 게스트 명단
final List<UserInfo> guestList = [];
// 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;
// 로그 스트림
final _logController = StreamController<String>.broadcast();
Stream<String> get logStream => _logController.stream;
try {
// A. TCP 서버 소켓 오픈 (Port 0 = 시스템 자동 할당)
_serverSocket = await ServerSocket.bind(InternetAddress.anyIPv4, 0);
int port = _serverSocket!.port;
print('[Host] Server opened on port: $port');
// 하트비트 & 재접속
Timer? _heartbeatTimer;
Timer? _disconnectWaitTimer;
DateTime? _lastPongTime;
bool _isReconnecting = false;
final interfaces = await NetworkInterface.list(type: InternetAddressType.IPv4);
String myIp = '127.0.0.1';
try {
// 보통 wlan0 혹은 en0가 와이파이 인터페이스
myIp = interfaces.firstWhere((i) => i.name != 'lo').addresses.first.address;
} catch (e) {
print('IP search failed: $e');
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}) {
final uuid = const Uuid().v4().substring(0, 8);
final randomColor = 0xFF000000 | (nickname.hashCode & 0xFFFFFF);
me = UserInfo(id: uuid, nickname: nickname, colorValue: randomColor);
_log("초기화 완료: ${me.nickname}");
}
// B. 게스트 접속 대기
void _log(String msg) {
final timestamp = DateTime.now().toIso8601String().split('T').last.substring(0, 8);
print("[$timestamp] $msg");
_logController.add("[$timestamp] $msg");
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
if (role == NetworkRole.guest && _clientSocket == null && hostIp != null) {
_attemptReconnection();
}
}
}
// ------------------------------------------------------------------------
// 레디 시스템
// ------------------------------------------------------------------------
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), () {
final startPayload = {'type': 'GAME_START', 'gameId': 'quiz_ox'};
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
// ------------------------------------------------------------------------
Future<void> startHosting(String roomName) async {
stopNetwork(force: true);
role = NetworkRole.host;
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);
});
// C. mDNS로 방 광고 (Broadcast)
// 서비스 타입은 고유해야 함 (_playwith._tcp)
// 이름 포맷: "방이름#호스트ID" (중복 방지 및 식별용)
_bonsoirService = BonsoirService(
name: '$roomName#${me.id}',
name: '$roomName#${me.id}',
type: '_playwith._tcp',
port: port,
attributes: {'ip': myIp},
attributes: {'ip': hostIp!},
);
_bonsoirBroadcast = BonsoirBroadcast(service: _bonsoirService!);
await _bonsoirBroadcast!.ready;
await _bonsoirBroadcast!.start();
print('[Host] Start advertising room: $roomName');
// [NEW] 미디어 DB 초기화 (Host)
await MediaManager().initialize(roomName);
_startHeartbeat();
notifyListeners();
} catch (e) {
print('[Host] Error starting host: $e');
stopNetwork();
_log("❌ 방 생성 실패: $e");
stopNetwork(force: true);
}
}
/// 새로운 게스트가 접속했을 때
void _handleNewGuest(Socket client) {
print('[Host] New guest connected: ${client.remoteAddress.address}');
_connectedGuests.add(client);
// 데이터 수신 리스너 부착
_log("🎉 연결됨: ${client.remoteAddress.address}");
_connectedGuests[client] = null;
client.listen(
(Uint8List data) => _onDataReceived(client, data),
onError: (e) => _removeGuest(client),
@@ -126,163 +185,289 @@ final interfaces = await NetworkInterface.list(type: InternetAddressType.IPv4);
}
void _removeGuest(Socket client) {
print('[Host] Guest disconnected');
final UserInfo? user = _connectedGuests[client];
if (user != null) {
_log("👋 퇴장: ${user.nickname}");
guestList.removeWhere((u) => u.id == user.id);
}
_connectedGuests.remove(client);
client.close();
notifyListeners();
}
// ------------------------------------------------------------------------
// 4. Guest Logic (참가자)
// Guest Logic
// ------------------------------------------------------------------------
/// 주변 방 찾기 (mDNS Discovery)
Stream<List<BonsoirService>> discoverRooms() {
// 리스트를 계속 갱신해서 내보내기 위한 컨트롤러
final controller = StreamController<List<BonsoirService>>();
final List<BonsoirService> foundServices = [];
_bonsoirDiscovery?.stop();
_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));
}
Future(() async {
try {
_bonsoirDiscovery = BonsoirDiscovery(type: '_playwith._tcp');
await _bonsoirDiscovery!.start();
if (_bonsoirDiscovery?.eventStream != null) {
_bonsoirDiscovery!.eventStream!.listen((dynamic event) {
final String type = event.type.toString();
if (event.service == null) return;
if (type.contains('Found')) {
foundServices.removeWhere((s) => s.name == event.service!.name);
foundServices.add(event.service!);
controller.add(List.from(foundServices));
} else if (type.contains('Lost')) {
foundServices.removeWhere((s) => s.name == event.service!.name);
controller.add(List.from(foundServices));
}
});
}
});
} catch (e) {
_log("스캔 실패: $e");
}
});
return controller.stream;
}
/// 방 접속하기
Future<void> joinRoom(String ip, int port) async {
stopNetwork(); // 기존 연결 정리
if (role != NetworkRole.guest) stopNetwork(force: true);
role = NetworkRole.guest;
hostIp = ip;
hostPort = port;
try {
print('[Guest] Connecting to $ip:$port...');
_clientSocket = await Socket.connect(ip, port);
print('[Guest] Connected!');
_log("🚀 접속 시도: $ip:$port");
_clientSocket = await Socket.connect(ip, port, timeout: const Duration(seconds: 5));
_log("✅ 접속 성공!");
// 접속 성공 시 즉시 내 정보 전송 (Handshake)
sendMessage({
'type': 'HANDSHAKE',
'senderId': me.id,
'payload': me.toJson(),
});
sendMessage({'type': 'HANDSHAKE', 'payload': me.toJson()});
// [NEW] 미디어 DB 초기화 (Guest는 임시 ID 사용)
await MediaManager().initialize("guest_${ip.replaceAll('.', '_')}");
_lastPongTime = DateTime.now();
_startHeartbeat();
_cancelDisconnectTimer();
// 데이터 수신 리스너
_clientSocket!.listen(
(Uint8List data) => _onDataReceived(_clientSocket!, data),
onError: (e) {
print('[Guest] Connection error: $e');
stopNetwork();
},
onDone: () {
print('[Guest] Disconnected by host');
stopNetwork();
},
onError: (e) => _handleConnectionLost(e),
onDone: () => _handleConnectionLost("Socket Closed"),
);
notifyListeners();
} catch (e) {
print('[Guest] Failed to join: $e');
role = NetworkRole.none;
notifyListeners();
rethrow; // UI에서 에러 처리할 수 있게 던짐
_log("❌ 접속 실패: $e");
if (!_isReconnecting) stopNetwork(force: true);
rethrow;
}
}
// ------------------------------------------------------------------------
// 5. Common Logic (데이터 송수신)
// 데이터 송수신 & 라우팅 (핵심)
// ------------------------------------------------------------------------
void sendPacket(PlayPacket packet) {
sendMessage(packet.toJson());
}
/// 메시지 전송
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.guest && _clientSocket == null) return;
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');
}
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");
}
}
final List<int> data = utf8.encode('$jsonString\n');
if (role == NetworkRole.host) {
for (var socket in _connectedGuests.keys) {
socket.add(data);
}
} else {
_clientSocket?.add(data);
}
}
/// 데이터 수신 처리
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);
final Map<String, dynamic> jsonMap = jsonDecode(msg);
// 2. (옵션) Host라면, 받은 메시지를 다른 Guest들에게도 전달(Relay)해야 할 수 있음
// 게임 로직에 따라 다르지만 보통 Host가 중계자 역할을 함
// if (role == NetworkRole.host) { sendMessage(parsedData); }
// 1. 시스템 메시지 (Ping/Pong)
if (jsonMap['type'] == 'PING') {
sendMessage({'type': 'PONG'});
_lastPongTime = DateTime.now();
return;
}
if (jsonMap['type'] == 'PONG') {
_lastPongTime = DateTime.now();
return;
}
// 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;
}
// 4. 게임 시작
if (jsonMap['type'] == 'GAME_START') {
_resetAllReadyState();
_messageController.add(jsonMap);
return;
}
// 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) {
print('[Network] Parse Error: $e\nMessage: $msg');
_log("파싱 에러: $e");
}
}
}
// ------------------------------------------------------------------------
// 6. Cleanup
// 연결 관리
// ------------------------------------------------------------------------
/// 네트워크 종료 및 리소스 정리
void stopNetwork() {
print('[Network] Stopping network...');
void _handleConnectionLost(dynamic reason) {
if (role != NetworkRole.guest) return;
_log("⚠️ 연결 끊김: $reason");
// mDNS 중지
_clientSocket?.destroy();
_clientSocket = null;
if (_disconnectWaitTimer != null && _disconnectWaitTimer!.isActive) return;
_disconnectWaitTimer = Timer(const Duration(seconds: RECONNECT_WAIT_SEC), () {
_log("💀 복구 실패. 종료.");
stopNetwork(force: true);
});
_attemptReconnection();
}
Future<void> _attemptReconnection() async {
if (hostIp == null || hostPort == null) return;
_isReconnecting = true;
while (_disconnectWaitTimer != null && _disconnectWaitTimer!.isActive) {
try {
await joinRoom(hostIp!, hostPort!);
_log("✅ 재접속 성공!");
_isReconnecting = false;
return;
} catch (e) {
await Future.delayed(const Duration(seconds: 1));
}
}
_isReconnecting = false;
}
void _cancelDisconnectTimer() {
_disconnectWaitTimer?.cancel();
_disconnectWaitTimer = null;
}
void _startHeartbeat() {
_heartbeatTimer?.cancel();
_heartbeatTimer = Timer.periodic(const Duration(seconds: HEARTBEAT_INTERVAL_SEC), (timer) {
try { sendMessage({'type': 'PING'}); } catch (_) {}
if (role == NetworkRole.guest && _lastPongTime != null) {
if (DateTime.now().difference(_lastPongTime!).inSeconds > TIMEOUT_SEC) {
_handleConnectionLost("Heartbeat Timeout");
}
}
});
}
Future<String?> _getWifiIp() async {
try {
for (var interface in await NetworkInterface.list()) {
if (interface.name.contains('wlan') || interface.name.contains('en') || interface.name.contains('ap')) {
for (var addr in interface.addresses) {
if (addr.type == InternetAddressType.IPv4 && !addr.isLoopback) {
return addr.address;
}
}
}
}
} catch (e) {/**/}
return null;
}
void stopNetwork({bool force = false}) {
if (!force && _disconnectWaitTimer != null) return;
_log("🛑 종료");
// [NEW] 미디어 DB 정리
MediaManager().cleanup();
_heartbeatTimer?.cancel();
_disconnectWaitTimer?.cancel();
_disconnectWaitTimer = null;
_bonsoirBroadcast?.stop();
_bonsoirDiscovery?.stop();
// 소켓 닫기
_serverSocket?.close();
_clientSocket?.close();
for (var socket in _connectedGuests) {
socket.close();
}
for (var s in _connectedGuests.keys) s.close();
_connectedGuests.clear();
guestList.clear();
role = NetworkRole.none;
_serverSocket = null;
_clientSocket = null;
if (force) { hostIp = null; hostPort = null; }
notifyListeners();
}
}