Release: dev to main #28

Merged
devadmin merged 4 commits from dev into main 2026-11-07 13:01:33 -06:00
11 changed files with 299 additions and 36 deletions
@@ -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)
}
}