mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-12 03:21:08 +00:00
feat(session): animate the logo on the loading screen
This commit is contained in:
1 parent
2b1ef0d3e6
commit
110f5e595d
4 files changed
+270
-109
No files matched your search
@@ -944,6 +944,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
if (drawerOpen && !drawerControllerActive) sessionOverlay.requestFocus()
|
||||
drawerControllerActive = true
|
||||
}
|
||||
// While the session starts, B is the loading screen's Cancel, as its corner says.
|
||||
if (fromController && event.keyCode == KeyEvent.KEYCODE_BUTTON_B && ::loading.isInitialized && loading.visible && !loading.ended && !drawerOpen) {
|
||||
if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) {
|
||||
SessionService.stop(this)
|
||||
finish()
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (drawerOpen) {
|
||||
if (fromController && (event.keyCode == KeyEvent.KEYCODE_BUTTON_L1 || event.keyCode == KeyEvent.KEYCODE_BUTTON_R1)) {
|
||||
if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) {
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import com.droiddeck.launcher.frontend.Library
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.offset
|
||||
@@ -191,7 +204,6 @@ fun LoadingOverlay(
|
||||
title: String = "Starting Steam", steam: Boolean = true, onCancel: (() -> Unit)? = null,
|
||||
) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val stages = remember(steam) { loadStages(steam) }
|
||||
var reached by remember { mutableStateOf(0) }
|
||||
LaunchedEffect(step, steam) {
|
||||
@@ -199,128 +211,100 @@ fun LoadingOverlay(
|
||||
if (at > reached) reached = at
|
||||
}
|
||||
val active = reached.coerceAtMost(stages.lastIndex)
|
||||
val stageRows: @Composable () -> Unit = {
|
||||
stages.forEachIndexed { i, stage ->
|
||||
StageRow(
|
||||
label = stage.label,
|
||||
state = when { i < active -> StageMark.DONE; i == active -> StageMark.ACTIVE; else -> StageMark.PENDING },
|
||||
detail = if (percent >= 0 || readableStep(step)) step.replaceFirstChar { it.uppercase() } else stage.detail,
|
||||
percent = percent,
|
||||
)
|
||||
}
|
||||
val detail = if (percent >= 0 || readableStep(step)) step.replaceFirstChar { it.uppercase() } else stages[active].detail
|
||||
val context = LocalContext.current
|
||||
// The Steam tab's wall behind it, dimmed and slowed: starting Steam reads as the same place settling in.
|
||||
val games by produceState(emptyList<Library.SteamGame>()) {
|
||||
value = withContext(Dispatchers.IO) { runCatching { Library.steamGames(context).sortedByDescending { it.lastPlayed } }.getOrDefault(emptyList()) }
|
||||
}
|
||||
androidx.compose.foundation.layout.BoxWithConstraints(
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(colors.background)
|
||||
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {},
|
||||
) {
|
||||
// A 4:3, square or short screen has no room for the side-by-side layout: one column, the spinner
|
||||
// beside the title, and the tip and Cancel along the bottom where they stay in view.
|
||||
if (maxWidth < 600.dp || maxHeight < 400.dp) Column(modifier = Modifier.fillMaxSize().padding(horizontal = 24.dp, vertical = 20.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(48.dp)) {
|
||||
if (!ended) CircularProgressIndicator(color = pal.signal, trackColor = pal.line, strokeWidth = 2.dp, modifier = Modifier.fillMaxSize())
|
||||
Image(painterResource(R.drawable.logo), contentDescription = null, modifier = Modifier.size(32.dp))
|
||||
}
|
||||
Text(
|
||||
if (ended) "The session ended" else title, fontSize = 22.sp, fontWeight = FontWeight.Bold, color = colors.onBackground,
|
||||
maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (!ended && elapsed.isNotEmpty()) Text(elapsed.substringBefore(' '), fontSize = 14.sp, color = colors.onSurfaceVariant)
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(top = 12.dp)) {
|
||||
if (ended) Text(step, color = colors.onSurfaceVariant, fontSize = 14.sp)
|
||||
else stageRows()
|
||||
}
|
||||
if (!ended && (hint.isNotEmpty() || onCancel != null)) {
|
||||
Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 10.dp),
|
||||
) {
|
||||
Text(hint, fontSize = 13.sp, lineHeight = 18.sp, color = colors.onSurfaceVariant, maxLines = 3, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
|
||||
if (onCancel != null) SecondaryButton("Cancel", compact = true, onClick = onCancel)
|
||||
}
|
||||
}
|
||||
} else Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize()) {
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.weight(0.4f).fillMaxHeight()) {
|
||||
if (!ended) CircularProgressIndicator(
|
||||
color = pal.signal, trackColor = pal.line, strokeWidth = 2.dp, modifier = Modifier.size(150.dp),
|
||||
)
|
||||
Image(painterResource(R.drawable.logo), contentDescription = null, modifier = Modifier.size(84.dp))
|
||||
}
|
||||
// Centred when it fits, scrolling when a long failure message does not.
|
||||
Box(contentAlignment = Alignment.CenterStart, modifier = Modifier.weight(0.6f).fillMaxHeight()) {
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(rememberScrollState())
|
||||
.padding(start = 8.dp, end = 48.dp, top = 28.dp, bottom = 28.dp),
|
||||
) {
|
||||
Text(
|
||||
if (ended) "The session ended" else title,
|
||||
color = colors.onBackground, fontSize = 28.sp, fontWeight = FontWeight.Bold,
|
||||
CapsuleWall(games, driftMs = 65_000, modifier = Modifier.alpha(0.3f))
|
||||
Box(
|
||||
Modifier.fillMaxSize().drawBehind {
|
||||
drawRect(
|
||||
Brush.radialGradient(
|
||||
0f to colors.background.copy(alpha = 0.94f),
|
||||
0.6f to colors.background.copy(alpha = 0.72f),
|
||||
1f to colors.background.copy(alpha = 0.55f),
|
||||
center = Offset(size.width / 2f, size.height * 0.46f), radius = maxOf(size.width, size.height) * 0.55f,
|
||||
),
|
||||
)
|
||||
if (ended) {
|
||||
Text(step, color = colors.onSurfaceVariant, fontSize = 14.sp, modifier = Modifier.padding(top = 10.dp))
|
||||
} else {
|
||||
if (elapsed.isNotEmpty()) Text(elapsed, color = colors.onSurfaceVariant, fontSize = 13.sp, modifier = Modifier.padding(top = 2.dp))
|
||||
Spacer(Modifier.height(16.dp))
|
||||
stageRows()
|
||||
if (hint.isNotEmpty()) Text(
|
||||
hint, color = colors.onSurfaceVariant, fontSize = 14.sp,
|
||||
modifier = Modifier.padding(top = 16.dp).widthIn(max = 480.dp)
|
||||
.clip(RoundedCornerShape(12.dp)).border(1.dp, pal.line2, RoundedCornerShape(12.dp))
|
||||
.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
)
|
||||
if (onCancel != null) Box(Modifier.padding(top = 16.dp)) { SecondaryButton("Cancel", onClick = onCancel) }
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
Box(Modifier.fillMaxSize().background(Brush.verticalGradient(0.7f to Color.Transparent, 1f to colors.background.copy(alpha = 0.95f))))
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.align(Alignment.Center).verticalScroll(rememberScrollState()).padding(horizontal = 32.dp, vertical = 24.dp),
|
||||
) {
|
||||
LogoThrobber(Modifier.width(if (ended) 88.dp else 110.dp), running = !ended)
|
||||
Spacer(Modifier.height(30.dp))
|
||||
Text(
|
||||
if (ended) "The session ended" else title, fontSize = 20.sp, fontWeight = FontWeight.SemiBold,
|
||||
color = colors.onBackground, textAlign = TextAlign.Center, maxLines = 2, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
if (ended) step else detail, fontSize = 14.sp, lineHeight = 20.sp, color = colors.onSurfaceVariant, textAlign = TextAlign.Center,
|
||||
maxLines = if (ended) 12 else 2, overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(top = 6.dp).widthIn(max = 520.dp),
|
||||
)
|
||||
if (!ended) LoadSegments(stages.size, active, percent, Modifier.padding(top = 18.dp))
|
||||
}
|
||||
}
|
||||
if (!ended && hint.isNotEmpty()) Text(
|
||||
hint, fontSize = 13.sp, lineHeight = 18.sp, color = colors.onSurfaceVariant.copy(alpha = 0.8f), textAlign = TextAlign.Center,
|
||||
maxLines = 3, overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.align(Alignment.BottomCenter).padding(start = 120.dp, end = 120.dp, bottom = 28.dp),
|
||||
)
|
||||
if (!ended && onCancel != null) CancelHint(onCancel, Modifier.align(Alignment.BottomEnd).padding(end = 16.dp, bottom = 16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
private enum class StageMark { DONE, ACTIVE, PENDING }
|
||||
|
||||
/** One segment per stage: done ones filled, the current one filling (or running, with no percent). */
|
||||
@Composable
|
||||
private fun StageRow(label: String, state: StageMark, detail: String, percent: Int) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
private fun LoadSegments(count: Int, active: Int, percent: Int, modifier: Modifier = Modifier) {
|
||||
val pal = LocalPalette.current
|
||||
val shape = RoundedCornerShape(14.dp)
|
||||
Column(
|
||||
modifier = Modifier.widthIn(max = 520.dp).fillMaxWidth()
|
||||
.then(
|
||||
if (state == StageMark.ACTIVE) Modifier.padding(vertical = 4.dp).clip(shape).background(colors.surface)
|
||||
.border(1.dp, pal.line, shape).padding(horizontal = 12.dp, vertical = 10.dp)
|
||||
else Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
),
|
||||
val track = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.12f)
|
||||
val run = rememberInfiniteTransition(label = "segment")
|
||||
.animateFloat(-0.4f, 1f, infiniteRepeatable(tween(1300, easing = FastOutSlowInEasing)), label = "run")
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = modifier.semantics { contentDescription = "Step ${active + 1} of $count" },
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
when (state) {
|
||||
StageMark.DONE -> Box(contentAlignment = Alignment.Center, modifier = Modifier.size(24.dp).clip(CircleShape).background(pal.good.copy(alpha = 0.16f))) {
|
||||
Icon(Icons.Filled.Check, contentDescription = "Done", tint = pal.good, modifier = Modifier.size(15.dp))
|
||||
}
|
||||
StageMark.ACTIVE -> Box(contentAlignment = Alignment.Center, modifier = Modifier.size(24.dp).border(2.dp, pal.signal, CircleShape)) {
|
||||
Box(Modifier.size(9.dp).clip(CircleShape).background(pal.signal))
|
||||
}
|
||||
StageMark.PENDING -> Box(Modifier.size(24.dp).border(2.dp, pal.line2, CircleShape))
|
||||
}
|
||||
Text(
|
||||
label, fontSize = 16.sp, modifier = Modifier.weight(1f),
|
||||
fontWeight = if (state == StageMark.ACTIVE) FontWeight.SemiBold else FontWeight.Normal,
|
||||
color = if (state == StageMark.PENDING) colors.onSurfaceVariant else colors.onBackground,
|
||||
repeat(count) { i ->
|
||||
Box(
|
||||
Modifier.width(44.dp).height(3.dp).clip(RoundedCornerShape(2.dp))
|
||||
.background(if (i < active) pal.signal else track)
|
||||
.drawBehind {
|
||||
if (i != active) return@drawBehind
|
||||
if (percent >= 0) drawRect(pal.signal, size = Size(size.width * percent.coerceIn(0, 100) / 100f, size.height))
|
||||
else drawRect(pal.signal, topLeft = Offset(size.width * run.value, 0f), size = Size(size.width * 0.4f, size.height))
|
||||
},
|
||||
)
|
||||
if (state == StageMark.ACTIVE && percent >= 0) Text("$percent%", fontSize = 14.sp, color = colors.onSurfaceVariant)
|
||||
}
|
||||
if (state == StageMark.ACTIVE) {
|
||||
Text(
|
||||
detail, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(start = 36.dp, top = 6.dp),
|
||||
)
|
||||
val bar = Modifier.padding(start = 36.dp, top = 8.dp).fillMaxWidth().height(4.dp)
|
||||
if (percent >= 0) LinearProgressIndicator(progress = { percent / 100f }, modifier = bar, color = pal.signal, trackColor = pal.line)
|
||||
else LinearProgressIndicator(modifier = bar, color = pal.signal, trackColor = pal.line)
|
||||
}
|
||||
}
|
||||
|
||||
/** Cancel as the pad's B, in the corner; a tap works too. */
|
||||
@Composable
|
||||
private fun CancelHint(onCancel: () -> Unit, modifier: Modifier = Modifier) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = src.collectIsHoveredAsState().value || src.collectIsFocusedAsState().value
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = modifier.heightIn(min = 44.dp).clip(RoundedCornerShape(22.dp))
|
||||
.background(if (hot) colors.onBackground.copy(alpha = 0.08f) else Color.Transparent)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onCancel)
|
||||
.padding(horizontal = 12.dp),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(20.dp).clip(CircleShape).background(colors.onBackground)) {
|
||||
Text("B", fontSize = 12.sp, fontWeight = FontWeight.Bold, color = colors.background)
|
||||
}
|
||||
Text("Cancel", fontSize = 13.sp, color = colors.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -116,19 +116,23 @@ internal fun SteamHome(s: FrontEndState, a: FrontEndActions, modifier: Modifier)
|
||||
* library draws the same wall in blank capsules, slower.
|
||||
*/
|
||||
@Composable
|
||||
private fun CapsuleWall(games: List<Library.SteamGame>) {
|
||||
internal fun CapsuleWall(
|
||||
games: List<Library.SteamGame>,
|
||||
driftMs: Int = if (games.isEmpty()) 75_000 else 40_000,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val capW = 112.dp
|
||||
val capH = 168.dp
|
||||
val gap = 14.dp
|
||||
val transition = rememberInfiniteTransition(label = "wall")
|
||||
val drift = transition.animateFloat(
|
||||
0f, 1f,
|
||||
infiniteRepeatable(tween(if (games.isEmpty()) 75_000 else 40_000, easing = LinearEasing)),
|
||||
infiniteRepeatable(tween(driftMs, easing = LinearEasing)),
|
||||
label = "drift",
|
||||
)
|
||||
// Animations off in the system settings: a still wall.
|
||||
val still = Motion.scale == 0f
|
||||
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||
BoxWithConstraints(modifier.fillMaxSize()) {
|
||||
val wallW = maxWidth * 1.3f
|
||||
val wallH = maxHeight * 2.4f
|
||||
val columns = ((wallW + gap) / (capW + gap)).toInt() + 1
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.compose.animation.core.CubicBezierEasing
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.drawscope.rotate
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* The DroidDeck mark as the loading indicator. Every loop the D's square half bends into an arc,
|
||||
* the ring they make turns once around the ball, and the half straightens back into the D.
|
||||
*/
|
||||
@Composable
|
||||
internal fun LogoThrobber(modifier: Modifier = Modifier, running: Boolean = true) {
|
||||
val ink = MaterialTheme.colorScheme.onBackground
|
||||
val ball = LocalPalette.current.signal
|
||||
val loop = rememberInfiniteTransition(label = "throbber")
|
||||
.animateFloat(0f, 1f, infiniteRepeatable(tween(LOOP_MS, easing = LinearEasing)), label = "loop")
|
||||
// Animations off in the system settings, or nothing running: the plain D.
|
||||
val still = !running || Motion.scale == 0f
|
||||
val left = remember { Path() }
|
||||
val right = remember { Path() }
|
||||
Canvas(modifier.aspectRatio(LogoShape.WIDTH / LogoShape.HEIGHT)) {
|
||||
val p = if (still) 0f else loop.value
|
||||
val bend = when {
|
||||
p < 0.10f -> 0f
|
||||
p < 0.24f -> Settle.transform((p - 0.10f) / 0.14f)
|
||||
p < 0.76f -> 1f
|
||||
p < 0.90f -> 1f - Settle.transform((p - 0.76f) / 0.14f)
|
||||
else -> 0f
|
||||
}
|
||||
val turn = if (p < 0.16f || p > 0.84f) 0f else Turn.transform((p - 0.16f) / 0.68f) * 360f
|
||||
val ballScale = when {
|
||||
p < 0.12f -> 1f
|
||||
p < 0.30f -> 1f - 0.2f * Settle.transform((p - 0.12f) / 0.18f)
|
||||
p < 0.70f -> 0.8f
|
||||
p < 0.88f -> 0.8f + 0.2f * Settle.transform((p - 0.70f) / 0.18f)
|
||||
else -> 1f
|
||||
}
|
||||
val s = size.width / LogoShape.WIDTH
|
||||
fun px(x: Float, y: Float) = Offset((x - LogoShape.LEFT) * s, y * s)
|
||||
val centre = px(LogoShape.CX, LogoShape.CY)
|
||||
LogoShape.left(left, bend, ::px)
|
||||
LogoShape.right(right, ::px)
|
||||
rotate(turn, centre) {
|
||||
drawPath(left, ink)
|
||||
drawPath(right, ink)
|
||||
}
|
||||
drawCircle(ball, radius = LogoShape.BALL * s * ballScale, center = centre)
|
||||
}
|
||||
}
|
||||
|
||||
private const val LOOP_MS = 1900
|
||||
private val Settle = CubicBezierEasing(0.5f, 0f, 0.3f, 1f)
|
||||
private val Turn = CubicBezierEasing(0.65f, 0f, 0.35f, 1f)
|
||||
|
||||
/**
|
||||
* The logo's geometry in its SVG's own units (viewBox -55.98 0 239.92 223.72): a D of a square
|
||||
* left side and a round right side, cut by a ring around the ball and split top and bottom.
|
||||
* The left half is sampled along rays from the centre so it can bend, point for point, into the
|
||||
* mirror of the right half's arc.
|
||||
*/
|
||||
private object LogoShape {
|
||||
const val LEFT = -55.98f
|
||||
const val WIDTH = 239.92f
|
||||
const val HEIGHT = 223.72f
|
||||
const val CX = 63.98f
|
||||
const val CY = 111.86f
|
||||
const val BALL = 55.98f
|
||||
private const val OUTER = 111.86f
|
||||
private const val INNER = 71.81f
|
||||
private const val SQUARE_X = -31.98f
|
||||
private const val SPLIT_LEFT = 56.055f
|
||||
private const val SPLIT_RIGHT = 71.885f
|
||||
|
||||
private class Pt(val x: Float, val y: Float)
|
||||
|
||||
private fun at(a: Double, r: Float) = Pt((CX + r * cos(a)).toFloat(), (CY + r * sin(a)).toFloat())
|
||||
private fun angle(x: Float, y: Float) = atan2((y - CY).toDouble(), (x - CX).toDouble())
|
||||
// Angles taken the long way round, through the left: from about -94 degrees down to -266.
|
||||
private fun leftward(a: Double) = if (a < 0) a else a - 2 * Math.PI
|
||||
|
||||
private val rays: List<Double> = run {
|
||||
val top = angle(SPLIT_LEFT, 0f)
|
||||
val topCorner = angle(SQUARE_X, 0f)
|
||||
val bottomCorner = leftward(angle(SQUARE_X, HEIGHT))
|
||||
val bottom = leftward(angle(SPLIT_LEFT, HEIGHT))
|
||||
// The corners are rays of their own, so the square half keeps sharp corners.
|
||||
fun span(a: Double, b: Double, n: Int) = List(n) { i -> a + (b - a) * i / (n - 1) }
|
||||
span(top, topCorner, 9).dropLast(1) + span(topCorner, bottomCorner, 25).dropLast(1) + span(bottomCorner, bottom, 9)
|
||||
}
|
||||
|
||||
private val square: List<Pt> = rays.map { a ->
|
||||
val c = cos(a)
|
||||
val sn = sin(a)
|
||||
val hits = buildList {
|
||||
if (c < -1e-9) add((SQUARE_X - CX) / c)
|
||||
if (sn < -1e-9) add((0f - CY) / sn)
|
||||
if (sn > 1e-9) add((HEIGHT - CY) / sn)
|
||||
}
|
||||
val t = hits.minOrNull() ?: 0.0
|
||||
Pt((CX + t * c).toFloat(), (CY + t * sn).toFloat())
|
||||
}.toMutableList().also { it[0] = Pt(SPLIT_LEFT, 0f); it[it.lastIndex] = Pt(SPLIT_LEFT, HEIGHT) }
|
||||
|
||||
private val arc: List<Pt> = rays.map { at(it, OUTER) }.toMutableList().also {
|
||||
val dy = sqrt(OUTER * OUTER - (SPLIT_LEFT - CX) * (SPLIT_LEFT - CX))
|
||||
it[0] = Pt(SPLIT_LEFT, CY - dy)
|
||||
it[it.lastIndex] = Pt(SPLIT_LEFT, CY + dy)
|
||||
}
|
||||
|
||||
private val leftInner: List<Pt> = run {
|
||||
val dx = (SPLIT_LEFT - CX).toDouble()
|
||||
val dy = sqrt(INNER * INNER - dx * dx)
|
||||
val from = atan2(dy, dx)
|
||||
val to = atan2(-dy, dx) + 2 * Math.PI
|
||||
List(rays.size) { i -> at(from + (to - from) * i / (rays.size - 1), INNER) }
|
||||
}
|
||||
|
||||
private val rightHalf: List<Pt> = run {
|
||||
val dx = (SPLIT_RIGHT - CX).toDouble()
|
||||
val oy = sqrt(OUTER * OUTER - dx * dx)
|
||||
val iy = sqrt(INNER * INNER - dx * dx)
|
||||
val outerFrom = atan2(-oy, dx)
|
||||
val outerTo = atan2(oy, dx)
|
||||
val innerFrom = atan2(iy, dx)
|
||||
val innerTo = atan2(-iy, dx)
|
||||
List(40) { i -> at(outerFrom + (outerTo - outerFrom) * i / 39, OUTER) } +
|
||||
List(40) { i -> at(innerFrom + (innerTo - innerFrom) * i / 39, INNER) }
|
||||
}
|
||||
|
||||
/** The left half, [bend] of the way from the square D side (0) to the arc (1). */
|
||||
fun left(path: Path, bend: Float, px: (Float, Float) -> Offset) {
|
||||
path.reset()
|
||||
square.forEachIndexed { i, q ->
|
||||
val r = arc[i]
|
||||
val o = px(q.x + (r.x - q.x) * bend, q.y + (r.y - q.y) * bend)
|
||||
if (i == 0) path.moveTo(o.x, o.y) else path.lineTo(o.x, o.y)
|
||||
}
|
||||
for (q in leftInner) px(q.x, q.y).let { path.lineTo(it.x, it.y) }
|
||||
path.close()
|
||||
}
|
||||
|
||||
fun right(path: Path, px: (Float, Float) -> Offset) {
|
||||
path.reset()
|
||||
rightHalf.forEachIndexed { i, q ->
|
||||
val o = px(q.x, q.y)
|
||||
if (i == 0) path.moveTo(o.x, o.y) else path.lineTo(o.x, o.y)
|
||||
}
|
||||
path.close()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user