tawasuyu

sergio/humanoid

HTML 50Markdown 1TOML 10 Sutichaykuna

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>

12ead2dfd3c230a7a3c63c9e4f7ea4de41e7ebdd · ruwaq sergio · (4 killa ñawpaq) · tayta 8ed22f8ad · kay commitpi sach'ata qhaway

Semántico sayk'uy

Mana ima sutichaypas t'ikrakurqanchu: qillqalla, yachachiylla utaq willaylla t'ikrakurqan.

17 qillqakuna · +817 −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")