diff --git a/README.md b/README.md index c70579e..f452991 100644 --- a/README.md +++ b/README.md @@ -173,7 +173,7 @@ production package instead. - `.github/workflows/ci.yml` — builds, tests and lints on `dev` and on pull requests into `dev` or `main`. Its APK artifact is a dev build, versioned `.0-dev` — the series being whatever `plgVersionSeries` says in - `gradle.properties`, currently **1.1**. + `gradle.properties`, currently **1.3**. - `.github/workflows/release.yml` — on push to `main`, builds the APK and publishes it as a GitHub Release tagged `v.`. The in-app updater reads that tag, which is why the APK's `versionCode` is the same run number. @@ -201,7 +201,7 @@ A version is **`.`**. The series is set by hand, once, in `gradle.properties`: ```properties -plgVersionSeries=1.1 +plgVersionSeries=1.3 ``` Both workflows read that same property when they name a release tag, and the 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/AppRoot.kt b/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt index 5c2c3bd..98bf6af 100644 --- a/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt +++ b/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt @@ -26,6 +26,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -45,6 +46,7 @@ import org.prolibertate.games.net.LobbyController import org.prolibertate.games.score.ScorekeeperRepository import org.prolibertate.games.settings.Settings import org.prolibertate.games.settings.SettingsRepository +import org.prolibertate.games.ui.game.LocalCardSpeed import org.prolibertate.games.update.UpdateChannel import org.prolibertate.games.update.Updater @@ -121,14 +123,19 @@ fun AppRoot(env: AppEnv) { var splashDone by remember { mutableStateOf(false) } Box(modifier = Modifier.fillMaxSize()) { - AppContent( - env = env, - settings = settings, - stack = stack, - showComingSoon = showComingSoon, - push = push, - pop = pop, - ) + // Card motion reads its speed from here rather than from a parameter. + // Every card screen animates, most of them several layers deep, and the + // alternative was passing one unchanging number down through all of it. + CompositionLocalProvider(LocalCardSpeed provides settings.animationSpeed) { + AppContent( + env = env, + settings = settings, + stack = stack, + showComingSoon = showComingSoon, + push = push, + pop = pop, + ) + } // Over the top, so the menu behind it is already composed when it fades. if (!splashDone) { diff --git a/app/src/main/java/org/prolibertate/games/ui/common/Components.kt b/app/src/main/java/org/prolibertate/games/ui/common/Components.kt index 8ef3193..5bb4965 100644 --- a/app/src/main/java/org/prolibertate/games/ui/common/Components.kt +++ b/app/src/main/java/org/prolibertate/games/ui/common/Components.kt @@ -18,6 +18,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp @@ -41,6 +42,13 @@ fun PlayingCardView( width: Dp = 64.dp, selected: Boolean = false, enabled: Boolean = true, + /** + * How far off the surface the card is sitting. The default is the thickness + * of a card lying on a table, which is what separates a card *on* the felt + * from a card painted onto it; a card in flight is raised further by + * whatever is animating it. + */ + elevation: Dp = CARD_RESTING_ELEVATION, /** * Short note printed on the face, for when the rank and suit alone do not * say what the card does — a Sequence jack being the obvious case, since @@ -54,6 +62,7 @@ fun PlayingCardView( modifier = modifier .width(width) .aspectRatio(CARD_ASPECT) + .shadow(elevation, RoundedCornerShape(width * 0.12f)) .clip(RoundedCornerShape(width * 0.12f)) .background(Color.White) .border( @@ -102,11 +111,13 @@ fun PlayingCardView( fun CardBackView( modifier: Modifier = Modifier, width: Dp = 64.dp, + elevation: Dp = CARD_RESTING_ELEVATION, ) { Box( modifier = modifier .width(width) .aspectRatio(CARD_ASPECT) + .shadow(elevation, RoundedCornerShape(width * 0.12f)) .clip(RoundedCornerShape(width * 0.12f)) .background(MaterialTheme.colorScheme.primary) .border(1.dp, Color(0x33000000), RoundedCornerShape(width * 0.12f)), @@ -121,3 +132,11 @@ fun CardBackView( } const val CARD_ASPECT = 5f / 7f + +/** + * A card lying on a surface. Small enough to read as the thickness of a piece + * of card rather than as a floating tile — the miniature card backs used as + * hand counters are twelve dp wide, and anything heavier turns those into + * smudges. + */ +val CARD_RESTING_ELEVATION: Dp = 1.5.dp 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 new file mode 100644 index 0000000..d766cca --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/CardPhysics.kt @@ -0,0 +1,708 @@ +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 +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.LaunchedEffect +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.runtime.staticCompositionLocalOf +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.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 +import kotlinx.coroutines.delay +import org.prolibertate.games.game.cards.Card +import org.prolibertate.games.settings.Settings +import org.prolibertate.games.ui.common.CARD_ASPECT +import org.prolibertate.games.ui.common.CARD_RESTING_ELEVATION +import org.prolibertate.games.ui.common.CardBackView +import org.prolibertate.games.ui.common.PlayingCardView + +/** + * How cards move. + * + * A card thrown onto a table does three things that a card faded into place + * does not: it arrives from the side of the person who threw it, it overshoots + * and rocks back rather than stopping dead, and it comes to rest a little + * crooked and a little off the spot it was aimed at. This file is those three + * things, and nothing else — no ragdolls, no collisions. Real cards played by + * a real hand are only slightly random, and the whole difficulty is in keeping + * the randomness *small*: a table where every card lands at a jaunty angle + * stops looking like cards and starts looking like a screensaver. + * + * The randomness is therefore bounded and deterministic. Bounded because every + * scatter is a fraction of a card width or a handful of degrees; deterministic + * because it is hashed out of the card's own identity rather than drawn from a + * generator, which is what stops a card twitching to a new angle every time + * something else on the screen recomposes. The same card in the same place + * always lies the same way, and two cards never lie the same way as each other. + */ + +// --------------------------------------------------------------------------- +// Bounds. Every one of these is deliberately smaller than it wants to be. +// --------------------------------------------------------------------------- + +/** Most a settled card is left turned by, in degrees. */ +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 + +/** Tilt of a card held in a hand, where the pack has been squared up. */ +const val HAND_TILT_DEGREES = 1.6f + +/** How far off the table a thrown card starts, in card widths. */ +const val THROW_DISTANCE = 2.2f + +/** How much bigger a card looks while it is above the table rather than on it. */ +private const val THROW_SCALE = 1.11f + +/** + * Shadow under a card in flight. What it settles to is the ordinary resting + * elevation every other card on the screen already has, read from there rather + * than repeated, so a card that has just landed is indistinguishable from one + * that has been lying there all along. + */ +private const val THROW_ELEVATION_DP = 9f +private val REST_ELEVATION_DP = CARD_RESTING_ELEVATION.value + +/** + * Bounce in the settle. Below 1 is an underdamped spring, so the card carries + * past its resting place and comes back — which is the whole point. Much below + * this and it wobbles like rubber. + */ +private const val SETTLE_DAMPING = 0.62f + +/** Stiffness that gives [CARD_THROW_MILLIS] at normal speed. */ +private const val SETTLE_STIFFNESS = 420f + +/** Nominal flight time of a card, before the animation-speed setting. */ +const val CARD_THROW_MILLIS = 380 + +/** Nominal time a finished trick takes to be gathered up. */ +const val CARD_SWEEP_MILLIS = 520 + +/** + * How much of the sweep is spent letting the stragglers go. Cards do not all + * leave at once when a hand scoops them up, but they do all leave quickly. + */ +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. +// --------------------------------------------------------------------------- + +/** + * A hash, not a random number. + * + * Two rounds of a linear congruential step with a shift between them, which is + * more than enough to stop neighbouring seeds producing neighbouring angles — + * the visible failure would be a whole suit landing at the same tilt. + */ +private fun scramble(seed: Int): Int { + var h = seed + repeat(2) { + h = h * 1_664_525 + 1_013_904_223 + h = h xor (h ushr 15) + } + return h +} + +/** + * A value in `[-1, 1)` for this [seed] and [salt], stable for the life of the + * program and identical between runs. [salt] separates the several independent + * numbers one card needs from a single seed. + */ +fun unitNoise(seed: Int, salt: Int): Float { + val bits = scramble(seed * 31 + salt * 0x5F5F) ushr 8 + return bits / 8_388_608f - 1f +} + +/** A seed for one card, optionally distinguished by where it is lying. */ +fun cardSeed(card: Card, place: Int = 0): Int = + (card.rank.ordinal * 4 + card.suit.ordinal) * 71 + place * 7919 + +/** + * Where a card has come to rest, and how it got there. + * + * All four scatters come off one seed, so the same card always lies the same + * way: [tiltDegrees] and the two drifts are where it ended up, [spinDegrees] is + * the turn it was still carrying on the way in, and [lateness] is its place in + * the queue when a group of cards moves together. + */ +@Immutable +data class CardRest( + val tiltDegrees: Float, + val driftX: Float, + val driftY: Float, + val spinDegrees: Float, + val lateness: Float, +) + +/** The resting scatter for [seed], within the bounds set at the top of the file. */ +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 + +/** + * Where a card thrown by a seat comes in from, in card widths, for a four-seat + * table where relative seat 0 sits at the bottom and the rest run clockwise. + * A card arrives from beyond its own player's edge, which is the direction it + * would have been thrown from. + */ +fun seatOrigin(relative: Int, distance: Float = THROW_DISTANCE): Pair = + when (relative) { + 0 -> 0f to distance + 1 -> -distance to 0f + 2 -> 0f to -distance + else -> distance to 0f + } + +/** + * Where a card lands on a pile from, in card widths, when there is no seat to + * read a direction off — a discard pile takes cards from everyone. The angle is + * hashed rather than drawn, so a card always arrives from the same side. + */ +fun pileOrigin(seed: Int, distance: Float = THROW_DISTANCE): Pair { + val angle = (unitNoise(seed, 7) + 1f) * PI.toFloat() + 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 + * arithmetic stays plain Kotlin and can be tested without a composition. + */ +private fun accelerate(x: Float): Float = x * x * (0.35f + 0.65f * x) + +/** + * How far through leaving a single card is, given how far through the sweep as + * a whole is. Cards start over a window of [spread] rather than together, so a + * trick gathers up in one motion rather than four cards moving in lockstep. + */ +fun sweepProgress(sweep: Float, rest: CardRest, spread: Float = SWEEP_SPREAD): Float { + val start = rest.lateness.coerceIn(0f, 1f) * spread.coerceIn(0f, 0.9f) + val raw = ((sweep - start) / (1f - start)).coerceIn(0f, 1f) + return accelerate(raw) +} + +/** 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. +// --------------------------------------------------------------------------- + +/** + * The animation-speed setting, so card motion follows the same slider as + * everything else. A composition local rather than a parameter because it would + * otherwise have to be threaded through every card screen and down into every + * pile inside them, to say one number that never changes mid-game. + */ +val LocalCardSpeed = staticCompositionLocalOf { 1f } + +/** A nominal duration, adjusted for the speed the player has chosen. */ +@Composable +fun motionMillis(nominal: Int): Int { + val speed = LocalCardSpeed.current.coerceIn(Settings.MIN_SPEED, Settings.MAX_SPEED) + return (nominal / speed).toInt().coerceAtLeast(1) +} + +/** + * Where a card is, and what it looks like, part way through being thrown. + * + * [alpha] is only ever 0 or 1, and only ever 0 for a card waiting its turn in a + * set that is being played together. Without it a delayed card would sit + * plainly visible two card widths off the table until its moment came, which + * reads as a card teleporting out and flying back rather than as one still in + * somebody's hand. + */ +@Immutable +data class CardLanding( + val offsetX: Float, + val offsetY: Float, + val rotation: Float, + val scale: Float, + val elevation: Dp, + val alpha: Float = 1f, +) + +/** + * Follows one card from the moment it is played until it has stopped moving. + * + * The whole flight is one underdamped spring on a single 0-to-1 value, and + * everything else is read off it. That is why the card overshoots its resting + * place and rocks back instead of easing to a halt: the spring passes 1 and + * returns, and the position, the angle and the height above the table all + * follow it there and back together. Driving them separately was tried and + * looked like three animations that happened to start at the same time. + * + * [key] is what counts as a new card arriving. It must change when the card in + * this place changes and at no other time, or the card either fails to animate + * or animates again every recomposition. + */ +@Composable +fun rememberCardLanding( + key: Any?, + rest: CardRest, + facingDegrees: Float, + fromX: Float, + fromY: Float, + cardWidthPx: Float, + durationMillis: Int, + delayMillis: Int = 0, +): CardLanding { + val settle = remember(key) { Animatable(0f) } + var launched by remember(key) { mutableStateOf(delayMillis <= 0) } + LaunchedEffect(key) { + if (delayMillis > 0) { + delay(delayMillis.toLong()) + launched = true + } + settle.animateTo( + targetValue = 1f, + animationSpec = spring( + dampingRatio = SETTLE_DAMPING, + stiffness = SETTLE_STIFFNESS * CARD_THROW_MILLIS / durationMillis, + ), + ) + } + + val travelled = settle.value + // Height above the table is the one thing that must not overshoot: a card + // that has landed cannot rise again, whatever the spring is still doing. + val landed = travelled.coerceIn(0f, 1f) + return CardLanding( + offsetX = blend(fromX, rest.driftX * cardWidthPx, travelled), + offsetY = blend(fromY, rest.driftY * cardWidthPx, travelled), + rotation = blend( + facingDegrees + rest.spinDegrees, + facingDegrees + rest.tiltDegrees, + travelled, + ), + scale = blend(THROW_SCALE, 1f, landed), + elevation = blend(THROW_ELEVATION_DP, REST_ELEVATION_DP, landed).dp, + alpha = if (launched) 1f else 0f, + ) +} + +/** Puts a card where its [landing] says it is. For cards drawn on their own. */ +fun Modifier.landed(landing: CardLanding): Modifier = this + .offset { IntOffset(landing.offsetX.roundToInt(), landing.offsetY.roundToInt()) } + .graphicsLayer { + scaleX = landing.scale + scaleY = landing.scale + rotationZ = landing.rotation + alpha = landing.alpha + } + +/** 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) + +/** + * How much of a pile was already lying there rather than played onto it. + * + * Cards above this line get thrown on; cards below it are simply drawn where + * they lie. Two things have to be true and only one of them is obvious. The + * obvious one is that a pile you have just opened the screen on was not built + * in front of you, so none of it should fly in. The other is that a pile gets + * *shorter* between rounds — a new deal replaces a twenty-card heap with a + * single turned card — and a mark left at twenty would then sit above every + * card of the new round and quietly switch the animation off for the rest of + * the game. So the mark follows the pile down, and only ever down. + * + * A plain holder rather than snapshot state on purpose: this is written and + * read within one composition, and it must not be the reason anything + * recomposes. + */ +@Composable +private fun rememberPileWatermark(size: Int): Int { + val mark = remember { PileWatermark(size) } + if (size < mark.value) mark.value = size + return mark.value +} + +/** + * A pile of cards seen from above. + * + * Only the top few are drawn, each lying where it fell, which is enough to read + * as a heap without painting fifty cards nobody can see. Cards already on the + * pile when the screen opens are drawn settled — the pile was built before you + * got here — and only cards played after that are thrown on. + */ +@Composable +fun CardPile( + cards: List, + width: Dp, + modifier: Modifier = Modifier, + depth: Int = PILE_DEPTH, +) { + if (cards.isEmpty()) return + + // Position in the pile, not position in the window, so a card keeps its + // identity — and so its resting angle — as the window slides past it. + val bottom = (cards.size - depth).coerceAtLeast(0) + val alreadyDown = rememberPileWatermark(cards.size) + val cardWidthPx = with(LocalDensity.current) { width.toPx() } + val throwMillis = motionMillis(CARD_THROW_MILLIS) + + // Room for the scatter, so the outermost card is not clipped by whatever + // this pile has been placed inside. + val margin = width * HEAP_DRIFT_FRACTION + Box( + modifier = modifier + .padding(horizontal = margin, vertical = margin) + .width(width) + .height(width / CARD_ASPECT), + ) { + for (index in bottom until cards.size) { + key(index) { + val card = cards[index] + val rest = heapRest(cardSeed(card, index)) + val landing = if (index < alreadyDown) { + CardLanding( + offsetX = rest.driftX * cardWidthPx, + offsetY = rest.driftY * cardWidthPx, + rotation = rest.tiltDegrees, + scale = 1f, + elevation = REST_ELEVATION_DP.dp, + ) + } else { + val (fromX, fromY) = pileOrigin(cardSeed(card, index)) + rememberCardLanding( + key = index, + rest = rest, + facingDegrees = 0f, + fromX = fromX * cardWidthPx, + fromY = fromY * cardWidthPx, + cardWidthPx = cardWidthPx, + durationMillis = throwMillis, + ) + } + PlayingCardView( + card = card, + width = width, + elevation = landing.elevation, + modifier = Modifier.landed(landing), + ) + } + } + } +} + +/** + * A stock of face-down cards, drawn with enough of the pack under the top card + * to show there is something left in it. The offsets are a squared-up deck's + * worth — a couple of percent — rather than the scatter a played card gets. + */ +@Composable +fun CardBackStack( + count: Int, + width: Dp, + modifier: Modifier = Modifier, + depth: Int = 3, +) { + if (count <= 0) return + + val shown = count.coerceAtMost(depth) + val step = width * 0.018f + Box( + modifier = modifier + .padding(end = step * shown, bottom = step * shown) + .width(width) + .height(width / CARD_ASPECT), + ) { + // Bottom of the pack first, so the top card is drawn last and sits on top. + for (below in (shown - 1) downTo 0) { + CardBackView( + width = width, + elevation = if (below == 0) REST_ELEVATION_DP.dp else 0.dp, + modifier = Modifier + .offset(x = step * below, y = step * below) + .rotate(unitNoise(below, 8) * 0.8f), + ) + } + } +} diff --git a/app/src/main/java/org/prolibertate/games/ui/game/CrazyEightsScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/CrazyEightsScreen.kt index 98f4553..6511970 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/CrazyEightsScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/CrazyEightsScreen.kt @@ -27,6 +27,7 @@ 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.text.font.FontWeight import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -181,7 +182,10 @@ private fun PileArea(state: CrazyEightsState, cardWidth: Dp) { color = MaterialTheme.colorScheme.onPrimary, ) } else { - CardBackView(width = cardWidth) + // A pack, not a single back: the stock is the one thing on + // this screen that is visibly a stack of card, and drawing + // it as one is most of what makes the table look real. + CardBackStack(count = state.stock.size, width = cardWidth) } } Column(horizontalAlignment = Alignment.CenterHorizontally) { @@ -190,7 +194,9 @@ private fun PileArea(state: CrazyEightsState, cardWidth: Dp) { style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onPrimary, ) - state.topCard?.let { PlayingCardView(card = it, width = cardWidth) } + // The discard heap rather than only its top card, so a card + // played onto it lands on something. + CardPile(cards = state.discard, width = cardWidth) } } // An eight on top says nothing about what may be played next, so the @@ -331,6 +337,7 @@ private fun HandRow( enabled = canPlay, selected = card == pendingWild, caption = if (isWild(card)) "WILD" else null, + modifier = Modifier.rotate(handTilt(cardSeed(card))), onClick = { onTap(card) }, ) } 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 3165a39..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 @@ -1,5 +1,6 @@ package org.prolibertate.games.ui.game +import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.background @@ -25,6 +26,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.LaunchedEffect 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 @@ -33,6 +35,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset @@ -184,9 +187,6 @@ private fun seatLabel(relative: Int): String = when (relative) { /** How long a completed trick rests on the table before being swept away. */ const val TRICK_HOLD_MILLIS = 1_000L -/** Duration of the sweep itself. */ -private const val TRICK_SWEEP_MILLIS = 450 - @Composable private fun TrickArea( state: EuchreState, @@ -206,9 +206,12 @@ private fun TrickArea( sweepStarted = true } } + // Driven linearly, because the shape of the sweep belongs to each card + // rather than to the clock: sweepProgress gives them their own start and + // their own acceleration off this one value. val sweep by animateFloatAsState( targetValue = if (sweeping && sweepStarted) 1f else 0f, - animationSpec = tween(durationMillis = TRICK_SWEEP_MILLIS), + animationSpec = tween(durationMillis = motionMillis(CARD_SWEEP_MILLIS), easing = LinearEasing), label = "trickSweep", ) @@ -220,10 +223,25 @@ private fun TrickArea( 2 -> 0f to -1f else -> 1f to 0f } - val travelPx = with(LocalDensity.current) { (cardWidth * 2.5f).toPx() } + val cardWidthPx = with(LocalDensity.current) { cardWidth.toPx() } + 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( @@ -249,45 +267,77 @@ private fun TrickArea( ) } + // Keyed by seat: one card per seat per trick, so this holds a card's + // flight steady while the trick fills up and while the finished trick + // is swapped for the copy that gets swept away. Without it the list + // position would decide identity and every card would jump back into + // the air whenever another one landed. cardsOnTable.forEach { played -> - val relative = relativePosition(played.seat, localSeat) - val alignment = when (relative) { - 0 -> Alignment.BottomCenter - 1 -> Alignment.CenterStart - 2 -> Alignment.TopCenter - else -> Alignment.CenterEnd - } - // Rotate to face the player who played it: the side seats end up - // landscape, the partner's card upside down from your side. - val rotation = when (relative) { - 1 -> 90f - 2 -> 180f - 3 -> -90f - else -> 0f - } + key(played.seat) { + val relative = relativePosition(played.seat, localSeat) + val alignment = when (relative) { + 0 -> Alignment.BottomCenter + 1 -> Alignment.CenterStart + 2 -> Alignment.TopCenter + else -> Alignment.CenterEnd + } + // Turned to face the player who played it: the side seats end + // up landscape, the partner's card upside down from your side. + val facing = when (relative) { + 1 -> 90f + 2 -> 180f + 3 -> -90f + else -> 0f + } - Column( - modifier = Modifier - .align(alignment) - .offset { - IntOffset( - x = (dirX * travelPx * sweep).roundToInt(), - y = (dirY * travelPx * sweep).roundToInt(), - ) - } - .alpha(1f - sweep), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - PlayingCardView( - card = played.card, - width = cardWidth, - modifier = Modifier.rotate(rotation), - ) - Text( - text = seatLabel(relative), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onPrimary, + val rest = cardRest(cardSeed(played.card, played.seat)) + val (fromX, fromY) = seatOrigin(relative) + val landing = rememberCardLanding( + key = played.card, + rest = rest, + facingDegrees = facing, + fromX = fromX * cardWidthPx, + fromY = fromY * cardWidthPx, + cardWidthPx = cardWidthPx, + durationMillis = throwMillis, ) + val leaving = sweepProgress(sweep, rest) + + Column( + modifier = Modifier + .align(alignment) + .offset { + IntOffset( + x = (landing.offsetX + dirX * travelPx * leaving).roundToInt(), + y = (landing.offsetY + dirY * travelPx * leaving).roundToInt(), + ) + } + .graphicsLayer { + // 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, + ) { + PlayingCardView( + card = played.card, + width = cardWidth, + elevation = landing.elevation, + // The turn it picks up on the way out is the same turn + // it carried on the way in, so a card leaves the table + // the way it arrived. + modifier = Modifier + .rotate(landing.rotation + rest.spinDegrees * leaving), + ) + Text( + text = seatLabel(relative), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimary, + ) + } } } @@ -449,6 +499,11 @@ private fun HandRow( card = card, width = cardWidth, enabled = canPlay || canDiscard, + // A hand that has been squared up is still not square. Two + // degrees is below the threshold at which anyone notices a + // tilt and above the one at which a row of cards stops + // looking printed. + modifier = Modifier.rotate(handTilt(cardSeed(card))), onClick = { when { canDiscard -> onPlay(Discard(card)) diff --git a/app/src/main/java/org/prolibertate/games/ui/game/GolfScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/GolfScreen.kt index 1a19ba6..5b3a782 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/GolfScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/GolfScreen.kt @@ -252,7 +252,14 @@ private fun StockAndDiscard( Column(horizontalAlignment = Alignment.CenterHorizontally) { Text("Stock", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onPrimary) - CardBackView(width = cardWidth) + // Drawing the pack rather than one back also means an empty stock + // now looks empty, where before it showed a card that was not there. + if (state.stock.isEmpty()) { + Text("empty", style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimary) + } else { + CardBackStack(count = state.stock.size, width = cardWidth) + } if (legal.contains(DrawFromStock)) { Button(onClick = { onMove(DrawFromStock) }) { Text("Draw") } } @@ -261,7 +268,7 @@ private fun StockAndDiscard( Column(horizontalAlignment = Alignment.CenterHorizontally) { Text("Discard", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onPrimary) - state.discard.lastOrNull()?.let { PlayingCardView(card = it, width = cardWidth) } + CardPile(cards = state.discard, width = cardWidth) if (legal.contains(DrawFromDiscard)) { Button(onClick = { onMove(DrawFromDiscard) }) { Text("Take") } } 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 dd70ea6..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 @@ -18,6 +18,7 @@ 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 @@ -25,6 +26,8 @@ import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.runtime.mutableStateListOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import org.prolibertate.games.game.cards.Card @@ -126,6 +129,7 @@ fun HeartsScreen( selected = chosen.contains(card), enabled = if (selectable) true else playable.contains(card), caption = pointsOf(card).takeIf { it > 0 }?.let { "−$it" }, + modifier = Modifier.rotate(handTilt(cardSeed(card))), onClick = { if (selectable) { toggle(chosen, card) @@ -188,13 +192,35 @@ private fun TrickView(state: HeartsState) { if (showing.isEmpty()) { Text("Nothing played yet.", style = MaterialTheme.typography.bodySmall) } else { + // Hearts lays its trick out in a row rather than round a table, so + // there is no seat direction to throw a card in from — each one + // comes in from its own side, which the seed decides and keeps. + val width = 46.dp + val widthPx = with(LocalDensity.current) { width.toPx() } + val throwMillis = motionMillis(CARD_THROW_MILLIS) FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) { showing.forEach { played -> - PlayingCardView( - card = played.card, - width = 46.dp, - caption = "seat ${played.seat}", - ) + key(played.seat) { + val seed = cardSeed(played.card, played.seat) + val rest = cardRest(seed) + val (fromX, fromY) = pileOrigin(seed) + val landing = rememberCardLanding( + key = played.card, + rest = rest, + facingDegrees = 0f, + fromX = fromX * widthPx, + fromY = fromY * widthPx, + cardWidthPx = widthPx, + durationMillis = throwMillis, + ) + PlayingCardView( + card = played.card, + width = width, + caption = "seat ${played.seat}", + elevation = landing.elevation, + modifier = Modifier.landed(landing), + ) + } } } } @@ -214,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 c907d9b..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 @@ -1,5 +1,6 @@ package org.prolibertate.games.ui.game +import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.background @@ -25,6 +26,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect 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 @@ -32,6 +34,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha 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 @@ -181,9 +184,6 @@ private fun seatLabel(relative: Int): String = when (relative) { else -> "Right" } -/** Duration of the sweep towards the winner. */ -private const val SWEEP_MILLIS = 450 - @Composable private fun TrickArea( state: KaiserState, @@ -203,7 +203,7 @@ private fun TrickArea( } val sweep by animateFloatAsState( targetValue = if (sweeping && sweepStarted) 1f else 0f, - animationSpec = tween(durationMillis = SWEEP_MILLIS), + animationSpec = tween(durationMillis = motionMillis(CARD_SWEEP_MILLIS), easing = LinearEasing), label = "kaiserSweep", ) @@ -214,9 +214,25 @@ private fun TrickArea( 2 -> 0f to -1f else -> 1f to 0f } - val travelPx = with(LocalDensity.current) { (cardWidth * 2.5f).toPx() } + val cardWidthPx = with(LocalDensity.current) { cardWidth.toPx() } + 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 { @@ -230,43 +246,65 @@ private fun TrickArea( } cardsOnTable.forEach { played -> - val relative = relativePosition(played.seat, localSeat) - val alignment = when (relative) { - 0 -> Alignment.BottomCenter - 1 -> Alignment.CenterStart - 2 -> Alignment.TopCenter - else -> Alignment.CenterEnd - } - val rotation = when (relative) { - 1 -> 90f - 2 -> 180f - 3 -> -90f - else -> 0f - } + key(played.seat) { + val relative = relativePosition(played.seat, localSeat) + val alignment = when (relative) { + 0 -> Alignment.BottomCenter + 1 -> Alignment.CenterStart + 2 -> Alignment.TopCenter + else -> Alignment.CenterEnd + } + val facing = when (relative) { + 1 -> 90f + 2 -> 180f + 3 -> -90f + else -> 0f + } - Column( - modifier = Modifier - .align(alignment) - .offset { - IntOffset( - x = (dirX * travelPx * sweep).roundToInt(), - y = (dirY * travelPx * sweep).roundToInt(), - ) - } - .alpha(1f - sweep), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - PlayingCardView( - card = played.card, - width = cardWidth, - caption = counterCaption(played.card), - modifier = Modifier.rotate(rotation), - ) - Text( - text = seatLabel(relative), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onPrimary, + val rest = cardRest(cardSeed(played.card, played.seat)) + val (fromX, fromY) = seatOrigin(relative) + val landing = rememberCardLanding( + key = played.card, + rest = rest, + facingDegrees = facing, + fromX = fromX * cardWidthPx, + fromY = fromY * cardWidthPx, + cardWidthPx = cardWidthPx, + durationMillis = throwMillis, ) + val leaving = sweepProgress(sweep, rest) + + Column( + modifier = Modifier + .align(alignment) + .offset { + IntOffset( + x = (landing.offsetX + dirX * travelPx * leaving).roundToInt(), + y = (landing.offsetY + dirY * travelPx * leaving).roundToInt(), + ) + } + .graphicsLayer { + val gathered = landing.scale * gatherScale(leaving) + scaleX = gathered + scaleY = gathered + alpha = landing.alpha * gatherAlpha(leaving) + }, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + PlayingCardView( + card = played.card, + width = cardWidth, + caption = counterCaption(played.card), + elevation = landing.elevation, + modifier = Modifier + .rotate(landing.rotation + rest.spinDegrees * leaving), + ) + Text( + text = seatLabel(relative), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimary, + ) + } } } @@ -406,6 +444,7 @@ private fun HandRow( width = cardWidth, enabled = canPlay, caption = counterCaption(card), + modifier = Modifier.rotate(handTilt(cardSeed(card))), onClick = { if (canPlay) onPlay(PlayCard(card)) }, ) } 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 8f8d8ff..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 @@ -20,11 +24,17 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +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 @@ -34,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 @@ -155,11 +166,7 @@ private fun PileView(state: PresidentState, cardWidth: Dp) { style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onPrimary, ) - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - state.pile.takeLast(state.setSize).forEach { card -> - PlayingCardView(card = card, width = cardWidth) - } - } + PileHeap(state, cardWidth) Text( text = "Beat it with ${state.setSize} card(s)", style = MaterialTheme.typography.labelSmall, @@ -168,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) { @@ -258,7 +379,11 @@ private fun HandView(state: PresidentState, localSeat: Int, cardWidth: Dp) { Text("—", style = MaterialTheme.typography.bodySmall) } hand.forEach { card -> - PlayingCardView(card = card, width = cardWidth * 0.8f) + PlayingCardView( + card = card, + width = cardWidth * 0.8f, + modifier = Modifier.rotate(handTilt(cardSeed(card))), + ) } } } 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 56fcf7e..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 @@ -1,5 +1,6 @@ package org.prolibertate.games.ui.game +import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.background @@ -26,6 +27,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect 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 @@ -33,6 +35,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha 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 @@ -190,9 +193,6 @@ private fun BidTally(state: WizardState, localSeat: Int) { private fun seatLabel(seat: Int, localSeat: Int): String = if (seat == localSeat) "You" else "Seat $seat" -/** Duration of the sweep towards the winner. */ -private const val SWEEP_MILLIS = 450 - @Composable private fun TrickArea( state: WizardState, @@ -213,7 +213,7 @@ private fun TrickArea( } val sweep by animateFloatAsState( targetValue = if (sweeping && sweepStarted) 1f else 0f, - animationSpec = tween(durationMillis = SWEEP_MILLIS), + animationSpec = tween(durationMillis = motionMillis(CARD_SWEEP_MILLIS), easing = LinearEasing), label = "wizardSweep", ) @@ -226,7 +226,26 @@ private fun TrickArea( // Cards sit on a circle that fits inside whatever space the table has. val radius = (minOf(maxWidth, maxHeight) / 2 - cardWidth * 0.7f).coerceAtLeast(0.dp) val radiusPx = with(LocalDensity.current) { radius.toPx() } - val travelPx = with(LocalDensity.current) { (cardWidth * 2f).toPx() } + val cardWidthPx = with(LocalDensity.current) { cardWidth.toPx() } + 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( @@ -241,37 +260,63 @@ private fun TrickArea( } cardsOnTable.forEach { played -> - val relative = ((played.seat - localSeat) % players + players) % players - val angle = 2.0 * PI * relative / players - // Relative seat 0 is you, at the bottom; the rest run clockwise. - val x = -sin(angle) * radiusPx - val y = cos(angle) * radiusPx - // Turn each card to face whoever played it. - val rotation = (relative * 360f / players) + key(played.seat) { + val relative = ((played.seat - localSeat) % players + players) % players + val angle = 2.0 * PI * relative / players + // Relative seat 0 is you, at the bottom; the rest run clockwise. + val x = -sin(angle) * radiusPx + val y = cos(angle) * radiusPx + // Turn each card to face whoever played it. + val facing = (relative * 360f / players) - Column( - modifier = Modifier - .align(Alignment.Center) - .offset { - IntOffset( - x = (x + -sin(winnerAngle) * travelPx * sweep).roundToInt(), - y = (y + cos(winnerAngle) * travelPx * sweep).roundToInt(), - ) - } - .alpha(1f - sweep), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - PlayingCardView( - card = played.card, - width = cardWidth, - caption = specialCaption(played.card), - modifier = Modifier.rotate(rotation), - ) - Text( - text = seatLabel(played.seat, localSeat), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onPrimary, + // A card comes in along the ray it sits on, from further out — + // which on a round table is the direction its player is in, + // however many of them there are. + val rest = cardRest(cardSeed(played.card, played.seat)) + val landing = rememberCardLanding( + key = played.card, + rest = rest, + facingDegrees = facing, + fromX = (-sin(angle) * THROW_DISTANCE * cardWidthPx).toFloat(), + fromY = (cos(angle) * THROW_DISTANCE * cardWidthPx).toFloat(), + cardWidthPx = cardWidthPx, + durationMillis = throwMillis, ) + val leaving = sweepProgress(sweep, rest) + + Column( + modifier = Modifier + .align(Alignment.Center) + .offset { + IntOffset( + x = (x + landing.offsetX + + -sin(winnerAngle) * travelPx * leaving).roundToInt(), + y = (y + landing.offsetY + + cos(winnerAngle) * travelPx * leaving).roundToInt(), + ) + } + .graphicsLayer { + val gathered = landing.scale * gatherScale(leaving) + scaleX = gathered + scaleY = gathered + alpha = landing.alpha * gatherAlpha(leaving) + }, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + PlayingCardView( + card = played.card, + width = cardWidth, + caption = specialCaption(played.card), + elevation = landing.elevation, + modifier = Modifier + .rotate(landing.rotation + rest.spinDegrees * leaving), + ) + Text( + text = seatLabel(played.seat, localSeat), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimary, + ) + } } } @@ -395,6 +440,7 @@ private fun HandRow( width = cardWidth, enabled = canPlay, caption = specialCaption(card), + modifier = Modifier.rotate(handTilt(cardSeed(card))), onClick = { if (canPlay) onPlay(PlayCard(card)) }, ) } 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 new file mode 100644 index 0000000..4eee44b --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/ui/game/CardPhysicsTest.kt @@ -0,0 +1,247 @@ +package org.prolibertate.games.ui.game + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import kotlin.math.abs +import org.prolibertate.games.game.cards.Card +import org.prolibertate.games.game.cards.Decks +import org.prolibertate.games.game.cards.Rank +import org.prolibertate.games.game.cards.Suit + +/** + * The scatter under the card animations. + * + * Two properties matter and neither is visible in a screenshot. It has to be + * *bounded*, because the whole difference between cards on a table and cards + * thrown down a flight of stairs is the size of the numbers; and it has to be + * *stable*, because a card that picks a new angle on every recomposition + * twitches, and a card whose angle depends on where it sits in a list jumps + * whenever a card is played next to it. + */ +class CardPhysicsTest { + + private val deck = Decks.standard52() + + @Test + fun `noise stays inside minus one to one`() { + for (seed in -500..500) { + for (salt in 0..8) { + val n = unitNoise(seed, salt) + assertTrue("$seed/$salt gave $n", n >= -1f && n < 1f) + } + } + } + + @Test + fun `noise is the same every time it is asked`() { + assertEquals(unitNoise(12345, 3), unitNoise(12345, 3), 0f) + assertEquals(unitNoise(-7, 1), unitNoise(-7, 1), 0f) + } + + /** + * The salt is what lets one seed produce a tilt, two drifts, a spin and a + * queue position that are not all the same number. If it stopped separating + * them, every card would drift exactly as far as it tilted. + */ + @Test + fun `salts do not agree with each other`() { + val fromOneSeed = (0..8).map { unitNoise(4242, it) } + assertEquals(fromOneSeed.size, fromOneSeed.toSet().size) + } + + /** Sequential seeds are the normal case: adjacent cards in a hand. */ + @Test + fun `neighbouring seeds do not give neighbouring values`() { + val runs = (0 until 200).count { abs(unitNoise(it, 1) - unitNoise(it + 1, 1)) < 0.05f } + assertTrue("$runs of 200 neighbouring seeds landed together", runs < 30) + } + + @Test + fun `every card in a deck rests within the stated bounds`() { + deck.forEach { card -> + val rest = cardRest(cardSeed(card)) + assertTrue(abs(rest.tiltDegrees) <= REST_TILT_DEGREES) + assertTrue(abs(rest.driftX) <= REST_DRIFT_FRACTION) + assertTrue(abs(rest.driftY) <= REST_DRIFT_FRACTION) + assertTrue(abs(rest.spinDegrees) <= THROW_SPIN_DEGREES) + assertTrue(rest.lateness in 0f..1f) + assertTrue(abs(handTilt(cardSeed(card))) <= HAND_TILT_DEGREES) + } + } + + @Test + fun `a card lies the same way however often it is asked`() { + val card = Card(Rank.QUEEN, Suit.SPADES) + assertEquals(cardRest(cardSeed(card)), cardRest(cardSeed(card))) + assertEquals(cardRest(cardSeed(card, 3)), cardRest(cardSeed(card, 3))) + } + + /** + * Two cards lying at the same angle in the same place is the one thing that + * makes a scattered pile look printed rather than dealt. + */ + @Test + fun `no two cards in a deck share a seed`() { + val seeds = deck.map { cardSeed(it) } + assertEquals(seeds.size, seeds.toSet().size) + } + + /** + * A pile can hold the same card twice — Golf and Spider deal more than one + * deck — so where a card is lying has to be part of its seed. + */ + @Test + fun `the same card in two places lies two ways`() { + val card = Card(Rank.SEVEN, Suit.HEARTS) + 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`() { + assertEquals(0f to THROW_DISTANCE, seatOrigin(0)) + assertEquals(-THROW_DISTANCE to 0f, seatOrigin(1)) + assertEquals(0f to -THROW_DISTANCE, seatOrigin(2)) + assertEquals(THROW_DISTANCE to 0f, seatOrigin(3)) + } + + @Test + fun `a pile card comes in from somewhere off the pile`() { + deck.forEach { card -> + val (x, y) = pileOrigin(cardSeed(card)) + val distance = kotlin.math.hypot(x, y) + assertEquals(THROW_DISTANCE, distance, 0.001f) + } + } + + @Test + fun `nothing has left the table before the sweep starts`() { + deck.take(8).forEach { card -> + assertEquals(0f, sweepProgress(0f, cardRest(cardSeed(card))), 0f) + } + } + + @Test + fun `everything has left the table by the end of the sweep`() { + deck.take(8).forEach { card -> + assertEquals(1f, sweepProgress(1f, cardRest(cardSeed(card))), 0.0001f) + } + } + + @Test + fun `a card only ever gets further from the table`() { + val rest = cardRest(cardSeed(Card(Rank.ACE, Suit.CLUBS))) + var previous = 0f + for (step in 0..100) { + val now = sweepProgress(step / 100f, rest) + assertTrue("went backwards at $step", now >= previous - 0.0001f) + assertTrue(now in 0f..1f) + previous = now + } + } + + /** + * The stagger: a trick gathers up as one motion, so the cards must not all + * be at the same point in it, and none of them may still be sitting there + * when the sweep is nearly over. + */ + @Test + fun `cards leave at different moments but none is left behind`() { + val hand = deck.take(4).map { cardRest(cardSeed(it)) } + val halfway = hand.map { sweepProgress(0.5f, it) } + assertEquals(halfway.size, halfway.toSet().size) + 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`() { + deck.take(4).forEach { card -> + assertEquals(0f, sweepProgress(0f, cardRest(cardSeed(card)), spread = 0f), 0f) + } + } +} diff --git a/gradle.properties b/gradle.properties index cf29b77..8d326d4 100644 --- a/gradle.properties +++ b/gradle.properties @@ -22,4 +22,4 @@ android.nonTransitiveRClass=true # Only the series is set by hand. The number after it is the CI run number, so # it keeps climbing on its own and an APK's versionCode never goes backwards # when this changes. -plgVersionSeries=1.2 +plgVersionSeries=1.3