Author SHA1 Message Date
adrianoftyrielandPaperclip 81feeee132 Catan Compose UI: board, placement flow, personal panel, event feed
- CatanScreen.kt: Canvas hex board using CatanBoard geometry (hexCenter, VERTEX_POS, EDGE_VERTICES)
- Tap-to-target + commit pattern with detectTapGestures (Ta Yü precedent)
- BoardGeometry for vertex/edge hit detection
- drawTerrains, drawBuildings, drawRoads, drawNumbers, drawPorts, drawRobber, drawDice
- Legal-target highlighting for settlement/road/city/SetupPlace
- Personal panel: resources, dev cards, VP, longest-road/largest-army banners
- Commit/End-turn/Roll buttons with 48dp targets
- CatanEventFeed: shows last 4 log entries (seat-redacted via CatanRules.viewFor)
- Layout: Column with Modifier.weight(1.6f) board region — rotation-safe, measures space
- PlayScreen.kt: CATAN case wired with CatanRules + CatanAi
- GameCatalog: CATAN entry (available=false, menus=Board) — dev builds show it

Co-Authored-By: Paperclip <[email protected]>
2026-10-14 22:03:13 +00:00
adrianoftyrielandPaperclip 1f7fced24c WALA-120: Catan Compose screen skeleton + PlayScreen wiring (CatanScreen.kt, PlayScreen CATAN case)
- CatanScreen consumes only game/catan/ engine API (CatanState, CatanMove, CatanRules, CatanAi).
- Board renders to remaining space using Canvas (measure-based, no fixed dp, rotation-safe).
- Personal panel shows resources (counts), dev-card counts, VP context, roll/place buttons
  with targets ≥48dp or tap+commit; never reads hidden info — enforced by CatanRules.viewFor.
- Event feed reads current.log (public event feed).
- Registered in PlayScreen with CatanRules + CatanAi controller.
- Catalog entry CATAN remains available=false for integration task to flip.

Co-Authored-By: Paperclip <[email protected]>
2026-10-14 17:25:00 +00:00
2 changed files with 619 additions and 0 deletions
@@ -17,6 +17,10 @@ import org.prolibertate.games.game.chess.ChessAi
import org.prolibertate.games.game.chess.ChessMove import org.prolibertate.games.game.chess.ChessMove
import org.prolibertate.games.game.chess.ChessRules import org.prolibertate.games.game.chess.ChessRules
import org.prolibertate.games.game.chess.ChessState import org.prolibertate.games.game.chess.ChessState
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.crazyeights.CrazyEightsAi import org.prolibertate.games.game.crazyeights.CrazyEightsAi
import org.prolibertate.games.game.crazyeights.CrazyEightsMove import org.prolibertate.games.game.crazyeights.CrazyEightsMove
import org.prolibertate.games.game.crazyeights.CrazyEightsPhase import org.prolibertate.games.game.crazyeights.CrazyEightsPhase
@@ -112,6 +116,7 @@ import org.prolibertate.games.settings.Settings
import org.prolibertate.games.ui.game.BackgammonScreen import org.prolibertate.games.ui.game.BackgammonScreen
import org.prolibertate.games.ui.game.CheckersScreen import org.prolibertate.games.ui.game.CheckersScreen
import org.prolibertate.games.ui.game.ChessScreen import org.prolibertate.games.ui.game.ChessScreen
import org.prolibertate.games.ui.game.CatanScreen
import org.prolibertate.games.ui.game.CrazyEightsScreen import org.prolibertate.games.ui.game.CrazyEightsScreen
import org.prolibertate.games.ui.game.CribbageScreen import org.prolibertate.games.ui.game.CribbageScreen
import org.prolibertate.games.ui.game.HeartsScreen import org.prolibertate.games.ui.game.HeartsScreen
@@ -668,6 +673,23 @@ fun PlayScreen(
ChessScreen(controller = controller, localSeat = route.localSeat, onExit = onExit) 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,
aiThinkingMillis = { settings.scaled(700L) },
)
}
StartMatch(env, controller, route)
CatanScreen(controller = controller, localSeat = route.localSeat, onExit = onExit)
}
else -> Text("That game isn't playable yet.") else -> Text("That game isn't playable yet.")
} }
} }
@@ -0,0 +1,597 @@
package org.prolibertate.games.ui.game
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
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.PaddingSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.saveable.rememberSaveable
import androidx.compose.ui.Alignment
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.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
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.CatanAi
import org.prolibertate.games.game.catan.CatanBoard
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.CatanPhase
import org.prolibertate.games.game.catan.DevCardType
import org.prolibertate.games.game.catan.Resource
import org.prolibertate.games.game.catan.RollDice
import org.prolibertate.games.game.catan.EndTurn
import org.prolibertate.games.game.catan.BuildingType
import org.prolibertate.games.game.catan.HexCoord
import org.prolibertate.games.game.catan.NO_PLAYER
import org.prolibertate.games.net.MatchController
import org.prolibertate.games.ui.ScreenScaffold
private const val TITLE = "Katan"
// Board paint colours, kept local
private val TileForest = Color(0xFF6B8E6B)
private val TilePasture = Color(0xFFB8D48A)
private val TileFields = Color(0xFFEBCB84)
private val TileHills = Color(0xFFCD853F)
private val TileMountains = Color(0xFF878787)
private val TileDesert = Color(0xFFE5D6A8)
private val TileBorder = Color(0xFF6E6350)
private val TokenRed = Color(0xFFFFEB3B)
private val TokenWhite = Color(0xFFFFFFFF)
private val PlayerColors = listOf(Color(0xFF6B4226), Color(0xFFC71585), Color(0xFF006400), Color(0xFF1974D2))
private val HintColor = Color(0x7F1976D2)
/**
* Catan screen — consumes only the game/catan/ engine API.
* Layout measures the space it is given: board above, personal panel + feed below.
* Tap-to-target + commit pattern for settlement/road/city placement (upstream's Ta Yü
* precedent), with targets ≥48dp on the commit buttons.
* Hidden information is never read — the UI consumes the seat-redacted view produced
* by CatanRules.viewFor, which masks devHands/devDeck for non-local seats.
*/
@Composable
fun CatanScreen(
controller: MatchController<CatanState, CatanMove>,
localSeat: Int,
onExit: () -> Unit,
) {
val state by controller.state.collectAsState()
val legal by controller.legalMoves.collectAsState()
val abandoned by controller.abandoned.collectAsState()
// Placement flow state: which vertex/edge the player has tapped, and the
// legal move currently lined up on it. Tapping again cycles to the next
// legal placement that covers the same spot; commit submits it.
var tappedVertex by remember { mutableStateOf<Int?>(null) }
var tappedEdge by remember { mutableStateOf<Int?>(null) }
var linedMove by remember { mutableStateOf<CatanMove?>(null) }
val current = state
if (current == null) {
ScreenScaffold(title = TITLE, onBack = onExit) { modifier ->
Box(modifier, contentAlignment = Alignment.Center) { Text("Setting up…") }
}
return
}
// Reset lined placement when the legal set changes (a move landed, phase advanced).
LaunchedEffect(legal, current.phase) {
tappedVertex = null
tappedEdge = null
linedMove = null
}
val yours = current.turn == localSeat && current.phase != CatanPhase.GAME_OVER
ScreenScaffold(title = TITLE, onBack = onExit) { modifier ->
// Board above, personal panel + feed below — board sized to remaining space.
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(modifier = Modifier.weight(1.6f).fillMaxWidth()) {
CatanBoardView(
state = current,
legal = legal,
linedMove = linedMove,
tappedVertex = tappedVertex,
tappedEdge = tappedEdge,
onVertexTapped = { v ->
tappedVertex = v
tappedEdge = null
linedMove = pickMoveAtVertex(legal, v, linedMove, legal)
},
onEdgeTapped = { e ->
tappedEdge = e
tappedVertex = null
linedMove = pickMoveAtEdge(legal, e, linedMove, legal)
},
localSeat = localSeat,
)
}
// Commit bar — always ≥48dp targets
if (yours && linedMove.value != null) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Button(
onClick = { controller.submit(linedMove.value!!) },
modifier = Modifier.weight(1f).height(48.dp),
) { Text("Place") }
Button(
onClick = { linedMove = null; tappedVertex = null; tappedEdge = null },
modifier = Modifier.weight(1f).height(48.dp),
) { Text("Cancel") }
}
}
// Personal panel + turn actions
CatanPersonalPanel(
state = current,
localSeat = localSeat,
legal = legal,
onRoll = { controller.submit(RollDice()) },
onEndTurn = { controller.submit(EndTurn()) },
)
CatanEventFeed(log = current.log)
}
abandoned?.let { /* PlayScreen pattern: table abandoned dialog */ }
}
}
/** Pick the next legal move on [target], cycling from the currently lined one. */
private fun pickMoveAtVertex(
legal: List<CatanMove>,
target: Int,
current: CatanMove?,
allLegal: List<CatanMove>,
): CatanMove? {
val matching = allLegal.filter { m ->
when (m) {
is BuildSettlement -> m.vertex == target
is BuildCity -> m.vertex == target
is org.prolibertate.games.game.catan.SetupPlace -> m.vertex == target
else -> false
}
}
if (matching.isEmpty()) return current
val pos = matching.indexOf(current)
return if (pos >= 0 && matching.size > 1) {
matching[(pos + 1) % matching.size]
} else {
matching.first()
}
}
private fun pickMoveAtEdge(
legal: List<CatanMove>,
target: Int,
current: CatanMove?,
allLegal: List<CatanMove>,
): CatanMove? {
val matching = allLegal.filter { m ->
when (m) {
is BuildRoad -> m.edge == target
is org.prolibertate.games.game.catan.SetupPlace -> m.edge == target
else -> false
}
}
if (matching.isEmpty()) return current
val pos = matching.indexOf(current)
return if (pos >= 0 && matching.size > 1) {
matching[(pos + 1) % matching.size]
} else {
matching.first()
}
}
// ---------------------------------------------------------------------------
// Board
// ---------------------------------------------------------------------------
/** Geometry for a board that fills its canvas. */
private class BoardGeometry(val halfW: Float, val halfH: Float, val scale: Float) {
fun hexCenterPx(h: Int): Offset {
val (lx, ly) = CatanBoard.hexCenter(h)
// Canvas Y grows downward; lattice +Y is toward top of board → flip.
return Offset(halfW + lx * scale, halfH - ly * scale)
}
fun vertexPx(v: Int): Offset {
val (lx, ly) = CatanBoard.VERTEX_POS[v]
return Offset(halfW + lx * scale / 2f, halfH - ly * scale / 2f)
}
fun edgeMidPx(e: Int): Offset {
val (a, b) = CatanBoard.EDGE_VERTICES[e]
val pa = vertexPx(a)
val pb = vertexPx(b)
return Offset((pa.x + pb.x) / 2f, (pa.y + pb.y) / 2f)
}
}
@Composable
private fun CatanBoardView(
state: CatanState,
legal: List<CatanMove>,
linedMove: androidx.compose.runtime.MutableState<CatanMove?>,
tappedVertex: Int?,
tappedEdge: Int?,
onVertexTapped: (Int) -> Unit,
onEdgeTapped: (Int) -> Unit,
localSeat: Int,
) {
val geometry = remember { BoardGeometry(0f, 0f, 0f) }
Canvas(
modifier = Modifier
.fillMaxSize()
.background(Color(0xFFE8DCC8))
.pointerInput(Unit) {
detectTapGestures { offset ->
// Find tapped vertex or edge via geometry
val g = geometry
if (g.scale == 0f) return@detectTapGestures
for (v in 0 until CatanBoard.VERTEX_COUNT) {
val pos = g.vertexPx(v)
if (Offset(offset.x - pos.x, offset.y - pos.y).getOrElse { 0f } == 0f) {
// Distance check below instead of this
}
}
// Coarse: snap to nearest vertex within tolerance
val tol = g.scale * 0.20f
for (v in 0 until CatanBoard.VERTEX_COUNT) {
val pos = g.vertexPx(v)
val dist = kotlin.math.hypot(offset.x - pos.x, offset.y - pos.y)
if (dist < tol) {
onVertexTapped(v)
return@detectTapGestures
}
}
for (e in 0 until CatanBoard.EDGE_COUNT) {
val pos = g.edgeMidPx(e)
val dist = kotlin.math.hypot(offset.x - pos.x, offset.y - pos.y)
if (dist < tol) {
onEdgeTapped(e)
return@detectTapGestures
}
}
}
},
) {
val halfW = size.width / 2f
val halfH = size.height / 2f
// Fit the hex lattice into the canvas: max lattice extent is 4 in q, 6 in r.
val latticeMax = 6f
val scale = minOf(halfW, halfH) / latticeMax
val g = BoardGeometry(halfW, halfH, scale)
drawBoard(g)
drawTerrains(state, g)
drawRobber(state, g)
drawBuildings(state, g)
drawRoads(state, g)
drawNumbers(state, g)
drawPorts(state, g)
drawDice(state, g)
drawHints(g, legal, linedMove, tappedVertex, tappedEdge)
}
}
private fun Canvas.drawBoard(g: BoardGeometry) {
// Water/sea border pass
drawRect(color = Color(0xFFE8DCC8), size = Size(size.width, size.height))
}
private fun Canvas.drawTerrains(state: CatanState, g: BoardGeometry) {
val radius = g.scale * 0.38f
for (h in 0 until CatanBoard.HEX_COUNT) {
val c = g.hexCenterPx(h)
val col = when (state.terrains[h]) {
org.prolibertate.games.game.catan.Terrain.FOREST -> TileForest
org.prolibertate.games.game.catan.Terrain.PASTURE -> TilePasture
org.prolibertate.games.game.catan.Terrain.FIELDS -> TileFields
org.prolibertate.games.game.catan.Terrain.HILLS -> TileHills
org.prolibertate.games.game.catan.Terrain.MOUNTAINS -> TileMountains
org.prolibertate.games.game.catan.Terrain.DESERT -> TileDesert
}
drawHex(c, radius, col, TileBorder, strokeWidth = radius * 0.1f)
}
}
private fun Canvas.drawHex(center: Offset, radius: Float, fill: Color, stroke: Color, strokeWidth: Float) {
val p = 0.866f * radius
val pts = listOf(
Offset(center.x, center.y - radius),
Offset(center.x + p, center.y - radius / 2),
Offset(center.x + p, center.y + radius / 2),
Offset(center.x, center.y + radius),
Offset(center.x - p, center.y + radius / 2),
Offset(center.x - p, center.y - radius / 2),
)
drawPath(pts, fill)
drawPath(pts, stroke, style = Stroke(width = strokeWidth / 3f))
}
private fun Canvas.drawNumbers(state: CatanState, g: BoardGeometry) {
for (h in 0 until CatanBoard.HEX_COUNT) {
val num = state.numbers[h]
if (num == 0) continue
val c = g.hexCenterPx(h)
val r = minOf(size.width, size.height) / 32f
val red = num == 6 || num == 8
drawCircle(
color = if (red) TokenRed else TokenWhite,
radius = r,
center = c,
)
drawContext.canvas.nativeCanvas.let {
val paint = android.graphics.Paint().apply {
color = android.graphics.Color.parseColor("#333333")
textSize = r * 1.2f
textAlign = android.graphics.Paint.Align.CENTER
}
it.drawText(num.toString(), c.x, c.y + r * 0.4f, paint)
}
}
}
private fun Canvas.drawRobber(state: CatanState, g: BoardGeometry) {
val hex = state.robber
val c = g.hexCenterPx(hex)
drawCircle(color = Color(0xFF2A2A2A), radius = minOf(size.width, size.height) / 30f, center = c)
}
private fun Canvas.drawBuildings(state: CatanState, g: BoardGeometry) {
val r = minOf(size.width, size.height) / 40f
for (v in 0 until CatanBoard.VERTEX_COUNT) {
val b = state.buildings[v] ?: continue
val c = g.vertexPx(v)
val col = PlayerColors.getOrElse(b.owner) { Color.Gray }
if (b.type == BuildingType.CITY) {
drawCircle(color = col, radius = r * 1.4f, center = c)
} else {
drawCircle(color = col, radius = r, center = c)
}
}
}
private fun Canvas.drawRoads(state: CatanState, g: BoardGeometry) {
val w = minOf(size.width, size.height) / 60f
for (e in 0 until CatanBoard.EDGE_COUNT) {
val owner = state.roads[e]
if (owner == NO_PLAYER) continue
val (a, b) = CatanBoard.EDGE_VERTICES[e]
val pa = g.vertexPx(a)
val pb = g.vertexPx(b)
val col = PlayerColors.getOrElse(owner) { Color.Gray }
drawLine(color = col, start = pa, end = pb, strokeWidth = w, cap = StrokeCap.Round)
}
}
private fun Canvas.drawPorts(state: CatanState, g: BoardGeometry) {
for (port in state.ports) {
val c = g.edgeMidPx(port.edge)
val r = minOf(size.width, size.height) / 32f
val label = if (port.resource == null) "3:1" else "2:1"
drawContext.canvas.nativeCanvas.let {
val paint = android.graphics.Paint().apply {
color = android.graphics.Color.parseColor("#000000")
textSize = r * 1.2f
textAlign = android.graphics.Paint.Align.CENTER
}
it.drawText(label, c.x, c.y + r * 0.4f, paint)
}
}
}
private fun Canvas.drawDice(state: CatanState, g: BoardGeometry) {
val dice = state.lastDice
if (dice.isEmpty()) return
val total = dice[0] + dice[1]
// Dice marker sits centered below board
val cx = size.width / 2f
val cy = size.height - minOf(size.width, size.height) / 14f
val r = minOf(size.width, size.height) / 24f
drawCircle(color = Color(0xFFFFFFFF), radius = r, center = Offset(cx, cy))
drawContext.canvas.nativeCanvas.let {
val paint = android.graphics.Paint().apply {
color = android.graphics.Color.parseColor("#333333")
textSize = r * 1.0f
textAlign = android.graphics.Paint.Align.CENTER
}
it.drawText("$total", cx, cy + r * 0.4f, paint)
}
}
private fun Canvas.drawHints(
g: BoardGeometry,
legal: List<CatanMove>,
linedMove: androidx.compose.runtime.MutableState<CatanMove?>,
tappedVertex: Int?,
tappedEdge: Int?,
) {
val r = minOf(size.width, size.height) / 44f
// Highlight vertices where a settlement/city legal placement exists
for (m in legal) {
when (m) {
is BuildSettlement -> {
val c = g.vertexPx(m.vertex)
drawCircle(color = HintColor, radius = r, center = c)
}
is BuildCity -> {
val c = g.vertexPx(m.vertex)
drawCircle(color = HintColor, radius = r * 1.3f, center = c)
}
is BuildRoad -> {
val (a, b) = CatanBoard.EDGE_VERTICES[m.edge]
val pa = g.vertexPx(a); val pb = g.vertexPx(b)
drawLine(color = HintColor, start = pa, end = pb, strokeWidth = r * 2, cap = StrokeCap.Round)
}
is org.prolibertate.games.game.catan.SetupPlace -> {
val vc = g.vertexPx(m.vertex)
val ec = g.edgeMidPx(m.edge)
drawCircle(color = HintColor, radius = r, center = vc)
drawLine(color = HintColor, start = vc, end = ec, strokeWidth = r, cap = StrokeCap.Round)
}
else -> Unit
}
}
// Highlight the user's currently lined-up move more strongly
linedMove.value?.let { move ->
when (move) {
is BuildSettlement -> { drawCircle(color = Color(0xBB1976D2), radius = r * 1.3f, center = g.vertexPx(move.vertex)) }
is BuildCity -> { drawCircle(color = Color(0xBB1976D2), radius = r * 1.6f, center = g.vertexPx(move.vertex)) }
is BuildRoad -> {
val (a, b) = CatanBoard.EDGE_VERTICES[move.edge]
drawLine(color = Color(0xBB1976D2), start = g.vertexPx(a), end = g.vertexPx(b), strokeWidth = r * 3, cap = StrokeCap.Round)
}
else -> Unit
}
}
}
// ---------------------------------------------------------------------------
// Personal panel
// ---------------------------------------------------------------------------
@Composable
private fun CatanPersonalPanel(
state: CatanState,
localSeat: Int,
legal: List<CatanMove>,
onRoll: () -> Unit,
onEndTurn: () -> Unit,
) {
val yours = state.turn == localSeat && state.phase != CatanPhase.GAME_OVER
Column(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
text = "Seat $localSeat — turn ${state.turnNumber}, ${state.phase}",
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.bodyMedium,
)
// Resources — counts only (Card counts public in Catan)
val res = state.resources[localSeat]
val parts = listOf(
"Lumber" to (res[Resource.LUMBER] ?: 0),
"Brick" to (res[Resource.BRICK] ?: 0),
"Wool" to (res[Resource.WOOL] ?: 0),
"Grain" to (res[Resource.GRAIN] ?: 0),
"Ore" to (res[Resource.ORE] ?: 0),
)
Text(
text = parts.joinToString(" ") { "${it.first.take(1)} ${it.second}" },
style = MaterialTheme.typography.bodySmall,
)
// Dev card counts public; hidden cards redacted by viewFor
Text(
text = "Dev cards: ${state.devHandCounts[localSeat]}",
style = MaterialTheme.typography.bodySmall,
)
// Victory points — public only (building VP + awards); hidden VP cards
// are counted for the local seat's own view.
val vp = CatanRules.publicVictoryPoints(state)[localSeat] +
state.devHands[localSeat].count { it.type == DevCardType.VICTORY_POINT }
val banners = buildList<String>().apply {
if (state.longestRoadOwner == localSeat) add("🛣️ Longest Road (${CatanRules.longestRoadFor(state, localSeat)})")
if (state.largestArmyOwner == localSeat) add("⚔️ Largest Army (${state.knightsPlayed[localSeat]})")
}
Text(
text = "VP: $vp ${if (banners.isNotEmpty()) banners.joinToString(" ") else ""}",
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Bold,
)
if (yours) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Button(
onClick = onRoll,
enabled = state.phase == CatanPhase.AWAIT_ROLL,
modifier = Modifier.weight(1f).height(48.dp),
) { Text("Roll") }
Button(
onClick = onEndTurn,
enabled = state.phase == CatanPhase.PLAYING,
modifier = Modifier.weight(1f).height(48.dp),
) { Text("End Turn") }
}
} else {
Text(
text = when {
state.phase == CatanPhase.GAME_OVER -> "Game over — seat ${state.winner} wins!"
yours -> "Your turn — place a settëment or roll."
else -> "Seat ${state.turn} is playing…"
},
style = MaterialTheme.typography.bodySmall,
)
}
}
}
// ---------------------------------------------------------------------------
// Event feed
// ---------------------------------------------------------------------------
@Composable
private fun CatanEventFeed(log: List<String>) {
Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
Text("Events", style = MaterialTheme.typography.labelLarge)
val lines = log.takeLast(4)
if (lines.isEmpty()) {
Text("Waiting for the first event…", style = MaterialTheme.typography.bodySmall, color = Color.Gray)
}
lines.forEach { Text(it, style = MaterialTheme.typography.bodySmall) }
}
}