Model card play as cards landing on a table

Cards appeared and disappeared. A card was drawn at its resting place
the frame it was played, and a finished trick slid off in a straight
line at a constant speed with every card starting and stopping
together. Nothing about that reads as a hand putting a card down.

What a real card does is arrive from the side of the person who threw
it, carry past the spot it was aimed at and rock back, and finish a
little crooked and a little off the mark. That is the whole model. There
are no collisions and no ragdolls, because the difference between cards
on a table and cards thrown down a stairwell is entirely the size of the
numbers: the tilt is capped at four and a half degrees and the drift at
five per cent of a card width, and both are smaller than they want to
be.

The flight is one underdamped spring on a single 0-to-1 value, with
position, angle and height above the table all read off it. The
overshoot is the spring passing 1 and returning, so the card carries and
settles as one motion rather than as three animations that happen to
start together. Driving them apart was tried and looked like it.

The scatter is hashed out of the card's identity rather than drawn from
a generator. A generator would give a card a new angle on every
recomposition, which twitches; hashing the list position instead would
make a card jump whenever another landed beside it. Hashed this way a
card lies the same way for as long as it lies there, and no two cards in
a deck lie the same way as each other. The pure half of that — the
hash, the bounds, the sweep stagger — is unit tested, since it is not
something a screenshot would show.

The sweep now staggers and accelerates: cards break away over the first
third of it rather than in lockstep, each carrying out the same turn it
carried in. Piles are drawn as the last few cards of the heap and stocks
as a pack with depth, so a card played onto a discard pile lands on
something. Every card gains the shadow of a card lying on felt.

Applied to Euchre, Kaiser, Wizard and Hearts at the trick, and to
Crazy 8s, President and Golf at the pile; hands get a tilt small enough
that nobody sees it and large enough that a row of cards stops looking
printed. Motion follows the existing animation-speed setting through a
composition local, so the slider still governs all of it.

Series bumped to 1.3 for the dev channel.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0132V9gE6DF9itz2z3Hn7BK9
This commit is contained in:
Claude
2026-08-13 20:45:37 +00:00
parent c471839b60
commit 7c839a9242
13 changed files with 950 additions and 138 deletions
+2 -2
View File
@@ -173,7 +173,7 @@ production package instead.
- `.github/workflows/ci.yml` — builds, tests and lints on `dev` and on pull - `.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 requests into `dev` or `main`. Its APK artifact is a dev build, versioned
`<series>.0-dev` — the series being whatever `plgVersionSeries` says in `<series>.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 - `.github/workflows/release.yml` — on push to `main`, builds the APK and
publishes it as a GitHub Release tagged `v<series>.<run-number>`. The in-app updater publishes it as a GitHub Release tagged `v<series>.<run-number>`. The in-app updater
reads that tag, which is why the APK's `versionCode` is the same run number. reads that tag, which is why the APK's `versionCode` is the same run number.
@@ -201,7 +201,7 @@ A version is **`<series>.<run number>`**. The series is set by hand, once, in
`gradle.properties`: `gradle.properties`:
```properties ```properties
plgVersionSeries=1.1 plgVersionSeries=1.3
``` ```
Both workflows read that same property when they name a release tag, and the Both workflows read that same property when they name a release tag, and the
@@ -26,6 +26,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf 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.score.ScorekeeperRepository
import org.prolibertate.games.settings.Settings import org.prolibertate.games.settings.Settings
import org.prolibertate.games.settings.SettingsRepository import org.prolibertate.games.settings.SettingsRepository
import org.prolibertate.games.ui.game.LocalCardSpeed
import org.prolibertate.games.update.UpdateChannel import org.prolibertate.games.update.UpdateChannel
import org.prolibertate.games.update.Updater import org.prolibertate.games.update.Updater
@@ -121,6 +123,10 @@ fun AppRoot(env: AppEnv) {
var splashDone by remember { mutableStateOf(false) } var splashDone by remember { mutableStateOf(false) }
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
// 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( AppContent(
env = env, env = env,
settings = settings, settings = settings,
@@ -129,6 +135,7 @@ fun AppRoot(env: AppEnv) {
push = push, push = push,
pop = pop, pop = pop,
) )
}
// Over the top, so the menu behind it is already composed when it fades. // Over the top, so the menu behind it is already composed when it fades.
if (!splashDone) { if (!splashDone) {
@@ -18,6 +18,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
@@ -41,6 +42,13 @@ fun PlayingCardView(
width: Dp = 64.dp, width: Dp = 64.dp,
selected: Boolean = false, selected: Boolean = false,
enabled: Boolean = true, 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 * 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 * say what the card does — a Sequence jack being the obvious case, since
@@ -54,6 +62,7 @@ fun PlayingCardView(
modifier = modifier modifier = modifier
.width(width) .width(width)
.aspectRatio(CARD_ASPECT) .aspectRatio(CARD_ASPECT)
.shadow(elevation, RoundedCornerShape(width * 0.12f))
.clip(RoundedCornerShape(width * 0.12f)) .clip(RoundedCornerShape(width * 0.12f))
.background(Color.White) .background(Color.White)
.border( .border(
@@ -102,11 +111,13 @@ fun PlayingCardView(
fun CardBackView( fun CardBackView(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
width: Dp = 64.dp, width: Dp = 64.dp,
elevation: Dp = CARD_RESTING_ELEVATION,
) { ) {
Box( Box(
modifier = modifier modifier = modifier
.width(width) .width(width)
.aspectRatio(CARD_ASPECT) .aspectRatio(CARD_ASPECT)
.shadow(elevation, RoundedCornerShape(width * 0.12f))
.clip(RoundedCornerShape(width * 0.12f)) .clip(RoundedCornerShape(width * 0.12f))
.background(MaterialTheme.colorScheme.primary) .background(MaterialTheme.colorScheme.primary)
.border(1.dp, Color(0x33000000), RoundedCornerShape(width * 0.12f)), .border(1.dp, Color(0x33000000), RoundedCornerShape(width * 0.12f)),
@@ -121,3 +132,11 @@ fun CardBackView(
} }
const val CARD_ASPECT = 5f / 7f 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
@@ -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<Float, Float> =
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<Float, Float> {
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<Card>,
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),
)
}
}
}
@@ -27,6 +27,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
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, color = MaterialTheme.colorScheme.onPrimary,
) )
} else { } 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) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
@@ -190,7 +194,9 @@ private fun PileArea(state: CrazyEightsState, cardWidth: Dp) {
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimary, 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 // An eight on top says nothing about what may be played next, so the
@@ -331,6 +337,7 @@ private fun HandRow(
enabled = canPlay, enabled = canPlay,
selected = card == pendingWild, selected = card == pendingWild,
caption = if (isWild(card)) "WILD" else null, caption = if (isWild(card)) "WILD" else null,
modifier = Modifier.rotate(handTilt(cardSeed(card))),
onClick = { onTap(card) }, onClick = { onTap(card) },
) )
} }
@@ -1,5 +1,6 @@
package org.prolibertate.games.ui.game package org.prolibertate.games.ui.game
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.background import androidx.compose.foundation.background
@@ -25,6 +26,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue 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.text.font.FontWeight
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset 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. */ /** How long a completed trick rests on the table before being swept away. */
const val TRICK_HOLD_MILLIS = 1_000L const val TRICK_HOLD_MILLIS = 1_000L
/** Duration of the sweep itself. */
private const val TRICK_SWEEP_MILLIS = 450
@Composable @Composable
private fun TrickArea( private fun TrickArea(
state: EuchreState, state: EuchreState,
@@ -206,9 +206,12 @@ private fun TrickArea(
sweepStarted = true 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( val sweep by animateFloatAsState(
targetValue = if (sweeping && sweepStarted) 1f else 0f, targetValue = if (sweeping && sweepStarted) 1f else 0f,
animationSpec = tween(durationMillis = TRICK_SWEEP_MILLIS), animationSpec = tween(durationMillis = motionMillis(CARD_SWEEP_MILLIS), easing = LinearEasing),
label = "trickSweep", label = "trickSweep",
) )
@@ -220,7 +223,9 @@ private fun TrickArea(
2 -> 0f to -1f 2 -> 0f to -1f
else -> 1f to 0f 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)) { Box(modifier = Modifier.fillMaxSize().padding(4.dp)) {
@@ -249,7 +254,13 @@ 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 -> cardsOnTable.forEach { played ->
key(played.seat) {
val relative = relativePosition(played.seat, localSeat) val relative = relativePosition(played.seat, localSeat)
val alignment = when (relative) { val alignment = when (relative) {
0 -> Alignment.BottomCenter 0 -> Alignment.BottomCenter
@@ -257,31 +268,53 @@ private fun TrickArea(
2 -> Alignment.TopCenter 2 -> Alignment.TopCenter
else -> Alignment.CenterEnd else -> Alignment.CenterEnd
} }
// Rotate to face the player who played it: the side seats end up // Turned to face the player who played it: the side seats end
// landscape, the partner's card upside down from your side. // up landscape, the partner's card upside down from your side.
val rotation = when (relative) { val facing = when (relative) {
1 -> 90f 1 -> 90f
2 -> 180f 2 -> 180f
3 -> -90f 3 -> -90f
else -> 0f else -> 0f
} }
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( Column(
modifier = Modifier modifier = Modifier
.align(alignment) .align(alignment)
.offset { .offset {
IntOffset( IntOffset(
x = (dirX * travelPx * sweep).roundToInt(), x = (landing.offsetX + dirX * travelPx * leaving).roundToInt(),
y = (dirY * travelPx * sweep).roundToInt(), y = (landing.offsetY + dirY * travelPx * leaving).roundToInt(),
) )
} }
.alpha(1f - sweep), .graphicsLayer {
scaleX = landing.scale
scaleY = landing.scale
alpha = landing.alpha * (1f - leaving)
},
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
PlayingCardView( PlayingCardView(
card = played.card, card = played.card,
width = cardWidth, width = cardWidth,
modifier = Modifier.rotate(rotation), 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(
text = seatLabel(relative), text = seatLabel(relative),
@@ -290,6 +323,7 @@ private fun TrickArea(
) )
} }
} }
}
// Name the winner while their cards are still on the table. // Name the winner while their cards are still on the table.
if (sweeping && state.lastTrickWinner != null) { if (sweeping && state.lastTrickWinner != null) {
@@ -449,6 +483,11 @@ private fun HandRow(
card = card, card = card,
width = cardWidth, width = cardWidth,
enabled = canPlay || canDiscard, 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 = { onClick = {
when { when {
canDiscard -> onPlay(Discard(card)) canDiscard -> onPlay(Discard(card))
@@ -252,7 +252,14 @@ private fun StockAndDiscard(
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Stock", style = MaterialTheme.typography.labelSmall, Text("Stock", style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimary) 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)) { if (legal.contains(DrawFromStock)) {
Button(onClick = { onMove(DrawFromStock) }) { Text("Draw") } Button(onClick = { onMove(DrawFromStock) }) { Text("Draw") }
} }
@@ -261,7 +268,7 @@ private fun StockAndDiscard(
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Discard", style = MaterialTheme.typography.labelSmall, Text("Discard", style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimary) color = MaterialTheme.colorScheme.onPrimary)
state.discard.lastOrNull()?.let { PlayingCardView(card = it, width = cardWidth) } CardPile(cards = state.discard, width = cardWidth)
if (legal.contains(DrawFromDiscard)) { if (legal.contains(DrawFromDiscard)) {
Button(onClick = { onMove(DrawFromDiscard) }) { Text("Take") } Button(onClick = { onMove(DrawFromDiscard) }) { Text("Take") }
} }
@@ -18,6 +18,7 @@ import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -25,6 +26,8 @@ import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateListOf
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import org.prolibertate.games.game.cards.Card import org.prolibertate.games.game.cards.Card
@@ -126,6 +129,7 @@ fun HeartsScreen(
selected = chosen.contains(card), selected = chosen.contains(card),
enabled = if (selectable) true else playable.contains(card), enabled = if (selectable) true else playable.contains(card),
caption = pointsOf(card).takeIf { it > 0 }?.let { "−$it" }, caption = pointsOf(card).takeIf { it > 0 }?.let { "−$it" },
modifier = Modifier.rotate(handTilt(cardSeed(card))),
onClick = { onClick = {
if (selectable) { if (selectable) {
toggle(chosen, card) toggle(chosen, card)
@@ -188,18 +192,40 @@ private fun TrickView(state: HeartsState) {
if (showing.isEmpty()) { if (showing.isEmpty()) {
Text("Nothing played yet.", style = MaterialTheme.typography.bodySmall) Text("Nothing played yet.", style = MaterialTheme.typography.bodySmall)
} else { } 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)) { FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
showing.forEach { played -> showing.forEach { played ->
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( PlayingCardView(
card = played.card, card = played.card,
width = 46.dp, width = width,
caption = "seat ${played.seat}", caption = "seat ${played.seat}",
elevation = landing.elevation,
modifier = Modifier.landed(landing),
) )
} }
} }
} }
} }
} }
}
@Composable @Composable
private fun ScoreRow(state: HeartsState, localSeat: Int) { private fun ScoreRow(state: HeartsState, localSeat: Int) {
@@ -1,5 +1,6 @@
package org.prolibertate.games.ui.game package org.prolibertate.games.ui.game
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.background import androidx.compose.foundation.background
@@ -25,6 +26,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -32,6 +34,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
@@ -181,9 +184,6 @@ private fun seatLabel(relative: Int): String = when (relative) {
else -> "Right" else -> "Right"
} }
/** Duration of the sweep towards the winner. */
private const val SWEEP_MILLIS = 450
@Composable @Composable
private fun TrickArea( private fun TrickArea(
state: KaiserState, state: KaiserState,
@@ -203,7 +203,7 @@ private fun TrickArea(
} }
val sweep by animateFloatAsState( val sweep by animateFloatAsState(
targetValue = if (sweeping && sweepStarted) 1f else 0f, targetValue = if (sweeping && sweepStarted) 1f else 0f,
animationSpec = tween(durationMillis = SWEEP_MILLIS), animationSpec = tween(durationMillis = motionMillis(CARD_SWEEP_MILLIS), easing = LinearEasing),
label = "kaiserSweep", label = "kaiserSweep",
) )
@@ -214,7 +214,9 @@ private fun TrickArea(
2 -> 0f to -1f 2 -> 0f to -1f
else -> 1f to 0f 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)) { Box(modifier = Modifier.fillMaxSize().padding(4.dp)) {
if (cardsOnTable.isEmpty()) { if (cardsOnTable.isEmpty()) {
@@ -230,6 +232,7 @@ private fun TrickArea(
} }
cardsOnTable.forEach { played -> cardsOnTable.forEach { played ->
key(played.seat) {
val relative = relativePosition(played.seat, localSeat) val relative = relativePosition(played.seat, localSeat)
val alignment = when (relative) { val alignment = when (relative) {
0 -> Alignment.BottomCenter 0 -> Alignment.BottomCenter
@@ -237,30 +240,49 @@ private fun TrickArea(
2 -> Alignment.TopCenter 2 -> Alignment.TopCenter
else -> Alignment.CenterEnd else -> Alignment.CenterEnd
} }
val rotation = when (relative) { val facing = when (relative) {
1 -> 90f 1 -> 90f
2 -> 180f 2 -> 180f
3 -> -90f 3 -> -90f
else -> 0f else -> 0f
} }
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( Column(
modifier = Modifier modifier = Modifier
.align(alignment) .align(alignment)
.offset { .offset {
IntOffset( IntOffset(
x = (dirX * travelPx * sweep).roundToInt(), x = (landing.offsetX + dirX * travelPx * leaving).roundToInt(),
y = (dirY * travelPx * sweep).roundToInt(), y = (landing.offsetY + dirY * travelPx * leaving).roundToInt(),
) )
} }
.alpha(1f - sweep), .graphicsLayer {
scaleX = landing.scale
scaleY = landing.scale
alpha = landing.alpha * (1f - leaving)
},
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
PlayingCardView( PlayingCardView(
card = played.card, card = played.card,
width = cardWidth, width = cardWidth,
caption = counterCaption(played.card), caption = counterCaption(played.card),
modifier = Modifier.rotate(rotation), elevation = landing.elevation,
modifier = Modifier
.rotate(landing.rotation + rest.spinDegrees * leaving),
) )
Text( Text(
text = seatLabel(relative), text = seatLabel(relative),
@@ -269,6 +291,7 @@ private fun TrickArea(
) )
} }
} }
}
if (sweeping && state.lastTrickWinner != null) { if (sweeping && state.lastTrickWinner != null) {
Text( Text(
@@ -406,6 +429,7 @@ private fun HandRow(
width = cardWidth, width = cardWidth,
enabled = canPlay, enabled = canPlay,
caption = counterCaption(card), caption = counterCaption(card),
modifier = Modifier.rotate(handTilt(cardSeed(card))),
onClick = { if (canPlay) onPlay(PlayCard(card)) }, onClick = { if (canPlay) onPlay(PlayCard(card)) },
) )
} }
@@ -20,8 +20,11 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.text.font.FontWeight
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
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, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimary, 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)) { Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
state.pile.takeLast(state.setSize).forEach { card -> onTop.forEachIndexed { offsetInSet, card ->
PlayingCardView(card = card, width = cardWidth) 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( Text(
@@ -258,7 +288,11 @@ private fun HandView(state: PresidentState, localSeat: Int, cardWidth: Dp) {
Text("—", style = MaterialTheme.typography.bodySmall) Text("—", style = MaterialTheme.typography.bodySmall)
} }
hand.forEach { card -> hand.forEach { card ->
PlayingCardView(card = card, width = cardWidth * 0.8f) PlayingCardView(
card = card,
width = cardWidth * 0.8f,
modifier = Modifier.rotate(handTilt(cardSeed(card))),
)
} }
} }
} }
@@ -1,5 +1,6 @@
package org.prolibertate.games.ui.game package org.prolibertate.games.ui.game
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.background import androidx.compose.foundation.background
@@ -26,6 +27,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -33,6 +35,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp 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 = private fun seatLabel(seat: Int, localSeat: Int): String =
if (seat == localSeat) "You" else "Seat $seat" if (seat == localSeat) "You" else "Seat $seat"
/** Duration of the sweep towards the winner. */
private const val SWEEP_MILLIS = 450
@Composable @Composable
private fun TrickArea( private fun TrickArea(
state: WizardState, state: WizardState,
@@ -213,7 +213,7 @@ private fun TrickArea(
} }
val sweep by animateFloatAsState( val sweep by animateFloatAsState(
targetValue = if (sweeping && sweepStarted) 1f else 0f, targetValue = if (sweeping && sweepStarted) 1f else 0f,
animationSpec = tween(durationMillis = SWEEP_MILLIS), animationSpec = tween(durationMillis = motionMillis(CARD_SWEEP_MILLIS), easing = LinearEasing),
label = "wizardSweep", label = "wizardSweep",
) )
@@ -226,7 +226,9 @@ private fun TrickArea(
// Cards sit on a circle that fits inside whatever space the table has. // 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 radius = (minOf(maxWidth, maxHeight) / 2 - cardWidth * 0.7f).coerceAtLeast(0.dp)
val radiusPx = with(LocalDensity.current) { radius.toPx() } 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()) { if (cardsOnTable.isEmpty()) {
Text( Text(
@@ -241,31 +243,55 @@ private fun TrickArea(
} }
cardsOnTable.forEach { played -> cardsOnTable.forEach { played ->
key(played.seat) {
val relative = ((played.seat - localSeat) % players + players) % players val relative = ((played.seat - localSeat) % players + players) % players
val angle = 2.0 * PI * relative / players val angle = 2.0 * PI * relative / players
// Relative seat 0 is you, at the bottom; the rest run clockwise. // Relative seat 0 is you, at the bottom; the rest run clockwise.
val x = -sin(angle) * radiusPx val x = -sin(angle) * radiusPx
val y = cos(angle) * radiusPx val y = cos(angle) * radiusPx
// Turn each card to face whoever played it. // Turn each card to face whoever played it.
val rotation = (relative * 360f / players) val facing = (relative * 360f / players)
// 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( Column(
modifier = Modifier modifier = Modifier
.align(Alignment.Center) .align(Alignment.Center)
.offset { .offset {
IntOffset( IntOffset(
x = (x + -sin(winnerAngle) * travelPx * sweep).roundToInt(), x = (x + landing.offsetX +
y = (y + cos(winnerAngle) * travelPx * sweep).roundToInt(), -sin(winnerAngle) * travelPx * leaving).roundToInt(),
y = (y + landing.offsetY +
cos(winnerAngle) * travelPx * leaving).roundToInt(),
) )
} }
.alpha(1f - sweep), .graphicsLayer {
scaleX = landing.scale
scaleY = landing.scale
alpha = landing.alpha * (1f - leaving)
},
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
PlayingCardView( PlayingCardView(
card = played.card, card = played.card,
width = cardWidth, width = cardWidth,
caption = specialCaption(played.card), caption = specialCaption(played.card),
modifier = Modifier.rotate(rotation), elevation = landing.elevation,
modifier = Modifier
.rotate(landing.rotation + rest.spinDegrees * leaving),
) )
Text( Text(
text = seatLabel(played.seat, localSeat), text = seatLabel(played.seat, localSeat),
@@ -274,6 +300,7 @@ private fun TrickArea(
) )
} }
} }
}
if (sweeping && state.lastTrickWinner != null) { if (sweeping && state.lastTrickWinner != null) {
Text( Text(
@@ -395,6 +422,7 @@ private fun HandRow(
width = cardWidth, width = cardWidth,
enabled = canPlay, enabled = canPlay,
caption = specialCaption(card), caption = specialCaption(card),
modifier = Modifier.rotate(handTilt(cardSeed(card))),
onClick = { if (canPlay) onPlay(PlayCard(card)) }, onClick = { if (canPlay) onPlay(PlayCard(card)) },
) )
} }
@@ -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)
}
}
}
+1 -1
View File
@@ -22,4 +22,4 @@ android.nonTransitiveRClass=true
# Only the series is set by hand. The number after it is the CI run number, so # 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 # it keeps climbing on its own and an APK's versionCode never goes backwards
# when this changes. # when this changes.
plgVersionSeries=1.2 plgVersionSeries=1.3