From 7268d62790355ca85ddbe76deac9aeb2f692245a Mon Sep 17 00:00:00 2001 From: Jay Date: Sun, 26 Jul 2026 22:18:39 -0400 Subject: [PATCH] Improve table legibility and showdown focus --- CLAUDE.md | 5 +- .../java/com/jsjdesigns/poker/HandSummary.kt | 3 + .../java/com/jsjdesigns/poker/TableScreen.kt | 228 ++++++++++++------ .../com/jsjdesigns/poker/HandSummaryTest.kt | 2 + .../com/jsjdesigns/poker/TableOrbitTest.kt | 25 ++ 5 files changed, 186 insertions(+), 77 deletions(-) create mode 100644 app/src/test/java/com/jsjdesigns/poker/TableOrbitTest.kt diff --git a/CLAUDE.md b/CLAUDE.md index ac1cd3f..d4d3d30 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -149,7 +149,10 @@ export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home" completed hand stays on screen until the player explicitly starts the next one. The portrait table follows the 1A design direction: oval felt, orbiting seats, spatial bets, large 2:3 card art, always-visible legal sizing controls, and a - winner-focused pot/hand breakdown at completion. + winner-focused pot/hand breakdown at completion. Face-up cards have fixed + legibility floors rather than shrinking on short displays; the opponent orbit + remains above the community-card band, and the terminal modal repeats the + board beside exact pot awards so dimming the table never hides the explanation. - A cash-game session begins at an explicit take-a-seat screen. Opponents may auto-reload below the big blind; the human is never silently topped up and must explicitly choose "Reload to N & deal" from the completed-hand screen. diff --git a/app/src/main/java/com/jsjdesigns/poker/HandSummary.kt b/app/src/main/java/com/jsjdesigns/poker/HandSummary.kt index 24dc3b6..6477c33 100644 --- a/app/src/main/java/com/jsjdesigns/poker/HandSummary.kt +++ b/app/src/main/java/com/jsjdesigns/poker/HandSummary.kt @@ -26,6 +26,8 @@ data class PotAwardSummary( */ data class HandSummary( val handNumber: Int, + /** Public community cards retained so the modal can explain the winning hand. */ + val board: List, /** Display labels in the engine's winner order; the human seat is always "You". */ val winnerLabels: List, /** Derived from winner seat indices, never from a player-controlled name. */ @@ -101,6 +103,7 @@ fun handSummaryFor( return HandSummary( handNumber = handNumber, + board = result.board.toList(), winnerLabels = result.winners.map { winner -> if (winner == heroSeat) "You" else seatNames[winner] }, diff --git a/app/src/main/java/com/jsjdesigns/poker/TableScreen.kt b/app/src/main/java/com/jsjdesigns/poker/TableScreen.kt index df4c83c..5b1c9dc 100644 --- a/app/src/main/java/com/jsjdesigns/poker/TableScreen.kt +++ b/app/src/main/java/com/jsjdesigns/poker/TableScreen.kt @@ -58,6 +58,9 @@ import com.jsjdesigns.poker.core.Card import com.jsjdesigns.poker.game.DecisionOffer import com.jsjdesigns.poker.game.SeatSnapshot import com.jsjdesigns.poker.game.TableSnapshot +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.sin private val Night = Color(0xFF04100C) private val TableBackground = Color(0xFF071D16) @@ -73,6 +76,26 @@ private val Muted = Color(0xFF9FB0A6) private val Danger = Color(0xFF8C242A) private val Panel = Color(0xE604120D) +internal data class NormalizedSeatPosition( + val centerX: Float, + val topY: Float, +) + +/** + * Five positions along the upper half of an ellipse. + * + * Keeping this geometry separate from Compose makes the table relationship + * testable: opponents occupy the upper arc while the board owns the middle band. + */ +internal fun opponentSeatOrbit(): List = + listOf(145.0, 118.0, 90.0, 62.0, 35.0).map { degrees -> + val radians = degrees * PI / 180.0 + NormalizedSeatPosition( + centerX = (0.5 + 0.46 * cos(radians)).toFloat(), + topY = (0.35 - 0.27 * sin(radians)).toFloat(), + ) + } + @Composable fun TableScreen(vm: PokerViewModel) { val state by vm.state.collectAsStateWithLifecycle() @@ -187,26 +210,21 @@ private fun PokerTable( .clip(RoundedCornerShape(26.dp)) .background(TableBackground), ) { - val compact = maxHeight < 520.dp TableFelt(Modifier.fillMaxSize()) - val opponentModifiers = listOf( - Modifier.align(Alignment.CenterStart).offset(x = 7.dp, y = (-26).dp), - Modifier.align(Alignment.TopStart).offset(x = 55.dp, y = 18.dp), - Modifier.align(Alignment.TopCenter).offset(y = 2.dp), - Modifier.align(Alignment.TopEnd).offset(x = (-55).dp, y = 18.dp), - Modifier.align(Alignment.CenterEnd).offset(x = (-7).dp, y = (-26).dp), - ) + val orbit = opponentSeatOrbit() seats.filter { it.index != HERO_SEAT } .forEachIndexed { index, seat -> + val position = orbit.getOrElse(index) { orbit.last() } + val seatWidth = if (seat.revealed && seat.hole != null) 90.dp else 76.dp OpponentSeat( seat = seat, isTurn = snapshot?.toAct == seat.index, isWinner = seat.index in winners, - compact = compact, - modifier = opponentModifiers.getOrElse(index) { - Modifier.align(Alignment.TopCenter) - }, + modifier = Modifier.offset( + x = maxWidth * position.centerX - seatWidth / 2, + y = maxHeight * position.topY, + ), ) } @@ -214,21 +232,31 @@ private fun PokerTable( snapshot = snapshot, status = status, tableTalk = tableTalk, - compact = compact, modifier = Modifier .align(Alignment.Center) - .offset(y = if (compact) 12.dp else 20.dp), + .offset(y = 18.dp), ) HeroSeat( seat = seats.firstOrNull { it.index == HERO_SEAT }, isTurn = snapshot?.toAct == HERO_SEAT, isWinner = HERO_SEAT in winners, - compact = compact, modifier = Modifier .align(Alignment.BottomCenter) .padding(bottom = 10.dp), ) + + handSummary?.let { summary -> + Box( + modifier = Modifier + .fillMaxSize() + .background(Night.copy(alpha = 0.82f)) + .padding(18.dp), + contentAlignment = Alignment.Center, + ) { + ShowdownModal(summary) + } + } } } @@ -279,15 +307,15 @@ private fun OpponentSeat( seat: SeatSnapshot, isTurn: Boolean, isWinner: Boolean, - compact: Boolean, modifier: Modifier = Modifier, ) { val faded = seat.folded && !isWinner - val faceWidth = if (compact) 29.dp else 33.dp - val faceHeight = faceWidth * 1.5f + val revealed = seat.revealed && seat.hole != null + val faceWidth = 52.dp + val faceHeight = 78.dp Column( modifier = modifier - .width(if (compact) 68.dp else 76.dp) + .width(if (revealed) 90.dp else 76.dp) .alpha(if (faded) 0.38f else 1f), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(3.dp), @@ -309,8 +337,8 @@ private fun OpponentSeat( contentAlignment = Alignment.Center, ) { val hole = seat.hole - if (seat.revealed && hole != null) { - Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) { + if (revealed && hole != null) { + Row(horizontalArrangement = Arrangement.spacedBy((-18).dp)) { repeat(2) { index -> CardImage( hole.getOrNull(index), @@ -413,12 +441,11 @@ private fun HeroSeat( seat: SeatSnapshot?, isTurn: Boolean, isWinner: Boolean, - compact: Boolean, modifier: Modifier = Modifier, ) { val folded = seat?.folded == true - val cardWidth = if (compact) 58.dp else 68.dp - val cardHeight = cardWidth * 1.5f + val cardWidth = 76.dp + val cardHeight = 114.dp Surface( modifier = modifier.alpha(if (folded) 0.42f else 1f), shape = RoundedCornerShape(16.dp), @@ -481,19 +508,18 @@ private fun BoardAndStatus( snapshot: TableSnapshot?, status: TableStatus?, tableTalk: com.jsjdesigns.poker.bot.TableTalkLine?, - compact: Boolean, modifier: Modifier = Modifier, ) { val board = snapshot?.board.orEmpty() - val cardWidth = if (compact) 45.dp else 52.dp - val cardHeight = cardWidth * 1.5f + val cardWidth = 52.dp + val cardHeight = 78.dp Column( modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(if (compact) 5.dp else 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { PotPill(snapshot?.pot ?: 0) - Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(5.dp)) { repeat(5) { index -> if (index < board.size) { CardImage(board[index], Modifier.size(cardWidth, cardHeight)) @@ -521,8 +547,8 @@ private fun BoardAndStatus( tableTalk?.let { Text( "${it.speakerName}: “${it.text}”", - color = Cream.copy(alpha = 0.74f), - fontSize = 10.sp, + color = Cream.copy(alpha = 0.9f), + fontSize = 11.sp, fontWeight = FontWeight.Medium, textAlign = TextAlign.Center, maxLines = 1, @@ -667,7 +693,7 @@ private fun TableControls( ) { when { offer != null -> DecisionControls(offer, onFold, onCheckCall, onRaise) - handSummary != null -> HandResultPanel( + handSummary != null -> CompletionControls( summary = handSummary, nextHandRequested = nextHandRequested, rebuyRequired = rebuyRequired, @@ -876,7 +902,74 @@ private fun ActionButtonText(title: String, detail: String?, color: Color) { } @Composable -private fun HandResultPanel( +private fun ShowdownModal(summary: HandSummary) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + color = Color(0xFA07130F), + border = androidx.compose.foundation.BorderStroke(2.dp, TableGold.copy(alpha = 0.72f)), + shadowElevation = 14.dp, + ) { + Column( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 15.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + if (summary.wentToShowdown) "SHOWDOWN" else "HAND COMPLETE", + color = TableGold, + fontWeight = FontWeight.ExtraBold, + fontSize = 10.sp, + letterSpacing = 1.8.sp, + ) + Text( + summary.title, + color = GoldLight, + fontWeight = FontWeight.ExtraBold, + fontSize = 24.sp, + ) + Text( + summary.detail, + color = Cream.copy(alpha = 0.94f), + fontWeight = FontWeight.Medium, + fontSize = 13.sp, + textAlign = TextAlign.Center, + ) + if (summary.wentToShowdown && summary.board.isNotEmpty()) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + "BOARD", + color = Muted.copy(alpha = 0.9f), + fontWeight = FontWeight.Bold, + fontSize = 9.sp, + letterSpacing = 1.3.sp, + ) + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + summary.board.forEach { card -> + CardImage(card, Modifier.size(46.dp, 69.dp)) + } + } + } + } + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + summary.awards.forEach { award -> + PotAwardCard(award) + } + } + } + } +} + +@Composable +private fun CompletionControls( summary: HandSummary, nextHandRequested: Boolean, rebuyRequired: Boolean, @@ -886,31 +979,10 @@ private fun HandResultPanel( Column( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 14.dp, vertical = 10.dp), + .padding(horizontal = 14.dp, vertical = 9.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(6.dp), ) { - Text( - summary.title, - color = GoldLight, - fontWeight = FontWeight.ExtraBold, - fontSize = 20.sp, - ) - Text( - summary.detail, - color = Cream.copy(alpha = 0.72f), - fontSize = 11.sp, - ) - Row( - modifier = Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - summary.awards.forEach { award -> - PotAwardCard(award) - } - } if (rebuyRequired) { Text( "Your stack cannot cover the big blind.", @@ -943,36 +1015,40 @@ private fun HandResultPanel( @Composable private fun PotAwardCard(award: PotAwardSummary) { + // For a split pot, showing only the first winner's cards would imply those + // cards alone explain the award. The modal retains the board and names every + // tied winner with their exact payout instead. + val visibleCards = award.winners + .singleOrNull() + ?.hole + .orEmpty() Card( + modifier = Modifier.width(310.dp), colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.07f)), - border = androidx.compose.foundation.BorderStroke(1.dp, TableGold.copy(alpha = 0.22f)), - shape = RoundedCornerShape(12.dp), + border = androidx.compose.foundation.BorderStroke(1.dp, TableGold.copy(alpha = 0.38f)), + shape = RoundedCornerShape(14.dp), ) { Row( - modifier = Modifier.padding(horizontal = 10.dp, vertical = 7.dp), + modifier = Modifier.padding(horizontal = 11.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), ) { - // For a split pot, showing only the first winner's cards would imply - // those cards alone explain the award. The board remains visible and - // every tied winner is named instead. - val visibleCards = award.winners - .singleOrNull() - ?.hole - .orEmpty() if (visibleCards.isNotEmpty()) { - Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { visibleCards.take(2).forEach { card -> - CardImage(card, Modifier.size(34.dp, 51.dp)) + CardImage(card, Modifier.size(76.dp, 114.dp)) } } } - Column(modifier = Modifier.width(112.dp)) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { Text( "${award.label.uppercase()} ${award.amount}", color = TableGold, fontWeight = FontWeight.ExtraBold, - fontSize = 9.sp, + fontSize = 11.sp, letterSpacing = 0.5.sp, ) Text( @@ -982,18 +1058,18 @@ private fun PotAwardCard(award: PotAwardSummary) { award.winners.joinToString(" • ") { "${it.label} ${it.amountWon}" } }, color = Cream, - fontWeight = FontWeight.Bold, - fontSize = 12.sp, - maxLines = 1, + fontWeight = FontWeight.ExtraBold, + fontSize = 16.sp, + maxLines = 2, overflow = TextOverflow.Ellipsis, ) val descriptions = award.winners.mapNotNull(AwardWinner::handDescription).distinct() if (descriptions.isNotEmpty()) { Text( descriptions.joinToString(" / "), - color = GoldLight.copy(alpha = 0.82f), - fontWeight = FontWeight.Medium, - fontSize = 10.sp, + color = GoldLight, + fontWeight = FontWeight.Bold, + fontSize = 14.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, ) diff --git a/app/src/test/java/com/jsjdesigns/poker/HandSummaryTest.kt b/app/src/test/java/com/jsjdesigns/poker/HandSummaryTest.kt index 6075fc6..8a2d297 100644 --- a/app/src/test/java/com/jsjdesigns/poker/HandSummaryTest.kt +++ b/app/src/test/java/com/jsjdesigns/poker/HandSummaryTest.kt @@ -72,6 +72,7 @@ class HandSummaryTest { assertEquals("Ada wins", summary.title) assertEquals("Showdown • Pot 16 • You lost 8", summary.detail) assertEquals(7, summary.handNumber) + assertEquals(cardsOf("2c 7d 9s Jc 3h").toList(), summary.board) } @Test @@ -151,6 +152,7 @@ class VisibleHandSummaryTest { private val summary = HandSummary( handNumber = 4, + board = emptyList(), winnerLabels = listOf("Ada"), heroWon = false, potSize = 20, diff --git a/app/src/test/java/com/jsjdesigns/poker/TableOrbitTest.kt b/app/src/test/java/com/jsjdesigns/poker/TableOrbitTest.kt new file mode 100644 index 0000000..897a9fc --- /dev/null +++ b/app/src/test/java/com/jsjdesigns/poker/TableOrbitTest.kt @@ -0,0 +1,25 @@ +package com.jsjdesigns.poker + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class TableOrbitTest { + + @Test + fun `five opponents form a symmetric upper ellipse above the board band`() { + val positions = opponentSeatOrbit() + + assertEquals(5, positions.size) + assertTrue(positions.zipWithNext().all { (left, right) -> left.centerX < right.centerX }) + assertTrue(positions.all { it.centerX in 0f..1f }) + assertTrue( + "opponent tops must stay in the upper fifth, leaving the middle to the board", + positions.all { it.topY < 0.21f }, + ) + assertEquals(1f, positions.first().centerX + positions.last().centerX, 0.001f) + assertEquals(positions.first().topY, positions.last().topY, 0.001f) + assertEquals(1f, positions[1].centerX + positions[3].centerX, 0.001f) + assertEquals(positions[1].topY, positions[3].topY, 0.001f) + } +}