fix: 7-0自由选择交换对象、解除死锁、出牌记录优化、万能牌摸牌选色

This commit is contained in:
flykhan 2026-04-26 17:17:54 +08:00
parent 8ac938cb2b
commit 548fbef889
5 changed files with 279 additions and 29 deletions

View File

@ -432,6 +432,7 @@ fun UnoApp() {
myPlayerId = myPlayerId, myPlayerId = myPlayerId,
isMyTurn = state.currentPlayer.id == myPlayerId, isMyTurn = state.currentPlayer.id == myPlayerId,
errorMessage = errorMessage, errorMessage = errorMessage,
isSevenZeroMode = false,
onPlayCard = { index -> onPlayCard = { index ->
errorMessage = "" errorMessage = ""
val colorToSend = if (index >= 0 && index < myCards.size && myCards[index].type.isWild) { val colorToSend = if (index >= 0 && index < myCards.size && myCards[index].type.isWild) {

View File

@ -124,7 +124,8 @@ class GameEngine(private val rules: GameRules = GameRules.forMode(GameMode.NORMA
sealed class PlayResult { sealed class PlayResult {
data class Success( data class Success(
val state: GameState, val state: GameState,
val drawnCardPlayableIndex: Int = -1 val drawnCardPlayableIndex: Int = -1,
val needsSwapTarget: Boolean = false
) : PlayResult() ) : PlayResult()
data class Error(val message: String) : PlayResult() data class Error(val message: String) : PlayResult()
} }
@ -133,7 +134,8 @@ class GameEngine(private val rules: GameRules = GameRules.forMode(GameMode.NORMA
state: GameState, state: GameState,
playerId: String, playerId: String,
cardIndex: Int, cardIndex: Int,
chosenColor: CardColor? = null chosenColor: CardColor? = null,
swapTargetId: String? = null
): PlayResult { ): PlayResult {
val playerIndex = state.players.indexOfFirst { it.id == playerId } val playerIndex = state.players.indexOfFirst { it.id == playerId }
if (playerIndex != state.currentPlayerIndex) if (playerIndex != state.currentPlayerIndex)
@ -162,7 +164,7 @@ class GameEngine(private val rules: GameRules = GameRules.forMode(GameMode.NORMA
!card.matches(topCard, state.currentWildColor, state.flipped)) !card.matches(topCard, state.currentWildColor, state.flipped))
return PlayResult.Error("不能出这张牌") return PlayResult.Error("不能出这张牌")
return executeCardPlay(state, player, playerIndex, cardIndex, card, chosenColor, canStack) return executeCardPlay(state, player, playerIndex, cardIndex, card, chosenColor, canStack, swapTargetId)
} }
private fun executeCardPlay( private fun executeCardPlay(
@ -172,7 +174,8 @@ class GameEngine(private val rules: GameRules = GameRules.forMode(GameMode.NORMA
cardIndex: Int, cardIndex: Int,
card: Card, card: Card,
chosenColor: CardColor?, chosenColor: CardColor?,
isStacking: Boolean isStacking: Boolean,
swapTargetId: String? = null
): PlayResult { ): PlayResult {
val newDiscard = discardPile.toMutableList() val newDiscard = discardPile.toMutableList()
newDiscard.add(card) newDiscard.add(card)
@ -187,7 +190,8 @@ class GameEngine(private val rules: GameRules = GameRules.forMode(GameMode.NORMA
var nextIndex = state.nextPlayerIndex() var nextIndex = state.nextPlayerIndex()
var direction = state.direction var direction = state.direction
var flipped = state.flipped var flipped = state.flipped
var message = "${player.name} 出了 ${card.displayText}" val cardColorText = if (card.color == CardColor.WILD) "万能" else card.color.displayName
var message = "${player.name} 出了 ${cardColorText} ${card.displayText}"
val activeCard = card.activeCard(flipped) val activeCard = card.activeCard(flipped)
@ -303,13 +307,38 @@ class GameEngine(private val rules: GameRules = GameRules.forMode(GameMode.NORMA
pendingDraw = 0 pendingDraw = 0
sevenZeroDone = true sevenZeroDone = true
} else if (pendingDraw == -2) { } else if (pendingDraw == -2) {
// 7: swap hand with the next player // 7: swap hand with player of choice
val nextIdx = state.nextPlayerIndex() val targetIdx = if (swapTargetId != null) {
val nextCards = state.players[nextIdx].cards state.players.indexOfFirst { it.id == swapTargetId }
} else {
state.nextPlayerIndex()
}
// If target not specified and more than 2 players, need UI to choose
if (swapTargetId == null && state.players.size > 2) {
// Return state with 7 removed but no swap yet, flag for target selection
val partialPlayers = state.players.mapIndexed { i, p ->
if (i == playerIndex) p.copy(cards = newCards, cardCount = newCards.size)
else p.copy(isCurrentTurn = false)
}
return PlayResult.Success(
state.copy(
players = partialPlayers,
currentPlayerIndex = playerIndex,
discardPile = discardPile.toList(),
drawPileCount = drawPile.size,
pendingDrawCount = 0,
flipped = flipped,
turnNumber = state.turnNumber + 1,
message = message + ",选择交换对象"
),
needsSwapTarget = true
)
}
val nextCards = state.players[targetIdx].cards
modifiedPlayers = state.players.mapIndexed { i, p -> modifiedPlayers = state.players.mapIndexed { i, p ->
when (i) { when (i) {
playerIndex -> p.copy(cards = nextCards, cardCount = nextCards.size) playerIndex -> p.copy(cards = nextCards, cardCount = nextCards.size)
nextIdx -> p.copy(cards = newCards, cardCount = newCards.size) targetIdx -> p.copy(cards = newCards, cardCount = newCards.size)
else -> p else -> p
} }
} }
@ -424,8 +453,28 @@ class GameEngine(private val rules: GameRules = GameRules.forMode(GameMode.NORMA
if (drawPile.isNotEmpty()) drawPile.removeAt(0) else null if (drawPile.isNotEmpty()) drawPile.removeAt(0) else null
} }
if (drawnCards.isEmpty()) if (drawnCards.isEmpty()) {
return PlayResult.Error("牌堆已空,无法摸牌") // Can't draw - just pass turn
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 = 0,
pendingDrawCount = if (state.pendingDrawCount > 0) 0 else state.pendingDrawCount,
flipped = state.flipped,
turnNumber = state.turnNumber + 1,
message = "${player.name} 无法摸牌,跳过回合"
)
)
}
val newCards = player.cards.toMutableList() val newCards = player.cards.toMutableList()
newCards.addAll(drawnCards) newCards.addAll(drawnCards)
@ -489,6 +538,69 @@ class GameEngine(private val rules: GameRules = GameRules.forMode(GameMode.NORMA
) )
} }
fun finishSwap(state: GameState, playerId: String, swapTargetId: String): PlayResult {
val playerIndex = state.players.indexOfFirst { it.id == playerId }
val targetIdx = state.players.indexOfFirst { it.id == swapTargetId }
if (playerIndex < 0 || targetIdx < 0) return PlayResult.Error("玩家不存在")
if (playerIndex == targetIdx) return PlayResult.Error("不能和自己交换")
val currentPlayer = state.players[playerIndex]
val targetPlayer = state.players[targetIdx]
val updatedPlayers = state.players.mapIndexed { i, p ->
when (i) {
playerIndex -> p.copy(
cards = targetPlayer.cards,
cardCount = targetPlayer.cards.size,
isCurrentTurn = false
)
targetIdx -> p.copy(
cards = currentPlayer.cards,
cardCount = currentPlayer.cards.size
)
else -> p.copy(isCurrentTurn = false)
}
}
val nextIndex = state.nextPlayerIndex()
val updatedWithTurn = updatedPlayers.mapIndexed { i, p ->
if (i == nextIndex) p.copy(isCurrentTurn = true) else p
}
val isWinner = currentPlayer.cards.isEmpty()
if (isWinner) {
return PlayResult.Success(
state.copy(
players = updatedWithTurn,
isGameOver = true,
winner = currentPlayer.copy(cards = emptyList(), cardCount = 0),
discardPile = discardPile.toList(),
drawPileCount = drawPile.size,
turnNumber = state.turnNumber + 1,
message = "${currentPlayer.name}${targetPlayer.name} 交换手牌,${currentPlayer.name} 赢了!"
)
)
}
val calledUno = currentPlayer.cards.size == 1
val finalPlayers = updatedWithTurn.mapIndexed { i, p ->
if (i == playerIndex) p.copy(calledUno = calledUno) else p
}
return PlayResult.Success(
state.copy(
players = finalPlayers,
currentPlayerIndex = nextIndex,
discardPile = discardPile.toList(),
drawPileCount = drawPile.size,
pendingDrawCount = 0,
flipped = state.flipped,
turnNumber = state.turnNumber + 1,
message = "${currentPlayer.name}${targetPlayer.name} 交换手牌"
)
)
}
fun skipAfterDraw(state: GameState): PlayResult { fun skipAfterDraw(state: GameState): PlayResult {
val nextIndex = state.nextPlayerIndex() val nextIndex = state.nextPlayerIndex()
val updatedPlayers = state.players.mapIndexed { i, p -> val updatedPlayers = state.players.mapIndexed { i, p ->
@ -521,7 +633,13 @@ class GameEngine(private val rules: GameRules = GameRules.forMode(GameMode.NORMA
private fun reshuffleDiscard() { private fun reshuffleDiscard() {
if (discardPile.size <= 1 && drawPile.isNotEmpty()) return if (discardPile.size <= 1 && drawPile.isNotEmpty()) return
if (discardPile.isEmpty()) return
val topCard = discardPile.removeAt(discardPile.size - 1) val topCard = discardPile.removeAt(discardPile.size - 1)
if (discardPile.isEmpty()) {
// Only the top card exists - put it back, can't reshuffle
discardPile.add(topCard)
return
}
drawPile.addAll(discardPile) drawPile.addAll(discardPile)
shuffle(drawPile) shuffle(drawPile)
discardPile.clear() discardPile.clear()

View File

@ -30,17 +30,26 @@ object SimpleAI {
data class AIMove( data class AIMove(
val type: MoveType, val type: MoveType,
val cardIndex: Int = -1, val cardIndex: Int = -1,
val chosenColor: CardColor? = null val chosenColor: CardColor? = null,
val swapTargetId: String? = null
) )
enum class MoveType { PLAY, DRAW } enum class MoveType { PLAY, DRAW }
fun pickSwapTarget(players: List<Player>, selfId: String): String? {
val others = players.filter { it.id != selfId }
if (others.isEmpty()) return null
return others.random(Random).id
}
fun decideMove( fun decideMove(
player: Player, player: Player,
topCard: Card?, topCard: Card?,
wildColor: CardColor?, wildColor: CardColor?,
flipped: Boolean = false, flipped: Boolean = false,
difficulty: AIDifficulty = AIDifficulty.NORMAL difficulty: AIDifficulty = AIDifficulty.NORMAL,
otherPlayers: List<Player> = emptyList(),
isSevenZero: Boolean = false
): AIMove { ): AIMove {
val hand = player.cards val hand = player.cards
if (hand.isEmpty()) return AIMove(MoveType.DRAW) if (hand.isEmpty()) return AIMove(MoveType.DRAW)
@ -62,11 +71,15 @@ object SimpleAI {
// Easy: play random card // Easy: play random card
if (difficulty == AIDifficulty.EASY) { if (difficulty == AIDifficulty.EASY) {
val chosen = playable.random() val chosen = playable.random()
if (chosen.second.type.isWild || chosen.second.activeCard(flipped).type.isWild) { val idx = hand.indexOf(chosen.second)
return if (chosen.second.type.isWild || chosen.second.activeCard(flipped).type.isWild) {
val color = pickBestColor(hand, flipped) val color = pickBestColor(hand, flipped)
return AIMove(MoveType.PLAY, hand.indexOf(chosen.second), color) AIMove(MoveType.PLAY, idx, color)
} else if (isSevenZero && chosen.second.type == CardType.NUMBER && chosen.second.number == 7) {
AIMove(MoveType.PLAY, idx, swapTargetId = pickSwapTarget(otherPlayers, player.id))
} else {
AIMove(MoveType.PLAY, idx)
} }
return AIMove(MoveType.PLAY, hand.indexOf(chosen.second))
} }
// Hard: prefer cards that hurt the opponent most // Hard: prefer cards that hurt the opponent most
@ -106,6 +119,10 @@ object SimpleAI {
return AIMove(MoveType.PLAY, hand.indexOf(chosen.second), color) return AIMove(MoveType.PLAY, hand.indexOf(chosen.second), color)
} }
if (isSevenZero && chosen.second.type == CardType.NUMBER && chosen.second.number == 7) {
return AIMove(MoveType.PLAY, hand.indexOf(chosen.second), swapTargetId = pickSwapTarget(otherPlayers, player.id))
}
return AIMove(MoveType.PLAY, hand.indexOf(chosen.second)) return AIMove(MoveType.PLAY, hand.indexOf(chosen.second))
} }

View File

@ -34,6 +34,8 @@ fun GameScreen(
onCallUno: () -> Unit = {}, onCallUno: () -> Unit = {},
onChallengeUno: (String) -> Unit = {}, onChallengeUno: (String) -> Unit = {},
onShowLog: () -> Unit = {}, onShowLog: () -> Unit = {},
onPlaySeven: (Int) -> Unit = {},
isSevenZeroMode: Boolean = false,
errorMessage: String errorMessage: String
) { ) {
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
@ -289,6 +291,8 @@ fun GameScreen(
if (realCard.type.isWild) { if (realCard.type.isWild) {
selectedAutoCard = sortedCards.indexOf(realCard) selectedAutoCard = sortedCards.indexOf(realCard)
showColorPicker = true showColorPicker = true
} else if (isSevenZeroMode && realCard.type == CardType.NUMBER && realCard.number == 7 && gameState.players.size > 2) {
onPlaySeven(myCards.indexOf(realCard))
} else { } else {
selectedCardIndex = index selectedCardIndex = index
onPlayCard(myCards.indexOf(realCard)) onPlayCard(myCards.indexOf(realCard))

View File

@ -25,6 +25,7 @@ import com.unogame.game.GameMode
import com.unogame.game.GameRules import com.unogame.game.GameRules
import com.unogame.game.SimpleAI import com.unogame.game.SimpleAI
import com.unogame.model.* import com.unogame.model.*
import com.unogame.ui.components.ColorPickerDialog
import com.unogame.ui.theme.* import com.unogame.ui.theme.*
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -122,8 +123,14 @@ fun LocalGameScreen(
var showDrawPlayPopup by remember { mutableStateOf(false) } var showDrawPlayPopup by remember { mutableStateOf(false) }
var pendingDrawState by remember { mutableStateOf<GameState?>(null) } var pendingDrawState by remember { mutableStateOf<GameState?>(null) }
var pendingDrawnCardIndex by remember { mutableIntStateOf(-1) } var pendingDrawnCardIndex by remember { mutableIntStateOf(-1) }
var pendingDrawIsWild by remember { mutableStateOf(false) }
var showDrawPlayWildPicker by remember { mutableStateOf(false) }
var pendingDrawStateForWild by remember { mutableStateOf<GameState?>(null) }
var pendingDrawCardIdxForWild by remember { mutableIntStateOf(-1) }
var gameLog by remember { mutableStateOf(listOf<String>()) } var gameLog by remember { mutableStateOf(listOf<String>()) }
var showLogDialog by remember { mutableStateOf(false) } var showLogDialog by remember { mutableStateOf(false) }
var showSwapTargetPicker by remember { mutableStateOf(false) }
var pendingSwapState by remember { mutableStateOf<GameState?>(null) }
val myPlayerId = "human" val myPlayerId = "human"
val currentPlayer = gameState.currentPlayer val currentPlayer = gameState.currentPlayer
@ -134,7 +141,7 @@ fun LocalGameScreen(
myCards = state.players.find { it.id == myPlayerId }?.cards ?: myCards myCards = state.players.find { it.id == myPlayerId }?.cards ?: myCards
errorMessage = "" errorMessage = ""
if (state.message.isNotEmpty()) { if (state.message.isNotEmpty()) {
gameLog = gameLog + state.message gameLog = listOf(state.message) + gameLog
} }
if (state.isGameOver) { if (state.isGameOver) {
isGameOver = true isGameOver = true
@ -165,10 +172,17 @@ fun LocalGameScreen(
} }
} }
fun executePlay(cardIndex: Int, chosenColor: CardColor? = null) { fun executePlay(cardIndex: Int, chosenColor: CardColor? = null, swapTargetId: String? = null) {
val result = engine.playCard(gameState, myPlayerId, cardIndex, chosenColor) val result = engine.playCard(gameState, myPlayerId, cardIndex, chosenColor, swapTargetId)
when (result) { when (result) {
is GameEngine.PlayResult.Success -> updateState(result.state) is GameEngine.PlayResult.Success -> {
if (result.needsSwapTarget) {
pendingSwapState = result.state
showSwapTargetPicker = true
} else {
updateState(result.state)
}
}
is GameEngine.PlayResult.Error -> errorMessage = result.message is GameEngine.PlayResult.Error -> errorMessage = result.message
} }
} }
@ -180,6 +194,8 @@ fun LocalGameScreen(
if (result.drawnCardPlayableIndex >= 0) { if (result.drawnCardPlayableIndex >= 0) {
pendingDrawState = result.state pendingDrawState = result.state
pendingDrawnCardIndex = result.drawnCardPlayableIndex pendingDrawnCardIndex = result.drawnCardPlayableIndex
val drawnCard = result.state.players.find { it.id == myPlayerId }?.cards?.getOrNull(result.drawnCardPlayableIndex)
pendingDrawIsWild = drawnCard?.type?.isWild == true
showDrawPlayPopup = true showDrawPlayPopup = true
} else { } else {
updateState(result.state) updateState(result.state)
@ -195,10 +211,14 @@ fun LocalGameScreen(
if (current.id != myPlayerId) { if (current.id != myPlayerId) {
delay(1200L) delay(1200L)
val move = SimpleAI.decideMove(current, gameState.topCard, gameState.currentWildColor, gameState.flipped, aiDiff) val move = SimpleAI.decideMove(
current, gameState.topCard, gameState.currentWildColor, gameState.flipped, aiDiff,
otherPlayers = gameState.players,
isSevenZero = mode == GameMode.SEVEN_ZERO
)
when (move.type) { when (move.type) {
SimpleAI.MoveType.PLAY -> { SimpleAI.MoveType.PLAY -> {
val result = engine.playCard(gameState, current.id, move.cardIndex, move.chosenColor) val result = engine.playCard(gameState, current.id, move.cardIndex, move.chosenColor, move.swapTargetId)
if (result is GameEngine.PlayResult.Success) { if (result is GameEngine.PlayResult.Success) {
updateState(result.state) updateState(result.state)
} else { } else {
@ -246,13 +266,22 @@ fun LocalGameScreen(
TextButton(onClick = { TextButton(onClick = {
val state = pendingDrawState!! val state = pendingDrawState!!
val cardIdx = pendingDrawnCardIndex val cardIdx = pendingDrawnCardIndex
showDrawPlayPopup = false val isWild = pendingDrawIsWild
pendingDrawState = null if (isWild) {
if (cardIdx >= 0) { showDrawPlayPopup = false
val result = engine.playCard(state, myPlayerId, cardIdx) selectedWildColor = null
when (result) { showDrawPlayWildPicker = true
is GameEngine.PlayResult.Success -> updateState(result.state) pendingDrawStateForWild = state
is GameEngine.PlayResult.Error -> errorMessage = result.message pendingDrawCardIdxForWild = cardIdx
} else {
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) } }) { Text("出牌", color = GoldAccent) }
@ -308,6 +337,85 @@ fun LocalGameScreen(
) )
} }
// Color picker for draw-then-play wild card
if (showDrawPlayWildPicker) {
ColorPickerDialog(
flipped = gameState.flipped,
onColorSelected = { color ->
showDrawPlayWildPicker = false
val state = pendingDrawStateForWild!!
val cardIdx = pendingDrawCardIdxForWild
pendingDrawStateForWild = null
if (cardIdx >= 0) {
val result = engine.playCard(state, myPlayerId, cardIdx, color)
when (result) {
is GameEngine.PlayResult.Success -> updateState(result.state)
is GameEngine.PlayResult.Error -> errorMessage = result.message
}
}
},
onDismiss = {
showDrawPlayWildPicker = false
// Fall back to skip on dismiss
val state = pendingDrawStateForWild!!
val result = engine.skipAfterDraw(state)
if (result is GameEngine.PlayResult.Success) updateState(result.state)
pendingDrawStateForWild = null
}
)
}
// 7-0 swap target picker
if (showSwapTargetPicker && pendingSwapState != null) {
val otherPlayers = gameState.players.filter { it.id != myPlayerId }
AlertDialog(
onDismissRequest = {
// Cancel swap, just finish the turn
if (pendingSwapState != null) {
gameState = pendingSwapState!!
updateState(gameState)
showSwapTargetPicker = false
}
},
title = { Text("选择交换对象", color = GoldAccent) },
text = {
Column {
Text("选择要交换手牌的玩家:", color = Color.White.copy(alpha = 0.7f), fontSize = 14.sp)
Spacer(modifier = Modifier.height(8.dp))
otherPlayers.forEach { p ->
TextButton(
onClick = {
val state = pendingSwapState!!
showSwapTargetPicker = false
pendingSwapState = null
val result = engine.finishSwap(state, myPlayerId, p.id)
when (result) {
is GameEngine.PlayResult.Success -> updateState(result.state)
is GameEngine.PlayResult.Error -> errorMessage = result.message
}
},
modifier = Modifier.fillMaxWidth()
) {
Icon(Icons.Default.Person, null, tint = GoldAccent, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(8.dp))
Text(p.name, color = Color.White, fontSize = 14.sp)
}
}
}
},
confirmButton = {
TextButton(onClick = {
if (pendingSwapState != null) {
gameState = pendingSwapState!!
updateState(gameState)
}
showSwapTargetPicker = false
}) { Text("取消", color = Color.White.copy(alpha = 0.5f)) }
},
containerColor = DarkSurface
)
}
if (isGameOver) { if (isGameOver) {
GameOverScreen( GameOverScreen(
winnerName = winnerName, winnerName = winnerName,
@ -326,7 +434,9 @@ fun LocalGameScreen(
myPlayerId = myPlayerId, myPlayerId = myPlayerId,
isMyTurn = isMyTurn, isMyTurn = isMyTurn,
errorMessage = errorMessage, errorMessage = errorMessage,
isSevenZeroMode = mode == GameMode.SEVEN_ZERO,
onPlayCard = { index -> executePlay(index, selectedWildColor) }, onPlayCard = { index -> executePlay(index, selectedWildColor) },
onPlaySeven = { index -> executePlay(index, null) },
onDrawCard = { executeDraw() }, onDrawCard = { executeDraw() },
onChooseColor = { selectedWildColor = it }, onChooseColor = { selectedWildColor = it },
onShowLog = { showLogDialog = true }, onShowLog = { showLogDialog = true },