Give Snake its sprites and an original chiptune (0.77.0)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
@@ -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())
|
||||
|
||||
@@ -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')
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user