This commit is contained in:
2025-11-21 17:58:56 +09:00
parent e2bff7279a
commit dbf70e01f2
158 changed files with 6687 additions and 0 deletions
+31
View File
@@ -0,0 +1,31 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.buildlog/
.history
.svn/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
/pubspec.lock
**/doc/api/
.dart_tool/
.flutter-plugins-dependencies
/build/
/coverage/
+10
View File
@@ -0,0 +1,10 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "adc901062556672b4138e18a4dc62a4be8f4b3c2"
channel: "stable"
project_type: package
+3
View File
@@ -0,0 +1,3 @@
## 0.0.1
* TODO: Describe initial release.
+1
View File
@@ -0,0 +1 @@
TODO: Add your license here.
+39
View File
@@ -0,0 +1,39 @@
<!--
This README describes the package. If you publish this package to pub.dev,
this README's contents appear on the landing page for your package.
For information about how to write a good package README, see the guide for
[writing package pages](https://dart.dev/tools/pub/writing-package-pages).
For general information about developing packages, see the Dart guide for
[creating packages](https://dart.dev/guides/libraries/create-packages)
and the Flutter guide for
[developing packages and plugins](https://flutter.dev/to/develop-packages).
-->
TODO: Put a short description of the package here that helps potential users
know whether this package might be useful for them.
## Features
TODO: List what your package can do. Maybe include images, gifs, or videos.
## Getting started
TODO: List prerequisites and provide or point to information on how to
start using the package.
## Usage
TODO: Include short and useful examples for package users. Add longer examples
to `/example` folder.
```dart
const like = 'sample';
```
## Additional information
TODO: Tell users more about the package: where to find more information, how to
contribute to the package, how to file issues, what response they can expect
from the package authors, and more.
+4
View File
@@ -0,0 +1,4 @@
include: package:flutter_lints/flutter.yaml
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
+31
View File
@@ -0,0 +1,31 @@
// lib/game/base_game.dart
import 'package:flutter/material.dart';
/// 모든 미니게임의 부모 클래스
abstract class BaseGame {
/// 게임 고유 ID (예: 'quiz', 'bomb') - 패킷 라우팅용
String get id;
/// 게임 이름 (로비 표시용)
String get name;
/// 게임 설명
String get description;
/// [Host] 방장이 보는 게임 화면 빌드
Widget buildHostView(BuildContext context);
/// [Guest] 참가자가 보는 게임 화면 빌드
Widget buildGuestView(BuildContext context);
/// 게임이 시작될 때 초기화 로직 (변수 초기화 등)
void onStart();
/// 네트워크 메시지 수신 처리
/// [senderId]: 보낸 사람 ID
/// [payload]: 수신된 데이터 (JSON)
void onMessageReceived(String senderId, Map<String, dynamic> payload);
/// 게임 종료 및 메모리 정리
void onDispose();
}
+53
View File
@@ -0,0 +1,53 @@
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)
const UserInfo({
required this.id,
required this.nickname,
this.avatarIndex = 0,
this.colorValue = 0xFF2196F3, // 기본값 Blue
});
/// 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,
);
}
/// Object -> JSON 변환 (네트워크 전송 시)
Map<String, dynamic> toJson() {
return {
'id': id,
'nickname': nickname,
'avatarIndex': avatarIndex,
'colorValue': colorValue,
};
}
/// 복사본 생성 (불변 객체 수정용)
UserInfo copyWith({
String? id,
String? nickname,
int? avatarIndex,
int? colorValue,
}) {
return UserInfo(
id: id ?? this.id,
nickname: nickname ?? this.nickname,
avatarIndex: avatarIndex ?? this.avatarIndex,
colorValue: colorValue ?? this.colorValue,
);
}
@override
List<Object?> get props => [id, nickname, avatarIndex, colorValue];
}
@@ -0,0 +1,278 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:bonsoir/bonsoir.dart'; // mDNS 패키지
import 'package:flutter/foundation.dart';
import 'package:uuid/uuid.dart';
import '../model/user_info.dart';
/// 현재 네트워크 상태 (역할)
enum NetworkRole { none, host, guest }
/// P2P 네트워크 통신을 담당하는 싱글톤 매니저
class NetworkManager extends ChangeNotifier {
// ------------------------------------------------------------------------
// 1. Singleton & Initialization
// ------------------------------------------------------------------------
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})');
}
// ------------------------------------------------------------------------
// 2. Variables & Streams
// ------------------------------------------------------------------------
// 소켓
ServerSocket? _serverSocket; // (Host용)
Socket? _clientSocket; // (Guest용)
final List<Socket> _connectedGuests = []; // (Host가 관리하는 게스트 목록)
// 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;
try {
// A. TCP 서버 소켓 오픈 (Port 0 = 시스템 자동 할당)
_serverSocket = await ServerSocket.bind(InternetAddress.anyIPv4, 0);
int port = _serverSocket!.port;
print('[Host] Server opened on port: $port');
// B. 게스트 접속 대기
_serverSocket!.listen((Socket client) {
_handleNewGuest(client);
});
// C. mDNS로 방 광고 (Broadcast)
// 서비스 타입은 고유해야 함 (_playwith._tcp)
// 이름 포맷: "방이름#호스트ID" (중복 방지 및 식별용)
_bonsoirService = BonsoirService(
name: '$roomName#${me.id}',
type: '_playwith._tcp',
port: port,
);
_bonsoirBroadcast = BonsoirBroadcast(service: _bonsoirService!);
await _bonsoirBroadcast!.ready;
await _bonsoirBroadcast!.start();
print('[Host] Start advertising room: $roomName');
notifyListeners();
} catch (e) {
print('[Host] Error starting host: $e');
stopNetwork();
}
}
/// 새로운 게스트가 접속했을 때
void _handleNewGuest(Socket client) {
print('[Host] New guest connected: ${client.remoteAddress.address}');
_connectedGuests.add(client);
// 데이터 수신 리스너 부착
client.listen(
(Uint8List data) => _onDataReceived(client, data),
onError: (e) => _removeGuest(client),
onDone: () => _removeGuest(client),
);
}
void _removeGuest(Socket client) {
print('[Host] Guest disconnected');
_connectedGuests.remove(client);
client.close();
notifyListeners();
}
// ------------------------------------------------------------------------
// 4. Guest Logic (참가자)
// ------------------------------------------------------------------------
/// 주변 방 찾기 (mDNS Discovery)
Stream<List<BonsoirService>> discoverRooms() {
// 리스트를 계속 갱신해서 내보내기 위한 컨트롤러
final controller = StreamController<List<BonsoirService>>();
final List<BonsoirService> foundServices = [];
_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));
}
}
});
});
return controller.stream;
}
/// 방 접속하기
Future<void> joinRoom(String ip, int port) async {
stopNetwork(); // 기존 연결 정리
role = NetworkRole.guest;
try {
print('[Guest] Connecting to $ip:$port...');
_clientSocket = await Socket.connect(ip, port);
print('[Guest] Connected!');
// 접속 성공 시 즉시 내 정보 전송 (Handshake)
sendMessage({
'type': 'HANDSHAKE',
'senderId': me.id,
'payload': me.toJson(),
});
// 데이터 수신 리스너
_clientSocket!.listen(
(Uint8List data) => _onDataReceived(_clientSocket!, data),
onError: (e) {
print('[Guest] Connection error: $e');
stopNetwork();
},
onDone: () {
print('[Guest] Disconnected by host');
stopNetwork();
},
);
notifyListeners();
} catch (e) {
print('[Guest] Failed to join: $e');
role = NetworkRole.none;
notifyListeners();
rethrow; // UI에서 에러 처리할 수 있게 던짐
}
}
// ------------------------------------------------------------------------
// 5. Common Logic (데이터 송수신)
// ------------------------------------------------------------------------
/// 메시지 전송
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.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');
}
}
/// 데이터 수신 처리
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);
// 2. (옵션) Host라면, 받은 메시지를 다른 Guest들에게도 전달(Relay)해야 할 수 있음
// 게임 로직에 따라 다르지만 보통 Host가 중계자 역할을 함
// if (role == NetworkRole.host) { sendMessage(parsedData); }
} catch (e) {
print('[Network] Parse Error: $e\nMessage: $msg');
}
}
}
// ------------------------------------------------------------------------
// 6. Cleanup
// ------------------------------------------------------------------------
/// 네트워크 종료 및 리소스 정리
void stopNetwork() {
print('[Network] Stopping network...');
// mDNS 중지
_bonsoirBroadcast?.stop();
_bonsoirDiscovery?.stop();
// 소켓 닫기
_serverSocket?.close();
_clientSocket?.close();
for (var socket in _connectedGuests) {
socket.close();
}
_connectedGuests.clear();
role = NetworkRole.none;
_serverSocket = null;
_clientSocket = null;
notifyListeners();
}
}
+7
View File
@@ -0,0 +1,7 @@
// lib/playwith_core.dart
library playwith_core;
export 'game/base_game.dart';
export 'network/network_manager.dart';
export 'model/user_info.dart';
+16
View File
@@ -0,0 +1,16 @@
name: playwith_core
description: Core logic for playWith game platform.
version: 0.0.1
environment:
sdk: ^3.0.0
dependencies:
flutter:
sdk: flutter
# 네트워크 디스커버리 (mDNS)
bonsoir: ^2.0.0
# 고유 ID 생성
uuid: ^4.0.0
# 데이터 비교 및 불변성 (선택사항이지만 추천)
equatable: ^2.0.5
@@ -0,0 +1,12 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:playwith_core/playwith_core.dart';
void main() {
test('adds one to input values', () {
final calculator = Calculator();
expect(calculator.addOne(2), 3);
expect(calculator.addOne(-7), -6);
expect(calculator.addOne(0), 1);
});
}