Compare commits

..
Author SHA1 Message Date
Claude 4ed110e68c Production release: build and ship a non-debuggable release APK
CI / Build and check (pull_request) Successful in 12m45s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
release.yml shipped app-debug.apk, so production was debuggable. It now runs
assembleRelease, signed with the same keystore (identical certificate, so
updates still install over existing ones), with isDebuggable = false.
Minification stays off: R8 cannot be tried on a device here.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TWwLCrXa9aFVg8dZsCdLBZ
2026-11-07 20:15:24 +00:00
devadmin b818997699 Merge pull request 'End game on every card game; Resume game menu' (#27) from feature/end-game-and-resume into dev
CI / Build and check (push) Successful in 9m35s
CI / Deploy to Cloudflare Pages (push) Successful in 35s
CI / Build and check (pull_request) Successful in 9m21s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
2026-11-07 12:35:01 -06:00
Claude aeda2924f4 End-game button on every card game; Resume game menu
CI / Build and check (pull_request) Successful in 9m33s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
President, Crazy Eights, Cribbage, Euchre, Golf, Kaiser, Sequence and Wizard
gain the "End game" button and the left-the-table dialog the other games have.
The main menu's inline Continue list becomes a Resume game button opening a
screen of all saved games, each with Resume and Delete (confirmed).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TWwLCrXa9aFVg8dZsCdLBZ
2026-11-07 18:25:11 +00:00
devadmin 2e234c7f20 Merge pull request 'Katan: barbarians and improvement levels in the wide HUD' (#26) from fix/katan-landscape-hud into dev
CI / Build and check (push) Successful in 9m4s
CI / Deploy to Cloudflare Pages (push) Successful in 33s
2026-10-29 10:12:26 -06:00
Claude f4f53a93b6 Katan: show barbarian strip and improvement levels in the wide HUD
CI / Build and check (pull_request) Successful in 8m47s
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-29 16:03:31 +00:00
devadmin bce9b68ece Merge pull request 'Katan: Seafarers rule set' (#14) from Seafarers into dev
CI / Build and check (push) Successful in 9m12s
CI / Build and check (pull_request) Successful in 9m16s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
CI / Deploy to Cloudflare Pages (push) Successful in 32s
2026-10-28 21:27:34 -06:00
13 changed files with 307 additions and 39 deletions
+3 -3
View File
@@ -49,8 +49,8 @@ jobs:
- name: Set up Android SDK
uses: android-actions/setup-android@v4
- name: Build debug APK
run: ./gradlew assembleDebug --no-daemon --stacktrace
- name: Build release APK
run: ./gradlew assembleRelease --no-daemon --stacktrace
- name: Stage APK for release
id: stage
@@ -65,7 +65,7 @@ jobs:
test -n "$offset"
# Same arithmetic as build.gradle.kts, so the tag names the APK's own number.
version="v${series}.$(( ${{ github.run_number }} + offset ))"
src="app/build/outputs/apk/debug/app-debug.apk"
src="app/build/outputs/apk/release/app-release.apk"
dest="ProLibertateGames-${version}.apk"
cp "$src" "$dest"
# The same build again under a name that never changes. GitHub's
+5
View File
@@ -104,6 +104,11 @@ android {
buildTypes {
release {
// Signed with the same key as the debug builds, so a release installs
// over what is already on a device; a different key would make the
// updater's installs fail with a signature mismatch.
signingConfig = signingConfigs.getByName("debug")
isDebuggable = false
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
@@ -88,6 +88,9 @@ sealed interface Route {
data class Section(val menu: GameMenu) : Route
data object Settings : Route
data object Scorekeeper : Route
/** Every saved game, to carry on from or delete. */
data object Resume : Route
data class Setup(val gameId: String) : Route
data class Lobby(val gameId: String, val optionsJson: String, val hosting: Boolean) : Route
data class Play(
@@ -188,10 +191,8 @@ private fun AppContent(
// Dev builds show the games that are not finished yet; production
// releases list only what can actually be played.
showComingSoon = showComingSoon,
saved = savedGames(env, stack),
onResume = { saved ->
push(Route.Play(saved.gameId, saved.config, true, saved.localSeat, saved.stateJson))
},
hasSaved = savedGames(env, stack).isNotEmpty(),
onResume = { push(Route.Resume) },
onPickSection = { push(Route.Section(it)) },
onJoinGame = { push(Route.Lobby(gameId = "", optionsJson = "{}", hosting = false)) },
onScorekeeper = { push(Route.Scorekeeper) },
@@ -206,6 +207,25 @@ private fun AppContent(
onBack = pop,
)
is Route.Resume -> {
// Bumped when a game is deleted, so the list is read again.
var deletions by remember(route) { mutableIntStateOf(0) }
val saved by produceState(emptyList<SavedMatch>(), route, deletions) {
value = withContext(Dispatchers.IO) { env.saves.all() }
}
ResumeScreen(
saved = saved,
onResume = { match ->
push(Route.Play(match.gameId, match.config, true, match.localSeat, match.stateJson))
},
onDelete = { match ->
env.saves.clear(match.gameId)
deletions++
},
onBack = pop,
)
}
is Route.Settings -> SettingsScreen(
env = env,
settings = settings,
@@ -271,8 +291,8 @@ private fun AppContent(
@Composable
fun MainMenuScreen(
showComingSoon: Boolean,
saved: List<SavedMatch>,
onResume: (SavedMatch) -> Unit,
hasSaved: Boolean,
onResume: () -> Unit,
onPickSection: (GameMenu) -> Unit,
onJoinGame: () -> Unit,
onScorekeeper: () -> Unit,
@@ -322,20 +342,11 @@ fun MainMenuScreen(
)
}
// Games left half-played, most recent first, above everything
// else: coming back to the app is mostly coming back to one.
if (saved.isNotEmpty()) {
Text(
"Continue",
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.fillMaxWidth(),
)
val now = System.currentTimeMillis()
saved.forEach { match ->
val title = GameCatalog.byId(match.gameId)?.title ?: match.gameId
Button(onClick = { onResume(match) }, modifier = Modifier.fillMaxWidth()) {
Text(savedLine(title, match, now))
}
// Games left half-played, above everything else: coming back to
// the app is mostly coming back to one.
if (hasSaved) {
Button(onClick = onResume, modifier = Modifier.fillMaxWidth()) {
Text("Resume game")
}
}
@@ -0,0 +1,87 @@
package org.prolibertate.games.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import org.prolibertate.games.game.GameCatalog
import org.prolibertate.games.game.engine.SavedMatch
/**
* Every game that can be carried on from, most recently played first, each with
* a way to pick it up and a way to throw it away.
*/
@Composable
fun ResumeScreen(
saved: List<SavedMatch>,
onResume: (SavedMatch) -> Unit,
onDelete: (SavedMatch) -> Unit,
onBack: () -> Unit,
) {
var deleting by remember { mutableStateOf<SavedMatch?>(null) }
ScreenScaffold(title = "Resume game", onBack = onBack) { modifier ->
if (saved.isEmpty()) {
Text("No games to resume.", modifier = modifier)
} else {
val now = System.currentTimeMillis()
LazyColumn(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(saved, key = { it.gameId }) { match ->
val title = GameCatalog.byId(match.gameId)?.title ?: match.gameId
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(title, fontWeight = FontWeight.Bold)
Text(
buildString {
append("Saved ${agoText(now, match.savedAtMillis)}")
if (match.summary.isNotBlank()) append(" · ${match.summary}")
},
style = MaterialTheme.typography.bodySmall,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { onResume(match) }) { Text("Resume") }
TextButton(onClick = { deleting = match }) { Text("Delete") }
}
}
}
}
}
}
}
deleting?.let { match ->
val title = GameCatalog.byId(match.gameId)?.title ?: match.gameId
AlertDialog(
onDismissRequest = { deleting = null },
title = { Text("Delete this game?") },
text = { Text("Your saved game of $title will be removed and cannot be resumed.") },
confirmButton = {
TextButton(onClick = { deleting = null; onDelete(match) }) { Text("Delete") }
},
dismissButton = { TextButton(onClick = { deleting = null }) { Text("Keep it") } },
)
}
}
@@ -60,6 +60,22 @@ fun CrazyEightsScreen(
onExit: () -> Unit,
) {
val state by controller.state.collectAsState()
val abandoned by controller.abandoned.collectAsState()
var confirmingEnd by remember { mutableStateOf(false) }
val endGameAction: @Composable () -> Unit = {
TextButton(onClick = { confirmingEnd = true }) { Text("End game") }
}
val dialogs: @Composable () -> Unit = {
abandoned?.let { PatienceLeftDialog(it, onExit, controller::dismissAbandoned) }
if (confirmingEnd) {
PatienceEndDialog(
what = "The game ends here. Anyone else at the table is told you have left.",
onEnd = onExit,
onKeepPlaying = { confirmingEnd = false },
)
}
}
val legal by controller.legalMoves.collectAsState()
// Playing an eight is two decisions: the card, then the suit it turns the
@@ -68,13 +84,15 @@ fun CrazyEightsScreen(
val current = state
if (current == null) {
ScreenScaffold(title = "Crazy 8s", onBack = onExit) { modifier ->
ScreenScaffold(title = "Crazy 8s", onBack = onExit, actions = endGameAction) { modifier ->
dialogs()
Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") }
}
return
}
ScreenScaffold(title = "Crazy 8s", onBack = onExit) { modifier ->
ScreenScaffold(title = "Crazy 8s", onBack = onExit, actions = endGameAction) { modifier ->
dialogs()
BoxWithConstraints(modifier = modifier) {
val cardWidth = (minOf(maxWidth, maxHeight) * 0.16f).coerceIn(44.dp, 92.dp)
@@ -65,11 +65,28 @@ fun CribbageScreen(
onExit: () -> Unit,
) {
val state by controller.state.collectAsState()
val abandoned by controller.abandoned.collectAsState()
var confirmingEnd by remember { mutableStateOf(false) }
val endGameAction: @Composable () -> Unit = {
TextButton(onClick = { confirmingEnd = true }) { Text("End game") }
}
val dialogs: @Composable () -> Unit = {
abandoned?.let { PatienceLeftDialog(it, onExit, controller::dismissAbandoned) }
if (confirmingEnd) {
PatienceEndDialog(
what = "The game ends here. Anyone else at the table is told you have left.",
onEnd = onExit,
onKeepPlaying = { confirmingEnd = false },
)
}
}
val legal by controller.legalMoves.collectAsState()
val current = state
if (current == null) {
ScreenScaffold(title = "Cribbage", onBack = onExit) { modifier ->
ScreenScaffold(title = "Cribbage", onBack = onExit, actions = endGameAction) { modifier ->
dialogs()
Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") }
}
return
@@ -79,7 +96,8 @@ fun CribbageScreen(
// together. Forgotten whenever a new hand is dealt.
var chosen by remember(current.handNumber) { mutableStateOf(emptySet<Card>()) }
ScreenScaffold(title = "Cribbage", onBack = onExit) { modifier ->
ScreenScaffold(title = "Cribbage", onBack = onExit, actions = endGameAction) { modifier ->
dialogs()
BoxWithConstraints(modifier = modifier) {
val cardWidth = (minOf(maxWidth, maxHeight) * 0.16f).coerceIn(44.dp, 92.dp)
@@ -22,6 +22,7 @@ import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.LaunchedEffect
@@ -72,18 +73,36 @@ fun EuchreScreen(
onExit: () -> Unit,
) {
val state by controller.state.collectAsState()
val abandoned by controller.abandoned.collectAsState()
var confirmingEnd by remember { mutableStateOf(false) }
val endGameAction: @Composable () -> Unit = {
TextButton(onClick = { confirmingEnd = true }) { Text("End game") }
}
val dialogs: @Composable () -> Unit = {
abandoned?.let { PatienceLeftDialog(it, onExit, controller::dismissAbandoned) }
if (confirmingEnd) {
PatienceEndDialog(
what = "The game ends here. Anyone else at the table is told you have left.",
onEnd = onExit,
onKeepPlaying = { confirmingEnd = false },
)
}
}
val legal by controller.legalMoves.collectAsState()
val notice by controller.notice.collectAsState()
val current = state
if (current == null) {
ScreenScaffold(title = "Euchre", onBack = onExit) { modifier ->
ScreenScaffold(title = "Euchre", onBack = onExit, actions = endGameAction) { modifier ->
dialogs()
Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") }
}
return
}
ScreenScaffold(title = "Euchre", onBack = onExit) { modifier ->
ScreenScaffold(title = "Euchre", onBack = onExit, actions = endGameAction) { modifier ->
dialogs()
BoxWithConstraints(modifier = modifier) {
val compact = maxWidth < 600.dp
val cardWidth = (minOf(maxWidth, maxHeight) * if (compact) 0.17f else 0.12f)
@@ -69,18 +69,36 @@ fun GolfScreen(
onExit: () -> Unit,
) {
val state by controller.state.collectAsState()
val abandoned by controller.abandoned.collectAsState()
var confirmingEnd by remember { mutableStateOf(false) }
val endGameAction: @Composable () -> Unit = {
TextButton(onClick = { confirmingEnd = true }) { Text("End game") }
}
val dialogs: @Composable () -> Unit = {
abandoned?.let { PatienceLeftDialog(it, onExit, controller::dismissAbandoned) }
if (confirmingEnd) {
PatienceEndDialog(
what = "The game ends here. Anyone else at the table is told you have left.",
onEnd = onExit,
onKeepPlaying = { confirmingEnd = false },
)
}
}
val legal by controller.legalMoves.collectAsState()
var mode by remember { mutableStateOf(PlaceMode.SWAP) }
val current = state
if (current == null) {
ScreenScaffold(title = "Golf", onBack = onExit) { modifier ->
ScreenScaffold(title = "Golf", onBack = onExit, actions = endGameAction) { modifier ->
dialogs()
Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") }
}
return
}
ScreenScaffold(title = "Golf", onBack = onExit) { modifier ->
ScreenScaffold(title = "Golf", onBack = onExit, actions = endGameAction) { modifier ->
dialogs()
BoxWithConstraints(modifier = modifier) {
val shortest = minOf(maxWidth, maxHeight)
val ownCard = (shortest * 0.16f).coerceIn(40.dp, 84.dp)
@@ -22,6 +22,7 @@ import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -74,18 +75,36 @@ fun KaiserScreen(
onExit: () -> Unit,
) {
val state by controller.state.collectAsState()
val abandoned by controller.abandoned.collectAsState()
var confirmingEnd by remember { mutableStateOf(false) }
val endGameAction: @Composable () -> Unit = {
TextButton(onClick = { confirmingEnd = true }) { Text("End game") }
}
val dialogs: @Composable () -> Unit = {
abandoned?.let { PatienceLeftDialog(it, onExit, controller::dismissAbandoned) }
if (confirmingEnd) {
PatienceEndDialog(
what = "The game ends here. Anyone else at the table is told you have left.",
onEnd = onExit,
onKeepPlaying = { confirmingEnd = false },
)
}
}
val legal by controller.legalMoves.collectAsState()
val notice by controller.notice.collectAsState()
val current = state
if (current == null) {
ScreenScaffold(title = "Kaiser", onBack = onExit) { modifier ->
ScreenScaffold(title = "Kaiser", onBack = onExit, actions = endGameAction) { modifier ->
dialogs()
Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") }
}
return
}
ScreenScaffold(title = "Kaiser", onBack = onExit) { modifier ->
ScreenScaffold(title = "Kaiser", onBack = onExit, actions = endGameAction) { modifier ->
dialogs()
BoxWithConstraints(modifier = modifier) {
val compact = maxWidth < 600.dp
val cardWidth = (minOf(maxWidth, maxHeight) * if (compact) 0.15f else 0.11f)
@@ -20,11 +20,15 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.TextButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
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.rotate
@@ -66,16 +70,34 @@ fun PresidentScreen(
) {
val state by controller.state.collectAsState()
val legal by controller.legalMoves.collectAsState()
val abandoned by controller.abandoned.collectAsState()
var confirmingEnd by remember { mutableStateOf(false) }
val endGameAction: @Composable () -> Unit = {
TextButton(onClick = { confirmingEnd = true }) { Text("End game") }
}
val dialogs: @Composable () -> Unit = {
abandoned?.let { PatienceLeftDialog(it, onExit, controller::dismissAbandoned) }
if (confirmingEnd) {
PatienceEndDialog(
what = "The game ends here. Anyone else at the table is told you have left.",
onEnd = onExit,
onKeepPlaying = { confirmingEnd = false },
)
}
}
val current = state
if (current == null) {
ScreenScaffold(title = "President", onBack = onExit) { modifier ->
ScreenScaffold(title = "President", onBack = onExit, actions = endGameAction) { modifier ->
Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") }
dialogs()
}
return
}
ScreenScaffold(title = "President", onBack = onExit) { modifier ->
ScreenScaffold(title = "President", onBack = onExit, actions = endGameAction) { modifier ->
dialogs()
BoxWithConstraints(modifier = modifier) {
val cardWidth = (minOf(maxWidth, maxHeight) * 0.15f).coerceIn(44.dp, 88.dp)
@@ -18,6 +18,7 @@ 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.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -81,12 +82,29 @@ fun SequenceScreen(
onExit: () -> Unit,
) {
val state by controller.state.collectAsState()
val abandoned by controller.abandoned.collectAsState()
var confirmingEnd by remember { mutableStateOf(false) }
val endGameAction: @Composable () -> Unit = {
TextButton(onClick = { confirmingEnd = true }) { Text("End game") }
}
val dialogs: @Composable () -> Unit = {
abandoned?.let { PatienceLeftDialog(it, onExit, controller::dismissAbandoned) }
if (confirmingEnd) {
PatienceEndDialog(
what = "The game ends here. Anyone else at the table is told you have left.",
onEnd = onExit,
onKeepPlaying = { confirmingEnd = false },
)
}
}
val legal by controller.legalMoves.collectAsState()
var selected by remember { mutableStateOf<Card?>(null) }
val current = state
if (current == null) {
ScreenScaffold(title = "Sequence", onBack = onExit) { modifier ->
ScreenScaffold(title = "Sequence", onBack = onExit, actions = endGameAction) { modifier ->
dialogs()
Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") }
}
return
@@ -97,7 +115,8 @@ fun SequenceScreen(
val removals = legal.filterIsInstance<RemoveChip>().filter { it.card == selected }
val highlighted = (placements.map { it.cell } + removals.map { it.cell }).toSet()
ScreenScaffold(title = "Sequence", onBack = onExit) { modifier ->
ScreenScaffold(title = "Sequence", onBack = onExit, actions = endGameAction) { modifier ->
dialogs()
BoxWithConstraints(modifier = modifier) {
val wide = maxWidth > maxHeight
val boardSide = if (wide) {
@@ -75,18 +75,36 @@ fun WizardScreen(
onExit: () -> Unit,
) {
val state by controller.state.collectAsState()
val abandoned by controller.abandoned.collectAsState()
var confirmingEnd by remember { mutableStateOf(false) }
val endGameAction: @Composable () -> Unit = {
TextButton(onClick = { confirmingEnd = true }) { Text("End game") }
}
val dialogs: @Composable () -> Unit = {
abandoned?.let { PatienceLeftDialog(it, onExit, controller::dismissAbandoned) }
if (confirmingEnd) {
PatienceEndDialog(
what = "The game ends here. Anyone else at the table is told you have left.",
onEnd = onExit,
onKeepPlaying = { confirmingEnd = false },
)
}
}
val legal by controller.legalMoves.collectAsState()
val notice by controller.notice.collectAsState()
val current = state
if (current == null) {
ScreenScaffold(title = "Wizard", onBack = onExit) { modifier ->
ScreenScaffold(title = "Wizard", onBack = onExit, actions = endGameAction) { modifier ->
dialogs()
Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") }
}
return
}
ScreenScaffold(title = "Wizard", onBack = onExit) { modifier ->
ScreenScaffold(title = "Wizard", onBack = onExit, actions = endGameAction) { modifier ->
dialogs()
BoxWithConstraints(modifier = modifier) {
val cardWidth = (minOf(maxWidth, maxHeight) * 0.14f).coerceIn(40.dp, 88.dp)
@@ -313,6 +313,10 @@ fun CatanHud(
) {
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(2.dp)) {
HudPlayerStrip(state, seats, pointsOf, style)
// Cities & Knights: the ship and the improvement levels are as much part
// of the game as the scores, so they sit with them rather than in a panel
// this layout does not have room for.
if (state.isCk) CatanBarbarians(state, Modifier.padding(horizontal = 8.dp), 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)) {
@@ -453,6 +457,16 @@ private fun HudPlayerStrip(
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))
// Trade, politics, science: the three improvement levels.
if (state.isCk) {
Text(
text = org.prolibertate.games.game.catan.Track.entries.joinToString(" ") {
"${it.name.first()}${org.prolibertate.games.game.catan.CatanCk.improvement(state, seat, it)}"
},
fontSize = 12.sp,
maxLines = 1,
)
}
Text("${pointsOf(seat)} VP · ${state.resourceCount(seat)}🂠", fontSize = 12.sp, maxLines = 1)
}
}