Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e84119670 | ||
|
|
b90058afda | ||
|
|
ef69704e00 | ||
|
|
3b0b307997 |
@@ -12,6 +12,11 @@ const val LONGEST_ROAD_MIN = 5
|
||||
/** Knights required to take Largest Army. */
|
||||
const val LARGEST_ARMY_MIN = 3
|
||||
|
||||
/** Pieces each player has: a player who has laid them all cannot build another. */
|
||||
const val MAX_ROADS = 15
|
||||
const val MAX_SETTLEMENTS = 5
|
||||
const val MAX_CITIES = 4
|
||||
|
||||
/** Resource cards of each type the bank starts with. */
|
||||
const val BANK_PER_RESOURCE = 19
|
||||
|
||||
|
||||
@@ -272,11 +272,18 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
return takes
|
||||
}
|
||||
|
||||
/** Road placements that connect to [seat]'s network, cost aside. */
|
||||
/**
|
||||
* Road placements that connect to [seat]'s network, cost aside. A player
|
||||
* with all fifteen roads laid has none.
|
||||
*/
|
||||
private fun roadMoves(state: CatanState, seat: Int): List<BuildRoad> =
|
||||
if (state.roads.count { it == seat } >= MAX_ROADS) {
|
||||
emptyList()
|
||||
} else {
|
||||
(0 until CatanBoard.EDGE_COUNT)
|
||||
.filter { state.roads[it] == NO_PLAYER && roadConnects(state, seat, it) }
|
||||
.map { BuildRoad(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Settlement placements: an open vertex at least two edges from every
|
||||
@@ -285,15 +292,27 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
* own move.)
|
||||
*/
|
||||
private fun settlementVertices(state: CatanState, seat: Int): List<Int> =
|
||||
if (piecesOf(state, seat, BuildingType.SETTLEMENT) >= MAX_SETTLEMENTS) {
|
||||
emptyList()
|
||||
} else {
|
||||
(0 until CatanBoard.VERTEX_COUNT).filter { v ->
|
||||
vertexOpenForSettlement(state, v) &&
|
||||
CatanBoard.VERTEX_EDGES[v].any { state.roads[it] == seat }
|
||||
}
|
||||
}
|
||||
|
||||
private fun cityVertices(state: CatanState, seat: Int): List<Int> =
|
||||
if (piecesOf(state, seat, BuildingType.CITY) >= MAX_CITIES) {
|
||||
emptyList()
|
||||
} else {
|
||||
(0 until CatanBoard.VERTEX_COUNT).filter { v ->
|
||||
state.buildings[v] == CatanBuilding(BuildingType.SETTLEMENT, seat)
|
||||
}
|
||||
}
|
||||
|
||||
/** How many settlements or cities [seat] currently has standing. */
|
||||
private fun piecesOf(state: CatanState, seat: Int, type: BuildingType): Int =
|
||||
state.buildings.count { it?.owner == seat && it.type == type }
|
||||
|
||||
private fun tradeResponses(state: CatanState, seat: Int): List<CatanMove> {
|
||||
val offer = state.pendingTrade ?: return emptyList()
|
||||
@@ -310,13 +329,25 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
state.buildings[v] == null &&
|
||||
CatanBoard.VERTEX_NEIGHBORS[v].all { state.buildings[it] == null }
|
||||
|
||||
/** A road on [edge] would join [seat]'s network of roads and buildings. */
|
||||
/**
|
||||
* A road on [edge] would join [seat]'s network of roads and buildings.
|
||||
*
|
||||
* It must start from a crossing the network actually reaches. An opponent's
|
||||
* settlement or city on a crossing breaks the network there: roads on either
|
||||
* side of it do not join, so a road cannot be laid *past* it, even with one
|
||||
* of your own roads ending at the same crossing.
|
||||
*/
|
||||
private fun roadConnects(state: CatanState, seat: Int, edge: Int): Boolean {
|
||||
val (a, b) = CatanBoard.EDGE_VERTICES[edge]
|
||||
return CatanBoard.VERTEX_EDGES[a].any { state.roads[it] == seat } ||
|
||||
CatanBoard.VERTEX_EDGES[b].any { state.roads[it] == seat } ||
|
||||
state.buildings[a]?.owner == seat ||
|
||||
state.buildings[b]?.owner == seat
|
||||
return networkReaches(state, seat, a) || networkReaches(state, seat, b)
|
||||
}
|
||||
|
||||
/** Whether [seat]'s roads and buildings reach [vertex] so a road may start there. */
|
||||
private fun networkReaches(state: CatanState, seat: Int, vertex: Int): Boolean {
|
||||
val building = state.buildings[vertex]
|
||||
// Your own building is always a way in; anyone else's is a wall.
|
||||
if (building != null) return building.owner == seat
|
||||
return CatanBoard.VERTEX_EDGES[vertex].any { state.roads[it] == seat }
|
||||
}
|
||||
|
||||
private fun offerLegal(state: CatanState, seat: Int, move: OfferTrade): Boolean {
|
||||
|
||||
@@ -11,6 +11,7 @@ import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
@@ -30,6 +31,7 @@ import org.prolibertate.games.game.catan.BuildingType
|
||||
import org.prolibertate.games.game.catan.CatanBoard
|
||||
import org.prolibertate.games.game.catan.CatanState
|
||||
import org.prolibertate.games.game.catan.NO_PLAYER
|
||||
import org.prolibertate.games.game.catan.Resource
|
||||
|
||||
/**
|
||||
* The island, drawn and tappable.
|
||||
@@ -54,6 +56,8 @@ fun CatanBoardView(
|
||||
onHexTap: (Int) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
style: CatanStyle = LocalCatanStyle.current,
|
||||
/** Hexes to light up as having produced on the latest roll. */
|
||||
rolledHexes: Set<Int> = emptySet(),
|
||||
) {
|
||||
val measurer = rememberTextMeasurer()
|
||||
var size by remember { mutableStateOf(IntSize.Zero) }
|
||||
@@ -99,6 +103,7 @@ fun CatanBoardView(
|
||||
drawRect(color = style.sea)
|
||||
drawPorts(state, board, style, measurer)
|
||||
drawHexes(state, board, style, measurer)
|
||||
drawRolled(rolledHexes, board, style)
|
||||
drawRobber(state, board, style)
|
||||
drawRoads(state, board, style)
|
||||
drawEdgeTargets(targets, board, style)
|
||||
@@ -130,6 +135,17 @@ private fun DrawScope.drawHexes(
|
||||
}
|
||||
}
|
||||
|
||||
/** A bright wash and rim on each hex that paid out, drawn over the terrain and under the pieces. */
|
||||
private fun DrawScope.drawRolled(hexes: Set<Int>, board: BoardLayout, style: CatanStyle) {
|
||||
for (hex in hexes) {
|
||||
val centre = board.hexCenter(hex)
|
||||
val path = polygon(board.hexCorners(hex).map { insetToward(it, centre, style.hexInset + 0.04f) })
|
||||
drawPath(path, color = style.rolled.copy(alpha = 0.28f))
|
||||
drawPath(path, color = style.pieceOutline, style = Stroke(width = board.radius * 0.13f))
|
||||
drawPath(path, color = style.rolled, style = Stroke(width = board.radius * 0.075f))
|
||||
}
|
||||
}
|
||||
|
||||
private fun DrawScope.drawToken(
|
||||
number: Int,
|
||||
at: Pt,
|
||||
@@ -206,7 +222,7 @@ private fun DrawScope.drawPorts(
|
||||
val anchor = board.portAnchor(port.edge)
|
||||
val (a, b) = board.edgeEnds(port.edge)
|
||||
val tint = port.resource?.let { style.resource.getValue(it) } ?: style.portInk
|
||||
// Two short piers from the marker to the vertices that can use the harbour.
|
||||
// Two piers from the marker to the vertices that can use the harbour.
|
||||
for (end in listOf(a, b)) {
|
||||
drawLine(
|
||||
color = style.portMarker.copy(alpha = 0.9f),
|
||||
@@ -224,10 +240,26 @@ private fun DrawScope.drawPorts(
|
||||
center = anchor.offset(),
|
||||
style = Stroke(width = board.radius * 0.07f),
|
||||
)
|
||||
// What the harbour trades: a picture of the resource, or "?" for any.
|
||||
val iconAt = Pt(anchor.x, anchor.y - radius * 0.27f)
|
||||
val iconSize = radius * 0.5f
|
||||
val resource = port.resource
|
||||
if (resource != null) {
|
||||
drawResourceIcon(resource, iconAt, iconSize, tint, style.pieceOutline)
|
||||
} else {
|
||||
drawCentered(
|
||||
measurer = measurer,
|
||||
text = "?",
|
||||
at = iconAt,
|
||||
fontPx = board.radius * style.portText * 1.5f,
|
||||
color = style.portInk,
|
||||
weight = FontWeight.ExtraBold,
|
||||
)
|
||||
}
|
||||
drawCentered(
|
||||
measurer = measurer,
|
||||
text = "${port.ratio}:1",
|
||||
at = anchor,
|
||||
at = Pt(anchor.x, anchor.y + radius * 0.5f),
|
||||
fontPx = board.radius * style.portText,
|
||||
color = style.portInk,
|
||||
weight = FontWeight.Bold,
|
||||
@@ -235,6 +267,81 @@ private fun DrawScope.drawPorts(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A small pictogram of [resource] centred on [at], about 2·[s] across: a tree,
|
||||
* bricks, a sheep, an ear of wheat or a rock. Drawn with paths so there are no
|
||||
* image assets to ship or scale.
|
||||
*/
|
||||
private fun DrawScope.drawResourceIcon(resource: Resource, at: Pt, s: Float, fill: Color, line: Color) {
|
||||
val outline = Stroke(width = s * 0.12f)
|
||||
when (resource) {
|
||||
Resource.LUMBER -> {
|
||||
val crown = Path().apply {
|
||||
moveTo(at.x, at.y - s)
|
||||
lineTo(at.x + s * 0.8f, at.y + s * 0.3f)
|
||||
lineTo(at.x - s * 0.8f, at.y + s * 0.3f)
|
||||
close()
|
||||
}
|
||||
drawRect(color = line, topLeft = Offset(at.x - s * 0.14f, at.y + s * 0.3f), size = Size(s * 0.28f, s * 0.6f))
|
||||
drawPath(crown, color = fill)
|
||||
drawPath(crown, color = line, style = outline)
|
||||
}
|
||||
|
||||
Resource.BRICK -> {
|
||||
val w = s * 0.9f
|
||||
val h = s * 0.42f
|
||||
for ((row, shift) in listOf(0f to 0f, 1f to w * 0.5f)) {
|
||||
val top = at.y - h + row * h
|
||||
val left = at.x - w + shift - w * 0.25f
|
||||
for (col in 0..1) {
|
||||
val x = left + col * w
|
||||
drawRect(color = fill, topLeft = Offset(x, top), size = Size(w, h))
|
||||
drawRect(color = line, topLeft = Offset(x, top), size = Size(w, h), style = outline)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Resource.WOOL -> {
|
||||
for ((dx, dy) in listOf(-0.45f to 0f, 0.1f to -0.25f, 0.5f to 0.05f, 0f to 0.2f)) {
|
||||
drawCircle(color = fill, radius = s * 0.42f, center = Offset(at.x + dx * s, at.y + dy * s))
|
||||
}
|
||||
drawCircle(color = line, radius = s * 0.24f, center = Offset(at.x - s * 0.85f, at.y + s * 0.15f))
|
||||
drawRect(color = line, topLeft = Offset(at.x - s * 0.5f, at.y + s * 0.5f), size = Size(s * 0.14f, s * 0.45f))
|
||||
drawRect(color = line, topLeft = Offset(at.x + s * 0.3f, at.y + s * 0.5f), size = Size(s * 0.14f, s * 0.45f))
|
||||
}
|
||||
|
||||
Resource.GRAIN -> {
|
||||
drawLine(color = line, start = Offset(at.x, at.y + s), end = Offset(at.x, at.y - s * 0.7f), strokeWidth = s * 0.14f)
|
||||
for (i in 0..2) {
|
||||
val y = at.y - s * 0.7f + i * s * 0.5f
|
||||
for (side in listOf(-1f, 1f)) {
|
||||
val kernel = Path().apply {
|
||||
moveTo(at.x, y + s * 0.3f)
|
||||
lineTo(at.x + side * s * 0.6f, y - s * 0.05f)
|
||||
lineTo(at.x + side * s * 0.15f, y - s * 0.2f)
|
||||
close()
|
||||
}
|
||||
drawPath(kernel, color = fill)
|
||||
drawPath(kernel, color = line, style = Stroke(width = s * 0.08f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Resource.ORE -> {
|
||||
val peak = Path().apply {
|
||||
moveTo(at.x - s, at.y + s * 0.8f)
|
||||
lineTo(at.x - s * 0.3f, at.y - s * 0.6f)
|
||||
lineTo(at.x + s * 0.1f, at.y + s * 0.05f)
|
||||
lineTo(at.x + s * 0.45f, at.y - s * 0.3f)
|
||||
lineTo(at.x + s, at.y + s * 0.8f)
|
||||
close()
|
||||
}
|
||||
drawPath(peak, color = fill)
|
||||
drawPath(peak, color = line, style = outline)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pieces
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -102,7 +102,7 @@ object CatanGeometry {
|
||||
private const val LATTICE_Y_UNITS = 16
|
||||
|
||||
/** Empty space round the island, in hex radii, for the sea and the harbours. */
|
||||
const val MARGIN = 1.15f
|
||||
const val MARGIN = 1.75f
|
||||
|
||||
private val boardWidthInRadii = LATTICE_X_UNITS * SQRT3 / 2f + 2 * MARGIN
|
||||
private val boardHeightInRadii = LATTICE_Y_UNITS / 2f + 2 * MARGIN
|
||||
@@ -119,5 +119,5 @@ object CatanGeometry {
|
||||
|
||||
private const val SQRT3 = 1.7320508f
|
||||
|
||||
/** How far past the coast a harbour marker sits, in hex radii. */
|
||||
private const val PORT_OFFSET = 0.62f
|
||||
/** How far past the coast a harbour marker sits, in hex radii. Far enough that its piers are clearly visible. */
|
||||
internal const val PORT_OFFSET = 1.0f
|
||||
|
||||
@@ -171,6 +171,25 @@ object CatanInteraction {
|
||||
|
||||
fun canRoll(legal: List<CatanMove>): Boolean = legal.any { it is RollDice }
|
||||
|
||||
/**
|
||||
* The hexes that paid out for this turn's roll, so the board can light them.
|
||||
* Empty until the turn's roll has happened (the dice left over from the last
|
||||
* turn are not this turn's), on a seven, and for the hex the robber sits on.
|
||||
*/
|
||||
fun rolledHexes(state: CatanState): Set<Int> {
|
||||
if (state.lastDice.size != 2) return emptySet()
|
||||
if (state.phase == CatanPhase.AWAIT_ROLL || state.phase == CatanPhase.SETUP_FIRST ||
|
||||
state.phase == CatanPhase.SETUP_SECOND
|
||||
) {
|
||||
return emptySet()
|
||||
}
|
||||
val total = state.lastDice.sum()
|
||||
if (total == 7) return emptySet()
|
||||
return (0 until CatanBoard.HEX_COUNT)
|
||||
.filter { state.numbers[it] == total && it != state.robber }
|
||||
.toSet()
|
||||
}
|
||||
|
||||
fun canEndTurn(legal: List<CatanMove>): Boolean = legal.any { it is EndTurn }
|
||||
|
||||
fun canEndFreeRoads(legal: List<CatanMove>): Boolean = legal.any { it is EndFreeRoads }
|
||||
|
||||
@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
@@ -18,17 +19,25 @@ import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.prolibertate.games.game.catan.CatanPhase
|
||||
@@ -118,11 +127,18 @@ fun CatanHand(
|
||||
|
||||
/**
|
||||
* One player's public standing: colour, name, points, how many cards they hold
|
||||
* (not which), development cards, knights and their longest road.
|
||||
* (not which), and — in the roomier layout — development cards, knights and
|
||||
* their longest road.
|
||||
*
|
||||
* **Fixed height.** Every line is always present (blank when there is nothing to
|
||||
* say) and never wraps, so the card is the same height whatever the game is
|
||||
* doing. The board is given whatever room is left, so anything that made this
|
||||
* card taller or shorter would rescale the island under the player's thumb.
|
||||
*
|
||||
* [points] is passed in rather than computed here because the local player's
|
||||
* own count includes hidden victory-point cards and everyone else's does not;
|
||||
* the screen decides which.
|
||||
* the screen decides which. [compact] is the narrow portrait strip (four cards
|
||||
* across a phone); the default has room for the detail lines.
|
||||
*/
|
||||
@Composable
|
||||
fun CatanPlayerCard(
|
||||
@@ -132,6 +148,7 @@ fun CatanPlayerCard(
|
||||
points: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
style: CatanStyle = LocalCatanStyle.current,
|
||||
compact: Boolean = false,
|
||||
) {
|
||||
val onTurn = state.turn == seat && state.phase != CatanPhase.GAME_OVER
|
||||
val shape = RoundedCornerShape(12.dp)
|
||||
@@ -155,39 +172,54 @@ fun CatanPlayerCard(
|
||||
.background(style.seats[seat % style.seats.size])
|
||||
.border(1.dp, style.pieceOutline, CircleShape),
|
||||
)
|
||||
Text(
|
||||
text = seats.label(seat),
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
CardLine(seats.label(seat), MaterialTheme.typography.labelLarge, FontWeight.Bold)
|
||||
}
|
||||
CardLine("$points VP", MaterialTheme.typography.titleMedium, FontWeight.Bold)
|
||||
if (compact) {
|
||||
CardLine("${state.resourceCount(seat)} cards", MaterialTheme.typography.bodySmall)
|
||||
} else {
|
||||
CardLine(
|
||||
"${state.resourceCount(seat)} cards · ${state.devHandCounts[seat]} dev",
|
||||
MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
CardLine(
|
||||
"Knights ${state.knightsPlayed[seat]} · Road ${CatanRules.longestRoadFor(state, seat)}",
|
||||
MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = "$points VP",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
text = "${state.resourceCount(seat)} cards · ${state.devHandCounts[seat]} dev",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
Text(
|
||||
text = "Knights ${state.knightsPlayed[seat]} · Road ${CatanRules.longestRoadFor(state, seat)}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
val awards = buildList {
|
||||
if (state.longestRoadOwner == seat) add("Longest road")
|
||||
if (state.largestArmyOwner == seat) add("Largest army")
|
||||
if (state.longestRoadOwner == seat) add(if (compact) "Road" else "Longest road")
|
||||
if (state.largestArmyOwner == seat) add(if (compact) "Army" else "Largest army")
|
||||
}
|
||||
if (awards.isNotEmpty()) {
|
||||
Text(
|
||||
text = awards.joinToString(" · "),
|
||||
// Always drawn, so the card does not grow when somebody takes an award.
|
||||
CardLine(
|
||||
text = awards.joinToString(" · ").ifEmpty { BLANK },
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
weight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** A non-breaking space: a line of text with nothing in it, still one line tall. */
|
||||
private const val BLANK = "\u00A0"
|
||||
|
||||
/** One line of a player card: never wraps, so the card's height cannot change. */
|
||||
@Composable
|
||||
private fun CardLine(
|
||||
text: String,
|
||||
style: TextStyle,
|
||||
weight: FontWeight? = null,
|
||||
color: Color = Color.Unspecified,
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = style,
|
||||
fontWeight = weight,
|
||||
color = color,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -221,15 +253,14 @@ fun CatanDice(dice: List<Int>, modifier: Modifier = Modifier) {
|
||||
)
|
||||
}
|
||||
}
|
||||
if (dice.size == 2) {
|
||||
// Always drawn ("= –" before the first roll) so the row never changes width.
|
||||
Text(
|
||||
text = "= ${dice.sum()}",
|
||||
text = if (dice.size == 2) "= ${dice.sum()}" else "= –",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Actions
|
||||
@@ -238,7 +269,6 @@ fun CatanDice(dice: List<Int>, modifier: Modifier = Modifier) {
|
||||
/** What the action bar offers right now. Built by the screen from [CatanInteraction]. */
|
||||
data class CatanActions(
|
||||
val canRoll: Boolean = false,
|
||||
val showTurnActions: Boolean = false,
|
||||
val canBuild: Set<BuildKind> = emptySet(),
|
||||
val building: BuildKind? = null,
|
||||
val canBuyCard: Boolean = false,
|
||||
@@ -250,7 +280,16 @@ data class CatanActions(
|
||||
val canRepickSetup: Boolean = false,
|
||||
)
|
||||
|
||||
/** The buttons for whatever the player can do now. Pure display: every press is a lambda. */
|
||||
/**
|
||||
* The buttons for whatever the player can do now. Pure display: every press is a lambda.
|
||||
*
|
||||
* **The bar never changes size.** The top row holds the one "primary" action
|
||||
* (roll, end turn, done with roads, cancel) and keeps its height when it is
|
||||
* empty; the second row is always all six buttons, greyed out when they do not
|
||||
* apply. Buttons appearing and disappearing used to resize this area and, with
|
||||
* it, the board above — so nothing here may be conditionally *present*, only
|
||||
* conditionally *enabled*.
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun CatanActionBar(
|
||||
@@ -264,33 +303,48 @@ fun CatanActionBar(
|
||||
onEndFreeRoads: () -> Unit,
|
||||
onRepickSetup: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
/** Small screens: fold Road / Settlement / City into one "Build" button with a menu. */
|
||||
compact: Boolean = false,
|
||||
) {
|
||||
FlowRow(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().height(PRIMARY_ROW_HEIGHT),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
if (actions.canRoll) Button(onClick = onRoll) { Text(CatanText.ROLL) }
|
||||
|
||||
if (actions.canRepickSetup) {
|
||||
OutlinedButton(onClick = onRepickSetup) { Text(CatanText.CANCEL) }
|
||||
}
|
||||
|
||||
if (actions.showTurnActions) {
|
||||
BuildButton(BuildKind.ROAD, CatanText.ROAD, CatanText.ROAD_COST, actions, onBuild)
|
||||
BuildButton(BuildKind.SETTLEMENT, CatanText.SETTLEMENT, CatanText.SETTLEMENT_COST, actions, onBuild)
|
||||
BuildButton(BuildKind.CITY, CatanText.CITY, CatanText.CITY_COST, actions, onBuild)
|
||||
OutlinedButton(onClick = onBuyCard, enabled = actions.canBuyCard) {
|
||||
LabelWithCost(CatanText.BUY_CARD, CatanText.DEV_COST)
|
||||
}
|
||||
OutlinedButton(onClick = onPlayCard, enabled = actions.canPlayCard) { Text(CatanText.PLAY_CARD) }
|
||||
OutlinedButton(onClick = onTrade, enabled = actions.canTrade) { Text(CatanText.TRADE) }
|
||||
}
|
||||
|
||||
if (actions.canRepickSetup) OutlinedButton(onClick = onRepickSetup) { Text(CatanText.CANCEL) }
|
||||
if (actions.canEndFreeRoads) Button(onClick = onEndFreeRoads) { Text(CatanText.STOP_ROADS) }
|
||||
if (actions.canEndTurn) Button(onClick = onEndTurn) { Text(CatanText.END_TURN) }
|
||||
}
|
||||
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
if (compact) {
|
||||
BuildMenuButton(actions, onBuild)
|
||||
} else {
|
||||
BuildButton(BuildKind.ROAD, CatanText.ROAD, CatanText.ROAD_COST, actions, onBuild)
|
||||
BuildButton(BuildKind.SETTLEMENT, CatanText.SETTLEMENT, CatanText.SETTLEMENT_COST, actions, onBuild)
|
||||
BuildButton(BuildKind.CITY, CatanText.CITY, CatanText.CITY_COST, actions, onBuild)
|
||||
}
|
||||
OutlinedButton(onClick = onBuyCard, enabled = actions.canBuyCard) {
|
||||
LabelWithCost(CatanText.BUY_CARD, CatanText.DEV_COST)
|
||||
}
|
||||
OutlinedButton(onClick = onPlayCard, enabled = actions.canPlayCard) {
|
||||
LabelWithCost(CatanText.PLAY_CARD, CatanText.NO_COST)
|
||||
}
|
||||
OutlinedButton(onClick = onTrade, enabled = actions.canTrade) {
|
||||
LabelWithCost(CatanText.TRADE, CatanText.NO_COST)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Room for one button, held whether or not there is one to show. */
|
||||
private val PRIMARY_ROW_HEIGHT = 48.dp
|
||||
|
||||
@Composable
|
||||
private fun BuildButton(
|
||||
@@ -310,6 +364,52 @@ private fun BuildButton(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The small-screen stand-in for the three build buttons: one "Build" button
|
||||
* whose menu lists Road, Settlement and City with their costs. While a build
|
||||
* mode is armed the button shows which, and choosing that entry again cancels it.
|
||||
*/
|
||||
@Composable
|
||||
private fun BuildMenuButton(actions: CatanActions, onBuild: (BuildKind?) -> Unit) {
|
||||
var open by remember { mutableStateOf(false) }
|
||||
val armed = actions.building
|
||||
val anyEnabled = actions.canBuild.isNotEmpty() || armed != null
|
||||
val label: @Composable () -> Unit = {
|
||||
LabelWithCost(CatanText.BUILD, armed?.let { buildLabel(it) } ?: CatanText.NO_COST)
|
||||
}
|
||||
Box {
|
||||
if (armed != null) {
|
||||
FilledTonalButton(onClick = { open = true }, enabled = anyEnabled) { label() }
|
||||
} else {
|
||||
OutlinedButton(onClick = { open = true }, enabled = anyEnabled) { label() }
|
||||
}
|
||||
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
|
||||
for (kind in BuildKind.entries) {
|
||||
DropdownMenuItem(
|
||||
text = { LabelWithCost(buildLabel(kind), buildCost(kind)) },
|
||||
enabled = kind in actions.canBuild,
|
||||
onClick = {
|
||||
open = false
|
||||
onBuild(if (armed == kind) null else kind)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildLabel(kind: BuildKind): String = when (kind) {
|
||||
BuildKind.ROAD -> CatanText.ROAD
|
||||
BuildKind.SETTLEMENT -> CatanText.SETTLEMENT
|
||||
BuildKind.CITY -> CatanText.CITY
|
||||
}
|
||||
|
||||
private fun buildCost(kind: BuildKind): String = when (kind) {
|
||||
BuildKind.ROAD -> CatanText.ROAD_COST
|
||||
BuildKind.SETTLEMENT -> CatanText.SETTLEMENT_COST
|
||||
BuildKind.CITY -> CatanText.CITY_COST
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LabelWithCost(label: String, cost: String) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
|
||||
@@ -112,14 +112,13 @@ private fun PanelsPreview() {
|
||||
Column(Modifier.padding(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
(0 until state.playerCount).forEach { seat ->
|
||||
CatanPlayerCard(state, seat, seats, points[seat], Modifier.weight(1f))
|
||||
CatanPlayerCard(state, seat, seats, points[seat], Modifier.weight(1f), compact = true)
|
||||
}
|
||||
}
|
||||
CatanHand(state, seat = 0)
|
||||
CatanDice(state.lastDice)
|
||||
CatanActionBar(
|
||||
actions = CatanActions(
|
||||
showTurnActions = true,
|
||||
canBuild = setOf(BuildKind.ROAD, BuildKind.CITY),
|
||||
building = BuildKind.ROAD,
|
||||
canBuyCard = true,
|
||||
|
||||
@@ -24,6 +24,8 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.prolibertate.games.game.catan.AcceptTrade
|
||||
import org.prolibertate.games.game.catan.BuyDevCard
|
||||
@@ -130,7 +132,6 @@ fun CatanScreen(
|
||||
val playing = current.phase == CatanPhase.PLAYING && legal.isNotEmpty()
|
||||
val actions = CatanActions(
|
||||
canRoll = CatanInteraction.canRoll(legal),
|
||||
showTurnActions = playing,
|
||||
canBuild = CatanInteraction.canBuild(legal),
|
||||
building = building,
|
||||
canBuyCard = CatanInteraction.canBuyDevCard(legal),
|
||||
@@ -140,6 +141,7 @@ fun CatanScreen(
|
||||
canEndFreeRoads = CatanInteraction.canEndFreeRoads(legal),
|
||||
canRepickSetup = setupVertex != null,
|
||||
)
|
||||
val rolledHexes = CatanInteraction.rolledHexes(current)
|
||||
val prompt = CatanText.prompt(current, seats, setupVertexChosen = setupVertex != null, building = building)
|
||||
|
||||
val handle: (TapOutcome) -> Unit = { outcome ->
|
||||
@@ -160,6 +162,8 @@ fun CatanScreen(
|
||||
ScreenScaffold(title = CatanText.TITLE, onBack = onExit, actions = endGameAction) { modifier ->
|
||||
BoxWithConstraints(modifier = modifier) {
|
||||
val wide = maxWidth > maxHeight * 1.1f
|
||||
// Phones (either way up) get the one-button build menu.
|
||||
val compactBuild = !wide || maxWidth < 600.dp
|
||||
|
||||
val board: @Composable (Modifier) -> Unit = { boardModifier ->
|
||||
CatanBoardView(
|
||||
@@ -171,6 +175,7 @@ fun CatanScreen(
|
||||
onHexTap = { handle(CatanInteraction.onHexTap(it, legal)) },
|
||||
modifier = boardModifier,
|
||||
style = style,
|
||||
rolledHexes = rolledHexes,
|
||||
)
|
||||
}
|
||||
val players: @Composable (Boolean) -> Unit = { grid ->
|
||||
@@ -194,6 +199,7 @@ fun CatanScreen(
|
||||
legal.firstOrNull { m -> m is EndFreeRoads }?.let(submit)
|
||||
},
|
||||
onRepickSetup = { setupVertex = null },
|
||||
compact = compactBuild,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -275,6 +281,7 @@ private fun PlayerCards(
|
||||
points = pointsOf(seat),
|
||||
modifier = Modifier.weight(1f),
|
||||
style = style,
|
||||
compact = !twoPerRow,
|
||||
)
|
||||
}
|
||||
// Keep an odd last row's card the same width as the others.
|
||||
@@ -284,6 +291,14 @@ private fun PlayerCards(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The one line telling the player what is going on, and the dice.
|
||||
*
|
||||
* **Fixed height.** The text always occupies exactly two lines (padded when it is
|
||||
* shorter, clipped when it is longer) and a refused move replaces the prompt
|
||||
* rather than adding to it, so this row is the same height whatever is said and
|
||||
* the board below it never moves.
|
||||
*/
|
||||
@Composable
|
||||
private fun PromptRow(prompt: String, notice: String?, state: CatanState) {
|
||||
Row(
|
||||
@@ -291,16 +306,15 @@ private fun PromptRow(prompt: String, notice: String?, state: CatanState) {
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(prompt, style = MaterialTheme.typography.bodyMedium)
|
||||
if (notice != null) {
|
||||
Text(
|
||||
notice,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
text = notice ?: prompt,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (notice != null) MaterialTheme.colorScheme.error else Color.Unspecified,
|
||||
minLines = 2,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
CatanDice(state.lastDice)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,8 @@ data class CatanStyle(
|
||||
val tokenHotInk: Color,
|
||||
/** The robber's pawn. */
|
||||
val robber: Color,
|
||||
/** Outline and wash on hexes that produced for the latest dice roll. */
|
||||
val rolled: Color,
|
||||
/** Harbour marker disc and its ink. */
|
||||
val portMarker: Color,
|
||||
val portInk: Color,
|
||||
@@ -64,12 +66,12 @@ data class CatanStyle(
|
||||
val citySize: Float = 0.42f,
|
||||
val tokenRadius: Float = 0.36f,
|
||||
val robberSize: Float = 0.34f,
|
||||
val portRadius: Float = 0.34f,
|
||||
val portRadius: Float = 0.46f,
|
||||
val targetRadius: Float = 0.20f,
|
||||
/** How close a tap must land to a vertex / edge / hex centre to count. */
|
||||
val tapReach: Float = 0.55f,
|
||||
val tokenText: Float = 0.34f,
|
||||
val portText: Float = 0.26f,
|
||||
val portText: Float = 0.27f,
|
||||
)
|
||||
|
||||
/** The light palette, which is also the reference for the dark one. */
|
||||
@@ -88,6 +90,7 @@ private val LightStyle = CatanStyle(
|
||||
tokenInk = Color(0xFF2B2B2B),
|
||||
tokenHotInk = Color(0xFFC62828),
|
||||
robber = Color(0xFF263238),
|
||||
rolled = Color(0xFFFFFFFF),
|
||||
portMarker = Color(0xFFFFF8E1),
|
||||
portInk = Color(0xFF2B2B2B),
|
||||
resource = mapOf(
|
||||
|
||||
@@ -103,6 +103,12 @@ object CatanText {
|
||||
const val CONFIRM = "Confirm"
|
||||
const val STOP_ROADS = "Done with roads"
|
||||
const val BUILD = "Build"
|
||||
|
||||
/**
|
||||
* The empty second line of a button that has no cost, so every button in the
|
||||
* action bar is the same height. A non-breaking space: blank, but a line.
|
||||
*/
|
||||
const val NO_COST = "\u00A0"
|
||||
const val YOUR_HAND = "Your hand"
|
||||
const val CARDS = "cards"
|
||||
const val ACCEPT = "Accept"
|
||||
|
||||
@@ -340,6 +340,89 @@ class CatanRulesTest {
|
||||
)
|
||||
}
|
||||
|
||||
/** Seat 0 owns a settlement at vertex 0 and a two-road chain v0 - w - x; `beyond` continues past x. */
|
||||
private data class Chain(val state: CatanState, val x: Int, val beyond: Int, val spur: Int?)
|
||||
|
||||
private fun chain(): Chain {
|
||||
val e1 = CatanBoard.VERTEX_EDGES[0][0]
|
||||
val w = CatanBoard.otherEnd(e1, 0)
|
||||
val e2 = CatanBoard.VERTEX_EDGES[w].first { it != e1 }
|
||||
val x = CatanBoard.otherEnd(e2, w)
|
||||
val beyond = CatanBoard.VERTEX_EDGES[x].first { it != e2 }
|
||||
val spur = CatanBoard.VERTEX_EDGES[w].firstOrNull { it != e1 && it != e2 }
|
||||
val state = craft()
|
||||
.copy(resources = hands(hand(Resource.LUMBER to 5, Resource.BRICK to 5)))
|
||||
.withBuilding(0, BuildingType.SETTLEMENT, 0)
|
||||
.withRoad(e1, 0)
|
||||
.withRoad(e2, 0)
|
||||
return Chain(state, x, beyond, spur)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a road cannot be built past an opponent's settlement`() {
|
||||
val (open, x, beyond, spur) = chain()
|
||||
assertTrue("open crossing: the road continues", BuildRoad(beyond) in CatanRules.legalMoves(open, 0))
|
||||
|
||||
val blocked = open.withBuilding(x, BuildingType.SETTLEMENT, 1)
|
||||
assertFalse(
|
||||
"an opponent's settlement on the crossing breaks the network",
|
||||
BuildRoad(beyond) in CatanRules.legalMoves(blocked, 0),
|
||||
)
|
||||
// The same through a city, and while laying free roads from Road Building.
|
||||
val city = open.withBuilding(x, BuildingType.CITY, 1)
|
||||
assertFalse(BuildRoad(beyond) in CatanRules.legalMoves(city, 0))
|
||||
val free = blocked.copy(phase = CatanPhase.FREE_ROADS, pendingFreeRoads = 2)
|
||||
assertFalse(BuildRoad(beyond) in CatanRules.legalMoves(free, 0))
|
||||
// The rest of the network is untouched: the spur at the other crossing still works.
|
||||
if (spur != null) assertTrue(BuildRoad(spur) in CatanRules.legalMoves(blocked, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `your own settlement on the crossing does not block your road`() {
|
||||
val (open, x, beyond, _) = chain()
|
||||
val own = open.withBuilding(x, BuildingType.SETTLEMENT, 0)
|
||||
assertTrue(BuildRoad(beyond) in CatanRules.legalMoves(own, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a player runs out of pieces`() {
|
||||
val rich = hands(
|
||||
hand(
|
||||
Resource.LUMBER to 9, Resource.BRICK to 9, Resource.WOOL to 9,
|
||||
Resource.GRAIN to 9, Resource.ORE to 9,
|
||||
),
|
||||
)
|
||||
|
||||
// Roads: fifteen, and no sixteenth - in the main phase or from Road Building.
|
||||
var roads = craft().copy(resources = rich).withBuilding(0, BuildingType.SETTLEMENT, 0)
|
||||
repeat(14) {
|
||||
roads = roads.withRoad(CatanRules.legalMoves(roads, 0).filterIsInstance<BuildRoad>().first().edge, 0)
|
||||
}
|
||||
assertTrue("fourteen laid: one more is allowed", CatanRules.legalMoves(roads, 0).any { it is BuildRoad })
|
||||
roads = roads.withRoad(CatanRules.legalMoves(roads, 0).filterIsInstance<BuildRoad>().first().edge, 0)
|
||||
assertEquals(15, roads.roads.count { it == 0 })
|
||||
assertTrue("fifteen laid: none left", CatanRules.legalMoves(roads, 0).none { it is BuildRoad })
|
||||
val free = roads.copy(phase = CatanPhase.FREE_ROADS, pendingFreeRoads = 2)
|
||||
assertEquals(listOf<CatanMove>(EndFreeRoads), CatanRules.legalMoves(free, 0))
|
||||
|
||||
// Settlements: five.
|
||||
val homes = scattered(7)
|
||||
var settled = craft().copy(resources = rich)
|
||||
for (i in 0 until 4) settled = settled.withBuilding(homes[i], BuildingType.SETTLEMENT, 0)
|
||||
settled = settled.withRoad(CatanBoard.VERTEX_EDGES[homes[5]][0], 0)
|
||||
assertTrue("four standing: a fifth is allowed", BuildSettlement(homes[5]) in CatanRules.legalMoves(settled, 0))
|
||||
settled = settled.withBuilding(homes[4], BuildingType.SETTLEMENT, 0)
|
||||
assertFalse("five standing: no sixth", BuildSettlement(homes[5]) in CatanRules.legalMoves(settled, 0))
|
||||
|
||||
// Cities: four. A raised settlement returns to the supply, so five settlements stays possible.
|
||||
var raised = craft().copy(resources = rich)
|
||||
for (i in 0 until 3) raised = raised.withBuilding(homes[i], BuildingType.CITY, 0)
|
||||
raised = raised.withBuilding(homes[3], BuildingType.SETTLEMENT, 0)
|
||||
assertTrue("three cities: a fourth is allowed", BuildCity(homes[3]) in CatanRules.legalMoves(raised, 0))
|
||||
raised = raised.withBuilding(homes[3], BuildingType.CITY, 0).withBuilding(homes[4], BuildingType.SETTLEMENT, 0)
|
||||
assertFalse("four cities: no fifth", BuildCity(homes[4]) in CatanRules.legalMoves(raised, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a settlement needs an open vertex on the network, a city one of yours`() {
|
||||
val base = craft()
|
||||
|
||||
@@ -111,7 +111,7 @@ class CatanGeometryTest {
|
||||
val anchor = layout.portAnchor(e)
|
||||
val mid = layout.edgeMidpoint(e)
|
||||
assertTrue("harbour on edge $e should be past the coast", dist(anchor, centre) > dist(mid, centre))
|
||||
assertTrue("and not floating far from it", abs(dist(anchor, mid) - layout.radius * 0.62f) < 1f)
|
||||
assertTrue("and not floating far from it", abs(dist(anchor, mid) - layout.radius * PORT_OFFSET) < 1f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,4 +322,27 @@ class CatanInteractionTest {
|
||||
val short = pending.withHand(1, hand())
|
||||
assertEquals(false to true, CatanInteraction.tradeResponses(CatanRules.legalMoves(short, 1)))
|
||||
}
|
||||
|
||||
// --- Rolled-hex highlight --------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `rolled hexes are those carrying the number, minus the robber's`() {
|
||||
val base = playing(turn = 0)
|
||||
val number = base.numbers.first { it != 0 && it != 7 }
|
||||
val state = base.copy(lastDice = if (number <= 7) listOf(number - 1, 1) else listOf(6, number - 6))
|
||||
val all = (0 until CatanBoard.HEX_COUNT).filter { state.numbers[it] == number }
|
||||
assertTrue(all.isNotEmpty())
|
||||
assertEquals(all.filter { it != state.robber }.toSet(), CatanInteraction.rolledHexes(state))
|
||||
val blocked = state.copy(robber = all.first())
|
||||
assertFalse(all.first() in CatanInteraction.rolledHexes(blocked))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nothing is highlighted before this turn's roll, on a seven, or in setup`() {
|
||||
val rolled = playing(turn = 0).copy(lastDice = listOf(3, 3))
|
||||
assertTrue(CatanInteraction.rolledHexes(rolled.copy(phase = CatanPhase.AWAIT_ROLL)).isEmpty())
|
||||
assertTrue(CatanInteraction.rolledHexes(rolled.copy(lastDice = listOf(3, 4))).isEmpty())
|
||||
assertTrue(CatanInteraction.rolledHexes(rolled.copy(phase = CatanPhase.SETUP_FIRST)).isEmpty())
|
||||
assertTrue(CatanInteraction.rolledHexes(rolled.copy(lastDice = emptyList())).isEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
+13
-1
@@ -159,7 +159,19 @@ its edge.
|
||||
A settlement goes on an **empty vertex** at least two edges from every standing
|
||||
building and connected to the seat's own road network. A city upgrades one of
|
||||
the seat's own settlements. A road goes on an empty edge that connects to the
|
||||
seat's network (a road or a building at one of its ends).
|
||||
seat's network: it must start from a crossing where the seat has a building, or
|
||||
where one of its roads ends.
|
||||
|
||||
**An opponent's settlement or city on a crossing breaks the network there.** A
|
||||
road cannot be laid past it, even from a crossing where one of your own roads
|
||||
ends — only a crossing you own a building on is a way through. The same rule
|
||||
applies to the free roads of a Road Building card, and it is the same wall that
|
||||
cuts Longest Road (below).
|
||||
|
||||
**Pieces are limited**: each player has **15 roads, 5 settlements and 4 cities**.
|
||||
A player who has laid them all cannot build another of that kind. Raising a
|
||||
settlement into a city returns the settlement to the supply, so the five
|
||||
settlements remain available to be laid again.
|
||||
|
||||
The development deck is the published 25 cards: **14 Knights, 2 Road Building,
|
||||
2 Monopoly, 2 Year of Plenty, 5 Victory Point**. Cards are bought blind from the
|
||||
|
||||
@@ -71,6 +71,14 @@ screen's UI state. The legal-move list stays the gate.
|
||||
size of the deck; it is not known to a client. Card *counts* (`resourceCount`,
|
||||
`devHandCounts`) are public.
|
||||
5. **The state is immutable.** Never mutate it; submit a move.
|
||||
6. **The layout must not move.** Everything outside the board has a *fixed
|
||||
height*, and the board takes whatever is left, so the island's scale is the
|
||||
same all game. Concretely: a control is *enabled or disabled*, never
|
||||
*present or absent* (the action bar always shows every button); text that can
|
||||
vary in length is pinned to a fixed number of lines (`minLines = maxLines`);
|
||||
a line that is sometimes empty is still drawn (a non-breaking space), as the
|
||||
award line on a player card is. A change that makes any non-board element
|
||||
taller or shorter at runtime will rescale the board under the player's thumb.
|
||||
|
||||
## Interaction model (what the player actually does)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user