Arcade extras: three backdrops and Brick Break power-ups

Port of release 0.108.60, plus design/arcade/build-power-sounds.py.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-11-06 16:04:24 +00:00
co-authored by Claude Opus 5.5
parent d6701bcaca
commit d03cefe6f8
16 changed files with 389 additions and 26 deletions
@@ -0,0 +1,76 @@
package tv.orbit.classic
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithCache
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.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.rotate
import kotlin.random.Random
/**
* The Arcade's backdrops (Paul, 6 November 2026, from the Arcade extras sheet): one chosen in the Arcade, behind every game
* but Comet Trail (which keeps its own changing skies) and Patience (its card table). Drawn once and kept, not animated, so
* a game never pays for it frame by frame; each game keeps its own dark playing panel on top.
*/
internal enum class ArcadeBackdrop(val title: String) { NightSky("Night Sky"), Nebula("Nebula"), Moonrise("Moonrise") }
internal object ArcadeLook {
var backdrop by mutableStateOf(ArcadeBackdrop.NightSky)
fun load(settings: OrbitSettings) { backdrop = ArcadeBackdrop.entries.firstOrNull { it.name == settings.arcadeBackdrop } ?: ArcadeBackdrop.NightSky }
/** The next one along, for the Arcade's Backdrop button. */
fun next(settings: OrbitSettings) {
backdrop = ArcadeBackdrop.entries[(backdrop.ordinal + 1) % ArcadeBackdrop.entries.size]
settings.arcadeBackdrop = backdrop.name
}
}
private val BackdropNavy = Color(0xFF071632)
/** The chosen backdrop behind a game, in place of its plain navy. */
internal fun Modifier.arcadeBackdrop(which: ArcadeBackdrop = ArcadeLook.backdrop): Modifier = drawWithCache {
val w = size.width; val h = size.height
val rnd = Random(which.ordinal + 11)
val stars = List(if (which == ArcadeBackdrop.Moonrise) 40 else 90) { Triple(rnd.nextFloat() * w, rnd.nextFloat() * h * (if (which == ArcadeBackdrop.Moonrise) .6f else 1f), .6f + rnd.nextFloat() * 1.4f) }
val skyline = Path().apply {
moveTo(0f, h); var x = 0f; lineTo(0f, h * .8f)
while (x < w) { val top = h * (.62f + rnd.nextFloat() * .2f); val step = w * (.03f + rnd.nextFloat() * .04f); lineTo(x, top); lineTo(x + step, top); x += step }
lineTo(w, h); close()
}
val windows = List(36) { Offset(rnd.nextFloat() * w, h * (.84f + rnd.nextFloat() * .14f)) }
onDrawBehind {
when (which) {
ArcadeBackdrop.NightSky -> {
drawRect(BackdropNavy)
stars.forEach { (x, y, r) -> drawCircle(Color.White.copy(alpha = .25f + r / 4), r, Offset(x, y)) }
// A faint gold orbit, as on the Welcome moment.
rotate(-12f, Offset(w / 2, h / 2)) { drawOval(Color(0xFFFFDE59).copy(alpha = .14f), Offset(w * .12f, h * .3f), Size(w * .76f, h * .4f), style = Stroke(2.5f)) }
}
ArcadeBackdrop.Nebula -> {
drawRect(Color(0xFF0A1230))
drawCircle(Brush.radialGradient(listOf(OrbCharacter.Drift.colour.copy(alpha = .32f), Color.Transparent), Offset(w * .3f, h * .38f), w * .42f), w * .42f, Offset(w * .3f, h * .38f))
drawCircle(Brush.radialGradient(listOf(Color(0xFFA77BFF).copy(alpha = .34f), Color.Transparent), Offset(w * .72f, h * .66f), w * .45f), w * .45f, Offset(w * .72f, h * .66f))
stars.forEach { (x, y, r) -> drawCircle(Color.White.copy(alpha = .2f + r / 5), r, Offset(x, y)) }
}
ArcadeBackdrop.Moonrise -> {
drawRect(Brush.verticalGradient(listOf(BackdropNavy, Color(0xFF22305E))))
stars.forEach { (x, y, r) -> drawCircle(Color.White.copy(alpha = .2f + r / 5), r, Offset(x, y)) }
val moon = Offset(w * .74f, h * .62f); val mr = h * .2f
drawCircle(Color(0xFFC9B5FF), mr, moon)
drawCircle(Color(0xFFB59CF5), mr * .2f, moon + Offset(-mr * .3f, -mr * .28f)); drawCircle(Color(0xFFB59CF5), mr * .13f, moon + Offset(mr * .36f, mr * .3f))
drawPath(skyline, Color(0xFF061025))
windows.forEach { drawRect(Color(0xFFFFDE59).copy(alpha = .8f), it, Size(4f, 4f)) }
}
}
// Dimmed, so the game always reads first.
drawRect(BackdropNavy.copy(alpha = .35f))
}
}
@@ -190,7 +190,7 @@ internal fun BlastOffScreen(best: Int, onBest: (Int) -> Unit, onLeave: () -> Uni
val bestBefore = remember(game.alive) { best }
val newBest = game.score > bestBefore && game.score > 0
BackHandler { if (game.alive) paused = !paused else onLeave() }
Row(Modifier.fillMaxSize().background(NightNavy).padding(20.dp).focusRequester(focus).focusable().onPreviewKeyEvent { event ->
Row(Modifier.fillMaxSize().arcadeBackdrop().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 next = when (native.keyCode) {
@@ -174,7 +174,7 @@ internal fun BlockDropScreen(best: Int, onBest: (Int) -> Unit, onLeave: () -> Un
val bestBefore = remember(game.alive) { best }
val newBest = game.score > bestBefore && game.score > 0
BackHandler { if (game.alive) paused = !paused else onLeave() }
Row(Modifier.fillMaxSize().background(NightNavy).padding(20.dp).focusRequester(focus).focusable().onPreviewKeyEvent { event ->
Row(Modifier.fillMaxSize().arcadeBackdrop().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 next = when (native.keyCode) {
@@ -16,6 +16,7 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.CornerRadius
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.input.key.onPreviewKeyEvent
import androidx.compose.ui.platform.LocalWindowInfo
@@ -39,7 +40,7 @@ internal const val BRICK_COLS = 10
private const val BRICK_ROWS = 6
private const val BRICK_TOP = 28f
private const val BRICK_HEIGHT = 10f
private const val PADDLE_Y = 226f
internal const val PADDLE_Y = 226f
private const val PADDLE_W = 48f
private const val PADDLE_SPEED = 230f
internal const val BALL_R = 3f
@@ -62,11 +63,47 @@ internal fun bricksFor(level: Int): List<Brick> = BrickLayouts[(level - 1) % Bri
line.mapIndexedNotNull { col, c -> (c - '0').takeIf { it > 0 }?.let { Brick(col, row, it) } }
}
/**
* Power-ups from the cast (Paul, 6 November 2026, from the Arcade extras sheet): about one brick in ten drops a gold star;
* catch it with the paddle for its power and its sound.
*/
internal enum class BrickPower(val title: String) {
DoubleLuna("Double Luna"), BlazeRun("Blaze Run"), DriftNet("Drift Net"), ZipSplit("Zip Split"), WideOrbit("Wide Orbit")
}
internal data class BrickBall(val x: Float, val y: Float, val vx: Float, val vy: Float)
internal data class BrickDrop(val x: Float, val y: Float, val power: BrickPower)
internal data class BrickSpark(val x: Float, val y: Float)
private const val PADDLE_W_WIDE = 80f
internal const val WIDE_SECONDS = 15f
private const val DROP_SPEED = 60f
private const val SPARK_SPEED = 260f
private const val VOLLEY_GAP = .45f
/** How long Blaze takes to cross the wall. */
private const val BLAZE_SECONDS = .7f
/** One brick in this many drops a star. */
internal const val DROP_ONE_IN = 10
internal data class BrickGame(val paddleX: Float, val ballX: Float, val ballY: Float, val vx: Float, val vy: Float, val bricks: List<Brick>,
val launched: Boolean = false, val score: Int = 0, val lives: Int = 3, val level: Int = 1) {
val launched: Boolean = false, val score: Int = 0, val lives: Int = 3, val level: Int = 1,
/** Double Luna's other balls; a life goes only when the last ball drops. */
val extra: List<BrickBall> = emptyList(), val drops: List<BrickDrop> = emptyList(), val sparks: List<BrickSpark> = emptyList(),
/** Drift Net: saves the next ball that drops, once. */
val net: Boolean = false,
/** Wide Orbit's seconds left. */
val wideFor: Float = 0f,
/** Zip Split: volleys still to fire, and when the next goes. */
val volleys: Int = 0, val volleyIn: Float = 0f,
/** Blaze Run: the row Blaze is crossing and how far across he is (0 to 1), or -1 when he isn't. */
val blazeRow: Int = -1, val blazeAt: Float = -1f,
val seed: Int = 1,
/** Powers caught since the screen last played their sounds. */
val sounds: List<BrickPower> = emptyList()) {
val alive get() = lives > 0
/** The ball's speed for this level: a little quicker each wall. */
val speed get() = 160f + (level - 1) * 18f
val paddleW get() = if (wideFor > 0f) PADDLE_W_WIDE else PADDLE_W
fun launch(): BrickGame = if (launched || !alive) this else copy(launched = true, vx = speed * .45f, vy = -speed * sqrt(1 - .45f * .45f))
@@ -77,36 +114,102 @@ internal data class BrickGame(val paddleX: Float, val ballX: Float, val ballY: F
return g
}
private fun tick(d: Float, move: Int): BrickGame {
val px = (paddleX + move * PADDLE_SPEED * d).coerceIn(PADDLE_W / 2, BRICK_W - PADDLE_W / 2)
if (!launched) return copy(paddleX = px, ballX = px, ballY = PADDLE_Y - BALL_R - 1)
var x = ballX + vx * d; var y = ballY + vy * d; var nvx = vx; var nvy = vy
/** A new ball on the paddle, every power and falling star cleared away. */
private fun onPaddle(px: Float) = copy(paddleX = px, ballX = px, ballY = PADDLE_Y - BALL_R - 1, vx = 0f, vy = 0f, launched = false,
extra = emptyList(), drops = emptyList(), sparks = emptyList(), net = false, wideFor = 0f, volleys = 0, blazeRow = -1, blazeAt = -1f)
private class Play(var bricks: List<Brick>, var score: Int, var net: Boolean, val broken: MutableList<Brick> = mutableListOf())
/** Hits a brick: one hit off, or gone (and remembered, for its chance of a star). */
private fun Play.hit(b: Brick, whole: Boolean = false) {
bricks = if (b.hits > 1 && !whole) bricks - b + b.copy(hits = b.hits - 1) else { broken += b; bricks - b }
score += 10 * level
}
private fun Play.move(ball: BrickBall, d: Float, px: Float, w: Float): BrickBall? {
var x = ball.x + ball.vx * d; var y = ball.y + ball.vy * d; var nvx = ball.vx; var nvy = ball.vy
if (x < BALL_R) { x = BALL_R; nvx = abs(nvx) }
if (x > BRICK_W - BALL_R) { x = BRICK_W - BALL_R; nvx = -abs(nvx) }
if (y < BALL_R) { y = BALL_R; nvy = abs(nvy) }
// The paddle: where it's hit sets the angle, from steep in the middle to shallow at the ends.
if (nvy > 0 && y + BALL_R >= PADDLE_Y && y + BALL_R <= PADDLE_Y + 6 && abs(x - px) <= PADDLE_W / 2 + BALL_R) {
val along = ((x - px) / (PADDLE_W / 2)).coerceIn(-1f, 1f) * .8f
if (nvy > 0 && y + BALL_R >= PADDLE_Y && y + BALL_R <= PADDLE_Y + 6 && abs(x - px) <= w / 2 + BALL_R) {
val along = ((x - px) / (w / 2)).coerceIn(-1f, 1f) * .8f
nvx = speed * along; nvy = -speed * sqrt(1 - along * along); y = PADDLE_Y - BALL_R
}
var bricks2 = bricks; var score2 = score
bricks.firstOrNull { b -> x + BALL_R > b.left && x - BALL_R < b.right && y + BALL_R > b.top && y - BALL_R < b.bottom }?.let { b ->
// Bounce off the side it came through: whichever overlap is smaller.
val overlapX = minOf(x + BALL_R - b.left, b.right - (x - BALL_R)); val overlapY = minOf(y + BALL_R - b.top, b.bottom - (y - BALL_R))
if (overlapX < overlapY) nvx = -nvx else nvy = -nvy
bricks2 = if (b.hits > 1) bricks - b + b.copy(hits = b.hits - 1) else bricks - b
score2 += 10 * level
hit(b)
}
if (bricks2.isEmpty()) {
// Drift Net catches one ball just below the paddle and sends it back up.
if (net && nvy > 0 && y + BALL_R >= PADDLE_Y + 10) { net = false; nvy = -abs(nvy); y = PADDLE_Y + 10 - BALL_R }
return if (y > BRICK_H + BALL_R) null else BrickBall(x, y, nvx, nvy)
}
private fun tick(d: Float, move: Int): BrickGame {
val w = paddleW
val px = (paddleX + move * PADDLE_SPEED * d).coerceIn(w / 2, BRICK_W - w / 2)
if (!launched) return copy(paddleX = px, ballX = px, ballY = PADDLE_Y - BALL_R - 1)
val play = Play(bricks, score, net)
var main = play.move(BrickBall(ballX, ballY, vx, vy), d, px, w)
var others = extra.mapNotNull { play.move(it, d, px, w) }
// Zip's sparks fly straight up and break the first brick they meet.
val sparks2 = (sparks + if (volleys > 0 && volleyIn - d <= 0f) listOf(-14f, 0f, 14f).map { BrickSpark(px + it, PADDLE_Y - 2) } else emptyList()).mapNotNull { s ->
val ny = s.y - SPARK_SPEED * d
val target = play.bricks.filter { s.x in it.left..it.right && it.bottom >= ny && it.top <= s.y }.maxByOrNull { it.bottom }
if (target != null) { play.hit(target); null } else if (ny < 0f) null else s.copy(y = ny)
}
val volleys2 = if (volleys > 0 && volleyIn - d <= 0f) volleys - 1 else volleys
val volleyIn2 = if (volleys > 0 && volleyIn - d <= 0f) VOLLEY_GAP else volleyIn - d
// Blaze burns across his row from left to right, every brick he passes gone.
var blazeAt2 = blazeAt; var blazeRow2 = blazeRow
if (blazeAt >= 0f) {
blazeAt2 = blazeAt + d / BLAZE_SECONDS
play.bricks.filter { it.row == blazeRow && it.left <= blazeAt2 * BRICK_W }.forEach { play.hit(it, whole = true) }
if (blazeAt2 > 1.15f) { blazeAt2 = -1f; blazeRow2 = -1 }
}
// Every brick that goes has its chance of a star.
var seed2 = seed
val dropped = play.broken.mapNotNull { b ->
seed2 = nextSeed(seed2)
if (seed2 % DROP_ONE_IN != 0) null else { seed2 = nextSeed(seed2); BrickDrop((b.left + b.right) / 2, b.bottom, BrickPower.entries[(seed2 / 7) % BrickPower.entries.size]) }
}
var caught = emptyList<BrickPower>()
val drops2 = (drops + dropped).mapNotNull { drop ->
val ny = drop.y + DROP_SPEED * d
when {
ny >= PADDLE_Y - 2 && ny <= PADDLE_Y + 6 && abs(drop.x - px) <= w / 2 + 4 -> { caught = caught + drop.power; null }
ny > BRICK_H -> null
else -> drop.copy(y = ny)
}
}
var g = copy(paddleX = px, bricks = play.bricks, score = play.score, net = play.net, sparks = sparks2, drops = drops2, volleys = volleys2, volleyIn = volleyIn2,
blazeAt = blazeAt2, blazeRow = blazeRow2, wideFor = maxOf(0f, wideFor - d), seed = seed2, sounds = sounds + caught)
// A lost ball: another of Double Luna's takes over; the last one costs a life.
if (main == null && others.isNotEmpty()) { main = others.first(); others = others.drop(1) }
if (main == null) return g.onPaddle(px).copy(lives = lives - 1)
g = g.copy(ballX = main.x, ballY = main.y, vx = main.vx, vy = main.vy, extra = others)
caught.forEach { g = g.power(it) }
if (g.bricks.isEmpty()) {
// Wall cleared: the next layout, the ball back on the paddle.
return copy(paddleX = px, ballX = px, ballY = PADDLE_Y - BALL_R - 1, vx = 0f, vy = 0f, bricks = bricksFor(level + 1), launched = false, score = score2 + 100 * level, level = level + 1)
return g.onPaddle(px).copy(bricks = bricksFor(level + 1), score = g.score + 100 * level, level = level + 1)
}
if (y > BRICK_H + BALL_R) return copy(paddleX = px, ballX = px, ballY = PADDLE_Y - BALL_R - 1, vx = 0f, vy = 0f, launched = false, lives = lives - 1, bricks = bricks2, score = score2)
return copy(paddleX = px, ballX = x, ballY = y, vx = nvx, vy = nvy, bricks = bricks2, score = score2)
return g
}
/** A caught star's power. */
internal fun power(p: BrickPower): BrickGame = when (p) {
BrickPower.DoubleLuna -> copy(extra = extra + BrickBall(ballX, ballY, -vx, vy))
BrickPower.BlazeRun -> bricks.maxOfOrNull { it.row }?.let { copy(blazeRow = it, blazeAt = 0f) } ?: this
BrickPower.DriftNet -> copy(net = true)
BrickPower.ZipSplit -> copy(volleys = 3, volleyIn = 0f)
BrickPower.WideOrbit -> copy(wideFor = WIDE_SECONDS)
}
companion object {
fun start() = BrickGame(BRICK_W / 2, BRICK_W / 2, PADDLE_Y - BALL_R - 1, 0f, 0f, bricksFor(1))
fun start() = BrickGame(BRICK_W / 2, BRICK_W / 2, PADDLE_Y - BALL_R - 1, 0f, 0f, bricksFor(1), seed = (System.nanoTime() and 0x7fffffff).toInt().coerceAtLeast(1))
internal fun nextSeed(seed: Int) = ((seed.toLong() * 1103515245L + 12345L) and 0x7fffffffL).toInt()
}
}
@@ -123,14 +226,37 @@ internal fun BrickField(game: BrickGame, modifier: Modifier) = Canvas(modifier)
drawRoundRect(if (b.hits > 1) Color.White else colour, Offset(o.x + b.left * k, o.y + b.top * k), Size((b.right - b.left) * k, BRICK_HEIGHT * k), CornerRadius(2 * k))
if (b.hits > 1) drawRoundRect(colour, Offset(o.x + (b.left + 1.5f) * k, o.y + (b.top + 1.5f) * k), Size((b.right - b.left - 3f) * k, (BRICK_HEIGHT - 3f) * k), CornerRadius(1.5f * k))
}
drawRoundRect(NightYellow, Offset(o.x + (game.paddleX - PADDLE_W / 2) * k, o.y + PADDLE_Y * k), Size(PADDLE_W * k, 6 * k), CornerRadius(3 * k))
val at = { x: Float, y: Float -> Offset(o.x + x * k, o.y + y * k) }
// Drift Net: a soft teal band under the paddle while it's waiting to save a ball.
if (game.net) drawRoundRect(Brush.horizontalGradient(listOf(Color.Transparent, OrbCharacter.Drift.colour.copy(alpha = .75f), Color.Transparent), o.x, o.x + BRICK_W * k),
at(4f, PADDLE_Y + 9f), Size((BRICK_W - 8f) * k, 3 * k), CornerRadius(1.5f * k))
// Blaze Run: Blaze crossing his row, his tail behind him.
if (game.blazeAt >= 0f) {
val y = BRICK_TOP + game.blazeRow * (BRICK_HEIGHT + 2f) + BRICK_HEIGHT / 2; val x = game.blazeAt * BRICK_W
drawRoundRect(Brush.horizontalGradient(listOf(Color.Transparent, Color(0xFFFFC23D)), o.x + (x - 60f) * k, o.x + x * k), at(x - 60f, y - 2.5f), Size(60f * k, 5 * k), CornerRadius(2.5f * k))
drawOrbChaser(at(x, y), 7f * k, OrbCharacter.Blaze, OrbDir.Right, scared = false, returning = false, blink = false, wobble = 0f)
}
// Falling stars, and Zip's sparks.
game.drops.forEach { d -> drawCircle(NightYellow.copy(alpha = .25f), 6f * k, at(d.x, d.y)); drawPath(starPath(at(d.x, d.y), 4.5f * k, 2f * k, 0f), NightYellow) }
game.sparks.forEach { s -> drawPath(starPath(at(s.x, s.y), 2.6f * k, 1.1f * k, 0f), OrbCharacter.Zip.colour) }
val w = game.paddleW
drawRoundRect(NightYellow, at(game.paddleX - w / 2, PADDLE_Y), Size(w * k, 6 * k), CornerRadius(3 * k))
// Wide Orbit's time left, as a thin line along the paddle that shortens.
if (game.wideFor > 0f) drawRect(Color.White.copy(alpha = .8f), at(game.paddleX - w / 2, PADDLE_Y + 7f), Size(w * k * (game.wideFor / WIDE_SECONDS), 1f * k))
// Luna is the ball (Paul, 6 November 2026), drawn a little larger than she bounces, so her face can be seen.
drawOrbChaser(Offset(o.x + game.ballX * k, o.y + game.ballY * k), BALL_R * k * 2.2f, OrbCharacter.Luna, OrbDir.Down, scared = false, returning = false, blink = false, wobble = 0f)
(listOf(BrickBall(game.ballX, game.ballY, 0f, 0f)) + game.extra).forEach { b ->
drawOrbChaser(at(b.x, b.y), BALL_R * k * 2.2f, OrbCharacter.Luna, OrbDir.Down, scared = false, returning = false, blink = false, wobble = 0f)
}
}
@Composable
internal fun BrickBreakScreen(best: Int, onBest: (Int) -> Unit, onLeave: () -> Unit) {
var game by remember { mutableStateOf(BrickGame.start()) }
val context = androidx.compose.ui.platform.LocalContext.current
val sfx = remember { BrickSounds(context) }
val settings = remember { OrbitSettings(context) }
var soundsOn by remember { mutableStateOf(settings.arcadeSounds) }
DisposableEffect(sfx) { onDispose { sfx.release() } }
var paused by remember { mutableStateOf(false) }
var held by remember { mutableIntStateOf(0) }
val focus = remember { FocusRequester() }
@@ -141,6 +267,7 @@ internal fun BrickBreakScreen(best: Int, onBest: (Int) -> Unit, onLeave: () -> U
while (!paused && game.alive) {
val now = withFrameNanos { it }
game = game.step(((now - last) / 1e9f).coerceAtMost(.05f), held); last = now
if (game.sounds.isNotEmpty()) { if (soundsOn) game.sounds.forEach { sfx.play(it) }; game = game.copy(sounds = emptyList()) }
}
if (!game.alive && game.score > best) onBest(game.score)
}
@@ -150,7 +277,7 @@ internal fun BrickBreakScreen(best: Int, onBest: (Int) -> Unit, onLeave: () -> U
val bestBefore = remember(game.alive) { best }
val newBest = game.score > bestBefore && game.score > 0
BackHandler { if (game.alive) paused = !paused else onLeave() }
Row(Modifier.fillMaxSize().background(NightNavy).padding(20.dp).focusRequester(focus).focusable().onPreviewKeyEvent { event ->
Row(Modifier.fillMaxSize().arcadeBackdrop().padding(20.dp).focusRequester(focus).focusable().onPreviewKeyEvent { event ->
val native = event.nativeKeyEvent
if (paused || !game.alive) return@onPreviewKeyEvent false
val down = native.action == KeyEvent.ACTION_DOWN
@@ -170,9 +297,14 @@ internal fun BrickBreakScreen(best: Int, onBest: (Int) -> Unit, onLeave: () -> U
Text("BEST", color = NightMuted, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 2.sp)
Text(maxOf(best, game.score).toString(), color = Color.White, fontFamily = Marquee, fontSize = 32.sp)
Text("WALL ${game.level} · BALLS ${game.lives}", color = NightMuted, fontSize = 13.sp, fontWeight = FontWeight.SemiBold)
// What's helping right now.
listOfNotNull(BrickPower.DoubleLuna.takeIf { game.extra.isNotEmpty() }, BrickPower.BlazeRun.takeIf { game.blazeAt >= 0f }, BrickPower.DriftNet.takeIf { game.net },
BrickPower.ZipSplit.takeIf { game.volleys > 0 || game.sparks.isNotEmpty() }, BrickPower.WideOrbit.takeIf { game.wideFor > 0f })
.forEach { Text(it.title.uppercase(), color = Honey, fontSize = 13.sp, fontWeight = FontWeight.SemiBold) }
Spacer(Modifier.weight(1f))
Text("◄► Hold to move", color = NightMuted, fontSize = 13.sp)
Text("OK Launch Back Pause", color = NightMuted, fontSize = 13.sp)
Text("Catch the gold stars", color = NightMuted, fontSize = 13.sp)
}
Box(Modifier.weight(1f).fillMaxHeight().border(2.dp, NightBlue), contentAlignment = Alignment.Center) {
BrickField(game, Modifier.fillMaxSize().padding(6.dp))
@@ -185,8 +317,20 @@ internal fun BrickBreakScreen(best: Int, onBest: (Int) -> Unit, onLeave: () -> U
if (!game.alive) game = BrickGame.start()
held = 0; paused = false
})
OrbitButton("Sounds: ${if (soundsOn) "On" else "Off"}", Modifier.width(200.dp), onClick = { soundsOn = !soundsOn; settings.arcadeSounds = soundsOn })
OrbitButton("Leave the game", Modifier.width(200.dp), onClick = onLeave)
}
}
}
}
/** Each power's short sound, made for Orbit (design/arcade/build-power-sounds.py); quiet, and only while a game plays. */
internal class BrickSounds(context: android.content.Context) {
private val pool = android.media.SoundPool.Builder().setMaxStreams(3)
.setAudioAttributes(android.media.AudioAttributes.Builder().setUsage(android.media.AudioAttributes.USAGE_GAME).setContentType(android.media.AudioAttributes.CONTENT_TYPE_SONIFICATION).build()).build()
private val ids = mapOf(BrickPower.DoubleLuna to R.raw.power_double_luna, BrickPower.BlazeRun to R.raw.power_blaze_run, BrickPower.DriftNet to R.raw.power_drift_net,
BrickPower.ZipSplit to R.raw.power_zip_split, BrickPower.WideOrbit to R.raw.power_wide_orbit).mapValues { (_, raw) -> pool.load(context, raw, 1) }
fun play(power: BrickPower) { ids[power]?.let { pool.play(it, .7f, .7f, 1, 0, 1f) } }
fun release() = pool.release()
}
@@ -491,7 +491,7 @@ internal fun OrbManScreen(best: Int, onBest: (Int) -> Unit, onLeave: () -> Unit)
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(NightNavy).padding(20.dp).focusRequester(focus).focusable().onPreviewKeyEvent { event ->
Row(Modifier.fillMaxSize().arcadeBackdrop().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) {
@@ -100,7 +100,7 @@ internal fun PairsScreen(best: Int, onBest: (Int) -> Unit, onLeave: () -> Unit)
var newBest by remember { mutableStateOf(false) }
LaunchedEffect(game.done) { if (game.done) { newBest = best == 0 || game.moves < best; if (newBest) onBest(game.moves); menuFocus.requestFocus() } else focus.requestFocus() }
BackHandler(onBack = onLeave)
Row(Modifier.fillMaxSize().background(NightNavy).padding(20.dp).focusRequester(focus).focusable().onPreviewKeyEvent { event ->
Row(Modifier.fillMaxSize().arcadeBackdrop().padding(20.dp).focusRequester(focus).focusable().onPreviewKeyEvent { event ->
val native = event.nativeKeyEvent
if (native.action != KeyEvent.ACTION_DOWN || game.done) return@onPreviewKeyEvent false
val next = when (native.keyCode) {
@@ -191,7 +191,7 @@ internal fun SkydokuScreen(solved: Int, dailyDone: Boolean, onSolved: (daily: Bo
LaunchedEffect(game.done) { if (game.done) onSolved(daily) }
LaunchedEffect(paused, game.done, game.out) { if (paused || game.done || game.out) menuFocus.requestFocus() else focus.requestFocus() }
BackHandler { if (!game.done && !game.out) paused = !paused else onLeave() }
Row(Modifier.fillMaxSize().background(NightNavy).padding(20.dp).focusRequester(focus).focusable().onPreviewKeyEvent { event ->
Row(Modifier.fillMaxSize().arcadeBackdrop().padding(20.dp).focusRequester(focus).focusable().onPreviewKeyEvent { event ->
val native = event.nativeKeyEvent
if (native.action != KeyEvent.ACTION_DOWN || paused || game.done || game.out) return@onPreviewKeyEvent false
game = when (native.keyCode) {
@@ -372,7 +372,7 @@ private fun GameCard(game: ArcadeGame, modifier: Modifier, focus: FocusRequester
@Composable
internal fun OrbitArcade(modifier: Modifier, onPlaying: (Boolean) -> Unit) {
val context = LocalContext.current
val settings = remember { OrbitSettings(context) }
val settings = remember { OrbitSettings(context).also(ArcadeLook::load) }
var best by remember { mutableIntStateOf(settings.snakeBest) }
var blockBest by remember { mutableIntStateOf(settings.blockDropBest) }
var brickBest by remember { mutableIntStateOf(settings.brickBreakBest) }
@@ -521,6 +521,10 @@ internal fun OrbitArcade(modifier: Modifier, onPlaying: (Boolean) -> Unit) {
}
}
}
Text("◄►▲▼ Choose OK Play Back Main menu", color = Muted, fontSize = 11.sp)
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text("◄►▲▼ Choose OK Play Back Main menu", Modifier.weight(1f), color = Muted, fontSize = 11.sp)
// One backdrop behind every game (Paul, 6 November 2026): OK goes to the next.
OrbitButton("Backdrop: ${ArcadeLook.backdrop.title}", Modifier.width(220.dp), centred = true) { ArcadeLook.next(settings) }
}
}
}
@@ -117,6 +117,14 @@ internal class OrbitSettings(context: Context) {
get() = saved.getString("look", null)?.let { name -> OrbitLook.entries.firstOrNull { it.name == name } } ?: OrbitLook.Night
set(value) { saved.edit().putString("look", value.name).apply() }
/** Turns off moving presentation: the Drive-In's curtains, and every slide and rise becomes a short fade (design/motion). */
/** Brick Break's power-up sounds (Paul, 6 November 2026). */
var arcadeSounds: Boolean
get() = saved.getBoolean("arcade_sounds", true)
set(value) { saved.edit().putBoolean("arcade_sounds", value).apply() }
/** The Arcade backdrop behind every game ([ArcadeBackdrop]); Night Sky until one is chosen. */
var arcadeBackdrop: String
get() = saved.getString("arcade_backdrop", null) ?: ArcadeBackdrop.NightSky.name
set(value) { saved.edit().putString("arcade_backdrop", value).apply() }
var reduceMotion: Boolean
get() = saved.getBoolean("reduce_motion", false)
set(value) { saved.edit().putBoolean("reduce_motion", value).apply() }
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -43,4 +43,44 @@ class BrickBreakTest {
assertEquals(2, cleared.level); assertEquals(bricksFor(2), cleared.bricks); assertTrue(cleared.speed > BrickGame.start().speed)
assertFalse(BrickGame.start().copy(lives = 0).alive)
}
// Power-ups from the cast (Paul, 6 November 2026).
private fun playing() = BrickGame.start().copy(launched = true, bricks = bricksFor(1), ballX = 160f, ballY = 150f, vx = 60f, vy = -150f)
@Test fun aCaughtStarGivesItsPowerAndItsSound() {
val star = BrickDrop(160f, PADDLE_Y - 6f, BrickPower.WideOrbit)
val g = playing().copy(paddleX = 160f, drops = listOf(star)).step(.2f, 0)
assertTrue(g.drops.isEmpty()); assertEquals(listOf(BrickPower.WideOrbit), g.sounds); assertTrue(g.paddleW > 48f)
// Missed, it just falls away.
assertTrue(playing().copy(paddleX = 30f, drops = listOf(star)).step(.5f, 0).let { it.drops.isEmpty() && it.sounds.isEmpty() })
}
@Test fun doubleLunaCostsALifeOnlyWhenTheLastBallDrops() {
val two = playing().power(BrickPower.DoubleLuna)
assertEquals(1, two.extra.size)
val falling = two.copy(paddleX = 30f, ballX = 300f, ballY = 230f, vx = 0f, vy = 160f)
val after = falling.step(.2f, 0)
assertEquals(3, after.lives); assertTrue(after.extra.isEmpty()); assertTrue(after.launched)
}
@Test fun blazeBurnsTheLowestRowAndDriftSavesOneBall() {
val g = playing().power(BrickPower.BlazeRun)
val lowest = g.bricks.maxOf { it.row }
val burnt = g.copy(ballX = 10f, ballY = 200f, vx = 0f, vy = -1f).step(1f, 0)
assertTrue(burnt.bricks.none { it.row == lowest }); assertTrue(burnt.score >= 10 * BRICK_COLS)
val saved = playing().power(BrickPower.DriftNet).copy(paddleX = 30f, ballX = 300f, ballY = 225f, vx = 0f, vy = 160f).step(.2f, 0)
assertEquals(3, saved.lives); assertFalse(saved.net); assertTrue(saved.vy < 0)
}
@Test fun zipFiresThreeVolleysThatBreakBricks() {
val g = playing().copy(paddleX = 160f, ballX = 10f, ballY = 200f, vx = 0f, vy = -1f).power(BrickPower.ZipSplit)
val before = g.bricks.size
val after = g.step(1.6f, 0)
assertEquals(0, after.volleys); assertTrue(before - after.bricks.size >= 6)
}
@Test fun losingABallClearsThePowers() {
val g = playing().power(BrickPower.WideOrbit).power(BrickPower.ZipSplit).copy(paddleX = 30f, ballX = 300f, ballY = 230f, vx = 0f, vy = 160f).step(.3f, 0)
assertEquals(2, g.lives); assertEquals(0f, g.wideFor, 0f); assertEquals(0, g.volleys)
}
}
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""
Brick Break's power-up sounds (Paul, 6 November 2026, from the Arcade extras sheet), synthesised here so they're Orbit's
own: no samples, no licences. Each is under a second, written as a small Ogg Vorbis file into the app's res/raw.
- double_luna: a soft two-note chime, up a fifth.
- blaze_run: a rising whoosh of filtered noise.
- drift_net: a low, soft hum that swells and settles.
- zip_split: three quick zips, falling.
- wide_orbit: a stretching wobble.
"""
import math, random, struct, subprocess, sys, tempfile, wave
from pathlib import Path
RATE = 22050
HERE = Path(__file__).resolve().parent
OUT = Path(sys.argv[1]) if len(sys.argv) > 1 else HERE.parent.parent / 'android' / 'app' / 'src' / 'main' / 'res' / 'raw'
def envelope(t, length, attack=.01, release=.15):
if t < attack: return t / attack
if t > length - release: return max(0.0, (length - t) / release)
return 1.0
def tone(freq, t):
# A sine with a little of its octave, so it rings like a chime rather than a test tone.
return math.sin(2 * math.pi * freq * t) + .3 * math.sin(4 * math.pi * freq * t)
def double_luna():
n = int(RATE * .55); out = []
for i in range(n):
t = i / RATE
a = tone(784, t) * envelope(t, .3, release=.25) if t < .3 else 0
b = tone(1175, t - .14) * envelope(t - .14, .41, release=.3) if t >= .14 else 0
out.append(.35 * (a + b))
return out
def blaze_run():
n = int(RATE * .6); out = []; rnd = random.Random(7); low = 0.0
for i in range(n):
t = i / RATE; cut = .02 + .5 * (t / .6) ** 2
low += cut * (rnd.uniform(-1, 1) - low)
out.append(.9 * low * envelope(t, .6, attack=.05, release=.2) + .15 * math.sin(2 * math.pi * (200 + 900 * t) * t) * envelope(t, .6))
return out
def drift_net():
n = int(RATE * .7); out = []
for i in range(n):
t = i / RATE; swell = math.sin(math.pi * t / .7)
out.append(.4 * swell * (math.sin(2 * math.pi * 110 * t) + .5 * math.sin(2 * math.pi * 165 * t) * (1 + .3 * math.sin(2 * math.pi * 6 * t))))
return out
def zip_split():
out = []
for k in range(3):
n = int(RATE * .09)
for i in range(n):
t = i / RATE; f = 2400 - 1600 * t / .09 - 200 * k
out.append(.25 * (2 * ((f * t) % 1) - 1) * envelope(t, .09, attack=.004, release=.03))
out += [0.0] * int(RATE * .04)
return out
def wide_orbit():
n = int(RATE * .5); out = []; phase = 0.0
for i in range(n):
t = i / RATE; f = 330 + 220 * (t / .5) + 40 * math.sin(2 * math.pi * 9 * t)
phase += 2 * math.pi * f / RATE
out.append(.35 * math.sin(phase) * envelope(t, .5, release=.18))
return out
def write(name, samples):
OUT.mkdir(parents=True, exist_ok=True)
peak = max(abs(s) for s in samples) or 1
with tempfile.NamedTemporaryFile(suffix='.wav') as tmp:
with wave.open(tmp.name, 'wb') as w:
w.setnchannels(1); w.setsampwidth(2); w.setframerate(RATE)
w.writeframes(b''.join(struct.pack('<h', int(32767 * .8 * s / peak)) for s in samples))
target = OUT / f'power_{name}.ogg'
subprocess.run(['ffmpeg', '-v', 'error', '-y', '-i', tmp.name, '-c:a', 'libvorbis', '-q:a', '3', str(target)], check=True)
print(target.name, target.stat().st_size)
for name, make in [('double_luna', double_luna), ('blaze_run', blaze_run), ('drift_net', drift_net), ('zip_split', zip_split), ('wide_orbit', wide_orbit)]:
write(name, make())