diff --git a/app/src/main/java/com/jsjdesigns/poker/TableScreen.kt b/app/src/main/java/com/jsjdesigns/poker/TableScreen.kt index 05601f3..18812f4 100644 --- a/app/src/main/java/com/jsjdesigns/poker/TableScreen.kt +++ b/app/src/main/java/com/jsjdesigns/poker/TableScreen.kt @@ -63,6 +63,7 @@ import com.jsjdesigns.poker.game.DecisionOffer import com.jsjdesigns.poker.game.SeatSnapshot import com.jsjdesigns.poker.game.TableSnapshot import kotlin.math.roundToInt +import kotlin.math.sqrt private val Night = Color(0xFF04100C) private val TableBackground = Color(0xFF071D16) @@ -111,15 +112,40 @@ internal fun visibleChipCount(amount: Int): Int = when { internal data class SeatPlacement( /** Distance in dp from the table's left edge to this seat's left edge. */ val leftDp: Float, - /** Vertical lane as a fraction of table height. */ - val topFraction: Float, + /** Where this seat's avatar centre belongs, as a fraction of table height. */ + val centerYFraction: Float, ) /** Space kept clear at the table's left and right edges. */ private const val SEAT_EDGE_MARGIN_DP = 6f /** Smallest horizontal gap permitted between two neighbouring seats. */ -private const val SEAT_MIN_GAP_DP = 6f +private const val SEAT_MIN_GAP_DP = 10f + +// The felt oval, as fractions of the table box. Seat placement reads the same +// constants the felt is drawn from, so seats cannot drift off the rail when the +// table shape changes — the two were previously unrelated numbers. +private const val FELT_LEFT = 0.012f +private const val FELT_RIGHT = 0.988f +private const val FELT_TOP = 0.055f +private const val FELT_BOTTOM = 0.965f + +/** + * Height fraction where the felt's upper rail sits at a given horizontal + * position. + * + * Seats were previously assigned hand-picked vertical lanes, which left the + * shoulder seats floating on the felt interior while the crown and side seats + * straddled the rail. Solving the ellipse puts every seat on the same edge. + */ +internal fun railCenterYFraction(centerXFraction: Float): Float { + val cx = (FELT_LEFT + FELT_RIGHT) / 2f + val rx = (FELT_RIGHT - FELT_LEFT) / 2f + val cy = (FELT_TOP + FELT_BOTTOM) / 2f + val ry = (FELT_BOTTOM - FELT_TOP) / 2f + val u = ((centerXFraction - cx) / rx).coerceIn(-1f, 1f) + return cy - ry * sqrt(1f - u * u) +} /** * Width one opponent seat may occupy, derived from the table's real width. @@ -143,13 +169,14 @@ internal fun opponentSeatPlacements( availableWidthDp: Float, seatWidthDp: Float = opponentSeatWidthDp(availableWidthDp), ): List { - val lanes = listOf(0.30f, 0.15f, 0f, 0.15f, 0.30f) val span = availableWidthDp - 2f * SEAT_EDGE_MARGIN_DP - seatWidthDp val step = span / 4f return List(5) { index -> + val left = SEAT_EDGE_MARGIN_DP + step * index + val centerXFraction = (left + seatWidthDp / 2f) / availableWidthDp SeatPlacement( - leftDp = SEAT_EDGE_MARGIN_DP + step * index, - topFraction = lanes[index], + leftDp = left, + centerYFraction = railCenterYFraction(centerXFraction), ) } } @@ -284,7 +311,10 @@ private fun PokerTable( width = seatWidth.dp, modifier = Modifier.offset( x = placement.leftDp.dp, - y = maxHeight * placement.topFraction, + // Placement gives where the avatar centre belongs on the + // rail; the seat's top is half an avatar above it. + y = maxHeight * placement.centerYFraction - + (seatWidth.dp * 0.58f).coerceIn(34.dp, 48.dp) / 2, ), ) } @@ -329,12 +359,11 @@ private fun TableFelt(modifier: Modifier = Modifier) { // An oval sized to the container, so the felt grows into whatever height // the table is given rather than staying a width-sized circle. val inset = 9.dp.toPx() - val railTop = size.height * 0.055f val railRect = androidx.compose.ui.geometry.Rect( - left = size.width * 0.012f, - top = railTop, - right = size.width * 0.988f, - bottom = size.height * 0.965f, + left = size.width * FELT_LEFT, + top = size.height * FELT_TOP, + right = size.width * FELT_RIGHT, + bottom = size.height * FELT_BOTTOM, ) drawOval( brush = Brush.linearGradient( diff --git a/app/src/test/java/com/jsjdesigns/poker/TableOrbitTest.kt b/app/src/test/java/com/jsjdesigns/poker/TableOrbitTest.kt index ac0aca5..c33770e 100644 --- a/app/src/test/java/com/jsjdesigns/poker/TableOrbitTest.kt +++ b/app/src/test/java/com/jsjdesigns/poker/TableOrbitTest.kt @@ -60,30 +60,41 @@ class TableOrbitTest { @Test fun `seats are staggered into an arc rather than one straight row`() { - val lanes = opponentSeatPlacements(384f).map { it.topFraction } + val lanes = opponentSeatPlacements(384f).map { it.centerYFraction } assertTrue("outer seats should sit lower than the crown", lanes[0] > lanes[2]) - assertTrue("the arc should be symmetric", lanes[0] == lanes[4] && lanes[1] == lanes[3]) - assertEquals("the middle seat crowns the arc", 0f, lanes[2], 0.001f) + assertEquals("the arc should be symmetric", lanes[0], lanes[4], 0.0005f) + assertEquals("the arc should be symmetric", lanes[1], lanes[3], 0.0005f) + assertTrue("the middle seat crowns the arc", lanes[2] < lanes[1]) } /** - * The shoulder seats sat at 0.06 against a crown of 0.0, so three seats read - * as one crowded row instead of a curve. They belong between the crown and - * the side seats. + * Every seat must land on the felt's edge. Hand-picked lanes left the + * shoulder seats floating on the felt interior while the crown and side + * seats straddled the rail, so the row looked inconsistent. */ @Test - fun `shoulder seats sit between the crown and the side seats`() { - val lanes = opponentSeatPlacements(384f).map { it.topFraction } - val crown = lanes[2] - val side = lanes[0] - val shoulder = lanes[1] - assertTrue("shoulder must be below the crown", shoulder > crown) - assertTrue("shoulder must be above the side seats", shoulder < side) - val midpoint = (crown + side) / 2f - assertTrue( - "shoulder $shoulder should be near the midpoint $midpoint, not hugging the crown", - kotlin.math.abs(shoulder - midpoint) <= 0.05f, - ) + fun `every seat sits on the felt rail at its own horizontal position`() { + for (width in widths) { + val seatWidth = opponentSeatWidthDp(width) + opponentSeatPlacements(width, seatWidth).forEach { placement -> + val centerX = (placement.leftDp + seatWidth / 2f) / width + assertEquals( + "at ${width}dp a seat drifted off the rail", + railCenterYFraction(centerX), + placement.centerYFraction, + 0.0001f, + ) + } + } + } + + @Test + fun `the rail solution is a real ellipse, not a flat line`() { + val crown = railCenterYFraction(0.5f) + val shoulder = railCenterYFraction(0.30f) + val side = railCenterYFraction(0.06f) + assertTrue("the crown is the highest point", crown < shoulder) + assertTrue("the rail keeps dropping toward the sides", shoulder < side) } @Test