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]>
This commit is contained in:
co-authored by
Paperclip
parent
1f7fced24c
commit
81feeee132
@@ -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<Int?>(null) }
|
||||
var tappedEdge by remember { mutableStateOf<Int?>(null) }
|
||||
var linedMove by remember { mutableStateOf<CatanMove?>(null) }
|
||||
|
||||
/** Hex board rendered to the space given — no hard-coded dp sizes. */
|
||||
@Composable
|
||||
private fun CatanBoardView(state: CatanState, legal: List<CatanMove>, 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<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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 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<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>,
|
||||
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<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("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<String>) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(8.dp).height(80.dp)) {
|
||||
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) }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user