Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f69f94c21 | ||
|
|
81feeee132 | ||
|
|
1f7fced24c |
@@ -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,610 @@
|
|||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Build menu — gated by available resources
|
||||||
|
val canBuildSettlement = (res[Resource.LUMBER] ?: 0) >= 1 && (res[Resource.BRICK] ?: 0) >= 1 && (res[Resource.WOOL] ?: 0) >= 1 && (res[Resource.GRAIN] ?: 0) >= 1
|
||||||
|
val canBuildCity = (res[Resource.ORE] ?: 0) >= 3 && (res[Resource.GRAIN] ?: 0) >= 2
|
||||||
|
val canBuyDev = (res[Resource.ORE] ?: 0) >= 1 && (res[Resource.WOOL] ?: 0) >= 1 && (res[Resource.GRAIN] ?: 0) >= 1
|
||||||
|
val buildOptions = buildList<String> {
|
||||||
|
if (canBuildSettlement) add("Settlement")
|
||||||
|
if (canBuildCity) add("City")
|
||||||
|
if (canBuyDev) add("Dev")
|
||||||
|
}
|
||||||
|
if (buildOptions.isNotEmpty() && yours) {
|
||||||
|
Text("Build: ${buildOptions.joinToString(", ")}", style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
|
||||||
|
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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user