Merge dev: Euchre bidding fix, trick sweep, branding, menu filtering
Publishes a production release carrying everything since v1.0.2. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_015qjfMiPUDshdKEUa6vxqzN
This commit is contained in:
@@ -12,7 +12,11 @@ The app shell is complete and two games are playable end to end:
|
|||||||
| **Playable** | Euchre, Sequence |
|
| **Playable** | Euchre, Sequence |
|
||||||
| **Listed, not yet implemented** | Kaiser, President, Golf, Wizard, Crazy 8s, Mastermind, Backgammon, Chess, Checkers |
|
| **Listed, not yet implemented** | Kaiser, President, Golf, Wizard, Crazy 8s, Mastermind, Backgammon, Chess, Checkers |
|
||||||
|
|
||||||
Unimplemented games appear in the menu marked *Coming soon*. Adding one means
|
Unimplemented games appear in the menu marked *Coming soon* **on dev builds
|
||||||
|
only** — a production release lists just the games that can actually be played,
|
||||||
|
so nobody installs it and taps into a dead end. The menu decides this from the
|
||||||
|
installed APK's own version name, so it follows the build rather than the
|
||||||
|
channel selected for future updates. Adding one means
|
||||||
writing a `GameRules` implementation, a `GameAi`, and a screen, then flipping
|
writing a `GameRules` implementation, a `GameAi`, and a screen, then flipping
|
||||||
`available` in `GameCatalog` — the menu, lobby, networking, settings and OTA
|
`available` in `GameCatalog` — the menu, lobby, networking, settings and OTA
|
||||||
layers are game-agnostic and need no changes.
|
layers are game-agnostic and need no changes.
|
||||||
@@ -53,6 +57,31 @@ them.
|
|||||||
- **Production** — stable builds, published from `main`.
|
- **Production** — stable builds, published from `main`.
|
||||||
- **Dev** — preview builds, published from `dev` on every green CI run.
|
- **Dev** — preview builds, published from `dev` on every green CI run.
|
||||||
|
|
||||||
|
## Branding
|
||||||
|
|
||||||
|
The launcher icon is a single square of the Wallace tartan, and the app opens on
|
||||||
|
a tartan splash carrying the name in a Celtic uncial hand for two seconds before
|
||||||
|
fading into the menu.
|
||||||
|
|
||||||
|
The tartan is drawn, not an image: `ui/theme/Tartan.kt` lays the sett down as
|
||||||
|
warp and then as weft at half opacity, which is what produces the blended
|
||||||
|
squares instead of a flat grid. The icon is the same construction as a vector
|
||||||
|
drawable, so it stays sharp at every density and needs no PNGs.
|
||||||
|
|
||||||
|
Two honest caveats:
|
||||||
|
|
||||||
|
- **The sett is an approximation.** `WallaceSett` reproduces what the Wallace
|
||||||
|
tartan reads as — a bold red and black check divided by a narrow gold
|
||||||
|
overstripe — but the registered threadcount could not be verified, so the
|
||||||
|
numbers were chosen to look right rather than to be authoritative. They are
|
||||||
|
plain data; replacing them with the registered counts rebrands the whole app
|
||||||
|
and needs no other change.
|
||||||
|
- **The typeface is bundled under a licence.** Uncial Antiqua, © 2011 Brian J.
|
||||||
|
Bonislawsky DBA Astigmatic, Reserved Font Name "Uncial Antiqua", used under
|
||||||
|
the SIL Open Font License 1.1. The full licence is at
|
||||||
|
[docs/licenses/UncialAntiqua-OFL.txt](docs/licenses/UncialAntiqua-OFL.txt) and
|
||||||
|
must stay with the font — the OFL requires it to be distributed alongside.
|
||||||
|
|
||||||
## Screen sizes
|
## Screen sizes
|
||||||
|
|
||||||
There are no fixed orientations or hard-coded card sizes. Layouts measure the
|
There are no fixed orientations or hard-coded card sizes. Layouts measure the
|
||||||
|
|||||||
@@ -154,6 +154,22 @@ object GameCatalog {
|
|||||||
|
|
||||||
fun byId(id: String): GameDescriptor? = all.firstOrNull { it.id == id }
|
fun byId(id: String): GameDescriptor? = all.firstOrNull { it.id == id }
|
||||||
|
|
||||||
fun byCategory(category: GameCategory): List<GameDescriptor> =
|
/**
|
||||||
all.filter { it.category == category }
|
* Games in a category.
|
||||||
|
*
|
||||||
|
* [includeComingSoon] is what separates the two update channels: a dev
|
||||||
|
* build lists everything, including the games that are still only entries
|
||||||
|
* in this table, so the roadmap is visible while working on it. A
|
||||||
|
* production build lists only what can actually be played, so nobody
|
||||||
|
* downloads a release and taps into a dead end.
|
||||||
|
*/
|
||||||
|
fun byCategory(
|
||||||
|
category: GameCategory,
|
||||||
|
includeComingSoon: Boolean = true,
|
||||||
|
): List<GameDescriptor> = all.filter {
|
||||||
|
it.category == category && (includeComingSoon || it.available)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything currently playable, regardless of category. */
|
||||||
|
val playable: List<GameDescriptor> get() = all.filter { it.available }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,14 +16,20 @@ import org.prolibertate.games.game.engine.GameAi
|
|||||||
class EuchreAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
|
class EuchreAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
|
||||||
GameAi<EuchreState, EuchreMove> {
|
GameAi<EuchreState, EuchreMove> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thresholds are calibrated against the score an average five-card hand
|
||||||
|
* earns from [handValue], which sits around 11. A threshold near that mean
|
||||||
|
* means every seat bids and the card is never turned down — see
|
||||||
|
* EuchreBiddingRateTest, which pins the resulting order-up rate.
|
||||||
|
*/
|
||||||
enum class Difficulty(val bidThreshold: Int, val aloneThreshold: Int) {
|
enum class Difficulty(val bidThreshold: Int, val aloneThreshold: Int) {
|
||||||
/** Bids loosely and plays straightforwardly. */
|
/** Bids loosely and plays straightforwardly. */
|
||||||
EASY(bidThreshold = 7, aloneThreshold = 26),
|
EASY(bidThreshold = 21, aloneThreshold = 32),
|
||||||
|
|
||||||
NORMAL(bidThreshold = 9, aloneThreshold = 24),
|
NORMAL(bidThreshold = 24, aloneThreshold = 32),
|
||||||
|
|
||||||
/** Bids tighter and is harder to sneak a trick past. */
|
/** Bids tighter and is harder to sneak a trick past. */
|
||||||
CAUTIOUS(bidThreshold = 11, aloneThreshold = 22),
|
CAUTIOUS(bidThreshold = 27, aloneThreshold = 31),
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun chooseMove(state: EuchreState, seat: Int, legal: List<EuchreMove>): EuchreMove {
|
override fun chooseMove(state: EuchreState, seat: Int, legal: List<EuchreMove>): EuchreMove {
|
||||||
@@ -47,19 +53,25 @@ class EuchreAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
|
|||||||
*/
|
*/
|
||||||
private fun handValue(hand: List<Card>, trump: Suit): Int {
|
private fun handValue(hand: List<Card>, trump: Suit): Int {
|
||||||
var value = 0
|
var value = 0
|
||||||
|
var trumpCount = 0
|
||||||
for (card in hand) {
|
for (card in hand) {
|
||||||
value += when {
|
value += when {
|
||||||
isRightBower(card, trump) -> 9
|
isRightBower(card, trump) -> 10
|
||||||
isLeftBower(card, trump) -> 7
|
isLeftBower(card, trump) -> 8
|
||||||
isTrumpCard(card, trump) -> 3 + (card.rank.order - Rank.NINE.order) / 2
|
isTrumpCard(card, trump) -> 4 + (card.rank.order - Rank.NINE.order) / 2
|
||||||
card.rank == Rank.ACE -> 3
|
card.rank == Rank.ACE -> 3
|
||||||
card.rank == Rank.KING -> 1
|
card.rank == Rank.KING -> 1
|
||||||
else -> 0
|
else -> 0
|
||||||
}
|
}
|
||||||
|
if (isTrumpCard(card, trump)) trumpCount++
|
||||||
}
|
}
|
||||||
// A void in a side suit means an early chance to trump in.
|
// A void is a chance to trump in — but only while there is trump left
|
||||||
|
// to do it with. Counting voids on a trumpless hand was inflating
|
||||||
|
// exactly the weak hands that should be passing.
|
||||||
|
if (trumpCount >= 2) {
|
||||||
val sideSuits = Suit.entries.filter { it != trump }
|
val sideSuits = Suit.entries.filter { it != trump }
|
||||||
value += sideSuits.count { suit -> hand.none { effectiveSuit(it, trump) == suit } } * 2
|
value += sideSuits.count { suit -> hand.none { effectiveSuit(it, trump) == suit } } * 2
|
||||||
|
}
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,12 @@ data class EuchreState(
|
|||||||
val turn: Int,
|
val turn: Int,
|
||||||
val passes: Int,
|
val passes: Int,
|
||||||
val trick: List<PlayedCard>,
|
val trick: List<PlayedCard>,
|
||||||
|
/**
|
||||||
|
* The trick that has just been won, kept on the table so it can be read and
|
||||||
|
* then animated away. Cleared as soon as the next card is played. Winner is
|
||||||
|
* [lastTrickWinner].
|
||||||
|
*/
|
||||||
|
val completedTrick: List<PlayedCard> = emptyList(),
|
||||||
val leader: Int,
|
val leader: Int,
|
||||||
val tricksWon: List<Int>,
|
val tricksWon: List<Int>,
|
||||||
/** Two entries: team 0 is seats 0 and 2, team 1 is seats 1 and 3. */
|
/** Two entries: team 0 is seats 0 and 2, team 1 is seats 1 and 3. */
|
||||||
|
|||||||
@@ -277,6 +277,7 @@ object EuchreRules : GameRules<EuchreState, EuchreMove> {
|
|||||||
leader = leader,
|
leader = leader,
|
||||||
turn = leader,
|
turn = leader,
|
||||||
trick = emptyList(),
|
trick = emptyList(),
|
||||||
|
completedTrick = emptyList(),
|
||||||
passes = 0,
|
passes = 0,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -285,7 +286,8 @@ object EuchreRules : GameRules<EuchreState, EuchreMove> {
|
|||||||
val hands = state.hands.toMutableList()
|
val hands = state.hands.toMutableList()
|
||||||
hands[seat] = hands[seat] - card
|
hands[seat] = hands[seat] - card
|
||||||
val trick = state.trick + PlayedCard(seat, card)
|
val trick = state.trick + PlayedCard(seat, card)
|
||||||
val played = state.copy(hands = hands, trick = trick)
|
// Playing to a new trick sweeps the previous one off the table.
|
||||||
|
val played = state.copy(hands = hands, trick = trick, completedTrick = emptyList())
|
||||||
|
|
||||||
if (trick.size < state.activeSeatCount) {
|
if (trick.size < state.activeSeatCount) {
|
||||||
return played.copy(turn = nextActive(state, seat))
|
return played.copy(turn = nextActive(state, seat))
|
||||||
@@ -300,6 +302,9 @@ object EuchreRules : GameRules<EuchreState, EuchreMove> {
|
|||||||
|
|
||||||
val afterTrick = played.copy(
|
val afterTrick = played.copy(
|
||||||
trick = emptyList(),
|
trick = emptyList(),
|
||||||
|
// Held so the finished trick can be seen before it is cleared away,
|
||||||
|
// rather than the fourth card vanishing the instant it lands.
|
||||||
|
completedTrick = trick,
|
||||||
tricksWon = tricksWon,
|
tricksWon = tricksWon,
|
||||||
leader = winner,
|
leader = winner,
|
||||||
turn = winner,
|
turn = winner,
|
||||||
|
|||||||
@@ -41,6 +41,12 @@ class MatchController<S : Any, M : Any>(
|
|||||||
private val advanceIdle: (S) -> S? = { null },
|
private val advanceIdle: (S) -> S? = { null },
|
||||||
/** Scaled by the animation-speed setting so the table paces itself. */
|
/** Scaled by the animation-speed setting so the table paces itself. */
|
||||||
private val aiThinkingMillis: () -> Long = { 700L },
|
private val aiThinkingMillis: () -> Long = { 700L },
|
||||||
|
/**
|
||||||
|
* Extra pause before the next move is made, given the current state. Lets a
|
||||||
|
* game hold a finished trick on the table long enough to be read before the
|
||||||
|
* next card lands on top of it.
|
||||||
|
*/
|
||||||
|
private val holdBeforeNextMove: (S) -> Long = { 0L },
|
||||||
) {
|
) {
|
||||||
|
|
||||||
enum class Role { HOST, CLIENT }
|
enum class Role { HOST, CLIENT }
|
||||||
@@ -207,7 +213,7 @@ class MatchController<S : Any, M : Any>(
|
|||||||
|
|
||||||
if (seatKinds[seat] != PlayerKind.AI) return // a person's turn
|
if (seatKinds[seat] != PlayerKind.AI) return // a person's turn
|
||||||
|
|
||||||
delay(aiThinkingMillis())
|
delay(holdBeforeNextMove(current) + aiThinkingMillis())
|
||||||
val chosen = lock.withLock {
|
val chosen = lock.withLock {
|
||||||
val live = authoritative ?: return
|
val live = authoritative ?: return
|
||||||
if (rules.currentSeat(live) != seat) return@withLock null
|
if (rules.currentSeat(live) != seat) return@withLock null
|
||||||
|
|||||||
@@ -25,10 +25,23 @@ data class Settings(
|
|||||||
val soundEnabled: Boolean = true,
|
val soundEnabled: Boolean = true,
|
||||||
val animationSpeed: Float = 1.0f,
|
val animationSpeed: Float = 1.0f,
|
||||||
val checkForUpdatesOnLaunch: Boolean = true,
|
val checkForUpdatesOnLaunch: Boolean = true,
|
||||||
val playerName: String = "Player",
|
/**
|
||||||
|
* 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
|
||||||
|
* that clearing the field wrote "Player" straight back and the text
|
||||||
|
* snapped back before a real name could be typed.
|
||||||
|
*/
|
||||||
|
val playerName: String = "",
|
||||||
/** Which builds the updater offers. Production unless deliberately changed. */
|
/** Which builds the updater offers. Production unless deliberately changed. */
|
||||||
val updateChannel: UpdateChannel = UpdateChannel.PRODUCTION,
|
val updateChannel: UpdateChannel = UpdateChannel.PRODUCTION,
|
||||||
) {
|
) {
|
||||||
|
/**
|
||||||
|
* The name other players actually see. This is where the default lives, so
|
||||||
|
* an empty field is a display concern rather than something written back
|
||||||
|
* into what the user is typing.
|
||||||
|
*/
|
||||||
|
val displayName: String get() = playerName.trim().ifBlank { DEFAULT_PLAYER_NAME }
|
||||||
|
|
||||||
/** Scales a nominal duration by the chosen speed. */
|
/** Scales a nominal duration by the chosen speed. */
|
||||||
fun scaled(millis: Long): Long =
|
fun scaled(millis: Long): Long =
|
||||||
(millis / animationSpeed.coerceIn(MIN_SPEED, MAX_SPEED)).toLong()
|
(millis / animationSpeed.coerceIn(MIN_SPEED, MAX_SPEED)).toLong()
|
||||||
@@ -36,6 +49,7 @@ data class Settings(
|
|||||||
companion object {
|
companion object {
|
||||||
const val MIN_SPEED = 0.5f
|
const val MIN_SPEED = 0.5f
|
||||||
const val MAX_SPEED = 2.0f
|
const val MAX_SPEED = 2.0f
|
||||||
|
const val DEFAULT_PLAYER_NAME = "Player"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,7 +68,7 @@ class SettingsRepository(private val context: Context) {
|
|||||||
soundEnabled = prefs[Keys.SOUND] ?: true,
|
soundEnabled = prefs[Keys.SOUND] ?: true,
|
||||||
animationSpeed = prefs[Keys.SPEED] ?: 1.0f,
|
animationSpeed = prefs[Keys.SPEED] ?: 1.0f,
|
||||||
checkForUpdatesOnLaunch = prefs[Keys.UPDATE_ON_LAUNCH] ?: true,
|
checkForUpdatesOnLaunch = prefs[Keys.UPDATE_ON_LAUNCH] ?: true,
|
||||||
playerName = prefs[Keys.NAME] ?: "Player",
|
playerName = prefs[Keys.NAME] ?: "",
|
||||||
updateChannel = prefs[Keys.CHANNEL]
|
updateChannel = prefs[Keys.CHANNEL]
|
||||||
?.let { stored -> runCatching { UpdateChannel.valueOf(stored) }.getOrNull() }
|
?.let { stored -> runCatching { UpdateChannel.valueOf(stored) }.getOrNull() }
|
||||||
?: UpdateChannel.PRODUCTION,
|
?: UpdateChannel.PRODUCTION,
|
||||||
@@ -79,7 +93,8 @@ class SettingsRepository(private val context: Context) {
|
|||||||
context.dataStore.edit { it[Keys.UPDATE_ON_LAUNCH] = enabled }
|
context.dataStore.edit { it[Keys.UPDATE_ON_LAUNCH] = enabled }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Stores the name verbatim. The fallback belongs to [Settings.displayName]. */
|
||||||
suspend fun setPlayerName(name: String) {
|
suspend fun setPlayerName(name: String) {
|
||||||
context.dataStore.edit { it[Keys.NAME] = name.trim().ifBlank { "Player" } }
|
context.dataStore.edit { it[Keys.NAME] = name }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import org.prolibertate.games.game.engine.TableConfig
|
|||||||
import org.prolibertate.games.net.LobbyController
|
import org.prolibertate.games.net.LobbyController
|
||||||
import org.prolibertate.games.settings.Settings
|
import org.prolibertate.games.settings.Settings
|
||||||
import org.prolibertate.games.settings.SettingsRepository
|
import org.prolibertate.games.settings.SettingsRepository
|
||||||
|
import org.prolibertate.games.update.UpdateChannel
|
||||||
import org.prolibertate.games.update.Updater
|
import org.prolibertate.games.update.Updater
|
||||||
|
|
||||||
/** Everything the screens need, assembled once by the activity. */
|
/** Everything the screens need, assembled once by the activity. */
|
||||||
@@ -75,11 +76,15 @@ fun AppRoot(env: AppEnv) {
|
|||||||
val settings by env.settingsRepository.settings.collectAsState(initial = Settings())
|
val settings by env.settingsRepository.settings.collectAsState(initial = Settings())
|
||||||
var stack by remember { mutableStateOf(listOf<Route>(Route.Menu)) }
|
var stack by remember { mutableStateOf(listOf<Route>(Route.Menu)) }
|
||||||
|
|
||||||
fun push(route: Route) {
|
// Read from the installed APK's own version name, so this follows the build
|
||||||
stack = stack + route
|
// rather than the channel the user has selected for future updates.
|
||||||
|
val showComingSoon = remember {
|
||||||
|
env.updater.installedChannel() == UpdateChannel.DEV
|
||||||
}
|
}
|
||||||
|
|
||||||
fun pop() {
|
val push: (Route) -> Unit = { route -> stack = stack + route }
|
||||||
|
|
||||||
|
val pop: () -> Unit = {
|
||||||
if (stack.size > 1) {
|
if (stack.size > 1) {
|
||||||
// Leaving a lobby or a table tears the networking down with it.
|
// Leaving a lobby or a table tears the networking down with it.
|
||||||
when (stack.last()) {
|
when (stack.last()) {
|
||||||
@@ -92,8 +97,40 @@ fun AppRoot(env: AppEnv) {
|
|||||||
|
|
||||||
BackHandler(enabled = stack.size > 1) { pop() }
|
BackHandler(enabled = stack.size > 1) { pop() }
|
||||||
|
|
||||||
|
var splashDone by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
AppContent(
|
||||||
|
env = env,
|
||||||
|
settings = settings,
|
||||||
|
stack = stack,
|
||||||
|
showComingSoon = showComingSoon,
|
||||||
|
push = push,
|
||||||
|
pop = pop,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Over the top, so the menu behind it is already composed when it fades.
|
||||||
|
if (!splashDone) {
|
||||||
|
SplashScreen(onFinished = { splashDone = true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The navigation stack proper, kept separate so the splash can sit over it. */
|
||||||
|
@Composable
|
||||||
|
private fun AppContent(
|
||||||
|
env: AppEnv,
|
||||||
|
settings: Settings,
|
||||||
|
stack: List<Route>,
|
||||||
|
showComingSoon: Boolean,
|
||||||
|
push: (Route) -> Unit,
|
||||||
|
pop: () -> Unit,
|
||||||
|
) {
|
||||||
when (val route = stack.last()) {
|
when (val route = stack.last()) {
|
||||||
is Route.Menu -> MainMenuScreen(
|
is Route.Menu -> MainMenuScreen(
|
||||||
|
// Dev builds show the games that are not finished yet; production
|
||||||
|
// releases list only what can actually be played.
|
||||||
|
showComingSoon = showComingSoon,
|
||||||
onPickGame = { push(Route.Setup(it.id)) },
|
onPickGame = { push(Route.Setup(it.id)) },
|
||||||
onJoinGame = { push(Route.Lobby(gameId = "", optionsJson = "{}", hosting = false)) },
|
onJoinGame = { push(Route.Lobby(gameId = "", optionsJson = "{}", hosting = false)) },
|
||||||
onSettings = { push(Route.Settings) },
|
onSettings = { push(Route.Settings) },
|
||||||
@@ -107,7 +144,7 @@ fun AppRoot(env: AppEnv) {
|
|||||||
|
|
||||||
is Route.Setup -> GameSetupScreen(
|
is Route.Setup -> GameSetupScreen(
|
||||||
descriptor = GameCatalog.byId(route.gameId)!!,
|
descriptor = GameCatalog.byId(route.gameId)!!,
|
||||||
playerName = settings.playerName,
|
playerName = settings.displayName,
|
||||||
onBack = { pop() },
|
onBack = { pop() },
|
||||||
onPlayOffline = { config -> push(Route.Play(route.gameId, config, true, 0)) },
|
onPlayOffline = { config -> push(Route.Play(route.gameId, config, true, 0)) },
|
||||||
onHostOnline = { optionsJson ->
|
onHostOnline = { optionsJson ->
|
||||||
@@ -118,7 +155,7 @@ fun AppRoot(env: AppEnv) {
|
|||||||
is Route.Lobby -> LobbyScreen(
|
is Route.Lobby -> LobbyScreen(
|
||||||
env = env,
|
env = env,
|
||||||
route = route,
|
route = route,
|
||||||
playerName = settings.playerName,
|
playerName = settings.displayName,
|
||||||
onBack = { pop() },
|
onBack = { pop() },
|
||||||
onStart = { config, hosting, seat ->
|
onStart = { config, hosting, seat ->
|
||||||
push(Route.Play(config.gameId, config, hosting, seat))
|
push(Route.Play(config.gameId, config, hosting, seat))
|
||||||
@@ -141,6 +178,7 @@ fun AppRoot(env: AppEnv) {
|
|||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun MainMenuScreen(
|
fun MainMenuScreen(
|
||||||
|
showComingSoon: Boolean,
|
||||||
onPickGame: (GameDescriptor) -> Unit,
|
onPickGame: (GameDescriptor) -> Unit,
|
||||||
onJoinGame: () -> Unit,
|
onJoinGame: () -> Unit,
|
||||||
onSettings: () -> Unit,
|
onSettings: () -> Unit,
|
||||||
@@ -175,6 +213,10 @@ fun MainMenuScreen(
|
|||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
) {
|
) {
|
||||||
GameCategory.entries.forEach { category ->
|
GameCategory.entries.forEach { category ->
|
||||||
|
val games = GameCatalog.byCategory(category, includeComingSoon = showComingSoon)
|
||||||
|
// Don't leave a heading stranded over nothing.
|
||||||
|
if (games.isEmpty()) return@forEach
|
||||||
|
|
||||||
item(span = { androidx.compose.foundation.lazy.grid.GridItemSpan(maxLineSpan) }) {
|
item(span = { androidx.compose.foundation.lazy.grid.GridItemSpan(maxLineSpan) }) {
|
||||||
Text(
|
Text(
|
||||||
text = category.label,
|
text = category.label,
|
||||||
@@ -183,7 +225,7 @@ fun MainMenuScreen(
|
|||||||
modifier = Modifier.padding(top = 12.dp, bottom = 4.dp),
|
modifier = Modifier.padding(top = 12.dp, bottom = 4.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
items(GameCatalog.byCategory(category)) { game ->
|
items(games) { game ->
|
||||||
GameTile(game = game, onClick = { onPickGame(game) })
|
GameTile(game = game, onClick = { onPickGame(game) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import org.prolibertate.games.game.sequence.SequenceState
|
|||||||
import org.prolibertate.games.net.MatchController
|
import org.prolibertate.games.net.MatchController
|
||||||
import org.prolibertate.games.settings.Settings
|
import org.prolibertate.games.settings.Settings
|
||||||
import org.prolibertate.games.ui.game.EuchreScreen
|
import org.prolibertate.games.ui.game.EuchreScreen
|
||||||
|
import org.prolibertate.games.ui.game.TRICK_HOLD_MILLIS
|
||||||
import org.prolibertate.games.ui.game.SequenceScreen
|
import org.prolibertate.games.ui.game.SequenceScreen
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -58,10 +59,24 @@ fun PlayScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
aiThinkingMillis = { settings.scaled(700L) },
|
aiThinkingMillis = { settings.scaled(700L) },
|
||||||
|
// Keep the finished trick on the table before the next
|
||||||
|
// card lands on top of it.
|
||||||
|
holdBeforeNextMove = { state ->
|
||||||
|
if (state.completedTrick.isNotEmpty()) {
|
||||||
|
settings.scaled(TRICK_HOLD_MILLIS)
|
||||||
|
} else {
|
||||||
|
0L
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
StartMatch(env, controller, route)
|
StartMatch(env, controller, route)
|
||||||
EuchreScreen(controller = controller, localSeat = route.localSeat, onExit = onExit)
|
EuchreScreen(
|
||||||
|
controller = controller,
|
||||||
|
localSeat = route.localSeat,
|
||||||
|
trickHoldMillis = settings.scaled(TRICK_HOLD_MILLIS),
|
||||||
|
onExit = onExit,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
GameCatalog.SEQUENCE -> {
|
GameCatalog.SEQUENCE -> {
|
||||||
|
|||||||
@@ -36,7 +36,11 @@ fun SettingsScreen(
|
|||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var name by remember(settings.playerName) { mutableStateOf(settings.playerName) }
|
// Null until the user touches the field, at which point their text takes
|
||||||
|
// over for good. Keying this off the stored name instead meant every
|
||||||
|
// keystroke re-seeded the field from what had just been written, so the
|
||||||
|
// caret jumped and a cleared field refilled itself.
|
||||||
|
var editedName by remember { mutableStateOf<String?>(null) }
|
||||||
var updateStatus by remember { mutableStateOf<String?>(null) }
|
var updateStatus by remember { mutableStateOf<String?>(null) }
|
||||||
var pendingRelease by remember { mutableStateOf<Updater.Release?>(null) }
|
var pendingRelease by remember { mutableStateOf<Updater.Release?>(null) }
|
||||||
|
|
||||||
@@ -86,14 +90,22 @@ fun SettingsScreen(
|
|||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
)
|
)
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = name,
|
value = editedName ?: settings.playerName,
|
||||||
onValueChange = { entered ->
|
onValueChange = { entered ->
|
||||||
name = entered
|
editedName = entered
|
||||||
scope.launch { env.settingsRepository.setPlayerName(entered) }
|
scope.launch { env.settingsRepository.setPlayerName(entered) }
|
||||||
},
|
},
|
||||||
|
placeholder = { Text(Settings.DEFAULT_PLAYER_NAME) },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||||
)
|
)
|
||||||
|
if ((editedName ?: settings.playerName).isBlank()) {
|
||||||
|
Text(
|
||||||
|
text = "Leave it empty and you'll show up as " +
|
||||||
|
"${Settings.DEFAULT_PLAYER_NAME}.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package org.prolibertate.games.ui
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
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.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.Shadow
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.Font
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import org.prolibertate.games.R
|
||||||
|
import org.prolibertate.games.ui.theme.TartanBackground
|
||||||
|
|
||||||
|
/** How long the splash holds at full opacity before fading. */
|
||||||
|
private const val SPLASH_HOLD_MILLIS = 2_000L
|
||||||
|
|
||||||
|
private const val SPLASH_FADE_MILLIS = 600
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uncial Antiqua — a Celtic uncial face, bundled under the SIL Open Font
|
||||||
|
* License. See docs/licenses/UncialAntiqua-OFL.txt.
|
||||||
|
*/
|
||||||
|
val CelticFontFamily = FontFamily(Font(R.font.uncial_antiqua))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The launch screen: Wallace tartan under the name of the house.
|
||||||
|
*
|
||||||
|
* Drawn as an overlay rather than a separate destination, so the menu is
|
||||||
|
* already composed and ready behind it and the fade reveals a live screen
|
||||||
|
* instead of a blank one.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun SplashScreen(
|
||||||
|
onFinished: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
var fading by remember { mutableStateOf(false) }
|
||||||
|
val alpha by animateFloatAsState(
|
||||||
|
targetValue = if (fading) 0f else 1f,
|
||||||
|
animationSpec = tween(durationMillis = SPLASH_FADE_MILLIS),
|
||||||
|
label = "splashFade",
|
||||||
|
)
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
delay(SPLASH_HOLD_MILLIS)
|
||||||
|
fading = true
|
||||||
|
delay(SPLASH_FADE_MILLIS.toLong())
|
||||||
|
onFinished()
|
||||||
|
}
|
||||||
|
|
||||||
|
BoxWithConstraints(modifier = modifier.fillMaxSize().alpha(alpha)) {
|
||||||
|
// Read the constraints here and hold them in locals: inside Column the
|
||||||
|
// implicit receiver becomes ColumnScope, and maxWidth/maxHeight are no
|
||||||
|
// longer in scope.
|
||||||
|
val shortestSide = minOf(maxWidth, maxHeight).value
|
||||||
|
|
||||||
|
// Weave scale follows the screen so the cloth reads the same on a phone
|
||||||
|
// and a tablet rather than turning into a fine grid on a large display.
|
||||||
|
val threadWidth = (shortestSide * 0.012f).dp
|
||||||
|
val titleSize = (shortestSide * 0.13f).sp
|
||||||
|
TartanBackground(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
threadWidth = threadWidth,
|
||||||
|
)
|
||||||
|
|
||||||
|
// A scrim keeps the lettering legible over the busiest part of the sett.
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.Black.copy(alpha = 0.45f))
|
||||||
|
)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(horizontal = 24.dp),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Pro Libertate",
|
||||||
|
style = TextStyle(
|
||||||
|
fontFamily = CelticFontFamily,
|
||||||
|
fontSize = titleSize,
|
||||||
|
color = Color(0xFFF7EFD8),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
shadow = Shadow(
|
||||||
|
color = Color.Black,
|
||||||
|
offset = Offset(0f, 3f),
|
||||||
|
blurRadius = 10f,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Games",
|
||||||
|
style = TextStyle(
|
||||||
|
fontFamily = CelticFontFamily,
|
||||||
|
fontSize = titleSize,
|
||||||
|
color = Color(0xFFF2C200),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
shadow = Shadow(
|
||||||
|
color = Color.Black,
|
||||||
|
offset = Offset(0f, 3f),
|
||||||
|
blurRadius = 10f,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,6 +41,12 @@ fun PlayingCardView(
|
|||||||
width: Dp = 64.dp,
|
width: Dp = 64.dp,
|
||||||
selected: Boolean = false,
|
selected: Boolean = false,
|
||||||
enabled: Boolean = true,
|
enabled: Boolean = true,
|
||||||
|
/**
|
||||||
|
* Short note printed on the face, for when the rank and suit alone do not
|
||||||
|
* say what the card does — a Sequence jack being the obvious case, since
|
||||||
|
* one-eyed and two-eyed jacks are only distinguishable by their artwork.
|
||||||
|
*/
|
||||||
|
caption: String? = null,
|
||||||
onClick: (() -> Unit)? = null,
|
onClick: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val ink = if (card.suit.isRed) CardRed else CardBlack
|
val ink = if (card.suit.isRed) CardRed else CardBlack
|
||||||
@@ -61,6 +67,8 @@ fun PlayingCardView(
|
|||||||
),
|
),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
|
// Shrink the pips slightly when a caption has to share the face.
|
||||||
|
val pipScale = if (caption == null) 0.34f else 0.28f
|
||||||
Column(
|
Column(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
@@ -69,13 +77,22 @@ fun PlayingCardView(
|
|||||||
text = card.rank.short,
|
text = card.rank.short,
|
||||||
color = ink,
|
color = ink,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
fontSize = (width.value * 0.34f).sp,
|
fontSize = (width.value * pipScale).sp,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = card.suit.symbol,
|
text = card.suit.symbol,
|
||||||
color = ink,
|
color = ink,
|
||||||
fontSize = (width.value * 0.34f).sp,
|
fontSize = (width.value * pipScale).sp,
|
||||||
)
|
)
|
||||||
|
if (caption != null) {
|
||||||
|
Text(
|
||||||
|
text = caption,
|
||||||
|
color = ink,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = (width.value * 0.145f).sp,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
package org.prolibertate.games.ui.game
|
package org.prolibertate.games.ui.game
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
@@ -18,13 +23,22 @@ import androidx.compose.material3.OutlinedButton
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.draw.rotate
|
import androidx.compose.ui.draw.rotate
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlin.math.roundToInt
|
||||||
import org.prolibertate.games.game.euchre.CallTrump
|
import org.prolibertate.games.game.euchre.CallTrump
|
||||||
import org.prolibertate.games.game.euchre.Discard
|
import org.prolibertate.games.game.euchre.Discard
|
||||||
import org.prolibertate.games.game.euchre.EuchreMove
|
import org.prolibertate.games.game.euchre.EuchreMove
|
||||||
@@ -51,6 +65,7 @@ import org.prolibertate.games.ui.theme.FeltGreenDark
|
|||||||
fun EuchreScreen(
|
fun EuchreScreen(
|
||||||
controller: MatchController<EuchreState, EuchreMove>,
|
controller: MatchController<EuchreState, EuchreMove>,
|
||||||
localSeat: Int,
|
localSeat: Int,
|
||||||
|
trickHoldMillis: Long,
|
||||||
onExit: () -> Unit,
|
onExit: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val state by controller.state.collectAsState()
|
val state by controller.state.collectAsState()
|
||||||
@@ -84,7 +99,7 @@ fun EuchreScreen(
|
|||||||
.background(FeltGreenDark),
|
.background(FeltGreenDark),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
TrickArea(current, localSeat, cardWidth)
|
TrickArea(current, localSeat, cardWidth, trickHoldMillis)
|
||||||
}
|
}
|
||||||
|
|
||||||
notice?.let {
|
notice?.let {
|
||||||
@@ -166,8 +181,47 @@ private fun seatLabel(relative: Int): String = when (relative) {
|
|||||||
* turned to face them, so you can read a trick at a glance instead of matching
|
* turned to face them, so you can read a trick at a glance instead of matching
|
||||||
* seat numbers to cards.
|
* seat numbers to cards.
|
||||||
*/
|
*/
|
||||||
|
/** How long a completed trick rests on the table before being swept away. */
|
||||||
|
const val TRICK_HOLD_MILLIS = 1_000L
|
||||||
|
|
||||||
|
/** Duration of the sweep itself. */
|
||||||
|
private const val TRICK_SWEEP_MILLIS = 450
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun TrickArea(state: EuchreState, localSeat: Int, cardWidth: Dp) {
|
private fun TrickArea(
|
||||||
|
state: EuchreState,
|
||||||
|
localSeat: Int,
|
||||||
|
cardWidth: Dp,
|
||||||
|
trickHoldMillis: Long,
|
||||||
|
) {
|
||||||
|
// A finished trick stays put for a beat so it can be read, then slides off
|
||||||
|
// towards whoever won it.
|
||||||
|
val sweeping = state.trick.isEmpty() && state.completedTrick.isNotEmpty()
|
||||||
|
val cardsOnTable = if (sweeping) state.completedTrick else state.trick
|
||||||
|
|
||||||
|
var sweepStarted by remember(state.completedTrick) { mutableStateOf(false) }
|
||||||
|
LaunchedEffect(state.completedTrick) {
|
||||||
|
if (state.completedTrick.isNotEmpty()) {
|
||||||
|
delay(trickHoldMillis)
|
||||||
|
sweepStarted = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val sweep by animateFloatAsState(
|
||||||
|
targetValue = if (sweeping && sweepStarted) 1f else 0f,
|
||||||
|
animationSpec = tween(durationMillis = TRICK_SWEEP_MILLIS),
|
||||||
|
label = "trickSweep",
|
||||||
|
)
|
||||||
|
|
||||||
|
// Everything travels towards the winner's edge of the table together.
|
||||||
|
val winnerRelative = state.lastTrickWinner?.let { relativePosition(it, localSeat) } ?: 0
|
||||||
|
val (dirX, dirY) = when (winnerRelative) {
|
||||||
|
0 -> 0f to 1f
|
||||||
|
1 -> -1f to 0f
|
||||||
|
2 -> 0f to -1f
|
||||||
|
else -> 1f to 0f
|
||||||
|
}
|
||||||
|
val travelPx = with(LocalDensity.current) { (cardWidth * 2.5f).toPx() }
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize().padding(4.dp)) {
|
Box(modifier = Modifier.fillMaxSize().padding(4.dp)) {
|
||||||
|
|
||||||
// The turn card sits in the middle of the table during bidding.
|
// The turn card sits in the middle of the table during bidding.
|
||||||
@@ -185,7 +239,9 @@ private fun TrickArea(state: EuchreState, localSeat: Int, cardWidth: Dp) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.upCard == null && state.trick.isEmpty() && state.phase == EuchrePhase.PLAYING) {
|
if (state.upCard == null && cardsOnTable.isEmpty() &&
|
||||||
|
state.phase == EuchrePhase.PLAYING
|
||||||
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = if (state.turn == localSeat) "Lead a card" else "Waiting…",
|
text = if (state.turn == localSeat) "Lead a card" else "Waiting…",
|
||||||
color = MaterialTheme.colorScheme.onPrimary,
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
@@ -193,7 +249,7 @@ private fun TrickArea(state: EuchreState, localSeat: Int, cardWidth: Dp) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
state.trick.forEach { played ->
|
cardsOnTable.forEach { played ->
|
||||||
val relative = relativePosition(played.seat, localSeat)
|
val relative = relativePosition(played.seat, localSeat)
|
||||||
val alignment = when (relative) {
|
val alignment = when (relative) {
|
||||||
0 -> Alignment.BottomCenter
|
0 -> Alignment.BottomCenter
|
||||||
@@ -211,7 +267,15 @@ private fun TrickArea(state: EuchreState, localSeat: Int, cardWidth: Dp) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.align(alignment),
|
modifier = Modifier
|
||||||
|
.align(alignment)
|
||||||
|
.offset {
|
||||||
|
IntOffset(
|
||||||
|
x = (dirX * travelPx * sweep).roundToInt(),
|
||||||
|
y = (dirY * travelPx * sweep).roundToInt(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.alpha(1f - sweep),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
PlayingCardView(
|
PlayingCardView(
|
||||||
@@ -227,6 +291,16 @@ private fun TrickArea(state: EuchreState, localSeat: Int, cardWidth: Dp) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Name the winner while their cards are still on the table.
|
||||||
|
if (sweeping && state.lastTrickWinner != null) {
|
||||||
|
Text(
|
||||||
|
text = "${seatLabel(winnerRelative)} takes it",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
modifier = Modifier.align(Alignment.Center).alpha(1f - sweep),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Who is on the clock, shown where they sit rather than as a seat number.
|
// Who is on the clock, shown where they sit rather than as a seat number.
|
||||||
if (state.phase == EuchrePhase.PLAYING && state.trick.size < state.activeSeatCount) {
|
if (state.phase == EuchrePhase.PLAYING && state.trick.size < state.activeSeatCount) {
|
||||||
val waitingOn = relativePosition(state.turn, localSeat)
|
val waitingOn = relativePosition(state.turn, localSeat)
|
||||||
@@ -242,6 +316,7 @@ private fun TrickArea(state: EuchreState, localSeat: Int, cardWidth: Dp) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun BiddingControls(
|
private fun BiddingControls(
|
||||||
state: EuchreState,
|
state: EuchreState,
|
||||||
@@ -260,6 +335,8 @@ private fun BiddingControls(
|
|||||||
// the turn card to somebody else or to yourself.
|
// the turn card to somebody else or to yourself.
|
||||||
val youAreDealer = state.turn == state.dealer
|
val youAreDealer = state.turn == state.dealer
|
||||||
val upCard = state.upCard
|
val upCard = state.upCard
|
||||||
|
val canPass = Pass in legal
|
||||||
|
val choices = legal.filter { it != Pass }
|
||||||
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
Text(
|
Text(
|
||||||
@@ -282,13 +359,25 @@ private fun BiddingControls(
|
|||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Row(
|
// Stick the dealer is the one case where there is genuinely no way out,
|
||||||
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
// so say so rather than leaving it looking like a missing button.
|
||||||
|
if (!canPass) {
|
||||||
|
Text(
|
||||||
|
text = "Everyone has passed, so as dealer you must name a suit — " +
|
||||||
|
"that's stick the dealer. Turn it off in game setup if you'd " +
|
||||||
|
"rather the hand be thrown in.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wraps rather than scrolls. A scrolling row pushed Pass off the right
|
||||||
|
// edge of a phone screen, which made bidding look compulsory.
|
||||||
|
FlowRow(
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
) {
|
) {
|
||||||
legal.forEach { move ->
|
choices.forEach { move ->
|
||||||
val label = when (move) {
|
val label = when (move) {
|
||||||
is Pass -> "Pass"
|
|
||||||
is OrderUp -> when {
|
is OrderUp -> when {
|
||||||
youAreDealer && move.alone -> "Pick it up, alone"
|
youAreDealer && move.alone -> "Pick it up, alone"
|
||||||
youAreDealer -> "Pick it up"
|
youAreDealer -> "Pick it up"
|
||||||
@@ -301,13 +390,21 @@ private fun BiddingControls(
|
|||||||
|
|
||||||
is Discard -> "Discard ${move.card.label}"
|
is Discard -> "Discard ${move.card.label}"
|
||||||
is PlayCard -> move.card.label
|
is PlayCard -> move.card.label
|
||||||
|
is Pass -> "Pass"
|
||||||
}
|
}
|
||||||
if (move is Pass) {
|
|
||||||
OutlinedButton(onClick = { onMove(move) }) { Text(label) }
|
|
||||||
} else {
|
|
||||||
Button(onClick = { onMove(move) }) { Text(label) }
|
Button(onClick = { onMove(move) }) { Text(label) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Kept out of the wrapping group and full width so it can never be
|
||||||
|
// clipped, scrolled away or mistaken for one of the bids.
|
||||||
|
if (canPass) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { onMove(Pass) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text("Pass")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ import androidx.compose.ui.unit.sp
|
|||||||
import org.prolibertate.games.game.cards.Card
|
import org.prolibertate.games.game.cards.Card
|
||||||
import org.prolibertate.games.game.sequence.BOARD_SIZE
|
import org.prolibertate.games.game.sequence.BOARD_SIZE
|
||||||
import org.prolibertate.games.game.sequence.ExchangeDeadCard
|
import org.prolibertate.games.game.sequence.ExchangeDeadCard
|
||||||
|
import org.prolibertate.games.game.sequence.isOneEyedJack
|
||||||
|
import org.prolibertate.games.game.sequence.isTwoEyedJack
|
||||||
import org.prolibertate.games.game.sequence.NO_TEAM
|
import org.prolibertate.games.game.sequence.NO_TEAM
|
||||||
import org.prolibertate.games.game.sequence.PlaceChip
|
import org.prolibertate.games.game.sequence.PlaceChip
|
||||||
import org.prolibertate.games.game.sequence.RemoveChip
|
import org.prolibertate.games.game.sequence.RemoveChip
|
||||||
@@ -54,6 +56,17 @@ import org.prolibertate.games.ui.theme.TeamColours
|
|||||||
*/
|
*/
|
||||||
private val HighlightAmber = Color(0xFFFFC107)
|
private val HighlightAmber = Color(0xFFFFC107)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a jack does, printed on its face. On a real deck this is carried by the
|
||||||
|
* artwork — a jack shown in profile has one eye and removes a chip, one shown
|
||||||
|
* face-on has two and is wild — which a rank-and-suit card cannot convey.
|
||||||
|
*/
|
||||||
|
private fun jackCaption(card: Card): String? = when {
|
||||||
|
isTwoEyedJack(card) -> "WILD"
|
||||||
|
isOneEyedJack(card) -> "REMOVE"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The Sequence board.
|
* The Sequence board.
|
||||||
*
|
*
|
||||||
@@ -294,6 +307,9 @@ private fun HandStrip(
|
|||||||
width = cardWidth,
|
width = cardWidth,
|
||||||
selected = card == selected,
|
selected = card == selected,
|
||||||
enabled = card in playable,
|
enabled = card in playable,
|
||||||
|
// A jack's power comes from its artwork, which these cards
|
||||||
|
// do not have, so spell it out on the face instead.
|
||||||
|
caption = jackCaption(card),
|
||||||
onClick = {
|
onClick = {
|
||||||
val dead = exchanges[card]
|
val dead = exchanges[card]
|
||||||
if (dead != null) onExchange(dead) else onSelect(card)
|
if (dead != null) onExchange(dead) else onSelect(card)
|
||||||
@@ -301,9 +317,18 @@ private fun HandStrip(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
selected?.let {
|
selected?.let { card ->
|
||||||
Text(
|
Text(
|
||||||
text = "Tap a highlighted square to play ${it.label}.",
|
text = when {
|
||||||
|
isTwoEyedJack(card) ->
|
||||||
|
"${card.label} is wild — tap any empty square."
|
||||||
|
|
||||||
|
isOneEyedJack(card) ->
|
||||||
|
"${card.label} removes a chip — tap an opponent's, " +
|
||||||
|
"as long as it isn't part of a finished sequence."
|
||||||
|
|
||||||
|
else -> "Tap a highlighted square to play ${card.label}."
|
||||||
|
},
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package org.prolibertate.games.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/** One band of a tartan sett, measured in threads. */
|
||||||
|
data class TartanStripe(val color: Color, val threads: Int)
|
||||||
|
|
||||||
|
val WallaceRed = Color(0xFFB3121B)
|
||||||
|
val WallaceBlack = Color(0xFF111111)
|
||||||
|
val WallaceGold = Color(0xFFF2C200)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Wallace sett.
|
||||||
|
*
|
||||||
|
* NOTE: this is an approximation, not a certified threadcount. It reproduces
|
||||||
|
* what the Wallace tartan reads as — a bold red and black check divided by a
|
||||||
|
* narrow gold overstripe — but the exact thread counts held by the Scottish
|
||||||
|
* Register of Tartans could not be verified from here, so the numbers below
|
||||||
|
* are chosen to look right rather than to be authoritative. They are plain
|
||||||
|
* data: replacing them with the registered counts changes the whole app's
|
||||||
|
* branding and needs no other edit.
|
||||||
|
*/
|
||||||
|
val WallaceSett: List<TartanStripe> = listOf(
|
||||||
|
TartanStripe(WallaceRed, 40),
|
||||||
|
TartanStripe(WallaceBlack, 40),
|
||||||
|
TartanStripe(WallaceGold, 4),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draws a tartan by laying the sett down vertically as warp, then across as
|
||||||
|
* weft at half opacity. The overlap is what produces the blended squares and
|
||||||
|
* the sense of a woven cloth rather than a printed grid — red warp under black
|
||||||
|
* weft comes out a darker red, exactly as it does on the loom.
|
||||||
|
*/
|
||||||
|
fun DrawScope.drawTartan(
|
||||||
|
sett: List<TartanStripe> = WallaceSett,
|
||||||
|
threadSize: Float,
|
||||||
|
) {
|
||||||
|
if (sett.isEmpty() || threadSize <= 0f) return
|
||||||
|
val repeat = sett.sumOf { it.threads } * threadSize
|
||||||
|
if (repeat <= 0f) return
|
||||||
|
|
||||||
|
// Warp: full-height bands.
|
||||||
|
var x = 0f
|
||||||
|
while (x < size.width) {
|
||||||
|
var offset = x
|
||||||
|
for (stripe in sett) {
|
||||||
|
val width = stripe.threads * threadSize
|
||||||
|
drawRect(
|
||||||
|
color = stripe.color,
|
||||||
|
topLeft = Offset(offset, 0f),
|
||||||
|
size = Size(width, size.height),
|
||||||
|
)
|
||||||
|
offset += width
|
||||||
|
}
|
||||||
|
x += repeat
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weft: full-width bands, half opacity so the warp shows through.
|
||||||
|
var y = 0f
|
||||||
|
while (y < size.height) {
|
||||||
|
var offset = y
|
||||||
|
for (stripe in sett) {
|
||||||
|
val height = stripe.threads * threadSize
|
||||||
|
drawRect(
|
||||||
|
color = stripe.color.copy(alpha = 0.5f),
|
||||||
|
topLeft = Offset(0f, offset),
|
||||||
|
size = Size(size.width, height),
|
||||||
|
)
|
||||||
|
offset += height
|
||||||
|
}
|
||||||
|
y += repeat
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A tartan fill. [threadWidth] sets the weave scale, so the same cloth can be
|
||||||
|
* a fine weave on a phone and a coarse one on a tablet without redrawing it.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun TartanBackground(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
sett: List<TartanStripe> = WallaceSett,
|
||||||
|
threadWidth: Dp = 2.dp,
|
||||||
|
) {
|
||||||
|
Canvas(modifier = modifier) {
|
||||||
|
drawTartan(sett = sett, threadSize = threadWidth.toPx())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,59 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
A single square of the Wallace tartan.
|
||||||
|
|
||||||
|
The sett is laid down as warp (opaque, vertical) over a black ground, then as
|
||||||
|
weft (half opacity, horizontal), which is what produces the blended squares
|
||||||
|
rather than a flat grid. One full repeat is 72 units wide, so the centre
|
||||||
|
72x72 safe zone of the adaptive icon shows exactly one repeat.
|
||||||
|
|
||||||
|
Thread proportions match WallaceSett in Tartan.kt — see the note there about
|
||||||
|
these being a visual approximation rather than the registered threadcount.
|
||||||
|
-->
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:width="108dp"
|
android:width="108dp"
|
||||||
android:height="108dp"
|
android:height="108dp"
|
||||||
android:viewportWidth="108"
|
android:viewportWidth="108"
|
||||||
android:viewportHeight="108">
|
android:viewportHeight="108">
|
||||||
<!-- Solid grass-green field. -->
|
|
||||||
|
<!-- Ground -->
|
||||||
<path
|
<path
|
||||||
android:fillColor="#2E7D32"
|
android:fillColor="#111111"
|
||||||
android:pathData="M0,0h108v108h-108z" />
|
android:pathData="M0,0h108v108h-108z" />
|
||||||
|
|
||||||
|
<!-- Warp: red -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#B3121B"
|
||||||
|
android:pathData="M0,0h34.29v108h-34.29z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#B3121B"
|
||||||
|
android:pathData="M72,0h34.29v108h-34.29z" />
|
||||||
|
<!-- Warp: gold overstripe -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#F2C200"
|
||||||
|
android:pathData="M68.57,0h3.43v108h-3.43z" />
|
||||||
|
|
||||||
|
<!-- Weft: red -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#B3121B"
|
||||||
|
android:fillAlpha="0.5"
|
||||||
|
android:pathData="M0,0h108v34.29h-108z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#B3121B"
|
||||||
|
android:fillAlpha="0.5"
|
||||||
|
android:pathData="M0,72h108v34.29h-108z" />
|
||||||
|
<!-- Weft: black -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#111111"
|
||||||
|
android:fillAlpha="0.5"
|
||||||
|
android:pathData="M0,34.29h108v34.28h-108z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#111111"
|
||||||
|
android:fillAlpha="0.5"
|
||||||
|
android:pathData="M0,106.29h108v1.71h-108z" />
|
||||||
|
<!-- Weft: gold overstripe -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#F2C200"
|
||||||
|
android:fillAlpha="0.5"
|
||||||
|
android:pathData="M0,68.57h108v3.43h-108z" />
|
||||||
</vector>
|
</vector>
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Deliberately empty. The icon is the cloth itself, so the adaptive icon's
|
||||||
|
foreground layer carries nothing — anything drawn here would sit on top of the
|
||||||
|
tartan and break the "single square of tartan" idea.
|
||||||
|
-->
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:width="108dp"
|
android:width="108dp"
|
||||||
android:height="108dp"
|
android:height="108dp"
|
||||||
android:viewportWidth="108"
|
android:viewportWidth="108"
|
||||||
android:viewportHeight="108">
|
android:viewportHeight="108">
|
||||||
<!-- A black hole in the center of the icon's safe zone. -->
|
|
||||||
<path
|
<path
|
||||||
android:fillColor="#111111"
|
android:fillColor="#00000000"
|
||||||
android:pathData="M54,54m-26,0a26,26 0,1 1,52 0a26,26 0,1 1,-52 0" />
|
android:pathData="M0,0h108v108h-108z" />
|
||||||
<path
|
|
||||||
android:fillColor="#000000"
|
|
||||||
android:pathData="M54,54m-20,0a20,20 0,1 1,40 0a20,20 0,1 1,-40 0" />
|
|
||||||
</vector>
|
</vector>
|
||||||
|
|||||||
Binary file not shown.
@@ -1,17 +1,21 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!-- Fallback launcher icon for API < 26 (no adaptive-icon support). -->
|
<!-- Fallback launcher icon for API < 26, which has no adaptive-icon support.
|
||||||
|
Same Wallace tartan swatch as drawable/ic_launcher_background.xml, drawn
|
||||||
|
at icon scale so one full sett repeat fills the tile. -->
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:width="48dp"
|
android:width="48dp"
|
||||||
android:height="48dp"
|
android:height="48dp"
|
||||||
android:viewportWidth="108"
|
android:viewportWidth="84"
|
||||||
android:viewportHeight="108">
|
android:viewportHeight="84">
|
||||||
<path
|
|
||||||
android:fillColor="#2E7D32"
|
<path android:fillColor="#111111" android:pathData="M0,0h84v84h-84z" />
|
||||||
android:pathData="M0,0h108v108h-108z" />
|
|
||||||
<path
|
<!-- Warp -->
|
||||||
android:fillColor="#111111"
|
<path android:fillColor="#B3121B" android:pathData="M0,0h40v84h-40z" />
|
||||||
android:pathData="M54,54m-26,0a26,26 0,1 1,52 0a26,26 0,1 1,-52 0" />
|
<path android:fillColor="#F2C200" android:pathData="M80,0h4v84h-4z" />
|
||||||
<path
|
|
||||||
android:fillColor="#000000"
|
<!-- Weft -->
|
||||||
android:pathData="M54,54m-20,0a20,20 0,1 1,40 0a20,20 0,1 1,-40 0" />
|
<path android:fillColor="#B3121B" android:fillAlpha="0.5" android:pathData="M0,0h84v40h-84z" />
|
||||||
|
<path android:fillColor="#111111" android:fillAlpha="0.5" android:pathData="M0,40h84v40h-84z" />
|
||||||
|
<path android:fillColor="#F2C200" android:fillAlpha="0.5" android:pathData="M0,80h84v4h-84z" />
|
||||||
</vector>
|
</vector>
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!-- Fallback round launcher icon for API < 26. -->
|
<!-- Fallback launcher icon for API < 26, which has no adaptive-icon support.
|
||||||
|
Same Wallace tartan swatch as drawable/ic_launcher_background.xml, drawn
|
||||||
|
at icon scale so one full sett repeat fills the tile. -->
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:width="48dp"
|
android:width="48dp"
|
||||||
android:height="48dp"
|
android:height="48dp"
|
||||||
android:viewportWidth="108"
|
android:viewportWidth="84"
|
||||||
android:viewportHeight="108">
|
android:viewportHeight="84">
|
||||||
<path
|
|
||||||
android:fillColor="#2E7D32"
|
<path android:fillColor="#111111" android:pathData="M0,0h84v84h-84z" />
|
||||||
android:pathData="M54,54m-54,0a54,54 0,1 1,108 0a54,54 0,1 1,-108 0" />
|
|
||||||
<path
|
<!-- Warp -->
|
||||||
android:fillColor="#111111"
|
<path android:fillColor="#B3121B" android:pathData="M0,0h40v84h-40z" />
|
||||||
android:pathData="M54,54m-26,0a26,26 0,1 1,52 0a26,26 0,1 1,-52 0" />
|
<path android:fillColor="#F2C200" android:pathData="M80,0h4v84h-4z" />
|
||||||
<path
|
|
||||||
android:fillColor="#000000"
|
<!-- Weft -->
|
||||||
android:pathData="M54,54m-20,0a20,20 0,1 1,40 0a20,20 0,1 1,-40 0" />
|
<path android:fillColor="#B3121B" android:fillAlpha="0.5" android:pathData="M0,0h84v40h-84z" />
|
||||||
|
<path android:fillColor="#111111" android:fillAlpha="0.5" android:pathData="M0,40h84v40h-84z" />
|
||||||
|
<path android:fillColor="#F2C200" android:fillAlpha="0.5" android:pathData="M0,80h84v4h-84z" />
|
||||||
</vector>
|
</vector>
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<color name="ic_launcher_background">#1B5E20</color>
|
<color name="ic_launcher_background">#B3121B</color>
|
||||||
<color name="black">#FF000000</color>
|
<color name="black">#FF000000</color>
|
||||||
<color name="white">#FFFFFFFF</color>
|
<color name="white">#FFFFFFFF</color>
|
||||||
|
<!-- Shown for the instant before Compose draws, so launch does not flash
|
||||||
|
white before the tartan splash appears. -->
|
||||||
|
<color name="splash_background">#4A0B10</color>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -5,5 +5,6 @@
|
|||||||
<style name="Theme.ProLibertateGames" parent="android:Theme.Material.NoActionBar">
|
<style name="Theme.ProLibertateGames" parent="android:Theme.Material.NoActionBar">
|
||||||
<item name="android:windowNoTitle">true</item>
|
<item name="android:windowNoTitle">true</item>
|
||||||
<item name="android:windowContentOverlay">@null</item>
|
<item name="android:windowContentOverlay">@null</item>
|
||||||
|
<item name="android:windowBackground">@color/splash_background</item>
|
||||||
</style>
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package org.prolibertate.games.game
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class CatalogTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `every game has a unique id`() {
|
||||||
|
val ids = GameCatalog.all.map { it.id }
|
||||||
|
assertEquals("ids must be unique", ids.size, ids.distinct().size)
|
||||||
|
ids.forEach { assertEquals(it, GameCatalog.byId(it)?.id) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a production build lists only games that can be played`() {
|
||||||
|
GameCategory.entries.forEach { category ->
|
||||||
|
val shown = GameCatalog.byCategory(category, includeComingSoon = false)
|
||||||
|
assertTrue(
|
||||||
|
"$category offers an unfinished game in a release build",
|
||||||
|
shown.all { it.available },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a dev build lists everything including what is still coming`() {
|
||||||
|
val shown = GameCategory.entries.flatMap {
|
||||||
|
GameCatalog.byCategory(it, includeComingSoon = true)
|
||||||
|
}
|
||||||
|
assertEquals(GameCatalog.all.size, shown.size)
|
||||||
|
assertTrue("unfinished games are visible while developing", shown.any { !it.available })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `hiding unfinished games still leaves something to play in each category`() {
|
||||||
|
// A category filtered down to nothing would leave a stranded heading,
|
||||||
|
// and a menu with no playable game at all would be a broken release.
|
||||||
|
GameCategory.entries.forEach { category ->
|
||||||
|
assertFalse(
|
||||||
|
"$category has nothing playable in a release build",
|
||||||
|
GameCatalog.byCategory(category, includeComingSoon = false).isEmpty(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
assertTrue(GameCatalog.playable.isNotEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `playable games are the ones with engines wired up`() {
|
||||||
|
assertEquals(
|
||||||
|
setOf(GameCatalog.EUCHRE, GameCatalog.SEQUENCE),
|
||||||
|
GameCatalog.playable.map { it.id }.toSet(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `player counts are sane`() {
|
||||||
|
GameCatalog.all.forEach { game ->
|
||||||
|
assertTrue("${game.id} min players", game.minPlayers >= 2)
|
||||||
|
assertTrue("${game.id} max >= min", game.maxPlayers >= game.minPlayers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package org.prolibertate.games.game.euchre
|
||||||
|
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import org.prolibertate.games.game.engine.PlayerKind
|
||||||
|
import org.prolibertate.games.game.engine.PlayerSlot
|
||||||
|
import org.prolibertate.games.game.engine.TableConfig
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How often the AI takes the bid.
|
||||||
|
*
|
||||||
|
* This exists because an over-eager AI does not look like a tuning problem from
|
||||||
|
* the table — it looks like the game refusing to let you bid. If the three
|
||||||
|
* computer players order up nearly every deal, a human dealer is dragged into
|
||||||
|
* picking up the turn card again and again and never gets a say.
|
||||||
|
*/
|
||||||
|
class EuchreBiddingRateTest {
|
||||||
|
|
||||||
|
private val json = Json { encodeDefaults = true }
|
||||||
|
|
||||||
|
private fun config(seed: Long, options: EuchreOptions = EuchreOptions()) = TableConfig(
|
||||||
|
gameId = "euchre",
|
||||||
|
seats = (0 until 4).map {
|
||||||
|
PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it % 2)
|
||||||
|
},
|
||||||
|
optionsJson = json.encodeToString(options),
|
||||||
|
seed = seed,
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class Outcome(
|
||||||
|
val orderedUpInRoundOne: Boolean,
|
||||||
|
val reachedRoundTwo: Boolean,
|
||||||
|
val dealerForcedToPickUp: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Runs the bidding for one deal with every seat played by the AI. */
|
||||||
|
private fun bidOutDeal(seed: Long): Outcome {
|
||||||
|
val ai = EuchreAi()
|
||||||
|
var state = EuchreRules.initialState(config(seed))
|
||||||
|
val dealer = state.dealer
|
||||||
|
var reachedRoundTwo = false
|
||||||
|
var guard = 0
|
||||||
|
|
||||||
|
while (guard++ < 40) {
|
||||||
|
when (state.phase) {
|
||||||
|
EuchrePhase.BID_ROUND_1 -> Unit
|
||||||
|
EuchrePhase.BID_ROUND_2 -> reachedRoundTwo = true
|
||||||
|
else -> break
|
||||||
|
}
|
||||||
|
val seat = EuchreRules.currentSeat(state) ?: break
|
||||||
|
val legal = EuchreRules.legalMoves(state, seat)
|
||||||
|
state = EuchreRules.applyMove(state, seat, ai.chooseMove(state, seat, legal))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reaching the discard step means somebody ordered it up and the dealer
|
||||||
|
// now has to take the card whether they like it or not.
|
||||||
|
val orderedUpInRoundOne = !reachedRoundTwo && state.trump != null
|
||||||
|
return Outcome(
|
||||||
|
orderedUpInRoundOne = orderedUpInRoundOne,
|
||||||
|
reachedRoundTwo = reachedRoundTwo,
|
||||||
|
dealerForcedToPickUp = orderedUpInRoundOne && state.maker != dealer,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the ai does not order up on most deals`() {
|
||||||
|
val deals = 2000
|
||||||
|
val outcomes = (0 until deals).map { bidOutDeal(it.toLong()) }
|
||||||
|
|
||||||
|
val orderedUp = outcomes.count { it.orderedUpInRoundOne }
|
||||||
|
val forcedPickUp = outcomes.count { it.dealerForcedToPickUp }
|
||||||
|
val roundTwo = outcomes.count { it.reachedRoundTwo }
|
||||||
|
|
||||||
|
val orderedUpRate = orderedUp * 100.0 / deals
|
||||||
|
val forcedRate = forcedPickUp * 100.0 / deals
|
||||||
|
println(
|
||||||
|
"ordered up in round 1: %.1f%% dealer forced to pick up: %.1f%% reached round 2: %.1f%%"
|
||||||
|
.format(orderedUpRate, forcedRate, roundTwo * 100.0 / deals)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Ordering up should be a decision, not a formality. Real play turns the
|
||||||
|
// card down more often than not, so a majority of deals must survive
|
||||||
|
// round one.
|
||||||
|
assertTrue(
|
||||||
|
"AI orders up on %.1f%% of deals — far too eager".format(orderedUpRate),
|
||||||
|
orderedUpRate <= 55.0,
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
"AI almost never bids (%.1f%%) — too timid".format(orderedUpRate),
|
||||||
|
orderedUpRate >= 15.0,
|
||||||
|
)
|
||||||
|
// The specific complaint: as dealer you keep being handed the turn card.
|
||||||
|
assertTrue(
|
||||||
|
"dealer is dragged into picking up on %.1f%% of deals".format(forcedRate),
|
||||||
|
forcedRate <= 45.0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `going alone stays rare`() {
|
||||||
|
val ai = EuchreAi()
|
||||||
|
var alone = 0
|
||||||
|
val deals = 1000
|
||||||
|
repeat(deals) { seed ->
|
||||||
|
var state = EuchreRules.initialState(config(seed.toLong()))
|
||||||
|
var guard = 0
|
||||||
|
while (state.trump == null && guard++ < 40) {
|
||||||
|
val seat = EuchreRules.currentSeat(state) ?: break
|
||||||
|
val legal = EuchreRules.legalMoves(state, seat)
|
||||||
|
if (legal.isEmpty()) break
|
||||||
|
state = EuchreRules.applyMove(state, seat, ai.chooseMove(state, seat, legal))
|
||||||
|
}
|
||||||
|
if (state.aloneSeat != null) alone++
|
||||||
|
}
|
||||||
|
val rate = alone * 100.0 / deals
|
||||||
|
println("went alone: %.1f%%".format(rate))
|
||||||
|
assertTrue("going alone on %.1f%% of deals is too often".format(rate), rate <= 12.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -304,6 +304,45 @@ class EuchreRulesTest {
|
|||||||
assertEquals(0, finished.scores[0])
|
assertEquals(0, finished.scores[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- Completed tricks ---------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a finished trick stays on the table until the next card is played`() {
|
||||||
|
val trump = Suit.SPADES
|
||||||
|
val hands = listOf(
|
||||||
|
listOf(Card(Rank.ACE, Suit.SPADES), Card(Rank.NINE, Suit.HEARTS)),
|
||||||
|
listOf(Card(Rank.NINE, Suit.SPADES), Card(Rank.TEN, Suit.HEARTS)),
|
||||||
|
listOf(Card(Rank.TEN, Suit.SPADES), Card(Rank.QUEEN, Suit.HEARTS)),
|
||||||
|
listOf(Card(Rank.QUEEN, Suit.SPADES), Card(Rank.KING, Suit.HEARTS)),
|
||||||
|
)
|
||||||
|
var state = playingState(hands = hands, trump = trump)
|
||||||
|
|
||||||
|
// Play a full trick.
|
||||||
|
repeat(4) {
|
||||||
|
val seat = EuchreRules.currentSeat(state)!!
|
||||||
|
state = EuchreRules.applyMove(state, seat, PlayCard(state.hands[seat].first()))
|
||||||
|
}
|
||||||
|
|
||||||
|
assertTrue("the live trick is cleared", state.trick.isEmpty())
|
||||||
|
assertEquals("all four cards are held for display", 4, state.completedTrick.size)
|
||||||
|
assertEquals("seat 0 played the ace of trump", 0, state.lastTrickWinner)
|
||||||
|
|
||||||
|
// The next card played sweeps it away.
|
||||||
|
val next = EuchreRules.currentSeat(state)!!
|
||||||
|
state = EuchreRules.applyMove(state, next, PlayCard(state.hands[next].first()))
|
||||||
|
assertTrue("held trick is cleared once play resumes", state.completedTrick.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a fresh hand starts with nothing held on the table`() {
|
||||||
|
var state = EuchreRules.initialState(config())
|
||||||
|
assertTrue(state.completedTrick.isEmpty())
|
||||||
|
state = EuchreRules.applyMove(state, state.turn, OrderUp(alone = false))
|
||||||
|
val discard = EuchreRules.legalMoves(state, state.dealer).first() as Discard
|
||||||
|
state = EuchreRules.applyMove(state, state.dealer, discard)
|
||||||
|
assertTrue("play begins with a clear table", state.completedTrick.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
// -- Full games ---------------------------------------------------------
|
// -- Full games ---------------------------------------------------------
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -53,6 +53,11 @@ Play one card and place a chip on a matching empty square, then draw.
|
|||||||
- **Two-eyed jacks** (♦ and ♣) are wild: place a chip on any empty square.
|
- **Two-eyed jacks** (♦ and ♣) are wild: place a chip on any empty square.
|
||||||
- **One-eyed jacks** (♥ and ♠) remove one opposing chip — but never a chip that
|
- **One-eyed jacks** (♥ and ♠) remove one opposing chip — but never a chip that
|
||||||
is part of a completed sequence, and never your own team's chip.
|
is part of a completed sequence, and never your own team's chip.
|
||||||
|
|
||||||
|
On a physical deck the difference is carried by the artwork: a jack drawn in
|
||||||
|
profile shows one eye, a jack drawn face-on shows two. These cards are drawn as
|
||||||
|
rank and suit only, so a jack in hand is captioned **WILD** or **REMOVE**
|
||||||
|
instead.
|
||||||
- **Dead card — *toggle*, on by default.** If both squares printed with your card
|
- **Dead card — *toggle*, on by default.** If both squares printed with your card
|
||||||
are already occupied, you may discard it and draw a replacement. This does not
|
are already occupied, you may discard it and draw a replacement. This does not
|
||||||
cost your turn, and you may do it once per turn.
|
cost your turn, and you may do it once per turn.
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright (c) 2011 by Brian J. Bonislawsky DBA Astigmatic (AOETI) ([email protected]), with Reserved Font Name "Uncial Antiqua"
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
Reference in New Issue
Block a user