diff --git a/app/src/main/java/org/prolibertate/games/game/Catalog.kt b/app/src/main/java/org/prolibertate/games/game/Catalog.kt index 306e0b1..ec88e0b 100644 --- a/app/src/main/java/org/prolibertate/games/game/Catalog.kt +++ b/app/src/main/java/org/prolibertate/games/game/Catalog.kt @@ -1,22 +1,39 @@ 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 - * 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. + * A tree rather than a list, because "trick-taking" is a kind of card game + * rather than a rival to it. [parent] is read from a `when` rather than passed + * to the constructor: an enum entry cannot refer to another entry while the + * class is still initialising, and a lookup afterwards is free. */ -enum class GameCategory(val label: String) { - CARD("Playing Card Games"), - BOARD("Board Games"), +enum class GameMenu(val label: String, val blurb: String) { + SOLITAIRE("Solitaire", "Played alone, against the deal or the board."), + 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 get() = entries.filter { it.parent == this } + + val isTopLevel: Boolean get() = parent == null } data class GameDescriptor( val id: 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, val minPlayers: Int, val maxPlayers: Int, /** True when players are grouped into partnerships rather than playing solo. */ @@ -25,6 +42,14 @@ data class GameDescriptor( 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 { const val EUCHRE = "euchre" @@ -56,7 +81,7 @@ object GameCatalog { GameDescriptor( id = EUCHRE, title = "Euchre", - category = GameCategory.CARD, + menus = setOf(GameMenu.CARDS, GameMenu.TRICK_TAKING), minPlayers = 4, maxPlayers = 4, teamBased = true, @@ -66,7 +91,7 @@ object GameCatalog { GameDescriptor( id = KAISER, title = "Kaiser", - category = GameCategory.CARD, + menus = setOf(GameMenu.CARDS, GameMenu.TRICK_TAKING), minPlayers = 4, maxPlayers = 4, teamBased = true, @@ -76,7 +101,7 @@ object GameCatalog { GameDescriptor( id = PRESIDENT, title = "President", - category = GameCategory.CARD, + menus = setOf(GameMenu.CARDS), minPlayers = 3, maxPlayers = 7, teamBased = false, @@ -86,7 +111,7 @@ object GameCatalog { GameDescriptor( id = GOLF, title = "Golf", - category = GameCategory.CARD, + menus = setOf(GameMenu.CARDS), minPlayers = 2, maxPlayers = 6, teamBased = false, @@ -96,7 +121,7 @@ object GameCatalog { GameDescriptor( id = WIZARD, title = "Wizard", - category = GameCategory.CARD, + menus = setOf(GameMenu.CARDS, GameMenu.TRICK_TAKING), minPlayers = 3, maxPlayers = 6, teamBased = false, @@ -106,7 +131,7 @@ object GameCatalog { GameDescriptor( id = CRAZY_EIGHTS, title = "Crazy 8s", - category = GameCategory.CARD, + menus = setOf(GameMenu.CARDS), minPlayers = 2, maxPlayers = 6, teamBased = false, @@ -116,7 +141,7 @@ object GameCatalog { GameDescriptor( id = CRIBBAGE, title = "Cribbage", - category = GameCategory.CARD, + menus = setOf(GameMenu.CARDS), minPlayers = 2, maxPlayers = 4, // Only at four, where the two pairs share a board between them. @@ -128,7 +153,7 @@ object GameCatalog { GameDescriptor( id = HEARTS, title = "Hearts", - category = GameCategory.CARD, + menus = setOf(GameMenu.CARDS, GameMenu.TRICK_TAKING), minPlayers = 4, maxPlayers = 4, teamBased = false, @@ -139,7 +164,7 @@ object GameCatalog { GameDescriptor( id = KLONDIKE, title = "Klondike", - category = GameCategory.CARD, + menus = setOf(GameMenu.SOLITAIRE, GameMenu.CARDS), minPlayers = 1, maxPlayers = 1, teamBased = false, @@ -150,7 +175,7 @@ object GameCatalog { GameDescriptor( id = FREECELL, title = "FreeCell", - category = GameCategory.CARD, + menus = setOf(GameMenu.SOLITAIRE, GameMenu.CARDS), minPlayers = 1, maxPlayers = 1, teamBased = false, @@ -161,7 +186,7 @@ object GameCatalog { GameDescriptor( id = SPIDER, title = "Spider", - category = GameCategory.CARD, + menus = setOf(GameMenu.SOLITAIRE, GameMenu.CARDS), minPlayers = 1, maxPlayers = 1, teamBased = false, @@ -172,7 +197,7 @@ object GameCatalog { GameDescriptor( id = PYRAMID, title = "Pyramid", - category = GameCategory.CARD, + menus = setOf(GameMenu.SOLITAIRE, GameMenu.CARDS), minPlayers = 1, maxPlayers = 1, teamBased = false, @@ -183,7 +208,7 @@ object GameCatalog { GameDescriptor( id = SEQUENCE, title = "Sequence", - category = GameCategory.BOARD, + menus = setOf(GameMenu.BOARD, GameMenu.CARDS), minPlayers = 2, maxPlayers = 12, teamBased = true, @@ -193,7 +218,7 @@ object GameCatalog { GameDescriptor( id = MASTERMIND, title = "Mastermind", - category = GameCategory.BOARD, + menus = setOf(GameMenu.BOARD), minPlayers = 2, maxPlayers = 2, teamBased = false, @@ -204,7 +229,7 @@ object GameCatalog { GameDescriptor( id = BACKGAMMON, title = "Backgammon", - category = GameCategory.BOARD, + menus = setOf(GameMenu.BOARD), minPlayers = 2, maxPlayers = 2, teamBased = false, @@ -214,7 +239,7 @@ object GameCatalog { GameDescriptor( id = CHESS, title = "Chess", - category = GameCategory.BOARD, + menus = setOf(GameMenu.BOARD), minPlayers = 2, maxPlayers = 2, teamBased = false, @@ -224,7 +249,7 @@ object GameCatalog { GameDescriptor( id = CHECKERS, title = "Checkers", - category = GameCategory.BOARD, + menus = setOf(GameMenu.BOARD), minPlayers = 2, maxPlayers = 2, teamBased = false, @@ -234,7 +259,7 @@ object GameCatalog { GameDescriptor( id = MORRIS, title = "Nine Men's Morris", - category = GameCategory.BOARD, + menus = setOf(GameMenu.BOARD), minPlayers = 2, maxPlayers = 2, teamBased = false, @@ -244,7 +269,7 @@ object GameCatalog { GameDescriptor( id = PEG_SOLITAIRE, title = "Peg Solitaire", - category = GameCategory.BOARD, + menus = setOf(GameMenu.SOLITAIRE, GameMenu.BOARD), // The first game here played alone. Everything downstream reads the // seat count from these two numbers, so one seat is all it takes. minPlayers = 1, @@ -257,7 +282,7 @@ object GameCatalog { GameDescriptor( id = YAHTZEE, title = "Yahtzee", - category = GameCategory.BOARD, + menus = setOf(GameMenu.BOARD), minPlayers = 1, maxPlayers = 6, teamBased = false, @@ -268,7 +293,7 @@ object GameCatalog { GameDescriptor( id = PIRATES, title = "Pirates and Bulgars", - category = GameCategory.BOARD, + menus = setOf(GameMenu.BOARD), minPlayers = 2, maxPlayers = 2, teamBased = false, @@ -279,7 +304,7 @@ object GameCatalog { GameDescriptor( id = TAYU, title = "Ta Yü", - category = GameCategory.BOARD, + menus = setOf(GameMenu.BOARD), minPlayers = 2, maxPlayers = 4, // 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 * downloads a release and taps into a dead end. */ - fun byCategory( - category: GameCategory, + fun inMenu( + menu: GameMenu, includeComingSoon: Boolean = true, ): List = 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 get() = all.filter { it.available } } diff --git a/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt b/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt index e140fc3..59e8693 100644 --- a/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt +++ b/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding 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.items import androidx.compose.material3.Button @@ -36,7 +37,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import org.prolibertate.games.R 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.engine.TableConfig import org.prolibertate.games.net.LobbyController @@ -64,6 +65,9 @@ class AppEnv( */ sealed interface 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 Scorekeeper : 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 // releases list only what can actually be played. showComingSoon = showComingSoon, - onPickGame = { push(Route.Setup(it.id)) }, + onPickSection = { push(Route.Section(it)) }, onJoinGame = { push(Route.Lobby(gameId = "", optionsJson = "{}", hosting = false)) }, onScorekeeper = { push(Route.Scorekeeper) }, 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( env = env, settings = settings, @@ -206,7 +218,7 @@ private fun AppContent( @Composable fun MainMenuScreen( showComingSoon: Boolean, - onPickGame: (GameDescriptor) -> Unit, + onPickSection: (GameMenu) -> Unit, onJoinGame: () -> Unit, onScorekeeper: () -> Unit, onSettings: () -> Unit, @@ -248,28 +260,127 @@ fun MainMenuScreen( horizontalArrangement = Arrangement.spacedBy(8.dp), 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, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(top = 12.dp, bottom = 4.dp), - ) - } - items(games) { game -> - GameTile(game = game, onClick = { onPickGame(game) }) - } + // Only the top of the tree. Trick-taking is reached through + // Card Games, which is where somebody looking for it would go. + val sections = GameMenu.entries.filter { + it.isTopLevel && GameCatalog.hasAnything(it, includeComingSoon = showComingSoon) + } + items(sections) { section -> + SectionTile( + menu = section, + count = countIn(section, showComingSoon), + onClick = { onPickSection(section) }, + ) } } } } } + +/** + * 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 = 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, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(top = 12.dp, bottom = 4.dp), + ) + } + } + items(games) { 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), + ) + } + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun GameTile(game: GameDescriptor, onClick: () -> Unit) { diff --git a/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt b/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt index 2c23ca4..674a2a4 100644 --- a/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt +++ b/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt @@ -16,21 +16,54 @@ class CatalogTest { @Test fun `a production build lists only games that can be played`() { - GameCategory.entries.forEach { category -> - val shown = GameCatalog.byCategory(category, includeComingSoon = false) + GameMenu.entries.forEach { menu -> + val shown = GameCatalog.inMenu(menu, includeComingSoon = false) assertTrue( - "$category offers an unfinished game in a release build", + "$menu offers an unfinished game in a release build", shown.all { it.available }, ) } } @Test - fun `a dev build lists the whole catalogue`() { - val shown = GameCategory.entries.flatMap { - GameCatalog.byCategory(it, includeComingSoon = true) + fun `every game is reachable from at least one menu`() { + // Counting is no good now that a game can be in several menus: the + // 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 @@ -42,19 +75,19 @@ class CatalogTest { // engine is written. assertTrue(GameCatalog.all.all { it.available }) assertEquals( - GameCatalog.byCategory(GameCategory.BOARD, includeComingSoon = false), - GameCatalog.byCategory(GameCategory.BOARD, includeComingSoon = true), + GameCatalog.inMenu(GameMenu.BOARD, includeComingSoon = false), + GameCatalog.inMenu(GameMenu.BOARD, includeComingSoon = true), ) } @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(), + fun `hiding unfinished games still leaves something to play in every menu`() { + // A menu filtered down to nothing would leave a stranded heading, and a + // release where a whole section was empty would be a broken one. + GameMenu.entries.forEach { menu -> + assertTrue( + "$menu has nothing playable in a release build", + GameCatalog.hasAnything(menu, includeComingSoon = false), ) } assertTrue(GameCatalog.playable.isNotEmpty())