UnoGame/app/src/main/java/com/unogame/ui/screens/LocalGameScreen.kt

640 lines
29 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package com.unogame.ui.screens
import android.content.Context
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
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.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
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
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 com.unogame.ui.components.ColorPickerDialog
import com.unogame.ui.components.getBotAvatar
import com.unogame.ui.theme.*
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
val BOT_NAMES = listOf(
"赛博打工人", "机器人-钛君", "赛博牛马", "硅基生物", "电子包浆", "铁憨憨",
"充电宝精", "电子宠物", "终结者", "天网", "瓦力", "伊娃",
"人工智障", "GPT仔", "大模型基佬", "电耗子", "塑料脑", "波塔", "阿法狗"
)
fun pickUniqueBotNames(count: Int): List<String> {
val shuffled = BOT_NAMES.shuffled()
val names = mutableListOf<String>()
for (i in 0 until count) {
if (i < shuffled.size) names.add(shuffled[i])
else names.add("机器人${i + 1}")
}
return names
}
data class ScoreEntry(
val name: String,
val mode: String,
val points: Int,
val difficulty: String,
val duration: Int,
val playerCount: Int,
val turnNumber: Int,
val date: Long,
val scoreDetail: String? = "",
val opponentDetail: String? = ""
)
object Scoreboard {
private const val PREFS_KEY = "uno_scoreboard"
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 addEntry(
context: Context,
name: String,
mode: String,
points: Int,
difficulty: String,
duration: Int,
playerCount: Int,
turnNumber: Int,
scoreDetail: String = "",
opponentDetail: String = ""
) {
val scores = loadScores(context).toMutableList()
scores.add(
ScoreEntry(
name = name,
mode = mode,
points = points,
difficulty = difficulty,
duration = duration,
playerCount = playerCount,
turnNumber = turnNumber,
date = System.currentTimeMillis(),
scoreDetail = scoreDetail,
opponentDetail = opponentDetail
)
)
saveScores(context, scores.sortedByDescending { it.points }.take(50))
}
}
@Composable
fun LocalGameScreen(
totalPlayers: Int,
humanPlayerName: String,
mode: GameMode,
botNames: List<String> = emptyList(),
handOffset: Float = 0f,
onBackToMenu: () -> Unit
) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
val rules = remember { GameRules.forMode(mode, GameRules.loadMaxHandSize(context, mode)) }
val engine = remember { GameEngine(rules) }
val aiDiff = remember { AIDifficulty.load(context, mode) }
val gameStartTime = remember { System.currentTimeMillis() }
val players = remember {
val names = if (botNames.isEmpty()) pickUniqueBotNames(totalPlayers - 1) else botNames
val list = mutableListOf<Player>()
list.add(Player(id = "human", name = humanPlayerName, isCurrentTurn = true))
for (i in 0 until totalPlayers - 1) {
list.add(Player(id = "bot_${i + 2}", name = names.getOrElse(i) { "机器人${i + 1}" }, 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) }
var gamePoints by remember { mutableIntStateOf(0) }
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 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 showLogDialog by remember { mutableStateOf(false) }
var showSwapTargetPicker by remember { mutableStateOf(false) }
var pendingSwapState by remember { mutableStateOf<GameState?>(null) }
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.message.isNotEmpty()) {
// 倒序插入,最新在上
gameLog = listOf(state.message) + gameLog
}
if (state.isGameOver) {
isGameOver = true
winnerName = state.winner?.name ?: ""
isYouWinner = state.winner?.id == myPlayerId
if (isYouWinner && !scoreSaved) {
scoreSaved = true
gameTurnNumber = state.turnNumber
gameDuration = ((System.currentTimeMillis() - gameStartTime) / 1000).toInt()
gameDifficulty = aiDiff.displayName
gamePoints = state.players.filter { it.id != myPlayerId }.sumOf { player ->
player.cards.sumOf { card ->
val active = if (state.flipped && card.flipSide != null) card.flipSide else card
active.score
}
}
val scoreDetails = state.players.filter { it.id != myPlayerId }.flatMap { player ->
player.cards.map { card ->
val active = if (state.flipped && card.flipSide != null) card.flipSide else card
val label = if (active.type == CardType.NUMBER) "${active.color.displayName}${active.number}"
else "${active.color.displayName}${active.type.symbol}"
"${label}(${active.score}分)"
}
}
val detailStr = scoreDetails.joinToString(", ")
// 对手名和各自总分
val opponentDetails = state.players.filter { it.id != myPlayerId }.joinToString("") { p ->
val pScore = p.cards.sumOf { card ->
val active = if (state.flipped && card.flipSide != null) card.flipSide else card
active.score
}
"${p.name}${pScore}"
}
Scoreboard.addEntry(
context = context,
name = humanPlayerName,
mode = mode.displayName,
points = gamePoints,
difficulty = gameDifficulty,
duration = gameDuration,
playerCount = totalPlayers,
turnNumber = gameTurnNumber,
scoreDetail = detailStr,
opponentDetail = opponentDetails
)
}
}
}
fun executePlay(cardIndex: Int, chosenColor: CardColor? = null, swapTargetId: String? = null) {
val result = engine.playCard(gameState, myPlayerId, cardIndex, chosenColor, swapTargetId)
when (result) {
is GameEngine.PlayResult.Success -> {
if (result.needsSwapTarget) {
pendingSwapState = result.state
showSwapTargetPicker = true
} else {
updateState(result.state)
}
}
is GameEngine.PlayResult.Error -> errorMessage = result.message
}
}
fun executeDraw() {
val result = engine.drawCard(gameState, myPlayerId)
when (result) {
is GameEngine.PlayResult.Success -> {
if (result.drawnCardPlayableIndex >= 0) {
pendingDrawState = result.state
pendingDrawnCardIndex = result.drawnCardPlayableIndex
val drawnCard = result.state.players.find { it.id == myPlayerId }?.cards?.getOrNull(result.drawnCardPlayableIndex)
pendingDrawIsWild = drawnCard?.type?.isWild == true
showDrawPlayPopup = true
} else {
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,
otherPlayers = gameState.players,
isSevenZero = mode == GameMode.SEVEN_ZERO
)
when (move.type) {
SimpleAI.MoveType.PLAY -> {
val result = engine.playCard(gameState, current.id, move.cardIndex, move.chosenColor, move.swapTargetId)
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) {
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)
}
}
}
}
}
}
// Show flip indicator
val displayCards = remember(gameState.flipped, myCards) {
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
val isWild = pendingDrawIsWild
if (isWild) {
showDrawPlayPopup = false
selectedWildColor = null
showDrawPlayWildPicker = true
pendingDrawStateForWild = state
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) }
},
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
)
}
// 出牌记录弹窗
if (showLogDialog) {
// 卡牌颜色名映射
val cardColorMap = mapOf(
"" to UnoRed, "" to UnoBlue,
"" to Color(0xFFFDD835), "绿" to UnoGreen,
"" to Color(0xFFE91E63), "" to Color(0xFF9C27B0),
"" to Color(0xFF009688), "" to Color(0xFFFF9800),
"万能" to Color(0xFF616161), "" to Color(0xFF424242)
)
fun parsePlayerName(msg: String): String = msg.substringBefore(" 出了").substringBefore(" 摸了").substringBefore(" 选择").substringBefore(" 无法")
fun parseColorText(msg: String): String {
val after = msg.substringAfter(" 出了 ", "")
if (after.isEmpty()) return ""
val colorNames = listOf("万能", "", "", "", "绿", "", "", "", "", "")
return colorNames.firstOrNull { after.startsWith(it) } ?: ""
}
fun parseArrowColor(msg: String): Pair<String, Color>? {
val arrowIdx = msg.indexOf("")
if (arrowIdx < 0) return null
val afterArrow = msg.substring(arrowIdx + 3).trimStart()
val cn = listOf("", "", "", "绿", "", "", "", "", "", "万能")
for (name in cn) {
if (afterArrow.startsWith(name)) {
return name to (cardColorMap[name] ?: Color.White)
}
}
return null
}
val creamBg = Color(0xFFFFF8E1) // 护眼米白
val dimText = Color(0xFF555555) // 浅色底上的暗灰文字
// 列表状态放在外层供标题栏和内容区共享
val listState = rememberLazyListState()
val canScrollForward by remember { derivedStateOf { listState.canScrollForward } }
val canScrollBackward by remember { derivedStateOf { listState.canScrollBackward } }
AlertDialog(
modifier = Modifier.fillMaxWidth(0.98f),
onDismissRequest = { showLogDialog = false },
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("出牌记录", color = Color(0xFF333333), fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f))
if (gameLog.size > 4 && canScrollBackward) {
var topPressed by remember { mutableStateOf(false) }
val topScale by animateFloatAsState(if (topPressed) 1.4f else 1f, spring())
if (topPressed) {
LaunchedEffect(Unit) { kotlinx.coroutines.delay(400); topPressed = false }
}
IconButton(
onClick = { topPressed = true; scope.launch { listState.animateScrollToItem(0) } },
modifier = Modifier.size(28.dp).scale(topScale)
) { Icon(Icons.Default.KeyboardArrowUp, "到顶部", tint = Color(0xFF555555)) }
} else {
Spacer(modifier = Modifier.size(28.dp)) // 占位保持标题居中
}
}
},
text = {
// 记录内容
if (gameLog.isEmpty()) {
Text("暂无记录", color = dimText, fontSize = 13.sp)
} else {
Box(
modifier = Modifier
.height(450.dp)
.fillMaxWidth()
) {
// 滚动内容(纯文字区域)
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize()
) {
itemsIndexed(gameLog) { index, msg ->
val playerName = parsePlayerName(msg)
val avatar = getBotAvatar(playerName)
val colorText = parseColorText(msg)
val cardColor = cardColorMap[colorText]
val isPlay = " 出了 " in msg
val arrowColor = parseArrowColor(msg)
val afterPlayer = msg.substringAfter(playerName)
val annotated = buildAnnotatedString {
withStyle(SpanStyle(color = Color(0xFF999999), fontSize = 13.sp)) {
append("${gameLog.size - index}. ")
}
withStyle(SpanStyle(color = avatar.color, fontWeight = FontWeight.Bold, fontSize = 13.sp)) {
append(playerName)
}
if (isPlay && (colorText.isNotEmpty() || arrowColor != null)) {
if (arrowColor != null) {
val prefix = afterPlayer.substringBefore(" 万能")
val cardBlock = afterPlayer.substringAfter(" 出了 ").substringBefore("")
val remain = afterPlayer.substringAfter(prefix + " 出了 " + cardBlock)
withStyle(SpanStyle(color = dimText, fontSize = 13.sp)) {
append(prefix); append(" 出了 ")
}
withStyle(SpanStyle(color = Color.Black, fontWeight = FontWeight.Black,
fontSize = 13.sp, background = arrowColor.second.copy(alpha = 0.9f))) {
append(cardBlock)
}
if (remain.isNotEmpty()) {
withStyle(SpanStyle(color = Color(0xFF777777), fontSize = 12.sp)) {
append(remain)
}
}
} else {
val prefix = afterPlayer.substringBefore(colorText)
val cardId = colorText + afterPlayer.substringAfter(colorText).substringBefore("")
val remain = afterPlayer.substringAfter(prefix + cardId)
withStyle(SpanStyle(color = dimText, fontSize = 13.sp)) {
append(prefix)
}
withStyle(SpanStyle(color = Color.Black, fontWeight = FontWeight.Black,
fontSize = 13.sp, background = (cardColor ?: Color.Gray).copy(alpha = 0.9f))) {
append(cardId)
}
if (remain.isNotEmpty()) {
withStyle(SpanStyle(color = Color(0xFF777777), fontSize = 12.sp)) {
append(remain)
}
}
}
} else {
withStyle(SpanStyle(color = dimText, fontSize = 13.sp)) {
append(afterPlayer)
}
}
}
Text(annotated, modifier = Modifier.padding(vertical = 5.dp))
}
}
} // Box 结束
}
},
confirmButton = {
Row(verticalAlignment = Alignment.CenterVertically) {
if (canScrollForward) {
var bottomPressed by remember { mutableStateOf(false) }
val bottomScale by animateFloatAsState(if (bottomPressed) 1.4f else 1f, spring())
if (bottomPressed) {
LaunchedEffect(Unit) { kotlinx.coroutines.delay(400); bottomPressed = false }
}
IconButton(
onClick = { bottomPressed = true; scope.launch { listState.animateScrollToItem(gameLog.size - 1) } },
modifier = Modifier.size(28.dp).scale(bottomScale)
) { Icon(Icons.Default.KeyboardArrowDown, "到底部", tint = Color(0xFF555555)) }
} else {
Spacer(modifier = Modifier.size(28.dp))
}
Spacer(modifier = Modifier.weight(1f))
TextButton(onClick = { showLogDialog = false }) {
Text("关闭", color = Color(0xFF333333))
}
}
},
containerColor = creamBg
)
}
// 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) {
GameOverScreen(
winnerName = winnerName,
isYouWinner = isYouWinner,
onBackToMenu = onBackToMenu,
points = gamePoints,
difficulty = gameDifficulty,
duration = gameDuration,
turnNumber = gameTurnNumber,
playerCount = totalPlayers
)
} else {
GameScreen(
gameState = gameState,
myCards = displayCards,
myPlayerId = myPlayerId,
isMyTurn = isMyTurn,
errorMessage = errorMessage,
handOffset = handOffset,
isSevenZeroMode = mode == GameMode.SEVEN_ZERO,
onPlayCard = { index -> executePlay(index, selectedWildColor) },
onPlaySeven = { index -> executePlay(index, null) },
onDrawCard = { executeDraw() },
onChooseColor = { selectedWildColor = it },
onShowLog = { showLogDialog = true },
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
}
}
)
}
}