The arcade redesigned, and Orb-Man (from 0.108.24)
pauljd/davidson-ci: Davidson CI / quality (push) Failing after 2m30s
pauljd/davidson-ci: Davidson CI / ai-review (push) Failing after 1m34s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-11-05 20:51:43 +00:00
co-authored by Claude Opus 5.5
parent 0c1cbf5ed8
commit 18d309651f
4 changed files with 707 additions and 54 deletions
@@ -0,0 +1,452 @@
package tv.orbit.classic
import android.view.KeyEvent
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Canvas
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.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalWindowInfo
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 kotlin.math.PI
import kotlin.math.abs
import kotlin.math.sin
/**
* Orb-Man (Paul, 5 November 2026): a maze chase starring the Tots mascot. Clear every star from Orbit's own maze while four
* space rocks give chase; a power hexagon makes the rocks take fright, the screen glows, and for a while they can be caught.
* Each cleared maze is a level, a little quicker than the last. The game itself has no screen in it, so it can be tested.
*/
internal const val ORB_TICK_MS = 20L
/** The maze's left half and middle column; the right half mirrors it. # wall, . star, o power hexagon, - the rocks' door, H their den, P start. */
private val OrbMazeLeft = listOf(
"############",
"#o.........#",
"#.###.####.#",
"#...........",
"#.###.#.#...",
"#.....#..##-",
"...#..#..#HH",
"#.#...#..###",
"#.#.###.....",
"#...#...##.#",
"#.###.#.##..",
"#o....#....P",
"############")
internal val OrbMazeRows: List<String> = OrbMazeLeft.map { left -> left + left.substring(0, left.length - 1).reversed().replace('P', '.') }
internal data class OrbPos(val x: Int, val y: Int)
internal enum class OrbDir(val dx: Int, val dy: Int) {
Up(0, -1), Left(-1, 0), Down(0, 1), Right(1, 0);
val opposite get() = when (this) { Up -> Down; Down -> Up; Left -> Right; Right -> Left }
}
internal class OrbMaze(val rows: List<String> = OrbMazeRows) {
val width = rows[0].length
val height = rows.size
private fun at(x: Int, y: Int) = rows.getOrNull(y)?.getOrNull(Math.floorMod(x, width)) ?: '#'
fun wall(p: OrbPos) = at(p.x, p.y) == '#'
fun door(p: OrbPos) = at(p.x, p.y) == '-'
fun den(p: OrbPos) = at(p.x, p.y) == 'H'
/** One step on, through the side tunnels where a row is open at both ends. */
fun step(p: OrbPos, d: OrbDir) = OrbPos(Math.floorMod(p.x + d.dx, width), p.y + d.dy)
fun cells(kind: Char) = rows.flatMapIndexed { y, row -> row.mapIndexedNotNull { x, c -> if (c == kind) OrbPos(x, y) else null } }
val start get() = cells('P').single()
val denCells get() = cells('H')
val doorCell get() = cells('-').single()
/** Just outside the door, where a rock heads to leave the den. */
val outside get() = OrbPos(doorCell.x, doorCell.y - 1)
}
/** Where a moving thing is, and where it came from this step, for drawing it in between. */
internal data class OrbMover(val pos: OrbPos, val from: OrbPos, val dir: OrbDir, val movedAt: Int = 0)
internal data class OrbChaser(val mover: OrbMover, val home: OrbPos, val releaseAt: Int,
/** Frightened by a power hexagon: slow, wandering, and can be caught. */
val scared: Boolean = false,
/** Caught: only its eyes, hurrying home to the den. */
val returning: Boolean = false,
/** Out of the den and on the loose. */
val out: Boolean = false)
internal enum class OrbEvent { Star, Power, Catch, Caught, Cleared }
internal data class OrbGame(
val maze: OrbMaze, val player: OrbMover, val wanted: OrbDir, val chasers: List<OrbChaser>,
val stars: Set<OrbPos>, val powers: Set<OrbPos>, val score: Int = 0, val lives: Int = 3, val level: Int = 1,
val tick: Int = 0,
/** Ticks of fright left. */
val powerLeft: Int = 0,
/** Rocks caught on this hexagon, for the doubling score. */
val chain: Int = 0,
/** A moment's pause before play: the start, after being caught, and between levels. */
val readyLeft: Int = READY_TICKS,
val alive: Boolean = true,
val events: List<OrbEvent> = emptyList()) {
val playerEvery get() = (8 - (level - 1) / 2).coerceAtLeast(6)
val chaserEvery get() = (10 - (level - 1)).coerceAtLeast(7)
val powerTicks get() = (400 - 40 * (level - 1)).coerceAtLeast(150)
val powered get() = powerLeft > 0
fun turn(d: OrbDir) = copy(wanted = d)
private fun open(p: OrbPos) = !maze.wall(p) && !maze.door(p) && !maze.den(p)
fun step(random: java.util.Random): OrbGame {
if (!alive) return this
if (readyLeft > 0) return copy(readyLeft = readyLeft - 1, tick = tick + 1, events = emptyList())
val t = tick + 1
val events = mutableListOf<OrbEvent>()
var g = copy(tick = t, events = emptyList(), powerLeft = (powerLeft - 1).coerceAtLeast(0))
if (g.powerLeft == 0 && powerLeft > 0) g = g.copy(chasers = g.chasers.map { it.copy(scared = false) }, chain = 0)
// Orb-Man: the turn you asked for as soon as there's room for it, otherwise straight on until a wall.
if (t - player.movedAt >= playerEvery) {
val dir = listOf(wanted, player.dir).firstOrNull { open(maze.step(player.pos, it)) }
val moved = if (dir != null) OrbMover(maze.step(player.pos, dir), player.pos, dir, t) else player.copy(from = player.pos, movedAt = t)
var stars = g.stars; var powers = g.powers; var score = g.score; var chasers = g.chasers; var powerLeft = g.powerLeft; var chain = g.chain
if (moved.pos in stars) { stars = stars - moved.pos; score += 10; events += OrbEvent.Star }
if (moved.pos in powers) {
powers = powers - moved.pos; score += 50; events += OrbEvent.Power
powerLeft = powerTicks; chain = 0
// The rocks take fright and turn about.
chasers = chasers.map { c -> if (c.returning || !c.out) c else c.copy(scared = true, mover = c.mover.copy(dir = c.mover.dir.opposite)) }
}
g = g.copy(player = moved, stars = stars, powers = powers, score = score, chasers = chasers, powerLeft = powerLeft, chain = chain)
}
g = g.copy(chasers = g.chasers.mapIndexed { i, c -> g.moveChaser(i, c, t, random) })
// Caught, or catching: on the same square, or passing through each other.
var result = g
for ((i, c) in g.chasers.withIndex()) {
if (c.returning || !c.out) continue
val meet = c.mover.pos == g.player.pos || (c.mover.pos == g.player.from && c.mover.from == g.player.pos)
if (!meet) continue
if (c.scared) {
events += OrbEvent.Catch
result = result.copy(score = result.score + (200 shl result.chain).coerceAtMost(1600), chain = result.chain + 1,
chasers = result.chasers.mapIndexed { j, o -> if (j == i) o.copy(scared = false, returning = true) else o })
} else {
events += OrbEvent.Caught
val lives = result.lives - 1
return if (lives <= 0) result.copy(lives = 0, alive = false, events = events)
else result.restart(lives = lives).copy(events = events)
}
}
if (result.stars.isEmpty() && result.powers.isEmpty()) {
events += OrbEvent.Cleared
return start(maze, result.level + 1, result.score, result.lives).copy(events = events)
}
return result.copy(events = events)
}
/** Everyone back to their places for another go; the stars stay as they are. */
private fun restart(lives: Int) = copy(lives = lives, player = OrbMover(maze.start, maze.start, OrbDir.Left, tick), wanted = OrbDir.Left,
chasers = chasersAtHome(maze, tick), powerLeft = 0, chain = 0, readyLeft = READY_TICKS)
private fun moveChaser(index: Int, c: OrbChaser, t: Int, random: java.util.Random): OrbChaser {
val every = when { c.returning -> 4; c.scared -> chaserEvery + 6; !c.out -> chaserEvery + 4; else -> chaserEvery }
if (t - c.mover.movedAt < every) return c
if (!c.out && !c.returning && t < c.releaseAt) return c
val target = when {
c.returning -> if (maze.den(c.mover.pos) || maze.door(c.mover.pos)) c.home else maze.doorCell
!c.out -> maze.outside
else -> chaseTarget(index, c)
}
// Rocks use the door going out and coming home; once out, the den is closed to them.
fun passable(p: OrbPos) = !maze.wall(p) && (c.returning || !c.out || (!maze.door(p) && !maze.den(p)))
val choices = OrbDir.entries.filter { passable(maze.step(c.mover.pos, it)) }
val forward = choices.filter { it != c.mover.dir.opposite }.ifEmpty { choices }
if (forward.isEmpty()) return c.copy(mover = c.mover.copy(from = c.mover.pos, movedAt = t))
val dir = if (c.scared) forward[random.nextInt(forward.size)]
else forward.minBy { d -> maze.step(c.mover.pos, d).let { (it.x - target.x) * (it.x - target.x) + (it.y - target.y) * (it.y - target.y) } }
val next = maze.step(c.mover.pos, dir)
val mover = OrbMover(next, c.mover.pos, dir, t)
return when {
c.returning && next == c.home -> c.copy(mover = mover, returning = false, out = false, releaseAt = t + 60)
!c.out && next == maze.outside -> c.copy(mover = mover, out = true, scared = false)
else -> c.copy(mover = mover)
}
}
/** Each rock has its own way of hunting. */
private fun chaseTarget(index: Int, c: OrbChaser): OrbPos {
val p = player.pos; val d = player.dir
return when (index) {
// Straight for Orb-Man.
0 -> p
// Cuts in three squares ahead of him.
1 -> OrbPos(p.x + 3 * d.dx, p.y + 3 * d.dy)
// Hunts him close up, and drifts back to its corner when he's far away.
2 -> if (abs(c.mover.pos.x - p.x) + abs(c.mover.pos.y - p.y) > 9) OrbPos(maze.width - 2, maze.height - 2) else p
// Guards the stars nearest to him.
else -> stars.minByOrNull { abs(it.x - p.x) + abs(it.y - p.y) } ?: p
}
}
companion object {
const val READY_TICKS = 100
fun chasersAtHome(maze: OrbMaze, tick: Int): List<OrbChaser> {
val den = maze.denCells
// The first starts just outside; the others leave the den one after another.
return List(4) { i ->
if (i == 0) OrbChaser(OrbMover(maze.outside, maze.outside, OrbDir.Left, tick), den[1], tick, out = true)
else { val home = den[(i - 1) % den.size]; OrbChaser(OrbMover(home, home, OrbDir.Up, tick), home, tick + READY_TICKS + i * 150) }
}
}
fun start(maze: OrbMaze = OrbMaze(), level: Int = 1, score: Int = 0, lives: Int = 3): OrbGame =
OrbGame(maze, OrbMover(maze.start, maze.start, OrbDir.Left), OrbDir.Left, chasersAtHome(maze, 0), maze.cells('.').toSet(), maze.cells('o').toSet(),
score = score, lives = lives, level = level)
}
}
private val Marquee = FontFamily(Font(R.font.anton_regular))
private val Honey = Color(0xFFFFD84A)
internal val OrbChaserColours = listOf(Color(0xFFFF4FA3), Color(0xFF35D6F2), Color(0xFFFF8A3D), Color(0xFFA77BFF))
private val OrbWall = Color(0xFF2E4FE0)
private val OrbWallLight = Color(0xFF7D95FF)
private val OrbFloor = Color(0xFF0A1850)
private val OrbStar = Color(0xFFFFE066)
private val Mascot = Color(0xFFF7A928)
private val Outline = Color(0xFF14213D)
/** The Tots mascot: a smiling orange planet in its ring, looking where it's going. [chomp] opens its smile, 0 to 1. */
internal fun DrawScope.drawOrbMascot(centre: Offset, r: Float, dir: OrbDir = OrbDir.Right, chomp: Float = 0f) {
// The ring behind, then the planet, then the ring's front.
val ringW = r * 2.7f; val ringH = r * .9f
val ringTop = Offset(centre.x - ringW / 2, centre.y - ringH / 2 + r * .15f)
drawArc(Color.White, 180f, 180f, false, ringTop, Size(ringW, ringH), style = Stroke(r * .2f))
drawCircle(Outline, r * 1.08f, centre)
drawCircle(Mascot, r, centre)
drawCircle(Color.White.copy(alpha = .25f), r * .32f, centre + Offset(-r * .4f, -r * .45f))
val look = Offset(dir.dx * r * .16f, dir.dy * r * .14f)
drawCircle(Outline, r * .13f, centre + Offset(-r * .33f, -r * .2f) + look)
drawCircle(Outline, r * .13f, centre + Offset(r * .33f, -r * .2f) + look)
val mouth = r * (.42f + .2f * chomp)
drawArc(Outline, 15f, 150f, chomp > .5f, Offset(centre.x - mouth + look.x, centre.y - mouth * .55f + look.y), Size(mouth * 2, mouth * (1.1f + chomp * .5f)),
style = Stroke(r * .14f, cap = StrokeCap.Round))
drawArc(Color.White, 0f, 180f, false, ringTop, Size(ringW, ringH), style = Stroke(r * .2f))
drawArc(Outline, 0f, 180f, false, ringTop - Offset(0f, -r * .1f), Size(ringW, ringH), style = Stroke(r * .05f))
}
/** A space rock: lumpy, with eyes that follow its way. Frightened it turns pale and wobbles; caught, only its eyes go home. */
internal fun DrawScope.drawOrbChaser(centre: Offset, r: Float, colour: Color, dir: OrbDir, scared: Boolean, returning: Boolean, blink: Boolean, wobble: Float) {
if (!returning) {
val body = if (scared) (if (blink) Color(0xFFE8F0FF) else Color(0xFF4D6BFF)) else colour
val rock = Path().apply {
val n = 9
for (i in 0..n) {
val a = 2 * PI * i / n
val bump = 1f + .12f * sin(a * 3 + wobble).toFloat()
val x = centre.x + (r * bump * kotlin.math.cos(a)).toFloat(); val y = centre.y + (r * bump * sin(a)).toFloat()
if (i == 0) moveTo(x, y) else lineTo(x, y)
}
close()
}
drawPath(rock, body)
drawCircle(Color.White.copy(alpha = .18f), r * .3f, centre + Offset(-r * .35f, -r * .4f))
drawCircle(Color.Black.copy(alpha = .12f), r * .18f, centre + Offset(r * .4f, r * .35f))
if (scared) {
// A worried wavy mouth instead of eyes that hunt.
val w = r * .9f; val y = centre.y + r * .3f
val p = Path().apply { moveTo(centre.x - w / 2, y); for (k in 1..4) lineTo(centre.x - w / 2 + k * w / 4, y + if (k % 2 == 1) -r * .14f else 0f) }
drawPath(p, if (blink) Color(0xFF4D6BFF) else Color.White, style = Stroke(r * .1f))
drawCircle(if (blink) Color(0xFF4D6BFF) else Color.White, r * .1f, centre + Offset(-r * .3f, -r * .15f))
drawCircle(if (blink) Color(0xFF4D6BFF) else Color.White, r * .1f, centre + Offset(r * .3f, -r * .15f))
return
}
}
val look = Offset(dir.dx * r * .14f, dir.dy * r * .14f)
for (side in listOf(-1f, 1f)) {
val eye = centre + Offset(side * r * .36f, -r * .12f)
drawCircle(Color.White, r * .27f, eye)
drawCircle(Color(0xFF14213D), r * .13f, eye + look)
}
}
/** The maze and everyone in it. [phase] is how far between ticks we are, for smooth movement; [glow] the power glow, 0 to 1. */
internal fun DrawScope.drawOrbField(game: OrbGame, phase: Float = 0f, glow: Float = 0f, timeMs: Long = 0L) {
val maze = game.maze
val cell = minOf(size.width / maze.width, size.height / maze.height)
val origin = Offset((size.width - cell * maze.width) / 2, (size.height - cell * maze.height) / 2)
fun centre(p: OrbPos) = origin + Offset((p.x + .5f) * cell, (p.y + .5f) * cell)
drawRect(OrbFloor, origin, Size(cell * maze.width, cell * maze.height))
// A faint check in the floor.
for (y in 0 until maze.height) for (x in 0 until maze.width) if ((x + y) % 2 == 0) drawRect(Color.White.copy(alpha = .025f), origin + Offset(x * cell, y * cell), Size(cell, cell))
if (glow > 0f) drawRect(Brush.radialGradient(listOf(Color(0xFF6EF0FF).copy(alpha = .28f * glow), Color(0xFF6EF0FF).copy(alpha = .06f * glow)),
centre(game.player.pos), maxOf(size.width, size.height) * .7f), origin, Size(cell * maze.width, cell * maze.height))
// Walls as glowing pipes: a joint on every wall square, joined to its neighbours.
val walls = (0 until maze.height).flatMap { y -> (0 until maze.width).map { x -> OrbPos(x, y) } }.filter { maze.wall(it) }
val wallColour = if (glow > 0f) androidx.compose.ui.graphics.lerp(OrbWall, Color(0xFF29C4E8), glow * .6f) else OrbWall
for ((width, colour) in listOf(cell * .46f to wallColour, cell * .16f to OrbWallLight)) for (w in walls) {
val c = centre(w)
drawCircle(colour, width / 2, c)
for (d in listOf(OrbDir.Right, OrbDir.Down)) {
val n = OrbPos(w.x + d.dx, w.y + d.dy)
if (n.x < maze.width && n.y < maze.height && maze.wall(n)) drawLine(colour, c, centre(n), width)
}
}
drawLine(Color(0xFFFF9BD0), centre(maze.doorCell) - Offset(cell * .45f, 0f), centre(maze.doorCell) + Offset(cell * .45f, 0f), cell * .12f)
for (s in game.stars) drawCircle(OrbStar, cell * .1f, centre(s))
// The power hexagons pulse gently through Orbit's colours.
val pulse = .85f + .15f * sin(timeMs / 260.0).toFloat()
game.powers.forEachIndexed { i, p ->
val hex = Path().apply {
for (k in 0..5) { val a = Math.toRadians(60.0 * k + 30); val at = centre(p) + Offset((cell * .32f * pulse * kotlin.math.cos(a)).toFloat(), (cell * .32f * pulse * sin(a)).toFloat()); if (k == 0) moveTo(at.x, at.y) else lineTo(at.x, at.y) }
close()
}
drawPath(hex, listOf(Color(0xFF6EF0FF), Color(0xFFFF7AC6), Color(0xFFB79BFF), Color(0xFF7DFFB0))[i % 4])
}
fun drawn(m: OrbMover, every: Int): Offset {
val a = centre(m.from); val b = centre(m.pos)
if (abs(m.pos.x - m.from.x) > 1) return b
val f = ((game.tick - m.movedAt + phase) / every).coerceIn(0f, 1f)
return a + (b - a) * f
}
val blinking = game.powered && game.powerLeft < 100 && (game.powerLeft / 12) % 2 == 0
game.chasers.forEachIndexed { i, c ->
val every = when { c.returning -> 4; c.scared -> game.chaserEvery + 6; !c.out -> game.chaserEvery + 4; else -> game.chaserEvery }
drawOrbChaser(drawn(c.mover, every), cell * .42f, OrbChaserColours[i], c.mover.dir, c.scared, c.returning, blinking, timeMs / 140f + i)
}
val chomp = if (game.readyLeft > 0) 0f else (.5f + .5f * sin(timeMs / 70.0).toFloat())
drawOrbMascot(drawn(game.player, game.playerEvery), cell * .38f, game.player.dir, chomp)
}
/** Short sounds made on the box itself, quiet and original: a pip for each star, a rising swell for power, and so on. */
private class OrbSounds {
private val rate = 22_050
private fun tone(ms: Int, from: Double, to: Double, volume: Double): android.media.AudioTrack {
val n = rate * ms / 1000
val pcm = ShortArray(n) { i ->
val t = i.toDouble() / rate
val f = from + (to - from) * i / n
val env = minOf(1.0, i / (rate * .005)) * minOf(1.0, (n - i) / (rate * .02))
(sin(2 * PI * f * t) * env * volume * Short.MAX_VALUE).toInt().toShort()
}
return android.media.AudioTrack.Builder()
.setAudioAttributes(android.media.AudioAttributes.Builder().setUsage(android.media.AudioAttributes.USAGE_GAME).setContentType(android.media.AudioAttributes.CONTENT_TYPE_SONIFICATION).build())
.setAudioFormat(android.media.AudioFormat.Builder().setEncoding(android.media.AudioFormat.ENCODING_PCM_16BIT).setSampleRate(rate).setChannelMask(android.media.AudioFormat.CHANNEL_OUT_MONO).build())
.setTransferMode(android.media.AudioTrack.MODE_STATIC).setBufferSizeInBytes(n * 2).build().apply { write(pcm, 0, n) }
}
private val tracks = runCatching {
mapOf("starA" to tone(45, 620.0, 660.0, .12), "starB" to tone(45, 780.0, 820.0, .12), OrbEvent.Power.name to tone(380, 280.0, 900.0, .22),
OrbEvent.Catch.name to tone(260, 500.0, 1200.0, .22), OrbEvent.Caught.name to tone(900, 720.0, 140.0, .22), OrbEvent.Cleared.name to tone(600, 520.0, 1040.0, .2))
}.getOrDefault(emptyMap())
private var flip = false
fun play(event: OrbEvent) {
val key = if (event == OrbEvent.Star) { flip = !flip; if (flip) "starA" else "starB" } else event.name
val track = tracks[key] ?: return
runCatching { track.stop(); track.reloadStaticData(); track.play() }
}
fun release() = tracks.values.forEach { runCatching { it.release() } }
}
@Composable
internal fun OrbManScreen(best: Int, onBest: (Int) -> Unit, onLeave: () -> Unit) {
val context = LocalContext.current
val settings = remember { OrbitSettings(context) }
val calm = remember { settings.reduceMotion }
var soundOn by remember { mutableStateOf(settings.orbManSound) }
val sounds = remember { OrbSounds() }
DisposableEffect(Unit) { onDispose { sounds.release() } }
val random = remember { java.util.Random() }
var game by remember { mutableStateOf(OrbGame.start()) }
var paused by remember { mutableStateOf(false) }
var phase by remember { mutableFloatStateOf(0f) }
var clock by remember { mutableLongStateOf(0L) }
val focus = remember { FocusRequester() }
val menuFocus = remember { FocusRequester() }
val windowFocused = LocalWindowInfo.current.isWindowFocused
LaunchedEffect(windowFocused) { if (!windowFocused && game.alive) paused = true }
// One frame clock: whole ticks move the game, what's left over places everyone smoothly in between.
LaunchedEffect(paused, game.alive) {
var last = -1L; var carry = 0L
while (!paused && game.alive) {
withFrameNanos { now ->
val ms = now / 1_000_000
if (last >= 0) carry += (ms - last).coerceAtMost(100)
last = ms
clock = ms
var g = game
while (carry >= ORB_TICK_MS) {
carry -= ORB_TICK_MS
g = g.step(random)
if (soundOn) g.events.forEach(sounds::play)
if (!g.alive) break
}
game = g
phase = carry.toFloat() / ORB_TICK_MS
}
}
if (!game.alive && game.score > best) onBest(game.score)
}
LaunchedEffect(paused, game.alive) { if (paused || !game.alive) menuFocus.requestFocus() else focus.requestFocus() }
BackHandler { if (game.alive) paused = !paused else onLeave() }
// The power glow breathes gently; with Reduce motion on it holds steady.
val glow = if (!game.powered) 0f else if (calm) .7f else .55f + .25f * sin(clock / 400.0).toFloat()
Row(Modifier.fillMaxSize().background(Navy).padding(20.dp).focusRequester(focus).focusable().onPreviewKeyEvent { event ->
val native = event.nativeKeyEvent
if (native.action != KeyEvent.ACTION_DOWN || paused || !game.alive) return@onPreviewKeyEvent false
val dir = when (native.keyCode) {
KeyEvent.KEYCODE_DPAD_UP -> OrbDir.Up; KeyEvent.KEYCODE_DPAD_DOWN -> OrbDir.Down
KeyEvent.KEYCODE_DPAD_LEFT -> OrbDir.Left; KeyEvent.KEYCODE_DPAD_RIGHT -> OrbDir.Right
else -> null
}
if (dir != null) game = game.turn(dir)
if (native.keyCode == KeyEvent.KEYCODE_DPAD_CENTER || native.keyCode == KeyEvent.KEYCODE_ENTER) { paused = true; return@onPreviewKeyEvent true }
dir != null
}, horizontalArrangement = Arrangement.spacedBy(20.dp)) {
Column(Modifier.width(170.dp).fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text("ORB-MAN", color = Honey, fontFamily = Marquee, fontSize = 34.sp)
Spacer(Modifier.height(10.dp))
Text("SCORE", color = Muted, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 2.sp)
Text(game.score.toString(), color = Color.White, fontFamily = Marquee, fontSize = 32.sp)
Text("BEST", color = Muted, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 2.sp)
Text(maxOf(best, game.score).toString(), color = Color.White, fontFamily = Marquee, fontSize = 32.sp)
Text("LEVEL ${game.level}", color = Muted, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 2.sp)
Canvas(Modifier.height(30.dp).fillMaxWidth()) { repeat(game.lives) { drawOrbMascot(Offset(16f + it * 44f, size.height / 2), 11f) } }
Spacer(Modifier.weight(1f))
Text("◄►▲▼ Steer", color = Muted, fontSize = 13.sp)
Text("OK or Back Pause", color = Muted, fontSize = 13.sp)
}
Box(Modifier.weight(1f).fillMaxHeight(), contentAlignment = Alignment.Center) {
Canvas(Modifier.fillMaxSize()) { drawOrbField(game, phase, glow, clock) }
if (game.alive && game.readyLeft > 0 && !paused) Text(if (game.tick == 0 && game.level == 1) "READY!" else if (game.score > 0 && game.stars.size + game.powers.size == game.maze.cells('.').size + game.maze.cells('o').size) "LEVEL ${game.level}" else "READY!",
Modifier.background(Navy.copy(alpha = .8f), RoundedCornerShape(6.dp)).padding(horizontal = 16.dp, vertical = 6.dp), color = Honey, fontFamily = Marquee, fontSize = 26.sp)
if (paused || !game.alive) 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(if (game.alive) "PAUSED" else "GAME OVER", color = Honey, fontFamily = Marquee, fontSize = 30.sp)
if (!game.alive) Text(if (game.score > best) "New best: ${game.score}" else "Score: ${game.score} · level ${game.level}", color = Color.White, fontSize = 16.sp)
OrbitButton(if (game.alive) "Carry on" else "Play again", Modifier.width(200.dp).focusRequester(menuFocus), onClick = {
if (!game.alive) game = OrbGame.start()
paused = false
})
OrbitButton("Sound: ${if (soundOn) "On" else "Off"}", Modifier.width(200.dp), onClick = { soundOn = !soundOn; settings.orbManSound = soundOn })
OrbitButton("Leave the game", Modifier.width(200.dp), onClick = onLeave)
}
}
}
}
@@ -301,25 +301,74 @@ private fun OrbitHiveScreen(onLeave: () -> Unit) {
}, onRelease = { it.destroy() })
}
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."),
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.")
private enum class ArcadeGame(val title: String, val tagline: String, val blurb: String) {
OrbMan("Orb-Man", "Maze chase with the Tots mascot", "Chase the stars, dodge the space rocks, clear the maze."),
Hive("OrbitHive", "Link, match, clear the hive", "Fire flowers into the honeycomb, and clear the hive before it reaches you."),
Snake("Snake", "Grow, turn, beat your best", "Eat, grow, and don't hit the walls or yourself."),
Blocks("Block Drop", "Stack the blocks, keep it tidy", "Turn and drop the falling blocks, and clear full rows before the stack reaches the top."),
Bricks("Brick Break", "Bounce, break, clear the screen", "Keep the ball in play with your paddle and break every brick in the wall."),
Pairs("Pairs", "Find the matching pairs", "Turn two cards at a time and find every pair of Orbit's channels in as few moves as you can."),
Patience("Patience", "Classic solitaire on Orbit", "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(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"; ArcadeGame.Pairs -> "Pairs"; ArcadeGame.Patience -> "Patience" }, color = if (focused) Navy else Color.White, fontSize = 18.sp, fontWeight = FontWeight.SemiBold)
/** The newest game, flagged as the arcade's pick. */
private val ArcadePick = ArcadeGame.OrbMan
/** Each game's little picture, for its card. */
private fun androidx.compose.ui.graphics.drawscope.DrawScope.gameIcon(game: ArcadeGame) {
val s = size.minDimension; val c = center
when (game) {
ArcadeGame.OrbMan -> drawOrbMascot(c, s * .28f)
ArcadeGame.Hive -> listOf(Offset(0f, -.2f) to Color(0xFFFFD84A), Offset(-.18f, .12f) to Color(0xFF6EF0FF), Offset(.18f, .12f) to Color(0xFFFF7AC6))
.forEach { (o, col) -> drawPath(hexagon(c + Offset(o.x * s, o.y * s), s * .17f), col) }
ArcadeGame.Snake -> {
listOf(-.32f, -.16f, 0f).forEachIndexed { i, x -> drawCircle(Color(0xFF3DDC97), s * .1f, c + Offset(x * s, (if (i % 2 == 0) .12f else .04f) * s)) }
val head = c + Offset(.2f * s, -.06f * s)
drawCircle(Color(0xFF3DDC97), s * .14f, head)
drawCircle(Color.White, s * .05f, head + Offset(-s * .05f, -s * .03f)); drawCircle(Color.White, s * .05f, head + Offset(s * .05f, -s * .03f))
drawCircle(Navy, s * .025f, head + Offset(-s * .05f, -s * .03f)); drawCircle(Navy, s * .025f, head + Offset(s * .05f, -s * .03f))
}
ArcadeGame.Blocks -> { val b = s * .2f
listOf(Offset(0f, -1f), Offset(-1f, 0f), Offset(0f, 0f), Offset(1f, 0f)).forEach { o -> drawRect(Color(0xFF4C7DFF), c + Offset(o.x * b - b / 2 + 1, o.y * b - 1), Size(b - 2, b - 2)) } }
ArcadeGame.Bricks -> {
for (row in 0..1) for (col in 0..2) drawRect(Color(0xFFFF6BA8), Offset(c.x - s * .4f + col * s * .27f + (row % 2) * s * .1f, c.y - s * .35f + row * s * .16f), Size(s * .24f, s * .12f))
drawCircle(Color.White, s * .06f, c + Offset(s * .05f, s * .12f))
drawRect(Color(0xFF6EF0FF), Offset(c.x - s * .18f, c.y + s * .3f), Size(s * .36f, s * .06f))
}
ArcadeGame.Pairs -> for (i in 0..1) {
val at = Offset(c.x - s * .36f + i * s * .38f, c.y - s * .3f)
drawRoundRect(Color(0xFF1E3E8C), at, Size(s * .34f, s * .5f), androidx.compose.ui.geometry.CornerRadius(s * .04f))
drawRoundRect(Color.White, at, Size(s * .34f, s * .5f), androidx.compose.ui.geometry.CornerRadius(s * .04f), style = Stroke(s * .03f))
drawCircle(Color(0xFFFFD84A), s * .08f, at + Offset(s * .17f, s * .25f))
}
ArcadeGame.Patience -> for (i in 0..2) withTransform({ rotate(-18f + i * 18f, c + Offset(0f, s * .3f)) }) {
val at = Offset(c.x - s * .15f, c.y - s * .32f)
drawRoundRect(Color(0xFFF4F6FB), at, Size(s * .3f, s * .44f), androidx.compose.ui.geometry.CornerRadius(s * .04f))
drawRoundRect(Navy, at, Size(s * .3f, s * .44f), androidx.compose.ui.geometry.CornerRadius(s * .04f), style = Stroke(s * .02f))
if (i == 2) drawCircle(Navy, s * .06f, at + Offset(s * .15f, s * .2f))
}
}
}
/** The arcade: the chosen game on the left, and a honeycomb of what's here, with room for more. */
/** A game on the right: its picture, name and line, focused as on What's On (navy inside a gold ring). */
@Composable
private fun GameCard(game: ArcadeGame, modifier: Modifier, focus: FocusRequester?, up: FocusRequester?, onFocus: () -> Unit, onPlay: () -> Unit) {
var focused by remember { mutableStateOf(false) }
val corner = RoundedCornerShape(8.dp)
Row(modifier.then(if (focus != null) Modifier.focusRequester(focus) else Modifier).focusProperties { if (up != null) this.up = up }
.onFocusChanged { focused = it.isFocused; if (it.isFocused) onFocus() }
.background(if (focused) Navy else Panel, corner).border(if (focused) 3.dp else 1.dp, if (focused) Yellow else Blue, corner)
.clickable(onClick = onPlay).padding(horizontal = 10.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Canvas(Modifier.size(42.dp)) { gameIcon(game) }
Box(Modifier.width(1.dp).fillMaxHeight(.7f).background(Muted.copy(alpha = .4f)))
Column(verticalArrangement = Arrangement.spacedBy(1.dp)) {
Text(game.title.uppercase().takeIf { game == ArcadeGame.OrbMan } ?: game.title, color = Color.White, fontFamily = Marquee, fontSize = 18.sp, maxLines = 1)
Text(game.tagline, color = Muted, fontSize = 11.sp, maxLines = 2, lineHeight = 13.sp)
}
}
}
/** The arcade (Paul, 5 November 2026, from his reference): the chosen game large on the left, every game as a card on the right, and what you played last. */
@Composable
internal fun OrbitArcade(modifier: Modifier, onPlaying: (Boolean) -> Unit) {
val context = LocalContext.current
@@ -329,10 +378,18 @@ internal fun OrbitArcade(modifier: Modifier, onPlaying: (Boolean) -> Unit) {
var brickBest by remember { mutableIntStateOf(settings.brickBreakBest) }
var pairsBest by remember { mutableIntStateOf(settings.pairsBest) }
var patienceWins by remember { mutableIntStateOf(settings.patienceWins) }
var orbBest by remember { mutableIntStateOf(settings.orbManBest) }
var recent by remember { mutableStateOf(settings.recentGames.mapNotNull { name -> ArcadeGame.entries.firstOrNull { it.name == name } }) }
var playing by remember { mutableStateOf<ArcadeGame?>(null) }
var chosen by remember { mutableStateOf(ArcadeGame.Snake) }
var chosen by remember { mutableStateOf(ArcadeGame.OrbMan) }
fun play(game: ArcadeGame) {
recent = (listOf(game) + recent.filter { it != game }).take(6)
settings.recentGames = recent.map { it.name }
playing = game
}
DisposableEffect(playing) { onPlaying(playing != null); onDispose { onPlaying(false) } }
when (playing) {
ArcadeGame.OrbMan -> { OrbManScreen(orbBest, onBest = { orbBest = it; settings.orbManBest = it }, onLeave = { playing = null }); return }
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 }
@@ -341,6 +398,15 @@ internal fun OrbitArcade(modifier: Modifier, onPlaying: (Boolean) -> Unit) {
ArcadeGame.Blocks -> { BlockDropScreen(blockBest, onBest = { blockBest = it; settings.blockDropBest = it }, onLeave = { playing = null }); return }
null -> {}
}
fun bestLine(game: ArcadeGame): String? = when (game) {
ArcadeGame.OrbMan -> orbBest.takeIf { it > 0 }?.let { "%,d".format(it) }
ArcadeGame.Snake -> best.takeIf { it > 0 }?.let { "%,d".format(it) }
ArcadeGame.Blocks -> blockBest.takeIf { it > 0 }?.let { "%,d".format(it) }
ArcadeGame.Bricks -> brickBest.takeIf { it > 0 }?.let { "%,d".format(it) }
ArcadeGame.Pairs -> pairsBest.takeIf { it > 0 }?.let { "$it moves" }
ArcadeGame.Patience -> patienceWins.takeIf { it > 0 }?.let { "$it won" }
ArcadeGame.Hive -> null
}
val first = remember { FocusRequester() }
LaunchedEffect(Unit) { first.requestFocus() }
val nav = LocalNavFocus.current
@@ -350,51 +416,90 @@ internal fun OrbitArcade(modifier: Modifier, onPlaying: (Boolean) -> Unit) {
BlockGame(board, BlockPiece(2, 1, 4, 9), 0, emptyList())
}
val brickPreview = remember { BrickGame.start().copy(bricks = bricksFor(2), ballX = 180f, ballY = 150f, paddleX = 150f) }
Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(Modifier.fillMaxWidth().weight(1f), horizontalArrangement = Arrangement.spacedBy(20.dp)) {
Column(Modifier.weight(1f).fillMaxHeight().background(Panel, RoundedCornerShape(6.dp)).padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Image(painterResource(R.drawable.orbit_arcade_logo), "Orbit Arcade", Modifier.width(256.dp).height(90.dp))
Box(Modifier.fillMaxWidth().weight(1f).background(Color(0xFF050E2A))) {
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))
}
val orbPreview = remember {
val g = OrbGame.start()
val chasers = g.chasers.mapIndexed { i, c -> val p = listOf(OrbPos(5, 1), OrbPos(17, 3), OrbPos(14, 11), OrbPos(3, 8))[i]; c.copy(mover = OrbMover(p, p, OrbDir.Left), out = true) }
g.copy(player = OrbMover(OrbPos(9, 8), OrbPos(9, 8), OrbDir.Right), chasers = chasers, stars = g.stars.filter { it.y != 3 || it.x > 9 }.toSet(), readyLeft = 0)
}
var clock by remember { mutableLongStateOf(0L) }
LaunchedEffect(chosen) { if (chosen == ArcadeGame.OrbMan) while (true) { withFrameNanos { clock = it / 1_000_000 } } }
Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Row(Modifier.fillMaxWidth().weight(1f), horizontalArrangement = Arrangement.spacedBy(16.dp)) {
// The chosen game, large.
Column(Modifier.weight(1.05f).fillMaxHeight().background(Brush.verticalGradient(listOf(Blue, Panel)), RoundedCornerShape(10.dp)).padding(14.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) {
Column {
if (chosen == ArcadePick) Text("ARCADE PICK", Modifier.background(Yellow, RoundedCornerShape(4.dp)).padding(horizontal = 8.dp, vertical = 2.dp), color = Navy, fontSize = 11.sp, fontWeight = FontWeight.Bold)
Text(chosen.title.uppercase(), color = Color.White, fontFamily = Marquee, fontSize = 36.sp, maxLines = 1)
}
else Canvas(Modifier.fillMaxSize()) {
val colours = listOf(Color(0xFFE53935), Color(0xFF1E66D0), Color(0xFF2E9D3A), Color(0xFFF4F4F4), Color(0xFFF5A623), Color(0xFF8E44AD))
val r = size.height / 9
for (row in 0..3) for (col in 0 until (size.width / (r * 1.8f)).toInt()) {
val at = Offset(r + col * r * 1.8f + (row % 2) * r * .9f, r + row * r * 1.56f)
drawPath(hexagon(at, r * .92f), colours[(col * 3 + row * 5 + col * row) % colours.size])
Box(Modifier.width(1.dp).height(40.dp).background(Muted.copy(alpha = .5f)))
Text(chosen.tagline, color = Color.White, fontSize = 14.sp, maxLines = 2)
}
Box(Modifier.fillMaxWidth().weight(1f).background(Color(0xFF050E2A), RoundedCornerShape(8.dp)).border(2.dp, Color(0xFF3D63FF), RoundedCornerShape(8.dp)).padding(6.dp)) {
when (chosen) {
ArcadeGame.OrbMan -> Canvas(Modifier.fillMaxSize()) { drawOrbField(orbPreview, timeMs = clock) }
ArcadeGame.Snake -> SnakeField(preview, Modifier.fillMaxSize())
ArcadeGame.Blocks -> BlockField(blockPreview, Modifier.fillMaxSize().padding(4.dp))
ArcadeGame.Bricks -> BrickField(brickPreview, Modifier.fillMaxSize().padding(4.dp))
ArcadeGame.Pairs, 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))
}
}
ArcadeGame.Hive -> Canvas(Modifier.fillMaxSize()) {
val colours = listOf(Color(0xFFE53935), Color(0xFF1E66D0), Color(0xFF2E9D3A), Color(0xFFF4F4F4), Color(0xFFF5A623), Color(0xFF8E44AD))
val r = size.height / 9
for (row in 0..3) for (col in 0 until (size.width / (r * 1.8f)).toInt()) {
val at = Offset(r + col * r * 1.8f + (row % 2) * r * .9f, r + row * r * 1.56f)
drawPath(hexagon(at, r * .92f), colours[(col * 3 + row * 5 + col * row) % colours.size])
}
}
}
}
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 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)
Text(chosen.blurb, color = Color.White, fontSize = 14.sp, maxLines = 2)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) {
bestLine(chosen)?.let { Text(if (chosen == ArcadeGame.Patience) "Games: " else "Your best: ", color = Color.White, fontSize = 15.sp); Text(it, color = Yellow, fontFamily = Marquee, fontSize = 20.sp) }
?: Text("New to you", color = Muted, fontSize = 15.sp)
Box(Modifier.width(1.dp).height(24.dp).background(Muted.copy(alpha = .5f)))
Text("OK Play", Modifier.background(Yellow, RoundedCornerShape(5.dp)).padding(horizontal = 12.dp, vertical = 3.dp), color = Navy, fontSize = 15.sp, fontWeight = FontWeight.Bold)
}
}
Column(Modifier.width(290.dp).fillMaxHeight(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
GameTile(ArcadeGame.Snake, first, nav, { chosen = ArcadeGame.Snake }, { playing = ArcadeGame.Snake })
GameTile(ArcadeGame.Hive, null, nav, { chosen = ArcadeGame.Hive }, { playing = ArcadeGame.Hive })
// Every game, two to a row, then what you played last.
Column(Modifier.weight(1f).fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(7.dp)) {
ArcadeGame.entries.chunked(2).forEachIndexed { row, pair ->
Row(Modifier.fillMaxWidth().height(56.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
pair.forEachIndexed { i, game ->
GameCard(game, Modifier.weight(1f).fillMaxHeight(), if (row == 0 && i == 0) first else null, if (row == 0) nav else null, { chosen = game }, { play(game) })
}
if (pair.size == 1) Spacer(Modifier.weight(1f))
}
}
Spacer(Modifier.height(4.dp))
// A honeycomb: each row of two sits in the gaps of the one above.
Row(Modifier.offset(y = (-18).dp), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
GameTile(ArcadeGame.Blocks, null, null, { chosen = ArcadeGame.Blocks }, { playing = ArcadeGame.Blocks })
GameTile(ArcadeGame.Bricks, null, null, { chosen = ArcadeGame.Bricks }, { playing = ArcadeGame.Bricks })
}
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 })
if (recent.isNotEmpty()) {
Spacer(Modifier.height(2.dp))
Box(Modifier.fillMaxWidth().height(1.dp).background(Muted.copy(alpha = .3f)))
Text("RECENTLY PLAYED", color = Color.White, fontFamily = Marquee, fontSize = 16.sp)
Row(Modifier.fillMaxWidth().height(46.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
recent.take(3).forEach { game ->
var focused by remember { mutableStateOf(false) }
val corner = RoundedCornerShape(8.dp)
Row(Modifier.weight(1f).fillMaxHeight().onFocusChanged { focused = it.isFocused; if (it.isFocused) chosen = game }
.background(if (focused) Navy else Panel, corner).border(if (focused) 3.dp else 0.dp, if (focused) Yellow else Color.Transparent, corner)
.clickable { play(game) }.padding(horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Canvas(Modifier.size(30.dp)) { gameIcon(game) }
Column {
Text(game.title, color = Color.White, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, maxLines = 1)
Text(bestLine(game)?.let { "Your best: $it" } ?: "Played recently", color = Muted, fontSize = 10.sp, maxLines = 1)
}
}
}
repeat(3 - minOf(3, recent.size)) { Spacer(Modifier.weight(1f)) }
}
}
}
}
@@ -57,6 +57,16 @@ internal class OrbitSettings(context: Context) {
var patienceWins: Int
get() = saved.getInt("patience_wins", 0)
set(value) { saved.edit().putInt("patience_wins", value).apply() }
var orbManBest: Int
get() = saved.getInt("orb_man_best", 0)
set(value) { saved.edit().putInt("orb_man_best", value).apply() }
var orbManSound: Boolean
get() = saved.getBoolean("orb_man_sound", true)
set(value) { saved.edit().putBoolean("orb_man_sound", value).apply() }
/** The arcade's games, most recently played first, by name. */
var recentGames: List<String>
get() = saved.getString("recent_games", "").orEmpty().split(',').filter { it.isNotBlank() }
set(value) { saved.edit().putString("recent_games", value.joinToString(",")).apply() }
var subtitlesOn: Boolean
get() = saved.getBoolean("subtitles_on", false)
set(value) { saved.edit().putBoolean("subtitles_on", value).apply() }
@@ -0,0 +1,86 @@
package tv.orbit.classic
import org.junit.Assert.*
import org.junit.Test
class OrbManTest {
private val maze = OrbMaze()
private val random = java.util.Random(5)
private fun play(g: OrbGame, ticks: Int): OrbGame { var x = g; repeat(ticks) { x = x.step(random) }; return x }
@Test fun theMazeIsWholeAndEveryStarCanBeReached() {
assertTrue(maze.rows.all { it.length == maze.width })
val open = { p: OrbPos -> !maze.wall(p) && !maze.door(p) && !maze.den(p) }
val seen = mutableSetOf(maze.start); val queue = ArrayDeque(listOf(maze.start))
while (queue.isNotEmpty()) { val p = queue.removeFirst(); for (d in OrbDir.entries) { val n = maze.step(p, d); if (n.y in 0 until maze.height && open(n) && seen.add(n)) queue += n } }
assertTrue((maze.cells('.') + maze.cells('o')).all { it in seen })
assertEquals(4, maze.cells('o').size)
// The rocks' way out of the den leads into the maze.
assertTrue(maze.outside in seen)
// A tunnel through the side.
assertEquals(OrbPos(maze.width - 1, 6), maze.step(OrbPos(0, 6), OrbDir.Left))
}
@Test fun orbManEatsStarsAndTurnsWhenThereIsRoom() {
var g = OrbGame.start(maze)
g = play(g, OrbGame.READY_TICKS + g.playerEvery)
assertEquals(OrbPos(maze.start.x - 1, maze.start.y), g.player.pos)
assertEquals(10, g.score); assertTrue(OrbEvent.Star in g.events || g.score == 10)
// Up is a wall at (8, 11): he carries on left, and goes up as soon as there's room, at (7, 11).
val at = OrbPos(8, 11)
g = g.copy(player = OrbMover(at, at, OrbDir.Left, g.tick), wanted = OrbDir.Up)
g = play(g, g.playerEvery)
assertEquals(OrbPos(7, 11), g.player.pos); assertEquals(OrbDir.Left, g.player.dir)
g = play(g, g.playerEvery)
assertEquals(OrbPos(7, 10), g.player.pos); assertEquals(OrbDir.Up, g.player.dir)
}
@Test fun aPowerHexagonFrightensTheRocksAndCatchingOneScores() {
val g0 = OrbGame.start(maze).copy(readyLeft = 0)
val power = maze.cells('o').first { it.y == maze.start.y }
// Stand next to a power hexagon, facing it.
val beside = OrbPos(power.x + 1, power.y)
var g = g0.copy(player = OrbMover(beside, beside, OrbDir.Left, 0), wanted = OrbDir.Left)
g = play(g, g.playerEvery)
assertEquals(power, g.player.pos)
assertTrue(g.powered); assertTrue(OrbEvent.Power in g.events)
assertTrue(g.chasers.filter { it.out }.all { it.scared })
// A frightened rock on his square is caught, worth 200, then 400.
val rock = g.chasers[0].copy(mover = OrbMover(g.player.pos, g.player.pos, OrbDir.Right, g.tick), scared = true, out = true)
val next = g.copy(chasers = listOf(rock) + g.chasers.drop(1)).step(random)
assertTrue(next.chasers[0].returning); assertEquals(1, next.chain)
assertTrue(next.score >= g.score + 200)
}
@Test fun beingCaughtCostsALifeAndTheLastOneEndsIt() {
val g = OrbGame.start(maze).copy(readyLeft = 0)
val rock = g.chasers[0].copy(mover = OrbMover(g.player.pos, g.player.pos, OrbDir.Left, 0))
val after = g.copy(chasers = listOf(rock) + g.chasers.drop(1)).step(random)
assertEquals(2, after.lives); assertEquals(maze.start, after.player.pos); assertTrue(after.readyLeft > 0)
val last = g.copy(lives = 1, chasers = listOf(rock) + g.chasers.drop(1)).step(random)
assertFalse(last.alive)
}
@Test fun clearingTheMazeIsTheNextLevelAndQuicker() {
val g = OrbGame.start(maze).copy(readyLeft = 0)
val star = OrbPos(maze.start.x - 1, maze.start.y)
val next = play(g.copy(stars = setOf(star), powers = emptySet()), g.playerEvery)
assertEquals(2, next.level); assertTrue(OrbEvent.Cleared in next.events)
assertEquals(maze.cells('.').size, next.stars.size)
assertTrue(OrbGame.start(maze, level = 6).chaserEvery < g.chaserEvery)
assertTrue(OrbGame.start(maze, level = 6).powerTicks < g.powerTicks)
}
@Test fun theRocksLeaveTheDenAndHuntWithoutGettingStuck() {
// A long game with Orb-Man somewhere they can't reach him: every rock gets out and keeps moving.
var g = OrbGame.start(maze)
g = g.copy(player = OrbMover(OrbPos(0, 0), OrbPos(0, 0), OrbDir.Left))
var most = 0
repeat(3_000) {
g = g.step(random)
assertTrue(g.chasers.all { !maze.wall(it.mover.pos) })
most = maxOf(most, g.chasers.count { it.out })
}
assertTrue(g.alive); assertEquals(4, most)
}
}