Compare commits

...
Author SHA1 Message Date
Claude 8990dde363 Yahtzee: two-column score card with larger text, scrolls sideways when needed
CI / Build and check (pull_request) Successful in 8m45s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0177JQTtqM7EzJyXYiZYEJTX
2026-10-27 03:50:51 +00:00
devadmin c2b21a07f5 Merge pull request 'Yahtzee: pip dice, confirm zero scores; text size setting' (#21) from yahtzee-improvements into dev
CI / Build and check (push) Successful in 8m43s
CI / Deploy to Cloudflare Pages (push) Successful in 29s
2026-10-26 20:03:26 -06:00
Claude e98afbfd8d Yahtzee: pip dice, confirm zero scores; add text size setting
CI / Build and check (pull_request) Successful in 8m45s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0177JQTtqM7EzJyXYiZYEJTX
2026-10-27 01:52:15 +00:00
devadmin 2c90de66d1 Merge pull request 'Release Katan and Meowdoku to production' (#19) from fix/release-katan-meowdoku into dev
CI / Build and check (push) Successful in 8m50s
CI / Build and check (pull_request) Successful in 8m50s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
CI / Deploy to Cloudflare Pages (push) Successful in 34s
2026-10-26 12:23:15 -06:00
Claude f93eba88e5 Release Katan and Meowdoku: open the production gate
CI / Build and check (pull_request) Successful in 8m43s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01S8evMuUxrft6TQj6NdAoqQ
2026-10-26 18:14:12 +00:00
devadmin 36ab518309 Merge pull request 'Scorekeeper: name players at setup; refocus after a round' (#17) from feature/scorekeeper-setup-names into dev
CI / Build and check (push) Successful in 9m36s
CI / Build and check (pull_request) Successful in 9m34s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
CI / Deploy to Cloudflare Pages (push) Successful in 36s
2026-10-26 10:35:39 -06:00
Claude 77cc6c5a05 Scorekeeper: ask for each player's name at setup; refocus first cell after a round
CI / Build and check (pull_request) Successful in 9m40s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01S8evMuUxrft6TQj6NdAoqQ
2026-10-26 04:24:25 +00:00
devadmin 166f85734b Merge pull request 'Scorekeeper: remember the last 10 player names' (#16) from feature/scorekeeper-memory into dev
CI / Build and check (push) Successful in 8m44s
CI / Deploy to Cloudflare Pages (push) Successful in 30s
2026-10-25 21:34:23 -06:00
Claude 25e38c4bf8 Scorekeeper: remember the last 10 player names and offer them when naming a column
CI / Build and check (pull_request) Successful in 8m50s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01S8evMuUxrft6TQj6NdAoqQ
2026-10-26 03:16:53 +00:00
devadmin 6bf7522042 Merge pull request 'Meowdoku: rules, AI and screen refinements' (#15) from meow2 into dev
CI / Build and check (push) Successful in 20m52s
CI / Deploy to Cloudflare Pages (push) Successful in 33s
2026-10-25 20:29:24 -06:00
Claude 7942adeaf7 Meowdoku: rules, AI and screen refinements
CI / Build and check (pull_request) Successful in 10m1s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01S8evMuUxrft6TQj6NdAoqQ
2026-10-26 01:14:45 +00:00
devadmin 488f600f15 Merge pull request 'Katan: Catan Universe style HUD and trade board (2D)' (#13) from CatanImprovements into dev
CI / Build and check (push) Successful in 8m49s
CI / Deploy to Cloudflare Pages (push) Successful in 31s
2026-10-24 11:13:07 -06:00
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
27 changed files with 1943 additions and 249 deletions
@@ -362,12 +362,7 @@ object GameCatalog {
maxPlayers = 4,
teamBased = false,
blurb = "Trade, build, and settle the island, in the base game or Cities & Knights.",
// Engine and screen exist but have not been verified on two real
// devices yet. Dev builds can open it as a *Preview*; a production
// build does not list it. Flip `available` on, and `devPreview`
// off, once two-device play has been verified.
available = false,
devPreview = true,
available = true,
),
GameDescriptor(
id = MEOWDOKU,
@@ -378,8 +373,7 @@ object GameCatalog {
teamBased = false,
blurb = "One cat in every row, column and colour, and no two touching. " +
"Wrong taps cost a heart; the board only ever accepts a cat that belongs.",
available = false,
devPreview = true,
available = true,
),
)
@@ -25,19 +25,12 @@ object MeowdokuAi : GameAi<MeowdokuState, MeowdokuMove> {
require(legal.isNotEmpty()) { "No move available" }
val places = legal.filterIsInstance<PlaceCat>().associateBy { it.square }
// The first cat still missing. A pencil cross in front of it is the
// player's own note being wrong, so erase it; a hint spends one token
// and reveals the square regardless.
// The first cat still missing. A cross on its square is no obstacle:
// crossed squares take attempts like anything else, and a correct one
// replaces the cross.
for (row in 0 until state.side) {
val missing = state.cats.none { it.row == row }
if (!missing) continue
val square = Square(row, state.catCols[row])
places[square]?.let { return it }
if (square in state.marks) {
if (TakeHint in legal && state.hintsLeft > 0) return TakeHint
legal.filterIsInstance<TogglePencil>()
.firstOrNull { it.square == square }
?.let { return it }
if (state.cats.none { it.row == row }) {
places[Square(row, state.catCols[row])]?.let { return it }
}
}
@@ -4,11 +4,11 @@ import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
// Meowdoku hides N cats in an N by N board: exactly one cat in every row, every
// column and every colour, and no two cats sharing even a corner. The player
// taps the squares they believe hold a cat; the board only ever accepts a
// correct tap, so a cat that lands is a cat that was always going to be there.
// A wrong tap costs a heart and crosses the square over, which is what makes
// pencil marks a choice rather than a necessity.
// column and every colour, and no two cats sharing even a corner. A tap crosses
// a square over; a second tap on that square asks the board whether a cat
// lives there. A right answer lands the cat, a wrong one costs a heart and
// keeps the cross — so the crossing over is both the player's note and the
// first half of every guess, and the hearts are what stop it being a lottery.
/** One square of the board. */
@Serializable
@@ -83,23 +83,25 @@ data class MeowdokuState(
sealed interface MeowdokuMove
/**
* Taps a square expecting a cat.
* The second tap of a double tap: the cat attempt.
*
* One move rather than two, because that is what the player is doing: the board
* decides whether the tap found a cat or cost a heart. A square that is already
* crossed over has to be un-crossed first — the tap that made the cross is
* exactly the tap this would repeat.
* decides whether the tap found a cat or cost a heart. The square may carry the
* cross its own first tap just drew — that cross is part of the double tap,
* not a decision against it — and a correct attempt replaces it.
*/
@Serializable
@SerialName("cat")
data class PlaceCat(val square: Square) : MeowdokuMove
/**
* Draws or erases a pencil cross on a square with no cat on it.
* Draws or erases a cross on a square with no cat on it.
*
* Free, and it cannot be undone by the board: crosses are the player's notes,
* not the puzzle's. A cross on a square that actually holds a cat costs nothing
* to erase, which is why placing a cat never consults the marks.
* This is the first tap of a tap pair and every cell of a drag stroke, so it is
* the note-taking half of the scheme: free, unrewarded, and the board cannot
* lose a heart over it. A cross drawn here can still be tapped through with a
* cat attempt — the note is the player's, and only they get to change their
* mind about it.
*/
@Serializable
@SerialName("pencil")
@@ -69,10 +69,14 @@ object MeowdokuRules : GameRules<MeowdokuState, MeowdokuMove> {
/**
* Every tap the board would take.
*
* Cats go on squares that are neither cat nor cross; pencils work any
* square without a cat on it, adding or erasing a cross; a hint is offered
* while there are hints left and cats still missing. The list is written
* row by row, which is also the order the hint engine uses.
* The scheme is the original's: the first tap crosses a square over and
* opens a short window, and a second tap in that window is the cat attempt
* — so an attempt is offered on every square without a cat, crossed or
* not. The cross the attempt replaces is exactly the cross the first tap
* drew. Cats, once placed, are never tapped again: they are not a guess
* you take back.
*
* The list is written row by row, which is also the order the hint uses.
*/
override fun legalMoves(state: MeowdokuState, seat: Int): List<MeowdokuMove> {
if (seat != SOLO_SEAT || isFinished(state)) return emptyList()
@@ -82,7 +86,7 @@ object MeowdokuRules : GameRules<MeowdokuState, MeowdokuMove> {
for (col in 0 until state.side) {
val square = Square(row, col)
if (square in state.cats) continue
if (square !in state.marks) cats += PlaceCat(square)
cats += PlaceCat(square)
pencils += TogglePencil(square)
}
}
@@ -105,17 +109,18 @@ object MeowdokuRules : GameRules<MeowdokuState, MeowdokuMove> {
}
/**
* The tap that is the whole game: found a cat, or lost a heart.
* The second tap of a double tap: found a cat, or lost a heart.
*
* The square was already crossed? Then the tap is not offered at all — see
* [legalMoves] — so a player who pencilled over the truth has to notice and
* erase before tapping. That is the one way a board can be lost that the
* board itself did not do.
* A correct tap clears the cross the first tap left behind — the cat
* replaces it. A wrong one leaves the cross where it is and spends a
* heart, because a wrong square crossed twice is the same mistake twice,
* and the board remembers it either way.
*/
private fun tap(state: MeowdokuState, square: Square): MeowdokuState {
if (state.isCatSquare(square)) {
val next = state.copy(
cats = state.cats + square,
marks = state.marks - square,
lastSquare = square,
lastWrong = false,
)
@@ -0,0 +1,17 @@
package org.prolibertate.games.score
/** The short list of names offered when a column is being named. */
object RecentNames {
const val LIMIT = 10
/**
* [name] moved to the front of [names], trimmed, with any earlier copy
* (compared ignoring case) dropped and the list cut to [LIMIT]. A blank name
* leaves the list as it was.
*/
fun with(names: List<String>, name: String): List<String> {
val typed = name.trim()
if (typed.isEmpty()) return names
return (listOf(typed) + names.filterNot { it.equals(typed, ignoreCase = true) }).take(LIMIT)
}
}
@@ -29,6 +29,7 @@ class ScorekeeperRepository(private val context: Context) {
private object Keys {
val SHEET = stringPreferencesKey("sheet")
val RECENT_NAMES = stringPreferencesKey("recent_names")
}
// Unknown keys are ignored so a sheet written by a newer build can still be
@@ -45,6 +46,24 @@ class ScorekeeperRepository(private val context: Context) {
?: ScoreSheet()
}
/** The names last used on a sheet, most recent first, for the rename dialog. */
val recentNames: Flow<List<String>> = context.scorekeeperDataStore.data.map { prefs ->
prefs[Keys.RECENT_NAMES]
?.let { stored ->
runCatching { json.decodeFromString<List<String>>(stored) }.getOrNull()
}
?: emptyList()
}
suspend fun rememberName(name: String) {
context.scorekeeperDataStore.edit { prefs ->
val current = prefs[Keys.RECENT_NAMES]
?.let { runCatching { json.decodeFromString<List<String>>(it) }.getOrNull() }
.orEmpty()
prefs[Keys.RECENT_NAMES] = json.encodeToString(RecentNames.with(current, name))
}
}
suspend fun save(sheet: ScoreSheet) {
context.scorekeeperDataStore.edit { it[Keys.SHEET] = json.encodeToString(sheet) }
}
@@ -31,8 +31,18 @@ sealed interface SheetOp {
/** Sets a fresh sheet up for [count] players, scored by round or by tally. */
@Serializable
@SerialName("start")
data class StartSheet(val count: Int, val mode: ScoringMode = ScoringMode.ROUNDS) : SheetOp {
override fun applyTo(sheet: ScoreSheet): ScoreSheet = ScoreSheet.of(count, mode)
data class StartSheet(
val count: Int,
val mode: ScoringMode = ScoringMode.ROUNDS,
/** Names for the first seats, in order; a seat past the end stays unnamed. */
val names: List<String> = emptyList(),
) : SheetOp {
override fun applyTo(sheet: ScoreSheet): ScoreSheet {
val seated = ScoreSheet.of(count, mode)
return seated.players.foldIndexed(seated) { index, acc, player ->
names.getOrNull(index)?.let { acc.renamed(player.id, it) } ?: acc
}
}
}
/** Rubs everything out. */
@@ -24,6 +24,8 @@ data class Settings(
val soundEnabled: Boolean = true,
val animationSpeed: Float = 1.0f,
val checkForUpdatesOnLaunch: Boolean = true,
/** Multiplies the system font size across the whole app. */
val textScale: Float = 1.0f,
/**
* Exactly what the user has typed, which may be blank while they are
* mid-edit. Nothing substitutes a default into this value: doing so meant
@@ -46,6 +48,8 @@ data class Settings(
companion object {
const val MIN_SPEED = 0.5f
const val MAX_SPEED = 2.0f
const val MIN_TEXT_SCALE = 0.8f
const val MAX_TEXT_SCALE = 1.6f
const val DEFAULT_PLAYER_NAME = "Player"
}
}
@@ -56,6 +60,7 @@ class SettingsRepository(private val context: Context) {
val SOUND = booleanPreferencesKey("sound_enabled")
val SPEED = floatPreferencesKey("animation_speed")
val UPDATE_ON_LAUNCH = booleanPreferencesKey("check_updates_on_launch")
val TEXT_SCALE = floatPreferencesKey("text_scale")
val NAME = stringPreferencesKey("player_name")
// There is deliberately no update-channel key. The channel is a
// property of the installed APK, not a preference — an install that
@@ -69,6 +74,8 @@ class SettingsRepository(private val context: Context) {
soundEnabled = prefs[Keys.SOUND] ?: true,
animationSpeed = prefs[Keys.SPEED] ?: 1.0f,
checkForUpdatesOnLaunch = prefs[Keys.UPDATE_ON_LAUNCH] ?: true,
textScale = (prefs[Keys.TEXT_SCALE] ?: 1.0f)
.coerceIn(Settings.MIN_TEXT_SCALE, Settings.MAX_TEXT_SCALE),
playerName = prefs[Keys.NAME] ?: "",
)
}
@@ -83,6 +90,12 @@ class SettingsRepository(private val context: Context) {
}
}
suspend fun setTextScale(scale: Float) {
context.dataStore.edit {
it[Keys.TEXT_SCALE] = scale.coerceIn(Settings.MIN_TEXT_SCALE, Settings.MAX_TEXT_SCALE)
}
}
suspend fun setCheckForUpdatesOnLaunch(enabled: Boolean) {
context.dataStore.edit { it[Keys.UPDATE_ON_LAUNCH] = enabled }
}
@@ -20,6 +20,8 @@ import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Density
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
@@ -139,7 +141,14 @@ fun AppRoot(env: AppEnv) {
// Card motion reads its speed from here rather than from a parameter.
// Every card screen animates, most of them several layers deep, and the
// alternative was passing one unchanging number down through all of it.
CompositionLocalProvider(LocalCardSpeed provides settings.animationSpeed) {
val density = LocalDensity.current
val scaledDensity = remember(density, settings.textScale) {
Density(density.density, density.fontScale * settings.textScale)
}
CompositionLocalProvider(
LocalCardSpeed provides settings.animationSpeed,
LocalDensity provides scaledDensity,
) {
AppContent(
env = env,
settings = settings,
@@ -36,6 +36,7 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -114,6 +115,8 @@ fun ScorekeeperScreen(
local = repository.sheet.first()
loaded = true
}
val recentNames by repository.recentNames.collectAsState(initial = emptyList())
val nameUsed: (String) -> Unit = { name -> scope.launch { repository.rememberName(name) } }
val shared by session.state.collectAsState()
val role = shared.role
@@ -220,7 +223,7 @@ fun ScorekeeperScreen(
role == Role.GUEST -> Column(modifier = modifier) {
ConnectionBanner(shared = shared, onSync = { session.sync() })
Scoring(modifier = Modifier.weight(1f), sheet = sheet, onOp = change)
Scoring(Modifier.weight(1f), sheet, change, recentNames, nameUsed)
}
joining && role == Role.NONE ->
@@ -228,11 +231,16 @@ fun ScorekeeperScreen(
!sheet.started -> SetupPane(
modifier = modifier,
onStart = { count, mode -> change(StartSheet(count, mode)) },
recentNames = recentNames,
onStart = { count, mode, names ->
change(StartSheet(count, mode, names))
// Last first, so the list reads in seating order.
names.reversed().forEach(nameUsed)
},
onJoin = { joining = true },
)
else -> Scoring(modifier = modifier, sheet = sheet, onOp = change)
else -> Scoring(modifier, sheet, change, recentNames, nameUsed)
}
}
@@ -289,9 +297,46 @@ fun ScorekeeperScreen(
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun SetupPane(modifier: Modifier, onStart: (Int, ScoringMode) -> Unit, onJoin: () -> Unit) {
private fun SetupPane(
modifier: Modifier,
recentNames: List<String>,
onStart: (Int, ScoringMode, List<String>) -> Unit,
onJoin: () -> Unit,
) {
var count by remember { mutableStateOf(ScoreSheet.DEFAULT_PLAYERS) }
var mode by remember { mutableStateOf(ScoringMode.ROUNDS) }
// -1 while the options are showing; otherwise the seat being named.
var naming by remember { mutableStateOf(-1) }
val names = remember { mutableStateListOf<String>() }
if (naming >= 0) {
NamePane(
modifier = modifier,
seat = naming,
count = count,
name = names.getOrElse(naming) { "" },
// Offered once only: two players on one sheet with the same name
// are indistinguishable.
suggestions = recentNames.filter { recent ->
names.withIndex().none { (seat, taken) ->
seat != naming && taken.trim().equals(recent.trim(), ignoreCase = true)
}
},
onName = { typed ->
while (names.size <= naming) names.add("")
names[naming] = typed
},
onBack = { naming -= 1 },
onNext = {
if (naming + 1 < count) {
naming += 1
} else {
onStart(count, mode, List(count) { names.getOrElse(it) { "" }.trim() })
}
},
)
return
}
Column(
modifier = modifier.verticalScroll(rememberScrollState()),
@@ -339,33 +384,98 @@ private fun SetupPane(modifier: Modifier, onStart: (Int, ScoringMode) -> Unit, o
}
}
Text(
text = "Names come next: tap a column head to name it. Players can be " +
"added, removed or dragged into a different order at any point " +
"without losing anybody's score.",
text = "You will be asked for each name next, or skip them and tap a " +
"column head later. Players can be added, removed or dragged into " +
"a different order at any point without losing anybody's score.",
style = MaterialTheme.typography.bodySmall,
)
PrimaryAction(text = "Start scoring") { onStart(count, mode) }
PrimaryAction(text = "Next: name the players") { naming = 0 }
OutlinedButton(onClick = onJoin, modifier = Modifier.fillMaxWidth()) {
Text("Join a shared sheet nearby")
}
}
}
/** One seat's name, asked for on its own; a blank leaves the seat as "Player N". */
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun NamePane(
modifier: Modifier,
seat: Int,
count: Int,
name: String,
suggestions: List<String>,
onName: (String) -> Unit,
onBack: () -> Unit,
onNext: () -> Unit,
) {
val focus = remember { FocusRequester() }
LaunchedEffect(seat) { focus.requestFocus() }
val last = seat + 1 >= count
Column(
modifier = modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text("Player ${seat + 1} of $count", style = MaterialTheme.typography.titleMedium)
OutlinedTextField(
value = name,
onValueChange = onName,
placeholder = { Text("Player ${seat + 1}") },
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = if (last) ImeAction.Done else ImeAction.Next),
keyboardActions = KeyboardActions(onNext = { onNext() }, onDone = { onNext() }),
modifier = Modifier.fillMaxWidth().focusRequester(focus),
)
if (suggestions.isNotEmpty()) {
Text("Recent names", style = MaterialTheme.typography.labelMedium)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
suggestions.forEach { recent ->
FilterChip(
selected = recent.equals(name.trim(), ignoreCase = true),
onClick = { onName(recent) },
label = { Text(recent) },
)
}
}
}
PrimaryAction(text = if (last) "Start scoring" else "Next") { onNext() }
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(onClick = onBack) { Text("Back") }
TextButton(onClick = { onName(""); onNext() }) { Text(if (last) "Skip and start" else "Skip") }
}
}
}
// ---------------------------------------------------------------------------
// The sheet: a column per player, a row per round, totals ruled off at the foot
// ---------------------------------------------------------------------------
@Composable
private fun Scoring(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit) {
private fun Scoring(
modifier: Modifier,
sheet: ScoreSheet,
onOp: (SheetOp) -> Unit,
recentNames: List<String>,
onNameUsed: (String) -> Unit,
) {
if (sheet.mode == ScoringMode.TALLY) {
TallySheet(modifier = modifier, sheet = sheet, onOp = onOp)
TallySheet(modifier, sheet, onOp, recentNames, onNameUsed)
} else {
Sheet(modifier = modifier, sheet = sheet, onOp = onOp)
Sheet(modifier, sheet, onOp, recentNames, onNameUsed)
}
}
@Composable
private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit) {
private fun Sheet(
modifier: Modifier,
sheet: ScoreSheet,
onOp: (SheetOp) -> Unit,
recentNames: List<String>,
onNameUsed: (String) -> Unit,
) {
// One scroll state shared by the head, the rows and the totals, so all three
// move together: a column and the name over it must never come apart. Every
// row is built the same way and is therefore exactly the same width, which
@@ -384,6 +494,9 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit
var lastTyped by remember { mutableStateOf(-1) }
// The cell to put the cursor in when a round is opened by tapping it.
var focusPlayer by remember { mutableStateOf(-1) }
// Bumped when a round is finished, to put the cursor back in the first cell
// of the row that is now being filled in.
var nextRoundFocus by remember { mutableStateOf(0) }
val editing = if (correcting >= 0) correction else pending
@@ -513,7 +626,7 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit
)
},
trailing = { EmptyCell(width = ADD_COLUMN, height = ROW_HEIGHT) },
) { _, player ->
) { index, player ->
if (correcting >= 0) {
// A round is being corrected above; what is typed here
// is kept, but only one row is edited at a time.
@@ -521,6 +634,8 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit
} else {
PointsField(
text = pending[player.id].orEmpty(),
autoFocus = index == 0 && nextRoundFocus > 0,
focusRequest = nextRoundFocus,
onText = {
pending[player.id] = it
lastTyped = player.id
@@ -563,6 +678,7 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit
onOp(AddRound(amountsAsPoints(pending)))
pending.clear()
}
nextRoundFocus += 1
lastTyped = -1
focusPlayer = -1
},
@@ -587,6 +703,8 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit
RenameDialog(
sheet = sheet,
player = renamed,
recentNames = recentNames,
onNameUsed = onNameUsed,
onName = { onOp(RenamePlayer(renamed.id, it)) },
onRemove = {
onOp(RemovePlayer(renamed.id))
@@ -607,7 +725,13 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit
* sheet, so tapping one in the list takes it back off.
*/
@Composable
private fun TallySheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit) {
private fun TallySheet(
modifier: Modifier,
sheet: ScoreSheet,
onOp: (SheetOp) -> Unit,
recentNames: List<String>,
onNameUsed: (String) -> Unit,
) {
var renaming by remember { mutableStateOf(-1) }
Column(modifier = modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -676,6 +800,8 @@ private fun TallySheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) ->
RenameDialog(
sheet = sheet,
player = renamed,
recentNames = recentNames,
onNameUsed = onNameUsed,
onName = { onOp(RenamePlayer(renamed.id, it)) },
onRemove = {
onOp(RemovePlayer(renamed.id))
@@ -961,9 +1087,14 @@ private fun PointsCell(text: String, muted: Boolean = false, onClick: (() -> Uni
/** A cell being typed into. Blank means nothing entered; a typed 0 is kept and shown. */
@Composable
private fun PointsField(text: String, onText: (String) -> Unit, autoFocus: Boolean = false) {
private fun PointsField(
text: String,
onText: (String) -> Unit,
autoFocus: Boolean = false,
focusRequest: Int = 0,
) {
val focus = remember { FocusRequester() }
LaunchedEffect(autoFocus) { if (autoFocus) focus.requestFocus() }
LaunchedEffect(autoFocus, focusRequest) { if (autoFocus) focus.requestFocus() }
BasicTextField(
value = text,
onValueChange = { onText(sanitiseAmount(it)) },
@@ -1064,16 +1195,27 @@ private fun ActionBar(
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun RenameDialog(
sheet: ScoreSheet,
player: ScorePlayer,
recentNames: List<String>,
onNameUsed: (String) -> Unit,
onName: (String) -> Unit,
onRemove: () -> Unit,
onDismiss: () -> Unit,
) {
// Closing the dialog is what files the name away, so a name is remembered
// once rather than once per keystroke.
val finish = {
onNameUsed(player.name)
onDismiss()
}
val taken = sheet.players.filter { it.id != player.id }.map { it.name.trim().lowercase() }
val suggestions = recentNames.filter { it.trim().lowercase() !in taken }
AlertDialog(
onDismissRequest = onDismiss,
onDismissRequest = finish,
title = { Text("Name this column") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -1084,6 +1226,21 @@ private fun RenameDialog(
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
if (suggestions.isNotEmpty()) {
Text("Recent names", style = MaterialTheme.typography.labelMedium)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
suggestions.forEach { name ->
FilterChip(
selected = name.equals(player.name.trim(), ignoreCase = true),
onClick = { onName(name) },
label = { Text(name) },
)
}
}
}
TextButton(
onClick = onRemove,
// The last two columns stay: a sheet needs somebody on it.
@@ -1091,7 +1248,7 @@ private fun RenameDialog(
) { Text("Remove this player and their score") }
}
},
confirmButton = { TextButton(onClick = onDismiss) { Text("Done") } },
confirmButton = { TextButton(onClick = finish) { Text("Done") } },
)
}
@@ -24,6 +24,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
import org.prolibertate.games.settings.Settings
import org.prolibertate.games.update.Release
import org.prolibertate.games.update.UpdateChannel
@@ -83,6 +84,26 @@ fun SettingsScreen(
Divider()
Column {
Text("Text size", fontWeight = FontWeight.Bold)
Text(
text = "${(settings.textScale * 100).roundToInt()}% — " +
"scales text throughout the app, on top of your phone's setting",
style = MaterialTheme.typography.bodySmall,
)
Slider(
value = settings.textScale,
onValueChange = { scale ->
scope.launch { env.settingsRepository.setTextScale(scale) }
},
valueRange = Settings.MIN_TEXT_SCALE..Settings.MAX_TEXT_SCALE,
// 10% increments across 80%-160%.
steps = 7,
)
}
Divider()
Column {
Text("Your name", fontWeight = FontWeight.Bold)
Text(
@@ -2,7 +2,6 @@ package org.prolibertate.games.ui.game
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
@@ -12,6 +11,8 @@ import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.FilterChip
@@ -23,10 +24,13 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
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.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -41,9 +45,20 @@ import org.prolibertate.games.net.MatchController
import org.prolibertate.games.ui.LocalEndGameHandle
import org.prolibertate.games.ui.ScreenScaffold
import org.prolibertate.games.ui.theme.CardRed
import kotlin.math.abs
import kotlin.math.roundToInt
private const val TITLE = "Meowdoku"
/**
* How long the second tap of a double tap may take to arrive.
*
* 350 ms, the number the original game ships: long enough that a deliberate
* one-two still reads as one question, short enough that nobody pairs two
* taps they meant for two separate squares.
*/
private const val DOUBLE_TAP_WINDOW_MILLIS = 350L
/**
* The region colours, one per cat the board can hold.
*
@@ -66,16 +81,14 @@ private val RegionColours = listOf(
Color(0xFFE3E7C1), // lime
)
/** What a tap does. The original game has a pencil tool too, and this is it. */
private enum class TapMode { CATS, PENCIL }
/**
* The puzzle board.
* The puzzle board, tapped the way the original's is.
*
* Cells are laid out by weight inside a square frame, which keeps the grid
* exactly as wide as it is tall on any screen, and scales the glyphs with the
* cell rather than the font setting: a cat is half a square no matter how big
* the square is.
* One tap crosses a square over; a second tap on the same square within
* [DOUBLE_TAP_WINDOW_MILLIS] asks the board whether a cat lives there. Drag
* strokes paint or erase crosses along the line the finger takes, which is
* how a row gets crossed out in one gesture rather than eight taps. Cats are
* permanent: tapping one does nothing, and a stroke skips over it.
*/
@Composable
fun MeowdokuScreen(
@@ -87,7 +100,6 @@ fun MeowdokuScreen(
val legal by controller.legalMoves.collectAsState()
val abandoned by controller.abandoned.collectAsState()
var mode by remember { mutableStateOf(TapMode.CATS) }
var confirmingEnd by remember { mutableStateOf(false) }
val endGameAction: @Composable () -> Unit = {
@@ -118,8 +130,8 @@ fun MeowdokuScreen(
current.solved -> "Found every cat" +
if (current.wrongCats == 0) ", not one tap wasted." else ", with ${current.wrongCats} wrong taps."
current.lost -> "Out of hearts, with ${current.side - current.catsFound} cats still hiding."
mode == TapMode.CATS -> "${current.catsFound} of ${current.side} cats found. Tap where a cat lives."
else -> "Tap to cross a square over, or to erase a cross."
else -> "${current.catsFound} of ${current.side} cats found. " +
"Tap to cross a square, tap it again for a cat."
},
fontWeight = FontWeight.Bold,
)
@@ -131,37 +143,20 @@ fun MeowdokuScreen(
Board(
state = current,
places = places,
pencils = pencils,
onTap = { square ->
when {
mode == TapMode.CATS && square in places ->
controller.submit(PlaceCat(square))
mode == TapMode.PENCIL && square in pencils ->
controller.submit(TogglePencil(square))
}
},
// Moves are refused rather than hidden: an attempt or a note
// the board would reject (a tap on a cat, mostly) has to die
// here, in the gesture, where the player can be doing nothing
// else the same instant.
onNote = { square -> if (square in pencils) controller.submit(TogglePencil(square)) },
onAttempt = { square -> if (square in places) controller.submit(PlaceCat(square)) },
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (TakeHint in legal) {
FilterChip(
selected = mode == TapMode.CATS,
onClick = { mode = TapMode.CATS },
label = { Text("Cats") },
selected = false,
onClick = { controller.submit(TakeHint) },
label = { Text("Hint") },
)
FilterChip(
selected = mode == TapMode.PENCIL,
onClick = { mode = TapMode.PENCIL },
label = { Text("Pencil") },
)
if (TakeHint in legal) {
FilterChip(
selected = false,
onClick = { controller.submit(TakeHint) },
label = { Text("Hint") },
)
}
}
if (finished) {
@@ -181,30 +176,110 @@ fun MeowdokuScreen(
}
}
/**
* The board as one gesture surface.
*
* Cells are drawn by weight inside a square frame, but input is not per-cell:
* a stroke is one continuous gesture, so the pointer works in board
* coordinates and maps positions to squares itself. The cell list renders
* underneath, and the live state is read through [rememberUpdatedState]
* because a gesture outlives the recompositions it lands in.
*/
@Composable
private fun Board(
state: MeowdokuState,
places: Set<Square>,
pencils: Set<Square>,
onTap: (Square) -> Unit,
onNote: (Square) -> Unit,
onAttempt: (Square) -> Unit,
) {
val side = state.side
val current by rememberUpdatedState(state)
val note by rememberUpdatedState(onNote)
val attempt by rememberUpdatedState(onAttempt)
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
// One cell's edge, so a cat is always about half a square whatever
// board was dealt.
val cell = maxWidth / state.side
Column(modifier = Modifier.aspectRatio(1f)) {
for (row in 0 until state.side) {
Row(modifier = Modifier.weight(1f)) {
for (col in 0 until state.side) {
val square = Square(row, col)
CellView(
square = square,
state = state,
tappable = square in places || square in pencils,
glyphSize = (cell.value * 0.55f).sp,
modifier = Modifier.weight(1f),
onTap = { onTap(square) },
)
val cell = maxWidth / side
Box(
modifier = Modifier
.aspectRatio(1f)
.pointerInput(side) {
val cellPx = size.width.toFloat() / side
var lastTap: Square? = null
var lastTapAt = 0L
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false)
val board = current
val start = squareAt(down.position, cellPx, side)
?: return@awaitEachGesture
if (start in board.cats) return@awaitEachGesture
val now = System.currentTimeMillis()
if (start == lastTap && now - lastTapAt < DOUBLE_TAP_WINDOW_MILLIS) {
// The second tap of a pair: the cat attempt, and
// no note — the cross it lands on was drawn by
// this pair's first tap, and the board replaces it.
lastTap = null
attempt(start)
do {
val event = awaitPointerEvent()
val change = event.changes.firstOrNull { it.id == down.id }
change?.consume()
} while (change != null && change.pressed)
return@awaitEachGesture
}
// The first tap: a note, and the stroke that may follow
// paints the same note everywhere it goes.
lastTap = start
lastTapAt = now
val erasing = start in board.marks
note(start)
val marks = board.marks.toMutableSet()
marks.flip(start)
var previous = start
while (true) {
val event = awaitPointerEvent()
val change = event.changes.firstOrNull { it.id == down.id }
if (change == null || !change.pressed) break
change.consume()
val here = squareAt(change.position, cellPx, side) ?: continue
if (here == previous) continue
for (square in path(previous, here)) {
if (square in board.cats) continue
// `marks` is the stroke's own mirror: the base
// board plus every note this stroke has
// already submitted. The state flow lags a
// frame behind submissions, and a stroke that
// crosses a square twice must leave it at the
// stroke's target, not flip it back.
//
// Erasing means "flip everything still
// crossed"; painting means "flip everything
// still blank" — which is the same test read
// two ways: flip while marked == erasing.
if ((square in marks) == erasing) {
note(square)
marks.flip(square)
}
}
previous = here
}
}
}
) {
Column(modifier = Modifier.aspectRatio(1f)) {
for (row in 0 until side) {
Row(modifier = Modifier.weight(1f)) {
for (col in 0 until side) {
CellView(
square = Square(row, col),
state = current,
glyphSize = (cell.value * 0.55f).sp,
modifier = Modifier.weight(1f),
)
}
}
}
}
@@ -212,14 +287,46 @@ private fun Board(
}
}
/** Which square a pointer position is over, or null if it has left the board. */
private fun squareAt(position: Offset, cellPx: Float, side: Int): Square? {
val col = (position.x / cellPx).toInt()
val row = (position.y / cellPx).toInt()
if (row !in 0 until side || col !in 0 until side) return null
return Square(row, col)
}
/** Add the square, or drop it if it was already there — the stroke's own toggle. */
private fun MutableSet<Square>.flip(square: Square) {
if (!add(square)) remove(square)
}
/**
* The squares between two consecutive pointer reads, endpoints not repeated.
*
* A fast stroke publishes fewer positions than it crosses cells, so the line
* between two reads is walked in whole-cell steps. [path] returns at most one
* square per cell crossed, in order, which is what lets a stroke cross a row
* in one drag without missing the squares between two events.
*/
private fun path(from: Square, to: Square): List<Square> {
val dr = to.row - from.row
val dc = to.col - from.col
val steps = maxOf(abs(dr), abs(dc))
if (steps <= 1) return listOf(to)
return (1..steps).map { i ->
Square(
from.row + (dr * i.toFloat() / steps).roundToInt(),
from.col + (dc * i.toFloat() / steps).roundToInt(),
)
}
}
@Composable
private fun CellView(
square: Square,
state: MeowdokuState,
tappable: Boolean,
glyphSize: TextUnit,
modifier: Modifier,
onTap: () -> Unit,
) {
val isCat = square in state.cats
val isMark = square in state.marks
@@ -231,8 +338,7 @@ private fun CellView(
.background(RegionColours[state.regionOf(square) % RegionColours.size])
.then(
if (justWrong) Modifier.border(3.dp, CardRed) else Modifier
)
.clickable(enabled = tappable) { onTap() },
),
contentAlignment = Alignment.Center,
) {
when {
@@ -6,7 +6,11 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -30,6 +34,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import org.prolibertate.games.game.yahtzee.DICE_COUNT
@@ -69,6 +74,7 @@ fun YahtzeeScreen(
val kept = remember { mutableStateListOf<Int>() }
var confirmingEnd by remember { mutableStateOf(false) }
var confirmingZero by remember { mutableStateOf<YahtzeeCategory?>(null) }
val endGameAction: @Composable () -> Unit = {
TextButton(onClick = { confirmingEnd = true }) { Text("End game") }
@@ -138,8 +144,14 @@ fun YahtzeeScreen(
localSeat = localSeat,
writable = if (yours) writable else emptySet(),
onWrite = {
controller.submit(ScoreIn(it))
kept.clear()
// A zero is a box spent for nothing, and one mis-tap on a
// dense card is all it takes, so ask first.
if (scoreOf(it, current.dice) == 0) {
confirmingZero = it
} else {
controller.submit(ScoreIn(it))
kept.clear()
}
},
)
@@ -150,9 +162,31 @@ fun YahtzeeScreen(
abandoned?.let { LeftCardDialog(it, onExit, controller::dismissAbandoned) }
if (confirmingEnd) EndCardDialog(onExit) { confirmingEnd = false }
confirmingZero?.let { category ->
ZeroScoreDialog(
category = category,
onConfirm = {
confirmingZero = null
controller.submit(ScoreIn(category))
kept.clear()
},
onCancel = { confirmingZero = null },
)
}
}
}
@Composable
private fun ZeroScoreDialog(category: YahtzeeCategory, onConfirm: () -> Unit, onCancel: () -> Unit) {
AlertDialog(
onDismissRequest = onCancel,
title = { Text("Score zero?") },
text = { Text("These dice are worth 0 in ${category.label}. Write the zero there?") },
confirmButton = { TextButton(onClick = onConfirm) { Text("Score 0") } },
dismissButton = { TextButton(onClick = onCancel) { Text("Choose another") } },
)
}
@Composable
private fun DiceRow(
dice: List<Int>,
@@ -190,12 +224,29 @@ private fun Die(face: Int?, held: Boolean, enabled: Boolean, onClick: () -> Unit
.clickable(enabled = enabled) { onClick() },
contentAlignment = Alignment.Center,
) {
Text(
text = face?.toString() ?: "·",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = WallaceRed,
)
if (face != null) {
Canvas(modifier = Modifier.size(48.dp)) {
val radius = size.minDimension * 0.08f
pipPositions(face).forEach { (x, y) ->
drawCircle(WallaceRed, radius, Offset(size.width * x, size.height * y))
}
}
}
}
}
/** Pip centres as fractions of the die face, in the usual casino layout. */
private fun pipPositions(face: Int): List<Pair<Float, Float>> {
val lo = 0.28f
val mid = 0.5f
val hi = 0.72f
return when (face) {
1 -> listOf(mid to mid)
2 -> listOf(lo to lo, hi to hi)
3 -> listOf(lo to lo, mid to mid, hi to hi)
4 -> listOf(lo to lo, hi to lo, lo to hi, hi to hi)
5 -> listOf(lo to lo, hi to lo, mid to mid, lo to hi, hi to hi)
else -> listOf(lo to lo, hi to lo, lo to mid, hi to mid, lo to hi, hi to hi)
}
}
@@ -206,28 +257,61 @@ private fun ScoreCard(
writable: Set<YahtzeeCategory>,
onWrite: (YahtzeeCategory) -> Unit,
) {
Column(modifier = Modifier.fillMaxWidth()) {
Row(modifier = Modifier.fillMaxWidth()) {
Text("", modifier = Modifier.weight(2f), style = MaterialTheme.typography.labelSmall)
state.cards.indices.forEach { seat ->
Text(
text = if (seat == localSeat) "You" else "S$seat",
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
)
}
}
YahtzeeSection.entries.forEach { section ->
YahtzeeCategory.entries.filter { it.section == section }.forEach { category ->
CardRow(state, localSeat, category, writable, onWrite)
}
if (section == YahtzeeSection.UPPER) {
// Upper boxes and bonus on the left, lower boxes and total on the right, so
// each half is wide enough for text that can actually be read. Each half
// has a floor on its width; if two of them do not fit (many players, large
// text) the pair scrolls sideways instead of squeezing.
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
val needed = (CARD_LABEL_WIDTH + CARD_SEAT_WIDTH * state.cards.size).dp
val half = maxOf((maxWidth - CARD_GAP.dp) / 2, needed)
Row(
modifier = Modifier.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(CARD_GAP.dp),
) {
Column(modifier = Modifier.width(half)) {
CardHeader(state, localSeat)
CardSection(YahtzeeSection.UPPER, state, localSeat, writable, onWrite)
TotalRow("Bonus at 63", state.cards.map { it.upperBonus })
}
Column(modifier = Modifier.width(half)) {
CardHeader(state, localSeat)
CardSection(YahtzeeSection.LOWER, state, localSeat, writable, onWrite)
Divider()
TotalRow("Total", state.cards.indices.map { state.totalFor(it) }, bold = true)
}
}
Divider()
TotalRow("Total", state.cards.indices.map { state.totalFor(it) }, bold = true)
}
}
private const val CARD_LABEL_WIDTH = 120
private const val CARD_SEAT_WIDTH = 56
private const val CARD_GAP = 12
@Composable
private fun CardHeader(state: YahtzeeState, localSeat: Int) {
Row(modifier = Modifier.fillMaxWidth()) {
Text("", modifier = Modifier.weight(2f))
state.cards.indices.forEach { seat ->
Text(
text = if (seat == localSeat) "You" else "S$seat",
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
)
}
}
}
@Composable
private fun CardSection(
section: YahtzeeSection,
state: YahtzeeState,
localSeat: Int,
writable: Set<YahtzeeCategory>,
onWrite: (YahtzeeCategory) -> Unit,
) {
YahtzeeCategory.entries.filter { it.section == section }.forEach { category ->
CardRow(state, localSeat, category, writable, onWrite)
}
}
@@ -244,12 +328,12 @@ private fun CardRow(
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = open) { onWrite(category) }
.padding(vertical = 2.dp),
.padding(vertical = 5.dp),
) {
Text(
text = category.label,
modifier = Modifier.weight(2f),
style = MaterialTheme.typography.bodySmall,
style = MaterialTheme.typography.bodyMedium,
fontWeight = if (open) FontWeight.Bold else FontWeight.Normal,
)
state.cards.forEachIndexed { seat, card ->
@@ -263,7 +347,7 @@ private fun CardRow(
else -> "—"
},
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodySmall,
style = MaterialTheme.typography.bodyMedium,
color = if (written == null && open) WallaceRed else MaterialTheme.colorScheme.onSurface,
)
}
@@ -272,18 +356,18 @@ private fun CardRow(
@Composable
private fun TotalRow(label: String, values: List<Int>, bold: Boolean = false) {
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp)) {
Text(
text = label,
modifier = Modifier.weight(2f),
style = MaterialTheme.typography.bodySmall,
style = MaterialTheme.typography.bodyMedium,
fontWeight = if (bold) FontWeight.Bold else FontWeight.Normal,
)
values.forEach {
Text(
text = "$it",
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodySmall,
style = MaterialTheme.typography.bodyMedium,
fontWeight = if (bold) FontWeight.Bold else FontWeight.Normal,
)
}
@@ -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)
}
}
}
}
@@ -106,42 +106,33 @@ class CatalogTest {
}
@Test
fun `every reference game in the catalogue can be played`() {
// Every game the app ships is playable. The exceptions are the games
// whose engine and screen exist but have not been verified on a real
// device yet: they are marked as previews so a production build keeps
// them out while a dev one can open them. `available = false` is what
// keeps an unverified game out of a production build while leaving it
// visible on a dev one.
assertEquals(
listOf(GameCatalog.CATAN, GameCatalog.MEOWDOKU),
GameCatalog.all.filterNot { it.available }.map { it.id },
)
// It is also the only difference the two channels see in a menu.
// Katan sits under Board and Meowdoku under Solitaire, so the two
// channels part ways in different sections.
val hiddenFromBoard = GameCatalog.inMenu(GameMenu.BOARD, includeComingSoon = true) -
GameCatalog.inMenu(GameMenu.BOARD, includeComingSoon = false)
assertEquals(listOf(GameCatalog.byId(GameCatalog.CATAN)!!), hiddenFromBoard)
val hiddenFromSolitaire = GameCatalog.inMenu(GameMenu.SOLITAIRE, includeComingSoon = true) -
GameCatalog.inMenu(GameMenu.SOLITAIRE, includeComingSoon = false)
assertEquals(listOf(GameCatalog.byId(GameCatalog.MEOWDOKU)!!), hiddenFromSolitaire)
fun `every game in the catalogue can be played`() {
// `available` is the release gate. Nothing is held back at the moment,
// so a production menu lists every game; a game parked behind the gate
// belongs in `devPreview`, and must say so rather than being quietly
// unavailable everywhere.
GameCatalog.all.filterNot { it.available }.forEach { game ->
assertTrue("${game.id} is unavailable but not a dev preview", game.devPreview)
}
GameMenu.entries.forEach { menu ->
val held = GameCatalog.inMenu(menu, includeComingSoon = true) -
GameCatalog.inMenu(menu, includeComingSoon = false).toSet()
held.forEach { assertTrue("${it.id} hidden from production without a preview flag", it.devPreview) }
}
}
@Test
fun `a preview game opens on a dev build and nowhere else`() {
// Katan until its two-device play is verified; Meowdoku until its board
// and hearts have been looked at on a real screen.
val preview = GameCatalog.all.filter { it.devPreview }
assertEquals(
setOf(GameCatalog.CATAN, GameCatalog.MEOWDOKU),
preview.map { it.id }.toSet(),
)
preview.forEach { game ->
GameCatalog.all.filter { it.devPreview }.forEach { game ->
assertFalse("${game.id}: preview is for games that are not yet available", game.available)
assertTrue("${game.id} should open on a dev build", game.playableIn(devBuild = true))
assertFalse("${game.id} must not open in production", game.playableIn(devBuild = false))
}
// The two games that were once previews now open everywhere.
listOf(GameCatalog.CATAN, GameCatalog.MEOWDOKU).forEach { id ->
val game = GameCatalog.byId(id)!!
assertTrue("$id should open in production", game.playableIn(devBuild = false))
}
// An ordinary finished game opens everywhere.
val chess = GameCatalog.byId(GameCatalog.CHESS)!!
assertTrue(chess.playableIn(devBuild = true) && chess.playableIn(devBuild = false))
@@ -186,6 +177,8 @@ class CatalogTest {
GameCatalog.MASTERMIND,
GameCatalog.BACKGAMMON,
GameCatalog.PIRATES,
GameCatalog.CATAN,
GameCatalog.MEOWDOKU,
),
GameCatalog.playable.map { it.id }.toSet(),
)
@@ -198,17 +198,37 @@ class MeowdokuRulesTest {
}
@Test
fun `a tapped square that is crossed over is not offered`() {
fun `a crossed square still takes a cat attempt`() {
// The double tap is one gesture pair: the first tap crosses the square
// and the second asks it. The cross the pair drew cannot veto the
// question that follows it.
val state = dealt(6)
val wrongRow = 1
val wrongCol = if (state.catCols[wrongRow] == 0) 1 else 0
val marked = MeowdokuRules.applyMove(state, 0, PlaceCat(Square(wrongRow, wrongCol)))
assertThrows(IllegalArgumentException::class.java) {
MeowdokuRules.applyMove(marked, 0, PlaceCat(Square(wrongRow, wrongCol)))
}
// A pencil erases the cross and opens the tap again.
val erased = MeowdokuRules.applyMove(marked, 0, TogglePencil(Square(wrongRow, wrongCol)))
assertFalse(Square(wrongRow, wrongCol) in erased.marks)
val truth = Square(0, state.catCols[0])
val crossed = MeowdokuRules.applyMove(state, 0, TogglePencil(truth))
assertTrue(truth in crossed.marks)
assertTrue(
"the attempt is offered over its own cross",
PlaceCat(truth) in MeowdokuRules.legalMoves(crossed, 0),
)
val placed = MeowdokuRules.applyMove(crossed, 0, PlaceCat(truth))
assertTrue(truth in placed.cats)
assertFalse("the cat replaces the cross", truth in placed.marks)
assertEquals(3, placed.heartsLeft)
assertEquals(0, placed.wrongCats)
}
@Test
fun `a wrong pair keeps its cross and spends a heart`() {
val state = dealt(6)
val wrongCol = if (state.catCols[1] == 0) 1 else 0
val wrong = Square(1, wrongCol)
val crossed = MeowdokuRules.applyMove(state, 0, TogglePencil(wrong))
val judged = MeowdokuRules.applyMove(crossed, 0, PlaceCat(wrong))
assertTrue(wrong in judged.marks)
assertFalse(wrong in judged.cats)
assertEquals(2, judged.heartsLeft)
assertEquals(1, judged.wrongCats)
}
@Test
@@ -0,0 +1,30 @@
package org.prolibertate.games.score
import org.junit.Test
import org.junit.Assert.assertEquals
class RecentNamesTest {
@Test
fun newestComesFirst() {
assertEquals(listOf("Bo", "Al"), RecentNames.with(listOf("Al"), "Bo"))
}
@Test
fun repeatMovesToFrontIgnoringCase() {
assertEquals(listOf("al", "Bo"), RecentNames.with(listOf("Bo", "Al"), " al "))
}
@Test
fun blankChangesNothing() {
assertEquals(listOf("Al"), RecentNames.with(listOf("Al"), " "))
}
@Test
fun keepsOnlyTen() {
val names = (1..10).map { "n$it" }
val result = RecentNames.with(names, "new")
assertEquals(10, result.size)
assertEquals("new", result.first())
assertEquals("n9", result.last())
}
}
@@ -199,6 +199,14 @@ class ScoreSheetTest {
assertEquals(ScoringMode.ROUNDS, StartSheet(3).applyTo(ScoreSheet()).mode)
}
@Test
fun startingWithNamesSeatsThemInOrderAndLeavesTheRestUnnamed() {
val sheet = StartSheet(3, names = listOf("Ann", "")).applyTo(ScoreSheet())
assertEquals(listOf("Ann", "", ""), sheet.players.map { it.name })
assertEquals("Ann", sheet.displayName(sheet.players[0]))
assertEquals("Player 2", sheet.displayName(sheet.players[1]))
}
@Test
fun `tally entries add up per player and can be taken back`() {
val sheet = ScoreSheet.of(2, ScoringMode.TALLY)
@@ -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`).
+24 -13
View File
@@ -19,22 +19,33 @@ and it sees only the colours and the rules — never the cats it was grown from.
## Playing it
**Tap a square where a cat lives.** A correct tap lands the cat; a wrong one
costs a heart and crosses the square over, because being wrong here is
information and the board keeps it for you. Finding all N cats wins; losing the
hearts ends the board with the rest still hidden.
Controls are the original's, and they carry the game's whole psychology:
The **Pencil** toggle is for the marks you make yourself. They are free, they
cost nothing to erase, and the board does not judge them. The one trap they
carry: a square crossed over is a square you have decided against, so tapping a
cat onto it is refused until the pencil erases the note. The original game
draws crosses for you on a wrong tap for the same reason — a wrong square
crossed twice is the same mistake twice — and here the same thing happens
whether the cross came from the heart or from the pencil.
- **One tap crosses a square over.** Free, instant, erasable — the note you
make while thinking.
- **A second tap on the same square within 350 ms is the cat attempt.** The
board judges it against the answer it was dealt: correct, the cross is
replaced by a cat; wrong, the cross stays and a heart goes. This is why a
guess feels like a decision: your own note has to survive one more tap
before it becomes a bet.
- **Drag to cross a line.** The stroke paints the same note every square it
enters: start on a blank square and it crosses the row or column you drag
along; start on a crossed square and the stroke erases them instead. Cats
are skipped — placed cats are permanent.
A square you pencilled over can still be tapped for a cat, and the cross is
forgiven: the attempt replaces it if it is right. The one thing no gesture
undoes is a cat once placed.
The original ships a fifth gesture — a **draft** scratchpad mode, a tool you
buy, where crosses and cats are laid down without judgement and applied all at
once — and it is left out here with the rest of the shop: a scratchpad whose
notes cost nothing is what tapping already is.
A **Hint** spends a token and shows the cat in the first row that still lacks
one. The original sells these as tools; here they are simply counted, three per
board by default, which keeps the hint a decision rather than a reflex.
one, replacing any cross drawn over it. The original sells these as tools; here
they are simply counted, three per board by default, which keeps the hint a
decision rather than a reflex.
## The heart
+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.