diff options
Diffstat (limited to 'ui/src/commonMain/kotlin/coffee/liz')
4 files changed, 28 insertions, 191 deletions
diff --git a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/App.kt b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/App.kt index c133046..e8f64a3 100644 --- a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/App.kt +++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/App.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -49,18 +50,7 @@ fun RookeryApp(m: AppModel) { val dark = when (m.themeMode) { ThemeMode.SYSTEM -> isSystemInDarkTheme(); ThemeMode.LIGHT -> false; ThemeMode.DARK -> true } RookeryTheme(dark) { val p = Rk.c - Box( - Modifier.fillMaxSize().background(p.bg).pointerInput(Unit) { - // watch the pointer for the penguin without taking events from anything - awaitPointerEventScope { - while (true) { - val e = awaitPointerEvent(PointerEventPass.Initial) - m.pointer = if (e.type == PointerEventType.Exit) null else e.changes.firstOrNull()?.position ?: m.pointer - } - } - }, - ) { - if (m.snow && !m.reducedMotion) Snow(p.ink.copy(alpha = if (p.dark) 0.5f else 0.28f), Modifier.fillMaxSize()) + Box(Modifier.fillMaxSize().background(p.bg)) { Column(Modifier.fillMaxSize().padding(horizontal = 14.dp, vertical = 8.dp)) { if (!m.displayMode) Header(m) Box(Modifier.weight(1f).fillMaxWidth()) { @@ -83,7 +73,7 @@ fun RookeryApp(m: AppModel) { m.penguinSays != null && s is State.Stopped && s.penalty == Penalty.DNF -> Mood.SAD else -> Mood.FREE } - PenguinFriend(m.pointer, mood, m.penguinSays, m.reducedMotion) + PenguinFriend(mood, m.penguinSays, m.reducedMotion) m.party?.let { PbParty(it.title, it.detail, m.reducedMotion) { m.party = null } } } } @@ -96,38 +86,15 @@ private fun Header(m: AppModel) { Spacer(Modifier.width(18.dp)) Tabs(Tab.entries, m.tab, { it.label }, { m.tab = it }) } - var news by remember { mutableStateOf(penguinNews()) } - LaunchedEffect(Unit) { while (true) { delay(90_000); news = penguinNews() } } - Marquee(news, m.reducedMotion, color = Rk.c.inkDim) Hr() } @Composable private fun Footer(m: AppModel) { Hr() - Row(verticalAlignment = Alignment.CenterVertically) { + Row(Modifier.height(31.dp), verticalAlignment = Alignment.CenterVertically) { Spacer(Modifier.width(84.dp)) // the penguin's spot - Label("this is ${m.host.deviceId}. ", color = Rk.c.inkDim, style = Rk.t.label.copy(fontStyle = FontStyle.Italic)) if (m.tab == Tab.TIMER) RetroButton("big display", { m.changeDisplayMode(true) }, style = Rk.t.label) - Spacer(Modifier.weight(1f)) - Blinkie(m.reducedMotion) - } -} - -/** An 88x31 button, as every homepage should have. */ -@Composable -private fun Blinkie(still: Boolean) { - var phase by remember { mutableIntStateOf(0) } - LaunchedEffect(still) { while (!still) { delay(320); phase++ } } - val p = Rk.c - Box(Modifier.size(88.dp, 31.dp).background(p.field).clearAndSetSemantics { }, Alignment.Center) { - Canvas(Modifier.fillMaxSize()) { - val w = 2.dp.toPx(); val dash = 4.dp.toPx() - drawRect(p.lavender, Offset(w / 2, w / 2), Size(size.width - w, size.height - w), style = Stroke(w)) - drawRect(p.accent, Offset(w / 2, w / 2), Size(size.width - w, size.height - w), - style = Stroke(w, pathEffect = PathEffect.dashPathEffect(floatArrayOf(dash, dash), if (phase % 2 == 0) 0f else dash))) - } - BasicText("waddle on", style = Rk.t.label.copy(color = if (phase % 4 < 2) p.ink else p.accent, fontStyle = FontStyle.Italic)) } } diff --git a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/AppModel.kt b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/AppModel.kt index d5b8c34..4aa37d9 100644 --- a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/AppModel.kt +++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/AppModel.kt @@ -95,8 +95,6 @@ class AppModel(val repo: Repo, private val scrambler: Scrambler, val host: Host, private set fun changeDisplayMode(on: Boolean) { displayMode = on; if (on) tab = Tab.TIMER; host.setFullscreen(on) } - /** Where the pointer is, for the penguin to follow. */ - var pointer by mutableStateOf<androidx.compose.ui.geometry.Offset?>(null) var penguinSays by mutableStateOf<String?>(null) private set private var sayJob: kotlinx.coroutines.Job? = null @@ -105,8 +103,7 @@ class AppModel(val repo: Repo, private val scrambler: Scrambler, val host: Host, penguinSays = text sayJob = scope.launch { delay(ms); penguinSays = null } } - var snow by mutableStateOf(repo.boolPref("snow", true)); private set - fun changeSnow(on: Boolean) { snow = on; repo.setPref("snow", on) } + var derived by mutableStateOf(Derived()) private set var sessionId by mutableStateOf(repo.pref("lastSession")) diff --git a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/OtherScreens.kt b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/OtherScreens.kt index da8bb04..f467fe7 100644 --- a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/OtherScreens.kt +++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/OtherScreens.kt @@ -73,8 +73,7 @@ fun SettingsScreen(m: AppModel) { Label("page: ", color = Rk.c.inkDim) Tabs(ThemeMode.entries, m.themeMode, { when (it) { ThemeMode.SYSTEM -> "whatever the computer says"; ThemeMode.LIGHT -> "paper"; ThemeMode.DARK -> "night" } }, { m.setTheme(it) }) } - Checkbox("snow", m.snow, m::changeSnow) - Checkbox("hold still (no snow, no marquee, a penguin that stays put)", m.reducedMotion, m::changeReducedMotion) + Checkbox("hold everything still (no confetti, no gliding cube, a penguin that doesn't fidget)", m.reducedMotion, m::changeReducedMotion) Hr() Heading("syncing") Label("Everything lives in one folder, as a log per device that only that device writes. Share the folder with Syncthing (or Drive, or a USB stick) and the devices merge on their own.") diff --git a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Penguin.kt b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Penguin.kt index 32d8141..548451f 100644 --- a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Penguin.kt +++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Penguin.kt @@ -100,73 +100,43 @@ fun Penguin(anim: PenguinAnim, size: Dp = 64.dp, reducedMotion: Boolean = false, enum class Mood { FREE, WATCHING, READY, SOLVING, HAPPY, SAD } /** - * The penguin lives in the window the way it lives on liz.coffee: it waddles after your pointer, stops - * a polite distance away, pecks around when you hold still, and falls asleep if you leave it long - * enough. While a solve is on it stands still and watches, and it says a word when you finish. + * The penguin keeps to its corner of the window. It pecks about now and then, nods off if nothing + * happens for a while, stands to attention when the timer is armed, and says a word after a solve. */ @Composable -fun PenguinFriend(pointer: Offset?, mood: Mood, says: String?, reducedMotion: Boolean, size: Dp = 64.dp) { +fun PenguinFriend(mood: Mood, says: String?, reducedMotion: Boolean, size: Dp = 64.dp) { val sheet = penguinSheet() - val density = LocalDensity.current - val sizePx = with(density) { size.toPx() } + val sizePx = with(LocalDensity.current) { size.toPx() } BoxWithConstraints(Modifier.fillMaxSize().clearAndSetSemantics { }) { - val w = constraints.maxWidth.toFloat(); val h = constraints.maxHeight.toFloat() - val home = Offset(sizePx * 0.9f, h - sizePx * 0.8f) - var pos by remember { mutableStateOf(home) } + val h = constraints.maxHeight.toFloat() + val pos = Offset(sizePx * 0.9f, h - sizePx * 0.8f) var anim by remember { mutableStateOf(PenguinAnim.IDLE) } - var clock by remember { mutableLongStateOf(0) } - LaunchedEffect(mood, reducedMotion, pointer == null, w, h) { - var last = withFrameMillis { it } - var still = 0L // ms spent standing - var startled = 0L // ms left of the "!" before walking - var pecking = 0L + var frame by remember { mutableIntStateOf(0) } + LaunchedEffect(mood, reducedMotion) { + var still = 0L + var pecking = 0 while (true) { - val now = withFrameMillis { it } - val dt = (now - last).coerceAtMost(100); last = now; clock = now - if (reducedMotion) { - pos = home - anim = when (mood) { Mood.READY, Mood.HAPPY -> PenguinAnim.ALERT; Mood.SAD -> PenguinAnim.TIRED; else -> PenguinAnim.IDLE } - continue - } - when (mood) { - Mood.WATCHING, Mood.SOLVING -> { anim = PenguinAnim.IDLE; still = 0; continue } - Mood.READY -> { anim = PenguinAnim.ALERT; still = 0; continue } - else -> {} - } - // stand below-right of the pointer, like a pet at your heel - val target = (pointer?.let { it + Offset(sizePx * 0.7f, sizePx * 0.6f) } ?: home) - .let { Offset(it.x.coerceIn(sizePx / 2, w - sizePx / 2), it.y.coerceIn(sizePx / 2, h - sizePx / 2)) } - val dx = target.x - pos.x; val dy = target.y - pos.y - val dist = hypot(dx, dy) - if (dist < sizePx * 0.35f) { - still += dt; startled = 0 - anim = when { - mood == Mood.HAPPY -> if ((now / 160) % 2 == 0L) PenguinAnim.ALERT else PenguinAnim.IDLE - mood == Mood.SAD -> PenguinAnim.TIRED - still > 25_000 -> PenguinAnim.SLEEP - pecking > 0 -> { pecking -= dt; PenguinAnim.PECK } - else -> { if (still > 3_000 && Random.nextInt(500) == 0) pecking = 1_400; PenguinAnim.IDLE } - } - } else { - if (still > 600 && startled == 0L) startled = 260 // notice the pointer before chasing it - still = 0; pecking = 0 - if (startled > 0) { startled = (startled - dt).coerceAtLeast(1).let { if (it <= 1) -1 else it }; anim = PenguinAnim.ALERT; continue } - val step = minOf(dist, sizePx * 3.2f * dt / 1000f) - pos = Offset(pos.x + dx / dist * step, pos.y + dy / dist * step) - val dirs = listOf(PenguinAnim.E, PenguinAnim.SE, PenguinAnim.S, PenguinAnim.SW, PenguinAnim.W, PenguinAnim.NW, PenguinAnim.N, PenguinAnim.NE) - anim = dirs[(((atan2(dy, dx) / (PI / 4)).roundToInt() % 8) + 8) % 8] + anim = when { + mood == Mood.READY -> PenguinAnim.ALERT + mood == Mood.HAPPY -> if (reducedMotion || frame % 2 == 0) PenguinAnim.ALERT else PenguinAnim.IDLE + mood == Mood.SAD -> PenguinAnim.TIRED + mood != Mood.FREE || reducedMotion -> PenguinAnim.IDLE + still > 40_000 -> PenguinAnim.SLEEP + pecking > 0 -> { pecking--; PenguinAnim.PECK } + else -> { if (still > 5_000 && Random.nextInt(60) == 0) pecking = 8; PenguinAnim.IDLE } } + val ms = if (anim == PenguinAnim.SLEEP) 700L else 180L + delay(ms); still += ms; frame++ } } - val frameMs = if (anim == PenguinAnim.SLEEP) 700 else 170 Canvas(Modifier.fillMaxSize()) { - drawPenguin(sheet, anim.frames[((clock / frameMs) % anim.frames.size).toInt()], Offset(pos.x - sizePx / 2, pos.y - sizePx / 2), sizePx) + drawPenguin(sheet, anim.frames[if (reducedMotion) 0 else frame % anim.frames.size], Offset(pos.x - sizePx / 2, pos.y - sizePx / 2), sizePx) } if (says != null) { val p = Rk.c BasicText( says, - Modifier.offset { IntOffset((pos.x + sizePx * 0.35f).roundToInt().coerceAtMost((w - sizePx * 2.2f).roundToInt()), (pos.y - sizePx * 0.95f).roundToInt().coerceAtLeast(0)) } + Modifier.offset { IntOffset((pos.x + sizePx * 0.35f).roundToInt(), (pos.y - sizePx * 0.95f).roundToInt().coerceAtLeast(0)) } .background(p.bg).edge(p, sunk = false).padding(horizontal = 8.dp, vertical = 3.dp), style = Rk.t.body.copy(color = p.ink, fontStyle = FontStyle.Italic), ) @@ -174,102 +144,6 @@ fun PenguinFriend(pointer: Offset?, mood: Mood, says: String?, reducedMotion: Bo } } -// ---------------------------------------------------------------- snow - -/** Outline of a Koch snowflake with unit circumradius. */ -private fun kochPoints(depth: Int): List<Offset> { - fun sub(a: Offset, b: Offset, d: Int, out: MutableList<Offset>) { - if (d == 0) { out += a; return } - val v = (b - a) / 3f - val p1 = a + v; val p3 = a + v * 2f - val peak = p1 + Offset((v.x * 0.5f + v.y * 0.8660254f), (-v.x * 0.8660254f + v.y * 0.5f)) - sub(a, p1, d - 1, out); sub(p1, peak, d - 1, out); sub(peak, p3, d - 1, out); sub(p3, b, d - 1, out) - } - val tri = (0 until 3).map { Offset(cos(-PI / 2 + it * 2 * PI / 3).toFloat(), sin(-PI / 2 + it * 2 * PI / 3).toFloat()) } - return buildList { for (i in 0 until 3) sub(tri[i], tri[(i + 2) % 3], depth, this) } -} - -/** Koch snowflakes drifting down behind everything, as on the site. Purely decorative. */ -@Composable -fun Snow(color: Color, modifier: Modifier = Modifier, flakes: Int = 34) { - class Flake(var x: Float, var y: Float, val size: Float, val fall: Float, val sway: Float, val phase: Float, val spin: Float, val alpha: Float) - val all = remember { - val sizes = floatArrayOf(3f, 6f, 9f, 13f, 18f) - List(flakes) { - val s = sizes[Random.nextInt(sizes.size)] - Flake(Random.nextFloat(), Random.nextFloat(), s, 0.012f + s * 0.0022f + Random.nextFloat() * 0.01f, 6f + Random.nextFloat() * 14f, Random.nextFloat() * 6.28f, (Random.nextFloat() - 0.5f) * 40f, 0.25f + Random.nextFloat() * 0.5f) - } - } - val outline = remember { Path().apply { val pts = kochPoints(2); moveTo(pts[0].x, pts[0].y); pts.drop(1).forEach { lineTo(it.x, it.y) }; close() } } - var t by remember { mutableFloatStateOf(0f) } - LaunchedEffect(Unit) { - var last = withFrameMillis { it } - while (true) { - delay(33) // ~30fps is plenty for snow - val now = withFrameMillis { it } - val dt = (now - last).coerceAtMost(100) / 1000f; last = now - for (f in all) { f.y += f.fall * dt; if (f.y > 1.05f) { f.y = -0.05f; f.x = Random.nextFloat() } } - t += dt - } - } - Canvas(modifier.clearAndSetSemantics { }) { - val time = t - for (f in all) { - val r = f.size.dp.toPx() / 2 - val x = f.x * size.width + sin(time * 0.6f + f.phase) * f.sway.dp.toPx() - translate(x, f.y * size.height) { - rotate(time * f.spin + f.phase * 57f, pivot = Offset.Zero) { - drawContext.transform.scale(r, r, Offset.Zero) - drawPath(outline, color.copy(alpha = f.alpha * 0.22f)) - drawPath(outline, color.copy(alpha = f.alpha), style = Stroke(0.09f)) - } - } - } - } -} - -// ---------------------------------------------------------------- marquee - -/** `<marquee>`. Take it away and she will find you. Stands still when motion is reduced. */ -@Composable -fun Marquee(text: String, reducedMotion: Boolean, modifier: Modifier = Modifier, color: Color? = null) { - var textW by remember(text) { mutableIntStateOf(0) } - var x by remember(text) { mutableFloatStateOf(Float.NaN) } - BoxWithConstraints(modifier.fillMaxWidth().clipToBounds()) { - val w = constraints.maxWidth.toFloat() - val speed = with(LocalDensity.current) { 48.dp.toPx() } - LaunchedEffect(text, reducedMotion, w) { - if (reducedMotion) { x = 0f; return@LaunchedEffect } - if (x.isNaN()) x = w - var last = withFrameMillis { it } - while (true) { - val now = withFrameMillis { it } - x -= speed * (now - last).coerceAtMost(100) / 1000f; last = now - if (textW > 0 && x < -textW) x = w - } - } - BasicText( - text, - Modifier.wrapContentWidth(Alignment.Start, unbounded = true).onSizeChanged { textW = it.width } - .offset { IntOffset(if (x.isNaN()) w.roundToInt() else x.roundToInt(), 0) }, - style = Rk.t.body.copy(color = color ?: Rk.c.ink), maxLines = 1, softWrap = false, - ) - } -} - -/** Colony news in the manner of the site's generated penguin paragraph. */ -fun penguinNews(random: Random = Random.Default, sentences: Int = 4): String { - val subject = listOf("Some windswept colony", "One damp chick", "A well-loved adelie creche", "One fluffy raft", "The flipper", "One beak", "A suspicious gentoo", "Every macaroni penguin", "One penguin", "The smallest rockhopper", "A committee of emperors") - val adverb = listOf("smugly", "quietly", "sideways", "twice", "suspiciously", "almost", "ceremonially", "against advice", "for no reason") - val verb = listOf("polishes", "guards", "toboggans past", "hoards", "counts", "pecks at", "dives for", "times", "scrambles", "inspects", "memorises") - val thing = listOf("three krill", "a shiny fish", "a spare flipper", "the nest", "someone's chick", "a 3x3", "the last pebble", "an ao5", "a stackmat", "a lettering scheme", "the T-perm") - val where = listOf("beneath the waves", "during the blizzard", "on the ice shelf", "before inspection ends", "in under fifteen seconds", "blindfolded", "") - val noise = listOf("Squawk.", "Waddle.", "Waddle.", "Honk.", "") - return (1..sentences).joinToString(" ") { - listOf(subject.random(random), adverb.random(random), verb.random(random), thing.random(random), where.random(random)).filter { it.isNotEmpty() }.joinToString(" ") + ". " + noise.random(random) - }.replace(Regex(" +"), " ").trim() -} - // ---------------------------------------------------------------- pb party /** A new personal best: it snows penguins for a few seconds under a `<blink>`ing notice. */ |
