Lay the Klondike table out the way a real one is

Three changes, all about where things are and how much room they take.

Columns now overlap by a third rather than sitting one under another. A column
of thirteen took more vertical space than a phone has; overlapped, it fits, and
a third is the most that can be hidden while every card still shows its rank and
suit. Absolute offsets rather than negative spacing, because the column has to
work out its own height anyway to reserve the right room.

The cards turned off the stock fan sideways by the same third. Drawing three and
showing one hid two cards the player is entitled to see -- they are face up on
the table. Only the last is playable, which the rules already enforce; the other
two are there to be read.

The stock and the cards turned from it move to the bottom left, under the hand
that turns them, and the foundations keep the top on their own. That is where
both live on a real table, and it puts the pile you tap most within reach of a
thumb rather than at the far corner of the screen.

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:07:39 +00:00
parent 40ea68f4c0
commit deaf85938c
@@ -9,8 +9,9 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth 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.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@@ -31,15 +32,18 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import org.prolibertate.games.game.cards.Card
import org.prolibertate.games.game.klondike.Draw import org.prolibertate.games.game.klondike.Draw
import org.prolibertate.games.game.klondike.KlondikeMove import org.prolibertate.games.game.klondike.KlondikeMove
import org.prolibertate.games.game.klondike.KlondikeState import org.prolibertate.games.game.klondike.KlondikeState
import org.prolibertate.games.game.klondike.MoveCards import org.prolibertate.games.game.klondike.MoveCards
import org.prolibertate.games.game.klondike.Redeal import org.prolibertate.games.game.klondike.Redeal
import org.prolibertate.games.game.klondike.Spot import org.prolibertate.games.game.klondike.Spot
import org.prolibertate.games.game.klondike.TableauPile
import org.prolibertate.games.game.klondike.foundationSuit import org.prolibertate.games.game.klondike.foundationSuit
import org.prolibertate.games.net.MatchController import org.prolibertate.games.net.MatchController
import org.prolibertate.games.ui.ScreenScaffold import org.prolibertate.games.ui.ScreenScaffold
import org.prolibertate.games.ui.common.CARD_ASPECT
import org.prolibertate.games.ui.common.CardBackView import org.prolibertate.games.ui.common.CardBackView
import org.prolibertate.games.ui.common.PlayingCardView import org.prolibertate.games.ui.common.PlayingCardView
import org.prolibertate.games.ui.theme.FeltGreenDark import org.prolibertate.games.ui.theme.FeltGreenDark
@@ -47,18 +51,39 @@ import org.prolibertate.games.ui.theme.Parchment
import org.prolibertate.games.ui.theme.WallaceGold import org.prolibertate.games.ui.theme.WallaceGold
private const val TITLE = "Klondike" private const val TITLE = "Klondike"
private val CARD_WIDTH: Dp = 40.dp
private val CARD_WIDTH: Dp = 44.dp
private val CARD_HEIGHT: Dp = CARD_WIDTH / CARD_ASPECT
/**
* How far down each card in a column sits from the one above it.
*
* Two thirds of a card, so each is overlapped by a third. That is the most that
* can be hidden while every card still shows its rank and suit, and it is what
* keeps a column of thirteen on the screen instead of running off the bottom.
*/
private val STACK_STEP: Dp = CARD_HEIGHT * 2f / 3f
/** The same overlap sideways, for the three cards turned off the stock. */
private val FAN_STEP: Dp = CARD_WIDTH * 2f / 3f
/** At most three are ever turned at once, so at most three are ever fanned. */
private const val MAX_FANNED = 3
/** /**
* The patience table. * The patience table.
* *
* Play is tap-to-take then tap-to-put, rather than dragging: on a phone a drag * Laid out the way a real one is: the homes along the top where cards are going,
* over seven columns of overlapping cards is mostly a test of aim. Tapping a * the columns filling the middle, and the pack in the bottom corner under your
* face-up card takes it and everything sitting on it, so a run travels the way * hand. Columns overlap by a third so a long one still fits, and the cards
* it would on a table. * turned off the stock fan sideways by the same third so all three can be seen
* rather than only the one on top.
* *
* Which taps do anything is read straight off the legal moves the rules offer, * Play is tap-to-take then tap-to-put rather than dragging: across seven columns
* so the screen never has to know why a move is allowed — only that it is. * of overlapping cards on a phone, a drag is mostly a test of aim. Tapping a
* face-up card takes it and everything resting on it, so a run travels the way
* it would on a table. Which taps do anything is read off the legal moves the
* rules offer, so the screen never has to know why a move is allowed.
*/ */
@Composable @Composable
fun KlondikeScreen( fun KlondikeScreen(
@@ -92,7 +117,6 @@ fun KlondikeScreen(
val canRedeal = legal.contains(Redeal) val canRedeal = legal.contains(Redeal)
val finished = legal.isEmpty() val finished = legal.isEmpty()
// Where the cards in hand could go, and which spots could be picked up.
val targets = held?.let { (from, count) -> val targets = held?.let { (from, count) ->
moves.filter { it.from == from && it.count == count }.map { it.to }.toSet() moves.filter { it.from == from && it.count == count }.map { it.to }.toSet()
}.orEmpty() }.orEmpty()
@@ -115,7 +139,7 @@ fun KlondikeScreen(
ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier -> ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier ->
Column( Column(
modifier = modifier.verticalScroll(rememberScrollState()), modifier = modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(10.dp),
) { ) {
Text( Text(
text = when { text = when {
@@ -127,26 +151,8 @@ fun KlondikeScreen(
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
) )
// The homes, along the top where they are being filled towards.
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
// Stock, waste, and the four homes across the top.
Slot(
highlighted = false,
onClick = { if (canDraw) controller.submit(Draw) else if (canRedeal) controller.submit(Redeal) },
) {
if (current.stock.isNotEmpty()) {
CardBackView(width = CARD_WIDTH)
} else {
Empty(label = if (canRedeal) "↻" else "")
}
}
Slot(
highlighted = held?.first == Spot.waste,
onClick = { take(Spot.waste, 1) },
) {
current.waste.lastOrNull()
?.let { PlayingCardView(card = it, width = CARD_WIDTH) }
?: Empty()
}
current.foundations.forEachIndexed { index, pile -> current.foundations.forEachIndexed { index, pile ->
Slot( Slot(
highlighted = targets.contains(Spot.foundation(index)), highlighted = targets.contains(Spot.foundation(index)),
@@ -162,28 +168,43 @@ fun KlondikeScreen(
Row( Row(
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(6.dp), horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.Top,
) { ) {
current.tableau.forEachIndexed { index, pile -> current.tableau.forEachIndexed { index, pile ->
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { ColumnOfCards(
if (pile.isEmpty) { pile = pile,
held = held,
index = index,
isTarget = targets.contains(Spot.tableau(index)),
onTap = { count -> take(Spot.tableau(index), count) },
)
}
}
// The pack sits at the bottom left, under the hand that turns it.
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Slot( Slot(
highlighted = targets.contains(Spot.tableau(index)), highlighted = false,
onClick = { take(Spot.tableau(index), 1) }, onClick = {
) { Empty() } if (canDraw) {
} controller.submit(Draw)
pile.faceDown.forEach { CardBackView(width = CARD_WIDTH) } } else if (canRedeal) {
pile.faceUp.forEachIndexed { depth, card -> controller.submit(Redeal)
// Taking a card takes everything resting on it, so
// the count is however many are below it in the run.
val count = pile.faceUp.size - depth
Slot(
highlighted = held == (Spot.tableau(index) to count) ||
(depth == pile.faceUp.lastIndex && targets.contains(Spot.tableau(index))),
onClick = { take(Spot.tableau(index), count) },
) { PlayingCardView(card = card, width = CARD_WIDTH) }
} }
},
) {
if (current.stock.isNotEmpty()) {
CardBackView(width = CARD_WIDTH)
} else {
Empty(label = if (canRedeal) "↻" else "")
} }
} }
WasteFan(
waste = current.waste,
fanned = minOf(current.options.drawCount, MAX_FANNED),
highlighted = held?.first == Spot.waste,
onTap = { take(Spot.waste, 1) },
)
} }
if (finished) { if (finished) {
@@ -203,10 +224,91 @@ fun KlondikeScreen(
} }
} }
/**
* One column, overlapped by a third.
*
* Absolute offsets rather than negative spacing: the height has to be worked out
* anyway so the column reserves the right room, and once that is known placing
* each card is the simpler half.
*/
@Composable @Composable
private fun Slot(highlighted: Boolean, onClick: () -> Unit, content: @Composable () -> Unit) { private fun ColumnOfCards(
pile: TableauPile,
held: Pair<Spot, Int>?,
index: Int,
isTarget: Boolean,
onTap: (Int) -> Unit,
) {
val total = pile.faceDown.size + pile.faceUp.size
if (total == 0) {
Slot(highlighted = isTarget, onClick = { onTap(1) }) { Empty() }
return
}
Box( Box(
modifier = Modifier modifier = Modifier
.width(CARD_WIDTH)
.height(CARD_HEIGHT + STACK_STEP * (total - 1)),
) {
pile.faceDown.forEachIndexed { depth, _ ->
CardBackView(
width = CARD_WIDTH,
modifier = Modifier.offset(y = STACK_STEP * depth),
)
}
pile.faceUp.forEachIndexed { depth, card ->
// Taking a card takes everything resting on it.
val count = pile.faceUp.size - depth
val row = pile.faceDown.size + depth
Slot(
modifier = Modifier.offset(y = STACK_STEP * row),
highlighted = held == (Spot.tableau(index) to count) ||
(depth == pile.faceUp.lastIndex && isTarget),
onClick = { onTap(count) },
) { PlayingCardView(card = card, width = CARD_WIDTH) }
}
}
}
/**
* The cards turned off the stock, fanned sideways.
*
* Drawing three and showing one would hide two cards the player is entitled to
* see — they are face up on the table. Only the last is playable, which the
* rules already enforce; the other two are there to be read.
*/
@Composable
private fun WasteFan(
waste: List<Card>,
fanned: Int,
highlighted: Boolean,
onTap: () -> Unit,
) {
val shown = waste.takeLast(fanned)
if (shown.isEmpty()) {
Slot(highlighted = false, onClick = onTap) { Empty() }
return
}
Box(modifier = Modifier.width(CARD_WIDTH + FAN_STEP * (shown.size - 1))) {
shown.forEachIndexed { position, card ->
val isTop = position == shown.lastIndex
Slot(
modifier = Modifier.offset(x = FAN_STEP * position),
highlighted = isTop && highlighted,
onClick = { if (isTop) onTap() },
) { PlayingCardView(card = card, width = CARD_WIDTH) }
}
}
}
@Composable
private fun Slot(
highlighted: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
Box(
modifier = modifier
.width(CARD_WIDTH) .width(CARD_WIDTH)
.border( .border(
border = BorderStroke(if (highlighted) 3.dp else 0.dp, WallaceGold), border = BorderStroke(if (highlighted) 3.dp else 0.dp, WallaceGold),
@@ -224,7 +326,7 @@ private fun Empty(label: String = "") {
Box( Box(
modifier = Modifier modifier = Modifier
.width(CARD_WIDTH) .width(CARD_WIDTH)
.aspectRatio(0.7f) .height(CARD_HEIGHT)
.background(FeltGreenDark, RoundedCornerShape(4.dp)), .background(FeltGreenDark, RoundedCornerShape(4.dp)),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {