summaryrefslogtreecommitdiff
path: root/ui/src/commonMain/kotlin/coffee
diff options
context:
space:
mode:
Diffstat (limited to 'ui/src/commonMain/kotlin/coffee')
-rw-r--r--ui/src/commonMain/kotlin/coffee/liz/rookery/ui/App.kt1
-rw-r--r--ui/src/commonMain/kotlin/coffee/liz/rookery/ui/AppModel.kt8
-rw-r--r--ui/src/commonMain/kotlin/coffee/liz/rookery/ui/BldModel.kt77
-rw-r--r--ui/src/commonMain/kotlin/coffee/liz/rookery/ui/BldScreen.kt619
-rw-r--r--ui/src/commonMain/kotlin/coffee/liz/rookery/ui/PuzzleView.kt49
-rw-r--r--ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Retro.kt2
6 files changed, 752 insertions, 4 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 3cc79dd..40b799d 100644
--- a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/App.kt
+++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/App.kt
@@ -31,6 +31,7 @@ fun RookeryApp(m: AppModel) {
when (m.tab) {
Tab.TIMER -> TimerScreen(m)
Tab.PAIRS -> PairsScreen(m)
+ Tab.BLD -> BldScreen(m)
Tab.IMPORT -> ImportScreen(m)
Tab.SETTINGS -> SettingsScreen(m)
}
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 db34c7b..69e0947 100644
--- a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/AppModel.kt
+++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/AppModel.kt
@@ -46,7 +46,7 @@ interface Host {
val deviceId: String
}
-enum class Tab(val label: String) { TIMER("Timer"), PAIRS("Letter pairs"), IMPORT("Import"), SETTINGS("Settings") }
+enum class Tab(val label: String) { TIMER("Timer"), PAIRS("Letter pairs"), BLD("BLD"), IMPORT("Import"), SETTINGS("Settings") }
enum class ThemeMode { SYSTEM, LIGHT, DARK }
/** Everything derived from the store, recomputed off the UI thread when it changes. */
@@ -77,6 +77,7 @@ data class Replay(
class AppModel(val repo: Repo, private val scrambler: Scrambler, val host: Host, private val scope: CoroutineScope) {
var tab by mutableStateOf(Tab.TIMER)
+ var bldMode by mutableStateOf(BldMode.P2W)
var derived by mutableStateOf(Derived())
private set
var sessionId by mutableStateOf(repo.pref("lastSession"))
@@ -302,4 +303,9 @@ class AppModel(val repo: Repo, private val scrambler: Scrambler, val host: Host,
repo.setPairImage(pair, repo.store.blobs.put(bytes, path.substringAfterLast('.', "jpg")))
}
fun blob(name: String): ByteArray? = repo.store.blobs.get(name)
+
+ /** A fresh official scramble, generated off the UI thread. */
+ suspend fun freshScramble(e: Event): String = withContext(Dispatchers.Default) { scrambler.next(e) }
+
+ val bld: BldModel by lazy { BldModel(repo) }
}
diff --git a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/BldModel.kt b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/BldModel.kt
new file mode 100644
index 0000000..e2c0760
--- /dev/null
+++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/BldModel.kt
@@ -0,0 +1,77 @@
+package coffee.liz.rookery.ui
+
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import coffee.liz.rookery.bld.BldSetup
+import coffee.liz.rookery.bld.Cube3
+import coffee.liz.rookery.bld.PieceType
+import coffee.liz.rookery.epochMillis
+import coffee.liz.rookery.randomId
+import coffee.liz.rookery.srs.DrillPicker
+import coffee.liz.rookery.srs.Fsrs
+import coffee.liz.rookery.srs.Grade
+import coffee.liz.rookery.srs.Memory
+import coffee.liz.rookery.srs.Review
+import coffee.liz.rookery.srs.gradeAnswer
+import coffee.liz.rookery.store.Repo
+
+/**
+ * Shared state for the BLD trainers: the solver's setup and every item's review history.
+ *
+ * Item ids: `p2w:AB` pair→word, `w2p:AB` word→pair, `name:c:15` / `find:e:3` target drills
+ * (type c/e, Speffz position of the sticker's home).
+ */
+class BldModel(private val repo: Repo) {
+ var setup by mutableStateOf(repo.bldSetup())
+ private set
+
+ private val history = HashMap<String, MutableList<Review>>()
+ /** Letter Pair Gym results become synthetic past reviews, so its weak pairs come up first. */
+ private val seeds: Map<String, List<Review>>
+ private val picker = DrillPicker()
+
+ init {
+ for (r in repo.reviews()) history.getOrPut(r.item) { mutableListOf() } += r
+ val dayMs = Memory.DAY_MS.toLong()
+ val start = epochMillis() - 30 * dayMs
+ seeds = repo.gymHistory().mapKeys { "p2w:${it.key}" }.mapValues { (item, rec) ->
+ rec.mapIndexed { i, c ->
+ Review(item, start + i * dayMs, when (c) { 'm' -> Grade.AGAIN; 's' -> Grade.HARD; else -> Grade.GOOD })
+ }
+ }
+ }
+
+ private fun reviewsOf(item: String): List<Review> = history[item] ?: seeds[item].orEmpty()
+
+ fun memory(item: String): Memory? = Fsrs.replay(reviewsOf(item))
+
+ private fun recentMisses(item: String): Int = reviewsOf(item).sortedBy { it.at }.takeLast(3).count { it.grade == Grade.AGAIN }
+
+ /** Next item to drill out of [items], avoiding the most recent few. */
+ fun pick(items: List<String>, recent: List<String>): String? =
+ picker.pick(items, ::memory, ::recentMisses, recent.takeLast(minOf(4, items.size / 2)), epochMillis())
+
+ /** Record an answer; returns its grade. */
+ fun record(item: String, correct: Boolean, ms: Long, goodMs: Long = 2_500): Grade {
+ val grade = gradeAnswer(correct, ms, goodMs)
+ val r = Review(item, epochMillis(), grade, ms)
+ repo.addReview(randomId(), r)
+ // the first real review replaces any seeded history
+ history.getOrPut(item) { seeds[item].orEmpty().toMutableList() } += r
+ return grade
+ }
+
+ fun updateSetup(s: BldSetup) { repo.setBldSetup(s); setup = s }
+
+ /** Positions you'd ever memo for a type: everything off the buffer piece. */
+ fun memoPositions(type: PieceType): List<Int> {
+ val buffer = Cube3.pieceId(type, setup.buffer(type))
+ return (0 until 24).filter { Cube3.pieceId(type, it) != buffer }
+ }
+
+ /** Session accuracy for the status line. */
+ var sessionRight by mutableStateOf(0)
+ var sessionTotal by mutableStateOf(0)
+ fun tally(correct: Boolean) { sessionTotal++; if (correct) sessionRight++ }
+}
diff --git a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/BldScreen.kt b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/BldScreen.kt
new file mode 100644
index 0000000..1d5d69d
--- /dev/null
+++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/BldScreen.kt
@@ -0,0 +1,619 @@
+package coffee.liz.rookery.ui
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.focusable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+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
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.BasicText
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableLongStateOf
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.ImageBitmap
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.input.key.KeyEventType
+import androidx.compose.ui.input.key.key
+import androidx.compose.ui.input.key.onPreviewKeyEvent
+import androidx.compose.ui.input.key.type
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.semantics.LiveRegionMode
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.liveRegion
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import coffee.liz.rookery.bld.BldSetup
+import coffee.liz.rookery.bld.Cube3
+import coffee.liz.rookery.bld.LetterScheme
+import coffee.liz.rookery.bld.PieceType
+import coffee.liz.rookery.bld.Target
+import coffee.liz.rookery.bld.Tracer
+import coffee.liz.rookery.model.Event
+import coffee.liz.rookery.model.MemoSession
+import coffee.liz.rookery.model.Puzzle
+import coffee.liz.rookery.model.formatTime
+import coffee.liz.rookery.monoMillis
+import coffee.liz.rookery.epochMillis
+import coffee.liz.rookery.puzzle.CubeMapping
+import coffee.liz.rookery.puzzle.Puzzles
+import coffee.liz.rookery.srs.Grade
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import org.jetbrains.compose.resources.decodeToImageBitmap
+import kotlin.random.Random
+
+enum class BldMode(val label: String) {
+ P2W("Pair → word"), W2P("Word → pair"), TARGETS("Targets"), MEMO("Memo"), SETUP("Setup")
+}
+
+@Composable
+fun BldScreen(m: AppModel) {
+ val mode = m.bldMode
+ Column(Modifier.fillMaxSize().padding(8.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Tabs(BldMode.entries, mode, { it.label }, { m.bldMode = it })
+ Spacer(Modifier.weight(1f))
+ val b = m.bld
+ if (b.sessionTotal > 0) Label("this session: ${b.sessionRight}/${b.sessionTotal}", style = Rk.t.label, color = Rk.c.inkDim)
+ }
+ Box(Modifier.weight(1f).fillMaxWidth().background(Rk.c.panel).bevel(Rk.c).padding(10.dp)) {
+ when (mode) {
+ BldMode.P2W -> PairToWord(m)
+ BldMode.W2P -> WordToPair(m)
+ BldMode.TARGETS -> Targets(m)
+ BldMode.MEMO -> MemoTrainer(m)
+ BldMode.SETUP -> BldSetupScreen(m)
+ }
+ }
+ }
+}
+
+@Composable
+private fun pairImage(m: AppModel, blob: String?): ImageBitmap? =
+ remember(blob) { blob?.let { runCatching { m.blob(it)?.decodeToImageBitmap() }.getOrNull() } }
+
+/** A focus-holding box that routes keys to [onKey]; requests focus whenever [key] changes. */
+@Composable
+private fun KeyBox(key: Any?, onKey: (Key) -> Boolean, modifier: Modifier = Modifier, content: @Composable () -> Unit) {
+ val focus = remember { FocusRequester() }
+ LaunchedEffect(key) { runCatching { focus.requestFocus() } }
+ Box(
+ modifier.focusRequester(focus).focusable()
+ .onPreviewKeyEvent { if (it.type == KeyEventType.KeyDown) onKey(it.key) else false },
+ ) { content() }
+}
+
+// ---------------------------------------------------------------- pair → word
+
+@Composable
+private fun PairToWord(m: AppModel) {
+ val bld = m.bld
+ val pairs = m.derived.pairs.filterValues { it.primary != null }
+ val keys = remember(pairs.keys) { pairs.keys.sorted() }
+ if (keys.isEmpty()) { Label("No letter pairs with words yet. Import some, or add words in Letter pairs."); return }
+
+ val batchSize = 3
+ val recent = remember { mutableStateListOf<String>() }
+ fun newBatch(): List<String> {
+ val out = mutableListOf<String>()
+ repeat(minOf(batchSize, keys.size)) {
+ bld.pick(keys.map { "p2w:$it" }, recent + out.map { "p2w:$it" })?.removePrefix("p2w:")?.let { out += it }
+ }
+ return out
+ }
+ var batch by remember { mutableStateOf(newBatch()) }
+ var started by remember(batch) { mutableLongStateOf(monoMillis()) }
+ var revealedAfter by remember(batch) { mutableStateOf<Long?>(null) }
+ val missed = remember(batch) { mutableStateListOf<String>() }
+
+ fun reveal() { if (revealedAfter == null) revealedAfter = monoMillis() - started }
+ fun next() {
+ val ms = (revealedAfter ?: (monoMillis() - started)) / batch.size
+ for (p in batch) { val ok = p !in missed; bld.record("p2w:$p", ok, ms); bld.tally(ok) }
+ recent += batch.map { "p2w:$it" }
+ batch = newBatch()
+ }
+
+ KeyBox(batch, { k ->
+ when {
+ revealedAfter == null && (k == Key.Spacebar || k == Key.Enter) -> { reveal(); true }
+ revealedAfter != null && k == Key.Enter -> { next(); true }
+ revealedAfter != null && k in listOf(Key.One, Key.Two, Key.Three) -> {
+ val p = batch.getOrNull(listOf(Key.One, Key.Two, Key.Three).indexOf(k)) ?: return@KeyBox true
+ if (p in missed) missed.remove(p) else missed.add(p); true
+ }
+ else -> false
+ }
+ }, Modifier.fillMaxSize()) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxSize()) {
+ Label(
+ if (revealedAfter == null) "Say each word to yourself, then press Space."
+ else "Mark any you blanked on (1–3 or click), then Enter. ${formatTime(revealedAfter)} for ${batch.size}.",
+ color = Rk.c.inkDim,
+ )
+ Spacer(Modifier.height(12.dp))
+ Row(horizontalArrangement = Arrangement.spacedBy(14.dp), modifier = Modifier.weight(1f)) {
+ for ((i, p) in batch.withIndex()) {
+ val lp = pairs[p]
+ val miss = p in missed
+ Panel(
+ Modifier.width(260.dp).fillMaxSize()
+ .clickable(enabled = revealedAfter != null) { if (miss) missed.remove(p) else missed.add(p) },
+ sunken = true,
+ ) {
+ Label("${i + 1}", style = Rk.t.label, color = Rk.c.inkDim)
+ BasicText(p, Modifier.fillMaxWidth(), style = Rk.t.mono.copy(fontSize = 84.sp, color = Rk.c.ink, textAlign = TextAlign.Center))
+ if (revealedAfter != null && lp != null) {
+ val img = pairImage(m, lp.image)
+ Box(Modifier.fillMaxWidth().height(150.dp), Alignment.Center) {
+ if (img != null) Image(img, lp.primary, Modifier.fillMaxSize(), contentScale = ContentScale.Fit)
+ }
+ BasicText(lp.primary.orEmpty(), Modifier.fillMaxWidth(), style = Rk.t.mono.copy(fontSize = 34.sp, color = if (miss) Rk.c.bad else Rk.c.ink, textAlign = TextAlign.Center))
+ val others = lp.words - setOfNotNull(lp.primary)
+ if (others.isNotEmpty()) Label(others.sorted().joinToString(" · "), Modifier.fillMaxWidth(), color = Rk.c.inkDim, style = Rk.t.body.copy(textAlign = TextAlign.Center))
+ lp.note?.let { Label(it, Modifier.fillMaxWidth(), color = Rk.c.inkDim, style = Rk.t.body.copy(textAlign = TextAlign.Center)) }
+ Spacer(Modifier.height(6.dp))
+ Label(if (miss) "✗ blanked" else "✓ got it", color = if (miss) Rk.c.bad else Rk.c.good, style = Rk.t.heading)
+ }
+ }
+ }
+ }
+ Spacer(Modifier.height(8.dp))
+ Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
+ if (revealedAfter == null) RetroButton("Reveal (space)", { reveal() }) else RetroButton("Next (enter)", { next() })
+ }
+ }
+ }
+}
+
+// ---------------------------------------------------------------- word → pair
+
+@Composable
+private fun WordToPair(m: AppModel) {
+ val bld = m.bld
+ val pairs = m.derived.pairs.filterValues { it.primary != null }
+ val keys = remember(pairs.keys) { pairs.keys.sorted() }
+ if (keys.isEmpty()) { Label("No letter pairs with words yet."); return }
+ val scope = rememberCoroutineScope()
+ val recent = remember { mutableStateListOf<String>() }
+ var current by remember { mutableStateOf(bld.pick(keys.map { "w2p:$it" }, emptyList())!!.removePrefix("w2p:")) }
+ var typed by remember(current) { mutableStateOf("") }
+ var started by remember(current) { mutableLongStateOf(monoMillis()) }
+ var verdict by remember(current) { mutableStateOf<Pair<Boolean, Grade>?>(null) }
+ val lp = pairs[current]
+ val word = lp?.primary.orEmpty()
+
+ fun advance() {
+ recent += "w2p:$current"
+ current = bld.pick(keys.map { "w2p:$it" }, recent)!!.removePrefix("w2p:")
+ }
+ fun answer(guess: String?) {
+ if (verdict != null) return
+ // Any pair whose main word is this word counts (duplicate words happen).
+ val ok = guess != null && (guess == current || pairs[guess]?.primary?.equals(word, ignoreCase = true) == true)
+ val ms = monoMillis() - started
+ verdict = ok to bld.record("w2p:$current", ok, ms, goodMs = 2_000)
+ bld.tally(ok)
+ scope.launch { delay(if (ok) 600 else 1_600); advance() }
+ }
+
+ val img = pairImage(m, lp?.image)
+ val focus = remember { FocusRequester() }
+ LaunchedEffect(current) { runCatching { focus.requestFocus() } }
+ Column(Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) {
+ Label("Type the pair. Tab to give up and see it.", color = Rk.c.inkDim)
+ Spacer(Modifier.height(10.dp))
+ Box(Modifier.size(320.dp, 240.dp).background(Rk.c.field).bevel(Rk.c, sunken = true), Alignment.Center) {
+ if (img != null) Image(img, word, Modifier.fillMaxSize().padding(4.dp), contentScale = ContentScale.Fit)
+ else Label("(no image)", color = Rk.c.inkDim, style = Rk.t.label)
+ }
+ BasicText(word, Modifier.padding(8.dp), style = Rk.t.mono.copy(fontSize = 56.sp, color = Rk.c.ink, textAlign = TextAlign.Center))
+ RetroField(
+ typed,
+ { v ->
+ val clean = v.uppercase().filter { it in LETTERS }.take(2)
+ typed = clean
+ if (clean.length == 2) answer(clean)
+ },
+ Modifier.width(140.dp).focusRequester(focus).onPreviewKeyEvent {
+ if (it.type == KeyEventType.KeyDown && it.key == Key.Tab) { answer(null); true } else false
+ },
+ placeholder = "??", style = Rk.t.mono.copy(fontSize = 48.sp, textAlign = TextAlign.Center), description = "pair for $word",
+ )
+ Spacer(Modifier.height(10.dp))
+ verdict?.let { (ok, grade) ->
+ Label(
+ if (ok) "✓ $current (${grade.name.lowercase()})" else "✗ it's $current",
+ Modifier.semantics { liveRegion = LiveRegionMode.Polite },
+ color = if (ok) Rk.c.good else Rk.c.bad, style = Rk.t.heading.copy(fontSize = 22.sp),
+ )
+ }
+ }
+}
+
+// ---------------------------------------------------------------- targets
+
+private fun randomScramble(rng: Random = Random.Default): String {
+ val faces = "URFDLB"; val sfx = listOf("", "'", "2")
+ val out = mutableListOf<String>()
+ var last = -1
+ while (out.size < 25) {
+ val f = rng.nextInt(6)
+ if (f == last) continue
+ last = f; out += "${faces[f]}${sfx[rng.nextInt(3)]}"
+ }
+ return out.joinToString(" ")
+}
+
+@Composable
+private fun Targets(m: AppModel) {
+ val bld = m.bld
+ val setup = bld.setup
+ val tracer = remember(setup) { Tracer(setup) }
+ val mapping = remember { CubeMapping(Puzzles.cube(3), 3) }
+ val colors = remember { Puzzles.colors(Puzzle.CUBE3).map(::rgb) }
+ var find by remember { mutableStateOf(false) }
+ var types by remember { mutableStateOf(setOf(PieceType.CORNER, PieceType.EDGE)) }
+ val recent = remember { mutableStateListOf<String>() }
+ val scope = rememberCoroutineScope()
+ val camera = remember { Camera() }
+
+ data class Question(val scramble: String, val state: IntArray, val type: PieceType, val home: Int, val where: Int)
+ fun newQuestion(): Question {
+ val items = types.flatMap { t -> bld.memoPositions(t).map { "${if (find) "find" else "name"}:${if (t == PieceType.CORNER) "c" else "e"}:$it" } }
+ val item = bld.pick(items, recent)!!
+ val parts = item.split(':')
+ val type = if (parts[1] == "c") PieceType.CORNER else PieceType.EDGE
+ val home = parts[2].toInt()
+ val scr = randomScramble()
+ val state = setup.stateOf(scr)
+ val where = tracer.homes(state, type).indexOf(home)
+ return Question(scr, state, type, home, where)
+ }
+ var q by remember(find, types, setup) { mutableStateOf(newQuestion()) }
+ var started by remember(q) { mutableLongStateOf(monoMillis()) }
+ var verdict by remember(q) { mutableStateOf<Boolean?>(null) }
+ var typed by remember(q) { mutableStateOf("") }
+ val item = "${if (find) "find" else "name"}:${if (q.type == PieceType.CORNER) "c" else "e"}:${q.home}"
+ val whereSlot = Cube3.slotOf(q.type, q.where)
+ LaunchedEffect(q) {
+ if (!find) cameraFacing(whereSlot / 9).let { (y, p) -> camera.yaw = y; camera.pitch = p }
+ }
+
+ fun answer(ok: Boolean) {
+ if (verdict != null) return
+ verdict = ok
+ bld.record(item, ok, monoMillis() - started, goodMs = 2_000)
+ bld.tally(ok)
+ recent += item
+ scope.launch { delay(if (ok) 500 else 1_800); q = newQuestion() }
+ }
+ val letter = setup.scheme.letter(q.type, q.home)
+ val kind = if (q.type == PieceType.CORNER) "corner" else "edge"
+
+ Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
+ Column(Modifier.weight(1f)) {
+ PuzzleView(
+ mapping.state(q.state), colors, Modifier.fillMaxWidth().weight(1f), camera,
+ description = if (find) "scrambled cube: click where the $kind sticker $letter is" else "scrambled cube with one $kind sticker outlined",
+ highlight = if (!find || verdict == false) mapping.stickerOfSlot(whereSlot) else null,
+ highlightColor = if (find && verdict == false) Rk.c.bad else Color(0xFFFF2BD6),
+ onStickerTap = if (find) { i ->
+ val slot = mapping.slotOf[i]
+ val pos = Cube3.positionOf(q.type, slot)
+ // clicking the wrong piece type just doesn't count
+ if (pos != null || Cube3.positionOf(if (q.type == PieceType.CORNER) PieceType.EDGE else PieceType.CORNER, slot) != null) answer(slot == whereSlot)
+ } else null,
+ )
+ Label("Drag to turn the cube. Shown as you hold it (your orientation from Setup).", color = Rk.c.inkDim, style = Rk.t.label)
+ }
+ Column(Modifier.width(320.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ GroupBox("Drill") {
+ Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
+ RetroButton("Name it", { find = false }, selected = !find, description = "see a sticker, type its letter")
+ RetroButton("Find it", { find = true }, selected = find, description = "see a letter, click the sticker")
+ }
+ Spacer(Modifier.height(6.dp))
+ Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
+ for (t in PieceType.entries) RetroButton(
+ t.name.lowercase() + "s",
+ { types = if (t in types && types.size > 1) types - t else types + t },
+ selected = t in types,
+ )
+ }
+ }
+ GroupBox(if (find) "Find this $kind" else "What letter is the outlined $kind sticker?") {
+ if (find) {
+ BasicText("$letter", Modifier.fillMaxWidth(), style = Rk.t.mono.copy(fontSize = 96.sp, color = Rk.c.ink, textAlign = TextAlign.Center))
+ Label("click where that sticker is now", Modifier.fillMaxWidth(), color = Rk.c.inkDim, style = Rk.t.body.copy(textAlign = TextAlign.Center))
+ } else {
+ val focus = remember { FocusRequester() }
+ LaunchedEffect(q) { runCatching { focus.requestFocus() } }
+ RetroField(
+ typed,
+ { v ->
+ val c = v.uppercase().filter { it.isLetter() }.take(1)
+ typed = c
+ if (c.isNotEmpty()) answer(setup.scheme.position(q.type, c[0]) == q.home)
+ },
+ Modifier.fillMaxWidth().focusRequester(focus),
+ placeholder = "?", style = Rk.t.mono.copy(fontSize = 64.sp, textAlign = TextAlign.Center), description = "letter for the outlined sticker",
+ )
+ }
+ Spacer(Modifier.height(8.dp))
+ verdict?.let { ok ->
+ Label(
+ if (ok) "✓ $letter — ${Cube3.stickerName(q.type, q.home)}" else "✗ that's $letter — ${Cube3.stickerName(q.type, q.home)}",
+ Modifier.semantics { liveRegion = LiveRegionMode.Polite },
+ color = if (ok) Rk.c.good else Rk.c.bad, style = Rk.t.heading,
+ )
+ }
+ }
+ Label("Weak letters come up more often; every answer is saved and synced.", color = Rk.c.inkDim, style = Rk.t.label)
+ }
+ }
+}
+
+// ---------------------------------------------------------------- memo
+
+private enum class MemoPhase { LOADING, MEMO, RECALL, RESULT }
+
+private data class MemoResult(
+ val memoMs: Long,
+ val ok: Map<PieceType, Boolean>,
+ val wrong: Map<PieceType, List<String>>,
+ val yours: Map<PieceType, String>,
+ val traced: Map<PieceType, String>,
+ val errors: List<String>,
+ val parity: Boolean,
+)
+
+@Composable
+private fun MemoTrainer(m: AppModel) {
+ val bld = m.bld
+ val setup = bld.setup
+ val tracer = remember(setup) { Tracer(setup) }
+ val mapping = remember { CubeMapping(Puzzles.cube(3), 3) }
+ val colors = remember { Puzzles.colors(Puzzle.CUBE3).map(::rgb) }
+ val camera = remember { Camera() }
+ var round by remember { mutableStateOf(0) }
+ var scramble by remember(round) { mutableStateOf<String?>(null) }
+ var phase by remember(round) { mutableStateOf(MemoPhase.LOADING) }
+ var memoStart by remember(round) { mutableLongStateOf(0) }
+ var memoMs by remember(round) { mutableLongStateOf(0) }
+ var edges by remember(round) { mutableStateOf("") }
+ var corners by remember(round) { mutableStateOf("") }
+ var result by remember(round) { mutableStateOf<MemoResult?>(null) }
+ var now by remember { mutableLongStateOf(monoMillis()) }
+
+ LaunchedEffect(round) {
+ scramble = m.freshScramble(Event.E333BF)
+ phase = MemoPhase.MEMO
+ memoStart = monoMillis()
+ }
+ LaunchedEffect(phase) { while (phase == MemoPhase.MEMO) { now = monoMillis(); delay(50) } }
+ val state = remember(scramble, setup) { scramble?.let { setup.stateOf(it) } }
+
+ fun submit() {
+ val s = state ?: return
+ val (e, eErr) = tracer.parse(edges, PieceType.EDGE)
+ val (c, cErr) = tracer.parse(corners, PieceType.CORNER)
+ val after = tracer.execute(s, c + e)
+ val traced = PieceType.entries.associateWith { tracer.letters(tracer.trace(s, it)) }
+ val r = MemoResult(
+ memoMs,
+ PieceType.entries.associateWith { tracer.isSolved(after, it) },
+ PieceType.entries.associateWith { tracer.wrongPieces(after, it) },
+ mapOf(PieceType.EDGE to tracer.letters(e), PieceType.CORNER to tracer.letters(c)),
+ traced,
+ eErr + cErr,
+ tracer.hasParity(tracer.trace(s, PieceType.CORNER)),
+ )
+ result = r
+ phase = MemoPhase.RESULT
+ val letters = traced.values.sumOf { it.length }
+ val pairsTotal = (letters + 1) / 2
+ m.repo.addMemoSession(
+ MemoSession(epochMillis(), pairsTotal, memoMs, if (r.ok.values.all { it }) pairsTotal else 0),
+ mapOf("scramble" to (scramble ?: ""), "edges" to r.yours.getValue(PieceType.EDGE), "corners" to r.yours.getValue(PieceType.CORNER)),
+ )
+ bld.tally(r.ok.values.all { it })
+ }
+
+ Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
+ Column(Modifier.weight(1f)) {
+ when {
+ state == null -> Box(Modifier.fillMaxSize(), Alignment.Center) { Label("scrambling…", color = Rk.c.inkDim) }
+ phase == MemoPhase.RECALL -> Box(Modifier.fillMaxSize().background(Rk.c.field).bevel(Rk.c, sunken = true), Alignment.Center) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Penguin(PenguinAnim.SLEEP, 96.dp, m.reducedMotion)
+ Label("cube hidden — blindfold on", color = Rk.c.inkDim)
+ }
+ }
+ else -> {
+ PuzzleView(mapping.state(state), colors, Modifier.fillMaxWidth().weight(1f), camera, description = "scrambled cube to memorise, drag to turn")
+ Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
+ Label("look at:", style = Rk.t.label)
+ for ((i, f) in "URFDLB".withIndex()) RetroButton("$f", { cameraFacing(i).let { (y, p) -> camera.yaw = y; camera.pitch = p } }, description = "turn the view to the $f face")
+ }
+ }
+ }
+ }
+ Column(Modifier.width(380.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ GroupBox("Memo") {
+ Label(scramble ?: "…", style = Rk.t.mono.copy(fontSize = 16.sp), color = Rk.c.inkDim)
+ Spacer(Modifier.height(6.dp))
+ val shown = when (phase) { MemoPhase.MEMO -> now - memoStart; else -> memoMs }
+ BasicText(formatTime(shown), style = Rk.t.mono.copy(fontSize = 48.sp, color = Rk.c.ink))
+ when (phase) {
+ MemoPhase.LOADING -> {}
+ MemoPhase.MEMO -> RetroButton("Done memorising", { memoMs = monoMillis() - memoStart; phase = MemoPhase.RECALL })
+ MemoPhase.RECALL -> {
+ val focus = remember { FocusRequester() }
+ LaunchedEffect(Unit) { runCatching { focus.requestFocus() } }
+ Label("Edges", style = Rk.t.label)
+ RetroField(edges, { edges = it }, Modifier.fillMaxWidth().focusRequester(focus), password = true, style = Rk.t.mono.copy(fontSize = 28.sp), description = "edge memo")
+ Label("Corners", style = Rk.t.label)
+ RetroField(corners, { corners = it }, Modifier.fillMaxWidth(), password = true, style = Rk.t.mono.copy(fontSize = 28.sp), onSubmit = { submit() }, description = "corner memo")
+ Spacer(Modifier.height(6.dp))
+ RetroButton("Check", { submit() })
+ }
+ MemoPhase.RESULT -> RetroButton("Next cube", { round++ })
+ }
+ }
+ result?.let { r -> MemoResultBox(r, setup) }
+ }
+ }
+}
+
+@Composable
+private fun MemoResultBox(r: MemoResult, setup: BldSetup) {
+ val allOk = r.ok.values.all { it }
+ GroupBox(if (allOk) "Solved!" else "Not quite") {
+ if (allOk) Row(verticalAlignment = Alignment.CenterVertically) {
+ Penguin(PenguinAnim.ALERT, 48.dp)
+ Label("Executed your memo and the cube came out solved.", color = Rk.c.good)
+ }
+ for (t in listOf(PieceType.EDGE, PieceType.CORNER)) {
+ val name = if (t == PieceType.EDGE) "Edges" else "Corners"
+ Spacer(Modifier.height(6.dp))
+ Label("$name ${if (r.ok.getValue(t)) "✓" else "✗"}", style = Rk.t.heading, color = if (r.ok.getValue(t)) Rk.c.good else Rk.c.bad)
+ Label("you: ${r.yours.getValue(t).chunked(2).joinToString(" ").ifEmpty { "—" }}", style = Rk.t.mono)
+ Label("traced: ${r.traced.getValue(t).chunked(2).joinToString(" ").ifEmpty { "—" }}", style = Rk.t.mono, color = Rk.c.inkDim)
+ val wrong = r.wrong.getValue(t)
+ if (wrong.isNotEmpty()) Label("left wrong: ${wrong.joinToString(", ")}", color = Rk.c.bad)
+ }
+ if (r.errors.isNotEmpty()) { Spacer(Modifier.height(6.dp)); for (e in r.errors) Label("! $e", color = Rk.c.gold) }
+ Spacer(Modifier.height(6.dp))
+ Label(
+ (if (r.parity) "Parity: yes (odd targets)." else "Parity: no.") +
+ " Traced memo is one valid answer; any memo that solves counts.",
+ color = Rk.c.inkDim, style = Rk.t.label,
+ )
+ }
+}
+
+// ---------------------------------------------------------------- setup
+
+@Composable
+private fun BldSetupScreen(m: AppModel) {
+ val bld = m.bld
+ val setup = bld.setup
+ var editing by remember { mutableStateOf<Pair<PieceType, Int>?>(null) }
+ Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
+ Column(Modifier.weight(1f).verticalScroll(rememberScrollState())) {
+ GroupBox("Lettering") {
+ Label("Click a sticker, then type its letter. Letters swap if already used. Shown in your solving orientation.", color = Rk.c.inkDim)
+ Spacer(Modifier.height(8.dp))
+ LetterNet(setup, editing) { editing = it }
+ Spacer(Modifier.height(8.dp))
+ editing?.let { (type, pos) ->
+ var v by remember(editing) { mutableStateOf("") }
+ val focus = remember { FocusRequester() }
+ LaunchedEffect(editing) { runCatching { focus.requestFocus() } }
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Label("${Cube3.stickerName(type, pos)} (${type.name.lowercase()}) is ${setup.scheme.letter(type, pos)} → ", style = Rk.t.body)
+ RetroField(v, { nv ->
+ val c = nv.uppercase().filter { it.isLetter() }.take(1)
+ v = c
+ if (c.isNotEmpty()) { bld.updateSetup(setup.copy(scheme = assign(setup.scheme, type, pos, c[0]))); editing = null }
+ }, Modifier.width(60.dp).focusRequester(focus), placeholder = "?", style = Rk.t.mono.copy(fontSize = 24.sp))
+ }
+ }
+ Spacer(Modifier.height(6.dp))
+ RetroButton("Reset to Speffz", { bld.updateSetup(setup.copy(scheme = LetterScheme())) })
+ }
+ }
+ Column(Modifier.width(380.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ GroupBox("Orientation") {
+ val presets = listOf("yellow top, orange front" to "x2 y'", "white top, green front (WCA)" to "", "yellow top, green front" to "z2", "white top, orange front" to "y'")
+ for ((label, rot) in presets) RetroButton(label, { bld.updateSetup(setup.copy(rotation = rot)) }, Modifier.fillMaxWidth().padding(bottom = 3.dp), selected = setup.rotation == rot)
+ Label("rotation from scrambling orientation: ${setup.rotation.ifEmpty { "none" }}", color = Rk.c.inkDim, style = Rk.t.label)
+ }
+ GroupBox("Buffers") {
+ for (t in listOf(PieceType.CORNER, PieceType.EDGE)) {
+ val buf = setup.buffer(t)
+ Label("${if (t == PieceType.CORNER) "Corner" else "Edge"} buffer: ${setup.scheme.letter(t, buf)} (${Cube3.stickerName(t, buf)})", style = Rk.t.body)
+ Row(horizontalArrangement = Arrangement.spacedBy(3.dp), modifier = Modifier.padding(bottom = 6.dp)) {
+ val options = if (t == PieceType.CORNER) listOf(0, 2, 1, 3) else listOf(1, 2, 0, 3)
+ for (p in options) RetroButton(
+ Cube3.pieceName(t, p), { bld.updateSetup(if (t == PieceType.CORNER) setup.copy(cornerBuffer = p) else setup.copy(edgeBuffer = p)) },
+ selected = Cube3.pieceId(t, p) == Cube3.pieceId(t, buf),
+ )
+ }
+ }
+ Label("Method: Old Pochmann corners (helper RDF/P) and OP edges (T/Ja/Jb), R-perm parity. M2 support comes with the solve analyser.", color = Rk.c.inkDim, style = Rk.t.label)
+ }
+ }
+ }
+}
+
+private fun assign(s: LetterScheme, type: PieceType, pos: Int, letter: Char): LetterScheme {
+ val str = (if (type == PieceType.CORNER) s.corners else s.edges).toCharArray()
+ val old = str[pos]
+ val clash = str.indexOf(letter)
+ if (clash >= 0) str[clash] = old
+ str[pos] = letter
+ return if (type == PieceType.CORNER) s.copy(corners = str.concatToString()) else s.copy(edges = str.concatToString())
+}
+
+/** Flat net (U on top; L F R B; D below) showing every sticker's letter, coloured as you hold the cube. */
+@Composable
+private fun LetterNet(setup: BldSetup, editing: Pair<PieceType, Int>?, onPick: (Pair<PieceType, Int>) -> Unit) {
+ val colors = remember { Puzzles.colors(Puzzle.CUBE3).map(::rgb) }
+ val solved = setup.solvedColors
+ val cell = 38.dp
+ // face → (column, row) on the net, in faces U R F D L B
+ val at = listOf(1 to 0, 2 to 1, 1 to 1, 1 to 2, 0 to 1, 3 to 1)
+ Column {
+ for (netRow in 0 until 9) Row {
+ for (netCol in 0 until 12) {
+ val face = at.indexOfFirst { (c, r) -> c == netCol / 3 && r == netRow / 3 }
+ if (face < 0) { Spacer(Modifier.size(cell)); continue }
+ val slot = face * 9 + (netRow % 3) * 3 + netCol % 3
+ val target = Cube3.positionOf(PieceType.CORNER, slot)?.let { PieceType.CORNER to it }
+ ?: Cube3.positionOf(PieceType.EDGE, slot)?.let { PieceType.EDGE to it }
+ val color = colors[solved[slot]]
+ val sel = target != null && target == editing
+ val isBuffer = target != null && Cube3.pieceId(target.first, target.second) == Cube3.pieceId(target.first, setup.buffer(target.first))
+ Box(
+ Modifier.size(cell).padding(1.dp).background(Color(0xFF14181C)).padding(2.dp).background(color)
+ .then(if (sel) Modifier.outline(Color(0xFFFF2BD6)) else Modifier)
+ .then(if (target != null) Modifier.clickable { onPick(target) } else Modifier)
+ .semantics { if (target != null) contentDescription = "${Cube3.stickerName(target.first, target.second)} ${setup.scheme.letter(target.first, target.second)}" },
+ Alignment.Center,
+ ) {
+ if (target != null) BasicText(
+ "${setup.scheme.letter(target.first, target.second)}",
+ style = Rk.t.mono.copy(fontSize = 22.sp, color = if (isBuffer) Color(0xFF888888) else Color.Black),
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/PuzzleView.kt b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/PuzzleView.kt
index 41ea00f..f2a8b67 100644
--- a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/PuzzleView.kt
+++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/PuzzleView.kt
@@ -2,6 +2,7 @@ package coffee.liz.rookery.ui
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.detectDragGestures
+import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
@@ -46,11 +47,23 @@ fun PuzzleView(
camera: Camera = remember { Camera() },
anim: TurnAnim? = null,
description: String = "3D puzzle view",
+ /** Sticker index to outline, e.g. "what letter is this?" */
+ highlight: Int? = null,
+ highlightColor: Color = Color(0xFFFF2BD6),
+ /** Called with the front-most sticker under a tap/click. */
+ onStickerTap: ((Int) -> Unit)? = null,
) {
val puzzle = state.puzzle
val body = Color(0xFF14181C)
+ // Screen polygons from the last frame, front-most last, for hit testing taps.
+ val hits = remember { ArrayList<Pair<Int, List<Offset>>>() }
Canvas(
modifier
+ .then(if (onStickerTap == null) Modifier else Modifier.pointerInput(onStickerTap) {
+ detectTapGestures { pos ->
+ hits.lastOrNull { (_, poly) -> inside(pos, poly) }?.let { onStickerTap(it.first) }
+ }
+ })
.pointerInput(camera) {
detectDragGestures { change, drag ->
change.consume()
@@ -86,7 +99,7 @@ fun PuzzleView(
Triple(a.turn, axis.normal, angle)
}
- class Drawn(val tile: Polygon, val color: Color, val depth: Double, val shade: Float)
+ class Drawn(val index: Int, val tile: Polygon, val color: Color, val depth: Double, val shade: Float)
val drawn = ArrayList<Drawn>(puzzle.stickers.size)
for ((i, s) in puzzle.stickers.withIndex()) {
var poly = s
@@ -98,19 +111,49 @@ fun PuzzleView(
val n = v.normal
if ((n dot toCam) <= 0) continue // back face
val lambert = maxOf(0.0, n dot light).toFloat()
- drawn += Drawn(v, faceColors[state.colors[i] % faceColors.size], v.centroid.z, 0.62f + 0.38f * lambert)
+ drawn += Drawn(i, v, faceColors[state.colors[i] % faceColors.size], v.centroid.z, 0.62f + 0.38f * lambert)
}
drawn.sortBy { it.depth }
+ hits.clear()
for (d in drawn) {
- fill(d.tile.points.map(::project), body)
+ val tile = d.tile.points.map(::project)
+ fill(tile, body)
val sticker = d.tile.inset(0.86).points.map(::project)
fill(sticker, d.color.shaded(d.shade))
// a thin highlight on the sticker's upper edge for a slightly glossy, skeuomorphic feel
drawPath(path(sticker), Color.White.copy(alpha = 0.10f * d.shade), style = Stroke(1f))
+ hits += d.index to tile
+ }
+ if (highlight != null) drawn.firstOrNull { it.index == highlight }?.let { d ->
+ val ring = d.tile.inset(0.97).points.map(::project)
+ drawPath(path(ring), Color.Black, style = Stroke(7f))
+ drawPath(path(ring), highlightColor, style = Stroke(4f))
}
}
}
+/** Ray-casting point-in-polygon test. */
+private fun inside(p: Offset, poly: List<Offset>): Boolean {
+ var c = false
+ var j = poly.size - 1
+ for (i in poly.indices) {
+ val a = poly[i]; val b = poly[j]
+ if ((a.y > p.y) != (b.y > p.y) && p.x < (b.x - a.x) * (p.y - a.y) / (b.y - a.y) + a.x) c = !c
+ j = i
+ }
+ return c
+}
+
+/** Camera angles that put a face of a cube (U R F D L B) in view, tilted a little for context. */
+fun cameraFacing(face: Int): Pair<Float, Float> = when (face) {
+ 0 -> -30f to 62f
+ 1 -> -68f to 22f
+ 2 -> -22f to 22f
+ 3 -> -30f to -62f
+ 4 -> 68f to 22f
+ else -> 158f to 22f
+}
+
private fun path(pts: List<Offset>) = Path().apply {
moveTo(pts[0].x, pts[0].y)
for (k in 1 until pts.size) lineTo(pts[k].x, pts[k].y)
diff --git a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Retro.kt b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Retro.kt
index 2fe6be5..8fb3a18 100644
--- a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Retro.kt
+++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Retro.kt
@@ -164,6 +164,7 @@ fun RetroField(
singleLine: Boolean = true,
onSubmit: (() -> Unit)? = null,
description: String? = null,
+ password: Boolean = false,
) {
val p = Rk.c
val st = (style ?: Rk.t.body).copy(color = p.ink)
@@ -172,6 +173,7 @@ fun RetroField(
modifier.background(p.field).bevel(p, sunken = true).padding(horizontal = 8.dp, vertical = 6.dp)
.semantics { if (description != null) contentDescription = description },
textStyle = st, singleLine = singleLine, cursorBrush = SolidColor(p.ink),
+ visualTransformation = if (password) androidx.compose.ui.text.input.PasswordVisualTransformation('•') else androidx.compose.ui.text.input.VisualTransformation.None,
keyboardOptions = KeyboardOptions(imeAction = if (onSubmit != null) ImeAction.Done else ImeAction.Default),
keyboardActions = KeyboardActions(onDone = { onSubmit?.invoke() }),
decorationBox = { inner ->