Merge pull request #30: play onto the pile, and keep the tricks you win

Play onto the pile, and keep the tricks you win
This commit is contained in:
adrianoftyriel
2026-08-13 16:17:05 -06:00
committed by GitHub
11 changed files with 611 additions and 51 deletions
@@ -92,6 +92,14 @@ data class KaiserState(
val lastTrickWinner: Int?,
/** Points taken this hand, per team. */
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 log: List<String>,
)
@@ -62,6 +62,7 @@ object KaiserRules : GameRules<KaiserState, KaiserMove> {
leader = (dealer + 1) % 4,
lastTrickWinner = null,
handPoints = listOf(0, 0),
tricksWon = List(4) { 0 },
scores = scores,
log = log,
)
@@ -224,10 +225,14 @@ object KaiserRules : GameRules<KaiserState, KaiserMove> {
val handPoints = played.handPoints.toMutableList()
handPoints[teamOf(winner)] = handPoints[teamOf(winner)] + points
val tricksWon = played.tricksWon.toMutableList()
tricksWon[winner] = tricksWon[winner] + 1
val afterTrick = played.copy(
trick = emptyList(),
completedTrick = trick,
handPoints = handPoints,
tricksWon = tricksWon,
leader = winner,
turn = winner,
lastTrickWinner = winner,
@@ -3,6 +3,7 @@ package org.prolibertate.games.ui.game
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.spring
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
@@ -16,6 +17,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.graphicsLayer
@@ -24,6 +26,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.cos
import kotlin.math.roundToInt
import kotlin.math.sin
@@ -65,6 +68,16 @@ const val REST_TILT_DEGREES = 4.5f
/** Most a settled card misses its spot by, as a fraction of its own width. */
const val REST_DRIFT_FRACTION = 0.055f
/**
* The same two bounds for a card thrown onto a heap rather than laid in front
* of a player, which is a good deal less tidy. They are also what makes a heap
* legible: with the tight bounds a discard pile of five cards looks like one
* card, because the four underneath are hidden to within a twentieth of a card
* width of the top one.
*/
const val HEAP_TILT_DEGREES = 8f
const val HEAP_DRIFT_FRACTION = 0.15f
/** Extra turn a card carries while it is still travelling. */
const val THROW_SPIN_DEGREES = 15f
@@ -108,6 +121,16 @@ const val CARD_SWEEP_MILLIS = 520
*/
private const val SWEEP_SPREAD = 0.3f
/**
* The last stretch of the sweep, over which a gathered card fades out. It has
* to be late: the cards vanish exactly as the winner's pile grows by one, and
* that swap is only invisible if they are still whole most of the way there.
*/
private const val SWEEP_FADE = 0.42f
/** How small four cards squared into a bundle end up against one card face. */
private const val GATHERED_SCALE = 0.62f
// ---------------------------------------------------------------------------
// The pure part: where a card lands, and how far through leaving it is.
// ---------------------------------------------------------------------------
@@ -160,14 +183,28 @@ data class CardRest(
)
/** The resting scatter for [seed], within the bounds set at the top of the file. */
fun cardRest(seed: Int): CardRest = CardRest(
tiltDegrees = unitNoise(seed, 1) * REST_TILT_DEGREES,
driftX = unitNoise(seed, 2) * REST_DRIFT_FRACTION,
driftY = unitNoise(seed, 3) * REST_DRIFT_FRACTION,
fun cardRest(
seed: Int,
tilt: Float = REST_TILT_DEGREES,
drift: Float = REST_DRIFT_FRACTION,
): CardRest = CardRest(
tiltDegrees = unitNoise(seed, 1) * tilt,
driftX = unitNoise(seed, 2) * drift,
driftY = unitNoise(seed, 3) * drift,
spinDegrees = unitNoise(seed, 4) * THROW_SPIN_DEGREES,
lateness = (unitNoise(seed, 5) + 1f) / 2f,
)
/**
* The same, at the looser bounds a heap gets.
*
* A card keeps this scatter for as long as it is on the heap, including once
* something has been played on top of it. Switching a card to a wider scatter
* the moment it stopped being the top card would make it shuffle sideways on
* its own, which is the one thing on a table that never happens.
*/
fun heapRest(seed: Int): CardRest = cardRest(seed, HEAP_TILT_DEGREES, HEAP_DRIFT_FRACTION)
/** The small tilt of a card sitting in a squared-up hand. */
fun handTilt(seed: Int): Float = unitNoise(seed, 6) * HAND_TILT_DEGREES
@@ -195,6 +232,24 @@ fun pileOrigin(seed: Int, distance: Float = THROW_DISTANCE): Pair<Float, Float>
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
@@ -213,9 +268,20 @@ fun sweepProgress(sweep: Float, rest: CardRest, spread: Float = SWEEP_SPREAD): F
return accelerate(raw)
}
private fun blend(from: Float, to: Float, fraction: Float): Float =
/** Straight interpolation, [fraction] being free to overshoot past one. */
fun blend(from: Float, to: Float, fraction: Float): Float =
from + (to - from) * fraction
/**
* How solid a card being gathered up still is. Whole for most of the journey
* and gone at the end of it, so it disappears at the moment the winner's pile
* grows rather than dissolving on the way over.
*/
fun gatherAlpha(leaving: Float): Float = ((1f - leaving) / SWEEP_FADE).coerceIn(0f, 1f)
/** And how far it has been squared down into the bundle it is joining. */
fun gatherScale(leaving: Float): Float = blend(1f, GATHERED_SCALE, leaving.coerceIn(0f, 1f))
// ---------------------------------------------------------------------------
// The Compose part.
// ---------------------------------------------------------------------------
@@ -324,7 +390,193 @@ fun Modifier.landed(landing: CardLanding): Modifier = this
}
/** How many cards of a pile are worth drawing. Below this it is not a heap. */
private const val PILE_DEPTH = 4
private const val PILE_DEPTH = 5
/**
* How many taken tricks are worth drawing separately. Past this the pile stops
* growing rather than climbing off the table — Wizard's last round is twenty
* tricks, and twenty bundles of stagger is not a pile, it is a staircase.
*/
private const val WON_STACK_DEPTH = 8
/** How far each taken trick sits off the one under it, in card widths. */
private const val WON_STACK_STEP = 0.045f
/**
* The tricks a player has taken, squared up and turned face down in front of
* them.
*
* One card back per trick rather than four, which is what a real table looks
* like: a trick is gathered, squared and put down as a single bundle, and from
* above you see the top card of each bundle and the edges of the rest. The
* count is the pile — it is the thing a player actually glances at to see how
* the hand is going, and it is why cards are kept rather than binned.
*/
@Composable
fun WonTrickStack(
tricks: Int,
width: Dp,
modifier: Modifier = Modifier,
facingDegrees: Float = 0f,
/** Where on the table these were gathered from, in pixels. */
fromX: Float = 0f,
fromY: Float = 0f,
) {
if (tricks <= 0) return
val shown = tricks.coerceAtMost(WON_STACK_DEPTH)
val step = width * WON_STACK_STEP
val widthPx = with(LocalDensity.current) { width.toPx() }
val throwMillis = motionMillis(CARD_THROW_MILLIS)
Box(
modifier = modifier
.padding(end = step * shown, bottom = step * shown)
.width(width)
.height(width / CARD_ASPECT),
) {
for (bundle in 0 until shown) {
key(bundle) {
// Each bundle is squared up, not thrown down, so it gets a
// fraction of the scatter a played card gets.
val rest = cardRest(bundle * 613, tilt = REST_TILT_DEGREES * 0.5f)
val top = bundle == shown - 1
val landing = if (!top) {
CardLanding(
offsetX = 0f,
offsetY = 0f,
rotation = facingDegrees + rest.tiltDegrees,
scale = 1f,
elevation = REST_ELEVATION_DP.dp,
)
} else {
// Only the newest bundle arrives; the ones under it have
// been sitting there since earlier in the hand.
rememberCardLanding(
key = tricks,
rest = rest.copy(driftX = 0f, driftY = 0f),
facingDegrees = facingDegrees,
fromX = fromX,
fromY = fromY,
cardWidthPx = widthPx,
durationMillis = throwMillis,
)
}
CardBackView(
width = width,
elevation = landing.elevation,
modifier = Modifier
.offset(x = step * bundle, y = step * bundle)
.landed(landing),
)
}
}
}
}
/** How wide a taken-trick pile is drawn against a card on the table. */
private const val WON_STACK_SCALE = 0.5f
/**
* Everyone's taken tricks, at a table of four with partners opposite.
*
* Each pile sits beside its own player's card, one place clockwise along their
* edge, which is both where a real player would put them and the one spot on
* each edge that nothing else on the table is using.
*/
@Composable
fun BoxScope.TakenTricks(tricksWon: List<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)
@@ -378,7 +630,7 @@ fun CardPile(
// Room for the scatter, so the outermost card is not clipped by whatever
// this pile has been placed inside.
val margin = width * REST_DRIFT_FRACTION
val margin = width * HEAP_DRIFT_FRACTION
Box(
modifier = modifier
.padding(horizontal = margin, vertical = margin)
@@ -388,7 +640,7 @@ fun CardPile(
for (index in bottom until cards.size) {
key(index) {
val card = cards[index]
val rest = cardRest(cardSeed(card, index))
val rest = heapRest(cardSeed(card, index))
val landing = if (index < alreadyDown) {
CardLanding(
offsetX = rest.driftX * cardWidthPx,
@@ -22,13 +22,16 @@ import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@@ -280,8 +283,34 @@ private fun TableArea(state: CribbageState, localSeat: Int, cardWidth: Dp) {
color = ink,
)
}
state.series.forEach { pegged ->
PlayingCardView(card = pegged.card, width = cardWidth * 0.7f)
// The series stays on the table until it is counted out, so each
// card is laid beside the last rather than replacing it. Keyed
// by its place in the series so only the card just pegged is
// thrown down.
val pegWidth = cardWidth * 0.7f
val pegWidthPx = with(LocalDensity.current) { pegWidth.toPx() }
val throwMillis = motionMillis(CARD_THROW_MILLIS)
state.series.forEachIndexed { place, pegged ->
key(place) {
val seed = cardSeed(pegged.card, place)
val rest = cardRest(seed)
val (fromX, fromY) = pileOrigin(seed)
val landing = rememberCardLanding(
key = place,
rest = rest,
facingDegrees = 0f,
fromX = fromX * pegWidthPx,
fromY = fromY * pegWidthPx,
cardWidthPx = pegWidthPx,
durationMillis = throwMillis,
)
PlayingCardView(
card = pegged.card,
width = pegWidth,
elevation = landing.elevation,
modifier = Modifier.landed(landing),
)
}
}
}
}
@@ -415,6 +444,7 @@ private fun HandRow(
width = cardWidth,
enabled = enabled,
selected = card in chosen,
modifier = Modifier.rotate(handTilt(cardSeed(card))),
onClick = { onTap(card) },
)
}
@@ -227,8 +227,21 @@ private fun TrickArea(
val travelPx = cardWidthPx * 2.5f
val throwMillis = motionMillis(CARD_THROW_MILLIS)
// The count follows the cards rather than the rules, so a pile grows as the
// trick reaches it and not the moment the last card was played.
val arrived = rememberArrivedTricks(
tricksWon = state.tricksWon,
sweeping = sweeping,
sweepStarted = sweepStarted,
sweepMillis = motionMillis(CARD_SWEEP_MILLIS),
)
Box(modifier = Modifier.fillMaxSize().padding(4.dp)) {
// What each player has taken, face down in front of them. Drawn first
// so a trick still being gathered passes over the pile it is joining.
TakenTricks(arrived, localSeat, cardWidth)
// The turn card sits in the middle of the table during bidding.
state.upCard?.let { up ->
Column(
@@ -300,9 +313,12 @@ private fun TrickArea(
)
}
.graphicsLayer {
scaleX = landing.scale
scaleY = landing.scale
alpha = landing.alpha * (1f - leaving)
// Squared down into the bundle it is joining, and
// still whole until it gets there.
val gathered = landing.scale * gatherScale(leaving)
scaleX = gathered
scaleY = gathered
alpha = landing.alpha * gatherAlpha(leaving)
},
horizontalAlignment = Alignment.CenterHorizontally,
) {
@@ -240,6 +240,16 @@ private fun ScoreRow(state: HeartsState, localSeat: Int) {
style = MaterialTheme.typography.labelSmall,
fontWeight = if (seat == localSeat) FontWeight.Bold else FontWeight.Normal,
)
// The tricks this player has taken, face down in front of them.
// Hearts keeps the cards themselves rather than a count, since
// it is the cards that are scored at the end of the round, so
// the number of tricks is however many players-worth of them
// there are.
WonTrickStack(
tricks = state.taken.getOrElse(seat) { emptyList() }.size /
state.options.playerCount,
width = 26.dp,
)
Text("$score", fontWeight = FontWeight.Bold)
// What this round has cost so far, which is the number anybody
// actually watches while a hand is being played.
@@ -218,7 +218,21 @@ private fun TrickArea(
val travelPx = cardWidthPx * 2.5f
val throwMillis = motionMillis(CARD_THROW_MILLIS)
// Kaiser scores in points rather than tricks, but the cards are still won
// and still kept, and how many each player is sitting on is worth seeing.
val arrived = rememberArrivedTricks(
tricksWon = state.tricksWon,
sweeping = sweeping,
sweepStarted = sweepStarted,
sweepMillis = motionMillis(CARD_SWEEP_MILLIS),
)
Box(modifier = Modifier.fillMaxSize().padding(4.dp)) {
// Drawn first, so a trick still being gathered passes over the pile it
// is on its way to joining.
TakenTricks(arrived, localSeat, cardWidth)
if (cardsOnTable.isEmpty()) {
Text(
text = when {
@@ -270,9 +284,10 @@ private fun TrickArea(
)
}
.graphicsLayer {
scaleX = landing.scale
scaleY = landing.scale
alpha = landing.alpha * (1f - leaving)
val gathered = landing.scale * gatherScale(leaving)
scaleX = gathered
scaleY = gathered
alpha = landing.alpha * gatherAlpha(leaving)
},
horizontalAlignment = Alignment.CenterHorizontally,
) {
@@ -1,5 +1,7 @@
package org.prolibertate.games.ui.game
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
@@ -11,7 +13,9 @@ import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
@@ -24,10 +28,13 @@ import androidx.compose.runtime.key
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import kotlin.math.roundToInt
import org.prolibertate.games.game.president.PassTurn
import org.prolibertate.games.game.president.PlayCards
import org.prolibertate.games.game.president.PresidentMove
@@ -37,6 +44,7 @@ import org.prolibertate.games.game.president.titleFor
import org.prolibertate.games.net.MatchController
import org.prolibertate.games.ui.PrimaryAction
import org.prolibertate.games.ui.ScreenScaffold
import org.prolibertate.games.ui.common.CARD_ASPECT
import org.prolibertate.games.ui.common.CardBackView
import org.prolibertate.games.ui.common.PlayingCardView
import org.prolibertate.games.ui.theme.FeltGreenDark
@@ -158,19 +166,93 @@ private fun PileView(state: PresidentState, cardWidth: Dp) {
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimary,
)
// The set on top of the pile. A set is played as one gesture but not as
// one card: they leave the hand together and land one after another,
// which is what the stagger off each card's own seed is for.
val onTop = state.pile.takeLast(state.setSize)
val topIndex = state.pile.size - onTop.size
val cardWidthPx = with(LocalDensity.current) { cardWidth.toPx() }
PileHeap(state, cardWidth)
Text(
text = "Beat it with ${state.setSize} card(s)",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimary,
)
}
}
/** 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)
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
onTop.forEachIndexed { offsetInSet, card ->
val place = topIndex + offsetInSet
// 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 rest = cardRest(cardSeed(card, place))
val (fromX, fromY) = pileOrigin(cardSeed(card, 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,
@@ -179,23 +261,32 @@ private fun PileView(state: PresidentState, cardWidth: Dp) {
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.landed(landing),
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
},
)
}
}
}
Text(
text = "Beat it with ${state.setSize} card(s)",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimary,
)
}
}
/** Everyone else, as card counts — their hands are never sent to this device. */
@@ -230,6 +230,23 @@ private fun TrickArea(
val travelPx = cardWidthPx * 2f
val throwMillis = motionMillis(CARD_THROW_MILLIS)
val arrived = rememberArrivedTricks(
tricksWon = state.tricksWon,
sweeping = sweeping,
sweepStarted = sweepStarted,
sweepMillis = motionMillis(CARD_SWEEP_MILLIS),
)
// What each player has taken, face down beside their own place at the
// ring. Drawn first so a trick being gathered passes over its pile.
TakenTricksAround(
tricksWon = arrived,
localSeat = localSeat,
players = players,
cardWidth = cardWidth,
radiusPx = radiusPx,
)
if (cardsOnTable.isEmpty()) {
Text(
text = when {
@@ -279,9 +296,10 @@ private fun TrickArea(
)
}
.graphicsLayer {
scaleX = landing.scale
scaleY = landing.scale
alpha = landing.alpha * (1f - leaving)
val gathered = landing.scale * gatherScale(leaving)
scaleX = gathered
scaleY = gathered
alpha = landing.alpha * gatherAlpha(leaving)
},
horizontalAlignment = Alignment.CenterHorizontally,
) {
@@ -250,6 +250,39 @@ class KaiserRulesTest {
assertEquals("all four held for display", 4, state.completedTrick.size)
}
/**
* Kaiser scores in points, not tricks, so nothing in the rules reads this
* count — the table does, to draw the cards each player has taken. That is
* exactly why it is worth a test: a field no rule depends on is a field
* that can quietly stop being maintained.
*/
@Test
fun `tricks are counted to the seat that took them`() {
// Everything is clubs and nothing is trump, so each trick simply goes
// to the highest card in it: the ace, then the king.
val hands = listOf(
listOf(Card(Rank.ACE, Suit.CLUBS), Card(Rank.SEVEN, Suit.CLUBS)),
listOf(Card(Rank.EIGHT, Suit.CLUBS), Card(Rank.NINE, Suit.CLUBS)),
listOf(Card(Rank.TEN, Suit.CLUBS), Card(Rank.JACK, Suit.CLUBS)),
listOf(Card(Rank.QUEEN, Suit.CLUBS), Card(Rank.KING, Suit.CLUBS)),
)
var state = playingState(hands, trump = Suit.SPADES)
assertEquals("nothing taken at the deal", listOf(0, 0, 0, 0), state.tricksWon)
repeat(4) {
val seat = KaiserRules.currentSeat(state)!!
state = KaiserRules.applyMove(state, seat, PlayCard(state.hands[seat].first()))
}
assertEquals("the ace takes the first", listOf(1, 0, 0, 0), state.tricksWon)
repeat(4) {
val seat = KaiserRules.currentSeat(state)!!
state = KaiserRules.applyMove(state, seat, PlayCard(state.hands[seat].first()))
}
assertEquals("the king takes the second", listOf(1, 0, 0, 1), state.tricksWon)
assertEquals("and they add up", 2, state.tricksWon.sum())
}
// -- Scoring ------------------------------------------------------------
@Test
@@ -98,6 +98,60 @@ class CardPhysicsTest {
assertNotEquals(cardRest(cardSeed(card, 0)), cardRest(cardSeed(card, 1)))
}
/**
* A heap is untidier than a card laid in front of a player, and has to be:
* at the tight bounds five cards on a discard pile are hidden to within a
* twentieth of a card width of each other and the pile looks like one card.
*/
@Test
fun `a heap lies looser than a table but still within bounds`() {
deck.forEach { card ->
val seed = cardSeed(card)
val heap = heapRest(seed)
val table = cardRest(seed)
assertTrue(abs(heap.tiltDegrees) <= HEAP_TILT_DEGREES)
assertTrue(abs(heap.driftX) <= HEAP_DRIFT_FRACTION)
assertTrue(abs(heap.driftY) <= HEAP_DRIFT_FRACTION)
assertTrue(abs(heap.tiltDegrees) >= abs(table.tiltDegrees))
assertTrue(abs(heap.driftX) >= abs(table.driftX))
}
}
/**
* The two have to agree about everything except how far the card strayed,
* or a card would jump when it was drawn one way rather than the other.
*/
@Test
fun `a heap and a table agree about the throw`() {
deck.take(6).forEach { card ->
val seed = cardSeed(card)
assertEquals(cardRest(seed).spinDegrees, heapRest(seed).spinDegrees, 0f)
assertEquals(cardRest(seed).lateness, heapRest(seed).lateness, 0f)
}
}
/**
* A buried card has to end up near the rim of whatever is covering it. One
* left in the middle is one nobody can see, and then drawing the pile as a
* pile achieves nothing.
*/
@Test
fun `a buried card ends up out where a corner of it shows`() {
deck.forEach { card ->
val (x, y) = buriedOffset(cardSeed(card), halfWidth = 100f, halfHeight = 40f)
val howFarOut = kotlin.math.hypot(x / 100f, y / 40f)
assertTrue("$card sat at $howFarOut of the way out", howFarOut >= 0.719f)
assertTrue(howFarOut <= 1.001f)
}
}
/** Two cards buried under the same set must not end up in the same place. */
@Test
fun `buried cards do not stack on each other`() {
val places = (0 until 6).map { buriedOffset(cardSeed(deck[it], it), 100f, 40f) }
assertEquals(places.size, places.toSet().size)
}
/** Every seat's card arrives from that seat, and from a card-width or two out. */
@Test
fun `a card comes in from its own player's side`() {
@@ -155,6 +209,34 @@ class CardPhysicsTest {
hand.forEach { assertTrue(sweepProgress(0.95f, it) > 0.5f) }
}
/**
* A gathered card has to still be whole when it reaches the winner's pile,
* because it vanishes at the same moment the pile grows by one and that
* swap is only invisible if there was something there to swap.
*/
@Test
fun `a gathered card is whole most of the way and gone at the end`() {
assertEquals(1f, gatherAlpha(0f), 0f)
assertEquals(1f, gatherAlpha(0.5f), 0f)
assertEquals(0f, gatherAlpha(1f), 0f)
var previous = 1f
for (step in 0..100) {
val now = gatherAlpha(step / 100f)
assertTrue("went back up at $step", now <= previous + 0.0001f)
assertTrue(now in 0f..1f)
previous = now
}
}
/** And is squared down into the bundle rather than arriving full size. */
@Test
fun `a gathered card is squared down on the way`() {
assertEquals(1f, gatherScale(0f), 0f)
assertTrue(gatherScale(1f) < 1f)
assertTrue(gatherScale(1f) > 0.5f)
assertTrue(gatherScale(0.5f) < gatherScale(0.25f))
}
/** A trick that never completes must not leave a card part way off the table. */
@Test
fun `a sweep that never starts moves nothing`() {