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) <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user