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:
@@ -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)),
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user