diff --git a/app/src/main/java/org/prolibertate/games/game/Catalog.kt b/app/src/main/java/org/prolibertate/games/game/Catalog.kt index bf0b16d..840b2fe 100644 --- a/app/src/main/java/org/prolibertate/games/game/Catalog.kt +++ b/app/src/main/java/org/prolibertate/games/game/Catalog.kt @@ -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", diff --git a/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt b/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt index e7ae875..e140fc3 100644 --- a/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt +++ b/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt @@ -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), diff --git a/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt b/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt index 1e03faa..1fe2edf 100644 --- a/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt @@ -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)) }, + ) + } +} diff --git a/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt b/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt index 56ecd49..334f578 100644 --- a/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt @@ -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( + 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( + 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( diff --git a/app/src/main/java/org/prolibertate/games/ui/game/HeartsScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/HeartsScreen.kt new file mode 100644 index 0000000..dd70ea6 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/HeartsScreen.kt @@ -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, + 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() } + 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().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) { + 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") } }, + ) +} diff --git a/app/src/main/java/org/prolibertate/games/ui/game/PegSolitaireScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/PegSolitaireScreen.kt new file mode 100644 index 0000000..bb27414 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/PegSolitaireScreen.kt @@ -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, + 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(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() + 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, + destinations: Set, + 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 = + 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") } }, + ) +} diff --git a/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt b/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt index 80766a4..9c44e39 100644 --- a/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt +++ b/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt @@ -71,6 +71,8 @@ class CatalogTest { GameCatalog.KAISER, GameCatalog.CRAZY_EIGHTS, GameCatalog.CRIBBAGE, + GameCatalog.HEARTS, + GameCatalog.PEG_SOLITAIRE, GameCatalog.WIZARD, GameCatalog.CHESS, GameCatalog.TAYU, @@ -87,7 +89,11 @@ class CatalogTest { @Test fun `player counts are sane`() { GameCatalog.all.forEach { game -> - assertTrue("${game.id} min players", game.minPlayers >= 2) + // One, not two. Peg solitaire is played alone, and the engine took + // that without changes — one seat, always on the clock, nothing to + // redact. Everything downstream reads the seat count from here, so + // this is the only place that had to allow it. + assertTrue("${game.id} min players", game.minPlayers >= 1) assertTrue("${game.id} max >= min", game.maxPlayers >= game.minPlayers) } }