diff --git a/app/src/main/java/com/unogame/MainActivity.kt b/app/src/main/java/com/unogame/MainActivity.kt index d1d39ea..2ca722e 100644 --- a/app/src/main/java/com/unogame/MainActivity.kt +++ b/app/src/main/java/com/unogame/MainActivity.kt @@ -377,6 +377,7 @@ fun UnoApp() { LocalSetupScreen( playerName = savedName, modeDisplayName = mode.displayName, + mode = mode, onStartGame = { totalPlayers, name -> if (name.isNotEmpty()) { savedName = name diff --git a/app/src/main/java/com/unogame/game/GameEngine.kt b/app/src/main/java/com/unogame/game/GameEngine.kt index 1f7b2bb..0b4b9ed 100644 --- a/app/src/main/java/com/unogame/game/GameEngine.kt +++ b/app/src/main/java/com/unogame/game/GameEngine.kt @@ -122,7 +122,10 @@ class GameEngine(private val rules: GameRules = GameRules.forMode(GameMode.NORMA } sealed class PlayResult { - data class Success(val state: GameState) : PlayResult() + data class Success( + val state: GameState, + val drawnCardPlayableIndex: Int = -1 + ) : PlayResult() data class Error(val message: String) : PlayResult() } @@ -301,15 +304,13 @@ class GameEngine(private val rules: GameRules = GameRules.forMode(GameMode.NORMA 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 - } + val nextIdx = state.nextPlayerIndex() + 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 @@ -355,7 +356,7 @@ class GameEngine(private val rules: GameRules = GameRules.forMode(GameMode.NORMA } val calledUno = newCards.size == 1 - val basePlayers = if (activeCard.type == CardType.SKIP_ALL) modifiedPlayers else state.players + val basePlayers = if (activeCard.type == CardType.SKIP_ALL || sevenZeroDone) 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) @@ -387,11 +388,12 @@ class GameEngine(private val rules: GameRules = GameRules.forMode(GameMode.NORMA 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) + // Hand size limit: cap draw to not exceed maxHandSize + val handLimit = rules.maxHandSize + if (handLimit > 0) { + val remaining = (handLimit - player.cards.size).coerceAtLeast(0) if (remaining == 0) { - val message = "${player.name} 手牌已满10张,跳过摸牌" + val message = "${player.name} 手牌已满${handLimit}张,跳过摸牌" val nextIndex = state.nextPlayerIndex() val updatedPlayers = state.players.mapIndexed { i, p -> when (i) { @@ -432,7 +434,7 @@ class GameEngine(private val rules: GameRules = GameRules.forMode(GameMode.NORMA val message = if (state.pendingDrawCount > 0) { if (actualDraw < drawAmount && actualDraw < state.pendingDrawCount) - "${player.name} 摸了 ${drawnCards.size} 张惩罚牌(已达10张上限)" + "${player.name} 摸了 ${drawnCards.size} 张惩罚牌(已达${handLimit}张上限)" else if (actualDraw < drawAmount) "${player.name} 摸了 ${drawnCards.size} 张惩罚牌(牌不够,需${drawAmount}张)" else @@ -441,13 +443,60 @@ class GameEngine(private val rules: GameRules = GameRules.forMode(GameMode.NORMA "${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) + // For voluntary draws, check if the drawn card can be played + var drawnCardPlayableIndex = -1 + val wasVoluntary = state.pendingDrawCount == 0 + val topCard = discardPile.lastOrNull() + if (wasVoluntary && drawnCards.isNotEmpty() && topCard != null) { + val wildColor = state.currentWildColor + val flipped = state.flipped + val lastDrawn = drawnCards.last() + if (lastDrawn.matches(topCard, wildColor, flipped)) { + drawnCardPlayableIndex = newCards.size - 1 } } + val updatedPlayers = state.players.mapIndexed { i, p -> + when (i) { + playerIndex -> p.copy( + cards = newCards, + isCurrentTurn = drawnCardPlayableIndex >= 0, + cardCount = newCards.size + ) + else -> if (!wasVoluntary || drawnCardPlayableIndex < 0) { + if (i == nextIndex) p.copy(isCurrentTurn = true) else p.copy(isCurrentTurn = false) + } else { + p.copy(isCurrentTurn = false) + } + } + } + + val finalNextIndex = if (drawnCardPlayableIndex >= 0) playerIndex else nextIndex + + return PlayResult.Success( + state = state.copy( + players = updatedPlayers, + currentPlayerIndex = finalNextIndex, + discardPile = discardPile.toList(), + drawPileCount = drawPile.size, + currentWildColor = state.currentWildColor, + pendingDrawCount = if (drawnCardPlayableIndex >= 0) state.pendingDrawCount else 0, + flipped = state.flipped, + turnNumber = state.turnNumber + 1, + message = message + ), + drawnCardPlayableIndex = drawnCardPlayableIndex + ) + } + + fun skipAfterDraw(state: GameState): PlayResult { + val nextIndex = state.nextPlayerIndex() + val updatedPlayers = state.players.mapIndexed { i, p -> + when (i) { + state.currentPlayerIndex -> p.copy(isCurrentTurn = false) + else -> if (i == nextIndex) p.copy(isCurrentTurn = true) else p.copy(isCurrentTurn = false) + } + } return PlayResult.Success( state.copy( players = updatedPlayers, @@ -458,7 +507,7 @@ class GameEngine(private val rules: GameRules = GameRules.forMode(GameMode.NORMA pendingDrawCount = 0, flipped = state.flipped, turnNumber = state.turnNumber + 1, - message = message + message = "${state.currentPlayer.name} 选择不出牌" ) ) } @@ -491,9 +540,9 @@ class GameEngine(private val rules: GameRules = GameRules.forMode(GameMode.NORMA 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) + val handLimit = rules.maxHandSize + if (handLimit > 0) { + val remaining = (handLimit - target.cards.size).coerceAtLeast(0) if (remaining == 0) { val updatedPlayers = state.players.mapIndexed { i, p -> if (i == targetIdx) p.copy(calledUno = true) else p @@ -502,7 +551,7 @@ class GameEngine(private val rules: GameRules = GameRules.forMode(GameMode.NORMA players = updatedPlayers, discardPile = discardPile.toList(), drawPileCount = drawPile.size, - message = "${state.players[challengerIdx].name} 抓住了 ${target.name} 没喊UNO!(手牌已满10张,免罚)", + message = "${state.players[challengerIdx].name} 抓住了 ${target.name} 没喊UNO!(手牌已满${handLimit}张,免罚)", turnNumber = state.turnNumber + 1 )) } diff --git a/app/src/main/java/com/unogame/game/GameRules.kt b/app/src/main/java/com/unogame/game/GameRules.kt index 7786dea..9a03ff2 100644 --- a/app/src/main/java/com/unogame/game/GameRules.kt +++ b/app/src/main/java/com/unogame/game/GameRules.kt @@ -1,5 +1,6 @@ package com.unogame.game +import android.content.Context import com.unogame.model.* enum class GameMode(val displayName: String) { @@ -15,9 +16,12 @@ data class GameRules( val allowStacking: Boolean, val colors: List, val usesFlip: Boolean, - val usesNoMercyCards: Boolean + val usesNoMercyCards: Boolean, + val maxHandSize: Int = 0 // 0 = no limit ) { companion object { + private const val PREF_KEY_PREFIX = "handsize_" + fun forMode(mode: GameMode): GameRules = when (mode) { GameMode.NORMAL -> GameRules( mode = mode, @@ -52,5 +56,24 @@ data class GameRules( usesNoMercyCards = false ) } + + fun forMode(mode: GameMode, maxHandSize: Int): GameRules { + return forMode(mode).copy(maxHandSize = maxHandSize) + } + + fun loadMaxHandSize(context: Context, mode: GameMode): Int { + return context.getSharedPreferences("unogame_prefs", Context.MODE_PRIVATE) + .getInt(PREF_KEY_PREFIX + mode.name, defaultMaxHandSize(mode)) + } + + fun saveMaxHandSize(context: Context, mode: GameMode, size: Int) { + context.getSharedPreferences("unogame_prefs", Context.MODE_PRIVATE) + .edit().putInt(PREF_KEY_PREFIX + mode.name, size).apply() + } + + fun defaultMaxHandSize(mode: GameMode): Int = when (mode) { + GameMode.NO_MERCY -> 10 + else -> 0 // no limit for other modes + } } } diff --git a/app/src/main/java/com/unogame/model/Card.kt b/app/src/main/java/com/unogame/model/Card.kt index 3beb4a7..eba0203 100644 --- a/app/src/main/java/com/unogame/model/Card.kt +++ b/app/src/main/java/com/unogame/model/Card.kt @@ -56,6 +56,7 @@ data class Card( if (c.color == CardColor.WILD) return true if (o.color == CardColor.WILD) return currentWildColor == null || c.color == currentWildColor + if (c.type == CardType.FLIP || o.type == CardType.FLIP) return true if (c.color == o.color) return true if (c.type == CardType.NUMBER && o.type == CardType.NUMBER && c.number == o.number) return true diff --git a/app/src/main/java/com/unogame/ui/screens/GameScreen.kt b/app/src/main/java/com/unogame/ui/screens/GameScreen.kt index 7fd476c..cebefb2 100644 --- a/app/src/main/java/com/unogame/ui/screens/GameScreen.kt +++ b/app/src/main/java/com/unogame/ui/screens/GameScreen.kt @@ -33,6 +33,7 @@ fun GameScreen( onChooseColor: (CardColor) -> Unit, onCallUno: () -> Unit = {}, onChallengeUno: (String) -> Unit = {}, + onShowLog: () -> Unit = {}, errorMessage: String ) { val scrollState = rememberScrollState() @@ -62,17 +63,29 @@ fun GameScreen( Card( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp), + .padding(horizontal = 16.dp) + .clickable { onShowLog() }, 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) - ) + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = gameState.message, + color = GoldAccent, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier.weight(1f) + ) + Icon( + Icons.Default.List, + null, + tint = Color.White.copy(alpha = 0.4f), + modifier = Modifier.size(16.dp) + ) + } } } } diff --git a/app/src/main/java/com/unogame/ui/screens/LocalGameScreen.kt b/app/src/main/java/com/unogame/ui/screens/LocalGameScreen.kt index eba9b27..aa803c4 100644 --- a/app/src/main/java/com/unogame/ui/screens/LocalGameScreen.kt +++ b/app/src/main/java/com/unogame/ui/screens/LocalGameScreen.kt @@ -1,8 +1,22 @@ package com.unogame.ui.screens import android.content.Context +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.rememberScrollState +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.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import com.google.gson.Gson import com.google.gson.reflect.TypeToken import com.unogame.game.AIDifficulty @@ -11,6 +25,7 @@ import com.unogame.game.GameMode import com.unogame.game.GameRules import com.unogame.game.SimpleAI import com.unogame.model.* +import com.unogame.ui.theme.* import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -78,7 +93,7 @@ fun LocalGameScreen( ) { val scope = rememberCoroutineScope() val context = LocalContext.current - val rules = remember { GameRules.forMode(mode) } + val rules = remember { GameRules.forMode(mode, GameRules.loadMaxHandSize(context, mode)) } val engine = remember { GameEngine(rules) } val aiDiff = remember { AIDifficulty.load(context) } val gameStartTime = remember { System.currentTimeMillis() } @@ -104,6 +119,11 @@ fun LocalGameScreen( var gameDuration by remember { mutableIntStateOf(0) } var gameDifficulty by remember { mutableStateOf("") } var gameTurnNumber by remember { mutableIntStateOf(0) } + var showDrawPlayPopup by remember { mutableStateOf(false) } + var pendingDrawState by remember { mutableStateOf(null) } + var pendingDrawnCardIndex by remember { mutableIntStateOf(-1) } + var gameLog by remember { mutableStateOf(listOf()) } + var showLogDialog by remember { mutableStateOf(false) } val myPlayerId = "human" val currentPlayer = gameState.currentPlayer @@ -113,6 +133,9 @@ fun LocalGameScreen( gameState = state myCards = state.players.find { it.id == myPlayerId }?.cards ?: myCards errorMessage = "" + if (state.message.isNotEmpty()) { + gameLog = gameLog + state.message + } if (state.isGameOver) { isGameOver = true winnerName = state.winner?.name ?: "" @@ -153,7 +176,15 @@ fun LocalGameScreen( fun executeDraw() { val result = engine.drawCard(gameState, myPlayerId) when (result) { - is GameEngine.PlayResult.Success -> updateState(result.state) + is GameEngine.PlayResult.Success -> { + if (result.drawnCardPlayableIndex >= 0) { + pendingDrawState = result.state + pendingDrawnCardIndex = result.drawnCardPlayableIndex + showDrawPlayPopup = true + } else { + updateState(result.state) + } + } is GameEngine.PlayResult.Error -> errorMessage = result.message } } @@ -177,7 +208,19 @@ fun LocalGameScreen( } SimpleAI.MoveType.DRAW -> { val drawResult = engine.drawCard(gameState, current.id) - if (drawResult is GameEngine.PlayResult.Success) updateState(drawResult.state) + if (drawResult is GameEngine.PlayResult.Success) { + if (drawResult.drawnCardPlayableIndex >= 0) { + val playResult = engine.playCard(drawResult.state, current.id, drawResult.drawnCardPlayableIndex) + if (playResult is GameEngine.PlayResult.Success) { + updateState(playResult.state) + } else { + val skipResult = engine.skipAfterDraw(drawResult.state) + if (skipResult is GameEngine.PlayResult.Success) updateState(skipResult.state) + } + } else { + updateState(drawResult.state) + } + } } } } @@ -188,6 +231,83 @@ fun LocalGameScreen( myCards.map { it.activeCard(gameState.flipped) } } + // Draw-then-play popup + if (showDrawPlayPopup && pendingDrawState != null) { + AlertDialog( + onDismissRequest = { + val state = pendingDrawState!! + val result = engine.skipAfterDraw(state) + if (result is GameEngine.PlayResult.Success) updateState(result.state) + showDrawPlayPopup = false + }, + title = { Text("摸到了可出的牌", color = GoldAccent) }, + text = { Text("刚摸到的牌可以打出去,要出这张牌吗?", color = Color.White.copy(alpha = 0.8f)) }, + confirmButton = { + TextButton(onClick = { + val state = pendingDrawState!! + val cardIdx = pendingDrawnCardIndex + showDrawPlayPopup = false + pendingDrawState = null + if (cardIdx >= 0) { + val result = engine.playCard(state, myPlayerId, cardIdx) + when (result) { + is GameEngine.PlayResult.Success -> updateState(result.state) + is GameEngine.PlayResult.Error -> errorMessage = result.message + } + } + }) { Text("出牌", color = GoldAccent) } + }, + dismissButton = { + TextButton(onClick = { + val state = pendingDrawState!! + val result = engine.skipAfterDraw(state) + if (result is GameEngine.PlayResult.Success) updateState(result.state) + showDrawPlayPopup = false + }) { Text("跳过", color = Color.White.copy(alpha = 0.5f)) } + }, + containerColor = DarkSurface + ) + } + + // Game log dialog + if (showLogDialog) { + AlertDialog( + onDismissRequest = { showLogDialog = false }, + title = { Text("出牌记录", color = GoldAccent, fontWeight = FontWeight.Bold) }, + text = { + Column( + modifier = Modifier + .heightIn(max = 400.dp) + .verticalScroll(rememberScrollState()) + ) { + if (gameLog.isEmpty()) { + Text("暂无记录", color = Color.White.copy(alpha = 0.5f)) + } + gameLog.forEachIndexed { index, msg -> + Row(modifier = Modifier.padding(vertical = 2.dp)) { + Text( + "${index + 1}. ", + color = Color.White.copy(alpha = 0.3f), + fontSize = 12.sp + ) + Text( + msg, + color = Color.White.copy(alpha = 0.8f), + fontSize = 12.sp + ) + } + } + } + }, + confirmButton = { + TextButton(onClick = { showLogDialog = false }) { + Text("关闭", color = GoldAccent) + } + }, + containerColor = DarkSurface + ) + } + if (isGameOver) { GameOverScreen( winnerName = winnerName, @@ -209,6 +329,7 @@ fun LocalGameScreen( onPlayCard = { index -> executePlay(index, selectedWildColor) }, onDrawCard = { executeDraw() }, onChooseColor = { selectedWildColor = it }, + onShowLog = { showLogDialog = true }, onCallUno = { // Mark player as having called UNO val updated = gameState.players.map { diff --git a/app/src/main/java/com/unogame/ui/screens/LocalSetupScreen.kt b/app/src/main/java/com/unogame/ui/screens/LocalSetupScreen.kt index e32dab0..a734002 100644 --- a/app/src/main/java/com/unogame/ui/screens/LocalSetupScreen.kt +++ b/app/src/main/java/com/unogame/ui/screens/LocalSetupScreen.kt @@ -26,6 +26,7 @@ import com.unogame.ui.theme.* fun LocalSetupScreen( playerName: String, modeDisplayName: String, + mode: com.unogame.game.GameMode, onStartGame: (Int, String) -> Unit, onBack: () -> Unit ) { @@ -33,6 +34,8 @@ fun LocalSetupScreen( 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 initMaxHandSize = remember { com.unogame.game.GameRules.loadMaxHandSize(context, mode) } + var maxHandSize by remember { mutableIntStateOf(initMaxHandSize) } val botNames = listOf("🤖 机器人1", "🤖 机器人2", "🤖 机器人3") @@ -152,6 +155,41 @@ fun LocalSetupScreen( Spacer(modifier = Modifier.height(28.dp)) + // Hand size limit + Text("手牌上限", color = Color.White.copy(alpha = 0.6f), fontSize = 14.sp) + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Slider( + value = maxHandSize.toFloat(), + onValueChange = { maxHandSize = it.toInt() }, + valueRange = 0f..20f, + steps = 19, + modifier = Modifier.weight(1f), + colors = SliderDefaults.colors( + thumbColor = GoldAccent, + activeTrackColor = GoldAccent + ) + ) + Text( + text = if (maxHandSize == 0) "无限制" else "${maxHandSize}张", + color = GoldAccent, + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.width(56.dp) + ) + } + Text( + "0 = 无限制,默认各模式不限制,无情模式默认10张上限", + color = Color.White.copy(alpha = 0.35f), + fontSize = 11.sp + ) + + 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)) @@ -178,6 +216,7 @@ fun LocalSetupScreen( Button( onClick = { com.unogame.game.AIDifficulty.save(context, difficulty) + com.unogame.game.GameRules.saveMaxHandSize(context, mode, maxHandSize) onStartGame(totalPlayers, name.ifBlank { "玩家" }) }, modifier = Modifier.fillMaxWidth().height(56.dp), diff --git a/app/src/main/java/com/unogame/ui/screens/MainMenuScreen.kt b/app/src/main/java/com/unogame/ui/screens/MainMenuScreen.kt index 68bb7cb..dcc31b9 100644 --- a/app/src/main/java/com/unogame/ui/screens/MainMenuScreen.kt +++ b/app/src/main/java/com/unogame/ui/screens/MainMenuScreen.kt @@ -74,7 +74,10 @@ fun MainMenuScreen( // Name input OutlinedTextField( value = playerName, - onValueChange = { playerName = it.take(10) }, + onValueChange = { + playerName = it.take(10) + onNameChanged(playerName.ifBlank { "玩家" }) + }, label = { Text("你的昵称", color = Color.White.copy(alpha = 0.6f)) }, singleLine = true, colors = OutlinedTextFieldDefaults.colors( diff --git a/app/src/main/java/com/unogame/ui/screens/RulesHelpScreen.kt b/app/src/main/java/com/unogame/ui/screens/RulesHelpScreen.kt index b9c9539..a694d10 100644 --- a/app/src/main/java/com/unogame/ui/screens/RulesHelpScreen.kt +++ b/app/src/main/java/com/unogame/ui/screens/RulesHelpScreen.kt @@ -147,7 +147,11 @@ fun RulesHelpScreen(onBack: () -> Unit) { Label("UNO叫牌与抓人") Bullet("出牌后手牌剩1张时,红色\"喊UNO!\"按钮出现,必须点击") Bullet("未喊且被其他玩家\"抓XX!\",罚抽2张") - Bullet("若已被抓过或手牌已满10张(无情模式),免罚") + Bullet("若已被抓过或手牌已满上限(无情模式),免罚") + SpacerH(8) + Label("操作提示") + Bullet("长按手牌可查看牌面说明与分数") + Bullet("点击出牌记录可以查看完整出牌历史") SpacerH(8) Label("游戏结束与计分") Bullet("先出完手牌者获胜,单局结束")