Seat every player on the felt rail

Bruno and Dex floated on the felt interior while Ada, Cleo and Enzo straddled
the rail, so the row looked inconsistent. The cause was that vertical lanes were
hand-picked constants (0.30 / 0.15 / 0 / 0.15 / 0.30) with no relationship to
the oval they were meant to sit on — any value would put some seats on the edge
and others adrift, which is why tuning them kept trading one complaint for
another.

Seat placement now solves the felt ellipse for each seat's own horizontal
position, and reads the same FELT_LEFT/RIGHT/TOP/BOTTOM constants the felt is
drawn from. The rail and the seats can no longer disagree, and a test asserts
every seat lands on the rail at every supported width rather than merely
checking that lanes are ordered.

At 384dp the solved fractions are 0.247 / 0.095 / 0.055 / 0.095 / 0.247 —
a real curve rather than three seats abreast.

Seat gap widened from 6dp to 10dp so the name plates have breathing room now
that the shoulder seats sit nearer the crown.

Verified on Galaxy S24+ geometry: nothing clipped at either edge.

261 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:26:17 -04:00
parent a11daddf6c
commit 835d26cc7e
2 changed files with 70 additions and 30 deletions
@@ -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<SeatPlacement> {
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(
@@ -60,31 +60,42 @@ 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
fun `spacing is even so the row reads as a deliberate arrangement`() {