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 0000000..bdb6a0b Binary files /dev/null and b/android/app/src/main/res/raw/power_blaze_run.ogg differ 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 0000000..3fdcf26 Binary files /dev/null and b/android/app/src/main/res/raw/power_double_luna.ogg differ 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 0000000..07cbda5 Binary files /dev/null and b/android/app/src/main/res/raw/power_drift_net.ogg differ 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 0000000..ead1ba7 Binary files /dev/null and b/android/app/src/main/res/raw/power_wide_orbit.ogg differ diff --git a/android/app/src/main/res/raw/power_zip_split.ogg b/android/app/src/main/res/raw/power_zip_split.ogg new file mode 100644 index 0000000..e771140 Binary files /dev/null and b/android/app/src/main/res/raw/power_zip_split.ogg differ 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('