Merge pull request #29: card play as cards landing on a table
Model card play as cards landing on a table
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
|
- `.github/workflows/ci.yml` — builds, tests and lints on `dev` and on pull
|
||||||
requests into `dev` or `main`. Its APK artifact is a dev build, versioned
|
requests into `dev` or `main`. Its APK artifact is a dev build, versioned
|
||||||
`<series>.0-dev` — the series being whatever `plgVersionSeries` says in
|
`<series>.0-dev` — the series being whatever `plgVersionSeries` says in
|
||||||
`gradle.properties`, currently **1.1**.
|
`gradle.properties`, currently **1.3**.
|
||||||
- `.github/workflows/release.yml` — on push to `main`, builds the APK and
|
- `.github/workflows/release.yml` — on push to `main`, builds the APK and
|
||||||
publishes it as a GitHub Release tagged `v<series>.<run-number>`. The in-app updater
|
publishes it as a GitHub Release tagged `v<series>.<run-number>`. The in-app updater
|
||||||
reads that tag, which is why the APK's `versionCode` is the same run number.
|
reads that tag, which is why the APK's `versionCode` is the same run number.
|
||||||
@@ -201,7 +201,7 @@ A version is **`<series>.<run number>`**. The series is set by hand, once, in
|
|||||||
`gradle.properties`:
|
`gradle.properties`:
|
||||||
|
|
||||||
```properties
|
```properties
|
||||||
plgVersionSeries=1.1
|
plgVersionSeries=1.3
|
||||||
```
|
```
|
||||||
|
|
||||||
Both workflows read that same property when they name a release tag, and the
|
Both workflows read that same property when they name a release tag, and the
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -45,6 +46,7 @@ import org.prolibertate.games.net.LobbyController
|
|||||||
import org.prolibertate.games.score.ScorekeeperRepository
|
import org.prolibertate.games.score.ScorekeeperRepository
|
||||||
import org.prolibertate.games.settings.Settings
|
import org.prolibertate.games.settings.Settings
|
||||||
import org.prolibertate.games.settings.SettingsRepository
|
import org.prolibertate.games.settings.SettingsRepository
|
||||||
|
import org.prolibertate.games.ui.game.LocalCardSpeed
|
||||||
import org.prolibertate.games.update.UpdateChannel
|
import org.prolibertate.games.update.UpdateChannel
|
||||||
import org.prolibertate.games.update.Updater
|
import org.prolibertate.games.update.Updater
|
||||||
|
|
||||||
@@ -121,6 +123,10 @@ fun AppRoot(env: AppEnv) {
|
|||||||
var splashDone by remember { mutableStateOf(false) }
|
var splashDone by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
// 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(
|
AppContent(
|
||||||
env = env,
|
env = env,
|
||||||
settings = settings,
|
settings = settings,
|
||||||
@@ -129,6 +135,7 @@ fun AppRoot(env: AppEnv) {
|
|||||||
push = push,
|
push = push,
|
||||||
pop = pop,
|
pop = pop,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Over the top, so the menu behind it is already composed when it fades.
|
// Over the top, so the menu behind it is already composed when it fades.
|
||||||
if (!splashDone) {
|
if (!splashDone) {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.shadow
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
@@ -41,6 +42,13 @@ fun PlayingCardView(
|
|||||||
width: Dp = 64.dp,
|
width: Dp = 64.dp,
|
||||||
selected: Boolean = false,
|
selected: Boolean = false,
|
||||||
enabled: Boolean = true,
|
enabled: Boolean = true,
|
||||||
|
/**
|
||||||
|
* How far off the surface the card is sitting. The default is the thickness
|
||||||
|
* of a card lying on a table, which is what separates a card *on* the felt
|
||||||
|
* from a card painted onto it; a card in flight is raised further by
|
||||||
|
* whatever is animating it.
|
||||||
|
*/
|
||||||
|
elevation: Dp = CARD_RESTING_ELEVATION,
|
||||||
/**
|
/**
|
||||||
* Short note printed on the face, for when the rank and suit alone do not
|
* Short note printed on the face, for when the rank and suit alone do not
|
||||||
* say what the card does — a Sequence jack being the obvious case, since
|
* say what the card does — a Sequence jack being the obvious case, since
|
||||||
@@ -54,6 +62,7 @@ fun PlayingCardView(
|
|||||||
modifier = modifier
|
modifier = modifier
|
||||||
.width(width)
|
.width(width)
|
||||||
.aspectRatio(CARD_ASPECT)
|
.aspectRatio(CARD_ASPECT)
|
||||||
|
.shadow(elevation, RoundedCornerShape(width * 0.12f))
|
||||||
.clip(RoundedCornerShape(width * 0.12f))
|
.clip(RoundedCornerShape(width * 0.12f))
|
||||||
.background(Color.White)
|
.background(Color.White)
|
||||||
.border(
|
.border(
|
||||||
@@ -102,11 +111,13 @@ fun PlayingCardView(
|
|||||||
fun CardBackView(
|
fun CardBackView(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
width: Dp = 64.dp,
|
width: Dp = 64.dp,
|
||||||
|
elevation: Dp = CARD_RESTING_ELEVATION,
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.width(width)
|
.width(width)
|
||||||
.aspectRatio(CARD_ASPECT)
|
.aspectRatio(CARD_ASPECT)
|
||||||
|
.shadow(elevation, RoundedCornerShape(width * 0.12f))
|
||||||
.clip(RoundedCornerShape(width * 0.12f))
|
.clip(RoundedCornerShape(width * 0.12f))
|
||||||
.background(MaterialTheme.colorScheme.primary)
|
.background(MaterialTheme.colorScheme.primary)
|
||||||
.border(1.dp, Color(0x33000000), RoundedCornerShape(width * 0.12f)),
|
.border(1.dp, Color(0x33000000), RoundedCornerShape(width * 0.12f)),
|
||||||
@@ -121,3 +132,11 @@ fun CardBackView(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const val CARD_ASPECT = 5f / 7f
|
const val CARD_ASPECT = 5f / 7f
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A card lying on a surface. Small enough to read as the thickness of a piece
|
||||||
|
* of card rather than as a floating tile — the miniature card backs used as
|
||||||
|
* hand counters are twelve dp wide, and anything heavier turns those into
|
||||||
|
* smudges.
|
||||||
|
*/
|
||||||
|
val CARD_RESTING_ELEVATION: Dp = 1.5.dp
|
||||||
|
|||||||
@@ -0,0 +1,456 @@
|
|||||||
|
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.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.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.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
|
||||||
|
|
||||||
|
/** 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 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): CardRest = CardRest(
|
||||||
|
tiltDegrees = unitNoise(seed, 1) * REST_TILT_DEGREES,
|
||||||
|
driftX = unitNoise(seed, 2) * REST_DRIFT_FRACTION,
|
||||||
|
driftY = unitNoise(seed, 3) * REST_DRIFT_FRACTION,
|
||||||
|
spinDegrees = unitNoise(seed, 4) * THROW_SPIN_DEGREES,
|
||||||
|
lateness = (unitNoise(seed, 5) + 1f) / 2f,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 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
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun blend(from: Float, to: Float, fraction: Float): Float =
|
||||||
|
from + (to - from) * fraction
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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 = 4
|
||||||
|
|
||||||
|
/** 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 * REST_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 = cardRest(cardSeed(card, index))
|
||||||
|
val landing = if (index < alreadyDown) {
|
||||||
|
CardLanding(
|
||||||
|
offsetX = rest.driftX * cardWidthPx,
|
||||||
|
offsetY = rest.driftY * cardWidthPx,
|
||||||
|
rotation = rest.tiltDegrees,
|
||||||
|
scale = 1f,
|
||||||
|
elevation = REST_ELEVATION_DP.dp,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val (fromX, fromY) = pileOrigin(cardSeed(card, index))
|
||||||
|
rememberCardLanding(
|
||||||
|
key = index,
|
||||||
|
rest = rest,
|
||||||
|
facingDegrees = 0f,
|
||||||
|
fromX = fromX * cardWidthPx,
|
||||||
|
fromY = fromY * cardWidthPx,
|
||||||
|
cardWidthPx = cardWidthPx,
|
||||||
|
durationMillis = throwMillis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
PlayingCardView(
|
||||||
|
card = card,
|
||||||
|
width = width,
|
||||||
|
elevation = landing.elevation,
|
||||||
|
modifier = Modifier.landed(landing),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A stock of face-down cards, drawn with enough of the pack under the top card
|
||||||
|
* to show there is something left in it. The offsets are a squared-up deck's
|
||||||
|
* worth — a couple of percent — rather than the scatter a played card gets.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CardBackStack(
|
||||||
|
count: Int,
|
||||||
|
width: Dp,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
depth: Int = 3,
|
||||||
|
) {
|
||||||
|
if (count <= 0) return
|
||||||
|
|
||||||
|
val shown = count.coerceAtMost(depth)
|
||||||
|
val step = width * 0.018f
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.padding(end = step * shown, bottom = step * shown)
|
||||||
|
.width(width)
|
||||||
|
.height(width / CARD_ASPECT),
|
||||||
|
) {
|
||||||
|
// Bottom of the pack first, so the top card is drawn last and sits on top.
|
||||||
|
for (below in (shown - 1) downTo 0) {
|
||||||
|
CardBackView(
|
||||||
|
width = width,
|
||||||
|
elevation = if (below == 0) REST_ELEVATION_DP.dp else 0.dp,
|
||||||
|
modifier = Modifier
|
||||||
|
.offset(x = step * below, y = step * below)
|
||||||
|
.rotate(unitNoise(below, 8) * 0.8f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.rotate
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -181,7 +182,10 @@ private fun PileArea(state: CrazyEightsState, cardWidth: Dp) {
|
|||||||
color = MaterialTheme.colorScheme.onPrimary,
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
CardBackView(width = cardWidth)
|
// A pack, not a single back: the stock is the one thing on
|
||||||
|
// this screen that is visibly a stack of card, and drawing
|
||||||
|
// it as one is most of what makes the table look real.
|
||||||
|
CardBackStack(count = state.stock.size, width = cardWidth)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
@@ -190,7 +194,9 @@ private fun PileArea(state: CrazyEightsState, cardWidth: Dp) {
|
|||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onPrimary,
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
)
|
)
|
||||||
state.topCard?.let { PlayingCardView(card = it, width = cardWidth) }
|
// The discard heap rather than only its top card, so a card
|
||||||
|
// played onto it lands on something.
|
||||||
|
CardPile(cards = state.discard, width = cardWidth)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// An eight on top says nothing about what may be played next, so the
|
// An eight on top says nothing about what may be played next, so the
|
||||||
@@ -331,6 +337,7 @@ private fun HandRow(
|
|||||||
enabled = canPlay,
|
enabled = canPlay,
|
||||||
selected = card == pendingWild,
|
selected = card == pendingWild,
|
||||||
caption = if (isWild(card)) "WILD" else null,
|
caption = if (isWild(card)) "WILD" else null,
|
||||||
|
modifier = Modifier.rotate(handTilt(cardSeed(card))),
|
||||||
onClick = { onTap(card) },
|
onClick = { onTap(card) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package org.prolibertate.games.ui.game
|
package org.prolibertate.games.ui.game
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.LinearEasing
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
@@ -25,6 +26,7 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.key
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
@@ -33,6 +35,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.draw.rotate
|
import androidx.compose.ui.draw.rotate
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.IntOffset
|
import androidx.compose.ui.unit.IntOffset
|
||||||
@@ -184,9 +187,6 @@ private fun seatLabel(relative: Int): String = when (relative) {
|
|||||||
/** How long a completed trick rests on the table before being swept away. */
|
/** How long a completed trick rests on the table before being swept away. */
|
||||||
const val TRICK_HOLD_MILLIS = 1_000L
|
const val TRICK_HOLD_MILLIS = 1_000L
|
||||||
|
|
||||||
/** Duration of the sweep itself. */
|
|
||||||
private const val TRICK_SWEEP_MILLIS = 450
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun TrickArea(
|
private fun TrickArea(
|
||||||
state: EuchreState,
|
state: EuchreState,
|
||||||
@@ -206,9 +206,12 @@ private fun TrickArea(
|
|||||||
sweepStarted = true
|
sweepStarted = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Driven linearly, because the shape of the sweep belongs to each card
|
||||||
|
// rather than to the clock: sweepProgress gives them their own start and
|
||||||
|
// their own acceleration off this one value.
|
||||||
val sweep by animateFloatAsState(
|
val sweep by animateFloatAsState(
|
||||||
targetValue = if (sweeping && sweepStarted) 1f else 0f,
|
targetValue = if (sweeping && sweepStarted) 1f else 0f,
|
||||||
animationSpec = tween(durationMillis = TRICK_SWEEP_MILLIS),
|
animationSpec = tween(durationMillis = motionMillis(CARD_SWEEP_MILLIS), easing = LinearEasing),
|
||||||
label = "trickSweep",
|
label = "trickSweep",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -220,7 +223,9 @@ private fun TrickArea(
|
|||||||
2 -> 0f to -1f
|
2 -> 0f to -1f
|
||||||
else -> 1f to 0f
|
else -> 1f to 0f
|
||||||
}
|
}
|
||||||
val travelPx = with(LocalDensity.current) { (cardWidth * 2.5f).toPx() }
|
val cardWidthPx = with(LocalDensity.current) { cardWidth.toPx() }
|
||||||
|
val travelPx = cardWidthPx * 2.5f
|
||||||
|
val throwMillis = motionMillis(CARD_THROW_MILLIS)
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize().padding(4.dp)) {
|
Box(modifier = Modifier.fillMaxSize().padding(4.dp)) {
|
||||||
|
|
||||||
@@ -249,7 +254,13 @@ private fun TrickArea(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keyed by seat: one card per seat per trick, so this holds a card's
|
||||||
|
// flight steady while the trick fills up and while the finished trick
|
||||||
|
// is swapped for the copy that gets swept away. Without it the list
|
||||||
|
// position would decide identity and every card would jump back into
|
||||||
|
// the air whenever another one landed.
|
||||||
cardsOnTable.forEach { played ->
|
cardsOnTable.forEach { played ->
|
||||||
|
key(played.seat) {
|
||||||
val relative = relativePosition(played.seat, localSeat)
|
val relative = relativePosition(played.seat, localSeat)
|
||||||
val alignment = when (relative) {
|
val alignment = when (relative) {
|
||||||
0 -> Alignment.BottomCenter
|
0 -> Alignment.BottomCenter
|
||||||
@@ -257,31 +268,53 @@ private fun TrickArea(
|
|||||||
2 -> Alignment.TopCenter
|
2 -> Alignment.TopCenter
|
||||||
else -> Alignment.CenterEnd
|
else -> Alignment.CenterEnd
|
||||||
}
|
}
|
||||||
// Rotate to face the player who played it: the side seats end up
|
// Turned to face the player who played it: the side seats end
|
||||||
// landscape, the partner's card upside down from your side.
|
// up landscape, the partner's card upside down from your side.
|
||||||
val rotation = when (relative) {
|
val facing = when (relative) {
|
||||||
1 -> 90f
|
1 -> 90f
|
||||||
2 -> 180f
|
2 -> 180f
|
||||||
3 -> -90f
|
3 -> -90f
|
||||||
else -> 0f
|
else -> 0f
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(alignment)
|
.align(alignment)
|
||||||
.offset {
|
.offset {
|
||||||
IntOffset(
|
IntOffset(
|
||||||
x = (dirX * travelPx * sweep).roundToInt(),
|
x = (landing.offsetX + dirX * travelPx * leaving).roundToInt(),
|
||||||
y = (dirY * travelPx * sweep).roundToInt(),
|
y = (landing.offsetY + dirY * travelPx * leaving).roundToInt(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.alpha(1f - sweep),
|
.graphicsLayer {
|
||||||
|
scaleX = landing.scale
|
||||||
|
scaleY = landing.scale
|
||||||
|
alpha = landing.alpha * (1f - leaving)
|
||||||
|
},
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
PlayingCardView(
|
PlayingCardView(
|
||||||
card = played.card,
|
card = played.card,
|
||||||
width = cardWidth,
|
width = cardWidth,
|
||||||
modifier = Modifier.rotate(rotation),
|
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(
|
||||||
text = seatLabel(relative),
|
text = seatLabel(relative),
|
||||||
@@ -290,6 +323,7 @@ private fun TrickArea(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Name the winner while their cards are still on the table.
|
// Name the winner while their cards are still on the table.
|
||||||
if (sweeping && state.lastTrickWinner != null) {
|
if (sweeping && state.lastTrickWinner != null) {
|
||||||
@@ -449,6 +483,11 @@ private fun HandRow(
|
|||||||
card = card,
|
card = card,
|
||||||
width = cardWidth,
|
width = cardWidth,
|
||||||
enabled = canPlay || canDiscard,
|
enabled = canPlay || canDiscard,
|
||||||
|
// A hand that has been squared up is still not square. Two
|
||||||
|
// degrees is below the threshold at which anyone notices a
|
||||||
|
// tilt and above the one at which a row of cards stops
|
||||||
|
// looking printed.
|
||||||
|
modifier = Modifier.rotate(handTilt(cardSeed(card))),
|
||||||
onClick = {
|
onClick = {
|
||||||
when {
|
when {
|
||||||
canDiscard -> onPlay(Discard(card))
|
canDiscard -> onPlay(Discard(card))
|
||||||
|
|||||||
@@ -252,7 +252,14 @@ private fun StockAndDiscard(
|
|||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
Text("Stock", style = MaterialTheme.typography.labelSmall,
|
Text("Stock", style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onPrimary)
|
color = MaterialTheme.colorScheme.onPrimary)
|
||||||
CardBackView(width = cardWidth)
|
// Drawing the pack rather than one back also means an empty stock
|
||||||
|
// now looks empty, where before it showed a card that was not there.
|
||||||
|
if (state.stock.isEmpty()) {
|
||||||
|
Text("empty", style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary)
|
||||||
|
} else {
|
||||||
|
CardBackStack(count = state.stock.size, width = cardWidth)
|
||||||
|
}
|
||||||
if (legal.contains(DrawFromStock)) {
|
if (legal.contains(DrawFromStock)) {
|
||||||
Button(onClick = { onMove(DrawFromStock) }) { Text("Draw") }
|
Button(onClick = { onMove(DrawFromStock) }) { Text("Draw") }
|
||||||
}
|
}
|
||||||
@@ -261,7 +268,7 @@ private fun StockAndDiscard(
|
|||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
Text("Discard", style = MaterialTheme.typography.labelSmall,
|
Text("Discard", style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onPrimary)
|
color = MaterialTheme.colorScheme.onPrimary)
|
||||||
state.discard.lastOrNull()?.let { PlayingCardView(card = it, width = cardWidth) }
|
CardPile(cards = state.discard, width = cardWidth)
|
||||||
if (legal.contains(DrawFromDiscard)) {
|
if (legal.contains(DrawFromDiscard)) {
|
||||||
Button(onClick = { onMove(DrawFromDiscard) }) { Text("Take") }
|
Button(onClick = { onMove(DrawFromDiscard) }) { Text("Take") }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import androidx.compose.material3.TextButton
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.key
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
@@ -25,6 +26,8 @@ import androidx.compose.runtime.snapshots.SnapshotStateList
|
|||||||
import androidx.compose.runtime.mutableStateListOf
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.rotate
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import org.prolibertate.games.game.cards.Card
|
import org.prolibertate.games.game.cards.Card
|
||||||
@@ -126,6 +129,7 @@ fun HeartsScreen(
|
|||||||
selected = chosen.contains(card),
|
selected = chosen.contains(card),
|
||||||
enabled = if (selectable) true else playable.contains(card),
|
enabled = if (selectable) true else playable.contains(card),
|
||||||
caption = pointsOf(card).takeIf { it > 0 }?.let { "−$it" },
|
caption = pointsOf(card).takeIf { it > 0 }?.let { "−$it" },
|
||||||
|
modifier = Modifier.rotate(handTilt(cardSeed(card))),
|
||||||
onClick = {
|
onClick = {
|
||||||
if (selectable) {
|
if (selectable) {
|
||||||
toggle(chosen, card)
|
toggle(chosen, card)
|
||||||
@@ -188,18 +192,40 @@ private fun TrickView(state: HeartsState) {
|
|||||||
if (showing.isEmpty()) {
|
if (showing.isEmpty()) {
|
||||||
Text("Nothing played yet.", style = MaterialTheme.typography.bodySmall)
|
Text("Nothing played yet.", style = MaterialTheme.typography.bodySmall)
|
||||||
} else {
|
} else {
|
||||||
|
// Hearts lays its trick out in a row rather than round a table, so
|
||||||
|
// there is no seat direction to throw a card in from — each one
|
||||||
|
// comes in from its own side, which the seed decides and keeps.
|
||||||
|
val width = 46.dp
|
||||||
|
val widthPx = with(LocalDensity.current) { width.toPx() }
|
||||||
|
val throwMillis = motionMillis(CARD_THROW_MILLIS)
|
||||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
showing.forEach { played ->
|
showing.forEach { played ->
|
||||||
|
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(
|
PlayingCardView(
|
||||||
card = played.card,
|
card = played.card,
|
||||||
width = 46.dp,
|
width = width,
|
||||||
caption = "seat ${played.seat}",
|
caption = "seat ${played.seat}",
|
||||||
|
elevation = landing.elevation,
|
||||||
|
modifier = Modifier.landed(landing),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ScoreRow(state: HeartsState, localSeat: Int) {
|
private fun ScoreRow(state: HeartsState, localSeat: Int) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package org.prolibertate.games.ui.game
|
package org.prolibertate.games.ui.game
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.LinearEasing
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
@@ -25,6 +26,7 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.key
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
@@ -32,6 +34,7 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.draw.rotate
|
import androidx.compose.ui.draw.rotate
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
@@ -181,9 +184,6 @@ private fun seatLabel(relative: Int): String = when (relative) {
|
|||||||
else -> "Right"
|
else -> "Right"
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Duration of the sweep towards the winner. */
|
|
||||||
private const val SWEEP_MILLIS = 450
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun TrickArea(
|
private fun TrickArea(
|
||||||
state: KaiserState,
|
state: KaiserState,
|
||||||
@@ -203,7 +203,7 @@ private fun TrickArea(
|
|||||||
}
|
}
|
||||||
val sweep by animateFloatAsState(
|
val sweep by animateFloatAsState(
|
||||||
targetValue = if (sweeping && sweepStarted) 1f else 0f,
|
targetValue = if (sweeping && sweepStarted) 1f else 0f,
|
||||||
animationSpec = tween(durationMillis = SWEEP_MILLIS),
|
animationSpec = tween(durationMillis = motionMillis(CARD_SWEEP_MILLIS), easing = LinearEasing),
|
||||||
label = "kaiserSweep",
|
label = "kaiserSweep",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -214,7 +214,9 @@ private fun TrickArea(
|
|||||||
2 -> 0f to -1f
|
2 -> 0f to -1f
|
||||||
else -> 1f to 0f
|
else -> 1f to 0f
|
||||||
}
|
}
|
||||||
val travelPx = with(LocalDensity.current) { (cardWidth * 2.5f).toPx() }
|
val cardWidthPx = with(LocalDensity.current) { cardWidth.toPx() }
|
||||||
|
val travelPx = cardWidthPx * 2.5f
|
||||||
|
val throwMillis = motionMillis(CARD_THROW_MILLIS)
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize().padding(4.dp)) {
|
Box(modifier = Modifier.fillMaxSize().padding(4.dp)) {
|
||||||
if (cardsOnTable.isEmpty()) {
|
if (cardsOnTable.isEmpty()) {
|
||||||
@@ -230,6 +232,7 @@ private fun TrickArea(
|
|||||||
}
|
}
|
||||||
|
|
||||||
cardsOnTable.forEach { played ->
|
cardsOnTable.forEach { played ->
|
||||||
|
key(played.seat) {
|
||||||
val relative = relativePosition(played.seat, localSeat)
|
val relative = relativePosition(played.seat, localSeat)
|
||||||
val alignment = when (relative) {
|
val alignment = when (relative) {
|
||||||
0 -> Alignment.BottomCenter
|
0 -> Alignment.BottomCenter
|
||||||
@@ -237,30 +240,49 @@ private fun TrickArea(
|
|||||||
2 -> Alignment.TopCenter
|
2 -> Alignment.TopCenter
|
||||||
else -> Alignment.CenterEnd
|
else -> Alignment.CenterEnd
|
||||||
}
|
}
|
||||||
val rotation = when (relative) {
|
val facing = when (relative) {
|
||||||
1 -> 90f
|
1 -> 90f
|
||||||
2 -> 180f
|
2 -> 180f
|
||||||
3 -> -90f
|
3 -> -90f
|
||||||
else -> 0f
|
else -> 0f
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(alignment)
|
.align(alignment)
|
||||||
.offset {
|
.offset {
|
||||||
IntOffset(
|
IntOffset(
|
||||||
x = (dirX * travelPx * sweep).roundToInt(),
|
x = (landing.offsetX + dirX * travelPx * leaving).roundToInt(),
|
||||||
y = (dirY * travelPx * sweep).roundToInt(),
|
y = (landing.offsetY + dirY * travelPx * leaving).roundToInt(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.alpha(1f - sweep),
|
.graphicsLayer {
|
||||||
|
scaleX = landing.scale
|
||||||
|
scaleY = landing.scale
|
||||||
|
alpha = landing.alpha * (1f - leaving)
|
||||||
|
},
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
PlayingCardView(
|
PlayingCardView(
|
||||||
card = played.card,
|
card = played.card,
|
||||||
width = cardWidth,
|
width = cardWidth,
|
||||||
caption = counterCaption(played.card),
|
caption = counterCaption(played.card),
|
||||||
modifier = Modifier.rotate(rotation),
|
elevation = landing.elevation,
|
||||||
|
modifier = Modifier
|
||||||
|
.rotate(landing.rotation + rest.spinDegrees * leaving),
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = seatLabel(relative),
|
text = seatLabel(relative),
|
||||||
@@ -269,6 +291,7 @@ private fun TrickArea(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (sweeping && state.lastTrickWinner != null) {
|
if (sweeping && state.lastTrickWinner != null) {
|
||||||
Text(
|
Text(
|
||||||
@@ -406,6 +429,7 @@ private fun HandRow(
|
|||||||
width = cardWidth,
|
width = cardWidth,
|
||||||
enabled = canPlay,
|
enabled = canPlay,
|
||||||
caption = counterCaption(card),
|
caption = counterCaption(card),
|
||||||
|
modifier = Modifier.rotate(handTilt(cardSeed(card))),
|
||||||
onClick = { if (canPlay) onPlay(PlayCard(card)) },
|
onClick = { if (canPlay) onPlay(PlayCard(card)) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,8 +20,11 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.key
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.rotate
|
||||||
|
import androidx.compose.ui.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
|
||||||
@@ -155,9 +158,36 @@ 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
|
||||||
|
// one card: they leave the hand together and land one after another,
|
||||||
|
// which is what the stagger off each card's own seed is for.
|
||||||
|
val onTop = state.pile.takeLast(state.setSize)
|
||||||
|
val topIndex = state.pile.size - onTop.size
|
||||||
|
val cardWidthPx = with(LocalDensity.current) { cardWidth.toPx() }
|
||||||
|
val throwMillis = motionMillis(CARD_THROW_MILLIS)
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
state.pile.takeLast(state.setSize).forEach { card ->
|
onTop.forEachIndexed { offsetInSet, card ->
|
||||||
PlayingCardView(card = card, width = cardWidth)
|
val place = topIndex + offsetInSet
|
||||||
|
key(place) {
|
||||||
|
val rest = cardRest(cardSeed(card, place))
|
||||||
|
val (fromX, fromY) = pileOrigin(cardSeed(card, place))
|
||||||
|
val landing = rememberCardLanding(
|
||||||
|
key = place,
|
||||||
|
rest = rest,
|
||||||
|
facingDegrees = 0f,
|
||||||
|
fromX = fromX * cardWidthPx,
|
||||||
|
fromY = fromY * cardWidthPx,
|
||||||
|
cardWidthPx = cardWidthPx,
|
||||||
|
durationMillis = throwMillis,
|
||||||
|
delayMillis = (rest.lateness * throwMillis * 0.3f).toInt(),
|
||||||
|
)
|
||||||
|
PlayingCardView(
|
||||||
|
card = card,
|
||||||
|
width = cardWidth,
|
||||||
|
elevation = landing.elevation,
|
||||||
|
modifier = Modifier.landed(landing),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
@@ -258,7 +288,11 @@ private fun HandView(state: PresidentState, localSeat: Int, cardWidth: Dp) {
|
|||||||
Text("—", style = MaterialTheme.typography.bodySmall)
|
Text("—", style = MaterialTheme.typography.bodySmall)
|
||||||
}
|
}
|
||||||
hand.forEach { card ->
|
hand.forEach { card ->
|
||||||
PlayingCardView(card = card, width = cardWidth * 0.8f)
|
PlayingCardView(
|
||||||
|
card = card,
|
||||||
|
width = cardWidth * 0.8f,
|
||||||
|
modifier = Modifier.rotate(handTilt(cardSeed(card))),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package org.prolibertate.games.ui.game
|
package org.prolibertate.games.ui.game
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.LinearEasing
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
@@ -26,6 +27,7 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.key
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
@@ -33,6 +35,7 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.draw.rotate
|
import androidx.compose.ui.draw.rotate
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
@@ -190,9 +193,6 @@ private fun BidTally(state: WizardState, localSeat: Int) {
|
|||||||
private fun seatLabel(seat: Int, localSeat: Int): String =
|
private fun seatLabel(seat: Int, localSeat: Int): String =
|
||||||
if (seat == localSeat) "You" else "Seat $seat"
|
if (seat == localSeat) "You" else "Seat $seat"
|
||||||
|
|
||||||
/** Duration of the sweep towards the winner. */
|
|
||||||
private const val SWEEP_MILLIS = 450
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun TrickArea(
|
private fun TrickArea(
|
||||||
state: WizardState,
|
state: WizardState,
|
||||||
@@ -213,7 +213,7 @@ private fun TrickArea(
|
|||||||
}
|
}
|
||||||
val sweep by animateFloatAsState(
|
val sweep by animateFloatAsState(
|
||||||
targetValue = if (sweeping && sweepStarted) 1f else 0f,
|
targetValue = if (sweeping && sweepStarted) 1f else 0f,
|
||||||
animationSpec = tween(durationMillis = SWEEP_MILLIS),
|
animationSpec = tween(durationMillis = motionMillis(CARD_SWEEP_MILLIS), easing = LinearEasing),
|
||||||
label = "wizardSweep",
|
label = "wizardSweep",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -226,7 +226,9 @@ private fun TrickArea(
|
|||||||
// Cards sit on a circle that fits inside whatever space the table has.
|
// Cards sit on a circle that fits inside whatever space the table has.
|
||||||
val radius = (minOf(maxWidth, maxHeight) / 2 - cardWidth * 0.7f).coerceAtLeast(0.dp)
|
val radius = (minOf(maxWidth, maxHeight) / 2 - cardWidth * 0.7f).coerceAtLeast(0.dp)
|
||||||
val radiusPx = with(LocalDensity.current) { radius.toPx() }
|
val radiusPx = with(LocalDensity.current) { radius.toPx() }
|
||||||
val travelPx = with(LocalDensity.current) { (cardWidth * 2f).toPx() }
|
val cardWidthPx = with(LocalDensity.current) { cardWidth.toPx() }
|
||||||
|
val travelPx = cardWidthPx * 2f
|
||||||
|
val throwMillis = motionMillis(CARD_THROW_MILLIS)
|
||||||
|
|
||||||
if (cardsOnTable.isEmpty()) {
|
if (cardsOnTable.isEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
@@ -241,31 +243,55 @@ private fun TrickArea(
|
|||||||
}
|
}
|
||||||
|
|
||||||
cardsOnTable.forEach { played ->
|
cardsOnTable.forEach { played ->
|
||||||
|
key(played.seat) {
|
||||||
val relative = ((played.seat - localSeat) % players + players) % players
|
val relative = ((played.seat - localSeat) % players + players) % players
|
||||||
val angle = 2.0 * PI * relative / players
|
val angle = 2.0 * PI * relative / players
|
||||||
// Relative seat 0 is you, at the bottom; the rest run clockwise.
|
// Relative seat 0 is you, at the bottom; the rest run clockwise.
|
||||||
val x = -sin(angle) * radiusPx
|
val x = -sin(angle) * radiusPx
|
||||||
val y = cos(angle) * radiusPx
|
val y = cos(angle) * radiusPx
|
||||||
// Turn each card to face whoever played it.
|
// Turn each card to face whoever played it.
|
||||||
val rotation = (relative * 360f / players)
|
val facing = (relative * 360f / players)
|
||||||
|
|
||||||
|
// 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(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.Center)
|
.align(Alignment.Center)
|
||||||
.offset {
|
.offset {
|
||||||
IntOffset(
|
IntOffset(
|
||||||
x = (x + -sin(winnerAngle) * travelPx * sweep).roundToInt(),
|
x = (x + landing.offsetX +
|
||||||
y = (y + cos(winnerAngle) * travelPx * sweep).roundToInt(),
|
-sin(winnerAngle) * travelPx * leaving).roundToInt(),
|
||||||
|
y = (y + landing.offsetY +
|
||||||
|
cos(winnerAngle) * travelPx * leaving).roundToInt(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.alpha(1f - sweep),
|
.graphicsLayer {
|
||||||
|
scaleX = landing.scale
|
||||||
|
scaleY = landing.scale
|
||||||
|
alpha = landing.alpha * (1f - leaving)
|
||||||
|
},
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
PlayingCardView(
|
PlayingCardView(
|
||||||
card = played.card,
|
card = played.card,
|
||||||
width = cardWidth,
|
width = cardWidth,
|
||||||
caption = specialCaption(played.card),
|
caption = specialCaption(played.card),
|
||||||
modifier = Modifier.rotate(rotation),
|
elevation = landing.elevation,
|
||||||
|
modifier = Modifier
|
||||||
|
.rotate(landing.rotation + rest.spinDegrees * leaving),
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = seatLabel(played.seat, localSeat),
|
text = seatLabel(played.seat, localSeat),
|
||||||
@@ -274,6 +300,7 @@ private fun TrickArea(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (sweeping && state.lastTrickWinner != null) {
|
if (sweeping && state.lastTrickWinner != null) {
|
||||||
Text(
|
Text(
|
||||||
@@ -395,6 +422,7 @@ private fun HandRow(
|
|||||||
width = cardWidth,
|
width = cardWidth,
|
||||||
enabled = canPlay,
|
enabled = canPlay,
|
||||||
caption = specialCaption(card),
|
caption = specialCaption(card),
|
||||||
|
modifier = Modifier.rotate(handTilt(cardSeed(card))),
|
||||||
onClick = { if (canPlay) onPlay(PlayCard(card)) },
|
onClick = { if (canPlay) onPlay(PlayCard(card)) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
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)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 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
|
# Only the series is set by hand. The number after it is the CI run number, so
|
||||||
# it keeps climbing on its own and an APK's versionCode never goes backwards
|
# it keeps climbing on its own and an APK's versionCode never goes backwards
|
||||||
# when this changes.
|
# when this changes.
|
||||||
plgVersionSeries=1.2
|
plgVersionSeries=1.3
|
||||||
|
|||||||
Reference in New Issue
Block a user