Yahtzee: pip dice, confirm zero scores; text size setting #21

Merged
devadmin merged 1 commits from yahtzee-improvements into dev 2026-10-26 20:03:26 -06:00
4 changed files with 100 additions and 9 deletions
@@ -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(
@@ -7,6 +7,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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 +31,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 +71,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 +141,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 +159,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 +221,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)
}
}