Model card play as cards landing on a table
Cards appeared and disappeared. A card was drawn at its resting place the frame it was played, and a finished trick slid off in a straight line at a constant speed with every card starting and stopping together. Nothing about that reads as a hand putting a card down. What a real card does is arrive from the side of the person who threw it, carry past the spot it was aimed at and rock back, and finish a little crooked and a little off the mark. That is the whole model. There are no collisions and no ragdolls, because the difference between cards on a table and cards thrown down a stairwell is entirely the size of the numbers: the tilt is capped at four and a half degrees and the drift at five per cent of a card width, and both are smaller than they want to be. The flight is one underdamped spring on a single 0-to-1 value, with position, angle and height above the table all read off it. The overshoot is the spring passing 1 and returning, so the card carries and settles as one motion rather than as three animations that happen to start together. Driving them apart was tried and looked like it. The scatter is hashed out of the card's identity rather than drawn from a generator. A generator would give a card a new angle on every recomposition, which twitches; hashing the list position instead would make a card jump whenever another landed beside it. Hashed this way a card lies the same way for as long as it lies there, and no two cards in a deck lie the same way as each other. The pure half of that — the hash, the bounds, the sweep stagger — is unit tested, since it is not something a screenshot would show. The sweep now staggers and accelerates: cards break away over the first third of it rather than in lockstep, each carrying out the same turn it carried in. Piles are drawn as the last few cards of the heap and stocks as a pack with depth, so a card played onto a discard pile lands on something. Every card gains the shadow of a card lying on felt. Applied to Euchre, Kaiser, Wizard and Hearts at the trick, and to Crazy 8s, President and Golf at the pile; hands get a tilt small enough that nobody sees it and large enough that a row of cards stops looking printed. Motion follows the existing animation-speed setting through a composition local, so the slider still governs all of it. Series bumped to 1.3 for the dev channel. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_0132V9gE6DF9itz2z3Hn7BK9
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user