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