Smooth Snake, fix Play again and Carry on, and blend the Arcade logo (0.76.1)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
# Orbit TV 0.76.1: smoother Snake
|
||||
|
||||
From Paul's play. Play again and Carry on no longer drop back to the pause menu: buttons disappearing looked like losing the remote, so now only the whole window losing focus pauses a game. The snake glides between cells, drawn every frame, rather than jumping. The Arcade logo has no background, so it sits into the panel.
|
||||
|
||||
Checked: build, lint and all unit tests pass, including the glide. Not checked until installed: how smooth it is on the box.
|
||||
@@ -9,8 +9,8 @@ android {
|
||||
applicationId = "tv.orbit.classic"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 80
|
||||
versionName = "0.76.0-top-nav-arcade"
|
||||
versionCode = 81
|
||||
versionName = "0.76.1-smoother-snake"
|
||||
}
|
||||
buildFeatures { compose = true }
|
||||
compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
|
||||
|
||||
@@ -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.76.0\"")
|
||||
connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.76.1\"")
|
||||
request.token?.let { connection.setRequestProperty("X-Emby-Token", it) }
|
||||
if (request.method == "POST") {
|
||||
val bytes = request.body.orEmpty().toByteArray(Charsets.UTF_8)
|
||||
|
||||
@@ -24,6 +24,7 @@ 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.platform.LocalWindowInfo
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
@@ -91,46 +92,65 @@ private fun hexagon(center: Offset, radius: Float) = Path().apply {
|
||||
close()
|
||||
}
|
||||
|
||||
/** The snake's line from head to tail, partway between two steps, so it glides from cell to cell rather than jumping. */
|
||||
internal fun snakeLine(game: SnakeGame, before: List<SnakeCell>?, progress: Float): List<Pair<Float, Float>> {
|
||||
val cells = game.body.map { it.x.toFloat() to it.y.toFloat() }.toMutableList()
|
||||
if (before.isNullOrEmpty() || !game.alive) return cells
|
||||
fun between(from: SnakeCell, to: SnakeCell) = (from.x + (to.x - from.x) * progress) to (from.y + (to.y - from.y) * progress)
|
||||
cells[0] = between(before.first(), game.body.first())
|
||||
cells[cells.lastIndex] = between(before.last(), game.body.last())
|
||||
return cells
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SnakeField(game: SnakeGame, modifier: Modifier) {
|
||||
private fun SnakeField(game: SnakeGame, modifier: Modifier, before: List<SnakeCell>? = null, progress: Float = 1f) {
|
||||
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()))
|
||||
fun center(x: Float, y: Float) = Offset(left + (x + .5f) * cell, top + (y + .5f) * cell)
|
||||
drawPath(hexagon(center(game.food.x.toFloat(), game.food.y.toFloat()), cell * .46f), Berry)
|
||||
val line = snakeLine(game, before, progress).map { center(it.first, it.second) }
|
||||
line.zipWithNext { a, b -> drawLine(Sky, a, b, strokeWidth = cell * .68f, cap = StrokeCap.Round) }
|
||||
drawCircle(Color.White, cell * .42f, line.first())
|
||||
val look = Offset(game.heading.dx * cell * .16f, game.heading.dy * cell * .16f)
|
||||
drawCircle(Navy, cell * .13f, center(game.body.first()) + look)
|
||||
drawCircle(Navy, cell * .13f, line.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. */
|
||||
/** Runs until it's left. A reminder taking over 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 before by remember { mutableStateOf<List<SnakeCell>?>(null) }
|
||||
var progress by remember { mutableFloatStateOf(1f) }
|
||||
var paused by remember { mutableStateOf(false) }
|
||||
var hadFocus by remember { mutableStateOf(false) }
|
||||
val focus = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) { focus.requestFocus() }
|
||||
// Only the whole window losing focus counts: buttons coming and going move focus around too.
|
||||
val windowFocused = LocalWindowInfo.current.isWindowFocused
|
||||
LaunchedEffect(windowFocused) { if (!windowFocused && game.alive) paused = true }
|
||||
// Steps on the clock, drawn on every frame between them.
|
||||
LaunchedEffect(paused, game.alive) {
|
||||
var last = withFrameNanos { it }
|
||||
while (!paused && game.alive) {
|
||||
delay(game.tickMs)
|
||||
game = game.step(random)
|
||||
val time = withFrameNanos { it }
|
||||
val tick = game.tickMs * 1_000_000f
|
||||
if (time - last >= tick) {
|
||||
last += tick.toLong()
|
||||
before = game.body
|
||||
game = game.step(random)
|
||||
}
|
||||
progress = ((time - last) / tick).coerceIn(0f, 1f)
|
||||
}
|
||||
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 ->
|
||||
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 direction = when (native.keyCode) {
|
||||
@@ -155,12 +175,13 @@ private fun SnakeScreen(best: Int, onBest: (Int) -> Unit, onLeave: () -> Unit) {
|
||||
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))
|
||||
SnakeField(game, Modifier.fillMaxSize().padding(6.dp), before, progress)
|
||||
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 }
|
||||
if (!game.alive) { game = SnakeGame.start(30, 22, random); before = null; progress = 1f }
|
||||
paused = false
|
||||
})
|
||||
OrbitButton("Leave the game", Modifier.width(200.dp), onClick = onLeave)
|
||||
}
|
||||
|
||||
@@ -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.76.0")
|
||||
next.setRequestProperty("User-Agent", "OrbitTV/0.76.1")
|
||||
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()) {
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 19 KiB |
@@ -62,4 +62,15 @@ class SnakeGameTest {
|
||||
assertFalse(start.food in start.body)
|
||||
assertTrue(start.alive)
|
||||
}
|
||||
|
||||
@Test fun theSnakeGlidesBetweenSteps() {
|
||||
val before = listOf(SnakeCell(3, 5), SnakeCell(2, 5), SnakeCell(1, 5))
|
||||
val moved = game(3 to 5, 2 to 5, 1 to 5).step(random)
|
||||
val halfway = snakeLine(moved, before, .5f)
|
||||
assertEquals(3.5f to 5f, halfway.first())
|
||||
assertEquals(3f to 5f, halfway[1])
|
||||
assertEquals(1.5f to 5f, halfway.last())
|
||||
assertEquals(4f to 5f, snakeLine(moved, before, 1f).first())
|
||||
assertEquals(4f to 5f, snakeLine(moved, null, .5f).first())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="360" viewBox="0 0 1024 360"><defs><linearGradient id="silver" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#FFFFFF"/><stop offset=".44" stop-color="#E8EDF5"/><stop offset=".5" stop-color="#B7CBE8"/><stop offset="1" stop-color="#F5F7FF"/></linearGradient><linearGradient id="honey" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#FFF1A8"/><stop offset=".5" stop-color="#FFD84A"/><stop offset="1" stop-color="#F2A516"/></linearGradient><linearGradient id="space" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#071632"/><stop offset=".6" stop-color="#0E1F52"/><stop offset="1" stop-color="#2A1760"/></linearGradient><radialGradient id="planet" cx=".35" cy=".3" r=".8"><stop offset="0" stop-color="#6C8CFF"/><stop offset=".6" stop-color="#3A3FA8"/><stop offset="1" stop-color="#1A1650"/></radialGradient><radialGradient id="glass" cx=".35" cy=".3" r=".8"><stop offset="0" stop-color="#FFFFFF" stop-opacity=".55"/><stop offset="1" stop-color="#BFE3FF" stop-opacity=".12"/></radialGradient></defs><g transform="translate(30,20) scale(0.58)"><path d="M335 132C141 114 66 384 257 475C143 363 185 207 335 132Z" fill="url(#silver)"/><path d="M129 369C102 399 111 425 161 428C267 432 431 340 445 289C455 250 405 250 369 261" fill="none" stroke="#FFD84A" stroke-width="13"/><circle cx="430" cy="282" r="10" fill="#FFD84A"/></g><text x="366" y="108" font-family="Barlow" font-size="44" fill="#F5F7FF" font-weight="600" letter-spacing="16">ORBIT</text><text x="362" y="262" font-family="Anton" font-size="150" fill="url(#honey)" letter-spacing="4">ARCADE</text><path d="M378.0 280.0L400.5 293.0L400.5 319.0L378.0 332.0L355.5 319.0L355.5 293.0Z" fill="#FF5A4F"/><path d="M378.0 288.3L393.3 297.2L393.3 314.8L378.0 323.7L362.7 314.8L362.7 297.2Z" fill="#FFFFFF" fill-opacity=".2"/><path d="M374.4 294.6L379.8 297.7L379.8 303.9L374.4 307.0L369.0 303.9L369.0 297.7Z" fill="#FFFFFF" fill-opacity=".55"/><path d="M440.0 280.0L462.5 293.0L462.5 319.0L440.0 332.0L417.5 319.0L417.5 293.0Z" fill="#3FC1FF"/><path d="M440.0 288.3L455.3 297.2L455.3 314.8L440.0 323.7L424.7 314.8L424.7 297.2Z" fill="#FFFFFF" fill-opacity=".2"/><path d="M436.4 294.6L441.8 297.7L441.8 303.9L436.4 307.0L431.0 303.9L431.0 297.7Z" fill="#FFFFFF" fill-opacity=".55"/><path d="M502.0 280.0L524.5 293.0L524.5 319.0L502.0 332.0L479.5 319.0L479.5 293.0Z" fill="#5BD46A"/><path d="M502.0 288.3L517.3 297.2L517.3 314.8L502.0 323.7L486.7 314.8L486.7 297.2Z" fill="#FFFFFF" fill-opacity=".2"/><path d="M498.4 294.6L503.8 297.7L503.8 303.9L498.4 307.0L493.0 303.9L493.0 297.7Z" fill="#FFFFFF" fill-opacity=".55"/><path d="M564.0 280.0L586.5 293.0L586.5 319.0L564.0 332.0L541.5 319.0L541.5 293.0Z" fill="#C77DFF"/><path d="M564.0 288.3L579.3 297.2L579.3 314.8L564.0 323.7L548.7 314.8L548.7 297.2Z" fill="#FFFFFF" fill-opacity=".2"/><path d="M560.4 294.6L565.8 297.7L565.8 303.9L560.4 307.0L555.0 303.9L555.0 297.7Z" fill="#FFFFFF" fill-opacity=".55"/><path d="M626.0 280.0L648.5 293.0L648.5 319.0L626.0 332.0L603.5 319.0L603.5 293.0Z" fill="#FF8A3D"/><path d="M626.0 288.3L641.3 297.2L641.3 314.8L626.0 323.7L610.7 314.8L610.7 297.2Z" fill="#FFFFFF" fill-opacity=".2"/><path d="M622.4 294.6L627.8 297.7L627.8 303.9L622.4 307.0L617.0 303.9L617.0 297.7Z" fill="#FFFFFF" fill-opacity=".55"/><g transform="translate(858,306) scale(0.52)"><ellipse cx="-28" cy="-54" rx="26" ry="42" transform="rotate(-24 -28 -54)" fill="#CFE3FF" fill-opacity=".8"/><ellipse cx="14" cy="-58" rx="22" ry="36" transform="rotate(16 14 -58)" fill="#CFE3FF" fill-opacity=".6"/><ellipse cx="-30" cy="4" rx="64" ry="46" fill="url(#honey)"/><path d="M-64 -30C-72 -8 -72 18 -62 34L-44 44C-54 14 -54 -14 -44 -42ZM-22 -46C-32 -14 -32 22 -22 50L-2 46C-12 16 -12 -14 -4 -44Z" fill="#071632"/><circle cx="46" cy="-8" r="34" fill="#FFD84A"/><circle cx="56" cy="-14" r="7" fill="#071632"/><path d="M50 8C58 13 66 12 72 6" fill="none" stroke="#071632" stroke-width="4" stroke-linecap="round"/><circle cx="46" cy="-8" r="46" fill="url(#glass)" stroke="#E8EDF5" stroke-width="5"/><path d="M34 -52L26 -80M58 -52L68 -80" stroke="#E8EDF5" stroke-width="4"/><circle cx="26" cy="-84" r="6" fill="#FF5A4F"/><circle cx="68" cy="-84" r="6" fill="#3FC1FF"/></g></svg>
|
||||
|
After Width: | Height: | Size: 4.2 KiB |
@@ -44,6 +44,12 @@ def write(name, w, h, body):
|
||||
write('arcade_logo', 1024, 360, '<rect width="1024" height="360" fill="#071632"/>' + stars(1024, 360, 45) + mark(30, 20, .58) + text(366, 108, 'ORBIT', 44, '#F5F7FF', 'Barlow', 'font-weight="600" letter-spacing="16"') +
|
||||
text(362, 262, 'ARCADE', 150, 'url(#honey)', 'Anton', 'letter-spacing="4"') + ''.join(piece(378 + i * 62, 306, 26, c) for i, c in enumerate(PIECES[:5])) + bee(858, 306, .52))
|
||||
|
||||
# The app's copy has no background, so it sits on whatever's behind it.
|
||||
write('arcade_logo_clear', 1024, 360, mark(30, 20, .58) + text(366, 108, 'ORBIT', 44, '#F5F7FF', 'Barlow', 'font-weight="600" letter-spacing="16"') +
|
||||
text(362, 262, 'ARCADE', 150, 'url(#honey)', 'Anton', 'letter-spacing="4"') + ''.join(piece(378 + i * 62, 306, 26, c) for i, c in enumerate(PIECES[:5])) + bee(858, 306, .52))
|
||||
from PIL import Image
|
||||
Image.open(HERE / 'arcade_logo_clear.png').convert('RGBA').resize((512, 180), Image.LANCZOS).save(HERE.parents[1] / 'android/app/src/main/res/drawable-nodpi/orbit_arcade_logo.webp', quality=92)
|
||||
|
||||
# Icons for the menu, drawn in a box 100 wide around 0,0.
|
||||
def snake(c):
|
||||
return f'<path d="M-40 26H-6V-8H30V-34" fill="none" stroke="{c}" stroke-width="13" stroke-linejoin="round" stroke-linecap="round"/><circle cx="30" cy="-36" r="9" fill="{c}"/><rect x="-40" y="-30" width="15" height="15" fill="#FF5A4F"/>'
|
||||
|
||||
Reference in New Issue
Block a user