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