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.Random333435enum 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)495051525354555657@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 68 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 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 106 107 onFinished()108 }109110 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}