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 kotlin.math.roundToInt
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.cos
import kotlin.math.max
import kotlin.math.sin
import kotlin.math.sqrt
private val Night = Color(0xFF04100C)
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. */
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.
*/
@@ -204,14 +236,35 @@ internal fun opponentSeatWidthDp(
*/
internal fun opponentSeatPlacements(
availableWidthDp: Float,
availableHeightDp: Float,
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 centerX = availableWidthDp / 2f
val step = (2f * ORBIT_HALF_ANGLE_DEG) / 4f
return List(5) { index ->
val degrees = -ORBIT_HALF_ANGLE_DEG + step * index
val radians = degrees * PI.toFloat() / 180f
val radians = (-ORBIT_HALF_ANGLE_DEG + step * index) * PI.toFloat() / 180f
SeatPlacement(
leftDp = centerX + orbitRx * sin(radians) - seatWidthDp / 2f,
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
fun TableScreen(vm: PokerViewModel) {
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.
var sizingRaise by remember(offer?.token) { mutableStateOf(false) }
var seatStyle by remember { mutableStateOf(SeatStyle.PLATE) }
var tableStyle by remember { mutableStateOf(TableStyle.OVAL) }
Box(
modifier = Modifier
@@ -247,7 +329,9 @@ fun TableScreen(vm: PokerViewModel) {
snapshot = snap,
config = state.gameConfig,
seatStyle = seatStyle,
tableStyle = tableStyle,
onCycleSeatStyle = { seatStyle = seatStyle.next() },
onCycleTableStyle = { tableStyle = tableStyle.next() },
)
PokerTable(
snapshot = snap,
@@ -255,6 +339,7 @@ fun TableScreen(vm: PokerViewModel) {
tableTalk = tableTalk,
handSummary = handSummary,
seatStyle = seatStyle,
tableStyle = tableStyle,
// Weighted against a control area whose height no longer changes,
// so the felt keeps one shape for the whole hand.
modifier = Modifier
@@ -295,7 +380,9 @@ private fun TableHeader(
snapshot: TableSnapshot?,
config: CashGameConfig?,
seatStyle: SeatStyle,
tableStyle: TableStyle,
onCycleSeatStyle: () -> Unit,
onCycleTableStyle: () -> Unit,
) {
Row(
modifier = Modifier
@@ -335,12 +422,32 @@ private fun TableHeader(
),
) {
Text(
"SEATS · ${seatStyle.label.uppercase()}",
modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp),
seatStyle.label.uppercase(),
modifier = Modifier.padding(horizontal = 9.dp, vertical = 5.dp),
color = TableGold,
fontWeight = FontWeight.Bold,
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(
@@ -373,6 +480,7 @@ private fun PokerTable(
tableTalk: com.jsjdesigns.poker.bot.TableTalkLine?,
handSummary: HandSummary?,
seatStyle: SeatStyle,
tableStyle: TableStyle,
modifier: Modifier = Modifier,
) {
val seats = snapshot?.seats.orEmpty()
@@ -385,12 +493,17 @@ private fun PokerTable(
modifier = modifier
.background(TableBackground),
) {
TableFelt(Modifier.fillMaxSize())
TableFelt(tableStyle, Modifier.fillMaxSize())
// 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.
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 }
.forEachIndexed { index, seat ->
val placement = placements.getOrElse(index) { placements.last() }
@@ -452,50 +565,91 @@ private fun PokerTable(
}
@Composable
private fun TableFelt(modifier: Modifier = Modifier) {
private fun TableFelt(style: TableStyle, modifier: Modifier = 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 railRect = androidx.compose.ui.geometry.Rect(
left = size.width * FELT_LEFT,
top = size.height * FELT_TOP,
right = size.width * FELT_RIGHT,
bottom = size.height * FELT_BOTTOM,
val railBrush = Brush.linearGradient(
colors = listOf(RailLight, RailDark),
start = Offset(0f, 0f),
end = Offset(size.width, size.height),
)
drawOval(
brush = Brush.linearGradient(
colors = listOf(RailLight, RailDark),
start = Offset(railRect.left, railRect.top),
end = Offset(railRect.right, railRect.bottom),
),
topLeft = Offset(railRect.left, railRect.top),
size = androidx.compose.ui.geometry.Size(railRect.width, railRect.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,
)
val feltTopLeft = Offset(railRect.left + inset, railRect.top + inset)
val feltSize = androidx.compose.ui.geometry.Size(
railRect.width - inset * 2,
railRect.height - inset * 2,
)
drawOval(
brush = Brush.radialGradient(
colors = listOf(FeltLight, FeltMid, FeltDark),
center = Offset(
feltTopLeft.x + feltSize.width * 0.5f,
feltTopLeft.y + feltSize.height * 0.34f,
),
radius = feltSize.minDimension * 1.15f,
),
topLeft = feltTopLeft,
size = feltSize,
)
drawOval(
color = TableGold.copy(alpha = 0.30f),
topLeft = feltTopLeft,
size = feltSize,
style = Stroke(width = 1.dp.toPx()),
)
when (style) {
TableStyle.OVAL -> {
val rail = androidx.compose.ui.geometry.Rect(
left = size.width * FELT_LEFT,
top = size.height * FELT_TOP,
right = size.width * FELT_RIGHT,
bottom = size.height * FELT_BOTTOM,
)
drawOval(railBrush, Offset(rail.left, rail.top), rail.size)
val tl = Offset(rail.left + inset, rail.top + inset)
val sz = androidx.compose.ui.geometry.Size(
rail.width - inset * 2,
rail.height - inset * 2,
)
drawOval(feltBrush(sz.width, sz.height, tl), tl, sz)
drawOval(TableGold.copy(alpha = 0.30f), tl, sz, style = Stroke(1.dp.toPx()))
}
TableStyle.ARENA -> {
// A circle wider than the canvas. The side lobes fall outside and
// are clipped away, leaving a broad, shallow top arc.
val radius = size.width * ARENA_RADIUS_W
val center = Offset(size.width / 2f, size.height * ORBIT_CROWN_Y + radius)
drawCircle(railBrush, radius, center)
drawCircle(
Brush.radialGradient(
colors = listOf(FeltLight, FeltMid, FeltDark),
center = Offset(center.x, center.y - radius * 0.55f),
radius = radius * 1.25f,
),
radius - inset,
center,
)
drawCircle(
TableGold.copy(alpha = 0.30f),
radius - inset,
center,
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()),
)
}
}
}
}
@@ -26,7 +26,7 @@ class TableOrbitTest {
val seatHeightFraction = 0.14f
for (width in widths) {
val seatWidth = opponentSeatWidthDp(width)
val placements = opponentSeatPlacements(width, seatWidth)
val placements = opponentSeatPlacements(width, 610f, seatWidth)
assertEquals(5, placements.size)
placements.zipWithNext { left, right ->
val horizontalGap = right.leftDp - (left.leftDp + seatWidth)
@@ -44,7 +44,7 @@ class TableOrbitTest {
fun `seats stay inside the table at any supported width`() {
for (width in widths) {
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 last seat overflows by " +
@@ -65,7 +65,7 @@ class TableOrbitTest {
@Test
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])
assertEquals("the arc should be symmetric", lanes[0], lanes[4], 0.0005f)
assertEquals("the arc should be symmetric", lanes[1], lanes[3], 0.0005f)
@@ -78,7 +78,7 @@ class TableOrbitTest {
*/
@Test
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 shoulderToSide = lanes[0] - lanes[1]
assertTrue("the side seats must drop well below the crown", lanes[0] - lanes[2] > 0.20f)
@@ -88,7 +88,7 @@ class TableOrbitTest {
shoulderToSide > crownToShoulder,
)
val angles = opponentSeatPlacements(384f)
val angles = opponentSeatPlacements(384f, 610f)
val xs = angles.map { it.leftDp }
assertTrue("seats must run left to right", xs.zipWithNext().all { (a, b) -> a < b })
}
@@ -102,4 +102,58 @@ class TableOrbitTest {
assertEquals(7, counts.last())
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),
)
}
}