uno初版
This commit is contained in:
parent
bd07dc0cdf
commit
1a215a4b8f
60
app/build.gradle.kts
Normal file
60
app/build.gradle.kts
Normal 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
12
app/proguard-rules.pro
vendored
Normal 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.** { *; }
|
||||||
30
app/src/main/AndroidManifest.xml
Normal file
30
app/src/main/AndroidManifest.xml
Normal 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>
|
||||||
491
app/src/main/java/com/unogame/MainActivity.kt
Normal file
491
app/src/main/java/com/unogame/MainActivity.kt
Normal 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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
531
app/src/main/java/com/unogame/game/GameEngine.kt
Normal file
531
app/src/main/java/com/unogame/game/GameEngine.kt
Normal 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
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
56
app/src/main/java/com/unogame/game/GameRules.kt
Normal file
56
app/src/main/java/com/unogame/game/GameRules.kt
Normal 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
121
app/src/main/java/com/unogame/game/SimpleAI.kt
Normal file
121
app/src/main/java/com/unogame/game/SimpleAI.kt
Normal 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)
|
||||||
|
}
|
||||||
82
app/src/main/java/com/unogame/model/Card.kt
Normal file
82
app/src/main/java/com/unogame/model/Card.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
32
app/src/main/java/com/unogame/model/GameState.kt
Normal file
32
app/src/main/java/com/unogame/model/GameState.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
12
app/src/main/java/com/unogame/model/Player.kt
Normal file
12
app/src/main/java/com/unogame/model/Player.kt
Normal 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
|
||||||
|
)
|
||||||
195
app/src/main/java/com/unogame/network/DiscoveryService.kt
Normal file
195
app/src/main/java/com/unogame/network/DiscoveryService.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
210
app/src/main/java/com/unogame/network/GameClient.kt
Normal file
210
app/src/main/java/com/unogame/network/GameClient.kt
Normal 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) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
265
app/src/main/java/com/unogame/network/GameServer.kt
Normal file
265
app/src/main/java/com/unogame/network/GameServer.kt
Normal 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
|
||||||
|
)
|
||||||
|
}
|
||||||
127
app/src/main/java/com/unogame/network/Protocol.kt
Normal file
127
app/src/main/java/com/unogame/network/Protocol.kt
Normal 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
|
||||||
|
)
|
||||||
|
}
|
||||||
293
app/src/main/java/com/unogame/ui/components/CardView.kt
Normal file
293
app/src/main/java/com/unogame/ui/components/CardView.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
78
app/src/main/java/com/unogame/ui/components/PlayerAvatar.kt
Normal file
78
app/src/main/java/com/unogame/ui/components/PlayerAvatar.kt
Normal 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
54
app/src/main/java/com/unogame/ui/components/PlayerHand.kt
Normal file
54
app/src/main/java/com/unogame/ui/components/PlayerHand.kt
Normal 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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
23
app/src/main/java/com/unogame/ui/navigation/Screen.kt
Normal file
23
app/src/main/java/com/unogame/ui/navigation/Screen.kt
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
83
app/src/main/java/com/unogame/ui/screens/GameOverScreen.kt
Normal file
83
app/src/main/java/com/unogame/ui/screens/GameOverScreen.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
312
app/src/main/java/com/unogame/ui/screens/GameScreen.kt
Normal file
312
app/src/main/java/com/unogame/ui/screens/GameScreen.kt
Normal 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
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
323
app/src/main/java/com/unogame/ui/screens/LobbyScreen.kt
Normal file
323
app/src/main/java/com/unogame/ui/screens/LobbyScreen.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
176
app/src/main/java/com/unogame/ui/screens/LocalGameScreen.kt
Normal file
176
app/src/main/java/com/unogame/ui/screens/LocalGameScreen.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
254
app/src/main/java/com/unogame/ui/screens/LocalSetupScreen.kt
Normal file
254
app/src/main/java/com/unogame/ui/screens/LocalSetupScreen.kt
Normal 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
249
app/src/main/java/com/unogame/ui/screens/MainMenuScreen.kt
Normal file
249
app/src/main/java/com/unogame/ui/screens/MainMenuScreen.kt
Normal 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
123
app/src/main/java/com/unogame/ui/screens/ModeSelectScreen.kt
Normal file
123
app/src/main/java/com/unogame/ui/screens/ModeSelectScreen.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
214
app/src/main/java/com/unogame/ui/screens/RulesHelpScreen.kt
Normal file
214
app/src/main/java/com/unogame/ui/screens/RulesHelpScreen.kt
Normal 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("💀 三、无情UNO(No Mercy)", UnoRed) {
|
||||||
|
Label("牌组构成(112张)")
|
||||||
|
Bullet("标准牌:数字0-9、Skip、Reverse、+2(各色×2)、Wild×4、Wild+4×4")
|
||||||
|
Bullet("新增:+1×8、+3×4、+6×2、+10×1(本版简化为+6×2、+10×1、DiscardColor×1)")
|
||||||
|
Bullet("DiscardColor:弃掉手中所有同颜色牌")
|
||||||
|
Bullet("Wild+4Reverse:+4并反转方向")
|
||||||
|
SpacerH(8)
|
||||||
|
Label("核心规则变化")
|
||||||
|
Bullet("手牌上限10张:满10张时跳过一切摸牌惩罚")
|
||||||
|
Bullet("惩罚牌可叠加:同颜色Draw牌可以累加(需颜色匹配或出万能)")
|
||||||
|
Bullet("例:红+2 → 红+6 → 红+10 → 万能+4 → 下家摸22张")
|
||||||
|
Bullet("普通数字牌不能用来截停叠加惩罚")
|
||||||
|
SpacerH(8)
|
||||||
|
Label("惩罚叠加规则")
|
||||||
|
Bullet("只能叠同颜色的Draw牌或万能牌")
|
||||||
|
Bullet("若无法叠加,必须一次性摸走全部累计张数")
|
||||||
|
Bullet("Wild+4可以叠加到任何颜色的Draw牌上")
|
||||||
|
}
|
||||||
|
|
||||||
|
SpacerH(16)
|
||||||
|
|
||||||
|
// ══════════════ 7-0 ══════════════
|
||||||
|
SectionCard("🔄 四、7-0 规则(经典村规)", UnoBlue) {
|
||||||
|
Bullet("叠加于标准UNO之上,牌组同标准(108张)")
|
||||||
|
SpacerH(8)
|
||||||
|
Label("出 7:交换手牌")
|
||||||
|
Bullet("打出数字7后,与下家交换全部手牌")
|
||||||
|
Bullet("交换后若手牌变为0张,立即获胜")
|
||||||
|
Bullet("AI自动选择最优策略")
|
||||||
|
SpacerH(8)
|
||||||
|
Label("出 0:全体传牌")
|
||||||
|
Bullet("打出数字0后,全体玩家将手牌传给下家(按当前方向)")
|
||||||
|
Bullet("传递同时进行,每人手牌数量不变但有变化")
|
||||||
|
SpacerH(8)
|
||||||
|
Label("注意事项")
|
||||||
|
Bullet("7和0不能叠加到惩罚牌上")
|
||||||
|
Bullet("交换/传牌后仍需按规定喊UNO")
|
||||||
|
}
|
||||||
|
|
||||||
|
SpacerH(16)
|
||||||
|
|
||||||
|
// ══════════════ Common ══════════════
|
||||||
|
SectionCard("📐 通用规则", Color.White.copy(alpha = 0.7f)) {
|
||||||
|
Label("UNO叫牌与抓人")
|
||||||
|
Bullet("出牌后手牌剩1张时,红色\"喊UNO!\"按钮出现,必须点击")
|
||||||
|
Bullet("未喊且被其他玩家\"抓XX!\",罚抽2张")
|
||||||
|
Bullet("若已被抓过或手牌已满10张(无情模式),免罚")
|
||||||
|
SpacerH(8)
|
||||||
|
Label("游戏结束与计分")
|
||||||
|
Bullet("先出完手牌者获胜,单局结束")
|
||||||
|
Bullet("胜者计分 = 其他玩家手牌分数之和")
|
||||||
|
Bullet("数字牌:面值分;Skip/Reverse/+2/+1/+3/+5:20分")
|
||||||
|
Bullet("Wild/Wild+2/Wild+4:50分;+6:40分;+10:60分")
|
||||||
|
Bullet("积分保存到排行榜,Top 20 可查看")
|
||||||
|
}
|
||||||
|
|
||||||
|
SpacerH(16)
|
||||||
|
|
||||||
|
// ── AI difficulty ──
|
||||||
|
SectionCard("🤖 AI 难度说明", UnoOrange) {
|
||||||
|
Label("简单")
|
||||||
|
Bullet("随机出牌,30%概率有牌不出选择摸牌")
|
||||||
|
Bullet("适合新手练习")
|
||||||
|
SpacerH(4)
|
||||||
|
Label("普通")
|
||||||
|
Bullet("按优先级出牌:+10 > +6 > +4 > +2 > Skip > 数字")
|
||||||
|
Bullet("标准对局体验")
|
||||||
|
SpacerH(4)
|
||||||
|
Label("困难")
|
||||||
|
Bullet("手牌≤3时惩罚牌优先级翻倍,更狠地压对手")
|
||||||
|
Bullet("高分段训练用")
|
||||||
|
}
|
||||||
|
|
||||||
|
SpacerH(32)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SectionCard(title: String, accent: Color, content: @Composable ColumnScope.() -> Unit) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = DarkSurface)
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
|
Text(title, color = accent, fontSize = 18.sp, fontWeight = FontWeight.Bold)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Bullet(text: String) {
|
||||||
|
Row(modifier = Modifier.padding(vertical = 2.dp)) {
|
||||||
|
Text("• ", color = Color.White.copy(alpha = 0.4f), fontSize = 14.sp)
|
||||||
|
Text(text, color = Color.White.copy(alpha = 0.85f), fontSize = 13.sp, lineHeight = 19.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Label(text: String) {
|
||||||
|
Text(text, color = GoldAccent.copy(alpha = 0.75f), fontSize = 13.sp, fontWeight = FontWeight.Medium)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SpacerH(dp: Int) {
|
||||||
|
Spacer(modifier = Modifier.height(dp.dp))
|
||||||
|
}
|
||||||
142
app/src/main/java/com/unogame/ui/screens/ScoreboardScreen.kt
Normal file
142
app/src/main/java/com/unogame/ui/screens/ScoreboardScreen.kt
Normal 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
24
app/src/main/java/com/unogame/ui/theme/CardTheme.kt
Normal file
24
app/src/main/java/com/unogame/ui/theme/CardTheme.kt
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
57
app/src/main/java/com/unogame/ui/theme/Color.kt
Normal file
57
app/src/main/java/com/unogame/ui/theme/Color.kt
Normal 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
|
||||||
|
}
|
||||||
25
app/src/main/java/com/unogame/ui/theme/TableBg.kt
Normal file
25
app/src/main/java/com/unogame/ui/theme/TableBg.kt
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
6
app/src/main/java/com/unogame/ui/theme/Theme.kt
Normal file
6
app/src/main/java/com/unogame/ui/theme/Theme.kt
Normal 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 }
|
||||||
22
app/src/main/res/drawable/ic_launcher.xml
Normal file
22
app/src/main/res/drawable/ic_launcher.xml
Normal 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>
|
||||||
7
app/src/main/res/values/colors.xml
Normal file
7
app/src/main/res/values/colors.xml
Normal 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>
|
||||||
4
app/src/main/res/values/strings.xml
Normal file
4
app/src/main/res/values/strings.xml
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">UNO卡牌</string>
|
||||||
|
</resources>
|
||||||
9
app/src/main/res/values/themes.xml
Normal file
9
app/src/main/res/values/themes.xml
Normal 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
4
build.gradle.kts
Normal 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
4
gradle.properties
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
android.useAndroidX=true
|
||||||
|
kotlin.code.style=official
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
5
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
5
gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
18
settings.gradle.kts
Normal 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")
|
||||||
Loading…
x
Reference in New Issue
Block a user