Screens for Hearts and peg solitaire, and a seat for one

Both engines had been sitting behind ids with no screen. This gives them one
each and puts them in the catalogue.

Peg solitaire is the first game here played alone, and almost nothing had to
move for it. seatCountFor already fell through to minPlayers, so one seat came
free; CatalogTest's floor of two players is the only invariant that had to go,
and it went to one. The setup screen grows a solo path instead: a one-seat game
has nobody to play against and no seat anyone could join, so it offers "Play"
and not "Host a game for others to join", and the menu says "On your own"
rather than "1 players".

Playing again cannot go through advanceIdle. driveIdleSeats returns the moment
rules.isFinished is true, before it ever reaches the confirmation branch, and a
peg board with no jumps left genuinely is finished -- unlike a Wizard round,
which is only between deals. Rather than bend isFinished into meaning something
else, the screen rebuilds the controller behind a generation counter. The board
is deterministic, so the retry is the same puzzle rather than a new one.

One board layout serves both geometries. Each hole is placed in hole-widths
rather than pixels and a triangle offsets its rows by half a hole, which is the
same staggering that gives a triangular hole six neighbours instead of four.

The Hearts screen reads the phase because two different things happen at it:
choosing three cards to give away, which is a multiple selection nothing else
in the app does, and then playing one card at a time like every other trick
screen here. Passing is capped at three by the picker rather than by the error
the rules would otherwise raise.

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 14:13:10 +00:00
parent 51aa0c3518
commit 434ea86605
7 changed files with 693 additions and 22 deletions
@@ -41,12 +41,6 @@ object GameCatalog {
* [all] when its screen lands.
*/
const val HEARTS = "hearts"
/**
* Peg solitaire, likewise an id ahead of its screen. It is also the first
* game here played by one person, so it arrives with the seat-count change
* that allows it rather than before it.
*/
const val PEG_SOLITAIRE = "pegsolitaire"
const val SEQUENCE = "sequence"
const val MASTERMIND = "mastermind"
@@ -130,6 +124,17 @@ object GameCatalog {
"twice round the board to 121.",
available = true,
),
GameDescriptor(
id = HEARTS,
title = "Hearts",
category = GameCategory.CARD,
minPlayers = 4,
maxPlayers = 4,
teamBased = false,
blurb = "Take no trick worth taking. Every heart costs a point, the queen " +
"of spades costs thirteen, and taking all of them costs everybody else.",
available = true,
),
GameDescriptor(
id = SEQUENCE,
title = "Sequence",
@@ -191,6 +196,19 @@ object GameCatalog {
blurb = "Three in a row takes a piece. Reduce them to two and the game is yours.",
available = true,
),
GameDescriptor(
id = PEG_SOLITAIRE,
title = "Peg Solitaire",
category = GameCategory.BOARD,
// The first game here played alone. Everything downstream reads the
// seat count from these two numbers, so one seat is all it takes.
minPlayers = 1,
maxPlayers = 1,
teamBased = false,
blurb = "The tee game from the table, and four larger boards. Jump a peg, " +
"take the one you passed, and try to finish with one standing.",
available = true,
),
GameDescriptor(
id = PIRATES,
title = "Pirates and Bulgars",
@@ -297,14 +297,12 @@ private fun GameTile(game: GameDescriptor, onClick: () -> Unit) {
modifier = Modifier.padding(top = 4.dp),
)
Text(
text = if (game.available) {
if (game.minPlayers == game.maxPlayers) {
"${game.minPlayers} players"
} else {
"${game.minPlayers}–${game.maxPlayers} players"
}
} else {
"Coming soon"
text = when {
!game.available -> "Coming soon"
// A puzzle for one, rather than "1 players".
game.maxPlayers == 1 -> "On your own"
game.minPlayers == game.maxPlayers -> "${game.minPlayers} players"
else -> "${game.minPlayers}–${game.maxPlayers} players"
},
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(top = 8.dp),
@@ -40,6 +40,10 @@ import org.prolibertate.games.game.crazyeights.CrazyEightsOptions
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.pegsolitaire.PegBoard
import org.prolibertate.games.game.pegsolitaire.PegGoal
import org.prolibertate.games.game.pegsolitaire.PegSolitaireOptions
import org.prolibertate.games.game.kaiser.KaiserOptions
import org.prolibertate.games.game.mastermind.MastermindLevel
import org.prolibertate.games.game.mastermind.MastermindOptions
@@ -88,6 +92,8 @@ fun GameSetupScreen(
var backgammon by remember { mutableStateOf(BackgammonOptions()) }
var mastermind by remember { mutableStateOf(MastermindOptions()) }
var pirates by remember { mutableStateOf(PiratesOptions()) }
var hearts by remember { mutableStateOf(HeartsOptions()) }
var pegs by remember { mutableStateOf(PegSolitaireOptions()) }
// 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) }
@@ -111,6 +117,8 @@ fun GameSetupScreen(
GameCatalog.BACKGAMMON -> setupJson.encodeToString(backgammon)
GameCatalog.MASTERMIND -> setupJson.encodeToString(mastermind)
GameCatalog.PIRATES -> setupJson.encodeToString(pirates)
GameCatalog.HEARTS -> setupJson.encodeToString(hearts)
GameCatalog.PEG_SOLITAIRE -> setupJson.encodeToString(pegs)
else -> "{}"
}
val seatCount = seatCountFor(descriptor.id, optionsJson)
@@ -162,15 +170,27 @@ fun GameSetupScreen(
onSide = { playPirates = it },
)
GameCatalog.HEARTS -> HeartsOptionsEditor(hearts) { hearts = it }
GameCatalog.PEG_SOLITAIRE -> PegSolitaireOptionsEditor(pegs) { pegs = it }
else -> Text("No options yet for this game.")
}
Divider()
Text("Opponents", fontWeight = FontWeight.Bold)
// A one-seat game has nobody to play against and no seat anyone
// could join, so it is offered as itself rather than as a table
// with every other chair filled by the computer.
val solo = seatCount == 1
Text(if (solo) "On your own" else "Opponents", fontWeight = FontWeight.Bold)
Text(
text = "Play now and every other seat is taken by the computer. Host a game " +
"instead and people can claim those seats as they join — whatever is " +
"still empty when you start stays computer-controlled.",
text = if (solo) {
"A puzzle for one. There is nobody to wait for and nothing to host."
} else {
"Play now and every other seat is taken by the computer. Host a game " +
"instead and people can claim those seats as they join — whatever is " +
"still empty when you start stays computer-controlled."
},
style = MaterialTheme.typography.bodySmall,
)
@@ -182,14 +202,16 @@ fun GameSetupScreen(
descriptor.id == GameCatalog.PIRATES && playPirates -> 1
else -> 0
}
PrimaryAction(text = "Play against the computer") {
PrimaryAction(text = if (solo) "Play" else "Play against the computer") {
onPlayOffline(
offlineConfig(descriptor.id, optionsJson, seatCount, playerName, localSeat),
localSeat,
)
}
PrimaryAction(text = "Host a game for others to join") {
onHostOnline(optionsJson)
if (!solo) {
PrimaryAction(text = "Host a game for others to join") {
onHostOnline(optionsJson)
}
}
}
}
@@ -944,3 +966,49 @@ private fun ToggleRow(
Switch(checked = checked, onCheckedChange = onChange)
}
}
@Composable
private fun HeartsOptionsEditor(options: HeartsOptions, onChange: (HeartsOptions) -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
ChipRow(
label = "Game ends at",
values = listOf(50, 100),
selected = options.targetScore,
display = { "$it points" },
onSelect = { onChange(options.copy(targetScore = it)) },
)
ToggleRow(
title = "Shooting the moon",
subtitle = "Take all twenty-six and score nothing, while everyone else takes " +
"the lot. Switched off, a moon is simply the worst hand at the table.",
checked = options.allowShootTheMoon,
onChange = { onChange(options.copy(allowShootTheMoon = it)) },
)
}
}
@Composable
private fun PegSolitaireOptionsEditor(
options: PegSolitaireOptions,
onChange: (PegSolitaireOptions) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
ChipRow(
label = "Board",
values = PegBoard.entries.toList(),
selected = options.board,
display = { it.label },
// The opening hole is cleared as well: a hole that exists on the old
// board may be a missing corner on the new one, and the options
// refuse to be built that way.
onSelect = { onChange(options.copy(board = it, startEmpty = null)) },
)
ChipRow(
label = "Finish",
values = PegGoal.entries.toList(),
selected = options.goal,
display = { it.label },
onSelect = { onChange(options.copy(goal = it)) },
)
}
}
@@ -4,7 +4,10 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
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.runtime.rememberCoroutineScope
import org.prolibertate.games.game.GameCatalog
import org.prolibertate.games.game.chess.ChessAi
@@ -26,6 +29,15 @@ 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.hearts.HeartsAi
import org.prolibertate.games.game.hearts.HeartsMove
import org.prolibertate.games.game.hearts.HeartsPhase
import org.prolibertate.games.game.hearts.HeartsRules
import org.prolibertate.games.game.hearts.HeartsState
import org.prolibertate.games.game.pegsolitaire.PegSolitaireAi
import org.prolibertate.games.game.pegsolitaire.PegSolitaireMove
import org.prolibertate.games.game.pegsolitaire.PegSolitaireRules
import org.prolibertate.games.game.pegsolitaire.PegSolitaireState
import org.prolibertate.games.game.golf.GolfAi
import org.prolibertate.games.game.golf.GolfMove
import org.prolibertate.games.game.golf.GolfPhase
@@ -430,6 +442,67 @@ fun PlayScreen(
PiratesScreen(controller = controller, localSeat = route.localSeat, onExit = onExit)
}
GameCatalog.HEARTS -> {
val controller = remember(route) {
MatchController<HeartsState, HeartsMove>(
rules = HeartsRules,
ai = HeartsAi,
config = route.config,
scope = scope,
role = role,
localSeats = setOf(route.localSeat),
primarySeat = route.localSeat,
advanceIdle = { state ->
if (state.phase == HeartsPhase.ROUND_OVER) {
HeartsRules.startNextRound(state)
} else {
null
}
},
// What a round cost is read before the next deal.
awaitsConfirmation = { state -> state.phase == HeartsPhase.ROUND_OVER },
aiThinkingMillis = { settings.scaled(700L) },
holdBeforeNextMove = { state ->
if (state.completedTrick.isNotEmpty()) {
settings.scaled(TRICK_HOLD_MILLIS)
} else {
0L
}
},
)
}
StartMatch(env, controller, route)
HeartsScreen(controller = controller, localSeat = route.localSeat, onExit = onExit)
}
GameCatalog.PEG_SOLITAIRE -> {
// Playing again means a fresh board rather than a fresh round, and
// it cannot go through advanceIdle: driveIdleSeats returns as soon
// as rules.isFinished is true, and a peg board with no jumps left
// genuinely is finished. Rebuilding the controller is the honest
// way to deal again, and the board is deterministic, so the retry
// is the same puzzle.
var generation by remember(route) { mutableIntStateOf(0) }
val controller = remember(route, generation) {
MatchController<PegSolitaireState, PegSolitaireMove>(
rules = PegSolitaireRules,
// Never consulted while a person holds the only seat.
ai = PegSolitaireAi,
config = route.config,
scope = scope,
role = role,
localSeats = setOf(route.localSeat),
primarySeat = route.localSeat,
)
}
StartMatch(env, controller, route)
PegSolitaireScreen(
controller = controller,
onRestart = { generation++ },
onExit = onExit,
)
}
GameCatalog.MORRIS -> {
val controller = remember(route) {
MatchController<MorrisState, MorrisMove>(
@@ -0,0 +1,249 @@
package org.prolibertate.games.ui.game
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.rememberScrollState
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.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.runtime.mutableStateListOf
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.cards.Card
import org.prolibertate.games.game.hearts.HeartsMove
import org.prolibertate.games.game.hearts.HeartsPhase
import org.prolibertate.games.game.hearts.HeartsState
import org.prolibertate.games.game.hearts.PASS_SIZE
import org.prolibertate.games.game.hearts.PassCards
import org.prolibertate.games.game.hearts.PlayCard
import org.prolibertate.games.game.hearts.pointsOf
import org.prolibertate.games.net.MatchController
import org.prolibertate.games.ui.ScreenScaffold
import org.prolibertate.games.ui.common.PlayingCardView
private const val TITLE = "Hearts"
/**
* The Hearts table.
*
* Two quite different things happen at this screen, so it reads the phase and
* shows one or the other: choosing three cards to give away, which is a
* multiple selection nothing else in the app does, and then playing one card at
* a time, which every trick-taking screen here does the same way.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun HeartsScreen(
controller: MatchController<HeartsState, HeartsMove>,
localSeat: Int,
onExit: () -> Unit,
) {
val state by controller.state.collectAsState()
val legal by controller.legalMoves.collectAsState()
val abandoned by controller.abandoned.collectAsState()
val awaiting by controller.awaitingConfirmation.collectAsState()
val chosen = remember { mutableStateListOf<Card>() }
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 { LeftHeartsDialog(it, onExit, controller::dismissAbandoned) }
if (confirmingEnd) EndHeartsDialog(onExit) { confirmingEnd = false }
}
return
}
val hand = current.hands.getOrElse(localSeat) { emptyList() }
val passing = current.phase == HeartsPhase.PASSING
val alreadyPassed = current.passSelections.getOrElse(localSeat) { emptyList() }.isNotEmpty()
val playable = legal.filterIsInstance<PlayCard>().map { it.card }.toSet()
ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier ->
Column(
modifier = modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
text = when {
current.phase == HeartsPhase.GAME_OVER -> "Game over — lowest score wins."
current.phase == HeartsPhase.ROUND_OVER -> "Round scored."
passing && alreadyPassed -> "Waiting for the others to choose…"
passing -> "Choose three to pass ${current.passDirection.label}"
playable.isEmpty() -> "Waiting…"
else -> "Your turn"
},
fontWeight = FontWeight.Bold,
)
Text(
text = "Every heart costs a point and the queen of spades costs thirteen. " +
"Take none of them.",
style = MaterialTheme.typography.bodySmall,
)
TrickView(current)
ScoreRow(current, localSeat)
Divider()
Text(
text = if (passing && !alreadyPassed) {
"Passing ${current.passDirection.label} — ${chosen.size} of $PASS_SIZE chosen"
} else {
"Your hand"
},
style = MaterialTheme.typography.labelLarge,
)
FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
hand.forEach { card ->
val selectable = passing && !alreadyPassed
PlayingCardView(
card = card,
width = 52.dp,
selected = chosen.contains(card),
enabled = if (selectable) true else playable.contains(card),
caption = pointsOf(card).takeIf { it > 0 }?.let { "−$it" },
onClick = {
if (selectable) {
toggle(chosen, card)
} else if (playable.contains(card)) {
controller.submit(PlayCard(card))
}
},
)
}
}
if (passing && !alreadyPassed) {
Button(
enabled = chosen.size == PASS_SIZE,
onClick = {
controller.submit(PassCards(chosen.toList()))
chosen.clear()
},
) { Text("Pass these three") }
}
if (awaiting) {
Button(onClick = controller::confirmAdvance) { Text("Next round") }
}
current.log.takeLast(3).forEach {
Text(it, style = MaterialTheme.typography.bodySmall)
}
}
abandoned?.let { LeftHeartsDialog(it, onExit, controller::dismissAbandoned) }
if (confirmingEnd) EndHeartsDialog(onExit) { confirmingEnd = false }
}
}
/** Adds or removes [card], never letting the selection run past three. */
private fun toggle(chosen: SnapshotStateList<Card>, card: Card) {
if (chosen.contains(card)) {
chosen.remove(card)
} else if (chosen.size < PASS_SIZE) {
chosen.add(card)
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun TrickView(state: HeartsState) {
// The trick just swept is held on the table for a moment, so show that when
// there is nothing in front of the players yet.
val showing = state.trick.ifEmpty { state.completedTrick }
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = if (state.trick.isEmpty() && showing.isNotEmpty()) {
"Last trick — taken by seat ${state.leader}"
} else {
"Trick ${state.trickNumber + 1} of ${state.tricksPerRound}"
},
style = MaterialTheme.typography.labelLarge,
)
if (showing.isEmpty()) {
Text("Nothing played yet.", style = MaterialTheme.typography.bodySmall)
} else {
FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
showing.forEach { played ->
PlayingCardView(
card = played.card,
width = 46.dp,
caption = "seat ${played.seat}",
)
}
}
}
}
}
@Composable
private fun ScoreRow(state: HeartsState, localSeat: Int) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
state.scores.forEachIndexed { seat, score ->
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = if (seat == localSeat) "You" else "Seat $seat",
style = MaterialTheme.typography.labelSmall,
fontWeight = if (seat == localSeat) FontWeight.Bold else FontWeight.Normal,
)
Text("$score", fontWeight = FontWeight.Bold)
// What this round has cost so far, which is the number anybody
// actually watches while a hand is being played.
Text(
text = "+${state.roundScores.getOrElse(seat) { 0 }}",
style = MaterialTheme.typography.labelSmall,
)
}
}
}
}
@Composable
private fun EndHeartsDialog(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 LeftHeartsDialog(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,259 @@
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.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
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.pegsolitaire.Hole
import org.prolibertate.games.game.pegsolitaire.PegBoard
import org.prolibertate.games.game.pegsolitaire.PegJump
import org.prolibertate.games.game.pegsolitaire.PegSolitaireMove
import org.prolibertate.games.game.pegsolitaire.PegSolitaireState
import org.prolibertate.games.net.MatchController
import org.prolibertate.games.ui.ScreenScaffold
import org.prolibertate.games.ui.theme.FeltGreenDark
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 = "Peg Solitaire"
/**
* The peg board.
*
* One layout serves both board shapes. Each hole is placed at a position in
* hole-widths rather than pixels, and a triangle simply offsets its rows by half
* a hole so they stagger — which is the same thing that gives a triangular hole
* six neighbours instead of four. Scaling that grid to whatever width the phone
* has is then the only sizing decision left.
*/
@Composable
fun PegSolitaireScreen(
controller: MatchController<PegSolitaireState, PegSolitaireMove>,
onRestart: () -> Unit,
onExit: () -> Unit,
) {
val state by controller.state.collectAsState()
val legal by controller.legalMoves.collectAsState()
val abandoned by controller.abandoned.collectAsState()
var selected by remember { mutableStateOf<Hole?>(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("Setting up…") }
abandoned?.let { LeftBoardDialog(it, onExit, controller::dismissAbandoned) }
if (confirmingEnd) EndBoardDialog(onExit) { confirmingEnd = false }
}
return
}
val jumps = legal.filterIsInstance<PegJump>()
val movable = jumps.map { it.from }.toSet()
val landings = selected?.let { from -> jumps.filter { it.from == from } }.orEmpty()
val destinations = landings.map { it.to }.toSet()
val finished = jumps.isEmpty()
ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier ->
Column(
modifier = modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
text = when {
current.solved -> "Solved, in ${current.jumps} jumps."
finished -> "Stuck with ${current.remaining} pegs."
selected != null -> "Tap a ringed hole to jump into it."
else -> "Tap a peg that can jump."
},
fontWeight = FontWeight.Bold,
)
Text(
text = "${current.board.label}. ${current.remaining} pegs left, " +
"${current.jumps} jumps made.",
style = MaterialTheme.typography.bodySmall,
)
Board(
state = current,
selected = selected,
movable = movable,
destinations = destinations,
onTap = { hole ->
selected = when {
// Completing a jump takes priority: the tapped hole is
// one the selected peg was offered.
selected != null && destinations.contains(hole) -> {
controller.submit(PegJump(from = selected!!, to = hole))
null
}
movable.contains(hole) -> hole
else -> null
}
},
)
if (finished) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = onRestart) { Text("Play again") }
TextButton(onClick = onExit) { Text("Leave") }
}
}
current.log.takeLast(3).forEach {
Text(it, style = MaterialTheme.typography.bodySmall)
}
}
abandoned?.let { LeftBoardDialog(it, onExit, controller::dismissAbandoned) }
if (confirmingEnd) EndBoardDialog(onExit) { confirmingEnd = false }
}
}
@Composable
private fun Board(
state: PegSolitaireState,
selected: Hole?,
movable: Set<Hole>,
destinations: Set<Hole>,
onTap: (Hole) -> Unit,
) {
val board = state.board
val holes = remember(board) { board.holes().toList() }
val places = remember(board) { holes.associateWith { placeOf(board, it) } }
val xs = places.values.map { it.first }
val ys = places.values.map { it.second }
val minX = xs.min()
val minY = ys.min()
val spanX = xs.max() - minX
val spanY = ys.max() - minY
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
// One extra hole-width so the last column has somewhere to sit.
val cell: Dp = maxWidth / (spanX + 1f)
Box(
modifier = Modifier
.fillMaxWidth()
.height(cell * (spanY + 1f))
.background(FeltGreenDark),
) {
holes.forEach { hole ->
val (x, y) = places.getValue(hole)
HoleView(
filled = state.pegs.contains(hole),
selected = hole == selected,
canMove = movable.contains(hole),
isDestination = destinations.contains(hole),
size = cell,
modifier = Modifier.offset(x = cell * (x - minX), y = cell * (y - minY)),
onTap = { onTap(hole) },
)
}
}
}
}
/**
* Where a hole sits, in hole-widths from the board's top left.
*
* The half-hole shift on a triangle is the whole difference between the two
* geometries as far as drawing is concerned.
*/
private fun placeOf(board: PegBoard, hole: Hole): Pair<Float, Float> =
if (board.isTriangular) {
(hole.col - hole.row / 2f) to hole.row.toFloat()
} else {
hole.col.toFloat() to hole.row.toFloat()
}
@Composable
private fun HoleView(
filled: Boolean,
selected: Boolean,
canMove: Boolean,
isDestination: Boolean,
size: Dp,
modifier: Modifier,
onTap: () -> Unit,
) {
Box(modifier = modifier.size(size), contentAlignment = Alignment.Center) {
val diameter = size * 0.72f
val ring = when {
selected -> WallaceGold
isDestination -> WallaceGold
else -> Parchment.copy(alpha = 0.35f)
}
Box(
modifier = Modifier
.size(diameter)
.background(
color = if (filled) WallaceRed else FeltGreenDark,
shape = CircleShape,
)
.border(
// A destination is drawn as an empty ring rather than filled,
// so it never reads as a peg already sitting there.
border = BorderStroke(if (selected || isDestination) 3.dp else 1.dp, ring),
shape = CircleShape,
)
.clickable(enabled = filled && canMove || isDestination) { onTap() },
)
}
}
@Composable
private fun EndBoardDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
AlertDialog(
onDismissRequest = onKeepPlaying,
title = { Text("End this game?") },
text = { Text("The board is left as it stands.") },
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
)
}
@Composable
private fun LeftBoardDialog(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 board") } },
)
}