Nonet: 6 variantes nuevas (jigsaw, par/impar, consecutivo, XV, mayor-que, killer)
Motor generalizado: regiones irregulares, máscara de paridad por celda, restricciones binarias entre vecinos y jaulas con suma — todo sobre el mismo solver/cavado con solución única (verificado para 13 variantes × 4 dificultades). Teclado reorganizado: las 6 acciones pasan a columnas izquierda (deshacer/ rehacer/comprobar) y derecha (edit/borrar/pista) del 3×3. Overlays por variante: bordes de región, sombreado par, marcas de arista (barra/X/V/‹›) y jaulas punteadas con su suma. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Delta semántico
Ninguna definición cambió: el cambio es de texto, documentación o datos.
3 archivos · +504 −178
diff --git a/nonet/src/main/java/com/rizoma/nonet/game/Motor.kt b/nonet/src/main/java/com/rizoma/nonet/game/Motor.kt
index 3b30a20..3468fc8 100644
--- a/nonet/src/main/java/com/rizoma/nonet/game/Motor.kt
+++ b/nonet/src/main/java/com/rizoma/nonet/game/Motor.kt
@@ -4,108 +4,218 @@ import com.rizoma.nonet.model.Dificultad
import com.rizoma.nonet.model.Variant
import java.util.Random
+/** Relación entre dos celdas vecinas (consecutivo, sumas XV, desigualdad). */
+enum class Rel { CONSEC, NO_CONSEC, SUM5, SUM10, NO_5_10, LT, GT }
+
+/** Restricción binaria vista desde una celda: contra [other] debe cumplirse [rel]. */
+class BinPeer(val other: Int, val rel: Rel)
+
+/** Jaula de Killer: un conjunto de celdas que suma [sum] y no repite dígitos. */
+class Cage(val cells: IntArray, val sum: Int)
+
+/** Marca a dibujar entre dos celdas adyacentes [a]-[b] (orientada para < >). */
+class Arista(val a: Int, val b: Int, val rel: Rel)
+
/**
- * 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).
+ * Todas las restricciones de un puzzle concreto, ya resueltas para el solver:
+ * vecinos de no-repetición, dígitos permitidos por celda (paridad), relaciones
+ * binarias por celda y jaulas. El clásico y las de "región extra" solo usan
+ * [peers] (con [allowed] lleno y sin binarios ni jaulas).
*/
+class Reglas(
+ val peers: Array<IntArray>,
+ val allowed: IntArray,
+ val bin: Array<List<BinPeer>>,
+ val cagesOf: Array<List<Cage>>,
+ val cages: List<Cage>,
+)
+
+/** Un puzzle generado: pistas, solución única, reglas y datos para dibujar. */
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>,
+ val givens: IntArray,
+ val solution: IntArray,
+ val reglas: Reglas,
+ val regiones: IntArray?, // jigsaw: región (0..8) por celda; null = cajas 3×3
+ val aristas: List<Arista>, // marcas entre celdas (consecutivo/XV/mayor-que)
) {
- /** Clave estable: misma (variante, dificultad, índice) → mismo puzzle siempre. */
+ val peers: Array<IntArray> get() = reglas.peers
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.
+ * Motor unificado de todas las variantes. Genera una solución completa bajo las
+ * regiones de la variante, deriva de ella las restricciones propias (paridad,
+ * marcas, jaulas) y cava 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
- }
+ private const val TODOS = 0x3FE // bits 1..9
+ private const val PARES = (1 shl 2) or (1 shl 4) or (1 shl 6) or (1 shl 8)
+ private const val IMPARES = (1 shl 1) or (1 shl 3) or (1 shl 5) or (1 shl 7) or (1 shl 9)
- /** 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 regiones = if (v.tipo == Variant.Tipo.JIGSAW) jigsaw(rng) else null
+ val baseGroups = ArrayList<IntArray>(36)
+ for (r in 0 until 9) baseGroups.add(IntArray(9) { r * 9 + it })
+ for (c in 0 until 9) baseGroups.add(IntArray(9) { it * 9 + c })
+ baseGroups.addAll(regiones?.let { regionGroups(it) } ?: boxes())
+ baseGroups.addAll(v.extraGroups())
+
+ // Solución completa bajo las regiones (las demás restricciones se derivan).
val solution = IntArray(81)
- fillFull(solution, peers, rng) // backtracking completo: siempre la halla si existe
+ fillFull(solution, soloPeers(peersOf(baseGroups)), rng)
+
+ val (reglas, aristas) = construir(v, solution, baseGroups, rng)
val givens = solution.copyOf()
- dig(givens, peers, dif.pistasObjetivo, rng)
+ dig(givens, reglas, dif.pistasObjetivo, rng)
- return Puzzle(v, dif, index, givens, solution, groups, peers)
+ return Puzzle(v, dif, index, givens, solution, reglas, regiones, aristas)
}
- /** 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)
+ /** Cuenta soluciones de [grid] bajo [reglas] hasta [limite]. */
+ fun contarSoluciones(grid: IntArray, reglas: Reglas, limite: Int): Int =
+ count(grid.copyOf(), reglas, limite)
- // ---- interno ------------------------------------------------------
+ // ---- Construcción de reglas por variante --------------------------
- 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 construir(
+ v: Variant,
+ sol: IntArray,
+ baseGroups: List<IntArray>,
+ rng: Random,
+ ): Pair<Reglas, List<Arista>> {
+ val allowed = IntArray(81) { TODOS }
+ val bin = Array(81) { ArrayList<BinPeer>() }
+ val aristas = ArrayList<Arista>()
+ var cages = emptyList<Cage>()
+ var groups = baseGroups
+
+ when (v.tipo) {
+ Variant.Tipo.PARIDAD ->
+ for (i in 0 until 81) allowed[i] = if (sol[i] % 2 == 0) PARES else IMPARES
+
+ Variant.Tipo.CONSEC -> adyacentes(soloOrtogonales = true) { a, b ->
+ val consec = kotlin.math.abs(sol[a] - sol[b]) == 1
+ bin[a].add(BinPeer(b, if (consec) Rel.CONSEC else Rel.NO_CONSEC))
+ bin[b].add(BinPeer(a, if (consec) Rel.CONSEC else Rel.NO_CONSEC))
+ if (consec) aristas.add(Arista(a, b, Rel.CONSEC))
+ }
+
+ Variant.Tipo.XV -> adyacentes(soloOrtogonales = true) { a, b ->
+ val s = sol[a] + sol[b]
+ val rel = when (s) { 5 -> Rel.SUM5; 10 -> Rel.SUM10; else -> Rel.NO_5_10 }
+ bin[a].add(BinPeer(b, rel)); bin[b].add(BinPeer(a, rel))
+ if (rel != Rel.NO_5_10) aristas.add(Arista(a, b, rel))
+ }
+
+ Variant.Tipo.MAYOR -> adyacentes(soloOrtogonales = true, mismaCaja = true) { a, b ->
+ // a<b: a lleva LT contra b; b lleva GT contra a. Arista orientada a→b.
+ if (sol[a] < sol[b]) {
+ bin[a].add(BinPeer(b, Rel.LT)); bin[b].add(BinPeer(a, Rel.GT))
+ aristas.add(Arista(a, b, Rel.LT))
+ } else {
+ bin[a].add(BinPeer(b, Rel.GT)); bin[b].add(BinPeer(a, Rel.LT))
+ aristas.add(Arista(a, b, Rel.GT))
+ }
+ }
+
+ Variant.Tipo.KILLER -> {
+ cages = jaulas(sol, rng)
+ // El no-repetir de cada jaula se enforza vía peers.
+ groups = baseGroups + cages.map { it.cells }
+ }
+
+ else -> {} // REGION / JIGSAW: solo regiones
+ }
+
+ val peers = peersOf(groups)
+ val cagesOf = Array(81) { ArrayList<Cage>() }
+ for (c in cages) for (i in c.cells) cagesOf[i].add(c)
+ val reglas = Reglas(
+ peers = peers,
+ allowed = allowed,
+ bin = Array(81) { bin[it] },
+ cagesOf = Array(81) { cagesOf[it] },
+ cages = cages,
+ )
+ return reglas to aristas
}
- private fun usedMask(grid: IntArray, peers: IntArray): Int {
+ // ---- Solver (peers + allowed + binarios + jaulas) -----------------
+
+ private const val RESUELTO = -2
+ private const val SIN_SALIDA = -1
+
+ /** Candidatos válidos de la celda [c]: permitidos − usados por peers − binarios. */
+ private fun candMask(grid: IntArray, c: Int, reglas: Reglas): Int {
+ var m = reglas.allowed[c] and TODOS
+ for (p in reglas.peers[c]) { val v = grid[p]; if (v != 0) m = m and (1 shl v).inv() }
+ for (bp in reglas.bin[c]) {
+ val e = grid[bp.other]
+ if (e != 0) m = m and permitidosPorRel(e, bp.rel)
+ }
+ return m
+ }
+
+ /** Máscara de dígitos d que cumplen rel(d, [e]) contra el vecino con valor [e]. */
+ private fun permitidosPorRel(e: Int, rel: Rel): Int {
var m = 0
- for (p in peers) { val v = grid[p]; if (v != 0) m = m or (1 shl v) }
+ var d = 1
+ while (d <= 9) {
+ val ok = when (rel) {
+ Rel.CONSEC -> kotlin.math.abs(d - e) == 1
+ Rel.NO_CONSEC -> kotlin.math.abs(d - e) != 1
+ Rel.SUM5 -> d + e == 5
+ Rel.SUM10 -> d + e == 10
+ Rel.NO_5_10 -> d + e != 5 && d + e != 10
+ Rel.LT -> d < e
+ Rel.GT -> d > e
+ }
+ if (ok) m = m or (1 shl d)
+ d++
+ }
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
+ /** ¿Las jaulas que tocan [c] siguen siendo viables tras colocar un dígito? */
+ private fun cagesOk(grid: IntArray, c: Int, reglas: Reglas): Boolean {
+ for (cage in reglas.cagesOf[c]) {
+ var suma = 0; var llenas = 0
+ for (i in cage.cells) { val v = grid[i]; if (v != 0) { suma += v; llenas++ } }
+ val faltan = cage.cells.size - llenas
+ if (faltan == 0) {
+ if (suma != cage.sum) return false
+ } else {
+ // Cota: cada celda restante aporta ≥1 distinto y ≤ (9,8,…).
+ if (suma + faltan > cage.sum) return false
+ var maxAdd = 0; var d = 9
+ repeat(faltan) { maxAdd += d; d-- }
+ if (suma + maxAdd < cage.sum) return false
+ }
+ }
+ return true
+ }
- /** 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)
+ private fun fillFull(grid: IntArray, reglas: Reglas, rng: Random): Boolean {
+ val (idx, mask) = mrv(grid, reglas)
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
+ if (cagesOk(grid, idx, reglas) && fillFull(grid, reglas, rng)) return true
+ grid[idx] = 0
}
- 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)
+ private fun count(grid: IntArray, reglas: Reglas, limite: Int): Int {
+ val (idx, mask) = mrv(grid, reglas)
if (idx == RESUELTO) return 1
if (idx == SIN_SALIDA) return 0
var total = 0
@@ -113,7 +223,7 @@ object Motor {
while (d <= 9) {
if (mask and (1 shl d) != 0) {
grid[idx] = d
- total += count(grid, peers, limite - total)
+ if (cagesOk(grid, idx, reglas)) total += count(grid, reglas, limite - total)
grid[idx] = 0
if (total >= limite) break
}
@@ -122,35 +232,137 @@ object Motor {
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> {
+ private fun mrv(grid: IntArray, reglas: Reglas): 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 cand = candMask(grid, c, reglas)
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
- }
+ 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) {
+ private fun dig(grid: IntArray, reglas: Reglas, 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--
+ if (count(grid.copyOf(), reglas, 2) != 1) grid[c] = saved else pistas--
+ }
+ }
+
+ // ---- Regiones -----------------------------------------------------
+
+ private fun boxes(): List<IntArray> = (0 until 9).map { b ->
+ val r0 = (b / 3) * 3; val c0 = (b % 3) * 3
+ IntArray(9) { i -> (r0 + i / 3) * 9 + (c0 + i % 3) }
+ }
+
+ private fun regionGroups(reg: IntArray): List<IntArray> =
+ (0 until 9).map { id -> (0 until 81).filter { reg[it] == id }.toIntArray() }
+
+ /** 9 regiones conexas de 9 celdas (jigsaw), creciendo la más pequeña con vecino libre. */
+ private fun jigsaw(rng: Random): IntArray {
+ repeat(300) {
+ val reg = IntArray(81) { -1 }
+ val sizes = IntArray(9)
+ val seeds = (0 until 81).toMutableList().also { shuffle2(it, rng) }.take(9)
+ seeds.forEachIndexed { id, cell -> reg[cell] = id; sizes[id] = 1 }
+ var faltan = 81 - 9
+ var atascado = false
+ while (faltan > 0 && !atascado) {
+ // región más pequeña (<9) con algún vecino libre
+ var elegida = -1; var menor = 10
+ for (id in 0 until 9) if (sizes[id] in 1 until 9 && sizes[id] < menor && tieneVecinoLibre(reg, id)) {
+ menor = sizes[id]; elegida = id
+ }
+ if (elegida == -1) { atascado = true; break }
+ val libres = vecinosLibres(reg, elegida)
+ val cell = libres[rng.nextInt(libres.size)]
+ reg[cell] = elegida; sizes[elegida]++; faltan--
+ }
+ if (!atascado && sizes.all { it == 9 }) return reg
+ }
+ // Fallback improbable: cajas 3×3.
+ val reg = IntArray(81); for (i in 0 until 81) reg[i] = (i / 9 / 3) * 3 + (i % 9 / 3)
+ return reg
+ }
+
+ private fun tieneVecinoLibre(reg: IntArray, id: Int): Boolean {
+ for (i in 0 until 81) if (reg[i] == id) for (n in ortog(i)) if (reg[n] == -1) return true
+ return false
+ }
+
+ private fun vecinosLibres(reg: IntArray, id: Int): IntArray {
+ val s = HashSet<Int>()
+ for (i in 0 until 81) if (reg[i] == id) for (n in ortog(i)) if (reg[n] == -1) s.add(n)
+ return s.toIntArray()
+ }
+
+ // ---- Jaulas (Killer) ----------------------------------------------
+
+ private fun jaulas(sol: IntArray, rng: Random): List<Cage> {
+ val asign = IntArray(81) { -1 }
+ val cages = ArrayList<Cage>()
+ val order = IntArray(81) { it }.also { shuffle(it, rng) }
+ for (start in order) {
+ if (asign[start] != -1) continue
+ val objetivo = 2 + rng.nextInt(3) // 2..4
+ val celdas = ArrayList<Int>().apply { add(start) }
+ asign[start] = cages.size
+ val usados = HashSet<Int>().apply { add(sol[start]) }
+ while (celdas.size < objetivo) {
+ // vecino libre de cualquier celda de la jaula cuyo dígito no se repita
+ val cand = ArrayList<Int>()
+ for (cl in celdas) for (n in ortog(cl)) if (asign[n] == -1 && sol[n] !in usados) cand.add(n)
+ if (cand.isEmpty()) break
+ val n = cand[rng.nextInt(cand.size)]
+ asign[n] = cages.size; celdas.add(n); usados.add(sol[n])
+ }
+ cages.add(Cage(celdas.toIntArray(), celdas.sumOf { sol[it] }))
}
+ return cages
+ }
+
+ // ---- Utilidades ---------------------------------------------------
+
+ private 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() }
+ }
+
+ /** Reglas con solo peers (allowed lleno, sin binarios ni jaulas): para generar la solución. */
+ private fun soloPeers(peers: Array<IntArray>) =
+ Reglas(peers, IntArray(81) { TODOS }, Array(81) { emptyList<BinPeer>() }, Array(81) { emptyList<Cage>() }, emptyList())
+
+ /** Vecinos ortogonales (arriba/abajo/izq/der) de una celda. */
+ private fun ortog(i: Int): IntArray {
+ val r = i / 9; val c = i % 9
+ val out = ArrayList<Int>(4)
+ if (r > 0) out.add(i - 9); if (r < 8) out.add(i + 9)
+ if (c > 0) out.add(i - 1); if (c < 8) out.add(i + 1)
+ return out.toIntArray()
+ }
+
+ /** Recorre cada par adyacente una vez (derecha y abajo), opcionalmente solo dentro de caja. */
+ private inline fun adyacentes(soloOrtogonales: Boolean, mismaCaja: Boolean = false, accion: (Int, Int) -> Unit) {
+ for (r in 0 until 9) for (c in 0 until 9) {
+ val a = r * 9 + c
+ if (c < 8) { val b = a + 1; if (!mismaCaja || c % 3 != 2) accion(a, b) }
+ if (r < 8) { val b = a + 9; if (!mismaCaja || r % 3 != 2) accion(a, b) }
+ }
+ }
+
+ 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
+ for (ch in s) h = 31 * h + ch.code
+ return h
}
private fun digitsOf(mask: Int): IntArray {
@@ -161,9 +373,10 @@ object Motor {
}
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
- }
+ for (i in a.indices.reversed()) { val j = rng.nextInt(i + 1); val t = a[i]; a[i] = a[j]; a[j] = t }
+ }
+
+ private fun shuffle2(a: MutableList<Int>, 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
index 723aa65..f7e72c8 100644
--- a/nonet/src/main/java/com/rizoma/nonet/model/Variant.kt
+++ b/nonet/src/main/java/com/rizoma/nonet/model/Variant.kt
@@ -1,102 +1,123 @@
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.
+ * Variantes de Sudoku. Todas parten del clásico (filas, columnas y 9 regiones con
+ * 1-9). Según su [tipo], el motor añade: grupos extra de unicidad, regiones
+ * irregulares (jigsaw), una máscara de paridad por celda (par/impar), relaciones
+ * entre celdas vecinas (consecutivo, XV, mayor-que) o jaulas con suma (killer).
*
- * 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.
+ * Catálogo de https://sudoku-puzzles.net/es/variantes-de-sudoku/.
+ * Índice de celda = fila*9 + columna.
*/
enum class Variant(
val id: String,
val titulo: String,
val regla: String,
+ val tipo: Tipo,
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,
+ Tipo.REGION, Realce.NINGUNO,
),
X(
"x", "Sudoku X",
"Además del clásico, las dos diagonales principales también llevan 1-9 sin repetir.",
- Realce.DIAGONALES,
+ Tipo.REGION, Realce.DIAGONALES,
),
WINDOKU(
"windoku", "Windoku",
"Cuatro cajas 3×3 sombreadas (las 'ventanas') también deben contener 1-9 sin repetir.",
- Realce.CELDAS,
+ Tipo.REGION, 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,
+ Tipo.REGION, Realce.POSICION,
),
PUNTO_CENTRAL(
"centro", "Punto central",
"Las nueve celdas centrales de cada caja forman un grupo 1-9 adicional.",
- Realce.CELDAS,
+ Tipo.REGION, Realce.CELDAS,
),
ASTERISCO(
"asterisco", "Asterisco",
"Nueve celdas en patrón de estrella alrededor del centro forman un grupo 1-9.",
- Realce.CELDAS,
+ Tipo.REGION, Realce.CELDAS,
),
GIRANDOLA(
"girandola", "Girándola",
"Nueve celdas en molinillo (pinwheel) a través del centro forman un grupo 1-9.",
- Realce.CELDAS,
+ Tipo.REGION, Realce.CELDAS,
+ ),
+ JIGSAW(
+ "jigsaw", "Jigsaw",
+ "Las cajas no son 3×3: son nueve regiones irregulares, cada una con 1-9.",
+ Tipo.JIGSAW, Realce.REGIONES,
+ ),
+ PAR_IMPAR(
+ "parimpar", "Par / impar",
+ "Las casillas sombreadas (cuadradas) llevan un dígito par; las demás, impar.",
+ Tipo.PARIDAD, Realce.PARIDAD,
+ ),
+ CONSECUTIVO(
+ "consecutivo", "Consecutivo",
+ "Una barra entre dos casillas indica dígitos consecutivos; sin barra, no lo son.",
+ Tipo.CONSEC, Realce.ARISTAS,
+ ),
+ XV(
+ "xv", "Sudoku XV",
+ "Una X entre dos casillas indica que suman 10; una V, que suman 5; sin marca, ninguno.",
+ Tipo.XV, Realce.ARISTAS,
+ ),
+ MAYOR_QUE(
+ "mayor", "Mayor que",
+ "Los signos < y > entre casillas vecinas de una caja indican cuál dígito es mayor.",
+ Tipo.MAYOR, Realce.ARISTAS,
+ ),
+ KILLER(
+ "killer", "Killer",
+ "Cada jaula punteada suma el número indicado y no repite dígitos.",
+ Tipo.KILLER, Realce.JAULAS,
);
+ /** Familia de la variante: determina qué restricciones añade el motor. */
+ enum class Tipo { REGION, JIGSAW, PARIDAD, CONSEC, XV, MAYOR, KILLER }
+
/** Cómo se dibuja la restricción extra en el tablero. */
- enum class Realce { NINGUNO, DIAGONALES, CELDAS, POSICION }
+ enum class Realce { NINGUNO, DIAGONALES, CELDAS, POSICION, REGIONES, PARIDAD, ARISTAS, JAULAS }
/**
- * 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.
+ * Grupos extra (cada uno, 9 índices) de las variantes de "región extra".
+ * Las demás familias derivan sus restricciones de la solución (en el motor).
*/
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
+ IntArray(9) { it * 9 + it },
+ IntArray(9) { it * 9 + (8 - it) },
)
WINDOKU -> WINDOWS.map { (r0, c0) -> box3x3(r0, c0) }
- DG -> (0 until 9).map { p -> // p = posición dentro de la caja
+ DG -> (0 until 9).map { p ->
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)
- }
+ IntArray(9) { b -> ((b / 3) * 3 + pr) * 9 + ((b % 3) * 3 + pc) }
}
- PUNTO_CENTRAL -> listOf(
- IntArray(9) { b -> (((b / 3) * 3 + 1) * 9) + ((b % 3) * 3 + 1) },
- )
+ 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))
+ else -> emptyList()
}
- /** Las celdas resaltadas (unión de los grupos extra), para sombrearlas. */
+ /** Celdas resaltadas (unión de grupos extra) para las variantes de tipo CELDAS. */
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.
+ fun box3x3(r0: Int, c0: Int): IntArray = IntArray(9) { i -> (r0 + i / 3) * 9 + (c0 + i % 3) }
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 }
+ fun cells(rc: List<Pair<Int, Int>>): IntArray = IntArray(rc.size) { rc[it].first * 9 + rc[it].second }
}
}
diff --git a/nonet/src/main/java/com/rizoma/nonet/ui/BoardScreen.kt b/nonet/src/main/java/com/rizoma/nonet/ui/BoardScreen.kt
index e13e862..109e6cc 100644
--- a/nonet/src/main/java/com/rizoma/nonet/ui/BoardScreen.kt
+++ b/nonet/src/main/java/com/rizoma/nonet/ui/BoardScreen.kt
@@ -1,5 +1,7 @@
package com.rizoma.nonet.ui
+import android.graphics.Paint
+import android.graphics.Typeface
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@@ -30,10 +32,15 @@ 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.graphics.PathEffect
+import androidx.compose.ui.graphics.nativeCanvas
+import androidx.compose.ui.graphics.toArgb
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.game.Puzzle
+import com.rizoma.nonet.game.Rel
import com.rizoma.nonet.model.Variant
import com.rizoma.nonet.ui.theme.MuyTenue
import com.rizoma.nonet.ui.theme.NeonAmbar
@@ -45,6 +52,8 @@ import com.rizoma.nonet.ui.theme.SuperficieAlta
import com.rizoma.nonet.ui.theme.Tenue
import com.rizoma.nonet.ui.theme.Vacio
+private const val PARES = (1 shl 2) or (1 shl 4) or (1 shl 6) or (1 shl 8)
+
@Composable
fun BoardScreen(vm: NonetViewModel) {
Column(
@@ -55,13 +64,10 @@ fun BoardScreen(vm: NonetViewModel) {
.verticalScroll(rememberScrollState())
.padding(14.dp),
) {
- // Barra superior.
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(
"‹ menú",
- color = Tenue,
- fontSize = 14.sp,
- fontWeight = FontWeight.Medium,
+ color = Tenue, fontSize = 14.sp, fontWeight = FontWeight.Medium,
modifier = Modifier.clip(RoundedCornerShape(50)).clickable { vm.volverAlMenu() }
.padding(vertical = 6.dp, horizontal = 2.dp),
)
@@ -103,14 +109,20 @@ fun BoardScreen(vm: NonetViewModel) {
@Composable
private fun Tablero(vm: NonetViewModel) {
+ val p = vm.puzzle ?: return
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 relacionadas = if (foco in 0..80) p.peers[foco].toHashSet() else emptySet<Int>()
val conflictos = vm.conflictos()
val errores = vm.errores
+ // Paint reutilizable para los glifos del Canvas (X, V, < >, sumas de jaula).
+ val paint = remember {
+ Paint().apply { isAntiAlias = true; typeface = Typeface.DEFAULT_BOLD; textAlign = Paint.Align.CENTER }
+ }
+
Box(Modifier.fillMaxWidth().aspectRatio(1f).clip(RoundedCornerShape(8.dp)).background(Superficie)) {
Column(Modifier.fillMaxSize()) {
for (r in 0 until 9) {
@@ -127,31 +139,130 @@ private fun Tablero(vm: NonetViewModel) {
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),
+ sombra = sombraDe(i, p, variante, sombreadas),
onClick = { vm.tocarCelda(i) },
)
}
}
}
}
- // Líneas de la rejilla (+ diagonales del Sudoku X) por encima.
+ // Overlay: líneas de regiones/cajas, diagonales, marcas de aristas y jaulas.
Canvas(Modifier.fillMaxSize()) {
val cell = size.width / 9f
+ fun cx(c: Int) = (c + 0.5f) * cell
+ fun cy(r: Int) = (r + 0.5f) * cell
+
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)
}
+
+ // Líneas finas de todas las celdas.
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)
+ drawLine(MuyTenue.copy(alpha = 0.4f), Offset(k * cell, 0f), Offset(k * cell, size.height), 1f)
+ drawLine(MuyTenue.copy(alpha = 0.4f), Offset(0f, k * cell), Offset(size.width, k * cell), 1f)
+ }
+ // Bordes gruesos: regiones del jigsaw, o cajas 3×3.
+ val reg = p.regiones
+ if (reg != null) {
+ for (r in 0 until 9) for (c in 0 until 9) {
+ val i = r * 9 + c
+ if (c < 8 && reg[i] != reg[i + 1]) drawLine(Tenue, Offset((c + 1) * cell, r * cell), Offset((c + 1) * cell, (r + 1) * cell), 3f)
+ if (r < 8 && reg[i] != reg[i + 9]) drawLine(Tenue, Offset(c * cell, (r + 1) * cell), Offset((c + 1) * cell, (r + 1) * cell), 3f)
+ }
+ drawLine(Tenue, Offset(0f, 0f), Offset(size.width, 0f), 3f)
+ drawLine(Tenue, Offset(0f, size.height), Offset(size.width, size.height), 3f)
+ drawLine(Tenue, Offset(0f, 0f), Offset(0f, size.height), 3f)
+ drawLine(Tenue, Offset(size.width, 0f), Offset(size.width, size.height), 3f)
+ } else {
+ for (k in 0..9 step 3) {
+ drawLine(Tenue, Offset(k * cell, 0f), Offset(k * cell, size.height), 3f)
+ drawLine(Tenue, Offset(0f, k * cell), Offset(size.width, k * cell), 3f)
+ }
+ }
+
+ // Jaulas (Killer): borde punteado + suma en la celda superior izquierda.
+ if (p.reglas.cages.isNotEmpty()) {
+ val cageId = IntArray(81) { -1 }
+ p.reglas.cages.forEachIndexed { id, cg -> for (cl in cg.cells) cageId[cl] = id }
+ val dash = PathEffect.dashPathEffect(floatArrayOf(5f, 5f))
+ val ins = cell * 0.10f
+ for (rr in 0 until 9) for (cc in 0 until 9) {
+ val i = rr * 9 + cc
+ val id = cageId[i]
+ val l = cc * cell + ins; val rt = (cc + 1) * cell - ins
+ val t = rr * cell + ins; val b = (rr + 1) * cell - ins
+ if (cc == 0 || cageId[i - 1] != id) drawLine(NeonAmbar, Offset(l, t), Offset(l, b), 1.5f, pathEffect = dash)
+ if (cc == 8 || cageId[i + 1] != id) drawLine(NeonAmbar, Offset(rt, t), Offset(rt, b), 1.5f, pathEffect = dash)
+ if (rr == 0 || cageId[i - 9] != id) drawLine(NeonAmbar, Offset(l, t), Offset(rt, t), 1.5f, pathEffect = dash)
+ if (rr == 8 || cageId[i + 9] != id) drawLine(NeonAmbar, Offset(l, b), Offset(rt, b), 1.5f, pathEffect = dash)
+ }
+ paint.color = NeonAmbar.toArgb()
+ paint.textAlign = Paint.Align.LEFT
+ paint.textSize = cell * 0.26f
+ for (cg in p.reglas.cages) {
+ val top = cg.cells.min()
+ drawIntoCanvas2(this) { nc ->
+ nc.drawText("${cg.sum}", (top % 9) * cell + ins + 2f, (top / 9) * cell + ins + cell * 0.24f, paint)
+ }
+ }
+ paint.textAlign = Paint.Align.CENTER
+ }
+
+ // Marcas de aristas (consecutivo / XV / mayor-que).
+ if (p.aristas.isNotEmpty()) {
+ paint.textSize = cell * 0.42f
+ for (a in p.aristas) {
+ val ra = a.a / 9; val ca = a.a % 9
+ val horizontal = a.b == a.a + 1
+ val mx = if (horizontal) (ca + 1) * cell else cx(ca)
+ val my = if (horizontal) cy(ra) else (ra + 1) * cell
+ when (a.rel) {
+ Rel.CONSEC -> {
+ // Barra blanca perpendicular a la arista.
+ val h = cell * 0.16f
+ if (horizontal) drawLine(Color.White, Offset(mx, my - h), Offset(mx, my + h), 4f)
+ else drawLine(Color.White, Offset(mx - h, my), Offset(mx + h, my), 4f)
+ }
+ Rel.SUM5, Rel.SUM10 -> {
+ drawCircle(Vacio, cell * 0.16f, Offset(mx, my))
+ paint.color = NeonCian.toArgb()
+ glifo(this, paint, if (a.rel == Rel.SUM10) "X" else "V", mx, my)
+ }
+ Rel.LT, Rel.GT -> {
+ drawCircle(Vacio, cell * 0.16f, Offset(mx, my))
+ paint.color = NeonVerde.toArgb()
+ val s = when {
+ horizontal && a.rel == Rel.LT -> "‹"
+ horizontal -> "›"
+ a.rel == Rel.LT -> "˄"
+ else -> "˅"
+ }
+ glifo(this, paint, s, mx, my)
+ }
+ else -> {}
+ }
+ }
}
}
}
}
+/** Dibuja [s] centrado verticalmente en (x,y) con el [paint] dado. */
+private fun glifo(scope: androidx.compose.ui.graphics.drawscope.DrawScope, paint: Paint, s: String, x: Float, y: Float) {
+ drawIntoCanvas2(scope) { nc ->
+ val dy = (paint.descent() + paint.ascent()) / 2f
+ nc.drawText(s, x, y - dy, paint)
+ }
+}
+
+private inline fun drawIntoCanvas2(
+ scope: androidx.compose.ui.graphics.drawscope.DrawScope,
+ block: (android.graphics.Canvas) -> Unit,
+) {
+ block(scope.drawContext.canvas.nativeCanvas)
+}
+
@Composable
private fun Celda(
modifier: Modifier,
@@ -176,11 +287,7 @@ private fun Celda(
when {
valor != 0 -> Text(
"$valor",
- color = when {
- errorRojo -> NeonRosa
- fija -> Color.White
- else -> NeonCian
- },
+ color = when { errorRojo -> NeonRosa; fija -> Color.White; else -> NeonCian },
fontSize = 18.sp,
fontWeight = if (fija) FontWeight.Bold else FontWeight.Medium,
)
@@ -189,7 +296,6 @@ private fun Celda(
}
}
-/** 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)) {
@@ -213,50 +319,46 @@ private fun NotasMini(notas: Int, activo: Int) {
}
}
-private fun sombraDe(i: Int, v: Variant, sombreadas: Set<Int>): Color = when (v.realce) {
+private fun sombraDe(i: Int, p: Puzzle, 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)
+ val pos = ((i / 9) % 3) * 3 + (i % 9) % 3
+ Color.hsv(pos * 40f, 0.55f, 0.9f).copy(alpha = 0.10f)
}
+ // Par/impar: las celdas "par" (cuadradas) van sombreadas; las impar, limpias.
+ Variant.Realce.PARIDAD -> if (p.reglas.allowed[i] == PARES) Color.White.copy(alpha = 0.08f) else Color.Transparent
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() }
+ // Columnas: [undo/redo/comprobar] · 3×3 números · [edit/borrar/pista]
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
+ Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) {
+ Accion("↶", "deshacer", NeonCian, vm.puedeDeshacer) { vm.deshacer() }
+ Accion("↷", "rehacer", NeonCian, vm.puedeRehacer) { vm.rehacerJugada() }
+ Accion("♥", "comprobar", NeonVerde, !vm.completado) { 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) }
+ Column(Modifier.weight(3f), verticalArrangement = Arrangement.spacedBy(6.dp)) {
+ 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)) {
+ Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Accion(
"✎", if (vm.modoEdicion) "edit ✓" else "edit",
- if (vm.modoEdicion) NeonAmbar else Tenue, true, Modifier.weight(1f),
- resaltado = vm.modoEdicion,
+ if (vm.modoEdicion) NeonAmbar else Tenue, true, 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() }
+ Accion("⌫", "borrar", NeonRosa, true, resaltado = vm.activo == NonetViewModel.BORRAR) { vm.activarBorrar() }
+ Accion("◌", "pista", NeonAmbar, !vm.completado) { vm.pista() }
}
}
}
@@ -273,17 +375,9 @@ private fun DigitoKey(d: Int, restantes: Int, activo: Boolean, modifier: Modifie
.clickable(onClick = onClick),
contentAlignment = Alignment.Center,
) {
+ Text("$d", color = if (agotado) MuyTenue else NeonCian, fontSize = 22.sp, fontWeight = FontWeight.SemiBold)
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,
+ "$restantes", color = MuyTenue, fontSize = 9.sp,
modifier = Modifier.align(Alignment.BottomEnd).padding(end = 5.dp, bottom = 2.dp),
)
}
@@ -295,13 +389,13 @@ private fun Accion(
label: String,
accent: Color,
habilitado: Boolean,
- modifier: Modifier,
resaltado: Boolean = false,
onClick: () -> Unit,
) {
Box(
- modifier
- .height(46.dp)
+ Modifier
+ .fillMaxWidth()
+ .height(48.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))
@@ -311,10 +405,8 @@ private fun Accion(
) {
Text(icono, color = accent, fontSize = 16.sp)
Text(
- label,
- color = accent,
- fontSize = 9.sp,
- modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 3.dp),
+ label, color = accent, fontSize = 8.sp,
+ modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 2.dp),
)
}
}