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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user