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:
@@ -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<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,
|
||||
) {
|
||||
|
||||
Reference in New Issue
Block a user