2025-11-24 17:53:00 +09:00
import ' dart:async ' ;
2025-11-25 17:25:16 +09:00
import ' dart:math ' ;
2025-11-24 17:53:00 +09:00
import ' package:flutter/material.dart ' ;
import ' package:playwith_core/playwith_core.dart ' ;
2025-11-25 17:25:16 +09:00
import ' model/quiz_model.dart ' ; // QuizSet, QuizItem 모델 필요
2025-11-24 17:53:00 +09:00
2025-11-25 17:25:16 +09:00
// --- Enums ---
2025-11-24 17:53:00 +09:00
enum PlayerStatus { alive , dead , winner , loser }
2025-11-25 17:25:16 +09:00
enum GamePhase { voteRule , voteInput , playing , result }
enum InputMode { touch , voice }
enum GameRule { survival , suddenDeath , scoreAttack , relay }
2025-11-24 17:53:00 +09:00
class QuizGame extends BaseGame {
@ override
2025-11-25 17:25:16 +09:00
String get id = > " quiz_mix " ; // main.dart 등록 ID와 일치해야 함
2025-11-24 17:53:00 +09:00
@ override
2025-11-25 17:25:16 +09:00
String get name = > " 멀티 모드 퀴즈 " ;
2025-11-24 17:53:00 +09:00
@ override
2025-11-25 17:25:16 +09:00
String get description = > " 투표로 룰을 정하고 승리하세요! " ;
2025-11-24 17:53:00 +09:00
// ------------------------------------------------------------------------
// 상태 변수
// ------------------------------------------------------------------------
final _gameStateController = StreamController < Map < String , dynamic > > . broadcast ( ) ;
Stream < Map < String , dynamic > > get gameStateStream = > _gameStateController . stream ;
Map < String , dynamic > ? _lastState ;
2025-11-25 17:25:16 +09:00
// 진행 상태
GamePhase _phase = GamePhase . voteRule ;
GameRule _selectedRule = GameRule . survival ;
InputMode _selectedInputMode = InputMode . touch ;
// 데이터
2025-11-25 16:34:13 +09:00
final Set < String > _aliveUsers = { } ;
final Set < String > _answeredUsers = { } ;
2025-11-25 17:25:16 +09:00
final Map < String , int > _scores = { } ;
final Map < String , String > _votes = { } ;
// 릴레이 모드 전용
List < String > _turnOrder = [ ] ;
int _currentTurnIndex = 0 ;
2025-11-24 17:53:00 +09:00
2025-11-25 17:25:16 +09:00
// 내 상태
2025-11-24 17:53:00 +09:00
PlayerStatus _myStatus = PlayerStatus . alive ;
String ? _mySelectedAnswer ;
bool _isLockedIn = false ;
Timer ? _lockInTimer ;
2025-11-25 17:25:16 +09:00
// UI 상태
2025-11-24 17:53:00 +09:00
bool _isCountingDown = false ;
int _countdownValue = 3 ;
2025-11-25 16:34:13 +09:00
bool _isShowingResult = false ;
2025-11-25 17:25:16 +09:00
// 문제 데이터
List < QuizItem > _questions = [ ] ;
2025-11-24 17:53:00 +09:00
int _currentQuestionIndex = - 1 ;
// ------------------------------------------------------------------------
// 라이프사이클
// ------------------------------------------------------------------------
@ override
void onStart ( ) {
2025-11-25 17:25:16 +09:00
super . onStart ( ) ;
2025-11-24 17:53:00 +09:00
print ( " Quiz Game Started! " ) ;
2025-11-25 17:25:16 +09:00
_resetGame ( ) ;
2025-11-25 16:34:13 +09:00
2025-11-25 17:25:16 +09:00
// 문제 로드 (QuizModel이 없다면 하드코딩된 리스트 사용 가능)
try {
_questions = QuizSet . getDummy10 ( ) ;
} 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 " ] ) ,
] ;
2025-11-24 17:53:00 +09:00
}
2025-11-25 17:25:16 +09:00
// [Host] 1단계: 룰 투표 시작
2025-11-24 17:53:00 +09:00
if ( NetworkManager ( ) . role = = NetworkRole . host ) {
2025-11-25 17:25:16 +09:00
Future . delayed ( const Duration ( milliseconds: 1000 ) , ( ) {
_broadcastState ( { ' type ' : ' PHASE_CHANGE ' , ' phase ' : ' VOTE_RULE ' } ) ;
2025-11-24 17:53:00 +09:00
} ) ;
}
}
2025-11-25 17:25:16 +09:00
void _resetGame ( ) {
_phase = GamePhase . voteRule ;
_lastState = null ;
_aliveUsers . clear ( ) ;
_scores . clear ( ) ;
_votes . clear ( ) ;
_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 ;
}
2025-11-24 17:53:00 +09:00
@ override
void onDispose ( ) {
_lockInTimer ? . cancel ( ) ;
_gameStateController . close ( ) ;
2025-11-25 16:34:13 +09:00
super . onDispose ( ) ;
2025-11-24 17:53:00 +09:00
}
// ------------------------------------------------------------------------
2025-11-25 17:25:16 +09:00
// 메시지 처리 (Logic Hub)
2025-11-24 17:53:00 +09:00
// ------------------------------------------------------------------------
@ override
void onMessageReceived ( String senderId , Map < String , dynamic > payload ) {
2025-11-25 17:25:16 +09:00
if ( ! [ ' ANSWER_SUBMIT ' , ' VOTE_SUBMIT ' ] . contains ( payload [ ' type ' ] ) ) {
_lastState = payload ;
2025-11-24 17:53:00 +09:00
}
2025-11-25 17:25:16 +09:00
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 ;
2025-11-24 17:53:00 +09:00
}
2025-11-25 17:25:16 +09:00
}
2025-11-24 17:53:00 +09:00
2025-11-25 17:25:16 +09:00
// --- Handlers ---
2025-11-24 17:53:00 +09:00
2025-11-25 17:25:16 +09:00
void _handlePhaseChange ( Map < String , dynamic > payload ) {
final phaseStr = payload [ ' phase ' ] ;
if ( phaseStr = = ' VOTE_RULE ' ) _phase = GamePhase . voteRule ;
else if ( phaseStr = = ' VOTE_INPUT ' ) {
_phase = GamePhase . voteInput ;
_selectedRule = GameRule . values . firstWhere ( ( e ) = > e . name = = payload [ ' rule ' ] , orElse: ( ) = > GameRule . survival ) ;
}
else if ( phaseStr = = ' PLAYING ' ) {
_phase = GamePhase . playing ;
_selectedInputMode = payload [ ' inputMode ' ] = = ' voice ' ? InputMode . voice : InputMode . touch ;
if ( _selectedRule = = GameRule . relay ) {
_turnOrder = List < String > . from ( payload [ ' turnOrder ' ] ? ? [ ] ) ;
_currentTurnIndex = 0 ;
}
}
_gameStateController . add ( payload ) ;
_votes . clear ( ) ;
}
2025-11-24 17:53:00 +09:00
2025-11-25 17:25:16 +09:00
void _handleVoteSubmit ( Map < String , dynamic > payload ) {
if ( NetworkManager ( ) . role ! = NetworkRole . host ) return ;
_votes [ payload [ ' userId ' ] ] = payload [ ' vote ' ] ;
2025-11-24 17:53:00 +09:00
2025-11-25 17:25:16 +09:00
// 전원 투표 완료 체크
// (중간에 나간 사람 고려하여 aliveUsers 기준으로 체크하거나 타임아웃 필요. MVP는 단순 크기 비교)
if ( _votes . length > = _aliveUsers . length ) {
if ( _phase = = GamePhase . voteRule ) _decideRule ( ) ;
else if ( _phase = = GamePhase . voteInput ) _decideInputAndStart ( ) ;
}
}
2025-11-24 17:53:00 +09:00
2025-11-25 17:25:16 +09:00
void _handleCountdown ( Map < String , dynamic > payload ) {
_isShowingResult = false ;
_isCountingDown = true ;
_countdownValue = payload [ ' count ' ] ;
if ( _countdownValue > 0 ) SoundManager ( ) . playSfx ( SoundKey . click ) ;
_gameStateController . add ( payload ) ;
}
2025-11-24 17:53:00 +09:00
2025-11-25 17:25:16 +09:00
void _handleAnswerSubmit ( Map < String , dynamic > payload ) {
if ( NetworkManager ( ) . role ! = NetworkRole . host ) return ;
2025-11-24 17:53:00 +09:00
2025-11-25 17:25:16 +09:00
final String userId = payload [ ' userId ' ] ;
final String answer = payload [ ' answer ' ] ;
2025-11-24 17:53:00 +09:00
2025-11-25 17:25:16 +09:00
if ( _answeredUsers . contains ( userId ) ) return ;
if ( _selectedRule = = GameRule . relay & & _turnOrder [ _currentTurnIndex ] ! = userId ) return ;
2025-11-24 17:53:00 +09:00
2025-11-25 17:25:16 +09:00
_answeredUsers . add ( userId ) ;
final currentQ = _questions [ _currentQuestionIndex ] ;
bool isCorrect = false ;
if ( _selectedInputMode = = InputMode . voice ) {
isCorrect = VoiceManager ( ) . checkAnswer ( answer , currentQ . answer ) ;
} else {
isCorrect = ( answer = = currentQ . answer ) ;
2025-11-24 17:53:00 +09:00
}
2025-11-25 17:25:16 +09:00
// 룰별 탈락 처리
if ( _selectedRule = = GameRule . scoreAttack ) {
if ( isCorrect ) _scores [ userId ] = ( _scores [ userId ] ? ? 0 ) + 1 ;
} else {
if ( ! isCorrect ) {
2025-11-25 16:34:13 +09:00
_aliveUsers . remove ( userId ) ;
2025-11-25 17:25:16 +09:00
NetworkManager ( ) . sendMessage ( { ' type ' : ' PLAYER_ELIMINATED ' , ' targetUserId ' : userId } ) ;
if ( userId = = NetworkManager ( ) . me . id ) _handleLocalElimination ( ) ;
if ( _selectedRule = = GameRule . suddenDeath | | _selectedRule = = GameRule . relay ) {
_broadcastState ( { ' type ' : ' PLAYER_STATUS_UPDATE ' , ' userId ' : userId , ' isSubmitted ' : true , ' isAlive ' : false } ) ;
Future . delayed ( const Duration ( milliseconds: 1000 ) , ( ) = > _finishGame ( winnerId: null ) ) ;
return ;
}
2025-11-25 16:34:13 +09:00
}
2025-11-24 17:53:00 +09:00
}
2025-11-25 17:25:16 +09:00
_broadcastState ( {
' type ' : ' PLAYER_STATUS_UPDATE ' ,
' userId ' : userId ,
' isSubmitted ' : true ,
' isAlive ' : _aliveUsers . contains ( userId ) ,
' score ' : _scores [ userId ]
} ) ;
2025-11-25 16:34:13 +09:00
2025-11-25 17:25:16 +09:00
// 다음 진행 판단
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 ;
2025-11-24 17:53:00 +09:00
}
2025-11-25 17:25:16 +09:00
if ( shouldAdvance ) {
Future . delayed ( const Duration ( milliseconds: 1000 ) , ( ) = > _showRoundResultAndNext ( ) ) ;
2025-11-24 17:53:00 +09:00
}
}
2025-11-25 17:25:16 +09:00
void _handleStatusUpdate ( Map < String , dynamic > payload ) {
_answeredUsers . add ( payload [ ' userId ' ] ) ;
if ( payload [ ' isAlive ' ] = = false ) _aliveUsers . remove ( payload [ ' userId ' ] ) ;
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 ( ) {
2025-11-24 17:53:00 +09:00
SoundManager ( ) . playSfx ( SoundKey . wrong ) ;
_myStatus = PlayerStatus . dead ;
}
2025-11-25 17:25:16 +09:00
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 ( ) ;
}
_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 ) {
_myStatus = PlayerStatus . winner ;
SoundManager ( ) . playSfx ( SoundKey . win ) ;
} else {
_myStatus = PlayerStatus . loser ;
if ( winnerId = = ' ALL_LOSE ' ) SoundManager ( ) . playSfx ( SoundKey . wrong ) ;
}
_gameStateController . add ( payload ) ;
}
2025-11-24 17:53:00 +09:00
// ------------------------------------------------------------------------
2025-11-25 17:25:16 +09:00
// [Host Logic]
2025-11-24 17:53:00 +09:00
// ------------------------------------------------------------------------
2025-11-25 17:25:16 +09:00
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 ;
_selectedRule = GameRule . values . firstWhere ( ( e ) = > e . name = = topRule , orElse: ( ) = > GameRule . survival ) ;
_broadcastState ( {
' type ' : ' PHASE_CHANGE ' ,
' phase ' : ' VOTE_INPUT ' ,
' rule ' : _selectedRule . name
} ) ;
}
void _decideInputAndStart ( ) {
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 ;
List < String > ? turnOrder ;
if ( _selectedRule = = GameRule . relay ) {
turnOrder = _aliveUsers . toList ( ) . . shuffle ( ) ;
}
_broadcastState ( {
' type ' : ' PHASE_CHANGE ' ,
' phase ' : ' PLAYING ' ,
' inputMode ' : mode . name ,
' turnOrder ' : turnOrder
} ) ;
_selectedInputMode = mode ;
_turnOrder = turnOrder ? ? [ ] ;
_phase = GamePhase . playing ;
Future . delayed ( const Duration ( seconds: 2 ) , ( ) = > _startCountdownSequence ( ) ) ;
}
2025-11-25 16:34:13 +09:00
void _showRoundResultAndNext ( ) {
2025-11-24 17:53:00 +09:00
final currentQ = _questions [ _currentQuestionIndex ] ;
2025-11-25 17:25:16 +09:00
int nextTurn = _currentTurnIndex ;
if ( _selectedRule = = GameRule . relay ) {
nextTurn = ( _currentTurnIndex + 1 ) % _aliveUsers . length ;
}
_broadcastState ( {
2025-11-24 17:53:00 +09:00
' type ' : ' ROUND_RESULT ' ,
' status ' : ' RESULT ' ,
2025-11-25 17:25:16 +09:00
' correctAnswer ' : currentQ . answer ,
2025-11-25 16:34:13 +09:00
' survivors ' : _aliveUsers . toList ( ) ,
2025-11-25 17:25:16 +09:00
' scores ' : _scores ,
' nextTurnIndex ' : nextTurn
2025-11-24 17:53:00 +09:00
} ) ;
2025-11-25 17:25:16 +09:00
_currentTurnIndex = nextTurn ;
Future . delayed ( const Duration ( seconds: 3 ) , ( ) = > _checkWinnerAndNext ( ) ) ;
2025-11-24 17:53:00 +09:00
}
2025-11-25 16:34:13 +09:00
void _checkWinnerAndNext ( ) {
2025-11-25 17:25:16 +09:00
int totalPlayers = NetworkManager ( ) . guestList . length + 1 ;
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 ;
}
} else {
winnerId = _aliveUsers . isNotEmpty ? _aliveUsers . first : null ;
}
}
else if ( _selectedRule ! = GameRule . scoreAttack & & _aliveUsers . length < = 1 ) {
if ( _aliveUsers . isNotEmpty ) {
isEnd = true ;
winnerId = _aliveUsers . first ;
} else {
isEnd = true ;
winnerId = null ;
}
2025-11-24 17:53:00 +09:00
}
2025-11-25 17:25:16 +09:00
if ( isEnd ) {
_finishGame ( winnerId: winnerId ) ;
} else {
_startCountdownSequence ( ) ;
}
2025-11-25 16:34:13 +09:00
}
2025-11-25 17:25:16 +09:00
void _startCountdownSequence ( ) {
2025-11-25 16:34:13 +09:00
int count = 3 ;
Timer . periodic ( const Duration ( seconds: 1 ) , ( timer ) {
_broadcastState ( { ' type ' : ' GAME_COUNTDOWN ' , ' count ' : count } ) ;
if ( count = = 0 ) {
timer . cancel ( ) ;
2025-11-25 17:25:16 +09:00
_sendNewQuestion ( ) ;
2025-11-25 16:34:13 +09:00
}
count - - ;
} ) ;
}
void _sendNewQuestion ( ) {
2025-11-24 17:53:00 +09:00
_currentQuestionIndex + + ;
2025-11-25 17:25:16 +09:00
final qData = _questions [ _currentQuestionIndex ] ;
2025-11-24 17:53:00 +09:00
_resetLocalState ( ) ;
2025-11-25 17:25:16 +09:00
_broadcastState ( { ' type ' : ' GAME_STATE_UPDATE ' , ' status ' : ' QUESTION ' , ' data ' : qData . toJson ( ) } ) ;
2025-11-24 17:53:00 +09:00
}
void _finishGame ( { String ? winnerId } ) {
final endData = {
' type ' : ' GAME_OVER ' ,
' winnerId ' : winnerId ? ? ' NONE ' ,
' winnerName ' : _findUserName ( winnerId )
} ;
_broadcastState ( endData ) ;
}
void _broadcastState ( Map < String , dynamic > data ) {
_lastState = data ;
_gameStateController . add ( data ) ;
2025-11-25 17:25:16 +09:00
if ( NetworkManager ( ) . role = = NetworkRole . host ) NetworkManager ( ) . sendMessage ( data ) ;
2025-11-24 17:53:00 +09:00
}
void _resetLocalState ( ) {
_answeredUsers . clear ( ) ;
_mySelectedAnswer = null ;
_isLockedIn = false ;
_lockInTimer ? . cancel ( ) ;
}
String _findUserName ( String ? id ) {
if ( id = = null ) return ' 없음 ' ;
if ( id = = NetworkManager ( ) . me . id ) return NetworkManager ( ) . me . nickname ;
return NetworkManager ( ) . guestList . firstWhere ( ( u ) = > u . id = = id , orElse: ( ) = > UserInfo ( id: ' ' , nickname: ' Unknown ' ) ) . nickname ;
}
// ------------------------------------------------------------------------
2025-11-25 17:25:16 +09:00
// [UI]
2025-11-24 17:53:00 +09:00
// ------------------------------------------------------------------------
@ override
2025-11-25 17:25:16 +09:00
Widget buildHostView ( BuildContext context ) = > _buildScreen ( context , true ) ;
2025-11-24 17:53:00 +09:00
@ override
2025-11-25 17:25:16 +09:00
Widget buildGuestView ( BuildContext context ) = > _buildScreen ( context , false ) ;
2025-11-24 17:53:00 +09:00
2025-11-25 17:25:16 +09:00
Widget _buildScreen ( BuildContext context , bool isHost ) {
2025-11-24 17:53:00 +09:00
return Scaffold (
appBar: AppBar (
2025-11-25 17:25:16 +09:00
title: const Text ( " PlayWith 퀴즈 " ) ,
centerTitle: true ,
2025-11-24 17:53:00 +09:00
automaticallyImplyLeading: false ,
actions: [
2025-11-25 16:34:13 +09:00
if ( isHost ) IconButton ( icon: const Icon ( Icons . close ) , onPressed: ( ) = > _confirmExit ( context ) )
2025-11-24 17:53:00 +09:00
] ,
) ,
2025-11-25 17:25:16 +09:00
body: Padding (
padding: const EdgeInsets . only ( bottom: 140.0 ) ,
child: StreamBuilder < Map < String , dynamic > > (
stream: gameStateStream ,
initialData: _lastState ,
builder: ( context , snapshot ) {
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 ) ;
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 ( ) ;
return _buildPlayArea ( context , qData ) ;
}
return _buildWaitingScreen ( " 준비 중... " ) ;
} ,
) ,
2025-11-24 17:53:00 +09:00
) ,
) ;
}
2025-11-25 17:25:16 +09:00
// --- 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 _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 ' ) ) ,
] ,
) ,
] ,
) ,
) ;
}
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 ) ;
}
Widget _buildPlayArea ( BuildContext context , Map < String , dynamic > qData ) {
bool isMyTurn = true ;
String currentTurnName = " " ;
if ( _selectedRule = = GameRule . relay ) {
String currentUserId = _turnOrder . isNotEmpty ? _turnOrder [ _currentTurnIndex ] : " " ;
isMyTurn = currentUserId = = NetworkManager ( ) . me . id ;
currentTurnName = _findUserName ( currentUserId ) ;
}
if ( _myStatus = = PlayerStatus . dead & & _selectedRule ! = GameRule . scoreAttack ) {
return Center ( child: Column ( mainAxisAlignment: MainAxisAlignment . center , children: [ const Icon ( Icons . sentiment_dissatisfied , size: 70 , color: Colors . grey ) , const SizedBox ( height: 10 ) , const Text ( " 탈락했습니다 👻 " , style: TextStyle ( fontSize: 24 , fontWeight: FontWeight . bold ) ) , const SizedBox ( height: 20 ) , Text ( " 문제: ${ qData [ ' question ' ] } " , style: const TextStyle ( color: Colors . grey ) ) ] ) ) ;
2025-11-24 17:53:00 +09:00
}
return Column (
children: [
2025-11-25 17:25:16 +09:00
Container (
padding: const EdgeInsets . all ( 10 ) ,
color: Colors . grey [ 100 ] ,
child: _selectedRule = = GameRule . scoreAttack
? _ScoreBoard ( scores: _scores )
: _PlayerStatusGrid ( aliveUsers: _aliveUsers , answeredUsers: _answeredUsers ) ,
2025-11-25 16:34:13 +09:00
) ,
2025-11-25 17:25:16 +09:00
if ( _selectedRule = = GameRule . relay )
Container (
width: double . infinity ,
padding: const EdgeInsets . all ( 8 ) ,
color: isMyTurn ? Colors . blueAccent : Colors . grey [ 300 ] ,
child: Text ( isMyTurn ? " 내 차례입니다! " : " $ currentTurnName님의 차례 " , textAlign: TextAlign . center , style: TextStyle ( color: isMyTurn ? Colors . white : Colors . black , fontWeight: FontWeight . bold ) ) ,
) ,
2025-11-25 16:34:13 +09:00
2025-11-25 17:25:16 +09:00
const Divider ( height: 1 ) ,
2025-11-24 17:53:00 +09:00
Expanded (
flex: 4 ,
2025-11-25 17:25:16 +09:00
child: Center ( child: Padding ( padding: const EdgeInsets . all ( 20 ) , child: Text ( qData [ ' question ' ] , textAlign: TextAlign . center , style: const TextStyle ( fontSize: 28 , fontWeight: FontWeight . bold ) ) ) ) ,
2025-11-24 17:53:00 +09:00
) ,
2025-11-25 16:34:13 +09:00
2025-11-24 17:53:00 +09:00
Expanded (
flex: 3 ,
2025-11-25 17:25:16 +09:00
child: ! isMyTurn
? const Center ( child: Text ( " 다른 사람이 푸는 중... " , style: TextStyle ( fontSize: 18 , color: Colors . grey ) ) )
: ( _selectedInputMode = = InputMode . touch
? _buildTouchInput ( qData [ ' options ' ] ! = null ? List < String > . from ( qData [ ' options ' ] ) : [ " O " , " X " ] )
: _buildVoiceInput ( ) ) ,
2025-11-24 17:53:00 +09:00
) ,
] ,
) ;
}
2025-11-25 17:25:16 +09:00
Widget _buildTouchInput ( List < String > options ) {
if ( _isLockedIn ) return _buildLockedUI ( ) ;
return Center ( child: Wrap ( spacing: 20 , runSpacing: 20 , alignment: WrapAlignment . center , children: options . map ( ( opt ) = > _AnswerBtn ( text: opt , color: Colors . blueAccent , isSelected: _mySelectedAnswer = = opt , onTap: ( ) = > _selectAnswer ( opt ) ) ) . toList ( ) ) ) ;
}
Widget _buildVoiceInput ( ) {
if ( _isLockedIn ) return _buildLockedUI ( ) ;
return Column ( mainAxisAlignment: MainAxisAlignment . center , children: [ VoiceWidget ( isListening: VoiceManager ( ) . isListening ) , const SizedBox ( height: 20 ) , GestureDetector ( onLongPressStart: ( _ ) async { await VoiceManager ( ) . startListening ( onResult: ( text ) { } ) ; } , onLongPressEnd: ( _ ) async { await VoiceManager ( ) . stopListening ( ) ; _selectAnswer ( " O " ) ; } , child: Container ( padding: const EdgeInsets . all ( 20 ) , decoration: const BoxDecoration ( color: Colors . redAccent , shape: BoxShape . circle ) , child: const Icon ( Icons . mic , size: 40 , color: Colors . white ) ) ) , const SizedBox ( height: 10 ) , const Text ( " 버튼을 누르고 정답을 말하세요! " , style: TextStyle ( color: Colors . grey ) ) ] ) ;
}
Widget _buildLockedUI ( ) {
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 ) ) ] ) ) ;
}
2025-11-25 16:34:13 +09:00
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 ,
2025-11-25 17:25:16 +09:00
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 ) ) ) ,
2025-11-25 16:34:13 +09:00
) ,
const SizedBox ( height: 40 ) ,
2025-11-25 17:25:16 +09:00
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 ) ) ,
2025-11-24 17:53:00 +09:00
] ,
) ,
) ;
}
2025-11-25 17:25:16 +09:00
// Helper methods
2025-11-24 17:53:00 +09:00
void _selectAnswer ( String answer ) {
_lockInTimer ? . cancel ( ) ;
_mySelectedAnswer = answer ;
SoundManager ( ) . playSfx ( SoundKey . click ) ;
2025-11-25 16:34:13 +09:00
_gameStateController . add ( { ' type ' : ' UI_REFRESH ' } ) ;
_lockInTimer = Timer ( const Duration ( seconds: 3 ) , ( ) { _submitFinalAnswer ( ) ; } ) ;
2025-11-24 17:53:00 +09:00
}
2025-11-25 16:34:13 +09:00
2025-11-24 17:53:00 +09:00
void _submitFinalAnswer ( ) {
if ( _mySelectedAnswer = = null ) return ;
_isLockedIn = true ;
2025-11-25 16:34:13 +09:00
_gameStateController . add ( { ' type ' : ' UI_REFRESH ' } ) ;
final payload = { ' type ' : ' ANSWER_SUBMIT ' , ' answer ' : _mySelectedAnswer , ' userId ' : NetworkManager ( ) . me . id } ;
if ( NetworkManager ( ) . role = = NetworkRole . host ) { onMessageReceived ( " " , payload ) ; } else { NetworkManager ( ) . sendMessage ( payload ) ; }
2025-11-24 17:53:00 +09:00
}
Widget _buildResultScreen ( BuildContext context , String winnerName ) {
2025-11-25 17:25:16 +09:00
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 ( " 로비로 돌아가기 " ) ) ] ) ) ;
2025-11-24 17:53:00 +09:00
}
2025-11-25 16:34:13 +09:00
2025-11-24 17:53:00 +09:00
Widget _buildWaitingScreen ( String msg ) = > Center ( child: Column ( mainAxisAlignment: MainAxisAlignment . center , children: [ const CircularProgressIndicator ( ) , SizedBox ( height: 20 ) , Text ( msg ) ] ) ) ;
2025-11-25 16:34:13 +09:00
2025-11-24 17:53:00 +09:00
void _confirmExit ( BuildContext context ) {
2025-11-25 17:25:16 +09:00
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 ) ) ) ] ) ) ;
}
}
// [Components]
class _ScoreBoard extends StatelessWidget {
final Map < String , int > scores ;
const _ScoreBoard ( { required this . scores } ) ;
@ 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 ) ) ] ) ) ;
} ,
) ,
) ;
2025-11-24 17:53:00 +09:00
}
}
class _PlayerStatusGrid extends StatelessWidget {
2025-11-25 17:25:16 +09:00
final Set < String > aliveUsers ; final Set < String > answeredUsers ;
2025-11-24 17:53:00 +09:00
const _PlayerStatusGrid ( { required this . aliveUsers , required this . answeredUsers } ) ;
@ override
Widget build ( BuildContext context ) {
final allUsers = [ NetworkManager ( ) . me , . . . NetworkManager ( ) . guestList ] ;
2025-11-25 16:34:13 +09:00
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 ) ) ] ) ) ; } ) ) ;
2025-11-24 17:53:00 +09:00
}
}
2025-11-25 17:25:16 +09:00
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 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 ) ) ] ) ) ;
}
}
2025-11-24 17:53:00 +09:00
class _AnswerBtn extends StatelessWidget {
2025-11-25 16:34:13 +09:00
final String text ; final Color color ; final bool isSelected ; final VoidCallback onTap ;
2025-11-24 17:53:00 +09:00
const _AnswerBtn ( { required this . text , required this . color , required this . isSelected , required this . onTap } ) ;
@ override
Widget build ( BuildContext context ) {
2025-11-25 17:25:16 +09:00
return GestureDetector ( onTap: onTap , child: AnimatedContainer ( duration: const Duration ( milliseconds: 200 ) , width: isSelected ? 140 : 120 , height: isSelected ? 140 : 120 , decoration: BoxDecoration ( color: color . withOpacity ( isSelected ? 1.0 : 0.6 ) , shape: BoxShape . circle , border: isSelected ? Border . all ( color: Colors . white , width: 5 ) : null , boxShadow: [ BoxShadow ( color: color . withOpacity ( 0.4 ) , blurRadius: 10 , offset: const Offset ( 0 , 6 ) ) ] ) , child: Center ( child: Text ( text , style: const TextStyle ( fontSize: 30 , color: Colors . white , fontWeight: FontWeight . bold ) ) ) ) ) ;
2025-11-24 17:53:00 +09:00
}
}