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.Canvas
|
||||||
import androidx.compose.foundation.background
|
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.Button
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.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 androidx.compose.ui.unit.dp
|
||||||
import org.prolibertate.games.game.catan.CatanMove
|
import org.prolibertate.games.game.catan.BuildCity
|
||||||
import org.prolibertate.games.game.catan.CatanState
|
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.CatanBoard
|
||||||
|
import org.prolibertate.games.game.catan.CatanMove
|
||||||
import org.prolibertate.games.game.catan.CatanRules
|
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.net.MatchController
|
||||||
import org.prolibertate.games.ui.ScreenScaffold
|
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.
|
* Catan screen — consumes only the game/catan/ engine API.
|
||||||
* Board renders to remaining space (no fixed dp); placement uses tap-to-target + commit
|
* Layout measures the space it is given: board above, personal panel + feed below.
|
||||||
* where targets are smaller than a fingertip (Ta Yü precedent); hand/trade/panel 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
|
@Composable
|
||||||
fun CatanScreen(
|
fun CatanScreen(
|
||||||
@@ -33,104 +92,506 @@ fun CatanScreen(
|
|||||||
val legal by controller.legalMoves.collectAsState()
|
val legal by controller.legalMoves.collectAsState()
|
||||||
val abandoned by controller.abandoned.collectAsState()
|
val abandoned by controller.abandoned.collectAsState()
|
||||||
|
|
||||||
val current = state
|
// Placement flow state: which vertex/edge the player has tapped, and the
|
||||||
ScreenScaffold(title = "Katan", onBack = onExit) { modifier ->
|
// legal move currently lined up on it. Tapping again cycles to the next
|
||||||
if (current == null) {
|
// legal placement that covers the same spot; commit submits it.
|
||||||
Box(modifier, contentAlignment = Alignment.Center) { Text("Setting up…") }
|
var tappedVertex by remember { mutableStateOf<Int?>(null) }
|
||||||
abandoned?.let { /* dialog handled by PlayScreen layer per pattern */ }
|
var tappedEdge by remember { mutableStateOf<Int?>(null) }
|
||||||
return@ScreenScaffold
|
var linedMove by remember { mutableStateOf<CatanMove?>(null) }
|
||||||
}
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Hex board rendered to the space given — no hard-coded dp sizes. */
|
val current = state
|
||||||
@Composable
|
if (current == null) {
|
||||||
private fun CatanBoardView(state: CatanState, legal: List<CatanMove>, localSeat: Int) {
|
ScreenScaffold(title = TITLE, onBack = onExit) { modifier ->
|
||||||
// Canvas-based hex rendering; full geometry kept in CatanBoard (Spec fidelity — geometry is spec).
|
Box(modifier, contentAlignment = Alignment.Center) { Text("Setting up…") }
|
||||||
Canvas(modifier = Modifier.fillMaxSize().background(Color(0xFFE8DCC8))) {
|
}
|
||||||
val hexCount = CatanBoard.HEX_COUNT
|
return
|
||||||
// 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
|
// Reset lined placement when the legal set changes (a move landed, phase advanced).
|
||||||
val cx = size.width / 2
|
LaunchedEffect(legal, current.phase) {
|
||||||
val cy = size.height / 2
|
tappedVertex = null
|
||||||
val radius = minOf(size.width, size.height) / 10
|
tappedEdge = null
|
||||||
for (h in 0 until hexCount) {
|
linedMove = null
|
||||||
val hex = CatanBoard.HEXES[h]
|
}
|
||||||
val (lx, ly) = CatanBoard.hexCenter(h)
|
|
||||||
val x = cx + lx * radius * 0.8f
|
val yours = current.turn == localSeat && current.phase != CatanPhase.GAME_OVER
|
||||||
val y = cy + ly * radius * 0.8f
|
|
||||||
drawCircle(
|
ScreenScaffold(title = TITLE, onBack = onExit) { modifier ->
|
||||||
color = if (h == state.robber) Color(0xFF4A4A4A) else when (state.terrains[h]) {
|
// Board above, personal panel + feed below — board sized to remaining space.
|
||||||
org.prolibertate.games.game.catan.Terrain.FOREST -> Color(0xFF6B8E6B)
|
Column(
|
||||||
org.prolibertate.games.game.catan.Terrain.PASTURE -> Color(0xFFB8D48A)
|
modifier = modifier
|
||||||
org.prolibertate.games.game.catan.Terrain.FIELDS -> Color(0xFFEBCB84)
|
.fillMaxSize()
|
||||||
org.prolibertate.games.game.catan.Terrain.HILLS -> Color(0xFFCD853F)
|
.verticalScroll(rememberScrollState()),
|
||||||
org.prolibertate.games.game.catan.Terrain.MOUNTAINS -> Color(0xFF808080)
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
org.prolibertate.games.game.catan.Terrain.DESERT -> Color(0xFFE5D6A8)
|
) {
|
||||||
},
|
Box(modifier = Modifier.weight(1.6f).fillMaxWidth()) {
|
||||||
radius = radius,
|
CatanBoardView(
|
||||||
center = androidx.compose.ui.geometry.Offset(x, y),
|
state = current,
|
||||||
)
|
legal = legal,
|
||||||
// Number token
|
linedMove = linedMove,
|
||||||
val num = state.numbers[h]
|
tappedVertex = tappedVertex,
|
||||||
if (num > 0) {
|
tappedEdge = tappedEdge,
|
||||||
drawCircle(
|
onVertexTapped = { v ->
|
||||||
color = Color(0xFFE8DCC8),
|
tappedVertex = v
|
||||||
radius = radius * 0.35f,
|
tappedEdge = null
|
||||||
center = androidx.compose.ui.geometry.Offset(x, y),
|
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
|
@Composable
|
||||||
private fun CatanPersonalPanel(
|
private fun CatanPersonalPanel(
|
||||||
state: CatanState,
|
state: CatanState,
|
||||||
localSeat: Int,
|
localSeat: Int,
|
||||||
legal: List<CatanMove>,
|
legal: List<CatanMove>,
|
||||||
onSubmit: (CatanMove) -> Unit,
|
onRoll: () -> Unit,
|
||||||
|
onEndTurn: () -> Unit,
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.fillMaxWidth().padding(8.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
val yours = state.turn == localSeat && state.phase != CatanPhase.GAME_OVER
|
||||||
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
|
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 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)
|
val parts = listOf(
|
||||||
// Dev card counts public (hidden cards redacted by viewFor)
|
"Lumber" to (res[Resource.LUMBER] ?: 0),
|
||||||
Text("Dev cards held: ${state.devHandCounts[localSeat]}", style = MaterialTheme.typography.bodySmall)
|
"Brick" to (res[Resource.BRICK] ?: 0),
|
||||||
// Victory points (public count + hidden VP cards estimated by viewFor logic; here shown via state public fields only)
|
"Wool" to (res[Resource.WOOL] ?: 0),
|
||||||
// Build / roll button — only when legal moves exist
|
"Grain" to (res[Resource.GRAIN] ?: 0),
|
||||||
if (legal.isNotEmpty()) {
|
"Ore" to (res[Resource.ORE] ?: 0),
|
||||||
Button(onClick = { legal.firstOrNull()?.let(onSubmit) }, modifier = Modifier.fillMaxWidth().height(48.dp)) {
|
)
|
||||||
Text("Submit: ${legal.first()}")
|
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 {
|
} 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
|
@Composable
|
||||||
private fun CatanLogPanel(log: List<String>) {
|
private fun CatanEventFeed(log: List<String>) {
|
||||||
Column(modifier = Modifier.fillMaxWidth().padding(8.dp).height(80.dp)) {
|
Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
|
||||||
Text("Events", style = MaterialTheme.typography.labelLarge)
|
Text("Events", style = MaterialTheme.typography.labelLarge)
|
||||||
val lines = log.takeLast(4)
|
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) }
|
lines.forEach { Text(it, style = MaterialTheme.typography.bodySmall) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user