Compare commits
18
Commits
meow
...
yahtzee-stats
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebab7a8415 | ||
|
|
a0b15b3140 | ||
|
|
8990dde363 | ||
|
|
c2b21a07f5 | ||
|
|
e98afbfd8d | ||
|
|
2c90de66d1 | ||
|
|
f93eba88e5 | ||
|
|
36ab518309 | ||
|
|
77cc6c5a05 | ||
|
|
166f85734b | ||
|
|
25e38c4bf8 | ||
|
|
6bf7522042 | ||
|
|
7942adeaf7 | ||
|
|
488f600f15 | ||
|
|
9b938ffd8b | ||
|
|
ccf8d5a1ba | ||
|
|
a00df43a44 | ||
|
|
fae031d2b5 |
@@ -10,6 +10,7 @@ import org.prolibertate.games.net.LobbyController
|
||||
import org.prolibertate.games.net.ScorekeeperSession
|
||||
import org.prolibertate.games.score.ScorekeeperRepository
|
||||
import org.prolibertate.games.settings.SettingsRepository
|
||||
import org.prolibertate.games.settings.YahtzeeStatsRepository
|
||||
import org.prolibertate.games.ui.AppEnv
|
||||
import org.prolibertate.games.ui.AppRoot
|
||||
import org.prolibertate.games.ui.theme.ProLibertateTheme
|
||||
@@ -43,6 +44,7 @@ class MainActivity : ComponentActivity() {
|
||||
),
|
||||
peerId = peerId,
|
||||
saves = SavedGameStore(File(filesDir, "saved-games")),
|
||||
yahtzeeStats = YahtzeeStatsRepository(applicationContext),
|
||||
)
|
||||
|
||||
setContent {
|
||||
|
||||
@@ -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,58 @@
|
||||
package org.prolibertate.games.game.yahtzee
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/** What one finished game says about the local player's card. */
|
||||
@Serializable
|
||||
data class YahtzeeRecord(
|
||||
val total: Int,
|
||||
val gotBonus: Boolean,
|
||||
/** Yahtzees scored, counting the extra ones that earned the hundred. */
|
||||
val yahtzees: Int,
|
||||
/** Boxes written in as nothing. */
|
||||
val zeros: Int,
|
||||
val players: Int,
|
||||
val won: Boolean,
|
||||
)
|
||||
|
||||
/** Builds the record of [seat]'s card in a finished game. Ties for first count as a win. */
|
||||
fun recordOf(state: YahtzeeState, seat: Int): YahtzeeRecord {
|
||||
val card = state.cards[seat]
|
||||
val mine = state.totalFor(seat)
|
||||
val best = state.cards.indices.maxOf { state.totalFor(it) }
|
||||
return YahtzeeRecord(
|
||||
total = mine,
|
||||
gotBonus = card.upperBonus > 0,
|
||||
yahtzees = (if (card[YahtzeeCategory.YAHTZEE] == YAHTZEE_SCORE) 1 else 0) +
|
||||
state.yahtzeeBonuses[seat] / YAHTZEE_BONUS,
|
||||
zeros = card.boxes.count { it == 0 },
|
||||
players = state.cards.size,
|
||||
won = mine >= best,
|
||||
)
|
||||
}
|
||||
|
||||
/** A game set against the ones before it. [rank] is 1 for a new best. */
|
||||
data class YahtzeeComparison(
|
||||
val current: YahtzeeRecord,
|
||||
val previousGames: Int,
|
||||
val bestBefore: Int?,
|
||||
val averageBefore: Double?,
|
||||
val rank: Int,
|
||||
val gamesRanked: Int,
|
||||
val bonusRateBefore: Double?,
|
||||
val yahtzeesBefore: Int,
|
||||
val winRateBefore: Double?,
|
||||
)
|
||||
|
||||
fun compareToHistory(previous: List<YahtzeeRecord>, current: YahtzeeRecord): YahtzeeComparison =
|
||||
YahtzeeComparison(
|
||||
current = current,
|
||||
previousGames = previous.size,
|
||||
bestBefore = previous.maxOfOrNull { it.total },
|
||||
averageBefore = previous.takeIf { it.isNotEmpty() }?.map { it.total }?.average(),
|
||||
rank = 1 + previous.count { it.total > current.total },
|
||||
gamesRanked = previous.size + 1,
|
||||
bonusRateBefore = previous.takeIf { it.isNotEmpty() }?.let { p -> p.count { it.gotBonus }.toDouble() / p.size },
|
||||
yahtzeesBefore = previous.sumOf { it.yahtzees },
|
||||
winRateBefore = previous.takeIf { it.isNotEmpty() }?.let { p -> p.count { it.won }.toDouble() / p.size },
|
||||
)
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package org.prolibertate.games.settings
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.prolibertate.games.game.yahtzee.YahtzeeComparison
|
||||
import org.prolibertate.games.game.yahtzee.YahtzeeRecord
|
||||
import org.prolibertate.games.game.yahtzee.compareToHistory
|
||||
|
||||
private val Context.yahtzeeStatsStore: DataStore<Preferences> by preferencesDataStore(
|
||||
name = "yahtzee_stats",
|
||||
)
|
||||
|
||||
/** The local player's finished Yahtzee games, newest last, kept to the last [KEEP]. */
|
||||
class YahtzeeStatsRepository(private val context: Context) {
|
||||
|
||||
private val key = stringPreferencesKey("games")
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
private fun decode(stored: String?): List<YahtzeeRecord> =
|
||||
stored?.let { runCatching { json.decodeFromString<List<YahtzeeRecord>>(it) }.getOrNull() }
|
||||
.orEmpty()
|
||||
|
||||
/** Compares [record] with every earlier game, then adds it to the history. */
|
||||
suspend fun finish(record: YahtzeeRecord): YahtzeeComparison {
|
||||
val previous = decode(context.yahtzeeStatsStore.data.first()[key])
|
||||
context.yahtzeeStatsStore.edit {
|
||||
it[key] = json.encodeToString((previous + record).takeLast(KEEP))
|
||||
}
|
||||
return compareToHistory(previous, record)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val KEEP = 500
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -49,6 +51,7 @@ import org.prolibertate.games.net.ScorekeeperSession
|
||||
import org.prolibertate.games.score.ScorekeeperRepository
|
||||
import org.prolibertate.games.settings.Settings
|
||||
import org.prolibertate.games.settings.SettingsRepository
|
||||
import org.prolibertate.games.settings.YahtzeeStatsRepository
|
||||
import org.prolibertate.games.ui.game.LocalCardSpeed
|
||||
import org.prolibertate.games.update.UpdateChannel
|
||||
import org.prolibertate.games.update.Updater
|
||||
@@ -68,6 +71,7 @@ class AppEnv(
|
||||
val peerId: String,
|
||||
/** Games in progress, kept between sessions. */
|
||||
val saves: SavedGameStore,
|
||||
val yahtzeeStats: YahtzeeStatsRepository,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -139,7 +143,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,
|
||||
|
||||
@@ -594,7 +594,12 @@ fun PlayScreen(
|
||||
)
|
||||
}
|
||||
StartMatch(env, controller, route)
|
||||
YahtzeeScreen(controller = controller, localSeat = route.localSeat, onExit = onExit)
|
||||
YahtzeeScreen(
|
||||
controller = controller,
|
||||
localSeat = route.localSeat,
|
||||
stats = env.yahtzeeStats,
|
||||
onExit = onExit,
|
||||
)
|
||||
}
|
||||
|
||||
GameCatalog.KLONDIKE -> {
|
||||
|
||||
@@ -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,12 +34,17 @@ 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
|
||||
import org.prolibertate.games.game.yahtzee.RollDice
|
||||
import org.prolibertate.games.game.yahtzee.ScoreIn
|
||||
import org.prolibertate.games.game.yahtzee.YahtzeeCategory
|
||||
import org.prolibertate.games.game.yahtzee.YahtzeeComparison
|
||||
import org.prolibertate.games.game.yahtzee.recordOf
|
||||
import org.prolibertate.games.settings.YahtzeeStatsRepository
|
||||
import kotlin.math.roundToInt
|
||||
import org.prolibertate.games.game.yahtzee.YahtzeeMove
|
||||
import org.prolibertate.games.game.yahtzee.YahtzeeSection
|
||||
import org.prolibertate.games.game.yahtzee.YahtzeeState
|
||||
@@ -61,6 +70,7 @@ private const val TITLE = "Yahtzee"
|
||||
fun YahtzeeScreen(
|
||||
controller: MatchController<YahtzeeState, YahtzeeMove>,
|
||||
localSeat: Int,
|
||||
stats: YahtzeeStatsRepository,
|
||||
onExit: () -> Unit,
|
||||
) {
|
||||
val state by controller.state.collectAsState()
|
||||
@@ -69,6 +79,11 @@ fun YahtzeeScreen(
|
||||
|
||||
val kept = remember { mutableStateListOf<Int>() }
|
||||
var confirmingEnd by remember { mutableStateOf(false) }
|
||||
var confirmingZero by remember { mutableStateOf<YahtzeeCategory?>(null) }
|
||||
var comparison by remember { mutableStateOf<YahtzeeComparison?>(null) }
|
||||
// Only a game seen to finish is recorded: reopening a saved game that was
|
||||
// already over must not count it a second time.
|
||||
var sawInProgress by remember { mutableStateOf(false) }
|
||||
|
||||
val endGameAction: @Composable () -> Unit = {
|
||||
TextButton(onClick = { confirmingEnd = true }) { Text("End game") }
|
||||
@@ -89,6 +104,15 @@ fun YahtzeeScreen(
|
||||
// refused, and the Throw button would look broken again.
|
||||
LaunchedEffect(current.turn, current.round) { kept.clear() }
|
||||
|
||||
LaunchedEffect(current.isOver) {
|
||||
if (!current.isOver) {
|
||||
sawInProgress = true
|
||||
} else if (sawInProgress) {
|
||||
sawInProgress = false
|
||||
comparison = stats.finish(recordOf(current, localSeat))
|
||||
}
|
||||
}
|
||||
|
||||
val yours = current.turn == localSeat && !current.isOver
|
||||
val canRoll = legal.any { it is RollDice }
|
||||
val writable = legal.filterIsInstance<ScoreIn>().map { it.category }.toSet()
|
||||
@@ -138,8 +162,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 +180,79 @@ fun YahtzeeScreen(
|
||||
|
||||
abandoned?.let { LeftCardDialog(it, onExit, controller::dismissAbandoned) }
|
||||
if (confirmingEnd) EndCardDialog(onExit) { confirmingEnd = false }
|
||||
comparison?.let { StatsDialog(it, onExit) { comparison = null } }
|
||||
confirmingZero?.let { category ->
|
||||
ZeroScoreDialog(
|
||||
category = category,
|
||||
onConfirm = {
|
||||
confirmingZero = null
|
||||
controller.submit(ScoreIn(category))
|
||||
kept.clear()
|
||||
},
|
||||
onCancel = { confirmingZero = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatsDialog(c: YahtzeeComparison, onLeave: () -> Unit, onClose: () -> Unit) {
|
||||
fun pct(v: Double?) = v?.let { "${(it * 100).roundToInt()}%" } ?: "—"
|
||||
val headline = when {
|
||||
c.previousGames == 0 -> "Your first recorded game."
|
||||
c.rank == 1 -> "A new personal best!"
|
||||
else -> "${ordinal(c.rank)} best of ${c.gamesRanked} games."
|
||||
}
|
||||
AlertDialog(
|
||||
onDismissRequest = onClose,
|
||||
title = { Text("Game over: ${c.current.total} points") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(headline, fontWeight = FontWeight.Bold)
|
||||
StatLine("", "This game", "Before")
|
||||
StatLine("Score", "${c.current.total}", c.averageBefore?.let { "avg ${it.roundToInt()}, best ${c.bestBefore}" } ?: "—")
|
||||
StatLine("Upper bonus", if (c.current.gotBonus) "Yes" else "No", pct(c.bonusRateBefore))
|
||||
StatLine("Yahtzees", "${c.current.yahtzees}", "${c.yahtzeesBefore} total")
|
||||
StatLine("Zeroed boxes", "${c.current.zeros}", "")
|
||||
if (c.current.players > 1) {
|
||||
StatLine("Result", if (c.current.won) "Won" else "Lost", "won ${pct(c.winRateBefore)}")
|
||||
}
|
||||
Text(
|
||||
"Games before this one: ${c.previousGames}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onLeave) { Text("Leave the game") } },
|
||||
dismissButton = { TextButton(onClick = onClose) { Text("Look at the card") } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatLine(label: String, now: String, before: String) {
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(label, modifier = Modifier.weight(1.2f), fontWeight = FontWeight.Bold)
|
||||
Text(now, modifier = Modifier.weight(1f))
|
||||
Text(before, modifier = Modifier.weight(1.6f))
|
||||
}
|
||||
}
|
||||
|
||||
private fun ordinal(n: Int): String {
|
||||
val suffix = if (n % 100 in 11..13) "th" else when (n % 10) { 1 -> "st"; 2 -> "nd"; 3 -> "rd"; else -> "th" }
|
||||
return "$n$suffix"
|
||||
}
|
||||
|
||||
@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 +290,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 +323,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 +394,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 +413,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 +422,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.game.yahtzee
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class YahtzeeStatsTest {
|
||||
private fun game(total: Int, bonus: Boolean = false, yahtzees: Int = 0, won: Boolean = true) =
|
||||
YahtzeeRecord(total, bonus, yahtzees, zeros = 0, players = 2, won = won)
|
||||
|
||||
@Test
|
||||
fun firstGameHasNothingToCompareTo() {
|
||||
val c = compareToHistory(emptyList(), game(200))
|
||||
assertEquals(1, c.rank)
|
||||
assertEquals(0, c.previousGames)
|
||||
assertNull(c.bestBefore)
|
||||
assertNull(c.averageBefore)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ranksAgainstPreviousGames() {
|
||||
val history = listOf(game(150, bonus = true, yahtzees = 1), game(250), game(200, won = false))
|
||||
val c = compareToHistory(history, game(220))
|
||||
assertEquals(2, c.rank)
|
||||
assertEquals(4, c.gamesRanked)
|
||||
assertEquals(250, c.bestBefore)
|
||||
assertEquals(200.0, c.averageBefore!!, 0.001)
|
||||
assertEquals(1, c.yahtzeesBefore)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user