Add a switchable table shape alongside the seat style

A second header chip cycles the felt shape, so table and seat treatments can be
compared together on a device rather than argued about from screenshots.

- Oval: ellipse fitted to the container. Seats step by equal angle, because even
  horizontal spacing on a deep ellipse always bunches the middle three.
- Arena: a circle wider than the screen. The left and right lobes fall outside
  and are clipped, trading side space nobody uses for a broad, shallow top arc.
  The arc drop from crown to outermost seat falls from roughly 143dp to 70dp at
  384dp wide, so seats spread across the full width instead of stacking.
- Stadium: racetrack. Flat across the middle, curving only at the ends.

Shape and seat arc are one decision, not two: seats are placed on whichever
curve the felt is drawn from, so they cannot drift off it the way hand-picked
lanes did. On the shallow shapes seats space evenly by x, which is exactly where
that works — the vertical differences are small enough to read as a gentle curve.

Tests cover all three shapes at every supported width for overflow and
collision, and assert arena flattens the arc relative to oval.

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-28 07:34:27 -04:00
parent ba2675d6ff
commit e46712c4c7
2 changed files with 260 additions and 52 deletions
@@ -65,8 +65,11 @@ import com.jsjdesigns.poker.game.SeatSnapshot
import com.jsjdesigns.poker.game.TableSnapshot import com.jsjdesigns.poker.game.TableSnapshot
import kotlin.math.roundToInt import kotlin.math.roundToInt
import kotlin.math.PI import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.cos import kotlin.math.cos
import kotlin.math.max
import kotlin.math.sin import kotlin.math.sin
import kotlin.math.sqrt
private val Night = Color(0xFF04100C) private val Night = Color(0xFF04100C)
private val TableBackground = Color(0xFF071D16) private val TableBackground = Color(0xFF071D16)
@@ -142,6 +145,35 @@ private const val ORBIT_CROWN_Y = 0.06f
/** Vertical radius of the seating arc, as a fraction of table height. */ /** Vertical radius of the seating arc, as a fraction of table height. */
private const val ORBIT_RY = 0.34f private const val ORBIT_RY = 0.34f
/** Arena circle radius, as a multiple of table width. */
private const val ARENA_RADIUS_W = 0.78f
/** Corner radius of the stadium's rounded ends, as a fraction of table width. */
private const val STADIUM_CORNER_W = 0.30f
/**
* Competing felt shapes, switchable at runtime alongside [SeatStyle].
*
* The shape and the seat arc are one decision, not two: seats are placed on
* whichever curve the felt is drawn from, so they cannot drift off it.
*/
enum class TableStyle(val label: String) {
/** Ellipse fitted to the container. Deepest arc, narrowest crown. */
OVAL("Oval"),
/**
* A circle wider than the screen, so the left and right extremes fall
* outside and only the broad top arc is used. Trades the unused side lobes
* for a wider, shallower run of seats.
*/
ARENA("Arena"),
/** Racetrack: flat across the middle, curving only at the ends. */
STADIUM("Stadium");
fun next(): TableStyle = entries[(ordinal + 1) % entries.size]
}
/** /**
* Height fraction of the pot, centred inside the ring the seats enclose. * Height fraction of the pot, centred inside the ring the seats enclose.
*/ */
@@ -204,14 +236,35 @@ internal fun opponentSeatWidthDp(
*/ */
internal fun opponentSeatPlacements( internal fun opponentSeatPlacements(
availableWidthDp: Float, availableWidthDp: Float,
availableHeightDp: Float,
seatWidthDp: Float = opponentSeatWidthDp(availableWidthDp), seatWidthDp: Float = opponentSeatWidthDp(availableWidthDp),
): List<SeatPlacement> { tableStyle: TableStyle = TableStyle.OVAL,
): List<SeatPlacement> = when (tableStyle) {
TableStyle.OVAL -> ovalPlacements(availableWidthDp, seatWidthDp)
TableStyle.ARENA -> arcPlacements(availableWidthDp, availableHeightDp, seatWidthDp) { dx, radius ->
radius - sqrt(max(0f, radius * radius - dx * dx))
}
TableStyle.STADIUM -> arcPlacements(availableWidthDp, availableHeightDp, seatWidthDp) { dx, _ ->
val corner = STADIUM_CORNER_W * availableWidthDp
val flatHalf = availableWidthDp / 2f - corner
val over = abs(dx) - flatHalf
if (over <= 0f) 0f else corner - sqrt(max(0f, corner * corner - over * over))
}
}
/**
* Equal-angle steps around an ellipse.
*
* Even horizontal spacing cannot also be even around an oval, so this steps by
* angle instead; the side seats fall to board level and the rest space evenly
* from them.
*/
private fun ovalPlacements(availableWidthDp: Float, seatWidthDp: Float): List<SeatPlacement> {
val orbitRx = (availableWidthDp - seatWidthDp) / 2f - SEAT_EDGE_MARGIN_DP val orbitRx = (availableWidthDp - seatWidthDp) / 2f - SEAT_EDGE_MARGIN_DP
val centerX = availableWidthDp / 2f val centerX = availableWidthDp / 2f
val step = (2f * ORBIT_HALF_ANGLE_DEG) / 4f val step = (2f * ORBIT_HALF_ANGLE_DEG) / 4f
return List(5) { index -> return List(5) { index ->
val degrees = -ORBIT_HALF_ANGLE_DEG + step * index val radians = (-ORBIT_HALF_ANGLE_DEG + step * index) * PI.toFloat() / 180f
val radians = degrees * PI.toFloat() / 180f
SeatPlacement( SeatPlacement(
leftDp = centerX + orbitRx * sin(radians) - seatWidthDp / 2f, leftDp = centerX + orbitRx * sin(radians) - seatWidthDp / 2f,
centerYFraction = ORBIT_CROWN_Y + ORBIT_RY * (1f - cos(radians)), centerYFraction = ORBIT_CROWN_Y + ORBIT_RY * (1f - cos(radians)),
@@ -219,6 +272,34 @@ internal fun opponentSeatPlacements(
} }
} }
/**
* Evenly spaced across the width, with height following the felt's own curve.
*
* A shallow curve is exactly where even horizontal spacing works: the vertical
* differences are small, so seats read as a gentle arc rather than bunching the
* way they do on a deep ellipse.
*/
private fun arcPlacements(
availableWidthDp: Float,
availableHeightDp: Float,
seatWidthDp: Float,
dropAt: (dx: Float, radius: Float) -> Float,
): List<SeatPlacement> {
val radius = ARENA_RADIUS_W * availableWidthDp
val centerX = availableWidthDp / 2f
val span = availableWidthDp - 2f * SEAT_EDGE_MARGIN_DP - seatWidthDp
val step = span / 4f
val crownDp = ORBIT_CROWN_Y * availableHeightDp
return List(5) { index ->
val left = SEAT_EDGE_MARGIN_DP + step * index
val dx = left + seatWidthDp / 2f - centerX
SeatPlacement(
leftDp = left,
centerYFraction = (crownDp + dropAt(dx, radius)) / availableHeightDp,
)
}
}
@Composable @Composable
fun TableScreen(vm: PokerViewModel) { fun TableScreen(vm: PokerViewModel) {
val state by vm.state.collectAsStateWithLifecycle() val state by vm.state.collectAsStateWithLifecycle()
@@ -235,6 +316,7 @@ fun TableScreen(vm: PokerViewModel) {
// visibly reshaped the oval into a circle every time Raise was tapped. // visibly reshaped the oval into a circle every time Raise was tapped.
var sizingRaise by remember(offer?.token) { mutableStateOf(false) } var sizingRaise by remember(offer?.token) { mutableStateOf(false) }
var seatStyle by remember { mutableStateOf(SeatStyle.PLATE) } var seatStyle by remember { mutableStateOf(SeatStyle.PLATE) }
var tableStyle by remember { mutableStateOf(TableStyle.OVAL) }
Box( Box(
modifier = Modifier modifier = Modifier
@@ -247,7 +329,9 @@ fun TableScreen(vm: PokerViewModel) {
snapshot = snap, snapshot = snap,
config = state.gameConfig, config = state.gameConfig,
seatStyle = seatStyle, seatStyle = seatStyle,
tableStyle = tableStyle,
onCycleSeatStyle = { seatStyle = seatStyle.next() }, onCycleSeatStyle = { seatStyle = seatStyle.next() },
onCycleTableStyle = { tableStyle = tableStyle.next() },
) )
PokerTable( PokerTable(
snapshot = snap, snapshot = snap,
@@ -255,6 +339,7 @@ fun TableScreen(vm: PokerViewModel) {
tableTalk = tableTalk, tableTalk = tableTalk,
handSummary = handSummary, handSummary = handSummary,
seatStyle = seatStyle, seatStyle = seatStyle,
tableStyle = tableStyle,
// Weighted against a control area whose height no longer changes, // Weighted against a control area whose height no longer changes,
// so the felt keeps one shape for the whole hand. // so the felt keeps one shape for the whole hand.
modifier = Modifier modifier = Modifier
@@ -295,7 +380,9 @@ private fun TableHeader(
snapshot: TableSnapshot?, snapshot: TableSnapshot?,
config: CashGameConfig?, config: CashGameConfig?,
seatStyle: SeatStyle, seatStyle: SeatStyle,
tableStyle: TableStyle,
onCycleSeatStyle: () -> Unit, onCycleSeatStyle: () -> Unit,
onCycleTableStyle: () -> Unit,
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
@@ -335,12 +422,32 @@ private fun TableHeader(
), ),
) { ) {
Text( Text(
"SEATS · ${seatStyle.label.uppercase()}", seatStyle.label.uppercase(),
modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp), modifier = Modifier.padding(horizontal = 9.dp, vertical = 5.dp),
color = TableGold, color = TableGold,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
fontSize = 10.sp, fontSize = 10.sp,
letterSpacing = 1.sp, letterSpacing = 0.8.sp,
)
}
Surface(
modifier = Modifier
.padding(end = 8.dp)
.clickable(onClick = onCycleTableStyle),
shape = RoundedCornerShape(50),
color = Color.White.copy(alpha = 0.10f),
border = androidx.compose.foundation.BorderStroke(
1.dp,
FeltLight.copy(alpha = 0.75f),
),
) {
Text(
tableStyle.label.uppercase(),
modifier = Modifier.padding(horizontal = 9.dp, vertical = 5.dp),
color = FeltLight,
fontWeight = FontWeight.Bold,
fontSize = 10.sp,
letterSpacing = 0.8.sp,
) )
} }
Surface( Surface(
@@ -373,6 +480,7 @@ private fun PokerTable(
tableTalk: com.jsjdesigns.poker.bot.TableTalkLine?, tableTalk: com.jsjdesigns.poker.bot.TableTalkLine?,
handSummary: HandSummary?, handSummary: HandSummary?,
seatStyle: SeatStyle, seatStyle: SeatStyle,
tableStyle: TableStyle,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val seats = snapshot?.seats.orEmpty() val seats = snapshot?.seats.orEmpty()
@@ -385,12 +493,17 @@ private fun PokerTable(
modifier = modifier modifier = modifier
.background(TableBackground), .background(TableBackground),
) { ) {
TableFelt(Modifier.fillMaxSize()) TableFelt(tableStyle, Modifier.fillMaxSize())
// Seat width and position both come from the measured table, so five // Seat width and position both come from the measured table, so five
// seats fit with real gaps on a 384dp phone as well as a 427dp emulator. // seats fit with real gaps on a 384dp phone as well as a 427dp emulator.
val seatWidth = opponentSeatWidthDp(maxWidth.value, seatStyle) val seatWidth = opponentSeatWidthDp(maxWidth.value, seatStyle)
val placements = opponentSeatPlacements(maxWidth.value, seatWidth) val placements = opponentSeatPlacements(
availableWidthDp = maxWidth.value,
availableHeightDp = maxHeight.value,
seatWidthDp = seatWidth,
tableStyle = tableStyle,
)
seats.filter { it.index != HERO_SEAT } seats.filter { it.index != HERO_SEAT }
.forEachIndexed { index, seat -> .forEachIndexed { index, seat ->
val placement = placements.getOrElse(index) { placements.last() } val placement = placements.getOrElse(index) { placements.last() }
@@ -452,51 +565,92 @@ private fun PokerTable(
} }
@Composable @Composable
private fun TableFelt(modifier: Modifier = Modifier) { private fun TableFelt(style: TableStyle, modifier: Modifier = Modifier) {
Canvas(modifier) { Canvas(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 inset = 9.dp.toPx()
val railRect = androidx.compose.ui.geometry.Rect( val railBrush = Brush.linearGradient(
colors = listOf(RailLight, RailDark),
start = Offset(0f, 0f),
end = Offset(size.width, size.height),
)
fun feltBrush(width: Float, height: Float, topLeft: Offset) = Brush.radialGradient(
colors = listOf(FeltLight, FeltMid, FeltDark),
center = Offset(topLeft.x + width * 0.5f, topLeft.y + height * 0.34f),
radius = minOf(width, height) * 1.15f,
)
when (style) {
TableStyle.OVAL -> {
val rail = androidx.compose.ui.geometry.Rect(
left = size.width * FELT_LEFT, left = size.width * FELT_LEFT,
top = size.height * FELT_TOP, top = size.height * FELT_TOP,
right = size.width * FELT_RIGHT, right = size.width * FELT_RIGHT,
bottom = size.height * FELT_BOTTOM, bottom = size.height * FELT_BOTTOM,
) )
drawOval( drawOval(railBrush, Offset(rail.left, rail.top), rail.size)
brush = Brush.linearGradient( val tl = Offset(rail.left + inset, rail.top + inset)
colors = listOf(RailLight, RailDark), val sz = androidx.compose.ui.geometry.Size(
start = Offset(railRect.left, railRect.top), rail.width - inset * 2,
end = Offset(railRect.right, railRect.bottom), rail.height - inset * 2,
),
topLeft = Offset(railRect.left, railRect.top),
size = androidx.compose.ui.geometry.Size(railRect.width, railRect.height),
) )
drawOval(feltBrush(sz.width, sz.height, tl), tl, sz)
drawOval(TableGold.copy(alpha = 0.30f), tl, sz, style = Stroke(1.dp.toPx()))
}
val feltTopLeft = Offset(railRect.left + inset, railRect.top + inset) TableStyle.ARENA -> {
val feltSize = androidx.compose.ui.geometry.Size( // A circle wider than the canvas. The side lobes fall outside and
railRect.width - inset * 2, // are clipped away, leaving a broad, shallow top arc.
railRect.height - inset * 2, val radius = size.width * ARENA_RADIUS_W
) val center = Offset(size.width / 2f, size.height * ORBIT_CROWN_Y + radius)
drawOval( drawCircle(railBrush, radius, center)
brush = Brush.radialGradient( drawCircle(
Brush.radialGradient(
colors = listOf(FeltLight, FeltMid, FeltDark), colors = listOf(FeltLight, FeltMid, FeltDark),
center = Offset( center = Offset(center.x, center.y - radius * 0.55f),
feltTopLeft.x + feltSize.width * 0.5f, radius = radius * 1.25f,
feltTopLeft.y + feltSize.height * 0.34f,
), ),
radius = feltSize.minDimension * 1.15f, radius - inset,
), center,
topLeft = feltTopLeft,
size = feltSize,
) )
drawOval( drawCircle(
color = TableGold.copy(alpha = 0.30f), TableGold.copy(alpha = 0.30f),
topLeft = feltTopLeft, radius - inset,
size = feltSize, center,
style = Stroke(width = 1.dp.toPx()), style = Stroke(1.dp.toPx()),
) )
} }
TableStyle.STADIUM -> {
val corner = size.width * STADIUM_CORNER_W
val rail = androidx.compose.ui.geometry.Rect(
left = size.width * 0.012f,
top = size.height * 0.03f,
right = size.width * 0.988f,
bottom = size.height * 1.02f,
)
drawRoundRect(
railBrush,
Offset(rail.left, rail.top),
rail.size,
androidx.compose.ui.geometry.CornerRadius(corner, corner),
)
val tl = Offset(rail.left + inset, rail.top + inset)
val sz = androidx.compose.ui.geometry.Size(
rail.width - inset * 2,
rail.height - inset * 2,
)
val cr = androidx.compose.ui.geometry.CornerRadius(corner - inset, corner - inset)
drawRoundRect(feltBrush(sz.width, sz.height, tl), tl, sz, cr)
drawRoundRect(
TableGold.copy(alpha = 0.30f),
tl,
sz,
cr,
style = Stroke(1.dp.toPx()),
)
}
}
}
} }
@Composable @Composable
@@ -26,7 +26,7 @@ class TableOrbitTest {
val seatHeightFraction = 0.14f val seatHeightFraction = 0.14f
for (width in widths) { for (width in widths) {
val seatWidth = opponentSeatWidthDp(width) val seatWidth = opponentSeatWidthDp(width)
val placements = opponentSeatPlacements(width, seatWidth) val placements = opponentSeatPlacements(width, 610f, seatWidth)
assertEquals(5, placements.size) assertEquals(5, placements.size)
placements.zipWithNext { left, right -> placements.zipWithNext { left, right ->
val horizontalGap = right.leftDp - (left.leftDp + seatWidth) val horizontalGap = right.leftDp - (left.leftDp + seatWidth)
@@ -44,7 +44,7 @@ class TableOrbitTest {
fun `seats stay inside the table at any supported width`() { fun `seats stay inside the table at any supported width`() {
for (width in widths) { for (width in widths) {
val seatWidth = opponentSeatWidthDp(width) val seatWidth = opponentSeatWidthDp(width)
val placements = opponentSeatPlacements(width, seatWidth) val placements = opponentSeatPlacements(width, 610f, seatWidth)
assertTrue("at ${width}dp the first seat is off-screen", placements.first().leftDp >= 0f) assertTrue("at ${width}dp the first seat is off-screen", placements.first().leftDp >= 0f)
assertTrue( assertTrue(
"at ${width}dp the last seat overflows by " + "at ${width}dp the last seat overflows by " +
@@ -65,7 +65,7 @@ class TableOrbitTest {
@Test @Test
fun `seats are staggered into an arc rather than one straight row`() { fun `seats are staggered into an arc rather than one straight row`() {
val lanes = opponentSeatPlacements(384f).map { it.centerYFraction } val lanes = opponentSeatPlacements(384f, 610f).map { it.centerYFraction }
assertTrue("outer seats should sit lower than the crown", lanes[0] > lanes[2]) assertTrue("outer seats should sit lower than the crown", lanes[0] > lanes[2])
assertEquals("the arc should be symmetric", lanes[0], lanes[4], 0.0005f) assertEquals("the arc should be symmetric", lanes[0], lanes[4], 0.0005f)
assertEquals("the arc should be symmetric", lanes[1], lanes[3], 0.0005f) assertEquals("the arc should be symmetric", lanes[1], lanes[3], 0.0005f)
@@ -78,7 +78,7 @@ class TableOrbitTest {
*/ */
@Test @Test
fun `seats step evenly around the arc`() { fun `seats step evenly around the arc`() {
val lanes = opponentSeatPlacements(384f).map { it.centerYFraction } val lanes = opponentSeatPlacements(384f, 610f).map { it.centerYFraction }
val crownToShoulder = lanes[1] - lanes[2] val crownToShoulder = lanes[1] - lanes[2]
val shoulderToSide = lanes[0] - lanes[1] val shoulderToSide = lanes[0] - lanes[1]
assertTrue("the side seats must drop well below the crown", lanes[0] - lanes[2] > 0.20f) assertTrue("the side seats must drop well below the crown", lanes[0] - lanes[2] > 0.20f)
@@ -88,7 +88,7 @@ class TableOrbitTest {
shoulderToSide > crownToShoulder, shoulderToSide > crownToShoulder,
) )
val angles = opponentSeatPlacements(384f) val angles = opponentSeatPlacements(384f, 610f)
val xs = angles.map { it.leftDp } val xs = angles.map { it.leftDp }
assertTrue("seats must run left to right", xs.zipWithNext().all { (a, b) -> a < b }) assertTrue("seats must run left to right", xs.zipWithNext().all { (a, b) -> a < b })
} }
@@ -102,4 +102,58 @@ class TableOrbitTest {
assertEquals(7, counts.last()) assertEquals(7, counts.last())
assertTrue(counts.zipWithNext().all { (left, right) -> left <= right }) assertTrue(counts.zipWithNext().all { (left, right) -> left <= right })
} }
/**
* Every felt shape must keep five seats on screen and clear of each other.
* The shapes differ only in how deep the arc runs.
*/
@Test
fun `every table shape seats five players without collision or overflow`() {
val seatHeightFraction = 0.14f
for (style in TableStyle.entries) {
for (width in widths) {
val seatWidth = opponentSeatWidthDp(width)
val placements = opponentSeatPlacements(width, 610f, seatWidth, style)
assertEquals(5, placements.size)
assertTrue(
"${style.label} at ${width}dp runs off the left",
placements.first().leftDp >= -0.01f,
)
assertTrue(
"${style.label} at ${width}dp runs off the right",
placements.last().leftDp + seatWidth <= width + 0.01f,
)
placements.zipWithNext { a, b ->
val horizontal = b.leftDp - (a.leftDp + seatWidth)
val vertical = kotlin.math.abs(b.centerYFraction - a.centerYFraction)
assertTrue(
"${style.label} at ${width}dp: seats collide " +
"(h $horizontal, v $vertical)",
horizontal >= -0.01f || vertical >= seatHeightFraction,
)
}
}
}
}
/**
* The point of the arena shape: sacrificing the unused side lobes buys a
* wider, shallower run of seats than the oval can offer.
*/
@Test
fun `arena runs a shallower arc than the oval`() {
fun drop(style: TableStyle): Float {
val lanes = opponentSeatPlacements(384f, 610f, opponentSeatWidthDp(384f), style)
.map { it.centerYFraction }
return lanes.max() - lanes.min()
}
assertTrue(
"arena should flatten the arc relative to the oval",
drop(TableStyle.ARENA) < drop(TableStyle.OVAL),
)
assertTrue(
"stadium should be flatter still",
drop(TableStyle.STADIUM) < drop(TableStyle.ARENA),
)
}
} }