From de0b96279d780ebac88ccfe8d408a8a6de7153c7 Mon Sep 17 00:00:00 2001 From: Jay Date: Mon, 27 Jul 2026 22:34:59 -0400 Subject: [PATCH] Step seats evenly around the arc and centre the pot among them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous attempts kept trading one crowding complaint for the other because they all assumed evenly spaced x. That cannot also be evenly spaced around an oval: solving an ellipse for evenly spaced x values always bunches the middle three near the crown and strands the side seats, so every vertical lane value fixed one end and broke the other. Seats now step by equal ANGLE around the arc. The side seats fall to 0.295 of table height — level with the board rather than pinned to the top — and the rest space evenly from them. Neighbouring seats may now overlap horizontally by a few dp at the shallow ends of the arc. That is correct for an arc and harmless, because those pairs are separated vertically by more than a seat's height. The test asserts separation on at least one axis rather than demanding a horizontal gap, which is what forced the edge-to-edge row in the first place. The pot moves into the ring the seats enclose (0.30 of height) instead of sitting stranded below it. The board stays where it was, clear of the lowest seat plates. Verified on Galaxy S24+ geometry: nothing clipped at either edge. 259 tests, 0 failures. Lint 0 errors. Co-Authored-By: Claude Opus 5 (1M context) --- .../java/com/jsjdesigns/poker/TableScreen.kt | 81 ++++++++++++------- .../com/jsjdesigns/poker/TableOrbitTest.kt | 64 ++++++--------- 2 files changed, 76 insertions(+), 69 deletions(-) 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