Give Snake its sprites and an original chiptune (0.77.0)
pauljd/davidson-ci: Davidson CI / quality (push) Failing after 2m9s
pauljd/davidson-ci: Davidson CI / ai-review (push) Failing after 1m21s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-11-02 14:51:36 +00:00
co-authored by Claude Opus 5.5
parent 458eb042c9
commit 847c9a108d
14 changed files with 124 additions and 24 deletions
+6
View File
@@ -0,0 +1,6 @@
# Orbit TV 0.77.0: Snake sprites and music
- The green snake and red apple from Paul's Snake Sprites pack by Bas de Reuver. Its readme allows personal and commercial use, and asks for a credit, which is under Emby account. A copy is in design/arcade/snake-sprites. Each piece glides between cells, and the head changes on death.
- An original 27-second chiptune loop, written and synthesised by design/arcade/build-music.py, so there's no licence to clear. It plays during a game, stops when paused or over, and can be turned off from the pause menu, which is remembered. 159 KB.
Checked: build, lint and all unit tests pass. I can't hear the tune or see the sprites on the TV, so both are Paul's call.
+2 -2
View File
@@ -9,8 +9,8 @@ android {
applicationId = "tv.orbit.classic"
minSdk = 26
targetSdk = 36
versionCode = 83
versionName = "0.76.3-snake-backdrops"
versionCode = 84
versionName = "0.77.0-snake-sprites-music"
}
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.3\"")
connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.77.0\"")
request.token?.let { connection.setRequestProperty("X-Emby-Token", it) }
if (request.method == "POST") {
val bytes = request.body.orEmpty().toByteArray(Charsets.UTF_8)
@@ -23,18 +23,22 @@ 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.FilterQuality
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.graphics.drawscope.withTransform
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.res.imageResource
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.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Text
@@ -85,8 +89,6 @@ internal data class SnakeGame(val width: Int, val height: Int, val body: List<Sn
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) {
@@ -141,31 +143,34 @@ private fun SnakeBackdrop(scene: Int, modifier: Modifier) {
}
}
/** 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
}
/** Where each piece of the snake is drawn, 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>> =
game.body.mapIndexed { index, cell ->
val from = before?.getOrNull(index)?.takeIf { game.alive } ?: cell
(from.x + (cell.x - from.x) * progress) to (from.y + (cell.y - from.y) * progress)
}
@Composable
private fun SnakeField(game: SnakeGame, modifier: Modifier, before: List<SnakeCell>? = null, progress: Float = 1f) {
val head = ImageBitmap.imageResource(R.drawable.snake_head)
val dead = ImageBitmap.imageResource(R.drawable.snake_dead)
val blob = ImageBitmap.imageResource(R.drawable.snake_blob)
val apple = ImageBitmap.imageResource(R.drawable.snake_apple)
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(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, line.first() + look)
fun sprite(image: ImageBitmap, x: Float, y: Float, scale: Float) {
val side = (cell * scale).toInt()
val offset = (cell - side) / 2
drawImage(image, dstOffset = IntOffset((left + x * cell + offset).toInt(), (top + y * cell + offset).toInt()), dstSize = IntSize(side, side), filterQuality = FilterQuality.Medium)
}
sprite(apple, game.food.x.toFloat(), game.food.y.toFloat(), 1.05f)
val line = snakeLine(game, before, progress)
line.drop(1).asReversed().forEach { (x, y) -> sprite(blob, x, y, 1f) }
line.first().let { (x, y) -> sprite(if (game.alive) head else dead, x, y, 1.25f) }
}
}
@@ -196,6 +201,13 @@ private fun SnakeScreen(best: Int, onBest: (Int) -> Unit, onLeave: () -> Unit) {
}
if (!game.alive && game.score > best) onBest(game.score)
}
val context = LocalContext.current
val settings = remember { OrbitSettings(context) }
var musicOn by remember { mutableStateOf(settings.snakeMusic) }
val music = remember { android.media.MediaPlayer.create(context, R.raw.snake_theme)?.apply { isLooping = true; setVolume(.5f, .5f) } }
DisposableEffect(music) { onDispose { music?.release() } }
val playing = musicOn && !paused && game.alive
LaunchedEffect(playing) { if (playing) music?.start() else if (music?.isPlaying == true) music.pause() }
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() }
@@ -233,6 +245,7 @@ private fun SnakeScreen(best: Int, onBest: (Int) -> Unit, onLeave: () -> Unit) {
if (!game.alive) { game = SnakeGame.start(30, 22, random); before = null; progress = 1f }
paused = false
})
OrbitButton("Music: ${if (musicOn) "On" else "Off"}", Modifier.width(200.dp), onClick = { musicOn = !musicOn; settings.snakeMusic = musicOn })
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.3")
next.setRequestProperty("User-Agent", "OrbitTV/0.77.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()) {
@@ -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 snakeMusic: Boolean
get() = saved.getBoolean("snake_music", true)
set(value) { saved.edit().putBoolean("snake_music", value).apply() }
var snakeBest: Int
get() = saved.getInt("snake_best", 0)
set(value) { saved.edit().putInt("snake_best", value).apply() }
@@ -137,6 +140,7 @@ private fun AccountDetail(server: String, titles: Long?, onSignOut: () -> Unit,
}
} else SettingLine("Sign out", "Disconnect this box from Emby.") { OrbitButton("Sign out", onClick = { confirming = true }) }
Text("Orbit TV $version", color = Muted, fontSize = 12.sp)
Text("Snake graphics by Bas de Reuver, bdrgames.nl", color = Muted, fontSize = 12.sp)
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 780 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.
@@ -69,7 +69,7 @@ class SnakeGameTest {
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(2.5f 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())
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""An original chiptune for Snake, written and synthesised here, so there's no licence to clear. Square-wave lead and arpeggio, triangle bass, noise hats."""
import math, random, struct, subprocess, tempfile, wave
from pathlib import Path
RATE, BPM = 22050, 144
EIGHTH = 60 / BPM / 2
OUT = Path(__file__).parents[2] / 'android/app/src/main/res/raw/snake_theme.ogg'
E2, Fs2, G2, A2, B2, C3, D3 = 40, 42, 43, 45, 47, 48, 50
# Lead as (note, eighths) per bar, 0 is a rest. E minor, climbing in the second half.
E4, Fs4, G4, A4, B4, C5, D5, E5, Fs5, G5 = 64, 66, 67, 69, 71, 72, 74, 76, 78, 79
LEAD = [
[(E4, 2), (G4, 1), (B4, 1), (E5, 2), (D5, 1), (B4, 1)], [(C5, 2), (B4, 1), (A4, 1), (G4, 2), (A4, 2)],
[(B4, 3), (D5, 1), (G5, 2), (Fs5, 1), (E5, 1)], [(D5, 2), (A4, 2), (Fs4, 2), (A4, 2)],
[(E4, 2), (G4, 1), (B4, 1), (E5, 2), (G5, 1), (E5, 1)], [(C5, 2), (E5, 1), (C5, 1), (A4, 2), (C5, 2)],
[(A4, 2), (C5, 1), (E5, 1), (D5, 1), (C5, 1), (A4, 2)], [(B4, 3), (Fs4, 1), (B4, 2), (0, 2)],
[(G5, 1), (E5, 1), (B4, 1), (E5, 1), (G5, 1), (E5, 1), (B4, 1), (E5, 1)], [(E5, 1), (C5, 1), (G4, 1), (C5, 1), (E5, 2), (D5, 2)],
[(D5, 1), (B4, 1), (G4, 1), (B4, 1), (D5, 2), (G5, 2)], [(Fs5, 2), (D5, 1), (A4, 1), (Fs4, 2), (A4, 2)],
[(E5, 3), (G5, 1), (Fs5, 2), (E5, 2)], [(C5, 3), (E5, 1), (D5, 2), (C5, 2)],
[(A4, 1), (C5, 1), (E5, 1), (A4, 1), (C5, 1), (E5, 1), (G5, 2)], [(Fs5, 2), (E5, 1), (D5, 1), (B4, 4)],
]
# Each bar's chord, as the bass note and the notes above it.
CHORDS = [(E2, (0, 7, 12, 15)), (C3, (0, 7, 12, 16)), (G2, (0, 7, 12, 16)), (D3, (0, 7, 12, 16)),
(E2, (0, 7, 12, 15)), (C3, (0, 7, 12, 16)), (A2, (0, 7, 12, 15)), (B2, (0, 7, 12, 16))] * 2
def freq(note): return 440 * 2 ** ((note - 69) / 12)
def square(t, f, duty): return 1.0 if (t * f) % 1 < duty else -1.0
def triangle(t, f): return 4 * abs((t * f) % 1 - .5) - 1
total = int(RATE * EIGHTH * 8 * len(LEAD))
mix = [0.0] * total
def add(start, length, f, wave_fn, level, decay):
a, b = int(start * RATE), int((start + length) * RATE)
for i in range(a, min(b, total)):
t = i / RATE
age = t - start
env = min(1, age * 200) * min(1, (start + length - t) * 120) * (decay + (1 - decay) * math.exp(-age * 4))
mix[i] += wave_fn(t, f) * level * env
beat = 0.0
for bar, notes in enumerate(LEAD):
start = bar * EIGHTH * 8
at = start
for note, length in notes:
if note: add(at, length * EIGHTH * .95, freq(note), lambda t, f: square(t, f, .25), .20, .55)
at += length * EIGHTH
root, shape = CHORDS[bar]
for step in range(16):
# A quick arpeggio up the chord, at sixteenths, an octave and more above the bass.
add(start + step * EIGHTH / 2, EIGHTH / 2 * .8, freq(root + 24 + shape[step % 4]), lambda t, f: square(t, f, .125), .07, .3)
for step in range(8):
add(start + step * EIGHTH, EIGHTH * .9, freq(root + (12 if step % 2 else 0)), triangle, .28, .8)
noise = random.Random(4)
for hit in range(len(LEAD) * 8):
start = hit * EIGHTH
a, b = int(start * RATE), int((start + EIGHTH * (.45 if hit % 4 == 2 else .2)) * RATE)
level = .10 if hit % 4 == 2 else .05
held = 0.0
for i in range(a, min(b, total)):
if i % 2 == 0: held = noise.choice((-1.0, 1.0))
mix[i] += held * level * (1 - (i - a) / (b - a))
peak = max(abs(s) for s in mix)
with tempfile.TemporaryDirectory() as folder:
wav = Path(folder) / 'theme.wav'
with wave.open(str(wav), 'wb') as out:
out.setnchannels(1); out.setsampwidth(2); out.setframerate(RATE)
out.writeframes(b''.join(struct.pack('<h', int(s / peak * 26000)) for s in mix))
OUT.parent.mkdir(exist_ok=True)
subprocess.run(['ffmpeg', '-y', '-loglevel', 'error', '-i', str(wav), '-c:a', 'libvorbis', '-q:a', '3', str(OUT)], check=True)
print(f'{total / RATE:.1f} s, {OUT.stat().st_size // 1024} KB')
+11
View File
@@ -0,0 +1,11 @@
Snake Sprites
-------------
2D sprite graphics tiles for the classic snake game,
includes snake head, snake blobs, apple, wall blocks, bomb, easter egg, oliebol.
Originally used in Impossible Snake (html5/web, 2017)
You may use these graphics in personal and commercial projects.
Credit would be appreciated but is not mandatory.
Graphics by Bas de Reuver (www.bdrgames.nl, bdr1976@gmail.com)