From bf40c42c2c3a8bf4141e4abfe516791a6d5c24d8 Mon Sep 17 00:00:00 2001 From: lunaticbum Date: Wed, 26 Nov 2025 18:10:10 +0900 Subject: [PATCH] ... --- apps/app/ios/Podfile.lock | 28 + apps/app/lib/lobby_screen.dart | 403 ++++++++----- apps/app/lib/main.dart | 4 +- apps/app/lib/screens/settings_screen.dart | 82 ++- .../flutter/generated_plugin_registrant.cc | 4 + .../app/linux/flutter/generated_plugins.cmake | 1 + .../Flutter/GeneratedPluginRegistrant.swift | 4 + apps/app/pubspec.lock | 106 +++- apps/app/pubspec.yaml | 3 +- .../flutter/generated_plugin_registrant.cc | 3 + .../windows/flutter/generated_plugins.cmake | 1 + packages/core/lib/game/balance_game.dart | 230 ++++++++ packages/core/lib/game/janggi_game.dart | 356 +++++++++++ packages/core/lib/game/memory_game.dart | 333 +++++++++++ packages/core/lib/game/omok_game.dart | 249 ++++++++ .../quiz/lib => core/lib/game}/quiz_game.dart | 554 ++++++++++-------- packages/core/lib/game/spider_multi_game.dart | 372 ++++++++++++ packages/core/lib/game/sudoku_multi_game.dart | 372 ++++++++++++ packages/core/lib/game/tap_battle_game.dart | 217 +++++++ packages/core/lib/game/yutnori_game.dart | 474 +++++++++++++++ .../core/lib/manager/settings_manager.dart | 37 +- packages/core/lib/model/game_info.dart | 93 +++ packages/core/lib/model/quiz_model.dart | 101 ++++ packages/core/lib/model/spider_model.dart | 37 ++ packages/core/lib/model/sudoku_game_dto.dart | 33 ++ .../core/lib/network/network_manager.dart | 138 ++--- packages/core/lib/playwith_core.dart | 15 +- .../lib/screens/game_selection_screen.dart | 76 +++ .../core/lib/widgets/ad_banner_widget.dart | 66 +++ .../core/lib/widgets/game_chat_overlay.dart | 550 +++++++++-------- packages/core/lib/widgets/spider_widgets.dart | 71 +++ packages/core/lib/widgets/sudoku_widgets.dart | 181 ++++++ packages/core/pubspec.yaml | 3 + packages/games/quiz/.gitignore | 31 - packages/games/quiz/.metadata | 10 - packages/games/quiz/CHANGELOG.md | 3 - packages/games/quiz/LICENSE | 1 - packages/games/quiz/README.md | 39 -- packages/games/quiz/analysis_options.yaml | 4 - packages/games/quiz/lib/model/quiz_model.dart | 108 ---- .../games/quiz/lib/playwith_game_quiz.dart | 5 - packages/games/quiz/pubspec.yaml | 15 - .../quiz/test/playwith_game_quiz_test.dart | 12 - 43 files changed, 4454 insertions(+), 971 deletions(-) create mode 100644 packages/core/lib/game/balance_game.dart create mode 100644 packages/core/lib/game/janggi_game.dart create mode 100644 packages/core/lib/game/memory_game.dart create mode 100644 packages/core/lib/game/omok_game.dart rename packages/{games/quiz/lib => core/lib/game}/quiz_game.dart (54%) create mode 100644 packages/core/lib/game/spider_multi_game.dart create mode 100644 packages/core/lib/game/sudoku_multi_game.dart create mode 100644 packages/core/lib/game/tap_battle_game.dart create mode 100644 packages/core/lib/game/yutnori_game.dart create mode 100644 packages/core/lib/model/game_info.dart create mode 100644 packages/core/lib/model/quiz_model.dart create mode 100644 packages/core/lib/model/spider_model.dart create mode 100644 packages/core/lib/model/sudoku_game_dto.dart create mode 100644 packages/core/lib/screens/game_selection_screen.dart create mode 100644 packages/core/lib/widgets/ad_banner_widget.dart create mode 100644 packages/core/lib/widgets/spider_widgets.dart create mode 100644 packages/core/lib/widgets/sudoku_widgets.dart delete mode 100644 packages/games/quiz/.gitignore delete mode 100644 packages/games/quiz/.metadata delete mode 100644 packages/games/quiz/CHANGELOG.md delete mode 100644 packages/games/quiz/LICENSE delete mode 100644 packages/games/quiz/README.md delete mode 100644 packages/games/quiz/analysis_options.yaml delete mode 100644 packages/games/quiz/lib/model/quiz_model.dart delete mode 100644 packages/games/quiz/lib/playwith_game_quiz.dart delete mode 100644 packages/games/quiz/pubspec.yaml delete mode 100644 packages/games/quiz/test/playwith_game_quiz_test.dart diff --git a/apps/app/ios/Podfile.lock b/apps/app/ios/Podfile.lock index 9b2eb7c..6f157f1 100644 --- a/apps/app/ios/Podfile.lock +++ b/apps/app/ios/Podfile.lock @@ -48,6 +48,12 @@ PODS: - gal (1.0.0): - Flutter - FlutterMacOS + - Google-Mobile-Ads-SDK (11.13.0): + - GoogleUserMessagingPlatform (>= 1.1) + - google_mobile_ads (5.3.1): + - Flutter + - Google-Mobile-Ads-SDK (~> 11.13.0) + - webview_flutter_wkwebview - GoogleDataTransport (9.4.1): - GoogleUtilities/Environment (~> 7.7) - nanopb (< 2.30911.0, >= 2.30908.0) @@ -62,6 +68,7 @@ PODS: - GoogleToolboxForMac/Defines (= 4.2.1) - "GoogleToolboxForMac/NSData+zlib (4.2.1)": - GoogleToolboxForMac/Defines (= 4.2.1) + - GoogleUserMessagingPlatform (3.1.0) - GoogleUtilities/Environment (7.13.3): - GoogleUtilities/Privacy - PromisesObjC (< 3.0, >= 1.2) @@ -143,6 +150,11 @@ PODS: - sqlite3/rtree - sqlite3/session - SwiftyGif (5.4.5) + - url_launcher_ios (0.0.1): + - Flutter + - webview_flutter_wkwebview (0.0.1): + - Flutter + - FlutterMacOS DEPENDENCIES: - audioplayers_darwin (from `.symlinks/plugins/audioplayers_darwin/darwin`) @@ -151,6 +163,7 @@ DEPENDENCIES: - Flutter (from `Flutter`) - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) - gal (from `.symlinks/plugins/gal/darwin`) + - google_mobile_ads (from `.symlinks/plugins/google_mobile_ads/ios`) - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) - mobile_scanner (from `.symlinks/plugins/mobile_scanner/ios`) - objective_c (from `.symlinks/plugins/objective_c/ios`) @@ -158,6 +171,8 @@ DEPENDENCIES: - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - speech_to_text (from `.symlinks/plugins/speech_to_text/darwin`) - sqlite3_flutter_libs (from `.symlinks/plugins/sqlite3_flutter_libs/darwin`) + - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) + - webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`) SPEC REPOS: trunk: @@ -165,9 +180,11 @@ SPEC REPOS: - CwlCatchExceptionSupport - DKImagePickerController - DKPhotoGallery + - Google-Mobile-Ads-SDK - GoogleDataTransport - GoogleMLKit - GoogleToolboxForMac + - GoogleUserMessagingPlatform - GoogleUtilities - GoogleUtilitiesComponents - GTMSessionFetcher @@ -194,6 +211,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/flutter_local_notifications/ios" gal: :path: ".symlinks/plugins/gal/darwin" + google_mobile_ads: + :path: ".symlinks/plugins/google_mobile_ads/ios" image_picker_ios: :path: ".symlinks/plugins/image_picker_ios/ios" mobile_scanner: @@ -208,6 +227,10 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/speech_to_text/darwin" sqlite3_flutter_libs: :path: ".symlinks/plugins/sqlite3_flutter_libs/darwin" + url_launcher_ios: + :path: ".symlinks/plugins/url_launcher_ios/ios" + webview_flutter_wkwebview: + :path: ".symlinks/plugins/webview_flutter_wkwebview/darwin" SPEC CHECKSUMS: audioplayers_darwin: 4027b33a8f471d996c13f71cb77f0b1583b5d923 @@ -220,9 +243,12 @@ SPEC CHECKSUMS: Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 flutter_local_notifications: 4cde75091f6327eb8517fa068a0a5950212d2086 gal: 6a522c75909f1244732d4596d11d6a2f86ff37a5 + Google-Mobile-Ads-SDK: 14f57f2dc33532a24db288897e26494640810407 + google_mobile_ads: fe0e2c1764ad95323dd0e3081d0bb2d58411f957 GoogleDataTransport: 6c09b596d841063d76d4288cc2d2f42cc36e1e2a GoogleMLKit: 97ac7af399057e99182ee8edfa8249e3226a4065 GoogleToolboxForMac: d1a2cbf009c453f4d6ded37c105e2f67a32206d8 + GoogleUserMessagingPlatform: befe603da6501006420c206222acd449bba45a9c GoogleUtilities: ea963c370a38a8069cc5f7ba4ca849a60b6d7d15 GoogleUtilitiesComponents: 679b2c881db3b615a2777504623df6122dd20afe GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6 @@ -242,6 +268,8 @@ SPEC CHECKSUMS: sqlite3: 73513155ec6979715d3904ef53a8d68892d4032b sqlite3_flutter_libs: 86f82662868ee26ff3451f73cac9c5fc2a1f57fa SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 + url_launcher_ios: bb13df5870e8c4234ca12609d04010a21be43dfa + webview_flutter_wkwebview: 29eb20d43355b48fe7d07113835b9128f84e3af4 PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e diff --git a/apps/app/lib/lobby_screen.dart b/apps/app/lib/lobby_screen.dart index 09bdce7..6758ab9 100644 --- a/apps/app/lib/lobby_screen.dart +++ b/apps/app/lib/lobby_screen.dart @@ -2,10 +2,10 @@ 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'; // Core (AvatarWidget, NetworkManager 등) -import 'package:playwith_game_quiz/quiz_game.dart'; +import 'package:playwith_core/playwith_core.dart'; import 'package:qr_flutter/qr_flutter.dart'; + class LobbyScreen extends StatefulWidget { const LobbyScreen({super.key}); @@ -22,40 +22,68 @@ class _LobbyScreenState extends State { void initState() { super.initState(); - // 로그 리스너 _net.logStream.listen((log) { if (!mounted) return; setState(() { _logs.add(log); if (_logs.length > 100) _logs.removeAt(0); }); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (_scrollController.hasClients) { - _scrollController.animateTo( - _scrollController.position.maxScrollExtent, - duration: const Duration(milliseconds: 200), - curve: Curves.easeOut, - ); - } - }); + + 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') { + + 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()); } } }); } - void _startGameAndNavigate(BaseGame game) { + Future _startGameAndNavigate(BaseGame game) async { if (!mounted) return; game.onStart(); - Navigator.push( + await Navigator.push( context, MaterialPageRoute(builder: (context) { Widget gameView; @@ -64,16 +92,103 @@ class _LobbyScreenState extends State { } else { gameView = game.buildGuestView(context); } - - // 게임 위 채팅 오버레이 return Stack( children: [ gameView, - const SafeArea(child: GameChatOverlay()), + 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 @@ -81,50 +196,50 @@ class _LobbyScreenState extends State { return ListenableBuilder( listenable: _net, builder: (context, child) { - return Scaffold( - appBar: AppBar( - title: const Text('대기실'), - centerTitle: true, - actions: [ - // 연결된 상태라면 나가기 버튼 표시 - if (_net.role != NetworkRole.none) - IconButton( - icon: const Icon(Icons.exit_to_app, color: Colors.red), - tooltip: "나가기", - onPressed: () => _net.stopNetwork(), - ) - ], - ), - body: Column( - children: [ - // 메인 바디 (연결 상태에 따라 분기) - Expanded(flex: 3, child: _buildMainBody()), - - const Divider(thickness: 1, height: 1), - - // 하단 디버그 로그 (개발용) - _buildDebugConsole(), - ], - ), + 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(), + ], + ), + ); + }, ); }, ); } - // [Main Body] 상태에 따라 초기화면 vs 대기실 화면 분기 - Widget _buildMainBody() { - // 1. 아직 연결 안 됨 (초기 화면) - if (_net.role == NetworkRole.none) { - return _buildInitView(); - } - - // 2. 연결됨 (대기실 - 방장/참가자 통합 UI) - return _buildLobbyView(); - } - - // ------------------------------------------------------------------------ - // 1. 초기 화면 (방 만들기 / 찾기) - // ------------------------------------------------------------------------ Widget _buildInitView() { return Center( child: SingleChildScrollView( @@ -134,6 +249,15 @@ class _LobbyScreenState extends State { 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: [ @@ -141,13 +265,7 @@ class _LobbyScreenState extends State { title: "방 만들기\n(Host)", color: Colors.blue[100]!, icon: Icons.add_home_work, - onTap: () { - _net.startHosting("${_net.me.nickname}의 방"); - // 방장은 방 만들자마자 QR 팝업 - Future.delayed(const Duration(milliseconds: 500), () { - if (mounted && _net.role == NetworkRole.host) _showHostQRDialog(); - }); - }, + onTap: () => _navigateToGameSelection(isSolo: false), ), _BigButton( title: "방 찾기\n(Guest)", @@ -157,6 +275,7 @@ class _LobbyScreenState extends State { ), ], ), + const SizedBox(height: 30), ElevatedButton.icon( @@ -176,20 +295,32 @@ class _LobbyScreenState extends State { ); } - // ------------------------------------------------------------------------ - // 2. 대기실 화면 (통합 UI) - // ------------------------------------------------------------------------ Widget _buildLobbyView() { + final currentGame = AppGames.getById(_net.selectedGameId); + return Column( children: [ - // A. 상단 정보 카드 + 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), - decoration: BoxDecoration( - color: Colors.grey[100], - border: const Border(bottom: BorderSide(color: Colors.black12)), - ), + color: Colors.grey[100], child: Column( children: [ Row( @@ -203,70 +334,53 @@ class _LobbyScreenState extends State { ), ], ), - - // 방장일 경우 접속 정보 표시 if (_net.role == NetworkRole.host) ...[ - const SizedBox(height: 15), - Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(20), border: Border.all(color: Colors.blue.shade100)), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text("IP: ${_net.hostIp} : ${_net.hostPort}", style: const TextStyle(fontWeight: FontWeight.bold)), - const SizedBox(width: 10), - InkWell( - onTap: () => _showHostQRDialog(), - child: const Icon(Icons.qr_code, color: Colors.black87), - ) - ], - ), + 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)), ] ], ), ), - - // B. 참가자 리스트 (나 + 게스트) + const Divider(height: 1), Expanded( child: ListView( padding: const EdgeInsets.all(16), children: [ - const Text("대기 중인 참가자", style: TextStyle(color: Colors.grey, fontWeight: FontWeight.bold)), + const Text("참가자 목록", style: TextStyle(color: Colors.grey, fontWeight: FontWeight.bold)), const SizedBox(height: 10), - - // 1. 나 (항상 맨 위) _buildUserTile(_net.me, isMe: true), - - // 2. 다른 참가자들 ..._net.guestList.map((guest) => _buildUserTile(guest, isMe: false)), - // 대기 문구 if (_net.guestList.isEmpty && _net.role == NetworkRole.host) - Padding( - padding: const EdgeInsets.only(top: 40.0), - child: Center( - child: Column( - children: const [ - CircularProgressIndicator(), - SizedBox(height: 20), - Text("친구를 기다리는 중...", style: TextStyle(color: Colors.grey)), - ], - ), - ), + const Padding( + padding: EdgeInsets.all(40.0), + child: Center(child: Text("참가자를 기다리는 중...\nQR 코드를 보여주세요.", textAlign: TextAlign.center, style: TextStyle(color: Colors.grey))), ), ], ), ), - - // C. 하단 레디 버튼 - _buildReadyButton(), + Padding( + padding: const EdgeInsets.only(bottom: 10.0), + child: _buildReadyButton(), + ), ], ); } @@ -282,7 +396,7 @@ class _LobbyScreenState extends State { margin: const EdgeInsets.symmetric(vertical: 6), child: ListTile( contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - leading: AvatarWidget(user: user, size: 50), // Core의 AvatarWidget 사용 + leading: AvatarWidget(user: user, size: 50), title: Text( user.nickname + (isMe ? " (나)" : ""), style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16), @@ -297,66 +411,40 @@ class _LobbyScreenState extends State { Widget _buildReadyButton() { bool isReady = _net.me.isReady; - // 조건: 방장이라도 게스트가 없으면 레디 불가 (혼자 게임 불가) - // 게스트는 들어오자마자 레디 가능 - bool canReady = _net.role == NetworkRole.host ? _net.guestList.isNotEmpty : true; + bool canReady = _net.role == NetworkRole.host ? _net.guestList.isNotEmpty : true; return Container( width: double.infinity, - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: Colors.white, - boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 10, offset: const Offset(0, -5))], - ), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), child: ElevatedButton( onPressed: canReady ? () => _net.toggleReady() - : () { - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("친구를 초대해야 시작할 수 있습니다!"))); - }, + : () => ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("친구를 초대해야 시작할 수 있습니다!"))), style: ElevatedButton.styleFrom( - backgroundColor: !canReady - ? Colors.grey[300] - : (isReady ? Colors.redAccent : Colors.blueAccent), + backgroundColor: !canReady ? Colors.grey[300] : (isReady ? Colors.redAccent : Colors.blueAccent), padding: const EdgeInsets.symmetric(vertical: 18), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)), + 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 - ), + style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: !canReady ? Colors.grey : Colors.white), ), ), ); } - // ------------------------------------------------------------------------ - // [Components] 디버그 콘솔 - // ------------------------------------------------------------------------ Widget _buildDebugConsole() { return Column( children: [ - GestureDetector( - onTap: () => _scrollController.jumpTo(_scrollController.position.maxScrollExtent), - child: Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(vertical: 5, horizontal: 10), - color: Colors.black87, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: const [ - Text("DEBUG LOGS", style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 10)), - Icon(Icons.keyboard_arrow_down, color: Colors.white, size: 14) - ], - ), - ), + 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: 100, + height: 150, child: Container( color: Colors.black, child: ListView.builder( @@ -364,10 +452,10 @@ class _LobbyScreenState extends State { itemCount: _logs.length, itemBuilder: (context, index) { return Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 1.0), + padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 2.0), child: Text( _logs[index], - style: const TextStyle(color: Colors.greenAccent, fontSize: 10, fontFamily: 'Courier'), + style: const TextStyle(color: Colors.greenAccent, fontSize: 12, fontFamily: 'Courier'), ), ); }, @@ -378,9 +466,6 @@ class _LobbyScreenState extends State { ); } - // ------------------------------------------------------------------------ - // [Dialogs] QR, 검색, 수동입력 - // ------------------------------------------------------------------------ void _showHostQRDialog() { if (_net.hostIp == null || _net.hostPort == null) return; final qrData = jsonEncode({'ip': _net.hostIp, 'port': _net.hostPort}); @@ -394,7 +479,7 @@ class _LobbyScreenState extends State { child: Column( mainAxisSize: MainAxisSize.min, children: [ - const Text("친구 초대", style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold)), + const Text("초대 QR 코드", style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold)), const SizedBox(height: 20), Container( padding: const EdgeInsets.all(10), @@ -444,7 +529,7 @@ class _LobbyScreenState extends State { showDialog( context: context, builder: (context) => AlertDialog( - title: const Text("방 찾기"), + title: const Text("방 찾는 중..."), content: SizedBox( width: double.maxFinite, height: 300, diff --git a/apps/app/lib/main.dart b/apps/app/lib/main.dart index b267918..82c7b6d 100644 --- a/apps/app/lib/main.dart +++ b/apps/app/lib/main.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; import 'package:playwith_core/playwith_core.dart'; -import 'package:playwith_game_quiz/quiz_game.dart'; import 'login_screen.dart'; // [수정] 인트로 스크린 import (경로가 다르면 수정 필요) import 'intro/intro_screen.dart'; // 만약 intro 폴더에 넣으셨다면 이 경로 사용 import 'lobby_screen.dart'; +import 'package:google_mobile_ads/google_mobile_ads.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -15,7 +15,7 @@ Future main() async { SoundKey.win: 'audio/win.mp3', SoundKey.click: 'audio/correct.mp3', }); - + await MobileAds.instance.initialize(); // [추가] await NotificationManager().initialize(); runApp(const PlayWithApp()); diff --git a/apps/app/lib/screens/settings_screen.dart b/apps/app/lib/screens/settings_screen.dart index 2bf6657..57d48e2 100644 --- a/apps/app/lib/screens/settings_screen.dart +++ b/apps/app/lib/screens/settings_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:playwith_core/playwith_core.dart'; // AvatarWidget 포함됨 +import 'package:url_launcher/url_launcher.dart'; // [추가] 링크 이동용 class SettingsScreen extends StatefulWidget { const SettingsScreen({super.key}); @@ -24,6 +25,24 @@ class _SettingsScreenState extends State { super.dispose(); } + // [추가] 홈페이지 열기 함수 + Future _launchHomepage() async { + // 이동할 홈페이지 주소를 입력하세요 + final Uri url = Uri.parse('https://lunaticbum.kr"'); + + try { + if (!await launchUrl(url, mode: LaunchMode.externalApplication)) { + throw Exception('Could not launch $url'); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("페이지를 열 수 없습니다.")), + ); + } + } + } + @override Widget build(BuildContext context) { return Scaffold( @@ -41,7 +60,6 @@ class _SettingsScreenState extends State { padding: const EdgeInsets.all(16.0), child: Column( children: [ - // 아바타 변경 영역 GestureDetector( onTap: () => _settings.pickProfileImage(), child: Stack( @@ -70,7 +88,6 @@ class _SettingsScreenState extends State { const SizedBox(height: 20), - // 닉네임 입력 TextField( controller: _nickController, decoration: const InputDecoration( @@ -83,7 +100,6 @@ class _SettingsScreenState extends State { const SizedBox(height: 10), - // 기본 아바타 색상 선택 (이미지 없을 때 사용) const Align(alignment: Alignment.centerLeft, child: Text("기본 배경색")), const SizedBox(height: 5), SingleChildScrollView( @@ -171,6 +187,66 @@ class _SettingsScreenState extends State { ), ), ), + + const SizedBox(height: 20), + + // 3. 개발자 옵션 (디버그 로그) + _buildSectionTitle("개발자 옵션"), + Card( + child: SwitchListTile( + title: const Text("디버그 로그 표시"), + subtitle: const Text("로비 화면 하단에 네트워크 로그를 표시합니다."), + value: _settings.isShowDebugLog, + onChanged: (val) => _settings.toggleDebugLog(val), + ), + ), + + const SizedBox(height: 20), + + // [추가] 4. 정보 섹션 (라이선스) + _buildSectionTitle("정보"), + Card( + child: ListTile( + leading: const Icon(Icons.description_outlined), + title: const Text("오픈소스 라이선스"), + subtitle: const Text("앱에 사용된 라이브러리 정보"), + trailing: const Icon(Icons.arrow_forward_ios, size: 16, color: Colors.grey), + onTap: () { + // 플러터 내장 라이선스 페이지 호출 + showLicensePage( + context: context, + applicationName: "PlayWith", + applicationVersion: "1.0.0", + // applicationIcon: Image.asset('assets/icon.png', width: 50), // 아이콘이 있다면 주석 해제 + ); + }, + ), + ), + + const SizedBox(height: 40), + + // [추가] 하단 카피라이트 & 링크 + GestureDetector( + onTap: _launchHomepage, + child: Column( + children: const [ + Text( + "© 2025 lunaticbum. All rights reserved.", + style: TextStyle(color: Colors.grey, fontSize: 12), + ), + SizedBox(height: 4), + Text( + "https://lunaticbum.kr", // 보여줄 텍스트 + style: TextStyle( + color: Colors.blueAccent, + fontSize: 12, + decoration: TextDecoration.underline + ), + ), + ], + ), + ), + const SizedBox(height: 30), ], ); }, diff --git a/apps/app/linux/flutter/generated_plugin_registrant.cc b/apps/app/linux/flutter/generated_plugin_registrant.cc index b0cb8c8..d0dd3a2 100644 --- a/apps/app/linux/flutter/generated_plugin_registrant.cc +++ b/apps/app/linux/flutter/generated_plugin_registrant.cc @@ -9,6 +9,7 @@ #include #include #include +#include void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) audioplayers_linux_registrar = @@ -20,4 +21,7 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) sqlite3_flutter_libs_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "Sqlite3FlutterLibsPlugin"); sqlite3_flutter_libs_plugin_register_with_registrar(sqlite3_flutter_libs_registrar); + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); } diff --git a/apps/app/linux/flutter/generated_plugins.cmake b/apps/app/linux/flutter/generated_plugins.cmake index af61658..386d850 100644 --- a/apps/app/linux/flutter/generated_plugins.cmake +++ b/apps/app/linux/flutter/generated_plugins.cmake @@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST audioplayers_linux file_selector_linux sqlite3_flutter_libs + url_launcher_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/apps/app/macos/Flutter/GeneratedPluginRegistrant.swift b/apps/app/macos/Flutter/GeneratedPluginRegistrant.swift index 038ec7c..6af84d1 100644 --- a/apps/app/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/apps/app/macos/Flutter/GeneratedPluginRegistrant.swift @@ -15,6 +15,8 @@ import mobile_scanner import shared_preferences_foundation import speech_to_text import sqlite3_flutter_libs +import url_launcher_macos +import webview_flutter_wkwebview func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin")) @@ -27,4 +29,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SpeechToTextPlugin.register(with: registry.registrar(forPlugin: "SpeechToTextPlugin")) Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) + WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin")) } diff --git a/apps/app/pubspec.lock b/apps/app/pubspec.lock index 730ead3..2304590 100644 --- a/apps/app/pubspec.lock +++ b/apps/app/pubspec.lock @@ -18,7 +18,7 @@ packages: source: hosted version: "2.13.0" audioplayers: - dependency: "direct main" + dependency: transitive description: name: audioplayers sha256: "5441fa0ceb8807a5ad701199806510e56afde2b4913d9d17c2f19f2902cf0ae4" @@ -336,6 +336,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" + google_mobile_ads: + dependency: transitive + description: + name: google_mobile_ads + sha256: "0d4a3744b5e8ed1b8be6a1b452d309f811688855a497c6113fc4400f922db603" + url: "https://pub.dev" + source: hosted + version: "5.3.1" http: dependency: transitive description: @@ -851,6 +859,70 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611" + url: "https://pub.dev" + source: hosted + version: "6.3.28" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: cfde38aa257dae62ffe79c87fab20165dfdf6988c1d31b58ebf59b9106062aad + url: "https://pub.dev" + source: hosted + version: "6.3.6" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" uuid: dependency: transitive description: @@ -883,6 +955,38 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + webview_flutter: + dependency: transitive + description: + name: webview_flutter + sha256: c3e4fe614b1c814950ad07186007eff2f2e5dd2935eba7b9a9a1af8e5885f1ba + url: "https://pub.dev" + source: hosted + version: "4.13.0" + webview_flutter_android: + dependency: transitive + description: + name: webview_flutter_android + sha256: "3fcca88ee2ae568807ebd42deed235bb8dd8e62b3e4d5caff67daa6bce062cca" + url: "https://pub.dev" + source: hosted + version: "4.10.9" + webview_flutter_platform_interface: + dependency: transitive + description: + name: webview_flutter_platform_interface + sha256: "63d26ee3aca7256a83ccb576a50272edd7cfc80573a4305caa98985feb493ee0" + url: "https://pub.dev" + source: hosted + version: "2.14.0" + webview_flutter_wkwebview: + dependency: transitive + description: + name: webview_flutter_wkwebview + sha256: a57b76a081bed3bf3a71a486bdf83642b00f1a7342043d50367cea68f338b1af + url: "https://pub.dev" + source: hosted + version: "3.23.4" win32: dependency: transitive description: diff --git a/apps/app/pubspec.yaml b/apps/app/pubspec.yaml index ca3020e..628c228 100644 --- a/apps/app/pubspec.yaml +++ b/apps/app/pubspec.yaml @@ -38,8 +38,7 @@ dependencies: permission_handler: ^11.0.0 qr_flutter: ^4.1.0 mobile_scanner: ^5.1.0 - audioplayers: ^6.0.0 - + url_launcher: ^6.2.0 # [추가] 웹 브라우저 열기용 dev_dependencies: flutter_test: diff --git a/apps/app/windows/flutter/generated_plugin_registrant.cc b/apps/app/windows/flutter/generated_plugin_registrant.cc index d285588..850e9b5 100644 --- a/apps/app/windows/flutter/generated_plugin_registrant.cc +++ b/apps/app/windows/flutter/generated_plugin_registrant.cc @@ -13,6 +13,7 @@ #include #include #include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { AudioplayersWindowsPluginRegisterWithRegistrar( @@ -29,4 +30,6 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("SpeechToTextWindows")); Sqlite3FlutterLibsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("Sqlite3FlutterLibsPlugin")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/apps/app/windows/flutter/generated_plugins.cmake b/apps/app/windows/flutter/generated_plugins.cmake index fd566e5..5fd5d2e 100644 --- a/apps/app/windows/flutter/generated_plugins.cmake +++ b/apps/app/windows/flutter/generated_plugins.cmake @@ -10,6 +10,7 @@ list(APPEND FLUTTER_PLUGIN_LIST permission_handler_windows speech_to_text_windows sqlite3_flutter_libs + url_launcher_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/packages/core/lib/game/balance_game.dart b/packages/core/lib/game/balance_game.dart new file mode 100644 index 0000000..f7db714 --- /dev/null +++ b/packages/core/lib/game/balance_game.dart @@ -0,0 +1,230 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:playwith_core/playwith_core.dart'; + +class BalanceGame extends BaseGame { + @override + String get id => "balance_game"; + @override + String get name => "밸런스 게임"; + @override + String get description => "마음이 통하는지 확인해보세요!"; + + @override + void onStart() { + super.onStart(); + // Host가 첫 문제를 설정해서 전송 + if (NetworkManager().role == NetworkRole.host) { + Future.delayed(const Duration(milliseconds: 500), () { + final payload = {'type': 'NEXT_QUESTION', 'index': 0}; + onMessageReceived(NetworkManager().me.id, payload); + NetworkManager().sendMessage(payload); + }); + } + } + + @override + void onMessageReceived(String senderId, Map payload) { + // UI에서 처리 + } + + @override + Widget buildHostView(BuildContext context) => BalanceGameScreen(gameInstance: this); + @override + Widget buildGuestView(BuildContext context) => BalanceGameScreen(gameInstance: this); +} + +class BalanceGameScreen extends StatefulWidget { + final BalanceGame gameInstance; + const BalanceGameScreen({super.key, required this.gameInstance}); + + @override + State createState() => _BalanceGameScreenState(); +} + +class _BalanceGameScreenState extends State { + // 문제 데이터 (가벼운 커플용 질문) + final List> questions = [ + {'A': '평생 라면만 먹기', 'B': '평생 탄산만 마시기'}, + {'A': '다시 태어나면\n원빈 얼굴', 'B': '다시 태어나면\n삼성 이재용 재력'}, + {'A': '1년 동안\n스킨십 금지', 'B': '1년 동안\n스마트폰 금지'}, + {'A': '애인이\n바람피우기', 'B': '애인이\n전재산 날리기'}, + {'A': '여름에\n에어컨 없이 살기', 'B': '겨울에\n보일러 없이 살기'}, + {'A': '매일 사랑해 듣기', 'B': '매일 10만원 받기'}, + {'A': '과거로 가기', 'B': '미래로 가기'}, + {'A': '평생 고기 못 먹기', 'B': '평생 밀가루 못 먹기'}, + ]; + + int currentIndex = -1; + String? myChoice; // 'A' or 'B' + String? opponentChoice; + bool isResultShown = false; + + @override + void initState() { + super.initState(); + NetworkManager().messageStream.listen(_handleMessage); + } + + void _handleMessage(Map payload) { + if (!mounted) return; + + if (payload['type'] == 'NEXT_QUESTION') { + setState(() { + currentIndex = payload['index']; + myChoice = null; + opponentChoice = null; + isResultShown = false; + }); + } else if (payload['type'] == 'SELECT') { + if (payload['senderId'] != NetworkManager().me.id) { + setState(() { + opponentChoice = payload['choice']; + _checkResult(); + }); + } + } + } + + void _onSelect(String choice) { + if (myChoice != null) return; // 이미 선택함 + + setState(() { + myChoice = choice; + }); + + NetworkManager().sendMessage({ + 'type': 'SELECT', + 'choice': choice, + 'senderId': NetworkManager().me.id + }); + + _checkResult(); + } + + void _checkResult() { + if (myChoice != null && opponentChoice != null) { + setState(() { + isResultShown = true; + }); + + // 3초 후 다음 문제 (Host만 전송) + if (NetworkManager().role == NetworkRole.host) { + Future.delayed(const Duration(seconds: 3), () { + if (!mounted) return; + if (currentIndex < questions.length - 1) { + final payload = {'type': 'NEXT_QUESTION', 'index': currentIndex + 1}; + NetworkManager().sendMessage(payload); + // 나 자신에게도 처리 (핸들러 호출 없이 직접 상태 변경해도 되지만 통일성을 위해) + // 여기선 직접 호출 대신 메시지 수신 로직이 처리하도록 둠 + // (NetworkManager가 host일 때 loopback 안 하므로 직접 호출 필요) + // 하지만 NetworkManager 수정본에서는 host도 onMessageReceived 호출하므로 패스 + // 만약 lobby_screen 등에서 분기처리된 경우 broadcastState 같은게 필요. + // 간단히: + NetworkManager().sendMessage(payload); + // Host 자신은 리스너가 안돌수 있으므로 직접 처리 + _handleMessage(payload); + } else { + // 게임 끝 + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("모든 질문이 끝났습니다!"))); + } + }); + } + } + } + + @override + Widget build(BuildContext context) { + if (currentIndex == -1) return const Scaffold(body: Center(child: CircularProgressIndicator())); + + final q = questions[currentIndex]; + final bool isMatched = (myChoice == opponentChoice); + + return Scaffold( + appBar: AppBar(title: Text("밸런스 게임 ${currentIndex + 1}/${questions.length}")), + body: Column( + children: [ + Expanded( + child: Row( + children: [ + // 선택지 A + Expanded( + child: _buildOptionButton('A', q['A']!, Colors.redAccent), + ), + // 선택지 B + Expanded( + child: _buildOptionButton('B', q['B']!, Colors.blueAccent), + ), + ], + ), + ), + if (isResultShown) + Container( + height: 100, + width: double.infinity, + color: isMatched ? Colors.pinkAccent : Colors.grey, + alignment: Alignment.center, + child: Text( + isMatched ? "찌찌뽕! ❤ (통했군요!)" : "동상이몽... 💔 (다르네요)", + style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Colors.white), + ), + ) + else + Container( + height: 100, + alignment: Alignment.center, + child: Text( + myChoice == null ? "선택해주세요!" : (opponentChoice == null ? "상대방 기다리는 중..." : ""), + style: const TextStyle(fontSize: 18, color: Colors.grey), + ), + ), + ], + ), + ); + } + + Widget _buildOptionButton(String key, String text, Color color) { + bool isSelected = myChoice == key; + bool showOpponentSelection = isResultShown && opponentChoice == key; + + return GestureDetector( + onTap: () => _onSelect(key), + child: Container( + margin: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: isSelected ? color : color.withOpacity(0.1), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: isSelected ? color : Colors.transparent, + width: 4 + ), + boxShadow: isSelected ? [BoxShadow(color: color.withOpacity(0.4), blurRadius: 10)] : [], + ), + child: Stack( + children: [ + Center( + child: Text( + text, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: isSelected ? Colors.white : Colors.black87 + ), + ), + ), + if (showOpponentSelection) + Positioned( + top: 10, right: 10, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration(color: Colors.black87, borderRadius: BorderRadius.circular(8)), + child: const Text("상대방 PICK", style: TextStyle(color: Colors.white, fontSize: 12)), + ), + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/packages/core/lib/game/janggi_game.dart b/packages/core/lib/game/janggi_game.dart new file mode 100644 index 0000000..2b114b1 --- /dev/null +++ b/packages/core/lib/game/janggi_game.dart @@ -0,0 +1,356 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:playwith_core/playwith_core.dart'; + +class JanggiGame extends BaseGame { + @override + String get id => "janggi"; + @override + String get name => "장기"; + @override + String get description => "초한지의 결전! 장군!"; + + @override + void onStart() { + super.onStart(); + // 게임 시작 시 초기화 로직이 필요하면 여기에 추가 + } + + // [수정] 필수 메서드 구현 추가 + @override + void onMessageReceived(String senderId, Map payload) { + // BaseGame은 패킷 수신 시 UI(JanggiScreen)에 전달하는 역할이 주가 되므로 + // 여기서는 특별한 로직 없이 두거나, 필요시 전역 상태를 업데이트합니다. + // 실제 게임 로직은 JanggiScreen의 StreamBuilder나 리스너에서 처리됩니다. + } + + @override + Widget buildHostView(BuildContext context) => JanggiScreen(isHan: true, gameInstance: this); + @override + Widget buildGuestView(BuildContext context) => JanggiScreen(isHan: false, gameInstance: this); +} + +// 기물 타입 +enum PieceType { king, guard, horse, elephant, chariot, cannon, soldier } +// 진영 (한: Red, 초: Green/Blue) +enum Team { han, cho } + +class Piece { + final PieceType type; + final Team team; + Piece(this.type, this.team); + + String get label { + if (team == Team.han) { + switch (type) { + case PieceType.king: return '漢'; // 궁(장) + case PieceType.guard: return '士'; // 사 + case PieceType.horse: return '馬'; // 마 + case PieceType.elephant: return '象'; // 상 + case PieceType.chariot: return '車'; // 차 + case PieceType.cannon: return '包'; // 포 + case PieceType.soldier: return '兵'; // 병 + } + } else { + switch (type) { + case PieceType.king: return '楚'; // 궁(장) + case PieceType.guard: return '士'; + case PieceType.horse: return '馬'; + case PieceType.elephant: return '象'; + case PieceType.chariot: return '車'; + case PieceType.cannon: return '包'; + case PieceType.soldier: return '卒'; // 졸 + } + } + } +} + +class JanggiScreen extends StatefulWidget { + final bool isHan; // 방장이 한(Red), 게스트가 초(Green) + final JanggiGame gameInstance; + + const JanggiScreen({super.key, required this.isHan, required this.gameInstance}); + + @override + State createState() => _JanggiScreenState(); +} + +class _JanggiScreenState extends State { + // 10행 9열 + final List> board = List.generate(10, (_) => List.filled(9, null)); + Team currentTurn = Team.han; // 한나라 선 + + // 선택된 기물 좌표 + int? selectedX; + int? selectedY; + List validMoves = []; + + @override + void initState() { + super.initState(); + _initBoard(); + NetworkManager().messageStream.listen(_handleMessage); + } + + void _initBoard() { + // 초기 배치 (상마상마 타입 기준) + _placeRow(0, Team.cho, [PieceType.chariot, PieceType.elephant, PieceType.horse, PieceType.guard, null, PieceType.guard, PieceType.elephant, PieceType.horse, PieceType.chariot]); + board[1][4] = Piece(PieceType.king, Team.cho); + board[2][1] = Piece(PieceType.cannon, Team.cho); board[2][7] = Piece(PieceType.cannon, Team.cho); + _placeRow(3, Team.cho, [PieceType.soldier, null, PieceType.soldier, null, PieceType.soldier, null, PieceType.soldier, null, PieceType.soldier]); + + _placeRow(9, Team.han, [PieceType.chariot, PieceType.elephant, PieceType.horse, PieceType.guard, null, PieceType.guard, PieceType.elephant, PieceType.horse, PieceType.chariot]); + board[8][4] = Piece(PieceType.king, Team.han); + board[7][1] = Piece(PieceType.cannon, Team.han); board[7][7] = Piece(PieceType.cannon, Team.han); + _placeRow(6, Team.han, [PieceType.soldier, null, PieceType.soldier, null, PieceType.soldier, null, PieceType.soldier, null, PieceType.soldier]); + } + + void _placeRow(int row, Team team, List types) { + for (int i = 0; i < 9; i++) { + if (types[i] != null) board[row][i] = Piece(types[i]!, team); + } + } + + void _handleMessage(Map payload) { + if (!mounted) return; + if (payload['type'] == 'MOVE') { + _executeMove(payload['fx'], payload['fy'], payload['tx'], payload['ty']); + } else if (payload['type'] == 'GAME_OVER') { + _showEndDialog(payload['winner']); + } + } + + void _onTapCell(int x, int y) { + // 내 턴 확인 + if (currentTurn != (widget.isHan ? Team.han : Team.cho)) return; + + // 1. 기물 선택 + if (board[y][x]?.team == (widget.isHan ? Team.han : Team.cho)) { + setState(() { + selectedX = x; + selectedY = y; + // 이동 가능 경로 계산 + validMoves = _calculateValidMoves(x, y, board[y][x]!); + }); + } + // 2. 이동 + else if (selectedX != null) { + // 유효한 이동인지 확인 + bool isValid = validMoves.any((p) => p.x == x && p.y == y); + if (isValid) { + _executeMove(selectedX!, selectedY!, x, y); + + NetworkManager().sendMessage({ + 'type': 'MOVE', + 'fx': selectedX, 'fy': selectedY, + 'tx': x, 'ty': y + }); + } else { + // 선택 해제 + setState(() { selectedX = null; validMoves = []; }); + } + } + } + + void _executeMove(int fx, int fy, int tx, int ty) { + setState(() { + Piece? target = board[ty][tx]; + board[ty][tx] = board[fy][fx]; + board[fy][fx] = null; + + selectedX = null; + validMoves = []; + currentTurn = (currentTurn == Team.han) ? Team.cho : Team.han; + + if (target?.type == PieceType.king) { + _showEndDialog(currentTurn == Team.han ? "Cho" : "Han"); + } + }); + SoundManager().playSfx(SoundKey.click); + } + + List _calculateValidMoves(int x, int y, Piece p) { + List moves = []; + + void addIfValid(int nx, int ny) { + if (nx < 0 || nx >= 9 || ny < 0 || ny >= 10) return; + if (board[ny][nx]?.team == p.team) return; // 같은 편 불가 + moves.add(Point(nx, ny)); + } + + // 차(車): 직선 쭉 + if (p.type == PieceType.chariot) { + _addLinearMoves(x, y, moves); + } + // 졸/병: 앞, 옆 + else if (p.type == PieceType.soldier) { + int dy = (p.team == Team.cho) ? 1 : -1; // 초는 아래로, 한은 위로 + addIfValid(x, y + dy); + addIfValid(x - 1, y); + addIfValid(x + 1, y); + } + // 마(馬): 날일자 (멱 체크 필요) + else if (p.type == PieceType.horse) { + // [수정] Dart 문법에 맞게 List 사용 + final List listX = [1, 2, 2, 1, -1, -2, -2, -1]; + final List listY = [-2, -1, 1, 2, 2, 1, -1, -2]; + + for(int i=0; i<8; i++) { + // 멱 체크 (가는 길 중간) + int mx = x + (listX[i] ~/ 2); // 대략적 중간점 + int my = y + (listY[i] ~/ 2); + if (mx >=0 && mx <9 && my >=0 && my <10 && board[my][mx] == null) { + addIfValid(x + listX[i], y + listY[i]); + } + } + } + // 궁/사: 궁성 내에서만 + else if (p.type == PieceType.king || p.type == PieceType.guard) { + for (int dy = -1; dy <= 1; dy++) { + for (int dx = -1; dx <= 1; dx++) { + if (dx == 0 && dy == 0) continue; + int nx = x + dx; int ny = y + dy; + // 궁성 범위 체크 + bool inPalace = (nx >= 3 && nx <= 5) && + ((p.team == Team.cho) ? (ny >= 0 && ny <= 2) : (ny >= 7 && ny <= 9)); + if (inPalace) addIfValid(nx, ny); + } + } + } + // 상(象), 포(包) 등은 복잡하여 생략 (필요시 추가 구현) + + return moves; + } + + void _addLinearMoves(int x, int y, List moves) { + // [수정] Dart 문법에 맞게 List 사용 + final List dx = [1, -1, 0, 0]; + final List dy = [0, 0, 1, -1]; + + for(int i=0; i<4; i++) { + for(int k=1; k<10; k++) { + int nx = x + dx[i]*k; + int ny = y + dy[i]*k; + if (nx < 0 || nx >= 9 || ny < 0 || ny >= 10) break; + if (board[ny][nx] != null) { + if (board[ny][nx]!.team != board[y][x]!.team) moves.add(Point(nx, ny)); + break; // 막힘 + } + moves.add(Point(nx, ny)); + } + } + } + + void _showEndDialog(String msg) { + showDialog(context: context, builder: (_) => AlertDialog(title: const Text("게임 종료"), content: Text(msg))); + } + + @override + Widget build(BuildContext context) { + // 내가 초나라(Green)라면 보드를 뒤집어서 보여줌 + final bool flipBoard = !widget.isHan; + + return Scaffold( + appBar: AppBar( + title: Text("장기 - ${widget.isHan ? '한(漢, Red)' : '초(楚, Green)'}"), + backgroundColor: widget.isHan ? Colors.red[100] : Colors.green[100], + ), + backgroundColor: const Color(0xFFE6B45C), + body: LayoutBuilder( + builder: (context, constraints) { + double cellW = constraints.maxWidth / 9; + double cellH = cellW; + + return Stack( + children: [ + // 격자 + CustomPaint(size: Size(constraints.maxWidth, cellH * 10), painter: JanggiGridPainter()), + + // 기물 + ...List.generate(90, (index) { + int x = index % 9; + int y = index ~/ 9; + + // 화면 표시 좌표 (뒤집기 고려) + int displayX = flipBoard ? (8 - x) : x; + int displayY = flipBoard ? (9 - y) : y; + + Piece? p = board[y][x]; + bool isSelected = (x == selectedX && y == selectedY); + bool isValid = validMoves.any((pt) => pt.x == x && pt.y == y); + + return Positioned( + left: displayX * cellW, + top: displayY * cellH, + width: cellW, + height: cellH, + child: GestureDetector( + onTap: () => _onTapCell(x, y), + child: Container( + decoration: BoxDecoration( + color: isSelected ? Colors.blue.withOpacity(0.3) : (isValid ? Colors.green.withOpacity(0.3) : null), + border: isSelected ? Border.all(color: Colors.blue, width: 2) : null, + ), + child: p == null + ? (isValid ? const Icon(Icons.circle, size: 10, color: Colors.green) : null) + : _buildPieceWidget(p, cellW), + ), + ), + ); + }), + ], + ); + }, + ), + ); + } + + Widget _buildPieceWidget(Piece p, double size) { + return Container( + margin: const EdgeInsets.all(2), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.orange[100], + border: Border.all(color: p.team == Team.han ? Colors.red : Colors.green[800]!, width: 2), + boxShadow: const [BoxShadow(blurRadius: 2, offset: Offset(1,1))] + ), + child: Center( + child: Text( + p.label, + style: TextStyle( + fontSize: size * (p.type == PieceType.king ? 0.5 : 0.4), + fontWeight: FontWeight.bold, + color: p.team == Team.han ? Colors.red : Colors.green[800], + ), + ), + ), + ); + } +} + +class Point { final int x, y; Point(this.x, this.y); } + +class JanggiGridPainter extends CustomPainter { + @override + void paint(Canvas canvas, Size size) { + final paint = Paint()..color = Colors.black..strokeWidth = 1; + double cw = size.width / 9; + double ch = cw; + + // 선 그리기 (가운데 중심) + for(int i=0; i<10; i++) { + canvas.drawLine(Offset(cw/2, ch/2 + i*ch), Offset(size.width - cw/2, ch/2 + i*ch), paint); + } + for(int i=0; i<9; i++) { + canvas.drawLine(Offset(cw/2 + i*cw, ch/2), Offset(cw/2 + i*cw, size.height - ch/2 + (ch-cw)*0), paint); + } + // 궁성 대각선 + canvas.drawLine(Offset(cw/2 + 3*cw, ch/2), Offset(cw/2 + 5*cw, ch/2 + 2*ch), paint); + canvas.drawLine(Offset(cw/2 + 5*cw, ch/2), Offset(cw/2 + 3*cw, ch/2 + 2*ch), paint); + + canvas.drawLine(Offset(cw/2 + 3*cw, ch/2 + 7*ch), Offset(cw/2 + 5*cw, ch/2 + 9*ch), paint); + canvas.drawLine(Offset(cw/2 + 5*cw, ch/2 + 7*ch), Offset(cw/2 + 3*cw, ch/2 + 9*ch), paint); + } + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} \ No newline at end of file diff --git a/packages/core/lib/game/memory_game.dart b/packages/core/lib/game/memory_game.dart new file mode 100644 index 0000000..3f8edc1 --- /dev/null +++ b/packages/core/lib/game/memory_game.dart @@ -0,0 +1,333 @@ +import 'dart:async'; +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:playwith_core/playwith_core.dart'; + +class MemoryGame extends BaseGame { + @override + String get id => "memory_battle"; + @override + String get name => "그림 찾기"; + @override + String get description => "기억력의 한판 승부!"; + + // 0: Red(Host), 1: Blue(Guest) + int? _myTeam; + + @override + void onStart() { + super.onStart(); + _myTeam = NetworkManager().role == NetworkRole.host ? 0 : 1; + + // Host가 카드 섞어서 전송 + if (NetworkManager().role == NetworkRole.host) { + final int seed = Random().nextInt(1000000); + final payload = {'type': 'GAME_INIT', 'seed': seed}; + + // 약간의 딜레이 후 전송 (접속 안정화) + Future.delayed(const Duration(milliseconds: 500), () { + onMessageReceived(NetworkManager().me.id, payload); + NetworkManager().sendMessage(payload); + }); + } + } + + @override + void onMessageReceived(String senderId, Map payload) { + // BaseGame 핸들러는 비워둠 (UI에서 Stream으로 처리) + } + + @override + Widget buildHostView(BuildContext context) => MemoryGameScreen(myTeam: 0, gameInstance: this); + @override + Widget buildGuestView(BuildContext context) => MemoryGameScreen(myTeam: 1, gameInstance: this); +} + +class MemoryGameScreen extends StatefulWidget { + final int myTeam; + final MemoryGame gameInstance; + + const MemoryGameScreen({super.key, required this.myTeam, required this.gameInstance}); + + @override + State createState() => _MemoryGameScreenState(); +} + +class _MemoryGameScreenState extends State { + // 6 x 5 = 30장 (15쌍) + static const int rows = 6; + static const int cols = 5; + + // 아이콘 목록 (15개) + final List icons = [ + Icons.ac_unit, Icons.access_alarm, Icons.accessibility, Icons.account_balance, Icons.adb, + Icons.add_shopping_cart, Icons.airplanemode_active, Icons.anchor, Icons.android, Icons.apartment, + Icons.apple, Icons.attach_money, Icons.audiotrack, Icons.auto_awesome, Icons.bakery_dining, + ]; + + List cards = []; // 카드 ID (0~14) + List isRevealed = []; // 현재 뒤집혀 있는지 + List isMatched = []; // 짝을 맞춰서 사라졌는지 + + int currentTurn = 0; // 0: Red, 1: Blue + List score = [0, 0]; // [Red점수, Blue점수] + + List selectedIndices = []; // 현재 선택한 카드 인덱스 (최대 2개) + bool isProcessing = false; // 애니메이션 중 터치 방지 + + @override + void initState() { + super.initState(); + // 초기 상태 (로딩 중) + cards = List.filled(rows * cols, -1); + isRevealed = List.filled(rows * cols, false); + isMatched = List.filled(rows * cols, false); + + NetworkManager().messageStream.listen(_handleMessage); + } + + void _handleMessage(Map payload) { + if (!mounted) return; + + if (payload['type'] == 'GAME_INIT') { + _initGame(payload['seed']); + } + else if (payload['type'] == 'FLIP') { + final int index = payload['index']; + _flipCard(index); + } + else if (payload['type'] == 'RESULT') { + final bool match = payload['match']; + final int idx1 = payload['idx1']; + final int idx2 = payload['idx2']; + final int scorer = payload['scorer']; + + _handleResult(match, idx1, idx2, scorer); + } + else if (payload['type'] == 'GAME_OVER') { + _showGameOverDialog(payload['winnerTeam']); + } + } + + void _initGame(int seed) { + final random = Random(seed); + List deck = []; + for (int i = 0; i < 15; i++) { + deck.add(i); + deck.add(i); // 2장씩 + } + deck.shuffle(random); + + setState(() { + cards = deck; + isRevealed = List.filled(rows * cols, false); + isMatched = List.filled(rows * cols, false); + currentTurn = 0; + score = [0, 0]; + selectedIndices.clear(); + isProcessing = false; + }); + } + + void _onCardTap(int index) { + if (cards[0] == -1) return; // 로딩 전 + if (currentTurn != widget.myTeam) return; // 내 턴 아님 + if (isProcessing) return; // 처리 중 + if (isMatched[index] || isRevealed[index]) return; // 이미 맞췄거나 뒤집힌 카드 + + // 카드 뒤집기 전송 + NetworkManager().sendMessage({'type': 'FLIP', 'index': index}); + + // 내 화면 즉시 반영 (반응성 향상) + _flipCard(index); + } + + void _flipCard(int index) { + setState(() { + isRevealed[index] = true; + selectedIndices.add(index); + }); + SoundManager().playSfx(SoundKey.click); + + // 2장을 뒤집었을 때 (Host가 판정) + if (selectedIndices.length == 2) { + // Host만 판정 로직 수행 + if (NetworkManager().role == NetworkRole.host) { + final int idx1 = selectedIndices[0]; + final int idx2 = selectedIndices[1]; + final bool isMatch = cards[idx1] == cards[idx2]; + + // 1초 딜레이 후 결과 전송 (보여줄 시간 확보) + Future.delayed(const Duration(milliseconds: 800), () { + final resultPayload = { + 'type': 'RESULT', + 'match': isMatch, + 'idx1': idx1, + 'idx2': idx2, + 'scorer': currentTurn // 현재 턴인 사람이 점수 획득 시도 + }; + NetworkManager().sendMessage(resultPayload); + _handleMessage(resultPayload); // 나 자신도 처리 + }); + } + } + } + + void _handleResult(bool match, int idx1, int idx2, int scorer) { + setState(() { + selectedIndices.clear(); + + if (match) { + // 매치 성공 + isMatched[idx1] = true; + isMatched[idx2] = true; + score[scorer]++; + SoundManager().playSfx(SoundKey.correct); + + // 맞춘 사람은 턴 유지 (한 번 더!) + // 턴 변경 없음 + + // 게임 종료 체크 + if (score[0] + score[1] == 15) { + int winner = score[0] > score[1] ? 0 : (score[0] < score[1] ? 1 : -1); // -1은 무승부 + Future.delayed(const Duration(milliseconds: 500), () { + NetworkManager().sendMessage({'type': 'GAME_OVER', 'winnerTeam': winner}); + _showGameOverDialog(winner); + }); + } + } else { + // 매치 실패 -> 다시 뒤집기 + isRevealed[idx1] = false; + isRevealed[idx2] = false; + + // 턴 넘기기 + currentTurn = 1 - scorer; + } + }); + } + + void _showGameOverDialog(int winnerTeam) { + String msg; + if (winnerTeam == -1) msg = "무승부입니다!"; + else if (winnerTeam == widget.myTeam) msg = "승리했습니다! 🎉"; + else msg = "패배했습니다... 😭"; + + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => AlertDialog( + title: const Text("게임 종료"), + content: Text(msg), + actions: [ + TextButton( + onPressed: () { + Navigator.pop(context); + Navigator.pop(context); + }, + child: const Text("나가기"), + ) + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + if (cards.isEmpty || cards[0] == -1) { + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } + + final bool myTurn = currentTurn == widget.myTeam; + final Color teamColor = widget.myTeam == 0 ? Colors.redAccent : Colors.blueAccent; + + return Scaffold( + appBar: AppBar( + title: Text(myTurn ? "나의 턴!" : "상대방 턴..."), + backgroundColor: myTurn ? teamColor : Colors.grey, + elevation: 0, + ), + body: Column( + children: [ + // 점수판 + Container( + padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 30), + color: Colors.grey[200], + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _buildScoreBox("나", score[widget.myTeam], teamColor, myTurn), + const Text("VS", style: TextStyle(fontWeight: FontWeight.bold, color: Colors.grey)), + _buildScoreBox("상대", score[1 - widget.myTeam], Colors.grey, !myTurn), + ], + ), + ), + + const SizedBox(height: 10), + + // 카드 그리드 + Expanded( + child: Padding( + padding: const EdgeInsets.all(10.0), + child: GridView.builder( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: cols, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + childAspectRatio: 0.8, + ), + itemCount: rows * cols, + itemBuilder: (context, index) { + return _buildCard(index); + }, + ), + ), + ), + ], + ), + ); + } + + Widget _buildScoreBox(String label, int score, Color color, bool isActive) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + decoration: BoxDecoration( + color: isActive ? color : Colors.white, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: color, width: 2), + boxShadow: isActive ? [BoxShadow(color: color.withOpacity(0.4), blurRadius: 8)] : [], + ), + child: Column( + children: [ + Text(label, style: TextStyle(color: isActive ? Colors.white : color, fontWeight: FontWeight.bold)), + Text("$score", style: TextStyle(color: isActive ? Colors.white : color, fontSize: 24, fontWeight: FontWeight.bold)), + ], + ), + ); + } + + Widget _buildCard(int index) { + final bool revealed = isRevealed[index] || isMatched[index]; + final bool matched = isMatched[index]; + + return GestureDetector( + onTap: () => _onCardTap(index), + child: AnimatedContainer( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + decoration: BoxDecoration( + color: matched + ? Colors.transparent // 맞춘 카드는 투명하게 + : (revealed ? Colors.white : Colors.indigoAccent), + borderRadius: BorderRadius.circular(8), + border: matched ? null : Border.all(color: Colors.indigo, width: 1), + boxShadow: (!matched && !revealed) ? [const BoxShadow(color: Colors.black26, offset: Offset(2,2), blurRadius: 2)] : [], + ), + child: matched + ? const SizedBox() + : (revealed + ? Icon(icons[cards[index]], size: 32, color: Colors.indigo) + : const Icon(Icons.question_mark, color: Colors.white24)), + ), + ); + } +} \ No newline at end of file diff --git a/packages/core/lib/game/omok_game.dart b/packages/core/lib/game/omok_game.dart new file mode 100644 index 0000000..e4c18a5 --- /dev/null +++ b/packages/core/lib/game/omok_game.dart @@ -0,0 +1,249 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:playwith_core/playwith_core.dart'; + +class OmokGame extends BaseGame { + @override + String get id => "omok"; + @override + String get name => "오목"; + @override + String get description => "오목 한 판 승부!"; + + // 1: 흑(Host), 2: 백(Guest) + int? _myStone; + + @override + void onStart() { + super.onStart(); + // 방장이 흑(1), 게스트가 백(2) + _myStone = NetworkManager().role == NetworkRole.host ? 1 : 2; + } + + @override + void onMessageReceived(String senderId, Map payload) { + // BaseGame은 상태 관리를 자식 위젯(OmokScreen)에게 위임하므로 + // 여기서는 패킷을 전달하기만 하면 됩니다. (StreamBuilder가 처리) + } + + @override + Widget buildHostView(BuildContext context) => OmokScreen(myStone: 1, gameInstance: this); + + @override + Widget buildGuestView(BuildContext context) => OmokScreen(myStone: 2, gameInstance: this); +} + +class OmokScreen extends StatefulWidget { + final int myStone; // 1: 흑, 2: 백 + final OmokGame gameInstance; + + const OmokScreen({super.key, required this.myStone, required this.gameInstance}); + + @override + State createState() => _OmokScreenState(); +} + +class _OmokScreenState extends State { + // 0: 빈칸, 1: 흑, 2: 백 + final List> board = List.generate(15, (_) => List.filled(15, 0)); + int currentTurn = 1; // 흑 먼저 + bool isGameOver = false; + + @override + void initState() { + super.initState(); + NetworkManager().messageStream.listen(_handleMessage); + } + + void _handleMessage(Map payload) { + if (!mounted) return; + if (payload['type'] == 'MOVE') { + final int x = payload['x']; + final int y = payload['y']; + final int stone = payload['stone']; + _placeStone(x, y, stone); + } else if (payload['type'] == 'GAME_OVER') { + _showGameOverDialog(payload['winner']); + } + } + + void _onTap(int x, int y) { + if (isGameOver) return; + if (currentTurn != widget.myStone) return; // 내 턴 아님 + if (board[y][x] != 0) return; // 이미 돌 있음 + + // 착수 + _placeStone(x, y, widget.myStone); + + // 전송 + NetworkManager().sendMessage({ + 'type': 'MOVE', + 'x': x, + 'y': y, + 'stone': widget.myStone, + }); + } + + void _placeStone(int x, int y, int stone) { + setState(() { + board[y][x] = stone; + + // 승리 체크 + if (_checkWin(x, y, stone)) { + isGameOver = true; + if (stone == widget.myStone) { + // 내가 이겼으면 승리 선언 전송 + NetworkManager().sendMessage({'type': 'GAME_OVER', 'winner': stone}); + _showGameOverDialog(stone); + } + } else { + // 턴 넘기기 + currentTurn = (stone == 1) ? 2 : 1; + } + }); + + SoundManager().playSfx(SoundKey.click); + } + + // 승리 조건 (5목) 체크 + bool _checkWin(int x, int y, int stone) { + final directions = [ + [1, 0], [0, 1], [1, 1], [1, -1] // 가로, 세로, 대각선, 역대각선 + ]; + + for (var d in directions) { + int count = 1; + // 정방향 탐색 + for (int i = 1; i < 5; i++) { + int nx = x + d[0] * i; + int ny = y + d[1] * i; + if (nx < 0 || ny < 0 || nx >= 15 || ny >= 15 || board[ny][nx] != stone) break; + count++; + } + // 역방향 탐색 + for (int i = 1; i < 5; i++) { + int nx = x - d[0] * i; + int ny = y - d[1] * i; + if (nx < 0 || ny < 0 || nx >= 15 || ny >= 15 || board[ny][nx] != stone) break; + count++; + } + if (count >= 5) return true; + } + return false; + } + + void _showGameOverDialog(int winner) { + String msg = (winner == widget.myStone) ? "승리했습니다! 🎉" : "패배했습니다... 😭"; + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => AlertDialog( + title: const Text("게임 종료"), + content: Text(msg), + actions: [ + TextButton( + onPressed: () { + Navigator.pop(context); + Navigator.pop(context); + }, + child: const Text("나가기"), + ) + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + final bool myTurn = currentTurn == widget.myStone; + + return Scaffold( + appBar: AppBar( + title: Text(myTurn ? "나의 턴 (${widget.myStone == 1 ? '흑' : '백'})" : "상대방 생각 중..."), + backgroundColor: myTurn ? Colors.blue[100] : Colors.grey[200], + ), + backgroundColor: const Color(0xFFDCB35C), // 바둑판 색 + body: Center( + child: AspectRatio( + aspectRatio: 1.0, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: LayoutBuilder( + builder: (context, constraints) { + final double cellSize = constraints.maxWidth / 15; + return Stack( + children: [ + // 격자 그리기 + CustomPaint( + size: Size(constraints.maxWidth, constraints.maxWidth), + painter: GridPainter(), + ), + // 터치 영역 및 돌 그리기 + ...List.generate(15 * 15, (index) { + final int x = index % 15; + final int y = index ~/ 15; + final int stone = board[y][x]; + + return Positioned( + left: x * cellSize, + top: y * cellSize, + width: cellSize, + height: cellSize, + child: GestureDetector( + onTap: () => _onTap(x, y), + child: Container( + color: Colors.transparent, // 터치 영역 확보 + child: stone == 0 + ? null + : FractionallySizedBox( + widthFactor: 0.8, + heightFactor: 0.8, + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: stone == 1 ? Colors.black : Colors.white, + boxShadow: const [BoxShadow(blurRadius: 2, offset: Offset(1,1), color: Colors.black45)] + ), + ), + ), + ), + ), + ); + }), + ], + ); + }, + ), + ), + ), + ), + ); + } +} + +class GridPainter extends CustomPainter { + @override + void paint(Canvas canvas, Size size) { + final paint = Paint()..color = Colors.black..strokeWidth = 1.0; + final double step = size.width / 15; + final double halfStep = step / 2; + + // 선 그리기 (중심에 맞게) + for (int i = 0; i < 15; i++) { + final double pos = halfStep + i * step; + canvas.drawLine(Offset(pos, halfStep), Offset(pos, size.height - halfStep), paint); // 세로 + canvas.drawLine(Offset(halfStep, pos), Offset(size.width - halfStep, pos), paint); // 가로 + } + + // 화점 (천원 등) + final dotPaint = Paint()..color = Colors.black..style = PaintingStyle.fill; + final dots = [3, 7, 11]; + for (int y in dots) { + for (int x in dots) { + canvas.drawCircle(Offset(halfStep + x * step, halfStep + y * step), 3.0, dotPaint); + } + } + } + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} \ No newline at end of file diff --git a/packages/games/quiz/lib/quiz_game.dart b/packages/core/lib/game/quiz_game.dart similarity index 54% rename from packages/games/quiz/lib/quiz_game.dart rename to packages/core/lib/game/quiz_game.dart index 0ec7e4a..13d2e20 100644 --- a/packages/games/quiz/lib/quiz_game.dart +++ b/packages/core/lib/game/quiz_game.dart @@ -2,21 +2,21 @@ import 'dart:async'; import 'dart:math'; import 'package:flutter/material.dart'; import 'package:playwith_core/playwith_core.dart'; -import 'model/quiz_model.dart'; // QuizSet, QuizItem 모델 필요 +import '../model/quiz_model.dart'; + -// --- Enums --- enum PlayerStatus { alive, dead, winner, loser } -enum GamePhase { voteRule, voteInput, playing, result } +enum GamePhase { selectCategory, voteRule, voteInput, voteTime, playing, result } enum InputMode { touch, voice } enum GameRule { survival, suddenDeath, scoreAttack, relay } class QuizGame extends BaseGame { @override - String get id => "quiz_mix"; // main.dart 등록 ID와 일치해야 함 + String get id => "quiz_mix"; @override String get name => "멀티 모드 퀴즈"; @override - String get description => "투표로 룰을 정하고 승리하세요!"; + String get description => "다함께 투표하고 퀴즈를 풀어보세요!"; // ------------------------------------------------------------------------ // 상태 변수 @@ -25,36 +25,34 @@ class QuizGame extends BaseGame { Stream> get gameStateStream => _gameStateController.stream; Map? _lastState; - // 진행 상태 - GamePhase _phase = GamePhase.voteRule; + GamePhase _phase = GamePhase.selectCategory; GameRule _selectedRule = GameRule.survival; InputMode _selectedInputMode = InputMode.touch; + int _selectedTimeLimit = 5; - // 데이터 final Set _aliveUsers = {}; final Set _answeredUsers = {}; final Map _scores = {}; - final Map _votes = {}; - - // 릴레이 모드 전용 + final Map _votes = {}; + List _turnOrder = []; int _currentTurnIndex = 0; - // 내 상태 PlayerStatus _myStatus = PlayerStatus.alive; String? _mySelectedAnswer; bool _isLockedIn = false; - Timer? _lockInTimer; - // UI 상태 bool _isCountingDown = false; int _countdownValue = 3; bool _isShowingResult = false; - // 문제 데이터 + List _masterQuestions = []; + final Set _selectedCategories = {}; List _questions = []; int _currentQuestionIndex = -1; + Timer? _hostQuestionTimer; + // ------------------------------------------------------------------------ // 라이프사이클 // ------------------------------------------------------------------------ @@ -63,28 +61,33 @@ class QuizGame extends BaseGame { super.onStart(); print("Quiz Game Started!"); _resetGame(); - - // 문제 로드 (QuizModel이 없다면 하드코딩된 리스트 사용 가능) + try { - _questions = QuizSet.getDummy10(); + _masterQuestions = QuizSet.getStandard50(); } catch (e) { - // Fallback Dummy - _questions = [ - QuizItem(type: QuizType.text, question: "사과는 영어로 Apple?", answer: "O", options: ["O", "X"]), - QuizItem(type: QuizType.text, question: "바나나는 길어지면 기차?", answer: "X", options: ["O", "X"]), - ]; + _masterQuestions = [QuizItem(type: QuizType.text, category: "기타", question: "Error", answer: "O", options: ["O","X"])]; } - // [Host] 1단계: 룰 투표 시작 + _selectedCategories.clear(); + for (var q in _masterQuestions) { + _selectedCategories.add(q.category); + } + + _aliveUsers.add(NetworkManager().me.id); + for (var guest in NetworkManager().guestList) { + _aliveUsers.add(guest.id); + } + for (var uid in _aliveUsers) _scores[uid] = 0; + if (NetworkManager().role == NetworkRole.host) { - Future.delayed(const Duration(milliseconds: 1000), () { - _broadcastState({'type': 'PHASE_CHANGE', 'phase': 'VOTE_RULE'}); + Future.delayed(const Duration(milliseconds: 500), () { + _broadcastState({'type': 'PHASE_CHANGE', 'phase': 'SELECT_CATEGORY'}); }); } } void _resetGame() { - _phase = GamePhase.voteRule; + _phase = GamePhase.selectCategory; _lastState = null; _aliveUsers.clear(); _scores.clear(); @@ -92,75 +95,121 @@ class QuizGame extends BaseGame { _turnOrder.clear(); _currentQuestionIndex = -1; _resetLocalState(); - - final allUsers = [NetworkManager().me, ...NetworkManager().guestList]; - for (var u in allUsers) { - _aliveUsers.add(u.id); - _scores[u.id] = 0; - } _myStatus = PlayerStatus.alive; + _hostQuestionTimer?.cancel(); } @override void onDispose() { - _lockInTimer?.cancel(); + _hostQuestionTimer?.cancel(); _gameStateController.close(); super.onDispose(); } // ------------------------------------------------------------------------ - // 메시지 처리 (Logic Hub) + // 메시지 처리 // ------------------------------------------------------------------------ @override void onMessageReceived(String senderId, Map payload) { if (!['ANSWER_SUBMIT', 'VOTE_SUBMIT'].contains(payload['type'])) { - _lastState = payload; + _lastState = payload; } switch (payload['type']) { - case 'PHASE_CHANGE': _handlePhaseChange(payload); break; - case 'VOTE_SUBMIT': _handleVoteSubmit(payload); break; - case 'GAME_COUNTDOWN': _handleCountdown(payload); break; - case 'ANSWER_SUBMIT': _handleAnswerSubmit(payload); break; - case 'PLAYER_STATUS_UPDATE': _handleStatusUpdate(payload); break; - case 'PLAYER_ELIMINATED': _handleEliminated(payload); break; - case 'ROUND_RESULT': _handleRoundResult(payload); break; - case 'GAME_STATE_UPDATE': _handleNewQuestion(payload); break; - case 'GAME_OVER': _handleGameOver(payload); break; - case 'GAME_EXIT': _gameStateController.add(payload); break; + case 'PHASE_CHANGE': + _handlePhaseChange(payload); + break; + case 'VOTE_SUBMIT': + _handleVoteSubmit(payload); + break; + case 'GAME_COUNTDOWN': + _handleCountdown(payload); + break; + case 'ANSWER_SUBMIT': + _handleAnswerSubmit(payload); + break; + case 'PLAYER_STATUS_UPDATE': + _handleStatusUpdate(payload); + break; + case 'PLAYER_ELIMINATED': + _handleEliminated(payload); + break; + case 'ROUND_RESULT': + _handleRoundResult(payload); + break; + case 'GAME_STATE_UPDATE': + _handleNewQuestion(payload); + break; + case 'GAME_OVER': + _handleGameOver(payload); + break; + case 'GAME_EXIT': + _gameStateController.add(payload); + break; + case 'system_message': + _gameStateController.add(payload); + break; } } - // --- Handlers --- + // ------------------------------------------------------------------------ + // Handlers + // ------------------------------------------------------------------------ void _handlePhaseChange(Map payload) { final phaseStr = payload['phase']; - if (phaseStr == 'VOTE_RULE') _phase = GamePhase.voteRule; + + if (phaseStr == 'SELECT_CATEGORY') { + _phase = GamePhase.selectCategory; + } + else if (phaseStr == 'VOTE_RULE') { + _phase = GamePhase.voteRule; + _votes.clear(); + + if (payload['categories'] != null) { + final List cats = payload['categories']; + _selectedCategories.clear(); + _selectedCategories.addAll(cats.cast()); + _questions = _masterQuestions.where((q) => _selectedCategories.contains(q.category)).toList(); + if (_questions.isEmpty) _questions = List.from(_masterQuestions); + } + } else if (phaseStr == 'VOTE_INPUT') { _phase = GamePhase.voteInput; _selectedRule = GameRule.values.firstWhere((e) => e.name == payload['rule'], orElse: () => GameRule.survival); + _votes.clear(); + } + else if (phaseStr == 'VOTE_TIME') { + _phase = GamePhase.voteTime; + _selectedInputMode = payload['inputMode'] == 'voice' ? InputMode.voice : InputMode.touch; + _votes.clear(); } else if (phaseStr == 'PLAYING') { _phase = GamePhase.playing; - _selectedInputMode = payload['inputMode'] == 'voice' ? InputMode.voice : InputMode.touch; + _selectedTimeLimit = payload['timeLimit'] ?? 5; if (_selectedRule == GameRule.relay) { _turnOrder = List.from(payload['turnOrder'] ?? []); _currentTurnIndex = 0; } } _gameStateController.add(payload); - _votes.clear(); } void _handleVoteSubmit(Map payload) { - if (NetworkManager().role != NetworkRole.host) return; - _votes[payload['userId']] = payload['vote']; + final uid = payload['userId']; + _votes[uid] = payload['vote']; + _gameStateController.add({'type': 'UI_REFRESH'}); - // 전원 투표 완료 체크 - // (중간에 나간 사람 고려하여 aliveUsers 기준으로 체크하거나 타임아웃 필요. MVP는 단순 크기 비교) - if (_votes.length >= _aliveUsers.length) { - if (_phase == GamePhase.voteRule) _decideRule(); - else if (_phase == GamePhase.voteInput) _decideInputAndStart(); + if (NetworkManager().role == NetworkRole.host) { + if (_votes.length >= _aliveUsers.length) { + if (_phase == GamePhase.voteRule) { + _decideRule(); + } else if (_phase == GamePhase.voteInput) { + _decideInput(); + } else if (_phase == GamePhase.voteTime) { + _decideTimeAndStart(); + } + } } } @@ -191,7 +240,6 @@ class QuizGame extends BaseGame { isCorrect = (answer == currentQ.answer); } - // 룰별 탈락 처리 if (_selectedRule == GameRule.scoreAttack) { if (isCorrect) _scores[userId] = (_scores[userId] ?? 0) + 1; } else { @@ -216,18 +264,13 @@ class QuizGame extends BaseGame { 'score': _scores[userId] }); - // 다음 진행 판단 - bool shouldAdvance = false; - if (_selectedRule == GameRule.relay) { - shouldAdvance = true; - } else { - int targetCount = _selectedRule == GameRule.scoreAttack - ? NetworkManager().guestList.length + 1 - : _aliveUsers.length + (isCorrect ? 0 : 1); - if (_answeredUsers.length >= targetCount) shouldAdvance = true; - } + int targetCount = _selectedRule == GameRule.scoreAttack + ? NetworkManager().guestList.length + 1 + : _aliveUsers.length + (isCorrect ? 0 : 1); + if (_selectedRule == GameRule.relay) targetCount = 1; - if (shouldAdvance) { + if (_answeredUsers.length >= targetCount) { + _hostQuestionTimer?.cancel(); Future.delayed(const Duration(milliseconds: 1000), () => _showRoundResultAndNext()); } } @@ -238,38 +281,25 @@ class QuizGame extends BaseGame { if (payload['score'] != null) _scores[payload['userId']] = payload['score']; _gameStateController.add(payload); } - void _handleEliminated(Map payload) { if (payload['targetUserId'] == NetworkManager().me.id) _handleLocalElimination(); _gameStateController.add({'type': 'UI_REFRESH'}); } - - void _handleLocalElimination() { - SoundManager().playSfx(SoundKey.wrong); - _myStatus = PlayerStatus.dead; - } - void _handleRoundResult(Map payload) { _isCountingDown = false; _isShowingResult = true; if (_selectedRule == GameRule.relay) _currentTurnIndex = payload['nextTurnIndex'] ?? 0; - final survivors = payload['survivors'] ?? []; bool amISurvived = survivors.contains(NetworkManager().me.id); - - if (!amISurvived && _myStatus == PlayerStatus.alive && _selectedRule != GameRule.scoreAttack) { - _handleLocalElimination(); - } + if (!amISurvived && _myStatus == PlayerStatus.alive && _selectedRule != GameRule.scoreAttack) _handleLocalElimination(); _gameStateController.add(payload); } - void _handleNewQuestion(Map payload) { _isCountingDown = false; _isShowingResult = false; _resetLocalState(); _gameStateController.add(payload); } - void _handleGameOver(Map payload) { final winnerId = payload['winnerId']; if (winnerId == NetworkManager().me.id) { @@ -281,14 +311,32 @@ class QuizGame extends BaseGame { } _gameStateController.add(payload); } + void _handleLocalElimination() { + SoundManager().playSfx(SoundKey.wrong); + _myStatus = PlayerStatus.dead; + } // ------------------------------------------------------------------------ - // [Host Logic] + // [Host Logic] 결정 로직 // ------------------------------------------------------------------------ + + void _confirmCategories() { + if (_selectedCategories.isEmpty) return; + _broadcastState({ + 'type': 'PHASE_CHANGE', + 'phase': 'VOTE_RULE', + 'categories': _selectedCategories.toList(), + }); + } + void _decideRule() { final counts = {}; for (var v in _votes.values) { counts[v] = (counts[v] ?? 0) + 1; } - String topRule = counts.entries.reduce((a, b) => a.value >= b.value ? a : b).key; + + String topRule = 'survival'; + if (counts.isNotEmpty) { + topRule = counts.entries.reduce((a, b) => a.value >= b.value ? a : b).key; + } _selectedRule = GameRule.values.firstWhere((e) => e.name == topRule, orElse: () => GameRule.survival); @@ -299,11 +347,28 @@ class QuizGame extends BaseGame { }); } - void _decideInputAndStart() { + void _decideInput() { int touch = _votes.values.where((v) => v == 'touch').length; int voice = _votes.values.where((v) => v == 'voice').length; InputMode mode = (touch >= voice) ? InputMode.touch : InputMode.voice; + _broadcastState({ + 'type': 'PHASE_CHANGE', + 'phase': 'VOTE_TIME', + 'inputMode': mode.name, + }); + } + + void _decideTimeAndStart() { + final counts = {}; + for (var v in _votes.values) { counts[v] = (counts[v] ?? 0) + 1; } + + String topTime = '5'; + if (counts.isNotEmpty) { + topTime = counts.entries.reduce((a, b) => a.value >= b.value ? a : b).key; + } + int timeLimit = int.tryParse(topTime) ?? 5; + List? turnOrder; if (_selectedRule == GameRule.relay) { turnOrder = _aliveUsers.toList()..shuffle(); @@ -312,24 +377,21 @@ class QuizGame extends BaseGame { _broadcastState({ 'type': 'PHASE_CHANGE', 'phase': 'PLAYING', - 'inputMode': mode.name, + 'timeLimit': timeLimit, 'turnOrder': turnOrder }); - _selectedInputMode = mode; - _turnOrder = turnOrder ?? []; - _phase = GamePhase.playing; - Future.delayed(const Duration(seconds: 2), () => _startCountdownSequence()); } void _showRoundResultAndNext() { + _hostQuestionTimer?.cancel(); + final currentQ = _questions[_currentQuestionIndex]; int nextTurn = _currentTurnIndex; if (_selectedRule == GameRule.relay) { nextTurn = (_currentTurnIndex + 1) % _aliveUsers.length; } - _broadcastState({ 'type': 'ROUND_RESULT', 'status': 'RESULT', @@ -338,34 +400,40 @@ class QuizGame extends BaseGame { 'scores': _scores, 'nextTurnIndex': nextTurn }); - _currentTurnIndex = nextTurn; - Future.delayed(const Duration(seconds: 3), () => _checkWinnerAndNext()); } void _checkWinnerAndNext() { - int totalPlayers = NetworkManager().guestList.length + 1; + bool isSolo = NetworkManager().guestList.isEmpty; bool isEnd = false; String? winnerId; if (_currentQuestionIndex >= _questions.length - 1) { - isEnd = true; - if (_selectedRule == GameRule.scoreAttack) { - if (_scores.isNotEmpty) { - winnerId = _scores.entries.reduce((a, b) => a.value >= b.value ? a : b).key; - } + if (isSolo) { + _questions = _masterQuestions.where((q) => _selectedCategories.contains(q.category)).toList(); + _questions.shuffle(); + _currentQuestionIndex = -1; + _broadcastState({'type': 'system_message', 'message': '문제가 리필되었습니다! 🔄'}); } else { - winnerId = _aliveUsers.isNotEmpty ? _aliveUsers.first : null; + isEnd = true; + if (_selectedRule == GameRule.scoreAttack && _scores.isNotEmpty) { + winnerId = _scores.entries.reduce((a, b) => a.value >= b.value ? a : b).key; + } else { + winnerId = _aliveUsers.isNotEmpty ? _aliveUsers.first : null; + } } } - else if (_selectedRule != GameRule.scoreAttack && _aliveUsers.length <= 1) { - if (_aliveUsers.isNotEmpty) { + else if (_selectedRule != GameRule.scoreAttack) { + if (isSolo) { + if (_aliveUsers.isEmpty) { + isEnd = true; + winnerId = null; + } + } + else if (_aliveUsers.length <= 1) { isEnd = true; - winnerId = _aliveUsers.first; - } else { - isEnd = true; - winnerId = null; + winnerId = _aliveUsers.isNotEmpty ? _aliveUsers.first : null; } } @@ -380,10 +448,7 @@ class QuizGame extends BaseGame { int count = 3; Timer.periodic(const Duration(seconds: 1), (timer) { _broadcastState({'type': 'GAME_COUNTDOWN', 'count': count}); - if (count == 0) { - timer.cancel(); - _sendNewQuestion(); - } + if (count == 0) { timer.cancel(); _sendNewQuestion(); } count--; }); } @@ -392,29 +457,66 @@ class QuizGame extends BaseGame { _currentQuestionIndex++; final qData = _questions[_currentQuestionIndex]; _resetLocalState(); - _broadcastState({'type': 'GAME_STATE_UPDATE', 'status': 'QUESTION', 'data': qData.toJson()}); + + _broadcastState({ + 'type': 'GAME_STATE_UPDATE', + 'status': 'QUESTION', + 'data': qData.toJson(), + 'timeLimit': _selectedTimeLimit + }); + + _hostQuestionTimer?.cancel(); + _hostQuestionTimer = Timer(Duration(seconds: _selectedTimeLimit + 1), _handleQuestionTimeout); + } + + void _handleQuestionTimeout() { + if (NetworkManager().role != NetworkRole.host) return; + + List timeoutUsers = []; + if (_selectedRule == GameRule.relay) { + String currentTurnUser = _turnOrder[_currentTurnIndex]; + if (!_answeredUsers.contains(currentTurnUser) && _aliveUsers.contains(currentTurnUser)) { + timeoutUsers.add(currentTurnUser); + } + } else { + for (var uid in _aliveUsers) { + if (!_answeredUsers.contains(uid)) timeoutUsers.add(uid); + } + } + + if (timeoutUsers.isNotEmpty) { + for (var uid in timeoutUsers) { + if (_selectedRule != GameRule.scoreAttack) { + _aliveUsers.remove(uid); + NetworkManager().sendMessage({'type': 'PLAYER_ELIMINATED', 'targetUserId': uid}); + if (uid == NetworkManager().me.id) _handleLocalElimination(); + } + } + } + + _showRoundResultAndNext(); } void _finishGame({String? winnerId}) { - final endData = { - 'type': 'GAME_OVER', - 'winnerId': winnerId ?? 'NONE', - 'winnerName': _findUserName(winnerId) - }; + _hostQuestionTimer?.cancel(); + final endData = {'type': 'GAME_OVER', 'winnerId': winnerId ?? 'NONE', 'winnerName': _findUserName(winnerId)}; _broadcastState(endData); } void _broadcastState(Map data) { _lastState = data; - _gameStateController.add(data); - if (NetworkManager().role == NetworkRole.host) NetworkManager().sendMessage(data); + if (NetworkManager().role == NetworkRole.host) { + NetworkManager().sendMessage(data); + onMessageReceived(NetworkManager().me.id, data); + } else { + _gameStateController.add(data); + } } void _resetLocalState() { _answeredUsers.clear(); _mySelectedAnswer = null; _isLockedIn = false; - _lockInTimer?.cancel(); } String _findUserName(String? id) { @@ -424,25 +526,27 @@ class QuizGame extends BaseGame { } // ------------------------------------------------------------------------ - // [UI] + // [UI] Unified View // ------------------------------------------------------------------------ @override - Widget buildHostView(BuildContext context) => _buildScreen(context, true); + Widget buildHostView(BuildContext context) => _buildSharedScreen(context, isHost: true); @override - Widget buildGuestView(BuildContext context) => _buildScreen(context, false); + Widget buildGuestView(BuildContext context) => _buildSharedScreen(context, isHost: false); - Widget _buildScreen(BuildContext context, bool isHost) { + Widget _buildSharedScreen(BuildContext context, {required bool isHost}) { return Scaffold( appBar: AppBar( - title: const Text("PlayWith 퀴즈"), + title: const Text("OX 서바이벌"), centerTitle: true, automaticallyImplyLeading: false, actions: [ if (isHost) IconButton(icon: const Icon(Icons.close), onPressed: () => _confirmExit(context)) ], ), + // [추가] 하단 배너 광고 배치 + bottomNavigationBar: const SafeArea(child: AdBannerWidget()), body: Padding( - padding: const EdgeInsets.only(bottom: 140.0), + padding: const EdgeInsets.only(bottom: 0), // 광고가 bottomNavigationBar에 있으므로 padding 제거 child: StreamBuilder>( stream: gameStateStream, initialData: _lastState, @@ -450,24 +554,37 @@ class QuizGame extends BaseGame { if (!snapshot.hasData) return _buildWaitingScreen("로딩 중..."); final data = snapshot.data!; - if (_phase == GamePhase.voteRule || (data['type'] == 'PHASE_CHANGE' && data['phase'] == 'VOTING')) return _buildRuleVotingView(context); - if (_phase == GamePhase.voteInput || (data['type'] == 'PHASE_CHANGE' && data['phase'] == 'VOTE_INPUT')) return _buildInputVotingView(context); + // 1. 카테고리 + if (_phase == GamePhase.selectCategory || (data['type'] == 'PHASE_CHANGE' && data['phase'] == 'SELECT_CATEGORY')) { + return _buildCategorySelectionView(context, isHost); + } + // 2. 규칙 + if (_phase == GamePhase.voteRule || (data['type'] == 'PHASE_CHANGE' && data['phase'] == 'VOTE_RULE')) { + return _buildRuleVotingView(context, isHost); + } + // 3. 입력 방식 + if (_phase == GamePhase.voteInput || (data['type'] == 'PHASE_CHANGE' && data['phase'] == 'VOTE_INPUT')) { + return _buildInputVotingView(context, isHost); + } + // 4. 시간 제한 투표 + if (_phase == GamePhase.voteTime || (data['type'] == 'PHASE_CHANGE' && data['phase'] == 'VOTE_TIME')) { + return _buildTimeVotingView(context, isHost); + } + // 5. 게임 진행 if (_isCountingDown || (data['type'] == 'GAME_COUNTDOWN')) { int count = data['count'] ?? 3; return Center(child: Text(count > 0 ? "$count" : "START!", style: const TextStyle(fontSize: 90, fontWeight: FontWeight.bold, color: Colors.blue))); } - if (data['type'] == 'GAME_EXIT') { WidgetsBinding.instance.addPostFrameCallback((_) { if(context.mounted) Navigator.pop(context); }); return const Center(child: Text("종료되었습니다.")); } if (data['type'] == 'GAME_OVER') return _buildResultScreen(context, data['winnerName']); - if (_isShowingResult || data['status'] == 'RESULT') return _buildRoundResultScreen(data); - if (data['status'] == 'QUESTION' || _currentQuestionIndex >= 0) { - Map qData = data['data'] ?? _questions[_currentQuestionIndex].toJson(); + Map qData = data['data'] ?? (_currentQuestionIndex < _questions.length ? _questions[_currentQuestionIndex].toJson() : {}); + if (qData.isEmpty) return _buildWaitingScreen("문제 로딩 중..."); return _buildPlayArea(context, qData); } @@ -478,56 +595,45 @@ class QuizGame extends BaseGame { ); } - // --- UI Parts --- - - Widget _buildRuleVotingView(BuildContext context) { - if (_votes.containsKey(NetworkManager().me.id)) return _buildWaitingScreen("룰 투표 완료! 대기 중..."); - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text("어떤 게임을 할까요?", style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)), - const SizedBox(height: 30), - Wrap( - spacing: 15, runSpacing: 15, alignment: WrapAlignment.center, - children: [ - _VoteButton(icon: Icons.local_fire_department, label: "서바이벌", color: Colors.red, onTap: () => _submitVote('survival')), - _VoteButton(icon: Icons.dangerous, label: "단체 한방", color: Colors.black, onTap: () => _submitVote('suddenDeath')), - _VoteButton(icon: Icons.score, label: "점수 내기", color: Colors.blue, onTap: () => _submitVote('scoreAttack')), - _VoteButton(icon: Icons.directions_run, label: "이어 달리기", color: Colors.green, onTap: () => _submitVote('relay')), - ], - ), - ], - ), - ); + Widget _buildCategorySelectionView(BuildContext context, bool isHost) { + if (!isHost) { + return const Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [CircularProgressIndicator(), SizedBox(height: 20), Text("방장이 문제 카테고리를 고르고 있습니다...", style: TextStyle(fontSize: 16, color: Colors.grey))])); + } + final allCategories = _masterQuestions.map((q) => q.category).toSet().toList()..sort(); + return Center(child: SingleChildScrollView(child: Padding(padding: const EdgeInsets.all(24.0), child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [const Text("출제할 카테고리 선택", style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)), const SizedBox(height: 10), const Text("원하는 분야만 골라서 플레이하세요!", style: TextStyle(color: Colors.grey)), const SizedBox(height: 30), Wrap(spacing: 10, runSpacing: 10, alignment: WrapAlignment.center, children: allCategories.map((cat) { final isSelected = _selectedCategories.contains(cat); return FilterChip(label: Text(cat), selected: isSelected, onSelected: (bool selected) { if (!selected && _selectedCategories.length <= 1) return; if (selected) { _selectedCategories.add(cat); } else { _selectedCategories.remove(cat); } _gameStateController.add({'type': 'UI_REFRESH'}); }); }).toList()), const SizedBox(height: 40), ElevatedButton(onPressed: _confirmCategories, style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 40, vertical: 15), backgroundColor: Colors.blueAccent), child: Text("선택 완료 (${_selectedCategories.length}개)", style: const TextStyle(fontSize: 18, color: Colors.white)))])))); } - Widget _buildInputVotingView(BuildContext context) { - if (_votes.containsKey(NetworkManager().me.id)) return _buildWaitingScreen("입력 방식 투표 완료! 대기 중..."); - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text("어떻게 맞출까요?", style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)), - const SizedBox(height: 30), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - _VoteButton(icon: Icons.touch_app, label: "터치", color: Colors.blue, onTap: () => _submitVote('touch')), - _VoteButton(icon: Icons.mic, label: "음성", color: Colors.orange, onTap: () => _submitVote('voice')), - ], - ), - ], - ), - ); + Widget _buildRuleVotingView(BuildContext context, bool isHost) { + bool hasVoted = _votes.containsKey(NetworkManager().me.id); + int voteCount = _votes.length; + int totalPlayers = _aliveUsers.length; + return Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [Text("어떤 게임을 할까요? ($voteCount/$totalPlayers)", style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), const SizedBox(height: 30), if (hasVoted) ...[const CircularProgressIndicator(), const SizedBox(height: 20), const Text("다른 참가자를 기다리는 중...", style: TextStyle(color: Colors.grey)), if (isHost) Padding(padding: const EdgeInsets.only(top: 30), child: ElevatedButton.icon(icon: const Icon(Icons.play_arrow), label: const Text("강제 집계 및 시작"), style: ElevatedButton.styleFrom(backgroundColor: Colors.orange), onPressed: () => _decideRule()))] else Wrap(spacing: 15, runSpacing: 15, alignment: WrapAlignment.center, children: [_VoteButton(icon: Icons.local_fire_department, label: "서바이벌", color: Colors.red, onTap: () => _submitVote('survival')), _VoteButton(icon: Icons.dangerous, label: "단체 한방", color: Colors.black, onTap: () => _submitVote('suddenDeath')), _VoteButton(icon: Icons.score, label: "점수 내기", color: Colors.blue, onTap: () => _submitVote('scoreAttack')), _VoteButton(icon: Icons.directions_run, label: "이어 달리기", color: Colors.green, onTap: () => _submitVote('relay'))])])); + } + + Widget _buildInputVotingView(BuildContext context, bool isHost) { + bool hasVoted = _votes.containsKey(NetworkManager().me.id); + int voteCount = _votes.length; + int totalPlayers = _aliveUsers.length; + return Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [Text("어떻게 맞출까요? ($voteCount/$totalPlayers)", style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), const SizedBox(height: 30), if (hasVoted) ...[const CircularProgressIndicator(), const SizedBox(height: 20), const Text("대기 중...", style: TextStyle(color: Colors.grey)), if (isHost) Padding(padding: const EdgeInsets.only(top: 30), child: ElevatedButton.icon(icon: const Icon(Icons.play_arrow), label: const Text("강제 집계 및 이동"), style: ElevatedButton.styleFrom(backgroundColor: Colors.orange), onPressed: () => _decideInput()))] else Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [_VoteButton(icon: Icons.touch_app, label: "터치", color: Colors.blue, onTap: () => _submitVote('touch')), _VoteButton(icon: Icons.mic, label: "음성", color: Colors.orange, onTap: () => _submitVote('voice'))])])); + } + + Widget _buildTimeVotingView(BuildContext context, bool isHost) { + bool hasVoted = _votes.containsKey(NetworkManager().me.id); + int voteCount = _votes.length; + int totalPlayers = _aliveUsers.length; + return Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [Text("제한 시간은 몇 초? ($voteCount/$totalPlayers)", style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), const SizedBox(height: 30), if (hasVoted) ...[const CircularProgressIndicator(), const SizedBox(height: 20), const Text("대기 중...", style: TextStyle(color: Colors.grey)), if (isHost) Padding(padding: const EdgeInsets.only(top: 30), child: ElevatedButton.icon(icon: const Icon(Icons.play_arrow), label: const Text("강제 집계 및 시작"), style: ElevatedButton.styleFrom(backgroundColor: Colors.orange), onPressed: () => _decideTimeAndStart()))] else Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [_VoteButton(icon: Icons.timer_3, label: "3초", color: Colors.red, onTap: () => _submitVote('3')), _VoteButton(icon: Icons.timer, label: "5초", color: Colors.green, onTap: () => _submitVote('5')), _VoteButton(icon: Icons.timer_10, label: "7초", color: Colors.blue, onTap: () => _submitVote('7')), _VoteButton(icon: Icons.hourglass_top, label: "10초", color: Colors.purple, onTap: () => _submitVote('10'))])])); } void _submitVote(String vote) { - _votes[NetworkManager().me.id] = vote; - _gameStateController.add({'type': 'UI_REFRESH'}); final payload = {'type': 'VOTE_SUBMIT', 'userId': NetworkManager().me.id, 'vote': vote}; - if (NetworkManager().role == NetworkRole.host) onMessageReceived("", payload); - else NetworkManager().sendMessage(payload); + _votes[NetworkManager().me.id] = vote; + _gameStateController.add({'type': 'UI_REFRESH'}); + + if (NetworkManager().role == NetworkRole.host) { + onMessageReceived("", payload); + } else { + NetworkManager().sendMessage(payload); + } } Widget _buildPlayArea(BuildContext context, Map qData) { @@ -545,6 +651,13 @@ class QuizGame extends BaseGame { return Column( children: [ + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 4), + color: Colors.amberAccent.withOpacity(0.2), + child: Text("분야: ${qData['category'] ?? '기타'} | ⏱️ 제한시간 ${_selectedTimeLimit}초", textAlign: TextAlign.center, style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.orange)), + ), + Container( padding: const EdgeInsets.all(10), color: Colors.grey[100], @@ -553,6 +666,16 @@ class QuizGame extends BaseGame { : _PlayerStatusGrid(aliveUsers: _aliveUsers, answeredUsers: _answeredUsers), ), + TweenAnimationBuilder( + tween: Tween(begin: 1.0, end: 0.0), + duration: Duration(seconds: _selectedTimeLimit), + builder: (context, value, _) => LinearProgressIndicator( + value: value, + backgroundColor: Colors.grey[300], + color: value > 0.3 ? Colors.green : Colors.red + ), + ), + if (_selectedRule == GameRule.relay) Container( width: double.infinity, @@ -594,38 +717,12 @@ class QuizGame extends BaseGame { return Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [Icon(Icons.check, size: 80, color: Colors.blue), const SizedBox(height: 20), const Text("제출 완료!", style: TextStyle(fontSize: 22))])); } - Widget _buildRoundResultScreen(Map data) { - final String correctAnswer = data['correctAnswer'] ?? "?"; - final List survivors = data['survivors'] ?? []; - final bool amISurvived = survivors.contains(NetworkManager().me.id); - - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text("정답은?", style: TextStyle(fontSize: 24, color: Colors.grey)), - const SizedBox(height: 20), - Container( - width: 160, height: 160, - decoration: BoxDecoration(color: correctAnswer == "O" ? Colors.blue : Colors.red, shape: BoxShape.circle, boxShadow: [BoxShadow(color: Colors.black26, blurRadius: 10, offset: const Offset(0, 5))]), - child: Center(child: Text(correctAnswer, style: const TextStyle(fontSize: 80, color: Colors.white, fontWeight: FontWeight.bold))), - ), - const SizedBox(height: 40), - if (_myStatus == PlayerStatus.dead) const Text("이미 탈락하셨습니다. 👻", style: TextStyle(fontSize: 20, color: Colors.grey)) - else if (amISurvived) const Text("생존! 🎉", style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold, color: Colors.green)) - else const Text("탈락했습니다... 😭", style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold, color: Colors.red)), - ], - ), - ); - } - - // Helper methods void _selectAnswer(String answer) { - _lockInTimer?.cancel(); + if (_isLockedIn) return; _mySelectedAnswer = answer; SoundManager().playSfx(SoundKey.click); _gameStateController.add({'type': 'UI_REFRESH'}); - _lockInTimer = Timer(const Duration(seconds: 3), () { _submitFinalAnswer(); }); + _submitFinalAnswer(); } void _submitFinalAnswer() { @@ -635,42 +732,31 @@ class QuizGame extends BaseGame { final payload = {'type': 'ANSWER_SUBMIT', 'answer': _mySelectedAnswer, 'userId': NetworkManager().me.id}; if (NetworkManager().role == NetworkRole.host) { onMessageReceived("", payload); } else { NetworkManager().sendMessage(payload); } } - + + Widget _buildRoundResultScreen(Map data) { + final String correctAnswer = data['correctAnswer'] ?? "?"; + final List survivors = data['survivors'] ?? []; + final bool amISurvived = survivors.contains(NetworkManager().me.id); + return Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [const Text("정답은?", style: TextStyle(fontSize: 24, color: Colors.grey)), const SizedBox(height: 20), Container(width: 160, height: 160, decoration: BoxDecoration(color: correctAnswer == "O" ? Colors.blue : Colors.red, shape: BoxShape.circle, boxShadow: [BoxShadow(color: Colors.black26, blurRadius: 10, offset: const Offset(0, 5))]), child: Center(child: Text(correctAnswer, style: const TextStyle(fontSize: 60, color: Colors.white, fontWeight: FontWeight.bold)))), const SizedBox(height: 40), if (_myStatus == PlayerStatus.dead) const Text("이미 탈락하셨습니다. 👻", style: TextStyle(fontSize: 20, color: Colors.grey)) else if (amISurvived) const Text("생존! 🎉", style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold, color: Colors.green)) else const Text("탈락했습니다... 😭", style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold, color: Colors.red))])); + } + Widget _buildResultScreen(BuildContext context, String winnerName) { return Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [Icon(Icons.emoji_events, size: 100, color: Colors.amber), const SizedBox(height: 20), const Text("게임 종료", style: TextStyle(fontSize: 30, fontWeight: FontWeight.bold)), const SizedBox(height: 10), Text("우승: $winnerName", style: const TextStyle(fontSize: 20)), const SizedBox(height: 50), ElevatedButton(onPressed: () { onDispose(); Navigator.pop(context); }, child: const Text("로비로 돌아가기"))])); } Widget _buildWaitingScreen(String msg) => Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [const CircularProgressIndicator(), SizedBox(height: 20), Text(msg)])); - - void _confirmExit(BuildContext context) { - showDialog(context: context, builder: (ctx) => AlertDialog(title: const Text("종료"), content: const Text("방을 폭파하시겠습니까?"), actions: [TextButton(onPressed: ()=>Navigator.pop(ctx), child: const Text("취소")), TextButton(onPressed: () { Navigator.pop(ctx); NetworkManager().sendMessage({'type': 'GAME_EXIT'}); onDispose(); Navigator.pop(context); }, child: const Text("종료", style: TextStyle(color: Colors.red)))])); - } + void _confirmExit(BuildContext context) { Navigator.pop(context); } } -// [Components] -class _ScoreBoard extends StatelessWidget { - final Map scores; - const _ScoreBoard({required this.scores}); +// Components +class _VoteButton extends StatelessWidget { + final IconData icon; final String label; final Color color; final VoidCallback onTap; + const _VoteButton({required this.icon, required this.label, required this.color, required this.onTap}); @override Widget build(BuildContext context) { - return SizedBox( - height: 60, - child: ListView.builder( - scrollDirection: Axis.horizontal, - itemCount: scores.length, - itemBuilder: (context, index) { - final uid = scores.keys.elementAt(index); - final score = scores[uid]; - String name = "?"; - if (uid == NetworkManager().me.id) name = NetworkManager().me.nickname; - else { try { name = NetworkManager().guestList.firstWhere((u) => u.id == uid).nickname; } catch(_) {} } - return Container(margin: const EdgeInsets.symmetric(horizontal: 8), padding: const EdgeInsets.all(8), decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(10), border: Border.all(color: Colors.blue.shade100)), child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [Text(name, style: const TextStyle(fontSize: 10)), Text("$score점", style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.blue))])); - }, - ), - ); + return GestureDetector(onTap: onTap, child: Column(children: [Container(width: 80, height: 80, decoration: BoxDecoration(color: color.withOpacity(0.1), borderRadius: BorderRadius.circular(20), border: Border.all(color: color, width: 2)), child: Icon(icon, size: 40, color: color)), const SizedBox(height: 5), Text(label, style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold, color: color))])); } } - class _PlayerStatusGrid extends StatelessWidget { final Set aliveUsers; final Set answeredUsers; const _PlayerStatusGrid({required this.aliveUsers, required this.answeredUsers}); @@ -680,16 +766,14 @@ class _PlayerStatusGrid extends StatelessWidget { return Container(height: 80, width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 10), color: Colors.grey[50], child: ListView.builder(scrollDirection: Axis.horizontal, itemCount: allUsers.length, itemBuilder: (context, index) { final user = allUsers[index]; final isAlive = aliveUsers.contains(user.id); final isSubmitted = answeredUsers.contains(user.id); return Padding(padding: const EdgeInsets.symmetric(horizontal: 6.0), child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [Stack(children: [Container(width: 40, height: 40, decoration: BoxDecoration(shape: BoxShape.circle, color: isAlive ? Color(user.colorValue) : Colors.grey, border: isSubmitted ? Border.all(color: Colors.green, width: 3) : null), child: AvatarWidget(user: user, size: 40)), if (!isAlive) Positioned.fill(child: Container(decoration: BoxDecoration(color: Colors.black54, shape: BoxShape.circle), child: const Icon(Icons.close, size: 20, color: Colors.white)))]), const SizedBox(height: 4), Text(user.nickname, style: TextStyle(fontSize: 10, color: isAlive ? Colors.black : Colors.grey))])); })); } } - -class _VoteButton extends StatelessWidget { - final IconData icon; final String label; final Color color; final VoidCallback onTap; - const _VoteButton({required this.icon, required this.label, required this.color, required this.onTap}); +class _ScoreBoard extends StatelessWidget { + final Map scores; + const _ScoreBoard({required this.scores}); @override Widget build(BuildContext context) { - return GestureDetector(onTap: onTap, child: Column(children: [Container(width: 80, height: 80, decoration: BoxDecoration(color: color.withOpacity(0.1), borderRadius: BorderRadius.circular(20), border: Border.all(color: color, width: 2)), child: Icon(icon, size: 40, color: color)), const SizedBox(height: 5), Text(label, style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold, color: color))])); + return SizedBox(height: 80, child: ListView.builder(scrollDirection: Axis.horizontal, itemCount: scores.length, itemBuilder: (context, index) { final uid = scores.keys.elementAt(index); final score = scores[uid]; String name = "?"; if (uid == NetworkManager().me.id) name = NetworkManager().me.nickname; else { try { name = NetworkManager().guestList.firstWhere((u) => u.id == uid).nickname; } catch(_) {} } return Container(margin: const EdgeInsets.symmetric(horizontal: 8), padding: const EdgeInsets.all(8), decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(10), border: Border.all(color: Colors.blue.shade100)), child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [Text(name, style: const TextStyle(fontSize: 12)), Text("$score점", style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.blue))])); },),); } } - class _AnswerBtn extends StatelessWidget { final String text; final Color color; final bool isSelected; final VoidCallback onTap; const _AnswerBtn({required this.text, required this.color, required this.isSelected, required this.onTap}); diff --git a/packages/core/lib/game/spider_multi_game.dart b/packages/core/lib/game/spider_multi_game.dart new file mode 100644 index 0000000..2d1f75e --- /dev/null +++ b/packages/core/lib/game/spider_multi_game.dart @@ -0,0 +1,372 @@ +import 'dart:async'; +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:playwith_core/playwith_core.dart'; +import '../model/spider_model.dart'; +import '../widgets/spider_widgets.dart'; + +class SpiderMultiGame extends BaseGame { + @override + String get id => "spider_battle"; + @override + String get name => "스파이더 배틀"; + @override + String get description => "K부터 A까지 카드를 정렬하세요.\n완성하면 상대방에게 카드를 뿌려 공격합니다!"; + + int? _randomSeed; + + @override + void onStart() { + super.onStart(); + if (NetworkManager().role == NetworkRole.host) { + final int seed = Random().nextInt(1000000); + final payload = {'type': 'GAME_START_DATA', 'seed': seed}; + onMessageReceived(NetworkManager().me.id, payload); + NetworkManager().sendMessage(payload); + } + } + + @override + void onMessageReceived(String senderId, Map payload) { + if (payload['type'] == 'GAME_START_DATA') { + _randomSeed = payload['seed']; + } + } + + @override + Widget buildHostView(BuildContext context) => _buildScreen(); + @override + Widget buildGuestView(BuildContext context) => _buildScreen(); + + Widget _buildScreen() { + if (_randomSeed == null) return const Scaffold(body: Center(child: CircularProgressIndicator())); + return SpiderBattleScreen(seed: _randomSeed!, gameInstance: this); + } +} + +class SpiderBattleScreen extends StatefulWidget { + final int seed; + final SpiderMultiGame gameInstance; + + const SpiderBattleScreen({super.key, required this.seed, required this.gameInstance}); + + @override + State createState() => _SpiderBattleScreenState(); +} + +class _SpiderBattleScreenState extends State { + // 게임 상태 + List> tableau = List.generate(10, (_) => []); // 10개 컬럼 + List stock = []; // 뽑을 카드 + List> foundation = []; // 완성된 세트 + + int _moves = 0; + final int _numSuits = 1; // 난이도 (1: 스페이드만, 2: 하트/스페이드, 4: 전체) + + @override + void initState() { + super.initState(); + _initializeGame(); + NetworkManager().messageStream.listen(_handleNetworkMessage); + } + + void _handleNetworkMessage(Map payload) { + if (!mounted) return; + if (payload['type'] == 'ATTACK') { + _onAttacked(payload['senderName']); + } else if (payload['type'] == 'GAME_WIN') { + _showGameOverDialog(payload['winnerName']); + } + } + + // --- 게임 로직 --- + + void _initializeGame() { + final random = Random(widget.seed); + List deck = []; + + // 2세트(104장) 생성 + int idCounter = 0; + for (int i = 0; i < 8; i++) { // 1 suit 모드 기준 (스페이드 13장 * 8세트) + for (int r = 1; r <= 13; r++) { + deck.add(SpiderCard(id: idCounter++, suit: SpiderSuit.spade, rank: r)); + } + } + deck.shuffle(random); + + // 태블로에 카드 분배 (앞 4줄 6장, 뒤 6줄 5장 = 54장) + int cardIdx = 0; + for (int i = 0; i < 10; i++) { + int count = (i < 4) ? 6 : 5; + for (int j = 0; j < count; j++) { + final card = deck[cardIdx++]; + if (j == count - 1) card.isFaceUp = true; // 맨 윗장 오픈 + tableau[i].add(card); + } + } + // 남은 카드는 스톡으로 + stock = deck.sublist(cardIdx); + } + + // [공격 받음] 상대가 세트를 완성하면 내 태블로 각 열에 카드 1장씩 추가됨 + void _onAttacked(String attackerName) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("⚔️ $attackerName님의 공격! 카드가 추가됩니다!"), backgroundColor: Colors.red), + ); + SoundManager().playSfx(SoundKey.wrong); + + setState(() { + // 방해 카드 생성 (무작위) + for (int i = 0; i < 10; i++) { + final badCard = SpiderCard( + id: 9999 + Random().nextInt(10000), + suit: SpiderSuit.spade, + rank: Random().nextInt(13) + 1, + isFaceUp: true + ); + tableau[i].add(badCard); + } + }); + } + + // [카드 이동] 드래그 앤 드롭 + void _onCardDrop(List movingCards, int fromColIndex, int toColIndex) { + setState(() { + _moves++; + // 1. 원래 위치에서 제거 + final fromCol = tableau[fromColIndex]; + fromCol.removeRange(fromCol.length - movingCards.length, fromCol.length); + + // 2. 뒷면 카드 뒤집기 + if (fromCol.isNotEmpty && !fromCol.last.isFaceUp) { + fromCol.last.isFaceUp = true; + } + + // 3. 새 위치에 추가 + tableau[toColIndex].addAll(movingCards); + + // 4. 세트 완성 체크 + _checkCompleteSet(toColIndex); + }); + } + + // [세트 완성 체크] K -> A 순서인지 확인 + void _checkCompleteSet(int colIndex) { + final col = tableau[colIndex]; + if (col.length < 13) return; + + // 끝에서 13장 확인 + List last13 = col.sublist(col.length - 13); + bool isComplete = true; + + // K(13) ... A(1) 순서여야 함 + for (int i = 0; i < 13; i++) { + if (last13[i].rank != 13 - i) { + isComplete = false; + break; + } + } + + if (isComplete) { + // 완성! + setState(() { + col.removeRange(col.length - 13, col.length); + foundation.add(last13); + if (col.isNotEmpty && !col.last.isFaceUp) col.last.isFaceUp = true; + }); + + SoundManager().playSfx(SoundKey.correct); + + // [공격 전송] + NetworkManager().sendMessage({'type': 'ATTACK', 'senderName': NetworkManager().me.nickname}); + + // 승리 체크 (8세트 완성) + if (foundation.length >= 8) { + final winPayload = {'type': 'GAME_WIN', 'winnerName': NetworkManager().me.nickname}; + NetworkManager().sendMessage(winPayload); + _showGameOverDialog(NetworkManager().me.nickname); + } + } + } + + // [스톡에서 카드 뽑기] + void _dealFromStock() { + if (stock.isEmpty) return; + // 빈 열이 있으면 규칙상 못 뽑게 할 수도 있으나, 여기선 허용 (캐주얼) + setState(() { + for (int i = 0; i < 10; i++) { + if (stock.isNotEmpty) { + final card = stock.removeLast(); + card.isFaceUp = true; + tableau[i].add(card); + _checkCompleteSet(i); // 운 좋게 완성될 수도 있음 + } + } + }); + } + + bool _canMove(SpiderCard topCard, SpiderCard? bottomCard) { + if (bottomCard == null) return true; // 빈 열에는 이동 가능 + // 규칙: 랭크가 1 작아야 함 (색상은 1 suit 모드라 무시) + return bottomCard.rank == topCard.rank + 1; + } + + void _showGameOverDialog(String winnerName) { + bool isMe = winnerName == NetworkManager().me.nickname; + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => AlertDialog( + title: Text(isMe ? "승리! 🎉" : "패배 😭"), + content: Text(isMe ? "축하합니다! 모든 세트를 완성했습니다." : "$winnerName 님이 먼저 완료했습니다."), + actions: [ + TextButton( + onPressed: () { Navigator.pop(context); Navigator.pop(context); }, + child: const Text("나가기"), + ) + ], + ), + ); + } + + // --- UI --- + + @override + Widget build(BuildContext context) { + final size = MediaQuery.of(context).size; + final cardWidth = (size.width - 20) / 10; // 10열 + final cardHeight = cardWidth * 1.4; + + return Scaffold( + backgroundColor: Colors.green[800], + appBar: AppBar( + title: Text("스파이더 배틀 (${foundation.length}/8)"), + backgroundColor: Colors.green[900], + elevation: 0, + ), + body: Column( + children: [ + // 1. 태블로 (카드 놓는 곳) + Expanded( + child: Stack( + children: List.generate(10, (colIndex) { + return Positioned( + left: colIndex * cardWidth + 10, + top: 10, + child: _buildTableauColumn(colIndex, cardWidth, cardHeight), + ); + }), + ), + ), + + // 2. 하단 바 (스톡 & 완성된 덱) + Container( + height: cardHeight + 20, + color: Colors.green[900], + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // 완성된 덱 표시 + Row( + children: foundation.map((_) => Padding( + padding: const EdgeInsets.only(right: 4.0), + child: SpiderCardWidget( + card: SpiderCard(id: 0, suit: SpiderSuit.spade, rank: 13, isFaceUp: true), // K표시 + width: cardWidth * 0.8, + height: cardHeight * 0.8 + ), + )).toList(), + ), + + // 스톡 (클릭 시 배분) + GestureDetector( + onTap: _dealFromStock, + child: stock.isEmpty + ? Container(width: cardWidth, height: cardHeight, decoration: BoxDecoration(border: Border.all(color: Colors.white30), borderRadius: BorderRadius.circular(4))) + : SpiderCardWidget(card: SpiderCard(id: -1, suit: SpiderSuit.spade, rank: 0), width: cardWidth, height: cardHeight), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildTableauColumn(int colIndex, double width, double height) { + final pile = tableau[colIndex]; + + // DragTarget: 이 컬럼으로 카드가 들어오는 것을 감지 + return DragTarget>( + onWillAccept: (data) { + if (data == null) return false; + final List movingCards = data['cards']; + final int fromIndex = data['fromIndex']; + if (fromIndex == colIndex) return false; // 제자리 드롭 무시 + + final SpiderCard topMoving = movingCards.first; + final SpiderCard? targetBottom = pile.isEmpty ? null : pile.last; + + return _canMove(topMoving, targetBottom); + }, + onAccept: (data) { + _onCardDrop(data['cards'], data['fromIndex'], colIndex); + }, + builder: (context, candidateData, rejectedData) { + return SizedBox( + width: width, + height: MediaQuery.of(context).size.height * 0.7, + child: Stack( + children: [ + // 빈 공간 표시 (타겟 영역 확보용) + Container(width: width, height: 100, color: Colors.transparent), + + // 쌓인 카드들 + ...List.generate(pile.length, (i) { + final card = pile[i]; + final offset = i * 25.0; // 카드 겹침 간격 + + // 드래그 가능한 카드인지 확인 (오픈되어 있고, 위 카드들과 연속된 순서인지) + bool isDraggable = card.isFaceUp; + if (isDraggable && i < pile.length - 1) { + // 내 위에 있는 카드들이 나랑 연속되어야 함 + for (int k = i; k < pile.length - 1; k++) { + if (pile[k].rank != pile[k+1].rank + 1) { + isDraggable = false; + break; + } + } + } + + Widget cardWidget = SpiderCardWidget(card: card, width: width, height: height); + + if (isDraggable) { + // 같이 움직일 카드 묶음 + final movingCards = pile.sublist(i); + + return Positioned( + top: offset, + child: Draggable>( + data: {'cards': movingCards, 'fromIndex': colIndex}, + feedback: Material( + color: Colors.transparent, + child: Column( + children: movingCards.map((c) => SpiderCardWidget(card: c, width: width, height: height)).toList(), + ), + ), + childWhenDragging: const SizedBox(), // 드래그 중엔 숨김 (또는 밑장 표시) + child: cardWidget, + ), + ); + } else { + return Positioned(top: offset, child: cardWidget); + } + }), + ], + ), + ); + }, + ); + } +} \ No newline at end of file diff --git a/packages/core/lib/game/sudoku_multi_game.dart b/packages/core/lib/game/sudoku_multi_game.dart new file mode 100644 index 0000000..797b7b9 --- /dev/null +++ b/packages/core/lib/game/sudoku_multi_game.dart @@ -0,0 +1,372 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:playwith_core/playwith_core.dart'; + +import '../model/sudoku_game_dto.dart'; +import '../widgets/sudoku_widgets.dart'; + +class SudokuMultiGame extends BaseGame { + @override + String get id => "sudoku_battle"; + @override + String get name => "스도쿠 배틀"; + @override + String get description => "먼저 완성하는 사람이 승리! 줄을 맞추면 상대를 방해합니다."; + + final StreamController _puzzleStreamController = StreamController.broadcast(); + + // --------------------------------------------------------------------------- + // [Host] 퍼즐 데이터 로딩 로직 + // --------------------------------------------------------------------------- + Future _fetchPuzzleFromApi(String difficulty) async { + const String baseUrl = "https://lunaticbum.kr"; + try { + final response = await http.get( + Uri.parse('$baseUrl/puzzle/sudoku/start?difficulty=$difficulty'), + ).timeout(const Duration(seconds: 5)); + + if (response.statusCode == 200) { + final data = jsonDecode(utf8.decode(response.bodyBytes)); + return SudokuGameDto.fromJson(data); + } + } catch (e) { + print("API 호출 실패, 더미 데이터 사용: $e"); + } + + return SudokuGameDto( + puzzleId: 0, + blockSize: 2, + question: "0034340000430300", + solution: "1234341221434321", + ); + } + + @override + void onStart() async { + super.onStart(); + + if (NetworkManager().role == NetworkRole.host) { + final int diffValue = NetworkManager().selectedGameConfig['difficulty'] ?? 1; + final puzzleData = await _fetchPuzzleFromApi(diffValue.toString()); + + final payload = { + 'type': 'GAME_DATA', + ...puzzleData.toJson(), + }; + + onMessageReceived(NetworkManager().me.id, payload); + NetworkManager().sendMessage(payload); + } + } + + @override + void onMessageReceived(String senderId, Map payload) { + if (payload['type'] == 'GAME_DATA') { + final puzzle = SudokuGameDto.fromJson(payload); + _puzzleStreamController.add(puzzle); + } + } + + @override + void onDispose() { + _puzzleStreamController.close(); + super.onDispose(); + } + + @override + Widget buildHostView(BuildContext context) => _buildGameScreen(context); + + @override + Widget buildGuestView(BuildContext context) => _buildGameScreen(context); + + Widget _buildGameScreen(BuildContext context) { + return StreamBuilder( + stream: _puzzleStreamController.stream, + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(), + SizedBox(height: 20), + Text("퍼즐을 불러오는 중입니다..."), + ], + ), + ), + ); + } + return SudokuBattleScreen(gameData: snapshot.data!, gameInstance: this); + }, + ); + } +} + +// ----------------------------------------------------------------------------- +// 게임 화면 (UI + 로직) +// ----------------------------------------------------------------------------- +class SudokuBattleScreen extends StatefulWidget { + final SudokuGameDto gameData; + final SudokuMultiGame gameInstance; + + const SudokuBattleScreen({super.key, required this.gameData, required this.gameInstance}); + + @override + State createState() => _SudokuBattleScreenState(); +} + +class _SudokuBattleScreenState extends State { + late List puzzleCells; + late List originalCells; + late List solutionCells; + late int blockSize; + late int gridSize; + + int? selectedIndex; + int? selectedNumberPad; + Set incorrectCells = {}; + final Set _completedGroups = {}; + + @override + void initState() { + super.initState(); + blockSize = widget.gameData.blockSize; + gridSize = blockSize * blockSize; + + puzzleCells = widget.gameData.question.split('').map(_charToInt).toList(); + originalCells = List.from(puzzleCells); + solutionCells = widget.gameData.solution.split('').map(_charToInt).toList(); + + NetworkManager().messageStream.listen(_handleNetworkMessage); + } + + int _charToInt(String char) { + if (char == '0') return 0; + return int.tryParse(char) ?? 0; + } + + void _handleNetworkMessage(Map payload) { + if (!mounted) return; + + if (payload['type'] == 'ATTACK') { + final attackerName = payload['senderName']; + _applyAttack(attackerName); + } else if (payload['type'] == 'GAME_WIN') { + final winnerName = payload['winnerName']; + _showGameOverDialog(winnerName); + } + } + + void _applyAttack(String attackerName) { + List myInputs = []; + for (int i = 0; i < puzzleCells.length; i++) { + if (originalCells[i] == 0 && puzzleCells[i] != 0) { + myInputs.add(i); + } + } + + if (myInputs.isNotEmpty) { + final randomIdx = myInputs[Random().nextInt(myInputs.length)]; + setState(() { + puzzleCells[randomIdx] = 0; + }); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text("⚔️ $attackerName님의 공격! 숫자가 지워졌습니다!"), + backgroundColor: Colors.redAccent, + duration: const Duration(milliseconds: 1500), + ), + ); + SoundManager().playSfx(SoundKey.wrong); + } + } + + void _onNumberTapped(int number) { + if (selectedIndex == null) return; + if (originalCells[selectedIndex!] != 0) return; + + setState(() { + puzzleCells[selectedIndex!] = number; + + if (number != solutionCells[selectedIndex!]) { + incorrectCells.add(selectedIndex!); + } else { + incorrectCells.remove(selectedIndex!); + _checkAttackTrigger(selectedIndex!); + _checkWinCondition(); + } + }); + } + + void _checkAttackTrigger(int index) { + int row = index ~/ gridSize; + int col = index % gridSize; + int blockRow = (row ~/ blockSize) * blockSize; + int blockCol = (col ~/ blockSize) * blockSize; + + if (_isGroupComplete(getRowIndices(row), "ROW_$row")) _sendAttack(); + if (_isGroupComplete(getColIndices(col), "COL_$col")) _sendAttack(); + if (_isGroupComplete(getBlockIndices(blockRow, blockCol), "BLOCK_${blockRow}_$blockCol")) _sendAttack(); + } + + bool _isGroupComplete(List indices, String groupKey) { + if (_completedGroups.contains(groupKey)) return false; + for (int idx in indices) { + if (puzzleCells[idx] == 0 || puzzleCells[idx] != solutionCells[idx]) { + return false; + } + } + _completedGroups.add(groupKey); + return true; + } + + void _sendAttack() { + final payload = { + 'type': 'ATTACK', + 'senderName': NetworkManager().me.nickname, + }; + NetworkManager().sendMessage(payload); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text("🚀 공격 발사!"), + backgroundColor: Colors.blueAccent, + duration: Duration(milliseconds: 1000), + ), + ); + SoundManager().playSfx(SoundKey.correct); + } + + void _checkWinCondition() { + if (!puzzleCells.contains(0) && incorrectCells.isEmpty) { + final payload = {'type': 'GAME_WIN', 'winnerName': NetworkManager().me.nickname}; + NetworkManager().sendMessage(payload); + _showGameOverDialog(NetworkManager().me.nickname); + } + } + + void _showGameOverDialog(String winnerName) { + bool isMe = winnerName == NetworkManager().me.nickname; + if (isMe) SoundManager().playSfx(SoundKey.win); + + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => AlertDialog( + title: Text(isMe ? "승리! 🎉" : "패배 😭"), + content: Text(isMe ? "축하합니다!" : "$winnerName 님이 승리했습니다."), + actions: [ + TextButton( + onPressed: () { + Navigator.pop(context); + Navigator.pop(context); + }, + child: const Text("나가기"), + ) + ], + ), + ); + } + + List getRowIndices(int row) => List.generate(gridSize, (i) => row * gridSize + i); + List getColIndices(int col) => List.generate(gridSize, (i) => i * gridSize + col); + List getBlockIndices(int startRow, int startCol) { + List indices = []; + for (int r = 0; r < blockSize; r++) { + for (int c = 0; c < blockSize; c++) { + indices.add((startRow + r) * gridSize + (startCol + c)); + } + } + return indices; + } + + @override + Widget build(BuildContext context) { + final Map numberCounts = {}; + for (int i = 1; i <= gridSize; i++) numberCounts[i] = 0; + for (int cell in puzzleCells) { + if (cell != 0) numberCounts[cell] = (numberCounts[cell] ?? 0) + 1; + } + + return Scaffold( + appBar: AppBar( + title: const Text("스도쿠 배틀"), + automaticallyImplyLeading: false, + actions: [ + IconButton( + icon: const Icon(Icons.exit_to_app), + onPressed: () => Navigator.pop(context), + ) + ], + ), + body: Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + // 1. 상단 빈칸 정보 + Padding( + padding: const EdgeInsets.symmetric(vertical: 10.0), + child: Text( + "남은 빈칸: ${puzzleCells.where((e)=>e==0).length}", + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18) + ), + ), + + // 2. 게임 보드 (Center와 Expanded 제거하여 상단 배치) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: SudokuBoard( + blockSize: blockSize, + cells: puzzleCells, + originalCells: originalCells, + selectedIndex: selectedIndex, + selectedNumberPad: selectedNumberPad, + incorrectCells: incorrectCells, + onCellTapped: (index) { + setState(() { + selectedIndex = index; + // [핵심] 넘버패드 선택된 상태에서 칸 누르면 -> 입력 후 즉시 포커스 해제 + if (selectedNumberPad != null) { + _onNumberTapped(selectedNumberPad!); + selectedIndex = null; // 입력했으므로 선택 해제 + } + }); + }, + ), + ), + + const Spacer(), // 남은 공간을 밀어내어 키패드를 하단으로 (또는 제거하여 바로 아래 붙일 수 있음) + + // 3. 키패드 (높이 증가) + Container( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 30), + height: 240, // [수정] 높이 확대 + color: Colors.grey[50], + child: NumberPad( + blockSize: blockSize, + numberCounts: numberCounts, + selectedNumber: selectedNumberPad, + onNumberTapped: (num) { + setState(() { + // [핵심] 칸이 선택된 상태에서 숫자 누르면 -> 입력 후 즉시 포커스 해제 + if (selectedIndex != null) { + _onNumberTapped(num); + selectedIndex = null; // 입력했으므로 선택 해제 + selectedNumberPad = null; // 모드 초기화 + } else { + // 칸 선택 없이 숫자만 누르면 '숫자 우선 모드' 토글 + selectedNumberPad = (selectedNumberPad == num) ? null : num; + } + }); + }, + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/packages/core/lib/game/tap_battle_game.dart b/packages/core/lib/game/tap_battle_game.dart new file mode 100644 index 0000000..22c3a55 --- /dev/null +++ b/packages/core/lib/game/tap_battle_game.dart @@ -0,0 +1,217 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:playwith_core/playwith_core.dart'; + +class TapBattleGame extends BaseGame { + @override + String get id => "tap_battle"; + @override + String get name => "터치 배틀"; + @override + String get description => "빠르게 눌러서 상대를 밀어내세요!"; + + // 0: Red(Host), 1: Blue(Guest) + int? _myTeam; + + @override + void onStart() { + super.onStart(); + _myTeam = NetworkManager().role == NetworkRole.host ? 0 : 1; + } + + @override + void onMessageReceived(String senderId, Map payload) { + // UI에서 처리 + } + + @override + Widget buildHostView(BuildContext context) => TapBattleScreen(myTeam: 0, gameInstance: this); + @override + Widget buildGuestView(BuildContext context) => TapBattleScreen(myTeam: 1, gameInstance: this); +} + +class TapBattleScreen extends StatefulWidget { + final int myTeam; + final TapBattleGame gameInstance; + + const TapBattleScreen({super.key, required this.myTeam, required this.gameInstance}); + + @override + State createState() => _TapBattleScreenState(); +} + +class _TapBattleScreenState extends State { + // 점수 범위: -50 ~ 50 (0이 중앙) + // Red(Host)가 누르면 +, Blue(Guest)가 누르면 - + int score = 0; + static const int maxScore = 50; + bool isGameOver = false; + + // 네트워크 과부하 방지를 위한 스로틀링 + Timer? _syncTimer; + int _localClicks = 0; // 전송 안 된 클릭 수 + + @override + void initState() { + super.initState(); + NetworkManager().messageStream.listen(_handleMessage); + + // 0.2초마다 모아서 전송 + _syncTimer = Timer.periodic(const Duration(milliseconds: 200), (timer) { + if (_localClicks != 0 && !isGameOver) { + NetworkManager().sendMessage({ + 'type': 'CLICK', + 'amount': _localClicks, + 'senderTeam': widget.myTeam + }); + _localClicks = 0; + } + }); + } + + @override + void dispose() { + _syncTimer?.cancel(); + super.dispose(); + } + + void _handleMessage(Map payload) { + if (!mounted || isGameOver) return; + + if (payload['type'] == 'CLICK') { + int amount = payload['amount']; + int team = payload['senderTeam']; + + setState(() { + if (team == 0) score += amount; + else score -= amount; + _checkWin(); + }); + } else if (payload['type'] == 'GAME_OVER') { + _finishGame(payload['winner']); + } + } + + void _onTap() { + if (isGameOver) return; + + setState(() { + if (widget.myTeam == 0) score++; + else score--; + _localClicks++; // 전송 큐에 적립 + + // 로컬에서도 즉시 승리 체크 (반응성) + _checkWin(); + }); + SoundManager().playSfx(SoundKey.click); + } + + void _checkWin() { + if (score >= maxScore) { + // Red 승리 + _sendGameOver(0); + } else if (score <= -maxScore) { + // Blue 승리 + _sendGameOver(1); + } + } + + void _sendGameOver(int winnerTeam) { + if (isGameOver) return; + isGameOver = true; + NetworkManager().sendMessage({'type': 'GAME_OVER', 'winner': winnerTeam}); + _finishGame(winnerTeam); + } + + void _finishGame(int winnerTeam) { + setState(() { isGameOver = true; }); + String msg = (winnerTeam == widget.myTeam) ? "승리! 🎉" : "패배... 💪"; + + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => AlertDialog( + title: const Text("게임 종료"), + content: Text(msg), + actions: [ + TextButton( + onPressed: () { + Navigator.pop(context); + Navigator.pop(context); + }, + child: const Text("나가기"), + ) + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + // 게이지 비율 계산 (0.0 ~ 1.0) + // score -50 => 0.0 (Blue Win) + // score 0 => 0.5 + // score 50 => 1.0 (Red Win) + double progress = (score + maxScore) / (maxScore * 2); + + return Scaffold( + appBar: AppBar(title: const Text("터치 배틀!"), centerTitle: true), + body: Column( + children: [ + // 게이지 바 + Container( + height: 60, + width: double.infinity, + color: Colors.grey[300], + child: Row( + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 100), + width: MediaQuery.of(context).size.width * progress, + height: 60, + color: Colors.redAccent, // Host + child: Align(alignment: Alignment.centerLeft, child: Padding(padding: EdgeInsets.all(8), child: Text("RED", style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold)))), + ), + Expanded( + child: Container( + height: 60, + color: Colors.blueAccent, // Guest + child: Align(alignment: Alignment.centerRight, child: Padding(padding: EdgeInsets.all(8), child: Text("BLUE", style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold)))), + ), + ), + ], + ), + ), + + const SizedBox(height: 20), + Text(widget.myTeam == 0 ? "당신은 RED팀!" : "당신은 BLUE팀!", style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + const Text("버튼을 빠르게 연타해서 상대를 밀어내세요!", style: TextStyle(color: Colors.grey)), + + const Spacer(), + + // 터치 버튼 + GestureDetector( + onTapDown: (_) => _onTap(), + child: Container( + margin: const EdgeInsets.all(30), + width: 200, + height: 200, + decoration: BoxDecoration( + color: widget.myTeam == 0 ? Colors.red : Colors.blue, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow(color: Colors.black.withOpacity(0.3), blurRadius: 10, offset: const Offset(0, 5)) + ] + ), + child: const Center( + child: Text("TAP!", style: TextStyle(color: Colors.white, fontSize: 40, fontWeight: FontWeight.bold)), + ), + ), + ), + + const Spacer(), + ], + ), + ); + } +} \ No newline at end of file diff --git a/packages/core/lib/game/yutnori_game.dart b/packages/core/lib/game/yutnori_game.dart new file mode 100644 index 0000000..11d1665 --- /dev/null +++ b/packages/core/lib/game/yutnori_game.dart @@ -0,0 +1,474 @@ +import 'dart:async'; +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:playwith_core/playwith_core.dart'; + +class YutnoriGame extends BaseGame { + @override + String get id => "yutnori"; + @override + String get name => "윷놀이"; + @override + String get description => "가족과 함께하는 민속놀이"; + + // [수정] 필수 메서드 구현 추가 + @override + void onMessageReceived(String senderId, Map payload) { + // BaseGame의 기본 핸들러입니다. + // 실제 게임 로직은 YutnoriScreen 내부의 NetworkManager 리스너에서 처리하므로 + // 여기서는 비워두어도 무방합니다. + } + + @override + Widget buildHostView(BuildContext context) => YutnoriScreen(myTeam: 0, gameInstance: this); // 0: Red + @override + Widget buildGuestView(BuildContext context) => YutnoriScreen(myTeam: 1, gameInstance: this); // 1: Blue +} + +class YutnoriScreen extends StatefulWidget { + final int myTeam; // 0: Red(Host), 1: Blue(Guest) + final YutnoriGame gameInstance; + + const YutnoriScreen({super.key, required this.myTeam, required this.gameInstance}); + + @override + State createState() => _YutnoriScreenState(); +} + +class _YutnoriScreenState extends State { + // 게임 상태 + int currentTurn = 0; // 0: Red, 1: Blue + List yutResultQueue = []; // 던진 윷 결과 저장 (윷/모 나오면 계속 던짐) + bool canThrow = true; // 던질 수 있는 상태인가? + + // 말 위치 (각 팀 4개) + // 0: 시작 전, 1~20: 바깥 트랙, 21~25: 대각선1, 26~30: 대각선2, 99: 골인 + List> tokens = [ + [0, 0, 0, 0], // Team 0 (Red) + [0, 0, 0, 0] // Team 1 (Blue) + ]; + + String infoMessage = "게임을 시작합니다!"; + + @override + void initState() { + super.initState(); + NetworkManager().messageStream.listen(_handleMessage); + } + + void _handleMessage(Map payload) { + if (!mounted) return; + if (payload['type'] == 'THROW') { + final int result = payload['result']; + final String msg = payload['message']; + setState(() { + yutResultQueue.add(result); + infoMessage = msg; + // 윷(4)이나 모(5)가 아니면 턴 넘기기 대기 (말 이동 후 넘김) + if (result < 4) canThrow = false; + }); + } else if (payload['type'] == 'MOVE') { + final int team = payload['team']; + final int tokenIdx = payload['tokenIdx']; + final int targetPos = payload['targetPos']; + final bool extraTurn = payload['extraTurn']; + + setState(() { + // 말 이동 및 잡기 처리 + _executeMove(team, tokenIdx, targetPos); + + // 사용한 윷 결과 제거 (FIFO) + if (yutResultQueue.isNotEmpty) yutResultQueue.removeAt(0); + + if (extraTurn) { + infoMessage = "한 번 더 하세요!"; + currentTurn = team; + canThrow = true; + } else if (yutResultQueue.isNotEmpty) { + infoMessage = "남은 패로 이동하세요."; + currentTurn = team; + canThrow = false; + } else { + // 턴 종료 + currentTurn = 1 - currentTurn; + canThrow = true; + infoMessage = "${currentTurn == 0 ? 'Red' : 'Blue'} 팀 차례입니다."; + } + }); + } else if (payload['type'] == 'WIN') { + _showWinDialog(payload['team']); + } + } + + // --------------------------------------------------------------------------- + // 로직: 윷 던지기 + // --------------------------------------------------------------------------- + void _onThrowYut() { + if (currentTurn != widget.myTeam) return; + if (!canThrow) return; + + // 확률 기반 윷 던지기 (도:1, 개:2, 걸:3, 윷:4, 모:5) + // 단순화된 확률: 개(35%), 걸(30%), 도(15%), 윷(10%), 모(10%) + int rand = Random().nextInt(100); + int result = 1; + String name = "도"; + if (rand < 35) { result = 2; name = "개"; } + else if (rand < 65) { result = 3; name = "걸"; } + else if (rand < 80) { result = 1; name = "도"; } + else if (rand < 90) { result = 4; name = "윷"; } + else { result = 5; name = "모"; } + + final msg = "${widget.myTeam == 0 ? 'Red' : 'Blue'}팀: $name!"; + + NetworkManager().sendMessage({ + 'type': 'THROW', + 'result': result, + 'message': msg + }); + + // 로컬 반영 + setState(() { + yutResultQueue.add(result); + infoMessage = msg; + if (result < 4) canThrow = false; // 윷/모 아니면 던지기 끝 + }); + } + + // --------------------------------------------------------------------------- + // 로직: 말 이동 + // --------------------------------------------------------------------------- + void _onTokenTap(int tokenIdx) { + if (currentTurn != widget.myTeam) return; + if (yutResultQueue.isEmpty) return; // 이동할 패가 없음 + + // 대기 중인 첫 번째 패 사용 + int moveAmount = yutResultQueue.first; + int currentPos = tokens[widget.myTeam][tokenIdx]; + + if (currentPos == 99) return; // 이미 골인한 말 + + // 이동 경로 계산 + int nextPos = _calculateNextPos(currentPos, moveAmount); + + // 잡기 여부 확인 (상대방 말이 있는가?) + bool catchOpponent = false; + int opponentTeam = 1 - widget.myTeam; + if (nextPos != 99) { // 골인이 아닐 때만 + for (int i = 0; i < 4; i++) { + if (tokens[opponentTeam][i] == nextPos) { + catchOpponent = true; + break; + } + } + } + + // 윷/모가 나왔거나 상대를 잡았으면 한 번 더 + bool extraTurn = (moveAmount >= 4) || catchOpponent; + + // 이동 실행 및 전송 + _executeMove(widget.myTeam, tokenIdx, nextPos); + + NetworkManager().sendMessage({ + 'type': 'MOVE', + 'team': widget.myTeam, + 'tokenIdx': tokenIdx, + 'targetPos': nextPos, + 'extraTurn': extraTurn + }); + + // 로컬 상태 업데이트 (전송 후 즉시 반영) + setState(() { + yutResultQueue.removeAt(0); + if (extraTurn) { + infoMessage = catchOpponent ? "잡았다! 한 번 더!" : "한 번 더!"; + canThrow = true; + } else if (yutResultQueue.isNotEmpty) { + infoMessage = "남은 패로 이동하세요."; + canThrow = false; + } else { + currentTurn = 1 - currentTurn; + canThrow = true; + infoMessage = "${currentTurn == 0 ? 'Red' : 'Blue'} 팀 차례입니다."; + } + }); + + _checkWin(); + } + + void _executeMove(int team, int idx, int target) { + // 상대방 말 잡기 구현 + if (target != 99) { + int opponent = 1 - team; + for (int i = 0; i < 4; i++) { + if (tokens[opponent][i] == target) { + tokens[opponent][i] = 0; // 시작점으로 보냄 + } + } + } + tokens[team][idx] = target; + } + + // 이동 경로 하드코딩 + int _calculateNextPos(int current, int step) { + if (current == 0) return step; + + int next = current; + for (int i = 0; i < step; i++) { + if (next == 99) break; // 이미 골인 + + // 특수 분기점 + if (next == 5) next = 21; // 우하단 코너 -> 대각선 진입 + else if (next == 10) next = 26; // 좌하단 코너 -> 대각선 진입 + else if (next == 23) next = 24; // 대각선1 -> 중앙 + else if (next == 24) next = 28; // 중앙 -> 수직상승 (단순화: 중앙에선 무조건 출구방향) + else if (next == 25) next = 15; // 대각선1 끝 -> 외곽 + else if (next == 27) next = 24; // 대각선2 -> 중앙 + else if (next == 30) next = 20; // 대각선2 끝 -> 외곽 (사실상 1이 됨) + else if (next == 20) next = 99; // 골인 + else if (next == 29) next = 20; // 중앙직진 -> 외곽 + else next++; + } + + // 범위 초과 보정 (외곽 돌 때) + if (next > 20 && next < 21) next = 99; // 20 넘어가면 골인 + + return next; + } + + void _checkWin() { + if (tokens[widget.myTeam].every((pos) => pos == 99)) { + NetworkManager().sendMessage({'type': 'WIN', 'team': widget.myTeam}); + _showWinDialog(widget.myTeam); + } + } + + void _showWinDialog(int winnerTeam) { + bool isMe = winnerTeam == widget.myTeam; + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => AlertDialog( + title: Text(isMe ? "승리! 🎉" : "패배 😭"), + content: Text(isMe ? "축하합니다! 모든 말이 들어왔습니다." : "상대방이 먼저 들어왔습니다."), + actions: [ + TextButton( + onPressed: () { Navigator.pop(context); Navigator.pop(context); }, + child: const Text("나가기"), + ) + ], + ), + ); + } + + // --------------------------------------------------------------------------- + // UI + // --------------------------------------------------------------------------- + @override + Widget build(BuildContext context) { + final bool myTurn = currentTurn == widget.myTeam; + final Color teamColor = widget.myTeam == 0 ? Colors.redAccent : Colors.blueAccent; + + return Scaffold( + appBar: AppBar( + title: const Text("윷놀이"), + centerTitle: true, + ), + body: Column( + children: [ + // 상단 정보 + Container( + padding: const EdgeInsets.all(16), + color: myTurn ? teamColor.withOpacity(0.1) : Colors.grey[200], + width: double.infinity, + child: Column( + children: [ + Text(infoMessage, style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: myTurn ? teamColor : Colors.black)), + if (yutResultQueue.isNotEmpty) + Text("나온 패: ${_yutName(yutResultQueue)}", style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + ], + ), + ), + + // 윷판 (말판) + Expanded( + child: Center( + child: AspectRatio( + aspectRatio: 1.0, + child: LayoutBuilder( + builder: (context, constraints) { + return Stack( + children: [ + // 1. 말판 배경 그림 + CustomPaint( + size: Size(constraints.maxWidth, constraints.maxWidth), + painter: YutBoardPainter(), + ), + // 2. 말 배치 + ..._buildTokens(constraints.maxWidth, 0, Colors.red), + ..._buildTokens(constraints.maxWidth, 1, Colors.blue), + ], + ); + }, + ), + ), + ), + ), + + // 하단 컨트롤 (윷 던지기 버튼) + Padding( + padding: const EdgeInsets.all(20), + child: SizedBox( + width: double.infinity, + height: 60, + child: ElevatedButton( + onPressed: (myTurn && canThrow) ? _onThrowYut : null, + style: ElevatedButton.styleFrom( + backgroundColor: teamColor, + foregroundColor: Colors.white, + ), + child: Text(canThrow ? "윷 던지기!" : "말을 움직이세요"), + ), + ), + ), + ], + ), + ); + } + + List _buildTokens(double boardSize, int team, Color color) { + List widgets = []; + // 말이 겹쳐있으면 약간씩 빗겨서 표시 + Map posCount = {}; + + for (int i = 0; i < 4; i++) { + int pos = tokens[team][i]; + if (pos == 99) continue; // 골인한 말은 안 그림 + + // 위치 카운트 (겹침 처리) + int count = posCount[pos] ?? 0; + posCount[pos] = count + 1; + + Offset offset = _getPosOffset(pos, boardSize); + + // 겹칠 경우 오프셋 적용 + double dx = offset.dx + (count * 5); + double dy = offset.dy - (count * 5); + + // 대기 상태(0)는 하단에 별도 배치 + if (pos == 0) { + double startX = team == 0 ? 20 : boardSize - 40; + dx = startX + (i%2 * 15); + dy = boardSize - 20 - (i~/2 * 15); + } + + widgets.add(Positioned( + left: dx - 12, // 중심점 보정 + top: dy - 12, + child: GestureDetector( + onTap: () => _onTokenTap(i), + child: Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + boxShadow: const [BoxShadow(color: Colors.black38, blurRadius: 2, offset: Offset(1,1))] + ), + child: Center(child: Text("${i+1}", style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold))), + ), + ), + )); + } + return widgets; + } + + String _yutName(List queue) { + return queue.map((v) { + switch(v) { + case 1: return "도"; + case 2: return "개"; + case 3: return "걸"; + case 4: return "윷"; + case 5: return "모"; + default: return ""; + } + }).join(", "); + } + + // 말판 좌표 계산 (하드코딩된 좌표 매핑) + Offset _getPosOffset(int pos, double size) { + double padding = 40.0; + double w = size - padding * 2; + double step = w / 5; + double startX = size - padding; + double startY = size - padding; + + if (pos >= 1 && pos <= 5) return Offset(startX, startY - (pos * step)); // 우측변 ↑ + if (pos >= 6 && pos <= 10) return Offset(startX - ((pos - 5) * step), padding); // 상단변 ← + if (pos >= 11 && pos <= 15) return Offset(padding, padding + ((pos - 10) * step)); // 좌측변 ↓ + if (pos >= 16 && pos <= 20) return Offset(padding + ((pos - 15) * step), startY); // 하단변 → + + // 대각선 1 (5 -> 21...) + if (pos == 21) return Offset(startX - step, padding + step); + if (pos == 22) return Offset(startX - step*2, padding + step*2); + if (pos == 23) return Offset(startX - step*3, padding + step*3); // 중앙 직전 + + // 중앙 + if (pos == 24) return Offset(size/2, size/2); + + // 대각선 2 (10 -> 26...) + if (pos == 26) return Offset(padding + step, padding + step); + if (pos == 27) return Offset(padding + step*2, padding + step*2); + + // 중앙 이후 + if (pos == 28) return Offset(size/2, size/2 + step); // 중앙 -> 아래 + if (pos == 29) return Offset(size/2, size/2 + step*2); // 중앙 -> 아래 + + return Offset(size - padding, size - padding); // 기본값 (출발점) + } +} + +// 말판 그리기 (원형 + 대각선) +class YutBoardPainter extends CustomPainter { + @override + void paint(Canvas canvas, Size size) { + final paint = Paint()..color = Colors.black..style = PaintingStyle.stroke..strokeWidth = 2.0; + final dotPaint = Paint()..color = Colors.black12..style = PaintingStyle.fill; + final bigDotPaint = Paint()..color = Colors.black26..style = PaintingStyle.fill; + + double padding = 40.0; + double w = size.width - padding * 2; + double step = w / 5; + + // 대각선 + canvas.drawLine(Offset(padding, padding), Offset(size.width - padding, size.height - padding), paint); + canvas.drawLine(Offset(size.width - padding, padding), Offset(padding, size.height - padding), paint); + + // 점 그리기 + List dots = []; + + // 외곽 20개 + for (int i=0; i<=5; i++) dots.add(Offset(size.width - padding, size.height - padding - (i*step))); + for (int i=1; i<=5; i++) dots.add(Offset(size.width - padding - (i*step), padding)); + for (int i=1; i<=5; i++) dots.add(Offset(padding, padding + (i*step))); + for (int i=1; i<5; i++) dots.add(Offset(padding + (i*step), size.height - padding)); + + // 대각선 + dots.add(Offset(size.width/2, size.height/2)); // 중앙 + + for (var dot in dots) { + canvas.drawCircle(dot, 8.0, dotPaint); + canvas.drawCircle(dot, 8.0, paint); + } + + // 코너 강조 + canvas.drawCircle(Offset(size.width - padding, size.height - padding), 12, bigDotPaint); // 출발 + canvas.drawCircle(Offset(size.width - padding, padding), 12, bigDotPaint); + canvas.drawCircle(Offset(padding, padding), 12, bigDotPaint); + canvas.drawCircle(Offset(size.width/2, size.height/2), 12, bigDotPaint); // 중앙 + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} \ No newline at end of file diff --git a/packages/core/lib/manager/settings_manager.dart b/packages/core/lib/manager/settings_manager.dart index 08f5e2f..affbb9c 100644 --- a/packages/core/lib/manager/settings_manager.dart +++ b/packages/core/lib/manager/settings_manager.dart @@ -29,7 +29,8 @@ class SettingsNotifier with ChangeNotifier { static const String _keyThemeColor = 'theme_color'; static const String _keyDarkMode = 'is_dark_mode'; static const String _keyFontScale = 'font_scale'; - static const String _keyProfileImage = 'profile_image_base64'; // [추가] 키 + static const String _keyProfileImage = 'profile_image_base64'; + static const String _keyShowDebug = 'show_debug_log'; // [추가] 키 // --- 상태 변수 (State) --- @@ -37,8 +38,9 @@ class SettingsNotifier with ChangeNotifier { int _avatarIndex = 0; String _themeColorName = 'Blue'; bool _isDarkMode = false; - double _fontScale = 1.0; // 1.0 = 기본, 0.8 = 작게, 1.5 = 크게 - String? _profileImageBase64; // [추가] 상태 변수 + double _fontScale = 1.0; + String? _profileImageBase64; + bool _isShowDebugLog = false; // [추가] 디버그 로그 표시 여부 (기본값 false) // --- Getters --- String get nickname => _nickname; @@ -46,11 +48,11 @@ class SettingsNotifier with ChangeNotifier { String get themeColorName => _themeColorName; bool get isDarkMode => _isDarkMode; double get fontScale => _fontScale; - String? get profileImageBase64 => _profileImageBase64; // Getter 추가 + String? get profileImageBase64 => _profileImageBase64; + bool get isShowDebugLog => _isShowDebugLog; // [추가] Getter MaterialColor get currentColor => appColors[_themeColorName] ?? Colors.blue; - // 테마 데이터 생성 (Main에서 사용) ThemeData get currentTheme { final base = ThemeData( useMaterial3: true, @@ -60,8 +62,6 @@ class SettingsNotifier with ChangeNotifier { ), brightness: _isDarkMode ? Brightness.dark : Brightness.light, ); - - // 폰트 사이즈 적용 안된 버전 반환 (TextTheme은 Builder에서 MediaQuery로 적용하는 게 더 깔끔함) return base; } @@ -76,12 +76,11 @@ class SettingsNotifier with ChangeNotifier { _themeColorName = prefs.getString(_keyThemeColor) ?? 'Blue'; _isDarkMode = prefs.getBool(_keyDarkMode) ?? false; _fontScale = prefs.getDouble(_keyFontScale) ?? 1.0; - _profileImageBase64 = prefs.getString(_keyProfileImage); // 로드 + _profileImageBase64 = prefs.getString(_keyProfileImage); + _isShowDebugLog = prefs.getBool(_keyShowDebug) ?? false; // [추가] 로드 notifyListeners(); } - // 프로필 설정 - // [수정] 프로필 텍스트/인덱스 설정 Future setProfile(String nick, int avatarIdx) async { _nickname = nick; _avatarIndex = avatarIdx; @@ -91,15 +90,13 @@ class SettingsNotifier with ChangeNotifier { await prefs.setInt(_keyAvatarIdx, avatarIdx); } - // [추가] 프로필 이미지 설정 (500x500 리사이징) Future pickProfileImage() async { final picker = ImagePicker(); - // maxWidth/maxHeight를 지정하면 알아서 리사이징 해줌 (비율 유지) final XFile? image = await picker.pickImage( source: ImageSource.gallery, maxWidth: 500, maxHeight: 500, - imageQuality: 70, // 용량 최적화 + imageQuality: 70, ); if (image != null) { @@ -114,7 +111,6 @@ class SettingsNotifier with ChangeNotifier { } } - // [추가] 프로필 이미지 삭제 (기본 아바타로 복귀) Future clearProfileImage() async { _profileImageBase64 = null; notifyListeners(); @@ -122,7 +118,6 @@ class SettingsNotifier with ChangeNotifier { await prefs.remove(_keyProfileImage); } - // 테마 색상 설정 Future setThemeColor(String colorName) async { if (!appColors.containsKey(colorName)) return; _themeColorName = colorName; @@ -131,7 +126,6 @@ class SettingsNotifier with ChangeNotifier { await prefs.setString(_keyThemeColor, colorName); } - // 다크 모드 토글 Future toggleDarkMode(bool value) async { _isDarkMode = value; notifyListeners(); @@ -139,11 +133,18 @@ class SettingsNotifier with ChangeNotifier { await prefs.setBool(_keyDarkMode, value); } - // 폰트 크기 설정 Future setFontScale(double scale) async { - _fontScale = scale.clamp(0.8, 2.0); // 최소 0.8배 ~ 최대 2.0배 제한 + _fontScale = scale.clamp(0.8, 2.0); notifyListeners(); final prefs = await SharedPreferences.getInstance(); await prefs.setDouble(_keyFontScale, _fontScale); } + + // [추가] 디버그 로그 토글 함수 + Future toggleDebugLog(bool value) async { + _isShowDebugLog = value; + notifyListeners(); + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_keyShowDebug, value); + } } \ No newline at end of file diff --git a/packages/core/lib/model/game_info.dart b/packages/core/lib/model/game_info.dart new file mode 100644 index 0000000..1d12ac1 --- /dev/null +++ b/packages/core/lib/model/game_info.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; + +class GameInfo { + final String id; + final String name; + final String description; + final IconData icon; + final bool isSinglePlayerSupported; // 싱글 플레이 지원 여부 + + const GameInfo({ + required this.id, + required this.name, + required this.description, + required this.icon, + this.isSinglePlayerSupported = false, + }); +} + +class AppGames { + static const List games = [ + GameInfo( + id: 'quiz_mix', + name: 'OX 서바이벌', + description: '최후의 1인이 될 때까지!\n다함께 푸는 퀴즈 서바이벌', + icon: Icons.quiz, + isSinglePlayerSupported: true, + ), + GameInfo( + id: 'sudoku_battle', + name: '스도쿠 배틀', + description: '먼저 완성하면 승리!\n상대를 방해하며 퍼즐을 푸세요.', + icon: Icons.grid_on, + isSinglePlayerSupported: true, // 연습 모드 가능 + ), + // 추후 추가 예정 게임들 (비활성화 상태로 표시하거나 주석 처리) + GameInfo( + id: 'spider_battle', + name: '스파이더 카드', + description: '카드 정렬의 달인을 찾아라!', + icon: Icons.style, + isSinglePlayerSupported: true, // 연습 모드 가능 + ), + GameInfo( + id: 'omok', + name: '오목', + description: '먼저 5줄을 만들면 승리!\n흑백의 치열한 두뇌 싸움', + icon: Icons.circle_outlined, + isSinglePlayerSupported: false, // 1:1 전용 + ), + // [추가] 장기 + GameInfo( + id: 'janggi', + name: '장기', + description: '한국 전통 보드게임\n초(楚)와 한(漢)의 승부', + icon: Icons.games, + isSinglePlayerSupported: false, // 1:1 전용 + ), + GameInfo( + id: 'yutnori', + name: '윷놀이', + description: '던져라 윷! 잡아라 말!\n역전의 드라마 명절 게임', + icon: Icons.kebab_dining, // 윷가락과 비슷한 아이콘 사용 + isSinglePlayerSupported: false, + ), + GameInfo( + id: 'memory_battle', + name: '그림 찾기', + description: '기억력 대결!\n짝을 더 많이 찾는 사람이 승리', + icon: Icons.flip, + isSinglePlayerSupported: false, + ), + // [추가] 밸런스 게임 + GameInfo( + id: 'balance_game', + name: '밸런스 게임', + description: '우리는 천생연분?\n동시에 같은 답을 골라보세요!', + icon: Icons.favorite, + isSinglePlayerSupported: false, + ), + // [추가] 터치 배틀 + GameInfo( + id: 'tap_battle', + name: '터치 배틀', + description: '단순 무식 스피드 대결!\n누가 더 빨리 누를까?', + icon: Icons.touch_app, + isSinglePlayerSupported: false, + ), + ]; + + static GameInfo getById(String id) { + return games.firstWhere((g) => g.id == id, orElse: () => games.first); + } +} \ No newline at end of file diff --git a/packages/core/lib/model/quiz_model.dart b/packages/core/lib/model/quiz_model.dart new file mode 100644 index 0000000..aac55e6 --- /dev/null +++ b/packages/core/lib/model/quiz_model.dart @@ -0,0 +1,101 @@ +enum QuizType { text, image } + +class QuizItem { + final QuizType type; + final String category; // [추가] 카테고리 + final String question; + final String answer; + final List options; + + QuizItem({ + required this.type, + required this.category, // [추가] + required this.question, + required this.answer, + required this.options, + }); + + Map toJson() => { + 'type': type.name, + 'category': category, // [추가] + 'question': question, + 'answer': answer, + 'options': options, + }; + + factory QuizItem.fromJson(Map json) { + return QuizItem( + type: json['type'] == 'image' ? QuizType.image : QuizType.text, + category: json['category'] ?? '기타', // [추가] 없을 경우 대비 + question: json['question'], + answer: json['answer'], + options: List.from(json['options'] ?? []), + ); + } +} + +class QuizSet { + static List getStandard50() { + return [ + // 1~10: 믹스 + QuizItem(type: QuizType.text, category: "상식", question: "사과는 영어로 Apple이다.", answer: "O", options: ["O", "X"]), + QuizItem(type: QuizType.text, category: "동물", question: "북극곰의 피부색은 흰색이다.", answer: "X", options: ["O", "X"]), + QuizItem(type: QuizType.text, category: "동물", question: "돌고래는 '어류(물고기)'다.", answer: "X", options: ["O", "X"]), + QuizItem(type: QuizType.text, category: "역사", question: "임진왜란이 일어난 해는?", answer: "1592년", options: ["1392년", "1492년", "1592년", "1950년"]), + QuizItem(type: QuizType.text, category: "넌센스", question: "왕이 넘어지면?", answer: "킹콩", options: ["왕콩", "킹콩", "전하", "꽈당"]), + QuizItem(type: QuizType.text, category: "속담", question: "가는 말이 고와야 [ ? ]가 곱다.", answer: "오는 말", options: ["오는 말", "가는 발", "너의 말", "우리 말"]), + QuizItem(type: QuizType.text, category: "수학", question: "5 + 5 × 5 = ?", answer: "30", options: ["25", "30", "50", "10"]), + QuizItem(type: QuizType.text, category: "수도", question: "미국의 수도는 어디일까요?", answer: "워싱턴 D.C.", options: ["뉴욕", "LA", "워싱턴 D.C.", "시카고"]), + QuizItem(type: QuizType.text, category: "넌센스", question: "세상에서 가장 뜨거운 바다는?", answer: "열바다", options: ["불바다", "열바다", "사랑해", "동해"]), + QuizItem(type: QuizType.text, category: "기타", question: "개발자님은 이 앱을 완성할 수 있다!", answer: "O", options: ["O", "X"]), + + // 11~20: 동물 + QuizItem(type: QuizType.text, category: "동물", question: "낙지의 심장은 3개다.", answer: "O", options: ["O", "X"]), + QuizItem(type: QuizType.text, category: "동물", question: "펭귄은 북극에 산다.", answer: "X", options: ["O", "X"]), + QuizItem(type: QuizType.text, category: "동물", question: "상어는 부레가 없다.", answer: "O", options: ["O", "X"]), + QuizItem(type: QuizType.text, category: "동물", question: "토끼는 눈을 뜨고 잔다.", answer: "O", options: ["O", "X"]), + QuizItem(type: QuizType.text, category: "동물", question: "기린의 목뼈 개수는 사람보다 훨씬 많다.", answer: "X", options: ["O", "X"]), + QuizItem(type: QuizType.text, category: "동물", question: "금붕어의 기억력은 3초다.", answer: "X", options: ["O", "X"]), + QuizItem(type: QuizType.text, category: "동물", question: "달팽이도 이빨이 있다.", answer: "O", options: ["O", "X"]), + QuizItem(type: QuizType.text, category: "동물", question: "뱀은 뒤로 갈 수 있다.", answer: "X", options: ["O", "X"]), + QuizItem(type: QuizType.text, category: "동물", question: "고양이는 단맛을 느끼지 못한다.", answer: "O", options: ["O", "X"]), + QuizItem(type: QuizType.text, category: "동물", question: "지구에서 가장 큰 동물은 코끼리다.", answer: "X", options: ["O", "X"]), + + // 21~30: 수도 + QuizItem(type: QuizType.text, category: "수도", question: "호주의 수도는?", answer: "캔버라", options: ["시드니", "멜버른", "캔버라", "퍼스"]), + QuizItem(type: QuizType.text, category: "수도", question: "캐나다의 수도는?", answer: "오타와", options: ["토론토", "밴쿠버", "몬트리올", "오타와"]), + QuizItem(type: QuizType.text, category: "수도", question: "베트남의 수도는?", answer: "하노이", options: ["호치민", "하노이", "다낭", "나트랑"]), + QuizItem(type: QuizType.text, category: "수도", question: "터키(튀르키예)의 수도는?", answer: "앙카라", options: ["이스탄불", "앙카라", "이즈미르", "안탈리아"]), + QuizItem(type: QuizType.text, category: "수도", question: "브라질의 수도는?", answer: "브라질리아", options: ["상파울루", "리우데자네이루", "브라질리아", "살바도르"]), + QuizItem(type: QuizType.text, category: "수도", question: "스페인의 수도는?", answer: "마드리드", options: ["바르셀로나", "마드리드", "세비야", "발렌시아"]), + QuizItem(type: QuizType.text, category: "수도", question: "독일의 수도는?", answer: "베를린", options: ["뮌헨", "프랑크푸르트", "베를린", "함부르크"]), + QuizItem(type: QuizType.text, category: "수도", question: "이집트의 수도는?", answer: "카이로", options: ["카이로", "알렉산드리아", "룩소르", "아스완"]), + QuizItem(type: QuizType.text, category: "수도", question: "인도의 수도는?", answer: "뉴델리", options: ["뭄바이", "뉴델리", "방갈로르", "콜카타"]), + QuizItem(type: QuizType.text, category: "수도", question: "스위스의 수도는?", answer: "베른", options: ["취리히", "제네바", "베른", "바젤"]), + + // 31~40: 넌센스 + QuizItem(type: QuizType.text, category: "넌센스", question: "세상에서 가장 추운 바다는?", answer: "썰렁해", options: ["동해", "썰렁해", "북극해", "냉해"]), + QuizItem(type: QuizType.text, category: "넌센스", question: "차가 울면?", answer: "잉카", options: ["엉엉", "부릉부릉", "잉카", "흑흑"]), + QuizItem(type: QuizType.text, category: "넌센스", question: "반성문을 영어로 하면?", answer: "글로벌", options: ["쏘리", "글로벌", "미스테이크", "리포트"]), + QuizItem(type: QuizType.text, category: "넌센스", question: "딸기가 도망가면?", answer: "딸기쨈", options: ["딸기시럽", "딸기주스", "딸기쨈", "딸기런"]), + QuizItem(type: QuizType.text, category: "넌센스", question: "우유가 아프면?", answer: "앙팡", options: ["서울우유", "앙팡", "매일우유", "아야"]), + QuizItem(type: QuizType.text, category: "넌센스", question: "세상에서 가장 가난한 왕은?", answer: "최저임금", options: ["세종대왕", "최저임금", "버거킹", "제왕"]), + QuizItem(type: QuizType.text, category: "넌센스", question: "비가 1시간 동안 내리면?", answer: "추적60분", options: ["장마", "소나기", "추적60분", "비와이"]), + QuizItem(type: QuizType.text, category: "넌센스", question: "도둑이 훔친 돈을 영어로?", answer: "슬그머니", options: ["머니머니", "슬그머니", "스틸머니", "블랙머니"]), + QuizItem(type: QuizType.text, category: "넌센스", question: "오리가 얼면?", answer: "언덕", options: ["빙판", "언덕", "동동", "꽥꽥"]), + QuizItem(type: QuizType.text, category: "넌센스", question: "전주비빔밥보다 맛있는 비빔밥은?", answer: "이번주비빔밥", options: ["돌솥비빔밥", "산채비빔밥", "이번주비빔밥", "육회비빔밥"]), + + // 41~50: 상식 + QuizItem(type: QuizType.text, category: "상식", question: "피카소의 국적은?", answer: "스페인", options: ["프랑스", "이탈리아", "스페인", "독일"]), + QuizItem(type: QuizType.text, category: "역사", question: "대한민국 임시정부가 수립된 연도는?", answer: "1919년", options: ["1910년", "1919년", "1945년", "1948년"]), + QuizItem(type: QuizType.text, category: "수학", question: "원주율(π)의 근사값은?", answer: "3.14", options: ["3.14", "3.15", "3.12", "3.16"]), + QuizItem(type: QuizType.text, category: "상식", question: "축구 경기 한 팀의 선수는 몇 명인가?", answer: "11명", options: ["9명", "10명", "11명", "12명"]), + QuizItem(type: QuizType.text, category: "상식", question: "세계에서 가장 인구가 많은 나라는? (2023년 기준)", answer: "인도", options: ["중국", "미국", "인도", "인도네시아"]), + QuizItem(type: QuizType.text, category: "상식", question: "비빔밥에 들어가지 않는 것은?", answer: "초콜릿", options: ["고추장", "참기름", "밥", "초콜릿"]), + QuizItem(type: QuizType.text, category: "상식", question: "다음 중 발효 식품이 아닌 것은?", answer: "두부", options: ["김치", "된장", "요거트", "두부"]), + QuizItem(type: QuizType.text, category: "상식", question: "음악의 아버지는 누구인가?", answer: "바흐", options: ["모차르트", "베토벤", "바흐", "슈베르트"]), + QuizItem(type: QuizType.text, category: "상식", question: "해리포터가 다니는 마법 학교 이름은?", answer: "호그와트", options: ["호그와트", "아즈카반", "그리핀도르", "슬리데린"]), + QuizItem(type: QuizType.text, category: "기타", question: "마지막 문제입니다. 개발자가 좋아하는 요일은?", answer: "금요일", options: ["월요일", "수요일", "목요일", "금요일"]), + ]; + } +} \ No newline at end of file diff --git a/packages/core/lib/model/spider_model.dart b/packages/core/lib/model/spider_model.dart new file mode 100644 index 0000000..5d60b21 --- /dev/null +++ b/packages/core/lib/model/spider_model.dart @@ -0,0 +1,37 @@ +enum SpiderSuit { spade, heart, club, diamond } + +class SpiderCard { + final int id; + final SpiderSuit suit; + final int rank; + bool isFaceUp; + + SpiderCard({ + required this.id, + required this.suit, + required this.rank, + this.isFaceUp = false, + }); + + // 랭크: 1(A) ~ 13(K) + String get rankText { + switch (rank) { + case 1: return 'A'; + case 11: return 'J'; + case 12: return 'Q'; + case 13: return 'K'; + default: return rank.toString(); + } + } + + bool get isRed => suit == SpiderSuit.heart || suit == SpiderSuit.diamond; + + String get suitSymbol { + switch (suit) { + case SpiderSuit.spade: return '♠'; + case SpiderSuit.heart: return '♥'; + case SpiderSuit.club: return '♣'; + case SpiderSuit.diamond: return '♦'; + } + } +} \ No newline at end of file diff --git a/packages/core/lib/model/sudoku_game_dto.dart b/packages/core/lib/model/sudoku_game_dto.dart new file mode 100644 index 0000000..97e68d3 --- /dev/null +++ b/packages/core/lib/model/sudoku_game_dto.dart @@ -0,0 +1,33 @@ +class SudokuGameDto { + final int puzzleId; + final String question; + final String solution; + final int blockSize; + final int gridSize; + + SudokuGameDto({ + required this.puzzleId, + required this.question, + required this.solution, + required this.blockSize, + }) : gridSize = blockSize * blockSize; + + factory SudokuGameDto.fromJson(Map json) { + int bs = json['blockSize'] ?? 3; + return SudokuGameDto( + puzzleId: json['puzzleId'] ?? 0, + question: json['question'] ?? '', + solution: json['solution'] ?? '', + blockSize: bs, + ); + } + + Map toJson() { + return { + 'puzzleId': puzzleId, + 'question': question, + 'solution': solution, + 'blockSize': blockSize, + }; + } +} \ No newline at end of file diff --git a/packages/core/lib/network/network_manager.dart b/packages/core/lib/network/network_manager.dart index e67e7b4..b353cf5 100644 --- a/packages/core/lib/network/network_manager.dart +++ b/packages/core/lib/network/network_manager.dart @@ -7,11 +7,12 @@ 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'; +import '../manager/notification_manager.dart'; enum NetworkRole { none, host, guest } @@ -26,9 +27,7 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver { // ------------------------------------------------------------------------ // 상수 설정 // ------------------------------------------------------------------------ - // [핵심] 패킷 구분자 (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; @@ -47,8 +46,6 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver { final Map _connectedGuests = {}; final List guestList = []; - - // [버퍼] 소켓별로 들어오다 만 데이터를 저장 final Map _packetBuffers = {}; BonsoirService? _bonsoirService; @@ -66,21 +63,21 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver { DateTime? _lastPongTime; bool _isReconnecting = false; + // [추가] 현재 선택된 게임 ID 및 설정 + String selectedGameId = 'quiz_mix'; + Map selectedGameConfig = {}; // 난이도 등 저장 + // ------------------------------------------------------------------------ // 초기화 // ------------------------------------------------------------------------ - void initialize({ - required String nickname, - String? profileImage, // [추가] 선택적 파라미터 - }) { + 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, - profileImageBase64: profileImage, // [적용] + profileImageBase64: profileImage ); _log("초기화 완료: ${me.nickname}"); } @@ -103,6 +100,14 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver { // ------------------------------------------------------------------------ // 레디 시스템 // ------------------------------------------------------------------------ + + // [수정] 게임 선택 함수 (Config 추가) + void selectGame(String gameId, {Map? config}) { + selectedGameId = gameId; + selectedGameConfig = config ?? {}; + notifyListeners(); + } + void toggleReady() { me = me.copyWith(isReady: !me.isReady); notifyListeners(); @@ -128,7 +133,12 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver { if (allGuestsReady) { _log("🚀 전원 준비 완료! 3초 후 게임 시작..."); Future.delayed(const Duration(seconds: 1), () { - final startPayload = {'type': 'GAME_START', 'gameId': 'quiz_ox'}; + // [수정] 게임 시작 패킷에 Config 포함 + final startPayload = { + 'type': 'GAME_START', + 'gameId': selectedGameId, + 'config': selectedGameConfig + }; sendMessage(startPayload); _messageController.add(startPayload); _resetAllReadyState(); @@ -174,7 +184,6 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver { await _bonsoirBroadcast!.start(); await MediaManager().initialize(roomName); - _startHeartbeat(); notifyListeners(); @@ -187,8 +196,12 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver { void _handleNewGuest(Socket client) { _log("🎉 연결됨: ${client.remoteAddress.address}"); _connectedGuests[client] = null; - _packetBuffers[client] = ""; // 버퍼 초기화 + _packetBuffers[client] = ""; + final myHandshake = {'type': 'HANDSHAKE', 'payload': me.toJson()}; + final jsonString = jsonEncode(myHandshake); + client.add(utf8.encode('$jsonString$PACKET_DELIMITER')); + client.listen( (Uint8List data) => _onDataReceived(client, data), onError: (e) => _removeGuest(client), @@ -243,6 +256,32 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver { return controller.stream; } + // [수정] 싱글 모드 시작 시 Config 추가 + Future startSoloMode(String gameId, {Map? 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 joinRoom(String ip, int port) async { if (role != NetworkRole.guest) stopNetwork(force: true); role = NetworkRole.guest; @@ -254,7 +293,7 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver { _clientSocket = await Socket.connect(ip, port, timeout: const Duration(seconds: 5)); _log("✅ 접속 성공!"); - _packetBuffers[_clientSocket!] = ""; // 내 버퍼 초기화 + _packetBuffers[_clientSocket!] = ""; sendMessage({'type': 'HANDSHAKE', 'payload': me.toJson()}); @@ -279,7 +318,7 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver { } // ------------------------------------------------------------------------ - // 데이터 송수신 (버퍼링 및 구분자 로직 강화) + // 데이터 송수신 // ------------------------------------------------------------------------ void sendPacket(PlayPacket packet) { sendMessage(packet.toJson()); @@ -289,8 +328,6 @@ 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') { _log("📤 전송: [CHAT]"); @@ -301,7 +338,6 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver { } } - // [핵심] 메시지 뒤에 고유 구분자를 붙여서 전송 final fullMessage = '$jsonString$PACKET_DELIMITER'; final List data = utf8.encode(fullMessage); @@ -316,30 +352,17 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver { void _onDataReceived(Socket socket, Uint8List data) { try { - // 1. 기존 버퍼 가져오기 String buffer = _packetBuffers[socket] ?? ""; - // 2. 새 데이터 추가 buffer += utf8.decode(data, allowMalformed: true); - // 3. 구분자(PACKET_DELIMITER)를 기준으로 메시지 추출 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); - } + if (msg.trim().isNotEmpty) _processMessage(socket, msg); } - - // 4. 남은 찌꺼기(다음 패킷의 일부)를 다시 버퍼에 저장 _packetBuffers[socket] = buffer; - } catch (e) { _log("데이터 수신 에러: $e"); } @@ -361,7 +384,11 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver { if (jsonMap['type'] == 'HANDSHAKE') { final guestInfo = UserInfo.fromJson(jsonMap['payload']); - _connectedGuests[socket] = guestInfo; + + if (role == NetworkRole.host) { + _connectedGuests[socket] = guestInfo; + } + guestList.removeWhere((u) => u.id == guestInfo.id); guestList.add(guestInfo); notifyListeners(); @@ -386,6 +413,13 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver { } if (jsonMap['type'] == 'GAME_START') { + if (jsonMap['gameId'] != null) { + selectedGameId = jsonMap['gameId']; + } + // [추가] Config 동기화 + if (jsonMap['config'] != null) { + selectedGameConfig = jsonMap['config']; + } _resetAllReadyState(); _messageController.add(jsonMap); return; @@ -410,52 +444,23 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver { } } - // ------------------------------------------------------------------------ - // 연결 관리 - // ------------------------------------------------------------------------ void _handleConnectionLost(dynamic reason) { if (role != NetworkRole.guest) return; - - _log("⚠️ 연결 끊김: $reason"); - _clientSocket?.destroy(); _clientSocket = null; - 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 _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) { @@ -499,10 +504,7 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver { void stopNetwork({bool force = false}) { if (!force && _disconnectWaitTimer != null) return; - - _log("🛑 종료"); MediaManager().cleanup(); - _heartbeatTimer?.cancel(); _disconnectWaitTimer?.cancel(); _disconnectWaitTimer = null; @@ -510,12 +512,10 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver { _bonsoirDiscovery?.stop(); _serverSocket?.close(); _clientSocket?.close(); - for (var s in _connectedGuests.keys) s.close(); _connectedGuests.clear(); - _packetBuffers.clear(); // 버퍼 정리 + _packetBuffers.clear(); guestList.clear(); - role = NetworkRole.none; _serverSocket = null; _clientSocket = null; diff --git a/packages/core/lib/playwith_core.dart b/packages/core/lib/playwith_core.dart index 28efa39..c875366 100644 --- a/packages/core/lib/playwith_core.dart +++ b/packages/core/lib/playwith_core.dart @@ -4,10 +4,12 @@ export 'game/base_game.dart'; export 'network/network_manager.dart'; export 'model/user_info.dart'; export 'model/play_packet.dart'; +export 'model/game_info.dart'; export 'utils/sound_manager.dart'; export 'manager/global_chat_manager.dart'; export 'manager/media_manager.dart'; export 'manager/settings_manager.dart'; +export 'manager/notification_manager.dart'; // 추가 export 'database/ephemeral_database.dart'; // [Widget] @@ -15,4 +17,15 @@ export 'widgets/game_chat_overlay.dart'; export 'widgets/avatar_widget.dart'; // [추가됨] export 'manager/voice_manager.dart'; export 'widgets/voice_widget.dart'; -export 'manager/notification_manager.dart'; // 추가 \ No newline at end of file +export 'widgets/ad_banner_widget.dart'; +export 'screens/game_selection_screen.dart'; +export 'game/sudoku_multi_game.dart'; +export 'game/quiz_game.dart'; +export 'game/spider_multi_game.dart'; +export 'game/omok_game.dart'; +export 'game/janggi_game.dart'; +export 'game/yutnori_game.dart'; + +export 'game/memory_game.dart'; +export 'game/tap_battle_game.dart'; +export 'game/balance_game.dart'; \ No newline at end of file diff --git a/packages/core/lib/screens/game_selection_screen.dart b/packages/core/lib/screens/game_selection_screen.dart new file mode 100644 index 0000000..b701f5a --- /dev/null +++ b/packages/core/lib/screens/game_selection_screen.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; +import '../model/game_info.dart'; // 위에서 만든 모델 + +class GameSelectionScreen extends StatelessWidget { + final Function(String gameId) onGameSelected; + + const GameSelectionScreen({super.key, required this.onGameSelected}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text("게임 선택")), + body: GridView.builder( + padding: const EdgeInsets.all(16), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, // 한 줄에 2개 + childAspectRatio: 0.8, + crossAxisSpacing: 16, + mainAxisSpacing: 16, + ), + itemCount: AppGames.games.length, + itemBuilder: (context, index) { + final game = AppGames.games[index]; + final bool isReady = !game.description.contains("[준비중]"); // 간단한 활성화 체크 + + return Opacity( + opacity: isReady ? 1.0 : 0.5, + child: GestureDetector( + onTap: isReady ? () => onGameSelected(game.id) : null, + child: Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: isReady ? Colors.blueAccent.withOpacity(0.3) : Colors.grey.withOpacity(0.3), + width: 2 + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(game.icon, size: 60, color: isReady ? Colors.blue : Colors.grey), + const SizedBox(height: 16), + Text( + game.name, + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Text( + game.description, + style: const TextStyle(fontSize: 12, color: Colors.grey), + textAlign: TextAlign.center, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ); + }, + ), + ); + } +} \ No newline at end of file diff --git a/packages/core/lib/widgets/ad_banner_widget.dart b/packages/core/lib/widgets/ad_banner_widget.dart new file mode 100644 index 0000000..17737c6 --- /dev/null +++ b/packages/core/lib/widgets/ad_banner_widget.dart @@ -0,0 +1,66 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:google_mobile_ads/google_mobile_ads.dart'; + +class AdBannerWidget extends StatefulWidget { + const AdBannerWidget({super.key}); + + @override + State createState() => _AdBannerWidgetState(); +} + +class _AdBannerWidgetState extends State { + BannerAd? _bannerAd; + bool _isLoaded = false; + + // 테스트용 광고 ID (실제 출시 전에는 본인의 광고 ID로 교체해야 합니다) + final String _adUnitId = Platform.isAndroid + ? 'ca-app-pub-3940256099942544/6300978111' // 안드로이드 테스트 ID + : 'ca-app-pub-3940256099942544/2934735716'; // iOS 테스트 ID + + @override + void initState() { + super.initState(); + _loadAd(); + } + + void _loadAd() { + _bannerAd = BannerAd( + adUnitId: _adUnitId, + request: const AdRequest(), + size: AdSize.banner, + listener: BannerAdListener( + onAdLoaded: (ad) { + debugPrint('$ad loaded.'); + setState(() { + _isLoaded = true; + }); + }, + onAdFailedToLoad: (ad, err) { + debugPrint('BannerAd failed to load: $err'); + ad.dispose(); + }, + ), + )..load(); + } + + @override + void dispose() { + _bannerAd?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (_bannerAd != null && _isLoaded) { + return Container( + alignment: Alignment.center, + width: _bannerAd!.size.width.toDouble(), + height: _bannerAd!.size.height.toDouble(), + child: AdWidget(ad: _bannerAd!), + ); + } + // 광고가 로드되지 않았을 때 공간을 차지하지 않거나 대체 위젯 표시 + return const SizedBox.shrink(); + } +} \ No newline at end of file diff --git a/packages/core/lib/widgets/game_chat_overlay.dart b/packages/core/lib/widgets/game_chat_overlay.dart index 9d093cc..270486f 100644 --- a/packages/core/lib/widgets/game_chat_overlay.dart +++ b/packages/core/lib/widgets/game_chat_overlay.dart @@ -1,17 +1,23 @@ import 'dart:async'; import 'dart:io'; +import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:gal/gal.dart'; import 'package:image_picker/image_picker.dart'; import '../manager/global_chat_manager.dart'; import '../manager/media_manager.dart'; import '../database/ephemeral_database.dart'; -import '../network/network_manager.dart'; // UserInfo 조회를 위해 추가 +import '../network/network_manager.dart'; import '../model/user_info.dart'; -import 'avatar_widget.dart'; // AvatarWidget import +import 'avatar_widget.dart'; class GameChatOverlay extends StatefulWidget { - const GameChatOverlay({super.key}); + final double bottomOffset; // 초기 위치 설정을 위한 하단 여백 + + const GameChatOverlay({ + super.key, + this.bottomOffset = 0.0, + }); @override State createState() => _GameChatOverlayState(); @@ -20,21 +26,28 @@ class GameChatOverlay extends StatefulWidget { class _GameChatOverlayState extends State { final TextEditingController _textController = TextEditingController(); final ScrollController _scrollController = ScrollController(); - - bool _isExpanded = false; - + + bool _isExpanded = false; // 채팅창 열림 여부 + Offset _position = Offset.zero; // 현재 위치 + bool _isInitialized = false; // 초기 위치 설정 여부 + int _unreadCount = 0; - String _latestPreview = "채팅에 참여해보세요!"; - + String _latestPreview = ""; + StreamSubscription? _chatSub; StreamSubscription? _mediaSub; int _lastChatLength = 0; int _lastMediaLength = 0; + // 창 크기 설정 + final double _fabSize = 60.0; + final double _windowWidth = 320.0; + final double _windowHeight = 450.0; + @override void initState() { super.initState(); - + _chatSub = GlobalChatManager().messageStream.listen((messages) { if (messages.isEmpty) return; if (messages.length > _lastChatLength) { @@ -56,7 +69,7 @@ class _GameChatOverlayState extends State { if (!_isExpanded && mounted) { setState(() { _unreadCount++; - _latestPreview = "📷 ${lastMedia.senderName}님이 사진을 보냈습니다."; + _latestPreview = "📷 사진 도착"; }); } } @@ -77,256 +90,299 @@ class _GameChatOverlayState extends State { setState(() { _isExpanded = !_isExpanded; if (_isExpanded) { - _unreadCount = 0; - _latestPreview = ""; + _unreadCount = 0; // 열면 읽음 처리 + + // 화면 밖으로 나가지 않도록 위치 보정 + // (버튼 상태일 때 구석에 있다가 열리면 화면 밖으로 나갈 수 있음) + final screenSize = MediaQuery.of(context).size; + double newX = _position.dx; + double newY = _position.dy; + + if (newX + _windowWidth > screenSize.width) { + newX = screenSize.width - _windowWidth - 10; + } + if (newY + _windowHeight > screenSize.height) { + newY = screenSize.height - _windowHeight - 80; // 하단 여유 + } + _position = Offset(math.max(10, newX), math.max(40, newY)); } }); } @override Widget build(BuildContext context) { - // [핵심 수정] 키보드가 올라왔을 때 그 높이만큼 값을 가져옴 - final bottomPadding = MediaQuery.of(context).viewInsets.bottom; + return LayoutBuilder( + builder: (context, constraints) { + // 1. 초기 위치 설정 (우측 하단, 광고 위) + if (!_isInitialized) { + final initialX = constraints.maxWidth - _fabSize - 20; + final initialY = constraints.maxHeight - _fabSize - widget.bottomOffset - 20; + _position = Offset(initialX, initialY); + _isInitialized = true; + } - return Align( - alignment: Alignment.bottomCenter, - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), // 반응 속도를 위해 조금 빠르게 조정 - height: _isExpanded ? 500 : 60, - - // [핵심 수정] 기존 마진(10)에 키보드 높이(bottomPadding)를 더해줌 - // 이렇게 하면 키보드가 올라올 때 채팅창도 같이 올라갑니다. - margin: EdgeInsets.only( - left: 10, - right: 10, - bottom: 10 + bottomPadding, - ), - - decoration: BoxDecoration( - color: Colors.black.withOpacity(0.85), - borderRadius: BorderRadius.circular(20), - boxShadow: [BoxShadow(color: Colors.black26, blurRadius: 10, spreadRadius: 2)], - ), - child: Column( + return Stack( children: [ - // 1. 상단 핸들 - GestureDetector( - onTap: _toggleExpand, - behavior: HitTestBehavior.translucent, - child: Container( - width: double.infinity, - height: 60, - padding: const EdgeInsets.symmetric(horizontal: 20), - alignment: Alignment.centerLeft, - child: Row( - children: [ - Icon( - _isExpanded ? Icons.keyboard_arrow_down : Icons.keyboard_arrow_up, - color: Colors.white70, - ), - const SizedBox(width: 10), + Positioned( + left: _position.dx, + top: _position.dy, + child: GestureDetector( + onPanUpdate: (details) { + setState(() { + // 2. 드래그 이동 (화면 밖으로 나가지 않게 제한) + double newX = _position.dx + details.delta.dx; + double newY = _position.dy + details.delta.dy; - if (!_isExpanded) - Expanded( - child: Text( - _latestPreview, - style: const TextStyle(color: Colors.white, fontSize: 14), - overflow: TextOverflow.ellipsis, - maxLines: 1, - ), - ) - else - const Text("채팅 및 미디어", style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 16)), - - if (!_isExpanded && _unreadCount > 0) - Container( - margin: const EdgeInsets.only(left: 10), - padding: const EdgeInsets.all(6), - decoration: const BoxDecoration(color: Colors.redAccent, shape: BoxShape.circle), - child: Text("$_unreadCount", style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold)), - ), - ], + final double currentWidth = _isExpanded ? _windowWidth : _fabSize; + final double currentHeight = _isExpanded ? _windowHeight : _fabSize; + + newX = newX.clamp(0.0, constraints.maxWidth - currentWidth); + newY = newY.clamp(0.0, constraints.maxHeight - currentHeight); + + _position = Offset(newX, newY); + }); + }, + child: Material( + color: Colors.transparent, + elevation: 8, + borderRadius: BorderRadius.circular(_isExpanded ? 20 : 30), + child: _isExpanded ? _buildExpandedView() : _buildCollapsedView(), ), ), ), + ], + ); + }, + ); + } - // 2. 내부 콘텐츠 - if (_isExpanded) ...[ - const Divider(height: 1, color: Colors.white24), - - // 미디어 갤러리 - Container( - height: 110, - width: double.infinity, - color: Colors.black12, - child: StreamBuilder>( - stream: MediaManager().galleryStream, - initialData: const [], - builder: (context, snapshot) { - if (snapshot.hasError) return const Center(child: Icon(Icons.error, color: Colors.grey)); - - final mediaList = snapshot.data ?? []; - if (mediaList.isEmpty) { - return const Center(child: Text("공유된 사진이 없습니다.", style: TextStyle(color: Colors.white38, fontSize: 12))); - } - - return ListView.builder( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.all(10), - itemCount: mediaList.length, - itemBuilder: (context, index) { - final item = mediaList[index]; - return Padding( - padding: const EdgeInsets.only(right: 10), - child: GestureDetector( - onTap: () => _showFullImage(context, item), - child: Column( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Image.file( - File(item.filePath), - width: 70, height: 70, - fit: BoxFit.cover, - errorBuilder: (_,__,___) => Container( - width: 70, height: 70, color: Colors.grey[800], - child: const Icon(Icons.broken_image, color: Colors.white54), - ), - ), - ), - const SizedBox(height: 4), - Text( - item.senderName.length > 4 ? "${item.senderName.substring(0,4)}.." : item.senderName, - style: const TextStyle(color: Colors.white70, fontSize: 10), - ) - ], - ), - ), - ); - }, - ); - }, + // [UI] 닫힌 상태 (플로팅 버튼) + Widget _buildCollapsedView() { + return GestureDetector( + onTap: _toggleExpand, + child: Container( + width: _fabSize, + height: _fabSize, + decoration: const BoxDecoration( + color: Colors.blueAccent, + shape: BoxShape.circle, + ), + child: Stack( + alignment: Alignment.center, + children: [ + const Icon(Icons.chat_bubble_outline, color: Colors.white, size: 28), + if (_unreadCount > 0) + Positioned( + right: 0, + top: 0, + child: Container( + padding: const EdgeInsets.all(6), + decoration: const BoxDecoration( + color: Colors.redAccent, + shape: BoxShape.circle, + ), + child: Text( + _unreadCount > 9 ? "9+" : "$_unreadCount", + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), ), ), - - const Divider(height: 1, color: Colors.white24), - - // 채팅 리스트 - // 3. 채팅 리스트 부분 - Expanded( - child: StreamBuilder>( - stream: GlobalChatManager().messageStream, - builder: (context, snapshot) { - final messages = snapshot.data ?? []; - // ... (스크롤 로직 동일) ... - - return ListView.builder( - controller: _scrollController, - padding: const EdgeInsets.all(10), - itemCount: messages.length, - itemBuilder: (context, index) { - final msg = messages[index]; - - // [핵심] 메시지 보낸 사람의 최신 정보 찾기 (이미지 표시용) - UserInfo? senderInfo; - if (msg.isMe) { - senderInfo = NetworkManager().me; - } else { - // 게스트 리스트에서 찾기 (나갔으면 null일 수 있음) - try { - senderInfo = NetworkManager().guestList.firstWhere((u) => u.id == msg.senderId); - } catch (_) {} - } - - return Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Row( - mainAxisAlignment: msg.isMe ? MainAxisAlignment.end : MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - if (!msg.isMe) ...[ - // [수정] AvatarWidget 적용 - AvatarWidget( - user: senderInfo, // 유저 정보가 있으면 이미지 자동 적용 - nickname: msg.senderName, // 없으면 이름 첫 글자 - size: 30, // 채팅창에 맞게 작게 - ), - const SizedBox(width: 8), - ], - Flexible( - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: msg.isMe ? Colors.blueAccent : Colors.white10, - borderRadius: BorderRadius.circular(15), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (!msg.isMe) - Text(msg.senderName, style: const TextStyle(fontSize: 10, color: Colors.grey)), - Text(msg.text, style: const TextStyle(color: Colors.white)), - ], - ), - ), - ), - ], - ), - ); - }, - ); - }, - ), - ), - - // 입력창 - Container( - padding: const EdgeInsets.all(8.0), - color: Colors.black54, - child: Row( - children: [ - IconButton( - icon: const Icon(Icons.add_photo_alternate, color: Colors.blueAccent), - onPressed: _pickAndSendImage, - ), - Expanded( - child: TextField( - controller: _textController, - style: const TextStyle(color: Colors.white), - decoration: const InputDecoration( - hintText: "메시지 보내기...", - hintStyle: TextStyle(color: Colors.white54), - border: InputBorder.none, - contentPadding: EdgeInsets.symmetric(horizontal: 10), - ), - onSubmitted: _sendMessage, - ), - ), - IconButton( - icon: const Icon(Icons.send, color: Colors.blue), - onPressed: () => _sendMessage(_textController.text), - ), - ], - ), - ), - ], ], ), ), ); } + // [UI] 열린 상태 (채팅창) + Widget _buildExpandedView() { + return Container( + width: _windowWidth, + height: _windowHeight, + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.9), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: Colors.white12), + ), + child: Column( + children: [ + // 헤더 (드래그 핸들) + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: const BoxDecoration( + color: Colors.white10, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Icon(Icons.drag_handle, color: Colors.white54), + const Text("채팅", style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold)), + GestureDetector( + onTap: _toggleExpand, + child: const Icon(Icons.close, color: Colors.white70), + ), + ], + ), + ), + + // 미디어 갤러리 (있으면 표시) + StreamBuilder>( + stream: MediaManager().galleryStream, + initialData: const [], + builder: (context, snapshot) { + final mediaList = snapshot.data ?? []; + if (mediaList.isEmpty) return const SizedBox(); + + return Container( + height: 80, + color: Colors.black12, + child: ListView.builder( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.all(8), + itemCount: mediaList.length, + itemBuilder: (context, index) { + final item = mediaList[index]; + return GestureDetector( + onTap: () => _showFullImage(context, item), + child: Padding( + padding: const EdgeInsets.only(right: 8), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.file( + File(item.filePath), + width: 64, height: 64, + fit: BoxFit.cover, + ), + ), + ), + ); + }, + ), + ); + }, + ), + + // 채팅 리스트 + Expanded( + child: StreamBuilder>( + stream: GlobalChatManager().messageStream, + builder: (context, snapshot) { + final messages = snapshot.data ?? []; + return ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.all(12), + itemCount: messages.length, + itemBuilder: (context, index) { + final msg = messages[index]; + UserInfo? senderInfo; + if (!msg.isMe) { + try { + senderInfo = NetworkManager().guestList.firstWhere((u) => u.id == msg.senderId); + } catch (_) {} + } + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + mainAxisAlignment: msg.isMe ? MainAxisAlignment.end : MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (!msg.isMe) ...[ + AvatarWidget(user: senderInfo, nickname: msg.senderName, size: 28), + const SizedBox(width: 8), + ], + Flexible( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: msg.isMe ? Colors.blueAccent : Colors.white12, + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!msg.isMe) + Text(msg.senderName, style: const TextStyle(fontSize: 10, color: Colors.grey)), + Text(msg.text, style: const TextStyle(color: Colors.white)), + ], + ), + ), + ), + ], + ), + ); + }, + ); + }, + ), + ), + + // 입력창 + Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.add_photo_alternate, color: Colors.blueAccent), + onPressed: _pickAndSendImage, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + const SizedBox(width: 8), + Expanded( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + color: Colors.white12, + borderRadius: BorderRadius.circular(20), + ), + child: TextField( + controller: _textController, + style: const TextStyle(color: Colors.white), + decoration: const InputDecoration( + hintText: "메시지...", + hintStyle: TextStyle(color: Colors.white38), + border: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.symmetric(vertical: 10), + ), + onSubmitted: _sendMessage, + ), + ), + ), + IconButton( + icon: const Icon(Icons.send, color: Colors.blue), + onPressed: () => _sendMessage(_textController.text), + ), + ], + ), + ), + ], + ), + ); + } + void _sendMessage(String text) { if (text.trim().isEmpty) return; GlobalChatManager().sendMessage(text); _textController.clear(); + // 메시지 전송 후 스크롤 하단으로 + Future.delayed(const Duration(milliseconds: 100), () { + if (_scrollController.hasClients) { + _scrollController.jumpTo(_scrollController.position.maxScrollExtent); + } + }); } Future _pickAndSendImage() async { final picker = ImagePicker(); final XFile? image = await picker.pickImage( source: ImageSource.gallery, - imageQuality: 70, + imageQuality: 70, maxWidth: 1024, ); @@ -345,34 +401,22 @@ class _GameChatOverlayState extends State { alignment: Alignment.center, children: [ InteractiveViewer(child: Image.file(File(item.filePath))), - Positioned( - top: 40, left: 20, right: 20, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - IconButton( - icon: const Icon(Icons.close, color: Colors.white, size: 30), - onPressed: () => Navigator.pop(ctx), - ), - IconButton( - icon: const Icon(Icons.download, color: Colors.white, size: 30), - tooltip: "갤러리에 저장", - onPressed: () => _saveImageToGallery(context, item.filePath), - ), - ], + top: 40, + right: 20, + child: IconButton( + icon: const Icon(Icons.close, color: Colors.white, size: 30), + onPressed: () => Navigator.pop(ctx), ), ), - Positioned( - bottom: 20, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), - color: Colors.black54, - decoration: BoxDecoration(borderRadius: BorderRadius.circular(5)), - child: Text("From: ${item.senderName}", style: const TextStyle(color: Colors.white)), + bottom: 40, + child: IconButton( + icon: const Icon(Icons.download, color: Colors.white, size: 30), + tooltip: "저장", + onPressed: () => _saveImageToGallery(context, item.filePath), ), - ) + ), ], ), ), @@ -384,7 +428,7 @@ class _GameChatOverlayState extends State { await Gal.putImage(filePath); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text("갤러리에 저장되었습니다! ✅")), + const SnackBar(content: Text("저장되었습니다! ✅")), ); } } catch (e) { diff --git a/packages/core/lib/widgets/spider_widgets.dart b/packages/core/lib/widgets/spider_widgets.dart new file mode 100644 index 0000000..f0e98c1 --- /dev/null +++ b/packages/core/lib/widgets/spider_widgets.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import '../model/spider_model.dart'; + +class SpiderCardWidget extends StatelessWidget { + final SpiderCard card; + final double width; + final double height; + + const SpiderCardWidget({ + super.key, + required this.card, + required this.width, + required this.height, + }); + + @override + Widget build(BuildContext context) { + return Container( + width: width, + height: height, + decoration: BoxDecoration( + color: card.isFaceUp ? Colors.white : Colors.blue[800], // 뒷면 색상 + border: Border.all(color: Colors.black, width: 0.5), + borderRadius: BorderRadius.circular(4.0), + boxShadow: [ + BoxShadow(color: Colors.black26, blurRadius: 2, offset: const Offset(1, 1)), + ], + ), + child: card.isFaceUp ? _buildFace() : _buildBack(), + ); + } + + Widget _buildBack() { + return Center( + child: Container( + margin: const EdgeInsets.all(4), + decoration: BoxDecoration( + border: Border.all(color: Colors.white, width: 1), + borderRadius: BorderRadius.circular(2), + ), + child: const Center( + child: Icon(Icons.pets, color: Colors.white30, size: 20), + ), + ), + ); + } + + Widget _buildFace() { + return Stack( + children: [ + // 왼쪽 상단 숫자 + Positioned( + top: 2, left: 4, + child: Column( + children: [ + Text(card.rankText, style: TextStyle(color: card.isRed ? Colors.red : Colors.black, fontWeight: FontWeight.bold, fontSize: 14)), + Text(card.suitSymbol, style: TextStyle(color: card.isRed ? Colors.red : Colors.black, fontSize: 10)), + ], + ), + ), + // 중앙 심볼 + Center( + child: Text( + card.suitSymbol, + style: TextStyle(color: card.isRed ? Colors.red : Colors.black, fontSize: width * 0.5), + ), + ), + ], + ); + } +} \ No newline at end of file diff --git a/packages/core/lib/widgets/sudoku_widgets.dart b/packages/core/lib/widgets/sudoku_widgets.dart new file mode 100644 index 0000000..034541d --- /dev/null +++ b/packages/core/lib/widgets/sudoku_widgets.dart @@ -0,0 +1,181 @@ +import 'package:flutter/material.dart'; + +// ----------------------------------------------------------------------------- +// 1. Sudoku Board (보드판) +// ----------------------------------------------------------------------------- +class SudokuBoard extends StatelessWidget { + final int blockSize; + final List cells; + final List originalCells; + final int? selectedIndex; + final int? selectedNumberPad; + final Set incorrectCells; + final Function(int) onCellTapped; + + const SudokuBoard({ + super.key, + required this.blockSize, + required this.cells, + required this.originalCells, + required this.selectedIndex, + required this.selectedNumberPad, + required this.incorrectCells, + required this.onCellTapped, + }); + + String _getSymbol(int value) { + if (value == 0) return ''; + if (value >= 1 && value <= 9) return value.toString(); + if (value >= 10) return String.fromCharCode('A'.codeUnitAt(0) + (value - 10)); + return '?'; + } + + @override + Widget build(BuildContext context) { + final int gridSize = blockSize * blockSize; + final double fontSize = (gridSize > 9) ? 12 : 24; + final bool isDark = Theme.of(context).brightness == Brightness.dark; + + // 심플한 색상 정의 (테마 의존성 제거) + final Color thickBorderColor = isDark ? Colors.white70 : Colors.black87; + final Color thinBorderColor = isDark ? Colors.white24 : Colors.black12; + + final Color incorrectBg = Colors.red.withOpacity(0.2); + final Color highlightedBg = Colors.blue.withOpacity(0.2); + final Color selectedBg = Colors.blue.withOpacity(0.4); // 선택된 셀 배경 + final Color editableBg = isDark ? Colors.grey[800]! : Colors.white; + final Color fixedBg = isDark ? Colors.grey[700]! : Colors.grey[200]!; + + final Color selectedTextColor = Colors.white; + final Color incorrectTextColor = Colors.red; + final Color editableTextColor = Colors.blue[700]!; + final Color fixedTextColor = isDark ? Colors.white : Colors.black; + + return AspectRatio( + aspectRatio: 1.0, + child: Container( + decoration: BoxDecoration(border: Border.all(color: thickBorderColor, width: 2)), + child: GridView.builder( + physics: const NeverScrollableScrollPhysics(), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: gridSize, + ), + itemCount: gridSize * gridSize, + itemBuilder: (context, index) { + int row = index ~/ gridSize; + int col = index % gridSize; + + int cellValue = cells[index]; + bool isEditable = (originalCells[index] == 0); + bool isSelected = (index == selectedIndex); + + // 같은 숫자가 선택되었을 때 하이라이트 + bool isHighlighted = (cellValue != 0 && + selectedNumberPad != null && + cellValue == selectedNumberPad); + + bool isIncorrect = incorrectCells.contains(index); + + // 테두리 그리기 (블록 경계는 두껍게) + BorderSide rightBorder = (col % blockSize == blockSize - 1 && col != gridSize - 1) + ? BorderSide(color: thickBorderColor, width: 2.0) + : BorderSide(color: thinBorderColor, width: 0.5); + + BorderSide bottomBorder = (row % blockSize == blockSize - 1 && row != gridSize - 1) + ? BorderSide(color: thickBorderColor, width: 2.0) + : BorderSide(color: thinBorderColor, width: 0.5); + + Color bgColor = isEditable ? editableBg : fixedBg; + if (isIncorrect) bgColor = incorrectBg; + else if (isSelected) bgColor = selectedBg; // 선택된 셀이 우선 + else if (isHighlighted) bgColor = highlightedBg; + + Color txtColor = isEditable ? editableTextColor : fixedTextColor; + if (isSelected) txtColor = selectedTextColor; + if (isIncorrect) txtColor = incorrectTextColor; + + return GestureDetector( + onTap: () => onCellTapped(index), + child: Container( + alignment: Alignment.center, + decoration: BoxDecoration( + color: bgColor, + border: Border(right: rightBorder, bottom: bottomBorder), + ), + child: Text( + _getSymbol(cellValue), + style: TextStyle( + fontSize: fontSize, + fontWeight: FontWeight.bold, + color: txtColor, + ), + ), + ), + ); + }, + ), + ), + ); + } +} + +// ----------------------------------------------------------------------------- +// 2. Number Pad (숫자 키패드) +// ----------------------------------------------------------------------------- +class NumberPad extends StatelessWidget { + final int blockSize; + final Map numberCounts; + final int? selectedNumber; + final Function(int) onNumberTapped; + + const NumberPad({ + super.key, + required this.blockSize, + required this.numberCounts, + required this.selectedNumber, + required this.onNumberTapped, + }); + + String _getSymbol(int value) { + if (value >= 1 && value <= 9) return value.toString(); + if (value >= 10) return String.fromCharCode('A'.codeUnitAt(0) + (value - 10)); + return '?'; + } + + @override + Widget build(BuildContext context) { + final int gridSize = blockSize * blockSize; + // 가로 모드 등 복잡한 레이아웃 제거하고 단순 GridView로 통일 + return GridView.builder( + itemCount: gridSize, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: blockSize > 3 ? 8 : blockSize * 3, // 적절히 줄 바꿈 + mainAxisSpacing: 8, + crossAxisSpacing: 8, + childAspectRatio: 1.2, + ), + itemBuilder: (context, index) { + int numberValue = index + 1; + bool isSelected = (numberValue == selectedNumber); + bool isCompleted = (numberCounts[numberValue] ?? 0) >= gridSize; + + return ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: isSelected ? Colors.blue : (isCompleted ? Colors.grey[300] : Colors.white), + foregroundColor: isSelected ? Colors.white : (isCompleted ? Colors.grey : Colors.black), + elevation: isCompleted ? 0 : 2, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + padding: EdgeInsets.zero, + ), + onPressed: isCompleted ? null : () => onNumberTapped(numberValue), + child: Text( + _getSymbol(numberValue), + style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), + ); + }, + ); + } +} \ No newline at end of file diff --git a/packages/core/pubspec.yaml b/packages/core/pubspec.yaml index 21efc15..7382ae2 100644 --- a/packages/core/pubspec.yaml +++ b/packages/core/pubspec.yaml @@ -25,6 +25,9 @@ dependencies: shared_preferences: ^2.2.2 speech_to_text: ^7.0.0 flutter_local_notifications: ^17.0.0 + google_mobile_ads: ^5.0.0 + audioplayers: ^6.0.0 # 여기로 이동 + dev_dependencies: drift_dev: ^2.13.0 build_runner: ^2.4.6 \ No newline at end of file diff --git a/packages/games/quiz/.gitignore b/packages/games/quiz/.gitignore deleted file mode 100644 index dd5eb98..0000000 --- a/packages/games/quiz/.gitignore +++ /dev/null @@ -1,31 +0,0 @@ -# Miscellaneous -*.class -*.log -*.pyc -*.swp -.DS_Store -.atom/ -.buildlog/ -.history -.svn/ -migrate_working_dir/ - -# IntelliJ related -*.iml -*.ipr -*.iws -.idea/ - -# The .vscode folder contains launch configuration and tasks you configure in -# VS Code which you may wish to be included in version control, so this line -# is commented out by default. -#.vscode/ - -# Flutter/Dart/Pub related -# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. -/pubspec.lock -**/doc/api/ -.dart_tool/ -.flutter-plugins-dependencies -/build/ -/coverage/ diff --git a/packages/games/quiz/.metadata b/packages/games/quiz/.metadata deleted file mode 100644 index d7469f0..0000000 --- a/packages/games/quiz/.metadata +++ /dev/null @@ -1,10 +0,0 @@ -# This file tracks properties of this Flutter project. -# Used by Flutter tool to assess capabilities and perform upgrades etc. -# -# This file should be version controlled and should not be manually edited. - -version: - revision: "adc901062556672b4138e18a4dc62a4be8f4b3c2" - channel: "stable" - -project_type: package diff --git a/packages/games/quiz/CHANGELOG.md b/packages/games/quiz/CHANGELOG.md deleted file mode 100644 index 41cc7d8..0000000 --- a/packages/games/quiz/CHANGELOG.md +++ /dev/null @@ -1,3 +0,0 @@ -## 0.0.1 - -* TODO: Describe initial release. diff --git a/packages/games/quiz/LICENSE b/packages/games/quiz/LICENSE deleted file mode 100644 index ba75c69..0000000 --- a/packages/games/quiz/LICENSE +++ /dev/null @@ -1 +0,0 @@ -TODO: Add your license here. diff --git a/packages/games/quiz/README.md b/packages/games/quiz/README.md deleted file mode 100644 index 4a260d8..0000000 --- a/packages/games/quiz/README.md +++ /dev/null @@ -1,39 +0,0 @@ - - -TODO: Put a short description of the package here that helps potential users -know whether this package might be useful for them. - -## Features - -TODO: List what your package can do. Maybe include images, gifs, or videos. - -## Getting started - -TODO: List prerequisites and provide or point to information on how to -start using the package. - -## Usage - -TODO: Include short and useful examples for package users. Add longer examples -to `/example` folder. - -```dart -const like = 'sample'; -``` - -## Additional information - -TODO: Tell users more about the package: where to find more information, how to -contribute to the package, how to file issues, what response they can expect -from the package authors, and more. diff --git a/packages/games/quiz/analysis_options.yaml b/packages/games/quiz/analysis_options.yaml deleted file mode 100644 index a5744c1..0000000 --- a/packages/games/quiz/analysis_options.yaml +++ /dev/null @@ -1,4 +0,0 @@ -include: package:flutter_lints/flutter.yaml - -# Additional information about this file can be found at -# https://dart.dev/guides/language/analysis-options diff --git a/packages/games/quiz/lib/model/quiz_model.dart b/packages/games/quiz/lib/model/quiz_model.dart deleted file mode 100644 index 90c9c50..0000000 --- a/packages/games/quiz/lib/model/quiz_model.dart +++ /dev/null @@ -1,108 +0,0 @@ -enum QuizType { text, image } // 문제 유형 - -class QuizItem { - final QuizType type; - final String question; // 질문 텍스트 - final String answer; // 정답 - final List options; // 보기 (객관식용, 없으면 주관식/OX) - - QuizItem({ - required this.type, - required this.question, - required this.answer, - required this.options, - }); - - Map toJson() => { - 'type': type.name, - 'question': question, - 'answer': answer, - 'options': options, - }; - - factory QuizItem.fromJson(Map json) { - return QuizItem( - type: json['type'] == 'image' ? QuizType.image : QuizType.text, - question: json['question'], - answer: json['answer'], - options: List.from(json['options'] ?? []), - ); - } -} - -class QuizSet { - static List getDummy10() { - return [ - // 1. [OX] 기본 - QuizItem( - type: QuizType.text, - question: "사과는 영어로 Apple이다.", - answer: "O", - options: ["O", "X"], - ), - // 2. [OX] 상식 - QuizItem( - type: QuizType.text, - question: "북극곰의 피부색은 흰색이다.", - answer: "X", // 검은색임 - options: ["O", "X"], - ), - // 3. [OX] 생물 - QuizItem( - type: QuizType.text, - question: "돌고래는 '어류(물고기)'다.", - answer: "X", // 포유류 - options: ["O", "X"], - ), - // 4. [4지선다] 역사 - QuizItem( - type: QuizType.text, - question: "임진왜란이 일어난 해는?", - answer: "1592년", - options: ["1392년", "1492년", "1592년", "1950년"], - ), - // 5. [4지선다] 넌센스 - QuizItem( - type: QuizType.text, - question: "왕이 넘어지면?", - answer: "킹콩", - options: ["왕콩", "킹콩", "전하", "꽈당"], - ), - // 6. [주관식/음성] 속담 (보기를 줘서 터치도 가능하게 함) - QuizItem( - type: QuizType.text, - question: "가는 말이 고와야 [ ? ]가 곱다.", - answer: "오는 말", - options: ["오는 말", "가는 발", "너의 말", "우리 말"], - ), - // 7. [수학] 연산 - QuizItem( - type: QuizType.text, - question: "5 + 5 × 5 = ?", - answer: "30", - options: ["25", "30", "50", "10"], - ), - // 8. [상식] 수도 맞추기 - QuizItem( - type: QuizType.text, - question: "미국의 수도는 어디일까요?", - answer: "워싱턴 D.C.", - options: ["뉴욕", "LA", "워싱턴 D.C.", "시카고"], - ), - // 9. [넌센스] - QuizItem( - type: QuizType.text, - question: "세상에서 가장 뜨거운 바다는?", - answer: "열바다", - options: ["불바다", "열바다", "사랑해", "동해"], - ), - // 10. [OX] 마지막 - QuizItem( - type: QuizType.text, - question: "개발자님은 이 앱을 완성할 수 있다!", - answer: "O", - options: ["O", "X"], - ), - ]; - } -} \ No newline at end of file diff --git a/packages/games/quiz/lib/playwith_game_quiz.dart b/packages/games/quiz/lib/playwith_game_quiz.dart deleted file mode 100644 index 298576d..0000000 --- a/packages/games/quiz/lib/playwith_game_quiz.dart +++ /dev/null @@ -1,5 +0,0 @@ -/// A Calculator. -class Calculator { - /// Returns [value] plus 1. - int addOne(int value) => value + 1; -} diff --git a/packages/games/quiz/pubspec.yaml b/packages/games/quiz/pubspec.yaml deleted file mode 100644 index 353e287..0000000 --- a/packages/games/quiz/pubspec.yaml +++ /dev/null @@ -1,15 +0,0 @@ -name: playwith_game_quiz -description: A quiz game module. -version: 0.0.1 -publish_to: 'none' - -environment: - sdk: ^3.0.0 - -dependencies: - flutter: - sdk: flutter - # Core 모듈 연결 - playwith_core: - path: ../../core - audioplayers: ^6.0.0 # 여기로 이동 \ No newline at end of file diff --git a/packages/games/quiz/test/playwith_game_quiz_test.dart b/packages/games/quiz/test/playwith_game_quiz_test.dart deleted file mode 100644 index 1ca8cd8..0000000 --- a/packages/games/quiz/test/playwith_game_quiz_test.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; - -import 'package:playwith_game_quiz/playwith_game_quiz.dart'; - -void main() { - test('adds one to input values', () { - final calculator = Calculator(); - expect(calculator.addOne(2), 3); - expect(calculator.addOne(-7), -6); - expect(calculator.addOne(0), 1); - }); -}