From d03cefe6f842e08cafd5dfce59a06a0a11f8124d Mon Sep 17 00:00:00 2001 From: Pauljd Date: Fri, 6 Nov 2026 16:04:24 +0000 Subject: [PATCH] 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) --- .../java/tv/orbit/classic/ArcadeBackdrop.kt | 76 ++++++++ .../java/tv/orbit/classic/ArcadeBlastOff.kt | 2 +- .../java/tv/orbit/classic/ArcadeBlockDrop.kt | 2 +- .../java/tv/orbit/classic/ArcadeBrickBreak.kt | 182 ++++++++++++++++-- .../java/tv/orbit/classic/ArcadeOrbMan.kt | 2 +- .../main/java/tv/orbit/classic/ArcadePairs.kt | 2 +- .../java/tv/orbit/classic/ArcadeSkydoku.kt | 2 +- .../main/java/tv/orbit/classic/OrbitArcade.kt | 8 +- .../main/java/tv/orbit/classic/Settings.kt | 8 + .../app/src/main/res/raw/power_blaze_run.ogg | Bin 0 -> 6617 bytes .../src/main/res/raw/power_double_luna.ogg | Bin 0 -> 4446 bytes .../app/src/main/res/raw/power_drift_net.ogg | Bin 0 -> 4310 bytes .../app/src/main/res/raw/power_wide_orbit.ogg | Bin 0 -> 4158 bytes .../app/src/main/res/raw/power_zip_split.ogg | Bin 0 -> 5882 bytes .../java/tv/orbit/classic/BrickBreakTest.kt | 40 ++++ design/arcade/build-power-sounds.py | 91 +++++++++ 16 files changed, 389 insertions(+), 26 deletions(-) create mode 100644 android/app/src/main/java/tv/orbit/classic/ArcadeBackdrop.kt create mode 100644 android/app/src/main/res/raw/power_blaze_run.ogg create mode 100644 android/app/src/main/res/raw/power_double_luna.ogg create mode 100644 android/app/src/main/res/raw/power_drift_net.ogg create mode 100644 android/app/src/main/res/raw/power_wide_orbit.ogg create mode 100644 android/app/src/main/res/raw/power_zip_split.ogg create mode 100644 design/arcade/build-power-sounds.py diff --git a/android/app/src/main/java/tv/orbit/classic/ArcadeBackdrop.kt b/android/app/src/main/java/tv/orbit/classic/ArcadeBackdrop.kt new file mode 100644 index 0000000..f4fb570 --- /dev/null +++ b/android/app/src/main/java/tv/orbit/classic/ArcadeBackdrop.kt @@ -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)) + } +} diff --git a/android/app/src/main/java/tv/orbit/classic/ArcadeBlastOff.kt b/android/app/src/main/java/tv/orbit/classic/ArcadeBlastOff.kt index f649aa4..e4d9136 100644 --- a/android/app/src/main/java/tv/orbit/classic/ArcadeBlastOff.kt +++ b/android/app/src/main/java/tv/orbit/classic/ArcadeBlastOff.kt @@ -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) { diff --git a/android/app/src/main/java/tv/orbit/classic/ArcadeBlockDrop.kt b/android/app/src/main/java/tv/orbit/classic/ArcadeBlockDrop.kt index 90a3654..342550c 100644 --- a/android/app/src/main/java/tv/orbit/classic/ArcadeBlockDrop.kt +++ b/android/app/src/main/java/tv/orbit/classic/ArcadeBlockDrop.kt @@ -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) { diff --git a/android/app/src/main/java/tv/orbit/classic/ArcadeBrickBreak.kt b/android/app/src/main/java/tv/orbit/classic/ArcadeBrickBreak.kt index 628be02..fb32849 100644 --- a/android/app/src/main/java/tv/orbit/classic/ArcadeBrickBreak.kt +++ b/android/app/src/main/java/tv/orbit/classic/ArcadeBrickBreak.kt @@ -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 = 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, - 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 = emptyList(), val drops: List = emptyList(), val sparks: List = 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 = 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, var score: Int, var net: Boolean, val broken: MutableList = 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() + 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() +} diff --git a/android/app/src/main/java/tv/orbit/classic/ArcadeOrbMan.kt b/android/app/src/main/java/tv/orbit/classic/ArcadeOrbMan.kt index 3a4edd6..a92a7ef 100644 --- a/android/app/src/main/java/tv/orbit/classic/ArcadeOrbMan.kt +++ b/android/app/src/main/java/tv/orbit/classic/ArcadeOrbMan.kt @@ -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) { diff --git a/android/app/src/main/java/tv/orbit/classic/ArcadePairs.kt b/android/app/src/main/java/tv/orbit/classic/ArcadePairs.kt index cc23729..2103c37 100644 --- a/android/app/src/main/java/tv/orbit/classic/ArcadePairs.kt +++ b/android/app/src/main/java/tv/orbit/classic/ArcadePairs.kt @@ -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) { diff --git a/android/app/src/main/java/tv/orbit/classic/ArcadeSkydoku.kt b/android/app/src/main/java/tv/orbit/classic/ArcadeSkydoku.kt index bd8657b..675782a 100644 --- a/android/app/src/main/java/tv/orbit/classic/ArcadeSkydoku.kt +++ b/android/app/src/main/java/tv/orbit/classic/ArcadeSkydoku.kt @@ -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) { diff --git a/android/app/src/main/java/tv/orbit/classic/OrbitArcade.kt b/android/app/src/main/java/tv/orbit/classic/OrbitArcade.kt index 3316bb8..499e6c2 100644 --- a/android/app/src/main/java/tv/orbit/classic/OrbitArcade.kt +++ b/android/app/src/main/java/tv/orbit/classic/OrbitArcade.kt @@ -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) } + } } } 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 e4b0ac3..05f0f2c 100644 --- a/android/app/src/main/java/tv/orbit/classic/Settings.kt +++ b/android/app/src/main/java/tv/orbit/classic/Settings.kt @@ -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() } diff --git a/android/app/src/main/res/raw/power_blaze_run.ogg b/android/app/src/main/res/raw/power_blaze_run.ogg new file mode 100644 index 0000000000000000000000000000000000000000..bdb6a0becce74e17905b5b6dd5a63b3234799f84 GIT binary patch literal 6617 zcmcgQc|4TezYis2Nkh{ZnnY3a*vC?tC`Jn-;<2Q$8%qp{Aw_xBpsa}@G}dP9yN2d1 z?RF8_3oSxPTHbiI-ZQ%I?=GMFxu5&jJ)bk@Ip_JF^WB&8%wb<&TSyrC_x7>gs$DSM z!#;jeNI~diP>81=L-0aqi-X_+K?=FQpJhUa1tW((4-NFAIs zD^jQOwG@O7^DwrD>Rw}MRlFfKqs9cLB@tV3|1oS`Q3DVdy++O}6)+fsSwR{C@Zo=p z1%QGm!0$(t4~8foOjGViQ#VgH>}n8n%Yl8C8|(+|1BVv_-4+8U2fZg3y`K(xyJUKg z^8#IlTwI33{%cx!~V+628(ApS=pmzXO zfJwTc`4c_M|6>;@Ru}@#hfYtWSk)al0Jkb%cU9$J(gz|-vpf%x=F7-yU{U@BiW0Zr z$%GC!6_JcA$s)Jo-WD=biLtfAV~pPBB?VwSX=oov7p;n|Xj(gQn+j8g*rx1-Y?)!R ztSH;K90zJQCnk%Mc$r~E*{+g~P$kN|(Ej+y&uaE3hnG+Ku|AHl6` zUAQVX(^IXiWyG^fpO?h|s4%o%xHXSn$!}vbwW|i%)GmXTtjO-VnJmV-GzJTx3PY$C zRf9Ry3jOqcs$W%WDm76+Jdx;&P}R#?9H|M!mMli1#_(fmHK1XeXwXJwrqwOFQY!?g z5~=_y?Hz4-yD9w4p$N^YFRlzt^3b!yV;VC9{_9j*^B6t%MxHUzRj=9p?~GbTBHtU} zo-#gE4Lx^yw7;Fre9Zs5I8yt-;25);hstK4NwZr*KXja8`D<3acBal54auK*WPs|U zzK2^UY!jP@^RCu5iz-pO04inz@$VdlHfhGwuIe5TFqMZbjMSpcD6-0VqSdo`k_haqGg}&prl^)Zv_t%=IQ@aVz%|HbFvE&W(eUmuF)QO)Qz%s}Sc-WW4=Vvd zgcG22)tcCP21lDDlCXKNkcF4mN3HUmu&Q(_?$ zic$;$P=SCI76kA{Jr%4dU}*nuhG=Zv9wqTyZV#edc5TRpPT(z6aWfY2x&$U@CWAtc400HRAg~Mt_r}6vfY(SAE#zO z#^8SBE=le&_p9>gHb7|%j%c^ySuD>)5EV!w|3J5i8Qfa1n*j500UvT>Sky!V(R3tH ze`bOSRGGuK5LKc9fMQIl0DvaXvO!uzDTN9EF=*0E0T31igb-Bh|4E2lpo*;2 z^8fzc>^S>xW z7`m|$4=Kt7usu}XRG!egvEmQlVpo7+q)<>WD-|itDoP>4FbAQCOVfJH-{L_j0@V6G{p?&T7jC!!H@|` zu|QlEZW@TW3_G$I-~b^M0xUr0Gz5mA=PQ{SMd~Q6M{Tsc@d3if*ULcQCSuGA9r7zN z--06U&)&WkZdK%Q)E}U2)8ap3Xh{Zy1Ex~MDHcwaf+}Qs8g9!l?Xp3LeLsf)K*d9O+u@s0-@-0sBAGw@xps1+aN>ZeP$w1yjVhi ztR_n7e5kb7$GfrXcjn!A5hE4&`?7_GJ_tdn){wMxU$?!Zv%A-^fD@so&O`&;f`)?5 zK#=kl<9++|7#py%I63)^_)Q9%6_o@ZGAqs4*m&is2u5S$mG7g~)w(OMAi?*M;DRZ> z$#-d!qx0G0!Sqo7px~fmVaJ2~Plum45yA*N9T;|i>R_iPuh~zxx(*fWAnj3)#0|;# z*YzF43?BHr)nWHbAB!J;dbM?_#(8_)cJ#i2ep#52ZT4;$$F1+~K5>R!qR2n@>bp|U zxksz7RS!UEJ=!tQi#v;NMDg)4C%JCC{u;bC_i9tKadsX38dzbc0k0AzdrF!R9|$l977ao;?`b>5+`IdYrT)O?f6zqJ6=R3JWHw^oY^95vpzP-2NwO*7r8zf?Q(htg{|;r|DKCB ztUbQYYl^a5?ijruu#`xm1o0DttBVKGPLjYt?Lbq*g!cbL(ZK&4v#^< z)c32A@E%#8W%~Yp@d%F6U-d0R^aN+TUO!b;cXa3ne?taBib`0a_VlXPPcj2?n0^>A zbmyC7Z`0szCKEHao!om9J)!Q9Y_{%H&ZM+#Np`pQ`h}{$z0C8JZtnD7^)oK_=UV$) zHis;3UFquT4zs#mm)2Ag?6le=D`LORO=E|@R4o1su>8}0OHcDI-ouvIY4N;@LqNQD z#)&r4>l?l-Ec$7aB7WuRkz~9cteTrNAFZbj1?3|78ra?TvAR?j0^RHTMAP-9>XJrA z?8L7l78gAVg=Xplt3tve3_LZAH1k8S(~`~up6T}tY#Tdny{*4r+`n^be#A2e%IjQ_ zE&krmaiLP}3Rl3H?8Dqo6VLBe*SV@ff3TVD zdhUtH?ypdkgg*FZz`0;tS+(0dfIgY)^zF?>zdhMEG;3qDWoIt9+jEZTHh#YTQxhoxj>4jV$>* zzjL<4TmG!&T;Tg7y?6U6jS~0CUR87yiC`sTgMLI^^<{b5FyhODB$Gekt4;+^dg@OX zN!V-%zf>-DYkmv=9i72FQz*Mf0^E9_Z`s)Ah3x@tUYn1*{jhr$F%%Z=vSayN{fEQa z*9^D@aiV|n$YX=D%1O+7%PQ{)u0>~t9xkV9RUR8n9KJ7hTi*BZ7Yot&Fq5X*Myoq# z4fDtLt|6u4X5(hA=jk+5$rj=C4rB+miFf6QuX0rr5IiEWx{=a1D$@|Sd2Xt@?=Jhq zjiMv>KI!dx72f3|`Ef0KQ}Wc+MjYD7<>XVuaeru__WP%lRTEw;>7&`JvCfzz@rhBJ z-LGckmWD2QNV|1qx`*rte>(AY+g>|@S$I#QdrQ7NiWyxa>uSLj7*4i;`)Eped~wPD z*v7TFhuEbb-%e}pp;N`MwG-GD<;h~ocQ>sh96u}{dEM-gYnt5r0)HvzxK%^#@jsd0 zcSm*YfBgRS^3vxzd|&gWqilr|(sJ7EPt3`1&dX&NqTP3?C<}pDZhzEXi7sb&p2S<# z?78{no=C-#<>N=;5))zjdJtXAgXE^>&;_c-W0@7i6OHbPhHd@R~Nw8AV;q zSOY<~MCFA#VlqZ$(lgz(CU*B{&N&#;H?IyqoK+&^pqC~oH>JNAE zQyqC9U*EWrQ#zu+cvGr}Ek6Bl;8M8K!S|@(gLR)aV2^CuD&i<=s1n-PEkdo7X?_aa zN2Rk%%9TC{L{xBJXKlW#qj6GmYO&T=(2M#$mk!_E8 zAMOYZtYM=glhutqNp}c}r}fo*L@FX)^b}^Xn;vwxYX$_t@Kp322V1h(AFir#;ybVQ z>@8@YiI)@|U%VrvkR;IPyuR@M8MasA&-8)@{rm$AVnN6aOVjwr*M4byl#a&M{mmA- zZO4y~JpC(Y`i9!@##zgX#>WnB1pbLnl7C}Ib=eR2r9b?QJSGU(S>?ol-ZIzHs z4Ly#pUR7&D*8GNf;OcOYWEz_6&JEw=Vj?t;ekq*aLAVsW;9`4t!Q{)9VwGUmwk$j` z;hog$K-JC=;m!I|Qo#-H-^^3%$1E!|-XCsy=7QTHyay15AlFwyGIlaOjoD|PeJ`1D z{VMfxXw}Zl!W4FS+%vqoux^}_Ovp$PoOG&WFYoJ{L=OwwO^wFmdr}X*-S_o@=?vl3 z$85DN@(Y71CcVoA>%_w&Mt9k(G9NlqK4^`2@25A_6k@~2<>45HM9@dl)(gW>UIh=3 z-2;VMhlZr6Y;$E_-x+r4{dH>Q;EMsDa@+57KMz=2N=EE0&i>XSV%to8CyYzRD3Cc; zVI$#Ib!%5q;40A$F-e3gK22a*=kzqv}$aw`l+k~Iw)#R-qGng zO?sp!LU}uqJf`2gJiqJG&%x6se;Cc&^FV4|U$OTgcBTdM7ck;~ea`soA@ZTwVEF>F zATj6BMXP;G_IkB#Er>jUql!7E9y040wV*H5-YMOy7#S!k+xx`FTRNf3$5xb#obxfg z(si0Qv&ak$D_);Xd6Bu$IkDgGBu8fVu%cV@bg{_T{Q>!@T!#gVvg<`X3DTq9pEyPW}_|`nog#^MEP7QT)6KP4rde}JDj?qC3Jhk&4%qlw-W?%x~_rD z^|uGKjYJ*MN@;vI)|9D6pwsj#`SpjN?h6|qz}OOB6|lOi>7yNQp3E2Nf7Txkw+c*2 zlBJhF^dUO`j`UCX*8DndY5Q_9XrY4o_B;h)pflP!yVPH#TcR?Y8dfpYubTwha)-FZ=(c&rY+ zuPbG_^UUKK1B*xto1)^KZreFx(f+m{U&0O{2|f{;8R1_WuyBNe%bTDj5&U256uao~ z)f#s@J`j|hzqWhIEc&%z?V#s!k{>*{gzfxSQunRwm-TTOK7LBRCjEq1pf;p#d&6Dyxp35%fPtRdRel;8$&IA8JA?Nz z7i@f$Z{#Ym1{Rd7oM|&Z z(m#BD;%RSkmKX7<%dMISEvbKs`i1e!n#vkk-ZEv|d!}A|{Oksj>iL?X;;ArC$@M4G X#(&tZ?ltv#adU+CWeZrd_J literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/raw/power_double_luna.ogg b/android/app/src/main/res/raw/power_double_luna.ogg new file mode 100644 index 0000000000000000000000000000000000000000..3fdcf261b3485715aeef3100e911acceae09ac12 GIT binary patch literal 4446 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 literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/raw/power_drift_net.ogg b/android/app/src/main/res/raw/power_drift_net.ogg new file mode 100644 index 0000000000000000000000000000000000000000..07cbda5b8d8e4bf1bc87872ec023b56caab046be GIT binary patch literal 4310 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 K^OFIsPUvr+#9qe$ literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/raw/power_wide_orbit.ogg b/android/app/src/main/res/raw/power_wide_orbit.ogg new file mode 100644 index 0000000000000000000000000000000000000000..ead1ba7fa85797a1d9968755ba3e49d6ee3f3729 GIT binary patch literal 4158 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 literal 0 HcmV?d00001 diff --git a/android/app/src/test/java/tv/orbit/classic/BrickBreakTest.kt b/android/app/src/test/java/tv/orbit/classic/BrickBreakTest.kt index f9189e0..5e99acc 100644 --- a/android/app/src/test/java/tv/orbit/classic/BrickBreakTest.kt +++ b/android/app/src/test/java/tv/orbit/classic/BrickBreakTest.kt @@ -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) + } } diff --git a/design/arcade/build-power-sounds.py b/design/arcade/build-power-sounds.py new file mode 100644 index 0000000..fb40a53 --- /dev/null +++ b/design/arcade/build-power-sounds.py @@ -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('