diff --git a/app/src/main/java/com/jsjdesigns/poker/TableScreen.kt b/app/src/main/java/com/jsjdesigns/poker/TableScreen.kt index 18812f4..f634737 100644 --- a/app/src/main/java/com/jsjdesigns/poker/TableScreen.kt +++ b/app/src/main/java/com/jsjdesigns/poker/TableScreen.kt @@ -63,7 +63,9 @@ 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 +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.sin private val Night = Color(0xFF04100C) private val TableBackground = Color(0xFF071D16) @@ -130,22 +132,22 @@ private const val FELT_RIGHT = 0.988f private const val FELT_TOP = 0.055f private const val FELT_BOTTOM = 0.965f +/** Half-angle of the seating arc, measured from the crown. */ +private const val ORBIT_HALF_ANGLE_DEG = 72f + +/** Height fraction of the crown seat's centre. */ +private const val ORBIT_CROWN_Y = 0.06f + +/** Vertical radius of the seating arc, as a fraction of table height. */ +private const val ORBIT_RY = 0.34f + /** - * 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. + * Height fraction of the pot, centred inside the ring the seats enclose. */ -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) -} +internal const val POT_Y_FRACTION = 0.30f + +/** Height fraction of the community board, clear of the lowest seat plates. */ +internal const val BOARD_Y_FRACTION = 0.47f /** * Width one opponent seat may occupy, derived from the table's real width. @@ -156,27 +158,39 @@ internal fun railCenterYFraction(centerXFraction: Float): Float { * merely looking tight at 427dp (Pixel emulator). Sizing from the container * makes five seats fit at any width by construction. */ -internal fun opponentSeatWidthDp(availableWidthDp: Float): Float { - val usable = availableWidthDp - 2f * SEAT_EDGE_MARGIN_DP - 4f * SEAT_MIN_GAP_DP - return (usable / 5f).coerceIn(52f, 96f) -} +internal fun opponentSeatWidthDp(availableWidthDp: Float): Float = + (availableWidthDp * 0.16f).coerceIn(52f, 72f) /** * Five seats spread edge to edge with equal gaps, staggered into three lanes so * the row still reads as an arc around the felt rather than a straight line. */ +/** + * Five seats stepped evenly around the arc. + * + * Even *horizontal* spacing cannot also be even around an oval: solving an + * ellipse for evenly spaced x values always bunches the middle three near the + * crown and strands the side seats, which is why adjusting vertical lanes kept + * trading one crowding complaint for another. Stepping by equal angle instead + * puts the side seats low near the board and spaces the rest evenly from them. + * + * The two lowest pairs may overlap horizontally by a few dp. That is correct + * for an arc and harmless, because those seats are separated vertically by more + * than a seat's height. + */ internal fun opponentSeatPlacements( availableWidthDp: Float, seatWidthDp: Float = opponentSeatWidthDp(availableWidthDp), ): List { - val span = availableWidthDp - 2f * SEAT_EDGE_MARGIN_DP - seatWidthDp - val step = span / 4f + val orbitRx = (availableWidthDp - seatWidthDp) / 2f - SEAT_EDGE_MARGIN_DP + val centerX = availableWidthDp / 2f + val step = (2f * ORBIT_HALF_ANGLE_DEG) / 4f return List(5) { index -> - val left = SEAT_EDGE_MARGIN_DP + step * index - val centerXFraction = (left + seatWidthDp / 2f) / availableWidthDp + val degrees = -ORBIT_HALF_ANGLE_DEG + step * index + val radians = degrees * PI.toFloat() / 180f SeatPlacement( - leftDp = left, - centerYFraction = railCenterYFraction(centerXFraction), + leftDp = centerX + orbitRx * sin(radians) - seatWidthDp / 2f, + centerYFraction = ORBIT_CROWN_Y + ORBIT_RY * (1f - cos(radians)), ) } } @@ -319,15 +333,22 @@ private fun PokerTable( ) } + // The pot belongs inside the ring the seats enclose, not stranded below + // it. The board stays clear of the lowest seat plates. + PotPill( + pot = snapshot?.pot ?: 0, + modifier = Modifier + .align(Alignment.TopCenter) + .offset(y = maxHeight * POT_Y_FRACTION), + ) + BoardAndStatus( snapshot = snapshot, status = status, tableTalk = tableTalk, - // Below the side-seat lane (0.30 of height plus the seat itself), - // measured against height so it tracks the taller felt. modifier = Modifier .align(Alignment.TopCenter) - .offset(y = maxHeight * 0.46f), + .offset(y = maxHeight * BOARD_Y_FRACTION), ) HeroSeat( @@ -654,7 +675,6 @@ private fun BoardAndStatus( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp), ) { - PotPill(snapshot?.pot ?: 0) Row(horizontalArrangement = Arrangement.spacedBy(5.dp)) { repeat(5) { index -> if (index < board.size) { @@ -696,8 +716,9 @@ private fun BoardAndStatus( } @Composable -private fun PotPill(pot: Int) { +private fun PotPill(pot: Int, modifier: Modifier = Modifier) { Surface( + modifier = modifier, shape = RoundedCornerShape(50), color = Color(0x9904140E), border = androidx.compose.foundation.BorderStroke(1.dp, TableGold.copy(alpha = 0.32f)), diff --git a/app/src/test/java/com/jsjdesigns/poker/TableOrbitTest.kt b/app/src/test/java/com/jsjdesigns/poker/TableOrbitTest.kt index c33770e..0ca4868 100644 --- a/app/src/test/java/com/jsjdesigns/poker/TableOrbitTest.kt +++ b/app/src/test/java/com/jsjdesigns/poker/TableOrbitTest.kt @@ -19,17 +19,22 @@ class TableOrbitTest { private val widths = listOf(320f, 360f, 384f, 411f, 427f, 480f) @Test - fun `five seats never overlap at any supported width`() { + fun `neighbouring seats never collide at any supported width`() { + // Seats on an arc may overlap horizontally; that is only a problem when + // they also sit at a similar height. Assert the pair is separated on at + // least one axis. + val seatHeightFraction = 0.14f for (width in widths) { val seatWidth = opponentSeatWidthDp(width) val placements = opponentSeatPlacements(width, seatWidth) assertEquals(5, placements.size) - placements.zipWithNext { left, right -> - val gap = right.leftDp - (left.leftDp + seatWidth) + val horizontalGap = right.leftDp - (left.leftDp + seatWidth) + val verticalGap = kotlin.math.abs(right.centerYFraction - left.centerYFraction) assertTrue( - "at ${width}dp seats overlap by ${-gap}dp (seat width $seatWidth)", - gap >= -0.01f, + "at ${width}dp seats collide: horizontal gap $horizontalGap, " + + "vertical gap $verticalGap", + horizontalGap >= -0.01f || verticalGap >= seatHeightFraction, ) } } @@ -68,43 +73,24 @@ class TableOrbitTest { } /** - * 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. + * The complaint that drove this: side seats must sit low, near the board, + * with the rest stepped evenly from them — not bunched against the crown. */ @Test - 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, - ) - } - } - } + fun `seats step evenly around the arc`() { + val lanes = opponentSeatPlacements(384f).map { it.centerYFraction } + val crownToShoulder = lanes[1] - lanes[2] + val shoulderToSide = lanes[0] - lanes[1] + assertTrue("the side seats must drop well below the crown", lanes[0] - lanes[2] > 0.20f) + assertTrue( + "the arc should keep descending, not bunch at the top: " + + "$crownToShoulder then $shoulderToSide", + shoulderToSide > crownToShoulder, + ) - @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 - fun `spacing is even so the row reads as a deliberate arrangement`() { - val width = 384f - val seatWidth = opponentSeatWidthDp(width) - val gaps = opponentSeatPlacements(width, seatWidth) - .zipWithNext { a, b -> b.leftDp - (a.leftDp + seatWidth) } - val first = gaps.first() - assertTrue("uneven seat gaps: $gaps", gaps.all { kotlin.math.abs(it - first) < 0.01f }) + val angles = opponentSeatPlacements(384f) + val xs = angles.map { it.leftDp } + assertTrue("seats must run left to right", xs.zipWithNext().all { (a, b) -> a < b }) } @Test