tawasuyu

Nonet: nuevo módulo — Sudoku con 7 variantes, dificultad y persistencia

Familia de "región extra" sobre un único motor (genera + soluciona con solución
única, verificado para las 7 × 4 dificultades): Clásico, X, Windoku, grupos
disjuntos, punto central, asterisco y girándola. Generación determinista por
(variante, dificultad, índice); persiste cuántos se generaron, el avance de
cada uno (rejilla + notas) y los completados. Dinámica "elige número → toca
celdas", con teclado deshacer/rehacer/comprobar, 3×3 y edit(borrador)/borrar/pista.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

b8eadeacd36e54799d6e7fd8ecf42ea973c8999a · por Sergio · (hace 4 meses) · padre f37d04fbc · ver el árbol en este commit

Delta semántico

Ninguna definición cambió: el cambio es de texto, documentación o datos.

17 archivos · +1309 −0

diff --git a/nonet/build.gradle.kts b/nonet/build.gradle.kts
new file mode 100644
index 0000000..b9679ab
--- /dev/null
+++ b/nonet/build.gradle.kts
@@ -0,0 +1,50 @@
+plugins {
+    alias(libs.plugins.android.application)
+    alias(libs.plugins.kotlin.android)
+    alias(libs.plugins.kotlin.compose)
+}
+
+android {
+    namespace = "com.rizoma.nonet"
+    compileSdk = 36
+
+    defaultConfig {
+        applicationId = "com.rizoma.nonet"
+        minSdk = 26
+        targetSdk = 36
+        versionCode = 1
+        versionName = "0.1.0"
+    }
+
+    buildTypes {
+        release {
+            isMinifyEnabled = false
+        }
+    }
+    compileOptions {
+        sourceCompatibility = JavaVersion.VERSION_17
+        targetCompatibility = JavaVersion.VERSION_17
+    }
+    kotlinOptions {
+        jvmTarget = "17"
+    }
+    buildFeatures {
+        compose = true
+    }
+}
+
+dependencies {
+    implementation(libs.androidx.core.ktx)
+    implementation(libs.androidx.lifecycle.runtime.ktx)
+    implementation(libs.androidx.lifecycle.viewmodel.compose)
+    implementation(libs.androidx.activity.compose)
+    implementation(platform(libs.androidx.compose.bom))
+    implementation(libs.androidx.compose.ui)
+    implementation(libs.androidx.compose.ui.graphics)
+    implementation(libs.androidx.compose.ui.util)
+    implementation(libs.androidx.compose.foundation)
+    implementation(libs.androidx.compose.animation)
+    implementation(libs.androidx.compose.material3)
+    implementation(libs.androidx.datastore.preferences)
+    implementation(libs.kotlinx.coroutines.android)
+}
diff --git a/nonet/src/main/AndroidManifest.xml b/nonet/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..0a075ab
--- /dev/null
+++ b/nonet/src/main/AndroidManifest.xml
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+
+    <!-- Juego offline: sin permisos de red. Los puzzles se generan por seed. -->
+
+    <application
+        android:label="@string/app_name"
+        android:icon="@mipmap/ic_launcher"
+        android:theme="@style/Theme.Nonet"
+        android:supportsRtl="true">
+
+        <activity
+            android:name=".MainActivity"
+            android:exported="true"
+            android:launchMode="singleTask"
+            android:configChanges="orientation|screenSize|keyboard|keyboardHidden|uiMode">
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN" />
+                <category android:name="android.intent.category.LAUNCHER" />
+            </intent-filter>
+        </activity>
+    </application>
+</manifest>
diff --git a/nonet/src/main/java/com/rizoma/nonet/MainActivity.kt b/nonet/src/main/java/com/rizoma/nonet/MainActivity.kt
new file mode 100644
index 0000000..0139f29
--- /dev/null
+++ b/nonet/src/main/java/com/rizoma/nonet/MainActivity.kt
@@ -0,0 +1,20 @@
+package com.rizoma.nonet
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.core.view.WindowCompat
+import com.rizoma.nonet.ui.NonetApp
+import com.rizoma.nonet.ui.theme.NonetTheme
+
+class MainActivity : ComponentActivity() {
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        WindowCompat.setDecorFitsSystemWindows(window, false)
+        setContent {
+            NonetTheme {
+                NonetApp()
+            }
+        }
+    }
+}
diff --git a/nonet/src/main/java/com/rizoma/nonet/NonetViewModel.kt b/nonet/src/main/java/com/rizoma/nonet/NonetViewModel.kt
new file mode 100644
index 0000000..4aaffae
--- /dev/null
+++ b/nonet/src/main/java/com/rizoma/nonet/NonetViewModel.kt
@@ -0,0 +1,254 @@
+package com.rizoma.nonet
+
+import android.app.Application
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshots.SnapshotStateList
+import androidx.compose.runtime.toMutableStateList
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import com.rizoma.nonet.data.Progreso
+import com.rizoma.nonet.game.Motor
+import com.rizoma.nonet.game.Puzzle
+import com.rizoma.nonet.model.Dificultad
+import com.rizoma.nonet.model.Variant
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+enum class Pantalla { Menu, Tablero }
+
+/**
+ * Orquesta el Sudoku: navegación del menú (variante + dificultad + lista de
+ * puzzles generados), generación determinista en segundo plano y la partida en
+ * curso, que se guarda en cada jugada para reanudarla al volver.
+ */
+class NonetViewModel(app: Application) : AndroidViewModel(app) {
+
+    private val progreso = Progreso(app)
+
+    var pantalla by mutableStateOf(Pantalla.Menu); private set
+
+    // Selección del menú.
+    var variante by mutableStateOf(Variant.CLASICO); private set
+    var dificultad by mutableStateOf(Dificultad.FACIL); private set
+
+    // Logros/avance, observados para refrescar el menú al instante.
+    var generados by mutableStateOf<Map<String, Int>>(emptyMap()); private set
+    var completados by mutableStateOf<Set<String>>(emptySet()); private set
+    var enCurso by mutableStateOf<Set<String>>(emptySet()); private set
+
+    // Partida en curso.
+    var puzzle by mutableStateOf<Puzzle?>(null); private set
+    var generando by mutableStateOf(false); private set
+    var completado by mutableStateOf(false); private set
+
+    // Dinámica "elige número → toca celdas": [activo] es el dígito 1..9 que se
+    // marca al tocar, o [BORRAR]. [celdaFoco] es la última tocada (resalte/pista).
+    var activo by mutableIntStateOf(1); private set
+    var celdaFoco by mutableIntStateOf(-1); private set
+    // "edit"/borrador: OFF marca dígitos duros; ON marca notas a lápiz (posibles).
+    var modoEdicion by mutableStateOf(false); private set
+    // Celdas marcadas en rojo por "comprobar" (valor distinto al de la solución).
+    var errores by mutableStateOf<Set<Int>>(emptySet()); private set
+
+    // Historial para deshacer/rehacer (instantáneas de rejilla + notas).
+    private val historial = ArrayDeque<Pair<IntArray, IntArray>>()
+    private val rehacer = ArrayDeque<Pair<IntArray, IntArray>>()
+    var puedeDeshacer by mutableStateOf(false); private set
+    var puedeRehacer by mutableStateOf(false); private set
+
+    /** Rejilla actual (81; 0 = vacía) y notas a lápiz (81 máscaras de bits 1..9). */
+    var entradas: SnapshotStateList<Int> = IntArray(81).toList().toMutableStateList(); private set
+    var notas: SnapshotStateList<Int> = IntArray(81).toList().toMutableStateList(); private set
+
+    init {
+        viewModelScope.launch { progreso.generados.collect { generados = it } }
+        viewModelScope.launch { progreso.completados.collect { completados = it } }
+        viewModelScope.launch { progreso.enCurso.collect { enCurso = it } }
+    }
+
+    // ---- Menú ---------------------------------------------------------
+
+    fun elegirVariante(v: Variant) { variante = v }
+    fun elegirDificultad(d: Dificultad) { dificultad = d }
+
+    /** Cuántos puzzles existen ya de la (variante, dificultad) seleccionadas. */
+    fun cuenta(v: Variant = variante, d: Dificultad = dificultad): Int =
+        generados["${v.id}:${d.ordinal}"] ?: 1
+
+    fun puzzleId(v: Variant, d: Dificultad, index: Int) = "${v.id}:${d.ordinal}:$index"
+
+    /** Genera el siguiente puzzle de la selección actual y lo abre. */
+    fun generarNuevo() {
+        viewModelScope.launch {
+            val index = progreso.generarSiguiente(variante.id, dificultad.ordinal)
+            abrir(variante, dificultad, index)
+        }
+    }
+
+    /** Abre (genera deterministamente + restaura avance) un puzzle concreto. */
+    fun abrir(v: Variant, d: Dificultad, index: Int) {
+        variante = v
+        dificultad = d
+        pantalla = Pantalla.Tablero
+        generando = true
+        activo = 1
+        celdaFoco = -1
+        completado = false
+        modoEdicion = false
+        errores = emptySet()
+        historial.clear(); rehacer.clear(); sincronizarPilas()
+        viewModelScope.launch {
+            val p = withContext(Dispatchers.Default) { Motor.generar(v, d, index) }
+            val guardadas = progreso.cargarEntradas(p.id)
+            val guardadasNotas = progreso.cargarNotas(p.id)
+            // Pistas fijas siempre; sobre ellas, el avance guardado si lo hay.
+            val grid = p.givens.copyOf()
+            if (guardadas != null) for (i in 0 until 81) if (p.givens[i] == 0) grid[i] = guardadas[i]
+            puzzle = p
+            entradas = grid.toList().toMutableStateList()
+            notas = (guardadasNotas ?: IntArray(81)).toList().toMutableStateList()
+            completado = grid.toList() == p.solution.toList()
+            generando = false
+        }
+    }
+
+    fun volverAlMenu() {
+        pantalla = Pantalla.Menu
+        puzzle = null
+        celdaFoco = -1
+    }
+
+    // ---- Juego --------------------------------------------------------
+
+    fun esFija(i: Int): Boolean = puzzle?.let { it.givens[i] != 0 } ?: true
+
+    /** Elige el dígito activo (1..9) que se marcará al tocar celdas. */
+    fun elegirActivo(d: Int) { activo = d }
+
+    /** Activa la herramienta de borrado: tocar celdas las vacía. */
+    fun activarBorrar() { activo = BORRAR }
+
+    /** Alterna "edit" (borrador): ON marca notas; OFF marca dígitos duros. */
+    fun alternarEdicion() { modoEdicion = !modoEdicion }
+
+    /** Toca una celda: aplica el activo (dígito duro, nota a lápiz o borrado). */
+    fun tocarCelda(i: Int) {
+        val p = puzzle ?: return
+        celdaFoco = i
+        if (completado || esFija(i)) return
+        when {
+            activo == BORRAR -> {
+                if (entradas[i] == 0 && notas[i] == 0) return
+                empujarHistorial()
+                entradas[i] = 0
+                notas[i] = 0
+            }
+            modoEdicion -> {
+                if (entradas[i] != 0) return        // las notas solo en celdas sin dígito duro
+                empujarHistorial()
+                notas[i] = notas[i] xor (1 shl activo)
+            }
+            else -> {
+                empujarHistorial()
+                entradas[i] = if (entradas[i] == activo) 0 else activo
+                notas[i] = 0
+            }
+        }
+        errores = emptySet()                        // cualquier cambio invalida el último "comprobar"
+        comprobarVictoria(p)
+        guardar()
+    }
+
+    fun deshacer() {
+        if (historial.isEmpty()) return
+        rehacer.addLast(estadoActual())
+        aplicar(historial.removeLast())
+        sincronizarPilas()
+        guardar()
+    }
+
+    fun rehacerJugada() {
+        if (rehacer.isEmpty()) return
+        historial.addLast(estadoActual())
+        aplicar(rehacer.removeLast())
+        sincronizarPilas()
+        guardar()
+    }
+
+    /** "Comprobar": marca las celdas cuyo valor no coincide con la solución. */
+    fun comprobar() {
+        val p = puzzle ?: return
+        errores = (0 until 81).filter { entradas[it] != 0 && entradas[it] != p.solution[it] }.toSet()
+    }
+
+    /** "Pista": rellena la celda en foco (o la primera errónea/vacía) con su valor. */
+    fun pista() {
+        val p = puzzle ?: return
+        if (completado) return
+        val i = celdaFoco.takeIf { it in 0..80 && !esFija(it) && entradas[it] != p.solution[it] }
+            ?: (0 until 81).firstOrNull { !esFija(it) && entradas[it] != p.solution[it] }
+            ?: return
+        empujarHistorial()
+        entradas[i] = p.solution[i]
+        notas[i] = 0
+        celdaFoco = i
+        errores = emptySet()
+        comprobarVictoria(p)
+        guardar()
+    }
+
+    /** Celdas en conflicto (mismo dígito que un par de su grupo). Para pintarlas. */
+    fun conflictos(): Set<Int> {
+        val p = puzzle ?: return emptySet()
+        val out = HashSet<Int>()
+        for (i in 0 until 81) {
+            val v = entradas[i]
+            if (v == 0) continue
+            for (q in p.peers[i]) if (entradas[q] == v) { out.add(i); out.add(q) }
+        }
+        return out
+    }
+
+    private fun comprobarVictoria(p: Puzzle) {
+        for (i in 0 until 81) if (entradas[i] != p.solution[i]) return
+        completado = true
+        viewModelScope.launch { progreso.marcarCompletado(p.id) }
+    }
+
+    // ---- Historial (deshacer/rehacer) --------------------------------
+
+    private fun estadoActual(): Pair<IntArray, IntArray> = entradas.toIntArray() to notas.toIntArray()
+
+    private fun empujarHistorial() {
+        historial.addLast(estadoActual())
+        if (historial.size > 200) historial.removeFirst()
+        rehacer.clear()
+        sincronizarPilas()
+    }
+
+    private fun aplicar(estado: Pair<IntArray, IntArray>) {
+        val (e, n) = estado
+        for (i in 0 until 81) { entradas[i] = e[i]; notas[i] = n[i] }
+        completado = puzzle?.let { p -> (0 until 81).all { entradas[it] == p.solution[it] } } ?: false
+    }
+
+    private fun sincronizarPilas() {
+        puedeDeshacer = historial.isNotEmpty()
+        puedeRehacer = rehacer.isNotEmpty()
+    }
+
+    private fun guardar() {
+        val p = puzzle ?: return
+        val e = entradas.toIntArray()
+        val n = notas.toIntArray()
+        viewModelScope.launch { progreso.guardar(p.id, e, n) }
+    }
+
+    companion object {
+        const val BORRAR = -1
+    }
+}
diff --git a/nonet/src/main/java/com/rizoma/nonet/data/Progreso.kt b/nonet/src/main/java/com/rizoma/nonet/data/Progreso.kt
new file mode 100644
index 0000000..c983eea
--- /dev/null
+++ b/nonet/src/main/java/com/rizoma/nonet/data/Progreso.kt
@@ -0,0 +1,100 @@
+package com.rizoma.nonet.data
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.intPreferencesKey
+import androidx.datastore.preferences.core.stringPreferencesKey
+import androidx.datastore.preferences.core.stringSetPreferencesKey
+import androidx.datastore.preferences.preferencesDataStore
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+
+private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "nonet")
+
+/**
+ * Persistencia del Sudoku. Tres cosas:
+ *  · **cuántos puzzles** se han generado por (variante, dificultad) —cada uno es
+ *    determinista por su índice, así que basta guardar la cuenta—;
+ *  · el **avance** de cada puzzle (la rejilla actual y las notas a lápiz), para
+ *    reanudarlo justo donde se dejó al cerrar;
+ *  · qué puzzles están **completados**.
+ */
+class Progreso(context: Context) {
+    private val store = context.applicationContext.dataStore
+
+    /** Cuántos puzzles existen por clave "variantId:dif" (mínimo 1: siempre hay el #0). */
+    val generados: Flow<Map<String, Int>> = store.data.map { p ->
+        buildMap {
+            p.asMap().forEach { (k, v) ->
+                if (k.name.startsWith(GEN_PREFIX) && v is Int) put(k.name.removePrefix(GEN_PREFIX), v)
+            }
+        }
+    }
+
+    /** Ids de puzzles completados. */
+    val completados: Flow<Set<String>> = store.data.map { it[COMPLETADOS] ?: emptySet() }
+
+    /** Puzzles con avance guardado sin completar (para marcarlos "en curso"). */
+    val enCurso: Flow<Set<String>> = store.data.map { p ->
+        buildSet {
+            p.asMap().forEach { (k, _) ->
+                if (k.name.startsWith(ENTRADAS_PREFIX)) add(k.name.removePrefix(ENTRADAS_PREFIX))
+            }
+        }.minus(p[COMPLETADOS] ?: emptySet())
+    }
+
+    fun cuenta(variantId: String, dif: Int): Flow<Int> =
+        generados.map { it["$variantId:$dif"] ?: 1 }
+
+    /** Crea el siguiente puzzle de (variante, dificultad) y devuelve su índice. */
+    suspend fun generarSiguiente(variantId: String, dif: Int): Int {
+        val key = intPreferencesKey(GEN_PREFIX + "$variantId:$dif")
+        var nuevo = 1
+        store.edit { p ->
+            val actual = p[key] ?: 1
+            nuevo = actual
+            p[key] = actual + 1
+        }
+        return nuevo
+    }
+
+    /** Rejilla guardada (81 dígitos, 0 = vacía) o null si no hay avance. */
+    suspend fun cargarEntradas(puzzleId: String): IntArray? {
+        val s = store.data.first()[stringPreferencesKey(ENTRADAS_PREFIX + puzzleId)] ?: return null
+        if (s.length != 81) return null
+        return IntArray(81) { s[it].digitToIntOrNull() ?: 0 }
+    }
+
+    /** Notas a lápiz por celda (81 máscaras de bits 1..9) o null. */
+    suspend fun cargarNotas(puzzleId: String): IntArray? {
+        val s = store.data.first()[stringPreferencesKey(NOTAS_PREFIX + puzzleId)] ?: return null
+        val parts = s.split(',')
+        if (parts.size != 81) return null
+        return IntArray(81) { parts[it].toIntOrNull() ?: 0 }
+    }
+
+    /** Guarda el avance (rejilla + notas) de un puzzle. */
+    suspend fun guardar(puzzleId: String, entradas: IntArray, notas: IntArray) {
+        store.edit { p ->
+            p[stringPreferencesKey(ENTRADAS_PREFIX + puzzleId)] =
+                entradas.joinToString("") { (it.coerceIn(0, 9)).toString() }
+            p[stringPreferencesKey(NOTAS_PREFIX + puzzleId)] = notas.joinToString(",")
+        }
+    }
+
+    suspend fun marcarCompletado(puzzleId: String) {
+        store.edit { p ->
+            p[COMPLETADOS] = (p[COMPLETADOS] ?: emptySet()) + puzzleId
+        }
+    }
+
+    private companion object {
+        const val GEN_PREFIX = "n:"        // cuenta de generados por variante:dif
+        const val ENTRADAS_PREFIX = "e:"   // rejilla en curso por puzzleId
+        const val NOTAS_PREFIX = "k:"      // notas por puzzleId
+        val COMPLETADOS = stringSetPreferencesKey("completados")
+    }
+}
diff --git a/nonet/src/main/java/com/rizoma/nonet/game/Motor.kt b/nonet/src/main/java/com/rizoma/nonet/game/Motor.kt
new file mode 100644
index 0000000..3b30a20
--- /dev/null
+++ b/nonet/src/main/java/com/rizoma/nonet/game/Motor.kt
@@ -0,0 +1,169 @@
+package com.rizoma.nonet.game
+
+import com.rizoma.nonet.model.Dificultad
+import com.rizoma.nonet.model.Variant
+import java.util.Random
+
+/**
+ * Un puzzle generado: las pistas (givens) y su solución única, más las regiones
+ * de la variante (para validar jugadas y resaltar conflictos en la UI).
+ */
+class Puzzle(
+    val variant: Variant,
+    val dificultad: Dificultad,
+    val index: Int,
+    val givens: IntArray,    // 81; 0 = celda vacía a rellenar
+    val solution: IntArray,  // 81; solución completa (única)
+    val groups: List<IntArray>,
+    val peers: Array<IntArray>,
+) {
+    /** Clave estable: misma (variante, dificultad, índice) → mismo puzzle siempre. */
+    val id: String get() = "${variant.id}:${dificultad.ordinal}:$index"
+}
+
+/**
+ * El motor de la familia "región extra". Todo se reduce a un conjunto de
+ * **grupos** (cada uno, 9 celdas que llevan 1-9 sin repetir): 27 clásicos +
+ * los extra de la variante. Generar = hallar una solución completa aleatoria y
+ * luego **cavar** celdas mientras la solución siga siendo única.
+ */
+object Motor {
+    // Bits 1..9 puestos: máscara de "todos los dígitos disponibles".
+    private const val TODOS = 0x3FE
+
+    /** Las 27 regiones clásicas (filas, columnas, cajas) + las extra de [v]. */
+    fun groupsFor(v: Variant): List<IntArray> {
+        val g = ArrayList<IntArray>(36)
+        for (r in 0 until 9) g.add(IntArray(9) { r * 9 + it })
+        for (c in 0 until 9) g.add(IntArray(9) { it * 9 + c })
+        for (b in 0 until 9) {
+            val r0 = (b / 3) * 3; val c0 = (b % 3) * 3
+            g.add(IntArray(9) { i -> (r0 + i / 3) * 9 + (c0 + i % 3) })
+        }
+        g.addAll(v.extraGroups())
+        return g
+    }
+
+    /** Para cada celda, las que comparten algún grupo (sin incluirse a sí misma). */
+    fun peersOf(groups: List<IntArray>): Array<IntArray> {
+        val sets = Array(81) { HashSet<Int>() }
+        for (grp in groups) for (a in grp) for (b in grp) if (a != b) sets[a].add(b)
+        return Array(81) { sets[it].toIntArray() }
+    }
+
+    /** Genera el puzzle determinista de (variante, dificultad, índice). */
+    fun generar(v: Variant, dif: Dificultad, index: Int): Puzzle {
+        val rng = Random(seedFor(v, dif, index))
+        val groups = groupsFor(v)
+        val peers = peersOf(groups)
+
+        val solution = IntArray(81)
+        fillFull(solution, peers, rng) // backtracking completo: siempre la halla si existe
+
+        val givens = solution.copyOf()
+        dig(givens, peers, dif.pistasObjetivo, rng)
+
+        return Puzzle(v, dif, index, givens, solution, groups, peers)
+    }
+
+    /** Cuenta soluciones de [grid] hasta [limite] (2 basta para juzgar unicidad). */
+    fun contarSoluciones(grid: IntArray, peers: Array<IntArray>, limite: Int): Int =
+        count(grid.copyOf(), peers, limite)
+
+    // ---- interno ------------------------------------------------------
+
+    private fun seedFor(v: Variant, dif: Dificultad, index: Int): Long {
+        val s = "${v.id}|${dif.ordinal}|$index"
+        var h = 1_125_899_906_842_597L // primo grande
+        for (ch in s) h = 31 * h + ch.code
+        return h
+    }
+
+    private fun usedMask(grid: IntArray, peers: IntArray): Int {
+        var m = 0
+        for (p in peers) { val v = grid[p]; if (v != 0) m = m or (1 shl v) }
+        return m
+    }
+
+    // Centinelas de mrv en el índice de celda.
+    private const val RESUELTO = -2   // no quedan celdas vacías
+    private const val SIN_SALIDA = -1 // alguna vacía sin candidatos posibles
+
+    /** Rellena una solución completa con orden de dígitos aleatorio (MRV). */
+    private fun fillFull(grid: IntArray, peers: Array<IntArray>, rng: Random): Boolean {
+        val (idx, mask) = mrv(grid, peers)
+        if (idx == RESUELTO) return true
+        if (idx == SIN_SALIDA) return false
+        val digits = digitsOf(mask).also { shuffle(it, rng) }
+        for (d in digits) {
+            grid[idx] = d
+            if (fillFull(grid, peers, rng)) return true
+        }
+        grid[idx] = 0
+        return false
+    }
+
+    /** Cuenta soluciones (capado a [limite]) por backtracking con MRV. */
+    private fun count(grid: IntArray, peers: Array<IntArray>, limite: Int): Int {
+        val (idx, mask) = mrv(grid, peers)
+        if (idx == RESUELTO) return 1
+        if (idx == SIN_SALIDA) return 0
+        var total = 0
+        var d = 1
+        while (d <= 9) {
+            if (mask and (1 shl d) != 0) {
+                grid[idx] = d
+                total += count(grid, peers, limite - total)
+                grid[idx] = 0
+                if (total >= limite) break
+            }
+            d++
+        }
+        return total
+    }
+
+    /**
+     * Celda vacía con menos candidatos + su máscara de candidatos. Índice
+     * [RESUELTO] si no quedan vacías, o [SIN_SALIDA] si alguna no tiene candidato.
+     */
+    private fun mrv(grid: IntArray, peers: Array<IntArray>): Pair<Int, Int> {
+        var bestIdx = RESUELTO; var bestMask = 0; var bestCount = 10
+        for (c in 0 until 81) {
+            if (grid[c] != 0) continue
+            val cand = TODOS and usedMask(grid, peers[c]).inv()
+            val cnt = Integer.bitCount(cand)
+            if (cnt == 0) return SIN_SALIDA to 0
+            if (cnt < bestCount) {
+                bestCount = cnt; bestIdx = c; bestMask = cand
+                if (cnt == 1) break
+            }
+        }
+        return bestIdx to bestMask
+    }
+
+    /** Quita celdas en orden aleatorio mientras la solución siga siendo única. */
+    private fun dig(grid: IntArray, peers: Array<IntArray>, pistasObjetivo: Int, rng: Random) {
+        val order = IntArray(81) { it }.also { shuffle(it, rng) }
+        var pistas = 81
+        for (c in order) {
+            if (pistas <= pistasObjetivo) break
+            val saved = grid[c]
+            grid[c] = 0
+            if (count(grid.copyOf(), peers, 2) != 1) grid[c] = saved else pistas--
+        }
+    }
+
+    private fun digitsOf(mask: Int): IntArray {
+        val out = ArrayList<Int>(9)
+        var d = 1
+        while (d <= 9) { if (mask and (1 shl d) != 0) out.add(d); d++ }
+        return out.toIntArray()
+    }
+
+    private fun shuffle(a: IntArray, rng: Random) {
+        for (i in a.indices.reversed()) {
+            val j = rng.nextInt(i + 1)
+            val t = a[i]; a[i] = a[j]; a[j] = t
+        }
+    }
+}
diff --git a/nonet/src/main/java/com/rizoma/nonet/model/Variant.kt b/nonet/src/main/java/com/rizoma/nonet/model/Variant.kt
new file mode 100644
index 0000000..723aa65
--- /dev/null
+++ b/nonet/src/main/java/com/rizoma/nonet/model/Variant.kt
@@ -0,0 +1,109 @@
+package com.rizoma.nonet.model
+
+/**
+ * Las variantes de la "familia de región extra": todas son Sudoku clásico (filas,
+ * columnas y cajas 3×3 con 1-9) MÁS cero o más **grupos extra** —conjuntos de 9
+ * celdas que también deben contener cada dígito una sola vez—. Como el único
+ * cambio entre ellas son esos grupos, comparten un solo generador/solucionador.
+ *
+ * Catálogo tomado de https://sudoku-puzzles.net/es/variantes-de-sudoku/ (las que
+ * se expresan como regiones de unicidad). Índice de celda = fila*9 + columna.
+ */
+enum class Variant(
+    val id: String,
+    val titulo: String,
+    val regla: String,
+    val realce: Realce,
+) {
+    CLASICO(
+        "clasico", "Clásico",
+        "Cada fila, columna y caja 3×3 contiene los dígitos del 1 al 9 sin repetir.",
+        Realce.NINGUNO,
+    ),
+    X(
+        "x", "Sudoku X",
+        "Además del clásico, las dos diagonales principales también llevan 1-9 sin repetir.",
+        Realce.DIAGONALES,
+    ),
+    WINDOKU(
+        "windoku", "Windoku",
+        "Cuatro cajas 3×3 sombreadas (las 'ventanas') también deben contener 1-9 sin repetir.",
+        Realce.CELDAS,
+    ),
+    DG(
+        "dg", "Grupos disjuntos",
+        "Las nueve celdas que ocupan la misma posición dentro de su caja forman un grupo 1-9.",
+        Realce.POSICION,
+    ),
+    PUNTO_CENTRAL(
+        "centro", "Punto central",
+        "Las nueve celdas centrales de cada caja forman un grupo 1-9 adicional.",
+        Realce.CELDAS,
+    ),
+    ASTERISCO(
+        "asterisco", "Asterisco",
+        "Nueve celdas en patrón de estrella alrededor del centro forman un grupo 1-9.",
+        Realce.CELDAS,
+    ),
+    GIRANDOLA(
+        "girandola", "Girándola",
+        "Nueve celdas en molinillo (pinwheel) a través del centro forman un grupo 1-9.",
+        Realce.CELDAS,
+    );
+
+    /** Cómo se dibuja la restricción extra en el tablero. */
+    enum class Realce { NINGUNO, DIAGONALES, CELDAS, POSICION }
+
+    /**
+     * Grupos extra (cada uno, 9 índices de celda) que se suman a las 27 regiones
+     * clásicas. La familia entera vive aquí: añadir otra variante de este tipo es
+     * añadir una entrada al enum y su lista de grupos.
+     */
+    fun extraGroups(): List<IntArray> = when (this) {
+        CLASICO -> emptyList()
+        X -> listOf(
+            IntArray(9) { it * 9 + it },        // diagonal principal
+            IntArray(9) { it * 9 + (8 - it) },  // diagonal secundaria
+        )
+        WINDOKU -> WINDOWS.map { (r0, c0) -> box3x3(r0, c0) }
+        DG -> (0 until 9).map { p ->            // p = posición dentro de la caja
+            val pr = p / 3; val pc = p % 3
+            IntArray(9) { b ->                  // b = índice de caja
+                val br = b / 3; val bc = b % 3
+                (br * 3 + pr) * 9 + (bc * 3 + pc)
+            }
+        }
+        PUNTO_CENTRAL -> listOf(
+            IntArray(9) { b -> (((b / 3) * 3 + 1) * 9) + ((b % 3) * 3 + 1) },
+        )
+        ASTERISCO -> listOf(cells(ASTERISK_RC))
+        GIRANDOLA -> listOf(cells(GIRANDOLA_RC))
+    }
+
+    /** Las celdas resaltadas (unión de los grupos extra), para sombrearlas. */
+    fun highlightCells(): Set<Int> =
+        if (realce == Realce.CELDAS) extraGroups().flatMap { it.asList() }.toSet() else emptySet()
+
+    private companion object {
+        // Esquinas (fila, col) de las 4 ventanas de Windoku.
+        val WINDOWS = listOf(1 to 1, 1 to 5, 5 to 1, 5 to 5)
+
+        fun box3x3(r0: Int, c0: Int): IntArray =
+            IntArray(9) { i -> (r0 + i / 3) * 9 + (c0 + i % 3) }
+
+        // Patrones canónicos (fila, col), 0-indexados.
+        val ASTERISK_RC = listOf(1 to 4, 2 to 2, 2 to 6, 4 to 1, 4 to 4, 4 to 7, 6 to 2, 6 to 6, 7 to 4)
+        val GIRANDOLA_RC = listOf(0 to 4, 1 to 1, 1 to 7, 4 to 0, 4 to 4, 4 to 8, 7 to 1, 7 to 7, 8 to 4)
+
+        fun cells(rc: List<Pair<Int, Int>>): IntArray =
+            IntArray(rc.size) { rc[it].first * 9 + rc[it].second }
+    }
+}
+
+/** Las cuatro dificultades, con cuántas pistas dejar al cavar el puzzle. */
+enum class Dificultad(val titulo: String, val pistasObjetivo: Int) {
+    FACIL("fácil", 46),
+    MEDIO("medio", 38),
+    DIFICIL("difícil", 31),
+    EXPERTO("experto", 26),
+}
diff --git a/nonet/src/main/java/com/rizoma/nonet/ui/BoardScreen.kt b/nonet/src/main/java/com/rizoma/nonet/ui/BoardScreen.kt
new file mode 100644
index 0000000..e13e862
--- /dev/null
+++ b/nonet/src/main/java/com/rizoma/nonet/ui/BoardScreen.kt
@@ -0,0 +1,320 @@
+package com.rizoma.nonet.ui
+
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+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.WindowInsets
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxHeight
+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.systemBars
+import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.rizoma.nonet.NonetViewModel
+import com.rizoma.nonet.model.Variant
+import com.rizoma.nonet.ui.theme.MuyTenue
+import com.rizoma.nonet.ui.theme.NeonAmbar
+import com.rizoma.nonet.ui.theme.NeonCian
+import com.rizoma.nonet.ui.theme.NeonRosa
+import com.rizoma.nonet.ui.theme.NeonVerde
+import com.rizoma.nonet.ui.theme.Superficie
+import com.rizoma.nonet.ui.theme.SuperficieAlta
+import com.rizoma.nonet.ui.theme.Tenue
+import com.rizoma.nonet.ui.theme.Vacio
+
+@Composable
+fun BoardScreen(vm: NonetViewModel) {
+    Column(
+        Modifier
+            .fillMaxSize()
+            .background(Vacio)
+            .windowInsetsPadding(WindowInsets.systemBars)
+            .verticalScroll(rememberScrollState())
+            .padding(14.dp),
+    ) {
+        // Barra superior.
+        Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
+            Text(
+                "‹  menú",
+                color = Tenue,
+                fontSize = 14.sp,
+                fontWeight = FontWeight.Medium,
+                modifier = Modifier.clip(RoundedCornerShape(50)).clickable { vm.volverAlMenu() }
+                    .padding(vertical = 6.dp, horizontal = 2.dp),
+            )
+            Spacer(Modifier.weight(1f))
+            Text(vm.variante.titulo, color = Color.White, fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
+            Text("  ·  ${vm.dificultad.titulo}", color = MuyTenue, fontSize = 13.sp)
+        }
+
+        Spacer(Modifier.height(10.dp))
+        Text(
+            when {
+                vm.completado -> "¡resuelto!"
+                vm.errores.isNotEmpty() -> "${vm.errores.size} por revisar"
+                else -> "elige un número y toca las celdas"
+            },
+            color = when {
+                vm.completado -> NeonVerde
+                vm.errores.isNotEmpty() -> NeonRosa
+                else -> MuyTenue
+            },
+            fontSize = 13.sp,
+            fontWeight = if (vm.completado) FontWeight.Bold else FontWeight.Normal,
+        )
+        Spacer(Modifier.height(10.dp))
+
+        if (vm.generando || vm.puzzle == null) {
+            Box(Modifier.fillMaxWidth().height(300.dp), contentAlignment = Alignment.Center) {
+                Text("generando…", color = NeonCian, fontSize = 16.sp)
+            }
+            return@Column
+        }
+
+        Tablero(vm)
+        Spacer(Modifier.height(14.dp))
+        Teclado(vm)
+        Spacer(Modifier.height(16.dp))
+    }
+}
+
+@Composable
+private fun Tablero(vm: NonetViewModel) {
+    val variante = vm.variante
+    val sombreadas = remember(variante) { variante.highlightCells() }
+    val activo = vm.activo
+    val foco = vm.celdaFoco
+    val relacionadas = if (foco in 0..80) vm.puzzle?.peers?.get(foco)?.toHashSet() ?: emptySet() else emptySet<Int>()
+    val conflictos = vm.conflictos()
+    val errores = vm.errores
+
+    Box(Modifier.fillMaxWidth().aspectRatio(1f).clip(RoundedCornerShape(8.dp)).background(Superficie)) {
+        Column(Modifier.fillMaxSize()) {
+            for (r in 0 until 9) {
+                Row(Modifier.fillMaxWidth().weight(1f)) {
+                    for (c in 0 until 9) {
+                        val i = r * 9 + c
+                        Celda(
+                            modifier = Modifier.weight(1f).fillMaxHeight(),
+                            valor = vm.entradas[i],
+                            notas = vm.notas[i],
+                            activo = activo,
+                            fija = vm.esFija(i),
+                            foco = i == foco,
+                            relacionada = i in relacionadas,
+                            mismoDigito = activo in 1..9 && vm.entradas[i] == activo,
+                            errorRojo = i in conflictos || i in errores,
+                            sombra = sombraDe(i, variante, sombreadas),
+                            onClick = { vm.tocarCelda(i) },
+                        )
+                    }
+                }
+            }
+        }
+        // Líneas de la rejilla (+ diagonales del Sudoku X) por encima.
+        Canvas(Modifier.fillMaxSize()) {
+            val cell = size.width / 9f
+            if (variante == Variant.X) {
+                drawLine(NeonRosa.copy(alpha = 0.30f), Offset(0f, 0f), Offset(size.width, size.height), 3f)
+                drawLine(NeonRosa.copy(alpha = 0.30f), Offset(size.width, 0f), Offset(0f, size.height), 3f)
+            }
+            for (k in 0..9) {
+                val gruesa = k % 3 == 0
+                val color = if (gruesa) Tenue else MuyTenue.copy(alpha = 0.4f)
+                val w = if (gruesa) 3f else 1f
+                drawLine(color, Offset(k * cell, 0f), Offset(k * cell, size.height), w)
+                drawLine(color, Offset(0f, k * cell), Offset(size.width, k * cell), w)
+            }
+        }
+    }
+}
+
+@Composable
+private fun Celda(
+    modifier: Modifier,
+    valor: Int,
+    notas: Int,
+    activo: Int,
+    fija: Boolean,
+    foco: Boolean,
+    relacionada: Boolean,
+    mismoDigito: Boolean,
+    errorRojo: Boolean,
+    sombra: Color,
+    onClick: () -> Unit,
+) {
+    val bg = when {
+        foco -> NeonCian.copy(alpha = 0.32f)
+        mismoDigito -> NeonCian.copy(alpha = 0.16f)
+        relacionada -> Color.White.copy(alpha = 0.05f)
+        else -> sombra
+    }
+    Box(modifier.background(bg).clickable(onClick = onClick), contentAlignment = Alignment.Center) {
+        when {
+            valor != 0 -> Text(
+                "$valor",
+                color = when {
+                    errorRojo -> NeonRosa
+                    fija -> Color.White
+                    else -> NeonCian
+                },
+                fontSize = 18.sp,
+                fontWeight = if (fija) FontWeight.Bold else FontWeight.Medium,
+            )
+            notas != 0 -> NotasMini(notas, activo)
+        }
+    }
+}
+
+/** Mini 3×3 de notas a lápiz; la nota del dígito activo se realza. */
+@Composable
+private fun NotasMini(notas: Int, activo: Int) {
+    Column(Modifier.fillMaxSize().padding(1.dp)) {
+        for (fila in 0 until 3) {
+            Row(Modifier.fillMaxWidth().weight(1f)) {
+                for (col in 0 until 3) {
+                    val d = fila * 3 + col + 1
+                    Box(Modifier.weight(1f).fillMaxHeight(), contentAlignment = Alignment.Center) {
+                        if (notas and (1 shl d) != 0) {
+                            Text(
+                                "$d",
+                                color = if (d == activo) NeonCian else MuyTenue,
+                                fontSize = 8.sp,
+                                fontWeight = if (d == activo) FontWeight.Bold else FontWeight.Normal,
+                            )
+                        }
+                    }
+                }
+            }
+        }
+    }
+}
+
+private fun sombraDe(i: Int, v: Variant, sombreadas: Set<Int>): Color = when (v.realce) {
+    Variant.Realce.CELDAS -> if (i in sombreadas) NeonAmbar.copy(alpha = 0.13f) else Color.Transparent
+    Variant.Realce.POSICION -> {
+        // Tinte tenue por posición dentro de la caja (los 9 grupos disjuntos).
+        val p = ((i / 9) % 3) * 3 + (i % 9) % 3
+        Color.hsv(p * 40f, 0.55f, 0.9f).copy(alpha = 0.10f)
+    }
+    else -> Color.Transparent
+}
+
+@Composable
+private fun Teclado(vm: NonetViewModel) {
+    // Cuántos de cada dígito quedan por colocar (9 menos los ya puestos).
+    val restantes = IntArray(10)
+    for (v in vm.entradas) if (v in 1..9) restantes[v]++
+
+    Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
+        // Fila superior: deshacer, rehacer, comprobar.
+        Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
+            Accion("↶", "deshacer", NeonCian, vm.puedeDeshacer, Modifier.weight(1f)) { vm.deshacer() }
+            Accion("↷", "rehacer", NeonCian, vm.puedeRehacer, Modifier.weight(1f)) { vm.rehacerJugada() }
+            Accion("♥", "comprobar", NeonVerde, !vm.completado, Modifier.weight(1f)) { vm.comprobar() }
+        }
+        // Numérico 3×3.
+        for (fila in 0 until 3) {
+            Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
+                for (col in 0 until 3) {
+                    val d = fila * 3 + col + 1
+                    DigitoKey(d, 9 - restantes[d], vm.activo == d, Modifier.weight(1f)) { vm.elegirActivo(d) }
+                }
+            }
+        }
+        // Fila inferior: edit (borrador), borrar, pista.
+        Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
+            Accion(
+                "✎", if (vm.modoEdicion) "edit ✓" else "edit",
+                if (vm.modoEdicion) NeonAmbar else Tenue, true, Modifier.weight(1f),
+                resaltado = vm.modoEdicion,
+            ) { vm.alternarEdicion() }
+            Accion(
+                "⌫", "borrar", NeonRosa, true, Modifier.weight(1f),
+                resaltado = vm.activo == NonetViewModel.BORRAR,
+            ) { vm.activarBorrar() }
+            Accion("◌", "pista", NeonAmbar, !vm.completado, Modifier.weight(1f)) { vm.pista() }
+        }
+    }
+}
+
+@Composable
+private fun DigitoKey(d: Int, restantes: Int, activo: Boolean, modifier: Modifier, onClick: () -> Unit) {
+    val agotado = restantes <= 0
+    Box(
+        modifier
+            .height(48.dp)
+            .clip(RoundedCornerShape(12.dp))
+            .background(if (activo) NeonCian.copy(alpha = 0.20f) else SuperficieAlta)
+            .border(1.dp, if (activo) NeonCian else Color.Transparent, RoundedCornerShape(12.dp))
+            .clickable(onClick = onClick),
+        contentAlignment = Alignment.Center,
+    ) {
+        Text(
+            "$d",
+            color = if (agotado) MuyTenue else NeonCian,
+            fontSize = 22.sp,
+            fontWeight = FontWeight.SemiBold,
+        )
+        // Cuántos quedan, en la esquina inferior.
+        Text(
+            "$restantes",
+            color = MuyTenue,
+            fontSize = 9.sp,
+            modifier = Modifier.align(Alignment.BottomEnd).padding(end = 5.dp, bottom = 2.dp),
+        )
+    }
+}
+
+@Composable
+private fun Accion(
+    icono: String,
+    label: String,
+    accent: Color,
+    habilitado: Boolean,
+    modifier: Modifier,
+    resaltado: Boolean = false,
+    onClick: () -> Unit,
+) {
+    Box(
+        modifier
+            .height(46.dp)
+            .clip(RoundedCornerShape(12.dp))
+            .background(if (resaltado) accent.copy(alpha = 0.18f) else SuperficieAlta)
+            .border(1.dp, if (resaltado) accent else Color.Transparent, RoundedCornerShape(12.dp))
+            .alpha(if (habilitado) 1f else 0.35f)
+            .clickable(enabled = habilitado, onClick = onClick),
+        contentAlignment = Alignment.Center,
+    ) {
+        Text(icono, color = accent, fontSize = 16.sp)
+        Text(
+            label,
+            color = accent,
+            fontSize = 9.sp,
+            modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 3.dp),
+        )
+    }
+}
diff --git a/nonet/src/main/java/com/rizoma/nonet/ui/MenuScreen.kt b/nonet/src/main/java/com/rizoma/nonet/ui/MenuScreen.kt
new file mode 100644
index 0000000..0cefdf5
--- /dev/null
+++ b/nonet/src/main/java/com/rizoma/nonet/ui/MenuScreen.kt
@@ -0,0 +1,172 @@
+package com.rizoma.nonet.ui
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.horizontalScroll
+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.WindowInsets
+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.systemBars
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.rizoma.nonet.NonetViewModel
+import com.rizoma.nonet.model.Dificultad
+import com.rizoma.nonet.model.Variant
+import com.rizoma.nonet.ui.theme.MuyTenue
+import com.rizoma.nonet.ui.theme.NeonAmbar
+import com.rizoma.nonet.ui.theme.NeonCian
+import com.rizoma.nonet.ui.theme.NeonVerde
+import com.rizoma.nonet.ui.theme.SuperficieAlta
+import com.rizoma.nonet.ui.theme.Tenue
+import com.rizoma.nonet.ui.theme.Vacio
+
+@Composable
+fun MenuScreen(vm: NonetViewModel) {
+    Column(
+        Modifier
+            .fillMaxSize()
+            .background(Vacio)
+            .windowInsetsPadding(WindowInsets.systemBars)
+            .verticalScroll(rememberScrollState())
+            .padding(horizontal = 22.dp),
+    ) {
+        Spacer(Modifier.height(28.dp))
+        Text("nonet", color = Color.White, fontSize = 34.sp, fontWeight = FontWeight.Bold)
+        Text("sudoku por variante y dificultad", color = MuyTenue, fontSize = 12.sp)
+        Spacer(Modifier.height(22.dp))
+
+        // Selector de variante (scroll horizontal de chips).
+        Text("VARIANTE", color = MuyTenue, fontSize = 11.sp, letterSpacing = 3.sp)
+        Spacer(Modifier.height(8.dp))
+        Row(
+            Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
+            horizontalArrangement = Arrangement.spacedBy(8.dp),
+        ) {
+            Variant.entries.forEach { v ->
+                Chip(v.titulo, selected = v == vm.variante, accent = NeonCian) { vm.elegirVariante(v) }
+            }
+        }
+        Spacer(Modifier.height(8.dp))
+        Text(vm.variante.regla, color = Tenue, fontSize = 12.sp)
+
+        Spacer(Modifier.height(20.dp))
+
+        // Selector de dificultad.
+        Text("DIFICULTAD", color = MuyTenue, fontSize = 11.sp, letterSpacing = 3.sp)
+        Spacer(Modifier.height(8.dp))
+        Row(
+            Modifier.fillMaxWidth(),
+            horizontalArrangement = Arrangement.spacedBy(8.dp),
+        ) {
+            Dificultad.entries.forEach { d ->
+                Box(Modifier.weight(1f)) {
+                    Chip(d.titulo, selected = d == vm.dificultad, accent = NeonVerde, fill = true) {
+                        vm.elegirDificultad(d)
+                    }
+                }
+            }
+        }
+
+        Spacer(Modifier.height(24.dp))
+
+        // Puzzles generados de la selección actual.
+        val n = vm.cuenta()
+        Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
+            Text("PUZZLES", color = MuyTenue, fontSize = 11.sp, letterSpacing = 3.sp)
+            Spacer(Modifier.weight(1f))
+            Text("$n generado${if (n == 1) "" else "s"}", color = MuyTenue, fontSize = 11.sp)
+        }
+        Spacer(Modifier.height(12.dp))
+
+        (0 until n).toList().chunked(5).forEach { fila ->
+            Row(Modifier.fillMaxWidth().padding(vertical = 5.dp), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
+                fila.forEach { idx ->
+                    val id = vm.puzzleId(vm.variante, vm.dificultad, idx)
+                    val estado = when {
+                        id in vm.completados -> NeonVerde
+                        id in vm.enCurso -> NeonAmbar
+                        else -> MuyTenue
+                    }
+                    Box(Modifier.weight(1f), contentAlignment = Alignment.Center) {
+                        PuzzleNode(idx + 1, estado) { vm.abrir(vm.variante, vm.dificultad, idx) }
+                    }
+                }
+                repeat(5 - fila.size) { Spacer(Modifier.weight(1f)) }
+            }
+        }
+
+        Spacer(Modifier.height(16.dp))
+        Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
+            Chip("✦  nuevo puzzle", selected = false, accent = NeonCian) { vm.generarNuevo() }
+        }
+        Spacer(Modifier.height(40.dp))
+    }
+}
+
+@Composable
+private fun Chip(
+    text: String,
+    selected: Boolean,
+    accent: Color,
+    fill: Boolean = false,
+    onClick: () -> Unit,
+) {
+    Box(
+        (if (fill) Modifier.fillMaxWidth() else Modifier)
+            .clip(RoundedCornerShape(50))
+            .background(if (selected) accent.copy(alpha = 0.18f) else SuperficieAlta)
+            .border(1.dp, if (selected) accent else Color.Transparent, RoundedCornerShape(50))
+            .clickable(onClick = onClick)
+            .padding(horizontal = 16.dp, vertical = 10.dp),
+        contentAlignment = Alignment.Center,
+    ) {
+        Text(
+            text,
+            color = if (selected) accent else Tenue,
+            fontSize = 14.sp,
+            fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal,
+            maxLines = 1,
+        )
+    }
+}
+
+@Composable
+private fun PuzzleNode(numero: Int, accent: Color, onClick: () -> Unit) {
+    Column(horizontalAlignment = Alignment.CenterHorizontally) {
+        Box(
+            Modifier
+                .size(52.dp)
+                .clip(RoundedCornerShape(14.dp))
+                .background(SuperficieAlta)
+                .border(1.5.dp, accent.copy(alpha = 0.7f), RoundedCornerShape(14.dp))
+                .clickable(onClick = onClick),
+            contentAlignment = Alignment.Center,
+        ) {
+            Text("$numero", color = accent, fontSize = 18.sp, fontWeight = FontWeight.SemiBold)
+        }
+        Spacer(Modifier.height(4.dp))
+        Box(Modifier.size(6.dp).clip(CircleShape).background(accent))
+    }
+}
diff --git a/nonet/src/main/java/com/rizoma/nonet/ui/NonetApp.kt b/nonet/src/main/java/com/rizoma/nonet/ui/NonetApp.kt
new file mode 100644
index 0000000..0ffc009
--- /dev/null
+++ b/nonet/src/main/java/com/rizoma/nonet/ui/NonetApp.kt
@@ -0,0 +1,18 @@
+package com.rizoma.nonet.ui
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.runtime.Composable
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.rizoma.nonet.NonetViewModel
+import com.rizoma.nonet.Pantalla
+
+@Composable
+fun NonetApp(vm: NonetViewModel = viewModel()) {
+    when (vm.pantalla) {
+        Pantalla.Menu -> MenuScreen(vm)
+        Pantalla.Tablero -> {
+            BackHandler { vm.volverAlMenu() }
+            BoardScreen(vm)
+        }
+    }
+}
diff --git a/nonet/src/main/java/com/rizoma/nonet/ui/theme/Theme.kt b/nonet/src/main/java/com/rizoma/nonet/ui/theme/Theme.kt
new file mode 100644
index 0000000..af546af
--- /dev/null
+++ b/nonet/src/main/java/com/rizoma/nonet/ui/theme/Theme.kt
@@ -0,0 +1,37 @@
+package com.rizoma.nonet.ui.theme
+
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+
+// Paleta del rizoma: neón sobre vacío (compartida con Matiz/Cubo/Órbita…).
+val Vacio = Color(0xFF050507)
+val Superficie = Color(0xFF0C0C12)
+val SuperficieAlta = Color(0xFF14141C)
+val NeonVerde = Color(0xFF7CFF4F)
+val NeonRosa = Color(0xFFFF2E88)
+val NeonCian = Color(0xFF00E5FF)
+val NeonAmbar = Color(0xFFFFC857)
+val Tenue = Color(0xB3FFFFFF)
+val MuyTenue = Color(0x66FFFFFF)
+
+private val EsquemaNonet = darkColorScheme(
+    primary = NeonVerde,
+    onPrimary = Vacio,
+    secondary = NeonRosa,
+    onSecondary = Vacio,
+    tertiary = NeonCian,
+    onTertiary = Vacio,
+    background = Vacio,
+    onBackground = Color.White,
+    surface = Superficie,
+    onSurface = Color.White,
+    surfaceVariant = SuperficieAlta,
+    onSurfaceVariant = Tenue,
+)
+
+@Composable
+fun NonetTheme(content: @Composable () -> Unit) {
+    MaterialTheme(colorScheme = EsquemaNonet, content = content)
+}
diff --git a/nonet/src/main/res/drawable/ic_launcher_fg.xml b/nonet/src/main/res/drawable/ic_launcher_fg.xml
new file mode 100644
index 0000000..8358a31
--- /dev/null
+++ b/nonet/src/main/res/drawable/ic_launcher_fg.xml
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="utf-8"?>
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="108dp"
+    android:height="108dp"
+    android:viewportWidth="108"
+    android:viewportHeight="108">
+    <!-- rejilla 3×3 con tres casillas neón: un nonet -->
+    <path android:fillColor="#7CFF4F" android:pathData="M30 30 h16 v16 h-16 z" />
+    <path android:fillColor="#00E5FF" android:pathData="M62 46 h16 v16 h-16 z" />
+    <path android:fillColor="#FF2E88" android:pathData="M46 62 h16 v16 h-16 z" />
+    <!-- líneas de la rejilla -->
+    <path android:strokeColor="#66FFFFFF" android:strokeWidth="2" android:pathData="M30 30 h48 v48 h-48 z" />
+    <path android:strokeColor="#66FFFFFF" android:strokeWidth="2" android:pathData="M46 30 V78 M62 30 V78 M30 46 H78 M30 62 H78" />
+</vector>
diff --git a/nonet/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/nonet/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..088fcce
--- /dev/null
+++ b/nonet/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
+    <background android:drawable="@color/ic_bg" />
+    <foreground android:drawable="@drawable/ic_launcher_fg" />
+</adaptive-icon>
diff --git a/nonet/src/main/res/values/colors.xml b/nonet/src/main/res/values/colors.xml
new file mode 100644
index 0000000..917256d
--- /dev/null
+++ b/nonet/src/main/res/values/colors.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <color name="ic_bg">#050507</color>
+</resources>
diff --git a/nonet/src/main/res/values/strings.xml b/nonet/src/main/res/values/strings.xml
new file mode 100644
index 0000000..2af3bcc
--- /dev/null
+++ b/nonet/src/main/res/values/strings.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <string name="app_name">nonet</string>
+</resources>
diff --git a/nonet/src/main/res/values/themes.xml b/nonet/src/main/res/values/themes.xml
new file mode 100644
index 0000000..eb7345a
--- /dev/null
+++ b/nonet/src/main/res/values/themes.xml
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <style name="Theme.Nonet" parent="android:Theme.Material.NoActionBar">
+        <item name="android:windowBackground">#050507</item>
+        <item name="android:statusBarColor">@android:color/transparent</item>
+        <item name="android:navigationBarColor">@android:color/transparent</item>
+        <item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
+    </style>
+</resources>
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 1b2b56e..cdd3e4b 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -27,3 +27,4 @@ include(":matiz")
 include(":nimbo")
 include(":notas")
 include(":cubo")
+include(":nonet")