1package com.peachpasswords.ui.components23import androidx.compose.foundation.Canvas4import androidx.compose.runtime.Composable5import androidx.compose.runtime.LaunchedEffect6import androidx.compose.runtime.State7import androidx.compose.runtime.getValue8import androidx.compose.runtime.mutableLongStateOf9import androidx.compose.runtime.remember10import androidx.compose.runtime.withFrameMillis11import androidx.compose.ui.Modifier12import androidx.compose.ui.geometry.Offset13import androidx.compose.ui.geometry.Rect14import androidx.compose.ui.geometry.Size15import androidx.compose.ui.graphics.Brush16import androidx.compose.ui.graphics.Color17import androidx.compose.ui.graphics.Path18import androidx.compose.ui.graphics.drawscope.DrawScope19import androidx.compose.ui.graphics.drawscope.Stroke20import androidx.compose.ui.graphics.drawscope.rotate21import androidx.compose.ui.graphics.drawscope.scale22import androidx.compose.ui.graphics.drawscope.translate23import androidx.compose.ui.graphics.vector.PathParser24import kotlin.math.sin25262728293031323334353637enum class BirdPose { IDLE, CHIRPING, ATTENTIVE, HAPPY, SAD }3839enum class BirdKind { FINCH, SWALLOW }4041private const val VB_W = 160f42private const val VB_H = 150f4344private class BirdPalette(45 val hi: Color,46 val mid: Color,47 val lo: Color,48 val shade: Color,49 val cream: Color,50 val beak: Color,51 val beakShade: Color,52 val legs: Color,53)5455private val Finch = BirdPalette(56 hi = Color(0xFFF4A6B6), mid = Color(0xFFDC8294), lo = Color(0xFFA85870),57 shade = Color(0xFF7A3E54), cream = Color(0xFFF5D8C8),58 beak = Color(0xFFF5C764), beakShade = Color(0xFFC8932E), legs = Color(0xFF7A3E54),59)6061private val Swallow = BirdPalette(62 hi = Color(0xFFF4C8A0), mid = Color(0xFFDC9968), lo = Color(0xFFA86B3E),63 shade = Color(0xFF7A4A26), cream = Color(0xFFF7E0C8),64 beak = Color(0xFFF5C764), beakShade = Color(0xFFB87A22), legs = Color(0xFF5E3A1F),65)6667private val EyeDark = Color(0xFF1F0A10)68private val EyeGlint = Color(0xF2FFF0E1)69private val GroundShadow = Color(0x73000000)707172private val pathCache = HashMap<String, Path>()7374private fun svg(d: String): Path = pathCache.getOrPut(d) { PathParser().parsePathString(d).toPath() }757677private fun radial(b: Rect, cx: Float, cy: Float, r: Float, stops: Array<Pair<Float, Color>>) =78 Brush.radialGradient(79 colorStops = stops,80 center = Offset(b.left + b.width * cx, b.top + b.height * cy),81 radius = maxOf(b.width, b.height) * r,82 )8384@Composable85fun PairingBird(86 kind: BirdKind,87 pose: BirdPose,88 modifier: Modifier = Modifier,89) {90 val elapsed by rememberFrameMillis()91 Canvas(modifier) {92 val s = minOf(size.width / VB_W, size.height / VB_H)93 translate((size.width - VB_W * s) / 2f, (size.height - VB_H * s) / 2f) {94 scale(s, s, pivot = Offset.Zero) {95 96 97 if (kind == BirdKind.SWALLOW) {98 scale(-1f, 1f, pivot = Offset(VB_W / 2f, VB_H / 2f)) { drawBird(kind, pose, elapsed) }99 } else {100 drawBird(kind, pose, elapsed)101 }102 }103 }104 }105}106107108109110111@Composable112private fun rememberFrameMillis(): State<Long> {113 val millis = remember { mutableLongStateOf(0L) }114 LaunchedEffect(Unit) {115 val start = withFrameMillis { it }116 while (true) withFrameMillis { millis.longValue = it - start }117 }118 return millis119}120121122private fun cycle(elapsed: Long, periodMs: Int): Float = (elapsed % periodMs) / periodMs.toFloat()123124125private fun swell(t: Float): Float = sin(t * Math.PI.toFloat())126127128129130131private fun chirpBeat(t: Float): Float = when {132 t < 0.70f || t > 0.94f -> 0f133 else -> swell((t - 0.70f) / 0.24f)134}135136private fun DrawScope.drawBird(kind: BirdKind, pose: BirdPose, elapsed: Long) {137 val p = if (kind == BirdKind.FINCH) Finch else Swallow138 val chirping = pose == BirdPose.CHIRPING139 val beat = if (chirping) chirpBeat(cycle(elapsed, 1700)) else 0f140 val bob = -1.5f * swell(cycle(elapsed, 2400))141142 143 val blinkT = cycle(elapsed, 5200)144 val eyeOpen = if (blinkT > 0.92f && blinkT < 0.96f) 0.05f else 1f145146 if (kind == BirdKind.FINCH) {147 drawOval(GroundShadow, topLeft = Offset(38f, 134.5f), size = Size(80f, 7f))148 } else {149 drawOval(GroundShadow, topLeft = Offset(52f, 137f), size = Size(68f, 6f))150 }151152 if (beat > 0f) {153 val aura = if (kind == BirdKind.FINCH) Rect(16f, 28f, 136f, 120f) else Rect(20f, 36f, 140f, 120f)154 drawOval(155 p.hi.copy(alpha = 0.18f * 0.8f * beat),156 topLeft = Offset(aura.left, aura.top),157 size = Size(aura.width, aura.height),158 )159 }160161 drawTail(kind, p, pose, beat, elapsed)162 drawFeet(kind, p)163164 165 166 val droop = pose == BirdPose.SAD167 translate(0f, if (droop) 5f else bob) {168 rotate(if (droop) -2f else 0f, pivot = Offset(VB_W / 2f, VB_H * 0.8f)) {169 drawBody(kind, p)170 drawWing(kind, p, pose, elapsed)171 }172 }173174 val headPivot = if (kind == BirdKind.FINCH) Offset(95f, 52f) else Offset(116f, 64f)175 val headTilt = when (pose) {176 BirdPose.ATTENTIVE -> -6f177 BirdPose.CHIRPING -> -4f * beat178 else -> 0f179 }180 translate(0f, bob - 3f * beat) {181 rotate(headTilt, pivot = headPivot) {182 drawHead(kind, p, pose, beat, eyeOpen)183 }184 }185}186187private fun DrawScope.drawTail(kind: BirdKind, p: BirdPalette, pose: BirdPose, beat: Float, elapsed: Long) {188 val twitch = when (pose) {189 BirdPose.CHIRPING -> -3f * beat190 BirdPose.ATTENTIVE -> -4f191 BirdPose.HAPPY -> 6f * sin(cycle(elapsed, 500) * 2f * Math.PI.toFloat())192 else -> 0f193 }194 195 val pivot = if (kind == BirdKind.FINCH) Offset(32f, 95f) else Offset(38f, 80f)196 rotate(twitch, pivot = pivot) {197 if (kind == BirdKind.FINCH) {198 drawPath(svg("M 32 92 Q 8 90, 6 80 Q 10 95, 28 100 Z"), p.lo)199 drawPath(svg("M 32 95 Q 4 99, 4 92 Q 10 102, 28 102 Z"), p.shade, alpha = 0.85f)200 drawPath(svg("M 32 98 Q 8 108, 12 110 Q 20 110, 30 105 Z"), p.lo, alpha = 0.9f)201 } else {202 drawPath(svg("M 38 80 Q 8 70, 0 60 Q 8 78, 28 88 Q 14 90, 6 96 Q 16 94, 32 92 L 38 84 Z"), p.lo)203 drawPath(svg("M 30 84 Q 14 78, 6 70 Q 18 84, 30 88 Z"), p.shade, alpha = 0.7f)204 }205 }206}207208private fun DrawScope.drawFeet(kind: BirdKind, p: BirdPalette) {209 val leg = Stroke(width = if (kind == BirdKind.FINCH) 1.6f else 1.5f, cap = androidx.compose.ui.graphics.StrokeCap.Round)210 val toe = Stroke(width = if (kind == BirdKind.FINCH) 1.2f else 1.1f, cap = androidx.compose.ui.graphics.StrokeCap.Round)211 if (kind == BirdKind.FINCH) {212 drawLine(p.legs, Offset(62f, 118f), Offset(60f, 134f), strokeWidth = leg.width, cap = leg.cap)213 drawPath(svg("M 60 134 L 55 138 M 60 134 L 60 139 M 60 134 L 65 138"), p.legs, style = toe)214 drawLine(p.legs, Offset(84f, 118f), Offset(86f, 134f), strokeWidth = leg.width, cap = leg.cap)215 drawPath(svg("M 86 134 L 81 138 M 86 134 L 86 139 M 86 134 L 91 138"), p.legs, style = toe)216 } else {217 drawLine(p.legs, Offset(76f, 118f), Offset(74f, 136f), strokeWidth = leg.width, cap = leg.cap)218 drawPath(svg("M 74 136 L 69 140 M 74 136 L 74 141 M 74 136 L 79 140"), p.legs, style = toe)219 drawLine(p.legs, Offset(96f, 118f), Offset(98f, 136f), strokeWidth = leg.width, cap = leg.cap)220 drawPath(svg("M 98 136 L 93 140 M 98 136 L 98 141 M 98 136 L 103 140"), p.legs, style = toe)221 }222}223224private fun DrawScope.drawBody(kind: BirdKind, p: BirdPalette) {225 if (kind == BirdKind.FINCH) {226 val body = svg("M 30 88 C 24 64, 40 38, 72 36 C 105 34, 124 56, 122 84 C 120 110, 100 122, 70 120 C 44 118, 32 108, 30 88 Z")227 drawPath(body, radial(body.getBounds(), 0.42f, 0.32f, 0.72f, arrayOf(0f to p.hi, 0.55f to p.mid, 1f to p.lo)))228 val belly = svg("M 40 82 C 42 100, 60 112, 80 110 C 100 108, 110 100, 112 86 C 100 102, 80 106, 60 102 C 50 100, 44 92, 40 82 Z")229 val bb = belly.getBounds()230 drawPath(231 belly,232 Brush.verticalGradient(233 colorStops = arrayOf(234 0f to p.cream.copy(alpha = 0f),235 0.5f to p.cream.copy(alpha = 0.85f),236 1f to p.cream.copy(alpha = 0.5f),237 ),238 startY = bb.top, endY = bb.bottom,239 ),240 )241 } else {242 val body = svg("M 36 82 C 32 56, 56 42, 88 44 C 116 46, 134 60, 134 80 C 134 102, 116 118, 90 118 C 60 118, 40 104, 36 82 Z")243 drawPath(body, radial(body.getBounds(), 0.55f, 0.35f, 0.75f, arrayOf(0f to p.hi, 0.5f to p.mid, 1f to p.lo)))244 drawPath(245 svg("M 50 64 C 60 52, 80 46, 100 48 C 116 50, 126 60, 128 70 C 120 60, 100 56, 80 60 C 64 64, 56 68, 50 64 Z"),246 p.shade, alpha = 0.5f,247 )248 val throat = svg("M 64 82 C 66 100, 86 114, 102 112 C 116 110, 124 100, 126 88 C 116 102, 100 108, 84 104 C 74 102, 68 94, 64 82 Z")249 val tb = throat.getBounds()250 drawPath(251 throat,252 Brush.verticalGradient(253 colorStops = arrayOf(0f to p.cream.copy(alpha = 0.95f), 1f to p.cream.copy(alpha = 0.2f)),254 startY = tb.top, endY = tb.bottom,255 ),256 )257 }258}259260private fun DrawScope.drawWing(kind: BirdKind, p: BirdPalette, pose: BirdPose, elapsed: Long) {261 val hair = Stroke(width = if (kind == BirdKind.FINCH) 0.7f else 0.6f, cap = androidx.compose.ui.graphics.StrokeCap.Round)262 val flutter = if (pose == BirdPose.HAPPY) -16f * swell(cycle(elapsed, 320)) else 0f263 val pivot = if (kind == BirdKind.FINCH) Offset(92f, 78f) else Offset(103f, 89f)264 rotate(flutter, pivot = pivot) {265 if (kind == BirdKind.FINCH) {266 drawPath(svg("M 56 60 C 68 56, 88 60, 102 68 C 108 76, 106 86, 96 90 C 80 92, 64 86, 54 78 C 50 70, 50 64, 56 60 Z"), p.lo)267 for (d in listOf("M 64 64 Q 72 70, 78 80", "M 74 62 Q 82 70, 88 82", "M 84 64 Q 90 72, 94 84")) {268 drawPath(svg(d), p.shade, alpha = 0.55f, style = hair)269 }270 drawPath(svg("M 56 60 C 68 56, 88 60, 102 68"), p.shade, alpha = 0.4f, style = Stroke(width = 1f))271 } else {272 drawPath(svg("M 50 70 C 64 64, 84 66, 100 72 C 112 78, 112 90, 100 96 C 84 100, 64 96, 52 88 C 46 80, 46 74, 50 70 Z"), p.lo)273 for (d in listOf("M 96 88 L 110 92 L 100 96 Z", "M 88 92 L 102 98 L 92 100 Z", "M 80 94 L 92 100 L 84 102 Z")) {274 drawPath(svg(d), p.shade, alpha = 0.85f)275 }276 for (d in listOf("M 58 76 Q 70 82, 80 90", "M 68 72 Q 80 80, 90 88", "M 80 70 Q 90 78, 100 86")) {277 drawPath(svg(d), p.shade, alpha = 0.5f, style = hair)278 }279 drawPath(svg("M 50 70 C 64 64, 84 66, 100 72"), p.shade, alpha = 0.55f, style = Stroke(width = 1f))280 }281 }282}283284private fun DrawScope.drawHead(kind: BirdKind, p: BirdPalette, pose: BirdPose, beat: Float, eyeOpen: Float) {285 if (kind == BirdKind.FINCH) {286 val perk = if (pose == BirdPose.HAPPY) -15f else 0f287 rotate(perk, pivot = Offset(88f, 30f)) {288 drawPath(svg("M 88 22 Q 86 12, 94 12 Q 94 20, 92 26 Z"), p.lo)289 drawPath(svg("M 96 20 Q 96 10, 104 14 Q 102 22, 100 26 Z"), p.shade, alpha = 0.95f)290 drawPath(svg("M 104 24 Q 108 16, 114 20 Q 110 28, 106 30 Z"), p.lo, alpha = 0.85f)291 }292 val head = Rect(72f, 22f, 124f, 74f)293 drawCircle(radial(head, 0.5f, 0.4f, 0.7f, arrayOf(0f to p.hi, 1f to p.mid)), radius = 26f, center = Offset(98f, 48f))294 drawPath(svg("M 76 46 Q 80 30, 96 26 Q 86 32, 80 48 Z"), p.shade, alpha = 0.35f)295 scale(1f, eyeOpen, pivot = Offset(108f, 42f)) {296 drawCircle(EyeDark, radius = 4.5f, center = Offset(108f, 42f))297 drawCircle(EyeGlint, radius = 1.4f, center = Offset(109.4f, 40.5f))298 drawCircle(EyeGlint.copy(alpha = 0.6f), radius = 0.5f, center = Offset(106.5f, 44f))299 }300 rotate(-14f * beat, pivot = Offset(120f, 52f)) {301 drawPath(svg("M 120 48 L 138 50 L 122 56 Z"), p.beak)302 drawPath(svg("M 120 48 L 138 50 L 128 52 Z"), p.beakShade, alpha = 0.55f)303 drawPath(svg("M 122 52 L 134 51"), p.beakShade, alpha = 0.6f, style = Stroke(width = 0.6f))304 }305 } else {306 val head = Rect(98f, 44f, 138f, 84f)307 drawCircle(radial(head, 0.45f, 0.4f, 0.7f, arrayOf(0f to p.hi, 1f to p.mid)), radius = 20f, center = Offset(118f, 64f))308 drawPath(svg("M 100 60 Q 108 46, 124 46 Q 134 50, 136 60 Q 124 54, 108 60 Z"), p.shade, alpha = 0.55f)309 scale(1f, eyeOpen, pivot = Offset(126f, 60f)) {310 drawCircle(EyeDark, radius = 4f, center = Offset(126f, 60f))311 drawCircle(EyeGlint, radius = 1.2f, center = Offset(127.2f, 58.8f))312 }313 rotate(-14f * beat, pivot = Offset(136f, 64f)) {314 drawPath(svg("M 136 60 L 154 62 L 138 66 Z"), p.beak)315 drawPath(svg("M 136 60 L 154 62 L 145 63 Z"), p.beakShade, alpha = 0.5f)316 drawPath(svg("M 138 63 L 152 63"), p.beakShade, alpha = 0.6f, style = Stroke(width = 0.5f))317 }318 }319}