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>
Semantic delta
No definition changed: this change is text, documentation or data.
17 files · +1309 −0
- nonet/build.gradle.kts +50 −0
- nonet/src/main/AndroidManifest.xml +23 −0
- nonet/src/main/java/com/rizoma/nonet/MainActivity.kt +20 −0
- nonet/src/main/java/com/rizoma/nonet/NonetViewModel.kt +254 −0
- nonet/src/main/java/com/rizoma/nonet/data/Progreso.kt +100 −0
- nonet/src/main/java/com/rizoma/nonet/game/Motor.kt +169 −0
- nonet/src/main/java/com/rizoma/nonet/model/Variant.kt +109 −0
- nonet/src/main/java/com/rizoma/nonet/ui/BoardScreen.kt +320 −0
- nonet/src/main/java/com/rizoma/nonet/ui/MenuScreen.kt +172 −0
- nonet/src/main/java/com/rizoma/nonet/ui/NonetApp.kt +18 −0
- nonet/src/main/java/com/rizoma/nonet/ui/theme/Theme.kt +37 −0
- nonet/src/main/res/drawable/ic_launcher_fg.xml +14 −0
- nonet/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +5 −0
- nonet/src/main/res/values/colors.xml +4 −0
- nonet/src/main/res/values/strings.xml +4 −0
- nonet/src/main/res/values/themes.xml +9 −0
- settings.gradle.kts +1 −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")