Release: dev to main #7
@@ -42,7 +42,20 @@ data class GameDescriptor(
|
||||
val teamBased: Boolean,
|
||||
val blurb: String,
|
||||
val available: Boolean,
|
||||
/**
|
||||
* Playable on a dev build while still hidden from production.
|
||||
*
|
||||
* [available] is the release gate: it is what a production build lists and
|
||||
* lets people open. A game that has an engine and a screen but has not yet
|
||||
* been verified on real devices sets this instead, so it can be played and
|
||||
* tested from the dev channel without being offered to everyone. Flip
|
||||
* [available] on (and this off) once it has been verified.
|
||||
*/
|
||||
val devPreview: Boolean = false,
|
||||
) {
|
||||
/** Whether the menu should let this game be opened in the given kind of build. */
|
||||
fun playableIn(devBuild: Boolean): Boolean = available || (devBuild && devPreview)
|
||||
|
||||
/**
|
||||
* The one menu this game is listed under.
|
||||
*
|
||||
@@ -348,10 +361,12 @@ object GameCatalog {
|
||||
maxPlayers = 4,
|
||||
teamBased = false,
|
||||
blurb = "Trade, build, and settle the island.",
|
||||
// Listed, not playable: the engine and screen land with the Katan
|
||||
// game task. Dev builds show the entry as *Coming soon*; a
|
||||
// production build lists only what can actually be played.
|
||||
// Engine and screen exist but have not been verified on two real
|
||||
// devices yet. Dev builds can open it as a *Preview*; a production
|
||||
// build does not list it. Flip `available` on, and `devPreview`
|
||||
// off, once two-device play has been verified.
|
||||
available = false,
|
||||
devPreview = true,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -364,7 +364,11 @@ fun SectionScreen(
|
||||
}
|
||||
}
|
||||
items(games) { game ->
|
||||
GameTile(game = game, onClick = { onPickGame(game) })
|
||||
GameTile(
|
||||
game = game,
|
||||
playable = game.playableIn(devBuild = showComingSoon),
|
||||
onClick = { onPickGame(game) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -403,12 +407,12 @@ private fun SectionTile(menu: GameMenu, count: Int, onClick: () -> Unit) {
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun GameTile(game: GameDescriptor, onClick: () -> Unit) {
|
||||
private fun GameTile(game: GameDescriptor, playable: Boolean, onClick: () -> Unit) {
|
||||
Card(
|
||||
onClick = onClick,
|
||||
enabled = game.available,
|
||||
enabled = playable,
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (game.available) {
|
||||
containerColor = if (playable) {
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surface
|
||||
@@ -427,13 +431,18 @@ private fun GameTile(game: GameDescriptor, onClick: () -> Unit) {
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
Text(
|
||||
text = when {
|
||||
!game.available -> "Coming soon"
|
||||
val players = when {
|
||||
// A puzzle for one, rather than "1 players".
|
||||
game.maxPlayers == 1 -> "On your own"
|
||||
game.minPlayers == game.maxPlayers -> "${game.minPlayers} players"
|
||||
else -> "${game.minPlayers}–${game.maxPlayers} players"
|
||||
}
|
||||
Text(
|
||||
text = when {
|
||||
!playable -> "Coming soon"
|
||||
// Playable on this dev build only; production hides it.
|
||||
!game.available -> "Preview · $players"
|
||||
else -> players
|
||||
},
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
|
||||
@@ -57,6 +57,7 @@ import org.prolibertate.games.game.morris.MorrisOptions
|
||||
import org.prolibertate.games.game.pirates.PiratesLevel
|
||||
import org.prolibertate.games.game.pirates.PiratesOptions
|
||||
import org.prolibertate.games.game.president.PresidentOptions
|
||||
import org.prolibertate.games.game.catan.CatanOptions
|
||||
import org.prolibertate.games.game.sequence.SequenceOptions
|
||||
import org.prolibertate.games.game.tayu.TayuLevel
|
||||
import org.prolibertate.games.game.tayu.TayuOptions
|
||||
@@ -104,6 +105,7 @@ fun GameSetupScreen(
|
||||
var freecell by remember { mutableStateOf(FreeCellOptions()) }
|
||||
var spider by remember { mutableStateOf(SpiderOptions()) }
|
||||
var pyramid by remember { mutableStateOf(PyramidOptions()) }
|
||||
var catan by remember { mutableStateOf(CatanOptions()) }
|
||||
// Not an engine option: which seat the local player takes. Seat 0 is
|
||||
// always White, so choosing Black means sitting in seat 1.
|
||||
var playWhite by remember { mutableStateOf(true) }
|
||||
@@ -134,6 +136,7 @@ fun GameSetupScreen(
|
||||
GameCatalog.FREECELL -> setupJson.encodeToString(freecell)
|
||||
GameCatalog.SPIDER -> setupJson.encodeToString(spider)
|
||||
GameCatalog.PYRAMID -> setupJson.encodeToString(pyramid)
|
||||
GameCatalog.CATAN -> setupJson.encodeToString(catan)
|
||||
else -> "{}"
|
||||
}
|
||||
val seatCount = seatCountFor(descriptor.id, optionsJson)
|
||||
@@ -199,6 +202,8 @@ fun GameSetupScreen(
|
||||
|
||||
GameCatalog.PYRAMID -> PyramidOptionsEditor(pyramid) { pyramid = it }
|
||||
|
||||
GameCatalog.CATAN -> CatanOptionsEditor(catan) { catan = it }
|
||||
|
||||
else -> Text("No options yet for this game.")
|
||||
}
|
||||
|
||||
@@ -303,6 +308,9 @@ fun seatCountFor(gameId: String, optionsJson: String): Int {
|
||||
GameCatalog.TAYU ->
|
||||
setupJson.decodeFromString<TayuOptions>(optionsJson).playerCount
|
||||
|
||||
GameCatalog.CATAN ->
|
||||
setupJson.decodeFromString<CatanOptions>(optionsJson).playerCount
|
||||
|
||||
else -> fallback
|
||||
}
|
||||
}.getOrDefault(fallback)
|
||||
@@ -326,6 +334,27 @@ fun teamForSeat(gameId: String, seat: Int, seatCount: Int): Int = when (gameId)
|
||||
else -> seat
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CatanOptionsEditor(options: CatanOptions, onChange: (CatanOptions) -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ChipRow(
|
||||
label = "Players",
|
||||
values = listOf(3, 4),
|
||||
selected = options.playerCount,
|
||||
display = { "$it players" },
|
||||
onSelect = { onChange(options.copy(playerCount = it)) },
|
||||
)
|
||||
ChipRow(
|
||||
label = "Game to",
|
||||
// Five is a short game for trying things out; ten is the published target.
|
||||
values = listOf(5, 8, 10, 12),
|
||||
selected = options.pointsToWin,
|
||||
display = { "$it points" },
|
||||
onSelect = { onChange(options.copy(pointsToWin = it)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EuchreOptionsEditor(options: EuchreOptions, onChange: (EuchreOptions) -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
|
||||
@@ -13,6 +13,10 @@ import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import org.prolibertate.games.game.GameCatalog
|
||||
import org.prolibertate.games.game.catan.CatanAi
|
||||
import org.prolibertate.games.game.catan.CatanMove
|
||||
import org.prolibertate.games.game.catan.CatanRules
|
||||
import org.prolibertate.games.game.catan.CatanState
|
||||
import org.prolibertate.games.game.chess.ChessAi
|
||||
import org.prolibertate.games.game.chess.ChessMove
|
||||
import org.prolibertate.games.game.chess.ChessRules
|
||||
@@ -110,6 +114,7 @@ import org.prolibertate.games.game.wizard.WizardState
|
||||
import org.prolibertate.games.net.MatchController
|
||||
import org.prolibertate.games.settings.Settings
|
||||
import org.prolibertate.games.ui.game.BackgammonScreen
|
||||
import org.prolibertate.games.ui.game.catan.CatanScreen
|
||||
import org.prolibertate.games.ui.game.CheckersScreen
|
||||
import org.prolibertate.games.ui.game.ChessScreen
|
||||
import org.prolibertate.games.ui.game.CrazyEightsScreen
|
||||
@@ -668,6 +673,30 @@ fun PlayScreen(
|
||||
ChessScreen(controller = controller, localSeat = route.localSeat, onExit = onExit)
|
||||
}
|
||||
|
||||
GameCatalog.CATAN -> {
|
||||
val controller = remember(route) {
|
||||
MatchController<CatanState, CatanMove>(
|
||||
rules = CatanRules,
|
||||
ai = CatanAi(),
|
||||
config = route.config,
|
||||
scope = scope,
|
||||
role = role,
|
||||
localSeats = setOf(route.localSeat),
|
||||
primarySeat = route.localSeat,
|
||||
// A computer's turn is several small moves (roll, build,
|
||||
// trade, end), so the pause is per move, not per turn.
|
||||
aiThinkingMillis = { settings.scaled(450L) },
|
||||
)
|
||||
}
|
||||
StartMatch(env, controller, route)
|
||||
CatanScreen(
|
||||
controller = controller,
|
||||
localSeat = route.localSeat,
|
||||
seatNames = route.config.seats.sortedBy { it.seat }.map { it.name },
|
||||
onExit = onExit,
|
||||
)
|
||||
}
|
||||
|
||||
else -> Text("That game isn't playable yet.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
package org.prolibertate.games.ui.game.catan
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
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.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.TextMeasurer
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.drawText
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
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
|
||||
|
||||
/**
|
||||
* The island, drawn and tappable.
|
||||
*
|
||||
* A stateless view: it is handed the state, the parts of the board that are
|
||||
* currently tappable ([targets]) and the one setup settlement that is waiting
|
||||
* for its road ([selectedVertex]), and it reports taps. It knows no rules; what
|
||||
* a tap *means* is decided by [CatanInteraction]. Where things are is decided by
|
||||
* [BoardLayout], and what they look like by [CatanStyle], so this file is only
|
||||
* the glue between the three.
|
||||
*
|
||||
* To change how a piece is drawn, edit the matching `draw…` function below. To
|
||||
* change a colour or a proportion, edit [CatanStyle] instead.
|
||||
*/
|
||||
@Composable
|
||||
fun CatanBoardView(
|
||||
state: CatanState,
|
||||
targets: BoardTargets,
|
||||
selectedVertex: Int?,
|
||||
onVertexTap: (Int) -> Unit,
|
||||
onEdgeTap: (Int) -> Unit,
|
||||
onHexTap: (Int) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
style: CatanStyle = LocalCatanStyle.current,
|
||||
) {
|
||||
val measurer = rememberTextMeasurer()
|
||||
var size by remember { mutableStateOf(IntSize.Zero) }
|
||||
val layout = remember(size) {
|
||||
if (size.width > 0 && size.height > 0) {
|
||||
CatanGeometry.fit(size.width.toFloat(), size.height.toFloat())
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// Held in State so the gesture detector, which is only restarted when the
|
||||
// layout or targets change, always calls the latest callbacks.
|
||||
val vertexTap by rememberUpdatedState(onVertexTap)
|
||||
val edgeTap by rememberUpdatedState(onEdgeTap)
|
||||
val hexTap by rememberUpdatedState(onHexTap)
|
||||
|
||||
Canvas(
|
||||
modifier = modifier
|
||||
.aspectRatio(CatanGeometry.ASPECT)
|
||||
.onSizeChanged { size = it }
|
||||
.semantics { contentDescription = "Katan island board" }
|
||||
.pointerInput(layout, targets, style) {
|
||||
detectTapGestures { tap ->
|
||||
val board = layout ?: return@detectTapGestures
|
||||
val at = Pt(tap.x, tap.y)
|
||||
val reach = board.radius * style.tapReach
|
||||
when {
|
||||
targets.vertices.isNotEmpty() ->
|
||||
board.nearestVertex(at, reach, targets.vertices)?.let { vertexTap(it) }
|
||||
|
||||
targets.edges.isNotEmpty() ->
|
||||
board.nearestEdge(at, reach, targets.edges)?.let { edgeTap(it) }
|
||||
|
||||
targets.hexes.isNotEmpty() ->
|
||||
// A hex is big: accept a tap anywhere near its middle.
|
||||
board.nearestHex(at, board.radius * 0.95f, targets.hexes)?.let { hexTap(it) }
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
val board = layout ?: return@Canvas
|
||||
drawRect(color = style.sea)
|
||||
drawPorts(state, board, style, measurer)
|
||||
drawHexes(state, board, style, measurer)
|
||||
drawRobber(state, board, style)
|
||||
drawRoads(state, board, style)
|
||||
drawEdgeTargets(targets, board, style)
|
||||
drawBuildings(state, board, style)
|
||||
drawVertexTargets(targets, selectedVertex, board, style)
|
||||
drawHexTargets(targets, board, style)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Terrain and tokens
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private fun DrawScope.drawHexes(
|
||||
state: CatanState,
|
||||
board: BoardLayout,
|
||||
style: CatanStyle,
|
||||
measurer: TextMeasurer,
|
||||
) {
|
||||
for (hex in 0 until CatanBoard.HEX_COUNT) {
|
||||
val centre = board.hexCenter(hex)
|
||||
val corners = board.hexCorners(hex).map { insetToward(it, centre, style.hexInset) }
|
||||
val path = polygon(corners)
|
||||
drawPath(path, color = style.terrain.getValue(state.terrains[hex]))
|
||||
drawPath(path, color = style.hexEdge, style = Stroke(width = board.radius * 0.03f))
|
||||
|
||||
val number = state.numbers[hex]
|
||||
if (number != 0) drawToken(number, centre, board, style, measurer)
|
||||
}
|
||||
}
|
||||
|
||||
private fun DrawScope.drawToken(
|
||||
number: Int,
|
||||
at: Pt,
|
||||
board: BoardLayout,
|
||||
style: CatanStyle,
|
||||
measurer: TextMeasurer,
|
||||
) {
|
||||
val radius = board.radius * style.tokenRadius
|
||||
drawCircle(color = style.token, radius = radius, center = at.offset())
|
||||
drawCircle(
|
||||
color = style.hexEdge,
|
||||
radius = radius,
|
||||
center = at.offset(),
|
||||
style = Stroke(width = board.radius * 0.02f),
|
||||
)
|
||||
val hot = number == 6 || number == 8
|
||||
drawCentered(
|
||||
measurer = measurer,
|
||||
text = number.toString(),
|
||||
at = Pt(at.x, at.y - radius * 0.12f),
|
||||
fontPx = board.radius * style.tokenText,
|
||||
color = if (hot) style.tokenHotInk else style.tokenInk,
|
||||
weight = if (hot) FontWeight.ExtraBold else FontWeight.Bold,
|
||||
)
|
||||
// One pip per thirty-sixth of the odds, so the likelier numbers read at a glance.
|
||||
val pips = CatanBoard.numberOdds(number)
|
||||
val pipRadius = radius * 0.075f
|
||||
val gap = pipRadius * 2.6f
|
||||
val startX = at.x - gap * (pips - 1) / 2f
|
||||
for (i in 0 until pips) {
|
||||
drawCircle(
|
||||
color = if (hot) style.tokenHotInk else style.tokenInk,
|
||||
radius = pipRadius,
|
||||
center = Offset(startX + i * gap, at.y + radius * 0.52f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun DrawScope.drawRobber(state: CatanState, board: BoardLayout, style: CatanStyle) {
|
||||
val hex = board.hexCenter(state.robber)
|
||||
// Beside the number token rather than on it, so the number stays readable.
|
||||
val x = hex.x + board.radius * 0.5f
|
||||
val y = hex.y + board.radius * 0.3f
|
||||
val s = board.radius * style.robberSize
|
||||
val pawn = Path().apply {
|
||||
moveTo(x - s * 0.55f, y + s * 0.9f)
|
||||
lineTo(x - s * 0.25f, y - s * 0.15f)
|
||||
lineTo(x + s * 0.25f, y - s * 0.15f)
|
||||
lineTo(x + s * 0.55f, y + s * 0.9f)
|
||||
close()
|
||||
}
|
||||
drawPath(pawn, color = style.robber)
|
||||
drawCircle(color = style.robber, radius = s * 0.38f, center = Offset(x, y - s * 0.42f))
|
||||
drawPath(pawn, color = Color.White.copy(alpha = 0.7f), style = Stroke(width = s * 0.07f))
|
||||
drawCircle(
|
||||
color = Color.White.copy(alpha = 0.7f),
|
||||
radius = s * 0.38f,
|
||||
center = Offset(x, y - s * 0.42f),
|
||||
style = Stroke(width = s * 0.07f),
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Harbours
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private fun DrawScope.drawPorts(
|
||||
state: CatanState,
|
||||
board: BoardLayout,
|
||||
style: CatanStyle,
|
||||
measurer: TextMeasurer,
|
||||
) {
|
||||
for (port in state.ports) {
|
||||
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.
|
||||
for (end in listOf(a, b)) {
|
||||
drawLine(
|
||||
color = style.portMarker.copy(alpha = 0.9f),
|
||||
start = anchor.offset(),
|
||||
end = end.offset(),
|
||||
strokeWidth = board.radius * 0.07f,
|
||||
cap = StrokeCap.Round,
|
||||
)
|
||||
}
|
||||
val radius = board.radius * style.portRadius
|
||||
drawCircle(color = style.portMarker, radius = radius, center = anchor.offset())
|
||||
drawCircle(
|
||||
color = tint,
|
||||
radius = radius,
|
||||
center = anchor.offset(),
|
||||
style = Stroke(width = board.radius * 0.07f),
|
||||
)
|
||||
drawCentered(
|
||||
measurer = measurer,
|
||||
text = "${port.ratio}:1",
|
||||
at = anchor,
|
||||
fontPx = board.radius * style.portText,
|
||||
color = style.portInk,
|
||||
weight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pieces
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private fun DrawScope.drawRoads(state: CatanState, board: BoardLayout, style: CatanStyle) {
|
||||
for (edge in 0 until CatanBoard.EDGE_COUNT) {
|
||||
val owner = state.roads[edge]
|
||||
if (owner == NO_PLAYER) continue
|
||||
val (a, b) = board.edgeEnds(edge)
|
||||
// Pulled in from the ends so adjacent roads do not fuse into a blob.
|
||||
val from = lerp(a, b, 0.12f)
|
||||
val to = lerp(a, b, 0.88f)
|
||||
val width = board.radius * style.roadWidth
|
||||
drawLine(
|
||||
color = style.pieceOutline,
|
||||
start = from.offset(),
|
||||
end = to.offset(),
|
||||
strokeWidth = width * 1.45f,
|
||||
cap = StrokeCap.Round,
|
||||
)
|
||||
drawLine(
|
||||
color = style.seats[owner % style.seats.size],
|
||||
start = from.offset(),
|
||||
end = to.offset(),
|
||||
strokeWidth = width,
|
||||
cap = StrokeCap.Round,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun DrawScope.drawBuildings(state: CatanState, board: BoardLayout, style: CatanStyle) {
|
||||
for (vertex in 0 until CatanBoard.VERTEX_COUNT) {
|
||||
val building = state.buildings[vertex] ?: continue
|
||||
val at = board.vertex(vertex)
|
||||
val fill = style.seats[building.owner % style.seats.size]
|
||||
val path = when (building.type) {
|
||||
BuildingType.SETTLEMENT -> housePath(at, board.radius * style.settlementSize)
|
||||
BuildingType.CITY -> cityPath(at, board.radius * style.citySize * 0.8f)
|
||||
}
|
||||
drawPath(path, color = fill)
|
||||
drawPath(path, color = style.pieceOutline, style = Stroke(width = board.radius * 0.045f))
|
||||
}
|
||||
}
|
||||
|
||||
/** One gable: a settlement. [s] is its half-width. */
|
||||
private fun housePath(at: Pt, s: Float): Path = Path().apply {
|
||||
moveTo(at.x - s, at.y + s * 0.7f)
|
||||
lineTo(at.x - s, at.y - s * 0.1f)
|
||||
lineTo(at.x, at.y - s)
|
||||
lineTo(at.x + s, at.y - s * 0.1f)
|
||||
lineTo(at.x + s, at.y + s * 0.7f)
|
||||
close()
|
||||
}
|
||||
|
||||
/** Two gables, the taller on the left: a city. [s] is the unit its outline is scaled by. */
|
||||
private fun cityPath(at: Pt, s: Float): Path = Path().apply {
|
||||
moveTo(at.x - s, at.y + s * 0.7f)
|
||||
lineTo(at.x - s, at.y - s * 0.5f)
|
||||
lineTo(at.x - s * 0.45f, at.y - s * 1.1f)
|
||||
lineTo(at.x + s * 0.1f, at.y - s * 0.5f)
|
||||
lineTo(at.x + s * 0.1f, at.y - s * 0.1f)
|
||||
lineTo(at.x + s * 0.55f, at.y - s * 0.55f)
|
||||
lineTo(at.x + s * 1.1f, at.y - s * 0.1f)
|
||||
lineTo(at.x + s * 1.1f, at.y + s * 0.7f)
|
||||
close()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tap targets
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private fun DrawScope.drawVertexTargets(
|
||||
targets: BoardTargets,
|
||||
selected: Int?,
|
||||
board: BoardLayout,
|
||||
style: CatanStyle,
|
||||
) {
|
||||
val radius = board.radius * style.targetRadius
|
||||
for (vertex in targets.vertices) {
|
||||
val at = board.vertex(vertex).offset()
|
||||
drawCircle(color = style.target.copy(alpha = 0.85f), radius = radius, center = at)
|
||||
drawCircle(
|
||||
color = style.pieceOutline,
|
||||
radius = radius,
|
||||
center = at,
|
||||
style = Stroke(width = board.radius * 0.04f),
|
||||
)
|
||||
}
|
||||
if (selected != null) {
|
||||
val at = board.vertex(selected).offset()
|
||||
drawCircle(
|
||||
color = style.targetSelected,
|
||||
radius = radius * 1.6f,
|
||||
center = at,
|
||||
style = Stroke(width = board.radius * 0.09f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun DrawScope.drawEdgeTargets(targets: BoardTargets, board: BoardLayout, style: CatanStyle) {
|
||||
for (edge in targets.edges) {
|
||||
val (a, b) = board.edgeEnds(edge)
|
||||
val from = lerp(a, b, 0.15f)
|
||||
val to = lerp(a, b, 0.85f)
|
||||
drawLine(
|
||||
color = style.pieceOutline,
|
||||
start = from.offset(),
|
||||
end = to.offset(),
|
||||
strokeWidth = board.radius * style.roadWidth * 1.5f,
|
||||
cap = StrokeCap.Round,
|
||||
)
|
||||
drawLine(
|
||||
color = style.target.copy(alpha = 0.9f),
|
||||
start = from.offset(),
|
||||
end = to.offset(),
|
||||
strokeWidth = board.radius * style.roadWidth,
|
||||
cap = StrokeCap.Round,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun DrawScope.drawHexTargets(targets: BoardTargets, board: BoardLayout, style: CatanStyle) {
|
||||
for (hex in targets.hexes) {
|
||||
val centre = board.hexCenter(hex)
|
||||
val path = polygon(board.hexCorners(hex).map { insetToward(it, centre, style.hexInset + 0.08f) })
|
||||
drawPath(path, color = style.target.copy(alpha = 0.38f))
|
||||
drawPath(path, color = style.target, style = Stroke(width = board.radius * 0.07f))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Drawing helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private fun Pt.offset(): Offset = Offset(x, y)
|
||||
|
||||
private fun lerp(a: Pt, b: Pt, t: Float): Pt = Pt(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t)
|
||||
|
||||
/** [p] moved [fraction] of the way toward [centre]. */
|
||||
private fun insetToward(p: Pt, centre: Pt, fraction: Float): Pt = lerp(p, centre, fraction)
|
||||
|
||||
private fun polygon(points: List<Pt>): Path = Path().apply {
|
||||
points.forEachIndexed { i, p -> if (i == 0) moveTo(p.x, p.y) else lineTo(p.x, p.y) }
|
||||
close()
|
||||
}
|
||||
|
||||
private fun DrawScope.drawCentered(
|
||||
measurer: TextMeasurer,
|
||||
text: String,
|
||||
at: Pt,
|
||||
fontPx: Float,
|
||||
color: Color,
|
||||
weight: FontWeight,
|
||||
) {
|
||||
val layout = measurer.measure(
|
||||
text = text,
|
||||
style = TextStyle(fontSize = fontPx.toSp(), fontWeight = weight, color = color),
|
||||
)
|
||||
drawText(
|
||||
textLayoutResult = layout,
|
||||
topLeft = Offset(at.x - layout.size.width / 2f, at.y - layout.size.height / 2f),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,531 @@
|
||||
package org.prolibertate.games.ui.game.catan
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
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.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.prolibertate.games.game.catan.CatanMove
|
||||
import org.prolibertate.games.game.catan.CatanRules
|
||||
import org.prolibertate.games.game.catan.CatanState
|
||||
import org.prolibertate.games.game.catan.DevCardType
|
||||
import org.prolibertate.games.game.catan.Discard
|
||||
import org.prolibertate.games.game.catan.MaritimeTrade
|
||||
import org.prolibertate.games.game.catan.OfferTrade
|
||||
import org.prolibertate.games.game.catan.PlayKnight
|
||||
import org.prolibertate.games.game.catan.PlayRoadBuilding
|
||||
import org.prolibertate.games.game.catan.Resource
|
||||
import org.prolibertate.games.game.catan.TradeOffer
|
||||
|
||||
/*
|
||||
* Every Katan dialog. Each is a stateless composable apart from the selection it
|
||||
* is helping the player make (cards picked, a partner chosen); the move it ends
|
||||
* in is reported through a lambda and sent by the screen. What is *allowed* is
|
||||
* always asked of [CatanInteraction], never decided here.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared pieces
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** A resource tile with a minus and a plus, for choosing how many cards. */
|
||||
@Composable
|
||||
private fun ResourceCounter(
|
||||
resource: Resource,
|
||||
count: Int,
|
||||
max: Int,
|
||||
onChange: (Int) -> Unit,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
ResourceTile(resource, count, enabled = max > 0 || count > 0)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
OutlinedButton(
|
||||
onClick = { onChange(count - 1) },
|
||||
enabled = count > 0,
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
modifier = Modifier.width(34.dp).height(32.dp),
|
||||
) { Text("−") }
|
||||
OutlinedButton(
|
||||
onClick = { onChange(count + 1) },
|
||||
enabled = count < max,
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
modifier = Modifier.width(34.dp).height(32.dp),
|
||||
) { Text("+") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Map<Resource, Int>.asCards(): List<Resource> =
|
||||
Resource.entries.flatMap { r -> List(this[r] ?: 0) { r } }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Discarding on a seven
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A seven was rolled and this player holds more than seven cards: choose which
|
||||
* half to give up. Not dismissable — the game is waiting on it.
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun CatanDiscardDialog(
|
||||
held: Map<Resource, Int>,
|
||||
quota: Int,
|
||||
legal: List<CatanMove>,
|
||||
onSubmit: (Discard) -> Unit,
|
||||
) {
|
||||
var picked by remember { mutableStateOf(emptyMap<Resource, Int>()) }
|
||||
val selection = picked.asCards()
|
||||
val move = CatanInteraction.discardMove(legal, selection)
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = {},
|
||||
title = { Text("Discard $quota cards") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(
|
||||
"A seven was rolled. Choose $quota cards to give up " +
|
||||
"(${selection.size} of $quota chosen).",
|
||||
)
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Resource.entries.forEach { resource ->
|
||||
val have = held[resource] ?: 0
|
||||
if (have > 0) {
|
||||
ResourceCounter(
|
||||
resource = resource,
|
||||
count = picked[resource] ?: 0,
|
||||
max = if (selection.size < quota) have else (picked[resource] ?: 0),
|
||||
onChange = { n -> picked = picked + (resource to n.coerceIn(0, have)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(onClick = { move?.let(onSubmit) }, enabled = move != null) { Text("Discard") }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The robber
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** More than one player can be robbed from this hex: choose whom. */
|
||||
@Composable
|
||||
fun CatanVictimDialog(
|
||||
victims: List<Int>,
|
||||
seats: CatanSeats,
|
||||
onPick: (Int) -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onCancel,
|
||||
title = { Text("Steal from…") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
victims.forEach { seat ->
|
||||
OutlinedButton(onClick = { onPick(seat) }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(seats.label(seat))
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {},
|
||||
dismissButton = { TextButton(onClick = onCancel) { Text(CatanText.CANCEL) } },
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Development cards
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The player's development cards. A Knight or Road Building plays at once; a
|
||||
* Monopoly or Year of Plenty asks for its resource choice in a second step.
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun CatanPlayCardDialog(
|
||||
hand: List<DevCardInHand>,
|
||||
onPlay: (CatanMove) -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
) {
|
||||
var chosen by remember { mutableStateOf<DevCardInHand?>(null) }
|
||||
var picked by remember { mutableStateOf(emptyList<Resource>()) }
|
||||
val card = chosen
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onCancel,
|
||||
title = { Text(card?.let { CatanText.devCard(it.card.type) } ?: "Development cards") },
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier.heightIn(max = 420.dp).verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
when {
|
||||
card == null -> {
|
||||
if (hand.isEmpty()) Text("You have no development cards.")
|
||||
hand.forEach { entry -> DevCardRow(entry, onPlay) { chosen = it } }
|
||||
}
|
||||
|
||||
card.card.type == DevCardType.MONOPOLY -> {
|
||||
Text("Name a resource. Every other player hands over all of it.")
|
||||
val choices = CatanInteraction.monopolyChoices(card.plays)
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Resource.entries.forEach { resource ->
|
||||
val move = choices[resource]
|
||||
ResourceTile(
|
||||
resource = resource,
|
||||
count = 0,
|
||||
enabled = move != null,
|
||||
onClick = { move?.let(onPlay) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
card.card.type == DevCardType.YEAR_OF_PLENTY -> {
|
||||
Text("Take two resource cards from the bank (${picked.size} of 2 chosen).")
|
||||
val available = CatanInteraction.plentyAvailable(card.plays, picked)
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Resource.entries.forEach { resource ->
|
||||
ResourceTile(
|
||||
resource = resource,
|
||||
count = picked.count { it == resource },
|
||||
selected = resource in picked,
|
||||
enabled = resource in available,
|
||||
onClick = { picked = picked + resource },
|
||||
)
|
||||
}
|
||||
}
|
||||
if (picked.isNotEmpty()) {
|
||||
TextButton(onClick = { picked = emptyList() }) { Text("Clear") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
if (card != null && card.card.type == DevCardType.YEAR_OF_PLENTY) {
|
||||
val move = CatanInteraction.plentyMove(card.plays, picked)
|
||||
Button(onClick = { move?.let(onPlay) }, enabled = move != null) { Text("Take") }
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
if (card == null) {
|
||||
onCancel()
|
||||
} else {
|
||||
chosen = null
|
||||
picked = emptyList()
|
||||
}
|
||||
},
|
||||
) { Text(if (card == null) CatanText.CANCEL else "Back") }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DevCardRow(
|
||||
entry: DevCardInHand,
|
||||
onPlay: (CatanMove) -> Unit,
|
||||
onChoose: (DevCardInHand) -> Unit,
|
||||
) {
|
||||
val type = entry.card.type
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(CatanText.devCard(type), fontWeight = FontWeight.Bold)
|
||||
Text(CatanText.devCardHelp(type), style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
when {
|
||||
type == DevCardType.VICTORY_POINT -> Text("Hidden", style = MaterialTheme.typography.labelSmall)
|
||||
!entry.playable -> Text("Not now", style = MaterialTheme.typography.labelSmall)
|
||||
entry.plays.size == 1 && (entry.plays[0] is PlayKnight || entry.plays[0] is PlayRoadBuilding) ->
|
||||
Button(onClick = { onPlay(entry.plays[0]) }) { Text("Play") }
|
||||
|
||||
else -> Button(onClick = { onChoose(entry) }) { Text("Play…") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Trading
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private enum class TradeTab { BANK, PLAYER }
|
||||
|
||||
/**
|
||||
* Trade with the bank at the best ratio the player has, or offer cards to one
|
||||
* other player. Offers are free-form, so they are checked with the engine's
|
||||
* structural rule ([CatanInteraction.offerAllowed]) rather than looked up in
|
||||
* the legal list.
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun CatanTradeDialog(
|
||||
state: CatanState,
|
||||
seat: Int,
|
||||
seats: CatanSeats,
|
||||
legal: List<CatanMove>,
|
||||
onBank: (MaritimeTrade) -> Unit,
|
||||
onOffer: (OfferTrade) -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
) {
|
||||
var tab by remember { mutableStateOf(TradeTab.BANK) }
|
||||
var bankGive by remember { mutableStateOf<Resource?>(null) }
|
||||
var partner by remember { mutableStateOf<Int?>(null) }
|
||||
var give by remember { mutableStateOf(emptyMap<Resource, Int>()) }
|
||||
var get by remember { mutableStateOf(emptyMap<Resource, Int>()) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onCancel,
|
||||
title = { Text(CatanText.TRADE) },
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier.heightIn(max = 460.dp).verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
TabButton(CatanText.TRADE_WITH_BANK, tab == TradeTab.BANK) { tab = TradeTab.BANK }
|
||||
TabButton(CatanText.TRADE_WITH_PLAYER, tab == TradeTab.PLAYER) { tab = TradeTab.PLAYER }
|
||||
}
|
||||
|
||||
if (tab == TradeTab.BANK) {
|
||||
val trades = CatanInteraction.bankTrades(legal)
|
||||
if (trades.isEmpty()) {
|
||||
Text("You do not hold enough of any one resource to trade with the bank.")
|
||||
} else {
|
||||
Text("Give:", fontWeight = FontWeight.Bold)
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
trades.keys.forEach { resource ->
|
||||
val ratio = CatanInteraction.bankRatio(state, seat, resource)
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
ResourceTile(
|
||||
resource = resource,
|
||||
count = ratio,
|
||||
selected = bankGive == resource,
|
||||
onClick = { bankGive = resource },
|
||||
)
|
||||
Text("$ratio : 1", style = MaterialTheme.typography.labelSmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
val chosen = bankGive
|
||||
if (chosen != null && chosen in trades) {
|
||||
Text("Get one:", fontWeight = FontWeight.Bold)
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
trades.getValue(chosen).forEach { resource ->
|
||||
ResourceTile(
|
||||
resource = resource,
|
||||
count = 1,
|
||||
onClick = { onBank(MaritimeTrade(chosen, resource)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text("Offer to:", fontWeight = FontWeight.Bold)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
(0 until state.playerCount).filter { it != seat }.forEach { other ->
|
||||
TabButton(seats.label(other), partner == other) { partner = other }
|
||||
}
|
||||
}
|
||||
Text(CatanText.YOU_GIVE, fontWeight = FontWeight.Bold)
|
||||
CounterRow(
|
||||
counts = give,
|
||||
limit = { state.resources[seat][it] ?: 0 },
|
||||
onChange = { give = it },
|
||||
)
|
||||
Text(CatanText.YOU_GET, fontWeight = FontWeight.Bold)
|
||||
CounterRow(counts = get, limit = { 9 }, onChange = { get = it })
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
if (tab == TradeTab.PLAYER) {
|
||||
val to = partner
|
||||
val giveCards = give.asCards()
|
||||
val getCards = get.asCards()
|
||||
val allowed = to != null &&
|
||||
CatanInteraction.offerAllowed(state, seat, to, giveCards, getCards)
|
||||
Button(
|
||||
onClick = { if (to != null) onOffer(OfferTrade(to, giveCards, getCards)) },
|
||||
enabled = allowed,
|
||||
) { Text(CatanText.SEND_OFFER) }
|
||||
}
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onCancel) { Text(CatanText.CANCEL) } },
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun CounterRow(
|
||||
counts: Map<Resource, Int>,
|
||||
limit: (Resource) -> Int,
|
||||
onChange: (Map<Resource, Int>) -> Unit,
|
||||
) {
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Resource.entries.forEach { resource ->
|
||||
ResourceCounter(
|
||||
resource = resource,
|
||||
count = counts[resource] ?: 0,
|
||||
max = limit(resource),
|
||||
onChange = { n -> onChange(counts + (resource to n.coerceIn(0, limit(resource)))) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TabButton(label: String, selected: Boolean, onClick: () -> Unit) {
|
||||
if (selected) {
|
||||
FilledTonalButton(onClick = onClick) { Text(label) }
|
||||
} else {
|
||||
OutlinedButton(onClick = onClick) { Text(label) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Another player has offered the local player a trade. */
|
||||
@Composable
|
||||
fun CatanTradeResponseDialog(
|
||||
offer: TradeOffer,
|
||||
seats: CatanSeats,
|
||||
canAccept: Boolean,
|
||||
onAccept: () -> Unit,
|
||||
onDecline: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = {},
|
||||
title = { Text("Trade offer") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(
|
||||
CatanText.tradeSummary(
|
||||
by = seats.label(offer.by),
|
||||
to = "you",
|
||||
give = offer.give,
|
||||
get = offer.get,
|
||||
),
|
||||
)
|
||||
if (!canAccept) {
|
||||
Text(
|
||||
"You cannot accept this: one side no longer holds the cards.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = { Button(onClick = onAccept, enabled = canAccept) { Text(CatanText.ACCEPT) } },
|
||||
dismissButton = { TextButton(onClick = onDecline) { Text(CatanText.DECLINE) } },
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ending
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** The game is over: who won, and what everyone finished with. */
|
||||
@Composable
|
||||
fun CatanGameOverDialog(
|
||||
state: CatanState,
|
||||
seats: CatanSeats,
|
||||
onExit: () -> Unit,
|
||||
onLook: () -> Unit,
|
||||
) {
|
||||
val points = (0 until state.playerCount).map { seat ->
|
||||
// The winner's hidden victory-point cards are revealed by winning; the
|
||||
// host's copy of the state has them, a client's has only its own.
|
||||
seat to if (seat == seats.me) CatanRules.victoryPoints(state, seat)
|
||||
else CatanRules.publicVictoryPoints(state)[seat]
|
||||
}
|
||||
AlertDialog(
|
||||
onDismissRequest = onLook,
|
||||
title = { Text(CatanText.winnerHeadline(state, seats)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
points.sortedByDescending { it.second }.forEach { (seat, vp) ->
|
||||
Text("${seats.label(seat)}: $vp points")
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onExit) { Text(CatanText.LEAVE_GAME) } },
|
||||
dismissButton = { TextButton(onClick = onLook) { Text(CatanText.LOOK_AT_BOARD) } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CatanEndGameDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onKeepPlaying,
|
||||
title = { Text("End this game?") },
|
||||
text = {
|
||||
Text(
|
||||
"This game ends here and the position is not saved. Anyone else " +
|
||||
"at the table is told you have left.",
|
||||
)
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
|
||||
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CatanAbandonedDialog(notice: String, onExit: () -> Unit, onStay: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onStay,
|
||||
title = { Text("The game has ended") },
|
||||
text = { Text("$notice There are no more moves to play.") },
|
||||
confirmButton = { TextButton(onClick = onExit) { Text(CatanText.LEAVE_GAME) } },
|
||||
dismissButton = { TextButton(onClick = onStay) { Text(CatanText.LOOK_AT_BOARD) } },
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package org.prolibertate.games.ui.game.catan
|
||||
|
||||
import org.prolibertate.games.game.catan.CatanBoard
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/** A point in board pixels. Plain floats, so this file needs no Compose types. */
|
||||
data class Pt(val x: Float, val y: Float)
|
||||
|
||||
/**
|
||||
* Where the island sits on screen: the one place the engine's integer lattice
|
||||
* (see [CatanBoard]) becomes pixels, and pixels become "which vertex / edge /
|
||||
* hex did the player tap".
|
||||
*
|
||||
* Pure and Android-free, so it is unit-tested (`CatanGeometryTest`). The drawing
|
||||
* code ([CatanBoardView]) and the tap handling both go through this, which is
|
||||
* what keeps what is drawn and what is tappable from ever disagreeing.
|
||||
*
|
||||
* Lattice to pixel: a hex of circumradius `R` is pointy-top, so one lattice x-unit
|
||||
* is `R·√3/2` and one lattice y-unit is `R/2`, and lattice +Y points up the board
|
||||
* while pixel +y points down.
|
||||
*/
|
||||
class BoardLayout(
|
||||
val width: Float,
|
||||
val height: Float,
|
||||
/** Circumradius of one hex, in pixels. */
|
||||
val radius: Float,
|
||||
) {
|
||||
private val centreX = width / 2f
|
||||
private val centreY = height / 2f
|
||||
private val unitX = radius * SQRT3 / 2f
|
||||
private val unitY = radius / 2f
|
||||
|
||||
fun point(latticeX: Int, latticeY: Int): Pt =
|
||||
Pt(centreX + latticeX * unitX, centreY - latticeY * unitY)
|
||||
|
||||
fun hexCenter(hex: Int): Pt {
|
||||
val (lx, ly) = CatanBoard.hexCenter(hex)
|
||||
return point(lx, ly)
|
||||
}
|
||||
|
||||
fun vertex(vertex: Int): Pt {
|
||||
val (lx, ly) = CatanBoard.VERTEX_POS[vertex]
|
||||
return point(lx, ly)
|
||||
}
|
||||
|
||||
fun hexCorners(hex: Int): List<Pt> = CatanBoard.HEX_CORNERS[hex].map { vertex(it) }
|
||||
|
||||
fun edgeEnds(edge: Int): Pair<Pt, Pt> {
|
||||
val (a, b) = CatanBoard.EDGE_VERTICES[edge]
|
||||
return vertex(a) to vertex(b)
|
||||
}
|
||||
|
||||
fun edgeMidpoint(edge: Int): Pt {
|
||||
val (a, b) = edgeEnds(edge)
|
||||
return Pt((a.x + b.x) / 2f, (a.y + b.y) / 2f)
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a harbour's marker is drawn: just off the coast, straight out from
|
||||
* the middle of its edge. The marker is joined to the edge's two vertices by
|
||||
* the board view, which is what shows which settlements can use it.
|
||||
*/
|
||||
fun portAnchor(edge: Int): Pt {
|
||||
val mid = edgeMidpoint(edge)
|
||||
val dx = mid.x - centreX
|
||||
val dy = mid.y - centreY
|
||||
val length = sqrt(dx * dx + dy * dy).coerceAtLeast(1f)
|
||||
return Pt(mid.x + dx / length * radius * PORT_OFFSET, mid.y + dy / length * radius * PORT_OFFSET)
|
||||
}
|
||||
|
||||
/** The nearest of [among] within [reach] pixels of [at], or null. */
|
||||
fun nearestVertex(at: Pt, reach: Float, among: Collection<Int>): Int? =
|
||||
nearest(at, reach, among) { vertex(it) }
|
||||
|
||||
fun nearestEdge(at: Pt, reach: Float, among: Collection<Int>): Int? =
|
||||
nearest(at, reach, among) { edgeMidpoint(it) }
|
||||
|
||||
fun nearestHex(at: Pt, reach: Float, among: Collection<Int>): Int? =
|
||||
nearest(at, reach, among) { hexCenter(it) }
|
||||
|
||||
private fun nearest(at: Pt, reach: Float, among: Collection<Int>, position: (Int) -> Pt): Int? {
|
||||
var best: Int? = null
|
||||
var bestDistance = reach * reach
|
||||
for (candidate in among) {
|
||||
val p = position(candidate)
|
||||
val dx = p.x - at.x
|
||||
val dy = p.y - at.y
|
||||
val d = dx * dx + dy * dy
|
||||
if (d <= bestDistance) {
|
||||
best = candidate
|
||||
bestDistance = d
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
}
|
||||
|
||||
object CatanGeometry {
|
||||
|
||||
/** Lattice extents of the island: x spans -5..5, y spans -8..8. */
|
||||
private const val LATTICE_X_UNITS = 10
|
||||
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
|
||||
|
||||
private val boardWidthInRadii = LATTICE_X_UNITS * SQRT3 / 2f + 2 * MARGIN
|
||||
private val boardHeightInRadii = LATTICE_Y_UNITS / 2f + 2 * MARGIN
|
||||
|
||||
/** Width ÷ height of the board including its margin, for sizing its box. */
|
||||
val ASPECT: Float = boardWidthInRadii / boardHeightInRadii
|
||||
|
||||
/** The largest layout that fits [width] × [height] pixels. */
|
||||
fun fit(width: Float, height: Float): BoardLayout {
|
||||
val radius = minOf(width / boardWidthInRadii, height / boardHeightInRadii)
|
||||
return BoardLayout(width, height, radius)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -0,0 +1,273 @@
|
||||
package org.prolibertate.games.ui.game.catan
|
||||
|
||||
import org.prolibertate.games.game.catan.AcceptTrade
|
||||
import org.prolibertate.games.game.catan.BuildCity
|
||||
import org.prolibertate.games.game.catan.BuildRoad
|
||||
import org.prolibertate.games.game.catan.BuildSettlement
|
||||
import org.prolibertate.games.game.catan.BuyDevCard
|
||||
import org.prolibertate.games.game.catan.CatanBoard
|
||||
import org.prolibertate.games.game.catan.CatanMove
|
||||
import org.prolibertate.games.game.catan.CatanPhase
|
||||
import org.prolibertate.games.game.catan.CatanRules
|
||||
import org.prolibertate.games.game.catan.CatanState
|
||||
import org.prolibertate.games.game.catan.DeclineTrade
|
||||
import org.prolibertate.games.game.catan.DevCard
|
||||
import org.prolibertate.games.game.catan.DevCardType
|
||||
import org.prolibertate.games.game.catan.Discard
|
||||
import org.prolibertate.games.game.catan.EndFreeRoads
|
||||
import org.prolibertate.games.game.catan.EndTurn
|
||||
import org.prolibertate.games.game.catan.HexCoord
|
||||
import org.prolibertate.games.game.catan.MaritimeTrade
|
||||
import org.prolibertate.games.game.catan.MoveRobber
|
||||
import org.prolibertate.games.game.catan.OfferTrade
|
||||
import org.prolibertate.games.game.catan.PlayKnight
|
||||
import org.prolibertate.games.game.catan.PlayMonopoly
|
||||
import org.prolibertate.games.game.catan.PlayRoadBuilding
|
||||
import org.prolibertate.games.game.catan.PlayYearOfPlenty
|
||||
import org.prolibertate.games.game.catan.Resource
|
||||
import org.prolibertate.games.game.catan.RollDice
|
||||
import org.prolibertate.games.game.catan.SetupPlace
|
||||
|
||||
/** What a player can ask to build by tapping the board. */
|
||||
enum class BuildKind { ROAD, SETTLEMENT, CITY }
|
||||
|
||||
/** The parts of the board the player may tap right now. Everything else is inert. */
|
||||
data class BoardTargets(
|
||||
val vertices: Set<Int> = emptySet(),
|
||||
val edges: Set<Int> = emptySet(),
|
||||
val hexes: Set<Int> = emptySet(),
|
||||
) {
|
||||
val isEmpty: Boolean get() = vertices.isEmpty() && edges.isEmpty() && hexes.isEmpty()
|
||||
}
|
||||
|
||||
/** What a tap on the board amounts to. */
|
||||
sealed interface TapOutcome {
|
||||
/** Nothing: the tap landed somewhere that is not a legal target. */
|
||||
data object Ignored : TapOutcome
|
||||
|
||||
/** Send this move to the table. */
|
||||
data class Submit(val move: CatanMove) : TapOutcome
|
||||
|
||||
/** Setup: remember this settlement; the road that goes with it is tapped next. */
|
||||
data class PickSetupVertex(val vertex: Int) : TapOutcome
|
||||
|
||||
/** The robber's hex has more than one possible victim: ask which. */
|
||||
data class PickRobberVictim(val hex: HexCoord, val victims: List<Int>) : TapOutcome
|
||||
}
|
||||
|
||||
/**
|
||||
* One development card in the local player's hand, and what can be done with it
|
||||
* right now. [plays] is empty when it cannot be played this turn (bought this
|
||||
* turn, a card has already been played, a victory-point card, or it is not the
|
||||
* player's turn).
|
||||
*/
|
||||
data class DevCardInHand(val card: DevCard, val plays: List<CatanMove>) {
|
||||
val playable: Boolean get() = plays.isNotEmpty()
|
||||
}
|
||||
|
||||
/**
|
||||
* The rules of *tapping*: given the game state, the moves the engine says are
|
||||
* legal, and what the player has selected, what can be tapped and what a tap does.
|
||||
*
|
||||
* Pure functions with no Android and no Compose, so the whole interaction model is
|
||||
* unit-tested (`CatanInteractionTest`). The screen holds the two bits of UI state
|
||||
* ([BuildKind] and the chosen setup settlement) and passes them in; it contains no
|
||||
* game rules of its own. **Anything that decides whether a tap is allowed belongs
|
||||
* here, never in a composable** — the engine's [legal] list is the single source
|
||||
* of truth, so the UI can never offer a move the rules would refuse.
|
||||
*/
|
||||
object CatanInteraction {
|
||||
|
||||
// --- Board ---------------------------------------------------------------
|
||||
|
||||
fun targets(
|
||||
state: CatanState,
|
||||
legal: List<CatanMove>,
|
||||
building: BuildKind?,
|
||||
setupVertex: Int?,
|
||||
): BoardTargets {
|
||||
val setup = legal.filterIsInstance<SetupPlace>()
|
||||
if (setup.isNotEmpty()) {
|
||||
return if (setupVertex == null) {
|
||||
BoardTargets(vertices = setup.map { it.vertex }.toSet())
|
||||
} else {
|
||||
BoardTargets(edges = setup.filter { it.vertex == setupVertex }.map { it.edge }.toSet())
|
||||
}
|
||||
}
|
||||
|
||||
val robber = legal.filterIsInstance<MoveRobber>()
|
||||
if (robber.isNotEmpty()) {
|
||||
return BoardTargets(hexes = robber.mapNotNull { CatanBoard.hexIndex[it.hex] }.toSet())
|
||||
}
|
||||
|
||||
val roads = legal.filterIsInstance<BuildRoad>().map { it.edge }.toSet()
|
||||
if (state.phase == CatanPhase.FREE_ROADS) return BoardTargets(edges = roads)
|
||||
|
||||
return when (building) {
|
||||
BuildKind.ROAD -> BoardTargets(edges = roads)
|
||||
BuildKind.SETTLEMENT ->
|
||||
BoardTargets(vertices = legal.filterIsInstance<BuildSettlement>().map { it.vertex }.toSet())
|
||||
|
||||
BuildKind.CITY ->
|
||||
BoardTargets(vertices = legal.filterIsInstance<BuildCity>().map { it.vertex }.toSet())
|
||||
|
||||
null -> BoardTargets()
|
||||
}
|
||||
}
|
||||
|
||||
fun onVertexTap(
|
||||
vertex: Int,
|
||||
legal: List<CatanMove>,
|
||||
building: BuildKind?,
|
||||
setupVertex: Int?,
|
||||
): TapOutcome {
|
||||
val setup = legal.filterIsInstance<SetupPlace>()
|
||||
if (setup.isNotEmpty()) {
|
||||
// Re-picking is allowed until the road is chosen, so a misplaced
|
||||
// tap does not commit the settlement.
|
||||
return if (setup.any { it.vertex == vertex }) {
|
||||
TapOutcome.PickSetupVertex(vertex)
|
||||
} else {
|
||||
TapOutcome.Ignored
|
||||
}
|
||||
}
|
||||
val move: CatanMove = when (building) {
|
||||
BuildKind.SETTLEMENT -> BuildSettlement(vertex)
|
||||
BuildKind.CITY -> BuildCity(vertex)
|
||||
else -> return TapOutcome.Ignored
|
||||
}
|
||||
return if (move in legal) TapOutcome.Submit(move) else TapOutcome.Ignored
|
||||
}
|
||||
|
||||
fun onEdgeTap(
|
||||
edge: Int,
|
||||
legal: List<CatanMove>,
|
||||
building: BuildKind?,
|
||||
setupVertex: Int?,
|
||||
): TapOutcome {
|
||||
if (legal.any { it is SetupPlace }) {
|
||||
if (setupVertex == null) return TapOutcome.Ignored
|
||||
val move = SetupPlace(setupVertex, edge)
|
||||
return if (move in legal) TapOutcome.Submit(move) else TapOutcome.Ignored
|
||||
}
|
||||
// Free roads need no build mode; a paid road does.
|
||||
val canBuild = building == BuildKind.ROAD || legal.any { it is EndFreeRoads }
|
||||
if (!canBuild) return TapOutcome.Ignored
|
||||
val move = BuildRoad(edge)
|
||||
return if (move in legal) TapOutcome.Submit(move) else TapOutcome.Ignored
|
||||
}
|
||||
|
||||
fun onHexTap(hex: Int, legal: List<CatanMove>): TapOutcome {
|
||||
val coord = CatanBoard.HEXES.getOrNull(hex) ?: return TapOutcome.Ignored
|
||||
val moves = legal.filterIsInstance<MoveRobber>().filter { it.hex == coord }
|
||||
return when {
|
||||
moves.isEmpty() -> TapOutcome.Ignored
|
||||
moves.size == 1 -> TapOutcome.Submit(moves.first())
|
||||
else -> TapOutcome.PickRobberVictim(coord, moves.mapNotNull { it.victim })
|
||||
}
|
||||
}
|
||||
|
||||
// --- Action bar ------------------------------------------------------------
|
||||
|
||||
fun canRoll(legal: List<CatanMove>): Boolean = legal.any { it is RollDice }
|
||||
|
||||
fun canEndTurn(legal: List<CatanMove>): Boolean = legal.any { it is EndTurn }
|
||||
|
||||
fun canEndFreeRoads(legal: List<CatanMove>): Boolean = legal.any { it is EndFreeRoads }
|
||||
|
||||
fun canBuyDevCard(legal: List<CatanMove>): Boolean = legal.any { it is BuyDevCard }
|
||||
|
||||
fun canBuild(legal: List<CatanMove>): Set<BuildKind> = buildSet {
|
||||
if (legal.any { it is BuildRoad }) add(BuildKind.ROAD)
|
||||
if (legal.any { it is BuildSettlement }) add(BuildKind.SETTLEMENT)
|
||||
if (legal.any { it is BuildCity }) add(BuildKind.CITY)
|
||||
}
|
||||
|
||||
fun tradeResponses(legal: List<CatanMove>): Pair<Boolean, Boolean> =
|
||||
legal.any { it is AcceptTrade } to legal.any { it is DeclineTrade }
|
||||
|
||||
// --- Development cards -----------------------------------------------------
|
||||
|
||||
/** [seat]'s development cards, each with the plays the engine currently allows. */
|
||||
fun devHand(state: CatanState, seat: Int, legal: List<CatanMove>): List<DevCardInHand> =
|
||||
state.devHands.getOrNull(seat).orEmpty().map { card ->
|
||||
DevCardInHand(card, legal.filter { playOf(it) == card.id })
|
||||
}.sortedWith(compareBy({ it.card.type.ordinal }, { it.card.id }))
|
||||
|
||||
private fun playOf(move: CatanMove): Int? = when (move) {
|
||||
is PlayKnight -> move.card
|
||||
is PlayRoadBuilding -> move.card
|
||||
is PlayMonopoly -> move.card
|
||||
is PlayYearOfPlenty -> move.card
|
||||
else -> null
|
||||
}
|
||||
|
||||
/** The Monopoly moves for one card, by the resource each would name. */
|
||||
fun monopolyChoices(plays: List<CatanMove>): Map<Resource, PlayMonopoly> =
|
||||
plays.filterIsInstance<PlayMonopoly>().associateBy { it.resource }
|
||||
|
||||
/** The Year of Plenty move that takes exactly [take], if the engine allows it. */
|
||||
fun plentyMove(plays: List<CatanMove>, take: List<Resource>): PlayYearOfPlenty? {
|
||||
val want = take.groupingBy { it }.eachCount()
|
||||
return plays.filterIsInstance<PlayYearOfPlenty>().firstOrNull {
|
||||
it.take.groupingBy { r -> r }.eachCount() == want
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resources a Year of Plenty can still be asked for, given what is already
|
||||
* [picked]: those that complete some take the engine allows. Empty once the
|
||||
* two cards are chosen.
|
||||
*/
|
||||
fun plentyAvailable(plays: List<CatanMove>, picked: List<Resource>): Set<Resource> {
|
||||
val chosen = picked.groupingBy { it }.eachCount()
|
||||
val available = mutableSetOf<Resource>()
|
||||
for (play in plays.filterIsInstance<PlayYearOfPlenty>()) {
|
||||
if (!chosen.all { (resource, n) -> play.take.count { it == resource } >= n }) continue
|
||||
val remaining = play.take.toMutableList()
|
||||
picked.forEach { remaining.remove(it) }
|
||||
available += remaining
|
||||
}
|
||||
return available
|
||||
}
|
||||
|
||||
// --- Discarding -----------------------------------------------------------
|
||||
|
||||
/** How many cards a player holding [held] must discard on a seven. */
|
||||
fun discardQuota(held: Int): Int = held / 2
|
||||
|
||||
/** The discard move that gives up exactly [selection], if the engine allows it. */
|
||||
fun discardMove(legal: List<CatanMove>, selection: List<Resource>): Discard? {
|
||||
val want = selection.groupingBy { it }.eachCount()
|
||||
return legal.filterIsInstance<Discard>().firstOrNull {
|
||||
it.cards.groupingBy { r -> r }.eachCount() == want
|
||||
}
|
||||
}
|
||||
|
||||
// --- Trading ---------------------------------------------------------------
|
||||
|
||||
/** Bank trades on offer: what can be given, and for what. */
|
||||
fun bankTrades(legal: List<CatanMove>): Map<Resource, List<Resource>> =
|
||||
legal.filterIsInstance<MaritimeTrade>()
|
||||
.groupBy({ it.give }, { it.get })
|
||||
.mapValues { (_, gets) -> gets.sortedBy { it.ordinal } }
|
||||
.toSortedMap(compareBy<Resource> { it.ordinal })
|
||||
|
||||
/** What it costs [seat] to give one [resource] to the bank: 4, 3 or 2 cards. */
|
||||
fun bankRatio(state: CatanState, seat: Int, resource: Resource): Int =
|
||||
CatanRules.bestPortRatio(state, seat, resource)
|
||||
|
||||
/**
|
||||
* Whether [seat] can offer [give] for [get] to [to] right now. Uses the
|
||||
* engine's own structural check, since offers are not in the enumerated
|
||||
* move list.
|
||||
*/
|
||||
fun offerAllowed(
|
||||
state: CatanState,
|
||||
seat: Int,
|
||||
to: Int,
|
||||
give: List<Resource>,
|
||||
get: List<Resource>,
|
||||
): Boolean = give.isNotEmpty() && get.isNotEmpty() &&
|
||||
CatanRules.isLegalMove(state, seat, OfferTrade(to, give, get))
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
package org.prolibertate.games.ui.game.catan
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
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.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
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.FilledTonalButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
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.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.prolibertate.games.game.catan.CatanPhase
|
||||
import org.prolibertate.games.game.catan.CatanRules
|
||||
import org.prolibertate.games.game.catan.CatanState
|
||||
import org.prolibertate.games.game.catan.Resource
|
||||
|
||||
/*
|
||||
* Stateless building blocks for the Katan screen.
|
||||
*
|
||||
* Every composable here takes plain values and reports events through lambdas;
|
||||
* none of them holds game state or touches the controller, so each can be
|
||||
* restyled, re-laid-out or replaced on its own. Colours and proportions come
|
||||
* from [CatanStyle] and wording from [CatanText].
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resource tile
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* One resource as a coloured tile with its count. The hand, the discard picker
|
||||
* and the trade pickers all use this, so a resource looks the same everywhere.
|
||||
*/
|
||||
@Composable
|
||||
fun ResourceTile(
|
||||
resource: Resource,
|
||||
count: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
selected: Boolean = false,
|
||||
enabled: Boolean = true,
|
||||
style: CatanStyle = LocalCatanStyle.current,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
val shape = RoundedCornerShape(10.dp)
|
||||
Column(
|
||||
modifier = modifier
|
||||
.defaultMinSize(minWidth = 56.dp)
|
||||
.clip(shape)
|
||||
.background(style.resource.getValue(resource).copy(alpha = if (enabled) 1f else 0.4f))
|
||||
.border(
|
||||
width = if (selected) 3.dp else 1.dp,
|
||||
color = if (selected) Color.White else Color(0x33000000),
|
||||
shape = shape,
|
||||
)
|
||||
.then(if (onClick != null && enabled) Modifier.clickable { onClick() } else Modifier)
|
||||
.padding(horizontal = 6.dp, vertical = 6.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = count.toString(),
|
||||
color = style.resourceInk,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 20.sp,
|
||||
)
|
||||
Text(
|
||||
text = CatanText.resource(resource),
|
||||
color = style.resourceInk,
|
||||
fontSize = 11.sp,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The local player's cards: one tile per resource, in a row that wraps. */
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun CatanHand(
|
||||
state: CatanState,
|
||||
seat: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
FlowRow(
|
||||
modifier = modifier,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Resource.entries.forEach { resource ->
|
||||
ResourceTile(resource, state.resources[seat][resource] ?: 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Players
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* One player's public standing: colour, name, points, how many cards they hold
|
||||
* (not which), development cards, knights and their longest road.
|
||||
*
|
||||
* [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.
|
||||
*/
|
||||
@Composable
|
||||
fun CatanPlayerCard(
|
||||
state: CatanState,
|
||||
seat: Int,
|
||||
seats: CatanSeats,
|
||||
points: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
style: CatanStyle = LocalCatanStyle.current,
|
||||
) {
|
||||
val onTurn = state.turn == seat && state.phase != CatanPhase.GAME_OVER
|
||||
val shape = RoundedCornerShape(12.dp)
|
||||
Column(
|
||||
modifier = modifier
|
||||
.clip(shape)
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.border(
|
||||
width = if (onTurn) 3.dp else 1.dp,
|
||||
color = if (onTurn) MaterialTheme.colorScheme.primary else Color(0x22000000),
|
||||
shape = shape,
|
||||
)
|
||||
.padding(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(14.dp)
|
||||
.clip(CircleShape)
|
||||
.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,
|
||||
)
|
||||
}
|
||||
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 (awards.isNotEmpty()) {
|
||||
Text(
|
||||
text = awards.joinToString(" · "),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dice
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** The two dice as last rolled, with their total. Blank until the first roll. */
|
||||
@Composable
|
||||
fun CatanDice(dice: List<Int>, modifier: Modifier = Modifier) {
|
||||
Row(
|
||||
modifier = modifier,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
val shown = if (dice.size == 2) dice else listOf(null, null)
|
||||
shown.forEach { value ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(Color.White)
|
||||
.border(1.dp, Color(0x55000000), RoundedCornerShape(8.dp)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = value?.toString() ?: "–",
|
||||
color = Color(0xFF1C1B1F),
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 20.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (dice.size == 2) {
|
||||
Text(
|
||||
text = "= ${dice.sum()}",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Actions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 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,
|
||||
val canPlayCard: Boolean = false,
|
||||
val canTrade: Boolean = false,
|
||||
val canEndTurn: Boolean = false,
|
||||
val canEndFreeRoads: Boolean = false,
|
||||
/** A setup settlement is chosen and a different one may still be picked. */
|
||||
val canRepickSetup: Boolean = false,
|
||||
)
|
||||
|
||||
/** The buttons for whatever the player can do now. Pure display: every press is a lambda. */
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun CatanActionBar(
|
||||
actions: CatanActions,
|
||||
onRoll: () -> Unit,
|
||||
onBuild: (BuildKind?) -> Unit,
|
||||
onBuyCard: () -> Unit,
|
||||
onPlayCard: () -> Unit,
|
||||
onTrade: () -> Unit,
|
||||
onEndTurn: () -> Unit,
|
||||
onEndFreeRoads: () -> Unit,
|
||||
onRepickSetup: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
FlowRow(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
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.canEndFreeRoads) Button(onClick = onEndFreeRoads) { Text(CatanText.STOP_ROADS) }
|
||||
if (actions.canEndTurn) Button(onClick = onEndTurn) { Text(CatanText.END_TURN) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BuildButton(
|
||||
kind: BuildKind,
|
||||
label: String,
|
||||
cost: String,
|
||||
actions: CatanActions,
|
||||
onBuild: (BuildKind?) -> Unit,
|
||||
) {
|
||||
val enabled = kind in actions.canBuild
|
||||
val active = actions.building == kind
|
||||
val onClick = { onBuild(if (active) null else kind) }
|
||||
if (active) {
|
||||
FilledTonalButton(onClick = onClick, enabled = enabled) { LabelWithCost(label, cost) }
|
||||
} else {
|
||||
OutlinedButton(onClick = onClick, enabled = enabled) { LabelWithCost(label, cost) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LabelWithCost(label: String, cost: String) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(label)
|
||||
Text(cost, fontSize = 9.sp, maxLines = 1)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Log
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** The most recent happenings, newest last. Scrolls. */
|
||||
@Composable
|
||||
fun CatanLog(state: CatanState, modifier: Modifier = Modifier) {
|
||||
if (state.log.isEmpty()) {
|
||||
Text(CatanText.NO_LOG, style = MaterialTheme.typography.bodySmall, modifier = modifier)
|
||||
return
|
||||
}
|
||||
Column(modifier = modifier.verticalScroll(rememberScrollState())) {
|
||||
state.log.takeLast(40).forEach {
|
||||
Text(text = it, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package org.prolibertate.games.ui.game.catan
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.prolibertate.games.game.GameCatalog
|
||||
import org.prolibertate.games.game.catan.CatanAi
|
||||
import org.prolibertate.games.game.catan.CatanOptions
|
||||
import org.prolibertate.games.game.catan.CatanPhase
|
||||
import org.prolibertate.games.game.catan.CatanRules
|
||||
import org.prolibertate.games.game.catan.CatanState
|
||||
import org.prolibertate.games.game.engine.PlayerKind
|
||||
import org.prolibertate.games.game.engine.PlayerSlot
|
||||
import org.prolibertate.games.game.engine.TableConfig
|
||||
|
||||
/*
|
||||
* Android Studio previews for the Katan UI. They exist so the look can be
|
||||
* iterated on without starting a match: open this file, open the Preview pane,
|
||||
* and edit CatanStyle / CatanText / the panels. Each preview draws a real
|
||||
* position (the computer plays itself for a while), not a mock.
|
||||
*
|
||||
* Nothing here ships in a behaviour sense: previews are only compiled for tooling.
|
||||
*/
|
||||
|
||||
private val previewJson = Json { encodeDefaults = true }
|
||||
|
||||
/** A real game, played forward by the computer for [moves] moves, for previewing. */
|
||||
private fun sampleState(players: Int = 4, moves: Int = 90, seed: Long = 11L): CatanState {
|
||||
val config = TableConfig(
|
||||
gameId = GameCatalog.CATAN,
|
||||
seats = (0 until players).map { PlayerSlot(it, "P$it", PlayerKind.AI, it) },
|
||||
optionsJson = previewJson.encodeToString(CatanOptions(playerCount = players)),
|
||||
seed = seed,
|
||||
)
|
||||
val ai = CatanAi()
|
||||
var state = CatanRules.initialState(config)
|
||||
repeat(moves) {
|
||||
if (state.phase == CatanPhase.GAME_OVER) return state
|
||||
val seat = CatanRules.currentSeat(state) ?: return state
|
||||
val legal = CatanRules.legalMoves(state, seat)
|
||||
if (legal.isEmpty()) return state
|
||||
state = CatanRules.applyMove(state, seat, ai.chooseMove(state, seat, legal))
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
@Preview(showBackground = true, widthDp = 360, heightDp = 360)
|
||||
@Composable
|
||||
private fun BoardPreview() {
|
||||
val state = remember { sampleState() }
|
||||
CompositionLocalProvider(LocalCatanStyle provides catanStyle(dark = false)) {
|
||||
CatanBoardView(
|
||||
state = state,
|
||||
targets = BoardTargets(),
|
||||
selectedVertex = null,
|
||||
onVertexTap = {},
|
||||
onEdgeTap = {},
|
||||
onHexTap = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true, widthDp = 360, heightDp = 360)
|
||||
@Composable
|
||||
private fun BoardDarkPreview() {
|
||||
val state = remember { sampleState() }
|
||||
CompositionLocalProvider(LocalCatanStyle provides catanStyle(dark = true)) {
|
||||
CatanBoardView(
|
||||
state = state,
|
||||
targets = BoardTargets(),
|
||||
selectedVertex = null,
|
||||
onVertexTap = {},
|
||||
onEdgeTap = {},
|
||||
onHexTap = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true, widthDp = 360, heightDp = 360)
|
||||
@Composable
|
||||
private fun BoardWithTargetsPreview() {
|
||||
val state = remember { sampleState(moves = 6) }
|
||||
val legal = remember(state) { CatanRules.currentSeat(state)?.let { CatanRules.legalMoves(state, it) }.orEmpty() }
|
||||
CompositionLocalProvider(LocalCatanStyle provides catanStyle(dark = false)) {
|
||||
CatanBoardView(
|
||||
state = state,
|
||||
targets = CatanInteraction.targets(state, legal, building = null, setupVertex = null),
|
||||
selectedVertex = null,
|
||||
onVertexTap = {},
|
||||
onEdgeTap = {},
|
||||
onHexTap = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true, widthDp = 360, heightDp = 560)
|
||||
@Composable
|
||||
private fun PanelsPreview() {
|
||||
val state = remember { sampleState() }
|
||||
val seats = CatanSeats(me = 0, names = listOf("You", "Ann", "Bo", "Cy"))
|
||||
val points = remember(state) { CatanRules.publicVictoryPoints(state) }
|
||||
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))
|
||||
}
|
||||
}
|
||||
CatanHand(state, seat = 0)
|
||||
CatanDice(state.lastDice)
|
||||
CatanActionBar(
|
||||
actions = CatanActions(
|
||||
showTurnActions = true,
|
||||
canBuild = setOf(BuildKind.ROAD, BuildKind.CITY),
|
||||
building = BuildKind.ROAD,
|
||||
canBuyCard = true,
|
||||
canTrade = true,
|
||||
canEndTurn = true,
|
||||
),
|
||||
onRoll = {}, onBuild = {}, onBuyCard = {}, onPlayCard = {}, onTrade = {},
|
||||
onEndTurn = {}, onEndFreeRoads = {}, onRepickSetup = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
package org.prolibertate.games.ui.game.catan
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
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.unit.dp
|
||||
import org.prolibertate.games.game.catan.AcceptTrade
|
||||
import org.prolibertate.games.game.catan.BuyDevCard
|
||||
import org.prolibertate.games.game.catan.CatanMove
|
||||
import org.prolibertate.games.game.catan.CatanPhase
|
||||
import org.prolibertate.games.game.catan.CatanRules
|
||||
import org.prolibertate.games.game.catan.CatanState
|
||||
import org.prolibertate.games.game.catan.DeclineTrade
|
||||
import org.prolibertate.games.game.catan.EndFreeRoads
|
||||
import org.prolibertate.games.game.catan.EndTurn
|
||||
import org.prolibertate.games.game.catan.MoveRobber
|
||||
import org.prolibertate.games.game.catan.RollDice
|
||||
import org.prolibertate.games.game.catan.SetupPlace
|
||||
import org.prolibertate.games.net.MatchController
|
||||
import org.prolibertate.games.ui.ScreenScaffold
|
||||
|
||||
/**
|
||||
* Katan, on the island.
|
||||
*
|
||||
* This composable is only wiring. It reads the match from the controller, holds
|
||||
* the handful of purely visual choices (which build mode is armed, which setup
|
||||
* settlement is waiting for its road, which dialog is open), and hands
|
||||
* everything else to single-purpose pieces:
|
||||
*
|
||||
* | Concern | Where it lives |
|
||||
* | ------------------------------ | -------------------------- |
|
||||
* | Colours, sizes, shapes | [CatanStyle] |
|
||||
* | Every word on screen | [CatanText] |
|
||||
* | Island → pixels, taps → hexes | [CatanGeometry] |
|
||||
* | What is tappable, what a tap does | [CatanInteraction] |
|
||||
* | Drawing the island | [CatanBoardView] |
|
||||
* | Cards, players, dice, buttons | `CatanPanels.kt` |
|
||||
* | Every dialog | `CatanDialogs.kt` |
|
||||
*
|
||||
* There are no game rules here: the engine's legal-move list is the only source
|
||||
* of what the player may do, so the screen cannot offer a move the rules refuse.
|
||||
* See `docs/UI-catan.md` for how to adapt the look and feel.
|
||||
*/
|
||||
@Composable
|
||||
fun CatanScreen(
|
||||
controller: MatchController<CatanState, CatanMove>,
|
||||
localSeat: Int,
|
||||
onExit: () -> Unit,
|
||||
seatNames: List<String> = emptyList(),
|
||||
) {
|
||||
val state by controller.state.collectAsState()
|
||||
val legal by controller.legalMoves.collectAsState()
|
||||
val abandoned by controller.abandoned.collectAsState()
|
||||
val notice by controller.notice.collectAsState()
|
||||
|
||||
// Purely visual state. Anything that affects the game goes through submit().
|
||||
var building by remember { mutableStateOf<BuildKind?>(null) }
|
||||
var setupVertex by remember { mutableStateOf<Int?>(null) }
|
||||
var victimChoice by remember { mutableStateOf<TapOutcome.PickRobberVictim?>(null) }
|
||||
var showCards by remember { mutableStateOf(false) }
|
||||
var showTrade by remember { mutableStateOf(false) }
|
||||
var confirmingEnd by remember { mutableStateOf(false) }
|
||||
var resultDismissed by remember { mutableStateOf(false) }
|
||||
|
||||
val dark = isSystemInDarkTheme()
|
||||
val style = remember(dark) { catanStyle(dark) }
|
||||
val seats = remember(localSeat, seatNames) { CatanSeats(localSeat, seatNames) }
|
||||
|
||||
// The engine's list changes after every move; drop any selection it no
|
||||
// longer supports (a build mode the player can no longer afford, a setup
|
||||
// settlement that is no longer pending).
|
||||
LaunchedEffect(legal) {
|
||||
if (building != null && building !in CatanInteraction.canBuild(legal)) building = null
|
||||
if (setupVertex != null && legal.none { it is SetupPlace && it.vertex == setupVertex }) {
|
||||
setupVertex = null
|
||||
}
|
||||
if (legal.isEmpty()) {
|
||||
showCards = false
|
||||
showTrade = false
|
||||
victimChoice = null
|
||||
}
|
||||
}
|
||||
|
||||
val submit: (CatanMove) -> Unit = { move ->
|
||||
controller.submit(move)
|
||||
building = null
|
||||
setupVertex = null
|
||||
victimChoice = null
|
||||
showCards = false
|
||||
showTrade = false
|
||||
}
|
||||
|
||||
val endGameAction: @Composable () -> Unit = {
|
||||
TextButton(onClick = { confirmingEnd = true }) { Text(CatanText.END_GAME) }
|
||||
}
|
||||
|
||||
val current = state
|
||||
if (current == null) {
|
||||
ScreenScaffold(title = CatanText.TITLE, onBack = onExit, actions = endGameAction) { modifier ->
|
||||
Box(modifier, contentAlignment = Alignment.Center) { Text("Setting up…") }
|
||||
abandoned?.let { CatanAbandonedDialog(it, onExit, controller::dismissAbandoned) }
|
||||
if (confirmingEnd) CatanEndGameDialog(onExit) { confirmingEnd = false }
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val targets = CatanInteraction.targets(current, legal, building, setupVertex)
|
||||
val devHand = CatanInteraction.devHand(current, localSeat, legal)
|
||||
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),
|
||||
canPlayCard = devHand.any { it.playable },
|
||||
canTrade = playing,
|
||||
canEndTurn = CatanInteraction.canEndTurn(legal),
|
||||
canEndFreeRoads = CatanInteraction.canEndFreeRoads(legal),
|
||||
canRepickSetup = setupVertex != null,
|
||||
)
|
||||
val prompt = CatanText.prompt(current, seats, setupVertexChosen = setupVertex != null, building = building)
|
||||
|
||||
val handle: (TapOutcome) -> Unit = { outcome ->
|
||||
when (outcome) {
|
||||
TapOutcome.Ignored -> Unit
|
||||
is TapOutcome.Submit -> submit(outcome.move)
|
||||
is TapOutcome.PickSetupVertex -> setupVertex = outcome.vertex
|
||||
is TapOutcome.PickRobberVictim -> victimChoice = outcome
|
||||
}
|
||||
}
|
||||
|
||||
val publicPoints = CatanRules.publicVictoryPoints(current)
|
||||
val pointsOf: (Int) -> Int = { seat ->
|
||||
if (seat == localSeat) CatanRules.victoryPoints(current, seat) else publicPoints[seat]
|
||||
}
|
||||
|
||||
CompositionLocalProvider(LocalCatanStyle provides style) {
|
||||
ScreenScaffold(title = CatanText.TITLE, onBack = onExit, actions = endGameAction) { modifier ->
|
||||
BoxWithConstraints(modifier = modifier) {
|
||||
val wide = maxWidth > maxHeight * 1.1f
|
||||
|
||||
val board: @Composable (Modifier) -> Unit = { boardModifier ->
|
||||
CatanBoardView(
|
||||
state = current,
|
||||
targets = targets,
|
||||
selectedVertex = setupVertex,
|
||||
onVertexTap = { handle(CatanInteraction.onVertexTap(it, legal, building, setupVertex)) },
|
||||
onEdgeTap = { handle(CatanInteraction.onEdgeTap(it, legal, building, setupVertex)) },
|
||||
onHexTap = { handle(CatanInteraction.onHexTap(it, legal)) },
|
||||
modifier = boardModifier,
|
||||
style = style,
|
||||
)
|
||||
}
|
||||
val players: @Composable (Boolean) -> Unit = { grid ->
|
||||
PlayerCards(current, seats, pointsOf, style, twoPerRow = grid)
|
||||
}
|
||||
val promptRow: @Composable () -> Unit = {
|
||||
PromptRow(prompt, notice, current)
|
||||
}
|
||||
val handAndActions: @Composable () -> Unit = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
CatanHand(current, localSeat)
|
||||
CatanActionBar(
|
||||
actions = actions,
|
||||
onRoll = { submit(RollDice()) },
|
||||
onBuild = { building = it },
|
||||
onBuyCard = { legal.firstOrNull { m -> m is BuyDevCard }?.let(submit) },
|
||||
onPlayCard = { showCards = true },
|
||||
onTrade = { showTrade = true },
|
||||
onEndTurn = { legal.firstOrNull { m -> m is EndTurn }?.let(submit) },
|
||||
onEndFreeRoads = {
|
||||
legal.firstOrNull { m -> m is EndFreeRoads }?.let(submit)
|
||||
},
|
||||
onRepickSetup = { setupVertex = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (wide) {
|
||||
Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Box(Modifier.weight(1.5f).fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
board(Modifier)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.weight(1f).fillMaxSize().verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
players(true)
|
||||
promptRow()
|
||||
handAndActions()
|
||||
CatanLog(current, Modifier.fillMaxWidth().height(120.dp))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
players(false)
|
||||
promptRow()
|
||||
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
board(Modifier)
|
||||
}
|
||||
handAndActions()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CatanDialogHost(
|
||||
state = current,
|
||||
seats = seats,
|
||||
localSeat = localSeat,
|
||||
legal = legal,
|
||||
abandoned = abandoned,
|
||||
confirmingEnd = confirmingEnd,
|
||||
resultDismissed = resultDismissed,
|
||||
victimChoice = victimChoice,
|
||||
showCards = showCards,
|
||||
showTrade = showTrade,
|
||||
devHand = devHand,
|
||||
onExit = onExit,
|
||||
onSubmit = submit,
|
||||
onStay = controller::dismissAbandoned,
|
||||
onKeepPlaying = { confirmingEnd = false },
|
||||
onDismissResult = { resultDismissed = true },
|
||||
onCancelVictim = { victimChoice = null },
|
||||
onCancelCards = { showCards = false },
|
||||
onCancelTrade = { showTrade = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Layout pieces
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@Composable
|
||||
private fun PlayerCards(
|
||||
state: CatanState,
|
||||
seats: CatanSeats,
|
||||
pointsOf: (Int) -> Int,
|
||||
style: CatanStyle,
|
||||
twoPerRow: Boolean,
|
||||
) {
|
||||
val order = (0 until state.playerCount).toList()
|
||||
val rows = if (twoPerRow) order.chunked(2) else listOf(order)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
rows.forEach { row ->
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
row.forEach { seat ->
|
||||
CatanPlayerCard(
|
||||
state = state,
|
||||
seat = seat,
|
||||
seats = seats,
|
||||
points = pointsOf(seat),
|
||||
modifier = Modifier.weight(1f),
|
||||
style = style,
|
||||
)
|
||||
}
|
||||
// Keep an odd last row's card the same width as the others.
|
||||
if (twoPerRow && row.size == 1) Box(Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PromptRow(prompt: String, notice: String?, state: CatanState) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
CatanDice(state.lastDice)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Chooses which dialog, if any, is showing. Pulled out of [CatanScreen] so the
|
||||
* priority order — an ended table beats a result beats a forced discard beats
|
||||
* an optional picker — is in one readable place.
|
||||
*/
|
||||
@Composable
|
||||
private fun CatanDialogHost(
|
||||
state: CatanState,
|
||||
seats: CatanSeats,
|
||||
localSeat: Int,
|
||||
legal: List<CatanMove>,
|
||||
abandoned: String?,
|
||||
confirmingEnd: Boolean,
|
||||
resultDismissed: Boolean,
|
||||
victimChoice: TapOutcome.PickRobberVictim?,
|
||||
showCards: Boolean,
|
||||
showTrade: Boolean,
|
||||
devHand: List<DevCardInHand>,
|
||||
onExit: () -> Unit,
|
||||
onSubmit: (CatanMove) -> Unit,
|
||||
onStay: () -> Unit,
|
||||
onKeepPlaying: () -> Unit,
|
||||
onDismissResult: () -> Unit,
|
||||
onCancelVictim: () -> Unit,
|
||||
onCancelCards: () -> Unit,
|
||||
onCancelTrade: () -> Unit,
|
||||
) {
|
||||
val offer = state.pendingTrade
|
||||
when {
|
||||
abandoned != null -> CatanAbandonedDialog(abandoned, onExit, onStay)
|
||||
|
||||
confirmingEnd -> CatanEndGameDialog(onEnd = onExit, onKeepPlaying = onKeepPlaying)
|
||||
|
||||
state.phase == CatanPhase.GAME_OVER ->
|
||||
if (!resultDismissed) CatanGameOverDialog(state, seats, onExit, onDismissResult)
|
||||
|
||||
state.phase == CatanPhase.DISCARDING && state.discardSeat == localSeat ->
|
||||
CatanDiscardDialog(
|
||||
held = state.resources[localSeat],
|
||||
quota = CatanInteraction.discardQuota(state.resourceCount(localSeat)),
|
||||
legal = legal,
|
||||
onSubmit = onSubmit,
|
||||
)
|
||||
|
||||
state.phase == CatanPhase.TRADE_PENDING && offer != null && offer.to == localSeat ->
|
||||
CatanTradeResponseDialog(
|
||||
offer = offer,
|
||||
seats = seats,
|
||||
canAccept = CatanInteraction.tradeResponses(legal).first,
|
||||
onAccept = { onSubmit(AcceptTrade) },
|
||||
onDecline = { onSubmit(DeclineTrade) },
|
||||
)
|
||||
|
||||
victimChoice != null ->
|
||||
CatanVictimDialog(
|
||||
victims = victimChoice.victims,
|
||||
seats = seats,
|
||||
onPick = { victim -> onSubmit(MoveRobber(victimChoice.hex, victim)) },
|
||||
onCancel = onCancelVictim,
|
||||
)
|
||||
|
||||
showCards -> CatanPlayCardDialog(devHand, onPlay = onSubmit, onCancel = onCancelCards)
|
||||
|
||||
showTrade ->
|
||||
CatanTradeDialog(
|
||||
state = state,
|
||||
seat = localSeat,
|
||||
seats = seats,
|
||||
legal = legal,
|
||||
onBank = onSubmit,
|
||||
onOffer = onSubmit,
|
||||
onCancel = onCancelTrade,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package org.prolibertate.games.ui.game.catan
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import org.prolibertate.games.game.catan.Resource
|
||||
import org.prolibertate.games.game.catan.Terrain
|
||||
|
||||
/**
|
||||
* Every visual decision for the Katan screen, in one place.
|
||||
*
|
||||
* **To restyle Katan, edit this file.** Nothing else in `ui/game/catan/` hard-
|
||||
* codes a colour or a proportion: the board, the hand, the player strip and the
|
||||
* dialogs all read a [CatanStyle] from [LocalCatanStyle]. A different art
|
||||
* direction is a different [CatanStyle] value (see [catanStyle]); a new theme
|
||||
* can be supplied at the screen's root with
|
||||
* `CompositionLocalProvider(LocalCatanStyle provides myStyle)`.
|
||||
*
|
||||
* Wording is the same story in [CatanText]; geometry is in [CatanGeometry].
|
||||
*
|
||||
* Proportions are fractions of the *hex radius* (centre of a hex to one of its
|
||||
* corners), so they scale with the board whatever the screen size.
|
||||
*/
|
||||
@Immutable
|
||||
data class CatanStyle(
|
||||
// --- Board surfaces -----------------------------------------------------
|
||||
/** The water around the island. */
|
||||
val sea: Color,
|
||||
/** Fill of each hex by terrain. */
|
||||
val terrain: Map<Terrain, Color>,
|
||||
/** Outline between hexes. */
|
||||
val hexEdge: Color,
|
||||
/** The number-token disc, and the ink for ordinary and hot (6 / 8) numbers. */
|
||||
val token: Color,
|
||||
val tokenInk: Color,
|
||||
val tokenHotInk: Color,
|
||||
/** The robber's pawn. */
|
||||
val robber: Color,
|
||||
/** Harbour marker disc and its ink. */
|
||||
val portMarker: Color,
|
||||
val portInk: Color,
|
||||
|
||||
// --- Resources (hand tiles, trade pickers, harbour markers) -------------
|
||||
val resource: Map<Resource, Color>,
|
||||
val resourceInk: Color,
|
||||
|
||||
// --- Players ------------------------------------------------------------
|
||||
/** One colour per seat, in seat order. Roads, settlements, cities, player strip. */
|
||||
val seats: List<Color>,
|
||||
/** Outline drawn round every piece so any seat colour reads on any terrain. */
|
||||
val pieceOutline: Color,
|
||||
|
||||
// --- Interaction feedback ----------------------------------------------
|
||||
/** Marks where the player may build or move right now. */
|
||||
val target: Color,
|
||||
/** The setup settlement that has been chosen and is waiting for its road. */
|
||||
val targetSelected: Color,
|
||||
|
||||
// --- Metrics (fractions of the hex radius) ------------------------------
|
||||
/** How far each tile polygon is pulled in from its corners, leaving a gap. */
|
||||
val hexInset: Float = 0.04f,
|
||||
val roadWidth: Float = 0.20f,
|
||||
val settlementSize: Float = 0.30f,
|
||||
val citySize: Float = 0.42f,
|
||||
val tokenRadius: Float = 0.36f,
|
||||
val robberSize: Float = 0.34f,
|
||||
val portRadius: Float = 0.34f,
|
||||
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,
|
||||
)
|
||||
|
||||
/** The light palette, which is also the reference for the dark one. */
|
||||
private val LightStyle = CatanStyle(
|
||||
sea = Color(0xFF3A7CA5),
|
||||
terrain = mapOf(
|
||||
Terrain.FOREST to Color(0xFF2F6B3A),
|
||||
Terrain.PASTURE to Color(0xFF9CCC65),
|
||||
Terrain.FIELDS to Color(0xFFE8C84A),
|
||||
Terrain.HILLS to Color(0xFFB4532F),
|
||||
Terrain.MOUNTAINS to Color(0xFF7C8794),
|
||||
Terrain.DESERT to Color(0xFFE6D3A0),
|
||||
),
|
||||
hexEdge = Color(0x55000000),
|
||||
token = Color(0xFFF7EFD9),
|
||||
tokenInk = Color(0xFF2B2B2B),
|
||||
tokenHotInk = Color(0xFFC62828),
|
||||
robber = Color(0xFF263238),
|
||||
portMarker = Color(0xFFFFF8E1),
|
||||
portInk = Color(0xFF2B2B2B),
|
||||
resource = mapOf(
|
||||
Resource.LUMBER to Color(0xFF2F6B3A),
|
||||
Resource.BRICK to Color(0xFFB4532F),
|
||||
Resource.WOOL to Color(0xFF8BC34A),
|
||||
Resource.GRAIN to Color(0xFFE0B020),
|
||||
Resource.ORE to Color(0xFF6B7684),
|
||||
),
|
||||
resourceInk = Color(0xFFFFFFFF),
|
||||
seats = listOf(
|
||||
Color(0xFF1565C0), // blue
|
||||
Color(0xFFEF6C00), // orange
|
||||
Color(0xFFF5F5F5), // white
|
||||
Color(0xFF8E24AA), // purple
|
||||
),
|
||||
pieceOutline = Color(0xFF1B1B1B),
|
||||
target = Color(0xFFFFEB3B),
|
||||
targetSelected = Color(0xFF00E5FF),
|
||||
)
|
||||
|
||||
/** Deeper sea and slightly dimmer land so the board is not a glare at night. */
|
||||
private val DarkStyle = LightStyle.copy(
|
||||
sea = Color(0xFF14354D),
|
||||
terrain = LightStyle.terrain.mapValues { (_, c) -> c.dim(0.82f) },
|
||||
token = Color(0xFFE9DFC4),
|
||||
)
|
||||
|
||||
/** The style for the current light / dark setting. */
|
||||
fun catanStyle(dark: Boolean): CatanStyle = if (dark) DarkStyle else LightStyle
|
||||
|
||||
/**
|
||||
* What the Katan composables read their look from. Defaults to the light style
|
||||
* so a preview or a test with no provider still draws something sensible;
|
||||
* [CatanScreen] provides the right one for the current theme.
|
||||
*/
|
||||
val LocalCatanStyle = staticCompositionLocalOf { LightStyle }
|
||||
|
||||
private fun Color.dim(factor: Float): Color =
|
||||
Color(red * factor, green * factor, blue * factor, alpha)
|
||||
@@ -0,0 +1,213 @@
|
||||
package org.prolibertate.games.ui.game.catan
|
||||
|
||||
import org.prolibertate.games.game.catan.CatanPhase
|
||||
import org.prolibertate.games.game.catan.CatanRules
|
||||
import org.prolibertate.games.game.catan.CatanState
|
||||
import org.prolibertate.games.game.catan.DevCardType
|
||||
import org.prolibertate.games.game.catan.Port
|
||||
import org.prolibertate.games.game.catan.Resource
|
||||
import org.prolibertate.games.game.catan.Terrain
|
||||
|
||||
/**
|
||||
* Who is who at the table, for wording. [names] may be empty or short — a
|
||||
* client device does not always know everyone's name — and the rest fall back
|
||||
* to "Seat N".
|
||||
*/
|
||||
class CatanSeats(val me: Int, private val names: List<String> = emptyList()) {
|
||||
fun label(seat: Int): String = when {
|
||||
seat == me -> "You"
|
||||
else -> names.getOrNull(seat)?.takeIf { it.isNotBlank() } ?: "Seat ${seat + 1}"
|
||||
}
|
||||
|
||||
/** For mid-sentence use: "you" rather than "You". */
|
||||
fun lower(seat: Int): String = if (seat == me) "you" else label(seat)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every word the Katan screen shows, in one place.
|
||||
*
|
||||
* **To change copy, or to localise, edit this file.** The composables never
|
||||
* build a sentence themselves; they ask for it here. Keeping the sentences as
|
||||
* plain functions of state also means they are unit-tested (`CatanTextTest`)
|
||||
* without a device.
|
||||
*
|
||||
* "Katan" is this app's name for the game (see `docs/RULES-catan.md`); the
|
||||
* resources are lumber, brick, wool, grain and ore.
|
||||
*/
|
||||
object CatanText {
|
||||
|
||||
const val TITLE = "Katan"
|
||||
|
||||
// --- Names ---------------------------------------------------------------
|
||||
|
||||
fun resource(resource: Resource): String = when (resource) {
|
||||
Resource.LUMBER -> "Lumber"
|
||||
Resource.BRICK -> "Brick"
|
||||
Resource.WOOL -> "Wool"
|
||||
Resource.GRAIN -> "Grain"
|
||||
Resource.ORE -> "Ore"
|
||||
}
|
||||
|
||||
fun terrain(terrain: Terrain): String = when (terrain) {
|
||||
Terrain.FOREST -> "Forest"
|
||||
Terrain.PASTURE -> "Pasture"
|
||||
Terrain.FIELDS -> "Fields"
|
||||
Terrain.HILLS -> "Hills"
|
||||
Terrain.MOUNTAINS -> "Mountains"
|
||||
Terrain.DESERT -> "Desert"
|
||||
}
|
||||
|
||||
fun devCard(type: DevCardType): String = when (type) {
|
||||
DevCardType.KNIGHT -> "Knight"
|
||||
DevCardType.ROAD_BUILDING -> "Road building"
|
||||
DevCardType.MONOPOLY -> "Monopoly"
|
||||
DevCardType.YEAR_OF_PLENTY -> "Year of plenty"
|
||||
DevCardType.VICTORY_POINT -> "Victory point"
|
||||
}
|
||||
|
||||
fun devCardHelp(type: DevCardType): String = when (type) {
|
||||
DevCardType.KNIGHT -> "Move the robber and steal a card. Three played knights take Largest Army."
|
||||
DevCardType.ROAD_BUILDING -> "Build two roads for free."
|
||||
DevCardType.MONOPOLY -> "Name a resource and take every card of it from the other players."
|
||||
DevCardType.YEAR_OF_PLENTY -> "Take any two resource cards from the bank."
|
||||
DevCardType.VICTORY_POINT -> "One victory point. Kept hidden until you win."
|
||||
}
|
||||
|
||||
/** "3:1" for a generic harbour, "2:1" for a resource one. */
|
||||
fun portRatio(port: Port): String = "${port.ratio}:1"
|
||||
|
||||
fun portName(port: Port): String =
|
||||
port.resource?.let { "${portRatio(port)} ${resource(it).lowercase()}" } ?: "3:1 any"
|
||||
|
||||
// --- Costs ---------------------------------------------------------------
|
||||
|
||||
fun cost(cost: Map<Resource, Int>): String =
|
||||
cost.entries.joinToString(" + ") { (r, n) -> "$n ${resource(r).lowercase()}" }
|
||||
|
||||
val ROAD_COST: String get() = cost(CatanRules.ROAD_COST)
|
||||
val SETTLEMENT_COST: String get() = cost(CatanRules.SETTLEMENT_COST)
|
||||
val CITY_COST: String get() = cost(CatanRules.CITY_COST)
|
||||
val DEV_COST: String get() = cost(CatanRules.DEV_COST)
|
||||
|
||||
// --- Buttons and headings --------------------------------------------------
|
||||
|
||||
const val ROLL = "Roll dice"
|
||||
const val END_TURN = "End turn"
|
||||
const val ROAD = "Road"
|
||||
const val SETTLEMENT = "Settlement"
|
||||
const val CITY = "City"
|
||||
const val BUY_CARD = "Buy card"
|
||||
const val PLAY_CARD = "Play card"
|
||||
const val TRADE = "Trade"
|
||||
const val CANCEL = "Cancel"
|
||||
const val CONFIRM = "Confirm"
|
||||
const val STOP_ROADS = "Done with roads"
|
||||
const val BUILD = "Build"
|
||||
const val YOUR_HAND = "Your hand"
|
||||
const val CARDS = "cards"
|
||||
const val ACCEPT = "Accept"
|
||||
const val DECLINE = "Decline"
|
||||
const val TRADE_WITH_PLAYER = "Offer to a player"
|
||||
const val TRADE_WITH_BANK = "Trade with the bank"
|
||||
const val YOU_GIVE = "You give"
|
||||
const val YOU_GET = "You get"
|
||||
const val SEND_OFFER = "Send offer"
|
||||
const val NO_LOG = "Nothing has happened yet."
|
||||
const val END_GAME = "End game"
|
||||
const val LOOK_AT_BOARD = "Look at the board"
|
||||
const val LEAVE_GAME = "Leave the game"
|
||||
|
||||
// --- The prompt line ------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The one line that tells the player what is going on and what to do.
|
||||
*
|
||||
* [setupVertexChosen] is the setup settlement already picked and waiting
|
||||
* for its road; [building] is the build mode the player has entered, if any.
|
||||
*/
|
||||
fun prompt(
|
||||
state: CatanState,
|
||||
seats: CatanSeats,
|
||||
setupVertexChosen: Boolean,
|
||||
building: BuildKind?,
|
||||
): String {
|
||||
val me = seats.me
|
||||
val onClock = CatanRules.currentSeat(state)
|
||||
val mine = onClock == me
|
||||
val turn = seats.label(state.turn)
|
||||
|
||||
return when (state.phase) {
|
||||
CatanPhase.GAME_OVER -> {
|
||||
val winner = state.winner
|
||||
if (winner == null) "The game is over." else "${seats.label(winner)} won the game."
|
||||
}
|
||||
|
||||
CatanPhase.SETUP_FIRST, CatanPhase.SETUP_SECOND -> when {
|
||||
!mine -> "${seats.label(onClock ?: state.turn)} is placing a settlement and a road…"
|
||||
setupVertexChosen -> "Now tap the road to place beside that settlement."
|
||||
state.phase == CatanPhase.SETUP_SECOND ->
|
||||
"Place your second settlement. It earns resources from the hexes around it."
|
||||
|
||||
else -> "Place a settlement, then a road."
|
||||
}
|
||||
|
||||
CatanPhase.AWAIT_ROLL ->
|
||||
if (mine) "Your turn. Roll the dice." else "$turn is rolling…"
|
||||
|
||||
CatanPhase.DISCARDING -> {
|
||||
val discarder = state.discardSeat
|
||||
when {
|
||||
discarder == me -> "A seven! You hold ${state.resourceCount(me)} cards; " +
|
||||
"discard ${state.resourceCount(me) / 2}."
|
||||
|
||||
discarder != null -> "A seven! ${seats.label(discarder)} must discard half."
|
||||
else -> "A seven! Discarding…"
|
||||
}
|
||||
}
|
||||
|
||||
CatanPhase.ROBBER ->
|
||||
if (mine) "Move the robber. Tap a hex." else "$turn is moving the robber…"
|
||||
|
||||
CatanPhase.PLAYING -> when {
|
||||
!mine -> "$turn is playing…"
|
||||
building == BuildKind.ROAD -> "Tap an edge to build a road."
|
||||
building == BuildKind.SETTLEMENT -> "Tap an empty crossing to build a settlement."
|
||||
building == BuildKind.CITY -> "Tap one of your settlements to upgrade it."
|
||||
else -> "Your turn. Build, trade, or end the turn."
|
||||
}
|
||||
|
||||
CatanPhase.FREE_ROADS ->
|
||||
if (mine) {
|
||||
"Road building: place ${state.pendingFreeRoads} more " +
|
||||
if (state.pendingFreeRoads == 1) "road." else "roads."
|
||||
} else {
|
||||
"$turn is placing free roads…"
|
||||
}
|
||||
|
||||
CatanPhase.TRADE_PENDING -> {
|
||||
val offer = state.pendingTrade
|
||||
when {
|
||||
offer == null -> "Waiting for a reply…"
|
||||
offer.to == me -> "${seats.label(offer.by)} offers you a trade."
|
||||
offer.by == me -> "Waiting for ${seats.label(offer.to)} to answer your offer…"
|
||||
else -> "${seats.label(offer.by)} has offered ${seats.label(offer.to)} a trade."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** "3 Lumber + 1 Brick" for a flat list of cards. */
|
||||
fun cards(cards: List<Resource>): String =
|
||||
cards.groupingBy { it }.eachCount().entries
|
||||
.sortedBy { it.key.ordinal }
|
||||
.joinToString(" + ") { (r, n) -> "$n ${resource(r)}" }
|
||||
.ifEmpty { "nothing" }
|
||||
|
||||
fun tradeSummary(by: String, to: String, give: List<Resource>, get: List<Resource>): String =
|
||||
"$by offers ${cards(give)} to $to for ${cards(get)}"
|
||||
|
||||
fun winnerHeadline(state: CatanState, seats: CatanSeats): String {
|
||||
val winner = state.winner ?: return "Game over"
|
||||
return if (winner == seats.me) "You won!" else "${seats.label(winner)} won"
|
||||
}
|
||||
}
|
||||
@@ -121,6 +121,20 @@ class CatalogTest {
|
||||
assertEquals(listOf(GameCatalog.byId(GameCatalog.CATAN)!!), hiddenFromProduction)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a preview game opens on a dev build and nowhere else`() {
|
||||
val preview = GameCatalog.all.filter { it.devPreview }
|
||||
assertTrue("Katan is the preview game", preview.map { it.id } == listOf(GameCatalog.CATAN))
|
||||
preview.forEach { game ->
|
||||
assertFalse("${game.id}: preview is for games that are not yet available", game.available)
|
||||
assertTrue("${game.id} should open on a dev build", game.playableIn(devBuild = true))
|
||||
assertFalse("${game.id} must not open in production", game.playableIn(devBuild = false))
|
||||
}
|
||||
// An ordinary finished game opens everywhere.
|
||||
val chess = GameCatalog.byId(GameCatalog.CHESS)!!
|
||||
assertTrue(chess.playableIn(devBuild = true) && chess.playableIn(devBuild = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hiding unfinished games still leaves something to play in every menu`() {
|
||||
// A menu filtered down to nothing would leave a stranded heading, and a
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package org.prolibertate.games.ui.game.catan
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.prolibertate.games.game.GameCatalog
|
||||
import org.prolibertate.games.game.catan.BuildingType
|
||||
import org.prolibertate.games.game.catan.CatanBuilding
|
||||
import org.prolibertate.games.game.catan.CatanOptions
|
||||
import org.prolibertate.games.game.catan.CatanPhase
|
||||
import org.prolibertate.games.game.catan.CatanRules
|
||||
import org.prolibertate.games.game.catan.CatanState
|
||||
import org.prolibertate.games.game.catan.Resource
|
||||
import org.prolibertate.games.game.engine.PlayerKind
|
||||
import org.prolibertate.games.game.engine.PlayerSlot
|
||||
import org.prolibertate.games.game.engine.TableConfig
|
||||
|
||||
/** Shared fixtures for the Katan UI-logic tests: real islands, bent to taste. */
|
||||
internal object CatanFixtures {
|
||||
|
||||
private val json = Json { encodeDefaults = true }
|
||||
|
||||
fun config(players: Int = 3, seed: Long = 7L) = TableConfig(
|
||||
gameId = GameCatalog.CATAN,
|
||||
seats = (0 until players).map { PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it) },
|
||||
optionsJson = json.encodeToString(CatanOptions(playerCount = players)),
|
||||
seed = seed,
|
||||
)
|
||||
|
||||
/** A fresh table, on the first setup placement. */
|
||||
fun start(players: Int = 3, seed: Long = 7L): CatanState =
|
||||
CatanRules.initialState(config(players, seed))
|
||||
|
||||
/** A mid-game state on [turn]'s turn, then adjusted by [adjust]. */
|
||||
fun playing(
|
||||
turn: Int = 0,
|
||||
players: Int = 3,
|
||||
adjust: CatanState.() -> CatanState = { this },
|
||||
): CatanState = start(players)
|
||||
.copy(phase = CatanPhase.PLAYING, turn = turn, turnNumber = 1)
|
||||
.adjust()
|
||||
|
||||
fun hand(vararg cards: Pair<Resource, Int>): Map<Resource, Int> =
|
||||
Resource.entries.associateWith { cards.toMap()[it] ?: 0 }
|
||||
|
||||
fun CatanState.withHand(seat: Int, hand: Map<Resource, Int>): CatanState =
|
||||
copy(resources = resources.toMutableList().also { it[seat] = hand })
|
||||
|
||||
fun CatanState.withBuilding(vertex: Int, type: BuildingType, owner: Int): CatanState =
|
||||
copy(buildings = buildings.toMutableList().also { it[vertex] = CatanBuilding(type, owner) })
|
||||
|
||||
fun CatanState.withRoad(edge: Int, owner: Int): CatanState =
|
||||
copy(roads = roads.toMutableList().also { it[edge] = owner })
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package org.prolibertate.games.ui.game.catan
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.prolibertate.games.game.catan.CatanBoard
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.sqrt
|
||||
|
||||
class CatanGeometryTest {
|
||||
|
||||
private val sizes = listOf(360f to 800f, 800f to 360f, 500f to 500f, 1000f to 1000f / CatanGeometry.ASPECT)
|
||||
|
||||
private fun dist(a: Pt, b: Pt): Float = sqrt((a.x - b.x) * (a.x - b.x) + (a.y - b.y) * (a.y - b.y))
|
||||
|
||||
@Test
|
||||
fun `the whole island fits inside the box at every shape`() {
|
||||
for ((w, h) in sizes) {
|
||||
val layout = CatanGeometry.fit(w, h)
|
||||
for (v in 0 until CatanBoard.VERTEX_COUNT) {
|
||||
val p = layout.vertex(v)
|
||||
assertTrue("vertex $v x=${p.x} outside 0..$w", p.x in 0f..w)
|
||||
assertTrue("vertex $v y=${p.y} outside 0..$h", p.y in 0f..h)
|
||||
}
|
||||
for (e in CatanBoard.PORT_POSITIONS) {
|
||||
val p = layout.portAnchor(e)
|
||||
assertTrue("harbour marker x=${p.x} outside 0..$w at ${w}x$h", p.x in 0f..w)
|
||||
assertTrue("harbour marker y=${p.y} outside 0..$h at ${w}x$h", p.y in 0f..h)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the layout is as large as the box allows`() {
|
||||
// Tall and narrow is limited by width, short and wide by height; either
|
||||
// way the island should use most of the limiting side.
|
||||
val tall = CatanGeometry.fit(360f, 800f)
|
||||
val wide = CatanGeometry.fit(800f, 360f)
|
||||
assertTrue(tall.radius > 20f)
|
||||
assertTrue(wide.radius > 20f)
|
||||
val xs = (0 until CatanBoard.VERTEX_COUNT).map { tall.vertex(it).x }
|
||||
assertTrue("island should span most of the width", xs.max() - xs.min() > 360f * 0.7f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every edge is one hex radius long and every hex corner is one radius from its centre`() {
|
||||
val layout = CatanGeometry.fit(500f, 500f)
|
||||
for (e in 0 until CatanBoard.EDGE_COUNT) {
|
||||
val (a, b) = layout.edgeEnds(e)
|
||||
assertEquals("edge $e length", layout.radius, dist(a, b), layout.radius * 0.01f)
|
||||
}
|
||||
for (h in 0 until CatanBoard.HEX_COUNT) {
|
||||
val centre = layout.hexCenter(h)
|
||||
for (corner in layout.hexCorners(h)) {
|
||||
assertEquals("hex $h corner", layout.radius, dist(centre, corner), layout.radius * 0.01f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all fifty-four vertices sit at distinct places`() {
|
||||
val layout = CatanGeometry.fit(500f, 500f)
|
||||
val places = (0 until CatanBoard.VERTEX_COUNT).map {
|
||||
val p = layout.vertex(it)
|
||||
Math.round(p.x) to Math.round(p.y)
|
||||
}.toSet()
|
||||
assertEquals(CatanBoard.VERTEX_COUNT, places.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a tap on a vertex finds that vertex, and a tap far away finds nothing`() {
|
||||
val layout = CatanGeometry.fit(500f, 500f)
|
||||
val all = (0 until CatanBoard.VERTEX_COUNT).toList()
|
||||
for (v in all) {
|
||||
val p = layout.vertex(v)
|
||||
assertEquals(v, layout.nearestVertex(p, layout.radius * 0.5f, all))
|
||||
// A slightly clumsy tap still lands on it.
|
||||
assertEquals(v, layout.nearestVertex(Pt(p.x + layout.radius * 0.2f, p.y - layout.radius * 0.15f), layout.radius * 0.55f, all))
|
||||
}
|
||||
assertNull(layout.nearestVertex(Pt(-100f, -100f), layout.radius * 0.5f, all))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hit testing only considers the candidates it is given`() {
|
||||
val layout = CatanGeometry.fit(500f, 500f)
|
||||
val at = layout.vertex(5)
|
||||
assertNull(layout.nearestVertex(at, layout.radius * 0.3f, listOf(6)))
|
||||
assertEquals(6, layout.nearestVertex(layout.vertex(6), layout.radius * 0.3f, listOf(6)))
|
||||
assertEquals(3, layout.nearestEdge(layout.edgeMidpoint(3), 5f, listOf(3, 4)))
|
||||
assertEquals(7, layout.nearestHex(layout.hexCenter(7), 5f, listOf(7, 8)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the nearest candidate wins when several are in reach`() {
|
||||
val layout = CatanGeometry.fit(500f, 500f)
|
||||
val a = 10
|
||||
val b = CatanBoard.VERTEX_NEIGHBORS[a].first()
|
||||
val pa = layout.vertex(a)
|
||||
val pb = layout.vertex(b)
|
||||
// A point two-thirds of the way toward b is nearer b, though both are in reach.
|
||||
val at = Pt(pa.x + (pb.x - pa.x) * 0.66f, pa.y + (pb.y - pa.y) * 0.66f)
|
||||
assertEquals(b, layout.nearestVertex(at, layout.radius * 2f, listOf(a, b)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `harbour markers sit outside the coast`() {
|
||||
val layout = CatanGeometry.fit(500f, 500f)
|
||||
val centre = Pt(500f / 2f, 500f / 2f)
|
||||
for (e in CatanBoard.PORT_POSITIONS) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
package org.prolibertate.games.ui.game.catan
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.prolibertate.games.game.catan.BuildCity
|
||||
import org.prolibertate.games.game.catan.BuildRoad
|
||||
import org.prolibertate.games.game.catan.BuildSettlement
|
||||
import org.prolibertate.games.game.catan.BuildingType
|
||||
import org.prolibertate.games.game.catan.CatanBoard
|
||||
import org.prolibertate.games.game.catan.CatanPhase
|
||||
import org.prolibertate.games.game.catan.CatanRules
|
||||
import org.prolibertate.games.game.catan.DevCard
|
||||
import org.prolibertate.games.game.catan.DevCardType
|
||||
import org.prolibertate.games.game.catan.Discard
|
||||
import org.prolibertate.games.game.catan.MaritimeTrade
|
||||
import org.prolibertate.games.game.catan.MoveRobber
|
||||
import org.prolibertate.games.game.catan.PlayKnight
|
||||
import org.prolibertate.games.game.catan.PlayMonopoly
|
||||
import org.prolibertate.games.game.catan.PlayYearOfPlenty
|
||||
import org.prolibertate.games.game.catan.Resource
|
||||
import org.prolibertate.games.game.catan.SetupPlace
|
||||
import org.prolibertate.games.ui.game.catan.CatanFixtures.hand
|
||||
import org.prolibertate.games.ui.game.catan.CatanFixtures.playing
|
||||
import org.prolibertate.games.ui.game.catan.CatanFixtures.start
|
||||
import org.prolibertate.games.ui.game.catan.CatanFixtures.withBuilding
|
||||
import org.prolibertate.games.ui.game.catan.CatanFixtures.withHand
|
||||
import org.prolibertate.games.ui.game.catan.CatanFixtures.withRoad
|
||||
|
||||
class CatanInteractionTest {
|
||||
|
||||
private val rich = hand(
|
||||
Resource.LUMBER to 5, Resource.BRICK to 5, Resource.WOOL to 5,
|
||||
Resource.GRAIN to 5, Resource.ORE to 5,
|
||||
)
|
||||
|
||||
// --- Setup ---------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `setup first offers every settlement spot, then only the roads beside the chosen one`() {
|
||||
val state = start()
|
||||
val legal = CatanRules.legalMoves(state, 0)
|
||||
val setup = legal.filterIsInstance<SetupPlace>()
|
||||
|
||||
val first = CatanInteraction.targets(state, legal, null, null)
|
||||
assertEquals(setup.map { it.vertex }.toSet(), first.vertices)
|
||||
assertTrue(first.edges.isEmpty() && first.hexes.isEmpty())
|
||||
|
||||
val vertex = first.vertices.first()
|
||||
val second = CatanInteraction.targets(state, legal, null, vertex)
|
||||
assertTrue(second.vertices.isEmpty())
|
||||
assertEquals(setup.filter { it.vertex == vertex }.map { it.edge }.toSet(), second.edges)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a setup placement is two taps - the settlement, then its road`() {
|
||||
val state = start()
|
||||
val legal = CatanRules.legalMoves(state, 0)
|
||||
val vertex = CatanInteraction.targets(state, legal, null, null).vertices.first()
|
||||
val edge = CatanInteraction.targets(state, legal, null, vertex).edges.first()
|
||||
|
||||
assertEquals(TapOutcome.PickSetupVertex(vertex), CatanInteraction.onVertexTap(vertex, legal, null, null))
|
||||
assertEquals(TapOutcome.Submit(SetupPlace(vertex, edge)), CatanInteraction.onEdgeTap(edge, legal, null, vertex))
|
||||
// A road tapped before any settlement is chosen means nothing.
|
||||
assertEquals(TapOutcome.Ignored, CatanInteraction.onEdgeTap(edge, legal, null, null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a road that does not touch the chosen settlement is refused`() {
|
||||
val state = start()
|
||||
val legal = CatanRules.legalMoves(state, 0)
|
||||
val vertex = CatanInteraction.targets(state, legal, null, null).vertices.first()
|
||||
val beside = CatanBoard.VERTEX_EDGES[vertex].toSet()
|
||||
val far = (0 until CatanBoard.EDGE_COUNT).first { it !in beside }
|
||||
assertEquals(TapOutcome.Ignored, CatanInteraction.onEdgeTap(far, legal, null, vertex))
|
||||
}
|
||||
|
||||
// --- Building -------------------------------------------------------------
|
||||
|
||||
/** Seat 0 owns a settlement at vertex 0 and a road two edges long leading away from it. */
|
||||
private fun established(): Triple<org.prolibertate.games.game.catan.CatanState, Int, Int> {
|
||||
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 state = playing {
|
||||
withHand(0, rich).withBuilding(0, BuildingType.SETTLEMENT, 0).withRoad(e1, 0).withRoad(e2, 0)
|
||||
}
|
||||
return Triple(state, x, e2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nothing on the board is tappable until a build mode is chosen`() {
|
||||
val (state, _, _) = established()
|
||||
val legal = CatanRules.legalMoves(state, 0)
|
||||
assertTrue(CatanInteraction.targets(state, legal, null, null).isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build modes target exactly what the engine allows`() {
|
||||
val (state, x, _) = established()
|
||||
val legal = CatanRules.legalMoves(state, 0)
|
||||
|
||||
assertEquals(
|
||||
legal.filterIsInstance<BuildRoad>().map { it.edge }.toSet(),
|
||||
CatanInteraction.targets(state, legal, BuildKind.ROAD, null).edges,
|
||||
)
|
||||
assertTrue(x in CatanInteraction.targets(state, legal, BuildKind.SETTLEMENT, null).vertices)
|
||||
assertEquals(setOf(0), CatanInteraction.targets(state, legal, BuildKind.CITY, null).vertices)
|
||||
assertEquals(setOf(BuildKind.ROAD, BuildKind.SETTLEMENT, BuildKind.CITY), CatanInteraction.canBuild(legal))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a tap builds only when the matching mode is armed and the spot is legal`() {
|
||||
val (state, x, _) = established()
|
||||
val legal = CatanRules.legalMoves(state, 0)
|
||||
val road = legal.filterIsInstance<BuildRoad>().first().edge
|
||||
|
||||
assertEquals(TapOutcome.Submit(BuildSettlement(x)), CatanInteraction.onVertexTap(x, legal, BuildKind.SETTLEMENT, null))
|
||||
assertEquals(TapOutcome.Submit(BuildCity(0)), CatanInteraction.onVertexTap(0, legal, BuildKind.CITY, null))
|
||||
assertEquals(TapOutcome.Submit(BuildRoad(road)), CatanInteraction.onEdgeTap(road, legal, BuildKind.ROAD, null))
|
||||
|
||||
// No mode armed: nothing happens.
|
||||
assertEquals(TapOutcome.Ignored, CatanInteraction.onVertexTap(x, legal, null, null))
|
||||
assertEquals(TapOutcome.Ignored, CatanInteraction.onEdgeTap(road, legal, null, null))
|
||||
// A settlement on an occupied crossing is not offered.
|
||||
assertEquals(TapOutcome.Ignored, CatanInteraction.onVertexTap(0, legal, BuildKind.SETTLEMENT, null))
|
||||
// The wrong mode for the spot.
|
||||
assertEquals(TapOutcome.Ignored, CatanInteraction.onVertexTap(x, legal, BuildKind.CITY, null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `without the cards, nothing can be built`() {
|
||||
val (state, _, _) = established()
|
||||
val broke = state.withHand(0, hand())
|
||||
val legal = CatanRules.legalMoves(broke, 0)
|
||||
assertTrue(CatanInteraction.canBuild(legal).isEmpty())
|
||||
assertFalse(CatanInteraction.canBuyDevCard(legal))
|
||||
assertTrue(CatanInteraction.canEndTurn(legal))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `free roads from a road building card need no build mode`() {
|
||||
val (state, _, _) = established()
|
||||
val free = state.copy(phase = CatanPhase.FREE_ROADS, pendingFreeRoads = 2)
|
||||
val legal = CatanRules.legalMoves(free, 0)
|
||||
val targets = CatanInteraction.targets(free, legal, null, null)
|
||||
assertTrue(targets.edges.isNotEmpty())
|
||||
val edge = targets.edges.first()
|
||||
assertEquals(TapOutcome.Submit(BuildRoad(edge)), CatanInteraction.onEdgeTap(edge, legal, null, null))
|
||||
assertTrue(CatanInteraction.canEndFreeRoads(legal))
|
||||
}
|
||||
|
||||
// --- The robber ---------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `the robber may go to any hex but the one it is on`() {
|
||||
val state = playing { copy(phase = CatanPhase.ROBBER) }
|
||||
val legal = CatanRules.legalMoves(state, 0)
|
||||
val targets = CatanInteraction.targets(state, legal, null, null)
|
||||
assertEquals((0 until CatanBoard.HEX_COUNT).filter { it != state.robber }.toSet(), targets.hexes)
|
||||
|
||||
val hex = targets.hexes.first()
|
||||
// Nobody has built anything, so there is nobody to rob: the move goes straight through.
|
||||
assertEquals(
|
||||
TapOutcome.Submit(MoveRobber(CatanBoard.HEXES[hex], null)),
|
||||
CatanInteraction.onHexTap(hex, legal),
|
||||
)
|
||||
assertEquals(TapOutcome.Ignored, CatanInteraction.onHexTap(state.robber, legal))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a hex with two possible victims asks which`() {
|
||||
val base = playing { copy(phase = CatanPhase.ROBBER) }
|
||||
val hex = (0 until CatanBoard.HEX_COUNT).first { it != base.robber }
|
||||
val corners = CatanBoard.HEX_CORNERS[hex]
|
||||
val state = base
|
||||
.withBuilding(corners[0], BuildingType.SETTLEMENT, 1)
|
||||
.withBuilding(corners[3], BuildingType.SETTLEMENT, 2)
|
||||
.withHand(1, hand(Resource.ORE to 1))
|
||||
.withHand(2, hand(Resource.GRAIN to 1))
|
||||
val legal = CatanRules.legalMoves(state, 0)
|
||||
|
||||
val outcome = CatanInteraction.onHexTap(hex, legal)
|
||||
assertTrue(outcome is TapOutcome.PickRobberVictim)
|
||||
outcome as TapOutcome.PickRobberVictim
|
||||
assertEquals(CatanBoard.HEXES[hex], outcome.hex)
|
||||
assertEquals(setOf(1, 2), outcome.victims.toSet())
|
||||
}
|
||||
|
||||
// --- Discarding -----------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a seven costs half the hand, rounded down`() {
|
||||
assertEquals(4, CatanInteraction.discardQuota(9))
|
||||
assertEquals(4, CatanInteraction.discardQuota(8))
|
||||
assertEquals(3, CatanInteraction.discardQuota(7))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a discard is found only when it names exactly an allowed set of cards`() {
|
||||
val state = playing {
|
||||
copy(phase = CatanPhase.DISCARDING, discardSeat = 0)
|
||||
.withHand(0, hand(Resource.LUMBER to 3, Resource.BRICK to 3, Resource.WOOL to 3))
|
||||
}
|
||||
val legal = CatanRules.legalMoves(state, 0)
|
||||
assertTrue(legal.all { it is Discard })
|
||||
|
||||
val good = listOf(Resource.LUMBER, Resource.LUMBER, Resource.BRICK, Resource.WOOL)
|
||||
assertNotNull(CatanInteraction.discardMove(legal, good))
|
||||
// Order does not matter.
|
||||
assertNotNull(CatanInteraction.discardMove(legal, good.reversed()))
|
||||
// Too few, and more than are held.
|
||||
assertNull(CatanInteraction.discardMove(legal, good.take(3)))
|
||||
assertNull(CatanInteraction.discardMove(legal, List(4) { Resource.LUMBER }))
|
||||
}
|
||||
|
||||
// --- Trading ---------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `bank trades list what can be given and what it can buy`() {
|
||||
val state = playing { withHand(0, hand(Resource.GRAIN to 4)) }
|
||||
val legal = CatanRules.legalMoves(state, 0)
|
||||
assertTrue(legal.any { it is MaritimeTrade })
|
||||
|
||||
val trades = CatanInteraction.bankTrades(legal)
|
||||
assertEquals(setOf(Resource.GRAIN), trades.keys)
|
||||
assertEquals(Resource.entries.filter { it != Resource.GRAIN }, trades.getValue(Resource.GRAIN))
|
||||
assertEquals(4, CatanInteraction.bankRatio(state, 0, Resource.GRAIN))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an offer is allowed only to another player, for something different, from cards held`() {
|
||||
val state = playing { withHand(0, hand(Resource.LUMBER to 2)) }
|
||||
val lumber = listOf(Resource.LUMBER)
|
||||
val grain = listOf(Resource.GRAIN)
|
||||
|
||||
assertTrue(CatanInteraction.offerAllowed(state, 0, 1, lumber, grain))
|
||||
assertFalse("to oneself", CatanInteraction.offerAllowed(state, 0, 0, lumber, grain))
|
||||
assertFalse("nothing offered", CatanInteraction.offerAllowed(state, 0, 1, emptyList(), grain))
|
||||
assertFalse("nothing asked", CatanInteraction.offerAllowed(state, 0, 1, lumber, emptyList()))
|
||||
assertFalse("like for like", CatanInteraction.offerAllowed(state, 0, 1, lumber, lumber))
|
||||
assertFalse("more than held", CatanInteraction.offerAllowed(state, 0, 1, List(3) { Resource.LUMBER }, grain))
|
||||
assertFalse("not their turn", CatanInteraction.offerAllowed(state, 1, 0, lumber, grain))
|
||||
}
|
||||
|
||||
// --- Development cards -------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a development card is playable unless it was bought this turn or is a victory point`() {
|
||||
val state = playing {
|
||||
copy(
|
||||
devHands = listOf(
|
||||
listOf(
|
||||
DevCard(1, DevCardType.KNIGHT, boughtTurn = 0),
|
||||
DevCard(2, DevCardType.VICTORY_POINT, boughtTurn = 0),
|
||||
DevCard(3, DevCardType.KNIGHT, boughtTurn = 1),
|
||||
),
|
||||
emptyList(),
|
||||
emptyList(),
|
||||
),
|
||||
)
|
||||
}
|
||||
val legal = CatanRules.legalMoves(state, 0)
|
||||
val hand = CatanInteraction.devHand(state, 0, legal)
|
||||
|
||||
// Sorted by type then id: the two knights, then the victory point.
|
||||
assertEquals(listOf(1, 3, 2), hand.map { it.card.id })
|
||||
assertEquals(listOf(true, false, false), hand.map { it.playable })
|
||||
assertEquals(listOf<Any>(PlayKnight(1)), hand.first().plays)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `monopoly offers one play per resource`() {
|
||||
val plays = Resource.entries.map { PlayMonopoly(9, it) }
|
||||
val choices = CatanInteraction.monopolyChoices(plays)
|
||||
assertEquals(Resource.entries.toSet(), choices.keys)
|
||||
assertEquals(PlayMonopoly(9, Resource.ORE), choices.getValue(Resource.ORE))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `year of plenty narrows to the cards that still complete a take`() {
|
||||
val plays = listOf(
|
||||
PlayYearOfPlenty(4, listOf(Resource.LUMBER, Resource.BRICK)),
|
||||
PlayYearOfPlenty(4, listOf(Resource.LUMBER, Resource.LUMBER)),
|
||||
PlayYearOfPlenty(4, listOf(Resource.ORE)),
|
||||
)
|
||||
assertEquals(
|
||||
setOf(Resource.LUMBER, Resource.BRICK, Resource.ORE),
|
||||
CatanInteraction.plentyAvailable(plays, emptyList()),
|
||||
)
|
||||
assertEquals(
|
||||
setOf(Resource.LUMBER, Resource.BRICK),
|
||||
CatanInteraction.plentyAvailable(plays, listOf(Resource.LUMBER)),
|
||||
)
|
||||
assertEquals(setOf(Resource.LUMBER), CatanInteraction.plentyAvailable(plays, listOf(Resource.BRICK)))
|
||||
assertTrue(CatanInteraction.plentyAvailable(plays, listOf(Resource.LUMBER, Resource.LUMBER)).isEmpty())
|
||||
|
||||
assertEquals(plays[0], CatanInteraction.plentyMove(plays, listOf(Resource.BRICK, Resource.LUMBER)))
|
||||
assertEquals(plays[2], CatanInteraction.plentyMove(plays, listOf(Resource.ORE)))
|
||||
assertNull(CatanInteraction.plentyMove(plays, listOf(Resource.GRAIN)))
|
||||
}
|
||||
|
||||
// --- Trade replies ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `the addressee of an offer is given accept and decline`() {
|
||||
val base = playing {
|
||||
withHand(0, hand(Resource.LUMBER to 1)).withHand(1, hand(Resource.GRAIN to 1))
|
||||
}
|
||||
val offer = org.prolibertate.games.game.catan.TradeOffer(
|
||||
by = 0, to = 1, give = listOf(Resource.LUMBER), get = listOf(Resource.GRAIN),
|
||||
)
|
||||
val pending = base.copy(phase = CatanPhase.TRADE_PENDING, pendingTrade = offer)
|
||||
val legal = CatanRules.legalMoves(pending, 1)
|
||||
assertEquals(true to true, CatanInteraction.tradeResponses(legal))
|
||||
|
||||
// If the addressee no longer holds what was asked, only decline remains.
|
||||
val short = pending.withHand(1, hand())
|
||||
assertEquals(false to true, CatanInteraction.tradeResponses(CatanRules.legalMoves(short, 1)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package org.prolibertate.games.ui.game.catan
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.prolibertate.games.game.catan.CatanPhase
|
||||
import org.prolibertate.games.game.catan.Port
|
||||
import org.prolibertate.games.game.catan.Resource
|
||||
import org.prolibertate.games.ui.game.catan.CatanFixtures.hand
|
||||
import org.prolibertate.games.ui.game.catan.CatanFixtures.playing
|
||||
import org.prolibertate.games.ui.game.catan.CatanFixtures.start
|
||||
import org.prolibertate.games.ui.game.catan.CatanFixtures.withHand
|
||||
|
||||
class CatanTextTest {
|
||||
|
||||
private val names = listOf("Ann", "Bo", "Cy")
|
||||
|
||||
@Test
|
||||
fun `seats are named, with You for the local player and a number when a name is unknown`() {
|
||||
val seats = CatanSeats(me = 1, names = names)
|
||||
assertEquals("You", seats.label(1))
|
||||
assertEquals("Ann", seats.label(0))
|
||||
assertEquals("you", seats.lower(1))
|
||||
assertEquals("Seat 4", seats.label(3))
|
||||
assertEquals("Seat 1", CatanSeats(me = 1).label(0))
|
||||
assertEquals("Seat 1", CatanSeats(me = 1, names = listOf(" ")).label(0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setup prompts say what to place next`() {
|
||||
val state = start()
|
||||
val mine = CatanSeats(0, names)
|
||||
assertTrue(CatanText.prompt(state, mine, setupVertexChosen = false, building = null).contains("Place a settlement"))
|
||||
assertTrue(CatanText.prompt(state, mine, setupVertexChosen = true, building = null).contains("road"))
|
||||
assertTrue(CatanText.prompt(state, CatanSeats(1, names), false, null).startsWith("Ann is placing"))
|
||||
val second = state.copy(phase = CatanPhase.SETUP_SECOND, setupIndex = 3)
|
||||
assertTrue(CatanText.prompt(second, CatanSeats(2, names), false, null).contains("second settlement"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the turn prompts follow the phase`() {
|
||||
val roll = playing().copy(phase = CatanPhase.AWAIT_ROLL)
|
||||
assertTrue(CatanText.prompt(roll, CatanSeats(0, names), false, null).contains("Roll the dice"))
|
||||
assertEquals("Ann is rolling…", CatanText.prompt(roll, CatanSeats(1, names), false, null))
|
||||
|
||||
val turn = playing()
|
||||
assertTrue(CatanText.prompt(turn, CatanSeats(0, names), false, null).contains("Your turn"))
|
||||
assertEquals("Tap an edge to build a road.", CatanText.prompt(turn, CatanSeats(0, names), false, BuildKind.ROAD))
|
||||
assertEquals("Bo is playing…", CatanText.prompt(playing(turn = 1), CatanSeats(0, names), false, null))
|
||||
|
||||
val robber = playing().copy(phase = CatanPhase.ROBBER)
|
||||
assertTrue(CatanText.prompt(robber, CatanSeats(0, names), false, null).contains("robber"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the discard prompt names the quota`() {
|
||||
val state = playing {
|
||||
copy(phase = CatanPhase.DISCARDING, discardSeat = 0)
|
||||
.withHand(0, hand(Resource.LUMBER to 9))
|
||||
}
|
||||
assertTrue(CatanText.prompt(state, CatanSeats(0, names), false, null).contains("discard 4"))
|
||||
assertTrue(CatanText.prompt(state, CatanSeats(1, names), false, null).contains("Ann must discard half"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `road building counts down in the singular and the plural`() {
|
||||
val two = playing().copy(phase = CatanPhase.FREE_ROADS, pendingFreeRoads = 2)
|
||||
val one = two.copy(pendingFreeRoads = 1)
|
||||
assertTrue(CatanText.prompt(two, CatanSeats(0, names), false, null).endsWith("2 more roads."))
|
||||
assertTrue(CatanText.prompt(one, CatanSeats(0, names), false, null).endsWith("1 more road."))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the end of the game names the winner`() {
|
||||
val over = playing().copy(phase = CatanPhase.GAME_OVER, winner = 1)
|
||||
assertEquals("Bo won the game.", CatanText.prompt(over, CatanSeats(0, names), false, null))
|
||||
assertEquals("You won!", CatanText.winnerHeadline(over, CatanSeats(1, names)))
|
||||
assertEquals("Bo won", CatanText.winnerHeadline(over, CatanSeats(0, names)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cards read as a sorted tally`() {
|
||||
assertEquals(
|
||||
"2 Lumber + 1 Grain",
|
||||
CatanText.cards(listOf(Resource.GRAIN, Resource.LUMBER, Resource.LUMBER)),
|
||||
)
|
||||
assertEquals("nothing", CatanText.cards(emptyList()))
|
||||
assertEquals(
|
||||
"Ann offers 1 Lumber to you for 2 Ore",
|
||||
CatanText.tradeSummary("Ann", "you", listOf(Resource.LUMBER), listOf(Resource.ORE, Resource.ORE)),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `costs and harbours read as the rules state them`() {
|
||||
assertEquals("1 lumber + 1 brick", CatanText.ROAD_COST)
|
||||
assertEquals("2 grain + 3 ore", CatanText.CITY_COST)
|
||||
assertEquals("2:1 ore", CatanText.portName(Port(edge = 0, resource = Resource.ORE, ratio = 2)))
|
||||
assertEquals("3:1 any", CatanText.portName(Port(edge = 0, resource = null, ratio = 3)))
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,8 @@ Naming: the catalogue id, lowercase, e.g. `crazy8s` → `RULES-crazy8s.md`.
|
||||
|
||||
- Build, CI, releases and OTA are described in the CI section of the root
|
||||
[README](../README.md).
|
||||
- [UI-catan.md](UI-catan.md) — how the Katan screen is layered, and how to restyle,
|
||||
re-copy or re-lay-out it without touching the rules.
|
||||
- `licenses/` — third-party licences that must travel with the app
|
||||
(Uncial Antiqua under the SIL OFL; the app itself is GPL-3.0, see `/LICENSE`).
|
||||
- Repo-level agent notes live in [`CLAUDE.md`](../CLAUDE.md) at the root.
|
||||
|
||||
+4
-6
@@ -19,8 +19,7 @@ behaviour is pinned by `CatanBoardTest`, `CatanRulesTest` and `CatanAiTest`.
|
||||
- **Points to win — *toggle*.** Defaults to 10. `CatanOptions.pointsToWin`, at
|
||||
least 2. A lower target makes a test game short.
|
||||
|
||||
The setup screen that exposes these lands with the screen task; the engine reads
|
||||
them from the table configuration today.
|
||||
The setup screen exposes both (players: 3 or 4; game to 5, 8, 10 or 12 points).
|
||||
|
||||
## Board
|
||||
|
||||
@@ -291,10 +290,9 @@ timing rules; Longest Road and Largest Army worth five; victory at ten.
|
||||
- **No player-to-player trade over the wire beyond accept/decline.** The engine
|
||||
supports it; whether the UI surfaces it is the screen task's concern.
|
||||
- **No trading away a development card.** Not part of the base game.
|
||||
- **The setup screen is not part of this task.** `CatanOptions` (players, target
|
||||
points) is read from the table configuration; the screen that sets it, and the
|
||||
catalogue descriptor's `available` flag, land with the screen and verification
|
||||
tasks.
|
||||
- **The screen is built, not yet verified on two devices.** The Katan screen and
|
||||
setup options exist (see [UI-catan.md](UI-catan.md)); the catalogue entry is a
|
||||
dev-channel preview until two-device play has been verified.
|
||||
- **The AI does not look ahead.** It scores one move deep with a fixed resource
|
||||
valuation. It plays legally and plausibly; it is not a strong opponent, and no
|
||||
strength claim is made.
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
# Katan UI — how it is built, and how to change it
|
||||
|
||||
The Katan screen (`app/src/main/java/org/prolibertate/games/ui/game/catan/`) is
|
||||
built in layers so that **look, wording, layout and interaction can each be
|
||||
changed on their own**. If you are an agent asked to restyle, retheme,
|
||||
re-copy or re-lay-out Katan, start here and touch only the layer you were asked
|
||||
about.
|
||||
|
||||
The rules live elsewhere and are not part of the UI: `game/catan/` is a pure
|
||||
state machine (see [RULES-catan.md](RULES-catan.md)). **Nothing in `ui/game/catan/`
|
||||
decides what is legal.** The engine's legal-move list is the single source of
|
||||
truth, so the UI can only ever offer a move the rules would accept.
|
||||
|
||||
## The layers
|
||||
|
||||
| Layer | File | Owns | Change it to… |
|
||||
| --- | --- | --- | --- |
|
||||
| **Style** | `CatanStyle.kt` | Every colour, proportion and the light/dark palettes | Re-skin: new terrain colours, seat colours, piece sizes, token look |
|
||||
| **Copy** | `CatanText.kt` | Every sentence, button label and name shown | Reword, localise, rename resources |
|
||||
| **Geometry** | `CatanGeometry.kt` | Engine lattice → pixels, and pixel → nearest vertex/edge/hex | Change board scale, margin, harbour placement, tap reach (rarely) |
|
||||
| **Interaction** | `CatanInteraction.kt` | What is tappable, what a tap means, which buttons are live | Change *how* something is selected (never *whether* it is legal) |
|
||||
| **Board drawing** | `CatanBoardView.kt` | The `Canvas`: tiles, tokens, ports, robber, roads, pieces, highlights | Change how a piece is drawn (art, shapes, animation) |
|
||||
| **Panels** | `CatanPanels.kt` | Resource tiles, hand, player cards, dice, action bar, log | Restyle or rearrange widgets |
|
||||
| **Dialogs** | `CatanDialogs.kt` | Discard, robber victim, play card, trade, trade reply, game over | Change a dialog's flow or look |
|
||||
| **Screen** | `CatanScreen.kt` | Wiring to `MatchController`, portrait/landscape layout, which dialog shows | Change the layout or add a new element |
|
||||
| **Previews** | `CatanPreviews.kt` | Android Studio previews of real positions | Iterate on the look without a match |
|
||||
|
||||
Everything outside this folder that mentions Katan is one-line wiring:
|
||||
`PlayScreen.kt` (builds the `MatchController`), `GameSetupScreen.kt` (players and
|
||||
points-to-win options), `Catalog.kt` (the catalogue entry).
|
||||
|
||||
## Recipes
|
||||
|
||||
**Change colours or sizes.** Edit `CatanStyle.kt`. `LightStyle` is the reference
|
||||
palette; `DarkStyle` is derived from it. Metrics (`roadWidth`, `settlementSize`,
|
||||
`tokenRadius`, …) are fractions of the hex radius, so they scale with the screen.
|
||||
To try a whole new theme, provide it at the root:
|
||||
`CompositionLocalProvider(LocalCatanStyle provides myStyle) { … }`.
|
||||
|
||||
**Change wording or translate.** Edit `CatanText.kt`. Composables never build a
|
||||
sentence themselves. `prompt(...)` is the one-line "what is happening / what do I
|
||||
do" text; it is a pure function of the state, with tests in `CatanTextTest`.
|
||||
|
||||
**Redraw a piece** (settlement, road, robber, port, token). Edit the matching
|
||||
`draw…` function in `CatanBoardView.kt`. Positions come from `BoardLayout`, so you
|
||||
only decide the shape. Keep the hit target generous (see Accessibility).
|
||||
|
||||
**Rearrange the screen.** Edit `CatanScreen.kt`. It builds four independent
|
||||
pieces (`board`, `players`, `promptRow`, `handAndActions`) and arranges them
|
||||
differently in portrait and landscape. Moving or dropping a piece does not affect
|
||||
the others.
|
||||
|
||||
**Add a widget.** Make it a stateless composable in `CatanPanels.kt` (values in,
|
||||
lambdas out). Do not give it the `MatchController`.
|
||||
|
||||
**Change how a selection works** (for example a one-tap build instead of a
|
||||
build-mode toggle). Edit `CatanInteraction.kt` and its tests, then adjust the
|
||||
screen's UI state. The legal-move list stays the gate.
|
||||
|
||||
## Rules of the road
|
||||
|
||||
1. **Never put a rule in a composable.** If a button's enabled state or a tap's
|
||||
result depends on game logic, it goes in `CatanInteraction` (pure, tested) and
|
||||
is derived from the engine's `legal` list or `CatanRules`.
|
||||
2. **Never hard-code a colour or a string** outside `CatanStyle` / `CatanText`.
|
||||
3. **Stateless by default.** Panels and dialogs take values and report events. The
|
||||
only state they may own is the transient selection they are helping the player
|
||||
make (cards picked in a trade, for example).
|
||||
4. **Hidden information.** The local view comes from `CatanRules.viewFor`: other
|
||||
players' development cards and the deck are empty on a client. Do not show the
|
||||
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.
|
||||
|
||||
## Interaction model (what the player actually does)
|
||||
|
||||
| Phase | The player… |
|
||||
| --- | --- |
|
||||
| Setup | taps a highlighted crossing (settlement), then a highlighted edge beside it (road). *Cancel* re-picks the settlement |
|
||||
| Roll | presses **Roll dice** |
|
||||
| Seven | **Discard** dialog (forced); then taps a hex to move the robber; a victim dialog appears only if more than one player can be robbed |
|
||||
| Main turn | arms **Road / Settlement / City** and taps a highlighted spot; **Buy card**; **Play card**; **Trade**; **End turn** |
|
||||
| Road Building | taps highlighted edges, or **Done with roads** |
|
||||
| Offered a trade | **Accept / Decline** dialog (forced) |
|
||||
| Game over | result dialog |
|
||||
|
||||
Trade offers are free-form (two card multisets), so they are validated with
|
||||
`CatanRules.isLegalMove`, not looked up in the legal list.
|
||||
|
||||
## Testing
|
||||
|
||||
Gradle does not run in the agent sandbox; **CI is the compile and test check**
|
||||
(dispatch `ci.yml` on your branch). What is covered:
|
||||
|
||||
- `CatanGeometryTest` — the island fits any box, edges and corners are one radius,
|
||||
hit-testing finds the right vertex/edge/hex.
|
||||
- `CatanInteractionTest` — targets and tap outcomes for setup, building, free
|
||||
roads, the robber (including the victim choice), discards, bank trades, offers,
|
||||
development cards and trade replies, all against real engine states.
|
||||
- `CatanTextTest` — the prompts and tallies.
|
||||
|
||||
Drawing and dialogs are not unit-tested; use the previews in `CatanPreviews.kt`
|
||||
and play a game on a dev build.
|
||||
|
||||
## Accessibility notes
|
||||
|
||||
- Tap targets: vertices and edges accept a tap within `tapReach` (0.7) hex radii,
|
||||
about 23 dp on a phone. If you shrink the board, keep that physical size.
|
||||
- Seat colours are blue, orange, white and purple, each outlined in near-black so
|
||||
they read on any terrain. Colour is the only piece identity today; adding a
|
||||
shape or letter per seat would help colour-blind players (a good first task).
|
||||
- The board has a content description but no per-hex semantics; a screen-reader
|
||||
mode would need one.
|
||||
|
||||
## Status and known gaps
|
||||
|
||||
The screen is **playable and feature-complete for the engine's rules**, but is
|
||||
**unverified on two real devices**, so the catalogue entry is `devPreview`
|
||||
(openable on a dev build, hidden from production). To release it: verify
|
||||
two-device play, then in `Catalog.kt` set `available = true` and remove
|
||||
`devPreview = true`, and update `CatalogTest`.
|
||||
|
||||
Not done yet, in rough priority order:
|
||||
|
||||
1. Two-device verification (host + guest over the LAN) and a full game on a phone
|
||||
and a tablet.
|
||||
2. Feedback on events: dice-roll animation, a highlight on hexes that produced,
|
||||
resource-gain flourishes, haptics. The state carries the dice and a text log;
|
||||
it does not say which hexes paid out, so that needs either a diff of two states
|
||||
or an engine addition.
|
||||
3. Art: terrain textures, resource and harbour icons, a real robber pawn.
|
||||
4. Open trade to the whole table and counter-offers (an engine limitation too).
|
||||
5. A board that shows the *last* move (a faint highlight of what just changed).
|
||||
6. Landscape polish on large tablets; the right-hand panel scrolls but is plain.
|
||||
Reference in New Issue
Block a user