Playable Android table

The game runs on device: verified on a Pixel 10 Pro emulator (Android 17) by
installing, tapping through a hand, and confirming it advanced pre-flop to flop
with correct pot, folds, and re-offered action.

App:
- :app module on AGP 9.2.1. Note AGP 9 has built-in Kotlin support, so applying
  org.jetbrains.kotlin.android conflicts with it ("extension with name 'kotlin'
  already registered"); only android.application + kotlin.compose are applied,
  matching recipeze.
- PokerViewModel runs a continuous cash game and publishes to Compose.
- Compose table: opponents, board, pot, hero, action bar with a raise slider.

Frames are queued, not conflated. An all-in runout emits flop, turn and river
microseconds apart; pushing those into a StateFlow would collapse them and the
board would jump from empty to complete. The engine's suspending observer sends
into a Channel, a consumer paces each frame, and only then is StateFlow updated
— so backpressure paces the engine rather than the UI dropping frames. Three
tests cover this, including a characterisation test showing a conflating
StateFlow does lose the intermediate frames.

Assets:
- tools/generate_card_assets.sh rasterises the SVGs into four density buckets
  using sips, which renders SVG directly — no librsvg or ImageMagick.
- Resource names are prefixed card_ because Android resource names may not start
  with a digit (10_of_clubs would be rejected).
- CardArt.kt maps deck index to drawable via static R references, so R8 resource
  shrinking cannot strip the artwork the way getIdentifier lookups would risk.

Layout fixes found by actually looking at the running app: five opponents did
not fit a fixed-width scrolling row (Enzo was off-screen), the header collided
with the status bar clock, and the board floated against a large dead space.

Tests: 52 -> 55, green on jvmTest and testAndroidHostTest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jay
2026-07-25 17:23:34 -04:00
parent 679b25e2c7
commit 950c7ceb57
234 changed files with 726 additions and 5 deletions
@@ -0,0 +1,260 @@
package com.jsjdesigns.poker
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.jsjdesigns.poker.game.SeatSnapshot
import com.jsjdesigns.poker.game.TableSnapshot
private val Felt = Color(0xFF0B3D26)
@Composable
fun TableScreen(vm: PokerViewModel) {
val state by vm.state.collectAsStateWithLifecycle()
val offer by vm.offer.collectAsStateWithLifecycle()
val snap = state.snapshot
Column(
modifier = Modifier
.fillMaxSize()
.background(Felt)
.systemBarsPadding()
.padding(12.dp),
) {
Text(
text = "Hand ${state.handsPlayed + 1} ${snap?.street ?: ""}",
color = Color.White.copy(alpha = 0.6f),
fontSize = 12.sp,
)
Spacer(Modifier.height(8.dp))
// Opponents
// Weighted rather than fixed-width: five opponents must all fit on a
// phone, and a horizontally scrolling table hides players from you.
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
snap?.seats?.filter { it.index != HERO_SEAT }?.forEach { seat ->
Box(Modifier.weight(1f)) {
OpponentSeat(seat, isTurn = snap.toAct == seat.index)
}
}
}
Spacer(Modifier.weight(1f))
// Board and pot
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = "POT ${snap?.pot ?: 0}",
color = Color(0xFFE3C179),
fontWeight = FontWeight.Bold,
fontSize = 18.sp,
)
Spacer(Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
val board = snap?.board.orEmpty()
repeat(5) { i ->
if (i < board.size) {
CardImage(board[i], Modifier.size(54.dp, 81.dp))
} else {
Box(
Modifier
.size(54.dp, 81.dp)
.clip(RoundedCornerShape(4.dp))
.background(Color.White.copy(alpha = 0.06f)),
)
}
}
}
}
Spacer(Modifier.weight(1f))
// Hero
val hero = snap?.seats?.firstOrNull { it.index == HERO_SEAT }
HeroSeat(hero, isTurn = snap?.toAct == HERO_SEAT)
Spacer(Modifier.height(12.dp))
ActionBar(
offer = offer,
onFold = vm::fold,
onCheckCall = vm::checkOrCall,
onRaise = vm::raiseTo,
)
}
}
@Composable
private fun OpponentSeat(seat: SeatSnapshot, isTurn: Boolean) {
Card(
colors = CardDefaults.cardColors(
containerColor = if (isTurn) Color(0xFF1D6B45) else Color.White.copy(alpha = 0.07f),
),
modifier = Modifier.fillMaxWidth(),
) {
Column(
modifier = Modifier.padding(6.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) {
if (seat.folded) {
Box(Modifier.size(24.dp, 36.dp))
} else {
val hole = seat.hole
repeat(2) { i ->
CardImage(hole?.getOrNull(i), Modifier.size(24.dp, 36.dp))
}
}
}
Spacer(Modifier.height(4.dp))
Text(
seat.name + if (seat.isButton) "" else "",
color = Color.White,
fontSize = 11.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.alpha(if (seat.folded) 0.4f else 1f),
)
Text(
if (seat.allIn) "ALL IN" else "${seat.stack}",
color = if (seat.allIn) Color(0xFFE3C179) else Color.White.copy(alpha = 0.7f),
fontSize = 11.sp,
)
if (seat.committedThisRound > 0) {
Text(
"bet ${seat.committedThisRound}",
color = Color(0xFFE3C179),
fontSize = 10.sp,
)
}
}
}
}
@Composable
private fun HeroSeat(seat: SeatSnapshot?, isTurn: Boolean) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
repeat(2) { i ->
CardImage(seat?.hole?.getOrNull(i), Modifier.size(64.dp, 96.dp))
}
}
Column {
Text(
(seat?.name ?: "You") + if (seat?.isButton == true) "" else "",
color = if (isTurn) Color(0xFFE3C179) else Color.White,
fontWeight = FontWeight.Bold,
)
Text("${seat?.stack ?: 0}", color = Color.White.copy(alpha = 0.75f))
if ((seat?.committedThisRound ?: 0) > 0) {
Text("bet ${seat?.committedThisRound}", color = Color(0xFFE3C179), fontSize = 12.sp)
}
}
}
}
@Composable
private fun ActionBar(
offer: com.jsjdesigns.poker.game.DecisionOffer?,
onFold: () -> Unit,
onCheckCall: () -> Unit,
onRaise: (Int) -> Unit,
) {
if (offer == null) {
Box(Modifier.fillMaxWidth().height(96.dp), contentAlignment = Alignment.Center) {
Text("Waiting…", color = Color.White.copy(alpha = 0.4f))
}
return
}
var raiseTo by remember { mutableIntStateOf(offer.minRaiseTo) }
// Reset the slider whenever a new decision arrives, or it keeps the old hand's value.
LaunchedEffect(offer) { raiseTo = offer.minRaiseTo }
Column(Modifier.fillMaxWidth()) {
if (offer.canRaise && offer.maxRaiseTo > offer.minRaiseTo) {
Text("Raise to $raiseTo", color = Color.White, fontSize = 13.sp)
Slider(
value = raiseTo.toFloat(),
onValueChange = { raiseTo = it.toInt() },
valueRange = offer.minRaiseTo.toFloat()..offer.maxRaiseTo.toFloat(),
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Button(
onClick = onFold,
modifier = Modifier.weight(1f),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF8F1218)),
) { Text("Fold") }
Button(
onClick = onCheckCall,
modifier = Modifier.weight(1f),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2C6E49)),
) { Text(if (offer.canCheck) "Check" else "Call ${offer.toCall}") }
if (offer.canRaise && offer.maxRaiseTo > offer.minRaiseTo) {
Button(
onClick = { onRaise(raiseTo) },
modifier = Modifier.weight(1f),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF14357A)),
) { Text(if (raiseTo >= offer.maxRaiseTo) "All in" else "Raise") }
}
}
}
}
@Composable
private fun CardImage(index: Int?, modifier: Modifier) {
Image(
painter = painterResource(if (index != null) cardFace(index) else cardBack()),
contentDescription = null,
modifier = modifier.clip(RoundedCornerShape(4.dp)),
)
}