Skip to content

Instantly share code, notes, and snippets.

@Kyriakos-Georgiopoulos
Created July 9, 2026 14:21
Show Gist options
  • Select an option

  • Save Kyriakos-Georgiopoulos/467e9b2d0bd3336e73c1d37c16775239 to your computer and use it in GitHub Desktop.

Select an option

Save Kyriakos-Georgiopoulos/467e9b2d0bd3336e73c1d37c16775239 to your computer and use it in GitHub Desktop.
/*
* Copyright 2026 Kyriakos Georgiopoulos
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import android.graphics.BlurMaskFilter
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.waitForUpOrCancellation
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.draw.scale
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlin.math.min
import kotlin.math.pow
/**
* Gameplay timing. Gravity starts at [BASE_TICK_MS] per row and speeds up by
* [SPEED_STEP_PER_LEVEL_MS] each level, floored at [MIN_TICK_MS]. Held D-pad input
* auto-repeats arcade-style: DAS (Delayed Auto Shift) waits [DAS_DELAY_MS] after the
* first move, then ARR (Auto Repeat Rate) fires every [ARR_INTERVAL_MS].
*/
private object BrickConfig {
const val BASE_TICK_MS = 800L
const val SPEED_STEP_PER_LEVEL_MS = 60L
const val MIN_TICK_MS = 100L
const val DAS_DELAY_MS = 200L
const val ARR_INTERVAL_MS = 50L
}
/** Shared by every physical control: stiff enough to settle within the key travel, no visible bounce. */
private val PressSpring = spring<Float>(stiffness = Spring.StiffnessHigh)
// The seven tetrominoes as 0/1 row-major bitmaps.
private val I_PIECE = listOf(listOf(1, 1, 1, 1))
private val J_PIECE = listOf(listOf(1, 0, 0), listOf(1, 1, 1))
private val L_PIECE = listOf(listOf(0, 0, 1), listOf(1, 1, 1))
private val O_PIECE = listOf(listOf(1, 1), listOf(1, 1))
private val S_PIECE = listOf(listOf(0, 1, 1), listOf(1, 1, 0))
private val T_PIECE = listOf(listOf(0, 1, 0), listOf(1, 1, 1))
private val Z_PIECE = listOf(listOf(1, 1, 0), listOf(0, 1, 1))
private val SHAPES = listOf(I_PIECE, J_PIECE, L_PIECE, O_PIECE, S_PIECE, T_PIECE, Z_PIECE)
/**
* A falling tetromino: a [matrix] bitmap positioned at board column [x] / row [y].
* Immutable on purpose — moves and rotations produce copies, so every step is a
* distinct snapshot value the draw phase can observe.
*/
@Immutable
private data class BrickPiece(val matrix: List<List<Int>>, val x: Int = 3, val y: Int = 0) {
/** 90° clockwise rotation: `new(i)(j) = old(rows - 1 - j)(i)`. O is rotation-symmetric, skip the copy. */
fun rotated(): BrickPiece {
if (matrix == O_PIECE) return this
val r = matrix.size
val c = matrix[0].size
val newMatrix = List(c) { i -> List(r) { j -> matrix[r - 1 - j][i] } }
return copy(matrix = newMatrix)
}
}
/**
* Classic falling-blocks rules on a 10×20 board. Rows 0–1 are the hidden spawn zone —
* only rows 2..19 are rendered, so new pieces slide into view instead of popping in.
* Scoring uses the classic 40/100/300/1200 × level table and the level rises every
* 10 cleared lines.
*
* Every mutable property is snapshot state: the LCD reads [board] and [currentPiece]
* in the draw phase, so a gravity tick repaints pixels without recomposing anything.
*/
@Stable
private class BrickGame {
var board by mutableStateOf(List(20) { List(10) { 0 } })
var currentPiece by mutableStateOf(BrickPiece(SHAPES.random()))
var nextPiece by mutableStateOf(BrickPiece(SHAPES.random()))
var score by mutableIntStateOf(0)
var level by mutableIntStateOf(1)
var lines by mutableIntStateOf(0)
var isGameOver by mutableStateOf(false)
fun moveLeft() {
if (isValid(currentPiece.matrix, currentPiece.x - 1, currentPiece.y)) {
currentPiece = currentPiece.copy(x = currentPiece.x - 1)
}
}
fun moveRight() {
if (isValid(currentPiece.matrix, currentPiece.x + 1, currentPiece.y)) {
currentPiece = currentPiece.copy(x = currentPiece.x + 1)
}
}
fun rotate() {
val p = currentPiece.rotated()
if (isValid(p.matrix, p.x, p.y)) currentPiece = p
}
/** Teleports the piece to the lowest valid row and locks it in one move. */
fun hardDrop() {
var dropY = currentPiece.y
while (isValid(currentPiece.matrix, currentPiece.x, dropY + 1)) dropY++
currentPiece = currentPiece.copy(y = dropY)
lockAndSpawn()
}
/** One gravity step: fall one row, or lock the piece once it rests on something. */
fun tick() {
if (isValid(currentPiece.matrix, currentPiece.x, currentPiece.y + 1)) {
currentPiece = currentPiece.copy(y = currentPiece.y + 1)
} else {
lockAndSpawn()
}
}
fun reset() {
board = List(20) { List(10) { 0 } }
score = 0
level = 1
lines = 0
isGameOver = false
currentPiece = BrickPiece(SHAPES.random())
nextPiece = BrickPiece(SHAPES.random())
}
/** True if [matrix] placed at ([cx], [cy]) stays on the board and overlaps no settled block. */
private fun isValid(matrix: List<List<Int>>, cx: Int, cy: Int): Boolean {
for (r in matrix.indices) {
for (c in matrix[r].indices) {
if (matrix[r][c] == 1) {
val nx = cx + c
val ny = cy + r
if (nx !in 0..9 || ny !in 0..19 || board[ny][nx] == 1) return false
}
}
}
return true
}
/** Stamps the piece into the board, clears and scores full lines, then spawns the next piece. */
private fun lockAndSpawn() {
val newBoard = board.map { it.toMutableList() }.toMutableList()
var gameOver = false
for (r in currentPiece.matrix.indices) {
for (c in currentPiece.matrix[r].indices) {
if (currentPiece.matrix[r][c] == 1) {
val ny = currentPiece.y + r
if (ny < 0) gameOver = true
else newBoard[ny][currentPiece.x + c] = 1
}
}
}
if (gameOver) {
isGameOver = true
return
}
val fullLines = newBoard.indices.filter { r -> newBoard[r].all { it == 1 } }
fullLines.reversed().forEach { newBoard.removeAt(it) }
repeat(fullLines.size) { newBoard.add(0, MutableList(10) { 0 }) }
board = newBoard
lines += fullLines.size
level = 1 + (lines / 10)
score += when (fullLines.size) {
1 -> 40 * level
2 -> 100 * level
3 -> 300 * level
4 -> 1200 * level
else -> 0
}
currentPiece = nextPiece.copy(x = 3, y = 0)
nextPiece = BrickPiece(SHAPES.random())
if (!isValid(currentPiece.matrix, currentPiece.x, currentPiece.y)) isGameOver = true
}
}
// Palette sampled from photo reference of the classic 1989 handheld. Casing greys come as a
// highlight/base/shadow triplet so every surface can fake the same top-left
// key light; the LCD pair (base/dark tint) reproduces the olive "pea soup"
// screen with its faintly visible off pixels.
private val CasingBase = Color(0xFFCFCFCB)
private val CasingHighlight = Color(0xFFEBEBEB)
private val CasingShadow = Color(0xFFA0A09B)
private val BezelBase = Color(0xFF5A5B64)
private val BezelDark = Color(0xFF323339)
private val ScreenBase = Color(0xFF7D9221)
private val ScreenDark = Color(0xFF6B8018)
private val PixelDark = Color(0xFF1E3814)
private val ButtonMagentaBase = Color(0xFF9D1E5C)
private val ButtonMagentaHighlight = Color(0xFFCE307E)
private val ButtonMagentaShadow = Color(0xFF5A0C30)
private val DPadBase = Color(0xFF191919)
private val LogoBlue = Color(0xFF1B2264)
/**
* One star in the background parallax field. [xOffset]/[yOffset] are normalized 0..1
* positions; [speed] multiplies the global drift phase, so faster (nearer) stars streak
* past slower (farther) ones — cheap parallax from a single animated value.
*/
@Immutable
private data class RetroStar(
val xOffset: Float,
val yOffset: Float,
val radius: Float,
val alpha: Float,
val speed: Float
)
/**
* Arcade-grade press handling. [onPress] fires on the raw pointer-down — no tap slop,
* no waiting for up like `clickable` — which is what makes the controls feel zero-latency.
* With [repeating], held input replays DAS/ARR style (one move, a beat, then a rapid
* stream), matching how the original console treats a held D-pad.
*
* [isPressed] is only written here, never read, so consumers decide the observation
* phase — this file reads it exclusively inside draw lambdas and snapshotFlows,
* keeping button mashing recomposition-free.
*/
private fun Modifier.arcadeButton(
isPressed: MutableState<Boolean>? = null,
repeating: Boolean = false,
onPress: () -> Unit
): Modifier = pointerInput(Unit) {
coroutineScope {
awaitPointerEventScope {
while (true) {
awaitFirstDown(requireUnconsumed = false)
isPressed?.value = true
onPress()
val repeatJob = if (repeating) {
launch {
delay(BrickConfig.DAS_DELAY_MS)
while (true) {
delay(BrickConfig.ARR_INTERVAL_MS)
onPress()
}
}
} else null
waitForUpOrCancellation()
isPressed?.value = false
repeatJob?.cancel()
}
}
}
}
@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF)
@Composable
fun BrickGameScreenPreview() {
BrickGameScreen()
}
/**
* The Jetpack Compose BRICK GAME — a playable retro handheld rendered entirely with
* Compose draw primitives — no bitmaps. The hardware art is authored on a fixed
* 350×630dp canvas and uniformly scaled to the viewport, so every hand-tuned
* proportion survives any screen size.
*/
@Composable
fun BrickGameScreen() {
val game = remember { BrickGame() }
BrickGravity(game)
BoxWithConstraints(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
RetroAnimatedBackground()
val scaleX = maxWidth / 350.dp
val scaleY = maxHeight / 630.dp
val dynamicScale = min(scaleX, scaleY) * 0.92f
Box(modifier = Modifier.scale(dynamicScale)) {
ConsoleRealistic(game)
}
}
}
/** Gravity tick loop. snapshotFlow restarts it on level/game-over changes without recomposing. */
@Composable
private fun BrickGravity(game: BrickGame) {
LaunchedEffect(Unit) {
snapshotFlow { game.isGameOver to game.level }
.collectLatest { (isGameOver, level) ->
if (isGameOver) return@collectLatest
val tickMs =
(BrickConfig.BASE_TICK_MS - (level - 1) * BrickConfig.SPEED_STEP_PER_LEVEL_MS)
.coerceAtLeast(BrickConfig.MIN_TICK_MS)
while (true) {
delay(tickMs)
game.tick()
}
}
}
}
/**
* Synthwave backdrop in four layers: gradient sky, parallax starfield, a scan-lined sun,
* and a perspective floor grid. Perspective is faked with two tricks — the sun's dark
* bands sit at `horizonY - i² · step` (quadratic spacing compresses toward the horizon,
* like foreshortening) and the floor's horizontal lines at `1.4^i` (exponential spacing
* expands toward the viewer). Animating the exponent/index by one full unit per cycle
* makes both loops seamless. All brushes and px constants are cached; the three phase
* values are read only inside the draw pass, so 60fps costs zero recompositions.
*/
@Composable
private fun RetroAnimatedBackground() {
val infiniteTransition = rememberInfiniteTransition(label = "arcade_bg")
val gridPhase by infiniteTransition.animateFloat(
initialValue = 0f, targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(600, easing = LinearEasing),
repeatMode = RepeatMode.Restart
), label = "grid"
)
val sunPhase by infiniteTransition.animateFloat(
initialValue = 0f, targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(2000, easing = LinearEasing),
repeatMode = RepeatMode.Restart
), label = "sun"
)
val starPhase by infiniteTransition.animateFloat(
initialValue = 0f, targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(30000, easing = LinearEasing),
repeatMode = RepeatMode.Restart
), label = "stars"
)
val stars = remember {
val random = kotlin.random.Random(42)
List(200) {
RetroStar(
random.nextFloat(),
random.nextFloat(),
random.nextFloat() * 2.5f + 0.5f,
random.nextFloat() * 0.7f + 0.1f,
random.nextFloat() * 3f + 1f
)
}
}
Spacer(
modifier = Modifier
.fillMaxSize()
.drawWithCache {
val horizonY = size.height * 0.65f
val skySize = Size(size.width, horizonY)
val skyBrush = Brush.verticalGradient(
colors = listOf(
Color(0xFF070212),
Color(0xFF240B45),
Color(0xFF7D145F),
Color(0xFFF75C54)
),
startY = 0f, endY = horizonY
)
val starFieldHeight = horizonY * 0.8f
val sunRadius = min(size.width, size.height) * 0.55f
val sunCenter = Offset(size.width / 2, horizonY)
val sunBrush = Brush.verticalGradient(
colors = listOf(Color(0xFFFFE53B), Color(0xFFFF2525)),
startY = sunCenter.y - sunRadius,
endY = sunCenter.y
)
val scanColor = Color(0xFFF75C54)
val scanStepPx = 1.8f.dp.toPx()
val scanUnitPx = 1.2f.dp.toPx()
val horizonBrush = Brush.horizontalGradient(
colors = listOf(Color.Transparent, Color(0xFF00FFFF), Color.Transparent)
)
val horizonStrokePx = 6.dp.toPx()
val groundBrush = Brush.verticalGradient(
colors = listOf(Color(0xFF130624), Color(0xFF05020B)),
startY = horizonY,
endY = size.height
)
val groundTopLeft = Offset(0f, horizonY)
val groundSize = Size(size.width, size.height - horizonY)
val gridColor = Color(0xFF00FFFF)
val gridInitialStep = 6.dp.toPx()
val gridStrokePx = 1.5f.dp.toPx()
val vpX = size.width / 2
val vpY = horizonY - 60.dp.toPx()
val vLineSpacingPx = 80.dp.toPx()
onDrawBehind {
drawRect(brush = skyBrush, size = skySize)
stars.forEach { star ->
val x = ((star.xOffset + starPhase * star.speed) % 1f) * size.width
val y = star.yOffset * starFieldHeight
drawCircle(
color = Color.White.copy(alpha = star.alpha),
radius = star.radius,
center = Offset(x, y)
)
}
drawCircle(brush = sunBrush, radius = sunRadius, center = sunCenter)
for (i in -2..25) {
val scanIndex = i - sunPhase
val lineY = horizonY - (scanIndex * scanIndex * scanStepPx)
if (lineY < sunCenter.y - sunRadius || lineY > horizonY) continue
val thickness = (20f - scanIndex).coerceAtLeast(0f) * scanUnitPx
drawLine(
color = scanColor,
start = Offset(0f, lineY),
end = Offset(size.width, lineY),
strokeWidth = thickness
)
}
drawLine(
brush = horizonBrush,
start = Offset(0f, horizonY),
end = Offset(size.width, horizonY),
strokeWidth = horizonStrokePx
)
drawRect(brush = groundBrush, topLeft = groundTopLeft, size = groundSize)
clipRect(left = 0f, top = horizonY, right = size.width, bottom = size.height) {
for (i in -5..25) {
val powY = 1.4f.pow(i + gridPhase)
val lineY = horizonY + gridInitialStep * powY
if (lineY > horizonY && lineY < size.height) {
val lineAlpha =
((lineY - horizonY) / (size.height - horizonY)).coerceIn(0.1f, 1f)
drawLine(
gridColor.copy(alpha = lineAlpha * 0.6f),
Offset(0f, lineY),
Offset(size.width, lineY),
gridStrokePx
)
}
}
for (i in -40..40) {
val endX = vpX + (i * vLineSpacingPx)
drawLine(
gridColor.copy(alpha = 0.4f),
Offset(vpX, vpY),
Offset(endX, size.height),
gridStrokePx
)
}
}
}
}
)
}
/**
* The console shell. Depth comes from a two-shadow setup borrowed from product
* photography: a wide, faint ambient blur (45dp, 31% black) plus a tight, dark
* contact blur (15dp, 63% black) — one blur alone reads flat. The body gradient
* runs highlight→shadow along the same top-left key light as every control, and
* the asymmetric 64dp corner is the retro handheld's signature bottom-right curve.
*/
@Composable
private fun ConsoleRealistic(game: BrickGame) {
Box(
modifier = Modifier
.width(350.dp)
.height(630.dp)
.drawWithCache {
val ambientPaint = android.graphics.Paint().apply {
color = android.graphics.Color.argb(80, 0, 0, 0)
maskFilter = BlurMaskFilter(45.dp.toPx(), BlurMaskFilter.Blur.NORMAL)
}
val contactPaint = android.graphics.Paint().apply {
color = android.graphics.Color.argb(160, 0, 0, 0)
maskFilter = BlurMaskFilter(15.dp.toPx(), BlurMaskFilter.Blur.NORMAL)
}
val cornerRad = 64.dp.toPx()
val ambientOffsetX = 15.dp.toPx()
val ambientOffsetY = 25.dp.toPx()
val contactOffsetX = 5.dp.toPx()
val contactOffsetY = 10.dp.toPx()
onDrawBehind {
drawIntoCanvas { canvas ->
canvas.nativeCanvas.drawRoundRect(
ambientOffsetX,
ambientOffsetY,
size.width + ambientOffsetX,
size.height + ambientOffsetY,
cornerRad,
cornerRad,
ambientPaint
)
canvas.nativeCanvas.drawRoundRect(
contactOffsetX,
contactOffsetY,
size.width + contactOffsetX,
size.height + contactOffsetY,
cornerRad,
cornerRad,
contactPaint
)
}
}
}
.clip(
RoundedCornerShape(
topStart = 16.dp,
topEnd = 16.dp,
bottomEnd = 64.dp,
bottomStart = 16.dp
)
)
.background(
Brush.linearGradient(
colors = listOf(
CasingHighlight,
CasingBase,
CasingShadow
),
start = Offset(0f, 0f),
end = Offset(Float.POSITIVE_INFINITY, Float.POSITIVE_INFINITY)
)
)
.padding(1.dp)
) {
Column(modifier = Modifier.fillMaxSize()) {
TopGrooves()
Spacer(modifier = Modifier.height(16.dp))
ScreenBezel(modifier = Modifier.padding(horizontal = 24.dp), game = game)
LogoSection()
Spacer(modifier = Modifier.weight(1f))
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 28.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
DPadRealistic(game)
ActionButtonsRealistic(game)
}
Spacer(modifier = Modifier.weight(1f))
Box(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 32.dp, start = 28.dp, end = 28.dp)
) {
Box(
modifier = Modifier
.align(Alignment.BottomStart)
.offset(x = 62.dp, y = (-25).dp)
) {
SystemButtonsRealistic(game)
}
Box(modifier = Modifier
.align(Alignment.BottomEnd)
.offset(x = 14.dp, y = 18.dp)) {
SpeakerGrillRealistic()
}
}
}
}
}
/**
* The grooves cut across the top edge and the ON/OFF power switch. A groove is two
* parallel lines — [CasingShadow] on the near wall, white on the far lip — because
* under a top-left light a recess darkens where it faces the light and catches a
* highlight on the opposite edge. A third faint dark line down the middle plays the
* cut itself. The switch pill inverts the trick (shadow up-left, highlight down-right)
* so it reads as raised.
*/
@Composable
private fun TopGrooves() {
Box(modifier = Modifier
.fillMaxWidth()
.height(26.dp)) {
val pillOffsetX = 44.dp
val pillWidth = 56.dp
Canvas(modifier = Modifier.fillMaxSize()) {
val stroke = 2.5.dp.toPx()
val y = size.height - stroke * 1.5f
val leftX = 24.dp.toPx()
val rightX = size.width - 24.dp.toPx()
drawLine(
color = CasingShadow,
start = Offset(0f, y),
end = Offset(size.width, y),
strokeWidth = stroke
)
drawLine(
color = Color.White.copy(alpha = 0.7f),
start = Offset(0f, y + stroke),
end = Offset(size.width, y + stroke),
strokeWidth = stroke
)
drawLine(
color = CasingShadow,
start = Offset(leftX, 0f),
end = Offset(leftX, y - stroke / 2),
strokeWidth = stroke
)
drawLine(
color = Color.White.copy(alpha = 0.7f),
start = Offset(leftX + stroke, 0f),
end = Offset(leftX + stroke, y + stroke / 2),
strokeWidth = stroke
)
drawLine(
color = CasingShadow,
start = Offset(rightX, 0f),
end = Offset(rightX, y - stroke / 2),
strokeWidth = stroke
)
drawLine(
color = Color.White.copy(alpha = 0.7f),
start = Offset(rightX + stroke, 0f),
end = Offset(rightX + stroke, y + stroke / 2),
strokeWidth = stroke
)
val cutStroke = stroke * 0.4f
drawLine(
color = Color.Black.copy(alpha = 0.15f),
start = Offset(0f, y + stroke * 0.3f),
end = Offset(size.width, y + stroke * 0.3f),
strokeWidth = cutStroke
)
drawLine(
color = Color.Black.copy(alpha = 0.15f),
start = Offset(leftX + stroke * 0.3f, 0f),
end = Offset(leftX + stroke * 0.3f, y),
strokeWidth = cutStroke
)
drawLine(
color = Color.Black.copy(alpha = 0.15f),
start = Offset(rightX + stroke * 0.3f, 0f),
end = Offset(rightX + stroke * 0.3f, y),
strokeWidth = cutStroke
)
val switchCenterX = pillOffsetX.toPx() + (pillWidth.toPx() * 0.30f)
val ridgeTop = 0f
val ridgeBottom = 4.5f.dp.toPx()
for (i in -1..1) {
val lineX = switchCenterX + (i * 4.5f.dp.toPx())
drawLine(
color = Color.White.copy(alpha = 0.9f),
start = Offset(lineX - 1.dp.toPx(), ridgeTop),
end = Offset(lineX - 1.dp.toPx(), ridgeBottom),
strokeWidth = 1.dp.toPx()
)
drawLine(
color = CasingBase,
start = Offset(lineX, ridgeTop),
end = Offset(lineX, ridgeBottom),
strokeWidth = 1.dp.toPx()
)
drawLine(
color = CasingShadow.copy(alpha = 0.95f),
start = Offset(lineX + 1.dp.toPx(), ridgeTop),
end = Offset(lineX + 1.dp.toPx(), ridgeBottom),
strokeWidth = 1.dp.toPx()
)
}
}
Box(
modifier = Modifier
.align(Alignment.TopStart)
.offset(x = pillOffsetX, y = 6.dp)
.width(pillWidth)
.height(13.dp)
.drawWithCache {
val corner = CornerRadius(size.height / 2)
val edgeBrush = Brush.linearGradient(
colors = listOf(
Color.Black.copy(alpha = 0.35f),
Color.Transparent,
Color.White.copy(alpha = 0.8f)
), start = Offset(0f, 0f), end = Offset(size.width, size.height)
)
val edgeStroke = Stroke(width = 1.dp.toPx())
val bevelPx = 1.dp.toPx()
onDrawBehind {
drawRoundRect(
color = Color.White.copy(alpha = 0.6f),
topLeft = Offset(bevelPx, bevelPx),
size = size,
cornerRadius = corner
)
drawRoundRect(
color = CasingShadow.copy(alpha = 0.8f),
topLeft = Offset(-bevelPx, -bevelPx),
size = size,
cornerRadius = corner
)
drawRoundRect(color = CasingBase, size = size, cornerRadius = corner)
drawRoundRect(
brush = edgeBrush,
size = size,
cornerRadius = corner,
style = edgeStroke
)
}
},
contentAlignment = Alignment.Center
) {
val text = "◀ OFF • ON ▶"
// Molded lettering: dark copy nudged down-right, light copy up-left,
// face color on top — three stacked Texts fake an embossed stamp.
Box(contentAlignment = Alignment.Center) {
Text(
text = text,
color = CasingShadow.copy(alpha = 0.9f),
fontSize = 7.5.sp,
fontWeight = FontWeight.ExtraBold,
fontFamily = FontFamily.SansSerif,
modifier = Modifier.offset(x = 0.5.dp, y = 0.5.dp)
)
Text(
text = text,
color = Color.White.copy(alpha = 0.9f),
fontSize = 7.5.sp,
fontWeight = FontWeight.ExtraBold,
fontFamily = FontFamily.SansSerif,
modifier = Modifier.offset(x = (-0.5).dp, y = (-0.5).dp)
)
Text(
text = text,
color = Color(0xFFE2E2DF),
fontSize = 7.5.sp,
fontWeight = FontWeight.ExtraBold,
fontFamily = FontFamily.SansSerif
)
}
}
}
}
/**
* The charcoal bezel around the LCD: radial gradient centered off the top-left so the
* panel catches light unevenly, a 4dp inner border stroke standing in for the glass
* inset, plus the printed trim lines and the "DOT MATRIX WITH ZERO RECOMPOSITION"
* legend — the original's marketing line swapped for this file's engineering claim.
*/
@Composable
private fun ScreenBezel(modifier: Modifier = Modifier, game: BrickGame) {
Box(
modifier = modifier
.fillMaxWidth()
.aspectRatio(1.1f)
.clip(
RoundedCornerShape(
topStart = 12.dp,
topEnd = 12.dp,
bottomEnd = 50.dp,
bottomStart = 12.dp
)
)
.background(
Brush.radialGradient(
colors = listOf(BezelBase, BezelDark),
center = Offset(400f, 400f),
radius = 800f
)
)
.drawBehind {
drawRoundRect(
color = Color.Black.copy(alpha = 0.4f),
size = size,
cornerRadius = CornerRadius(12.dp.toPx()),
style = Stroke(width = 4.dp.toPx())
)
}
.padding(16.dp)
) {
Column(modifier = Modifier.fillMaxSize()) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp, bottom = 4.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(1.5f.dp)
.background(ButtonMagentaBase)
)
Spacer(modifier = Modifier.height(2.dp))
Box(modifier = Modifier
.fillMaxWidth()
.height(1.5f.dp)
.background(LogoBlue))
}
Text(
"DOT MATRIX WITH ZERO RECOMPOSITION",
color = Color(0xFFAAAABB),
fontSize = 8.sp,
modifier = Modifier.padding(horizontal = 12.dp)
)
Column(modifier = Modifier.weight(1f)) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(1.5f.dp)
.background(ButtonMagentaBase)
)
Spacer(modifier = Modifier.height(2.dp))
Box(modifier = Modifier
.fillMaxWidth()
.height(1.5f.dp)
.background(LogoBlue))
}
}
Spacer(modifier = Modifier.height(10.dp))
Row(modifier = Modifier.fillMaxSize()) {
BatteryIndicator()
LCDScreen(game)
Spacer(modifier = Modifier.width(12.dp))
}
}
}
}
/**
* The battery LED, built as four layers of a lit glass dome: a soft red halo bleeding
* onto the bezel, a dark socket ring, a radial core whose hot spot sits off-center
* toward the light, and a small rotated specular streak on top.
*/
@Composable
private fun BatteryIndicator() {
Column(
modifier = Modifier
.width(36.dp)
.fillMaxHeight(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(30.dp))
Canvas(modifier = Modifier.size(20.dp)) {
val center = Offset(size.width / 2, size.height / 2)
val outerRadius = 5.dp.toPx()
val innerRadius = 4.dp.toPx()
drawCircle(
brush = Brush.radialGradient(
colors = listOf(
Color(0xFFFF2222).copy(alpha = 0.5f),
Color.Transparent
), center = center, radius = outerRadius * 2.5f
), radius = outerRadius * 2.5f, center = center
)
drawCircle(color = Color(0xFF111111), radius = outerRadius, center = center)
drawArc(
color = Color.White.copy(alpha = 0.2f),
startAngle = 0f,
sweepAngle = 90f,
useCenter = false,
topLeft = Offset(center.x - outerRadius, center.y - outerRadius),
size = Size(outerRadius * 2, outerRadius * 2),
style = Stroke(width = 1.dp.toPx())
)
drawCircle(
brush = Brush.radialGradient(
colors = listOf(
Color(0xFFFF9999),
Color(0xFFDD0000),
Color(0xFF550000)
),
center = center.copy(
x = center.x - innerRadius * 0.2f,
y = center.y - innerRadius * 0.2f
),
radius = innerRadius * 1.2f
), radius = innerRadius, center = center
)
rotate(
degrees = -45f,
pivot = center.copy(
x = center.x - innerRadius * 0.3f,
y = center.y - innerRadius * 0.3f
)
) {
drawOval(
color = Color.White.copy(alpha = 0.65f),
topLeft = Offset(center.x - innerRadius * 0.6f, center.y - innerRadius * 0.75f),
size = Size(innerRadius * 0.8f, innerRadius * 0.35f)
)
}
}
Spacer(modifier = Modifier.height(4.dp))
Text("BATTERY", color = Color(0xFFAAAABB), fontSize = 6.sp, fontWeight = FontWeight.Bold)
}
}
/**
* The dot-matrix LCD, split into two draw layers. The static parent draws the visible
* pixel grid, the dark side panel and an inset vignette (dark top/left, light bottom/right —
* the screen sits *behind* the bezel, so the shading is the inverse of a raised surface).
* The cached child layer bakes the brick-pattern walls into two Paths once per size and
* repaints only the falling blocks, so a gravity tick costs one draw pass and zero
* recompositions. Board rows 0–1 are the hidden spawn zone and are simply not drawn.
*/
@Composable
private fun RowScope.LCDScreen(game: BrickGame) {
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.background(ScreenBase)
.drawBehind {
val pad = 10.dp.toPx()
val innerHeight = size.height - (pad * 2)
val blockSize = innerHeight / 19f
val rightPanelX = pad + (blockSize * 12f)
val gridSpacing = 4.dp.toPx()
for (x in 0..rightPanelX.toInt() step gridSpacing.toInt()) {
drawLine(
color = ScreenDark,
start = Offset(x.toFloat(), 0f),
end = Offset(x.toFloat(), size.height),
strokeWidth = 1f
)
}
for (y in 0..size.height.toInt() step gridSpacing.toInt()) {
drawLine(
color = ScreenDark,
start = Offset(0f, y.toFloat()),
end = Offset(rightPanelX, y.toFloat()),
strokeWidth = 1f
)
}
drawRect(
color = PixelDark,
topLeft = Offset(rightPanelX, 0f),
size = Size(size.width - rightPanelX, size.height)
)
drawRect(
color = Color.Black.copy(alpha = 0.3f),
topLeft = Offset(0f, 0f),
size = Size(size.width, 6.dp.toPx())
)
drawRect(
color = Color.Black.copy(alpha = 0.3f),
topLeft = Offset(0f, 0f),
size = Size(6.dp.toPx(), size.height)
)
drawRect(
color = Color.White.copy(alpha = 0.2f),
topLeft = Offset(0f, size.height - 4f),
size = Size(size.width, 4f)
)
drawRect(
color = Color.White.copy(alpha = 0.2f),
topLeft = Offset(size.width - 4f, 0f),
size = Size(4f, size.height)
)
}
) {
Spacer(
modifier = Modifier
.fillMaxSize()
.padding(10.dp)
.drawWithCache {
val blockSize = size.height / 19f
val playFieldWidth = blockSize * 10f
val playFieldHeight = blockSize * 18f
val playFieldX = blockSize
val wallStroke = Stroke(width = 2.dp.toPx())
val brickStroke = Stroke(width = 1.5f.dp.toPx())
val blockStroke = Stroke(width = 1.5f.dp.toPx())
val blockPad = 1.dp.toPx()
val blockInnerPad = 3.dp.toPx()
val blockSide = blockSize - blockPad * 2
val blockOuterSize = Size(blockSide, blockSide)
val blockInnerSize =
Size(blockSide - blockInnerPad * 2, blockSide - blockInnerPad * 2)
// Walls and brick pattern are static per size — bake them into paths once.
val wallOutline = Path()
val brickPattern = Path()
fun addWall(x: Float, y: Float, w: Float, h: Float) {
wallOutline.addRect(Rect(x, y, x + w, y + h))
var currY = y + blockSize / 2f
var isShifted = false
while (currY < y + h) {
brickPattern.moveTo(x, currY)
brickPattern.lineTo(x + w, currY)
var currX = x + (if (isShifted) blockSize / 2f else 0f)
while (currX < x + w) {
brickPattern.moveTo(currX, currY - blockSize / 2f)
brickPattern.lineTo(currX, currY)
currX += blockSize
}
currY += blockSize / 2f
isShifted = !isShifted
}
}
addWall(0f, 0f, blockSize, playFieldHeight)
addWall(playFieldX + playFieldWidth, 0f, blockSize, playFieldHeight)
addWall(0f, playFieldHeight, playFieldWidth + blockSize * 2, blockSize)
wallOutline.addRect(
Rect(playFieldX, 0f, playFieldX + playFieldWidth, playFieldHeight)
)
onDrawBehind {
drawPath(wallOutline, PixelDark, style = wallStroke)
drawPath(brickPattern, PixelDark, style = brickStroke)
fun drawBrick(col: Int, row: Int) {
val topX = playFieldX + col * blockSize + blockPad
val topY = row * blockSize + blockPad
drawRect(
color = PixelDark,
topLeft = Offset(topX, topY),
size = blockOuterSize,
style = blockStroke
)
drawRect(
color = PixelDark,
topLeft = Offset(topX + blockInnerPad, topY + blockInnerPad),
size = blockInnerSize
)
}
for (r in 2..19) {
for (c in 0..9) {
if (game.board[r][c] == 1) drawBrick(c, r - 2)
}
}
if (!game.isGameOver) {
val piece = game.currentPiece
for (r in piece.matrix.indices) {
for (c in piece.matrix[r].indices) {
if (piece.matrix[r][c] == 1) {
val boardY = piece.y + r
if (boardY >= 2) drawBrick(piece.x + c, boardY - 2)
}
}
}
}
}
}
)
BrickStats(
game = game,
modifier = Modifier
.align(Alignment.CenterEnd)
.padding(end = 24.dp)
)
GameOverBadge(game)
}
}
/** Isolates score/level/lines text reads so per-lock recompositions stay off [LCDScreen]. */
@Composable
private fun BrickStats(game: BrickGame, modifier: Modifier = Modifier) {
Column(modifier = modifier, horizontalAlignment = Alignment.End) {
val labelWidth = 56.dp
StatLabel("SCORE", "${game.score}", labelWidth)
Spacer(modifier = Modifier.height(14.dp))
StatLabel("LEVEL", "${game.level}", labelWidth)
Spacer(modifier = Modifier.height(14.dp))
StatLabel("LINES", "${game.lines}", labelWidth)
Spacer(modifier = Modifier.height(14.dp))
NextPieceBox(game, labelWidth)
}
}
@Composable
private fun BoxScope.GameOverBadge(game: BrickGame) {
if (game.isGameOver) {
Box(
modifier = Modifier
.align(Alignment.Center)
.background(PixelDark)
.border(2.dp, ScreenBase)
.padding(horizontal = 16.dp, vertical = 12.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "GAME OVER",
color = ScreenBase,
fontWeight = FontWeight.Black,
fontSize = 16.sp
)
}
}
}
@Composable
private fun StatLabel(title: String, value: String, width: Dp) {
Column(
modifier = Modifier
.width(width)
.background(ScreenBase)
.padding(horizontal = 4.dp, vertical = 4.dp)
) {
Text(
text = title,
color = PixelDark,
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.align(Alignment.CenterHorizontally)
)
Text(
text = value,
color = PixelDark,
fontSize = 13.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.align(Alignment.End)
)
}
}
/** Preview of the upcoming piece, centered in its panel; reads [BrickGame.nextPiece] in the draw phase only. */
@Composable
private fun NextPieceBox(game: BrickGame, width: Dp) {
Box(
modifier = Modifier
.width(width)
.aspectRatio(1.25f)
.background(ScreenBase)
.padding(4.dp),
contentAlignment = Alignment.Center
) {
Spacer(
modifier = Modifier
.fillMaxSize()
.drawWithCache {
val blockSize = size.width / 4.5f
val blockPad = 1.dp.toPx()
val blockInnerPad = 2.dp.toPx()
val blockSide = blockSize - blockPad * 2
val blockOuterSize = Size(blockSide, blockSide)
val blockInnerSize =
Size(blockSide - blockInnerPad * 2, blockSide - blockInnerPad * 2)
val blockStroke = Stroke(width = 1.5f.dp.toPx())
onDrawBehind {
val matrix = game.nextPiece.matrix
val rCount = matrix.size
val cCount = matrix[0].size
val startX = (size.width - (cCount * blockSize)) / 2f
val startY = (size.height - (rCount * blockSize)) / 2f
for (r in 0 until rCount) {
for (c in 0 until cCount) {
if (matrix[r][c] == 1) {
val topX = startX + c * blockSize + blockPad
val topY = startY + r * blockSize + blockPad
drawRect(
color = PixelDark,
topLeft = Offset(topX, topY),
size = blockOuterSize,
style = blockStroke
)
drawRect(
color = PixelDark,
topLeft = Offset(
topX + blockInnerPad,
topY + blockInnerPad
),
size = blockInnerSize
)
}
}
}
}
}
)
}
}
@Composable
private fun LogoSection() {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 24.dp, top = 8.dp),
verticalAlignment = Alignment.Bottom
) {
Text(
"Jetpack Compose",
color = LogoBlue,
fontWeight = FontWeight.Bold,
fontSize = 14.sp,
fontFamily = FontFamily.SansSerif
)
Spacer(modifier = Modifier.width(6.dp))
Text(
"BRICK GAME",
color = LogoBlue,
fontWeight = FontWeight.Black,
fontStyle = FontStyle.Italic,
fontSize = 19.sp,
fontFamily = FontFamily.SansSerif
)
}
}
/**
* The D-pad, modeled as a real rocker: the cross pivots on a hollow center dome, so a
* press tilts the whole part ±10° around the opposite axis via `rotationX`/`rotationY`.
* `cameraDistance = 8f` is deliberately tiny (default is ~1280px) — a close camera
* exaggerates the perspective so even 10° reads clearly at 120dp. A radial shadow pool
* under the pressed arm sells the contact point.
*
* Up = hard drop, down = soft drop, left/right = shift (with DAS auto-repeat).
*/
@Composable
private fun DPadRealistic(game: BrickGame) {
val upPressed = remember { mutableStateOf(false) }
val downPressed = remember { mutableStateOf(false) }
val leftPressed = remember { mutableStateOf(false) }
val rightPressed = remember { mutableStateOf(false) }
// Animatable + snapshotFlow keeps press handling out of composition entirely:
// targets retarget mid-flight exactly like animateFloatAsState, but nothing recomposes.
val rotX = remember { Animatable(0f) }
val rotY = remember { Animatable(0f) }
LaunchedEffect(Unit) {
launch {
snapshotFlow {
if (upPressed.value) 10f else if (downPressed.value) -10f else 0f
}.collectLatest { rotX.animateTo(it, PressSpring) }
}
launch {
snapshotFlow {
if (leftPressed.value) -10f else if (rightPressed.value) 10f else 0f
}.collectLatest { rotY.animateTo(it, PressSpring) }
}
}
Box(modifier = Modifier.size(120.dp)) {
Canvas(modifier = Modifier.fillMaxSize()) {
val center = Offset(size.width / 2, size.height / 2)
val recessRadius = size.width / 2 - 2.dp.toPx()
drawCircle(
color = CasingHighlight,
radius = recessRadius,
center = center.copy(x = center.x + 1.5f.dp.toPx(), y = center.y + 1.5f.dp.toPx())
)
drawCircle(
color = CasingShadow,
radius = recessRadius,
center = center.copy(x = center.x - 1f.dp.toPx(), y = center.y - 1f.dp.toPx())
)
drawCircle(
brush = Brush.linearGradient(
colors = listOf(
CasingShadow.copy(alpha = 0.6f),
CasingBase,
CasingHighlight.copy(alpha = 0.4f)
), start = Offset(0f, 0f), end = Offset(size.width, size.height)
), radius = recessRadius, center = center
)
}
Spacer(
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
rotationX = rotX.value
rotationY = rotY.value
cameraDistance = 8f
}
.drawWithCache {
val center = Offset(size.width / 2, size.height / 2)
val recessRadius = size.width / 2 - 2.dp.toPx()
val crossLength = recessRadius * 1.45f
val crossWidth = crossLength * 0.35f
val startXY = (size.width - crossLength) / 2
val crossEnd = startXY + crossLength
val armStart = startXY + (crossLength - crossWidth) / 2
val armEnd = armStart + crossWidth
val dPadPath = Path().apply {
moveTo(armStart, startXY); lineTo(armEnd, startXY)
lineTo(armEnd, armStart); lineTo(crossEnd, armStart)
lineTo(crossEnd, armEnd); lineTo(armEnd, armEnd)
lineTo(armEnd, crossEnd); lineTo(armStart, crossEnd)
lineTo(armStart, armEnd); lineTo(startXY, armEnd)
lineTo(startXY, armStart); lineTo(armStart, armStart)
close()
}
val crossBrush = Brush.linearGradient(
colors = listOf(Color(0xFF333333), DPadBase, Color(0xFF0A0A0A)),
start = Offset(startXY, startXY),
end = Offset(crossEnd, crossEnd)
)
val edgeHighlightPath = Path().apply {
moveTo(armStart, startXY); lineTo(armEnd, startXY)
moveTo(startXY, armStart); lineTo(armStart, armStart)
lineTo(armStart, startXY)
moveTo(startXY, armStart); lineTo(startXY, armEnd)
}
val edgeStroke = Stroke(width = 2.dp.toPx())
val arrowHalfWidth = 4.dp.toPx()
val arrowRise = 4.dp.toPx()
val arrowDrop = 3.dp.toPx()
val arrowDist = recessRadius * 0.86f
val depthPx = 1.dp.toPx()
fun arrowPathAt(tip: Offset) = Path().apply {
moveTo(tip.x, tip.y - arrowRise)
lineTo(tip.x + arrowHalfWidth, tip.y + arrowDrop)
lineTo(tip.x - arrowHalfWidth, tip.y + arrowDrop)
close()
}
val arrows = listOf(
Offset(center.x, center.y - arrowDist) to 0f,
Offset(center.x, center.y + arrowDist) to 180f,
Offset(center.x - arrowDist, center.y) to -90f,
Offset(center.x + arrowDist, center.y) to 90f
).map { (pivot, degrees) -> Triple(arrowPathAt(pivot), pivot, degrees) }
val hollowRadius = crossWidth * 0.45f
val hollowShadowBrush = Brush.linearGradient(
colors = listOf(Color.Black.copy(alpha = 0.85f), Color.Transparent),
start = Offset(center.x - hollowRadius, center.y - hollowRadius),
end = Offset(center.x + hollowRadius, center.y + hollowRadius)
)
val hollowLightBrush = Brush.linearGradient(
colors = listOf(Color.Transparent, Color.White.copy(alpha = 0.25f)),
start = Offset(center.x - hollowRadius, center.y - hollowRadius),
end = Offset(center.x + hollowRadius, center.y + hollowRadius)
)
val gripWidth = crossWidth * 0.55f
val gripStartY = center.y - (crossLength / 2f) + 5.dp.toPx()
val gripSpacing = 4.dp.toPx()
val gripStrokePx = 1.5f.dp.toPx()
val gripRotations = floatArrayOf(0f, 180f, -90f, 90f)
val pressRadius = 18.dp.toPx()
val pressOffset = crossLength * 0.35f
val pressIndicators = listOf(
upPressed to center.copy(y = center.y - pressOffset),
downPressed to center.copy(y = center.y + pressOffset),
leftPressed to center.copy(x = center.x - pressOffset),
rightPressed to center.copy(x = center.x + pressOffset)
).map { (state, pos) ->
Triple(
state, pos,
Brush.radialGradient(
listOf(Color.Black.copy(alpha = 0.7f), Color.Transparent),
center = pos, radius = pressRadius
)
)
}
onDrawBehind {
drawPath(path = dPadPath, brush = crossBrush)
drawPath(
path = edgeHighlightPath,
color = Color(0xFF7A7A7A),
style = edgeStroke
)
// Carved arrows: dark pass up-left, light pass down-right — the
// inverse of the raised switch pill, so they read as engraved.
arrows.forEach { (path, pivot, degrees) ->
translate(left = -depthPx, top = -depthPx) {
rotate(degrees = degrees, pivot = pivot) {
drawPath(path, color = Color.Black.copy(alpha = 0.5f))
}
}
translate(left = depthPx, top = depthPx) {
rotate(degrees = degrees, pivot = pivot) {
drawPath(path, color = Color.White.copy(alpha = 0.6f))
}
}
rotate(degrees = degrees, pivot = pivot) {
drawPath(path, color = CasingShadow)
}
}
drawCircle(color = DPadBase, radius = hollowRadius, center = center)
drawCircle(
brush = hollowShadowBrush,
radius = hollowRadius,
center = center
)
drawCircle(brush = hollowLightBrush, radius = hollowRadius, center = center)
gripRotations.forEach { degrees ->
rotate(degrees = degrees, pivot = center) {
for (i in 0..2) {
val y = gripStartY + (i * gripSpacing)
drawLine(
color = Color.Black.copy(alpha = 0.9f),
start = Offset(center.x - gripWidth / 2f, y),
end = Offset(center.x + gripWidth / 2f, y),
strokeWidth = gripStrokePx
)
drawLine(
color = Color(0xFF555555),
start = Offset(center.x - gripWidth / 2f, y + gripStrokePx),
end = Offset(center.x + gripWidth / 2f, y + gripStrokePx),
strokeWidth = gripStrokePx
)
}
}
}
pressIndicators.forEach { (state, pos, brush) ->
if (state.value) drawCircle(
brush = brush,
radius = pressRadius,
center = pos
)
}
}
}
)
Box(
modifier = Modifier
.align(Alignment.TopCenter)
.size(40.dp)
.arcadeButton(upPressed, repeating = false) { game.hardDrop() })
Box(
modifier = Modifier
.align(Alignment.BottomCenter)
.size(40.dp)
.arcadeButton(downPressed, repeating = true) { game.tick() })
Box(
modifier = Modifier
.align(Alignment.CenterStart)
.size(40.dp)
.arcadeButton(leftPressed, repeating = true) { game.moveLeft() })
Box(
modifier = Modifier
.align(Alignment.CenterEnd)
.size(40.dp)
.arcadeButton(rightPressed, repeating = true) { game.moveRight() })
}
}
/** The A/B cluster: both buttons sit on a shared recessed pill, tilted -22° like the original. */
@Composable
private fun ActionButtonsRealistic(game: BrickGame) {
Box(
modifier = Modifier
.width(135.dp)
.height(120.dp),
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.width(116.dp)
.height(54.dp)
.rotate(-22f)
) {
Canvas(modifier = Modifier.fillMaxSize()) {
val corner = CornerRadius(size.height / 2, size.height / 2)
drawRoundRect(
color = CasingHighlight,
topLeft = Offset(1.5f.dp.toPx(), 1.5f.dp.toPx()),
size = size,
cornerRadius = corner
)
drawRoundRect(
color = CasingShadow,
topLeft = Offset(-1.dp.toPx(), -1.dp.toPx()),
size = size,
cornerRadius = corner
)
drawRoundRect(
brush = Brush.linearGradient(
colors = listOf(
CasingShadow.copy(alpha = 0.6f),
CasingBase,
CasingHighlight.copy(alpha = 0.4f)
), start = Offset(0f, 0f), end = Offset(size.width, size.height)
), size = size, cornerRadius = corner
)
}
Row(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 6.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
RealisticButton(text = "B", onClick = { game.rotate() })
RealisticButton(text = "A", onClick = { game.rotate() })
}
}
}
}
/**
* A domed A/B button. The cap is shaded by a radial gradient whose center is pulled
* up-left toward the light, layered with a rotated gloss oval for the specular streak.
* Pressing translates the cap diagonally toward its *fixed* drop shadow — the shrinking
* gap between cap and shadow is what reads as travel depth. The label is silk-screened
* on the casing below the button and rides the same spring.
*/
@Composable
private fun RealisticButton(text: String, onClick: () -> Unit) {
val isPressed = remember { mutableStateOf(false) }
val pushAnim = remember { Animatable(0f) }
LaunchedEffect(Unit) {
snapshotFlow { if (isPressed.value) 2.5f else 0f }
.collectLatest { pushAnim.animateTo(it, PressSpring) }
}
Box(modifier = Modifier
.size(44.dp)
.arcadeButton(isPressed) { onClick() }) {
Spacer(
modifier = Modifier
.fillMaxSize()
.drawWithCache {
val center = Offset(size.width / 2, size.height / 2)
val buttonRadius = size.width / 2 - 1.dp.toPx()
val shadowCenter =
center.copy(x = center.x + 3.dp.toPx(), y = center.y + 4.dp.toPx())
val actualButtonRadius = buttonRadius - 1.dp.toPx()
val faceBrush = Brush.radialGradient(
colors = listOf(
ButtonMagentaHighlight,
ButtonMagentaBase,
ButtonMagentaShadow
),
center = center.copy(
x = center.x - actualButtonRadius * 0.3f,
y = center.y - actualButtonRadius * 0.3f
),
radius = actualButtonRadius * 1.2f
)
val glossPivot = center.copy(
x = center.x - actualButtonRadius * 0.5f,
y = center.y - actualButtonRadius * 0.5f
)
val glossBrush = Brush.linearGradient(
colors = listOf(Color.White.copy(alpha = 0.85f), Color.Transparent),
start = Offset(
center.x - actualButtonRadius * 0.8f,
center.y - actualButtonRadius * 0.8f
),
end = Offset(center.x, center.y)
)
val glossTopLeft = Offset(
center.x - actualButtonRadius * 0.65f,
center.y - actualButtonRadius * 0.75f
)
val glossSize = Size(actualButtonRadius * 0.9f, actualButtonRadius * 0.4f)
onDrawBehind {
drawCircle(
color = Color.Black.copy(alpha = 0.5f),
radius = buttonRadius,
center = shadowCenter
)
// Shift the cap instead of rebuilding push-dependent brushes each frame.
val push = pushAnim.value.dp.toPx()
translate(left = push, top = push) {
drawCircle(
color = Color(0xFF222222),
radius = buttonRadius,
center = center
)
drawCircle(
brush = faceBrush,
radius = actualButtonRadius,
center = center
)
rotate(degrees = -35f, pivot = glossPivot) {
drawOval(
brush = glossBrush,
topLeft = glossTopLeft,
size = glossSize
)
}
}
}
}
)
Text(
text = text,
color = LogoBlue,
fontWeight = FontWeight.ExtraBold,
fontSize = 16.sp,
fontFamily = FontFamily.SansSerif,
modifier = Modifier
.align(Alignment.Center)
.offset {
val push = pushAnim.value
IntOffset((6 + push).dp.roundToPx(), (42 + push).dp.roundToPx())
}
)
}
}
@Composable
private fun SystemButtonsRealistic(game: BrickGame) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Box(modifier = Modifier.rotate(-22f)) {
SystemButton(text = "SELECT", onClick = { })
}
Box(modifier = Modifier
.offset(y = 16.dp)
.rotate(-22f)) {
SystemButton(text = "START", onClick = { game.reset() })
}
}
}
/**
* START/SELECT: a rubber pill sunk into a recessed well. The well darkens toward the
* top (its wall blocks the light), and the cap's drop shadow is skipped entirely while
* pressed — a cap sitting flush with the well floor can't cast one, and removing it is
* what makes the 1.5dp travel feel like real depth.
*/
@Composable
private fun SystemButton(text: String, onClick: () -> Unit) {
val isPressed = remember { mutableStateOf(false) }
val pushAnim = remember { Animatable(0f) }
LaunchedEffect(Unit) {
snapshotFlow { if (isPressed.value) 1.5f else 0f }
.collectLatest { pushAnim.animateTo(it, PressSpring) }
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.arcadeButton(isPressed) { onClick() }) {
Spacer(
modifier = Modifier
.width(50.dp)
.height(14.dp)
.drawWithCache {
val cornerRadius = CornerRadius(size.height / 2, size.height / 2)
val recessOverlayBrush = Brush.verticalGradient(
colors = listOf(Color(0xFF111111), Color.Transparent),
startY = 0f, endY = size.height * 0.7f
)
val paddingX = 2.dp.toPx()
val paddingTop = 1.dp.toPx()
val paddingBottom = 3.dp.toPx()
val buttonWidth = size.width - (paddingX * 2)
val buttonHeight = size.height - paddingTop - paddingBottom
val buttonSize = Size(buttonWidth, buttonHeight)
val buttonCorner = CornerRadius(buttonHeight / 2, buttonHeight / 2)
val restTopLeft = Offset(paddingX, paddingTop)
val shadowTopLeft = Offset(paddingX + 1.dp.toPx(), paddingTop + 2.dp.toPx())
val faceBrush = Brush.verticalGradient(
colors = listOf(
Color(0xFFD4D4D8),
Color(0xFF989AA0),
Color(0xFF6A6B70)
), startY = paddingTop, endY = paddingTop + buttonHeight
)
onDrawBehind {
drawRoundRect(
color = Color(0xFF4A4B50),
size = size,
cornerRadius = cornerRadius
)
drawRoundRect(
brush = recessOverlayBrush,
size = size,
cornerRadius = cornerRadius
)
if (!isPressed.value) {
drawRoundRect(
color = Color.Black.copy(alpha = 0.7f),
topLeft = shadowTopLeft,
size = buttonSize,
cornerRadius = buttonCorner
)
}
val push = pushAnim.value.dp.toPx()
translate(left = push, top = push) {
drawRoundRect(
brush = faceBrush,
topLeft = restTopLeft,
size = buttonSize,
cornerRadius = buttonCorner
)
}
}
}
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = text,
color = LogoBlue,
fontSize = 10.sp,
fontWeight = FontWeight.ExtraBold,
fontFamily = FontFamily.SansSerif
)
}
}
/**
* The six angled speaker slots. Each slot stacks a shadow copy (up-left), a highlight
* copy (down-right), the slot face, then two darkening gradients — from the left wall
* and the top lip — so the openings read as drilled through the casing at the
* original's signature -25° tilt.
*/
@Composable
private fun SpeakerGrillRealistic() {
Canvas(modifier = Modifier.size(width = 110.dp, height = 80.dp)) {
val slotWidth = 8.5f.dp.toPx()
val slotHeight = 52.dp.toPx()
val spacing = 16.dp.toPx()
val angle = -25f
rotate(angle, pivot = Offset(size.width / 2, size.height / 2)) {
for (i in 0..5) {
val xOffset = i * spacing + 12.dp.toPx()
val yOffset = 15.dp.toPx()
val cornerRadius = CornerRadius(slotWidth / 2, slotWidth / 2)
drawRoundRect(
color = CasingShadow.copy(alpha = 0.7f),
topLeft = Offset(xOffset - 1.dp.toPx(), yOffset - 1.dp.toPx()),
size = Size(slotWidth, slotHeight),
cornerRadius = cornerRadius
)
drawRoundRect(
color = CasingHighlight,
topLeft = Offset(xOffset + 1.dp.toPx(), yOffset + 1.dp.toPx()),
size = Size(slotWidth, slotHeight),
cornerRadius = cornerRadius
)
drawRoundRect(
color = Color(0xFF8A8A86),
topLeft = Offset(xOffset, yOffset),
size = Size(slotWidth, slotHeight),
cornerRadius = cornerRadius
)
drawRoundRect(
brush = Brush.horizontalGradient(
colors = listOf(
Color.Black.copy(
alpha = 0.35f
), Color.Transparent
), startX = xOffset, endX = xOffset + slotWidth * 0.8f
),
topLeft = Offset(xOffset, yOffset),
size = Size(slotWidth, slotHeight),
cornerRadius = cornerRadius
)
drawRoundRect(
brush = Brush.verticalGradient(
colors = listOf(
Color.Black.copy(alpha = 0.45f),
Color.Transparent
), startY = yOffset, endY = yOffset + slotHeight * 0.5f
),
topLeft = Offset(xOffset, yOffset),
size = Size(slotWidth, slotHeight),
cornerRadius = cornerRadius
)
}
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment