Improve table legibility and showdown focus
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
Card(
|
||||
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),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 7.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.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.
|
||||
// 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.38f)),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 11.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user