Compare commits

..
Author SHA1 Message Date
Claude 9b938ffd8b Merge remote-tracking branch 'origin/dev' into CatanImprovements
CI / Build and check (pull_request) Successful in 8m41s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
2026-10-24 16:58:58 +00:00
Claude ccf8d5a1ba Katan HUD: compact landscape layout for phone heights
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WrH771kLJmTHnEfcmV2YDx
2026-10-24 16:58:58 +00:00
devadmin a00df43a44 Merge pull request 'Meowdoku: cat puzzle engine, screen and docs' (#12) from meow into dev
CI / Build and check (push) Successful in 8m38s
CI / Deploy to Cloudflare Pages (push) Successful in 30s
2026-10-24 10:54:17 -06:00
Claude fae031d2b5 Katan: Catan Universe style landscape HUD and trade board
Drag-to-build with confirm/cancel, pinch/pan board, round turn and trade
buttons, and a trade board panel (bank, player, reply). Findings from the
Catan Universe 2.7.5 reverse engineering are in docs/RE-catan-universe.md.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WrH771kLJmTHnEfcmV2YDx
2026-10-24 16:49:18 +00:00
9 changed files with 1243 additions and 41 deletions
@@ -61,6 +61,11 @@ fun CatanBoardView(
rolledHexes: Set<Int> = emptySet(),
/** Cities & Knights: the knight whose menu is open or that is about to move. */
selectedKnight: Int? = null,
/** A placement lined up but not yet confirmed (or being dragged over): drawn as a solid ghost. */
stagedVertex: Int? = null,
stagedEdge: Int? = null,
/** The seat that will own the staged piece, for its colour. */
stagedSeat: Int? = null,
) {
val measurer = rememberTextMeasurer()
var size by remember { mutableStateOf(IntSize.Zero) }
@@ -118,6 +123,7 @@ fun CatanBoardView(
drawKnightTargets(targets, board, style)
drawVertexTargets(targets, selectedVertex ?: selectedKnight, board, style)
drawHexTargets(targets, board, style)
drawStaged(stagedVertex, stagedEdge, stagedSeat, board, style)
}
}
@@ -642,3 +648,21 @@ private fun DrawScope.drawCentered(
topLeft = Offset(at.x - layout.size.width / 2f, at.y - layout.size.height / 2f),
)
}
/** The ghost of a placement waiting for ✓: a solid piece in the player's colour with a bright ring. */
private fun DrawScope.drawStaged(vertex: Int?, edge: Int?, seat: Int?, board: BoardLayout, style: CatanStyle) {
val colour = seat?.let { style.seats.getOrNull(it) } ?: style.target
if (edge != null) {
val (a, b) = board.edgeEnds(edge)
val from = lerp(a, b, 0.1f)
val to = lerp(a, b, 0.9f)
drawLine(style.targetSelected, from.offset(), to.offset(), board.radius * style.roadWidth * 2.1f, StrokeCap.Round)
drawLine(colour, from.offset(), to.offset(), board.radius * style.roadWidth * 1.3f, StrokeCap.Round)
}
if (vertex != null) {
val at = board.vertex(vertex).offset()
val radius = board.radius * style.targetRadius
drawCircle(colour, radius * 1.3f, at)
drawCircle(style.targetSelected, radius * 1.9f, at, style = Stroke(width = board.radius * 0.1f))
}
}
@@ -0,0 +1,458 @@
package org.prolibertate.games.ui.game.catan
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.Surface
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/*
* The heads-up display pieces of the landscape Katan screen: the controls the
* Catan Universe match screen uses (see docs/RE-catan-universe.md), redrawn in
* 2D. Stateless, like everything in CatanPanels.kt: values in, lambdas out.
*/
/** A round HUD button: a glyph or short word, optionally with a one-word caption. */
@Composable
fun HudRoundButton(
glyph: String,
modifier: Modifier = Modifier,
caption: String? = null,
enabled: Boolean = true,
primary: Boolean = false,
tint: Color = Color.Unspecified,
diameter: Dp = 56.dp,
onClick: () -> Unit,
) {
val container = when {
tint != Color.Unspecified -> tint
primary -> MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.secondaryContainer
}
val ink = if (primary || tint != Color.Unspecified) Color.White else MaterialTheme.colorScheme.onSecondaryContainer
Column(modifier, horizontalAlignment = Alignment.CenterHorizontally) {
Box(
modifier = Modifier
.size(diameter)
.clip(CircleShape)
.background(container.copy(alpha = if (enabled) 1f else 0.35f))
.border(2.dp, Color(0x55000000), CircleShape)
.then(if (enabled) Modifier.clickable(onClick = onClick) else Modifier),
contentAlignment = Alignment.Center,
) {
Text(glyph, color = ink.copy(alpha = if (enabled) 1f else 0.6f), fontSize = 18.sp, fontWeight = FontWeight.Bold)
}
// Always laid out, so the button column never changes height.
Text(caption ?: " ", fontSize = 10.sp, maxLines = 1)
}
}
/**
* The left-hand build column: one icon per piece. **Drag one onto the board** and
* release over a lit spot to stage it; or tap to arm the board and tap a spot. The
* column reports pointer positions in window coordinates so the screen can find the
* spot under the finger whatever the board's zoom and pan.
*/
@Composable
fun HudBuildColumn(
kinds: List<BuildKind>,
canBuild: Set<BuildKind>,
armed: BuildKind?,
modifier: Modifier = Modifier,
onTap: (BuildKind) -> Unit,
onDragStart: (BuildKind, Offset) -> Unit,
onDrag: (Offset) -> Unit,
onDragEnd: () -> Unit,
) {
Column(modifier, verticalArrangement = Arrangement.spacedBy(3.dp), horizontalAlignment = Alignment.CenterHorizontally) {
kinds.forEach { kind ->
BuildChip(kind, enabled = kind in canBuild, armed = armed == kind, onTap, onDragStart, onDrag, onDragEnd)
}
}
}
@Composable
private fun BuildChip(
kind: BuildKind,
enabled: Boolean,
armed: Boolean,
onTap: (BuildKind) -> Unit,
onDragStart: (BuildKind, Offset) -> Unit,
onDrag: (Offset) -> Unit,
onDragEnd: () -> Unit,
) {
var coords by remember { mutableStateOf<LayoutCoordinates?>(null) }
val start by rememberUpdatedState(onDragStart)
val move by rememberUpdatedState(onDrag)
val end by rememberUpdatedState(onDragEnd)
val tap by rememberUpdatedState(onTap)
val shape = RoundedCornerShape(12.dp)
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(
modifier = Modifier
.size(46.dp)
.clip(shape)
.background(
when {
armed -> MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.secondaryContainer.copy(alpha = if (enabled) 1f else 0.35f)
},
)
.border(2.dp, Color(0x55000000), shape)
.onGloballyPositioned { coords = it }
.pointerInput(enabled, kind) {
if (!enabled) return@pointerInput
detectDragGestures(
onDragStart = { local ->
coords?.localToWindow(local)?.let { start(kind, it) }
},
onDrag = { change, _ ->
change.consume()
coords?.localToWindow(change.position)?.let { move(it) }
},
onDragEnd = { end() },
onDragCancel = { end() },
)
}
.then(if (enabled) Modifier.clickable { tap(kind) } else Modifier),
contentAlignment = Alignment.Center,
) {
Text(
CatanHudGlyphs.of(kind),
fontSize = 22.sp,
color = if (armed) Color.White else MaterialTheme.colorScheme.onSecondaryContainer,
)
}
Text(CatanHudGlyphs.label(kind), fontSize = 10.sp, maxLines = 1)
}
}
/** Glyphs and captions of the build column. Kept apart so the art can be swapped for drawables. */
object CatanHudGlyphs {
fun of(kind: BuildKind): String = when (kind) {
BuildKind.ROAD -> "━"
BuildKind.SETTLEMENT -> "⌂"
BuildKind.CITY -> "▲"
BuildKind.WALL -> "▦"
BuildKind.KNIGHT -> "♞"
}
fun label(kind: BuildKind): String = when (kind) {
BuildKind.ROAD -> CatanText.ROAD
BuildKind.SETTLEMENT -> CatanText.SETTLEMENT
BuildKind.CITY -> CatanText.CITY
BuildKind.WALL -> CatanText.WALL
BuildKind.KNIGHT -> CatanText.KNIGHT
}
}
/** ✓ beside ✗: confirm or cancel a staged placement. Always laid out; enabled only while something is staged. */
@Composable
fun HudConfirmButtons(
staged: Boolean,
modifier: Modifier = Modifier,
onConfirm: () -> Unit,
onCancel: () -> Unit,
) {
Row(modifier, horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.Bottom) {
HudRoundButton("✓", caption = CatanText.CONFIRM_BUILD, enabled = staged, tint = Color(0xFF2E7D32), diameter = 48.dp, onClick = onConfirm)
HudRoundButton("✕", caption = CatanText.CANCEL_BUILD, enabled = staged, tint = Color(0xFFC62828), diameter = 48.dp, onClick = onCancel)
}
}
/**
* Pan with one finger, pinch to zoom, like the Universe board camera. The content
* is drawn through a graphics layer, so taps inside it arrive already in the
* content's own coordinates and the board's hit-testing needs no changes.
*/
@Composable
fun ZoomPanBox(
modifier: Modifier = Modifier,
minScale: Float = 1f,
maxScale: Float = 3f,
content: @Composable () -> Unit,
) {
var scale by remember { mutableFloatStateOf(1f) }
var offset by remember { mutableStateOf(Offset.Zero) }
Box(
modifier = modifier
.clip(RoundedCornerShape(0.dp))
.pointerInput(Unit) {
detectTransformGestures { centroid, pan, zoom, _ ->
val newScale = (scale * zoom).coerceIn(minScale, maxScale)
val centre = Offset(size.width / 2f, size.height / 2f)
// The content point under the fingers stays under them while zooming; then pan.
val world = (centroid - centre - offset) / scale
val next = centroid - centre - world * newScale + pan
val maxX = size.width * (newScale - 1f) / 2f
val maxY = size.height * (newScale - 1f) / 2f
offset = Offset(next.x.coerceIn(-maxX, maxX), next.y.coerceIn(-maxY, maxY))
scale = newScale
}
},
) {
Box(
Modifier
.fillMaxSize()
.graphicsLayer {
scaleX = scale
scaleY = scale
translationX = offset.x
translationY = offset.y
},
contentAlignment = Alignment.Center,
) { content() }
}
}
/** A small caption pill, for the drag hint and the staged-move text. */
@Composable
fun HudPill(text: String, modifier: Modifier = Modifier) {
Text(
text,
color = Color.White,
fontSize = 12.sp,
maxLines = 1,
modifier = modifier
.clip(RoundedCornerShape(50))
.background(Color(0xCC000000))
.padding(horizontal = 12.dp, vertical = 4.dp),
)
}
/**
* The landscape screen, laid out like the Catan Universe match view:
*
* ```
* ┌ player strip ───────────────────────────────────────────────┐
* │ prompt + dice │
* │ [build] the island (pinch / pan) [✓] [✕] │
* │ [column] │
* │ [log] ┌ your cards ┐ [buy][play] [⇄][●]│
* └─────────────┴────────────┴───────────────────────────────────┘
* ```
*
* Everything here is wiring around the existing panels; the island is handed in as [board].
*/
@Composable
fun CatanHud(
state: org.prolibertate.games.game.catan.CatanState,
seats: CatanSeats,
style: CatanStyle,
actions: CatanActions,
prompt: String,
notice: String?,
legal: List<org.prolibertate.games.game.catan.CatanMove>,
localSeat: Int,
targets: BoardTargets,
pointsOf: (Int) -> Int,
staged: org.prolibertate.games.game.catan.CatanMove?,
dragKind: BuildKind?,
dragAt: Offset?,
showLog: Boolean,
boardCoords: LayoutCoordinates?,
building: BuildKind?,
onBoardCoords: (LayoutCoordinates) -> Unit,
onToggleLog: () -> Unit,
onArm: (BuildKind) -> Unit,
onDragStart: (BuildKind, Offset) -> Unit,
onDrag: (Offset) -> Unit,
onDragEnd: () -> Unit,
onConfirm: () -> Unit,
onCancelStaged: () -> Unit,
onRoll: () -> Unit,
onEndTurn: () -> Unit,
onEndFreeRoads: () -> Unit,
onEndSmith: () -> Unit,
onRepickSetup: () -> Unit,
onBuyCard: () -> Unit,
onPlayCard: () -> Unit,
onTrade: () -> Unit,
onImprove: () -> Unit,
onProgress: () -> Unit,
board: @Composable (Modifier) -> Unit,
) {
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(2.dp)) {
HudPlayerStrip(state, seats, pointsOf, style)
Box(Modifier.weight(1f).fillMaxWidth()) {
// The island fills the whole area; controls float over its sea margin.
ZoomPanBox(Modifier.fillMaxSize().padding(start = 64.dp, end = 64.dp, top = 30.dp, bottom = 44.dp)) {
board(Modifier.onGloballyPositioned(onBoardCoords))
}
// What is going on, and the dice. Two lines, always.
Row(
Modifier.align(Alignment.TopCenter).padding(horizontal = 64.dp)
.clip(RoundedCornerShape(10.dp))
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.8f))
.padding(horizontal = 8.dp, vertical = 1.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = notice ?: prompt,
style = MaterialTheme.typography.bodySmall,
color = if (notice != null) MaterialTheme.colorScheme.error else Color.Unspecified,
minLines = 2,
maxLines = 2,
modifier = Modifier.weight(1f),
)
CatanDice(state.lastDice, ck = state.isCk, event = state.ck?.eventDie)
}
// Left: pieces to drag onto the island.
HudBuildColumn(
kinds = actions.buildKinds,
canBuild = actions.canBuild,
armed = building,
modifier = Modifier.align(Alignment.CenterStart).padding(start = 6.dp),
onTap = onArm,
onDragStart = onDragStart,
onDrag = onDrag,
onDragEnd = onDragEnd,
)
// Right, middle: the card actions.
Column(
Modifier.align(Alignment.CenterEnd).padding(end = 6.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (actions.ck) {
HudRoundButton("⬆", caption = CatanText.IMPROVE, enabled = actions.canImprove, diameter = 44.dp, onClick = onImprove)
HudRoundButton("🂠", caption = CatanText.CARDS_SHORT, enabled = actions.canPlayProgress, diameter = 44.dp, onClick = onProgress)
} else {
HudRoundButton("+", caption = CatanText.BUY_SHORT, enabled = actions.canBuyCard, diameter = 44.dp, onClick = onBuyCard)
HudRoundButton("🂠", caption = CatanText.CARDS_SHORT, enabled = actions.canPlayCard, diameter = 44.dp, onClick = onPlayCard)
}
}
// Bottom centre: your cards.
Box(
Modifier.align(Alignment.BottomCenter).padding(bottom = 4.dp, start = 80.dp, end = 270.dp)
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.85f))
.horizontalScroll(rememberScrollState())
.padding(6.dp),
) { CatanHand(state, localSeat) }
// Bottom left: the log.
Column(Modifier.align(Alignment.BottomStart).padding(start = 6.dp, bottom = 4.dp)) {
if (showLog) {
Surface(
modifier = Modifier.padding(bottom = 6.dp),
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.92f),
tonalElevation = 4.dp,
) {
CatanLog(state, Modifier.padding(8.dp).height(150.dp).width(260.dp))
}
}
HudRoundButton("☰", caption = CatanText.LOG_BUTTON, diameter = 40.dp, onClick = onToggleLog)
}
// Bottom right: place / cancel a lined-up piece, trade, and the turn button.
Row(
Modifier.align(Alignment.BottomEnd).padding(end = 6.dp, bottom = 4.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.Bottom,
) {
HudConfirmButtons(staged = staged != null, onConfirm = onConfirm, onCancel = onCancelStaged)
HudRoundButton("⇄", caption = CatanText.TRADE, enabled = actions.canTrade, diameter = 48.dp, onClick = onTrade)
PrimaryTurnButton(actions, onRoll, onEndTurn, onEndFreeRoads, onEndSmith, onRepickSetup)
}
if (dragKind != null && dragAt == null) HudPill(CatanText.DRAG_HINT, Modifier.align(Alignment.TopCenter))
}
}
}
/** The one big button: roll, end the turn, finish roads, or step back from a setup choice. */
@Composable
private fun PrimaryTurnButton(
actions: CatanActions,
onRoll: () -> Unit,
onEndTurn: () -> Unit,
onEndFreeRoads: () -> Unit,
onEndSmith: () -> Unit,
onRepickSetup: () -> Unit,
) {
val (glyph, caption, enabled, click) = when {
actions.canRoll -> Quad("🎲", CatanText.ROLL_SHORT, true, onRoll)
actions.canEndFreeRoads -> Quad("✔", CatanText.DONE_SHORT, true, onEndFreeRoads)
actions.canEndSmith -> Quad("✔", CatanText.DONE_SHORT, true, onEndSmith)
actions.canRepickSetup -> Quad("↺", CatanText.CANCEL, true, onRepickSetup)
else -> Quad("➜", CatanText.END_SHORT, actions.canEndTurn, onEndTurn)
}
HudRoundButton(glyph, caption = caption, enabled = enabled, primary = true, diameter = 72.dp, onClick = click)
}
private data class Quad(val glyph: String, val caption: String, val enabled: Boolean, val click: () -> Unit)
/** Everyone's colour, name, points and card count in one thin row across the top. */
@Composable
private fun HudPlayerStrip(
state: org.prolibertate.games.game.catan.CatanState,
seats: CatanSeats,
pointsOf: (Int) -> Int,
style: CatanStyle,
) {
Row(Modifier.fillMaxWidth().padding(horizontal = 6.dp, vertical = 2.dp), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
(0 until state.playerCount).forEach { seat ->
val onTurn = seat == state.turn
val shape = RoundedCornerShape(10.dp)
Row(
Modifier.weight(1f)
.clip(shape)
.background(MaterialTheme.colorScheme.surfaceVariant)
.border(if (onTurn) 2.dp else 1.dp, if (onTurn) MaterialTheme.colorScheme.primary else Color(0x33000000), shape)
.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Box(
Modifier.size(14.dp).clip(CircleShape).background(style.seats[seat]).border(1.dp, style.pieceOutline, CircleShape),
)
Text(seats.label(seat), fontSize = 12.sp, fontWeight = FontWeight.Bold, maxLines = 1, modifier = Modifier.weight(1f))
Text("${pointsOf(seat)} VP · ${state.resourceCount(seat)}🂠", fontSize = 12.sp, maxLines = 1)
}
}
}
}
@@ -142,6 +142,42 @@ data class DevCardInHand(val card: DevCard, val plays: List<CatanMove>) {
*/
object CatanInteraction {
// --- Staged placements and drag-to-build ------------------------------------
/** A place on the board a dragged piece is over: one crossing or one edge. */
data class Spot(val vertex: Int? = null, val edge: Int? = null)
/**
* The spot a finger at [at] (board pixels) is nearest to, among [targets]. [reach] is
* generous (fingers cover what they point at), and edges are only offered when no
* crossing is lit, matching what a tap would do.
*/
fun snap(layout: BoardLayout, targets: BoardTargets, at: Pt, reach: Float): Spot? = when {
targets.tappableVertices.isNotEmpty() ->
layout.nearestVertex(at, reach, targets.tappableVertices)?.let { Spot(vertex = it) }
targets.edges.isNotEmpty() -> layout.nearestEdge(at, reach, targets.edges)?.let { Spot(edge = it) }
else -> null
}
/** The move a build tap or drop would make, if it is a placement that waits for ✓. */
fun stageable(outcome: TapOutcome, building: BuildKind?): CatanMove? {
if (building == null || outcome !is TapOutcome.Submit) return null
return outcome.move.takeIf {
it is BuildRoad || it is BuildSettlement || it is BuildCity || it is BuildWall || it is HireKnight
}
}
/** Where a staged move sits on the board, for drawing its ghost. */
fun spotOf(move: CatanMove?): Spot = when (move) {
is BuildRoad -> Spot(edge = move.edge)
is BuildSettlement -> Spot(vertex = move.vertex)
is BuildCity -> Spot(vertex = move.vertex)
is BuildWall -> Spot(vertex = move.vertex)
is HireKnight -> Spot(vertex = move.vertex)
else -> Spot()
}
// --- Board ---------------------------------------------------------------
fun targets(
@@ -9,6 +9,15 @@ import androidx.compose.foundation.layout.Row
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.horizontalScroll
import androidx.compose.foundation.background
import androidx.compose.material3.Surface
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.unit.IntSize
import org.prolibertate.games.game.catan.BuildRoad
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
@@ -22,6 +31,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -104,6 +114,18 @@ fun CatanScreen(
var armed by remember { mutableStateOf<ArmedCard?>(null) }
var showImprove by remember { mutableStateOf(false) }
var showProgress by remember { mutableStateOf(false) }
// The landscape HUD: a placement waiting for its ✓, the piece being dragged from the
// build column (and where the finger is, in window pixels), the log popup, and the board.
var staged by remember { mutableStateOf<CatanMove?>(null) }
var dragKind by remember { mutableStateOf<BuildKind?>(null) }
var dragAt by remember { mutableStateOf<Offset?>(null) }
var showLog by remember { mutableStateOf(false) }
var boardCoords by remember { mutableStateOf<LayoutCoordinates?>(null) }
val config = LocalConfiguration.current
// Landscape gets the heads-up display; portrait keeps the stacked layout.
val wide = config.screenWidthDp > config.screenHeightDp * 1.1f
val staging = wide
val dark = isSystemInDarkTheme()
val style = remember(dark) { catanStyle(dark) }
@@ -114,6 +136,7 @@ fun CatanScreen(
// settlement that is no longer pending).
LaunchedEffect(legal) {
if (building != null && building !in CatanInteraction.canBuild(legal)) building = null
if (staged != null && staged !in legal) staged = null
if (setupVertex != null && legal.none { it is SetupPlace && it.vertex == setupVertex }) {
setupVertex = null
}
@@ -128,6 +151,7 @@ fun CatanScreen(
knightMenu = null
movingKnight = null
armed = null
staged = null
}
}
@@ -143,6 +167,9 @@ fun CatanScreen(
knightMenu = null
movingKnight = null
armed = null
staged = null
dragKind = null
dragAt = null
}
val endGameAction: @Composable () -> Unit = {
@@ -192,13 +219,17 @@ fun CatanScreen(
)
val handle: (TapOutcome) -> Unit = { outcome ->
when (outcome) {
val wait = if (staging) CatanInteraction.stageable(outcome, building) else null
when {
wait != null -> staged = wait
else -> when (outcome) {
TapOutcome.Ignored -> Unit
is TapOutcome.Submit -> submit(outcome.move)
is TapOutcome.PickSetupVertex -> setupVertex = outcome.vertex
is TapOutcome.PickRobberVictim -> victimChoice = outcome
is TapOutcome.PickKnight -> knightMenu = outcome.vertex
is TapOutcome.PickFirstHex -> armed = armed?.copy(firstHex = outcome.hex)
}
}
}
@@ -210,10 +241,14 @@ fun CatanScreen(
CompositionLocalProvider(LocalCatanStyle provides style) {
ScreenScaffold(title = CatanText.TITLE, onBack = onExit, actions = endGameAction) { modifier ->
BoxWithConstraints(modifier = modifier) {
val wide = maxWidth > maxHeight * 1.1f
// Phones (either way up) get the one-button build menu.
val compactBuild = !wide || maxWidth < 600.dp
val hoverSpot = if (dragKind != null) dragSpot(boardCoords, dragAt, targets) else null
val stagedSpot = CatanInteraction.spotOf(staged)
val ghostVertex = hoverSpot?.vertex ?: stagedSpot.vertex
val ghostEdge = hoverSpot?.edge ?: stagedSpot.edge
val board: @Composable (Modifier) -> Unit = { boardModifier ->
CatanBoardView(
state = current,
@@ -233,6 +268,9 @@ fun CatanScreen(
style = style,
rolledHexes = rolledHexes,
selectedKnight = movingKnight ?: knightMenu,
stagedVertex = ghostVertex,
stagedEdge = ghostEdge,
stagedSeat = localSeat,
)
}
val players: @Composable (Boolean) -> Unit = { grid ->
@@ -265,21 +303,61 @@ fun CatanScreen(
}
if (wide) {
Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Box(Modifier.weight(1.5f).fillMaxSize(), contentAlignment = Alignment.Center) {
board(Modifier)
}
Column(
modifier = Modifier.weight(1f).fillMaxSize().verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
players(true)
if (current.isCk) CatanBarbarians(current, style = style)
promptRow()
handAndActions()
CatanLog(current, Modifier.fillMaxWidth().height(120.dp))
}
}
CatanHud(
state = current,
seats = seats,
style = style,
actions = actions,
prompt = prompt,
notice = notice,
legal = legal,
localSeat = localSeat,
targets = targets,
pointsOf = pointsOf,
staged = staged,
dragKind = dragKind,
dragAt = dragAt,
showLog = showLog,
boardCoords = boardCoords,
building = building,
onBoardCoords = { boardCoords = it },
onToggleLog = { showLog = !showLog },
onArm = { kind -> building = if (building == kind) null else kind; staged = null },
onDragStart = { kind, at ->
building = kind
staged = null
dragKind = kind
dragAt = at
},
onDrag = { dragAt = it },
onDragEnd = {
val spot = dragSpot(boardCoords, dragAt, targets)
dragKind = null
dragAt = null
val outcome = when {
spot?.vertex != null -> CatanInteraction.onVertexTap(
spot.vertex, legal, building, setupVertex,
ownKnights = if (legal.isNotEmpty()) ownKnights else emptySet(),
)
spot?.edge != null -> CatanInteraction.onEdgeTap(spot.edge, legal, building, setupVertex, armed)
else -> TapOutcome.Ignored
}
handle(outcome)
},
onConfirm = { staged?.let(submit) },
onCancelStaged = { staged = null; building = null },
onRoll = { submit(RollDice()) },
onEndTurn = { legal.firstOrNull { m -> m is EndTurn }?.let(submit) },
onEndFreeRoads = { legal.firstOrNull { m -> m is EndFreeRoads }?.let(submit) },
onEndSmith = { legal.firstOrNull { m -> m is EndSmith }?.let(submit) },
onRepickSetup = { setupVertex = null },
onBuyCard = { legal.firstOrNull { m -> m is BuyDevCard }?.let(submit) },
onPlayCard = { showCards = true },
onTrade = { showTrade = true },
onImprove = { showImprove = true },
onProgress = { showProgress = true },
board = { m -> board(m) },
)
} else {
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
players(false)
@@ -291,6 +369,39 @@ fun CatanScreen(
handAndActions()
}
}
// The trade board slides up over the bottom of the island (base game; Cities &
// Knights trades goods and still uses its dialogs).
val offerToMe = current.pendingTrade?.takeIf {
current.phase == CatanPhase.TRADE_PENDING && it.asking == localSeat && !current.isCk
}
val boardsAllowed = abandoned == null && !confirmingEnd && current.phase != CatanPhase.GAME_OVER
if (boardsAllowed && offerToMe != null) {
CatanTradeReplyBoard(
state = current,
seat = localSeat,
offer = offerToMe,
seats = seats,
canAccept = CatanInteraction.tradeResponses(legal).first,
onAccept = { submit(AcceptTrade) },
onDecline = { submit(DeclineTrade) },
onCounter = submit,
modifier = Modifier.align(Alignment.BottomCenter),
style = style,
)
} else if (boardsAllowed && showTrade && !current.isCk) {
CatanTradeBoard(
state = current,
seat = localSeat,
seats = seats,
legal = legal,
onBank = submit,
onOffer = submit,
onClose = { showTrade = false },
modifier = Modifier.align(Alignment.BottomCenter),
style = style,
)
}
}
CatanDialogHost(
@@ -432,7 +543,6 @@ private fun CatanDialogHost(
onCancelCards: () -> Unit,
onCancelTrade: () -> Unit,
) {
val offer = state.pendingTrade
val goodsOffer = state.ck?.goodsOffer
when {
abandoned != null -> CatanAbandonedDialog(abandoned, onExit, onStay)
@@ -473,18 +583,6 @@ private fun CatanDialogHost(
onNoDeal = { onSubmit(DismissCounters) },
)
state.phase == CatanPhase.TRADE_PENDING && offer != null && offer.asking == localSeat ->
CatanTradeResponseDialog(
state = state,
seat = localSeat,
offer = offer,
seats = seats,
canAccept = CatanInteraction.tradeResponses(legal).first,
onAccept = { onSubmit(AcceptTrade) },
onDecline = { onSubmit(DeclineTrade) },
onCounter = onSubmit,
)
state.phase == CatanPhase.TRADE_COUNTERS && state.turn == localSeat && legal.isNotEmpty() ->
CatanCounterChoiceDialog(
counters = CatanInteraction.counterChoices(state, legal),
@@ -541,17 +639,6 @@ private fun CatanDialogHost(
onOffer = onSubmit,
onCancel = onCancelTrade,
)
showTrade ->
CatanTradeDialog(
state = state,
seat = localSeat,
seats = seats,
legal = legal,
onBank = onSubmit,
onOffer = onSubmit,
onCancel = onCancelTrade,
)
}
}
@@ -612,3 +699,11 @@ private fun ChoreDialog(
else -> Unit
}
}
/** The board spot under a dragged finger ([at], in window pixels), or null off the board. */
private fun dragSpot(coords: LayoutCoordinates?, at: Offset?, targets: BoardTargets): CatanInteraction.Spot? {
if (coords == null || at == null || !coords.isAttached) return null
val local = coords.windowToLocal(at)
val layout = CatanGeometry.fit(coords.size.width.toFloat(), coords.size.height.toFloat())
return CatanInteraction.snap(layout, targets, Pt(local.x, local.y), layout.radius * 1.2f)
}
@@ -139,6 +139,28 @@ object CatanText {
const val YOU_GIVE = "You give"
const val YOU_GET = "You get"
const val SEND_OFFER = "Send offer"
// --- Trade board and HUD -----------------------------------------------------
const val TAB_PLAYER = "Player"
const val TAB_BANK = "Bank"
const val YOU_RECEIVE_ROW = "You get"
const val YOU_GIVE_ROW = "You give"
const val TRADE_HINT_PLAYER = "Tap cards to build an offer. Tap a player to address it to them alone."
const val TRADE_HINT_BANK = "Tap what you give, then what you want."
const val TRADE_HINT_BANK_EMPTY = "You do not hold enough of any one resource to trade with the bank."
const val TRADE_HINT_COUNTER = "Change the terms, then send them back."
const val HIDE = "Hide"
const val DENY = "Deny"
const val CONFIRM_BUILD = "Place"
const val CANCEL_BUILD = "Cancel"
const val DRAG_HINT = "Drag a piece onto the board"
const val LOG_BUTTON = "Log"
const val CARDS_SHORT = "Cards"
const val BUY_SHORT = "Buy"
const val ROLL_SHORT = "Roll"
const val END_SHORT = "End"
const val DONE_SHORT = "Done"
const val LOG_TITLE = "Table log"
const val NO_LOG = "Nothing has happened yet."
const val END_GAME = "End game"
const val LOOK_AT_BOARD = "Look at the board"
@@ -0,0 +1,415 @@
package org.prolibertate.games.ui.game.catan
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
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.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.prolibertate.games.game.catan.CatanMove
import org.prolibertate.games.game.catan.CatanState
import org.prolibertate.games.game.catan.CounterTrade
import org.prolibertate.games.game.catan.MaritimeTrade
import org.prolibertate.games.game.catan.OfferTrade
import org.prolibertate.games.game.catan.Resource
import org.prolibertate.games.game.catan.TradeOffer
/*
* The trade board: the 2D version of Catan Universe's trade panel (see
* docs/RE-catan-universe.md). One panel slides up over the bottom of the board:
*
* header [Player | Bank] hint rival buttons
* top row the cards you GET, tinted with the other side's colour
* arrows
* bottom row the cards you GIVE, tinted with your colour
* right ▾ hide ✕ deny ✓ accept
*
* Like every Katan panel it decides nothing: what may be offered is asked of
* [CatanInteraction], and what a bank trade costs comes from the engine's legal list.
*/
private enum class TradeMode { PLAYER, BANK }
private fun Map<Resource, Int>.cards(): List<Resource> =
Resource.entries.flatMap { r -> List(this[r] ?: 0) { r } }
/** The panel for starting a trade: with the bank, or an offer to one rival or everyone. */
@Composable
fun CatanTradeBoard(
state: CatanState,
seat: Int,
seats: CatanSeats,
legal: List<CatanMove>,
onBank: (MaritimeTrade) -> Unit,
onOffer: (OfferTrade) -> Unit,
onClose: () -> Unit,
modifier: Modifier = Modifier,
style: CatanStyle = LocalCatanStyle.current,
) {
var mode by remember { mutableStateOf(TradeMode.BANK) }
var to by remember { mutableStateOf<Int?>(null) }
var give by remember { mutableStateOf(emptyMap<Resource, Int>()) }
var get by remember { mutableStateOf(emptyMap<Resource, Int>()) }
var bankGive by remember { mutableStateOf<Resource?>(null) }
var bankGet by remember { mutableStateOf<Resource?>(null) }
val bank = CatanInteraction.bankTrades(legal)
val giveCards = give.cards()
val getCards = get.cards()
val canSend = CatanInteraction.offerAllowed(state, seat, to, giveCards, getCards)
val bankMove = bankGive?.let { g -> bankGet?.takeIf { it in bank[g].orEmpty() }?.let { MaritimeTrade(g, it) } }
val rivals = (0 until state.playerCount).filter { it != seat }
val headerRight: @Composable () -> Unit = {
if (mode == TradeMode.PLAYER) {
RivalButtons(rivals, to, style, seats) { to = if (to == it) null else it }
}
}
TradeBoardFrame(
modifier = modifier,
topTint = if (mode == TradeMode.PLAYER && to != null) style.seats[to!!] else Color.Gray,
bottomTint = style.seats[seat],
header = {
TradeTab(CatanText.TAB_BANK, mode == TradeMode.BANK) { mode = TradeMode.BANK }
TradeTab(CatanText.TAB_PLAYER, mode == TradeMode.PLAYER) { mode = TradeMode.PLAYER }
},
headline = when {
mode == TradeMode.PLAYER -> CatanText.TRADE_HINT_PLAYER
bank.isEmpty() -> CatanText.TRADE_HINT_BANK_EMPTY
else -> CatanText.TRADE_HINT_BANK
},
headerRight = headerRight,
topLabel = CatanText.YOU_RECEIVE_ROW,
bottomLabel = CatanText.YOU_GIVE_ROW,
topRow = {
if (mode == TradeMode.PLAYER) {
TradeCardRow(
counts = get,
limit = { 9 },
onChange = { get = it },
)
} else {
val choices = bankGive?.let { bank[it] }.orEmpty().toSet()
TradeCardRow(
counts = bankGet?.let { mapOf(it to 1) } ?: emptyMap(),
limit = { if (it in choices) 1 else 0 },
enabledOf = { it in choices },
onChange = {},
pick = { bankGet = if (bankGet == it) null else it },
)
}
},
bottomRow = {
if (mode == TradeMode.PLAYER) {
TradeCardRow(
counts = give,
limit = { state.resources[seat][it] ?: 0 },
onChange = { give = it },
)
} else {
TradeCardRow(
counts = bank.keys.associateWith { CatanInteraction.bankRatio(state, seat, it) },
limit = { 0 },
enabledOf = { it in bank },
selected = bankGive,
caption = { r -> if (r in bank) "${CatanInteraction.bankRatio(state, seat, r)} : 1" else " " },
onChange = {},
pick = { r ->
bankGive = if (bankGive == r) null else r
bankGet = null
},
)
}
},
acceptEnabled = if (mode == TradeMode.PLAYER) canSend else bankMove != null,
onAccept = {
if (mode == TradeMode.PLAYER) {
onOffer(OfferTrade(to, giveCards, getCards))
} else {
bankMove?.let(onBank)
}
},
denyEnabled = true,
onDeny = {
give = emptyMap()
get = emptyMap()
bankGive = null
bankGet = null
to = null
},
onHide = onClose,
)
}
/**
* The panel for an offer made to the local player: the same layout, read-only. ✓ accepts,
* ✕ declines, and *Counter…* turns the rows editable, pre-filled with the mirror of the offer.
*/
@Composable
fun CatanTradeReplyBoard(
state: CatanState,
seat: Int,
offer: TradeOffer,
seats: CatanSeats,
canAccept: Boolean,
onAccept: () -> Unit,
onDecline: () -> Unit,
onCounter: (CounterTrade) -> Unit,
modifier: Modifier = Modifier,
style: CatanStyle = LocalCatanStyle.current,
) {
var countering by remember(offer) { mutableStateOf(false) }
var give by remember(offer) { mutableStateOf(offer.get.groupingBy { it }.eachCount()) }
var get by remember(offer) { mutableStateOf(offer.give.groupingBy { it }.eachCount()) }
val counter = CounterTrade(give.cards(), get.cards())
TradeBoardFrame(
modifier = modifier,
topTint = style.seats[offer.by],
bottomTint = style.seats[seat],
header = {
TradeTab(CatanText.COUNTER, countering) { countering = !countering }
},
headline = if (countering) {
CatanText.TRADE_HINT_COUNTER
} else {
CatanText.tradeSummary(
by = seats.label(offer.by),
to = if (offer.to == null) "the table" else "you",
give = offer.give,
get = offer.get,
)
},
headerRight = {},
topLabel = CatanText.YOU_RECEIVE_ROW,
bottomLabel = CatanText.YOU_GIVE_ROW,
topRow = {
if (countering) {
TradeCardRow(get, limit = { state.resources[offer.by][it] ?: 0 }, onChange = { get = it })
} else {
TradeCardRow(offer.give.groupingBy { it }.eachCount(), limit = { 0 }, onChange = {})
}
},
bottomRow = {
if (countering) {
TradeCardRow(give, limit = { state.resources[seat][it] ?: 0 }, onChange = { give = it })
} else {
TradeCardRow(offer.get.groupingBy { it }.eachCount(), limit = { 0 }, onChange = {})
}
},
acceptEnabled = if (countering) {
CatanInteraction.counterAllowed(state, seat, counter.give, counter.get)
} else {
canAccept
},
onAccept = { if (countering) onCounter(counter) else onAccept() },
denyEnabled = true,
onDeny = onDecline,
onHide = null,
)
}
// ---------------------------------------------------------------------------
// Pieces
// ---------------------------------------------------------------------------
@Composable
private fun TradeBoardFrame(
topTint: Color,
bottomTint: Color,
header: @Composable () -> Unit,
headline: String,
headerRight: @Composable () -> Unit,
topLabel: String,
bottomLabel: String,
topRow: @Composable () -> Unit,
bottomRow: @Composable () -> Unit,
acceptEnabled: Boolean,
onAccept: () -> Unit,
denyEnabled: Boolean,
onDeny: () -> Unit,
onHide: (() -> Unit)?,
modifier: Modifier = Modifier,
) {
Surface(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.97f),
tonalElevation = 6.dp,
shadowElevation = 12.dp,
) {
Row(Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
header()
Text(
headline,
style = MaterialTheme.typography.bodySmall,
// Two lines, always: the panel keeps its height whatever is said.
minLines = 2,
maxLines = 2,
modifier = Modifier.weight(1f),
)
headerRight()
}
HorizontalDivider()
TintedRow(topTint, topLabel, topRow)
Text("⇣ ⇣ ⇣", modifier = Modifier.align(Alignment.CenterHorizontally), fontSize = 14.sp)
TintedRow(bottomTint, bottomLabel, bottomRow)
}
Column(
modifier = Modifier.padding(start = 10.dp).width(72.dp),
verticalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally,
) {
// Always three slots, so the column does not shift when there is nothing to hide.
if (onHide != null) {
HudRoundButton("▾", caption = CatanText.HIDE, diameter = 48.dp, onClick = onHide)
} else {
Box(Modifier.size(48.dp))
}
HudRoundButton("✕", caption = CatanText.DENY, enabled = denyEnabled, tint = Color(0xFFC62828), diameter = 52.dp, onClick = onDeny)
HudRoundButton("✓", caption = CatanText.ACCEPT, enabled = acceptEnabled, tint = Color(0xFF2E7D32), diameter = 60.dp, onClick = onAccept)
}
}
}
}
@Composable
private fun TintedRow(tint: Color, label: String, content: @Composable () -> Unit) {
val shape = RoundedCornerShape(12.dp)
Column(
Modifier
.fillMaxWidth()
.clip(shape)
.background(tint.copy(alpha = 0.18f))
.border(1.dp, tint.copy(alpha = 0.5f), shape)
.padding(horizontal = 8.dp, vertical = 4.dp),
) {
Text(label, fontSize = 10.sp, fontWeight = FontWeight.Bold)
Box(Modifier.horizontalScroll(rememberScrollState())) { content() }
}
}
/**
* Five resource cards in a row. Tapping a card adds one (up to [limit]); the "−" under it
* takes one back. A bank row passes [pick] instead, and a tap selects the card.
*/
@Composable
private fun TradeCardRow(
counts: Map<Resource, Int>,
limit: (Resource) -> Int,
onChange: (Map<Resource, Int>) -> Unit,
enabledOf: (Resource) -> Boolean = { true },
selected: Resource? = null,
caption: ((Resource) -> String)? = null,
pick: ((Resource) -> Unit)? = null,
) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Resource.entries.forEach { resource ->
val n = counts[resource] ?: 0
Column(horizontalAlignment = Alignment.CenterHorizontally) {
ResourceTile(
resource = resource,
count = n,
selected = selected == resource,
enabled = enabledOf(resource) && (pick != null || n > 0 || limit(resource) > 0),
onClick = {
if (pick != null) {
pick(resource)
} else if (n < limit(resource)) {
onChange(counts + (resource to n + 1))
}
},
)
if (caption != null) {
Text(caption(resource), fontSize = 11.sp, fontWeight = FontWeight.Bold)
} else {
// Always laid out so the row never changes height.
Text(
"−",
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier
.alpha(if (n > 0 && pick == null && limit(resource) >= 0) 1f else 0f)
.clickable(enabled = n > 0 && pick == null) {
onChange(counts + (resource to n - 1))
}
.padding(horizontal = 14.dp),
)
}
}
}
}
}
@Composable
private fun TradeTab(label: String, selected: Boolean, onClick: () -> Unit) {
val shape = RoundedCornerShape(50)
Text(
label,
fontSize = 13.sp,
fontWeight = FontWeight.Bold,
color = if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface,
modifier = Modifier
.clip(shape)
.background(if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant)
.clickable(onClick = onClick)
.padding(horizontal = 14.dp, vertical = 8.dp),
)
}
/** One round button per rival: tap to address the offer to them alone, tap again for everyone. */
@Composable
private fun RivalButtons(
rivals: List<Int>,
chosen: Int?,
style: CatanStyle,
seats: CatanSeats,
onTap: (Int) -> Unit,
) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
rivals.forEach { rival ->
val picked = chosen == rival
Box(
Modifier
.size(40.dp)
.clip(CircleShape)
.background(style.seats[rival])
.border(if (picked) 4.dp else 1.dp, if (picked) Color.White else Color(0x55000000), CircleShape)
.clickable { onTap(rival) },
contentAlignment = Alignment.Center,
) {
Text(seats.label(rival).take(1).uppercase(), color = Color.White, fontWeight = FontWeight.Bold, fontSize = 14.sp)
}
}
}
}
@@ -0,0 +1,56 @@
package org.prolibertate.games.ui.game.catan
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Test
import org.prolibertate.games.game.catan.BuildRoad
import org.prolibertate.games.game.catan.BuildSettlement
import org.prolibertate.games.game.catan.EndTurn
/** The pure half of the landscape HUD: drag-to-build snapping and the staged ✓ / ✗ placement. */
class CatanHudTest {
@Test
fun `a build tap is staged, not sent, but only while a build mode is armed`() {
val road = BuildRoad(0)
val outcome = TapOutcome.Submit(road)
assertEquals(road, CatanInteraction.stageable(outcome, BuildKind.ROAD))
assertNull(CatanInteraction.stageable(outcome, null))
// Ending the turn is never a placement.
assertNull(CatanInteraction.stageable(TapOutcome.Submit(EndTurn), BuildKind.ROAD))
assertNull(CatanInteraction.stageable(TapOutcome.Ignored, BuildKind.ROAD))
}
@Test
fun `the ghost of a staged move sits on its crossing or its edge`() {
assertEquals(CatanInteraction.Spot(vertex = 4), CatanInteraction.spotOf(BuildSettlement(4)))
assertEquals(CatanInteraction.Spot(edge = 9), CatanInteraction.spotOf(BuildRoad(9)))
assertEquals(CatanInteraction.Spot(), CatanInteraction.spotOf(null))
}
@Test
fun `a dragged finger snaps to the nearest lit crossing within reach, and off the board to nothing`() {
val layout = CatanGeometry.fit(800f, 600f)
val lit = setOf(3, 10, 21)
val targets = BoardTargets(vertices = lit)
val target = layout.vertex(10)
// A little off the crossing, well inside reach.
val near = Pt(target.x + layout.radius * 0.3f, target.y - layout.radius * 0.2f)
assertEquals(CatanInteraction.Spot(vertex = 10), CatanInteraction.snap(layout, targets, near, layout.radius * 1.2f))
// Far from every lit crossing.
assertNull(CatanInteraction.snap(layout, targets, Pt(-500f, -500f), layout.radius * 1.2f))
}
@Test
fun `with edges lit and no crossings, the drop snaps to an edge`() {
val layout = CatanGeometry.fit(800f, 600f)
val targets = BoardTargets(edges = setOf(5, 6))
val mid = layout.edgeMidpoint(6)
assertNotNull(CatanInteraction.snap(layout, targets, mid, layout.radius))
assertEquals(CatanInteraction.Spot(edge = 6), CatanInteraction.snap(layout, targets, mid, layout.radius))
}
}
+73
View File
@@ -0,0 +1,73 @@
# Catan Universe 2.7.5 — what the reverse engineering showed
Source: `Catan+Universe_2.7.5_APKPure.xapk` (Unity, IL2CPP). Method: the Unity scenes
(`level3` is the match scene) were parsed with UnityPy for the uGUI hierarchy (names,
anchors, sizes, which objects start hidden), and `global-metadata.dat` was searched for
class, method and game-action names. Code bodies were not decompiled; behaviour below
that is not visible in a hierarchy is marked *inferred*.
The game is **landscape-only**; the canvas is laid out against a 1920×1080 reference.
## Match HUD (`InGameUI_mobile`)
| Region | Contents |
| --- | --- |
| Top | `PlayerFrames`: one frame per player (avatar, colour, VP, card count, timer), a "shuffle stats" button at the right |
| Bottom bar | `ResourceContainer`, centred (anchors 25–75 % of the width, 90 px tall): one entry per resource (Lumber, Brick, Wool, Grain, Ore; Cloth, Coins, Paper for C&K; Fish, Feather, Coca for other expansions, hidden otherwise) |
| Bottom right | `BtnTurn` (140 px, roll / end turn), `BtnTrade` immediately to its left (counter-offer and "blocked" badge variants), `BtnAddDevCard`, a chat/log `BubblePanel` |
| Left edge | `Drag&DropBuild`: a column of piece icons. **The player drags a piece from it onto the board** (`Drag&DropBuildRemove` is the drop target that cancels) |
| Mid right | `ConfirmButtonGroup`: a ✓ (`CatanConfirmBuildButton`) over a ✗ (`CatanCancelBuildButton`). A drop only *previews*; the ✓ commits |
| Board | `CandidateFactory` makes highlight markers for every legal spot (corner, edge, face); `QuickBuildTrigger{Corner,Edge,Face}` are hit zones for a one-tap/hold build; a candidate ticker shows the one-line instruction |
| Centre | Played-card panel, progress-card chooser, discard panel (`CardPanel` slides up over the bottom bar with a header) |
Gestures (`CatanGestureHandler`, `BaseGesture`, `TapHoldHandler`): one finger pans the
board, two-finger pinch zooms (`GetPinchZoomMagnitude`, `AutomaticZoom`), tap selects a
candidate, hold opens quick-build. Camera is a fixed-pitch table view.
## Trade board (`TradeBoard_mobile`)
A full-width panel that slides up over the bottom of the screen (`Animator`, collapsible):
```
┌────────────────────────────────────────────────────────────┬──────┐
│ [Player] [Bank] headline / description ☐ Block trade │ P1 P2│ header
├────────────────────────────────────────────────────────────┤ P3 P4│ (135 px)
│ OPPONENT row — cards you get (tinted by the opponent's │ │
│ colour) │ Hide │
│ ⇣⇣⇣ arrows (or the bank ratio chips 4:1 3:1 2:1) │ Deny │
│ PLAYER row — cards you give (tinted by your colour) │Accept│
└────────────────────────────────────────────────────────────┴──────┘
```
* **Header.** A *Player / Bank* toggle (extra toggles for the Inka 3:2 / 2:1 rules). A
**Block trade** checkbox refuses all incoming offers. One round button per rival
(`Player1…5`, with a selected border) picks whom the offer is addressed to; none
selected means everyone.
* **Content.** Two rows of resource cards separated by arrows. The top row is what you
*want*, the bottom row what you *give*. Tapping a card in a row adds one; tapping a
selected card removes one (*inferred from `AddFieldToTradePayment`/`ApplyCancelTradeResource`*).
In Bank mode a ratio row (`CardRatios`) shows the best rate per resource (harbours).
* **Right column (180 px).** Hide/collapse, **Deny** (✗), **Accept** (✓). When you
are the one receiving an offer, ✓ accepts and the chip-icon variant `Icon_CounterOffer`
turns the ✓ into a *counter-offer* send. A circular turn timer wraps the ✓.
* **Incoming offers** (`IncomingTradeContainer`) show in the same panel; the offerer's
colour tints the opponent row. Counter-offers land back in the proposer's panel with
an `AcceptCounterOfferButtonImage`.
## Seafarers game actions (from the metadata)
`CatanSeafarer_*_GameAction` names show what the mode adds to the base game:
* **Ships:** `BuildShip`, `MoveShip` (only the ship at the open end of a route, not on
the turn it was built), `BuildFreeRoad`; roads and ships share `AvailableCandidatesRoadAndShip`.
* **Pirate** alongside the robber: `RobberPirat_Move`, `RobberPirat_SelectPlayer`;
ships cannot be built or moved past a pirate-blocked edge.
* **Gold fields:** `ChooseGoldfieldResource` (a hex that pays any resource of choice).
* **Fog islands:** `Check_FogField`, `DistributeFogField` (hidden hex revealed when a
road/ship reaches it, the player takes the resource), `Shuffle_Islands`, `Shuffle_TileValueList`.
* **Treasure islands / Oasis / Great Canal** are Catan Universe scenario extras and are
**not** part of the physical Seafarers rules; they are out of scope here.
* **Setup:** `StartPhase_BuildSettlement`, `StartPhase_BuildRoadShip`, `PlaceValueChip`
(number tokens placed per scenario), `Shuffle_Harbors`.
* **Island bonus:** special victory points for the first settlement on a new island
(`AllIslandIds`, `CatanIslandState`).
+23
View File
@@ -22,6 +22,8 @@ truth, so the UI can only ever offer a move the rules would accept.
| **Board drawing** | `CatanBoardView.kt` | The `Canvas`: tiles, tokens, ports, robber, roads, pieces, highlights | Change how a piece is drawn (art, shapes, animation) |
| **Panels** | `CatanPanels.kt` | Resource tiles, hand, player cards, dice, action bar, log | Restyle or rearrange widgets |
| **Dialogs** | `CatanDialogs.kt` | Discard, robber victim, play card, trade, trade reply, game over | Change a dialog's flow or look |
| **HUD** | `CatanHud.kt` | The landscape controls: build column, ✓ / ✗, round turn / trade buttons, pinch-pan board | Move or restyle a landscape control |
| **Trade board** | `CatanTradeBoard.kt` | The base-game trade panel (offer, bank, reply) | Change how trades are composed or answered |
| **Screen** | `CatanScreen.kt` | Wiring to `MatchController`, portrait/landscape layout, which dialog shows | Change the layout or add a new element |
| **Previews** | `CatanPreviews.kt` | Android Studio previews of real positions | Iterate on the look without a match |
@@ -169,3 +171,24 @@ Not done yet, in rough priority order:
4. A timer or cancel button on offers (the engine has neither).
5. A board that shows the *last* move (a faint highlight of what just changed).
6. Landscape polish on large tablets; the right-hand panel scrolls but is plain.
## Landscape HUD and trade board (from Catan Universe)
Landscape screens use the control scheme of Catan Universe's match view, redrawn in 2D
(what was learned, and from where: [RE-catan-universe.md](RE-catan-universe.md)). Portrait keeps the stacked layout.
* **Drag to build.** The left column holds Road / Settlement / City (and Wall / Knight in
Cities & Knights). Drag a piece onto the island: lit spots snap under the finger
(`CatanInteraction.snap`), release over one to **stage** it, then press **✓** to place or **✕**
to cancel (`CatanInteraction.stageable`, `spotOf`). A tap on a piece still arms the board for
tapping a spot. Setup placements, free roads and chores are not staged: they commit on tap.
* **Pinch and pan** the island (`ZoomPanBox`). The board is drawn through a graphics layer, so
taps and drops arrive in board coordinates; hit-testing is unchanged.
* **Round buttons**, bottom right: the big one is roll / end turn / done (whatever the engine
currently allows); ⇄ is trade; the small pair is buy / play card (Improve / Cards in C&K).
* **Trade board** (`CatanTradeBoard`, base game): a panel over the bottom of the island with a
Bank / Player toggle, a round button per rival to address an offer to one player, a *get* row on
top, a *give* row below, and ▾ hide / ✕ clear / ✓ send on the right. Bank mode shows each resource's
ratio under the *give* card. An offer made to you opens the same panel read-only: ✓ accepts,
✕ declines, *Counter…* makes the rows editable.
Cities & Knights goods trades and the counter-offer chooser still use dialogs.