feat: UNO Kotlin Multiplatform app (Android, Desktop, Web)

- Full UNO game logic with AI opponents
- Compose Multiplatform UI with animations
- Shared module (commonMain) with game engine
- Desktop app (Compose Desktop)
- Android app (Compose Multiplatform)
- Web app (Kotlin/Wasm)
- Nix shell for reproducible dev environment
This commit is contained in:
2026-08-02 18:21:57 +02:00
commit 786b813f7b
25 changed files with 2093 additions and 0 deletions
+44
View File
@@ -0,0 +1,44 @@
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidLibrary)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
alias(libs.plugins.kotlinSerialization)
}
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class)
kotlin {
androidTarget {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
}
jvm()
wasmJs {
browser()
}
sourceSets {
commonMain.dependencies {
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.coroutines.core)
}
}
}
android {
namespace = "com.uno.shared"
compileSdk = 35
defaultConfig {
minSdk = 26
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
@@ -0,0 +1,236 @@
package com.uno.game
import com.uno.game.model.*
import kotlin.random.Random
class GameEngine {
fun createDeck(): Deck {
val cards = mutableListOf<Card>()
for (color in CardColor.entries) {
cards.add(Card(color, CardValue.ZERO))
for (value in listOf(
CardValue.ONE, CardValue.TWO, CardValue.THREE, CardValue.FOUR,
CardValue.FIVE, CardValue.SIX, CardValue.SEVEN, CardValue.EIGHT, CardValue.NINE
)) {
cards.add(Card(color, value))
cards.add(Card(color, value))
}
cards.add(Card(color, CardValue.SKIP))
cards.add(Card(color, CardValue.SKIP))
cards.add(Card(color, CardValue.REVERSE))
cards.add(Card(color, CardValue.REVERSE))
cards.add(Card(color, CardValue.DRAW_TWO))
cards.add(Card(color, CardValue.DRAW_TWO))
}
repeat(4) {
cards.add(Card(null, CardValue.WILD))
cards.add(Card(null, CardValue.WILD_DRAW_FOUR))
}
return Deck(drawPile = cards.shuffled(Random))
}
fun dealCards(deck: Deck, playerCount: Int, cardsPerPlayer: Int = 7): Triple<Deck, List<Player>, Player> {
var currentDeck = deck
val players = mutableListOf<Player>()
for (i in 0 until playerCount) {
val (drawnDeck, cards) = currentDeck.draw(cardsPerPlayer)
currentDeck = drawnDeck
players.add(
Player(
id = "player_$i",
name = if (i == 0) "Du" else "KI $i",
hand = cards,
isHuman = i == 0
)
)
}
var (drawnDeck, firstCard) = currentDeck.draw(1)
while (firstCard.first().isWild) {
val reshuffled = createDeck()
val reshuffledState = reshuffled.copy(
drawPile = reshuffled.drawPile + currentDeck.discardPile + firstCard
)
val result = reshuffledState.draw(1)
drawnDeck = result.first.copy(discardPile = emptyList())
firstCard = result.second
}
val initialCard = firstCard.first()
drawnDeck = drawnDeck.discard(initialCard)
return Triple(drawnDeck, players, players[0])
}
fun startGame(playerCount: Int = 4): GameState {
val deck = createDeck()
val (newDeck, players, _) = dealCards(deck, playerCount)
return GameState(
phase = GamePhase.PLAYING,
players = players,
currentPlayerIndex = 0,
deck = newDeck,
direction = GameDirection.CLOCKWISE,
currentColor = newDeck.topCard?.color
)
}
fun playCard(state: GameState, playerIndex: Int, card: Card, chosenColor: CardColor? = null): GameState {
val player = state.players[playerIndex]
val topCard = state.deck.topCard
require(player.hand.contains(card)) { "Spieler hat diese Karte nicht" }
if (topCard != null) {
require(card.canPlayOn(topCard, state.currentColor)) { "Karte kann nicht gespielt werden" }
}
val newHand = player.hand - card
val newPlayers = state.players.toMutableList()
newPlayers[playerIndex] = player.copy(hand = newHand)
var newState = state.copy(
players = newPlayers,
deck = state.deck.discard(card),
currentColor = if (card.isWild) chosenColor else card.color
)
if (newHand.isEmpty()) {
return newState.copy(
phase = GamePhase.FINISHED,
winner = newPlayers[playerIndex]
)
}
newState = when (card.value) {
CardValue.REVERSE -> {
val newDirection = state.direction.reverse()
val newCurrent = if (state.players.size == 2) state.nextPlayerIndex() else state.nextPlayerIndex()
newState.copy(direction = newDirection, currentPlayerIndex = newCurrent)
}
CardValue.SKIP -> {
val skipped = state.nextPlayerIndex()
newState.copy(currentPlayerIndex = state.nextPlayerIndex().let { idx ->
val size = state.players.size
when (state.direction) {
GameDirection.CLOCKWISE -> (skipped + 1) % size
GameDirection.COUNTER_CLOCKWISE -> (skipped - 1 + size) % size
}
})
}
CardValue.DRAW_TWO -> {
val targetIndex = state.nextPlayerIndex()
val targetPlayer = state.players[targetIndex]
val (drawnDeck, drawnCards) = state.deck.draw(2)
val updatedPlayers = state.players.toMutableList()
updatedPlayers[targetIndex] = targetPlayer.copy(hand = targetPlayer.hand + drawnCards)
val nextAfterSkip = when (state.direction) {
GameDirection.CLOCKWISE -> (targetIndex + 1) % state.players.size
GameDirection.COUNTER_CLOCKWISE -> (targetIndex - 1 + state.players.size) % state.players.size
}
newState.copy(
deck = drawnDeck,
players = updatedPlayers,
currentPlayerIndex = nextAfterSkip
)
}
CardValue.WILD_DRAW_FOUR -> {
val targetIndex = state.nextPlayerIndex()
val targetPlayer = state.players[targetIndex]
val (drawnDeck, drawnCards) = newState.deck.draw(4)
val updatedPlayers = newState.players.toMutableList()
updatedPlayers[targetIndex] = targetPlayer.copy(hand = targetPlayer.hand + drawnCards)
val nextAfterSkip = when (newState.direction) {
GameDirection.CLOCKWISE -> (targetIndex + 1) % newState.players.size
GameDirection.COUNTER_CLOCKWISE -> (targetIndex - 1 + newState.players.size) % newState.players.size
}
newState.copy(
deck = drawnDeck,
players = updatedPlayers,
currentPlayerIndex = nextAfterSkip
)
}
else -> {
newState.copy(currentPlayerIndex = newState.nextPlayerIndex())
}
}
return newState
}
fun drawCard(state: GameState, playerIndex: Int): GameState {
val player = state.players[playerIndex]
var deck = state.deck
if (deck.drawPileEmpty) {
val topCard = deck.topCard ?: return state
val reshuffled = deck.discardPile.dropLast(1).shuffled(Random)
deck = Deck(drawPile = reshuffled, discardPile = listOf(topCard))
}
val (newDeck, drawnCards) = deck.draw(1)
val newPlayers = state.players.toMutableList()
newPlayers[playerIndex] = player.copy(hand = player.hand + drawnCards)
return state.copy(
players = newPlayers,
deck = newDeck,
currentPlayerIndex = state.nextPlayerIndex()
)
}
fun callUno(state: GameState, playerIndex: Int): GameState {
val player = state.players[playerIndex]
val newPlayers = state.players.toMutableList()
newPlayers[playerIndex] = player.copy(hasCalledUno = true)
return state.copy(
players = newPlayers,
lastAction = "${player.name} ruft UNO!"
)
}
fun aiPlay(state: GameState, playerIndex: Int): GameState {
val player = state.players[playerIndex]
if (player.isHuman) return state
val topCard = state.deck.topCard
val playableCards = if (topCard != null) {
player.hand.filter { it.canPlayOn(topCard, state.currentColor) }
} else {
emptyList()
}
if (playableCards.isEmpty()) {
return drawCard(state, playerIndex)
}
val nonWildCards = playableCards.filter { !it.isWild }
val cardToPlay = if (nonWildCards.isNotEmpty()) {
nonWildCards.maxByOrNull { it.value.points } ?: nonWildCards.first()
} else {
playableCards.first()
}
val chosenColor = if (cardToPlay.isWild) {
val colorCounts = player.hand.filter { !it.isWild }
.groupBy { it.color }
.mapValues { it.value.size }
colorCounts.maxByOrNull { it.value }?.key ?: CardColor.RED
} else {
null
}
var newState = playCard(state, playerIndex, cardToPlay, chosenColor)
if (player.hand.size == 2 && !player.hasCalledUno) {
newState = callUno(newState, playerIndex)
}
return newState
}
}
@@ -0,0 +1,59 @@
package com.uno.game.model
import kotlinx.serialization.Serializable
@Serializable
enum class CardColor {
RED, GREEN, BLUE, YELLOW;
val displayName: String
get() = when (this) {
RED -> "Rot"
GREEN -> "Grün"
BLUE -> "Blau"
YELLOW -> "Gelb"
}
}
@Serializable
enum class CardValue(val points: Int) {
ZERO(0),
ONE(1), TWO(2), THREE(3), FOUR(4),
FIVE(5), SIX(6), SEVEN(7), EIGHT(8), NINE(9),
SKIP(20),
REVERSE(20),
DRAW_TWO(20),
WILD(50),
WILD_DRAW_FOUR(50);
val isActionCard: Boolean
get() = this in listOf(SKIP, REVERSE, DRAW_TWO, WILD, WILD_DRAW_FOUR)
val isWild: Boolean
get() = this in listOf(WILD, WILD_DRAW_FOUR)
}
@Serializable
data class Card(
val color: CardColor?,
val value: CardValue
) {
val isWild: Boolean get() = value.isWild
val isActionCard: Boolean get() = value.isActionCard
val displayValue: String
get() = when (value) {
CardValue.SKIP -> "🚫"
CardValue.REVERSE -> "↩️"
CardValue.DRAW_TWO -> "+2"
CardValue.WILD -> "🌈"
CardValue.WILD_DRAW_FOUR -> "+4"
else -> value.ordinal.toString()
}
fun canPlayOn(topCard: Card, currentColor: CardColor?): Boolean {
if (isWild) return true
if (color == currentColor) return true
if (value == topCard.value) return true
return false
}
}
@@ -0,0 +1,33 @@
package com.uno.game.model
import kotlinx.serialization.Serializable
@Serializable
enum class GameDirection {
CLOCKWISE, COUNTER_CLOCKWISE;
fun reverse(): GameDirection = when (this) {
CLOCKWISE -> COUNTER_CLOCKWISE
COUNTER_CLOCKWISE -> CLOCKWISE
}
}
@Serializable
data class Deck(
val drawPile: List<Card> = emptyList(),
val discardPile: List<Card> = emptyList()
) {
val topCard: Card? get() = discardPile.lastOrNull()
val drawPileEmpty: Boolean get() = drawPile.isEmpty()
val discardPileSize: Int get() = discardPile.size
fun draw(count: Int = 1): Pair<Deck, List<Card>> {
val drawn = drawPile.take(count)
val remaining = drawPile.drop(count)
return Pair(copy(drawPile = remaining), drawn)
}
fun discard(card: Card): Deck {
return copy(discardPile = discardPile + card)
}
}
@@ -0,0 +1,45 @@
package com.uno.game.model
import kotlinx.serialization.Serializable
@Serializable
enum class GamePhase {
WAITING_FOR_PLAYERS,
PLAYING,
FINISHED
}
@Serializable
enum class TurnAction {
PLAY_CARD,
DRAW_CARD,
CALL_UNO,
PASS
}
@Serializable
data class GameState(
val phase: GamePhase = GamePhase.WAITING_FOR_PLAYERS,
val players: List<Player> = emptyList(),
val currentPlayerIndex: Int = 0,
val deck: Deck = Deck(),
val direction: GameDirection = GameDirection.CLOCKWISE,
val currentColor: CardColor? = null,
val winner: Player? = null,
val pendingDraw: Int = 0,
val lastAction: String = ""
) {
val currentPlayer: Player? get() = players.getOrNull(currentPlayerIndex)
val isFinished: Boolean get() = phase == GamePhase.FINISHED
val topCard: Card? get() = deck.topCard
fun nextPlayerIndex(): Int {
val size = players.size
return when (direction) {
GameDirection.CLOCKWISE -> (currentPlayerIndex + 1) % size
GameDirection.COUNTER_CLOCKWISE -> (currentPlayerIndex - 1 + size) % size
}
}
fun findPlayer(id: String): Player? = players.find { it.id == id }
}
@@ -0,0 +1,15 @@
package com.uno.game.model
import kotlinx.serialization.Serializable
@Serializable
data class Player(
val id: String,
val name: String,
val hand: List<Card> = emptyList(),
val isHuman: Boolean = true,
val hasCalledUno: Boolean = false
) {
val cardCount: Int get() = hand.size
val isEmpty: Boolean get() = hand.isEmpty()
}
@@ -0,0 +1,733 @@
package com.uno.shared
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.*
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
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.scale
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.uno.game.GameEngine
import com.uno.game.model.*
import kotlin.math.roundToInt
// region --- Animations ---
@Composable
fun rememberPulseAnimation(): Float {
val infiniteTransition = rememberInfiniteTransition()
val pulse by infiniteTransition.animateFloat(
initialValue = 0.8f,
targetValue = 1.2f,
animationSpec = infiniteRepeatable(
animation = tween(600, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse
)
)
return pulse
}
@Composable
fun rememberGlowAnimation(): Float {
val infiniteTransition = rememberInfiniteTransition()
val glow by infiniteTransition.animateFloat(
initialValue = 0.3f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(800, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse
)
)
return glow
}
@Composable
fun AnimatedCardEntry(
card: Card,
onClick: () -> Unit,
modifier: Modifier = Modifier,
isPlayable: Boolean = true,
animationDelay: Int = 0
) {
var visible by remember { mutableStateOf(false) }
LaunchedEffect(card) {
visible = false
kotlinx.coroutines.delay(animationDelay.toLong())
visible = true
}
AnimatedVisibility(
visible = visible,
enter = scaleIn(
initialScale = 0f,
animationSpec = tween(300, delayMillis = animationDelay, easing = FastOutSlowInEasing)
) + fadeIn(animationSpec = tween(300, delayMillis = animationDelay))
) {
UnoCardView(card = card, onClick = onClick, modifier = modifier, isPlayable = isPlayable)
}
}
@Composable
fun AnimatedTopCard(card: Card) {
var prevState by remember { mutableStateOf(card) }
var playAnim by remember { mutableStateOf(false) }
LaunchedEffect(card) {
if (card != prevState) {
playAnim = true
kotlinx.coroutines.delay(400)
playAnim = false
prevState = card
}
}
val scale by animateFloatAsState(
targetValue = if (playAnim) 1.3f else 1f,
animationSpec = spring(dampingRatio = 0.4f, stiffness = Spring.StiffnessLow)
)
val rotation by animateFloatAsState(
targetValue = if (playAnim) 15f else 0f,
animationSpec = tween(400, easing = FastOutSlowInEasing)
)
Box(
modifier = Modifier.graphicsLayer {
scaleX = scale
scaleY = scale
rotationZ = rotation
}
) {
UnoCardView(card = card, onClick = {})
}
}
// endregion
@Composable
fun UnoGameScreen(
state: GameState,
onPlayCard: (Card, CardColor?) -> Unit,
onDrawCard: () -> Unit,
onCallUno: () -> Unit
) {
var showColorPicker by remember { mutableStateOf(false) }
var pendingWildCard by remember { mutableStateOf<Card?>(null) }
var previousTopCard by remember { mutableStateOf(state.deck.topCard) }
var showUnoCall by remember { mutableStateOf(false) }
var previousLastAction by remember { mutableStateOf(state.lastAction) }
LaunchedEffect(state.lastAction) {
if (state.lastAction != previousLastAction && state.lastAction.contains("UNO")) {
showUnoCall = true
kotlinx.coroutines.delay(1500)
showUnoCall = false
}
previousLastAction = state.lastAction
}
LaunchedEffect(state.deck.topCard) {
previousTopCard = state.deck.topCard
}
Box(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
GameInfoBar(state)
Spacer(modifier = Modifier.height(12.dp))
OpponentHands(state)
Spacer(modifier = Modifier.height(12.dp))
DrawPileAndTopCard(state, onDrawCard)
Spacer(modifier = Modifier.height(12.dp))
PlayerHand(
player = state.players.firstOrNull(),
topCard = state.deck.topCard,
currentColor = state.currentColor,
isCurrentTurn = state.currentPlayerIndex == 0,
onCardClick = { card ->
if (card.isWild) {
pendingWildCard = card
showColorPicker = true
} else {
onPlayCard(card, null)
}
}
)
Spacer(modifier = Modifier.height(8.dp))
ActionButtons(state, onDrawCard, onCallUno)
AnimatedContent(
targetState = state.lastAction,
transitionSpec = {
slideInHorizontally { it } + fadeIn() togetherWith
slideOutHorizontally { -it } + fadeOut()
},
label = "action"
) { action ->
if (action.isNotEmpty()) {
Text(
text = action,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 8.dp)
)
}
}
}
// UNO call overlay
AnimatedVisibility(
visible = showUnoCall,
enter = scaleIn(
initialScale = 0f,
animationSpec = spring(dampingRatio = 0.3f, stiffness = Spring.StiffnessLow)
) + fadeIn(),
exit = scaleOut(targetScale = 2f) + fadeOut(),
modifier = Modifier.align(Alignment.Center)
) {
Text(
text = "UNO!",
fontSize = 72.sp,
fontWeight = FontWeight.ExtraBold,
color = Color(0xFFFF6D00),
modifier = Modifier
.shadow(16.dp, RoundedCornerShape(16.dp))
.background(Color.White.copy(alpha = 0.9f), RoundedCornerShape(16.dp))
.padding(horizontal = 32.dp, vertical = 16.dp)
)
}
// Winner celebration overlay
if (state.isFinished) {
WinnerCelebration(state.winner) {
// dismiss handled by caller
}
}
}
if (showColorPicker) {
AnimatedColorPickerDialog(
onColorSelected = { color ->
pendingWildCard?.let { onPlayCard(it, color) }
showColorPicker = false
pendingWildCard = null
},
onDismiss = {
showColorPicker = false
pendingWildCard = null
}
)
}
}
// region --- GameInfoBar ---
@Composable
private fun GameInfoBar(state: GameState) {
val directionRotation by animateFloatAsState(
targetValue = if (state.direction == GameDirection.CLOCKWISE) 0f else 180f,
animationSpec = tween(500, easing = FastOutSlowInEasing),
label = "direction"
)
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer)
) {
Row(
modifier = Modifier.fillMaxWidth().padding(12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "Richtung:",
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "↗️",
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.graphicsLayer {
rotationZ = directionRotation
}
)
}
Text(
text = "Stapel: ${state.deck.drawPile.size}",
style = MaterialTheme.typography.bodyMedium
)
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "Farbe: ",
style = MaterialTheme.typography.bodyMedium
)
state.currentColor?.let { color ->
val bgColor = when (color) {
CardColor.RED -> Color(0xFFE53935)
CardColor.GREEN -> Color(0xFF43A047)
CardColor.BLUE -> Color(0xFF1E88E5)
CardColor.YELLOW -> Color(0xFFFDD835)
}
Box(
modifier = Modifier
.size(14.dp)
.clip(RoundedCornerShape(3.dp))
.background(bgColor)
)
}
}
}
}
}
// endregion
// region --- OpponentHands ---
@Composable
private fun OpponentHands(state: GameState) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
state.players.drop(1).forEach { player ->
OpponentHandView(player, isActive = state.currentPlayerIndex == state.players.indexOf(player))
}
}
}
@Composable
private fun OpponentHandView(player: Player, isActive: Boolean) {
val pulse = if (isActive) rememberPulseAnimation() else 1f
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.scale(pulse)
) {
if (isActive) {
Box(
modifier = Modifier
.size(8.dp)
.clip(RoundedCornerShape(4.dp))
.background(MaterialTheme.colorScheme.primary)
)
Spacer(modifier = Modifier.height(2.dp))
}
Text(
text = player.name,
style = MaterialTheme.typography.labelMedium,
fontWeight = if (isActive) FontWeight.ExtraBold else FontWeight.Bold,
color = if (isActive) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(4.dp))
Row {
repeat(minOf(player.hand.size, 5)) {
AnimatedVisibility(
visible = true,
enter = scaleIn(
initialScale = 0f,
animationSpec = tween(200, delayMillis = it * 50)
)
) {
CardBack()
}
}
if (player.hand.size > 5) {
Text("+${player.hand.size - 5}", fontSize = 10.sp, modifier = Modifier.padding(start = 2.dp))
}
}
Text(
text = "${player.hand.size} Karten",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Composable
private fun CardBack() {
Box(
modifier = Modifier
.size(width = 30.dp, height = 45.dp)
.clip(RoundedCornerShape(4.dp))
.background(Color(0xFF1A237E))
.border(1.dp, Color.White, RoundedCornerShape(4.dp))
.padding(2.dp),
contentAlignment = Alignment.Center
) {
Text("UNO", color = Color.White, fontSize = 7.sp, fontWeight = FontWeight.Bold)
}
}
// endregion
// region --- DrawPileAndTopCard ---
@Composable
private fun DrawPileAndTopCard(state: GameState, onDrawCard: () -> Unit) {
var drawHover by remember { mutableStateOf(false) }
val drawScale by animateFloatAsState(
targetValue = if (drawHover) 1.1f else 1f,
animationSpec = spring(dampingRatio = 0.5f)
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.scale(drawScale)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
drawHover = true
tryAwaitRelease()
drawHover = false
},
onTap = { onDrawCard() }
)
}
) {
CardBack()
Spacer(modifier = Modifier.height(4.dp))
Text("Ziehen", style = MaterialTheme.typography.labelSmall)
}
Spacer(modifier = Modifier.width(32.dp))
state.deck.topCard?.let { card ->
AnimatedTopCard(card = card)
}
}
}
// endregion
// region --- UnoCardView ---
@Composable
fun UnoCardView(
card: Card,
onClick: () -> Unit,
modifier: Modifier = Modifier,
isPlayable: Boolean = true
) {
val glowAlpha = if (isPlayable) rememberGlowAnimation() else 0f
val backgroundColor = when (card.color) {
CardColor.RED -> Color(0xFFE53935)
CardColor.GREEN -> Color(0xFF43A047)
CardColor.BLUE -> Color(0xFF1E88E5)
CardColor.YELLOW -> Color(0xFFFDD835)
null -> Color(0xFF424242)
}
val textColor = if (card.color == CardColor.YELLOW) Color.Black else Color.White
Box(
modifier = modifier
.size(width = 50.dp, height = 75.dp)
.shadow(
elevation = if (isPlayable) (4 * glowAlpha).dp else 2.dp,
shape = RoundedCornerShape(6.dp),
ambientColor = backgroundColor,
spotColor = backgroundColor
)
.clip(RoundedCornerShape(6.dp))
.background(backgroundColor)
.then(
if (isPlayable) {
Modifier.border(
width = (1.5f + glowAlpha * 1.5f).dp,
color = Color.White.copy(alpha = glowAlpha * 0.8f),
shape = RoundedCornerShape(6.dp)
)
} else {
Modifier.border(2.dp, Color.Gray.copy(alpha = 0.5f), RoundedCornerShape(6.dp))
}
)
.clickable(enabled = isPlayable) { onClick() }
.padding(4.dp),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = card.displayValue,
color = textColor,
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center
)
if (card.color != null) {
Text(text = card.color.displayName, color = textColor, fontSize = 7.sp)
}
}
}
}
// endregion
// region --- PlayerHand ---
@Composable
private fun PlayerHand(
player: Player?,
topCard: Card?,
currentColor: CardColor?,
isCurrentTurn: Boolean,
onCardClick: (Card) -> Unit
) {
if (player == null) return
val turnScale by animateFloatAsState(
targetValue = if (isCurrentTurn) 1.02f else 1f,
animationSpec = spring(dampingRatio = 0.6f)
)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.scale(turnScale)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (isCurrentTurn) {
val dotPulse = rememberPulseAnimation()
Box(
modifier = Modifier
.size((8 * dotPulse).dp)
.clip(RoundedCornerShape(4.dp))
.background(MaterialTheme.colorScheme.primary)
)
Spacer(modifier = Modifier.width(6.dp))
}
Text(
text = "${player.name} (${player.hand.size} Karten)",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = if (isCurrentTurn) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurface
)
}
Spacer(modifier = Modifier.height(8.dp))
LazyRow(
horizontalArrangement = Arrangement.spacedBy((-8).dp),
contentPadding = PaddingValues(horizontal = 16.dp)
) {
items(player.hand, key = { "${it.color}_${it.value}_${player.hand.indexOf(it)}" }) { card ->
val canPlay = topCard != null && card.canPlayOn(topCard, currentColor)
AnimatedCardEntry(
card = card,
onClick = { onCardClick(card) },
modifier = Modifier.padding(horizontal = 4.dp),
isPlayable = canPlay && isCurrentTurn,
animationDelay = player.hand.indexOf(card) * 30
)
}
}
}
}
// endregion
// region --- ActionButtons ---
@Composable
private fun ActionButtons(
state: GameState,
onDrawCard: () -> Unit,
onCallUno: () -> Unit
) {
val isMyTurn = state.currentPlayerIndex == 0
val player = state.players.firstOrNull()
val hasTwoCards = player?.hand?.size == 2
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(onClick = onDrawCard, enabled = isMyTurn) {
Text("Karte ziehen")
}
val unoScale by animateFloatAsState(
targetValue = if (hasTwoCards && isMyTurn && !(player?.hasCalledUno ?: true)) 1.1f else 1f,
animationSpec = infiniteRepeatable(
animation = tween(500, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse
)
)
Button(
onClick = onCallUno,
enabled = isMyTurn && hasTwoCards && !(player?.hasCalledUno ?: true),
modifier = Modifier.scale(unoScale),
colors = ButtonDefaults.buttonColors(
containerColor = if (hasTwoCards) Color(0xFFFF6D00)
else MaterialTheme.colorScheme.primary
)
) {
Text("UNO!")
}
}
}
// endregion
// region --- WinnerCelebration ---
@Composable
fun WinnerCelebration(winner: Player?, onDismiss: () -> Unit) {
val infiniteTransition = rememberInfiniteTransition()
val celebrationScale by infiniteTransition.animateFloat(
initialValue = 0.8f,
targetValue = 1.1f,
animationSpec = infiniteRepeatable(
animation = tween(500, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse
)
)
val celebrationRotation by infiniteTransition.animateFloat(
initialValue = -3f,
targetValue = 3f,
animationSpec = infiniteRepeatable(
animation = tween(400, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse
)
)
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
text = "🎉 Gewonnen! 🎉",
modifier = Modifier
.fillMaxWidth()
.graphicsLayer {
scaleX = celebrationScale
scaleY = celebrationScale
rotationZ = celebrationRotation
},
textAlign = TextAlign.Center,
fontSize = 28.sp,
fontWeight = FontWeight.ExtraBold,
color = MaterialTheme.colorScheme.primary
)
},
text = {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = winner?.name ?: "Unbekannt",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(8.dp))
Text("Herzlichen Glückwunsch!", style = MaterialTheme.typography.bodyLarge)
}
},
confirmButton = {
Button(
onClick = onDismiss,
modifier = Modifier.scale(celebrationScale)
) {
Text("Nochmal spielen")
}
}
)
}
// endregion
// region --- AnimatedColorPickerDialog ---
@Composable
fun AnimatedColorPickerDialog(
onColorSelected: (CardColor) -> Unit,
onDismiss: () -> Unit
) {
var visible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { visible = true }
AlertDialog(
onDismissRequest = onDismiss,
title = {
AnimatedVisibility(
visible = visible,
enter = slideInVertically(initialOffsetY = { -it / 2 }) + fadeIn()
) {
Text("Farbe wählen")
}
},
text = {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
CardColor.entries.forEachIndexed { index, color ->
val bgColor = when (color) {
CardColor.RED -> Color(0xFFE53935)
CardColor.GREEN -> Color(0xFF43A047)
CardColor.BLUE -> Color(0xFF1E88E5)
CardColor.YELLOW -> Color(0xFFFDD835)
}
AnimatedVisibility(
visible = visible,
enter = scaleIn(
initialScale = 0f,
animationSpec = tween(300, delayMillis = index * 80, easing = FastOutSlowInEasing)
) + fadeIn(tween(300, delayMillis = index * 80))
) {
Box(
modifier = Modifier
.size(60.dp)
.clip(RoundedCornerShape(8.dp))
.background(bgColor)
.clickable { onColorSelected(color) },
contentAlignment = Alignment.Center
) {
Text(
text = color.displayName,
color = if (color == CardColor.YELLOW) Color.Black else Color.White,
fontWeight = FontWeight.Bold
)
}
}
}
}
},
confirmButton = {}
)
}
// endregion