This commit is contained in:
2025-11-26 18:10:10 +09:00
parent 283f08786e
commit bf40c42c2c
43 changed files with 4454 additions and 971 deletions
+230
View 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
View 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
View 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
View 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;
}
+784
View File
@@ -0,0 +1,784 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:playwith_core/playwith_core.dart';
import '../model/quiz_model.dart';
enum PlayerStatus { alive, dead, winner, loser }
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";
@override
String get name => "멀티 모드 퀴즈";
@override
String get description => "다함께 투표하고 퀴즈를 풀어보세요!";
// ------------------------------------------------------------------------
// 상태 변수
// ------------------------------------------------------------------------
final _gameStateController = StreamController<Map<String, dynamic>>.broadcast();
Stream<Map<String, dynamic>> get gameStateStream => _gameStateController.stream;
Map<String, dynamic>? _lastState;
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 = {};
List<String> _turnOrder = [];
int _currentTurnIndex = 0;
PlayerStatus _myStatus = PlayerStatus.alive;
String? _mySelectedAnswer;
bool _isLockedIn = false;
bool _isCountingDown = false;
int _countdownValue = 3;
bool _isShowingResult = false;
List<QuizItem> _masterQuestions = [];
final Set<String> _selectedCategories = {};
List<QuizItem> _questions = [];
int _currentQuestionIndex = -1;
Timer? _hostQuestionTimer;
// ------------------------------------------------------------------------
// 라이프사이클
// ------------------------------------------------------------------------
@override
void onStart() {
super.onStart();
print("Quiz Game Started!");
_resetGame();
try {
_masterQuestions = QuizSet.getStandard50();
} catch (e) {
_masterQuestions = [QuizItem(type: QuizType.text, category: "기타", question: "Error", answer: "O", options: ["O","X"])];
}
_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: 500), () {
_broadcastState({'type': 'PHASE_CHANGE', 'phase': 'SELECT_CATEGORY'});
});
}
}
void _resetGame() {
_phase = GamePhase.selectCategory;
_lastState = null;
_aliveUsers.clear();
_scores.clear();
_votes.clear();
_turnOrder.clear();
_currentQuestionIndex = -1;
_resetLocalState();
_myStatus = PlayerStatus.alive;
_hostQuestionTimer?.cancel();
}
@override
void onDispose() {
_hostQuestionTimer?.cancel();
_gameStateController.close();
super.onDispose();
}
// ------------------------------------------------------------------------
// 메시지 처리
// ------------------------------------------------------------------------
@override
void onMessageReceived(String senderId, Map<String, dynamic> payload) {
if (!['ANSWER_SUBMIT', 'VOTE_SUBMIT'].contains(payload['type'])) {
_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 'system_message':
_gameStateController.add(payload);
break;
}
}
// ------------------------------------------------------------------------
// Handlers
// ------------------------------------------------------------------------
void _handlePhaseChange(Map<String, dynamic> payload) {
final phaseStr = payload['phase'];
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;
_selectedTimeLimit = payload['timeLimit'] ?? 5;
if (_selectedRule == GameRule.relay) {
_turnOrder = List<String>.from(payload['turnOrder'] ?? []);
_currentTurnIndex = 0;
}
}
_gameStateController.add(payload);
}
void _handleVoteSubmit(Map<String, dynamic> payload) {
final uid = payload['userId'];
_votes[uid] = payload['vote'];
_gameStateController.add({'type': 'UI_REFRESH'});
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();
}
}
}
}
void _handleCountdown(Map<String, dynamic> payload) {
_isShowingResult = false;
_isCountingDown = true;
_countdownValue = payload['count'];
if (_countdownValue > 0) SoundManager().playSfx(SoundKey.click);
_gameStateController.add(payload);
}
void _handleAnswerSubmit(Map<String, dynamic> payload) {
if (NetworkManager().role != NetworkRole.host) return;
final String userId = payload['userId'];
final String answer = payload['answer'];
if (_answeredUsers.contains(userId)) return;
if (_selectedRule == GameRule.relay && _turnOrder[_currentTurnIndex] != userId) return;
_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);
}
if (_selectedRule == GameRule.scoreAttack) {
if (isCorrect) _scores[userId] = (_scores[userId] ?? 0) + 1;
} else {
if (!isCorrect) {
_aliveUsers.remove(userId);
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;
}
}
}
_broadcastState({
'type': 'PLAYER_STATUS_UPDATE',
'userId': userId,
'isSubmitted': true,
'isAlive': _aliveUsers.contains(userId),
'score': _scores[userId]
});
int targetCount = _selectedRule == GameRule.scoreAttack
? NetworkManager().guestList.length + 1
: _aliveUsers.length + (isCorrect ? 0 : 1);
if (_selectedRule == GameRule.relay) targetCount = 1;
if (_answeredUsers.length >= targetCount) {
_hostQuestionTimer?.cancel();
Future.delayed(const Duration(milliseconds: 1000), () => _showRoundResultAndNext());
}
}
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 _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);
}
void _handleLocalElimination() {
SoundManager().playSfx(SoundKey.wrong);
_myStatus = PlayerStatus.dead;
}
// ------------------------------------------------------------------------
// [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 = '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);
_broadcastState({
'type': 'PHASE_CHANGE',
'phase': 'VOTE_INPUT',
'rule': _selectedRule.name
});
}
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();
}
_broadcastState({
'type': 'PHASE_CHANGE',
'phase': 'PLAYING',
'timeLimit': timeLimit,
'turnOrder': turnOrder
});
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',
'correctAnswer': currentQ.answer,
'survivors': _aliveUsers.toList(),
'scores': _scores,
'nextTurnIndex': nextTurn
});
_currentTurnIndex = nextTurn;
Future.delayed(const Duration(seconds: 3), () => _checkWinnerAndNext());
}
void _checkWinnerAndNext() {
bool isSolo = NetworkManager().guestList.isEmpty;
bool isEnd = false;
String? winnerId;
if (_currentQuestionIndex >= _questions.length - 1) {
if (isSolo) {
_questions = _masterQuestions.where((q) => _selectedCategories.contains(q.category)).toList();
_questions.shuffle();
_currentQuestionIndex = -1;
_broadcastState({'type': 'system_message', 'message': '문제가 리필되었습니다! 🔄'});
} else {
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) {
if (isSolo) {
if (_aliveUsers.isEmpty) {
isEnd = true;
winnerId = null;
}
}
else if (_aliveUsers.length <= 1) {
isEnd = true;
winnerId = _aliveUsers.isNotEmpty ? _aliveUsers.first : null;
}
}
if (isEnd) {
_finishGame(winnerId: winnerId);
} else {
_startCountdownSequence();
}
}
void _startCountdownSequence() {
int count = 3;
Timer.periodic(const Duration(seconds: 1), (timer) {
_broadcastState({'type': 'GAME_COUNTDOWN', 'count': count});
if (count == 0) { timer.cancel(); _sendNewQuestion(); }
count--;
});
}
void _sendNewQuestion() {
_currentQuestionIndex++;
final qData = _questions[_currentQuestionIndex];
_resetLocalState();
_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}) {
_hostQuestionTimer?.cancel();
final endData = {'type': 'GAME_OVER', 'winnerId': winnerId ?? 'NONE', 'winnerName': _findUserName(winnerId)};
_broadcastState(endData);
}
void _broadcastState(Map<String, dynamic> data) {
_lastState = 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;
}
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;
}
// ------------------------------------------------------------------------
// [UI] Unified View
// ------------------------------------------------------------------------
@override
Widget buildHostView(BuildContext context) => _buildSharedScreen(context, isHost: true);
@override
Widget buildGuestView(BuildContext context) => _buildSharedScreen(context, isHost: false);
Widget _buildSharedScreen(BuildContext context, {required bool isHost}) {
return Scaffold(
appBar: AppBar(
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: 0), // 광고가 bottomNavigationBar에 있으므로 padding 제거
child: StreamBuilder<Map<String, dynamic>>(
stream: gameStateStream,
initialData: _lastState,
builder: (context, snapshot) {
if (!snapshot.hasData) return _buildWaitingScreen("로딩 중...");
final data = snapshot.data!;
// 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'] ?? (_currentQuestionIndex < _questions.length ? _questions[_currentQuestionIndex].toJson() : {});
if (qData.isEmpty) return _buildWaitingScreen("문제 로딩 중...");
return _buildPlayArea(context, qData);
}
return _buildWaitingScreen("준비 중...");
},
),
),
);
}
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 _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) {
final payload = {'type': 'VOTE_SUBMIT', 'userId': NetworkManager().me.id, 'vote': vote};
_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) {
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))]));
}
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],
child: _selectedRule == GameRule.scoreAttack
? _ScoreBoard(scores: _scores)
: _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,
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)),
),
const Divider(height: 1),
Expanded(
flex: 4,
child: Center(child: Padding(padding: const EdgeInsets.all(20), child: Text(qData['question'], textAlign: TextAlign.center, style: const TextStyle(fontSize: 28, fontWeight: FontWeight.bold)))),
),
Expanded(
flex: 3,
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()),
),
],
);
}
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))]));
}
void _selectAnswer(String answer) {
if (_isLockedIn) return;
_mySelectedAnswer = answer;
SoundManager().playSfx(SoundKey.click);
_gameStateController.add({'type': 'UI_REFRESH'});
_submitFinalAnswer();
}
void _submitFinalAnswer() {
if (_mySelectedAnswer == null) return;
_isLockedIn = true;
_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); }
}
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) { Navigator.pop(context); }
}
// 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 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});
@override
Widget build(BuildContext context) {
final allUsers = [NetworkManager().me, ...NetworkManager().guestList];
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 _ScoreBoard extends StatelessWidget {
final Map<String, int> scores;
const _ScoreBoard({required this.scores});
@override
Widget build(BuildContext context) {
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});
@override
Widget build(BuildContext context) {
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)))));
}
}
@@ -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);
}
}),
],
),
);
},
);
}
}
@@ -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
View 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
View 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;
}