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")