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:
@@ -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)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
delay(200)
|
||||||
|
isActive = false
|
||||||
|
currentCard = null
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val animatedOffsetY by animateFloatAsState(
|
@Composable
|
||||||
targetValue = if (hasAnimated) 0f else if (fromBottom) 300f else -300f,
|
fun FlyingCardOverlay(
|
||||||
animationSpec = spring(dampingRatio = 0.5f, stiffness = Spring.StiffnessMedium),
|
state: FlyingCardState,
|
||||||
label = "flyY"
|
screenCenterX: Float,
|
||||||
)
|
screenCenterY: Float
|
||||||
val animatedScale by animateFloatAsState(
|
) {
|
||||||
targetValue = if (hasAnimated) 1f else 0.4f,
|
if (!state.isActive || state.card == null) return
|
||||||
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() }
|
|
||||||
|
|
||||||
|
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
|
|
||||||
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),
|
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Column(
|
Box(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
modifier = Modifier
|
||||||
verticalArrangement = Arrangement.Center
|
.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)
|
Column(
|
||||||
if (card.color != null) {
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
Text(card.color.displayName, color = textColor, fontSize = 8.sp)
|
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 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 {
|
||||||
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))
|
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))
|
||||||
|
|
||||||
state.deck.topCard?.let { card ->
|
// Top card with flying animation
|
||||||
val fromBottom = lastPlayedByPlayerIndex == 0
|
Box(modifier = Modifier.onGloballyPositioned { coords ->
|
||||||
FlyingTopCard(card = card, fromBottom = fromBottom, key = flyingCardKey)
|
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 ---
|
// 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()
|
||||||
|
|||||||
Reference in New Issue
Block a user