Matiz: nuevo juego :matiz — puzzle de degradados de color (estilo I Love Hue/Blendoku)
Rejilla de baldosas de color plano: las esquinas (y algunas más en niveles fáciles) quedan fijas y el resto se baraja. Se arrastra una baldosa sobre otra para intercambiarlas hasta reconstruir el degradado 2D. Al resolver, el hueco entre baldosas y el redondeo se desvanecen: el degradado se vuelve continuo. - Niveles autogenerables y deterministas por seed (SplitMix64): el mismo número de nivel produce el mismo puzzle en cualquier dispositivo, así es compartible. - Interpolación bilineal en CIELab (no sRGB) para degradados perceptualmente suaves; esquinas sembradas en HSV con matiz horizontal y brillo vertical. - Dificultad creciente: la rejilla crece (3x3 → 6x8) y las baldosas fijas decrecen con el nivel. - Detección de resuelto por ΔE (tolera baldosas casi idénticas). - Progreso (nivel actual/máximo) persistido en DataStore. - Estética del rizoma: neón sobre vacío, háptica al intercambiar. 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.
17 archivos · +817 −0
- matiz/build.gradle.kts +50 −0
- matiz/src/main/AndroidManifest.xml +23 −0
- matiz/src/main/java/com/rizoma/matiz/MainActivity.kt +20 −0
- matiz/src/main/java/com/rizoma/matiz/MatizViewModel.kt +59 −0
- matiz/src/main/java/com/rizoma/matiz/color/ColorMath.kt +83 −0
- matiz/src/main/java/com/rizoma/matiz/data/Progreso.kt +33 −0
- matiz/src/main/java/com/rizoma/matiz/game/Level.kt +148 −0
- matiz/src/main/java/com/rizoma/matiz/game/Rng.kt +30 −0
- matiz/src/main/java/com/rizoma/matiz/ui/Board.kt +136 −0
- matiz/src/main/java/com/rizoma/matiz/ui/MatizApp.kt +164 −0
- matiz/src/main/java/com/rizoma/matiz/ui/theme/Theme.kt +36 −0
- matiz/src/main/res/drawable/ic_launcher_fg.xml +12 −0
- matiz/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +5 −0
- matiz/src/main/res/values/colors.xml +4 −0
- matiz/src/main/res/values/strings.xml +4 −0
- matiz/src/main/res/values/themes.xml +9 −0
- settings.gradle.kts +1 −0
diff --git a/matiz/build.gradle.kts b/matiz/build.gradle.kts
new file mode 100644
index 0000000..bf28a0e
--- /dev/null
+++ b/matiz/build.gradle.kts
@@ -0,0 +1,50 @@
+plugins {
+ alias(libs.plugins.android.application)
+ alias(libs.plugins.kotlin.android)
+ alias(libs.plugins.kotlin.compose)
+}
+
+android {
+ namespace = "com.rizoma.matiz"
+ compileSdk = 36
+
+ defaultConfig {
+ applicationId = "com.rizoma.matiz"
+ minSdk = 26
+ targetSdk = 36
+ versionCode = 1
+ versionName = "0.1.0"
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+ kotlinOptions {
+ jvmTarget = "17"
+ }
+ buildFeatures {
+ compose = true
+ }
+}
+
+dependencies {
+ implementation(libs.androidx.core.ktx)
+ implementation(libs.androidx.lifecycle.runtime.ktx)
+ implementation(libs.androidx.lifecycle.viewmodel.compose)
+ implementation(libs.androidx.activity.compose)
+ implementation(platform(libs.androidx.compose.bom))
+ implementation(libs.androidx.compose.ui)
+ implementation(libs.androidx.compose.ui.graphics)
+ implementation(libs.androidx.compose.ui.util)
+ implementation(libs.androidx.compose.foundation)
+ implementation(libs.androidx.compose.animation)
+ implementation(libs.androidx.compose.material3)
+ implementation(libs.androidx.datastore.preferences)
+ implementation(libs.kotlinx.coroutines.android)
+}
diff --git a/matiz/src/main/AndroidManifest.xml b/matiz/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..dbbed8b
--- /dev/null
+++ b/matiz/src/main/AndroidManifest.xml
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+
+ <!-- Juego offline: sin permisos de red. Los niveles se generan por seed. -->
+
+ <application
+ android:label="@string/app_name"
+ android:icon="@mipmap/ic_launcher"
+ android:theme="@style/Theme.Matiz"
+ android:supportsRtl="true">
+
+ <activity
+ android:name=".MainActivity"
+ android:exported="true"
+ android:launchMode="singleTask"
+ android:configChanges="orientation|screenSize|keyboard|keyboardHidden|uiMode">
+ <intent-filter>
+ <action android:name="android.intent.action.MAIN" />
+ <category android:name="android.intent.category.LAUNCHER" />
+ </intent-filter>
+ </activity>
+ </application>
+</manifest>
diff --git a/matiz/src/main/java/com/rizoma/matiz/MainActivity.kt b/matiz/src/main/java/com/rizoma/matiz/MainActivity.kt
new file mode 100644
index 0000000..4c28a69
--- /dev/null
+++ b/matiz/src/main/java/com/rizoma/matiz/MainActivity.kt
@@ -0,0 +1,20 @@
+package com.rizoma.matiz
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.core.view.WindowCompat
+import com.rizoma.matiz.ui.MatizApp
+import com.rizoma.matiz.ui.theme.MatizTheme
+
+class MainActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ WindowCompat.setDecorFitsSystemWindows(window, false)
+ setContent {
+ MatizTheme {
+ MatizApp()
+ }
+ }
+ }
+}
diff --git a/matiz/src/main/java/com/rizoma/matiz/MatizViewModel.kt b/matiz/src/main/java/com/rizoma/matiz/MatizViewModel.kt
new file mode 100644
index 0000000..8dcdb20
--- /dev/null
+++ b/matiz/src/main/java/com/rizoma/matiz/MatizViewModel.kt
@@ -0,0 +1,59 @@
+package com.rizoma.matiz
+
+import android.app.Application
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import com.rizoma.matiz.data.Progreso
+import com.rizoma.matiz.game.Levels
+import com.rizoma.matiz.game.Puzzle
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+
+class MatizViewModel(app: Application) : AndroidViewModel(app) {
+ private val progreso = Progreso(app)
+
+ var puzzle by mutableStateOf(Levels.generate(1)); private set
+ var moves by mutableIntStateOf(0); private set
+ var solved by mutableStateOf(false); private set
+
+ // Cambia en cada intercambio/recarga: como `arrangement` es un IntArray
+ // mutado in situ, este contador es lo que dispara el redibujado en Compose.
+ var epoch by mutableIntStateOf(0); private set
+
+ init {
+ viewModelScope.launch {
+ val n = progreso.nivel.first()
+ if (n != puzzle.level) load(n)
+ }
+ }
+
+ /** Intercambia dos baldosas si ninguna está fija. */
+ fun swap(a: Int, b: Int) {
+ if (solved || a == b) return
+ val p = puzzle
+ if (p.locked[a] || p.locked[b]) return
+ val t = p.arrangement[a]; p.arrangement[a] = p.arrangement[b]; p.arrangement[b] = t
+ moves++
+ epoch++
+ if (p.solved) {
+ solved = true
+ viewModelScope.launch { progreso.setNivel(p.level + 1) }
+ }
+ }
+
+ fun next() = load(puzzle.level + 1)
+ fun prev() = load(puzzle.level - 1)
+ fun retry() = load(puzzle.level)
+
+ private fun load(level: Int) {
+ puzzle = Levels.generate(level.coerceAtLeast(1))
+ moves = 0
+ solved = false
+ epoch++
+ viewModelScope.launch { progreso.setNivel(puzzle.level) }
+ }
+}
diff --git a/matiz/src/main/java/com/rizoma/matiz/color/ColorMath.kt b/matiz/src/main/java/com/rizoma/matiz/color/ColorMath.kt
new file mode 100644
index 0000000..1fc91eb
--- /dev/null
+++ b/matiz/src/main/java/com/rizoma/matiz/color/ColorMath.kt
@@ -0,0 +1,83 @@
+package com.rizoma.matiz.color
+
+import androidx.compose.ui.graphics.Color
+import kotlin.math.abs
+import kotlin.math.pow
+import kotlin.math.sqrt
+
+/**
+ * Color en espacio CIELab (D65). Interpolar AQUÍ —y no en sRGB— es lo que da
+ * los degradados perceptualmente suaves característicos de este tipo de juego:
+ * la transición de color avanza a velocidad uniforme para el ojo.
+ */
+data class Lab(val l: Double, val a: Double, val b: Double)
+
+private fun srgbToLinear(c: Double) =
+ if (c <= 0.04045) c / 12.92 else ((c + 0.055) / 1.055).pow(2.4)
+
+private fun linearToSrgb(c: Double) =
+ if (c <= 0.0031308) c * 12.92 else 1.055 * c.pow(1.0 / 2.4) - 0.055
+
+private const val XN = 0.95047
+private const val YN = 1.0
+private const val ZN = 1.08883
+
+private fun f(t: Double) = if (t > 0.008856) Math.cbrt(t) else 7.787 * t + 16.0 / 116.0
+private fun fInv(t: Double): Double {
+ val t3 = t * t * t
+ return if (t3 > 0.008856) t3 else (t - 16.0 / 116.0) / 7.787
+}
+
+fun Color.toLab(): Lab {
+ val r = srgbToLinear(red.toDouble())
+ val g = srgbToLinear(green.toDouble())
+ val b = srgbToLinear(blue.toDouble())
+ val x = (r * 0.4124 + g * 0.3576 + b * 0.1805) / XN
+ val y = (r * 0.2126 + g * 0.7152 + b * 0.0722) / YN
+ val z = (r * 0.0193 + g * 0.1192 + b * 0.9505) / ZN
+ val fx = f(x); val fy = f(y); val fz = f(z)
+ return Lab(116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz))
+}
+
+fun Lab.toColor(): Color {
+ val fy = (l + 16) / 116
+ val fx = fy + a / 500
+ val fz = fy - b / 200
+ val x = fInv(fx) * XN
+ val y = fInv(fy) * YN
+ val z = fInv(fz) * ZN
+ val r = linearToSrgb(x * 3.2406 + y * -1.5372 + z * -0.4986)
+ val g = linearToSrgb(x * -0.9689 + y * 1.8758 + z * 0.0415)
+ val b = linearToSrgb(x * 0.0557 + y * -0.2040 + z * 1.0570)
+ return Color(
+ r.coerceIn(0.0, 1.0).toFloat(),
+ g.coerceIn(0.0, 1.0).toFloat(),
+ b.coerceIn(0.0, 1.0).toFloat(),
+ )
+}
+
+/** Distancia ΔE perceptual entre dos colores (CIE76). */
+fun Lab.distance(o: Lab): Double {
+ val dl = l - o.l; val da = a - o.a; val db = b - o.b
+ return sqrt(dl * dl + da * da + db * db)
+}
+
+fun lerp(a: Lab, b: Lab, t: Double) =
+ Lab(a.l + (b.l - a.l) * t, a.a + (b.a - a.a) * t, a.b + (b.b - a.b) * t)
+
+/** HSV → Color, para sembrar las esquinas con colores vivos y variados. */
+fun hsv(h: Double, s: Double, v: Double): Color {
+ val hh = (((h % 360) + 360) % 360) / 60.0
+ val c = v * s
+ val x = c * (1 - abs(hh % 2 - 1))
+ val m = v - c
+ val (r, g, b) = when (hh.toInt()) {
+ 0 -> Triple(c, x, 0.0)
+ 1 -> Triple(x, c, 0.0)
+ 2 -> Triple(0.0, c, x)
+ 3 -> Triple(0.0, x, c)
+ 4 -> Triple(x, 0.0, c)
+ else -> Triple(c, 0.0, x)
+ }
+ return Color((r + m).toFloat(), (g + m).toFloat(), (b + m).toFloat())
+}
diff --git a/matiz/src/main/java/com/rizoma/matiz/data/Progreso.kt b/matiz/src/main/java/com/rizoma/matiz/data/Progreso.kt
new file mode 100644
index 0000000..40b929c
--- /dev/null
+++ b/matiz/src/main/java/com/rizoma/matiz/data/Progreso.kt
@@ -0,0 +1,33 @@
+package com.rizoma.matiz.data
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.intPreferencesKey
+import androidx.datastore.preferences.preferencesDataStore
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+
+private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "matiz")
+
+/** Persiste el progreso: nivel actual y nivel máximo alcanzado. */
+class Progreso(context: Context) {
+ private val store = context.applicationContext.dataStore
+
+ val nivel: Flow<Int> = store.data.map { (it[KEY_NIVEL] ?: 1).coerceAtLeast(1) }
+ val maximo: Flow<Int> = store.data.map { (it[KEY_MAX] ?: 1).coerceAtLeast(1) }
+
+ suspend fun setNivel(n: Int) {
+ val nivel = n.coerceAtLeast(1)
+ store.edit { p ->
+ p[KEY_NIVEL] = nivel
+ if (nivel > (p[KEY_MAX] ?: 1)) p[KEY_MAX] = nivel
+ }
+ }
+
+ private companion object {
+ val KEY_NIVEL = intPreferencesKey("nivel")
+ val KEY_MAX = intPreferencesKey("maximo")
+ }
+}
diff --git a/matiz/src/main/java/com/rizoma/matiz/game/Level.kt b/matiz/src/main/java/com/rizoma/matiz/game/Level.kt
new file mode 100644
index 0000000..e907a17
--- /dev/null
+++ b/matiz/src/main/java/com/rizoma/matiz/game/Level.kt
@@ -0,0 +1,148 @@
+package com.rizoma.matiz.game
+
+import androidx.compose.ui.graphics.Color
+import com.rizoma.matiz.color.Lab
+import com.rizoma.matiz.color.distance
+import com.rizoma.matiz.color.hsv
+import com.rizoma.matiz.color.lerp
+import com.rizoma.matiz.color.toColor
+import com.rizoma.matiz.color.toLab
+
+/**
+ * Un puzzle: el color objetivo de cada celda (el degradado resuelto), qué
+ * celdas están fijas (anclan el degradado y no se mueven) y la disposición
+ * actual de las baldosas.
+ *
+ * `arrangement[pos]` = id de la baldosa que ocupa la posición `pos`. El id de
+ * una baldosa es su celda de origen, así que su color real es `targets[id]`.
+ */
+class Puzzle(
+ val level: Int,
+ val cols: Int,
+ val rows: Int,
+ val targets: List<Color>,
+ val locked: BooleanArray,
+ val arrangement: IntArray,
+) {
+ private val labs: List<Lab> = targets.map { it.toLab() }
+
+ val size: Int get() = cols * rows
+
+ /** Color de la baldosa que ahora mismo ocupa `pos`. */
+ fun colorAt(pos: Int): Color = targets[arrangement[pos]]
+
+ /** ¿La baldosa en `pos` coincide (perceptualmente) con el objetivo de la celda? */
+ fun isCorrect(pos: Int): Boolean = labs[arrangement[pos]].distance(labs[pos]) < SOLVE_EPS
+
+ val solved: Boolean get() = (0 until size).all { isCorrect(it) }
+
+ companion object {
+ // ΔE por debajo del cual dos colores se consideran "el mismo sitio".
+ // ~6 tolera baldosas casi idénticas sin volver el puzzle irresoluble.
+ const val SOLVE_EPS = 6.0
+ }
+}
+
+/** Generación determinista de niveles a partir del número de nivel (= seed). */
+object Levels {
+ // Forma de la rejilla por nivel (cols × rows), creciendo poco a poco.
+ private val SHAPES = listOf(
+ 3 to 3, 3 to 4, 4 to 4, 4 to 5, 4 to 6,
+ 5 to 6, 5 to 7, 5 to 8, 6 to 7, 6 to 8,
+ )
+
+ private const val BASE = 0x5249_5A4F_4D41_0001L // "RIZOMA"
+
+ /** Seed estable para un nivel: mismo número → mismo puzzle en todas partes. */
+ fun seedFor(level: Int): Long = BASE xor (level.toLong() * -0x61c8864680b583ebL)
+
+ fun generate(level: Int): Puzzle {
+ val n = level.coerceAtLeast(1)
+ val rng = Rng(seedFor(n))
+ val (cols, rows) = SHAPES[(n - 1).coerceIn(0, SHAPES.lastIndex)]
+
+ val (tl, tr, bl, br) = corners(rng)
+ val targets = ArrayList<Color>(cols * rows)
+ for (r in 0 until rows) {
+ val ty = if (rows == 1) 0.0 else r.toDouble() / (rows - 1)
+ val left = lerp(tl, bl, ty)
+ val right = lerp(tr, br, ty)
+ for (c in 0 until cols) {
+ val tx = if (cols == 1) 0.0 else c.toDouble() / (cols - 1)
+ targets.add(lerp(left, right, tx).toColor())
+ }
+ }
+
+ val locked = lockMask(n, cols, rows, rng)
+ val arrangement = shuffle(targets, locked, rng)
+ return Puzzle(n, cols, rows, targets, locked, arrangement)
+ }
+
+ /**
+ * Cuatro esquinas en Lab. Por construcción el matiz cambia de izquierda a
+ * derecha y el brillo de arriba a abajo, con un pequeño "twist" de matiz
+ * entre filas para que el degradado no sea trivialmente lineal. Esto
+ * garantiza ambos ejes perceptibles y, por tanto, un puzzle siempre
+ * resoluble.
+ */
+ private fun corners(rng: Rng): List<Lab> {
+ val hueL = rng.range(0.0, 360.0)
+ val signo = if (rng.nextDouble() < 0.5) 1.0 else -1.0
+ val hueR = hueL + rng.range(45.0, 160.0) * signo
+ val satT = rng.range(0.60, 0.95)
+ val satB = rng.range(0.50, 0.90)
+ val valT = rng.range(0.75, 1.0) // arriba más claro
+ val valB = rng.range(0.40, valT - 0.20) // abajo, al menos 0.2 más oscuro
+ val twist = rng.range(-25.0, 25.0)
+ return listOf(
+ hsv(hueL, satT, valT).toLab(),
+ hsv(hueR, satT, valT).toLab(),
+ hsv(hueL + twist, satB, valB).toLab(),
+ hsv(hueR + twist, satB, valB).toLab(),
+ )
+ }
+
+ /** Esquinas siempre fijas; el resto de fijas decrece con el nivel (más difícil). */
+ private fun lockMask(level: Int, cols: Int, rows: Int, rng: Rng): BooleanArray {
+ val n = cols * rows
+ val locked = BooleanArray(n)
+ locked[0] = true
+ locked[cols - 1] = true
+ locked[n - cols] = true
+ locked[n - 1] = true
+
+ val ratio = when {
+ level <= 2 -> 0.45
+ level <= 4 -> 0.30
+ level <= 6 -> 0.18
+ level <= 9 -> 0.08
+ else -> 0.0
+ }
+ val pool = (0 until n).filter { !locked[it] }.toMutableList()
+ val extra = (pool.size * ratio).toInt()
+ repeat(extra) {
+ if (pool.isNotEmpty()) locked[pool.removeAt(rng.nextInt(pool.size))] = true
+ }
+ return locked
+ }
+
+ /** Fisher–Yates sobre las celdas libres; reintenta si saliera ya resuelto. */
+ private fun shuffle(targets: List<Color>, locked: BooleanArray, rng: Rng): IntArray {
+ val n = targets.size
+ val arr = IntArray(n) { it }
+ val free = (0 until n).filter { !locked[it] }
+ if (free.size <= 1) return arr
+
+ val ids = free.toMutableList()
+ repeat(64) {
+ for (i in ids.indices.reversed()) {
+ val j = rng.nextInt(i + 1)
+ val t = ids[i]; ids[i] = ids[j]; ids[j] = t
+ }
+ for (k in free.indices) arr[free[k]] = ids[k]
+ val intacto = free.all { targets[arr[it]] == targets[it] }
+ if (!intacto) return arr
+ }
+ return arr
+ }
+}
diff --git a/matiz/src/main/java/com/rizoma/matiz/game/Rng.kt b/matiz/src/main/java/com/rizoma/matiz/game/Rng.kt
new file mode 100644
index 0000000..ffe79e4
--- /dev/null
+++ b/matiz/src/main/java/com/rizoma/matiz/game/Rng.kt
@@ -0,0 +1,30 @@
+package com.rizoma.matiz.game
+
+/**
+ * SplitMix64 determinista: la misma seed produce exactamente la misma
+ * secuencia en cualquier dispositivo. Es lo que hace que "el nivel 42" se
+ * vea idéntico para todos y sea compartible con solo su número.
+ */
+class Rng(seed: Long) {
+ private var s = seed
+
+ fun nextLong(): Long {
+ s += -0x61c8864680b583ebL // 0x9E3779B97F4A7C15
+ var z = s
+ z = (z xor (z ushr 30)) * -0x40a7b892e31b1a47L // 0xBF58476D1CE4E5B9
+ z = (z xor (z ushr 27)) * -0x6b2fb644ecceee15L // 0x94D049BB133111EB
+ return z xor (z ushr 31)
+ }
+
+ /** Double en [0, 1). */
+ fun nextDouble(): Double = (nextLong() ushr 11) * (1.0 / (1L shl 53))
+
+ /** Int en [0, bound). */
+ fun nextInt(bound: Int): Int {
+ require(bound > 0)
+ return (nextDouble() * bound).toInt().coerceIn(0, bound - 1)
+ }
+
+ /** Double en [min, max). */
+ fun range(min: Double, max: Double): Double = min + nextDouble() * (max - min)
+}
diff --git a/matiz/src/main/java/com/rizoma/matiz/ui/Board.kt b/matiz/src/main/java/com/rizoma/matiz/ui/Board.kt
new file mode 100644
index 0000000..c5cef30
--- /dev/null
+++ b/matiz/src/main/java/com/rizoma/matiz/ui/Board.kt
@@ -0,0 +1,136 @@
+package com.rizoma.matiz.ui
+
+import android.view.HapticFeedbackConstants
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.gestures.detectDragGestures
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.CornerRadius
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.drawscope.DrawScope
+import androidx.compose.ui.graphics.luminance
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.platform.LocalView
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.dp
+import com.rizoma.matiz.MatizViewModel
+import com.rizoma.matiz.game.Puzzle
+
+/**
+ * El tablero. Arrastras una baldosa sobre otra para intercambiarlas; las fijas
+ * (marcadas con un anillo) no se mueven. Al resolver, el hueco entre baldosas y
+ * el redondeo se desvanecen y el degradado se vuelve una superficie continua.
+ */
+@Composable
+fun Board(vm: MatizViewModel, modifier: Modifier = Modifier) {
+ val puzzle = vm.puzzle
+ val epoch = vm.epoch // leerlo aquí fuerza recomponer tras cada swap
+ val solved = vm.solved
+ val view = LocalView.current
+
+ val merge by animateFloatAsState(
+ targetValue = if (solved) 1f else 0f,
+ animationSpec = tween(700),
+ label = "merge",
+ )
+
+ var dragFrom by remember(epoch) { mutableStateOf(-1) }
+ var dragPos by remember { mutableStateOf(Offset.Zero) }
+
+ Canvas(
+ modifier = modifier.pointerInput(epoch, solved) {
+ if (solved) return@pointerInput
+ detectDragGestures(
+ onDragStart = { off ->
+ val idx = cellAt(off, size, puzzle.cols, puzzle.rows)
+ dragFrom = if (idx >= 0 && !puzzle.locked[idx]) idx else -1
+ dragPos = off
+ },
+ onDrag = { change, _ ->
+ dragPos = change.position
+ change.consume()
+ },
+ onDragEnd = {
+ val target = cellAt(dragPos, size, puzzle.cols, puzzle.rows)
+ if (dragFrom >= 0 && target >= 0 && target != dragFrom && !puzzle.locked[target]) {
+ vm.swap(dragFrom, target)
+ view.performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK)
+ }
+ dragFrom = -1
+ },
+ onDragCancel = { dragFrom = -1 },
+ )
+ },
+ ) {
+ val gap = (1f - merge) * 3.dp.toPx()
+ val radius = (1f - merge) * 6.dp.toPx()
+ val cw = size.width / puzzle.cols
+ val ch = size.height / puzzle.rows
+
+ for (pos in 0 until puzzle.size) {
+ if (pos == dragFrom) continue // la arrastrada se dibuja al final, encima
+ tile(pos, puzzle, cw, ch, gap, radius, drawLock = !solved)
+ }
+
+ if (dragFrom >= 0) {
+ val w = cw - gap; val h = ch - gap
+ drawRoundRect(
+ color = puzzle.colorAt(dragFrom),
+ topLeft = Offset(dragPos.x - w / 2, dragPos.y - h / 2),
+ size = Size(w, h),
+ cornerRadius = CornerRadius(radius * 1.6f),
+ )
+ }
+ }
+}
+
+private fun DrawScope.tile(
+ pos: Int,
+ puzzle: Puzzle,
+ cw: Float,
+ ch: Float,
+ gap: Float,
+ radius: Float,
+ drawLock: Boolean,
+) {
+ val col = pos % puzzle.cols
+ val row = pos / puzzle.cols
+ val x = col * cw + gap / 2f
+ val y = row * ch + gap / 2f
+ val w = cw - gap
+ val h = ch - gap
+ val color = puzzle.colorAt(pos)
+
+ drawRoundRect(
+ color = color,
+ topLeft = Offset(x, y),
+ size = Size(w, h),
+ cornerRadius = CornerRadius(radius),
+ )
+
+ if (drawLock && puzzle.locked[pos]) {
+ // Anillo que indica baldosa fija; tono que contraste con el color.
+ val mark = if (color.luminance() > 0.5f) Color.Black else Color.White
+ drawCircle(
+ color = mark.copy(alpha = 0.55f),
+ radius = minOf(w, h) * 0.11f,
+ center = Offset(x + w / 2f, y + h / 2f),
+ style = androidx.compose.ui.graphics.drawscope.Stroke(width = 2.dp.toPx()),
+ )
+ }
+}
+
+private fun cellAt(off: Offset, size: IntSize, cols: Int, rows: Int): Int {
+ if (off.x < 0f || off.y < 0f || off.x >= size.width || off.y >= size.height) return -1
+ val c = (off.x / (size.width.toFloat() / cols)).toInt().coerceIn(0, cols - 1)
+ val r = (off.y / (size.height.toFloat() / rows)).toInt().coerceIn(0, rows - 1)
+ return r * cols + c
+}
diff --git a/matiz/src/main/java/com/rizoma/matiz/ui/MatizApp.kt b/matiz/src/main/java/com/rizoma/matiz/ui/MatizApp.kt
new file mode 100644
index 0000000..7df2cb5
--- /dev/null
+++ b/matiz/src/main/java/com/rizoma/matiz/ui/MatizApp.kt
@@ -0,0 +1,164 @@
+package com.rizoma.matiz.ui
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutVertically
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.rizoma.matiz.MatizViewModel
+import com.rizoma.matiz.ui.theme.MuyTenue
+import com.rizoma.matiz.ui.theme.NeonVerde
+import com.rizoma.matiz.ui.theme.SuperficieAlta
+import com.rizoma.matiz.ui.theme.Tenue
+import com.rizoma.matiz.ui.theme.Vacio
+import androidx.compose.ui.graphics.Color
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.systemBars
+import androidx.compose.foundation.layout.windowInsetsPadding
+
+@Composable
+fun MatizApp(vm: MatizViewModel = viewModel()) {
+ val ratio = vm.puzzle.cols.toFloat() / vm.puzzle.rows
+
+ Box(
+ Modifier
+ .fillMaxSize()
+ .background(Vacio)
+ .windowInsetsPadding(WindowInsets.systemBars),
+ ) {
+ Column(Modifier.fillMaxSize().padding(20.dp)) {
+ Header(vm)
+ Box(
+ Modifier.fillMaxWidth().weight(1f),
+ contentAlignment = Alignment.Center,
+ ) {
+ Board(
+ vm,
+ Modifier
+ .fillMaxSize()
+ .aspectRatio(ratio, matchHeightConstraintsFirst = ratio < 1f),
+ )
+ }
+ Controls(vm)
+ }
+
+ AnimatedVisibility(
+ visible = vm.solved,
+ enter = slideInVertically { it } + fadeIn(),
+ exit = slideOutVertically { it } + fadeOut(),
+ modifier = Modifier.align(Alignment.BottomCenter),
+ ) {
+ SolvedBanner(vm)
+ }
+ }
+}
+
+@Composable
+private fun Header(vm: MatizViewModel) {
+ Row(
+ Modifier.fillMaxWidth().padding(bottom = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(Modifier.weight(1f)) {
+ Text("NIVEL", color = MuyTenue, fontSize = 11.sp, letterSpacing = 3.sp)
+ Text(
+ "${vm.puzzle.level}",
+ color = Color.White,
+ fontSize = 36.sp,
+ fontWeight = FontWeight.Bold,
+ )
+ }
+ Column(horizontalAlignment = Alignment.End) {
+ Text("MOVIMIENTOS", color = MuyTenue, fontSize = 11.sp, letterSpacing = 3.sp)
+ Text(
+ "${vm.moves}",
+ color = if (vm.solved) NeonVerde else Color.White,
+ fontSize = 24.sp,
+ fontWeight = FontWeight.SemiBold,
+ )
+ }
+ }
+}
+
+@Composable
+private fun Controls(vm: MatizViewModel) {
+ Row(
+ Modifier.fillMaxWidth().padding(top = 14.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Pill("‹ anterior", enabled = vm.puzzle.level > 1) { vm.prev() }
+ Pill("barajar") { vm.retry() }
+ Pill("siguiente ›") { vm.next() }
+ }
+}
+
+@Composable
+private fun Pill(text: String, enabled: Boolean = true, onClick: () -> Unit) {
+ Surface(
+ color = SuperficieAlta,
+ shape = RoundedCornerShape(50),
+ modifier = Modifier
+ .clip(RoundedCornerShape(50))
+ .then(if (enabled) Modifier.clickable(onClick = onClick) else Modifier),
+ ) {
+ Text(
+ text,
+ color = if (enabled) Tenue else MuyTenue,
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Medium,
+ modifier = Modifier.padding(horizontal = 18.dp, vertical = 11.dp),
+ )
+ }
+}
+
+@Composable
+private fun SolvedBanner(vm: MatizViewModel) {
+ Surface(
+ color = SuperficieAlta,
+ shape = RoundedCornerShape(18.dp),
+ modifier = Modifier.padding(20.dp).fillMaxWidth(),
+ ) {
+ Row(
+ Modifier.fillMaxWidth().padding(20.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column {
+ Text("resuelto", color = NeonVerde, fontSize = 20.sp, fontWeight = FontWeight.Bold)
+ Text("${vm.moves} movimientos", color = Tenue, fontSize = 13.sp)
+ }
+ Text(
+ "siguiente →",
+ color = NeonVerde,
+ fontSize = 16.sp,
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier
+ .clip(RoundedCornerShape(50))
+ .clickable { vm.next() }
+ .padding(horizontal = 14.dp, vertical = 8.dp),
+ )
+ }
+ }
+}
diff --git a/matiz/src/main/java/com/rizoma/matiz/ui/theme/Theme.kt b/matiz/src/main/java/com/rizoma/matiz/ui/theme/Theme.kt
new file mode 100644
index 0000000..f3a563f
--- /dev/null
+++ b/matiz/src/main/java/com/rizoma/matiz/ui/theme/Theme.kt
@@ -0,0 +1,36 @@
+package com.rizoma.matiz.ui.theme
+
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+
+// Paleta del rizoma: neón sobre vacío (compartida con Órbita/Trazo/Consola).
+val Vacio = Color(0xFF050507)
+val Superficie = Color(0xFF0C0C12)
+val SuperficieAlta = Color(0xFF14141C)
+val NeonVerde = Color(0xFF7CFF4F)
+val NeonRosa = Color(0xFFFF2E88)
+val NeonCian = Color(0xFF00E5FF)
+val Tenue = Color(0xB3FFFFFF)
+val MuyTenue = Color(0x66FFFFFF)
+
+private val EsquemaMatiz = darkColorScheme(
+ primary = NeonVerde,
+ onPrimary = Vacio,
+ secondary = NeonRosa,
+ onSecondary = Vacio,
+ tertiary = NeonCian,
+ onTertiary = Vacio,
+ background = Vacio,
+ onBackground = Color.White,
+ surface = Superficie,
+ onSurface = Color.White,
+ surfaceVariant = SuperficieAlta,
+ onSurfaceVariant = Tenue,
+)
+
+@Composable
+fun MatizTheme(content: @Composable () -> Unit) {
+ MaterialTheme(colorScheme = EsquemaMatiz, content = content)
+}
diff --git a/matiz/src/main/res/drawable/ic_launcher_fg.xml b/matiz/src/main/res/drawable/ic_launcher_fg.xml
new file mode 100644
index 0000000..b8c7713
--- /dev/null
+++ b/matiz/src/main/res/drawable/ic_launcher_fg.xml
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="utf-8"?>
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="108dp"
+ android:height="108dp"
+ android:viewportWidth="108"
+ android:viewportHeight="108">
+ <!-- cuatro baldosas: un degradado de matiz en miniatura -->
+ <path android:fillColor="#FF2E88" android:pathData="M28 28 h24 v24 h-24 z" />
+ <path android:fillColor="#B45CFF" android:pathData="M56 28 h24 v24 h-24 z" />
+ <path android:fillColor="#00E5FF" android:pathData="M28 56 h24 v24 h-24 z" />
+ <path android:fillColor="#7CFF4F" android:pathData="M56 56 h24 v24 h-24 z" />
+</vector>
diff --git a/matiz/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/matiz/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..088fcce
--- /dev/null
+++ b/matiz/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
+ <background android:drawable="@color/ic_bg" />
+ <foreground android:drawable="@drawable/ic_launcher_fg" />
+</adaptive-icon>
diff --git a/matiz/src/main/res/values/colors.xml b/matiz/src/main/res/values/colors.xml
new file mode 100644
index 0000000..917256d
--- /dev/null
+++ b/matiz/src/main/res/values/colors.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <color name="ic_bg">#050507</color>
+</resources>
diff --git a/matiz/src/main/res/values/strings.xml b/matiz/src/main/res/values/strings.xml
new file mode 100644
index 0000000..57288fa
--- /dev/null
+++ b/matiz/src/main/res/values/strings.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="app_name">matiz</string>
+</resources>
diff --git a/matiz/src/main/res/values/themes.xml b/matiz/src/main/res/values/themes.xml
new file mode 100644
index 0000000..2127fdd
--- /dev/null
+++ b/matiz/src/main/res/values/themes.xml
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <style name="Theme.Matiz" parent="android:Theme.Material.NoActionBar">
+ <item name="android:windowBackground">#050507</item>
+ <item name="android:statusBarColor">@android:color/transparent</item>
+ <item name="android:navigationBarColor">@android:color/transparent</item>
+ <item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
+ </style>
+</resources>
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 6d52dec..6c08155 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -23,3 +23,4 @@ rootProject.name = "rizoma"
include(":launcher")
include(":keyboard")
include(":consola")
+include(":matiz")