diff --git a/app/src/main/java/com/jsjdesigns/poker/TableScreen.kt b/app/src/main/java/com/jsjdesigns/poker/TableScreen.kt index 630f339..d071c80 100644 --- a/app/src/main/java/com/jsjdesigns/poker/TableScreen.kt +++ b/app/src/main/java/com/jsjdesigns/poker/TableScreen.kt @@ -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 { + tableStyle: TableStyle = TableStyle.OVAL, +): List = 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 { 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 { + 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()), + ) + } + } } } diff --git a/app/src/test/java/com/jsjdesigns/poker/TableOrbitTest.kt b/app/src/test/java/com/jsjdesigns/poker/TableOrbitTest.kt index 0ca4868..bcf9864 100644 --- a/app/src/test/java/com/jsjdesigns/poker/TableOrbitTest.kt +++ b/app/src/test/java/com/jsjdesigns/poker/TableOrbitTest.kt @@ -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), + ) + } }