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:
@@ -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,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package org.prolibertate.games.ui.game
|
|||||||
import androidx.compose.animation.core.Animatable
|
import androidx.compose.animation.core.Animatable
|
||||||
import androidx.compose.animation.core.spring
|
import androidx.compose.animation.core.spring
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.offset
|
import androidx.compose.foundation.layout.offset
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
@@ -16,6 +17,7 @@ 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.runtime.staticCompositionLocalOf
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
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.graphics.graphicsLayer
|
||||||
@@ -24,6 +26,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 +68,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
|
||||||
|
|
||||||
@@ -108,6 +121,16 @@ const val CARD_SWEEP_MILLIS = 520
|
|||||||
*/
|
*/
|
||||||
private const val SWEEP_SPREAD = 0.3f
|
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.
|
// 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. */
|
/** 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 +232,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,9 +268,20 @@ 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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 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. */
|
/** 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. */
|
/** Holder for [rememberPileWatermark]. Deliberately not snapshot state. */
|
||||||
private class PileWatermark(var value: Int)
|
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
|
// 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 +640,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) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -227,8 +227,21 @@ private fun TrickArea(
|
|||||||
val travelPx = cardWidthPx * 2.5f
|
val travelPx = cardWidthPx * 2.5f
|
||||||
val throwMillis = motionMillis(CARD_THROW_MILLIS)
|
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(
|
||||||
@@ -300,9 +313,12 @@ private fun TrickArea(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
.graphicsLayer {
|
.graphicsLayer {
|
||||||
scaleX = landing.scale
|
// Squared down into the bundle it is joining, and
|
||||||
scaleY = landing.scale
|
// still whole until it gets there.
|
||||||
alpha = landing.alpha * (1f - leaving)
|
val gathered = landing.scale * gatherScale(leaving)
|
||||||
|
scaleX = gathered
|
||||||
|
scaleY = gathered
|
||||||
|
alpha = landing.alpha * gatherAlpha(leaving)
|
||||||
},
|
},
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -240,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.
|
||||||
|
|||||||
@@ -218,7 +218,21 @@ private fun TrickArea(
|
|||||||
val travelPx = cardWidthPx * 2.5f
|
val travelPx = cardWidthPx * 2.5f
|
||||||
val throwMillis = motionMillis(CARD_THROW_MILLIS)
|
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 {
|
||||||
@@ -270,9 +284,10 @@ private fun TrickArea(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
.graphicsLayer {
|
.graphicsLayer {
|
||||||
scaleX = landing.scale
|
val gathered = landing.scale * gatherScale(leaving)
|
||||||
scaleY = landing.scale
|
scaleX = gathered
|
||||||
alpha = landing.alpha * (1f - leaving)
|
scaleY = gathered
|
||||||
|
alpha = landing.alpha * gatherAlpha(leaving)
|
||||||
},
|
},
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -230,6 +230,23 @@ private fun TrickArea(
|
|||||||
val travelPx = cardWidthPx * 2f
|
val travelPx = cardWidthPx * 2f
|
||||||
val throwMillis = motionMillis(CARD_THROW_MILLIS)
|
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(
|
||||||
text = when {
|
text = when {
|
||||||
@@ -279,9 +296,10 @@ private fun TrickArea(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
.graphicsLayer {
|
.graphicsLayer {
|
||||||
scaleX = landing.scale
|
val gathered = landing.scale * gatherScale(leaving)
|
||||||
scaleY = landing.scale
|
scaleX = gathered
|
||||||
alpha = landing.alpha * (1f - leaving)
|
scaleY = gathered
|
||||||
|
alpha = landing.alpha * gatherAlpha(leaving)
|
||||||
},
|
},
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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`() {
|
||||||
@@ -155,6 +209,34 @@ class CardPhysicsTest {
|
|||||||
hand.forEach { assertTrue(sweepProgress(0.95f, it) > 0.5f) }
|
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. */
|
/** A trick that never completes must not leave a card part way off the table. */
|
||||||
@Test
|
@Test
|
||||||
fun `a sweep that never starts moves nothing`() {
|
fun `a sweep that never starts moves nothing`() {
|
||||||
|
|||||||
Reference in New Issue
Block a user