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
@@ -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<Int>,
/** Display labels in the engine's winner order; the human seat is always "You". */
val winnerLabels: List<String>,
/** 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]
},
@@ -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<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
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,
)
@@ -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,
@@ -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)
}
}