fix: 修复7-0交换手牌bug、Flip卡面匹配bug,新增加摸牌后可出牌弹窗、出牌记录查看、手牌上限设置、昵称实时保存

This commit is contained in:
flykhan 2026-04-26 16:37:42 +08:00
parent d201ae48eb
commit 8ac938cb2b
9 changed files with 293 additions and 39 deletions

View File

@ -377,6 +377,7 @@ fun UnoApp() {
LocalSetupScreen(
playerName = savedName,
modeDisplayName = mode.displayName,
mode = mode,
onStartGame = { totalPlayers, name ->
if (name.isNotEmpty()) {
savedName = name

View File

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

View File

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

View File

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

View File

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

View File

@ -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<GameState?>(null) }
var pendingDrawnCardIndex by remember { mutableIntStateOf(-1) }
var gameLog by remember { mutableStateOf(listOf<String>()) }
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 {

View File

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

View File

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

View File

@ -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("先出完手牌者获胜,单局结束")