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
@@ -4,44 +4,61 @@ import 'package:drift/native.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as p;
part 'ephemeral_database.g.dart'; // (나중에 생성될 파일)
part 'ephemeral_database.g.dart';
// 테이블 정의: 미디어 메타데이터
// 기존 MediaItems 테이블
class MediaItems extends Table {
TextColumn get id => text()(); // UUID
TextColumn get id => text()();
TextColumn get senderId => text()();
TextColumn get senderName => text()();
TextColumn get type => text()(); // 'IMAGE', 'VIDEO', 'AUDIO'
TextColumn get filePath => text()(); // 로컬 저장 경로
TextColumn get type => text()();
TextColumn get filePath => text()();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
@override
Set<Column> get primaryKey => {id};
}
@DriftDatabase(tables: [MediaItems])
// [추가] 패킷 로그 테이블 (재전송용)
class PacketLogs extends Table {
IntColumn get seq => integer()(); // 순번 (Primary Key)
TextColumn get payload => text()(); // JSON 데이터
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
@override
Set<Column> get primaryKey => {seq};
}
@DriftDatabase(tables: [MediaItems, PacketLogs]) // [수정] PacketLogs 추가
class EphemeralDatabase extends _$EphemeralDatabase {
// 싱글톤이 아닌 인스턴스로 관리 (방 만들 때 생성, 나갈 때 close)
EphemeralDatabase(QueryExecutor e) : super(e);
@override
int get schemaVersion => 1;
int get schemaVersion => 2; // [수정] 버전 업 (기존 앱 삭제 후 설치 권장)
// 팩토리: 파일 기반 DB 생성
static Future<EphemeralDatabase> create(String roomName) async {
final dbFolder = await getApplicationDocumentsDirectory();
// 방 이름이나 ID로 파일명 구분
final file = File(p.join(dbFolder.path, 'room_$roomName.sqlite'));
return EphemeralDatabase(NativeDatabase(file));
}
// CRUD 쿼리
Future<List<MediaItem>> getAllMedia() => select(mediaItems).get();
Future<int> insertMedia(MediaItemsCompanion entry) => into(mediaItems).insert(entry);
// [핵심] 방 폭파 시 데이터 삭제
// [추가] 패킷 저장
Future<int> logPacket(int seq, String json) {
return into(packetLogs).insert(PacketLogsCompanion(
seq: Value(seq),
payload: Value(json),
));
}
// [추가] 특정 범위의 패킷 가져오기 (재전송 요청 시 사용)
Future<List<PacketLog>> getPacketsInRange(int fromSeq, int toSeq) {
return (select(packetLogs)..where((tbl) => tbl.seq.isBetweenValues(fromSeq, toSeq))).get();
}
Future<void> wipeData() async {
await close(); // DB 연결 종료
// 실제 파일 삭제 로직은 Manager에서 수행 (DB 파일 자체를 날림)
await close();
}
}
@@ -351,15 +351,229 @@ class MediaItemsCompanion extends UpdateCompanion<MediaItem> {
}
}
class $PacketLogsTable extends PacketLogs
with TableInfo<$PacketLogsTable, PacketLog> {
@override
final GeneratedDatabase attachedDatabase;
final String? _alias;
$PacketLogsTable(this.attachedDatabase, [this._alias]);
static const VerificationMeta _seqMeta = const VerificationMeta('seq');
@override
late final GeneratedColumn<int> seq = GeneratedColumn<int>(
'seq', aliasedName, false,
type: DriftSqlType.int, requiredDuringInsert: false);
static const VerificationMeta _payloadMeta =
const VerificationMeta('payload');
@override
late final GeneratedColumn<String> payload = GeneratedColumn<String>(
'payload', aliasedName, false,
type: DriftSqlType.string, requiredDuringInsert: true);
static const VerificationMeta _createdAtMeta =
const VerificationMeta('createdAt');
@override
late final GeneratedColumn<DateTime> createdAt = GeneratedColumn<DateTime>(
'created_at', aliasedName, false,
type: DriftSqlType.dateTime,
requiredDuringInsert: false,
defaultValue: currentDateAndTime);
@override
List<GeneratedColumn> get $columns => [seq, payload, createdAt];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'packet_logs';
@override
VerificationContext validateIntegrity(Insertable<PacketLog> instance,
{bool isInserting = false}) {
final context = VerificationContext();
final data = instance.toColumns(true);
if (data.containsKey('seq')) {
context.handle(
_seqMeta, seq.isAcceptableOrUnknown(data['seq']!, _seqMeta));
}
if (data.containsKey('payload')) {
context.handle(_payloadMeta,
payload.isAcceptableOrUnknown(data['payload']!, _payloadMeta));
} else if (isInserting) {
context.missing(_payloadMeta);
}
if (data.containsKey('created_at')) {
context.handle(_createdAtMeta,
createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta));
}
return context;
}
@override
Set<GeneratedColumn> get $primaryKey => {seq};
@override
PacketLog map(Map<String, dynamic> data, {String? tablePrefix}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return PacketLog(
seq: attachedDatabase.typeMapping
.read(DriftSqlType.int, data['${effectivePrefix}seq'])!,
payload: attachedDatabase.typeMapping
.read(DriftSqlType.string, data['${effectivePrefix}payload'])!,
createdAt: attachedDatabase.typeMapping
.read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!,
);
}
@override
$PacketLogsTable createAlias(String alias) {
return $PacketLogsTable(attachedDatabase, alias);
}
}
class PacketLog extends DataClass implements Insertable<PacketLog> {
final int seq;
final String payload;
final DateTime createdAt;
const PacketLog(
{required this.seq, required this.payload, required this.createdAt});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
map['seq'] = Variable<int>(seq);
map['payload'] = Variable<String>(payload);
map['created_at'] = Variable<DateTime>(createdAt);
return map;
}
PacketLogsCompanion toCompanion(bool nullToAbsent) {
return PacketLogsCompanion(
seq: Value(seq),
payload: Value(payload),
createdAt: Value(createdAt),
);
}
factory PacketLog.fromJson(Map<String, dynamic> json,
{ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return PacketLog(
seq: serializer.fromJson<int>(json['seq']),
payload: serializer.fromJson<String>(json['payload']),
createdAt: serializer.fromJson<DateTime>(json['createdAt']),
);
}
@override
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'seq': serializer.toJson<int>(seq),
'payload': serializer.toJson<String>(payload),
'createdAt': serializer.toJson<DateTime>(createdAt),
};
}
PacketLog copyWith({int? seq, String? payload, DateTime? createdAt}) =>
PacketLog(
seq: seq ?? this.seq,
payload: payload ?? this.payload,
createdAt: createdAt ?? this.createdAt,
);
PacketLog copyWithCompanion(PacketLogsCompanion data) {
return PacketLog(
seq: data.seq.present ? data.seq.value : this.seq,
payload: data.payload.present ? data.payload.value : this.payload,
createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt,
);
}
@override
String toString() {
return (StringBuffer('PacketLog(')
..write('seq: $seq, ')
..write('payload: $payload, ')
..write('createdAt: $createdAt')
..write(')'))
.toString();
}
@override
int get hashCode => Object.hash(seq, payload, createdAt);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is PacketLog &&
other.seq == this.seq &&
other.payload == this.payload &&
other.createdAt == this.createdAt);
}
class PacketLogsCompanion extends UpdateCompanion<PacketLog> {
final Value<int> seq;
final Value<String> payload;
final Value<DateTime> createdAt;
const PacketLogsCompanion({
this.seq = const Value.absent(),
this.payload = const Value.absent(),
this.createdAt = const Value.absent(),
});
PacketLogsCompanion.insert({
this.seq = const Value.absent(),
required String payload,
this.createdAt = const Value.absent(),
}) : payload = Value(payload);
static Insertable<PacketLog> custom({
Expression<int>? seq,
Expression<String>? payload,
Expression<DateTime>? createdAt,
}) {
return RawValuesInsertable({
if (seq != null) 'seq': seq,
if (payload != null) 'payload': payload,
if (createdAt != null) 'created_at': createdAt,
});
}
PacketLogsCompanion copyWith(
{Value<int>? seq, Value<String>? payload, Value<DateTime>? createdAt}) {
return PacketLogsCompanion(
seq: seq ?? this.seq,
payload: payload ?? this.payload,
createdAt: createdAt ?? this.createdAt,
);
}
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
if (seq.present) {
map['seq'] = Variable<int>(seq.value);
}
if (payload.present) {
map['payload'] = Variable<String>(payload.value);
}
if (createdAt.present) {
map['created_at'] = Variable<DateTime>(createdAt.value);
}
return map;
}
@override
String toString() {
return (StringBuffer('PacketLogsCompanion(')
..write('seq: $seq, ')
..write('payload: $payload, ')
..write('createdAt: $createdAt')
..write(')'))
.toString();
}
}
abstract class _$EphemeralDatabase extends GeneratedDatabase {
_$EphemeralDatabase(QueryExecutor e) : super(e);
$EphemeralDatabaseManager get managers => $EphemeralDatabaseManager(this);
late final $MediaItemsTable mediaItems = $MediaItemsTable(this);
late final $PacketLogsTable packetLogs = $PacketLogsTable(this);
@override
Iterable<TableInfo<Table, Object?>> get allTables =>
allSchemaEntities.whereType<TableInfo<Table, Object?>>();
@override
List<DatabaseSchemaEntity> get allSchemaEntities => [mediaItems];
List<DatabaseSchemaEntity> get allSchemaEntities => [mediaItems, packetLogs];
}
typedef $$MediaItemsTableCreateCompanionBuilder = MediaItemsCompanion Function({
@@ -548,10 +762,147 @@ typedef $$MediaItemsTableProcessedTableManager = ProcessedTableManager<
),
MediaItem,
PrefetchHooks Function()>;
typedef $$PacketLogsTableCreateCompanionBuilder = PacketLogsCompanion Function({
Value<int> seq,
required String payload,
Value<DateTime> createdAt,
});
typedef $$PacketLogsTableUpdateCompanionBuilder = PacketLogsCompanion Function({
Value<int> seq,
Value<String> payload,
Value<DateTime> createdAt,
});
class $$PacketLogsTableFilterComposer
extends Composer<_$EphemeralDatabase, $PacketLogsTable> {
$$PacketLogsTableFilterComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnFilters<int> get seq => $composableBuilder(
column: $table.seq, builder: (column) => ColumnFilters(column));
ColumnFilters<String> get payload => $composableBuilder(
column: $table.payload, builder: (column) => ColumnFilters(column));
ColumnFilters<DateTime> get createdAt => $composableBuilder(
column: $table.createdAt, builder: (column) => ColumnFilters(column));
}
class $$PacketLogsTableOrderingComposer
extends Composer<_$EphemeralDatabase, $PacketLogsTable> {
$$PacketLogsTableOrderingComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnOrderings<int> get seq => $composableBuilder(
column: $table.seq, builder: (column) => ColumnOrderings(column));
ColumnOrderings<String> get payload => $composableBuilder(
column: $table.payload, builder: (column) => ColumnOrderings(column));
ColumnOrderings<DateTime> get createdAt => $composableBuilder(
column: $table.createdAt, builder: (column) => ColumnOrderings(column));
}
class $$PacketLogsTableAnnotationComposer
extends Composer<_$EphemeralDatabase, $PacketLogsTable> {
$$PacketLogsTableAnnotationComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
GeneratedColumn<int> get seq =>
$composableBuilder(column: $table.seq, builder: (column) => column);
GeneratedColumn<String> get payload =>
$composableBuilder(column: $table.payload, builder: (column) => column);
GeneratedColumn<DateTime> get createdAt =>
$composableBuilder(column: $table.createdAt, builder: (column) => column);
}
class $$PacketLogsTableTableManager extends RootTableManager<
_$EphemeralDatabase,
$PacketLogsTable,
PacketLog,
$$PacketLogsTableFilterComposer,
$$PacketLogsTableOrderingComposer,
$$PacketLogsTableAnnotationComposer,
$$PacketLogsTableCreateCompanionBuilder,
$$PacketLogsTableUpdateCompanionBuilder,
(
PacketLog,
BaseReferences<_$EphemeralDatabase, $PacketLogsTable, PacketLog>
),
PacketLog,
PrefetchHooks Function()> {
$$PacketLogsTableTableManager(_$EphemeralDatabase db, $PacketLogsTable table)
: super(TableManagerState(
db: db,
table: table,
createFilteringComposer: () =>
$$PacketLogsTableFilterComposer($db: db, $table: table),
createOrderingComposer: () =>
$$PacketLogsTableOrderingComposer($db: db, $table: table),
createComputedFieldComposer: () =>
$$PacketLogsTableAnnotationComposer($db: db, $table: table),
updateCompanionCallback: ({
Value<int> seq = const Value.absent(),
Value<String> payload = const Value.absent(),
Value<DateTime> createdAt = const Value.absent(),
}) =>
PacketLogsCompanion(
seq: seq,
payload: payload,
createdAt: createdAt,
),
createCompanionCallback: ({
Value<int> seq = const Value.absent(),
required String payload,
Value<DateTime> createdAt = const Value.absent(),
}) =>
PacketLogsCompanion.insert(
seq: seq,
payload: payload,
createdAt: createdAt,
),
withReferenceMapper: (p0) => p0
.map((e) => (e.readTable(table), BaseReferences(db, table, e)))
.toList(),
prefetchHooksCallback: null,
));
}
typedef $$PacketLogsTableProcessedTableManager = ProcessedTableManager<
_$EphemeralDatabase,
$PacketLogsTable,
PacketLog,
$$PacketLogsTableFilterComposer,
$$PacketLogsTableOrderingComposer,
$$PacketLogsTableAnnotationComposer,
$$PacketLogsTableCreateCompanionBuilder,
$$PacketLogsTableUpdateCompanionBuilder,
(
PacketLog,
BaseReferences<_$EphemeralDatabase, $PacketLogsTable, PacketLog>
),
PacketLog,
PrefetchHooks Function()>;
class $EphemeralDatabaseManager {
final _$EphemeralDatabase _db;
$EphemeralDatabaseManager(this._db);
$$MediaItemsTableTableManager get mediaItems =>
$$MediaItemsTableTableManager(_db, _db.mediaItems);
$$PacketLogsTableTableManager get packetLogs =>
$$PacketLogsTableTableManager(_db, _db.packetLogs);
}
+265
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;
}
@@ -40,6 +40,7 @@ class MediaManager {
MediaManager._internal();
EphemeralDatabase? _db;
EphemeralDatabase? get db => _db;
String? _currentRoomId;
final Map<String, _TransferState> _activeTransfers = {};
+59
View File
@@ -85,6 +85,65 @@ class AppGames {
icon: Icons.touch_app,
isSinglePlayerSupported: false,
),
GameInfo(
id: 'world_tour',
name: '월드 투어',
description: '주사위를 굴려 세계 여행!\n땅을 사고 통행료를 받으세요.',
icon: Icons.public,
isSinglePlayerSupported: false,
),
// [추가] 오셀로
GameInfo(
id: 'othello',
name: '오셀로',
description: '돌을 뒤집어라!\n마지막에 웃는 자가 승리',
icon: Icons.circle, // 흑백 원 아이콘
isSinglePlayerSupported: false,
),
// [추가] 알카노이드
GameInfo(
id: 'arkanoid',
name: '벽돌 깨기',
description: '추억의 아케이드!\n누가 더 높은 점수를 낼까?',
icon: Icons.view_module,
isSinglePlayerSupported: true,
),// [추가] 매스 런 (게이트 런)
GameInfo(
id: 'math_run',
name: '매스 런',
description: '좌우로 움직여 숫자를 늘리세요!\n높은 점수가 승리합니다.',
icon: Icons.calculate,
isSinglePlayerSupported: true,
),
// [추가] 점프 배틀 (횡스크롤)
GameInfo(
id: 'jump_battle',
name: '점프 배틀',
description: '장애물을 피해 끝까지 달리세요!\n타이밍 싸움!',
icon: Icons.directions_run,
isSinglePlayerSupported: true,
),
GameInfo(
id: 'iam_ground',
name: '아이엠그라운드',
description: '리듬을 타며 이름을 공격하세요!\n박자를 놓치면 탈락!',
icon: Icons.music_note, // 음표 아이콘
isSinglePlayerSupported: false, // 최소 2인 이상
),
GameInfo(
id: 'survivor',
name: '서바이버',
description: '몰려오는 몬스터를 막아내세요!\n이동만 하면 자동으로 공격합니다.',
icon: Icons.bug_report,
isSinglePlayerSupported: true,
),
GameInfo(
id: 'sequence_memory',
name: '기억의 신',
description: '반짝이는 순서를 기억하세요!\n라운드가 갈수록 종류가 다양해집니다.',
icon: Icons.apps,
isSinglePlayerSupported: true,
),
];
static GameInfo getById(String id) {
+8 -12
View File
@@ -1,44 +1,41 @@
import 'dart:convert';
/// 패킷의 종류 (라우팅 기준)
enum PacketType {
system, // 시스템 (레디, 시작, 종료, 핸드셰이크 등)
chat, // 채팅 (GlobalChatManager로 전달)
game, // 게임 로직 (GameController로 전달)
media,
unknown
system, chat, game, media, unknown
}
class PlayPacket {
final PacketType type;
final String senderId; // 보낸 사람 ID
final dynamic payload; // 실제 데이터 (Map, List, String 등)
final String senderId;
final dynamic payload;
final int timestamp;
final int? seq; // [추가] 패킷 순번
PlayPacket({
required this.type,
required this.senderId,
required this.payload,
required this.timestamp,
this.seq, // [추가]
});
// JSON -> 객체
factory PlayPacket.fromJson(Map<String, dynamic> json) {
return PlayPacket(
type: _parseType(json['type']),
senderId: json['senderId'] ?? 'unknown',
payload: json['payload'],
timestamp: json['timestamp'] ?? DateTime.now().millisecondsSinceEpoch,
seq: json['seq'], // [추가]
);
}
// 객체 -> JSON
Map<String, dynamic> toJson() {
return {
'type': type.name, // enum을 문자열로 ('chat', 'game'...)
'type': type.name,
'senderId': senderId,
'payload': payload,
'timestamp': timestamp,
if (seq != null) 'seq': seq, // [추가]
};
}
@@ -46,7 +43,6 @@ class PlayPacket {
for (var t in PacketType.values) {
if (t.name == typeStr) return t;
}
// 호환성: 기존 레거시 메시지(PING, ANSWER_SUBMIT 등)는 'unknown'이나 별도 처리
return PacketType.unknown;
}
}
@@ -0,0 +1,28 @@
class SpiderGameDto {
final int puzzleId;
final int difficulty; // 1, 2, 4 (Suits)
final List<int> cards; // 0~103 (카드 덱)
SpiderGameDto({
required this.puzzleId,
required this.difficulty,
required this.cards,
});
factory SpiderGameDto.fromJson(Map<String, dynamic> json) {
return SpiderGameDto(
puzzleId: json['puzzleId'] ?? 0,
difficulty: json['difficulty'] ?? 1,
// 카드 배열 파싱
cards: (json['cards'] as List<dynamic>?)?.map((e) => e as int).toList() ?? [],
);
}
Map<String, dynamic> toJson() {
return {
'puzzleId': puzzleId,
'difficulty': difficulty,
'cards': cards,
};
}
}
+194 -153
View File
@@ -12,7 +12,7 @@ import '../model/user_info.dart';
import '../model/play_packet.dart';
import '../manager/global_chat_manager.dart';
import '../manager/media_manager.dart';
import '../manager/notification_manager.dart';
import '../database/ephemeral_database.dart';
enum NetworkRole { none, host, guest }
@@ -24,17 +24,13 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
WidgetsBinding.instance.addObserver(this);
}
// ------------------------------------------------------------------------
// 상수 설정
// ------------------------------------------------------------------------
static const String PACKET_DELIMITER = "|||EOP|||";
static const int HEARTBEAT_INTERVAL_SEC = 3;
static const int TIMEOUT_SEC = 10;
static const int RECONNECT_WAIT_SEC = 5;
// ------------------------------------------------------------------------
// 상태 변수
// ------------------------------------------------------------------------
late UserInfo me;
NetworkRole role = NetworkRole.none;
@@ -44,14 +40,15 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
ServerSocket? _serverSocket;
Socket? _clientSocket;
final Map<Socket, UserInfo?> _connectedGuests = {};
final List<UserInfo> guestList = [];
final Map<Socket, UserInfo?> _connectedGuests = {};
final Map<Socket, String> _packetBuffers = {};
BonsoirService? _bonsoirService;
BonsoirBroadcast? _bonsoirBroadcast;
BonsoirDiscovery? _bonsoirDiscovery;
final List<UserInfo> guestList = [];
final _messageController = StreamController<Map<String, dynamic>>.broadcast();
Stream<Map<String, dynamic>> get messageStream => _messageController.stream;
@@ -63,9 +60,13 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
DateTime? _lastPongTime;
bool _isReconnecting = false;
// [추가] 현재 선택된 게임 ID 및 설정
String selectedGameId = 'quiz_mix';
Map<String, dynamic> selectedGameConfig = {}; // 난이도 등 저장
Map<String, dynamic> selectedGameConfig = {};
int _sendSeq = 0;
int _recvSeq = 0;
EphemeralDatabase? get _database => MediaManager().db;
// ------------------------------------------------------------------------
// 초기화
@@ -98,82 +99,24 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
}
// ------------------------------------------------------------------------
// 레디 시스템
// ------------------------------------------------------------------------
// [수정] 게임 선택 함수 (Config 추가)
void selectGame(String gameId, {Map<String, dynamic>? config}) {
selectedGameId = gameId;
selectedGameConfig = config ?? {};
notifyListeners();
}
void toggleReady() {
me = me.copyWith(isReady: !me.isReady);
notifyListeners();
final payload = {
'type': 'TOGGLE_READY',
'userId': me.id,
'isReady': me.isReady,
};
sendMessage(payload);
if (role == NetworkRole.host) {
_checkAllReadyAndStart();
}
}
void _checkAllReadyAndStart() {
if (guestList.isEmpty) return;
if (!me.isReady) return;
bool allGuestsReady = guestList.every((u) => u.isReady);
if (allGuestsReady) {
_log("🚀 전원 준비 완료! 3초 후 게임 시작...");
Future.delayed(const Duration(seconds: 1), () {
// [수정] 게임 시작 패킷에 Config 포함
final startPayload = {
'type': 'GAME_START',
'gameId': selectedGameId,
'config': selectedGameConfig
};
sendMessage(startPayload);
_messageController.add(startPayload);
_resetAllReadyState();
});
}
}
void _resetAllReadyState() {
me = me.copyWith(isReady: false);
for (int i = 0; i < guestList.length; i++) {
guestList[i] = guestList[i].copyWith(isReady: false);
}
notifyListeners();
}
// ------------------------------------------------------------------------
// Host Logic
// [Socket] 호스팅 로직 (WiFi/Hotspot)
// ------------------------------------------------------------------------
Future<void> startHosting(String roomName) async {
stopNetwork(force: true);
await stopNetwork(force: true);
role = NetworkRole.host;
_sendSeq = 0; _recvSeq = 0;
try {
_serverSocket = await ServerSocket.bind(InternetAddress.anyIPv4, 0);
int port = _serverSocket!.port;
this.hostPort = port;
String? myIp = await _getWifiIp();
this.hostIp = myIp ?? '127.0.0.1';
_log("✅ 방 생성: $hostIp : $port");
_serverSocket!.listen((Socket client) {
_handleNewGuest(client);
});
_bonsoirService = BonsoirService(
name: '$roomName#${me.id}',
type: '_playwith._tcp',
@@ -182,11 +125,9 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
);
_bonsoirBroadcast = BonsoirBroadcast(service: _bonsoirService!);
await _bonsoirBroadcast!.start();
await MediaManager().initialize(roomName);
_startHeartbeat();
notifyListeners();
} catch (e) {
_log("❌ 방 생성 실패: $e");
stopNetwork(force: true);
@@ -199,8 +140,16 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
_packetBuffers[client] = "";
final myHandshake = {'type': 'HANDSHAKE', 'payload': me.toJson()};
final jsonString = jsonEncode(myHandshake);
client.add(utf8.encode('$jsonString$PACKET_DELIMITER'));
client.add(utf8.encode('${jsonEncode(myHandshake)}$PACKET_DELIMITER'));
Future.delayed(const Duration(milliseconds: 500), () {
final gameSync = {
'type': 'GAME_CHANGED',
'gameId': selectedGameId,
'config': selectedGameConfig
};
client.add(utf8.encode('${jsonEncode(gameSync)}$PACKET_DELIMITER'));
});
client.listen(
(Uint8List data) => _onDataReceived(client, data),
@@ -221,9 +170,7 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
notifyListeners();
}
// ------------------------------------------------------------------------
// Guest Logic
// ------------------------------------------------------------------------
// [Socket] 게스트 로직
Stream<List<BonsoirService>> discoverRooms() {
final controller = StreamController<List<BonsoirService>>();
final List<BonsoirService> foundServices = [];
@@ -233,7 +180,6 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
try {
_bonsoirDiscovery = BonsoirDiscovery(type: '_playwith._tcp');
await _bonsoirDiscovery!.start();
if (_bonsoirDiscovery?.eventStream != null) {
_bonsoirDiscovery!.eventStream!.listen((dynamic event) {
final String type = event.type.toString();
@@ -256,60 +202,30 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
return controller.stream;
}
// [수정] 싱글 모드 시작 시 Config 추가
Future<void> startSoloMode(String gameId, {Map<String, dynamic>? config}) async {
stopNetwork(force: true);
role = NetworkRole.host;
hostIp = "Solo Mode";
hostPort = 0;
selectedGameId = gameId;
selectedGameConfig = config ?? {}; // Config 저장
await MediaManager().initialize("solo_session");
_log("👤 싱글 플레이 모드 시작: $gameId");
notifyListeners();
// 바로 시작 신호
Future.delayed(const Duration(milliseconds: 100), () {
_messageController.add({
'type': 'GAME_START',
'gameId': gameId,
'config': selectedGameConfig
});
});
}
Future<void> joinRoom(String ip, int port) async {
if (role != NetworkRole.guest) stopNetwork(force: true);
if (role != NetworkRole.guest) await stopNetwork(force: true);
role = NetworkRole.guest;
hostIp = ip;
hostPort = port;
_sendSeq = 0; _recvSeq = 0;
try {
_log("🚀 접속 시도: $ip:$port");
_clientSocket = await Socket.connect(ip, port, timeout: const Duration(seconds: 5));
_log("✅ 접속 성공!");
_packetBuffers[_clientSocket!] = "";
sendMessage({'type': 'HANDSHAKE', 'payload': me.toJson()});
final myHandshake = {'type': 'HANDSHAKE', 'payload': me.toJson()};
_clientSocket!.add(utf8.encode('${jsonEncode(myHandshake)}$PACKET_DELIMITER'));
await MediaManager().initialize("guest_${ip.replaceAll('.', '_')}");
_lastPongTime = DateTime.now();
_startHeartbeat();
_cancelDisconnectTimer();
_clientSocket!.listen(
(Uint8List data) => _onDataReceived(_clientSocket!, data),
onError: (e) => _handleConnectionLost(e),
onDone: () => _handleConnectionLost("Socket Closed"),
onError: (e) => stopNetwork(force: true),
onDone: () => stopNetwork(force: true),
);
notifyListeners();
} catch (e) {
_log("❌ 접속 실패: $e");
if (!_isReconnecting) stopNetwork(force: true);
@@ -317,6 +233,25 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
}
}
// 싱글 모드
Future<void> startSoloMode(String gameId, {Map<String, dynamic>? config}) async {
await stopNetwork(force: true);
role = NetworkRole.host;
hostIp = "Solo Mode";
hostPort = 0;
selectedGameId = gameId;
selectedGameConfig = config ?? {};
_sendSeq = 0; _recvSeq = 0;
await MediaManager().initialize("solo_session");
_log("👤 싱글 플레이 모드: $gameId");
notifyListeners();
Future.delayed(const Duration(milliseconds: 100), () {
_messageController.add({'type': 'GAME_START', 'gameId': gameId, 'config': selectedGameConfig});
});
}
// ------------------------------------------------------------------------
// 데이터 송수신
// ------------------------------------------------------------------------
@@ -327,15 +262,20 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
void sendMessage(Map<String, dynamic> messageMap) {
if (role == NetworkRole.guest && _clientSocket == null) return;
final String type = messageMap['type'] ?? '';
bool isSystem = ['PING', 'PONG', 'HANDSHAKE', 'REQ_RESEND', 'RESEND_DATA'].contains(type);
if (!isSystem) {
_sendSeq++;
messageMap['seq'] = _sendSeq;
if (_database != null) _database!.logPacket(_sendSeq, jsonEncode(messageMap));
}
final jsonString = jsonEncode(messageMap);
if (messageMap['type'] != 'PING' && messageMap['type'] != 'PONG') {
if (messageMap['type'] == 'chat') {
_log("📤 전송: [CHAT]");
} else if (messageMap['type'] == 'media') {
_log("📤 전송: [MEDIA]");
} else {
_log("📤 전송: $jsonString");
}
if (!isSystem) {
if (type == 'chat') _log("📤 전송(#$_sendSeq): [CHAT]");
else if (type == 'media') _log("📤 전송(#$_sendSeq): [MEDIA]");
else _log("📤 전송(#$_sendSeq): $jsonString");
}
final fullMessage = '$jsonString$PACKET_DELIMITER';
@@ -354,12 +294,10 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
try {
String buffer = _packetBuffers[socket] ?? "";
buffer += utf8.decode(data, allowMalformed: true);
while (buffer.contains(PACKET_DELIMITER)) {
final int delimiterIndex = buffer.indexOf(PACKET_DELIMITER);
final String msg = buffer.substring(0, delimiterIndex);
buffer = buffer.substring(delimiterIndex + PACKET_DELIMITER.length);
if (msg.trim().isNotEmpty) _processMessage(socket, msg);
}
_packetBuffers[socket] = buffer;
@@ -368,35 +306,63 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
}
}
void _processMessage(Socket socket, String msg) {
void _processMessage(Socket? socket, String msg) {
try {
final Map<String, dynamic> jsonMap = jsonDecode(msg);
final String type = jsonMap['type'] ?? '';
if (jsonMap['type'] == 'PING') {
if (type == 'PING') {
sendMessage({'type': 'PONG'});
_lastPongTime = DateTime.now();
return;
}
if (jsonMap['type'] == 'PONG') {
if (type == 'PONG') {
_lastPongTime = DateTime.now();
return;
}
if (jsonMap['type'] == 'HANDSHAKE') {
if (type == 'HANDSHAKE') {
final guestInfo = UserInfo.fromJson(jsonMap['payload']);
if (role == NetworkRole.host) {
if (role == NetworkRole.host && socket != null) {
_connectedGuests[socket] = guestInfo;
}
guestList.removeWhere((u) => u.id == guestInfo.id);
guestList.add(guestInfo);
notifyListeners();
_messageController.add(jsonMap);
return;
}
if (jsonMap['type'] == 'TOGGLE_READY') {
if (type == 'GAME_CHANGED') {
if (jsonMap['gameId'] != null) {
selectedGameId = jsonMap['gameId'];
selectedGameConfig = jsonMap['config'] ?? {};
notifyListeners();
}
return;
}
if (type == 'REQ_RESEND') {
_handleResendRequest(jsonMap['from'], jsonMap['to']);
return;
}
if (type == 'RESEND_DATA') {
_processMessage(socket, jsonMap['data']);
return;
}
if (jsonMap.containsKey('seq')) {
int seq = jsonMap['seq'];
if (seq > _recvSeq + 1) {
_log("⚠️ 패킷 유실 감지! (기대: ${_recvSeq + 1}, 수신: $seq)");
sendMessage({
'type': 'REQ_RESEND',
'from': _recvSeq + 1,
'to': seq - 1
});
}
if (seq > _recvSeq) _recvSeq = seq;
}
if (type == 'TOGGLE_READY') {
final String userId = jsonMap['userId'];
final bool isReady = jsonMap['isReady'];
final index = guestList.indexWhere((u) => u.id == userId);
@@ -412,14 +378,9 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
return;
}
if (jsonMap['type'] == 'GAME_START') {
if (jsonMap['gameId'] != null) {
selectedGameId = jsonMap['gameId'];
}
// [추가] Config 동기화
if (jsonMap['config'] != null) {
selectedGameConfig = jsonMap['config'];
}
if (type == 'GAME_START') {
if (jsonMap['gameId'] != null) selectedGameId = jsonMap['gameId'];
if (jsonMap['config'] != null) selectedGameConfig = jsonMap['config'];
_resetAllReadyState();
_messageController.add(jsonMap);
return;
@@ -444,6 +405,22 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
}
}
Future<void> _handleResendRequest(int fromSeq, int toSeq) async {
if (_database == null) return;
final packets = await _database!.getPacketsInRange(fromSeq, toSeq);
for (var p in packets) {
final resendPacket = {'type': 'RESEND_DATA', 'data': p.payload};
final jsonString = jsonEncode(resendPacket);
final data = utf8.encode('$jsonString$PACKET_DELIMITER');
if (role == NetworkRole.host) {
for (var s in _connectedGuests.keys) s.add(data);
} else {
_clientSocket?.add(data);
}
}
}
void _handleConnectionLost(dynamic reason) {
if (role != NetworkRole.guest) return;
_clientSocket?.destroy();
@@ -502,24 +479,88 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
return null;
}
void stopNetwork({bool force = false}) {
Future<void> stopNetwork({bool force = false}) async {
if (!force && _disconnectWaitTimer != null) return;
MediaManager().cleanup();
_heartbeatTimer?.cancel();
_disconnectWaitTimer?.cancel();
_disconnectWaitTimer = null;
_bonsoirBroadcast?.stop();
_bonsoirDiscovery?.stop();
_serverSocket?.close();
_clientSocket?.close();
for (var s in _connectedGuests.keys) s.close();
_connectedGuests.clear();
_bonsoirBroadcast?.stop();
_bonsoirDiscovery?.stop();
MediaManager().cleanup();
_heartbeatTimer?.cancel();
_disconnectWaitTimer?.cancel();
_disconnectWaitTimer = null;
_packetBuffers.clear();
guestList.clear();
role = NetworkRole.none;
_serverSocket = null;
_clientSocket = null;
if (force) { hostIp = null; hostPort = null; }
if (force) {
hostIp = null;
hostPort = null;
}
notifyListeners();
}
// ------------------------------------------------------------------------
// 게임 관리
// ------------------------------------------------------------------------
void selectGame(String gameId, {Map<String, dynamic>? config}) {
selectedGameId = gameId;
selectedGameConfig = config ?? {};
notifyListeners();
if (role == NetworkRole.host) {
sendMessage({
'type': 'GAME_CHANGED',
'gameId': gameId,
'config': selectedGameConfig
});
}
}
void toggleReady() {
me = me.copyWith(isReady: !me.isReady);
notifyListeners();
sendMessage({
'type': 'TOGGLE_READY',
'userId': me.id,
'isReady': me.isReady,
});
if (role == NetworkRole.host) _checkAllReadyAndStart();
}
void _checkAllReadyAndStart() {
if (guestList.isEmpty && role != NetworkRole.host) return;
if (!me.isReady) return;
bool allGuestsReady = guestList.every((u) => u.isReady);
if (allGuestsReady) {
_log("🚀 전원 준비 완료! 3초 후 게임 시작...");
Future.delayed(const Duration(seconds: 1), () {
final startPayload = {
'type': 'GAME_START',
'gameId': selectedGameId,
'config': selectedGameConfig
};
sendMessage(startPayload);
_messageController.add(startPayload);
_resetAllReadyState();
});
}
}
void _resetAllReadyState() {
me = me.copyWith(isReady: false);
for (int i = 0; i < guestList.length; i++) {
guestList[i] = guestList[i].copyWith(isReady: false);
}
notifyListeners();
}
}
+9 -1
View File
@@ -28,4 +28,12 @@ export 'game/yutnori_game.dart';
export 'game/memory_game.dart';
export 'game/tap_battle_game.dart';
export 'game/balance_game.dart';
export 'game/balance_game.dart';
export 'game/world_tour_game.dart';
export 'game/othello_game.dart';
export 'game/arkanoid_game.dart';
export 'game/math_run_game.dart';
export 'game/jump_game.dart';
export 'game/iam_ground_game.dart';
export 'game/survivor_game.dart';
export 'game/sequence_memory_game.dart';
+4
View File
@@ -27,6 +27,10 @@ dependencies:
flutter_local_notifications: ^17.0.0
google_mobile_ads: ^5.0.0
audioplayers: ^6.0.0 # 여기로 이동
wifi_iot: ^0.3.19 # 와이파이 연결 및 정보 확인용
network_info_plus: ^5.0.1 # 게이트웨이(방장 IP) 확인용
nearby_connections: ^4.0.0
device_info_plus: ^10.1.0 # [추가] 기기 정보 확인용
dev_dependencies:
drift_dev: ^2.13.0