diff --git a/app/build.gradle.kts b/app/build.gradle.kts
new file mode 100644
index 0000000..641b103
--- /dev/null
+++ b/app/build.gradle.kts
@@ -0,0 +1,60 @@
+plugins {
+ id("com.android.application")
+ id("org.jetbrains.kotlin.android")
+}
+
+android {
+ namespace = "com.unogame"
+ compileSdk = 34
+
+ defaultConfig {
+ applicationId = "com.unogame"
+ minSdk = 24
+ targetSdk = 34
+ versionCode = 1
+ versionName = "1.0"
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ kotlinOptions {
+ jvmTarget = "17"
+ }
+
+ buildFeatures {
+ compose = true
+ }
+
+ composeOptions {
+ kotlinCompilerExtensionVersion = "1.5.5"
+ }
+}
+
+dependencies {
+ val composeBom = platform("androidx.compose:compose-bom:2023.10.01")
+ implementation(composeBom)
+
+ implementation("androidx.core:core-ktx:1.12.0")
+ implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2")
+ implementation("androidx.activity:activity-compose:1.8.1")
+ implementation("androidx.compose.ui:ui")
+ implementation("androidx.compose.ui:ui-graphics")
+ implementation("androidx.compose.ui:ui-tooling-preview")
+ implementation("androidx.compose.material3:material3")
+ implementation("androidx.compose.material:material-icons-extended")
+ implementation("androidx.navigation:navigation-compose:2.7.5")
+ implementation("com.google.code.gson:gson:2.10.1")
+
+ debugImplementation("androidx.compose.ui:ui-tooling")
+ debugImplementation("androidx.compose.ui:ui-test-manifest")
+}
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
new file mode 100644
index 0000000..ef7f222
--- /dev/null
+++ b/app/proguard-rules.pro
@@ -0,0 +1,12 @@
+# ProGuard rules for Uno Game
+-keepattributes Signature
+-keepattributes *Annotation*
+
+# Gson
+-keep class com.unogame.network.Protocol$Message { *; }
+-keep class com.unogame.network.Protocol$PlayerData { *; }
+-keep class com.unogame.network.Protocol$StateData { *; }
+-keep class com.unogame.network.Protocol$CardData { *; }
+
+# Kotlin
+-keep class kotlin.** { *; }
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..51c9ff2
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/unogame/MainActivity.kt b/app/src/main/java/com/unogame/MainActivity.kt
new file mode 100644
index 0000000..d1d39ea
--- /dev/null
+++ b/app/src/main/java/com/unogame/MainActivity.kt
@@ -0,0 +1,491 @@
+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(null) }
+ var gameClient by remember { mutableStateOf(null) }
+ var discoveryService by remember { mutableStateOf(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(null) }
+ var myCards by remember { mutableStateOf>(emptyList()) }
+ var players by remember { mutableStateOf>(emptyList()) }
+ var selectedWildColor by remember { mutableStateOf(null) }
+
+ // Discovery state
+ var discoveredHosts by remember { mutableStateOf>(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,
+ 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 }
+ }
+ }
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/unogame/game/GameEngine.kt b/app/src/main/java/com/unogame/game/GameEngine.kt
new file mode 100644
index 0000000..1f7b2bb
--- /dev/null
+++ b/app/src/main/java/com/unogame/game/GameEngine.kt
@@ -0,0 +1,531 @@
+package com.unogame.game
+
+import com.unogame.model.*
+import kotlin.random.Random
+
+class GameEngine(private val rules: GameRules = GameRules.forMode(GameMode.NORMAL)) {
+
+ private val drawPile = mutableListOf()
+ private val discardPile = mutableListOf()
+
+ private fun buildDeck(): List = when (rules.mode) {
+ GameMode.NORMAL, GameMode.SEVEN_ZERO -> buildNormalDeck()
+ GameMode.FLIP -> buildFlipDeck()
+ GameMode.NO_MERCY -> buildNoMercyDeck()
+ }
+
+ private fun buildNormalDeck(): List = buildList {
+ rules.colors.forEach { color ->
+ add(Card(color, CardType.NUMBER, 0))
+ for (num in 1..9) {
+ add(Card(color, CardType.NUMBER, num))
+ add(Card(color, CardType.NUMBER, num))
+ }
+ repeat(2) { add(Card(color, CardType.SKIP)) }
+ repeat(2) { add(Card(color, CardType.REVERSE)) }
+ repeat(2) { add(Card(color, CardType.DRAW_TWO)) }
+ }
+ repeat(4) { add(Card(CardColor.WILD, CardType.WILD)) }
+ repeat(4) { add(Card(CardColor.WILD, CardType.WILD_DRAW_FOUR)) }
+ }
+
+ private fun buildFlipDeck(): List {
+ val lightColors = listOf(CardColor.RED, CardColor.BLUE, CardColor.GREEN, CardColor.YELLOW)
+ val darkColors = listOf(CardColor.PINK, CardColor.PURPLE, CardColor.TEAL, CardColor.ORANGE)
+ val colorMap = lightColors.zip(darkColors).toMap()
+
+ return buildList {
+ lightColors.forEach { lc ->
+ val dc = colorMap[lc]!!
+ // Number cards
+ add(Card(lc, CardType.NUMBER, 0, flipSide = Card(dc, CardType.NUMBER, 0)))
+ for (num in 1..9) {
+ add(Card(lc, CardType.NUMBER, num, flipSide = Card(dc, CardType.NUMBER, num)))
+ add(Card(lc, CardType.NUMBER, num, flipSide = Card(dc, CardType.NUMBER, num)))
+ }
+ // Light: Skip → Dark: SkipAll
+ repeat(2) { add(Card(lc, CardType.SKIP, flipSide = Card(dc, CardType.SKIP_ALL))) }
+ // Light: Reverse → Dark: DrawTwo
+ repeat(2) { add(Card(lc, CardType.REVERSE, flipSide = Card(dc, CardType.DRAW_TWO))) }
+ // Light: DrawTwo → Dark: DrawFive
+ repeat(2) { add(Card(lc, CardType.DRAW_TWO, flipSide = Card(dc, CardType.DRAW_FIVE))) }
+ }
+ // Wild: light=Wild, dark=WildDrawTwo
+ repeat(4) { add(Card(CardColor.WILD, CardType.WILD, flipSide = Card(CardColor.WILD, CardType.WILD_DRAW_TWO))) }
+ // WildDrawFour stays
+ repeat(4) { add(Card(CardColor.WILD, CardType.WILD_DRAW_FOUR, flipSide = Card(CardColor.WILD, CardType.WILD_DRAW_FOUR))) }
+ // Flip cards (one per light color)
+ lightColors.forEach { lc ->
+ add(Card(lc, CardType.FLIP))
+ }
+ }
+ }
+
+ private fun buildNoMercyDeck(): List = buildList {
+ rules.colors.forEach { color ->
+ add(Card(color, CardType.NUMBER, 0))
+ for (num in 1..9) {
+ add(Card(color, CardType.NUMBER, num))
+ add(Card(color, CardType.NUMBER, num))
+ }
+ repeat(2) { add(Card(color, CardType.SKIP)) }
+ repeat(2) { add(Card(color, CardType.REVERSE)) }
+ repeat(2) { add(Card(color, CardType.DRAW_TWO)) }
+ // No Mercy extras
+ repeat(2) { add(Card(color, CardType.DRAW_SIX)) }
+ repeat(1) { add(Card(color, CardType.DRAW_TEN)) }
+ repeat(1) { add(Card(color, CardType.DISCARD_COLOR)) }
+ }
+ repeat(4) { add(Card(CardColor.WILD, CardType.WILD)) }
+ repeat(4) { add(Card(CardColor.WILD, CardType.WILD_DRAW_FOUR)) }
+ repeat(2) { add(Card(CardColor.WILD, CardType.WILD_DRAW_FOUR_REVERSE)) }
+ repeat(2) { add(Card(CardColor.WILD, CardType.WILD_DRAW_COLOR)) }
+ }
+
+ fun createInitialState(players: List): GameState {
+ drawPile.clear()
+ discardPile.clear()
+ drawPile.addAll(buildDeck())
+ shuffle(drawPile)
+
+ val hands = players.map { player ->
+ val cards = (1..rules.startingHandSize).map { drawPile.removeAt(0) }
+ player.copy(cards = cards, cardCount = cards.size)
+ }
+
+ var firstCard = drawPile.removeAt(0)
+ while (firstCard.type.isWild || firstCard.type == CardType.FLIP) {
+ drawPile.add(0, firstCard)
+ shuffle(drawPile)
+ firstCard = drawPile.removeAt(0)
+ }
+ discardPile.add(firstCard)
+
+ val wildColor: CardColor? = if (firstCard.type.isWild) {
+ val active = firstCard.activeCard(false)
+ active.color
+ } else null
+
+ return GameState(
+ players = hands.mapIndexed { i, p -> p.copy(isCurrentTurn = i == 0) },
+ currentPlayerIndex = 0,
+ direction = 1,
+ discardPile = discardPile.toList(),
+ drawPileCount = drawPile.size,
+ currentWildColor = wildColor,
+ flipped = false
+ )
+ }
+
+ fun shuffle(cards: MutableList) {
+ cards.shuffle(Random)
+ }
+
+ sealed class PlayResult {
+ data class Success(val state: GameState) : PlayResult()
+ data class Error(val message: String) : PlayResult()
+ }
+
+ fun playCard(
+ state: GameState,
+ playerId: String,
+ cardIndex: Int,
+ chosenColor: CardColor? = null
+ ): PlayResult {
+ val playerIndex = state.players.indexOfFirst { it.id == playerId }
+ if (playerIndex != state.currentPlayerIndex)
+ return PlayResult.Error("不是你的回合")
+
+ val player = state.players[playerIndex]
+ if (cardIndex < 0 || cardIndex >= player.cards.size)
+ return PlayResult.Error("无效的卡牌")
+
+ val card = player.cards[cardIndex]
+ val topCard = discardPile.lastOrNull()
+
+ // In No Mercy mode, stacking requires matching color (or wild)
+ val canStack = rules.allowStacking && card.type.isDrawPenalty
+ && topCard != null && topCard.activeCard(state.flipped).type.isDrawPenalty
+ && state.pendingDrawCount > 0
+ && (card.color == CardColor.WILD ||
+ card.activeCard(state.flipped).color == (state.currentWildColor ?: topCard.activeCard(state.flipped).color))
+
+ // Block card play when there's a pending draw (must draw instead)
+ // Only draw penalty cards can be played to stack
+ if (state.pendingDrawCount > 0 && !canStack)
+ return PlayResult.Error("必须先摸 ${state.pendingDrawCount} 张惩罚牌")
+
+ if (!canStack && topCard != null &&
+ !card.matches(topCard, state.currentWildColor, state.flipped))
+ return PlayResult.Error("不能出这张牌")
+
+ return executeCardPlay(state, player, playerIndex, cardIndex, card, chosenColor, canStack)
+ }
+
+ private fun executeCardPlay(
+ state: GameState,
+ player: Player,
+ playerIndex: Int,
+ cardIndex: Int,
+ card: Card,
+ chosenColor: CardColor?,
+ isStacking: Boolean
+ ): PlayResult {
+ val newDiscard = discardPile.toMutableList()
+ newDiscard.add(card)
+ discardPile.clear()
+ discardPile.addAll(newDiscard)
+
+ val newCards = player.cards.toMutableList()
+ newCards.removeAt(cardIndex)
+
+ var wildColor: CardColor? = null // cleared by default, only set by wild cards
+ var pendingDraw = state.pendingDrawCount
+ var nextIndex = state.nextPlayerIndex()
+ var direction = state.direction
+ var flipped = state.flipped
+ var message = "${player.name} 出了 ${card.displayText}"
+
+ val activeCard = card.activeCard(flipped)
+
+ when (activeCard.type) {
+ CardType.SKIP -> {
+ message += ",跳过下家"
+ nextIndex = advanceIndex(state, nextIndex)
+ }
+ CardType.REVERSE -> {
+ direction *= -1
+ if (state.players.size == 2) {
+ // In 2-player game, reverse acts as skip
+ nextIndex = advanceIndex(state, nextIndex)
+ message += ",反转方向(跳过)"
+ } else {
+ val tempState = state.copy(direction = direction)
+ nextIndex = tempState.nextPlayerIndex()
+ message += ",反转方向"
+ }
+ }
+ CardType.DRAW_TWO -> {
+ pendingDraw = if (isStacking) pendingDraw + 2 else 2
+ message += ",下家摸${if (isStacking) "累计" else ""}${pendingDraw}张牌"
+ }
+ CardType.WILD -> {
+ wildColor = chosenColor ?: CardColor.RED
+ message += ",选择 ${wildColor!!.displayName}"
+ }
+ CardType.WILD_DRAW_FOUR -> {
+ wildColor = chosenColor ?: CardColor.RED
+ pendingDraw = if (isStacking) pendingDraw + 4 else 4
+ message += ",下家摸${pendingDraw}张牌,选${wildColor!!.displayName}"
+ }
+ // Flip mode dark side effects
+ CardType.SKIP_ALL -> {
+ pendingDraw = 0
+ message += ",所有其他玩家摸1张"
+ nextIndex = advanceIndex(state, nextIndex)
+ }
+ CardType.DRAW_FIVE -> {
+ pendingDraw = 5
+ message += ",下家摸5张牌"
+ }
+ CardType.WILD_DRAW_TWO -> {
+ wildColor = chosenColor ?: CardColor.RED
+ pendingDraw = 2
+ message += ",下家摸2张,选${wildColor!!.displayName}"
+ }
+ CardType.FLIP -> {
+ flipped = !flipped
+ wildColor = null
+ message += ",翻转!切换到${if (flipped) "深色面" else "浅色面"}"
+ }
+ // No Mercy effects
+ CardType.DRAW_SIX -> {
+ pendingDraw = if (isStacking) pendingDraw + 6 else 6
+ message += ",下家摸${pendingDraw}张牌"
+ }
+ CardType.DRAW_TEN -> {
+ pendingDraw = if (isStacking) pendingDraw + 10 else 10
+ message += ",下家摸${pendingDraw}张牌"
+ }
+ CardType.WILD_DRAW_FOUR_REVERSE -> {
+ direction *= -1
+ wildColor = chosenColor ?: CardColor.RED
+ pendingDraw = 4
+ val tempState = state.copy(direction = direction)
+ nextIndex = tempState.nextPlayerIndex()
+ message += "+4反转,选${wildColor!!.displayName}"
+ }
+ CardType.DISCARD_COLOR -> {
+ val discardColor = card.activeCard(flipped).color
+ val filtered = newCards.filter { it.activeCard(flipped).color != discardColor }
+ val removed = newCards.size - filtered.size
+ newCards.clear()
+ newCards.addAll(filtered)
+ message += ",弃掉${removed}张${discardColor.displayName}牌"
+ if (filtered.size <= 1) message += ",即将胜利!"
+ }
+ CardType.WILD_DRAW_COLOR -> {
+ wildColor = chosenColor ?: CardColor.RED
+ nextIndex = advanceIndex(state, nextIndex)
+ message += ",跳过下家并选${wildColor!!.displayName}"
+ }
+ CardType.NUMBER -> {
+ // 7-0 rules
+ if (rules.mode == GameMode.SEVEN_ZERO && card.number == 0) {
+ pendingDraw = -1 // signal: all pass hands
+ message += ",0!全体传牌"
+ }
+ if (rules.mode == GameMode.SEVEN_ZERO && card.number == 7) {
+ pendingDraw = -2 // signal: swap with next player
+ message += ",7!交换手牌"
+ }
+ }
+ }
+
+ // Handle special card effects that modify multiple players
+ var modifiedPlayers = state.players
+ var sevenZeroDone = false
+
+ // Handle 7-0 rules: pass hands (0) or swap (7)
+ if (rules.mode == GameMode.SEVEN_ZERO) {
+ if (pendingDraw == -1) {
+ // 0: all players pass hand to next in direction
+ val n = state.players.size
+ val passCards = state.players.map { it.cards }
+ modifiedPlayers = state.players.mapIndexed { i, p ->
+ val fromIdx = ((i - direction) % n + n) % n
+ val received = if (fromIdx == playerIndex) newCards else passCards[fromIdx]
+ p.copy(cards = received, cardCount = received.size)
+ }
+ pendingDraw = 0
+ sevenZeroDone = true
+ } else if (pendingDraw == -2) {
+ // 7: swap hand with the next player
+ val nextIdx = advanceIndex(state, state.nextPlayerIndex())
+ if (nextIdx != playerIndex) {
+ val nextCards = state.players[nextIdx].cards
+ modifiedPlayers = state.players.mapIndexed { i, p ->
+ when (i) {
+ playerIndex -> p.copy(cards = nextCards, cardCount = nextCards.size)
+ nextIdx -> p.copy(cards = newCards, cardCount = newCards.size)
+ else -> p
+ }
+ }
+ }
+ pendingDraw = 0
+ sevenZeroDone = true
+ }
+ }
+
+ // Handle SkipAll: all other players draw 1 card
+ if (activeCard.type == CardType.SKIP_ALL) {
+ modifiedPlayers = state.players.mapIndexed { i, p ->
+ if (i != playerIndex) {
+ if (drawPile.isEmpty()) reshuffleDiscard()
+ val newPile = p.cards.toMutableList()
+ if (drawPile.isNotEmpty()) newPile.add(drawPile.removeAt(0))
+ p.copy(cards = newPile, cardCount = newPile.size)
+ } else {
+ p.copy(cards = newCards, cardCount = newCards.size)
+ }
+ }
+ }
+
+ val isWinner = newCards.isEmpty()
+ if (isWinner) {
+ val basePlayers = if (activeCard.type == CardType.SKIP_ALL || sevenZeroDone) modifiedPlayers else state.players
+ val updatedPlayers = basePlayers.mapIndexed { i, p ->
+ if (i == playerIndex) p.copy(cards = if (sevenZeroDone) p.cards else newCards, isCurrentTurn = false, calledUno = false)
+ else p.copy(isCurrentTurn = false)
+ }
+ return PlayResult.Success(
+ state.copy(
+ players = updatedPlayers,
+ discardPile = discardPile.toList(),
+ drawPileCount = drawPile.size,
+ currentWildColor = wildColor,
+ isGameOver = true,
+ winner = player.copy(cards = emptyList(), cardCount = 0),
+ pendingDrawCount = 0,
+ flipped = flipped,
+ turnNumber = state.turnNumber + 1,
+ message = message + ",${player.name} 赢了!"
+ )
+ )
+ }
+
+ val calledUno = newCards.size == 1
+ val basePlayers = if (activeCard.type == CardType.SKIP_ALL) modifiedPlayers else state.players
+ val updatedPlayers = basePlayers.mapIndexed { i, p ->
+ when (i) {
+ playerIndex -> p.copy(cards = if (sevenZeroDone) p.cards else newCards, isCurrentTurn = false, calledUno = calledUno, cardCount = if (sevenZeroDone) p.cards.size else newCards.size)
+ else -> if (i == nextIndex) p.copy(isCurrentTurn = true) else p.copy(isCurrentTurn = false)
+ }
+ }
+
+ return PlayResult.Success(
+ state.copy(
+ players = updatedPlayers,
+ currentPlayerIndex = nextIndex,
+ direction = direction,
+ discardPile = discardPile.toList(),
+ drawPileCount = drawPile.size,
+ currentWildColor = wildColor,
+ pendingDrawCount = pendingDraw,
+ flipped = flipped,
+ turnNumber = state.turnNumber + 1,
+ message = message
+ )
+ )
+ }
+
+ fun drawCard(state: GameState, playerId: String): PlayResult {
+ val playerIndex = state.players.indexOfFirst { it.id == playerId }
+ if (playerIndex != state.currentPlayerIndex)
+ return PlayResult.Error("不是你的回合")
+
+ val player = state.players[playerIndex]
+ var drawAmount = if (state.pendingDrawCount > 0) state.pendingDrawCount else 1
+
+ // No Mercy 10-card limit: cap draw to not exceed 10
+ if (rules.mode == GameMode.NO_MERCY) {
+ val remaining = (10 - player.cards.size).coerceAtLeast(0)
+ if (remaining == 0) {
+ val message = "${player.name} 手牌已满10张,跳过摸牌"
+ val nextIndex = state.nextPlayerIndex()
+ val updatedPlayers = state.players.mapIndexed { i, p ->
+ when (i) {
+ playerIndex -> p.copy(isCurrentTurn = false)
+ else -> if (i == nextIndex) p.copy(isCurrentTurn = true) else p.copy(isCurrentTurn = false)
+ }
+ }
+ return PlayResult.Success(state.copy(
+ players = updatedPlayers,
+ currentPlayerIndex = nextIndex,
+ discardPile = discardPile.toList(),
+ drawPileCount = drawPile.size,
+ pendingDrawCount = 0,
+ flipped = state.flipped,
+ turnNumber = state.turnNumber + 1,
+ message = message
+ ))
+ }
+ drawAmount = minOf(drawAmount, remaining)
+ }
+
+ if (drawPile.isEmpty()) {
+ reshuffleDiscard()
+ }
+
+ val actualDraw = minOf(drawAmount, if (drawPile.isEmpty()) { reshuffleDiscard(); drawPile.size } else drawPile.size)
+ val drawnCards = (1..actualDraw).mapNotNull {
+ if (drawPile.isNotEmpty()) drawPile.removeAt(0) else null
+ }
+
+ if (drawnCards.isEmpty())
+ return PlayResult.Error("牌堆已空,无法摸牌")
+
+ val newCards = player.cards.toMutableList()
+ newCards.addAll(drawnCards)
+
+ var nextIndex = state.nextPlayerIndex()
+
+ val message = if (state.pendingDrawCount > 0) {
+ if (actualDraw < drawAmount && actualDraw < state.pendingDrawCount)
+ "${player.name} 摸了 ${drawnCards.size} 张惩罚牌(已达10张上限)"
+ else if (actualDraw < drawAmount)
+ "${player.name} 摸了 ${drawnCards.size} 张惩罚牌(牌不够,需${drawAmount}张)"
+ else
+ "${player.name} 摸了 ${drawnCards.size} 张惩罚牌"
+ } else {
+ "${player.name} 摸了一张牌"
+ }
+
+ val updatedPlayers = state.players.mapIndexed { i, p ->
+ when (i) {
+ playerIndex -> p.copy(cards = newCards, isCurrentTurn = false, cardCount = newCards.size)
+ else -> if (i == nextIndex) p.copy(isCurrentTurn = true) else p.copy(isCurrentTurn = false)
+ }
+ }
+
+ return PlayResult.Success(
+ state.copy(
+ players = updatedPlayers,
+ currentPlayerIndex = nextIndex,
+ discardPile = discardPile.toList(),
+ drawPileCount = drawPile.size,
+ currentWildColor = state.currentWildColor,
+ pendingDrawCount = 0,
+ flipped = state.flipped,
+ turnNumber = state.turnNumber + 1,
+ message = message
+ )
+ )
+ }
+
+ private fun advanceIndex(state: GameState, fromIndex: Int): Int {
+ var next = fromIndex + state.direction
+ if (next < 0) next = state.players.size - 1
+ if (next >= state.players.size) next = 0
+ return next
+ }
+
+ private fun reshuffleDiscard() {
+ if (discardPile.size <= 1 && drawPile.isNotEmpty()) return
+ val topCard = discardPile.removeAt(discardPile.size - 1)
+ drawPile.addAll(discardPile)
+ shuffle(drawPile)
+ discardPile.clear()
+ discardPile.add(topCard)
+ }
+
+ fun getDrawPileSize(): Int = drawPile.size
+ fun getDiscardPile(): List = discardPile.toList()
+
+ fun challengeUno(state: GameState, challengerId: String, targetId: String): PlayResult {
+ val challengerIdx = state.players.indexOfFirst { it.id == challengerId }
+ val targetIdx = state.players.indexOfFirst { it.id == targetId }
+ if (challengerIdx < 0 || targetIdx < 0) return PlayResult.Error("玩家不存在")
+ if (challengerIdx == targetIdx) return PlayResult.Error("不能抓自己")
+ val target = state.players[targetIdx]
+ if (target.cardCount != 1) return PlayResult.Error("该玩家不止1张牌")
+ if (target.calledUno) return PlayResult.Error("该玩家已经喊过UNO了")
+ var drawPenalty = 2
+ // No Mercy 10-card limit: skip penalty if at limit, cap if near limit
+ if (rules.mode == GameMode.NO_MERCY) {
+ val remaining = (10 - target.cards.size).coerceAtLeast(0)
+ if (remaining == 0) {
+ val updatedPlayers = state.players.mapIndexed { i, p ->
+ if (i == targetIdx) p.copy(calledUno = true) else p
+ }
+ return PlayResult.Success(state.copy(
+ players = updatedPlayers,
+ discardPile = discardPile.toList(),
+ drawPileCount = drawPile.size,
+ message = "${state.players[challengerIdx].name} 抓住了 ${target.name} 没喊UNO!(手牌已满10张,免罚)",
+ turnNumber = state.turnNumber + 1
+ ))
+ }
+ drawPenalty = minOf(2, remaining)
+ }
+ // Target draws penalty cards
+ val penaltyCards = mutableListOf()
+ repeat(drawPenalty) {
+ if (drawPile.isEmpty()) reshuffleDiscard()
+ if (drawPile.isNotEmpty()) penaltyCards.add(drawPile.removeAt(0))
+ }
+ val newTargetCards = target.cards.toMutableList()
+ newTargetCards.addAll(penaltyCards)
+ val updatedPlayers = state.players.mapIndexed { i, p ->
+ if (i == targetIdx) p.copy(cards = newTargetCards, calledUno = true, cardCount = newTargetCards.size)
+ else p
+ }
+ return PlayResult.Success(state.copy(
+ players = updatedPlayers,
+ drawPileCount = drawPile.size,
+ discardPile = discardPile.toList(),
+ message = "${state.players[challengerIdx].name} 抓住了 ${target.name} 没喊UNO!${target.name} 罚摸${drawPenalty}张",
+ turnNumber = state.turnNumber + 1
+ ))
+ }
+}
diff --git a/app/src/main/java/com/unogame/game/GameRules.kt b/app/src/main/java/com/unogame/game/GameRules.kt
new file mode 100644
index 0000000..7786dea
--- /dev/null
+++ b/app/src/main/java/com/unogame/game/GameRules.kt
@@ -0,0 +1,56 @@
+package com.unogame.game
+
+import com.unogame.model.*
+
+enum class GameMode(val displayName: String) {
+ NORMAL("普通模式"),
+ FLIP("UNO Flip"),
+ NO_MERCY("无情UNO"),
+ SEVEN_ZERO("7-0规则")
+}
+
+data class GameRules(
+ val mode: GameMode,
+ val startingHandSize: Int,
+ val allowStacking: Boolean,
+ val colors: List,
+ val usesFlip: Boolean,
+ val usesNoMercyCards: Boolean
+) {
+ companion object {
+ fun forMode(mode: GameMode): GameRules = when (mode) {
+ GameMode.NORMAL -> GameRules(
+ mode = mode,
+ startingHandSize = 7,
+ allowStacking = false,
+ colors = listOf(CardColor.RED, CardColor.BLUE, CardColor.GREEN, CardColor.YELLOW),
+ usesFlip = false,
+ usesNoMercyCards = false
+ )
+ GameMode.FLIP -> GameRules(
+ mode = mode,
+ startingHandSize = 7,
+ allowStacking = false,
+ colors = listOf(CardColor.RED, CardColor.BLUE, CardColor.GREEN, CardColor.YELLOW),
+ usesFlip = true,
+ usesNoMercyCards = false
+ )
+ GameMode.NO_MERCY -> GameRules(
+ mode = mode,
+ startingHandSize = 10,
+ allowStacking = true,
+ colors = listOf(CardColor.RED, CardColor.BLUE, CardColor.GREEN, CardColor.YELLOW),
+ usesFlip = false,
+ usesNoMercyCards = true
+ )
+ GameMode.SEVEN_ZERO -> GameRules(
+ mode = mode,
+ startingHandSize = 7,
+ allowStacking = false,
+ colors = listOf(CardColor.RED, CardColor.BLUE, CardColor.GREEN, CardColor.YELLOW),
+ usesFlip = false,
+ usesNoMercyCards = false
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/unogame/game/SimpleAI.kt b/app/src/main/java/com/unogame/game/SimpleAI.kt
new file mode 100644
index 0000000..9b8a7c3
--- /dev/null
+++ b/app/src/main/java/com/unogame/game/SimpleAI.kt
@@ -0,0 +1,121 @@
+package com.unogame.game
+
+import android.content.Context
+import com.unogame.model.*
+import kotlin.random.Random
+
+enum class AIDifficulty(val displayName: String) {
+ EASY("简单"),
+ NORMAL("普通"),
+ HARD("困难");
+
+ companion object {
+ private const val KEY = "ai_difficulty"
+
+ fun load(context: Context): AIDifficulty {
+ val name = context.getSharedPreferences("unogame_prefs", Context.MODE_PRIVATE)
+ .getString(KEY, NORMAL.name) ?: NORMAL.name
+ return try { valueOf(name) } catch (_: Exception) { NORMAL }
+ }
+
+ fun save(context: Context, diff: AIDifficulty) {
+ context.getSharedPreferences("unogame_prefs", Context.MODE_PRIVATE)
+ .edit().putString(KEY, diff.name).apply()
+ }
+ }
+}
+
+object SimpleAI {
+
+ data class AIMove(
+ val type: MoveType,
+ val cardIndex: Int = -1,
+ val chosenColor: CardColor? = null
+ )
+
+ enum class MoveType { PLAY, DRAW }
+
+ fun decideMove(
+ player: Player,
+ topCard: Card?,
+ wildColor: CardColor?,
+ flipped: Boolean = false,
+ difficulty: AIDifficulty = AIDifficulty.NORMAL
+ ): AIMove {
+ val hand = player.cards
+ if (hand.isEmpty()) return AIMove(MoveType.DRAW)
+
+ val playable = hand.mapIndexedNotNull { i, card ->
+ if (topCard == null || card.matches(topCard, wildColor, flipped)) i to card
+ else null
+ }
+
+ // Easy: 30% chance to draw instead of play
+ if (difficulty == AIDifficulty.EASY && playable.isNotEmpty() && Random.nextFloat() < 0.3f) {
+ return AIMove(MoveType.DRAW)
+ }
+
+ if (playable.isEmpty()) {
+ return AIMove(MoveType.DRAW)
+ }
+
+ // Easy: play random card
+ if (difficulty == AIDifficulty.EASY) {
+ val chosen = playable.random()
+ if (chosen.second.type.isWild || chosen.second.activeCard(flipped).type.isWild) {
+ val color = pickBestColor(hand, flipped)
+ return AIMove(MoveType.PLAY, hand.indexOf(chosen.second), color)
+ }
+ return AIMove(MoveType.PLAY, hand.indexOf(chosen.second))
+ }
+
+ // Hard: prefer cards that hurt the opponent most
+ // Normal: priority ordering
+ fun priority(c: Card): Int {
+ val t = c.activeCard(flipped).type
+ var base = when (t) {
+ CardType.DRAW_TEN -> 10
+ CardType.DRAW_SIX -> 9
+ CardType.DRAW_FIVE -> 8
+ CardType.WILD_DRAW_FOUR -> 7
+ CardType.WILD_DRAW_FOUR_REVERSE -> 7
+ CardType.WILD_DRAW_TWO -> 6
+ CardType.DRAW_TWO -> 5
+ CardType.FLIP -> 4
+ CardType.SKIP, CardType.SKIP_ALL -> 3
+ CardType.REVERSE -> 3
+ CardType.DISCARD_COLOR -> 2
+ CardType.WILD_DRAW_COLOR -> 2
+ CardType.WILD -> 2
+ CardType.NUMBER -> 1
+ }
+ // Hard: prioritize action cards even more when opponent has few cards
+ if (difficulty == AIDifficulty.HARD && hand.size <= 3 && t in listOf(
+ CardType.DRAW_TEN, CardType.DRAW_SIX, CardType.DRAW_FIVE,
+ CardType.WILD_DRAW_FOUR, CardType.DRAW_TWO, CardType.SKIP
+ )) {
+ base += 5
+ }
+ return base
+ }
+
+ val chosen = playable.maxByOrNull { priority(it.second) }!!
+
+ if (chosen.second.type.isWild || chosen.second.activeCard(flipped).type.isWild) {
+ val color = pickBestColor(hand, flipped)
+ return AIMove(MoveType.PLAY, hand.indexOf(chosen.second), color)
+ }
+
+ return AIMove(MoveType.PLAY, hand.indexOf(chosen.second))
+ }
+
+ fun pickBestColor(hand: List, flipped: Boolean = false): CardColor {
+ val validColors = listOf(CardColor.RED, CardColor.BLUE, CardColor.GREEN, CardColor.YELLOW)
+ val counts = validColors.associateWith { color ->
+ hand.count { it.activeCard(flipped).color == color }
+ }
+ return counts.maxByOrNull { it.value }?.key ?: CardColor.RED
+ }
+
+ fun chooseWildColor(hand: List, flipped: Boolean = false): CardColor = pickBestColor(hand, flipped)
+}
diff --git a/app/src/main/java/com/unogame/model/Card.kt b/app/src/main/java/com/unogame/model/Card.kt
new file mode 100644
index 0000000..3beb4a7
--- /dev/null
+++ b/app/src/main/java/com/unogame/model/Card.kt
@@ -0,0 +1,82 @@
+package com.unogame.model
+
+enum class CardColor(val displayName: String, val hexColor: Long, val isDark: Boolean = false) {
+ RED("红", 0xFFE53935),
+ BLUE("蓝", 0xFF1E88E5),
+ GREEN("绿", 0xFF43A047),
+ YELLOW("黄", 0xFFFDD835),
+ WILD("", 0xFF212121),
+ // Flip dark side colors
+ PINK("粉", 0xFFE91E63, true),
+ PURPLE("紫", 0xFF9C27B0, true),
+ TEAL("青", 0xFF009688, true),
+ ORANGE("橙", 0xFFFF9800, true);
+
+ val isWild: Boolean get() = this == WILD
+}
+
+enum class CardType(val symbol: String, val isFlipOnly: Boolean = false, val isNoMercyOnly: Boolean = false) {
+ NUMBER(""),
+ SKIP("⊘"),
+ REVERSE("⟲"),
+ DRAW_TWO("+2"),
+ WILD("W"),
+ WILD_DRAW_FOUR("+4"),
+ // Flip specials
+ FLIP("↻", isFlipOnly = true),
+ DRAW_FIVE("+5", isFlipOnly = true),
+ SKIP_ALL("⊙", isFlipOnly = true),
+ WILD_DRAW_TWO("W+2", isFlipOnly = true),
+ // No Mercy specials
+ DRAW_SIX("+6", isNoMercyOnly = true),
+ DRAW_TEN("+10", isNoMercyOnly = true),
+ WILD_DRAW_FOUR_REVERSE("+4↶", isNoMercyOnly = true),
+ DISCARD_COLOR("≡", isNoMercyOnly = true),
+ WILD_DRAW_COLOR("W≡", isNoMercyOnly = true);
+
+ val isAction: Boolean get() = this != NUMBER
+ val isWild: Boolean get() = this == WILD || this == WILD_DRAW_FOUR
+ || this == WILD_DRAW_TWO || this == WILD_DRAW_FOUR_REVERSE || this == WILD_DRAW_COLOR
+ val isDrawPenalty: Boolean get() = this in listOf(DRAW_TWO, DRAW_FIVE, DRAW_SIX, DRAW_TEN,
+ WILD_DRAW_FOUR, WILD_DRAW_TWO, WILD_DRAW_FOUR_REVERSE)
+}
+
+data class Card(
+ val color: CardColor,
+ val type: CardType,
+ val number: Int = -1,
+ val flipSide: Card? = null
+) {
+ val displayText: String
+ get() = if (type == CardType.NUMBER) number.toString() else type.symbol
+
+ fun matches(other: Card, currentWildColor: CardColor?, flipped: Boolean = false): Boolean {
+ val c = if (flipped && flipSide != null) flipSide!! else this
+ val o = if (flipped && other.flipSide != null) other.flipSide!! else other
+ if (c.color == CardColor.WILD) return true
+ if (o.color == CardColor.WILD)
+ return currentWildColor == null || c.color == currentWildColor
+ if (c.color == o.color) return true
+ if (c.type == CardType.NUMBER && o.type == CardType.NUMBER && c.number == o.number)
+ return true
+ if (c.type != CardType.NUMBER && c.type == o.type) return true
+ return false
+ }
+
+ fun activeCard(flipped: Boolean): Card =
+ if (flipped && flipSide != null) flipSide!! else this
+
+ val score: Int
+ get() = when (type) {
+ CardType.NUMBER -> number
+ CardType.SKIP, CardType.REVERSE, CardType.DRAW_TWO -> 20
+ CardType.DRAW_FIVE, CardType.DRAW_SIX -> 40
+ CardType.DRAW_TEN -> 60
+ CardType.SKIP_ALL -> 30
+ CardType.DISCARD_COLOR -> 40
+ CardType.FLIP -> 30
+ CardType.WILD, CardType.WILD_DRAW_TWO -> 40
+ CardType.WILD_DRAW_FOUR, CardType.WILD_DRAW_FOUR_REVERSE -> 50
+ CardType.WILD_DRAW_COLOR -> 60
+ }
+}
diff --git a/app/src/main/java/com/unogame/model/GameState.kt b/app/src/main/java/com/unogame/model/GameState.kt
new file mode 100644
index 0000000..82c5a1a
--- /dev/null
+++ b/app/src/main/java/com/unogame/model/GameState.kt
@@ -0,0 +1,32 @@
+package com.unogame.model
+
+data class GameState(
+ val players: List = emptyList(),
+ val currentPlayerIndex: Int = 0,
+ val direction: Int = 1,
+ val discardPile: List = emptyList(),
+ val drawPileCount: Int = 76,
+ val currentWildColor: CardColor? = null,
+ val isGameOver: Boolean = false,
+ val winner: Player? = null,
+ val pendingDrawCount: Int = 0,
+ val flipped: Boolean = false,
+ val turnNumber: Int = 0,
+ val message: String = ""
+) {
+ val currentPlayer: Player
+ get() = if (players.isEmpty())
+ Player("", "")
+ else players[currentPlayerIndex]
+
+ val topCard: Card?
+ get() = discardPile.lastOrNull()
+
+ fun nextPlayerIndex(): Int {
+ if (players.isEmpty()) return 0
+ var next = currentPlayerIndex + direction
+ if (next < 0) next = players.size - 1
+ if (next >= players.size) next = 0
+ return next
+ }
+}
diff --git a/app/src/main/java/com/unogame/model/Player.kt b/app/src/main/java/com/unogame/model/Player.kt
new file mode 100644
index 0000000..04fab44
--- /dev/null
+++ b/app/src/main/java/com/unogame/model/Player.kt
@@ -0,0 +1,12 @@
+package com.unogame.model
+
+data class Player(
+ val id: String,
+ val name: String,
+ val cards: List = emptyList(),
+ val isHost: Boolean = false,
+ val isCurrentTurn: Boolean = false,
+ val isConnected: Boolean = true,
+ val calledUno: Boolean = false,
+ val cardCount: Int = 0
+)
diff --git a/app/src/main/java/com/unogame/network/DiscoveryService.kt b/app/src/main/java/com/unogame/network/DiscoveryService.kt
new file mode 100644
index 0000000..02c5ba5
--- /dev/null
+++ b/app/src/main/java/com/unogame/network/DiscoveryService.kt
@@ -0,0 +1,195 @@
+package com.unogame.network
+
+import kotlinx.coroutines.*
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import java.net.*
+
+data class DiscoveredHost(
+ val name: String,
+ val address: String,
+ val playerCount: Int
+)
+
+class DiscoveryService {
+
+ private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
+ private var isDiscovering = false
+ private var isAdvertising = false
+
+ private val _hosts = MutableStateFlow>(emptyList())
+ val hosts: StateFlow> = _hosts
+
+ private val seenHosts = mutableSetOf()
+
+ fun startDiscovery(playerName: String) {
+ if (isDiscovering) return
+ isDiscovering = true
+ seenHosts.clear()
+ _hosts.value = emptyList()
+
+ scope.launch {
+ try {
+ val socket = DatagramSocket()
+ socket.broadcast = true
+ socket.soTimeout = 1000
+
+ val broadcastAddrs = getBroadcastAddresses()
+ val ownAddresses = getLocalAddresses()
+
+ while (isDiscovering) {
+ val msg = Protocol.Message(type = Protocol.CMD_DISCOVER, name = playerName)
+ val data = Protocol.toJson(msg).toByteArray()
+
+ // Send to all subnet broadcast addresses
+ for (addr in broadcastAddrs) {
+ try {
+ socket.send(DatagramPacket(data, data.size, addr, Protocol.DISCOVERY_PORT))
+ } catch (_: Exception) {}
+ }
+
+ // Listen for responses (collect for up to 1 second)
+ val buffer = ByteArray(1024)
+ val deadline = System.currentTimeMillis() + 1000
+ while (isDiscovering && System.currentTimeMillis() < deadline) {
+ try {
+ val recvPacket = DatagramPacket(buffer, buffer.size)
+ socket.receive(recvPacket)
+
+ val senderAddr = recvPacket.address.hostAddress ?: continue
+ // Skip responses from self
+ if (ownAddresses.contains(senderAddr)) continue
+
+ val json = String(recvPacket.data, 0, recvPacket.length)
+ val response = Protocol.fromJson(json)
+ if (response?.type == Protocol.CMD_DISCOVER_RESPONSE) {
+ val host = DiscoveredHost(
+ name = response.name ?: "Unknown",
+ address = senderAddr,
+ playerCount = response.players?.size ?: 0
+ )
+ if (seenHosts.add(host.address)) {
+ _hosts.value = _hosts.value + host
+ }
+ }
+ } catch (_: SocketTimeoutException) {
+ break
+ } catch (_: Exception) {
+ // skip malformed packets
+ }
+ }
+ }
+ socket.close()
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
+ }
+ }
+
+ fun stopDiscovery() {
+ isDiscovering = false
+ seenHosts.clear()
+ _hosts.value = emptyList()
+ }
+
+ fun startAdvertising(hostName: String, playerCount: Int) {
+ if (isAdvertising) return
+ isAdvertising = true
+
+ scope.launch {
+ try {
+ val adSocket = DatagramSocket(Protocol.DISCOVERY_PORT)
+ val buffer = ByteArray(1024)
+
+ while (isAdvertising) {
+ try {
+ val packet = DatagramPacket(buffer, buffer.size)
+ adSocket.soTimeout = 2000
+ adSocket.receive(packet)
+ val json = String(packet.data, 0, packet.length)
+ val msg = Protocol.fromJson(json)
+ if (msg?.type == Protocol.CMD_DISCOVER) {
+ val response = Protocol.Message(
+ type = Protocol.CMD_DISCOVER_RESPONSE,
+ name = hostName,
+ players = listOf(
+ Protocol.PlayerData(
+ id = "",
+ name = hostName,
+ cardCount = 0,
+ isHost = true,
+ isCurrentTurn = false,
+ isConnected = true,
+ calledUno = false
+ )
+ ),
+ message = "$playerCount"
+ )
+ val respData = Protocol.toJson(response).toByteArray()
+ val respPacket = DatagramPacket(
+ respData, respData.size,
+ packet.address, packet.port
+ )
+ adSocket.send(respPacket)
+ }
+ } catch (_: SocketTimeoutException) {
+ // continue waiting
+ }
+ }
+ adSocket.close()
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
+ }
+ }
+
+ fun stopAdvertising() {
+ isAdvertising = false
+ }
+
+ fun shutdown() {
+ stopDiscovery()
+ stopAdvertising()
+ scope.cancel()
+ }
+
+ /**
+ * Get subnet broadcast addresses for all non-loopback IPv4 interfaces.
+ * Falls back to 255.255.255.255 if no interfaces found.
+ */
+ private fun getBroadcastAddresses(): List {
+ val list = mutableListOf()
+ try {
+ NetworkInterface.getNetworkInterfaces()?.asSequence()?.forEach { iface ->
+ if (iface.isLoopback || !iface.isUp) return@forEach
+ iface.interfaceAddresses.forEach { ifaceAddr ->
+ val addr = ifaceAddr.address
+ if (!addr.isLoopbackAddress && addr is Inet4Address) {
+ val broadcast = ifaceAddr.broadcast ?: return@forEach
+ list.add(broadcast)
+ }
+ }
+ }
+ } catch (_: Exception) {}
+ if (list.isEmpty()) {
+ list.add(InetAddress.getByName("255.255.255.255"))
+ }
+ return list
+ }
+
+ /**
+ * Get all local non-loopback IPv4 addresses (to filter out self-responses).
+ */
+ private fun getLocalAddresses(): Set {
+ val set = mutableSetOf()
+ try {
+ NetworkInterface.getNetworkInterfaces()?.asSequence()?.forEach { iface ->
+ if (iface.isLoopback || !iface.isUp) return@forEach
+ iface.inetAddresses.asSequence()
+ .filter { !it.isLoopbackAddress && it is Inet4Address }
+ .forEach { set.add(it.hostAddress ?: "") }
+ }
+ } catch (_: Exception) {}
+ return set
+ }
+}
diff --git a/app/src/main/java/com/unogame/network/GameClient.kt b/app/src/main/java/com/unogame/network/GameClient.kt
new file mode 100644
index 0000000..87e1a4f
--- /dev/null
+++ b/app/src/main/java/com/unogame/network/GameClient.kt
@@ -0,0 +1,210 @@
+package com.unogame.network
+
+import com.unogame.model.*
+import kotlinx.coroutines.*
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import java.io.*
+import java.net.InetSocketAddress
+import java.net.Socket
+import java.net.SocketException
+
+data class ClientEvent(
+ val type: String, // CONNECTED, GAME_STATE, ERROR, DISCONNECTED
+ val gameState: GameState? = null,
+ val playerId: String = "",
+ val players: List = emptyList(),
+ val playerCards: List = emptyList(),
+ val message: String = ""
+)
+
+class GameClient {
+ private var socket: Socket? = null
+ private var writer: PrintWriter? = null
+ private var reader: BufferedReader? = null
+ private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
+
+ val _events = MutableStateFlow(null)
+ val events: StateFlow = _events
+
+ var myPlayerId: String = ""
+ private set
+ var myCards: List = emptyList()
+ private set
+ var currentPlayers: List = emptyList()
+ private set
+ var gameState: GameState? = null
+ private set
+
+ var lastError: String = ""
+ private set
+
+ suspend fun connect(host: String, name: String): Boolean = withContext(Dispatchers.IO) {
+ try {
+ android.util.Log.d("UnoClient", "正在连接 $host:${Protocol.GAME_PORT}")
+ val addr = try { InetSocketAddress(host, Protocol.GAME_PORT) }
+ catch (e: Exception) { InetSocketAddress(host.replace(" ", ""), Protocol.GAME_PORT) }
+ socket = Socket()
+ socket!!.connect(addr, 8000)
+ socket!!.soTimeout = 30000
+ writer = PrintWriter(socket!!.getOutputStream(), true)
+ reader = BufferedReader(InputStreamReader(socket!!.getInputStream()))
+
+ android.util.Log.d("UnoClient", "已连接,发送JOIN")
+ // Send join
+ val joinMsg = Protocol.Message(type = Protocol.CMD_JOIN, name = name)
+ writer?.println(Protocol.toJson(joinMsg))
+
+ scope.launch {
+ try {
+ var line: String?
+ while (reader?.readLine().also { line = it } != null) {
+ val msg = Protocol.fromJson(line!!) ?: continue
+ handleMessage(msg)
+ }
+ } catch (e: SocketException) {
+ _events.value = ClientEvent(type = "DISCONNECTED", message = "连接断开")
+ } catch (e: Exception) {
+ e.printStackTrace()
+ _events.value = ClientEvent(type = "DISCONNECTED", message = e.message ?: "连接错误")
+ }
+ }
+ true
+ } catch (e: Exception) {
+ lastError = "${e.javaClass.simpleName}: ${e.message ?: "(无详情)"}"
+ false
+ }
+ }
+
+ private fun handleMessage(msg: Protocol.Message) {
+ when (msg.type) {
+ Protocol.CMD_JOIN_ACCEPTED -> {
+ myPlayerId = msg.playerId ?: ""
+ val players = msg.players?.map {
+ Player(id = it.id, name = it.name, cardCount = it.cardCount,
+ isHost = it.isHost, isCurrentTurn = it.isCurrentTurn,
+ isConnected = it.isConnected)
+ } ?: emptyList()
+ currentPlayers = players
+ _events.value = ClientEvent(
+ type = "CONNECTED",
+ playerId = myPlayerId,
+ players = players
+ )
+ }
+
+ Protocol.CMD_PLAYER_JOINED -> {
+ val player = msg.player ?: return
+ val p = Player(id = player.id, name = player.name,
+ isHost = player.isHost,
+ isCurrentTurn = player.isCurrentTurn,
+ isConnected = player.isConnected)
+ currentPlayers = currentPlayers + p
+ _events.value = ClientEvent(
+ type = "PLAYER_JOINED",
+ players = currentPlayers
+ )
+ }
+
+ Protocol.CMD_PLAYER_LEFT -> {
+ val pid = msg.playerId ?: return
+ currentPlayers = currentPlayers.filter { it.id != pid }
+ _events.value = ClientEvent(
+ type = "PLAYER_LEFT",
+ players = currentPlayers
+ )
+ }
+
+ Protocol.CMD_GAME_STARTED -> {
+ val stateData = msg.state ?: return
+ gameState = dataToGameState(stateData)
+ myCards = msg.cards?.map { Protocol.dataToCard(it) } ?: emptyList()
+ _events.value = ClientEvent(
+ type = "GAME_STARTED",
+ gameState = gameState,
+ playerCards = myCards,
+ players = gameState?.players ?: emptyList()
+ )
+ }
+
+ Protocol.CMD_GAME_STATE -> {
+ val stateData = msg.state ?: return
+ gameState = dataToGameState(stateData)
+ myCards = msg.cards?.map { Protocol.dataToCard(it) } ?: myCards
+ _events.value = ClientEvent(
+ type = "GAME_STATE",
+ gameState = gameState,
+ playerCards = myCards,
+ players = gameState?.players ?: emptyList()
+ )
+ }
+
+ Protocol.CMD_ERROR -> {
+ _events.value = ClientEvent(
+ type = "ERROR",
+ message = msg.message ?: "未知错误"
+ )
+ }
+ }
+ }
+
+ private fun dataToGameState(data: Protocol.StateData): GameState {
+ return GameState(
+ players = data.players.map {
+ Player(
+ id = it.id, name = it.name,
+ cardCount = it.cardCount, isHost = it.isHost,
+ isCurrentTurn = it.isCurrentTurn,
+ isConnected = it.isConnected,
+ calledUno = it.calledUno
+ )
+ },
+ currentPlayerIndex = data.currentPlayerIndex,
+ direction = data.direction,
+ discardPile = listOf(data.topCard?.let { Protocol.dataToCard(it) } ?: Card(CardColor.RED, CardType.NUMBER, 0)),
+ drawPileCount = data.drawPileCount,
+ currentWildColor = data.currentWildColor?.let { CardColor.valueOf(it) },
+ isGameOver = data.isGameOver,
+ winner = if (data.winnerId != null) Player(
+ id = data.winnerId, name = data.winnerName ?: ""
+ ) else null,
+ pendingDrawCount = data.pendingDrawCount,
+ flipped = data.flipped,
+ turnNumber = data.turnNumber,
+ message = data.message
+ )
+ }
+
+ suspend fun playCard(cardIndex: Int, chosenColor: CardColor? = null) = withContext(Dispatchers.IO) {
+ val msg = Protocol.Message(
+ type = Protocol.CMD_PLAY_CARD,
+ playerId = myPlayerId,
+ cardIndex = cardIndex,
+ chosenColor = chosenColor?.name
+ )
+ writer?.println(Protocol.toJson(msg))
+ }
+
+ suspend fun drawCard() = withContext(Dispatchers.IO) {
+ val msg = Protocol.Message(
+ type = Protocol.CMD_DRAW_CARD,
+ playerId = myPlayerId
+ )
+ writer?.println(Protocol.toJson(msg))
+ }
+
+ suspend fun startGame() = withContext(Dispatchers.IO) {
+ val msg = Protocol.Message(
+ type = Protocol.CMD_START_GAME,
+ playerId = myPlayerId
+ )
+ writer?.println(Protocol.toJson(msg))
+ }
+
+ fun disconnect() {
+ scope.cancel()
+ try { writer?.close() } catch (_: Exception) {}
+ try { reader?.close() } catch (_: Exception) {}
+ try { socket?.close() } catch (_: Exception) {}
+ }
+}
diff --git a/app/src/main/java/com/unogame/network/GameServer.kt b/app/src/main/java/com/unogame/network/GameServer.kt
new file mode 100644
index 0000000..7b3ed52
--- /dev/null
+++ b/app/src/main/java/com/unogame/network/GameServer.kt
@@ -0,0 +1,265 @@
+package com.unogame.network
+
+import com.unogame.game.GameEngine
+import com.unogame.model.*
+import kotlinx.coroutines.*
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import java.io.*
+import java.net.ServerSocket
+import java.net.Socket
+import java.util.*
+import java.util.concurrent.ConcurrentHashMap
+
+data class ServerEvent(
+ val type: String, // STATE_UPDATE, PLAYER_JOINED, PLAYER_LEFT, ERROR, GAME_STARTED
+ val gameState: GameState? = null,
+ val player: Player? = null,
+ val message: String = "",
+ val players: List = emptyList(),
+ val winner: Player? = null
+)
+
+class GameServer {
+ private var serverSocket: ServerSocket? = null
+ private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
+ private val clients = ConcurrentHashMap()
+ private val engine = GameEngine()
+
+ private val waitingPlayers = mutableListOf()
+ private var gameState: GameState? = null
+ private var isGameRunning = false
+ private var hostId: String = ""
+
+ private val _events = MutableStateFlow(null)
+ val events: MutableStateFlow = _events
+
+ fun start(): Boolean {
+ return try {
+ serverSocket = ServerSocket(Protocol.GAME_PORT)
+ serverSocket?.reuseAddress = true
+ android.util.Log.d("UnoServer", "服务器已启动,端口 ${Protocol.GAME_PORT}")
+ scope.launch {
+ android.util.Log.d("UnoServer", "Accept循环已启动")
+ while (serverSocket != null && !serverSocket!!.isClosed) {
+ try {
+ val clientSocket = serverSocket?.accept() ?: break
+ android.util.Log.d("UnoServer", "接受新连接: ${clientSocket.inetAddress}")
+ scope.launch { handleClient(clientSocket) }
+ } catch (e: Exception) {
+ if (serverSocket?.isClosed == false) {
+ android.util.Log.e("UnoServer", "Accept异常", e)
+ }
+ break
+ }
+ }
+ }
+ true
+ } catch (e: Exception) {
+ e.printStackTrace()
+ false
+ }
+ }
+
+ private suspend fun handleClient(socket: Socket) {
+ var playerId = ""
+ var writer: PrintWriter? = null
+ try {
+ val reader = BufferedReader(InputStreamReader(socket.getInputStream()))
+ writer = PrintWriter(socket.getOutputStream(), true)
+
+ var line = reader.readLine()
+ while (line != null) {
+ val msg = Protocol.fromJson(line) ?: break
+
+ when (msg.type) {
+ Protocol.CMD_JOIN -> {
+ playerId = UUID.randomUUID().toString()
+ val isHost = waitingPlayers.isEmpty()
+ if (isHost) hostId = playerId
+
+ val player = Player(
+ id = playerId,
+ name = msg.name ?: "Player",
+ isHost = isHost
+ )
+
+ synchronized(waitingPlayers) {
+ waitingPlayers.add(player)
+ }
+
+ clients[playerId] = ClientHandler(socket, writer, player)
+
+ // Send join accepted
+ val acceptedMsg = Protocol.Message(
+ type = Protocol.CMD_JOIN_ACCEPTED,
+ playerId = playerId,
+ players = waitingPlayers.map { Protocol.playerToData(it) }
+ )
+ writer.println(Protocol.toJson(acceptedMsg))
+
+ // Broadcast to all clients
+ broadcastPlayerJoined(player)
+ }
+
+ Protocol.CMD_START_GAME -> {
+ if (msg.playerId == hostId && !isGameRunning && waitingPlayers.size >= 2) {
+ startGame()
+ } else {
+ sendTo(playerId, Protocol.Message(
+ type = Protocol.CMD_ERROR,
+ message = if (waitingPlayers.size < 2) "至少需要2名玩家" else "只有房主可以开始游戏"
+ ))
+ }
+ }
+
+ Protocol.CMD_PLAY_CARD -> {
+ if (!isGameRunning) continue
+ val result = engine.playCard(
+ gameState!!, playerId,
+ msg.cardIndex ?: -1,
+ if (msg.chosenColor != null) CardColor.valueOf(msg.chosenColor) else null
+ )
+ when (result) {
+ is GameEngine.PlayResult.Success -> {
+ gameState = result.state
+ broadcastState()
+ if (gameState!!.isGameOver) {
+ isGameRunning = false
+ }
+ }
+ is GameEngine.PlayResult.Error -> {
+ sendTo(playerId, Protocol.Message(
+ type = Protocol.CMD_ERROR,
+ message = result.message
+ ))
+ }
+ }
+ }
+
+ Protocol.CMD_DRAW_CARD -> {
+ if (!isGameRunning) continue
+ val result = engine.drawCard(gameState!!, playerId)
+ when (result) {
+ is GameEngine.PlayResult.Success -> {
+ gameState = result.state
+ broadcastState()
+ }
+ is GameEngine.PlayResult.Error -> {
+ sendTo(playerId, Protocol.Message(
+ type = Protocol.CMD_ERROR,
+ message = result.message
+ ))
+ }
+ }
+ }
+
+ Protocol.CMD_CALL_UNO -> {
+ // Uno call handled client-side
+ }
+ }
+
+ line = reader.readLine()
+ }
+ } catch (e: Exception) {
+ // Client disconnected
+ } finally {
+ if (playerId.isNotEmpty()) {
+ handleDisconnect(playerId)
+ }
+ try { socket.close() } catch (_: Exception) {}
+ }
+ }
+
+ private fun startGame() {
+ val players = synchronized(waitingPlayers) { waitingPlayers.toList() }
+ val state = engine.createInitialState(players)
+ gameState = state
+
+ isGameRunning = true
+
+ // Send initial game started with private cards per player
+ broadcastStateWithCards()
+ }
+
+ private fun broadcastState() {
+ broadcastStateWithCards()
+ }
+
+ private fun broadcastStateWithCards() {
+ val state = gameState ?: return
+ val baseData = Protocol.stateToData(state)
+ clients.values.forEach { handler ->
+ val playerId = handler.player.id
+ val playerState = state.players.find { it.id == playerId }
+ val playerCards = playerState?.cards?.map { Protocol.cardToData(it) }
+ val message = Protocol.Message(
+ type = Protocol.CMD_GAME_STATE,
+ state = baseData,
+ cards = playerCards
+ )
+ handler.writer.println(Protocol.toJson(message))
+ }
+ }
+
+ private fun broadcastPlayerJoined(player: Player) {
+ val msg = Protocol.Message(
+ type = Protocol.CMD_PLAYER_JOINED,
+ player = Protocol.playerToData(player)
+ )
+ val json = Protocol.toJson(msg)
+ clients.values.forEach { it.writer.println(json) }
+ _events.value = ServerEvent(
+ type = "PLAYER_JOINED",
+ player = player,
+ players = waitingPlayers.toList()
+ )
+ }
+
+ private fun sendTo(playerId: String, message: Protocol.Message) {
+ clients[playerId]?.let {
+ it.writer.println(Protocol.toJson(message))
+ }
+ }
+
+ private fun handleDisconnect(playerId: String) {
+ clients.remove(playerId)
+ synchronized(waitingPlayers) {
+ waitingPlayers.removeAll { it.id == playerId }
+ }
+
+ val msg = Protocol.Message(
+ type = Protocol.CMD_PLAYER_LEFT,
+ playerId = playerId
+ )
+ val json = Protocol.toJson(msg)
+ clients.values.forEach { it.writer.println(json) }
+
+ if (isGameRunning && waitingPlayers.size < 2) {
+ isGameRunning = false
+ gameState = gameState?.copy(
+ isGameOver = true,
+ message = "玩家断开连接,游戏结束"
+ )
+ broadcastState()
+ }
+ }
+
+ fun getWaitingPlayers(): List = waitingPlayers.toList()
+ fun getGameState(): GameState? = gameState
+
+ fun shutdown() {
+ isGameRunning = false
+ scope.cancel()
+ clients.values.forEach { try { it.socket.close() } catch (_: Exception) {} }
+ clients.clear()
+ waitingPlayers.clear()
+ try { serverSocket?.close() } catch (_: Exception) {}
+ }
+
+ private data class ClientHandler(
+ val socket: Socket,
+ val writer: PrintWriter,
+ val player: Player
+ )
+}
diff --git a/app/src/main/java/com/unogame/network/Protocol.kt b/app/src/main/java/com/unogame/network/Protocol.kt
new file mode 100644
index 0000000..061815b
--- /dev/null
+++ b/app/src/main/java/com/unogame/network/Protocol.kt
@@ -0,0 +1,127 @@
+package com.unogame.network
+
+import com.google.gson.Gson
+import com.google.gson.reflect.TypeToken
+import com.unogame.model.*
+
+object Protocol {
+ val gson = Gson()
+
+ // Client -> Server
+ const val CMD_JOIN = "JOIN"
+ const val CMD_PLAY_CARD = "PLAY_CARD"
+ const val CMD_DRAW_CARD = "DRAW_CARD"
+ const val CMD_CALL_UNO = "CALL_UNO"
+ const val CMD_START_GAME = "START_GAME"
+ const val CMD_LEAVE = "LEAVE"
+
+ // Server -> Client
+ const val CMD_JOIN_ACCEPTED = "JOIN_ACCEPTED"
+ const val CMD_PLAYER_JOINED = "PLAYER_JOINED"
+ const val CMD_PLAYER_LEFT = "PLAYER_LEFT"
+ const val CMD_GAME_STATE = "GAME_STATE"
+ const val CMD_ERROR = "ERROR"
+ const val CMD_GAME_STARTED = "GAME_STARTED"
+ const val CMD_PLAYER_DISCONNECTED = "PLAYER_DISCONNECTED"
+
+ // Discovery
+ const val CMD_DISCOVER = "DISCOVER"
+ const val CMD_DISCOVER_RESPONSE = "DISCOVER_RESPONSE"
+
+ const val DISCOVERY_PORT = 9876
+ const val GAME_PORT = 9877
+
+ data class Message(
+ val type: String,
+ val playerId: String? = null,
+ val name: String? = null,
+ val cardIndex: Int? = null,
+ val chosenColor: String? = null,
+ val players: List? = null,
+ val player: PlayerData? = null,
+ val state: StateData? = null,
+ val message: String? = null,
+ val address: String? = null,
+ val cards: List? = null
+ )
+
+ data class PlayerData(
+ val id: String,
+ val name: String,
+ val cardCount: Int,
+ val isHost: Boolean,
+ val isCurrentTurn: Boolean,
+ val isConnected: Boolean,
+ val calledUno: Boolean
+ )
+
+ data class StateData(
+ val players: List,
+ val currentPlayerIndex: Int,
+ val direction: Int,
+ val topCard: CardData?,
+ val drawPileCount: Int,
+ val currentWildColor: String?,
+ val isGameOver: Boolean,
+ val winnerId: String?,
+ val winnerName: String?,
+ val pendingDrawCount: Int,
+ val flipped: Boolean = false,
+ val turnNumber: Int = 0,
+ val message: String
+ )
+
+ data class CardData(
+ val color: String,
+ val type: String,
+ val number: Int
+ )
+
+ fun toJson(msg: Message): String = gson.toJson(msg)
+
+ fun fromJson(json: String): Message? {
+ return try {
+ gson.fromJson(json, Message::class.java)
+ } catch (e: Exception) {
+ null
+ }
+ }
+
+ fun playerToData(p: Player): PlayerData = PlayerData(
+ id = p.id,
+ name = p.name,
+ cardCount = p.cards.size,
+ isHost = p.isHost,
+ isCurrentTurn = p.isCurrentTurn,
+ isConnected = p.isConnected,
+ calledUno = p.calledUno
+ )
+
+ fun stateToData(s: GameState): StateData = StateData(
+ players = s.players.map { playerToData(it) },
+ currentPlayerIndex = s.currentPlayerIndex,
+ direction = s.direction,
+ topCard = s.topCard?.let { cardToData(it) },
+ drawPileCount = s.drawPileCount,
+ currentWildColor = s.currentWildColor?.name,
+ isGameOver = s.isGameOver,
+ winnerId = s.winner?.id,
+ winnerName = s.winner?.name,
+ pendingDrawCount = s.pendingDrawCount,
+ flipped = s.flipped,
+ turnNumber = s.turnNumber,
+ message = s.message
+ )
+
+ fun cardToData(c: Card): CardData = CardData(
+ color = c.color.name,
+ type = c.type.name,
+ number = c.number
+ )
+
+ fun dataToCard(d: CardData): Card = Card(
+ color = CardColor.valueOf(d.color),
+ type = CardType.valueOf(d.type),
+ number = d.number
+ )
+}
diff --git a/app/src/main/java/com/unogame/ui/components/CardView.kt b/app/src/main/java/com/unogame/ui/components/CardView.kt
new file mode 100644
index 0000000..16af18d
--- /dev/null
+++ b/app/src/main/java/com/unogame/ui/components/CardView.kt
@@ -0,0 +1,293 @@
+package com.unogame.ui.components
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.shadow
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.window.Popup
+import com.unogame.model.Card
+import com.unogame.model.CardColor
+import com.unogame.model.CardType
+import com.unogame.ui.theme.*
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+fun CardView(
+ card: Card,
+ modifier: Modifier = Modifier,
+ selected: Boolean = false,
+ playable: Boolean = true,
+ theme: CardTheme = LocalCardTheme.current,
+ onClick: () -> Unit = {}
+) {
+ val cardBg = getCardBgColor(card.color.name)
+ val isWild = card.color == CardColor.WILD
+ var showInfo by remember { mutableStateOf(false) }
+
+ Box {
+ when (theme) {
+ CardTheme.CLASSIC -> ClassicCard(card, cardBg, isWild, modifier, selected, playable, onClick, onLongPress = { showInfo = true })
+ CardTheme.ELEGANT -> ElegantCard(card, cardBg, isWild, modifier, selected, playable, onClick, onLongPress = { showInfo = true })
+ CardTheme.MIDNIGHT -> MidnightCard(card, cardBg, isWild, modifier, selected, playable, onClick, onLongPress = { showInfo = true })
+ }
+
+ if (showInfo) {
+ CardInfoPopup(card) { showInfo = false }
+ }
+ }
+}
+
+// ── Classic ──
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+private fun ClassicCard(
+ card: Card, cardBg: Color, isWild: Boolean,
+ modifier: Modifier, selected: Boolean, playable: Boolean, onClick: () -> Unit, onLongPress: () -> Unit
+) {
+ Box(
+ modifier = modifier
+ .width(60.dp).height(90.dp)
+ .then(if (selected) Modifier.offset(y = (-10).dp) else Modifier)
+ .shadow(if (selected) 8.dp else 2.dp, RoundedCornerShape(10.dp))
+ .clip(RoundedCornerShape(10.dp))
+ .background(
+ if (isWild) Brush.verticalGradient(listOf(CardRedBg, CardBlueBg, CardGreenBg, CardYellowBg))
+ else Brush.verticalGradient(listOf(cardBg, cardBg.copy(alpha = 0.8f)))
+ )
+ .border(2.dp, if (selected) Color.White else Color.Transparent, RoundedCornerShape(10.dp))
+ .then(Modifier.combinedClickable(onClick = onClick, onLongClick = onLongPress)),
+ contentAlignment = Alignment.Center
+ ) { CardContent(card, isWild) }
+}
+
+// ── Elegant ──
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+private fun ElegantCard(
+ card: Card, cardBg: Color, isWild: Boolean,
+ modifier: Modifier, selected: Boolean, playable: Boolean, onClick: () -> Unit, onLongPress: () -> Unit
+) {
+ val borderColor = if (isWild) GoldAccent else cardBg.copy(alpha = 0.5f)
+ Box(
+ modifier = modifier
+ .width(60.dp).height(90.dp)
+ .then(if (selected) Modifier.offset(y = (-10).dp) else Modifier)
+ .shadow(if (selected) 8.dp else 3.dp, RoundedCornerShape(14.dp))
+ .clip(RoundedCornerShape(14.dp))
+ .background(Color.White)
+ .border(2.dp, if (selected) GoldAccent else borderColor, RoundedCornerShape(14.dp))
+ .then(Modifier.combinedClickable(onClick = onClick, onLongClick = onLongPress)),
+ contentAlignment = Alignment.Center
+ ) {
+ // Inner colored oval
+ Box(
+ modifier = Modifier
+ .fillMaxWidth(0.82f).fillMaxHeight(0.78f)
+ .clip(RoundedCornerShape(50))
+ .background(
+ if (isWild) Brush.verticalGradient(listOf(CardRedBg, CardBlueBg, CardGreenBg, CardYellowBg))
+ else Brush.linearGradient(listOf(cardBg, cardBg.copy(alpha = 0.6f)))
+ ),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = card.displayText,
+ color = Color.White,
+ fontSize = if (card.type == CardType.NUMBER && card.number >= 10) 16.sp else 22.sp,
+ fontWeight = FontWeight.Black
+ )
+ }
+ // Corner numbers
+ Text(card.displayText, modifier = Modifier.align(Alignment.TopStart).padding(6.dp, 4.dp),
+ color = if (isWild) Color.White else cardBg, fontSize = 10.sp, fontWeight = FontWeight.Bold)
+ Text(card.displayText, modifier = Modifier.align(Alignment.BottomEnd).padding(6.dp, 4.dp),
+ color = if (isWild) Color.White else cardBg, fontSize = 10.sp, fontWeight = FontWeight.Bold)
+ }
+}
+
+// ── Midnight ──
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+private fun MidnightCard(
+ card: Card, cardBg: Color, isWild: Boolean,
+ modifier: Modifier, selected: Boolean, playable: Boolean, onClick: () -> Unit, onLongPress: () -> Unit
+) {
+ val glow = if (isWild) Color.Magenta.copy(alpha = 0.6f) else cardBg.copy(alpha = 0.6f)
+ Box(
+ modifier = modifier
+ .width(60.dp).height(90.dp)
+ .then(if (selected) Modifier.offset(y = (-10).dp) else Modifier)
+ .shadow(if (selected) 12.dp else 4.dp, RoundedCornerShape(12.dp), ambientColor = glow, spotColor = glow)
+ .clip(RoundedCornerShape(12.dp))
+ .background(DarkCard)
+ .border(1.5.dp, glow, RoundedCornerShape(12.dp))
+ .then(Modifier.combinedClickable(onClick = onClick, onLongClick = onLongPress)),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
+ Text(
+ text = card.displayText,
+ color = if (isWild) Color.Magenta else getCardColor(card.color.name),
+ fontSize = 24.sp,
+ fontWeight = FontWeight.Black
+ )
+ if (card.color != CardColor.WILD) {
+ // mini color dots
+ Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) {
+ repeat(3) {
+ Box(Modifier.size(3.dp).clip(CircleShape).background(getCardColor(card.color.name)))
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun CardInfoPopup(card: Card, onDismiss: () -> Unit) {
+ val effectText = when (card.type) {
+ CardType.SKIP -> "跳过下家出牌"
+ CardType.REVERSE -> "反转出牌方向(2人局=跳过)"
+ CardType.DRAW_TWO -> "下家摸2张并跳过"
+ CardType.WILD -> "指定任一颜色继续"
+ CardType.WILD_DRAW_FOUR -> "下家摸4张并跳过,指定颜色"
+ CardType.FLIP -> "翻转所有牌到另一面"
+ CardType.DRAW_FIVE -> "下家摸5张并跳过"
+ CardType.SKIP_ALL -> "其他玩家各摸1张,跳过下家"
+ CardType.WILD_DRAW_TWO -> "下家摸2张并跳过,指定颜色"
+ CardType.DRAW_SIX -> "下家摸6张并跳过"
+ CardType.DRAW_TEN -> "下家摸10张并跳过"
+ CardType.WILD_DRAW_FOUR_REVERSE -> "方向反转,下家摸4张,指定颜色"
+ CardType.DISCARD_COLOR -> "弃掉手中所有同颜色牌"
+ CardType.WILD_DRAW_COLOR -> "跳过下家,指定颜色"
+ CardType.NUMBER -> "数字牌,配对颜色或数字"
+ }
+
+ Popup(onDismissRequest = onDismiss) {
+ Card(
+ modifier = Modifier.padding(32.dp),
+ shape = RoundedCornerShape(16.dp),
+ colors = CardDefaults.cardColors(containerColor = DarkSurface)
+ ) {
+ Column(modifier = Modifier.padding(20.dp)) {
+ Text("卡牌说明", color = GoldAccent, fontSize = 18.sp, fontWeight = FontWeight.Bold)
+ Spacer(modifier = Modifier.height(12.dp))
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ CardView(card, selected = false, playable = false, onClick = {})
+ Spacer(modifier = Modifier.width(16.dp))
+ Column {
+ Text("颜色: ${card.color.displayName}", color = getCardColor(card.color.name), fontSize = 14.sp, fontWeight = FontWeight.Bold)
+ Text("类型: ${card.displayText}", color = Color.White, fontSize = 14.sp)
+ if (card.type == CardType.NUMBER) {
+ Text("数字: ${card.number}", color = Color.White.copy(alpha = 0.7f), fontSize = 13.sp)
+ }
+ }
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(effectText, color = Color.White.copy(alpha = 0.8f), fontSize = 13.sp, lineHeight = 18.sp)
+ Spacer(modifier = Modifier.height(12.dp))
+ TextButton(onClick = onDismiss, modifier = Modifier.align(Alignment.End)) {
+ Text("关闭", color = GoldAccent)
+ }
+ }
+ }
+ }
+}
+
+// ── Shared inner content for classic ──
+@Composable
+private fun CardContent(card: Card, isWild: Boolean) {
+ if (isWild) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
+ Text(
+ text = if (card.type == CardType.WILD_DRAW_FOUR) "+4" else "W",
+ color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.Black
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ Row(Modifier.fillMaxWidth(0.7f), horizontalArrangement = Arrangement.SpaceEvenly) {
+ listOf(UnoRed, UnoBlue, UnoGreen, UnoYellow).forEach { c ->
+ Box(Modifier.size(6.dp).clip(CircleShape).background(c))
+ }
+ }
+ }
+ } else {
+ Column(Modifier.fillMaxSize().padding(4.dp), horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.SpaceBetween) {
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) {
+ Text(card.displayText, color = Color.White.copy(alpha = 0.8f), fontSize = 10.sp, fontWeight = FontWeight.Bold)
+ }
+ Text(card.displayText, color = Color.White, fontSize = 26.sp, fontWeight = FontWeight.Black)
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
+ Text(card.displayText, color = Color.White.copy(alpha = 0.8f), fontSize = 10.sp, fontWeight = FontWeight.Bold)
+ }
+ }
+ }
+}
+
+@Composable
+fun CardBack(modifier: Modifier = Modifier, theme: CardTheme = LocalCardTheme.current) {
+ when (theme) {
+ CardTheme.CLASSIC -> {
+ Box(
+ modifier = modifier.width(60.dp).height(90.dp)
+ .shadow(2.dp, RoundedCornerShape(10.dp))
+ .clip(RoundedCornerShape(10.dp))
+ .background(DarkCard)
+ .border(2.dp, GoldAccent, RoundedCornerShape(10.dp)),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Text("U", color = GoldAccent, fontSize = 28.sp, fontWeight = FontWeight.Black)
+ Text("N", color = GoldAccent, fontSize = 28.sp, fontWeight = FontWeight.Black)
+ Text("O", color = GoldAccent, fontSize = 28.sp, fontWeight = FontWeight.Black)
+ }
+ }
+ }
+ CardTheme.ELEGANT -> {
+ Box(
+ modifier = modifier.width(60.dp).height(90.dp)
+ .shadow(3.dp, RoundedCornerShape(14.dp))
+ .clip(RoundedCornerShape(14.dp))
+ .background(Color.White)
+ .border(2.dp, GoldAccent, RoundedCornerShape(14.dp)),
+ contentAlignment = Alignment.Center
+ ) {
+ Box(
+ Modifier.fillMaxWidth(0.75f).fillMaxHeight(0.7f)
+ .clip(RoundedCornerShape(40))
+ .background(DarkCard),
+ contentAlignment = Alignment.Center
+ ) {
+ Text("UNO", color = GoldAccent, fontSize = 16.sp, fontWeight = FontWeight.Black)
+ }
+ }
+ }
+ CardTheme.MIDNIGHT -> {
+ Box(
+ modifier = modifier.width(60.dp).height(90.dp)
+ .shadow(4.dp, RoundedCornerShape(12.dp), ambientColor = Color.Magenta.copy(alpha = 0.4f))
+ .clip(RoundedCornerShape(12.dp))
+ .background(DarkCard)
+ .border(1.5.dp, Color.Magenta.copy(alpha = 0.5f), RoundedCornerShape(12.dp)),
+ contentAlignment = Alignment.Center
+ ) {
+ Text("U", color = Color.Magenta, fontSize = 32.sp, fontWeight = FontWeight.Black)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/unogame/ui/components/ColorPickerDialog.kt b/app/src/main/java/com/unogame/ui/components/ColorPickerDialog.kt
new file mode 100644
index 0000000..599707a
--- /dev/null
+++ b/app/src/main/java/com/unogame/ui/components/ColorPickerDialog.kt
@@ -0,0 +1,77 @@
+package com.unogame.ui.components
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.*
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.window.Dialog
+import com.unogame.model.CardColor
+import com.unogame.ui.theme.*
+
+@Composable
+fun ColorPickerDialog(
+ flipped: Boolean = false,
+ onColorSelected: (CardColor) -> Unit,
+ onDismiss: () -> Unit
+) {
+ val colors = if (flipped)
+ listOf(CardColor.PINK, CardColor.PURPLE, CardColor.TEAL, CardColor.ORANGE)
+ else
+ listOf(CardColor.RED, CardColor.BLUE, CardColor.GREEN, CardColor.YELLOW)
+ Dialog(onDismissRequest = onDismiss) {
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ shape = RoundedCornerShape(16.dp),
+ colors = CardDefaults.cardColors(containerColor = DarkSurface)
+ ) {
+ Column(
+ modifier = Modifier.padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(
+ "选择颜色",
+ style = MaterialTheme.typography.headlineSmall,
+ color = Color.White
+ )
+ Spacer(modifier = Modifier.height(24.dp))
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceEvenly
+ ) {
+ colors.forEach { color ->
+ Box(
+ modifier = Modifier
+ .size(56.dp)
+ .clip(CircleShape)
+ .background(getCardColor(color.name))
+ .border(3.dp, Color.White, CircleShape)
+ .clickable { onColorSelected(color) },
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ color.displayName,
+ color = Color.White,
+ fontWeight = FontWeight.Bold,
+ fontSize = 14.sp
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/unogame/ui/components/PlayerAvatar.kt b/app/src/main/java/com/unogame/ui/components/PlayerAvatar.kt
new file mode 100644
index 0000000..9e48d43
--- /dev/null
+++ b/app/src/main/java/com/unogame/ui/components/PlayerAvatar.kt
@@ -0,0 +1,78 @@
+package com.unogame.ui.components
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.*
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.unogame.model.Player
+import com.unogame.ui.theme.*
+
+@Composable
+fun PlayerAvatar(
+ player: Player,
+ isYou: Boolean = false,
+ direction: Int = 1,
+ modifier: Modifier = Modifier
+) {
+ val accentColor = if (player.isCurrentTurn) GoldAccent else Color.Gray
+
+ Row(
+ modifier = modifier
+ .clip(RoundedCornerShape(12.dp))
+ .background(if (isYou) DarkSurface.copy(alpha = 0.8f) else DarkCard)
+ .border(2.dp, accentColor, RoundedCornerShape(12.dp))
+ .padding(horizontal = 12.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Box(
+ modifier = Modifier
+ .size(36.dp)
+ .clip(CircleShape)
+ .background(accentColor),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = player.name.take(1).uppercase(),
+ color = Color.White,
+ fontWeight = FontWeight.Bold,
+ fontSize = 16.sp
+ )
+ }
+ Spacer(modifier = Modifier.width(8.dp))
+ Column {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ text = player.name,
+ color = Color.White,
+ fontWeight = FontWeight.Bold,
+ fontSize = 14.sp
+ )
+ if (player.calledUno) {
+ Spacer(modifier = Modifier.width(4.dp))
+ Text(
+ "!",
+ color = GoldAccent,
+ fontWeight = FontWeight.Black,
+ fontSize = 16.sp
+ )
+ }
+ }
+ Text(
+ text = "${player.cardCount} 张牌 ${if (isYou) "(你)" else ""} ${if (player.isHost) "[房主]" else ""} ${if (player.isCurrentTurn) if (direction == 1) "→" else "←" else ""}",
+ color = Color.White.copy(alpha = 0.6f),
+ fontSize = 11.sp
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/unogame/ui/components/PlayerHand.kt b/app/src/main/java/com/unogame/ui/components/PlayerHand.kt
new file mode 100644
index 0000000..6b1c8cd
--- /dev/null
+++ b/app/src/main/java/com/unogame/ui/components/PlayerHand.kt
@@ -0,0 +1,54 @@
+package com.unogame.ui.components
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.horizontalScroll
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.unogame.model.Card
+import com.unogame.model.CardColor
+
+@Composable
+fun PlayerHand(
+ cards: List,
+ selectedIndex: Int,
+ topCard: Card?,
+ currentWildColor: CardColor?,
+ onCardClick: (Int) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ val scrollState = rememberScrollState()
+
+ Column(modifier = modifier) {
+ Text(
+ "你的手牌 (${cards.size})",
+ color = Color.White.copy(alpha = 0.7f),
+ fontSize = 14.sp,
+ modifier = Modifier.padding(bottom = 8.dp)
+ )
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .horizontalScroll(scrollState),
+ horizontalArrangement = Arrangement.Center
+ ) {
+ cards.forEachIndexed { index, card ->
+ val isPlayable = topCard == null || card.matches(topCard, currentWildColor)
+ CardView(
+ card = card,
+ selected = index == selectedIndex,
+ playable = isPlayable,
+ onClick = { onCardClick(index) },
+ modifier = Modifier.padding(horizontal = 2.dp)
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/unogame/ui/navigation/Screen.kt b/app/src/main/java/com/unogame/ui/navigation/Screen.kt
new file mode 100644
index 0000000..3c9a667
--- /dev/null
+++ b/app/src/main/java/com/unogame/ui/navigation/Screen.kt
@@ -0,0 +1,23 @@
+package com.unogame.ui.navigation
+
+sealed class Screen(val route: String) {
+ data object MainMenu : Screen("main_menu")
+ data object Lobby : Screen("lobby/{isHost}") {
+ fun createRoute(isHost: Boolean) = "lobby/$isHost"
+ }
+ data object ModeSelect : Screen("mode_select")
+ data object LocalSetup : Screen("local_setup/{modeName}") {
+ fun createRoute(mode: String) = "local_setup/$mode"
+ }
+ data object LocalGame : Screen("local_game/{modeName}/{totalPlayers}/{humanPlayerName}") {
+ fun createRoute(mode: String, totalPlayers: Int, humanPlayerName: String) =
+ "local_game/$mode/$totalPlayers/$humanPlayerName"
+ }
+ data object Scoreboard : Screen("scoreboard")
+ data object Rules : Screen("rules")
+ data object Game : Screen("game")
+ data object GameOver : Screen("game_over/{winnerName}/{isYouWinner}") {
+ fun createRoute(winnerName: String, isYouWinner: Boolean) =
+ "game_over/$winnerName/$isYouWinner"
+ }
+}
diff --git a/app/src/main/java/com/unogame/ui/screens/GameOverScreen.kt b/app/src/main/java/com/unogame/ui/screens/GameOverScreen.kt
new file mode 100644
index 0000000..cb35586
--- /dev/null
+++ b/app/src/main/java/com/unogame/ui/screens/GameOverScreen.kt
@@ -0,0 +1,83 @@
+package com.unogame.ui.screens
+
+import androidx.compose.animation.*
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.unogame.ui.theme.*
+
+@Composable
+fun GameOverScreen(
+ winnerName: String,
+ isYouWinner: Boolean,
+ onBackToMenu: () -> Unit
+) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(LocalTableBg.current.color),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(32.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Icon(
+ Icons.Default.EmojiEvents,
+ contentDescription = null,
+ tint = GoldAccent,
+ modifier = Modifier.size(80.dp)
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ Text(
+ text = if (isYouWinner) "恭喜你赢了!" else "游戏结束",
+ fontSize = 36.sp,
+ fontWeight = FontWeight.Black,
+ color = GoldAccent,
+ textAlign = TextAlign.Center
+ )
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ Text(
+ text = if (isYouWinner) "你是UNO冠军!" else "$winnerName 赢得了比赛",
+ fontSize = 18.sp,
+ color = Color.White.copy(alpha = 0.8f),
+ textAlign = TextAlign.Center
+ )
+
+ Spacer(modifier = Modifier.height(48.dp))
+
+ Button(
+ onClick = onBackToMenu,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(56.dp),
+ shape = RoundedCornerShape(16.dp),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = GoldAccent,
+ contentColor = Color.Black
+ )
+ ) {
+ Icon(Icons.Default.Home, null)
+ Spacer(modifier = Modifier.width(8.dp))
+ Text("返回主菜单", fontSize = 18.sp, fontWeight = FontWeight.Bold)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/unogame/ui/screens/GameScreen.kt b/app/src/main/java/com/unogame/ui/screens/GameScreen.kt
new file mode 100644
index 0000000..7fd476c
--- /dev/null
+++ b/app/src/main/java/com/unogame/ui/screens/GameScreen.kt
@@ -0,0 +1,312 @@
+package com.unogame.ui.screens
+
+import androidx.compose.foundation.*
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.unogame.model.*
+import com.unogame.ui.components.*
+import com.unogame.ui.theme.*
+
+@Composable
+fun GameScreen(
+ gameState: GameState,
+ myCards: List,
+ myPlayerId: String,
+ isMyTurn: Boolean,
+ onPlayCard: (Int) -> Unit,
+ onDrawCard: () -> Unit,
+ onChooseColor: (CardColor) -> Unit,
+ onCallUno: () -> Unit = {},
+ onChallengeUno: (String) -> Unit = {},
+ errorMessage: String
+) {
+ val scrollState = rememberScrollState()
+ var selectedCardIndex by remember { mutableIntStateOf(-1) }
+ var showColorPicker by remember { mutableStateOf(false) }
+ var selectedAutoCard by remember { mutableIntStateOf(-1) }
+ val flipped = gameState.flipped
+
+ val topCard = gameState.topCard
+ val currentPlayer = gameState.currentPlayer
+ val sortedCards = myCards.sortedWith(compareBy({ it.color.ordinal }, { it.number }, { it.type.ordinal }))
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(LocalTableBg.current.color)
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(scrollState)
+ .padding(top = 24.dp, bottom = 100.dp)
+ ) {
+ // Game message - fixed height area
+ Box(modifier = Modifier.fillMaxWidth().heightIn(min = 36.dp)) {
+ if (gameState.message.isNotEmpty()) {
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ colors = CardDefaults.cardColors(containerColor = DarkSurface),
+ shape = RoundedCornerShape(8.dp)
+ ) {
+ Text(
+ text = gameState.message,
+ color = GoldAccent,
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Medium,
+ modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
+ )
+ }
+ }
+ }
+ // Top bar
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ "方向: ${if (gameState.direction == 1) "→ 顺时" else "← 逆时"}",
+ color = Color.White.copy(alpha = 0.6f),
+ fontSize = 14.sp
+ )
+ if (flipped) {
+ Text(
+ "🔮 深色面",
+ color = UnoPurple,
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Bold
+ )
+ }
+ Text(
+ "牌堆: ${gameState.drawPileCount}张",
+ color = Color.White.copy(alpha = 0.6f),
+ fontSize = 14.sp
+ )
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ if (errorMessage.isNotEmpty()) {
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ colors = CardDefaults.cardColors(containerColor = UnoRed.copy(alpha = 0.3f)),
+ shape = RoundedCornerShape(8.dp)
+ ) {
+ Text(
+ text = errorMessage,
+ color = Color.White,
+ fontSize = 14.sp,
+ modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
+ )
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ }
+
+ // Other players
+ LazyRow(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ items(gameState.players.filter { it.id != myPlayerId }) { player ->
+ PlayerAvatar(
+ player = player,
+ isYou = false,
+ direction = gameState.direction,
+ modifier = Modifier.padding(vertical = 4.dp)
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ // Center area: discard pile + draw pile
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 32.dp),
+ horizontalArrangement = Arrangement.SpaceEvenly,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // Draw pile
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ CardBack(
+ modifier = Modifier.clickable(enabled = isMyTurn) {
+ onDrawCard()
+ }
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ "摸牌",
+ color = if (isMyTurn) GoldAccent else Color.Gray,
+ fontSize = 12.sp
+ )
+ }
+
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Text(
+ text = "当前弃牌堆",
+ color = Color.White.copy(alpha = 0.5f),
+ fontSize = 12.sp
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ // Top card
+ if (topCard != null) {
+ val displayTop = topCard.activeCard(flipped)
+ CardView(
+ card = displayTop.copy(
+ color = if (gameState.currentWildColor != null && topCard.color == CardColor.WILD)
+ gameState.currentWildColor!! else displayTop.color
+ ),
+ playable = false
+ )
+ }
+ if (gameState.currentWildColor != null) {
+ Text(
+ "当前颜色: ${gameState.currentWildColor!!.displayName}",
+ color = getCardColor(gameState.currentWildColor!!.name),
+ fontSize = 12.sp,
+ fontWeight = FontWeight.Bold
+ )
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ // Pending draw indicator
+ if (gameState.pendingDrawCount > 0) {
+ Text(
+ if (isMyTurn) "⚠ 你必须摸 ${gameState.pendingDrawCount} 张牌"
+ else "下家需摸 ${gameState.pendingDrawCount} 张牌",
+ color = if (isMyTurn) UnoRed else Color.White.copy(alpha = 0.6f),
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Bold,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+
+ // Current turn indicator
+ Text(
+ text = if (isMyTurn) "轮到你了!" else "等待: ${currentPlayer.name}",
+ color = if (isMyTurn) GoldAccent else Color.White.copy(alpha = 0.6f),
+ fontSize = 18.sp,
+ fontWeight = FontWeight.Bold,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth()
+ )
+
+ // UNO call / challenge buttons
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // UNO button for current player
+ if (isMyTurn && sortedCards.size <= 2) {
+ val me = gameState.players.find { it.id == myPlayerId }
+ Button(
+ onClick = onCallUno,
+ colors = ButtonDefaults.buttonColors(
+ containerColor = if (me?.calledUno == true) Color.Gray else UnoRed,
+ contentColor = Color.White
+ ),
+ shape = RoundedCornerShape(20.dp),
+ modifier = Modifier.height(36.dp)
+ ) {
+ Text(
+ if (me?.calledUno == true) "UNO ✓" else "喊 UNO!",
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Black
+ )
+ }
+ }
+ // Challenge buttons for other players who have 1 card
+ if (isMyTurn) {
+ gameState.players.filter { it.id != myPlayerId && it.cardCount == 1 && !it.calledUno }.forEach { p ->
+ Spacer(modifier = Modifier.width(8.dp))
+ OutlinedButton(
+ onClick = { onChallengeUno(p.id) },
+ colors = ButtonDefaults.outlinedButtonColors(contentColor = GoldAccent),
+ shape = RoundedCornerShape(20.dp),
+ modifier = Modifier.height(36.dp)
+ ) {
+ Text("抓 ${p.name}!", fontSize = 12.sp, fontWeight = FontWeight.Bold)
+ }
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+ }
+
+ // Hand cards pinned at bottom
+ PlayerHand(
+ cards = sortedCards,
+ selectedIndex = selectedCardIndex,
+ topCard = topCard,
+ currentWildColor = gameState.currentWildColor,
+ onCardClick = { index ->
+ if (!isMyTurn) return@PlayerHand
+ val realCard = sortedCards[index]
+ if (realCard.type.isWild) {
+ selectedAutoCard = sortedCards.indexOf(realCard)
+ showColorPicker = true
+ } else {
+ selectedCardIndex = index
+ onPlayCard(myCards.indexOf(realCard))
+ }
+ },
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 12.dp)
+ .background(LocalTableBg.current.color.copy(alpha = 0.9f))
+ )
+ }
+
+ // Color picker dialog
+ if (showColorPicker) {
+ ColorPickerDialog(
+ flipped = flipped,
+ onColorSelected = { color ->
+ showColorPicker = false
+ if (selectedAutoCard >= 0) {
+ onChooseColor(color)
+ onPlayCard(myCards.indexOf(sortedCards[selectedAutoCard]))
+ selectedCardIndex = selectedAutoCard
+ selectedAutoCard = -1
+ }
+ },
+ onDismiss = {
+ showColorPicker = false
+ selectedAutoCard = -1
+ selectedCardIndex = -1
+ }
+ )
+ }
+}
diff --git a/app/src/main/java/com/unogame/ui/screens/LobbyScreen.kt b/app/src/main/java/com/unogame/ui/screens/LobbyScreen.kt
new file mode 100644
index 0000000..006a35f
--- /dev/null
+++ b/app/src/main/java/com/unogame/ui/screens/LobbyScreen.kt
@@ -0,0 +1,323 @@
+package com.unogame.ui.screens
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.unogame.model.Player
+import com.unogame.network.DiscoveredHost
+import com.unogame.ui.components.PlayerAvatar
+import com.unogame.ui.theme.*
+
+@Composable
+fun LobbyScreen(
+ isHost: Boolean,
+ hostIp: String,
+ players: List,
+ discoveredHosts: List,
+ isDiscovering: Boolean,
+ isConnecting: Boolean,
+ connectedHost: String,
+ errorMessage: String,
+ onStartGame: () -> Unit,
+ onJoinHost: (DiscoveredHost) -> Unit,
+ onRefreshDiscovery: () -> Unit,
+ onManualConnect: (String) -> Unit,
+ onBack: () -> Unit
+) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(LocalTableBg.current.color)
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(24.dp)
+ ) {
+ // Header
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ IconButton(onClick = onBack) {
+ Icon(Icons.Default.ArrowBack, "返回", tint = Color.White)
+ }
+ Text(
+ text = if (isHost) "房间大厅" else "加入游戏",
+ fontSize = 24.sp,
+ fontWeight = FontWeight.Bold,
+ color = Color.White
+ )
+ Text(
+ text = if (isHost) "房主" else "玩家",
+ color = GoldAccent
+ )
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ if (errorMessage.isNotEmpty()) {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(containerColor = UnoRed.copy(alpha = 0.2f)),
+ shape = RoundedCornerShape(12.dp)
+ ) {
+ Text(
+ text = errorMessage,
+ color = Color.White,
+ modifier = Modifier.padding(16.dp),
+ fontSize = 14.sp
+ )
+ }
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+
+ if (isHost) {
+ // Host lobby
+ Text(
+ text = "等待玩家加入... (${players.size}/10)",
+ color = Color.White.copy(alpha = 0.7f),
+ fontSize = 16.sp
+ )
+ if (hostIp.isNotEmpty()) {
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text = "你的IP: $hostIp(告诉朋友用此IP连接)",
+ color = GoldAccent.copy(alpha = 0.8f),
+ fontSize = 13.sp
+ )
+ }
+ Spacer(modifier = Modifier.height(16.dp))
+
+ if (players.isNotEmpty()) {
+ Text(
+ "玩家列表",
+ color = Color.White.copy(alpha = 0.6f),
+ fontSize = 14.sp
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ players.forEach { player ->
+ PlayerAvatar(
+ player = player,
+ isYou = false,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp)
+ )
+ }
+ }
+ } else {
+ if (connectedHost.isEmpty()) {
+ // Show discovered hosts
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ "附近房间",
+ color = Color.White,
+ fontSize = 16.sp,
+ fontWeight = FontWeight.Bold
+ )
+ TextButton(onClick = onRefreshDiscovery) {
+ Icon(Icons.Default.Refresh, "刷新", tint = GoldAccent)
+ Spacer(modifier = Modifier.width(4.dp))
+ Text("刷新", color = GoldAccent, fontSize = 14.sp)
+ }
+ }
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ if (isDiscovering) {
+ CircularProgressIndicator(
+ modifier = Modifier.align(Alignment.CenterHorizontally),
+ color = GoldAccent
+ )
+ }
+
+ if (discoveredHosts.isEmpty() && !isDiscovering) {
+ Text(
+ "没有发现房间,请确保在同一WiFi下,并点刷新",
+ color = Color.White.copy(alpha = 0.5f),
+ fontSize = 14.sp
+ )
+ } else {
+ LazyColumn {
+ items(discoveredHosts) { host ->
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp),
+ shape = RoundedCornerShape(12.dp),
+ colors = CardDefaults.cardColors(containerColor = DarkSurface)
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column {
+ Text(
+ "${host.name} 的房间",
+ color = Color.White,
+ fontWeight = FontWeight.Bold,
+ fontSize = 16.sp
+ )
+ Text(
+ "IP: ${host.address}",
+ color = Color.White.copy(alpha = 0.5f),
+ fontSize = 12.sp
+ )
+ }
+ Button(
+ onClick = { onJoinHost(host) },
+ colors = ButtonDefaults.buttonColors(
+ containerColor = GoldAccent,
+ contentColor = Color.Black
+ ),
+ shape = RoundedCornerShape(8.dp)
+ ) {
+ Text("加入")
+ }
+ }
+ }
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // Manual IP entry
+ var manualIp by remember { mutableStateOf("") }
+ Text(
+ "手动连接(输入房主IP)",
+ color = Color.White.copy(alpha = 0.6f),
+ fontSize = 13.sp
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ OutlinedTextField(
+ value = manualIp,
+ onValueChange = { manualIp = it },
+ placeholder = { Text("192.168.x.x", color = Color.Gray) },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
+ colors = OutlinedTextFieldDefaults.colors(
+ focusedTextColor = Color.White,
+ unfocusedTextColor = Color.White,
+ focusedBorderColor = GoldAccent,
+ unfocusedBorderColor = Color.Gray,
+ cursorColor = GoldAccent
+ ),
+ modifier = Modifier.weight(1f)
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Button(
+ onClick = {
+ val ip = manualIp.trim()
+ if (ip.isNotEmpty()) onManualConnect(ip)
+ },
+ colors = ButtonDefaults.buttonColors(
+ containerColor = GoldAccent,
+ contentColor = Color.Black
+ ),
+ shape = RoundedCornerShape(8.dp)
+ ) {
+ Icon(Icons.Default.Link, null, modifier = Modifier.size(18.dp))
+ Spacer(modifier = Modifier.width(4.dp))
+ Text("连接", fontSize = 14.sp)
+ }
+ }
+ } else {
+ // Connected to host, waiting for game start
+ Text(
+ "已连接到主机",
+ color = GoldAccent,
+ fontSize = 16.sp,
+ fontWeight = FontWeight.Bold
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ "等待房主开始游戏...",
+ color = Color.White.copy(alpha = 0.7f),
+ fontSize = 14.sp
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ if (players.isNotEmpty()) {
+ Text(
+ "玩家列表",
+ color = Color.White.copy(alpha = 0.6f),
+ fontSize = 14.sp
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ players.forEach { player ->
+ PlayerAvatar(
+ player = player,
+ isYou = false,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp)
+ )
+ }
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ if (isConnecting) {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ CircularProgressIndicator(color = GoldAccent)
+ Spacer(modifier = Modifier.height(8.dp))
+ Text("正在连接...", color = Color.White.copy(alpha = 0.7f), fontSize = 14.sp)
+ }
+ }
+
+ // Start game button for host
+ if (isHost && players.size >= 2) {
+ Button(
+ onClick = onStartGame,
+ modifier = Modifier.fillMaxWidth().height(56.dp),
+ shape = RoundedCornerShape(16.dp),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = GoldAccent,
+ contentColor = Color.Black
+ )
+ ) {
+ Icon(Icons.Default.PlayArrow, null)
+ Spacer(modifier = Modifier.width(8.dp))
+ Text("开始游戏", fontSize = 18.sp, fontWeight = FontWeight.Bold)
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/unogame/ui/screens/LocalGameScreen.kt b/app/src/main/java/com/unogame/ui/screens/LocalGameScreen.kt
new file mode 100644
index 0000000..fe6c331
--- /dev/null
+++ b/app/src/main/java/com/unogame/ui/screens/LocalGameScreen.kt
@@ -0,0 +1,176 @@
+package com.unogame.ui.screens
+
+import android.content.Context
+import androidx.compose.runtime.*
+import androidx.compose.ui.platform.LocalContext
+import com.google.gson.Gson
+import com.google.gson.reflect.TypeToken
+import com.unogame.game.AIDifficulty
+import com.unogame.game.GameEngine
+import com.unogame.game.GameMode
+import com.unogame.game.GameRules
+import com.unogame.game.SimpleAI
+import com.unogame.model.*
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+
+data class ScoreEntry(val name: String, val wins: Int, val mode: String)
+
+object Scoreboard {
+ private const val PREFS_KEY = "uno_scores"
+ private val gson = Gson()
+
+ fun loadScores(context: Context): List {
+ val json = context.getSharedPreferences("unogame_prefs", Context.MODE_PRIVATE)
+ .getString(PREFS_KEY, null) ?: return emptyList()
+ return try {
+ gson.fromJson(json, object : TypeToken>() {}.type) ?: emptyList()
+ } catch (_: Exception) { emptyList() }
+ }
+
+ fun saveScores(context: Context, scores: List) {
+ context.getSharedPreferences("unogame_prefs", Context.MODE_PRIVATE)
+ .edit().putString(PREFS_KEY, gson.toJson(scores)).apply()
+ }
+
+ fun addWin(context: Context, name: String, mode: GameMode) {
+ val scores = loadScores(context).toMutableList()
+ val existing = scores.indexOfFirst { it.name == name && it.mode == mode.displayName }
+ if (existing >= 0) {
+ scores[existing] = scores[existing].copy(wins = scores[existing].wins + 1)
+ } else {
+ scores.add(ScoreEntry(name, 1, mode.displayName))
+ }
+ saveScores(context, scores.sortedByDescending { it.wins }.take(20))
+ }
+}
+
+@Composable
+fun LocalGameScreen(
+ totalPlayers: Int,
+ humanPlayerName: String,
+ mode: GameMode,
+ onBackToMenu: () -> Unit
+) {
+ val scope = rememberCoroutineScope()
+ val context = LocalContext.current
+ val rules = remember { GameRules.forMode(mode) }
+ val engine = remember { GameEngine(rules) }
+ val aiDiff = remember { AIDifficulty.load(context) }
+
+ val players = remember {
+ val list = mutableListOf()
+ list.add(Player(id = "human", name = humanPlayerName, isCurrentTurn = true))
+ for (i in 2..totalPlayers) {
+ list.add(Player(id = "bot_$i", name = "机器人$i", isCurrentTurn = false))
+ }
+ list
+ }
+
+ var gameState by remember { mutableStateOf(engine.createInitialState(players)) }
+ var myCards by remember { mutableStateOf(gameState.players.find { it.id == "human" }?.cards ?: emptyList()) }
+ var errorMessage by remember { mutableStateOf("") }
+ var selectedWildColor by remember { mutableStateOf(null) }
+ var isGameOver by remember { mutableStateOf(false) }
+ var winnerName by remember { mutableStateOf("") }
+ var isYouWinner by remember { mutableStateOf(false) }
+ var scoreSaved by remember { mutableStateOf(false) }
+
+ val myPlayerId = "human"
+ val currentPlayer = gameState.currentPlayer
+ val isMyTurn = currentPlayer.id == myPlayerId && !isGameOver
+
+ fun updateState(state: GameState) {
+ gameState = state
+ myCards = state.players.find { it.id == myPlayerId }?.cards ?: myCards
+ errorMessage = ""
+ if (state.isGameOver) {
+ isGameOver = true
+ winnerName = state.winner?.name ?: ""
+ isYouWinner = state.winner?.id == myPlayerId
+ if (isYouWinner && !scoreSaved) {
+ scoreSaved = true
+ Scoreboard.addWin(context, humanPlayerName, mode)
+ }
+ }
+ }
+
+ fun executePlay(cardIndex: Int, chosenColor: CardColor? = null) {
+ val result = engine.playCard(gameState, myPlayerId, cardIndex, chosenColor)
+ when (result) {
+ is GameEngine.PlayResult.Success -> updateState(result.state)
+ is GameEngine.PlayResult.Error -> errorMessage = result.message
+ }
+ }
+
+ fun executeDraw() {
+ val result = engine.drawCard(gameState, myPlayerId)
+ when (result) {
+ is GameEngine.PlayResult.Success -> updateState(result.state)
+ is GameEngine.PlayResult.Error -> errorMessage = result.message
+ }
+ }
+
+ LaunchedEffect(gameState.turnNumber) {
+ if (isGameOver) return@LaunchedEffect
+ val current = gameState.currentPlayer
+ if (current.id != myPlayerId) {
+ delay(1200L)
+
+ val move = SimpleAI.decideMove(current, gameState.topCard, gameState.currentWildColor, gameState.flipped, aiDiff)
+ when (move.type) {
+ SimpleAI.MoveType.PLAY -> {
+ val result = engine.playCard(gameState, current.id, move.cardIndex, move.chosenColor)
+ if (result is GameEngine.PlayResult.Success) {
+ updateState(result.state)
+ } else {
+ val drawResult = engine.drawCard(gameState, current.id)
+ if (drawResult is GameEngine.PlayResult.Success) updateState(drawResult.state)
+ }
+ }
+ SimpleAI.MoveType.DRAW -> {
+ val drawResult = engine.drawCard(gameState, current.id)
+ if (drawResult is GameEngine.PlayResult.Success) updateState(drawResult.state)
+ }
+ }
+ }
+ }
+
+ // Show flip indicator
+ val displayCards = remember(gameState.flipped, myCards) {
+ myCards.map { it.activeCard(gameState.flipped) }
+ }
+
+ if (isGameOver) {
+ GameOverScreen(
+ winnerName = winnerName,
+ isYouWinner = isYouWinner,
+ onBackToMenu = onBackToMenu
+ )
+ } else {
+ GameScreen(
+ gameState = gameState,
+ myCards = displayCards,
+ myPlayerId = myPlayerId,
+ isMyTurn = isMyTurn,
+ errorMessage = errorMessage,
+ onPlayCard = { index -> executePlay(index, selectedWildColor) },
+ onDrawCard = { executeDraw() },
+ onChooseColor = { selectedWildColor = it },
+ onCallUno = {
+ // Mark player as having called UNO
+ val updated = gameState.players.map {
+ if (it.id == myPlayerId) it.copy(calledUno = true) else it
+ }
+ gameState = gameState.copy(players = updated)
+ },
+ onChallengeUno = { targetId ->
+ val result = engine.challengeUno(gameState, myPlayerId, targetId)
+ when (result) {
+ is GameEngine.PlayResult.Success -> updateState(result.state)
+ is GameEngine.PlayResult.Error -> errorMessage = result.message
+ }
+ }
+ )
+ }
+}
diff --git a/app/src/main/java/com/unogame/ui/screens/LocalSetupScreen.kt b/app/src/main/java/com/unogame/ui/screens/LocalSetupScreen.kt
new file mode 100644
index 0000000..e32dab0
--- /dev/null
+++ b/app/src/main/java/com/unogame/ui/screens/LocalSetupScreen.kt
@@ -0,0 +1,254 @@
+package com.unogame.ui.screens
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.unogame.ui.theme.*
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun LocalSetupScreen(
+ playerName: String,
+ modeDisplayName: String,
+ onStartGame: (Int, String) -> Unit,
+ onBack: () -> Unit
+) {
+ var totalPlayers by remember { mutableIntStateOf(2) }
+ var name by remember { mutableStateOf(playerName) }
+ var difficulty by remember { mutableStateOf(com.unogame.game.AIDifficulty.NORMAL) }
+ val context = androidx.compose.ui.platform.LocalContext.current
+
+ val botNames = listOf("🤖 机器人1", "🤖 机器人2", "🤖 机器人3")
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(LocalTableBg.current.color)
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(24.dp)
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ IconButton(onClick = onBack) {
+ Icon(Icons.Default.ArrowBack, "返回", tint = Color.White)
+ }
+ Text(
+ "本地模式 - $modeDisplayName",
+ fontSize = 24.sp,
+ fontWeight = FontWeight.Bold,
+ color = Color.White
+ )
+ }
+
+ Spacer(modifier = Modifier.height(32.dp))
+
+ // Player name
+ Text("你的名字", color = Color.White.copy(alpha = 0.6f), fontSize = 14.sp)
+ Spacer(modifier = Modifier.height(8.dp))
+ OutlinedTextField(
+ value = name,
+ onValueChange = { name = it.take(10) },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth(),
+ colors = OutlinedTextFieldDefaults.colors(
+ focusedTextColor = Color.White,
+ unfocusedTextColor = Color.White,
+ focusedBorderColor = GoldAccent,
+ unfocusedBorderColor = Color.Gray,
+ cursorColor = GoldAccent
+ )
+ )
+
+ Spacer(modifier = Modifier.height(28.dp))
+
+ // Player count selector
+ Text("总玩家数", color = Color.White.copy(alpha = 0.6f), fontSize = 14.sp)
+ Spacer(modifier = Modifier.height(12.dp))
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceEvenly
+ ) {
+ listOf(2, 3, 4).forEach { count ->
+ val isSelected = totalPlayers == count
+ Card(
+ modifier = Modifier
+ .size(90.dp)
+ .clickable { totalPlayers = count },
+ shape = RoundedCornerShape(16.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = if (isSelected) GoldAccent else DarkSurface
+ )
+ ) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Text(
+ text = "$count",
+ fontSize = 32.sp,
+ fontWeight = FontWeight.Black,
+ color = if (isSelected) Color.Black else Color.White
+ )
+ Text(
+ text = "人",
+ fontSize = 12.sp,
+ color = if (isSelected) Color.Black.copy(alpha = 0.7f) else Color.White.copy(alpha = 0.5f)
+ )
+ }
+ }
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(28.dp))
+
+ // AI difficulty
+ Text("AI难度", color = Color.White.copy(alpha = 0.6f), fontSize = 14.sp)
+ Spacer(modifier = Modifier.height(8.dp))
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ com.unogame.game.AIDifficulty.values().forEach { d ->
+ val selected = difficulty == d
+ FilterChip(
+ selected = selected,
+ onClick = { difficulty = d },
+ label = { Text(d.displayName, fontSize = 13.sp) },
+ colors = FilterChipDefaults.filterChipColors(
+ selectedContainerColor = when(d) {
+ com.unogame.game.AIDifficulty.EASY -> UnoGreen
+ com.unogame.game.AIDifficulty.NORMAL -> GoldAccent
+ com.unogame.game.AIDifficulty.HARD -> UnoRed
+ },
+ selectedLabelColor = Color.Black
+ )
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(28.dp))
+
+ // Player list preview
+ Text("参与者预览", color = Color.White.copy(alpha = 0.6f), fontSize = 14.sp)
+ Spacer(modifier = Modifier.height(12.dp))
+
+ // You
+ PlayerSlot(
+ name = name.ifBlank { "玩家" },
+ isHuman = true,
+ isYou = true
+ )
+
+ // Bots
+ for (i in 1 until totalPlayers) {
+ Spacer(modifier = Modifier.height(8.dp))
+ PlayerSlot(
+ name = botNames[i - 1],
+ isHuman = false,
+ isYou = false
+ )
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ Button(
+ onClick = {
+ com.unogame.game.AIDifficulty.save(context, difficulty)
+ onStartGame(totalPlayers, name.ifBlank { "玩家" })
+ },
+ modifier = Modifier.fillMaxWidth().height(56.dp),
+ shape = RoundedCornerShape(16.dp),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = GoldAccent,
+ contentColor = Color.Black
+ )
+ ) {
+ Icon(Icons.Default.PlayArrow, null)
+ Spacer(modifier = Modifier.width(8.dp))
+ Text("开始游戏", fontSize = 18.sp, fontWeight = FontWeight.Bold)
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(12.dp),
+ colors = CardDefaults.cardColors(containerColor = DarkSurface.copy(alpha = 0.5f))
+ ) {
+ Text(
+ "• 1位真人 + ${totalPlayers - 1}个机器人\n• 支持2-4人局\n• 机器人会自动出牌,无需等待",
+ color = Color.White.copy(alpha = 0.5f),
+ fontSize = 12.sp,
+ lineHeight = 20.sp,
+ modifier = Modifier.padding(16.dp)
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun PlayerSlot(
+ name: String,
+ isHuman: Boolean,
+ isYou: Boolean
+) {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(12.dp),
+ colors = CardDefaults.cardColors(containerColor = DarkSurface)
+ ) {
+ Row(
+ modifier = Modifier.padding(12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Box(
+ modifier = Modifier
+ .size(40.dp)
+ .clip(CircleShape)
+ .background(if (isHuman) GoldAccent else Color.Gray),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ if (isHuman) Icons.Default.Person else Icons.Default.SmartToy,
+ contentDescription = null,
+ tint = if (isHuman) Color.Black else Color.White,
+ modifier = Modifier.size(24.dp)
+ )
+ }
+ Spacer(modifier = Modifier.width(12.dp))
+ Column {
+ Text(name, color = Color.White, fontWeight = FontWeight.Bold, fontSize = 16.sp)
+ Text(
+ if (isHuman) "真人玩家${if (isYou) " (你)" else ""}" else "AI机器人",
+ color = Color.White.copy(alpha = 0.5f),
+ fontSize = 12.sp
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/unogame/ui/screens/MainMenuScreen.kt b/app/src/main/java/com/unogame/ui/screens/MainMenuScreen.kt
new file mode 100644
index 0000000..68bb7cb
--- /dev/null
+++ b/app/src/main/java/com/unogame/ui/screens/MainMenuScreen.kt
@@ -0,0 +1,249 @@
+package com.unogame.ui.screens
+
+import androidx.compose.animation.*
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.unogame.ui.theme.*
+
+@Composable
+fun MainMenuScreen(
+ initialName: String,
+ currentTheme: CardTheme,
+ currentBg: TableBg,
+ onHostGame: (String) -> Unit,
+ onJoinGame: (String) -> Unit,
+ onLocalGame: () -> Unit,
+ onScoreboard: () -> Unit,
+ onRules: () -> Unit,
+ onToggleTheme: () -> Unit,
+ onToggleBg: () -> Unit,
+ onToggleOrientation: () -> Unit,
+ isLandscape: Boolean,
+ onNameChanged: (String) -> Unit
+) {
+ var playerName by remember { mutableStateOf(initialName) }
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(LocalTableBg.current.color),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(32.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ // Title
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ text = "UNO",
+ fontSize = 72.sp,
+ fontWeight = FontWeight.Black,
+ color = GoldAccent,
+ textAlign = TextAlign.Center
+ )
+ }
+ Text(
+ text = "卡牌游戏",
+ fontSize = 18.sp,
+ color = Color.White.copy(alpha = 0.6f),
+ textAlign = TextAlign.Center
+ )
+
+ Spacer(modifier = Modifier.height(60.dp))
+
+ // Name input
+ OutlinedTextField(
+ value = playerName,
+ onValueChange = { playerName = it.take(10) },
+ label = { Text("你的昵称", color = Color.White.copy(alpha = 0.6f)) },
+ singleLine = true,
+ colors = OutlinedTextFieldDefaults.colors(
+ focusedTextColor = Color.White,
+ unfocusedTextColor = Color.White,
+ focusedBorderColor = GoldAccent,
+ unfocusedBorderColor = Color.Gray,
+ cursorColor = GoldAccent
+ ),
+ modifier = Modifier.fillMaxWidth()
+ )
+
+ Spacer(modifier = Modifier.height(32.dp))
+
+ // Host Game button
+ Button(
+ onClick = {
+ val name = playerName.ifBlank { "玩家" }
+ onNameChanged(name)
+ onHostGame(name)
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(56.dp),
+ shape = RoundedCornerShape(16.dp),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = DarkSurface,
+ contentColor = GoldAccent
+ )
+ ) {
+ Icon(Icons.Default.Home, contentDescription = null)
+ Spacer(modifier = Modifier.width(12.dp))
+ Text("创建房间", fontSize = 18.sp, fontWeight = FontWeight.Bold)
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Join Game button
+ Button(
+ onClick = {
+ val name = playerName.ifBlank { "玩家" }
+ onNameChanged(name)
+ onJoinGame(name)
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(56.dp),
+ shape = RoundedCornerShape(16.dp),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = DarkSurface,
+ contentColor = Color.White
+ )
+ ) {
+ Icon(Icons.Default.Search, contentDescription = null)
+ Spacer(modifier = Modifier.width(12.dp))
+ Text("加入房间", fontSize = 18.sp, fontWeight = FontWeight.Bold)
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Local Game button
+ Button(
+ onClick = onLocalGame,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(56.dp),
+ shape = RoundedCornerShape(16.dp),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = DarkSurface,
+ contentColor = UnoGreen
+ )
+ ) {
+ Icon(Icons.Default.PhoneAndroid, contentDescription = null)
+ Spacer(modifier = Modifier.width(12.dp))
+ Text("本地模式", fontSize = 18.sp, fontWeight = FontWeight.Bold)
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Scoreboard button
+ Button(
+ onClick = onScoreboard,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(56.dp),
+ shape = RoundedCornerShape(16.dp),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = DarkSurface,
+ contentColor = UnoPurple.copy(alpha = 0.8f)
+ )
+ ) {
+ Icon(Icons.Default.EmojiEvents, contentDescription = null)
+ Spacer(modifier = Modifier.width(12.dp))
+ Text("排行榜", fontSize = 18.sp, fontWeight = FontWeight.Bold)
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Rules button
+ Button(
+ onClick = onRules,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(56.dp),
+ shape = RoundedCornerShape(16.dp),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = DarkSurface,
+ contentColor = Color.White.copy(alpha = 0.7f)
+ )
+ ) {
+ Icon(Icons.Default.MenuBook, contentDescription = null)
+ Spacer(modifier = Modifier.width(12.dp))
+ Text("规则说明", fontSize = 18.sp, fontWeight = FontWeight.Bold)
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Theme toggle
+ TextButton(onClick = onToggleTheme) {
+ Icon(Icons.Default.Palette, null, tint = Color.White.copy(alpha = 0.5f))
+ Spacer(modifier = Modifier.width(6.dp))
+ Text("卡面: ${currentTheme.displayName}", color = Color.White.copy(alpha = 0.5f), fontSize = 14.sp)
+ Spacer(modifier = Modifier.width(4.dp))
+ Text("→", color = GoldAccent, fontSize = 14.sp)
+ }
+
+ // Background toggle
+ TextButton(onClick = onToggleBg) {
+ Icon(Icons.Default.Wallpaper, null, tint = Color.White.copy(alpha = 0.5f))
+ Spacer(modifier = Modifier.width(6.dp))
+ Text("牌桌: ${currentBg.displayName}", color = Color.White.copy(alpha = 0.5f), fontSize = 14.sp)
+ Spacer(modifier = Modifier.width(4.dp))
+ Text("→", color = GoldAccent, fontSize = 14.sp)
+ }
+
+ // Orientation toggle
+ TextButton(onClick = onToggleOrientation) {
+ Icon(if (isLandscape) Icons.Default.StayCurrentLandscape else Icons.Default.StayCurrentPortrait,
+ null, tint = Color.White.copy(alpha = 0.5f))
+ Spacer(modifier = Modifier.width(6.dp))
+ Text(if (isLandscape) "横屏" else "竖屏", color = Color.White.copy(alpha = 0.5f), fontSize = 14.sp)
+ Spacer(modifier = Modifier.width(4.dp))
+ Text("→", color = GoldAccent, fontSize = 14.sp)
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Instructions
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(12.dp),
+ colors = CardDefaults.cardColors(containerColor = DarkSurface.copy(alpha = 0.5f))
+ ) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ Text(
+ "游戏说明",
+ color = GoldAccent,
+ fontWeight = FontWeight.Bold,
+ fontSize = 14.sp
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ "• 创建房间:作为房主等待其他玩家加入\n• 加入房间:搜索同一WiFi下的房间\n• 与同颜色、同数字/功能的牌匹配",
+ color = Color.White.copy(alpha = 0.7f),
+ fontSize = 12.sp,
+ lineHeight = 18.sp
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/unogame/ui/screens/ModeSelectScreen.kt b/app/src/main/java/com/unogame/ui/screens/ModeSelectScreen.kt
new file mode 100644
index 0000000..bd45254
--- /dev/null
+++ b/app/src/main/java/com/unogame/ui/screens/ModeSelectScreen.kt
@@ -0,0 +1,123 @@
+package com.unogame.ui.screens
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.unogame.game.GameMode
+import com.unogame.ui.theme.*
+
+@Composable
+fun ModeSelectScreen(
+ playerName: String,
+ onStartGame: (GameMode) -> Unit,
+ onBack: () -> Unit
+) {
+ Box(
+ modifier = Modifier.fillMaxSize().background(LocalTableBg.current.color)
+ ) {
+ Column(
+ modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(24.dp)
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ IconButton(onClick = onBack) {
+ Icon(Icons.Default.ArrowBack, "返回", tint = Color.White)
+ }
+ Text("选择模式", fontSize = 24.sp, fontWeight = FontWeight.Bold, color = Color.White)
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+ Text("玩家: $playerName", color = Color.White.copy(alpha = 0.6f), fontSize = 14.sp)
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ ModeCard(
+ mode = GameMode.NORMAL,
+ icon = Icons.Default.Style,
+ title = "普通模式",
+ desc = "经典UNO规则\n7张手牌起点\n同色同数出牌",
+ onClick = { onStartGame(GameMode.NORMAL) }
+ )
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ ModeCard(
+ mode = GameMode.FLIP,
+ icon = Icons.Default.FlipToBack,
+ title = "UNO Flip",
+ desc = "双面牌+翻转机制\n每张牌有深浅两面\n翻转牌切换整个游戏\n深色面: +5、全体摸牌",
+ onClick = { onStartGame(GameMode.FLIP) }
+ )
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ ModeCard(
+ mode = GameMode.NO_MERCY,
+ icon = Icons.Default.Dangerous,
+ title = "无情UNO",
+ desc = "残酷规则\n10张手牌起点\n+6、+10惩罚牌\n+牌可以叠加\n弃同色牌飞跃",
+ onClick = { onStartGame(GameMode.NO_MERCY) }
+ )
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ ModeCard(
+ mode = GameMode.SEVEN_ZERO,
+ icon = Icons.Default.SwapHoriz,
+ title = "7-0 规则",
+ desc = "经典村规\n出7:交换手牌\n出0:全体传牌\n其他规则同普通UNO",
+ onClick = { onStartGame(GameMode.SEVEN_ZERO) }
+ )
+ }
+ }
+}
+
+@Composable
+private fun ModeCard(
+ mode: GameMode,
+ icon: androidx.compose.ui.graphics.vector.ImageVector,
+ title: String,
+ desc: String,
+ onClick: () -> Unit
+) {
+ val accent = when (mode) {
+ GameMode.NORMAL -> GoldAccent
+ GameMode.FLIP -> UnoPurple
+ GameMode.NO_MERCY -> UnoRed
+ GameMode.SEVEN_ZERO -> UnoBlue
+ }
+
+ Card(
+ modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
+ shape = RoundedCornerShape(16.dp),
+ colors = CardDefaults.cardColors(containerColor = DarkSurface)
+ ) {
+ Row(
+ modifier = Modifier.padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(icon, null, tint = accent, modifier = Modifier.size(40.dp))
+ Spacer(modifier = Modifier.width(16.dp))
+ Column(modifier = Modifier.weight(1f)) {
+ Text(title, color = accent, fontSize = 18.sp, fontWeight = FontWeight.Bold)
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(desc, color = Color.White.copy(alpha = 0.7f), fontSize = 13.sp, lineHeight = 18.sp)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/unogame/ui/screens/RulesHelpScreen.kt b/app/src/main/java/com/unogame/ui/screens/RulesHelpScreen.kt
new file mode 100644
index 0000000..b9c9539
--- /dev/null
+++ b/app/src/main/java/com/unogame/ui/screens/RulesHelpScreen.kt
@@ -0,0 +1,214 @@
+package com.unogame.ui.screens
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.unogame.ui.theme.*
+
+@Composable
+fun RulesHelpScreen(onBack: () -> Unit) {
+ val scrollState = rememberScrollState()
+
+ Box(modifier = Modifier.fillMaxSize().background(LocalTableBg.current.color)) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(scrollState)
+ .padding(24.dp)
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ IconButton(onClick = onBack) {
+ Icon(Icons.Default.ArrowBack, "返回", tint = Color.White)
+ }
+ Text("详细规则", fontSize = 24.sp, fontWeight = FontWeight.Bold, color = Color.White)
+ }
+
+ SpacerH(20)
+
+ // ══════════════ Standard ══════════════
+ SectionCard("🃏 一、普通模式(标准UNO)", GoldAccent) {
+ Label("牌组构成(108张)")
+ Bullet("数字牌 76张:红黄蓝绿各19张(0号1张,1-9各2张)")
+ Bullet("功能牌 24张:每色 Skip×2、Reverse×2、+2×2")
+ Bullet("万能牌 8张:Wild×4、Wild+4×4")
+ SpacerH(8)
+ Label("出牌规则")
+ Bullet("必须出与弃牌堆顶牌同颜色或同数字/同符号的牌")
+ Bullet("Wild任意时候可出,出牌者指定下一回合颜色")
+ Bullet("Wild+4只能在手上无当前颜色时出(可被挑战)")
+ Bullet("不能出牌时必须从牌堆抽一张,抽到的牌可立即打出")
+ SpacerH(8)
+ Label("功能牌效果")
+ Bullet("Skip:跳过下家")
+ Bullet("Reverse:反转出牌方向(2人局=跳过)")
+ Bullet("+2:下家抽2张并跳过本回合")
+ Bullet("Wild:指定颜色")
+ Bullet("Wild+4:下家抽4张并跳过,指定颜色")
+ SpacerH(8)
+ Label("UNO叫牌")
+ Bullet("手牌剩1张时必须喊UNO,未喊被抓住罚抽2张")
+ }
+
+ SpacerH(16)
+
+ // ══════════════ Flip ══════════════
+ SectionCard("🔮 二、UNO Flip(翻转UNO)", UnoPurple) {
+ Label("牌组特点")
+ Bullet("所有牌双面印刷:浅色面(红黄蓝绿)+ 深色面(粉紫青橙)")
+ Bullet("翻转牌 4张(每色各1张):打出后全部牌翻面")
+ Bullet("初始全部为浅色面朝上")
+ SpacerH(8)
+ Label("双面功能对照")
+ Row(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
+ Column(Modifier.weight(1f)) {
+ Text("浅色面", color = GoldAccent, fontSize = 12.sp, fontWeight = FontWeight.Bold)
+ Text("Skip", color = Color.White.copy(alpha = 0.7f), fontSize = 12.sp)
+ Text("Reverse", color = Color.White.copy(alpha = 0.7f), fontSize = 12.sp)
+ Text("+2", color = Color.White.copy(alpha = 0.7f), fontSize = 12.sp)
+ Text("Wild", color = Color.White.copy(alpha = 0.7f), fontSize = 12.sp)
+ Text("Wild+4", color = Color.White.copy(alpha = 0.7f), fontSize = 12.sp)
+ }
+ Column(Modifier.weight(1f)) {
+ Text("→ 深色面", color = UnoPurple, fontSize = 12.sp, fontWeight = FontWeight.Bold)
+ Text("全体摸1张", color = Color.White.copy(alpha = 0.7f), fontSize = 12.sp)
+ Text("→ +2", color = Color.White.copy(alpha = 0.7f), fontSize = 12.sp)
+ Text("→ +5", color = Color.White.copy(alpha = 0.7f), fontSize = 12.sp)
+ Text("→ Wild+2", color = Color.White.copy(alpha = 0.7f), fontSize = 12.sp)
+ Text("Wild+4(不变)", color = Color.White.copy(alpha = 0.7f), fontSize = 12.sp)
+ }
+ }
+ SpacerH(4)
+ Bullet("深色面颜色:粉/紫/青/橙")
+ Bullet("翻转牌有颜色,打出后同时触发翻转效果")
+ }
+
+ SpacerH(16)
+
+ // ══════════════ No Mercy ══════════════
+ SectionCard("💀 三、无情UNO(No Mercy)", UnoRed) {
+ Label("牌组构成(112张)")
+ Bullet("标准牌:数字0-9、Skip、Reverse、+2(各色×2)、Wild×4、Wild+4×4")
+ Bullet("新增:+1×8、+3×4、+6×2、+10×1(本版简化为+6×2、+10×1、DiscardColor×1)")
+ Bullet("DiscardColor:弃掉手中所有同颜色牌")
+ Bullet("Wild+4Reverse:+4并反转方向")
+ SpacerH(8)
+ Label("核心规则变化")
+ Bullet("手牌上限10张:满10张时跳过一切摸牌惩罚")
+ Bullet("惩罚牌可叠加:同颜色Draw牌可以累加(需颜色匹配或出万能)")
+ Bullet("例:红+2 → 红+6 → 红+10 → 万能+4 → 下家摸22张")
+ Bullet("普通数字牌不能用来截停叠加惩罚")
+ SpacerH(8)
+ Label("惩罚叠加规则")
+ Bullet("只能叠同颜色的Draw牌或万能牌")
+ Bullet("若无法叠加,必须一次性摸走全部累计张数")
+ Bullet("Wild+4可以叠加到任何颜色的Draw牌上")
+ }
+
+ SpacerH(16)
+
+ // ══════════════ 7-0 ══════════════
+ SectionCard("🔄 四、7-0 规则(经典村规)", UnoBlue) {
+ Bullet("叠加于标准UNO之上,牌组同标准(108张)")
+ SpacerH(8)
+ Label("出 7:交换手牌")
+ Bullet("打出数字7后,与下家交换全部手牌")
+ Bullet("交换后若手牌变为0张,立即获胜")
+ Bullet("AI自动选择最优策略")
+ SpacerH(8)
+ Label("出 0:全体传牌")
+ Bullet("打出数字0后,全体玩家将手牌传给下家(按当前方向)")
+ Bullet("传递同时进行,每人手牌数量不变但有变化")
+ SpacerH(8)
+ Label("注意事项")
+ Bullet("7和0不能叠加到惩罚牌上")
+ Bullet("交换/传牌后仍需按规定喊UNO")
+ }
+
+ SpacerH(16)
+
+ // ══════════════ Common ══════════════
+ SectionCard("📐 通用规则", Color.White.copy(alpha = 0.7f)) {
+ Label("UNO叫牌与抓人")
+ Bullet("出牌后手牌剩1张时,红色\"喊UNO!\"按钮出现,必须点击")
+ Bullet("未喊且被其他玩家\"抓XX!\",罚抽2张")
+ Bullet("若已被抓过或手牌已满10张(无情模式),免罚")
+ SpacerH(8)
+ Label("游戏结束与计分")
+ Bullet("先出完手牌者获胜,单局结束")
+ Bullet("胜者计分 = 其他玩家手牌分数之和")
+ Bullet("数字牌:面值分;Skip/Reverse/+2/+1/+3/+5:20分")
+ Bullet("Wild/Wild+2/Wild+4:50分;+6:40分;+10:60分")
+ Bullet("积分保存到排行榜,Top 20 可查看")
+ }
+
+ SpacerH(16)
+
+ // ── AI difficulty ──
+ SectionCard("🤖 AI 难度说明", UnoOrange) {
+ Label("简单")
+ Bullet("随机出牌,30%概率有牌不出选择摸牌")
+ Bullet("适合新手练习")
+ SpacerH(4)
+ Label("普通")
+ Bullet("按优先级出牌:+10 > +6 > +4 > +2 > Skip > 数字")
+ Bullet("标准对局体验")
+ SpacerH(4)
+ Label("困难")
+ Bullet("手牌≤3时惩罚牌优先级翻倍,更狠地压对手")
+ Bullet("高分段训练用")
+ }
+
+ SpacerH(32)
+ }
+ }
+}
+
+@Composable
+private fun SectionCard(title: String, accent: Color, content: @Composable ColumnScope.() -> Unit) {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(16.dp),
+ colors = CardDefaults.cardColors(containerColor = DarkSurface)
+ ) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ Text(title, color = accent, fontSize = 18.sp, fontWeight = FontWeight.Bold)
+ Spacer(modifier = Modifier.height(12.dp))
+ content()
+ }
+ }
+}
+
+@Composable
+private fun Bullet(text: String) {
+ Row(modifier = Modifier.padding(vertical = 2.dp)) {
+ Text("• ", color = Color.White.copy(alpha = 0.4f), fontSize = 14.sp)
+ Text(text, color = Color.White.copy(alpha = 0.85f), fontSize = 13.sp, lineHeight = 19.sp)
+ }
+}
+
+@Composable
+private fun Label(text: String) {
+ Text(text, color = GoldAccent.copy(alpha = 0.75f), fontSize = 13.sp, fontWeight = FontWeight.Medium)
+ Spacer(modifier = Modifier.height(4.dp))
+}
+
+@Composable
+private fun SpacerH(dp: Int) {
+ Spacer(modifier = Modifier.height(dp.dp))
+}
diff --git a/app/src/main/java/com/unogame/ui/screens/ScoreboardScreen.kt b/app/src/main/java/com/unogame/ui/screens/ScoreboardScreen.kt
new file mode 100644
index 0000000..80423e0
--- /dev/null
+++ b/app/src/main/java/com/unogame/ui/screens/ScoreboardScreen.kt
@@ -0,0 +1,142 @@
+package com.unogame.ui.screens
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.unogame.game.GameMode
+import com.unogame.ui.theme.*
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ScoreboardScreen(onBack: () -> Unit) {
+ val context = LocalContext.current
+ val scores = remember { Scoreboard.loadScores(context) }
+ var selectedFilter by remember { mutableStateOf("全部") }
+ val modes = listOf("全部") + GameMode.values().map { it.displayName }
+
+ val filtered = if (selectedFilter == "全部") scores
+ else scores.filter { it.mode == selectedFilter }
+
+ Box(modifier = Modifier.fillMaxSize().background(LocalTableBg.current.color)) {
+ Column(modifier = Modifier.fillMaxSize().padding(24.dp)) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ IconButton(onClick = onBack) {
+ Icon(Icons.Default.ArrowBack, "返回", tint = Color.White)
+ }
+ Text("积分排行榜", fontSize = 24.sp, fontWeight = FontWeight.Bold, color = Color.White)
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Mode filter chips
+ if (scores.isNotEmpty()) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ modes.forEach { mode ->
+ FilterChip(
+ selected = selectedFilter == mode,
+ onClick = { selectedFilter = mode },
+ label = { Text(mode, fontSize = 12.sp) },
+ colors = FilterChipDefaults.filterChipColors(
+ selectedContainerColor = GoldAccent,
+ selectedLabelColor = Color.Black
+ )
+ )
+ }
+ }
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+
+ if (filtered.isEmpty()) {
+ Spacer(modifier = Modifier.height(60.dp))
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Icon(
+ Icons.Default.EmojiEvents,
+ null,
+ tint = Color.Gray,
+ modifier = Modifier.size(64.dp)
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(
+ "暂无记录\n赢一局就会有排名",
+ color = Color.White.copy(alpha = 0.5f),
+ fontSize = 14.sp,
+ textAlign = TextAlign.Center
+ )
+ }
+ } else {
+ LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ itemsIndexed(filtered) { index, entry ->
+ val rankColor = when (index) {
+ 0 -> GoldAccent
+ 1 -> Color(0xFFC0C0C0)
+ 2 -> Color(0xFFCD7F32)
+ else -> Color.White.copy(alpha = 0.5f)
+ }
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(12.dp),
+ colors = CardDefaults.cardColors(containerColor = if (index < 3) DarkSurface else DarkCard)
+ ) {
+ Row(
+ modifier = Modifier.padding(12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // Rank
+ Text(
+ text = "#${index + 1}",
+ color = rankColor,
+ fontSize = 18.sp,
+ fontWeight = FontWeight.Black,
+ modifier = Modifier.width(44.dp)
+ )
+ // Name
+ Column(modifier = Modifier.weight(1f)) {
+ Text(entry.name, color = Color.White, fontWeight = FontWeight.Bold, fontSize = 15.sp)
+ Text(
+ entry.mode,
+ color = Color.White.copy(alpha = 0.5f),
+ fontSize = 12.sp
+ )
+ }
+ // Wins
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(Icons.Default.Star, null, tint = GoldAccent, modifier = Modifier.size(18.dp))
+ Spacer(modifier = Modifier.width(4.dp))
+ Text(
+ "${entry.wins}胜",
+ color = GoldAccent,
+ fontWeight = FontWeight.Bold,
+ fontSize = 16.sp
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/unogame/ui/theme/CardTheme.kt b/app/src/main/java/com/unogame/ui/theme/CardTheme.kt
new file mode 100644
index 0000000..7ff3e36
--- /dev/null
+++ b/app/src/main/java/com/unogame/ui/theme/CardTheme.kt
@@ -0,0 +1,24 @@
+package com.unogame.ui.theme
+
+import android.content.Context
+
+enum class CardTheme(val displayName: String) {
+ CLASSIC("经典"),
+ ELEGANT("优雅"),
+ MIDNIGHT("暗夜");
+
+ companion object {
+ private const val KEY = "card_theme"
+
+ fun load(context: Context): CardTheme {
+ val name = context.getSharedPreferences("unogame_prefs", Context.MODE_PRIVATE)
+ .getString(KEY, ELEGANT.name) ?: ELEGANT.name
+ return try { valueOf(name) } catch (_: Exception) { ELEGANT }
+ }
+
+ fun save(context: Context, theme: CardTheme) {
+ context.getSharedPreferences("unogame_prefs", Context.MODE_PRIVATE)
+ .edit().putString(KEY, theme.name).apply()
+ }
+ }
+}
diff --git a/app/src/main/java/com/unogame/ui/theme/Color.kt b/app/src/main/java/com/unogame/ui/theme/Color.kt
new file mode 100644
index 0000000..6ae133f
--- /dev/null
+++ b/app/src/main/java/com/unogame/ui/theme/Color.kt
@@ -0,0 +1,57 @@
+package com.unogame.ui.theme
+
+import androidx.compose.ui.graphics.Color
+
+// Uno card colors
+val UnoRed = Color(0xFFE53935)
+val UnoBlue = Color(0xFF1E88E5)
+val UnoGreen = Color(0xFF43A047)
+val UnoYellow = Color(0xFFFDD835)
+val UnoWild = Color(0xFF212121)
+
+// Theme colors
+val DarkBackground = Color(0xFF121212)
+val DarkSurface = Color(0xFF1E1E1E)
+val DarkCard = Color(0xFF2D2D2D)
+val GoldAccent = Color(0xFFFFD700)
+
+// Card background colors
+val CardRedBg = Color(0xFFD32F2F)
+val CardBlueBg = Color(0xFF1976D2)
+val CardGreenBg = Color(0xFF388E3C)
+val CardYellowBg = Color(0xFFFBC02D)
+// Flip dark side
+val UnoPink = Color(0xFFE91E63)
+val UnoPurple = Color(0xFF9C27B0)
+val UnoTeal = Color(0xFF009688)
+val UnoOrange = Color(0xFFFF9800)
+val CardPinkBg = Color(0xFFC2185B)
+val CardPurpleBg = Color(0xFF7B1FA2)
+val CardTealBg = Color(0xFF00796B)
+val CardOrangeBg = Color(0xFFF57C00)
+
+fun getCardColor(colorName: String): Color = when (colorName) {
+ "RED" -> UnoRed
+ "BLUE" -> UnoBlue
+ "GREEN" -> UnoGreen
+ "YELLOW" -> UnoYellow
+ "WILD" -> UnoWild
+ "PINK" -> UnoPink
+ "PURPLE" -> UnoPurple
+ "TEAL" -> UnoTeal
+ "ORANGE" -> UnoOrange
+ else -> Color.Gray
+}
+
+fun getCardBgColor(colorName: String): Color = when (colorName) {
+ "RED" -> CardRedBg
+ "BLUE" -> CardBlueBg
+ "GREEN" -> CardGreenBg
+ "YELLOW" -> CardYellowBg
+ "WILD" -> DarkCard
+ "PINK" -> CardPinkBg
+ "PURPLE" -> CardPurpleBg
+ "TEAL" -> CardTealBg
+ "ORANGE" -> CardOrangeBg
+ else -> Color.Gray
+}
diff --git a/app/src/main/java/com/unogame/ui/theme/TableBg.kt b/app/src/main/java/com/unogame/ui/theme/TableBg.kt
new file mode 100644
index 0000000..03203e2
--- /dev/null
+++ b/app/src/main/java/com/unogame/ui/theme/TableBg.kt
@@ -0,0 +1,25 @@
+package com.unogame.ui.theme
+
+import android.content.Context
+import androidx.compose.ui.graphics.Color
+
+enum class TableBg(val displayName: String, val color: Color) {
+ DARK("暗黑", Color(0xFF121212)),
+ GREEN("墨绿", Color(0xFF1A3C2A)),
+ BLUE("深蓝", Color(0xFF0D1B2A));
+
+ companion object {
+ private const val KEY = "table_bg"
+
+ fun load(context: Context): TableBg {
+ val name = context.getSharedPreferences("unogame_prefs", Context.MODE_PRIVATE)
+ .getString(KEY, GREEN.name) ?: GREEN.name
+ return try { valueOf(name) } catch (_: Exception) { GREEN }
+ }
+
+ fun save(context: Context, bg: TableBg) {
+ context.getSharedPreferences("unogame_prefs", Context.MODE_PRIVATE)
+ .edit().putString(KEY, bg.name).apply()
+ }
+ }
+}
diff --git a/app/src/main/java/com/unogame/ui/theme/Theme.kt b/app/src/main/java/com/unogame/ui/theme/Theme.kt
new file mode 100644
index 0000000..9479688
--- /dev/null
+++ b/app/src/main/java/com/unogame/ui/theme/Theme.kt
@@ -0,0 +1,6 @@
+package com.unogame.ui.theme
+
+import androidx.compose.runtime.compositionLocalOf
+
+val LocalCardTheme = compositionLocalOf { CardTheme.ELEGANT }
+val LocalTableBg = compositionLocalOf { TableBg.GREEN }
diff --git a/app/src/main/res/drawable/ic_launcher.xml b/app/src/main/res/drawable/ic_launcher.xml
new file mode 100644
index 0000000..e965e40
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..2c10d59
--- /dev/null
+++ b/app/src/main/res/values/colors.xml
@@ -0,0 +1,7 @@
+
+
+ #FF000000
+ #FFFFFFFF
+ #FF121212
+ #FFFFD700
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..aeb0a4e
--- /dev/null
+++ b/app/src/main/res/values/strings.xml
@@ -0,0 +1,4 @@
+
+
+ UNO卡牌
+
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..0bb1d52
--- /dev/null
+++ b/app/src/main/res/values/themes.xml
@@ -0,0 +1,9 @@
+
+
+
+
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 0000000..3a6ec8d
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,4 @@
+plugins {
+ id("com.android.application") version "8.2.0" apply false
+ id("org.jetbrains.kotlin.android") version "1.9.20" apply false
+}
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..deab996
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,4 @@
+android.useAndroidX=true
+kotlin.code.style=official
+android.nonTransitiveRClass=true
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..a595206
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,5 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 0000000..75ca94f
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1,18 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "UnoGame"
+include(":app")