diff --git a/shared/src/commonMain/kotlin/com/uno/shared/UnoGameScreen.kt b/shared/src/commonMain/kotlin/com/uno/shared/UnoGameScreen.kt index 6a44842..782d34e 100644 --- a/shared/src/commonMain/kotlin/com/uno/shared/UnoGameScreen.kt +++ b/shared/src/commonMain/kotlin/com/uno/shared/UnoGameScreen.kt @@ -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(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(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()