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.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
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.shape.RoundedCornerShape
@@ -31,15 +32,18 @@ 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.cards.Card
import org.prolibertate.games.game.klondike.Draw
import org.prolibertate.games.game.klondike.KlondikeMove
import org.prolibertate.games.game.klondike.KlondikeState
import org.prolibertate.games.game.klondike.MoveCards
import org.prolibertate.games.game.klondike.Redeal
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.net.MatchController
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.PlayingCardView
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
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.
*
* Play is tap-to-take then tap-to-put, rather than dragging: on a phone a drag
* over seven columns of overlapping cards is mostly a test of aim. Tapping a
* face-up card takes it and everything sitting on it, so a run travels the way
* it would on a table.
* Laid out the way a real one is: the homes along the top where cards are going,
* the columns filling the middle, and the pack in the bottom corner under your
* hand. Columns overlap by a third so a long one still fits, and the cards
* 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,
* so the screen never has to know why a move is allowed — only that it is.
* Play is tap-to-take then tap-to-put rather than dragging: across seven columns
* 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
fun KlondikeScreen(
@@ -92,7 +117,6 @@ fun KlondikeScreen(
val canRedeal = legal.contains(Redeal)
val finished = legal.isEmpty()
// Where the cards in hand could go, and which spots could be picked up.
val targets = held?.let { (from, count) ->
moves.filter { it.from == from && it.count == count }.map { it.to }.toSet()
}.orEmpty()
@@ -115,7 +139,7 @@ fun KlondikeScreen(
ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier ->
Column(
modifier = modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
text = when {
@@ -127,26 +151,8 @@ fun KlondikeScreen(
fontWeight = FontWeight.Bold,
)
// The homes, along the top where they are being filled towards.
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 ->
Slot(
highlighted = targets.contains(Spot.foundation(index)),
@@ -162,28 +168,43 @@ fun KlondikeScreen(
Row(
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.Top,
) {
current.tableau.forEachIndexed { index, pile ->
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
if (pile.isEmpty) {
ColumnOfCards(
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(
highlighted = targets.contains(Spot.tableau(index)),
onClick = { take(Spot.tableau(index), 1) },
) { Empty() }
}
pile.faceDown.forEach { CardBackView(width = CARD_WIDTH) }
pile.faceUp.forEachIndexed { depth, card ->
// 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) }
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 "")
}
}
WasteFan(
waste = current.waste,
fanned = minOf(current.options.drawCount, MAX_FANNED),
highlighted = held?.first == Spot.waste,
onTap = { take(Spot.waste, 1) },
)
}
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
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(
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)
.border(
border = BorderStroke(if (highlighted) 3.dp else 0.dp, WallaceGold),
@@ -224,7 +326,7 @@ private fun Empty(label: String = "") {
Box(
modifier = Modifier
.width(CARD_WIDTH)
.aspectRatio(0.7f)
.height(CARD_HEIGHT)
.background(FeltGreenDark, RoundedCornerShape(4.dp)),
contentAlignment = Alignment.Center,
) {