uno初版

This commit is contained in:
flykhan 2026-04-26 10:40:54 +08:00
parent bd07dc0cdf
commit 1a215a4b8f
40 changed files with 4810 additions and 0 deletions

60
app/build.gradle.kts Normal file
View File

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

12
app/proguard-rules.pro vendored Normal file
View File

@ -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.** { *; }

View File

@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<application
android:allowBackup="false"
android:icon="@drawable/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.UnoGame"
android:usesCleartextTraffic="true"
tools:targetApi="34">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:configChanges="orientation|screenSize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@ -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<GameServer?>(null) }
var gameClient by remember { mutableStateOf<GameClient?>(null) }
var discoveryService by remember { mutableStateOf<DiscoveryService?>(null) }
var isHost by remember { mutableStateOf(false) }
var myPlayerId by remember { mutableStateOf("") }
var myName by remember { mutableStateOf("") }
var errorMessage by remember { mutableStateOf("") }
// Game state
var gameState by remember { mutableStateOf<GameState?>(null) }
var myCards by remember { mutableStateOf<List<Card>>(emptyList()) }
var players by remember { mutableStateOf<List<Player>>(emptyList()) }
var selectedWildColor by remember { mutableStateOf<CardColor?>(null) }
// Discovery state
var discoveredHosts by remember { mutableStateOf<List<DiscoveredHost>>(emptyList()) }
var isDiscovering by remember { mutableStateOf(false) }
var isConnecting by remember { mutableStateOf(false) }
var connectedHost by remember { mutableStateOf("") }
var hostIp by remember { mutableStateOf("") }
// Cleanup function
fun cleanup() {
gameServer?.shutdown()
gameClient?.disconnect()
discoveryService?.shutdown()
gameServer = null
gameClient = null
discoveryService = null
myPlayerId = ""
isHost = false
errorMessage = ""
gameState = null
myCards = emptyList()
players = emptyList()
discoveredHosts = emptyList()
isDiscovering = false
isConnecting = false
connectedHost = ""
hostIp = ""
}
// Listen to client events
LaunchedEffect(gameClient) {
gameClient?.events?.collect { event ->
// Always sync critical state from client (StateFlow events can be lost)
val myId = gameClient?.myPlayerId ?: ""
if (myId.isNotEmpty() && myPlayerId.isEmpty()) {
myPlayerId = myId
players = gameClient?.currentPlayers ?: players
isConnecting = false
connectedHost = "${gameClient?.currentPlayers?.find { it.isHost }?.name ?: "主机"}"
}
when (event?.type) {
"CONNECTED" -> {
myPlayerId = event.playerId
players = event.players
isConnecting = false
connectedHost = "${event.players.find { it.isHost }?.name ?: "主机"}"
}
"PLAYER_JOINED" -> {
players = gameClient?.currentPlayers ?: event.players
}
"PLAYER_LEFT" -> {
players = gameClient?.currentPlayers ?: event.players
}
"GAME_STARTED" -> {
gameState = event.gameState
myCards = event.playerCards
players = event.players
selectedWildColor = null
navController.navigate(Screen.Game.route)
}
"GAME_STATE" -> {
val prevState = gameState
gameState = event.gameState
myCards = event.playerCards
players = event.players
// Navigate to game over if needed
if (event.gameState?.isGameOver == true && prevState?.isGameOver != true) {
val winner = event.gameState.winner
val isYou = winner?.id == myPlayerId
navController.navigate(
Screen.GameOver.createRoute(
winnerName = winner?.name ?: "",
isYouWinner = isYou
)
)
}
}
"ERROR" -> {
errorMessage = event.message
}
"DISCONNECTED" -> {
errorMessage = event.message
navController.navigate(Screen.MainMenu.route) {
popUpTo(0) { inclusive = true }
}
cleanup()
}
}
}
}
CompositionLocalProvider(
LocalCardTheme provides cardTheme,
LocalTableBg provides tableBg
) {
NavHost(
navController = navController,
startDestination = Screen.MainMenu.route,
modifier = Modifier.fillMaxSize()
) {
composable(Screen.MainMenu.route) {
MainMenuScreen(
initialName = savedName,
onNameChanged = { name ->
savedName = name
prefs.edit().putString("player_name", name).apply()
},
onLocalGame = {
navController.navigate(Screen.ModeSelect.route)
},
onScoreboard = {
navController.navigate(Screen.Scoreboard.route)
},
onRules = {
navController.navigate(Screen.Rules.route)
},
currentTheme = cardTheme,
currentBg = tableBg,
onToggleTheme = {
val next = CardTheme.values()[(cardTheme.ordinal + 1) % CardTheme.values().size]
cardTheme = next
CardTheme.save(context, next)
},
onToggleBg = {
val next = TableBg.values()[(tableBg.ordinal + 1) % TableBg.values().size]
tableBg = next
TableBg.save(context, next)
},
onToggleOrientation = {
isLandscape = !isLandscape
prefs.edit().putBoolean("landscape", isLandscape).apply()
(context as? android.app.Activity)?.requestedOrientation =
if (isLandscape) ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
else ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
},
isLandscape = isLandscape,
onHostGame = { name ->
myName = name
isHost = true
errorMessage = ""
hostIp = getLocalIpAddress()
val server = GameServer()
if (server.start()) {
gameServer = server
val discovery = DiscoveryService()
discoveryService = discovery
discovery.startAdvertising(name, 1)
navController.navigate(Screen.Lobby.createRoute(true))
// Host connects as a client too (to localhost) - on IO thread
scope.launch {
val client = GameClient()
if (client.connect("127.0.0.1", name)) {
gameClient = client
}
}
} else {
errorMessage = "无法创建房间,请检查网络"
}
},
onJoinGame = { name ->
myName = name
isHost = false
val discovery = DiscoveryService()
discoveryService = discovery
discovery.startDiscovery(name)
isDiscovering = true
scope.launch {
discovery.hosts.collect { hosts ->
discoveredHosts = hosts
isDiscovering = hosts.isEmpty()
}
}
navController.navigate(Screen.Lobby.createRoute(false))
}
)
}
composable(
route = Screen.Lobby.route,
arguments = listOf(navArgument("isHost") { type = NavType.BoolType })
) { backStackEntry ->
val hostFlag = backStackEntry.arguments?.getBoolean("isHost") ?: false
LaunchedEffect(Unit) {
if (hostFlag && players.size < 2) {
errorMessage = ""
}
errorMessage = ""
}
LobbyScreen(
isHost = hostFlag,
hostIp = hostIp,
players = players,
discoveredHosts = discoveredHosts,
isDiscovering = isDiscovering,
isConnecting = isConnecting,
connectedHost = connectedHost,
errorMessage = errorMessage,
onStartGame = {
scope.launch { gameClient?.startGame() }
},
onJoinHost = { host ->
isConnecting = true
errorMessage = ""
scope.launch {
try {
val client = GameClient()
if (client.connect(host.address, myName)) {
gameClient = client
discoveryService?.stopDiscovery()
} else {
errorMessage = "无法连接到 ${host.name} (${host.address}): ${client.lastError}"
}
} catch (e: Exception) {
android.util.Log.e("UnoApp", "加入房间崩溃", e)
errorMessage = "连接出错: ${e.message}"
}
isConnecting = false
}
},
onRefreshDiscovery = {
discoveredHosts = emptyList()
isDiscovering = true
discoveryService?.stopDiscovery()
discoveryService = DiscoveryService()
discoveryService?.startDiscovery(myName)
scope.launch {
discoveryService?.hosts?.collect { hosts ->
discoveredHosts = hosts
isDiscovering = hosts.isEmpty()
}
}
},
onManualConnect = { ip ->
isConnecting = true
errorMessage = ""
scope.launch {
try {
val client = GameClient()
if (client.connect(ip, myName)) {
gameClient = client
discoveryService?.stopDiscovery()
} else {
errorMessage = "无法连接到 $ip: ${client.lastError}"
}
} catch (e: Exception) {
android.util.Log.e("UnoApp", "手动连接崩溃", e)
errorMessage = "连接出错: ${e.message}"
}
isConnecting = false
}
},
onBack = {
cleanup()
navController.popBackStack()
}
)
}
composable(Screen.ModeSelect.route) {
ModeSelectScreen(
playerName = savedName,
onStartGame = { mode ->
navController.navigate(Screen.LocalSetup.createRoute(mode.name))
},
onBack = { navController.popBackStack() }
)
}
composable(
route = Screen.LocalSetup.route,
arguments = listOf(navArgument("modeName") { type = NavType.StringType })
) { backStackEntry ->
val modeName = backStackEntry.arguments?.getString("modeName") ?: "NORMAL"
val mode = try { GameMode.valueOf(modeName) } catch (_: Exception) { GameMode.NORMAL }
LocalSetupScreen(
playerName = savedName,
modeDisplayName = mode.displayName,
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 }
}
}
)
}
}
}
}

View File

@ -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<Card>()
private val discardPile = mutableListOf<Card>()
private fun buildDeck(): List<Card> = when (rules.mode) {
GameMode.NORMAL, GameMode.SEVEN_ZERO -> buildNormalDeck()
GameMode.FLIP -> buildFlipDeck()
GameMode.NO_MERCY -> buildNoMercyDeck()
}
private fun buildNormalDeck(): List<Card> = 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<Card> {
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<Card> = 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<Player>): 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<Card>) {
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<Card> = 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<Card>()
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
))
}
}

View File

@ -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<CardColor>,
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
)
}
}
}

View File

@ -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<Card>, 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<Card>, flipped: Boolean = false): CardColor = pickBestColor(hand, flipped)
}

View File

@ -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
}
}

View File

@ -0,0 +1,32 @@
package com.unogame.model
data class GameState(
val players: List<Player> = emptyList(),
val currentPlayerIndex: Int = 0,
val direction: Int = 1,
val discardPile: List<Card> = 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
}
}

View File

@ -0,0 +1,12 @@
package com.unogame.model
data class Player(
val id: String,
val name: String,
val cards: List<Card> = emptyList(),
val isHost: Boolean = false,
val isCurrentTurn: Boolean = false,
val isConnected: Boolean = true,
val calledUno: Boolean = false,
val cardCount: Int = 0
)

View File

@ -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<List<DiscoveredHost>>(emptyList())
val hosts: StateFlow<List<DiscoveredHost>> = _hosts
private val seenHosts = mutableSetOf<String>()
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<InetAddress> {
val list = mutableListOf<InetAddress>()
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<String> {
val set = mutableSetOf<String>()
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
}
}

View File

@ -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<Player> = emptyList(),
val playerCards: List<Card> = 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<ClientEvent?>(null)
val events: StateFlow<ClientEvent?> = _events
var myPlayerId: String = ""
private set
var myCards: List<Card> = emptyList()
private set
var currentPlayers: List<Player> = 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) {}
}
}

View File

@ -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<Player> = emptyList(),
val winner: Player? = null
)
class GameServer {
private var serverSocket: ServerSocket? = null
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val clients = ConcurrentHashMap<String, ClientHandler>()
private val engine = GameEngine()
private val waitingPlayers = mutableListOf<Player>()
private var gameState: GameState? = null
private var isGameRunning = false
private var hostId: String = ""
private val _events = MutableStateFlow<ServerEvent?>(null)
val events: MutableStateFlow<ServerEvent?> = _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<Player> = 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
)
}

View File

@ -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<PlayerData>? = null,
val player: PlayerData? = null,
val state: StateData? = null,
val message: String? = null,
val address: String? = null,
val cards: List<CardData>? = 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<PlayerData>,
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
)
}

View File

@ -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)
}
}
}
}

View File

@ -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
)
}
}
}
}
}
}
}

View File

@ -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
)
}
}
}

View File

@ -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<Card>,
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)
)
}
}
}
}

View File

@ -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"
}
}

View File

@ -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)
}
}
}
}

View File

@ -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<Card>,
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
}
)
}
}

View File

@ -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<Player>,
discoveredHosts: List<DiscoveredHost>,
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)
}
}
}
}
}

View File

@ -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<ScoreEntry> {
val json = context.getSharedPreferences("unogame_prefs", Context.MODE_PRIVATE)
.getString(PREFS_KEY, null) ?: return emptyList()
return try {
gson.fromJson(json, object : TypeToken<List<ScoreEntry>>() {}.type) ?: emptyList()
} catch (_: Exception) { emptyList() }
}
fun saveScores(context: Context, scores: List<ScoreEntry>) {
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<Player>()
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<CardColor?>(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
}
}
)
}
}

View File

@ -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
)
}
}
}
}

View File

@ -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
)
}
}
}
}
}

View File

@ -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)
}
}
}
}

View File

@ -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("💀 三、无情UNONo 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/+520分")
Bullet("Wild/Wild+2/Wild+450分+640分+1060分")
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))
}

View File

@ -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
)
}
}
}
}
}
}
}
}
}

View File

@ -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()
}
}
}

View File

@ -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
}

View File

@ -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()
}
}
}

View File

@ -0,0 +1,6 @@
package com.unogame.ui.theme
import androidx.compose.runtime.compositionLocalOf
val LocalCardTheme = compositionLocalOf { CardTheme.ELEGANT }
val LocalTableBg = compositionLocalOf { TableBg.GREEN }

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#1E1E1E"
android:pathData="M0,0h108v108H0z" />
<path
android:fillColor="#FFD700"
android:pathData="M54,24 L84,34 L84,74 L54,84 L24,74 L24,34Z" />
<path
android:fillColor="#E53935"
android:pathData="M42,44 m-16,0 a16,16 0,1 1,32 0 a16,16 0,1 1,-32 0" />
<path
android:fillColor="#FFFFFF"
android:pathData="M54,32 L62,48 L54,64 L46,48Z" />
<path
android:fillColor="#FFD700"
android:pathData="M50,60 L54,68 L58,60Z" />
</vector>

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
<color name="dark_background">#FF121212</color>
<color name="gold_accent">#FFFFD700</color>
</resources>

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">UNO卡牌</string>
</resources>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.UnoGame" parent="android:Theme.Material.NoActionBar">
<item name="android:windowBackground">@color/dark_background</item>
<item name="android:statusBarColor">@color/dark_background</item>
<item name="android:navigationBarColor">@color/dark_background</item>
<item name="android:windowLightStatusBar">false</item>
</style>
</resources>

4
build.gradle.kts Normal file
View File

@ -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
}

4
gradle.properties Normal file
View File

@ -0,0 +1,4 @@
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8

View File

@ -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

18
settings.gradle.kts Normal file
View File

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