RuneResolve.kt

7.0 KB · raw

1package com.peachpasswords.ui.vault23import androidx.compose.foundation.Canvas4import androidx.compose.foundation.layout.Box5import androidx.compose.foundation.layout.fillMaxWidth6import androidx.compose.foundation.layout.height7import androidx.compose.runtime.Composable8import androidx.compose.runtime.LaunchedEffect9import androidx.compose.runtime.getValue10import androidx.compose.runtime.mutableFloatStateOf11import androidx.compose.runtime.remember12import androidx.compose.runtime.setValue13import androidx.compose.runtime.withFrameMillis14import androidx.compose.ui.Alignment15import androidx.compose.ui.Modifier16import androidx.compose.ui.geometry.Offset17import androidx.compose.ui.graphics.Color18import androidx.compose.ui.graphics.drawscope.rotate19import androidx.compose.ui.graphics.graphicsLayer20import androidx.compose.ui.text.TextStyle21import androidx.compose.ui.text.drawText22import androidx.compose.ui.text.rememberTextMeasurer23import androidx.compose.ui.unit.dp24import androidx.compose.ui.unit.sp25import com.peachpasswords.ui.components.PeachMark26import com.peachpasswords.ui.theme.LocalPeachAccent27import com.peachpasswords.ui.theme.LocalReduceMotion28import com.peachpasswords.ui.theme.PeachType29import kotlin.math.PI30import kotlin.math.min31import kotlin.math.sin32import kotlin.random.Random3334/** Which leg of the restore animation is running (browser: RestoreSymbols' `animating`). */35enum class RunePhase { CHAOS, SNAP, BLOOM }3637private const val RUNE_COUNT = 6038private const val GRID_COLS = 839private const val GRID_ROWS = 640private const val SNAP_MS = 400f41private const val BLOOM_MS = 900f4243private class Rune(44    var x: Float, var y: Float,45    val vx: Float, val vy: Float,46    val glyph: String, val size: Float,47    var angle: Float, val va: Float,48)4950/**51 * The PeachScript restore's chaos → snap → bloom sequence, ported from the52 * browser's RestoreSymbols (popup/views/Lock.tsx): loose runes drift while the53 * backup decrypts, snap into a grid once it lands, then dissolve as the peach54 * mark blooms through. [phase] is driven by the import state; [onFinished]55 * fires when BLOOM completes so the caller can reveal its result screen.56 */57@Composable58fun RuneResolve(59    phase: RunePhase,60    modifier: Modifier = Modifier,61    onFinished: () -> Unit = {},62) {63    val accent = LocalPeachAccent.current64    val reduceMotion = LocalReduceMotion.current65    val measurer = rememberTextMeasurer()6667    // Fixed seed: the texture should look the same every restore, like the68    // browser's deterministic rune string.69    val runes = remember {70        val rng = Random(7)71        List(RUNE_COUNT) {72            Rune(73                x = rng.nextFloat(), y = rng.nextFloat(),74                vx = (rng.nextFloat() - 0.5f) * 3f, vy = (rng.nextFloat() - 0.5f) * 3f,75                glyph = (0xE000 + rng.nextInt(40)).toChar().toString(),76                size = 14f + rng.nextFloat() * 20f,77                angle = rng.nextFloat() * 2f * PI.toFloat(), va = (rng.nextFloat() - 0.5f) * 0.1f,78            )79        }80    }81    // Normalised x/y are seeded once per size; kept in px after first layout.82    var seeded by remember { mutableFloatStateOf(0f) }83    var elapsed by remember(phase) { mutableFloatStateOf(0f) }84    var clock by remember { mutableFloatStateOf(0f) }8586    LaunchedEffect(phase) {87        if (reduceMotion) {88            onFinished()89            return@LaunchedEffect90        }91        var last = withFrameMillis { it }92        while (true) {93            val now = withFrameMillis { it }94            val dt = (now - last).toFloat().coerceAtMost(64f)95            last = now96            clock += dt97            elapsed += dt98            val done = when (phase) {99                RunePhase.CHAOS -> false100                RunePhase.SNAP -> elapsed >= SNAP_MS101                RunePhase.BLOOM -> elapsed >= BLOOM_MS102            }103            if (done) break104        }105        // CHAOS runs until the import state moves it on; the timed phases report106        // completion so the caller can advance (SNAP → BLOOM → result).107        onFinished()108    }109110    // Peach mark blooms in over the second fifth of BLOOM, then fades back out.111    val markT = if (phase == RunePhase.BLOOM) ((elapsed / BLOOM_MS) - 0.2f) / 0.5f else 0f112    val markAlpha = when {113        markT <= 0f -> 0f114        markT < 0.5f -> markT * 2f115        else -> (2f - markT * 2f).coerceAtLeast(0f)116    }117118    Box(modifier.fillMaxWidth().height(180.dp), contentAlignment = Alignment.Center) {119        Canvas(Modifier.fillMaxWidth().height(180.dp)) {120            if (seeded == 0f) {121                runes.forEach { it.x *= size.width; it.y *= size.height }122                seeded = 1f123            }124            val progress = (elapsed / SNAP_MS).coerceIn(0f, 1f)125            val ease = 1f - (1f - progress) * (1f - progress) * (1f - progress)126            val cellW = size.width / GRID_COLS127            val cellH = size.height / GRID_ROWS128129            runes.forEachIndexed { i, r ->130                when (phase) {131                    RunePhase.CHAOS -> {132                        r.x = (r.x + r.vx + size.width) % size.width133                        r.y = (r.y + r.vy + size.height) % size.height134                        r.angle += r.va135                        val a = 0.5f + 0.5f * sin(clock / 400f + r.x)136                        drawGlyph(measurer, r.glyph, r.size, accent.copy(alpha = a), r.x, r.y, r.angle)137                    }138                    RunePhase.SNAP -> {139                        if (i >= GRID_COLS * GRID_ROWS) return@forEachIndexed140                        val tx = (i % GRID_COLS) * cellW + cellW / 2f141                        val ty = (i / GRID_COLS) * cellH + cellH / 2f142                        val cx = r.x + (tx - r.x) * ease143                        val cy = r.y + (ty - r.y) * ease144                        drawGlyph(measurer, r.glyph, 16f, accent.copy(alpha = 0.8f * ease), cx, cy, 0f)145                        if (progress >= 1f) { r.x = tx; r.y = ty }146                    }147                    RunePhase.BLOOM -> {148                        val a = 1f - min((elapsed / BLOOM_MS) / 0.4f, 1f)149                        if (a > 0f && i < 48) {150                            drawGlyph(measurer, r.glyph, 16f, accent.copy(alpha = a * 0.8f), r.x, r.y, 0f)151                        }152                    }153                }154            }155        }156        if (markAlpha > 0f) {157            PeachMark(158                size = 64.dp,159                modifier = Modifier.graphicsLayer {160                    val s = min(markT * 1.2f, 1f)161                    scaleX = s; scaleY = s; alpha = markAlpha162                },163            )164        }165    }166}167168private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawGlyph(169    measurer: androidx.compose.ui.text.TextMeasurer,170    glyph: String,171    sizeSp: Float,172    color: Color,173    x: Float,174    y: Float,175    angle: Float,176) {177    val laid = measurer.measure(glyph, TextStyle(fontFamily = PeachType.Script, fontSize = sizeSp.sp, color = color))178    rotate(degrees = angle * 180f / PI.toFloat(), pivot = Offset(x, y)) {179        drawText(laid, topLeft = Offset(x - laid.size.width / 2f, y - laid.size.height / 2f))180    }181}