Step seats evenly around the arc and centre the pot among them

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) <noreply@anthropic.com>
This commit is contained in:
Jay
2026-07-27 22:34:59 -04:00
parent 835d26cc7e
commit de0b96279d
2 changed files with 76 additions and 69 deletions
@@ -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<SeatPlacement> {
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)),
@@ -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