Screens for FreeCell, Spider and Pyramid, and all three into the catalogue

Seven games now, and the three new ones are tappable.

The furniture they share moves into CardTable: 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 rather than being rewritten to use them -- it was written
first, its constants are tuned to seven columns, and its private helpers cannot
collide with these.

FreeCell puts the cells on the left and the homes on the right, which is what
makes the cells read as somewhere to put things rather than somewhere cards come
from. Spider shows the two numbers the game actually turns on above the columns:
runs gone away, and rows still in the stock waiting to bury everything.

Pyramid is drawn as it is dealt, each row offset half a card so every card sits
in the notch between the two below it. The shape is the rules -- a card is only
in play once both the cards resting on it have gone -- so seeing it at a glance
is most of playing it. Tapping a king takes it alone; tapping anything else
holds it and lights up only what would make thirteen with it.

All three restart by rebuilding the controller, as Klondike and peg solitaire
do, because a blocked deal is genuinely finished and advanceIdle never runs.

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 16:23:17 +00:00
parent 4e7aa98b61
commit 459670012e
8 changed files with 898 additions and 0 deletions
@@ -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",
@@ -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)) },
)
}
@@ -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<FreeCellState, FreeCellMove>(
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<SpiderState, SpiderMove>(
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<PyramidState, PyramidMove>(
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<MorrisState, MorrisMove>(
@@ -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") } },
)
}
@@ -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<FreeCellState, FreeCellMove>,
onRestart: () -> Unit,
onExit: () -> Unit,
) {
val state by controller.state.collectAsState()
val legal by controller.legalMoves.collectAsState()
val abandoned by controller.abandoned.collectAsState()
var held by remember { mutableStateOf<Pair<Place, Int>?>(null) }
var confirmingEnd by remember { mutableStateOf(false) }
val endGameAction: @Composable () -> Unit = {
TextButton(onClick = { confirmingEnd = true }) { Text("End game") }
}
val current = state
if (current == null) {
ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier ->
Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") }
abandoned?.let { PatienceLeftDialog(it, onExit, controller::dismissAbandoned) }
if (confirmingEnd) {
PatienceEndDialog("The deal is left as it stands.", onExit) { confirmingEnd = false }
}
}
return
}
val moves = legal.filterIsInstance<MoveTo>()
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 }
}
}
}
@@ -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<PyramidState, PyramidMove>,
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<PyramidSpot?>(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<TakeKing>().map { it.spot }.toSet()
val pairs = legal.filterIsInstance<TakePair>()
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<PyramidSpot>,
partners: Set<PyramidSpot>,
pairable: Set<PyramidSpot>,
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(),
)
}
}
}
}
}
}
@@ -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<SpiderState, SpiderMove>,
onRestart: () -> Unit,
onExit: () -> Unit,
) {
val state by controller.state.collectAsState()
val legal by controller.legalMoves.collectAsState()
val abandoned by controller.abandoned.collectAsState()
var held by remember { mutableStateOf<Pair<Int, Int>?>(null) }
var confirmingEnd by remember { mutableStateOf(false) }
val endGameAction: @Composable () -> Unit = {
TextButton(onClick = { confirmingEnd = true }) { Text("End game") }
}
val current = state
if (current == null) {
ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier ->
Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") }
abandoned?.let { PatienceLeftDialog(it, onExit, controller::dismissAbandoned) }
if (confirmingEnd) {
PatienceEndDialog("The deal is left as it stands.", onExit) { confirmingEnd = false }
}
}
return
}
val runs = legal.filterIsInstance<MoveRun>()
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 }
}
}
}