fix: flying card now animates from player hand to discard pile

Restored FlyingCardOverlay approach with correct target position.
Tracks discard pile position via onGloballyPositioned and animates
card from hand position to that exact target using coroutine-based
Animatable for smooth spring physics.
This commit is contained in:
2026-08-02 18:45:27 +02:00
parent 673ed88d1c
commit f0fba426dc
@@ -18,13 +18,20 @@ import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInWindow
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.uno.game.GameEngine import com.uno.game.GameEngine
import com.uno.game.model.* import com.uno.game.model.*
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.coroutineScope
import kotlin.math.roundToInt
// region --- Animations --- // region --- Animations ---
@@ -68,37 +75,70 @@ fun AnimatedCardEntry(
} }
} }
@Composable // Flying card state tracking which player just played
fun FlyingTopCard( class FlyingCardState {
card: Card, var isActive by mutableStateOf(false)
fromBottom: Boolean, private set
key: Any var fromX by mutableStateOf(0f)
) { private set
var hasAnimated by remember(key) { mutableStateOf(false) } var fromY by mutableStateOf(0f)
private set
var card by mutableStateOf<Card?>(null)
private set
var isWild by mutableStateOf(false)
private set
LaunchedEffect(key) { private val animX = Animatable(0f)
hasAnimated = false private val animY = Animatable(0f)
delay(50) private val animScale = Animatable(0.3f)
hasAnimated = true private val animRotation = Animatable(0f)
val x by animX.asState()
val y by animY.asState()
val scale by animScale.asState()
val rotation by animRotation.asState()
var currentCard by mutableStateOf<Card?>(null)
private set
suspend fun flyTo(
c: Card, startX: Float, startY: Float,
targetX: Float, targetY: Float
) {
card = c
isWild = c.isWild
fromX = startX
fromY = startY
isActive = true
currentCard = c
animX.snapTo(startX - targetX)
animY.snapTo(startY - targetY)
animScale.snapTo(0.3f)
animRotation.snapTo(0f)
kotlinx.coroutines.coroutineScope {
launch { animX.animateTo(0f, spring(dampingRatio = 0.5f, stiffness = Spring.StiffnessMedium)) }
launch { animY.animateTo(0f, spring(dampingRatio = 0.5f, stiffness = Spring.StiffnessMedium)) }
launch { animScale.animateTo(1f, spring(dampingRatio = 0.5f, stiffness = Spring.StiffnessMedium)) }
launch { animRotation.animateTo((Math.random() * 30 - 15).toFloat(), spring(dampingRatio = 0.6f, stiffness = Spring.StiffnessLow)) }
} }
val animatedOffsetY by animateFloatAsState( delay(200)
targetValue = if (hasAnimated) 0f else if (fromBottom) 300f else -300f, isActive = false
animationSpec = spring(dampingRatio = 0.5f, stiffness = Spring.StiffnessMedium), currentCard = null
label = "flyY" }
) }
val animatedScale by animateFloatAsState(
targetValue = if (hasAnimated) 1f else 0.4f,
animationSpec = spring(dampingRatio = 0.5f, stiffness = Spring.StiffnessMedium),
label = "flyScale"
)
val animatedRotation by animateFloatAsState(
targetValue = 0f,
animationSpec = spring(dampingRatio = 0.6f, stiffness = Spring.StiffnessLow),
label = "flyRotation"
)
val initialRotation = remember(key) { (Math.random() * 40 - 20).toFloat() }
@Composable
fun FlyingCardOverlay(
state: FlyingCardState,
screenCenterX: Float,
screenCenterY: Float
) {
if (!state.isActive || state.card == null) return
val card = state.card!!
val backgroundColor = when (card.color) { val backgroundColor = when (card.color) {
CardColor.RED -> Color(0xFFE53935) CardColor.RED -> Color(0xFFE53935)
CardColor.GREEN -> Color(0xFF43A047) CardColor.GREEN -> Color(0xFF43A047)
@@ -110,28 +150,31 @@ fun FlyingTopCard(
Box( Box(
modifier = Modifier modifier = Modifier
.graphicsLayer { .fillMaxSize()
translationY = animatedOffsetY .pointerInput(Unit) {},
scaleX = animatedScale contentAlignment = Alignment.Center
scaleY = animatedScale ) {
rotationZ = if (hasAnimated) animatedRotation else initialRotation Box(
shadowElevation = if (hasAnimated) 8f else 20f modifier = Modifier
} .offset { IntOffset(state.x.roundToInt(), state.y.roundToInt()) }
.size(width = 55.dp, height = 82.dp) .scale(state.scale)
.shadow(10.dp, RoundedCornerShape(8.dp)) .graphicsLayer { rotationZ = state.rotation; shadowElevation = 20f }
.size(width = 60.dp, height = 90.dp)
.shadow(12.dp, RoundedCornerShape(8.dp))
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
.background(backgroundColor) .background(backgroundColor)
.border(2.dp, Color.White.copy(alpha = 0.7f), RoundedCornerShape(8.dp)) .border(2.dp, Color.White.copy(alpha = 0.7f), RoundedCornerShape(8.dp))
.padding(5.dp), .padding(6.dp),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center verticalArrangement = Arrangement.Center
) { ) {
Text(card.displayValue, color = textColor, fontSize = 20.sp, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center) Text(card.displayValue, color = textColor, fontSize = 22.sp, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center)
if (card.color != null) { if (card.color != null) {
Text(card.color.displayName, color = textColor, fontSize = 8.sp) Text(card.color.displayName, color = textColor, fontSize = 9.sp)
}
} }
} }
} }
@@ -151,23 +194,23 @@ fun UnoGameScreen(
var showUnoCall by remember { mutableStateOf(false) } var showUnoCall by remember { mutableStateOf(false) }
var previousLastAction by remember { mutableStateOf(state.lastAction) } var previousLastAction by remember { mutableStateOf(state.lastAction) }
var previousTopCard by remember { mutableStateOf(state.deck.topCard) } var previousTopCard by remember { mutableStateOf(state.deck.topCard) }
var lastPlayedByPlayerIndex by remember { mutableIntStateOf(0) }
var flyingCardKey by remember { mutableIntStateOf(0) } // Track player hand positions (screen coordinates)
var playerHandPosition by remember { mutableStateOf(IntOffset.Zero) }
// Track discard pile position (screen coordinates)
var discardPilePosition by remember { mutableStateOf(IntOffset.Zero) }
val flyingState = remember { FlyingCardState() }
val coroutineScope = rememberCoroutineScope()
// Detect when top card changes → trigger flying animation // Detect when top card changes → trigger flying animation
LaunchedEffect(state.deck.topCard) { LaunchedEffect(state.deck.topCard) {
val newTop = state.deck.topCard val newTop = state.deck.topCard
if (newTop != null && newTop != previousTopCard) { if (newTop != null && newTop != previousTopCard) {
flyingCardKey++
previousTopCard = newTop previousTopCard = newTop
} }
} }
// Track who is about to play
LaunchedEffect(state.currentPlayerIndex) {
lastPlayedByPlayerIndex = state.currentPlayerIndex
}
// UNO call overlay // UNO call overlay
LaunchedEffect(state.lastAction) { LaunchedEffect(state.lastAction) {
if (state.lastAction != previousLastAction && state.lastAction.contains("UNO")) { if (state.lastAction != previousLastAction && state.lastAction.contains("UNO")) {
@@ -187,23 +230,44 @@ fun UnoGameScreen(
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
OpponentHands(state) OpponentHands(state)
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
DrawPileAndTopCard(state, onDrawCard, lastPlayedByPlayerIndex, flyingCardKey)
// Discard pile with position tracking
DrawPileAndTopCard(
state = state,
onDrawCard = onDrawCard,
onPositionMeasured = { pos -> discardPilePosition = pos }
)
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
// Player hand with position tracking
PlayerHand( PlayerHand(
player = state.players.firstOrNull(), player = state.players.firstOrNull(),
topCard = state.deck.topCard, topCard = state.deck.topCard,
currentColor = state.currentColor, currentColor = state.currentColor,
isCurrentTurn = state.currentPlayerIndex == 0, isCurrentTurn = state.currentPlayerIndex == 0,
onCardClick = { card -> onCardClick = { card ->
lastPlayedByPlayerIndex = 0 // Trigger flying animation from hand to discard pile
if (card.isWild) { if (card.isWild) {
pendingWildCard = card pendingWildCard = card
showColorPicker = true showColorPicker = true
} else { } else {
coroutineScope.launch {
flyingState.flyTo(
c = card,
startX = playerHandPosition.x.toFloat(),
startY = playerHandPosition.y.toFloat(),
targetX = discardPilePosition.x.toFloat(),
targetY = discardPilePosition.y.toFloat()
)
delay(100)
onPlayCard(card, null) onPlayCard(card, null)
} }
} }
},
onPositionMeasured = { pos -> playerHandPosition = pos }
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
ActionButtons(state, onDrawCard, onCallUno) ActionButtons(state, onDrawCard, onCallUno)
@@ -221,6 +285,13 @@ fun UnoGameScreen(
} }
} }
// Flying card overlay (renders on top of everything)
FlyingCardOverlay(
state = flyingState,
screenCenterX = discardPilePosition.x.toFloat(),
screenCenterY = discardPilePosition.y.toFloat()
)
// UNO call overlay // UNO call overlay
AnimatedVisibility( AnimatedVisibility(
visible = showUnoCall, visible = showUnoCall,
@@ -244,7 +315,19 @@ fun UnoGameScreen(
if (showColorPicker) { if (showColorPicker) {
AnimatedColorPickerDialog( AnimatedColorPickerDialog(
onColorSelected = { color -> onColorSelected = { color ->
pendingWildCard?.let { onPlayCard(it, color) } pendingWildCard?.let { card ->
coroutineScope.launch {
flyingState.flyTo(
c = card,
startX = playerHandPosition.x.toFloat(),
startY = playerHandPosition.y.toFloat(),
targetX = discardPilePosition.x.toFloat(),
targetY = discardPilePosition.y.toFloat()
)
delay(100)
onPlayCard(card, color)
}
}
showColorPicker = false showColorPicker = false
pendingWildCard = null pendingWildCard = null
}, },
@@ -328,11 +411,23 @@ private fun CardBack() {
// region --- DrawPileAndTopCard --- // region --- DrawPileAndTopCard ---
@Composable @Composable
private fun DrawPileAndTopCard(state: GameState, onDrawCard: () -> Unit, lastPlayedByPlayerIndex: Int, flyingCardKey: Int) { private fun DrawPileAndTopCard(
state: GameState,
onDrawCard: () -> Unit,
onPositionMeasured: (IntOffset) -> Unit
) {
var drawHover by remember { mutableStateOf(false) } var drawHover by remember { mutableStateOf(false) }
val drawScale by animateFloatAsState(targetValue = if (drawHover) 1.1f else 1f, animationSpec = spring(dampingRatio = 0.5f)) val drawScale by animateFloatAsState(targetValue = if (drawHover) 1.1f else 1f, animationSpec = spring(dampingRatio = 0.5f))
val density = LocalDensity.current
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) { Row(
modifier = Modifier.fillMaxWidth().onGloballyPositioned { coords ->
val pos = coords.positionInWindow()
onPositionMeasured(IntOffset(pos.x.roundToInt(), pos.y.roundToInt()))
},
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.scale(drawScale).pointerInput(Unit) { modifier = Modifier.scale(drawScale).pointerInput(Unit) {
@@ -349,9 +444,39 @@ private fun DrawPileAndTopCard(state: GameState, onDrawCard: () -> Unit, lastPla
Spacer(modifier = Modifier.width(32.dp)) Spacer(modifier = Modifier.width(32.dp))
// Top card with flying animation
Box(modifier = Modifier.onGloballyPositioned { coords ->
val pos = coords.positionInWindow()
onPositionMeasured(IntOffset(pos.x.roundToInt(), pos.y.roundToInt()))
}) {
state.deck.topCard?.let { card -> state.deck.topCard?.let { card ->
val fromBottom = lastPlayedByPlayerIndex == 0 TopCardView(card)
FlyingTopCard(card = card, fromBottom = fromBottom, key = flyingCardKey) }
}
}
}
@Composable
private fun TopCardView(card: Card) {
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 = 60.dp, height = 90.dp)
.shadow(8.dp, RoundedCornerShape(8.dp))
.clip(RoundedCornerShape(8.dp))
.background(backgroundColor)
.border(2.dp, Color.White.copy(alpha = 0.7f), RoundedCornerShape(8.dp))
.padding(6.dp),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
Text(card.displayValue, color = textColor, fontSize = 22.sp, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center)
if (card.color != null) { Text(card.color.displayName, color = textColor, fontSize = 9.sp) }
} }
} }
} }
@@ -387,11 +512,23 @@ fun UnoCardView(card: Card, onClick: () -> Unit, modifier: Modifier = Modifier,
// region --- PlayerHand --- // region --- PlayerHand ---
@Composable @Composable
private fun PlayerHand(player: Player?, topCard: Card?, currentColor: CardColor?, isCurrentTurn: Boolean, onCardClick: (Card) -> Unit) { private fun PlayerHand(
player: Player?, topCard: Card?, currentColor: CardColor?, isCurrentTurn: Boolean,
onCardClick: (Card) -> Unit, onPositionMeasured: (IntOffset) -> Unit
) {
if (player == null) return if (player == null) return
val turnScale by animateFloatAsState(targetValue = if (isCurrentTurn) 1.02f else 1f, animationSpec = spring(dampingRatio = 0.6f)) val turnScale by animateFloatAsState(targetValue = if (isCurrentTurn) 1.02f else 1f, animationSpec = spring(dampingRatio = 0.6f))
val density = LocalDensity.current
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.scale(turnScale)) { Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.scale(turnScale)
.onGloballyPositioned { coords ->
val pos = coords.positionInWindow()
onPositionMeasured(IntOffset(pos.x.roundToInt(), pos.y.roundToInt()))
}
) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
if (isCurrentTurn) { if (isCurrentTurn) {
val dotPulse = rememberPulseAnimation() val dotPulse = rememberPulseAnimation()