diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanBoardView.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanBoardView.kt index c4cfbfa..69df57b 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanBoardView.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanBoardView.kt @@ -11,6 +11,7 @@ import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.StrokeCap @@ -30,6 +31,7 @@ import org.prolibertate.games.game.catan.BuildingType import org.prolibertate.games.game.catan.CatanBoard import org.prolibertate.games.game.catan.CatanState import org.prolibertate.games.game.catan.NO_PLAYER +import org.prolibertate.games.game.catan.Resource /** * The island, drawn and tappable. @@ -54,6 +56,8 @@ fun CatanBoardView( onHexTap: (Int) -> Unit, modifier: Modifier = Modifier, style: CatanStyle = LocalCatanStyle.current, + /** Hexes to light up as having produced on the latest roll. */ + rolledHexes: Set = emptySet(), ) { val measurer = rememberTextMeasurer() var size by remember { mutableStateOf(IntSize.Zero) } @@ -99,6 +103,7 @@ fun CatanBoardView( drawRect(color = style.sea) drawPorts(state, board, style, measurer) drawHexes(state, board, style, measurer) + drawRolled(rolledHexes, board, style) drawRobber(state, board, style) drawRoads(state, board, style) drawEdgeTargets(targets, board, style) @@ -130,6 +135,17 @@ private fun DrawScope.drawHexes( } } +/** A bright wash and rim on each hex that paid out, drawn over the terrain and under the pieces. */ +private fun DrawScope.drawRolled(hexes: Set, board: BoardLayout, style: CatanStyle) { + for (hex in hexes) { + val centre = board.hexCenter(hex) + val path = polygon(board.hexCorners(hex).map { insetToward(it, centre, style.hexInset + 0.04f) }) + drawPath(path, color = style.rolled.copy(alpha = 0.28f)) + drawPath(path, color = style.pieceOutline, style = Stroke(width = board.radius * 0.13f)) + drawPath(path, color = style.rolled, style = Stroke(width = board.radius * 0.075f)) + } +} + private fun DrawScope.drawToken( number: Int, at: Pt, @@ -206,7 +222,7 @@ private fun DrawScope.drawPorts( val anchor = board.portAnchor(port.edge) val (a, b) = board.edgeEnds(port.edge) val tint = port.resource?.let { style.resource.getValue(it) } ?: style.portInk - // Two short piers from the marker to the vertices that can use the harbour. + // Two piers from the marker to the vertices that can use the harbour. for (end in listOf(a, b)) { drawLine( color = style.portMarker.copy(alpha = 0.9f), @@ -224,10 +240,26 @@ private fun DrawScope.drawPorts( center = anchor.offset(), style = Stroke(width = board.radius * 0.07f), ) + // What the harbour trades: a picture of the resource, or "?" for any. + val iconAt = Pt(anchor.x, anchor.y - radius * 0.27f) + val iconSize = radius * 0.5f + val resource = port.resource + if (resource != null) { + drawResourceIcon(resource, iconAt, iconSize, tint, style.pieceOutline) + } else { + drawCentered( + measurer = measurer, + text = "?", + at = iconAt, + fontPx = board.radius * style.portText * 1.5f, + color = style.portInk, + weight = FontWeight.ExtraBold, + ) + } drawCentered( measurer = measurer, text = "${port.ratio}:1", - at = anchor, + at = Pt(anchor.x, anchor.y + radius * 0.5f), fontPx = board.radius * style.portText, color = style.portInk, weight = FontWeight.Bold, @@ -235,6 +267,81 @@ private fun DrawScope.drawPorts( } } +/** + * A small pictogram of [resource] centred on [at], about 2ยท[s] across: a tree, + * bricks, a sheep, an ear of wheat or a rock. Drawn with paths so there are no + * image assets to ship or scale. + */ +private fun DrawScope.drawResourceIcon(resource: Resource, at: Pt, s: Float, fill: Color, line: Color) { + val outline = Stroke(width = s * 0.12f) + when (resource) { + Resource.LUMBER -> { + val crown = Path().apply { + moveTo(at.x, at.y - s) + lineTo(at.x + s * 0.8f, at.y + s * 0.3f) + lineTo(at.x - s * 0.8f, at.y + s * 0.3f) + close() + } + drawRect(color = line, topLeft = Offset(at.x - s * 0.14f, at.y + s * 0.3f), size = Size(s * 0.28f, s * 0.6f)) + drawPath(crown, color = fill) + drawPath(crown, color = line, style = outline) + } + + Resource.BRICK -> { + val w = s * 0.9f + val h = s * 0.42f + for ((row, shift) in listOf(0f to 0f, 1f to w * 0.5f)) { + val top = at.y - h + row * h + val left = at.x - w + shift - w * 0.25f + for (col in 0..1) { + val x = left + col * w + drawRect(color = fill, topLeft = Offset(x, top), size = Size(w, h)) + drawRect(color = line, topLeft = Offset(x, top), size = Size(w, h), style = outline) + } + } + } + + Resource.WOOL -> { + for ((dx, dy) in listOf(-0.45f to 0f, 0.1f to -0.25f, 0.5f to 0.05f, 0f to 0.2f)) { + drawCircle(color = fill, radius = s * 0.42f, center = Offset(at.x + dx * s, at.y + dy * s)) + } + drawCircle(color = line, radius = s * 0.24f, center = Offset(at.x - s * 0.85f, at.y + s * 0.15f)) + drawRect(color = line, topLeft = Offset(at.x - s * 0.5f, at.y + s * 0.5f), size = Size(s * 0.14f, s * 0.45f)) + drawRect(color = line, topLeft = Offset(at.x + s * 0.3f, at.y + s * 0.5f), size = Size(s * 0.14f, s * 0.45f)) + } + + Resource.GRAIN -> { + drawLine(color = line, start = Offset(at.x, at.y + s), end = Offset(at.x, at.y - s * 0.7f), strokeWidth = s * 0.14f) + for (i in 0..2) { + val y = at.y - s * 0.7f + i * s * 0.5f + for (side in listOf(-1f, 1f)) { + val kernel = Path().apply { + moveTo(at.x, y + s * 0.3f) + lineTo(at.x + side * s * 0.6f, y - s * 0.05f) + lineTo(at.x + side * s * 0.15f, y - s * 0.2f) + close() + } + drawPath(kernel, color = fill) + drawPath(kernel, color = line, style = Stroke(width = s * 0.08f)) + } + } + } + + Resource.ORE -> { + val peak = Path().apply { + moveTo(at.x - s, at.y + s * 0.8f) + lineTo(at.x - s * 0.3f, at.y - s * 0.6f) + lineTo(at.x + s * 0.1f, at.y + s * 0.05f) + lineTo(at.x + s * 0.45f, at.y - s * 0.3f) + lineTo(at.x + s, at.y + s * 0.8f) + close() + } + drawPath(peak, color = fill) + drawPath(peak, color = line, style = outline) + } + } +} + // --------------------------------------------------------------------------- // Pieces // --------------------------------------------------------------------------- diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanGeometry.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanGeometry.kt index 3c33f01..433c456 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanGeometry.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanGeometry.kt @@ -102,7 +102,7 @@ object CatanGeometry { private const val LATTICE_Y_UNITS = 16 /** Empty space round the island, in hex radii, for the sea and the harbours. */ - const val MARGIN = 1.15f + const val MARGIN = 1.75f private val boardWidthInRadii = LATTICE_X_UNITS * SQRT3 / 2f + 2 * MARGIN private val boardHeightInRadii = LATTICE_Y_UNITS / 2f + 2 * MARGIN @@ -119,5 +119,5 @@ object CatanGeometry { private const val SQRT3 = 1.7320508f -/** How far past the coast a harbour marker sits, in hex radii. */ -private const val PORT_OFFSET = 0.62f +/** How far past the coast a harbour marker sits, in hex radii. Far enough that its piers are clearly visible. */ +internal const val PORT_OFFSET = 1.0f diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanInteraction.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanInteraction.kt index c7481d0..9a9ca7d 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanInteraction.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanInteraction.kt @@ -171,6 +171,25 @@ object CatanInteraction { fun canRoll(legal: List): Boolean = legal.any { it is RollDice } + /** + * The hexes that paid out for this turn's roll, so the board can light them. + * Empty until the turn's roll has happened (the dice left over from the last + * turn are not this turn's), on a seven, and for the hex the robber sits on. + */ + fun rolledHexes(state: CatanState): Set { + if (state.lastDice.size != 2) return emptySet() + if (state.phase == CatanPhase.AWAIT_ROLL || state.phase == CatanPhase.SETUP_FIRST || + state.phase == CatanPhase.SETUP_SECOND + ) { + return emptySet() + } + val total = state.lastDice.sum() + if (total == 7) return emptySet() + return (0 until CatanBoard.HEX_COUNT) + .filter { state.numbers[it] == total && it != state.robber } + .toSet() + } + fun canEndTurn(legal: List): Boolean = legal.any { it is EndTurn } fun canEndFreeRoads(legal: List): Boolean = legal.any { it is EndFreeRoads } diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanPanels.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanPanels.kt index eaf93f9..30317cd 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanPanels.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanPanels.kt @@ -19,11 +19,17 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -297,6 +303,8 @@ fun CatanActionBar( onEndFreeRoads: () -> Unit, onRepickSetup: () -> Unit, modifier: Modifier = Modifier, + /** Small screens: fold Road / Settlement / City into one "Build" button with a menu. */ + compact: Boolean = false, ) { Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { Row( @@ -315,9 +323,13 @@ fun CatanActionBar( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - 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) + if (compact) { + BuildMenuButton(actions, onBuild) + } else { + BuildButton(BuildKind.ROAD, CatanText.ROAD, CatanText.ROAD_COST, actions, onBuild) + BuildButton(BuildKind.SETTLEMENT, CatanText.SETTLEMENT, CatanText.SETTLEMENT_COST, actions, onBuild) + BuildButton(BuildKind.CITY, CatanText.CITY, CatanText.CITY_COST, actions, onBuild) + } OutlinedButton(onClick = onBuyCard, enabled = actions.canBuyCard) { LabelWithCost(CatanText.BUY_CARD, CatanText.DEV_COST) } @@ -352,6 +364,52 @@ private fun BuildButton( } } +/** + * The small-screen stand-in for the three build buttons: one "Build" button + * whose menu lists Road, Settlement and City with their costs. While a build + * mode is armed the button shows which, and choosing that entry again cancels it. + */ +@Composable +private fun BuildMenuButton(actions: CatanActions, onBuild: (BuildKind?) -> Unit) { + var open by remember { mutableStateOf(false) } + val armed = actions.building + val anyEnabled = actions.canBuild.isNotEmpty() || armed != null + val label: @Composable () -> Unit = { + LabelWithCost(CatanText.BUILD, armed?.let { buildLabel(it) } ?: CatanText.NO_COST) + } + Box { + if (armed != null) { + FilledTonalButton(onClick = { open = true }, enabled = anyEnabled) { label() } + } else { + OutlinedButton(onClick = { open = true }, enabled = anyEnabled) { label() } + } + DropdownMenu(expanded = open, onDismissRequest = { open = false }) { + for (kind in BuildKind.entries) { + DropdownMenuItem( + text = { LabelWithCost(buildLabel(kind), buildCost(kind)) }, + enabled = kind in actions.canBuild, + onClick = { + open = false + onBuild(if (armed == kind) null else kind) + }, + ) + } + } + } +} + +private fun buildLabel(kind: BuildKind): String = when (kind) { + BuildKind.ROAD -> CatanText.ROAD + BuildKind.SETTLEMENT -> CatanText.SETTLEMENT + BuildKind.CITY -> CatanText.CITY +} + +private fun buildCost(kind: BuildKind): String = when (kind) { + BuildKind.ROAD -> CatanText.ROAD_COST + BuildKind.SETTLEMENT -> CatanText.SETTLEMENT_COST + BuildKind.CITY -> CatanText.CITY_COST +} + @Composable private fun LabelWithCost(label: String, cost: String) { Column(horizontalAlignment = Alignment.CenterHorizontally) { diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanScreen.kt index 3e97876..9818719 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanScreen.kt @@ -141,6 +141,7 @@ fun CatanScreen( canEndFreeRoads = CatanInteraction.canEndFreeRoads(legal), canRepickSetup = setupVertex != null, ) + val rolledHexes = CatanInteraction.rolledHexes(current) val prompt = CatanText.prompt(current, seats, setupVertexChosen = setupVertex != null, building = building) val handle: (TapOutcome) -> Unit = { outcome -> @@ -161,6 +162,8 @@ fun CatanScreen( ScreenScaffold(title = CatanText.TITLE, onBack = onExit, actions = endGameAction) { modifier -> BoxWithConstraints(modifier = modifier) { val wide = maxWidth > maxHeight * 1.1f + // Phones (either way up) get the one-button build menu. + val compactBuild = !wide || maxWidth < 600.dp val board: @Composable (Modifier) -> Unit = { boardModifier -> CatanBoardView( @@ -172,6 +175,7 @@ fun CatanScreen( onHexTap = { handle(CatanInteraction.onHexTap(it, legal)) }, modifier = boardModifier, style = style, + rolledHexes = rolledHexes, ) } val players: @Composable (Boolean) -> Unit = { grid -> @@ -195,6 +199,7 @@ fun CatanScreen( legal.firstOrNull { m -> m is EndFreeRoads }?.let(submit) }, onRepickSetup = { setupVertex = null }, + compact = compactBuild, ) } } diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanStyle.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanStyle.kt index d7c96e8..256065e 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanStyle.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanStyle.kt @@ -36,6 +36,8 @@ data class CatanStyle( val tokenHotInk: Color, /** The robber's pawn. */ val robber: Color, + /** Outline and wash on hexes that produced for the latest dice roll. */ + val rolled: Color, /** Harbour marker disc and its ink. */ val portMarker: Color, val portInk: Color, @@ -64,12 +66,12 @@ data class CatanStyle( val citySize: Float = 0.42f, val tokenRadius: Float = 0.36f, val robberSize: Float = 0.34f, - val portRadius: Float = 0.34f, + val portRadius: Float = 0.46f, val targetRadius: Float = 0.20f, /** How close a tap must land to a vertex / edge / hex centre to count. */ val tapReach: Float = 0.55f, val tokenText: Float = 0.34f, - val portText: Float = 0.26f, + val portText: Float = 0.27f, ) /** The light palette, which is also the reference for the dark one. */ @@ -88,6 +90,7 @@ private val LightStyle = CatanStyle( tokenInk = Color(0xFF2B2B2B), tokenHotInk = Color(0xFFC62828), robber = Color(0xFF263238), + rolled = Color(0xFFFFFFFF), portMarker = Color(0xFFFFF8E1), portInk = Color(0xFF2B2B2B), resource = mapOf( diff --git a/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanGeometryTest.kt b/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanGeometryTest.kt index 69362b2..5981061 100644 --- a/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanGeometryTest.kt +++ b/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanGeometryTest.kt @@ -111,7 +111,7 @@ class CatanGeometryTest { val anchor = layout.portAnchor(e) val mid = layout.edgeMidpoint(e) assertTrue("harbour on edge $e should be past the coast", dist(anchor, centre) > dist(mid, centre)) - assertTrue("and not floating far from it", abs(dist(anchor, mid) - layout.radius * 0.62f) < 1f) + assertTrue("and not floating far from it", abs(dist(anchor, mid) - layout.radius * PORT_OFFSET) < 1f) } } } diff --git a/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanInteractionTest.kt b/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanInteractionTest.kt index 61fb44e..ce3eb10 100644 --- a/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanInteractionTest.kt +++ b/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanInteractionTest.kt @@ -322,4 +322,27 @@ class CatanInteractionTest { val short = pending.withHand(1, hand()) assertEquals(false to true, CatanInteraction.tradeResponses(CatanRules.legalMoves(short, 1))) } + + // --- Rolled-hex highlight -------------------------------------------------- + + @Test + fun `rolled hexes are those carrying the number, minus the robber's`() { + val base = playing(turn = 0) + val number = base.numbers.first { it != 0 && it != 7 } + val state = base.copy(lastDice = if (number <= 7) listOf(number - 1, 1) else listOf(6, number - 6)) + val all = (0 until CatanBoard.HEX_COUNT).filter { state.numbers[it] == number } + assertTrue(all.isNotEmpty()) + assertEquals(all.filter { it != state.robber }.toSet(), CatanInteraction.rolledHexes(state)) + val blocked = state.copy(robber = all.first()) + assertFalse(all.first() in CatanInteraction.rolledHexes(blocked)) + } + + @Test + fun `nothing is highlighted before this turn's roll, on a seven, or in setup`() { + val rolled = playing(turn = 0).copy(lastDice = listOf(3, 3)) + assertTrue(CatanInteraction.rolledHexes(rolled.copy(phase = CatanPhase.AWAIT_ROLL)).isEmpty()) + assertTrue(CatanInteraction.rolledHexes(rolled.copy(lastDice = listOf(3, 4))).isEmpty()) + assertTrue(CatanInteraction.rolledHexes(rolled.copy(phase = CatanPhase.SETUP_FIRST)).isEmpty()) + assertTrue(CatanInteraction.rolledHexes(rolled.copy(lastDice = emptyList())).isEmpty()) + } }