From e992a5ca5e2c84f81c469820569800e6bceac499 Mon Sep 17 00:00:00 2001 From: lunaticbum Date: Fri, 21 Nov 2025 18:04:15 +0900 Subject: [PATCH] ... --- apps/app/lib/intro_screen.dart | 58 ++++++ apps/app/lib/lobby_screen.dart | 184 ++++++++++++++++++ apps/app/lib/main.dart | 118 +---------- .../core/lib/network/network_manager.dart | 10 + 4 files changed, 261 insertions(+), 109 deletions(-) create mode 100644 apps/app/lib/intro_screen.dart create mode 100644 apps/app/lib/lobby_screen.dart diff --git a/apps/app/lib/intro_screen.dart b/apps/app/lib/intro_screen.dart new file mode 100644 index 0000000..029db0b --- /dev/null +++ b/apps/app/lib/intro_screen.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; +import 'package:playwith_core/playwith_core.dart'; // Core 패키지 import +import 'lobby_screen.dart'; + +class IntroScreen extends StatefulWidget { + const IntroScreen({super.key}); + + @override + State createState() => _IntroScreenState(); +} + +class _IntroScreenState extends State { + final _nicknameController = TextEditingController(); + + void _enterLobby() { + if (_nicknameController.text.trim().isEmpty) return; + + // 1. Core 패키지의 NetworkManager 초기화 + NetworkManager().initialize(nickname: _nicknameController.text.trim()); + + // 2. 로비 화면으로 이동 + Navigator.push( + context, + MaterialPageRoute(builder: (_) => const LobbyScreen()), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: Padding( + padding: const EdgeInsets.all(32.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('PlayWith', style: TextStyle(fontSize: 40, fontWeight: FontWeight.bold)), + const SizedBox(height: 40), + TextField( + controller: _nicknameController, + decoration: const InputDecoration( + labelText: '닉네임을 입력하세요', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 20), + ElevatedButton( + onPressed: _enterLobby, + style: ElevatedButton.styleFrom(minimumSize: const Size(double.infinity, 50)), + child: const Text('입장하기'), + ), + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/apps/app/lib/lobby_screen.dart b/apps/app/lib/lobby_screen.dart new file mode 100644 index 0000000..4d22394 --- /dev/null +++ b/apps/app/lib/lobby_screen.dart @@ -0,0 +1,184 @@ +import 'package:bonsoir/bonsoir.dart'; +import 'package:flutter/material.dart'; +import 'package:playwith_core/playwith_core.dart'; + +class LobbyScreen extends StatefulWidget { + const LobbyScreen({super.key}); + + @override + State createState() => _LobbyScreenState(); +} + +class _LobbyScreenState extends State { + final _net = NetworkManager(); // Singleton 인스턴스 + + @override + Widget build(BuildContext context) { + // NetworkManager의 상태(notifyListeners)가 변경될 때마다 화면 다시 그림 + return ListenableBuilder( + listenable: _net, + builder: (context, child) { + return Scaffold( + appBar: AppBar( + title: Text('안녕하세요, ${_net.me.nickname}님'), + actions: [ + if (_net.role != NetworkRole.none) + IconButton( + icon: const Icon(Icons.close), + onPressed: () => _net.stopNetwork(), // 연결 끊기 + ) + ], + ), + body: _buildBody(), + ); + }, + ); + } + + Widget _buildBody() { + // 1. 아무 역할도 없을 때 -> 선택 화면 + if (_net.role == NetworkRole.none) { + return Center( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _BigButton( + title: "방 만들기\n(Host)", + color: Colors.blue[100]!, + onTap: () => _net.startHosting("${_net.me.nickname}의 방"), + ), + _BigButton( + title: "방 찾기\n(Guest)", + color: Colors.green[100]!, + onTap: () => _showRoomListDialog(), + ), + ], + ), + ); + } + + // 2. Host 상태일 때 -> 대기실 화면 + if (_net.role == NetworkRole.host) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text("👑 방장입니다", style: TextStyle(fontSize: 24)), + const SizedBox(height: 20), + const CircularProgressIndicator(), + const SizedBox(height: 20), + const Text("참가자를 기다리는 중..."), + // TODO: 여기에 접속한 게스트 목록 표시 예정 + ], + ), + ); + } + + // 3. Guest 상태일 때 -> 대기실 화면 + if (_net.role == NetworkRole.guest) { + return const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text("✅ 접속 완료!", style: TextStyle(fontSize: 24)), + SizedBox(height: 20), + Text("방장이 게임을 시작하기를 기다리세요."), + ], + ), + ); + } + + return const SizedBox(); + } + + // [Guest용] 방 목록 팝업 + void _showRoomListDialog() { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text("방 찾는 중..."), + content: SizedBox( + width: double.maxFinite, + height: 300, + child: StreamBuilder>( + stream: _net.discoverRooms(), // Core의 방 찾기 스트림 + builder: (context, snapshot) { + if (!snapshot.hasData || snapshot.data!.isEmpty) { + return const Center(child: Text("발견된 방이 없습니다.\n(같은 와이파이인지 확인하세요)")); + } + + final services = snapshot.data!; + return ListView.builder( + itemCount: services.length, + itemBuilder: (context, index) { + final service = services[index]; + // 이름 포맷: "방이름#ID" -> "방이름"만 파싱 + final displayName = service.name.split('#').first; + + return ListTile( + leading: const Icon(Icons.meeting_room), + title: Text(displayName), + subtitle: Text(service.host ?? "IP 정보 없음"), + onTap: () async { + Navigator.pop(context); // 다이얼로그 닫기 + // 해당 방으로 접속 시도 (IP는 service.attributes나 resolve 과정 필요) + // Bonsoir는 service.host에 호스트네임이 들어오므로 resolve 필요 + // MVP 단계에서는 간단히: + await _resolveAndJoin(service); + }, + ); + }, + ); + }, + ), + ), + ); + }, + ); + } + + // Bonsoir Service Resolve (IP 주소 알아내기) + Future _resolveAndJoin(BonsoirService service) async { + // 실제로는 service.resolve() 호출 후 IP 획득 과정을 거쳐야 함. + // Bonsoir 패키지 특성상 resolve가 비동기로 돔. + // MVP 간소화를 위해 service에 host 정보가 있다고 가정하거나 + // Broadcast 시점에 attributes에 IP를 넣는 방식을 추천하지만, + // 일단 resolve 시도: + if (service is BonsoirBroadcast) { + // 이미 broadcast 객체라면 바로 정보가 있음 (내가 만든 방) + } else { + await service.resolve(service.resolveRealService); + } + + // IP가 ipv4 형태인지 확인 필요. 보통 service.ip 나 attributes 사용 + // 여기선 port만 확실하므로, 실제 IP 획득은 Bonsoir 예제 참고 필요 + // (테스트 환경에서는 보통 service.attributes에 {'ip': '192.168...'} 넣어서 보냄) + + // *중요*: 실제 구현 시 NetworkManager.startHosting에서 attributes에 IP를 넣어주는게 가장 확실함. + // 일단 현재 코드는 로직 흐름만 잡음. + + // _net.joinRoom('192.168.0.xxx', service.port); + } +} + +class _BigButton extends StatelessWidget { + final String title; + final Color color; + final VoidCallback onTap; + + const _BigButton({required this.title, required this.color, required this.onTap}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + width: 150, + height: 150, + decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(20)), + child: Center(child: Text(title, textAlign: TextAlign.center, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold))), + ), + ); + } +} \ No newline at end of file diff --git a/apps/app/lib/main.dart b/apps/app/lib/main.dart index 7b7f5b6..9439b20 100644 --- a/apps/app/lib/main.dart +++ b/apps/app/lib/main.dart @@ -1,122 +1,22 @@ import 'package:flutter/material.dart'; +import 'intro_screen.dart'; void main() { - runApp(const MyApp()); + runApp(const PlayWithApp()); } -class MyApp extends StatelessWidget { - const MyApp({super.key}); +class PlayWithApp extends StatelessWidget { + const PlayWithApp({super.key}); - // This widget is the root of your application. @override Widget build(BuildContext context) { return MaterialApp( - title: 'Flutter Demo', + title: 'PlayWith', theme: ThemeData( - // This is the theme of your application. - // - // TRY THIS: Try running your application with "flutter run". You'll see - // the application has a purple toolbar. Then, without quitting the app, - // try changing the seedColor in the colorScheme below to Colors.green - // and then invoke "hot reload" (save your changes or press the "hot - // reload" button in a Flutter-supported IDE, or press "r" if you used - // the command line to start the app). - // - // Notice that the counter didn't reset back to zero; the application - // state is not lost during the reload. To reset the state, use hot - // restart instead. - // - // This works for code too, not just values: Most code changes can be - // tested with just a hot reload. - colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), + primarySwatch: Colors.blue, + useMaterial3: true, ), - home: const MyHomePage(title: 'Flutter Demo Home Page'), + home: const IntroScreen(), ); } -} - -class MyHomePage extends StatefulWidget { - const MyHomePage({super.key, required this.title}); - - // This widget is the home page of your application. It is stateful, meaning - // that it has a State object (defined below) that contains fields that affect - // how it looks. - - // This class is the configuration for the state. It holds the values (in this - // case the title) provided by the parent (in this case the App widget) and - // used by the build method of the State. Fields in a Widget subclass are - // always marked "final". - - final String title; - - @override - State createState() => _MyHomePageState(); -} - -class _MyHomePageState extends State { - int _counter = 0; - - void _incrementCounter() { - setState(() { - // This call to setState tells the Flutter framework that something has - // changed in this State, which causes it to rerun the build method below - // so that the display can reflect the updated values. If we changed - // _counter without calling setState(), then the build method would not be - // called again, and so nothing would appear to happen. - _counter++; - }); - } - - @override - Widget build(BuildContext context) { - // This method is rerun every time setState is called, for instance as done - // by the _incrementCounter method above. - // - // The Flutter framework has been optimized to make rerunning build methods - // fast, so that you can just rebuild anything that needs updating rather - // than having to individually change instances of widgets. - return Scaffold( - appBar: AppBar( - // TRY THIS: Try changing the color here to a specific color (to - // Colors.amber, perhaps?) and trigger a hot reload to see the AppBar - // change color while the other colors stay the same. - backgroundColor: Theme.of(context).colorScheme.inversePrimary, - // Here we take the value from the MyHomePage object that was created by - // the App.build method, and use it to set our appbar title. - title: Text(widget.title), - ), - body: Center( - // Center is a layout widget. It takes a single child and positions it - // in the middle of the parent. - child: Column( - // Column is also a layout widget. It takes a list of children and - // arranges them vertically. By default, it sizes itself to fit its - // children horizontally, and tries to be as tall as its parent. - // - // Column has various properties to control how it sizes itself and - // how it positions its children. Here we use mainAxisAlignment to - // center the children vertically; the main axis here is the vertical - // axis because Columns are vertical (the cross axis would be - // horizontal). - // - // TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint" - // action in the IDE, or press "p" in the console), to see the - // wireframe for each widget. - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text('You have pushed the button this many times:'), - Text( - '$_counter', - style: Theme.of(context).textTheme.headlineMedium, - ), - ], - ), - ), - floatingActionButton: FloatingActionButton( - onPressed: _incrementCounter, - tooltip: 'Increment', - child: const Icon(Icons.add), - ), // This trailing comma makes auto-formatting nicer for build methods. - ); - } -} +} \ No newline at end of file diff --git a/packages/core/lib/network/network_manager.dart b/packages/core/lib/network/network_manager.dart index 97ad4d5..daf9195 100644 --- a/packages/core/lib/network/network_manager.dart +++ b/packages/core/lib/network/network_manager.dart @@ -75,6 +75,15 @@ class NetworkManager extends ChangeNotifier { int port = _serverSocket!.port; print('[Host] Server opened on port: $port'); +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'); + } + // B. 게스트 접속 대기 _serverSocket!.listen((Socket client) { _handleNewGuest(client); @@ -87,6 +96,7 @@ class NetworkManager extends ChangeNotifier { name: '$roomName#${me.id}', type: '_playwith._tcp', port: port, + attributes: {'ip': myIp}, ); _bonsoirBroadcast = BonsoirBroadcast(service: _bonsoirService!);