Screens for Yahtzee and Klondike, and both into the catalogue

Two engines that had been sitting behind ids now have screens, so they are
tappable and playable.

The Yahtzee card shows what the dice on the table would score in every box
still open. That comparison is the whole of the decision, and doing the
arithmetic by eye is not what anybody wants from a phone. A turn is two choices
and the screen shows them together: which dice to keep before throwing again,
and which box to write in when the throwing stops.

Klondike plays tap-to-take then tap-to-put rather than by dragging. A drag
across seven columns of overlapping cards on a phone is mostly a test of aim.
Tapping a face-up card takes it and everything resting on it, so a run travels
the way it would on a table. Which taps do anything is read off the legal moves
the rules offer, so the screen never has to know why a move is allowed.

Klondike deals again by rebuilding the controller, as peg solitaire does: a
blocked deal is genuinely finished, so driveIdleSeats has already stopped and
advanceIdle would never run.

Yahtzee seats one to six, so it needed a case in seatCountFor. Without one the
seat count falls through to the catalogue minimum, which is one now that solo
play exists -- a two-handed game would have been dealt a single seat and the
rules would have refused the table.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Urn6eEBA6XzrnfoibL7faC
This commit is contained in:
Claude
2026-08-08 15:39:04 +00:00
parent 3dbc4b5557
commit 831b2fc925
6 changed files with 705 additions and 10 deletions
@@ -35,19 +35,9 @@ object GameCatalog {
const val CRAZY_EIGHTS = "crazy8s"
const val CRIBBAGE = "cribbage"
/**
* Hearts has a rules engine and an AI but no screen yet, so it is an id
* without a [GameDescriptor]: nothing to tap, and nothing to crash. It joins
* [all] when its screen lands.
*/
const val HEARTS = "hearts"
const val PEG_SOLITAIRE = "pegsolitaire"
/**
* An id ahead of its screen: the engine is written and tested, but a
* catalogue entry with nothing behind it would crash on the first tap, so it
* joins [all] when its screen lands.
*/
const val YAHTZEE = "yahtzee"
const val KLONDIKE = "klondike"
const val SEQUENCE = "sequence"
@@ -143,6 +133,17 @@ object GameCatalog {
"of spades costs thirteen, and taking all of them costs everybody else.",
available = true,
),
GameDescriptor(
id = KLONDIKE,
title = "Klondike",
category = GameCategory.CARD,
minPlayers = 1,
maxPlayers = 1,
teamBased = false,
blurb = "Patience, as everyone means it. Seven columns down to the aces, " +
"and a pack that decides most of it before you start.",
available = true,
),
GameDescriptor(
id = SEQUENCE,
title = "Sequence",
@@ -217,6 +218,17 @@ object GameCatalog {
"take the one you passed, and try to finish with one standing.",
available = true,
),
GameDescriptor(
id = YAHTZEE,
title = "Yahtzee",
category = GameCategory.BOARD,
minPlayers = 1,
maxPlayers = 6,
teamBased = false,
blurb = "Five dice, three throws, thirteen boxes to fill. Every box can be " +
"written in once, so the hard part is what to give up on.",
available = true,
),
GameDescriptor(
id = PIRATES,
title = "Pirates and Bulgars",
@@ -41,6 +41,8 @@ import org.prolibertate.games.game.cribbage.CribbageOptions
import org.prolibertate.games.game.euchre.EuchreOptions
import org.prolibertate.games.game.golf.GolfOptions
import org.prolibertate.games.game.hearts.HeartsOptions
import org.prolibertate.games.game.klondike.KlondikeOptions
import org.prolibertate.games.game.yahtzee.YahtzeeOptions
import org.prolibertate.games.game.pegsolitaire.PegBoard
import org.prolibertate.games.game.pegsolitaire.PegGoal
import org.prolibertate.games.game.pegsolitaire.PegSolitaireOptions
@@ -94,6 +96,8 @@ fun GameSetupScreen(
var pirates by remember { mutableStateOf(PiratesOptions()) }
var hearts by remember { mutableStateOf(HeartsOptions()) }
var pegs by remember { mutableStateOf(PegSolitaireOptions()) }
var yahtzee by remember { mutableStateOf(YahtzeeOptions()) }
var klondike by remember { mutableStateOf(KlondikeOptions()) }
// Not an engine option: which seat the local player takes. Seat 0 is
// always White, so choosing Black means sitting in seat 1.
var playWhite by remember { mutableStateOf(true) }
@@ -119,6 +123,8 @@ fun GameSetupScreen(
GameCatalog.PIRATES -> setupJson.encodeToString(pirates)
GameCatalog.HEARTS -> setupJson.encodeToString(hearts)
GameCatalog.PEG_SOLITAIRE -> setupJson.encodeToString(pegs)
GameCatalog.YAHTZEE -> setupJson.encodeToString(yahtzee)
GameCatalog.KLONDIKE -> setupJson.encodeToString(klondike)
else -> "{}"
}
val seatCount = seatCountFor(descriptor.id, optionsJson)
@@ -174,6 +180,10 @@ fun GameSetupScreen(
GameCatalog.PEG_SOLITAIRE -> PegSolitaireOptionsEditor(pegs) { pegs = it }
GameCatalog.YAHTZEE -> YahtzeeOptionsEditor(yahtzee) { yahtzee = it }
GameCatalog.KLONDIKE -> KlondikeOptionsEditor(klondike) { klondike = it }
else -> Text("No options yet for this game.")
}
@@ -272,6 +282,9 @@ fun seatCountFor(gameId: String, optionsJson: String): Int {
GameCatalog.CHESS, GameCatalog.MORRIS, GameCatalog.CHECKERS,
GameCatalog.BACKGAMMON, GameCatalog.MASTERMIND, GameCatalog.PIRATES -> 2
GameCatalog.YAHTZEE ->
setupJson.decodeFromString<YahtzeeOptions>(optionsJson).playerCount
GameCatalog.TAYU ->
setupJson.decodeFromString<TayuOptions>(optionsJson).playerCount
@@ -1012,3 +1025,56 @@ private fun PegSolitaireOptionsEditor(
)
}
}
@Composable
private fun YahtzeeOptionsEditor(options: YahtzeeOptions, onChange: (YahtzeeOptions) -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
ChipRow(
label = "Players",
values = (1..6).toList(),
selected = options.playerCount,
display = { if (it == 1) "On your own" else "$it" },
onSelect = { onChange(options.copy(playerCount = it)) },
)
ToggleRow(
title = "Second Yahtzee scores a hundred",
subtitle = "Only once the Yahtzee box holds the fifty. A zero written there " +
"earlier forfeits it, as the printed rule has it.",
checked = options.yahtzeeBonus,
onChange = { onChange(options.copy(yahtzeeBonus = it)) },
)
}
}
@Composable
private fun KlondikeOptionsEditor(options: KlondikeOptions, onChange: (KlondikeOptions) -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
ChipRow(
label = "Turn from the stock",
values = listOf(1, 3),
selected = options.drawCount,
display = { "$it at a time" },
onSelect = { onChange(options.copy(drawCount = it)) },
)
ChipRow(
label = "Turning the waste back over",
values = listOf(null, 0, 1, 2),
selected = options.redealLimit,
display = {
when (it) {
null -> "As often as you like"
0 -> "Never — one pass"
else -> "$it times"
}
},
onSelect = { onChange(options.copy(redealLimit = it)) },
)
ToggleRow(
title = "Only a king fills a space",
subtitle = "Turning this off lets any card into an empty column, which makes " +
"far more deals winnable.",
checked = options.kingsOnlyInSpaces,
onChange = { onChange(options.copy(kingsOnlyInSpaces = it)) },
)
}
}
@@ -29,6 +29,14 @@ import org.prolibertate.games.game.euchre.EuchreMove
import org.prolibertate.games.game.euchre.EuchrePhase
import org.prolibertate.games.game.euchre.EuchreRules
import org.prolibertate.games.game.euchre.EuchreState
import org.prolibertate.games.game.klondike.KlondikeAi
import org.prolibertate.games.game.klondike.KlondikeMove
import org.prolibertate.games.game.klondike.KlondikeRules
import org.prolibertate.games.game.klondike.KlondikeState
import org.prolibertate.games.game.yahtzee.YahtzeeAi
import org.prolibertate.games.game.yahtzee.YahtzeeMove
import org.prolibertate.games.game.yahtzee.YahtzeeRules
import org.prolibertate.games.game.yahtzee.YahtzeeState
import org.prolibertate.games.game.hearts.HeartsAi
import org.prolibertate.games.game.hearts.HeartsMove
import org.prolibertate.games.game.hearts.HeartsPhase
@@ -94,12 +102,14 @@ import org.prolibertate.games.ui.game.ChessScreen
import org.prolibertate.games.ui.game.CrazyEightsScreen
import org.prolibertate.games.ui.game.CribbageScreen
import org.prolibertate.games.ui.game.HeartsScreen
import org.prolibertate.games.ui.game.KlondikeScreen
import org.prolibertate.games.ui.game.EuchreScreen
import org.prolibertate.games.ui.game.GolfScreen
import org.prolibertate.games.ui.game.KaiserScreen
import org.prolibertate.games.ui.game.MastermindScreen
import org.prolibertate.games.ui.game.MorrisScreen
import org.prolibertate.games.ui.game.PegSolitaireScreen
import org.prolibertate.games.ui.game.YahtzeeScreen
import org.prolibertate.games.ui.game.PiratesScreen
import org.prolibertate.games.ui.game.PresidentScreen
import org.prolibertate.games.ui.game.TRICK_HOLD_MILLIS
@@ -505,6 +515,50 @@ fun PlayScreen(
)
}
GameCatalog.YAHTZEE -> {
val controller = remember(route) {
MatchController<YahtzeeState, YahtzeeMove>(
rules = YahtzeeRules,
ai = YahtzeeAi,
config = route.config,
scope = scope,
role = role,
localSeats = setOf(route.localSeat),
primarySeat = route.localSeat,
// Long enough to watch the dice land, short enough that a
// six-handed game is not an evening of waiting.
aiThinkingMillis = { settings.scaled(500L) },
)
}
StartMatch(env, controller, route)
YahtzeeScreen(controller = controller, localSeat = route.localSeat, onExit = onExit)
}
GameCatalog.KLONDIKE -> {
// Dealing again rebuilds the controller, for the same reason peg
// solitaire does: a blocked deal is genuinely finished, so
// driveIdleSeats has already stopped and advanceIdle never runs.
var generation by remember(route) { mutableIntStateOf(0) }
val controller = remember(route, generation) {
MatchController<KlondikeState, KlondikeMove>(
rules = KlondikeRules,
// Only ever asked for a hint; the seat is the player's.
ai = KlondikeAi,
config = route.config,
scope = scope,
role = role,
localSeats = setOf(route.localSeat),
primarySeat = route.localSeat,
)
}
StartMatch(env, controller, route)
KlondikeScreen(
controller = controller,
onRestart = { generation++ },
onExit = onExit,
)
}
GameCatalog.MORRIS -> {
val controller = remember(route) {
MatchController<MorrisState, MorrisMove>(
@@ -0,0 +1,257 @@
package org.prolibertate.games.ui.game
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
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.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import org.prolibertate.games.game.klondike.Draw
import org.prolibertate.games.game.klondike.KlondikeMove
import org.prolibertate.games.game.klondike.KlondikeState
import org.prolibertate.games.game.klondike.MoveCards
import org.prolibertate.games.game.klondike.Redeal
import org.prolibertate.games.game.klondike.Spot
import org.prolibertate.games.game.klondike.foundationSuit
import org.prolibertate.games.net.MatchController
import org.prolibertate.games.ui.ScreenScaffold
import org.prolibertate.games.ui.common.CardBackView
import org.prolibertate.games.ui.common.PlayingCardView
import org.prolibertate.games.ui.theme.FeltGreenDark
import org.prolibertate.games.ui.theme.Parchment
import org.prolibertate.games.ui.theme.WallaceGold
private const val TITLE = "Klondike"
private val CARD_WIDTH: Dp = 40.dp
/**
* The patience table.
*
* Play is tap-to-take then tap-to-put, rather than dragging: on a phone a drag
* over seven columns of overlapping cards is mostly a test of aim. Tapping a
* face-up card takes it and everything sitting on it, so a run travels the way
* it would on a table.
*
* Which taps do anything is read straight off the legal moves the rules offer,
* so the screen never has to know why a move is allowed — only that it is.
*/
@Composable
fun KlondikeScreen(
controller: MatchController<KlondikeState, KlondikeMove>,
onRestart: () -> Unit,
onExit: () -> Unit,
) {
val state by controller.state.collectAsState()
val legal by controller.legalMoves.collectAsState()
val abandoned by controller.abandoned.collectAsState()
var held by remember { mutableStateOf<Pair<Spot, Int>?>(null) }
var confirmingEnd by remember { mutableStateOf(false) }
val endGameAction: @Composable () -> Unit = {
TextButton(onClick = { confirmingEnd = true }) { Text("End game") }
}
val current = state
if (current == null) {
ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier ->
Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") }
abandoned?.let { LeftTableDialog(it, onExit, controller::dismissAbandoned) }
if (confirmingEnd) EndTableDialog(onExit) { confirmingEnd = false }
}
return
}
val moves = legal.filterIsInstance<MoveCards>()
val canDraw = legal.contains(Draw)
val canRedeal = legal.contains(Redeal)
val finished = legal.isEmpty()
// Where the cards in hand could go, and which spots could be picked up.
val targets = held?.let { (from, count) ->
moves.filter { it.from == from && it.count == count }.map { it.to }.toSet()
}.orEmpty()
val sources = moves.map { it.from to it.count }.toSet()
fun take(spot: Spot, count: Int) {
val inHand = held
held = when {
inHand != null && targets.contains(spot) -> {
controller.submit(MoveCards(inHand.first, spot, inHand.second))
null
}
inHand?.first == spot -> null
sources.contains(spot to count) -> spot to count
else -> null
}
}
ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier ->
Column(
modifier = modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = when {
current.isWon -> "Out, in ${current.moves} moves."
finished -> "Blocked — nothing left to try."
held != null -> "Tap where it goes."
else -> "${current.foundations.sumOf { it.size }} of 52 home."
},
fontWeight = FontWeight.Bold,
)
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
// Stock, waste, and the four homes across the top.
Slot(
highlighted = false,
onClick = { if (canDraw) controller.submit(Draw) else if (canRedeal) controller.submit(Redeal) },
) {
if (current.stock.isNotEmpty()) {
CardBackView(width = CARD_WIDTH)
} else {
Empty(label = if (canRedeal) "↻" else "")
}
}
Slot(
highlighted = held?.first == Spot.waste,
onClick = { take(Spot.waste, 1) },
) {
current.waste.lastOrNull()
?.let { PlayingCardView(card = it, width = CARD_WIDTH) }
?: Empty()
}
current.foundations.forEachIndexed { index, pile ->
Slot(
highlighted = targets.contains(Spot.foundation(index)),
onClick = { take(Spot.foundation(index), 1) },
) {
pile.lastOrNull()
?.let { PlayingCardView(card = it, width = CARD_WIDTH) }
?: Empty(label = foundationSuit(index).symbol)
}
}
}
Row(
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
current.tableau.forEachIndexed { index, pile ->
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
if (pile.isEmpty) {
Slot(
highlighted = targets.contains(Spot.tableau(index)),
onClick = { take(Spot.tableau(index), 1) },
) { Empty() }
}
pile.faceDown.forEach { CardBackView(width = CARD_WIDTH) }
pile.faceUp.forEachIndexed { depth, card ->
// Taking a card takes everything resting on it, so
// the count is however many are below it in the run.
val count = pile.faceUp.size - depth
Slot(
highlighted = held == (Spot.tableau(index) to count) ||
(depth == pile.faceUp.lastIndex && targets.contains(Spot.tableau(index))),
onClick = { take(Spot.tableau(index), count) },
) { PlayingCardView(card = card, width = CARD_WIDTH) }
}
}
}
}
if (finished) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = onRestart) { Text("Deal again") }
TextButton(onClick = onExit) { Text("Leave") }
}
}
current.log.takeLast(2).forEach {
Text(it, style = MaterialTheme.typography.bodySmall)
}
}
abandoned?.let { LeftTableDialog(it, onExit, controller::dismissAbandoned) }
if (confirmingEnd) EndTableDialog(onExit) { confirmingEnd = false }
}
}
@Composable
private fun Slot(highlighted: Boolean, onClick: () -> Unit, content: @Composable () -> Unit) {
Box(
modifier = Modifier
.width(CARD_WIDTH)
.border(
border = BorderStroke(if (highlighted) 3.dp else 0.dp, WallaceGold),
shape = RoundedCornerShape(4.dp),
)
.clickable { onClick() },
) {
content()
}
}
/** An empty place on the table, drawn so it can still be tapped into. */
@Composable
private fun Empty(label: String = "") {
Box(
modifier = Modifier
.width(CARD_WIDTH)
.aspectRatio(0.7f)
.background(FeltGreenDark, RoundedCornerShape(4.dp)),
contentAlignment = Alignment.Center,
) {
if (label.isNotEmpty()) {
Text(label, color = Parchment.copy(alpha = 0.5f))
}
}
}
@Composable
private fun EndTableDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
AlertDialog(
onDismissRequest = onKeepPlaying,
title = { Text("End this game?") },
text = { Text("The deal is left as it stands.") },
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
)
}
@Composable
private fun LeftTableDialog(notice: String, onExit: () -> Unit, onStay: () -> Unit) {
AlertDialog(
onDismissRequest = onStay,
title = { Text("The game has ended") },
text = { Text(notice) },
confirmButton = { TextButton(onClick = onExit) { Text("Leave the game") } },
dismissButton = { TextButton(onClick = onStay) { Text("Look at the table") } },
)
}
@@ -0,0 +1,304 @@
package org.prolibertate.games.ui.game
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
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.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Divider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import org.prolibertate.games.game.yahtzee.DICE_COUNT
import org.prolibertate.games.game.yahtzee.RollDice
import org.prolibertate.games.game.yahtzee.ScoreIn
import org.prolibertate.games.game.yahtzee.YahtzeeCategory
import org.prolibertate.games.game.yahtzee.YahtzeeMove
import org.prolibertate.games.game.yahtzee.YahtzeeSection
import org.prolibertate.games.game.yahtzee.YahtzeeState
import org.prolibertate.games.game.yahtzee.scoreOf
import org.prolibertate.games.net.MatchController
import org.prolibertate.games.ui.ScreenScaffold
import org.prolibertate.games.ui.theme.Parchment
import org.prolibertate.games.ui.theme.WallaceGold
import org.prolibertate.games.ui.theme.WallaceRed
private const val TITLE = "Yahtzee"
/**
* The Yahtzee card.
*
* A turn is two decisions and the screen shows them together: which dice to keep
* before throwing again, and which box to write in when the throwing stops. Each
* open box shows what the dice on the table would score in it, because that
* comparison is the whole of the choice — and doing the arithmetic by eye is not
* what anyone wants from a phone.
*/
@Composable
fun YahtzeeScreen(
controller: MatchController<YahtzeeState, YahtzeeMove>,
localSeat: Int,
onExit: () -> Unit,
) {
val state by controller.state.collectAsState()
val legal by controller.legalMoves.collectAsState()
val abandoned by controller.abandoned.collectAsState()
val kept = remember { mutableStateListOf<Int>() }
var confirmingEnd by remember { mutableStateOf(false) }
val endGameAction: @Composable () -> Unit = {
TextButton(onClick = { confirmingEnd = true }) { Text("End game") }
}
val current = state
if (current == null) {
ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier ->
Box(modifier, contentAlignment = Alignment.Center) { Text("Setting up…") }
abandoned?.let { LeftCardDialog(it, onExit, controller::dismissAbandoned) }
if (confirmingEnd) EndCardDialog(onExit) { confirmingEnd = false }
}
return
}
val yours = current.turn == localSeat && !current.isOver
val canRoll = legal.any { it is RollDice }
val writable = legal.filterIsInstance<ScoreIn>().map { it.category }.toSet()
ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier ->
Column(
modifier = modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
text = when {
current.isOver -> "Cards full."
!yours -> "Seat ${current.turn} is throwing…"
!current.hasRolled -> "Your turn — throw the dice."
else -> "${current.rollsLeft} throws left, or write it down."
},
fontWeight = FontWeight.Bold,
)
DiceRow(
dice = current.dice,
kept = kept,
enabled = yours && current.hasRolled && canRoll,
onToggle = { index ->
if (kept.contains(index)) kept.remove(index) else kept.add(index)
},
)
if (yours && canRoll) {
Button(
onClick = {
controller.submit(RollDice(kept.toList()))
// What was kept applied to the throw just made; the next
// one starts from whatever is on the table now.
kept.clear()
},
) {
Text(if (current.hasRolled) "Throw the rest" else "Throw")
}
}
Divider()
ScoreCard(
state = current,
localSeat = localSeat,
writable = if (yours) writable else emptySet(),
onWrite = {
controller.submit(ScoreIn(it))
kept.clear()
},
)
current.log.takeLast(3).forEach {
Text(it, style = MaterialTheme.typography.bodySmall)
}
}
abandoned?.let { LeftCardDialog(it, onExit, controller::dismissAbandoned) }
if (confirmingEnd) EndCardDialog(onExit) { confirmingEnd = false }
}
}
@Composable
private fun DiceRow(
dice: List<Int>,
kept: List<Int>,
enabled: Boolean,
onToggle: (Int) -> Unit,
) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (dice.isEmpty()) {
repeat(DICE_COUNT) { Die(face = null, held = false, enabled = false) {} }
} else {
dice.forEachIndexed { index, face ->
Die(
face = face,
held = kept.contains(index),
enabled = enabled,
onClick = { onToggle(index) },
)
}
}
}
}
@Composable
private fun Die(face: Int?, held: Boolean, enabled: Boolean, onClick: () -> Unit) {
Box(
modifier = Modifier
.size(48.dp)
.background(if (face == null) Parchment.copy(alpha = 0.3f) else Parchment, RoundedCornerShape(8.dp))
.border(
width = if (held) 3.dp else 1.dp,
color = if (held) WallaceGold else WallaceRed.copy(alpha = 0.4f),
shape = RoundedCornerShape(8.dp),
)
.clickable(enabled = enabled) { onClick() },
contentAlignment = Alignment.Center,
) {
Text(
text = face?.toString() ?: "·",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = WallaceRed,
)
}
}
@Composable
private fun ScoreCard(
state: YahtzeeState,
localSeat: Int,
writable: Set<YahtzeeCategory>,
onWrite: (YahtzeeCategory) -> Unit,
) {
Column(modifier = Modifier.fillMaxWidth()) {
Row(modifier = Modifier.fillMaxWidth()) {
Text("", modifier = Modifier.weight(2f), style = MaterialTheme.typography.labelSmall)
state.cards.indices.forEach { seat ->
Text(
text = if (seat == localSeat) "You" else "S$seat",
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
)
}
}
YahtzeeSection.entries.forEach { section ->
YahtzeeCategory.entries.filter { it.section == section }.forEach { category ->
CardRow(state, localSeat, category, writable, onWrite)
}
if (section == YahtzeeSection.UPPER) {
TotalRow("Bonus at 63", state.cards.map { it.upperBonus })
}
}
Divider()
TotalRow("Total", state.cards.indices.map { state.totalFor(it) }, bold = true)
}
}
@Composable
private fun CardRow(
state: YahtzeeState,
localSeat: Int,
category: YahtzeeCategory,
writable: Set<YahtzeeCategory>,
onWrite: (YahtzeeCategory) -> Unit,
) {
val open = writable.contains(category)
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = open) { onWrite(category) }
.padding(vertical = 2.dp),
) {
Text(
text = category.label,
modifier = Modifier.weight(2f),
style = MaterialTheme.typography.bodySmall,
fontWeight = if (open) FontWeight.Bold else FontWeight.Normal,
)
state.cards.forEachIndexed { seat, card ->
val written = card[category]
Text(
text = when {
written != null -> "$written"
// What this throw would be worth here, which is the only
// number the decision actually turns on.
seat == localSeat && open -> "(${scoreOf(category, state.dice)})"
else -> "—"
},
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodySmall,
color = if (written == null && open) WallaceRed else MaterialTheme.colorScheme.onSurface,
)
}
}
}
@Composable
private fun TotalRow(label: String, values: List<Int>, bold: Boolean = false) {
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
Text(
text = label,
modifier = Modifier.weight(2f),
style = MaterialTheme.typography.bodySmall,
fontWeight = if (bold) FontWeight.Bold else FontWeight.Normal,
)
values.forEach {
Text(
text = "$it",
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodySmall,
fontWeight = if (bold) FontWeight.Bold else FontWeight.Normal,
)
}
}
}
@Composable
private fun EndCardDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
AlertDialog(
onDismissRequest = onKeepPlaying,
title = { Text("End this game?") },
text = { Text("The game ends here. Anyone else at the table is told you have left.") },
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
)
}
@Composable
private fun LeftCardDialog(notice: String, onExit: () -> Unit, onStay: () -> Unit) {
AlertDialog(
onDismissRequest = onStay,
title = { Text("The game has ended") },
text = { Text(notice) },
confirmButton = { TextButton(onClick = onExit) { Text("Leave the game") } },
dismissButton = { TextButton(onClick = onStay) { Text("Look at the card") } },
)
}