Compare commits
7
Commits
e992a5ca5e
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8f431cac2 | ||
|
|
22caf64855 | ||
|
|
bf40c42c2c | ||
|
|
283f08786e | ||
|
|
bc57468aaa | ||
|
|
92a4525091 | ||
|
|
dde81cab65 |
@@ -7,16 +7,21 @@ plugins {
|
||||
|
||||
android {
|
||||
namespace = "com.playwith.playwith_app"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
compileSdk = 36
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
// [수정할 부분]
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
// 기존 1.8 -> 17로 변경
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
// [수정할 부분]
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_11.toString()
|
||||
// 기존 '1.8' -> '17'로 변경
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
@@ -24,8 +29,11 @@ android {
|
||||
applicationId = "com.playwith.playwith_app"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
// [수정] 최소 SDK는 21 이상이면 됨
|
||||
minSdk = flutter.minSdkVersion
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
// [수정] 타겟 SDK도 34로 올림
|
||||
targetSdk = 36
|
||||
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
}
|
||||
@@ -39,6 +47,12 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// [추가] 디슈가링 라이브러리 추가 (필수)
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4")
|
||||
}
|
||||
|
||||
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
|
||||
@@ -1,4 +1,33 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE"/>
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
|
||||
android:usesPermissionFlags="neverForLocation"/>
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES"
|
||||
android:usesPermissionFlags="neverForLocation" />
|
||||
<uses-permission android:name="android.permission.CAMERA"/>
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||
|
||||
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.speech.RecognitionService" />
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<application
|
||||
android:label="playwith_app"
|
||||
android:name="${applicationName}"
|
||||
@@ -30,6 +59,12 @@
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.ads.APPLICATION_ID"
|
||||
android:value="ca-app-pub-9504446465764716~3452126047"
|
||||
/>
|
||||
|
||||
</application>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
|
||||
@@ -1,5 +1,43 @@
|
||||
package com.playwith.playwith_app
|
||||
|
||||
import android.content.Context
|
||||
import android.net.wifi.WifiManager
|
||||
import android.os.Bundle
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
class MainActivity: FlutterActivity() {
|
||||
// 멀티캐스트 잠금 객체
|
||||
private var multicastLock: WifiManager.MulticastLock? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
acquireMulticastLock()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
releaseMulticastLock()
|
||||
}
|
||||
|
||||
private fun acquireMulticastLock() {
|
||||
try {
|
||||
val wifiManager = applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
|
||||
// "multicastLock" 태그로 잠금 생성
|
||||
multicastLock = wifiManager.createMulticastLock("multicastLock")
|
||||
multicastLock?.setReferenceCounted(true)
|
||||
multicastLock?.acquire() // 잠금 활성화 (멀티캐스트 수신 허용)
|
||||
println("[Android Native] Multicast Lock Acquired!")
|
||||
} catch (e: Exception) {
|
||||
println("[Android Native] Failed to acquire Multicast Lock: $e")
|
||||
}
|
||||
}
|
||||
|
||||
private fun releaseMulticastLock() {
|
||||
try {
|
||||
multicastLock?.release() // 잠금 해제
|
||||
println("[Android Native] Multicast Lock Released!")
|
||||
} catch (e: Exception) {
|
||||
println("[Android Native] Failed to release Multicast Lock: $e")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,16 +5,16 @@ allprojects {
|
||||
}
|
||||
}
|
||||
|
||||
val newBuildDir: Directory =
|
||||
rootProject.layout.buildDirectory
|
||||
.dir("../../build")
|
||||
.get()
|
||||
// [핵심] 빌드 결과물 위치를 Flutter 표준 경로(../../build)로 변경
|
||||
// 이 코드가 없으면 Flutter가 APK를 찾지 못해 에러가 납니다.
|
||||
val newBuildDir: Directory = rootProject.layout.buildDirectory.dir("../../build").get()
|
||||
rootProject.layout.buildDirectory.value(newBuildDir)
|
||||
|
||||
subprojects {
|
||||
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
|
||||
project.layout.buildDirectory.value(newSubprojectBuildDir)
|
||||
}
|
||||
|
||||
subprojects {
|
||||
project.evaluationDependsOn(":app")
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Uncomment this line to define a global platform for your project
|
||||
# platform :ios, '13.0'
|
||||
platform :ios, '13.0'
|
||||
|
||||
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
PODS:
|
||||
- audioplayers_darwin (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- bonsoir_darwin (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- CwlCatchException (2.2.1):
|
||||
- CwlCatchExceptionSupport (~> 2.2.1)
|
||||
- CwlCatchExceptionSupport (2.2.1)
|
||||
- device_info_plus (0.0.1):
|
||||
- Flutter
|
||||
- DKImagePickerController/Core (4.3.9):
|
||||
- DKImagePickerController/ImageDataManager
|
||||
- DKImagePickerController/Resource
|
||||
- DKImagePickerController/ImageDataManager (4.3.9)
|
||||
- DKImagePickerController/PhotoGallery (4.3.9):
|
||||
- DKImagePickerController/Core
|
||||
- DKPhotoGallery
|
||||
- DKImagePickerController/Resource (4.3.9)
|
||||
- DKPhotoGallery (0.0.19):
|
||||
- DKPhotoGallery/Core (= 0.0.19)
|
||||
- DKPhotoGallery/Model (= 0.0.19)
|
||||
- DKPhotoGallery/Preview (= 0.0.19)
|
||||
- DKPhotoGallery/Resource (= 0.0.19)
|
||||
- SDWebImage
|
||||
- SwiftyGif
|
||||
- DKPhotoGallery/Core (0.0.19):
|
||||
- DKPhotoGallery/Model
|
||||
- DKPhotoGallery/Preview
|
||||
- SDWebImage
|
||||
- SwiftyGif
|
||||
- DKPhotoGallery/Model (0.0.19):
|
||||
- SDWebImage
|
||||
- SwiftyGif
|
||||
- DKPhotoGallery/Preview (0.0.19):
|
||||
- DKPhotoGallery/Model
|
||||
- DKPhotoGallery/Resource
|
||||
- SDWebImage
|
||||
- SwiftyGif
|
||||
- DKPhotoGallery/Resource (0.0.19):
|
||||
- SDWebImage
|
||||
- SwiftyGif
|
||||
- file_picker (0.0.1):
|
||||
- DKImagePickerController/PhotoGallery
|
||||
- Flutter
|
||||
- Flutter (1.0.0)
|
||||
- flutter_local_notifications (0.0.1):
|
||||
- Flutter
|
||||
- gal (1.0.0):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- Google-Mobile-Ads-SDK (11.13.0):
|
||||
- GoogleUserMessagingPlatform (>= 1.1)
|
||||
- google_mobile_ads (5.3.1):
|
||||
- Flutter
|
||||
- Google-Mobile-Ads-SDK (~> 11.13.0)
|
||||
- webview_flutter_wkwebview
|
||||
- GoogleDataTransport (9.4.1):
|
||||
- GoogleUtilities/Environment (~> 7.7)
|
||||
- nanopb (< 2.30911.0, >= 2.30908.0)
|
||||
- PromisesObjC (< 3.0, >= 1.2)
|
||||
- GoogleMLKit/BarcodeScanning (6.0.0):
|
||||
- GoogleMLKit/MLKitCore
|
||||
- MLKitBarcodeScanning (~> 5.0.0)
|
||||
- GoogleMLKit/MLKitCore (6.0.0):
|
||||
- MLKitCommon (~> 11.0.0)
|
||||
- GoogleToolboxForMac/Defines (4.2.1)
|
||||
- GoogleToolboxForMac/Logger (4.2.1):
|
||||
- GoogleToolboxForMac/Defines (= 4.2.1)
|
||||
- "GoogleToolboxForMac/NSData+zlib (4.2.1)":
|
||||
- GoogleToolboxForMac/Defines (= 4.2.1)
|
||||
- GoogleUserMessagingPlatform (3.1.0)
|
||||
- GoogleUtilities/Environment (7.13.3):
|
||||
- GoogleUtilities/Privacy
|
||||
- PromisesObjC (< 3.0, >= 1.2)
|
||||
- GoogleUtilities/Logger (7.13.3):
|
||||
- GoogleUtilities/Environment
|
||||
- GoogleUtilities/Privacy
|
||||
- GoogleUtilities/Privacy (7.13.3)
|
||||
- GoogleUtilities/UserDefaults (7.13.3):
|
||||
- GoogleUtilities/Logger
|
||||
- GoogleUtilities/Privacy
|
||||
- GoogleUtilitiesComponents (1.1.0):
|
||||
- GoogleUtilities/Logger
|
||||
- GTMSessionFetcher/Core (3.5.0)
|
||||
- image_picker_ios (0.0.1):
|
||||
- Flutter
|
||||
- MLImage (1.0.0-beta5)
|
||||
- MLKitBarcodeScanning (5.0.0):
|
||||
- MLKitCommon (~> 11.0)
|
||||
- MLKitVision (~> 7.0)
|
||||
- MLKitCommon (11.0.0):
|
||||
- GoogleDataTransport (< 10.0, >= 9.4.1)
|
||||
- GoogleToolboxForMac/Logger (< 5.0, >= 4.2.1)
|
||||
- "GoogleToolboxForMac/NSData+zlib (< 5.0, >= 4.2.1)"
|
||||
- GoogleUtilities/UserDefaults (< 8.0, >= 7.13.0)
|
||||
- GoogleUtilitiesComponents (~> 1.0)
|
||||
- GTMSessionFetcher/Core (< 4.0, >= 3.3.2)
|
||||
- MLKitVision (7.0.0):
|
||||
- GoogleToolboxForMac/Logger (< 5.0, >= 4.2.1)
|
||||
- "GoogleToolboxForMac/NSData+zlib (< 5.0, >= 4.2.1)"
|
||||
- GTMSessionFetcher/Core (< 4.0, >= 3.3.2)
|
||||
- MLImage (= 1.0.0-beta5)
|
||||
- MLKitCommon (~> 11.0)
|
||||
- mobile_scanner (5.2.3):
|
||||
- Flutter
|
||||
- GoogleMLKit/BarcodeScanning (~> 6.0.0)
|
||||
- nanopb (2.30910.0):
|
||||
- nanopb/decode (= 2.30910.0)
|
||||
- nanopb/encode (= 2.30910.0)
|
||||
- nanopb/decode (2.30910.0)
|
||||
- nanopb/encode (2.30910.0)
|
||||
- network_info_plus (0.0.1):
|
||||
- Flutter
|
||||
- objective_c (0.0.1):
|
||||
- Flutter
|
||||
- permission_handler_apple (9.3.0):
|
||||
- Flutter
|
||||
- PromisesObjC (2.4.0)
|
||||
- SDWebImage (5.21.1):
|
||||
- SDWebImage/Core (= 5.21.1)
|
||||
- SDWebImage/Core (5.21.1)
|
||||
- shared_preferences_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- speech_to_text (7.2.0):
|
||||
- CwlCatchException
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- sqlite3 (3.50.4):
|
||||
- sqlite3/common (= 3.50.4)
|
||||
- sqlite3/common (3.50.4)
|
||||
- sqlite3/dbstatvtab (3.50.4):
|
||||
- sqlite3/common
|
||||
- sqlite3/fts5 (3.50.4):
|
||||
- sqlite3/common
|
||||
- sqlite3/math (3.50.4):
|
||||
- sqlite3/common
|
||||
- sqlite3/perf-threadsafe (3.50.4):
|
||||
- sqlite3/common
|
||||
- sqlite3/rtree (3.50.4):
|
||||
- sqlite3/common
|
||||
- sqlite3/session (3.50.4):
|
||||
- sqlite3/common
|
||||
- sqlite3_flutter_libs (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- sqlite3 (~> 3.50.4)
|
||||
- sqlite3/dbstatvtab
|
||||
- sqlite3/fts5
|
||||
- sqlite3/math
|
||||
- sqlite3/perf-threadsafe
|
||||
- sqlite3/rtree
|
||||
- sqlite3/session
|
||||
- SwiftyGif (5.4.5)
|
||||
- url_launcher_ios (0.0.1):
|
||||
- Flutter
|
||||
- webview_flutter_wkwebview (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- wifi_iot (0.0.1):
|
||||
- Flutter
|
||||
|
||||
DEPENDENCIES:
|
||||
- audioplayers_darwin (from `.symlinks/plugins/audioplayers_darwin/darwin`)
|
||||
- bonsoir_darwin (from `.symlinks/plugins/bonsoir_darwin/darwin`)
|
||||
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
|
||||
- file_picker (from `.symlinks/plugins/file_picker/ios`)
|
||||
- Flutter (from `Flutter`)
|
||||
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
|
||||
- gal (from `.symlinks/plugins/gal/darwin`)
|
||||
- google_mobile_ads (from `.symlinks/plugins/google_mobile_ads/ios`)
|
||||
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
||||
- mobile_scanner (from `.symlinks/plugins/mobile_scanner/ios`)
|
||||
- network_info_plus (from `.symlinks/plugins/network_info_plus/ios`)
|
||||
- objective_c (from `.symlinks/plugins/objective_c/ios`)
|
||||
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
|
||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||
- speech_to_text (from `.symlinks/plugins/speech_to_text/darwin`)
|
||||
- sqlite3_flutter_libs (from `.symlinks/plugins/sqlite3_flutter_libs/darwin`)
|
||||
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
||||
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`)
|
||||
- wifi_iot (from `.symlinks/plugins/wifi_iot/ios`)
|
||||
|
||||
SPEC REPOS:
|
||||
trunk:
|
||||
- CwlCatchException
|
||||
- CwlCatchExceptionSupport
|
||||
- DKImagePickerController
|
||||
- DKPhotoGallery
|
||||
- Google-Mobile-Ads-SDK
|
||||
- GoogleDataTransport
|
||||
- GoogleMLKit
|
||||
- GoogleToolboxForMac
|
||||
- GoogleUserMessagingPlatform
|
||||
- GoogleUtilities
|
||||
- GoogleUtilitiesComponents
|
||||
- GTMSessionFetcher
|
||||
- MLImage
|
||||
- MLKitBarcodeScanning
|
||||
- MLKitCommon
|
||||
- MLKitVision
|
||||
- nanopb
|
||||
- PromisesObjC
|
||||
- SDWebImage
|
||||
- sqlite3
|
||||
- SwiftyGif
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
audioplayers_darwin:
|
||||
:path: ".symlinks/plugins/audioplayers_darwin/darwin"
|
||||
bonsoir_darwin:
|
||||
:path: ".symlinks/plugins/bonsoir_darwin/darwin"
|
||||
device_info_plus:
|
||||
:path: ".symlinks/plugins/device_info_plus/ios"
|
||||
file_picker:
|
||||
:path: ".symlinks/plugins/file_picker/ios"
|
||||
Flutter:
|
||||
:path: Flutter
|
||||
flutter_local_notifications:
|
||||
:path: ".symlinks/plugins/flutter_local_notifications/ios"
|
||||
gal:
|
||||
:path: ".symlinks/plugins/gal/darwin"
|
||||
google_mobile_ads:
|
||||
:path: ".symlinks/plugins/google_mobile_ads/ios"
|
||||
image_picker_ios:
|
||||
:path: ".symlinks/plugins/image_picker_ios/ios"
|
||||
mobile_scanner:
|
||||
:path: ".symlinks/plugins/mobile_scanner/ios"
|
||||
network_info_plus:
|
||||
:path: ".symlinks/plugins/network_info_plus/ios"
|
||||
objective_c:
|
||||
:path: ".symlinks/plugins/objective_c/ios"
|
||||
permission_handler_apple:
|
||||
:path: ".symlinks/plugins/permission_handler_apple/ios"
|
||||
shared_preferences_foundation:
|
||||
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
|
||||
speech_to_text:
|
||||
:path: ".symlinks/plugins/speech_to_text/darwin"
|
||||
sqlite3_flutter_libs:
|
||||
:path: ".symlinks/plugins/sqlite3_flutter_libs/darwin"
|
||||
url_launcher_ios:
|
||||
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
||||
webview_flutter_wkwebview:
|
||||
:path: ".symlinks/plugins/webview_flutter_wkwebview/darwin"
|
||||
wifi_iot:
|
||||
:path: ".symlinks/plugins/wifi_iot/ios"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
audioplayers_darwin: 4027b33a8f471d996c13f71cb77f0b1583b5d923
|
||||
bonsoir_darwin: e3b8526c42ca46a885142df84229131dfabea842
|
||||
CwlCatchException: 7acc161b299a6de7f0a46a6ed741eae2c8b4d75a
|
||||
CwlCatchExceptionSupport: 54ccab8d8c78907b57f99717fb19d4cc3bce02dc
|
||||
device_info_plus: 97af1d7e84681a90d0693e63169a5d50e0839a0d
|
||||
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
|
||||
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
|
||||
file_picker: b159e0c068aef54932bb15dc9fd1571818edaf49
|
||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||
flutter_local_notifications: 4cde75091f6327eb8517fa068a0a5950212d2086
|
||||
gal: 6a522c75909f1244732d4596d11d6a2f86ff37a5
|
||||
Google-Mobile-Ads-SDK: 14f57f2dc33532a24db288897e26494640810407
|
||||
google_mobile_ads: fe0e2c1764ad95323dd0e3081d0bb2d58411f957
|
||||
GoogleDataTransport: 6c09b596d841063d76d4288cc2d2f42cc36e1e2a
|
||||
GoogleMLKit: 97ac7af399057e99182ee8edfa8249e3226a4065
|
||||
GoogleToolboxForMac: d1a2cbf009c453f4d6ded37c105e2f67a32206d8
|
||||
GoogleUserMessagingPlatform: befe603da6501006420c206222acd449bba45a9c
|
||||
GoogleUtilities: ea963c370a38a8069cc5f7ba4ca849a60b6d7d15
|
||||
GoogleUtilitiesComponents: 679b2c881db3b615a2777504623df6122dd20afe
|
||||
GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6
|
||||
image_picker_ios: 4f2f91b01abdb52842a8e277617df877e40f905b
|
||||
MLImage: 1824212150da33ef225fbd3dc49f184cf611046c
|
||||
MLKitBarcodeScanning: 10ca0845a6d15f2f6e911f682a1998b68b973e8b
|
||||
MLKitCommon: afec63980417d29ffbb4790529a1b0a2291699e1
|
||||
MLKitVision: e858c5f125ecc288e4a31127928301eaba9ae0c1
|
||||
mobile_scanner: 96e91f2e1fb396bb7df8da40429ba8dfad664740
|
||||
nanopb: 438bc412db1928dac798aa6fd75726007be04262
|
||||
network_info_plus: 9d930145451916919786087c4173226363616071
|
||||
objective_c: 77e887b5ba1827970907e10e832eec1683f3431d
|
||||
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
|
||||
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
||||
SDWebImage: f29024626962457f3470184232766516dee8dfea
|
||||
shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6
|
||||
speech_to_text: 87bf9298952e8d9073be1b6aade6d5758db5170c
|
||||
sqlite3: 73513155ec6979715d3904ef53a8d68892d4032b
|
||||
sqlite3_flutter_libs: 86f82662868ee26ff3451f73cac9c5fc2a1f57fa
|
||||
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
|
||||
url_launcher_ios: bb13df5870e8c4234ca12609d04010a21be43dfa
|
||||
webview_flutter_wkwebview: 29eb20d43355b48fe7d07113835b9128f84e3af4
|
||||
wifi_iot: b5aafd6f9b52f8a357383a1deabab45f31cd602d
|
||||
|
||||
PODFILE CHECKSUM: 251cb053df7158f337c0712f2ab29f4e0fa474ce
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
@@ -10,6 +10,8 @@
|
||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
|
||||
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
|
||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
||||
51DBF7C9342CCC1E84264FAF /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 57CE06CA7580196D1806764B /* Pods_RunnerTests.framework */; };
|
||||
576F22A04C2FA735A59CCB8C /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8D742755804ABA6F6EFA09B0 /* Pods_Runner.framework */; };
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||
@@ -44,10 +46,16 @@
|
||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
||||
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
|
||||
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
34B441CD4D8D7C51D92293E3 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
||||
4E75157E0476F57C9A36AAFF /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
|
||||
57CE06CA7580196D1806764B /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||
7CAA68E8ECB59955A38AF698 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
|
||||
7DB0FB06F75F1BDF46E6C95B /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
8D742755804ABA6F6EFA09B0 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
||||
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
|
||||
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
@@ -55,13 +63,25 @@
|
||||
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
A0F77A962ED7E5CF0058AC51 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
|
||||
D4D50D0F4B952368E1319826 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
|
||||
F6B684F378DF7FE6F2C3CABC /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
373154A272D279F7BAD56A3D /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
51DBF7C9342CCC1E84264FAF /* Pods_RunnerTests.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
97C146EB1CF9000F007C117D /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
576F22A04C2FA735A59CCB8C /* Pods_Runner.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -76,6 +96,15 @@
|
||||
path = RunnerTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
86E8FF9E84BDE142E9B2A7E5 /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
8D742755804ABA6F6EFA09B0 /* Pods_Runner.framework */,
|
||||
57CE06CA7580196D1806764B /* Pods_RunnerTests.framework */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
9740EEB11CF90186004384FC /* Flutter */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -94,6 +123,8 @@
|
||||
97C146F01CF9000F007C117D /* Runner */,
|
||||
97C146EF1CF9000F007C117D /* Products */,
|
||||
331C8082294A63A400263BE5 /* RunnerTests */,
|
||||
A70DA976E0448863AE3AAB92 /* Pods */,
|
||||
86E8FF9E84BDE142E9B2A7E5 /* Frameworks */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -109,6 +140,7 @@
|
||||
97C146F01CF9000F007C117D /* Runner */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A0F77A962ED7E5CF0058AC51 /* Runner.entitlements */,
|
||||
97C146FA1CF9000F007C117D /* Main.storyboard */,
|
||||
97C146FD1CF9000F007C117D /* Assets.xcassets */,
|
||||
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
|
||||
@@ -121,6 +153,19 @@
|
||||
path = Runner;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A70DA976E0448863AE3AAB92 /* Pods */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
34B441CD4D8D7C51D92293E3 /* Pods-Runner.debug.xcconfig */,
|
||||
F6B684F378DF7FE6F2C3CABC /* Pods-Runner.release.xcconfig */,
|
||||
D4D50D0F4B952368E1319826 /* Pods-Runner.profile.xcconfig */,
|
||||
7DB0FB06F75F1BDF46E6C95B /* Pods-RunnerTests.debug.xcconfig */,
|
||||
4E75157E0476F57C9A36AAFF /* Pods-RunnerTests.release.xcconfig */,
|
||||
7CAA68E8ECB59955A38AF698 /* Pods-RunnerTests.profile.xcconfig */,
|
||||
);
|
||||
path = Pods;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
@@ -128,8 +173,10 @@
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
|
||||
buildPhases = (
|
||||
72139F7C55C8F7C501C7B164 /* [CP] Check Pods Manifest.lock */,
|
||||
331C807D294A63A400263BE5 /* Sources */,
|
||||
331C807F294A63A400263BE5 /* Resources */,
|
||||
373154A272D279F7BAD56A3D /* Frameworks */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@@ -145,12 +192,15 @@
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||
buildPhases = (
|
||||
88859AD75AFEC32170AD0FCA /* [CP] Check Pods Manifest.lock */,
|
||||
9740EEB61CF901F6004384FC /* Run Script */,
|
||||
97C146EA1CF9000F007C117D /* Sources */,
|
||||
97C146EB1CF9000F007C117D /* Frameworks */,
|
||||
97C146EC1CF9000F007C117D /* Resources */,
|
||||
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
||||
9DCF60722691A8C657DDBEC4 /* [CP] Embed Pods Frameworks */,
|
||||
AB0501777866B4369253924C /* [CP] Copy Pods Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@@ -238,6 +288,50 @@
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
|
||||
};
|
||||
72139F7C55C8F7C501C7B164 /* [CP] Check Pods Manifest.lock */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
|
||||
"${PODS_ROOT}/Manifest.lock",
|
||||
);
|
||||
name = "[CP] Check Pods Manifest.lock";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
"$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
88859AD75AFEC32170AD0FCA /* [CP] Check Pods Manifest.lock */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
|
||||
"${PODS_ROOT}/Manifest.lock",
|
||||
);
|
||||
name = "[CP] Check Pods Manifest.lock";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
9740EEB61CF901F6004384FC /* Run Script */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
@@ -253,6 +347,40 @@
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
|
||||
};
|
||||
9DCF60722691A8C657DDBEC4 /* [CP] Embed Pods Frameworks */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
name = "[CP] Embed Pods Frameworks";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
AB0501777866B4369253924C /* [CP] Copy Pods Resources */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
name = "[CP] Copy Pods Resources";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
@@ -361,6 +489,7 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = TDVYRQ3Z3E;
|
||||
ENABLE_BITCODE = NO;
|
||||
@@ -379,6 +508,7 @@
|
||||
};
|
||||
331C8088294A63A400263BE5 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 7DB0FB06F75F1BDF46E6C95B /* Pods-RunnerTests.debug.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
@@ -396,6 +526,7 @@
|
||||
};
|
||||
331C8089294A63A400263BE5 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 4E75157E0476F57C9A36AAFF /* Pods-RunnerTests.release.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
@@ -411,6 +542,7 @@
|
||||
};
|
||||
331C808A294A63A400263BE5 /* Profile */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 7CAA68E8ECB59955A38AF698 /* Pods-RunnerTests.profile.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
@@ -541,6 +673,7 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = TDVYRQ3Z3E;
|
||||
ENABLE_BITCODE = NO;
|
||||
@@ -564,6 +697,7 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = TDVYRQ3Z3E;
|
||||
ENABLE_BITCODE = NO;
|
||||
|
||||
@@ -4,4 +4,7 @@
|
||||
<FileRef
|
||||
location = "group:Runner.xcodeproj">
|
||||
</FileRef>
|
||||
<FileRef
|
||||
location = "group:Pods/Pods.xcodeproj">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
|
||||
@@ -45,5 +45,41 @@
|
||||
<true/>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>주변 친구들과 게임을 하기 위해 로컬 네트워크 권한이 필요합니다.</string>
|
||||
<key>NSBonjourServices</key>
|
||||
<array>
|
||||
<string>_playwith._tcp</string>
|
||||
<string>_playwith._udp</string>
|
||||
</array>
|
||||
|
||||
<key>GADApplicationIdentifier</key>
|
||||
<string>ca-app-pub-3940256099942544~1458002511</string>
|
||||
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>QR 코드를 스캔하여 방에 접속하기 위해 카메라 권한이 필요합니다.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>채팅방에 사진을 공유하기 위해 갤러리 접근 권한이 필요합니다.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>사진을 찍어 공유하기 위해 카메라 권한이 필요합니다.</string>
|
||||
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>동영상 촬영 및 음성 정답 입력을 위해 마이크 권한이 필요합니다.</string>
|
||||
|
||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||
<string>이미지를 갤러리에 저장하기 위해 권한이 필요합니다.</string>
|
||||
<key>NSSpeechRecognitionUsageDescription</key>
|
||||
<string>말한 내용을 텍스트로 변환하여 정답을 확인합니다.</string>
|
||||
|
||||
<key>com.apple.developer.networking.wifi-info</key>
|
||||
<true/>
|
||||
<key>NEHotspotConfiguration</key>
|
||||
<true/>
|
||||
|
||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||
<string>주변 친구를 찾기 위해 블루투스를 사용합니다.</string>
|
||||
<key>NSBluetoothPeripheralUsageDescription</key>
|
||||
<string>주변 친구를 찾기 위해 블루투스를 사용합니다.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.developer.networking.HotspotConfiguration</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,44 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:playwith_core/playwith_core.dart'; // SettingsNotifier
|
||||
import 'intro_view.dart';
|
||||
|
||||
class IntroScreen extends StatelessWidget {
|
||||
final WidgetBuilder nextScreenBuilder;
|
||||
|
||||
const IntroScreen({
|
||||
super.key,
|
||||
required this.nextScreenBuilder,
|
||||
});
|
||||
|
||||
void _navigateToNextScreen(BuildContext context) {
|
||||
// 인트로 종료 후 다음 화면(Lobby)으로 이동 (뒤로가기 불가)
|
||||
Navigator.of(context).pushReplacement(
|
||||
PageRouteBuilder(
|
||||
pageBuilder: (context, animation, secondaryAnimation) => nextScreenBuilder(context),
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||
return FadeTransition(opacity: animation, child: child);
|
||||
},
|
||||
transitionDuration: const Duration(milliseconds: 800), // 부드러운 전환
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// SettingsNotifier 싱글톤에서 현재 색상 가져오기
|
||||
final Color currentColor = SettingsNotifier().currentColor;
|
||||
|
||||
return Scaffold(
|
||||
// 배경색은 테마 배경색 사용
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
body: Center(
|
||||
child: IntroViewFlutter(
|
||||
mainColor: currentColor,
|
||||
onAnimationFinished: () {
|
||||
_navigateToNextScreen(context);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// "SBSPACE"를 한 줄로 그리는 IntroView
|
||||
class IntroViewFlutter extends StatefulWidget {
|
||||
final Color mainColor;
|
||||
final VoidCallback onAnimationFinished;
|
||||
|
||||
const IntroViewFlutter({
|
||||
super.key,
|
||||
required this.mainColor,
|
||||
required this.onAnimationFinished,
|
||||
});
|
||||
|
||||
@override
|
||||
State<IntroViewFlutter> createState() => _IntroViewFlutterState();
|
||||
}
|
||||
|
||||
class _IntroViewFlutterState extends State<IntroViewFlutter>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _controller;
|
||||
late final Animation<int> _logoTextAnimation;
|
||||
late final Animation<int> _missionTextAnimation;
|
||||
|
||||
static const String _logoString = "SBSPACE";
|
||||
static const String _missionString = "Simple is Best.";
|
||||
// [참고] 폰트가 없으면 기본 폰트로 나옵니다. 에셋에 폰트 추가가 필요할 수 있습니다.
|
||||
static const String _fontFamily = "Sdmisaeng";
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
const int logoDuration = _logoString.length * 150;
|
||||
const int missionDuration = _missionString.length * 100;
|
||||
final int totalAnimationDuration = logoDuration + missionDuration;
|
||||
|
||||
_controller = AnimationController(
|
||||
duration: Duration(milliseconds: totalAnimationDuration),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_logoTextAnimation = IntTween(begin: 0, end: _logoString.length).animate(
|
||||
CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: Interval(0.0, logoDuration / totalAnimationDuration, curve: Curves.linear),
|
||||
),
|
||||
);
|
||||
|
||||
_missionTextAnimation = IntTween(begin: 0, end: _missionString.length).animate(
|
||||
CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: Interval(logoDuration / totalAnimationDuration, 1.0, curve: Curves.linear),
|
||||
),
|
||||
);
|
||||
|
||||
_controller.addStatusListener((status) {
|
||||
if (status == AnimationStatus.completed) {
|
||||
Future.delayed(const Duration(seconds: 1), () {
|
||||
if (mounted) widget.onAnimationFinished();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
_controller.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, child) {
|
||||
return CustomPaint(
|
||||
painter: _IntroPainter(
|
||||
mainColor: widget.mainColor,
|
||||
fontFamily: _fontFamily,
|
||||
logoTextLength: _logoTextAnimation.value,
|
||||
missionTextLength: _missionTextAnimation.value,
|
||||
),
|
||||
size: Size.infinite,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _IntroPainter extends CustomPainter {
|
||||
final Color mainColor;
|
||||
final String fontFamily;
|
||||
final int logoTextLength;
|
||||
final int missionTextLength;
|
||||
|
||||
static const String _logoString = "SBSPACE";
|
||||
static const String _missionString = "Simple is Best.";
|
||||
|
||||
_IntroPainter({
|
||||
required this.mainColor,
|
||||
required this.fontFamily,
|
||||
required this.logoTextLength,
|
||||
required this.missionTextLength,
|
||||
});
|
||||
|
||||
TextSpan _buildLogoSpan(int length) {
|
||||
final Color normalColor = mainColor.withOpacity(0.6);
|
||||
final List<TextSpan> children = [];
|
||||
|
||||
if (length >= 1) children.add(TextSpan(text: "S", style: TextStyle(color: mainColor)));
|
||||
if (length >= 2) children.add(TextSpan(text: "B", style: TextStyle(color: mainColor)));
|
||||
if (length >= 3) {
|
||||
final String spaceToDraw = _logoString.substring(2, length.clamp(2, _logoString.length));
|
||||
children.add(TextSpan(text: spaceToDraw, style: TextStyle(color: normalColor)));
|
||||
}
|
||||
|
||||
return TextSpan(
|
||||
style: TextStyle(fontFamily: fontFamily, fontWeight: FontWeight.bold),
|
||||
children: children,
|
||||
);
|
||||
}
|
||||
|
||||
TextPainter _createTextPainter(TextSpan textSpan, double fontSize) {
|
||||
final style = textSpan.style!.copyWith(fontSize: fontSize);
|
||||
final painter = TextPainter(
|
||||
text: TextSpan(children: textSpan.children, style: style),
|
||||
textDirection: TextDirection.ltr,
|
||||
);
|
||||
painter.layout();
|
||||
return painter;
|
||||
}
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final double logoFontSize = size.shortestSide / 6.0;
|
||||
|
||||
const double tempMissionFontSize = 100.0;
|
||||
final TextPainter tpMissionTemp = TextPainter(
|
||||
text: TextSpan(
|
||||
text: _missionString,
|
||||
style: TextStyle(fontFamily: fontFamily, fontWeight: FontWeight.bold, fontSize: tempMissionFontSize)
|
||||
),
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout();
|
||||
|
||||
final double targetWidth = size.width * 0.9;
|
||||
final double scale = targetWidth / tpMissionTemp.width;
|
||||
final double missionFontSize = tempMissionFontSize * scale;
|
||||
|
||||
final tpLogoFull = _createTextPainter(_buildLogoSpan(_logoString.length), logoFontSize);
|
||||
|
||||
final TextPainter tpMissionFull = TextPainter(
|
||||
text: TextSpan(
|
||||
text: _missionString,
|
||||
style: TextStyle(
|
||||
color: mainColor,
|
||||
fontSize: missionFontSize,
|
||||
fontFamily: fontFamily,
|
||||
fontWeight: FontWeight.bold
|
||||
)
|
||||
),
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout();
|
||||
|
||||
final double padding = logoFontSize * 0.1;
|
||||
final double totalHeight = tpLogoFull.height + padding + tpMissionFull.height;
|
||||
final double startyLogo = (size.height - totalHeight) / 2.0;
|
||||
final double startyMission = startyLogo + tpLogoFull.height + padding;
|
||||
|
||||
final double startxLogo = (size.width - tpLogoFull.width) / 2.0;
|
||||
final double startxMission = (size.width - tpMissionFull.width) / 2.0;
|
||||
|
||||
final tpLogoSub = _createTextPainter(_buildLogoSpan(logoTextLength), logoFontSize);
|
||||
tpLogoSub.paint(canvas, Offset(startxLogo, startyLogo));
|
||||
|
||||
final String missionToDraw = _missionString.substring(0, missionTextLength);
|
||||
final tpMissionSub = TextPainter(
|
||||
text: TextSpan(text: missionToDraw, style: tpMissionFull.text!.style),
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout();
|
||||
|
||||
tpMissionSub.paint(canvas, Offset(startxMission, startyMission));
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _IntroPainter oldDelegate) {
|
||||
return oldDelegate.mainColor != mainColor ||
|
||||
oldDelegate.logoTextLength != logoTextLength ||
|
||||
oldDelegate.missionTextLength != missionTextLength;
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
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<IntroScreen> createState() => _IntroScreenState();
|
||||
}
|
||||
|
||||
class _IntroScreenState extends State<IntroScreen> {
|
||||
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('입장하기'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+685
-81
@@ -1,7 +1,15 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:bonsoir/bonsoir.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||
import 'package:playwith_core/playwith_core.dart';
|
||||
|
||||
import 'package:qr_flutter/qr_flutter.dart';
|
||||
import 'package:wifi_iot/wifi_iot.dart';
|
||||
import 'package:network_info_plus/network_info_plus.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
class LobbyScreen extends StatefulWidget {
|
||||
const LobbyScreen({super.key});
|
||||
|
||||
@@ -10,122 +18,623 @@ class LobbyScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _LobbyScreenState extends State<LobbyScreen> {
|
||||
final _net = NetworkManager(); // Singleton 인스턴스
|
||||
final _net = NetworkManager();
|
||||
final List<String> _logs = [];
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_net.logStream.listen((log) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_logs.add(log);
|
||||
if (_logs.length > 100) _logs.removeAt(0);
|
||||
});
|
||||
if (SettingsNotifier().isShowDebugLog) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.animateTo(
|
||||
_scrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
_net.messageStream.listen((data) {
|
||||
if (data['type'] == 'GAME_START') {
|
||||
final String gameId = data['gameId'];
|
||||
_routeToGame(gameId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Future<int?> _showSpiderDifficultyDialog() {
|
||||
return showDialog<int>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text("스파이더 난이도"),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
title: const Text("초급 (1가지 무늬)"),
|
||||
subtitle: const Text("스페이드만 사용"),
|
||||
leading: const Icon(Icons.looks_one, color: Colors.green),
|
||||
onTap: () => Navigator.pop(context, 1),
|
||||
),
|
||||
ListTile(
|
||||
title: const Text("중급 (2가지 무늬)"),
|
||||
subtitle: const Text("스페이드 + 하트"),
|
||||
leading: const Icon(Icons.looks_two, color: Colors.orange),
|
||||
onTap: () => Navigator.pop(context, 2),
|
||||
),
|
||||
ListTile(
|
||||
title: const Text("고급 (4가지 무늬)"),
|
||||
subtitle: const Text("모든 무늬 사용"),
|
||||
leading: const Icon(Icons.looks_4, color: Colors.red),
|
||||
onTap: () => Navigator.pop(context, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [TextButton(onPressed: () => Navigator.pop(context, null), child: const Text("취소"))],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _routeToGame(String gameId) {
|
||||
if (gameId == 'quiz_ox' || gameId == 'quiz_mix') {
|
||||
_startGameAndNavigate(QuizGame());
|
||||
} else if (gameId == 'sudoku_battle') {
|
||||
_startGameAndNavigate(SudokuMultiGame());
|
||||
} else if (gameId == 'spider_battle') {
|
||||
_startGameAndNavigate(SpiderMultiGame());
|
||||
} else if (gameId == 'omok') {
|
||||
_startGameAndNavigate(OmokGame());
|
||||
} else if (gameId == 'janggi') {
|
||||
_startGameAndNavigate(JanggiGame());
|
||||
} else if (gameId == 'yutnori') {
|
||||
_startGameAndNavigate(YutnoriGame());
|
||||
} else if (gameId == 'memory_battle') {
|
||||
_startGameAndNavigate(MemoryGame());
|
||||
} else if (gameId == 'balance_game') {
|
||||
_startGameAndNavigate(BalanceGame());
|
||||
} else if (gameId == 'tap_battle') {
|
||||
_startGameAndNavigate(TapBattleGame());
|
||||
} else if (gameId == 'world_tour') {
|
||||
_startGameAndNavigate(WorldTourGame());
|
||||
} else if (gameId == 'othello') {
|
||||
_startGameAndNavigate(OthelloGame());
|
||||
} else if (gameId == 'arkanoid') {
|
||||
_startGameAndNavigate(ArkanoidGame());
|
||||
}else if (gameId == 'math_run') {
|
||||
_startGameAndNavigate(MathRunGame());
|
||||
}
|
||||
else if (gameId == 'jump_battle') {
|
||||
_startGameAndNavigate(JumpGame());
|
||||
}
|
||||
// [추가] 아이엠그라운드 연결
|
||||
else if (gameId == 'iam_ground') {
|
||||
_startGameAndNavigate(IAmGroundGame());
|
||||
}
|
||||
else if (gameId == 'survivor') {
|
||||
_startGameAndNavigate(SurvivorGame());
|
||||
}
|
||||
else if (gameId == 'sequence_memory') {
|
||||
_startGameAndNavigate(SequenceMemoryGame());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _startGameAndNavigate(BaseGame game) async {
|
||||
if (!mounted) return;
|
||||
game.onStart();
|
||||
|
||||
await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) {
|
||||
Widget gameView;
|
||||
if (_net.role == NetworkRole.host) {
|
||||
gameView = game.buildHostView(context);
|
||||
} else {
|
||||
gameView = game.buildGuestView(context);
|
||||
}
|
||||
return Stack(
|
||||
children: [
|
||||
gameView,
|
||||
const SafeArea(
|
||||
child: GameChatOverlay(bottomOffset: 60.0),
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
if (_net.hostIp == "Solo Mode") {
|
||||
_net.stopNetwork();
|
||||
}
|
||||
}
|
||||
|
||||
void _navigateToGameSelection({required bool isSolo}) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => GameSelectionScreen(
|
||||
onGameSelected: (gameId) async {
|
||||
Map<String, dynamic> config = {};
|
||||
if (gameId == 'sudoku_battle') {
|
||||
final difficulty = await _showDifficultyDialog();
|
||||
if (difficulty == null) return;
|
||||
config['difficulty'] = difficulty;
|
||||
}
|
||||
// [추가] 스파이더 난이도
|
||||
else if (gameId == 'spider_battle') {
|
||||
final suits = await _showSpiderDifficultyDialog();
|
||||
if (suits == null) return;
|
||||
config['difficulty'] = suits; // numSuits (1, 2, 4)
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
Navigator.pop(context);
|
||||
|
||||
if (isSolo) {
|
||||
_net.startSoloMode(gameId, config: config);
|
||||
} else {
|
||||
_net.selectGame(gameId, config: config);
|
||||
_net.startHosting("${_net.me.nickname}의 방");
|
||||
|
||||
Future.delayed(const Duration(milliseconds: 500), () {
|
||||
if (mounted && _net.role == NetworkRole.host) _showHostQRDialog();
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<int?> _showDifficultyDialog() {
|
||||
return showDialog<int>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text("난이도 선택"),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(title: const Text("쉬움 (4x4)"), onTap: () => Navigator.pop(context, 1)),
|
||||
ListTile(title: const Text("보통 (9x9)"), onTap: () => Navigator.pop(context, 4)),
|
||||
ListTile(title: const Text("어려움 (9x9)"), onTap: () => Navigator.pop(context, 7)),
|
||||
],
|
||||
),
|
||||
actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text("취소"))],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _openGameSelector() {
|
||||
if (_net.role != NetworkRole.host) return;
|
||||
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => GameSelectionScreen(
|
||||
onGameSelected: (gameId) async {
|
||||
Map<String, dynamic> config = {};
|
||||
if (gameId == 'sudoku_battle') {
|
||||
final difficulty = await _showDifficultyDialog();
|
||||
if (difficulty == null) return;
|
||||
config['difficulty'] = difficulty;
|
||||
}
|
||||
else if (gameId == 'spider_battle') {
|
||||
final suits = await _showSpiderDifficultyDialog();
|
||||
if (suits == null) return;
|
||||
config['difficulty'] = suits;
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
Navigator.pop(context);
|
||||
|
||||
if (_net.role == NetworkRole.host) {
|
||||
_net.selectGame(gameId, config: config);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// NetworkManager의 상태(notifyListeners)가 변경될 때마다 화면 다시 그림
|
||||
return ListenableBuilder(
|
||||
listenable: _net,
|
||||
builder: (context, child) {
|
||||
return ListenableBuilder(
|
||||
listenable: SettingsNotifier(),
|
||||
builder: (context, _) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('안녕하세요, ${_net.me.nickname}님'),
|
||||
title: Text('대기실: ${_net.me.nickname}'),
|
||||
actions: [
|
||||
if (_net.role == NetworkRole.host)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.qr_code),
|
||||
tooltip: "초대 QR 보기",
|
||||
onPressed: () => _showHostQRDialog(),
|
||||
),
|
||||
|
||||
if (_net.role != NetworkRole.none)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => _net.stopNetwork(), // 연결 끊기
|
||||
icon: const Icon(Icons.exit_to_app),
|
||||
tooltip: "나가기",
|
||||
onPressed: () => _net.stopNetwork(),
|
||||
)
|
||||
],
|
||||
),
|
||||
body: _buildBody(),
|
||||
bottomNavigationBar: const SafeArea(child: AdBannerWidget()),
|
||||
body: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: _net.role == NetworkRole.none
|
||||
? _buildInitView()
|
||||
: _buildLobbyView()
|
||||
),
|
||||
const Divider(thickness: 1, height: 1),
|
||||
if (SettingsNotifier().isShowDebugLog) _buildDebugConsole(),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody() {
|
||||
// 1. 아무 역할도 없을 때 -> 선택 화면
|
||||
if (_net.role == NetworkRole.none) {
|
||||
Widget _buildInitView() {
|
||||
return Center(
|
||||
child: Row(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text("게임을 시작해볼까요?", style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 40),
|
||||
|
||||
_BigButton(
|
||||
title: "혼자 연습하기\n(Single)",
|
||||
color: Colors.orange[100]!,
|
||||
icon: Icons.person,
|
||||
onTap: () => _navigateToGameSelection(isSolo: true),
|
||||
),
|
||||
|
||||
const SizedBox(height: 30),
|
||||
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
_BigButton(
|
||||
title: "방 만들기\n(Host)",
|
||||
color: Colors.blue[100]!,
|
||||
onTap: () => _net.startHosting("${_net.me.nickname}의 방"),
|
||||
icon: Icons.add_home_work,
|
||||
onTap: () => _navigateToGameSelection(isSolo: false),
|
||||
),
|
||||
_BigButton(
|
||||
title: "방 찾기\n(Guest)",
|
||||
color: Colors.green[100]!,
|
||||
icon: Icons.search,
|
||||
onTap: () => _showRoomListDialog(),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 30),
|
||||
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Icons.qr_code_scanner),
|
||||
label: const Text("QR 코드로 접속하기"),
|
||||
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 15)),
|
||||
onPressed: () => _openQRScanner(),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
TextButton(
|
||||
onPressed: () => _showManualJoinDialog(),
|
||||
child: const Text("IP 주소 직접 입력 (비상용)", style: TextStyle(color: Colors.grey)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Host 상태일 때 -> 대기실 화면
|
||||
if (_net.role == NetworkRole.host) {
|
||||
return Center(
|
||||
child: Column(
|
||||
Widget _buildLobbyView() {
|
||||
final currentGame = AppGames.getById(_net.selectedGameId);
|
||||
final bool isHost = _net.role == NetworkRole.host;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: isHost ? _openGameSelector : null,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 20),
|
||||
color: Colors.indigo.withOpacity(0.1),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text("👑 방장입니다", style: TextStyle(fontSize: 24)),
|
||||
const SizedBox(height: 20),
|
||||
const CircularProgressIndicator(),
|
||||
const SizedBox(height: 20),
|
||||
const Text("참가자를 기다리는 중..."),
|
||||
// TODO: 여기에 접속한 게스트 목록 표시 예정
|
||||
Icon(currentGame.icon, size: 24, color: Colors.indigo),
|
||||
const SizedBox(width: 10),
|
||||
Column(
|
||||
children: [
|
||||
Text(
|
||||
"현재 게임: ${currentGame.name}",
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.indigo),
|
||||
),
|
||||
if (isHost)
|
||||
const Text("(눌러서 변경)", style: TextStyle(fontSize: 12, color: Colors.grey)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(20),
|
||||
color: Colors.grey[100],
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(isHost ? Icons.wifi_tethering : Icons.wifi, color: Colors.blue),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
isHost ? "👑 방장 (나)" : "참가자 (나)",
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (isHost) ...[
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
SelectableText(
|
||||
"IP: ${_net.hostIp} / Port: ${_net.hostPort}",
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
InkWell(
|
||||
onTap: () => _showHostQRDialog(),
|
||||
child: const Icon(Icons.qr_code, color: Colors.black87),
|
||||
)
|
||||
],
|
||||
),
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.wifi_password),
|
||||
label: const Text("핫스팟(야외용) QR 만들기"),
|
||||
onPressed: () => _showHotspotCreateDialog(),
|
||||
),
|
||||
] else ...[
|
||||
const SizedBox(height: 10),
|
||||
Text("방장 IP: ${_net.hostIp ?? '...'}", style: const TextStyle(color: Colors.grey)),
|
||||
]
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
const Text("참가자 목록", style: TextStyle(color: Colors.grey, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 10),
|
||||
_buildUserTile(_net.me, isMe: true),
|
||||
..._net.guestList.map((guest) => _buildUserTile(guest, isMe: false)),
|
||||
|
||||
if (_net.guestList.isEmpty && isHost)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(40.0),
|
||||
child: Center(child: Text("참가자를 기다리는 중...\nQR 코드를 보여주세요.", textAlign: TextAlign.center, style: TextStyle(color: Colors.grey))),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10.0),
|
||||
child: _buildReadyButton(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildUserTile(UserInfo user, {required bool isMe}) {
|
||||
return Card(
|
||||
elevation: user.isReady ? 4 : 1,
|
||||
color: user.isReady ? Colors.green[50] : Colors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: user.isReady ? const BorderSide(color: Colors.green, width: 2) : BorderSide.none,
|
||||
),
|
||||
margin: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
leading: AvatarWidget(user: user, size: 50),
|
||||
title: Text(
|
||||
user.nickname + (isMe ? " (나)" : ""),
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||
),
|
||||
subtitle: Text(isMe ? "준비 버튼을 눌러주세요" : (user.isReady ? "준비 완료!" : "준비 중..."), style: TextStyle(color: Colors.grey[600], fontSize: 12)),
|
||||
trailing: user.isReady
|
||||
? const Icon(Icons.check_circle, color: Colors.green, size: 32)
|
||||
: const Icon(Icons.hourglass_empty, color: Colors.grey, size: 32),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildReadyButton() {
|
||||
bool isReady = _net.me.isReady;
|
||||
bool canReady = _net.role == NetworkRole.host ? _net.guestList.isNotEmpty : true;
|
||||
if (_net.hostIp == "Solo Mode") canReady = true;
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
||||
child: ElevatedButton(
|
||||
onPressed: canReady
|
||||
? () => _net.toggleReady()
|
||||
: () => ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("친구를 초대해야 시작할 수 있습니다!"))),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: !canReady ? Colors.grey[300] : (isReady ? Colors.redAccent : Colors.blueAccent),
|
||||
padding: const EdgeInsets.symmetric(vertical: 18),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
elevation: canReady ? 5 : 0,
|
||||
),
|
||||
child: Text(
|
||||
isReady ? "준비 취소 (WAIT)" : "준비 완료 (READY)",
|
||||
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: !canReady ? Colors.grey : Colors.white),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDebugConsole() {
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
color: Colors.black87,
|
||||
child: const Text("DEBUG LOGS", style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
SizedBox(
|
||||
height: 150,
|
||||
child: Container(
|
||||
color: Colors.black,
|
||||
child: ListView.builder(
|
||||
controller: _scrollController,
|
||||
itemCount: _logs.length,
|
||||
itemBuilder: (context, index) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 2.0),
|
||||
child: Text(
|
||||
_logs[index],
|
||||
style: const TextStyle(color: Colors.greenAccent, fontSize: 12, fontFamily: 'Courier'),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _showHotspotCreateDialog() {
|
||||
final ssidCtrl = TextEditingController();
|
||||
final pwCtrl = TextEditingController();
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text("핫스팟 QR 만들기"),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text("스마트폰 설정에서 핫스팟을 켜고,\n그 정보를 입력해주세요.", style: TextStyle(fontSize: 12, color: Colors.grey)),
|
||||
const SizedBox(height: 10),
|
||||
TextField(controller: ssidCtrl, decoration: const InputDecoration(labelText: "핫스팟 이름 (SSID)")),
|
||||
TextField(controller: pwCtrl, decoration: const InputDecoration(labelText: "비밀번호")),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text("취소")),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
if (ssidCtrl.text.isEmpty) return;
|
||||
Navigator.pop(ctx);
|
||||
|
||||
final Map<String, dynamic> qrData = {
|
||||
'type': 'hotspot_invite',
|
||||
'ssid': ssidCtrl.text,
|
||||
'pwd': pwCtrl.text,
|
||||
'port': _net.hostPort ?? 0,
|
||||
};
|
||||
_showGeneratedQR(jsonEncode(qrData), "핫스팟 + 게임 접속 QR");
|
||||
},
|
||||
child: const Text("생성"),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Guest 상태일 때 -> 대기실 화면
|
||||
if (_net.role == NetworkRole.guest) {
|
||||
return const Center(
|
||||
void _showHostQRDialog() {
|
||||
if (_net.hostIp == null || _net.hostPort == null) return;
|
||||
final qrData = jsonEncode({'ip': _net.hostIp, 'port': _net.hostPort});
|
||||
_showGeneratedQR(qrData, "초대 QR 코드 (같은 와이파이)");
|
||||
}
|
||||
|
||||
void _showGeneratedQR(String data, String title) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => Dialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text("✅ 접속 완료!", style: TextStyle(fontSize: 24)),
|
||||
SizedBox(height: 20),
|
||||
Text("방장이 게임을 시작하기를 기다리세요."),
|
||||
Text(title, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 20),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(border: Border.all(color: Colors.black12), borderRadius: BorderRadius.circular(10)),
|
||||
child: QrImageView(data: data, version: QrVersions.auto, size: 220.0),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
ElevatedButton(onPressed: () => Navigator.pop(context), child: const Text("닫기"))
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return const SizedBox();
|
||||
}
|
||||
|
||||
// [Guest용] 방 목록 팝업
|
||||
void _showRoomListDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text("방 찾는 중..."),
|
||||
content: SizedBox(
|
||||
width: double.maxFinite,
|
||||
height: 300,
|
||||
child: StreamBuilder<List<BonsoirService>>(
|
||||
stream: _net.discoverRooms(), // Core의 방 찾기 스트림
|
||||
stream: _net.discoverRooms(),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData || snapshot.data!.isEmpty) {
|
||||
return const Center(child: Text("발견된 방이 없습니다.\n(같은 와이파이인지 확인하세요)"));
|
||||
return const Center(child: Text("검색 중...\n같은 와이파이인지 확인하세요."));
|
||||
}
|
||||
|
||||
final services = snapshot.data!;
|
||||
return ListView.builder(
|
||||
itemCount: services.length,
|
||||
itemCount: snapshot.data!.length,
|
||||
itemBuilder: (context, index) {
|
||||
final service = services[index];
|
||||
// 이름 포맷: "방이름#ID" -> "방이름"만 파싱
|
||||
final displayName = service.name.split('#').first;
|
||||
|
||||
final service = snapshot.data![index];
|
||||
final ip = service.attributes?['ip'] ?? '알 수 없음';
|
||||
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);
|
||||
title: Text(service.name.split('#').first),
|
||||
subtitle: Text(ip),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
if (service.attributes != null && service.attributes!['ip'] != null) {
|
||||
_net.joinRoom(service.attributes!['ip']!, service.port);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
@@ -133,51 +642,146 @@ class _LobbyScreenState extends State<LobbyScreen> {
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text("닫기"))],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Bonsoir Service Resolve (IP 주소 알아내기)
|
||||
Future<void> _resolveAndJoin(BonsoirService service) async {
|
||||
// 실제로는 service.resolve() 호출 후 IP 획득 과정을 거쳐야 함.
|
||||
// Bonsoir 패키지 특성상 resolve가 비동기로 돔.
|
||||
// MVP 간소화를 위해 service에 host 정보가 있다고 가정하거나
|
||||
// Broadcast 시점에 attributes에 IP를 넣는 방식을 추천하지만,
|
||||
// 일단 resolve 시도:
|
||||
if (service is BonsoirBroadcast) {
|
||||
// 이미 broadcast 객체라면 바로 정보가 있음 (내가 만든 방)
|
||||
void _openQRScanner() {
|
||||
bool isScanCompleted = false;
|
||||
Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (context) => Scaffold(
|
||||
appBar: AppBar(title: const Text("QR 스캔")),
|
||||
body: MobileScanner(
|
||||
onDetect: (capture) async {
|
||||
if (isScanCompleted) return;
|
||||
final List<Barcode> barcodes = capture.barcodes;
|
||||
|
||||
for (final barcode in barcodes) {
|
||||
final String? rawValue = barcode.rawValue;
|
||||
if (rawValue == null) continue;
|
||||
|
||||
try {
|
||||
final data = jsonDecode(rawValue);
|
||||
|
||||
// 핫스팟 자동 접속
|
||||
if (data['type'] == 'hotspot_invite') {
|
||||
isScanCompleted = true;
|
||||
await _connectToHotspotAndJoin(
|
||||
data['ssid'],
|
||||
data['pwd'],
|
||||
data['port']
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 일반 게임 접속
|
||||
if (data['ip'] != null && data['port'] != null) {
|
||||
isScanCompleted = true;
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("접속 중...")));
|
||||
_net.joinRoom(data['ip'], data['port']);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
// JSON 아님
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
Future<void> _connectToHotspotAndJoin(String ssid, String pwd, int port) async {
|
||||
if (Platform.isAndroid) {
|
||||
var status = await Permission.location.request();
|
||||
if (!status.isGranted) return;
|
||||
}
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text("핫스팟 '$ssid' 연결 시도 중...")),
|
||||
);
|
||||
|
||||
try {
|
||||
bool connected = await WiFiForIoTPlugin.connect(
|
||||
ssid,
|
||||
password: pwd,
|
||||
security: NetworkSecurity.WPA,
|
||||
joinOnce: true,
|
||||
withInternet: false,
|
||||
);
|
||||
|
||||
if (connected) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text("와이파이 연결 성공! 방장을 찾는 중...")),
|
||||
);
|
||||
|
||||
final info = NetworkInfo();
|
||||
String? gatewayIp = await info.getWifiGatewayIP();
|
||||
|
||||
if (gatewayIp != null) {
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
if (!mounted) return;
|
||||
Navigator.pop(context);
|
||||
_net.joinRoom(gatewayIp, port);
|
||||
} else {
|
||||
await service.resolve(service.resolveRealService);
|
||||
throw Exception("방장 IP를 찾을 수 없습니다.");
|
||||
}
|
||||
} else {
|
||||
throw Exception("와이파이 연결 실패.");
|
||||
}
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text("오류: $e")),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
void _showManualJoinDialog() {
|
||||
final ipCtrl = TextEditingController(text: "192.168.");
|
||||
final portCtrl = TextEditingController();
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text("직접 입력"),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(controller: ipCtrl, decoration: const InputDecoration(labelText: "IP Address")),
|
||||
TextField(controller: portCtrl, decoration: const InputDecoration(labelText: "Port")),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: const Text("취소")),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
final ip = ipCtrl.text.trim();
|
||||
final port = int.tryParse(portCtrl.text.trim());
|
||||
if (ip.isNotEmpty && port != null) {
|
||||
Navigator.pop(context);
|
||||
_net.joinRoom(ip, port);
|
||||
}
|
||||
},
|
||||
child: const Text("접속"),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BigButton extends StatelessWidget {
|
||||
final String title;
|
||||
final Color color;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _BigButton({required this.title, required this.color, required this.onTap});
|
||||
|
||||
final String title; final Color color; final IconData icon; final VoidCallback onTap;
|
||||
const _BigButton({required this.title, required this.color, required this.icon, 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))),
|
||||
width: 130, height: 130,
|
||||
decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(20), boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 10, offset: const Offset(0, 5))]),
|
||||
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [Icon(icon, size: 40, color: Colors.black54), const SizedBox(height: 10), Text(title, textAlign: TextAlign.center, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold))]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:playwith_core/playwith_core.dart';
|
||||
import 'lobby_screen.dart';
|
||||
import 'screens/settings_screen.dart';
|
||||
|
||||
class LoginScreen extends StatefulWidget {
|
||||
const LoginScreen({super.key});
|
||||
|
||||
@override
|
||||
State<LoginScreen> createState() => _LoginScreenState();
|
||||
}
|
||||
|
||||
class _LoginScreenState extends State<LoginScreen> {
|
||||
final _nicknameController = TextEditingController();
|
||||
final _settings = SettingsNotifier();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 저장된 닉네임 반영
|
||||
Future.delayed(const Duration(milliseconds: 100), () {
|
||||
if (mounted && _settings.nickname.isNotEmpty) {
|
||||
setState(() {
|
||||
_nicknameController.text = _settings.nickname;
|
||||
});
|
||||
}
|
||||
});
|
||||
_settings.addListener(_syncSettings);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_settings.removeListener(_syncSettings);
|
||||
_nicknameController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _syncSettings() {
|
||||
if (_nicknameController.text != _settings.nickname) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_nicknameController.text = _settings.nickname;
|
||||
});
|
||||
}
|
||||
}
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
Future<void> _enterLobby() async {
|
||||
final inputNick = _nicknameController.text.trim();
|
||||
if (inputNick.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("닉네임을 입력해주세요.")));
|
||||
return;
|
||||
}
|
||||
|
||||
// 안드로이드 권한 체크
|
||||
if (Platform.isAndroid) {
|
||||
Map<Permission, PermissionStatus> statuses = await [
|
||||
Permission.location,
|
||||
Permission.nearbyWifiDevices,
|
||||
].request();
|
||||
|
||||
bool isNearby = statuses[Permission.nearbyWifiDevices]?.isGranted ?? false;
|
||||
bool isLocation = statuses[Permission.location]?.isGranted ?? false;
|
||||
if (!isNearby && !isLocation) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("⚠️ 권한 허용이 필요합니다.")));
|
||||
}
|
||||
}
|
||||
|
||||
// 변경 사항 저장
|
||||
if (inputNick != _settings.nickname) {
|
||||
await _settings.setProfile(inputNick, _settings.avatarIndex);
|
||||
}
|
||||
|
||||
// [수정] 초기화 시 닉네임과 이미지를 함께 전달
|
||||
NetworkManager().initialize(
|
||||
nickname: _settings.nickname,
|
||||
profileImage: _settings.profileImageBase64, // [추가]
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
Navigator.push(context, MaterialPageRoute(builder: (_) => const LobbyScreen()));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.settings, color: Colors.grey),
|
||||
tooltip: "설정",
|
||||
onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const SettingsScreen())),
|
||||
)
|
||||
],
|
||||
),
|
||||
body: Center(
|
||||
child: SingleChildScrollView(
|
||||
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),
|
||||
|
||||
// [핵심] AvatarWidget 사용 (Core 컴포넌트)
|
||||
ListenableBuilder(
|
||||
listenable: _settings,
|
||||
builder: (context, _) {
|
||||
return GestureDetector(
|
||||
onTap: () => _settings.pickProfileImage(),
|
||||
child: Stack(
|
||||
children: [
|
||||
AvatarWidget(
|
||||
base64Image: _settings.profileImageBase64,
|
||||
colorValue: Colors.primaries[_settings.avatarIndex % Colors.primaries.length].value,
|
||||
nickname: _nicknameController.text,
|
||||
size: 120,
|
||||
),
|
||||
Positioned(
|
||||
right: 0, bottom: 0,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: const BoxDecoration(color: Colors.blue, shape: BoxShape.circle),
|
||||
child: const Icon(Icons.camera_alt, size: 20, color: Colors.white),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 30),
|
||||
|
||||
TextField(
|
||||
controller: _nicknameController,
|
||||
textAlign: TextAlign.center,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '닉네임',
|
||||
border: OutlineInputBorder(),
|
||||
floatingLabelBehavior: FloatingLabelBehavior.always,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 30),
|
||||
|
||||
ElevatedButton(
|
||||
onPressed: _enterLobby,
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size(double.infinity, 50),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||
),
|
||||
child: const Text('입장하기', style: TextStyle(fontSize: 18)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+67
-7
@@ -1,22 +1,82 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'intro_screen.dart';
|
||||
import 'package:playwith_core/playwith_core.dart';
|
||||
import 'login_screen.dart'; // [수정] 인트로 스크린 import (경로가 다르면 수정 필요)
|
||||
import 'intro/intro_screen.dart'; // 만약 intro 폴더에 넣으셨다면 이 경로 사용
|
||||
import 'lobby_screen.dart';
|
||||
import 'package:google_mobile_ads/google_mobile_ads.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
SoundManager().initialize(soundPaths: {
|
||||
SoundKey.bgm: 'audio/bgm.mp3',
|
||||
SoundKey.correct: 'audio/correct.mp3',
|
||||
SoundKey.wrong: 'audio/wrong.mp3',
|
||||
SoundKey.win: 'audio/win.mp3',
|
||||
SoundKey.click: 'audio/correct.mp3',
|
||||
});
|
||||
await MobileAds.instance.initialize(); // [추가]
|
||||
await NotificationManager().initialize();
|
||||
|
||||
void main() {
|
||||
runApp(const PlayWithApp());
|
||||
}
|
||||
|
||||
class PlayWithApp extends StatelessWidget {
|
||||
class PlayWithApp extends StatefulWidget {
|
||||
const PlayWithApp({super.key});
|
||||
|
||||
@override
|
||||
State<PlayWithApp> createState() => _PlayWithAppState();
|
||||
}
|
||||
|
||||
class _PlayWithAppState extends State<PlayWithApp> {
|
||||
final _net = NetworkManager();
|
||||
final _settings = SettingsNotifier();
|
||||
|
||||
final List<BaseGame> _games = [
|
||||
QuizGame(),
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_net.messageStream.listen((data) {
|
||||
// 라우팅 로직...
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: _settings,
|
||||
builder: (context, child) {
|
||||
return MaterialApp(
|
||||
title: 'PlayWith',
|
||||
theme: ThemeData(
|
||||
primarySwatch: Colors.blue,
|
||||
useMaterial3: true,
|
||||
theme: _settings.currentTheme,
|
||||
themeMode: _settings.currentThemeMode,
|
||||
builder: (context, child) {
|
||||
return MediaQuery(
|
||||
data: MediaQuery.of(context).copyWith(
|
||||
textScaler: TextScaler.linear(_settings.fontScale),
|
||||
),
|
||||
home: const IntroScreen(),
|
||||
child: child!,
|
||||
);
|
||||
},
|
||||
// [핵심 수정] 앱 시작 시 IntroScreen을 먼저 보여줌
|
||||
// nextScreenBuilder를 통해 애니메이션 종료 후 갈 곳(Lobby) 지정
|
||||
home: IntroScreen(
|
||||
nextScreenBuilder: (context) => const LoginScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// [팁] 인트로와 닉네임 입력(IntroScreen.dart의 기존 로직)을 연결하기 위한 래퍼
|
||||
// 기존에 있던 닉네임 입력 화면(IntroScreen)과 이름이 겹치므로,
|
||||
// 기존의 닉네임 입력 화면은 'LoginScreen'이나 'NameInputScreen'으로 이름을 바꾸는 게 좋습니다.
|
||||
// 만약 'IntroScreen' 파일이 닉네임 입력 화면이었다면,
|
||||
// 이번에 만든 애니메이션 화면을 'SplashAnimationScreen' 등으로 이름을 지어서 구분해주세요.
|
||||
|
||||
// 여기서는 이번에 만든 애니메이션 화면을 'IntroAnimationScreen'이라고 가정하고,
|
||||
// 애니메이션이 끝나면 -> 닉네임 입력 화면(기존 IntroScreen) -> 로비 순서로 가는 게 자연스럽습니다.
|
||||
@@ -0,0 +1,263 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:playwith_core/playwith_core.dart'; // AvatarWidget 포함됨
|
||||
import 'package:url_launcher/url_launcher.dart'; // [추가] 링크 이동용
|
||||
|
||||
class SettingsScreen extends StatefulWidget {
|
||||
const SettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
State<SettingsScreen> createState() => _SettingsScreenState();
|
||||
}
|
||||
|
||||
class _SettingsScreenState extends State<SettingsScreen> {
|
||||
final _nickController = TextEditingController();
|
||||
final _settings = SettingsNotifier();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_nickController.text = _settings.nickname;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nickController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// [추가] 홈페이지 열기 함수
|
||||
Future<void> _launchHomepage() async {
|
||||
// 이동할 홈페이지 주소를 입력하세요
|
||||
final Uri url = Uri.parse('https://lunaticbum.kr"');
|
||||
|
||||
try {
|
||||
if (!await launchUrl(url, mode: LaunchMode.externalApplication)) {
|
||||
throw Exception('Could not launch $url');
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text("페이지를 열 수 없습니다.")),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text("설정")),
|
||||
body: ListenableBuilder(
|
||||
listenable: _settings,
|
||||
builder: (context, _) {
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
// 1. 프로필 설정 섹션
|
||||
_buildSectionTitle("프로필 설정"),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => _settings.pickProfileImage(),
|
||||
child: Stack(
|
||||
alignment: Alignment.bottomRight,
|
||||
children: [
|
||||
AvatarWidget(
|
||||
base64Image: _settings.profileImageBase64,
|
||||
colorValue: Colors.primaries[_settings.avatarIndex % Colors.primaries.length].value,
|
||||
nickname: _nickController.text,
|
||||
size: 100,
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: const BoxDecoration(color: Colors.blue, shape: BoxShape.circle),
|
||||
child: const Icon(Icons.edit, size: 16, color: Colors.white),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
if (_settings.profileImageBase64 != null)
|
||||
TextButton(
|
||||
onPressed: () => _settings.clearProfileImage(),
|
||||
child: const Text("이미지 삭제 (기본값 사용)", style: TextStyle(color: Colors.red)),
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
TextField(
|
||||
controller: _nickController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "닉네임",
|
||||
border: OutlineInputBorder(),
|
||||
helperText: "게임에서 사용할 이름을 입력하세요.",
|
||||
),
|
||||
onChanged: (val) => _settings.setProfile(val, _settings.avatarIndex),
|
||||
),
|
||||
|
||||
const SizedBox(height: 10),
|
||||
|
||||
const Align(alignment: Alignment.centerLeft, child: Text("기본 배경색")),
|
||||
const SizedBox(height: 5),
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: List.generate(Colors.primaries.length, (index) {
|
||||
final isSelected = _settings.avatarIndex == index;
|
||||
return GestureDetector(
|
||||
onTap: () => _settings.setProfile(_nickController.text, index),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(right: 8),
|
||||
width: 30,
|
||||
height: 30,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.primaries[index],
|
||||
shape: BoxShape.circle,
|
||||
border: isSelected ? Border.all(color: Colors.black, width: 2) : null,
|
||||
),
|
||||
child: isSelected ? const Icon(Icons.check, size: 16, color: Colors.white) : null,
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// 2. 디스플레이 설정 섹션
|
||||
_buildSectionTitle("화면 설정"),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SwitchListTile(
|
||||
title: const Text("다크 모드"),
|
||||
value: _settings.isDarkMode,
|
||||
onChanged: (val) => _settings.toggleDarkMode(val),
|
||||
),
|
||||
const Divider(),
|
||||
|
||||
const Text("글자 크기", style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
Slider(
|
||||
value: _settings.fontScale,
|
||||
min: 0.8,
|
||||
max: 1.5,
|
||||
divisions: 7,
|
||||
label: "${(_settings.fontScale * 100).toInt()}%",
|
||||
onChanged: (val) => _settings.setFontScale(val),
|
||||
),
|
||||
Text(
|
||||
"이 크기로 보입니다.",
|
||||
style: TextStyle(fontSize: 16 * _settings.fontScale),
|
||||
),
|
||||
const Divider(),
|
||||
|
||||
const Text("테마 색상", style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 10),
|
||||
Wrap(
|
||||
spacing: 10,
|
||||
runSpacing: 10,
|
||||
children: appColors.entries.map((entry) {
|
||||
final isSelected = _settings.themeColorName == entry.key;
|
||||
return GestureDetector(
|
||||
onTap: () => _settings.setThemeColor(entry.key),
|
||||
child: Container(
|
||||
width: 40, height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: entry.value,
|
||||
shape: BoxShape.circle,
|
||||
border: isSelected ? Border.all(color: Colors.black, width: 3) : null,
|
||||
boxShadow: [if(isSelected) const BoxShadow(blurRadius: 5, color: Colors.black26)],
|
||||
),
|
||||
child: isSelected ? const Icon(Icons.check, color: Colors.white) : null,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// 3. 개발자 옵션 (디버그 로그)
|
||||
_buildSectionTitle("개발자 옵션"),
|
||||
Card(
|
||||
child: SwitchListTile(
|
||||
title: const Text("디버그 로그 표시"),
|
||||
subtitle: const Text("로비 화면 하단에 네트워크 로그를 표시합니다."),
|
||||
value: _settings.isShowDebugLog,
|
||||
onChanged: (val) => _settings.toggleDebugLog(val),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// [추가] 4. 정보 섹션 (라이선스)
|
||||
_buildSectionTitle("정보"),
|
||||
Card(
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.description_outlined),
|
||||
title: const Text("오픈소스 라이선스"),
|
||||
subtitle: const Text("앱에 사용된 라이브러리 정보"),
|
||||
trailing: const Icon(Icons.arrow_forward_ios, size: 16, color: Colors.grey),
|
||||
onTap: () {
|
||||
// 플러터 내장 라이선스 페이지 호출
|
||||
showLicensePage(
|
||||
context: context,
|
||||
applicationName: "PlayWith",
|
||||
applicationVersion: "1.0.0",
|
||||
// applicationIcon: Image.asset('assets/icon.png', width: 50), // 아이콘이 있다면 주석 해제
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 40),
|
||||
|
||||
// [추가] 하단 카피라이트 & 링크
|
||||
GestureDetector(
|
||||
onTap: _launchHomepage,
|
||||
child: Column(
|
||||
children: const [
|
||||
Text(
|
||||
"© 2025 lunaticbum. All rights reserved.",
|
||||
style: TextStyle(color: Colors.grey, fontSize: 12),
|
||||
),
|
||||
SizedBox(height: 4),
|
||||
Text(
|
||||
"https://lunaticbum.kr", // 보여줄 텍스트
|
||||
style: TextStyle(
|
||||
color: Colors.blueAccent,
|
||||
fontSize: 12,
|
||||
decoration: TextDecoration.underline
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 30),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSectionTitle(String title) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 8, bottom: 8),
|
||||
child: Text(title, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.grey)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,22 @@
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <audioplayers_linux/audioplayers_linux_plugin.h>
|
||||
#include <file_selector_linux/file_selector_plugin.h>
|
||||
#include <sqlite3_flutter_libs/sqlite3_flutter_libs_plugin.h>
|
||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||
|
||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||
g_autoptr(FlPluginRegistrar) audioplayers_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "AudioplayersLinuxPlugin");
|
||||
audioplayers_linux_plugin_register_with_registrar(audioplayers_linux_registrar);
|
||||
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
|
||||
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
|
||||
g_autoptr(FlPluginRegistrar) sqlite3_flutter_libs_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "Sqlite3FlutterLibsPlugin");
|
||||
sqlite3_flutter_libs_plugin_register_with_registrar(sqlite3_flutter_libs_registrar);
|
||||
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
|
||||
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
audioplayers_linux
|
||||
file_selector_linux
|
||||
sqlite3_flutter_libs
|
||||
url_launcher_linux
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
||||
@@ -5,8 +5,34 @@
|
||||
import FlutterMacOS
|
||||
import Foundation
|
||||
|
||||
import audioplayers_darwin
|
||||
import bonsoir_darwin
|
||||
import device_info_plus
|
||||
import file_picker
|
||||
import file_selector_macos
|
||||
import flutter_local_notifications
|
||||
import gal
|
||||
import mobile_scanner
|
||||
import network_info_plus
|
||||
import shared_preferences_foundation
|
||||
import speech_to_text
|
||||
import sqlite3_flutter_libs
|
||||
import url_launcher_macos
|
||||
import webview_flutter_wkwebview
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin"))
|
||||
SwiftBonsoirPlugin.register(with: registry.registrar(forPlugin: "SwiftBonsoirPlugin"))
|
||||
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
|
||||
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
|
||||
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
||||
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
||||
GalPlugin.register(with: registry.registrar(forPlugin: "GalPlugin"))
|
||||
MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin"))
|
||||
NetworkInfoPlusPlugin.register(with: registry.registrar(forPlugin: "NetworkInfoPlusPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
SpeechToTextPlugin.register(with: registry.registrar(forPlugin: "SpeechToTextPlugin"))
|
||||
Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin"))
|
||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||
WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin"))
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
platform :osx, '10.15'
|
||||
platform :osx, '12.0'
|
||||
|
||||
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
PODS:
|
||||
- audioplayers_darwin (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- bonsoir_darwin (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- CwlCatchException (2.2.1):
|
||||
- CwlCatchExceptionSupport (~> 2.2.1)
|
||||
- CwlCatchExceptionSupport (2.2.1)
|
||||
- file_picker (0.0.1):
|
||||
- FlutterMacOS
|
||||
- file_selector_macos (0.0.1):
|
||||
- FlutterMacOS
|
||||
- flutter_local_notifications (0.0.1):
|
||||
- FlutterMacOS
|
||||
- FlutterMacOS (1.0.0)
|
||||
- gal (1.0.0):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- mobile_scanner (5.2.3):
|
||||
- FlutterMacOS
|
||||
- objective_c (0.0.1):
|
||||
- FlutterMacOS
|
||||
- shared_preferences_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- speech_to_text (7.2.0):
|
||||
- CwlCatchException
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- sqlite3 (3.50.4):
|
||||
- sqlite3/common (= 3.50.4)
|
||||
- sqlite3/common (3.50.4)
|
||||
- sqlite3/dbstatvtab (3.50.4):
|
||||
- sqlite3/common
|
||||
- sqlite3/fts5 (3.50.4):
|
||||
- sqlite3/common
|
||||
- sqlite3/math (3.50.4):
|
||||
- sqlite3/common
|
||||
- sqlite3/perf-threadsafe (3.50.4):
|
||||
- sqlite3/common
|
||||
- sqlite3/rtree (3.50.4):
|
||||
- sqlite3/common
|
||||
- sqlite3/session (3.50.4):
|
||||
- sqlite3/common
|
||||
- sqlite3_flutter_libs (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- sqlite3 (~> 3.50.4)
|
||||
- sqlite3/dbstatvtab
|
||||
- sqlite3/fts5
|
||||
- sqlite3/math
|
||||
- sqlite3/perf-threadsafe
|
||||
- sqlite3/rtree
|
||||
- sqlite3/session
|
||||
- url_launcher_macos (0.0.1):
|
||||
- FlutterMacOS
|
||||
- webview_flutter_wkwebview (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
|
||||
DEPENDENCIES:
|
||||
- audioplayers_darwin (from `Flutter/ephemeral/.symlinks/plugins/audioplayers_darwin/darwin`)
|
||||
- bonsoir_darwin (from `Flutter/ephemeral/.symlinks/plugins/bonsoir_darwin/darwin`)
|
||||
- file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`)
|
||||
- file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`)
|
||||
- flutter_local_notifications (from `Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos`)
|
||||
- FlutterMacOS (from `Flutter/ephemeral`)
|
||||
- gal (from `Flutter/ephemeral/.symlinks/plugins/gal/darwin`)
|
||||
- mobile_scanner (from `Flutter/ephemeral/.symlinks/plugins/mobile_scanner/macos`)
|
||||
- objective_c (from `Flutter/ephemeral/.symlinks/plugins/objective_c/macos`)
|
||||
- shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||
- speech_to_text (from `Flutter/ephemeral/.symlinks/plugins/speech_to_text/darwin`)
|
||||
- sqlite3_flutter_libs (from `Flutter/ephemeral/.symlinks/plugins/sqlite3_flutter_libs/darwin`)
|
||||
- url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`)
|
||||
- webview_flutter_wkwebview (from `Flutter/ephemeral/.symlinks/plugins/webview_flutter_wkwebview/darwin`)
|
||||
|
||||
SPEC REPOS:
|
||||
trunk:
|
||||
- CwlCatchException
|
||||
- CwlCatchExceptionSupport
|
||||
- sqlite3
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
audioplayers_darwin:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/audioplayers_darwin/darwin
|
||||
bonsoir_darwin:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/bonsoir_darwin/darwin
|
||||
file_picker:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/file_picker/macos
|
||||
file_selector_macos:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos
|
||||
flutter_local_notifications:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos
|
||||
FlutterMacOS:
|
||||
:path: Flutter/ephemeral
|
||||
gal:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/gal/darwin
|
||||
mobile_scanner:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/mobile_scanner/macos
|
||||
objective_c:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/objective_c/macos
|
||||
shared_preferences_foundation:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin
|
||||
speech_to_text:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/speech_to_text/darwin
|
||||
sqlite3_flutter_libs:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/sqlite3_flutter_libs/darwin
|
||||
url_launcher_macos:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos
|
||||
webview_flutter_wkwebview:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/webview_flutter_wkwebview/darwin
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
audioplayers_darwin: 4027b33a8f471d996c13f71cb77f0b1583b5d923
|
||||
bonsoir_darwin: e3b8526c42ca46a885142df84229131dfabea842
|
||||
CwlCatchException: 7acc161b299a6de7f0a46a6ed741eae2c8b4d75a
|
||||
CwlCatchExceptionSupport: 54ccab8d8c78907b57f99717fb19d4cc3bce02dc
|
||||
file_picker: e716a70a9fe5fd9e09ebc922d7541464289443af
|
||||
file_selector_macos: 3e56eaea051180007b900eacb006686fd54da150
|
||||
flutter_local_notifications: 4b427ffabf278fc6ea9484c97505e231166927a5
|
||||
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
|
||||
gal: 6a522c75909f1244732d4596d11d6a2f86ff37a5
|
||||
mobile_scanner: 0a05256215b047af27b9495db3b77640055e8824
|
||||
objective_c: e5f8194456e8fc943e034d1af00510a1bc29c067
|
||||
shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6
|
||||
speech_to_text: 87bf9298952e8d9073be1b6aade6d5758db5170c
|
||||
sqlite3: 73513155ec6979715d3904ef53a8d68892d4032b
|
||||
sqlite3_flutter_libs: 86f82662868ee26ff3451f73cac9c5fc2a1f57fa
|
||||
url_launcher_macos: 175a54c831f4375a6cf895875f716ee5af3888ce
|
||||
webview_flutter_wkwebview: 29eb20d43355b48fe7d07113835b9128f84e3af4
|
||||
|
||||
PODFILE CHECKSUM: 1e95c36afbfd1cb6423ceca4de7a8e1b256fb6ac
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
@@ -21,12 +21,14 @@
|
||||
/* End PBXAggregateTarget section */
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
16081A8E490D05A0EB9DCB3B /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C20AF64D20A0D998576B7391 /* Pods_Runner.framework */; };
|
||||
331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; };
|
||||
335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; };
|
||||
33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; };
|
||||
33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; };
|
||||
33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; };
|
||||
33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; };
|
||||
868F63F0C0F04AA311C4DEB8 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8AA200BBA16E846EF02EA220 /* Pods_RunnerTests.framework */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -60,11 +62,12 @@
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
2FD6D31AB2013B75CB16C72F /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
|
||||
331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
|
||||
333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = "<group>"; };
|
||||
335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = "<group>"; };
|
||||
33CC10ED2044A3C60003C045 /* playwith_app.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "playwith_app.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
33CC10ED2044A3C60003C045 /* playwith_app.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = playwith_app.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = "<group>"; };
|
||||
33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = "<group>"; };
|
||||
@@ -76,8 +79,15 @@
|
||||
33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = "<group>"; };
|
||||
33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = "<group>"; };
|
||||
33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = "<group>"; };
|
||||
5CCBBFA4C427193DF37044DA /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
5F844C09FCD307AF262BDB41 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; };
|
||||
86F5450344B9BE2273976B6B /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
|
||||
8AA200BBA16E846EF02EA220 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
8B9DF8C58AFF7B81844064AC /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; };
|
||||
BD1BC5FF85A673AC7738F6DF /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
|
||||
C20AF64D20A0D998576B7391 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -85,6 +95,7 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
868F63F0C0F04AA311C4DEB8 /* Pods_RunnerTests.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -92,6 +103,7 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
16081A8E490D05A0EB9DCB3B /* Pods_Runner.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -125,6 +137,7 @@
|
||||
331C80D6294CF71000263BE5 /* RunnerTests */,
|
||||
33CC10EE2044A3C60003C045 /* Products */,
|
||||
D73912EC22F37F3D000D13A0 /* Frameworks */,
|
||||
855AC529D1809DF3B555D40F /* Pods */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -172,9 +185,25 @@
|
||||
path = Runner;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
855AC529D1809DF3B555D40F /* Pods */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
5F844C09FCD307AF262BDB41 /* Pods-Runner.debug.xcconfig */,
|
||||
2FD6D31AB2013B75CB16C72F /* Pods-Runner.release.xcconfig */,
|
||||
BD1BC5FF85A673AC7738F6DF /* Pods-Runner.profile.xcconfig */,
|
||||
5CCBBFA4C427193DF37044DA /* Pods-RunnerTests.debug.xcconfig */,
|
||||
86F5450344B9BE2273976B6B /* Pods-RunnerTests.release.xcconfig */,
|
||||
8B9DF8C58AFF7B81844064AC /* Pods-RunnerTests.profile.xcconfig */,
|
||||
);
|
||||
name = Pods;
|
||||
path = Pods;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D73912EC22F37F3D000D13A0 /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
C20AF64D20A0D998576B7391 /* Pods_Runner.framework */,
|
||||
8AA200BBA16E846EF02EA220 /* Pods_RunnerTests.framework */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
@@ -186,6 +215,7 @@
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
|
||||
buildPhases = (
|
||||
289049F07BF5608C0DEEA906 /* [CP] Check Pods Manifest.lock */,
|
||||
331C80D1294CF70F00263BE5 /* Sources */,
|
||||
331C80D2294CF70F00263BE5 /* Frameworks */,
|
||||
331C80D3294CF70F00263BE5 /* Resources */,
|
||||
@@ -204,11 +234,13 @@
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||
buildPhases = (
|
||||
772657A59DCC9D8E14DB7114 /* [CP] Check Pods Manifest.lock */,
|
||||
33CC10E92044A3C60003C045 /* Sources */,
|
||||
33CC10EA2044A3C60003C045 /* Frameworks */,
|
||||
33CC10EB2044A3C60003C045 /* Resources */,
|
||||
33CC110E2044A8840003C045 /* Bundle Framework */,
|
||||
3399D490228B24CF009A79C7 /* ShellScript */,
|
||||
C8ACC0A85F9FDC2B7315ABC5 /* [CP] Embed Pods Frameworks */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@@ -291,6 +323,28 @@
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXShellScriptBuildPhase section */
|
||||
289049F07BF5608C0DEEA906 /* [CP] Check Pods Manifest.lock */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
|
||||
"${PODS_ROOT}/Manifest.lock",
|
||||
);
|
||||
name = "[CP] Check Pods Manifest.lock";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
"$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
3399D490228B24CF009A79C7 /* ShellScript */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
@@ -329,6 +383,45 @@
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire";
|
||||
};
|
||||
772657A59DCC9D8E14DB7114 /* [CP] Check Pods Manifest.lock */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
|
||||
"${PODS_ROOT}/Manifest.lock",
|
||||
);
|
||||
name = "[CP] Check Pods Manifest.lock";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
C8ACC0A85F9FDC2B7315ABC5 /* [CP] Embed Pods Frameworks */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
name = "[CP] Embed Pods Frameworks";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
@@ -380,6 +473,7 @@
|
||||
/* Begin XCBuildConfiguration section */
|
||||
331C80DB294CF71000263BE5 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 5CCBBFA4C427193DF37044DA /* Pods-RunnerTests.debug.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
@@ -394,6 +488,7 @@
|
||||
};
|
||||
331C80DC294CF71000263BE5 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 86F5450344B9BE2273976B6B /* Pods-RunnerTests.release.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
@@ -408,6 +503,7 @@
|
||||
};
|
||||
331C80DD294CF71000263BE5 /* Profile */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 8B9DF8C58AFF7B81844064AC /* Pods-RunnerTests.profile.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
|
||||
@@ -4,4 +4,7 @@
|
||||
<FileRef
|
||||
location = "group:Runner.xcodeproj">
|
||||
</FileRef>
|
||||
<FileRef
|
||||
location = "group:Pods/Pods.xcodeproj">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
|
||||
+790
-16
@@ -1,6 +1,14 @@
|
||||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: args
|
||||
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.7.0"
|
||||
async:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -9,38 +17,110 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.13.0"
|
||||
audioplayers:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: audioplayers
|
||||
sha256: "5441fa0ceb8807a5ad701199806510e56afde2b4913d9d17c2f19f2902cf0ae4"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.5.1"
|
||||
audioplayers_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: audioplayers_android
|
||||
sha256: "60a6728277228413a85755bd3ffd6fab98f6555608923813ce383b190a360605"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.2.1"
|
||||
audioplayers_darwin:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: audioplayers_darwin
|
||||
sha256: "0811d6924904ca13f9ef90d19081e4a87f7297ddc19fc3d31f60af1aaafee333"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.0"
|
||||
audioplayers_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: audioplayers_linux
|
||||
sha256: f75bce1ce864170ef5e6a2c6a61cd3339e1a17ce11e99a25bae4474ea491d001
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.2.1"
|
||||
audioplayers_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: audioplayers_platform_interface
|
||||
sha256: "0e2f6a919ab56d0fec272e801abc07b26ae7f31980f912f24af4748763e5a656"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.1.1"
|
||||
audioplayers_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: audioplayers_web
|
||||
sha256: "1c0f17cec68455556775f1e50ca85c40c05c714a99c5eb1d2d57cc17ba5522d7"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.1.1"
|
||||
audioplayers_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: audioplayers_windows
|
||||
sha256: "4048797865105b26d47628e6abb49231ea5de84884160229251f37dfcbe52fd7"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.2.1"
|
||||
bonsoir:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: bonsoir
|
||||
sha256: d5b0cb2d38ac8b0057990e3046ef0e4552b63f6d636284c7e57ac2b257e2000a
|
||||
sha256: "15de9d734708ccce1484ea9bbc750e364af88cb30fe6392368dc0cf233ddee5e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0+1"
|
||||
version: "6.0.1"
|
||||
bonsoir_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: bonsoir_android
|
||||
sha256: "21c38f707df0755b5bedd05c5fd52cb5e9fa9f4a4efbcef94cb9669029c73d75"
|
||||
sha256: e19728f94a0d9813abf9e2edf644fede008e58ef539865a1be86ac5d8994154e
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
version: "6.0.1"
|
||||
bonsoir_darwin:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: bonsoir_darwin
|
||||
sha256: fe36cb2acec37c175213364a91ae1b2866a47e061f18b11322ac8e0ca39d5e61
|
||||
sha256: e242a03a019fd474be657715826cfc13e43d02c88e46ec5611a20b9d4f72854d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0+1"
|
||||
version: "6.0.1"
|
||||
bonsoir_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: bonsoir_linux
|
||||
sha256: "5f40f8fa6dc79245ddde38f4440bc0f49cd25ee6be04a4e56fe9fca0d2be7998"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.1"
|
||||
bonsoir_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: bonsoir_platform_interface
|
||||
sha256: "97081d861ff2e7b45edd9e17ae1b35530d5a7ec561e3377e00da2e0cfae500dc"
|
||||
sha256: "3fa0c46b30eb2a2f48be6fa53591a5c0425bf00520be761b61763e58b51814ff"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
version: "6.0.1"
|
||||
bonsoir_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: bonsoir_windows
|
||||
sha256: "34c54802baaa2f00e3c4ab7ea46888f2a829876753778e2f40e3f273c3382d34"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.1"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -73,6 +153,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
convert:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: convert
|
||||
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
cross_file:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cross_file
|
||||
sha256: "701dcfc06da0882883a2657c445103380e53e647060ad8d9dfb710c100996608"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.5+1"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -81,6 +177,38 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.7"
|
||||
dbus:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dbus
|
||||
sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.11"
|
||||
device_info_plus:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: device_info_plus
|
||||
sha256: a7fd703482b391a87d60b6061d04dfdeab07826b96f9abd8f5ed98068acc0074
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.1.2"
|
||||
device_info_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: device_info_plus_platform_interface
|
||||
sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.3"
|
||||
drift:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: drift
|
||||
sha256: "83290a32ae006a7535c5ecf300722cb77177250d9df4ee2becc5fa8a36095114"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.29.0"
|
||||
equatable:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -97,6 +225,62 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.3"
|
||||
ffi:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: ffi
|
||||
sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
file:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file
|
||||
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
file_picker:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_picker
|
||||
sha256: ab13ae8ef5580a411c458d6207b6774a6c237d77ac37011b13994879f68a8810
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.3.7"
|
||||
file_selector_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_linux
|
||||
sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.4"
|
||||
file_selector_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_macos
|
||||
sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.5"
|
||||
file_selector_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_platform_interface
|
||||
sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.7.0"
|
||||
file_selector_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_windows
|
||||
sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.3+5"
|
||||
fixnum:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -118,11 +302,152 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.0.0"
|
||||
flutter_local_notifications:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_local_notifications
|
||||
sha256: "674173fd3c9eda9d4c8528da2ce0ea69f161577495a9cc835a2a4ecd7eadeb35"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "17.2.4"
|
||||
flutter_local_notifications_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_local_notifications_linux
|
||||
sha256: c49bd06165cad9beeb79090b18cd1eb0296f4bf4b23b84426e37dd7c027fc3af
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.1"
|
||||
flutter_local_notifications_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_local_notifications_platform_interface
|
||||
sha256: "85f8d07fe708c1bdcf45037f2c0109753b26ae077e9d9e899d55971711a4ea66"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.2.0"
|
||||
flutter_plugin_android_lifecycle:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_plugin_android_lifecycle
|
||||
sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.33"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_web_plugins:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
gal:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: gal
|
||||
sha256: "969598f986789127fd407a750413249e1352116d4c2be66e81837ffeeaafdfee"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.2"
|
||||
google_mobile_ads:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: google_mobile_ads
|
||||
sha256: "0d4a3744b5e8ed1b8be6a1b452d309f811688855a497c6113fc4400f922db603"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.3.1"
|
||||
http:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http
|
||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.6.0"
|
||||
http_parser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http_parser
|
||||
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
image_picker:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image_picker
|
||||
sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
image_picker_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image_picker_android
|
||||
sha256: "5e9bf126c37c117cf8094215373c6d561117a3cfb50ebc5add1a61dc6e224677"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.8.13+10"
|
||||
image_picker_for_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image_picker_for_web
|
||||
sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.1"
|
||||
image_picker_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image_picker_ios
|
||||
sha256: "997d100ce1dda5b1ba4085194c5e36c9f8a1fb7987f6a36ab677a344cd2dc986"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.8.13+2"
|
||||
image_picker_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image_picker_linux
|
||||
sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.2"
|
||||
image_picker_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image_picker_macos
|
||||
sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.2+1"
|
||||
image_picker_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image_picker_platform_interface
|
||||
sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.11.1"
|
||||
image_picker_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image_picker_windows
|
||||
sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.2"
|
||||
json_annotation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: json_annotation
|
||||
sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.9.0"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -179,6 +504,62 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.16.0"
|
||||
mime:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: mime
|
||||
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
mobile_scanner:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: mobile_scanner
|
||||
sha256: d234581c090526676fd8fab4ada92f35c6746e3fb4f05a399665d75a399fb760
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.2.3"
|
||||
nearby_connections:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: nearby_connections
|
||||
sha256: "94d500bdb11f9a3db3b1cb2949ab438107e581f0142380efc17ecc8038e99369"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.3.0"
|
||||
network_info_plus:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: network_info_plus
|
||||
sha256: "5bd4b86e28fed5ed4e6ac7764133c031dfb7d3f46aa2a81b46f55038aa78ecc0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.0.3"
|
||||
network_info_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: network_info_plus_platform_interface
|
||||
sha256: "7e7496a8a9d8136859b8881affc613c4a21304afeb6c324bcefc4bd0aff6b94b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.2"
|
||||
nm:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: nm
|
||||
sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.5.0"
|
||||
objective_c:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: objective_c
|
||||
sha256: "1f81ed9e41909d44162d7ec8663b2c647c202317cc0b56d3d56f6a13146a0b64"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.1.0"
|
||||
path:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -187,6 +568,126 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
path_provider:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider
|
||||
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.5"
|
||||
path_provider_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_android
|
||||
sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.22"
|
||||
path_provider_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_foundation
|
||||
sha256: "6192e477f34018ef1ea790c56fffc7302e3bc3efede9e798b934c252c8c105ba"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.0"
|
||||
path_provider_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_linux
|
||||
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.1"
|
||||
path_provider_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_platform_interface
|
||||
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
path_provider_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_windows
|
||||
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.0"
|
||||
pedantic:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pedantic
|
||||
sha256: "67fc27ed9639506c856c840ccce7594d0bdcd91bc8d53d6e52359449a1d50602"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.11.1"
|
||||
permission_handler:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: permission_handler
|
||||
sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "11.4.0"
|
||||
permission_handler_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_android
|
||||
sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "12.1.0"
|
||||
permission_handler_apple:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_apple
|
||||
sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.4.7"
|
||||
permission_handler_html:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_html
|
||||
sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.3+5"
|
||||
permission_handler_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_platform_interface
|
||||
sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.3.0"
|
||||
permission_handler_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_windows
|
||||
sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.1"
|
||||
petitparser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: petitparser
|
||||
sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
platform:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: platform
|
||||
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.6"
|
||||
playwith_core:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -194,13 +695,6 @@ packages:
|
||||
relative: true
|
||||
source: path
|
||||
version: "0.0.1"
|
||||
playwith_game_quiz:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "../../packages/games/quiz"
|
||||
relative: true
|
||||
source: path
|
||||
version: "0.0.1"
|
||||
plugin_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -209,6 +703,86 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
pub_semver:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pub_semver
|
||||
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
qr:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: qr
|
||||
sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
qr_flutter:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: qr_flutter
|
||||
sha256: "5095f0fc6e3f71d08adef8feccc8cea4f12eec18a2e31c2e8d82cb6019f4b097"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.0"
|
||||
shared_preferences:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences
|
||||
sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.3"
|
||||
shared_preferences_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_android
|
||||
sha256: "46a46fd64659eff15f4638bbe19de43f9483f0e0bf024a9fb6b3582064bacc7b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.17"
|
||||
shared_preferences_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_foundation
|
||||
sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.6"
|
||||
shared_preferences_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_linux
|
||||
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
shared_preferences_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_platform_interface
|
||||
sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
shared_preferences_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_web
|
||||
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.3"
|
||||
shared_preferences_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_windows
|
||||
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
sky_engine:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
@@ -222,6 +796,46 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.10.1"
|
||||
speech_to_text:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: speech_to_text
|
||||
sha256: c07557664974afa061f221d0d4186935bea4220728ea9446702825e8b988db04
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.3.0"
|
||||
speech_to_text_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: speech_to_text_platform_interface
|
||||
sha256: a1935847704e41ee468aad83181ddd2423d0833abe55d769c59afca07adb5114
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.0"
|
||||
speech_to_text_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: speech_to_text_windows
|
||||
sha256: "2c9846d18253c7bbe059a276297ef9f27e8a2745dead32192525beb208195072"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0+beta.8"
|
||||
sqlite3:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqlite3
|
||||
sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.9.4"
|
||||
sqlite3_flutter_libs:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqlite3_flutter_libs
|
||||
sha256: "69c80d812ef2500202ebd22002cbfc1b6565e9ff56b2f971e757fac5d42294df"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.5.40"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -246,6 +860,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
synchronized:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: synchronized
|
||||
sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.4.0"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -262,6 +884,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.6"
|
||||
timezone:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: timezone
|
||||
sha256: "2236ec079a174ce07434e89fcd3fcda430025eb7692244139a9cf54fdcf1fc7d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.4"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -270,6 +900,70 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
url_launcher:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: url_launcher
|
||||
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.2"
|
||||
url_launcher_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_android
|
||||
sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.28"
|
||||
url_launcher_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_ios
|
||||
sha256: cfde38aa257dae62ffe79c87fab20165dfdf6988c1d31b58ebf59b9106062aad
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.6"
|
||||
url_launcher_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_linux
|
||||
sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.2"
|
||||
url_launcher_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_macos
|
||||
sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.5"
|
||||
url_launcher_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_platform_interface
|
||||
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.2"
|
||||
url_launcher_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_web
|
||||
sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
url_launcher_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_windows
|
||||
sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.5"
|
||||
uuid:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -294,6 +988,86 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "15.0.2"
|
||||
web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web
|
||||
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
webview_flutter:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: webview_flutter
|
||||
sha256: c3e4fe614b1c814950ad07186007eff2f2e5dd2935eba7b9a9a1af8e5885f1ba
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.13.0"
|
||||
webview_flutter_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: webview_flutter_android
|
||||
sha256: "3fcca88ee2ae568807ebd42deed235bb8dd8e62b3e4d5caff67daa6bce062cca"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.10.9"
|
||||
webview_flutter_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: webview_flutter_platform_interface
|
||||
sha256: "63d26ee3aca7256a83ccb576a50272edd7cfc80573a4305caa98985feb493ee0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.14.0"
|
||||
webview_flutter_wkwebview:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: webview_flutter_wkwebview
|
||||
sha256: a57b76a081bed3bf3a71a486bdf83642b00f1a7342043d50367cea68f338b1af
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.23.4"
|
||||
wifi_iot:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: wifi_iot
|
||||
sha256: "0861aed0c0afd6031b4337811d31cdd181c594a8a2c73e94826ea21d2cb4707b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.19+2"
|
||||
win32:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: win32
|
||||
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.15.0"
|
||||
win32_registry:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: win32_registry
|
||||
sha256: "21ec76dfc731550fd3e2ce7a33a9ea90b828fdf19a5c3bcf556fa992cfa99852"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.5"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: xdg_directories
|
||||
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
xml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: xml
|
||||
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.6.1"
|
||||
sdks:
|
||||
dart: ">=3.9.2 <4.0.0"
|
||||
flutter: ">=3.18.0-18.0.pre.54"
|
||||
flutter: ">=3.35.0"
|
||||
|
||||
+6
-37
@@ -33,8 +33,10 @@ dependencies:
|
||||
# 로컬 패키지 추가
|
||||
playwith_core:
|
||||
path: ../../packages/core
|
||||
playwith_game_quiz:
|
||||
path: ../../packages/games/quiz
|
||||
permission_handler: ^11.0.0
|
||||
qr_flutter: ^4.1.0
|
||||
mobile_scanner: ^5.1.0
|
||||
url_launcher: ^6.2.0 # [추가] 웹 브라우저 열기용
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
@@ -52,39 +54,6 @@ dev_dependencies:
|
||||
|
||||
# The following section is specific to Flutter packages.
|
||||
flutter:
|
||||
|
||||
# The following line ensures that the Material Icons font is
|
||||
# included with your application, so that you can use the icons in
|
||||
# the material Icons class.
|
||||
uses-material-design: true
|
||||
|
||||
# To add assets to your application, add an assets section, like this:
|
||||
# assets:
|
||||
# - images/a_dot_burr.jpeg
|
||||
# - images/a_dot_ham.jpeg
|
||||
|
||||
# An image asset can refer to one or more resolution-specific "variants", see
|
||||
# https://flutter.dev/to/resolution-aware-images
|
||||
|
||||
# For details regarding adding assets from package dependencies, see
|
||||
# https://flutter.dev/to/asset-from-package
|
||||
|
||||
# To add custom fonts to your application, add a fonts section here,
|
||||
# in this "flutter" section. Each entry in this list should have a
|
||||
# "family" key with the font family name, and a "fonts" key with a
|
||||
# list giving the asset and other descriptors for the font. For
|
||||
# example:
|
||||
# fonts:
|
||||
# - family: Schyler
|
||||
# fonts:
|
||||
# - asset: fonts/Schyler-Regular.ttf
|
||||
# - asset: fonts/Schyler-Italic.ttf
|
||||
# style: italic
|
||||
# - family: Trajan Pro
|
||||
# fonts:
|
||||
# - asset: fonts/TrajanPro.ttf
|
||||
# - asset: fonts/TrajanPro_Bold.ttf
|
||||
# weight: 700
|
||||
#
|
||||
# For details regarding fonts from package dependencies,
|
||||
# see https://flutter.dev/to/font-from-package
|
||||
assets:
|
||||
- assets/audio/
|
||||
@@ -6,6 +6,30 @@
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <audioplayers_windows/audioplayers_windows_plugin.h>
|
||||
#include <bonsoir_windows/bonsoir_windows_plugin_c_api.h>
|
||||
#include <file_selector_windows/file_selector_windows.h>
|
||||
#include <gal/gal_plugin_c_api.h>
|
||||
#include <permission_handler_windows/permission_handler_windows_plugin.h>
|
||||
#include <speech_to_text_windows/speech_to_text_windows.h>
|
||||
#include <sqlite3_flutter_libs/sqlite3_flutter_libs_plugin.h>
|
||||
#include <url_launcher_windows/url_launcher_windows.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
AudioplayersWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("AudioplayersWindowsPlugin"));
|
||||
BonsoirWindowsPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("BonsoirWindowsPluginCApi"));
|
||||
FileSelectorWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
||||
GalPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("GalPluginCApi"));
|
||||
PermissionHandlerWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
|
||||
SpeechToTextWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("SpeechToTextWindows"));
|
||||
Sqlite3FlutterLibsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("Sqlite3FlutterLibsPlugin"));
|
||||
UrlLauncherWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
||||
}
|
||||
|
||||
@@ -3,6 +3,14 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
audioplayers_windows
|
||||
bonsoir_windows
|
||||
file_selector_windows
|
||||
gal
|
||||
permission_handler_windows
|
||||
speech_to_text_windows
|
||||
sqlite3_flutter_libs
|
||||
url_launcher_windows
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
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';
|
||||
|
||||
// 기존 MediaItems 테이블
|
||||
class MediaItems extends Table {
|
||||
TextColumn get id => text()();
|
||||
TextColumn get senderId => text()();
|
||||
TextColumn get senderName => text()();
|
||||
TextColumn get type => text()();
|
||||
TextColumn get filePath => text()();
|
||||
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
|
||||
// [추가] 패킷 로그 테이블 (재전송용)
|
||||
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 {
|
||||
EphemeralDatabase(QueryExecutor e) : super(e);
|
||||
|
||||
@override
|
||||
int get schemaVersion => 2; // [수정] 버전 업 (기존 앱 삭제 후 설치 권장)
|
||||
|
||||
static Future<EphemeralDatabase> create(String roomName) async {
|
||||
final dbFolder = await getApplicationDocumentsDirectory();
|
||||
final file = File(p.join(dbFolder.path, 'room_$roomName.sqlite'));
|
||||
return EphemeralDatabase(NativeDatabase(file));
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,908 @@
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
|
||||
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, packetLogs];
|
||||
}
|
||||
|
||||
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()>;
|
||||
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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:playwith_core/playwith_core.dart';
|
||||
|
||||
class BalanceGame extends BaseGame {
|
||||
@override
|
||||
String get id => "balance_game";
|
||||
@override
|
||||
String get name => "밸런스 게임";
|
||||
@override
|
||||
String get description => "마음이 통하는지 확인해보세요!";
|
||||
|
||||
@override
|
||||
void onStart() {
|
||||
super.onStart();
|
||||
// Host가 첫 문제를 설정해서 전송
|
||||
if (NetworkManager().role == NetworkRole.host) {
|
||||
Future.delayed(const Duration(milliseconds: 500), () {
|
||||
final payload = {'type': 'NEXT_QUESTION', 'index': 0};
|
||||
onMessageReceived(NetworkManager().me.id, payload);
|
||||
NetworkManager().sendMessage(payload);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void onMessageReceived(String senderId, Map<String, dynamic> payload) {
|
||||
// UI에서 처리
|
||||
}
|
||||
|
||||
@override
|
||||
Widget buildHostView(BuildContext context) => BalanceGameScreen(gameInstance: this);
|
||||
@override
|
||||
Widget buildGuestView(BuildContext context) => BalanceGameScreen(gameInstance: this);
|
||||
}
|
||||
|
||||
class BalanceGameScreen extends StatefulWidget {
|
||||
final BalanceGame gameInstance;
|
||||
const BalanceGameScreen({super.key, required this.gameInstance});
|
||||
|
||||
@override
|
||||
State<BalanceGameScreen> createState() => _BalanceGameScreenState();
|
||||
}
|
||||
|
||||
class _BalanceGameScreenState extends State<BalanceGameScreen> {
|
||||
// 문제 데이터 (가벼운 커플용 질문)
|
||||
final List<Map<String, String>> questions = [
|
||||
{'A': '평생 라면만 먹기', 'B': '평생 탄산만 마시기'},
|
||||
{'A': '다시 태어나면\n원빈 얼굴', 'B': '다시 태어나면\n삼성 이재용 재력'},
|
||||
{'A': '1년 동안\n스킨십 금지', 'B': '1년 동안\n스마트폰 금지'},
|
||||
{'A': '애인이\n바람피우기', 'B': '애인이\n전재산 날리기'},
|
||||
{'A': '여름에\n에어컨 없이 살기', 'B': '겨울에\n보일러 없이 살기'},
|
||||
{'A': '매일 사랑해 듣기', 'B': '매일 10만원 받기'},
|
||||
{'A': '과거로 가기', 'B': '미래로 가기'},
|
||||
{'A': '평생 고기 못 먹기', 'B': '평생 밀가루 못 먹기'},
|
||||
];
|
||||
|
||||
int currentIndex = -1;
|
||||
String? myChoice; // 'A' or 'B'
|
||||
String? opponentChoice;
|
||||
bool isResultShown = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
NetworkManager().messageStream.listen(_handleMessage);
|
||||
}
|
||||
|
||||
void _handleMessage(Map<String, dynamic> payload) {
|
||||
if (!mounted) return;
|
||||
|
||||
if (payload['type'] == 'NEXT_QUESTION') {
|
||||
setState(() {
|
||||
currentIndex = payload['index'];
|
||||
myChoice = null;
|
||||
opponentChoice = null;
|
||||
isResultShown = false;
|
||||
});
|
||||
} else if (payload['type'] == 'SELECT') {
|
||||
if (payload['senderId'] != NetworkManager().me.id) {
|
||||
setState(() {
|
||||
opponentChoice = payload['choice'];
|
||||
_checkResult();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _onSelect(String choice) {
|
||||
if (myChoice != null) return; // 이미 선택함
|
||||
|
||||
setState(() {
|
||||
myChoice = choice;
|
||||
});
|
||||
|
||||
NetworkManager().sendMessage({
|
||||
'type': 'SELECT',
|
||||
'choice': choice,
|
||||
'senderId': NetworkManager().me.id
|
||||
});
|
||||
|
||||
_checkResult();
|
||||
}
|
||||
|
||||
void _checkResult() {
|
||||
if (myChoice != null && opponentChoice != null) {
|
||||
setState(() {
|
||||
isResultShown = true;
|
||||
});
|
||||
|
||||
// 3초 후 다음 문제 (Host만 전송)
|
||||
if (NetworkManager().role == NetworkRole.host) {
|
||||
Future.delayed(const Duration(seconds: 3), () {
|
||||
if (!mounted) return;
|
||||
if (currentIndex < questions.length - 1) {
|
||||
final payload = {'type': 'NEXT_QUESTION', 'index': currentIndex + 1};
|
||||
NetworkManager().sendMessage(payload);
|
||||
// 나 자신에게도 처리 (핸들러 호출 없이 직접 상태 변경해도 되지만 통일성을 위해)
|
||||
// 여기선 직접 호출 대신 메시지 수신 로직이 처리하도록 둠
|
||||
// (NetworkManager가 host일 때 loopback 안 하므로 직접 호출 필요)
|
||||
// 하지만 NetworkManager 수정본에서는 host도 onMessageReceived 호출하므로 패스
|
||||
// 만약 lobby_screen 등에서 분기처리된 경우 broadcastState 같은게 필요.
|
||||
// 간단히:
|
||||
NetworkManager().sendMessage(payload);
|
||||
// Host 자신은 리스너가 안돌수 있으므로 직접 처리
|
||||
_handleMessage(payload);
|
||||
} else {
|
||||
// 게임 끝
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("모든 질문이 끝났습니다!")));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (currentIndex == -1) return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||
|
||||
final q = questions[currentIndex];
|
||||
final bool isMatched = (myChoice == opponentChoice);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text("밸런스 게임 ${currentIndex + 1}/${questions.length}")),
|
||||
body: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
// 선택지 A
|
||||
Expanded(
|
||||
child: _buildOptionButton('A', q['A']!, Colors.redAccent),
|
||||
),
|
||||
// 선택지 B
|
||||
Expanded(
|
||||
child: _buildOptionButton('B', q['B']!, Colors.blueAccent),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isResultShown)
|
||||
Container(
|
||||
height: 100,
|
||||
width: double.infinity,
|
||||
color: isMatched ? Colors.pinkAccent : Colors.grey,
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
isMatched ? "찌찌뽕! ❤ (통했군요!)" : "동상이몽... 💔 (다르네요)",
|
||||
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Colors.white),
|
||||
),
|
||||
)
|
||||
else
|
||||
Container(
|
||||
height: 100,
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
myChoice == null ? "선택해주세요!" : (opponentChoice == null ? "상대방 기다리는 중..." : ""),
|
||||
style: const TextStyle(fontSize: 18, color: Colors.grey),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildOptionButton(String key, String text, Color color) {
|
||||
bool isSelected = myChoice == key;
|
||||
bool showOpponentSelection = isResultShown && opponentChoice == key;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => _onSelect(key),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? color : color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: isSelected ? color : Colors.transparent,
|
||||
width: 4
|
||||
),
|
||||
boxShadow: isSelected ? [BoxShadow(color: color.withOpacity(0.4), blurRadius: 10)] : [],
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Center(
|
||||
child: Text(
|
||||
text,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isSelected ? Colors.white : Colors.black87
|
||||
),
|
||||
),
|
||||
),
|
||||
if (showOpponentSelection)
|
||||
Positioned(
|
||||
top: 10, right: 10,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(color: Colors.black87, borderRadius: BorderRadius.circular(8)),
|
||||
child: const Text("상대방 PICK", style: TextStyle(color: Colors.white, fontSize: 12)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
/// 게임이 시작될 때 초기화 로직 (변수 초기화 등)
|
||||
void onStart();
|
||||
// 네트워크 스트림 자동 구독
|
||||
_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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 네트워크 메시지 수신 처리
|
||||
/// [senderId]: 보낸 사람 ID
|
||||
/// [payload]: 수신된 데이터 (JSON)
|
||||
/// [Core] 게임 종료 시 자동 호출 (super.onDispose() 필수 호출)
|
||||
@mustCallSuper
|
||||
void onDispose() {
|
||||
print("[$name] Game Engine Disposed");
|
||||
_internalSubscription?.cancel();
|
||||
}
|
||||
|
||||
/// [Abstract] 자식 클래스가 구현해야 할 데이터 처리 메서드
|
||||
/// Core가 메시지를 받으면 이 함수를 실행시켜 줍니다.
|
||||
void onMessageReceived(String senderId, Map<String, dynamic> payload);
|
||||
|
||||
/// 게임 종료 및 메모리 정리
|
||||
void onDispose();
|
||||
// UI 빌더
|
||||
Widget buildHostView(BuildContext context);
|
||||
Widget buildGuestView(BuildContext context);
|
||||
}
|
||||
@@ -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("나가기")),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:playwith_core/playwith_core.dart';
|
||||
|
||||
class JanggiGame extends BaseGame {
|
||||
@override
|
||||
String get id => "janggi";
|
||||
@override
|
||||
String get name => "장기";
|
||||
@override
|
||||
String get description => "초한지의 결전! 장군!";
|
||||
|
||||
@override
|
||||
void onStart() {
|
||||
super.onStart();
|
||||
}
|
||||
|
||||
// [수정] 필수 메서드 구현 추가
|
||||
@override
|
||||
void onMessageReceived(String senderId, Map<String, dynamic> payload) {
|
||||
// 게임 로직은 JanggiScreen 내부에서 NetworkManager 스트림을 통해 처리합니다.
|
||||
}
|
||||
|
||||
@override
|
||||
Widget buildHostView(BuildContext context) => JanggiScreen(isHan: true, gameInstance: this);
|
||||
@override
|
||||
Widget buildGuestView(BuildContext context) => JanggiScreen(isHan: false, gameInstance: this);
|
||||
}
|
||||
|
||||
// 기물 타입
|
||||
enum PieceType { king, guard, horse, elephant, chariot, cannon, soldier }
|
||||
// 진영 (한: Red, 초: Green/Blue)
|
||||
enum Team { han, cho }
|
||||
|
||||
class Piece {
|
||||
final PieceType type;
|
||||
final Team team;
|
||||
Piece(this.type, this.team);
|
||||
|
||||
String get label {
|
||||
if (team == Team.han) {
|
||||
switch (type) {
|
||||
case PieceType.king: return '漢'; // 궁(장)
|
||||
case PieceType.guard: return '士'; // 사
|
||||
case PieceType.horse: return '馬'; // 마
|
||||
case PieceType.elephant: return '象'; // 상
|
||||
case PieceType.chariot: return '車'; // 차
|
||||
case PieceType.cannon: return '包'; // 포
|
||||
case PieceType.soldier: return '兵'; // 병
|
||||
}
|
||||
} else {
|
||||
switch (type) {
|
||||
case PieceType.king: return '楚'; // 궁(장)
|
||||
case PieceType.guard: return '士';
|
||||
case PieceType.horse: return '馬';
|
||||
case PieceType.elephant: return '象';
|
||||
case PieceType.chariot: return '車';
|
||||
case PieceType.cannon: return '包';
|
||||
case PieceType.soldier: return '卒'; // 졸
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class JanggiScreen extends StatefulWidget {
|
||||
final bool isHan; // 방장이 한(Red), 게스트가 초(Green)
|
||||
final JanggiGame gameInstance;
|
||||
|
||||
const JanggiScreen({super.key, required this.isHan, required this.gameInstance});
|
||||
|
||||
@override
|
||||
State<JanggiScreen> createState() => _JanggiScreenState();
|
||||
}
|
||||
|
||||
class _JanggiScreenState extends State<JanggiScreen> {
|
||||
// 10행 9열
|
||||
final List<List<Piece?>> board = List.generate(10, (_) => List.filled(9, null));
|
||||
Team currentTurn = Team.han; // 한나라 선
|
||||
|
||||
// 선택된 기물 좌표
|
||||
int? selectedX;
|
||||
int? selectedY;
|
||||
List<Point> validMoves = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initBoard();
|
||||
NetworkManager().messageStream.listen(_handleMessage);
|
||||
}
|
||||
|
||||
void _initBoard() {
|
||||
// 초기 배치 (상마상마 타입 기준)
|
||||
_placeRow(0, Team.cho, [PieceType.chariot, PieceType.elephant, PieceType.horse, PieceType.guard, null, PieceType.guard, PieceType.elephant, PieceType.horse, PieceType.chariot]);
|
||||
board[1][4] = Piece(PieceType.king, Team.cho);
|
||||
board[2][1] = Piece(PieceType.cannon, Team.cho); board[2][7] = Piece(PieceType.cannon, Team.cho);
|
||||
_placeRow(3, Team.cho, [PieceType.soldier, null, PieceType.soldier, null, PieceType.soldier, null, PieceType.soldier, null, PieceType.soldier]);
|
||||
|
||||
_placeRow(9, Team.han, [PieceType.chariot, PieceType.elephant, PieceType.horse, PieceType.guard, null, PieceType.guard, PieceType.elephant, PieceType.horse, PieceType.chariot]);
|
||||
board[8][4] = Piece(PieceType.king, Team.han);
|
||||
board[7][1] = Piece(PieceType.cannon, Team.han); board[7][7] = Piece(PieceType.cannon, Team.han);
|
||||
_placeRow(6, Team.han, [PieceType.soldier, null, PieceType.soldier, null, PieceType.soldier, null, PieceType.soldier, null, PieceType.soldier]);
|
||||
}
|
||||
|
||||
void _placeRow(int row, Team team, List<PieceType?> types) {
|
||||
for (int i = 0; i < 9; i++) {
|
||||
if (types[i] != null) board[row][i] = Piece(types[i]!, team);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleMessage(Map<String, dynamic> payload) {
|
||||
if (!mounted) return;
|
||||
if (payload['type'] == 'MOVE') {
|
||||
_executeMove(payload['fx'], payload['fy'], payload['tx'], payload['ty']);
|
||||
} else if (payload['type'] == 'GAME_OVER') {
|
||||
_showEndDialog(payload['winner']);
|
||||
}
|
||||
}
|
||||
|
||||
void _onTapCell(int x, int y) {
|
||||
// 내 턴 확인
|
||||
if (currentTurn != (widget.isHan ? Team.han : Team.cho)) return;
|
||||
|
||||
// 1. 기물 선택
|
||||
if (board[y][x]?.team == (widget.isHan ? Team.han : Team.cho)) {
|
||||
setState(() {
|
||||
selectedX = x;
|
||||
selectedY = y;
|
||||
// 이동 가능 경로 계산
|
||||
validMoves = _calculateValidMoves(x, y, board[y][x]!);
|
||||
});
|
||||
}
|
||||
// 2. 이동
|
||||
else if (selectedX != null) {
|
||||
// 유효한 이동인지 확인
|
||||
bool isValid = validMoves.any((p) => p.x == x && p.y == y);
|
||||
if (isValid) {
|
||||
_executeMove(selectedX!, selectedY!, x, y);
|
||||
|
||||
NetworkManager().sendMessage({
|
||||
'type': 'MOVE',
|
||||
'fx': selectedX, 'fy': selectedY,
|
||||
'tx': x, 'ty': y
|
||||
});
|
||||
} else {
|
||||
// 선택 해제
|
||||
setState(() { selectedX = null; validMoves = []; });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _executeMove(int fx, int fy, int tx, int ty) {
|
||||
setState(() {
|
||||
Piece? target = board[ty][tx];
|
||||
board[ty][tx] = board[fy][fx];
|
||||
board[fy][fx] = null;
|
||||
|
||||
selectedX = null;
|
||||
validMoves = [];
|
||||
currentTurn = (currentTurn == Team.han) ? Team.cho : Team.han;
|
||||
|
||||
if (target?.type == PieceType.king) {
|
||||
_showEndDialog(currentTurn == Team.han ? "Cho" : "Han");
|
||||
}
|
||||
});
|
||||
SoundManager().playSfx(SoundKey.click);
|
||||
}
|
||||
|
||||
List<Point> _calculateValidMoves(int x, int y, Piece p) {
|
||||
List<Point> moves = [];
|
||||
|
||||
void addIfValid(int nx, int ny) {
|
||||
if (nx < 0 || nx >= 9 || ny < 0 || ny >= 10) return;
|
||||
if (board[ny][nx]?.team == p.team) return; // 같은 편 불가
|
||||
moves.add(Point(nx, ny));
|
||||
}
|
||||
|
||||
// 차(車): 직선 쭉
|
||||
if (p.type == PieceType.chariot) {
|
||||
_addLinearMoves(x, y, moves);
|
||||
}
|
||||
// 졸/병: 앞, 옆
|
||||
else if (p.type == PieceType.soldier) {
|
||||
int dy = (p.team == Team.cho) ? 1 : -1; // 초는 아래로, 한은 위로
|
||||
addIfValid(x, y + dy);
|
||||
addIfValid(x - 1, y);
|
||||
addIfValid(x + 1, y);
|
||||
}
|
||||
// 마(馬): 날일자 (멱 체크 필요)
|
||||
else if (p.type == PieceType.horse) {
|
||||
// [수정] Dart 문법에 맞게 List<int> 사용
|
||||
final List<int> listX = [1, 2, 2, 1, -1, -2, -2, -1];
|
||||
final List<int> listY = [-2, -1, 1, 2, 2, 1, -1, -2];
|
||||
|
||||
for(int i=0; i<8; i++) {
|
||||
// 멱 체크 (가는 길 중간)
|
||||
int mx = x + (listX[i] ~/ 2); // 대략적 중간점
|
||||
int my = y + (listY[i] ~/ 2);
|
||||
if (mx >=0 && mx <9 && my >=0 && my <10 && board[my][mx] == null) {
|
||||
addIfValid(x + listX[i], y + listY[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 궁/사: 궁성 내에서만
|
||||
else if (p.type == PieceType.king || p.type == PieceType.guard) {
|
||||
for (int dy = -1; dy <= 1; dy++) {
|
||||
for (int dx = -1; dx <= 1; dx++) {
|
||||
if (dx == 0 && dy == 0) continue;
|
||||
int nx = x + dx; int ny = y + dy;
|
||||
// 궁성 범위 체크
|
||||
bool inPalace = (nx >= 3 && nx <= 5) &&
|
||||
((p.team == Team.cho) ? (ny >= 0 && ny <= 2) : (ny >= 7 && ny <= 9));
|
||||
if (inPalace) addIfValid(nx, ny);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return moves;
|
||||
}
|
||||
|
||||
void _addLinearMoves(int x, int y, List<Point> moves) {
|
||||
// [수정] Dart 문법에 맞게 List<int> 사용
|
||||
final List<int> dx = [1, -1, 0, 0];
|
||||
final List<int> dy = [0, 0, 1, -1];
|
||||
|
||||
for(int i=0; i<4; i++) {
|
||||
for(int k=1; k<10; k++) {
|
||||
int nx = x + dx[i]*k;
|
||||
int ny = y + dy[i]*k;
|
||||
if (nx < 0 || nx >= 9 || ny < 0 || ny >= 10) break;
|
||||
if (board[ny][nx] != null) {
|
||||
if (board[ny][nx]!.team != board[y][x]!.team) moves.add(Point(nx, ny));
|
||||
break; // 막힘
|
||||
}
|
||||
moves.add(Point(nx, ny));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showEndDialog(String msg) {
|
||||
showDialog(context: context, builder: (_) => AlertDialog(title: const Text("게임 종료"), content: Text(msg)));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bool myTurn = currentTurn == (widget.isHan ? Team.han : Team.cho);
|
||||
// 내가 초나라(Green)라면 보드를 뒤집어서 보여줌 (아래가 내 진영이 되도록)
|
||||
final bool flipBoard = !widget.isHan;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text("장기 - ${widget.isHan ? '한(漢, Red)' : '초(楚, Green)'}"),
|
||||
backgroundColor: widget.isHan ? Colors.red[100] : Colors.green[100],
|
||||
),
|
||||
backgroundColor: const Color(0xFFE6B45C),
|
||||
body: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
double cellW = constraints.maxWidth / 9;
|
||||
double cellH = cellW;
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
// 격자
|
||||
CustomPaint(size: Size(constraints.maxWidth, cellH * 10), painter: JanggiGridPainter()),
|
||||
|
||||
// 기물
|
||||
...List.generate(90, (index) {
|
||||
int x = index % 9;
|
||||
int y = index ~/ 9;
|
||||
|
||||
// 화면 표시 좌표 (뒤집기 고려)
|
||||
int displayX = flipBoard ? (8 - x) : x;
|
||||
int displayY = flipBoard ? (9 - y) : y;
|
||||
|
||||
Piece? p = board[y][x];
|
||||
bool isSelected = (x == selectedX && y == selectedY);
|
||||
bool isValid = validMoves.any((pt) => pt.x == x && pt.y == y);
|
||||
|
||||
return Positioned(
|
||||
left: displayX * cellW,
|
||||
top: displayY * cellH,
|
||||
width: cellW,
|
||||
height: cellH,
|
||||
child: GestureDetector(
|
||||
onTap: () => _onTapCell(x, y),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? Colors.blue.withOpacity(0.3) : (isValid ? Colors.green.withOpacity(0.3) : null),
|
||||
border: isSelected ? Border.all(color: Colors.blue, width: 2) : null,
|
||||
),
|
||||
child: p == null
|
||||
? (isValid ? const Icon(Icons.circle, size: 10, color: Colors.green) : null)
|
||||
: _buildPieceWidget(p, cellW),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPieceWidget(Piece p, double size) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.all(2),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.orange[100],
|
||||
border: Border.all(color: p.team == Team.han ? Colors.red : Colors.green[800]!, width: 2),
|
||||
boxShadow: const [BoxShadow(blurRadius: 2, offset: Offset(1,1))]
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
p.label,
|
||||
style: TextStyle(
|
||||
fontSize: size * (p.type == PieceType.king ? 0.5 : 0.4),
|
||||
fontWeight: FontWeight.bold,
|
||||
color: p.team == Team.han ? Colors.red : Colors.green[800],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class Point { final int x, y; Point(this.x, this.y); }
|
||||
|
||||
class JanggiGridPainter extends CustomPainter {
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()..color = Colors.black..strokeWidth = 1;
|
||||
double cw = size.width / 9;
|
||||
double ch = cw;
|
||||
|
||||
// 선 그리기 (가운데 중심)
|
||||
for(int i=0; i<10; i++) {
|
||||
canvas.drawLine(Offset(cw/2, ch/2 + i*ch), Offset(size.width - cw/2, ch/2 + i*ch), paint);
|
||||
}
|
||||
for(int i=0; i<9; i++) {
|
||||
canvas.drawLine(Offset(cw/2 + i*cw, ch/2), Offset(cw/2 + i*cw, size.height - ch/2 + (ch-cw)*0), paint);
|
||||
}
|
||||
// 궁성 대각선
|
||||
canvas.drawLine(Offset(cw/2 + 3*cw, ch/2), Offset(cw/2 + 5*cw, ch/2 + 2*ch), paint);
|
||||
canvas.drawLine(Offset(cw/2 + 5*cw, ch/2), Offset(cw/2 + 3*cw, ch/2 + 2*ch), paint);
|
||||
|
||||
canvas.drawLine(Offset(cw/2 + 3*cw, ch/2 + 7*ch), Offset(cw/2 + 5*cw, ch/2 + 9*ch), paint);
|
||||
canvas.drawLine(Offset(cw/2 + 5*cw, ch/2 + 7*ch), Offset(cw/2 + 3*cw, ch/2 + 9*ch), paint);
|
||||
}
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:playwith_core/playwith_core.dart';
|
||||
|
||||
class MemoryGame extends BaseGame {
|
||||
@override
|
||||
String get id => "memory_battle";
|
||||
@override
|
||||
String get name => "그림 찾기";
|
||||
@override
|
||||
String get description => "기억력의 한판 승부!";
|
||||
|
||||
// 0: Red(Host), 1: Blue(Guest)
|
||||
int? _myTeam;
|
||||
|
||||
@override
|
||||
void onStart() {
|
||||
super.onStart();
|
||||
_myTeam = NetworkManager().role == NetworkRole.host ? 0 : 1;
|
||||
|
||||
// Host가 카드 섞어서 전송
|
||||
if (NetworkManager().role == NetworkRole.host) {
|
||||
final int seed = Random().nextInt(1000000);
|
||||
final payload = {'type': 'GAME_INIT', 'seed': seed};
|
||||
|
||||
// 약간의 딜레이 후 전송 (접속 안정화)
|
||||
Future.delayed(const Duration(milliseconds: 500), () {
|
||||
onMessageReceived(NetworkManager().me.id, payload);
|
||||
NetworkManager().sendMessage(payload);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void onMessageReceived(String senderId, Map<String, dynamic> payload) {
|
||||
// BaseGame 핸들러는 비워둠 (UI에서 Stream으로 처리)
|
||||
}
|
||||
|
||||
@override
|
||||
Widget buildHostView(BuildContext context) => MemoryGameScreen(myTeam: 0, gameInstance: this);
|
||||
@override
|
||||
Widget buildGuestView(BuildContext context) => MemoryGameScreen(myTeam: 1, gameInstance: this);
|
||||
}
|
||||
|
||||
class MemoryGameScreen extends StatefulWidget {
|
||||
final int myTeam;
|
||||
final MemoryGame gameInstance;
|
||||
|
||||
const MemoryGameScreen({super.key, required this.myTeam, required this.gameInstance});
|
||||
|
||||
@override
|
||||
State<MemoryGameScreen> createState() => _MemoryGameScreenState();
|
||||
}
|
||||
|
||||
class _MemoryGameScreenState extends State<MemoryGameScreen> {
|
||||
// 6 x 5 = 30장 (15쌍)
|
||||
static const int rows = 6;
|
||||
static const int cols = 5;
|
||||
|
||||
// 아이콘 목록 (15개)
|
||||
final List<IconData> icons = [
|
||||
Icons.ac_unit, Icons.access_alarm, Icons.accessibility, Icons.account_balance, Icons.adb,
|
||||
Icons.add_shopping_cart, Icons.airplanemode_active, Icons.anchor, Icons.android, Icons.apartment,
|
||||
Icons.apple, Icons.attach_money, Icons.audiotrack, Icons.auto_awesome, Icons.bakery_dining,
|
||||
];
|
||||
|
||||
List<int> cards = []; // 카드 ID (0~14)
|
||||
List<bool> isRevealed = []; // 현재 뒤집혀 있는지
|
||||
List<bool> isMatched = []; // 짝을 맞춰서 사라졌는지
|
||||
|
||||
int currentTurn = 0; // 0: Red, 1: Blue
|
||||
List<int> score = [0, 0]; // [Red점수, Blue점수]
|
||||
|
||||
List<int> selectedIndices = []; // 현재 선택한 카드 인덱스 (최대 2개)
|
||||
bool isProcessing = false; // 애니메이션 중 터치 방지
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 초기 상태 (로딩 중)
|
||||
cards = List.filled(rows * cols, -1);
|
||||
isRevealed = List.filled(rows * cols, false);
|
||||
isMatched = List.filled(rows * cols, false);
|
||||
|
||||
NetworkManager().messageStream.listen(_handleMessage);
|
||||
}
|
||||
|
||||
void _handleMessage(Map<String, dynamic> payload) {
|
||||
if (!mounted) return;
|
||||
|
||||
if (payload['type'] == 'GAME_INIT') {
|
||||
_initGame(payload['seed']);
|
||||
}
|
||||
else if (payload['type'] == 'FLIP') {
|
||||
final int index = payload['index'];
|
||||
_flipCard(index);
|
||||
}
|
||||
else if (payload['type'] == 'RESULT') {
|
||||
final bool match = payload['match'];
|
||||
final int idx1 = payload['idx1'];
|
||||
final int idx2 = payload['idx2'];
|
||||
final int scorer = payload['scorer'];
|
||||
|
||||
_handleResult(match, idx1, idx2, scorer);
|
||||
}
|
||||
else if (payload['type'] == 'GAME_OVER') {
|
||||
_showGameOverDialog(payload['winnerTeam']);
|
||||
}
|
||||
}
|
||||
|
||||
void _initGame(int seed) {
|
||||
final random = Random(seed);
|
||||
List<int> deck = [];
|
||||
for (int i = 0; i < 15; i++) {
|
||||
deck.add(i);
|
||||
deck.add(i); // 2장씩
|
||||
}
|
||||
deck.shuffle(random);
|
||||
|
||||
setState(() {
|
||||
cards = deck;
|
||||
isRevealed = List.filled(rows * cols, false);
|
||||
isMatched = List.filled(rows * cols, false);
|
||||
currentTurn = 0;
|
||||
score = [0, 0];
|
||||
selectedIndices.clear();
|
||||
isProcessing = false;
|
||||
});
|
||||
}
|
||||
|
||||
void _onCardTap(int index) {
|
||||
if (cards[0] == -1) return; // 로딩 전
|
||||
if (currentTurn != widget.myTeam) return; // 내 턴 아님
|
||||
if (isProcessing) return; // 처리 중
|
||||
if (isMatched[index] || isRevealed[index]) return; // 이미 맞췄거나 뒤집힌 카드
|
||||
|
||||
// 카드 뒤집기 전송
|
||||
NetworkManager().sendMessage({'type': 'FLIP', 'index': index});
|
||||
|
||||
// 내 화면 즉시 반영 (반응성 향상)
|
||||
_flipCard(index);
|
||||
}
|
||||
|
||||
void _flipCard(int index) {
|
||||
setState(() {
|
||||
isRevealed[index] = true;
|
||||
selectedIndices.add(index);
|
||||
});
|
||||
SoundManager().playSfx(SoundKey.click);
|
||||
|
||||
// 2장을 뒤집었을 때 (Host가 판정)
|
||||
if (selectedIndices.length == 2) {
|
||||
// Host만 판정 로직 수행
|
||||
if (NetworkManager().role == NetworkRole.host) {
|
||||
final int idx1 = selectedIndices[0];
|
||||
final int idx2 = selectedIndices[1];
|
||||
final bool isMatch = cards[idx1] == cards[idx2];
|
||||
|
||||
// 1초 딜레이 후 결과 전송 (보여줄 시간 확보)
|
||||
Future.delayed(const Duration(milliseconds: 800), () {
|
||||
final resultPayload = {
|
||||
'type': 'RESULT',
|
||||
'match': isMatch,
|
||||
'idx1': idx1,
|
||||
'idx2': idx2,
|
||||
'scorer': currentTurn // 현재 턴인 사람이 점수 획득 시도
|
||||
};
|
||||
NetworkManager().sendMessage(resultPayload);
|
||||
_handleMessage(resultPayload); // 나 자신도 처리
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _handleResult(bool match, int idx1, int idx2, int scorer) {
|
||||
setState(() {
|
||||
selectedIndices.clear();
|
||||
|
||||
if (match) {
|
||||
// 매치 성공
|
||||
isMatched[idx1] = true;
|
||||
isMatched[idx2] = true;
|
||||
score[scorer]++;
|
||||
SoundManager().playSfx(SoundKey.correct);
|
||||
|
||||
// 맞춘 사람은 턴 유지 (한 번 더!)
|
||||
// 턴 변경 없음
|
||||
|
||||
// 게임 종료 체크
|
||||
if (score[0] + score[1] == 15) {
|
||||
int winner = score[0] > score[1] ? 0 : (score[0] < score[1] ? 1 : -1); // -1은 무승부
|
||||
Future.delayed(const Duration(milliseconds: 500), () {
|
||||
NetworkManager().sendMessage({'type': 'GAME_OVER', 'winnerTeam': winner});
|
||||
_showGameOverDialog(winner);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// 매치 실패 -> 다시 뒤집기
|
||||
isRevealed[idx1] = false;
|
||||
isRevealed[idx2] = false;
|
||||
|
||||
// 턴 넘기기
|
||||
currentTurn = 1 - scorer;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _showGameOverDialog(int winnerTeam) {
|
||||
String msg;
|
||||
if (winnerTeam == -1) msg = "무승부입니다!";
|
||||
else if (winnerTeam == widget.myTeam) msg = "승리했습니다! 🎉";
|
||||
else 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("나가기"),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (cards.isEmpty || cards[0] == -1) {
|
||||
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||
}
|
||||
|
||||
final bool myTurn = currentTurn == widget.myTeam;
|
||||
final Color teamColor = widget.myTeam == 0 ? Colors.redAccent : Colors.blueAccent;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(myTurn ? "나의 턴!" : "상대방 턴..."),
|
||||
backgroundColor: myTurn ? teamColor : Colors.grey,
|
||||
elevation: 0,
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// 점수판
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 30),
|
||||
color: Colors.grey[200],
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
_buildScoreBox("나", score[widget.myTeam], teamColor, myTurn),
|
||||
const Text("VS", style: TextStyle(fontWeight: FontWeight.bold, color: Colors.grey)),
|
||||
_buildScoreBox("상대", score[1 - widget.myTeam], Colors.grey, !myTurn),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 10),
|
||||
|
||||
// 카드 그리드
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(10.0),
|
||||
child: GridView.builder(
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: cols,
|
||||
crossAxisSpacing: 8,
|
||||
mainAxisSpacing: 8,
|
||||
childAspectRatio: 0.8,
|
||||
),
|
||||
itemCount: rows * cols,
|
||||
itemBuilder: (context, index) {
|
||||
return _buildCard(index);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildScoreBox(String label, int score, Color color, bool isActive) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: isActive ? color : Colors.white,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: color, width: 2),
|
||||
boxShadow: isActive ? [BoxShadow(color: color.withOpacity(0.4), blurRadius: 8)] : [],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(label, style: TextStyle(color: isActive ? Colors.white : color, fontWeight: FontWeight.bold)),
|
||||
Text("$score", style: TextStyle(color: isActive ? Colors.white : color, fontSize: 24, fontWeight: FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCard(int index) {
|
||||
final bool revealed = isRevealed[index] || isMatched[index];
|
||||
final bool matched = isMatched[index];
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => _onCardTap(index),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
decoration: BoxDecoration(
|
||||
color: matched
|
||||
? Colors.transparent // 맞춘 카드는 투명하게
|
||||
: (revealed ? Colors.white : Colors.indigoAccent),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: matched ? null : Border.all(color: Colors.indigo, width: 1),
|
||||
boxShadow: (!matched && !revealed) ? [const BoxShadow(color: Colors.black26, offset: Offset(2,2), blurRadius: 2)] : [],
|
||||
),
|
||||
child: matched
|
||||
? const SizedBox()
|
||||
: (revealed
|
||||
? Icon(icons[cards[index]], size: 32, color: Colors.indigo)
|
||||
: const Icon(Icons.question_mark, color: Colors.white24)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:playwith_core/playwith_core.dart';
|
||||
|
||||
class OmokGame extends BaseGame {
|
||||
@override
|
||||
String get id => "omok";
|
||||
@override
|
||||
String get name => "오목";
|
||||
@override
|
||||
String get description => "오목 한 판 승부!";
|
||||
|
||||
// 1: 흑(Host), 2: 백(Guest)
|
||||
int? _myStone;
|
||||
|
||||
@override
|
||||
void onStart() {
|
||||
super.onStart();
|
||||
// 방장이 흑(1), 게스트가 백(2)
|
||||
_myStone = NetworkManager().role == NetworkRole.host ? 1 : 2;
|
||||
}
|
||||
|
||||
@override
|
||||
void onMessageReceived(String senderId, Map<String, dynamic> payload) {
|
||||
// BaseGame은 상태 관리를 자식 위젯(OmokScreen)에게 위임하므로
|
||||
// 여기서는 패킷을 전달하기만 하면 됩니다. (StreamBuilder가 처리)
|
||||
}
|
||||
|
||||
@override
|
||||
Widget buildHostView(BuildContext context) => OmokScreen(myStone: 1, gameInstance: this);
|
||||
|
||||
@override
|
||||
Widget buildGuestView(BuildContext context) => OmokScreen(myStone: 2, gameInstance: this);
|
||||
}
|
||||
|
||||
class OmokScreen extends StatefulWidget {
|
||||
final int myStone; // 1: 흑, 2: 백
|
||||
final OmokGame gameInstance;
|
||||
|
||||
const OmokScreen({super.key, required this.myStone, required this.gameInstance});
|
||||
|
||||
@override
|
||||
State<OmokScreen> createState() => _OmokScreenState();
|
||||
}
|
||||
|
||||
class _OmokScreenState extends State<OmokScreen> {
|
||||
// 0: 빈칸, 1: 흑, 2: 백
|
||||
final List<List<int>> board = List.generate(15, (_) => List.filled(15, 0));
|
||||
int currentTurn = 1; // 흑 먼저
|
||||
bool isGameOver = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
NetworkManager().messageStream.listen(_handleMessage);
|
||||
}
|
||||
|
||||
void _handleMessage(Map<String, dynamic> payload) {
|
||||
if (!mounted) return;
|
||||
if (payload['type'] == 'MOVE') {
|
||||
final int x = payload['x'];
|
||||
final int y = payload['y'];
|
||||
final int stone = payload['stone'];
|
||||
_placeStone(x, y, stone);
|
||||
} else if (payload['type'] == 'GAME_OVER') {
|
||||
_showGameOverDialog(payload['winner']);
|
||||
}
|
||||
}
|
||||
|
||||
void _onTap(int x, int y) {
|
||||
if (isGameOver) return;
|
||||
if (currentTurn != widget.myStone) return; // 내 턴 아님
|
||||
if (board[y][x] != 0) return; // 이미 돌 있음
|
||||
|
||||
// 착수
|
||||
_placeStone(x, y, widget.myStone);
|
||||
|
||||
// 전송
|
||||
NetworkManager().sendMessage({
|
||||
'type': 'MOVE',
|
||||
'x': x,
|
||||
'y': y,
|
||||
'stone': widget.myStone,
|
||||
});
|
||||
}
|
||||
|
||||
void _placeStone(int x, int y, int stone) {
|
||||
setState(() {
|
||||
board[y][x] = stone;
|
||||
|
||||
// 승리 체크
|
||||
if (_checkWin(x, y, stone)) {
|
||||
isGameOver = true;
|
||||
if (stone == widget.myStone) {
|
||||
// 내가 이겼으면 승리 선언 전송
|
||||
NetworkManager().sendMessage({'type': 'GAME_OVER', 'winner': stone});
|
||||
_showGameOverDialog(stone);
|
||||
}
|
||||
} else {
|
||||
// 턴 넘기기
|
||||
currentTurn = (stone == 1) ? 2 : 1;
|
||||
}
|
||||
});
|
||||
|
||||
SoundManager().playSfx(SoundKey.click);
|
||||
}
|
||||
|
||||
// 승리 조건 (5목) 체크
|
||||
bool _checkWin(int x, int y, int stone) {
|
||||
final directions = [
|
||||
[1, 0], [0, 1], [1, 1], [1, -1] // 가로, 세로, 대각선, 역대각선
|
||||
];
|
||||
|
||||
for (var d in directions) {
|
||||
int count = 1;
|
||||
// 정방향 탐색
|
||||
for (int i = 1; i < 5; i++) {
|
||||
int nx = x + d[0] * i;
|
||||
int ny = y + d[1] * i;
|
||||
if (nx < 0 || ny < 0 || nx >= 15 || ny >= 15 || board[ny][nx] != stone) break;
|
||||
count++;
|
||||
}
|
||||
// 역방향 탐색
|
||||
for (int i = 1; i < 5; i++) {
|
||||
int nx = x - d[0] * i;
|
||||
int ny = y - d[1] * i;
|
||||
if (nx < 0 || ny < 0 || nx >= 15 || ny >= 15 || board[ny][nx] != stone) break;
|
||||
count++;
|
||||
}
|
||||
if (count >= 5) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void _showGameOverDialog(int winner) {
|
||||
String msg = (winner == widget.myStone) ? "승리했습니다! 🎉" : "패배했습니다... 😭";
|
||||
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("나가기"),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bool myTurn = currentTurn == widget.myStone;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(myTurn ? "나의 턴 (${widget.myStone == 1 ? '흑' : '백'})" : "상대방 생각 중..."),
|
||||
backgroundColor: myTurn ? Colors.blue[100] : Colors.grey[200],
|
||||
),
|
||||
backgroundColor: const Color(0xFFDCB35C), // 바둑판 색
|
||||
body: Center(
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1.0,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final double cellSize = constraints.maxWidth / 15;
|
||||
return Stack(
|
||||
children: [
|
||||
// 격자 그리기
|
||||
CustomPaint(
|
||||
size: Size(constraints.maxWidth, constraints.maxWidth),
|
||||
painter: GridPainter(),
|
||||
),
|
||||
// 터치 영역 및 돌 그리기
|
||||
...List.generate(15 * 15, (index) {
|
||||
final int x = index % 15;
|
||||
final int y = index ~/ 15;
|
||||
final int stone = board[y][x];
|
||||
|
||||
return Positioned(
|
||||
left: x * cellSize,
|
||||
top: y * cellSize,
|
||||
width: cellSize,
|
||||
height: cellSize,
|
||||
child: GestureDetector(
|
||||
onTap: () => _onTap(x, y),
|
||||
child: Container(
|
||||
color: Colors.transparent, // 터치 영역 확보
|
||||
child: stone == 0
|
||||
? null
|
||||
: FractionallySizedBox(
|
||||
widthFactor: 0.8,
|
||||
heightFactor: 0.8,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: stone == 1 ? Colors.black : Colors.white,
|
||||
boxShadow: const [BoxShadow(blurRadius: 2, offset: Offset(1,1), color: Colors.black45)]
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class GridPainter extends CustomPainter {
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()..color = Colors.black..strokeWidth = 1.0;
|
||||
final double step = size.width / 15;
|
||||
final double halfStep = step / 2;
|
||||
|
||||
// 선 그리기 (중심에 맞게)
|
||||
for (int i = 0; i < 15; i++) {
|
||||
final double pos = halfStep + i * step;
|
||||
canvas.drawLine(Offset(pos, halfStep), Offset(pos, size.height - halfStep), paint); // 세로
|
||||
canvas.drawLine(Offset(halfStep, pos), Offset(size.width - halfStep, pos), paint); // 가로
|
||||
}
|
||||
|
||||
// 화점 (천원 등)
|
||||
final dotPaint = Paint()..color = Colors.black..style = PaintingStyle.fill;
|
||||
final dots = [3, 7, 11];
|
||||
for (int y in dots) {
|
||||
for (int x in dots) {
|
||||
canvas.drawCircle(Offset(halfStep + x * step, halfStep + y * step), 3.0, dotPaint);
|
||||
}
|
||||
}
|
||||
}
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||
}
|
||||
@@ -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,784 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:playwith_core/playwith_core.dart';
|
||||
import '../model/quiz_model.dart';
|
||||
|
||||
|
||||
enum PlayerStatus { alive, dead, winner, loser }
|
||||
enum GamePhase { selectCategory, voteRule, voteInput, voteTime, playing, result }
|
||||
enum InputMode { touch, voice }
|
||||
enum GameRule { survival, suddenDeath, scoreAttack, relay }
|
||||
|
||||
class QuizGame extends BaseGame {
|
||||
@override
|
||||
String get id => "quiz_mix";
|
||||
@override
|
||||
String get name => "멀티 모드 퀴즈";
|
||||
@override
|
||||
String get description => "다함께 투표하고 퀴즈를 풀어보세요!";
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 상태 변수
|
||||
// ------------------------------------------------------------------------
|
||||
final _gameStateController = StreamController<Map<String, dynamic>>.broadcast();
|
||||
Stream<Map<String, dynamic>> get gameStateStream => _gameStateController.stream;
|
||||
Map<String, dynamic>? _lastState;
|
||||
|
||||
GamePhase _phase = GamePhase.selectCategory;
|
||||
GameRule _selectedRule = GameRule.survival;
|
||||
InputMode _selectedInputMode = InputMode.touch;
|
||||
int _selectedTimeLimit = 5;
|
||||
|
||||
final Set<String> _aliveUsers = {};
|
||||
final Set<String> _answeredUsers = {};
|
||||
final Map<String, int> _scores = {};
|
||||
final Map<String, String> _votes = {};
|
||||
|
||||
List<String> _turnOrder = [];
|
||||
int _currentTurnIndex = 0;
|
||||
|
||||
PlayerStatus _myStatus = PlayerStatus.alive;
|
||||
String? _mySelectedAnswer;
|
||||
bool _isLockedIn = false;
|
||||
|
||||
bool _isCountingDown = false;
|
||||
int _countdownValue = 3;
|
||||
bool _isShowingResult = false;
|
||||
|
||||
List<QuizItem> _masterQuestions = [];
|
||||
final Set<String> _selectedCategories = {};
|
||||
List<QuizItem> _questions = [];
|
||||
int _currentQuestionIndex = -1;
|
||||
|
||||
Timer? _hostQuestionTimer;
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 라이프사이클
|
||||
// ------------------------------------------------------------------------
|
||||
@override
|
||||
void onStart() {
|
||||
super.onStart();
|
||||
print("Quiz Game Started!");
|
||||
_resetGame();
|
||||
|
||||
try {
|
||||
_masterQuestions = QuizSet.getStandard50();
|
||||
} catch (e) {
|
||||
_masterQuestions = [QuizItem(type: QuizType.text, category: "기타", question: "Error", answer: "O", options: ["O","X"])];
|
||||
}
|
||||
|
||||
_selectedCategories.clear();
|
||||
for (var q in _masterQuestions) {
|
||||
_selectedCategories.add(q.category);
|
||||
}
|
||||
|
||||
_aliveUsers.add(NetworkManager().me.id);
|
||||
for (var guest in NetworkManager().guestList) {
|
||||
_aliveUsers.add(guest.id);
|
||||
}
|
||||
for (var uid in _aliveUsers) _scores[uid] = 0;
|
||||
|
||||
if (NetworkManager().role == NetworkRole.host) {
|
||||
Future.delayed(const Duration(milliseconds: 500), () {
|
||||
_broadcastState({'type': 'PHASE_CHANGE', 'phase': 'SELECT_CATEGORY'});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _resetGame() {
|
||||
_phase = GamePhase.selectCategory;
|
||||
_lastState = null;
|
||||
_aliveUsers.clear();
|
||||
_scores.clear();
|
||||
_votes.clear();
|
||||
_turnOrder.clear();
|
||||
_currentQuestionIndex = -1;
|
||||
_resetLocalState();
|
||||
_myStatus = PlayerStatus.alive;
|
||||
_hostQuestionTimer?.cancel();
|
||||
}
|
||||
|
||||
@override
|
||||
void onDispose() {
|
||||
_hostQuestionTimer?.cancel();
|
||||
_gameStateController.close();
|
||||
super.onDispose();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 메시지 처리
|
||||
// ------------------------------------------------------------------------
|
||||
@override
|
||||
void onMessageReceived(String senderId, Map<String, dynamic> payload) {
|
||||
if (!['ANSWER_SUBMIT', 'VOTE_SUBMIT'].contains(payload['type'])) {
|
||||
_lastState = payload;
|
||||
}
|
||||
|
||||
switch (payload['type']) {
|
||||
case 'PHASE_CHANGE':
|
||||
_handlePhaseChange(payload);
|
||||
break;
|
||||
case 'VOTE_SUBMIT':
|
||||
_handleVoteSubmit(payload);
|
||||
break;
|
||||
case 'GAME_COUNTDOWN':
|
||||
_handleCountdown(payload);
|
||||
break;
|
||||
case 'ANSWER_SUBMIT':
|
||||
_handleAnswerSubmit(payload);
|
||||
break;
|
||||
case 'PLAYER_STATUS_UPDATE':
|
||||
_handleStatusUpdate(payload);
|
||||
break;
|
||||
case 'PLAYER_ELIMINATED':
|
||||
_handleEliminated(payload);
|
||||
break;
|
||||
case 'ROUND_RESULT':
|
||||
_handleRoundResult(payload);
|
||||
break;
|
||||
case 'GAME_STATE_UPDATE':
|
||||
_handleNewQuestion(payload);
|
||||
break;
|
||||
case 'GAME_OVER':
|
||||
_handleGameOver(payload);
|
||||
break;
|
||||
case 'GAME_EXIT':
|
||||
_gameStateController.add(payload);
|
||||
break;
|
||||
case 'system_message':
|
||||
_gameStateController.add(payload);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// Handlers
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
void _handlePhaseChange(Map<String, dynamic> payload) {
|
||||
final phaseStr = payload['phase'];
|
||||
|
||||
if (phaseStr == 'SELECT_CATEGORY') {
|
||||
_phase = GamePhase.selectCategory;
|
||||
}
|
||||
else if (phaseStr == 'VOTE_RULE') {
|
||||
_phase = GamePhase.voteRule;
|
||||
_votes.clear();
|
||||
|
||||
if (payload['categories'] != null) {
|
||||
final List<dynamic> cats = payload['categories'];
|
||||
_selectedCategories.clear();
|
||||
_selectedCategories.addAll(cats.cast<String>());
|
||||
_questions = _masterQuestions.where((q) => _selectedCategories.contains(q.category)).toList();
|
||||
if (_questions.isEmpty) _questions = List.from(_masterQuestions);
|
||||
}
|
||||
}
|
||||
else if (phaseStr == 'VOTE_INPUT') {
|
||||
_phase = GamePhase.voteInput;
|
||||
_selectedRule = GameRule.values.firstWhere((e) => e.name == payload['rule'], orElse: () => GameRule.survival);
|
||||
_votes.clear();
|
||||
}
|
||||
else if (phaseStr == 'VOTE_TIME') {
|
||||
_phase = GamePhase.voteTime;
|
||||
_selectedInputMode = payload['inputMode'] == 'voice' ? InputMode.voice : InputMode.touch;
|
||||
_votes.clear();
|
||||
}
|
||||
else if (phaseStr == 'PLAYING') {
|
||||
_phase = GamePhase.playing;
|
||||
_selectedTimeLimit = payload['timeLimit'] ?? 5;
|
||||
if (_selectedRule == GameRule.relay) {
|
||||
_turnOrder = List<String>.from(payload['turnOrder'] ?? []);
|
||||
_currentTurnIndex = 0;
|
||||
}
|
||||
}
|
||||
_gameStateController.add(payload);
|
||||
}
|
||||
|
||||
void _handleVoteSubmit(Map<String, dynamic> payload) {
|
||||
final uid = payload['userId'];
|
||||
_votes[uid] = payload['vote'];
|
||||
_gameStateController.add({'type': 'UI_REFRESH'});
|
||||
|
||||
if (NetworkManager().role == NetworkRole.host) {
|
||||
if (_votes.length >= _aliveUsers.length) {
|
||||
if (_phase == GamePhase.voteRule) {
|
||||
_decideRule();
|
||||
} else if (_phase == GamePhase.voteInput) {
|
||||
_decideInput();
|
||||
} else if (_phase == GamePhase.voteTime) {
|
||||
_decideTimeAndStart();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _handleCountdown(Map<String, dynamic> payload) {
|
||||
_isShowingResult = false;
|
||||
_isCountingDown = true;
|
||||
_countdownValue = payload['count'];
|
||||
if (_countdownValue > 0) SoundManager().playSfx(SoundKey.click);
|
||||
_gameStateController.add(payload);
|
||||
}
|
||||
|
||||
void _handleAnswerSubmit(Map<String, dynamic> payload) {
|
||||
if (NetworkManager().role != NetworkRole.host) return;
|
||||
|
||||
final String userId = payload['userId'];
|
||||
final String answer = payload['answer'];
|
||||
|
||||
if (_answeredUsers.contains(userId)) return;
|
||||
if (_selectedRule == GameRule.relay && _turnOrder[_currentTurnIndex] != userId) return;
|
||||
|
||||
_answeredUsers.add(userId);
|
||||
|
||||
final currentQ = _questions[_currentQuestionIndex];
|
||||
bool isCorrect = false;
|
||||
if (_selectedInputMode == InputMode.voice) {
|
||||
isCorrect = VoiceManager().checkAnswer(answer, currentQ.answer);
|
||||
} else {
|
||||
isCorrect = (answer == currentQ.answer);
|
||||
}
|
||||
|
||||
if (_selectedRule == GameRule.scoreAttack) {
|
||||
if (isCorrect) _scores[userId] = (_scores[userId] ?? 0) + 1;
|
||||
} else {
|
||||
if (!isCorrect) {
|
||||
_aliveUsers.remove(userId);
|
||||
NetworkManager().sendMessage({'type': 'PLAYER_ELIMINATED', 'targetUserId': userId});
|
||||
if (userId == NetworkManager().me.id) _handleLocalElimination();
|
||||
|
||||
if (_selectedRule == GameRule.suddenDeath || _selectedRule == GameRule.relay) {
|
||||
_broadcastState({'type': 'PLAYER_STATUS_UPDATE', 'userId': userId, 'isSubmitted': true, 'isAlive': false});
|
||||
Future.delayed(const Duration(milliseconds: 1000), () => _finishGame(winnerId: null));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_broadcastState({
|
||||
'type': 'PLAYER_STATUS_UPDATE',
|
||||
'userId': userId,
|
||||
'isSubmitted': true,
|
||||
'isAlive': _aliveUsers.contains(userId),
|
||||
'score': _scores[userId]
|
||||
});
|
||||
|
||||
int targetCount = _selectedRule == GameRule.scoreAttack
|
||||
? NetworkManager().guestList.length + 1
|
||||
: _aliveUsers.length + (isCorrect ? 0 : 1);
|
||||
if (_selectedRule == GameRule.relay) targetCount = 1;
|
||||
|
||||
if (_answeredUsers.length >= targetCount) {
|
||||
_hostQuestionTimer?.cancel();
|
||||
Future.delayed(const Duration(milliseconds: 1000), () => _showRoundResultAndNext());
|
||||
}
|
||||
}
|
||||
|
||||
void _handleStatusUpdate(Map<String, dynamic> payload) {
|
||||
_answeredUsers.add(payload['userId']);
|
||||
if (payload['isAlive'] == false) _aliveUsers.remove(payload['userId']);
|
||||
if (payload['score'] != null) _scores[payload['userId']] = payload['score'];
|
||||
_gameStateController.add(payload);
|
||||
}
|
||||
void _handleEliminated(Map<String, dynamic> payload) {
|
||||
if (payload['targetUserId'] == NetworkManager().me.id) _handleLocalElimination();
|
||||
_gameStateController.add({'type': 'UI_REFRESH'});
|
||||
}
|
||||
void _handleRoundResult(Map<String, dynamic> payload) {
|
||||
_isCountingDown = false;
|
||||
_isShowingResult = true;
|
||||
if (_selectedRule == GameRule.relay) _currentTurnIndex = payload['nextTurnIndex'] ?? 0;
|
||||
final survivors = payload['survivors'] ?? [];
|
||||
bool amISurvived = survivors.contains(NetworkManager().me.id);
|
||||
if (!amISurvived && _myStatus == PlayerStatus.alive && _selectedRule != GameRule.scoreAttack) _handleLocalElimination();
|
||||
_gameStateController.add(payload);
|
||||
}
|
||||
void _handleNewQuestion(Map<String, dynamic> payload) {
|
||||
_isCountingDown = false;
|
||||
_isShowingResult = false;
|
||||
_resetLocalState();
|
||||
_gameStateController.add(payload);
|
||||
}
|
||||
void _handleGameOver(Map<String, dynamic> payload) {
|
||||
final winnerId = payload['winnerId'];
|
||||
if (winnerId == NetworkManager().me.id) {
|
||||
_myStatus = PlayerStatus.winner;
|
||||
SoundManager().playSfx(SoundKey.win);
|
||||
} else {
|
||||
_myStatus = PlayerStatus.loser;
|
||||
if (winnerId == 'ALL_LOSE') SoundManager().playSfx(SoundKey.wrong);
|
||||
}
|
||||
_gameStateController.add(payload);
|
||||
}
|
||||
void _handleLocalElimination() {
|
||||
SoundManager().playSfx(SoundKey.wrong);
|
||||
_myStatus = PlayerStatus.dead;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// [Host Logic] 결정 로직
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
void _confirmCategories() {
|
||||
if (_selectedCategories.isEmpty) return;
|
||||
_broadcastState({
|
||||
'type': 'PHASE_CHANGE',
|
||||
'phase': 'VOTE_RULE',
|
||||
'categories': _selectedCategories.toList(),
|
||||
});
|
||||
}
|
||||
|
||||
void _decideRule() {
|
||||
final counts = <String, int>{};
|
||||
for (var v in _votes.values) { counts[v] = (counts[v] ?? 0) + 1; }
|
||||
|
||||
String topRule = 'survival';
|
||||
if (counts.isNotEmpty) {
|
||||
topRule = counts.entries.reduce((a, b) => a.value >= b.value ? a : b).key;
|
||||
}
|
||||
|
||||
_selectedRule = GameRule.values.firstWhere((e) => e.name == topRule, orElse: () => GameRule.survival);
|
||||
|
||||
_broadcastState({
|
||||
'type': 'PHASE_CHANGE',
|
||||
'phase': 'VOTE_INPUT',
|
||||
'rule': _selectedRule.name
|
||||
});
|
||||
}
|
||||
|
||||
void _decideInput() {
|
||||
int touch = _votes.values.where((v) => v == 'touch').length;
|
||||
int voice = _votes.values.where((v) => v == 'voice').length;
|
||||
InputMode mode = (touch >= voice) ? InputMode.touch : InputMode.voice;
|
||||
|
||||
_broadcastState({
|
||||
'type': 'PHASE_CHANGE',
|
||||
'phase': 'VOTE_TIME',
|
||||
'inputMode': mode.name,
|
||||
});
|
||||
}
|
||||
|
||||
void _decideTimeAndStart() {
|
||||
final counts = <String, int>{};
|
||||
for (var v in _votes.values) { counts[v] = (counts[v] ?? 0) + 1; }
|
||||
|
||||
String topTime = '5';
|
||||
if (counts.isNotEmpty) {
|
||||
topTime = counts.entries.reduce((a, b) => a.value >= b.value ? a : b).key;
|
||||
}
|
||||
int timeLimit = int.tryParse(topTime) ?? 5;
|
||||
|
||||
List<String>? turnOrder;
|
||||
if (_selectedRule == GameRule.relay) {
|
||||
turnOrder = _aliveUsers.toList()..shuffle();
|
||||
}
|
||||
|
||||
_broadcastState({
|
||||
'type': 'PHASE_CHANGE',
|
||||
'phase': 'PLAYING',
|
||||
'timeLimit': timeLimit,
|
||||
'turnOrder': turnOrder
|
||||
});
|
||||
|
||||
Future.delayed(const Duration(seconds: 2), () => _startCountdownSequence());
|
||||
}
|
||||
|
||||
void _showRoundResultAndNext() {
|
||||
_hostQuestionTimer?.cancel();
|
||||
|
||||
final currentQ = _questions[_currentQuestionIndex];
|
||||
int nextTurn = _currentTurnIndex;
|
||||
if (_selectedRule == GameRule.relay) {
|
||||
nextTurn = (_currentTurnIndex + 1) % _aliveUsers.length;
|
||||
}
|
||||
_broadcastState({
|
||||
'type': 'ROUND_RESULT',
|
||||
'status': 'RESULT',
|
||||
'correctAnswer': currentQ.answer,
|
||||
'survivors': _aliveUsers.toList(),
|
||||
'scores': _scores,
|
||||
'nextTurnIndex': nextTurn
|
||||
});
|
||||
_currentTurnIndex = nextTurn;
|
||||
Future.delayed(const Duration(seconds: 3), () => _checkWinnerAndNext());
|
||||
}
|
||||
|
||||
void _checkWinnerAndNext() {
|
||||
bool isSolo = NetworkManager().guestList.isEmpty;
|
||||
bool isEnd = false;
|
||||
String? winnerId;
|
||||
|
||||
if (_currentQuestionIndex >= _questions.length - 1) {
|
||||
if (isSolo) {
|
||||
_questions = _masterQuestions.where((q) => _selectedCategories.contains(q.category)).toList();
|
||||
_questions.shuffle();
|
||||
_currentQuestionIndex = -1;
|
||||
_broadcastState({'type': 'system_message', 'message': '문제가 리필되었습니다! 🔄'});
|
||||
} else {
|
||||
isEnd = true;
|
||||
if (_selectedRule == GameRule.scoreAttack && _scores.isNotEmpty) {
|
||||
winnerId = _scores.entries.reduce((a, b) => a.value >= b.value ? a : b).key;
|
||||
} else {
|
||||
winnerId = _aliveUsers.isNotEmpty ? _aliveUsers.first : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (_selectedRule != GameRule.scoreAttack) {
|
||||
if (isSolo) {
|
||||
if (_aliveUsers.isEmpty) {
|
||||
isEnd = true;
|
||||
winnerId = null;
|
||||
}
|
||||
}
|
||||
else if (_aliveUsers.length <= 1) {
|
||||
isEnd = true;
|
||||
winnerId = _aliveUsers.isNotEmpty ? _aliveUsers.first : null;
|
||||
}
|
||||
}
|
||||
|
||||
if (isEnd) {
|
||||
_finishGame(winnerId: winnerId);
|
||||
} else {
|
||||
_startCountdownSequence();
|
||||
}
|
||||
}
|
||||
|
||||
void _startCountdownSequence() {
|
||||
int count = 3;
|
||||
Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
_broadcastState({'type': 'GAME_COUNTDOWN', 'count': count});
|
||||
if (count == 0) { timer.cancel(); _sendNewQuestion(); }
|
||||
count--;
|
||||
});
|
||||
}
|
||||
|
||||
void _sendNewQuestion() {
|
||||
_currentQuestionIndex++;
|
||||
final qData = _questions[_currentQuestionIndex];
|
||||
_resetLocalState();
|
||||
|
||||
_broadcastState({
|
||||
'type': 'GAME_STATE_UPDATE',
|
||||
'status': 'QUESTION',
|
||||
'data': qData.toJson(),
|
||||
'timeLimit': _selectedTimeLimit
|
||||
});
|
||||
|
||||
_hostQuestionTimer?.cancel();
|
||||
_hostQuestionTimer = Timer(Duration(seconds: _selectedTimeLimit + 1), _handleQuestionTimeout);
|
||||
}
|
||||
|
||||
void _handleQuestionTimeout() {
|
||||
if (NetworkManager().role != NetworkRole.host) return;
|
||||
|
||||
List<String> timeoutUsers = [];
|
||||
if (_selectedRule == GameRule.relay) {
|
||||
String currentTurnUser = _turnOrder[_currentTurnIndex];
|
||||
if (!_answeredUsers.contains(currentTurnUser) && _aliveUsers.contains(currentTurnUser)) {
|
||||
timeoutUsers.add(currentTurnUser);
|
||||
}
|
||||
} else {
|
||||
for (var uid in _aliveUsers) {
|
||||
if (!_answeredUsers.contains(uid)) timeoutUsers.add(uid);
|
||||
}
|
||||
}
|
||||
|
||||
if (timeoutUsers.isNotEmpty) {
|
||||
for (var uid in timeoutUsers) {
|
||||
if (_selectedRule != GameRule.scoreAttack) {
|
||||
_aliveUsers.remove(uid);
|
||||
NetworkManager().sendMessage({'type': 'PLAYER_ELIMINATED', 'targetUserId': uid});
|
||||
if (uid == NetworkManager().me.id) _handleLocalElimination();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_showRoundResultAndNext();
|
||||
}
|
||||
|
||||
void _finishGame({String? winnerId}) {
|
||||
_hostQuestionTimer?.cancel();
|
||||
final endData = {'type': 'GAME_OVER', 'winnerId': winnerId ?? 'NONE', 'winnerName': _findUserName(winnerId)};
|
||||
_broadcastState(endData);
|
||||
}
|
||||
|
||||
void _broadcastState(Map<String, dynamic> data) {
|
||||
_lastState = data;
|
||||
if (NetworkManager().role == NetworkRole.host) {
|
||||
NetworkManager().sendMessage(data);
|
||||
onMessageReceived(NetworkManager().me.id, data);
|
||||
} else {
|
||||
_gameStateController.add(data);
|
||||
}
|
||||
}
|
||||
|
||||
void _resetLocalState() {
|
||||
_answeredUsers.clear();
|
||||
_mySelectedAnswer = null;
|
||||
_isLockedIn = false;
|
||||
}
|
||||
|
||||
String _findUserName(String? id) {
|
||||
if (id == null) return '없음';
|
||||
if (id == NetworkManager().me.id) return NetworkManager().me.nickname;
|
||||
return NetworkManager().guestList.firstWhere((u) => u.id == id, orElse: () => UserInfo(id: '', nickname: 'Unknown')).nickname;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// [UI] Unified View
|
||||
// ------------------------------------------------------------------------
|
||||
@override
|
||||
Widget buildHostView(BuildContext context) => _buildSharedScreen(context, isHost: true);
|
||||
@override
|
||||
Widget buildGuestView(BuildContext context) => _buildSharedScreen(context, isHost: false);
|
||||
|
||||
Widget _buildSharedScreen(BuildContext context, {required bool isHost}) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text("OX 서바이벌"),
|
||||
centerTitle: true,
|
||||
automaticallyImplyLeading: false,
|
||||
actions: [
|
||||
if (isHost) IconButton(icon: const Icon(Icons.close), onPressed: () => _confirmExit(context))
|
||||
],
|
||||
),
|
||||
// [추가] 하단 배너 광고 배치
|
||||
bottomNavigationBar: const SafeArea(child: AdBannerWidget()),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 0), // 광고가 bottomNavigationBar에 있으므로 padding 제거
|
||||
child: StreamBuilder<Map<String, dynamic>>(
|
||||
stream: gameStateStream,
|
||||
initialData: _lastState,
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) return _buildWaitingScreen("로딩 중...");
|
||||
final data = snapshot.data!;
|
||||
|
||||
// 1. 카테고리
|
||||
if (_phase == GamePhase.selectCategory || (data['type'] == 'PHASE_CHANGE' && data['phase'] == 'SELECT_CATEGORY')) {
|
||||
return _buildCategorySelectionView(context, isHost);
|
||||
}
|
||||
// 2. 규칙
|
||||
if (_phase == GamePhase.voteRule || (data['type'] == 'PHASE_CHANGE' && data['phase'] == 'VOTE_RULE')) {
|
||||
return _buildRuleVotingView(context, isHost);
|
||||
}
|
||||
// 3. 입력 방식
|
||||
if (_phase == GamePhase.voteInput || (data['type'] == 'PHASE_CHANGE' && data['phase'] == 'VOTE_INPUT')) {
|
||||
return _buildInputVotingView(context, isHost);
|
||||
}
|
||||
// 4. 시간 제한 투표
|
||||
if (_phase == GamePhase.voteTime || (data['type'] == 'PHASE_CHANGE' && data['phase'] == 'VOTE_TIME')) {
|
||||
return _buildTimeVotingView(context, isHost);
|
||||
}
|
||||
|
||||
// 5. 게임 진행
|
||||
if (_isCountingDown || (data['type'] == 'GAME_COUNTDOWN')) {
|
||||
int count = data['count'] ?? 3;
|
||||
return Center(child: Text(count > 0 ? "$count" : "START!", style: const TextStyle(fontSize: 90, fontWeight: FontWeight.bold, color: Colors.blue)));
|
||||
}
|
||||
if (data['type'] == 'GAME_EXIT') {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) { if(context.mounted) Navigator.pop(context); });
|
||||
return const Center(child: Text("종료되었습니다."));
|
||||
}
|
||||
if (data['type'] == 'GAME_OVER') return _buildResultScreen(context, data['winnerName']);
|
||||
if (_isShowingResult || data['status'] == 'RESULT') return _buildRoundResultScreen(data);
|
||||
if (data['status'] == 'QUESTION' || _currentQuestionIndex >= 0) {
|
||||
Map<String, dynamic> qData = data['data'] ?? (_currentQuestionIndex < _questions.length ? _questions[_currentQuestionIndex].toJson() : {});
|
||||
if (qData.isEmpty) return _buildWaitingScreen("문제 로딩 중...");
|
||||
return _buildPlayArea(context, qData);
|
||||
}
|
||||
|
||||
return _buildWaitingScreen("준비 중...");
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCategorySelectionView(BuildContext context, bool isHost) {
|
||||
if (!isHost) {
|
||||
return const Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [CircularProgressIndicator(), SizedBox(height: 20), Text("방장이 문제 카테고리를 고르고 있습니다...", style: TextStyle(fontSize: 16, color: Colors.grey))]));
|
||||
}
|
||||
final allCategories = _masterQuestions.map((q) => q.category).toSet().toList()..sort();
|
||||
return Center(child: SingleChildScrollView(child: Padding(padding: const EdgeInsets.all(24.0), child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [const Text("출제할 카테고리 선택", style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)), const SizedBox(height: 10), const Text("원하는 분야만 골라서 플레이하세요!", style: TextStyle(color: Colors.grey)), const SizedBox(height: 30), Wrap(spacing: 10, runSpacing: 10, alignment: WrapAlignment.center, children: allCategories.map((cat) { final isSelected = _selectedCategories.contains(cat); return FilterChip(label: Text(cat), selected: isSelected, onSelected: (bool selected) { if (!selected && _selectedCategories.length <= 1) return; if (selected) { _selectedCategories.add(cat); } else { _selectedCategories.remove(cat); } _gameStateController.add({'type': 'UI_REFRESH'}); }); }).toList()), const SizedBox(height: 40), ElevatedButton(onPressed: _confirmCategories, style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 40, vertical: 15), backgroundColor: Colors.blueAccent), child: Text("선택 완료 (${_selectedCategories.length}개)", style: const TextStyle(fontSize: 18, color: Colors.white)))]))));
|
||||
}
|
||||
|
||||
Widget _buildRuleVotingView(BuildContext context, bool isHost) {
|
||||
bool hasVoted = _votes.containsKey(NetworkManager().me.id);
|
||||
int voteCount = _votes.length;
|
||||
int totalPlayers = _aliveUsers.length;
|
||||
return Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [Text("어떤 게임을 할까요? ($voteCount/$totalPlayers)", style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), const SizedBox(height: 30), if (hasVoted) ...[const CircularProgressIndicator(), const SizedBox(height: 20), const Text("다른 참가자를 기다리는 중...", style: TextStyle(color: Colors.grey)), if (isHost) Padding(padding: const EdgeInsets.only(top: 30), child: ElevatedButton.icon(icon: const Icon(Icons.play_arrow), label: const Text("강제 집계 및 시작"), style: ElevatedButton.styleFrom(backgroundColor: Colors.orange), onPressed: () => _decideRule()))] else Wrap(spacing: 15, runSpacing: 15, alignment: WrapAlignment.center, children: [_VoteButton(icon: Icons.local_fire_department, label: "서바이벌", color: Colors.red, onTap: () => _submitVote('survival')), _VoteButton(icon: Icons.dangerous, label: "단체 한방", color: Colors.black, onTap: () => _submitVote('suddenDeath')), _VoteButton(icon: Icons.score, label: "점수 내기", color: Colors.blue, onTap: () => _submitVote('scoreAttack')), _VoteButton(icon: Icons.directions_run, label: "이어 달리기", color: Colors.green, onTap: () => _submitVote('relay'))])]));
|
||||
}
|
||||
|
||||
Widget _buildInputVotingView(BuildContext context, bool isHost) {
|
||||
bool hasVoted = _votes.containsKey(NetworkManager().me.id);
|
||||
int voteCount = _votes.length;
|
||||
int totalPlayers = _aliveUsers.length;
|
||||
return Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [Text("어떻게 맞출까요? ($voteCount/$totalPlayers)", style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), const SizedBox(height: 30), if (hasVoted) ...[const CircularProgressIndicator(), const SizedBox(height: 20), const Text("대기 중...", style: TextStyle(color: Colors.grey)), if (isHost) Padding(padding: const EdgeInsets.only(top: 30), child: ElevatedButton.icon(icon: const Icon(Icons.play_arrow), label: const Text("강제 집계 및 이동"), style: ElevatedButton.styleFrom(backgroundColor: Colors.orange), onPressed: () => _decideInput()))] else Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [_VoteButton(icon: Icons.touch_app, label: "터치", color: Colors.blue, onTap: () => _submitVote('touch')), _VoteButton(icon: Icons.mic, label: "음성", color: Colors.orange, onTap: () => _submitVote('voice'))])]));
|
||||
}
|
||||
|
||||
Widget _buildTimeVotingView(BuildContext context, bool isHost) {
|
||||
bool hasVoted = _votes.containsKey(NetworkManager().me.id);
|
||||
int voteCount = _votes.length;
|
||||
int totalPlayers = _aliveUsers.length;
|
||||
return Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [Text("제한 시간은 몇 초? ($voteCount/$totalPlayers)", style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), const SizedBox(height: 30), if (hasVoted) ...[const CircularProgressIndicator(), const SizedBox(height: 20), const Text("대기 중...", style: TextStyle(color: Colors.grey)), if (isHost) Padding(padding: const EdgeInsets.only(top: 30), child: ElevatedButton.icon(icon: const Icon(Icons.play_arrow), label: const Text("강제 집계 및 시작"), style: ElevatedButton.styleFrom(backgroundColor: Colors.orange), onPressed: () => _decideTimeAndStart()))] else Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [_VoteButton(icon: Icons.timer_3, label: "3초", color: Colors.red, onTap: () => _submitVote('3')), _VoteButton(icon: Icons.timer, label: "5초", color: Colors.green, onTap: () => _submitVote('5')), _VoteButton(icon: Icons.timer_10, label: "7초", color: Colors.blue, onTap: () => _submitVote('7')), _VoteButton(icon: Icons.hourglass_top, label: "10초", color: Colors.purple, onTap: () => _submitVote('10'))])]));
|
||||
}
|
||||
|
||||
void _submitVote(String vote) {
|
||||
final payload = {'type': 'VOTE_SUBMIT', 'userId': NetworkManager().me.id, 'vote': vote};
|
||||
_votes[NetworkManager().me.id] = vote;
|
||||
_gameStateController.add({'type': 'UI_REFRESH'});
|
||||
|
||||
if (NetworkManager().role == NetworkRole.host) {
|
||||
onMessageReceived("", payload);
|
||||
} else {
|
||||
NetworkManager().sendMessage(payload);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildPlayArea(BuildContext context, Map<String, dynamic> qData) {
|
||||
bool isMyTurn = true;
|
||||
String currentTurnName = "";
|
||||
if (_selectedRule == GameRule.relay) {
|
||||
String currentUserId = _turnOrder.isNotEmpty ? _turnOrder[_currentTurnIndex] : "";
|
||||
isMyTurn = currentUserId == NetworkManager().me.id;
|
||||
currentTurnName = _findUserName(currentUserId);
|
||||
}
|
||||
|
||||
if (_myStatus == PlayerStatus.dead && _selectedRule != GameRule.scoreAttack) {
|
||||
return Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [const Icon(Icons.sentiment_dissatisfied, size: 70, color: Colors.grey), const SizedBox(height: 10), const Text("탈락했습니다 👻", style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)), const SizedBox(height: 20), Text("문제: ${qData['question']}", style: const TextStyle(color: Colors.grey))]));
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
color: Colors.amberAccent.withOpacity(0.2),
|
||||
child: Text("분야: ${qData['category'] ?? '기타'} | ⏱️ 제한시간 ${_selectedTimeLimit}초", textAlign: TextAlign.center, style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.orange)),
|
||||
),
|
||||
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
color: Colors.grey[100],
|
||||
child: _selectedRule == GameRule.scoreAttack
|
||||
? _ScoreBoard(scores: _scores)
|
||||
: _PlayerStatusGrid(aliveUsers: _aliveUsers, answeredUsers: _answeredUsers),
|
||||
),
|
||||
|
||||
TweenAnimationBuilder<double>(
|
||||
tween: Tween(begin: 1.0, end: 0.0),
|
||||
duration: Duration(seconds: _selectedTimeLimit),
|
||||
builder: (context, value, _) => LinearProgressIndicator(
|
||||
value: value,
|
||||
backgroundColor: Colors.grey[300],
|
||||
color: value > 0.3 ? Colors.green : Colors.red
|
||||
),
|
||||
),
|
||||
|
||||
if (_selectedRule == GameRule.relay)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(8),
|
||||
color: isMyTurn ? Colors.blueAccent : Colors.grey[300],
|
||||
child: Text(isMyTurn ? "내 차례입니다!" : "$currentTurnName님의 차례", textAlign: TextAlign.center, style: TextStyle(color: isMyTurn ? Colors.white : Colors.black, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
|
||||
const Divider(height: 1),
|
||||
|
||||
Expanded(
|
||||
flex: 4,
|
||||
child: Center(child: Padding(padding: const EdgeInsets.all(20), child: Text(qData['question'], textAlign: TextAlign.center, style: const TextStyle(fontSize: 28, fontWeight: FontWeight.bold)))),
|
||||
),
|
||||
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: !isMyTurn
|
||||
? const Center(child: Text("다른 사람이 푸는 중...", style: TextStyle(fontSize: 18, color: Colors.grey)))
|
||||
: (_selectedInputMode == InputMode.touch
|
||||
? _buildTouchInput(qData['options'] != null ? List<String>.from(qData['options']) : ["O", "X"])
|
||||
: _buildVoiceInput()),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTouchInput(List<String> options) {
|
||||
if (_isLockedIn) return _buildLockedUI();
|
||||
return Center(child: Wrap(spacing: 20, runSpacing: 20, alignment: WrapAlignment.center, children: options.map((opt) => _AnswerBtn(text: opt, color: Colors.blueAccent, isSelected: _mySelectedAnswer == opt, onTap: () => _selectAnswer(opt))).toList()));
|
||||
}
|
||||
|
||||
Widget _buildVoiceInput() {
|
||||
if (_isLockedIn) return _buildLockedUI();
|
||||
return Column(mainAxisAlignment: MainAxisAlignment.center, children: [VoiceWidget(isListening: VoiceManager().isListening), const SizedBox(height: 20), GestureDetector(onLongPressStart: (_) async { await VoiceManager().startListening(onResult: (text) {}); }, onLongPressEnd: (_) async { await VoiceManager().stopListening(); _selectAnswer("O"); }, child: Container(padding: const EdgeInsets.all(20), decoration: const BoxDecoration(color: Colors.redAccent, shape: BoxShape.circle), child: const Icon(Icons.mic, size: 40, color: Colors.white))), const SizedBox(height: 10), const Text("버튼을 누르고 정답을 말하세요!", style: TextStyle(color: Colors.grey))]);
|
||||
}
|
||||
|
||||
Widget _buildLockedUI() {
|
||||
return Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [Icon(Icons.check, size: 80, color: Colors.blue), const SizedBox(height: 20), const Text("제출 완료!", style: TextStyle(fontSize: 22))]));
|
||||
}
|
||||
|
||||
void _selectAnswer(String answer) {
|
||||
if (_isLockedIn) return;
|
||||
_mySelectedAnswer = answer;
|
||||
SoundManager().playSfx(SoundKey.click);
|
||||
_gameStateController.add({'type': 'UI_REFRESH'});
|
||||
_submitFinalAnswer();
|
||||
}
|
||||
|
||||
void _submitFinalAnswer() {
|
||||
if (_mySelectedAnswer == null) return;
|
||||
_isLockedIn = true;
|
||||
_gameStateController.add({'type': 'UI_REFRESH'});
|
||||
final payload = {'type': 'ANSWER_SUBMIT', 'answer': _mySelectedAnswer, 'userId': NetworkManager().me.id};
|
||||
if (NetworkManager().role == NetworkRole.host) { onMessageReceived("", payload); } else { NetworkManager().sendMessage(payload); }
|
||||
}
|
||||
|
||||
Widget _buildRoundResultScreen(Map<String, dynamic> data) {
|
||||
final String correctAnswer = data['correctAnswer'] ?? "?";
|
||||
final List<dynamic> survivors = data['survivors'] ?? [];
|
||||
final bool amISurvived = survivors.contains(NetworkManager().me.id);
|
||||
return Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [const Text("정답은?", style: TextStyle(fontSize: 24, color: Colors.grey)), const SizedBox(height: 20), Container(width: 160, height: 160, decoration: BoxDecoration(color: correctAnswer == "O" ? Colors.blue : Colors.red, shape: BoxShape.circle, boxShadow: [BoxShadow(color: Colors.black26, blurRadius: 10, offset: const Offset(0, 5))]), child: Center(child: Text(correctAnswer, style: const TextStyle(fontSize: 60, color: Colors.white, fontWeight: FontWeight.bold)))), const SizedBox(height: 40), if (_myStatus == PlayerStatus.dead) const Text("이미 탈락하셨습니다. 👻", style: TextStyle(fontSize: 20, color: Colors.grey)) else if (amISurvived) const Text("생존! 🎉", style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold, color: Colors.green)) else const Text("탈락했습니다... 😭", style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold, color: Colors.red))]));
|
||||
}
|
||||
|
||||
Widget _buildResultScreen(BuildContext context, String winnerName) {
|
||||
return Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [Icon(Icons.emoji_events, size: 100, color: Colors.amber), const SizedBox(height: 20), const Text("게임 종료", style: TextStyle(fontSize: 30, fontWeight: FontWeight.bold)), const SizedBox(height: 10), Text("우승: $winnerName", style: const TextStyle(fontSize: 20)), const SizedBox(height: 50), ElevatedButton(onPressed: () { onDispose(); Navigator.pop(context); }, child: const Text("로비로 돌아가기"))]));
|
||||
}
|
||||
|
||||
Widget _buildWaitingScreen(String msg) => Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [const CircularProgressIndicator(), SizedBox(height: 20), Text(msg)]));
|
||||
void _confirmExit(BuildContext context) { Navigator.pop(context); }
|
||||
}
|
||||
|
||||
// Components
|
||||
class _VoteButton extends StatelessWidget {
|
||||
final IconData icon; final String label; final Color color; final VoidCallback onTap;
|
||||
const _VoteButton({required this.icon, required this.label, required this.color, required this.onTap});
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(onTap: onTap, child: Column(children: [Container(width: 80, height: 80, decoration: BoxDecoration(color: color.withOpacity(0.1), borderRadius: BorderRadius.circular(20), border: Border.all(color: color, width: 2)), child: Icon(icon, size: 40, color: color)), const SizedBox(height: 5), Text(label, style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold, color: color))]));
|
||||
}
|
||||
}
|
||||
class _PlayerStatusGrid extends StatelessWidget {
|
||||
final Set<String> aliveUsers; final Set<String> answeredUsers;
|
||||
const _PlayerStatusGrid({required this.aliveUsers, required this.answeredUsers});
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final allUsers = [NetworkManager().me, ...NetworkManager().guestList];
|
||||
return Container(height: 80, width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 10), color: Colors.grey[50], child: ListView.builder(scrollDirection: Axis.horizontal, itemCount: allUsers.length, itemBuilder: (context, index) { final user = allUsers[index]; final isAlive = aliveUsers.contains(user.id); final isSubmitted = answeredUsers.contains(user.id); return Padding(padding: const EdgeInsets.symmetric(horizontal: 6.0), child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [Stack(children: [Container(width: 40, height: 40, decoration: BoxDecoration(shape: BoxShape.circle, color: isAlive ? Color(user.colorValue) : Colors.grey, border: isSubmitted ? Border.all(color: Colors.green, width: 3) : null), child: AvatarWidget(user: user, size: 40)), if (!isAlive) Positioned.fill(child: Container(decoration: BoxDecoration(color: Colors.black54, shape: BoxShape.circle), child: const Icon(Icons.close, size: 20, color: Colors.white)))]), const SizedBox(height: 4), Text(user.nickname, style: TextStyle(fontSize: 10, color: isAlive ? Colors.black : Colors.grey))])); }));
|
||||
}
|
||||
}
|
||||
class _ScoreBoard extends StatelessWidget {
|
||||
final Map<String, int> scores;
|
||||
const _ScoreBoard({required this.scores});
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(height: 80, child: ListView.builder(scrollDirection: Axis.horizontal, itemCount: scores.length, itemBuilder: (context, index) { final uid = scores.keys.elementAt(index); final score = scores[uid]; String name = "?"; if (uid == NetworkManager().me.id) name = NetworkManager().me.nickname; else { try { name = NetworkManager().guestList.firstWhere((u) => u.id == uid).nickname; } catch(_) {} } return Container(margin: const EdgeInsets.symmetric(horizontal: 8), padding: const EdgeInsets.all(8), decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(10), border: Border.all(color: Colors.blue.shade100)), child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [Text(name, style: const TextStyle(fontSize: 12)), Text("$score점", style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.blue))])); },),);
|
||||
}
|
||||
}
|
||||
class _AnswerBtn extends StatelessWidget {
|
||||
final String text; final Color color; final bool isSelected; final VoidCallback onTap;
|
||||
const _AnswerBtn({required this.text, required this.color, required this.isSelected, required this.onTap});
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(onTap: onTap, child: AnimatedContainer(duration: const Duration(milliseconds: 200), width: isSelected ? 140 : 120, height: isSelected ? 140 : 120, decoration: BoxDecoration(color: color.withOpacity(isSelected ? 1.0 : 0.6), shape: BoxShape.circle, border: isSelected ? Border.all(color: Colors.white, width: 5) : null, boxShadow: [BoxShadow(color: color.withOpacity(0.4), blurRadius: 10, offset: const Offset(0, 6))]), child: Center(child: Text(text, style: const TextStyle(fontSize: 30, color: Colors.white, fontWeight: FontWeight.bold)))));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
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 {
|
||||
@override
|
||||
String get id => "spider_battle";
|
||||
@override
|
||||
String get name => "스파이더 배틀";
|
||||
@override
|
||||
String get description => "세트를 완성하면 상대를 공격합니다!\n(내 남은 카드 중 1장이 상대에게 넘어갑니다)";
|
||||
|
||||
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() async {
|
||||
super.onStart();
|
||||
if (NetworkManager().role == NetworkRole.host) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void onMessageReceived(String senderId, Map<String, dynamic> payload) {
|
||||
if (payload['type'] == 'GAME_START_DATA') {
|
||||
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() {
|
||||
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 SpiderGameDto gameData;
|
||||
final SpiderMultiGame 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, (_) => []);
|
||||
List<SpiderCard> stock = [];
|
||||
List<List<SpiderCard>> foundation = [];
|
||||
int _moves = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initializeGame();
|
||||
NetworkManager().messageStream.listen(_handleNetworkMessage);
|
||||
}
|
||||
|
||||
void _handleNetworkMessage(Map<String, dynamic> payload) {
|
||||
if (!mounted) return;
|
||||
if (payload['type'] == 'ATTACK') {
|
||||
final attackerName = payload['senderName'];
|
||||
_onAttacked(attackerName);
|
||||
} else if (payload['type'] == 'GAME_WIN') {
|
||||
_showGameOverDialog(payload['winnerName']);
|
||||
}
|
||||
}
|
||||
|
||||
void _initializeGame() {
|
||||
List<SpiderCard> deck = [];
|
||||
int idCounter = 0;
|
||||
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));
|
||||
}
|
||||
|
||||
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++) {
|
||||
if (cardIdx < totalCards) {
|
||||
final card = deck[cardIdx++];
|
||||
if (j == count - 1) card.isFaceUp = true;
|
||||
tableau[i].add(card);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (cardIdx < totalCards) {
|
||||
stock = deck.sublist(cardIdx);
|
||||
}
|
||||
}
|
||||
|
||||
// [수정됨] 공격 처리: 내 스톡에서 카드를 꺼내 내 태블로에 추가
|
||||
void _onAttacked(String attackerName) {
|
||||
if (stock.isEmpty) {
|
||||
// 스톡이 없으면 공격 무효 (또는 다른 패널티 적용 가능)
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text("⚔️ $attackerName님의 공격을 막았습니다! (남은 카드 없음)")),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
// 스톡에서 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++;
|
||||
final fromCol = tableau[fromColIndex];
|
||||
fromCol.removeRange(fromCol.length - movingCards.length, fromCol.length);
|
||||
if (fromCol.isNotEmpty && !fromCol.last.isFaceUp) {
|
||||
fromCol.last.isFaceUp = true;
|
||||
}
|
||||
tableau[toColIndex].addAll(movingCards);
|
||||
_checkCompleteSet(toColIndex);
|
||||
});
|
||||
}
|
||||
|
||||
void _checkCompleteSet(int colIndex) {
|
||||
final col = tableau[colIndex];
|
||||
if (col.length < 13) return;
|
||||
|
||||
List<SpiderCard> last13 = col.sublist(col.length - 13);
|
||||
final targetSuit = last13.first.suit;
|
||||
bool isComplete = true;
|
||||
for (int i = 0; i < 13; i++) {
|
||||
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);
|
||||
if (col.isNotEmpty && !col.last.isFaceUp) col.last.isFaceUp = true;
|
||||
});
|
||||
|
||||
SoundManager().playSfx(SoundKey.correct);
|
||||
NetworkManager().sendMessage({'type': 'ATTACK', 'senderName': NetworkManager().me.nickname});
|
||||
|
||||
if (foundation.length >= 8) {
|
||||
final winPayload = {'type': 'GAME_WIN', 'winnerName': NetworkManager().me.nickname};
|
||||
NetworkManager().sendMessage(winPayload);
|
||||
_showGameOverDialog(NetworkManager().me.nickname);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _dealFromStock() {
|
||||
if (stock.isEmpty) return;
|
||||
setState(() {
|
||||
// [수정] 남은 카드가 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;
|
||||
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 님이 먼저 완료했습니다."),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () { Navigator.pop(context); Navigator.pop(context); },
|
||||
child: const Text("나가기"),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final size = MediaQuery.of(context).size;
|
||||
final cardWidth = (size.width - 20) / 10;
|
||||
final cardHeight = cardWidth * 1.4;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.green[800],
|
||||
appBar: AppBar(
|
||||
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: [
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: List.generate(10, (colIndex) {
|
||||
return Positioned(
|
||||
left: colIndex * cardWidth + 10,
|
||||
top: 10,
|
||||
child: _buildTableauColumn(colIndex, cardWidth, cardHeight),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
height: cardHeight + 20,
|
||||
color: Colors.green[900],
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
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),
|
||||
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)))
|
||||
: 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)),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTableauColumn(int colIndex, double width, double height) {
|
||||
final pile = tableau[colIndex];
|
||||
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;
|
||||
|
||||
final SpiderCard topMoving = movingCards.first;
|
||||
final SpiderCard? targetBottom = pile.isEmpty ? null : pile.last;
|
||||
return _canMove(topMoving, targetBottom);
|
||||
},
|
||||
onAccept: (data) {
|
||||
_onCardDrop(data['cards'], data['fromIndex'], colIndex);
|
||||
},
|
||||
builder: (context, candidateData, rejectedData) {
|
||||
return SizedBox(
|
||||
width: width,
|
||||
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;
|
||||
|
||||
bool isDraggable = card.isFaceUp;
|
||||
if (isDraggable && i < pile.length - 1) {
|
||||
for (int k = i; k < pile.length - 1; k++) {
|
||||
if (pile[k].suit != pile[k+1].suit || pile[k].rank != pile[k+1].rank + 1) {
|
||||
isDraggable = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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>>(
|
||||
data: {'cards': movingCards, 'fromIndex': colIndex},
|
||||
feedback: Material(
|
||||
color: Colors.transparent,
|
||||
child: Column(
|
||||
children: movingCards.map((c) => SpiderCardWidget(card: c, width: width, height: height)).toList(),
|
||||
),
|
||||
),
|
||||
childWhenDragging: const SizedBox(),
|
||||
child: cardWidget,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return Positioned(top: offset, child: cardWidget);
|
||||
}
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
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/sudoku_game_dto.dart';
|
||||
import '../widgets/sudoku_widgets.dart';
|
||||
|
||||
class SudokuMultiGame extends BaseGame {
|
||||
@override
|
||||
String get id => "sudoku_battle";
|
||||
@override
|
||||
String get name => "스도쿠 배틀";
|
||||
@override
|
||||
String get description => "먼저 완성하는 사람이 승리! 줄을 맞추면 상대를 방해합니다.";
|
||||
|
||||
final StreamController<SudokuGameDto?> _puzzleStreamController = StreamController<SudokuGameDto?>.broadcast();
|
||||
|
||||
Future<SudokuGameDto> _fetchPuzzleFromApi(String difficulty) async {
|
||||
const String baseUrl = "https://lunaticbum.kr";
|
||||
try {
|
||||
final response = await http.get(
|
||||
Uri.parse('$baseUrl/puzzle/sudoku/start?difficulty=$difficulty'),
|
||||
).timeout(const Duration(seconds: 5));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(utf8.decode(response.bodyBytes));
|
||||
return SudokuGameDto.fromJson(data);
|
||||
}
|
||||
} catch (e) {
|
||||
print("API 호출 실패, 더미 데이터 사용: $e");
|
||||
}
|
||||
|
||||
return SudokuGameDto(
|
||||
puzzleId: 0,
|
||||
blockSize: 2,
|
||||
question: "0034340000430300",
|
||||
solution: "1234341221434321",
|
||||
);
|
||||
}
|
||||
|
||||
@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()};
|
||||
onMessageReceived(NetworkManager().me.id, payload);
|
||||
NetworkManager().sendMessage(payload);
|
||||
}
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void onDispose() {
|
||||
_puzzleStreamController.close();
|
||||
super.onDispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget buildHostView(BuildContext context) => _buildGameScreen(context);
|
||||
@override
|
||||
Widget buildGuestView(BuildContext context) => _buildGameScreen(context);
|
||||
|
||||
Widget _buildGameScreen(BuildContext context) {
|
||||
return StreamBuilder<SudokuGameDto?>(
|
||||
stream: _puzzleStreamController.stream,
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) {
|
||||
return const Scaffold(
|
||||
body: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
return SudokuBattleScreen(gameData: snapshot.data!, gameInstance: this);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SudokuBattleScreen extends StatefulWidget {
|
||||
final SudokuGameDto gameData;
|
||||
final SudokuMultiGame gameInstance;
|
||||
|
||||
const SudokuBattleScreen({super.key, required this.gameData, required this.gameInstance});
|
||||
|
||||
@override
|
||||
State<SudokuBattleScreen> createState() => _SudokuBattleScreenState();
|
||||
}
|
||||
|
||||
class _SudokuBattleScreenState extends State<SudokuBattleScreen> {
|
||||
late List<int> puzzleCells;
|
||||
late List<int> originalCells;
|
||||
late List<int> solutionCells;
|
||||
late int blockSize;
|
||||
late int gridSize;
|
||||
|
||||
int? selectedIndex;
|
||||
int? selectedNumberPad;
|
||||
Set<int> incorrectCells = {};
|
||||
final Set<String> _completedGroups = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
blockSize = widget.gameData.blockSize;
|
||||
gridSize = blockSize * blockSize;
|
||||
|
||||
puzzleCells = widget.gameData.question.split('').map(_charToInt).toList();
|
||||
originalCells = List.from(puzzleCells);
|
||||
solutionCells = widget.gameData.solution.split('').map(_charToInt).toList();
|
||||
|
||||
// [핵심 수정] 공격 이벤트 리스너 등록
|
||||
NetworkManager().messageStream.listen(_handleNetworkMessage);
|
||||
}
|
||||
|
||||
int _charToInt(String char) {
|
||||
if (char == '0') return 0;
|
||||
return int.tryParse(char) ?? 0;
|
||||
}
|
||||
|
||||
void _handleNetworkMessage(Map<String, dynamic> payload) {
|
||||
if (!mounted) return;
|
||||
|
||||
if (payload['type'] == 'ATTACK') {
|
||||
final attackerName = payload['senderName'];
|
||||
_applyAttack(attackerName);
|
||||
} else if (payload['type'] == 'GAME_WIN') {
|
||||
final winnerName = payload['winnerName'];
|
||||
_showGameOverDialog(winnerName);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
if (myInputs.isNotEmpty) {
|
||||
final randomIdx = myInputs[Random().nextInt(myInputs.length)];
|
||||
setState(() {
|
||||
puzzleCells[randomIdx] = 0; // 숫자 지움
|
||||
});
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text("⚔️ $attackerName님의 공격! 숫자가 지워졌습니다!"),
|
||||
backgroundColor: Colors.redAccent,
|
||||
duration: const Duration(milliseconds: 1500),
|
||||
),
|
||||
);
|
||||
SoundManager().playSfx(SoundKey.wrong);
|
||||
}
|
||||
}
|
||||
|
||||
void _onNumberTapped(int number) {
|
||||
if (selectedIndex == null) return;
|
||||
if (originalCells[selectedIndex!] != 0) return;
|
||||
|
||||
setState(() {
|
||||
puzzleCells[selectedIndex!] = number;
|
||||
|
||||
if (number != solutionCells[selectedIndex!]) {
|
||||
incorrectCells.add(selectedIndex!);
|
||||
} else {
|
||||
incorrectCells.remove(selectedIndex!);
|
||||
_checkAttackTrigger(selectedIndex!);
|
||||
_checkWinCondition();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _checkAttackTrigger(int index) {
|
||||
int row = index ~/ gridSize;
|
||||
int col = index % gridSize;
|
||||
int blockRow = (row ~/ blockSize) * blockSize;
|
||||
int blockCol = (col ~/ blockSize) * blockSize;
|
||||
|
||||
if (_isGroupComplete(getRowIndices(row), "ROW_$row")) _sendAttack();
|
||||
if (_isGroupComplete(getColIndices(col), "COL_$col")) _sendAttack();
|
||||
if (_isGroupComplete(getBlockIndices(blockRow, blockCol), "BLOCK_${blockRow}_$blockCol")) _sendAttack();
|
||||
}
|
||||
|
||||
bool _isGroupComplete(List<int> indices, String groupKey) {
|
||||
if (_completedGroups.contains(groupKey)) return false;
|
||||
for (int idx in indices) {
|
||||
if (puzzleCells[idx] == 0 || puzzleCells[idx] != solutionCells[idx]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
_completedGroups.add(groupKey);
|
||||
return true;
|
||||
}
|
||||
|
||||
void _sendAttack() {
|
||||
final payload = {
|
||||
'type': 'ATTACK',
|
||||
'senderName': NetworkManager().me.nickname,
|
||||
};
|
||||
NetworkManager().sendMessage(payload);
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text("🚀 공격 발사!"),
|
||||
backgroundColor: Colors.blueAccent,
|
||||
duration: Duration(milliseconds: 1000),
|
||||
),
|
||||
);
|
||||
SoundManager().playSfx(SoundKey.correct);
|
||||
}
|
||||
|
||||
void _checkWinCondition() {
|
||||
if (!puzzleCells.contains(0) && incorrectCells.isEmpty) {
|
||||
final payload = {'type': 'GAME_WIN', 'winnerName': NetworkManager().me.nickname};
|
||||
NetworkManager().sendMessage(payload);
|
||||
_showGameOverDialog(NetworkManager().me.nickname);
|
||||
}
|
||||
}
|
||||
|
||||
void _showGameOverDialog(String winnerName) {
|
||||
bool isMe = winnerName == NetworkManager().me.nickname;
|
||||
if (isMe) SoundManager().playSfx(SoundKey.win);
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(isMe ? "승리! 🎉" : "패배 😭"),
|
||||
content: Text(isMe ? "축하합니다! $winnerName 님이 승리했습니다." : "$winnerName 님이 먼저 퍼즐을 완성했습니다."),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: const Text("나가기"),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<int> getRowIndices(int row) => List.generate(gridSize, (i) => row * gridSize + i);
|
||||
List<int> getColIndices(int col) => List.generate(gridSize, (i) => i * gridSize + col);
|
||||
List<int> getBlockIndices(int startRow, int startCol) {
|
||||
List<int> indices = [];
|
||||
for (int r = 0; r < blockSize; r++) {
|
||||
for (int c = 0; c < blockSize; c++) {
|
||||
indices.add((startRow + r) * gridSize + (startCol + c));
|
||||
}
|
||||
}
|
||||
return indices;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final Map<int, int> numberCounts = {};
|
||||
for (int i = 1; i <= gridSize; i++) numberCounts[i] = 0;
|
||||
for (int cell in puzzleCells) {
|
||||
if (cell != 0) numberCounts[cell] = (numberCounts[cell] ?? 0) + 1;
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text("스도쿠 배틀"),
|
||||
automaticallyImplyLeading: false,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.exit_to_app),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
)
|
||||
],
|
||||
),
|
||||
// 하단 배너 광고
|
||||
bottomNavigationBar: const SafeArea(child: AdBannerWidget()),
|
||||
body: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 10),
|
||||
Padding(
|
||||
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;
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Container(
|
||||
padding: const EdgeInsets.fromLTRB(16, 10, 16, 30),
|
||||
height: 240,
|
||||
color: Colors.grey[50],
|
||||
child: NumberPad(
|
||||
blockSize: blockSize,
|
||||
numberCounts: numberCounts,
|
||||
selectedNumber: selectedNumberPad,
|
||||
onNumberTapped: (num) {
|
||||
setState(() {
|
||||
if (selectedIndex != null) {
|
||||
_onNumberTapped(num);
|
||||
selectedIndex = null;
|
||||
selectedNumberPad = null;
|
||||
} else {
|
||||
selectedNumberPad = (selectedNumberPad == num) ? null : num;
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// [보조] 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 }
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:playwith_core/playwith_core.dart';
|
||||
|
||||
class TapBattleGame extends BaseGame {
|
||||
@override
|
||||
String get id => "tap_battle";
|
||||
@override
|
||||
String get name => "터치 배틀";
|
||||
@override
|
||||
String get description => "빠르게 눌러서 상대를 밀어내세요!";
|
||||
|
||||
// 0: Red(Host), 1: Blue(Guest)
|
||||
int? _myTeam;
|
||||
|
||||
@override
|
||||
void onStart() {
|
||||
super.onStart();
|
||||
_myTeam = NetworkManager().role == NetworkRole.host ? 0 : 1;
|
||||
}
|
||||
|
||||
@override
|
||||
void onMessageReceived(String senderId, Map<String, dynamic> payload) {
|
||||
// UI에서 처리
|
||||
}
|
||||
|
||||
@override
|
||||
Widget buildHostView(BuildContext context) => TapBattleScreen(myTeam: 0, gameInstance: this);
|
||||
@override
|
||||
Widget buildGuestView(BuildContext context) => TapBattleScreen(myTeam: 1, gameInstance: this);
|
||||
}
|
||||
|
||||
class TapBattleScreen extends StatefulWidget {
|
||||
final int myTeam;
|
||||
final TapBattleGame gameInstance;
|
||||
|
||||
const TapBattleScreen({super.key, required this.myTeam, required this.gameInstance});
|
||||
|
||||
@override
|
||||
State<TapBattleScreen> createState() => _TapBattleScreenState();
|
||||
}
|
||||
|
||||
class _TapBattleScreenState extends State<TapBattleScreen> {
|
||||
// 점수 범위: -50 ~ 50 (0이 중앙)
|
||||
// Red(Host)가 누르면 +, Blue(Guest)가 누르면 -
|
||||
int score = 0;
|
||||
static const int maxScore = 50;
|
||||
bool isGameOver = false;
|
||||
|
||||
// 네트워크 과부하 방지를 위한 스로틀링
|
||||
Timer? _syncTimer;
|
||||
int _localClicks = 0; // 전송 안 된 클릭 수
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
NetworkManager().messageStream.listen(_handleMessage);
|
||||
|
||||
// 0.2초마다 모아서 전송
|
||||
_syncTimer = Timer.periodic(const Duration(milliseconds: 200), (timer) {
|
||||
if (_localClicks != 0 && !isGameOver) {
|
||||
NetworkManager().sendMessage({
|
||||
'type': 'CLICK',
|
||||
'amount': _localClicks,
|
||||
'senderTeam': widget.myTeam
|
||||
});
|
||||
_localClicks = 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_syncTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleMessage(Map<String, dynamic> payload) {
|
||||
if (!mounted || isGameOver) return;
|
||||
|
||||
if (payload['type'] == 'CLICK') {
|
||||
int amount = payload['amount'];
|
||||
int team = payload['senderTeam'];
|
||||
|
||||
setState(() {
|
||||
if (team == 0) score += amount;
|
||||
else score -= amount;
|
||||
_checkWin();
|
||||
});
|
||||
} else if (payload['type'] == 'GAME_OVER') {
|
||||
_finishGame(payload['winner']);
|
||||
}
|
||||
}
|
||||
|
||||
void _onTap() {
|
||||
if (isGameOver) return;
|
||||
|
||||
setState(() {
|
||||
if (widget.myTeam == 0) score++;
|
||||
else score--;
|
||||
_localClicks++; // 전송 큐에 적립
|
||||
|
||||
// 로컬에서도 즉시 승리 체크 (반응성)
|
||||
_checkWin();
|
||||
});
|
||||
SoundManager().playSfx(SoundKey.click);
|
||||
}
|
||||
|
||||
void _checkWin() {
|
||||
if (score >= maxScore) {
|
||||
// Red 승리
|
||||
_sendGameOver(0);
|
||||
} else if (score <= -maxScore) {
|
||||
// Blue 승리
|
||||
_sendGameOver(1);
|
||||
}
|
||||
}
|
||||
|
||||
void _sendGameOver(int winnerTeam) {
|
||||
if (isGameOver) return;
|
||||
isGameOver = true;
|
||||
NetworkManager().sendMessage({'type': 'GAME_OVER', 'winner': winnerTeam});
|
||||
_finishGame(winnerTeam);
|
||||
}
|
||||
|
||||
void _finishGame(int winnerTeam) {
|
||||
setState(() { isGameOver = true; });
|
||||
String msg = (winnerTeam == 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("나가기"),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 게이지 비율 계산 (0.0 ~ 1.0)
|
||||
// score -50 => 0.0 (Blue Win)
|
||||
// score 0 => 0.5
|
||||
// score 50 => 1.0 (Red Win)
|
||||
double progress = (score + maxScore) / (maxScore * 2);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text("터치 배틀!"), centerTitle: true),
|
||||
body: Column(
|
||||
children: [
|
||||
// 게이지 바
|
||||
Container(
|
||||
height: 60,
|
||||
width: double.infinity,
|
||||
color: Colors.grey[300],
|
||||
child: Row(
|
||||
children: [
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 100),
|
||||
width: MediaQuery.of(context).size.width * progress,
|
||||
height: 60,
|
||||
color: Colors.redAccent, // Host
|
||||
child: Align(alignment: Alignment.centerLeft, child: Padding(padding: EdgeInsets.all(8), child: Text("RED", style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold)))),
|
||||
),
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 60,
|
||||
color: Colors.blueAccent, // Guest
|
||||
child: Align(alignment: Alignment.centerRight, child: Padding(padding: EdgeInsets.all(8), child: Text("BLUE", style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold)))),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
Text(widget.myTeam == 0 ? "당신은 RED팀!" : "당신은 BLUE팀!", style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
||||
const Text("버튼을 빠르게 연타해서 상대를 밀어내세요!", style: TextStyle(color: Colors.grey)),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
// 터치 버튼
|
||||
GestureDetector(
|
||||
onTapDown: (_) => _onTap(),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.all(30),
|
||||
width: 200,
|
||||
height: 200,
|
||||
decoration: BoxDecoration(
|
||||
color: widget.myTeam == 0 ? Colors.red : Colors.blue,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(color: Colors.black.withOpacity(0.3), blurRadius: 10, offset: const Offset(0, 5))
|
||||
]
|
||||
),
|
||||
child: const Center(
|
||||
child: Text("TAP!", style: TextStyle(color: Colors.white, fontSize: 40, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const Spacer(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:playwith_core/playwith_core.dart';
|
||||
|
||||
class YutnoriGame extends BaseGame {
|
||||
@override
|
||||
String get id => "yutnori";
|
||||
@override
|
||||
String get name => "윷놀이";
|
||||
@override
|
||||
String get description => "가족과 함께하는 민속놀이";
|
||||
|
||||
// [수정] 필수 메서드 구현 추가
|
||||
@override
|
||||
void onMessageReceived(String senderId, Map<String, dynamic> payload) {
|
||||
// BaseGame의 기본 핸들러입니다.
|
||||
// 실제 게임 로직은 YutnoriScreen 내부의 NetworkManager 리스너에서 처리하므로
|
||||
// 여기서는 비워두어도 무방합니다.
|
||||
}
|
||||
|
||||
@override
|
||||
Widget buildHostView(BuildContext context) => YutnoriScreen(myTeam: 0, gameInstance: this); // 0: Red
|
||||
@override
|
||||
Widget buildGuestView(BuildContext context) => YutnoriScreen(myTeam: 1, gameInstance: this); // 1: Blue
|
||||
}
|
||||
|
||||
class YutnoriScreen extends StatefulWidget {
|
||||
final int myTeam; // 0: Red(Host), 1: Blue(Guest)
|
||||
final YutnoriGame gameInstance;
|
||||
|
||||
const YutnoriScreen({super.key, required this.myTeam, required this.gameInstance});
|
||||
|
||||
@override
|
||||
State<YutnoriScreen> createState() => _YutnoriScreenState();
|
||||
}
|
||||
|
||||
class _YutnoriScreenState extends State<YutnoriScreen> {
|
||||
// 게임 상태
|
||||
int currentTurn = 0; // 0: Red, 1: Blue
|
||||
List<int> yutResultQueue = []; // 던진 윷 결과 저장 (윷/모 나오면 계속 던짐)
|
||||
bool canThrow = true; // 던질 수 있는 상태인가?
|
||||
|
||||
// 말 위치 (각 팀 4개)
|
||||
// 0: 시작 전, 1~20: 바깥 트랙, 21~25: 대각선1, 26~30: 대각선2, 99: 골인
|
||||
List<List<int>> tokens = [
|
||||
[0, 0, 0, 0], // Team 0 (Red)
|
||||
[0, 0, 0, 0] // Team 1 (Blue)
|
||||
];
|
||||
|
||||
String infoMessage = "게임을 시작합니다!";
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
NetworkManager().messageStream.listen(_handleMessage);
|
||||
}
|
||||
|
||||
void _handleMessage(Map<String, dynamic> payload) {
|
||||
if (!mounted) return;
|
||||
if (payload['type'] == 'THROW') {
|
||||
final int result = payload['result'];
|
||||
final String msg = payload['message'];
|
||||
setState(() {
|
||||
yutResultQueue.add(result);
|
||||
infoMessage = msg;
|
||||
// 윷(4)이나 모(5)가 아니면 턴 넘기기 대기 (말 이동 후 넘김)
|
||||
if (result < 4) canThrow = false;
|
||||
});
|
||||
} else if (payload['type'] == 'MOVE') {
|
||||
final int team = payload['team'];
|
||||
final int tokenIdx = payload['tokenIdx'];
|
||||
final int targetPos = payload['targetPos'];
|
||||
final bool extraTurn = payload['extraTurn'];
|
||||
|
||||
setState(() {
|
||||
// 말 이동 및 잡기 처리
|
||||
_executeMove(team, tokenIdx, targetPos);
|
||||
|
||||
// 사용한 윷 결과 제거 (FIFO)
|
||||
if (yutResultQueue.isNotEmpty) yutResultQueue.removeAt(0);
|
||||
|
||||
if (extraTurn) {
|
||||
infoMessage = "한 번 더 하세요!";
|
||||
currentTurn = team;
|
||||
canThrow = true;
|
||||
} else if (yutResultQueue.isNotEmpty) {
|
||||
infoMessage = "남은 패로 이동하세요.";
|
||||
currentTurn = team;
|
||||
canThrow = false;
|
||||
} else {
|
||||
// 턴 종료
|
||||
currentTurn = 1 - currentTurn;
|
||||
canThrow = true;
|
||||
infoMessage = "${currentTurn == 0 ? 'Red' : 'Blue'} 팀 차례입니다.";
|
||||
}
|
||||
});
|
||||
} else if (payload['type'] == 'WIN') {
|
||||
_showWinDialog(payload['team']);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 로직: 윷 던지기
|
||||
// ---------------------------------------------------------------------------
|
||||
void _onThrowYut() {
|
||||
if (currentTurn != widget.myTeam) return;
|
||||
if (!canThrow) return;
|
||||
|
||||
// 확률 기반 윷 던지기 (도:1, 개:2, 걸:3, 윷:4, 모:5)
|
||||
// 단순화된 확률: 개(35%), 걸(30%), 도(15%), 윷(10%), 모(10%)
|
||||
int rand = Random().nextInt(100);
|
||||
int result = 1;
|
||||
String name = "도";
|
||||
if (rand < 35) { result = 2; name = "개"; }
|
||||
else if (rand < 65) { result = 3; name = "걸"; }
|
||||
else if (rand < 80) { result = 1; name = "도"; }
|
||||
else if (rand < 90) { result = 4; name = "윷"; }
|
||||
else { result = 5; name = "모"; }
|
||||
|
||||
final msg = "${widget.myTeam == 0 ? 'Red' : 'Blue'}팀: $name!";
|
||||
|
||||
NetworkManager().sendMessage({
|
||||
'type': 'THROW',
|
||||
'result': result,
|
||||
'message': msg
|
||||
});
|
||||
|
||||
// 로컬 반영
|
||||
setState(() {
|
||||
yutResultQueue.add(result);
|
||||
infoMessage = msg;
|
||||
if (result < 4) canThrow = false; // 윷/모 아니면 던지기 끝
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 로직: 말 이동
|
||||
// ---------------------------------------------------------------------------
|
||||
void _onTokenTap(int tokenIdx) {
|
||||
if (currentTurn != widget.myTeam) return;
|
||||
if (yutResultQueue.isEmpty) return; // 이동할 패가 없음
|
||||
|
||||
// 대기 중인 첫 번째 패 사용
|
||||
int moveAmount = yutResultQueue.first;
|
||||
int currentPos = tokens[widget.myTeam][tokenIdx];
|
||||
|
||||
if (currentPos == 99) return; // 이미 골인한 말
|
||||
|
||||
// 이동 경로 계산
|
||||
int nextPos = _calculateNextPos(currentPos, moveAmount);
|
||||
|
||||
// 잡기 여부 확인 (상대방 말이 있는가?)
|
||||
bool catchOpponent = false;
|
||||
int opponentTeam = 1 - widget.myTeam;
|
||||
if (nextPos != 99) { // 골인이 아닐 때만
|
||||
for (int i = 0; i < 4; i++) {
|
||||
if (tokens[opponentTeam][i] == nextPos) {
|
||||
catchOpponent = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 윷/모가 나왔거나 상대를 잡았으면 한 번 더
|
||||
bool extraTurn = (moveAmount >= 4) || catchOpponent;
|
||||
|
||||
// 이동 실행 및 전송
|
||||
_executeMove(widget.myTeam, tokenIdx, nextPos);
|
||||
|
||||
NetworkManager().sendMessage({
|
||||
'type': 'MOVE',
|
||||
'team': widget.myTeam,
|
||||
'tokenIdx': tokenIdx,
|
||||
'targetPos': nextPos,
|
||||
'extraTurn': extraTurn
|
||||
});
|
||||
|
||||
// 로컬 상태 업데이트 (전송 후 즉시 반영)
|
||||
setState(() {
|
||||
yutResultQueue.removeAt(0);
|
||||
if (extraTurn) {
|
||||
infoMessage = catchOpponent ? "잡았다! 한 번 더!" : "한 번 더!";
|
||||
canThrow = true;
|
||||
} else if (yutResultQueue.isNotEmpty) {
|
||||
infoMessage = "남은 패로 이동하세요.";
|
||||
canThrow = false;
|
||||
} else {
|
||||
currentTurn = 1 - currentTurn;
|
||||
canThrow = true;
|
||||
infoMessage = "${currentTurn == 0 ? 'Red' : 'Blue'} 팀 차례입니다.";
|
||||
}
|
||||
});
|
||||
|
||||
_checkWin();
|
||||
}
|
||||
|
||||
void _executeMove(int team, int idx, int target) {
|
||||
// 상대방 말 잡기 구현
|
||||
if (target != 99) {
|
||||
int opponent = 1 - team;
|
||||
for (int i = 0; i < 4; i++) {
|
||||
if (tokens[opponent][i] == target) {
|
||||
tokens[opponent][i] = 0; // 시작점으로 보냄
|
||||
}
|
||||
}
|
||||
}
|
||||
tokens[team][idx] = target;
|
||||
}
|
||||
|
||||
// 이동 경로 하드코딩
|
||||
int _calculateNextPos(int current, int step) {
|
||||
if (current == 0) return step;
|
||||
|
||||
int next = current;
|
||||
for (int i = 0; i < step; i++) {
|
||||
if (next == 99) break; // 이미 골인
|
||||
|
||||
// 특수 분기점
|
||||
if (next == 5) next = 21; // 우하단 코너 -> 대각선 진입
|
||||
else if (next == 10) next = 26; // 좌하단 코너 -> 대각선 진입
|
||||
else if (next == 23) next = 24; // 대각선1 -> 중앙
|
||||
else if (next == 24) next = 28; // 중앙 -> 수직상승 (단순화: 중앙에선 무조건 출구방향)
|
||||
else if (next == 25) next = 15; // 대각선1 끝 -> 외곽
|
||||
else if (next == 27) next = 24; // 대각선2 -> 중앙
|
||||
else if (next == 30) next = 20; // 대각선2 끝 -> 외곽 (사실상 1이 됨)
|
||||
else if (next == 20) next = 99; // 골인
|
||||
else if (next == 29) next = 20; // 중앙직진 -> 외곽
|
||||
else next++;
|
||||
}
|
||||
|
||||
// 범위 초과 보정 (외곽 돌 때)
|
||||
if (next > 20 && next < 21) next = 99; // 20 넘어가면 골인
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
void _checkWin() {
|
||||
if (tokens[widget.myTeam].every((pos) => pos == 99)) {
|
||||
NetworkManager().sendMessage({'type': 'WIN', 'team': widget.myTeam});
|
||||
_showWinDialog(widget.myTeam);
|
||||
}
|
||||
}
|
||||
|
||||
void _showWinDialog(int winnerTeam) {
|
||||
bool isMe = winnerTeam == widget.myTeam;
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => AlertDialog(
|
||||
title: Text(isMe ? "승리! 🎉" : "패배 😭"),
|
||||
content: Text(isMe ? "축하합니다! 모든 말이 들어왔습니다." : "상대방이 먼저 들어왔습니다."),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () { Navigator.pop(context); Navigator.pop(context); },
|
||||
child: const Text("나가기"),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UI
|
||||
// ---------------------------------------------------------------------------
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bool myTurn = currentTurn == widget.myTeam;
|
||||
final Color teamColor = widget.myTeam == 0 ? Colors.redAccent : Colors.blueAccent;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text("윷놀이"),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// 상단 정보
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
color: myTurn ? teamColor.withOpacity(0.1) : Colors.grey[200],
|
||||
width: double.infinity,
|
||||
child: Column(
|
||||
children: [
|
||||
Text(infoMessage, style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: myTurn ? teamColor : Colors.black)),
|
||||
if (yutResultQueue.isNotEmpty)
|
||||
Text("나온 패: ${_yutName(yutResultQueue)}", style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 윷판 (말판)
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1.0,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return Stack(
|
||||
children: [
|
||||
// 1. 말판 배경 그림
|
||||
CustomPaint(
|
||||
size: Size(constraints.maxWidth, constraints.maxWidth),
|
||||
painter: YutBoardPainter(),
|
||||
),
|
||||
// 2. 말 배치
|
||||
..._buildTokens(constraints.maxWidth, 0, Colors.red),
|
||||
..._buildTokens(constraints.maxWidth, 1, Colors.blue),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 하단 컨트롤 (윷 던지기 버튼)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
height: 60,
|
||||
child: ElevatedButton(
|
||||
onPressed: (myTurn && canThrow) ? _onThrowYut : null,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: teamColor,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
child: Text(canThrow ? "윷 던지기!" : "말을 움직이세요"),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildTokens(double boardSize, int team, Color color) {
|
||||
List<Widget> widgets = [];
|
||||
// 말이 겹쳐있으면 약간씩 빗겨서 표시
|
||||
Map<int, int> posCount = {};
|
||||
|
||||
for (int i = 0; i < 4; i++) {
|
||||
int pos = tokens[team][i];
|
||||
if (pos == 99) continue; // 골인한 말은 안 그림
|
||||
|
||||
// 위치 카운트 (겹침 처리)
|
||||
int count = posCount[pos] ?? 0;
|
||||
posCount[pos] = count + 1;
|
||||
|
||||
Offset offset = _getPosOffset(pos, boardSize);
|
||||
|
||||
// 겹칠 경우 오프셋 적용
|
||||
double dx = offset.dx + (count * 5);
|
||||
double dy = offset.dy - (count * 5);
|
||||
|
||||
// 대기 상태(0)는 하단에 별도 배치
|
||||
if (pos == 0) {
|
||||
double startX = team == 0 ? 20 : boardSize - 40;
|
||||
dx = startX + (i%2 * 15);
|
||||
dy = boardSize - 20 - (i~/2 * 15);
|
||||
}
|
||||
|
||||
widgets.add(Positioned(
|
||||
left: dx - 12, // 중심점 보정
|
||||
top: dy - 12,
|
||||
child: GestureDetector(
|
||||
onTap: () => _onTokenTap(i),
|
||||
child: Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 2),
|
||||
boxShadow: const [BoxShadow(color: Colors.black38, blurRadius: 2, offset: Offset(1,1))]
|
||||
),
|
||||
child: Center(child: Text("${i+1}", style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold))),
|
||||
),
|
||||
),
|
||||
));
|
||||
}
|
||||
return widgets;
|
||||
}
|
||||
|
||||
String _yutName(List<int> queue) {
|
||||
return queue.map((v) {
|
||||
switch(v) {
|
||||
case 1: return "도";
|
||||
case 2: return "개";
|
||||
case 3: return "걸";
|
||||
case 4: return "윷";
|
||||
case 5: return "모";
|
||||
default: return "";
|
||||
}
|
||||
}).join(", ");
|
||||
}
|
||||
|
||||
// 말판 좌표 계산 (하드코딩된 좌표 매핑)
|
||||
Offset _getPosOffset(int pos, double size) {
|
||||
double padding = 40.0;
|
||||
double w = size - padding * 2;
|
||||
double step = w / 5;
|
||||
double startX = size - padding;
|
||||
double startY = size - padding;
|
||||
|
||||
if (pos >= 1 && pos <= 5) return Offset(startX, startY - (pos * step)); // 우측변 ↑
|
||||
if (pos >= 6 && pos <= 10) return Offset(startX - ((pos - 5) * step), padding); // 상단변 ←
|
||||
if (pos >= 11 && pos <= 15) return Offset(padding, padding + ((pos - 10) * step)); // 좌측변 ↓
|
||||
if (pos >= 16 && pos <= 20) return Offset(padding + ((pos - 15) * step), startY); // 하단변 →
|
||||
|
||||
// 대각선 1 (5 -> 21...)
|
||||
if (pos == 21) return Offset(startX - step, padding + step);
|
||||
if (pos == 22) return Offset(startX - step*2, padding + step*2);
|
||||
if (pos == 23) return Offset(startX - step*3, padding + step*3); // 중앙 직전
|
||||
|
||||
// 중앙
|
||||
if (pos == 24) return Offset(size/2, size/2);
|
||||
|
||||
// 대각선 2 (10 -> 26...)
|
||||
if (pos == 26) return Offset(padding + step, padding + step);
|
||||
if (pos == 27) return Offset(padding + step*2, padding + step*2);
|
||||
|
||||
// 중앙 이후
|
||||
if (pos == 28) return Offset(size/2, size/2 + step); // 중앙 -> 아래
|
||||
if (pos == 29) return Offset(size/2, size/2 + step*2); // 중앙 -> 아래
|
||||
|
||||
return Offset(size - padding, size - padding); // 기본값 (출발점)
|
||||
}
|
||||
}
|
||||
|
||||
// 말판 그리기 (원형 + 대각선)
|
||||
class YutBoardPainter extends CustomPainter {
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()..color = Colors.black..style = PaintingStyle.stroke..strokeWidth = 2.0;
|
||||
final dotPaint = Paint()..color = Colors.black12..style = PaintingStyle.fill;
|
||||
final bigDotPaint = Paint()..color = Colors.black26..style = PaintingStyle.fill;
|
||||
|
||||
double padding = 40.0;
|
||||
double w = size.width - padding * 2;
|
||||
double step = w / 5;
|
||||
|
||||
// 대각선
|
||||
canvas.drawLine(Offset(padding, padding), Offset(size.width - padding, size.height - padding), paint);
|
||||
canvas.drawLine(Offset(size.width - padding, padding), Offset(padding, size.height - padding), paint);
|
||||
|
||||
// 점 그리기
|
||||
List<Offset> dots = [];
|
||||
|
||||
// 외곽 20개
|
||||
for (int i=0; i<=5; i++) dots.add(Offset(size.width - padding, size.height - padding - (i*step)));
|
||||
for (int i=1; i<=5; i++) dots.add(Offset(size.width - padding - (i*step), padding));
|
||||
for (int i=1; i<=5; i++) dots.add(Offset(padding, padding + (i*step)));
|
||||
for (int i=1; i<5; i++) dots.add(Offset(padding + (i*step), size.height - padding));
|
||||
|
||||
// 대각선
|
||||
dots.add(Offset(size.width/2, size.height/2)); // 중앙
|
||||
|
||||
for (var dot in dots) {
|
||||
canvas.drawCircle(dot, 8.0, dotPaint);
|
||||
canvas.drawCircle(dot, 8.0, paint);
|
||||
}
|
||||
|
||||
// 코너 강조
|
||||
canvas.drawCircle(Offset(size.width - padding, size.height - padding), 12, bigDotPaint); // 출발
|
||||
canvas.drawCircle(Offset(size.width - padding, padding), 12, bigDotPaint);
|
||||
canvas.drawCircle(Offset(padding, padding), 12, bigDotPaint);
|
||||
canvas.drawCircle(Offset(size.width/2, size.height/2), 12, bigDotPaint); // 중앙
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/widgets.dart'; // AppLifecycleState
|
||||
import '../network/network_manager.dart';
|
||||
import '../model/play_packet.dart';
|
||||
import 'notification_manager.dart'; // [New]
|
||||
|
||||
class ChatMessage {
|
||||
final String senderId; // [추가] 유저 조회용
|
||||
final String senderName;
|
||||
final String text;
|
||||
final bool isMe;
|
||||
final DateTime timestamp;
|
||||
|
||||
ChatMessage({
|
||||
required this.senderId, // 생성자 추가
|
||||
required this.senderName,
|
||||
required this.text,
|
||||
required this.isMe,
|
||||
}) : timestamp = DateTime.now();
|
||||
}
|
||||
|
||||
class GlobalChatManager {
|
||||
static final GlobalChatManager _instance = GlobalChatManager._internal();
|
||||
factory GlobalChatManager() => _instance;
|
||||
GlobalChatManager._internal();
|
||||
|
||||
final _messageController = StreamController<List<ChatMessage>>.broadcast();
|
||||
Stream<List<ChatMessage>> get messageStream => _messageController.stream;
|
||||
|
||||
final List<ChatMessage> _messages = [];
|
||||
|
||||
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(
|
||||
senderId: packet.senderId, // ID 저장
|
||||
senderName: senderName,
|
||||
text: text,
|
||||
isMe: isMe,
|
||||
);
|
||||
_messages.add(chatMsg);
|
||||
|
||||
_messageController.add(List.from(_messages));
|
||||
|
||||
if (!isMe) {
|
||||
_checkBackgroundAndNotify(senderName, text);
|
||||
}
|
||||
}
|
||||
|
||||
// [New] 백그라운드 체크 로직
|
||||
void _checkBackgroundAndNotify(String sender, String text) {
|
||||
// WidgetsBinding을 통해 현재 앱 상태 확인
|
||||
final state = WidgetsBinding.instance.lifecycleState;
|
||||
|
||||
// 앱이 꺼져있거나(paused), 비활성(inactive) 상태일 때
|
||||
if (state == AppLifecycleState.paused || state == AppLifecycleState.inactive || state == AppLifecycleState.detached) {
|
||||
NotificationManager().showNotification(
|
||||
id: DateTime.now().millisecondsSinceEpoch % 10000, // 유니크 ID
|
||||
title: sender,
|
||||
body: text,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void sendMessage(String text) {
|
||||
if (text.trim().isEmpty) return;
|
||||
|
||||
final myInfo = NetworkManager().me;
|
||||
|
||||
final myMsg = ChatMessage(
|
||||
senderId: myInfo.id, // 내 ID
|
||||
senderName: myInfo.nickname,
|
||||
text: text,
|
||||
isMe: true,
|
||||
);
|
||||
_messages.add(myMsg);
|
||||
_messageController.add(List.from(_messages));
|
||||
|
||||
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,280 @@
|
||||
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 _TransferState {
|
||||
final String mediaId;
|
||||
final String fileName;
|
||||
final String senderId;
|
||||
final String senderName;
|
||||
final String type;
|
||||
final int totalChunks;
|
||||
final File tempFile;
|
||||
final IOSink fileSink;
|
||||
int receivedChunks = 0;
|
||||
|
||||
_TransferState({
|
||||
required this.mediaId,
|
||||
required this.fileName,
|
||||
required this.senderId,
|
||||
required this.senderName,
|
||||
required this.type,
|
||||
required this.totalChunks,
|
||||
required this.tempFile,
|
||||
required this.fileSink,
|
||||
});
|
||||
}
|
||||
|
||||
class MediaManager {
|
||||
static final MediaManager _instance = MediaManager._internal();
|
||||
factory MediaManager() => _instance;
|
||||
MediaManager._internal();
|
||||
|
||||
EphemeralDatabase? _db;
|
||||
EphemeralDatabase? get db => _db;
|
||||
String? _currentRoomId;
|
||||
|
||||
final Map<String, _TransferState> _activeTransfers = {};
|
||||
Completer<void>? _ackCompleter;
|
||||
|
||||
// [설정] 안정성을 위해 16KB 사용
|
||||
static const int CHUNK_SIZE = 16 * 1024;
|
||||
|
||||
Stream<List<MediaItem>> get galleryStream {
|
||||
if (_db == null) return const Stream.empty();
|
||||
return _db!.select(_db!.mediaItems).watch();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 초기화 및 정리
|
||||
// ---------------------------------------------------------------------------
|
||||
Future<void> initialize(String roomId) async {
|
||||
await cleanup();
|
||||
_currentRoomId = roomId;
|
||||
_db = await EphemeralDatabase.create(roomId);
|
||||
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final roomDir = Directory('${tempDir.path}/rooms/$roomId');
|
||||
if (!await roomDir.exists()) {
|
||||
await roomDir.create(recursive: true);
|
||||
}
|
||||
print("[MediaManager] Initialized. Storage: ${roomDir.path}");
|
||||
}
|
||||
|
||||
Future<void> cleanup() async {
|
||||
if (_db != null) {
|
||||
await _db!.close();
|
||||
_db = null;
|
||||
}
|
||||
for (var state in _activeTransfers.values) {
|
||||
await state.fileSink.close();
|
||||
}
|
||||
_activeTransfers.clear();
|
||||
_ackCompleter = null;
|
||||
|
||||
if (_currentRoomId != null) {
|
||||
try {
|
||||
final dbFolder = await getApplicationDocumentsDirectory();
|
||||
final dbFile = File('${dbFolder.path}/room_$_currentRoomId.sqlite');
|
||||
if (await dbFile.exists()) await dbFile.delete();
|
||||
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final roomDir = Directory('${tempDir.path}/rooms/$_currentRoomId');
|
||||
if (await roomDir.exists()) await roomDir.delete(recursive: true);
|
||||
print("[MediaManager] Cleaned up 🔥");
|
||||
} catch (e) {
|
||||
print("[MediaManager] Cleanup Error: $e");
|
||||
}
|
||||
_currentRoomId = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 미디어 전송 (Stop-and-Wait ARQ)
|
||||
// ---------------------------------------------------------------------------
|
||||
Future<void> sendMedia({
|
||||
required String filePath,
|
||||
required String type,
|
||||
}) async {
|
||||
if (_db == null || _currentRoomId == null) return;
|
||||
|
||||
final myInfo = NetworkManager().me;
|
||||
final mediaId = const Uuid().v4();
|
||||
final file = File(filePath);
|
||||
final fileName = filePath.split('/').last;
|
||||
final int fileSize = await file.length();
|
||||
final int totalChunks = (fileSize / CHUNK_SIZE).ceil();
|
||||
|
||||
print("[MediaManager] Uploading $fileName ($totalChunks chunks) with ACK...");
|
||||
|
||||
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()),
|
||||
));
|
||||
|
||||
// 헤더 전송
|
||||
await _sendPacketAndWaitAck(PlayPacket(
|
||||
type: PacketType.media,
|
||||
senderId: myInfo.id,
|
||||
timestamp: DateTime.now().millisecondsSinceEpoch,
|
||||
payload: {
|
||||
'step': 'HEADER',
|
||||
'mediaId': mediaId,
|
||||
'fileName': fileName,
|
||||
'senderName': myInfo.nickname,
|
||||
'type': type,
|
||||
'totalChunks': totalChunks,
|
||||
'fileSize': fileSize,
|
||||
},
|
||||
));
|
||||
|
||||
// 청크 전송
|
||||
final raf = await file.open();
|
||||
try {
|
||||
for (int i = 0; i < totalChunks; i++) {
|
||||
int length = CHUNK_SIZE;
|
||||
if (i == totalChunks - 1) {
|
||||
length = fileSize - (i * CHUNK_SIZE);
|
||||
}
|
||||
|
||||
List<int> bytes = await raf.read(length);
|
||||
String base64Chunk = base64Encode(bytes);
|
||||
|
||||
await _sendPacketAndWaitAck(PlayPacket(
|
||||
type: PacketType.media,
|
||||
senderId: myInfo.id,
|
||||
timestamp: DateTime.now().millisecondsSinceEpoch,
|
||||
payload: {
|
||||
'step': 'CHUNK',
|
||||
'mediaId': mediaId,
|
||||
'index': i,
|
||||
'data': base64Chunk,
|
||||
},
|
||||
));
|
||||
}
|
||||
} finally {
|
||||
await raf.close();
|
||||
}
|
||||
|
||||
print("[MediaManager] Upload Complete: $fileName");
|
||||
}
|
||||
|
||||
Future<void> _sendPacketAndWaitAck(PlayPacket packet) async {
|
||||
_ackCompleter = Completer<void>();
|
||||
NetworkManager().sendPacket(packet);
|
||||
try {
|
||||
// [설정] 타임아웃 30초로 증가
|
||||
await _ackCompleter!.future.timeout(const Duration(seconds: 30));
|
||||
} catch (e) {
|
||||
print("[MediaManager] ACK Timeout! 전송 실패 가능성 있음.");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 패킷 수신
|
||||
// ---------------------------------------------------------------------------
|
||||
Future<void> onMediaReceived(PlayPacket packet) async {
|
||||
final data = packet.payload as Map<String, dynamic>;
|
||||
final String step = data['step'];
|
||||
|
||||
if (step == 'ACK') {
|
||||
if (_ackCompleter != null && !_ackCompleter!.isCompleted) {
|
||||
_ackCompleter!.complete();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (packet.senderId == NetworkManager().me.id) return;
|
||||
if (_db == null) return;
|
||||
|
||||
final String mediaId = data['mediaId'];
|
||||
|
||||
try {
|
||||
if (step == 'HEADER') {
|
||||
final String fileName = data['fileName'];
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final savePath = '${tempDir.path}/rooms/$_currentRoomId/${const Uuid().v4()}_$fileName';
|
||||
|
||||
final file = File(savePath);
|
||||
await file.create(recursive: true);
|
||||
final sink = file.openWrite();
|
||||
|
||||
_activeTransfers[mediaId] = _TransferState(
|
||||
mediaId: mediaId,
|
||||
fileName: fileName,
|
||||
senderId: packet.senderId,
|
||||
senderName: data['senderName'],
|
||||
type: data['type'],
|
||||
totalChunks: data['totalChunks'],
|
||||
tempFile: file,
|
||||
fileSink: sink,
|
||||
);
|
||||
|
||||
print("[MediaManager] Recv Header. Sending ACK.");
|
||||
_sendAck(mediaId);
|
||||
}
|
||||
|
||||
else if (step == 'CHUNK') {
|
||||
final state = _activeTransfers[mediaId];
|
||||
if (state == null) return;
|
||||
|
||||
final String base64Data = data['data'];
|
||||
final List<int> bytes = base64Decode(base64Data);
|
||||
|
||||
state.fileSink.add(bytes);
|
||||
state.receivedChunks++;
|
||||
|
||||
_sendAck(mediaId);
|
||||
|
||||
if (state.receivedChunks >= state.totalChunks) {
|
||||
await _finishTransfer(state);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
print("[MediaManager] Receive Error: $e");
|
||||
}
|
||||
}
|
||||
|
||||
void _sendAck(String mediaId) {
|
||||
final myInfo = NetworkManager().me;
|
||||
NetworkManager().sendPacket(PlayPacket(
|
||||
type: PacketType.media,
|
||||
senderId: myInfo.id,
|
||||
timestamp: DateTime.now().millisecondsSinceEpoch,
|
||||
payload: {
|
||||
'step': 'ACK',
|
||||
'mediaId': mediaId,
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
Future<void> _finishTransfer(_TransferState state) async {
|
||||
await state.fileSink.flush();
|
||||
await state.fileSink.close();
|
||||
|
||||
await _db!.insertMedia(MediaItemsCompanion(
|
||||
id: drift.Value(state.mediaId),
|
||||
senderId: drift.Value(state.senderId),
|
||||
senderName: drift.Value(state.senderName),
|
||||
type: drift.Value(state.type),
|
||||
filePath: drift.Value(state.tempFile.path),
|
||||
createdAt: drift.Value(DateTime.now()),
|
||||
));
|
||||
|
||||
_activeTransfers.remove(state.mediaId);
|
||||
print("[MediaManager] File Download Complete: ${state.fileName}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import 'dart:typed_data'; // [추가] Int64List 사용을 위해 필요
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
|
||||
class NotificationManager {
|
||||
static final NotificationManager _instance = NotificationManager._internal();
|
||||
factory NotificationManager() => _instance;
|
||||
NotificationManager._internal();
|
||||
|
||||
final FlutterLocalNotificationsPlugin _flutterLocalNotificationsPlugin =
|
||||
FlutterLocalNotificationsPlugin();
|
||||
|
||||
bool _isInitialized = false;
|
||||
|
||||
Future<void> initialize() async {
|
||||
if (_isInitialized) return;
|
||||
|
||||
const AndroidInitializationSettings initializationSettingsAndroid =
|
||||
AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||
|
||||
const DarwinInitializationSettings initializationSettingsDarwin =
|
||||
DarwinInitializationSettings(
|
||||
requestAlertPermission: true,
|
||||
requestBadgePermission: true,
|
||||
requestSoundPermission: true, // iOS는 사운드 권한이 곧 진동 권한과 연결됨
|
||||
);
|
||||
|
||||
const InitializationSettings initializationSettings = InitializationSettings(
|
||||
android: initializationSettingsAndroid,
|
||||
iOS: initializationSettingsDarwin,
|
||||
);
|
||||
|
||||
await _flutterLocalNotificationsPlugin.initialize(initializationSettings);
|
||||
_isInitialized = true;
|
||||
}
|
||||
|
||||
Future<void> showNotification({
|
||||
required int id,
|
||||
required String title,
|
||||
required String body,
|
||||
String? payload,
|
||||
}) async {
|
||||
|
||||
// [핵심] 진동 패턴 정의 (대기 -> 진동 -> 대기 -> 진동 ... 밀리초 단위)
|
||||
// 예: 0ms 대기 후, 1000ms(1초) 진동, 500ms 쉬고, 1000ms 진동
|
||||
final Int64List vibrationPattern = Int64List.fromList([0, 1000, 500, 1000]);
|
||||
|
||||
final AndroidNotificationDetails androidPlatformChannelSpecifics =
|
||||
AndroidNotificationDetails(
|
||||
'playwith_channel_id_v2', // [중요] 설정을 바꾸면 채널 ID도 바꿔야 적용됨 (기존 ID는 설정 유지됨)
|
||||
'PlayWith Alarms',
|
||||
channelDescription: '게임 및 채팅 알림 (진동 포함)',
|
||||
importance: Importance.max, // 소리+진동을 위해 Max 필수
|
||||
priority: Priority.high, // 헤드업 알림을 위해 High 필수
|
||||
enableVibration: true, // 진동 켜기
|
||||
vibrationPattern: vibrationPattern, // 패턴 적용
|
||||
playSound: true, // 소리도 같이
|
||||
);
|
||||
|
||||
final NotificationDetails platformChannelSpecifics =
|
||||
NotificationDetails(android: androidPlatformChannelSpecifics);
|
||||
|
||||
await _flutterLocalNotificationsPlugin.show(
|
||||
id,
|
||||
title,
|
||||
body,
|
||||
platformChannelSpecifics,
|
||||
payload: payload,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import 'dart:convert'; // Base64용
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:image_picker/image_picker.dart'; // 이미지 피커
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
// 1. 앱에서 사용할 색상표 정의 (Core에 둡니다)
|
||||
final Map<String, MaterialColor> appColors = {
|
||||
'Blue': Colors.blue,
|
||||
'Green': Colors.green,
|
||||
'Red': Colors.red,
|
||||
'Purple': Colors.purple,
|
||||
'Orange': Colors.orange,
|
||||
'Teal': Colors.teal,
|
||||
'Pink': Colors.pink,
|
||||
'Amber': Colors.amber,
|
||||
};
|
||||
|
||||
class SettingsNotifier with ChangeNotifier {
|
||||
static final SettingsNotifier _instance = SettingsNotifier._internal();
|
||||
factory SettingsNotifier() => _instance;
|
||||
SettingsNotifier._internal() {
|
||||
_loadSettings();
|
||||
}
|
||||
|
||||
// --- 저장 키 (Keys) ---
|
||||
static const String _keyNickname = 'nickname';
|
||||
static const String _keyAvatarIdx = 'avatar_index';
|
||||
static const String _keyThemeColor = 'theme_color';
|
||||
static const String _keyDarkMode = 'is_dark_mode';
|
||||
static const String _keyFontScale = 'font_scale';
|
||||
static const String _keyProfileImage = 'profile_image_base64';
|
||||
static const String _keyShowDebug = 'show_debug_log'; // [추가] 키
|
||||
|
||||
|
||||
// --- 상태 변수 (State) ---
|
||||
String _nickname = "";
|
||||
int _avatarIndex = 0;
|
||||
String _themeColorName = 'Blue';
|
||||
bool _isDarkMode = false;
|
||||
double _fontScale = 1.0;
|
||||
String? _profileImageBase64;
|
||||
bool _isShowDebugLog = false; // [추가] 디버그 로그 표시 여부 (기본값 false)
|
||||
|
||||
// --- Getters ---
|
||||
String get nickname => _nickname;
|
||||
int get avatarIndex => _avatarIndex;
|
||||
String get themeColorName => _themeColorName;
|
||||
bool get isDarkMode => _isDarkMode;
|
||||
double get fontScale => _fontScale;
|
||||
String? get profileImageBase64 => _profileImageBase64;
|
||||
bool get isShowDebugLog => _isShowDebugLog; // [추가] Getter
|
||||
|
||||
MaterialColor get currentColor => appColors[_themeColorName] ?? Colors.blue;
|
||||
|
||||
ThemeData get currentTheme {
|
||||
final base = ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: currentColor,
|
||||
brightness: _isDarkMode ? Brightness.dark : Brightness.light,
|
||||
),
|
||||
brightness: _isDarkMode ? Brightness.dark : Brightness.light,
|
||||
);
|
||||
return base;
|
||||
}
|
||||
|
||||
ThemeMode get currentThemeMode => _isDarkMode ? ThemeMode.dark : ThemeMode.light;
|
||||
|
||||
// --- Methods ---
|
||||
|
||||
Future<void> _loadSettings() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_nickname = prefs.getString(_keyNickname) ?? "";
|
||||
_avatarIndex = prefs.getInt(_keyAvatarIdx) ?? 0;
|
||||
_themeColorName = prefs.getString(_keyThemeColor) ?? 'Blue';
|
||||
_isDarkMode = prefs.getBool(_keyDarkMode) ?? false;
|
||||
_fontScale = prefs.getDouble(_keyFontScale) ?? 1.0;
|
||||
_profileImageBase64 = prefs.getString(_keyProfileImage);
|
||||
_isShowDebugLog = prefs.getBool(_keyShowDebug) ?? false; // [추가] 로드
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setProfile(String nick, int avatarIdx) async {
|
||||
_nickname = nick;
|
||||
_avatarIndex = avatarIdx;
|
||||
notifyListeners();
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_keyNickname, nick);
|
||||
await prefs.setInt(_keyAvatarIdx, avatarIdx);
|
||||
}
|
||||
|
||||
Future<void> pickProfileImage() async {
|
||||
final picker = ImagePicker();
|
||||
final XFile? image = await picker.pickImage(
|
||||
source: ImageSource.gallery,
|
||||
maxWidth: 500,
|
||||
maxHeight: 500,
|
||||
imageQuality: 70,
|
||||
);
|
||||
|
||||
if (image != null) {
|
||||
final bytes = await File(image.path).readAsBytes();
|
||||
final base64String = base64Encode(bytes);
|
||||
|
||||
_profileImageBase64 = base64String;
|
||||
notifyListeners();
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_keyProfileImage, base64String);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> clearProfileImage() async {
|
||||
_profileImageBase64 = null;
|
||||
notifyListeners();
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_keyProfileImage);
|
||||
}
|
||||
|
||||
Future<void> setThemeColor(String colorName) async {
|
||||
if (!appColors.containsKey(colorName)) return;
|
||||
_themeColorName = colorName;
|
||||
notifyListeners();
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_keyThemeColor, colorName);
|
||||
}
|
||||
|
||||
Future<void> toggleDarkMode(bool value) async {
|
||||
_isDarkMode = value;
|
||||
notifyListeners();
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_keyDarkMode, value);
|
||||
}
|
||||
|
||||
Future<void> setFontScale(double scale) async {
|
||||
_fontScale = scale.clamp(0.8, 2.0);
|
||||
notifyListeners();
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setDouble(_keyFontScale, _fontScale);
|
||||
}
|
||||
|
||||
// [추가] 디버그 로그 토글 함수
|
||||
Future<void> toggleDebugLog(bool value) async {
|
||||
_isShowDebugLog = value;
|
||||
notifyListeners();
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_keyShowDebug, value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import 'dart:async';
|
||||
import 'package:speech_to_text/speech_to_text.dart';
|
||||
|
||||
class VoiceManager {
|
||||
static final VoiceManager _instance = VoiceManager._internal();
|
||||
factory VoiceManager() => _instance;
|
||||
VoiceManager._internal();
|
||||
|
||||
final SpeechToText _speech = SpeechToText();
|
||||
bool _isAvailable = false;
|
||||
|
||||
// 실시간 음성 인식 결과를 UI에 뿌려주는 스트림
|
||||
final _resultController = StreamController<String>.broadcast();
|
||||
Stream<String> get resultStream => _resultController.stream;
|
||||
|
||||
// 현재 듣고 있는지 여부
|
||||
bool get isListening => _speech.isListening;
|
||||
|
||||
/// 초기화 (앱 시작 시 또는 게임 진입 시 호출)
|
||||
Future<bool> initialize() async {
|
||||
if (_isAvailable) return true;
|
||||
try {
|
||||
_isAvailable = await _speech.initialize(
|
||||
onStatus: (status) => print('[Voice] Status: $status'),
|
||||
onError: (error) => print('[Voice] Error: $error'),
|
||||
);
|
||||
return _isAvailable;
|
||||
} catch (e) {
|
||||
print("[Voice] Init Failed: $e");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 듣기 시작 (5초간 유지)
|
||||
Future<void> startListening({
|
||||
required Function(String result) onResult,
|
||||
int listenForSeconds = 5
|
||||
}) async {
|
||||
if (!_isAvailable) {
|
||||
bool init = await initialize();
|
||||
if (!init) return;
|
||||
}
|
||||
|
||||
_speech.listen(
|
||||
onResult: (result) {
|
||||
// 실시간 결과를 스트림에 전송 (UI 표시용)
|
||||
_resultController.add(result.recognizedWords);
|
||||
|
||||
// 최종 결과가 확정되면 콜백 호출
|
||||
if (result.finalResult) {
|
||||
onResult(result.recognizedWords);
|
||||
}
|
||||
},
|
||||
listenFor: Duration(seconds: listenForSeconds),
|
||||
localeId: "ko_KR", // 한국어 강제 (필요시 설정에서 가져오도록 변경)
|
||||
cancelOnError: true,
|
||||
partialResults: true, // 말하는 도중에도 결과 받기
|
||||
);
|
||||
}
|
||||
|
||||
/// 듣기 중단
|
||||
Future<void> stopListening() async {
|
||||
await _speech.stop();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// [정답 판독기] 퍼지 매칭 (Fuzzy Matching)
|
||||
// ---------------------------------------------------------------------------
|
||||
/// 사용자가 말한 것(input)이 정답(answer)과 얼마나 비슷한지 체크
|
||||
/// 반환값: 정답 여부 (true/false)
|
||||
bool checkAnswer(String input, String answer, {double threshold = 0.8}) {
|
||||
final cleanInput = _normalize(input);
|
||||
final cleanAnswer = _normalize(answer);
|
||||
|
||||
// 1. 완전 일치
|
||||
if (cleanInput == cleanAnswer) return true;
|
||||
|
||||
// 2. 포함 관계 ("이순신 장군" -> "이순신")
|
||||
if (cleanInput.contains(cleanAnswer)) return true;
|
||||
|
||||
// 3. 유사도 검사 (Jaccard Similarity 간이 구현)
|
||||
// 글자 단위로 쪼개서 얼마나 겹치는지 확인
|
||||
final similarity = _calculateSimilarity(cleanInput, cleanAnswer);
|
||||
print("[Voice] '$input' vs '$answer' -> Similarity: $similarity");
|
||||
|
||||
return similarity >= threshold;
|
||||
}
|
||||
|
||||
String _normalize(String text) {
|
||||
return text.replaceAll(RegExp(r'\s+'), '').toLowerCase(); // 공백 제거, 소문자
|
||||
}
|
||||
|
||||
double _calculateSimilarity(String s1, String s2) {
|
||||
final set1 = s1.split('').toSet();
|
||||
final set2 = s2.split('').toSet();
|
||||
final intersection = set1.intersection(set2).length;
|
||||
final union = set1.union(set2).length;
|
||||
return intersection / union;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class GameInfo {
|
||||
final String id;
|
||||
final String name;
|
||||
final String description;
|
||||
final IconData icon;
|
||||
final bool isSinglePlayerSupported; // 싱글 플레이 지원 여부
|
||||
|
||||
const GameInfo({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.description,
|
||||
required this.icon,
|
||||
this.isSinglePlayerSupported = false,
|
||||
});
|
||||
}
|
||||
|
||||
class AppGames {
|
||||
static const List<GameInfo> games = [
|
||||
GameInfo(
|
||||
id: 'quiz_mix',
|
||||
name: 'OX 서바이벌',
|
||||
description: '최후의 1인이 될 때까지!\n다함께 푸는 퀴즈 서바이벌',
|
||||
icon: Icons.quiz,
|
||||
isSinglePlayerSupported: true,
|
||||
),
|
||||
GameInfo(
|
||||
id: 'sudoku_battle',
|
||||
name: '스도쿠 배틀',
|
||||
description: '먼저 완성하면 승리!\n상대를 방해하며 퍼즐을 푸세요.',
|
||||
icon: Icons.grid_on,
|
||||
isSinglePlayerSupported: true, // 연습 모드 가능
|
||||
),
|
||||
// 추후 추가 예정 게임들 (비활성화 상태로 표시하거나 주석 처리)
|
||||
GameInfo(
|
||||
id: 'spider_battle',
|
||||
name: '스파이더 카드',
|
||||
description: '카드 정렬의 달인을 찾아라!',
|
||||
icon: Icons.style,
|
||||
isSinglePlayerSupported: true, // 연습 모드 가능
|
||||
),
|
||||
GameInfo(
|
||||
id: 'omok',
|
||||
name: '오목',
|
||||
description: '먼저 5줄을 만들면 승리!\n흑백의 치열한 두뇌 싸움',
|
||||
icon: Icons.circle_outlined,
|
||||
isSinglePlayerSupported: false, // 1:1 전용
|
||||
),
|
||||
// [추가] 장기
|
||||
GameInfo(
|
||||
id: 'janggi',
|
||||
name: '장기',
|
||||
description: '한국 전통 보드게임\n초(楚)와 한(漢)의 승부',
|
||||
icon: Icons.games,
|
||||
isSinglePlayerSupported: false, // 1:1 전용
|
||||
),
|
||||
GameInfo(
|
||||
id: 'yutnori',
|
||||
name: '윷놀이',
|
||||
description: '던져라 윷! 잡아라 말!\n역전의 드라마 명절 게임',
|
||||
icon: Icons.kebab_dining, // 윷가락과 비슷한 아이콘 사용
|
||||
isSinglePlayerSupported: false,
|
||||
),
|
||||
GameInfo(
|
||||
id: 'memory_battle',
|
||||
name: '그림 찾기',
|
||||
description: '기억력 대결!\n짝을 더 많이 찾는 사람이 승리',
|
||||
icon: Icons.flip,
|
||||
isSinglePlayerSupported: false,
|
||||
),
|
||||
// [추가] 밸런스 게임
|
||||
GameInfo(
|
||||
id: 'balance_game',
|
||||
name: '밸런스 게임',
|
||||
description: '우리는 천생연분?\n동시에 같은 답을 골라보세요!',
|
||||
icon: Icons.favorite,
|
||||
isSinglePlayerSupported: false,
|
||||
),
|
||||
// [추가] 터치 배틀
|
||||
GameInfo(
|
||||
id: 'tap_battle',
|
||||
name: '터치 배틀',
|
||||
description: '단순 무식 스피드 대결!\n누가 더 빨리 누를까?',
|
||||
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) {
|
||||
return games.firstWhere((g) => g.id == id, orElse: () => games.first);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import 'dart:convert';
|
||||
|
||||
enum PacketType {
|
||||
system, chat, game, media, unknown
|
||||
}
|
||||
|
||||
class PlayPacket {
|
||||
final PacketType type;
|
||||
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, // [추가]
|
||||
});
|
||||
|
||||
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'], // [추가]
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'type': type.name,
|
||||
'senderId': senderId,
|
||||
'payload': payload,
|
||||
'timestamp': timestamp,
|
||||
if (seq != null) 'seq': seq, // [추가]
|
||||
};
|
||||
}
|
||||
|
||||
static PacketType _parseType(String? typeStr) {
|
||||
for (var t in PacketType.values) {
|
||||
if (t.name == typeStr) return t;
|
||||
}
|
||||
return PacketType.unknown;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
enum QuizType { text, image }
|
||||
|
||||
class QuizItem {
|
||||
final QuizType type;
|
||||
final String category; // [추가] 카테고리
|
||||
final String question;
|
||||
final String answer;
|
||||
final List<String> options;
|
||||
|
||||
QuizItem({
|
||||
required this.type,
|
||||
required this.category, // [추가]
|
||||
required this.question,
|
||||
required this.answer,
|
||||
required this.options,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'type': type.name,
|
||||
'category': category, // [추가]
|
||||
'question': question,
|
||||
'answer': answer,
|
||||
'options': options,
|
||||
};
|
||||
|
||||
factory QuizItem.fromJson(Map<String, dynamic> json) {
|
||||
return QuizItem(
|
||||
type: json['type'] == 'image' ? QuizType.image : QuizType.text,
|
||||
category: json['category'] ?? '기타', // [추가] 없을 경우 대비
|
||||
question: json['question'],
|
||||
answer: json['answer'],
|
||||
options: List<String>.from(json['options'] ?? []),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class QuizSet {
|
||||
static List<QuizItem> getStandard50() {
|
||||
return [
|
||||
// 1~10: 믹스
|
||||
QuizItem(type: QuizType.text, category: "상식", question: "사과는 영어로 Apple이다.", answer: "O", options: ["O", "X"]),
|
||||
QuizItem(type: QuizType.text, category: "동물", question: "북극곰의 피부색은 흰색이다.", answer: "X", options: ["O", "X"]),
|
||||
QuizItem(type: QuizType.text, category: "동물", question: "돌고래는 '어류(물고기)'다.", answer: "X", options: ["O", "X"]),
|
||||
QuizItem(type: QuizType.text, category: "역사", question: "임진왜란이 일어난 해는?", answer: "1592년", options: ["1392년", "1492년", "1592년", "1950년"]),
|
||||
QuizItem(type: QuizType.text, category: "넌센스", question: "왕이 넘어지면?", answer: "킹콩", options: ["왕콩", "킹콩", "전하", "꽈당"]),
|
||||
QuizItem(type: QuizType.text, category: "속담", question: "가는 말이 고와야 [ ? ]가 곱다.", answer: "오는 말", options: ["오는 말", "가는 발", "너의 말", "우리 말"]),
|
||||
QuizItem(type: QuizType.text, category: "수학", question: "5 + 5 × 5 = ?", answer: "30", options: ["25", "30", "50", "10"]),
|
||||
QuizItem(type: QuizType.text, category: "수도", question: "미국의 수도는 어디일까요?", answer: "워싱턴 D.C.", options: ["뉴욕", "LA", "워싱턴 D.C.", "시카고"]),
|
||||
QuizItem(type: QuizType.text, category: "넌센스", question: "세상에서 가장 뜨거운 바다는?", answer: "열바다", options: ["불바다", "열바다", "사랑해", "동해"]),
|
||||
QuizItem(type: QuizType.text, category: "기타", question: "개발자님은 이 앱을 완성할 수 있다!", answer: "O", options: ["O", "X"]),
|
||||
|
||||
// 11~20: 동물
|
||||
QuizItem(type: QuizType.text, category: "동물", question: "낙지의 심장은 3개다.", answer: "O", options: ["O", "X"]),
|
||||
QuizItem(type: QuizType.text, category: "동물", question: "펭귄은 북극에 산다.", answer: "X", options: ["O", "X"]),
|
||||
QuizItem(type: QuizType.text, category: "동물", question: "상어는 부레가 없다.", answer: "O", options: ["O", "X"]),
|
||||
QuizItem(type: QuizType.text, category: "동물", question: "토끼는 눈을 뜨고 잔다.", answer: "O", options: ["O", "X"]),
|
||||
QuizItem(type: QuizType.text, category: "동물", question: "기린의 목뼈 개수는 사람보다 훨씬 많다.", answer: "X", options: ["O", "X"]),
|
||||
QuizItem(type: QuizType.text, category: "동물", question: "금붕어의 기억력은 3초다.", answer: "X", options: ["O", "X"]),
|
||||
QuizItem(type: QuizType.text, category: "동물", question: "달팽이도 이빨이 있다.", answer: "O", options: ["O", "X"]),
|
||||
QuizItem(type: QuizType.text, category: "동물", question: "뱀은 뒤로 갈 수 있다.", answer: "X", options: ["O", "X"]),
|
||||
QuizItem(type: QuizType.text, category: "동물", question: "고양이는 단맛을 느끼지 못한다.", answer: "O", options: ["O", "X"]),
|
||||
QuizItem(type: QuizType.text, category: "동물", question: "지구에서 가장 큰 동물은 코끼리다.", answer: "X", options: ["O", "X"]),
|
||||
|
||||
// 21~30: 수도
|
||||
QuizItem(type: QuizType.text, category: "수도", question: "호주의 수도는?", answer: "캔버라", options: ["시드니", "멜버른", "캔버라", "퍼스"]),
|
||||
QuizItem(type: QuizType.text, category: "수도", question: "캐나다의 수도는?", answer: "오타와", options: ["토론토", "밴쿠버", "몬트리올", "오타와"]),
|
||||
QuizItem(type: QuizType.text, category: "수도", question: "베트남의 수도는?", answer: "하노이", options: ["호치민", "하노이", "다낭", "나트랑"]),
|
||||
QuizItem(type: QuizType.text, category: "수도", question: "터키(튀르키예)의 수도는?", answer: "앙카라", options: ["이스탄불", "앙카라", "이즈미르", "안탈리아"]),
|
||||
QuizItem(type: QuizType.text, category: "수도", question: "브라질의 수도는?", answer: "브라질리아", options: ["상파울루", "리우데자네이루", "브라질리아", "살바도르"]),
|
||||
QuizItem(type: QuizType.text, category: "수도", question: "스페인의 수도는?", answer: "마드리드", options: ["바르셀로나", "마드리드", "세비야", "발렌시아"]),
|
||||
QuizItem(type: QuizType.text, category: "수도", question: "독일의 수도는?", answer: "베를린", options: ["뮌헨", "프랑크푸르트", "베를린", "함부르크"]),
|
||||
QuizItem(type: QuizType.text, category: "수도", question: "이집트의 수도는?", answer: "카이로", options: ["카이로", "알렉산드리아", "룩소르", "아스완"]),
|
||||
QuizItem(type: QuizType.text, category: "수도", question: "인도의 수도는?", answer: "뉴델리", options: ["뭄바이", "뉴델리", "방갈로르", "콜카타"]),
|
||||
QuizItem(type: QuizType.text, category: "수도", question: "스위스의 수도는?", answer: "베른", options: ["취리히", "제네바", "베른", "바젤"]),
|
||||
|
||||
// 31~40: 넌센스
|
||||
QuizItem(type: QuizType.text, category: "넌센스", question: "세상에서 가장 추운 바다는?", answer: "썰렁해", options: ["동해", "썰렁해", "북극해", "냉해"]),
|
||||
QuizItem(type: QuizType.text, category: "넌센스", question: "차가 울면?", answer: "잉카", options: ["엉엉", "부릉부릉", "잉카", "흑흑"]),
|
||||
QuizItem(type: QuizType.text, category: "넌센스", question: "반성문을 영어로 하면?", answer: "글로벌", options: ["쏘리", "글로벌", "미스테이크", "리포트"]),
|
||||
QuizItem(type: QuizType.text, category: "넌센스", question: "딸기가 도망가면?", answer: "딸기쨈", options: ["딸기시럽", "딸기주스", "딸기쨈", "딸기런"]),
|
||||
QuizItem(type: QuizType.text, category: "넌센스", question: "우유가 아프면?", answer: "앙팡", options: ["서울우유", "앙팡", "매일우유", "아야"]),
|
||||
QuizItem(type: QuizType.text, category: "넌센스", question: "세상에서 가장 가난한 왕은?", answer: "최저임금", options: ["세종대왕", "최저임금", "버거킹", "제왕"]),
|
||||
QuizItem(type: QuizType.text, category: "넌센스", question: "비가 1시간 동안 내리면?", answer: "추적60분", options: ["장마", "소나기", "추적60분", "비와이"]),
|
||||
QuizItem(type: QuizType.text, category: "넌센스", question: "도둑이 훔친 돈을 영어로?", answer: "슬그머니", options: ["머니머니", "슬그머니", "스틸머니", "블랙머니"]),
|
||||
QuizItem(type: QuizType.text, category: "넌센스", question: "오리가 얼면?", answer: "언덕", options: ["빙판", "언덕", "동동", "꽥꽥"]),
|
||||
QuizItem(type: QuizType.text, category: "넌센스", question: "전주비빔밥보다 맛있는 비빔밥은?", answer: "이번주비빔밥", options: ["돌솥비빔밥", "산채비빔밥", "이번주비빔밥", "육회비빔밥"]),
|
||||
|
||||
// 41~50: 상식
|
||||
QuizItem(type: QuizType.text, category: "상식", question: "피카소의 국적은?", answer: "스페인", options: ["프랑스", "이탈리아", "스페인", "독일"]),
|
||||
QuizItem(type: QuizType.text, category: "역사", question: "대한민국 임시정부가 수립된 연도는?", answer: "1919년", options: ["1910년", "1919년", "1945년", "1948년"]),
|
||||
QuizItem(type: QuizType.text, category: "수학", question: "원주율(π)의 근사값은?", answer: "3.14", options: ["3.14", "3.15", "3.12", "3.16"]),
|
||||
QuizItem(type: QuizType.text, category: "상식", question: "축구 경기 한 팀의 선수는 몇 명인가?", answer: "11명", options: ["9명", "10명", "11명", "12명"]),
|
||||
QuizItem(type: QuizType.text, category: "상식", question: "세계에서 가장 인구가 많은 나라는? (2023년 기준)", answer: "인도", options: ["중국", "미국", "인도", "인도네시아"]),
|
||||
QuizItem(type: QuizType.text, category: "상식", question: "비빔밥에 들어가지 않는 것은?", answer: "초콜릿", options: ["고추장", "참기름", "밥", "초콜릿"]),
|
||||
QuizItem(type: QuizType.text, category: "상식", question: "다음 중 발효 식품이 아닌 것은?", answer: "두부", options: ["김치", "된장", "요거트", "두부"]),
|
||||
QuizItem(type: QuizType.text, category: "상식", question: "음악의 아버지는 누구인가?", answer: "바흐", options: ["모차르트", "베토벤", "바흐", "슈베르트"]),
|
||||
QuizItem(type: QuizType.text, category: "상식", question: "해리포터가 다니는 마법 학교 이름은?", answer: "호그와트", options: ["호그와트", "아즈카반", "그리핀도르", "슬리데린"]),
|
||||
QuizItem(type: QuizType.text, category: "기타", question: "마지막 문제입니다. 개발자가 좋아하는 요일은?", answer: "금요일", options: ["월요일", "수요일", "목요일", "금요일"]),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
enum SpiderSuit { spade, heart, club, diamond }
|
||||
|
||||
class SpiderCard {
|
||||
final int id;
|
||||
final SpiderSuit suit;
|
||||
final int rank;
|
||||
bool isFaceUp;
|
||||
|
||||
SpiderCard({
|
||||
required this.id,
|
||||
required this.suit,
|
||||
required this.rank,
|
||||
this.isFaceUp = false,
|
||||
});
|
||||
|
||||
// 랭크: 1(A) ~ 13(K)
|
||||
String get rankText {
|
||||
switch (rank) {
|
||||
case 1: return 'A';
|
||||
case 11: return 'J';
|
||||
case 12: return 'Q';
|
||||
case 13: return 'K';
|
||||
default: return rank.toString();
|
||||
}
|
||||
}
|
||||
|
||||
bool get isRed => suit == SpiderSuit.heart || suit == SpiderSuit.diamond;
|
||||
|
||||
String get suitSymbol {
|
||||
switch (suit) {
|
||||
case SpiderSuit.spade: return '♠';
|
||||
case SpiderSuit.heart: return '♥';
|
||||
case SpiderSuit.club: return '♣';
|
||||
case SpiderSuit.diamond: return '♦';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
class SudokuGameDto {
|
||||
final int puzzleId;
|
||||
final String question;
|
||||
final String solution;
|
||||
final int blockSize;
|
||||
final int gridSize;
|
||||
|
||||
SudokuGameDto({
|
||||
required this.puzzleId,
|
||||
required this.question,
|
||||
required this.solution,
|
||||
required this.blockSize,
|
||||
}) : gridSize = blockSize * blockSize;
|
||||
|
||||
factory SudokuGameDto.fromJson(Map<String, dynamic> json) {
|
||||
int bs = json['blockSize'] ?? 3;
|
||||
return SudokuGameDto(
|
||||
puzzleId: json['puzzleId'] ?? 0,
|
||||
question: json['question'] ?? '',
|
||||
solution: json['solution'] ?? '',
|
||||
blockSize: bs,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'puzzleId': puzzleId,
|
||||
'question': question,
|
||||
'solution': solution,
|
||||
'blockSize': blockSize,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -3,51 +3,60 @@ 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;
|
||||
final String? profileImageBase64; // [추가] 커스텀 프로필 이미지 (Base64)
|
||||
|
||||
const UserInfo({
|
||||
required this.id,
|
||||
required this.nickname,
|
||||
this.avatarIndex = 0,
|
||||
this.colorValue = 0xFF2196F3, // 기본값 Blue
|
||||
this.colorValue = 0xFF2196F3,
|
||||
this.isReady = false,
|
||||
this.profileImageBase64, // 생성자 추가
|
||||
});
|
||||
|
||||
/// 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,
|
||||
profileImageBase64: json['profileImageBase64'] as String?, // 파싱 추가
|
||||
);
|
||||
}
|
||||
|
||||
/// Object -> JSON 변환 (네트워크 전송 시)
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'nickname': nickname,
|
||||
'avatarIndex': avatarIndex,
|
||||
'colorValue': colorValue,
|
||||
'isReady': isReady,
|
||||
'profileImageBase64': profileImageBase64, // 변환 추가
|
||||
};
|
||||
}
|
||||
|
||||
/// 복사본 생성 (불변 객체 수정용)
|
||||
UserInfo copyWith({
|
||||
String? id,
|
||||
String? nickname,
|
||||
int? avatarIndex,
|
||||
int? colorValue,
|
||||
bool? isReady,
|
||||
String? profileImageBase64, // copyWith 추가
|
||||
}) {
|
||||
return UserInfo(
|
||||
id: id ?? this.id,
|
||||
nickname: nickname ?? this.nickname,
|
||||
avatarIndex: avatarIndex ?? this.avatarIndex,
|
||||
colorValue: colorValue ?? this.colorValue,
|
||||
isReady: isReady ?? this.isReady,
|
||||
profileImageBase64: profileImageBase64 ?? this.profileImageBase64,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [id, nickname, avatarIndex, colorValue];
|
||||
List<Object?> get props => [id, nickname, avatarIndex, colorValue, isReady, profileImageBase64];
|
||||
}
|
||||
@@ -3,121 +3,154 @@ 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';
|
||||
import '../database/ephemeral_database.dart';
|
||||
|
||||
/// 현재 네트워크 상태 (역할)
|
||||
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
|
||||
// ------------------------------------------------------------------------
|
||||
// 상수 설정
|
||||
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;
|
||||
|
||||
// 소켓
|
||||
ServerSocket? _serverSocket; // (Host용)
|
||||
Socket? _clientSocket; // (Guest용)
|
||||
final List<Socket> _connectedGuests = []; // (Host가 관리하는 게스트 목록)
|
||||
// 상태 변수
|
||||
late UserInfo me;
|
||||
NetworkRole role = NetworkRole.none;
|
||||
|
||||
String? hostIp;
|
||||
int? hostPort;
|
||||
|
||||
ServerSocket? _serverSocket;
|
||||
Socket? _clientSocket;
|
||||
|
||||
final Map<Socket, UserInfo?> _connectedGuests = {};
|
||||
final Map<Socket, String> _packetBuffers = {};
|
||||
|
||||
// mDNS (방 찾기/만들기)
|
||||
BonsoirService? _bonsoirService;
|
||||
BonsoirBroadcast? _bonsoirBroadcast;
|
||||
BonsoirDiscovery? _bonsoirDiscovery;
|
||||
|
||||
// 수신된 데이터를 앱(GameManager)으로 전달하는 스트림
|
||||
final List<UserInfo> guestList = [];
|
||||
|
||||
final _messageController = StreamController<Map<String, dynamic>>.broadcast();
|
||||
Stream<Map<String, dynamic>> get messageStream => _messageController.stream;
|
||||
|
||||
final _logController = StreamController<String>.broadcast();
|
||||
Stream<String> get logStream => _logController.stream;
|
||||
|
||||
Timer? _heartbeatTimer;
|
||||
Timer? _disconnectWaitTimer;
|
||||
DateTime? _lastPongTime;
|
||||
bool _isReconnecting = false;
|
||||
|
||||
String selectedGameId = 'quiz_mix';
|
||||
Map<String, dynamic> selectedGameConfig = {};
|
||||
|
||||
int _sendSeq = 0;
|
||||
int _recvSeq = 0;
|
||||
|
||||
EphemeralDatabase? get _database => MediaManager().db;
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 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');
|
||||
|
||||
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');
|
||||
void initialize({required String nickname, String? profileImage}) {
|
||||
final uuid = const Uuid().v4().substring(0, 8);
|
||||
final randomColor = 0xFF000000 | (nickname.hashCode & 0xFFFFFF);
|
||||
me = UserInfo(
|
||||
id: uuid,
|
||||
nickname: nickname,
|
||||
colorValue: randomColor,
|
||||
profileImageBase64: profileImage
|
||||
);
|
||||
_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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// [Socket] 호스팅 로직 (WiFi/Hotspot)
|
||||
// ------------------------------------------------------------------------
|
||||
Future<void> startHosting(String roomName) async {
|
||||
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);
|
||||
});
|
||||
|
||||
// C. mDNS로 방 광고 (Broadcast)
|
||||
// 서비스 타입은 고유해야 함 (_playwith._tcp)
|
||||
// 이름 포맷: "방이름#호스트ID" (중복 방지 및 식별용)
|
||||
_bonsoirService = BonsoirService(
|
||||
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');
|
||||
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;
|
||||
_packetBuffers[client] = "";
|
||||
|
||||
final myHandshake = {'type': 'HANDSHAKE', 'payload': me.toJson()};
|
||||
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),
|
||||
onError: (e) => _removeGuest(client),
|
||||
@@ -126,163 +159,408 @@ 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);
|
||||
_packetBuffers.remove(client);
|
||||
client.close();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 4. Guest Logic (참가자)
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 주변 방 찾기 (mDNS Discovery)
|
||||
// [Socket] 게스트 로직
|
||||
Stream<List<BonsoirService>> discoverRooms() {
|
||||
// 리스트를 계속 갱신해서 내보내기 위한 컨트롤러
|
||||
final controller = StreamController<List<BonsoirService>>();
|
||||
final List<BonsoirService> foundServices = [];
|
||||
_bonsoirDiscovery?.stop();
|
||||
|
||||
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;
|
||||
|
||||
_bonsoirDiscovery!.ready.then((_) {
|
||||
_bonsoirDiscovery!.start();
|
||||
|
||||
_bonsoirDiscovery!.eventStream!.listen((event) {
|
||||
if (event.type == BonsoirDiscoveryEventType.discoveryServiceFound) {
|
||||
if (event.service != null) {
|
||||
if (type.contains('Found')) {
|
||||
foundServices.removeWhere((s) => s.name == event.service!.name);
|
||||
foundServices.add(event.service!);
|
||||
controller.add(List.from(foundServices));
|
||||
}
|
||||
} else if (event.type == BonsoirDiscoveryEventType.discoveryServiceLost) {
|
||||
if (event.service != null) {
|
||||
} 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) await stopNetwork(force: true);
|
||||
role = NetworkRole.guest;
|
||||
hostIp = ip;
|
||||
hostPort = port;
|
||||
_sendSeq = 0; _recvSeq = 0;
|
||||
|
||||
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("✅ 접속 성공!");
|
||||
_packetBuffers[_clientSocket!] = "";
|
||||
|
||||
// 접속 성공 시 즉시 내 정보 전송 (Handshake)
|
||||
sendMessage({
|
||||
'type': 'HANDSHAKE',
|
||||
'senderId': me.id,
|
||||
'payload': me.toJson(),
|
||||
});
|
||||
final myHandshake = {'type': 'HANDSHAKE', 'payload': me.toJson()};
|
||||
_clientSocket!.add(utf8.encode('${jsonEncode(myHandshake)}$PACKET_DELIMITER'));
|
||||
|
||||
// 데이터 수신 리스너
|
||||
await MediaManager().initialize("guest_${ip.replaceAll('.', '_')}");
|
||||
_startHeartbeat();
|
||||
_clientSocket!.listen(
|
||||
(Uint8List data) => _onDataReceived(_clientSocket!, data),
|
||||
onError: (e) {
|
||||
print('[Guest] Connection error: $e');
|
||||
stopNetwork();
|
||||
},
|
||||
onDone: () {
|
||||
print('[Guest] Disconnected by host');
|
||||
stopNetwork();
|
||||
},
|
||||
onError: (e) => stopNetwork(force: true),
|
||||
onDone: () => stopNetwork(force: true),
|
||||
);
|
||||
|
||||
notifyListeners();
|
||||
|
||||
} catch (e) {
|
||||
print('[Guest] Failed to join: $e');
|
||||
role = NetworkRole.none;
|
||||
_log("❌ 접속 실패: $e");
|
||||
if (!_isReconnecting) stopNetwork(force: true);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// 싱글 모드
|
||||
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();
|
||||
rethrow; // UI에서 에러 처리할 수 있게 던짐
|
||||
}
|
||||
|
||||
Future.delayed(const Duration(milliseconds: 100), () {
|
||||
_messageController.add({'type': 'GAME_START', 'gameId': gameId, 'config': selectedGameConfig});
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 5. Common Logic (데이터 송수신)
|
||||
// 데이터 송수신
|
||||
// ------------------------------------------------------------------------
|
||||
void sendPacket(PlayPacket packet) {
|
||||
sendMessage(packet.toJson());
|
||||
}
|
||||
|
||||
/// 메시지 전송
|
||||
void sendMessage(Map<String, dynamic> messageMap) {
|
||||
try {
|
||||
// JSON 변환
|
||||
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);
|
||||
// 패킷 경계 처리를 위해 끝에 줄바꿈(\n) 추가 (가장 간단한 delimiter)
|
||||
final List<int> data = utf8.encode('$jsonString\n');
|
||||
if (!isSystem) {
|
||||
if (type == 'chat') _log("📤 전송(#$_sendSeq): [CHAT]");
|
||||
else if (type == 'media') _log("📤 전송(#$_sendSeq): [MEDIA]");
|
||||
else _log("📤 전송(#$_sendSeq): $jsonString");
|
||||
}
|
||||
|
||||
final fullMessage = '$jsonString$PACKET_DELIMITER';
|
||||
final List<int> data = utf8.encode(fullMessage);
|
||||
|
||||
if (role == NetworkRole.host) {
|
||||
// Host는 모든 Guest에게 브로드캐스트
|
||||
for (var socket in _connectedGuests) {
|
||||
for (var socket in _connectedGuests.keys) {
|
||||
socket.add(data);
|
||||
}
|
||||
} else if (role == NetworkRole.guest) {
|
||||
// Guest는 Host에게 전송
|
||||
} else {
|
||||
_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);
|
||||
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;
|
||||
} catch (e) {
|
||||
_log("데이터 수신 에러: $e");
|
||||
}
|
||||
}
|
||||
|
||||
// 1. 앱 로직으로 전달
|
||||
_messageController.add(parsedData);
|
||||
void _processMessage(Socket? socket, String msg) {
|
||||
try {
|
||||
final Map<String, dynamic> jsonMap = jsonDecode(msg);
|
||||
final String type = jsonMap['type'] ?? '';
|
||||
|
||||
// 2. (옵션) Host라면, 받은 메시지를 다른 Guest들에게도 전달(Relay)해야 할 수 있음
|
||||
// 게임 로직에 따라 다르지만 보통 Host가 중계자 역할을 함
|
||||
// if (role == NetworkRole.host) { sendMessage(parsedData); }
|
||||
if (type == 'PING') {
|
||||
sendMessage({'type': 'PONG'});
|
||||
_lastPongTime = DateTime.now();
|
||||
return;
|
||||
}
|
||||
if (type == 'PONG') {
|
||||
_lastPongTime = DateTime.now();
|
||||
return;
|
||||
}
|
||||
if (type == 'HANDSHAKE') {
|
||||
final guestInfo = UserInfo.fromJson(jsonMap['payload']);
|
||||
if (role == NetworkRole.host && socket != null) {
|
||||
_connectedGuests[socket] = guestInfo;
|
||||
}
|
||||
guestList.removeWhere((u) => u.id == guestInfo.id);
|
||||
guestList.add(guestInfo);
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
if (index != -1) {
|
||||
guestList[index] = guestList[index].copyWith(isReady: isReady);
|
||||
notifyListeners();
|
||||
}
|
||||
if (role == NetworkRole.host) {
|
||||
sendMessage(jsonMap);
|
||||
_checkAllReadyAndStart();
|
||||
}
|
||||
_messageController.add(jsonMap);
|
||||
return;
|
||||
}
|
||||
|
||||
if (type == 'GAME_START') {
|
||||
if (jsonMap['gameId'] != null) selectedGameId = jsonMap['gameId'];
|
||||
if (jsonMap['config'] != null) selectedGameConfig = jsonMap['config'];
|
||||
_resetAllReadyState();
|
||||
_messageController.add(jsonMap);
|
||||
return;
|
||||
}
|
||||
|
||||
if (jsonMap.containsKey('payload') && jsonMap.containsKey('senderId')) {
|
||||
final packet = PlayPacket.fromJson(jsonMap);
|
||||
if (packet.type == PacketType.chat) {
|
||||
GlobalChatManager().onPacketReceived(packet);
|
||||
return;
|
||||
}
|
||||
if (packet.type == PacketType.media) {
|
||||
MediaManager().onMediaReceived(packet);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_messageController.add(jsonMap);
|
||||
|
||||
} catch (e) {
|
||||
print('[Network] Parse Error: $e\nMessage: $msg');
|
||||
_log("JSON 파싱 실패: $e");
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 6. Cleanup
|
||||
// ------------------------------------------------------------------------
|
||||
void _handleConnectionLost(dynamic reason) {
|
||||
if (role != NetworkRole.guest) return;
|
||||
_clientSocket?.destroy();
|
||||
_clientSocket = null;
|
||||
if (_disconnectWaitTimer != null && _disconnectWaitTimer!.isActive) return;
|
||||
_disconnectWaitTimer = Timer(const Duration(seconds: RECONNECT_WAIT_SEC), () {
|
||||
stopNetwork(force: true);
|
||||
});
|
||||
_attemptReconnection();
|
||||
}
|
||||
|
||||
/// 네트워크 종료 및 리소스 정리
|
||||
void stopNetwork() {
|
||||
print('[Network] Stopping network...');
|
||||
Future<void> _attemptReconnection() async {
|
||||
if (hostIp == null || hostPort == null) return;
|
||||
_isReconnecting = true;
|
||||
while (_disconnectWaitTimer != null && _disconnectWaitTimer!.isActive) {
|
||||
try {
|
||||
await joinRoom(hostIp!, hostPort!);
|
||||
_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;
|
||||
}
|
||||
|
||||
Future<void> stopNetwork({bool force = false}) async {
|
||||
if (!force && _disconnectWaitTimer != null) return;
|
||||
|
||||
_serverSocket?.close();
|
||||
_clientSocket?.close();
|
||||
for (var s in _connectedGuests.keys) s.close();
|
||||
_connectedGuests.clear();
|
||||
|
||||
// mDNS 중지
|
||||
_bonsoirBroadcast?.stop();
|
||||
_bonsoirDiscovery?.stop();
|
||||
|
||||
// 소켓 닫기
|
||||
_serverSocket?.close();
|
||||
_clientSocket?.close();
|
||||
|
||||
for (var socket in _connectedGuests) {
|
||||
socket.close();
|
||||
}
|
||||
_connectedGuests.clear();
|
||||
|
||||
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;
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,39 @@
|
||||
// lib/playwith_core.dart
|
||||
|
||||
library playwith_core;
|
||||
|
||||
export 'game/base_game.dart';
|
||||
export 'network/network_manager.dart';
|
||||
export 'model/user_info.dart';
|
||||
export 'model/play_packet.dart';
|
||||
export 'model/game_info.dart';
|
||||
export 'utils/sound_manager.dart';
|
||||
export 'manager/global_chat_manager.dart';
|
||||
export 'manager/media_manager.dart';
|
||||
export 'manager/settings_manager.dart';
|
||||
export 'manager/notification_manager.dart'; // 추가
|
||||
export 'database/ephemeral_database.dart';
|
||||
|
||||
// [Widget]
|
||||
export 'widgets/game_chat_overlay.dart';
|
||||
export 'widgets/avatar_widget.dart'; // [추가됨]
|
||||
export 'manager/voice_manager.dart';
|
||||
export 'widgets/voice_widget.dart';
|
||||
export 'widgets/ad_banner_widget.dart';
|
||||
export 'screens/game_selection_screen.dart';
|
||||
export 'game/sudoku_multi_game.dart';
|
||||
export 'game/quiz_game.dart';
|
||||
export 'game/spider_multi_game.dart';
|
||||
export 'game/omok_game.dart';
|
||||
export 'game/janggi_game.dart';
|
||||
export 'game/yutnori_game.dart';
|
||||
|
||||
export 'game/memory_game.dart';
|
||||
export 'game/tap_battle_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';
|
||||
@@ -0,0 +1,76 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../model/game_info.dart'; // 위에서 만든 모델
|
||||
|
||||
class GameSelectionScreen extends StatelessWidget {
|
||||
final Function(String gameId) onGameSelected;
|
||||
|
||||
const GameSelectionScreen({super.key, required this.onGameSelected});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text("게임 선택")),
|
||||
body: GridView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2, // 한 줄에 2개
|
||||
childAspectRatio: 0.8,
|
||||
crossAxisSpacing: 16,
|
||||
mainAxisSpacing: 16,
|
||||
),
|
||||
itemCount: AppGames.games.length,
|
||||
itemBuilder: (context, index) {
|
||||
final game = AppGames.games[index];
|
||||
final bool isReady = !game.description.contains("[준비중]"); // 간단한 활성화 체크
|
||||
|
||||
return Opacity(
|
||||
opacity: isReady ? 1.0 : 0.5,
|
||||
child: GestureDetector(
|
||||
onTap: isReady ? () => onGameSelected(game.id) : null,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: isReady ? Colors.blueAccent.withOpacity(0.3) : Colors.grey.withOpacity(0.3),
|
||||
width: 2
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(game.icon, size: 60, color: isReady ? Colors.blue : Colors.grey),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
game.name,
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Text(
|
||||
game.description,
|
||||
style: const TextStyle(fontSize: 12, color: Colors.grey),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,66 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_mobile_ads/google_mobile_ads.dart';
|
||||
|
||||
class AdBannerWidget extends StatefulWidget {
|
||||
const AdBannerWidget({super.key});
|
||||
|
||||
@override
|
||||
State<AdBannerWidget> createState() => _AdBannerWidgetState();
|
||||
}
|
||||
|
||||
class _AdBannerWidgetState extends State<AdBannerWidget> {
|
||||
BannerAd? _bannerAd;
|
||||
bool _isLoaded = false;
|
||||
|
||||
// 테스트용 광고 ID (실제 출시 전에는 본인의 광고 ID로 교체해야 합니다)
|
||||
final String _adUnitId = Platform.isAndroid
|
||||
? 'ca-app-pub-3940256099942544/6300978111' // 안드로이드 테스트 ID
|
||||
: 'ca-app-pub-3940256099942544/2934735716'; // iOS 테스트 ID
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadAd();
|
||||
}
|
||||
|
||||
void _loadAd() {
|
||||
_bannerAd = BannerAd(
|
||||
adUnitId: _adUnitId,
|
||||
request: const AdRequest(),
|
||||
size: AdSize.banner,
|
||||
listener: BannerAdListener(
|
||||
onAdLoaded: (ad) {
|
||||
debugPrint('$ad loaded.');
|
||||
setState(() {
|
||||
_isLoaded = true;
|
||||
});
|
||||
},
|
||||
onAdFailedToLoad: (ad, err) {
|
||||
debugPrint('BannerAd failed to load: $err');
|
||||
ad.dispose();
|
||||
},
|
||||
),
|
||||
)..load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_bannerAd?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_bannerAd != null && _isLoaded) {
|
||||
return Container(
|
||||
alignment: Alignment.center,
|
||||
width: _bannerAd!.size.width.toDouble(),
|
||||
height: _bannerAd!.size.height.toDouble(),
|
||||
child: AdWidget(ad: _bannerAd!),
|
||||
);
|
||||
}
|
||||
// 광고가 로드되지 않았을 때 공간을 차지하지 않거나 대체 위젯 표시
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../model/user_info.dart'; // Core 내부 참조
|
||||
|
||||
class AvatarWidget extends StatelessWidget {
|
||||
final UserInfo? user; // UserInfo 객체가 있을 때
|
||||
final String? base64Image; // 객체 없이 이미지 데이터만 있을 때 (설정 화면 등)
|
||||
final int colorValue; // 기본 색상
|
||||
final String nickname; // 기본 닉네임
|
||||
final double size;
|
||||
|
||||
const AvatarWidget({
|
||||
super.key,
|
||||
this.user,
|
||||
this.base64Image,
|
||||
this.colorValue = 0xFF2196F3,
|
||||
this.nickname = "?",
|
||||
this.size = 50,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 1. 우선순위: UserInfo > 직접 입력된 값
|
||||
String? img = user?.profileImageBase64 ?? base64Image;
|
||||
int color = user?.colorValue ?? colorValue;
|
||||
String name = user?.nickname ?? nickname;
|
||||
if (name.isEmpty) name = "?";
|
||||
|
||||
ImageProvider? imageProvider;
|
||||
|
||||
// 2. Base64 이미지 디코딩
|
||||
if (img != null && img.isNotEmpty) {
|
||||
try {
|
||||
Uint8List bytes = base64Decode(img);
|
||||
imageProvider = MemoryImage(bytes);
|
||||
} catch (e) {
|
||||
debugPrint("Avatar decode error: $e");
|
||||
}
|
||||
}
|
||||
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: imageProvider != null ? null : Color(color),
|
||||
image: imageProvider != null
|
||||
? DecorationImage(image: imageProvider, fit: BoxFit.cover)
|
||||
: null,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.2),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
)
|
||||
],
|
||||
),
|
||||
child: imageProvider == null
|
||||
? Center(
|
||||
child: Text(
|
||||
name.isNotEmpty ? name[0] : "?",
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: size * 0.5
|
||||
),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gal/gal.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import '../manager/global_chat_manager.dart';
|
||||
import '../manager/media_manager.dart';
|
||||
import '../database/ephemeral_database.dart';
|
||||
import '../network/network_manager.dart';
|
||||
import '../model/user_info.dart';
|
||||
import 'avatar_widget.dart';
|
||||
|
||||
class GameChatOverlay extends StatefulWidget {
|
||||
final double bottomOffset; // 초기 위치 설정을 위한 하단 여백
|
||||
|
||||
const GameChatOverlay({
|
||||
super.key,
|
||||
this.bottomOffset = 0.0,
|
||||
});
|
||||
|
||||
@override
|
||||
State<GameChatOverlay> createState() => _GameChatOverlayState();
|
||||
}
|
||||
|
||||
class _GameChatOverlayState extends State<GameChatOverlay> {
|
||||
final TextEditingController _textController = TextEditingController();
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
bool _isExpanded = false; // 채팅창 열림 여부
|
||||
Offset _position = Offset.zero; // 현재 위치
|
||||
bool _isInitialized = false; // 초기 위치 설정 여부
|
||||
|
||||
int _unreadCount = 0;
|
||||
String _latestPreview = "";
|
||||
|
||||
StreamSubscription? _chatSub;
|
||||
StreamSubscription? _mediaSub;
|
||||
int _lastChatLength = 0;
|
||||
int _lastMediaLength = 0;
|
||||
|
||||
// 창 크기 설정
|
||||
final double _fabSize = 60.0;
|
||||
final double _windowWidth = 320.0;
|
||||
final double _windowHeight = 450.0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_chatSub = GlobalChatManager().messageStream.listen((messages) {
|
||||
if (messages.isEmpty) return;
|
||||
if (messages.length > _lastChatLength) {
|
||||
final lastMsg = messages.last;
|
||||
if (!_isExpanded && mounted) {
|
||||
setState(() {
|
||||
_unreadCount++;
|
||||
_latestPreview = "${lastMsg.senderName}: ${lastMsg.text}";
|
||||
});
|
||||
}
|
||||
}
|
||||
_lastChatLength = messages.length;
|
||||
});
|
||||
|
||||
_mediaSub = MediaManager().galleryStream.listen((mediaList) {
|
||||
if (mediaList.isEmpty) return;
|
||||
if (mediaList.length > _lastMediaLength) {
|
||||
final lastMedia = mediaList.last;
|
||||
if (!_isExpanded && mounted) {
|
||||
setState(() {
|
||||
_unreadCount++;
|
||||
_latestPreview = "📷 사진 도착";
|
||||
});
|
||||
}
|
||||
}
|
||||
_lastMediaLength = mediaList.length;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_chatSub?.cancel();
|
||||
_mediaSub?.cancel();
|
||||
_textController.dispose();
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _toggleExpand() {
|
||||
setState(() {
|
||||
_isExpanded = !_isExpanded;
|
||||
if (_isExpanded) {
|
||||
_unreadCount = 0; // 열면 읽음 처리
|
||||
|
||||
// 화면 밖으로 나가지 않도록 위치 보정
|
||||
// (버튼 상태일 때 구석에 있다가 열리면 화면 밖으로 나갈 수 있음)
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
double newX = _position.dx;
|
||||
double newY = _position.dy;
|
||||
|
||||
if (newX + _windowWidth > screenSize.width) {
|
||||
newX = screenSize.width - _windowWidth - 10;
|
||||
}
|
||||
if (newY + _windowHeight > screenSize.height) {
|
||||
newY = screenSize.height - _windowHeight - 80; // 하단 여유
|
||||
}
|
||||
_position = Offset(math.max(10, newX), math.max(40, newY));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// 1. 초기 위치 설정 (우측 하단, 광고 위)
|
||||
if (!_isInitialized) {
|
||||
final initialX = constraints.maxWidth - _fabSize - 20;
|
||||
final initialY = constraints.maxHeight - _fabSize - widget.bottomOffset - 20;
|
||||
_position = Offset(initialX, initialY);
|
||||
_isInitialized = true;
|
||||
}
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
left: _position.dx,
|
||||
top: _position.dy,
|
||||
child: GestureDetector(
|
||||
onPanUpdate: (details) {
|
||||
setState(() {
|
||||
// 2. 드래그 이동 (화면 밖으로 나가지 않게 제한)
|
||||
double newX = _position.dx + details.delta.dx;
|
||||
double newY = _position.dy + details.delta.dy;
|
||||
|
||||
final double currentWidth = _isExpanded ? _windowWidth : _fabSize;
|
||||
final double currentHeight = _isExpanded ? _windowHeight : _fabSize;
|
||||
|
||||
newX = newX.clamp(0.0, constraints.maxWidth - currentWidth);
|
||||
newY = newY.clamp(0.0, constraints.maxHeight - currentHeight);
|
||||
|
||||
_position = Offset(newX, newY);
|
||||
});
|
||||
},
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
elevation: 8,
|
||||
borderRadius: BorderRadius.circular(_isExpanded ? 20 : 30),
|
||||
child: _isExpanded ? _buildExpandedView() : _buildCollapsedView(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// [UI] 닫힌 상태 (플로팅 버튼)
|
||||
Widget _buildCollapsedView() {
|
||||
return GestureDetector(
|
||||
onTap: _toggleExpand,
|
||||
child: Container(
|
||||
width: _fabSize,
|
||||
height: _fabSize,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.blueAccent,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
const Icon(Icons.chat_bubble_outline, color: Colors.white, size: 28),
|
||||
if (_unreadCount > 0)
|
||||
Positioned(
|
||||
right: 0,
|
||||
top: 0,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.redAccent,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Text(
|
||||
_unreadCount > 9 ? "9+" : "$_unreadCount",
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// [UI] 열린 상태 (채팅창)
|
||||
Widget _buildExpandedView() {
|
||||
return Container(
|
||||
width: _windowWidth,
|
||||
height: _windowHeight,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.9),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: Colors.white12),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// 헤더 (드래그 핸들)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white10,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Icon(Icons.drag_handle, color: Colors.white54),
|
||||
const Text("채팅", style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold)),
|
||||
GestureDetector(
|
||||
onTap: _toggleExpand,
|
||||
child: const Icon(Icons.close, color: Colors.white70),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 미디어 갤러리 (있으면 표시)
|
||||
StreamBuilder<List<MediaItem>>(
|
||||
stream: MediaManager().galleryStream,
|
||||
initialData: const [],
|
||||
builder: (context, snapshot) {
|
||||
final mediaList = snapshot.data ?? [];
|
||||
if (mediaList.isEmpty) return const SizedBox();
|
||||
|
||||
return Container(
|
||||
height: 80,
|
||||
color: Colors.black12,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.all(8),
|
||||
itemCount: mediaList.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = mediaList[index];
|
||||
return GestureDetector(
|
||||
onTap: () => _showFullImage(context, item),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Image.file(
|
||||
File(item.filePath),
|
||||
width: 64, height: 64,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
// 채팅 리스트
|
||||
Expanded(
|
||||
child: StreamBuilder<List<ChatMessage>>(
|
||||
stream: GlobalChatManager().messageStream,
|
||||
builder: (context, snapshot) {
|
||||
final messages = snapshot.data ?? [];
|
||||
return ListView.builder(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.all(12),
|
||||
itemCount: messages.length,
|
||||
itemBuilder: (context, index) {
|
||||
final msg = messages[index];
|
||||
UserInfo? senderInfo;
|
||||
if (!msg.isMe) {
|
||||
try {
|
||||
senderInfo = NetworkManager().guestList.firstWhere((u) => u.id == msg.senderId);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
mainAxisAlignment: msg.isMe ? MainAxisAlignment.end : MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
if (!msg.isMe) ...[
|
||||
AvatarWidget(user: senderInfo, nickname: msg.senderName, size: 28),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Flexible(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: msg.isMe ? Colors.blueAccent : Colors.white12,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (!msg.isMe)
|
||||
Text(msg.senderName, style: const TextStyle(fontSize: 10, color: Colors.grey)),
|
||||
Text(msg.text, style: const TextStyle(color: Colors.white)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// 입력창
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add_photo_alternate, color: Colors.blueAccent),
|
||||
onPressed: _pickAndSendImage,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white12,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: TextField(
|
||||
controller: _textController,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
decoration: const InputDecoration(
|
||||
hintText: "메시지...",
|
||||
hintStyle: TextStyle(color: Colors.white38),
|
||||
border: InputBorder.none,
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.symmetric(vertical: 10),
|
||||
),
|
||||
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.delayed(const Duration(milliseconds: 100), () {
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.jumpTo(_scrollController.position.maxScrollExtent);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _pickAndSendImage() async {
|
||||
final picker = ImagePicker();
|
||||
final XFile? image = await picker.pickImage(
|
||||
source: ImageSource.gallery,
|
||||
imageQuality: 70,
|
||||
maxWidth: 1024,
|
||||
);
|
||||
|
||||
if (image != null) {
|
||||
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: 40,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.download, color: Colors.white, size: 30),
|
||||
tooltip: "저장",
|
||||
onPressed: () => _saveImageToGallery(context, item.filePath),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _saveImageToGallery(BuildContext context, String filePath) async {
|
||||
try {
|
||||
await Gal.putImage(filePath);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text("저장되었습니다! ✅")),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text("저장 실패: $e")),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../model/spider_model.dart';
|
||||
|
||||
class SpiderCardWidget extends StatelessWidget {
|
||||
final SpiderCard card;
|
||||
final double width;
|
||||
final double height;
|
||||
|
||||
const SpiderCardWidget({
|
||||
super.key,
|
||||
required this.card,
|
||||
required this.width,
|
||||
required this.height,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: width,
|
||||
height: height,
|
||||
decoration: BoxDecoration(
|
||||
color: card.isFaceUp ? Colors.white : Colors.blue[800], // 뒷면 색상
|
||||
border: Border.all(color: Colors.black, width: 0.5),
|
||||
borderRadius: BorderRadius.circular(4.0),
|
||||
boxShadow: [
|
||||
BoxShadow(color: Colors.black26, blurRadius: 2, offset: const Offset(1, 1)),
|
||||
],
|
||||
),
|
||||
child: card.isFaceUp ? _buildFace() : _buildBack(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBack() {
|
||||
return Center(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.white, width: 1),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
child: const Center(
|
||||
child: Icon(Icons.pets, color: Colors.white30, size: 20),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFace() {
|
||||
return Stack(
|
||||
children: [
|
||||
// 왼쪽 상단 숫자
|
||||
Positioned(
|
||||
top: 2, left: 4,
|
||||
child: Column(
|
||||
children: [
|
||||
Text(card.rankText, style: TextStyle(color: card.isRed ? Colors.red : Colors.black, fontWeight: FontWeight.bold, fontSize: 14)),
|
||||
Text(card.suitSymbol, style: TextStyle(color: card.isRed ? Colors.red : Colors.black, fontSize: 10)),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 중앙 심볼
|
||||
Center(
|
||||
child: Text(
|
||||
card.suitSymbol,
|
||||
style: TextStyle(color: card.isRed ? Colors.red : Colors.black, fontSize: width * 0.5),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 1. Sudoku Board (보드판)
|
||||
// -----------------------------------------------------------------------------
|
||||
class SudokuBoard extends StatelessWidget {
|
||||
final int blockSize;
|
||||
final List<int> cells;
|
||||
final List<int> originalCells;
|
||||
final int? selectedIndex;
|
||||
final int? selectedNumberPad;
|
||||
final Set<int> incorrectCells;
|
||||
final Function(int) onCellTapped;
|
||||
|
||||
const SudokuBoard({
|
||||
super.key,
|
||||
required this.blockSize,
|
||||
required this.cells,
|
||||
required this.originalCells,
|
||||
required this.selectedIndex,
|
||||
required this.selectedNumberPad,
|
||||
required this.incorrectCells,
|
||||
required this.onCellTapped,
|
||||
});
|
||||
|
||||
String _getSymbol(int value) {
|
||||
if (value == 0) return '';
|
||||
if (value >= 1 && value <= 9) return value.toString();
|
||||
if (value >= 10) return String.fromCharCode('A'.codeUnitAt(0) + (value - 10));
|
||||
return '?';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final int gridSize = blockSize * blockSize;
|
||||
final double fontSize = (gridSize > 9) ? 12 : 24;
|
||||
final bool isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
// 심플한 색상 정의 (테마 의존성 제거)
|
||||
final Color thickBorderColor = isDark ? Colors.white70 : Colors.black87;
|
||||
final Color thinBorderColor = isDark ? Colors.white24 : Colors.black12;
|
||||
|
||||
final Color incorrectBg = Colors.red.withOpacity(0.2);
|
||||
final Color highlightedBg = Colors.blue.withOpacity(0.2);
|
||||
final Color selectedBg = Colors.blue.withOpacity(0.4); // 선택된 셀 배경
|
||||
final Color editableBg = isDark ? Colors.grey[800]! : Colors.white;
|
||||
final Color fixedBg = isDark ? Colors.grey[700]! : Colors.grey[200]!;
|
||||
|
||||
final Color selectedTextColor = Colors.white;
|
||||
final Color incorrectTextColor = Colors.red;
|
||||
final Color editableTextColor = Colors.blue[700]!;
|
||||
final Color fixedTextColor = isDark ? Colors.white : Colors.black;
|
||||
|
||||
return AspectRatio(
|
||||
aspectRatio: 1.0,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(border: Border.all(color: thickBorderColor, width: 2)),
|
||||
child: GridView.builder(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: gridSize,
|
||||
),
|
||||
itemCount: gridSize * gridSize,
|
||||
itemBuilder: (context, index) {
|
||||
int row = index ~/ gridSize;
|
||||
int col = index % gridSize;
|
||||
|
||||
int cellValue = cells[index];
|
||||
bool isEditable = (originalCells[index] == 0);
|
||||
bool isSelected = (index == selectedIndex);
|
||||
|
||||
// 같은 숫자가 선택되었을 때 하이라이트
|
||||
bool isHighlighted = (cellValue != 0 &&
|
||||
selectedNumberPad != null &&
|
||||
cellValue == selectedNumberPad);
|
||||
|
||||
bool isIncorrect = incorrectCells.contains(index);
|
||||
|
||||
// 테두리 그리기 (블록 경계는 두껍게)
|
||||
BorderSide rightBorder = (col % blockSize == blockSize - 1 && col != gridSize - 1)
|
||||
? BorderSide(color: thickBorderColor, width: 2.0)
|
||||
: BorderSide(color: thinBorderColor, width: 0.5);
|
||||
|
||||
BorderSide bottomBorder = (row % blockSize == blockSize - 1 && row != gridSize - 1)
|
||||
? BorderSide(color: thickBorderColor, width: 2.0)
|
||||
: BorderSide(color: thinBorderColor, width: 0.5);
|
||||
|
||||
Color bgColor = isEditable ? editableBg : fixedBg;
|
||||
if (isIncorrect) bgColor = incorrectBg;
|
||||
else if (isSelected) bgColor = selectedBg; // 선택된 셀이 우선
|
||||
else if (isHighlighted) bgColor = highlightedBg;
|
||||
|
||||
Color txtColor = isEditable ? editableTextColor : fixedTextColor;
|
||||
if (isSelected) txtColor = selectedTextColor;
|
||||
if (isIncorrect) txtColor = incorrectTextColor;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => onCellTapped(index),
|
||||
child: Container(
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor,
|
||||
border: Border(right: rightBorder, bottom: bottomBorder),
|
||||
),
|
||||
child: Text(
|
||||
_getSymbol(cellValue),
|
||||
style: TextStyle(
|
||||
fontSize: fontSize,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: txtColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 2. Number Pad (숫자 키패드)
|
||||
// -----------------------------------------------------------------------------
|
||||
class NumberPad extends StatelessWidget {
|
||||
final int blockSize;
|
||||
final Map<int, int> numberCounts;
|
||||
final int? selectedNumber;
|
||||
final Function(int) onNumberTapped;
|
||||
|
||||
const NumberPad({
|
||||
super.key,
|
||||
required this.blockSize,
|
||||
required this.numberCounts,
|
||||
required this.selectedNumber,
|
||||
required this.onNumberTapped,
|
||||
});
|
||||
|
||||
String _getSymbol(int value) {
|
||||
if (value >= 1 && value <= 9) return value.toString();
|
||||
if (value >= 10) return String.fromCharCode('A'.codeUnitAt(0) + (value - 10));
|
||||
return '?';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final int gridSize = blockSize * blockSize;
|
||||
// 가로 모드 등 복잡한 레이아웃 제거하고 단순 GridView로 통일
|
||||
return GridView.builder(
|
||||
itemCount: gridSize,
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: blockSize > 3 ? 8 : blockSize * 3, // 적절히 줄 바꿈
|
||||
mainAxisSpacing: 8,
|
||||
crossAxisSpacing: 8,
|
||||
childAspectRatio: 1.2,
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
int numberValue = index + 1;
|
||||
bool isSelected = (numberValue == selectedNumber);
|
||||
bool isCompleted = (numberCounts[numberValue] ?? 0) >= gridSize;
|
||||
|
||||
return ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: isSelected ? Colors.blue : (isCompleted ? Colors.grey[300] : Colors.white),
|
||||
foregroundColor: isSelected ? Colors.white : (isCompleted ? Colors.grey : Colors.black),
|
||||
elevation: isCompleted ? 0 : 2,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
onPressed: isCompleted ? null : () => onNumberTapped(numberValue),
|
||||
child: Text(
|
||||
_getSymbol(numberValue),
|
||||
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../manager/voice_manager.dart';
|
||||
|
||||
class VoiceWidget extends StatefulWidget {
|
||||
final bool isListening;
|
||||
|
||||
const VoiceWidget({super.key, required this.isListening});
|
||||
|
||||
@override
|
||||
State<VoiceWidget> createState() => _VoiceWidgetState();
|
||||
}
|
||||
|
||||
class _VoiceWidgetState extends State<VoiceWidget> with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
String _liveText = "말씀하세요...";
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1000)
|
||||
)..repeat(reverse: true);
|
||||
|
||||
// 실시간 인식 내용 구독
|
||||
VoiceManager().resultStream.listen((text) {
|
||||
if (mounted) {
|
||||
setState(() => _liveText = text);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!widget.isListening) return const SizedBox();
|
||||
|
||||
return Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 100), // 하단에서 좀 띄움
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black87,
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 마이크 아이콘 애니메이션
|
||||
FadeTransition(
|
||||
opacity: _controller,
|
||||
child: const Icon(Icons.mic, color: Colors.redAccent, size: 40),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
// 인식된 텍스트
|
||||
Text(
|
||||
_liveText,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,30 @@ environment:
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
# 네트워크 디스커버리 (mDNS)
|
||||
bonsoir: ^2.0.0
|
||||
# 고유 ID 생성
|
||||
# 기존 ^2.0.0 등을 지우고 최신 버전으로 변경
|
||||
bonsoir: ^6.0.1
|
||||
uuid: ^4.0.0
|
||||
# 데이터 비교 및 불변성 (선택사항이지만 추천)
|
||||
equatable: ^2.0.5
|
||||
permission_handler: ^11.0.0
|
||||
# [DB]
|
||||
drift: ^2.13.0
|
||||
sqlite3_flutter_libs: ^0.5.0
|
||||
path_provider: ^2.1.1
|
||||
path: ^1.8.3
|
||||
gal: ^2.3.0 # [추가] 갤러리 저장용
|
||||
# [파일 피커]
|
||||
image_picker: ^1.1.2
|
||||
file_picker: ^8.1.4
|
||||
shared_preferences: ^2.2.2
|
||||
speech_to_text: ^7.0.0
|
||||
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
|
||||
build_runner: ^2.4.6
|
||||
@@ -1,31 +0,0 @@
|
||||
# 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/
|
||||
@@ -1,10 +0,0 @@
|
||||
# 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
|
||||
@@ -1,3 +0,0 @@
|
||||
## 0.0.1
|
||||
|
||||
* TODO: Describe initial release.
|
||||
@@ -1 +0,0 @@
|
||||
TODO: Add your license here.
|
||||
@@ -1,39 +0,0 @@
|
||||
<!--
|
||||
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.
|
||||
@@ -1,4 +0,0 @@
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
# Additional information about this file can be found at
|
||||
# https://dart.dev/guides/language/analysis-options
|
||||
@@ -1,5 +0,0 @@
|
||||
/// A Calculator.
|
||||
class Calculator {
|
||||
/// Returns [value] plus 1.
|
||||
int addOne(int value) => value + 1;
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
name: playwith_game_quiz
|
||||
description: "A new Flutter package project."
|
||||
version: 0.0.1
|
||||
homepage:
|
||||
|
||||
environment:
|
||||
sdk: ^3.9.2
|
||||
flutter: ">=1.17.0"
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
# 코어 패키지 의존성 추가
|
||||
playwith_core:
|
||||
path: ../../core
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^5.0.0
|
||||
|
||||
# For information on the generic Dart part of this file, see the
|
||||
# following page: https://dart.dev/tools/pub/pubspec
|
||||
|
||||
# The following section is specific to Flutter packages.
|
||||
flutter:
|
||||
|
||||
# To add assets to your package, add an assets section, like this:
|
||||
# assets:
|
||||
# - images/a_dot_burr.jpeg
|
||||
# - images/a_dot_ham.jpeg
|
||||
#
|
||||
# For details regarding assets in packages, see
|
||||
# https://flutter.dev/to/asset-from-package
|
||||
#
|
||||
# An image asset can refer to one or more resolution-specific "variants", see
|
||||
# https://flutter.dev/to/resolution-aware-images
|
||||
|
||||
# To add custom fonts to your package, add a fonts section here,
|
||||
# in this "flutter" section. Each entry in this list should have a
|
||||
# "family" key with the font family name, and a "fonts" key with a
|
||||
# list giving the asset and other descriptors for the font. For
|
||||
# example:
|
||||
# fonts:
|
||||
# - family: Schyler
|
||||
# fonts:
|
||||
# - asset: fonts/Schyler-Regular.ttf
|
||||
# - asset: fonts/Schyler-Italic.ttf
|
||||
# style: italic
|
||||
# - family: Trajan Pro
|
||||
# fonts:
|
||||
# - asset: fonts/TrajanPro.ttf
|
||||
# - asset: fonts/TrajanPro_Bold.ttf
|
||||
# weight: 700
|
||||
#
|
||||
# For details regarding fonts in packages, see
|
||||
# https://flutter.dev/to/font-from-package
|
||||
@@ -1,12 +0,0 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:playwith_game_quiz/playwith_game_quiz.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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user