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 710bcb9..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 @@ -24,6 +26,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import kotlin.math.PI +import kotlin.math.abs import kotlin.math.cos import kotlin.math.roundToInt import kotlin.math.sin @@ -65,6 +68,16 @@ const val REST_TILT_DEGREES = 4.5f /** Most a settled card misses its spot by, as a fraction of its own width. */ const val REST_DRIFT_FRACTION = 0.055f +/** + * The same two bounds for a card thrown onto a heap rather than laid in front + * of a player, which is a good deal less tidy. They are also what makes a heap + * legible: with the tight bounds a discard pile of five cards looks like one + * card, because the four underneath are hidden to within a twentieth of a card + * width of the top one. + */ +const val HEAP_TILT_DEGREES = 8f +const val HEAP_DRIFT_FRACTION = 0.15f + /** Extra turn a card carries while it is still travelling. */ const val THROW_SPIN_DEGREES = 15f @@ -108,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. // --------------------------------------------------------------------------- @@ -160,14 +183,28 @@ data class CardRest( ) /** The resting scatter for [seed], within the bounds set at the top of the file. */ -fun cardRest(seed: Int): CardRest = CardRest( - tiltDegrees = unitNoise(seed, 1) * REST_TILT_DEGREES, - driftX = unitNoise(seed, 2) * REST_DRIFT_FRACTION, - driftY = unitNoise(seed, 3) * REST_DRIFT_FRACTION, +fun cardRest( + seed: Int, + tilt: Float = REST_TILT_DEGREES, + drift: Float = REST_DRIFT_FRACTION, +): CardRest = CardRest( + tiltDegrees = unitNoise(seed, 1) * tilt, + driftX = unitNoise(seed, 2) * drift, + driftY = unitNoise(seed, 3) * drift, spinDegrees = unitNoise(seed, 4) * THROW_SPIN_DEGREES, lateness = (unitNoise(seed, 5) + 1f) / 2f, ) +/** + * The same, at the looser bounds a heap gets. + * + * A card keeps this scatter for as long as it is on the heap, including once + * something has been played on top of it. Switching a card to a wider scatter + * the moment it stopped being the top card would make it shuffle sideways on + * its own, which is the one thing on a table that never happens. + */ +fun heapRest(seed: Int): CardRest = cardRest(seed, HEAP_TILT_DEGREES, HEAP_DRIFT_FRACTION) + /** The small tilt of a card sitting in a squared-up hand. */ fun handTilt(seed: Int): Float = unitNoise(seed, 6) * HAND_TILT_DEGREES @@ -195,6 +232,24 @@ fun pileOrigin(seed: Int, distance: Float = THROW_DISTANCE): Pair return cos(angle) * distance to sin(angle) * distance } +/** + * Where a card that has been played *on* lies, given how much ground the cards + * now covering it take up — half the width and half the height of it, in + * pixels. + * + * Pushed out towards the edge of that ground rather than scattered evenly + * across it. A card buried dead centre under a set of four is a card nobody can + * see, and the entire point of still drawing it is that you can tell the heap + * has a history. Out at the edge a corner shows, which is what a real heap + * looks like from above. + */ +fun buriedOffset(seed: Int, halfWidth: Float, halfHeight: Float): Pair { + val angle = (unitNoise(seed, 11) + 1f) * PI.toFloat() + // Never right at the rim and never far inside it. + val reach = 0.72f + 0.28f * abs(unitNoise(seed, 12)) + return cos(angle) * halfWidth * reach to sin(angle) * halfHeight * reach +} + /** * Acceleration curve for cards being dragged off a table: slow to break away, * then gone. Written out rather than taken from an easing class so the sweep @@ -213,9 +268,20 @@ fun sweepProgress(sweep: Float, rest: CardRest, spread: Float = SWEEP_SPREAD): F return accelerate(raw) } -private fun blend(from: Float, to: Float, fraction: Float): Float = +/** Straight interpolation, [fraction] being free to overshoot past one. */ +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. // --------------------------------------------------------------------------- @@ -324,7 +390,193 @@ 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 = 4 +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) @@ -378,7 +630,7 @@ fun CardPile( // Room for the scatter, so the outermost card is not clipped by whatever // this pile has been placed inside. - val margin = width * REST_DRIFT_FRACTION + val margin = width * HEAP_DRIFT_FRACTION Box( modifier = modifier .padding(horizontal = margin, vertical = margin) @@ -388,7 +640,7 @@ fun CardPile( for (index in bottom until cards.size) { key(index) { val card = cards[index] - val rest = cardRest(cardSeed(card, index)) + val rest = heapRest(cardSeed(card, index)) val landing = if (index < alreadyDown) { CardLanding( offsetX = rest.driftX * cardWidthPx, diff --git a/app/src/main/java/org/prolibertate/games/ui/game/CribbageScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/CribbageScreen.kt index 13400a4..920cd67 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/CribbageScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/CribbageScreen.kt @@ -22,13 +22,16 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -280,8 +283,34 @@ private fun TableArea(state: CribbageState, localSeat: Int, cardWidth: Dp) { color = ink, ) } - state.series.forEach { pegged -> - PlayingCardView(card = pegged.card, width = cardWidth * 0.7f) + // The series stays on the table until it is counted out, so each + // card is laid beside the last rather than replacing it. Keyed + // by its place in the series so only the card just pegged is + // thrown down. + val pegWidth = cardWidth * 0.7f + val pegWidthPx = with(LocalDensity.current) { pegWidth.toPx() } + val throwMillis = motionMillis(CARD_THROW_MILLIS) + state.series.forEachIndexed { place, pegged -> + key(place) { + val seed = cardSeed(pegged.card, place) + val rest = cardRest(seed) + val (fromX, fromY) = pileOrigin(seed) + val landing = rememberCardLanding( + key = place, + rest = rest, + facingDegrees = 0f, + fromX = fromX * pegWidthPx, + fromY = fromY * pegWidthPx, + cardWidthPx = pegWidthPx, + durationMillis = throwMillis, + ) + PlayingCardView( + card = pegged.card, + width = pegWidth, + elevation = landing.elevation, + modifier = Modifier.landed(landing), + ) + } } } } @@ -415,6 +444,7 @@ private fun HandRow( width = cardWidth, enabled = enabled, selected = card in chosen, + modifier = Modifier.rotate(handTilt(cardSeed(card))), onClick = { onTap(card) }, ) } 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/PresidentScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/PresidentScreen.kt index 5894bc8..5b9a2ac 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/PresidentScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/PresidentScreen.kt @@ -1,5 +1,7 @@ package org.prolibertate.games.ui.game +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement @@ -11,7 +13,9 @@ import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme @@ -24,10 +28,13 @@ import androidx.compose.runtime.key import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp +import kotlin.math.roundToInt import org.prolibertate.games.game.president.PassTurn import org.prolibertate.games.game.president.PlayCards import org.prolibertate.games.game.president.PresidentMove @@ -37,6 +44,7 @@ import org.prolibertate.games.game.president.titleFor import org.prolibertate.games.net.MatchController import org.prolibertate.games.ui.PrimaryAction import org.prolibertate.games.ui.ScreenScaffold +import org.prolibertate.games.ui.common.CARD_ASPECT import org.prolibertate.games.ui.common.CardBackView import org.prolibertate.games.ui.common.PlayingCardView import org.prolibertate.games.ui.theme.FeltGreenDark @@ -158,38 +166,7 @@ private fun PileView(state: PresidentState, cardWidth: Dp) { style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onPrimary, ) - // The set on top of the pile. A set is played as one gesture but not as - // one card: they leave the hand together and land one after another, - // which is what the stagger off each card's own seed is for. - val onTop = state.pile.takeLast(state.setSize) - val topIndex = state.pile.size - onTop.size - val cardWidthPx = with(LocalDensity.current) { cardWidth.toPx() } - val throwMillis = motionMillis(CARD_THROW_MILLIS) - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - onTop.forEachIndexed { offsetInSet, card -> - val place = topIndex + offsetInSet - key(place) { - val rest = cardRest(cardSeed(card, place)) - val (fromX, fromY) = pileOrigin(cardSeed(card, place)) - val landing = rememberCardLanding( - key = place, - rest = rest, - facingDegrees = 0f, - fromX = fromX * cardWidthPx, - fromY = fromY * cardWidthPx, - cardWidthPx = cardWidthPx, - durationMillis = throwMillis, - delayMillis = (rest.lateness * throwMillis * 0.3f).toInt(), - ) - PlayingCardView( - card = card, - width = cardWidth, - elevation = landing.elevation, - modifier = Modifier.landed(landing), - ) - } - } - } + PileHeap(state, cardWidth) Text( text = "Beat it with ${state.setSize} card(s)", style = MaterialTheme.typography.labelSmall, @@ -198,6 +175,120 @@ private fun PileView(state: PresidentState, cardWidth: Dp) { } } +/** How much of what is under the top set is worth drawing. */ +private const val BURIED_DEPTH = 5 + +/** + * The pile, as a pile. + * + * Every card played since the pile was last cleared is still lying there, and a + * new set is played *onto* them rather than instead of them — which is what the + * pile is for. Only the top set has to be readable, since that is the one that + * has to be beaten, so it is laid out square and side by side; everything under + * it is pushed out towards the edge of the ground the top set covers, so a + * corner of it shows. + * + * The whole pile is drawn in one loop keyed by position, which is what lets a + * set slide out of the way as the next one lands on it instead of vanishing + * from the row and reappearing under the heap. Each card animates from wherever + * it actually is to wherever it now belongs, so being buried needs no + * bookkeeping about where it used to sit. + */ +@Composable +private fun PileHeap(state: PresidentState, cardWidth: Dp) { + val density = LocalDensity.current + val cardWidthPx = with(density) { cardWidth.toPx() } + val cardHeightPx = cardWidthPx / CARD_ASPECT + val gapPx = with(density) { 6.dp.toPx() } + val throwMillis = motionMillis(CARD_THROW_MILLIS) + + // Where the cards of the top set sit: centred, side by side. + val step = cardWidthPx + gapPx + val slotOffset = (state.setSize - 1) / 2f + + // Half the ground the top set covers, which is what the cards underneath + // have to get out from under. + val halfWidth = (step * state.setSize) / 2f + val halfHeight = cardHeightPx * 0.34f + + val lowest = (state.pile.size - state.setSize - BURIED_DEPTH).coerceAtLeast(0) + + Box( + modifier = Modifier.size( + width = with(density) { (halfWidth * 2f + cardWidthPx * 0.9f).toDp() }, + height = with(density) { (cardHeightPx + halfHeight * 2f).toDp() }, + ), + contentAlignment = Alignment.Center, + ) { + for (place in lowest until state.pile.size) { + key(place) { + val card = state.pile[place] + val seed = cardSeed(card, place) + val depth = state.pile.size - 1 - place + val onTop = depth < state.setSize + + val rest = heapRest(seed) + val (buriedX, buriedY) = buriedOffset(seed, halfWidth, halfHeight) + val (targetX, targetY) = if (onTop) { + // Slot 0 is the leftmost card of the set, which is the + // deepest of them — they were dealt onto the pile in order. + val slot = state.setSize - 1 - depth + (slot - slotOffset) * step to 0f + } else { + buriedX to buriedY + } + + val slideSpec = tween(durationMillis = throwMillis) + val x by animateFloatAsState(targetX, slideSpec, label = "pileX") + val y by animateFloatAsState(targetY, slideSpec, label = "pileY") + // Buried cards are knocked further askew than the tidy set on + // top of them, which is the other half of reading as a heap. + val tilt by animateFloatAsState( + targetValue = if (onTop) rest.tiltDegrees * 0.4f else rest.tiltDegrees, + animationSpec = slideSpec, + label = "pileTilt", + ) + + // The throw is relative to wherever the card belongs, so the two + // compose: a card lands in its slot, and is later shoved aside + // from it without the landing having to know. + val (fromX, fromY) = pileOrigin(seed) + val landing = rememberCardLanding( + key = place, + rest = rest, + facingDegrees = 0f, + fromX = fromX * cardWidthPx, + fromY = fromY * cardWidthPx, + cardWidthPx = cardWidthPx, + durationMillis = throwMillis, + // A set leaves the hand together and lands one after + // another rather than as a block. + delayMillis = (rest.lateness * throwMillis * 0.3f).toInt(), + ) + + PlayingCardView( + card = card, + width = cardWidth, + elevation = landing.elevation, + modifier = Modifier + .offset { + IntOffset( + (x + landing.offsetX).roundToInt(), + (y + landing.offsetY).roundToInt(), + ) + } + .graphicsLayer { + scaleX = landing.scale + scaleY = landing.scale + rotationZ = tilt + (landing.rotation - rest.tiltDegrees) + alpha = landing.alpha + }, + ) + } + } + } +} + /** Everyone else, as card counts — their hands are never sent to this device. */ @Composable private fun OpponentRow(state: PresidentState, localSeat: Int) { 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 0457f35..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 @@ -98,6 +98,60 @@ class CardPhysicsTest { assertNotEquals(cardRest(cardSeed(card, 0)), cardRest(cardSeed(card, 1))) } + /** + * A heap is untidier than a card laid in front of a player, and has to be: + * at the tight bounds five cards on a discard pile are hidden to within a + * twentieth of a card width of each other and the pile looks like one card. + */ + @Test + fun `a heap lies looser than a table but still within bounds`() { + deck.forEach { card -> + val seed = cardSeed(card) + val heap = heapRest(seed) + val table = cardRest(seed) + assertTrue(abs(heap.tiltDegrees) <= HEAP_TILT_DEGREES) + assertTrue(abs(heap.driftX) <= HEAP_DRIFT_FRACTION) + assertTrue(abs(heap.driftY) <= HEAP_DRIFT_FRACTION) + assertTrue(abs(heap.tiltDegrees) >= abs(table.tiltDegrees)) + assertTrue(abs(heap.driftX) >= abs(table.driftX)) + } + } + + /** + * The two have to agree about everything except how far the card strayed, + * or a card would jump when it was drawn one way rather than the other. + */ + @Test + fun `a heap and a table agree about the throw`() { + deck.take(6).forEach { card -> + val seed = cardSeed(card) + assertEquals(cardRest(seed).spinDegrees, heapRest(seed).spinDegrees, 0f) + assertEquals(cardRest(seed).lateness, heapRest(seed).lateness, 0f) + } + } + + /** + * A buried card has to end up near the rim of whatever is covering it. One + * left in the middle is one nobody can see, and then drawing the pile as a + * pile achieves nothing. + */ + @Test + fun `a buried card ends up out where a corner of it shows`() { + deck.forEach { card -> + val (x, y) = buriedOffset(cardSeed(card), halfWidth = 100f, halfHeight = 40f) + val howFarOut = kotlin.math.hypot(x / 100f, y / 40f) + assertTrue("$card sat at $howFarOut of the way out", howFarOut >= 0.719f) + assertTrue(howFarOut <= 1.001f) + } + } + + /** Two cards buried under the same set must not end up in the same place. */ + @Test + fun `buried cards do not stack on each other`() { + val places = (0 until 6).map { buriedOffset(cardSeed(deck[it], it), 100f, 40f) } + assertEquals(places.size, places.toSet().size) + } + /** Every seat's card arrives from that seat, and from a card-width or two out. */ @Test fun `a card comes in from its own player's side`() { @@ -155,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`() {