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/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..710bcb9 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/CardPhysics.kt @@ -0,0 +1,456 @@ +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.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.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.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 + +/** 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 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): CardRest = CardRest( + tiltDegrees = unitNoise(seed, 1) * REST_TILT_DEGREES, + driftX = unitNoise(seed, 2) * REST_DRIFT_FRACTION, + driftY = unitNoise(seed, 3) * REST_DRIFT_FRACTION, + spinDegrees = unitNoise(seed, 4) * THROW_SPIN_DEGREES, + lateness = (unitNoise(seed, 5) + 1f) / 2f, +) + +/** 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 +} + +/** + * 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) +} + +private fun blend(from: Float, to: Float, fraction: Float): Float = + from + (to - from) * fraction + +// --------------------------------------------------------------------------- +// 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 = 4 + +/** 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 * REST_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 = cardRest(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/EuchreScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/EuchreScreen.kt index 3165a39..8f2bc38 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,7 +223,9 @@ 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) Box(modifier = Modifier.fillMaxSize().padding(4.dp)) { @@ -249,45 +254,74 @@ 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 { + scaleX = landing.scale + scaleY = landing.scale + alpha = landing.alpha * (1f - 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 +483,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..259d64b 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), + ) + } } } } 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..fe51d58 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,7 +214,9 @@ 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) Box(modifier = Modifier.fillMaxSize().padding(4.dp)) { if (cardsOnTable.isEmpty()) { @@ -230,43 +232,64 @@ 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 { + scaleX = landing.scale + scaleY = landing.scale + alpha = landing.alpha * (1f - 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 +429,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..5894bc8 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 @@ -20,8 +20,11 @@ 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.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -155,9 +158,36 @@ 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)) { - state.pile.takeLast(state.setSize).forEach { card -> - PlayingCardView(card = card, width = cardWidth) + 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), + ) + } } } Text( @@ -258,7 +288,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..c780608 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,9 @@ 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) if (cardsOnTable.isEmpty()) { Text( @@ -241,37 +243,62 @@ 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 { + scaleX = landing.scale + scaleY = landing.scale + alpha = landing.alpha * (1f - 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 +422,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/ui/game/CardPhysicsTest.kt b/app/src/test/java/org/prolibertate/games/ui/game/CardPhysicsTest.kt new file mode 100644 index 0000000..0457f35 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/ui/game/CardPhysicsTest.kt @@ -0,0 +1,165 @@ +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))) + } + + /** 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 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