From a6291159c2f5dd36de9a20c4bb2cf9953ed7cf9a Mon Sep 17 00:00:00 2001 From: Pauljd Date: Thu, 5 Nov 2026 17:35:36 +0000 Subject: [PATCH] Orbit Arcade: Pairs and Patience, completing Paul's game list Pairs: twenty cards, ten pairs of Orbit's own channel logos; arrows move, OK turns a card, a match stays up, a miss turns back after a moment; the fewest moves is kept. Patience: classic one-card-draw, with a pointer over the stock, turned card, home piles and columns; OK picks up and puts down, up and down in a column choose how deep, Back puts a card back, Menu sends a card home; games won are counted. The honeycomb now holds all six games. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../main/java/tv/orbit/classic/ArcadePairs.kt | 138 +++++++++++ .../java/tv/orbit/classic/ArcadePatience.kt | 215 ++++++++++++++++++ .../main/java/tv/orbit/classic/OrbitArcade.kt | 31 ++- .../main/java/tv/orbit/classic/Settings.kt | 7 + .../tv/orbit/classic/PairsPatienceTest.kt | 52 +++++ 5 files changed, 436 insertions(+), 7 deletions(-) create mode 100644 android/app/src/main/java/tv/orbit/classic/ArcadePairs.kt create mode 100644 android/app/src/main/java/tv/orbit/classic/ArcadePatience.kt create mode 100644 android/app/src/test/java/tv/orbit/classic/PairsPatienceTest.kt diff --git a/android/app/src/main/java/tv/orbit/classic/ArcadePairs.kt b/android/app/src/main/java/tv/orbit/classic/ArcadePairs.kt new file mode 100644 index 0000000..3e2d459 --- /dev/null +++ b/android/app/src/main/java/tv/orbit/classic/ArcadePairs.kt @@ -0,0 +1,138 @@ +package tv.orbit.classic + +import android.view.KeyEvent +import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.scale +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.tv.material3.Text +import kotlinx.coroutines.delay + +/** + * Pairs for Orbit Arcade (Paul's approved list): twenty cards face down, ten pairs of Orbit's own channel logos. Move with the + * arrows, OK turns a card; two that match stay up, two that don't turn back after a moment. Clear the board in as few moves + * as you can; the best is kept on the box. + */ +internal const val PAIRS_COLUMNS = 5 +internal const val PAIRS_ROWS = 4 + +internal data class PairsGame(val cards: List, val matched: Set = emptySet(), val open: List = emptyList(), val cursor: Int = 0, val moves: Int = 0) { + val done get() = matched.size == cards.size + /** Two cards up that don't match: waiting to turn back. */ + val waiting get() = open.size == 2 + + fun moveCursor(dx: Int, dy: Int): PairsGame { + val x = (cursor % PAIRS_COLUMNS + dx).coerceIn(0, PAIRS_COLUMNS - 1); val y = (cursor / PAIRS_COLUMNS + dy).coerceIn(0, PAIRS_ROWS - 1) + return copy(cursor = y * PAIRS_COLUMNS + x) + } + + /** Turns the card under the cursor. A second card counts a move; a match stays up. */ + fun flip(): PairsGame { + val i = cursor + if (waiting || i in matched || i in open) return this + val now = open + i + if (now.size < 2) return copy(open = now) + return if (cards[now[0]] == cards[now[1]]) copy(matched = matched + now, open = emptyList(), moves = moves + 1) else copy(open = now, moves = moves + 1) + } + + /** Turns a mismatched pair back down. */ + fun hide() = if (waiting) copy(open = emptyList()) else this + + companion object { + fun deal(random: java.util.Random, kinds: Int = PAIRS_COLUMNS * PAIRS_ROWS / 2) = + PairsGame((0 until kinds).flatMap { listOf(it, it) }.toMutableList().also { java.util.Collections.shuffle(it, random) }) + } +} + +/** The ten channels whose logos make the pairs. */ +private val PairsChannels = listOf(101, 102, 201, 203, 301, AFTER_DARK_NUMBER, 309, 401, 601, 612) + +private val Marquee = FontFamily(Font(R.font.anton_regular)) +private val Honey = Color(0xFFFFD84A) + +@Composable +private fun PairsCard(channel: Int, up: Boolean, matched: Boolean, selected: Boolean, modifier: Modifier) { + val turn by animateFloatAsState(if (up) 1f else 0f, tween(260), label = "turn") + Box(modifier.graphicsLayer { rotationY = 180f * (1 - turn); cameraDistance = 12 * density } + .background(if (turn > .5f) Color(0xFF0B1B44) else Panel, RoundedCornerShape(8.dp)) + .border(if (selected) 3.dp else 1.dp, if (selected) Yellow else if (matched) Honey else Blue, RoundedCornerShape(8.dp)), contentAlignment = Alignment.Center) { + if (turn > .5f) ChannelBrands.forNumber(channel)?.let { Image(painterResource(it.logo), it.displayName, Modifier.fillMaxSize().padding(10.dp).alpha(if (matched) .7f else 1f)) } + else Text("O", color = Muted.copy(alpha = .5f), fontFamily = Marquee, fontSize = 26.sp) + } +} + +@Composable +internal fun PairsScreen(best: Int, onBest: (Int) -> Unit, onLeave: () -> Unit) { + val random = remember { java.util.Random() } + var game by remember { mutableStateOf(PairsGame.deal(random)) } + val focus = remember { FocusRequester() } + val menuFocus = remember { FocusRequester() } + LaunchedEffect(game.waiting) { if (game.waiting) { delay(900); game = game.hide() } } + var newBest by remember { mutableStateOf(false) } + LaunchedEffect(game.done) { if (game.done) { newBest = best == 0 || game.moves < best; if (newBest) onBest(game.moves); menuFocus.requestFocus() } else focus.requestFocus() } + BackHandler(onBack = onLeave) + Row(Modifier.fillMaxSize().background(Navy).padding(20.dp).focusRequester(focus).focusable().onPreviewKeyEvent { event -> + val native = event.nativeKeyEvent + if (native.action != KeyEvent.ACTION_DOWN || game.done) return@onPreviewKeyEvent false + val next = when (native.keyCode) { + KeyEvent.KEYCODE_DPAD_LEFT -> game.moveCursor(-1, 0) + KeyEvent.KEYCODE_DPAD_RIGHT -> game.moveCursor(1, 0) + KeyEvent.KEYCODE_DPAD_UP -> game.moveCursor(0, -1) + KeyEvent.KEYCODE_DPAD_DOWN -> game.moveCursor(0, 1) + KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER -> game.flip() + else -> null + } + if (next != null) game = next + next != null + }, horizontalArrangement = Arrangement.spacedBy(20.dp)) { + Column(Modifier.width(190.dp).fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text("PAIRS", color = Honey, fontFamily = Marquee, fontSize = 40.sp) + Spacer(Modifier.height(10.dp)) + Text("MOVES", color = Muted, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 2.sp) + Text(game.moves.toString(), color = Color.White, fontFamily = Marquee, fontSize = 36.sp) + Text("BEST", color = Muted, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 2.sp) + Text(if (best == 0) "–" else best.toString(), color = Color.White, fontFamily = Marquee, fontSize = 36.sp) + Text("PAIRS ${game.matched.size / 2} OF ${game.cards.size / 2}", color = Muted, fontSize = 13.sp, fontWeight = FontWeight.SemiBold) + Spacer(Modifier.weight(1f)) + Text("◄►▲▼ Move OK Turn", color = Muted, fontSize = 13.sp) + Text("Back Leave", color = Muted, fontSize = 13.sp) + } + Box(Modifier.weight(1f).fillMaxHeight(), contentAlignment = Alignment.Center) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + for (row in 0 until PAIRS_ROWS) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + for (col in 0 until PAIRS_COLUMNS) { + val i = row * PAIRS_COLUMNS + col + PairsCard(PairsChannels[game.cards[i]], i in game.matched || i in game.open, i in game.matched, i == game.cursor && !game.done, Modifier.width(120.dp).height(76.dp)) + } + } + } + if (game.done) Column(Modifier.background(Navy, RoundedCornerShape(6.dp)).border(2.dp, Honey, RoundedCornerShape(6.dp)).padding(20.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("ALL PAIRED", color = Honey, fontFamily = Marquee, fontSize = 30.sp) + Text(if (newBest) "New best: ${game.moves} moves" else "${game.moves} moves", color = Color.White, fontSize = 16.sp) + OrbitButton("Play again", Modifier.width(200.dp).focusRequester(menuFocus), onClick = { game = PairsGame.deal(random) }) + OrbitButton("Leave the game", Modifier.width(200.dp), onClick = onLeave) + } + } + } +} diff --git a/android/app/src/main/java/tv/orbit/classic/ArcadePatience.kt b/android/app/src/main/java/tv/orbit/classic/ArcadePatience.kt new file mode 100644 index 0000000..faae53f --- /dev/null +++ b/android/app/src/main/java/tv/orbit/classic/ArcadePatience.kt @@ -0,0 +1,215 @@ +package tv.orbit.classic + +import android.view.KeyEvent +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.tv.material3.Text + +/** + * Patience for Orbit Arcade (Paul's approved list): the classic one-card-draw game, played with the remote. The pointer moves + * over the stock, the turned card, the four home piles and the seven columns. OK on the stock turns a card; OK on a card picks + * it up (in a column, up and down choose how deep), and OK again puts it down where the pointer is, if it may go there. Back + * puts a held card back, or leaves. Every won game is counted on the box. + */ +internal data class PlayingCard(val rank: Int, val suit: Int, val up: Boolean = true) { + val red get() = suit == 1 || suit == 2 + val label get() = when (rank) { 1 -> "A"; 11 -> "J"; 12 -> "Q"; 13 -> "K"; else -> rank.toString() } + "♠♥♦♣"[suit] +} + +/** Piles by index: 0 the stock, 1 the turned cards, 2 to 5 the home piles, 6 to 12 the columns. */ +internal const val STOCK = 0 +internal const val WASTE = 1 +internal val HOMES = 2..5 +internal val COLUMNS = 6..12 + +/** Where the pointer is ([pile], and in a column how many face-up cards from the bottom it takes), and what's held. */ +internal data class PatienceGame(val piles: List>, val pile: Int = 6, val depth: Int = 1, val held: Pair? = null, val moves: Int = 0) { + val won get() = HOMES.all { piles[it].size == 13 } + + private fun faceUp(i: Int) = piles[i].takeLastWhile { it.up }.size + + fun point(to: Int) = copy(pile = to, depth = 1) + + /** Left and right along the row; up and down between the rows, or deeper and shallower within a column. */ + fun steer(dx: Int, dy: Int): PatienceGame { + if (dx != 0) { + val row = if (pile in COLUMNS) COLUMNS.toList() else listOf(STOCK, WASTE) + HOMES.toList() + return point(row[(row.indexOf(pile) + dx).coerceIn(0, row.size - 1)]) + } + if (pile in COLUMNS) { + if (dy < 0 && held == null && depth < faceUp(pile)) return copy(depth = depth + 1) + if (dy > 0 && depth > 1) return copy(depth = depth - 1) + if (dy < 0) return point(listOf(STOCK, WASTE, WASTE, 2, 3, 4, 5)[pile - 6]) + return this + } + return if (dy > 0) point(when (pile) { STOCK -> 6; WASTE -> 7; else -> pile + 7 }) else this + } + + /** Whether [card] may go on pile [to]: a home pile builds up by suit from the ace, a column down in alternate colours from a king. */ + fun allowed(cards: List, to: Int): Boolean { + val card = cards.first(); val top = piles[to].lastOrNull() + return when (to) { + in HOMES -> cards.size == 1 && (if (top == null) card.rank == 1 else top.suit == card.suit && card.rank == top.rank + 1) + in COLUMNS -> if (top == null) card.rank == 13 else top.up && top.red != card.red && card.rank == top.rank - 1 + else -> false + } + } + + /** OK: turn a stock card, pick up, or put down. */ + fun press(): PatienceGame { + held?.let { (from, n) -> + if (pile == from) return copy(held = null) + val cards = piles[from].takeLast(n) + if (!allowed(cards, pile)) return this + val next = piles.toMutableList() + next[from] = piles[from].dropLast(n).let { left -> if (left.isNotEmpty() && !left.last().up) left.dropLast(1) + left.last().copy(up = true) else left } + next[pile] = piles[pile] + cards + return copy(piles = next, held = null, depth = 1, moves = moves + 1) + } + return when (pile) { + STOCK -> { + val next = piles.toMutableList() + if (piles[STOCK].isEmpty()) { next[STOCK] = piles[WASTE].reversed().map { it.copy(up = false) }; next[WASTE] = emptyList() } + else { next[WASTE] = piles[WASTE] + piles[STOCK].last().copy(up = true); next[STOCK] = piles[STOCK].dropLast(1) } + copy(piles = next) + } + WASTE, in HOMES -> if (piles[pile].isEmpty()) this else copy(held = pile to 1) + else -> if (faceUp(pile) == 0) this else copy(held = pile to depth.coerceAtMost(faceUp(pile))) + } + } + + /** Sends the card under the pointer to its home pile if it can go, the usual shortcut. */ + fun sendHome(): PatienceGame { + if (held != null || pile !in listOf(WASTE) + COLUMNS.toList() || piles[pile].isEmpty() || !piles[pile].last().up) return this + val home = HOMES.firstOrNull { allowed(listOf(piles[pile].last()), it) } ?: return this + return copy(held = pile to 1, pile = home).press().copy(pile = pile) + } + + fun drop() = copy(held = null) + + companion object { + fun deal(random: java.util.Random): PatienceGame { + val deck = (0 until 4).flatMap { s -> (1..13).map { r -> PlayingCard(r, s, false) } }.toMutableList().also { java.util.Collections.shuffle(it, random) } + val piles = MutableList(13) { emptyList() } + var k = 0 + for (c in 0 until 7) { piles[6 + c] = (0..c).map { i -> deck[k++].copy(up = i == c) } } + piles[STOCK] = deck.drop(k) + return PatienceGame(piles) + } + } +} + +private val Marquee = FontFamily(Font(R.font.anton_regular)) +private val Honey = Color(0xFFFFD84A) +private val CardW = 64.dp +private val CardH = 88.dp + +@Composable +private fun CardFace(card: PlayingCard?, empty: String = "", modifier: Modifier = Modifier) { + Box(modifier.width(CardW).height(CardH).background(when { card == null -> Color(0x22FFFFFF); card.up -> Color(0xFFF4F6FB); else -> Panel }, RoundedCornerShape(6.dp)) + .border(1.dp, if (card?.up == false) Blue else Color(0x55FFFFFF), RoundedCornerShape(6.dp)).padding(5.dp)) { + when { + card == null -> Text(empty, Modifier.align(Alignment.Center), color = Muted.copy(alpha = .6f), fontSize = 14.sp) + card.up -> { + val colour = if (card.red) Color(0xFFD32F2F) else Color(0xFF0B1B44) + Text(card.label, color = colour, fontSize = 15.sp, fontWeight = FontWeight.Bold) + Text("♠♥♦♣"[card.suit].toString(), Modifier.align(Alignment.Center), color = colour, fontSize = 26.sp) + } + else -> Text("O", Modifier.align(Alignment.Center), color = Muted.copy(alpha = .5f), fontFamily = Marquee, fontSize = 20.sp) + } + } +} + +@Composable +internal fun PatienceScreen(wins: Int, onWin: () -> Unit, onLeave: () -> Unit) { + val random = remember { java.util.Random() } + var game by remember { mutableStateOf(PatienceGame.deal(random)) } + val focus = remember { FocusRequester() } + val menuFocus = remember { FocusRequester() } + LaunchedEffect(game.won) { if (game.won) { onWin(); menuFocus.requestFocus() } else focus.requestFocus() } + BackHandler { if (game.held != null) game = game.drop() else onLeave() } + Row(Modifier.fillMaxSize().background(Color(0xFF0A2A1F)).padding(16.dp).focusRequester(focus).focusable().onPreviewKeyEvent { event -> + val native = event.nativeKeyEvent + if (native.action != KeyEvent.ACTION_DOWN || game.won) return@onPreviewKeyEvent false + val next = when (native.keyCode) { + KeyEvent.KEYCODE_DPAD_LEFT -> game.steer(-1, 0) + KeyEvent.KEYCODE_DPAD_RIGHT -> game.steer(1, 0) + KeyEvent.KEYCODE_DPAD_UP -> game.steer(0, -1) + KeyEvent.KEYCODE_DPAD_DOWN -> game.steer(0, 1) + KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER -> if (native.repeatCount > 0) null else game.press() + KeyEvent.KEYCODE_MENU, KeyEvent.KEYCODE_PROG_RED -> game.sendHome() + else -> null + } + if (next != null) game = next + next != null + }, horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Column(Modifier.width(170.dp).fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text("PATIENCE", color = Honey, fontFamily = Marquee, fontSize = 34.sp) + Spacer(Modifier.height(10.dp)) + Text("MOVES", color = Muted, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 2.sp) + Text(game.moves.toString(), color = Color.White, fontFamily = Marquee, fontSize = 32.sp) + Text("GAMES WON", color = Muted, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 2.sp) + Text(wins.toString(), color = Color.White, fontFamily = Marquee, fontSize = 32.sp) + Spacer(Modifier.weight(1f)) + Text("◄►▲▼ Point", color = Muted, fontSize = 13.sp) + Text("OK Pick up / put down", color = Muted, fontSize = 13.sp) + Text("▲▼ in a column: how many", color = Muted, fontSize = 13.sp) + Text("Back Put back / leave", color = Muted, fontSize = 13.sp) + Text("Menu Send a card home", color = Muted, fontSize = 13.sp) + } + Box(Modifier.weight(1f).fillMaxHeight()) { + Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + listOf(STOCK, WASTE, -1, 2, 3, 4, 5).forEach { i -> + if (i < 0) Spacer(Modifier.width(CardW)) else { + val pointed = game.pile == i; val holding = game.held?.first == i + CardFace(game.piles[i].lastOrNull()?.let { if (i == STOCK) it.copy(up = false) else it }, if (i == STOCK) "↺" else if (i in HOMES) "A" else "", + Modifier.border(if (pointed) 3.dp else 0.dp, if (holding) Honey else Yellow, RoundedCornerShape(6.dp))) + } + } + } + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + for (c in COLUMNS) { + val cards = game.piles[c] + val pointed = game.pile == c + val chosen = if (game.held?.first == c) game.held!!.second else if (pointed) game.depth else 0 + Box(Modifier.width(CardW).height(CardH + 22.dp * 12)) { + if (cards.isEmpty()) CardFace(null, "K", Modifier.border(if (pointed) 3.dp else 0.dp, Yellow, RoundedCornerShape(6.dp))) + cards.forEachIndexed { n, card -> + val inRun = n >= cards.size - chosen + CardFace(card, modifier = Modifier.offset(y = (if (card.up) 22.dp else 12.dp) * n - offsetFix(cards, n)) + .border(if (inRun) 3.dp else 0.dp, if (game.held?.first == c) Honey else Yellow, RoundedCornerShape(6.dp))) + } + } + } + } + } + if (game.won) Column(Modifier.align(Alignment.Center).background(Navy, RoundedCornerShape(6.dp)).border(2.dp, Honey, RoundedCornerShape(6.dp)).padding(20.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("WON!", color = Honey, fontFamily = Marquee, fontSize = 30.sp) + Text("In ${game.moves} moves", color = Color.White, fontSize = 16.sp) + OrbitButton("Deal again", Modifier.width(200.dp).focusRequester(menuFocus), onClick = { game = PatienceGame.deal(random) }) + OrbitButton("Leave the game", Modifier.width(200.dp), onClick = onLeave) + } + } + } +} + +/** Face-down cards sit closer together than face-up ones: the offset saved above card [n]. */ +private fun offsetFix(cards: List, n: Int): Dp = if (!cards[n].up) 0.dp else 10.dp * cards.take(n).count { !it.up } diff --git a/android/app/src/main/java/tv/orbit/classic/OrbitArcade.kt b/android/app/src/main/java/tv/orbit/classic/OrbitArcade.kt index 76a7b50..36bdcec 100644 --- a/android/app/src/main/java/tv/orbit/classic/OrbitArcade.kt +++ b/android/app/src/main/java/tv/orbit/classic/OrbitArcade.kt @@ -305,15 +305,17 @@ private enum class ArcadeGame(val title: String, val blurb: String) { Snake("SNAKE", "Eat, grow, and don't hit the walls or yourself."), Hive("ORBITHIVE", "Fire flowers into the honeycomb, and clear the hive before it reaches you."), Blocks("BLOCK DROP", "Turn and drop the falling blocks, and clear full rows before the stack reaches the top."), - Bricks("BRICK BREAK", "Keep the ball in play with your paddle and break every brick in the wall.") + Bricks("BRICK BREAK", "Keep the ball in play with your paddle and break every brick in the wall."), + Pairs("PAIRS", "Turn two cards at a time and find every pair of Orbit's channels in as few moves as you can."), + Patience("PATIENCE", "The classic card game: build each suit home from ace to king.") } @Composable private fun GameTile(game: ArcadeGame, focus: FocusRequester?, nav: FocusRequester?, onFocus: () -> Unit, onPlay: () -> Unit) { var focused by remember { mutableStateOf(false) } - Box((if (focus != null) Modifier.focusRequester(focus) else Modifier).size(135.dp).focusProperties { if (nav != null) up = nav }.onFocusChanged { focused = it.isFocused; if (it.isFocused) onFocus() }.clickable(onClick = onPlay), contentAlignment = Alignment.Center) { + Box((if (focus != null) Modifier.focusRequester(focus) else Modifier).size(118.dp).focusProperties { if (nav != null) up = nav }.onFocusChanged { focused = it.isFocused; if (it.isFocused) onFocus() }.clickable(onClick = onPlay), contentAlignment = Alignment.Center) { Canvas(Modifier.fillMaxSize()) { drawPath(hexagon(center, size.minDimension / 2), if (focused) Yellow else Panel) } - Text(when (game) { ArcadeGame.Snake -> "Snake"; ArcadeGame.Hive -> "OrbitHive"; ArcadeGame.Blocks -> "Block Drop"; ArcadeGame.Bricks -> "Brick Break" }, color = if (focused) Navy else Color.White, fontSize = 18.sp, fontWeight = FontWeight.SemiBold) + Text(when (game) { ArcadeGame.Snake -> "Snake"; ArcadeGame.Hive -> "OrbitHive"; ArcadeGame.Blocks -> "Block Drop"; ArcadeGame.Bricks -> "Brick Break"; ArcadeGame.Pairs -> "Pairs"; ArcadeGame.Patience -> "Patience" }, color = if (focused) Navy else Color.White, fontSize = 18.sp, fontWeight = FontWeight.SemiBold) } } @@ -325,12 +327,16 @@ internal fun OrbitArcade(modifier: Modifier, onPlaying: (Boolean) -> Unit) { var best by remember { mutableIntStateOf(settings.snakeBest) } var blockBest by remember { mutableIntStateOf(settings.blockDropBest) } var brickBest by remember { mutableIntStateOf(settings.brickBreakBest) } + var pairsBest by remember { mutableIntStateOf(settings.pairsBest) } + var patienceWins by remember { mutableIntStateOf(settings.patienceWins) } var playing by remember { mutableStateOf(null) } var chosen by remember { mutableStateOf(ArcadeGame.Snake) } DisposableEffect(playing) { onPlaying(playing != null); onDispose { onPlaying(false) } } when (playing) { ArcadeGame.Snake -> { SnakeScreen(best, onBest = { best = it; settings.snakeBest = it }, onLeave = { playing = null }); return } ArcadeGame.Hive -> { OrbitHiveScreen(onLeave = { playing = null }); return } + ArcadeGame.Pairs -> { PairsScreen(pairsBest, onBest = { pairsBest = it; settings.pairsBest = it }, onLeave = { playing = null }); return } + ArcadeGame.Patience -> { PatienceScreen(patienceWins, onWin = { patienceWins += 1; settings.patienceWins = patienceWins }, onLeave = { playing = null }); return } ArcadeGame.Bricks -> { BrickBreakScreen(brickBest, onBest = { brickBest = it; settings.brickBreakBest = it }, onLeave = { playing = null }); return } ArcadeGame.Blocks -> { BlockDropScreen(blockBest, onBest = { blockBest = it; settings.blockDropBest = it }, onLeave = { playing = null }); return } null -> {} @@ -352,6 +358,16 @@ internal fun OrbitArcade(modifier: Modifier, onPlaying: (Boolean) -> Unit) { if (chosen == ArcadeGame.Snake) SnakeField(preview, Modifier.fillMaxSize()) else if (chosen == ArcadeGame.Blocks) BlockField(blockPreview, Modifier.fillMaxSize().padding(8.dp)) else if (chosen == ArcadeGame.Bricks) BrickField(brickPreview, Modifier.fillMaxSize().padding(8.dp)) + else if (chosen == ArcadeGame.Pairs || chosen == ArcadeGame.Patience) Canvas(Modifier.fillMaxSize().padding(14.dp)) { + // A spread of cards: face down for Pairs, a dealt column for Patience. + val w = size.width / 8; val h = w * 1.36f + for (i in 0 until 7) { + val down = chosen == ArcadeGame.Pairs && i % 3 != 1 + val at = Offset(i * w * 1.12f, if (chosen == ArcadeGame.Patience) i * h * .22f else (i % 2) * h * .6f) + drawRoundRect(if (down) Panel else Color(0xFFF4F6FB), at, Size(w, h), androidx.compose.ui.geometry.CornerRadius(w * .1f)) + drawRoundRect(if (down) Blue else BlockColours[i % BlockColours.size], at, Size(w, h), androidx.compose.ui.geometry.CornerRadius(w * .1f), style = Stroke(w * .05f)) + } + } else Canvas(Modifier.fillMaxSize()) { val colours = listOf(Color(0xFFE53935), Color(0xFF1E66D0), Color(0xFF2E9D3A), Color(0xFFF4F4F4), Color(0xFFF5A623), Color(0xFF8E44AD)) val r = size.height / 9 @@ -362,7 +378,8 @@ internal fun OrbitArcade(modifier: Modifier, onPlaying: (Boolean) -> Unit) { } } Text(chosen.title, color = Honey, fontFamily = Marquee, fontSize = 30.sp) - Text(if (chosen == ArcadeGame.Snake && best > 0) "Your best: $best" else if (chosen == ArcadeGame.Blocks && blockBest > 0) "Your best: $blockBest" else if (chosen == ArcadeGame.Bricks && brickBest > 0) "Your best: $brickBest" else chosen.blurb, color = Color.White, fontSize = 15.sp, maxLines = 2) + Text(if (chosen == ArcadeGame.Snake && best > 0) "Your best: $best" else if (chosen == ArcadeGame.Blocks && blockBest > 0) "Your best: $blockBest" else if (chosen == ArcadeGame.Bricks && brickBest > 0) "Your best: $brickBest" + else if (chosen == ArcadeGame.Pairs && pairsBest > 0) "Your best: $pairsBest moves" else if (chosen == ArcadeGame.Patience && patienceWins > 0) "Games won: $patienceWins" else chosen.blurb, color = Color.White, fontSize = 15.sp, maxLines = 2) } Column(Modifier.width(290.dp).fillMaxHeight(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) { Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { @@ -375,9 +392,9 @@ internal fun OrbitArcade(modifier: Modifier, onPlaying: (Boolean) -> Unit) { GameTile(ArcadeGame.Blocks, null, null, { chosen = ArcadeGame.Blocks }, { playing = ArcadeGame.Blocks }) GameTile(ArcadeGame.Bricks, null, null, { chosen = ArcadeGame.Bricks }, { playing = ArcadeGame.Bricks }) } - Box(Modifier.offset(y = (-30).dp).size(84.dp), contentAlignment = Alignment.Center) { - Canvas(Modifier.fillMaxSize()) { drawPath(hexagon(center, size.minDimension / 2), Panel.copy(alpha = .4f)) } - Text("More soon", color = Muted, fontSize = 11.sp) + Row(Modifier.offset(y = (-36).dp), horizontalArrangement = Arrangement.spacedBy(6.dp)) { + GameTile(ArcadeGame.Pairs, null, null, { chosen = ArcadeGame.Pairs }, { playing = ArcadeGame.Pairs }) + GameTile(ArcadeGame.Patience, null, null, { chosen = ArcadeGame.Patience }, { playing = ArcadeGame.Patience }) } } } diff --git a/android/app/src/main/java/tv/orbit/classic/Settings.kt b/android/app/src/main/java/tv/orbit/classic/Settings.kt index 7561c2a..15b9484 100644 --- a/android/app/src/main/java/tv/orbit/classic/Settings.kt +++ b/android/app/src/main/java/tv/orbit/classic/Settings.kt @@ -50,6 +50,13 @@ internal class OrbitSettings(context: Context) { var brickBreakBest: Int get() = saved.getInt("brick_break_best", 0) set(value) { saved.edit().putInt("brick_break_best", value).apply() } + /** Pairs: the fewest moves to clear the board, 0 before the first win. */ + var pairsBest: Int + get() = saved.getInt("pairs_best", 0) + set(value) { saved.edit().putInt("pairs_best", value).apply() } + var patienceWins: Int + get() = saved.getInt("patience_wins", 0) + set(value) { saved.edit().putInt("patience_wins", value).apply() } var subtitlesOn: Boolean get() = saved.getBoolean("subtitles_on", false) set(value) { saved.edit().putBoolean("subtitles_on", value).apply() } diff --git a/android/app/src/test/java/tv/orbit/classic/PairsPatienceTest.kt b/android/app/src/test/java/tv/orbit/classic/PairsPatienceTest.kt new file mode 100644 index 0000000..e2af42f --- /dev/null +++ b/android/app/src/test/java/tv/orbit/classic/PairsPatienceTest.kt @@ -0,0 +1,52 @@ +package tv.orbit.classic + +import org.junit.Assert.* +import org.junit.Test + +/** Pairs and Patience: the rules, as the remote plays them. */ +class PairsPatienceTest { + @Test fun pairsMatchStaysUpAndAMissTurnsBack() { + val g = PairsGame(listOf(0, 1, 0, 1) + List(16) { 2 + it / 2 }) + val matched = g.copy(cursor = 0).flip().copy(cursor = 2).flip() + assertEquals(setOf(0, 2), matched.matched); assertEquals(1, matched.moves); assertFalse(matched.waiting) + val missed = g.copy(cursor = 0).flip().copy(cursor = 1).flip() + assertTrue(missed.waiting); assertEquals(missed, missed.copy(cursor = 3).flip().copy(cursor = 1)) // no third card while two are up + assertTrue(missed.hide().open.isEmpty()) + assertEquals(g.copy(cursor = 0).flip(), g.copy(cursor = 0).flip().flip()) // the same card twice does nothing + assertEquals(PAIRS_COLUMNS - 1, g.moveCursor(9, 0).cursor); assertEquals(0, g.moveCursor(-1, -1).cursor) + val deal = PairsGame.deal(java.util.Random(3)); assertEquals(20, deal.cards.size); assertTrue(deal.cards.groupingBy { it }.eachCount().values.all { it == 2 }) + } + + @Test fun patienceDealsTheClassicLayout() { + val g = PatienceGame.deal(java.util.Random(5)) + assertEquals((1..7).toList(), COLUMNS.map { g.piles[it].size }) + assertTrue(COLUMNS.all { c -> g.piles[c].last().up && g.piles[c].dropLast(1).none { it.up } }) + assertEquals(24, g.piles[STOCK].size); assertEquals(52, g.piles.sumOf { it.size }) + // The stock turns one card at a time, and an empty stock takes the turned cards back. + var s = g.point(STOCK); repeat(24) { s = s.press() } + assertEquals(24, s.piles[WASTE].size); assertEquals(24, s.press().piles[STOCK].size) + } + + private fun game(vararg piles: Pair>) = PatienceGame(List(13) { i -> piles.firstOrNull { it.first == i }?.second.orEmpty() }) + + @Test fun cardsGoHomeBySuitAndColumnsBuildDownInAlternateColours() { + val g = game(6 to listOf(PlayingCard(1, 0)), 7 to listOf(PlayingCard(9, 1)), 8 to listOf(PlayingCard(5, 3, false), PlayingCard(8, 0))) + val home = g.point(6).press().point(2).press() + assertEquals(listOf(PlayingCard(1, 0)), home.piles[2]); assertEquals(1, home.moves) + val built = g.point(8).press().point(7).press() // black 8 on red 9 + assertEquals(2, built.piles[7].size); assertTrue(built.piles[8].single().up) // the card beneath turns up + assertEquals(g.piles, g.point(7).press().point(8).press().piles) // red 9 can't go on black 8: nothing moves + assertFalse(g.allowed(listOf(PlayingCard(12, 0)), 9)); assertTrue(g.allowed(listOf(PlayingCard(13, 0)), 9)) // only a king on an empty column + assertEquals(listOf(PlayingCard(1, 0)), g.point(6).sendHome().piles[2]) + } + + @Test fun aRunMovesTogetherAndBackPutsItDown() { + val g = game(6 to listOf(PlayingCard(10, 1), PlayingCard(9, 0), PlayingCard(8, 2)), 7 to listOf(PlayingCard(11, 3))) + val picked = g.point(6).steer(0, -1).steer(0, -1) + assertEquals(3, picked.depth) + val moved = picked.press().point(7).press() + assertEquals(4, moved.piles[7].size); assertTrue(moved.piles[6].isEmpty()) + assertNull(g.point(6).press().drop().held) + assertTrue(game(*HOMES.map { h -> h to (1..13).map { PlayingCard(it, h - 2) } }.toTypedArray()).won) + } +}