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