493 lines
19 KiB
Kotlin
493 lines
19 KiB
Kotlin
package com.unogame
|
|
|
|
import android.os.Bundle
|
|
import android.content.pm.ActivityInfo
|
|
import androidx.activity.ComponentActivity
|
|
import androidx.activity.compose.setContent
|
|
import androidx.compose.foundation.layout.fillMaxSize
|
|
import androidx.compose.material3.MaterialTheme
|
|
import androidx.compose.material3.darkColorScheme
|
|
import androidx.compose.runtime.*
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.platform.LocalContext
|
|
import androidx.navigation.NavType
|
|
import androidx.navigation.compose.*
|
|
import androidx.navigation.navArgument
|
|
import com.unogame.game.GameMode
|
|
import com.unogame.model.*
|
|
import com.unogame.network.*
|
|
import com.unogame.ui.navigation.Screen
|
|
import com.unogame.ui.screens.*
|
|
import com.unogame.ui.theme.*
|
|
import kotlinx.coroutines.launch
|
|
import java.net.Inet4Address
|
|
import java.net.NetworkInterface
|
|
|
|
fun getLocalIpAddress(): String {
|
|
try {
|
|
NetworkInterface.getNetworkInterfaces()?.asSequence()?.forEach { iface ->
|
|
if (iface.isLoopback || !iface.isUp) return@forEach
|
|
iface.inetAddresses.asSequence()
|
|
.filter { !it.isLoopbackAddress && it is Inet4Address }
|
|
.forEach { return it.hostAddress ?: "" }
|
|
}
|
|
} catch (_: Exception) {}
|
|
return ""
|
|
}
|
|
|
|
class MainActivity : ComponentActivity() {
|
|
|
|
override fun onCreate(savedInstanceState: Bundle?) {
|
|
super.onCreate(savedInstanceState)
|
|
|
|
setContent {
|
|
val darkScheme = darkColorScheme(
|
|
primary = GoldAccent,
|
|
secondary = GoldAccent,
|
|
background = DarkBackground,
|
|
surface = DarkSurface
|
|
)
|
|
|
|
MaterialTheme(colorScheme = darkScheme) {
|
|
UnoApp()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
fun UnoApp() {
|
|
val navController = rememberNavController()
|
|
val scope = rememberCoroutineScope()
|
|
val context = LocalContext.current
|
|
val prefs = context.getSharedPreferences("unogame_prefs", android.content.Context.MODE_PRIVATE)
|
|
|
|
// Load saved player name
|
|
var savedName by remember { mutableStateOf(prefs.getString("player_name", "玩家") ?: "玩家") }
|
|
var cardTheme by remember { mutableStateOf(CardTheme.load(context)) }
|
|
var tableBg by remember { mutableStateOf(TableBg.load(context)) }
|
|
var isLandscape by remember { mutableStateOf(prefs.getBoolean("landscape", false)) }
|
|
|
|
// Apply orientation on start
|
|
LaunchedEffect(Unit) {
|
|
(context as? android.app.Activity)?.requestedOrientation =
|
|
if (isLandscape) ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
|
|
else ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
|
}
|
|
|
|
// Apply saved orientation
|
|
LaunchedEffect(isLandscape) {
|
|
(context as? android.app.Activity)?.requestedOrientation =
|
|
if (isLandscape) ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
|
|
else ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
|
}
|
|
|
|
// Network state
|
|
var gameServer by remember { mutableStateOf<GameServer?>(null) }
|
|
var gameClient by remember { mutableStateOf<GameClient?>(null) }
|
|
var discoveryService by remember { mutableStateOf<DiscoveryService?>(null) }
|
|
var isHost by remember { mutableStateOf(false) }
|
|
var myPlayerId by remember { mutableStateOf("") }
|
|
var myName by remember { mutableStateOf("") }
|
|
var errorMessage by remember { mutableStateOf("") }
|
|
|
|
// Game state
|
|
var gameState by remember { mutableStateOf<GameState?>(null) }
|
|
var myCards by remember { mutableStateOf<List<Card>>(emptyList()) }
|
|
var players by remember { mutableStateOf<List<Player>>(emptyList()) }
|
|
var selectedWildColor by remember { mutableStateOf<CardColor?>(null) }
|
|
|
|
// Discovery state
|
|
var discoveredHosts by remember { mutableStateOf<List<DiscoveredHost>>(emptyList()) }
|
|
var isDiscovering by remember { mutableStateOf(false) }
|
|
var isConnecting by remember { mutableStateOf(false) }
|
|
var connectedHost by remember { mutableStateOf("") }
|
|
var hostIp by remember { mutableStateOf("") }
|
|
|
|
// Cleanup function
|
|
fun cleanup() {
|
|
gameServer?.shutdown()
|
|
gameClient?.disconnect()
|
|
discoveryService?.shutdown()
|
|
gameServer = null
|
|
gameClient = null
|
|
discoveryService = null
|
|
myPlayerId = ""
|
|
isHost = false
|
|
errorMessage = ""
|
|
gameState = null
|
|
myCards = emptyList()
|
|
players = emptyList()
|
|
discoveredHosts = emptyList()
|
|
isDiscovering = false
|
|
isConnecting = false
|
|
connectedHost = ""
|
|
hostIp = ""
|
|
}
|
|
|
|
// Listen to client events
|
|
LaunchedEffect(gameClient) {
|
|
gameClient?.events?.collect { event ->
|
|
// Always sync critical state from client (StateFlow events can be lost)
|
|
val myId = gameClient?.myPlayerId ?: ""
|
|
if (myId.isNotEmpty() && myPlayerId.isEmpty()) {
|
|
myPlayerId = myId
|
|
players = gameClient?.currentPlayers ?: players
|
|
isConnecting = false
|
|
connectedHost = "${gameClient?.currentPlayers?.find { it.isHost }?.name ?: "主机"}"
|
|
}
|
|
when (event?.type) {
|
|
"CONNECTED" -> {
|
|
myPlayerId = event.playerId
|
|
players = event.players
|
|
isConnecting = false
|
|
connectedHost = "${event.players.find { it.isHost }?.name ?: "主机"}"
|
|
}
|
|
"PLAYER_JOINED" -> {
|
|
players = gameClient?.currentPlayers ?: event.players
|
|
}
|
|
"PLAYER_LEFT" -> {
|
|
players = gameClient?.currentPlayers ?: event.players
|
|
}
|
|
"GAME_STARTED" -> {
|
|
gameState = event.gameState
|
|
myCards = event.playerCards
|
|
players = event.players
|
|
selectedWildColor = null
|
|
navController.navigate(Screen.Game.route)
|
|
}
|
|
"GAME_STATE" -> {
|
|
val prevState = gameState
|
|
gameState = event.gameState
|
|
myCards = event.playerCards
|
|
players = event.players
|
|
// Navigate to game over if needed
|
|
if (event.gameState?.isGameOver == true && prevState?.isGameOver != true) {
|
|
val winner = event.gameState.winner
|
|
val isYou = winner?.id == myPlayerId
|
|
navController.navigate(
|
|
Screen.GameOver.createRoute(
|
|
winnerName = winner?.name ?: "",
|
|
isYouWinner = isYou
|
|
)
|
|
)
|
|
}
|
|
}
|
|
"ERROR" -> {
|
|
errorMessage = event.message
|
|
}
|
|
"DISCONNECTED" -> {
|
|
errorMessage = event.message
|
|
navController.navigate(Screen.MainMenu.route) {
|
|
popUpTo(0) { inclusive = true }
|
|
}
|
|
cleanup()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
CompositionLocalProvider(
|
|
LocalCardTheme provides cardTheme,
|
|
LocalTableBg provides tableBg
|
|
) {
|
|
NavHost(
|
|
navController = navController,
|
|
startDestination = Screen.MainMenu.route,
|
|
modifier = Modifier.fillMaxSize()
|
|
) {
|
|
composable(Screen.MainMenu.route) {
|
|
MainMenuScreen(
|
|
initialName = savedName,
|
|
onNameChanged = { name ->
|
|
savedName = name
|
|
prefs.edit().putString("player_name", name).apply()
|
|
},
|
|
onLocalGame = {
|
|
navController.navigate(Screen.ModeSelect.route)
|
|
},
|
|
onScoreboard = {
|
|
navController.navigate(Screen.Scoreboard.route)
|
|
},
|
|
onRules = {
|
|
navController.navigate(Screen.Rules.route)
|
|
},
|
|
currentTheme = cardTheme,
|
|
currentBg = tableBg,
|
|
onToggleTheme = {
|
|
val next = CardTheme.values()[(cardTheme.ordinal + 1) % CardTheme.values().size]
|
|
cardTheme = next
|
|
CardTheme.save(context, next)
|
|
},
|
|
onToggleBg = {
|
|
val next = TableBg.values()[(tableBg.ordinal + 1) % TableBg.values().size]
|
|
tableBg = next
|
|
TableBg.save(context, next)
|
|
},
|
|
onToggleOrientation = {
|
|
isLandscape = !isLandscape
|
|
prefs.edit().putBoolean("landscape", isLandscape).apply()
|
|
(context as? android.app.Activity)?.requestedOrientation =
|
|
if (isLandscape) ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
|
|
else ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
|
},
|
|
isLandscape = isLandscape,
|
|
onHostGame = { name ->
|
|
myName = name
|
|
isHost = true
|
|
errorMessage = ""
|
|
hostIp = getLocalIpAddress()
|
|
val server = GameServer()
|
|
if (server.start()) {
|
|
gameServer = server
|
|
val discovery = DiscoveryService()
|
|
discoveryService = discovery
|
|
discovery.startAdvertising(name, 1)
|
|
navController.navigate(Screen.Lobby.createRoute(true))
|
|
// Host connects as a client too (to localhost) - on IO thread
|
|
scope.launch {
|
|
val client = GameClient()
|
|
if (client.connect("127.0.0.1", name)) {
|
|
gameClient = client
|
|
}
|
|
}
|
|
} else {
|
|
errorMessage = "无法创建房间,请检查网络"
|
|
}
|
|
},
|
|
onJoinGame = { name ->
|
|
myName = name
|
|
isHost = false
|
|
val discovery = DiscoveryService()
|
|
discoveryService = discovery
|
|
discovery.startDiscovery(name)
|
|
isDiscovering = true
|
|
|
|
scope.launch {
|
|
discovery.hosts.collect { hosts ->
|
|
discoveredHosts = hosts
|
|
isDiscovering = hosts.isEmpty()
|
|
}
|
|
}
|
|
navController.navigate(Screen.Lobby.createRoute(false))
|
|
}
|
|
)
|
|
}
|
|
|
|
composable(
|
|
route = Screen.Lobby.route,
|
|
arguments = listOf(navArgument("isHost") { type = NavType.BoolType })
|
|
) { backStackEntry ->
|
|
val hostFlag = backStackEntry.arguments?.getBoolean("isHost") ?: false
|
|
|
|
LaunchedEffect(Unit) {
|
|
if (hostFlag && players.size < 2) {
|
|
errorMessage = ""
|
|
}
|
|
errorMessage = ""
|
|
}
|
|
|
|
LobbyScreen(
|
|
isHost = hostFlag,
|
|
hostIp = hostIp,
|
|
players = players,
|
|
discoveredHosts = discoveredHosts,
|
|
isDiscovering = isDiscovering,
|
|
isConnecting = isConnecting,
|
|
connectedHost = connectedHost,
|
|
errorMessage = errorMessage,
|
|
onStartGame = {
|
|
scope.launch { gameClient?.startGame() }
|
|
},
|
|
onJoinHost = { host ->
|
|
isConnecting = true
|
|
errorMessage = ""
|
|
scope.launch {
|
|
try {
|
|
val client = GameClient()
|
|
if (client.connect(host.address, myName)) {
|
|
gameClient = client
|
|
discoveryService?.stopDiscovery()
|
|
} else {
|
|
errorMessage = "无法连接到 ${host.name} (${host.address}): ${client.lastError}"
|
|
}
|
|
} catch (e: Exception) {
|
|
android.util.Log.e("UnoApp", "加入房间崩溃", e)
|
|
errorMessage = "连接出错: ${e.message}"
|
|
}
|
|
isConnecting = false
|
|
}
|
|
},
|
|
onRefreshDiscovery = {
|
|
discoveredHosts = emptyList()
|
|
isDiscovering = true
|
|
discoveryService?.stopDiscovery()
|
|
discoveryService = DiscoveryService()
|
|
discoveryService?.startDiscovery(myName)
|
|
scope.launch {
|
|
discoveryService?.hosts?.collect { hosts ->
|
|
discoveredHosts = hosts
|
|
isDiscovering = hosts.isEmpty()
|
|
}
|
|
}
|
|
},
|
|
onManualConnect = { ip ->
|
|
isConnecting = true
|
|
errorMessage = ""
|
|
scope.launch {
|
|
try {
|
|
val client = GameClient()
|
|
if (client.connect(ip, myName)) {
|
|
gameClient = client
|
|
discoveryService?.stopDiscovery()
|
|
} else {
|
|
errorMessage = "无法连接到 $ip: ${client.lastError}"
|
|
}
|
|
} catch (e: Exception) {
|
|
android.util.Log.e("UnoApp", "手动连接崩溃", e)
|
|
errorMessage = "连接出错: ${e.message}"
|
|
}
|
|
isConnecting = false
|
|
}
|
|
},
|
|
onBack = {
|
|
cleanup()
|
|
navController.popBackStack()
|
|
}
|
|
)
|
|
}
|
|
|
|
composable(Screen.ModeSelect.route) {
|
|
ModeSelectScreen(
|
|
playerName = savedName,
|
|
onStartGame = { mode ->
|
|
navController.navigate(Screen.LocalSetup.createRoute(mode.name))
|
|
},
|
|
onBack = { navController.popBackStack() }
|
|
)
|
|
}
|
|
|
|
composable(
|
|
route = Screen.LocalSetup.route,
|
|
arguments = listOf(navArgument("modeName") { type = NavType.StringType })
|
|
) { backStackEntry ->
|
|
val modeName = backStackEntry.arguments?.getString("modeName") ?: "NORMAL"
|
|
val mode = try { GameMode.valueOf(modeName) } catch (_: Exception) { GameMode.NORMAL }
|
|
|
|
LocalSetupScreen(
|
|
playerName = savedName,
|
|
modeDisplayName = mode.displayName,
|
|
mode = mode,
|
|
onStartGame = { totalPlayers, name ->
|
|
if (name.isNotEmpty()) {
|
|
savedName = name
|
|
prefs.edit().putString("player_name", name).apply()
|
|
}
|
|
navController.navigate(
|
|
Screen.LocalGame.createRoute(modeName, totalPlayers, name)
|
|
)
|
|
},
|
|
onBack = { navController.popBackStack() }
|
|
)
|
|
}
|
|
|
|
composable(
|
|
route = Screen.LocalGame.route,
|
|
arguments = listOf(
|
|
navArgument("modeName") { type = NavType.StringType },
|
|
navArgument("totalPlayers") { type = NavType.IntType },
|
|
navArgument("humanPlayerName") { type = NavType.StringType }
|
|
)
|
|
) { backStackEntry ->
|
|
val modeName = backStackEntry.arguments?.getString("modeName") ?: "NORMAL"
|
|
val mode = try { GameMode.valueOf(modeName) } catch (_: Exception) { GameMode.NORMAL }
|
|
val totalPlayers = backStackEntry.arguments?.getInt("totalPlayers") ?: 2
|
|
val humanPlayerName = backStackEntry.arguments?.getString("humanPlayerName") ?: "玩家"
|
|
|
|
LocalGameScreen(
|
|
mode = mode,
|
|
totalPlayers = totalPlayers,
|
|
humanPlayerName = humanPlayerName,
|
|
onBackToMenu = {
|
|
navController.navigate(Screen.MainMenu.route) {
|
|
popUpTo(0) { inclusive = true }
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
composable(Screen.Scoreboard.route) {
|
|
ScoreboardScreen(onBack = { navController.popBackStack() })
|
|
}
|
|
|
|
composable(Screen.Rules.route) {
|
|
RulesHelpScreen(onBack = { navController.popBackStack() })
|
|
}
|
|
|
|
composable(Screen.Game.route) {
|
|
gameState?.let { state ->
|
|
GameScreen(
|
|
gameState = state,
|
|
myCards = myCards,
|
|
myPlayerId = myPlayerId,
|
|
isMyTurn = state.currentPlayer.id == myPlayerId,
|
|
errorMessage = errorMessage,
|
|
onPlayCard = { index ->
|
|
errorMessage = ""
|
|
val colorToSend = if (index >= 0 && index < myCards.size && myCards[index].type.isWild) {
|
|
selectedWildColor
|
|
} else null
|
|
scope.launch { gameClient?.playCard(index, colorToSend) }
|
|
selectedWildColor = null
|
|
},
|
|
onDrawCard = {
|
|
errorMessage = ""
|
|
scope.launch { gameClient?.drawCard() }
|
|
},
|
|
onChooseColor = { color ->
|
|
selectedWildColor = color
|
|
},
|
|
onCallUno = {
|
|
// Send call uno to server
|
|
scope.launch {
|
|
gameClient?.let {
|
|
val msg = com.unogame.network.Protocol.Message(
|
|
type = com.unogame.network.Protocol.CMD_CALL_UNO,
|
|
playerId = myPlayerId
|
|
)
|
|
// Use a simple approach - mark locally
|
|
}
|
|
}
|
|
},
|
|
onChallengeUno = { targetId ->
|
|
// Challenge handled via normal message sending
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
composable(
|
|
route = Screen.GameOver.route,
|
|
arguments = listOf(
|
|
navArgument("winnerName") { type = NavType.StringType },
|
|
navArgument("isYouWinner") { type = NavType.BoolType }
|
|
)
|
|
) { backStackEntry ->
|
|
val winnerName = backStackEntry.arguments?.getString("winnerName") ?: ""
|
|
val isYouWinner = backStackEntry.arguments?.getBoolean("isYouWinner") ?: false
|
|
|
|
GameOverScreen(
|
|
winnerName = winnerName,
|
|
isYouWinner = isYouWinner,
|
|
onBackToMenu = {
|
|
cleanup()
|
|
navController.navigate(Screen.MainMenu.route) {
|
|
popUpTo(0) { inclusive = true }
|
|
}
|
|
}
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|