From deaf85938c92795db064f8484bcb8bf56d29b0fc Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 16:07:39 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01Urn6eEBA6XzrnfoibL7faC --- .../games/ui/game/KlondikeScreen.kt | 198 +++++++++++++----- 1 file changed, 150 insertions(+), 48 deletions(-) diff --git a/app/src/main/java/org/prolibertate/games/ui/game/KlondikeScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/KlondikeScreen.kt index 1bd3ec3..af7920b 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/KlondikeScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/KlondikeScreen.kt @@ -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) { - 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) } + 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 = 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?, + 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, + 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, ) {