Rework table spacing and betting controls
This commit is contained in:
@@ -147,12 +147,16 @@ export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"
|
||||
- Android is playable in Compose. Engine snapshots are paced through a
|
||||
rendezvous channel, human decisions are matched by engine-owned tokens, and a
|
||||
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: circular felt, perimeter seats,
|
||||
spatial bets, large 2:3 card art, always-visible legal sizing controls, and a
|
||||
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.
|
||||
straddles the outside rail rather than occupying the community-card band.
|
||||
The terminal modal repeats the board, every public hand turned up (including
|
||||
losing hands), and exact pot awards so dimming the table never hides the
|
||||
explanation. Raise sizing combines coarse slider/presets with one-chip minus
|
||||
and plus controls; visible chip stacks grow with bets and the pot while numeric
|
||||
labels remain authoritative.
|
||||
- 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.
|
||||
|
||||
@@ -61,6 +61,13 @@ fun buttonsFor(offer: DecisionOffer): ActionButtons {
|
||||
fun raiseLabel(buttons: ActionButtons, amount: Int): String =
|
||||
if (amount >= buttons.sliderMax) "All in" else "Raise $amount"
|
||||
|
||||
/** One-chip fine tuning for the sizing rail, clamped to the engine's legal range. */
|
||||
fun adjustRaiseAmount(
|
||||
current: Int,
|
||||
change: Int,
|
||||
buttons: ActionButtons,
|
||||
): Int = (current + change).coerceIn(buttons.sliderMin, buttons.sliderMax)
|
||||
|
||||
/**
|
||||
* Honest, legal raise-to shortcuts for the always-visible sizing rail.
|
||||
*
|
||||
|
||||
@@ -2,6 +2,8 @@ package com.jsjdesigns.poker
|
||||
|
||||
import com.jsjdesigns.poker.core.HandEvaluator
|
||||
import com.jsjdesigns.poker.game.HandResult
|
||||
import com.jsjdesigns.poker.game.PotAward
|
||||
import com.jsjdesigns.poker.game.PotPayout
|
||||
|
||||
data class AwardWinner(
|
||||
val seat: Int,
|
||||
@@ -17,6 +19,15 @@ data class PotAwardSummary(
|
||||
val winners: List<AwardWinner>,
|
||||
)
|
||||
|
||||
data class ShowdownPlayerSummary(
|
||||
val seat: Int,
|
||||
val label: String,
|
||||
val hole: List<Int>,
|
||||
val handDescription: String,
|
||||
/** Total won across every main/side pot; zero means the hand was shown but lost. */
|
||||
val amountWon: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* Immutable, presentation-ready outcome for one completed hand.
|
||||
*
|
||||
@@ -35,6 +46,8 @@ data class HandSummary(
|
||||
val potSize: Int,
|
||||
val heroNet: Int,
|
||||
val wentToShowdown: Boolean,
|
||||
/** Every public hand turned face-up, including contenders that won no pot. */
|
||||
val showdownPlayers: List<ShowdownPlayerSummary>,
|
||||
/** Main/side-pot breakdown with public winning cards and evaluated hand names. */
|
||||
val awards: List<PotAwardSummary>,
|
||||
) {
|
||||
@@ -77,6 +90,22 @@ fun handSummaryFor(
|
||||
}
|
||||
}
|
||||
val multiplePots = result.potAwards.size > 1
|
||||
val wonBySeat = result.potAwards
|
||||
.flatMap(PotAward::payouts)
|
||||
.groupBy(PotPayout::seat)
|
||||
.mapValues { (_, payouts) -> payouts.sumOf(PotPayout::amount) }
|
||||
val showdownPlayers = result.showdownHands.map { showdown ->
|
||||
require(showdown.seat in seatNames.indices) {
|
||||
"showdown seat is outside the roster"
|
||||
}
|
||||
ShowdownPlayerSummary(
|
||||
seat = showdown.seat,
|
||||
label = if (showdown.seat == heroSeat) "You" else seatNames[showdown.seat],
|
||||
hole = showdown.hole.toList(),
|
||||
handDescription = HandEvaluator.describe(showdown.score),
|
||||
amountWon = wonBySeat[showdown.seat] ?: 0,
|
||||
)
|
||||
}
|
||||
val awards = result.potAwards.mapIndexed { index, award ->
|
||||
require(award.payouts.all { it.seat in seatNames.indices }) {
|
||||
"pot winner seat is outside the roster"
|
||||
@@ -111,6 +140,7 @@ fun handSummaryFor(
|
||||
potSize = result.potSize,
|
||||
heroNet = result.net[heroSeat],
|
||||
wentToShowdown = result.wentToShowdown,
|
||||
showdownPlayers = showdownPlayers,
|
||||
awards = awards,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,9 +6,11 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -41,7 +43,6 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
@@ -58,9 +59,7 @@ 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
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private val Night = Color(0xFF04100C)
|
||||
private val TableBackground = Color(0xFF071D16)
|
||||
@@ -76,25 +75,37 @@ private val Muted = Color(0xFF9FB0A6)
|
||||
private val Danger = Color(0xFF8C242A)
|
||||
private val Panel = Color(0xE604120D)
|
||||
|
||||
internal fun visibleChipCount(amount: Int): Int = when {
|
||||
amount <= 0 -> 0
|
||||
amount <= 2 -> 1
|
||||
amount <= 5 -> 2
|
||||
amount <= 10 -> 3
|
||||
amount <= 25 -> 4
|
||||
amount <= 60 -> 5
|
||||
amount <= 150 -> 6
|
||||
else -> 7
|
||||
}
|
||||
|
||||
internal data class NormalizedSeatPosition(
|
||||
val centerX: Float,
|
||||
val topY: Float,
|
||||
)
|
||||
|
||||
/**
|
||||
* Five positions along the upper half of an ellipse.
|
||||
* Five seats around the outside of a circular felt.
|
||||
*
|
||||
* Keeping this geometry separate from Compose makes the table relationship
|
||||
* testable: opponents occupy the upper arc while the board owns the middle band.
|
||||
* testable. The three top seats have independent horizontal lanes; the two side
|
||||
* seats straddle the rail instead of consuming the board's middle.
|
||||
*/
|
||||
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(),
|
||||
listOf(
|
||||
NormalizedSeatPosition(centerX = 0.13f, topY = 0.18f),
|
||||
NormalizedSeatPosition(centerX = 0.28f, topY = 0.01f),
|
||||
NormalizedSeatPosition(centerX = 0.50f, topY = 0f),
|
||||
NormalizedSeatPosition(centerX = 0.72f, topY = 0.01f),
|
||||
NormalizedSeatPosition(centerX = 0.87f, topY = 0.18f),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TableScreen(vm: PokerViewModel) {
|
||||
@@ -121,8 +132,9 @@ fun TableScreen(vm: PokerViewModel) {
|
||||
handSummary = handSummary,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
.aspectRatio(0.80f),
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
TableControls(
|
||||
offer = offer,
|
||||
handSummary = handSummary,
|
||||
@@ -157,13 +169,13 @@ private fun TableHeader(
|
||||
"HAND ${snapshot?.handNumber ?: 1}",
|
||||
color = Cream,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 12.sp,
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
Text(
|
||||
snapshot?.street?.name.orEmpty(),
|
||||
color = Muted.copy(alpha = 0.8f),
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 10.sp,
|
||||
fontSize = 11.sp,
|
||||
letterSpacing = 1.4.sp,
|
||||
)
|
||||
}
|
||||
@@ -183,7 +195,7 @@ private fun TableHeader(
|
||||
"${config?.bigBlind ?: DEFAULT_BIG_BLIND}",
|
||||
color = TableGold,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 11.sp,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -206,8 +218,6 @@ private fun PokerTable(
|
||||
|
||||
BoxWithConstraints(
|
||||
modifier = modifier
|
||||
.padding(horizontal = 6.dp)
|
||||
.clip(RoundedCornerShape(26.dp))
|
||||
.background(TableBackground),
|
||||
) {
|
||||
TableFelt(Modifier.fillMaxSize())
|
||||
@@ -216,14 +226,14 @@ private fun PokerTable(
|
||||
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
|
||||
val seatWidth = if (seat.revealed && seat.hole != null) 108.dp else 88.dp
|
||||
OpponentSeat(
|
||||
seat = seat,
|
||||
isTurn = snapshot?.toAct == seat.index,
|
||||
isWinner = seat.index in winners,
|
||||
modifier = Modifier.offset(
|
||||
x = maxWidth * position.centerX - seatWidth / 2,
|
||||
y = maxHeight * position.topY,
|
||||
y = maxWidth * position.topY,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -233,8 +243,8 @@ private fun PokerTable(
|
||||
status = status,
|
||||
tableTalk = tableTalk,
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.offset(y = 18.dp),
|
||||
.align(Alignment.TopCenter)
|
||||
.offset(y = maxWidth * 0.40f),
|
||||
)
|
||||
|
||||
HeroSeat(
|
||||
@@ -251,7 +261,7 @@ private fun PokerTable(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Night.copy(alpha = 0.82f))
|
||||
.padding(18.dp),
|
||||
.padding(8.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
ShowdownModal(summary)
|
||||
@@ -263,40 +273,33 @@ private fun PokerTable(
|
||||
@Composable
|
||||
private fun TableFelt(modifier: Modifier = Modifier) {
|
||||
Canvas(modifier) {
|
||||
val railTop = 2.dp.toPx()
|
||||
val railBottom = 18.dp.toPx()
|
||||
val horizontalBleed = 22.dp.toPx()
|
||||
val railSize = Size(
|
||||
width = size.width + horizontalBleed * 2,
|
||||
height = size.height - railTop - railBottom,
|
||||
)
|
||||
val railOffset = Offset(-horizontalBleed, railTop)
|
||||
drawOval(
|
||||
val center = Offset(size.width * 0.5f, size.width * 0.5f)
|
||||
val railRadius = size.minDimension * 0.405f
|
||||
drawCircle(
|
||||
brush = Brush.linearGradient(
|
||||
colors = listOf(RailLight, RailDark),
|
||||
start = railOffset,
|
||||
start = Offset(center.x - railRadius, center.y - railRadius),
|
||||
end = Offset(size.width, size.height),
|
||||
),
|
||||
topLeft = railOffset,
|
||||
size = railSize,
|
||||
radius = railRadius,
|
||||
center = center,
|
||||
)
|
||||
|
||||
val inset = 9.dp.toPx()
|
||||
val feltOffset = Offset(railOffset.x + inset, railOffset.y + inset)
|
||||
val feltSize = Size(railSize.width - inset * 2, railSize.height - inset * 2)
|
||||
drawOval(
|
||||
val feltRadius = railRadius - inset
|
||||
drawCircle(
|
||||
brush = Brush.radialGradient(
|
||||
colors = listOf(FeltLight, FeltMid, FeltDark),
|
||||
center = Offset(size.width * 0.5f, size.height * 0.38f),
|
||||
radius = size.maxDimension * 0.67f,
|
||||
center = Offset(center.x, center.y - feltRadius * 0.18f),
|
||||
radius = feltRadius * 1.15f,
|
||||
),
|
||||
topLeft = feltOffset,
|
||||
size = feltSize,
|
||||
radius = feltRadius,
|
||||
center = center,
|
||||
)
|
||||
drawOval(
|
||||
drawCircle(
|
||||
color = TableGold.copy(alpha = 0.34f),
|
||||
topLeft = feltOffset,
|
||||
size = feltSize,
|
||||
radius = feltRadius,
|
||||
center = center,
|
||||
style = Stroke(width = 1.dp.toPx()),
|
||||
)
|
||||
}
|
||||
@@ -311,14 +314,14 @@ private fun OpponentSeat(
|
||||
) {
|
||||
val faded = seat.folded && !isWinner
|
||||
val revealed = seat.revealed && seat.hole != null
|
||||
val faceWidth = 52.dp
|
||||
val faceHeight = 78.dp
|
||||
val faceWidth = 50.dp
|
||||
val faceHeight = 75.dp
|
||||
Column(
|
||||
modifier = modifier
|
||||
.width(if (revealed) 90.dp else 76.dp)
|
||||
.width(if (revealed) 108.dp else 88.dp)
|
||||
.alpha(if (faded) 0.38f else 1f),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(3.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
val highlight = when {
|
||||
@@ -333,12 +336,12 @@ private fun OpponentSeat(
|
||||
color = highlight,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
)
|
||||
.padding(3.dp),
|
||||
.padding(2.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
val hole = seat.hole
|
||||
if (revealed && hole != null) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy((-18).dp)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
repeat(2) { index ->
|
||||
CardImage(
|
||||
hole.getOrNull(index),
|
||||
@@ -347,7 +350,19 @@ private fun OpponentSeat(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(0.dp),
|
||||
) {
|
||||
OpponentAvatar(seat)
|
||||
if (!seat.folded) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy((-5).dp)) {
|
||||
repeat(2) {
|
||||
CardImage(null, Modifier.size(18.dp, 27.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (seat.isButton) {
|
||||
@@ -373,7 +388,7 @@ private fun OpponentSeat(
|
||||
seat.name,
|
||||
color = Cream,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 10.sp,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
@@ -389,7 +404,7 @@ private fun OpponentSeat(
|
||||
else -> TableGold.copy(alpha = 0.9f)
|
||||
},
|
||||
fontWeight = if (seat.allIn || isWinner) FontWeight.Bold else FontWeight.Medium,
|
||||
fontSize = 10.sp,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -406,7 +421,7 @@ private fun OpponentSeat(
|
||||
private fun OpponentAvatar(seat: SeatSnapshot) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(44.dp)
|
||||
.size(48.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
Brush.linearGradient(
|
||||
@@ -419,20 +434,8 @@ private fun OpponentAvatar(seat: SeatSnapshot) {
|
||||
seat.name.take(1).uppercase(),
|
||||
color = GoldLight,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 16.sp,
|
||||
fontSize = 18.sp,
|
||||
)
|
||||
if (!seat.folded) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.offset(x = 5.dp, y = 2.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy((-6).dp),
|
||||
) {
|
||||
repeat(2) {
|
||||
CardImage(null, Modifier.size(14.dp, 21.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -480,7 +483,7 @@ private fun HeroSeat(
|
||||
seat?.name ?: "You",
|
||||
color = Cream,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 14.sp,
|
||||
fontSize = 15.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
@@ -488,7 +491,7 @@ private fun HeroSeat(
|
||||
"${seat?.stack ?: 0}",
|
||||
color = TableGold,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 14.sp,
|
||||
fontSize = 15.sp,
|
||||
)
|
||||
when {
|
||||
folded -> StatusTag("FOLDED", Danger)
|
||||
@@ -511,8 +514,8 @@ private fun BoardAndStatus(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val board = snapshot?.board.orEmpty()
|
||||
val cardWidth = 52.dp
|
||||
val cardHeight = 78.dp
|
||||
val cardWidth = 58.dp
|
||||
val cardHeight = 87.dp
|
||||
Column(
|
||||
modifier = modifier,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
@@ -548,12 +551,12 @@ private fun BoardAndStatus(
|
||||
Text(
|
||||
"${it.speakerName}: “${it.text}”",
|
||||
color = Cream.copy(alpha = 0.9f),
|
||||
fontSize = 11.sp,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 1,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.width(260.dp),
|
||||
modifier = Modifier.width(292.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -567,15 +570,16 @@ private fun PotPill(pot: Int) {
|
||||
border = androidx.compose.foundation.BorderStroke(1.dp, TableGold.copy(alpha = 0.32f)),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 15.dp, vertical = 7.dp),
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 7.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
ChipStack(pot, chipSize = 18.dp)
|
||||
Text(
|
||||
"POT",
|
||||
color = TableGold.copy(alpha = 0.72f),
|
||||
color = TableGold.copy(alpha = 0.85f),
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 9.sp,
|
||||
fontSize = 10.sp,
|
||||
letterSpacing = 1.8.sp,
|
||||
)
|
||||
Text(
|
||||
@@ -602,13 +606,13 @@ private fun StatusPill(text: String, accent: Boolean) {
|
||||
text,
|
||||
color = if (accent) GoldLight else Cream.copy(alpha = 0.8f),
|
||||
fontWeight = if (accent) FontWeight.Bold else FontWeight.Medium,
|
||||
fontSize = 10.sp,
|
||||
fontSize = 12.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier
|
||||
.width(250.dp)
|
||||
.padding(horizontal = 11.dp, vertical = 5.dp),
|
||||
.width(286.dp)
|
||||
.padding(horizontal = 11.dp, vertical = 7.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -619,12 +623,12 @@ private fun BetMarker(amount: Int) {
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
ChipDot(Color(0xFFE9E7DD), 14.dp)
|
||||
ChipStack(amount, chipSize = 14.dp)
|
||||
Text(
|
||||
"$amount",
|
||||
color = Cream.copy(alpha = 0.9f),
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 10.sp,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -654,22 +658,50 @@ private fun StatusTag(text: String, color: Color) {
|
||||
text,
|
||||
color = color,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 9.sp,
|
||||
fontSize = 10.sp,
|
||||
letterSpacing = 0.5.sp,
|
||||
modifier = Modifier.padding(horizontal = 7.dp, vertical = 3.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChipStack(amount: Int, chipSize: Dp) {
|
||||
val colors = listOf(
|
||||
Color(0xFFE7E4D9),
|
||||
Color(0xFF2A9E6B),
|
||||
Color(0xFF2F72C4),
|
||||
Color(0xFFE2B85B),
|
||||
Color(0xFFB94B52),
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy((-4).dp),
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
) {
|
||||
repeat(visibleChipCount(amount)) { index ->
|
||||
Box(Modifier.padding(bottom = ((index % 3) * 3).dp)) {
|
||||
ChipDot(colors[index % colors.size], chipSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChipDot(color: Color, size: Dp) {
|
||||
Canvas(Modifier.size(size)) {
|
||||
drawCircle(color = color)
|
||||
val radius = this.size.minDimension * 0.48f
|
||||
drawCircle(color = Color.Black.copy(alpha = 0.34f), radius = radius)
|
||||
drawCircle(color = color, radius = radius * 0.91f)
|
||||
drawCircle(
|
||||
color = Color.White.copy(alpha = 0.48f),
|
||||
radius = this.size.minDimension * 0.34f,
|
||||
style = Stroke(width = 1.dp.toPx()),
|
||||
color = Color.White.copy(alpha = 0.62f),
|
||||
radius = radius * 0.63f,
|
||||
style = Stroke(width = 1.2.dp.toPx()),
|
||||
)
|
||||
val mark = radius * 0.63f
|
||||
drawLine(Color.White.copy(alpha = 0.78f), Offset(center.x, center.y - radius), Offset(center.x, center.y - mark), 1.5.dp.toPx())
|
||||
drawLine(Color.White.copy(alpha = 0.78f), Offset(center.x, center.y + mark), Offset(center.x, center.y + radius), 1.5.dp.toPx())
|
||||
drawLine(Color.White.copy(alpha = 0.78f), Offset(center.x - radius, center.y), Offset(center.x - mark, center.y), 1.5.dp.toPx())
|
||||
drawLine(Color.White.copy(alpha = 0.78f), Offset(center.x + mark, center.y), Offset(center.x + radius, center.y), 1.5.dp.toPx())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -749,27 +781,41 @@ private fun DecisionControls(
|
||||
"RAISE TO",
|
||||
color = Muted.copy(alpha = 0.72f),
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 10.sp,
|
||||
fontSize = 12.sp,
|
||||
letterSpacing = 1.4.sp,
|
||||
)
|
||||
Text(
|
||||
"$raiseTo",
|
||||
color = GoldLight,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 19.sp,
|
||||
fontSize = 22.sp,
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
FineTuneButton("−") {
|
||||
raiseTo = adjustRaiseAmount(raiseTo, -1, buttons)
|
||||
}
|
||||
Slider(
|
||||
value = raiseTo.coerceIn(buttons.sliderMin, buttons.sliderMax).toFloat(),
|
||||
onValueChange = { raiseTo = it.toInt() },
|
||||
onValueChange = { raiseTo = it.roundToInt() },
|
||||
valueRange = buttons.sliderMin.toFloat()..buttons.sliderMax.toFloat(),
|
||||
colors = SliderDefaults.colors(
|
||||
thumbColor = GoldLight,
|
||||
activeTrackColor = TableGold,
|
||||
inactiveTrackColor = Color.White.copy(alpha = 0.12f),
|
||||
),
|
||||
modifier = Modifier.height(26.dp),
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(44.dp),
|
||||
)
|
||||
FineTuneButton("+") {
|
||||
raiseTo = adjustRaiseAmount(raiseTo, 1, buttons)
|
||||
}
|
||||
}
|
||||
val presets = raisePresets(offer, buttons)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -780,8 +826,8 @@ private fun DecisionControls(
|
||||
onClick = { raiseTo = preset.amount },
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(32.dp),
|
||||
contentPadding = ButtonDefaults.ContentPadding,
|
||||
.height(42.dp),
|
||||
contentPadding = PaddingValues(horizontal = 4.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = if (raiseTo == preset.amount) {
|
||||
TableGold.copy(alpha = 0.2f)
|
||||
@@ -795,7 +841,7 @@ private fun DecisionControls(
|
||||
Text(
|
||||
"${preset.label} ${preset.amount}",
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 9.sp,
|
||||
fontSize = 11.sp,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
@@ -848,6 +894,25 @@ private fun DecisionControls(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FineTuneButton(
|
||||
label: String,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Button(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.size(44.dp),
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color.White.copy(alpha = 0.09f),
|
||||
contentColor = GoldLight,
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
) {
|
||||
Text(label, fontWeight = FontWeight.ExtraBold, fontSize = 24.sp)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActionButton(
|
||||
title: String,
|
||||
@@ -859,7 +924,7 @@ private fun ActionButton(
|
||||
) {
|
||||
Button(
|
||||
onClick = onClick,
|
||||
modifier = modifier.height(54.dp),
|
||||
modifier = modifier.height(60.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = container, contentColor = content),
|
||||
) {
|
||||
@@ -879,7 +944,7 @@ private fun ActionButton(
|
||||
Button(
|
||||
onClick = onClick,
|
||||
modifier = modifier
|
||||
.height(54.dp)
|
||||
.height(60.dp)
|
||||
.background(container, RoundedCornerShape(14.dp)),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
@@ -894,9 +959,9 @@ private fun ActionButton(
|
||||
@Composable
|
||||
private fun ActionButtonText(title: String, detail: String?, color: Color) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(title, color = color, fontWeight = FontWeight.ExtraBold, fontSize = 14.sp)
|
||||
Text(title, color = color, fontWeight = FontWeight.ExtraBold, fontSize = 16.sp)
|
||||
detail?.let {
|
||||
Text(it, color = color.copy(alpha = 0.68f), fontWeight = FontWeight.Bold, fontSize = 10.sp)
|
||||
Text(it, color = color.copy(alpha = 0.78f), fontWeight = FontWeight.Bold, fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -911,9 +976,9 @@ private fun ShowdownModal(summary: HandSummary) {
|
||||
shadowElevation = 14.dp,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 15.dp),
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(7.dp),
|
||||
) {
|
||||
Text(
|
||||
if (summary.wentToShowdown) "SHOWDOWN" else "HAND COMPLETE",
|
||||
@@ -949,23 +1014,43 @@ private fun ShowdownModal(summary: HandSummary) {
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
summary.board.forEach { card ->
|
||||
CardImage(card, Modifier.size(46.dp, 69.dp))
|
||||
CardImage(card, Modifier.size(50.dp, 75.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (summary.showdownPlayers.isNotEmpty()) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
"ALL SHOWDOWN HANDS",
|
||||
color = Muted.copy(alpha = 0.9f),
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 10.sp,
|
||||
letterSpacing = 1.2.sp,
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
summary.awards.forEach { award ->
|
||||
PotAwardCard(award)
|
||||
summary.showdownPlayers.forEach { player ->
|
||||
ShowdownPlayerCard(player)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
summary.awards.forEach { award -> PotAwardCard(award) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -1014,70 +1099,91 @@ private fun CompletionControls(
|
||||
}
|
||||
|
||||
@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()
|
||||
private fun ShowdownPlayerCard(player: ShowdownPlayerSummary) {
|
||||
Card(
|
||||
modifier = Modifier.width(310.dp),
|
||||
modifier = Modifier.width(118.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (player.amountWon > 0) {
|
||||
TableGold.copy(alpha = 0.13f)
|
||||
} else {
|
||||
Color.White.copy(alpha = 0.06f)
|
||||
},
|
||||
),
|
||||
border = androidx.compose.foundation.BorderStroke(
|
||||
1.dp,
|
||||
if (player.amountWon > 0) TableGold.copy(alpha = 0.62f) else Color.White.copy(alpha = 0.1f),
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(7.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(3.dp),
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
player.hole.take(2).forEach { card ->
|
||||
CardImage(card, Modifier.size(48.dp, 72.dp))
|
||||
}
|
||||
}
|
||||
Text(
|
||||
player.label,
|
||||
color = Cream,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 13.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
player.handDescription,
|
||||
color = if (player.amountWon > 0) GoldLight else Cream.copy(alpha = 0.84f),
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 11.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
if (player.amountWon > 0) "WON ${player.amountWon}" else "SHOWED",
|
||||
color = if (player.amountWon > 0) TableGold else Muted,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 10.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PotAwardCard(award: PotAwardSummary) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
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),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 11.dp, vertical = 10.dp),
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
if (visibleCards.isNotEmpty()) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
visibleCards.take(2).forEach { card ->
|
||||
CardImage(card, Modifier.size(76.dp, 114.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
"${award.label.uppercase()} ${award.amount}",
|
||||
color = TableGold,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 11.sp,
|
||||
letterSpacing = 0.5.sp,
|
||||
letterSpacing = 0.4.sp,
|
||||
)
|
||||
Text(
|
||||
if (award.winners.size == 1) {
|
||||
award.winners.single().label
|
||||
} else {
|
||||
award.winners.joinToString(" • ") { "${it.label} ${it.amountWon}" }
|
||||
},
|
||||
award.winners.joinToString(" • ") { "${it.label} ${it.amountWon}" },
|
||||
modifier = Modifier.weight(1f),
|
||||
color = Cream,
|
||||
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,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 14.sp,
|
||||
fontSize = 12.sp,
|
||||
textAlign = TextAlign.End,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CoachBar(presentation: CoachPresentation) {
|
||||
|
||||
@@ -142,6 +142,16 @@ class ActionButtonsTest {
|
||||
assertEquals("Raise 60", raiseLabel(b, 60))
|
||||
assertEquals("All in", raiseLabel(b, 500))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fine tuning moves one chip and cannot leave the legal range`() {
|
||||
val b = buttonsFor(offer(minRaiseTo = 20, maxRaiseTo = 500))
|
||||
|
||||
assertEquals(59, adjustRaiseAmount(60, -1, b))
|
||||
assertEquals(61, adjustRaiseAmount(60, 1, b))
|
||||
assertEquals(20, adjustRaiseAmount(20, -1, b))
|
||||
assertEquals(500, adjustRaiseAmount(500, 1, b))
|
||||
}
|
||||
}
|
||||
|
||||
class CallCostTest {
|
||||
|
||||
@@ -146,6 +146,36 @@ class HandSummaryTest {
|
||||
assertEquals("Ada", summary.awards[1].winners.single().label)
|
||||
assertEquals(kings.toList(), summary.awards[1].winners.single().hole)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `showdown lists every public contender including hands that lost`() {
|
||||
val board = cardsOf("2c 7d 9s Jc 3h")
|
||||
val aces = cardsOf("Ah Ad")
|
||||
val kings = cardsOf("Kh Kd")
|
||||
val result = HandResult(
|
||||
board = board,
|
||||
net = intArrayOf(10, -10),
|
||||
wentToShowdown = true,
|
||||
potSize = 20,
|
||||
events = emptyList(),
|
||||
showdownHands = listOf(
|
||||
ShowdownHand(0, aces.toList(), HandEvaluator.evaluate(aces + board)),
|
||||
ShowdownHand(1, kings.toList(), HandEvaluator.evaluate(kings + board)),
|
||||
),
|
||||
potAwards = listOf(PotAward(20, listOf(PotPayout(0, 20)))),
|
||||
)
|
||||
|
||||
val summary = handSummaryFor(
|
||||
handNumber = 12,
|
||||
result = result,
|
||||
seatNames = listOf("Jay", "Ada"),
|
||||
)
|
||||
|
||||
assertEquals(listOf("You", "Ada"), summary.showdownPlayers.map { it.label })
|
||||
assertEquals(listOf(20, 0), summary.showdownPlayers.map { it.amountWon })
|
||||
assertEquals(listOf("Pair", "Pair"), summary.showdownPlayers.map { it.handDescription })
|
||||
assertEquals(kings.toList(), summary.showdownPlayers[1].hole)
|
||||
}
|
||||
}
|
||||
|
||||
class VisibleHandSummaryTest {
|
||||
@@ -158,6 +188,7 @@ class VisibleHandSummaryTest {
|
||||
potSize = 20,
|
||||
heroNet = -4,
|
||||
wentToShowdown = true,
|
||||
showdownPlayers = emptyList(),
|
||||
awards = emptyList(),
|
||||
)
|
||||
|
||||
|
||||
@@ -7,19 +7,29 @@ import org.junit.Test
|
||||
class TableOrbitTest {
|
||||
|
||||
@Test
|
||||
fun `five opponents form a symmetric upper ellipse above the board band`() {
|
||||
fun `five opponents form a symmetric perimeter around the circular felt`() {
|
||||
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 },
|
||||
"all opponents stay around the upper perimeter",
|
||||
positions.all { it.topY < 0.30f },
|
||||
)
|
||||
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)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `visible chips accumulate monotonically but remain bounded`() {
|
||||
val amounts = listOf(0, 1, 3, 7, 20, 50, 100, 500)
|
||||
val counts = amounts.map(::visibleChipCount)
|
||||
|
||||
assertEquals(0, counts.first())
|
||||
assertEquals(7, counts.last())
|
||||
assertTrue(counts.zipWithNext().all { (left, right) -> left <= right })
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user