Nonet: 3 variantes de pista externa, dificultad real y menú en páginas
Variantes nuevas (16 en total): Rascacielos (visibilidad desde el borde), Little Killer (sumas en diagonales) y Rossini (orden de las 3 celdas del borde). El motor suma restricciones de visibilidad y diagonales-suma, con marco de pistas alrededor del tablero. Dificultad de verdad: fácil/medio se resuelven solo con lógica (singles), y difícil/experto permiten razonamiento avanzado dejando muchas menos pistas (23-30 vs 34-44). Verificado para las 16 variantes × 4 dificultades. Menú rediseñado: pager horizontal arrastrable, una página por variante que dibuja su forma particular (diagonales, ventanas, jaulas, flechas, etc.) con su selector de dificultad y el historial de puzzles generados/cumplidos. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Semantic delta
No definition changed: this change is text, documentation or data.
5 files · +467 −174
- nonet/src/main/java/com/rizoma/nonet/NonetViewModel.kt +4 −4
- nonet/src/main/java/com/rizoma/nonet/game/Motor.kt +195 −101
- nonet/src/main/java/com/rizoma/nonet/model/Variant.kt +28 −8
- nonet/src/main/java/com/rizoma/nonet/ui/BoardScreen.kt +56 −2
- nonet/src/main/java/com/rizoma/nonet/ui/MenuScreen.kt +184 −59
diff --git a/nonet/src/main/java/com/rizoma/nonet/NonetViewModel.kt b/nonet/src/main/java/com/rizoma/nonet/NonetViewModel.kt
index 4aaffae..7fbbab6 100644
--- a/nonet/src/main/java/com/rizoma/nonet/NonetViewModel.kt
+++ b/nonet/src/main/java/com/rizoma/nonet/NonetViewModel.kt
@@ -81,11 +81,11 @@ class NonetViewModel(app: Application) : AndroidViewModel(app) {
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() {
+ /** Genera el siguiente puzzle de (variante, dificultad) y lo abre. */
+ fun generarNuevo(v: Variant = variante, d: Dificultad = dificultad) {
viewModelScope.launch {
- val index = progreso.generarSiguiente(variante.id, dificultad.ordinal)
- abrir(variante, dificultad, index)
+ val index = progreso.generarSiguiente(v.id, d.ordinal)
+ abrir(v, d, index)
}
}
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 3468fc8..faed257 100644
--- a/nonet/src/main/java/com/rizoma/nonet/game/Motor.kt
+++ b/nonet/src/main/java/com/rizoma/nonet/game/Motor.kt
@@ -3,31 +3,43 @@ package com.rizoma.nonet.game
import com.rizoma.nonet.model.Dificultad
import com.rizoma.nonet.model.Variant
import java.util.Random
+import kotlin.math.abs
-/** Relación entre dos celdas vecinas (consecutivo, sumas XV, desigualdad). */
+/** Relación entre dos celdas vecinas (consecutivo, sumas XV, desigualdad, orden Rossini). */
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)
+/** Jaula/diagonal con suma. [noRepite] true en Killer; false en Little Killer (admite repes). */
+class Cage(val cells: IntArray, val sum: Int, val noRepite: Boolean = true)
+
+/** Restricción de visibilidad (rascacielos): por [cells] en orden de mirada, se ven [k]. */
+class Vis(val cells: IntArray, val k: Int)
/** Marca a dibujar entre dos celdas adyacentes [a]-[b] (orientada para < >). */
class Arista(val a: Int, val b: Int, val rel: Rel)
-/**
- * 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).
- */
+/** Pistas del borde, para dibujar el marco de las variantes con pistas externas. */
+class Borde(
+ val sky: Array<IntArray>? = null, // [lado 0=arr,1=ab,2=izq,3=der][9]; 0 = sin pista
+ val rossini: Array<IntArray>? = null, // [lado][9]; 0=nada, 1=creciente hacia dentro, 2=decreciente
+ val littleKiller: List<LK> = emptyList(), // flechas diagonales ancladas arriba
+)
+
+/** Flecha de Little Killer anclada sobre la columna [col]; dir 0=↘, 1=↗; suma de su diagonal. */
+class LK(val col: Int, val dir: Int, val sum: Int)
+
+/** Todas las restricciones de un puzzle, resueltas para el solver. */
class Reglas(
+ val groups: List<IntArray>,
val peers: Array<IntArray>,
val allowed: IntArray,
val bin: Array<List<BinPeer>>,
val cagesOf: Array<List<Cage>>,
val cages: List<Cage>,
+ val visOf: Array<List<Vis>>,
+ val vis: List<Vis>,
)
/** Un puzzle generado: pistas, solución única, reglas y datos para dibujar. */
@@ -40,6 +52,7 @@ class Puzzle(
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)
+ val borde: Borde?, // pistas externas (rascacielos/little killer/rossini)
) {
val peers: Array<IntArray> get() = reglas.peers
val id: String get() = "${variant.id}:${dificultad.ordinal}:$index"
@@ -47,11 +60,12 @@ class Puzzle(
/**
* 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.
+ * regiones de la variante, deriva de ella sus restricciones (paridad, marcas,
+ * jaulas, pistas de borde) y cava celdas mientras la solución siga siendo única;
+ * en fácil/medio, además, exigiendo que se resuelva solo con lógica de singles.
*/
object Motor {
- private const val TODOS = 0x3FE // bits 1..9
+ private const val TODOS = 0x3FE
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)
@@ -65,139 +79,208 @@ object Motor {
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, soloPeers(peersOf(baseGroups)), rng)
+ fillFull(solution, soloPeers(baseGroups), rng)
- val (reglas, aristas) = construir(v, solution, baseGroups, rng)
+ val construido = construir(v, solution, baseGroups, rng)
val givens = solution.copyOf()
- dig(givens, reglas, dif.pistasObjetivo, rng)
+ dig(givens, construido.reglas, dif.pistasObjetivo, dif.soloLogica, rng)
- return Puzzle(v, dif, index, givens, solution, reglas, regiones, aristas)
+ return Puzzle(v, dif, index, givens, solution, construido.reglas, regiones, construido.aristas, construido.borde)
}
- /** Cuenta soluciones de [grid] bajo [reglas] hasta [limite]. */
fun contarSoluciones(grid: IntArray, reglas: Reglas, limite: Int): Int =
count(grid.copyOf(), reglas, limite)
+ /** Una distribución jigsaw fija (para ilustrar la variante en el menú). */
+ fun jigsawEjemplo(): IntArray = jigsaw(Random(7))
+
// ---- Construcción de reglas por variante --------------------------
- private fun construir(
- v: Variant,
- sol: IntArray,
- baseGroups: List<IntArray>,
- rng: Random,
- ): Pair<Reglas, List<Arista>> {
+ private class Construido(val reglas: Reglas, val aristas: List<Arista>, val borde: Borde?)
+
+ private fun construir(v: Variant, sol: IntArray, baseGroups: List<IntArray>, rng: Random): Construido {
val allowed = IntArray(81) { TODOS }
val bin = Array(81) { ArrayList<BinPeer>() }
val aristas = ArrayList<Arista>()
var cages = emptyList<Cage>()
+ var vis = emptyList<Vis>()
var groups = baseGroups
+ var borde: Borde? = null
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))
+ Variant.Tipo.CONSEC -> adyacentes(true) { a, b ->
+ val consec = abs(sol[a] - sol[b]) == 1
+ val rel = if (consec) Rel.CONSEC else Rel.NO_CONSEC
+ bin[a].add(BinPeer(b, rel)); bin[b].add(BinPeer(a, rel))
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 }
+ Variant.Tipo.XV -> adyacentes(true) { a, b ->
+ val rel = when (sol[a] + sol[b]) { 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.
+ Variant.Tipo.MAYOR -> adyacentes(true, mismaCaja = true) { 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))
+ 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))
+ 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
+ Variant.Tipo.SKY -> {
+ val vlist = ArrayList<Vis>()
+ val top = IntArray(9); val bot = IntArray(9); val izq = IntArray(9); val der = IntArray(9)
+ for (i in 0 until 9) {
+ val fila = IntArray(9) { i * 9 + it }
+ val col = IntArray(9) { it * 9 + i }
+ izq[i] = visibles(fila.map { sol[it] }); vlist.add(Vis(fila, izq[i]))
+ val filaR = fila.reversedArray(); der[i] = visibles(filaR.map { sol[it] }); vlist.add(Vis(filaR, der[i]))
+ top[i] = visibles(col.map { sol[it] }); vlist.add(Vis(col, top[i]))
+ val colR = col.reversedArray(); bot[i] = visibles(colR.map { sol[it] }); vlist.add(Vis(colR, bot[i]))
+ }
+ vis = vlist
+ borde = Borde(sky = arrayOf(top, bot, izq, der))
+ }
+
+ Variant.Tipo.LITTLE -> {
+ val arrows = ArrayList<LK>()
+ val cs = ArrayList<Cage>()
+ // ↘ desde columnas pares, ↗ desde columnas impares (ancladas arriba).
+ for (c in 0 until 9) {
+ val dir = if (c % 2 == 0) 0 else 1
+ val cells = ArrayList<Int>()
+ var rr = 0; var cc = c
+ while (rr in 0..8 && cc in 0..8) { cells.add(rr * 9 + cc); rr++; cc += if (dir == 0) 1 else -1 }
+ if (cells.size >= 3) {
+ val suma = cells.sumOf { sol[it] }
+ cs.add(Cage(cells.toIntArray(), suma, noRepite = false))
+ arrows.add(LK(c, dir, suma))
+ }
+ }
+ cages = cs
+ borde = Borde(littleKiller = arrows)
+ }
+
+ Variant.Tipo.ROSSINI -> {
+ val ros = Array(4) { IntArray(9) }
+ // lado 0=arr (mira hacia abajo), 1=ab (hacia arriba), 2=izq (derecha), 3=der (izquierda)
+ for (i in 0 until 9) {
+ triple(0, i)?.let { t -> rossiniMarca(sol, t, bin, aristasNo = true)?.let { ros[0][i] = it } }
+ triple(1, i)?.let { t -> rossiniMarca(sol, t, bin)?.let { ros[1][i] = it } }
+ triple(2, i)?.let { t -> rossiniMarca(sol, t, bin)?.let { ros[2][i] = it } }
+ triple(3, i)?.let { t -> rossiniMarca(sol, t, bin)?.let { ros[3][i] = it } }
+ }
+ borde = Borde(rossini = ros)
+ }
+
+ else -> {}
}
val peers = peersOf(groups)
val cagesOf = Array(81) { ArrayList<Cage>() }
for (c in cages) for (i in c.cells) cagesOf[i].add(c)
+ val visOf = Array(81) { ArrayList<Vis>() }
+ for (vz in vis) for (i in vz.cells) visOf[i].add(vz)
+
val reglas = Reglas(
+ groups = groups,
peers = peers,
allowed = allowed,
bin = Array(81) { bin[it] },
cagesOf = Array(81) { cagesOf[it] },
cages = cages,
+ visOf = Array(81) { visOf[it] },
+ vis = vis,
)
- return reglas to aristas
+ return Construido(reglas, aristas, borde)
}
- // ---- Solver (peers + allowed + binarios + jaulas) -----------------
+ /** Las 3 celdas más cercanas a un lado, en orden hacia dentro. */
+ private fun triple(lado: Int, i: Int): IntArray? = when (lado) {
+ 0 -> intArrayOf(i, 9 + i, 18 + i) // columna i desde arriba
+ 1 -> intArrayOf(72 + i, 63 + i, 54 + i) // columna i desde abajo
+ 2 -> intArrayOf(i * 9, i * 9 + 1, i * 9 + 2) // fila i desde izquierda
+ 3 -> intArrayOf(i * 9 + 8, i * 9 + 7, i * 9 + 6) // fila i desde derecha
+ else -> null
+ }
+
+ /** Si el triple es estrictamente monótono, añade los binarios y devuelve 1/2 (cre/dec). */
+ private fun rossiniMarca(sol: IntArray, t: IntArray, bin: Array<ArrayList<BinPeer>>, aristasNo: Boolean = false): Int? {
+ val a = sol[t[0]]; val b = sol[t[1]]; val c = sol[t[2]]
+ val creciente = a < b && b < c
+ val decreciente = a > b && b > c
+ if (!creciente && !decreciente) return null
+ val rel = if (creciente) Rel.LT else Rel.GT
+ val inv = if (creciente) Rel.GT else Rel.LT
+ bin[t[0]].add(BinPeer(t[1], rel)); bin[t[1]].add(BinPeer(t[0], inv))
+ bin[t[1]].add(BinPeer(t[2], rel)); bin[t[2]].add(BinPeer(t[1], inv))
+ return if (creciente) 1 else 2
+ }
+
+ private fun visibles(linea: List<Int>): Int {
+ var max = 0; var n = 0
+ for (v in linea) if (v > max) { max = v; n++ }
+ return n
+ }
+
+ // ---- Solver -------------------------------------------------------
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)
- }
+ 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
- var d = 1
+ var m = 0; 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.CONSEC -> abs(d - e) == 1
+ Rel.NO_CONSEC -> 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++
+ if (ok) m = m or (1 shl d); d++
}
return m
}
- /** ¿Las jaulas que tocan [c] siguen siendo viables tras colocar un dígito? */
- private fun cagesOk(grid: IntArray, c: Int, reglas: Reglas): Boolean {
+ /** Tras colocar un dígito en [c], ¿siguen viables sus jaulas y visibilidades? */
+ private fun restriccionesOk(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 (faltan == 0) { if (suma != cage.sum) return false } else {
if (suma + faltan > cage.sum) return false
- var maxAdd = 0; var d = 9
- repeat(faltan) { maxAdd += d; d-- }
+ val maxAdd = if (cage.noRepite) { var s = 0; var d = 9; repeat(faltan) { s += d; d-- }; s } else faltan * 9
if (suma + maxAdd < cage.sum) return false
}
}
+ for (vz in reglas.visOf[c]) {
+ if (vz.cells.all { grid[it] != 0 }) {
+ if (visibles(vz.cells.map { grid[it] }) != vz.k) return false
+ }
+ }
return true
}
@@ -208,7 +291,7 @@ object Motor {
val digits = digitsOf(mask).also { shuffle(it, rng) }
for (d in digits) {
grid[idx] = d
- if (cagesOk(grid, idx, reglas) && fillFull(grid, reglas, rng)) return true
+ if (restriccionesOk(grid, idx, reglas) && fillFull(grid, reglas, rng)) return true
grid[idx] = 0
}
return false
@@ -218,12 +301,11 @@ object Motor {
val (idx, mask) = mrv(grid, reglas)
if (idx == RESUELTO) return 1
if (idx == SIN_SALIDA) return 0
- var total = 0
- var d = 1
+ var total = 0; var d = 1
while (d <= 9) {
if (mask and (1 shl d) != 0) {
grid[idx] = d
- if (cagesOk(grid, idx, reglas)) total += count(grid, reglas, limite - total)
+ if (restriccionesOk(grid, idx, reglas)) total += count(grid, reglas, limite - total)
grid[idx] = 0
if (total >= limite) break
}
@@ -244,18 +326,45 @@ object Motor {
return bestIdx to bestMask
}
- private fun dig(grid: IntArray, reglas: Reglas, pistasObjetivo: Int, rng: Random) {
+ /** ¿Se resuelve solo con singles (desnudos + ocultos)? Mide la dificultad "lógica". */
+ private fun resolublePorSingles(base: IntArray, reglas: Reglas): Boolean {
+ val grid = base.copyOf()
+ while (true) {
+ var avance = false
+ for (c in 0 until 81) if (grid[c] == 0) {
+ val m = candMask(grid, c, reglas)
+ if (m == 0) return false
+ if (Integer.bitCount(m) == 1) { grid[c] = 31 - Integer.numberOfLeadingZeros(m); avance = true }
+ }
+ for (grp in reglas.groups) {
+ var d = 1
+ while (d <= 9) {
+ if (grp.none { grid[it] == d }) {
+ var unico = -1; var cuenta = 0
+ for (cell in grp) if (grid[cell] == 0 && candMask(grid, cell, reglas) and (1 shl d) != 0) { unico = cell; cuenta++ }
+ if (cuenta == 1) { grid[unico] = d; avance = true }
+ }
+ d++
+ }
+ }
+ if (grid.none { it == 0 }) return true
+ if (!avance) return false
+ }
+ }
+
+ private fun dig(grid: IntArray, reglas: Reglas, objetivo: Int, soloLogica: Boolean, rng: Random) {
val order = IntArray(81) { it }.also { shuffle(it, rng) }
var pistas = 81
for (c in order) {
- if (pistas <= pistasObjetivo) break
+ if (pistas <= objetivo) break
val saved = grid[c]
grid[c] = 0
- if (count(grid.copyOf(), reglas, 2) != 1) grid[c] = saved else pistas--
+ val ok = count(grid.copyOf(), reglas, 2) == 1 && (!soloLogica || resolublePorSingles(grid, reglas))
+ if (!ok) grid[c] = saved else pistas--
}
}
- // ---- Regiones -----------------------------------------------------
+ // ---- Regiones / jaulas --------------------------------------------
private fun boxes(): List<IntArray> = (0 until 9).map { b ->
val r0 = (b / 3) * 3; val c0 = (b % 3) * 3
@@ -265,31 +374,22 @@ object Motor {
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 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
+ 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
- }
+ 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--
+ reg[libres[rng.nextInt(libres.size)]] = 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
+ return IntArray(81) { (it / 9 / 3) * 3 + (it % 9 / 3) }
}
private fun tieneVecinoLibre(reg: IntArray, id: Int): Boolean {
@@ -303,27 +403,23 @@ object Motor {
return s.toIntArray()
}
- // ---- Jaulas (Killer) ----------------------------------------------
-
private fun jaulas(sol: IntArray, rng: Random): List<Cage> {
- val asign = IntArray(81) { -1 }
- val cages = ArrayList<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 objetivo = 2 + rng.nextInt(3)
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] }))
+ cages.add(Cage(celdas.toIntArray(), celdas.sumOf { sol[it] }, noRepite = true))
}
return cages
}
@@ -336,25 +432,24 @@ object Motor {
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())
+ private fun soloPeers(groups: List<IntArray>) = Reglas(
+ groups, peersOf(groups), IntArray(81) { TODOS },
+ Array(81) { emptyList<BinPeer>() }, Array(81) { emptyList<Cage>() }, emptyList(),
+ Array(81) { emptyList<Vis>() }, 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)
+ 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) }
+ if (c < 8 && (!mismaCaja || c % 3 != 2)) accion(a, a + 1)
+ if (r < 8 && (!mismaCaja || r % 3 != 2)) accion(a, a + 9)
}
}
@@ -366,8 +461,7 @@ object Motor {
}
private fun digitsOf(mask: Int): IntArray {
- val out = ArrayList<Int>(9)
- var d = 1
+ 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()
}
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 f7e72c8..286049f 100644
--- a/nonet/src/main/java/com/rizoma/nonet/model/Variant.kt
+++ b/nonet/src/main/java/com/rizoma/nonet/model/Variant.kt
@@ -80,13 +80,28 @@ enum class Variant(
"killer", "Killer",
"Cada jaula punteada suma el número indicado y no repite dígitos.",
Tipo.KILLER, Realce.JAULAS,
+ ),
+ SKYSCRAPER(
+ "skyscraper", "Rascacielos",
+ "Las pistas del borde dicen cuántos 'rascacielos' se ven desde ese lado (los más altos tapan a los más bajos).",
+ Tipo.SKY, Realce.BORDE,
+ ),
+ LITTLE_KILLER(
+ "littlekiller", "Little Killer",
+ "Cada flecha diagonal indica la suma de todos los dígitos de su diagonal (pueden repetirse).",
+ Tipo.LITTLE, Realce.BORDE,
+ ),
+ ROSSINI(
+ "rossini", "Rossini",
+ "Una flecha en el borde indica que las tres casillas más cercanas van en orden creciente hacia donde apunta.",
+ Tipo.ROSSINI, Realce.BORDE,
);
/** Familia de la variante: determina qué restricciones añade el motor. */
- enum class Tipo { REGION, JIGSAW, PARIDAD, CONSEC, XV, MAYOR, KILLER }
+ enum class Tipo { REGION, JIGSAW, PARIDAD, CONSEC, XV, MAYOR, KILLER, SKY, LITTLE, ROSSINI }
/** Cómo se dibuja la restricción extra en el tablero. */
- enum class Realce { NINGUNO, DIAGONALES, CELDAS, POSICION, REGIONES, PARIDAD, ARISTAS, JAULAS }
+ enum class Realce { NINGUNO, DIAGONALES, CELDAS, POSICION, REGIONES, PARIDAD, ARISTAS, JAULAS, BORDE }
/**
* Grupos extra (cada uno, 9 índices) de las variantes de "región extra".
@@ -121,10 +136,15 @@ enum class Variant(
}
}
-/** 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),
+/**
+ * Las cuatro dificultades. [pistasObjetivo] es hasta cuántas pistas cavar;
+ * [soloLogica] exige que el puzzle se resuelva por singles (sin adivinar), lo
+ * que hace fáciles a fácil/medio. Difícil/experto permiten razonamiento avanzado
+ * y dejan muchas menos pistas: por eso son de verdad más difíciles.
+ */
+enum class Dificultad(val titulo: String, val pistasObjetivo: Int, val soloLogica: Boolean) {
+ FACIL("fácil", 44, true),
+ MEDIO("medio", 34, true),
+ DIFICIL("difícil", 30, false),
+ EXPERTO("experto", 23, false),
}
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 109e6cc..5215f05 100644
--- a/nonet/src/main/java/com/rizoma/nonet/ui/BoardScreen.kt
+++ b/nonet/src/main/java/com/rizoma/nonet/ui/BoardScreen.kt
@@ -118,12 +118,15 @@ private fun Tablero(vm: NonetViewModel) {
val conflictos = vm.conflictos()
val errores = vm.errores
- // Paint reutilizable para los glifos del Canvas (X, V, < >, sumas de jaula).
+ // Paint reutilizable para los glifos del Canvas (X, V, < >, sumas, pistas de borde).
val paint = remember {
Paint().apply { isAntiAlias = true; typeface = Typeface.DEFAULT_BOLD; textAlign = Paint.Align.CENTER }
}
+ // Las variantes con pistas externas reservan un margen alrededor del tablero.
+ val mg = if (p.borde != null) 24.dp else 0.dp
- Box(Modifier.fillMaxWidth().aspectRatio(1f).clip(RoundedCornerShape(8.dp)).background(Superficie)) {
+ Box(Modifier.fillMaxWidth().aspectRatio(1f)) {
+ Box(Modifier.fillMaxSize().padding(mg).clip(RoundedCornerShape(8.dp)).background(Superficie)) {
Column(Modifier.fillMaxSize()) {
for (r in 0 until 9) {
Row(Modifier.fillMaxWidth().weight(1f)) {
@@ -245,6 +248,57 @@ private fun Tablero(vm: NonetViewModel) {
}
}
}
+ }
+ // Marco de pistas externas: rascacielos (números), little killer y rossini (flechas).
+ if (p.borde != null) {
+ Canvas(Modifier.fillMaxSize()) {
+ val m = mg.toPx()
+ val inner = size.width - 2 * m
+ val cell = inner / 9f
+ fun gx(c: Int) = m + (c + 0.5f) * cell
+ fun gy(r: Int) = m + (r + 0.5f) * cell
+ val b = p.borde ?: return@Canvas
+ b.sky?.let { sky ->
+ paint.color = NeonCian.toArgb(); paint.textSize = cell * 0.40f
+ for (i in 0 until 9) {
+ if (sky[0][i] > 0) glifo(this, paint, "${sky[0][i]}", gx(i), m * 0.5f)
+ if (sky[1][i] > 0) glifo(this, paint, "${sky[1][i]}", gx(i), size.height - m * 0.5f)
+ if (sky[2][i] > 0) glifo(this, paint, "${sky[2][i]}", m * 0.5f, gy(i))
+ if (sky[3][i] > 0) glifo(this, paint, "${sky[3][i]}", size.width - m * 0.5f, gy(i))
+ }
+ }
+ b.rossini?.let { ros ->
+ paint.color = NeonVerde.toArgb(); paint.textSize = cell * 0.5f
+ for (i in 0 until 9) {
+ flecha(ros[0][i], 0)?.let { glifo(this, paint, it, gx(i), m * 0.5f) }
+ flecha(ros[1][i], 1)?.let { glifo(this, paint, it, gx(i), size.height - m * 0.5f) }
+ flecha(ros[2][i], 2)?.let { glifo(this, paint, it, m * 0.5f, gy(i)) }
+ flecha(ros[3][i], 3)?.let { glifo(this, paint, it, size.width - m * 0.5f, gy(i)) }
+ }
+ }
+ if (b.littleKiller.isNotEmpty()) {
+ paint.color = NeonAmbar.toArgb()
+ for (lk in b.littleKiller) {
+ paint.textSize = cell * 0.5f
+ glifo(this, paint, if (lk.dir == 0) "↘" else "↗", gx(lk.col), m * 0.34f)
+ paint.textSize = cell * 0.30f
+ glifo(this, paint, "${lk.sum}", gx(lk.col), m * 0.74f)
+ }
+ }
+ }
+ }
+ }
+}
+
+/** Flecha de Rossini para un lado (0=arr,1=ab,2=izq,3=der): apunta hacia el orden creciente. */
+private fun flecha(estado: Int, lado: Int): String? {
+ if (estado == 0) return null
+ val creciente = estado == 1
+ return when (lado) {
+ 0 -> if (creciente) "↓" else "↑"
+ 1 -> if (creciente) "↑" else "↓"
+ 2 -> if (creciente) "→" else "←"
+ else -> if (creciente) "←" else "→"
}
}
diff --git a/nonet/src/main/java/com/rizoma/nonet/ui/MenuScreen.kt b/nonet/src/main/java/com/rizoma/nonet/ui/MenuScreen.kt
index 0cefdf5..165bb91 100644
--- a/nonet/src/main/java/com/rizoma/nonet/ui/MenuScreen.kt
+++ b/nonet/src/main/java/com/rizoma/nonet/ui/MenuScreen.kt
@@ -1,9 +1,11 @@
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
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
@@ -16,27 +18,38 @@ 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.pager.HorizontalPager
+import androidx.compose.foundation.pager.rememberPagerState
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.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.drawscope.DrawScope
+import androidx.compose.ui.graphics.drawscope.Stroke
+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.Motor
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.NeonRosa
import com.rizoma.nonet.ui.theme.NeonVerde
import com.rizoma.nonet.ui.theme.SuperficieAlta
import com.rizoma.nonet.ui.theme.Tenue
@@ -44,95 +57,214 @@ import com.rizoma.nonet.ui.theme.Vacio
@Composable
fun MenuScreen(vm: NonetViewModel) {
+ val variantes = remember { Variant.entries }
+ val pager = rememberPagerState(initialPage = variantes.indexOf(vm.variante)) { variantes.size }
+ // La página visible fija la variante activa (para "nuevo" y la lista).
+ LaunchedEffect(pager.currentPage) { vm.elegirVariante(variantes[pager.currentPage]) }
+
Column(
- Modifier
- .fillMaxSize()
- .background(Vacio)
- .windowInsetsPadding(WindowInsets.systemBars)
- .verticalScroll(rememberScrollState())
- .padding(horizontal = 22.dp),
+ Modifier.fillMaxSize().background(Vacio).windowInsetsPadding(WindowInsets.systemBars),
) {
- 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))
+ Spacer(Modifier.height(20.dp))
+ Row(Modifier.fillMaxWidth().padding(horizontal = 22.dp), verticalAlignment = Alignment.CenterVertically) {
+ Column {
+ Text("nonet", color = Color.White, fontSize = 30.sp, fontWeight = FontWeight.Bold)
+ Text("desliza para ver cada variante", color = MuyTenue, fontSize = 12.sp)
+ }
+ Spacer(Modifier.weight(1f))
+ Text("${pager.currentPage + 1}/${variantes.size}", color = MuyTenue, fontSize = 13.sp)
+ }
+ // Puntos de página.
Row(
- Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
- horizontalArrangement = Arrangement.spacedBy(8.dp),
+ Modifier.fillMaxWidth().padding(horizontal = 22.dp, vertical = 10.dp),
+ horizontalArrangement = Arrangement.spacedBy(5.dp),
) {
- Variant.entries.forEach { v ->
- Chip(v.titulo, selected = v == vm.variante, accent = NeonCian) { vm.elegirVariante(v) }
+ variantes.indices.forEach { idx ->
+ val on = idx == pager.currentPage
+ Box(Modifier.size(if (on) 8.dp else 6.dp).clip(CircleShape).background(if (on) NeonCian else MuyTenue))
}
}
- Spacer(Modifier.height(8.dp))
- Text(vm.variante.regla, color = Tenue, fontSize = 12.sp)
- Spacer(Modifier.height(20.dp))
+ HorizontalPager(state = pager, modifier = Modifier.weight(1f)) { page ->
+ Pagina(vm, variantes[page])
+ }
+ }
+}
- // Selector de dificultad.
+@Composable
+private fun Pagina(vm: NonetViewModel, v: Variant) {
+ Column(
+ Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 22.dp),
+ ) {
+ Spacer(Modifier.height(8.dp))
+ Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
+ FormaVariante(v, Modifier.size(168.dp))
+ }
+ Spacer(Modifier.height(14.dp))
+ Text(v.titulo, color = Color.White, fontSize = 22.sp, fontWeight = FontWeight.Bold)
+ Spacer(Modifier.height(4.dp))
+ Text(v.regla, color = Tenue, fontSize = 13.sp)
+
+ Spacer(Modifier.height(18.dp))
Text("DIFICULTAD", color = MuyTenue, fontSize = 11.sp, letterSpacing = 3.sp)
Spacer(Modifier.height(8.dp))
- Row(
- Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.spacedBy(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)
- }
+ Chip(d.titulo, d == vm.dificultad, NeonVerde, fill = true) { vm.elegirDificultad(d) }
}
}
}
- Spacer(Modifier.height(24.dp))
-
- // Puzzles generados de la selección actual.
- val n = vm.cuenta()
+ Spacer(Modifier.height(20.dp))
+ val n = vm.cuenta(v, vm.dificultad)
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))
+ Spacer(Modifier.height(10.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 id = vm.puzzleId(v, 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) }
+ PuzzleNode(idx + 1, estado) { vm.abrir(v, vm.dificultad, idx) }
}
}
repeat(5 - fila.size) { Spacer(Modifier.weight(1f)) }
}
}
-
- Spacer(Modifier.height(16.dp))
+ Spacer(Modifier.height(14.dp))
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
- Chip("✦ nuevo puzzle", selected = false, accent = NeonCian) { vm.generarNuevo() }
+ Chip("✦ nuevo puzzle", false, NeonCian) { vm.generarNuevo(v, vm.dificultad) }
}
Spacer(Modifier.height(40.dp))
}
}
+/** Dibujo demostrativo de la forma particular de cada variante. */
+@Composable
+private fun FormaVariante(v: Variant, modifier: Modifier) {
+ val paint = remember {
+ Paint().apply { isAntiAlias = true; typeface = Typeface.DEFAULT_BOLD; textAlign = Paint.Align.CENTER }
+ }
+ val regionesEj = remember(v) { if (v.tipo == Variant.Tipo.JIGSAW) Motor.jigsawEjemplo() else null }
+ Box(modifier.clip(RoundedCornerShape(10.dp)).background(SuperficieAlta).padding(10.dp)) {
+ Canvas(Modifier.fillMaxSize()) {
+ val cell = size.width / 9f
+ fun cx(c: Int) = (c + 0.5f) * cell
+ fun cy(r: Int) = (r + 0.5f) * cell
+ val amber = NeonAmbar.copy(alpha = 0.30f)
+
+ // Sombreados que van DEBAJO de la rejilla.
+ when (v.realce) {
+ Variant.Realce.CELDAS -> for (i in v.highlightCells()) drawRect(amber, Offset((i % 9) * cell, (i / 9) * cell), androidx.compose.ui.geometry.Size(cell, cell))
+ Variant.Realce.POSICION -> for (i in 0 until 81) {
+ val pos = ((i / 9) % 3) * 3 + (i % 9) % 3
+ drawRect(Color.hsv(pos * 40f, 0.55f, 0.9f).copy(alpha = 0.16f), Offset((i % 9) * cell, (i / 9) * cell), androidx.compose.ui.geometry.Size(cell, cell))
+ }
+ Variant.Realce.PARIDAD -> for (i in 0 until 81) if ((i / 9 + i % 9) % 2 == 0)
+ drawRect(Color.White.copy(alpha = 0.10f), Offset((i % 9) * cell, (i / 9) * cell), androidx.compose.ui.geometry.Size(cell, cell))
+ else -> {}
+ }
+
+ // Rejilla fina.
+ for (k in 0..9) {
+ drawLine(MuyTenue.copy(alpha = 0.5f), Offset(k * cell, 0f), Offset(k * cell, size.height), 1f)
+ drawLine(MuyTenue.copy(alpha = 0.5f), Offset(0f, k * cell), Offset(size.width, k * cell), 1f)
+ }
+ // Regiones gruesas: jigsaw o cajas 3×3.
+ if (regionesEj != null) {
+ for (r in 0 until 9) for (c in 0 until 9) {
+ val i = r * 9 + c
+ if (c < 8 && regionesEj[i] != regionesEj[i + 1]) drawLine(Tenue, Offset((c + 1) * cell, r * cell), Offset((c + 1) * cell, (r + 1) * cell), 3f)
+ if (r < 8 && regionesEj[i] != regionesEj[i + 9]) drawLine(Tenue, Offset(c * cell, (r + 1) * cell), Offset((c + 1) * cell, (r + 1) * cell), 3f)
+ }
+ drawRect(Tenue, Offset(0f, 0f), androidx.compose.ui.geometry.Size(size.width, size.height), style = Stroke(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)
+ }
+ }
+
+ // Rasgo distintivo encima.
+ when (v) {
+ Variant.X -> {
+ drawLine(NeonRosa, Offset(0f, 0f), Offset(size.width, size.height), 3f)
+ drawLine(NeonRosa, Offset(size.width, 0f), Offset(0f, size.height), 3f)
+ }
+ Variant.CONSECUTIVO -> {
+ drawLine(Color.White, Offset(4 * cell, cy(4) - cell * 0.2f), Offset(4 * cell, cy(4) + cell * 0.2f), 5f)
+ }
+ Variant.XV -> {
+ paint.textSize = cell * 0.7f; paint.color = NeonCian.toArgb()
+ texto(this, paint, "X", 4 * cell, cy(3)); texto(this, paint, "V", 6 * cell, cy(5))
+ }
+ Variant.MAYOR_QUE -> {
+ paint.textSize = cell * 0.8f; paint.color = NeonVerde.toArgb()
+ texto(this, paint, "‹", 4 * cell, cy(4)); texto(this, paint, "˄", cx(5), 4 * cell)
+ }
+ Variant.KILLER -> {
+ val dash = PathEffect.dashPathEffect(floatArrayOf(5f, 5f))
+ val ins = cell * 0.12f
+ // jaula L sobre {(2,2),(2,3),(3,2)}
+ drawCage(this, listOf(2 to 2, 2 to 3, 3 to 2), cell, ins, dash)
+ paint.textSize = cell * 0.42f; paint.color = NeonAmbar.toArgb(); paint.textAlign = Paint.Align.LEFT
+ drawIntoCanvasM(this) { it.drawText("15", 2 * cell + ins + 2f, 2 * cell + ins + cell * 0.4f, paint) }
+ paint.textAlign = Paint.Align.CENTER
+ }
+ Variant.SKYSCRAPER -> {
+ paint.textSize = cell * 0.55f; paint.color = NeonCian.toArgb()
+ texto(this, paint, "2", cx(1), cy(0)); texto(this, paint, "3", cx(4), cy(0)); texto(this, paint, "1", cx(7), cy(0))
+ texto(this, paint, "2", cx(0), cy(3)); texto(this, paint, "4", cx(0), cy(6))
+ }
+ Variant.LITTLE_KILLER -> {
+ paint.textSize = cell * 0.6f; paint.color = NeonAmbar.toArgb()
+ texto(this, paint, "↘", cx(1), cy(0)); paint.textSize = cell * 0.45f
+ texto(this, paint, "17", cx(1), cy(1))
+ }
+ Variant.ROSSINI -> {
+ paint.textSize = cell * 0.7f; paint.color = NeonVerde.toArgb()
+ texto(this, paint, "↓", cx(3), cy(0)); texto(this, paint, "→", cx(0), cy(5))
+ }
+ else -> {}
+ }
+ }
+ }
+}
+
+private fun drawCage(scope: DrawScope, rc: List<Pair<Int, Int>>, cell: Float, ins: Float, dash: PathEffect) {
+ val set = rc.toHashSet()
+ for ((r, c) in rc) {
+ val l = c * cell + ins; val rt = (c + 1) * cell - ins; val t = r * cell + ins; val b = (r + 1) * cell - ins
+ if ((r to (c - 1)) !in set) scope.drawLine(NeonAmbar, Offset(l, t), Offset(l, b), 1.5f, pathEffect = dash)
+ if ((r to (c + 1)) !in set) scope.drawLine(NeonAmbar, Offset(rt, t), Offset(rt, b), 1.5f, pathEffect = dash)
+ if (((r - 1) to c) !in set) scope.drawLine(NeonAmbar, Offset(l, t), Offset(rt, t), 1.5f, pathEffect = dash)
+ if (((r + 1) to c) !in set) scope.drawLine(NeonAmbar, Offset(l, b), Offset(rt, b), 1.5f, pathEffect = dash)
+ }
+}
+
+private fun texto(scope: DrawScope, paint: Paint, s: String, x: Float, y: Float) {
+ drawIntoCanvasM(scope) {
+ val dy = (paint.descent() + paint.ascent()) / 2f
+ it.drawText(s, x, y - dy, paint)
+ }
+}
+
+private inline fun drawIntoCanvasM(scope: DrawScope, block: (android.graphics.Canvas) -> Unit) {
+ block(scope.drawContext.canvas.nativeCanvas)
+}
+
@Composable
-private fun Chip(
- text: String,
- selected: Boolean,
- accent: Color,
- fill: Boolean = false,
- onClick: () -> Unit,
-) {
+private fun Chip(text: String, selected: Boolean, accent: Color, fill: Boolean = false, onClick: () -> Unit) {
Box(
(if (fill) Modifier.fillMaxWidth() else Modifier)
.clip(RoundedCornerShape(50))
@@ -143,11 +275,8 @@ private fun Chip(
contentAlignment = Alignment.Center,
) {
Text(
- text,
- color = if (selected) accent else Tenue,
- fontSize = 14.sp,
- fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal,
- maxLines = 1,
+ text, color = if (selected) accent else Tenue, fontSize = 14.sp,
+ fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, maxLines = 1,
)
}
}
@@ -156,12 +285,8 @@ private fun Chip(
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),
+ 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)