diff --git a/app/src/main/java/org/prolibertate/games/game/kaiser/KaiserModel.kt b/app/src/main/java/org/prolibertate/games/game/kaiser/KaiserModel.kt index 127e5d2..233e659 100644 --- a/app/src/main/java/org/prolibertate/games/game/kaiser/KaiserModel.kt +++ b/app/src/main/java/org/prolibertate/games/game/kaiser/KaiserModel.kt @@ -92,6 +92,14 @@ data class KaiserState( val lastTrickWinner: Int?, /** Points taken this hand, per team. */ val handPoints: List, + /** + * Tricks taken this hand, per seat. Kaiser scores in points rather than + * tricks, so nothing in the rules needs this — it is what the table draws + * in front of each player, and a player who has taken four tricks and no + * counters has still taken four tricks. Defaulted so a client on an older + * build can still read a state from the wire. + */ + val tricksWon: List = List(4) { 0 }, val scores: List, val log: List, ) diff --git a/app/src/main/java/org/prolibertate/games/game/kaiser/KaiserRules.kt b/app/src/main/java/org/prolibertate/games/game/kaiser/KaiserRules.kt index de33e81..5fe41c7 100644 --- a/app/src/main/java/org/prolibertate/games/game/kaiser/KaiserRules.kt +++ b/app/src/main/java/org/prolibertate/games/game/kaiser/KaiserRules.kt @@ -62,6 +62,7 @@ object KaiserRules : GameRules { leader = (dealer + 1) % 4, lastTrickWinner = null, handPoints = listOf(0, 0), + tricksWon = List(4) { 0 }, scores = scores, log = log, ) @@ -224,10 +225,14 @@ object KaiserRules : GameRules { val handPoints = played.handPoints.toMutableList() handPoints[teamOf(winner)] = handPoints[teamOf(winner)] + points + val tricksWon = played.tricksWon.toMutableList() + tricksWon[winner] = tricksWon[winner] + 1 + val afterTrick = played.copy( trick = emptyList(), completedTrick = trick, handPoints = handPoints, + tricksWon = tricksWon, leader = winner, turn = winner, lastTrickWinner = winner, diff --git a/app/src/main/java/org/prolibertate/games/ui/game/CardPhysics.kt b/app/src/main/java/org/prolibertate/games/ui/game/CardPhysics.kt index 82319f7..d766cca 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/CardPhysics.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/CardPhysics.kt @@ -3,6 +3,7 @@ package org.prolibertate.games.ui.game import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.spring import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding @@ -16,6 +17,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.graphicsLayer @@ -119,6 +121,16 @@ const val CARD_SWEEP_MILLIS = 520 */ private const val SWEEP_SPREAD = 0.3f +/** + * The last stretch of the sweep, over which a gathered card fades out. It has + * to be late: the cards vanish exactly as the winner's pile grows by one, and + * that swap is only invisible if they are still whole most of the way there. + */ +private const val SWEEP_FADE = 0.42f + +/** How small four cards squared into a bundle end up against one card face. */ +private const val GATHERED_SCALE = 0.62f + // --------------------------------------------------------------------------- // The pure part: where a card lands, and how far through leaving it is. // --------------------------------------------------------------------------- @@ -260,6 +272,16 @@ fun sweepProgress(sweep: Float, rest: CardRest, spread: Float = SWEEP_SPREAD): F fun blend(from: Float, to: Float, fraction: Float): Float = from + (to - from) * fraction +/** + * How solid a card being gathered up still is. Whole for most of the journey + * and gone at the end of it, so it disappears at the moment the winner's pile + * grows rather than dissolving on the way over. + */ +fun gatherAlpha(leaving: Float): Float = ((1f - leaving) / SWEEP_FADE).coerceIn(0f, 1f) + +/** And how far it has been squared down into the bundle it is joining. */ +fun gatherScale(leaving: Float): Float = blend(1f, GATHERED_SCALE, leaving.coerceIn(0f, 1f)) + // --------------------------------------------------------------------------- // The Compose part. // --------------------------------------------------------------------------- @@ -370,6 +392,192 @@ fun Modifier.landed(landing: CardLanding): Modifier = this /** How many cards of a pile are worth drawing. Below this it is not a heap. */ private const val PILE_DEPTH = 5 +/** + * How many taken tricks are worth drawing separately. Past this the pile stops + * growing rather than climbing off the table — Wizard's last round is twenty + * tricks, and twenty bundles of stagger is not a pile, it is a staircase. + */ +private const val WON_STACK_DEPTH = 8 + +/** How far each taken trick sits off the one under it, in card widths. */ +private const val WON_STACK_STEP = 0.045f + +/** + * The tricks a player has taken, squared up and turned face down in front of + * them. + * + * One card back per trick rather than four, which is what a real table looks + * like: a trick is gathered, squared and put down as a single bundle, and from + * above you see the top card of each bundle and the edges of the rest. The + * count is the pile — it is the thing a player actually glances at to see how + * the hand is going, and it is why cards are kept rather than binned. + */ +@Composable +fun WonTrickStack( + tricks: Int, + width: Dp, + modifier: Modifier = Modifier, + facingDegrees: Float = 0f, + /** Where on the table these were gathered from, in pixels. */ + fromX: Float = 0f, + fromY: Float = 0f, +) { + if (tricks <= 0) return + + val shown = tricks.coerceAtMost(WON_STACK_DEPTH) + val step = width * WON_STACK_STEP + val widthPx = with(LocalDensity.current) { width.toPx() } + val throwMillis = motionMillis(CARD_THROW_MILLIS) + + Box( + modifier = modifier + .padding(end = step * shown, bottom = step * shown) + .width(width) + .height(width / CARD_ASPECT), + ) { + for (bundle in 0 until shown) { + key(bundle) { + // Each bundle is squared up, not thrown down, so it gets a + // fraction of the scatter a played card gets. + val rest = cardRest(bundle * 613, tilt = REST_TILT_DEGREES * 0.5f) + val top = bundle == shown - 1 + val landing = if (!top) { + CardLanding( + offsetX = 0f, + offsetY = 0f, + rotation = facingDegrees + rest.tiltDegrees, + scale = 1f, + elevation = REST_ELEVATION_DP.dp, + ) + } else { + // Only the newest bundle arrives; the ones under it have + // been sitting there since earlier in the hand. + rememberCardLanding( + key = tricks, + rest = rest.copy(driftX = 0f, driftY = 0f), + facingDegrees = facingDegrees, + fromX = fromX, + fromY = fromY, + cardWidthPx = widthPx, + durationMillis = throwMillis, + ) + } + CardBackView( + width = width, + elevation = landing.elevation, + modifier = Modifier + .offset(x = step * bundle, y = step * bundle) + .landed(landing), + ) + } + } + } +} + +/** How wide a taken-trick pile is drawn against a card on the table. */ +private const val WON_STACK_SCALE = 0.5f + +/** + * Everyone's taken tricks, at a table of four with partners opposite. + * + * Each pile sits beside its own player's card, one place clockwise along their + * edge, which is both where a real player would put them and the one spot on + * each edge that nothing else on the table is using. + */ +@Composable +fun BoxScope.TakenTricks(tricksWon: List, localSeat: Int, cardWidth: Dp) { + val stackWidth = cardWidth * WON_STACK_SCALE + val stackWidthPx = with(LocalDensity.current) { stackWidth.toPx() } + + for (seat in 0 until 4) { + val relative = ((seat - localSeat) % 4 + 4) % 4 + val alignment = when (relative) { + 0 -> Alignment.BottomCenter + 1 -> Alignment.CenterStart + 2 -> Alignment.TopCenter + else -> Alignment.CenterEnd + } + val (nudgeX, nudgeY) = when (relative) { + 0 -> cardWidth * 1.6f to 0.dp + 1 -> 0.dp to cardWidth * 1.4f + 2 -> -(cardWidth * 1.6f) to 0.dp + else -> 0.dp to -(cardWidth * 1.4f) + } + // Gathered in from the middle of the table, which is the opposite way + // from the edge this player sits at. + val (outX, outY) = seatOrigin(relative, 2.5f) + + WonTrickStack( + tricks = tricksWon.getOrElse(seat) { 0 }, + width = stackWidth, + facingDegrees = relative * 90f, + fromX = -outX * stackWidthPx, + fromY = -outY * stackWidthPx, + modifier = Modifier.align(alignment).offset(x = nudgeX, y = nudgeY), + ) + } +} + +/** + * The same, round a table of any size: each pile sits half a seat clockwise + * from its player's card, just inside the ring the cards are played on. + */ +@Composable +fun BoxScope.TakenTricksAround( + tricksWon: List, + localSeat: Int, + players: Int, + cardWidth: Dp, + radiusPx: Float, +) { + val stackWidth = cardWidth * WON_STACK_SCALE + val stackWidthPx = with(LocalDensity.current) { stackWidth.toPx() } + + for (seat in 0 until players) { + val relative = ((seat - localSeat) % players + players) % players + val angle = 2.0 * PI * relative / players + PI / players + val x = -sin(angle) * radiusPx + val y = cos(angle) * radiusPx + + WonTrickStack( + tricks = tricksWon.getOrElse(seat) { 0 }, + width = stackWidth, + facingDegrees = relative * 360f / players, + fromX = (sin(angle) * stackWidthPx * 2.5f).toFloat(), + fromY = (-cos(angle) * stackWidthPx * 2.5f).toFloat(), + modifier = Modifier + .align(Alignment.Center) + .offset { IntOffset(x.roundToInt(), y.roundToInt()) }, + ) + } +} + +/** + * Trick counts, held back until the cards have actually got there. + * + * The rules count a trick the instant its last card is played, which is a beat + * before the cards are gathered and two before they arrive. Reading the count + * straight from the state would grow the winner's pile while the trick was + * still lying in the middle of the table, and the cards would then slide over + * and land on a pile that had already counted them. + */ +@Composable +fun rememberArrivedTricks( + tricksWon: List, + sweeping: Boolean, + sweepStarted: Boolean, + sweepMillis: Int, +): List { + var arrived by remember { mutableStateOf(tricksWon) } + LaunchedEffect(tricksWon, sweeping, sweepStarted) { + // Still on the table waiting to be read: the pile has not won it yet. + if (sweeping && !sweepStarted) return@LaunchedEffect + if (sweeping) delay(sweepMillis.toLong()) + arrived = tricksWon + } + return arrived +} + /** Holder for [rememberPileWatermark]. Deliberately not snapshot state. */ private class PileWatermark(var value: Int) diff --git a/app/src/main/java/org/prolibertate/games/ui/game/EuchreScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/EuchreScreen.kt index 8f2bc38..40bfe03 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/EuchreScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/EuchreScreen.kt @@ -227,8 +227,21 @@ private fun TrickArea( val travelPx = cardWidthPx * 2.5f val throwMillis = motionMillis(CARD_THROW_MILLIS) + // The count follows the cards rather than the rules, so a pile grows as the + // trick reaches it and not the moment the last card was played. + val arrived = rememberArrivedTricks( + tricksWon = state.tricksWon, + sweeping = sweeping, + sweepStarted = sweepStarted, + sweepMillis = motionMillis(CARD_SWEEP_MILLIS), + ) + Box(modifier = Modifier.fillMaxSize().padding(4.dp)) { + // What each player has taken, face down in front of them. Drawn first + // so a trick still being gathered passes over the pile it is joining. + TakenTricks(arrived, localSeat, cardWidth) + // The turn card sits in the middle of the table during bidding. state.upCard?.let { up -> Column( @@ -300,9 +313,12 @@ private fun TrickArea( ) } .graphicsLayer { - scaleX = landing.scale - scaleY = landing.scale - alpha = landing.alpha * (1f - leaving) + // Squared down into the bundle it is joining, and + // still whole until it gets there. + val gathered = landing.scale * gatherScale(leaving) + scaleX = gathered + scaleY = gathered + alpha = landing.alpha * gatherAlpha(leaving) }, horizontalAlignment = Alignment.CenterHorizontally, ) { diff --git a/app/src/main/java/org/prolibertate/games/ui/game/HeartsScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/HeartsScreen.kt index 259d64b..53bbcb2 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/HeartsScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/HeartsScreen.kt @@ -240,6 +240,16 @@ private fun ScoreRow(state: HeartsState, localSeat: Int) { style = MaterialTheme.typography.labelSmall, fontWeight = if (seat == localSeat) FontWeight.Bold else FontWeight.Normal, ) + // The tricks this player has taken, face down in front of them. + // Hearts keeps the cards themselves rather than a count, since + // it is the cards that are scored at the end of the round, so + // the number of tricks is however many players-worth of them + // there are. + WonTrickStack( + tricks = state.taken.getOrElse(seat) { emptyList() }.size / + state.options.playerCount, + width = 26.dp, + ) Text("$score", fontWeight = FontWeight.Bold) // What this round has cost so far, which is the number anybody // actually watches while a hand is being played. diff --git a/app/src/main/java/org/prolibertate/games/ui/game/KaiserScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/KaiserScreen.kt index fe51d58..3568f59 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/KaiserScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/KaiserScreen.kt @@ -218,7 +218,21 @@ private fun TrickArea( val travelPx = cardWidthPx * 2.5f val throwMillis = motionMillis(CARD_THROW_MILLIS) + // Kaiser scores in points rather than tricks, but the cards are still won + // and still kept, and how many each player is sitting on is worth seeing. + val arrived = rememberArrivedTricks( + tricksWon = state.tricksWon, + sweeping = sweeping, + sweepStarted = sweepStarted, + sweepMillis = motionMillis(CARD_SWEEP_MILLIS), + ) + Box(modifier = Modifier.fillMaxSize().padding(4.dp)) { + + // Drawn first, so a trick still being gathered passes over the pile it + // is on its way to joining. + TakenTricks(arrived, localSeat, cardWidth) + if (cardsOnTable.isEmpty()) { Text( text = when { @@ -270,9 +284,10 @@ private fun TrickArea( ) } .graphicsLayer { - scaleX = landing.scale - scaleY = landing.scale - alpha = landing.alpha * (1f - leaving) + val gathered = landing.scale * gatherScale(leaving) + scaleX = gathered + scaleY = gathered + alpha = landing.alpha * gatherAlpha(leaving) }, horizontalAlignment = Alignment.CenterHorizontally, ) { diff --git a/app/src/main/java/org/prolibertate/games/ui/game/WizardScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/WizardScreen.kt index c780608..6227fc7 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/WizardScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/WizardScreen.kt @@ -230,6 +230,23 @@ private fun TrickArea( val travelPx = cardWidthPx * 2f val throwMillis = motionMillis(CARD_THROW_MILLIS) + val arrived = rememberArrivedTricks( + tricksWon = state.tricksWon, + sweeping = sweeping, + sweepStarted = sweepStarted, + sweepMillis = motionMillis(CARD_SWEEP_MILLIS), + ) + + // What each player has taken, face down beside their own place at the + // ring. Drawn first so a trick being gathered passes over its pile. + TakenTricksAround( + tricksWon = arrived, + localSeat = localSeat, + players = players, + cardWidth = cardWidth, + radiusPx = radiusPx, + ) + if (cardsOnTable.isEmpty()) { Text( text = when { @@ -279,9 +296,10 @@ private fun TrickArea( ) } .graphicsLayer { - scaleX = landing.scale - scaleY = landing.scale - alpha = landing.alpha * (1f - leaving) + val gathered = landing.scale * gatherScale(leaving) + scaleX = gathered + scaleY = gathered + alpha = landing.alpha * gatherAlpha(leaving) }, horizontalAlignment = Alignment.CenterHorizontally, ) { diff --git a/app/src/test/java/org/prolibertate/games/game/kaiser/KaiserRulesTest.kt b/app/src/test/java/org/prolibertate/games/game/kaiser/KaiserRulesTest.kt index 37ab58a..84876c6 100644 --- a/app/src/test/java/org/prolibertate/games/game/kaiser/KaiserRulesTest.kt +++ b/app/src/test/java/org/prolibertate/games/game/kaiser/KaiserRulesTest.kt @@ -250,6 +250,39 @@ class KaiserRulesTest { assertEquals("all four held for display", 4, state.completedTrick.size) } + /** + * Kaiser scores in points, not tricks, so nothing in the rules reads this + * count — the table does, to draw the cards each player has taken. That is + * exactly why it is worth a test: a field no rule depends on is a field + * that can quietly stop being maintained. + */ + @Test + fun `tricks are counted to the seat that took them`() { + // Everything is clubs and nothing is trump, so each trick simply goes + // to the highest card in it: the ace, then the king. + val hands = listOf( + listOf(Card(Rank.ACE, Suit.CLUBS), Card(Rank.SEVEN, Suit.CLUBS)), + listOf(Card(Rank.EIGHT, Suit.CLUBS), Card(Rank.NINE, Suit.CLUBS)), + listOf(Card(Rank.TEN, Suit.CLUBS), Card(Rank.JACK, Suit.CLUBS)), + listOf(Card(Rank.QUEEN, Suit.CLUBS), Card(Rank.KING, Suit.CLUBS)), + ) + var state = playingState(hands, trump = Suit.SPADES) + assertEquals("nothing taken at the deal", listOf(0, 0, 0, 0), state.tricksWon) + + repeat(4) { + val seat = KaiserRules.currentSeat(state)!! + state = KaiserRules.applyMove(state, seat, PlayCard(state.hands[seat].first())) + } + assertEquals("the ace takes the first", listOf(1, 0, 0, 0), state.tricksWon) + + repeat(4) { + val seat = KaiserRules.currentSeat(state)!! + state = KaiserRules.applyMove(state, seat, PlayCard(state.hands[seat].first())) + } + assertEquals("the king takes the second", listOf(1, 0, 0, 1), state.tricksWon) + assertEquals("and they add up", 2, state.tricksWon.sum()) + } + // -- Scoring ------------------------------------------------------------ @Test diff --git a/app/src/test/java/org/prolibertate/games/ui/game/CardPhysicsTest.kt b/app/src/test/java/org/prolibertate/games/ui/game/CardPhysicsTest.kt index 636aadc..4eee44b 100644 --- a/app/src/test/java/org/prolibertate/games/ui/game/CardPhysicsTest.kt +++ b/app/src/test/java/org/prolibertate/games/ui/game/CardPhysicsTest.kt @@ -209,6 +209,34 @@ class CardPhysicsTest { hand.forEach { assertTrue(sweepProgress(0.95f, it) > 0.5f) } } + /** + * A gathered card has to still be whole when it reaches the winner's pile, + * because it vanishes at the same moment the pile grows by one and that + * swap is only invisible if there was something there to swap. + */ + @Test + fun `a gathered card is whole most of the way and gone at the end`() { + assertEquals(1f, gatherAlpha(0f), 0f) + assertEquals(1f, gatherAlpha(0.5f), 0f) + assertEquals(0f, gatherAlpha(1f), 0f) + var previous = 1f + for (step in 0..100) { + val now = gatherAlpha(step / 100f) + assertTrue("went back up at $step", now <= previous + 0.0001f) + assertTrue(now in 0f..1f) + previous = now + } + } + + /** And is squared down into the bundle rather than arriving full size. */ + @Test + fun `a gathered card is squared down on the way`() { + assertEquals(1f, gatherScale(0f), 0f) + assertTrue(gatherScale(1f) < 1f) + assertTrue(gatherScale(1f) > 0.5f) + assertTrue(gatherScale(0.5f) < gatherScale(0.25f)) + } + /** A trick that never completes must not leave a card part way off the table. */ @Test fun `a sweep that never starts moves nothing`() {