Merge remote-tracking branch 'origin/dev' into claude/locked-phone-turn-issue-x9pvbv
This commit is contained in:
@@ -173,7 +173,7 @@ production package instead.
|
||||
- `.github/workflows/ci.yml` — builds, tests and lints on `dev` and on pull
|
||||
requests into `dev` or `main`. Its APK artifact is a dev build, versioned
|
||||
`<series>.0-dev` — the series being whatever `plgVersionSeries` says in
|
||||
`gradle.properties`, currently **1.1**.
|
||||
`gradle.properties`, currently **1.3**.
|
||||
- `.github/workflows/release.yml` — on push to `main`, builds the APK and
|
||||
publishes it as a GitHub Release tagged `v<series>.<run-number>`. The in-app updater
|
||||
reads that tag, which is why the APK's `versionCode` is the same run number.
|
||||
@@ -201,7 +201,7 @@ A version is **`<series>.<run number>`**. The series is set by hand, once, in
|
||||
`gradle.properties`:
|
||||
|
||||
```properties
|
||||
plgVersionSeries=1.1
|
||||
plgVersionSeries=1.3
|
||||
```
|
||||
|
||||
Both workflows read that same property when they name a release tag, and the
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -26,6 +26,7 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -45,6 +46,7 @@ import org.prolibertate.games.net.LobbyController
|
||||
import org.prolibertate.games.score.ScorekeeperRepository
|
||||
import org.prolibertate.games.settings.Settings
|
||||
import org.prolibertate.games.settings.SettingsRepository
|
||||
import org.prolibertate.games.ui.game.LocalCardSpeed
|
||||
import org.prolibertate.games.update.UpdateChannel
|
||||
import org.prolibertate.games.update.Updater
|
||||
|
||||
@@ -121,14 +123,19 @@ fun AppRoot(env: AppEnv) {
|
||||
var splashDone by remember { mutableStateOf(false) }
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
AppContent(
|
||||
env = env,
|
||||
settings = settings,
|
||||
stack = stack,
|
||||
showComingSoon = showComingSoon,
|
||||
push = push,
|
||||
pop = pop,
|
||||
)
|
||||
// Card motion reads its speed from here rather than from a parameter.
|
||||
// Every card screen animates, most of them several layers deep, and the
|
||||
// alternative was passing one unchanging number down through all of it.
|
||||
CompositionLocalProvider(LocalCardSpeed provides settings.animationSpeed) {
|
||||
AppContent(
|
||||
env = env,
|
||||
settings = settings,
|
||||
stack = stack,
|
||||
showComingSoon = showComingSoon,
|
||||
push = push,
|
||||
pop = pop,
|
||||
)
|
||||
}
|
||||
|
||||
// Over the top, so the menu behind it is already composed when it fades.
|
||||
if (!splashDone) {
|
||||
|
||||
@@ -18,6 +18,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
@@ -41,6 +42,13 @@ fun PlayingCardView(
|
||||
width: Dp = 64.dp,
|
||||
selected: Boolean = false,
|
||||
enabled: Boolean = true,
|
||||
/**
|
||||
* How far off the surface the card is sitting. The default is the thickness
|
||||
* of a card lying on a table, which is what separates a card *on* the felt
|
||||
* from a card painted onto it; a card in flight is raised further by
|
||||
* whatever is animating it.
|
||||
*/
|
||||
elevation: Dp = CARD_RESTING_ELEVATION,
|
||||
/**
|
||||
* Short note printed on the face, for when the rank and suit alone do not
|
||||
* say what the card does — a Sequence jack being the obvious case, since
|
||||
@@ -54,6 +62,7 @@ fun PlayingCardView(
|
||||
modifier = modifier
|
||||
.width(width)
|
||||
.aspectRatio(CARD_ASPECT)
|
||||
.shadow(elevation, RoundedCornerShape(width * 0.12f))
|
||||
.clip(RoundedCornerShape(width * 0.12f))
|
||||
.background(Color.White)
|
||||
.border(
|
||||
@@ -102,11 +111,13 @@ fun PlayingCardView(
|
||||
fun CardBackView(
|
||||
modifier: Modifier = Modifier,
|
||||
width: Dp = 64.dp,
|
||||
elevation: Dp = CARD_RESTING_ELEVATION,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.width(width)
|
||||
.aspectRatio(CARD_ASPECT)
|
||||
.shadow(elevation, RoundedCornerShape(width * 0.12f))
|
||||
.clip(RoundedCornerShape(width * 0.12f))
|
||||
.background(MaterialTheme.colorScheme.primary)
|
||||
.border(1.dp, Color(0x33000000), RoundedCornerShape(width * 0.12f)),
|
||||
@@ -121,3 +132,11 @@ fun CardBackView(
|
||||
}
|
||||
|
||||
const val CARD_ASPECT = 5f / 7f
|
||||
|
||||
/**
|
||||
* A card lying on a surface. Small enough to read as the thickness of a piece
|
||||
* of card rather than as a floating tile — the miniature card backs used as
|
||||
* hand counters are twelve dp wide, and anything heavier turns those into
|
||||
* smudges.
|
||||
*/
|
||||
val CARD_RESTING_ELEVATION: Dp = 1.5.dp
|
||||
|
||||
@@ -0,0 +1,708 @@
|
||||
package org.prolibertate.games.ui.game
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.sin
|
||||
import kotlinx.coroutines.delay
|
||||
import org.prolibertate.games.game.cards.Card
|
||||
import org.prolibertate.games.settings.Settings
|
||||
import org.prolibertate.games.ui.common.CARD_ASPECT
|
||||
import org.prolibertate.games.ui.common.CARD_RESTING_ELEVATION
|
||||
import org.prolibertate.games.ui.common.CardBackView
|
||||
import org.prolibertate.games.ui.common.PlayingCardView
|
||||
|
||||
/**
|
||||
* How cards move.
|
||||
*
|
||||
* A card thrown onto a table does three things that a card faded into place
|
||||
* does not: it arrives from the side of the person who threw it, it overshoots
|
||||
* and rocks back rather than stopping dead, and it comes to rest a little
|
||||
* crooked and a little off the spot it was aimed at. This file is those three
|
||||
* things, and nothing else — no ragdolls, no collisions. Real cards played by
|
||||
* a real hand are only slightly random, and the whole difficulty is in keeping
|
||||
* the randomness *small*: a table where every card lands at a jaunty angle
|
||||
* stops looking like cards and starts looking like a screensaver.
|
||||
*
|
||||
* The randomness is therefore bounded and deterministic. Bounded because every
|
||||
* scatter is a fraction of a card width or a handful of degrees; deterministic
|
||||
* because it is hashed out of the card's own identity rather than drawn from a
|
||||
* generator, which is what stops a card twitching to a new angle every time
|
||||
* something else on the screen recomposes. The same card in the same place
|
||||
* always lies the same way, and two cards never lie the same way as each other.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bounds. Every one of these is deliberately smaller than it wants to be.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Most a settled card is left turned by, in degrees. */
|
||||
const val REST_TILT_DEGREES = 4.5f
|
||||
|
||||
/** Most a settled card misses its spot by, as a fraction of its own width. */
|
||||
const val REST_DRIFT_FRACTION = 0.055f
|
||||
|
||||
/**
|
||||
* The same two bounds for a card thrown onto a heap rather than laid in front
|
||||
* of a player, which is a good deal less tidy. They are also what makes a heap
|
||||
* legible: with the tight bounds a discard pile of five cards looks like one
|
||||
* card, because the four underneath are hidden to within a twentieth of a card
|
||||
* width of the top one.
|
||||
*/
|
||||
const val HEAP_TILT_DEGREES = 8f
|
||||
const val HEAP_DRIFT_FRACTION = 0.15f
|
||||
|
||||
/** Extra turn a card carries while it is still travelling. */
|
||||
const val THROW_SPIN_DEGREES = 15f
|
||||
|
||||
/** Tilt of a card held in a hand, where the pack has been squared up. */
|
||||
const val HAND_TILT_DEGREES = 1.6f
|
||||
|
||||
/** How far off the table a thrown card starts, in card widths. */
|
||||
const val THROW_DISTANCE = 2.2f
|
||||
|
||||
/** How much bigger a card looks while it is above the table rather than on it. */
|
||||
private const val THROW_SCALE = 1.11f
|
||||
|
||||
/**
|
||||
* Shadow under a card in flight. What it settles to is the ordinary resting
|
||||
* elevation every other card on the screen already has, read from there rather
|
||||
* than repeated, so a card that has just landed is indistinguishable from one
|
||||
* that has been lying there all along.
|
||||
*/
|
||||
private const val THROW_ELEVATION_DP = 9f
|
||||
private val REST_ELEVATION_DP = CARD_RESTING_ELEVATION.value
|
||||
|
||||
/**
|
||||
* Bounce in the settle. Below 1 is an underdamped spring, so the card carries
|
||||
* past its resting place and comes back — which is the whole point. Much below
|
||||
* this and it wobbles like rubber.
|
||||
*/
|
||||
private const val SETTLE_DAMPING = 0.62f
|
||||
|
||||
/** Stiffness that gives [CARD_THROW_MILLIS] at normal speed. */
|
||||
private const val SETTLE_STIFFNESS = 420f
|
||||
|
||||
/** Nominal flight time of a card, before the animation-speed setting. */
|
||||
const val CARD_THROW_MILLIS = 380
|
||||
|
||||
/** Nominal time a finished trick takes to be gathered up. */
|
||||
const val CARD_SWEEP_MILLIS = 520
|
||||
|
||||
/**
|
||||
* How much of the sweep is spent letting the stragglers go. Cards do not all
|
||||
* leave at once when a hand scoops them up, but they do all leave quickly.
|
||||
*/
|
||||
private const val SWEEP_SPREAD = 0.3f
|
||||
|
||||
/**
|
||||
* The last stretch of the sweep, over which a gathered card fades out. It has
|
||||
* to be late: the cards vanish exactly as the winner's pile grows by one, and
|
||||
* that swap is only invisible if they are still whole most of the way there.
|
||||
*/
|
||||
private const val SWEEP_FADE = 0.42f
|
||||
|
||||
/** How small four cards squared into a bundle end up against one card face. */
|
||||
private const val GATHERED_SCALE = 0.62f
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The pure part: where a card lands, and how far through leaving it is.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A hash, not a random number.
|
||||
*
|
||||
* Two rounds of a linear congruential step with a shift between them, which is
|
||||
* more than enough to stop neighbouring seeds producing neighbouring angles —
|
||||
* the visible failure would be a whole suit landing at the same tilt.
|
||||
*/
|
||||
private fun scramble(seed: Int): Int {
|
||||
var h = seed
|
||||
repeat(2) {
|
||||
h = h * 1_664_525 + 1_013_904_223
|
||||
h = h xor (h ushr 15)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
/**
|
||||
* A value in `[-1, 1)` for this [seed] and [salt], stable for the life of the
|
||||
* program and identical between runs. [salt] separates the several independent
|
||||
* numbers one card needs from a single seed.
|
||||
*/
|
||||
fun unitNoise(seed: Int, salt: Int): Float {
|
||||
val bits = scramble(seed * 31 + salt * 0x5F5F) ushr 8
|
||||
return bits / 8_388_608f - 1f
|
||||
}
|
||||
|
||||
/** A seed for one card, optionally distinguished by where it is lying. */
|
||||
fun cardSeed(card: Card, place: Int = 0): Int =
|
||||
(card.rank.ordinal * 4 + card.suit.ordinal) * 71 + place * 7919
|
||||
|
||||
/**
|
||||
* Where a card has come to rest, and how it got there.
|
||||
*
|
||||
* All four scatters come off one seed, so the same card always lies the same
|
||||
* way: [tiltDegrees] and the two drifts are where it ended up, [spinDegrees] is
|
||||
* the turn it was still carrying on the way in, and [lateness] is its place in
|
||||
* the queue when a group of cards moves together.
|
||||
*/
|
||||
@Immutable
|
||||
data class CardRest(
|
||||
val tiltDegrees: Float,
|
||||
val driftX: Float,
|
||||
val driftY: Float,
|
||||
val spinDegrees: Float,
|
||||
val lateness: Float,
|
||||
)
|
||||
|
||||
/** The resting scatter for [seed], within the bounds set at the top of the file. */
|
||||
fun cardRest(
|
||||
seed: Int,
|
||||
tilt: Float = REST_TILT_DEGREES,
|
||||
drift: Float = REST_DRIFT_FRACTION,
|
||||
): CardRest = CardRest(
|
||||
tiltDegrees = unitNoise(seed, 1) * tilt,
|
||||
driftX = unitNoise(seed, 2) * drift,
|
||||
driftY = unitNoise(seed, 3) * drift,
|
||||
spinDegrees = unitNoise(seed, 4) * THROW_SPIN_DEGREES,
|
||||
lateness = (unitNoise(seed, 5) + 1f) / 2f,
|
||||
)
|
||||
|
||||
/**
|
||||
* The same, at the looser bounds a heap gets.
|
||||
*
|
||||
* A card keeps this scatter for as long as it is on the heap, including once
|
||||
* something has been played on top of it. Switching a card to a wider scatter
|
||||
* the moment it stopped being the top card would make it shuffle sideways on
|
||||
* its own, which is the one thing on a table that never happens.
|
||||
*/
|
||||
fun heapRest(seed: Int): CardRest = cardRest(seed, HEAP_TILT_DEGREES, HEAP_DRIFT_FRACTION)
|
||||
|
||||
/** The small tilt of a card sitting in a squared-up hand. */
|
||||
fun handTilt(seed: Int): Float = unitNoise(seed, 6) * HAND_TILT_DEGREES
|
||||
|
||||
/**
|
||||
* Where a card thrown by a seat comes in from, in card widths, for a four-seat
|
||||
* table where relative seat 0 sits at the bottom and the rest run clockwise.
|
||||
* A card arrives from beyond its own player's edge, which is the direction it
|
||||
* would have been thrown from.
|
||||
*/
|
||||
fun seatOrigin(relative: Int, distance: Float = THROW_DISTANCE): Pair<Float, Float> =
|
||||
when (relative) {
|
||||
0 -> 0f to distance
|
||||
1 -> -distance to 0f
|
||||
2 -> 0f to -distance
|
||||
else -> distance to 0f
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a card lands on a pile from, in card widths, when there is no seat to
|
||||
* read a direction off — a discard pile takes cards from everyone. The angle is
|
||||
* hashed rather than drawn, so a card always arrives from the same side.
|
||||
*/
|
||||
fun pileOrigin(seed: Int, distance: Float = THROW_DISTANCE): Pair<Float, Float> {
|
||||
val angle = (unitNoise(seed, 7) + 1f) * PI.toFloat()
|
||||
return cos(angle) * distance to sin(angle) * distance
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a card that has been played *on* lies, given how much ground the cards
|
||||
* now covering it take up — half the width and half the height of it, in
|
||||
* pixels.
|
||||
*
|
||||
* Pushed out towards the edge of that ground rather than scattered evenly
|
||||
* across it. A card buried dead centre under a set of four is a card nobody can
|
||||
* see, and the entire point of still drawing it is that you can tell the heap
|
||||
* has a history. Out at the edge a corner shows, which is what a real heap
|
||||
* looks like from above.
|
||||
*/
|
||||
fun buriedOffset(seed: Int, halfWidth: Float, halfHeight: Float): Pair<Float, Float> {
|
||||
val angle = (unitNoise(seed, 11) + 1f) * PI.toFloat()
|
||||
// Never right at the rim and never far inside it.
|
||||
val reach = 0.72f + 0.28f * abs(unitNoise(seed, 12))
|
||||
return cos(angle) * halfWidth * reach to sin(angle) * halfHeight * reach
|
||||
}
|
||||
|
||||
/**
|
||||
* Acceleration curve for cards being dragged off a table: slow to break away,
|
||||
* then gone. Written out rather than taken from an easing class so the sweep
|
||||
* arithmetic stays plain Kotlin and can be tested without a composition.
|
||||
*/
|
||||
private fun accelerate(x: Float): Float = x * x * (0.35f + 0.65f * x)
|
||||
|
||||
/**
|
||||
* How far through leaving a single card is, given how far through the sweep as
|
||||
* a whole is. Cards start over a window of [spread] rather than together, so a
|
||||
* trick gathers up in one motion rather than four cards moving in lockstep.
|
||||
*/
|
||||
fun sweepProgress(sweep: Float, rest: CardRest, spread: Float = SWEEP_SPREAD): Float {
|
||||
val start = rest.lateness.coerceIn(0f, 1f) * spread.coerceIn(0f, 0.9f)
|
||||
val raw = ((sweep - start) / (1f - start)).coerceIn(0f, 1f)
|
||||
return accelerate(raw)
|
||||
}
|
||||
|
||||
/** Straight interpolation, [fraction] being free to overshoot past one. */
|
||||
fun blend(from: Float, to: Float, fraction: Float): Float =
|
||||
from + (to - from) * fraction
|
||||
|
||||
/**
|
||||
* How solid a card being gathered up still is. Whole for most of the journey
|
||||
* and gone at the end of it, so it disappears at the moment the winner's pile
|
||||
* grows rather than dissolving on the way over.
|
||||
*/
|
||||
fun gatherAlpha(leaving: Float): Float = ((1f - leaving) / SWEEP_FADE).coerceIn(0f, 1f)
|
||||
|
||||
/** And how far it has been squared down into the bundle it is joining. */
|
||||
fun gatherScale(leaving: Float): Float = blend(1f, GATHERED_SCALE, leaving.coerceIn(0f, 1f))
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The Compose part.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The animation-speed setting, so card motion follows the same slider as
|
||||
* everything else. A composition local rather than a parameter because it would
|
||||
* otherwise have to be threaded through every card screen and down into every
|
||||
* pile inside them, to say one number that never changes mid-game.
|
||||
*/
|
||||
val LocalCardSpeed = staticCompositionLocalOf { 1f }
|
||||
|
||||
/** A nominal duration, adjusted for the speed the player has chosen. */
|
||||
@Composable
|
||||
fun motionMillis(nominal: Int): Int {
|
||||
val speed = LocalCardSpeed.current.coerceIn(Settings.MIN_SPEED, Settings.MAX_SPEED)
|
||||
return (nominal / speed).toInt().coerceAtLeast(1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a card is, and what it looks like, part way through being thrown.
|
||||
*
|
||||
* [alpha] is only ever 0 or 1, and only ever 0 for a card waiting its turn in a
|
||||
* set that is being played together. Without it a delayed card would sit
|
||||
* plainly visible two card widths off the table until its moment came, which
|
||||
* reads as a card teleporting out and flying back rather than as one still in
|
||||
* somebody's hand.
|
||||
*/
|
||||
@Immutable
|
||||
data class CardLanding(
|
||||
val offsetX: Float,
|
||||
val offsetY: Float,
|
||||
val rotation: Float,
|
||||
val scale: Float,
|
||||
val elevation: Dp,
|
||||
val alpha: Float = 1f,
|
||||
)
|
||||
|
||||
/**
|
||||
* Follows one card from the moment it is played until it has stopped moving.
|
||||
*
|
||||
* The whole flight is one underdamped spring on a single 0-to-1 value, and
|
||||
* everything else is read off it. That is why the card overshoots its resting
|
||||
* place and rocks back instead of easing to a halt: the spring passes 1 and
|
||||
* returns, and the position, the angle and the height above the table all
|
||||
* follow it there and back together. Driving them separately was tried and
|
||||
* looked like three animations that happened to start at the same time.
|
||||
*
|
||||
* [key] is what counts as a new card arriving. It must change when the card in
|
||||
* this place changes and at no other time, or the card either fails to animate
|
||||
* or animates again every recomposition.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberCardLanding(
|
||||
key: Any?,
|
||||
rest: CardRest,
|
||||
facingDegrees: Float,
|
||||
fromX: Float,
|
||||
fromY: Float,
|
||||
cardWidthPx: Float,
|
||||
durationMillis: Int,
|
||||
delayMillis: Int = 0,
|
||||
): CardLanding {
|
||||
val settle = remember(key) { Animatable(0f) }
|
||||
var launched by remember(key) { mutableStateOf(delayMillis <= 0) }
|
||||
LaunchedEffect(key) {
|
||||
if (delayMillis > 0) {
|
||||
delay(delayMillis.toLong())
|
||||
launched = true
|
||||
}
|
||||
settle.animateTo(
|
||||
targetValue = 1f,
|
||||
animationSpec = spring(
|
||||
dampingRatio = SETTLE_DAMPING,
|
||||
stiffness = SETTLE_STIFFNESS * CARD_THROW_MILLIS / durationMillis,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val travelled = settle.value
|
||||
// Height above the table is the one thing that must not overshoot: a card
|
||||
// that has landed cannot rise again, whatever the spring is still doing.
|
||||
val landed = travelled.coerceIn(0f, 1f)
|
||||
return CardLanding(
|
||||
offsetX = blend(fromX, rest.driftX * cardWidthPx, travelled),
|
||||
offsetY = blend(fromY, rest.driftY * cardWidthPx, travelled),
|
||||
rotation = blend(
|
||||
facingDegrees + rest.spinDegrees,
|
||||
facingDegrees + rest.tiltDegrees,
|
||||
travelled,
|
||||
),
|
||||
scale = blend(THROW_SCALE, 1f, landed),
|
||||
elevation = blend(THROW_ELEVATION_DP, REST_ELEVATION_DP, landed).dp,
|
||||
alpha = if (launched) 1f else 0f,
|
||||
)
|
||||
}
|
||||
|
||||
/** Puts a card where its [landing] says it is. For cards drawn on their own. */
|
||||
fun Modifier.landed(landing: CardLanding): Modifier = this
|
||||
.offset { IntOffset(landing.offsetX.roundToInt(), landing.offsetY.roundToInt()) }
|
||||
.graphicsLayer {
|
||||
scaleX = landing.scale
|
||||
scaleY = landing.scale
|
||||
rotationZ = landing.rotation
|
||||
alpha = landing.alpha
|
||||
}
|
||||
|
||||
/** How many cards of a pile are worth drawing. Below this it is not a heap. */
|
||||
private const val PILE_DEPTH = 5
|
||||
|
||||
/**
|
||||
* How many taken tricks are worth drawing separately. Past this the pile stops
|
||||
* growing rather than climbing off the table — Wizard's last round is twenty
|
||||
* tricks, and twenty bundles of stagger is not a pile, it is a staircase.
|
||||
*/
|
||||
private const val WON_STACK_DEPTH = 8
|
||||
|
||||
/** How far each taken trick sits off the one under it, in card widths. */
|
||||
private const val WON_STACK_STEP = 0.045f
|
||||
|
||||
/**
|
||||
* The tricks a player has taken, squared up and turned face down in front of
|
||||
* them.
|
||||
*
|
||||
* One card back per trick rather than four, which is what a real table looks
|
||||
* like: a trick is gathered, squared and put down as a single bundle, and from
|
||||
* above you see the top card of each bundle and the edges of the rest. The
|
||||
* count is the pile — it is the thing a player actually glances at to see how
|
||||
* the hand is going, and it is why cards are kept rather than binned.
|
||||
*/
|
||||
@Composable
|
||||
fun WonTrickStack(
|
||||
tricks: Int,
|
||||
width: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
facingDegrees: Float = 0f,
|
||||
/** Where on the table these were gathered from, in pixels. */
|
||||
fromX: Float = 0f,
|
||||
fromY: Float = 0f,
|
||||
) {
|
||||
if (tricks <= 0) return
|
||||
|
||||
val shown = tricks.coerceAtMost(WON_STACK_DEPTH)
|
||||
val step = width * WON_STACK_STEP
|
||||
val widthPx = with(LocalDensity.current) { width.toPx() }
|
||||
val throwMillis = motionMillis(CARD_THROW_MILLIS)
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.padding(end = step * shown, bottom = step * shown)
|
||||
.width(width)
|
||||
.height(width / CARD_ASPECT),
|
||||
) {
|
||||
for (bundle in 0 until shown) {
|
||||
key(bundle) {
|
||||
// Each bundle is squared up, not thrown down, so it gets a
|
||||
// fraction of the scatter a played card gets.
|
||||
val rest = cardRest(bundle * 613, tilt = REST_TILT_DEGREES * 0.5f)
|
||||
val top = bundle == shown - 1
|
||||
val landing = if (!top) {
|
||||
CardLanding(
|
||||
offsetX = 0f,
|
||||
offsetY = 0f,
|
||||
rotation = facingDegrees + rest.tiltDegrees,
|
||||
scale = 1f,
|
||||
elevation = REST_ELEVATION_DP.dp,
|
||||
)
|
||||
} else {
|
||||
// Only the newest bundle arrives; the ones under it have
|
||||
// been sitting there since earlier in the hand.
|
||||
rememberCardLanding(
|
||||
key = tricks,
|
||||
rest = rest.copy(driftX = 0f, driftY = 0f),
|
||||
facingDegrees = facingDegrees,
|
||||
fromX = fromX,
|
||||
fromY = fromY,
|
||||
cardWidthPx = widthPx,
|
||||
durationMillis = throwMillis,
|
||||
)
|
||||
}
|
||||
CardBackView(
|
||||
width = width,
|
||||
elevation = landing.elevation,
|
||||
modifier = Modifier
|
||||
.offset(x = step * bundle, y = step * bundle)
|
||||
.landed(landing),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** How wide a taken-trick pile is drawn against a card on the table. */
|
||||
private const val WON_STACK_SCALE = 0.5f
|
||||
|
||||
/**
|
||||
* Everyone's taken tricks, at a table of four with partners opposite.
|
||||
*
|
||||
* Each pile sits beside its own player's card, one place clockwise along their
|
||||
* edge, which is both where a real player would put them and the one spot on
|
||||
* each edge that nothing else on the table is using.
|
||||
*/
|
||||
@Composable
|
||||
fun BoxScope.TakenTricks(tricksWon: List<Int>, localSeat: Int, cardWidth: Dp) {
|
||||
val stackWidth = cardWidth * WON_STACK_SCALE
|
||||
val stackWidthPx = with(LocalDensity.current) { stackWidth.toPx() }
|
||||
|
||||
for (seat in 0 until 4) {
|
||||
val relative = ((seat - localSeat) % 4 + 4) % 4
|
||||
val alignment = when (relative) {
|
||||
0 -> Alignment.BottomCenter
|
||||
1 -> Alignment.CenterStart
|
||||
2 -> Alignment.TopCenter
|
||||
else -> Alignment.CenterEnd
|
||||
}
|
||||
val (nudgeX, nudgeY) = when (relative) {
|
||||
0 -> cardWidth * 1.6f to 0.dp
|
||||
1 -> 0.dp to cardWidth * 1.4f
|
||||
2 -> -(cardWidth * 1.6f) to 0.dp
|
||||
else -> 0.dp to -(cardWidth * 1.4f)
|
||||
}
|
||||
// Gathered in from the middle of the table, which is the opposite way
|
||||
// from the edge this player sits at.
|
||||
val (outX, outY) = seatOrigin(relative, 2.5f)
|
||||
|
||||
WonTrickStack(
|
||||
tricks = tricksWon.getOrElse(seat) { 0 },
|
||||
width = stackWidth,
|
||||
facingDegrees = relative * 90f,
|
||||
fromX = -outX * stackWidthPx,
|
||||
fromY = -outY * stackWidthPx,
|
||||
modifier = Modifier.align(alignment).offset(x = nudgeX, y = nudgeY),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The same, round a table of any size: each pile sits half a seat clockwise
|
||||
* from its player's card, just inside the ring the cards are played on.
|
||||
*/
|
||||
@Composable
|
||||
fun BoxScope.TakenTricksAround(
|
||||
tricksWon: List<Int>,
|
||||
localSeat: Int,
|
||||
players: Int,
|
||||
cardWidth: Dp,
|
||||
radiusPx: Float,
|
||||
) {
|
||||
val stackWidth = cardWidth * WON_STACK_SCALE
|
||||
val stackWidthPx = with(LocalDensity.current) { stackWidth.toPx() }
|
||||
|
||||
for (seat in 0 until players) {
|
||||
val relative = ((seat - localSeat) % players + players) % players
|
||||
val angle = 2.0 * PI * relative / players + PI / players
|
||||
val x = -sin(angle) * radiusPx
|
||||
val y = cos(angle) * radiusPx
|
||||
|
||||
WonTrickStack(
|
||||
tricks = tricksWon.getOrElse(seat) { 0 },
|
||||
width = stackWidth,
|
||||
facingDegrees = relative * 360f / players,
|
||||
fromX = (sin(angle) * stackWidthPx * 2.5f).toFloat(),
|
||||
fromY = (-cos(angle) * stackWidthPx * 2.5f).toFloat(),
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.offset { IntOffset(x.roundToInt(), y.roundToInt()) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trick counts, held back until the cards have actually got there.
|
||||
*
|
||||
* The rules count a trick the instant its last card is played, which is a beat
|
||||
* before the cards are gathered and two before they arrive. Reading the count
|
||||
* straight from the state would grow the winner's pile while the trick was
|
||||
* still lying in the middle of the table, and the cards would then slide over
|
||||
* and land on a pile that had already counted them.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberArrivedTricks(
|
||||
tricksWon: List<Int>,
|
||||
sweeping: Boolean,
|
||||
sweepStarted: Boolean,
|
||||
sweepMillis: Int,
|
||||
): List<Int> {
|
||||
var arrived by remember { mutableStateOf(tricksWon) }
|
||||
LaunchedEffect(tricksWon, sweeping, sweepStarted) {
|
||||
// Still on the table waiting to be read: the pile has not won it yet.
|
||||
if (sweeping && !sweepStarted) return@LaunchedEffect
|
||||
if (sweeping) delay(sweepMillis.toLong())
|
||||
arrived = tricksWon
|
||||
}
|
||||
return arrived
|
||||
}
|
||||
|
||||
/** Holder for [rememberPileWatermark]. Deliberately not snapshot state. */
|
||||
private class PileWatermark(var value: Int)
|
||||
|
||||
/**
|
||||
* How much of a pile was already lying there rather than played onto it.
|
||||
*
|
||||
* Cards above this line get thrown on; cards below it are simply drawn where
|
||||
* they lie. Two things have to be true and only one of them is obvious. The
|
||||
* obvious one is that a pile you have just opened the screen on was not built
|
||||
* in front of you, so none of it should fly in. The other is that a pile gets
|
||||
* *shorter* between rounds — a new deal replaces a twenty-card heap with a
|
||||
* single turned card — and a mark left at twenty would then sit above every
|
||||
* card of the new round and quietly switch the animation off for the rest of
|
||||
* the game. So the mark follows the pile down, and only ever down.
|
||||
*
|
||||
* A plain holder rather than snapshot state on purpose: this is written and
|
||||
* read within one composition, and it must not be the reason anything
|
||||
* recomposes.
|
||||
*/
|
||||
@Composable
|
||||
private fun rememberPileWatermark(size: Int): Int {
|
||||
val mark = remember { PileWatermark(size) }
|
||||
if (size < mark.value) mark.value = size
|
||||
return mark.value
|
||||
}
|
||||
|
||||
/**
|
||||
* A pile of cards seen from above.
|
||||
*
|
||||
* Only the top few are drawn, each lying where it fell, which is enough to read
|
||||
* as a heap without painting fifty cards nobody can see. Cards already on the
|
||||
* pile when the screen opens are drawn settled — the pile was built before you
|
||||
* got here — and only cards played after that are thrown on.
|
||||
*/
|
||||
@Composable
|
||||
fun CardPile(
|
||||
cards: List<Card>,
|
||||
width: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
depth: Int = PILE_DEPTH,
|
||||
) {
|
||||
if (cards.isEmpty()) return
|
||||
|
||||
// Position in the pile, not position in the window, so a card keeps its
|
||||
// identity — and so its resting angle — as the window slides past it.
|
||||
val bottom = (cards.size - depth).coerceAtLeast(0)
|
||||
val alreadyDown = rememberPileWatermark(cards.size)
|
||||
val cardWidthPx = with(LocalDensity.current) { width.toPx() }
|
||||
val throwMillis = motionMillis(CARD_THROW_MILLIS)
|
||||
|
||||
// Room for the scatter, so the outermost card is not clipped by whatever
|
||||
// this pile has been placed inside.
|
||||
val margin = width * HEAP_DRIFT_FRACTION
|
||||
Box(
|
||||
modifier = modifier
|
||||
.padding(horizontal = margin, vertical = margin)
|
||||
.width(width)
|
||||
.height(width / CARD_ASPECT),
|
||||
) {
|
||||
for (index in bottom until cards.size) {
|
||||
key(index) {
|
||||
val card = cards[index]
|
||||
val rest = heapRest(cardSeed(card, index))
|
||||
val landing = if (index < alreadyDown) {
|
||||
CardLanding(
|
||||
offsetX = rest.driftX * cardWidthPx,
|
||||
offsetY = rest.driftY * cardWidthPx,
|
||||
rotation = rest.tiltDegrees,
|
||||
scale = 1f,
|
||||
elevation = REST_ELEVATION_DP.dp,
|
||||
)
|
||||
} else {
|
||||
val (fromX, fromY) = pileOrigin(cardSeed(card, index))
|
||||
rememberCardLanding(
|
||||
key = index,
|
||||
rest = rest,
|
||||
facingDegrees = 0f,
|
||||
fromX = fromX * cardWidthPx,
|
||||
fromY = fromY * cardWidthPx,
|
||||
cardWidthPx = cardWidthPx,
|
||||
durationMillis = throwMillis,
|
||||
)
|
||||
}
|
||||
PlayingCardView(
|
||||
card = card,
|
||||
width = width,
|
||||
elevation = landing.elevation,
|
||||
modifier = Modifier.landed(landing),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A stock of face-down cards, drawn with enough of the pack under the top card
|
||||
* to show there is something left in it. The offsets are a squared-up deck's
|
||||
* worth — a couple of percent — rather than the scatter a played card gets.
|
||||
*/
|
||||
@Composable
|
||||
fun CardBackStack(
|
||||
count: Int,
|
||||
width: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
depth: Int = 3,
|
||||
) {
|
||||
if (count <= 0) return
|
||||
|
||||
val shown = count.coerceAtMost(depth)
|
||||
val step = width * 0.018f
|
||||
Box(
|
||||
modifier = modifier
|
||||
.padding(end = step * shown, bottom = step * shown)
|
||||
.width(width)
|
||||
.height(width / CARD_ASPECT),
|
||||
) {
|
||||
// Bottom of the pack first, so the top card is drawn last and sits on top.
|
||||
for (below in (shown - 1) downTo 0) {
|
||||
CardBackView(
|
||||
width = width,
|
||||
elevation = if (below == 0) REST_ELEVATION_DP.dp else 0.dp,
|
||||
modifier = Modifier
|
||||
.offset(x = step * below, y = step * below)
|
||||
.rotate(unitNoise(below, 8) * 0.8f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -181,7 +182,10 @@ private fun PileArea(state: CrazyEightsState, cardWidth: Dp) {
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
} else {
|
||||
CardBackView(width = cardWidth)
|
||||
// A pack, not a single back: the stock is the one thing on
|
||||
// this screen that is visibly a stack of card, and drawing
|
||||
// it as one is most of what makes the table look real.
|
||||
CardBackStack(count = state.stock.size, width = cardWidth)
|
||||
}
|
||||
}
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
@@ -190,7 +194,9 @@ private fun PileArea(state: CrazyEightsState, cardWidth: Dp) {
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
state.topCard?.let { PlayingCardView(card = it, width = cardWidth) }
|
||||
// The discard heap rather than only its top card, so a card
|
||||
// played onto it lands on something.
|
||||
CardPile(cards = state.discard, width = cardWidth)
|
||||
}
|
||||
}
|
||||
// An eight on top says nothing about what may be played next, so the
|
||||
@@ -331,6 +337,7 @@ private fun HandRow(
|
||||
enabled = canPlay,
|
||||
selected = card == pendingWild,
|
||||
caption = if (isWild(card)) "WILD" else null,
|
||||
modifier = Modifier.rotate(handTilt(cardSeed(card))),
|
||||
onClick = { onTap(card) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.prolibertate.games.ui.game
|
||||
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
@@ -25,6 +26,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -33,6 +35,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
@@ -184,9 +187,6 @@ private fun seatLabel(relative: Int): String = when (relative) {
|
||||
/** How long a completed trick rests on the table before being swept away. */
|
||||
const val TRICK_HOLD_MILLIS = 1_000L
|
||||
|
||||
/** Duration of the sweep itself. */
|
||||
private const val TRICK_SWEEP_MILLIS = 450
|
||||
|
||||
@Composable
|
||||
private fun TrickArea(
|
||||
state: EuchreState,
|
||||
@@ -206,9 +206,12 @@ private fun TrickArea(
|
||||
sweepStarted = true
|
||||
}
|
||||
}
|
||||
// Driven linearly, because the shape of the sweep belongs to each card
|
||||
// rather than to the clock: sweepProgress gives them their own start and
|
||||
// their own acceleration off this one value.
|
||||
val sweep by animateFloatAsState(
|
||||
targetValue = if (sweeping && sweepStarted) 1f else 0f,
|
||||
animationSpec = tween(durationMillis = TRICK_SWEEP_MILLIS),
|
||||
animationSpec = tween(durationMillis = motionMillis(CARD_SWEEP_MILLIS), easing = LinearEasing),
|
||||
label = "trickSweep",
|
||||
)
|
||||
|
||||
@@ -220,10 +223,25 @@ private fun TrickArea(
|
||||
2 -> 0f to -1f
|
||||
else -> 1f to 0f
|
||||
}
|
||||
val travelPx = with(LocalDensity.current) { (cardWidth * 2.5f).toPx() }
|
||||
val cardWidthPx = with(LocalDensity.current) { cardWidth.toPx() }
|
||||
val travelPx = cardWidthPx * 2.5f
|
||||
val throwMillis = motionMillis(CARD_THROW_MILLIS)
|
||||
|
||||
// The count follows the cards rather than the rules, so a pile grows as the
|
||||
// trick reaches it and not the moment the last card was played.
|
||||
val arrived = rememberArrivedTricks(
|
||||
tricksWon = state.tricksWon,
|
||||
sweeping = sweeping,
|
||||
sweepStarted = sweepStarted,
|
||||
sweepMillis = motionMillis(CARD_SWEEP_MILLIS),
|
||||
)
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize().padding(4.dp)) {
|
||||
|
||||
// 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(
|
||||
@@ -249,45 +267,77 @@ private fun TrickArea(
|
||||
)
|
||||
}
|
||||
|
||||
// Keyed by seat: one card per seat per trick, so this holds a card's
|
||||
// flight steady while the trick fills up and while the finished trick
|
||||
// is swapped for the copy that gets swept away. Without it the list
|
||||
// position would decide identity and every card would jump back into
|
||||
// the air whenever another one landed.
|
||||
cardsOnTable.forEach { played ->
|
||||
val relative = relativePosition(played.seat, localSeat)
|
||||
val alignment = when (relative) {
|
||||
0 -> Alignment.BottomCenter
|
||||
1 -> Alignment.CenterStart
|
||||
2 -> Alignment.TopCenter
|
||||
else -> Alignment.CenterEnd
|
||||
}
|
||||
// Rotate to face the player who played it: the side seats end up
|
||||
// landscape, the partner's card upside down from your side.
|
||||
val rotation = when (relative) {
|
||||
1 -> 90f
|
||||
2 -> 180f
|
||||
3 -> -90f
|
||||
else -> 0f
|
||||
}
|
||||
key(played.seat) {
|
||||
val relative = relativePosition(played.seat, localSeat)
|
||||
val alignment = when (relative) {
|
||||
0 -> Alignment.BottomCenter
|
||||
1 -> Alignment.CenterStart
|
||||
2 -> Alignment.TopCenter
|
||||
else -> Alignment.CenterEnd
|
||||
}
|
||||
// Turned to face the player who played it: the side seats end
|
||||
// up landscape, the partner's card upside down from your side.
|
||||
val facing = when (relative) {
|
||||
1 -> 90f
|
||||
2 -> 180f
|
||||
3 -> -90f
|
||||
else -> 0f
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(alignment)
|
||||
.offset {
|
||||
IntOffset(
|
||||
x = (dirX * travelPx * sweep).roundToInt(),
|
||||
y = (dirY * travelPx * sweep).roundToInt(),
|
||||
)
|
||||
}
|
||||
.alpha(1f - sweep),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
PlayingCardView(
|
||||
card = played.card,
|
||||
width = cardWidth,
|
||||
modifier = Modifier.rotate(rotation),
|
||||
)
|
||||
Text(
|
||||
text = seatLabel(relative),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
val rest = cardRest(cardSeed(played.card, played.seat))
|
||||
val (fromX, fromY) = seatOrigin(relative)
|
||||
val landing = rememberCardLanding(
|
||||
key = played.card,
|
||||
rest = rest,
|
||||
facingDegrees = facing,
|
||||
fromX = fromX * cardWidthPx,
|
||||
fromY = fromY * cardWidthPx,
|
||||
cardWidthPx = cardWidthPx,
|
||||
durationMillis = throwMillis,
|
||||
)
|
||||
val leaving = sweepProgress(sweep, rest)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(alignment)
|
||||
.offset {
|
||||
IntOffset(
|
||||
x = (landing.offsetX + dirX * travelPx * leaving).roundToInt(),
|
||||
y = (landing.offsetY + dirY * travelPx * leaving).roundToInt(),
|
||||
)
|
||||
}
|
||||
.graphicsLayer {
|
||||
// Squared down into the bundle it is joining, and
|
||||
// still whole until it gets there.
|
||||
val gathered = landing.scale * gatherScale(leaving)
|
||||
scaleX = gathered
|
||||
scaleY = gathered
|
||||
alpha = landing.alpha * gatherAlpha(leaving)
|
||||
},
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
PlayingCardView(
|
||||
card = played.card,
|
||||
width = cardWidth,
|
||||
elevation = landing.elevation,
|
||||
// The turn it picks up on the way out is the same turn
|
||||
// it carried on the way in, so a card leaves the table
|
||||
// the way it arrived.
|
||||
modifier = Modifier
|
||||
.rotate(landing.rotation + rest.spinDegrees * leaving),
|
||||
)
|
||||
Text(
|
||||
text = seatLabel(relative),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -449,6 +499,11 @@ private fun HandRow(
|
||||
card = card,
|
||||
width = cardWidth,
|
||||
enabled = canPlay || canDiscard,
|
||||
// A hand that has been squared up is still not square. Two
|
||||
// degrees is below the threshold at which anyone notices a
|
||||
// tilt and above the one at which a row of cards stops
|
||||
// looking printed.
|
||||
modifier = Modifier.rotate(handTilt(cardSeed(card))),
|
||||
onClick = {
|
||||
when {
|
||||
canDiscard -> onPlay(Discard(card))
|
||||
|
||||
@@ -252,7 +252,14 @@ private fun StockAndDiscard(
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("Stock", style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onPrimary)
|
||||
CardBackView(width = cardWidth)
|
||||
// Drawing the pack rather than one back also means an empty stock
|
||||
// now looks empty, where before it showed a card that was not there.
|
||||
if (state.stock.isEmpty()) {
|
||||
Text("empty", style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onPrimary)
|
||||
} else {
|
||||
CardBackStack(count = state.stock.size, width = cardWidth)
|
||||
}
|
||||
if (legal.contains(DrawFromStock)) {
|
||||
Button(onClick = { onMove(DrawFromStock) }) { Text("Draw") }
|
||||
}
|
||||
@@ -261,7 +268,7 @@ private fun StockAndDiscard(
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("Discard", style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onPrimary)
|
||||
state.discard.lastOrNull()?.let { PlayingCardView(card = it, width = cardWidth) }
|
||||
CardPile(cards = state.discard, width = cardWidth)
|
||||
if (legal.contains(DrawFromDiscard)) {
|
||||
Button(onClick = { onMove(DrawFromDiscard) }) { Text("Take") }
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ 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
|
||||
@@ -25,6 +26,8 @@ import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.prolibertate.games.game.cards.Card
|
||||
@@ -126,6 +129,7 @@ fun HeartsScreen(
|
||||
selected = chosen.contains(card),
|
||||
enabled = if (selectable) true else playable.contains(card),
|
||||
caption = pointsOf(card).takeIf { it > 0 }?.let { "−$it" },
|
||||
modifier = Modifier.rotate(handTilt(cardSeed(card))),
|
||||
onClick = {
|
||||
if (selectable) {
|
||||
toggle(chosen, card)
|
||||
@@ -188,13 +192,35 @@ private fun TrickView(state: HeartsState) {
|
||||
if (showing.isEmpty()) {
|
||||
Text("Nothing played yet.", style = MaterialTheme.typography.bodySmall)
|
||||
} else {
|
||||
// Hearts lays its trick out in a row rather than round a table, so
|
||||
// there is no seat direction to throw a card in from — each one
|
||||
// comes in from its own side, which the seed decides and keeps.
|
||||
val width = 46.dp
|
||||
val widthPx = with(LocalDensity.current) { width.toPx() }
|
||||
val throwMillis = motionMillis(CARD_THROW_MILLIS)
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
showing.forEach { played ->
|
||||
PlayingCardView(
|
||||
card = played.card,
|
||||
width = 46.dp,
|
||||
caption = "seat ${played.seat}",
|
||||
)
|
||||
key(played.seat) {
|
||||
val seed = cardSeed(played.card, played.seat)
|
||||
val rest = cardRest(seed)
|
||||
val (fromX, fromY) = pileOrigin(seed)
|
||||
val landing = rememberCardLanding(
|
||||
key = played.card,
|
||||
rest = rest,
|
||||
facingDegrees = 0f,
|
||||
fromX = fromX * widthPx,
|
||||
fromY = fromY * widthPx,
|
||||
cardWidthPx = widthPx,
|
||||
durationMillis = throwMillis,
|
||||
)
|
||||
PlayingCardView(
|
||||
card = played.card,
|
||||
width = width,
|
||||
caption = "seat ${played.seat}",
|
||||
elevation = landing.elevation,
|
||||
modifier = Modifier.landed(landing),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -214,6 +240,16 @@ private fun ScoreRow(state: HeartsState, localSeat: Int) {
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
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.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.prolibertate.games.ui.game
|
||||
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
@@ -25,6 +26,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
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
|
||||
@@ -32,6 +34,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
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
|
||||
@@ -181,9 +184,6 @@ private fun seatLabel(relative: Int): String = when (relative) {
|
||||
else -> "Right"
|
||||
}
|
||||
|
||||
/** Duration of the sweep towards the winner. */
|
||||
private const val SWEEP_MILLIS = 450
|
||||
|
||||
@Composable
|
||||
private fun TrickArea(
|
||||
state: KaiserState,
|
||||
@@ -203,7 +203,7 @@ private fun TrickArea(
|
||||
}
|
||||
val sweep by animateFloatAsState(
|
||||
targetValue = if (sweeping && sweepStarted) 1f else 0f,
|
||||
animationSpec = tween(durationMillis = SWEEP_MILLIS),
|
||||
animationSpec = tween(durationMillis = motionMillis(CARD_SWEEP_MILLIS), easing = LinearEasing),
|
||||
label = "kaiserSweep",
|
||||
)
|
||||
|
||||
@@ -214,9 +214,25 @@ private fun TrickArea(
|
||||
2 -> 0f to -1f
|
||||
else -> 1f to 0f
|
||||
}
|
||||
val travelPx = with(LocalDensity.current) { (cardWidth * 2.5f).toPx() }
|
||||
val cardWidthPx = with(LocalDensity.current) { cardWidth.toPx() }
|
||||
val travelPx = cardWidthPx * 2.5f
|
||||
val throwMillis = motionMillis(CARD_THROW_MILLIS)
|
||||
|
||||
// Kaiser scores in points rather than tricks, but the cards are still won
|
||||
// and still kept, and how many each player is sitting on is worth seeing.
|
||||
val arrived = rememberArrivedTricks(
|
||||
tricksWon = state.tricksWon,
|
||||
sweeping = sweeping,
|
||||
sweepStarted = sweepStarted,
|
||||
sweepMillis = motionMillis(CARD_SWEEP_MILLIS),
|
||||
)
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize().padding(4.dp)) {
|
||||
|
||||
// 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 {
|
||||
@@ -230,43 +246,65 @@ private fun TrickArea(
|
||||
}
|
||||
|
||||
cardsOnTable.forEach { played ->
|
||||
val relative = relativePosition(played.seat, localSeat)
|
||||
val alignment = when (relative) {
|
||||
0 -> Alignment.BottomCenter
|
||||
1 -> Alignment.CenterStart
|
||||
2 -> Alignment.TopCenter
|
||||
else -> Alignment.CenterEnd
|
||||
}
|
||||
val rotation = when (relative) {
|
||||
1 -> 90f
|
||||
2 -> 180f
|
||||
3 -> -90f
|
||||
else -> 0f
|
||||
}
|
||||
key(played.seat) {
|
||||
val relative = relativePosition(played.seat, localSeat)
|
||||
val alignment = when (relative) {
|
||||
0 -> Alignment.BottomCenter
|
||||
1 -> Alignment.CenterStart
|
||||
2 -> Alignment.TopCenter
|
||||
else -> Alignment.CenterEnd
|
||||
}
|
||||
val facing = when (relative) {
|
||||
1 -> 90f
|
||||
2 -> 180f
|
||||
3 -> -90f
|
||||
else -> 0f
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(alignment)
|
||||
.offset {
|
||||
IntOffset(
|
||||
x = (dirX * travelPx * sweep).roundToInt(),
|
||||
y = (dirY * travelPx * sweep).roundToInt(),
|
||||
)
|
||||
}
|
||||
.alpha(1f - sweep),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
PlayingCardView(
|
||||
card = played.card,
|
||||
width = cardWidth,
|
||||
caption = counterCaption(played.card),
|
||||
modifier = Modifier.rotate(rotation),
|
||||
)
|
||||
Text(
|
||||
text = seatLabel(relative),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
val rest = cardRest(cardSeed(played.card, played.seat))
|
||||
val (fromX, fromY) = seatOrigin(relative)
|
||||
val landing = rememberCardLanding(
|
||||
key = played.card,
|
||||
rest = rest,
|
||||
facingDegrees = facing,
|
||||
fromX = fromX * cardWidthPx,
|
||||
fromY = fromY * cardWidthPx,
|
||||
cardWidthPx = cardWidthPx,
|
||||
durationMillis = throwMillis,
|
||||
)
|
||||
val leaving = sweepProgress(sweep, rest)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(alignment)
|
||||
.offset {
|
||||
IntOffset(
|
||||
x = (landing.offsetX + dirX * travelPx * leaving).roundToInt(),
|
||||
y = (landing.offsetY + dirY * travelPx * leaving).roundToInt(),
|
||||
)
|
||||
}
|
||||
.graphicsLayer {
|
||||
val gathered = landing.scale * gatherScale(leaving)
|
||||
scaleX = gathered
|
||||
scaleY = gathered
|
||||
alpha = landing.alpha * gatherAlpha(leaving)
|
||||
},
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
PlayingCardView(
|
||||
card = played.card,
|
||||
width = cardWidth,
|
||||
caption = counterCaption(played.card),
|
||||
elevation = landing.elevation,
|
||||
modifier = Modifier
|
||||
.rotate(landing.rotation + rest.spinDegrees * leaving),
|
||||
)
|
||||
Text(
|
||||
text = seatLabel(relative),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -406,6 +444,7 @@ private fun HandRow(
|
||||
width = cardWidth,
|
||||
enabled = canPlay,
|
||||
caption = counterCaption(card),
|
||||
modifier = Modifier.rotate(handTilt(cardSeed(card))),
|
||||
onClick = { if (canPlay) onPlay(PlayCard(card)) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -20,11 +24,17 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
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
|
||||
@@ -34,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
|
||||
@@ -155,11 +166,7 @@ private fun PileView(state: PresidentState, cardWidth: Dp) {
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
state.pile.takeLast(state.setSize).forEach { card ->
|
||||
PlayingCardView(card = card, width = cardWidth)
|
||||
}
|
||||
}
|
||||
PileHeap(state, cardWidth)
|
||||
Text(
|
||||
text = "Beat it with ${state.setSize} card(s)",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
@@ -168,6 +175,120 @@ private fun PileView(state: PresidentState, cardWidth: Dp) {
|
||||
}
|
||||
}
|
||||
|
||||
/** How much of what is under the top set is worth drawing. */
|
||||
private const val BURIED_DEPTH = 5
|
||||
|
||||
/**
|
||||
* The pile, as a pile.
|
||||
*
|
||||
* Every card played since the pile was last cleared is still lying there, and a
|
||||
* new set is played *onto* them rather than instead of them — which is what the
|
||||
* pile is for. Only the top set has to be readable, since that is the one that
|
||||
* has to be beaten, so it is laid out square and side by side; everything under
|
||||
* it is pushed out towards the edge of the ground the top set covers, so a
|
||||
* corner of it shows.
|
||||
*
|
||||
* The whole pile is drawn in one loop keyed by position, which is what lets a
|
||||
* set slide out of the way as the next one lands on it instead of vanishing
|
||||
* from the row and reappearing under the heap. Each card animates from wherever
|
||||
* it actually is to wherever it now belongs, so being buried needs no
|
||||
* bookkeeping about where it used to sit.
|
||||
*/
|
||||
@Composable
|
||||
private fun PileHeap(state: PresidentState, cardWidth: Dp) {
|
||||
val density = LocalDensity.current
|
||||
val cardWidthPx = with(density) { cardWidth.toPx() }
|
||||
val cardHeightPx = cardWidthPx / CARD_ASPECT
|
||||
val gapPx = with(density) { 6.dp.toPx() }
|
||||
val throwMillis = motionMillis(CARD_THROW_MILLIS)
|
||||
|
||||
// Where the cards of the top set sit: centred, side by side.
|
||||
val step = cardWidthPx + gapPx
|
||||
val slotOffset = (state.setSize - 1) / 2f
|
||||
|
||||
// Half the ground the top set covers, which is what the cards underneath
|
||||
// have to get out from under.
|
||||
val halfWidth = (step * state.setSize) / 2f
|
||||
val halfHeight = cardHeightPx * 0.34f
|
||||
|
||||
val lowest = (state.pile.size - state.setSize - BURIED_DEPTH).coerceAtLeast(0)
|
||||
|
||||
Box(
|
||||
modifier = Modifier.size(
|
||||
width = with(density) { (halfWidth * 2f + cardWidthPx * 0.9f).toDp() },
|
||||
height = with(density) { (cardHeightPx + halfHeight * 2f).toDp() },
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
for (place in lowest until state.pile.size) {
|
||||
key(place) {
|
||||
val card = state.pile[place]
|
||||
val seed = cardSeed(card, place)
|
||||
val depth = state.pile.size - 1 - place
|
||||
val onTop = depth < state.setSize
|
||||
|
||||
val rest = heapRest(seed)
|
||||
val (buriedX, buriedY) = buriedOffset(seed, halfWidth, halfHeight)
|
||||
val (targetX, targetY) = if (onTop) {
|
||||
// Slot 0 is the leftmost card of the set, which is the
|
||||
// deepest of them — they were dealt onto the pile in order.
|
||||
val slot = state.setSize - 1 - depth
|
||||
(slot - slotOffset) * step to 0f
|
||||
} else {
|
||||
buriedX to buriedY
|
||||
}
|
||||
|
||||
val slideSpec = tween<Float>(durationMillis = throwMillis)
|
||||
val x by animateFloatAsState(targetX, slideSpec, label = "pileX")
|
||||
val y by animateFloatAsState(targetY, slideSpec, label = "pileY")
|
||||
// Buried cards are knocked further askew than the tidy set on
|
||||
// top of them, which is the other half of reading as a heap.
|
||||
val tilt by animateFloatAsState(
|
||||
targetValue = if (onTop) rest.tiltDegrees * 0.4f else rest.tiltDegrees,
|
||||
animationSpec = slideSpec,
|
||||
label = "pileTilt",
|
||||
)
|
||||
|
||||
// The throw is relative to wherever the card belongs, so the two
|
||||
// compose: a card lands in its slot, and is later shoved aside
|
||||
// from it without the landing having to know.
|
||||
val (fromX, fromY) = pileOrigin(seed)
|
||||
val landing = rememberCardLanding(
|
||||
key = place,
|
||||
rest = rest,
|
||||
facingDegrees = 0f,
|
||||
fromX = fromX * cardWidthPx,
|
||||
fromY = fromY * cardWidthPx,
|
||||
cardWidthPx = cardWidthPx,
|
||||
durationMillis = throwMillis,
|
||||
// A set leaves the hand together and lands one after
|
||||
// another rather than as a block.
|
||||
delayMillis = (rest.lateness * throwMillis * 0.3f).toInt(),
|
||||
)
|
||||
|
||||
PlayingCardView(
|
||||
card = card,
|
||||
width = cardWidth,
|
||||
elevation = landing.elevation,
|
||||
modifier = Modifier
|
||||
.offset {
|
||||
IntOffset(
|
||||
(x + landing.offsetX).roundToInt(),
|
||||
(y + landing.offsetY).roundToInt(),
|
||||
)
|
||||
}
|
||||
.graphicsLayer {
|
||||
scaleX = landing.scale
|
||||
scaleY = landing.scale
|
||||
rotationZ = tilt + (landing.rotation - rest.tiltDegrees)
|
||||
alpha = landing.alpha
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Everyone else, as card counts — their hands are never sent to this device. */
|
||||
@Composable
|
||||
private fun OpponentRow(state: PresidentState, localSeat: Int) {
|
||||
@@ -258,7 +379,11 @@ private fun HandView(state: PresidentState, localSeat: Int, cardWidth: Dp) {
|
||||
Text("—", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
hand.forEach { card ->
|
||||
PlayingCardView(card = card, width = cardWidth * 0.8f)
|
||||
PlayingCardView(
|
||||
card = card,
|
||||
width = cardWidth * 0.8f,
|
||||
modifier = Modifier.rotate(handTilt(cardSeed(card))),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.prolibertate.games.ui.game
|
||||
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
@@ -26,6 +27,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
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
|
||||
@@ -33,6 +35,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
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
|
||||
@@ -190,9 +193,6 @@ private fun BidTally(state: WizardState, localSeat: Int) {
|
||||
private fun seatLabel(seat: Int, localSeat: Int): String =
|
||||
if (seat == localSeat) "You" else "Seat $seat"
|
||||
|
||||
/** Duration of the sweep towards the winner. */
|
||||
private const val SWEEP_MILLIS = 450
|
||||
|
||||
@Composable
|
||||
private fun TrickArea(
|
||||
state: WizardState,
|
||||
@@ -213,7 +213,7 @@ private fun TrickArea(
|
||||
}
|
||||
val sweep by animateFloatAsState(
|
||||
targetValue = if (sweeping && sweepStarted) 1f else 0f,
|
||||
animationSpec = tween(durationMillis = SWEEP_MILLIS),
|
||||
animationSpec = tween(durationMillis = motionMillis(CARD_SWEEP_MILLIS), easing = LinearEasing),
|
||||
label = "wizardSweep",
|
||||
)
|
||||
|
||||
@@ -226,7 +226,26 @@ private fun TrickArea(
|
||||
// Cards sit on a circle that fits inside whatever space the table has.
|
||||
val radius = (minOf(maxWidth, maxHeight) / 2 - cardWidth * 0.7f).coerceAtLeast(0.dp)
|
||||
val radiusPx = with(LocalDensity.current) { radius.toPx() }
|
||||
val travelPx = with(LocalDensity.current) { (cardWidth * 2f).toPx() }
|
||||
val cardWidthPx = with(LocalDensity.current) { cardWidth.toPx() }
|
||||
val travelPx = cardWidthPx * 2f
|
||||
val throwMillis = motionMillis(CARD_THROW_MILLIS)
|
||||
|
||||
val arrived = rememberArrivedTricks(
|
||||
tricksWon = state.tricksWon,
|
||||
sweeping = sweeping,
|
||||
sweepStarted = sweepStarted,
|
||||
sweepMillis = motionMillis(CARD_SWEEP_MILLIS),
|
||||
)
|
||||
|
||||
// What each player has taken, face down beside their own place at the
|
||||
// ring. Drawn first so a trick being gathered passes over its pile.
|
||||
TakenTricksAround(
|
||||
tricksWon = arrived,
|
||||
localSeat = localSeat,
|
||||
players = players,
|
||||
cardWidth = cardWidth,
|
||||
radiusPx = radiusPx,
|
||||
)
|
||||
|
||||
if (cardsOnTable.isEmpty()) {
|
||||
Text(
|
||||
@@ -241,37 +260,63 @@ private fun TrickArea(
|
||||
}
|
||||
|
||||
cardsOnTable.forEach { played ->
|
||||
val relative = ((played.seat - localSeat) % players + players) % players
|
||||
val angle = 2.0 * PI * relative / players
|
||||
// Relative seat 0 is you, at the bottom; the rest run clockwise.
|
||||
val x = -sin(angle) * radiusPx
|
||||
val y = cos(angle) * radiusPx
|
||||
// Turn each card to face whoever played it.
|
||||
val rotation = (relative * 360f / players)
|
||||
key(played.seat) {
|
||||
val relative = ((played.seat - localSeat) % players + players) % players
|
||||
val angle = 2.0 * PI * relative / players
|
||||
// Relative seat 0 is you, at the bottom; the rest run clockwise.
|
||||
val x = -sin(angle) * radiusPx
|
||||
val y = cos(angle) * radiusPx
|
||||
// Turn each card to face whoever played it.
|
||||
val facing = (relative * 360f / players)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.offset {
|
||||
IntOffset(
|
||||
x = (x + -sin(winnerAngle) * travelPx * sweep).roundToInt(),
|
||||
y = (y + cos(winnerAngle) * travelPx * sweep).roundToInt(),
|
||||
)
|
||||
}
|
||||
.alpha(1f - sweep),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
PlayingCardView(
|
||||
card = played.card,
|
||||
width = cardWidth,
|
||||
caption = specialCaption(played.card),
|
||||
modifier = Modifier.rotate(rotation),
|
||||
)
|
||||
Text(
|
||||
text = seatLabel(played.seat, localSeat),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
// A card comes in along the ray it sits on, from further out —
|
||||
// which on a round table is the direction its player is in,
|
||||
// however many of them there are.
|
||||
val rest = cardRest(cardSeed(played.card, played.seat))
|
||||
val landing = rememberCardLanding(
|
||||
key = played.card,
|
||||
rest = rest,
|
||||
facingDegrees = facing,
|
||||
fromX = (-sin(angle) * THROW_DISTANCE * cardWidthPx).toFloat(),
|
||||
fromY = (cos(angle) * THROW_DISTANCE * cardWidthPx).toFloat(),
|
||||
cardWidthPx = cardWidthPx,
|
||||
durationMillis = throwMillis,
|
||||
)
|
||||
val leaving = sweepProgress(sweep, rest)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.offset {
|
||||
IntOffset(
|
||||
x = (x + landing.offsetX +
|
||||
-sin(winnerAngle) * travelPx * leaving).roundToInt(),
|
||||
y = (y + landing.offsetY +
|
||||
cos(winnerAngle) * travelPx * leaving).roundToInt(),
|
||||
)
|
||||
}
|
||||
.graphicsLayer {
|
||||
val gathered = landing.scale * gatherScale(leaving)
|
||||
scaleX = gathered
|
||||
scaleY = gathered
|
||||
alpha = landing.alpha * gatherAlpha(leaving)
|
||||
},
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
PlayingCardView(
|
||||
card = played.card,
|
||||
width = cardWidth,
|
||||
caption = specialCaption(played.card),
|
||||
elevation = landing.elevation,
|
||||
modifier = Modifier
|
||||
.rotate(landing.rotation + rest.spinDegrees * leaving),
|
||||
)
|
||||
Text(
|
||||
text = seatLabel(played.seat, localSeat),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -395,6 +440,7 @@ private fun HandRow(
|
||||
width = cardWidth,
|
||||
enabled = canPlay,
|
||||
caption = specialCaption(card),
|
||||
modifier = Modifier.rotate(handTilt(cardSeed(card))),
|
||||
onClick = { if (canPlay) onPlay(PlayCard(card)) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
package org.prolibertate.games.ui.game
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import kotlin.math.abs
|
||||
import org.prolibertate.games.game.cards.Card
|
||||
import org.prolibertate.games.game.cards.Decks
|
||||
import org.prolibertate.games.game.cards.Rank
|
||||
import org.prolibertate.games.game.cards.Suit
|
||||
|
||||
/**
|
||||
* The scatter under the card animations.
|
||||
*
|
||||
* Two properties matter and neither is visible in a screenshot. It has to be
|
||||
* *bounded*, because the whole difference between cards on a table and cards
|
||||
* thrown down a flight of stairs is the size of the numbers; and it has to be
|
||||
* *stable*, because a card that picks a new angle on every recomposition
|
||||
* twitches, and a card whose angle depends on where it sits in a list jumps
|
||||
* whenever a card is played next to it.
|
||||
*/
|
||||
class CardPhysicsTest {
|
||||
|
||||
private val deck = Decks.standard52()
|
||||
|
||||
@Test
|
||||
fun `noise stays inside minus one to one`() {
|
||||
for (seed in -500..500) {
|
||||
for (salt in 0..8) {
|
||||
val n = unitNoise(seed, salt)
|
||||
assertTrue("$seed/$salt gave $n", n >= -1f && n < 1f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `noise is the same every time it is asked`() {
|
||||
assertEquals(unitNoise(12345, 3), unitNoise(12345, 3), 0f)
|
||||
assertEquals(unitNoise(-7, 1), unitNoise(-7, 1), 0f)
|
||||
}
|
||||
|
||||
/**
|
||||
* The salt is what lets one seed produce a tilt, two drifts, a spin and a
|
||||
* queue position that are not all the same number. If it stopped separating
|
||||
* them, every card would drift exactly as far as it tilted.
|
||||
*/
|
||||
@Test
|
||||
fun `salts do not agree with each other`() {
|
||||
val fromOneSeed = (0..8).map { unitNoise(4242, it) }
|
||||
assertEquals(fromOneSeed.size, fromOneSeed.toSet().size)
|
||||
}
|
||||
|
||||
/** Sequential seeds are the normal case: adjacent cards in a hand. */
|
||||
@Test
|
||||
fun `neighbouring seeds do not give neighbouring values`() {
|
||||
val runs = (0 until 200).count { abs(unitNoise(it, 1) - unitNoise(it + 1, 1)) < 0.05f }
|
||||
assertTrue("$runs of 200 neighbouring seeds landed together", runs < 30)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every card in a deck rests within the stated bounds`() {
|
||||
deck.forEach { card ->
|
||||
val rest = cardRest(cardSeed(card))
|
||||
assertTrue(abs(rest.tiltDegrees) <= REST_TILT_DEGREES)
|
||||
assertTrue(abs(rest.driftX) <= REST_DRIFT_FRACTION)
|
||||
assertTrue(abs(rest.driftY) <= REST_DRIFT_FRACTION)
|
||||
assertTrue(abs(rest.spinDegrees) <= THROW_SPIN_DEGREES)
|
||||
assertTrue(rest.lateness in 0f..1f)
|
||||
assertTrue(abs(handTilt(cardSeed(card))) <= HAND_TILT_DEGREES)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a card lies the same way however often it is asked`() {
|
||||
val card = Card(Rank.QUEEN, Suit.SPADES)
|
||||
assertEquals(cardRest(cardSeed(card)), cardRest(cardSeed(card)))
|
||||
assertEquals(cardRest(cardSeed(card, 3)), cardRest(cardSeed(card, 3)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Two cards lying at the same angle in the same place is the one thing that
|
||||
* makes a scattered pile look printed rather than dealt.
|
||||
*/
|
||||
@Test
|
||||
fun `no two cards in a deck share a seed`() {
|
||||
val seeds = deck.map { cardSeed(it) }
|
||||
assertEquals(seeds.size, seeds.toSet().size)
|
||||
}
|
||||
|
||||
/**
|
||||
* A pile can hold the same card twice — Golf and Spider deal more than one
|
||||
* deck — so where a card is lying has to be part of its seed.
|
||||
*/
|
||||
@Test
|
||||
fun `the same card in two places lies two ways`() {
|
||||
val card = Card(Rank.SEVEN, Suit.HEARTS)
|
||||
assertNotEquals(cardRest(cardSeed(card, 0)), cardRest(cardSeed(card, 1)))
|
||||
}
|
||||
|
||||
/**
|
||||
* A heap is untidier than a card laid in front of a player, and has to be:
|
||||
* at the tight bounds five cards on a discard pile are hidden to within a
|
||||
* twentieth of a card width of each other and the pile looks like one card.
|
||||
*/
|
||||
@Test
|
||||
fun `a heap lies looser than a table but still within bounds`() {
|
||||
deck.forEach { card ->
|
||||
val seed = cardSeed(card)
|
||||
val heap = heapRest(seed)
|
||||
val table = cardRest(seed)
|
||||
assertTrue(abs(heap.tiltDegrees) <= HEAP_TILT_DEGREES)
|
||||
assertTrue(abs(heap.driftX) <= HEAP_DRIFT_FRACTION)
|
||||
assertTrue(abs(heap.driftY) <= HEAP_DRIFT_FRACTION)
|
||||
assertTrue(abs(heap.tiltDegrees) >= abs(table.tiltDegrees))
|
||||
assertTrue(abs(heap.driftX) >= abs(table.driftX))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The two have to agree about everything except how far the card strayed,
|
||||
* or a card would jump when it was drawn one way rather than the other.
|
||||
*/
|
||||
@Test
|
||||
fun `a heap and a table agree about the throw`() {
|
||||
deck.take(6).forEach { card ->
|
||||
val seed = cardSeed(card)
|
||||
assertEquals(cardRest(seed).spinDegrees, heapRest(seed).spinDegrees, 0f)
|
||||
assertEquals(cardRest(seed).lateness, heapRest(seed).lateness, 0f)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A buried card has to end up near the rim of whatever is covering it. One
|
||||
* left in the middle is one nobody can see, and then drawing the pile as a
|
||||
* pile achieves nothing.
|
||||
*/
|
||||
@Test
|
||||
fun `a buried card ends up out where a corner of it shows`() {
|
||||
deck.forEach { card ->
|
||||
val (x, y) = buriedOffset(cardSeed(card), halfWidth = 100f, halfHeight = 40f)
|
||||
val howFarOut = kotlin.math.hypot(x / 100f, y / 40f)
|
||||
assertTrue("$card sat at $howFarOut of the way out", howFarOut >= 0.719f)
|
||||
assertTrue(howFarOut <= 1.001f)
|
||||
}
|
||||
}
|
||||
|
||||
/** Two cards buried under the same set must not end up in the same place. */
|
||||
@Test
|
||||
fun `buried cards do not stack on each other`() {
|
||||
val places = (0 until 6).map { buriedOffset(cardSeed(deck[it], it), 100f, 40f) }
|
||||
assertEquals(places.size, places.toSet().size)
|
||||
}
|
||||
|
||||
/** Every seat's card arrives from that seat, and from a card-width or two out. */
|
||||
@Test
|
||||
fun `a card comes in from its own player's side`() {
|
||||
assertEquals(0f to THROW_DISTANCE, seatOrigin(0))
|
||||
assertEquals(-THROW_DISTANCE to 0f, seatOrigin(1))
|
||||
assertEquals(0f to -THROW_DISTANCE, seatOrigin(2))
|
||||
assertEquals(THROW_DISTANCE to 0f, seatOrigin(3))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a pile card comes in from somewhere off the pile`() {
|
||||
deck.forEach { card ->
|
||||
val (x, y) = pileOrigin(cardSeed(card))
|
||||
val distance = kotlin.math.hypot(x, y)
|
||||
assertEquals(THROW_DISTANCE, distance, 0.001f)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nothing has left the table before the sweep starts`() {
|
||||
deck.take(8).forEach { card ->
|
||||
assertEquals(0f, sweepProgress(0f, cardRest(cardSeed(card))), 0f)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `everything has left the table by the end of the sweep`() {
|
||||
deck.take(8).forEach { card ->
|
||||
assertEquals(1f, sweepProgress(1f, cardRest(cardSeed(card))), 0.0001f)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a card only ever gets further from the table`() {
|
||||
val rest = cardRest(cardSeed(Card(Rank.ACE, Suit.CLUBS)))
|
||||
var previous = 0f
|
||||
for (step in 0..100) {
|
||||
val now = sweepProgress(step / 100f, rest)
|
||||
assertTrue("went backwards at $step", now >= previous - 0.0001f)
|
||||
assertTrue(now in 0f..1f)
|
||||
previous = now
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The stagger: a trick gathers up as one motion, so the cards must not all
|
||||
* be at the same point in it, and none of them may still be sitting there
|
||||
* when the sweep is nearly over.
|
||||
*/
|
||||
@Test
|
||||
fun `cards leave at different moments but none is left behind`() {
|
||||
val hand = deck.take(4).map { cardRest(cardSeed(it)) }
|
||||
val halfway = hand.map { sweepProgress(0.5f, it) }
|
||||
assertEquals(halfway.size, halfway.toSet().size)
|
||||
hand.forEach { assertTrue(sweepProgress(0.95f, it) > 0.5f) }
|
||||
}
|
||||
|
||||
/**
|
||||
* A gathered card has to still be whole when it reaches the winner's pile,
|
||||
* because it vanishes at the same moment the pile grows by one and that
|
||||
* swap is only invisible if there was something there to swap.
|
||||
*/
|
||||
@Test
|
||||
fun `a gathered card is whole most of the way and gone at the end`() {
|
||||
assertEquals(1f, gatherAlpha(0f), 0f)
|
||||
assertEquals(1f, gatherAlpha(0.5f), 0f)
|
||||
assertEquals(0f, gatherAlpha(1f), 0f)
|
||||
var previous = 1f
|
||||
for (step in 0..100) {
|
||||
val now = gatherAlpha(step / 100f)
|
||||
assertTrue("went back up at $step", now <= previous + 0.0001f)
|
||||
assertTrue(now in 0f..1f)
|
||||
previous = now
|
||||
}
|
||||
}
|
||||
|
||||
/** And is squared down into the bundle rather than arriving full size. */
|
||||
@Test
|
||||
fun `a gathered card is squared down on the way`() {
|
||||
assertEquals(1f, gatherScale(0f), 0f)
|
||||
assertTrue(gatherScale(1f) < 1f)
|
||||
assertTrue(gatherScale(1f) > 0.5f)
|
||||
assertTrue(gatherScale(0.5f) < gatherScale(0.25f))
|
||||
}
|
||||
|
||||
/** A trick that never completes must not leave a card part way off the table. */
|
||||
@Test
|
||||
fun `a sweep that never starts moves nothing`() {
|
||||
deck.take(4).forEach { card ->
|
||||
assertEquals(0f, sweepProgress(0f, cardRest(cardSeed(card)), spread = 0f), 0f)
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -22,4 +22,4 @@ android.nonTransitiveRClass=true
|
||||
# Only the series is set by hand. The number after it is the CI run number, so
|
||||
# it keeps climbing on its own and an APK's versionCode never goes backwards
|
||||
# when this changes.
|
||||
plgVersionSeries=1.2
|
||||
plgVersionSeries=1.3
|
||||
|
||||
Reference in New Issue
Block a user