From cba66906bbb2dd7ddd91fb5c0d20a4c7f6a3ed40 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 22:10:04 +0000 Subject: [PATCH] Keep won tricks in front of the player who took them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A trick slid off towards its winner and stopped existing. Where it went was the only record that it had been won at all, and by the third trick of a hand there was nothing on the table to say who was sitting on what — which on a real table is the most-glanced-at thing in the game, and the whole reason cards are gathered rather than binned. Each player now has a face-down pile in front of them: one card back per trick, because a trick is squared into a single bundle and put down, and from above that is what you see. The piles sit beside each player's own card, one place clockwise along their edge, which is both where a player would actually put them and the one spot on each edge nothing else was using. Wizard seats three to six so its piles go half a seat clockwise round the same ring the cards are played on. The count follows the cards, not the rules. A trick is counted the instant its last card is played, which is a beat before the gather and two before the cards arrive; reading the state directly grew the winner's pile while the trick was still lying in the middle of the table, and the cards then slid over and landed on a pile that had already counted them. rememberArrivedTricks holds the count back until the sweep has actually run. The sweep changes to suit its new destination. A gathered card is squared down towards the size of the bundle it is joining, and stays whole until the last stretch instead of fading the whole way over: it now vanishes at the same moment the pile grows by one, and that swap is only invisible if there was something there to swap. Kaiser needed a per-seat trick count, which it had never had — it scores in points, so no rule wanted one. It is defaulted for the wire and carries a test, because a field no rule depends on is a field that can quietly stop being maintained. Hearts already keeps the cards themselves and counts them by the deal. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0132V9gE6DF9itz2z3Hn7BK9 --- .../games/game/kaiser/KaiserModel.kt | 8 + .../games/game/kaiser/KaiserRules.kt | 5 + .../prolibertate/games/ui/game/CardPhysics.kt | 208 ++++++++++++++++++ .../games/ui/game/EuchreScreen.kt | 22 +- .../games/ui/game/HeartsScreen.kt | 10 + .../games/ui/game/KaiserScreen.kt | 21 +- .../games/ui/game/WizardScreen.kt | 24 +- .../games/game/kaiser/KaiserRulesTest.kt | 33 +++ .../games/ui/game/CardPhysicsTest.kt | 28 +++ 9 files changed, 350 insertions(+), 9 deletions(-) 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`() {