From 2630215430fdbbac15288504e834804f10934956 Mon Sep 17 00:00:00 2001 From: Pauljd Date: Mon, 2 Nov 2026 14:22:35 +0000 Subject: [PATCH] Add a top nav and Orbit Arcade with Snake (0.76.0) Co-Authored-By: Claude Opus 5.5 (1M context) --- VERIFICATION-0.76.0.md | 7 + android/app/build.gradle.kts | 4 +- .../src/main/java/tv/orbit/classic/DriveIn.kt | 8 +- .../main/java/tv/orbit/classic/EmbyClient.kt | 2 +- .../tv/orbit/classic/EmbyConnectionScreen.kt | 3 +- .../main/java/tv/orbit/classic/HomeScreen.kt | 2 +- .../java/tv/orbit/classic/MainActivity.kt | 71 +++--- .../main/java/tv/orbit/classic/OrbitArcade.kt | 214 ++++++++++++++++++ .../tv/orbit/classic/SecureMediaDataSource.kt | 2 +- .../main/java/tv/orbit/classic/Settings.kt | 3 + .../res/drawable-nodpi/orbit_arcade_logo.webp | Bin 0 -> 10652 bytes .../java/tv/orbit/classic/SnakeGameTest.kt | 65 ++++++ docs/roadmap.md | 1 + 13 files changed, 348 insertions(+), 34 deletions(-) create mode 100644 VERIFICATION-0.76.0.md create mode 100644 android/app/src/main/java/tv/orbit/classic/OrbitArcade.kt create mode 100644 android/app/src/main/res/drawable-nodpi/orbit_arcade_logo.webp create mode 100644 android/app/src/test/java/tv/orbit/classic/SnakeGameTest.kt diff --git a/VERIFICATION-0.76.0.md b/VERIFICATION-0.76.0.md new file mode 100644 index 0000000..46249ac --- /dev/null +++ b/VERIFICATION-0.76.0.md @@ -0,0 +1,7 @@ +# Orbit TV 0.76.0: top nav and Orbit Arcade + +- A bar along the top of every screen, between the Orbit logo and the gear: All Channels, Drive-In and Arcade. It sits over the Drive-In's sky, and hides during a game. The logo goes to the main menu. +- Orbit Arcade has its own screen with Snake, and room for more. OK plays. +- Snake: steer with the arrows, eat to grow, and hit a wall or yourself to lose. It quickens as you score. Back pauses, and a reminder pauses it too. Your best score stays on the box. + +Checked: build, lint and 288 unit tests pass, including the game's rules. Not checked until installed: moving around the bar by remote, the bar over the Drive-In's scenes, and how the game plays and looks. Screenshots are blocked, so looks need Paul's eye. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index d45346d..7d9559b 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -9,8 +9,8 @@ android { applicationId = "tv.orbit.classic" minSdk = 26 targetSdk = 36 - versionCode = 79 - versionName = "0.75.0-bigger-film-lists" + versionCode = 80 + versionName = "0.76.0-top-nav-arcade" } buildFeatures { compose = true } compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 } diff --git a/android/app/src/main/java/tv/orbit/classic/DriveIn.kt b/android/app/src/main/java/tv/orbit/classic/DriveIn.kt index a4a4c7e..b47ecd2 100644 --- a/android/app/src/main/java/tv/orbit/classic/DriveIn.kt +++ b/android/app/src/main/java/tv/orbit/classic/DriveIn.kt @@ -19,6 +19,7 @@ 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.focusProperties import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color @@ -388,12 +389,15 @@ internal fun DriveInScreen(session: EmbySampleSession, plan: ChannelPlan?, visit val focus = remember { FocusRequester() } LaunchedEffect(active, ticket) { if (active && ticket == null) focus.requestFocus() } val scene = Math.floorMod(visit, DRIVE_IN_SCENES) - Box(modifier.fillMaxSize().background(Navy).focusRequester(focus).focusable().onPreviewKeyEvent { event -> + val nav = LocalNavFocus.current + Box(modifier.fillMaxSize().background(Navy).focusRequester(focus).focusProperties { if (nav != null) up = nav }.focusable().onPreviewKeyEvent { event -> val native = event.nativeKeyEvent // With the ticket open, the keys are for its buttons. if (native.action != KeyEvent.ACTION_DOWN || lineup.isEmpty() || ticket != null) return@onPreviewKeyEvent false when (native.keyCode) { - KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_DPAD_UP -> { chosenKind = lineup[Math.floorMod(index - 1, lineup.size)].kind; true } + // The list runs across the bottom, or down the side on the CRT. Up off the top goes to the bar. + KeyEvent.KEYCODE_DPAD_UP -> if (scene == 2 && index > 0) { chosenKind = lineup[index - 1].kind; true } else false + KeyEvent.KEYCODE_DPAD_LEFT -> { chosenKind = lineup[Math.floorMod(index - 1, lineup.size)].kind; true } KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_DPAD_DOWN -> { chosenKind = lineup[Math.floorMod(index + 1, lineup.size)].kind; true } KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER -> { if (native.repeatCount == 0) selected?.let { if (it.startMs <= now) onWatch() else ticket = it } diff --git a/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt b/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt index bba1944..94d7388 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt @@ -66,7 +66,7 @@ internal class HttpsEmbyTransport(internal val deviceId: String = UUID.randomUUI connection.connectTimeout = 10_000 connection.readTimeout = request.readTimeoutMs ?: if (request.path == "/Sessions/Logout") 5_000 else 15_000 connection.setRequestProperty("Accept", "application/json") - connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.75.0\"") + connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.76.0\"") request.token?.let { connection.setRequestProperty("X-Emby-Token", it) } if (request.method == "POST") { val bytes = request.body.orEmpty().toByteArray(Charsets.UTF_8) diff --git a/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt b/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt index 5a11943..bb7bd2a 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt @@ -54,7 +54,7 @@ private const val RESTORE_QUIET_TRIES = 4 @Composable internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolean, - mainGuide: Boolean, mainHome: Boolean = false, mainDriveIn: Boolean = false, onChannelAvailable: (Boolean) -> Unit, onRestoreChecked: () -> Unit = {}, onGuide: () -> Unit, + mainGuide: Boolean, mainHome: Boolean = false, mainDriveIn: Boolean = false, allChannelsPresses: Int = 0, onChannelAvailable: (Boolean) -> Unit, onRestoreChecked: () -> Unit = {}, onGuide: () -> Unit, onHome: () -> Unit = {}, onDriveIn: () -> Unit = {}) { val appContext = androidx.compose.ui.platform.LocalContext.current.applicationContext val savedStore = remember { RememberedConnectionStore.get(appContext) } @@ -279,6 +279,7 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea LaunchedEffect(mainGuide, mainHome) { if (!mainGuide && !mainHome) { watchingChannel = false; channelClockOffset = 0 } } + LaunchedEffect(allChannelsPresses) { guideCategory = ChannelCategory.All } LaunchedEffect(mainDriveIn) { if (mainDriveIn) { driveInVisits++; if (pendingDriveInWatch) { pendingDriveInWatch = false; watchingDriveIn = true } } else watchingDriveIn = false } diff --git a/android/app/src/main/java/tv/orbit/classic/HomeScreen.kt b/android/app/src/main/java/tv/orbit/classic/HomeScreen.kt index 84df1b1..63bd35d 100644 --- a/android/app/src/main/java/tv/orbit/classic/HomeScreen.kt +++ b/android/app/src/main/java/tv/orbit/classic/HomeScreen.kt @@ -128,7 +128,7 @@ internal fun HomeScreen(session: EmbySampleSession, channels: List { null } +/** The first place on the top bar, so the top of any screen can step straight up to it. */ +internal val LocalNavFocus = staticCompositionLocalOf { null } internal val Barlow = FontFamily(Font(R.font.barlow_regular), Font(R.font.barlow_semibold, FontWeight.SemiBold), Font(R.font.barlow_semibold, FontWeight.Bold)) internal val Navy = Color(0xFF071632) internal val Panel = Color(0xFF12356B) @@ -65,6 +66,17 @@ internal fun OrbitButton(label: String, modifier: Modifier = Modifier, selected: } } +/** One place along the top, always a press away. */ +@Composable +private fun NavItem(label: String, selected: Boolean, focus: FocusRequester?, onClick: () -> Unit) { + var focused by remember { mutableStateOf(false) } + val corner = androidx.compose.foundation.shape.RoundedCornerShape(5.dp) + Box((if (focus != null) Modifier.focusRequester(focus) else Modifier).height(32.dp).onFocusChanged { focused = it.isFocused } + .background(if (focused) Yellow else if (selected) Blue else Navy.copy(alpha = .72f), corner).clickable(onClick = onClick).padding(horizontal = 14.dp), contentAlignment = Alignment.Center) { + Text(label, color = if (focused) Navy else Color.White, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, maxLines = 1) + } +} + @Composable private fun OrbitApp() { var now by remember { mutableLongStateOf(System.currentTimeMillis()) } @@ -72,6 +84,8 @@ private fun OrbitApp() { var realChannelAvailable by remember { mutableStateOf(false) } var section by remember { mutableStateOf("Home") } var restoreChecked by remember { mutableStateOf(false) } + var allChannelsPresses by remember { mutableIntStateOf(0) } + var playingGame by remember { mutableStateOf(false) } // With nothing to watch yet, the only place to be is signing in or choosing channels. LaunchedEffect(realChannelAvailable, restoreChecked) { if (!realChannelAvailable && restoreChecked) section = "Connect Emby" } val lifecycle = LocalLifecycleOwner.current.lifecycle @@ -85,33 +99,38 @@ private fun OrbitApp() { } // Back on the main menu closes the app. BackHandler(realChannelAvailable && section != "TV Guide" && section != "Home") { section = "Home" } - val gearFocus = remember { FocusRequester() } + val navFocus = remember { FocusRequester() } Box(Modifier.fillMaxSize().background(Navy)) { - CompositionLocalProvider(LocalGearFocus provides gearFocus) { - // The Drive-In takes the whole screen. - Column(if (section == "Drive-In") Modifier.fillMaxSize() else Modifier.fillMaxSize().padding(horizontal = 36.dp, vertical = 16.dp)) { - if (section != "Drive-In") { - Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Image(painterResource(R.drawable.orbit_logo), "Orbit", Modifier.width(144.dp).height(36.dp)) - Spacer(Modifier.weight(1f)) - Text(SimpleDateFormat("EEE d MMM HH:mm", Locale.UK).format(Date(now)), color = Color.White, fontSize = 17.sp) - if (realChannelAvailable) { - Spacer(Modifier.width(12.dp)) - HomeCard(Modifier.size(38.dp, 32.dp), gearFocus, onClick = { section = "Connect Emby" }) { focused -> - Image(painterResource(R.drawable.orbit_icon_gear), "Settings", Modifier.fillMaxSize().padding(5.dp), colorFilter = if (focused) ColorFilter.tint(Navy) else null) - } - } + CompositionLocalProvider(LocalNavFocus provides navFocus) { + // The Drive-In and a game in play take the whole screen; the bar sits over the Drive-In's sky. + val fullBleed = section == "Drive-In" || playingGame + Column(if (fullBleed) Modifier.fillMaxSize() else Modifier.fillMaxSize().padding(start = 36.dp, end = 36.dp, top = 56.dp, bottom = 16.dp)) { + if (!restoreChecked) Text("Tuning in…", color = Muted, fontSize = 15.sp) + if (section == "Arcade" && realChannelAvailable) OrbitArcade(Modifier.weight(1f), onPlaying = { playingGame = it }) + EmbyConnectionScreen(if (section == "Arcade") Modifier else Modifier.weight(1f), visible = section == "Connect Emby", + mainGuide = section == "TV Guide" && realChannelAvailable, + mainHome = section == "Home" && realChannelAvailable, + mainDriveIn = section == "Drive-In" && realChannelAvailable, + allChannelsPresses = allChannelsPresses, + onChannelAvailable = { realChannelAvailable = it }, onRestoreChecked = { restoreChecked = true }, + onGuide = { section = "TV Guide" }, onHome = { section = "Home" }, onDriveIn = { section = "Drive-In" }) + } + if (!playingGame) Row(Modifier.fillMaxWidth().padding(start = 36.dp, end = 36.dp, top = 12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + HomeCard(Modifier.width(144.dp).height(36.dp), onClick = { if (realChannelAvailable) section = "Home" }) { focused -> + Image(painterResource(R.drawable.orbit_logo), "Orbit", Modifier.fillMaxSize(), colorFilter = if (focused) ColorFilter.tint(Navy) else null) } - Spacer(Modifier.height(8.dp)) + if (realChannelAvailable) { + Spacer(Modifier.width(8.dp)) + NavItem("All Channels", section == "TV Guide", navFocus) { allChannelsPresses++; section = "TV Guide" } + NavItem("Drive-In", section == "Drive-In", null) { section = "Drive-In" } + NavItem("Arcade", section == "Arcade", null) { section = "Arcade" } + } + Spacer(Modifier.weight(1f)) + Text(SimpleDateFormat("EEE d MMM HH:mm", Locale.UK).format(Date(now)), Modifier.background(if (section == "Drive-In") Navy.copy(alpha = .72f) else Color.Transparent).padding(horizontal = 6.dp, vertical = 3.dp), color = Color.White, fontSize = 17.sp) + if (realChannelAvailable) HomeCard(Modifier.size(38.dp, 32.dp), onClick = { section = "Connect Emby" }) { focused -> + Image(painterResource(R.drawable.orbit_icon_gear), "Settings", Modifier.fillMaxSize().padding(5.dp), colorFilter = if (focused) ColorFilter.tint(Navy) else null) + } } - if (!restoreChecked) Text("Tuning in…", color = Muted, fontSize = 15.sp) - EmbyConnectionScreen(Modifier.weight(1f), visible = section == "Connect Emby", - mainGuide = section == "TV Guide" && realChannelAvailable, - mainHome = section == "Home" && realChannelAvailable, - mainDriveIn = section == "Drive-In" && realChannelAvailable, - onChannelAvailable = { realChannelAvailable = it }, onRestoreChecked = { restoreChecked = true }, - onGuide = { section = "TV Guide" }, onHome = { section = "Home" }, onDriveIn = { section = "Drive-In" }) - } } } } diff --git a/android/app/src/main/java/tv/orbit/classic/OrbitArcade.kt b/android/app/src/main/java/tv/orbit/classic/OrbitArcade.kt new file mode 100644 index 0000000..441ccb3 --- /dev/null +++ b/android/app/src/main/java/tv/orbit/classic/OrbitArcade.kt @@ -0,0 +1,214 @@ +package tv.orbit.classic + +import android.view.KeyEvent +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +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.focusProperties +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.tv.material3.Text +import kotlinx.coroutines.delay +import kotlin.math.cos +import kotlin.math.sin + +internal enum class SnakeDirection(val dx: Int, val dy: Int) { + Up(0, -1), Down(0, 1), Left(-1, 0), Right(1, 0); + val opposite get() = when (this) { Up -> Down; Down -> Up; Left -> Right; Right -> Left } +} + +internal data class SnakeCell(val x: Int, val y: Int) + +/** The whole game, with no screen in it. Hit a wall or yourself and it's over. */ +internal data class SnakeGame(val width: Int, val height: Int, val body: List, val heading: SnakeDirection, val wanted: SnakeDirection, val food: SnakeCell, val score: Int = 0, val alive: Boolean = true) { + /** A turn back on yourself is ignored, as it could only ever end the game. */ + fun turn(direction: SnakeDirection) = if (direction == heading.opposite) this else copy(wanted = direction) + + fun step(random: java.util.Random): SnakeGame { + if (!alive) return this + val head = body.first() + val next = SnakeCell(head.x + wanted.dx, head.y + wanted.dy) + val eating = next == food + // The tail moves away this step, so it's safe to follow it unless it's about to grow. + val solid = if (eating) body else body.dropLast(1) + if (next.x !in 0 until width || next.y !in 0 until height || next in solid) return copy(heading = wanted, alive = false) + val grown = listOf(next) + solid + return copy(body = grown, heading = wanted, food = if (eating) nextFood(width, height, grown, random) ?: food else food, score = if (eating) score + 10 else score, + alive = grown.size < width * height) + } + + /** Starts at a walking pace and quickens with every bite, up to a limit. */ + val tickMs get() = (150L - score / 10 * 3).coerceAtLeast(70L) + + companion object { + fun start(width: Int, height: Int, random: java.util.Random): SnakeGame { + val y = height / 2 + val body = List(4) { SnakeCell(width / 4 - it, y) } + return SnakeGame(width, height, body, SnakeDirection.Right, SnakeDirection.Right, nextFood(width, height, body, random)!!) + } + fun nextFood(width: Int, height: Int, body: List, random: java.util.Random): SnakeCell? { + val free = (0 until height).flatMap { y -> (0 until width).map { x -> SnakeCell(x, y) } } - body.toSet() + return if (free.isEmpty()) null else free[random.nextInt(free.size)] + } + } +} + +private val Marquee = FontFamily(Font(R.font.anton_regular)) +private val Honey = Color(0xFFFFD84A) +private val Sky = Color(0xFF3FC1FF) +private val Berry = Color(0xFFFF5A4F) + +private fun hexagon(center: Offset, radius: Float) = Path().apply { + for (i in 0..5) { + val angle = Math.toRadians(60.0 * i) + val x = center.x + radius * sin(angle).toFloat() + val y = center.y - radius * cos(angle).toFloat() + if (i == 0) moveTo(x, y) else lineTo(x, y) + } + close() +} + +@Composable +private fun SnakeField(game: SnakeGame, modifier: Modifier) { + Canvas(modifier) { + val cell = minOf(size.width / game.width, size.height / game.height) + val left = (size.width - cell * game.width) / 2 + val top = (size.height - cell * game.height) / 2 + for (y in 0 until game.height) for (x in 0 until game.width) if ((x + y) % 2 == 0) + drawRect(Color.White.copy(alpha = .025f), Offset(left + x * cell, top + y * cell), Size(cell, cell)) + fun center(c: SnakeCell) = Offset(left + (c.x + .5f) * cell, top + (c.y + .5f) * cell) + drawPath(hexagon(center(game.food), cell * .46f), Berry) + game.body.zipWithNext { a, b -> drawLine(Sky, center(a), center(b), strokeWidth = cell * .68f, cap = StrokeCap.Round) } + drawCircle(Color.White, cell * .42f, center(game.body.first())) + val look = Offset(game.heading.dx * cell * .16f, game.heading.dy * cell * .16f) + drawCircle(Navy, cell * .13f, center(game.body.first()) + look) + } +} + +/** Runs until it's left. A reminder or anything else taking the remote pauses it, so nobody loses a game to a pop-up. */ +@Composable +private fun SnakeScreen(best: Int, onBest: (Int) -> Unit, onLeave: () -> Unit) { + val random = remember { java.util.Random() } + var game by remember { mutableStateOf(SnakeGame.start(30, 22, random)) } + var paused by remember { mutableStateOf(false) } + var hadFocus by remember { mutableStateOf(false) } + val focus = remember { FocusRequester() } + LaunchedEffect(Unit) { focus.requestFocus() } + LaunchedEffect(paused, game.alive) { + while (!paused && game.alive) { + delay(game.tickMs) + game = game.step(random) + } + if (!game.alive && game.score > best) onBest(game.score) + } + val menuFocus = remember { FocusRequester() } + LaunchedEffect(paused, game.alive) { if (paused || !game.alive) menuFocus.requestFocus() else focus.requestFocus() } + BackHandler { if (game.alive) paused = !paused else onLeave() } + Row(Modifier.fillMaxSize().background(Navy).padding(20.dp).focusRequester(focus).onFocusChanged { + if (hadFocus && !it.hasFocus && game.alive) paused = true + hadFocus = it.hasFocus + }.focusable().onPreviewKeyEvent { event -> + val native = event.nativeKeyEvent + if (native.action != KeyEvent.ACTION_DOWN || paused || !game.alive) return@onPreviewKeyEvent false + val direction = when (native.keyCode) { + KeyEvent.KEYCODE_DPAD_UP -> SnakeDirection.Up + KeyEvent.KEYCODE_DPAD_DOWN -> SnakeDirection.Down + KeyEvent.KEYCODE_DPAD_LEFT -> SnakeDirection.Left + KeyEvent.KEYCODE_DPAD_RIGHT -> SnakeDirection.Right + else -> null + } + if (direction != null) game = game.turn(direction) + direction != null + }, horizontalArrangement = Arrangement.spacedBy(20.dp)) { + Column(Modifier.width(190.dp).fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text("SNAKE", color = Honey, fontFamily = Marquee, fontSize = 40.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 = 36.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 = 36.sp) + Spacer(Modifier.weight(1f)) + Text("◄►▲▼ Steer", color = Muted, fontSize = 13.sp) + Text("Back Pause", color = Muted, fontSize = 13.sp) + } + Box(Modifier.weight(1f).fillMaxHeight().background(Color(0xFF050E2A)).border(2.dp, Blue), contentAlignment = Alignment.Center) { + SnakeField(game, Modifier.fillMaxSize().padding(6.dp)) + 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}", 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) paused = false else { game = SnakeGame.start(30, 22, random); paused = false } + }) + OrbitButton("Leave the game", Modifier.width(200.dp), onClick = onLeave) + } + } + } +} + +/** The arcade: the game on the left, and a honeycomb of what's here, with room for more. */ +@Composable +internal fun OrbitArcade(modifier: Modifier, onPlaying: (Boolean) -> Unit) { + val context = LocalContext.current + val settings = remember { OrbitSettings(context) } + var best by remember { mutableIntStateOf(settings.snakeBest) } + var playing by remember { mutableStateOf(false) } + DisposableEffect(playing) { onPlaying(playing); onDispose { onPlaying(false) } } + if (playing) { + SnakeScreen(best, onBest = { best = it; settings.snakeBest = it }, onLeave = { playing = false }) + return + } + val first = remember { FocusRequester() } + LaunchedEffect(Unit) { first.requestFocus() } + val nav = LocalNavFocus.current + val preview = remember { SnakeGame(16, 9, listOf(SnakeCell(9, 4), SnakeCell(8, 4), SnakeCell(7, 4), SnakeCell(6, 4), SnakeCell(6, 5), SnakeCell(5, 5), SnakeCell(4, 5)), SnakeDirection.Right, SnakeDirection.Right, SnakeCell(12, 4)) } + 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))) { SnakeField(preview, Modifier.fillMaxSize()) } + Text("SNAKE", color = Honey, fontFamily = Marquee, fontSize = 30.sp) + Text(if (best > 0) "Your best: $best" else "Eat, grow, and don't hit the walls or yourself.", color = Color.White, fontSize = 15.sp) + } + Column(Modifier.width(270.dp).fillMaxHeight(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) { + var focused by remember { mutableStateOf(false) } + Box(Modifier.size(170.dp).focusRequester(first).focusProperties { if (nav != null) up = nav }.onFocusChanged { focused = it.isFocused }.clickable { playing = true }, contentAlignment = Alignment.Center) { + Canvas(Modifier.fillMaxSize()) { drawPath(hexagon(center, size.minDimension / 2), if (focused) Yellow else Panel) } + Text("Snake", color = if (focused) Navy else Color.White, fontSize = 20.sp, fontWeight = FontWeight.SemiBold) + } + Spacer(Modifier.height(4.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + repeat(2) { + Box(Modifier.size(110.dp), contentAlignment = Alignment.Center) { + Canvas(Modifier.fillMaxSize()) { drawPath(hexagon(center, size.minDimension / 2), Panel.copy(alpha = .4f)) } + Text("More soon", color = Muted, fontSize = 12.sp) + } + } + } + } + } + Text("OK Play Back Main menu", color = Muted, fontSize = 11.sp) + } +} diff --git a/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt b/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt index bfa766c..e39b56c 100644 --- a/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt +++ b/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt @@ -33,7 +33,7 @@ internal class SecureMediaDataSource(private val session: EmbySampleSession) : B next.instanceFollowRedirects = false next.connectTimeout = 10_000 next.readTimeout = 20_000 - next.setRequestProperty("User-Agent", "OrbitTV/0.75.0") + next.setRequestProperty("User-Agent", "OrbitTV/0.76.0") next.setRequestProperty("Accept-Encoding", "identity") session.mediaHeaders(address).forEach { (key, value) -> next.setRequestProperty(key, value) } if (dataSpec.position != 0L || dataSpec.length != C.LENGTH_UNSET.toLong()) { 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 0a4f750..f980631 100644 --- a/android/app/src/main/java/tv/orbit/classic/Settings.kt +++ b/android/app/src/main/java/tv/orbit/classic/Settings.kt @@ -28,6 +28,9 @@ import androidx.tv.material3.Text /** Choices about how Orbit behaves on this box. Nothing here is private, so it needs no encrypted storage. */ internal class OrbitSettings(context: Context) { private val saved = context.getSharedPreferences("orbit_settings", Context.MODE_PRIVATE) + var snakeBest: Int + get() = saved.getInt("snake_best", 0) + set(value) { saved.edit().putInt("snake_best", value).apply() } var subtitlesOn: Boolean get() = saved.getBoolean("subtitles_on", false) set(value) { saved.edit().putBoolean("subtitles_on", value).apply() } diff --git a/android/app/src/main/res/drawable-nodpi/orbit_arcade_logo.webp b/android/app/src/main/res/drawable-nodpi/orbit_arcade_logo.webp new file mode 100644 index 0000000000000000000000000000000000000000..6de09c49b00c7eca71418cc4340d01c72b4e526d GIT binary patch literal 10652 zcmV;NDPz`BNk&GLDF6UhMM6+kP&gonDF6UalK`CoDgXks06sAoibJ9yp%Ur693TS( zrtTL#-~pNWf&P>7XTrL=_y6y|v!B)fWPV#}uhjp!{)PVY`+xG=ras2=EAvm#|I7b` z{k#7g|Ns9N+`sVu(7!4EPyb;5IsT8_5A{#{*Y}Uyuk+vk|Ns46`ltQV^#lB``oH{d z^WVIGEI;4>?uy_wDm}{>RwA{1^MLV4veZ;D74= zfBa#7+_3y=y1r*Vllwp9 zH#t7F^GEpy*l(==e*Lum*ZYPcf5!gw|EKp2`ZxG5`QPEcdB4wnhyOJGk^Zau$L}BbpZ{O|KIlD# ze(3ut{>lIACvI|DhT{%Z54X_3K@SP2vf@75+gLe!WIee|6ejiWt8^Fv_3_F~$GCE$ z5fb$chtGa$u;C?RwN|w$67@IA#z!5d5fN}C@Egrm&Cu&Ll|bF3x(6E@i?2xLDz&{; zc8E=(0ktzRX}(saB3`D?YaKmmK$at3wPAysG#<67YTNY}X=v;=(Z98(ksx(o#P!j_ z1{h43MTX-JRR5GBs@9}>ZlpMz8^52NNovzQZsfI@opCqhTV30=H3-9iqmUf&ot#** z-f5XY`jc1{0=>{eX*7XQUZhG|etp0gyiEHQP0Si!-Ac{2opI27e0CKj-uR$Vg<7>2 z9@k!o)}_=vaC!%ZB?hXf`8|7`x+{G|-va`m*Tx^&l2U<*?dtls!AQ8Q)KDxUizMkB zx9}6yMj-o8FD}9}(Ow_*J#h3b=&#F0JK9m8QuQ~=yjRX*<%-QL$uXf#3P@mTjp4;8 zh3RAAIr2dp8Gl$&*)AYz?C=N*`#(nH=#BOQa*T2&DVd?OW!(3vy05-;RE9Zgh<9<~ zeP#}x^}ab&LjGAQNfyhnu9uGl$gah%{&fef2VqP9<6VR4$j0R4KD|mj_RaNuJNBf3 zuFa04laNv)DdV^vm_l3Cd&vn8l7r6dvE5{!PY9M^8h}0;g(vcWXL(`EW`YD@fjuSn z^Goc^@@w_c%Ay?V^Ix#fXg+votRzB;g1fzg33UqV`Ll5*sp%aLY%sZ{iho1wC zuZ$c_r|<4=DK=bHqgwZGdw(L~b&>=)YzaaP3rC0Gt`h3BXPhrA4sxoXho~N*&Fwx1aac>&SoD2E9X*zW{e!OyF(ocBsl> z;b!hI4jn9}R}w5?ukGInJ^g3C2?P9=t#5HOM|5~bg9++|erz-WU5o$#sF#9~u4b>V zqc``VNjBJ5z6c{t3E)>O22v*W=H2t%o(9rU&^UFf7jhlVRaViX z;&78|qRVlvz${w@=p}?`ba>yCBP!Zz-?H=miOdfpKlrb?tPPv!4^ca-|5=((3D0#U;9h`q7sK7e<1imjQ0?UuL5d2mt0c&lcA|+Y$W6f zK9G)1=CT_5J<=BL(FB9Lo@O0SUM&f>D{OM-r>mVg!nWmnjBQbzzWl)`L@trL z$}vx*?nHWj9vlj+Up7;Sbj5zJGVYzE!nW$ud?Bvi&MY7v#Oxk?SJ18O9>Oui*sdYj zOf6bt&Im5=f4R5?0c9w#M5W_R4ba$V)KaEO7YmzKO=!MpE;8Eb@?~LdQW%PMMyS7p z-#I&;qIM!1fsv#XFy}_JRlE3M{JRCahzcnJUH(J9H|gRx2m-j0T=e(e{Jng-N|I1# zzu)m1WP!qEhAT6_I>E0M22G?9+gc=-@i}%HinIujqQGS~{>2>AxqWBIZQoWT0?I;I zioO8iZv*GJk1n|AzoYOu0}n=l^q$xzcb0+P?6)4GzHVtGfDJ=E)-E4D8@&8S;xOU! z`>14A*`GTO`ovoeU)a(aIb~`35UcNhooK7Z_T*akEc>UmUlPXbrSE9fT4z(DR?Y|L zF!6hSidK-F?@x~Wk<|jJJ8y6i$4tlRjjSaXZ=6@N(T!Dy8O^7G&DQo|84VrjD+=&F zbVB6IE9s6!ZPP_6VEJ1pr5?ajC87(P7dI+UQ6XuhlC((x6lOrCVKWsRWUcR!u_G${#56L9x6<(4 zDos!qM&Gr8$Ed-J)E2sZ*$i}dsCO{zT>{6N3NA5Yrc4`K+H)ed%O9=m^yUo<3NF1O zbG=LS{4#io^Qr@)CLvSgHZ0x=%O3JbK8Cy~5Qga(1gYU44@VcpIBg3!1HFz`b2(TnFI#-}i*7mYXpiDJDR85rqdyN?91(LM?S_O22v0L7Pt>afeSc^B8H43!l z@>(8MJZ$&dBNBuHvMX84^;8NsJZ5dO8KMJIvA~{1M6g>??XzSJ;0T`IYG*Z#bAc9$ zhBp-=FIG=Ul@oF0LZuK0d*vdAh%z|l7O+OZY5%hVmrsUX1pgDx>hKzZ3Q$ZnRj4<$ zlc4ecK-pXnDJK^SSm&lr7tTY#x!SS}(yRT|9ys7_gb&_&N0IYtT!-WV;-s+Tc?lcv1L`C@~a${y7P7tl8fJ{dY zns-wU-Z(lcRB##bJ)GoJr^lRRVziU@!LIid_V35`>D@GRTwZ5JC~=KfU}%9I*62Nj z$2&$+>ewXax!44?G_v(NaWX$qOGvg|YZEe%%l!9gSmVJDrn=&pny4}5ERkZ@ZALhW zldFWNE3?FbjOWT~EtPb-FYj^MoKNJ;0{>B(4OE z0iNkb%f;bVPIJ2@O_dSm171ME_4>yuD^YlrKuF0$RY3n`4s9otM_F6m2ij7YKbliW$7wtr!S_Z*=W?h(ui z!CVQ%Nujp|v6R95=n#}4T(B%{s%uZStZ0;;TsPyEQDNUBVtm^BE(OX5nfI@*m~-Q; zLAk@QU>Ad(pxruY$fm0Ez7TDBri0r^JgtrGySCAdx=}G8=lbK`dHW5=#x18g_B>^i@EV@;jH? z0!YPn9%tk{f8bGo6Y1}giT##wt;oCAzOxgiwwmYb#@ynK%vn+M^ z=n1bG`^^m1ijDGKk0jZ=6(*|a1wG+uPc%@X*1eu^!I?yPD9S%ArO2a1&o4^hmymMj zi6P*_`AublDa#b?=R}nn$Q0!hUi+%^-I8_~{@W0lTVEdgGEbBu!iN>Q{Ttq{s7cx6 zX5TI~F|UKoL#4xHwg@uKZ1WJmqTPwRaY7 zskq|J*e=8MRvQ^fn_A-nZN5qP}j*t5fubOAL_3ZHOng^-+*{mr02ej*K@CJzpX4L1w zDA0u?5)Rm5>Y1zLYnG6ju=BYK-raC3+_&Q2*Dj^d(BF^kl+a4z@xeI2svu@|)Pw!Z z?!N8r(s(dO=%Fp>GQZ9FpDKoU1}s2rP?Lt0uWN0yI^#Cr5Rc7kG*7T0&ZS~*A{Mmd za(W~A-*mOw`2;y-Y`*y5$s@q0iSLX@Gx=qtY&$(P&GX6^l|JI$>icAR%zLLI2Pwm7 zc6n;LbwG3LWI?v({_Ey4B;^`lrdU7rqpU&gT?AmMcYHfiG)@w7-E5t~jsB&S>K&oI z839*RlwsxXbHuV0#V+)NI=@wIP_F32JEX!Q>RbvWK7z&=ukYJ%v?a&02Jj{Fn!%iI zqJa;i4^d!)y<)X_FeTi^U-#l|dn3&zxq9rh|t0>a$0)fu@pB9nX)MlT=9N$Px)eHc#4(*3; z4Y`-8)0GM~vwR8P?X6II+fxxinyld=@JP>@Ke$QR2;!^F>(4-kAoT?O1i5Xs{1&|e z!vk8XT05EgN0Mnt6`vzuU@FWB)fvwrbO=S93h6ZlwLpBA;M4ad**k!YjgdwtW|_`! zsk_5Yr{}SNj2z~+58sMXc2dVl=ZNipb%2g)24Ydz;&%|~Qzr^!*}IxNty?vWL|p*> z@9j4snZb#EWg&{G|7iAeQn%&#vy;Oj*;Oez655^6`5F1(^*to@hOEEDb^^3C<6Zij z84Q#%i#svnL%l8Ek-5GW8*RQ~7&+p9P(Oql=tAiTFc}$|EIWIRw!no;C8k4Ty>7B3 ziWR49A#9zU_re$cN*u|u`qTWJR~P!_1wVPO+&ioa-gNPMrnsoNGI0jj)B1FT5TLx^ zj1iH&PUe-XzpD8n5{hYeDpasVdvnvP5QSluy7<$U_c~lEm-lK#m_3FA$^+o{=9uY1 zwxd9J%iClmIsrs8+d6qiR@ptt?M^HN#KGg4OLQz2c`Q?&tt;P#JZ928Z9+l2FaZv& z$D+;fn07&$Ejas`Zb^RrYYyGue*$SLkbP>eK`FQcO|zi!AS;`#P@m?}pD2sYFgS3( z*7r^OzwpSEVxn^Hk-aG*@KC@1J-Z&eO$QWckprOH`swTS-IDrudJu8u)CM~)lI_t( z3rJ6?n|MY){xyyWN!)snbb|G0Y`+vK{Ug?s!k~JGWApNlpqpZc{k{!^?@X$tm1n{n z`0wKg(%KT({x=kq@y*rvr779!hRjTVo90(DJyc64Zo#UyEarp<)64$rjf6!;MBWpUeM|bfrfk^>ZwUxM;{w1(BsE>HP6oFm zuvxHA=FD(a#1Au8X}jevONIr>)uW8ip14Q)9<(Hql{Y&rFKT126L$eo7qwn)pffqJTbpa!J0X`BZ5VP&4`lE5MhP6>mw~if4YD5HWDj#Um+Q)Z1jx zta!OFj9I)D;*4&%Uw35g<;^=h38Q`L8h8g!2xzyYn-1I0nLxwY$1m7z&d(u_d*9Q$ zO!nsGPjelNnO;*yjXxh@>08enuy^n&)YwgKG;;yO4}KEK!S87P8Alv+1;SO|geHN}nh5rFahP;+ zIdU2UsZoW;?zTfh$!KgUdC(8ut67!^TlVA0r=GrJpFq> z;{X^j+Dcm0B43Y5gX3lR0D~oir(%pz?UDya=6wunO&m!0h7yGwZQG|#-2_ede53Zfo^`{BghKxt5!KOi_^i#YR zXy1~EPAtBJX0;M}N=_^41j|3XtqBxk|u((%g5{U%^2W`adt9UP-hFXcjanmPU44U%BRLX`CQ6g$_ z5?pmgPugm`n`xC0r-%IVHjos$J$cJ`EZzbDJCk7VE%$NBF+qM`vtMtyx2~^rls%YI=iu``34l!Gf7!&Hn+eVd|BBr%swy;!C2|vDKWRwi%iRIR3aCe26VWl;F%SRH z#dR$PaE8QTB8L(@9aOUxg!siy={}Y5L)0lC!PJt7tTMKEvIUIoe-OwK$i+FW3#~s= z%l_Sz0r$ea_)3-=XQP}VEx%b*Ih*VlbXur@mD&6MhN(wQGh<-s^Kq*fyW)YW9OqF=PPeX}?g?;^gk)kv2K>~xjz z`HpYwV}xt@e7U*far;LRlc|(oYMZlTZCty$TthC zi>H*kV?2rYQZPpV7=>DWpc*x&dn>&_uicGX!$8XARhph%8!_+{UfgmBV2DHIt&frI zEwx{VDokq6rrHT%e=$fpy2@ccU()W#1S2kH>$ zbv?P3X%a|9inU9Ku_NjfnsY(=m4{t(XrkiC`DM<^ww?jz#r7sAv378u=%@+}Yx^PJ z{skQ}>Q0f@`yK*CiYy^&~nWeana)FBT zp8mbgLmn&?b$OD`ND+isS_#`GqFlmqAoK437+k=I%aEtK5X1h2zfQKT%$B5r2ID8N z9i_{3K%o36#Dfx~B+nh^`*2vR&8tTEag|NaE%$nNYzrCy1rgopiH zHDv6?Zfs+J2)bIwPeROt(1Plc7sEAv32(~7V)CfNbxfKv$4Kk3NW=~`5g3QcHjxp` zxQ0|={;h0pzCtBhh)g4BSNjrnQ&@tt`|-d)anXpV!Q(u!YDl?8x`RP$EPLCkYRl;_ zP6!L!MTwf;4KoNkbs*lNl~eu9lt0G7@W-7@-sW2k<4*P$wNmJ2F1nj-Ql`3%e4==3 zr`jh)*&!i($))W_u>&rpjAnjt&l4H$`T+=3TntIkKYdM?S$mf`?OiG)_>j9~V^Rpu z;wF;cr0RYczMA0~Npy*kD%8y4@!c9qOUXA~2wmGZ;6VTrO=F^O{pf`qAv}>WQxonE zkKZAd?`s7z>I71s;obcWSgvu=80##5Vg^~|J6-kZzuqzGE_7Y2uz-f0TtJi?dO@U> ze%?Ec*LP)vt3tuG1I$yOv#x1&1l^%UYusNNun82$FuUbx{u`$d$*vOZSe_@iyP`Gh z21ky0GVpd#>LznboCmaqi>)1C4Z@Ycly*Wh?vi|z5Eb>3vOt+mpUK$0AC7Oz?@JjR zOAxL$9|1A!*L%n876EL{cpXLXL58~aeWs$$gTtX{**m1Hf;$5i_vsviyCh=y=})LJ z8lhNBOq4{NU!e?`zw`Lc=9>4jI$^WrK0A0U4!wTO7%=n>$gxF0jUmIxmj3sdR^D1* z3k7R5?W$0lY3VJo*9IHELVkdCnFyLR;9X8X=99IYa2K#l$V2=l=ign~YNt_`3AemM zieP-d1)HCHKt6Tw7D@Nx>S8L6nN48PY0dMrvwF? zmxDgtyf>7Kg}{WubzwrB1|4-0hD)( z*M(JTrp16lWy)M2Is7R{ldoW1h8SP?fJWo2mhc70aors4!F-dO>f_UkHP7nlB^TBO zecVh8z?#2b6_}T&nQ^QjB2!=IQ14fng|CTY5x#4Iy#C?w>&QIvR?}^VQEc#v0-TIN z`u*)G*qZk@{$dwg%GO8r=(d&iu zn(&Ip!~-5wq_!dKI}(t*YBDQ{Ml~9i%|zDXDtVGI=M-J;p?dp(LgtS%E}i&iUTim<#2-rZkfoelue%J)ZmP zkv4c}`@qEdr{kHN5!Lhecp<7SJK|$TCL9m*>M4DACz&=qn^L1&R;-#7BzghLy!)dg z;$`U!l48W#q|V3x`^w3kcaUOo)Y-n{GuI^{ithgZxh@?yLjA|f)##HM!^&su@Cgdo zJ9U-d{hD{-tHnmF#IYLHHUMjJrvB1WiC?T=?tDWnG!BsxQ@9hZeoX_1*_#wLgF0-O z=HSxkC0|s(36@$`Fy}AD`WgA_>iIn2mDL#`mQ;Bk|=mr?)bEjz!4~^Aek_ii;ARz*LL~g<}0$gq)>L z^RHzA>B2kyY94{7Y&x5^J_L5FY~X1W&6Ty&sk_-5T?WJ$Tkw(`8R z3!`jAcAU8N{E4xd-=#Kxl77XA<_io|rE2!jAK9>}xXyRwEKAwW=7qKavr&qaLpi;m z1-(vDBswjl!1{rA7`wP$OQb@3y8sxJrTbLY6L>nd9I1nQBJ@S@uXJn zw)nSrxz?Db$LuJEUq6A?Q=G=`fd}rFG-|4Uwo8g^jPHCsE_S@2I1pm1`@}uk-&)8q z5YYp9Bm?{utdJO%%<5LM*5yKW%WlZY!Vrh6M#M<;E1+83=)apr#!ntJXe0b^Qt3<)sZBNzk4xx-s zYmgNNxjOe^g^a#ok*vF6p)Q7|IDK@%(7fUC6CiG*H1D==M_kL3!k^b!0DQ%OL>~sF zt0A$8Ro|=eJaqHJIlX=VQYGpSslfi7k21=OZ4QxTsWada{oTQ1ly1KFS(Q;ZQC=wj zU;8OdnTT#4~_jE4fu#P zJ$h5{8l5l3{dZe0@9ul$WlR!)z-YSn^3A#adB+Y}Z_k$axvHA$6&jZ<9~Kg)hMltB-hyg$bcd>_uy(8B-n8`F$h-;>wJe#WO zWBMO(F55iF`cWM3Hp}$(xJYI+d%Go(+YOwi%Q5Vq7PiV#S&CnSuMN{Zk4delzu4d3 zGxEUq=W0-WUrbghzF^HZSvFubrU4-~Kt2U$`_IGurg9!r|JSAhD7Y;yR)oTRKa66r ze9wvEr8P36orB4Uc(oIL+Gh~2QK00;ON~G9&eGj2S7Ec82RfA_ALWc#j_83!gq#1`!=7_pcc?K zT>-JN^I#{}155@Vvp1A>=iYT9R4iM6AuYN~m6&Y2Ms74tSPv(a!Z*#NF|^3W($zp( zc^a`07Dy3IDlh{xk4EKThqPWSoh@?R=wOMjXz?b`SOfSin~z~EA2@J~eR4c@xJH>@ zhc0vSAlW`V%GK=c<;SyMV?l#L7b-J@yQ3f|%yzCf{ttm^XyH$6a^%U>&OTJ@W=Oaf zB;xNpCk#(bQdtmFRh;9JVws;g_5<`nC#1_uZ%UG(5xTNQHVgT7u&w9%*OVDVf- zH!oS>lBQay;ND)em7XqnVoUgkZUH|1jQGl{&vSv!3xXut=Y#sK1K_U>!ATMWml0r! zv(s_uE1Jg8DHgttDqV{drvvLRWf9yqoFf3syc_cK`nYaBx-sx_TsghZx<6)AVa64< z3-u^~N`VBKK~OfORY&9~X8rQfZRI&tKt8*{pK111d7l&1pg?jh;BOaR^Y3khQOr@M zsl9&1nBh5&qE%)+JiNzC+FH#;iEreiyY=k{_>~p5EWyTaBl$W{U*zzFn^ziEiZLVOiN^!L_~e2wM%uU^Dr3oM;&4eaUm=JKTR;E+000000000= CjSM~j literal 0 HcmV?d00001 diff --git a/android/app/src/test/java/tv/orbit/classic/SnakeGameTest.kt b/android/app/src/test/java/tv/orbit/classic/SnakeGameTest.kt new file mode 100644 index 0000000..a96bc47 --- /dev/null +++ b/android/app/src/test/java/tv/orbit/classic/SnakeGameTest.kt @@ -0,0 +1,65 @@ +package tv.orbit.classic + +import org.junit.Assert.* +import org.junit.Test +import java.util.Random + +class SnakeGameTest { + private val random = Random(1) + private fun game(vararg cells: Pair, food: Pair = 9 to 9, heading: SnakeDirection = SnakeDirection.Right) = + SnakeGame(10, 10, cells.map { SnakeCell(it.first, it.second) }, heading, heading, SnakeCell(food.first, food.second)) + + @Test fun itMovesForwardKeepingItsLength() { + val moved = game(3 to 5, 2 to 5, 1 to 5).step(random) + assertEquals(listOf(SnakeCell(4, 5), SnakeCell(3, 5), SnakeCell(2, 5)), moved.body) + assertTrue(moved.alive) + } + + @Test fun eatingGrowsScoresAndPlacesNewFoodClearOfTheBody() { + val fed = game(3 to 5, 2 to 5, food = 4 to 5).step(random) + assertEquals(3, fed.body.size) + assertEquals(10, fed.score) + assertNotEquals(SnakeCell(4, 5), fed.food) + assertFalse(fed.food in fed.body) + } + + @Test fun aWallEndsTheGame() { + assertFalse(game(9 to 5, 8 to 5).step(random).alive) + } + + @Test fun hittingItselfEndsTheGame() { + val coiled = game(3 to 3, 3 to 4, 2 to 4, 2 to 3, 2 to 2, heading = SnakeDirection.Up).turn(SnakeDirection.Left) + assertFalse(coiled.step(random).alive) + } + + @Test fun followingItsOwnTailIsSafe() { + val loop = SnakeGame(10, 10, listOf(SnakeCell(3, 3), SnakeCell(3, 4), SnakeCell(4, 4), SnakeCell(4, 3)), SnakeDirection.Up, SnakeDirection.Right, SnakeCell(9, 9)) + assertTrue(loop.step(random).alive) + } + + @Test fun aTurnBackOnItselfIsIgnored() { + val game = game(3 to 5, 2 to 5) + assertEquals(SnakeDirection.Right, game.turn(SnakeDirection.Left).wanted) + assertEquals(SnakeDirection.Up, game.turn(SnakeDirection.Up).wanted) + } + + @Test fun twoQuickTurnsCantReverseIt() { + // Up then left before it moves: left is judged against where it's really heading. + val game = game(3 to 5, 2 to 5).turn(SnakeDirection.Up).turn(SnakeDirection.Left) + assertEquals(SnakeDirection.Right, game.heading) + assertEquals(SnakeDirection.Up, game.wanted) + } + + @Test fun itQuickensUpToALimit() { + assertEquals(150L, game(3 to 5, 2 to 5).tickMs) + assertTrue(game(3 to 5, 2 to 5).copy(score = 100).tickMs < 150L) + assertEquals(70L, game(3 to 5, 2 to 5).copy(score = 10_000).tickMs) + } + + @Test fun aNewGameStartsWithFoodOffTheSnake() { + val start = SnakeGame.start(30, 22, Random(2)) + assertEquals(4, start.body.size) + assertFalse(start.food in start.body) + assertTrue(start.alive) + } +} diff --git a/docs/roadmap.md b/docs/roadmap.md index cb3e121..daf156e 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -100,6 +100,7 @@ Paul's ideas, 1 November 2026. Not fixes and not scheduled. - **Orbit Drive-In name (Paul, 1 November 2026):** decided, replacing OrbitOffice. Event types inside it are Orbit Marquee, Orbit Double Bill, Orbit Triple Bill and The Late Show. The briefs and design/interface still show the old name. - **Orbit Drive-In (Paul, 2 November 2026):** built in 0.67 with three scenes that take turns: his theatre and drive-in pictures, and the CRT. Marquee nights, Friday double bills, Saturday triple bills and late shows from his films, booked a week ahead. Tickets and in-app reminders came in 0.71, 5 minutes before and at the start. +- **Top nav (Paul, 2 November 2026):** All Channels, Drive-In and Arcade along the top of every screen, in 0.76, each with its own screen, keeping the main menu clear. Snake is the Arcade's first game. - **Orbit Arcade (Paul, 2 November 2026):** the look in design/arcade is approved: logo, honeycomb menu and Bedlam screen. Games are OrbitHive Bedlam, Snake, Block Drop, Brick Break, Pairs and Patience. Small games only, so the app stays small: drawn in code with no game engine, and measured for size when built. Not scheduled. ## Channel numbers