This commit is contained in:
lunaticbum 2025-12-02 11:06:23 +09:00
parent bf40c42c2c
commit 22caf64855
34 changed files with 4594 additions and 505 deletions

View File

@ -6,20 +6,22 @@
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" />
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation"/>
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES"
android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.VIBRATE"/>
<queries>
<intent>
<action android:name="android.speech.RecognitionService" />

View File

@ -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'

View File

@ -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

View File

@ -63,6 +63,7 @@
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
A0F77A962ED7E5CF0058AC51 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
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 = "<group>"; };
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 = "<group>"; };
/* 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 = "<group>";
};
@ -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;

View File

@ -50,6 +50,7 @@
<key>NSBonjourServices</key>
<array>
<string>_playwith._tcp</string>
<string>_playwith._udp</string>
</array>
<key>GADApplicationIdentifier</key>
<string>ca-app-pub-3940256099942544~1458002511</string>
@ -67,5 +68,16 @@
<string>정답을 음성으로 말하기 위해 마이크 권한이 필요합니다.</string>
<key>NSSpeechRecognitionUsageDescription</key>
<string>말한 내용을 텍스트로 변환하여 정답을 확인합니다.</string>
<key>com.apple.developer.networking.wifi-info</key>
<true/>
<key>NEHotspotConfiguration</key>
<true/>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>주변 친구를 찾기 위해 블루투스를 사용합니다.</string>
<key>NSBluetoothPeripheralUsageDescription</key>
<string>주변 친구를 찾기 위해 블루투스를 사용합니다.</string>
<key>NSLocalNetworkUsageDescription</key>
<string>로컬 통신을 위해 필요합니다.</string>
</dict>
</plist>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.networking.HotspotConfiguration</key>
<true/>
</dict>
</plist>

View File

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

View File

@ -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"))

View File

@ -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'

136
apps/app/macos/Podfile.lock Normal file
View File

@ -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

View File

@ -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 = "<group>"; };
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 = "<group>"; };
333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = "<group>"; };
335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = "<group>"; };
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 = "<group>"; };
33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = "<group>"; };
33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = "<group>"; };
@ -76,8 +79,15 @@
33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = "<group>"; };
33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = "<group>"; };
33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = "<group>"; };
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 = "<group>"; };
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 = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; };
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 = "<group>"; };
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 = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; };
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 = "<group>"; };
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 = "<group>";
};
@ -172,9 +185,25 @@
path = Runner;
sourceTree = "<group>";
};
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 = "<group>";
};
D73912EC22F37F3D000D13A0 /* Frameworks */ = {
isa = PBXGroup;
children = (
C20AF64D20A0D998576B7391 /* Pods_Runner.framework */,
8AA200BBA16E846EF02EA220 /* Pods_RunnerTests.framework */,
);
name = Frameworks;
sourceTree = "<group>";
@ -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;

View File

@ -4,4 +4,7 @@
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
<FileRef
location = "group:Pods/Pods.xcodeproj">
</FileRef>
</Workspace>

View File

@ -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:

View File

@ -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

View File

@ -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<Column> 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<Column> 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<EphemeralDatabase> 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<List<MediaItem>> getAllMedia() => select(mediaItems).get();
Future<int> insertMedia(MediaItemsCompanion entry) => into(mediaItems).insert(entry);
// []
// []
Future<int> logPacket(int seq, String json) {
return into(packetLogs).insert(PacketLogsCompanion(
seq: Value(seq),
payload: Value(json),
));
}
// [] ( )
Future<List<PacketLog>> getPacketsInRange(int fromSeq, int toSeq) {
return (select(packetLogs)..where((tbl) => tbl.seq.isBetweenValues(fromSeq, toSeq))).get();
}
Future<void> wipeData() async {
await close(); // DB
// Manager에서 (DB )
await close();
}
}

View File

@ -351,15 +351,229 @@ class MediaItemsCompanion extends UpdateCompanion<MediaItem> {
}
}
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<int> seq = GeneratedColumn<int>(
'seq', aliasedName, false,
type: DriftSqlType.int, requiredDuringInsert: false);
static const VerificationMeta _payloadMeta =
const VerificationMeta('payload');
@override
late final GeneratedColumn<String> payload = GeneratedColumn<String>(
'payload', aliasedName, false,
type: DriftSqlType.string, requiredDuringInsert: true);
static const VerificationMeta _createdAtMeta =
const VerificationMeta('createdAt');
@override
late final GeneratedColumn<DateTime> createdAt = GeneratedColumn<DateTime>(
'created_at', aliasedName, false,
type: DriftSqlType.dateTime,
requiredDuringInsert: false,
defaultValue: currentDateAndTime);
@override
List<GeneratedColumn> 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<PacketLog> 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<GeneratedColumn> get $primaryKey => {seq};
@override
PacketLog map(Map<String, dynamic> 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<PacketLog> {
final int seq;
final String payload;
final DateTime createdAt;
const PacketLog(
{required this.seq, required this.payload, required this.createdAt});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
map['seq'] = Variable<int>(seq);
map['payload'] = Variable<String>(payload);
map['created_at'] = Variable<DateTime>(createdAt);
return map;
}
PacketLogsCompanion toCompanion(bool nullToAbsent) {
return PacketLogsCompanion(
seq: Value(seq),
payload: Value(payload),
createdAt: Value(createdAt),
);
}
factory PacketLog.fromJson(Map<String, dynamic> json,
{ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return PacketLog(
seq: serializer.fromJson<int>(json['seq']),
payload: serializer.fromJson<String>(json['payload']),
createdAt: serializer.fromJson<DateTime>(json['createdAt']),
);
}
@override
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'seq': serializer.toJson<int>(seq),
'payload': serializer.toJson<String>(payload),
'createdAt': serializer.toJson<DateTime>(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<PacketLog> {
final Value<int> seq;
final Value<String> payload;
final Value<DateTime> 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<PacketLog> custom({
Expression<int>? seq,
Expression<String>? payload,
Expression<DateTime>? createdAt,
}) {
return RawValuesInsertable({
if (seq != null) 'seq': seq,
if (payload != null) 'payload': payload,
if (createdAt != null) 'created_at': createdAt,
});
}
PacketLogsCompanion copyWith(
{Value<int>? seq, Value<String>? payload, Value<DateTime>? createdAt}) {
return PacketLogsCompanion(
seq: seq ?? this.seq,
payload: payload ?? this.payload,
createdAt: createdAt ?? this.createdAt,
);
}
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
if (seq.present) {
map['seq'] = Variable<int>(seq.value);
}
if (payload.present) {
map['payload'] = Variable<String>(payload.value);
}
if (createdAt.present) {
map['created_at'] = Variable<DateTime>(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<TableInfo<Table, Object?>> get allTables =>
allSchemaEntities.whereType<TableInfo<Table, Object?>>();
@override
List<DatabaseSchemaEntity> get allSchemaEntities => [mediaItems];
List<DatabaseSchemaEntity> get allSchemaEntities => [mediaItems, packetLogs];
}
typedef $$MediaItemsTableCreateCompanionBuilder = MediaItemsCompanion Function({
@ -548,10 +762,147 @@ typedef $$MediaItemsTableProcessedTableManager = ProcessedTableManager<
),
MediaItem,
PrefetchHooks Function()>;
typedef $$PacketLogsTableCreateCompanionBuilder = PacketLogsCompanion Function({
Value<int> seq,
required String payload,
Value<DateTime> createdAt,
});
typedef $$PacketLogsTableUpdateCompanionBuilder = PacketLogsCompanion Function({
Value<int> seq,
Value<String> payload,
Value<DateTime> createdAt,
});
class $$PacketLogsTableFilterComposer
extends Composer<_$EphemeralDatabase, $PacketLogsTable> {
$$PacketLogsTableFilterComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnFilters<int> get seq => $composableBuilder(
column: $table.seq, builder: (column) => ColumnFilters(column));
ColumnFilters<String> get payload => $composableBuilder(
column: $table.payload, builder: (column) => ColumnFilters(column));
ColumnFilters<DateTime> 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<int> get seq => $composableBuilder(
column: $table.seq, builder: (column) => ColumnOrderings(column));
ColumnOrderings<String> get payload => $composableBuilder(
column: $table.payload, builder: (column) => ColumnOrderings(column));
ColumnOrderings<DateTime> 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<int> get seq =>
$composableBuilder(column: $table.seq, builder: (column) => column);
GeneratedColumn<String> get payload =>
$composableBuilder(column: $table.payload, builder: (column) => column);
GeneratedColumn<DateTime> 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<int> seq = const Value.absent(),
Value<String> payload = const Value.absent(),
Value<DateTime> createdAt = const Value.absent(),
}) =>
PacketLogsCompanion(
seq: seq,
payload: payload,
createdAt: createdAt,
),
createCompanionCallback: ({
Value<int> seq = const Value.absent(),
required String payload,
Value<DateTime> 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);
}

View File

@ -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<String, dynamic> 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<ArkanoidScreen> createState() => _ArkanoidScreenState();
}
class _ArkanoidScreenState extends State<ArkanoidScreen> 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<Brick> bricks = [];
@override
void initState() {
super.initState();
_resetLevel();
_ticker = createTicker(_gameLoop)..start();
NetworkManager().messageStream.listen(_handleMessage);
}
void _handleMessage(Map<String, dynamic> 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<Brick> 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;
}

View File

@ -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<String, dynamic> 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<IAmGroundScreen> createState() => _IAmGroundScreenState();
}
class _IAmGroundScreenState extends State<IAmGroundScreen> 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<UserInfo> players = [];
Set<String> deadPlayers = {};
bool isMyTurn = false;
bool isTargeted = false;
List<bool> _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<String, dynamic> 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<String, dynamic> 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("나가기")),
],
),
);
}
}

View File

@ -13,15 +13,12 @@ class JanggiGame extends BaseGame {
@override
void onStart() {
super.onStart();
//
}
// []
@override
void onMessageReceived(String senderId, Map<String, dynamic> payload) {
// BaseGame은 UI(JanggiScreen)
// , .
// JanggiScreen의 StreamBuilder나 .
// JanggiScreen NetworkManager .
}
@override
@ -217,7 +214,6 @@ class _JanggiScreenState extends State<JanggiScreen> {
}
}
}
// (), () ( )
return moves;
}
@ -247,7 +243,8 @@ class _JanggiScreenState extends State<JanggiScreen> {
@override
Widget build(BuildContext context) {
// (Green)
final bool myTurn = currentTurn == (widget.isHan ? Team.han : Team.cho);
// (Green) ( )
final bool flipBoard = !widget.isHan;
return Scaffold(

View File

@ -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<String, dynamic> 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<JumpGameScreen> createState() => _JumpGameScreenState();
}
class _JumpGameScreenState extends State<JumpGameScreen> 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<String, dynamic> 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;
}

View File

@ -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<String, dynamic> 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<MathRunScreen> createState() => _MathRunScreenState();
}
class _MathRunScreenState extends State<MathRunScreen> 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<String, dynamic> 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;
}

View File

@ -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<String, dynamic> 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<OthelloScreen> createState() => _OthelloScreenState();
}
class _OthelloScreenState extends State<OthelloScreen> {
// 0: , 1: , 2:
final List<List<int>> 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<String, dynamic> 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); }

View File

@ -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<String, dynamic> 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<SequenceMemoryScreen> createState() => _SequenceMemoryScreenState();
}
class _SequenceMemoryScreenState extends State<SequenceMemoryScreen> {
// --- ---
int round = 1;
int sequenceLength = 3;
// 0: Red(Host), 1: Blue(Guest)
int currentTurn = 0;
// [] ( )
final List<String> _poolNum = List.generate(25, (i) => '${i + 1}'); // 1~25
final List<String> _poolKor = ['','','','','','','','','','','','','',''];
final List<String> _poolEng = List.generate(26, (i) => String.fromCharCode('A'.codeUnitAt(0) + i)); // A~Z
final List<String> _poolEmo = [
'🍎','🍌','🍇','🍉','🍓','🍒','🍍','🥝','🍋','🥑','🥦','🌽','🥕','🌭','🍔','🍟','🍕','🥪','🌮','🍦','🍧','🍩','🍪','🎂','🍬'
];
//
List<String> gridItems = [];
List<int> 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<String, dynamic> payload) {
if (!mounted) return;
String type = payload['type'];
if (type == 'NEW_ROUND') {
setState(() {
round = payload['round'];
currentTurn = payload['turn'];
gridItems = List<String>.from(payload['gridItems']);
targetSequence = List<int>.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<String> 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<String> nextGridItems = currentPool.take(totalCount).toList();
// 3. 퀀
// : 3 + ( - 1)
int length = 3 + (nextRound - 1);
List<int> 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<String, dynamic> payload) {
if (isSolo) {
_handleMessage(payload);
} else {
NetworkManager().sendMessage(payload);
if (widget.isHost) _handleMessage(payload);
}
}
// ---------------------------------------------------------------------------
//
// ---------------------------------------------------------------------------
Future<void> _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<void> _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
),
),
),
),
);
},
),
),
),
),
),
],
),
);
}
}

View File

@ -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<SpiderGameDto?> _gameDataStreamController = StreamController<SpiderGameDto?>.broadcast();
Future<SpiderGameDto> _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<int> 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<String, dynamic> 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<SpiderGameDto?>(
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<SpiderBattleScreen> createState() => _SpiderBattleScreenState();
}
class _SpiderBattleScreenState extends State<SpiderBattleScreen> {
//
List<List<SpiderCard>> tableau = List.generate(10, (_) => []); // 10
List<SpiderCard> stock = []; //
List<List<SpiderCard>> foundation = []; //
List<List<SpiderCard>> tableau = List.generate(10, (_) => []);
List<SpiderCard> stock = [];
List<List<SpiderCard>> foundation = [];
int _moves = 0;
final int _numSuits = 1; // (1: , 2: /, 4: )
@override
void initState() {
@ -73,102 +135,104 @@ class _SpiderBattleScreenState extends State<SpiderBattleScreen> {
void _handleNetworkMessage(Map<String, dynamic> 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<SpiderCard> 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<SpiderCard> 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<SpiderCard> 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<SpiderBattleScreen> {
});
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<SpiderBattleScreen> {
}
}
// [ ]
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<SpiderBattleScreen> {
);
}
// --- 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<SpiderBattleScreen> {
}),
),
),
// 2. ( & )
Container(
height: cardHeight + 20,
color: Colors.green[900],
@ -267,24 +329,29 @@ class _SpiderBattleScreenState extends State<SpiderBattleScreen> {
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<SpiderBattleScreen> {
Widget _buildTableauColumn(int colIndex, double width, double height) {
final pile = tableau[colIndex];
// DragTarget:
return DragTarget<Map<String, dynamic>>(
onWillAccept: (data) {
if (data == null) return false;
final List<SpiderCard> movingCards = data['cards'];
final int fromIndex = data['fromIndex'];
if (fromIndex == colIndex) return false; //
if (fromIndex == colIndex) return false;
final SpiderCard topMoving = movingCards.first;
final SpiderCard? targetBottom = pile.isEmpty ? null : pile.last;
return _canMove(topMoving, targetBottom);
},
onAccept: (data) {
@ -319,20 +383,15 @@ class _SpiderBattleScreenState extends State<SpiderBattleScreen> {
height: MediaQuery.of(context).size.height * 0.7,
child: Stack(
children: [
// ( )
Container(width: width, height: 100, color: Colors.transparent),
//
...List.generate(pile.length, (i) {
final card = pile[i];
final offset = i * 25.0; //
// ( , )
final offset = i * 25.0;
bool isDraggable = card.isFaceUp;
if (isDraggable && i < pile.length - 1) {
//
for (int k = i; k < pile.length - 1; k++) {
if (pile[k].rank != pile[k+1].rank + 1) {
if (pile[k].suit != pile[k+1].suit || pile[k].rank != pile[k+1].rank + 1) {
isDraggable = false;
break;
}
@ -342,9 +401,7 @@ class _SpiderBattleScreenState extends State<SpiderBattleScreen> {
Widget cardWidget = SpiderCardWidget(card: card, width: width, height: height);
if (isDraggable) {
//
final movingCards = pile.sublist(i);
return Positioned(
top: offset,
child: Draggable<Map<String, dynamic>>(
@ -355,7 +412,7 @@ class _SpiderBattleScreenState extends State<SpiderBattleScreen> {
children: movingCards.map((c) => SpiderCardWidget(card: c, width: width, height: height)).toList(),
),
),
childWhenDragging: const SizedBox(), // ( )
childWhenDragging: const SizedBox(),
child: cardWidget,
),
);

View File

@ -18,9 +18,6 @@ class SudokuMultiGame extends BaseGame {
final StreamController<SudokuGameDto?> _puzzleStreamController = StreamController<SudokuGameDto?>.broadcast();
// ---------------------------------------------------------------------------
// [Host]
// ---------------------------------------------------------------------------
Future<SudokuGameDto> _fetchPuzzleFromApi(String difficulty) async {
const String baseUrl = "https://lunaticbum.kr";
try {
@ -47,16 +44,10 @@ class SudokuMultiGame extends BaseGame {
@override
void onStart() async {
super.onStart();
if (NetworkManager().role == NetworkRole.host) {
final int diffValue = NetworkManager().selectedGameConfig['difficulty'] ?? 1;
final puzzleData = await _fetchPuzzleFromApi(diffValue.toString());
final payload = {
'type': 'GAME_DATA',
...puzzleData.toJson(),
};
final payload = {'type': 'GAME_DATA', ...puzzleData.toJson()};
onMessageReceived(NetworkManager().me.id, payload);
NetworkManager().sendMessage(payload);
}
@ -64,6 +55,9 @@ class SudokuMultiGame extends BaseGame {
@override
void onMessageReceived(String senderId, Map<String, dynamic> payload) {
// [ ] GAME_DATA는 , ATTACK ( )
// StreamBuilder가 , ATTACK (State) NetworkManager를 .
// .
if (payload['type'] == 'GAME_DATA') {
final puzzle = SudokuGameDto.fromJson(payload);
_puzzleStreamController.add(puzzle);
@ -78,7 +72,6 @@ class SudokuMultiGame extends BaseGame {
@override
Widget buildHostView(BuildContext context) => _buildGameScreen(context);
@override
Widget buildGuestView(BuildContext context) => _buildGameScreen(context);
@ -88,16 +81,7 @@ class SudokuMultiGame extends BaseGame {
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 20),
Text("퍼즐을 불러오는 중입니다..."),
],
),
),
body: Center(child: CircularProgressIndicator()),
);
}
return SudokuBattleScreen(gameData: snapshot.data!, gameInstance: this);
@ -106,9 +90,6 @@ class SudokuMultiGame extends BaseGame {
}
}
// -----------------------------------------------------------------------------
// (UI + )
// -----------------------------------------------------------------------------
class SudokuBattleScreen extends StatefulWidget {
final SudokuGameDto gameData;
final SudokuMultiGame gameInstance;
@ -141,6 +122,7 @@ class _SudokuBattleScreenState extends State<SudokuBattleScreen> {
originalCells = List.from(puzzleCells);
solutionCells = widget.gameData.solution.split('').map(_charToInt).toList();
// [ ]
NetworkManager().messageStream.listen(_handleNetworkMessage);
}
@ -164,6 +146,7 @@ class _SudokuBattleScreenState extends State<SudokuBattleScreen> {
void _applyAttack(String attackerName) {
List<int> myInputs = [];
for (int i = 0; i < puzzleCells.length; i++) {
//
if (originalCells[i] == 0 && puzzleCells[i] != 0) {
myInputs.add(i);
}
@ -172,7 +155,7 @@ class _SudokuBattleScreenState extends State<SudokuBattleScreen> {
if (myInputs.isNotEmpty) {
final randomIdx = myInputs[Random().nextInt(myInputs.length)];
setState(() {
puzzleCells[randomIdx] = 0;
puzzleCells[randomIdx] = 0; //
});
ScaffoldMessenger.of(context).showSnackBar(
@ -259,7 +242,7 @@ class _SudokuBattleScreenState extends State<SudokuBattleScreen> {
barrierDismissible: false,
builder: (context) => AlertDialog(
title: Text(isMe ? "승리! 🎉" : "패배 😭"),
content: Text(isMe ? "축하합니다!" : "$winnerName 님이 승리했습니다."),
content: Text(isMe ? "축하합니다! $winnerName 님이 승리했습니다." : "$winnerName 님이 먼저 퍼즐을 완성했습니다."),
actions: [
TextButton(
onPressed: () {
@ -304,47 +287,44 @@ class _SudokuBattleScreenState extends State<SudokuBattleScreen> {
)
],
),
//
bottomNavigationBar: const SafeArea(child: AdBannerWidget()),
body: Column(
mainAxisAlignment: MainAxisAlignment.start,
children: [
// 1.
const SizedBox(height: 10),
Padding(
padding: const EdgeInsets.symmetric(vertical: 10.0),
child: Text(
"남은 빈칸: ${puzzleCells.where((e)=>e==0).length}",
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18)
padding: const EdgeInsets.all(8.0),
child: Text("남은 빈칸: ${puzzleCells.where((e)=>e==0).length}",
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 10.0),
child: Center(
child: SudokuBoard(
blockSize: blockSize,
cells: puzzleCells,
originalCells: originalCells,
selectedIndex: selectedIndex,
selectedNumberPad: selectedNumberPad,
incorrectCells: incorrectCells,
onCellTapped: (index) {
setState(() {
selectedIndex = index;
if (selectedNumberPad != null) {
_onNumberTapped(selectedNumberPad!);
selectedIndex = null;
selectedNumberPad = null;
}
});
},
),
),
),
// 2. (Center와 Expanded )
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: SudokuBoard(
blockSize: blockSize,
cells: puzzleCells,
originalCells: originalCells,
selectedIndex: selectedIndex,
selectedNumberPad: selectedNumberPad,
incorrectCells: incorrectCells,
onCellTapped: (index) {
setState(() {
selectedIndex = index;
// [] ->
if (selectedNumberPad != null) {
_onNumberTapped(selectedNumberPad!);
selectedIndex = null; //
}
});
},
),
),
const Spacer(), // ( )
// 3. ( )
const Spacer(),
Container(
padding: const EdgeInsets.fromLTRB(16, 10, 16, 30),
height: 240, // []
height: 240,
color: Colors.grey[50],
child: NumberPad(
blockSize: blockSize,
@ -352,13 +332,11 @@ class _SudokuBattleScreenState extends State<SudokuBattleScreen> {
selectedNumber: selectedNumberPad,
onNumberTapped: (num) {
setState(() {
// [] ->
if (selectedIndex != null) {
_onNumberTapped(num);
selectedIndex = null; //
selectedNumberPad = null; //
selectedIndex = null;
selectedNumberPad = null;
} else {
// '숫자 우선 모드'
selectedNumberPad = (selectedNumberPad == num) ? null : num;
}
});
@ -369,4 +347,14 @@ class _SudokuBattleScreenState extends State<SudokuBattleScreen> {
),
);
}
}
}
// [] DTO에 (SudokuWidgets에서 )
class SudokuTheme {
final Color primaryColor;
final Color backgroundColor;
final SudokuSymbolType symbolType;
SudokuTheme({required this.primaryColor, required this.backgroundColor, required this.symbolType});
String getSymbol(int val) => val.toString();
}
enum SudokuSymbolType { number }

View File

@ -0,0 +1,418 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:playwith_core/playwith_core.dart';
class SurvivorGame extends BaseGame {
@override
String get id => "survivor";
@override
String get name => "서바이버";
@override
String get description => "최후의 생존자가 되세요!";
@override
void onMessageReceived(String senderId, Map<String, dynamic> payload) {
// ( )
}
@override
Widget buildHostView(BuildContext context) => SurvivorScreen(isHost: true, gameInstance: this);
@override
Widget buildGuestView(BuildContext context) => SurvivorScreen(isHost: false, gameInstance: this);
}
class SurvivorScreen extends StatefulWidget {
final bool isHost;
final SurvivorGame gameInstance;
const SurvivorScreen({super.key, required this.isHost, required this.gameInstance});
@override
State<SurvivorScreen> createState() => _SurvivorScreenState();
}
class _SurvivorScreenState extends State<SurvivorScreen> with SingleTickerProviderStateMixin {
late Ticker _ticker;
// --- ---
_Player player = _Player();
List<_Enemy> enemies = [];
List<_Bullet> bullets = [];
List<_Gem> gems = []; //
// --- ---
int score = 0; //
int opponentScore = 0;
int level = 1;
int exp = 0;
int maxExp = 5;
double gameTime = 0;
bool isGameOver = false;
//
Offset _joystickDelta = Offset.zero;
@override
void initState() {
super.initState();
_ticker = createTicker(_gameLoop)..start();
NetworkManager().messageStream.listen(_handleMessage);
}
void _handleMessage(Map<String, dynamic> payload) {
if (!mounted) return;
if (payload['type'] == 'SCORE') {
setState(() => opponentScore = payload['score']);
}
}
void _gameLoop(Duration elapsed) {
if (isGameOver) return;
setState(() {
gameTime += 0.016; // 60fps
// 1.
if (_joystickDelta != Offset.zero) {
player.x += _joystickDelta.dx * player.speed;
player.y += _joystickDelta.dy * player.speed;
// (0.0 ~ 1.0 )
player.x = player.x.clamp(0.0, 1.0);
player.y = player.y.clamp(0.0, 1.0);
}
// 2. ( )
if (Random().nextDouble() < 0.02 + (level * 0.005)) {
_spawnEnemy();
}
// 3. ( )
for (var enemy in enemies) {
double dx = player.x - enemy.x;
double dy = player.y - enemy.y;
double dist = sqrt(dx*dx + dy*dy);
if (dist > 0) {
enemy.x += (dx / dist) * enemy.speed;
enemy.y += (dy / dist) * enemy.speed;
}
// ()
if (dist < (player.size + enemy.size) / 2) {
_gameOver();
}
}
// 4. ( )
player.cooldown -= 0.016;
if (player.cooldown <= 0 && enemies.isNotEmpty) {
_Enemy? target = _findNearestEnemy();
if (target != null) {
_fireBullet(target);
player.cooldown = player.maxCooldown; //
}
}
// 5.
for (int i = bullets.length - 1; i >= 0; i--) {
var b = bullets[i];
b.x += b.vx;
b.y += b.vy;
b.life -= 0.016;
// or
if (b.x < 0 || b.x > 1 || b.y < 0 || b.y > 1 || b.life <= 0) {
bullets.removeAt(i);
continue;
}
//
for (int j = enemies.length - 1; j >= 0; j--) {
var e = enemies[j];
double dist = sqrt(pow(b.x - e.x, 2) + pow(b.y - e.y, 2));
if (dist < (b.size + e.size) / 2) {
//
e.hp--;
bullets.removeAt(i); // ( )
if (e.hp <= 0) {
enemies.removeAt(j);
_dropGem(e.x, e.y);
score++;
if (score % 10 == 0) NetworkManager().sendMessage({'type': 'SCORE', 'score': score});
}
break;
}
}
}
// 6. ()
for (int i = gems.length - 1; i >= 0; i--) {
var g = gems[i];
// ( )
double dx = player.x - g.x;
double dy = player.y - g.y;
double dist = sqrt(dx*dx + dy*dy);
if (dist < 0.15) { //
g.x += dx * 0.1;
g.y += dy * 0.1;
}
if (dist < player.size) {
gems.removeAt(i);
exp++;
if (exp >= maxExp) {
_levelUp();
}
}
}
});
}
_Enemy? _findNearestEnemy() {
_Enemy? nearest;
double minDst = 100.0;
for (var e in enemies) {
double dst = sqrt(pow(player.x - e.x, 2) + pow(player.y - e.y, 2));
if (dst < minDst) {
minDst = dst;
nearest = e;
}
}
// ( )
if (minDst < 0.4) return nearest;
return null;
}
void _fireBullet(_Enemy target) {
double dx = target.x - player.x;
double dy = target.y - player.y;
double dist = sqrt(dx*dx + dy*dy);
bullets.add(_Bullet(
x: player.x,
y: player.y,
vx: (dx/dist) * 0.02, //
vy: (dy/dist) * 0.02
));
// ( )
if (level >= 3) {
//
}
}
void _spawnEnemy() {
//
double x, y;
if (Random().nextBool()) {
x = Random().nextBool() ? -0.1 : 1.1;
y = Random().nextDouble();
} else {
x = Random().nextDouble();
y = Random().nextBool() ? -0.1 : 1.1;
}
enemies.add(_Enemy(
x: x, y: y,
hp: 1 + (level ~/ 2), //
speed: 0.002 + (level * 0.0005) //
));
}
void _dropGem(double x, double y) {
gems.add(_Gem(x: x, y: y));
}
void _levelUp() {
level++;
exp = 0;
maxExp += 5;
player.maxCooldown *= 0.9; //
//
SoundManager().playSfx(SoundKey.win); //
}
void _gameOver() {
isGameOver = true;
_ticker.stop();
SoundManager().playSfx(SoundKey.wrong);
String result = score > opponentScore ? "승리! (상대: $opponentScore)" : "패배... (상대: $opponentScore)";
if (score == opponentScore) result = "무승부!";
showDialog(
context: context,
barrierDismissible: false,
builder: (_) => AlertDialog(
title: const Text("생존 실패!"),
content: Text("최종 레벨: $level\n처치 수: $score\n$result"),
actions: [
TextButton(
onPressed: () { Navigator.pop(context); Navigator.pop(context); },
child: const Text("나가기"),
)
],
),
);
}
@override
void dispose() {
_ticker.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.green[900], //
body: Stack(
children: [
//
Positioned.fill(
child: CustomPaint(
painter: SurvivorPainter(player, enemies, bullets, gems),
),
),
// UI (, )
SafeArea(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text("KILL: $score", style: const TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold)),
Text("LV: $level", style: const TextStyle(color: Colors.yellowAccent, fontSize: 24, fontWeight: FontWeight.bold)),
Text("RIVAL: $opponentScore", style: const TextStyle(color: Colors.white70, fontSize: 16)),
],
),
const SizedBox(height: 5),
//
LinearProgressIndicator(
value: exp / maxExp,
backgroundColor: Colors.black26,
color: Colors.blueAccent,
minHeight: 8,
)
],
),
),
),
// ( )
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onPanStart: (d) => _updateJoystick(d.localPosition, start: true),
onPanUpdate: (d) => _updateJoystick(d.localPosition),
onPanEnd: (d) => setState(() => _joystickDelta = Offset.zero),
child: Container(color: Colors.transparent),
),
),
],
),
);
}
// ( )
Offset _startTouchPos = Offset.zero;
void _updateJoystick(Offset pos, {bool start = false}) {
if (start) _startTouchPos = pos;
Offset diff = pos - _startTouchPos;
double dist = diff.distance;
if (dist > 0) {
// ( )
if (dist > 50) diff = diff / dist * 50;
setState(() {
_joystickDelta = diff / 50.0; // -1.0 ~ 1.0
});
}
}
}
// --- ---
class _Player {
double x = 0.5, y = 0.5;
double size = 0.04;
double speed = 0.008;
double cooldown = 0;
double maxCooldown = 0.5; // 2
}
class _Enemy {
double x, y;
int hp;
double size = 0.03;
double speed;
_Enemy({required this.x, required this.y, this.hp = 1, this.speed = 0.002});
}
class _Bullet {
double x, y, vx, vy;
double size = 0.015;
double life = 2.0; // 2
_Bullet({required this.x, required this.y, required this.vx, required this.vy});
}
class _Gem {
double x, y;
_Gem({required this.x, required this.y});
}
// --- ---
class SurvivorPainter extends CustomPainter {
final _Player player;
final List<_Enemy> enemies;
final List<_Bullet> bullets;
final List<_Gem> gems;
SurvivorPainter(this.player, this.enemies, this.bullets, this.gems);
@override
void paint(Canvas canvas, Size size) {
final w = size.width;
final h = size.height;
//
Offset toPos(double x, double y) => Offset(x * w, y * h);
//
final gemPaint = Paint()..color = Colors.blueAccent;
for (var g in gems) {
canvas.drawCircle(toPos(g.x, g.y), w * 0.015, gemPaint);
}
//
final enemyPaint = Paint()..color = Colors.red;
for (var e in enemies) {
//
Rect rect = Rect.fromCenter(center: toPos(e.x, e.y), width: w * e.size * 2, height: w * e.size * 2);
canvas.drawRect(rect, enemyPaint);
}
//
final bulletPaint = Paint()..color = Colors.yellow;
for (var b in bullets) {
canvas.drawCircle(toPos(b.x, b.y), w * b.size, bulletPaint);
}
//
final playerPaint = Paint()..color = Colors.white;
canvas.drawCircle(toPos(player.x, player.y), w * player.size, playerPaint);
// (HP )
final borderPaint = Paint()..color = Colors.black..style = PaintingStyle.stroke..strokeWidth = 2;
canvas.drawCircle(toPos(player.x, player.y), w * player.size, borderPaint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}

View File

@ -0,0 +1,495 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:playwith_core/playwith_core.dart';
class WorldTourGame extends BaseGame {
@override
String get id => "world_tour";
@override
String get name => "월드 투어";
@override
String get description => "주사위로 떠나는 세계 여행\n건물을 지어 통행료를 높이세요!";
@override
void onMessageReceived(String senderId, Map<String, dynamic> payload) {
// UI에서
}
@override
Widget buildHostView(BuildContext context) => WorldTourScreen(myTeam: 0, gameInstance: this); // 0: Red
@override
Widget buildGuestView(BuildContext context) => WorldTourScreen(myTeam: 1, gameInstance: this); // 1: Blue
}
//
class City {
final String name;
final int basePrice; //
final int baseToll; //
int owner; // -1: , 0: Red, 1: Blue
int buildingLevel; // 0: , 1: 1, 2: 2, 3:
City(this.name, this.basePrice, this.baseToll, {this.owner = -1, this.buildingLevel = 0});
// ( 1 50% -> 3 2.5)
int get currentToll => (baseToll * (1 + 0.5 * buildingLevel)).toInt();
// ( 50%)
int get upgradeCost => (basePrice * 0.5).toInt();
}
class WorldTourScreen extends StatefulWidget {
final int myTeam; // 0: Red, 1: Blue
final WorldTourGame gameInstance;
const WorldTourScreen({super.key, required this.myTeam, required this.gameInstance});
@override
State<WorldTourScreen> createState() => _WorldTourScreenState();
}
class _WorldTourScreenState extends State<WorldTourScreen> {
// (20)
final List<City> board = [
City("출발", 0, 0), // 0
City("타이페이", 50, 30), // 1
City("베이징", 80, 40), // 2
City("마닐라", 100, 50), // 3
City("제주도", 150, 80), // 4
City("무인도", 0, 0), // 5
City("아테네", 180, 90), // 6
City("코펜하겐", 200, 100), // 7
City("오타와", 220, 110), // 8
City("베를린", 240, 120), // 9
City("사회복지", 0, 0), // 10
City("상파울루", 300, 150), // 11
City("시드니", 320, 160), // 12
City("하와이", 350, 180), // 13
City("리스본", 400, 200), // 14
City("세계여행", 0, 0), // 15
City("도쿄", 500, 300), // 16
City("파리", 600, 400), // 17
City("런던", 700, 500), // 18
City("서울", 1000, 800), // 19
];
List<int> positions = [0, 0]; //
List<int> money = [2000, 2000]; //
int currentTurn = 0; // 0: Red, 1: Blue
bool canRoll = true;
String infoMessage = "게임을 시작합니다!";
int? lastDice;
@override
void initState() {
super.initState();
NetworkManager().messageStream.listen(_handleMessage);
}
void _handleMessage(Map<String, dynamic> payload) {
if (!mounted) return;
if (payload['type'] == 'ROLL') {
int result = payload['result'];
int team = payload['team'];
setState(() {
lastDice = result;
_movePlayer(team, result);
});
} else if (payload['type'] == 'BUY') {
// or
int team = payload['team'];
int index = payload['index'];
int cost = payload['cost'];
bool isUpgrade = payload['isUpgrade'] ?? false;
setState(() {
money[team] -= cost;
board[index].owner = team;
if (isUpgrade) {
board[index].buildingLevel++;
infoMessage = "${board[index].name} 건물 증축! (${board[index].buildingLevel}단계)";
} else {
infoMessage = "${board[index].name} 구매 완료!";
}
_nextTurn();
});
} else if (payload['type'] == 'PAY') {
int from = payload['from'];
int to = payload['to'];
int amount = payload['amount'];
setState(() {
money[from] -= amount;
money[to] += amount;
infoMessage = "통행료 $amount 지불!";
_checkBankruptcy();
_nextTurn();
});
} else if (payload['type'] == 'PASS') {
_nextTurn();
} else if (payload['type'] == 'GAME_OVER') {
_showGameOverDialog(payload['winner']);
}
}
// --- ---
void _onRollDice() {
if (currentTurn != widget.myTeam || !canRoll) return;
int dice1 = Random().nextInt(6) + 1;
int dice2 = Random().nextInt(6) + 1;
int total = dice1 + dice2;
NetworkManager().sendMessage({'type': 'ROLL', 'result': total, 'team': widget.myTeam});
setState(() {
lastDice = total;
_movePlayer(widget.myTeam, total);
});
}
void _movePlayer(int team, int steps) {
canRoll = false;
int currentPos = positions[team];
int nextPos = (currentPos + steps) % 20;
// ()
if (nextPos < currentPos) {
money[team] += 300; //
infoMessage = "한 바퀴 돌았습니다! (+300)";
}
positions[team] = nextPos;
_handleArrival(team, nextPos);
}
void _handleArrival(int team, int index) {
City city = board[index];
// 1. (, ) -
if (city.basePrice == 0) {
if (team == widget.myTeam) {
Future.delayed(const Duration(seconds: 1), () {
NetworkManager().sendMessage({'type': 'PASS'});
_nextTurn();
});
}
return;
}
// 2. ->
if (city.owner == -1) {
if (team == widget.myTeam) {
if (money[team] >= city.basePrice) {
_showBuyDialog(index, isUpgrade: false);
} else {
NetworkManager().sendMessage({'type': 'PASS'});
_nextTurn();
}
}
}
// 3. -> (3 )
else if (city.owner == team) {
if (team == widget.myTeam) {
if (city.buildingLevel < 3 && money[team] >= city.upgradeCost) {
_showBuyDialog(index, isUpgrade: true);
} else {
//
NetworkManager().sendMessage({'type': 'PASS'});
_nextTurn();
}
}
}
// 4. ->
else {
if (team == widget.myTeam) {
int toll = city.currentToll;
NetworkManager().sendMessage({
'type': 'PAY',
'from': team,
'to': city.owner,
'amount': toll
});
setState(() {
money[team] -= toll;
money[city.owner] += toll;
infoMessage = "${city.name} 도착! 통행료 $toll 지불";
});
_checkBankruptcy();
_nextTurn();
}
}
}
void _showBuyDialog(int index, {required bool isUpgrade}) {
City city = board[index];
int cost = isUpgrade ? city.upgradeCost : city.basePrice;
String title = isUpgrade ? "${city.name} 증축?" : "${city.name} 구매?";
String content = isUpgrade
? "현재 단계: ${city.buildingLevel} -> ${city.buildingLevel + 1}\n비용: $cost"
: "가격: $cost\n기본 통행료: ${city.baseToll}";
showDialog(
context: context,
barrierDismissible: false,
builder: (_) => AlertDialog(
title: Text(title),
content: Text("$content\n보유 자금: ${money[widget.myTeam]}"),
actions: [
TextButton(
onPressed: () {
Navigator.pop(context);
NetworkManager().sendMessage({'type': 'PASS'});
_nextTurn();
},
child: const Text("패스"),
),
ElevatedButton(
onPressed: () {
Navigator.pop(context);
NetworkManager().sendMessage({
'type': 'BUY',
'team': widget.myTeam,
'index': index,
'cost': cost,
'isUpgrade': isUpgrade
});
setState(() {
money[widget.myTeam] -= cost;
board[index].owner = widget.myTeam;
if (isUpgrade) {
board[index].buildingLevel++;
infoMessage = "건물 업그레이드 완료!";
} else {
infoMessage = "구매 완료!";
}
_nextTurn();
});
},
child: Text(isUpgrade ? "증축" : "구매"),
),
],
),
);
}
void _nextTurn() {
setState(() {
currentTurn = 1 - currentTurn;
canRoll = true;
lastDice = null;
});
}
void _checkBankruptcy() {
if (money[0] < 0) _finishGame(1); // Blue Win
else if (money[1] < 0) _finishGame(0); // Red Win
}
void _finishGame(int winner) {
NetworkManager().sendMessage({'type': 'GAME_OVER', 'winner': winner});
_showGameOverDialog(winner);
}
void _showGameOverDialog(int winner) {
String msg = (winner == widget.myTeam) ? "승리! 🎉" : "파산했습니다... 💸";
showDialog(
context: context,
barrierDismissible: false,
builder: (_) => AlertDialog(
title: const Text("게임 종료"),
content: Text(msg),
actions: [
TextButton(
onPressed: () { Navigator.pop(context); Navigator.pop(context); },
child: const Text("나가기"),
)
],
),
);
}
// --- UI ---
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text("월드 투어")),
body: Column(
children: [
//
Container(
padding: const EdgeInsets.all(16),
color: Colors.blueGrey[50],
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildPlayerInfo(0, Colors.redAccent, "RED"),
const Text("VS", style: TextStyle(fontWeight: FontWeight.bold)),
_buildPlayerInfo(1, Colors.blueAccent, "BLUE"),
],
),
),
if (lastDice != null)
Padding(
padding: const EdgeInsets.all(8.0),
child: Text("주사위: $lastDice", style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
),
//
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
return Padding(
padding: const EdgeInsets.all(10.0),
child: CustomPaint(
size: Size(constraints.maxWidth, constraints.maxWidth),
painter: BoardPainter(board, positions),
),
);
},
),
),
//
Padding(
padding: const EdgeInsets.all(20),
child: SizedBox(
width: double.infinity,
height: 60,
child: ElevatedButton(
onPressed: (currentTurn == widget.myTeam && canRoll) ? _onRollDice : null,
style: ElevatedButton.styleFrom(
backgroundColor: widget.myTeam == 0 ? Colors.redAccent : Colors.blueAccent,
foregroundColor: Colors.white,
),
child: Text((currentTurn == widget.myTeam) ? "주사위 굴리기" : "상대방 차례"),
),
),
),
],
),
);
}
Widget _buildPlayerInfo(int team, Color color, String name) {
bool isTurn = currentTurn == team;
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: isTurn ? color.withOpacity(0.2) : Colors.transparent,
border: Border.all(color: color, width: 2),
borderRadius: BorderRadius.circular(10),
),
child: Column(
children: [
Text(name, style: TextStyle(fontWeight: FontWeight.bold, color: color)),
Text("${money[team]}", style: const TextStyle(fontSize: 18)),
],
),
);
}
}
// ( + )
class BoardPainter extends CustomPainter {
final List<City> board;
final List<int> positions;
BoardPainter(this.board, this.positions);
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()..style = PaintingStyle.stroke..strokeWidth = 1.0;
final fillPaint = Paint()..style = PaintingStyle.fill;
double cellSize = size.width / 6;
//
for (int i = 0; i < 20; i++) {
Rect rect = _getRect(i, cellSize, size.width);
//
if (board[i].owner != -1) {
fillPaint.color = board[i].owner == 0 ? Colors.redAccent.withOpacity(0.3) : Colors.blueAccent.withOpacity(0.3);
canvas.drawRect(rect, fillPaint);
} else if (i % 5 == 0) {
fillPaint.color = Colors.grey.withOpacity(0.2);
canvas.drawRect(rect, fillPaint);
}
paint.color = Colors.black;
canvas.drawRect(rect, paint);
// ( )
_drawText(canvas, board[i].name, rect.center, i);
// [] ()
if (board[i].owner != -1 && board[i].buildingLevel > 0) {
_drawBuilding(canvas, rect, board[i].buildingLevel, board[i].owner == 0 ? Colors.red : Colors.blue);
}
}
//
_drawToken(canvas, _getRect(positions[0], cellSize, size.width).center, Colors.red, -5);
_drawToken(canvas, _getRect(positions[1], cellSize, size.width).center, Colors.blue, 5);
}
void _drawBuilding(Canvas canvas, Rect rect, int level, Color color) {
// ( )
final paint = Paint()..color = color..style = PaintingStyle.fill;
double yPos = rect.top + 8;
double startX = rect.center.dx - ((level - 1) * 6); //
for (int i = 0; i < level; i++) {
canvas.drawCircle(Offset(startX + (i * 12), yPos), 3, paint);
}
}
void _drawToken(Canvas canvas, Offset center, Color color, double offset) {
final paint = Paint()..color = color..style = PaintingStyle.fill;
canvas.drawCircle(center + Offset(offset, offset), 8, paint);
paint.style = PaintingStyle.stroke;
paint.color = Colors.white;
paint.strokeWidth = 2;
canvas.drawCircle(center + Offset(offset, offset), 8, paint);
}
void _drawText(Canvas canvas, String text, Offset center, int index) {
TextSpan span = TextSpan(style: const TextStyle(color: Colors.black, fontSize: 10, fontWeight: FontWeight.bold), text: text);
TextPainter tp = TextPainter(text: span, textAlign: TextAlign.center, textDirection: TextDirection.ltr);
tp.layout();
tp.paint(canvas, center - Offset(tp.width / 2, tp.height / 2 - 5)); // ( )
}
Rect _getRect(int index, double size, double totalSize) {
// ( )
int side = index ~/ 5;
double x = 0, y = 0;
if (index >= 0 && index <= 5) {
x = totalSize - (index + 1) * size;
y = totalSize - size;
} else if (index > 5 && index <= 10) {
x = 0;
y = totalSize - (index - 5 + 1) * size;
} else if (index > 10 && index <= 15) {
x = (index - 10) * size;
y = 0;
} else {
x = totalSize - size;
y = (index - 15) * size;
}
return Rect.fromLTWH(x, y, size, size);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}

View File

@ -40,6 +40,7 @@ class MediaManager {
MediaManager._internal();
EphemeralDatabase? _db;
EphemeralDatabase? get db => _db;
String? _currentRoomId;
final Map<String, _TransferState> _activeTransfers = {};

View File

@ -85,6 +85,65 @@ class AppGames {
icon: Icons.touch_app,
isSinglePlayerSupported: false,
),
GameInfo(
id: 'world_tour',
name: '월드 투어',
description: '주사위를 굴려 세계 여행!\n땅을 사고 통행료를 받으세요.',
icon: Icons.public,
isSinglePlayerSupported: false,
),
// []
GameInfo(
id: 'othello',
name: '오셀로',
description: '돌을 뒤집어라!\n마지막에 웃는 자가 승리',
icon: Icons.circle, //
isSinglePlayerSupported: false,
),
// []
GameInfo(
id: 'arkanoid',
name: '벽돌 깨기',
description: '추억의 아케이드!\n누가 더 높은 점수를 낼까?',
icon: Icons.view_module,
isSinglePlayerSupported: true,
),// [] ( )
GameInfo(
id: 'math_run',
name: '매스 런',
description: '좌우로 움직여 숫자를 늘리세요!\n높은 점수가 승리합니다.',
icon: Icons.calculate,
isSinglePlayerSupported: true,
),
// [] ()
GameInfo(
id: 'jump_battle',
name: '점프 배틀',
description: '장애물을 피해 끝까지 달리세요!\n타이밍 싸움!',
icon: Icons.directions_run,
isSinglePlayerSupported: true,
),
GameInfo(
id: 'iam_ground',
name: '아이엠그라운드',
description: '리듬을 타며 이름을 공격하세요!\n박자를 놓치면 탈락!',
icon: Icons.music_note, //
isSinglePlayerSupported: false, // 2
),
GameInfo(
id: 'survivor',
name: '서바이버',
description: '몰려오는 몬스터를 막아내세요!\n이동만 하면 자동으로 공격합니다.',
icon: Icons.bug_report,
isSinglePlayerSupported: true,
),
GameInfo(
id: 'sequence_memory',
name: '기억의 신',
description: '반짝이는 순서를 기억하세요!\n라운드가 갈수록 종류가 다양해집니다.',
icon: Icons.apps,
isSinglePlayerSupported: true,
),
];
static GameInfo getById(String id) {

View File

@ -1,44 +1,41 @@
import 'dart:convert';
/// ( )
enum PacketType {
system, // (, , , )
chat, // (GlobalChatManager로 )
game, // (GameController로 )
media,
unknown
system, chat, game, media, unknown
}
class PlayPacket {
final PacketType type;
final String senderId; // ID
final dynamic payload; // (Map, List, String )
final String senderId;
final dynamic payload;
final int timestamp;
final int? seq; // []
PlayPacket({
required this.type,
required this.senderId,
required this.payload,
required this.timestamp,
this.seq, // []
});
// JSON ->
factory PlayPacket.fromJson(Map<String, dynamic> json) {
return PlayPacket(
type: _parseType(json['type']),
senderId: json['senderId'] ?? 'unknown',
payload: json['payload'],
timestamp: json['timestamp'] ?? DateTime.now().millisecondsSinceEpoch,
seq: json['seq'], // []
);
}
// -> JSON
Map<String, dynamic> toJson() {
return {
'type': type.name, // enum을 ('chat', 'game'...)
'type': type.name,
'senderId': senderId,
'payload': payload,
'timestamp': timestamp,
if (seq != null) 'seq': seq, // []
};
}
@ -46,7 +43,6 @@ class PlayPacket {
for (var t in PacketType.values) {
if (t.name == typeStr) return t;
}
// : (PING, ANSWER_SUBMIT ) 'unknown'
return PacketType.unknown;
}
}

View File

@ -0,0 +1,28 @@
class SpiderGameDto {
final int puzzleId;
final int difficulty; // 1, 2, 4 (Suits)
final List<int> cards; // 0~103 ( )
SpiderGameDto({
required this.puzzleId,
required this.difficulty,
required this.cards,
});
factory SpiderGameDto.fromJson(Map<String, dynamic> json) {
return SpiderGameDto(
puzzleId: json['puzzleId'] ?? 0,
difficulty: json['difficulty'] ?? 1,
//
cards: (json['cards'] as List<dynamic>?)?.map((e) => e as int).toList() ?? [],
);
}
Map<String, dynamic> toJson() {
return {
'puzzleId': puzzleId,
'difficulty': difficulty,
'cards': cards,
};
}
}

View File

@ -12,7 +12,7 @@ import '../model/user_info.dart';
import '../model/play_packet.dart';
import '../manager/global_chat_manager.dart';
import '../manager/media_manager.dart';
import '../manager/notification_manager.dart';
import '../database/ephemeral_database.dart';
enum NetworkRole { none, host, guest }
@ -24,17 +24,13 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
WidgetsBinding.instance.addObserver(this);
}
// ------------------------------------------------------------------------
//
// ------------------------------------------------------------------------
static const String PACKET_DELIMITER = "|||EOP|||";
static const int HEARTBEAT_INTERVAL_SEC = 3;
static const int TIMEOUT_SEC = 10;
static const int RECONNECT_WAIT_SEC = 5;
// ------------------------------------------------------------------------
//
// ------------------------------------------------------------------------
late UserInfo me;
NetworkRole role = NetworkRole.none;
@ -44,14 +40,15 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
ServerSocket? _serverSocket;
Socket? _clientSocket;
final Map<Socket, UserInfo?> _connectedGuests = {};
final List<UserInfo> guestList = [];
final Map<Socket, UserInfo?> _connectedGuests = {};
final Map<Socket, String> _packetBuffers = {};
BonsoirService? _bonsoirService;
BonsoirBroadcast? _bonsoirBroadcast;
BonsoirDiscovery? _bonsoirDiscovery;
final List<UserInfo> guestList = [];
final _messageController = StreamController<Map<String, dynamic>>.broadcast();
Stream<Map<String, dynamic>> get messageStream => _messageController.stream;
@ -63,9 +60,13 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
DateTime? _lastPongTime;
bool _isReconnecting = false;
// [] ID
String selectedGameId = 'quiz_mix';
Map<String, dynamic> selectedGameConfig = {}; //
Map<String, dynamic> selectedGameConfig = {};
int _sendSeq = 0;
int _recvSeq = 0;
EphemeralDatabase? get _database => MediaManager().db;
// ------------------------------------------------------------------------
//
@ -98,82 +99,24 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
}
// ------------------------------------------------------------------------
//
// ------------------------------------------------------------------------
// [] (Config )
void selectGame(String gameId, {Map<String, dynamic>? config}) {
selectedGameId = gameId;
selectedGameConfig = config ?? {};
notifyListeners();
}
void toggleReady() {
me = me.copyWith(isReady: !me.isReady);
notifyListeners();
final payload = {
'type': 'TOGGLE_READY',
'userId': me.id,
'isReady': me.isReady,
};
sendMessage(payload);
if (role == NetworkRole.host) {
_checkAllReadyAndStart();
}
}
void _checkAllReadyAndStart() {
if (guestList.isEmpty) return;
if (!me.isReady) return;
bool allGuestsReady = guestList.every((u) => u.isReady);
if (allGuestsReady) {
_log("🚀 전원 준비 완료! 3초 후 게임 시작...");
Future.delayed(const Duration(seconds: 1), () {
// [] Config
final startPayload = {
'type': 'GAME_START',
'gameId': selectedGameId,
'config': selectedGameConfig
};
sendMessage(startPayload);
_messageController.add(startPayload);
_resetAllReadyState();
});
}
}
void _resetAllReadyState() {
me = me.copyWith(isReady: false);
for (int i = 0; i < guestList.length; i++) {
guestList[i] = guestList[i].copyWith(isReady: false);
}
notifyListeners();
}
// ------------------------------------------------------------------------
// Host Logic
// [Socket] (WiFi/Hotspot)
// ------------------------------------------------------------------------
Future<void> startHosting(String roomName) async {
stopNetwork(force: true);
await stopNetwork(force: true);
role = NetworkRole.host;
_sendSeq = 0; _recvSeq = 0;
try {
_serverSocket = await ServerSocket.bind(InternetAddress.anyIPv4, 0);
int port = _serverSocket!.port;
this.hostPort = port;
String? myIp = await _getWifiIp();
this.hostIp = myIp ?? '127.0.0.1';
_log("✅ 방 생성: $hostIp : $port");
_serverSocket!.listen((Socket client) {
_handleNewGuest(client);
});
_bonsoirService = BonsoirService(
name: '$roomName#${me.id}',
type: '_playwith._tcp',
@ -182,11 +125,9 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
);
_bonsoirBroadcast = BonsoirBroadcast(service: _bonsoirService!);
await _bonsoirBroadcast!.start();
await MediaManager().initialize(roomName);
_startHeartbeat();
notifyListeners();
} catch (e) {
_log("❌ 방 생성 실패: $e");
stopNetwork(force: true);
@ -199,8 +140,16 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
_packetBuffers[client] = "";
final myHandshake = {'type': 'HANDSHAKE', 'payload': me.toJson()};
final jsonString = jsonEncode(myHandshake);
client.add(utf8.encode('$jsonString$PACKET_DELIMITER'));
client.add(utf8.encode('${jsonEncode(myHandshake)}$PACKET_DELIMITER'));
Future.delayed(const Duration(milliseconds: 500), () {
final gameSync = {
'type': 'GAME_CHANGED',
'gameId': selectedGameId,
'config': selectedGameConfig
};
client.add(utf8.encode('${jsonEncode(gameSync)}$PACKET_DELIMITER'));
});
client.listen(
(Uint8List data) => _onDataReceived(client, data),
@ -221,9 +170,7 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
notifyListeners();
}
// ------------------------------------------------------------------------
// Guest Logic
// ------------------------------------------------------------------------
// [Socket]
Stream<List<BonsoirService>> discoverRooms() {
final controller = StreamController<List<BonsoirService>>();
final List<BonsoirService> foundServices = [];
@ -233,7 +180,6 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
try {
_bonsoirDiscovery = BonsoirDiscovery(type: '_playwith._tcp');
await _bonsoirDiscovery!.start();
if (_bonsoirDiscovery?.eventStream != null) {
_bonsoirDiscovery!.eventStream!.listen((dynamic event) {
final String type = event.type.toString();
@ -256,60 +202,30 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
return controller.stream;
}
// [] Config
Future<void> startSoloMode(String gameId, {Map<String, dynamic>? config}) async {
stopNetwork(force: true);
role = NetworkRole.host;
hostIp = "Solo Mode";
hostPort = 0;
selectedGameId = gameId;
selectedGameConfig = config ?? {}; // Config
await MediaManager().initialize("solo_session");
_log("👤 싱글 플레이 모드 시작: $gameId");
notifyListeners();
//
Future.delayed(const Duration(milliseconds: 100), () {
_messageController.add({
'type': 'GAME_START',
'gameId': gameId,
'config': selectedGameConfig
});
});
}
Future<void> joinRoom(String ip, int port) async {
if (role != NetworkRole.guest) stopNetwork(force: true);
if (role != NetworkRole.guest) await stopNetwork(force: true);
role = NetworkRole.guest;
hostIp = ip;
hostPort = port;
_sendSeq = 0; _recvSeq = 0;
try {
_log("🚀 접속 시도: $ip:$port");
_clientSocket = await Socket.connect(ip, port, timeout: const Duration(seconds: 5));
_log("✅ 접속 성공!");
_packetBuffers[_clientSocket!] = "";
sendMessage({'type': 'HANDSHAKE', 'payload': me.toJson()});
final myHandshake = {'type': 'HANDSHAKE', 'payload': me.toJson()};
_clientSocket!.add(utf8.encode('${jsonEncode(myHandshake)}$PACKET_DELIMITER'));
await MediaManager().initialize("guest_${ip.replaceAll('.', '_')}");
_lastPongTime = DateTime.now();
_startHeartbeat();
_cancelDisconnectTimer();
_clientSocket!.listen(
(Uint8List data) => _onDataReceived(_clientSocket!, data),
onError: (e) => _handleConnectionLost(e),
onDone: () => _handleConnectionLost("Socket Closed"),
onError: (e) => stopNetwork(force: true),
onDone: () => stopNetwork(force: true),
);
notifyListeners();
} catch (e) {
_log("❌ 접속 실패: $e");
if (!_isReconnecting) stopNetwork(force: true);
@ -317,6 +233,25 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
}
}
//
Future<void> startSoloMode(String gameId, {Map<String, dynamic>? config}) async {
await stopNetwork(force: true);
role = NetworkRole.host;
hostIp = "Solo Mode";
hostPort = 0;
selectedGameId = gameId;
selectedGameConfig = config ?? {};
_sendSeq = 0; _recvSeq = 0;
await MediaManager().initialize("solo_session");
_log("👤 싱글 플레이 모드: $gameId");
notifyListeners();
Future.delayed(const Duration(milliseconds: 100), () {
_messageController.add({'type': 'GAME_START', 'gameId': gameId, 'config': selectedGameConfig});
});
}
// ------------------------------------------------------------------------
//
// ------------------------------------------------------------------------
@ -327,15 +262,20 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
void sendMessage(Map<String, dynamic> messageMap) {
if (role == NetworkRole.guest && _clientSocket == null) return;
final String type = messageMap['type'] ?? '';
bool isSystem = ['PING', 'PONG', 'HANDSHAKE', 'REQ_RESEND', 'RESEND_DATA'].contains(type);
if (!isSystem) {
_sendSeq++;
messageMap['seq'] = _sendSeq;
if (_database != null) _database!.logPacket(_sendSeq, jsonEncode(messageMap));
}
final jsonString = jsonEncode(messageMap);
if (messageMap['type'] != 'PING' && messageMap['type'] != 'PONG') {
if (messageMap['type'] == 'chat') {
_log("📤 전송: [CHAT]");
} else if (messageMap['type'] == 'media') {
_log("📤 전송: [MEDIA]");
} else {
_log("📤 전송: $jsonString");
}
if (!isSystem) {
if (type == 'chat') _log("📤 전송(#$_sendSeq): [CHAT]");
else if (type == 'media') _log("📤 전송(#$_sendSeq): [MEDIA]");
else _log("📤 전송(#$_sendSeq): $jsonString");
}
final fullMessage = '$jsonString$PACKET_DELIMITER';
@ -354,12 +294,10 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
try {
String buffer = _packetBuffers[socket] ?? "";
buffer += utf8.decode(data, allowMalformed: true);
while (buffer.contains(PACKET_DELIMITER)) {
final int delimiterIndex = buffer.indexOf(PACKET_DELIMITER);
final String msg = buffer.substring(0, delimiterIndex);
buffer = buffer.substring(delimiterIndex + PACKET_DELIMITER.length);
if (msg.trim().isNotEmpty) _processMessage(socket, msg);
}
_packetBuffers[socket] = buffer;
@ -368,35 +306,63 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
}
}
void _processMessage(Socket socket, String msg) {
void _processMessage(Socket? socket, String msg) {
try {
final Map<String, dynamic> jsonMap = jsonDecode(msg);
final String type = jsonMap['type'] ?? '';
if (jsonMap['type'] == 'PING') {
if (type == 'PING') {
sendMessage({'type': 'PONG'});
_lastPongTime = DateTime.now();
return;
}
if (jsonMap['type'] == 'PONG') {
if (type == 'PONG') {
_lastPongTime = DateTime.now();
return;
}
if (jsonMap['type'] == 'HANDSHAKE') {
if (type == 'HANDSHAKE') {
final guestInfo = UserInfo.fromJson(jsonMap['payload']);
if (role == NetworkRole.host) {
if (role == NetworkRole.host && socket != null) {
_connectedGuests[socket] = guestInfo;
}
guestList.removeWhere((u) => u.id == guestInfo.id);
guestList.add(guestInfo);
notifyListeners();
_messageController.add(jsonMap);
return;
}
if (jsonMap['type'] == 'TOGGLE_READY') {
if (type == 'GAME_CHANGED') {
if (jsonMap['gameId'] != null) {
selectedGameId = jsonMap['gameId'];
selectedGameConfig = jsonMap['config'] ?? {};
notifyListeners();
}
return;
}
if (type == 'REQ_RESEND') {
_handleResendRequest(jsonMap['from'], jsonMap['to']);
return;
}
if (type == 'RESEND_DATA') {
_processMessage(socket, jsonMap['data']);
return;
}
if (jsonMap.containsKey('seq')) {
int seq = jsonMap['seq'];
if (seq > _recvSeq + 1) {
_log("⚠️ 패킷 유실 감지! (기대: ${_recvSeq + 1}, 수신: $seq)");
sendMessage({
'type': 'REQ_RESEND',
'from': _recvSeq + 1,
'to': seq - 1
});
}
if (seq > _recvSeq) _recvSeq = seq;
}
if (type == 'TOGGLE_READY') {
final String userId = jsonMap['userId'];
final bool isReady = jsonMap['isReady'];
final index = guestList.indexWhere((u) => u.id == userId);
@ -412,14 +378,9 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
return;
}
if (jsonMap['type'] == 'GAME_START') {
if (jsonMap['gameId'] != null) {
selectedGameId = jsonMap['gameId'];
}
// [] Config
if (jsonMap['config'] != null) {
selectedGameConfig = jsonMap['config'];
}
if (type == 'GAME_START') {
if (jsonMap['gameId'] != null) selectedGameId = jsonMap['gameId'];
if (jsonMap['config'] != null) selectedGameConfig = jsonMap['config'];
_resetAllReadyState();
_messageController.add(jsonMap);
return;
@ -444,6 +405,22 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
}
}
Future<void> _handleResendRequest(int fromSeq, int toSeq) async {
if (_database == null) return;
final packets = await _database!.getPacketsInRange(fromSeq, toSeq);
for (var p in packets) {
final resendPacket = {'type': 'RESEND_DATA', 'data': p.payload};
final jsonString = jsonEncode(resendPacket);
final data = utf8.encode('$jsonString$PACKET_DELIMITER');
if (role == NetworkRole.host) {
for (var s in _connectedGuests.keys) s.add(data);
} else {
_clientSocket?.add(data);
}
}
}
void _handleConnectionLost(dynamic reason) {
if (role != NetworkRole.guest) return;
_clientSocket?.destroy();
@ -502,24 +479,88 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
return null;
}
void stopNetwork({bool force = false}) {
Future<void> stopNetwork({bool force = false}) async {
if (!force && _disconnectWaitTimer != null) return;
MediaManager().cleanup();
_heartbeatTimer?.cancel();
_disconnectWaitTimer?.cancel();
_disconnectWaitTimer = null;
_bonsoirBroadcast?.stop();
_bonsoirDiscovery?.stop();
_serverSocket?.close();
_clientSocket?.close();
for (var s in _connectedGuests.keys) s.close();
_connectedGuests.clear();
_bonsoirBroadcast?.stop();
_bonsoirDiscovery?.stop();
MediaManager().cleanup();
_heartbeatTimer?.cancel();
_disconnectWaitTimer?.cancel();
_disconnectWaitTimer = null;
_packetBuffers.clear();
guestList.clear();
role = NetworkRole.none;
_serverSocket = null;
_clientSocket = null;
if (force) { hostIp = null; hostPort = null; }
if (force) {
hostIp = null;
hostPort = null;
}
notifyListeners();
}
// ------------------------------------------------------------------------
//
// ------------------------------------------------------------------------
void selectGame(String gameId, {Map<String, dynamic>? config}) {
selectedGameId = gameId;
selectedGameConfig = config ?? {};
notifyListeners();
if (role == NetworkRole.host) {
sendMessage({
'type': 'GAME_CHANGED',
'gameId': gameId,
'config': selectedGameConfig
});
}
}
void toggleReady() {
me = me.copyWith(isReady: !me.isReady);
notifyListeners();
sendMessage({
'type': 'TOGGLE_READY',
'userId': me.id,
'isReady': me.isReady,
});
if (role == NetworkRole.host) _checkAllReadyAndStart();
}
void _checkAllReadyAndStart() {
if (guestList.isEmpty && role != NetworkRole.host) return;
if (!me.isReady) return;
bool allGuestsReady = guestList.every((u) => u.isReady);
if (allGuestsReady) {
_log("🚀 전원 준비 완료! 3초 후 게임 시작...");
Future.delayed(const Duration(seconds: 1), () {
final startPayload = {
'type': 'GAME_START',
'gameId': selectedGameId,
'config': selectedGameConfig
};
sendMessage(startPayload);
_messageController.add(startPayload);
_resetAllReadyState();
});
}
}
void _resetAllReadyState() {
me = me.copyWith(isReady: false);
for (int i = 0; i < guestList.length; i++) {
guestList[i] = guestList[i].copyWith(isReady: false);
}
notifyListeners();
}
}

View File

@ -28,4 +28,12 @@ export 'game/yutnori_game.dart';
export 'game/memory_game.dart';
export 'game/tap_battle_game.dart';
export 'game/balance_game.dart';
export 'game/balance_game.dart';
export 'game/world_tour_game.dart';
export 'game/othello_game.dart';
export 'game/arkanoid_game.dart';
export 'game/math_run_game.dart';
export 'game/jump_game.dart';
export 'game/iam_ground_game.dart';
export 'game/survivor_game.dart';
export 'game/sequence_memory_game.dart';

View File

@ -27,6 +27,10 @@ dependencies:
flutter_local_notifications: ^17.0.0
google_mobile_ads: ^5.0.0
audioplayers: ^6.0.0 # 여기로 이동
wifi_iot: ^0.3.19 # 와이파이 연결 및 정보 확인용
network_info_plus: ^5.0.1 # 게이트웨이(방장 IP) 확인용
nearby_connections: ^4.0.0
device_info_plus: ^10.1.0 # [추가] 기기 정보 확인용
dev_dependencies:
drift_dev: ^2.13.0