Improve table legibility and showdown focus

This commit is contained in:
Jay
2026-07-26 22:18:39 -04:00
parent df4d90f40b
commit 7268d62790
5 changed files with 186 additions and 77 deletions
+4 -1
View File
@@ -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. 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, 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 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 - 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 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. explicitly choose "Reload to N & deal" from the completed-hand screen.
@@ -26,6 +26,8 @@ data class PotAwardSummary(
*/ */
data class HandSummary( data class HandSummary(
val handNumber: Int, val handNumber: Int,
/** Public community cards retained so the modal can explain the winning hand. */
val board: List<Int>,
/** Display labels in the engine's winner order; the human seat is always "You". */ /** Display labels in the engine's winner order; the human seat is always "You". */
val winnerLabels: List<String>, val winnerLabels: List<String>,
/** Derived from winner seat indices, never from a player-controlled name. */ /** Derived from winner seat indices, never from a player-controlled name. */
@@ -101,6 +103,7 @@ fun handSummaryFor(
return HandSummary( return HandSummary(
handNumber = handNumber, handNumber = handNumber,
board = result.board.toList(),
winnerLabels = result.winners.map { winner -> winnerLabels = result.winners.map { winner ->
if (winner == heroSeat) "You" else seatNames[winner] if (winner == heroSeat) "You" else seatNames[winner]
}, },
@@ -58,6 +58,9 @@ import com.jsjdesigns.poker.core.Card
import com.jsjdesigns.poker.game.DecisionOffer import com.jsjdesigns.poker.game.DecisionOffer
import com.jsjdesigns.poker.game.SeatSnapshot import com.jsjdesigns.poker.game.SeatSnapshot
import com.jsjdesigns.poker.game.TableSnapshot 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 Night = Color(0xFF04100C)
private val TableBackground = Color(0xFF071D16) private val TableBackground = Color(0xFF071D16)
@@ -73,6 +76,26 @@ private val Muted = Color(0xFF9FB0A6)
private val Danger = Color(0xFF8C242A) private val Danger = Color(0xFF8C242A)
private val Panel = Color(0xE604120D) 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<NormalizedSeatPosition> =
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 @Composable
fun TableScreen(vm: PokerViewModel) { fun TableScreen(vm: PokerViewModel) {
val state by vm.state.collectAsStateWithLifecycle() val state by vm.state.collectAsStateWithLifecycle()
@@ -187,26 +210,21 @@ private fun PokerTable(
.clip(RoundedCornerShape(26.dp)) .clip(RoundedCornerShape(26.dp))
.background(TableBackground), .background(TableBackground),
) { ) {
val compact = maxHeight < 520.dp
TableFelt(Modifier.fillMaxSize()) TableFelt(Modifier.fillMaxSize())
val opponentModifiers = listOf( val orbit = opponentSeatOrbit()
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),
)
seats.filter { it.index != HERO_SEAT } seats.filter { it.index != HERO_SEAT }
.forEachIndexed { index, 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( OpponentSeat(
seat = seat, seat = seat,
isTurn = snapshot?.toAct == seat.index, isTurn = snapshot?.toAct == seat.index,
isWinner = seat.index in winners, isWinner = seat.index in winners,
compact = compact, modifier = Modifier.offset(
modifier = opponentModifiers.getOrElse(index) { x = maxWidth * position.centerX - seatWidth / 2,
Modifier.align(Alignment.TopCenter) y = maxHeight * position.topY,
}, ),
) )
} }
@@ -214,21 +232,31 @@ private fun PokerTable(
snapshot = snapshot, snapshot = snapshot,
status = status, status = status,
tableTalk = tableTalk, tableTalk = tableTalk,
compact = compact,
modifier = Modifier modifier = Modifier
.align(Alignment.Center) .align(Alignment.Center)
.offset(y = if (compact) 12.dp else 20.dp), .offset(y = 18.dp),
) )
HeroSeat( HeroSeat(
seat = seats.firstOrNull { it.index == HERO_SEAT }, seat = seats.firstOrNull { it.index == HERO_SEAT },
isTurn = snapshot?.toAct == HERO_SEAT, isTurn = snapshot?.toAct == HERO_SEAT,
isWinner = HERO_SEAT in winners, isWinner = HERO_SEAT in winners,
compact = compact,
modifier = Modifier modifier = Modifier
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
.padding(bottom = 10.dp), .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, seat: SeatSnapshot,
isTurn: Boolean, isTurn: Boolean,
isWinner: Boolean, isWinner: Boolean,
compact: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val faded = seat.folded && !isWinner val faded = seat.folded && !isWinner
val faceWidth = if (compact) 29.dp else 33.dp val revealed = seat.revealed && seat.hole != null
val faceHeight = faceWidth * 1.5f val faceWidth = 52.dp
val faceHeight = 78.dp
Column( Column(
modifier = modifier 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), .alpha(if (faded) 0.38f else 1f),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(3.dp), verticalArrangement = Arrangement.spacedBy(3.dp),
@@ -309,8 +337,8 @@ private fun OpponentSeat(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
val hole = seat.hole val hole = seat.hole
if (seat.revealed && hole != null) { if (revealed && hole != null) {
Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) { Row(horizontalArrangement = Arrangement.spacedBy((-18).dp)) {
repeat(2) { index -> repeat(2) { index ->
CardImage( CardImage(
hole.getOrNull(index), hole.getOrNull(index),
@@ -413,12 +441,11 @@ private fun HeroSeat(
seat: SeatSnapshot?, seat: SeatSnapshot?,
isTurn: Boolean, isTurn: Boolean,
isWinner: Boolean, isWinner: Boolean,
compact: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val folded = seat?.folded == true val folded = seat?.folded == true
val cardWidth = if (compact) 58.dp else 68.dp val cardWidth = 76.dp
val cardHeight = cardWidth * 1.5f val cardHeight = 114.dp
Surface( Surface(
modifier = modifier.alpha(if (folded) 0.42f else 1f), modifier = modifier.alpha(if (folded) 0.42f else 1f),
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(16.dp),
@@ -481,19 +508,18 @@ private fun BoardAndStatus(
snapshot: TableSnapshot?, snapshot: TableSnapshot?,
status: TableStatus?, status: TableStatus?,
tableTalk: com.jsjdesigns.poker.bot.TableTalkLine?, tableTalk: com.jsjdesigns.poker.bot.TableTalkLine?,
compact: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val board = snapshot?.board.orEmpty() val board = snapshot?.board.orEmpty()
val cardWidth = if (compact) 45.dp else 52.dp val cardWidth = 52.dp
val cardHeight = cardWidth * 1.5f val cardHeight = 78.dp
Column( Column(
modifier = modifier, modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(if (compact) 5.dp else 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
PotPill(snapshot?.pot ?: 0) PotPill(snapshot?.pot ?: 0)
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(5.dp)) {
repeat(5) { index -> repeat(5) { index ->
if (index < board.size) { if (index < board.size) {
CardImage(board[index], Modifier.size(cardWidth, cardHeight)) CardImage(board[index], Modifier.size(cardWidth, cardHeight))
@@ -521,8 +547,8 @@ private fun BoardAndStatus(
tableTalk?.let { tableTalk?.let {
Text( Text(
"${it.speakerName}: “${it.text}", "${it.speakerName}: “${it.text}",
color = Cream.copy(alpha = 0.74f), color = Cream.copy(alpha = 0.9f),
fontSize = 10.sp, fontSize = 11.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
maxLines = 1, maxLines = 1,
@@ -667,7 +693,7 @@ private fun TableControls(
) { ) {
when { when {
offer != null -> DecisionControls(offer, onFold, onCheckCall, onRaise) offer != null -> DecisionControls(offer, onFold, onCheckCall, onRaise)
handSummary != null -> HandResultPanel( handSummary != null -> CompletionControls(
summary = handSummary, summary = handSummary,
nextHandRequested = nextHandRequested, nextHandRequested = nextHandRequested,
rebuyRequired = rebuyRequired, rebuyRequired = rebuyRequired,
@@ -876,7 +902,74 @@ private fun ActionButtonText(title: String, detail: String?, color: Color) {
} }
@Composable @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, summary: HandSummary,
nextHandRequested: Boolean, nextHandRequested: Boolean,
rebuyRequired: Boolean, rebuyRequired: Boolean,
@@ -886,31 +979,10 @@ private fun HandResultPanel(
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 10.dp), .padding(horizontal = 14.dp, vertical = 9.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(6.dp), 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) { if (rebuyRequired) {
Text( Text(
"Your stack cannot cover the big blind.", "Your stack cannot cover the big blind.",
@@ -943,36 +1015,40 @@ private fun HandResultPanel(
@Composable @Composable
private fun PotAwardCard(award: PotAwardSummary) { 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( Card(
modifier = Modifier.width(310.dp),
colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.07f)), colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.07f)),
border = androidx.compose.foundation.BorderStroke(1.dp, TableGold.copy(alpha = 0.22f)), border = androidx.compose.foundation.BorderStroke(1.dp, TableGold.copy(alpha = 0.38f)),
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(14.dp),
) { ) {
Row( Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 7.dp), modifier = Modifier.padding(horizontal = 11.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically, 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()) { if (visibleCards.isNotEmpty()) {
Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
visibleCards.take(2).forEach { card -> 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( Text(
"${award.label.uppercase()} ${award.amount}", "${award.label.uppercase()} ${award.amount}",
color = TableGold, color = TableGold,
fontWeight = FontWeight.ExtraBold, fontWeight = FontWeight.ExtraBold,
fontSize = 9.sp, fontSize = 11.sp,
letterSpacing = 0.5.sp, letterSpacing = 0.5.sp,
) )
Text( Text(
@@ -982,18 +1058,18 @@ private fun PotAwardCard(award: PotAwardSummary) {
award.winners.joinToString("") { "${it.label} ${it.amountWon}" } award.winners.joinToString("") { "${it.label} ${it.amountWon}" }
}, },
color = Cream, color = Cream,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.ExtraBold,
fontSize = 12.sp, fontSize = 16.sp,
maxLines = 1, maxLines = 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
val descriptions = award.winners.mapNotNull(AwardWinner::handDescription).distinct() val descriptions = award.winners.mapNotNull(AwardWinner::handDescription).distinct()
if (descriptions.isNotEmpty()) { if (descriptions.isNotEmpty()) {
Text( Text(
descriptions.joinToString(" / "), descriptions.joinToString(" / "),
color = GoldLight.copy(alpha = 0.82f), color = GoldLight,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Bold,
fontSize = 10.sp, fontSize = 14.sp,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
@@ -72,6 +72,7 @@ class HandSummaryTest {
assertEquals("Ada wins", summary.title) assertEquals("Ada wins", summary.title)
assertEquals("Showdown • Pot 16 • You lost 8", summary.detail) assertEquals("Showdown • Pot 16 • You lost 8", summary.detail)
assertEquals(7, summary.handNumber) assertEquals(7, summary.handNumber)
assertEquals(cardsOf("2c 7d 9s Jc 3h").toList(), summary.board)
} }
@Test @Test
@@ -151,6 +152,7 @@ class VisibleHandSummaryTest {
private val summary = HandSummary( private val summary = HandSummary(
handNumber = 4, handNumber = 4,
board = emptyList(),
winnerLabels = listOf("Ada"), winnerLabels = listOf("Ada"),
heroWon = false, heroWon = false,
potSize = 20, potSize = 20,
@@ -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)
}
}