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 |
|
||||
| **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
|
||||
`available` in `GameCatalog` — the menu, lobby, networking, settings and OTA
|
||||
layers are game-agnostic and need no changes.
|
||||
@@ -53,6 +57,31 @@ them.
|
||||
- **Production** — stable builds, published from `main`.
|
||||
- **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
|
||||
|
||||
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 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) :
|
||||
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) {
|
||||
/** 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. */
|
||||
CAUTIOUS(bidThreshold = 11, aloneThreshold = 22),
|
||||
CAUTIOUS(bidThreshold = 27, aloneThreshold = 31),
|
||||
}
|
||||
|
||||
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 {
|
||||
var value = 0
|
||||
var trumpCount = 0
|
||||
for (card in hand) {
|
||||
value += when {
|
||||
isRightBower(card, trump) -> 9
|
||||
isLeftBower(card, trump) -> 7
|
||||
isTrumpCard(card, trump) -> 3 + (card.rank.order - Rank.NINE.order) / 2
|
||||
isRightBower(card, trump) -> 10
|
||||
isLeftBower(card, trump) -> 8
|
||||
isTrumpCard(card, trump) -> 4 + (card.rank.order - Rank.NINE.order) / 2
|
||||
card.rank == Rank.ACE -> 3
|
||||
card.rank == Rank.KING -> 1
|
||||
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 }
|
||||
value += sideSuits.count { suit -> hand.none { effectiveSuit(it, trump) == suit } } * 2
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +70,12 @@ data class EuchreState(
|
||||
val turn: Int,
|
||||
val passes: Int,
|
||||
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 tricksWon: List<Int>,
|
||||
/** 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,
|
||||
turn = leader,
|
||||
trick = emptyList(),
|
||||
completedTrick = emptyList(),
|
||||
passes = 0,
|
||||
)
|
||||
}
|
||||
@@ -285,7 +286,8 @@ object EuchreRules : GameRules<EuchreState, EuchreMove> {
|
||||
val hands = state.hands.toMutableList()
|
||||
hands[seat] = hands[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) {
|
||||
return played.copy(turn = nextActive(state, seat))
|
||||
@@ -300,6 +302,9 @@ object EuchreRules : GameRules<EuchreState, EuchreMove> {
|
||||
|
||||
val afterTrick = played.copy(
|
||||
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,
|
||||
leader = winner,
|
||||
turn = winner,
|
||||
|
||||
@@ -41,6 +41,12 @@ class MatchController<S : Any, M : Any>(
|
||||
private val advanceIdle: (S) -> S? = { null },
|
||||
/** Scaled by the animation-speed setting so the table paces itself. */
|
||||
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 }
|
||||
@@ -207,7 +213,7 @@ class MatchController<S : Any, M : Any>(
|
||||
|
||||
if (seatKinds[seat] != PlayerKind.AI) return // a person's turn
|
||||
|
||||
delay(aiThinkingMillis())
|
||||
delay(holdBeforeNextMove(current) + aiThinkingMillis())
|
||||
val chosen = lock.withLock {
|
||||
val live = authoritative ?: return
|
||||
if (rules.currentSeat(live) != seat) return@withLock null
|
||||
|
||||
@@ -25,10 +25,23 @@ data class Settings(
|
||||
val soundEnabled: Boolean = true,
|
||||
val animationSpeed: Float = 1.0f,
|
||||
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. */
|
||||
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. */
|
||||
fun scaled(millis: Long): Long =
|
||||
(millis / animationSpeed.coerceIn(MIN_SPEED, MAX_SPEED)).toLong()
|
||||
@@ -36,6 +49,7 @@ data class Settings(
|
||||
companion object {
|
||||
const val MIN_SPEED = 0.5f
|
||||
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,
|
||||
animationSpeed = prefs[Keys.SPEED] ?: 1.0f,
|
||||
checkForUpdatesOnLaunch = prefs[Keys.UPDATE_ON_LAUNCH] ?: true,
|
||||
playerName = prefs[Keys.NAME] ?: "Player",
|
||||
playerName = prefs[Keys.NAME] ?: "",
|
||||
updateChannel = prefs[Keys.CHANNEL]
|
||||
?.let { stored -> runCatching { UpdateChannel.valueOf(stored) }.getOrNull() }
|
||||
?: UpdateChannel.PRODUCTION,
|
||||
@@ -79,7 +93,8 @@ class SettingsRepository(private val context: Context) {
|
||||
context.dataStore.edit { it[Keys.UPDATE_ON_LAUNCH] = enabled }
|
||||
}
|
||||
|
||||
/** Stores the name verbatim. The fallback belongs to [Settings.displayName]. */
|
||||
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.settings.Settings
|
||||
import org.prolibertate.games.settings.SettingsRepository
|
||||
import org.prolibertate.games.update.UpdateChannel
|
||||
import org.prolibertate.games.update.Updater
|
||||
|
||||
/** 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())
|
||||
var stack by remember { mutableStateOf(listOf<Route>(Route.Menu)) }
|
||||
|
||||
fun push(route: Route) {
|
||||
stack = stack + route
|
||||
// Read from the installed APK's own version name, so this follows the build
|
||||
// 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) {
|
||||
// Leaving a lobby or a table tears the networking down with it.
|
||||
when (stack.last()) {
|
||||
@@ -92,8 +97,40 @@ fun AppRoot(env: AppEnv) {
|
||||
|
||||
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()) {
|
||||
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)) },
|
||||
onJoinGame = { push(Route.Lobby(gameId = "", optionsJson = "{}", hosting = false)) },
|
||||
onSettings = { push(Route.Settings) },
|
||||
@@ -107,7 +144,7 @@ fun AppRoot(env: AppEnv) {
|
||||
|
||||
is Route.Setup -> GameSetupScreen(
|
||||
descriptor = GameCatalog.byId(route.gameId)!!,
|
||||
playerName = settings.playerName,
|
||||
playerName = settings.displayName,
|
||||
onBack = { pop() },
|
||||
onPlayOffline = { config -> push(Route.Play(route.gameId, config, true, 0)) },
|
||||
onHostOnline = { optionsJson ->
|
||||
@@ -118,7 +155,7 @@ fun AppRoot(env: AppEnv) {
|
||||
is Route.Lobby -> LobbyScreen(
|
||||
env = env,
|
||||
route = route,
|
||||
playerName = settings.playerName,
|
||||
playerName = settings.displayName,
|
||||
onBack = { pop() },
|
||||
onStart = { config, hosting, seat ->
|
||||
push(Route.Play(config.gameId, config, hosting, seat))
|
||||
@@ -141,6 +178,7 @@ fun AppRoot(env: AppEnv) {
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MainMenuScreen(
|
||||
showComingSoon: Boolean,
|
||||
onPickGame: (GameDescriptor) -> Unit,
|
||||
onJoinGame: () -> Unit,
|
||||
onSettings: () -> Unit,
|
||||
@@ -175,6 +213,10 @@ fun MainMenuScreen(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
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) }) {
|
||||
Text(
|
||||
text = category.label,
|
||||
@@ -183,7 +225,7 @@ fun MainMenuScreen(
|
||||
modifier = Modifier.padding(top = 12.dp, bottom = 4.dp),
|
||||
)
|
||||
}
|
||||
items(GameCatalog.byCategory(category)) { game ->
|
||||
items(games) { 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.settings.Settings
|
||||
import org.prolibertate.games.ui.game.EuchreScreen
|
||||
import org.prolibertate.games.ui.game.TRICK_HOLD_MILLIS
|
||||
import org.prolibertate.games.ui.game.SequenceScreen
|
||||
|
||||
/**
|
||||
@@ -58,10 +59,24 @@ fun PlayScreen(
|
||||
}
|
||||
},
|
||||
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)
|
||||
EuchreScreen(controller = controller, localSeat = route.localSeat, onExit = onExit)
|
||||
EuchreScreen(
|
||||
controller = controller,
|
||||
localSeat = route.localSeat,
|
||||
trickHoldMillis = settings.scaled(TRICK_HOLD_MILLIS),
|
||||
onExit = onExit,
|
||||
)
|
||||
}
|
||||
|
||||
GameCatalog.SEQUENCE -> {
|
||||
|
||||
@@ -36,7 +36,11 @@ fun SettingsScreen(
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
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 pendingRelease by remember { mutableStateOf<Updater.Release?>(null) }
|
||||
|
||||
@@ -86,14 +90,22 @@ fun SettingsScreen(
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
value = editedName ?: settings.playerName,
|
||||
onValueChange = { entered ->
|
||||
name = entered
|
||||
editedName = entered
|
||||
scope.launch { env.settingsRepository.setPlayerName(entered) }
|
||||
},
|
||||
placeholder = { Text(Settings.DEFAULT_PLAYER_NAME) },
|
||||
singleLine = true,
|
||||
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()
|
||||
|
||||
@@ -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,
|
||||
selected: Boolean = false,
|
||||
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,
|
||||
) {
|
||||
val ink = if (card.suit.isRed) CardRed else CardBlack
|
||||
@@ -61,6 +67,8 @@ fun PlayingCardView(
|
||||
),
|
||||
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(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
@@ -69,13 +77,22 @@ fun PlayingCardView(
|
||||
text = card.rank.short,
|
||||
color = ink,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = (width.value * 0.34f).sp,
|
||||
fontSize = (width.value * pipScale).sp,
|
||||
)
|
||||
Text(
|
||||
text = card.suit.symbol,
|
||||
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
|
||||
|
||||
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.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
@@ -18,13 +23,22 @@ import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
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.text.font.FontWeight
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
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.Discard
|
||||
import org.prolibertate.games.game.euchre.EuchreMove
|
||||
@@ -51,6 +65,7 @@ import org.prolibertate.games.ui.theme.FeltGreenDark
|
||||
fun EuchreScreen(
|
||||
controller: MatchController<EuchreState, EuchreMove>,
|
||||
localSeat: Int,
|
||||
trickHoldMillis: Long,
|
||||
onExit: () -> Unit,
|
||||
) {
|
||||
val state by controller.state.collectAsState()
|
||||
@@ -84,7 +99,7 @@ fun EuchreScreen(
|
||||
.background(FeltGreenDark),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
TrickArea(current, localSeat, cardWidth)
|
||||
TrickArea(current, localSeat, cardWidth, trickHoldMillis)
|
||||
}
|
||||
|
||||
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
|
||||
* 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
|
||||
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)) {
|
||||
|
||||
// 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 = if (state.turn == localSeat) "Lead a card" else "Waiting…",
|
||||
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 alignment = when (relative) {
|
||||
0 -> Alignment.BottomCenter
|
||||
@@ -211,7 +267,15 @@ private fun TrickArea(state: EuchreState, localSeat: Int, cardWidth: Dp) {
|
||||
}
|
||||
|
||||
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,
|
||||
) {
|
||||
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.
|
||||
if (state.phase == EuchrePhase.PLAYING && state.trick.size < state.activeSeatCount) {
|
||||
val waitingOn = relativePosition(state.turn, localSeat)
|
||||
@@ -242,6 +316,7 @@ private fun TrickArea(state: EuchreState, localSeat: Int, cardWidth: Dp) {
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun BiddingControls(
|
||||
state: EuchreState,
|
||||
@@ -260,6 +335,8 @@ private fun BiddingControls(
|
||||
// the turn card to somebody else or to yourself.
|
||||
val youAreDealer = state.turn == state.dealer
|
||||
val upCard = state.upCard
|
||||
val canPass = Pass in legal
|
||||
val choices = legal.filter { it != Pass }
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(
|
||||
@@ -282,13 +359,25 @@ private fun BiddingControls(
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
||||
// Stick the dealer is the one case where there is genuinely no way out,
|
||||
// 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),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
legal.forEach { move ->
|
||||
choices.forEach { move ->
|
||||
val label = when (move) {
|
||||
is Pass -> "Pass"
|
||||
is OrderUp -> when {
|
||||
youAreDealer && move.alone -> "Pick it up, alone"
|
||||
youAreDealer -> "Pick it up"
|
||||
@@ -301,13 +390,21 @@ private fun BiddingControls(
|
||||
|
||||
is Discard -> "Discard ${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) }
|
||||
}
|
||||
}
|
||||
|
||||
// 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.sequence.BOARD_SIZE
|
||||
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.PlaceChip
|
||||
import org.prolibertate.games.game.sequence.RemoveChip
|
||||
@@ -54,6 +56,17 @@ import org.prolibertate.games.ui.theme.TeamColours
|
||||
*/
|
||||
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.
|
||||
*
|
||||
@@ -294,6 +307,9 @@ private fun HandStrip(
|
||||
width = cardWidth,
|
||||
selected = card == selected,
|
||||
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 = {
|
||||
val dead = exchanges[card]
|
||||
if (dead != null) onExchange(dead) else onSelect(card)
|
||||
@@ -301,9 +317,18 @@ private fun HandStrip(
|
||||
)
|
||||
}
|
||||
}
|
||||
selected?.let {
|
||||
selected?.let { card ->
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"?>
|
||||
<!--
|
||||
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"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<!-- Solid grass-green field. -->
|
||||
|
||||
<!-- Ground -->
|
||||
<path
|
||||
android:fillColor="#2E7D32"
|
||||
android:fillColor="#111111"
|
||||
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>
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
<?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"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<!-- A black hole in the center of the icon's safe zone. -->
|
||||
<path
|
||||
android:fillColor="#111111"
|
||||
android:pathData="M54,54m-26,0a26,26 0,1 1,52 0a26,26 0,1 1,-52 0" />
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M54,54m-20,0a20,20 0,1 1,40 0a20,20 0,1 1,-40 0" />
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
</vector>
|
||||
|
||||
Binary file not shown.
@@ -1,17 +1,21 @@
|
||||
<?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"
|
||||
android:width="48dp"
|
||||
android:height="48dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#2E7D32"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="#111111"
|
||||
android:pathData="M54,54m-26,0a26,26 0,1 1,52 0a26,26 0,1 1,-52 0" />
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M54,54m-20,0a20,20 0,1 1,40 0a20,20 0,1 1,-40 0" />
|
||||
android:viewportWidth="84"
|
||||
android:viewportHeight="84">
|
||||
|
||||
<path android:fillColor="#111111" android:pathData="M0,0h84v84h-84z" />
|
||||
|
||||
<!-- Warp -->
|
||||
<path android:fillColor="#B3121B" android:pathData="M0,0h40v84h-40z" />
|
||||
<path android:fillColor="#F2C200" android:pathData="M80,0h4v84h-4z" />
|
||||
|
||||
<!-- Weft -->
|
||||
<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>
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
<?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"
|
||||
android:width="48dp"
|
||||
android:height="48dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#2E7D32"
|
||||
android:pathData="M54,54m-54,0a54,54 0,1 1,108 0a54,54 0,1 1,-108 0" />
|
||||
<path
|
||||
android:fillColor="#111111"
|
||||
android:pathData="M54,54m-26,0a26,26 0,1 1,52 0a26,26 0,1 1,-52 0" />
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M54,54m-20,0a20,20 0,1 1,40 0a20,20 0,1 1,-40 0" />
|
||||
android:viewportWidth="84"
|
||||
android:viewportHeight="84">
|
||||
|
||||
<path android:fillColor="#111111" android:pathData="M0,0h84v84h-84z" />
|
||||
|
||||
<!-- Warp -->
|
||||
<path android:fillColor="#B3121B" android:pathData="M0,0h40v84h-40z" />
|
||||
<path android:fillColor="#F2C200" android:pathData="M80,0h4v84h-4z" />
|
||||
|
||||
<!-- Weft -->
|
||||
<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>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#1B5E20</color>
|
||||
<color name="ic_launcher_background">#B3121B</color>
|
||||
<color name="black">#FF000000</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>
|
||||
|
||||
@@ -5,5 +5,6 @@
|
||||
<style name="Theme.ProLibertateGames" parent="android:Theme.Material.NoActionBar">
|
||||
<item name="android:windowNoTitle">true</item>
|
||||
<item name="android:windowContentOverlay">@null</item>
|
||||
<item name="android:windowBackground">@color/splash_background</item>
|
||||
</style>
|
||||
</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])
|
||||
}
|
||||
|
||||
// -- 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 ---------------------------------------------------------
|
||||
|
||||
@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.
|
||||
- **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.
|
||||
|
||||
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
|
||||
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.
|
||||
|
||||
@@ -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