This commit is contained in:
2025-12-02 11:06:23 +09:00
parent bf40c42c2c
commit 22caf64855
34 changed files with 4594 additions and 505 deletions
+265
View File
@@ -0,0 +1,265 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart'; // [필수] Ticker 사용을 위해 추가
import 'package:playwith_core/playwith_core.dart';
class ArkanoidGame extends BaseGame {
@override
String get id => "arkanoid";
@override
String get name => "벽돌 깨기";
@override
String get description => "공을 튕겨 점수를 올리세요!";
@override
void onMessageReceived(String senderId, Map<String, dynamic> payload) {
// UI에서 처리
}
@override
Widget buildHostView(BuildContext context) => ArkanoidScreen(isHost: true, gameInstance: this);
@override
Widget buildGuestView(BuildContext context) => ArkanoidScreen(isHost: false, gameInstance: this);
}
class ArkanoidScreen extends StatefulWidget {
final bool isHost;
final ArkanoidGame gameInstance;
const ArkanoidScreen({super.key, required this.isHost, required this.gameInstance});
@override
State<ArkanoidScreen> createState() => _ArkanoidScreenState();
}
class _ArkanoidScreenState extends State<ArkanoidScreen> with SingleTickerProviderStateMixin {
late Ticker _ticker; // [수정] 이제 에러가 사라질 것입니다.
// 게임 상태
double paddleX = 0.0; // -1.0 ~ 1.0 (화면 비율)
double ballX = 0.0;
double ballY = 0.0;
double ballVelX = 0.01;
double ballVelY = -0.015;
int score = 0;
int opponentScore = 0;
int lives = 3;
bool isPlaying = false;
bool isGameOver = false;
List<Brick> bricks = [];
@override
void initState() {
super.initState();
_resetLevel();
_ticker = createTicker(_gameLoop)..start();
NetworkManager().messageStream.listen(_handleMessage);
}
void _handleMessage(Map<String, dynamic> payload) {
if (!mounted) return;
if (payload['type'] == 'SCORE') {
setState(() {
opponentScore = payload['score'];
});
} else if (payload['type'] == 'GAME_OVER_OPPONENT') {
// 상대방 게임 오버 알림 (선택 사항)
}
}
void _resetLevel() {
// 벽돌 생성 (5줄)
bricks.clear();
for (int i = 0; i < 5; i++) {
for (int j = -4; j <= 4; j++) {
bricks.add(Brick(x: j * 0.22, y: -0.8 + (i * 0.1)));
}
}
_resetBall();
}
void _resetBall() {
ballX = 0;
ballY = 0.5;
ballVelX = (Random().nextBool() ? 0.01 : -0.01);
ballVelY = -0.015;
isPlaying = false;
}
void _gameLoop(Duration elapsed) {
if (!isPlaying || isGameOver) return;
setState(() {
ballX += ballVelX;
ballY += ballVelY;
// 벽 충돌
if (ballX <= -1 || ballX >= 1) ballVelX = -ballVelX;
if (ballY <= -1) ballVelY = -ballVelY; // 천장
// 바닥 충돌 (라이프 감소)
if (ballY >= 1) {
lives--;
if (lives <= 0) {
isGameOver = true;
NetworkManager().sendMessage({'type': 'GAME_OVER_OPPONENT', 'score': score});
_showGameOverDialog();
} else {
_resetBall();
}
}
// 패들 충돌 (간략화: Y좌표가 0.9 근처이고 X범위 내일 때)
if (ballY >= 0.85 && ballY <= 0.95 && ballVelY > 0) {
if (ballX >= paddleX - 0.25 && ballX <= paddleX + 0.25) {
ballVelY = -ballVelY;
// 패들 맞은 위치에 따라 X속도 변화 (스핀 효과)
ballVelX += (ballX - paddleX) * 0.05;
SoundManager().playSfx(SoundKey.click);
}
}
// 벽돌 충돌
for (int i = 0; i < bricks.length; i++) {
if (!bricks[i].isBroken &&
ballX >= bricks[i].x - 0.1 && ballX <= bricks[i].x + 0.1 &&
ballY >= bricks[i].y - 0.05 && ballY <= bricks[i].y + 0.05) {
bricks[i].isBroken = true;
ballVelY = -ballVelY;
score += 10;
// 50점 단위로 점수 전송
if (score % 50 == 0) {
NetworkManager().sendMessage({'type': 'SCORE', 'score': score});
}
break; // 한 프레임에 하나만 깸
}
}
// 모든 벽돌 깸 -> 레벨 초기화 (무한 모드)
if (bricks.every((b) => b.isBroken)) {
_resetLevel();
ballVelY *= 1.1; // 속도 증가
}
});
}
void _onPanUpdate(DragUpdateDetails details) {
setState(() {
// 화면 너비를 -1 ~ 1 좌표계로 변환
paddleX += details.delta.dx / (MediaQuery.of(context).size.width / 2);
paddleX = paddleX.clamp(-0.8, 0.8);
if (!isPlaying && !isGameOver) isPlaying = true;
});
}
void _showGameOverDialog() {
String result = score > opponentScore ? "이겼습니다! (상대: $opponentScore)" : "졌습니다... (상대: $opponentScore)";
if (score == opponentScore) result = "무승부!";
showDialog(
context: context,
barrierDismissible: false,
builder: (_) => AlertDialog(
title: const Text("게임 오버"),
content: Text("내 점수: $score\n$result"),
actions: [
TextButton(
onPressed: () { Navigator.pop(context); Navigator.pop(context); },
child: const Text("나가기"),
)
],
),
);
}
@override
void dispose() {
_ticker.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text("나: $score"),
Text("❤️ $lives"),
Text("상대: $opponentScore"),
],
),
),
backgroundColor: Colors.black,
body: GestureDetector(
onPanUpdate: _onPanUpdate,
child: Container(
color: Colors.transparent,
width: double.infinity,
height: double.infinity,
child: CustomPaint(
painter: ArkanoidPainter(paddleX: paddleX, ballX: ballX, ballY: ballY, bricks: bricks),
),
),
),
);
}
}
class Brick {
double x, y;
bool isBroken = false;
Brick({required this.x, required this.y});
}
class ArkanoidPainter extends CustomPainter {
final double paddleX, ballX, ballY;
final List<Brick> bricks;
ArkanoidPainter({required this.paddleX, required this.ballX, required this.ballY, required this.bricks});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()..color = Colors.white;
final center = size.center(Offset.zero);
// 좌표 변환 함수 (-1~1 -> 화면 좌표)
double toScreenX(double v) => center.dx + v * (size.width / 2);
double toScreenY(double v) => center.dy + v * (size.height / 2);
// 패들 그리기
paint.color = Colors.blueAccent;
Rect paddleRect = Rect.fromCenter(
center: Offset(toScreenX(paddleX), toScreenY(0.9)),
width: size.width * 0.25, // 패들 너비
height: 20,
);
canvas.drawRRect(RRect.fromRectAndRadius(paddleRect, const Radius.circular(10)), paint);
// 공 그리기
paint.color = Colors.yellowAccent;
canvas.drawCircle(Offset(toScreenX(ballX), toScreenY(ballY)), 10, paint);
// 벽돌 그리기
paint.color = Colors.redAccent;
for (var b in bricks) {
if (!b.isBroken) {
Rect brickRect = Rect.fromCenter(
center: Offset(toScreenX(b.x), toScreenY(b.y)),
width: size.width * 0.2 - 5,
height: 20,
);
canvas.drawRRect(RRect.fromRectAndRadius(brickRect, const Radius.circular(4)), paint);
}
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}
+578
View File
@@ -0,0 +1,578 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:playwith_core/playwith_core.dart';
class IAmGroundGame extends BaseGame {
@override
String get id => "iam_ground";
@override
String get name => "아이엠그라운드";
@override
String get description => "8박자 리듬 체크 후 시작!\n정확한 박자에 입력하세요.";
@override
void onMessageReceived(String senderId, Map<String, dynamic> payload) {
// UI에서 처리
}
@override
Widget buildHostView(BuildContext context) => IAmGroundScreen(isHost: true, gameInstance: this);
@override
Widget buildGuestView(BuildContext context) => IAmGroundScreen(isHost: false, gameInstance: this);
}
class IAmGroundScreen extends StatefulWidget {
final bool isHost;
final IAmGroundGame gameInstance;
const IAmGroundScreen({super.key, required this.isHost, required this.gameInstance});
@override
State<IAmGroundScreen> createState() => _IAmGroundScreenState();
}
class _IAmGroundScreenState extends State<IAmGroundScreen> with SingleTickerProviderStateMixin {
late Ticker _ticker;
// --- 리듬 설정 ---
double bpm = 60.0; // [수정] 초기 속도 90 -> 60 (1초에 1박자)
double _beatInterval = 0.0;
double _currentTime = 0.0;
int _currentBeat = 0;
// --- 인트로(리듬체크) 관련 ---
bool _isIntro = false;
int _introBeatCount = 0;
Duration _gameStartTime = Duration.zero;
// --- 게임 상태 ---
String attackerId = "";
String targetId = "";
int attackCount = 0;
List<UserInfo> players = [];
Set<String> deadPlayers = {};
bool isMyTurn = false;
bool isTargeted = false;
List<bool> _inputChecklist = [false, false, false, false];
String centerMessage = "게임 대기 중...";
Color beatColor = Colors.grey;
// 솔로 모드 AI
bool isSolo = false;
final UserInfo aiPlayer = const UserInfo(id: 'ai_bot', nickname: '🤖 AI', colorValue: 0xFF607D8B);
bool _aiActionReserved = false;
@override
void initState() {
super.initState();
_initPlayers();
_updateBpm(60.0); // [수정] 초기값 60
_ticker = createTicker(_onTick);
NetworkManager().messageStream.listen(_handleMessage);
if (widget.isHost) {
Future.delayed(const Duration(seconds: 1), () {
_startGame(players.first.id);
});
}
}
void _initPlayers() {
players = [NetworkManager().me];
if (NetworkManager().guestList.isEmpty) {
isSolo = true;
players.add(aiPlayer);
} else {
players.addAll(NetworkManager().guestList);
}
}
void _updateBpm(double newBpm) {
bpm = newBpm;
_beatInterval = (60.0 / bpm) * 1000; // ms 단위
}
void _handleMessage(Map<String, dynamic> payload) {
if (!mounted) return;
String type = payload['type'];
if (type == 'START_GAME') {
setState(() {
attackerId = payload['firstAttacker'];
_updateBpm(60.0); // [수정] 게임 시작 시 60 BPM으로 초기화
deadPlayers.clear();
// 인트로 모드 진입
_isIntro = true;
_introBeatCount = 0;
centerMessage = "리듬 체크!";
_startMetronome();
});
}
else if (type == 'ATTACK_CMD') {
setState(() {
attackerId = "";
targetId = payload['targetId'];
attackCount = payload['count'];
centerMessage = "${_getName(targetId)}! $attackCount개!";
// 공격 성공할 때마다 BPM 조금씩 증가 (난이도 상승)
if (bpm < 160) _updateBpm(bpm + 2.0);
});
}
else if (type == 'DEFEND_SUCCESS') {
setState(() {
attackerId = payload['newAttacker'];
targetId = "";
attackCount = 0;
centerMessage = "${_getName(attackerId)} 공격 차례!";
});
}
else if (type == 'DIE') {
final deadId = payload['deadId'];
setState(() {
deadPlayers.add(deadId);
if (deadId == NetworkManager().me.id) {
if (isSolo) _showSoloRetryDialog();
else _showGameOverDialog("탈락했습니다... 😵");
} else if (isSolo && deadId == 'ai_bot') {
_showGameOverDialog("AI를 이겼습니다! 승리! 🎉");
}
if (widget.isHost && (deadId == attackerId || deadId == targetId)) {
_passTurnToNextAlive(deadId);
}
});
}
}
String _getName(String id) {
return players.firstWhere((u) => u.id == id, orElse: () => players.first).nickname;
}
// ---------------------------------------------------------------------------
// 리듬 엔진 (Ticker)
// ---------------------------------------------------------------------------
void _startMetronome() {
if (!_ticker.isActive) _ticker.start();
_gameStartTime = Duration.zero;
_currentTime = 0;
_currentBeat = 0;
}
void _onTick(Duration elapsed) {
// [인트로 모드]
if (_isIntro) {
_handleIntroTick(elapsed);
return;
}
// [게임 모드]
Duration gameElapsed = elapsed - _gameStartTime;
double measureDuration = _beatInterval * 4;
double globalTime = gameElapsed.inMilliseconds.toDouble();
double localTime = globalTime % measureDuration;
int newBeat = (localTime / _beatInterval).floor() + 1; // 1~4
if (newBeat != _currentBeat) {
_onBeatChanged(newBeat);
_currentBeat = newBeat;
}
_checkMiss(localTime);
setState(() {
_currentTime = localTime;
double beatProgress = (localTime % _beatInterval) / _beatInterval;
beatColor = Color.lerp(Colors.cyanAccent, Colors.grey[900], beatProgress)!;
});
}
void _handleIntroTick(Duration elapsed) {
double totalTime = elapsed.inMilliseconds.toDouble();
int introBeat = (totalTime / _beatInterval).floor() + 1;
if (introBeat > _introBeatCount) {
setState(() {
_introBeatCount = introBeat;
// 4-3-2-1 카운트다운
int displayNum = 5 - ((_introBeatCount - 1) % 4 + 1);
if (_introBeatCount <= 4) {
centerMessage = "리듬 체크: $displayNum";
beatColor = Colors.yellow;
} else {
centerMessage = "준비: $displayNum";
beatColor = Colors.orange;
}
SoundManager().playSfx(SoundKey.click);
});
if (_introBeatCount >= 8) {
setState(() {
_isIntro = false;
_gameStartTime = elapsed;
centerMessage = "START!";
_currentBeat = 0;
_resetMeasureState();
if (isSolo) _scheduleAiAction();
});
}
}
}
void _onBeatChanged(int beat) {
if (beat == 1) {
_resetMeasureState();
if (isSolo) _scheduleAiAction();
}
if (beat == 1) SoundManager().playSfx(SoundKey.click);
}
void _resetMeasureState() {
_inputChecklist = [false, false, false, false];
_aiActionReserved = false;
isMyTurn = (attackerId == NetworkManager().me.id);
isTargeted = (targetId == NetworkManager().me.id);
}
// ---------------------------------------------------------------------------
// 입력 판정
// ---------------------------------------------------------------------------
void _handleInput(String type, dynamic value) {
if (deadPlayers.contains(NetworkManager().me.id)) return;
if (_isIntro) return;
double closestDist = double.infinity;
int closestBeatIndex = -1;
for(int i=0; i<4; i++) {
double dist = (_currentTime - (i * _beatInterval)).abs();
if (dist < closestDist) {
closestDist = dist;
closestBeatIndex = i;
}
}
// 판정 범위: 0.2초 (200ms)로 조금 더 여유 있게 조정
double greatWindow = 200.0;
if (closestDist > greatWindow) {
_die("박자 놓침! (Bad Timing)");
return;
}
int hitBeat = closestBeatIndex + 1;
if (_inputChecklist[closestBeatIndex]) return;
_inputChecklist[closestBeatIndex] = true;
bool isCorrect = false;
if (isMyTurn) {
// 공격자: 3박자(이름), 4박자(숫자)
if (hitBeat == 3 && type == 'NAME' && value != NetworkManager().me.id) {
isCorrect = true;
targetId = value;
} else if (hitBeat == 4 && type == 'NUM') {
isCorrect = true;
if (targetId.isNotEmpty) {
_sendAttack(targetId, value);
} else {
_die("공격 대상 미지정!");
}
}
} else if (isTargeted) {
// 방어자
bool shouldHit = false;
if (attackCount == 1 && hitBeat == 4) shouldHit = true;
if (attackCount == 2 && (hitBeat == 3 || hitBeat == 4)) shouldHit = true;
if (attackCount == 3 && (hitBeat >= 2)) shouldHit = true;
if (attackCount == 4 && (hitBeat >= 1)) shouldHit = true;
if (shouldHit && type == 'NAME' && value == NetworkManager().me.id) {
isCorrect = true;
if (hitBeat == 4) {
_sendDefendSuccess();
}
}
}
if (!isCorrect) {
_die("틀린 입력!");
}
}
void _checkMiss(double localTime) {
double checkPoint = 210.0; // 판정 윈도우 종료 직후
for(int i=0; i<4; i++) {
double beatTime = i * _beatInterval;
if (localTime > beatTime + checkPoint && !_inputChecklist[i]) {
int beatNum = i + 1;
bool required = false;
if (isMyTurn) {
if (beatNum == 3 || beatNum == 4) required = true;
} else if (isTargeted) {
if (attackCount == 1 && beatNum == 4) required = true;
if (attackCount == 2 && (beatNum == 3 || beatNum == 4)) required = true;
if (attackCount == 3 && beatNum >= 2) required = true;
if (attackCount == 4 && beatNum >= 1) required = true;
}
if (required) {
_die("입력 시간 초과!");
}
_inputChecklist[i] = true;
}
}
}
void _die(String reason) {
if (deadPlayers.contains(NetworkManager().me.id)) return;
_broadcast({'type': 'DIE', 'deadId': NetworkManager().me.id});
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(reason), backgroundColor: Colors.red, duration: const Duration(milliseconds: 500)));
SoundManager().playSfx(SoundKey.wrong);
}
// ---------------------------------------------------------------------------
// 통신 및 AI
// ---------------------------------------------------------------------------
void _startGame(String startId) {
final payload = {'type': 'START_GAME', 'firstAttacker': startId};
_broadcast(payload);
}
void _sendAttack(String target, int count) {
final payload = {'type': 'ATTACK_CMD', 'targetId': target, 'count': count};
_broadcast(payload);
SoundManager().playSfx(SoundKey.correct);
}
void _sendDefendSuccess() {
final payload = {'type': 'DEFEND_SUCCESS', 'newAttacker': NetworkManager().me.id};
_broadcast(payload);
}
void _broadcast(Map<String, dynamic> payload) {
if (isSolo) {
_handleMessage(payload);
} else {
NetworkManager().sendMessage(payload);
if (widget.isHost) _handleMessage(payload);
}
}
void _passTurnToNextAlive(String deadId) {
int idx = players.indexWhere((u) => u.id == deadId);
for (int i = 1; i < players.length; i++) {
int nextIdx = (idx + i) % players.length;
if (!deadPlayers.contains(players[nextIdx].id)) {
_broadcast({'type': 'DEFEND_SUCCESS', 'newAttacker': players[nextIdx].id});
return;
}
}
}
void _scheduleAiAction() {
if (_aiActionReserved) return;
_aiActionReserved = true;
if (attackerId == 'ai_bot') {
Future.delayed(Duration(milliseconds: (_beatInterval * 3.5).toInt()), () {
if (!mounted) return;
_broadcast({'type': 'ATTACK_CMD', 'targetId': NetworkManager().me.id, 'count': Random().nextInt(4) + 1});
});
}
else if (targetId == 'ai_bot') {
Future.delayed(Duration(milliseconds: (_beatInterval * 3.8).toInt()), () {
if (!mounted) return;
_broadcast({'type': 'DEFEND_SUCCESS', 'newAttacker': 'ai_bot'});
});
}
}
// ---------------------------------------------------------------------------
// UI
// ---------------------------------------------------------------------------
@override
void dispose() {
_ticker.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
title: Text("아이엠그라운드 BPM:${bpm.toInt()}"),
backgroundColor: Colors.grey[900],
),
body: Column(
children: [
// 1. 리듬 바
Container(
height: 15,
width: double.infinity,
color: Colors.grey[800],
child: Stack(
children: [
FractionallySizedBox(
alignment: Alignment.centerLeft,
widthFactor: (_currentTime % _beatInterval) / _beatInterval,
child: Container(color: beatColor),
),
Row(
children: List.generate(4, (index) => Expanded(
child: Container(
decoration: BoxDecoration(
border: Border(right: BorderSide(color: Colors.black, width: 2))
),
),
)),
)
],
),
),
// 2. 중앙 정보
Expanded(
flex: 3,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
_isIntro ? "준비!" : "$_currentBeat",
style: TextStyle(fontSize: 60, fontWeight: FontWeight.bold, color: beatColor)
),
const SizedBox(height: 20),
Text(centerMessage, style: const TextStyle(color: Colors.white, fontSize: 24, fontWeight: FontWeight.bold)),
],
),
),
),
// 3. 숫자 패드
Container(
height: 80,
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: List.generate(4, (i) => _buildBtn("NUM", i + 1, "${i + 1}", Colors.orange)),
),
),
const SizedBox(height: 10),
// 4. 플레이어 버튼
Expanded(
flex: 4,
child: GridView.builder(
padding: const EdgeInsets.all(10),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 2.0,
mainAxisSpacing: 10,
crossAxisSpacing: 10,
),
itemCount: players.length,
itemBuilder: (context, index) {
final user = players[index];
final isMe = user.id == NetworkManager().me.id;
Color color = isMe ? Colors.green : Colors.blue;
if (user.id == attackerId) color = Colors.yellow;
if (user.id == targetId) color = Colors.red;
if (deadPlayers.contains(user.id)) color = Colors.grey;
return GestureDetector(
onTapDown: (_) => _handleInput('NAME', user.id),
child: Container(
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.white, width: 2),
),
alignment: Alignment.center,
child: Text(
user.nickname,
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.black),
),
),
);
},
),
),
],
),
);
}
Widget _buildBtn(String type, dynamic val, String label, Color color) {
return GestureDetector(
onTapDown: (_) => _handleInput(type, val),
child: Container(
width: 70, height: 70,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
),
alignment: Alignment.center,
child: Text(label, style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
),
);
}
void _showSoloRetryDialog() {
showDialog(
context: context,
barrierDismissible: false,
builder: (_) => AlertDialog(
title: const Text("연습 실패"),
actions: [
TextButton(onPressed: () { Navigator.pop(context); Navigator.pop(context); }, child: const Text("나가기")),
ElevatedButton(
onPressed: () {
Navigator.pop(context);
setState(() {
deadPlayers.clear();
_updateBpm(60.0); // 재시작 시 속도 초기화
_startGame(players.first.id);
});
},
child: const Text("재도전"),
),
],
),
);
}
void _showGameOverDialog(String msg) {
showDialog(
context: context,
barrierDismissible: false,
builder: (_) => AlertDialog(
title: const Text("게임 종료"),
content: Text(msg),
actions: [
TextButton(onPressed: () { Navigator.pop(context); Navigator.pop(context); }, child: const Text("나가기")),
],
),
);
}
}
+3 -6
View File
@@ -13,15 +13,12 @@ class JanggiGame extends BaseGame {
@override
void onStart() {
super.onStart();
// 게임 시작 시 초기화 로직이 필요하면 여기에 추가
}
// [수정] 필수 메서드 구현 추가
@override
void onMessageReceived(String senderId, Map<String, dynamic> payload) {
// BaseGame은 패킷 수신 시 UI(JanggiScreen)에 전달하는 역할이 주가 되므로
// 여기서는 특별한 로직 없이 두거나, 필요시 전역 상태를 업데이트합니다.
// 실제 게임 로직은 JanggiScreen의 StreamBuilder나 리스너에서 처리됩니다.
// 게임 로직은 JanggiScreen 내부에서 NetworkManager 스트림을 통해 처리합니다.
}
@override
@@ -217,7 +214,6 @@ class _JanggiScreenState extends State<JanggiScreen> {
}
}
}
// 상(象), 포(包) 등은 복잡하여 생략 (필요시 추가 구현)
return moves;
}
@@ -247,7 +243,8 @@ class _JanggiScreenState extends State<JanggiScreen> {
@override
Widget build(BuildContext context) {
// 내가 초나라(Green)라면 보드를 뒤집어서 보여줌
final bool myTurn = currentTurn == (widget.isHan ? Team.han : Team.cho);
// 내가 초나라(Green)라면 보드를 뒤집어서 보여줌 (아래가 내 진영이 되도록)
final bool flipBoard = !widget.isHan;
return Scaffold(
+239
View File
@@ -0,0 +1,239 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:playwith_core/playwith_core.dart';
class JumpGame extends BaseGame {
@override
String get id => "jump_battle";
@override
String get name => "점프 배틀";
@override
String get description => "장애물을 피해 오래 살아남으세요!";
@override
void onMessageReceived(String senderId, Map<String, dynamic> payload) { }
@override
Widget buildHostView(BuildContext context) => JumpGameScreen(isHost: true, gameInstance: this);
@override
Widget buildGuestView(BuildContext context) => JumpGameScreen(isHost: false, gameInstance: this);
}
class JumpGameScreen extends StatefulWidget {
final bool isHost;
final JumpGame gameInstance;
const JumpGameScreen({super.key, required this.isHost, required this.gameInstance});
@override
State<JumpGameScreen> createState() => _JumpGameScreenState();
}
class _JumpGameScreenState extends State<JumpGameScreen> with SingleTickerProviderStateMixin {
late Ticker _ticker;
// 게임 상태
double playerY = 0.0; // 0.0(바닥) ~ 1.0(최대 점프)
double velocityY = 0.0;
bool isJumping = false;
double scrollSpeed = 0.015;
int score = 0; // 거리 점수
int opponentScore = 0;
List<_Obstacle> obstacles = [];
bool isGameOver = false;
@override
void initState() {
super.initState();
_ticker = createTicker(_gameLoop)..start();
NetworkManager().messageStream.listen(_handleMessage);
}
void _handleMessage(Map<String, dynamic> payload) {
if (!mounted) return;
if (payload['type'] == 'SCORE_UPDATE') {
setState(() => opponentScore = payload['score']);
}
}
void _jump() {
if (!isJumping && !isGameOver) {
velocityY = 0.045; // 점프 힘
isJumping = true;
SoundManager().playSfx(SoundKey.click);
}
}
void _gameLoop(Duration elapsed) {
if (isGameOver) return;
setState(() {
// 1. 중력 적용
playerY += velocityY;
velocityY -= 0.0025; // 중력값
// 바닥 착지
if (playerY <= 0) {
playerY = 0;
isJumping = false;
velocityY = 0;
}
// 2. 장애물 생성 및 이동
if (obstacles.isEmpty || obstacles.last.x < 0.5) {
// 일정 간격으로 생성
if (Random().nextDouble() < 0.02) {
obstacles.add(_Obstacle(x: 1.5, width: 0.1, height: 0.1 + Random().nextDouble() * 0.1));
}
}
for (var obs in obstacles) {
obs.x -= scrollSpeed;
}
obstacles.removeWhere((obs) => obs.x < -1.2);
// 3. 점수 증가 (생존 시간)
score++;
if (score % 10 == 0) {
NetworkManager().sendMessage({'type': 'SCORE_UPDATE', 'score': score});
}
// 난이도 증가
if (score % 500 == 0) scrollSpeed += 0.001;
// 4. 충돌 체크
// 플레이어 X위치는 -0.6 정도로 고정 가정
double playerX = -0.6;
double playerSize = 0.1; // 히트박스 크기
for (var obs in obstacles) {
// X축 겹침
if (playerX + playerSize > obs.x && playerX - playerSize < obs.x + obs.width) {
// Y축 겹침 (플레이어가 장애물보다 낮으면 충돌)
if (playerY < obs.height) {
_gameOver();
}
}
}
});
}
void _gameOver() {
isGameOver = true;
_ticker.stop();
SoundManager().playSfx(SoundKey.wrong);
String result = score > opponentScore ? "승리! (상대: $opponentScore)" : "패배... (상대: $opponentScore)";
showDialog(
context: context,
barrierDismissible: false,
builder: (_) => AlertDialog(
title: const Text("충돌!"),
content: Text("기록: $score m\n$result"),
actions: [
TextButton(
onPressed: () { Navigator.pop(context); Navigator.pop(context); },
child: const Text("나가기"),
)
],
),
);
}
@override
void dispose() {
_ticker.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text("나: $score m"),
Text("상대: $opponentScore m"),
],
),
),
body: GestureDetector(
onTap: _jump,
child: Container(
color: Colors.white,
child: CustomPaint(
painter: JumpGamePainter(playerY: playerY, obstacles: obstacles),
size: Size.infinite,
),
),
),
);
}
}
class _Obstacle {
double x;
double width;
double height;
_Obstacle({required this.x, required this.width, required this.height});
}
class JumpGamePainter extends CustomPainter {
final double playerY;
final List<_Obstacle> obstacles;
JumpGamePainter({required this.playerY, required this.obstacles});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()..color = Colors.black;
// 바닥 선
double groundY = size.height * 0.8;
canvas.drawLine(Offset(0, groundY), Offset(size.width, groundY), paint..strokeWidth = 2);
// 좌표 변환: playerY (0~1) -> 화면 Y (groundY ~ 위쪽)
double toScreenY(double yRatio) => groundY - (yRatio * size.height * 0.5);
double toScreenX(double xRatio) => size.width/2 + (xRatio * size.width/2);
// 플레이어 (공룡/네모)
paint.color = Colors.green;
double pSize = 40;
Rect playerRect = Rect.fromCenter(
center: Offset(size.width * 0.2, toScreenY(playerY) - pSize/2),
width: pSize,
height: pSize,
);
canvas.drawRect(playerRect, paint);
// 장애물
paint.color = Colors.redAccent;
for (var obs in obstacles) {
// 화면 좌표 변환
// obs.x: 0이 중앙, -1이 왼쪽 끝
// 여기선 간단히 매핑
double obsX = size.width/2 + (obs.x * size.width/2);
// -> 위쪽 playerX(-0.6)과 좌표계 통일을 위해 약간 보정 필요하지만,
// 시각적 편의상 직접 매핑:
// 플레이어는 화면 좌측 20% 지점 고정.
// 장애물은 오른쪽에서 왼쪽으로 이동.
// obs.x = 1.5 -> 0.5 -> -1.0
// 화면 비율 그대로 사용
double left = (obs.x + 1) / 2 * size.width;
double top = groundY - (obs.height * size.height * 0.5);
double w = obs.width * size.width * 0.5;
canvas.drawRect(Rect.fromLTWH(left, top, w, groundY - top), paint);
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}
+297
View File
@@ -0,0 +1,297 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:playwith_core/playwith_core.dart';
class MathRunGame extends BaseGame {
@override
String get id => "math_run";
@override
String get name => "매스 런";
@override
String get description => "좋은 문을 통과해 숫자를 키우세요!";
@override
void onMessageReceived(String senderId, Map<String, dynamic> payload) {
// UI에서 처리
}
@override
Widget buildHostView(BuildContext context) => MathRunScreen(isHost: true, gameInstance: this);
@override
Widget buildGuestView(BuildContext context) => MathRunScreen(isHost: false, gameInstance: this);
}
class MathRunScreen extends StatefulWidget {
final bool isHost;
final MathRunGame gameInstance;
const MathRunScreen({super.key, required this.isHost, required this.gameInstance});
@override
State<MathRunScreen> createState() => _MathRunScreenState();
}
class _MathRunScreenState extends State<MathRunScreen> with SingleTickerProviderStateMixin {
late Ticker _ticker;
// 게임 상태
double playerX = 0.0; // -1.0 (좌) ~ 1.0 (우)
int myScore = 1; // 현재 병력(점수)
int opponentScore = 1;
double gameSpeed = 0.008; // 내려오는 속도
double distanceTraveled = 0;
List<_Gate> gates = [];
bool isPlaying = true;
bool isGameOver = false;
@override
void initState() {
super.initState();
_spawnInitialGates();
_ticker = createTicker(_gameLoop)..start();
NetworkManager().messageStream.listen(_handleMessage);
}
void _handleMessage(Map<String, dynamic> payload) {
if (!mounted) return;
if (payload['type'] == 'SCORE_UPDATE') {
setState(() {
opponentScore = payload['score'];
});
} else if (payload['type'] == 'GAME_OVER_OPPONENT') {
// 상대방 죽음 알림 (옵션)
}
}
void _spawnInitialGates() {
// 초기 게이트 생성
for (int i = 1; i < 5; i++) {
_spawnGateRow(-1.0 + (i * 0.6)); // Y 위치
}
}
void _spawnGateRow(double y) {
final random = Random();
// 좌측 게이트
int val1 = random.nextInt(10) + 2;
bool isMult1 = random.nextBool();
if (!isMult1) val1 *= 5; // 더하기는 좀 더 큰 수로
// 우측 게이트
int val2 = random.nextInt(10) + 2;
bool isMult2 = random.nextBool();
if (!isMult2) val2 *= 5;
// 가끔 함정(나누기/빼기) 추가
if (random.nextDouble() < 0.3) {
val1 = -val1;
}
gates.add(_Gate(x: -0.5, y: y, value: val1, isMultiply: isMult1));
gates.add(_Gate(x: 0.5, y: y, value: val2, isMultiply: isMult2));
}
void _gameLoop(Duration elapsed) {
if (!isPlaying || isGameOver) return;
setState(() {
// 게이트 이동 (플레이어가 앞으로 가는 효과)
for (var gate in gates) {
gate.y += gameSpeed;
}
// 지나간 게이트 삭제 및 새 게이트 생성
if (gates.isNotEmpty && gates.first.y > 1.2) {
gates.removeAt(0);
gates.removeAt(0); // 한 줄(2개) 삭제
// 새 줄 생성 (화면 위쪽 보이지 않는 곳에)
double lastY = gates.last.y;
_spawnGateRow(lastY - 0.6); // 간격 0.6
// 속도 점진적 증가
gameSpeed += 0.0001;
distanceTraveled += 0.1;
}
// 충돌 감지
for (var gate in gates) {
if (!gate.passed && gate.y > 0.75 && gate.y < 0.85) { // 플레이어 Y 위치 근처
// X 범위 체크 (플레이어 크기 고려)
if ((playerX - gate.x).abs() < 0.4) {
_applyGateEffect(gate);
gate.passed = true;
}
}
}
// 점수 0 되면 게임 오버
if (myScore <= 0) {
_finishGame();
}
});
}
void _applyGateEffect(_Gate gate) {
if (gate.isMultiply) {
if (gate.value > 0) myScore *= gate.value;
else myScore = (myScore / gate.value.abs()).floor(); // 음수 곱하기는 나누기로 처리 (함정)
} else {
myScore += gate.value;
}
SoundManager().playSfx(gate.value > 0 ? SoundKey.correct : SoundKey.wrong);
// 점수 전송
NetworkManager().sendMessage({'type': 'SCORE_UPDATE', 'score': myScore});
}
void _onPanUpdate(DragUpdateDetails details) {
if (isGameOver) return;
setState(() {
playerX += details.delta.dx / (MediaQuery.of(context).size.width / 2);
playerX = playerX.clamp(-0.8, 0.8);
});
}
void _finishGame() {
isPlaying = false;
isGameOver = true;
NetworkManager().sendMessage({'type': 'GAME_OVER_OPPONENT', 'score': myScore});
String result = myScore > opponentScore ? "승리! (상대: $opponentScore)" : "패배... (상대: $opponentScore)";
if (myScore == opponentScore) result = "무승부!";
showDialog(
context: context,
barrierDismissible: false,
builder: (_) => AlertDialog(
title: const Text("게임 종료"),
content: Text("최종 병력: $myScore\n$result"),
actions: [
TextButton(
onPressed: () { Navigator.pop(context); Navigator.pop(context); },
child: const Text("나가기"),
)
],
),
);
}
@override
void dispose() {
_ticker.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text("나: $myScore 💂"),
Text("상대: $opponentScore 💂"),
],
),
),
backgroundColor: Colors.grey[900],
body: GestureDetector(
onPanUpdate: _onPanUpdate,
child: Container(
color: Colors.transparent,
width: double.infinity,
height: double.infinity,
child: CustomPaint(
painter: MathRunPainter(playerX: playerX, gates: gates, score: myScore),
),
),
),
);
}
}
class _Gate {
double x; // -0.5 (왼쪽), 0.5 (오른쪽)
double y;
int value;
bool isMultiply;
bool passed = false;
_Gate({required this.x, required this.y, required this.value, required this.isMultiply});
}
class MathRunPainter extends CustomPainter {
final double playerX;
final List<_Gate> gates;
final int score;
MathRunPainter({required this.playerX, required this.gates, required this.score});
@override
void paint(Canvas canvas, Size size) {
final center = size.center(Offset.zero);
double toScreenX(double v) => center.dx + v * (size.width / 2);
double toScreenY(double v) => center.dy + v * (size.height / 2); // v: -1(상) ~ 1(하)
// 바닥 격자 효과 (속도감)
final paintLine = Paint()..color = Colors.white10..strokeWidth = 2;
canvas.drawLine(Offset(size.width * 0.3, 0), Offset(0, size.height), paintLine);
canvas.drawLine(Offset(size.width * 0.7, 0), Offset(size.width, size.height), paintLine);
// 게이트 그리기
for (var gate in gates) {
if (gate.passed) continue;
// 게이트 색상: 파랑(좋음), 빨강(나쁨)
bool isGood = (gate.isMultiply && gate.value > 1) || (!gate.isMultiply && gate.value > 0);
final color = isGood ? Colors.blueAccent : Colors.redAccent;
final paintGate = Paint()..color = color.withOpacity(0.6);
Rect rect = Rect.fromCenter(
center: Offset(toScreenX(gate.x), toScreenY(gate.y)),
width: size.width * 0.45,
height: 100, // 고정 높이
);
canvas.drawRRect(RRect.fromRectAndRadius(rect, const Radius.circular(10)), paintGate);
// 텍스트
String op = gate.isMultiply ? "x" : (gate.value >= 0 ? "+" : "");
// 나누기 함정은 음수 곱하기로 표현했으므로 표시 변환
if (gate.isMultiply && gate.value < 0) { op = "÷"; }
String text = "$op${gate.value.abs()}";
TextSpan span = TextSpan(
style: const TextStyle(color: Colors.white, fontSize: 24, fontWeight: FontWeight.bold),
text: text
);
TextPainter tp = TextPainter(text: span, textDirection: TextDirection.ltr);
tp.layout();
tp.paint(canvas, rect.center - Offset(tp.width / 2, tp.height / 2));
}
// 플레이어 (병력) 그리기
final paintPlayer = Paint()..color = Colors.yellow;
final playerCenter = Offset(toScreenX(playerX), toScreenY(0.8));
// 메인 캐릭터
canvas.drawCircle(playerCenter, 15, paintPlayer);
// 군중 효과 (점수에 따라 작은 원 추가)
int crowd = min(score, 20); // 최대 20개까지만 그림
for (int i = 0; i < crowd; i++) {
double angle = (i / crowd) * 2 * pi;
double radius = 25.0;
canvas.drawCircle(playerCenter + Offset(cos(angle)*radius, sin(angle)*radius), 5, paintPlayer..color = Colors.yellow.withOpacity(0.7));
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}
+325
View File
@@ -0,0 +1,325 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:playwith_core/playwith_core.dart';
class OthelloGame extends BaseGame {
@override
String get id => "othello";
@override
String get name => "오셀로";
@override
String get description => "돌을 뒤집어라!\n마지막에 웃는 자가 승리";
@override
void onMessageReceived(String senderId, Map<String, dynamic> payload) {
// UI에서 처리
}
@override
Widget buildHostView(BuildContext context) => OthelloScreen(myStone: 1, gameInstance: this); // 1: 흑(선공)
@override
Widget buildGuestView(BuildContext context) => OthelloScreen(myStone: 2, gameInstance: this); // 2: 백(후공)
}
class OthelloScreen extends StatefulWidget {
final int myStone; // 1: Black, 2: White
final OthelloGame gameInstance;
const OthelloScreen({super.key, required this.myStone, required this.gameInstance});
@override
State<OthelloScreen> createState() => _OthelloScreenState();
}
class _OthelloScreenState extends State<OthelloScreen> {
// 0: 빈칸, 1: 흑, 2: 백
final List<List<int>> board = List.generate(8, (_) => List.filled(8, 0));
int currentTurn = 1; // 흑 먼저
// [수정] _Point 사용
List<_Point> validMoves = [];
@override
void initState() {
super.initState();
_initBoard();
_calculateValidMoves();
NetworkManager().messageStream.listen(_handleMessage);
}
void _initBoard() {
// 오셀로 초기 배치
board[3][3] = 2;
board[3][4] = 1;
board[4][3] = 1;
board[4][4] = 2;
}
void _handleMessage(Map<String, dynamic> payload) {
if (!mounted) return;
if (payload['type'] == 'MOVE') {
int x = payload['x'];
int y = payload['y'];
int stone = payload['stone'];
_executeMove(x, y, stone);
} else if (payload['type'] == 'PASS') {
_passTurn();
}
}
void _onTap(int x, int y) {
if (currentTurn != widget.myStone) return;
if (!_isValidMove(x, y, widget.myStone)) return;
_executeMove(x, y, widget.myStone);
NetworkManager().sendMessage({
'type': 'MOVE',
'x': x,
'y': y,
'stone': widget.myStone
});
}
void _executeMove(int x, int y, int stone) {
setState(() {
board[y][x] = stone;
_flipStones(x, y, stone);
currentTurn = (stone == 1) ? 2 : 1;
_calculateValidMoves();
// 내가 둘 곳이 없으면 패스 처리
if (currentTurn == widget.myStone && validMoves.isEmpty) {
if (_isBoardFull() || _getScore(1) + _getScore(2) == 64) {
_showGameOver();
} else {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("둘 곳이 없어 턴을 넘깁니다.")));
NetworkManager().sendMessage({'type': 'PASS'});
Future.delayed(const Duration(seconds: 1), _passTurn);
}
}
});
SoundManager().playSfx(SoundKey.click);
}
void _passTurn() {
setState(() {
currentTurn = (currentTurn == 1) ? 2 : 1;
_calculateValidMoves();
// 패스했는데 나도 둘 곳 없으면 게임 종료
if (currentTurn == widget.myStone && validMoves.isEmpty) {
_showGameOver();
}
});
}
void _flipStones(int x, int y, int stone) {
final directions = [
[-1,-1], [0,-1], [1,-1],
[-1, 0], [1, 0],
[-1, 1], [0, 1], [1, 1]
];
for (var d in directions) {
int dx = d[0], dy = d[1];
int nx = x + dx, ny = y + dy;
List<_Point> flippable = []; // [수정] _Point 사용
while (nx >= 0 && nx < 8 && ny >= 0 && ny < 8) {
if (board[ny][nx] == 0) break;
if (board[ny][nx] == stone) {
for (var p in flippable) board[p.y][p.x] = stone;
break;
}
flippable.add(_Point(nx, ny));
nx += dx; ny += dy;
}
}
}
bool _isValidMove(int x, int y, int stone) {
if (board[y][x] != 0) return false;
final directions = [
[-1,-1], [0,-1], [1,-1],
[-1, 0], [1, 0],
[-1, 1], [0, 1], [1, 1]
];
for (var d in directions) {
int dx = d[0], dy = d[1];
int nx = x + dx, ny = y + dy;
bool hasOpponent = false;
while (nx >= 0 && nx < 8 && ny >= 0 && ny < 8) {
if (board[ny][nx] == 0) break;
if (board[ny][nx] == stone) {
if (hasOpponent) return true;
break;
}
hasOpponent = true;
nx += dx; ny += dy;
}
}
return false;
}
void _calculateValidMoves() {
validMoves.clear();
if (currentTurn != widget.myStone) return;
for (int y = 0; y < 8; y++) {
for (int x = 0; x < 8; x++) {
if (_isValidMove(x, y, widget.myStone)) {
validMoves.add(_Point(x, y)); // [수정] _Point 사용
}
}
}
}
bool _isBoardFull() {
for (var row in board) {
if (row.contains(0)) return false;
}
return true;
}
int _getScore(int stone) {
int count = 0;
for (var row in board) {
for (var cell in row) {
if (cell == stone) count++;
}
}
return count;
}
void _showGameOver() {
int blackScore = _getScore(1);
int whiteScore = _getScore(2);
String msg;
if (blackScore == whiteScore) msg = "무승부입니다!";
else if (widget.myStone == 1) {
msg = blackScore > whiteScore ? "승리했습니다! 🎉" : "패배했습니다... 😭";
} else {
msg = whiteScore > blackScore ? "승리했습니다! 🎉" : "패배했습니다... 😭";
}
showDialog(
context: context,
barrierDismissible: false,
builder: (_) => AlertDialog(
title: const Text("게임 종료"),
content: Text("$msg\n흑: $blackScore vs 백: $whiteScore"),
actions: [
TextButton(
onPressed: () { Navigator.pop(context); Navigator.pop(context); },
child: const Text("나가기"),
)
],
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text("오셀로")),
backgroundColor: Colors.green[800],
body: Column(
children: [
// 점수판
Padding(
padding: const EdgeInsets.all(20),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildPlayerInfo("흑 (Black)", 1),
_buildPlayerInfo("백 (White)", 2),
],
),
),
// 보드
Expanded(
child: Center(
child: AspectRatio(
aspectRatio: 1.0,
child: Container(
margin: const EdgeInsets.all(10),
color: Colors.black,
padding: const EdgeInsets.all(4),
child: GridView.builder(
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 8,
crossAxisSpacing: 2,
mainAxisSpacing: 2,
),
itemCount: 64,
itemBuilder: (context, index) {
int x = index % 8;
int y = index ~/ 8;
int cell = board[y][x];
bool isValid = validMoves.any((p) => p.x == x && p.y == y);
return GestureDetector(
onTap: () => _onTap(x, y),
child: Container(
color: Colors.green[700],
child: Center(
child: cell == 0
? (isValid ? Container(width: 10, height: 10, decoration: BoxDecoration(color: Colors.black26, shape: BoxShape.circle)) : null)
: Container(
width: 30, height: 30,
decoration: BoxDecoration(
color: cell == 1 ? Colors.black : Colors.white,
shape: BoxShape.circle,
boxShadow: const [BoxShadow(blurRadius: 2, offset: Offset(1,1), color: Colors.black54)]
),
),
),
),
);
},
),
),
),
),
),
if (currentTurn == widget.myStone)
const Padding(padding: EdgeInsets.all(20), child: Text("당신의 차례입니다", style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold)))
else
const Padding(padding: EdgeInsets.all(20), child: Text("상대방 생각 중...", style: TextStyle(color: Colors.white70, fontSize: 16))),
],
),
);
}
Widget _buildPlayerInfo(String label, int stone) {
bool isTurn = currentTurn == stone;
bool isMe = widget.myStone == stone;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
decoration: BoxDecoration(
color: isTurn ? Colors.amber.withOpacity(0.8) : Colors.white24,
borderRadius: BorderRadius.circular(10),
border: isMe ? Border.all(color: Colors.yellow, width: 2) : null,
),
child: Column(
children: [
Text(label, style: TextStyle(color: stone == 1 ? Colors.black : Colors.white, fontWeight: FontWeight.bold)),
Text("${_getScore(stone)}", style: TextStyle(color: stone == 1 ? Colors.black : Colors.white, fontSize: 24, fontWeight: FontWeight.bold)),
if (isMe) const Text("YOU", style: TextStyle(fontSize: 10, color: Colors.redAccent, fontWeight: FontWeight.bold)),
],
),
);
}
}
// [수정] Private Class로 변경하여 충돌 방지
class _Point { final int x, y; _Point(this.x, this.y); }
@@ -0,0 +1,414 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:playwith_core/playwith_core.dart';
class SequenceMemoryGame extends BaseGame {
@override
String get id => "sequence_memory";
@override
String get name => "기억의 신";
@override
String get description => "순서를 기억해 똑같이 누르세요!\n글자가 뒤섞여 나옵니다.";
@override
void onMessageReceived(String senderId, Map<String, dynamic> payload) {
// BaseGame 핸들러
}
@override
Widget buildHostView(BuildContext context) => SequenceMemoryScreen(isHost: true, gameInstance: this);
@override
Widget buildGuestView(BuildContext context) => SequenceMemoryScreen(isHost: false, gameInstance: this);
}
class SequenceMemoryScreen extends StatefulWidget {
final bool isHost;
final SequenceMemoryGame gameInstance;
const SequenceMemoryScreen({super.key, required this.isHost, required this.gameInstance});
@override
State<SequenceMemoryScreen> createState() => _SequenceMemoryScreenState();
}
class _SequenceMemoryScreenState extends State<SequenceMemoryScreen> {
// --- 게임 설정 ---
int round = 1;
int sequenceLength = 3;
// 0: Red(Host), 1: Blue(Guest)
int currentTurn = 0;
// [수정] 데이터 풀 대폭 확장 (랜덤성을 위해)
final List<String> _poolNum = List.generate(25, (i) => '${i + 1}'); // 1~25
final List<String> _poolKor = ['','','','','','','','','','','','','',''];
final List<String> _poolEng = List.generate(26, (i) => String.fromCharCode('A'.codeUnitAt(0) + i)); // A~Z
final List<String> _poolEmo = [
'🍎','🍌','🍇','🍉','🍓','🍒','🍍','🥝','🍋','🥑','🥦','🌽','🥕','🌭','🍔','🍟','🍕','🥪','🌮','🍦','🍧','🍩','🍪','🎂','🍬'
];
// 현재 라운드 데이터
List<String> gridItems = [];
List<int> targetSequence = [];
int currentGridSize = 2;
// 플레이 상태
bool isShowingSequence = false;
int? activeHighlightIndex;
int inputIndex = 0;
String infoMessage = "대결 시작!";
Color infoColor = Colors.white;
// AI 관련
bool isSolo = false;
final UserInfo aiPlayer = const UserInfo(id: 'ai_bot', nickname: '🤖 알파고', colorValue: 0xFF607D8B);
@override
void initState() {
super.initState();
if (NetworkManager().guestList.isEmpty) {
isSolo = true;
}
NetworkManager().messageStream.listen(_handleMessage);
if (widget.isHost) {
Future.delayed(const Duration(seconds: 1), () {
_startRound(1, 0);
});
}
}
// 라운드에 따른 그리드 크기
int _getGridDimension(int r) {
if (r <= 5) return 2; // 1~5라운드: 2x2
if (r <= 15) return 3; // 6~15라운드: 3x3
return 5; // 16라운드~: 5x5
}
void _handleMessage(Map<String, dynamic> payload) {
if (!mounted) return;
String type = payload['type'];
if (type == 'NEW_ROUND') {
setState(() {
round = payload['round'];
currentTurn = payload['turn'];
gridItems = List<String>.from(payload['gridItems']);
targetSequence = List<int>.from(payload['sequence']);
sequenceLength = targetSequence.length;
currentGridSize = _getGridDimension(round);
inputIndex = 0;
isShowingSequence = true;
activeHighlightIndex = null;
bool isMyTurn = _isMyTurn();
if (isMyTurn) {
infoMessage = "순서를 잘 보세요!";
infoColor = Colors.yellow;
} else {
String name = (currentTurn == 1 && isSolo) ? aiPlayer.nickname : "상대방";
infoMessage = "$name이(가) 기억하는 중...";
infoColor = Colors.grey;
}
});
_playSequenceAnimation();
}
else if (type == 'TURN_COMPLETE') {
if (widget.isHost) {
if (currentTurn == 0) {
_startRound(round, 1); // 라운드 유지
} else {
_startRound(round + 1, 0); // 라운드 증가
}
}
}
else if (type == 'GAME_OVER') {
_showGameOverDialog(payload['winner']);
}
}
bool _isMyTurn() {
if (widget.isHost) return currentTurn == 0;
return currentTurn == 1;
}
// ---------------------------------------------------------------------------
// 게임 로직 (Host)
// ---------------------------------------------------------------------------
void _startRound(int nextRound, int nextTurn) {
int dim = _getGridDimension(nextRound);
int totalCount = dim * dim; // 4, 9, 25
// 1. 데이터 풀 구성 (난이도에 따라 섞음)
List<String> currentPool = [];
// 기본적으로 숫자는 항상 포함하거나, 라운드가 높아지면 비중을 줄일 수 있음
// 여기서는 모든 풀을 합치고 랜덤으로 뽑는 방식
currentPool.addAll(_poolNum);
if (nextRound >= 6) currentPool.addAll(_poolKor); // 6~: 한글 추가
if (nextRound >= 12) currentPool.addAll(_poolEng); // 12~: 영어 추가
if (nextRound >= 18) currentPool.addAll(_poolEmo); // 18~: 이모지 추가
// 2. [핵심] 전체 풀에서 무작위로 'totalCount'개 뽑기
// (순서대로 뽑지 않고 섞어서 뽑음 -> 가, 다, 하, A, Z 가 섞여 나옴)
currentPool.shuffle();
List<String> nextGridItems = currentPool.take(totalCount).toList();
// 3. 정답 시퀀스 생성
// 길이: 3 + (라운드 - 1)
int length = 3 + (nextRound - 1);
List<int> nextSequence = [];
for (int i = 0; i < length; i++) {
nextSequence.add(Random().nextInt(totalCount));
}
final payload = {
'type': 'NEW_ROUND',
'round': nextRound,
'turn': nextTurn,
'gridItems': nextGridItems,
'sequence': nextSequence
};
_broadcast(payload);
}
void _broadcast(Map<String, dynamic> payload) {
if (isSolo) {
_handleMessage(payload);
} else {
NetworkManager().sendMessage(payload);
if (widget.isHost) _handleMessage(payload);
}
}
// ---------------------------------------------------------------------------
// 애니메이션 및 입력
// ---------------------------------------------------------------------------
Future<void> _playSequenceAnimation() async {
await Future.delayed(const Duration(seconds: 2));
for (int targetIdx in targetSequence) {
if (!mounted) return;
setState(() {
activeHighlightIndex = targetIdx;
SoundManager().playSfx(SoundKey.click);
});
// 난이도별 깜빡임 속도
int showTime = max(200, 600 - (round * 15));
await Future.delayed(Duration(milliseconds: showTime));
setState(() => activeHighlightIndex = null);
await Future.delayed(const Duration(milliseconds: 150));
}
if (!mounted) return;
setState(() {
isShowingSequence = false;
if (_isMyTurn()) {
infoMessage = "똑같이 누르세요!";
infoColor = Colors.greenAccent;
} else {
if (isSolo && currentTurn == 1) {
infoMessage = "${aiPlayer.nickname} 입력 중...";
infoColor = Colors.cyanAccent;
_playAiTurn();
} else {
infoMessage = "상대방 입력 중...";
infoColor = Colors.grey;
}
}
});
}
Future<void> _playAiTurn() async {
int inputDelay = max(250, 700 - (round * 20));
for (int targetIdx in targetSequence) {
if (!mounted) return;
await Future.delayed(Duration(milliseconds: inputDelay));
setState(() => activeHighlightIndex = targetIdx);
SoundManager().playSfx(SoundKey.click);
await Future.delayed(const Duration(milliseconds: 150));
setState(() => activeHighlightIndex = null);
}
await Future.delayed(const Duration(milliseconds: 500));
if (widget.isHost) {
_handleMessage({'type': 'TURN_COMPLETE'});
}
}
void _onButtonTap(int index) {
if (!_isMyTurn() || isShowingSequence) return;
if (targetSequence[inputIndex] == index) {
SoundManager().playSfx(SoundKey.click);
setState(() => activeHighlightIndex = index);
Future.delayed(const Duration(milliseconds: 100), () {
if (mounted) setState(() => activeHighlightIndex = null);
});
inputIndex++;
if (inputIndex >= targetSequence.length) {
SoundManager().playSfx(SoundKey.correct);
if (isSolo) {
_handleMessage({'type': 'TURN_COMPLETE'});
} else {
if (widget.isHost) {
if (currentTurn == 0) _startRound(round, 1);
else _startRound(round + 1, 0);
} else {
NetworkManager().sendMessage({'type': 'TURN_COMPLETE'});
}
}
}
} else {
SoundManager().playSfx(SoundKey.wrong);
_sendGameOver(1 - currentTurn);
}
}
void _sendGameOver(int winner) {
final payload = {'type': 'GAME_OVER', 'winner': winner};
_broadcast(payload);
}
void _showGameOverDialog(int winner) {
String msg;
if (isSolo) {
msg = "틀렸습니다!\n최종 기록: ${round}라운드 (길이 $sequenceLength)";
} else {
int myTeam = widget.isHost ? 0 : 1;
msg = (winner == myTeam) ? "승리했습니다! 🎉" : "패배했습니다... 😭";
}
showDialog(
context: context,
barrierDismissible: false,
builder: (_) => AlertDialog(
title: const Text("게임 종료"),
content: Text(msg),
actions: [
TextButton(
onPressed: () { Navigator.pop(context); Navigator.pop(context); },
child: const Text("나가기"),
)
],
),
);
}
// ---------------------------------------------------------------------------
// UI
// ---------------------------------------------------------------------------
@override
Widget build(BuildContext context) {
Color teamColor = (currentTurn == 0) ? Colors.redAccent : Colors.blueAccent;
double itemFontSize = currentGridSize == 5 ? 20 : 32;
return Scaffold(
appBar: AppBar(
title: Text("Round $round (길이: $sequenceLength)"),
backgroundColor: Colors.deepPurple,
),
backgroundColor: Colors.grey[900],
body: Column(
children: [
// 1. 상태 메시지
Container(
padding: const EdgeInsets.all(20),
width: double.infinity,
color: isShowingSequence ? Colors.black54 : teamColor.withOpacity(0.2),
child: Column(
children: [
Text(
infoMessage,
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: infoColor),
textAlign: TextAlign.center,
),
if (!isShowingSequence && _isMyTurn())
Text(
"$inputIndex / $sequenceLength",
style: const TextStyle(fontSize: 16, color: Colors.white70)
),
],
),
),
// 2. 그리드
Expanded(
child: Center(
child: Padding(
padding: const EdgeInsets.all(20.0),
child: AspectRatio(
aspectRatio: 1.0,
child: GridView.builder(
physics: const NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: currentGridSize,
mainAxisSpacing: 10,
crossAxisSpacing: 10,
),
itemCount: currentGridSize * currentGridSize,
itemBuilder: (context, index) {
if (gridItems.isEmpty) return const SizedBox();
String content = gridItems[index];
bool isHighlight = (index == activeHighlightIndex);
return GestureDetector(
onTapDown: (_) => _onButtonTap(index),
child: AnimatedContainer(
duration: const Duration(milliseconds: 100),
decoration: BoxDecoration(
color: isHighlight ? Colors.yellowAccent : Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isHighlight ? Colors.orange : Colors.grey,
width: isHighlight ? 4 : 1
),
boxShadow: isHighlight ? [
BoxShadow(color: Colors.yellow.withOpacity(0.6), blurRadius: 15)
] : [],
),
child: Center(
child: Text(
content,
style: TextStyle(
fontSize: itemFontSize,
fontWeight: FontWeight.bold,
color: Colors.black87
),
),
),
),
);
},
),
),
),
),
),
],
),
);
}
}
+163 -106
View File
@@ -1,8 +1,11 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:playwith_core/playwith_core.dart';
import '../model/spider_model.dart';
import '../model/spider_game_dto.dart';
import '../widgets/spider_widgets.dart';
class SpiderMultiGame extends BaseGame {
@@ -11,16 +14,62 @@ class SpiderMultiGame extends BaseGame {
@override
String get name => "스파이더 배틀";
@override
String get description => "K부터 A까지 카드를 정렬하세요.\n완성하면 상대에게 카드를 뿌려 공격합니다!";
String get description => "세트를 완성하면 상대를 공격합니다!\n(내 남은 카드 중 1장이 상대에게 넘어갑니다)";
int? _randomSeed;
final StreamController<SpiderGameDto?> _gameDataStreamController = StreamController<SpiderGameDto?>.broadcast();
Future<SpiderGameDto> _fetchSpiderGame(int difficulty) async {
const String baseUrl = "https://lunaticbum.kr";
try {
final response = await http.get(
Uri.parse('$baseUrl/puzzle/spider/start?difficulty=$difficulty'),
).timeout(const Duration(seconds: 5));
if (response.statusCode == 200) {
final data = jsonDecode(utf8.decode(response.bodyBytes));
return SpiderGameDto.fromJson(data);
}
} catch (e) {
print("API 호출 실패, 로컬 생성: $e");
}
return _generateLocalGame(difficulty);
}
SpiderGameDto _generateLocalGame(int difficulty) {
List<int> deck = [];
if (difficulty == 1) {
for (int i = 0; i < 8; i++) {
for (int r = 1; r <= 13; r++) deck.add(r);
}
} else if (difficulty == 2) {
for (int i = 0; i < 4; i++) {
for (int r = 1; r <= 13; r++) deck.add(r);
for (int r = 1; r <= 13; r++) deck.add(r + 100);
}
} else {
for (int i = 0; i < 2; i++) {
for (int r = 1; r <= 13; r++) deck.add(r);
for (int r = 1; r <= 13; r++) deck.add(r + 100);
for (int r = 1; r <= 13; r++) deck.add(r + 200);
for (int r = 1; r <= 13; r++) deck.add(r + 300);
}
}
deck.shuffle();
return SpiderGameDto(puzzleId: 0, difficulty: difficulty, cards: deck);
}
@override
void onStart() {
void onStart() async {
super.onStart();
if (NetworkManager().role == NetworkRole.host) {
final int seed = Random().nextInt(1000000);
final payload = {'type': 'GAME_START_DATA', 'seed': seed};
final int diff = NetworkManager().selectedGameConfig['difficulty'] ?? 1;
final gameData = await _fetchSpiderGame(diff);
final payload = {
'type': 'GAME_START_DATA',
...gameData.toJson(),
};
onMessageReceived(NetworkManager().me.id, payload);
NetworkManager().sendMessage(payload);
}
@@ -29,39 +78,52 @@ class SpiderMultiGame extends BaseGame {
@override
void onMessageReceived(String senderId, Map<String, dynamic> payload) {
if (payload['type'] == 'GAME_START_DATA') {
_randomSeed = payload['seed'];
final gameData = SpiderGameDto.fromJson(payload);
_gameDataStreamController.add(gameData);
}
}
@override
void onDispose() {
_gameDataStreamController.close();
super.onDispose();
}
@override
Widget buildHostView(BuildContext context) => _buildScreen();
@override
Widget buildGuestView(BuildContext context) => _buildScreen();
Widget _buildScreen() {
if (_randomSeed == null) return const Scaffold(body: Center(child: CircularProgressIndicator()));
return SpiderBattleScreen(seed: _randomSeed!, gameInstance: this);
return StreamBuilder<SpiderGameDto?>(
stream: _gameDataStreamController.stream,
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
return SpiderBattleScreen(gameData: snapshot.data!, gameInstance: this);
},
);
}
}
class SpiderBattleScreen extends StatefulWidget {
final int seed;
final SpiderGameDto gameData;
final SpiderMultiGame gameInstance;
const SpiderBattleScreen({super.key, required this.seed, required this.gameInstance});
const SpiderBattleScreen({super.key, required this.gameData, required this.gameInstance});
@override
State<SpiderBattleScreen> createState() => _SpiderBattleScreenState();
}
class _SpiderBattleScreenState extends State<SpiderBattleScreen> {
// 게임 상태
List<List<SpiderCard>> tableau = List.generate(10, (_) => []); // 10개 컬럼
List<SpiderCard> stock = []; // 뽑을 카드
List<List<SpiderCard>> foundation = []; // 완성된 세트
List<List<SpiderCard>> tableau = List.generate(10, (_) => []);
List<SpiderCard> stock = [];
List<List<SpiderCard>> foundation = [];
int _moves = 0;
final int _numSuits = 1; // 난이도 (1: 스페이드만, 2: 하트/스페이드, 4: 전체)
@override
void initState() {
@@ -73,102 +135,104 @@ class _SpiderBattleScreenState extends State<SpiderBattleScreen> {
void _handleNetworkMessage(Map<String, dynamic> payload) {
if (!mounted) return;
if (payload['type'] == 'ATTACK') {
_onAttacked(payload['senderName']);
final attackerName = payload['senderName'];
_onAttacked(attackerName);
} else if (payload['type'] == 'GAME_WIN') {
_showGameOverDialog(payload['winnerName']);
}
}
// --- 게임 로직 ---
void _initializeGame() {
final random = Random(widget.seed);
List<SpiderCard> deck = [];
// 2세트(104장) 생성
int idCounter = 0;
for (int i = 0; i < 8; i++) { // 1 suit 모드 기준 (스페이드 13장 * 8세트)
for (int r = 1; r <= 13; r++) {
deck.add(SpiderCard(id: idCounter++, suit: SpiderSuit.spade, rank: r));
}
for (int rawVal in widget.gameData.cards) {
SpiderSuit suit = SpiderSuit.spade;
int rank = rawVal;
if (rawVal > 300) { suit = SpiderSuit.diamond; rank = rawVal - 300; }
else if (rawVal > 200) { suit = SpiderSuit.club; rank = rawVal - 200; }
else if (rawVal > 100) { suit = SpiderSuit.heart; rank = rawVal - 100; }
if (rank < 1) rank = 1;
if (rank > 13) rank = 13;
deck.add(SpiderCard(id: idCounter++, suit: suit, rank: rank));
}
deck.shuffle(random);
// 태블로에 카드 분배 (앞 4줄 6장, 뒤 6줄 5장 = 54장)
int cardIdx = 0;
int totalCards = deck.length;
for (int i = 0; i < 10; i++) {
int count = (i < 4) ? 6 : 5;
for (int j = 0; j < count; j++) {
final card = deck[cardIdx++];
if (j == count - 1) card.isFaceUp = true; // 맨 윗장 오픈
tableau[i].add(card);
if (cardIdx < totalCards) {
final card = deck[cardIdx++];
if (j == count - 1) card.isFaceUp = true;
tableau[i].add(card);
}
}
}
// 남은 카드는 스톡으로
stock = deck.sublist(cardIdx);
if (cardIdx < totalCards) {
stock = deck.sublist(cardIdx);
}
}
// [공격 받음] 상대가 세트를 완성하면 내 태블로 각 열에 카드 1장씩 추가
// [수정됨] 공격 처리: 내 스톡에서 카드를 꺼내 내 태블로에 추가
void _onAttacked(String attackerName) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("⚔️ $attackerName님의 공격! 카드가 추가됩니다!"), backgroundColor: Colors.red),
);
SoundManager().playSfx(SoundKey.wrong);
if (stock.isEmpty) {
// 스톡이 없으면 공격 무효 (또는 다른 패널티 적용 가능)
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("⚔️ $attackerName님의 공격을 막았습니다! (남은 카드 없음)")),
);
return;
}
setState(() {
// 방해 카드 생성 (무작위)
for (int i = 0; i < 10; i++) {
final badCard = SpiderCard(
id: 9999 + Random().nextInt(10000),
suit: SpiderSuit.spade,
rank: Random().nextInt(13) + 1,
isFaceUp: true
);
tableau[i].add(badCard);
}
// 스톡에서 1장 꺼냄
final card = stock.removeLast();
card.isFaceUp = true;
// 랜덤한 컬럼에 추가
int targetCol = Random().nextInt(10);
tableau[targetCol].add(card);
// 혹시 이 카드로 인해 세트가 완성될 수도 있으니 체크
_checkCompleteSet(targetCol);
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text("⚔️ $attackerName님의 공격! 카드 1장이 추가됩니다!"),
backgroundColor: Colors.redAccent,
duration: const Duration(milliseconds: 1500),
),
);
SoundManager().playSfx(SoundKey.wrong);
}
// [카드 이동] 드래그 앤 드롭
void _onCardDrop(List<SpiderCard> movingCards, int fromColIndex, int toColIndex) {
setState(() {
_moves++;
// 1. 원래 위치에서 제거
final fromCol = tableau[fromColIndex];
fromCol.removeRange(fromCol.length - movingCards.length, fromCol.length);
// 2. 뒷면 카드 뒤집기
if (fromCol.isNotEmpty && !fromCol.last.isFaceUp) {
fromCol.last.isFaceUp = true;
}
// 3. 새 위치에 추가
tableau[toColIndex].addAll(movingCards);
// 4. 세트 완성 체크
_checkCompleteSet(toColIndex);
});
}
// [세트 완성 체크] K -> A 순서인지 확인
void _checkCompleteSet(int colIndex) {
final col = tableau[colIndex];
if (col.length < 13) return;
// 끝에서 13장 확인
List<SpiderCard> last13 = col.sublist(col.length - 13);
final targetSuit = last13.first.suit;
bool isComplete = true;
// K(13) ... A(1) 순서여야 함
for (int i = 0; i < 13; i++) {
if (last13[i].rank != 13 - i) {
isComplete = false;
break;
if (last13[i].suit != targetSuit || last13[i].rank != 13 - i) {
isComplete = false; break;
}
}
if (isComplete) {
// 완성!
setState(() {
col.removeRange(col.length - 13, col.length);
foundation.add(last13);
@@ -176,11 +240,8 @@ class _SpiderBattleScreenState extends State<SpiderBattleScreen> {
});
SoundManager().playSfx(SoundKey.correct);
// [공격 전송]
NetworkManager().sendMessage({'type': 'ATTACK', 'senderName': NetworkManager().me.nickname});
// 승리 체크 (8세트 완성)
if (foundation.length >= 8) {
final winPayload = {'type': 'GAME_WIN', 'winnerName': NetworkManager().me.nickname};
NetworkManager().sendMessage(winPayload);
@@ -189,36 +250,36 @@ class _SpiderBattleScreenState extends State<SpiderBattleScreen> {
}
}
// [스톡에서 카드 뽑기]
void _dealFromStock() {
if (stock.isEmpty) return;
// 빈 열이 있으면 규칙상 못 뽑게 할 수도 있으나, 여기선 허용 (캐주얼)
setState(() {
for (int i = 0; i < 10; i++) {
if (stock.isNotEmpty) {
final card = stock.removeLast();
card.isFaceUp = true;
tableau[i].add(card);
_checkCompleteSet(i); // 운 좋게 완성될 수도 있음
}
// [수정] 남은 카드가 10장 미만이면 남은 만큼만 뿌림
int count = min(10, stock.length);
for (int i = 0; i < count; i++) {
final card = stock.removeLast();
card.isFaceUp = true;
tableau[i].add(card);
_checkCompleteSet(i);
}
});
}
bool _canMove(SpiderCard topCard, SpiderCard? bottomCard) {
if (bottomCard == null) return true; // 빈 열에는 이동 가능
// 규칙: 랭크가 1 작아야 함 (색상은 1 suit 모드라 무시)
if (bottomCard == null) return true;
return bottomCard.rank == topCard.rank + 1;
}
void _showGameOverDialog(String winnerName) {
bool isMe = winnerName == NetworkManager().me.nickname;
if (isMe) SoundManager().playSfx(SoundKey.win);
else SoundManager().playSfx(SoundKey.wrong);
showDialog(
context: context,
barrierDismissible: false,
builder: (_) => AlertDialog(
title: Text(isMe ? "승리! 🎉" : "패배 😭"),
content: Text(isMe ? "축하합니다! 모든 세트를 완성했습니다." : "$winnerName 님이 먼저 완료했습니다."),
content: Text(isMe ? "축하합니다! 가장 먼저 끝냈습니다." : "$winnerName 님이 먼저 완료했습니다."),
actions: [
TextButton(
onPressed: () { Navigator.pop(context); Navigator.pop(context); },
@@ -229,24 +290,27 @@ class _SpiderBattleScreenState extends State<SpiderBattleScreen> {
);
}
// --- UI ---
@override
Widget build(BuildContext context) {
final size = MediaQuery.of(context).size;
final cardWidth = (size.width - 20) / 10; // 10열
final cardWidth = (size.width - 20) / 10;
final cardHeight = cardWidth * 1.4;
return Scaffold(
backgroundColor: Colors.green[800],
appBar: AppBar(
title: Text("스파이더 배틀 (${foundation.length}/8)"),
title: Text("스파이더 (${foundation.length}/8)"),
backgroundColor: Colors.green[900],
elevation: 0,
actions: [
Center(child: Padding(
padding: const EdgeInsets.only(right: 16.0),
child: Text("이동: $_moves", style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.white70)),
))
],
),
body: Column(
children: [
// 1. 태블로 (카드 놓는 곳)
Expanded(
child: Stack(
children: List.generate(10, (colIndex) {
@@ -258,8 +322,6 @@ class _SpiderBattleScreenState extends State<SpiderBattleScreen> {
}),
),
),
// 2. 하단 바 (스톡 & 완성된 덱)
Container(
height: cardHeight + 20,
color: Colors.green[900],
@@ -267,24 +329,29 @@ class _SpiderBattleScreenState extends State<SpiderBattleScreen> {
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// 완성된 덱 표시
Row(
children: foundation.map((_) => Padding(
padding: const EdgeInsets.only(right: 4.0),
child: SpiderCardWidget(
card: SpiderCard(id: 0, suit: SpiderSuit.spade, rank: 13, isFaceUp: true), // K표시
card: SpiderCard(id: 0, suit: SpiderSuit.spade, rank: 13, isFaceUp: true),
width: cardWidth * 0.8,
height: cardHeight * 0.8
),
)).toList(),
),
// 스톡 (클릭 시 배분)
GestureDetector(
onTap: _dealFromStock,
child: stock.isEmpty
? Container(width: cardWidth, height: cardHeight, decoration: BoxDecoration(border: Border.all(color: Colors.white30), borderRadius: BorderRadius.circular(4)))
: SpiderCardWidget(card: SpiderCard(id: -1, suit: SpiderSuit.spade, rank: 0), width: cardWidth, height: cardHeight),
: Stack(
children: [
SpiderCardWidget(card: SpiderCard(id: -1, suit: SpiderSuit.spade, rank: 0), width: cardWidth, height: cardHeight),
Positioned(
bottom: 5, right: 5,
child: Text("${stock.length}", style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 12)),
)
],
),
),
],
),
@@ -296,18 +363,15 @@ class _SpiderBattleScreenState extends State<SpiderBattleScreen> {
Widget _buildTableauColumn(int colIndex, double width, double height) {
final pile = tableau[colIndex];
// DragTarget: 이 컬럼으로 카드가 들어오는 것을 감지
return DragTarget<Map<String, dynamic>>(
onWillAccept: (data) {
if (data == null) return false;
final List<SpiderCard> movingCards = data['cards'];
final int fromIndex = data['fromIndex'];
if (fromIndex == colIndex) return false; // 제자리 드롭 무시
if (fromIndex == colIndex) return false;
final SpiderCard topMoving = movingCards.first;
final SpiderCard? targetBottom = pile.isEmpty ? null : pile.last;
return _canMove(topMoving, targetBottom);
},
onAccept: (data) {
@@ -319,20 +383,15 @@ class _SpiderBattleScreenState extends State<SpiderBattleScreen> {
height: MediaQuery.of(context).size.height * 0.7,
child: Stack(
children: [
// 빈 공간 표시 (타겟 영역 확보용)
Container(width: width, height: 100, color: Colors.transparent),
// 쌓인 카드들
...List.generate(pile.length, (i) {
final card = pile[i];
final offset = i * 25.0; // 카드 겹침 간격
// 드래그 가능한 카드인지 확인 (오픈되어 있고, 위 카드들과 연속된 순서인지)
final offset = i * 25.0;
bool isDraggable = card.isFaceUp;
if (isDraggable && i < pile.length - 1) {
// 내 위에 있는 카드들이 나랑 연속되어야 함
for (int k = i; k < pile.length - 1; k++) {
if (pile[k].rank != pile[k+1].rank + 1) {
if (pile[k].suit != pile[k+1].suit || pile[k].rank != pile[k+1].rank + 1) {
isDraggable = false;
break;
}
@@ -342,9 +401,7 @@ class _SpiderBattleScreenState extends State<SpiderBattleScreen> {
Widget cardWidget = SpiderCardWidget(card: card, width: width, height: height);
if (isDraggable) {
// 같이 움직일 카드 묶음
final movingCards = pile.sublist(i);
return Positioned(
top: offset,
child: Draggable<Map<String, dynamic>>(
@@ -355,7 +412,7 @@ class _SpiderBattleScreenState extends State<SpiderBattleScreen> {
children: movingCards.map((c) => SpiderCardWidget(card: c, width: width, height: height)).toList(),
),
),
childWhenDragging: const SizedBox(), // 드래그 중엔 숨김 (또는 밑장 표시)
childWhenDragging: const SizedBox(),
child: cardWidget,
),
);
+52 -64
View File
@@ -18,9 +18,6 @@ class SudokuMultiGame extends BaseGame {
final StreamController<SudokuGameDto?> _puzzleStreamController = StreamController<SudokuGameDto?>.broadcast();
// ---------------------------------------------------------------------------
// [Host] 퍼즐 데이터 로딩 로직
// ---------------------------------------------------------------------------
Future<SudokuGameDto> _fetchPuzzleFromApi(String difficulty) async {
const String baseUrl = "https://lunaticbum.kr";
try {
@@ -47,16 +44,10 @@ class SudokuMultiGame extends BaseGame {
@override
void onStart() async {
super.onStart();
if (NetworkManager().role == NetworkRole.host) {
final int diffValue = NetworkManager().selectedGameConfig['difficulty'] ?? 1;
final puzzleData = await _fetchPuzzleFromApi(diffValue.toString());
final payload = {
'type': 'GAME_DATA',
...puzzleData.toJson(),
};
final payload = {'type': 'GAME_DATA', ...puzzleData.toJson()};
onMessageReceived(NetworkManager().me.id, payload);
NetworkManager().sendMessage(payload);
}
@@ -64,6 +55,9 @@ class SudokuMultiGame extends BaseGame {
@override
void onMessageReceived(String senderId, Map<String, dynamic> payload) {
// [핵심 수정] GAME_DATA는 스트림으로, ATTACK 등은 이벤트 버스(또는 아래 화면 리스너)로 처리
// 하지만 여기서는 StreamBuilder가 화면을 구성하므로, ATTACK 이벤트 처리는 화면(State)에서 NetworkManager를 리스닝하는 것이 가장 빠릅니다.
// 따라서 여기서는 게임 데이터만 처리합니다.
if (payload['type'] == 'GAME_DATA') {
final puzzle = SudokuGameDto.fromJson(payload);
_puzzleStreamController.add(puzzle);
@@ -78,7 +72,6 @@ class SudokuMultiGame extends BaseGame {
@override
Widget buildHostView(BuildContext context) => _buildGameScreen(context);
@override
Widget buildGuestView(BuildContext context) => _buildGameScreen(context);
@@ -88,16 +81,7 @@ class SudokuMultiGame extends BaseGame {
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 20),
Text("퍼즐을 불러오는 중입니다..."),
],
),
),
body: Center(child: CircularProgressIndicator()),
);
}
return SudokuBattleScreen(gameData: snapshot.data!, gameInstance: this);
@@ -106,9 +90,6 @@ class SudokuMultiGame extends BaseGame {
}
}
// -----------------------------------------------------------------------------
// 게임 화면 (UI + 로직)
// -----------------------------------------------------------------------------
class SudokuBattleScreen extends StatefulWidget {
final SudokuGameDto gameData;
final SudokuMultiGame gameInstance;
@@ -141,6 +122,7 @@ class _SudokuBattleScreenState extends State<SudokuBattleScreen> {
originalCells = List.from(puzzleCells);
solutionCells = widget.gameData.solution.split('').map(_charToInt).toList();
// [핵심 수정] 공격 이벤트 리스너 등록
NetworkManager().messageStream.listen(_handleNetworkMessage);
}
@@ -164,6 +146,7 @@ class _SudokuBattleScreenState extends State<SudokuBattleScreen> {
void _applyAttack(String attackerName) {
List<int> myInputs = [];
for (int i = 0; i < puzzleCells.length; i++) {
// 원래 빈칸이었는데 채워둔 숫자들
if (originalCells[i] == 0 && puzzleCells[i] != 0) {
myInputs.add(i);
}
@@ -172,7 +155,7 @@ class _SudokuBattleScreenState extends State<SudokuBattleScreen> {
if (myInputs.isNotEmpty) {
final randomIdx = myInputs[Random().nextInt(myInputs.length)];
setState(() {
puzzleCells[randomIdx] = 0;
puzzleCells[randomIdx] = 0; // 숫자 지움
});
ScaffoldMessenger.of(context).showSnackBar(
@@ -259,7 +242,7 @@ class _SudokuBattleScreenState extends State<SudokuBattleScreen> {
barrierDismissible: false,
builder: (context) => AlertDialog(
title: Text(isMe ? "승리! 🎉" : "패배 😭"),
content: Text(isMe ? "축하합니다!" : "$winnerName 님이 승리했습니다."),
content: Text(isMe ? "축하합니다! $winnerName 님이 승리했습니다." : "$winnerName 님이 먼저 퍼즐을 완성했습니다."),
actions: [
TextButton(
onPressed: () {
@@ -304,47 +287,44 @@ class _SudokuBattleScreenState extends State<SudokuBattleScreen> {
)
],
),
// 하단 배너 광고
bottomNavigationBar: const SafeArea(child: AdBannerWidget()),
body: Column(
mainAxisAlignment: MainAxisAlignment.start,
children: [
// 1. 상단 빈칸 정보
const SizedBox(height: 10),
Padding(
padding: const EdgeInsets.symmetric(vertical: 10.0),
child: Text(
"남은 빈칸: ${puzzleCells.where((e)=>e==0).length}",
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18)
padding: const EdgeInsets.all(8.0),
child: Text("남은 빈칸: ${puzzleCells.where((e)=>e==0).length}",
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 10.0),
child: Center(
child: SudokuBoard(
blockSize: blockSize,
cells: puzzleCells,
originalCells: originalCells,
selectedIndex: selectedIndex,
selectedNumberPad: selectedNumberPad,
incorrectCells: incorrectCells,
onCellTapped: (index) {
setState(() {
selectedIndex = index;
if (selectedNumberPad != null) {
_onNumberTapped(selectedNumberPad!);
selectedIndex = null;
selectedNumberPad = null;
}
});
},
),
),
),
// 2. 게임 보드 (Center와 Expanded 제거하여 상단 배치)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: SudokuBoard(
blockSize: blockSize,
cells: puzzleCells,
originalCells: originalCells,
selectedIndex: selectedIndex,
selectedNumberPad: selectedNumberPad,
incorrectCells: incorrectCells,
onCellTapped: (index) {
setState(() {
selectedIndex = index;
// [핵심] 넘버패드 선택된 상태에서 칸 누르면 -> 입력 후 즉시 포커스 해제
if (selectedNumberPad != null) {
_onNumberTapped(selectedNumberPad!);
selectedIndex = null; // 입력했으므로 선택 해제
}
});
},
),
),
const Spacer(), // 남은 공간을 밀어내어 키패드를 하단으로 (또는 제거하여 바로 아래 붙일 수 있음)
// 3. 키패드 (높이 증가)
const Spacer(),
Container(
padding: const EdgeInsets.fromLTRB(16, 10, 16, 30),
height: 240, // [수정] 높이 확대
height: 240,
color: Colors.grey[50],
child: NumberPad(
blockSize: blockSize,
@@ -352,13 +332,11 @@ class _SudokuBattleScreenState extends State<SudokuBattleScreen> {
selectedNumber: selectedNumberPad,
onNumberTapped: (num) {
setState(() {
// [핵심] 칸이 선택된 상태에서 숫자 누르면 -> 입력 후 즉시 포커스 해제
if (selectedIndex != null) {
_onNumberTapped(num);
selectedIndex = null; // 입력했으므로 선택 해제
selectedNumberPad = null; // 모드 초기화
selectedIndex = null;
selectedNumberPad = null;
} else {
// 칸 선택 없이 숫자만 누르면 '숫자 우선 모드' 토글
selectedNumberPad = (selectedNumberPad == num) ? null : num;
}
});
@@ -369,4 +347,14 @@ class _SudokuBattleScreenState extends State<SudokuBattleScreen> {
),
);
}
}
}
// [보조] DTO에 없는 테마 클래스 간단 정의 (SudokuWidgets에서 요구할 수 있음)
class SudokuTheme {
final Color primaryColor;
final Color backgroundColor;
final SudokuSymbolType symbolType;
SudokuTheme({required this.primaryColor, required this.backgroundColor, required this.symbolType});
String getSymbol(int val) => val.toString();
}
enum SudokuSymbolType { number }
+418
View File
@@ -0,0 +1,418 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:playwith_core/playwith_core.dart';
class SurvivorGame extends BaseGame {
@override
String get id => "survivor";
@override
String get name => "서바이버";
@override
String get description => "최후의 생존자가 되세요!";
@override
void onMessageReceived(String senderId, Map<String, dynamic> payload) {
// 점수 공유 등 처리 (생략 가능)
}
@override
Widget buildHostView(BuildContext context) => SurvivorScreen(isHost: true, gameInstance: this);
@override
Widget buildGuestView(BuildContext context) => SurvivorScreen(isHost: false, gameInstance: this);
}
class SurvivorScreen extends StatefulWidget {
final bool isHost;
final SurvivorGame gameInstance;
const SurvivorScreen({super.key, required this.isHost, required this.gameInstance});
@override
State<SurvivorScreen> createState() => _SurvivorScreenState();
}
class _SurvivorScreenState extends State<SurvivorScreen> with SingleTickerProviderStateMixin {
late Ticker _ticker;
// --- 게임 엔티티 ---
_Player player = _Player();
List<_Enemy> enemies = [];
List<_Bullet> bullets = [];
List<_Gem> gems = []; // 경험치 보석
// --- 게임 상태 ---
int score = 0; // 킬 수
int opponentScore = 0;
int level = 1;
int exp = 0;
int maxExp = 5;
double gameTime = 0;
bool isGameOver = false;
// 조작
Offset _joystickDelta = Offset.zero;
@override
void initState() {
super.initState();
_ticker = createTicker(_gameLoop)..start();
NetworkManager().messageStream.listen(_handleMessage);
}
void _handleMessage(Map<String, dynamic> payload) {
if (!mounted) return;
if (payload['type'] == 'SCORE') {
setState(() => opponentScore = payload['score']);
}
}
void _gameLoop(Duration elapsed) {
if (isGameOver) return;
setState(() {
gameTime += 0.016; // 약 60fps
// 1. 플레이어 이동
if (_joystickDelta != Offset.zero) {
player.x += _joystickDelta.dx * player.speed;
player.y += _joystickDelta.dy * player.speed;
// 화면 밖 제한 (0.0 ~ 1.0 좌표계)
player.x = player.x.clamp(0.0, 1.0);
player.y = player.y.clamp(0.0, 1.0);
}
// 2. 적 생성 (시간 지날수록 많이)
if (Random().nextDouble() < 0.02 + (level * 0.005)) {
_spawnEnemy();
}
// 3. 적 이동 (플레이어 추적)
for (var enemy in enemies) {
double dx = player.x - enemy.x;
double dy = player.y - enemy.y;
double dist = sqrt(dx*dx + dy*dy);
if (dist > 0) {
enemy.x += (dx / dist) * enemy.speed;
enemy.y += (dy / dist) * enemy.speed;
}
// 플레이어 충돌 (피격)
if (dist < (player.size + enemy.size) / 2) {
_gameOver();
}
}
// 4. 자동 공격 (가장 가까운 적)
player.cooldown -= 0.016;
if (player.cooldown <= 0 && enemies.isNotEmpty) {
_Enemy? target = _findNearestEnemy();
if (target != null) {
_fireBullet(target);
player.cooldown = player.maxCooldown; // 공속
}
}
// 5. 총알 이동 및 충돌
for (int i = bullets.length - 1; i >= 0; i--) {
var b = bullets[i];
b.x += b.vx;
b.y += b.vy;
b.life -= 0.016;
// 화면 밖 or 수명 끝
if (b.x < 0 || b.x > 1 || b.y < 0 || b.y > 1 || b.life <= 0) {
bullets.removeAt(i);
continue;
}
// 적 충돌 체크
for (int j = enemies.length - 1; j >= 0; j--) {
var e = enemies[j];
double dist = sqrt(pow(b.x - e.x, 2) + pow(b.y - e.y, 2));
if (dist < (b.size + e.size) / 2) {
// 명중
e.hp--;
bullets.removeAt(i); // 총알 삭제 (관통 없음)
if (e.hp <= 0) {
enemies.removeAt(j);
_dropGem(e.x, e.y);
score++;
if (score % 10 == 0) NetworkManager().sendMessage({'type': 'SCORE', 'score': score});
}
break;
}
}
}
// 6. 보석 획득 (경험치)
for (int i = gems.length - 1; i >= 0; i--) {
var g = gems[i];
// 자석 효과 (플레이어 근처면 빨려옴)
double dx = player.x - g.x;
double dy = player.y - g.y;
double dist = sqrt(dx*dx + dy*dy);
if (dist < 0.15) { // 자석 범위
g.x += dx * 0.1;
g.y += dy * 0.1;
}
if (dist < player.size) {
gems.removeAt(i);
exp++;
if (exp >= maxExp) {
_levelUp();
}
}
}
});
}
_Enemy? _findNearestEnemy() {
_Enemy? nearest;
double minDst = 100.0;
for (var e in enemies) {
double dst = sqrt(pow(player.x - e.x, 2) + pow(player.y - e.y, 2));
if (dst < minDst) {
minDst = dst;
nearest = e;
}
}
// 사거리 체크 (화면 절반 정도)
if (minDst < 0.4) return nearest;
return null;
}
void _fireBullet(_Enemy target) {
double dx = target.x - player.x;
double dy = target.y - player.y;
double dist = sqrt(dx*dx + dy*dy);
bullets.add(_Bullet(
x: player.x,
y: player.y,
vx: (dx/dist) * 0.02, // 총알 속도
vy: (dy/dist) * 0.02
));
// 멀티샷 (레벨업 시 추가 가능)
if (level >= 3) {
// 약간 빗나간 총알 추가 등 로직 가능
}
}
void _spawnEnemy() {
// 화면 가장자리 랜덤 위치
double x, y;
if (Random().nextBool()) {
x = Random().nextBool() ? -0.1 : 1.1;
y = Random().nextDouble();
} else {
x = Random().nextDouble();
y = Random().nextBool() ? -0.1 : 1.1;
}
enemies.add(_Enemy(
x: x, y: y,
hp: 1 + (level ~/ 2), // 레벨 비례 체력
speed: 0.002 + (level * 0.0005) // 레벨 비례 속도
));
}
void _dropGem(double x, double y) {
gems.add(_Gem(x: x, y: y));
}
void _levelUp() {
level++;
exp = 0;
maxExp += 5;
player.maxCooldown *= 0.9; // 공속 증가
// 이펙트나 알림 추가 가능
SoundManager().playSfx(SoundKey.win); // 레벨업 효과음 대용
}
void _gameOver() {
isGameOver = true;
_ticker.stop();
SoundManager().playSfx(SoundKey.wrong);
String result = score > opponentScore ? "승리! (상대: $opponentScore)" : "패배... (상대: $opponentScore)";
if (score == opponentScore) result = "무승부!";
showDialog(
context: context,
barrierDismissible: false,
builder: (_) => AlertDialog(
title: const Text("생존 실패!"),
content: Text("최종 레벨: $level\n처치 수: $score\n$result"),
actions: [
TextButton(
onPressed: () { Navigator.pop(context); Navigator.pop(context); },
child: const Text("나가기"),
)
],
),
);
}
@override
void dispose() {
_ticker.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.green[900], // 잔디 느낌
body: Stack(
children: [
// 게임 화면
Positioned.fill(
child: CustomPaint(
painter: SurvivorPainter(player, enemies, bullets, gems),
),
),
// UI 오버레이 (점수, 레벨)
SafeArea(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text("KILL: $score", style: const TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold)),
Text("LV: $level", style: const TextStyle(color: Colors.yellowAccent, fontSize: 24, fontWeight: FontWeight.bold)),
Text("RIVAL: $opponentScore", style: const TextStyle(color: Colors.white70, fontSize: 16)),
],
),
const SizedBox(height: 5),
// 경험치 바
LinearProgressIndicator(
value: exp / maxExp,
backgroundColor: Colors.black26,
color: Colors.blueAccent,
minHeight: 8,
)
],
),
),
),
// 가상 조이스틱 (전체 화면 드래그 인식)
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onPanStart: (d) => _updateJoystick(d.localPosition, start: true),
onPanUpdate: (d) => _updateJoystick(d.localPosition),
onPanEnd: (d) => setState(() => _joystickDelta = Offset.zero),
child: Container(color: Colors.transparent),
),
),
],
),
);
}
// 가상 조이스틱 로직 (화면 어디든 터치해서 움직임)
Offset _startTouchPos = Offset.zero;
void _updateJoystick(Offset pos, {bool start = false}) {
if (start) _startTouchPos = pos;
Offset diff = pos - _startTouchPos;
double dist = diff.distance;
if (dist > 0) {
// 최대 거리 제한 (감도 조절)
if (dist > 50) diff = diff / dist * 50;
setState(() {
_joystickDelta = diff / 50.0; // -1.0 ~ 1.0 정규화
});
}
}
}
// --- 내부 클래스 ---
class _Player {
double x = 0.5, y = 0.5;
double size = 0.04;
double speed = 0.008;
double cooldown = 0;
double maxCooldown = 0.5; // 초당 2발
}
class _Enemy {
double x, y;
int hp;
double size = 0.03;
double speed;
_Enemy({required this.x, required this.y, this.hp = 1, this.speed = 0.002});
}
class _Bullet {
double x, y, vx, vy;
double size = 0.015;
double life = 2.0; // 2초 후 사라짐
_Bullet({required this.x, required this.y, required this.vx, required this.vy});
}
class _Gem {
double x, y;
_Gem({required this.x, required this.y});
}
// --- 페인터 ---
class SurvivorPainter extends CustomPainter {
final _Player player;
final List<_Enemy> enemies;
final List<_Bullet> bullets;
final List<_Gem> gems;
SurvivorPainter(this.player, this.enemies, this.bullets, this.gems);
@override
void paint(Canvas canvas, Size size) {
final w = size.width;
final h = size.height;
// 좌표 변환
Offset toPos(double x, double y) => Offset(x * w, y * h);
// 보석
final gemPaint = Paint()..color = Colors.blueAccent;
for (var g in gems) {
canvas.drawCircle(toPos(g.x, g.y), w * 0.015, gemPaint);
}
// 적
final enemyPaint = Paint()..color = Colors.red;
for (var e in enemies) {
// 사각형으로 그림
Rect rect = Rect.fromCenter(center: toPos(e.x, e.y), width: w * e.size * 2, height: w * e.size * 2);
canvas.drawRect(rect, enemyPaint);
}
// 총알
final bulletPaint = Paint()..color = Colors.yellow;
for (var b in bullets) {
canvas.drawCircle(toPos(b.x, b.y), w * b.size, bulletPaint);
}
// 플레이어
final playerPaint = Paint()..color = Colors.white;
canvas.drawCircle(toPos(player.x, player.y), w * player.size, playerPaint);
// 플레이어 테두리 (HP 느낌)
final borderPaint = Paint()..color = Colors.black..style = PaintingStyle.stroke..strokeWidth = 2;
canvas.drawCircle(toPos(player.x, player.y), w * player.size, borderPaint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}
+495
View File
@@ -0,0 +1,495 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:playwith_core/playwith_core.dart';
class WorldTourGame extends BaseGame {
@override
String get id => "world_tour";
@override
String get name => "월드 투어";
@override
String get description => "주사위로 떠나는 세계 여행\n건물을 지어 통행료를 높이세요!";
@override
void onMessageReceived(String senderId, Map<String, dynamic> payload) {
// UI에서 처리
}
@override
Widget buildHostView(BuildContext context) => WorldTourScreen(myTeam: 0, gameInstance: this); // 0: Red
@override
Widget buildGuestView(BuildContext context) => WorldTourScreen(myTeam: 1, gameInstance: this); // 1: Blue
}
// 도시 정보
class City {
final String name;
final int basePrice; // 땅값
final int baseToll; // 기본 통행료
int owner; // -1: 없음, 0: Red, 1: Blue
int buildingLevel; // 0: 땅만, 1: 1단계, 2: 2단계, 3: 랜드마크
City(this.name, this.basePrice, this.baseToll, {this.owner = -1, this.buildingLevel = 0});
// 현재 통행료 계산 (건물 1개당 50%씩 증가 -> 3단계면 2.5배)
int get currentToll => (baseToll * (1 + 0.5 * buildingLevel)).toInt();
// 업그레이드 비용 (땅값의 50%)
int get upgradeCost => (basePrice * 0.5).toInt();
}
class WorldTourScreen extends StatefulWidget {
final int myTeam; // 0: Red, 1: Blue
final WorldTourGame gameInstance;
const WorldTourScreen({super.key, required this.myTeam, required this.gameInstance});
@override
State<WorldTourScreen> createState() => _WorldTourScreenState();
}
class _WorldTourScreenState extends State<WorldTourScreen> {
// 보드 데이터 (20칸)
final List<City> board = [
City("출발", 0, 0), // 0
City("타이페이", 50, 30), // 1
City("베이징", 80, 40), // 2
City("마닐라", 100, 50), // 3
City("제주도", 150, 80), // 4
City("무인도", 0, 0), // 5
City("아테네", 180, 90), // 6
City("코펜하겐", 200, 100), // 7
City("오타와", 220, 110), // 8
City("베를린", 240, 120), // 9
City("사회복지", 0, 0), // 10
City("상파울루", 300, 150), // 11
City("시드니", 320, 160), // 12
City("하와이", 350, 180), // 13
City("리스본", 400, 200), // 14
City("세계여행", 0, 0), // 15
City("도쿄", 500, 300), // 16
City("파리", 600, 400), // 17
City("런던", 700, 500), // 18
City("서울", 1000, 800), // 19
];
List<int> positions = [0, 0]; // 위치
List<int> money = [2000, 2000]; // 자금
int currentTurn = 0; // 0: Red, 1: Blue
bool canRoll = true;
String infoMessage = "게임을 시작합니다!";
int? lastDice;
@override
void initState() {
super.initState();
NetworkManager().messageStream.listen(_handleMessage);
}
void _handleMessage(Map<String, dynamic> payload) {
if (!mounted) return;
if (payload['type'] == 'ROLL') {
int result = payload['result'];
int team = payload['team'];
setState(() {
lastDice = result;
_movePlayer(team, result);
});
} else if (payload['type'] == 'BUY') {
// 땅 구매 or 건물 업그레이드
int team = payload['team'];
int index = payload['index'];
int cost = payload['cost'];
bool isUpgrade = payload['isUpgrade'] ?? false;
setState(() {
money[team] -= cost;
board[index].owner = team;
if (isUpgrade) {
board[index].buildingLevel++;
infoMessage = "${board[index].name} 건물 증축! (${board[index].buildingLevel}단계)";
} else {
infoMessage = "${board[index].name} 구매 완료!";
}
_nextTurn();
});
} else if (payload['type'] == 'PAY') {
int from = payload['from'];
int to = payload['to'];
int amount = payload['amount'];
setState(() {
money[from] -= amount;
money[to] += amount;
infoMessage = "통행료 $amount 지불!";
_checkBankruptcy();
_nextTurn();
});
} else if (payload['type'] == 'PASS') {
_nextTurn();
} else if (payload['type'] == 'GAME_OVER') {
_showGameOverDialog(payload['winner']);
}
}
// --- 로직 ---
void _onRollDice() {
if (currentTurn != widget.myTeam || !canRoll) return;
int dice1 = Random().nextInt(6) + 1;
int dice2 = Random().nextInt(6) + 1;
int total = dice1 + dice2;
NetworkManager().sendMessage({'type': 'ROLL', 'result': total, 'team': widget.myTeam});
setState(() {
lastDice = total;
_movePlayer(widget.myTeam, total);
});
}
void _movePlayer(int team, int steps) {
canRoll = false;
int currentPos = positions[team];
int nextPos = (currentPos + steps) % 20;
// 한 바퀴 돌았는지 체크 (월급)
if (nextPos < currentPos) {
money[team] += 300; // 월급
infoMessage = "한 바퀴 돌았습니다! (+300)";
}
positions[team] = nextPos;
_handleArrival(team, nextPos);
}
void _handleArrival(int team, int index) {
City city = board[index];
// 1. 특수 지역 (출발, 무인도 등) - 주인 없음
if (city.basePrice == 0) {
if (team == widget.myTeam) {
Future.delayed(const Duration(seconds: 1), () {
NetworkManager().sendMessage({'type': 'PASS'});
_nextTurn();
});
}
return;
}
// 2. 빈 땅 -> 구매 가능
if (city.owner == -1) {
if (team == widget.myTeam) {
if (money[team] >= city.basePrice) {
_showBuyDialog(index, isUpgrade: false);
} else {
NetworkManager().sendMessage({'type': 'PASS'});
_nextTurn();
}
}
}
// 3. 내 땅 -> 업그레이드 가능 (3단계 미만일 때)
else if (city.owner == team) {
if (team == widget.myTeam) {
if (city.buildingLevel < 3 && money[team] >= city.upgradeCost) {
_showBuyDialog(index, isUpgrade: true);
} else {
// 이미 최고 레벨이거나 돈 부족
NetworkManager().sendMessage({'type': 'PASS'});
_nextTurn();
}
}
}
// 4. 남의 땅 -> 통행료
else {
if (team == widget.myTeam) {
int toll = city.currentToll;
NetworkManager().sendMessage({
'type': 'PAY',
'from': team,
'to': city.owner,
'amount': toll
});
setState(() {
money[team] -= toll;
money[city.owner] += toll;
infoMessage = "${city.name} 도착! 통행료 $toll 지불";
});
_checkBankruptcy();
_nextTurn();
}
}
}
void _showBuyDialog(int index, {required bool isUpgrade}) {
City city = board[index];
int cost = isUpgrade ? city.upgradeCost : city.basePrice;
String title = isUpgrade ? "${city.name} 증축?" : "${city.name} 구매?";
String content = isUpgrade
? "현재 단계: ${city.buildingLevel} -> ${city.buildingLevel + 1}\n비용: $cost"
: "가격: $cost\n기본 통행료: ${city.baseToll}";
showDialog(
context: context,
barrierDismissible: false,
builder: (_) => AlertDialog(
title: Text(title),
content: Text("$content\n보유 자금: ${money[widget.myTeam]}"),
actions: [
TextButton(
onPressed: () {
Navigator.pop(context);
NetworkManager().sendMessage({'type': 'PASS'});
_nextTurn();
},
child: const Text("패스"),
),
ElevatedButton(
onPressed: () {
Navigator.pop(context);
NetworkManager().sendMessage({
'type': 'BUY',
'team': widget.myTeam,
'index': index,
'cost': cost,
'isUpgrade': isUpgrade
});
setState(() {
money[widget.myTeam] -= cost;
board[index].owner = widget.myTeam;
if (isUpgrade) {
board[index].buildingLevel++;
infoMessage = "건물 업그레이드 완료!";
} else {
infoMessage = "구매 완료!";
}
_nextTurn();
});
},
child: Text(isUpgrade ? "증축" : "구매"),
),
],
),
);
}
void _nextTurn() {
setState(() {
currentTurn = 1 - currentTurn;
canRoll = true;
lastDice = null;
});
}
void _checkBankruptcy() {
if (money[0] < 0) _finishGame(1); // Blue Win
else if (money[1] < 0) _finishGame(0); // Red Win
}
void _finishGame(int winner) {
NetworkManager().sendMessage({'type': 'GAME_OVER', 'winner': winner});
_showGameOverDialog(winner);
}
void _showGameOverDialog(int winner) {
String msg = (winner == widget.myTeam) ? "승리! 🎉" : "파산했습니다... 💸";
showDialog(
context: context,
barrierDismissible: false,
builder: (_) => AlertDialog(
title: const Text("게임 종료"),
content: Text(msg),
actions: [
TextButton(
onPressed: () { Navigator.pop(context); Navigator.pop(context); },
child: const Text("나가기"),
)
],
),
);
}
// --- UI ---
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text("월드 투어")),
body: Column(
children: [
// 상태창
Container(
padding: const EdgeInsets.all(16),
color: Colors.blueGrey[50],
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildPlayerInfo(0, Colors.redAccent, "RED"),
const Text("VS", style: TextStyle(fontWeight: FontWeight.bold)),
_buildPlayerInfo(1, Colors.blueAccent, "BLUE"),
],
),
),
if (lastDice != null)
Padding(
padding: const EdgeInsets.all(8.0),
child: Text("주사위: $lastDice", style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
),
// 보드
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
return Padding(
padding: const EdgeInsets.all(10.0),
child: CustomPaint(
size: Size(constraints.maxWidth, constraints.maxWidth),
painter: BoardPainter(board, positions),
),
);
},
),
),
// 컨트롤
Padding(
padding: const EdgeInsets.all(20),
child: SizedBox(
width: double.infinity,
height: 60,
child: ElevatedButton(
onPressed: (currentTurn == widget.myTeam && canRoll) ? _onRollDice : null,
style: ElevatedButton.styleFrom(
backgroundColor: widget.myTeam == 0 ? Colors.redAccent : Colors.blueAccent,
foregroundColor: Colors.white,
),
child: Text((currentTurn == widget.myTeam) ? "주사위 굴리기" : "상대방 차례"),
),
),
),
],
),
);
}
Widget _buildPlayerInfo(int team, Color color, String name) {
bool isTurn = currentTurn == team;
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: isTurn ? color.withOpacity(0.2) : Colors.transparent,
border: Border.all(color: color, width: 2),
borderRadius: BorderRadius.circular(10),
),
child: Column(
children: [
Text(name, style: TextStyle(fontWeight: FontWeight.bold, color: color)),
Text("${money[team]}", style: const TextStyle(fontSize: 18)),
],
),
);
}
}
// 보드 그리기 (ㅁ자 형태 + 건물 표시)
class BoardPainter extends CustomPainter {
final List<City> board;
final List<int> positions;
BoardPainter(this.board, this.positions);
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()..style = PaintingStyle.stroke..strokeWidth = 1.0;
final fillPaint = Paint()..style = PaintingStyle.fill;
double cellSize = size.width / 6;
// 칸 그리기
for (int i = 0; i < 20; i++) {
Rect rect = _getRect(i, cellSize, size.width);
// 땅 주인 색칠
if (board[i].owner != -1) {
fillPaint.color = board[i].owner == 0 ? Colors.redAccent.withOpacity(0.3) : Colors.blueAccent.withOpacity(0.3);
canvas.drawRect(rect, fillPaint);
} else if (i % 5 == 0) {
fillPaint.color = Colors.grey.withOpacity(0.2);
canvas.drawRect(rect, fillPaint);
}
paint.color = Colors.black;
canvas.drawRect(rect, paint);
// 텍스트 (도시 이름)
_drawText(canvas, board[i].name, rect.center, i);
// [추가] 건물 표시 (별)
if (board[i].owner != -1 && board[i].buildingLevel > 0) {
_drawBuilding(canvas, rect, board[i].buildingLevel, board[i].owner == 0 ? Colors.red : Colors.blue);
}
}
// 말 그리기
_drawToken(canvas, _getRect(positions[0], cellSize, size.width).center, Colors.red, -5);
_drawToken(canvas, _getRect(positions[1], cellSize, size.width).center, Colors.blue, 5);
}
void _drawBuilding(Canvas canvas, Rect rect, int level, Color color) {
// 상단에 작은 원(또는 별)으로 건물 단계 표시
final paint = Paint()..color = color..style = PaintingStyle.fill;
double yPos = rect.top + 8;
double startX = rect.center.dx - ((level - 1) * 6); // 중앙 정렬
for (int i = 0; i < level; i++) {
canvas.drawCircle(Offset(startX + (i * 12), yPos), 3, paint);
}
}
void _drawToken(Canvas canvas, Offset center, Color color, double offset) {
final paint = Paint()..color = color..style = PaintingStyle.fill;
canvas.drawCircle(center + Offset(offset, offset), 8, paint);
paint.style = PaintingStyle.stroke;
paint.color = Colors.white;
paint.strokeWidth = 2;
canvas.drawCircle(center + Offset(offset, offset), 8, paint);
}
void _drawText(Canvas canvas, String text, Offset center, int index) {
TextSpan span = TextSpan(style: const TextStyle(color: Colors.black, fontSize: 10, fontWeight: FontWeight.bold), text: text);
TextPainter tp = TextPainter(text: span, textAlign: TextAlign.center, textDirection: TextDirection.ltr);
tp.layout();
tp.paint(canvas, center - Offset(tp.width / 2, tp.height / 2 - 5)); // 텍스트 약간 위로 (가격 등 표시 공간 확보 위해)
}
Rect _getRect(int index, double size, double totalSize) {
// 좌표 계산 로직 (기존 동일)
int side = index ~/ 5;
double x = 0, y = 0;
if (index >= 0 && index <= 5) {
x = totalSize - (index + 1) * size;
y = totalSize - size;
} else if (index > 5 && index <= 10) {
x = 0;
y = totalSize - (index - 5 + 1) * size;
} else if (index > 10 && index <= 15) {
x = (index - 10) * size;
y = 0;
} else {
x = totalSize - size;
y = (index - 15) * size;
}
return Rect.fromLTWH(x, y, size, size);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}