⎇ 6d5368c0b298161a61a974eb0c2905c0ec0d183f
nonet/src/main/java/com/rizoma/nonet/ui/BoardScreen.kt
Ñawpa willay
ch'uya
Nonet: 6 variantes nuevas (jigsaw, par/impar, consecutivo, XV, mayor-que, killer)
1package com.rizoma.nonet.ui
2
3import android.graphics.Paint
4import android.graphics.Typeface
5import androidx.compose.foundation.Canvas
6import androidx.compose.foundation.background
7import androidx.compose.foundation.border
8import androidx.compose.foundation.clickable
9import androidx.compose.foundation.layout.Arrangement
10import androidx.compose.foundation.layout.Box
11import androidx.compose.foundation.layout.Column
12import androidx.compose.foundation.layout.Row
13import androidx.compose.foundation.layout.Spacer
14import androidx.compose.foundation.layout.WindowInsets
15import androidx.compose.foundation.layout.aspectRatio
16import androidx.compose.foundation.layout.fillMaxHeight
17import androidx.compose.foundation.layout.fillMaxSize
18import androidx.compose.foundation.layout.fillMaxWidth
19import androidx.compose.foundation.layout.height
20import androidx.compose.foundation.layout.padding
21import androidx.compose.foundation.layout.systemBars
22import androidx.compose.foundation.layout.windowInsetsPadding
23import androidx.compose.foundation.rememberScrollState
24import androidx.compose.foundation.shape.RoundedCornerShape
25import androidx.compose.foundation.verticalScroll
26import androidx.compose.material3.Text
27import androidx.compose.runtime.Composable
28import androidx.compose.runtime.remember
29import androidx.compose.ui.Alignment
30import androidx.compose.ui.Modifier
31import androidx.compose.ui.draw.alpha
32import androidx.compose.ui.draw.clip
33import androidx.compose.ui.geometry.Offset
34import androidx.compose.ui.graphics.Color
35import androidx.compose.ui.graphics.PathEffect
36import androidx.compose.ui.graphics.nativeCanvas
37import androidx.compose.ui.graphics.toArgb
38import androidx.compose.ui.text.font.FontWeight
39import androidx.compose.ui.unit.dp
40import androidx.compose.ui.unit.sp
41import com.rizoma.nonet.NonetViewModel
42import com.rizoma.nonet.game.Puzzle
43import com.rizoma.nonet.game.Rel
44import com.rizoma.nonet.model.Variant
45import com.rizoma.nonet.ui.theme.MuyTenue
46import com.rizoma.nonet.ui.theme.NeonAmbar
47import com.rizoma.nonet.ui.theme.NeonCian
48import com.rizoma.nonet.ui.theme.NeonRosa
49import com.rizoma.nonet.ui.theme.NeonVerde
50import com.rizoma.nonet.ui.theme.Superficie
51import com.rizoma.nonet.ui.theme.SuperficieAlta
52import com.rizoma.nonet.ui.theme.Tenue
53import com.rizoma.nonet.ui.theme.Vacio
54
55private const val PARES = (1 shl 2) or (1 shl 4) or (1 shl 6) or (1 shl 8)
56
57@Composable
58fun BoardScreen(vm: NonetViewModel) {
59 Column(
60 Modifier
61 .fillMaxSize()
62 .background(Vacio)
63 .windowInsetsPadding(WindowInsets.systemBars)
64 .verticalScroll(rememberScrollState())
65 .padding(14.dp),
66 ) {
67 Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
68 Text(
69 "‹ menú",
70 color = Tenue, fontSize = 14.sp, fontWeight = FontWeight.Medium,
71 modifier = Modifier.clip(RoundedCornerShape(50)).clickable { vm.volverAlMenu() }
72 .padding(vertical = 6.dp, horizontal = 2.dp),
73 )
74 Spacer(Modifier.weight(1f))
75 Text(vm.variante.titulo, color = Color.White, fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
76 Text(" · ${vm.dificultad.titulo}", color = MuyTenue, fontSize = 13.sp)
77 }
78
79 Spacer(Modifier.height(10.dp))
80 Text(
81 when {
82 vm.completado -> "¡resuelto!"
83 vm.errores.isNotEmpty() -> "${vm.errores.size} por revisar"
84 else -> "elige un número y toca las celdas"
85 },
86 color = when {
87 vm.completado -> NeonVerde
88 vm.errores.isNotEmpty() -> NeonRosa
89 else -> MuyTenue
90 },
91 fontSize = 13.sp,
92 fontWeight = if (vm.completado) FontWeight.Bold else FontWeight.Normal,
93 )
94 Spacer(Modifier.height(10.dp))
95
96 if (vm.generando || vm.puzzle == null) {
97 Box(Modifier.fillMaxWidth().height(300.dp), contentAlignment = Alignment.Center) {
98 Text("generando…", color = NeonCian, fontSize = 16.sp)
99 }
100 return@Column
101 }
102
103 Tablero(vm)
104 Spacer(Modifier.height(14.dp))
105 Teclado(vm)
106 Spacer(Modifier.height(16.dp))
107 }
108}
109
110@Composable
111private fun Tablero(vm: NonetViewModel) {
112 val p = vm.puzzle ?: return
113 val variante = vm.variante
114 val sombreadas = remember(variante) { variante.highlightCells() }
115 val activo = vm.activo
116 val foco = vm.celdaFoco
117 val relacionadas = if (foco in 0..80) p.peers[foco].toHashSet() else emptySet<Int>()
118 val conflictos = vm.conflictos()
119 val errores = vm.errores
120
121 // Paint reutilizable para los glifos del Canvas (X, V, < >, sumas de jaula).
122 val paint = remember {
123 Paint().apply { isAntiAlias = true; typeface = Typeface.DEFAULT_BOLD; textAlign = Paint.Align.CENTER }
124 }
125
126 Box(Modifier.fillMaxWidth().aspectRatio(1f).clip(RoundedCornerShape(8.dp)).background(Superficie)) {
127 Column(Modifier.fillMaxSize()) {
128 for (r in 0 until 9) {
129 Row(Modifier.fillMaxWidth().weight(1f)) {
130 for (c in 0 until 9) {
131 val i = r * 9 + c
132 Celda(
133 modifier = Modifier.weight(1f).fillMaxHeight(),
134 valor = vm.entradas[i],
135 notas = vm.notas[i],
136 activo = activo,
137 fija = vm.esFija(i),
138 foco = i == foco,
139 relacionada = i in relacionadas,
140 mismoDigito = activo in 1..9 && vm.entradas[i] == activo,
141 errorRojo = i in conflictos || i in errores,
142 sombra = sombraDe(i, p, variante, sombreadas),
143 onClick = { vm.tocarCelda(i) },
144 )
145 }
146 }
147 }
148 }
149 // Overlay: líneas de regiones/cajas, diagonales, marcas de aristas y jaulas.
150 Canvas(Modifier.fillMaxSize()) {
151 val cell = size.width / 9f
152 fun cx(c: Int) = (c + 0.5f) * cell
153 fun cy(r: Int) = (r + 0.5f) * cell
154
155 if (variante == Variant.X) {
156 drawLine(NeonRosa.copy(alpha = 0.30f), Offset(0f, 0f), Offset(size.width, size.height), 3f)
157 drawLine(NeonRosa.copy(alpha = 0.30f), Offset(size.width, 0f), Offset(0f, size.height), 3f)
158 }
159
160 // Líneas finas de todas las celdas.
161 for (k in 0..9) {
162 drawLine(MuyTenue.copy(alpha = 0.4f), Offset(k * cell, 0f), Offset(k * cell, size.height), 1f)
163 drawLine(MuyTenue.copy(alpha = 0.4f), Offset(0f, k * cell), Offset(size.width, k * cell), 1f)
164 }
165 // Bordes gruesos: regiones del jigsaw, o cajas 3×3.
166 val reg = p.regiones
167 if (reg != null) {
168 for (r in 0 until 9) for (c in 0 until 9) {
169 val i = r * 9 + c
170 if (c < 8 && reg[i] != reg[i + 1]) drawLine(Tenue, Offset((c + 1) * cell, r * cell), Offset((c + 1) * cell, (r + 1) * cell), 3f)
171 if (r < 8 && reg[i] != reg[i + 9]) drawLine(Tenue, Offset(c * cell, (r + 1) * cell), Offset((c + 1) * cell, (r + 1) * cell), 3f)
172 }
173 drawLine(Tenue, Offset(0f, 0f), Offset(size.width, 0f), 3f)
174 drawLine(Tenue, Offset(0f, size.height), Offset(size.width, size.height), 3f)
175 drawLine(Tenue, Offset(0f, 0f), Offset(0f, size.height), 3f)
176 drawLine(Tenue, Offset(size.width, 0f), Offset(size.width, size.height), 3f)
177 } else {
178 for (k in 0..9 step 3) {
179 drawLine(Tenue, Offset(k * cell, 0f), Offset(k * cell, size.height), 3f)
180 drawLine(Tenue, Offset(0f, k * cell), Offset(size.width, k * cell), 3f)
181 }
182 }
183
184 // Jaulas (Killer): borde punteado + suma en la celda superior izquierda.
185 if (p.reglas.cages.isNotEmpty()) {
186 val cageId = IntArray(81) { -1 }
187 p.reglas.cages.forEachIndexed { id, cg -> for (cl in cg.cells) cageId[cl] = id }
188 val dash = PathEffect.dashPathEffect(floatArrayOf(5f, 5f))
189 val ins = cell * 0.10f
190 for (rr in 0 until 9) for (cc in 0 until 9) {
191 val i = rr * 9 + cc
192 val id = cageId[i]
193 val l = cc * cell + ins; val rt = (cc + 1) * cell - ins
194 val t = rr * cell + ins; val b = (rr + 1) * cell - ins
195 if (cc == 0 || cageId[i - 1] != id) drawLine(NeonAmbar, Offset(l, t), Offset(l, b), 1.5f, pathEffect = dash)
196 if (cc == 8 || cageId[i + 1] != id) drawLine(NeonAmbar, Offset(rt, t), Offset(rt, b), 1.5f, pathEffect = dash)
197 if (rr == 0 || cageId[i - 9] != id) drawLine(NeonAmbar, Offset(l, t), Offset(rt, t), 1.5f, pathEffect = dash)
198 if (rr == 8 || cageId[i + 9] != id) drawLine(NeonAmbar, Offset(l, b), Offset(rt, b), 1.5f, pathEffect = dash)
199 }
200 paint.color = NeonAmbar.toArgb()
201 paint.textAlign = Paint.Align.LEFT
202 paint.textSize = cell * 0.26f
203 for (cg in p.reglas.cages) {
204 val top = cg.cells.min()
205 drawIntoCanvas2(this) { nc ->
206 nc.drawText("${cg.sum}", (top % 9) * cell + ins + 2f, (top / 9) * cell + ins + cell * 0.24f, paint)
207 }
208 }
209 paint.textAlign = Paint.Align.CENTER
210 }
211
212 // Marcas de aristas (consecutivo / XV / mayor-que).
213 if (p.aristas.isNotEmpty()) {
214 paint.textSize = cell * 0.42f
215 for (a in p.aristas) {
216 val ra = a.a / 9; val ca = a.a % 9
217 val horizontal = a.b == a.a + 1
218 val mx = if (horizontal) (ca + 1) * cell else cx(ca)
219 val my = if (horizontal) cy(ra) else (ra + 1) * cell
220 when (a.rel) {
221 Rel.CONSEC -> {
222 // Barra blanca perpendicular a la arista.
223 val h = cell * 0.16f
224 if (horizontal) drawLine(Color.White, Offset(mx, my - h), Offset(mx, my + h), 4f)
225 else drawLine(Color.White, Offset(mx - h, my), Offset(mx + h, my), 4f)
226 }
227 Rel.SUM5, Rel.SUM10 -> {
228 drawCircle(Vacio, cell * 0.16f, Offset(mx, my))
229 paint.color = NeonCian.toArgb()
230 glifo(this, paint, if (a.rel == Rel.SUM10) "X" else "V", mx, my)
231 }
232 Rel.LT, Rel.GT -> {
233 drawCircle(Vacio, cell * 0.16f, Offset(mx, my))
234 paint.color = NeonVerde.toArgb()
235 val s = when {
236 horizontal && a.rel == Rel.LT -> "‹"
237 horizontal -> "›"
238 a.rel == Rel.LT -> "˄"
239 else -> "˅"
240 }
241 glifo(this, paint, s, mx, my)
242 }
243 else -> {}
244 }
245 }
246 }
247 }
248 }
249}
250
251/** Dibuja [s] centrado verticalmente en (x,y) con el [paint] dado. */
252private fun glifo(scope: androidx.compose.ui.graphics.drawscope.DrawScope, paint: Paint, s: String, x: Float, y: Float) {
253 drawIntoCanvas2(scope) { nc ->
254 val dy = (paint.descent() + paint.ascent()) / 2f
255 nc.drawText(s, x, y - dy, paint)
256 }
257}
258
259private inline fun drawIntoCanvas2(
260 scope: androidx.compose.ui.graphics.drawscope.DrawScope,
261 block: (android.graphics.Canvas) -> Unit,
262) {
263 block(scope.drawContext.canvas.nativeCanvas)
264}
265
266@Composable
267private fun Celda(
268 modifier: Modifier,
269 valor: Int,
270 notas: Int,
271 activo: Int,
272 fija: Boolean,
273 foco: Boolean,
274 relacionada: Boolean,
275 mismoDigito: Boolean,
276 errorRojo: Boolean,
277 sombra: Color,
278 onClick: () -> Unit,
279) {
280 val bg = when {
281 foco -> NeonCian.copy(alpha = 0.32f)
282 mismoDigito -> NeonCian.copy(alpha = 0.16f)
283 relacionada -> Color.White.copy(alpha = 0.05f)
284 else -> sombra
285 }
286 Box(modifier.background(bg).clickable(onClick = onClick), contentAlignment = Alignment.Center) {
287 when {
288 valor != 0 -> Text(
289 "$valor",
290 color = when { errorRojo -> NeonRosa; fija -> Color.White; else -> NeonCian },
291 fontSize = 18.sp,
292 fontWeight = if (fija) FontWeight.Bold else FontWeight.Medium,
293 )
294 notas != 0 -> NotasMini(notas, activo)
295 }
296 }
297}
298
299@Composable
300private fun NotasMini(notas: Int, activo: Int) {
301 Column(Modifier.fillMaxSize().padding(1.dp)) {
302 for (fila in 0 until 3) {
303 Row(Modifier.fillMaxWidth().weight(1f)) {
304 for (col in 0 until 3) {
305 val d = fila * 3 + col + 1
306 Box(Modifier.weight(1f).fillMaxHeight(), contentAlignment = Alignment.Center) {
307 if (notas and (1 shl d) != 0) {
308 Text(
309 "$d",
310 color = if (d == activo) NeonCian else MuyTenue,
311 fontSize = 8.sp,
312 fontWeight = if (d == activo) FontWeight.Bold else FontWeight.Normal,
313 )
314 }
315 }
316 }
317 }
318 }
319 }
320}
321
322private fun sombraDe(i: Int, p: Puzzle, v: Variant, sombreadas: Set<Int>): Color = when (v.realce) {
323 Variant.Realce.CELDAS -> if (i in sombreadas) NeonAmbar.copy(alpha = 0.13f) else Color.Transparent
324 Variant.Realce.POSICION -> {
325 val pos = ((i / 9) % 3) * 3 + (i % 9) % 3
326 Color.hsv(pos * 40f, 0.55f, 0.9f).copy(alpha = 0.10f)
327 }
328 // Par/impar: las celdas "par" (cuadradas) van sombreadas; las impar, limpias.
329 Variant.Realce.PARIDAD -> if (p.reglas.allowed[i] == PARES) Color.White.copy(alpha = 0.08f) else Color.Transparent
330 else -> Color.Transparent
331}
332
333@Composable
334private fun Teclado(vm: NonetViewModel) {
335 val restantes = IntArray(10)
336 for (v in vm.entradas) if (v in 1..9) restantes[v]++
337
338 // Columnas: [undo/redo/comprobar] · 3×3 números · [edit/borrar/pista]
339 Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
340 Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) {
341 Accion("↶", "deshacer", NeonCian, vm.puedeDeshacer) { vm.deshacer() }
342 Accion("↷", "rehacer", NeonCian, vm.puedeRehacer) { vm.rehacerJugada() }
343 Accion("♥", "comprobar", NeonVerde, !vm.completado) { vm.comprobar() }
344 }
345 Column(Modifier.weight(3f), verticalArrangement = Arrangement.spacedBy(6.dp)) {
346 for (fila in 0 until 3) {
347 Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
348 for (col in 0 until 3) {
349 val d = fila * 3 + col + 1
350 DigitoKey(d, 9 - restantes[d], vm.activo == d, Modifier.weight(1f)) { vm.elegirActivo(d) }
351 }
352 }
353 }
354 }
355 Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) {
356 Accion(
357 "✎", if (vm.modoEdicion) "edit ✓" else "edit",
358 if (vm.modoEdicion) NeonAmbar else Tenue, true, resaltado = vm.modoEdicion,
359 ) { vm.alternarEdicion() }
360 Accion("⌫", "borrar", NeonRosa, true, resaltado = vm.activo == NonetViewModel.BORRAR) { vm.activarBorrar() }
361 Accion("◌", "pista", NeonAmbar, !vm.completado) { vm.pista() }
362 }
363 }
364}
365
366@Composable
367private fun DigitoKey(d: Int, restantes: Int, activo: Boolean, modifier: Modifier, onClick: () -> Unit) {
368 val agotado = restantes <= 0
369 Box(
370 modifier
371 .height(48.dp)
372 .clip(RoundedCornerShape(12.dp))
373 .background(if (activo) NeonCian.copy(alpha = 0.20f) else SuperficieAlta)
374 .border(1.dp, if (activo) NeonCian else Color.Transparent, RoundedCornerShape(12.dp))
375 .clickable(onClick = onClick),
376 contentAlignment = Alignment.Center,
377 ) {
378 Text("$d", color = if (agotado) MuyTenue else NeonCian, fontSize = 22.sp, fontWeight = FontWeight.SemiBold)
379 Text(
380 "$restantes", color = MuyTenue, fontSize = 9.sp,
381 modifier = Modifier.align(Alignment.BottomEnd).padding(end = 5.dp, bottom = 2.dp),
382 )
383 }
384}
385
386@Composable
387private fun Accion(
388 icono: String,
389 label: String,
390 accent: Color,
391 habilitado: Boolean,
392 resaltado: Boolean = false,
393 onClick: () -> Unit,
394) {
395 Box(
396 Modifier
397 .fillMaxWidth()
398 .height(48.dp)
399 .clip(RoundedCornerShape(12.dp))
400 .background(if (resaltado) accent.copy(alpha = 0.18f) else SuperficieAlta)
401 .border(1.dp, if (resaltado) accent else Color.Transparent, RoundedCornerShape(12.dp))
402 .alpha(if (habilitado) 1f else 0.35f)
403 .clickable(enabled = habilitado, onClick = onClick),
404 contentAlignment = Alignment.Center,
405 ) {
406 Text(icono, color = accent, fontSize = 16.sp)
407 Text(
408 label, color = accent, fontSize = 8.sp,
409 modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 2.dp),
410 )
411 }
412}