mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-02 03:15:37 +00:00
Added Star History graph to README, tweaked AGENTS
This commit is contained in:
1 parent
d8db258bd8
commit
976fdfd949
4 files changed
+703
No files matched your search
+424
@@ -0,0 +1,424 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.presentation
|
||||
|
||||
import android.graphics.Paint
|
||||
import android.graphics.LinearGradient
|
||||
import android.graphics.RadialGradient
|
||||
import android.graphics.Shader
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.nativeCanvas
|
||||
import androidx.core.graphics.withRotation
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.min
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.sqrt
|
||||
import kotlin.random.Random
|
||||
|
||||
@Immutable
|
||||
data class BubblesStyle(
|
||||
val bgColor: Color = Color.Black,
|
||||
val bubbleCount: Int = 16,
|
||||
val minBubbleCount: Int = 8,
|
||||
val maxBubbleCount: Int = 16,
|
||||
val adaptiveBubbleCount: Boolean = true,
|
||||
val spawnIntervalMs: Long = 800L,
|
||||
val bubbleRadiusFraction: Float = 0.24f,
|
||||
val adaptiveSizing: Boolean = true,
|
||||
val referenceMinSizePx: Float = 360f,
|
||||
val referenceAreaPx: Float = 360f * 800f,
|
||||
val minBubbleRadiusPx: Float = 16f,
|
||||
val maxBubbleRadiusPx: Float = 256f,
|
||||
val speedScale: Float = 0.99f,
|
||||
val minVelocity: Float = 0.8f,
|
||||
val maxVelocity: Float = 3.2f,
|
||||
val hueRotationSpeedDps: Float = 60f, // degrees per second
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun BubblesEffect(
|
||||
modifier: Modifier = Modifier,
|
||||
isRunning: Boolean = true,
|
||||
style: BubblesStyle = BubblesStyle(),
|
||||
) {
|
||||
val renderer = remember { BubblesRenderer() }
|
||||
var frameSignal by remember { mutableIntStateOf(0) }
|
||||
|
||||
// Animation loop
|
||||
LaunchedEffect(isRunning, style) {
|
||||
if (!isRunning) return@LaunchedEffect
|
||||
var previousNanos = 0L
|
||||
while (isActive) {
|
||||
withFrameNanos { now ->
|
||||
if (previousNanos == 0L) previousNanos = now
|
||||
val deltaSec = ((now - previousNanos).coerceAtMost(MAX_STEP_NANOS)).toFloat() / NANOS_TO_SECONDS
|
||||
previousNanos = now
|
||||
renderer.update(deltaSec, style)
|
||||
frameSignal++
|
||||
}
|
||||
}
|
||||
}
|
||||
Canvas(modifier = modifier) {
|
||||
frameSignal
|
||||
renderer.ensureLayout(size)
|
||||
renderer.draw(this, style)
|
||||
}
|
||||
}
|
||||
|
||||
private const val MAX_STEP_NANOS = 16_666_667L // ~60 FPS
|
||||
private const val NANOS_TO_SECONDS = 1_000_000_000f
|
||||
private val SHELL_GRADIENT_STOPS = floatArrayOf(0f, 0.52f, 0.66f, 0.79f, 0.90f, 0.968f, 0.993f, 1f)
|
||||
private val INNER_GRADIENT_STOPS = floatArrayOf(0f, 0.34f, 0.68f, 1f)
|
||||
|
||||
private data class Velocity(var x: Float, var y: Float)
|
||||
|
||||
private data class Bubble(
|
||||
var x: Float,
|
||||
var y: Float,
|
||||
val radius: Float,
|
||||
val velocity: Velocity,
|
||||
val baseHue: Float, // 0-360, unique for each bubble
|
||||
)
|
||||
|
||||
private class BubblesRenderer {
|
||||
private var width = 0f
|
||||
private var height = 0f
|
||||
private val bubbles = mutableListOf<Bubble>()
|
||||
private val random = Random(System.currentTimeMillis())
|
||||
private var globalHueRotation = 0f // degrees, rotates all bubbles hues
|
||||
private var spawnAccumulatorSec = 0f
|
||||
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
private val shellColors = IntArray(8)
|
||||
private val innerColors = IntArray(4)
|
||||
|
||||
fun ensureLayout(size: Size) {
|
||||
val targetWidth = size.width
|
||||
val targetHeight = size.height
|
||||
if (targetWidth <= 0f || targetHeight <= 0f) return
|
||||
val shouldRebuild = width != targetWidth || height != targetHeight
|
||||
if (shouldRebuild) {
|
||||
width = targetWidth
|
||||
height = targetHeight
|
||||
bubbles.clear()
|
||||
spawnAccumulatorSec = 0f
|
||||
}
|
||||
}
|
||||
|
||||
fun spawnBubble(style: BubblesStyle): Boolean {
|
||||
if (width <= 0f || height <= 0f) return false
|
||||
val radius = resolveBubbleRadius(style)
|
||||
val (velocityMin, velocityMax) = resolveVelocityRange(style)
|
||||
val bubble = Bubble(
|
||||
x = radius,
|
||||
y = height - radius,
|
||||
radius = radius,
|
||||
velocity = Velocity(
|
||||
x = randomInRange(velocityMin, velocityMax),
|
||||
y = -randomInRange(velocityMin, velocityMax),
|
||||
),
|
||||
baseHue = random.nextFloat() * 360f, // Random starting hue for this bubble
|
||||
)
|
||||
bubbles.add(bubble)
|
||||
return true
|
||||
}
|
||||
|
||||
fun update(deltaSeconds: Float, style: BubblesStyle) {
|
||||
if (width <= 0f || height <= 0f) return
|
||||
|
||||
spawnMissingBubbles(deltaSeconds, style)
|
||||
|
||||
// Rotate hue for all bubbles
|
||||
globalHueRotation += style.hueRotationSpeedDps * deltaSeconds
|
||||
if (globalHueRotation >= 360f) globalHueRotation -= 360f
|
||||
val frameScale = deltaSeconds * 60f
|
||||
for (i in bubbles.indices) {
|
||||
val bubble = bubbles[i]
|
||||
// Update position
|
||||
bubble.x += bubble.velocity.x * frameScale
|
||||
bubble.y += bubble.velocity.y * frameScale
|
||||
// Bounce off walls
|
||||
if (bubble.x > width - bubble.radius) {
|
||||
bubble.x = width - bubble.radius
|
||||
bubble.velocity.x *= -1
|
||||
}
|
||||
if (bubble.x < bubble.radius) {
|
||||
bubble.x = bubble.radius
|
||||
bubble.velocity.x *= -1
|
||||
}
|
||||
if (bubble.y > height - bubble.radius) {
|
||||
bubble.y = height - bubble.radius
|
||||
bubble.velocity.y *= -1
|
||||
}
|
||||
if (bubble.y < bubble.radius) {
|
||||
bubble.y = bubble.radius
|
||||
bubble.velocity.y *= -1
|
||||
}
|
||||
}
|
||||
// Collision detection and resolution
|
||||
for (i in bubbles.indices) {
|
||||
for (j in (i + 1) until bubbles.size) {
|
||||
val b1 = bubbles[i]
|
||||
val b2 = bubbles[j]
|
||||
if (isCollided(b1, b2)) resolveCollision(b1, b2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun spawnMissingBubbles(deltaSeconds: Float, style: BubblesStyle) {
|
||||
val targetCount = resolveTargetBubbleCount(style)
|
||||
if (bubbles.size >= targetCount) return
|
||||
|
||||
val spawnIntervalSec = style.spawnIntervalMs.coerceAtLeast(1L) / 1_000f
|
||||
spawnAccumulatorSec += deltaSeconds
|
||||
|
||||
while (bubbles.size < targetCount && spawnAccumulatorSec >= spawnIntervalSec) {
|
||||
if (!spawnBubble(style)) break
|
||||
spawnAccumulatorSec -= spawnIntervalSec
|
||||
}
|
||||
}
|
||||
|
||||
fun draw(scope: DrawScope, style: BubblesStyle) {
|
||||
if (width <= 0f || height <= 0f) return
|
||||
scope.drawRect(style.bgColor)
|
||||
val canvas = scope.drawContext.canvas.nativeCanvas
|
||||
for (i in bubbles.indices) {
|
||||
drawBubbleWithGradient(canvas, bubbles[i])
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawBubbleWithGradient(canvas: android.graphics.Canvas, bubble: Bubble) {
|
||||
val hue = (bubble.baseHue + globalHueRotation) % 360f
|
||||
val lit = 53.33f + maxOf(0f, (70f - kotlin.math.abs(244f - hue)) / 4f)
|
||||
val rgb = hueToRgb(hue, lit)
|
||||
val shellColor = mixWithWhite(rgb, 0.04f)
|
||||
val innerColor = mixWithWhite(rgb, 0.10f)
|
||||
val shimmerColor = mixWithWhite(rgb, 0.35f)
|
||||
// Outer shell
|
||||
val shellGradient = RadialGradient(
|
||||
bubble.x, bubble.y, bubble.radius,
|
||||
buildShellColors(shellColor),
|
||||
SHELL_GRADIENT_STOPS,
|
||||
Shader.TileMode.CLAMP
|
||||
)
|
||||
paint.style = Paint.Style.FILL
|
||||
paint.shader = shellGradient
|
||||
canvas.drawCircle(bubble.x, bubble.y, bubble.radius, paint)
|
||||
// Subtle thin rim
|
||||
paint.shader = null
|
||||
paint.style = Paint.Style.STROKE
|
||||
paint.strokeWidth = maxOf(1f, bubble.radius * 0.024f)
|
||||
paint.color = colorWithAlpha(mixWithWhite(shellColor, 0.08f), 0.30f)
|
||||
canvas.drawCircle(bubble.x, bubble.y, bubble.radius - paint.strokeWidth * 0.5f, paint)
|
||||
// Top internal bubble
|
||||
val innerTop = bubble.y - bubble.radius * 0.96f
|
||||
val innerBottom = bubble.y + bubble.radius * 0.48f
|
||||
val innerLeft = bubble.x - bubble.radius * 0.84f
|
||||
val innerRight = bubble.x + bubble.radius * 0.84f
|
||||
val innerGradient = LinearGradient(
|
||||
bubble.x,
|
||||
innerTop,
|
||||
bubble.x,
|
||||
innerBottom,
|
||||
buildInnerColors(innerColor),
|
||||
INNER_GRADIENT_STOPS,
|
||||
Shader.TileMode.CLAMP
|
||||
)
|
||||
paint.style = Paint.Style.FILL
|
||||
paint.shader = innerGradient
|
||||
canvas.drawOval(
|
||||
innerLeft,
|
||||
innerTop,
|
||||
innerRight,
|
||||
innerBottom,
|
||||
paint
|
||||
)
|
||||
// Top-left shimmer
|
||||
paint.shader = null
|
||||
paint.style = Paint.Style.FILL
|
||||
paint.color = colorWithAlpha(shimmerColor, 0.95f)
|
||||
val shimmerCx = bubble.x - bubble.radius * 0.40f
|
||||
val shimmerCy = bubble.y - bubble.radius * 0.72f
|
||||
val shimmerHalfWidth = bubble.radius * 0.06f
|
||||
val shimmerHalfHeight = bubble.radius * 0.21f
|
||||
canvas.withRotation(60f, shimmerCx, shimmerCy) {
|
||||
drawOval(
|
||||
shimmerCx - shimmerHalfWidth,
|
||||
shimmerCy - shimmerHalfHeight,
|
||||
shimmerCx + shimmerHalfWidth,
|
||||
shimmerCy + shimmerHalfHeight,
|
||||
paint
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isCollided(bubble1: Bubble, bubble2: Bubble): Boolean {
|
||||
val dx = bubble1.x - bubble2.x
|
||||
val dy = bubble1.y - bubble2.y
|
||||
val radius = (bubble1.radius + bubble2.radius) * 0.9f
|
||||
return dx * dx + dy * dy < radius * radius
|
||||
}
|
||||
|
||||
private fun resolveCollision(particle: Bubble, otherParticle: Bubble) {
|
||||
val xVelocityDiff = particle.velocity.x - otherParticle.velocity.x
|
||||
val yVelocityDiff = particle.velocity.y - otherParticle.velocity.y
|
||||
val xDist = otherParticle.x - particle.x
|
||||
val yDist = otherParticle.y - particle.y
|
||||
// Prevent accidental overlap
|
||||
if (xVelocityDiff * xDist + yVelocityDiff * yDist >= 0) {
|
||||
val angle = -atan2(otherParticle.y - particle.y, otherParticle.x - particle.x)
|
||||
val m1 = 1f
|
||||
val m2 = 1f
|
||||
val u1 = rotate(particle.velocity, angle)
|
||||
val u2 = rotate(otherParticle.velocity, angle)
|
||||
val v1 = Velocity(
|
||||
x = (u1.x * (m1 - m2)) / (m1 + m2) + (u2.x * 2 * m2) / (m1 + m2),
|
||||
y = u1.y,
|
||||
)
|
||||
val v2 = Velocity(
|
||||
x = (u2.x * (m1 - m2)) / (m1 + m2) + (u1.x * 2 * m2) / (m1 + m2),
|
||||
y = u2.y,
|
||||
)
|
||||
val vFinal1 = rotate(v1, -angle)
|
||||
val vFinal2 = rotate(v2, -angle)
|
||||
particle.velocity.x = vFinal1.x
|
||||
particle.velocity.y = vFinal1.y
|
||||
otherParticle.velocity.x = vFinal2.x
|
||||
otherParticle.velocity.y = vFinal2.y
|
||||
}
|
||||
}
|
||||
|
||||
private fun rotate(velocity: Velocity, angle: Float): Velocity {
|
||||
return Velocity(
|
||||
x = velocity.x * cos(angle) - velocity.y * sin(angle),
|
||||
y = velocity.x * sin(angle) + velocity.y * cos(angle),
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildShellColors(shellColor: Int): IntArray {
|
||||
shellColors[0] = colorWithAlpha(shellColor, 0f)
|
||||
shellColors[1] = colorWithAlpha(shellColor, 0f)
|
||||
shellColors[2] = colorWithAlpha(shellColor, 0.04f)
|
||||
shellColors[3] = colorWithAlpha(shellColor, 0.12f)
|
||||
shellColors[4] = colorWithAlpha(shellColor, 0.28f)
|
||||
shellColors[5] = colorWithAlpha(shellColor, 0.44f)
|
||||
shellColors[6] = colorWithAlpha(shellColor, 0.58f)
|
||||
shellColors[7] = colorWithAlpha(shellColor, 0.64f)
|
||||
return shellColors
|
||||
}
|
||||
|
||||
private fun buildInnerColors(innerColor: Int): IntArray {
|
||||
innerColors[0] = colorWithAlpha(innerColor, 0.48f)
|
||||
innerColors[1] = colorWithAlpha(innerColor, 0.36f)
|
||||
innerColors[2] = colorWithAlpha(innerColor, 0.08f)
|
||||
innerColors[3] = colorWithAlpha(innerColor, 0f)
|
||||
return innerColors
|
||||
}
|
||||
|
||||
private fun resolveBubbleRadius(style: BubblesStyle): Float {
|
||||
val minDimension = min(width, height)
|
||||
val baseRadius = minDimension * style.bubbleRadiusFraction
|
||||
val minRadius = style.minBubbleRadiusPx.coerceAtLeast(1f)
|
||||
val maxRadius = maxOf(minRadius, style.maxBubbleRadiusPx)
|
||||
if (!style.adaptiveSizing || minDimension <= 0f) {
|
||||
return baseRadius.coerceIn(minRadius, maxRadius)
|
||||
}
|
||||
|
||||
val reference = style.referenceMinSizePx.coerceAtLeast(1f)
|
||||
val dampening = sqrt((reference / minDimension).coerceAtMost(1f))
|
||||
return (baseRadius * dampening).coerceIn(minRadius, maxRadius)
|
||||
}
|
||||
|
||||
private fun resolveTargetBubbleCount(style: BubblesStyle): Int {
|
||||
val baseCount = style.bubbleCount.coerceAtLeast(1)
|
||||
if (!style.adaptiveBubbleCount) return baseCount
|
||||
|
||||
val area = width * height
|
||||
val referenceArea = style.referenceAreaPx.coerceAtLeast(1f)
|
||||
val areaScale = sqrt((area / referenceArea).coerceAtLeast(0.25f))
|
||||
val scaledCount = (baseCount * areaScale).toInt()
|
||||
val minCount = style.minBubbleCount.coerceAtLeast(1)
|
||||
val maxCount = maxOf(minCount, style.maxBubbleCount.coerceAtLeast(1))
|
||||
return scaledCount.coerceIn(minCount, maxCount)
|
||||
}
|
||||
|
||||
private fun resolveVelocityRange(style: BubblesStyle): Pair<Float, Float> {
|
||||
val referenceMaxVelocity = maxOf(height / 300f, 1f)
|
||||
val velocityMin = (style.minVelocity * style.speedScale).coerceAtLeast(0.05f)
|
||||
val velocityMax = maxOf(velocityMin, min(style.maxVelocity, referenceMaxVelocity) * style.speedScale)
|
||||
return velocityMin to velocityMax
|
||||
}
|
||||
|
||||
private fun randomInRange(min: Float, max: Float): Float {
|
||||
return random.nextFloat() * (max - min) + min
|
||||
}
|
||||
}
|
||||
|
||||
private fun hueToRgb(h: Float, l: Float): Int {
|
||||
val hNorm = h / 360f
|
||||
val lNorm = l / 100f
|
||||
val sNorm = 1f
|
||||
val c = (1f - kotlin.math.abs(2f * lNorm - 1f)) * sNorm
|
||||
val hp = hNorm * 6f
|
||||
val x = c * (1f - kotlin.math.abs((hp % 2f) - 1f))
|
||||
val m = lNorm - c / 2f
|
||||
val (r, g, b) = when {
|
||||
hp < 1f -> Triple(c, x, 0f)
|
||||
hp < 2f -> Triple(x, c, 0f)
|
||||
hp < 3f -> Triple(0f, c, x)
|
||||
hp < 4f -> Triple(0f, x, c)
|
||||
hp < 5f -> Triple(x, 0f, c)
|
||||
else -> Triple(c, 0f, x)
|
||||
}
|
||||
val r8 = ((r + m) * 255).toInt().coerceIn(0, 255)
|
||||
val g8 = ((g + m) * 255).toInt().coerceIn(0, 255)
|
||||
val b8 = ((b + m) * 255).toInt().coerceIn(0, 255)
|
||||
return (0xFF shl 24) or (r8 shl 16) or (g8 shl 8) or b8
|
||||
}
|
||||
|
||||
private fun colorWithAlpha(color: Int, alpha: Float): Int {
|
||||
val a = (alpha * 255).toInt().coerceIn(0, 255)
|
||||
val r = (color shr 16) and 0xFF
|
||||
val g = (color shr 8) and 0xFF
|
||||
val b = color and 0xFF
|
||||
return (a shl 24) or (r shl 16) or (g shl 8) or b
|
||||
}
|
||||
|
||||
private fun mixWithWhite(color: Int, amount: Float): Int {
|
||||
val t = amount.coerceIn(0f, 1f)
|
||||
val r = (color shr 16) and 0xFF
|
||||
val g = (color shr 8) and 0xFF
|
||||
val b = color and 0xFF
|
||||
val mixedR = (r + (255 - r) * t).toInt().coerceIn(0, 255)
|
||||
val mixedG = (g + (255 - g) * t).toInt().coerceIn(0, 255)
|
||||
val mixedB = (b + (255 - b) * t).toInt().coerceIn(0, 255)
|
||||
return (0xFF shl 24) or (mixedR shl 16) or (mixedG shl 8) or mixedB
|
||||
}
|
||||
+260
@@ -0,0 +1,260 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.presentation
|
||||
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Typeface
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.nativeCanvas
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.random.Random
|
||||
|
||||
@Immutable
|
||||
data class MatrixStyle(
|
||||
val bgColor: Color = Color.Black,
|
||||
val bodyColor: Color = Color(0xFF29C94A),
|
||||
val headColor: Color = Color(0xFFC2FFC6),
|
||||
val tailAlphaFloor: Float = 0.14f, // 0.14f - 0.18f,
|
||||
val fontSize: TextUnit = 14.sp, // 11.sp - 14.sp
|
||||
val minStreamLength: Int = 8, // 6 - 8
|
||||
val maxStreamLength: Int = 28, // 20 - 28
|
||||
val minSpeedRps: Float = 10f, // 10f - 15f
|
||||
val maxSpeedRps: Float = 38f, // 38f - 45f
|
||||
val resetPauseSec: ClosedFloatingPointRange<Float> = 0.1f..1.0f,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun MatrixEffect(
|
||||
modifier: Modifier = Modifier,
|
||||
isRunning: Boolean = true,
|
||||
style: MatrixStyle = MatrixStyle(),
|
||||
symbols: String = DEFAULT_SYMBOLS,
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val renderer = remember { MatrixRenderer() }
|
||||
var frameSignal by remember { mutableIntStateOf(0) }
|
||||
|
||||
LaunchedEffect(isRunning, style, symbols) {
|
||||
if (!isRunning) return@LaunchedEffect
|
||||
|
||||
var previousNanos = 0L
|
||||
while (isActive) {
|
||||
withFrameNanos { now ->
|
||||
if (previousNanos == 0L) previousNanos = now
|
||||
val deltaSec = ((now - previousNanos).coerceAtMost(MAX_STEP_NANOS)).toFloat() / NANOS_TO_SECONDS
|
||||
previousNanos = now
|
||||
renderer.update(deltaSec, style)
|
||||
frameSignal++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Canvas(modifier = modifier) {
|
||||
frameSignal
|
||||
renderer.ensureLayout(size, density.density, style, symbols)
|
||||
renderer.draw(this, style)
|
||||
}
|
||||
}
|
||||
|
||||
private const val MAX_STEP_NANOS = 33_333_333L
|
||||
private const val NANOS_TO_SECONDS = 1_000_000_000f
|
||||
private const val DEFAULT_SYMBOLS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789<>=*+-~:;/[]{}()"
|
||||
|
||||
private fun Color.toArgb(): Int {
|
||||
val a = (alpha.coerceIn(0f, 1f) * 255f + 0.5f).toInt()
|
||||
val r = (red.coerceIn(0f, 1f) * 255f + 0.5f).toInt()
|
||||
val g = (green.coerceIn(0f, 1f) * 255f + 0.5f).toInt()
|
||||
val b = (blue.coerceIn(0f, 1f) * 255f + 0.5f).toInt()
|
||||
return (a shl 24) or (r shl 16) or (g shl 8) or b
|
||||
}
|
||||
|
||||
private class MatrixRenderer {
|
||||
private var columns = 0
|
||||
private var rows = 0
|
||||
private var width = 0f
|
||||
private var height = 0f
|
||||
private var fontSizePx = 0f
|
||||
private var charWidth = 0f
|
||||
private var charHeight = 0f
|
||||
private var baselineOffset = 0f
|
||||
private var symbolSet = ""
|
||||
private var symbols = charArrayOf()
|
||||
|
||||
private var glyphs = charArrayOf()
|
||||
private var streams = emptyArray<StreamState>()
|
||||
|
||||
private val bodyPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { typeface = Typeface.MONOSPACE }
|
||||
private val headPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { typeface = Typeface.MONOSPACE }
|
||||
private val charBuffer = CharArray(1)
|
||||
private val random = Random(System.currentTimeMillis())
|
||||
|
||||
fun ensureLayout(size: Size, density: Float, style: MatrixStyle, symbols: String) {
|
||||
val targetFontPx = style.fontSize.value * density
|
||||
val targetWidth = size.width
|
||||
val targetHeight = size.height
|
||||
val targetSymbols = symbols.ifBlank { DEFAULT_SYMBOLS }
|
||||
|
||||
if (targetWidth <= 0f || targetHeight <= 0f) return
|
||||
|
||||
val shouldRebuild = width != targetWidth ||
|
||||
height != targetHeight ||
|
||||
fontSizePx != targetFontPx ||
|
||||
symbolSet != targetSymbols
|
||||
|
||||
if (!shouldRebuild) return
|
||||
|
||||
width = targetWidth
|
||||
height = targetHeight
|
||||
fontSizePx = targetFontPx
|
||||
symbolSet = targetSymbols
|
||||
this.symbols = targetSymbols.toCharArray()
|
||||
|
||||
bodyPaint.textSize = targetFontPx
|
||||
headPaint.textSize = targetFontPx
|
||||
charWidth = max(bodyPaint.measureText("W"), 1f)
|
||||
val metrics = bodyPaint.fontMetrics
|
||||
charHeight = max(metrics.descent - metrics.ascent, 1f)
|
||||
baselineOffset = -metrics.ascent
|
||||
|
||||
columns = max((width / charWidth).toInt(), 1)
|
||||
rows = max((height / charHeight).toInt() + 2, 1)
|
||||
|
||||
glyphs = CharArray(columns * rows) { randomGlyph() }
|
||||
streams = Array(columns) { StreamState.random(rows, style, random) }
|
||||
}
|
||||
|
||||
fun update(deltaSeconds: Float, style: MatrixStyle) {
|
||||
if (columns == 0 || rows == 0 || deltaSeconds <= 0f) return
|
||||
|
||||
for (column in streams.indices) {
|
||||
val stream = streams[column]
|
||||
stream.pauseSec -= deltaSeconds
|
||||
if (stream.pauseSec > 0f) continue
|
||||
|
||||
val previousHead = floor(stream.headRow).toInt()
|
||||
stream.headRow += stream.speedRps * deltaSeconds
|
||||
val newHead = floor(stream.headRow).toInt()
|
||||
|
||||
if (newHead > previousHead) {
|
||||
for (row in (previousHead + 1)..newHead) {
|
||||
if (row in 0 until rows) {
|
||||
glyphs[row * columns + column] = randomGlyph()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (newHead - stream.length > rows) {
|
||||
stream.reset(rows, style, random)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun draw(scope: androidx.compose.ui.graphics.drawscope.DrawScope, style: MatrixStyle) {
|
||||
if (columns == 0 || rows == 0) return
|
||||
|
||||
scope.drawRect(style.bgColor)
|
||||
|
||||
bodyPaint.color = style.bodyColor.toArgb()
|
||||
headPaint.color = style.headColor.toArgb()
|
||||
val tailAlphaFloor = style.tailAlphaFloor.coerceIn(0f, 1f)
|
||||
|
||||
val canvas = scope.drawContext.canvas.nativeCanvas
|
||||
for (column in streams.indices) {
|
||||
val stream = streams[column]
|
||||
if (stream.pauseSec > 0f) continue
|
||||
|
||||
val head = floor(stream.headRow).toInt()
|
||||
val startRow = max(0, head - stream.length + 1)
|
||||
val endRow = min(rows - 1, head)
|
||||
if (startRow > endRow) continue
|
||||
|
||||
for (row in endRow downTo startRow) {
|
||||
val tailIndex = head - row
|
||||
|
||||
val paint = if (tailIndex == 0) headPaint else bodyPaint
|
||||
if (tailIndex != 0) {
|
||||
val normalized = ((stream.length - tailIndex).toFloat() / stream.length).coerceIn(0f, 1f)
|
||||
val alpha = tailAlphaFloor + (1f - tailAlphaFloor) * normalized
|
||||
paint.alpha = (alpha * 255).toInt()
|
||||
} else {
|
||||
paint.alpha = 255
|
||||
}
|
||||
|
||||
val glyph = glyphs[row * columns + column]
|
||||
charBuffer[0] = glyph
|
||||
val x = column * charWidth
|
||||
val y = row * charHeight + baselineOffset
|
||||
canvas.drawText(charBuffer, 0, 1, x, y, paint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun randomGlyph(): Char = symbols[random.nextInt(symbols.size)]
|
||||
}
|
||||
|
||||
private data class StreamState(
|
||||
var headRow: Float,
|
||||
var length: Int,
|
||||
var speedRps: Float,
|
||||
var pauseSec: Float,
|
||||
) {
|
||||
fun reset(rows: Int, style: MatrixStyle, random: Random) {
|
||||
val randomOffset = random.nextFloat() * rows
|
||||
headRow = -randomOffset
|
||||
length = random.nextInt(
|
||||
from = style.minStreamLength.coerceAtLeast(2),
|
||||
until = (style.maxStreamLength.coerceAtLeast(style.minStreamLength + 1) + 1),
|
||||
)
|
||||
speedRps = random.nextFloat() * (style.maxSpeedRps - style.minSpeedRps) + style.minSpeedRps
|
||||
pauseSec = random.nextFloat() * (style.resetPauseSec.endInclusive - style.resetPauseSec.start) +
|
||||
style.resetPauseSec.start
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun random(rows: Int, style: MatrixStyle, random: Random): StreamState {
|
||||
val length = random.nextInt(
|
||||
from = style.minStreamLength.coerceAtLeast(2),
|
||||
until = (style.maxStreamLength.coerceAtLeast(style.minStreamLength + 1) + 1),
|
||||
)
|
||||
return StreamState(
|
||||
headRow = -random.nextFloat() * rows,
|
||||
length = length,
|
||||
speedRps = random.nextFloat() * (style.maxSpeedRps - style.minSpeedRps) + style.minSpeedRps,
|
||||
pauseSec = random.nextFloat() * (style.resetPauseSec.endInclusive - style.resetPauseSec.start) +
|
||||
style.resetPauseSec.start,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user