The arcade redesigned, and Orb-Man (from 0.108.24)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,452 @@
|
||||
package tv.orbit.classic
|
||||
|
||||
import android.view.KeyEvent
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
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.StrokeCap
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalWindowInfo
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.tv.material3.Text
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.sin
|
||||
|
||||
/**
|
||||
* Orb-Man (Paul, 5 November 2026): a maze chase starring the Tots mascot. Clear every star from Orbit's own maze while four
|
||||
* space rocks give chase; a power hexagon makes the rocks take fright, the screen glows, and for a while they can be caught.
|
||||
* Each cleared maze is a level, a little quicker than the last. The game itself has no screen in it, so it can be tested.
|
||||
*/
|
||||
internal const val ORB_TICK_MS = 20L
|
||||
|
||||
/** The maze's left half and middle column; the right half mirrors it. # wall, . star, o power hexagon, - the rocks' door, H their den, P start. */
|
||||
private val OrbMazeLeft = listOf(
|
||||
"############",
|
||||
"#o.........#",
|
||||
"#.###.####.#",
|
||||
"#...........",
|
||||
"#.###.#.#...",
|
||||
"#.....#..##-",
|
||||
"...#..#..#HH",
|
||||
"#.#...#..###",
|
||||
"#.#.###.....",
|
||||
"#...#...##.#",
|
||||
"#.###.#.##..",
|
||||
"#o....#....P",
|
||||
"############")
|
||||
|
||||
internal val OrbMazeRows: List<String> = OrbMazeLeft.map { left -> left + left.substring(0, left.length - 1).reversed().replace('P', '.') }
|
||||
|
||||
internal data class OrbPos(val x: Int, val y: Int)
|
||||
|
||||
internal enum class OrbDir(val dx: Int, val dy: Int) {
|
||||
Up(0, -1), Left(-1, 0), Down(0, 1), Right(1, 0);
|
||||
val opposite get() = when (this) { Up -> Down; Down -> Up; Left -> Right; Right -> Left }
|
||||
}
|
||||
|
||||
internal class OrbMaze(val rows: List<String> = OrbMazeRows) {
|
||||
val width = rows[0].length
|
||||
val height = rows.size
|
||||
private fun at(x: Int, y: Int) = rows.getOrNull(y)?.getOrNull(Math.floorMod(x, width)) ?: '#'
|
||||
fun wall(p: OrbPos) = at(p.x, p.y) == '#'
|
||||
fun door(p: OrbPos) = at(p.x, p.y) == '-'
|
||||
fun den(p: OrbPos) = at(p.x, p.y) == 'H'
|
||||
/** One step on, through the side tunnels where a row is open at both ends. */
|
||||
fun step(p: OrbPos, d: OrbDir) = OrbPos(Math.floorMod(p.x + d.dx, width), p.y + d.dy)
|
||||
fun cells(kind: Char) = rows.flatMapIndexed { y, row -> row.mapIndexedNotNull { x, c -> if (c == kind) OrbPos(x, y) else null } }
|
||||
val start get() = cells('P').single()
|
||||
val denCells get() = cells('H')
|
||||
val doorCell get() = cells('-').single()
|
||||
/** Just outside the door, where a rock heads to leave the den. */
|
||||
val outside get() = OrbPos(doorCell.x, doorCell.y - 1)
|
||||
}
|
||||
|
||||
/** Where a moving thing is, and where it came from this step, for drawing it in between. */
|
||||
internal data class OrbMover(val pos: OrbPos, val from: OrbPos, val dir: OrbDir, val movedAt: Int = 0)
|
||||
|
||||
internal data class OrbChaser(val mover: OrbMover, val home: OrbPos, val releaseAt: Int,
|
||||
/** Frightened by a power hexagon: slow, wandering, and can be caught. */
|
||||
val scared: Boolean = false,
|
||||
/** Caught: only its eyes, hurrying home to the den. */
|
||||
val returning: Boolean = false,
|
||||
/** Out of the den and on the loose. */
|
||||
val out: Boolean = false)
|
||||
|
||||
internal enum class OrbEvent { Star, Power, Catch, Caught, Cleared }
|
||||
|
||||
internal data class OrbGame(
|
||||
val maze: OrbMaze, val player: OrbMover, val wanted: OrbDir, val chasers: List<OrbChaser>,
|
||||
val stars: Set<OrbPos>, val powers: Set<OrbPos>, val score: Int = 0, val lives: Int = 3, val level: Int = 1,
|
||||
val tick: Int = 0,
|
||||
/** Ticks of fright left. */
|
||||
val powerLeft: Int = 0,
|
||||
/** Rocks caught on this hexagon, for the doubling score. */
|
||||
val chain: Int = 0,
|
||||
/** A moment's pause before play: the start, after being caught, and between levels. */
|
||||
val readyLeft: Int = READY_TICKS,
|
||||
val alive: Boolean = true,
|
||||
val events: List<OrbEvent> = emptyList()) {
|
||||
|
||||
val playerEvery get() = (8 - (level - 1) / 2).coerceAtLeast(6)
|
||||
val chaserEvery get() = (10 - (level - 1)).coerceAtLeast(7)
|
||||
val powerTicks get() = (400 - 40 * (level - 1)).coerceAtLeast(150)
|
||||
val powered get() = powerLeft > 0
|
||||
|
||||
fun turn(d: OrbDir) = copy(wanted = d)
|
||||
|
||||
private fun open(p: OrbPos) = !maze.wall(p) && !maze.door(p) && !maze.den(p)
|
||||
|
||||
fun step(random: java.util.Random): OrbGame {
|
||||
if (!alive) return this
|
||||
if (readyLeft > 0) return copy(readyLeft = readyLeft - 1, tick = tick + 1, events = emptyList())
|
||||
val t = tick + 1
|
||||
val events = mutableListOf<OrbEvent>()
|
||||
var g = copy(tick = t, events = emptyList(), powerLeft = (powerLeft - 1).coerceAtLeast(0))
|
||||
if (g.powerLeft == 0 && powerLeft > 0) g = g.copy(chasers = g.chasers.map { it.copy(scared = false) }, chain = 0)
|
||||
// Orb-Man: the turn you asked for as soon as there's room for it, otherwise straight on until a wall.
|
||||
if (t - player.movedAt >= playerEvery) {
|
||||
val dir = listOf(wanted, player.dir).firstOrNull { open(maze.step(player.pos, it)) }
|
||||
val moved = if (dir != null) OrbMover(maze.step(player.pos, dir), player.pos, dir, t) else player.copy(from = player.pos, movedAt = t)
|
||||
var stars = g.stars; var powers = g.powers; var score = g.score; var chasers = g.chasers; var powerLeft = g.powerLeft; var chain = g.chain
|
||||
if (moved.pos in stars) { stars = stars - moved.pos; score += 10; events += OrbEvent.Star }
|
||||
if (moved.pos in powers) {
|
||||
powers = powers - moved.pos; score += 50; events += OrbEvent.Power
|
||||
powerLeft = powerTicks; chain = 0
|
||||
// The rocks take fright and turn about.
|
||||
chasers = chasers.map { c -> if (c.returning || !c.out) c else c.copy(scared = true, mover = c.mover.copy(dir = c.mover.dir.opposite)) }
|
||||
}
|
||||
g = g.copy(player = moved, stars = stars, powers = powers, score = score, chasers = chasers, powerLeft = powerLeft, chain = chain)
|
||||
}
|
||||
g = g.copy(chasers = g.chasers.mapIndexed { i, c -> g.moveChaser(i, c, t, random) })
|
||||
// Caught, or catching: on the same square, or passing through each other.
|
||||
var result = g
|
||||
for ((i, c) in g.chasers.withIndex()) {
|
||||
if (c.returning || !c.out) continue
|
||||
val meet = c.mover.pos == g.player.pos || (c.mover.pos == g.player.from && c.mover.from == g.player.pos)
|
||||
if (!meet) continue
|
||||
if (c.scared) {
|
||||
events += OrbEvent.Catch
|
||||
result = result.copy(score = result.score + (200 shl result.chain).coerceAtMost(1600), chain = result.chain + 1,
|
||||
chasers = result.chasers.mapIndexed { j, o -> if (j == i) o.copy(scared = false, returning = true) else o })
|
||||
} else {
|
||||
events += OrbEvent.Caught
|
||||
val lives = result.lives - 1
|
||||
return if (lives <= 0) result.copy(lives = 0, alive = false, events = events)
|
||||
else result.restart(lives = lives).copy(events = events)
|
||||
}
|
||||
}
|
||||
if (result.stars.isEmpty() && result.powers.isEmpty()) {
|
||||
events += OrbEvent.Cleared
|
||||
return start(maze, result.level + 1, result.score, result.lives).copy(events = events)
|
||||
}
|
||||
return result.copy(events = events)
|
||||
}
|
||||
|
||||
/** Everyone back to their places for another go; the stars stay as they are. */
|
||||
private fun restart(lives: Int) = copy(lives = lives, player = OrbMover(maze.start, maze.start, OrbDir.Left, tick), wanted = OrbDir.Left,
|
||||
chasers = chasersAtHome(maze, tick), powerLeft = 0, chain = 0, readyLeft = READY_TICKS)
|
||||
|
||||
private fun moveChaser(index: Int, c: OrbChaser, t: Int, random: java.util.Random): OrbChaser {
|
||||
val every = when { c.returning -> 4; c.scared -> chaserEvery + 6; !c.out -> chaserEvery + 4; else -> chaserEvery }
|
||||
if (t - c.mover.movedAt < every) return c
|
||||
if (!c.out && !c.returning && t < c.releaseAt) return c
|
||||
val target = when {
|
||||
c.returning -> if (maze.den(c.mover.pos) || maze.door(c.mover.pos)) c.home else maze.doorCell
|
||||
!c.out -> maze.outside
|
||||
else -> chaseTarget(index, c)
|
||||
}
|
||||
// Rocks use the door going out and coming home; once out, the den is closed to them.
|
||||
fun passable(p: OrbPos) = !maze.wall(p) && (c.returning || !c.out || (!maze.door(p) && !maze.den(p)))
|
||||
val choices = OrbDir.entries.filter { passable(maze.step(c.mover.pos, it)) }
|
||||
val forward = choices.filter { it != c.mover.dir.opposite }.ifEmpty { choices }
|
||||
if (forward.isEmpty()) return c.copy(mover = c.mover.copy(from = c.mover.pos, movedAt = t))
|
||||
val dir = if (c.scared) forward[random.nextInt(forward.size)]
|
||||
else forward.minBy { d -> maze.step(c.mover.pos, d).let { (it.x - target.x) * (it.x - target.x) + (it.y - target.y) * (it.y - target.y) } }
|
||||
val next = maze.step(c.mover.pos, dir)
|
||||
val mover = OrbMover(next, c.mover.pos, dir, t)
|
||||
return when {
|
||||
c.returning && next == c.home -> c.copy(mover = mover, returning = false, out = false, releaseAt = t + 60)
|
||||
!c.out && next == maze.outside -> c.copy(mover = mover, out = true, scared = false)
|
||||
else -> c.copy(mover = mover)
|
||||
}
|
||||
}
|
||||
|
||||
/** Each rock has its own way of hunting. */
|
||||
private fun chaseTarget(index: Int, c: OrbChaser): OrbPos {
|
||||
val p = player.pos; val d = player.dir
|
||||
return when (index) {
|
||||
// Straight for Orb-Man.
|
||||
0 -> p
|
||||
// Cuts in three squares ahead of him.
|
||||
1 -> OrbPos(p.x + 3 * d.dx, p.y + 3 * d.dy)
|
||||
// Hunts him close up, and drifts back to its corner when he's far away.
|
||||
2 -> if (abs(c.mover.pos.x - p.x) + abs(c.mover.pos.y - p.y) > 9) OrbPos(maze.width - 2, maze.height - 2) else p
|
||||
// Guards the stars nearest to him.
|
||||
else -> stars.minByOrNull { abs(it.x - p.x) + abs(it.y - p.y) } ?: p
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val READY_TICKS = 100
|
||||
fun chasersAtHome(maze: OrbMaze, tick: Int): List<OrbChaser> {
|
||||
val den = maze.denCells
|
||||
// The first starts just outside; the others leave the den one after another.
|
||||
return List(4) { i ->
|
||||
if (i == 0) OrbChaser(OrbMover(maze.outside, maze.outside, OrbDir.Left, tick), den[1], tick, out = true)
|
||||
else { val home = den[(i - 1) % den.size]; OrbChaser(OrbMover(home, home, OrbDir.Up, tick), home, tick + READY_TICKS + i * 150) }
|
||||
}
|
||||
}
|
||||
fun start(maze: OrbMaze = OrbMaze(), level: Int = 1, score: Int = 0, lives: Int = 3): OrbGame =
|
||||
OrbGame(maze, OrbMover(maze.start, maze.start, OrbDir.Left), OrbDir.Left, chasersAtHome(maze, 0), maze.cells('.').toSet(), maze.cells('o').toSet(),
|
||||
score = score, lives = lives, level = level)
|
||||
}
|
||||
}
|
||||
|
||||
private val Marquee = FontFamily(Font(R.font.anton_regular))
|
||||
private val Honey = Color(0xFFFFD84A)
|
||||
internal val OrbChaserColours = listOf(Color(0xFFFF4FA3), Color(0xFF35D6F2), Color(0xFFFF8A3D), Color(0xFFA77BFF))
|
||||
private val OrbWall = Color(0xFF2E4FE0)
|
||||
private val OrbWallLight = Color(0xFF7D95FF)
|
||||
private val OrbFloor = Color(0xFF0A1850)
|
||||
private val OrbStar = Color(0xFFFFE066)
|
||||
private val Mascot = Color(0xFFF7A928)
|
||||
private val Outline = Color(0xFF14213D)
|
||||
|
||||
/** The Tots mascot: a smiling orange planet in its ring, looking where it's going. [chomp] opens its smile, 0 to 1. */
|
||||
internal fun DrawScope.drawOrbMascot(centre: Offset, r: Float, dir: OrbDir = OrbDir.Right, chomp: Float = 0f) {
|
||||
// The ring behind, then the planet, then the ring's front.
|
||||
val ringW = r * 2.7f; val ringH = r * .9f
|
||||
val ringTop = Offset(centre.x - ringW / 2, centre.y - ringH / 2 + r * .15f)
|
||||
drawArc(Color.White, 180f, 180f, false, ringTop, Size(ringW, ringH), style = Stroke(r * .2f))
|
||||
drawCircle(Outline, r * 1.08f, centre)
|
||||
drawCircle(Mascot, r, centre)
|
||||
drawCircle(Color.White.copy(alpha = .25f), r * .32f, centre + Offset(-r * .4f, -r * .45f))
|
||||
val look = Offset(dir.dx * r * .16f, dir.dy * r * .14f)
|
||||
drawCircle(Outline, r * .13f, centre + Offset(-r * .33f, -r * .2f) + look)
|
||||
drawCircle(Outline, r * .13f, centre + Offset(r * .33f, -r * .2f) + look)
|
||||
val mouth = r * (.42f + .2f * chomp)
|
||||
drawArc(Outline, 15f, 150f, chomp > .5f, Offset(centre.x - mouth + look.x, centre.y - mouth * .55f + look.y), Size(mouth * 2, mouth * (1.1f + chomp * .5f)),
|
||||
style = Stroke(r * .14f, cap = StrokeCap.Round))
|
||||
drawArc(Color.White, 0f, 180f, false, ringTop, Size(ringW, ringH), style = Stroke(r * .2f))
|
||||
drawArc(Outline, 0f, 180f, false, ringTop - Offset(0f, -r * .1f), Size(ringW, ringH), style = Stroke(r * .05f))
|
||||
}
|
||||
|
||||
/** A space rock: lumpy, with eyes that follow its way. Frightened it turns pale and wobbles; caught, only its eyes go home. */
|
||||
internal fun DrawScope.drawOrbChaser(centre: Offset, r: Float, colour: Color, dir: OrbDir, scared: Boolean, returning: Boolean, blink: Boolean, wobble: Float) {
|
||||
if (!returning) {
|
||||
val body = if (scared) (if (blink) Color(0xFFE8F0FF) else Color(0xFF4D6BFF)) else colour
|
||||
val rock = Path().apply {
|
||||
val n = 9
|
||||
for (i in 0..n) {
|
||||
val a = 2 * PI * i / n
|
||||
val bump = 1f + .12f * sin(a * 3 + wobble).toFloat()
|
||||
val x = centre.x + (r * bump * kotlin.math.cos(a)).toFloat(); val y = centre.y + (r * bump * sin(a)).toFloat()
|
||||
if (i == 0) moveTo(x, y) else lineTo(x, y)
|
||||
}
|
||||
close()
|
||||
}
|
||||
drawPath(rock, body)
|
||||
drawCircle(Color.White.copy(alpha = .18f), r * .3f, centre + Offset(-r * .35f, -r * .4f))
|
||||
drawCircle(Color.Black.copy(alpha = .12f), r * .18f, centre + Offset(r * .4f, r * .35f))
|
||||
if (scared) {
|
||||
// A worried wavy mouth instead of eyes that hunt.
|
||||
val w = r * .9f; val y = centre.y + r * .3f
|
||||
val p = Path().apply { moveTo(centre.x - w / 2, y); for (k in 1..4) lineTo(centre.x - w / 2 + k * w / 4, y + if (k % 2 == 1) -r * .14f else 0f) }
|
||||
drawPath(p, if (blink) Color(0xFF4D6BFF) else Color.White, style = Stroke(r * .1f))
|
||||
drawCircle(if (blink) Color(0xFF4D6BFF) else Color.White, r * .1f, centre + Offset(-r * .3f, -r * .15f))
|
||||
drawCircle(if (blink) Color(0xFF4D6BFF) else Color.White, r * .1f, centre + Offset(r * .3f, -r * .15f))
|
||||
return
|
||||
}
|
||||
}
|
||||
val look = Offset(dir.dx * r * .14f, dir.dy * r * .14f)
|
||||
for (side in listOf(-1f, 1f)) {
|
||||
val eye = centre + Offset(side * r * .36f, -r * .12f)
|
||||
drawCircle(Color.White, r * .27f, eye)
|
||||
drawCircle(Color(0xFF14213D), r * .13f, eye + look)
|
||||
}
|
||||
}
|
||||
|
||||
/** The maze and everyone in it. [phase] is how far between ticks we are, for smooth movement; [glow] the power glow, 0 to 1. */
|
||||
internal fun DrawScope.drawOrbField(game: OrbGame, phase: Float = 0f, glow: Float = 0f, timeMs: Long = 0L) {
|
||||
val maze = game.maze
|
||||
val cell = minOf(size.width / maze.width, size.height / maze.height)
|
||||
val origin = Offset((size.width - cell * maze.width) / 2, (size.height - cell * maze.height) / 2)
|
||||
fun centre(p: OrbPos) = origin + Offset((p.x + .5f) * cell, (p.y + .5f) * cell)
|
||||
drawRect(OrbFloor, origin, Size(cell * maze.width, cell * maze.height))
|
||||
// A faint check in the floor.
|
||||
for (y in 0 until maze.height) for (x in 0 until maze.width) if ((x + y) % 2 == 0) drawRect(Color.White.copy(alpha = .025f), origin + Offset(x * cell, y * cell), Size(cell, cell))
|
||||
if (glow > 0f) drawRect(Brush.radialGradient(listOf(Color(0xFF6EF0FF).copy(alpha = .28f * glow), Color(0xFF6EF0FF).copy(alpha = .06f * glow)),
|
||||
centre(game.player.pos), maxOf(size.width, size.height) * .7f), origin, Size(cell * maze.width, cell * maze.height))
|
||||
// Walls as glowing pipes: a joint on every wall square, joined to its neighbours.
|
||||
val walls = (0 until maze.height).flatMap { y -> (0 until maze.width).map { x -> OrbPos(x, y) } }.filter { maze.wall(it) }
|
||||
val wallColour = if (glow > 0f) androidx.compose.ui.graphics.lerp(OrbWall, Color(0xFF29C4E8), glow * .6f) else OrbWall
|
||||
for ((width, colour) in listOf(cell * .46f to wallColour, cell * .16f to OrbWallLight)) for (w in walls) {
|
||||
val c = centre(w)
|
||||
drawCircle(colour, width / 2, c)
|
||||
for (d in listOf(OrbDir.Right, OrbDir.Down)) {
|
||||
val n = OrbPos(w.x + d.dx, w.y + d.dy)
|
||||
if (n.x < maze.width && n.y < maze.height && maze.wall(n)) drawLine(colour, c, centre(n), width)
|
||||
}
|
||||
}
|
||||
drawLine(Color(0xFFFF9BD0), centre(maze.doorCell) - Offset(cell * .45f, 0f), centre(maze.doorCell) + Offset(cell * .45f, 0f), cell * .12f)
|
||||
for (s in game.stars) drawCircle(OrbStar, cell * .1f, centre(s))
|
||||
// The power hexagons pulse gently through Orbit's colours.
|
||||
val pulse = .85f + .15f * sin(timeMs / 260.0).toFloat()
|
||||
game.powers.forEachIndexed { i, p ->
|
||||
val hex = Path().apply {
|
||||
for (k in 0..5) { val a = Math.toRadians(60.0 * k + 30); val at = centre(p) + Offset((cell * .32f * pulse * kotlin.math.cos(a)).toFloat(), (cell * .32f * pulse * sin(a)).toFloat()); if (k == 0) moveTo(at.x, at.y) else lineTo(at.x, at.y) }
|
||||
close()
|
||||
}
|
||||
drawPath(hex, listOf(Color(0xFF6EF0FF), Color(0xFFFF7AC6), Color(0xFFB79BFF), Color(0xFF7DFFB0))[i % 4])
|
||||
}
|
||||
fun drawn(m: OrbMover, every: Int): Offset {
|
||||
val a = centre(m.from); val b = centre(m.pos)
|
||||
if (abs(m.pos.x - m.from.x) > 1) return b
|
||||
val f = ((game.tick - m.movedAt + phase) / every).coerceIn(0f, 1f)
|
||||
return a + (b - a) * f
|
||||
}
|
||||
val blinking = game.powered && game.powerLeft < 100 && (game.powerLeft / 12) % 2 == 0
|
||||
game.chasers.forEachIndexed { i, c ->
|
||||
val every = when { c.returning -> 4; c.scared -> game.chaserEvery + 6; !c.out -> game.chaserEvery + 4; else -> game.chaserEvery }
|
||||
drawOrbChaser(drawn(c.mover, every), cell * .42f, OrbChaserColours[i], c.mover.dir, c.scared, c.returning, blinking, timeMs / 140f + i)
|
||||
}
|
||||
val chomp = if (game.readyLeft > 0) 0f else (.5f + .5f * sin(timeMs / 70.0).toFloat())
|
||||
drawOrbMascot(drawn(game.player, game.playerEvery), cell * .38f, game.player.dir, chomp)
|
||||
}
|
||||
|
||||
/** Short sounds made on the box itself, quiet and original: a pip for each star, a rising swell for power, and so on. */
|
||||
private class OrbSounds {
|
||||
private val rate = 22_050
|
||||
private fun tone(ms: Int, from: Double, to: Double, volume: Double): android.media.AudioTrack {
|
||||
val n = rate * ms / 1000
|
||||
val pcm = ShortArray(n) { i ->
|
||||
val t = i.toDouble() / rate
|
||||
val f = from + (to - from) * i / n
|
||||
val env = minOf(1.0, i / (rate * .005)) * minOf(1.0, (n - i) / (rate * .02))
|
||||
(sin(2 * PI * f * t) * env * volume * Short.MAX_VALUE).toInt().toShort()
|
||||
}
|
||||
return android.media.AudioTrack.Builder()
|
||||
.setAudioAttributes(android.media.AudioAttributes.Builder().setUsage(android.media.AudioAttributes.USAGE_GAME).setContentType(android.media.AudioAttributes.CONTENT_TYPE_SONIFICATION).build())
|
||||
.setAudioFormat(android.media.AudioFormat.Builder().setEncoding(android.media.AudioFormat.ENCODING_PCM_16BIT).setSampleRate(rate).setChannelMask(android.media.AudioFormat.CHANNEL_OUT_MONO).build())
|
||||
.setTransferMode(android.media.AudioTrack.MODE_STATIC).setBufferSizeInBytes(n * 2).build().apply { write(pcm, 0, n) }
|
||||
}
|
||||
private val tracks = runCatching {
|
||||
mapOf("starA" to tone(45, 620.0, 660.0, .12), "starB" to tone(45, 780.0, 820.0, .12), OrbEvent.Power.name to tone(380, 280.0, 900.0, .22),
|
||||
OrbEvent.Catch.name to tone(260, 500.0, 1200.0, .22), OrbEvent.Caught.name to tone(900, 720.0, 140.0, .22), OrbEvent.Cleared.name to tone(600, 520.0, 1040.0, .2))
|
||||
}.getOrDefault(emptyMap())
|
||||
private var flip = false
|
||||
fun play(event: OrbEvent) {
|
||||
val key = if (event == OrbEvent.Star) { flip = !flip; if (flip) "starA" else "starB" } else event.name
|
||||
val track = tracks[key] ?: return
|
||||
runCatching { track.stop(); track.reloadStaticData(); track.play() }
|
||||
}
|
||||
fun release() = tracks.values.forEach { runCatching { it.release() } }
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun OrbManScreen(best: Int, onBest: (Int) -> Unit, onLeave: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val settings = remember { OrbitSettings(context) }
|
||||
val calm = remember { settings.reduceMotion }
|
||||
var soundOn by remember { mutableStateOf(settings.orbManSound) }
|
||||
val sounds = remember { OrbSounds() }
|
||||
DisposableEffect(Unit) { onDispose { sounds.release() } }
|
||||
val random = remember { java.util.Random() }
|
||||
var game by remember { mutableStateOf(OrbGame.start()) }
|
||||
var paused by remember { mutableStateOf(false) }
|
||||
var phase by remember { mutableFloatStateOf(0f) }
|
||||
var clock by remember { mutableLongStateOf(0L) }
|
||||
val focus = remember { FocusRequester() }
|
||||
val menuFocus = remember { FocusRequester() }
|
||||
val windowFocused = LocalWindowInfo.current.isWindowFocused
|
||||
LaunchedEffect(windowFocused) { if (!windowFocused && game.alive) paused = true }
|
||||
// One frame clock: whole ticks move the game, what's left over places everyone smoothly in between.
|
||||
LaunchedEffect(paused, game.alive) {
|
||||
var last = -1L; var carry = 0L
|
||||
while (!paused && game.alive) {
|
||||
withFrameNanos { now ->
|
||||
val ms = now / 1_000_000
|
||||
if (last >= 0) carry += (ms - last).coerceAtMost(100)
|
||||
last = ms
|
||||
clock = ms
|
||||
var g = game
|
||||
while (carry >= ORB_TICK_MS) {
|
||||
carry -= ORB_TICK_MS
|
||||
g = g.step(random)
|
||||
if (soundOn) g.events.forEach(sounds::play)
|
||||
if (!g.alive) break
|
||||
}
|
||||
game = g
|
||||
phase = carry.toFloat() / ORB_TICK_MS
|
||||
}
|
||||
}
|
||||
if (!game.alive && game.score > best) onBest(game.score)
|
||||
}
|
||||
LaunchedEffect(paused, game.alive) { if (paused || !game.alive) menuFocus.requestFocus() else focus.requestFocus() }
|
||||
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(Navy).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) {
|
||||
KeyEvent.KEYCODE_DPAD_UP -> OrbDir.Up; KeyEvent.KEYCODE_DPAD_DOWN -> OrbDir.Down
|
||||
KeyEvent.KEYCODE_DPAD_LEFT -> OrbDir.Left; KeyEvent.KEYCODE_DPAD_RIGHT -> OrbDir.Right
|
||||
else -> null
|
||||
}
|
||||
if (dir != null) game = game.turn(dir)
|
||||
if (native.keyCode == KeyEvent.KEYCODE_DPAD_CENTER || native.keyCode == KeyEvent.KEYCODE_ENTER) { paused = true; return@onPreviewKeyEvent true }
|
||||
dir != null
|
||||
}, horizontalArrangement = Arrangement.spacedBy(20.dp)) {
|
||||
Column(Modifier.width(170.dp).fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text("ORB-MAN", color = Honey, fontFamily = Marquee, fontSize = 34.sp)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text("SCORE", color = Muted, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 2.sp)
|
||||
Text(game.score.toString(), color = Color.White, fontFamily = Marquee, fontSize = 32.sp)
|
||||
Text("BEST", color = Muted, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 2.sp)
|
||||
Text(maxOf(best, game.score).toString(), color = Color.White, fontFamily = Marquee, fontSize = 32.sp)
|
||||
Text("LEVEL ${game.level}", color = Muted, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 2.sp)
|
||||
Canvas(Modifier.height(30.dp).fillMaxWidth()) { repeat(game.lives) { drawOrbMascot(Offset(16f + it * 44f, size.height / 2), 11f) } }
|
||||
Spacer(Modifier.weight(1f))
|
||||
Text("◄►▲▼ Steer", color = Muted, fontSize = 13.sp)
|
||||
Text("OK or Back Pause", color = Muted, fontSize = 13.sp)
|
||||
}
|
||||
Box(Modifier.weight(1f).fillMaxHeight(), contentAlignment = Alignment.Center) {
|
||||
Canvas(Modifier.fillMaxSize()) { drawOrbField(game, phase, glow, clock) }
|
||||
if (game.alive && game.readyLeft > 0 && !paused) Text(if (game.tick == 0 && game.level == 1) "READY!" else if (game.score > 0 && game.stars.size + game.powers.size == game.maze.cells('.').size + game.maze.cells('o').size) "LEVEL ${game.level}" else "READY!",
|
||||
Modifier.background(Navy.copy(alpha = .8f), RoundedCornerShape(6.dp)).padding(horizontal = 16.dp, vertical = 6.dp), color = Honey, fontFamily = Marquee, fontSize = 26.sp)
|
||||
if (paused || !game.alive) Column(Modifier.background(Navy, RoundedCornerShape(6.dp)).border(2.dp, Honey, RoundedCornerShape(6.dp)).padding(20.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(if (game.alive) "PAUSED" else "GAME OVER", color = Honey, fontFamily = Marquee, fontSize = 30.sp)
|
||||
if (!game.alive) Text(if (game.score > best) "New best: ${game.score}" else "Score: ${game.score} · level ${game.level}", color = Color.White, fontSize = 16.sp)
|
||||
OrbitButton(if (game.alive) "Carry on" else "Play again", Modifier.width(200.dp).focusRequester(menuFocus), onClick = {
|
||||
if (!game.alive) game = OrbGame.start()
|
||||
paused = false
|
||||
})
|
||||
OrbitButton("Sound: ${if (soundOn) "On" else "Off"}", Modifier.width(200.dp), onClick = { soundOn = !soundOn; settings.orbManSound = soundOn })
|
||||
OrbitButton("Leave the game", Modifier.width(200.dp), onClick = onLeave)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -301,25 +301,74 @@ private fun OrbitHiveScreen(onLeave: () -> Unit) {
|
||||
}, onRelease = { it.destroy() })
|
||||
}
|
||||
|
||||
private enum class ArcadeGame(val title: String, val blurb: String) {
|
||||
Snake("SNAKE", "Eat, grow, and don't hit the walls or yourself."),
|
||||
Hive("ORBITHIVE", "Fire flowers into the honeycomb, and clear the hive before it reaches you."),
|
||||
Blocks("BLOCK DROP", "Turn and drop the falling blocks, and clear full rows before the stack reaches the top."),
|
||||
Bricks("BRICK BREAK", "Keep the ball in play with your paddle and break every brick in the wall."),
|
||||
Pairs("PAIRS", "Turn two cards at a time and find every pair of Orbit's channels in as few moves as you can."),
|
||||
Patience("PATIENCE", "The classic card game: build each suit home from ace to king.")
|
||||
private enum class ArcadeGame(val title: String, val tagline: String, val blurb: String) {
|
||||
OrbMan("Orb-Man", "Maze chase with the Tots mascot", "Chase the stars, dodge the space rocks, clear the maze."),
|
||||
Hive("OrbitHive", "Link, match, clear the hive", "Fire flowers into the honeycomb, and clear the hive before it reaches you."),
|
||||
Snake("Snake", "Grow, turn, beat your best", "Eat, grow, and don't hit the walls or yourself."),
|
||||
Blocks("Block Drop", "Stack the blocks, keep it tidy", "Turn and drop the falling blocks, and clear full rows before the stack reaches the top."),
|
||||
Bricks("Brick Break", "Bounce, break, clear the screen", "Keep the ball in play with your paddle and break every brick in the wall."),
|
||||
Pairs("Pairs", "Find the matching pairs", "Turn two cards at a time and find every pair of Orbit's channels in as few moves as you can."),
|
||||
Patience("Patience", "Classic solitaire on Orbit", "The classic card game: build each suit home from ace to king.")
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GameTile(game: ArcadeGame, focus: FocusRequester?, nav: FocusRequester?, onFocus: () -> Unit, onPlay: () -> Unit) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
Box((if (focus != null) Modifier.focusRequester(focus) else Modifier).size(118.dp).focusProperties { if (nav != null) up = nav }.onFocusChanged { focused = it.isFocused; if (it.isFocused) onFocus() }.clickable(onClick = onPlay), contentAlignment = Alignment.Center) {
|
||||
Canvas(Modifier.fillMaxSize()) { drawPath(hexagon(center, size.minDimension / 2), if (focused) Yellow else Panel) }
|
||||
Text(when (game) { ArcadeGame.Snake -> "Snake"; ArcadeGame.Hive -> "OrbitHive"; ArcadeGame.Blocks -> "Block Drop"; ArcadeGame.Bricks -> "Brick Break"; ArcadeGame.Pairs -> "Pairs"; ArcadeGame.Patience -> "Patience" }, color = if (focused) Navy else Color.White, fontSize = 18.sp, fontWeight = FontWeight.SemiBold)
|
||||
/** The newest game, flagged as the arcade's pick. */
|
||||
private val ArcadePick = ArcadeGame.OrbMan
|
||||
|
||||
/** Each game's little picture, for its card. */
|
||||
private fun androidx.compose.ui.graphics.drawscope.DrawScope.gameIcon(game: ArcadeGame) {
|
||||
val s = size.minDimension; val c = center
|
||||
when (game) {
|
||||
ArcadeGame.OrbMan -> drawOrbMascot(c, s * .28f)
|
||||
ArcadeGame.Hive -> listOf(Offset(0f, -.2f) to Color(0xFFFFD84A), Offset(-.18f, .12f) to Color(0xFF6EF0FF), Offset(.18f, .12f) to Color(0xFFFF7AC6))
|
||||
.forEach { (o, col) -> drawPath(hexagon(c + Offset(o.x * s, o.y * s), s * .17f), col) }
|
||||
ArcadeGame.Snake -> {
|
||||
listOf(-.32f, -.16f, 0f).forEachIndexed { i, x -> drawCircle(Color(0xFF3DDC97), s * .1f, c + Offset(x * s, (if (i % 2 == 0) .12f else .04f) * s)) }
|
||||
val head = c + Offset(.2f * s, -.06f * s)
|
||||
drawCircle(Color(0xFF3DDC97), s * .14f, head)
|
||||
drawCircle(Color.White, s * .05f, head + Offset(-s * .05f, -s * .03f)); drawCircle(Color.White, s * .05f, head + Offset(s * .05f, -s * .03f))
|
||||
drawCircle(Navy, s * .025f, head + Offset(-s * .05f, -s * .03f)); drawCircle(Navy, s * .025f, head + Offset(s * .05f, -s * .03f))
|
||||
}
|
||||
ArcadeGame.Blocks -> { val b = s * .2f
|
||||
listOf(Offset(0f, -1f), Offset(-1f, 0f), Offset(0f, 0f), Offset(1f, 0f)).forEach { o -> drawRect(Color(0xFF4C7DFF), c + Offset(o.x * b - b / 2 + 1, o.y * b - 1), Size(b - 2, b - 2)) } }
|
||||
ArcadeGame.Bricks -> {
|
||||
for (row in 0..1) for (col in 0..2) drawRect(Color(0xFFFF6BA8), Offset(c.x - s * .4f + col * s * .27f + (row % 2) * s * .1f, c.y - s * .35f + row * s * .16f), Size(s * .24f, s * .12f))
|
||||
drawCircle(Color.White, s * .06f, c + Offset(s * .05f, s * .12f))
|
||||
drawRect(Color(0xFF6EF0FF), Offset(c.x - s * .18f, c.y + s * .3f), Size(s * .36f, s * .06f))
|
||||
}
|
||||
ArcadeGame.Pairs -> for (i in 0..1) {
|
||||
val at = Offset(c.x - s * .36f + i * s * .38f, c.y - s * .3f)
|
||||
drawRoundRect(Color(0xFF1E3E8C), at, Size(s * .34f, s * .5f), androidx.compose.ui.geometry.CornerRadius(s * .04f))
|
||||
drawRoundRect(Color.White, at, Size(s * .34f, s * .5f), androidx.compose.ui.geometry.CornerRadius(s * .04f), style = Stroke(s * .03f))
|
||||
drawCircle(Color(0xFFFFD84A), s * .08f, at + Offset(s * .17f, s * .25f))
|
||||
}
|
||||
ArcadeGame.Patience -> for (i in 0..2) withTransform({ rotate(-18f + i * 18f, c + Offset(0f, s * .3f)) }) {
|
||||
val at = Offset(c.x - s * .15f, c.y - s * .32f)
|
||||
drawRoundRect(Color(0xFFF4F6FB), at, Size(s * .3f, s * .44f), androidx.compose.ui.geometry.CornerRadius(s * .04f))
|
||||
drawRoundRect(Navy, at, Size(s * .3f, s * .44f), androidx.compose.ui.geometry.CornerRadius(s * .04f), style = Stroke(s * .02f))
|
||||
if (i == 2) drawCircle(Navy, s * .06f, at + Offset(s * .15f, s * .2f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The arcade: the chosen game on the left, and a honeycomb of what's here, with room for more. */
|
||||
/** A game on the right: its picture, name and line, focused as on What's On (navy inside a gold ring). */
|
||||
@Composable
|
||||
private fun GameCard(game: ArcadeGame, modifier: Modifier, focus: FocusRequester?, up: FocusRequester?, onFocus: () -> Unit, onPlay: () -> Unit) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
val corner = RoundedCornerShape(8.dp)
|
||||
Row(modifier.then(if (focus != null) Modifier.focusRequester(focus) else Modifier).focusProperties { if (up != null) this.up = up }
|
||||
.onFocusChanged { focused = it.isFocused; if (it.isFocused) onFocus() }
|
||||
.background(if (focused) Navy else Panel, corner).border(if (focused) 3.dp else 1.dp, if (focused) Yellow else Blue, corner)
|
||||
.clickable(onClick = onPlay).padding(horizontal = 10.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Canvas(Modifier.size(42.dp)) { gameIcon(game) }
|
||||
Box(Modifier.width(1.dp).fillMaxHeight(.7f).background(Muted.copy(alpha = .4f)))
|
||||
Column(verticalArrangement = Arrangement.spacedBy(1.dp)) {
|
||||
Text(game.title.uppercase().takeIf { game == ArcadeGame.OrbMan } ?: game.title, color = Color.White, fontFamily = Marquee, fontSize = 18.sp, maxLines = 1)
|
||||
Text(game.tagline, color = Muted, fontSize = 11.sp, maxLines = 2, lineHeight = 13.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The arcade (Paul, 5 November 2026, from his reference): the chosen game large on the left, every game as a card on the right, and what you played last. */
|
||||
@Composable
|
||||
internal fun OrbitArcade(modifier: Modifier, onPlaying: (Boolean) -> Unit) {
|
||||
val context = LocalContext.current
|
||||
@@ -329,10 +378,18 @@ internal fun OrbitArcade(modifier: Modifier, onPlaying: (Boolean) -> Unit) {
|
||||
var brickBest by remember { mutableIntStateOf(settings.brickBreakBest) }
|
||||
var pairsBest by remember { mutableIntStateOf(settings.pairsBest) }
|
||||
var patienceWins by remember { mutableIntStateOf(settings.patienceWins) }
|
||||
var orbBest by remember { mutableIntStateOf(settings.orbManBest) }
|
||||
var recent by remember { mutableStateOf(settings.recentGames.mapNotNull { name -> ArcadeGame.entries.firstOrNull { it.name == name } }) }
|
||||
var playing by remember { mutableStateOf<ArcadeGame?>(null) }
|
||||
var chosen by remember { mutableStateOf(ArcadeGame.Snake) }
|
||||
var chosen by remember { mutableStateOf(ArcadeGame.OrbMan) }
|
||||
fun play(game: ArcadeGame) {
|
||||
recent = (listOf(game) + recent.filter { it != game }).take(6)
|
||||
settings.recentGames = recent.map { it.name }
|
||||
playing = game
|
||||
}
|
||||
DisposableEffect(playing) { onPlaying(playing != null); onDispose { onPlaying(false) } }
|
||||
when (playing) {
|
||||
ArcadeGame.OrbMan -> { OrbManScreen(orbBest, onBest = { orbBest = it; settings.orbManBest = it }, onLeave = { playing = null }); return }
|
||||
ArcadeGame.Snake -> { SnakeScreen(best, onBest = { best = it; settings.snakeBest = it }, onLeave = { playing = null }); return }
|
||||
ArcadeGame.Hive -> { OrbitHiveScreen(onLeave = { playing = null }); return }
|
||||
ArcadeGame.Pairs -> { PairsScreen(pairsBest, onBest = { pairsBest = it; settings.pairsBest = it }, onLeave = { playing = null }); return }
|
||||
@@ -341,6 +398,15 @@ internal fun OrbitArcade(modifier: Modifier, onPlaying: (Boolean) -> Unit) {
|
||||
ArcadeGame.Blocks -> { BlockDropScreen(blockBest, onBest = { blockBest = it; settings.blockDropBest = it }, onLeave = { playing = null }); return }
|
||||
null -> {}
|
||||
}
|
||||
fun bestLine(game: ArcadeGame): String? = when (game) {
|
||||
ArcadeGame.OrbMan -> orbBest.takeIf { it > 0 }?.let { "%,d".format(it) }
|
||||
ArcadeGame.Snake -> best.takeIf { it > 0 }?.let { "%,d".format(it) }
|
||||
ArcadeGame.Blocks -> blockBest.takeIf { it > 0 }?.let { "%,d".format(it) }
|
||||
ArcadeGame.Bricks -> brickBest.takeIf { it > 0 }?.let { "%,d".format(it) }
|
||||
ArcadeGame.Pairs -> pairsBest.takeIf { it > 0 }?.let { "$it moves" }
|
||||
ArcadeGame.Patience -> patienceWins.takeIf { it > 0 }?.let { "$it won" }
|
||||
ArcadeGame.Hive -> null
|
||||
}
|
||||
val first = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) { first.requestFocus() }
|
||||
val nav = LocalNavFocus.current
|
||||
@@ -350,51 +416,90 @@ internal fun OrbitArcade(modifier: Modifier, onPlaying: (Boolean) -> Unit) {
|
||||
BlockGame(board, BlockPiece(2, 1, 4, 9), 0, emptyList())
|
||||
}
|
||||
val brickPreview = remember { BrickGame.start().copy(bricks = bricksFor(2), ballX = 180f, ballY = 150f, paddleX = 150f) }
|
||||
Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(Modifier.fillMaxWidth().weight(1f), horizontalArrangement = Arrangement.spacedBy(20.dp)) {
|
||||
Column(Modifier.weight(1f).fillMaxHeight().background(Panel, RoundedCornerShape(6.dp)).padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Image(painterResource(R.drawable.orbit_arcade_logo), "Orbit Arcade", Modifier.width(256.dp).height(90.dp))
|
||||
Box(Modifier.fillMaxWidth().weight(1f).background(Color(0xFF050E2A))) {
|
||||
if (chosen == ArcadeGame.Snake) SnakeField(preview, Modifier.fillMaxSize())
|
||||
else if (chosen == ArcadeGame.Blocks) BlockField(blockPreview, Modifier.fillMaxSize().padding(8.dp))
|
||||
else if (chosen == ArcadeGame.Bricks) BrickField(brickPreview, Modifier.fillMaxSize().padding(8.dp))
|
||||
else if (chosen == ArcadeGame.Pairs || chosen == ArcadeGame.Patience) Canvas(Modifier.fillMaxSize().padding(14.dp)) {
|
||||
// A spread of cards: face down for Pairs, a dealt column for Patience.
|
||||
val w = size.width / 8; val h = w * 1.36f
|
||||
for (i in 0 until 7) {
|
||||
val down = chosen == ArcadeGame.Pairs && i % 3 != 1
|
||||
val at = Offset(i * w * 1.12f, if (chosen == ArcadeGame.Patience) i * h * .22f else (i % 2) * h * .6f)
|
||||
drawRoundRect(if (down) Panel else Color(0xFFF4F6FB), at, Size(w, h), androidx.compose.ui.geometry.CornerRadius(w * .1f))
|
||||
drawRoundRect(if (down) Blue else BlockColours[i % BlockColours.size], at, Size(w, h), androidx.compose.ui.geometry.CornerRadius(w * .1f), style = Stroke(w * .05f))
|
||||
}
|
||||
val orbPreview = remember {
|
||||
val g = OrbGame.start()
|
||||
val chasers = g.chasers.mapIndexed { i, c -> val p = listOf(OrbPos(5, 1), OrbPos(17, 3), OrbPos(14, 11), OrbPos(3, 8))[i]; c.copy(mover = OrbMover(p, p, OrbDir.Left), out = true) }
|
||||
g.copy(player = OrbMover(OrbPos(9, 8), OrbPos(9, 8), OrbDir.Right), chasers = chasers, stars = g.stars.filter { it.y != 3 || it.x > 9 }.toSet(), readyLeft = 0)
|
||||
}
|
||||
var clock by remember { mutableLongStateOf(0L) }
|
||||
LaunchedEffect(chosen) { if (chosen == ArcadeGame.OrbMan) while (true) { withFrameNanos { clock = it / 1_000_000 } } }
|
||||
Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Row(Modifier.fillMaxWidth().weight(1f), horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
// The chosen game, large.
|
||||
Column(Modifier.weight(1.05f).fillMaxHeight().background(Brush.verticalGradient(listOf(Blue, Panel)), RoundedCornerShape(10.dp)).padding(14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
Column {
|
||||
if (chosen == ArcadePick) Text("ARCADE PICK", Modifier.background(Yellow, RoundedCornerShape(4.dp)).padding(horizontal = 8.dp, vertical = 2.dp), color = Navy, fontSize = 11.sp, fontWeight = FontWeight.Bold)
|
||||
Text(chosen.title.uppercase(), color = Color.White, fontFamily = Marquee, fontSize = 36.sp, maxLines = 1)
|
||||
}
|
||||
else Canvas(Modifier.fillMaxSize()) {
|
||||
val colours = listOf(Color(0xFFE53935), Color(0xFF1E66D0), Color(0xFF2E9D3A), Color(0xFFF4F4F4), Color(0xFFF5A623), Color(0xFF8E44AD))
|
||||
val r = size.height / 9
|
||||
for (row in 0..3) for (col in 0 until (size.width / (r * 1.8f)).toInt()) {
|
||||
val at = Offset(r + col * r * 1.8f + (row % 2) * r * .9f, r + row * r * 1.56f)
|
||||
drawPath(hexagon(at, r * .92f), colours[(col * 3 + row * 5 + col * row) % colours.size])
|
||||
Box(Modifier.width(1.dp).height(40.dp).background(Muted.copy(alpha = .5f)))
|
||||
Text(chosen.tagline, color = Color.White, fontSize = 14.sp, maxLines = 2)
|
||||
}
|
||||
Box(Modifier.fillMaxWidth().weight(1f).background(Color(0xFF050E2A), RoundedCornerShape(8.dp)).border(2.dp, Color(0xFF3D63FF), RoundedCornerShape(8.dp)).padding(6.dp)) {
|
||||
when (chosen) {
|
||||
ArcadeGame.OrbMan -> Canvas(Modifier.fillMaxSize()) { drawOrbField(orbPreview, timeMs = clock) }
|
||||
ArcadeGame.Snake -> SnakeField(preview, Modifier.fillMaxSize())
|
||||
ArcadeGame.Blocks -> BlockField(blockPreview, Modifier.fillMaxSize().padding(4.dp))
|
||||
ArcadeGame.Bricks -> BrickField(brickPreview, Modifier.fillMaxSize().padding(4.dp))
|
||||
ArcadeGame.Pairs, ArcadeGame.Patience -> Canvas(Modifier.fillMaxSize().padding(14.dp)) {
|
||||
// A spread of cards: face down for Pairs, a dealt column for Patience.
|
||||
val w = size.width / 8; val h = w * 1.36f
|
||||
for (i in 0 until 7) {
|
||||
val down = chosen == ArcadeGame.Pairs && i % 3 != 1
|
||||
val at = Offset(i * w * 1.12f, if (chosen == ArcadeGame.Patience) i * h * .22f else (i % 2) * h * .6f)
|
||||
drawRoundRect(if (down) Panel else Color(0xFFF4F6FB), at, Size(w, h), androidx.compose.ui.geometry.CornerRadius(w * .1f))
|
||||
drawRoundRect(if (down) Blue else BlockColours[i % BlockColours.size], at, Size(w, h), androidx.compose.ui.geometry.CornerRadius(w * .1f), style = Stroke(w * .05f))
|
||||
}
|
||||
}
|
||||
ArcadeGame.Hive -> Canvas(Modifier.fillMaxSize()) {
|
||||
val colours = listOf(Color(0xFFE53935), Color(0xFF1E66D0), Color(0xFF2E9D3A), Color(0xFFF4F4F4), Color(0xFFF5A623), Color(0xFF8E44AD))
|
||||
val r = size.height / 9
|
||||
for (row in 0..3) for (col in 0 until (size.width / (r * 1.8f)).toInt()) {
|
||||
val at = Offset(r + col * r * 1.8f + (row % 2) * r * .9f, r + row * r * 1.56f)
|
||||
drawPath(hexagon(at, r * .92f), colours[(col * 3 + row * 5 + col * row) % colours.size])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(chosen.title, color = Honey, fontFamily = Marquee, fontSize = 30.sp)
|
||||
Text(if (chosen == ArcadeGame.Snake && best > 0) "Your best: $best" else if (chosen == ArcadeGame.Blocks && blockBest > 0) "Your best: $blockBest" else if (chosen == ArcadeGame.Bricks && brickBest > 0) "Your best: $brickBest"
|
||||
else if (chosen == ArcadeGame.Pairs && pairsBest > 0) "Your best: $pairsBest moves" else if (chosen == ArcadeGame.Patience && patienceWins > 0) "Games won: $patienceWins" else chosen.blurb, color = Color.White, fontSize = 15.sp, maxLines = 2)
|
||||
Text(chosen.blurb, color = Color.White, fontSize = 14.sp, maxLines = 2)
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
bestLine(chosen)?.let { Text(if (chosen == ArcadeGame.Patience) "Games: " else "Your best: ", color = Color.White, fontSize = 15.sp); Text(it, color = Yellow, fontFamily = Marquee, fontSize = 20.sp) }
|
||||
?: Text("New to you", color = Muted, fontSize = 15.sp)
|
||||
Box(Modifier.width(1.dp).height(24.dp).background(Muted.copy(alpha = .5f)))
|
||||
Text("OK Play", Modifier.background(Yellow, RoundedCornerShape(5.dp)).padding(horizontal = 12.dp, vertical = 3.dp), color = Navy, fontSize = 15.sp, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
Column(Modifier.width(290.dp).fillMaxHeight(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
GameTile(ArcadeGame.Snake, first, nav, { chosen = ArcadeGame.Snake }, { playing = ArcadeGame.Snake })
|
||||
GameTile(ArcadeGame.Hive, null, nav, { chosen = ArcadeGame.Hive }, { playing = ArcadeGame.Hive })
|
||||
// Every game, two to a row, then what you played last.
|
||||
Column(Modifier.weight(1f).fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(7.dp)) {
|
||||
ArcadeGame.entries.chunked(2).forEachIndexed { row, pair ->
|
||||
Row(Modifier.fillMaxWidth().height(56.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
pair.forEachIndexed { i, game ->
|
||||
GameCard(game, Modifier.weight(1f).fillMaxHeight(), if (row == 0 && i == 0) first else null, if (row == 0) nav else null, { chosen = game }, { play(game) })
|
||||
}
|
||||
if (pair.size == 1) Spacer(Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
// A honeycomb: each row of two sits in the gaps of the one above.
|
||||
Row(Modifier.offset(y = (-18).dp), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
GameTile(ArcadeGame.Blocks, null, null, { chosen = ArcadeGame.Blocks }, { playing = ArcadeGame.Blocks })
|
||||
GameTile(ArcadeGame.Bricks, null, null, { chosen = ArcadeGame.Bricks }, { playing = ArcadeGame.Bricks })
|
||||
}
|
||||
Row(Modifier.offset(y = (-36).dp), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
GameTile(ArcadeGame.Pairs, null, null, { chosen = ArcadeGame.Pairs }, { playing = ArcadeGame.Pairs })
|
||||
GameTile(ArcadeGame.Patience, null, null, { chosen = ArcadeGame.Patience }, { playing = ArcadeGame.Patience })
|
||||
if (recent.isNotEmpty()) {
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Box(Modifier.fillMaxWidth().height(1.dp).background(Muted.copy(alpha = .3f)))
|
||||
Text("RECENTLY PLAYED", color = Color.White, fontFamily = Marquee, fontSize = 16.sp)
|
||||
Row(Modifier.fillMaxWidth().height(46.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
recent.take(3).forEach { game ->
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
val corner = RoundedCornerShape(8.dp)
|
||||
Row(Modifier.weight(1f).fillMaxHeight().onFocusChanged { focused = it.isFocused; if (it.isFocused) chosen = game }
|
||||
.background(if (focused) Navy else Panel, corner).border(if (focused) 3.dp else 0.dp, if (focused) Yellow else Color.Transparent, corner)
|
||||
.clickable { play(game) }.padding(horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Canvas(Modifier.size(30.dp)) { gameIcon(game) }
|
||||
Column {
|
||||
Text(game.title, color = Color.White, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, maxLines = 1)
|
||||
Text(bestLine(game)?.let { "Your best: $it" } ?: "Played recently", color = Muted, fontSize = 10.sp, maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
repeat(3 - minOf(3, recent.size)) { Spacer(Modifier.weight(1f)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,16 @@ internal class OrbitSettings(context: Context) {
|
||||
var patienceWins: Int
|
||||
get() = saved.getInt("patience_wins", 0)
|
||||
set(value) { saved.edit().putInt("patience_wins", value).apply() }
|
||||
var orbManBest: Int
|
||||
get() = saved.getInt("orb_man_best", 0)
|
||||
set(value) { saved.edit().putInt("orb_man_best", value).apply() }
|
||||
var orbManSound: Boolean
|
||||
get() = saved.getBoolean("orb_man_sound", true)
|
||||
set(value) { saved.edit().putBoolean("orb_man_sound", value).apply() }
|
||||
/** The arcade's games, most recently played first, by name. */
|
||||
var recentGames: List<String>
|
||||
get() = saved.getString("recent_games", "").orEmpty().split(',').filter { it.isNotBlank() }
|
||||
set(value) { saved.edit().putString("recent_games", value.joinToString(",")).apply() }
|
||||
var subtitlesOn: Boolean
|
||||
get() = saved.getBoolean("subtitles_on", false)
|
||||
set(value) { saved.edit().putBoolean("subtitles_on", value).apply() }
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package tv.orbit.classic
|
||||
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class OrbManTest {
|
||||
private val maze = OrbMaze()
|
||||
private val random = java.util.Random(5)
|
||||
private fun play(g: OrbGame, ticks: Int): OrbGame { var x = g; repeat(ticks) { x = x.step(random) }; return x }
|
||||
|
||||
@Test fun theMazeIsWholeAndEveryStarCanBeReached() {
|
||||
assertTrue(maze.rows.all { it.length == maze.width })
|
||||
val open = { p: OrbPos -> !maze.wall(p) && !maze.door(p) && !maze.den(p) }
|
||||
val seen = mutableSetOf(maze.start); val queue = ArrayDeque(listOf(maze.start))
|
||||
while (queue.isNotEmpty()) { val p = queue.removeFirst(); for (d in OrbDir.entries) { val n = maze.step(p, d); if (n.y in 0 until maze.height && open(n) && seen.add(n)) queue += n } }
|
||||
assertTrue((maze.cells('.') + maze.cells('o')).all { it in seen })
|
||||
assertEquals(4, maze.cells('o').size)
|
||||
// The rocks' way out of the den leads into the maze.
|
||||
assertTrue(maze.outside in seen)
|
||||
// A tunnel through the side.
|
||||
assertEquals(OrbPos(maze.width - 1, 6), maze.step(OrbPos(0, 6), OrbDir.Left))
|
||||
}
|
||||
|
||||
@Test fun orbManEatsStarsAndTurnsWhenThereIsRoom() {
|
||||
var g = OrbGame.start(maze)
|
||||
g = play(g, OrbGame.READY_TICKS + g.playerEvery)
|
||||
assertEquals(OrbPos(maze.start.x - 1, maze.start.y), g.player.pos)
|
||||
assertEquals(10, g.score); assertTrue(OrbEvent.Star in g.events || g.score == 10)
|
||||
// Up is a wall at (8, 11): he carries on left, and goes up as soon as there's room, at (7, 11).
|
||||
val at = OrbPos(8, 11)
|
||||
g = g.copy(player = OrbMover(at, at, OrbDir.Left, g.tick), wanted = OrbDir.Up)
|
||||
g = play(g, g.playerEvery)
|
||||
assertEquals(OrbPos(7, 11), g.player.pos); assertEquals(OrbDir.Left, g.player.dir)
|
||||
g = play(g, g.playerEvery)
|
||||
assertEquals(OrbPos(7, 10), g.player.pos); assertEquals(OrbDir.Up, g.player.dir)
|
||||
}
|
||||
|
||||
@Test fun aPowerHexagonFrightensTheRocksAndCatchingOneScores() {
|
||||
val g0 = OrbGame.start(maze).copy(readyLeft = 0)
|
||||
val power = maze.cells('o').first { it.y == maze.start.y }
|
||||
// Stand next to a power hexagon, facing it.
|
||||
val beside = OrbPos(power.x + 1, power.y)
|
||||
var g = g0.copy(player = OrbMover(beside, beside, OrbDir.Left, 0), wanted = OrbDir.Left)
|
||||
g = play(g, g.playerEvery)
|
||||
assertEquals(power, g.player.pos)
|
||||
assertTrue(g.powered); assertTrue(OrbEvent.Power in g.events)
|
||||
assertTrue(g.chasers.filter { it.out }.all { it.scared })
|
||||
// A frightened rock on his square is caught, worth 200, then 400.
|
||||
val rock = g.chasers[0].copy(mover = OrbMover(g.player.pos, g.player.pos, OrbDir.Right, g.tick), scared = true, out = true)
|
||||
val next = g.copy(chasers = listOf(rock) + g.chasers.drop(1)).step(random)
|
||||
assertTrue(next.chasers[0].returning); assertEquals(1, next.chain)
|
||||
assertTrue(next.score >= g.score + 200)
|
||||
}
|
||||
|
||||
@Test fun beingCaughtCostsALifeAndTheLastOneEndsIt() {
|
||||
val g = OrbGame.start(maze).copy(readyLeft = 0)
|
||||
val rock = g.chasers[0].copy(mover = OrbMover(g.player.pos, g.player.pos, OrbDir.Left, 0))
|
||||
val after = g.copy(chasers = listOf(rock) + g.chasers.drop(1)).step(random)
|
||||
assertEquals(2, after.lives); assertEquals(maze.start, after.player.pos); assertTrue(after.readyLeft > 0)
|
||||
val last = g.copy(lives = 1, chasers = listOf(rock) + g.chasers.drop(1)).step(random)
|
||||
assertFalse(last.alive)
|
||||
}
|
||||
|
||||
@Test fun clearingTheMazeIsTheNextLevelAndQuicker() {
|
||||
val g = OrbGame.start(maze).copy(readyLeft = 0)
|
||||
val star = OrbPos(maze.start.x - 1, maze.start.y)
|
||||
val next = play(g.copy(stars = setOf(star), powers = emptySet()), g.playerEvery)
|
||||
assertEquals(2, next.level); assertTrue(OrbEvent.Cleared in next.events)
|
||||
assertEquals(maze.cells('.').size, next.stars.size)
|
||||
assertTrue(OrbGame.start(maze, level = 6).chaserEvery < g.chaserEvery)
|
||||
assertTrue(OrbGame.start(maze, level = 6).powerTicks < g.powerTicks)
|
||||
}
|
||||
|
||||
@Test fun theRocksLeaveTheDenAndHuntWithoutGettingStuck() {
|
||||
// A long game with Orb-Man somewhere they can't reach him: every rock gets out and keeps moving.
|
||||
var g = OrbGame.start(maze)
|
||||
g = g.copy(player = OrbMover(OrbPos(0, 0), OrbPos(0, 0), OrbDir.Left))
|
||||
var most = 0
|
||||
repeat(3_000) {
|
||||
g = g.step(random)
|
||||
assertTrue(g.chasers.all { !maze.wall(it.mover.pos) })
|
||||
most = maxOf(most, g.chasers.count { it.out })
|
||||
}
|
||||
assertTrue(g.alive); assertEquals(4, most)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user