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.graphicsLayer
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.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 kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.coroutineScope
import kotlin.math.roundToInt
// region --- Animations ---
@@ -68,37 +75,70 @@ fun AnimatedCardEntry(
}
}
@Composable
fun FlyingTopCard(
card: Card,
fromBottom: Boolean,
key: Any
) {
var hasAnimated by remember(key) { mutableStateOf(false) }
// Flying card state tracking which player just played
class FlyingCardState {
var isActive by mutableStateOf(false)
private set
var fromX by mutableStateOf(0f)
private set
var fromY by mutableStateOf(0f)
private set
var card by mutableStateOf<Card?>(null)
private set
var isWild by mutableStateOf(false)
private set
LaunchedEffect(key) {
hasAnimated = false
delay(50)
hasAnimated = true
private val animX = Animatable(0f)
private val animY = Animatable(0f)
private val animScale = Animatable(0.3f)
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)) }
}
delay(200)
isActive = false
currentCard = null
}
}
val animatedOffsetY by animateFloatAsState(
targetValue = if (hasAnimated) 0f else if (fromBottom) 300f else -300f,
animationSpec = spring(dampingRatio = 0.5f, stiffness = Spring.StiffnessMedium),
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) {
CardColor.RED -> Color(0xFFE53935)
CardColor.GREEN -> Color(0xFF43A047)
@@ -110,28 +150,31 @@ fun FlyingTopCard(
Box(
modifier = Modifier
.graphicsLayer {
translationY = animatedOffsetY
scaleX = animatedScale
scaleY = animatedScale
rotationZ = if (hasAnimated) animatedRotation else initialRotation
shadowElevation = if (hasAnimated) 8f else 20f
}
.size(width = 55.dp, height = 82.dp)
.shadow(10.dp, RoundedCornerShape(8.dp))
.clip(RoundedCornerShape(8.dp))
.background(backgroundColor)
.border(2.dp, Color.White.copy(alpha = 0.7f), RoundedCornerShape(8.dp))
.padding(5.dp),
.fillMaxSize()
.pointerInput(Unit) {},
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
Box(
modifier = Modifier
.offset { IntOffset(state.x.roundToInt(), state.y.roundToInt()) }
.scale(state.scale)
.graphicsLayer { rotationZ = state.rotation; shadowElevation = 20f }
.size(width = 60.dp, height = 90.dp)
.shadow(12.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
) {
Text(card.displayValue, color = textColor, fontSize = 20.sp, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center)
if (card.color != null) {
Text(card.color.displayName, color = textColor, fontSize = 8.sp)
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)
}
}
}
}
@@ -151,23 +194,23 @@ fun UnoGameScreen(
var showUnoCall by remember { mutableStateOf(false) }
var previousLastAction by remember { mutableStateOf(state.lastAction) }
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
LaunchedEffect(state.deck.topCard) {
val newTop = state.deck.topCard
if (newTop != null && newTop != previousTopCard) {
flyingCardKey++
previousTopCard = newTop
}
}
// Track who is about to play
LaunchedEffect(state.currentPlayerIndex) {
lastPlayedByPlayerIndex = state.currentPlayerIndex
}
// UNO call overlay
LaunchedEffect(state.lastAction) {
if (state.lastAction != previousLastAction && state.lastAction.contains("UNO")) {
@@ -187,23 +230,44 @@ fun UnoGameScreen(
Spacer(modifier = Modifier.height(12.dp))
OpponentHands(state)
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))
// Player hand with position tracking
PlayerHand(
player = state.players.firstOrNull(),
topCard = state.deck.topCard,
currentColor = state.currentColor,
isCurrentTurn = state.currentPlayerIndex == 0,
onCardClick = { card ->
lastPlayedByPlayerIndex = 0
// Trigger flying animation from hand to discard pile
if (card.isWild) {
pendingWildCard = card
showColorPicker = true
} else {
onPlayCard(card, null)
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)
}
}
}
},
onPositionMeasured = { pos -> playerHandPosition = pos }
)
Spacer(modifier = Modifier.height(8.dp))
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
AnimatedVisibility(
visible = showUnoCall,
@@ -244,7 +315,19 @@ fun UnoGameScreen(
if (showColorPicker) {
AnimatedColorPickerDialog(
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
pendingWildCard = null
},
@@ -328,11 +411,23 @@ private fun CardBack() {
// region --- DrawPileAndTopCard ---
@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) }
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(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.scale(drawScale).pointerInput(Unit) {
@@ -349,9 +444,39 @@ private fun DrawPileAndTopCard(state: GameState, onDrawCard: () -> Unit, lastPla
Spacer(modifier = Modifier.width(32.dp))
state.deck.topCard?.let { card ->
val fromBottom = lastPlayedByPlayerIndex == 0
FlyingTopCard(card = card, fromBottom = fromBottom, key = flyingCardKey)
// 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 ->
TopCardView(card)
}
}
}
}
@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 ---
@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
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) {
if (isCurrentTurn) {
val dotPulse = rememberPulseAnimation()