import 'dart:convert'; import 'package:bonsoir/bonsoir.dart'; import 'package:flutter/material.dart'; import 'package:mobile_scanner/mobile_scanner.dart'; import 'package:playwith_core/playwith_core.dart'; import 'package:qr_flutter/qr_flutter.dart'; class LobbyScreen extends StatefulWidget { const LobbyScreen({super.key}); @override State createState() => _LobbyScreenState(); } class _LobbyScreenState extends State { final _net = NetworkManager(); final List _logs = []; final ScrollController _scrollController = ScrollController(); @override void initState() { super.initState(); _net.logStream.listen((log) { if (!mounted) return; setState(() { _logs.add(log); if (_logs.length > 100) _logs.removeAt(0); }); if (SettingsNotifier().isShowDebugLog) { WidgetsBinding.instance.addPostFrameCallback((_) { if (_scrollController.hasClients) { _scrollController.animateTo( _scrollController.position.maxScrollExtent, duration: const Duration(milliseconds: 200), curve: Curves.easeOut, ); } }); } }); _net.messageStream.listen((data) { if (data['type'] == 'GAME_START') { final String gameId = data['gameId']; if (gameId == 'quiz_ox' || gameId == 'quiz_mix') { _startGameAndNavigate(QuizGame()); } else if (gameId == 'sudoku_battle') { _startGameAndNavigate(SudokuMultiGame()); } else if (gameId == 'spider_battle') { _startGameAndNavigate(SpiderMultiGame()); } // [추가] 오목 & 장기 연결 else if (gameId == 'omok') { _startGameAndNavigate(OmokGame()); } else if (gameId == 'janggi') { _startGameAndNavigate(JanggiGame()); } else if (gameId == 'yutnori') { _startGameAndNavigate(YutnoriGame()); } else if (gameId == 'memory_battle') { _startGameAndNavigate(MemoryGame()); } // [추가] 밸런스 & 터치 배틀 연결 else if (gameId == 'balance_game') { _startGameAndNavigate(BalanceGame()); } else if (gameId == 'tap_battle') { _startGameAndNavigate(TapBattleGame()); } } }); } Future _startGameAndNavigate(BaseGame game) async { if (!mounted) return; game.onStart(); await Navigator.push( context, MaterialPageRoute(builder: (context) { Widget gameView; if (_net.role == NetworkRole.host) { gameView = game.buildHostView(context); } else { gameView = game.buildGuestView(context); } return Stack( children: [ gameView, const SafeArea( // 배너 광고 높이만큼 띄워서 채팅창 표시 child: GameChatOverlay(bottomOffset: 60.0), ), ], ); }), ); // [수정] 게임 종료 후 복귀 시 로직 // 솔로 모드였다면 네트워크를 종료하고 초기 화면으로 돌아감 if (_net.hostIp == "Solo Mode") { _net.stopNetwork(); } } void _navigateToGameSelection({required bool isSolo}) { Navigator.push( context, MaterialPageRoute( builder: (context) => GameSelectionScreen( onGameSelected: (gameId) async { // 스도쿠 선택 시 난이도 팝업 Map config = {}; if (gameId == 'sudoku_battle') { final difficulty = await _showDifficultyDialog(); if (difficulty == null) return; // 취소함 config['difficulty'] = difficulty; } if (!mounted) return; Navigator.pop(context); // 선택 화면 닫기 if (isSolo) { _net.startSoloMode(gameId, config: config); } else { _net.selectGame(gameId, config: config); _net.startHosting("${_net.me.nickname}의 방"); Future.delayed(const Duration(milliseconds: 500), () { if (mounted && _net.role == NetworkRole.host) _showHostQRDialog(); }); } }, ), ), ); } // [추가] 난이도 선택 다이얼로그 Future _showDifficultyDialog() { return showDialog( context: context, barrierDismissible: false, builder: (context) => AlertDialog( title: const Text("난이도 선택"), content: Column( mainAxisSize: MainAxisSize.min, children: [ ListTile( title: const Text("쉬움"), leading: const Icon(Icons.filter_1, color: Colors.green), onTap: () => Navigator.pop(context, 4), ), ListTile( title: const Text("보통"), leading: const Icon(Icons.filter_2, color: Colors.blue), onTap: () => Navigator.pop(context, 5), // 4~5 레벨이 보통 9x9 ), ListTile( title: const Text("약간 어려움"), leading: const Icon(Icons.filter_3, color: Colors.red), onTap: () => Navigator.pop(context, 6), // 7 레벨이 어려움 ), ListTile( title: const Text("약간 어려움"), leading: const Icon(Icons.filter_4, color: Colors.red), onTap: () => Navigator.pop(context, 7), // 7 레벨이 어려움 ), ListTile( title: const Text("개 어려움"), leading: const Icon(Icons.filter_5, color: Colors.red), onTap: () => Navigator.pop(context, 8), // 7 레벨이 어려움 ), ], ), actions: [ TextButton( onPressed: () => Navigator.pop(context, null), child: const Text("취소"), ) ], ), ); } @override Widget build(BuildContext context) { return ListenableBuilder( listenable: _net, builder: (context, child) { return ListenableBuilder( listenable: SettingsNotifier(), builder: (context, _) { return Scaffold( appBar: AppBar( title: Text('대기실: ${_net.me.nickname}'), actions: [ if (_net.role == NetworkRole.host) IconButton( icon: const Icon(Icons.qr_code), tooltip: "초대 QR 보기", onPressed: () => _showHostQRDialog(), ), if (_net.role != NetworkRole.none) IconButton( icon: const Icon(Icons.exit_to_app), tooltip: "나가기", onPressed: () => _net.stopNetwork(), ) ], ), bottomNavigationBar: const SafeArea(child: AdBannerWidget()), body: Column( children: [ Expanded( flex: 3, child: _net.role == NetworkRole.none ? _buildInitView() : _buildLobbyView() ), const Divider(thickness: 1, height: 1), if (SettingsNotifier().isShowDebugLog) _buildDebugConsole(), ], ), ); }, ); }, ); } Widget _buildInitView() { return Center( child: SingleChildScrollView( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Text("게임을 시작해볼까요?", style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)), const SizedBox(height: 40), _BigButton( title: "혼자 연습하기\n(Single)", color: Colors.orange[100]!, icon: Icons.person, onTap: () => _navigateToGameSelection(isSolo: true), ), const SizedBox(height: 30), Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ _BigButton( title: "방 만들기\n(Host)", color: Colors.blue[100]!, icon: Icons.add_home_work, onTap: () => _navigateToGameSelection(isSolo: false), ), _BigButton( title: "방 찾기\n(Guest)", color: Colors.green[100]!, icon: Icons.search, onTap: () => _showRoomListDialog(), ), ], ), const SizedBox(height: 30), ElevatedButton.icon( icon: const Icon(Icons.qr_code_scanner), label: const Text("QR 코드로 접속하기"), style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 15)), onPressed: () => _openQRScanner(), ), const SizedBox(height: 10), TextButton( onPressed: () => _showManualJoinDialog(), child: const Text("IP 주소 직접 입력 (비상용)", style: TextStyle(color: Colors.grey)), ), ], ), ), ); } Widget _buildLobbyView() { final currentGame = AppGames.getById(_net.selectedGameId); return Column( children: [ Container( width: double.infinity, padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 20), color: Colors.indigo.withOpacity(0.1), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(currentGame.icon, size: 20, color: Colors.indigo), const SizedBox(width: 8), Text( "선택된 게임: ${currentGame.name}", style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.indigo), ), ], ), ), Container( width: double.infinity, padding: const EdgeInsets.all(20), color: Colors.grey[100], child: Column( children: [ Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(_net.role == NetworkRole.host ? Icons.wifi_tethering : Icons.wifi, color: Colors.blue), const SizedBox(width: 10), Text( _net.role == NetworkRole.host ? "👑 방장 (나)" : "참가자 (나)", style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), ), ], ), if (_net.role == NetworkRole.host) ...[ const SizedBox(height: 10), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ SelectableText( "IP: ${_net.hostIp} / Port: ${_net.hostPort}", style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16), ), const SizedBox(width: 10), InkWell( onTap: () => _showHostQRDialog(), child: const Icon(Icons.qr_code, color: Colors.black87), ) ], ), const SizedBox(height: 5), const Text("QR 코드를 눌러 친구를 초대하세요!", style: TextStyle(fontSize: 12, color: Colors.grey)), ] else ...[ const SizedBox(height: 10), Text("방장 IP: ${_net.hostIp ?? '...'}", style: const TextStyle(color: Colors.grey)), ] ], ), ), const Divider(height: 1), Expanded( child: ListView( padding: const EdgeInsets.all(16), children: [ const Text("참가자 목록", style: TextStyle(color: Colors.grey, fontWeight: FontWeight.bold)), const SizedBox(height: 10), _buildUserTile(_net.me, isMe: true), ..._net.guestList.map((guest) => _buildUserTile(guest, isMe: false)), if (_net.guestList.isEmpty && _net.role == NetworkRole.host) const Padding( padding: EdgeInsets.all(40.0), child: Center(child: Text("참가자를 기다리는 중...\nQR 코드를 보여주세요.", textAlign: TextAlign.center, style: TextStyle(color: Colors.grey))), ), ], ), ), Padding( padding: const EdgeInsets.only(bottom: 10.0), child: _buildReadyButton(), ), ], ); } Widget _buildUserTile(UserInfo user, {required bool isMe}) { return Card( elevation: user.isReady ? 4 : 1, color: user.isReady ? Colors.green[50] : Colors.white, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), side: user.isReady ? const BorderSide(color: Colors.green, width: 2) : BorderSide.none, ), margin: const EdgeInsets.symmetric(vertical: 6), child: ListTile( contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), leading: AvatarWidget(user: user, size: 50), title: Text( user.nickname + (isMe ? " (나)" : ""), style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16), ), subtitle: Text(isMe ? "준비 버튼을 눌러주세요" : (user.isReady ? "준비 완료!" : "준비 중..."), style: TextStyle(color: Colors.grey[600], fontSize: 12)), trailing: user.isReady ? const Icon(Icons.check_circle, color: Colors.green, size: 32) : const Icon(Icons.hourglass_empty, color: Colors.grey, size: 32), ), ); } Widget _buildReadyButton() { bool isReady = _net.me.isReady; bool canReady = _net.role == NetworkRole.host ? _net.guestList.isNotEmpty : true; return Container( width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), child: ElevatedButton( onPressed: canReady ? () => _net.toggleReady() : () => ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("친구를 초대해야 시작할 수 있습니다!"))), style: ElevatedButton.styleFrom( backgroundColor: !canReady ? Colors.grey[300] : (isReady ? Colors.redAccent : Colors.blueAccent), padding: const EdgeInsets.symmetric(vertical: 18), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), elevation: canReady ? 5 : 0, ), child: Text( isReady ? "준비 취소 (WAIT)" : "준비 완료 (READY)", style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: !canReady ? Colors.grey : Colors.white), ), ), ); } Widget _buildDebugConsole() { return Column( children: [ Container( width: double.infinity, padding: const EdgeInsets.all(8.0), color: Colors.black87, child: const Text("DEBUG LOGS", style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold)), ), SizedBox( height: 150, child: Container( color: Colors.black, child: ListView.builder( controller: _scrollController, itemCount: _logs.length, itemBuilder: (context, index) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 2.0), child: Text( _logs[index], style: const TextStyle(color: Colors.greenAccent, fontSize: 12, fontFamily: 'Courier'), ), ); }, ), ), ), ], ); } void _showHostQRDialog() { if (_net.hostIp == null || _net.hostPort == null) return; final qrData = jsonEncode({'ip': _net.hostIp, 'port': _net.hostPort}); showDialog( context: context, builder: (context) => Dialog( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), child: Padding( padding: const EdgeInsets.all(24.0), child: Column( mainAxisSize: MainAxisSize.min, children: [ const Text("초대 QR 코드", style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold)), const SizedBox(height: 20), Container( padding: const EdgeInsets.all(10), decoration: BoxDecoration(border: Border.all(color: Colors.black12), borderRadius: BorderRadius.circular(10)), child: QrImageView(data: qrData, version: QrVersions.auto, size: 220.0), ), const SizedBox(height: 20), SelectableText("IP: ${_net.hostIp}\nPort: ${_net.hostPort}", textAlign: TextAlign.center, style: const TextStyle(color: Colors.grey)), const SizedBox(height: 20), ElevatedButton(onPressed: () => Navigator.pop(context), child: const Text("닫기")) ], ), ), ), ); } void _openQRScanner() { bool isScanCompleted = false; Navigator.of(context).push(MaterialPageRoute( builder: (context) => Scaffold( appBar: AppBar(title: const Text("QR 스캔")), body: MobileScanner( onDetect: (capture) { if (isScanCompleted) return; for (final barcode in capture.barcodes) { if (barcode.rawValue != null) { try { final data = jsonDecode(barcode.rawValue!); if (data['ip'] != null && data['port'] != null) { isScanCompleted = true; Navigator.pop(context); ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("접속 중..."))); _net.joinRoom(data['ip'], data['port']); return; } } catch (e) {} } } }, ), ), )); } void _showRoomListDialog() { showDialog( context: context, builder: (context) => AlertDialog( title: const Text("방 찾는 중..."), content: SizedBox( width: double.maxFinite, height: 300, child: StreamBuilder>( stream: _net.discoverRooms(), builder: (context, snapshot) { if (!snapshot.hasData || snapshot.data!.isEmpty) { return const Center(child: Text("검색 중...\n같은 와이파이인지 확인하세요.")); } return ListView.builder( itemCount: snapshot.data!.length, itemBuilder: (context, index) { final service = snapshot.data![index]; final ip = service.attributes?['ip'] ?? '알 수 없음'; return ListTile( leading: const Icon(Icons.meeting_room), title: Text(service.name.split('#').first), subtitle: Text(ip), onTap: () { Navigator.pop(context); if (service.attributes != null && service.attributes!['ip'] != null) { _net.joinRoom(service.attributes!['ip']!, service.port); } }, ); }, ); }, ), ), actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text("닫기"))], ), ); } void _showManualJoinDialog() { final ipCtrl = TextEditingController(text: "192.168."); final portCtrl = TextEditingController(); showDialog( context: context, builder: (context) => AlertDialog( title: const Text("직접 입력"), content: Column( mainAxisSize: MainAxisSize.min, children: [ TextField(controller: ipCtrl, decoration: const InputDecoration(labelText: "IP Address")), TextField(controller: portCtrl, decoration: const InputDecoration(labelText: "Port")), ], ), actions: [ TextButton(onPressed: () => Navigator.pop(context), child: const Text("취소")), ElevatedButton( onPressed: () { final ip = ipCtrl.text.trim(); final port = int.tryParse(portCtrl.text.trim()); if (ip.isNotEmpty && port != null) { Navigator.pop(context); _net.joinRoom(ip, port); } }, child: const Text("접속"), ), ], ), ); } } class _BigButton extends StatelessWidget { final String title; final Color color; final IconData icon; final VoidCallback onTap; const _BigButton({required this.title, required this.color, required this.icon, required this.onTap}); @override Widget build(BuildContext context) { return GestureDetector( onTap: onTap, child: Container( width: 130, height: 130, decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(20), boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 10, offset: const Offset(0, 5))]), child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [Icon(icon, size: 40, color: Colors.black54), const SizedBox(height: 10), Text(title, textAlign: TextAlign.center, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold))]), ), ); } }