Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8990dde363 | ||
|
|
c2b21a07f5 | ||
|
|
e98afbfd8d | ||
|
|
2c90de66d1 | ||
|
|
f93eba88e5 | ||
|
|
36ab518309 |
@@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user