..
This commit is contained in:
parent
bf40c42c2c
commit
22caf64855
@ -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" />
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
8
apps/app/ios/Runner/Runner.entitlements
Normal file
8
apps/app/ios/Runner/Runner.entitlements
Normal 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>
|
||||
@ -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();
|
||||
|
||||
@ -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"))
|
||||
|
||||
@ -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
136
apps/app/macos/Podfile.lock
Normal 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
|
||||
@ -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;
|
||||
|
||||
@ -4,4 +4,7 @@
|
||||
<FileRef
|
||||
location = "group:Runner.xcodeproj">
|
||||
</FileRef>
|
||||
<FileRef
|
||||
location = "group:Pods/Pods.xcodeproj">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
265
packages/core/lib/game/arkanoid_game.dart
Normal file
265
packages/core/lib/game/arkanoid_game.dart
Normal 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;
|
||||
}
|
||||
578
packages/core/lib/game/iam_ground_game.dart
Normal file
578
packages/core/lib/game/iam_ground_game.dart
Normal 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("나가기")),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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(
|
||||
|
||||
239
packages/core/lib/game/jump_game.dart
Normal file
239
packages/core/lib/game/jump_game.dart
Normal 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;
|
||||
}
|
||||
297
packages/core/lib/game/math_run_game.dart
Normal file
297
packages/core/lib/game/math_run_game.dart
Normal 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;
|
||||
}
|
||||
325
packages/core/lib/game/othello_game.dart
Normal file
325
packages/core/lib/game/othello_game.dart
Normal 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); }
|
||||
414
packages/core/lib/game/sequence_memory_game.dart
Normal file
414
packages/core/lib/game/sequence_memory_game.dart
Normal 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
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
),
|
||||
);
|
||||
|
||||
@ -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 }
|
||||
418
packages/core/lib/game/survivor_game.dart
Normal file
418
packages/core/lib/game/survivor_game.dart
Normal 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;
|
||||
}
|
||||
495
packages/core/lib/game/world_tour_game.dart
Normal file
495
packages/core/lib/game/world_tour_game.dart
Normal 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;
|
||||
}
|
||||
@ -40,6 +40,7 @@ class MediaManager {
|
||||
MediaManager._internal();
|
||||
|
||||
EphemeralDatabase? _db;
|
||||
EphemeralDatabase? get db => _db;
|
||||
String? _currentRoomId;
|
||||
|
||||
final Map<String, _TransferState> _activeTransfers = {};
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
28
packages/core/lib/model/spider_game_dto.dart
Normal file
28
packages/core/lib/model/spider_game_dto.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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';
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user