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 14a48a8..306e0b1 100644 --- a/app/src/main/java/org/prolibertate/games/game/Catalog.kt +++ b/app/src/main/java/org/prolibertate/games/game/Catalog.kt @@ -147,6 +147,39 @@ object GameCatalog { "and a pack that decides most of it before you start.", available = true, ), + GameDescriptor( + id = FREECELL, + title = "FreeCell", + category = GameCategory.CARD, + minPlayers = 1, + maxPlayers = 1, + teamBased = false, + blurb = "Every card face up from the start, and four cells to park what is " + + "in the way. Nearly every deal can be won by someone good enough.", + available = true, + ), + GameDescriptor( + id = SPIDER, + title = "Spider", + category = GameCategory.CARD, + minPlayers = 1, + maxPlayers = 1, + teamBased = false, + blurb = "Two packs, ten columns, eight runs to build from king to ace. " + + "One suit is a pastime; four is a fight.", + available = true, + ), + GameDescriptor( + id = PYRAMID, + title = "Pyramid", + category = GameCategory.CARD, + minPlayers = 1, + maxPlayers = 1, + teamBased = false, + blurb = "Take cards away in pairs that make thirteen, kings alone. " + + "Nothing is built and nothing goes home — the pyramid just has to go.", + available = true, + ), GameDescriptor( id = SEQUENCE, title = "Sequence", 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 58ee816..80632ea 100644 --- a/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt @@ -42,6 +42,9 @@ 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.freecell.FreeCellOptions +import org.prolibertate.games.game.pyramid.PyramidOptions +import org.prolibertate.games.game.spider.SpiderOptions import org.prolibertate.games.game.yahtzee.YahtzeeOptions import org.prolibertate.games.game.pegsolitaire.PegBoard import org.prolibertate.games.game.pegsolitaire.PegGoal @@ -98,6 +101,9 @@ fun GameSetupScreen( var pegs by remember { mutableStateOf(PegSolitaireOptions()) } var yahtzee by remember { mutableStateOf(YahtzeeOptions()) } var klondike by remember { mutableStateOf(KlondikeOptions()) } + var freecell by remember { mutableStateOf(FreeCellOptions()) } + var spider by remember { mutableStateOf(SpiderOptions()) } + var pyramid by remember { mutableStateOf(PyramidOptions()) } // 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) } @@ -125,6 +131,9 @@ fun GameSetupScreen( GameCatalog.PEG_SOLITAIRE -> setupJson.encodeToString(pegs) GameCatalog.YAHTZEE -> setupJson.encodeToString(yahtzee) GameCatalog.KLONDIKE -> setupJson.encodeToString(klondike) + GameCatalog.FREECELL -> setupJson.encodeToString(freecell) + GameCatalog.SPIDER -> setupJson.encodeToString(spider) + GameCatalog.PYRAMID -> setupJson.encodeToString(pyramid) else -> "{}" } val seatCount = seatCountFor(descriptor.id, optionsJson) @@ -184,6 +193,12 @@ fun GameSetupScreen( GameCatalog.KLONDIKE -> KlondikeOptionsEditor(klondike) { klondike = it } + GameCatalog.FREECELL -> FreeCellOptionsEditor(freecell) { freecell = it } + + GameCatalog.SPIDER -> SpiderOptionsEditor(spider) { spider = it } + + GameCatalog.PYRAMID -> PyramidOptionsEditor(pyramid) { pyramid = it } + else -> Text("No options yet for this game.") } @@ -1078,3 +1093,51 @@ private fun KlondikeOptionsEditor(options: KlondikeOptions, onChange: (KlondikeO ) } } + +@Composable +private fun FreeCellOptionsEditor(options: FreeCellOptions, onChange: (FreeCellOptions) -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + ChipRow( + label = "Free cells", + values = (1..6).toList(), + selected = options.freeCells, + display = { "$it" }, + onSelect = { onChange(options.copy(freeCells = it)) }, + ) + ToggleRow( + title = "Move a run in one go", + subtitle = "The rules move a run one card at a time through the cells. This " + + "does the shuffling for you, which is bookkeeping rather than a decision.", + checked = options.allowSupermoves, + onChange = { onChange(options.copy(allowSupermoves = it)) }, + ) + } +} + +@Composable +private fun SpiderOptionsEditor(options: SpiderOptions, onChange: (SpiderOptions) -> Unit) { + ChipRow( + label = "Suits", + values = listOf(1, 2, 4), + selected = options.suits, + display = { + when (it) { + 1 -> "One — a pastime" + 2 -> "Two" + else -> "Four — a fight" + } + }, + onSelect = { onChange(options.copy(suits = it)) }, + ) +} + +@Composable +private fun PyramidOptionsEditor(options: PyramidOptions, onChange: (PyramidOptions) -> Unit) { + ChipRow( + label = "Turns through the pack", + values = listOf(0, 1, 2, 3), + selected = options.redeals, + display = { if (it == 0) "One pass only" else "$it turns back" }, + onSelect = { onChange(options.copy(redeals = 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 4b93b30..3359b69 100644 --- a/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt @@ -29,6 +29,16 @@ 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.freecell.FreeCellMove +import org.prolibertate.games.game.freecell.FreeCellRules +import org.prolibertate.games.game.freecell.FreeCellState +import org.prolibertate.games.game.pyramid.PyramidMove +import org.prolibertate.games.game.pyramid.PyramidRules +import org.prolibertate.games.game.pyramid.PyramidState +import org.prolibertate.games.game.solitaire.FirstLegalAi +import org.prolibertate.games.game.spider.SpiderMove +import org.prolibertate.games.game.spider.SpiderRules +import org.prolibertate.games.game.spider.SpiderState import org.prolibertate.games.game.klondike.KlondikeAi import org.prolibertate.games.game.klondike.KlondikeMove import org.prolibertate.games.game.klondike.KlondikeRules @@ -102,13 +112,16 @@ 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.FreeCellScreen import org.prolibertate.games.ui.game.KlondikeScreen +import org.prolibertate.games.ui.game.PyramidScreen 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.SpiderScreen import org.prolibertate.games.ui.game.YahtzeeScreen import org.prolibertate.games.ui.game.PiratesScreen import org.prolibertate.games.ui.game.PresidentScreen @@ -559,6 +572,57 @@ fun PlayScreen( ) } + GameCatalog.FREECELL -> { + var generation by remember(route) { mutableIntStateOf(0) } + val controller = remember(route, generation) { + MatchController( + rules = FreeCellRules, + ai = FirstLegalAi(), + config = route.config, + scope = scope, + role = role, + localSeats = setOf(route.localSeat), + primarySeat = route.localSeat, + ) + } + StartMatch(env, controller, route) + FreeCellScreen(controller = controller, onRestart = { generation++ }, onExit = onExit) + } + + GameCatalog.SPIDER -> { + var generation by remember(route) { mutableIntStateOf(0) } + val controller = remember(route, generation) { + MatchController( + rules = SpiderRules, + ai = FirstLegalAi(), + config = route.config, + scope = scope, + role = role, + localSeats = setOf(route.localSeat), + primarySeat = route.localSeat, + ) + } + StartMatch(env, controller, route) + SpiderScreen(controller = controller, onRestart = { generation++ }, onExit = onExit) + } + + GameCatalog.PYRAMID -> { + var generation by remember(route) { mutableIntStateOf(0) } + val controller = remember(route, generation) { + MatchController( + rules = PyramidRules, + ai = FirstLegalAi(), + config = route.config, + scope = scope, + role = role, + localSeats = setOf(route.localSeat), + primarySeat = route.localSeat, + ) + } + StartMatch(env, controller, route) + PyramidScreen(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/CardTable.kt b/app/src/main/java/org/prolibertate/games/ui/game/CardTable.kt new file mode 100644 index 0000000..b296437 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/CardTable.kt @@ -0,0 +1,96 @@ +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.Box +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.prolibertate.games.ui.common.CARD_ASPECT +import org.prolibertate.games.ui.theme.FeltGreenDark +import org.prolibertate.games.ui.theme.Parchment +import org.prolibertate.games.ui.theme.WallaceGold + +/** + * The furniture every patience table needs. + * + * FreeCell, Spider and Pyramid lay their cards out quite differently but they + * are all made of the same three things: a place a card sits, an empty place + * that can still be tapped into, and the two dialogs for leaving. Klondike keeps + * its own copies — it was written first and its layout constants are tuned to + * seven columns — but nothing new needs to repeat them. + */ +val TABLE_CARD_WIDTH: Dp = 44.dp +val TABLE_CARD_HEIGHT: Dp = TABLE_CARD_WIDTH / CARD_ASPECT + +/** Overlapped by a third, which hides as little as a stack can afford to. */ +val TABLE_STACK_STEP: Dp = TABLE_CARD_HEIGHT * 2f / 3f + +@Composable +fun TableSlot( + highlighted: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + width: Dp = TABLE_CARD_WIDTH, + content: @Composable () -> Unit, +) { + Box( + modifier = modifier + .width(width) + .border( + border = BorderStroke(if (highlighted) 3.dp else 0.dp, WallaceGold), + shape = RoundedCornerShape(4.dp), + ) + .clickable { onClick() }, + ) { + content() + } +} + +/** A place with no card on it, drawn so it is still somewhere to aim at. */ +@Composable +fun EmptySpace(label: String = "", width: Dp = TABLE_CARD_WIDTH) { + Box( + modifier = Modifier + .width(width) + .height(width / CARD_ASPECT) + .background(FeltGreenDark, RoundedCornerShape(4.dp)), + contentAlignment = Alignment.Center, + ) { + if (label.isNotEmpty()) { + Text(label, color = Parchment.copy(alpha = 0.5f)) + } + } +} + +@Composable +fun PatienceEndDialog(what: String, onEnd: () -> Unit, onKeepPlaying: () -> Unit) { + AlertDialog( + onDismissRequest = onKeepPlaying, + title = { Text("End this game?") }, + text = { Text(what) }, + confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } }, + dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } }, + ) +} + +@Composable +fun PatienceLeftDialog(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/FreeCellScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/FreeCellScreen.kt new file mode 100644 index 0000000..b2d58ab --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/FreeCellScreen.kt @@ -0,0 +1,188 @@ +package org.prolibertate.games.ui.game + +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +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 org.prolibertate.games.game.cards.Suit +import org.prolibertate.games.game.freecell.FreeCellMove +import org.prolibertate.games.game.freecell.FreeCellState +import org.prolibertate.games.game.freecell.MoveTo +import org.prolibertate.games.game.freecell.Place +import org.prolibertate.games.net.MatchController +import org.prolibertate.games.ui.ScreenScaffold +import org.prolibertate.games.ui.common.PlayingCardView + +private const val TITLE = "FreeCell" + +/** + * The FreeCell table. + * + * Cells on the left, homes on the right, eight columns below — the standard + * arrangement, and the one that makes the cells read as somewhere to put things + * rather than somewhere cards come from. Everything is face up from the first + * move, so the whole game is on the screen and the only thing hidden is what to + * do about it. + */ +@Composable +fun FreeCellScreen( + 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 held 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("Dealing…") } + abandoned?.let { PatienceLeftDialog(it, onExit, controller::dismissAbandoned) } + if (confirmingEnd) { + PatienceEndDialog("The deal is left as it stands.", onExit) { confirmingEnd = false } + } + } + return + } + + val moves = legal.filterIsInstance() + val finished = legal.isEmpty() + 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(place: Place, count: Int) { + val inHand = held + held = when { + inHand != null && targets.contains(place) -> { + controller.submit(MoveTo(inHand.first, place, inHand.second)) + null + } + + inHand?.first == place -> null + sources.contains(place to count) -> place to count + else -> null + } + } + + ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier -> + Column( + modifier = modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(10.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, " + + "${current.freeCellCount} cells free." + }, + fontWeight = FontWeight.Bold, + ) + + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + current.cells.forEachIndexed { index, card -> + TableSlot( + highlighted = held?.first == Place.cell(index) || + targets.contains(Place.cell(index)), + onClick = { take(Place.cell(index), 1) }, + ) { + card?.let { PlayingCardView(card = it, width = TABLE_CARD_WIDTH) } + ?: EmptySpace(label = "·") + } + } + current.foundations.forEachIndexed { index, pile -> + TableSlot( + highlighted = targets.contains(Place.foundation(index)), + onClick = { take(Place.foundation(index), 1) }, + ) { + pile.lastOrNull()?.let { PlayingCardView(card = it, width = TABLE_CARD_WIDTH) } + ?: EmptySpace(label = Suit.entries[index].symbol) + } + } + } + + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.Top, + ) { + current.tableau.forEachIndexed { index, column -> + if (column.isEmpty()) { + TableSlot( + highlighted = targets.contains(Place.column(index)), + onClick = { take(Place.column(index), 1) }, + ) { EmptySpace() } + } else { + Box( + modifier = Modifier + .width(TABLE_CARD_WIDTH) + .height(TABLE_CARD_HEIGHT + TABLE_STACK_STEP * (column.size - 1)), + ) { + column.forEachIndexed { depth, card -> + // Taking a card takes everything resting on it. + val count = column.size - depth + TableSlot( + modifier = Modifier.offset(y = TABLE_STACK_STEP * depth), + highlighted = held == (Place.column(index) to count) || + (depth == column.lastIndex && targets.contains(Place.column(index))), + onClick = { take(Place.column(index), count) }, + ) { PlayingCardView(card = card, width = TABLE_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 { PatienceLeftDialog(it, onExit, controller::dismissAbandoned) } + if (confirmingEnd) { + PatienceEndDialog("The deal is left as it stands.", onExit) { confirmingEnd = false } + } + } +} diff --git a/app/src/main/java/org/prolibertate/games/ui/game/PyramidScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/PyramidScreen.kt new file mode 100644 index 0000000..4701a5f --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/PyramidScreen.kt @@ -0,0 +1,260 @@ +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.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +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.pyramid.DrawCard +import org.prolibertate.games.game.pyramid.PYRAMID_SIZE +import org.prolibertate.games.game.pyramid.PyramidMove +import org.prolibertate.games.game.pyramid.PyramidSpot +import org.prolibertate.games.game.pyramid.PyramidState +import org.prolibertate.games.game.pyramid.PyramidZone +import org.prolibertate.games.game.pyramid.RecycleWaste +import org.prolibertate.games.game.pyramid.ROWS +import org.prolibertate.games.game.pyramid.TakeKing +import org.prolibertate.games.game.pyramid.TakePair +import org.prolibertate.games.game.pyramid.rowStart +import org.prolibertate.games.game.pyramid.valueOf +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 + +private const val TITLE = "Pyramid" + +/** Cards are small here: the widest row is seven of them side by side. */ +private val PYRAMID_CARD: Dp = 40.dp +private val PYRAMID_CARD_HEIGHT: Dp = PYRAMID_CARD * 7f / 5f + +/** Rows overlap vertically so the whole pyramid fits above the pack. */ +private val ROW_STEP: Dp = PYRAMID_CARD_HEIGHT * 2f / 3f + +/** + * The pyramid. + * + * Drawn as it is dealt: seven rows, each one offset half a card so every card + * sits on the two below it, and rows overlapping by a third so all seven fit. + * The shape is the rules — a card is only in play once both the cards resting on + * it have gone, and being able to see that at a glance is most of playing it. + * + * Tapping a king takes it alone. Tapping any other card holds it, and tapping + * its partner takes the pair; the ones that would pair with what is held are the + * only cards that light up. + */ +@Composable +fun PyramidScreen( + 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 held 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("Dealing…") } + abandoned?.let { PatienceLeftDialog(it, onExit, controller::dismissAbandoned) } + if (confirmingEnd) { + PatienceEndDialog("The pyramid is left as it stands.", onExit) { confirmingEnd = false } + } + } + return + } + + val kings = legal.filterIsInstance().map { it.spot }.toSet() + val pairs = legal.filterIsInstance() + val canDraw = legal.contains(DrawCard) + val canRedeal = legal.contains(RecycleWaste) + val finished = legal.isEmpty() + + // Everything that could pair with what is in hand. + val partners = held?.let { spot -> + pairs.mapNotNull { + when (spot) { + it.first -> it.second + it.second -> it.first + else -> null + } + }.toSet() + }.orEmpty() + val pairable = pairs.flatMap { listOf(it.first, it.second) }.toSet() + + fun tap(spot: PyramidSpot) { + held = when { + kings.contains(spot) && held == null -> { + controller.submit(TakeKing(spot)) + null + } + + held != null && partners.contains(spot) -> { + controller.submit(TakePair(held!!, spot)) + null + } + + held == spot -> null + pairable.contains(spot) -> spot + else -> null + } + } + + ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier -> + Column( + modifier = modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = when { + current.isWon -> "Pyramid cleared, in ${current.moves} moves." + finished -> "Blocked — nothing pairs to thirteen." + held != null -> "Tap the card that makes it thirteen." + else -> "${current.pyramid.count { it != null }} of $PYRAMID_SIZE left." + }, + fontWeight = FontWeight.Bold, + ) + + PyramidView( + state = current, + held = held, + kings = kings, + partners = partners, + pairable = pairable, + onTap = ::tap, + ) + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + TableSlot( + highlighted = false, + width = PYRAMID_CARD, + onClick = { + if (canDraw) { + controller.submit(DrawCard) + } else if (canRedeal) { + controller.submit(RecycleWaste) + } + }, + ) { + if (current.stock.isNotEmpty()) { + CardBackView(width = PYRAMID_CARD) + } else { + EmptySpace(label = if (canRedeal) "↻" else "", width = PYRAMID_CARD) + } + } + val wasteSpot = PyramidSpot(PyramidZone.WASTE) + TableSlot( + highlighted = held == wasteSpot || + partners.contains(wasteSpot) || + kings.contains(wasteSpot), + width = PYRAMID_CARD, + onClick = { tap(wasteSpot) }, + ) { + current.waste.lastOrNull() + ?.let { PlayingCardView(card = it, width = PYRAMID_CARD) } + ?: EmptySpace(width = PYRAMID_CARD) + } + Text( + text = "${current.stock.size} in hand, " + + "${current.options.redeals - current.redealsUsed} turns left", + style = MaterialTheme.typography.bodySmall, + ) + } + + 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 { PatienceLeftDialog(it, onExit, controller::dismissAbandoned) } + if (confirmingEnd) { + PatienceEndDialog("The pyramid is left as it stands.", onExit) { confirmingEnd = false } + } + } +} + +@Composable +private fun PyramidView( + state: PyramidState, + held: PyramidSpot?, + kings: Set, + partners: Set, + pairable: Set, + onTap: (PyramidSpot) -> Unit, +) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(PYRAMID_CARD_HEIGHT + ROW_STEP * (ROWS - 1)), + ) { + // Each row is half a card narrower on each side than the one below, so + // every card sits in the notch between the two under it. + for (row in 0 until ROWS) { + val cardsInRow = row + 1 + for (column in 0 until cardsInRow) { + val index = rowStart(row) + column + val card = state.pyramid[index] ?: continue + val spot = PyramidSpot(PyramidZone.PYRAMID, index) + val playable = pairable.contains(spot) || kings.contains(spot) + Box( + modifier = Modifier.offset( + // Centred on the widest row, which is seven wide. + x = PYRAMID_CARD * (ROWS - cardsInRow) / 2f + PYRAMID_CARD * column, + y = ROW_STEP * row, + ), + ) { + TableSlot( + highlighted = held == spot || partners.contains(spot), + width = PYRAMID_CARD, + onClick = { if (playable) onTap(spot) }, + ) { + PlayingCardView( + card = card, + width = PYRAMID_CARD, + enabled = playable, + caption = valueOf(card).toString(), + ) + } + } + } + } + } +} diff --git a/app/src/main/java/org/prolibertate/games/ui/game/SpiderScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/SpiderScreen.kt new file mode 100644 index 0000000..2abab04 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/SpiderScreen.kt @@ -0,0 +1,191 @@ +package org.prolibertate.games.ui.game + +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +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 org.prolibertate.games.game.spider.DealRow +import org.prolibertate.games.game.spider.MoveRun +import org.prolibertate.games.game.spider.RUNS_TO_WIN +import org.prolibertate.games.game.spider.SpiderMove +import org.prolibertate.games.game.spider.SpiderState +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 + +private const val TITLE = "Spider" + +/** + * The Spider table. + * + * Ten columns of a hundred and four cards, so it scrolls sideways and the cards + * are the same third-overlapped stacks as everywhere else. What is worth showing + * above them is the two numbers that decide the game: how many runs have gone + * away, and how many rows are still in the stock waiting to bury everything. + */ +@Composable +fun SpiderScreen( + 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 held 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("Dealing…") } + abandoned?.let { PatienceLeftDialog(it, onExit, controller::dismissAbandoned) } + if (confirmingEnd) { + PatienceEndDialog("The deal is left as it stands.", onExit) { confirmingEnd = false } + } + } + return + } + + val runs = legal.filterIsInstance() + val canDeal = legal.contains(DealRow) + val finished = legal.isEmpty() + val targets = held?.let { (from, count) -> + runs.filter { it.from == from && it.count == count }.map { it.to }.toSet() + }.orEmpty() + val sources = runs.map { it.from to it.count }.toSet() + + fun take(column: Int, count: Int) { + val inHand = held + held = when { + inHand != null && targets.contains(column) -> { + controller.submit(MoveRun(inHand.first, column, inHand.second)) + null + } + + inHand?.first == column && inHand.second == count -> null + sources.contains(column to count) -> column to count + else -> null + } + } + + ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier -> + Column( + modifier = modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = when { + current.isWon -> "All eight runs, in ${current.moves} moves." + finished -> "Blocked — nothing left to try." + held != null -> "Tap the column it goes to." + else -> "${current.completed} of $RUNS_TO_WIN runs away." + }, + fontWeight = FontWeight.Bold, + ) + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + TableSlot( + highlighted = false, + onClick = { if (canDeal) controller.submit(DealRow) }, + ) { + if (current.stock.isNotEmpty()) { + CardBackView(width = TABLE_CARD_WIDTH) + } else { + EmptySpace() + } + } + Text( + text = "${current.stock.size / 10} rows left" + + if (!canDeal && current.stock.isNotEmpty()) " — fill every column first" else "", + style = MaterialTheme.typography.bodySmall, + ) + } + + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.Top, + ) { + current.tableau.forEachIndexed { index, pile -> + val total = pile.faceDown.size + pile.faceUp.size + if (total == 0) { + TableSlot( + highlighted = targets.contains(index), + onClick = { take(index, 1) }, + ) { EmptySpace() } + } else { + Box( + modifier = Modifier + .width(TABLE_CARD_WIDTH) + .height(TABLE_CARD_HEIGHT + TABLE_STACK_STEP * (total - 1)), + ) { + pile.faceDown.forEachIndexed { depth, _ -> + CardBackView( + width = TABLE_CARD_WIDTH, + modifier = Modifier.offset(y = TABLE_STACK_STEP * depth), + ) + } + pile.faceUp.forEachIndexed { depth, card -> + val count = pile.faceUp.size - depth + val row = pile.faceDown.size + depth + TableSlot( + modifier = Modifier.offset(y = TABLE_STACK_STEP * row), + highlighted = held == (index to count) || + (depth == pile.faceUp.lastIndex && targets.contains(index)), + onClick = { take(index, count) }, + ) { PlayingCardView(card = card, width = TABLE_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 { PatienceLeftDialog(it, onExit, controller::dismissAbandoned) } + if (confirmingEnd) { + PatienceEndDialog("The deal is left as it stands.", onExit) { confirmingEnd = false } + } + } +} 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 45ed393..2c23ca4 100644 --- a/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt +++ b/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt @@ -75,6 +75,9 @@ class CatalogTest { GameCatalog.PEG_SOLITAIRE, GameCatalog.YAHTZEE, GameCatalog.KLONDIKE, + GameCatalog.FREECELL, + GameCatalog.SPIDER, + GameCatalog.PYRAMID, GameCatalog.WIZARD, GameCatalog.CHESS, GameCatalog.TAYU,