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:
@@ -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") } },
|
||||
)
|
||||
}
|
||||
@@ -73,6 +73,8 @@ class CatalogTest {
|
||||
GameCatalog.CRIBBAGE,
|
||||
GameCatalog.HEARTS,
|
||||
GameCatalog.PEG_SOLITAIRE,
|
||||
GameCatalog.YAHTZEE,
|
||||
GameCatalog.KLONDIKE,
|
||||
GameCatalog.WIZARD,
|
||||
GameCatalog.CHESS,
|
||||
GameCatalog.TAYU,
|
||||
|
||||
Reference in New Issue
Block a user