From 22caf64855db4605f9a17377130a667d42408df6 Mon Sep 17 00:00:00 2001 From: lunaticbum Date: Tue, 2 Dec 2025 11:06:23 +0900 Subject: [PATCH] .. --- .../android/app/src/main/AndroidManifest.xml | 18 +- apps/app/ios/Podfile | 2 +- apps/app/ios/Podfile.lock | 20 +- apps/app/ios/Runner.xcodeproj/project.pbxproj | 6 +- apps/app/ios/Runner/Info.plist | 12 + apps/app/ios/Runner/Runner.entitlements | 8 + apps/app/lib/lobby_screen.dart | 425 +++++++++---- .../Flutter/GeneratedPluginRegistrant.swift | 4 + apps/app/macos/Podfile | 2 +- apps/app/macos/Podfile.lock | 136 +++++ .../macos/Runner.xcodeproj/project.pbxproj | 98 ++- .../contents.xcworkspacedata | 3 + apps/app/pubspec.lock | 71 ++- apps/app/pubspec.yaml | 2 - .../core/lib/database/ephemeral_database.dart | 45 +- .../lib/database/ephemeral_database.g.dart | 353 ++++++++++- packages/core/lib/game/arkanoid_game.dart | 265 ++++++++ packages/core/lib/game/iam_ground_game.dart | 578 ++++++++++++++++++ packages/core/lib/game/janggi_game.dart | 9 +- packages/core/lib/game/jump_game.dart | 239 ++++++++ packages/core/lib/game/math_run_game.dart | 297 +++++++++ packages/core/lib/game/othello_game.dart | 325 ++++++++++ .../core/lib/game/sequence_memory_game.dart | 414 +++++++++++++ packages/core/lib/game/spider_multi_game.dart | 269 ++++---- packages/core/lib/game/sudoku_multi_game.dart | 116 ++-- packages/core/lib/game/survivor_game.dart | 418 +++++++++++++ packages/core/lib/game/world_tour_game.dart | 495 +++++++++++++++ packages/core/lib/manager/media_manager.dart | 1 + packages/core/lib/model/game_info.dart | 59 ++ packages/core/lib/model/play_packet.dart | 20 +- packages/core/lib/model/spider_game_dto.dart | 28 + .../core/lib/network/network_manager.dart | 347 ++++++----- packages/core/lib/playwith_core.dart | 10 +- packages/core/pubspec.yaml | 4 + 34 files changed, 4594 insertions(+), 505 deletions(-) create mode 100644 apps/app/ios/Runner/Runner.entitlements create mode 100644 apps/app/macos/Podfile.lock create mode 100644 packages/core/lib/game/arkanoid_game.dart create mode 100644 packages/core/lib/game/iam_ground_game.dart create mode 100644 packages/core/lib/game/jump_game.dart create mode 100644 packages/core/lib/game/math_run_game.dart create mode 100644 packages/core/lib/game/othello_game.dart create mode 100644 packages/core/lib/game/sequence_memory_game.dart create mode 100644 packages/core/lib/game/survivor_game.dart create mode 100644 packages/core/lib/game/world_tour_game.dart create mode 100644 packages/core/lib/model/spider_game_dto.dart diff --git a/apps/app/android/app/src/main/AndroidManifest.xml b/apps/app/android/app/src/main/AndroidManifest.xml index d61ef2e..5569421 100644 --- a/apps/app/android/app/src/main/AndroidManifest.xml +++ b/apps/app/android/app/src/main/AndroidManifest.xml @@ -6,20 +6,22 @@ - - - + + + + + + - - - - - + + diff --git a/apps/app/ios/Podfile b/apps/app/ios/Podfile index 620e46e..2dbf7d7 100644 --- a/apps/app/ios/Podfile +++ b/apps/app/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -# platform :ios, '13.0' +platform :ios, '13.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/apps/app/ios/Podfile.lock b/apps/app/ios/Podfile.lock index 6f157f1..b4152ca 100644 --- a/apps/app/ios/Podfile.lock +++ b/apps/app/ios/Podfile.lock @@ -8,6 +8,8 @@ PODS: - CwlCatchException (2.2.1): - CwlCatchExceptionSupport (~> 2.2.1) - CwlCatchExceptionSupport (2.2.1) + - device_info_plus (0.0.1): + - Flutter - DKImagePickerController/Core (4.3.9): - DKImagePickerController/ImageDataManager - DKImagePickerController/Resource @@ -109,6 +111,8 @@ PODS: - nanopb/encode (= 2.30910.0) - nanopb/decode (2.30910.0) - nanopb/encode (2.30910.0) + - network_info_plus (0.0.1): + - Flutter - objective_c (0.0.1): - Flutter - permission_handler_apple (9.3.0): @@ -155,10 +159,13 @@ PODS: - webview_flutter_wkwebview (0.0.1): - Flutter - FlutterMacOS + - wifi_iot (0.0.1): + - Flutter DEPENDENCIES: - audioplayers_darwin (from `.symlinks/plugins/audioplayers_darwin/darwin`) - bonsoir_darwin (from `.symlinks/plugins/bonsoir_darwin/darwin`) + - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - file_picker (from `.symlinks/plugins/file_picker/ios`) - Flutter (from `Flutter`) - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) @@ -166,6 +173,7 @@ DEPENDENCIES: - 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`) + - network_info_plus (from `.symlinks/plugins/network_info_plus/ios`) - objective_c (from `.symlinks/plugins/objective_c/ios`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) @@ -173,6 +181,7 @@ DEPENDENCIES: - 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`) + - wifi_iot (from `.symlinks/plugins/wifi_iot/ios`) SPEC REPOS: trunk: @@ -203,6 +212,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/audioplayers_darwin/darwin" bonsoir_darwin: :path: ".symlinks/plugins/bonsoir_darwin/darwin" + device_info_plus: + :path: ".symlinks/plugins/device_info_plus/ios" file_picker: :path: ".symlinks/plugins/file_picker/ios" Flutter: @@ -217,6 +228,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/image_picker_ios/ios" mobile_scanner: :path: ".symlinks/plugins/mobile_scanner/ios" + network_info_plus: + :path: ".symlinks/plugins/network_info_plus/ios" objective_c: :path: ".symlinks/plugins/objective_c/ios" permission_handler_apple: @@ -231,12 +244,15 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/url_launcher_ios/ios" webview_flutter_wkwebview: :path: ".symlinks/plugins/webview_flutter_wkwebview/darwin" + wifi_iot: + :path: ".symlinks/plugins/wifi_iot/ios" SPEC CHECKSUMS: audioplayers_darwin: 4027b33a8f471d996c13f71cb77f0b1583b5d923 bonsoir_darwin: e3b8526c42ca46a885142df84229131dfabea842 CwlCatchException: 7acc161b299a6de7f0a46a6ed741eae2c8b4d75a CwlCatchExceptionSupport: 54ccab8d8c78907b57f99717fb19d4cc3bce02dc + device_info_plus: 97af1d7e84681a90d0693e63169a5d50e0839a0d DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 file_picker: b159e0c068aef54932bb15dc9fd1571818edaf49 @@ -259,6 +275,7 @@ SPEC CHECKSUMS: MLKitVision: e858c5f125ecc288e4a31127928301eaba9ae0c1 mobile_scanner: 96e91f2e1fb396bb7df8da40429ba8dfad664740 nanopb: 438bc412db1928dac798aa6fd75726007be04262 + network_info_plus: 9d930145451916919786087c4173226363616071 objective_c: 77e887b5ba1827970907e10e832eec1683f3431d permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 @@ -270,7 +287,8 @@ SPEC CHECKSUMS: SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 url_launcher_ios: bb13df5870e8c4234ca12609d04010a21be43dfa webview_flutter_wkwebview: 29eb20d43355b48fe7d07113835b9128f84e3af4 + wifi_iot: b5aafd6f9b52f8a357383a1deabab45f31cd602d -PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e +PODFILE CHECKSUM: 251cb053df7158f337c0712f2ab29f4e0fa474ce COCOAPODS: 1.16.2 diff --git a/apps/app/ios/Runner.xcodeproj/project.pbxproj b/apps/app/ios/Runner.xcodeproj/project.pbxproj index 0a663ea..9c9a4a0 100644 --- a/apps/app/ios/Runner.xcodeproj/project.pbxproj +++ b/apps/app/ios/Runner.xcodeproj/project.pbxproj @@ -63,6 +63,7 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + A0F77A962ED7E5CF0058AC51 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = ""; }; D4D50D0F4B952368E1319826 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; F6B684F378DF7FE6F2C3CABC /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -139,6 +140,7 @@ 97C146F01CF9000F007C117D /* Runner */ = { isa = PBXGroup; children = ( + A0F77A962ED7E5CF0058AC51 /* Runner.entitlements */, 97C146FA1CF9000F007C117D /* Main.storyboard */, 97C146FD1CF9000F007C117D /* Assets.xcassets */, 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, @@ -161,7 +163,6 @@ 4E75157E0476F57C9A36AAFF /* Pods-RunnerTests.release.xcconfig */, 7CAA68E8ECB59955A38AF698 /* Pods-RunnerTests.profile.xcconfig */, ); - name = Pods; path = Pods; sourceTree = ""; }; @@ -488,6 +489,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = TDVYRQ3Z3E; ENABLE_BITCODE = NO; @@ -671,6 +673,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = TDVYRQ3Z3E; ENABLE_BITCODE = NO; @@ -694,6 +697,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = TDVYRQ3Z3E; ENABLE_BITCODE = NO; diff --git a/apps/app/ios/Runner/Info.plist b/apps/app/ios/Runner/Info.plist index eea8f74..c208cc4 100644 --- a/apps/app/ios/Runner/Info.plist +++ b/apps/app/ios/Runner/Info.plist @@ -50,6 +50,7 @@ NSBonjourServices _playwith._tcp + _playwith._udp GADApplicationIdentifier ca-app-pub-3940256099942544~1458002511 @@ -67,5 +68,16 @@ 정답을 음성으로 말하기 위해 마이크 권한이 필요합니다. NSSpeechRecognitionUsageDescription 말한 내용을 텍스트로 변환하여 정답을 확인합니다. + com.apple.developer.networking.wifi-info + + NEHotspotConfiguration + + NSBluetoothAlwaysUsageDescription +주변 친구를 찾기 위해 블루투스를 사용합니다. +NSBluetoothPeripheralUsageDescription +주변 친구를 찾기 위해 블루투스를 사용합니다. +NSLocalNetworkUsageDescription +로컬 통신을 위해 필요합니다. + diff --git a/apps/app/ios/Runner/Runner.entitlements b/apps/app/ios/Runner/Runner.entitlements new file mode 100644 index 0000000..7026276 --- /dev/null +++ b/apps/app/ios/Runner/Runner.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.developer.networking.HotspotConfiguration + + + diff --git a/apps/app/lib/lobby_screen.dart b/apps/app/lib/lobby_screen.dart index 6758ab9..455c63e 100644 --- a/apps/app/lib/lobby_screen.dart +++ b/apps/app/lib/lobby_screen.dart @@ -1,10 +1,14 @@ import 'dart:convert'; +import 'dart:io'; import 'package:bonsoir/bonsoir.dart'; import 'package:flutter/material.dart'; import 'package:mobile_scanner/mobile_scanner.dart'; import 'package:playwith_core/playwith_core.dart'; -import 'package:qr_flutter/qr_flutter.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:wifi_iot/wifi_iot.dart'; +import 'package:network_info_plus/network_info_plus.dart'; +import 'package:permission_handler/permission_handler.dart'; class LobbyScreen extends StatefulWidget { const LobbyScreen({super.key}); @@ -28,7 +32,6 @@ class _LobbyScreenState extends State { _logs.add(log); if (_logs.length > 100) _logs.removeAt(0); }); - if (SettingsNotifier().isShowDebugLog) { WidgetsBinding.instance.addPostFrameCallback((_) { if (_scrollController.hasClients) { @@ -45,40 +48,89 @@ class _LobbyScreenState extends State { _net.messageStream.listen((data) { if (data['type'] == 'GAME_START') { final String gameId = data['gameId']; - - if (gameId == 'quiz_ox' || gameId == 'quiz_mix') { - _startGameAndNavigate(QuizGame()); - } - else if (gameId == 'sudoku_battle') { - _startGameAndNavigate(SudokuMultiGame()); - } - else if (gameId == 'spider_battle') { - _startGameAndNavigate(SpiderMultiGame()); - } - // [추가] 오목 & 장기 연결 - else if (gameId == 'omok') { - _startGameAndNavigate(OmokGame()); - } - else if (gameId == 'janggi') { - _startGameAndNavigate(JanggiGame()); - } - else if (gameId == 'yutnori') { - _startGameAndNavigate(YutnoriGame()); - } - else if (gameId == 'memory_battle') { - _startGameAndNavigate(MemoryGame()); - } - // [추가] 밸런스 & 터치 배틀 연결 - else if (gameId == 'balance_game') { - _startGameAndNavigate(BalanceGame()); - } - else if (gameId == 'tap_battle') { - _startGameAndNavigate(TapBattleGame()); - } + _routeToGame(gameId); } }); } + +Future _showSpiderDifficultyDialog() { + return showDialog( + context: context, + barrierDismissible: false, + builder: (context) => AlertDialog( + title: const Text("스파이더 난이도"), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + title: const Text("초급 (1가지 무늬)"), + subtitle: const Text("스페이드만 사용"), + leading: const Icon(Icons.looks_one, color: Colors.green), + onTap: () => Navigator.pop(context, 1), + ), + ListTile( + title: const Text("중급 (2가지 무늬)"), + subtitle: const Text("스페이드 + 하트"), + leading: const Icon(Icons.looks_two, color: Colors.orange), + onTap: () => Navigator.pop(context, 2), + ), + ListTile( + title: const Text("고급 (4가지 무늬)"), + subtitle: const Text("모든 무늬 사용"), + leading: const Icon(Icons.looks_4, color: Colors.red), + onTap: () => Navigator.pop(context, 4), + ), + ], + ), + actions: [TextButton(onPressed: () => Navigator.pop(context, null), child: const Text("취소"))], + ), + ); + } + + void _routeToGame(String gameId) { + if (gameId == 'quiz_ox' || gameId == 'quiz_mix') { + _startGameAndNavigate(QuizGame()); + } else if (gameId == 'sudoku_battle') { + _startGameAndNavigate(SudokuMultiGame()); + } else if (gameId == 'spider_battle') { + _startGameAndNavigate(SpiderMultiGame()); + } else if (gameId == 'omok') { + _startGameAndNavigate(OmokGame()); + } else if (gameId == 'janggi') { + _startGameAndNavigate(JanggiGame()); + } else if (gameId == 'yutnori') { + _startGameAndNavigate(YutnoriGame()); + } else if (gameId == 'memory_battle') { + _startGameAndNavigate(MemoryGame()); + } else if (gameId == 'balance_game') { + _startGameAndNavigate(BalanceGame()); + } else if (gameId == 'tap_battle') { + _startGameAndNavigate(TapBattleGame()); + } else if (gameId == 'world_tour') { + _startGameAndNavigate(WorldTourGame()); + } else if (gameId == 'othello') { + _startGameAndNavigate(OthelloGame()); + } else if (gameId == 'arkanoid') { + _startGameAndNavigate(ArkanoidGame()); + }else if (gameId == 'math_run') { + _startGameAndNavigate(MathRunGame()); + } + else if (gameId == 'jump_battle') { + _startGameAndNavigate(JumpGame()); + } + // [추가] 아이엠그라운드 연결 + else if (gameId == 'iam_ground') { + _startGameAndNavigate(IAmGroundGame()); + } + else if (gameId == 'survivor') { + _startGameAndNavigate(SurvivorGame()); + } + else if (gameId == 'sequence_memory') { + _startGameAndNavigate(SequenceMemoryGame()); + } + } + Future _startGameAndNavigate(BaseGame game) async { if (!mounted) return; game.onStart(); @@ -96,7 +148,6 @@ class _LobbyScreenState extends State { children: [ gameView, const SafeArea( - // 배너 광고 높이만큼 띄워서 채팅창 표시 child: GameChatOverlay(bottomOffset: 60.0), ), ], @@ -104,8 +155,6 @@ class _LobbyScreenState extends State { }), ); - // [수정] 게임 종료 후 복귀 시 로직 - // 솔로 모드였다면 네트워크를 종료하고 초기 화면으로 돌아감 if (_net.hostIp == "Solo Mode") { _net.stopNetwork(); } @@ -117,16 +166,21 @@ class _LobbyScreenState extends State { MaterialPageRoute( builder: (context) => GameSelectionScreen( onGameSelected: (gameId) async { - // 스도쿠 선택 시 난이도 팝업 Map config = {}; if (gameId == 'sudoku_battle') { final difficulty = await _showDifficultyDialog(); - if (difficulty == null) return; // 취소함 + if (difficulty == null) return; config['difficulty'] = difficulty; } +// [추가] 스파이더 난이도 + else if (gameId == 'spider_battle') { + final suits = await _showSpiderDifficultyDialog(); + if (suits == null) return; + config['difficulty'] = suits; // numSuits (1, 2, 4) + } if (!mounted) return; - Navigator.pop(context); // 선택 화면 닫기 + Navigator.pop(context); if (isSolo) { _net.startSoloMode(gameId, config: config); @@ -144,7 +198,6 @@ class _LobbyScreenState extends State { ); } - // [추가] 난이도 선택 다이얼로그 Future _showDifficultyDialog() { return showDialog( context: context, @@ -154,39 +207,44 @@ class _LobbyScreenState extends State { content: Column( mainAxisSize: MainAxisSize.min, children: [ - ListTile( - title: const Text("쉬움"), - leading: const Icon(Icons.filter_1, color: Colors.green), - onTap: () => Navigator.pop(context, 4), - ), - ListTile( - title: const Text("보통"), - leading: const Icon(Icons.filter_2, color: Colors.blue), - onTap: () => Navigator.pop(context, 5), // 4~5 레벨이 보통 9x9 - ), - ListTile( - title: const Text("약간 어려움"), - leading: const Icon(Icons.filter_3, color: Colors.red), - onTap: () => Navigator.pop(context, 6), // 7 레벨이 어려움 - ), - ListTile( - title: const Text("약간 어려움"), - leading: const Icon(Icons.filter_4, color: Colors.red), - onTap: () => Navigator.pop(context, 7), // 7 레벨이 어려움 - ), - ListTile( - title: const Text("개 어려움"), - leading: const Icon(Icons.filter_5, color: Colors.red), - onTap: () => Navigator.pop(context, 8), // 7 레벨이 어려움 - ), + ListTile(title: const Text("쉬움 (4x4)"), onTap: () => Navigator.pop(context, 1)), + ListTile(title: const Text("보통 (9x9)"), onTap: () => Navigator.pop(context, 4)), + ListTile(title: const Text("어려움 (9x9)"), onTap: () => Navigator.pop(context, 7)), ], ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context, null), - child: const Text("취소"), - ) - ], + actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text("취소"))], + ), + ); + } + + void _openGameSelector() { + if (_net.role != NetworkRole.host) return; + + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => GameSelectionScreen( + onGameSelected: (gameId) async { + Map config = {}; + if (gameId == 'sudoku_battle') { + final difficulty = await _showDifficultyDialog(); + if (difficulty == null) return; + config['difficulty'] = difficulty; + } + else if (gameId == 'spider_battle') { + final suits = await _showSpiderDifficultyDialog(); + if (suits == null) return; + config['difficulty'] = suits; + } + + if (!mounted) return; + Navigator.pop(context); + + if (_net.role == NetworkRole.host) { + _net.selectGame(gameId, config: config); + } + }, + ), ), ); } @@ -228,9 +286,7 @@ class _LobbyScreenState extends State { : _buildLobbyView() ), const Divider(thickness: 1, height: 1), - - if (SettingsNotifier().isShowDebugLog) - _buildDebugConsole(), + if (SettingsNotifier().isShowDebugLog) _buildDebugConsole(), ], ), ); @@ -297,23 +353,33 @@ class _LobbyScreenState extends State { Widget _buildLobbyView() { final currentGame = AppGames.getById(_net.selectedGameId); + final bool isHost = _net.role == NetworkRole.host; return Column( children: [ - Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 20), - color: Colors.indigo.withOpacity(0.1), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(currentGame.icon, size: 20, color: Colors.indigo), - const SizedBox(width: 8), - Text( - "선택된 게임: ${currentGame.name}", - style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.indigo), - ), - ], + GestureDetector( + onTap: isHost ? _openGameSelector : null, + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 20), + color: Colors.indigo.withOpacity(0.1), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(currentGame.icon, size: 24, color: Colors.indigo), + const SizedBox(width: 10), + Column( + children: [ + Text( + "현재 게임: ${currentGame.name}", + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.indigo), + ), + if (isHost) + const Text("(눌러서 변경)", style: TextStyle(fontSize: 12, color: Colors.grey)), + ], + ), + ], + ), ), ), @@ -326,15 +392,15 @@ class _LobbyScreenState extends State { Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(_net.role == NetworkRole.host ? Icons.wifi_tethering : Icons.wifi, color: Colors.blue), + Icon(isHost ? Icons.wifi_tethering : Icons.wifi, color: Colors.blue), const SizedBox(width: 10), Text( - _net.role == NetworkRole.host ? "👑 방장 (나)" : "참가자 (나)", + isHost ? "👑 방장 (나)" : "참가자 (나)", style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), ), ], ), - if (_net.role == NetworkRole.host) ...[ + if (isHost) ...[ const SizedBox(height: 10), Row( mainAxisAlignment: MainAxisAlignment.center, @@ -350,8 +416,11 @@ class _LobbyScreenState extends State { ) ], ), - const SizedBox(height: 5), - const Text("QR 코드를 눌러 친구를 초대하세요!", style: TextStyle(fontSize: 12, color: Colors.grey)), + TextButton.icon( + icon: const Icon(Icons.wifi_password), + label: const Text("핫스팟(야외용) QR 만들기"), + onPressed: () => _showHotspotCreateDialog(), + ), ] else ...[ const SizedBox(height: 10), Text("방장 IP: ${_net.hostIp ?? '...'}", style: const TextStyle(color: Colors.grey)), @@ -369,7 +438,7 @@ class _LobbyScreenState extends State { _buildUserTile(_net.me, isMe: true), ..._net.guestList.map((guest) => _buildUserTile(guest, isMe: false)), - if (_net.guestList.isEmpty && _net.role == NetworkRole.host) + if (_net.guestList.isEmpty && isHost) const Padding( padding: EdgeInsets.all(40.0), child: Center(child: Text("참가자를 기다리는 중...\nQR 코드를 보여주세요.", textAlign: TextAlign.center, style: TextStyle(color: Colors.grey))), @@ -412,6 +481,7 @@ class _LobbyScreenState extends State { Widget _buildReadyButton() { bool isReady = _net.me.isReady; bool canReady = _net.role == NetworkRole.host ? _net.guestList.isNotEmpty : true; + if (_net.hostIp == "Solo Mode") canReady = true; return Container( width: double.infinity, @@ -466,10 +536,52 @@ class _LobbyScreenState extends State { ); } + void _showHotspotCreateDialog() { + final ssidCtrl = TextEditingController(); + final pwCtrl = TextEditingController(); + + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text("핫스팟 QR 만들기"), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text("스마트폰 설정에서 핫스팟을 켜고,\n그 정보를 입력해주세요.", style: TextStyle(fontSize: 12, color: Colors.grey)), + const SizedBox(height: 10), + TextField(controller: ssidCtrl, decoration: const InputDecoration(labelText: "핫스팟 이름 (SSID)")), + TextField(controller: pwCtrl, decoration: const InputDecoration(labelText: "비밀번호")), + ], + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), child: const Text("취소")), + ElevatedButton( + onPressed: () { + if (ssidCtrl.text.isEmpty) return; + Navigator.pop(ctx); + + final Map qrData = { + 'type': 'hotspot_invite', + 'ssid': ssidCtrl.text, + 'pwd': pwCtrl.text, + 'port': _net.hostPort ?? 0, + }; + _showGeneratedQR(jsonEncode(qrData), "핫스팟 + 게임 접속 QR"); + }, + child: const Text("생성"), + ) + ], + ), + ); + } + void _showHostQRDialog() { if (_net.hostIp == null || _net.hostPort == null) return; final qrData = jsonEncode({'ip': _net.hostIp, 'port': _net.hostPort}); - + _showGeneratedQR(qrData, "초대 QR 코드 (같은 와이파이)"); + } + + void _showGeneratedQR(String data, String title) { showDialog( context: context, builder: (context) => Dialog( @@ -479,16 +591,14 @@ class _LobbyScreenState extends State { child: Column( mainAxisSize: MainAxisSize.min, children: [ - const Text("초대 QR 코드", style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold)), + Text(title, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), const SizedBox(height: 20), Container( padding: const EdgeInsets.all(10), decoration: BoxDecoration(border: Border.all(color: Colors.black12), borderRadius: BorderRadius.circular(10)), - child: QrImageView(data: qrData, version: QrVersions.auto, size: 220.0), + child: QrImageView(data: data, version: QrVersions.auto, size: 220.0), ), const SizedBox(height: 20), - SelectableText("IP: ${_net.hostIp}\nPort: ${_net.hostPort}", textAlign: TextAlign.center, style: const TextStyle(color: Colors.grey)), - const SizedBox(height: 20), ElevatedButton(onPressed: () => Navigator.pop(context), child: const Text("닫기")) ], ), @@ -497,34 +607,6 @@ class _LobbyScreenState extends State { ); } - void _openQRScanner() { - bool isScanCompleted = false; - Navigator.of(context).push(MaterialPageRoute( - builder: (context) => Scaffold( - appBar: AppBar(title: const Text("QR 스캔")), - body: MobileScanner( - onDetect: (capture) { - if (isScanCompleted) return; - for (final barcode in capture.barcodes) { - if (barcode.rawValue != null) { - try { - final data = jsonDecode(barcode.rawValue!); - if (data['ip'] != null && data['port'] != null) { - isScanCompleted = true; - Navigator.pop(context); - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("접속 중..."))); - _net.joinRoom(data['ip'], data['port']); - return; - } - } catch (e) {} - } - } - }, - ), - ), - )); - } - void _showRoomListDialog() { showDialog( context: context, @@ -565,6 +647,97 @@ class _LobbyScreenState extends State { ); } + void _openQRScanner() { + bool isScanCompleted = false; + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => Scaffold( + appBar: AppBar(title: const Text("QR 스캔")), + body: MobileScanner( + onDetect: (capture) async { + if (isScanCompleted) return; + final List barcodes = capture.barcodes; + + for (final barcode in barcodes) { + final String? rawValue = barcode.rawValue; + if (rawValue == null) continue; + + try { + final data = jsonDecode(rawValue); + + // 핫스팟 자동 접속 + if (data['type'] == 'hotspot_invite') { + isScanCompleted = true; + await _connectToHotspotAndJoin( + data['ssid'], + data['pwd'], + data['port'] + ); + return; + } + + // 일반 게임 접속 + if (data['ip'] != null && data['port'] != null) { + isScanCompleted = true; + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("접속 중..."))); + _net.joinRoom(data['ip'], data['port']); + return; + } + } catch (e) { + // JSON 아님 + } + } + }, + ), + ), + )); + } + + Future _connectToHotspotAndJoin(String ssid, String pwd, int port) async { + if (Platform.isAndroid) { + var status = await Permission.location.request(); + if (!status.isGranted) return; + } + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("핫스팟 '$ssid' 연결 시도 중...")), + ); + + try { + bool connected = await WiFiForIoTPlugin.connect( + ssid, + password: pwd, + security: NetworkSecurity.WPA, + joinOnce: true, + withInternet: false, + ); + + if (connected) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("와이파이 연결 성공! 방장을 찾는 중...")), + ); + + final info = NetworkInfo(); + String? gatewayIp = await info.getWifiGatewayIP(); + + if (gatewayIp != null) { + await Future.delayed(const Duration(seconds: 1)); + if (!mounted) return; + Navigator.pop(context); + _net.joinRoom(gatewayIp, port); + } else { + throw Exception("방장 IP를 찾을 수 없습니다."); + } + } else { + throw Exception("와이파이 연결 실패."); + } + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("오류: $e")), + ); + } + } + void _showManualJoinDialog() { final ipCtrl = TextEditingController(text: "192.168."); final portCtrl = TextEditingController(); diff --git a/apps/app/macos/Flutter/GeneratedPluginRegistrant.swift b/apps/app/macos/Flutter/GeneratedPluginRegistrant.swift index 6af84d1..ee99d22 100644 --- a/apps/app/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/apps/app/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,11 +7,13 @@ import Foundation import audioplayers_darwin import bonsoir_darwin +import device_info_plus import file_picker import file_selector_macos import flutter_local_notifications import gal import mobile_scanner +import network_info_plus import shared_preferences_foundation import speech_to_text import sqlite3_flutter_libs @@ -21,11 +23,13 @@ import webview_flutter_wkwebview func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin")) SwiftBonsoirPlugin.register(with: registry.registrar(forPlugin: "SwiftBonsoirPlugin")) + DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) GalPlugin.register(with: registry.registrar(forPlugin: "GalPlugin")) MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin")) + NetworkInfoPlusPlugin.register(with: registry.registrar(forPlugin: "NetworkInfoPlusPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SpeechToTextPlugin.register(with: registry.registrar(forPlugin: "SpeechToTextPlugin")) Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin")) diff --git a/apps/app/macos/Podfile b/apps/app/macos/Podfile index ff5ddb3..167132a 100644 --- a/apps/app/macos/Podfile +++ b/apps/app/macos/Podfile @@ -1,4 +1,4 @@ -platform :osx, '10.15' +platform :osx, '12.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/apps/app/macos/Podfile.lock b/apps/app/macos/Podfile.lock new file mode 100644 index 0000000..dea93a5 --- /dev/null +++ b/apps/app/macos/Podfile.lock @@ -0,0 +1,136 @@ +PODS: + - audioplayers_darwin (0.0.1): + - Flutter + - FlutterMacOS + - bonsoir_darwin (0.0.1): + - Flutter + - FlutterMacOS + - CwlCatchException (2.2.1): + - CwlCatchExceptionSupport (~> 2.2.1) + - CwlCatchExceptionSupport (2.2.1) + - file_picker (0.0.1): + - FlutterMacOS + - file_selector_macos (0.0.1): + - FlutterMacOS + - flutter_local_notifications (0.0.1): + - FlutterMacOS + - FlutterMacOS (1.0.0) + - gal (1.0.0): + - Flutter + - FlutterMacOS + - mobile_scanner (5.2.3): + - FlutterMacOS + - objective_c (0.0.1): + - FlutterMacOS + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + - speech_to_text (7.2.0): + - CwlCatchException + - Flutter + - FlutterMacOS + - sqlite3 (3.50.4): + - sqlite3/common (= 3.50.4) + - sqlite3/common (3.50.4) + - sqlite3/dbstatvtab (3.50.4): + - sqlite3/common + - sqlite3/fts5 (3.50.4): + - sqlite3/common + - sqlite3/math (3.50.4): + - sqlite3/common + - sqlite3/perf-threadsafe (3.50.4): + - sqlite3/common + - sqlite3/rtree (3.50.4): + - sqlite3/common + - sqlite3/session (3.50.4): + - sqlite3/common + - sqlite3_flutter_libs (0.0.1): + - Flutter + - FlutterMacOS + - sqlite3 (~> 3.50.4) + - sqlite3/dbstatvtab + - sqlite3/fts5 + - sqlite3/math + - sqlite3/perf-threadsafe + - sqlite3/rtree + - sqlite3/session + - url_launcher_macos (0.0.1): + - FlutterMacOS + - webview_flutter_wkwebview (0.0.1): + - Flutter + - FlutterMacOS + +DEPENDENCIES: + - audioplayers_darwin (from `Flutter/ephemeral/.symlinks/plugins/audioplayers_darwin/darwin`) + - bonsoir_darwin (from `Flutter/ephemeral/.symlinks/plugins/bonsoir_darwin/darwin`) + - file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`) + - file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`) + - flutter_local_notifications (from `Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos`) + - FlutterMacOS (from `Flutter/ephemeral`) + - gal (from `Flutter/ephemeral/.symlinks/plugins/gal/darwin`) + - mobile_scanner (from `Flutter/ephemeral/.symlinks/plugins/mobile_scanner/macos`) + - objective_c (from `Flutter/ephemeral/.symlinks/plugins/objective_c/macos`) + - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) + - speech_to_text (from `Flutter/ephemeral/.symlinks/plugins/speech_to_text/darwin`) + - sqlite3_flutter_libs (from `Flutter/ephemeral/.symlinks/plugins/sqlite3_flutter_libs/darwin`) + - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) + - webview_flutter_wkwebview (from `Flutter/ephemeral/.symlinks/plugins/webview_flutter_wkwebview/darwin`) + +SPEC REPOS: + trunk: + - CwlCatchException + - CwlCatchExceptionSupport + - sqlite3 + +EXTERNAL SOURCES: + audioplayers_darwin: + :path: Flutter/ephemeral/.symlinks/plugins/audioplayers_darwin/darwin + bonsoir_darwin: + :path: Flutter/ephemeral/.symlinks/plugins/bonsoir_darwin/darwin + file_picker: + :path: Flutter/ephemeral/.symlinks/plugins/file_picker/macos + file_selector_macos: + :path: Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos + flutter_local_notifications: + :path: Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos + FlutterMacOS: + :path: Flutter/ephemeral + gal: + :path: Flutter/ephemeral/.symlinks/plugins/gal/darwin + mobile_scanner: + :path: Flutter/ephemeral/.symlinks/plugins/mobile_scanner/macos + objective_c: + :path: Flutter/ephemeral/.symlinks/plugins/objective_c/macos + shared_preferences_foundation: + :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin + speech_to_text: + :path: Flutter/ephemeral/.symlinks/plugins/speech_to_text/darwin + sqlite3_flutter_libs: + :path: Flutter/ephemeral/.symlinks/plugins/sqlite3_flutter_libs/darwin + url_launcher_macos: + :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos + webview_flutter_wkwebview: + :path: Flutter/ephemeral/.symlinks/plugins/webview_flutter_wkwebview/darwin + +SPEC CHECKSUMS: + audioplayers_darwin: 4027b33a8f471d996c13f71cb77f0b1583b5d923 + bonsoir_darwin: e3b8526c42ca46a885142df84229131dfabea842 + CwlCatchException: 7acc161b299a6de7f0a46a6ed741eae2c8b4d75a + CwlCatchExceptionSupport: 54ccab8d8c78907b57f99717fb19d4cc3bce02dc + file_picker: e716a70a9fe5fd9e09ebc922d7541464289443af + file_selector_macos: 3e56eaea051180007b900eacb006686fd54da150 + flutter_local_notifications: 4b427ffabf278fc6ea9484c97505e231166927a5 + FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 + gal: 6a522c75909f1244732d4596d11d6a2f86ff37a5 + mobile_scanner: 0a05256215b047af27b9495db3b77640055e8824 + objective_c: e5f8194456e8fc943e034d1af00510a1bc29c067 + shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6 + speech_to_text: 87bf9298952e8d9073be1b6aade6d5758db5170c + sqlite3: 73513155ec6979715d3904ef53a8d68892d4032b + sqlite3_flutter_libs: 86f82662868ee26ff3451f73cac9c5fc2a1f57fa + url_launcher_macos: 175a54c831f4375a6cf895875f716ee5af3888ce + webview_flutter_wkwebview: 29eb20d43355b48fe7d07113835b9128f84e3af4 + +PODFILE CHECKSUM: 1e95c36afbfd1cb6423ceca4de7a8e1b256fb6ac + +COCOAPODS: 1.16.2 diff --git a/apps/app/macos/Runner.xcodeproj/project.pbxproj b/apps/app/macos/Runner.xcodeproj/project.pbxproj index 9124f66..8f07d42 100644 --- a/apps/app/macos/Runner.xcodeproj/project.pbxproj +++ b/apps/app/macos/Runner.xcodeproj/project.pbxproj @@ -21,12 +21,14 @@ /* End PBXAggregateTarget section */ /* Begin PBXBuildFile section */ + 16081A8E490D05A0EB9DCB3B /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C20AF64D20A0D998576B7391 /* Pods_Runner.framework */; }; 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 868F63F0C0F04AA311C4DEB8 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8AA200BBA16E846EF02EA220 /* Pods_RunnerTests.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -60,11 +62,12 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 2FD6D31AB2013B75CB16C72F /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; - 33CC10ED2044A3C60003C045 /* playwith_app.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "playwith_app.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10ED2044A3C60003C045 /* playwith_app.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = playwith_app.app; sourceTree = BUILT_PRODUCTS_DIR; }; 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; @@ -76,8 +79,15 @@ 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 5CCBBFA4C427193DF37044DA /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 5F844C09FCD307AF262BDB41 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 86F5450344B9BE2273976B6B /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + 8AA200BBA16E846EF02EA220 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 8B9DF8C58AFF7B81844064AC /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + BD1BC5FF85A673AC7738F6DF /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + C20AF64D20A0D998576B7391 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -85,6 +95,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 868F63F0C0F04AA311C4DEB8 /* Pods_RunnerTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -92,6 +103,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 16081A8E490D05A0EB9DCB3B /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -125,6 +137,7 @@ 331C80D6294CF71000263BE5 /* RunnerTests */, 33CC10EE2044A3C60003C045 /* Products */, D73912EC22F37F3D000D13A0 /* Frameworks */, + 855AC529D1809DF3B555D40F /* Pods */, ); sourceTree = ""; }; @@ -172,9 +185,25 @@ path = Runner; sourceTree = ""; }; + 855AC529D1809DF3B555D40F /* Pods */ = { + isa = PBXGroup; + children = ( + 5F844C09FCD307AF262BDB41 /* Pods-Runner.debug.xcconfig */, + 2FD6D31AB2013B75CB16C72F /* Pods-Runner.release.xcconfig */, + BD1BC5FF85A673AC7738F6DF /* Pods-Runner.profile.xcconfig */, + 5CCBBFA4C427193DF37044DA /* Pods-RunnerTests.debug.xcconfig */, + 86F5450344B9BE2273976B6B /* Pods-RunnerTests.release.xcconfig */, + 8B9DF8C58AFF7B81844064AC /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; D73912EC22F37F3D000D13A0 /* Frameworks */ = { isa = PBXGroup; children = ( + C20AF64D20A0D998576B7391 /* Pods_Runner.framework */, + 8AA200BBA16E846EF02EA220 /* Pods_RunnerTests.framework */, ); name = Frameworks; sourceTree = ""; @@ -186,6 +215,7 @@ isa = PBXNativeTarget; buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( + 289049F07BF5608C0DEEA906 /* [CP] Check Pods Manifest.lock */, 331C80D1294CF70F00263BE5 /* Sources */, 331C80D2294CF70F00263BE5 /* Frameworks */, 331C80D3294CF70F00263BE5 /* Resources */, @@ -204,11 +234,13 @@ isa = PBXNativeTarget; buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( + 772657A59DCC9D8E14DB7114 /* [CP] Check Pods Manifest.lock */, 33CC10E92044A3C60003C045 /* Sources */, 33CC10EA2044A3C60003C045 /* Frameworks */, 33CC10EB2044A3C60003C045 /* Resources */, 33CC110E2044A8840003C045 /* Bundle Framework */, 3399D490228B24CF009A79C7 /* ShellScript */, + C8ACC0A85F9FDC2B7315ABC5 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -291,6 +323,28 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + 289049F07BF5608C0DEEA906 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; 3399D490228B24CF009A79C7 /* ShellScript */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -329,6 +383,45 @@ shellPath = /bin/sh; shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; }; + 772657A59DCC9D8E14DB7114 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + C8ACC0A85F9FDC2B7315ABC5 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -380,6 +473,7 @@ /* Begin XCBuildConfiguration section */ 331C80DB294CF71000263BE5 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 5CCBBFA4C427193DF37044DA /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; @@ -394,6 +488,7 @@ }; 331C80DC294CF71000263BE5 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 86F5450344B9BE2273976B6B /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; @@ -408,6 +503,7 @@ }; 331C80DD294CF71000263BE5 /* Profile */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 8B9DF8C58AFF7B81844064AC /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; diff --git a/apps/app/macos/Runner.xcworkspace/contents.xcworkspacedata b/apps/app/macos/Runner.xcworkspace/contents.xcworkspacedata index 1d526a1..21a3cc1 100644 --- a/apps/app/macos/Runner.xcworkspace/contents.xcworkspacedata +++ b/apps/app/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -4,4 +4,7 @@ + + diff --git a/apps/app/pubspec.lock b/apps/app/pubspec.lock index 2304590..4e6c6f9 100644 --- a/apps/app/pubspec.lock +++ b/apps/app/pubspec.lock @@ -185,6 +185,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.11" + device_info_plus: + dependency: transitive + description: + name: device_info_plus + sha256: a7fd703482b391a87d60b6061d04dfdeab07826b96f9abd8f5ed98068acc0074 + url: "https://pub.dev" + source: hosted + version: "10.1.2" + device_info_plus_platform_interface: + dependency: transitive + description: + name: device_info_plus_platform_interface + sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f + url: "https://pub.dev" + source: hosted + version: "7.0.3" drift: dependency: transitive description: @@ -504,6 +520,38 @@ packages: url: "https://pub.dev" source: hosted version: "5.2.3" + nearby_connections: + dependency: transitive + description: + name: nearby_connections + sha256: "94d500bdb11f9a3db3b1cb2949ab438107e581f0142380efc17ecc8038e99369" + url: "https://pub.dev" + source: hosted + version: "4.3.0" + network_info_plus: + dependency: transitive + description: + name: network_info_plus + sha256: "5bd4b86e28fed5ed4e6ac7764133c031dfb7d3f46aa2a81b46f55038aa78ecc0" + url: "https://pub.dev" + source: hosted + version: "5.0.3" + network_info_plus_platform_interface: + dependency: transitive + description: + name: network_info_plus_platform_interface + sha256: "7e7496a8a9d8136859b8881affc613c4a21304afeb6c324bcefc4bd0aff6b94b" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + nm: + dependency: transitive + description: + name: nm + sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254" + url: "https://pub.dev" + source: hosted + version: "0.5.0" objective_c: dependency: transitive description: @@ -647,13 +695,6 @@ packages: relative: true source: path version: "0.0.1" - playwith_game_quiz: - dependency: "direct main" - description: - path: "../../packages/games/quiz" - relative: true - source: path - version: "0.0.1" plugin_platform_interface: dependency: transitive description: @@ -987,6 +1028,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.23.4" + wifi_iot: + dependency: transitive + description: + name: wifi_iot + sha256: "0861aed0c0afd6031b4337811d31cdd181c594a8a2c73e94826ea21d2cb4707b" + url: "https://pub.dev" + source: hosted + version: "0.3.19+2" win32: dependency: transitive description: @@ -995,6 +1044,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.15.0" + win32_registry: + dependency: transitive + description: + name: win32_registry + sha256: "21ec76dfc731550fd3e2ce7a33a9ea90b828fdf19a5c3bcf556fa992cfa99852" + url: "https://pub.dev" + source: hosted + version: "1.1.5" xdg_directories: dependency: transitive description: diff --git a/apps/app/pubspec.yaml b/apps/app/pubspec.yaml index 628c228..2015a7f 100644 --- a/apps/app/pubspec.yaml +++ b/apps/app/pubspec.yaml @@ -33,8 +33,6 @@ dependencies: # 로컬 패키지 추가 playwith_core: path: ../../packages/core - playwith_game_quiz: - path: ../../packages/games/quiz permission_handler: ^11.0.0 qr_flutter: ^4.1.0 mobile_scanner: ^5.1.0 diff --git a/packages/core/lib/database/ephemeral_database.dart b/packages/core/lib/database/ephemeral_database.dart index e58cb89..939e91e 100644 --- a/packages/core/lib/database/ephemeral_database.dart +++ b/packages/core/lib/database/ephemeral_database.dart @@ -4,44 +4,61 @@ import 'package:drift/native.dart'; import 'package:path_provider/path_provider.dart'; import 'package:path/path.dart' as p; -part 'ephemeral_database.g.dart'; // (나중에 생성될 파일) +part 'ephemeral_database.g.dart'; -// 테이블 정의: 미디어 메타데이터 +// 기존 MediaItems 테이블 class MediaItems extends Table { - TextColumn get id => text()(); // UUID + TextColumn get id => text()(); TextColumn get senderId => text()(); TextColumn get senderName => text()(); - TextColumn get type => text()(); // 'IMAGE', 'VIDEO', 'AUDIO' - TextColumn get filePath => text()(); // 로컬 저장 경로 + TextColumn get type => text()(); + TextColumn get filePath => text()(); DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); @override Set get primaryKey => {id}; } -@DriftDatabase(tables: [MediaItems]) +// [추가] 패킷 로그 테이블 (재전송용) +class PacketLogs extends Table { + IntColumn get seq => integer()(); // 순번 (Primary Key) + TextColumn get payload => text()(); // JSON 데이터 + DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); + + @override + Set get primaryKey => {seq}; +} + +@DriftDatabase(tables: [MediaItems, PacketLogs]) // [수정] PacketLogs 추가 class EphemeralDatabase extends _$EphemeralDatabase { - // 싱글톤이 아닌 인스턴스로 관리 (방 만들 때 생성, 나갈 때 close) EphemeralDatabase(QueryExecutor e) : super(e); @override - int get schemaVersion => 1; + int get schemaVersion => 2; // [수정] 버전 업 (기존 앱 삭제 후 설치 권장) - // 팩토리: 파일 기반 DB 생성 static Future create(String roomName) async { final dbFolder = await getApplicationDocumentsDirectory(); - // 방 이름이나 ID로 파일명 구분 final file = File(p.join(dbFolder.path, 'room_$roomName.sqlite')); return EphemeralDatabase(NativeDatabase(file)); } - // CRUD 쿼리 Future> getAllMedia() => select(mediaItems).get(); Future insertMedia(MediaItemsCompanion entry) => into(mediaItems).insert(entry); - // [핵심] 방 폭파 시 데이터 삭제 + // [추가] 패킷 저장 + Future logPacket(int seq, String json) { + return into(packetLogs).insert(PacketLogsCompanion( + seq: Value(seq), + payload: Value(json), + )); + } + + // [추가] 특정 범위의 패킷 가져오기 (재전송 요청 시 사용) + Future> getPacketsInRange(int fromSeq, int toSeq) { + return (select(packetLogs)..where((tbl) => tbl.seq.isBetweenValues(fromSeq, toSeq))).get(); + } + Future wipeData() async { - await close(); // DB 연결 종료 - // 실제 파일 삭제 로직은 Manager에서 수행 (DB 파일 자체를 날림) + await close(); } } \ No newline at end of file diff --git a/packages/core/lib/database/ephemeral_database.g.dart b/packages/core/lib/database/ephemeral_database.g.dart index dd6ccb7..91dd21a 100644 --- a/packages/core/lib/database/ephemeral_database.g.dart +++ b/packages/core/lib/database/ephemeral_database.g.dart @@ -351,15 +351,229 @@ class MediaItemsCompanion extends UpdateCompanion { } } +class $PacketLogsTable extends PacketLogs + with TableInfo<$PacketLogsTable, PacketLog> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $PacketLogsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _seqMeta = const VerificationMeta('seq'); + @override + late final GeneratedColumn seq = GeneratedColumn( + 'seq', aliasedName, false, + type: DriftSqlType.int, requiredDuringInsert: false); + static const VerificationMeta _payloadMeta = + const VerificationMeta('payload'); + @override + late final GeneratedColumn payload = GeneratedColumn( + 'payload', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + static const VerificationMeta _createdAtMeta = + const VerificationMeta('createdAt'); + @override + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', aliasedName, false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: currentDateAndTime); + @override + List get $columns => [seq, payload, createdAt]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'packet_logs'; + @override + VerificationContext validateIntegrity(Insertable instance, + {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('seq')) { + context.handle( + _seqMeta, seq.isAcceptableOrUnknown(data['seq']!, _seqMeta)); + } + if (data.containsKey('payload')) { + context.handle(_payloadMeta, + payload.isAcceptableOrUnknown(data['payload']!, _payloadMeta)); + } else if (isInserting) { + context.missing(_payloadMeta); + } + if (data.containsKey('created_at')) { + context.handle(_createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta)); + } + return context; + } + + @override + Set get $primaryKey => {seq}; + @override + PacketLog map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return PacketLog( + seq: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}seq'])!, + payload: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}payload'])!, + createdAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!, + ); + } + + @override + $PacketLogsTable createAlias(String alias) { + return $PacketLogsTable(attachedDatabase, alias); + } +} + +class PacketLog extends DataClass implements Insertable { + final int seq; + final String payload; + final DateTime createdAt; + const PacketLog( + {required this.seq, required this.payload, required this.createdAt}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['seq'] = Variable(seq); + map['payload'] = Variable(payload); + map['created_at'] = Variable(createdAt); + return map; + } + + PacketLogsCompanion toCompanion(bool nullToAbsent) { + return PacketLogsCompanion( + seq: Value(seq), + payload: Value(payload), + createdAt: Value(createdAt), + ); + } + + factory PacketLog.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return PacketLog( + seq: serializer.fromJson(json['seq']), + payload: serializer.fromJson(json['payload']), + createdAt: serializer.fromJson(json['createdAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'seq': serializer.toJson(seq), + 'payload': serializer.toJson(payload), + 'createdAt': serializer.toJson(createdAt), + }; + } + + PacketLog copyWith({int? seq, String? payload, DateTime? createdAt}) => + PacketLog( + seq: seq ?? this.seq, + payload: payload ?? this.payload, + createdAt: createdAt ?? this.createdAt, + ); + PacketLog copyWithCompanion(PacketLogsCompanion data) { + return PacketLog( + seq: data.seq.present ? data.seq.value : this.seq, + payload: data.payload.present ? data.payload.value : this.payload, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + ); + } + + @override + String toString() { + return (StringBuffer('PacketLog(') + ..write('seq: $seq, ') + ..write('payload: $payload, ') + ..write('createdAt: $createdAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(seq, payload, createdAt); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is PacketLog && + other.seq == this.seq && + other.payload == this.payload && + other.createdAt == this.createdAt); +} + +class PacketLogsCompanion extends UpdateCompanion { + final Value seq; + final Value payload; + final Value createdAt; + const PacketLogsCompanion({ + this.seq = const Value.absent(), + this.payload = const Value.absent(), + this.createdAt = const Value.absent(), + }); + PacketLogsCompanion.insert({ + this.seq = const Value.absent(), + required String payload, + this.createdAt = const Value.absent(), + }) : payload = Value(payload); + static Insertable custom({ + Expression? seq, + Expression? payload, + Expression? createdAt, + }) { + return RawValuesInsertable({ + if (seq != null) 'seq': seq, + if (payload != null) 'payload': payload, + if (createdAt != null) 'created_at': createdAt, + }); + } + + PacketLogsCompanion copyWith( + {Value? seq, Value? payload, Value? createdAt}) { + return PacketLogsCompanion( + seq: seq ?? this.seq, + payload: payload ?? this.payload, + createdAt: createdAt ?? this.createdAt, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (seq.present) { + map['seq'] = Variable(seq.value); + } + if (payload.present) { + map['payload'] = Variable(payload.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('PacketLogsCompanion(') + ..write('seq: $seq, ') + ..write('payload: $payload, ') + ..write('createdAt: $createdAt') + ..write(')')) + .toString(); + } +} + abstract class _$EphemeralDatabase extends GeneratedDatabase { _$EphemeralDatabase(QueryExecutor e) : super(e); $EphemeralDatabaseManager get managers => $EphemeralDatabaseManager(this); late final $MediaItemsTable mediaItems = $MediaItemsTable(this); + late final $PacketLogsTable packetLogs = $PacketLogsTable(this); @override Iterable> get allTables => allSchemaEntities.whereType>(); @override - List get allSchemaEntities => [mediaItems]; + List get allSchemaEntities => [mediaItems, packetLogs]; } typedef $$MediaItemsTableCreateCompanionBuilder = MediaItemsCompanion Function({ @@ -548,10 +762,147 @@ typedef $$MediaItemsTableProcessedTableManager = ProcessedTableManager< ), MediaItem, PrefetchHooks Function()>; +typedef $$PacketLogsTableCreateCompanionBuilder = PacketLogsCompanion Function({ + Value seq, + required String payload, + Value createdAt, +}); +typedef $$PacketLogsTableUpdateCompanionBuilder = PacketLogsCompanion Function({ + Value seq, + Value payload, + Value createdAt, +}); + +class $$PacketLogsTableFilterComposer + extends Composer<_$EphemeralDatabase, $PacketLogsTable> { + $$PacketLogsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get seq => $composableBuilder( + column: $table.seq, builder: (column) => ColumnFilters(column)); + + ColumnFilters get payload => $composableBuilder( + column: $table.payload, builder: (column) => ColumnFilters(column)); + + ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, builder: (column) => ColumnFilters(column)); +} + +class $$PacketLogsTableOrderingComposer + extends Composer<_$EphemeralDatabase, $PacketLogsTable> { + $$PacketLogsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get seq => $composableBuilder( + column: $table.seq, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get payload => $composableBuilder( + column: $table.payload, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, builder: (column) => ColumnOrderings(column)); +} + +class $$PacketLogsTableAnnotationComposer + extends Composer<_$EphemeralDatabase, $PacketLogsTable> { + $$PacketLogsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get seq => + $composableBuilder(column: $table.seq, builder: (column) => column); + + GeneratedColumn get payload => + $composableBuilder(column: $table.payload, builder: (column) => column); + + GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); +} + +class $$PacketLogsTableTableManager extends RootTableManager< + _$EphemeralDatabase, + $PacketLogsTable, + PacketLog, + $$PacketLogsTableFilterComposer, + $$PacketLogsTableOrderingComposer, + $$PacketLogsTableAnnotationComposer, + $$PacketLogsTableCreateCompanionBuilder, + $$PacketLogsTableUpdateCompanionBuilder, + ( + PacketLog, + BaseReferences<_$EphemeralDatabase, $PacketLogsTable, PacketLog> + ), + PacketLog, + PrefetchHooks Function()> { + $$PacketLogsTableTableManager(_$EphemeralDatabase db, $PacketLogsTable table) + : super(TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$PacketLogsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$PacketLogsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$PacketLogsTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: ({ + Value seq = const Value.absent(), + Value payload = const Value.absent(), + Value createdAt = const Value.absent(), + }) => + PacketLogsCompanion( + seq: seq, + payload: payload, + createdAt: createdAt, + ), + createCompanionCallback: ({ + Value seq = const Value.absent(), + required String payload, + Value createdAt = const Value.absent(), + }) => + PacketLogsCompanion.insert( + seq: seq, + payload: payload, + createdAt: createdAt, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + )); +} + +typedef $$PacketLogsTableProcessedTableManager = ProcessedTableManager< + _$EphemeralDatabase, + $PacketLogsTable, + PacketLog, + $$PacketLogsTableFilterComposer, + $$PacketLogsTableOrderingComposer, + $$PacketLogsTableAnnotationComposer, + $$PacketLogsTableCreateCompanionBuilder, + $$PacketLogsTableUpdateCompanionBuilder, + ( + PacketLog, + BaseReferences<_$EphemeralDatabase, $PacketLogsTable, PacketLog> + ), + PacketLog, + PrefetchHooks Function()>; class $EphemeralDatabaseManager { final _$EphemeralDatabase _db; $EphemeralDatabaseManager(this._db); $$MediaItemsTableTableManager get mediaItems => $$MediaItemsTableTableManager(_db, _db.mediaItems); + $$PacketLogsTableTableManager get packetLogs => + $$PacketLogsTableTableManager(_db, _db.packetLogs); } diff --git a/packages/core/lib/game/arkanoid_game.dart b/packages/core/lib/game/arkanoid_game.dart new file mode 100644 index 0000000..9a166df --- /dev/null +++ b/packages/core/lib/game/arkanoid_game.dart @@ -0,0 +1,265 @@ +import 'dart:async'; +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; // [필수] Ticker 사용을 위해 추가 +import 'package:playwith_core/playwith_core.dart'; + +class ArkanoidGame extends BaseGame { + @override + String get id => "arkanoid"; + @override + String get name => "벽돌 깨기"; + @override + String get description => "공을 튕겨 점수를 올리세요!"; + + @override + void onMessageReceived(String senderId, Map payload) { + // UI에서 처리 + } + + @override + Widget buildHostView(BuildContext context) => ArkanoidScreen(isHost: true, gameInstance: this); + @override + Widget buildGuestView(BuildContext context) => ArkanoidScreen(isHost: false, gameInstance: this); +} + +class ArkanoidScreen extends StatefulWidget { + final bool isHost; + final ArkanoidGame gameInstance; + + const ArkanoidScreen({super.key, required this.isHost, required this.gameInstance}); + + @override + State createState() => _ArkanoidScreenState(); +} + +class _ArkanoidScreenState extends State with SingleTickerProviderStateMixin { + late Ticker _ticker; // [수정] 이제 에러가 사라질 것입니다. + + // 게임 상태 + double paddleX = 0.0; // -1.0 ~ 1.0 (화면 비율) + double ballX = 0.0; + double ballY = 0.0; + double ballVelX = 0.01; + double ballVelY = -0.015; + + int score = 0; + int opponentScore = 0; + int lives = 3; + bool isPlaying = false; + bool isGameOver = false; + + List bricks = []; + + @override + void initState() { + super.initState(); + _resetLevel(); + _ticker = createTicker(_gameLoop)..start(); + NetworkManager().messageStream.listen(_handleMessage); + } + + void _handleMessage(Map payload) { + if (!mounted) return; + if (payload['type'] == 'SCORE') { + setState(() { + opponentScore = payload['score']; + }); + } else if (payload['type'] == 'GAME_OVER_OPPONENT') { + // 상대방 게임 오버 알림 (선택 사항) + } + } + + void _resetLevel() { + // 벽돌 생성 (5줄) + bricks.clear(); + for (int i = 0; i < 5; i++) { + for (int j = -4; j <= 4; j++) { + bricks.add(Brick(x: j * 0.22, y: -0.8 + (i * 0.1))); + } + } + _resetBall(); + } + + void _resetBall() { + ballX = 0; + ballY = 0.5; + ballVelX = (Random().nextBool() ? 0.01 : -0.01); + ballVelY = -0.015; + isPlaying = false; + } + + void _gameLoop(Duration elapsed) { + if (!isPlaying || isGameOver) return; + + setState(() { + ballX += ballVelX; + ballY += ballVelY; + + // 벽 충돌 + if (ballX <= -1 || ballX >= 1) ballVelX = -ballVelX; + if (ballY <= -1) ballVelY = -ballVelY; // 천장 + + // 바닥 충돌 (라이프 감소) + if (ballY >= 1) { + lives--; + if (lives <= 0) { + isGameOver = true; + NetworkManager().sendMessage({'type': 'GAME_OVER_OPPONENT', 'score': score}); + _showGameOverDialog(); + } else { + _resetBall(); + } + } + + // 패들 충돌 (간략화: Y좌표가 0.9 근처이고 X범위 내일 때) + if (ballY >= 0.85 && ballY <= 0.95 && ballVelY > 0) { + if (ballX >= paddleX - 0.25 && ballX <= paddleX + 0.25) { + ballVelY = -ballVelY; + // 패들 맞은 위치에 따라 X속도 변화 (스핀 효과) + ballVelX += (ballX - paddleX) * 0.05; + SoundManager().playSfx(SoundKey.click); + } + } + + // 벽돌 충돌 + for (int i = 0; i < bricks.length; i++) { + if (!bricks[i].isBroken && + ballX >= bricks[i].x - 0.1 && ballX <= bricks[i].x + 0.1 && + ballY >= bricks[i].y - 0.05 && ballY <= bricks[i].y + 0.05) { + + bricks[i].isBroken = true; + ballVelY = -ballVelY; + score += 10; + + // 50점 단위로 점수 전송 + if (score % 50 == 0) { + NetworkManager().sendMessage({'type': 'SCORE', 'score': score}); + } + break; // 한 프레임에 하나만 깸 + } + } + + // 모든 벽돌 깸 -> 레벨 초기화 (무한 모드) + if (bricks.every((b) => b.isBroken)) { + _resetLevel(); + ballVelY *= 1.1; // 속도 증가 + } + }); + } + + void _onPanUpdate(DragUpdateDetails details) { + setState(() { + // 화면 너비를 -1 ~ 1 좌표계로 변환 + paddleX += details.delta.dx / (MediaQuery.of(context).size.width / 2); + paddleX = paddleX.clamp(-0.8, 0.8); + if (!isPlaying && !isGameOver) isPlaying = true; + }); + } + + void _showGameOverDialog() { + String result = score > opponentScore ? "이겼습니다! (상대: $opponentScore)" : "졌습니다... (상대: $opponentScore)"; + if (score == opponentScore) result = "무승부!"; + + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => AlertDialog( + title: const Text("게임 오버"), + content: Text("내 점수: $score\n$result"), + actions: [ + TextButton( + onPressed: () { Navigator.pop(context); Navigator.pop(context); }, + child: const Text("나가기"), + ) + ], + ), + ); + } + + @override + void dispose() { + _ticker.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text("나: $score"), + Text("❤️ $lives"), + Text("상대: $opponentScore"), + ], + ), + ), + backgroundColor: Colors.black, + body: GestureDetector( + onPanUpdate: _onPanUpdate, + child: Container( + color: Colors.transparent, + width: double.infinity, + height: double.infinity, + child: CustomPaint( + painter: ArkanoidPainter(paddleX: paddleX, ballX: ballX, ballY: ballY, bricks: bricks), + ), + ), + ), + ); + } +} + +class Brick { + double x, y; + bool isBroken = false; + Brick({required this.x, required this.y}); +} + +class ArkanoidPainter extends CustomPainter { + final double paddleX, ballX, ballY; + final List bricks; + + ArkanoidPainter({required this.paddleX, required this.ballX, required this.ballY, required this.bricks}); + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint()..color = Colors.white; + final center = size.center(Offset.zero); + + // 좌표 변환 함수 (-1~1 -> 화면 좌표) + double toScreenX(double v) => center.dx + v * (size.width / 2); + double toScreenY(double v) => center.dy + v * (size.height / 2); + + // 패들 그리기 + paint.color = Colors.blueAccent; + Rect paddleRect = Rect.fromCenter( + center: Offset(toScreenX(paddleX), toScreenY(0.9)), + width: size.width * 0.25, // 패들 너비 + height: 20, + ); + canvas.drawRRect(RRect.fromRectAndRadius(paddleRect, const Radius.circular(10)), paint); + + // 공 그리기 + paint.color = Colors.yellowAccent; + canvas.drawCircle(Offset(toScreenX(ballX), toScreenY(ballY)), 10, paint); + + // 벽돌 그리기 + paint.color = Colors.redAccent; + for (var b in bricks) { + if (!b.isBroken) { + Rect brickRect = Rect.fromCenter( + center: Offset(toScreenX(b.x), toScreenY(b.y)), + width: size.width * 0.2 - 5, + height: 20, + ); + canvas.drawRRect(RRect.fromRectAndRadius(brickRect, const Radius.circular(4)), paint); + } + } + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => true; +} \ No newline at end of file diff --git a/packages/core/lib/game/iam_ground_game.dart b/packages/core/lib/game/iam_ground_game.dart new file mode 100644 index 0000000..0e7930c --- /dev/null +++ b/packages/core/lib/game/iam_ground_game.dart @@ -0,0 +1,578 @@ +import 'dart:async'; +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:playwith_core/playwith_core.dart'; + +class IAmGroundGame extends BaseGame { + @override + String get id => "iam_ground"; + @override + String get name => "아이엠그라운드"; + @override + String get description => "8박자 리듬 체크 후 시작!\n정확한 박자에 입력하세요."; + + @override + void onMessageReceived(String senderId, Map payload) { + // UI에서 처리 + } + + @override + Widget buildHostView(BuildContext context) => IAmGroundScreen(isHost: true, gameInstance: this); + @override + Widget buildGuestView(BuildContext context) => IAmGroundScreen(isHost: false, gameInstance: this); +} + +class IAmGroundScreen extends StatefulWidget { + final bool isHost; + final IAmGroundGame gameInstance; + + const IAmGroundScreen({super.key, required this.isHost, required this.gameInstance}); + + @override + State createState() => _IAmGroundScreenState(); +} + +class _IAmGroundScreenState extends State with SingleTickerProviderStateMixin { + late Ticker _ticker; + + // --- 리듬 설정 --- + double bpm = 60.0; // [수정] 초기 속도 90 -> 60 (1초에 1박자) + double _beatInterval = 0.0; + double _currentTime = 0.0; + int _currentBeat = 0; + + // --- 인트로(리듬체크) 관련 --- + bool _isIntro = false; + int _introBeatCount = 0; + Duration _gameStartTime = Duration.zero; + + // --- 게임 상태 --- + String attackerId = ""; + String targetId = ""; + int attackCount = 0; + + List players = []; + Set deadPlayers = {}; + + bool isMyTurn = false; + bool isTargeted = false; + List _inputChecklist = [false, false, false, false]; + + String centerMessage = "게임 대기 중..."; + Color beatColor = Colors.grey; + + // 솔로 모드 AI + bool isSolo = false; + final UserInfo aiPlayer = const UserInfo(id: 'ai_bot', nickname: '🤖 AI', colorValue: 0xFF607D8B); + bool _aiActionReserved = false; + + @override + void initState() { + super.initState(); + _initPlayers(); + _updateBpm(60.0); // [수정] 초기값 60 + + _ticker = createTicker(_onTick); + NetworkManager().messageStream.listen(_handleMessage); + + if (widget.isHost) { + Future.delayed(const Duration(seconds: 1), () { + _startGame(players.first.id); + }); + } + } + + void _initPlayers() { + players = [NetworkManager().me]; + if (NetworkManager().guestList.isEmpty) { + isSolo = true; + players.add(aiPlayer); + } else { + players.addAll(NetworkManager().guestList); + } + } + + void _updateBpm(double newBpm) { + bpm = newBpm; + _beatInterval = (60.0 / bpm) * 1000; // ms 단위 + } + + void _handleMessage(Map payload) { + if (!mounted) return; + + String type = payload['type']; + + if (type == 'START_GAME') { + setState(() { + attackerId = payload['firstAttacker']; + _updateBpm(60.0); // [수정] 게임 시작 시 60 BPM으로 초기화 + deadPlayers.clear(); + + // 인트로 모드 진입 + _isIntro = true; + _introBeatCount = 0; + centerMessage = "리듬 체크!"; + + _startMetronome(); + }); + } + else if (type == 'ATTACK_CMD') { + setState(() { + attackerId = ""; + targetId = payload['targetId']; + attackCount = payload['count']; + centerMessage = "${_getName(targetId)}! $attackCount개!"; + + // 공격 성공할 때마다 BPM 조금씩 증가 (난이도 상승) + if (bpm < 160) _updateBpm(bpm + 2.0); + }); + } + else if (type == 'DEFEND_SUCCESS') { + setState(() { + attackerId = payload['newAttacker']; + targetId = ""; + attackCount = 0; + centerMessage = "${_getName(attackerId)} 공격 차례!"; + }); + } + else if (type == 'DIE') { + final deadId = payload['deadId']; + setState(() { + deadPlayers.add(deadId); + if (deadId == NetworkManager().me.id) { + if (isSolo) _showSoloRetryDialog(); + else _showGameOverDialog("탈락했습니다... 😵"); + } else if (isSolo && deadId == 'ai_bot') { + _showGameOverDialog("AI를 이겼습니다! 승리! 🎉"); + } + + if (widget.isHost && (deadId == attackerId || deadId == targetId)) { + _passTurnToNextAlive(deadId); + } + }); + } + } + + String _getName(String id) { + return players.firstWhere((u) => u.id == id, orElse: () => players.first).nickname; + } + + // --------------------------------------------------------------------------- + // 리듬 엔진 (Ticker) + // --------------------------------------------------------------------------- + void _startMetronome() { + if (!_ticker.isActive) _ticker.start(); + _gameStartTime = Duration.zero; + _currentTime = 0; + _currentBeat = 0; + } + + void _onTick(Duration elapsed) { + // [인트로 모드] + if (_isIntro) { + _handleIntroTick(elapsed); + return; + } + + // [게임 모드] + Duration gameElapsed = elapsed - _gameStartTime; + + double measureDuration = _beatInterval * 4; + double globalTime = gameElapsed.inMilliseconds.toDouble(); + double localTime = globalTime % measureDuration; + + int newBeat = (localTime / _beatInterval).floor() + 1; // 1~4 + + if (newBeat != _currentBeat) { + _onBeatChanged(newBeat); + _currentBeat = newBeat; + } + + _checkMiss(localTime); + + setState(() { + _currentTime = localTime; + double beatProgress = (localTime % _beatInterval) / _beatInterval; + beatColor = Color.lerp(Colors.cyanAccent, Colors.grey[900], beatProgress)!; + }); + } + + void _handleIntroTick(Duration elapsed) { + double totalTime = elapsed.inMilliseconds.toDouble(); + int introBeat = (totalTime / _beatInterval).floor() + 1; + + if (introBeat > _introBeatCount) { + setState(() { + _introBeatCount = introBeat; + + // 4-3-2-1 카운트다운 + int displayNum = 5 - ((_introBeatCount - 1) % 4 + 1); + + if (_introBeatCount <= 4) { + centerMessage = "리듬 체크: $displayNum"; + beatColor = Colors.yellow; + } else { + centerMessage = "준비: $displayNum"; + beatColor = Colors.orange; + } + + SoundManager().playSfx(SoundKey.click); + }); + + if (_introBeatCount >= 8) { + setState(() { + _isIntro = false; + _gameStartTime = elapsed; + centerMessage = "START!"; + _currentBeat = 0; + + _resetMeasureState(); + if (isSolo) _scheduleAiAction(); + }); + } + } + } + + void _onBeatChanged(int beat) { + if (beat == 1) { + _resetMeasureState(); + if (isSolo) _scheduleAiAction(); + } + if (beat == 1) SoundManager().playSfx(SoundKey.click); + } + + void _resetMeasureState() { + _inputChecklist = [false, false, false, false]; + _aiActionReserved = false; + + isMyTurn = (attackerId == NetworkManager().me.id); + isTargeted = (targetId == NetworkManager().me.id); + } + + // --------------------------------------------------------------------------- + // 입력 판정 + // --------------------------------------------------------------------------- + void _handleInput(String type, dynamic value) { + if (deadPlayers.contains(NetworkManager().me.id)) return; + if (_isIntro) return; + + double closestDist = double.infinity; + int closestBeatIndex = -1; + + for(int i=0; i<4; i++) { + double dist = (_currentTime - (i * _beatInterval)).abs(); + if (dist < closestDist) { + closestDist = dist; + closestBeatIndex = i; + } + } + + // 판정 범위: 0.2초 (200ms)로 조금 더 여유 있게 조정 + double greatWindow = 200.0; + + if (closestDist > greatWindow) { + _die("박자 놓침! (Bad Timing)"); + return; + } + + int hitBeat = closestBeatIndex + 1; + if (_inputChecklist[closestBeatIndex]) return; + _inputChecklist[closestBeatIndex] = true; + + bool isCorrect = false; + + if (isMyTurn) { + // 공격자: 3박자(이름), 4박자(숫자) + if (hitBeat == 3 && type == 'NAME' && value != NetworkManager().me.id) { + isCorrect = true; + targetId = value; + } else if (hitBeat == 4 && type == 'NUM') { + isCorrect = true; + if (targetId.isNotEmpty) { + _sendAttack(targetId, value); + } else { + _die("공격 대상 미지정!"); + } + } + } else if (isTargeted) { + // 방어자 + bool shouldHit = false; + if (attackCount == 1 && hitBeat == 4) shouldHit = true; + if (attackCount == 2 && (hitBeat == 3 || hitBeat == 4)) shouldHit = true; + if (attackCount == 3 && (hitBeat >= 2)) shouldHit = true; + if (attackCount == 4 && (hitBeat >= 1)) shouldHit = true; + + if (shouldHit && type == 'NAME' && value == NetworkManager().me.id) { + isCorrect = true; + if (hitBeat == 4) { + _sendDefendSuccess(); + } + } + } + + if (!isCorrect) { + _die("틀린 입력!"); + } + } + + void _checkMiss(double localTime) { + double checkPoint = 210.0; // 판정 윈도우 종료 직후 + for(int i=0; i<4; i++) { + double beatTime = i * _beatInterval; + if (localTime > beatTime + checkPoint && !_inputChecklist[i]) { + int beatNum = i + 1; + bool required = false; + + if (isMyTurn) { + if (beatNum == 3 || beatNum == 4) required = true; + } else if (isTargeted) { + if (attackCount == 1 && beatNum == 4) required = true; + if (attackCount == 2 && (beatNum == 3 || beatNum == 4)) required = true; + if (attackCount == 3 && beatNum >= 2) required = true; + if (attackCount == 4 && beatNum >= 1) required = true; + } + + if (required) { + _die("입력 시간 초과!"); + } + _inputChecklist[i] = true; + } + } + } + + void _die(String reason) { + if (deadPlayers.contains(NetworkManager().me.id)) return; + _broadcast({'type': 'DIE', 'deadId': NetworkManager().me.id}); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(reason), backgroundColor: Colors.red, duration: const Duration(milliseconds: 500))); + SoundManager().playSfx(SoundKey.wrong); + } + + // --------------------------------------------------------------------------- + // 통신 및 AI + // --------------------------------------------------------------------------- + void _startGame(String startId) { + final payload = {'type': 'START_GAME', 'firstAttacker': startId}; + _broadcast(payload); + } + + void _sendAttack(String target, int count) { + final payload = {'type': 'ATTACK_CMD', 'targetId': target, 'count': count}; + _broadcast(payload); + SoundManager().playSfx(SoundKey.correct); + } + + void _sendDefendSuccess() { + final payload = {'type': 'DEFEND_SUCCESS', 'newAttacker': NetworkManager().me.id}; + _broadcast(payload); + } + + void _broadcast(Map payload) { + if (isSolo) { + _handleMessage(payload); + } else { + NetworkManager().sendMessage(payload); + if (widget.isHost) _handleMessage(payload); + } + } + + void _passTurnToNextAlive(String deadId) { + int idx = players.indexWhere((u) => u.id == deadId); + for (int i = 1; i < players.length; i++) { + int nextIdx = (idx + i) % players.length; + if (!deadPlayers.contains(players[nextIdx].id)) { + _broadcast({'type': 'DEFEND_SUCCESS', 'newAttacker': players[nextIdx].id}); + return; + } + } + } + + void _scheduleAiAction() { + if (_aiActionReserved) return; + _aiActionReserved = true; + + if (attackerId == 'ai_bot') { + Future.delayed(Duration(milliseconds: (_beatInterval * 3.5).toInt()), () { + if (!mounted) return; + _broadcast({'type': 'ATTACK_CMD', 'targetId': NetworkManager().me.id, 'count': Random().nextInt(4) + 1}); + }); + } + else if (targetId == 'ai_bot') { + Future.delayed(Duration(milliseconds: (_beatInterval * 3.8).toInt()), () { + if (!mounted) return; + _broadcast({'type': 'DEFEND_SUCCESS', 'newAttacker': 'ai_bot'}); + }); + } + } + + // --------------------------------------------------------------------------- + // UI + // --------------------------------------------------------------------------- + @override + void dispose() { + _ticker.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + appBar: AppBar( + title: Text("아이엠그라운드 BPM:${bpm.toInt()}"), + backgroundColor: Colors.grey[900], + ), + body: Column( + children: [ + // 1. 리듬 바 + Container( + height: 15, + width: double.infinity, + color: Colors.grey[800], + child: Stack( + children: [ + FractionallySizedBox( + alignment: Alignment.centerLeft, + widthFactor: (_currentTime % _beatInterval) / _beatInterval, + child: Container(color: beatColor), + ), + Row( + children: List.generate(4, (index) => Expanded( + child: Container( + decoration: BoxDecoration( + border: Border(right: BorderSide(color: Colors.black, width: 2)) + ), + ), + )), + ) + ], + ), + ), + + // 2. 중앙 정보 + Expanded( + flex: 3, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + _isIntro ? "준비!" : "$_currentBeat", + style: TextStyle(fontSize: 60, fontWeight: FontWeight.bold, color: beatColor) + ), + const SizedBox(height: 20), + Text(centerMessage, style: const TextStyle(color: Colors.white, fontSize: 24, fontWeight: FontWeight.bold)), + ], + ), + ), + ), + + // 3. 숫자 패드 + Container( + height: 80, + padding: const EdgeInsets.symmetric(horizontal: 10), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: List.generate(4, (i) => _buildBtn("NUM", i + 1, "${i + 1}", Colors.orange)), + ), + ), + + const SizedBox(height: 10), + + // 4. 플레이어 버튼 + Expanded( + flex: 4, + child: GridView.builder( + padding: const EdgeInsets.all(10), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + childAspectRatio: 2.0, + mainAxisSpacing: 10, + crossAxisSpacing: 10, + ), + itemCount: players.length, + itemBuilder: (context, index) { + final user = players[index]; + final isMe = user.id == NetworkManager().me.id; + Color color = isMe ? Colors.green : Colors.blue; + if (user.id == attackerId) color = Colors.yellow; + if (user.id == targetId) color = Colors.red; + if (deadPlayers.contains(user.id)) color = Colors.grey; + + return GestureDetector( + onTapDown: (_) => _handleInput('NAME', user.id), + child: Container( + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.white, width: 2), + ), + alignment: Alignment.center, + child: Text( + user.nickname, + style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.black), + ), + ), + ); + }, + ), + ), + ], + ), + ); + } + + Widget _buildBtn(String type, dynamic val, String label, Color color) { + return GestureDetector( + onTapDown: (_) => _handleInput(type, val), + child: Container( + width: 70, height: 70, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + ), + alignment: Alignment.center, + child: Text(label, style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold)), + ), + ); + } + + void _showSoloRetryDialog() { + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => AlertDialog( + title: const Text("연습 실패"), + actions: [ + TextButton(onPressed: () { Navigator.pop(context); Navigator.pop(context); }, child: const Text("나가기")), + ElevatedButton( + onPressed: () { + Navigator.pop(context); + setState(() { + deadPlayers.clear(); + _updateBpm(60.0); // 재시작 시 속도 초기화 + _startGame(players.first.id); + }); + }, + child: const Text("재도전"), + ), + ], + ), + ); + } + + void _showGameOverDialog(String 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("나가기")), + ], + ), + ); + } +} \ No newline at end of file diff --git a/packages/core/lib/game/janggi_game.dart b/packages/core/lib/game/janggi_game.dart index 2b114b1..0b0835b 100644 --- a/packages/core/lib/game/janggi_game.dart +++ b/packages/core/lib/game/janggi_game.dart @@ -13,15 +13,12 @@ class JanggiGame extends BaseGame { @override void onStart() { super.onStart(); - // 게임 시작 시 초기화 로직이 필요하면 여기에 추가 } // [수정] 필수 메서드 구현 추가 @override void onMessageReceived(String senderId, Map payload) { - // BaseGame은 패킷 수신 시 UI(JanggiScreen)에 전달하는 역할이 주가 되므로 - // 여기서는 특별한 로직 없이 두거나, 필요시 전역 상태를 업데이트합니다. - // 실제 게임 로직은 JanggiScreen의 StreamBuilder나 리스너에서 처리됩니다. + // 게임 로직은 JanggiScreen 내부에서 NetworkManager 스트림을 통해 처리합니다. } @override @@ -217,7 +214,6 @@ class _JanggiScreenState extends State { } } } - // 상(象), 포(包) 등은 복잡하여 생략 (필요시 추가 구현) return moves; } @@ -247,7 +243,8 @@ class _JanggiScreenState extends State { @override Widget build(BuildContext context) { - // 내가 초나라(Green)라면 보드를 뒤집어서 보여줌 + final bool myTurn = currentTurn == (widget.isHan ? Team.han : Team.cho); + // 내가 초나라(Green)라면 보드를 뒤집어서 보여줌 (아래가 내 진영이 되도록) final bool flipBoard = !widget.isHan; return Scaffold( diff --git a/packages/core/lib/game/jump_game.dart b/packages/core/lib/game/jump_game.dart new file mode 100644 index 0000000..34fa5af --- /dev/null +++ b/packages/core/lib/game/jump_game.dart @@ -0,0 +1,239 @@ +import 'dart:async'; +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:playwith_core/playwith_core.dart'; + +class JumpGame extends BaseGame { + @override + String get id => "jump_battle"; + @override + String get name => "점프 배틀"; + @override + String get description => "장애물을 피해 오래 살아남으세요!"; + + @override + void onMessageReceived(String senderId, Map payload) { } + + @override + Widget buildHostView(BuildContext context) => JumpGameScreen(isHost: true, gameInstance: this); + @override + Widget buildGuestView(BuildContext context) => JumpGameScreen(isHost: false, gameInstance: this); +} + +class JumpGameScreen extends StatefulWidget { + final bool isHost; + final JumpGame gameInstance; + + const JumpGameScreen({super.key, required this.isHost, required this.gameInstance}); + + @override + State createState() => _JumpGameScreenState(); +} + +class _JumpGameScreenState extends State with SingleTickerProviderStateMixin { + late Ticker _ticker; + + // 게임 상태 + double playerY = 0.0; // 0.0(바닥) ~ 1.0(최대 점프) + double velocityY = 0.0; + bool isJumping = false; + + double scrollSpeed = 0.015; + int score = 0; // 거리 점수 + int opponentScore = 0; + + List<_Obstacle> obstacles = []; + bool isGameOver = false; + + @override + void initState() { + super.initState(); + _ticker = createTicker(_gameLoop)..start(); + NetworkManager().messageStream.listen(_handleMessage); + } + + void _handleMessage(Map payload) { + if (!mounted) return; + if (payload['type'] == 'SCORE_UPDATE') { + setState(() => opponentScore = payload['score']); + } + } + + void _jump() { + if (!isJumping && !isGameOver) { + velocityY = 0.045; // 점프 힘 + isJumping = true; + SoundManager().playSfx(SoundKey.click); + } + } + + void _gameLoop(Duration elapsed) { + if (isGameOver) return; + + setState(() { + // 1. 중력 적용 + playerY += velocityY; + velocityY -= 0.0025; // 중력값 + + // 바닥 착지 + if (playerY <= 0) { + playerY = 0; + isJumping = false; + velocityY = 0; + } + + // 2. 장애물 생성 및 이동 + if (obstacles.isEmpty || obstacles.last.x < 0.5) { + // 일정 간격으로 생성 + if (Random().nextDouble() < 0.02) { + obstacles.add(_Obstacle(x: 1.5, width: 0.1, height: 0.1 + Random().nextDouble() * 0.1)); + } + } + + for (var obs in obstacles) { + obs.x -= scrollSpeed; + } + obstacles.removeWhere((obs) => obs.x < -1.2); + + // 3. 점수 증가 (생존 시간) + score++; + if (score % 10 == 0) { + NetworkManager().sendMessage({'type': 'SCORE_UPDATE', 'score': score}); + } + + // 난이도 증가 + if (score % 500 == 0) scrollSpeed += 0.001; + + // 4. 충돌 체크 + // 플레이어 X위치는 -0.6 정도로 고정 가정 + double playerX = -0.6; + double playerSize = 0.1; // 히트박스 크기 + + for (var obs in obstacles) { + // X축 겹침 + if (playerX + playerSize > obs.x && playerX - playerSize < obs.x + obs.width) { + // Y축 겹침 (플레이어가 장애물보다 낮으면 충돌) + if (playerY < obs.height) { + _gameOver(); + } + } + } + }); + } + + void _gameOver() { + isGameOver = true; + _ticker.stop(); + SoundManager().playSfx(SoundKey.wrong); + + String result = score > opponentScore ? "승리! (상대: $opponentScore)" : "패배... (상대: $opponentScore)"; + + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => AlertDialog( + title: const Text("충돌!"), + content: Text("기록: $score m\n$result"), + actions: [ + TextButton( + onPressed: () { Navigator.pop(context); Navigator.pop(context); }, + child: const Text("나가기"), + ) + ], + ), + ); + } + + @override + void dispose() { + _ticker.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text("나: $score m"), + Text("상대: $opponentScore m"), + ], + ), + ), + body: GestureDetector( + onTap: _jump, + child: Container( + color: Colors.white, + child: CustomPaint( + painter: JumpGamePainter(playerY: playerY, obstacles: obstacles), + size: Size.infinite, + ), + ), + ), + ); + } +} + +class _Obstacle { + double x; + double width; + double height; + _Obstacle({required this.x, required this.width, required this.height}); +} + +class JumpGamePainter extends CustomPainter { + final double playerY; + final List<_Obstacle> obstacles; + + JumpGamePainter({required this.playerY, required this.obstacles}); + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint()..color = Colors.black; + + // 바닥 선 + double groundY = size.height * 0.8; + canvas.drawLine(Offset(0, groundY), Offset(size.width, groundY), paint..strokeWidth = 2); + + // 좌표 변환: playerY (0~1) -> 화면 Y (groundY ~ 위쪽) + double toScreenY(double yRatio) => groundY - (yRatio * size.height * 0.5); + double toScreenX(double xRatio) => size.width/2 + (xRatio * size.width/2); + + // 플레이어 (공룡/네모) + paint.color = Colors.green; + double pSize = 40; + Rect playerRect = Rect.fromCenter( + center: Offset(size.width * 0.2, toScreenY(playerY) - pSize/2), + width: pSize, + height: pSize, + ); + canvas.drawRect(playerRect, paint); + + // 장애물 + paint.color = Colors.redAccent; + for (var obs in obstacles) { + // 화면 좌표 변환 + // obs.x: 0이 중앙, -1이 왼쪽 끝 + // 여기선 간단히 매핑 + double obsX = size.width/2 + (obs.x * size.width/2); + // -> 위쪽 playerX(-0.6)과 좌표계 통일을 위해 약간 보정 필요하지만, + // 시각적 편의상 직접 매핑: + // 플레이어는 화면 좌측 20% 지점 고정. + // 장애물은 오른쪽에서 왼쪽으로 이동. + // obs.x = 1.5 -> 0.5 -> -1.0 + + // 화면 비율 그대로 사용 + double left = (obs.x + 1) / 2 * size.width; + double top = groundY - (obs.height * size.height * 0.5); + double w = obs.width * size.width * 0.5; + + canvas.drawRect(Rect.fromLTWH(left, top, w, groundY - top), paint); + } + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => true; +} \ No newline at end of file diff --git a/packages/core/lib/game/math_run_game.dart b/packages/core/lib/game/math_run_game.dart new file mode 100644 index 0000000..67e7f58 --- /dev/null +++ b/packages/core/lib/game/math_run_game.dart @@ -0,0 +1,297 @@ +import 'dart:async'; +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:playwith_core/playwith_core.dart'; + +class MathRunGame extends BaseGame { + @override + String get id => "math_run"; + @override + String get name => "매스 런"; + @override + String get description => "좋은 문을 통과해 숫자를 키우세요!"; + + @override + void onMessageReceived(String senderId, Map payload) { + // UI에서 처리 + } + + @override + Widget buildHostView(BuildContext context) => MathRunScreen(isHost: true, gameInstance: this); + @override + Widget buildGuestView(BuildContext context) => MathRunScreen(isHost: false, gameInstance: this); +} + +class MathRunScreen extends StatefulWidget { + final bool isHost; + final MathRunGame gameInstance; + + const MathRunScreen({super.key, required this.isHost, required this.gameInstance}); + + @override + State createState() => _MathRunScreenState(); +} + +class _MathRunScreenState extends State with SingleTickerProviderStateMixin { + late Ticker _ticker; + + // 게임 상태 + double playerX = 0.0; // -1.0 (좌) ~ 1.0 (우) + int myScore = 1; // 현재 병력(점수) + int opponentScore = 1; + + double gameSpeed = 0.008; // 내려오는 속도 + double distanceTraveled = 0; + + List<_Gate> gates = []; + bool isPlaying = true; + bool isGameOver = false; + + @override + void initState() { + super.initState(); + _spawnInitialGates(); + _ticker = createTicker(_gameLoop)..start(); + NetworkManager().messageStream.listen(_handleMessage); + } + + void _handleMessage(Map payload) { + if (!mounted) return; + if (payload['type'] == 'SCORE_UPDATE') { + setState(() { + opponentScore = payload['score']; + }); + } else if (payload['type'] == 'GAME_OVER_OPPONENT') { + // 상대방 죽음 알림 (옵션) + } + } + + void _spawnInitialGates() { + // 초기 게이트 생성 + for (int i = 1; i < 5; i++) { + _spawnGateRow(-1.0 + (i * 0.6)); // Y 위치 + } + } + + void _spawnGateRow(double y) { + final random = Random(); + // 좌측 게이트 + int val1 = random.nextInt(10) + 2; + bool isMult1 = random.nextBool(); + if (!isMult1) val1 *= 5; // 더하기는 좀 더 큰 수로 + + // 우측 게이트 + int val2 = random.nextInt(10) + 2; + bool isMult2 = random.nextBool(); + if (!isMult2) val2 *= 5; + + // 가끔 함정(나누기/빼기) 추가 + if (random.nextDouble() < 0.3) { + val1 = -val1; + } + + gates.add(_Gate(x: -0.5, y: y, value: val1, isMultiply: isMult1)); + gates.add(_Gate(x: 0.5, y: y, value: val2, isMultiply: isMult2)); + } + + void _gameLoop(Duration elapsed) { + if (!isPlaying || isGameOver) return; + + setState(() { + // 게이트 이동 (플레이어가 앞으로 가는 효과) + for (var gate in gates) { + gate.y += gameSpeed; + } + + // 지나간 게이트 삭제 및 새 게이트 생성 + if (gates.isNotEmpty && gates.first.y > 1.2) { + gates.removeAt(0); + gates.removeAt(0); // 한 줄(2개) 삭제 + + // 새 줄 생성 (화면 위쪽 보이지 않는 곳에) + double lastY = gates.last.y; + _spawnGateRow(lastY - 0.6); // 간격 0.6 + + // 속도 점진적 증가 + gameSpeed += 0.0001; + distanceTraveled += 0.1; + } + + // 충돌 감지 + for (var gate in gates) { + if (!gate.passed && gate.y > 0.75 && gate.y < 0.85) { // 플레이어 Y 위치 근처 + // X 범위 체크 (플레이어 크기 고려) + if ((playerX - gate.x).abs() < 0.4) { + _applyGateEffect(gate); + gate.passed = true; + } + } + } + + // 점수 0 되면 게임 오버 + if (myScore <= 0) { + _finishGame(); + } + }); + } + + void _applyGateEffect(_Gate gate) { + if (gate.isMultiply) { + if (gate.value > 0) myScore *= gate.value; + else myScore = (myScore / gate.value.abs()).floor(); // 음수 곱하기는 나누기로 처리 (함정) + } else { + myScore += gate.value; + } + SoundManager().playSfx(gate.value > 0 ? SoundKey.correct : SoundKey.wrong); + + // 점수 전송 + NetworkManager().sendMessage({'type': 'SCORE_UPDATE', 'score': myScore}); + } + + void _onPanUpdate(DragUpdateDetails details) { + if (isGameOver) return; + setState(() { + playerX += details.delta.dx / (MediaQuery.of(context).size.width / 2); + playerX = playerX.clamp(-0.8, 0.8); + }); + } + + void _finishGame() { + isPlaying = false; + isGameOver = true; + NetworkManager().sendMessage({'type': 'GAME_OVER_OPPONENT', 'score': myScore}); + + String result = myScore > opponentScore ? "승리! (상대: $opponentScore)" : "패배... (상대: $opponentScore)"; + if (myScore == opponentScore) result = "무승부!"; + + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => AlertDialog( + title: const Text("게임 종료"), + content: Text("최종 병력: $myScore\n$result"), + actions: [ + TextButton( + onPressed: () { Navigator.pop(context); Navigator.pop(context); }, + child: const Text("나가기"), + ) + ], + ), + ); + } + + @override + void dispose() { + _ticker.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text("나: $myScore 💂"), + Text("상대: $opponentScore 💂"), + ], + ), + ), + backgroundColor: Colors.grey[900], + body: GestureDetector( + onPanUpdate: _onPanUpdate, + child: Container( + color: Colors.transparent, + width: double.infinity, + height: double.infinity, + child: CustomPaint( + painter: MathRunPainter(playerX: playerX, gates: gates, score: myScore), + ), + ), + ), + ); + } +} + +class _Gate { + double x; // -0.5 (왼쪽), 0.5 (오른쪽) + double y; + int value; + bool isMultiply; + bool passed = false; + + _Gate({required this.x, required this.y, required this.value, required this.isMultiply}); +} + +class MathRunPainter extends CustomPainter { + final double playerX; + final List<_Gate> gates; + final int score; + + MathRunPainter({required this.playerX, required this.gates, required this.score}); + + @override + void paint(Canvas canvas, Size size) { + final center = size.center(Offset.zero); + double toScreenX(double v) => center.dx + v * (size.width / 2); + double toScreenY(double v) => center.dy + v * (size.height / 2); // v: -1(상) ~ 1(하) + + // 바닥 격자 효과 (속도감) + final paintLine = Paint()..color = Colors.white10..strokeWidth = 2; + canvas.drawLine(Offset(size.width * 0.3, 0), Offset(0, size.height), paintLine); + canvas.drawLine(Offset(size.width * 0.7, 0), Offset(size.width, size.height), paintLine); + + // 게이트 그리기 + for (var gate in gates) { + if (gate.passed) continue; + + // 게이트 색상: 파랑(좋음), 빨강(나쁨) + bool isGood = (gate.isMultiply && gate.value > 1) || (!gate.isMultiply && gate.value > 0); + final color = isGood ? Colors.blueAccent : Colors.redAccent; + final paintGate = Paint()..color = color.withOpacity(0.6); + + Rect rect = Rect.fromCenter( + center: Offset(toScreenX(gate.x), toScreenY(gate.y)), + width: size.width * 0.45, + height: 100, // 고정 높이 + ); + + canvas.drawRRect(RRect.fromRectAndRadius(rect, const Radius.circular(10)), paintGate); + + // 텍스트 + String op = gate.isMultiply ? "x" : (gate.value >= 0 ? "+" : ""); + // 나누기 함정은 음수 곱하기로 표현했으므로 표시 변환 + if (gate.isMultiply && gate.value < 0) { op = "÷"; } + + String text = "$op${gate.value.abs()}"; + + TextSpan span = TextSpan( + style: const TextStyle(color: Colors.white, fontSize: 24, fontWeight: FontWeight.bold), + text: text + ); + TextPainter tp = TextPainter(text: span, textDirection: TextDirection.ltr); + tp.layout(); + tp.paint(canvas, rect.center - Offset(tp.width / 2, tp.height / 2)); + } + + // 플레이어 (병력) 그리기 + final paintPlayer = Paint()..color = Colors.yellow; + final playerCenter = Offset(toScreenX(playerX), toScreenY(0.8)); + + // 메인 캐릭터 + canvas.drawCircle(playerCenter, 15, paintPlayer); + + // 군중 효과 (점수에 따라 작은 원 추가) + int crowd = min(score, 20); // 최대 20개까지만 그림 + for (int i = 0; i < crowd; i++) { + double angle = (i / crowd) * 2 * pi; + double radius = 25.0; + canvas.drawCircle(playerCenter + Offset(cos(angle)*radius, sin(angle)*radius), 5, paintPlayer..color = Colors.yellow.withOpacity(0.7)); + } + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => true; +} \ No newline at end of file diff --git a/packages/core/lib/game/othello_game.dart b/packages/core/lib/game/othello_game.dart new file mode 100644 index 0000000..1432979 --- /dev/null +++ b/packages/core/lib/game/othello_game.dart @@ -0,0 +1,325 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:playwith_core/playwith_core.dart'; + +class OthelloGame extends BaseGame { + @override + String get id => "othello"; + @override + String get name => "오셀로"; + @override + String get description => "돌을 뒤집어라!\n마지막에 웃는 자가 승리"; + + @override + void onMessageReceived(String senderId, Map payload) { + // UI에서 처리 + } + + @override + Widget buildHostView(BuildContext context) => OthelloScreen(myStone: 1, gameInstance: this); // 1: 흑(선공) + @override + Widget buildGuestView(BuildContext context) => OthelloScreen(myStone: 2, gameInstance: this); // 2: 백(후공) +} + +class OthelloScreen extends StatefulWidget { + final int myStone; // 1: Black, 2: White + final OthelloGame gameInstance; + + const OthelloScreen({super.key, required this.myStone, required this.gameInstance}); + + @override + State createState() => _OthelloScreenState(); +} + +class _OthelloScreenState extends State { + // 0: 빈칸, 1: 흑, 2: 백 + final List> board = List.generate(8, (_) => List.filled(8, 0)); + int currentTurn = 1; // 흑 먼저 + + // [수정] _Point 사용 + List<_Point> validMoves = []; + + @override + void initState() { + super.initState(); + _initBoard(); + _calculateValidMoves(); + NetworkManager().messageStream.listen(_handleMessage); + } + + void _initBoard() { + // 오셀로 초기 배치 + board[3][3] = 2; + board[3][4] = 1; + board[4][3] = 1; + board[4][4] = 2; + } + + void _handleMessage(Map payload) { + if (!mounted) return; + if (payload['type'] == 'MOVE') { + int x = payload['x']; + int y = payload['y']; + int stone = payload['stone']; + _executeMove(x, y, stone); + } else if (payload['type'] == 'PASS') { + _passTurn(); + } + } + + void _onTap(int x, int y) { + if (currentTurn != widget.myStone) return; + if (!_isValidMove(x, y, widget.myStone)) return; + + _executeMove(x, y, widget.myStone); + NetworkManager().sendMessage({ + 'type': 'MOVE', + 'x': x, + 'y': y, + 'stone': widget.myStone + }); + } + + void _executeMove(int x, int y, int stone) { + setState(() { + board[y][x] = stone; + _flipStones(x, y, stone); + + currentTurn = (stone == 1) ? 2 : 1; + _calculateValidMoves(); + + // 내가 둘 곳이 없으면 패스 처리 + if (currentTurn == widget.myStone && validMoves.isEmpty) { + if (_isBoardFull() || _getScore(1) + _getScore(2) == 64) { + _showGameOver(); + } else { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("둘 곳이 없어 턴을 넘깁니다."))); + NetworkManager().sendMessage({'type': 'PASS'}); + Future.delayed(const Duration(seconds: 1), _passTurn); + } + } + }); + SoundManager().playSfx(SoundKey.click); + } + + void _passTurn() { + setState(() { + currentTurn = (currentTurn == 1) ? 2 : 1; + _calculateValidMoves(); + + // 패스했는데 나도 둘 곳 없으면 게임 종료 + if (currentTurn == widget.myStone && validMoves.isEmpty) { + _showGameOver(); + } + }); + } + + void _flipStones(int x, int y, int stone) { + final directions = [ + [-1,-1], [0,-1], [1,-1], + [-1, 0], [1, 0], + [-1, 1], [0, 1], [1, 1] + ]; + + for (var d in directions) { + int dx = d[0], dy = d[1]; + int nx = x + dx, ny = y + dy; + List<_Point> flippable = []; // [수정] _Point 사용 + + while (nx >= 0 && nx < 8 && ny >= 0 && ny < 8) { + if (board[ny][nx] == 0) break; + if (board[ny][nx] == stone) { + for (var p in flippable) board[p.y][p.x] = stone; + break; + } + flippable.add(_Point(nx, ny)); + nx += dx; ny += dy; + } + } + } + + bool _isValidMove(int x, int y, int stone) { + if (board[y][x] != 0) return false; + + final directions = [ + [-1,-1], [0,-1], [1,-1], + [-1, 0], [1, 0], + [-1, 1], [0, 1], [1, 1] + ]; + + for (var d in directions) { + int dx = d[0], dy = d[1]; + int nx = x + dx, ny = y + dy; + bool hasOpponent = false; + + while (nx >= 0 && nx < 8 && ny >= 0 && ny < 8) { + if (board[ny][nx] == 0) break; + if (board[ny][nx] == stone) { + if (hasOpponent) return true; + break; + } + hasOpponent = true; + nx += dx; ny += dy; + } + } + return false; + } + + void _calculateValidMoves() { + validMoves.clear(); + if (currentTurn != widget.myStone) return; + + for (int y = 0; y < 8; y++) { + for (int x = 0; x < 8; x++) { + if (_isValidMove(x, y, widget.myStone)) { + validMoves.add(_Point(x, y)); // [수정] _Point 사용 + } + } + } + } + + bool _isBoardFull() { + for (var row in board) { + if (row.contains(0)) return false; + } + return true; + } + + int _getScore(int stone) { + int count = 0; + for (var row in board) { + for (var cell in row) { + if (cell == stone) count++; + } + } + return count; + } + + void _showGameOver() { + int blackScore = _getScore(1); + int whiteScore = _getScore(2); + String msg; + + if (blackScore == whiteScore) msg = "무승부입니다!"; + else if (widget.myStone == 1) { + msg = blackScore > whiteScore ? "승리했습니다! 🎉" : "패배했습니다... 😭"; + } else { + msg = whiteScore > blackScore ? "승리했습니다! 🎉" : "패배했습니다... 😭"; + } + + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => AlertDialog( + title: const Text("게임 종료"), + content: Text("$msg\n흑: $blackScore vs 백: $whiteScore"), + actions: [ + TextButton( + onPressed: () { Navigator.pop(context); Navigator.pop(context); }, + child: const Text("나가기"), + ) + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text("오셀로")), + backgroundColor: Colors.green[800], + body: Column( + children: [ + // 점수판 + Padding( + padding: const EdgeInsets.all(20), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _buildPlayerInfo("흑 (Black)", 1), + _buildPlayerInfo("백 (White)", 2), + ], + ), + ), + + // 보드 + Expanded( + child: Center( + child: AspectRatio( + aspectRatio: 1.0, + child: Container( + margin: const EdgeInsets.all(10), + color: Colors.black, + padding: const EdgeInsets.all(4), + child: GridView.builder( + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 8, + crossAxisSpacing: 2, + mainAxisSpacing: 2, + ), + itemCount: 64, + itemBuilder: (context, index) { + int x = index % 8; + int y = index ~/ 8; + int cell = board[y][x]; + bool isValid = validMoves.any((p) => p.x == x && p.y == y); + + return GestureDetector( + onTap: () => _onTap(x, y), + child: Container( + color: Colors.green[700], + child: Center( + child: cell == 0 + ? (isValid ? Container(width: 10, height: 10, decoration: BoxDecoration(color: Colors.black26, shape: BoxShape.circle)) : null) + : Container( + width: 30, height: 30, + decoration: BoxDecoration( + color: cell == 1 ? Colors.black : Colors.white, + shape: BoxShape.circle, + boxShadow: const [BoxShadow(blurRadius: 2, offset: Offset(1,1), color: Colors.black54)] + ), + ), + ), + ), + ); + }, + ), + ), + ), + ), + ), + + if (currentTurn == widget.myStone) + const Padding(padding: EdgeInsets.all(20), child: Text("당신의 차례입니다", style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold))) + else + const Padding(padding: EdgeInsets.all(20), child: Text("상대방 생각 중...", style: TextStyle(color: Colors.white70, fontSize: 16))), + ], + ), + ); + } + + Widget _buildPlayerInfo(String label, int stone) { + bool isTurn = currentTurn == stone; + bool isMe = widget.myStone == stone; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + decoration: BoxDecoration( + color: isTurn ? Colors.amber.withOpacity(0.8) : Colors.white24, + borderRadius: BorderRadius.circular(10), + border: isMe ? Border.all(color: Colors.yellow, width: 2) : null, + ), + child: Column( + children: [ + Text(label, style: TextStyle(color: stone == 1 ? Colors.black : Colors.white, fontWeight: FontWeight.bold)), + Text("${_getScore(stone)}", style: TextStyle(color: stone == 1 ? Colors.black : Colors.white, fontSize: 24, fontWeight: FontWeight.bold)), + if (isMe) const Text("YOU", style: TextStyle(fontSize: 10, color: Colors.redAccent, fontWeight: FontWeight.bold)), + ], + ), + ); + } +} + +// [수정] Private Class로 변경하여 충돌 방지 +class _Point { final int x, y; _Point(this.x, this.y); } \ No newline at end of file diff --git a/packages/core/lib/game/sequence_memory_game.dart b/packages/core/lib/game/sequence_memory_game.dart new file mode 100644 index 0000000..364c12b --- /dev/null +++ b/packages/core/lib/game/sequence_memory_game.dart @@ -0,0 +1,414 @@ +import 'dart:async'; +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:playwith_core/playwith_core.dart'; + +class SequenceMemoryGame extends BaseGame { + @override + String get id => "sequence_memory"; + @override + String get name => "기억의 신"; + @override + String get description => "순서를 기억해 똑같이 누르세요!\n글자가 뒤섞여 나옵니다."; + + @override + void onMessageReceived(String senderId, Map payload) { + // BaseGame 핸들러 + } + + @override + Widget buildHostView(BuildContext context) => SequenceMemoryScreen(isHost: true, gameInstance: this); + @override + Widget buildGuestView(BuildContext context) => SequenceMemoryScreen(isHost: false, gameInstance: this); +} + +class SequenceMemoryScreen extends StatefulWidget { + final bool isHost; + final SequenceMemoryGame gameInstance; + + const SequenceMemoryScreen({super.key, required this.isHost, required this.gameInstance}); + + @override + State createState() => _SequenceMemoryScreenState(); +} + +class _SequenceMemoryScreenState extends State { + // --- 게임 설정 --- + int round = 1; + int sequenceLength = 3; + + // 0: Red(Host), 1: Blue(Guest) + int currentTurn = 0; + + // [수정] 데이터 풀 대폭 확장 (랜덤성을 위해) + final List _poolNum = List.generate(25, (i) => '${i + 1}'); // 1~25 + final List _poolKor = ['가','나','다','라','마','바','사','아','자','차','카','타','파','하']; + final List _poolEng = List.generate(26, (i) => String.fromCharCode('A'.codeUnitAt(0) + i)); // A~Z + final List _poolEmo = [ + '🍎','🍌','🍇','🍉','🍓','🍒','🍍','🥝','🍋','🥑','🥦','🌽','🥕','🌭','🍔','🍟','🍕','🥪','🌮','🍦','🍧','🍩','🍪','🎂','🍬' + ]; + + // 현재 라운드 데이터 + List gridItems = []; + List targetSequence = []; + int currentGridSize = 2; + + // 플레이 상태 + bool isShowingSequence = false; + int? activeHighlightIndex; + int inputIndex = 0; + + String infoMessage = "대결 시작!"; + Color infoColor = Colors.white; + + // AI 관련 + bool isSolo = false; + final UserInfo aiPlayer = const UserInfo(id: 'ai_bot', nickname: '🤖 알파고', colorValue: 0xFF607D8B); + + @override + void initState() { + super.initState(); + + if (NetworkManager().guestList.isEmpty) { + isSolo = true; + } + + NetworkManager().messageStream.listen(_handleMessage); + + if (widget.isHost) { + Future.delayed(const Duration(seconds: 1), () { + _startRound(1, 0); + }); + } + } + + // 라운드에 따른 그리드 크기 + int _getGridDimension(int r) { + if (r <= 5) return 2; // 1~5라운드: 2x2 + if (r <= 15) return 3; // 6~15라운드: 3x3 + return 5; // 16라운드~: 5x5 + } + + void _handleMessage(Map payload) { + if (!mounted) return; + + String type = payload['type']; + + if (type == 'NEW_ROUND') { + setState(() { + round = payload['round']; + currentTurn = payload['turn']; + gridItems = List.from(payload['gridItems']); + targetSequence = List.from(payload['sequence']); + + sequenceLength = targetSequence.length; + currentGridSize = _getGridDimension(round); + + inputIndex = 0; + isShowingSequence = true; + activeHighlightIndex = null; + + bool isMyTurn = _isMyTurn(); + if (isMyTurn) { + infoMessage = "순서를 잘 보세요!"; + infoColor = Colors.yellow; + } else { + String name = (currentTurn == 1 && isSolo) ? aiPlayer.nickname : "상대방"; + infoMessage = "$name이(가) 기억하는 중..."; + infoColor = Colors.grey; + } + }); + + _playSequenceAnimation(); + } + else if (type == 'TURN_COMPLETE') { + if (widget.isHost) { + if (currentTurn == 0) { + _startRound(round, 1); // 라운드 유지 + } else { + _startRound(round + 1, 0); // 라운드 증가 + } + } + } + else if (type == 'GAME_OVER') { + _showGameOverDialog(payload['winner']); + } + } + + bool _isMyTurn() { + if (widget.isHost) return currentTurn == 0; + return currentTurn == 1; + } + + // --------------------------------------------------------------------------- + // 게임 로직 (Host) + // --------------------------------------------------------------------------- + void _startRound(int nextRound, int nextTurn) { + int dim = _getGridDimension(nextRound); + int totalCount = dim * dim; // 4, 9, 25 + + // 1. 데이터 풀 구성 (난이도에 따라 섞음) + List currentPool = []; + + // 기본적으로 숫자는 항상 포함하거나, 라운드가 높아지면 비중을 줄일 수 있음 + // 여기서는 모든 풀을 합치고 랜덤으로 뽑는 방식 + currentPool.addAll(_poolNum); + + if (nextRound >= 6) currentPool.addAll(_poolKor); // 6~: 한글 추가 + if (nextRound >= 12) currentPool.addAll(_poolEng); // 12~: 영어 추가 + if (nextRound >= 18) currentPool.addAll(_poolEmo); // 18~: 이모지 추가 + + // 2. [핵심] 전체 풀에서 무작위로 'totalCount'개 뽑기 + // (순서대로 뽑지 않고 섞어서 뽑음 -> 가, 다, 하, A, Z 가 섞여 나옴) + currentPool.shuffle(); + List nextGridItems = currentPool.take(totalCount).toList(); + + // 3. 정답 시퀀스 생성 + // 길이: 3 + (라운드 - 1) + int length = 3 + (nextRound - 1); + + List nextSequence = []; + for (int i = 0; i < length; i++) { + nextSequence.add(Random().nextInt(totalCount)); + } + + final payload = { + 'type': 'NEW_ROUND', + 'round': nextRound, + 'turn': nextTurn, + 'gridItems': nextGridItems, + 'sequence': nextSequence + }; + + _broadcast(payload); + } + + void _broadcast(Map payload) { + if (isSolo) { + _handleMessage(payload); + } else { + NetworkManager().sendMessage(payload); + if (widget.isHost) _handleMessage(payload); + } + } + + // --------------------------------------------------------------------------- + // 애니메이션 및 입력 + // --------------------------------------------------------------------------- + Future _playSequenceAnimation() async { + await Future.delayed(const Duration(seconds: 2)); + + for (int targetIdx in targetSequence) { + if (!mounted) return; + setState(() { + activeHighlightIndex = targetIdx; + SoundManager().playSfx(SoundKey.click); + }); + + // 난이도별 깜빡임 속도 + int showTime = max(200, 600 - (round * 15)); + await Future.delayed(Duration(milliseconds: showTime)); + + setState(() => activeHighlightIndex = null); + await Future.delayed(const Duration(milliseconds: 150)); + } + + if (!mounted) return; + setState(() { + isShowingSequence = false; + + if (_isMyTurn()) { + infoMessage = "똑같이 누르세요!"; + infoColor = Colors.greenAccent; + } else { + if (isSolo && currentTurn == 1) { + infoMessage = "${aiPlayer.nickname} 입력 중..."; + infoColor = Colors.cyanAccent; + _playAiTurn(); + } else { + infoMessage = "상대방 입력 중..."; + infoColor = Colors.grey; + } + } + }); + } + + Future _playAiTurn() async { + int inputDelay = max(250, 700 - (round * 20)); + + for (int targetIdx in targetSequence) { + if (!mounted) return; + await Future.delayed(Duration(milliseconds: inputDelay)); + + setState(() => activeHighlightIndex = targetIdx); + SoundManager().playSfx(SoundKey.click); + + await Future.delayed(const Duration(milliseconds: 150)); + setState(() => activeHighlightIndex = null); + } + + await Future.delayed(const Duration(milliseconds: 500)); + if (widget.isHost) { + _handleMessage({'type': 'TURN_COMPLETE'}); + } + } + + void _onButtonTap(int index) { + if (!_isMyTurn() || isShowingSequence) return; + + if (targetSequence[inputIndex] == index) { + SoundManager().playSfx(SoundKey.click); + + setState(() => activeHighlightIndex = index); + Future.delayed(const Duration(milliseconds: 100), () { + if (mounted) setState(() => activeHighlightIndex = null); + }); + + inputIndex++; + + if (inputIndex >= targetSequence.length) { + SoundManager().playSfx(SoundKey.correct); + + if (isSolo) { + _handleMessage({'type': 'TURN_COMPLETE'}); + } else { + if (widget.isHost) { + if (currentTurn == 0) _startRound(round, 1); + else _startRound(round + 1, 0); + } else { + NetworkManager().sendMessage({'type': 'TURN_COMPLETE'}); + } + } + } + } else { + SoundManager().playSfx(SoundKey.wrong); + _sendGameOver(1 - currentTurn); + } + } + + void _sendGameOver(int winner) { + final payload = {'type': 'GAME_OVER', 'winner': winner}; + _broadcast(payload); + } + + void _showGameOverDialog(int winner) { + String msg; + if (isSolo) { + msg = "틀렸습니다!\n최종 기록: ${round}라운드 (길이 $sequenceLength)"; + } else { + int myTeam = widget.isHost ? 0 : 1; + msg = (winner == 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("나가기"), + ) + ], + ), + ); + } + + // --------------------------------------------------------------------------- + // UI + // --------------------------------------------------------------------------- + @override + Widget build(BuildContext context) { + Color teamColor = (currentTurn == 0) ? Colors.redAccent : Colors.blueAccent; + double itemFontSize = currentGridSize == 5 ? 20 : 32; + + return Scaffold( + appBar: AppBar( + title: Text("Round $round (길이: $sequenceLength)"), + backgroundColor: Colors.deepPurple, + ), + backgroundColor: Colors.grey[900], + body: Column( + children: [ + // 1. 상태 메시지 + Container( + padding: const EdgeInsets.all(20), + width: double.infinity, + color: isShowingSequence ? Colors.black54 : teamColor.withOpacity(0.2), + child: Column( + children: [ + Text( + infoMessage, + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: infoColor), + textAlign: TextAlign.center, + ), + if (!isShowingSequence && _isMyTurn()) + Text( + "$inputIndex / $sequenceLength", + style: const TextStyle(fontSize: 16, color: Colors.white70) + ), + ], + ), + ), + + // 2. 그리드 + Expanded( + child: Center( + child: Padding( + padding: const EdgeInsets.all(20.0), + child: AspectRatio( + aspectRatio: 1.0, + child: GridView.builder( + physics: const NeverScrollableScrollPhysics(), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: currentGridSize, + mainAxisSpacing: 10, + crossAxisSpacing: 10, + ), + itemCount: currentGridSize * currentGridSize, + itemBuilder: (context, index) { + if (gridItems.isEmpty) return const SizedBox(); + + String content = gridItems[index]; + bool isHighlight = (index == activeHighlightIndex); + + return GestureDetector( + onTapDown: (_) => _onButtonTap(index), + child: AnimatedContainer( + duration: const Duration(milliseconds: 100), + decoration: BoxDecoration( + color: isHighlight ? Colors.yellowAccent : Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isHighlight ? Colors.orange : Colors.grey, + width: isHighlight ? 4 : 1 + ), + boxShadow: isHighlight ? [ + BoxShadow(color: Colors.yellow.withOpacity(0.6), blurRadius: 15) + ] : [], + ), + child: Center( + child: Text( + content, + style: TextStyle( + fontSize: itemFontSize, + fontWeight: FontWeight.bold, + color: Colors.black87 + ), + ), + ), + ), + ); + }, + ), + ), + ), + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/packages/core/lib/game/spider_multi_game.dart b/packages/core/lib/game/spider_multi_game.dart index 2d1f75e..1a42269 100644 --- a/packages/core/lib/game/spider_multi_game.dart +++ b/packages/core/lib/game/spider_multi_game.dart @@ -1,8 +1,11 @@ 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/spider_model.dart'; +import '../model/spider_game_dto.dart'; import '../widgets/spider_widgets.dart'; class SpiderMultiGame extends BaseGame { @@ -11,16 +14,62 @@ class SpiderMultiGame extends BaseGame { @override String get name => "스파이더 배틀"; @override - String get description => "K부터 A까지 카드를 정렬하세요.\n완성하면 상대방에게 카드를 뿌려 공격합니다!"; + String get description => "세트를 완성하면 상대를 공격합니다!\n(내 남은 카드 중 1장이 상대에게 넘어갑니다)"; - int? _randomSeed; + final StreamController _gameDataStreamController = StreamController.broadcast(); + + Future _fetchSpiderGame(int difficulty) async { + const String baseUrl = "https://lunaticbum.kr"; + try { + final response = await http.get( + Uri.parse('$baseUrl/puzzle/spider/start?difficulty=$difficulty'), + ).timeout(const Duration(seconds: 5)); + + if (response.statusCode == 200) { + final data = jsonDecode(utf8.decode(response.bodyBytes)); + return SpiderGameDto.fromJson(data); + } + } catch (e) { + print("API 호출 실패, 로컬 생성: $e"); + } + return _generateLocalGame(difficulty); + } + + SpiderGameDto _generateLocalGame(int difficulty) { + List deck = []; + if (difficulty == 1) { + for (int i = 0; i < 8; i++) { + for (int r = 1; r <= 13; r++) deck.add(r); + } + } else if (difficulty == 2) { + for (int i = 0; i < 4; i++) { + for (int r = 1; r <= 13; r++) deck.add(r); + for (int r = 1; r <= 13; r++) deck.add(r + 100); + } + } else { + for (int i = 0; i < 2; i++) { + for (int r = 1; r <= 13; r++) deck.add(r); + for (int r = 1; r <= 13; r++) deck.add(r + 100); + for (int r = 1; r <= 13; r++) deck.add(r + 200); + for (int r = 1; r <= 13; r++) deck.add(r + 300); + } + } + deck.shuffle(); + return SpiderGameDto(puzzleId: 0, difficulty: difficulty, cards: deck); + } @override - void onStart() { + void onStart() async { super.onStart(); if (NetworkManager().role == NetworkRole.host) { - final int seed = Random().nextInt(1000000); - final payload = {'type': 'GAME_START_DATA', 'seed': seed}; + final int diff = NetworkManager().selectedGameConfig['difficulty'] ?? 1; + final gameData = await _fetchSpiderGame(diff); + + final payload = { + 'type': 'GAME_START_DATA', + ...gameData.toJson(), + }; + onMessageReceived(NetworkManager().me.id, payload); NetworkManager().sendMessage(payload); } @@ -29,39 +78,52 @@ class SpiderMultiGame extends BaseGame { @override void onMessageReceived(String senderId, Map payload) { if (payload['type'] == 'GAME_START_DATA') { - _randomSeed = payload['seed']; + final gameData = SpiderGameDto.fromJson(payload); + _gameDataStreamController.add(gameData); } } + @override + void onDispose() { + _gameDataStreamController.close(); + super.onDispose(); + } + @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); + return StreamBuilder( + stream: _gameDataStreamController.stream, + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const Scaffold( + body: Center(child: CircularProgressIndicator()), + ); + } + return SpiderBattleScreen(gameData: snapshot.data!, gameInstance: this); + }, + ); } } class SpiderBattleScreen extends StatefulWidget { - final int seed; + final SpiderGameDto gameData; final SpiderMultiGame gameInstance; - const SpiderBattleScreen({super.key, required this.seed, required this.gameInstance}); + const SpiderBattleScreen({super.key, required this.gameData, required this.gameInstance}); @override State createState() => _SpiderBattleScreenState(); } class _SpiderBattleScreenState extends State { - // 게임 상태 - List> tableau = List.generate(10, (_) => []); // 10개 컬럼 - List stock = []; // 뽑을 카드 - List> foundation = []; // 완성된 세트 - + List> tableau = List.generate(10, (_) => []); + List stock = []; + List> foundation = []; int _moves = 0; - final int _numSuits = 1; // 난이도 (1: 스페이드만, 2: 하트/스페이드, 4: 전체) @override void initState() { @@ -73,102 +135,104 @@ class _SpiderBattleScreenState extends State { void _handleNetworkMessage(Map payload) { if (!mounted) return; if (payload['type'] == 'ATTACK') { - _onAttacked(payload['senderName']); + final attackerName = payload['senderName']; + _onAttacked(attackerName); } 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)); - } + for (int rawVal in widget.gameData.cards) { + SpiderSuit suit = SpiderSuit.spade; + int rank = rawVal; + if (rawVal > 300) { suit = SpiderSuit.diamond; rank = rawVal - 300; } + else if (rawVal > 200) { suit = SpiderSuit.club; rank = rawVal - 200; } + else if (rawVal > 100) { suit = SpiderSuit.heart; rank = rawVal - 100; } + if (rank < 1) rank = 1; + if (rank > 13) rank = 13; + deck.add(SpiderCard(id: idCounter++, suit: suit, rank: rank)); } - deck.shuffle(random); - // 태블로에 카드 분배 (앞 4줄 6장, 뒤 6줄 5장 = 54장) int cardIdx = 0; + int totalCards = deck.length; 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); + if (cardIdx < totalCards) { + final card = deck[cardIdx++]; + if (j == count - 1) card.isFaceUp = true; + tableau[i].add(card); + } } } - // 남은 카드는 스톡으로 - stock = deck.sublist(cardIdx); + if (cardIdx < totalCards) { + stock = deck.sublist(cardIdx); + } } - // [공격 받음] 상대가 세트를 완성하면 내 태블로 각 열에 카드 1장씩 추가됨 + // [수정됨] 공격 처리: 내 스톡에서 카드를 꺼내 내 태블로에 추가 void _onAttacked(String attackerName) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text("⚔️ $attackerName님의 공격! 카드가 추가됩니다!"), backgroundColor: Colors.red), - ); - SoundManager().playSfx(SoundKey.wrong); + if (stock.isEmpty) { + // 스톡이 없으면 공격 무효 (또는 다른 패널티 적용 가능) + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("⚔️ $attackerName님의 공격을 막았습니다! (남은 카드 없음)")), + ); + return; + } 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); - } + // 스톡에서 1장 꺼냄 + final card = stock.removeLast(); + card.isFaceUp = true; + + // 랜덤한 컬럼에 추가 + int targetCol = Random().nextInt(10); + tableau[targetCol].add(card); + + // 혹시 이 카드로 인해 세트가 완성될 수도 있으니 체크 + _checkCompleteSet(targetCol); }); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text("⚔️ $attackerName님의 공격! 카드 1장이 추가됩니다!"), + backgroundColor: Colors.redAccent, + duration: const Duration(milliseconds: 1500), + ), + ); + SoundManager().playSfx(SoundKey.wrong); } - // [카드 이동] 드래그 앤 드롭 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); + final targetSuit = last13.first.suit; bool isComplete = true; - - // K(13) ... A(1) 순서여야 함 for (int i = 0; i < 13; i++) { - if (last13[i].rank != 13 - i) { - isComplete = false; - break; + if (last13[i].suit != targetSuit || last13[i].rank != 13 - i) { + isComplete = false; break; } } if (isComplete) { - // 완성! setState(() { col.removeRange(col.length - 13, col.length); foundation.add(last13); @@ -176,11 +240,8 @@ class _SpiderBattleScreenState extends State { }); 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); @@ -189,36 +250,36 @@ class _SpiderBattleScreenState extends State { } } - // [스톡에서 카드 뽑기] 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); // 운 좋게 완성될 수도 있음 - } + // [수정] 남은 카드가 10장 미만이면 남은 만큼만 뿌림 + int count = min(10, stock.length); + for (int i = 0; i < count; i++) { + 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 모드라 무시) + if (bottomCard == null) return true; return bottomCard.rank == topCard.rank + 1; } void _showGameOverDialog(String winnerName) { bool isMe = winnerName == NetworkManager().me.nickname; + if (isMe) SoundManager().playSfx(SoundKey.win); + else SoundManager().playSfx(SoundKey.wrong); + showDialog( context: context, barrierDismissible: false, builder: (_) => AlertDialog( title: Text(isMe ? "승리! 🎉" : "패배 😭"), - content: Text(isMe ? "축하합니다! 모든 세트를 완성했습니다." : "$winnerName 님이 먼저 완료했습니다."), + content: Text(isMe ? "축하합니다! 가장 먼저 끝냈습니다." : "$winnerName 님이 먼저 완료했습니다."), actions: [ TextButton( onPressed: () { Navigator.pop(context); Navigator.pop(context); }, @@ -229,24 +290,27 @@ class _SpiderBattleScreenState extends State { ); } - // --- UI --- - @override Widget build(BuildContext context) { final size = MediaQuery.of(context).size; - final cardWidth = (size.width - 20) / 10; // 10열 + final cardWidth = (size.width - 20) / 10; final cardHeight = cardWidth * 1.4; return Scaffold( backgroundColor: Colors.green[800], appBar: AppBar( - title: Text("스파이더 배틀 (${foundation.length}/8)"), + title: Text("스파이더 (${foundation.length}/8)"), backgroundColor: Colors.green[900], elevation: 0, + actions: [ + Center(child: Padding( + padding: const EdgeInsets.only(right: 16.0), + child: Text("이동: $_moves", style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.white70)), + )) + ], ), body: Column( children: [ - // 1. 태블로 (카드 놓는 곳) Expanded( child: Stack( children: List.generate(10, (colIndex) { @@ -258,8 +322,6 @@ class _SpiderBattleScreenState extends State { }), ), ), - - // 2. 하단 바 (스톡 & 완성된 덱) Container( height: cardHeight + 20, color: Colors.green[900], @@ -267,24 +329,29 @@ class _SpiderBattleScreenState extends State { 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표시 + card: SpiderCard(id: 0, suit: SpiderSuit.spade, rank: 13, isFaceUp: true), 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), + : Stack( + children: [ + SpiderCardWidget(card: SpiderCard(id: -1, suit: SpiderSuit.spade, rank: 0), width: cardWidth, height: cardHeight), + Positioned( + bottom: 5, right: 5, + child: Text("${stock.length}", style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 12)), + ) + ], + ), ), ], ), @@ -296,18 +363,15 @@ class _SpiderBattleScreenState extends State { 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; // 제자리 드롭 무시 + if (fromIndex == colIndex) return false; final SpiderCard topMoving = movingCards.first; final SpiderCard? targetBottom = pile.isEmpty ? null : pile.last; - return _canMove(topMoving, targetBottom); }, onAccept: (data) { @@ -319,20 +383,15 @@ class _SpiderBattleScreenState extends State { 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; // 카드 겹침 간격 - - // 드래그 가능한 카드인지 확인 (오픈되어 있고, 위 카드들과 연속된 순서인지) + 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) { + if (pile[k].suit != pile[k+1].suit || pile[k].rank != pile[k+1].rank + 1) { isDraggable = false; break; } @@ -342,9 +401,7 @@ class _SpiderBattleScreenState extends State { Widget cardWidget = SpiderCardWidget(card: card, width: width, height: height); if (isDraggable) { - // 같이 움직일 카드 묶음 final movingCards = pile.sublist(i); - return Positioned( top: offset, child: Draggable>( @@ -355,7 +412,7 @@ class _SpiderBattleScreenState extends State { children: movingCards.map((c) => SpiderCardWidget(card: c, width: width, height: height)).toList(), ), ), - childWhenDragging: const SizedBox(), // 드래그 중엔 숨김 (또는 밑장 표시) + childWhenDragging: const SizedBox(), child: cardWidget, ), ); diff --git a/packages/core/lib/game/sudoku_multi_game.dart b/packages/core/lib/game/sudoku_multi_game.dart index 797b7b9..ca04bf8 100644 --- a/packages/core/lib/game/sudoku_multi_game.dart +++ b/packages/core/lib/game/sudoku_multi_game.dart @@ -18,9 +18,6 @@ class SudokuMultiGame extends BaseGame { final StreamController _puzzleStreamController = StreamController.broadcast(); - // --------------------------------------------------------------------------- - // [Host] 퍼즐 데이터 로딩 로직 - // --------------------------------------------------------------------------- Future _fetchPuzzleFromApi(String difficulty) async { const String baseUrl = "https://lunaticbum.kr"; try { @@ -47,16 +44,10 @@ class SudokuMultiGame extends BaseGame { @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(), - }; - + final payload = {'type': 'GAME_DATA', ...puzzleData.toJson()}; onMessageReceived(NetworkManager().me.id, payload); NetworkManager().sendMessage(payload); } @@ -64,6 +55,9 @@ class SudokuMultiGame extends BaseGame { @override void onMessageReceived(String senderId, Map payload) { + // [핵심 수정] GAME_DATA는 스트림으로, ATTACK 등은 이벤트 버스(또는 아래 화면 리스너)로 처리 + // 하지만 여기서는 StreamBuilder가 화면을 구성하므로, ATTACK 이벤트 처리는 화면(State)에서 NetworkManager를 리스닝하는 것이 가장 빠릅니다. + // 따라서 여기서는 게임 데이터만 처리합니다. if (payload['type'] == 'GAME_DATA') { final puzzle = SudokuGameDto.fromJson(payload); _puzzleStreamController.add(puzzle); @@ -78,7 +72,6 @@ class SudokuMultiGame extends BaseGame { @override Widget buildHostView(BuildContext context) => _buildGameScreen(context); - @override Widget buildGuestView(BuildContext context) => _buildGameScreen(context); @@ -88,16 +81,7 @@ class SudokuMultiGame extends BaseGame { builder: (context, snapshot) { if (!snapshot.hasData) { return const Scaffold( - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - CircularProgressIndicator(), - SizedBox(height: 20), - Text("퍼즐을 불러오는 중입니다..."), - ], - ), - ), + body: Center(child: CircularProgressIndicator()), ); } return SudokuBattleScreen(gameData: snapshot.data!, gameInstance: this); @@ -106,9 +90,6 @@ class SudokuMultiGame extends BaseGame { } } -// ----------------------------------------------------------------------------- -// 게임 화면 (UI + 로직) -// ----------------------------------------------------------------------------- class SudokuBattleScreen extends StatefulWidget { final SudokuGameDto gameData; final SudokuMultiGame gameInstance; @@ -141,6 +122,7 @@ class _SudokuBattleScreenState extends State { originalCells = List.from(puzzleCells); solutionCells = widget.gameData.solution.split('').map(_charToInt).toList(); + // [핵심 수정] 공격 이벤트 리스너 등록 NetworkManager().messageStream.listen(_handleNetworkMessage); } @@ -164,6 +146,7 @@ class _SudokuBattleScreenState extends State { void _applyAttack(String attackerName) { List myInputs = []; for (int i = 0; i < puzzleCells.length; i++) { + // 원래 빈칸이었는데 채워둔 숫자들 if (originalCells[i] == 0 && puzzleCells[i] != 0) { myInputs.add(i); } @@ -172,7 +155,7 @@ class _SudokuBattleScreenState extends State { if (myInputs.isNotEmpty) { final randomIdx = myInputs[Random().nextInt(myInputs.length)]; setState(() { - puzzleCells[randomIdx] = 0; + puzzleCells[randomIdx] = 0; // 숫자 지움 }); ScaffoldMessenger.of(context).showSnackBar( @@ -259,7 +242,7 @@ class _SudokuBattleScreenState extends State { barrierDismissible: false, builder: (context) => AlertDialog( title: Text(isMe ? "승리! 🎉" : "패배 😭"), - content: Text(isMe ? "축하합니다!" : "$winnerName 님이 승리했습니다."), + content: Text(isMe ? "축하합니다! $winnerName 님이 승리했습니다." : "$winnerName 님이 먼저 퍼즐을 완성했습니다."), actions: [ TextButton( onPressed: () { @@ -304,47 +287,44 @@ class _SudokuBattleScreenState extends State { ) ], ), + // 하단 배너 광고 + bottomNavigationBar: const SafeArea(child: AdBannerWidget()), body: Column( mainAxisAlignment: MainAxisAlignment.start, children: [ - // 1. 상단 빈칸 정보 + const SizedBox(height: 10), Padding( - padding: const EdgeInsets.symmetric(vertical: 10.0), - child: Text( - "남은 빈칸: ${puzzleCells.where((e)=>e==0).length}", - style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18) + padding: const EdgeInsets.all(8.0), + child: Text("남은 빈칸: ${puzzleCells.where((e)=>e==0).length}", + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 10.0), + child: Center( + 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; + selectedNumberPad = null; + } + }); + }, + ), ), ), - - // 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. 키패드 (높이 증가) + const Spacer(), Container( padding: const EdgeInsets.fromLTRB(16, 10, 16, 30), - height: 240, // [수정] 높이 확대 + height: 240, color: Colors.grey[50], child: NumberPad( blockSize: blockSize, @@ -352,13 +332,11 @@ class _SudokuBattleScreenState extends State { selectedNumber: selectedNumberPad, onNumberTapped: (num) { setState(() { - // [핵심] 칸이 선택된 상태에서 숫자 누르면 -> 입력 후 즉시 포커스 해제 if (selectedIndex != null) { _onNumberTapped(num); - selectedIndex = null; // 입력했으므로 선택 해제 - selectedNumberPad = null; // 모드 초기화 + selectedIndex = null; + selectedNumberPad = null; } else { - // 칸 선택 없이 숫자만 누르면 '숫자 우선 모드' 토글 selectedNumberPad = (selectedNumberPad == num) ? null : num; } }); @@ -369,4 +347,14 @@ class _SudokuBattleScreenState extends State { ), ); } -} \ No newline at end of file +} + +// [보조] DTO에 없는 테마 클래스 간단 정의 (SudokuWidgets에서 요구할 수 있음) +class SudokuTheme { + final Color primaryColor; + final Color backgroundColor; + final SudokuSymbolType symbolType; + SudokuTheme({required this.primaryColor, required this.backgroundColor, required this.symbolType}); + String getSymbol(int val) => val.toString(); +} +enum SudokuSymbolType { number } \ No newline at end of file diff --git a/packages/core/lib/game/survivor_game.dart b/packages/core/lib/game/survivor_game.dart new file mode 100644 index 0000000..f6d6f8d --- /dev/null +++ b/packages/core/lib/game/survivor_game.dart @@ -0,0 +1,418 @@ +import 'dart:async'; +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:playwith_core/playwith_core.dart'; + +class SurvivorGame extends BaseGame { + @override + String get id => "survivor"; + @override + String get name => "서바이버"; + @override + String get description => "최후의 생존자가 되세요!"; + + @override + void onMessageReceived(String senderId, Map payload) { + // 점수 공유 등 처리 (생략 가능) + } + + @override + Widget buildHostView(BuildContext context) => SurvivorScreen(isHost: true, gameInstance: this); + @override + Widget buildGuestView(BuildContext context) => SurvivorScreen(isHost: false, gameInstance: this); +} + +class SurvivorScreen extends StatefulWidget { + final bool isHost; + final SurvivorGame gameInstance; + + const SurvivorScreen({super.key, required this.isHost, required this.gameInstance}); + + @override + State createState() => _SurvivorScreenState(); +} + +class _SurvivorScreenState extends State with SingleTickerProviderStateMixin { + late Ticker _ticker; + + // --- 게임 엔티티 --- + _Player player = _Player(); + List<_Enemy> enemies = []; + List<_Bullet> bullets = []; + List<_Gem> gems = []; // 경험치 보석 + + // --- 게임 상태 --- + int score = 0; // 킬 수 + int opponentScore = 0; + int level = 1; + int exp = 0; + int maxExp = 5; + + double gameTime = 0; + bool isGameOver = false; + + // 조작 + Offset _joystickDelta = Offset.zero; + + @override + void initState() { + super.initState(); + _ticker = createTicker(_gameLoop)..start(); + NetworkManager().messageStream.listen(_handleMessage); + } + + void _handleMessage(Map payload) { + if (!mounted) return; + if (payload['type'] == 'SCORE') { + setState(() => opponentScore = payload['score']); + } + } + + void _gameLoop(Duration elapsed) { + if (isGameOver) return; + + setState(() { + gameTime += 0.016; // 약 60fps + + // 1. 플레이어 이동 + if (_joystickDelta != Offset.zero) { + player.x += _joystickDelta.dx * player.speed; + player.y += _joystickDelta.dy * player.speed; + + // 화면 밖 제한 (0.0 ~ 1.0 좌표계) + player.x = player.x.clamp(0.0, 1.0); + player.y = player.y.clamp(0.0, 1.0); + } + + // 2. 적 생성 (시간 지날수록 많이) + if (Random().nextDouble() < 0.02 + (level * 0.005)) { + _spawnEnemy(); + } + + // 3. 적 이동 (플레이어 추적) + for (var enemy in enemies) { + double dx = player.x - enemy.x; + double dy = player.y - enemy.y; + double dist = sqrt(dx*dx + dy*dy); + if (dist > 0) { + enemy.x += (dx / dist) * enemy.speed; + enemy.y += (dy / dist) * enemy.speed; + } + + // 플레이어 충돌 (피격) + if (dist < (player.size + enemy.size) / 2) { + _gameOver(); + } + } + + // 4. 자동 공격 (가장 가까운 적) + player.cooldown -= 0.016; + if (player.cooldown <= 0 && enemies.isNotEmpty) { + _Enemy? target = _findNearestEnemy(); + if (target != null) { + _fireBullet(target); + player.cooldown = player.maxCooldown; // 공속 + } + } + + // 5. 총알 이동 및 충돌 + for (int i = bullets.length - 1; i >= 0; i--) { + var b = bullets[i]; + b.x += b.vx; + b.y += b.vy; + b.life -= 0.016; + + // 화면 밖 or 수명 끝 + if (b.x < 0 || b.x > 1 || b.y < 0 || b.y > 1 || b.life <= 0) { + bullets.removeAt(i); + continue; + } + + // 적 충돌 체크 + for (int j = enemies.length - 1; j >= 0; j--) { + var e = enemies[j]; + double dist = sqrt(pow(b.x - e.x, 2) + pow(b.y - e.y, 2)); + if (dist < (b.size + e.size) / 2) { + // 명중 + e.hp--; + bullets.removeAt(i); // 총알 삭제 (관통 없음) + if (e.hp <= 0) { + enemies.removeAt(j); + _dropGem(e.x, e.y); + score++; + if (score % 10 == 0) NetworkManager().sendMessage({'type': 'SCORE', 'score': score}); + } + break; + } + } + } + + // 6. 보석 획득 (경험치) + for (int i = gems.length - 1; i >= 0; i--) { + var g = gems[i]; + // 자석 효과 (플레이어 근처면 빨려옴) + double dx = player.x - g.x; + double dy = player.y - g.y; + double dist = sqrt(dx*dx + dy*dy); + + if (dist < 0.15) { // 자석 범위 + g.x += dx * 0.1; + g.y += dy * 0.1; + } + + if (dist < player.size) { + gems.removeAt(i); + exp++; + if (exp >= maxExp) { + _levelUp(); + } + } + } + }); + } + + _Enemy? _findNearestEnemy() { + _Enemy? nearest; + double minDst = 100.0; + for (var e in enemies) { + double dst = sqrt(pow(player.x - e.x, 2) + pow(player.y - e.y, 2)); + if (dst < minDst) { + minDst = dst; + nearest = e; + } + } + // 사거리 체크 (화면 절반 정도) + if (minDst < 0.4) return nearest; + return null; + } + + void _fireBullet(_Enemy target) { + double dx = target.x - player.x; + double dy = target.y - player.y; + double dist = sqrt(dx*dx + dy*dy); + + bullets.add(_Bullet( + x: player.x, + y: player.y, + vx: (dx/dist) * 0.02, // 총알 속도 + vy: (dy/dist) * 0.02 + )); + + // 멀티샷 (레벨업 시 추가 가능) + if (level >= 3) { + // 약간 빗나간 총알 추가 등 로직 가능 + } + } + + void _spawnEnemy() { + // 화면 가장자리 랜덤 위치 + double x, y; + if (Random().nextBool()) { + x = Random().nextBool() ? -0.1 : 1.1; + y = Random().nextDouble(); + } else { + x = Random().nextDouble(); + y = Random().nextBool() ? -0.1 : 1.1; + } + + enemies.add(_Enemy( + x: x, y: y, + hp: 1 + (level ~/ 2), // 레벨 비례 체력 + speed: 0.002 + (level * 0.0005) // 레벨 비례 속도 + )); + } + + void _dropGem(double x, double y) { + gems.add(_Gem(x: x, y: y)); + } + + void _levelUp() { + level++; + exp = 0; + maxExp += 5; + player.maxCooldown *= 0.9; // 공속 증가 + // 이펙트나 알림 추가 가능 + SoundManager().playSfx(SoundKey.win); // 레벨업 효과음 대용 + } + + void _gameOver() { + isGameOver = true; + _ticker.stop(); + SoundManager().playSfx(SoundKey.wrong); + + String result = score > opponentScore ? "승리! (상대: $opponentScore)" : "패배... (상대: $opponentScore)"; + if (score == opponentScore) result = "무승부!"; + + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => AlertDialog( + title: const Text("생존 실패!"), + content: Text("최종 레벨: $level\n처치 수: $score\n$result"), + actions: [ + TextButton( + onPressed: () { Navigator.pop(context); Navigator.pop(context); }, + child: const Text("나가기"), + ) + ], + ), + ); + } + + @override + void dispose() { + _ticker.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.green[900], // 잔디 느낌 + body: Stack( + children: [ + // 게임 화면 + Positioned.fill( + child: CustomPaint( + painter: SurvivorPainter(player, enemies, bullets, gems), + ), + ), + + // UI 오버레이 (점수, 레벨) + SafeArea( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text("KILL: $score", style: const TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold)), + Text("LV: $level", style: const TextStyle(color: Colors.yellowAccent, fontSize: 24, fontWeight: FontWeight.bold)), + Text("RIVAL: $opponentScore", style: const TextStyle(color: Colors.white70, fontSize: 16)), + ], + ), + const SizedBox(height: 5), + // 경험치 바 + LinearProgressIndicator( + value: exp / maxExp, + backgroundColor: Colors.black26, + color: Colors.blueAccent, + minHeight: 8, + ) + ], + ), + ), + ), + + // 가상 조이스틱 (전체 화면 드래그 인식) + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onPanStart: (d) => _updateJoystick(d.localPosition, start: true), + onPanUpdate: (d) => _updateJoystick(d.localPosition), + onPanEnd: (d) => setState(() => _joystickDelta = Offset.zero), + child: Container(color: Colors.transparent), + ), + ), + ], + ), + ); + } + + // 가상 조이스틱 로직 (화면 어디든 터치해서 움직임) + Offset _startTouchPos = Offset.zero; + void _updateJoystick(Offset pos, {bool start = false}) { + if (start) _startTouchPos = pos; + + Offset diff = pos - _startTouchPos; + double dist = diff.distance; + if (dist > 0) { + // 최대 거리 제한 (감도 조절) + if (dist > 50) diff = diff / dist * 50; + setState(() { + _joystickDelta = diff / 50.0; // -1.0 ~ 1.0 정규화 + }); + } + } +} + +// --- 내부 클래스 --- +class _Player { + double x = 0.5, y = 0.5; + double size = 0.04; + double speed = 0.008; + double cooldown = 0; + double maxCooldown = 0.5; // 초당 2발 +} + +class _Enemy { + double x, y; + int hp; + double size = 0.03; + double speed; + _Enemy({required this.x, required this.y, this.hp = 1, this.speed = 0.002}); +} + +class _Bullet { + double x, y, vx, vy; + double size = 0.015; + double life = 2.0; // 2초 후 사라짐 + _Bullet({required this.x, required this.y, required this.vx, required this.vy}); +} + +class _Gem { + double x, y; + _Gem({required this.x, required this.y}); +} + +// --- 페인터 --- +class SurvivorPainter extends CustomPainter { + final _Player player; + final List<_Enemy> enemies; + final List<_Bullet> bullets; + final List<_Gem> gems; + + SurvivorPainter(this.player, this.enemies, this.bullets, this.gems); + + @override + void paint(Canvas canvas, Size size) { + final w = size.width; + final h = size.height; + + // 좌표 변환 + Offset toPos(double x, double y) => Offset(x * w, y * h); + + // 보석 + final gemPaint = Paint()..color = Colors.blueAccent; + for (var g in gems) { + canvas.drawCircle(toPos(g.x, g.y), w * 0.015, gemPaint); + } + + // 적 + final enemyPaint = Paint()..color = Colors.red; + for (var e in enemies) { + // 사각형으로 그림 + Rect rect = Rect.fromCenter(center: toPos(e.x, e.y), width: w * e.size * 2, height: w * e.size * 2); + canvas.drawRect(rect, enemyPaint); + } + + // 총알 + final bulletPaint = Paint()..color = Colors.yellow; + for (var b in bullets) { + canvas.drawCircle(toPos(b.x, b.y), w * b.size, bulletPaint); + } + + // 플레이어 + final playerPaint = Paint()..color = Colors.white; + canvas.drawCircle(toPos(player.x, player.y), w * player.size, playerPaint); + + // 플레이어 테두리 (HP 느낌) + final borderPaint = Paint()..color = Colors.black..style = PaintingStyle.stroke..strokeWidth = 2; + canvas.drawCircle(toPos(player.x, player.y), w * player.size, borderPaint); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => true; +} \ No newline at end of file diff --git a/packages/core/lib/game/world_tour_game.dart b/packages/core/lib/game/world_tour_game.dart new file mode 100644 index 0000000..66a2272 --- /dev/null +++ b/packages/core/lib/game/world_tour_game.dart @@ -0,0 +1,495 @@ +import 'dart:async'; +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:playwith_core/playwith_core.dart'; + +class WorldTourGame extends BaseGame { + @override + String get id => "world_tour"; + @override + String get name => "월드 투어"; + @override + String get description => "주사위로 떠나는 세계 여행\n건물을 지어 통행료를 높이세요!"; + + @override + void onMessageReceived(String senderId, Map payload) { + // UI에서 처리 + } + + @override + Widget buildHostView(BuildContext context) => WorldTourScreen(myTeam: 0, gameInstance: this); // 0: Red + @override + Widget buildGuestView(BuildContext context) => WorldTourScreen(myTeam: 1, gameInstance: this); // 1: Blue +} + +// 도시 정보 +class City { + final String name; + final int basePrice; // 땅값 + final int baseToll; // 기본 통행료 + int owner; // -1: 없음, 0: Red, 1: Blue + int buildingLevel; // 0: 땅만, 1: 1단계, 2: 2단계, 3: 랜드마크 + + City(this.name, this.basePrice, this.baseToll, {this.owner = -1, this.buildingLevel = 0}); + + // 현재 통행료 계산 (건물 1개당 50%씩 증가 -> 3단계면 2.5배) + int get currentToll => (baseToll * (1 + 0.5 * buildingLevel)).toInt(); + + // 업그레이드 비용 (땅값의 50%) + int get upgradeCost => (basePrice * 0.5).toInt(); +} + +class WorldTourScreen extends StatefulWidget { + final int myTeam; // 0: Red, 1: Blue + final WorldTourGame gameInstance; + + const WorldTourScreen({super.key, required this.myTeam, required this.gameInstance}); + + @override + State createState() => _WorldTourScreenState(); +} + +class _WorldTourScreenState extends State { + // 보드 데이터 (20칸) + final List board = [ + City("출발", 0, 0), // 0 + City("타이페이", 50, 30), // 1 + City("베이징", 80, 40), // 2 + City("마닐라", 100, 50), // 3 + City("제주도", 150, 80), // 4 + City("무인도", 0, 0), // 5 + City("아테네", 180, 90), // 6 + City("코펜하겐", 200, 100), // 7 + City("오타와", 220, 110), // 8 + City("베를린", 240, 120), // 9 + City("사회복지", 0, 0), // 10 + City("상파울루", 300, 150), // 11 + City("시드니", 320, 160), // 12 + City("하와이", 350, 180), // 13 + City("리스본", 400, 200), // 14 + City("세계여행", 0, 0), // 15 + City("도쿄", 500, 300), // 16 + City("파리", 600, 400), // 17 + City("런던", 700, 500), // 18 + City("서울", 1000, 800), // 19 + ]; + + List positions = [0, 0]; // 위치 + List money = [2000, 2000]; // 자금 + int currentTurn = 0; // 0: Red, 1: Blue + + bool canRoll = true; + String infoMessage = "게임을 시작합니다!"; + int? lastDice; + + @override + void initState() { + super.initState(); + NetworkManager().messageStream.listen(_handleMessage); + } + + void _handleMessage(Map payload) { + if (!mounted) return; + + if (payload['type'] == 'ROLL') { + int result = payload['result']; + int team = payload['team']; + + setState(() { + lastDice = result; + _movePlayer(team, result); + }); + } else if (payload['type'] == 'BUY') { + // 땅 구매 or 건물 업그레이드 + int team = payload['team']; + int index = payload['index']; + int cost = payload['cost']; + bool isUpgrade = payload['isUpgrade'] ?? false; + + setState(() { + money[team] -= cost; + board[index].owner = team; + if (isUpgrade) { + board[index].buildingLevel++; + infoMessage = "${board[index].name} 건물 증축! (${board[index].buildingLevel}단계)"; + } else { + infoMessage = "${board[index].name} 구매 완료!"; + } + _nextTurn(); + }); + } else if (payload['type'] == 'PAY') { + int from = payload['from']; + int to = payload['to']; + int amount = payload['amount']; + + setState(() { + money[from] -= amount; + money[to] += amount; + infoMessage = "통행료 $amount 지불!"; + _checkBankruptcy(); + _nextTurn(); + }); + } else if (payload['type'] == 'PASS') { + _nextTurn(); + } else if (payload['type'] == 'GAME_OVER') { + _showGameOverDialog(payload['winner']); + } + } + + // --- 로직 --- + + void _onRollDice() { + if (currentTurn != widget.myTeam || !canRoll) return; + + int dice1 = Random().nextInt(6) + 1; + int dice2 = Random().nextInt(6) + 1; + int total = dice1 + dice2; + + NetworkManager().sendMessage({'type': 'ROLL', 'result': total, 'team': widget.myTeam}); + + setState(() { + lastDice = total; + _movePlayer(widget.myTeam, total); + }); + } + + void _movePlayer(int team, int steps) { + canRoll = false; + int currentPos = positions[team]; + int nextPos = (currentPos + steps) % 20; + + // 한 바퀴 돌았는지 체크 (월급) + if (nextPos < currentPos) { + money[team] += 300; // 월급 + infoMessage = "한 바퀴 돌았습니다! (+300)"; + } + + positions[team] = nextPos; + _handleArrival(team, nextPos); + } + + void _handleArrival(int team, int index) { + City city = board[index]; + + // 1. 특수 지역 (출발, 무인도 등) - 주인 없음 + if (city.basePrice == 0) { + if (team == widget.myTeam) { + Future.delayed(const Duration(seconds: 1), () { + NetworkManager().sendMessage({'type': 'PASS'}); + _nextTurn(); + }); + } + return; + } + + // 2. 빈 땅 -> 구매 가능 + if (city.owner == -1) { + if (team == widget.myTeam) { + if (money[team] >= city.basePrice) { + _showBuyDialog(index, isUpgrade: false); + } else { + NetworkManager().sendMessage({'type': 'PASS'}); + _nextTurn(); + } + } + } + // 3. 내 땅 -> 업그레이드 가능 (3단계 미만일 때) + else if (city.owner == team) { + if (team == widget.myTeam) { + if (city.buildingLevel < 3 && money[team] >= city.upgradeCost) { + _showBuyDialog(index, isUpgrade: true); + } else { + // 이미 최고 레벨이거나 돈 부족 + NetworkManager().sendMessage({'type': 'PASS'}); + _nextTurn(); + } + } + } + // 4. 남의 땅 -> 통행료 + else { + if (team == widget.myTeam) { + int toll = city.currentToll; + NetworkManager().sendMessage({ + 'type': 'PAY', + 'from': team, + 'to': city.owner, + 'amount': toll + }); + setState(() { + money[team] -= toll; + money[city.owner] += toll; + infoMessage = "${city.name} 도착! 통행료 $toll 지불"; + }); + _checkBankruptcy(); + _nextTurn(); + } + } + } + + void _showBuyDialog(int index, {required bool isUpgrade}) { + City city = board[index]; + int cost = isUpgrade ? city.upgradeCost : city.basePrice; + String title = isUpgrade ? "${city.name} 증축?" : "${city.name} 구매?"; + String content = isUpgrade + ? "현재 단계: ${city.buildingLevel} -> ${city.buildingLevel + 1}\n비용: $cost" + : "가격: $cost\n기본 통행료: ${city.baseToll}"; + + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => AlertDialog( + title: Text(title), + content: Text("$content\n보유 자금: ${money[widget.myTeam]}"), + actions: [ + TextButton( + onPressed: () { + Navigator.pop(context); + NetworkManager().sendMessage({'type': 'PASS'}); + _nextTurn(); + }, + child: const Text("패스"), + ), + ElevatedButton( + onPressed: () { + Navigator.pop(context); + NetworkManager().sendMessage({ + 'type': 'BUY', + 'team': widget.myTeam, + 'index': index, + 'cost': cost, + 'isUpgrade': isUpgrade + }); + setState(() { + money[widget.myTeam] -= cost; + board[index].owner = widget.myTeam; + if (isUpgrade) { + board[index].buildingLevel++; + infoMessage = "건물 업그레이드 완료!"; + } else { + infoMessage = "구매 완료!"; + } + _nextTurn(); + }); + }, + child: Text(isUpgrade ? "증축" : "구매"), + ), + ], + ), + ); + } + + void _nextTurn() { + setState(() { + currentTurn = 1 - currentTurn; + canRoll = true; + lastDice = null; + }); + } + + void _checkBankruptcy() { + if (money[0] < 0) _finishGame(1); // Blue Win + else if (money[1] < 0) _finishGame(0); // Red Win + } + + void _finishGame(int winner) { + NetworkManager().sendMessage({'type': 'GAME_OVER', 'winner': winner}); + _showGameOverDialog(winner); + } + + void _showGameOverDialog(int winner) { + String msg = (winner == 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("나가기"), + ) + ], + ), + ); + } + + // --- UI --- + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text("월드 투어")), + body: Column( + children: [ + // 상태창 + Container( + padding: const EdgeInsets.all(16), + color: Colors.blueGrey[50], + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _buildPlayerInfo(0, Colors.redAccent, "RED"), + const Text("VS", style: TextStyle(fontWeight: FontWeight.bold)), + _buildPlayerInfo(1, Colors.blueAccent, "BLUE"), + ], + ), + ), + + if (lastDice != null) + Padding( + padding: const EdgeInsets.all(8.0), + child: Text("주사위: $lastDice", style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + ), + + // 보드 + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + return Padding( + padding: const EdgeInsets.all(10.0), + child: CustomPaint( + size: Size(constraints.maxWidth, constraints.maxWidth), + painter: BoardPainter(board, positions), + ), + ); + }, + ), + ), + + // 컨트롤 + Padding( + padding: const EdgeInsets.all(20), + child: SizedBox( + width: double.infinity, + height: 60, + child: ElevatedButton( + onPressed: (currentTurn == widget.myTeam && canRoll) ? _onRollDice : null, + style: ElevatedButton.styleFrom( + backgroundColor: widget.myTeam == 0 ? Colors.redAccent : Colors.blueAccent, + foregroundColor: Colors.white, + ), + child: Text((currentTurn == widget.myTeam) ? "주사위 굴리기" : "상대방 차례"), + ), + ), + ), + ], + ), + ); + } + + Widget _buildPlayerInfo(int team, Color color, String name) { + bool isTurn = currentTurn == team; + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: isTurn ? color.withOpacity(0.2) : Colors.transparent, + border: Border.all(color: color, width: 2), + borderRadius: BorderRadius.circular(10), + ), + child: Column( + children: [ + Text(name, style: TextStyle(fontWeight: FontWeight.bold, color: color)), + Text("${money[team]}원", style: const TextStyle(fontSize: 18)), + ], + ), + ); + } +} + +// 보드 그리기 (ㅁ자 형태 + 건물 표시) +class BoardPainter extends CustomPainter { + final List board; + final List positions; + + BoardPainter(this.board, this.positions); + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint()..style = PaintingStyle.stroke..strokeWidth = 1.0; + final fillPaint = Paint()..style = PaintingStyle.fill; + + double cellSize = size.width / 6; + + // 칸 그리기 + for (int i = 0; i < 20; i++) { + Rect rect = _getRect(i, cellSize, size.width); + + // 땅 주인 색칠 + if (board[i].owner != -1) { + fillPaint.color = board[i].owner == 0 ? Colors.redAccent.withOpacity(0.3) : Colors.blueAccent.withOpacity(0.3); + canvas.drawRect(rect, fillPaint); + } else if (i % 5 == 0) { + fillPaint.color = Colors.grey.withOpacity(0.2); + canvas.drawRect(rect, fillPaint); + } + + paint.color = Colors.black; + canvas.drawRect(rect, paint); + + // 텍스트 (도시 이름) + _drawText(canvas, board[i].name, rect.center, i); + + // [추가] 건물 표시 (별) + if (board[i].owner != -1 && board[i].buildingLevel > 0) { + _drawBuilding(canvas, rect, board[i].buildingLevel, board[i].owner == 0 ? Colors.red : Colors.blue); + } + } + + // 말 그리기 + _drawToken(canvas, _getRect(positions[0], cellSize, size.width).center, Colors.red, -5); + _drawToken(canvas, _getRect(positions[1], cellSize, size.width).center, Colors.blue, 5); + } + + void _drawBuilding(Canvas canvas, Rect rect, int level, Color color) { + // 상단에 작은 원(또는 별)으로 건물 단계 표시 + final paint = Paint()..color = color..style = PaintingStyle.fill; + double yPos = rect.top + 8; + double startX = rect.center.dx - ((level - 1) * 6); // 중앙 정렬 + + for (int i = 0; i < level; i++) { + canvas.drawCircle(Offset(startX + (i * 12), yPos), 3, paint); + } + } + + void _drawToken(Canvas canvas, Offset center, Color color, double offset) { + final paint = Paint()..color = color..style = PaintingStyle.fill; + canvas.drawCircle(center + Offset(offset, offset), 8, paint); + paint.style = PaintingStyle.stroke; + paint.color = Colors.white; + paint.strokeWidth = 2; + canvas.drawCircle(center + Offset(offset, offset), 8, paint); + } + + void _drawText(Canvas canvas, String text, Offset center, int index) { + TextSpan span = TextSpan(style: const TextStyle(color: Colors.black, fontSize: 10, fontWeight: FontWeight.bold), text: text); + TextPainter tp = TextPainter(text: span, textAlign: TextAlign.center, textDirection: TextDirection.ltr); + tp.layout(); + tp.paint(canvas, center - Offset(tp.width / 2, tp.height / 2 - 5)); // 텍스트 약간 위로 (가격 등 표시 공간 확보 위해) + } + + Rect _getRect(int index, double size, double totalSize) { + // 좌표 계산 로직 (기존 동일) + int side = index ~/ 5; + double x = 0, y = 0; + + if (index >= 0 && index <= 5) { + x = totalSize - (index + 1) * size; + y = totalSize - size; + } else if (index > 5 && index <= 10) { + x = 0; + y = totalSize - (index - 5 + 1) * size; + } else if (index > 10 && index <= 15) { + x = (index - 10) * size; + y = 0; + } else { + x = totalSize - size; + y = (index - 15) * size; + } + + return Rect.fromLTWH(x, y, size, size); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => true; +} \ No newline at end of file diff --git a/packages/core/lib/manager/media_manager.dart b/packages/core/lib/manager/media_manager.dart index 298bb4b..2b0e3bc 100644 --- a/packages/core/lib/manager/media_manager.dart +++ b/packages/core/lib/manager/media_manager.dart @@ -40,6 +40,7 @@ class MediaManager { MediaManager._internal(); EphemeralDatabase? _db; + EphemeralDatabase? get db => _db; String? _currentRoomId; final Map _activeTransfers = {}; diff --git a/packages/core/lib/model/game_info.dart b/packages/core/lib/model/game_info.dart index 1d12ac1..3aadb53 100644 --- a/packages/core/lib/model/game_info.dart +++ b/packages/core/lib/model/game_info.dart @@ -85,6 +85,65 @@ class AppGames { icon: Icons.touch_app, isSinglePlayerSupported: false, ), + GameInfo( + id: 'world_tour', + name: '월드 투어', + description: '주사위를 굴려 세계 여행!\n땅을 사고 통행료를 받으세요.', + icon: Icons.public, + isSinglePlayerSupported: false, + ), + // [추가] 오셀로 + GameInfo( + id: 'othello', + name: '오셀로', + description: '돌을 뒤집어라!\n마지막에 웃는 자가 승리', + icon: Icons.circle, // 흑백 원 아이콘 + isSinglePlayerSupported: false, + ), + // [추가] 알카노이드 + GameInfo( + id: 'arkanoid', + name: '벽돌 깨기', + description: '추억의 아케이드!\n누가 더 높은 점수를 낼까?', + icon: Icons.view_module, + isSinglePlayerSupported: true, + ),// [추가] 매스 런 (게이트 런) + GameInfo( + id: 'math_run', + name: '매스 런', + description: '좌우로 움직여 숫자를 늘리세요!\n높은 점수가 승리합니다.', + icon: Icons.calculate, + isSinglePlayerSupported: true, + ), + // [추가] 점프 배틀 (횡스크롤) + GameInfo( + id: 'jump_battle', + name: '점프 배틀', + description: '장애물을 피해 끝까지 달리세요!\n타이밍 싸움!', + icon: Icons.directions_run, + isSinglePlayerSupported: true, + ), + GameInfo( + id: 'iam_ground', + name: '아이엠그라운드', + description: '리듬을 타며 이름을 공격하세요!\n박자를 놓치면 탈락!', + icon: Icons.music_note, // 음표 아이콘 + isSinglePlayerSupported: false, // 최소 2인 이상 + ), + GameInfo( + id: 'survivor', + name: '서바이버', + description: '몰려오는 몬스터를 막아내세요!\n이동만 하면 자동으로 공격합니다.', + icon: Icons.bug_report, + isSinglePlayerSupported: true, + ), + GameInfo( + id: 'sequence_memory', + name: '기억의 신', + description: '반짝이는 순서를 기억하세요!\n라운드가 갈수록 종류가 다양해집니다.', + icon: Icons.apps, + isSinglePlayerSupported: true, + ), ]; static GameInfo getById(String id) { diff --git a/packages/core/lib/model/play_packet.dart b/packages/core/lib/model/play_packet.dart index 1b66965..96fb225 100644 --- a/packages/core/lib/model/play_packet.dart +++ b/packages/core/lib/model/play_packet.dart @@ -1,44 +1,41 @@ import 'dart:convert'; -/// 패킷의 종류 (라우팅 기준) enum PacketType { - system, // 시스템 (레디, 시작, 종료, 핸드셰이크 등) - chat, // 채팅 (GlobalChatManager로 전달) - game, // 게임 로직 (GameController로 전달) - media, - unknown + system, chat, game, media, unknown } class PlayPacket { final PacketType type; - final String senderId; // 보낸 사람 ID - final dynamic payload; // 실제 데이터 (Map, List, String 등) + final String senderId; + final dynamic payload; final int timestamp; + final int? seq; // [추가] 패킷 순번 PlayPacket({ required this.type, required this.senderId, required this.payload, required this.timestamp, + this.seq, // [추가] }); - // JSON -> 객체 factory PlayPacket.fromJson(Map json) { return PlayPacket( type: _parseType(json['type']), senderId: json['senderId'] ?? 'unknown', payload: json['payload'], timestamp: json['timestamp'] ?? DateTime.now().millisecondsSinceEpoch, + seq: json['seq'], // [추가] ); } - // 객체 -> JSON Map toJson() { return { - 'type': type.name, // enum을 문자열로 ('chat', 'game'...) + 'type': type.name, 'senderId': senderId, 'payload': payload, 'timestamp': timestamp, + if (seq != null) 'seq': seq, // [추가] }; } @@ -46,7 +43,6 @@ class PlayPacket { for (var t in PacketType.values) { if (t.name == typeStr) return t; } - // 호환성: 기존 레거시 메시지(PING, ANSWER_SUBMIT 등)는 'unknown'이나 별도 처리 return PacketType.unknown; } } \ No newline at end of file diff --git a/packages/core/lib/model/spider_game_dto.dart b/packages/core/lib/model/spider_game_dto.dart new file mode 100644 index 0000000..eb9d173 --- /dev/null +++ b/packages/core/lib/model/spider_game_dto.dart @@ -0,0 +1,28 @@ +class SpiderGameDto { + final int puzzleId; + final int difficulty; // 1, 2, 4 (Suits) + final List cards; // 0~103 (카드 덱) + + SpiderGameDto({ + required this.puzzleId, + required this.difficulty, + required this.cards, + }); + + factory SpiderGameDto.fromJson(Map json) { + return SpiderGameDto( + puzzleId: json['puzzleId'] ?? 0, + difficulty: json['difficulty'] ?? 1, + // 카드 배열 파싱 + cards: (json['cards'] as List?)?.map((e) => e as int).toList() ?? [], + ); + } + + Map toJson() { + return { + 'puzzleId': puzzleId, + 'difficulty': difficulty, + 'cards': cards, + }; + } +} \ 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 b353cf5..7636699 100644 --- a/packages/core/lib/network/network_manager.dart +++ b/packages/core/lib/network/network_manager.dart @@ -12,7 +12,7 @@ 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'; +import '../database/ephemeral_database.dart'; enum NetworkRole { none, host, guest } @@ -24,17 +24,13 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver { WidgetsBinding.instance.addObserver(this); } - // ------------------------------------------------------------------------ // 상수 설정 - // ------------------------------------------------------------------------ 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; - // ------------------------------------------------------------------------ // 상태 변수 - // ------------------------------------------------------------------------ late UserInfo me; NetworkRole role = NetworkRole.none; @@ -44,14 +40,15 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver { ServerSocket? _serverSocket; Socket? _clientSocket; - final Map _connectedGuests = {}; - final List guestList = []; + final Map _connectedGuests = {}; final Map _packetBuffers = {}; - + BonsoirService? _bonsoirService; BonsoirBroadcast? _bonsoirBroadcast; BonsoirDiscovery? _bonsoirDiscovery; + final List guestList = []; + final _messageController = StreamController>.broadcast(); Stream> get messageStream => _messageController.stream; @@ -63,9 +60,13 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver { DateTime? _lastPongTime; bool _isReconnecting = false; - // [추가] 현재 선택된 게임 ID 및 설정 String selectedGameId = 'quiz_mix'; - Map selectedGameConfig = {}; // 난이도 등 저장 + Map selectedGameConfig = {}; + + int _sendSeq = 0; + int _recvSeq = 0; + + EphemeralDatabase? get _database => MediaManager().db; // ------------------------------------------------------------------------ // 초기화 @@ -98,82 +99,24 @@ 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(); - - final payload = { - 'type': 'TOGGLE_READY', - 'userId': me.id, - 'isReady': me.isReady, - }; - sendMessage(payload); - - if (role == NetworkRole.host) { - _checkAllReadyAndStart(); - } - } - - void _checkAllReadyAndStart() { - if (guestList.isEmpty) return; - if (!me.isReady) return; - - bool allGuestsReady = guestList.every((u) => u.isReady); - - if (allGuestsReady) { - _log("🚀 전원 준비 완료! 3초 후 게임 시작..."); - Future.delayed(const Duration(seconds: 1), () { - // [수정] 게임 시작 패킷에 Config 포함 - final startPayload = { - 'type': 'GAME_START', - 'gameId': selectedGameId, - 'config': selectedGameConfig - }; - sendMessage(startPayload); - _messageController.add(startPayload); - _resetAllReadyState(); - }); - } - } - - void _resetAllReadyState() { - me = me.copyWith(isReady: false); - for (int i = 0; i < guestList.length; i++) { - guestList[i] = guestList[i].copyWith(isReady: false); - } - notifyListeners(); - } - - // ------------------------------------------------------------------------ - // Host Logic + // [Socket] 호스팅 로직 (WiFi/Hotspot) // ------------------------------------------------------------------------ Future startHosting(String roomName) async { - stopNetwork(force: true); + await stopNetwork(force: true); role = NetworkRole.host; + _sendSeq = 0; _recvSeq = 0; try { _serverSocket = await ServerSocket.bind(InternetAddress.anyIPv4, 0); int port = _serverSocket!.port; this.hostPort = port; - String? myIp = await _getWifiIp(); this.hostIp = myIp ?? '127.0.0.1'; _log("✅ 방 생성: $hostIp : $port"); - + _serverSocket!.listen((Socket client) { _handleNewGuest(client); }); - _bonsoirService = BonsoirService( name: '$roomName#${me.id}', type: '_playwith._tcp', @@ -182,11 +125,9 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver { ); _bonsoirBroadcast = BonsoirBroadcast(service: _bonsoirService!); await _bonsoirBroadcast!.start(); - await MediaManager().initialize(roomName); _startHeartbeat(); notifyListeners(); - } catch (e) { _log("❌ 방 생성 실패: $e"); stopNetwork(force: true); @@ -199,8 +140,16 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver { _packetBuffers[client] = ""; final myHandshake = {'type': 'HANDSHAKE', 'payload': me.toJson()}; - final jsonString = jsonEncode(myHandshake); - client.add(utf8.encode('$jsonString$PACKET_DELIMITER')); + client.add(utf8.encode('${jsonEncode(myHandshake)}$PACKET_DELIMITER')); + + Future.delayed(const Duration(milliseconds: 500), () { + final gameSync = { + 'type': 'GAME_CHANGED', + 'gameId': selectedGameId, + 'config': selectedGameConfig + }; + client.add(utf8.encode('${jsonEncode(gameSync)}$PACKET_DELIMITER')); + }); client.listen( (Uint8List data) => _onDataReceived(client, data), @@ -221,9 +170,7 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver { notifyListeners(); } - // ------------------------------------------------------------------------ - // Guest Logic - // ------------------------------------------------------------------------ + // [Socket] 게스트 로직 Stream> discoverRooms() { final controller = StreamController>(); final List foundServices = []; @@ -233,7 +180,6 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver { try { _bonsoirDiscovery = BonsoirDiscovery(type: '_playwith._tcp'); await _bonsoirDiscovery!.start(); - if (_bonsoirDiscovery?.eventStream != null) { _bonsoirDiscovery!.eventStream!.listen((dynamic event) { final String type = event.type.toString(); @@ -256,60 +202,30 @@ 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); + if (role != NetworkRole.guest) await stopNetwork(force: true); role = NetworkRole.guest; hostIp = ip; hostPort = port; + _sendSeq = 0; _recvSeq = 0; try { _log("🚀 접속 시도: $ip:$port"); _clientSocket = await Socket.connect(ip, port, timeout: const Duration(seconds: 5)); _log("✅ 접속 성공!"); - _packetBuffers[_clientSocket!] = ""; - - sendMessage({'type': 'HANDSHAKE', 'payload': me.toJson()}); + + final myHandshake = {'type': 'HANDSHAKE', 'payload': me.toJson()}; + _clientSocket!.add(utf8.encode('${jsonEncode(myHandshake)}$PACKET_DELIMITER')); await MediaManager().initialize("guest_${ip.replaceAll('.', '_')}"); - - _lastPongTime = DateTime.now(); _startHeartbeat(); - _cancelDisconnectTimer(); - _clientSocket!.listen( (Uint8List data) => _onDataReceived(_clientSocket!, data), - onError: (e) => _handleConnectionLost(e), - onDone: () => _handleConnectionLost("Socket Closed"), + onError: (e) => stopNetwork(force: true), + onDone: () => stopNetwork(force: true), ); notifyListeners(); - } catch (e) { _log("❌ 접속 실패: $e"); if (!_isReconnecting) stopNetwork(force: true); @@ -317,6 +233,25 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver { } } + // 싱글 모드 + Future startSoloMode(String gameId, {Map? config}) async { + await stopNetwork(force: true); + role = NetworkRole.host; + hostIp = "Solo Mode"; + hostPort = 0; + selectedGameId = gameId; + selectedGameConfig = config ?? {}; + _sendSeq = 0; _recvSeq = 0; + + await MediaManager().initialize("solo_session"); + _log("👤 싱글 플레이 모드: $gameId"); + notifyListeners(); + + Future.delayed(const Duration(milliseconds: 100), () { + _messageController.add({'type': 'GAME_START', 'gameId': gameId, 'config': selectedGameConfig}); + }); + } + // ------------------------------------------------------------------------ // 데이터 송수신 // ------------------------------------------------------------------------ @@ -327,15 +262,20 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver { void sendMessage(Map messageMap) { if (role == NetworkRole.guest && _clientSocket == null) return; + final String type = messageMap['type'] ?? ''; + bool isSystem = ['PING', 'PONG', 'HANDSHAKE', 'REQ_RESEND', 'RESEND_DATA'].contains(type); + + if (!isSystem) { + _sendSeq++; + messageMap['seq'] = _sendSeq; + if (_database != null) _database!.logPacket(_sendSeq, jsonEncode(messageMap)); + } + final jsonString = jsonEncode(messageMap); - if (messageMap['type'] != 'PING' && messageMap['type'] != 'PONG') { - if (messageMap['type'] == 'chat') { - _log("📤 전송: [CHAT]"); - } else if (messageMap['type'] == 'media') { - _log("📤 전송: [MEDIA]"); - } else { - _log("📤 전송: $jsonString"); - } + if (!isSystem) { + if (type == 'chat') _log("📤 전송(#$_sendSeq): [CHAT]"); + else if (type == 'media') _log("📤 전송(#$_sendSeq): [MEDIA]"); + else _log("📤 전송(#$_sendSeq): $jsonString"); } final fullMessage = '$jsonString$PACKET_DELIMITER'; @@ -354,12 +294,10 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver { try { String buffer = _packetBuffers[socket] ?? ""; buffer += utf8.decode(data, allowMalformed: true); - 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); } _packetBuffers[socket] = buffer; @@ -368,35 +306,63 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver { } } - void _processMessage(Socket socket, String msg) { + void _processMessage(Socket? socket, String msg) { try { final Map jsonMap = jsonDecode(msg); + final String type = jsonMap['type'] ?? ''; - if (jsonMap['type'] == 'PING') { + if (type == 'PING') { sendMessage({'type': 'PONG'}); _lastPongTime = DateTime.now(); return; } - if (jsonMap['type'] == 'PONG') { + if (type == 'PONG') { _lastPongTime = DateTime.now(); return; } - - if (jsonMap['type'] == 'HANDSHAKE') { + if (type == 'HANDSHAKE') { final guestInfo = UserInfo.fromJson(jsonMap['payload']); - - if (role == NetworkRole.host) { + if (role == NetworkRole.host && socket != null) { _connectedGuests[socket] = guestInfo; } - guestList.removeWhere((u) => u.id == guestInfo.id); guestList.add(guestInfo); notifyListeners(); - _messageController.add(jsonMap); return; } - if (jsonMap['type'] == 'TOGGLE_READY') { + if (type == 'GAME_CHANGED') { + if (jsonMap['gameId'] != null) { + selectedGameId = jsonMap['gameId']; + selectedGameConfig = jsonMap['config'] ?? {}; + notifyListeners(); + } + return; + } + + if (type == 'REQ_RESEND') { + _handleResendRequest(jsonMap['from'], jsonMap['to']); + return; + } + if (type == 'RESEND_DATA') { + _processMessage(socket, jsonMap['data']); + return; + } + + if (jsonMap.containsKey('seq')) { + int seq = jsonMap['seq']; + if (seq > _recvSeq + 1) { + _log("⚠️ 패킷 유실 감지! (기대: ${_recvSeq + 1}, 수신: $seq)"); + sendMessage({ + 'type': 'REQ_RESEND', + 'from': _recvSeq + 1, + 'to': seq - 1 + }); + } + if (seq > _recvSeq) _recvSeq = seq; + } + + if (type == 'TOGGLE_READY') { final String userId = jsonMap['userId']; final bool isReady = jsonMap['isReady']; final index = guestList.indexWhere((u) => u.id == userId); @@ -412,14 +378,9 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver { return; } - if (jsonMap['type'] == 'GAME_START') { - if (jsonMap['gameId'] != null) { - selectedGameId = jsonMap['gameId']; - } - // [추가] Config 동기화 - if (jsonMap['config'] != null) { - selectedGameConfig = jsonMap['config']; - } + if (type == 'GAME_START') { + if (jsonMap['gameId'] != null) selectedGameId = jsonMap['gameId']; + if (jsonMap['config'] != null) selectedGameConfig = jsonMap['config']; _resetAllReadyState(); _messageController.add(jsonMap); return; @@ -444,6 +405,22 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver { } } + Future _handleResendRequest(int fromSeq, int toSeq) async { + if (_database == null) return; + final packets = await _database!.getPacketsInRange(fromSeq, toSeq); + for (var p in packets) { + final resendPacket = {'type': 'RESEND_DATA', 'data': p.payload}; + final jsonString = jsonEncode(resendPacket); + final data = utf8.encode('$jsonString$PACKET_DELIMITER'); + + if (role == NetworkRole.host) { + for (var s in _connectedGuests.keys) s.add(data); + } else { + _clientSocket?.add(data); + } + } + } + void _handleConnectionLost(dynamic reason) { if (role != NetworkRole.guest) return; _clientSocket?.destroy(); @@ -502,24 +479,88 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver { return null; } - void stopNetwork({bool force = false}) { + Future stopNetwork({bool force = false}) async { if (!force && _disconnectWaitTimer != null) return; - MediaManager().cleanup(); - _heartbeatTimer?.cancel(); - _disconnectWaitTimer?.cancel(); - _disconnectWaitTimer = null; - _bonsoirBroadcast?.stop(); - _bonsoirDiscovery?.stop(); + _serverSocket?.close(); _clientSocket?.close(); for (var s in _connectedGuests.keys) s.close(); _connectedGuests.clear(); + + _bonsoirBroadcast?.stop(); + _bonsoirDiscovery?.stop(); + + MediaManager().cleanup(); + _heartbeatTimer?.cancel(); + _disconnectWaitTimer?.cancel(); + _disconnectWaitTimer = null; _packetBuffers.clear(); guestList.clear(); role = NetworkRole.none; _serverSocket = null; _clientSocket = null; - if (force) { hostIp = null; hostPort = null; } + + if (force) { + hostIp = null; + hostPort = null; + } + notifyListeners(); + } + + // ------------------------------------------------------------------------ + // 게임 관리 + // ------------------------------------------------------------------------ + void selectGame(String gameId, {Map? config}) { + selectedGameId = gameId; + selectedGameConfig = config ?? {}; + notifyListeners(); + + if (role == NetworkRole.host) { + sendMessage({ + 'type': 'GAME_CHANGED', + 'gameId': gameId, + 'config': selectedGameConfig + }); + } + } + + void toggleReady() { + me = me.copyWith(isReady: !me.isReady); + notifyListeners(); + sendMessage({ + 'type': 'TOGGLE_READY', + 'userId': me.id, + 'isReady': me.isReady, + }); + if (role == NetworkRole.host) _checkAllReadyAndStart(); + } + + void _checkAllReadyAndStart() { + if (guestList.isEmpty && role != NetworkRole.host) return; + if (!me.isReady) return; + + bool allGuestsReady = guestList.every((u) => u.isReady); + + if (allGuestsReady) { + _log("🚀 전원 준비 완료! 3초 후 게임 시작..."); + Future.delayed(const Duration(seconds: 1), () { + final startPayload = { + 'type': 'GAME_START', + 'gameId': selectedGameId, + 'config': selectedGameConfig + }; + sendMessage(startPayload); + _messageController.add(startPayload); + _resetAllReadyState(); + }); + } + } + + void _resetAllReadyState() { + me = me.copyWith(isReady: false); + for (int i = 0; i < guestList.length; i++) { + guestList[i] = guestList[i].copyWith(isReady: false); + } notifyListeners(); } } \ No newline at end of file diff --git a/packages/core/lib/playwith_core.dart b/packages/core/lib/playwith_core.dart index c875366..2715d8a 100644 --- a/packages/core/lib/playwith_core.dart +++ b/packages/core/lib/playwith_core.dart @@ -28,4 +28,12 @@ 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 +export 'game/balance_game.dart'; +export 'game/world_tour_game.dart'; +export 'game/othello_game.dart'; +export 'game/arkanoid_game.dart'; +export 'game/math_run_game.dart'; +export 'game/jump_game.dart'; +export 'game/iam_ground_game.dart'; +export 'game/survivor_game.dart'; +export 'game/sequence_memory_game.dart'; \ No newline at end of file diff --git a/packages/core/pubspec.yaml b/packages/core/pubspec.yaml index 7382ae2..a81b5f7 100644 --- a/packages/core/pubspec.yaml +++ b/packages/core/pubspec.yaml @@ -27,6 +27,10 @@ dependencies: flutter_local_notifications: ^17.0.0 google_mobile_ads: ^5.0.0 audioplayers: ^6.0.0 # 여기로 이동 + wifi_iot: ^0.3.19 # 와이파이 연결 및 정보 확인용 + network_info_plus: ^5.0.1 # 게이트웨이(방장 IP) 확인용 + nearby_connections: ^4.0.0 + device_info_plus: ^10.1.0 # [추가] 기기 정보 확인용 dev_dependencies: drift_dev: ^2.13.0