Skydoku becomes the cast's region puzzle; Blast Off combos (from 0.108.32)
pauljd/davidson-ci: Davidson CI / quality (push) Failing after 1m44s
pauljd/davidson-ci: Davidson CI / ai-review (push) Failing after 1m32s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-11-05 22:09:05 +00:00
co-authored by Claude Opus 5.5
parent ad4fe594e5
commit e21ab601f0
5 changed files with 247 additions and 179 deletions
@@ -31,7 +31,8 @@ import androidx.tv.material3.Text
/**
* Blast Off (Paul, 5 November 2026): Orbit's own block puzzle with the cast. Three shapes wait in the tray; fit them anywhere on
* the 8×8 board, and every full row or column clears. Clearing more than one at once is a combo, and Zip zooms past. When none of
* the 8×8 board, and every full row or column clears. Clearing several at once scores more, clears on placings in a row build a
* combo that multiplies them, and Zip zooms past. When none of
* the shapes left can fit, the game is over. The game has no screen in it, so it can be tested.
*/
internal const val BLAST_SIZE = 8
@@ -69,7 +70,9 @@ internal class BlastGame(
val score: Int = 0,
val alive: Boolean = true,
/** Rows and columns cleared by the last placing, for the cheer. */
val lastCleared: Int = 0) {
val lastCleared: Int = 0,
/** Placings in a row that cleared a line: the combo, which multiplies what each clear is worth. */
val streak: Int = 0) {
val piece get() = tray.getOrNull(selected)
@@ -81,8 +84,8 @@ internal class BlastGame(
fun fitsAnywhere(p: BlastPiece) = (0..BLAST_SIZE - p.shape.height).any { py -> (0..BLAST_SIZE - p.shape.width).any { px -> fits(p, px, py) } }
private fun copy(board: List<List<Int>> = this.board, tray: List<BlastPiece?> = this.tray, selected: Int = this.selected, x: Int = this.x, y: Int = this.y,
inTray: Boolean = this.inTray, score: Int = this.score, alive: Boolean = this.alive, lastCleared: Int = this.lastCleared) =
BlastGame(board, tray, selected, x, y, inTray, score, alive, lastCleared)
inTray: Boolean = this.inTray, score: Int = this.score, alive: Boolean = this.alive, lastCleared: Int = this.lastCleared, streak: Int = this.streak) =
BlastGame(board, tray, selected, x, y, inTray, score, alive, lastCleared, streak)
/** Moving the shape keeps it all on the board; down from the bottom goes to the tray. */
fun move(dx: Int, dy: Int): BlastGame {
@@ -116,11 +119,13 @@ internal class BlastGame(
rows.forEach { r -> for (c in 0 until BLAST_SIZE) placed[r][c] = 0 }
cols.forEach { c -> for (r in 0 until BLAST_SIZE) placed[r][c] = 0 }
val lines = rows.size + cols.size
// A square for each block placed, ten for each line, and more for clearing several at once.
val gained = p.shape.cells.size + 10 * lines * lines
// A square for each block placed, ten for each line, more for clearing several at once, and that again times the combo:
// clears on placings in a row build it up, and a placing that clears nothing ends it.
val streak = if (lines > 0) streak + 1 else 0
val gained = p.shape.cells.size + 10 * lines * lines * maxOf(1, streak)
var tray = tray.toMutableList<BlastPiece?>().also { it[selected] = null }.toList()
if (tray.all { it == null }) tray = deal(random)
val next = BlastGame(placed, tray, tray.indexOfFirst { it != null }, x, y, false, score + gained, true, lines)
val next = BlastGame(placed, tray, tray.indexOfFirst { it != null }, x, y, false, score + gained, true, lines, streak)
return next.copy(alive = next.tray.filterNotNull().any(next::fitsAnywhere)).clamped()
}
@@ -193,7 +198,7 @@ internal fun BlastOffScreen(best: Int, onBest: (Int) -> Unit, onLeave: () -> Uni
KeyEvent.KEYCODE_DPAD_RIGHT -> game.move(1, 0)
KeyEvent.KEYCODE_DPAD_UP -> game.move(0, -1)
KeyEvent.KEYCODE_DPAD_DOWN -> game.move(0, 1)
KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER -> game.press(random).also { if (it.score > game.score && it.lastCleared >= 2) zipAt = System.currentTimeMillis() }
KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER -> game.press(random).also { if (it.lastCleared >= 2 || it.streak >= 2) zipAt = System.currentTimeMillis() }
else -> null
}
if (next != null) game = next
@@ -206,6 +211,7 @@ internal fun BlastOffScreen(best: Int, onBest: (Int) -> Unit, onLeave: () -> Uni
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)
if (game.streak >= 2) Text("COMBO ×${game.streak}", color = Color(0xFFFF4FA3), fontFamily = Marquee, fontSize = 26.sp)
Spacer(Modifier.weight(1f))
Text("◄►▲▼ Move the shape", color = Muted, fontSize = 13.sp)
Text("OK Place it", color = Muted, fontSize = 13.sp)
@@ -26,186 +26,225 @@ 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.abs
/**
* Skydoku (Paul, 5 November 2026): a 6×6 sudoku with the six cast faces instead of numbers. Every row, every column and every
* 2×3 box holds each of Ollie, Blaze, Zip, Drift, Luna and Bumble once. Each puzzle is made fresh and has exactly one answer.
* Easy leaves plenty of faces in place; Harder takes away as many as it can. Clashes are ringed, never flashed.
* Skydoku (Paul, 5 November 2026: like Meowdoku, with our cast). The 6×6 sky is split into six coloured regions, one for each of
* the cast. Put each one in its own region so that no two share a row or a column, and no two touch, not even at the corners.
* Every puzzle has exactly one answer and is solved by logic alone. Placing someone in the wrong square costs a heart; three
* hearts a puzzle. Placing someone rightly crosses off every square they rule out. There's a puzzle of the day, the same all
* day, and new ones whenever you like. The game has no screen in it, so it can be tested.
*/
internal const val SKY_SIZE = 6
private const val SKY_BOX_ROWS = 2
private const val SKY_BOX_COLUMNS = 3
internal const val SKY_HEARTS = 3
internal fun skyPeers(i: Int): Set<Int> {
val r = i / SKY_SIZE; val c = i % SKY_SIZE
val br = r / SKY_BOX_ROWS * SKY_BOX_ROWS; val bc = c / SKY_BOX_COLUMNS * SKY_BOX_COLUMNS
return ((0 until SKY_SIZE).map { r * SKY_SIZE + it } + (0 until SKY_SIZE).map { it * SKY_SIZE + c } +
(0 until SKY_BOX_ROWS).flatMap { dr -> (0 until SKY_BOX_COLUMNS).map { dc -> (br + dr) * SKY_SIZE + bc + dc } }).toSet() - i
}
/** The region of each square (0 to 5), and the one answer: the column of the cast member in each row. */
internal class SkyPuzzle(val regions: IntArray, val answer: IntArray)
/** How many answers [cells] has, counting no further than [limit]. */
internal fun skySolutions(cells: IntArray, limit: Int = 2): Int {
val i = cells.indexOfFirst { it == 0 }
if (i < 0) return 1
var found = 0
for (v in 1..SKY_SIZE) {
if (skyPeers(i).any { cells[it] == v }) continue
cells[i] = v
found += skySolutions(cells, limit - found)
cells[i] = 0
if (found >= limit) break
/** Every placing of one per row and column, none touching, that puts one in each region. Counts no further than [limit]. */
internal fun skyAnswers(regions: IntArray, limit: Int = 2): List<IntArray> {
val found = mutableListOf<IntArray>()
val cols = IntArray(SKY_SIZE)
fun place(row: Int, usedCols: Int, usedRegions: Int) {
if (found.size >= limit) return
if (row == SKY_SIZE) { found += cols.copyOf(); return }
for (c in 0 until SKY_SIZE) {
if (usedCols and (1 shl c) != 0) continue
if (row > 0 && abs(cols[row - 1] - c) <= 1) continue
val region = regions[row * SKY_SIZE + c]
if (usedRegions and (1 shl region) != 0) continue
cols[row] = c
place(row + 1, usedCols or (1 shl c), usedRegions or (1 shl region))
}
}
place(0, 0, 0)
return found
}
private fun skyFill(cells: IntArray, random: java.util.Random): Boolean {
val i = cells.indexOfFirst { it == 0 }
if (i < 0) return true
for (v in (1..SKY_SIZE).shuffled(random)) {
if (skyPeers(i).any { cells[it] == v }) continue
cells[i] = v
if (skyFill(cells, random)) return true
cells[i] = 0
}
return false
}
internal enum class SkyLevel(val label: String, val keep: Int) { Easy("Easy", 20), Harder("Harder", 0) }
internal class SkyGame(val solution: IntArray, val givens: Set<Int>, val cells: IntArray, val cursor: Int = 0, val level: SkyLevel = SkyLevel.Easy) {
val done get() = cells.contentEquals(solution)
/** Squares whose face appears again in their row, column or box. */
val clashes get() = cells.indices.filter { i -> cells[i] != 0 && skyPeers(i).any { cells[it] == cells[i] } }.toSet()
fun move(dx: Int, dy: Int) = SkyGame(solution, givens, cells, ((cursor / SKY_SIZE + dy).coerceIn(0, SKY_SIZE - 1)) * SKY_SIZE + (cursor % SKY_SIZE + dx).coerceIn(0, SKY_SIZE - 1), level)
/** Puts a face (1 to 6) in the cursor's square, or clears it (0). The faces you were given stay put. */
fun put(v: Int): SkyGame {
if (cursor in givens || done) return this
return SkyGame(solution, givens, cells.copyOf().also { it[cursor] = v }, cursor, level)
}
companion object {
fun make(random: java.util.Random, level: SkyLevel): SkyGame {
val solution = IntArray(SKY_SIZE * SKY_SIZE)
skyFill(solution, random)
val puzzle = solution.copyOf()
// Take faces away one at a time, keeping only those removals that leave a single answer.
for (i in puzzle.indices.shuffled(random)) {
if (puzzle.count { it != 0 } <= level.keep) break
val v = puzzle[i]; puzzle[i] = 0
if (skySolutions(puzzle.copyOf()) != 1) puzzle[i] = v
internal fun skyMake(random: java.util.Random): SkyPuzzle {
while (true) {
// An answer first: one per row and column, never touching.
val answer = skyPlacing(random) ?: continue
// Then six regions grown out from it at random, one around each cast member, kept only if that answer is the only one.
repeat(60) {
val regions = IntArray(SKY_SIZE * SKY_SIZE) { -1 }
val frontier = mutableListOf<Int>()
for (r in 0 until SKY_SIZE) { val i = r * SKY_SIZE + answer[r]; regions[i] = r; frontier += i }
while (frontier.isNotEmpty()) {
val i = frontier.removeAt(random.nextInt(frontier.size))
val open = listOf(-SKY_SIZE, SKY_SIZE, -1, 1).map { i + it }.filter { n ->
n in regions.indices && regions[n] == -1 && (abs(n % SKY_SIZE - i % SKY_SIZE) <= 1)
}
if (open.isEmpty()) continue
val n = open[random.nextInt(open.size)]
regions[n] = regions[i]
frontier += i; frontier += n
}
val givens = puzzle.indices.filter { puzzle[it] != 0 }.toSet()
return SkyGame(solution, givens, puzzle, puzzle.indexOfFirst { it == 0 }.coerceAtLeast(0), level)
// Region numbers follow the cast order by the rows of their members, so colours vary.
if (skyAnswers(regions).size == 1) return SkyPuzzle(regions, answer)
}
}
}
private fun skyPlacing(random: java.util.Random): IntArray? {
val cols = IntArray(SKY_SIZE)
fun go(row: Int, used: Int): Boolean {
if (row == SKY_SIZE) return true
for (c in (0 until SKY_SIZE).shuffled(random)) {
if (used and (1 shl c) != 0 || (row > 0 && abs(cols[row - 1] - c) <= 1)) continue
cols[row] = c
if (go(row + 1, used or (1 shl c))) return true
}
return false
}
return if (go(0, 0)) cols else null
}
/** The squares a cast member at [i] rules out: their row, column and region, and the eight around them. */
internal fun skyRuledOut(regions: IntArray, i: Int): Set<Int> {
val r = i / SKY_SIZE; val c = i % SKY_SIZE
return regions.indices.filter { j ->
j != i && (j / SKY_SIZE == r || j % SKY_SIZE == c || regions[j] == regions[i] || (abs(j / SKY_SIZE - r) <= 1 && abs(j % SKY_SIZE - c) <= 1))
}.toSet()
}
internal class SkyGame(val puzzle: SkyPuzzle, val placed: Set<Int> = emptySet(), val crossed: Set<Int> = emptySet(), val hearts: Int = SKY_HEARTS,
val cursor: Int = 0,
/** The square of the last wrong try, shown with a cross until the next move. */
val wrong: Int? = null) {
val done get() = placed.size == SKY_SIZE
val out get() = hearts <= 0
private fun copy(placed: Set<Int> = this.placed, crossed: Set<Int> = this.crossed, hearts: Int = this.hearts, cursor: Int = this.cursor, wrong: Int? = null) =
SkyGame(puzzle, placed, crossed, hearts, cursor, wrong)
fun move(dx: Int, dy: Int) = copy(cursor = ((cursor / SKY_SIZE + dy).coerceIn(0, SKY_SIZE - 1)) * SKY_SIZE + (cursor % SKY_SIZE + dx).coerceIn(0, SKY_SIZE - 1))
/** OK: put the region's cast member here. Right, and the squares they rule out are crossed off; wrong, and a heart goes. */
fun place(): SkyGame {
if (done || out || cursor in placed || cursor in crossed) return this
val right = puzzle.answer[cursor / SKY_SIZE] == cursor % SKY_SIZE
return if (right) copy(placed = placed + cursor, crossed = crossed + skyRuledOut(puzzle.regions, cursor))
else copy(hearts = hearts - 1, crossed = crossed + cursor, wrong = cursor)
}
fun again() = SkyGame(puzzle)
}
/** The puzzle of the day: the same all day for everyone with Orbit. */
internal fun skyDaily(day: java.time.LocalDate) = skyMake(java.util.Random(day.toEpochDay() * 7_919 + 61))
private val Marquee = FontFamily(Font(R.font.anton_regular))
private val Honey = Color(0xFFFFD84A)
/** One of the six faces, 1 to 6, drawn to the cast's model sheet. */
internal fun DrawScope.drawSkyFace(v: Int, centre: Offset, r: Float) = when (v) {
1 -> drawOrbMascot(centre, r * .75f)
2 -> drawOrbChaser(centre, r * .85f, OrbCharacter.Blaze, OrbDir.Down, false, false, false, 0f)
3 -> drawOrbChaser(centre, r * .82f, OrbCharacter.Zip, OrbDir.Down, false, false, false, 0f)
4 -> drawOrbChaser(centre, r * .85f, OrbCharacter.Drift, OrbDir.Down, false, false, false, 0f)
5 -> drawOrbChaser(centre, r * .85f, OrbCharacter.Luna, OrbDir.Down, false, false, false, 0f)
6 -> drawOrbBee(centre + Offset(0f, r * .15f), r * .62f)
/** The cast in region order, each region in its member's colour. */
internal val SkyCast = listOf("Ollie", "Blaze", "Zip", "Drift", "Luna", "Bumble")
internal val SkyColours = listOf(Color(0xFFF7A928), OrbCharacter.Blaze.colour, OrbCharacter.Zip.colour, OrbCharacter.Drift.colour, OrbCharacter.Luna.colour, Color(0xFF8BD86A))
/** One of the six, by region, drawn to the cast's model sheet. */
internal fun DrawScope.drawSkyFace(region: Int, centre: Offset, r: Float) = when (region) {
0 -> drawOrbMascot(centre, r * .75f)
1 -> drawOrbChaser(centre, r * .85f, OrbCharacter.Blaze, OrbDir.Down, false, false, false, 0f)
2 -> drawOrbChaser(centre, r * .82f, OrbCharacter.Zip, OrbDir.Down, false, false, false, 0f)
3 -> drawOrbChaser(centre, r * .85f, OrbCharacter.Drift, OrbDir.Down, false, false, false, 0f)
4 -> drawOrbChaser(centre, r * .85f, OrbCharacter.Luna, OrbDir.Down, false, false, false, 0f)
5 -> drawOrbBee(centre + Offset(0f, r * .15f), r * .62f)
else -> {}
}
internal val SkyNames = listOf("Ollie", "Blaze", "Zip", "Drift", "Luna", "Bumble")
/** The board: regions tinted in their member's colour with bold edges between them, the cast where placed, crosses where ruled out. */
internal fun DrawScope.drawSkyBoard(game: SkyGame, showCursor: Boolean = true) {
val cell = size.minDimension / SKY_SIZE
val origin = Offset((size.width - cell * SKY_SIZE) / 2, (size.height - cell * SKY_SIZE) / 2)
val regions = game.puzzle.regions
for (i in regions.indices) {
val at = origin + Offset(i % SKY_SIZE * cell, i / SKY_SIZE * cell)
drawRect(Navy, at, Size(cell, cell))
drawRect(SkyColours[regions[i]].copy(alpha = .42f), at, Size(cell, cell))
if (i in game.placed) drawSkyFace(regions[i], at + Offset(cell / 2, cell / 2), cell * .36f)
else if (i in game.crossed) {
val k = cell * .14f; val c = at + Offset(cell / 2, cell / 2)
val ink = if (i == game.wrong) Color(0xFFFF6B6B) else Color.White.copy(alpha = .45f)
drawLine(ink, c - Offset(k, k), c + Offset(k, k), cell * (if (i == game.wrong) .06f else .035f))
drawLine(ink, c + Offset(-k, k), c + Offset(k, -k), cell * (if (i == game.wrong) .06f else .035f))
}
}
// Thin lines between squares, bold ones between regions.
for (i in regions.indices) {
val r = i / SKY_SIZE; val c = i % SKY_SIZE
val at = origin + Offset(c * cell, r * cell)
if (c < SKY_SIZE - 1) drawLine(if (regions[i] != regions[i + 1]) Color.White else Color.White.copy(alpha = .15f), at + Offset(cell, 0f), at + Offset(cell, cell), if (regions[i] != regions[i + 1]) cell * .06f else cell * .015f)
if (r < SKY_SIZE - 1) drawLine(if (regions[i] != regions[i + SKY_SIZE]) Color.White else Color.White.copy(alpha = .15f), at + Offset(0f, cell), at + Offset(cell, cell), if (regions[i] != regions[i + SKY_SIZE]) cell * .06f else cell * .015f)
}
drawRoundRect(Color.White, origin, Size(cell * SKY_SIZE, cell * SKY_SIZE), CornerRadius(cell * .08f), style = Stroke(cell * .07f))
if (showCursor && !game.done && !game.out) {
val at = origin + Offset(game.cursor % SKY_SIZE * cell, game.cursor / SKY_SIZE * cell)
drawRoundRect(Yellow, at + Offset(cell * .05f, cell * .05f), Size(cell * .9f, cell * .9f), CornerRadius(cell * .12f), style = Stroke(cell * .08f))
}
}
@Composable
internal fun SkydokuScreen(solved: Int, onSolved: () -> Unit, onLeave: () -> Unit) {
internal fun SkydokuScreen(solved: Int, dailyDone: Boolean, onSolved: (daily: Boolean) -> Unit, onLeave: () -> Unit) {
val random = remember { java.util.Random() }
var level by remember { mutableStateOf(SkyLevel.Easy) }
var game by remember { mutableStateOf(SkyGame.make(random, level)) }
var picking by remember { mutableStateOf<Int?>(null) }
val today = remember { java.time.LocalDate.now() }
var daily by remember { mutableStateOf(!dailyDone) }
var game by remember { mutableStateOf(SkyGame(if (!dailyDone) skyDaily(today) else skyMake(random))) }
var paused by remember { mutableStateOf(false) }
val focus = remember { FocusRequester() }
val menuFocus = remember { FocusRequester() }
LaunchedEffect(game.done) { if (game.done) onSolved() }
LaunchedEffect(paused, game.done) { if (paused || game.done) menuFocus.requestFocus() else focus.requestFocus() }
BackHandler { if (picking != null) picking = null else if (!game.done) paused = !paused else onLeave() }
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(Navy).padding(20.dp).focusRequester(focus).focusable().onPreviewKeyEvent { event ->
val native = event.nativeKeyEvent
if (native.action != KeyEvent.ACTION_DOWN || paused || game.done) return@onPreviewKeyEvent false
val typed = if (native.keyCode in KeyEvent.KEYCODE_0..KeyEvent.KEYCODE_6) native.keyCode - KeyEvent.KEYCODE_0 else null
if (typed != null) { game = game.put(typed); picking = null; return@onPreviewKeyEvent true }
// Choosing a face: a row of the six and a blank, under the board.
picking?.let { choice ->
when (native.keyCode) {
KeyEvent.KEYCODE_DPAD_LEFT -> picking = (choice - 1).coerceAtLeast(0)
KeyEvent.KEYCODE_DPAD_RIGHT -> picking = (choice + 1).coerceAtMost(SKY_SIZE)
KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER -> { game = game.put(if (choice == SKY_SIZE) 0 else choice + 1); picking = null }
KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_DOWN -> picking = null
else -> return@onPreviewKeyEvent false
}
return@onPreviewKeyEvent true
}
when (native.keyCode) {
KeyEvent.KEYCODE_DPAD_LEFT -> game = game.move(-1, 0)
KeyEvent.KEYCODE_DPAD_RIGHT -> game = game.move(1, 0)
KeyEvent.KEYCODE_DPAD_UP -> game = game.move(0, -1)
KeyEvent.KEYCODE_DPAD_DOWN -> game = game.move(0, 1)
KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER -> if (game.cursor !in game.givens) picking = (game.cells[game.cursor] - 1).let { if (it < 0) 0 else it }
if (native.action != KeyEvent.ACTION_DOWN || paused || game.done || game.out) return@onPreviewKeyEvent false
game = when (native.keyCode) {
KeyEvent.KEYCODE_DPAD_LEFT -> game.move(-1, 0)
KeyEvent.KEYCODE_DPAD_RIGHT -> game.move(1, 0)
KeyEvent.KEYCODE_DPAD_UP -> game.move(0, -1)
KeyEvent.KEYCODE_DPAD_DOWN -> game.move(0, 1)
KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER -> game.place()
else -> return@onPreviewKeyEvent false
}
true
}, horizontalArrangement = Arrangement.spacedBy(20.dp)) {
Column(Modifier.width(190.dp).fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Column(Modifier.width(200.dp).fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text("SKYDOKU", color = Honey, fontFamily = Marquee, fontSize = 32.sp)
Text(game.level.label.uppercase(), color = Muted, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 2.sp)
Spacer(Modifier.height(8.dp))
Text("Every row, column and box has each face once.", color = Color.White, fontSize = 14.sp)
Text("SOLVED", color = Muted, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 2.sp, modifier = Modifier.padding(top = 8.dp))
Text(solved.toString(), color = Color.White, fontFamily = Marquee, fontSize = 30.sp)
Text(if (daily) "TODAY'S PUZZLE" else "NEW PUZZLE", color = Muted, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 2.sp)
Canvas(Modifier.height(26.dp).fillMaxWidth()) {
repeat(SKY_HEARTS) { k ->
val c = Offset(13f + k * 34f, size.height / 2)
val full = k < game.hearts
val heart = androidx.compose.ui.graphics.Path().apply {
moveTo(c.x, c.y + 9f); cubicTo(c.x - 16f, c.y - 2f, c.x - 7f, c.y - 13f, c.x, c.y - 5f); cubicTo(c.x + 7f, c.y - 13f, c.x + 16f, c.y - 2f, c.x, c.y + 9f); close()
}
if (full) drawPath(heart, Color(0xFFFF6B8A)) else drawPath(heart, Muted.copy(alpha = .5f), style = Stroke(2f))
}
}
Spacer(Modifier.height(4.dp))
Text("Put each of the cast in their own colour.", color = Color.White, fontSize = 14.sp)
Text("One in every row and column, and no two touching, not even at the corners.", color = Muted, fontSize = 13.sp)
Text("SOLVED", color = Muted, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 2.sp, modifier = Modifier.padding(top = 6.dp))
Text(solved.toString(), color = Color.White, fontFamily = Marquee, fontSize = 28.sp)
Spacer(Modifier.weight(1f))
Text("◄►▲▼ Move", color = Muted, fontSize = 13.sp)
Text("OK Choose a face", color = Muted, fontSize = 13.sp)
Text("1 to 6 also work, 0 clears", color = Muted, fontSize = 13.sp)
Text("◄►▲▼ Move OK Place", color = Muted, fontSize = 13.sp)
Text("Back Pause", color = Muted, fontSize = 13.sp)
}
Box(Modifier.weight(1f).fillMaxHeight(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp)) {
val clashes = game.clashes
Canvas(Modifier.weight(1f).aspectRatio(1f)) {
val cell = size.minDimension / SKY_SIZE
for (i in 0 until SKY_SIZE * SKY_SIZE) {
val at = Offset(i % SKY_SIZE * cell, i / SKY_SIZE * cell)
val given = i in game.givens
drawRoundRect(if (given) Color(0xFF0E2560) else Color(0xFF16357A), at + Offset(cell * .04f, cell * .04f), Size(cell * .92f, cell * .92f), CornerRadius(cell * .14f))
if (game.cells[i] != 0) drawSkyFace(game.cells[i], at + Offset(cell / 2, cell / 2), cell * .36f)
if (i in clashes && !given) drawRoundRect(Color(0xFFFF6B6B), at + Offset(cell * .07f, cell * .07f), Size(cell * .86f, cell * .86f), CornerRadius(cell * .12f), style = Stroke(cell * .05f))
}
// The 2×3 boxes.
for (k in 0..SKY_SIZE / SKY_BOX_COLUMNS) drawLine(Muted.copy(alpha = .6f), Offset(k * SKY_BOX_COLUMNS * cell, 0f), Offset(k * SKY_BOX_COLUMNS * cell, size.height), cell * .04f)
for (k in 0..SKY_SIZE / SKY_BOX_ROWS) drawLine(Muted.copy(alpha = .6f), Offset(0f, k * SKY_BOX_ROWS * cell), Offset(size.width, k * SKY_BOX_ROWS * cell), cell * .04f)
if (!game.done) {
val at = Offset(game.cursor % SKY_SIZE * cell, game.cursor / SKY_SIZE * cell)
drawRoundRect(Yellow, at + Offset(cell * .03f, cell * .03f), Size(cell * .94f, cell * .94f), CornerRadius(cell * .14f), style = Stroke(cell * .07f))
}
}
// The faces to choose from, with a blank to clear.
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
for (k in 0..SKY_SIZE) {
val on = picking == k
val corner = RoundedCornerShape(8.dp)
Column(Modifier.size(64.dp, 72.dp).background(if (on) Navy else Panel, corner).border(if (on) 3.dp else 0.dp, if (on) Yellow else Color.Transparent, corner),
horizontalAlignment = Alignment.CenterHorizontally) {
Canvas(Modifier.size(48.dp)) { if (k < SKY_SIZE) drawSkyFace(k + 1, center, size.minDimension * .36f) else drawCircle(Muted.copy(alpha = .5f), size.minDimension * .3f, center, style = Stroke(3f)) }
Text(if (k < SKY_SIZE) SkyNames[k] else "Clear", color = if (picking == null) Muted.copy(alpha = .6f) else Color.White, fontSize = 10.sp)
}
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)) {
Canvas(Modifier.fillMaxHeight().aspectRatio(1f)) { drawSkyBoard(game) }
// Who goes in the region under the cursor.
Column(Modifier.width(110.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(4.dp)) {
val region = game.puzzle.regions[game.cursor]
Canvas(Modifier.size(72.dp)) { drawCircle(SkyColours[region].copy(alpha = .35f), size.minDimension / 2); drawSkyFace(region, center, size.minDimension * .4f) }
Text(SkyCast[region], color = Color.White, fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
Text("goes here?", color = Muted, fontSize = 12.sp)
}
}
if (paused || game.done) 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.done) "SOLVED!" else "PAUSED", color = Honey, fontFamily = Marquee, fontSize = 30.sp)
if (paused || game.done || game.out) 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.done) "SOLVED!" else if (game.out) "OUT OF HEARTS" else "PAUSED", color = Honey, fontFamily = Marquee, fontSize = 30.sp)
if (game.done) CastCheer()
if (!game.done) OrbitButton("Carry on", Modifier.width(220.dp).focusRequester(menuFocus), onClick = { paused = false })
for (choice in SkyLevel.entries) OrbitButton("New puzzle: ${choice.label}", Modifier.width(220.dp).then(if (game.done && choice == level) Modifier.focusRequester(menuFocus) else Modifier), onClick = {
level = choice; game = SkyGame.make(random, choice); picking = null; paused = false
if (game.out) Text("Every puzzle can be solved by logic alone.", color = Color.White, fontSize = 14.sp)
if (!game.done && !game.out) OrbitButton("Carry on", Modifier.width(220.dp).focusRequester(menuFocus), onClick = { paused = false })
if (game.out) OrbitButton("Try it again", Modifier.width(220.dp).focusRequester(menuFocus), onClick = { game = game.again() })
OrbitButton("New puzzle", Modifier.width(220.dp).then(if (game.done) Modifier.focusRequester(menuFocus) else Modifier), onClick = {
daily = false; game = SkyGame(skyMake(random)); paused = false
})
OrbitButton("Leave the game", Modifier.width(220.dp), onClick = onLeave)
}
@@ -309,7 +309,7 @@ private enum class ArcadeGame(val title: String, val tagline: String, val blurb:
// Paul, 5 November 2026: the Tots mascot is Ollie, so the game is renamed around the mascot, and OrbitHive becomes BumbleHive after its bee.
OrbMan("Star Chase", "Maze chase starring Ollie", "Help Ollie clear the stars, and dodge Blaze, Zip, Drift and Luna."),
Blast("Blast Off", "Fit the shapes, clear the lines", "Fit three shapes at a time on the board; full rows and columns clear. No room left and it's over."),
Sky("Skydoku", "The cast's own sudoku", "Every row, column and box holds each of the six cast once. Easy or Harder, always one answer."),
Sky("Skydoku", "Find each of the cast a spot", "Put each of the cast in their colour: one per row and column, none touching. Three hearts, a puzzle every day."),
Hive("BumbleHive", "Link, match, clear the hive", "Help Bumble 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."),
@@ -330,8 +330,9 @@ private fun androidx.compose.ui.graphics.drawscope.DrawScope.gameIcon(game: Arca
ArcadeGame.Blast -> { val b = s * .2f
listOf(Triple(-1f, -1f, 0), Triple(0f, -1f, 0), Triple(-1f, 0f, 0), Triple(0f, 0f, 1), Triple(1f, 0f, 1), Triple(0f, 1f, 2), Triple(1f, 1f, 3))
.forEach { (x, y, k) -> drawRoundRect(BlastColours[k], c + Offset(x * b - b / 2 + 1, y * b - b / 2 + 1), Size(b - 2, b - 2), androidx.compose.ui.geometry.CornerRadius(b * .2f)) } }
// All six of the cast, two rows of three.
ArcadeGame.Sky -> { val b = s * .3f
for (i in 0..3) { val at = c + Offset((i % 2 - .5f) * b * 1.1f, (i / 2 - .5f) * b * 1.1f); drawRoundRect(Color(0xFF16357A), at - Offset(b / 2, b / 2), Size(b, b), androidx.compose.ui.geometry.CornerRadius(b * .15f)); drawSkyFace(listOf(1, 3, 5, 6)[i], at, b * .34f) } }
for (i in 0..5) { val at = c + Offset((i % 3 - 1f) * b * 1.05f, (i / 3 - .5f) * b * 1.05f); drawRoundRect(SkyColours[i].copy(alpha = .4f), at - Offset(b / 2, b / 2), Size(b, b), androidx.compose.ui.geometry.CornerRadius(b * .15f)); drawSkyFace(i, at, b * .4f) } }
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)
@@ -406,7 +407,8 @@ internal fun OrbitArcade(modifier: Modifier, onPlaying: (Boolean) -> Unit) {
ArcadeGame.Snake -> { SnakeScreen(best, onBest = { best = it; settings.snakeBest = it }, onLeave = { playing = null }); return }
ArcadeGame.Hive -> { OrbitHiveScreen(onLeave = { playing = null }); return }
ArcadeGame.Blast -> { BlastOffScreen(blastBest, onBest = { blastBest = it; settings.blastBest = it }, onLeave = { playing = null }); return }
ArcadeGame.Sky -> { SkydokuScreen(skySolved, onSolved = { skySolved += 1; settings.skydokuSolved = skySolved }, onLeave = { playing = null }); return }
ArcadeGame.Sky -> { SkydokuScreen(skySolved, settings.skydokuDaily == java.time.LocalDate.now().toString(), onSolved = { daily ->
skySolved += 1; settings.skydokuSolved = skySolved; if (daily) settings.skydokuDaily = java.time.LocalDate.now().toString() }, onLeave = { playing = null }); return }
ArcadeGame.Pairs -> { PairsScreen(pairsBest, onBest = { pairsBest = it; settings.pairsBest = it }, onLeave = { playing = null }); return }
ArcadeGame.Patience -> { PatienceScreen(patienceWins, onWin = { patienceWins += 1; settings.patienceWins = patienceWins }, onLeave = { playing = null }); return }
ArcadeGame.Bricks -> { BrickBreakScreen(brickBest, onBest = { brickBest = it; settings.brickBreakBest = it }, onLeave = { playing = null }); return }
@@ -442,7 +444,7 @@ internal fun OrbitArcade(modifier: Modifier, onPlaying: (Boolean) -> Unit) {
val rows = listOf("..##....", "..##....", ".....###", "#....###", "##..####", "###.####", "####.###", "#######.")
BlastGame(rows.mapIndexed { y, row -> row.mapIndexed { x, ch -> if (ch == '#') 1 + (x * 2 + y * 3) % BlastColours.size else 0 } }, BlastGame.deal(java.util.Random(3)), 0, 0, 0)
}
val skyPreview = remember { SkyGame.make(java.util.Random(8), SkyLevel.Easy) }
val skyPreview = remember { val p = skyMake(java.util.Random(8)); SkyGame(p, placed = setOf(p.answer[1] + SKY_SIZE, p.answer[4] + 4 * SKY_SIZE)) }
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)) {
@@ -475,14 +477,7 @@ internal fun OrbitArcade(modifier: Modifier, onPlaying: (Boolean) -> Unit) {
}
}
ArcadeGame.Blast -> Canvas(Modifier.fillMaxSize().padding(6.dp)) { drawBlastBoard(blastPreview, showPiece = false) }
ArcadeGame.Sky -> Canvas(Modifier.fillMaxSize().padding(10.dp)) {
val cell = size.minDimension / SKY_SIZE; val left = (size.width - cell * SKY_SIZE) / 2
for (i in 0 until SKY_SIZE * SKY_SIZE) {
val at = Offset(left + i % SKY_SIZE * cell, i / SKY_SIZE * cell)
drawRoundRect(Color(0xFF16357A), at + Offset(cell * .05f, cell * .05f), Size(cell * .9f, cell * .9f), androidx.compose.ui.geometry.CornerRadius(cell * .14f))
if (skyPreview.cells[i] != 0) drawSkyFace(skyPreview.cells[i], at + Offset(cell / 2, cell / 2), cell * .34f)
}
}
ArcadeGame.Sky -> Canvas(Modifier.fillMaxSize().padding(10.dp)) { drawSkyBoard(skyPreview, showCursor = false) }
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
@@ -63,6 +63,10 @@ internal class OrbitSettings(context: Context) {
var blastBest: Int
get() = saved.getInt("blast_best", 0)
set(value) { saved.edit().putInt("blast_best", value).apply() }
/** The day today's Skydoku was solved, so it isn't offered again until tomorrow. */
var skydokuDaily: String
get() = saved.getString("skydoku_daily", "").orEmpty()
set(value) { saved.edit().putString("skydoku_daily", value).apply() }
var skydokuSolved: Int
get() = saved.getInt("skydoku_solved", 0)
set(value) { saved.edit().putInt("skydoku_solved", value).apply() }
@@ -42,27 +42,51 @@ class CastGamesTest {
g = g.move(0, -1); assertFalse(g.inTray)
}
@Test fun skydokuPuzzlesHaveOneAnswerAndCanBeSolved() {
for (level in SkyLevel.entries) {
val g = SkyGame.make(random, level)
assertEquals(1, skySolutions(g.cells.copyOf()))
assertTrue(g.solution.indices.all { i -> skyPeers(i).none { g.solution[it] == g.solution[i] } })
assertTrue(g.givens.all { g.cells[it] == g.solution[it] })
if (level == SkyLevel.Easy) assertTrue(g.givens.size >= 20) else assertTrue(g.givens.size < 20)
var x = g
for (i in g.cells.indices) if (i !in g.givens) x = SkyGame(x.solution, x.givens, x.cells, i, level).put(x.solution[i])
assertTrue(x.done)
// The faces you were given stay put.
val given = SkyGame(g.solution, g.givens, g.cells, g.givens.first())
assertSame(given, given.put(0))
}
@Test fun blastOffCombosBuildAndBreak() {
// Two clearing placings in a row: the second is worth double.
val g1 = BlastGame(board { r, c -> (r == 0 && c != 0) || (r == 1 && c != 0) }, listOf(one, one, one), 0, 0, 0).press(random)
assertEquals(1, g1.streak); assertEquals(1 + 10, g1.score)
val g2 = BlastGame(g1.board, g1.tray, g1.selected, 0, 1, score = g1.score, streak = g1.streak).press(random)
assertEquals(2, g2.streak); assertEquals(11 + 1 + 20, g2.score)
// A placing that clears nothing ends the combo.
val g3 = BlastGame(g2.board, listOf(one, one, one), 0, 4, 4, score = g2.score, streak = g2.streak).press(random)
assertEquals(0, g3.streak)
}
@Test fun skydokuRingsClashes() {
val g = SkyGame.make(random, SkyLevel.Easy)
val free = g.cells.indices.first { it !in g.givens }
val peerFace = skyPeers(free).map { g.cells[it] }.first { it != 0 }
assertTrue(free in SkyGame(g.solution, g.givens, g.cells, free).put(peerFace).clashes)
assertTrue(SkyGame(g.solution, g.givens, g.cells, free).put(g.solution[free]).clashes.isEmpty())
@Test fun skydokuPuzzlesHaveOneAnswer() {
repeat(5) {
val p = skyMake(random)
assertEquals(1, skyAnswers(p.regions).size)
assertTrue(skyAnswers(p.regions).single().contentEquals(p.answer))
// Six regions, each holding exactly one of the answer's squares, and the answer never touches or shares a column.
assertEquals(SKY_SIZE, p.regions.toSet().size)
assertEquals((0 until SKY_SIZE).toSet(), (0 until SKY_SIZE).map { p.regions[it * SKY_SIZE + p.answer[it]] }.toSet())
assertEquals(SKY_SIZE, p.answer.toSet().size)
assertTrue((1 until SKY_SIZE).all { kotlin.math.abs(p.answer[it] - p.answer[it - 1]) > 1 })
}
// The puzzle of the day is the same all day.
val day = java.time.LocalDate.of(2026, 11, 6)
assertTrue(skyDaily(day).regions.contentEquals(skyDaily(day).regions))
}
@Test fun skydokuPlacesRightCrossesOffAndCostsAHeartWhenWrong() {
val p = skyMake(random)
var g = SkyGame(p, cursor = p.answer[0])
g = g.place()
assertTrue(p.answer[0] in g.placed)
// Its row, column, region and neighbours are crossed off.
assertTrue(skyRuledOut(p.regions, p.answer[0]).all { it in g.crossed })
// A wrong square costs a heart and is crossed off.
val wrongSquare = (SKY_SIZE until SKY_SIZE * 2).first { it !in g.crossed && it % SKY_SIZE != p.answer[1] }
val missed = SkyGame(g.puzzle, g.placed, g.crossed, g.hearts, wrongSquare).place()
assertEquals(SKY_HEARTS - 1, missed.hearts); assertTrue(wrongSquare in missed.crossed)
// All six in their places: solved.
var x = SkyGame(p)
for (r in 0 until SKY_SIZE) x = SkyGame(x.puzzle, x.placed, x.crossed, x.hearts, r * SKY_SIZE + p.answer[r]).place()
assertTrue(x.done); assertEquals(SKY_HEARTS, x.hearts)
// Three wrong and it's over.
var y = SkyGame(p)
repeat(SKY_HEARTS) { k -> val w = (0 until SKY_SIZE * SKY_SIZE).first { it !in y.crossed && p.answer[it / SKY_SIZE] != it % SKY_SIZE }; y = SkyGame(y.puzzle, y.placed, y.crossed, y.hearts, w).place() }
assertTrue(y.out)
}
}