diff --git a/android/app/src/main/java/tv/orbit/classic/ArcadeBlockDrop.kt b/android/app/src/main/java/tv/orbit/classic/ArcadeBlockDrop.kt new file mode 100644 index 0000000..a6a631f --- /dev/null +++ b/android/app/src/main/java/tv/orbit/classic/ArcadeBlockDrop.kt @@ -0,0 +1,218 @@ +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.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.input.key.onPreviewKeyEvent +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 + +/** + * Block Drop for Orbit Arcade (from the game list Paul approved on 2 November 2026): a falling-blocks puzzle played on the remote. + * Left and right move, up turns, down drops a row, OK drops all the way, Back pauses. Seven shapes come from a shuffled bag, so + * none is starved; full rows clear for points that grow with the level, and the level rises every ten rows. Orbit's own look. + */ +internal const val BLOCK_COLUMNS = 10 +internal const val BLOCK_ROWS = 20 + +/** Each shape's cells in its own box, unturned; turning rotates them a quarter within that box. */ +private val ShapeCells: List>>> = listOf( + 4 to listOf(0 to 1, 1 to 1, 2 to 1, 3 to 1), // the long bar + 2 to listOf(0 to 0, 1 to 0, 0 to 1, 1 to 1), // the square + 3 to listOf(1 to 0, 0 to 1, 1 to 1, 2 to 1), // the T + 3 to listOf(1 to 0, 2 to 0, 0 to 1, 1 to 1), // the S + 3 to listOf(0 to 0, 1 to 0, 1 to 1, 2 to 1), // the Z + 3 to listOf(0 to 0, 0 to 1, 1 to 1, 2 to 1), // the J + 3 to listOf(2 to 0, 0 to 1, 1 to 1, 2 to 1)) // the L + +/** The cells of [kind] turned [turns] quarters, in its box. */ +internal fun shapeCells(kind: Int, turns: Int): List> { + val (box, cells) = ShapeCells[kind] + var out = cells + repeat(((turns % 4) + 4) % 4) { out = out.map { (x, y) -> (box - 1 - y) to x } } + return out +} + +internal data class BlockPiece(val kind: Int, val turns: Int, val x: Int, val y: Int) { + fun cells() = shapeCells(kind, turns).map { (cx, cy) -> (x + cx) to (y + cy) } +} + +/** The board as rows of colours (0 empty, otherwise the shape's kind + 1), the falling piece, the next, and the score. */ +internal data class BlockGame(val board: List, val piece: BlockPiece, val next: Int, val bag: List, val score: Int = 0, val lines: Int = 0, val alive: Boolean = true) { + val level get() = lines / 10 + 1 + /** Gravity's pace, quickening with each level and never faster than 90 ms a row. */ + val tickMs: Long get() = maxOf(90L, 800L - (level - 1) * 70L) + + private fun fits(p: BlockPiece) = p.cells().all { (x, y) -> x in 0 until BLOCK_COLUMNS && y < BLOCK_ROWS && (y < 0 || board[y][x] == 0) } + + fun move(dx: Int): BlockGame = if (!alive) this else piece.copy(x = piece.x + dx).let { if (fits(it)) copy(piece = it) else this } + + /** A quarter turn, nudged sideways (or up one) if the wall or the stack is in the way; no turn if nothing fits. */ + fun turn(): BlockGame { + if (!alive) return this + val turned = piece.copy(turns = piece.turns + 1) + for ((dx, dy) in listOf(0 to 0, -1 to 0, 1 to 0, -2 to 0, 2 to 0, 0 to -1)) turned.copy(x = turned.x + dx, y = turned.y + dy).let { if (fits(it)) return copy(piece = it) } + return this + } + + /** One row down by gravity or by the remote ([pressed] scores a point); a piece that can't fall locks in. */ + fun fall(pressed: Boolean = false, random: java.util.Random): BlockGame { + if (!alive) return this + val down = piece.copy(y = piece.y + 1) + return if (fits(down)) copy(piece = down, score = score + if (pressed) 1 else 0) else lock(random) + } + + /** All the way down at once, two points a row. */ + fun drop(random: java.util.Random): BlockGame { + if (!alive) return this + var p = piece; var rows = 0 + while (fits(p.copy(y = p.y + 1))) { p = p.copy(y = p.y + 1); rows++ } + return copy(piece = p, score = score + rows * 2).lock(random) + } + + /** Where the piece would land: drawn faintly as a guide. */ + fun landing(): BlockPiece { var p = piece; while (fits(p.copy(y = p.y + 1))) p = p.copy(y = p.y + 1); return p } + + private fun lock(random: java.util.Random): BlockGame { + val rows = board.map { it.copyOf() } + var above = false + piece.cells().forEach { (x, y) -> if (y < 0) above = true else rows[y][x] = piece.kind + 1 } + val kept = rows.filter { row -> row.any { it == 0 } } + val cleared = BLOCK_ROWS - kept.size + val board2 = List(cleared) { IntArray(BLOCK_COLUMNS) } + kept + val points = listOf(0, 100, 300, 500, 800)[cleared] * level + val (nextKind, bag2) = draw(bag, random) + val spawned = spawn(next) + val game = copy(board = board2, piece = spawned, next = nextKind, bag = bag2, score = score + points, lines = lines + cleared) + return if (above || !game.fits(spawned)) game.copy(alive = false) else game + } + + companion object { + fun spawn(kind: Int) = BlockPiece(kind, 0, (BLOCK_COLUMNS - ShapeCells[kind].first) / 2, if (kind == 0) -1 else 0) + /** The next shape from a shuffled bag of all seven, refilled when empty. */ + fun draw(bag: List, random: java.util.Random): Pair> { + val full = bag.ifEmpty { (0 until ShapeCells.size).toMutableList().also { java.util.Collections.shuffle(it, random) } } + return full.first() to full.drop(1) + } + fun start(random: java.util.Random): BlockGame { + val (first, bag1) = draw(emptyList(), random); val (second, bag2) = draw(bag1, random) + return BlockGame(List(BLOCK_ROWS) { IntArray(BLOCK_COLUMNS) }, spawn(first), second, bag2) + } + } +} + +private val Marquee = FontFamily(Font(R.font.anton_regular)) +private val Honey = Color(0xFFFFD84A) +/** Orbit's colours for the seven shapes: the channel palette, not anyone else's. */ +internal val BlockColours = listOf(Color(0xFF39C6F4), Color(0xFFFFD84A), Color(0xFFB36BFF), Color(0xFF6EDCAA), Color(0xFFFF6B6B), Color(0xFF4C7DFF), Color(0xFFFF9F43)) + +private fun DrawScope.cell(x: Int, y: Int, size: Float, origin: Offset, colour: Color, alpha: Float = 1f) { + val at = Offset(origin.x + x * size + size * .06f, origin.y + y * size + size * .06f); val s = Size(size * .88f, size * .88f) + drawRoundRect(colour.copy(alpha = alpha), at, s, CornerRadius(size * .18f)) + if (alpha == 1f) drawRoundRect(Color.White.copy(alpha = .22f), at, Size(s.width, s.height * .32f), CornerRadius(size * .18f)) +} + +@Composable +internal fun BlockField(game: BlockGame, modifier: Modifier) = Canvas(modifier) { + val size = minOf(this.size.width / BLOCK_COLUMNS, this.size.height / BLOCK_ROWS) + val origin = Offset((this.size.width - size * BLOCK_COLUMNS) / 2, (this.size.height - size * BLOCK_ROWS) / 2) + drawRect(Color(0xFF050E2A), origin, Size(size * BLOCK_COLUMNS, size * BLOCK_ROWS)) + for (y in 0 until BLOCK_ROWS) for (x in 0 until BLOCK_COLUMNS) { + val v = game.board[y][x] + if (v > 0) cell(x, y, size, origin, BlockColours[v - 1]) else drawRect(Color(0xFF0B1B44), Offset(origin.x + x * size + size * .45f, origin.y + y * size + size * .45f), Size(size * .1f, size * .1f)) + } + if (game.alive) { + game.landing().cells().forEach { (x, y) -> if (y >= 0) cell(x, y, size, origin, BlockColours[game.piece.kind], .22f) } + game.piece.cells().forEach { (x, y) -> if (y >= 0) cell(x, y, size, origin, BlockColours[game.piece.kind]) } + } +} + +@Composable +private fun NextPiece(kind: Int, modifier: Modifier) = Canvas(modifier) { + val size = this.size.minDimension / 4 + val cells = shapeCells(kind, 0) + val w = cells.maxOf { it.first } + 1; val h = cells.maxOf { it.second } + 1 + val origin = Offset((this.size.width - w * size) / 2, (this.size.height - h * size) / 2) + cells.forEach { (x, y) -> cell(x, y, size, origin, BlockColours[kind]) } +} + +@Composable +internal fun BlockDropScreen(best: Int, onBest: (Int) -> Unit, onLeave: () -> Unit) { + val random = remember { java.util.Random() } + var game by remember { mutableStateOf(BlockGame.start(random)) } + var paused by remember { mutableStateOf(false) } + val focus = remember { FocusRequester() } + val windowFocused = LocalWindowInfo.current.isWindowFocused + LaunchedEffect(windowFocused) { if (!windowFocused && game.alive) paused = true } + LaunchedEffect(paused, game.alive) { + while (!paused && game.alive) { kotlinx.coroutines.delay(game.tickMs); game = game.fall(random = random) } + if (!game.alive && game.score > best) onBest(game.score) + } + val menuFocus = remember { FocusRequester() } + LaunchedEffect(paused, game.alive) { if (paused || !game.alive) menuFocus.requestFocus() else focus.requestFocus() } + BackHandler { if (game.alive) 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.alive) return@onPreviewKeyEvent false + val next = when (native.keyCode) { + KeyEvent.KEYCODE_DPAD_LEFT -> game.move(-1) + KeyEvent.KEYCODE_DPAD_RIGHT -> game.move(1) + KeyEvent.KEYCODE_DPAD_UP -> game.turn() + KeyEvent.KEYCODE_DPAD_DOWN -> game.fall(pressed = true, random = random) + KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER -> game.drop(random) + else -> null + } + if (next != null) game = next + next != null + }, horizontalArrangement = Arrangement.spacedBy(20.dp)) { + Column(Modifier.width(190.dp).fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text("BLOCK DROP", 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} · ROWS ${game.lines}", color = Muted, fontSize = 13.sp, fontWeight = FontWeight.SemiBold) + Spacer(Modifier.weight(1f)) + Text("◄► Move ▲ Turn", color = Muted, fontSize = 13.sp) + Text("▼ Down OK Drop", color = Muted, fontSize = 13.sp) + Text("Back Pause", color = Muted, fontSize = 13.sp) + } + Box(Modifier.weight(1f).fillMaxHeight().border(2.dp, Blue), contentAlignment = Alignment.Center) { + BlockField(game, Modifier.fillMaxSize().padding(6.dp)) + 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}", 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 = BlockGame.start(random) + paused = false + }) + OrbitButton("Leave the game", Modifier.width(200.dp), onClick = onLeave) + } + } + Column(Modifier.width(130.dp).fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text("NEXT", color = Muted, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 2.sp) + NextPiece(game.next, Modifier.size(110.dp).background(Color(0xFF050E2A), RoundedCornerShape(6.dp)).padding(10.dp)) + } + } +} diff --git a/android/app/src/main/java/tv/orbit/classic/OrbitArcade.kt b/android/app/src/main/java/tv/orbit/classic/OrbitArcade.kt index 3e55758..380eb7a 100644 --- a/android/app/src/main/java/tv/orbit/classic/OrbitArcade.kt +++ b/android/app/src/main/java/tv/orbit/classic/OrbitArcade.kt @@ -303,7 +303,8 @@ private fun OrbitHiveScreen(onLeave: () -> Unit) { 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.") + 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.") } @Composable @@ -311,7 +312,7 @@ private fun GameTile(game: ArcadeGame, focus: FocusRequester?, nav: FocusRequest var focused by remember { mutableStateOf(false) } Box((if (focus != null) Modifier.focusRequester(focus) else Modifier).size(135.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(if (game == ArcadeGame.Snake) "Snake" else "OrbitHive", color = if (focused) Navy else Color.White, fontSize = 18.sp, fontWeight = FontWeight.SemiBold) + Text(when (game) { ArcadeGame.Snake -> "Snake"; ArcadeGame.Hive -> "OrbitHive"; ArcadeGame.Blocks -> "Block Drop" }, color = if (focused) Navy else Color.White, fontSize = 18.sp, fontWeight = FontWeight.SemiBold) } } @@ -321,24 +322,31 @@ internal fun OrbitArcade(modifier: Modifier, onPlaying: (Boolean) -> Unit) { val context = LocalContext.current val settings = remember { OrbitSettings(context) } var best by remember { mutableIntStateOf(settings.snakeBest) } + var blockBest by remember { mutableIntStateOf(settings.blockDropBest) } var playing by remember { mutableStateOf(null) } var chosen by remember { mutableStateOf(ArcadeGame.Snake) } DisposableEffect(playing) { onPlaying(playing != null); onDispose { onPlaying(false) } } when (playing) { ArcadeGame.Snake -> { SnakeScreen(best, onBest = { best = it; settings.snakeBest = it }, onLeave = { playing = null }); return } ArcadeGame.Hive -> { OrbitHiveScreen(onLeave = { playing = null }); return } + ArcadeGame.Blocks -> { BlockDropScreen(blockBest, onBest = { blockBest = it; settings.blockDropBest = it }, onLeave = { playing = null }); return } null -> {} } val first = remember { FocusRequester() } LaunchedEffect(Unit) { first.requestFocus() } val nav = LocalNavFocus.current val preview = remember { SnakeGame(16, 9, listOf(SnakeCell(9, 4), SnakeCell(8, 4), SnakeCell(7, 4), SnakeCell(6, 4), SnakeCell(6, 5), SnakeCell(5, 5), SnakeCell(4, 5)), SnakeDirection.Right, SnakeDirection.Right, SnakeCell(12, 4)) } + val blockPreview = remember { + val board = List(BLOCK_ROWS) { y -> IntArray(BLOCK_COLUMNS) { x -> if (y >= 16 && (x + y) % 9 != 0) 1 + (x * 3 + y) % 7 else 0 } } + BlockGame(board, BlockPiece(2, 1, 4, 9), 0, emptyList()) + } 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 Canvas(Modifier.fillMaxSize()) { val colours = listOf(Color(0xFFE53935), Color(0xFF1E66D0), Color(0xFF2E9D3A), Color(0xFFF4F4F4), Color(0xFFF5A623), Color(0xFF8E44AD)) val r = size.height / 9 @@ -349,7 +357,7 @@ internal fun OrbitArcade(modifier: Modifier, onPlaying: (Boolean) -> Unit) { } } Text(chosen.title, color = Honey, fontFamily = Marquee, fontSize = 30.sp) - Text(if (chosen == ArcadeGame.Snake && best > 0) "Your best: $best" else chosen.blurb, color = Color.White, fontSize = 15.sp, maxLines = 2) + Text(if (chosen == ArcadeGame.Snake && best > 0) "Your best: $best" else if (chosen == ArcadeGame.Blocks && blockBest > 0) "Your best: $blockBest" else chosen.blurb, color = Color.White, fontSize = 15.sp, maxLines = 2) } Column(Modifier.width(290.dp).fillMaxHeight(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) { Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { @@ -357,10 +365,13 @@ internal fun OrbitArcade(modifier: Modifier, onPlaying: (Boolean) -> Unit) { GameTile(ArcadeGame.Hive, null, nav, { chosen = ArcadeGame.Hive }, { playing = ArcadeGame.Hive }) } Spacer(Modifier.height(4.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { + GameTile(ArcadeGame.Blocks, null, null, { chosen = ArcadeGame.Blocks }, { playing = ArcadeGame.Blocks }) Box(Modifier.size(110.dp), contentAlignment = Alignment.Center) { Canvas(Modifier.fillMaxSize()) { drawPath(hexagon(center, size.minDimension / 2), Panel.copy(alpha = .4f)) } Text("More soon", color = Muted, fontSize = 12.sp) } + } } } Text("◄► Choose OK Play Back Main menu", color = Muted, fontSize = 11.sp) diff --git a/android/app/src/main/java/tv/orbit/classic/Settings.kt b/android/app/src/main/java/tv/orbit/classic/Settings.kt index f81d492..2161b8e 100644 --- a/android/app/src/main/java/tv/orbit/classic/Settings.kt +++ b/android/app/src/main/java/tv/orbit/classic/Settings.kt @@ -44,6 +44,9 @@ internal class OrbitSettings(context: Context) { var snakeBest: Int get() = saved.getInt("snake_best", 0) set(value) { saved.edit().putInt("snake_best", value).apply() } + var blockDropBest: Int + get() = saved.getInt("block_drop_best", 0) + set(value) { saved.edit().putInt("block_drop_best", value).apply() } var subtitlesOn: Boolean get() = saved.getBoolean("subtitles_on", false) set(value) { saved.edit().putBoolean("subtitles_on", value).apply() } diff --git a/android/app/src/test/java/tv/orbit/classic/BlockDropTest.kt b/android/app/src/test/java/tv/orbit/classic/BlockDropTest.kt new file mode 100644 index 0000000..27af307 --- /dev/null +++ b/android/app/src/test/java/tv/orbit/classic/BlockDropTest.kt @@ -0,0 +1,48 @@ +package tv.orbit.classic + +import org.junit.Assert.* +import org.junit.Test + +/** Block Drop: moves, turns with nudges off the walls, full rows clear for points, the bag deals every shape, and the stack ends it. */ +class BlockDropTest { + private val random = java.util.Random(7) + private fun empty() = List(BLOCK_ROWS) { IntArray(BLOCK_COLUMNS) } + + @Test fun fourTurnsComeBackToTheStart() { + for (kind in 0 until 7) assertEquals(shapeCells(kind, 0).toSet(), shapeCells(kind, 4).toSet()) + assertEquals(4, shapeCells(0, 1).size) + } + + @Test fun movesStopAtTheWallsAndATurnIsNudgedOffThem() { + var g = BlockGame(empty(), BlockPiece(0, 1, 0, 2), 1, emptyList()) // the bar, upright + repeat(12) { g = g.move(-1) } + assertTrue(g.piece.cells().all { it.first >= 0 }) + val turned = g.turn() + assertNotEquals(g.piece.turns, turned.piece.turns) // turned, nudged clear of the wall + assertTrue(turned.piece.cells().all { it.first in 0 until BLOCK_COLUMNS }) + } + + @Test fun aFullRowClearsAndScoresByLevel() { + val board = empty().also { b -> for (x in 0 until BLOCK_COLUMNS - 4) b[BLOCK_ROWS - 1][x] = 3 } + val g = BlockGame(board, BlockPiece(0, 0, BLOCK_COLUMNS - 4, 5), 1, emptyList()) // the flat bar fills the gap + val after = g.drop(random) + assertEquals(1, after.lines) + assertTrue(after.board[BLOCK_ROWS - 1].all { it == 0 }) + assertEquals(100 + (BLOCK_ROWS - 2 - 5) * 2, after.score) // the row, plus two points a row dropped + assertTrue(after.alive) + } + + @Test fun theBagDealsEveryShapeBeforeRepeating() { + var bag = emptyList(); val seen = mutableListOf() + repeat(7) { val (k, b) = BlockGame.draw(bag, random); seen += k; bag = b } + assertEquals((0 until 7).toSet(), seen.toSet()) + } + + @Test fun aStackToTheTopEndsTheGameAndTheLevelQuickens() { + val board = empty().also { b -> for (y in 2 until BLOCK_ROWS) for (x in 0 until BLOCK_COLUMNS) if (x != y % BLOCK_COLUMNS) b[y][x] = 1 } + val over = BlockGame(board, BlockPiece(1, 0, 4, 0), 1, emptyList()).drop(random) + assertFalse(over.alive) + assertTrue(BlockGame(empty(), BlockPiece(1, 0, 4, 0), 1, emptyList(), lines = 50).tickMs < BlockGame.start(random).tickMs) + assertEquals(90L, BlockGame(empty(), BlockPiece(1, 0, 4, 0), 1, emptyList(), lines = 500).tickMs) + } +}