Merge pull request #21: a menu tree
Solitaire, Card Games and Board Games, with Trick-taking under Card Games, and a game listed in every menu it belongs to rather than just one. Verified by ci.yml run 105.
This commit is contained in:
@@ -1,22 +1,39 @@
|
|||||||
package org.prolibertate.games.game
|
package org.prolibertate.games.game
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The catalogue the main menu is built from.
|
* The menus the main screen is built from.
|
||||||
*
|
*
|
||||||
* Every game the app intends to ship appears here, whether or not its rules
|
* A tree rather than a list, because "trick-taking" is a kind of card game
|
||||||
* engine exists yet. [GameDescriptor.available] is what the menu keys off to
|
* rather than a rival to it. [parent] is read from a `when` rather than passed
|
||||||
* decide between "play" and "coming soon", so adding a game is a matter of
|
* to the constructor: an enum entry cannot refer to another entry while the
|
||||||
* flipping that flag once its engine and screen land.
|
* class is still initialising, and a lookup afterwards is free.
|
||||||
*/
|
*/
|
||||||
enum class GameCategory(val label: String) {
|
enum class GameMenu(val label: String, val blurb: String) {
|
||||||
CARD("Playing Card Games"),
|
SOLITAIRE("Solitaire", "Played alone, against the deal or the board."),
|
||||||
BOARD("Board Games"),
|
CARDS("Card Games", "Anything played with a pack."),
|
||||||
|
TRICK_TAKING("Trick-taking", "Lead, follow, and count what you took."),
|
||||||
|
BOARD("Board Games", "Boards, pieces and dice."),
|
||||||
|
;
|
||||||
|
|
||||||
|
val parent: GameMenu? get() = when (this) {
|
||||||
|
TRICK_TAKING -> CARDS
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
val children: List<GameMenu> get() = entries.filter { it.parent == this }
|
||||||
|
|
||||||
|
val isTopLevel: Boolean get() = parent == null
|
||||||
}
|
}
|
||||||
|
|
||||||
data class GameDescriptor(
|
data class GameDescriptor(
|
||||||
val id: String,
|
val id: String,
|
||||||
val title: String,
|
val title: String,
|
||||||
val category: GameCategory,
|
/**
|
||||||
|
* Every menu this game appears under. A patience is both solitaire and a
|
||||||
|
* card game, and Euchre is both a card game and a trick-taking one — so
|
||||||
|
* this is a set, and the same game is listed in each place it belongs.
|
||||||
|
*/
|
||||||
|
val menus: Set<GameMenu>,
|
||||||
val minPlayers: Int,
|
val minPlayers: Int,
|
||||||
val maxPlayers: Int,
|
val maxPlayers: Int,
|
||||||
/** True when players are grouped into partnerships rather than playing solo. */
|
/** True when players are grouped into partnerships rather than playing solo. */
|
||||||
@@ -25,6 +42,14 @@ data class GameDescriptor(
|
|||||||
val available: Boolean,
|
val available: Boolean,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The catalogue the menus are built from.
|
||||||
|
*
|
||||||
|
* Every game the app intends to ship appears here, whether or not its rules
|
||||||
|
* engine exists yet. [GameDescriptor.available] is what the menu keys off to
|
||||||
|
* decide between "play" and "coming soon", so adding a game is a matter of
|
||||||
|
* flipping that flag once its engine and screen land.
|
||||||
|
*/
|
||||||
object GameCatalog {
|
object GameCatalog {
|
||||||
|
|
||||||
const val EUCHRE = "euchre"
|
const val EUCHRE = "euchre"
|
||||||
@@ -56,7 +81,7 @@ object GameCatalog {
|
|||||||
GameDescriptor(
|
GameDescriptor(
|
||||||
id = EUCHRE,
|
id = EUCHRE,
|
||||||
title = "Euchre",
|
title = "Euchre",
|
||||||
category = GameCategory.CARD,
|
menus = setOf(GameMenu.CARDS, GameMenu.TRICK_TAKING),
|
||||||
minPlayers = 4,
|
minPlayers = 4,
|
||||||
maxPlayers = 4,
|
maxPlayers = 4,
|
||||||
teamBased = true,
|
teamBased = true,
|
||||||
@@ -66,7 +91,7 @@ object GameCatalog {
|
|||||||
GameDescriptor(
|
GameDescriptor(
|
||||||
id = KAISER,
|
id = KAISER,
|
||||||
title = "Kaiser",
|
title = "Kaiser",
|
||||||
category = GameCategory.CARD,
|
menus = setOf(GameMenu.CARDS, GameMenu.TRICK_TAKING),
|
||||||
minPlayers = 4,
|
minPlayers = 4,
|
||||||
maxPlayers = 4,
|
maxPlayers = 4,
|
||||||
teamBased = true,
|
teamBased = true,
|
||||||
@@ -76,7 +101,7 @@ object GameCatalog {
|
|||||||
GameDescriptor(
|
GameDescriptor(
|
||||||
id = PRESIDENT,
|
id = PRESIDENT,
|
||||||
title = "President",
|
title = "President",
|
||||||
category = GameCategory.CARD,
|
menus = setOf(GameMenu.CARDS),
|
||||||
minPlayers = 3,
|
minPlayers = 3,
|
||||||
maxPlayers = 7,
|
maxPlayers = 7,
|
||||||
teamBased = false,
|
teamBased = false,
|
||||||
@@ -86,7 +111,7 @@ object GameCatalog {
|
|||||||
GameDescriptor(
|
GameDescriptor(
|
||||||
id = GOLF,
|
id = GOLF,
|
||||||
title = "Golf",
|
title = "Golf",
|
||||||
category = GameCategory.CARD,
|
menus = setOf(GameMenu.CARDS),
|
||||||
minPlayers = 2,
|
minPlayers = 2,
|
||||||
maxPlayers = 6,
|
maxPlayers = 6,
|
||||||
teamBased = false,
|
teamBased = false,
|
||||||
@@ -96,7 +121,7 @@ object GameCatalog {
|
|||||||
GameDescriptor(
|
GameDescriptor(
|
||||||
id = WIZARD,
|
id = WIZARD,
|
||||||
title = "Wizard",
|
title = "Wizard",
|
||||||
category = GameCategory.CARD,
|
menus = setOf(GameMenu.CARDS, GameMenu.TRICK_TAKING),
|
||||||
minPlayers = 3,
|
minPlayers = 3,
|
||||||
maxPlayers = 6,
|
maxPlayers = 6,
|
||||||
teamBased = false,
|
teamBased = false,
|
||||||
@@ -106,7 +131,7 @@ object GameCatalog {
|
|||||||
GameDescriptor(
|
GameDescriptor(
|
||||||
id = CRAZY_EIGHTS,
|
id = CRAZY_EIGHTS,
|
||||||
title = "Crazy 8s",
|
title = "Crazy 8s",
|
||||||
category = GameCategory.CARD,
|
menus = setOf(GameMenu.CARDS),
|
||||||
minPlayers = 2,
|
minPlayers = 2,
|
||||||
maxPlayers = 6,
|
maxPlayers = 6,
|
||||||
teamBased = false,
|
teamBased = false,
|
||||||
@@ -116,7 +141,7 @@ object GameCatalog {
|
|||||||
GameDescriptor(
|
GameDescriptor(
|
||||||
id = CRIBBAGE,
|
id = CRIBBAGE,
|
||||||
title = "Cribbage",
|
title = "Cribbage",
|
||||||
category = GameCategory.CARD,
|
menus = setOf(GameMenu.CARDS),
|
||||||
minPlayers = 2,
|
minPlayers = 2,
|
||||||
maxPlayers = 4,
|
maxPlayers = 4,
|
||||||
// Only at four, where the two pairs share a board between them.
|
// Only at four, where the two pairs share a board between them.
|
||||||
@@ -128,7 +153,7 @@ object GameCatalog {
|
|||||||
GameDescriptor(
|
GameDescriptor(
|
||||||
id = HEARTS,
|
id = HEARTS,
|
||||||
title = "Hearts",
|
title = "Hearts",
|
||||||
category = GameCategory.CARD,
|
menus = setOf(GameMenu.CARDS, GameMenu.TRICK_TAKING),
|
||||||
minPlayers = 4,
|
minPlayers = 4,
|
||||||
maxPlayers = 4,
|
maxPlayers = 4,
|
||||||
teamBased = false,
|
teamBased = false,
|
||||||
@@ -139,7 +164,7 @@ object GameCatalog {
|
|||||||
GameDescriptor(
|
GameDescriptor(
|
||||||
id = KLONDIKE,
|
id = KLONDIKE,
|
||||||
title = "Klondike",
|
title = "Klondike",
|
||||||
category = GameCategory.CARD,
|
menus = setOf(GameMenu.SOLITAIRE, GameMenu.CARDS),
|
||||||
minPlayers = 1,
|
minPlayers = 1,
|
||||||
maxPlayers = 1,
|
maxPlayers = 1,
|
||||||
teamBased = false,
|
teamBased = false,
|
||||||
@@ -150,7 +175,7 @@ object GameCatalog {
|
|||||||
GameDescriptor(
|
GameDescriptor(
|
||||||
id = FREECELL,
|
id = FREECELL,
|
||||||
title = "FreeCell",
|
title = "FreeCell",
|
||||||
category = GameCategory.CARD,
|
menus = setOf(GameMenu.SOLITAIRE, GameMenu.CARDS),
|
||||||
minPlayers = 1,
|
minPlayers = 1,
|
||||||
maxPlayers = 1,
|
maxPlayers = 1,
|
||||||
teamBased = false,
|
teamBased = false,
|
||||||
@@ -161,7 +186,7 @@ object GameCatalog {
|
|||||||
GameDescriptor(
|
GameDescriptor(
|
||||||
id = SPIDER,
|
id = SPIDER,
|
||||||
title = "Spider",
|
title = "Spider",
|
||||||
category = GameCategory.CARD,
|
menus = setOf(GameMenu.SOLITAIRE, GameMenu.CARDS),
|
||||||
minPlayers = 1,
|
minPlayers = 1,
|
||||||
maxPlayers = 1,
|
maxPlayers = 1,
|
||||||
teamBased = false,
|
teamBased = false,
|
||||||
@@ -172,7 +197,7 @@ object GameCatalog {
|
|||||||
GameDescriptor(
|
GameDescriptor(
|
||||||
id = PYRAMID,
|
id = PYRAMID,
|
||||||
title = "Pyramid",
|
title = "Pyramid",
|
||||||
category = GameCategory.CARD,
|
menus = setOf(GameMenu.SOLITAIRE, GameMenu.CARDS),
|
||||||
minPlayers = 1,
|
minPlayers = 1,
|
||||||
maxPlayers = 1,
|
maxPlayers = 1,
|
||||||
teamBased = false,
|
teamBased = false,
|
||||||
@@ -183,7 +208,7 @@ object GameCatalog {
|
|||||||
GameDescriptor(
|
GameDescriptor(
|
||||||
id = SEQUENCE,
|
id = SEQUENCE,
|
||||||
title = "Sequence",
|
title = "Sequence",
|
||||||
category = GameCategory.BOARD,
|
menus = setOf(GameMenu.BOARD, GameMenu.CARDS),
|
||||||
minPlayers = 2,
|
minPlayers = 2,
|
||||||
maxPlayers = 12,
|
maxPlayers = 12,
|
||||||
teamBased = true,
|
teamBased = true,
|
||||||
@@ -193,7 +218,7 @@ object GameCatalog {
|
|||||||
GameDescriptor(
|
GameDescriptor(
|
||||||
id = MASTERMIND,
|
id = MASTERMIND,
|
||||||
title = "Mastermind",
|
title = "Mastermind",
|
||||||
category = GameCategory.BOARD,
|
menus = setOf(GameMenu.BOARD),
|
||||||
minPlayers = 2,
|
minPlayers = 2,
|
||||||
maxPlayers = 2,
|
maxPlayers = 2,
|
||||||
teamBased = false,
|
teamBased = false,
|
||||||
@@ -204,7 +229,7 @@ object GameCatalog {
|
|||||||
GameDescriptor(
|
GameDescriptor(
|
||||||
id = BACKGAMMON,
|
id = BACKGAMMON,
|
||||||
title = "Backgammon",
|
title = "Backgammon",
|
||||||
category = GameCategory.BOARD,
|
menus = setOf(GameMenu.BOARD),
|
||||||
minPlayers = 2,
|
minPlayers = 2,
|
||||||
maxPlayers = 2,
|
maxPlayers = 2,
|
||||||
teamBased = false,
|
teamBased = false,
|
||||||
@@ -214,7 +239,7 @@ object GameCatalog {
|
|||||||
GameDescriptor(
|
GameDescriptor(
|
||||||
id = CHESS,
|
id = CHESS,
|
||||||
title = "Chess",
|
title = "Chess",
|
||||||
category = GameCategory.BOARD,
|
menus = setOf(GameMenu.BOARD),
|
||||||
minPlayers = 2,
|
minPlayers = 2,
|
||||||
maxPlayers = 2,
|
maxPlayers = 2,
|
||||||
teamBased = false,
|
teamBased = false,
|
||||||
@@ -224,7 +249,7 @@ object GameCatalog {
|
|||||||
GameDescriptor(
|
GameDescriptor(
|
||||||
id = CHECKERS,
|
id = CHECKERS,
|
||||||
title = "Checkers",
|
title = "Checkers",
|
||||||
category = GameCategory.BOARD,
|
menus = setOf(GameMenu.BOARD),
|
||||||
minPlayers = 2,
|
minPlayers = 2,
|
||||||
maxPlayers = 2,
|
maxPlayers = 2,
|
||||||
teamBased = false,
|
teamBased = false,
|
||||||
@@ -234,7 +259,7 @@ object GameCatalog {
|
|||||||
GameDescriptor(
|
GameDescriptor(
|
||||||
id = MORRIS,
|
id = MORRIS,
|
||||||
title = "Nine Men's Morris",
|
title = "Nine Men's Morris",
|
||||||
category = GameCategory.BOARD,
|
menus = setOf(GameMenu.BOARD),
|
||||||
minPlayers = 2,
|
minPlayers = 2,
|
||||||
maxPlayers = 2,
|
maxPlayers = 2,
|
||||||
teamBased = false,
|
teamBased = false,
|
||||||
@@ -244,7 +269,7 @@ object GameCatalog {
|
|||||||
GameDescriptor(
|
GameDescriptor(
|
||||||
id = PEG_SOLITAIRE,
|
id = PEG_SOLITAIRE,
|
||||||
title = "Peg Solitaire",
|
title = "Peg Solitaire",
|
||||||
category = GameCategory.BOARD,
|
menus = setOf(GameMenu.SOLITAIRE, GameMenu.BOARD),
|
||||||
// The first game here played alone. Everything downstream reads the
|
// The first game here played alone. Everything downstream reads the
|
||||||
// seat count from these two numbers, so one seat is all it takes.
|
// seat count from these two numbers, so one seat is all it takes.
|
||||||
minPlayers = 1,
|
minPlayers = 1,
|
||||||
@@ -257,7 +282,7 @@ object GameCatalog {
|
|||||||
GameDescriptor(
|
GameDescriptor(
|
||||||
id = YAHTZEE,
|
id = YAHTZEE,
|
||||||
title = "Yahtzee",
|
title = "Yahtzee",
|
||||||
category = GameCategory.BOARD,
|
menus = setOf(GameMenu.BOARD),
|
||||||
minPlayers = 1,
|
minPlayers = 1,
|
||||||
maxPlayers = 6,
|
maxPlayers = 6,
|
||||||
teamBased = false,
|
teamBased = false,
|
||||||
@@ -268,7 +293,7 @@ object GameCatalog {
|
|||||||
GameDescriptor(
|
GameDescriptor(
|
||||||
id = PIRATES,
|
id = PIRATES,
|
||||||
title = "Pirates and Bulgars",
|
title = "Pirates and Bulgars",
|
||||||
category = GameCategory.BOARD,
|
menus = setOf(GameMenu.BOARD),
|
||||||
minPlayers = 2,
|
minPlayers = 2,
|
||||||
maxPlayers = 2,
|
maxPlayers = 2,
|
||||||
teamBased = false,
|
teamBased = false,
|
||||||
@@ -279,7 +304,7 @@ object GameCatalog {
|
|||||||
GameDescriptor(
|
GameDescriptor(
|
||||||
id = TAYU,
|
id = TAYU,
|
||||||
title = "Ta Yü",
|
title = "Ta Yü",
|
||||||
category = GameCategory.BOARD,
|
menus = setOf(GameMenu.BOARD),
|
||||||
minPlayers = 2,
|
minPlayers = 2,
|
||||||
maxPlayers = 4,
|
maxPlayers = 4,
|
||||||
// Two play solo, four play in partnerships across the two axes.
|
// Two play solo, four play in partnerships across the two axes.
|
||||||
@@ -301,13 +326,24 @@ object GameCatalog {
|
|||||||
* production build lists only what can actually be played, so nobody
|
* production build lists only what can actually be played, so nobody
|
||||||
* downloads a release and taps into a dead end.
|
* downloads a release and taps into a dead end.
|
||||||
*/
|
*/
|
||||||
fun byCategory(
|
fun inMenu(
|
||||||
category: GameCategory,
|
menu: GameMenu,
|
||||||
includeComingSoon: Boolean = true,
|
includeComingSoon: Boolean = true,
|
||||||
): List<GameDescriptor> = all.filter {
|
): List<GameDescriptor> = all.filter {
|
||||||
it.category == category && (includeComingSoon || it.available)
|
it.menus.contains(menu) && (includeComingSoon || it.available)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Everything currently playable, regardless of category. */
|
/**
|
||||||
|
* Whether a menu is worth showing at all.
|
||||||
|
*
|
||||||
|
* A section with nothing in it and no populated child under it is a heading
|
||||||
|
* over an empty room, which is what a production build would get if every
|
||||||
|
* game in a menu were still unfinished.
|
||||||
|
*/
|
||||||
|
fun hasAnything(menu: GameMenu, includeComingSoon: Boolean = true): Boolean =
|
||||||
|
inMenu(menu, includeComingSoon).isNotEmpty() ||
|
||||||
|
menu.children.any { inMenu(it, includeComingSoon).isNotEmpty() }
|
||||||
|
|
||||||
|
/** Everything currently playable, whatever menu it is under. */
|
||||||
val playable: List<GameDescriptor> get() = all.filter { it.available }
|
val playable: List<GameDescriptor> get() = all.filter { it.available }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
|
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
import androidx.compose.foundation.lazy.grid.items
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
@@ -36,7 +37,7 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import org.prolibertate.games.R
|
import org.prolibertate.games.R
|
||||||
import org.prolibertate.games.game.GameCatalog
|
import org.prolibertate.games.game.GameCatalog
|
||||||
import org.prolibertate.games.game.GameCategory
|
import org.prolibertate.games.game.GameMenu
|
||||||
import org.prolibertate.games.game.GameDescriptor
|
import org.prolibertate.games.game.GameDescriptor
|
||||||
import org.prolibertate.games.game.engine.TableConfig
|
import org.prolibertate.games.game.engine.TableConfig
|
||||||
import org.prolibertate.games.net.LobbyController
|
import org.prolibertate.games.net.LobbyController
|
||||||
@@ -64,6 +65,9 @@ class AppEnv(
|
|||||||
*/
|
*/
|
||||||
sealed interface Route {
|
sealed interface Route {
|
||||||
data object Menu : Route
|
data object Menu : Route
|
||||||
|
|
||||||
|
/** One section of the menu: its games, and any sub-section under it. */
|
||||||
|
data class Section(val menu: GameMenu) : Route
|
||||||
data object Settings : Route
|
data object Settings : Route
|
||||||
data object Scorekeeper : Route
|
data object Scorekeeper : Route
|
||||||
data class Setup(val gameId: String) : Route
|
data class Setup(val gameId: String) : Route
|
||||||
@@ -152,12 +156,20 @@ private fun AppContent(
|
|||||||
// Dev builds show the games that are not finished yet; production
|
// Dev builds show the games that are not finished yet; production
|
||||||
// releases list only what can actually be played.
|
// releases list only what can actually be played.
|
||||||
showComingSoon = showComingSoon,
|
showComingSoon = showComingSoon,
|
||||||
onPickGame = { push(Route.Setup(it.id)) },
|
onPickSection = { push(Route.Section(it)) },
|
||||||
onJoinGame = { push(Route.Lobby(gameId = "", optionsJson = "{}", hosting = false)) },
|
onJoinGame = { push(Route.Lobby(gameId = "", optionsJson = "{}", hosting = false)) },
|
||||||
onScorekeeper = { push(Route.Scorekeeper) },
|
onScorekeeper = { push(Route.Scorekeeper) },
|
||||||
onSettings = { push(Route.Settings) },
|
onSettings = { push(Route.Settings) },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
is Route.Section -> SectionScreen(
|
||||||
|
menu = route.menu,
|
||||||
|
showComingSoon = showComingSoon,
|
||||||
|
onPickGame = { push(Route.Setup(it.id)) },
|
||||||
|
onPickSection = { push(Route.Section(it)) },
|
||||||
|
onBack = pop,
|
||||||
|
)
|
||||||
|
|
||||||
is Route.Settings -> SettingsScreen(
|
is Route.Settings -> SettingsScreen(
|
||||||
env = env,
|
env = env,
|
||||||
settings = settings,
|
settings = settings,
|
||||||
@@ -206,7 +218,7 @@ private fun AppContent(
|
|||||||
@Composable
|
@Composable
|
||||||
fun MainMenuScreen(
|
fun MainMenuScreen(
|
||||||
showComingSoon: Boolean,
|
showComingSoon: Boolean,
|
||||||
onPickGame: (GameDescriptor) -> Unit,
|
onPickSection: (GameMenu) -> Unit,
|
||||||
onJoinGame: () -> Unit,
|
onJoinGame: () -> Unit,
|
||||||
onScorekeeper: () -> Unit,
|
onScorekeeper: () -> Unit,
|
||||||
onSettings: () -> Unit,
|
onSettings: () -> Unit,
|
||||||
@@ -248,25 +260,124 @@ fun MainMenuScreen(
|
|||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
) {
|
) {
|
||||||
GameCategory.entries.forEach { category ->
|
// Only the top of the tree. Trick-taking is reached through
|
||||||
val games = GameCatalog.byCategory(category, includeComingSoon = showComingSoon)
|
// Card Games, which is where somebody looking for it would go.
|
||||||
// Don't leave a heading stranded over nothing.
|
val sections = GameMenu.entries.filter {
|
||||||
if (games.isEmpty()) return@forEach
|
it.isTopLevel && GameCatalog.hasAnything(it, includeComingSoon = showComingSoon)
|
||||||
|
}
|
||||||
|
items(sections) { section ->
|
||||||
|
SectionTile(
|
||||||
|
menu = section,
|
||||||
|
count = countIn(section, showComingSoon),
|
||||||
|
onClick = { onPickSection(section) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
item(span = { androidx.compose.foundation.lazy.grid.GridItemSpan(maxLineSpan) }) {
|
|
||||||
|
/**
|
||||||
|
* How many games a section offers, counting whatever is under it as well.
|
||||||
|
*
|
||||||
|
* A game in both a section and its sub-section is one game, not two, so this
|
||||||
|
* counts a set rather than adding the lists together.
|
||||||
|
*/
|
||||||
|
private fun countIn(menu: GameMenu, showComingSoon: Boolean): Int =
|
||||||
|
(GameCatalog.inMenu(menu, showComingSoon) +
|
||||||
|
menu.children.flatMap { GameCatalog.inMenu(it, showComingSoon) })
|
||||||
|
.toSet().size
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One section of the menu.
|
||||||
|
*
|
||||||
|
* Sub-sections come first and its own games after, so Card Games opens on
|
||||||
|
* "Trick-taking" and then everything played with a pack — including the
|
||||||
|
* trick-taking games themselves, which belong in both places and are listed in
|
||||||
|
* both. A game reachable one way and not the other would just be a game
|
||||||
|
* somebody could not find.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun SectionScreen(
|
||||||
|
menu: GameMenu,
|
||||||
|
showComingSoon: Boolean,
|
||||||
|
onPickGame: (GameDescriptor) -> Unit,
|
||||||
|
onPickSection: (GameMenu) -> Unit,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
) {
|
||||||
|
val children = menu.children.filter {
|
||||||
|
GameCatalog.hasAnything(it, includeComingSoon = showComingSoon)
|
||||||
|
}
|
||||||
|
val games = GameCatalog.inMenu(menu, includeComingSoon = showComingSoon)
|
||||||
|
|
||||||
|
ScreenScaffold(title = menu.label, onBack = onBack) { modifier ->
|
||||||
|
LazyVerticalGrid(
|
||||||
|
columns = GridCells.Adaptive(minSize = 172.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = modifier.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||||
Text(
|
Text(
|
||||||
text = category.label,
|
text = menu.blurb,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier.padding(vertical = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
items(children) { child ->
|
||||||
|
SectionTile(
|
||||||
|
menu = child,
|
||||||
|
count = countIn(child, showComingSoon),
|
||||||
|
onClick = { onPickSection(child) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (games.isNotEmpty() && children.isNotEmpty()) {
|
||||||
|
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||||
|
Text(
|
||||||
|
text = "All ${menu.label.lowercase()}",
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
modifier = Modifier.padding(top = 12.dp, bottom = 4.dp),
|
modifier = Modifier.padding(top = 12.dp, bottom = 4.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
items(games) { game ->
|
items(games) { game ->
|
||||||
GameTile(game = game, onClick = { onPickGame(game) })
|
GameTile(game = game, onClick = { onPickGame(game) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A way into a section, rather than into a game. */
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun SectionTile(menu: GameMenu, count: Int, onClick: () -> Unit) {
|
||||||
|
Card(
|
||||||
|
onClick = onClick,
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||||
|
),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(12.dp)) {
|
||||||
|
Text(
|
||||||
|
text = menu.label,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = menu.blurb,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = if (count == 1) "1 game" else "$count games",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
modifier = Modifier.padding(top = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,21 +16,54 @@ class CatalogTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `a production build lists only games that can be played`() {
|
fun `a production build lists only games that can be played`() {
|
||||||
GameCategory.entries.forEach { category ->
|
GameMenu.entries.forEach { menu ->
|
||||||
val shown = GameCatalog.byCategory(category, includeComingSoon = false)
|
val shown = GameCatalog.inMenu(menu, includeComingSoon = false)
|
||||||
assertTrue(
|
assertTrue(
|
||||||
"$category offers an unfinished game in a release build",
|
"$menu offers an unfinished game in a release build",
|
||||||
shown.all { it.available },
|
shown.all { it.available },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `a dev build lists the whole catalogue`() {
|
fun `every game is reachable from at least one menu`() {
|
||||||
val shown = GameCategory.entries.flatMap {
|
// Counting is no good now that a game can be in several menus: the
|
||||||
GameCatalog.byCategory(it, includeComingSoon = true)
|
// union is what matters, and a game in none of them would be in the
|
||||||
|
// catalogue but on no screen.
|
||||||
|
val reachable = GameMenu.entries.flatMap {
|
||||||
|
GameCatalog.inMenu(it, includeComingSoon = true)
|
||||||
|
}.toSet()
|
||||||
|
assertEquals(GameCatalog.all.toSet(), reachable)
|
||||||
|
GameCatalog.all.forEach {
|
||||||
|
assertTrue("${it.id} is in no menu", it.menus.isNotEmpty())
|
||||||
}
|
}
|
||||||
assertEquals(GameCatalog.all.size, shown.size)
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a game can be in more than one menu`() {
|
||||||
|
// The whole point of the set. A patience is solitaire and a card game;
|
||||||
|
// Euchre is a card game and a trick-taking one.
|
||||||
|
val klondike = GameCatalog.byId(GameCatalog.KLONDIKE)!!
|
||||||
|
assertTrue(klondike.menus.containsAll(setOf(GameMenu.SOLITAIRE, GameMenu.CARDS)))
|
||||||
|
assertTrue(GameCatalog.inMenu(GameMenu.SOLITAIRE).contains(klondike))
|
||||||
|
assertTrue(GameCatalog.inMenu(GameMenu.CARDS).contains(klondike))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `trick-taking sits under cards, and its games are card games too`() {
|
||||||
|
assertEquals(GameMenu.CARDS, GameMenu.TRICK_TAKING.parent)
|
||||||
|
assertEquals(listOf(GameMenu.TRICK_TAKING), GameMenu.CARDS.children)
|
||||||
|
assertTrue(GameMenu.TRICK_TAKING.isTopLevel.not())
|
||||||
|
assertTrue(GameMenu.SOLITAIRE.isTopLevel)
|
||||||
|
|
||||||
|
val tricks = GameCatalog.inMenu(GameMenu.TRICK_TAKING)
|
||||||
|
assertTrue("there should be some", tricks.isNotEmpty())
|
||||||
|
// A sub-menu that listed something its parent did not would be a game
|
||||||
|
// you could reach one way and not the other.
|
||||||
|
assertTrue(
|
||||||
|
"every trick-taking game is also a card game",
|
||||||
|
tricks.all { it.menus.contains(GameMenu.CARDS) },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -42,19 +75,19 @@ class CatalogTest {
|
|||||||
// engine is written.
|
// engine is written.
|
||||||
assertTrue(GameCatalog.all.all { it.available })
|
assertTrue(GameCatalog.all.all { it.available })
|
||||||
assertEquals(
|
assertEquals(
|
||||||
GameCatalog.byCategory(GameCategory.BOARD, includeComingSoon = false),
|
GameCatalog.inMenu(GameMenu.BOARD, includeComingSoon = false),
|
||||||
GameCatalog.byCategory(GameCategory.BOARD, includeComingSoon = true),
|
GameCatalog.inMenu(GameMenu.BOARD, includeComingSoon = true),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `hiding unfinished games still leaves something to play in each category`() {
|
fun `hiding unfinished games still leaves something to play in every menu`() {
|
||||||
// A category filtered down to nothing would leave a stranded heading,
|
// A menu filtered down to nothing would leave a stranded heading, and a
|
||||||
// and a menu with no playable game at all would be a broken release.
|
// release where a whole section was empty would be a broken one.
|
||||||
GameCategory.entries.forEach { category ->
|
GameMenu.entries.forEach { menu ->
|
||||||
assertFalse(
|
assertTrue(
|
||||||
"$category has nothing playable in a release build",
|
"$menu has nothing playable in a release build",
|
||||||
GameCatalog.byCategory(category, includeComingSoon = false).isEmpty(),
|
GameCatalog.hasAnything(menu, includeComingSoon = false),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
assertTrue(GameCatalog.playable.isNotEmpty())
|
assertTrue(GameCatalog.playable.isNotEmpty())
|
||||||
|
|||||||
Reference in New Issue
Block a user