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, ) {