diff --git a/android/app/src/main/java/tv/orbit/classic/ArcadeOrbMan.kt b/android/app/src/main/java/tv/orbit/classic/ArcadeOrbMan.kt new file mode 100644 index 0000000..ff191a1 --- /dev/null +++ b/android/app/src/main/java/tv/orbit/classic/ArcadeOrbMan.kt @@ -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 = 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 = 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, + val stars: Set, val powers: Set, 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 = 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() + 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 { + 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) + } + } + } +} diff --git a/android/app/src/main/java/tv/orbit/classic/OrbitArcade.kt b/android/app/src/main/java/tv/orbit/classic/OrbitArcade.kt index 36bdcec..9602eaf 100644 --- a/android/app/src/main/java/tv/orbit/classic/OrbitArcade.kt +++ b/android/app/src/main/java/tv/orbit/classic/OrbitArcade.kt @@ -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(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)) } + } } } } diff --git a/android/app/src/main/java/tv/orbit/classic/Settings.kt b/android/app/src/main/java/tv/orbit/classic/Settings.kt index 15b9484..385375f 100644 --- a/android/app/src/main/java/tv/orbit/classic/Settings.kt +++ b/android/app/src/main/java/tv/orbit/classic/Settings.kt @@ -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 + 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() } diff --git a/android/app/src/test/java/tv/orbit/classic/OrbManTest.kt b/android/app/src/test/java/tv/orbit/classic/OrbManTest.kt new file mode 100644 index 0000000..19b467f --- /dev/null +++ b/android/app/src/test/java/tv/orbit/classic/OrbManTest.kt @@ -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) + } +}