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>
@@ -9,8 +9,10 @@ No `java`/`gradle` on PATH — use Android Studio's bundled JDK:
|
||||
|
||||
```bash
|
||||
export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"
|
||||
./gradlew :engine:jvmTest # evaluator + engine tests
|
||||
./gradlew :sim:run --args="50000" # simulate 50k hands, print bot stats
|
||||
./gradlew :engine:jvmTest # engine tests (JVM)
|
||||
./gradlew :engine:testAndroidHostTest # same suite, Android variant
|
||||
./gradlew :sim:run --args="50000" # simulate 50k hands, print bot stats
|
||||
./gradlew :app:assembleDebug # build the APK
|
||||
```
|
||||
|
||||
## Layout
|
||||
@@ -21,7 +23,9 @@ export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"
|
||||
| `engine/src/commonMain/.../bot/` | Skill/style profiles, `MathBot` |
|
||||
| `engine/src/commonMain/.../game/` | `Table` — betting rounds, side pots, showdown |
|
||||
| `sim/` | JVM-only headless simulator used to **tune** bot profiles |
|
||||
| `assets/cards/` | 52 CC0 card faces + generated backs |
|
||||
| `app/` | Android app: Compose table, `PokerViewModel` |
|
||||
| `assets/cards/` | 52 CC0 card faces + generated backs (**source of truth**) |
|
||||
| `tools/generate_card_assets.sh` | Rasterises those SVGs into `app/.../drawable-*` |
|
||||
|
||||
`engine` is pure Kotlin with no platform APIs, so `androidTarget()` /
|
||||
`iosArm64()` slot in without touching `commonMain`.
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.jsjdesigns.poker"
|
||||
compileSdk = 37
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.jsjdesigns.poker"
|
||||
minSdk = 26
|
||||
targetSdk = 37
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release { isMinifyEnabled = false }
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
}
|
||||
|
||||
buildFeatures { compose = true }
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":engine"))
|
||||
implementation(libs.kotlinx.coroutines.android)
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.compose.ui)
|
||||
implementation(libs.androidx.compose.ui.graphics)
|
||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||
implementation(libs.androidx.compose.material3)
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Poker">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:screenOrientation="portrait"
|
||||
android:configChanges="orientation|screenSize|keyboardHidden">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.jsjdesigns.poker
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
|
||||
/**
|
||||
* Maps a deck index (0..51) to its drawable.
|
||||
*
|
||||
* Generated by tools/generate_card_assets.sh's companion step — regenerate rather
|
||||
* than hand-editing. References are static so R8 resource shrinking cannot strip
|
||||
* the artwork, which `getIdentifier` lookups would risk.
|
||||
*
|
||||
* Order matches [com.jsjdesigns.poker.core.Card]: index = (rank - 2) * 4 + suit,
|
||||
* suits ordered clubs, diamonds, hearts, spades.
|
||||
*/
|
||||
private val CARD_FACES = intArrayOf(
|
||||
R.drawable.card_2_of_clubs, // 0
|
||||
R.drawable.card_2_of_diamonds, // 1
|
||||
R.drawable.card_2_of_hearts, // 2
|
||||
R.drawable.card_2_of_spades, // 3
|
||||
R.drawable.card_3_of_clubs, // 4
|
||||
R.drawable.card_3_of_diamonds, // 5
|
||||
R.drawable.card_3_of_hearts, // 6
|
||||
R.drawable.card_3_of_spades, // 7
|
||||
R.drawable.card_4_of_clubs, // 8
|
||||
R.drawable.card_4_of_diamonds, // 9
|
||||
R.drawable.card_4_of_hearts, // 10
|
||||
R.drawable.card_4_of_spades, // 11
|
||||
R.drawable.card_5_of_clubs, // 12
|
||||
R.drawable.card_5_of_diamonds, // 13
|
||||
R.drawable.card_5_of_hearts, // 14
|
||||
R.drawable.card_5_of_spades, // 15
|
||||
R.drawable.card_6_of_clubs, // 16
|
||||
R.drawable.card_6_of_diamonds, // 17
|
||||
R.drawable.card_6_of_hearts, // 18
|
||||
R.drawable.card_6_of_spades, // 19
|
||||
R.drawable.card_7_of_clubs, // 20
|
||||
R.drawable.card_7_of_diamonds, // 21
|
||||
R.drawable.card_7_of_hearts, // 22
|
||||
R.drawable.card_7_of_spades, // 23
|
||||
R.drawable.card_8_of_clubs, // 24
|
||||
R.drawable.card_8_of_diamonds, // 25
|
||||
R.drawable.card_8_of_hearts, // 26
|
||||
R.drawable.card_8_of_spades, // 27
|
||||
R.drawable.card_9_of_clubs, // 28
|
||||
R.drawable.card_9_of_diamonds, // 29
|
||||
R.drawable.card_9_of_hearts, // 30
|
||||
R.drawable.card_9_of_spades, // 31
|
||||
R.drawable.card_10_of_clubs, // 32
|
||||
R.drawable.card_10_of_diamonds, // 33
|
||||
R.drawable.card_10_of_hearts, // 34
|
||||
R.drawable.card_10_of_spades, // 35
|
||||
R.drawable.card_jack_of_clubs, // 36
|
||||
R.drawable.card_jack_of_diamonds, // 37
|
||||
R.drawable.card_jack_of_hearts, // 38
|
||||
R.drawable.card_jack_of_spades, // 39
|
||||
R.drawable.card_queen_of_clubs, // 40
|
||||
R.drawable.card_queen_of_diamonds, // 41
|
||||
R.drawable.card_queen_of_hearts, // 42
|
||||
R.drawable.card_queen_of_spades, // 43
|
||||
R.drawable.card_king_of_clubs, // 44
|
||||
R.drawable.card_king_of_diamonds, // 45
|
||||
R.drawable.card_king_of_hearts, // 46
|
||||
R.drawable.card_king_of_spades, // 47
|
||||
R.drawable.card_ace_of_clubs, // 48
|
||||
R.drawable.card_ace_of_diamonds, // 49
|
||||
R.drawable.card_ace_of_hearts, // 50
|
||||
R.drawable.card_ace_of_spades, // 51
|
||||
)
|
||||
|
||||
@DrawableRes
|
||||
fun cardFace(index: Int): Int = CARD_FACES[index]
|
||||
|
||||
@DrawableRes
|
||||
fun cardBack(): Int = R.drawable.card_back
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.jsjdesigns.poker
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContent {
|
||||
MaterialTheme(colorScheme = darkColorScheme()) {
|
||||
val vm: PokerViewModel = viewModel()
|
||||
TableScreen(vm)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package com.jsjdesigns.poker
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.jsjdesigns.poker.bot.BotProfile
|
||||
import com.jsjdesigns.poker.bot.MathBot
|
||||
import com.jsjdesigns.poker.bot.PlayStyle
|
||||
import com.jsjdesigns.poker.bot.SkillLevel
|
||||
import com.jsjdesigns.poker.game.Action
|
||||
import com.jsjdesigns.poker.game.ActionType
|
||||
import com.jsjdesigns.poker.game.HumanAgent
|
||||
import com.jsjdesigns.poker.game.Seat
|
||||
import com.jsjdesigns.poker.game.Table
|
||||
import com.jsjdesigns.poker.game.TableSnapshot
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.random.Random
|
||||
|
||||
const val HERO_SEAT = 0
|
||||
private const val STARTING_STACK = 200
|
||||
private const val SMALL_BLIND = 1
|
||||
private const val BIG_BLIND = 2
|
||||
|
||||
data class UiState(
|
||||
val snapshot: TableSnapshot? = null,
|
||||
val handsPlayed: Int = 0,
|
||||
val message: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Drives a continuous cash game and publishes it to Compose.
|
||||
*
|
||||
* **Frames are queued, not conflated.** The engine can emit the flop, turn and
|
||||
* river of an all-in runout within microseconds of each other. Pushing those
|
||||
* straight into a `StateFlow` would collapse them — `StateFlow` keeps only the
|
||||
* latest value — and the board would appear to jump from empty to complete.
|
||||
* Instead the engine's suspending observer sends into a [Channel], a consumer
|
||||
* paces each frame, and only then is `StateFlow` updated. Because the observer
|
||||
* suspends when the channel is full, the engine cannot outrun the animation:
|
||||
* backpressure does the pacing for us.
|
||||
*/
|
||||
class PokerViewModel : ViewModel() {
|
||||
|
||||
private val human = HumanAgent()
|
||||
private val frames = Channel<TableSnapshot>(capacity = 32)
|
||||
|
||||
private val _state = MutableStateFlow(UiState())
|
||||
val state: StateFlow<UiState> = _state.asStateFlow()
|
||||
|
||||
/** What the player is being asked to decide, or null when it isn't their turn. */
|
||||
val offer = human.offer
|
||||
|
||||
private val roster = listOf(
|
||||
BotProfile("You", SkillLevel.EXPERT, PlayStyle.TIGHT_AGGRESSIVE),
|
||||
BotProfile("Ada", SkillLevel.EXPERT, PlayStyle.TIGHT_AGGRESSIVE, "Ice-cold. Punishes mistakes."),
|
||||
BotProfile("Bruno", SkillLevel.ADVANCED, PlayStyle.LOOSE_AGGRESSIVE, "Relentless pressure."),
|
||||
BotProfile("Cleo", SkillLevel.INTERMEDIATE, PlayStyle.TRAPPER, "Quiet until she has you."),
|
||||
BotProfile("Dex", SkillLevel.INTERMEDIATE, PlayStyle.CALLING_STATION, "Pays to see it."),
|
||||
BotProfile("Enzo", SkillLevel.BEGINNER, PlayStyle.MANIAC, "Chaos, and certain he's winning."),
|
||||
)
|
||||
|
||||
private val seats: List<Seat>
|
||||
private val table: Table
|
||||
|
||||
init {
|
||||
val deckRandom = Random(System.nanoTime())
|
||||
seats = roster.mapIndexed { i, profile ->
|
||||
val agent = if (i == HERO_SEAT) human else MathBot(profile, Random(deckRandom.nextLong()))
|
||||
Seat(i, profile.name, STARTING_STACK, agent)
|
||||
}
|
||||
table = Table(
|
||||
seats = seats,
|
||||
smallBlind = SMALL_BLIND,
|
||||
bigBlind = BIG_BLIND,
|
||||
random = deckRandom,
|
||||
observer = { frames.send(it) },
|
||||
)
|
||||
|
||||
viewModelScope.launch { consumeFrames() }
|
||||
viewModelScope.launch(Dispatchers.Default) { playContinuously() }
|
||||
}
|
||||
|
||||
private suspend fun consumeFrames() {
|
||||
for (frame in frames) {
|
||||
_state.value = _state.value.copy(snapshot = frame.maskedFor(HERO_SEAT))
|
||||
delay(pacingMillis(frame))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How long to hold a frame on screen. Zero when the player is on the clock —
|
||||
* never make someone wait to act — and longest for cards landing and for the
|
||||
* showdown, which are the moments worth watching.
|
||||
*/
|
||||
private fun pacingMillis(frame: TableSnapshot): Long = when (frame.phase) {
|
||||
TableSnapshot.Phase.DEALT -> 400
|
||||
TableSnapshot.Phase.BETTING ->
|
||||
if (frame.toAct == HERO_SEAT) 0 else if (frame.toAct != null) 250 else 450
|
||||
TableSnapshot.Phase.STREET_COMPLETE -> 700
|
||||
TableSnapshot.Phase.SHOWDOWN -> 2200
|
||||
TableSnapshot.Phase.COMPLETE -> 1200
|
||||
}
|
||||
|
||||
private suspend fun playContinuously() {
|
||||
while (true) {
|
||||
// Cash-game convention: top anyone back up who cannot cover a blind.
|
||||
for (s in seats) if (s.stack < BIG_BLIND) s.stack = STARTING_STACK
|
||||
table.advanceButton()
|
||||
runCatching { table.playHand() }
|
||||
.onSuccess { _state.value = _state.value.copy(handsPlayed = _state.value.handsPlayed + 1) }
|
||||
.onFailure { return } // scope cancelled: the screen went away
|
||||
}
|
||||
}
|
||||
|
||||
fun submit(action: Action) {
|
||||
viewModelScope.launch { human.submit(action) }
|
||||
}
|
||||
|
||||
fun fold() = submit(Action(ActionType.FOLD))
|
||||
|
||||
fun checkOrCall() {
|
||||
val o = offer.value ?: return
|
||||
submit(if (o.canCheck) Action(ActionType.CHECK) else Action(ActionType.CALL, o.toCall))
|
||||
}
|
||||
|
||||
fun raiseTo(amount: Int) = submit(Action(ActionType.RAISE, amount))
|
||||
|
||||
override fun onCleared() {
|
||||
// Release a hand parked on human input so the coroutine can finish.
|
||||
viewModelScope.launch { human.cancel() }
|
||||
frames.close()
|
||||
super.onCleared()
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
)
|
||||
}
|
||||
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 1.9 KiB |