summaryrefslogtreecommitdiff
path: root/ui/src/commonMain/kotlin
diff options
context:
space:
mode:
Diffstat (limited to 'ui/src/commonMain/kotlin')
-rw-r--r--ui/src/commonMain/kotlin/coffee/liz/rookery/ui/App.kt41
-rw-r--r--ui/src/commonMain/kotlin/coffee/liz/rookery/ui/AppModel.kt5
-rw-r--r--ui/src/commonMain/kotlin/coffee/liz/rookery/ui/OtherScreens.kt3
-rw-r--r--ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Penguin.kt170
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. */