Play onto the pile rather than instead of it

President drew only the set on top. Everything played before it was in
the state and simply not on the screen, so beating a pair looked like
the previous pair being taken away and replaced rather than covered.
The pile is the point of the game — what is under the top set is the
history of the round — and it was invisible.

The whole pile is now drawn in one loop keyed by position in it. The top
set is laid out square and side by side, because that is the set that
has to be beaten and the only one that has to be readable; everything
under it is pushed out towards the rim of the ground the top set covers,
so a corner shows. Buried dead centre is buried, and then drawing it
achieves nothing.

Keying by position is what makes the transition work. A superseded set
is the same composable it always was, so when its target moves from a
row slot to the rim it animates from wherever it actually is — no
bookkeeping about where it used to sit, and no card vanishing from the
row to reappear under the heap a frame later. The throw stays relative
to wherever a card belongs, so the two compose: a card lands in its
slot, and is shoved aside from it later without the landing having to
know.

The heap scatter is also now its own pair of bounds, looser than the
table's: eight degrees and fifteen per cent of a card width against four
and a half and five. This was not cosmetic. At the tight bounds a
discard pile of five cards was five cards hidden to within a twentieth
of a card width of each other, which reads as one card — Crazy 8s and
Golf were technically stacking and visibly not. A card keeps whichever
scatter it was given for as long as it lies there, since a card that
shuffled sideways the moment something landed on it would be a card
moving on its own.

Cribbage's series is the same shape of thing and was already keeping its
cards, so it gains the landing to match; its hand gains the tilt the
other hands have.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0132V9gE6DF9itz2z3Hn7BK9
This commit is contained in:
Claude
2026-08-13 22:00:58 +00:00
parent 7c839a9242
commit a7427b0cb5
4 changed files with 261 additions and 42 deletions
@@ -24,6 +24,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlin.math.PI import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.cos import kotlin.math.cos
import kotlin.math.roundToInt import kotlin.math.roundToInt
import kotlin.math.sin import kotlin.math.sin
@@ -65,6 +66,16 @@ const val REST_TILT_DEGREES = 4.5f
/** Most a settled card misses its spot by, as a fraction of its own width. */ /** Most a settled card misses its spot by, as a fraction of its own width. */
const val REST_DRIFT_FRACTION = 0.055f 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. */ /** Extra turn a card carries while it is still travelling. */
const val THROW_SPIN_DEGREES = 15f const val THROW_SPIN_DEGREES = 15f
@@ -160,14 +171,28 @@ data class CardRest(
) )
/** The resting scatter for [seed], within the bounds set at the top of the file. */ /** The resting scatter for [seed], within the bounds set at the top of the file. */
fun cardRest(seed: Int): CardRest = CardRest( fun cardRest(
tiltDegrees = unitNoise(seed, 1) * REST_TILT_DEGREES, seed: Int,
driftX = unitNoise(seed, 2) * REST_DRIFT_FRACTION, tilt: Float = REST_TILT_DEGREES,
driftY = unitNoise(seed, 3) * REST_DRIFT_FRACTION, 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, spinDegrees = unitNoise(seed, 4) * THROW_SPIN_DEGREES,
lateness = (unitNoise(seed, 5) + 1f) / 2f, 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. */ /** The small tilt of a card sitting in a squared-up hand. */
fun handTilt(seed: Int): Float = unitNoise(seed, 6) * HAND_TILT_DEGREES fun handTilt(seed: Int): Float = unitNoise(seed, 6) * HAND_TILT_DEGREES
@@ -195,6 +220,24 @@ fun pileOrigin(seed: Int, distance: Float = THROW_DISTANCE): Pair<Float, Float>
return cos(angle) * distance to sin(angle) * distance 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, * 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 * then gone. Written out rather than taken from an easing class so the sweep
@@ -213,7 +256,8 @@ fun sweepProgress(sweep: Float, rest: CardRest, spread: Float = SWEEP_SPREAD): F
return accelerate(raw) 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 from + (to - from) * fraction
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -324,7 +368,7 @@ fun Modifier.landed(landing: CardLanding): Modifier = this
} }
/** How many cards of a pile are worth drawing. Below this it is not a heap. */ /** 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
/** Holder for [rememberPileWatermark]. Deliberately not snapshot state. */ /** Holder for [rememberPileWatermark]. Deliberately not snapshot state. */
private class PileWatermark(var value: Int) private class PileWatermark(var value: Int)
@@ -378,7 +422,7 @@ fun CardPile(
// Room for the scatter, so the outermost card is not clipped by whatever // Room for the scatter, so the outermost card is not clipped by whatever
// this pile has been placed inside. // this pile has been placed inside.
val margin = width * REST_DRIFT_FRACTION val margin = width * HEAP_DRIFT_FRACTION
Box( Box(
modifier = modifier modifier = modifier
.padding(horizontal = margin, vertical = margin) .padding(horizontal = margin, vertical = margin)
@@ -388,7 +432,7 @@ fun CardPile(
for (index in bottom until cards.size) { for (index in bottom until cards.size) {
key(index) { key(index) {
val card = cards[index] val card = cards[index]
val rest = cardRest(cardSeed(card, index)) val rest = heapRest(cardSeed(card, index))
val landing = if (index < alreadyDown) { val landing = if (index < alreadyDown) {
CardLanding( CardLanding(
offsetX = rest.driftX * cardWidthPx, offsetX = rest.driftX * cardWidthPx,
@@ -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,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
@@ -24,10 +28,13 @@ 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.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
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
@@ -37,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
@@ -158,19 +166,93 @@ private fun PileView(state: PresidentState, cardWidth: Dp) {
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimary, color = MaterialTheme.colorScheme.onPrimary,
) )
// The set on top of the pile. A set is played as one gesture but not as PileHeap(state, cardWidth)
// one card: they leave the hand together and land one after another, Text(
// which is what the stagger off each card's own seed is for. text = "Beat it with ${state.setSize} card(s)",
val onTop = state.pile.takeLast(state.setSize) style = MaterialTheme.typography.labelSmall,
val topIndex = state.pile.size - onTop.size color = MaterialTheme.colorScheme.onPrimary,
val cardWidthPx = with(LocalDensity.current) { cardWidth.toPx() } )
}
}
/** 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) val throwMillis = motionMillis(CARD_THROW_MILLIS)
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
onTop.forEachIndexed { offsetInSet, card -> // Where the cards of the top set sit: centred, side by side.
val place = topIndex + offsetInSet 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) { key(place) {
val rest = cardRest(cardSeed(card, place)) val card = state.pile[place]
val (fromX, fromY) = pileOrigin(cardSeed(card, 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( val landing = rememberCardLanding(
key = place, key = place,
rest = rest, rest = rest,
@@ -179,23 +261,32 @@ private fun PileView(state: PresidentState, cardWidth: Dp) {
fromY = fromY * cardWidthPx, fromY = fromY * cardWidthPx,
cardWidthPx = cardWidthPx, cardWidthPx = cardWidthPx,
durationMillis = throwMillis, 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(), delayMillis = (rest.lateness * throwMillis * 0.3f).toInt(),
) )
PlayingCardView( PlayingCardView(
card = card, card = card,
width = cardWidth, width = cardWidth,
elevation = landing.elevation, 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. */ /** Everyone else, as card counts — their hands are never sent to this device. */
@@ -98,6 +98,60 @@ class CardPhysicsTest {
assertNotEquals(cardRest(cardSeed(card, 0)), cardRest(cardSeed(card, 1))) 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. */ /** Every seat's card arrives from that seat, and from a card-width or two out. */
@Test @Test
fun `a card comes in from its own player's side`() { fun `a card comes in from its own player's side`() {