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
+299 -126
View File
@@ -1,10 +1,14 @@
import 'dart:convert';
import 'dart:io';
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';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:wifi_iot/wifi_iot.dart';
import 'package:network_info_plus/network_info_plus.dart';
import 'package:permission_handler/permission_handler.dart';
class LobbyScreen extends StatefulWidget {
const LobbyScreen({super.key});
@@ -28,7 +32,6 @@ class _LobbyScreenState extends State<LobbyScreen> {
_logs.add(log);
if (_logs.length > 100) _logs.removeAt(0);
});
if (SettingsNotifier().isShowDebugLog) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
@@ -45,40 +48,89 @@ class _LobbyScreenState extends State<LobbyScreen> {
_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());
}
_routeToGame(gameId);
}
});
}
Future<int?> _showSpiderDifficultyDialog() {
return showDialog<int>(
context: context,
barrierDismissible: false,
builder: (context) => AlertDialog(
title: const Text("스파이더 난이도"),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
title: const Text("초급 (1가지 무늬)"),
subtitle: const Text("스페이드만 사용"),
leading: const Icon(Icons.looks_one, color: Colors.green),
onTap: () => Navigator.pop(context, 1),
),
ListTile(
title: const Text("중급 (2가지 무늬)"),
subtitle: const Text("스페이드 + 하트"),
leading: const Icon(Icons.looks_two, color: Colors.orange),
onTap: () => Navigator.pop(context, 2),
),
ListTile(
title: const Text("고급 (4가지 무늬)"),
subtitle: const Text("모든 무늬 사용"),
leading: const Icon(Icons.looks_4, color: Colors.red),
onTap: () => Navigator.pop(context, 4),
),
],
),
actions: [TextButton(onPressed: () => Navigator.pop(context, null), child: const Text("취소"))],
),
);
}
void _routeToGame(String 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());
} else if (gameId == 'world_tour') {
_startGameAndNavigate(WorldTourGame());
} else if (gameId == 'othello') {
_startGameAndNavigate(OthelloGame());
} else if (gameId == 'arkanoid') {
_startGameAndNavigate(ArkanoidGame());
}else if (gameId == 'math_run') {
_startGameAndNavigate(MathRunGame());
}
else if (gameId == 'jump_battle') {
_startGameAndNavigate(JumpGame());
}
// [추가] 아이엠그라운드 연결
else if (gameId == 'iam_ground') {
_startGameAndNavigate(IAmGroundGame());
}
else if (gameId == 'survivor') {
_startGameAndNavigate(SurvivorGame());
}
else if (gameId == 'sequence_memory') {
_startGameAndNavigate(SequenceMemoryGame());
}
}
Future<void> _startGameAndNavigate(BaseGame game) async {
if (!mounted) return;
game.onStart();
@@ -96,7 +148,6 @@ class _LobbyScreenState extends State<LobbyScreen> {
children: [
gameView,
const SafeArea(
// 배너 광고 높이만큼 띄워서 채팅창 표시
child: GameChatOverlay(bottomOffset: 60.0),
),
],
@@ -104,8 +155,6 @@ class _LobbyScreenState extends State<LobbyScreen> {
}),
);
// [수정] 게임 종료 후 복귀 시 로직
// 솔로 모드였다면 네트워크를 종료하고 초기 화면으로 돌아감
if (_net.hostIp == "Solo Mode") {
_net.stopNetwork();
}
@@ -117,16 +166,21 @@ class _LobbyScreenState extends State<LobbyScreen> {
MaterialPageRoute(
builder: (context) => GameSelectionScreen(
onGameSelected: (gameId) async {
// 스도쿠 선택 시 난이도 팝업
Map<String, dynamic> config = {};
if (gameId == 'sudoku_battle') {
final difficulty = await _showDifficultyDialog();
if (difficulty == null) return; // 취소함
if (difficulty == null) return;
config['difficulty'] = difficulty;
}
// [추가] 스파이더 난이도
else if (gameId == 'spider_battle') {
final suits = await _showSpiderDifficultyDialog();
if (suits == null) return;
config['difficulty'] = suits; // numSuits (1, 2, 4)
}
if (!mounted) return;
Navigator.pop(context); // 선택 화면 닫기
Navigator.pop(context);
if (isSolo) {
_net.startSoloMode(gameId, config: config);
@@ -144,7 +198,6 @@ class _LobbyScreenState extends State<LobbyScreen> {
);
}
// [추가] 난이도 선택 다이얼로그
Future<int?> _showDifficultyDialog() {
return showDialog<int>(
context: context,
@@ -154,39 +207,44 @@ class _LobbyScreenState extends State<LobbyScreen> {
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 레벨이 어려움
),
ListTile(title: const Text("쉬움 (4x4)"), onTap: () => Navigator.pop(context, 1)),
ListTile(title: const Text("보통 (9x9)"), onTap: () => Navigator.pop(context, 4)),
ListTile(title: const Text("어려움 (9x9)"), onTap: () => Navigator.pop(context, 7)),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, null),
child: const Text("취소"),
)
],
actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text("취소"))],
),
);
}
void _openGameSelector() {
if (_net.role != NetworkRole.host) return;
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => GameSelectionScreen(
onGameSelected: (gameId) async {
Map<String, dynamic> config = {};
if (gameId == 'sudoku_battle') {
final difficulty = await _showDifficultyDialog();
if (difficulty == null) return;
config['difficulty'] = difficulty;
}
else if (gameId == 'spider_battle') {
final suits = await _showSpiderDifficultyDialog();
if (suits == null) return;
config['difficulty'] = suits;
}
if (!mounted) return;
Navigator.pop(context);
if (_net.role == NetworkRole.host) {
_net.selectGame(gameId, config: config);
}
},
),
),
);
}
@@ -228,9 +286,7 @@ class _LobbyScreenState extends State<LobbyScreen> {
: _buildLobbyView()
),
const Divider(thickness: 1, height: 1),
if (SettingsNotifier().isShowDebugLog)
_buildDebugConsole(),
if (SettingsNotifier().isShowDebugLog) _buildDebugConsole(),
],
),
);
@@ -297,23 +353,33 @@ class _LobbyScreenState extends State<LobbyScreen> {
Widget _buildLobbyView() {
final currentGame = AppGames.getById(_net.selectedGameId);
final bool isHost = _net.role == NetworkRole.host;
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),
),
],
GestureDetector(
onTap: isHost ? _openGameSelector : null,
child: 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: 24, color: Colors.indigo),
const SizedBox(width: 10),
Column(
children: [
Text(
"현재 게임: ${currentGame.name}",
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.indigo),
),
if (isHost)
const Text("(눌러서 변경)", style: TextStyle(fontSize: 12, color: Colors.grey)),
],
),
],
),
),
),
@@ -326,15 +392,15 @@ class _LobbyScreenState extends State<LobbyScreen> {
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(_net.role == NetworkRole.host ? Icons.wifi_tethering : Icons.wifi, color: Colors.blue),
Icon(isHost ? Icons.wifi_tethering : Icons.wifi, color: Colors.blue),
const SizedBox(width: 10),
Text(
_net.role == NetworkRole.host ? "👑 방장 (나)" : "참가자 (나)",
isHost ? "👑 방장 (나)" : "참가자 (나)",
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
],
),
if (_net.role == NetworkRole.host) ...[
if (isHost) ...[
const SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.center,
@@ -350,8 +416,11 @@ class _LobbyScreenState extends State<LobbyScreen> {
)
],
),
const SizedBox(height: 5),
const Text("QR 코드를 눌러 친구를 초대하세요!", style: TextStyle(fontSize: 12, color: Colors.grey)),
TextButton.icon(
icon: const Icon(Icons.wifi_password),
label: const Text("핫스팟(야외용) QR 만들기"),
onPressed: () => _showHotspotCreateDialog(),
),
] else ...[
const SizedBox(height: 10),
Text("방장 IP: ${_net.hostIp ?? '...'}", style: const TextStyle(color: Colors.grey)),
@@ -369,7 +438,7 @@ class _LobbyScreenState extends State<LobbyScreen> {
_buildUserTile(_net.me, isMe: true),
..._net.guestList.map((guest) => _buildUserTile(guest, isMe: false)),
if (_net.guestList.isEmpty && _net.role == NetworkRole.host)
if (_net.guestList.isEmpty && isHost)
const Padding(
padding: EdgeInsets.all(40.0),
child: Center(child: Text("참가자를 기다리는 중...\nQR 코드를 보여주세요.", textAlign: TextAlign.center, style: TextStyle(color: Colors.grey))),
@@ -412,6 +481,7 @@ class _LobbyScreenState extends State<LobbyScreen> {
Widget _buildReadyButton() {
bool isReady = _net.me.isReady;
bool canReady = _net.role == NetworkRole.host ? _net.guestList.isNotEmpty : true;
if (_net.hostIp == "Solo Mode") canReady = true;
return Container(
width: double.infinity,
@@ -466,10 +536,52 @@ class _LobbyScreenState extends State<LobbyScreen> {
);
}
void _showHotspotCreateDialog() {
final ssidCtrl = TextEditingController();
final pwCtrl = TextEditingController();
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text("핫스팟 QR 만들기"),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text("스마트폰 설정에서 핫스팟을 켜고,\n그 정보를 입력해주세요.", style: TextStyle(fontSize: 12, color: Colors.grey)),
const SizedBox(height: 10),
TextField(controller: ssidCtrl, decoration: const InputDecoration(labelText: "핫스팟 이름 (SSID)")),
TextField(controller: pwCtrl, decoration: const InputDecoration(labelText: "비밀번호")),
],
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text("취소")),
ElevatedButton(
onPressed: () {
if (ssidCtrl.text.isEmpty) return;
Navigator.pop(ctx);
final Map<String, dynamic> qrData = {
'type': 'hotspot_invite',
'ssid': ssidCtrl.text,
'pwd': pwCtrl.text,
'port': _net.hostPort ?? 0,
};
_showGeneratedQR(jsonEncode(qrData), "핫스팟 + 게임 접속 QR");
},
child: const Text("생성"),
)
],
),
);
}
void _showHostQRDialog() {
if (_net.hostIp == null || _net.hostPort == null) return;
final qrData = jsonEncode({'ip': _net.hostIp, 'port': _net.hostPort});
_showGeneratedQR(qrData, "초대 QR 코드 (같은 와이파이)");
}
void _showGeneratedQR(String data, String title) {
showDialog(
context: context,
builder: (context) => Dialog(
@@ -479,16 +591,14 @@ class _LobbyScreenState extends State<LobbyScreen> {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text("초대 QR 코드", style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold)),
Text(title, style: const TextStyle(fontSize: 20, 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),
child: QrImageView(data: data, 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("닫기"))
],
),
@@ -497,34 +607,6 @@ class _LobbyScreenState extends State<LobbyScreen> {
);
}
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,
@@ -565,6 +647,97 @@ class _LobbyScreenState extends State<LobbyScreen> {
);
}
void _openQRScanner() {
bool isScanCompleted = false;
Navigator.of(context).push(MaterialPageRoute(
builder: (context) => Scaffold(
appBar: AppBar(title: const Text("QR 스캔")),
body: MobileScanner(
onDetect: (capture) async {
if (isScanCompleted) return;
final List<Barcode> barcodes = capture.barcodes;
for (final barcode in barcodes) {
final String? rawValue = barcode.rawValue;
if (rawValue == null) continue;
try {
final data = jsonDecode(rawValue);
// 핫스팟 자동 접속
if (data['type'] == 'hotspot_invite') {
isScanCompleted = true;
await _connectToHotspotAndJoin(
data['ssid'],
data['pwd'],
data['port']
);
return;
}
// 일반 게임 접속
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) {
// JSON 아님
}
}
},
),
),
));
}
Future<void> _connectToHotspotAndJoin(String ssid, String pwd, int port) async {
if (Platform.isAndroid) {
var status = await Permission.location.request();
if (!status.isGranted) return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("핫스팟 '$ssid' 연결 시도 중...")),
);
try {
bool connected = await WiFiForIoTPlugin.connect(
ssid,
password: pwd,
security: NetworkSecurity.WPA,
joinOnce: true,
withInternet: false,
);
if (connected) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("와이파이 연결 성공! 방장을 찾는 중...")),
);
final info = NetworkInfo();
String? gatewayIp = await info.getWifiGatewayIP();
if (gatewayIp != null) {
await Future.delayed(const Duration(seconds: 1));
if (!mounted) return;
Navigator.pop(context);
_net.joinRoom(gatewayIp, port);
} else {
throw Exception("방장 IP를 찾을 수 없습니다.");
}
} else {
throw Exception("와이파이 연결 실패.");
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("오류: $e")),
);
}
}
void _showManualJoinDialog() {
final ipCtrl = TextEditingController(text: "192.168.");
final portCtrl = TextEditingController();