Orbit Arcade: Pairs and Patience, completing Paul's game list
pauljd/davidson-ci: Davidson CI / quality (push) Failing after 1m59s
pauljd/davidson-ci: Davidson CI / ai-review (push) Failing after 1m34s

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) <noreply@anthropic.com>
This commit is contained in:
2026-11-05 17:35:36 +00:00
co-authored by Claude Opus 5.5
parent 23e63f114c
commit a6291159c2
5 changed files with 436 additions and 7 deletions
@@ -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<Int>, val matched: Set<Int> = emptySet(), val open: List<Int> = 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)
}
}
}
}
@@ -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<List<PlayingCard>>, val pile: Int = 6, val depth: Int = 1, val held: Pair<Int, Int>? = 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<PlayingCard>, 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<PlayingCard>() }
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<PlayingCard>, n: Int): Dp = if (!cards[n].up) 0.dp else 10.dp * cards.take(n).count { !it.up }
@@ -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<ArcadeGame?>(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 })
}
}
}
@@ -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() }
@@ -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<Int, List<PlayingCard>>) = 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)
}
}