diff options
Diffstat (limited to 'ui')
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 -> |
