This commit is contained in:
2025-11-24 17:53:00 +09:00
parent e992a5ca5e
commit dde81cab65
34 changed files with 3718 additions and 469 deletions
@@ -0,0 +1,47 @@
import 'dart:io';
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as p;
part 'ephemeral_database.g.dart'; // (나중에 생성될 파일)
// 테이블 정의: 미디어 메타데이터
class MediaItems extends Table {
TextColumn get id => text()(); // UUID
TextColumn get senderId => text()();
TextColumn get senderName => text()();
TextColumn get type => text()(); // 'IMAGE', 'VIDEO', 'AUDIO'
TextColumn get filePath => text()(); // 로컬 저장 경로
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
@override
Set<Column> get primaryKey => {id};
}
@DriftDatabase(tables: [MediaItems])
class EphemeralDatabase extends _$EphemeralDatabase {
// 싱글톤이 아닌 인스턴스로 관리 (방 만들 때 생성, 나갈 때 close)
EphemeralDatabase(QueryExecutor e) : super(e);
@override
int get schemaVersion => 1;
// 팩토리: 파일 기반 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<void> wipeData() async {
await close(); // DB 연결 종료
// 실제 파일 삭제 로직은 Manager에서 수행 (DB 파일 자체를 날림)
}
}
@@ -0,0 +1,557 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'ephemeral_database.dart';
// ignore_for_file: type=lint
class $MediaItemsTable extends MediaItems
with TableInfo<$MediaItemsTable, MediaItem> {
@override
final GeneratedDatabase attachedDatabase;
final String? _alias;
$MediaItemsTable(this.attachedDatabase, [this._alias]);
static const VerificationMeta _idMeta = const VerificationMeta('id');
@override
late final GeneratedColumn<String> id = GeneratedColumn<String>(
'id', aliasedName, false,
type: DriftSqlType.string, requiredDuringInsert: true);
static const VerificationMeta _senderIdMeta =
const VerificationMeta('senderId');
@override
late final GeneratedColumn<String> senderId = GeneratedColumn<String>(
'sender_id', aliasedName, false,
type: DriftSqlType.string, requiredDuringInsert: true);
static const VerificationMeta _senderNameMeta =
const VerificationMeta('senderName');
@override
late final GeneratedColumn<String> senderName = GeneratedColumn<String>(
'sender_name', aliasedName, false,
type: DriftSqlType.string, requiredDuringInsert: true);
static const VerificationMeta _typeMeta = const VerificationMeta('type');
@override
late final GeneratedColumn<String> type = GeneratedColumn<String>(
'type', aliasedName, false,
type: DriftSqlType.string, requiredDuringInsert: true);
static const VerificationMeta _filePathMeta =
const VerificationMeta('filePath');
@override
late final GeneratedColumn<String> filePath = GeneratedColumn<String>(
'file_path', 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 =>
[id, senderId, senderName, type, filePath, createdAt];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'media_items';
@override
VerificationContext validateIntegrity(Insertable<MediaItem> instance,
{bool isInserting = false}) {
final context = VerificationContext();
final data = instance.toColumns(true);
if (data.containsKey('id')) {
context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta));
} else if (isInserting) {
context.missing(_idMeta);
}
if (data.containsKey('sender_id')) {
context.handle(_senderIdMeta,
senderId.isAcceptableOrUnknown(data['sender_id']!, _senderIdMeta));
} else if (isInserting) {
context.missing(_senderIdMeta);
}
if (data.containsKey('sender_name')) {
context.handle(
_senderNameMeta,
senderName.isAcceptableOrUnknown(
data['sender_name']!, _senderNameMeta));
} else if (isInserting) {
context.missing(_senderNameMeta);
}
if (data.containsKey('type')) {
context.handle(
_typeMeta, type.isAcceptableOrUnknown(data['type']!, _typeMeta));
} else if (isInserting) {
context.missing(_typeMeta);
}
if (data.containsKey('file_path')) {
context.handle(_filePathMeta,
filePath.isAcceptableOrUnknown(data['file_path']!, _filePathMeta));
} else if (isInserting) {
context.missing(_filePathMeta);
}
if (data.containsKey('created_at')) {
context.handle(_createdAtMeta,
createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta));
}
return context;
}
@override
Set<GeneratedColumn> get $primaryKey => {id};
@override
MediaItem map(Map<String, dynamic> data, {String? tablePrefix}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return MediaItem(
id: attachedDatabase.typeMapping
.read(DriftSqlType.string, data['${effectivePrefix}id'])!,
senderId: attachedDatabase.typeMapping
.read(DriftSqlType.string, data['${effectivePrefix}sender_id'])!,
senderName: attachedDatabase.typeMapping
.read(DriftSqlType.string, data['${effectivePrefix}sender_name'])!,
type: attachedDatabase.typeMapping
.read(DriftSqlType.string, data['${effectivePrefix}type'])!,
filePath: attachedDatabase.typeMapping
.read(DriftSqlType.string, data['${effectivePrefix}file_path'])!,
createdAt: attachedDatabase.typeMapping
.read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!,
);
}
@override
$MediaItemsTable createAlias(String alias) {
return $MediaItemsTable(attachedDatabase, alias);
}
}
class MediaItem extends DataClass implements Insertable<MediaItem> {
final String id;
final String senderId;
final String senderName;
final String type;
final String filePath;
final DateTime createdAt;
const MediaItem(
{required this.id,
required this.senderId,
required this.senderName,
required this.type,
required this.filePath,
required this.createdAt});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
map['id'] = Variable<String>(id);
map['sender_id'] = Variable<String>(senderId);
map['sender_name'] = Variable<String>(senderName);
map['type'] = Variable<String>(type);
map['file_path'] = Variable<String>(filePath);
map['created_at'] = Variable<DateTime>(createdAt);
return map;
}
MediaItemsCompanion toCompanion(bool nullToAbsent) {
return MediaItemsCompanion(
id: Value(id),
senderId: Value(senderId),
senderName: Value(senderName),
type: Value(type),
filePath: Value(filePath),
createdAt: Value(createdAt),
);
}
factory MediaItem.fromJson(Map<String, dynamic> json,
{ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return MediaItem(
id: serializer.fromJson<String>(json['id']),
senderId: serializer.fromJson<String>(json['senderId']),
senderName: serializer.fromJson<String>(json['senderName']),
type: serializer.fromJson<String>(json['type']),
filePath: serializer.fromJson<String>(json['filePath']),
createdAt: serializer.fromJson<DateTime>(json['createdAt']),
);
}
@override
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'id': serializer.toJson<String>(id),
'senderId': serializer.toJson<String>(senderId),
'senderName': serializer.toJson<String>(senderName),
'type': serializer.toJson<String>(type),
'filePath': serializer.toJson<String>(filePath),
'createdAt': serializer.toJson<DateTime>(createdAt),
};
}
MediaItem copyWith(
{String? id,
String? senderId,
String? senderName,
String? type,
String? filePath,
DateTime? createdAt}) =>
MediaItem(
id: id ?? this.id,
senderId: senderId ?? this.senderId,
senderName: senderName ?? this.senderName,
type: type ?? this.type,
filePath: filePath ?? this.filePath,
createdAt: createdAt ?? this.createdAt,
);
MediaItem copyWithCompanion(MediaItemsCompanion data) {
return MediaItem(
id: data.id.present ? data.id.value : this.id,
senderId: data.senderId.present ? data.senderId.value : this.senderId,
senderName:
data.senderName.present ? data.senderName.value : this.senderName,
type: data.type.present ? data.type.value : this.type,
filePath: data.filePath.present ? data.filePath.value : this.filePath,
createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt,
);
}
@override
String toString() {
return (StringBuffer('MediaItem(')
..write('id: $id, ')
..write('senderId: $senderId, ')
..write('senderName: $senderName, ')
..write('type: $type, ')
..write('filePath: $filePath, ')
..write('createdAt: $createdAt')
..write(')'))
.toString();
}
@override
int get hashCode =>
Object.hash(id, senderId, senderName, type, filePath, createdAt);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is MediaItem &&
other.id == this.id &&
other.senderId == this.senderId &&
other.senderName == this.senderName &&
other.type == this.type &&
other.filePath == this.filePath &&
other.createdAt == this.createdAt);
}
class MediaItemsCompanion extends UpdateCompanion<MediaItem> {
final Value<String> id;
final Value<String> senderId;
final Value<String> senderName;
final Value<String> type;
final Value<String> filePath;
final Value<DateTime> createdAt;
final Value<int> rowid;
const MediaItemsCompanion({
this.id = const Value.absent(),
this.senderId = const Value.absent(),
this.senderName = const Value.absent(),
this.type = const Value.absent(),
this.filePath = const Value.absent(),
this.createdAt = const Value.absent(),
this.rowid = const Value.absent(),
});
MediaItemsCompanion.insert({
required String id,
required String senderId,
required String senderName,
required String type,
required String filePath,
this.createdAt = const Value.absent(),
this.rowid = const Value.absent(),
}) : id = Value(id),
senderId = Value(senderId),
senderName = Value(senderName),
type = Value(type),
filePath = Value(filePath);
static Insertable<MediaItem> custom({
Expression<String>? id,
Expression<String>? senderId,
Expression<String>? senderName,
Expression<String>? type,
Expression<String>? filePath,
Expression<DateTime>? createdAt,
Expression<int>? rowid,
}) {
return RawValuesInsertable({
if (id != null) 'id': id,
if (senderId != null) 'sender_id': senderId,
if (senderName != null) 'sender_name': senderName,
if (type != null) 'type': type,
if (filePath != null) 'file_path': filePath,
if (createdAt != null) 'created_at': createdAt,
if (rowid != null) 'rowid': rowid,
});
}
MediaItemsCompanion copyWith(
{Value<String>? id,
Value<String>? senderId,
Value<String>? senderName,
Value<String>? type,
Value<String>? filePath,
Value<DateTime>? createdAt,
Value<int>? rowid}) {
return MediaItemsCompanion(
id: id ?? this.id,
senderId: senderId ?? this.senderId,
senderName: senderName ?? this.senderName,
type: type ?? this.type,
filePath: filePath ?? this.filePath,
createdAt: createdAt ?? this.createdAt,
rowid: rowid ?? this.rowid,
);
}
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
if (id.present) {
map['id'] = Variable<String>(id.value);
}
if (senderId.present) {
map['sender_id'] = Variable<String>(senderId.value);
}
if (senderName.present) {
map['sender_name'] = Variable<String>(senderName.value);
}
if (type.present) {
map['type'] = Variable<String>(type.value);
}
if (filePath.present) {
map['file_path'] = Variable<String>(filePath.value);
}
if (createdAt.present) {
map['created_at'] = Variable<DateTime>(createdAt.value);
}
if (rowid.present) {
map['rowid'] = Variable<int>(rowid.value);
}
return map;
}
@override
String toString() {
return (StringBuffer('MediaItemsCompanion(')
..write('id: $id, ')
..write('senderId: $senderId, ')
..write('senderName: $senderName, ')
..write('type: $type, ')
..write('filePath: $filePath, ')
..write('createdAt: $createdAt, ')
..write('rowid: $rowid')
..write(')'))
.toString();
}
}
abstract class _$EphemeralDatabase extends GeneratedDatabase {
_$EphemeralDatabase(QueryExecutor e) : super(e);
$EphemeralDatabaseManager get managers => $EphemeralDatabaseManager(this);
late final $MediaItemsTable mediaItems = $MediaItemsTable(this);
@override
Iterable<TableInfo<Table, Object?>> get allTables =>
allSchemaEntities.whereType<TableInfo<Table, Object?>>();
@override
List<DatabaseSchemaEntity> get allSchemaEntities => [mediaItems];
}
typedef $$MediaItemsTableCreateCompanionBuilder = MediaItemsCompanion Function({
required String id,
required String senderId,
required String senderName,
required String type,
required String filePath,
Value<DateTime> createdAt,
Value<int> rowid,
});
typedef $$MediaItemsTableUpdateCompanionBuilder = MediaItemsCompanion Function({
Value<String> id,
Value<String> senderId,
Value<String> senderName,
Value<String> type,
Value<String> filePath,
Value<DateTime> createdAt,
Value<int> rowid,
});
class $$MediaItemsTableFilterComposer
extends Composer<_$EphemeralDatabase, $MediaItemsTable> {
$$MediaItemsTableFilterComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnFilters<String> get id => $composableBuilder(
column: $table.id, builder: (column) => ColumnFilters(column));
ColumnFilters<String> get senderId => $composableBuilder(
column: $table.senderId, builder: (column) => ColumnFilters(column));
ColumnFilters<String> get senderName => $composableBuilder(
column: $table.senderName, builder: (column) => ColumnFilters(column));
ColumnFilters<String> get type => $composableBuilder(
column: $table.type, builder: (column) => ColumnFilters(column));
ColumnFilters<String> get filePath => $composableBuilder(
column: $table.filePath, builder: (column) => ColumnFilters(column));
ColumnFilters<DateTime> get createdAt => $composableBuilder(
column: $table.createdAt, builder: (column) => ColumnFilters(column));
}
class $$MediaItemsTableOrderingComposer
extends Composer<_$EphemeralDatabase, $MediaItemsTable> {
$$MediaItemsTableOrderingComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnOrderings<String> get id => $composableBuilder(
column: $table.id, builder: (column) => ColumnOrderings(column));
ColumnOrderings<String> get senderId => $composableBuilder(
column: $table.senderId, builder: (column) => ColumnOrderings(column));
ColumnOrderings<String> get senderName => $composableBuilder(
column: $table.senderName, builder: (column) => ColumnOrderings(column));
ColumnOrderings<String> get type => $composableBuilder(
column: $table.type, builder: (column) => ColumnOrderings(column));
ColumnOrderings<String> get filePath => $composableBuilder(
column: $table.filePath, builder: (column) => ColumnOrderings(column));
ColumnOrderings<DateTime> get createdAt => $composableBuilder(
column: $table.createdAt, builder: (column) => ColumnOrderings(column));
}
class $$MediaItemsTableAnnotationComposer
extends Composer<_$EphemeralDatabase, $MediaItemsTable> {
$$MediaItemsTableAnnotationComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
GeneratedColumn<String> get id =>
$composableBuilder(column: $table.id, builder: (column) => column);
GeneratedColumn<String> get senderId =>
$composableBuilder(column: $table.senderId, builder: (column) => column);
GeneratedColumn<String> get senderName => $composableBuilder(
column: $table.senderName, builder: (column) => column);
GeneratedColumn<String> get type =>
$composableBuilder(column: $table.type, builder: (column) => column);
GeneratedColumn<String> get filePath =>
$composableBuilder(column: $table.filePath, builder: (column) => column);
GeneratedColumn<DateTime> get createdAt =>
$composableBuilder(column: $table.createdAt, builder: (column) => column);
}
class $$MediaItemsTableTableManager extends RootTableManager<
_$EphemeralDatabase,
$MediaItemsTable,
MediaItem,
$$MediaItemsTableFilterComposer,
$$MediaItemsTableOrderingComposer,
$$MediaItemsTableAnnotationComposer,
$$MediaItemsTableCreateCompanionBuilder,
$$MediaItemsTableUpdateCompanionBuilder,
(
MediaItem,
BaseReferences<_$EphemeralDatabase, $MediaItemsTable, MediaItem>
),
MediaItem,
PrefetchHooks Function()> {
$$MediaItemsTableTableManager(_$EphemeralDatabase db, $MediaItemsTable table)
: super(TableManagerState(
db: db,
table: table,
createFilteringComposer: () =>
$$MediaItemsTableFilterComposer($db: db, $table: table),
createOrderingComposer: () =>
$$MediaItemsTableOrderingComposer($db: db, $table: table),
createComputedFieldComposer: () =>
$$MediaItemsTableAnnotationComposer($db: db, $table: table),
updateCompanionCallback: ({
Value<String> id = const Value.absent(),
Value<String> senderId = const Value.absent(),
Value<String> senderName = const Value.absent(),
Value<String> type = const Value.absent(),
Value<String> filePath = const Value.absent(),
Value<DateTime> createdAt = const Value.absent(),
Value<int> rowid = const Value.absent(),
}) =>
MediaItemsCompanion(
id: id,
senderId: senderId,
senderName: senderName,
type: type,
filePath: filePath,
createdAt: createdAt,
rowid: rowid,
),
createCompanionCallback: ({
required String id,
required String senderId,
required String senderName,
required String type,
required String filePath,
Value<DateTime> createdAt = const Value.absent(),
Value<int> rowid = const Value.absent(),
}) =>
MediaItemsCompanion.insert(
id: id,
senderId: senderId,
senderName: senderName,
type: type,
filePath: filePath,
createdAt: createdAt,
rowid: rowid,
),
withReferenceMapper: (p0) => p0
.map((e) => (e.readTable(table), BaseReferences(db, table, e)))
.toList(),
prefetchHooksCallback: null,
));
}
typedef $$MediaItemsTableProcessedTableManager = ProcessedTableManager<
_$EphemeralDatabase,
$MediaItemsTable,
MediaItem,
$$MediaItemsTableFilterComposer,
$$MediaItemsTableOrderingComposer,
$$MediaItemsTableAnnotationComposer,
$$MediaItemsTableCreateCompanionBuilder,
$$MediaItemsTableUpdateCompanionBuilder,
(
MediaItem,
BaseReferences<_$EphemeralDatabase, $MediaItemsTable, MediaItem>
),
MediaItem,
PrefetchHooks Function()>;
class $EphemeralDatabaseManager {
final _$EphemeralDatabase _db;
$EphemeralDatabaseManager(this._db);
$$MediaItemsTableTableManager get mediaItems =>
$$MediaItemsTableTableManager(_db, _db.mediaItems);
}
+40 -18
View File
@@ -1,31 +1,53 @@
// lib/game/base_game.dart
import 'dart:async';
import 'package:flutter/material.dart';
import '../network/network_manager.dart';
import '../model/play_packet.dart';
/// 모든 미니게임의 부모 클래스
/// 모든 게임의 부모 클래스 (자동 패킷 리스닝 기능 탑재)
abstract class BaseGame {
/// 게임 고유 ID (예: 'quiz', 'bomb') - 패킷 라우팅용
String get id;
/// 게임 이름 (로비 표시용)
String get name;
/// 게임 설명
String get description;
/// [Host] 방장이 보는 게임 화면 빌드
Widget buildHostView(BuildContext context);
// 네트워크 구독 관리자 (private)
StreamSubscription? _internalSubscription;
/// [Guest] 참가자가 보는 게임 화면 빌드
Widget buildGuestView(BuildContext context);
/// [Core] 게임 시작 시 자동 호출 (super.onStart() 필수 호출)
@mustCallSuper
void onStart() {
print("[$name] Game Engine Started");
// 네트워크 스트림 자동 구독
_internalSubscription = NetworkManager().messageStream.listen((payload) {
// 1. PlayPacket으로 변환 시도 (구조화된 데이터)
if (payload.containsKey('type') && payload.containsKey('payload')) {
try {
// 만약 패킷 타입이 'game'이라면 onGamePacketReceived 호출
// (여기서는 단순화를 위해 모든 JSON을 자식에게 넘기되, 자식이 알아서 필터링하게 하거나
// PlayPacket 구조를 강제할 수 있습니다. 현재는 하위 호환성을 위해 raw data 전달)
onMessageReceived("", payload);
} catch (e) {
print("Packet Error: $e");
}
} else {
// 레거시 데이터 처리
onMessageReceived("", payload);
}
});
}
/// 게임이 시작될 때 초기화 로직 (변수 초기화 등)
void onStart();
/// [Core] 게임 종료 시 자동 호출 (super.onDispose() 필수 호출)
@mustCallSuper
void onDispose() {
print("[$name] Game Engine Disposed");
_internalSubscription?.cancel();
}
/// 네트워크 메시지 수신 처리
/// [senderId]: 보낸 사람 ID
/// [payload]: 수신된 데이터 (JSON)
/// [Abstract] 자식 클래스가 구현해야 할 데이터 처리 메서드
/// Core가 메시지를 받으면 이 함수를 실행시켜 줍니다.
void onMessageReceived(String senderId, Map<String, dynamic> payload);
/// 게임 종료 및 메모리 정리
void onDispose();
// UI 빌더
Widget buildHostView(BuildContext context);
Widget buildGuestView(BuildContext context);
}
@@ -0,0 +1,70 @@
import 'dart:async';
import '../network/network_manager.dart';
import '../model/play_packet.dart';
class ChatMessage {
final String senderName;
final String text;
final bool isMe;
final DateTime timestamp;
ChatMessage(this.senderName, this.text, this.isMe) : timestamp = DateTime.now();
}
class GlobalChatManager {
static final GlobalChatManager _instance = GlobalChatManager._internal();
factory GlobalChatManager() => _instance;
GlobalChatManager._internal();
// UI가 구독할 스트림
final _messageController = StreamController<List<ChatMessage>>.broadcast();
Stream<List<ChatMessage>> get messageStream => _messageController.stream;
final List<ChatMessage> _messages = [];
/// [NetworkManager]로부터 패킷을 전달받음
void onPacketReceived(PlayPacket packet) {
if (packet.type != PacketType.chat) return;
final data = packet.payload as Map<String, dynamic>;
final senderName = data['senderName'];
final text = data['text'];
final isMe = packet.senderId == NetworkManager().me.id;
final chatMsg = ChatMessage(senderName, text, isMe);
_messages.add(chatMsg);
// UI 갱신
_messageController.add(List.from(_messages));
}
/// 메시지 전송
void sendMessage(String text) {
if (text.trim().isEmpty) return;
final myInfo = NetworkManager().me;
// 1. 내 화면에 즉시 추가 (나한테는 네트워크로 안 돌아오므로)
final myMsg = ChatMessage(myInfo.nickname, text, true);
_messages.add(myMsg);
_messageController.add(List.from(_messages));
// 2. 네트워크 전송 (PlayPacket 포장)
final packet = PlayPacket(
type: PacketType.chat,
senderId: myInfo.id,
payload: {
'senderName': myInfo.nickname,
'text': text,
},
timestamp: DateTime.now().millisecondsSinceEpoch,
);
NetworkManager().sendPacket(packet);
}
void clearMessages() {
_messages.clear();
_messageController.add([]);
}
}
@@ -0,0 +1,149 @@
import 'dart:convert';
import 'dart:io';
import 'dart:async';
import 'dart:typed_data';
import 'package:drift/drift.dart' as drift;
import 'package:path_provider/path_provider.dart';
import 'package:uuid/uuid.dart';
import '../database/ephemeral_database.dart';
import '../network/network_manager.dart';
import '../model/play_packet.dart';
class MediaManager {
static final MediaManager _instance = MediaManager._internal();
factory MediaManager() => _instance;
MediaManager._internal();
EphemeralDatabase? _db;
String? _currentRoomId;
// UI에서 갤러리 변경을 감지하기 위한 스트림 (DB 변경 시 자동 발동)
Stream<List<MediaItem>> get galleryStream {
if (_db == null) return const Stream.empty();
return _db!.select(_db!.mediaItems).watch(); // watch()는 데이터 변경 시 자동 업데이트됨
}
// ---------------------------------------------------------------------------
// [1] 초기화 및 정리 (Lifecycle)
// ---------------------------------------------------------------------------
/// 방 생성/입장 시 호출 (DB 생성)
Future<void> initialize(String roomId) async {
// 기존 DB가 열려있다면 정리
await cleanup();
_currentRoomId = roomId;
_db = await EphemeralDatabase.create(roomId);
print("[MediaManager] DB Initialized for Room: $roomId");
}
/// 방 나갈 때 호출 (데이터 파괴)
Future<void> cleanup() async {
if (_db != null) {
await _db!.close();
_db = null;
}
// DB 파일 삭제 (흔적 지우기)
if (_currentRoomId != null) {
try {
final dbFolder = await getApplicationDocumentsDirectory();
// EphemeralDatabase.create에서 만든 파일명과 동일해야 함
final file = File('${dbFolder.path}/room_$_currentRoomId.sqlite');
if (await file.exists()) {
await file.delete();
print("[MediaManager] DB File Deleted 🔥");
}
} catch (e) {
print("[MediaManager] Cleanup Error: $e");
}
_currentRoomId = null;
}
}
// ---------------------------------------------------------------------------
// [2] 미디어 전송 (Send)
// ---------------------------------------------------------------------------
Future<void> sendMedia({
required String filePath,
required String type, // 'IMAGE', 'AUDIO'
}) async {
if (_db == null) return;
final myInfo = NetworkManager().me;
final mediaId = const Uuid().v4();
// A. 내 로컬 DB에 먼저 저장 (내가 보낸 것도 보여야 하니까)
await _db!.insertMedia(MediaItemsCompanion(
id: drift.Value(mediaId),
senderId: drift.Value(myInfo.id),
senderName: drift.Value(myInfo.nickname),
type: drift.Value(type),
filePath: drift.Value(filePath),
createdAt: drift.Value(DateTime.now()),
));
// B. 파일 읽기 및 인코딩 (MVP: Base64)
// 주의: 대용량 동영상은 이 방식으로 보내면 앱 멈춤. (추후 Chunk 방식 개선 필요)
final file = File(filePath);
final fileBytes = await file.readAsBytes();
final base64Data = base64Encode(fileBytes);
final fileName = filePath.split('/').last;
// C. 패킷 전송
final packet = PlayPacket(
type: PacketType.media,
senderId: myInfo.id,
payload: {
'id': mediaId,
'senderName': myInfo.nickname,
'type': type,
'data': base64Data,
'fileName': fileName,
},
timestamp: DateTime.now().millisecondsSinceEpoch,
);
NetworkManager().sendPacket(packet);
print("[MediaManager] Sent media: $fileName");
}
// ---------------------------------------------------------------------------
// [3] 미디어 수신 (Receive)
// ---------------------------------------------------------------------------
Future<void> onMediaReceived(PlayPacket packet) async {
if (_db == null) return;
try {
final data = packet.payload as Map<String, dynamic>;
final String base64Data = data['data'];
final String fileName = data['fileName'];
// A. 임시 폴더에 파일 저장
final tempDir = await getTemporaryDirectory();
// 파일명 충돌 방지를 위해 UUID나 Timestamp 붙여도 됨
final savePath = '${tempDir.path}/${const Uuid().v4()}_$fileName';
final bytes = base64Decode(base64Data);
await File(savePath).writeAsBytes(bytes);
// B. DB에 메타데이터 저장 -> watch() 중인 UI가 자동 업데이트됨
await _db!.insertMedia(MediaItemsCompanion(
id: drift.Value(data['id']),
senderId: drift.Value(packet.senderId),
senderName: drift.Value(data['senderName']),
type: drift.Value(data['type']),
filePath: drift.Value(savePath),
createdAt: drift.Value(DateTime.fromMillisecondsSinceEpoch(packet.timestamp)),
));
print("[MediaManager] File Saved: $savePath");
} catch (e) {
print("[MediaManager] Receive Error: $e");
}
}
}
+52
View File
@@ -0,0 +1,52 @@
import 'dart:convert';
/// 패킷의 종류 (라우팅 기준)
enum PacketType {
system, // 시스템 (레디, 시작, 종료, 핸드셰이크 등)
chat, // 채팅 (GlobalChatManager로 전달)
game, // 게임 로직 (GameController로 전달)
media,
unknown
}
class PlayPacket {
final PacketType type;
final String senderId; // 보낸 사람 ID
final dynamic payload; // 실제 데이터 (Map, List, String 등)
final int timestamp;
PlayPacket({
required this.type,
required this.senderId,
required this.payload,
required this.timestamp,
});
// 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,
);
}
// 객체 -> JSON
Map<String, dynamic> toJson() {
return {
'type': type.name, // enum을 문자열로 ('chat', 'game'...)
'senderId': senderId,
'payload': payload,
'timestamp': timestamp,
};
}
static PacketType _parseType(String? typeStr) {
for (var t in PacketType.values) {
if (t.name == typeStr) return t;
}
// 호환성: 기존 레거시 메시지(PING, ANSWER_SUBMIT 등)는 'unknown'이나 별도 처리
return PacketType.unknown;
}
}
+10 -7
View File
@@ -3,51 +3,54 @@ import 'package:equatable/equatable.dart';
class UserInfo extends Equatable {
final String id;
final String nickname;
final int avatarIndex; // 프로필 이미지 대신 사용할 아바타 번호 (0~9 등)
final int colorValue; // 유저 고유 컬러 (ARGB int)
final int avatarIndex;
final int colorValue;
final bool isReady; // [추가] 준비 상태
const UserInfo({
required this.id,
required this.nickname,
this.avatarIndex = 0,
this.colorValue = 0xFF2196F3, // 기본값 Blue
this.colorValue = 0xFF2196F3,
this.isReady = false, // 기본값 false
});
/// JSON -> Object 변환 (네트워크 수신 시)
factory UserInfo.fromJson(Map<String, dynamic> json) {
return UserInfo(
id: json['id'] as String,
nickname: json['nickname'] as String,
avatarIndex: json['avatarIndex'] as int? ?? 0,
colorValue: json['colorValue'] as int? ?? 0xFF2196F3,
isReady: json['isReady'] as bool? ?? false, // JSON 파싱 추가
);
}
/// Object -> JSON 변환 (네트워크 전송 시)
Map<String, dynamic> toJson() {
return {
'id': id,
'nickname': nickname,
'avatarIndex': avatarIndex,
'colorValue': colorValue,
'isReady': isReady, // JSON 변환 추가
};
}
/// 복사본 생성 (불변 객체 수정용)
UserInfo copyWith({
String? id,
String? nickname,
int? avatarIndex,
int? colorValue,
bool? isReady, // copyWith 추가
}) {
return UserInfo(
id: id ?? this.id,
nickname: nickname ?? this.nickname,
avatarIndex: avatarIndex ?? this.avatarIndex,
colorValue: colorValue ?? this.colorValue,
isReady: isReady ?? this.isReady,
);
}
@override
List<Object?> get props => [id, nickname, avatarIndex, colorValue];
List<Object?> get props => [id, nickname, avatarIndex, colorValue, isReady];
}
+350 -165
View File
@@ -3,121 +3,180 @@ import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:bonsoir/bonsoir.dart'; // mDNS 패키지
import 'package:bonsoir/bonsoir.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:uuid/uuid.dart';
import '../model/user_info.dart';
import '../model/play_packet.dart';
import '../manager/global_chat_manager.dart';
import '../manager/media_manager.dart'; // [New] 미디어 매니저
/// 현재 네트워크 상태 (역할)
enum NetworkRole { none, host, guest }
/// P2P 네트워크 통신을 담당하는 싱글톤 매니저
class NetworkManager extends ChangeNotifier {
// ------------------------------------------------------------------------
// 1. Singleton & Initialization
// ------------------------------------------------------------------------
class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
static final NetworkManager _instance = NetworkManager._internal();
factory NetworkManager() => _instance;
NetworkManager._internal();
/// 내 정보 (앱 시작 시 initialize 호출 필수)
late UserInfo me;
/// 현재 내 역할
NetworkRole role = NetworkRole.none;
/// 초기화 메서드 (닉네임 설정 및 ID 생성)
void initialize({required String nickname}) {
// 8자리 랜덤 ID 생성
final uuid = const Uuid().v4().substring(0, 8);
// 랜덤 컬러 (간단하게 해시코드로 생성 예시)
final randomColor = 0xFF000000 | (nickname.hashCode & 0xFFFFFF);
me = UserInfo(
id: uuid,
nickname: nickname,
colorValue: randomColor,
);
print('[Network] Initialized User: ${me.nickname} (${me.id})');
NetworkManager._internal() {
WidgetsBinding.instance.addObserver(this);
}
// ------------------------------------------------------------------------
// 2. Variables & Streams
// 상태 변수
// ------------------------------------------------------------------------
late UserInfo me;
NetworkRole role = NetworkRole.none;
String? hostIp;
int? hostPort;
ServerSocket? _serverSocket;
Socket? _clientSocket;
// 소켓
ServerSocket? _serverSocket; // (Host용)
Socket? _clientSocket; // (Guest용)
final List<Socket> _connectedGuests = []; // (Host가 관리하는 게스트 목록)
// 소켓과 유저 정보를 1:1 매핑
final Map<Socket, UserInfo?> _connectedGuests = {};
// UI 표시용 게스트 명단
final List<UserInfo> guestList = [];
// mDNS (방 찾기/만들기)
BonsoirService? _bonsoirService;
BonsoirBroadcast? _bonsoirBroadcast;
BonsoirDiscovery? _bonsoirDiscovery;
// 수신된 데이터를 앱(GameManager)으로 전달하는 스트림
// 게임 데이터 스트림
final _messageController = StreamController<Map<String, dynamic>>.broadcast();
Stream<Map<String, dynamic>> get messageStream => _messageController.stream;
// ------------------------------------------------------------------------
// 3. Host Logic (방장)
// ------------------------------------------------------------------------
/// 방 만들기
Future<void> startHosting(String roomName) async {
stopNetwork(); // 기존 연결 정리
role = NetworkRole.host;
// 로그 스트림
final _logController = StreamController<String>.broadcast();
Stream<String> get logStream => _logController.stream;
try {
// A. TCP 서버 소켓 오픈 (Port 0 = 시스템 자동 할당)
_serverSocket = await ServerSocket.bind(InternetAddress.anyIPv4, 0);
int port = _serverSocket!.port;
print('[Host] Server opened on port: $port');
// 하트비트 & 재접속
Timer? _heartbeatTimer;
Timer? _disconnectWaitTimer;
DateTime? _lastPongTime;
bool _isReconnecting = false;
final interfaces = await NetworkInterface.list(type: InternetAddressType.IPv4);
String myIp = '127.0.0.1';
try {
// 보통 wlan0 혹은 en0가 와이파이 인터페이스
myIp = interfaces.firstWhere((i) => i.name != 'lo').addresses.first.address;
} catch (e) {
print('IP search failed: $e');
static const int HEARTBEAT_INTERVAL_SEC = 3;
static const int TIMEOUT_SEC = 10;
static const int RECONNECT_WAIT_SEC = 5;
// ------------------------------------------------------------------------
// 초기화
// ------------------------------------------------------------------------
void initialize({required String nickname}) {
final uuid = const Uuid().v4().substring(0, 8);
final randomColor = 0xFF000000 | (nickname.hashCode & 0xFFFFFF);
me = UserInfo(id: uuid, nickname: nickname, colorValue: randomColor);
_log("초기화 완료: ${me.nickname}");
}
// B. 게스트 접속 대기
void _log(String msg) {
final timestamp = DateTime.now().toIso8601String().split('T').last.substring(0, 8);
print("[$timestamp] $msg");
_logController.add("[$timestamp] $msg");
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
if (role == NetworkRole.guest && _clientSocket == null && hostIp != null) {
_attemptReconnection();
}
}
}
// ------------------------------------------------------------------------
// 레디 시스템
// ------------------------------------------------------------------------
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), () {
final startPayload = {'type': 'GAME_START', 'gameId': 'quiz_ox'};
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
// ------------------------------------------------------------------------
Future<void> startHosting(String roomName) async {
stopNetwork(force: true);
role = NetworkRole.host;
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);
});
// C. mDNS로 방 광고 (Broadcast)
// 서비스 타입은 고유해야 함 (_playwith._tcp)
// 이름 포맷: "방이름#호스트ID" (중복 방지 및 식별용)
_bonsoirService = BonsoirService(
name: '$roomName#${me.id}',
name: '$roomName#${me.id}',
type: '_playwith._tcp',
port: port,
attributes: {'ip': myIp},
attributes: {'ip': hostIp!},
);
_bonsoirBroadcast = BonsoirBroadcast(service: _bonsoirService!);
await _bonsoirBroadcast!.ready;
await _bonsoirBroadcast!.start();
print('[Host] Start advertising room: $roomName');
// [NEW] 미디어 DB 초기화 (Host)
await MediaManager().initialize(roomName);
_startHeartbeat();
notifyListeners();
} catch (e) {
print('[Host] Error starting host: $e');
stopNetwork();
_log("❌ 방 생성 실패: $e");
stopNetwork(force: true);
}
}
/// 새로운 게스트가 접속했을 때
void _handleNewGuest(Socket client) {
print('[Host] New guest connected: ${client.remoteAddress.address}');
_connectedGuests.add(client);
// 데이터 수신 리스너 부착
_log("🎉 연결됨: ${client.remoteAddress.address}");
_connectedGuests[client] = null;
client.listen(
(Uint8List data) => _onDataReceived(client, data),
onError: (e) => _removeGuest(client),
@@ -126,163 +185,289 @@ final interfaces = await NetworkInterface.list(type: InternetAddressType.IPv4);
}
void _removeGuest(Socket client) {
print('[Host] Guest disconnected');
final UserInfo? user = _connectedGuests[client];
if (user != null) {
_log("👋 퇴장: ${user.nickname}");
guestList.removeWhere((u) => u.id == user.id);
}
_connectedGuests.remove(client);
client.close();
notifyListeners();
}
// ------------------------------------------------------------------------
// 4. Guest Logic (참가자)
// Guest Logic
// ------------------------------------------------------------------------
/// 주변 방 찾기 (mDNS Discovery)
Stream<List<BonsoirService>> discoverRooms() {
// 리스트를 계속 갱신해서 내보내기 위한 컨트롤러
final controller = StreamController<List<BonsoirService>>();
final List<BonsoirService> foundServices = [];
_bonsoirDiscovery?.stop();
_bonsoirDiscovery = BonsoirDiscovery(type: '_playwith._tcp');
_bonsoirDiscovery!.ready.then((_) {
_bonsoirDiscovery!.start();
_bonsoirDiscovery!.eventStream!.listen((event) {
if (event.type == BonsoirDiscoveryEventType.discoveryServiceFound) {
if (event.service != null) {
foundServices.add(event.service!);
controller.add(List.from(foundServices));
}
} else if (event.type == BonsoirDiscoveryEventType.discoveryServiceLost) {
if (event.service != null) {
foundServices.removeWhere((s) => s.name == event.service!.name);
controller.add(List.from(foundServices));
}
Future(() async {
try {
_bonsoirDiscovery = BonsoirDiscovery(type: '_playwith._tcp');
await _bonsoirDiscovery!.start();
if (_bonsoirDiscovery?.eventStream != null) {
_bonsoirDiscovery!.eventStream!.listen((dynamic event) {
final String type = event.type.toString();
if (event.service == null) return;
if (type.contains('Found')) {
foundServices.removeWhere((s) => s.name == event.service!.name);
foundServices.add(event.service!);
controller.add(List.from(foundServices));
} else if (type.contains('Lost')) {
foundServices.removeWhere((s) => s.name == event.service!.name);
controller.add(List.from(foundServices));
}
});
}
});
} catch (e) {
_log("스캔 실패: $e");
}
});
return controller.stream;
}
/// 방 접속하기
Future<void> joinRoom(String ip, int port) async {
stopNetwork(); // 기존 연결 정리
if (role != NetworkRole.guest) stopNetwork(force: true);
role = NetworkRole.guest;
hostIp = ip;
hostPort = port;
try {
print('[Guest] Connecting to $ip:$port...');
_clientSocket = await Socket.connect(ip, port);
print('[Guest] Connected!');
_log("🚀 접속 시도: $ip:$port");
_clientSocket = await Socket.connect(ip, port, timeout: const Duration(seconds: 5));
_log("✅ 접속 성공!");
// 접속 성공 시 즉시 내 정보 전송 (Handshake)
sendMessage({
'type': 'HANDSHAKE',
'senderId': me.id,
'payload': me.toJson(),
});
sendMessage({'type': 'HANDSHAKE', 'payload': me.toJson()});
// [NEW] 미디어 DB 초기화 (Guest는 임시 ID 사용)
await MediaManager().initialize("guest_${ip.replaceAll('.', '_')}");
_lastPongTime = DateTime.now();
_startHeartbeat();
_cancelDisconnectTimer();
// 데이터 수신 리스너
_clientSocket!.listen(
(Uint8List data) => _onDataReceived(_clientSocket!, data),
onError: (e) {
print('[Guest] Connection error: $e');
stopNetwork();
},
onDone: () {
print('[Guest] Disconnected by host');
stopNetwork();
},
onError: (e) => _handleConnectionLost(e),
onDone: () => _handleConnectionLost("Socket Closed"),
);
notifyListeners();
} catch (e) {
print('[Guest] Failed to join: $e');
role = NetworkRole.none;
notifyListeners();
rethrow; // UI에서 에러 처리할 수 있게 던짐
_log("❌ 접속 실패: $e");
if (!_isReconnecting) stopNetwork(force: true);
rethrow;
}
}
// ------------------------------------------------------------------------
// 5. Common Logic (데이터 송수신)
// 데이터 송수신 & 라우팅 (핵심)
// ------------------------------------------------------------------------
void sendPacket(PlayPacket packet) {
sendMessage(packet.toJson());
}
/// 메시지 전송
void sendMessage(Map<String, dynamic> messageMap) {
try {
// JSON 변환
final jsonString = jsonEncode(messageMap);
// 패킷 경계 처리를 위해 끝에 줄바꿈(\n) 추가 (가장 간단한 delimiter)
final List<int> data = utf8.encode('$jsonString\n');
if (role == NetworkRole.guest && _clientSocket == null) return;
if (role == NetworkRole.host) {
// Host는 모든 Guest에게 브로드캐스트
for (var socket in _connectedGuests) {
socket.add(data);
}
} else if (role == NetworkRole.guest) {
// Guest는 Host에게 전송
_clientSocket?.add(data);
}
} catch (e) {
print('[Network] Send Error: $e');
}
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");
}
}
final List<int> data = utf8.encode('$jsonString\n');
if (role == NetworkRole.host) {
for (var socket in _connectedGuests.keys) {
socket.add(data);
}
} else {
_clientSocket?.add(data);
}
}
/// 데이터 수신 처리
void _onDataReceived(Socket socket, Uint8List data) {
// 들어온 데이터를 String으로 변환
final String rawString = utf8.decode(data);
// TCP 패킷이 뭉쳐서 올 수 있으므로 \n으로 쪼갬
final List<String> splitMessages = rawString.split('\n');
for (var msg in splitMessages) {
if (msg.trim().isEmpty) continue;
try {
final Map<String, dynamic> parsedData = jsonDecode(msg);
// 1. 앱 로직으로 전달
_messageController.add(parsedData);
final Map<String, dynamic> jsonMap = jsonDecode(msg);
// 2. (옵션) Host라면, 받은 메시지를 다른 Guest들에게도 전달(Relay)해야 할 수 있음
// 게임 로직에 따라 다르지만 보통 Host가 중계자 역할을 함
// if (role == NetworkRole.host) { sendMessage(parsedData); }
// 1. 시스템 메시지 (Ping/Pong)
if (jsonMap['type'] == 'PING') {
sendMessage({'type': 'PONG'});
_lastPongTime = DateTime.now();
return;
}
if (jsonMap['type'] == 'PONG') {
_lastPongTime = DateTime.now();
return;
}
// 2. 핸드셰이크
if (jsonMap['type'] == 'HANDSHAKE') {
final guestInfo = UserInfo.fromJson(jsonMap['payload']);
_connectedGuests[socket] = guestInfo;
guestList.removeWhere((u) => u.id == guestInfo.id);
guestList.add(guestInfo);
notifyListeners();
_messageController.add(jsonMap);
return;
}
// 3. 레디 토글
if (jsonMap['type'] == 'TOGGLE_READY') {
final String userId = jsonMap['userId'];
final bool isReady = jsonMap['isReady'];
final index = guestList.indexWhere((u) => u.id == userId);
if (index != -1) {
guestList[index] = guestList[index].copyWith(isReady: isReady);
notifyListeners();
}
if (role == NetworkRole.host) {
sendMessage(jsonMap);
_checkAllReadyAndStart();
}
_messageController.add(jsonMap);
return;
}
// 4. 게임 시작
if (jsonMap['type'] == 'GAME_START') {
_resetAllReadyState();
_messageController.add(jsonMap);
return;
}
// 5. 패킷 라우팅 (Chat, Media, Game)
if (jsonMap.containsKey('payload') && jsonMap.containsKey('senderId')) {
final packet = PlayPacket.fromJson(jsonMap);
// [라우팅] 채팅 -> GlobalChatManager
if (packet.type == PacketType.chat) {
GlobalChatManager().onPacketReceived(packet);
return;
}
// [라우팅] 미디어 -> MediaManager
if (packet.type == PacketType.media) {
MediaManager().onMediaReceived(packet);
return;
}
}
// 6. 그 외 게임 데이터
_messageController.add(jsonMap);
} catch (e) {
print('[Network] Parse Error: $e\nMessage: $msg');
_log("파싱 에러: $e");
}
}
}
// ------------------------------------------------------------------------
// 6. Cleanup
// 연결 관리
// ------------------------------------------------------------------------
/// 네트워크 종료 및 리소스 정리
void stopNetwork() {
print('[Network] Stopping network...');
void _handleConnectionLost(dynamic reason) {
if (role != NetworkRole.guest) return;
_log("⚠️ 연결 끊김: $reason");
// mDNS 중지
_clientSocket?.destroy();
_clientSocket = null;
if (_disconnectWaitTimer != null && _disconnectWaitTimer!.isActive) return;
_disconnectWaitTimer = Timer(const Duration(seconds: RECONNECT_WAIT_SEC), () {
_log("💀 복구 실패. 종료.");
stopNetwork(force: true);
});
_attemptReconnection();
}
Future<void> _attemptReconnection() async {
if (hostIp == null || hostPort == null) return;
_isReconnecting = true;
while (_disconnectWaitTimer != null && _disconnectWaitTimer!.isActive) {
try {
await joinRoom(hostIp!, hostPort!);
_log("✅ 재접속 성공!");
_isReconnecting = false;
return;
} catch (e) {
await Future.delayed(const Duration(seconds: 1));
}
}
_isReconnecting = false;
}
void _cancelDisconnectTimer() {
_disconnectWaitTimer?.cancel();
_disconnectWaitTimer = null;
}
void _startHeartbeat() {
_heartbeatTimer?.cancel();
_heartbeatTimer = Timer.periodic(const Duration(seconds: HEARTBEAT_INTERVAL_SEC), (timer) {
try { sendMessage({'type': 'PING'}); } catch (_) {}
if (role == NetworkRole.guest && _lastPongTime != null) {
if (DateTime.now().difference(_lastPongTime!).inSeconds > TIMEOUT_SEC) {
_handleConnectionLost("Heartbeat Timeout");
}
}
});
}
Future<String?> _getWifiIp() async {
try {
for (var interface in await NetworkInterface.list()) {
if (interface.name.contains('wlan') || interface.name.contains('en') || interface.name.contains('ap')) {
for (var addr in interface.addresses) {
if (addr.type == InternetAddressType.IPv4 && !addr.isLoopback) {
return addr.address;
}
}
}
}
} catch (e) {/**/}
return null;
}
void stopNetwork({bool force = false}) {
if (!force && _disconnectWaitTimer != null) return;
_log("🛑 종료");
// [NEW] 미디어 DB 정리
MediaManager().cleanup();
_heartbeatTimer?.cancel();
_disconnectWaitTimer?.cancel();
_disconnectWaitTimer = null;
_bonsoirBroadcast?.stop();
_bonsoirDiscovery?.stop();
// 소켓 닫기
_serverSocket?.close();
_clientSocket?.close();
for (var socket in _connectedGuests) {
socket.close();
}
for (var s in _connectedGuests.keys) s.close();
_connectedGuests.clear();
guestList.clear();
role = NetworkRole.none;
_serverSocket = null;
_clientSocket = null;
if (force) { hostIp = null; hostPort = null; }
notifyListeners();
}
}
+10 -3
View File
@@ -1,7 +1,14 @@
// lib/playwith_core.dart
library playwith_core;
export 'game/base_game.dart';
export 'network/network_manager.dart';
export 'model/user_info.dart';
export 'model/user_info.dart';
export 'model/play_packet.dart';
export 'utils/sound_manager.dart';
export 'manager/global_chat_manager.dart';
export 'widgets/game_chat_overlay.dart';
// [추가] DB 관련 (Drift가 생성한 데이터 클래스들도 쓰기 위해)
export 'database/ephemeral_database.dart';
// Drift의 기본 타입(Value 등)을 쓰려면 아래 줄도 필요할 수 있음 (선택)
export 'package:drift/drift.dart' show Value;
@@ -0,0 +1,65 @@
import 'package:audioplayers/audioplayers.dart';
/// 사운드 키 상수 (오타 방지용)
class SoundKey {
static const String bgm = 'bgm';
static const String correct = 'correct';
static const String wrong = 'wrong';
static const String win = 'win';
static const String click = 'click';
}
class SoundManager {
static final SoundManager _instance = SoundManager._internal();
factory SoundManager() => _instance;
SoundManager._internal();
final AudioPlayer _bgmPlayer = AudioPlayer();
final AudioPlayer _sfxPlayer = AudioPlayer();
// [핵심] 키-경로 매핑 저장소
final Map<String, String> _soundPaths = {};
bool _isInitialized = false;
/// 앱 시작 시 사운드 경로 주입 (Dependency Injection)
void initialize({required Map<String, String> soundPaths}) {
_soundPaths.addAll(soundPaths);
_isInitialized = true;
print('[SoundManager] Initialized with ${_soundPaths.length} sounds');
}
/// BGM 재생
Future<void> playBgm(String key) async {
if (!_isInitialized) return;
final path = _soundPaths[key];
if (path != null) {
await _bgmPlayer.setReleaseMode(ReleaseMode.loop);
await _bgmPlayer.setVolume(0.3);
// AssetSource는 'assets/'를 생략하고 그 하위 경로를 입력받습니다.
// 예: assets/audio/bgm.mp3 -> AssetSource('audio/bgm.mp3')
await _bgmPlayer.play(AssetSource(path));
} else {
print('[SoundManager] BGM Key not found: $key');
}
}
Future<void> stopBgm() async {
await _bgmPlayer.stop();
}
/// 효과음 재생
Future<void> playSfx(String key) async {
if (!_isInitialized) return;
final path = _soundPaths[key];
if (path != null) {
// 효과음은 겹칠 수 있으므로 매번 stop 하거나 모드 설정
await _sfxPlayer.stop();
await _sfxPlayer.setVolume(1.0);
await _sfxPlayer.play(AssetSource(path));
} else {
print('[SoundManager] SFX Key not found: $key');
}
}
}
@@ -0,0 +1,229 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import '../manager/global_chat_manager.dart';
import '../manager/media_manager.dart'; // 미디어 매니저
import '../database/ephemeral_database.dart'; // DB 모델
class GameChatOverlay extends StatefulWidget {
const GameChatOverlay({super.key});
@override
State<GameChatOverlay> createState() => _GameChatOverlayState();
}
class _GameChatOverlayState extends State<GameChatOverlay> {
final TextEditingController _textController = TextEditingController();
final ScrollController _scrollController = ScrollController();
bool _isExpanded = false;
@override
Widget build(BuildContext context) {
return Align(
alignment: Alignment.bottomCenter,
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
// 높이 조정: 미디어 갤러리가 보일 공간 확보 (펼쳤을 때)
height: _isExpanded ? 500 : 60,
margin: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.85),
borderRadius: BorderRadius.circular(20),
boxShadow: [BoxShadow(color: Colors.black26, blurRadius: 10, spreadRadius: 2)],
),
child: Column(
children: [
// 1. 상단 핸들 (접기/펼치기)
GestureDetector(
onTap: () => setState(() => _isExpanded = !_isExpanded),
behavior: HitTestBehavior.translucent,
child: Container(
width: double.infinity,
height: 30,
alignment: Alignment.center,
child: Icon(
_isExpanded ? Icons.keyboard_arrow_down : Icons.keyboard_arrow_up,
color: Colors.white,
),
),
),
// 펼쳤을 때만 보이는 영역
if (_isExpanded) ...[
// 2. 미디어 갤러리 (가로 스크롤)
// DB의 변경사항을 실시간으로 감지(Stream)하여 보여줌
SizedBox(
height: 100,
child: StreamBuilder<List<MediaItem>>(
stream: MediaManager().galleryStream,
builder: (context, snapshot) {
final mediaList = snapshot.data ?? [];
if (mediaList.isEmpty) {
return const Center(child: Text("공유된 미디어가 없습니다.", style: TextStyle(color: Colors.white54, fontSize: 12)));
}
return ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 10),
itemCount: mediaList.length,
itemBuilder: (context, index) {
final item = mediaList[index];
return Padding(
padding: const EdgeInsets.only(right: 8.0),
child: GestureDetector(
onTap: () => _showFullImage(context, item),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.file(
File(item.filePath),
width: 100,
height: 100,
fit: BoxFit.cover,
errorBuilder: (_,__,___) => Container(
width: 100, height: 100, color: Colors.grey,
child: const Icon(Icons.broken_image),
),
),
),
),
);
},
);
},
),
),
const Divider(color: Colors.white24),
// 3. 채팅 리스트
Expanded(
child: StreamBuilder<List<ChatMessage>>(
stream: GlobalChatManager().messageStream,
builder: (context, snapshot) {
final messages = snapshot.data ?? [];
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.jumpTo(_scrollController.position.maxScrollExtent);
}
});
return ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.symmetric(horizontal: 10),
itemCount: messages.length,
itemBuilder: (context, index) {
final msg = messages[index];
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Text(
"${msg.senderName}: ${msg.text}",
style: TextStyle(
color: msg.isMe ? Colors.yellow : Colors.white,
fontSize: 14,
),
),
);
},
);
},
),
),
// 4. 입력창 (+ 미디어 버튼)
Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
children: [
// [추가] 이미지 전송 버튼
IconButton(
icon: const Icon(Icons.add_photo_alternate, color: Colors.blueAccent),
onPressed: _pickAndSendImage,
),
Expanded(
child: TextField(
controller: _textController,
style: const TextStyle(color: Colors.white),
decoration: const InputDecoration(
hintText: "채팅 입력...",
hintStyle: TextStyle(color: Colors.white54),
border: InputBorder.none,
isDense: true,
),
onSubmitted: _sendMessage,
),
),
IconButton(
icon: const Icon(Icons.send, color: Colors.blue),
onPressed: () => _sendMessage(_textController.text),
),
],
),
),
],
],
),
),
);
}
void _sendMessage(String text) {
if (text.trim().isEmpty) return;
GlobalChatManager().sendMessage(text);
_textController.clear();
}
// [이미지 선택 및 전송 로직]
Future<void> _pickAndSendImage() async {
final picker = ImagePicker();
// 갤러리에서 이미지 선택 (압축 옵션 추가 권장)
final XFile? image = await picker.pickImage(
source: ImageSource.gallery,
imageQuality: 50, // 전송 속도를 위해 품질 낮춤
maxWidth: 800,
);
if (image != null) {
// MediaManager를 통해 전송
await MediaManager().sendMedia(
filePath: image.path,
type: 'IMAGE',
);
}
}
// [이미지 크게 보기 팝업]
void _showFullImage(BuildContext context, MediaItem item) {
showDialog(
context: context,
builder: (ctx) => Dialog(
backgroundColor: Colors.transparent,
insetPadding: EdgeInsets.zero,
child: Stack(
alignment: Alignment.center,
children: [
InteractiveViewer(
child: Image.file(File(item.filePath)),
),
Positioned(
top: 40,
right: 20,
child: IconButton(
icon: const Icon(Icons.close, color: Colors.white, size: 30),
onPressed: () => Navigator.pop(ctx),
),
),
Positioned(
bottom: 20,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
color: Colors.black54,
child: Text("보낸 사람: ${item.senderName}", style: const TextStyle(color: Colors.white)),
),
)
],
),
),
);
}
}