Merge remote-tracking branch 'origin/dev' into claude/locked-phone-turn-issue-x9pvbv

This commit is contained in:
Claude
2026-08-17 15:33:41 +00:00
17 changed files with 1515 additions and 143 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
@@ -92,6 +92,14 @@ data class KaiserState(
val lastTrickWinner: Int?, val lastTrickWinner: Int?,
/** Points taken this hand, per team. */ /** Points taken this hand, per team. */
val handPoints: List<Int>, val handPoints: List<Int>,
/**
* Tricks taken this hand, per seat. Kaiser scores in points rather than
* tricks, so nothing in the rules needs this — it is what the table draws
* in front of each player, and a player who has taken four tricks and no
* counters has still taken four tricks. Defaulted so a client on an older
* build can still read a state from the wire.
*/
val tricksWon: List<Int> = List(4) { 0 },
val scores: List<Int>, val scores: List<Int>,
val log: List<String>, val log: List<String>,
) )
@@ -62,6 +62,7 @@ object KaiserRules : GameRules<KaiserState, KaiserMove> {
leader = (dealer + 1) % 4, leader = (dealer + 1) % 4,
lastTrickWinner = null, lastTrickWinner = null,
handPoints = listOf(0, 0), handPoints = listOf(0, 0),
tricksWon = List(4) { 0 },
scores = scores, scores = scores,
log = log, log = log,
) )
@@ -224,10 +225,14 @@ object KaiserRules : GameRules<KaiserState, KaiserMove> {
val handPoints = played.handPoints.toMutableList() val handPoints = played.handPoints.toMutableList()
handPoints[teamOf(winner)] = handPoints[teamOf(winner)] + points handPoints[teamOf(winner)] = handPoints[teamOf(winner)] + points
val tricksWon = played.tricksWon.toMutableList()
tricksWon[winner] = tricksWon[winner] + 1
val afterTrick = played.copy( val afterTrick = played.copy(
trick = emptyList(), trick = emptyList(),
completedTrick = trick, completedTrick = trick,
handPoints = handPoints, handPoints = handPoints,
tricksWon = tricksWon,
leader = winner, leader = winner,
turn = winner, turn = winner,
lastTrickWinner = winner, lastTrickWinner = winner,
@@ -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,14 +123,19 @@ fun AppRoot(env: AppEnv) {
var splashDone by remember { mutableStateOf(false) } var splashDone by remember { mutableStateOf(false) }
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
AppContent( // Card motion reads its speed from here rather than from a parameter.
env = env, // Every card screen animates, most of them several layers deep, and the
settings = settings, // alternative was passing one unchanging number down through all of it.
stack = stack, CompositionLocalProvider(LocalCardSpeed provides settings.animationSpeed) {
showComingSoon = showComingSoon, AppContent(
push = push, env = env,
pop = pop, settings = settings,
) stack = stack,
showComingSoon = showComingSoon,
push = push,
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,708 @@
package org.prolibertate.games.ui.game
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.spring
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.cos
import kotlin.math.roundToInt
import kotlin.math.sin
import kotlinx.coroutines.delay
import org.prolibertate.games.game.cards.Card
import org.prolibertate.games.settings.Settings
import org.prolibertate.games.ui.common.CARD_ASPECT
import org.prolibertate.games.ui.common.CARD_RESTING_ELEVATION
import org.prolibertate.games.ui.common.CardBackView
import org.prolibertate.games.ui.common.PlayingCardView
/**
* How cards move.
*
* A card thrown onto a table does three things that a card faded into place
* does not: it arrives from the side of the person who threw it, it overshoots
* and rocks back rather than stopping dead, and it comes to rest a little
* crooked and a little off the spot it was aimed at. This file is those three
* things, and nothing else — no ragdolls, no collisions. Real cards played by
* a real hand are only slightly random, and the whole difficulty is in keeping
* the randomness *small*: a table where every card lands at a jaunty angle
* stops looking like cards and starts looking like a screensaver.
*
* The randomness is therefore bounded and deterministic. Bounded because every
* scatter is a fraction of a card width or a handful of degrees; deterministic
* because it is hashed out of the card's own identity rather than drawn from a
* generator, which is what stops a card twitching to a new angle every time
* something else on the screen recomposes. The same card in the same place
* always lies the same way, and two cards never lie the same way as each other.
*/
// ---------------------------------------------------------------------------
// Bounds. Every one of these is deliberately smaller than it wants to be.
// ---------------------------------------------------------------------------
/** Most a settled card is left turned by, in degrees. */
const val REST_TILT_DEGREES = 4.5f
/** Most a settled card misses its spot by, as a fraction of its own width. */
const val REST_DRIFT_FRACTION = 0.055f
/**
* The same two bounds for a card thrown onto a heap rather than laid in front
* of a player, which is a good deal less tidy. They are also what makes a heap
* legible: with the tight bounds a discard pile of five cards looks like one
* card, because the four underneath are hidden to within a twentieth of a card
* width of the top one.
*/
const val HEAP_TILT_DEGREES = 8f
const val HEAP_DRIFT_FRACTION = 0.15f
/** Extra turn a card carries while it is still travelling. */
const val THROW_SPIN_DEGREES = 15f
/** Tilt of a card held in a hand, where the pack has been squared up. */
const val HAND_TILT_DEGREES = 1.6f
/** How far off the table a thrown card starts, in card widths. */
const val THROW_DISTANCE = 2.2f
/** How much bigger a card looks while it is above the table rather than on it. */
private const val THROW_SCALE = 1.11f
/**
* Shadow under a card in flight. What it settles to is the ordinary resting
* elevation every other card on the screen already has, read from there rather
* than repeated, so a card that has just landed is indistinguishable from one
* that has been lying there all along.
*/
private const val THROW_ELEVATION_DP = 9f
private val REST_ELEVATION_DP = CARD_RESTING_ELEVATION.value
/**
* Bounce in the settle. Below 1 is an underdamped spring, so the card carries
* past its resting place and comes back — which is the whole point. Much below
* this and it wobbles like rubber.
*/
private const val SETTLE_DAMPING = 0.62f
/** Stiffness that gives [CARD_THROW_MILLIS] at normal speed. */
private const val SETTLE_STIFFNESS = 420f
/** Nominal flight time of a card, before the animation-speed setting. */
const val CARD_THROW_MILLIS = 380
/** Nominal time a finished trick takes to be gathered up. */
const val CARD_SWEEP_MILLIS = 520
/**
* How much of the sweep is spent letting the stragglers go. Cards do not all
* leave at once when a hand scoops them up, but they do all leave quickly.
*/
private const val SWEEP_SPREAD = 0.3f
/**
* The last stretch of the sweep, over which a gathered card fades out. It has
* to be late: the cards vanish exactly as the winner's pile grows by one, and
* that swap is only invisible if they are still whole most of the way there.
*/
private const val SWEEP_FADE = 0.42f
/** How small four cards squared into a bundle end up against one card face. */
private const val GATHERED_SCALE = 0.62f
// ---------------------------------------------------------------------------
// The pure part: where a card lands, and how far through leaving it is.
// ---------------------------------------------------------------------------
/**
* A hash, not a random number.
*
* Two rounds of a linear congruential step with a shift between them, which is
* more than enough to stop neighbouring seeds producing neighbouring angles —
* the visible failure would be a whole suit landing at the same tilt.
*/
private fun scramble(seed: Int): Int {
var h = seed
repeat(2) {
h = h * 1_664_525 + 1_013_904_223
h = h xor (h ushr 15)
}
return h
}
/**
* A value in `[-1, 1)` for this [seed] and [salt], stable for the life of the
* program and identical between runs. [salt] separates the several independent
* numbers one card needs from a single seed.
*/
fun unitNoise(seed: Int, salt: Int): Float {
val bits = scramble(seed * 31 + salt * 0x5F5F) ushr 8
return bits / 8_388_608f - 1f
}
/** A seed for one card, optionally distinguished by where it is lying. */
fun cardSeed(card: Card, place: Int = 0): Int =
(card.rank.ordinal * 4 + card.suit.ordinal) * 71 + place * 7919
/**
* Where a card has come to rest, and how it got there.
*
* All four scatters come off one seed, so the same card always lies the same
* way: [tiltDegrees] and the two drifts are where it ended up, [spinDegrees] is
* the turn it was still carrying on the way in, and [lateness] is its place in
* the queue when a group of cards moves together.
*/
@Immutable
data class CardRest(
val tiltDegrees: Float,
val driftX: Float,
val driftY: Float,
val spinDegrees: Float,
val lateness: Float,
)
/** The resting scatter for [seed], within the bounds set at the top of the file. */
fun cardRest(
seed: Int,
tilt: Float = REST_TILT_DEGREES,
drift: Float = REST_DRIFT_FRACTION,
): CardRest = CardRest(
tiltDegrees = unitNoise(seed, 1) * tilt,
driftX = unitNoise(seed, 2) * drift,
driftY = unitNoise(seed, 3) * drift,
spinDegrees = unitNoise(seed, 4) * THROW_SPIN_DEGREES,
lateness = (unitNoise(seed, 5) + 1f) / 2f,
)
/**
* The same, at the looser bounds a heap gets.
*
* A card keeps this scatter for as long as it is on the heap, including once
* something has been played on top of it. Switching a card to a wider scatter
* the moment it stopped being the top card would make it shuffle sideways on
* its own, which is the one thing on a table that never happens.
*/
fun heapRest(seed: Int): CardRest = cardRest(seed, HEAP_TILT_DEGREES, HEAP_DRIFT_FRACTION)
/** The small tilt of a card sitting in a squared-up hand. */
fun handTilt(seed: Int): Float = unitNoise(seed, 6) * HAND_TILT_DEGREES
/**
* Where a card thrown by a seat comes in from, in card widths, for a four-seat
* table where relative seat 0 sits at the bottom and the rest run clockwise.
* A card arrives from beyond its own player's edge, which is the direction it
* would have been thrown from.
*/
fun seatOrigin(relative: Int, distance: Float = THROW_DISTANCE): Pair<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
}
/**
* Where a card that has been played *on* lies, given how much ground the cards
* now covering it take up — half the width and half the height of it, in
* pixels.
*
* Pushed out towards the edge of that ground rather than scattered evenly
* across it. A card buried dead centre under a set of four is a card nobody can
* see, and the entire point of still drawing it is that you can tell the heap
* has a history. Out at the edge a corner shows, which is what a real heap
* looks like from above.
*/
fun buriedOffset(seed: Int, halfWidth: Float, halfHeight: Float): Pair<Float, Float> {
val angle = (unitNoise(seed, 11) + 1f) * PI.toFloat()
// Never right at the rim and never far inside it.
val reach = 0.72f + 0.28f * abs(unitNoise(seed, 12))
return cos(angle) * halfWidth * reach to sin(angle) * halfHeight * reach
}
/**
* Acceleration curve for cards being dragged off a table: slow to break away,
* then gone. Written out rather than taken from an easing class so the sweep
* arithmetic stays plain Kotlin and can be tested without a composition.
*/
private fun accelerate(x: Float): Float = x * x * (0.35f + 0.65f * x)
/**
* How far through leaving a single card is, given how far through the sweep as
* a whole is. Cards start over a window of [spread] rather than together, so a
* trick gathers up in one motion rather than four cards moving in lockstep.
*/
fun sweepProgress(sweep: Float, rest: CardRest, spread: Float = SWEEP_SPREAD): Float {
val start = rest.lateness.coerceIn(0f, 1f) * spread.coerceIn(0f, 0.9f)
val raw = ((sweep - start) / (1f - start)).coerceIn(0f, 1f)
return accelerate(raw)
}
/** Straight interpolation, [fraction] being free to overshoot past one. */
fun blend(from: Float, to: Float, fraction: Float): Float =
from + (to - from) * fraction
/**
* How solid a card being gathered up still is. Whole for most of the journey
* and gone at the end of it, so it disappears at the moment the winner's pile
* grows rather than dissolving on the way over.
*/
fun gatherAlpha(leaving: Float): Float = ((1f - leaving) / SWEEP_FADE).coerceIn(0f, 1f)
/** And how far it has been squared down into the bundle it is joining. */
fun gatherScale(leaving: Float): Float = blend(1f, GATHERED_SCALE, leaving.coerceIn(0f, 1f))
// ---------------------------------------------------------------------------
// The Compose part.
// ---------------------------------------------------------------------------
/**
* The animation-speed setting, so card motion follows the same slider as
* everything else. A composition local rather than a parameter because it would
* otherwise have to be threaded through every card screen and down into every
* pile inside them, to say one number that never changes mid-game.
*/
val LocalCardSpeed = staticCompositionLocalOf { 1f }
/** A nominal duration, adjusted for the speed the player has chosen. */
@Composable
fun motionMillis(nominal: Int): Int {
val speed = LocalCardSpeed.current.coerceIn(Settings.MIN_SPEED, Settings.MAX_SPEED)
return (nominal / speed).toInt().coerceAtLeast(1)
}
/**
* Where a card is, and what it looks like, part way through being thrown.
*
* [alpha] is only ever 0 or 1, and only ever 0 for a card waiting its turn in a
* set that is being played together. Without it a delayed card would sit
* plainly visible two card widths off the table until its moment came, which
* reads as a card teleporting out and flying back rather than as one still in
* somebody's hand.
*/
@Immutable
data class CardLanding(
val offsetX: Float,
val offsetY: Float,
val rotation: Float,
val scale: Float,
val elevation: Dp,
val alpha: Float = 1f,
)
/**
* Follows one card from the moment it is played until it has stopped moving.
*
* The whole flight is one underdamped spring on a single 0-to-1 value, and
* everything else is read off it. That is why the card overshoots its resting
* place and rocks back instead of easing to a halt: the spring passes 1 and
* returns, and the position, the angle and the height above the table all
* follow it there and back together. Driving them separately was tried and
* looked like three animations that happened to start at the same time.
*
* [key] is what counts as a new card arriving. It must change when the card in
* this place changes and at no other time, or the card either fails to animate
* or animates again every recomposition.
*/
@Composable
fun rememberCardLanding(
key: Any?,
rest: CardRest,
facingDegrees: Float,
fromX: Float,
fromY: Float,
cardWidthPx: Float,
durationMillis: Int,
delayMillis: Int = 0,
): CardLanding {
val settle = remember(key) { Animatable(0f) }
var launched by remember(key) { mutableStateOf(delayMillis <= 0) }
LaunchedEffect(key) {
if (delayMillis > 0) {
delay(delayMillis.toLong())
launched = true
}
settle.animateTo(
targetValue = 1f,
animationSpec = spring(
dampingRatio = SETTLE_DAMPING,
stiffness = SETTLE_STIFFNESS * CARD_THROW_MILLIS / durationMillis,
),
)
}
val travelled = settle.value
// Height above the table is the one thing that must not overshoot: a card
// that has landed cannot rise again, whatever the spring is still doing.
val landed = travelled.coerceIn(0f, 1f)
return CardLanding(
offsetX = blend(fromX, rest.driftX * cardWidthPx, travelled),
offsetY = blend(fromY, rest.driftY * cardWidthPx, travelled),
rotation = blend(
facingDegrees + rest.spinDegrees,
facingDegrees + rest.tiltDegrees,
travelled,
),
scale = blend(THROW_SCALE, 1f, landed),
elevation = blend(THROW_ELEVATION_DP, REST_ELEVATION_DP, landed).dp,
alpha = if (launched) 1f else 0f,
)
}
/** Puts a card where its [landing] says it is. For cards drawn on their own. */
fun Modifier.landed(landing: CardLanding): Modifier = this
.offset { IntOffset(landing.offsetX.roundToInt(), landing.offsetY.roundToInt()) }
.graphicsLayer {
scaleX = landing.scale
scaleY = landing.scale
rotationZ = landing.rotation
alpha = landing.alpha
}
/** How many cards of a pile are worth drawing. Below this it is not a heap. */
private const val PILE_DEPTH = 5
/**
* How many taken tricks are worth drawing separately. Past this the pile stops
* growing rather than climbing off the table — Wizard's last round is twenty
* tricks, and twenty bundles of stagger is not a pile, it is a staircase.
*/
private const val WON_STACK_DEPTH = 8
/** How far each taken trick sits off the one under it, in card widths. */
private const val WON_STACK_STEP = 0.045f
/**
* The tricks a player has taken, squared up and turned face down in front of
* them.
*
* One card back per trick rather than four, which is what a real table looks
* like: a trick is gathered, squared and put down as a single bundle, and from
* above you see the top card of each bundle and the edges of the rest. The
* count is the pile — it is the thing a player actually glances at to see how
* the hand is going, and it is why cards are kept rather than binned.
*/
@Composable
fun WonTrickStack(
tricks: Int,
width: Dp,
modifier: Modifier = Modifier,
facingDegrees: Float = 0f,
/** Where on the table these were gathered from, in pixels. */
fromX: Float = 0f,
fromY: Float = 0f,
) {
if (tricks <= 0) return
val shown = tricks.coerceAtMost(WON_STACK_DEPTH)
val step = width * WON_STACK_STEP
val widthPx = with(LocalDensity.current) { width.toPx() }
val throwMillis = motionMillis(CARD_THROW_MILLIS)
Box(
modifier = modifier
.padding(end = step * shown, bottom = step * shown)
.width(width)
.height(width / CARD_ASPECT),
) {
for (bundle in 0 until shown) {
key(bundle) {
// Each bundle is squared up, not thrown down, so it gets a
// fraction of the scatter a played card gets.
val rest = cardRest(bundle * 613, tilt = REST_TILT_DEGREES * 0.5f)
val top = bundle == shown - 1
val landing = if (!top) {
CardLanding(
offsetX = 0f,
offsetY = 0f,
rotation = facingDegrees + rest.tiltDegrees,
scale = 1f,
elevation = REST_ELEVATION_DP.dp,
)
} else {
// Only the newest bundle arrives; the ones under it have
// been sitting there since earlier in the hand.
rememberCardLanding(
key = tricks,
rest = rest.copy(driftX = 0f, driftY = 0f),
facingDegrees = facingDegrees,
fromX = fromX,
fromY = fromY,
cardWidthPx = widthPx,
durationMillis = throwMillis,
)
}
CardBackView(
width = width,
elevation = landing.elevation,
modifier = Modifier
.offset(x = step * bundle, y = step * bundle)
.landed(landing),
)
}
}
}
}
/** How wide a taken-trick pile is drawn against a card on the table. */
private const val WON_STACK_SCALE = 0.5f
/**
* Everyone's taken tricks, at a table of four with partners opposite.
*
* Each pile sits beside its own player's card, one place clockwise along their
* edge, which is both where a real player would put them and the one spot on
* each edge that nothing else on the table is using.
*/
@Composable
fun BoxScope.TakenTricks(tricksWon: List<Int>, localSeat: Int, cardWidth: Dp) {
val stackWidth = cardWidth * WON_STACK_SCALE
val stackWidthPx = with(LocalDensity.current) { stackWidth.toPx() }
for (seat in 0 until 4) {
val relative = ((seat - localSeat) % 4 + 4) % 4
val alignment = when (relative) {
0 -> Alignment.BottomCenter
1 -> Alignment.CenterStart
2 -> Alignment.TopCenter
else -> Alignment.CenterEnd
}
val (nudgeX, nudgeY) = when (relative) {
0 -> cardWidth * 1.6f to 0.dp
1 -> 0.dp to cardWidth * 1.4f
2 -> -(cardWidth * 1.6f) to 0.dp
else -> 0.dp to -(cardWidth * 1.4f)
}
// Gathered in from the middle of the table, which is the opposite way
// from the edge this player sits at.
val (outX, outY) = seatOrigin(relative, 2.5f)
WonTrickStack(
tricks = tricksWon.getOrElse(seat) { 0 },
width = stackWidth,
facingDegrees = relative * 90f,
fromX = -outX * stackWidthPx,
fromY = -outY * stackWidthPx,
modifier = Modifier.align(alignment).offset(x = nudgeX, y = nudgeY),
)
}
}
/**
* The same, round a table of any size: each pile sits half a seat clockwise
* from its player's card, just inside the ring the cards are played on.
*/
@Composable
fun BoxScope.TakenTricksAround(
tricksWon: List<Int>,
localSeat: Int,
players: Int,
cardWidth: Dp,
radiusPx: Float,
) {
val stackWidth = cardWidth * WON_STACK_SCALE
val stackWidthPx = with(LocalDensity.current) { stackWidth.toPx() }
for (seat in 0 until players) {
val relative = ((seat - localSeat) % players + players) % players
val angle = 2.0 * PI * relative / players + PI / players
val x = -sin(angle) * radiusPx
val y = cos(angle) * radiusPx
WonTrickStack(
tricks = tricksWon.getOrElse(seat) { 0 },
width = stackWidth,
facingDegrees = relative * 360f / players,
fromX = (sin(angle) * stackWidthPx * 2.5f).toFloat(),
fromY = (-cos(angle) * stackWidthPx * 2.5f).toFloat(),
modifier = Modifier
.align(Alignment.Center)
.offset { IntOffset(x.roundToInt(), y.roundToInt()) },
)
}
}
/**
* Trick counts, held back until the cards have actually got there.
*
* The rules count a trick the instant its last card is played, which is a beat
* before the cards are gathered and two before they arrive. Reading the count
* straight from the state would grow the winner's pile while the trick was
* still lying in the middle of the table, and the cards would then slide over
* and land on a pile that had already counted them.
*/
@Composable
fun rememberArrivedTricks(
tricksWon: List<Int>,
sweeping: Boolean,
sweepStarted: Boolean,
sweepMillis: Int,
): List<Int> {
var arrived by remember { mutableStateOf(tricksWon) }
LaunchedEffect(tricksWon, sweeping, sweepStarted) {
// Still on the table waiting to be read: the pile has not won it yet.
if (sweeping && !sweepStarted) return@LaunchedEffect
if (sweeping) delay(sweepMillis.toLong())
arrived = tricksWon
}
return arrived
}
/** Holder for [rememberPileWatermark]. Deliberately not snapshot state. */
private class PileWatermark(var value: Int)
/**
* How much of a pile was already lying there rather than played onto it.
*
* Cards above this line get thrown on; cards below it are simply drawn where
* they lie. Two things have to be true and only one of them is obvious. The
* obvious one is that a pile you have just opened the screen on was not built
* in front of you, so none of it should fly in. The other is that a pile gets
* *shorter* between rounds — a new deal replaces a twenty-card heap with a
* single turned card — and a mark left at twenty would then sit above every
* card of the new round and quietly switch the animation off for the rest of
* the game. So the mark follows the pile down, and only ever down.
*
* A plain holder rather than snapshot state on purpose: this is written and
* read within one composition, and it must not be the reason anything
* recomposes.
*/
@Composable
private fun rememberPileWatermark(size: Int): Int {
val mark = remember { PileWatermark(size) }
if (size < mark.value) mark.value = size
return mark.value
}
/**
* A pile of cards seen from above.
*
* Only the top few are drawn, each lying where it fell, which is enough to read
* as a heap without painting fifty cards nobody can see. Cards already on the
* pile when the screen opens are drawn settled — the pile was built before you
* got here — and only cards played after that are thrown on.
*/
@Composable
fun CardPile(
cards: List<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 * HEAP_DRIFT_FRACTION
Box(
modifier = modifier
.padding(horizontal = margin, vertical = margin)
.width(width)
.height(width / CARD_ASPECT),
) {
for (index in bottom until cards.size) {
key(index) {
val card = cards[index]
val rest = heapRest(cardSeed(card, index))
val landing = if (index < alreadyDown) {
CardLanding(
offsetX = rest.driftX * cardWidthPx,
offsetY = rest.driftY * cardWidthPx,
rotation = rest.tiltDegrees,
scale = 1f,
elevation = REST_ELEVATION_DP.dp,
)
} else {
val (fromX, fromY) = pileOrigin(cardSeed(card, index))
rememberCardLanding(
key = index,
rest = rest,
facingDegrees = 0f,
fromX = fromX * cardWidthPx,
fromY = fromY * cardWidthPx,
cardWidthPx = cardWidthPx,
durationMillis = throwMillis,
)
}
PlayingCardView(
card = card,
width = width,
elevation = landing.elevation,
modifier = Modifier.landed(landing),
)
}
}
}
}
/**
* A stock of face-down cards, drawn with enough of the pack under the top card
* to show there is something left in it. The offsets are a squared-up deck's
* worth — a couple of percent — rather than the scatter a played card gets.
*/
@Composable
fun CardBackStack(
count: Int,
width: Dp,
modifier: Modifier = Modifier,
depth: Int = 3,
) {
if (count <= 0) return
val shown = count.coerceAtMost(depth)
val step = width * 0.018f
Box(
modifier = modifier
.padding(end = step * shown, bottom = step * shown)
.width(width)
.height(width / CARD_ASPECT),
) {
// Bottom of the pack first, so the top card is drawn last and sits on top.
for (below in (shown - 1) downTo 0) {
CardBackView(
width = width,
elevation = if (below == 0) REST_ELEVATION_DP.dp else 0.dp,
modifier = Modifier
.offset(x = step * below, y = step * below)
.rotate(unitNoise(below, 8) * 0.8f),
)
}
}
}
@@ -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) },
) )
} }
@@ -22,13 +22,16 @@ 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
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.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
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
@@ -280,8 +283,34 @@ private fun TableArea(state: CribbageState, localSeat: Int, cardWidth: Dp) {
color = ink, color = ink,
) )
} }
state.series.forEach { pegged -> // The series stays on the table until it is counted out, so each
PlayingCardView(card = pegged.card, width = cardWidth * 0.7f) // card is laid beside the last rather than replacing it. Keyed
// by its place in the series so only the card just pegged is
// thrown down.
val pegWidth = cardWidth * 0.7f
val pegWidthPx = with(LocalDensity.current) { pegWidth.toPx() }
val throwMillis = motionMillis(CARD_THROW_MILLIS)
state.series.forEachIndexed { place, pegged ->
key(place) {
val seed = cardSeed(pegged.card, place)
val rest = cardRest(seed)
val (fromX, fromY) = pileOrigin(seed)
val landing = rememberCardLanding(
key = place,
rest = rest,
facingDegrees = 0f,
fromX = fromX * pegWidthPx,
fromY = fromY * pegWidthPx,
cardWidthPx = pegWidthPx,
durationMillis = throwMillis,
)
PlayingCardView(
card = pegged.card,
width = pegWidth,
elevation = landing.elevation,
modifier = Modifier.landed(landing),
)
}
} }
} }
} }
@@ -415,6 +444,7 @@ private fun HandRow(
width = cardWidth, width = cardWidth,
enabled = enabled, enabled = enabled,
selected = card in chosen, selected = card in chosen,
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,10 +223,25 @@ 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)
// The count follows the cards rather than the rules, so a pile grows as the
// trick reaches it and not the moment the last card was played.
val arrived = rememberArrivedTricks(
tricksWon = state.tricksWon,
sweeping = sweeping,
sweepStarted = sweepStarted,
sweepMillis = motionMillis(CARD_SWEEP_MILLIS),
)
Box(modifier = Modifier.fillMaxSize().padding(4.dp)) { Box(modifier = Modifier.fillMaxSize().padding(4.dp)) {
// What each player has taken, face down in front of them. Drawn first
// so a trick still being gathered passes over the pile it is joining.
TakenTricks(arrived, localSeat, cardWidth)
// The turn card sits in the middle of the table during bidding. // The turn card sits in the middle of the table during bidding.
state.upCard?.let { up -> state.upCard?.let { up ->
Column( Column(
@@ -249,45 +267,77 @@ private fun TrickArea(
) )
} }
// Keyed by seat: one card per seat per trick, so this holds a card's
// flight steady while the trick fills up and while the finished trick
// is swapped for the copy that gets swept away. Without it the list
// position would decide identity and every card would jump back into
// the air whenever another one landed.
cardsOnTable.forEach { played -> cardsOnTable.forEach { played ->
val relative = relativePosition(played.seat, localSeat) key(played.seat) {
val alignment = when (relative) { val relative = relativePosition(played.seat, localSeat)
0 -> Alignment.BottomCenter val alignment = when (relative) {
1 -> Alignment.CenterStart 0 -> Alignment.BottomCenter
2 -> Alignment.TopCenter 1 -> Alignment.CenterStart
else -> Alignment.CenterEnd 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. // Turned to face the player who played it: the side seats end
val rotation = when (relative) { // up landscape, the partner's card upside down from your side.
1 -> 90f val facing = when (relative) {
2 -> 180f 1 -> 90f
3 -> -90f 2 -> 180f
else -> 0f 3 -> -90f
} else -> 0f
}
Column( val rest = cardRest(cardSeed(played.card, played.seat))
modifier = Modifier val (fromX, fromY) = seatOrigin(relative)
.align(alignment) val landing = rememberCardLanding(
.offset { key = played.card,
IntOffset( rest = rest,
x = (dirX * travelPx * sweep).roundToInt(), facingDegrees = facing,
y = (dirY * travelPx * sweep).roundToInt(), fromX = fromX * cardWidthPx,
) fromY = fromY * cardWidthPx,
} cardWidthPx = cardWidthPx,
.alpha(1f - sweep), durationMillis = throwMillis,
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 leaving = sweepProgress(sweep, rest)
Column(
modifier = Modifier
.align(alignment)
.offset {
IntOffset(
x = (landing.offsetX + dirX * travelPx * leaving).roundToInt(),
y = (landing.offsetY + dirY * travelPx * leaving).roundToInt(),
)
}
.graphicsLayer {
// Squared down into the bundle it is joining, and
// still whole until it gets there.
val gathered = landing.scale * gatherScale(leaving)
scaleX = gathered
scaleY = gathered
alpha = landing.alpha * gatherAlpha(leaving)
},
horizontalAlignment = Alignment.CenterHorizontally,
) {
PlayingCardView(
card = played.card,
width = cardWidth,
elevation = landing.elevation,
// The turn it picks up on the way out is the same turn
// it carried on the way in, so a card leaves the table
// the way it arrived.
modifier = Modifier
.rotate(landing.rotation + rest.spinDegrees * leaving),
)
Text(
text = seatLabel(relative),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimary,
)
}
} }
} }
@@ -449,6 +499,11 @@ private fun HandRow(
card = card, 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,13 +192,35 @@ 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 ->
PlayingCardView( key(played.seat) {
card = played.card, val seed = cardSeed(played.card, played.seat)
width = 46.dp, val rest = cardRest(seed)
caption = "seat ${played.seat}", val (fromX, fromY) = pileOrigin(seed)
) val landing = rememberCardLanding(
key = played.card,
rest = rest,
facingDegrees = 0f,
fromX = fromX * widthPx,
fromY = fromY * widthPx,
cardWidthPx = widthPx,
durationMillis = throwMillis,
)
PlayingCardView(
card = played.card,
width = width,
caption = "seat ${played.seat}",
elevation = landing.elevation,
modifier = Modifier.landed(landing),
)
}
} }
} }
} }
@@ -214,6 +240,16 @@ private fun ScoreRow(state: HeartsState, localSeat: Int) {
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
fontWeight = if (seat == localSeat) FontWeight.Bold else FontWeight.Normal, fontWeight = if (seat == localSeat) FontWeight.Bold else FontWeight.Normal,
) )
// The tricks this player has taken, face down in front of them.
// Hearts keeps the cards themselves rather than a count, since
// it is the cards that are scored at the end of the round, so
// the number of tricks is however many players-worth of them
// there are.
WonTrickStack(
tricks = state.taken.getOrElse(seat) { emptyList() }.size /
state.options.playerCount,
width = 26.dp,
)
Text("$score", fontWeight = FontWeight.Bold) Text("$score", fontWeight = FontWeight.Bold)
// What this round has cost so far, which is the number anybody // What this round has cost so far, which is the number anybody
// actually watches while a hand is being played. // actually watches while a hand is being played.
@@ -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,9 +214,25 @@ 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)
// Kaiser scores in points rather than tricks, but the cards are still won
// and still kept, and how many each player is sitting on is worth seeing.
val arrived = rememberArrivedTricks(
tricksWon = state.tricksWon,
sweeping = sweeping,
sweepStarted = sweepStarted,
sweepMillis = motionMillis(CARD_SWEEP_MILLIS),
)
Box(modifier = Modifier.fillMaxSize().padding(4.dp)) { Box(modifier = Modifier.fillMaxSize().padding(4.dp)) {
// Drawn first, so a trick still being gathered passes over the pile it
// is on its way to joining.
TakenTricks(arrived, localSeat, cardWidth)
if (cardsOnTable.isEmpty()) { if (cardsOnTable.isEmpty()) {
Text( Text(
text = when { text = when {
@@ -230,43 +246,65 @@ private fun TrickArea(
} }
cardsOnTable.forEach { played -> cardsOnTable.forEach { played ->
val relative = relativePosition(played.seat, localSeat) key(played.seat) {
val alignment = when (relative) { val relative = relativePosition(played.seat, localSeat)
0 -> Alignment.BottomCenter val alignment = when (relative) {
1 -> Alignment.CenterStart 0 -> Alignment.BottomCenter
2 -> Alignment.TopCenter 1 -> Alignment.CenterStart
else -> Alignment.CenterEnd 2 -> Alignment.TopCenter
} else -> Alignment.CenterEnd
val rotation = when (relative) { }
1 -> 90f val facing = when (relative) {
2 -> 180f 1 -> 90f
3 -> -90f 2 -> 180f
else -> 0f 3 -> -90f
} else -> 0f
}
Column( val rest = cardRest(cardSeed(played.card, played.seat))
modifier = Modifier val (fromX, fromY) = seatOrigin(relative)
.align(alignment) val landing = rememberCardLanding(
.offset { key = played.card,
IntOffset( rest = rest,
x = (dirX * travelPx * sweep).roundToInt(), facingDegrees = facing,
y = (dirY * travelPx * sweep).roundToInt(), fromX = fromX * cardWidthPx,
) fromY = fromY * cardWidthPx,
} cardWidthPx = cardWidthPx,
.alpha(1f - sweep), durationMillis = throwMillis,
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 leaving = sweepProgress(sweep, rest)
Column(
modifier = Modifier
.align(alignment)
.offset {
IntOffset(
x = (landing.offsetX + dirX * travelPx * leaving).roundToInt(),
y = (landing.offsetY + dirY * travelPx * leaving).roundToInt(),
)
}
.graphicsLayer {
val gathered = landing.scale * gatherScale(leaving)
scaleX = gathered
scaleY = gathered
alpha = landing.alpha * gatherAlpha(leaving)
},
horizontalAlignment = Alignment.CenterHorizontally,
) {
PlayingCardView(
card = played.card,
width = cardWidth,
caption = counterCaption(played.card),
elevation = landing.elevation,
modifier = Modifier
.rotate(landing.rotation + rest.spinDegrees * leaving),
)
Text(
text = seatLabel(relative),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimary,
)
}
} }
} }
@@ -406,6 +444,7 @@ private fun HandRow(
width = cardWidth, 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)) },
) )
} }
@@ -1,5 +1,7 @@
package org.prolibertate.games.ui.game package org.prolibertate.games.ui.game
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -11,7 +13,9 @@ import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -20,11 +24,17 @@ 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.graphics.graphicsLayer
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.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlin.math.roundToInt
import org.prolibertate.games.game.president.PassTurn import org.prolibertate.games.game.president.PassTurn
import org.prolibertate.games.game.president.PlayCards import org.prolibertate.games.game.president.PlayCards
import org.prolibertate.games.game.president.PresidentMove import org.prolibertate.games.game.president.PresidentMove
@@ -34,6 +44,7 @@ import org.prolibertate.games.game.president.titleFor
import org.prolibertate.games.net.MatchController import org.prolibertate.games.net.MatchController
import org.prolibertate.games.ui.PrimaryAction import org.prolibertate.games.ui.PrimaryAction
import org.prolibertate.games.ui.ScreenScaffold import org.prolibertate.games.ui.ScreenScaffold
import org.prolibertate.games.ui.common.CARD_ASPECT
import org.prolibertate.games.ui.common.CardBackView import org.prolibertate.games.ui.common.CardBackView
import org.prolibertate.games.ui.common.PlayingCardView import org.prolibertate.games.ui.common.PlayingCardView
import org.prolibertate.games.ui.theme.FeltGreenDark import org.prolibertate.games.ui.theme.FeltGreenDark
@@ -155,11 +166,7 @@ private fun PileView(state: PresidentState, cardWidth: Dp) {
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimary, color = MaterialTheme.colorScheme.onPrimary,
) )
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { PileHeap(state, cardWidth)
state.pile.takeLast(state.setSize).forEach { card ->
PlayingCardView(card = card, width = cardWidth)
}
}
Text( Text(
text = "Beat it with ${state.setSize} card(s)", text = "Beat it with ${state.setSize} card(s)",
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
@@ -168,6 +175,120 @@ private fun PileView(state: PresidentState, cardWidth: Dp) {
} }
} }
/** How much of what is under the top set is worth drawing. */
private const val BURIED_DEPTH = 5
/**
* The pile, as a pile.
*
* Every card played since the pile was last cleared is still lying there, and a
* new set is played *onto* them rather than instead of them — which is what the
* pile is for. Only the top set has to be readable, since that is the one that
* has to be beaten, so it is laid out square and side by side; everything under
* it is pushed out towards the edge of the ground the top set covers, so a
* corner of it shows.
*
* The whole pile is drawn in one loop keyed by position, which is what lets a
* set slide out of the way as the next one lands on it instead of vanishing
* from the row and reappearing under the heap. Each card animates from wherever
* it actually is to wherever it now belongs, so being buried needs no
* bookkeeping about where it used to sit.
*/
@Composable
private fun PileHeap(state: PresidentState, cardWidth: Dp) {
val density = LocalDensity.current
val cardWidthPx = with(density) { cardWidth.toPx() }
val cardHeightPx = cardWidthPx / CARD_ASPECT
val gapPx = with(density) { 6.dp.toPx() }
val throwMillis = motionMillis(CARD_THROW_MILLIS)
// Where the cards of the top set sit: centred, side by side.
val step = cardWidthPx + gapPx
val slotOffset = (state.setSize - 1) / 2f
// Half the ground the top set covers, which is what the cards underneath
// have to get out from under.
val halfWidth = (step * state.setSize) / 2f
val halfHeight = cardHeightPx * 0.34f
val lowest = (state.pile.size - state.setSize - BURIED_DEPTH).coerceAtLeast(0)
Box(
modifier = Modifier.size(
width = with(density) { (halfWidth * 2f + cardWidthPx * 0.9f).toDp() },
height = with(density) { (cardHeightPx + halfHeight * 2f).toDp() },
),
contentAlignment = Alignment.Center,
) {
for (place in lowest until state.pile.size) {
key(place) {
val card = state.pile[place]
val seed = cardSeed(card, place)
val depth = state.pile.size - 1 - place
val onTop = depth < state.setSize
val rest = heapRest(seed)
val (buriedX, buriedY) = buriedOffset(seed, halfWidth, halfHeight)
val (targetX, targetY) = if (onTop) {
// Slot 0 is the leftmost card of the set, which is the
// deepest of them — they were dealt onto the pile in order.
val slot = state.setSize - 1 - depth
(slot - slotOffset) * step to 0f
} else {
buriedX to buriedY
}
val slideSpec = tween<Float>(durationMillis = throwMillis)
val x by animateFloatAsState(targetX, slideSpec, label = "pileX")
val y by animateFloatAsState(targetY, slideSpec, label = "pileY")
// Buried cards are knocked further askew than the tidy set on
// top of them, which is the other half of reading as a heap.
val tilt by animateFloatAsState(
targetValue = if (onTop) rest.tiltDegrees * 0.4f else rest.tiltDegrees,
animationSpec = slideSpec,
label = "pileTilt",
)
// The throw is relative to wherever the card belongs, so the two
// compose: a card lands in its slot, and is later shoved aside
// from it without the landing having to know.
val (fromX, fromY) = pileOrigin(seed)
val landing = rememberCardLanding(
key = place,
rest = rest,
facingDegrees = 0f,
fromX = fromX * cardWidthPx,
fromY = fromY * cardWidthPx,
cardWidthPx = cardWidthPx,
durationMillis = throwMillis,
// A set leaves the hand together and lands one after
// another rather than as a block.
delayMillis = (rest.lateness * throwMillis * 0.3f).toInt(),
)
PlayingCardView(
card = card,
width = cardWidth,
elevation = landing.elevation,
modifier = Modifier
.offset {
IntOffset(
(x + landing.offsetX).roundToInt(),
(y + landing.offsetY).roundToInt(),
)
}
.graphicsLayer {
scaleX = landing.scale
scaleY = landing.scale
rotationZ = tilt + (landing.rotation - rest.tiltDegrees)
alpha = landing.alpha
},
)
}
}
}
}
/** Everyone else, as card counts — their hands are never sent to this device. */ /** Everyone else, as card counts — their hands are never sent to this device. */
@Composable @Composable
private fun OpponentRow(state: PresidentState, localSeat: Int) { private fun OpponentRow(state: PresidentState, localSeat: Int) {
@@ -258,7 +379,11 @@ private fun HandView(state: PresidentState, localSeat: Int, cardWidth: Dp) {
Text("—", style = MaterialTheme.typography.bodySmall) 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,26 @@ 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)
val arrived = rememberArrivedTricks(
tricksWon = state.tricksWon,
sweeping = sweeping,
sweepStarted = sweepStarted,
sweepMillis = motionMillis(CARD_SWEEP_MILLIS),
)
// What each player has taken, face down beside their own place at the
// ring. Drawn first so a trick being gathered passes over its pile.
TakenTricksAround(
tricksWon = arrived,
localSeat = localSeat,
players = players,
cardWidth = cardWidth,
radiusPx = radiusPx,
)
if (cardsOnTable.isEmpty()) { if (cardsOnTable.isEmpty()) {
Text( Text(
@@ -241,37 +260,63 @@ private fun TrickArea(
} }
cardsOnTable.forEach { played -> cardsOnTable.forEach { played ->
val relative = ((played.seat - localSeat) % players + players) % players key(played.seat) {
val angle = 2.0 * PI * relative / players val relative = ((played.seat - localSeat) % players + players) % players
// Relative seat 0 is you, at the bottom; the rest run clockwise. val angle = 2.0 * PI * relative / players
val x = -sin(angle) * radiusPx // Relative seat 0 is you, at the bottom; the rest run clockwise.
val y = cos(angle) * radiusPx val x = -sin(angle) * radiusPx
// Turn each card to face whoever played it. val y = cos(angle) * radiusPx
val rotation = (relative * 360f / players) // Turn each card to face whoever played it.
val facing = (relative * 360f / players)
Column( // A card comes in along the ray it sits on, from further out —
modifier = Modifier // which on a round table is the direction its player is in,
.align(Alignment.Center) // however many of them there are.
.offset { val rest = cardRest(cardSeed(played.card, played.seat))
IntOffset( val landing = rememberCardLanding(
x = (x + -sin(winnerAngle) * travelPx * sweep).roundToInt(), key = played.card,
y = (y + cos(winnerAngle) * travelPx * sweep).roundToInt(), rest = rest,
) facingDegrees = facing,
} fromX = (-sin(angle) * THROW_DISTANCE * cardWidthPx).toFloat(),
.alpha(1f - sweep), fromY = (cos(angle) * THROW_DISTANCE * cardWidthPx).toFloat(),
horizontalAlignment = Alignment.CenterHorizontally, cardWidthPx = cardWidthPx,
) { durationMillis = throwMillis,
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,
) )
val leaving = sweepProgress(sweep, rest)
Column(
modifier = Modifier
.align(Alignment.Center)
.offset {
IntOffset(
x = (x + landing.offsetX +
-sin(winnerAngle) * travelPx * leaving).roundToInt(),
y = (y + landing.offsetY +
cos(winnerAngle) * travelPx * leaving).roundToInt(),
)
}
.graphicsLayer {
val gathered = landing.scale * gatherScale(leaving)
scaleX = gathered
scaleY = gathered
alpha = landing.alpha * gatherAlpha(leaving)
},
horizontalAlignment = Alignment.CenterHorizontally,
) {
PlayingCardView(
card = played.card,
width = cardWidth,
caption = specialCaption(played.card),
elevation = landing.elevation,
modifier = Modifier
.rotate(landing.rotation + rest.spinDegrees * leaving),
)
Text(
text = seatLabel(played.seat, localSeat),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimary,
)
}
} }
} }
@@ -395,6 +440,7 @@ private fun HandRow(
width = cardWidth, 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)) },
) )
} }
@@ -250,6 +250,39 @@ class KaiserRulesTest {
assertEquals("all four held for display", 4, state.completedTrick.size) assertEquals("all four held for display", 4, state.completedTrick.size)
} }
/**
* Kaiser scores in points, not tricks, so nothing in the rules reads this
* count — the table does, to draw the cards each player has taken. That is
* exactly why it is worth a test: a field no rule depends on is a field
* that can quietly stop being maintained.
*/
@Test
fun `tricks are counted to the seat that took them`() {
// Everything is clubs and nothing is trump, so each trick simply goes
// to the highest card in it: the ace, then the king.
val hands = listOf(
listOf(Card(Rank.ACE, Suit.CLUBS), Card(Rank.SEVEN, Suit.CLUBS)),
listOf(Card(Rank.EIGHT, Suit.CLUBS), Card(Rank.NINE, Suit.CLUBS)),
listOf(Card(Rank.TEN, Suit.CLUBS), Card(Rank.JACK, Suit.CLUBS)),
listOf(Card(Rank.QUEEN, Suit.CLUBS), Card(Rank.KING, Suit.CLUBS)),
)
var state = playingState(hands, trump = Suit.SPADES)
assertEquals("nothing taken at the deal", listOf(0, 0, 0, 0), state.tricksWon)
repeat(4) {
val seat = KaiserRules.currentSeat(state)!!
state = KaiserRules.applyMove(state, seat, PlayCard(state.hands[seat].first()))
}
assertEquals("the ace takes the first", listOf(1, 0, 0, 0), state.tricksWon)
repeat(4) {
val seat = KaiserRules.currentSeat(state)!!
state = KaiserRules.applyMove(state, seat, PlayCard(state.hands[seat].first()))
}
assertEquals("the king takes the second", listOf(1, 0, 0, 1), state.tricksWon)
assertEquals("and they add up", 2, state.tricksWon.sum())
}
// -- Scoring ------------------------------------------------------------ // -- Scoring ------------------------------------------------------------
@Test @Test
@@ -0,0 +1,247 @@
package org.prolibertate.games.ui.game
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import kotlin.math.abs
import org.prolibertate.games.game.cards.Card
import org.prolibertate.games.game.cards.Decks
import org.prolibertate.games.game.cards.Rank
import org.prolibertate.games.game.cards.Suit
/**
* The scatter under the card animations.
*
* Two properties matter and neither is visible in a screenshot. It has to be
* *bounded*, because the whole difference between cards on a table and cards
* thrown down a flight of stairs is the size of the numbers; and it has to be
* *stable*, because a card that picks a new angle on every recomposition
* twitches, and a card whose angle depends on where it sits in a list jumps
* whenever a card is played next to it.
*/
class CardPhysicsTest {
private val deck = Decks.standard52()
@Test
fun `noise stays inside minus one to one`() {
for (seed in -500..500) {
for (salt in 0..8) {
val n = unitNoise(seed, salt)
assertTrue("$seed/$salt gave $n", n >= -1f && n < 1f)
}
}
}
@Test
fun `noise is the same every time it is asked`() {
assertEquals(unitNoise(12345, 3), unitNoise(12345, 3), 0f)
assertEquals(unitNoise(-7, 1), unitNoise(-7, 1), 0f)
}
/**
* The salt is what lets one seed produce a tilt, two drifts, a spin and a
* queue position that are not all the same number. If it stopped separating
* them, every card would drift exactly as far as it tilted.
*/
@Test
fun `salts do not agree with each other`() {
val fromOneSeed = (0..8).map { unitNoise(4242, it) }
assertEquals(fromOneSeed.size, fromOneSeed.toSet().size)
}
/** Sequential seeds are the normal case: adjacent cards in a hand. */
@Test
fun `neighbouring seeds do not give neighbouring values`() {
val runs = (0 until 200).count { abs(unitNoise(it, 1) - unitNoise(it + 1, 1)) < 0.05f }
assertTrue("$runs of 200 neighbouring seeds landed together", runs < 30)
}
@Test
fun `every card in a deck rests within the stated bounds`() {
deck.forEach { card ->
val rest = cardRest(cardSeed(card))
assertTrue(abs(rest.tiltDegrees) <= REST_TILT_DEGREES)
assertTrue(abs(rest.driftX) <= REST_DRIFT_FRACTION)
assertTrue(abs(rest.driftY) <= REST_DRIFT_FRACTION)
assertTrue(abs(rest.spinDegrees) <= THROW_SPIN_DEGREES)
assertTrue(rest.lateness in 0f..1f)
assertTrue(abs(handTilt(cardSeed(card))) <= HAND_TILT_DEGREES)
}
}
@Test
fun `a card lies the same way however often it is asked`() {
val card = Card(Rank.QUEEN, Suit.SPADES)
assertEquals(cardRest(cardSeed(card)), cardRest(cardSeed(card)))
assertEquals(cardRest(cardSeed(card, 3)), cardRest(cardSeed(card, 3)))
}
/**
* Two cards lying at the same angle in the same place is the one thing that
* makes a scattered pile look printed rather than dealt.
*/
@Test
fun `no two cards in a deck share a seed`() {
val seeds = deck.map { cardSeed(it) }
assertEquals(seeds.size, seeds.toSet().size)
}
/**
* A pile can hold the same card twice — Golf and Spider deal more than one
* deck — so where a card is lying has to be part of its seed.
*/
@Test
fun `the same card in two places lies two ways`() {
val card = Card(Rank.SEVEN, Suit.HEARTS)
assertNotEquals(cardRest(cardSeed(card, 0)), cardRest(cardSeed(card, 1)))
}
/**
* A heap is untidier than a card laid in front of a player, and has to be:
* at the tight bounds five cards on a discard pile are hidden to within a
* twentieth of a card width of each other and the pile looks like one card.
*/
@Test
fun `a heap lies looser than a table but still within bounds`() {
deck.forEach { card ->
val seed = cardSeed(card)
val heap = heapRest(seed)
val table = cardRest(seed)
assertTrue(abs(heap.tiltDegrees) <= HEAP_TILT_DEGREES)
assertTrue(abs(heap.driftX) <= HEAP_DRIFT_FRACTION)
assertTrue(abs(heap.driftY) <= HEAP_DRIFT_FRACTION)
assertTrue(abs(heap.tiltDegrees) >= abs(table.tiltDegrees))
assertTrue(abs(heap.driftX) >= abs(table.driftX))
}
}
/**
* The two have to agree about everything except how far the card strayed,
* or a card would jump when it was drawn one way rather than the other.
*/
@Test
fun `a heap and a table agree about the throw`() {
deck.take(6).forEach { card ->
val seed = cardSeed(card)
assertEquals(cardRest(seed).spinDegrees, heapRest(seed).spinDegrees, 0f)
assertEquals(cardRest(seed).lateness, heapRest(seed).lateness, 0f)
}
}
/**
* A buried card has to end up near the rim of whatever is covering it. One
* left in the middle is one nobody can see, and then drawing the pile as a
* pile achieves nothing.
*/
@Test
fun `a buried card ends up out where a corner of it shows`() {
deck.forEach { card ->
val (x, y) = buriedOffset(cardSeed(card), halfWidth = 100f, halfHeight = 40f)
val howFarOut = kotlin.math.hypot(x / 100f, y / 40f)
assertTrue("$card sat at $howFarOut of the way out", howFarOut >= 0.719f)
assertTrue(howFarOut <= 1.001f)
}
}
/** Two cards buried under the same set must not end up in the same place. */
@Test
fun `buried cards do not stack on each other`() {
val places = (0 until 6).map { buriedOffset(cardSeed(deck[it], it), 100f, 40f) }
assertEquals(places.size, places.toSet().size)
}
/** Every seat's card arrives from that seat, and from a card-width or two out. */
@Test
fun `a card comes in from its own player's side`() {
assertEquals(0f to THROW_DISTANCE, seatOrigin(0))
assertEquals(-THROW_DISTANCE to 0f, seatOrigin(1))
assertEquals(0f to -THROW_DISTANCE, seatOrigin(2))
assertEquals(THROW_DISTANCE to 0f, seatOrigin(3))
}
@Test
fun `a pile card comes in from somewhere off the pile`() {
deck.forEach { card ->
val (x, y) = pileOrigin(cardSeed(card))
val distance = kotlin.math.hypot(x, y)
assertEquals(THROW_DISTANCE, distance, 0.001f)
}
}
@Test
fun `nothing has left the table before the sweep starts`() {
deck.take(8).forEach { card ->
assertEquals(0f, sweepProgress(0f, cardRest(cardSeed(card))), 0f)
}
}
@Test
fun `everything has left the table by the end of the sweep`() {
deck.take(8).forEach { card ->
assertEquals(1f, sweepProgress(1f, cardRest(cardSeed(card))), 0.0001f)
}
}
@Test
fun `a card only ever gets further from the table`() {
val rest = cardRest(cardSeed(Card(Rank.ACE, Suit.CLUBS)))
var previous = 0f
for (step in 0..100) {
val now = sweepProgress(step / 100f, rest)
assertTrue("went backwards at $step", now >= previous - 0.0001f)
assertTrue(now in 0f..1f)
previous = now
}
}
/**
* The stagger: a trick gathers up as one motion, so the cards must not all
* be at the same point in it, and none of them may still be sitting there
* when the sweep is nearly over.
*/
@Test
fun `cards leave at different moments but none is left behind`() {
val hand = deck.take(4).map { cardRest(cardSeed(it)) }
val halfway = hand.map { sweepProgress(0.5f, it) }
assertEquals(halfway.size, halfway.toSet().size)
hand.forEach { assertTrue(sweepProgress(0.95f, it) > 0.5f) }
}
/**
* A gathered card has to still be whole when it reaches the winner's pile,
* because it vanishes at the same moment the pile grows by one and that
* swap is only invisible if there was something there to swap.
*/
@Test
fun `a gathered card is whole most of the way and gone at the end`() {
assertEquals(1f, gatherAlpha(0f), 0f)
assertEquals(1f, gatherAlpha(0.5f), 0f)
assertEquals(0f, gatherAlpha(1f), 0f)
var previous = 1f
for (step in 0..100) {
val now = gatherAlpha(step / 100f)
assertTrue("went back up at $step", now <= previous + 0.0001f)
assertTrue(now in 0f..1f)
previous = now
}
}
/** And is squared down into the bundle rather than arriving full size. */
@Test
fun `a gathered card is squared down on the way`() {
assertEquals(1f, gatherScale(0f), 0f)
assertTrue(gatherScale(1f) < 1f)
assertTrue(gatherScale(1f) > 0.5f)
assertTrue(gatherScale(0.5f) < gatherScale(0.25f))
}
/** A trick that never completes must not leave a card part way off the table. */
@Test
fun `a sweep that never starts moves nothing`() {
deck.take(4).forEach { card ->
assertEquals(0f, sweepProgress(0f, cardRest(cardSeed(card)), spread = 0f), 0f)
}
}
}
+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