Skydoku becomes the cast's region puzzle; Blast Off combos (from 0.108.32)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user