RuneGridBackground.kt

6.4 KB · raw

1package com.peachpasswords.ui.components23import androidx.compose.foundation.Canvas4import androidx.compose.runtime.Composable5import androidx.compose.runtime.LaunchedEffect6import androidx.compose.runtime.mutableFloatStateOf7import androidx.compose.runtime.withFrameMillis8import androidx.compose.runtime.remember9import androidx.compose.ui.Modifier10import androidx.compose.ui.geometry.Offset11import androidx.compose.ui.graphics.Color12import androidx.compose.ui.text.TextStyle13import androidx.compose.ui.text.drawText14import androidx.compose.ui.text.rememberTextMeasurer15import androidx.compose.ui.unit.Dp16import androidx.compose.ui.unit.dp17import androidx.compose.ui.unit.sp18import com.peachpasswords.ui.theme.LocalReduceMotion19import com.peachpasswords.ui.theme.PeachType20import com.peachpasswords.ui.theme.peachColors21import com.peachpasswords.ui.theme.LocalPeachAccent22import kotlin.math.PI23import kotlin.math.sin24import kotlin.random.Random25import kotlin.math.ceil2627private const val RUNE_COUNT = 25628private const val PATTERN_COLUMNS = 1629private const val GLOW_COUNT = 83031/**32 * Continuous PeachScript rune grid for onboarding only. This is deliberately33 * separate from LockRuneString, which is a static lock-screen accent row.34 *35 * Single layer at exactly 0.07 opacity — the real value from the browser's36 * `<RuneBackground opacity={0.07} />` (RuneBackground.tsx), not the ~8-15%37 * range a prior pass guessed. The browser has no second/parallax layer;38 * matching that means not adding one here either (ONBOARDING_ADDENDUM.md §2).39 */40@Composable41fun RuneGridBackground(42    modifier: Modifier = Modifier,43    alpha: Float = 0.07f,44    // Browser parity: the panic screen's final step runs the same drifting45    // field with a few runes flaring and dying at random (RuneCanvas's glows).46    glowing: Boolean = false,47) {48    val reduceMotion = LocalReduceMotion.current49    val accent = LocalPeachAccent.current50    val layer = rememberRuneLayer(51        color = peachColors.T0.copy(alpha = alpha),52        fontSize = 24,53        tileWidth = 54.dp,54        rowHeight = 42.dp,55        seed = 19,56    )5758    // Driven off the frame clock, not animateFloat/infiniteRepeatable: those59    // are scaled by the system animator duration setting, so an ambient60    // background silently froze solid wherever that's turned down to 0 (the61    // browser's requestAnimationFrame loop has no such dependency). Read in62    // the draw scope so a frame is a redraw, not a recomposition.63    val elapsedSeconds = remember { mutableFloatStateOf(0f) }64    LaunchedEffect(reduceMotion) {65        if (reduceMotion) return@LaunchedEffect66        var last = withFrameMillis { it }67        while (true) {68            val now = withFrameMillis { it }69            elapsedSeconds.floatValue += (now - last).coerceAtMost(64L) / 1000f70            last = now71        }72    }7374    Canvas(modifier) {75        val t = elapsedSeconds.floatValue76        drawRuneLayer(layer, t)77        if (glowing) drawRuneGlows(layer, t, accent)78    }79}8081/** A few runes flare and fade at random positions — the panic screen's chaos. */82private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawRuneGlows(83    layer: RuneLayer,84    elapsedSeconds: Float,85    accent: Color,86) {87    val tileWidth = layer.tileWidth.toPx()88    val rowHeight = layer.rowHeight.toPx()89    val cols = ceil(size.width / tileWidth).toInt() + 190    val rows = ceil(size.height / rowHeight).toInt() + 191    val rng = Random(layer.seed)92    repeat(GLOW_COUNT) {93        // Each glow keeps a fixed cell and its own period/offset, so the field94        // reads as random flares without tracking mutable per-glow state.95        val col = rng.nextInt(cols.coerceAtLeast(1))96        val row = rng.nextInt(rows.coerceAtLeast(1))97        val period = 2f + rng.nextFloat() * 3f98        val offset = rng.nextFloat() * period99        val phase = ((elapsedSeconds + offset) % period) / period100        val a = sin(phase * PI.toFloat()) * 0.85f101        if (a <= 0.02f) return@repeat102        val glyph = layer.glyphs[positiveMod(col * 31 + row * 17 + layer.seed, RUNE_COUNT)]103        drawText(104            glyph,105            topLeft = Offset(col * tileWidth, row * rowHeight),106            color = accent.copy(alpha = a),107        )108    }109}110111@Composable112private fun rememberRuneLayer(113    color: Color,114    fontSize: Int,115    tileWidth: Dp,116    rowHeight: Dp,117    seed: Int,118): RuneLayer {119    val measurer = rememberTextMeasurer()120    val style = remember(color, fontSize) {121        TextStyle(fontFamily = PeachType.Script, fontSize = fontSize.sp, color = color)122    }123    val glyphs = remember(style, measurer) {124        List(RUNE_COUNT) { index ->125            measurer.measure((0xE000 + index).toChar().toString(), style)126        }127    }128    return RuneLayer(129        glyphs = glyphs,130        tileWidth = tileWidth,131        rowHeight = rowHeight,132        patternWidthDp = tileWidth * PATTERN_COLUMNS,133        seed = seed,134    )135}136137private data class RuneLayer(138    val glyphs: List<androidx.compose.ui.text.TextLayoutResult>,139    val tileWidth: Dp,140    val rowHeight: Dp,141    val patternWidthDp: Dp,142    val seed: Int,143)144145/** Browser parity (RuneBackground.tsx SPEEDS): three drift rates, cycled by row. */146private val ROW_SPEEDS_DP = floatArrayOf(8f, 11f, 6f)147148private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawRuneLayer(149    layer: RuneLayer,150    elapsedSeconds: Float,151) {152    val tileWidth = layer.tileWidth.toPx()153    val rowHeight = layer.rowHeight.toPx()154    val patternWidth = layer.patternWidthDp.toPx()155    val columns = ceil(size.width / tileWidth).toInt() + PATTERN_COLUMNS * 2156    val rows = ceil(size.height / rowHeight).toInt() + 2157    val startColumn = -PATTERN_COLUMNS158159    for (row in -1 until rows) {160        val y = row * rowHeight161        // Each row drifts at its own speed, so the field shears instead of162        // sliding as one rigid block.163        val speedPx = ROW_SPEEDS_DP[positiveMod(row, ROW_SPEEDS_DP.size)] * density164        val offset = (elapsedSeconds * speedPx) % patternWidth165        for (i in 0..columns) {166            val column = startColumn + i167            val x = column * tileWidth - offset168            val patternColumn = positiveMod(column, PATTERN_COLUMNS)169            val glyphIndex = positiveMod(patternColumn * 31 + row * 17 + layer.seed, RUNE_COUNT)170            drawText(layer.glyphs[glyphIndex], topLeft = Offset(x, y))171        }172    }173}174175private fun positiveMod(value: Int, modulus: Int): Int = ((value % modulus) + modulus) % modulus