From 81feeee1322badd9c974c524a47a177e0805b7ab Mon Sep 17 00:00:00 2001 From: adrianoftyriel <12343778+adrianoftyriel@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:03:13 +0000 Subject: [PATCH] Catan Compose UI: board, placement flow, personal panel, event feed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../prolibertate/games/ui/game/CatanScreen.kt | 625 +++++++++++++++--- 1 file changed, 543 insertions(+), 82 deletions(-) diff --git a/app/src/main/java/org/prolibertate/games/ui/game/CatanScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/CatanScreen.kt index 2fd9eba..f287a6c 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/CatanScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/CatanScreen.kt @@ -2,26 +2,85 @@ package org.prolibertate.games.ui.game import androidx.compose.foundation.Canvas import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* +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.* +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.CatanMove -import org.prolibertate.games.game.catan.CatanState +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 catan/ engine API. - * Board renders to remaining space (no fixed dp); placement uses tap-to-target + commit - * where targets are smaller than a fingertip (Ta Yü precedent); hand/trade/panel below. + * 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( @@ -33,104 +92,506 @@ fun CatanScreen( val legal by controller.legalMoves.collectAsState() val abandoned by controller.abandoned.collectAsState() - val current = state - ScreenScaffold(title = "Katan", onBack = onExit) { modifier -> - if (current == null) { - Box(modifier, contentAlignment = Alignment.Center) { Text("Setting up…") } - abandoned?.let { /* dialog handled by PlayScreen layer per pattern */ } - return@ScreenScaffold - } - // Layout measures space it is given: board above (weighted), hand/trade below. - Column(modifier = modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) { - // Board region sized to remaining space - Box(modifier = Modifier.weight(1.6f).fillMaxWidth()) { - CatanBoardView(state = current, legal = legal, localSeat = localSeat) - } - // Personal panel + turn controls below board - CatanPersonalPanel(state = current, localSeat = localSeat, legal = legal) { move -> - controller.submit(move) - } - // Event feed (last events — never hidden info; seat-redacted view already in CatanRules.viewFor) - CatanLogPanel(log = current.log) - } - } -} + // 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(null) } + var tappedEdge by remember { mutableStateOf(null) } + var linedMove by remember { mutableStateOf(null) } -/** Hex board rendered to the space given — no hard-coded dp sizes. */ -@Composable -private fun CatanBoardView(state: CatanState, legal: List, localSeat: Int) { - // Canvas-based hex rendering; full geometry kept in CatanBoard (Spec fidelity — geometry is spec). - Canvas(modifier = Modifier.fillMaxSize().background(Color(0xFFE8DCC8))) { - val hexCount = CatanBoard.HEX_COUNT - // Place holder: draw a simplified hex grid to verify measure/layout path. - // Real hex geometry feeds from CatanBoard.VERTEX_POS / HEX_CORNERS. - val size = size - val cx = size.width / 2 - val cy = size.height / 2 - val radius = minOf(size.width, size.height) / 10 - for (h in 0 until hexCount) { - val hex = CatanBoard.HEXES[h] - val (lx, ly) = CatanBoard.hexCenter(h) - val x = cx + lx * radius * 0.8f - val y = cy + ly * radius * 0.8f - drawCircle( - color = if (h == state.robber) Color(0xFF4A4A4A) else when (state.terrains[h]) { - org.prolibertate.games.game.catan.Terrain.FOREST -> Color(0xFF6B8E6B) - org.prolibertate.games.game.catan.Terrain.PASTURE -> Color(0xFFB8D48A) - org.prolibertate.games.game.catan.Terrain.FIELDS -> Color(0xFFEBCB84) - org.prolibertate.games.game.catan.Terrain.HILLS -> Color(0xFFCD853F) - org.prolibertate.games.game.catan.Terrain.MOUNTAINS -> Color(0xFF808080) - org.prolibertate.games.game.catan.Terrain.DESERT -> Color(0xFFE5D6A8) - }, - radius = radius, - center = androidx.compose.ui.geometry.Offset(x, y), - ) - // Number token - val num = state.numbers[h] - if (num > 0) { - drawCircle( - color = Color(0xFFE8DCC8), - radius = radius * 0.35f, - center = androidx.compose.ui.geometry.Offset(x, y), + 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, + target: Int, + current: CatanMove?, + allLegal: List, +): 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, + target: Int, + current: CatanMove?, + allLegal: List, +): 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, + linedMove: androidx.compose.runtime.MutableState, + 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) } } } -/** Personal panel: resources, dev cards (counts only — redacted by viewFor), VP, banners. Targets ≥ 48dp. */ +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, + linedMove: androidx.compose.runtime.MutableState, + 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, - onSubmit: (CatanMove) -> Unit, + onRoll: () -> Unit, + onEndTurn: () -> Unit, ) { - Column(modifier = Modifier.fillMaxWidth().padding(8.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { - Text("Seat $localSeat — turn ${state.turnNumber}, phase ${state.phase}", style = MaterialTheme.typography.bodyMedium, fontWeight = androidx.compose.ui.text.font.FontWeight.Bold) - // Resources — counts public per spec; hidden cards never shown + 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] - Text("Lumber ${res[org.prolibertate.games.game.catan.Resource.LUMBER] ?: 0} Brick ${res[org.prolibertate.games.game.catan.Resource.BRICK] ?: 0} Wool ${res[org.prolibertate.games.game.catan.Resource.WOOL] ?: 0} Grain ${res[org.prolibertate.games.game.catan.Resource.GRAIN] ?: 0} Ore ${res[org.prolibertate.games.game.catan.Resource.ORE] ?: 0}", style = MaterialTheme.typography.bodySmall) - // Dev card counts public (hidden cards redacted by viewFor) - Text("Dev cards held: ${state.devHandCounts[localSeat]}", style = MaterialTheme.typography.bodySmall) - // Victory points (public count + hidden VP cards estimated by viewFor logic; here shown via state public fields only) - // Build / roll button — only when legal moves exist - if (legal.isNotEmpty()) { - Button(onClick = { legal.firstOrNull()?.let(onSubmit) }, modifier = Modifier.fillMaxWidth().height(48.dp)) { - Text("Submit: ${legal.first()}") + 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().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("No legal move — wait for turn.", style = MaterialTheme.typography.bodySmall, color = Color.Gray) + 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, + ) } } } -/** Game log / event feed so players follow opponents; never reads hidden info. */ +// --------------------------------------------------------------------------- +// Event feed +// --------------------------------------------------------------------------- + @Composable -private fun CatanLogPanel(log: List) { - Column(modifier = Modifier.fillMaxWidth().padding(8.dp).height(80.dp)) { +private fun CatanEventFeed(log: List) { + 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) } } }