gioser

Cubo: nuevo módulo — 2048 en 3D con OpenGL ES 2.0

Juego nuevo (app independiente :cubo): rejilla 3×3×3 con las seis
direcciones de caída (ejes X/Y/Z × ±1), fusiones tipo 2048 por líneas,
récord persistido en DataStore.

Render OpenGL: cubos coloreados y numerados (atlas de texturas),
iluminación por normales, ordenados de atrás-adelante y mezclados con
alfa (slider "ver dentro" para la transparencia). Rotación arrastrando
+ auto-giro en reposo y "pop" al aparecer baldosas. Gnomon de ejes
(X rojo, Y verde, Z azul) que orienta las caras; botones de dirección
coloreados igual.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

3ae5fa52dc10b21c431254e04a1b57e824fe7590 · por sergio · (hace 4 meses) · padre b3044bd18 · ver el árbol en este commit

Delta semántico

Ninguna definición cambió: el cambio es de texto, documentación o datos.

19 archivos · +1181 −0

diff --git a/cubo/build.gradle.kts b/cubo/build.gradle.kts
new file mode 100644
index 0000000..cb3713d
--- /dev/null
+++ b/cubo/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.cubo"
+    compileSdk = 36
+
+    defaultConfig {
+        applicationId = "com.rizoma.cubo"
+        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/cubo/src/main/AndroidManifest.xml b/cubo/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..58536a6
--- /dev/null
+++ b/cubo/src/main/AndroidManifest.xml
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+
+    <!-- Juego offline en 3D (OpenGL ES 2.0): sin permisos. -->
+    <uses-feature android:glEsVersion="0x00020000" android:required="true" />
+
+    <application
+        android:label="@string/app_name"
+        android:icon="@mipmap/ic_launcher"
+        android:theme="@style/Theme.Cubo"
+        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/cubo/src/main/java/com/rizoma/cubo/CuboViewModel.kt b/cubo/src/main/java/com/rizoma/cubo/CuboViewModel.kt
new file mode 100644
index 0000000..383e942
--- /dev/null
+++ b/cubo/src/main/java/com/rizoma/cubo/CuboViewModel.kt
@@ -0,0 +1,68 @@
+package com.rizoma.cubo
+
+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.cubo.data.Progreso
+import com.rizoma.cubo.game.Cubo
+import kotlinx.coroutines.launch
+
+/**
+ * Orquesta una partida de [Cubo]. El tablero es mutable in situ, así que un
+ * contador [version] es lo que dispara tanto la recomposición de la UI como el
+ * envío de la instantánea al renderer.
+ */
+class CuboViewModel(app: Application) : AndroidViewModel(app) {
+
+    private val progreso = Progreso(app)
+    private val juego = Cubo(n = 3)
+
+    val n: Int get() = juego.n
+
+    var score by mutableIntStateOf(0); private set
+    var maxTile by mutableIntStateOf(0); private set
+    var won by mutableStateOf(false); private set
+    var over by mutableStateOf(false); private set
+    var version by mutableIntStateOf(0); private set
+
+    var mejorScore by mutableIntStateOf(0); private set
+    var mejorTile by mutableIntStateOf(0); private set
+
+    init {
+        viewModelScope.launch { progreso.mejorScore.collect { mejorScore = it } }
+        viewModelScope.launch { progreso.mejorBaldosa.collect { mejorTile = it } }
+        juego.reset()
+        sync()
+    }
+
+    /** Instantánea barata del tablero para el renderer (copia defensiva). */
+    fun snapshot(): IntArray = juego.cells.copyOf()
+
+    val spawnIndex: Int get() = juego.lastSpawn
+
+    /** Mueve hacia una cara (eje 0/1/2 × dir ±1). */
+    fun move(axis: Int, dir: Int) {
+        if (over) return
+        if (juego.move(axis, dir)) sync()
+    }
+
+    fun reset() {
+        juego.reset()
+        sync()
+    }
+
+    private fun sync() {
+        score = juego.score
+        maxTile = juego.maxValue
+        won = juego.won
+        over = juego.over
+        version++
+        if (over || won) {
+            viewModelScope.launch { progreso.registrar(juego.score, juego.maxValue) }
+        }
+    }
+}
diff --git a/cubo/src/main/java/com/rizoma/cubo/MainActivity.kt b/cubo/src/main/java/com/rizoma/cubo/MainActivity.kt
new file mode 100644
index 0000000..b2ae940
--- /dev/null
+++ b/cubo/src/main/java/com/rizoma/cubo/MainActivity.kt
@@ -0,0 +1,20 @@
+package com.rizoma.cubo
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.core.view.WindowCompat
+import com.rizoma.cubo.ui.CuboApp
+import com.rizoma.cubo.ui.theme.CuboTheme
+
+class MainActivity : ComponentActivity() {
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        WindowCompat.setDecorFitsSystemWindows(window, false)
+        setContent {
+            CuboTheme {
+                CuboApp()
+            }
+        }
+    }
+}
diff --git a/cubo/src/main/java/com/rizoma/cubo/data/Progreso.kt b/cubo/src/main/java/com/rizoma/cubo/data/Progreso.kt
new file mode 100644
index 0000000..738e9a8
--- /dev/null
+++ b/cubo/src/main/java/com/rizoma/cubo/data/Progreso.kt
@@ -0,0 +1,32 @@
+package com.rizoma.cubo.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 = "cubo")
+
+/** Récords del cubo, persistidos en DataStore: mejor puntuación y mejor baldosa. */
+class Progreso(context: Context) {
+    private val store = context.applicationContext.dataStore
+
+    val mejorScore: Flow<Int> = store.data.map { it[KEY_SCORE] ?: 0 }
+    val mejorBaldosa: Flow<Int> = store.data.map { it[KEY_TILE] ?: 0 }
+
+    suspend fun registrar(score: Int, baldosa: Int) {
+        store.edit { p ->
+            if (score > (p[KEY_SCORE] ?: 0)) p[KEY_SCORE] = score
+            if (baldosa > (p[KEY_TILE] ?: 0)) p[KEY_TILE] = baldosa
+        }
+    }
+
+    private companion object {
+        val KEY_SCORE = intPreferencesKey("mejor_score")
+        val KEY_TILE = intPreferencesKey("mejor_baldosa")
+    }
+}
diff --git a/cubo/src/main/java/com/rizoma/cubo/game/Cubo.kt b/cubo/src/main/java/com/rizoma/cubo/game/Cubo.kt
new file mode 100644
index 0000000..54157cd
--- /dev/null
+++ b/cubo/src/main/java/com/rizoma/cubo/game/Cubo.kt
@@ -0,0 +1,106 @@
+package com.rizoma.cubo.game
+
+import java.util.Random
+
+/**
+ * 2048 en tres dimensiones. La rejilla es un cubo de [n]³ celdas; cada celda
+ * vale 0 (vacía) o una potencia de dos. Un movimiento "deja caer" todas las
+ * baldosas hacia una de las **seis** caras (eje 0/1/2 × dirección ±1): se
+ * compactan contra la pared y las iguales contiguas se fusionan una vez,
+ * exactamente como el 2048 clásico pero por líneas del eje elegido.
+ *
+ * El estado es un único [IntArray] indexado por `x + y*n + z*n²`, para que el
+ * renderer pueda leer una instantánea barata sin recorrer estructuras.
+ */
+class Cubo(val n: Int = 3, seed: Long = 0x43_55_42_4FL /* "CUBO" */) {
+
+    val cells = IntArray(n * n * n)
+    var score = 0; private set
+    var won = false; private set
+    /** Índice de la última baldosa aparecida (para animar su "pop"); -1 si ninguna. */
+    var lastSpawn = -1; private set
+
+    private val rng = Random(seed)
+
+    fun idx(x: Int, y: Int, z: Int) = x + y * n + z * n * n
+
+    /** Reinicia con dos baldosas, como el 2048 clásico. */
+    fun reset() {
+        cells.fill(0)
+        score = 0
+        won = false
+        lastSpawn = -1
+        spawn()
+        spawn()
+    }
+
+    /**
+     * Deja caer todo hacia la cara dada y, si algo se movió, aparece una baldosa
+     * nueva. Devuelve si el tablero cambió (un movimiento nulo no cuenta ni hace
+     * aparecer nada).
+     */
+    fun move(axis: Int, dir: Int): Boolean {
+        val before = cells.copyOf()
+        val towardHigh = dir > 0
+        when (axis) {
+            0 -> for (y in 0 until n) for (z in 0 until n)
+                slide({ a -> cells[idx(a, y, z)] }, { a, v -> cells[idx(a, y, z)] = v }, towardHigh)
+            1 -> for (x in 0 until n) for (z in 0 until n)
+                slide({ a -> cells[idx(x, a, z)] }, { a, v -> cells[idx(x, a, z)] = v }, towardHigh)
+            2 -> for (x in 0 until n) for (y in 0 until n)
+                slide({ a -> cells[idx(x, y, a)] }, { a, v -> cells[idx(x, y, a)] = v }, towardHigh)
+        }
+        val changed = !before.contentEquals(cells)
+        if (changed) spawn()
+        return changed
+    }
+
+    /** Compacta+fusiona una línea hacia la pared (lado alto si [towardHigh]). */
+    private inline fun slide(get: (Int) -> Int, set: (Int, Int) -> Unit, towardHigh: Boolean) {
+        // Orden de viaje: la pared primero, así la baldosa pegada a ella fusiona antes.
+        val order = if (towardHigh) IntArray(n) { n - 1 - it } else IntArray(n) { it }
+        val vals = ArrayList<Int>(n)
+        for (a in order) { val v = get(a); if (v != 0) vals.add(v) }
+
+        val merged = ArrayList<Int>(n)
+        var i = 0
+        while (i < vals.size) {
+            if (i + 1 < vals.size && vals[i] == vals[i + 1]) {
+                val v = vals[i] * 2
+                merged.add(v)
+                score += v
+                if (v >= 2048) won = true
+                i += 2
+            } else {
+                merged.add(vals[i]); i++
+            }
+        }
+        for (k in order.indices) set(order[k], if (k < merged.size) merged[k] else 0)
+    }
+
+    /** Hace aparecer una baldosa (90% un 2, 10% un 4) en una celda vacía. */
+    private fun spawn() {
+        val empties = ArrayList<Int>()
+        for (i in cells.indices) if (cells[i] == 0) empties.add(i)
+        if (empties.isEmpty()) { lastSpawn = -1; return }
+        val at = empties[rng.nextInt(empties.size)]
+        cells[at] = if (rng.nextInt(10) == 0) 4 else 2
+        lastSpawn = at
+    }
+
+    /** Hay jugada posible: alguna celda vacía, o dos iguales contiguas en algún eje. */
+    fun canMove(): Boolean {
+        if (cells.any { it == 0 }) return true
+        for (x in 0 until n) for (y in 0 until n) for (z in 0 until n) {
+            val v = cells[idx(x, y, z)]
+            if (x + 1 < n && cells[idx(x + 1, y, z)] == v) return true
+            if (y + 1 < n && cells[idx(x, y + 1, z)] == v) return true
+            if (z + 1 < n && cells[idx(x, y, z + 1)] == v) return true
+        }
+        return false
+    }
+
+    val over: Boolean get() = !canMove()
+
+    val maxValue: Int get() = cells.max()
+}
diff --git a/cubo/src/main/java/com/rizoma/cubo/gl/Atlas.kt b/cubo/src/main/java/com/rizoma/cubo/gl/Atlas.kt
new file mode 100644
index 0000000..ef3e7ad
--- /dev/null
+++ b/cubo/src/main/java/com/rizoma/cubo/gl/Atlas.kt
@@ -0,0 +1,80 @@
+package com.rizoma.cubo.gl
+
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Paint
+import android.graphics.RectF
+import android.graphics.Typeface
+
+/**
+ * Atlas de texturas: una cuadrícula 4×4 de baldosas, una por exponente
+ * (2..32768). Cada baldosa lleva su color de [Palette] y el número centrado, y
+ * se mapea a las caras de cada cubo del render. Generar un bitmap una sola vez
+ * y subirlo como textura evita dibujar texto en cada frame.
+ */
+object Atlas {
+    const val COLS = 4
+    const val ROWS = 4
+    private const val TILE = 128 // px por baldosa
+
+    /** Región UV (min + tamaño) del exponente dado dentro del atlas. */
+    fun uvMin(exp: Int): FloatArray {
+        val i = (exp - 1).coerceIn(0, COLS * ROWS - 1)
+        val col = i % COLS
+        val row = i / COLS
+        return floatArrayOf(col.toFloat() / COLS, row.toFloat() / ROWS)
+    }
+
+    val uvScale: FloatArray get() = floatArrayOf(1f / COLS, 1f / ROWS)
+
+    fun build(): Bitmap {
+        val bmp = Bitmap.createBitmap(COLS * TILE, ROWS * TILE, Bitmap.Config.ARGB_8888)
+        val canvas = Canvas(bmp)
+        val fill = Paint(Paint.ANTI_ALIAS_FLAG)
+        val text = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+            typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD)
+            textAlign = Paint.Align.CENTER
+        }
+
+        for (exp in 1..Palette.MAX_EXP) {
+            val i = exp - 1
+            val col = i % COLS
+            val row = i / COLS
+            val left = col * TILE
+            val top = row * TILE
+            val value = 1 shl exp
+
+            val color = Palette.colorFor(value)
+            fill.color = color
+            // Baldosa con margen y esquinas redondeadas: deja un "hueco" oscuro
+            // entre cubos contiguos sin necesidad de geometría extra.
+            val pad = TILE * 0.06f
+            canvas.drawRoundRect(
+                RectF(left + pad, top + pad, left + TILE - pad, top + TILE - pad),
+                TILE * 0.16f, TILE * 0.16f, fill,
+            )
+
+            // Número: oscuro sobre colores claros, blanco sobre oscuros.
+            text.color = if (luminance(color) > 0.6f) 0xFF101018.toInt() else Color.WHITE
+            val label = value.toString()
+            // Ajusta el tamaño para que quepa el ancho (32768 es largo).
+            var size = TILE * 0.42f
+            text.textSize = size
+            val maxW = TILE * 0.78f
+            val w = text.measureText(label)
+            if (w > maxW) { size *= maxW / w; text.textSize = size }
+            val cx = left + TILE / 2f
+            val cy = top + TILE / 2f - (text.descent() + text.ascent()) / 2f
+            canvas.drawText(label, cx, cy, text)
+        }
+        return bmp
+    }
+
+    private fun luminance(c: Int): Float {
+        val r = Color.red(c) / 255f
+        val g = Color.green(c) / 255f
+        val b = Color.blue(c) / 255f
+        return 0.2126f * r + 0.7152f * g + 0.0722f * b
+    }
+}
diff --git a/cubo/src/main/java/com/rizoma/cubo/gl/CuboGLView.kt b/cubo/src/main/java/com/rizoma/cubo/gl/CuboGLView.kt
new file mode 100644
index 0000000..2d3946e
--- /dev/null
+++ b/cubo/src/main/java/com/rizoma/cubo/gl/CuboGLView.kt
@@ -0,0 +1,40 @@
+package com.rizoma.cubo.gl
+
+import android.content.Context
+import android.opengl.GLSurfaceView
+import android.view.MotionEvent
+
+/**
+ * Vista GL del cubo: arrastra con un dedo para rotarlo. Render continuo para
+ * que la auto-rotación y el "pop" de las baldosas nuevas animen sin empujones
+ * manuales. La [renderer] expone el estado que la UI actualiza.
+ */
+class CuboGLView(context: Context) : GLSurfaceView(context) {
+
+    val renderer = CuboRenderer()
+
+    private var lastX = 0f
+    private var lastY = 0f
+
+    init {
+        setEGLContextClientVersion(2)
+        // Fondo del rizoma; sin alfa en la ventana (juego a pantalla completa).
+        setEGLConfigChooser(8, 8, 8, 8, 16, 0)
+        setRenderer(renderer)
+        renderMode = RENDERMODE_CONTINUOUSLY
+    }
+
+    override fun onTouchEvent(e: MotionEvent): Boolean {
+        when (e.actionMasked) {
+            MotionEvent.ACTION_DOWN -> { lastX = e.x; lastY = e.y }
+            MotionEvent.ACTION_MOVE -> {
+                val dx = e.x - lastX
+                val dy = e.y - lastY
+                lastX = e.x; lastY = e.y
+                // Arrastre horizontal → giro (yaw); vertical → cabeceo (pitch).
+                renderer.addRotation(dx * 0.4f, dy * 0.4f)
+            }
+        }
+        return true
+    }
+}
diff --git a/cubo/src/main/java/com/rizoma/cubo/gl/CuboRenderer.kt b/cubo/src/main/java/com/rizoma/cubo/gl/CuboRenderer.kt
new file mode 100644
index 0000000..5c620fa
--- /dev/null
+++ b/cubo/src/main/java/com/rizoma/cubo/gl/CuboRenderer.kt
@@ -0,0 +1,312 @@
+package com.rizoma.cubo.gl
+
+import android.opengl.GLES20
+import android.opengl.GLSurfaceView
+import android.opengl.GLUtils
+import android.opengl.Matrix
+import android.os.SystemClock
+import javax.microedition.khronos.egl.EGLConfig
+import javax.microedition.khronos.opengles.GL10
+
+/**
+ * Dibuja el cubo de baldosas con OpenGL ES 2.0. Cada celda llena es un cubo
+ * coloreado y numerado (vía [Atlas]); se ordenan de atrás hacia delante y se
+ * mezclan con alfa, de modo que bajando la opacidad se ve el interior. Encima
+ * se dibuja el armazón (caja) y un gnomon de ejes (X rojo, Y verde, Z azul)
+ * que orienta las seis direcciones de caída mientras se rota el cubo.
+ *
+ * Todos los campos que toca la UI son `@Volatile`: la instantánea del tablero
+ * llega del hilo principal y se lee en el hilo de GL.
+ */
+class CuboRenderer : GLSurfaceView.Renderer {
+
+    // ---- Estado compartido con la UI ----------------------------------
+    @Volatile private var snapshot = IntArray(0)
+    @Volatile private var gridN = 3
+    @Volatile private var spawnIndex = -1
+    @Volatile private var spawnAtMs = 0L
+    @Volatile var alpha = 0.62f
+    @Volatile var showWire = true
+    @Volatile var showAxes = true
+
+    @Volatile private var yaw = 28f
+    @Volatile private var pitch = -22f
+    @Volatile private var lastTouchMs = 0L
+
+    /** Publica una nueva instantánea del tablero (ya copiada por el llamante). */
+    fun submit(cells: IntArray, n: Int, spawn: Int) {
+        snapshot = cells
+        gridN = n
+        spawnIndex = spawn
+        spawnAtMs = SystemClock.elapsedRealtime()
+    }
+
+    fun addRotation(dx: Float, dy: Float) {
+        yaw += dx
+        pitch = (pitch + dy).coerceIn(-85f, 85f)
+        lastTouchMs = SystemClock.elapsedRealtime()
+    }
+
+    // ---- Recursos GL ---------------------------------------------------
+    private var cubeProg = 0
+    private var lineProg = 0
+    private var tex = 0
+
+    private val cubeBuf = Geometry.cube()
+    private var boxBuf = Geometry.boxEdges(1f)
+    private var axisX = Geometry.segment(floatArrayOf(1f, 0f, 0f))
+    private var axisY = Geometry.segment(floatArrayOf(0f, 1f, 0f))
+    private var axisZ = Geometry.segment(floatArrayOf(0f, 0f, 1f))
+
+    // Atributos/uniformes del programa de cubos.
+    private var aPos = 0; private var aNormal = 0; private var aUV = 0
+    private var uMVP = 0; private var uModel = 0; private var uUVMin = 0
+    private var uUVScale = 0; private var uAlpha = 0; private var uTex = 0
+    // Programa de líneas.
+    private var lPos = 0; private var lMVP = 0; private var lColor = 0
+
+    private val proj = FloatArray(16)
+    private val view = FloatArray(16)
+    private val vp = FloatArray(16)
+    private val rot = FloatArray(16)
+    private val tmp = FloatArray(16)
+    private val modelCube = FloatArray(16)
+    private val mvp = FloatArray(16)
+    private val mvpLine = FloatArray(16)
+
+    private val spacing = 1.0f
+    private val cubeSize = 0.92f
+    private var boundHalf = 1.5f
+
+    override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) {
+        GLES20.glClearColor(0.02f, 0.02f, 0.027f, 1f)
+        GLES20.glDisable(GLES20.GL_DEPTH_TEST) // orden de pintor + mezcla (transparencia)
+        GLES20.glEnable(GLES20.GL_BLEND)
+        GLES20.glBlendFunc(GLES20.GL_SRC_ALPHA, GLES20.GL_ONE_MINUS_SRC_ALPHA)
+        GLES20.glLineWidth(3f)
+
+        cubeProg = program(VS_CUBE, FS_CUBE)
+        aPos = GLES20.glGetAttribLocation(cubeProg, "aPos")
+        aNormal = GLES20.glGetAttribLocation(cubeProg, "aNormal")
+        aUV = GLES20.glGetAttribLocation(cubeProg, "aUV")
+        uMVP = GLES20.glGetUniformLocation(cubeProg, "uMVP")
+        uModel = GLES20.glGetUniformLocation(cubeProg, "uModel")
+        uUVMin = GLES20.glGetUniformLocation(cubeProg, "uUVMin")
+        uUVScale = GLES20.glGetUniformLocation(cubeProg, "uUVScale")
+        uAlpha = GLES20.glGetUniformLocation(cubeProg, "uAlpha")
+        uTex = GLES20.glGetUniformLocation(cubeProg, "uTex")
+
+        lineProg = program(VS_LINE, FS_LINE)
+        lPos = GLES20.glGetAttribLocation(lineProg, "aPos")
+        lMVP = GLES20.glGetUniformLocation(lineProg, "uMVP")
+        lColor = GLES20.glGetUniformLocation(lineProg, "uColor")
+
+        tex = uploadAtlas()
+    }
+
+    override fun onSurfaceChanged(gl: GL10?, width: Int, height: Int) {
+        GLES20.glViewport(0, 0, width, height)
+        val aspect = width.toFloat() / height.coerceAtLeast(1)
+        Matrix.perspectiveM(proj, 0, 42f, aspect, 1f, 40f)
+    }
+
+    override fun onDrawFrame(gl: GL10?) {
+        GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT or GLES20.GL_DEPTH_BUFFER_BIT)
+
+        val cells = snapshot
+        val n = gridN
+        boundHalf = (n - 1) * spacing / 2f + cubeSize / 2f + 0.12f
+
+        // Auto-rotación lenta tras unos segundos sin tocar: da vida y muestra el 3D.
+        val now = SystemClock.elapsedRealtime()
+        if (now - lastTouchMs > 2500L) yaw += 0.18f
+
+        val dist = boundHalf * 3.6f + 1.6f
+        Matrix.setLookAtM(view, 0, 0f, 0f, dist, 0f, 0f, 0f, 0f, 1f, 0f)
+        Matrix.multiplyMM(vp, 0, proj, 0, view, 0)
+
+        Matrix.setIdentityM(rot, 0)
+        Matrix.rotateM(rot, 0, pitch, 1f, 0f, 0f)
+        Matrix.rotateM(rot, 0, yaw, 0f, 1f, 0f)
+
+        if (cells.isNotEmpty()) drawCubes(cells, n, now)
+        if (showWire) drawBox()
+        if (showAxes) drawAxes()
+    }
+
+    private fun drawCubes(cells: IntArray, n: Int, now: Long) {
+        // Recolecta celdas llenas con su profundidad (z tras rotar) y ordena de
+        // lejos a cerca para que la mezcla por alfa quede bien.
+        val n2 = n * n
+        val filled = ArrayList<Int>(cells.size)
+        for (i in cells.indices) if (cells[i] != 0) filled.add(i)
+        filled.sortBy { i ->
+            val x = i % n; val y = (i / n) % n; val z = i / n2
+            rot[2] * coord(x, n) + rot[6] * coord(y, n) + rot[10] * coord(z, n)
+        }
+
+        GLES20.glUseProgram(cubeProg)
+        GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
+        GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, tex)
+        GLES20.glUniform1i(uTex, 0)
+        val scale = Geometry.FLOATS_PER_VERTEX * 4
+        cubeBuf.position(0)
+        GLES20.glVertexAttribPointer(aPos, 3, GLES20.GL_FLOAT, false, scale, cubeBuf)
+        GLES20.glEnableVertexAttribArray(aPos)
+        cubeBuf.position(3)
+        GLES20.glVertexAttribPointer(aNormal, 3, GLES20.GL_FLOAT, false, scale, cubeBuf)
+        GLES20.glEnableVertexAttribArray(aNormal)
+        cubeBuf.position(6)
+        GLES20.glVertexAttribPointer(aUV, 2, GLES20.GL_FLOAT, false, scale, cubeBuf)
+        GLES20.glEnableVertexAttribArray(aUV)
+
+        val uvScale = Atlas.uvScale
+        val spawnPop = if (spawnIndex >= 0) {
+            val t = ((now - spawnAtMs) / 160f).coerceIn(0f, 1f)
+            0.2f + 0.8f * (t * (2f - t)) // easeOutQuad
+        } else 1f
+
+        for (i in filled) {
+            val x = i % n; val y = (i / n) % n; val z = i / n2
+            val value = cells[i]
+            val pop = if (i == spawnIndex) spawnPop else 1f
+            val s = cubeSize * pop
+
+            Matrix.setIdentityM(tmp, 0)
+            Matrix.translateM(tmp, 0, coord(x, n), coord(y, n), coord(z, n))
+            Matrix.scaleM(tmp, 0, s, s, s)
+            Matrix.multiplyMM(modelCube, 0, rot, 0, tmp, 0)
+            Matrix.multiplyMM(mvp, 0, vp, 0, modelCube, 0)
+
+            GLES20.glUniformMatrix4fv(uMVP, 1, false, mvp, 0)
+            GLES20.glUniformMatrix4fv(uModel, 1, false, modelCube, 0)
+            val min = Atlas.uvMin(Palette.exp(value))
+            GLES20.glUniform2f(uUVMin, min[0], min[1])
+            GLES20.glUniform2f(uUVScale, uvScale[0], uvScale[1])
+            GLES20.glUniform1f(uAlpha, alpha)
+            GLES20.glDrawArrays(GLES20.GL_TRIANGLES, 0, Geometry.CUBE_VERTEX_COUNT)
+        }
+
+        GLES20.glDisableVertexAttribArray(aPos)
+        GLES20.glDisableVertexAttribArray(aNormal)
+        GLES20.glDisableVertexAttribArray(aUV)
+    }
+
+    private fun drawBox() {
+        boxBuf = Geometry.boxEdges(boundHalf)
+        Matrix.multiplyMM(mvpLine, 0, vp, 0, rot, 0)
+        GLES20.glUseProgram(lineProg)
+        GLES20.glUniformMatrix4fv(lMVP, 1, false, mvpLine, 0)
+        GLES20.glUniform4f(lColor, 1f, 1f, 1f, 0.18f)
+        boxBuf.position(0)
+        GLES20.glVertexAttribPointer(lPos, 3, GLES20.GL_FLOAT, false, 12, boxBuf)
+        GLES20.glEnableVertexAttribArray(lPos)
+        GLES20.glDrawArrays(GLES20.GL_LINES, 0, 24)
+        GLES20.glDisableVertexAttribArray(lPos)
+    }
+
+    private fun drawAxes() {
+        val len = boundHalf + 0.45f
+        axisX = Geometry.segment(floatArrayOf(len, 0f, 0f))
+        axisY = Geometry.segment(floatArrayOf(0f, len, 0f))
+        axisZ = Geometry.segment(floatArrayOf(0f, 0f, len))
+        Matrix.multiplyMM(mvpLine, 0, vp, 0, rot, 0)
+        GLES20.glUseProgram(lineProg)
+        GLES20.glUniformMatrix4fv(lMVP, 1, false, mvpLine, 0)
+        axis(axisX, 1f, 0.18f, 0.34f) // X rojo
+        axis(axisY, 0.49f, 1f, 0.31f) // Y verde
+        axis(axisZ, 0f, 0.9f, 1f)     // Z azul
+    }
+
+    private fun axis(buf: java.nio.FloatBuffer, r: Float, g: Float, b: Float) {
+        GLES20.glUniform4f(lColor, r, g, b, 0.95f)
+        buf.position(0)
+        GLES20.glVertexAttribPointer(lPos, 3, GLES20.GL_FLOAT, false, 12, buf)
+        GLES20.glEnableVertexAttribArray(lPos)
+        GLES20.glDrawArrays(GLES20.GL_LINES, 0, 2)
+        GLES20.glDisableVertexAttribArray(lPos)
+    }
+
+    /** Centro de la celda [i] (0..n-1) en el eje, simétrico respecto al origen. */
+    private fun coord(i: Int, n: Int): Float = (i - (n - 1) / 2f) * spacing
+
+    private fun uploadAtlas(): Int {
+        val ids = IntArray(1)
+        GLES20.glGenTextures(1, ids, 0)
+        GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, ids[0])
+        GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR)
+        GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR)
+        GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE)
+        GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE)
+        val bmp = Atlas.build()
+        GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, bmp, 0)
+        bmp.recycle()
+        return ids[0]
+    }
+
+    private fun program(vs: String, fs: String): Int {
+        val v = shader(GLES20.GL_VERTEX_SHADER, vs)
+        val f = shader(GLES20.GL_FRAGMENT_SHADER, fs)
+        val p = GLES20.glCreateProgram()
+        GLES20.glAttachShader(p, v)
+        GLES20.glAttachShader(p, f)
+        GLES20.glLinkProgram(p)
+        val ok = IntArray(1)
+        GLES20.glGetProgramiv(p, GLES20.GL_LINK_STATUS, ok, 0)
+        check(ok[0] != 0) { "link: " + GLES20.glGetProgramInfoLog(p) }
+        return p
+    }
+
+    private fun shader(type: Int, src: String): Int {
+        val s = GLES20.glCreateShader(type)
+        GLES20.glShaderSource(s, src)
+        GLES20.glCompileShader(s)
+        val ok = IntArray(1)
+        GLES20.glGetShaderiv(s, GLES20.GL_COMPILE_STATUS, ok, 0)
+        check(ok[0] != 0) { "compile: " + GLES20.glGetShaderInfoLog(s) }
+        return s
+    }
+
+    private companion object {
+        const val VS_CUBE = """
+            uniform mat4 uMVP;
+            uniform mat4 uModel;
+            attribute vec3 aPos;
+            attribute vec3 aNormal;
+            attribute vec2 aUV;
+            varying vec2 vUV;
+            varying vec3 vN;
+            void main() {
+                gl_Position = uMVP * vec4(aPos, 1.0);
+                vUV = aUV;
+                mat3 nm = mat3(uModel[0].xyz, uModel[1].xyz, uModel[2].xyz);
+                vN = nm * aNormal;
+            }
+        """
+        const val FS_CUBE = """
+            precision mediump float;
+            uniform sampler2D uTex;
+            uniform vec2 uUVMin;
+            uniform vec2 uUVScale;
+            uniform float uAlpha;
+            varying vec2 vUV;
+            varying vec3 vN;
+            void main() {
+                vec3 L = normalize(vec3(0.4, 0.8, 0.6));
+                vec4 t = texture2D(uTex, uUVMin + vUV * uUVScale);
+                float d = 0.55 + 0.45 * max(dot(normalize(vN), L), 0.0);
+                gl_FragColor = vec4(t.rgb * d, uAlpha);
+            }
+        """
+        const val VS_LINE = """
+            uniform mat4 uMVP;
+            attribute vec3 aPos;
+            void main() { gl_Position = uMVP * vec4(aPos, 1.0); }
+        """
+        const val FS_LINE = """
+            precision mediump float;
+            uniform vec4 uColor;
+            void main() { gl_FragColor = uColor; }
+        """
+    }
+}
diff --git a/cubo/src/main/java/com/rizoma/cubo/gl/Geometry.kt b/cubo/src/main/java/com/rizoma/cubo/gl/Geometry.kt
new file mode 100644
index 0000000..0fa647a
--- /dev/null
+++ b/cubo/src/main/java/com/rizoma/cubo/gl/Geometry.kt
@@ -0,0 +1,75 @@
+package com.rizoma.cubo.gl
+
+import java.nio.ByteBuffer
+import java.nio.ByteOrder
+import java.nio.FloatBuffer
+
+/** Geometría estática: un cubo unidad texturizado y las líneas del armazón. */
+object Geometry {
+
+    const val FLOATS_PER_VERTEX = 8 // pos(3) + normal(3) + uv(2)
+    const val CUBE_VERTEX_COUNT = 36
+
+    /** Cubo centrado en el origen, arista 1 (de -0.5 a 0.5). 6 caras texturizadas. */
+    fun cube(): FloatBuffer {
+        val h = 0.5f
+        // Cada cara: 4 esquinas (c0..c3) + normal. UV: c0=(0,1) c1=(1,1) c2=(1,0) c3=(0,0),
+        // así el número del atlas sale derecho (v=0 es el borde superior de la baldosa).
+        val faces = arrayOf(
+            // +Z
+            face(floatArrayOf(-h, -h, h), floatArrayOf(h, -h, h), floatArrayOf(h, h, h), floatArrayOf(-h, h, h), 0f, 0f, 1f),
+            // -Z
+            face(floatArrayOf(h, -h, -h), floatArrayOf(-h, -h, -h), floatArrayOf(-h, h, -h), floatArrayOf(h, h, -h), 0f, 0f, -1f),
+            // +X
+            face(floatArrayOf(h, -h, h), floatArrayOf(h, -h, -h), floatArrayOf(h, h, -h), floatArrayOf(h, h, h), 1f, 0f, 0f),
+            // -X
+            face(floatArrayOf(-h, -h, -h), floatArrayOf(-h, -h, h), floatArrayOf(-h, h, h), floatArrayOf(-h, h, -h), -1f, 0f, 0f),
+            // +Y
+            face(floatArrayOf(-h, h, h), floatArrayOf(h, h, h), floatArrayOf(h, h, -h), floatArrayOf(-h, h, -h), 0f, 1f, 0f),
+            // -Y
+            face(floatArrayOf(-h, -h, -h), floatArrayOf(h, -h, -h), floatArrayOf(h, -h, h), floatArrayOf(-h, -h, h), 0f, -1f, 0f),
+        )
+        val data = FloatArray(CUBE_VERTEX_COUNT * FLOATS_PER_VERTEX)
+        var o = 0
+        for (f in faces) { System.arraycopy(f, 0, data, o, f.size); o += f.size }
+        return data.toBuffer()
+    }
+
+    /** 12 aristas de una caja [-half, half] como pares de vértices (GL_LINES). */
+    fun boxEdges(half: Float): FloatBuffer {
+        val c = arrayOf(
+            floatArrayOf(-half, -half, -half), floatArrayOf(half, -half, -half),
+            floatArrayOf(half, half, -half), floatArrayOf(-half, half, -half),
+            floatArrayOf(-half, -half, half), floatArrayOf(half, -half, half),
+            floatArrayOf(half, half, half), floatArrayOf(-half, half, half),
+        )
+        val edges = arrayOf(
+            0 to 1, 1 to 2, 2 to 3, 3 to 0, // cara trasera
+            4 to 5, 5 to 6, 6 to 7, 7 to 4, // cara delantera
+            0 to 4, 1 to 5, 2 to 6, 3 to 7, // aristas que las unen
+        )
+        val data = FloatArray(edges.size * 2 * 3)
+        var o = 0
+        for ((a, b) in edges) {
+            System.arraycopy(c[a], 0, data, o, 3); o += 3
+            System.arraycopy(c[b], 0, data, o, 3); o += 3
+        }
+        return data.toBuffer()
+    }
+
+    /** Segmento (origen → punta) para un eje del gnomon. */
+    fun segment(end: FloatArray): FloatBuffer =
+        floatArrayOf(0f, 0f, 0f, end[0], end[1], end[2]).toBuffer()
+
+    private fun face(c0: FloatArray, c1: FloatArray, c2: FloatArray, c3: FloatArray, nx: Float, ny: Float, nz: Float): FloatArray {
+        fun v(c: FloatArray, u: Float, w: Float) = floatArrayOf(c[0], c[1], c[2], nx, ny, nz, u, w)
+        // Dos triángulos: c0,c1,c2 y c0,c2,c3.
+        return v(c0, 0f, 1f) + v(c1, 1f, 1f) + v(c2, 1f, 0f) +
+            v(c0, 0f, 1f) + v(c2, 1f, 0f) + v(c3, 0f, 0f)
+    }
+
+    private fun FloatArray.toBuffer(): FloatBuffer =
+        ByteBuffer.allocateDirect(size * 4).order(ByteOrder.nativeOrder()).asFloatBuffer().apply {
+            put(this@toBuffer); position(0)
+        }
+}
diff --git a/cubo/src/main/java/com/rizoma/cubo/gl/Palette.kt b/cubo/src/main/java/com/rizoma/cubo/gl/Palette.kt
new file mode 100644
index 0000000..4c5cf04
--- /dev/null
+++ b/cubo/src/main/java/com/rizoma/cubo/gl/Palette.kt
@@ -0,0 +1,40 @@
+package com.rizoma.cubo.gl
+
+/**
+ * Color de una baldosa por su valor. Gama neón sobre vacío (la del rizoma),
+ * recorriendo el círculo de matices según el exponente para que cada potencia
+ * de dos se distinga de un vistazo.
+ */
+object Palette {
+    // Exponente 1..15 → valor 2..32768. Colores ARGB opacos.
+    private val COLORS = intArrayOf(
+        0xFF2A2A3A.toInt(), // 1  (no usado: exp 0)
+        0xFF3DD6FF.toInt(), // 2
+        0xFF00E5FF.toInt(), // 4
+        0xFF36E0B0.toInt(), // 8
+        0xFF7CFF4F.toInt(), // 16
+        0xFFC8FF3D.toInt(), // 32
+        0xFFFFE03D.toInt(), // 64
+        0xFFFFB23D.toInt(), // 128
+        0xFFFF7A3D.toInt(), // 256
+        0xFFFF4F6D.toInt(), // 512
+        0xFFFF2E88.toInt(), // 1024
+        0xFFE23DFF.toInt(), // 2048
+        0xFFB45CFF.toInt(), // 4096
+        0xFF7C5CFF.toInt(), // 8192
+        0xFF5C7CFF.toInt(), // 16384
+        0xFFEAF2FF.toInt(), // 32768
+    )
+
+    const val MAX_EXP = 15
+
+    /** Exponente de un valor (2 → 1, 4 → 2, …). 0 si no es potencia válida. */
+    fun exp(value: Int): Int {
+        if (value < 2) return 0
+        var v = value; var e = 0
+        while (v > 1) { v = v shr 1; e++ }
+        return e
+    }
+
+    fun colorFor(value: Int): Int = COLORS[exp(value).coerceIn(0, COLORS.lastIndex)]
+}
diff --git a/cubo/src/main/java/com/rizoma/cubo/ui/CuboApp.kt b/cubo/src/main/java/com/rizoma/cubo/ui/CuboApp.kt
new file mode 100644
index 0000000..7307632
--- /dev/null
+++ b/cubo/src/main/java/com/rizoma/cubo/ui/CuboApp.kt
@@ -0,0 +1,263 @@
+package com.rizoma.cubo.ui
+
+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.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.systemBars
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Slider
+import androidx.compose.material3.SliderDefaults
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalView
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+import androidx.lifecycle.findViewTreeLifecycleOwner
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.rizoma.cubo.CuboViewModel
+import com.rizoma.cubo.gl.CuboGLView
+import com.rizoma.cubo.ui.theme.MuyTenue
+import com.rizoma.cubo.ui.theme.NeonCian
+import com.rizoma.cubo.ui.theme.NeonRojo
+import com.rizoma.cubo.ui.theme.NeonVerde
+import com.rizoma.cubo.ui.theme.SuperficieAlta
+import com.rizoma.cubo.ui.theme.Tenue
+import com.rizoma.cubo.ui.theme.Vacio
+
+@Composable
+fun CuboApp(vm: CuboViewModel = viewModel()) {
+    val androidView = LocalView.current
+    // Una sola instancia de la vista GL durante toda la vida de la pantalla.
+    val glView = remember { mutableStateOf<CuboGLView?>(null) }
+
+    var alpha by remember { mutableFloatStateOf(0.62f) }
+    var wire by remember { mutableStateOf(true) }
+    var axes by remember { mutableStateOf(true) }
+
+    // Empuja el tablero al renderer cada vez que cambia la partida.
+    LaunchedEffect(vm.version, glView.value) {
+        glView.value?.renderer?.submit(vm.snapshot(), vm.n, vm.spawnIndex)
+    }
+    LaunchedEffect(alpha, wire, axes, glView.value) {
+        glView.value?.renderer?.let {
+            it.alpha = alpha; it.showWire = wire; it.showAxes = axes
+        }
+    }
+
+    Box(
+        Modifier
+            .fillMaxSize()
+            .background(Vacio)
+            .windowInsetsPadding(WindowInsets.systemBars),
+    ) {
+        Column(Modifier.fillMaxSize().padding(16.dp)) {
+            Header(vm)
+
+            // El cubo 3D: ocupa el grueso de la pantalla, arrastra para rotar.
+            Box(Modifier.fillMaxWidth().weight(1f)) {
+                AndroidViewHost(onView = { glView.value = it })
+            }
+
+            Transparencia(alpha, { alpha = it }, wire, { wire = it }, axes, { axes = it })
+            Spacer(Modifier.height(12.dp))
+            DirectionPad(enabled = !vm.over) { axis, dir -> vm.move(axis, dir) }
+        }
+
+        // El banner de fin de partida solo bloquea cuando ya no hay jugadas;
+        // tras llegar a 2048 se sigue jugando (un distintivo aparece en la cabecera).
+        if (vm.over) {
+            Banner(vm) { vm.reset() }
+        }
+    }
+
+    // Liga el ciclo de vida de la GLSurfaceView al de la pantalla (onPause/onResume).
+    DisposableEffect(androidView, glView.value) {
+        val view = glView.value
+        val owner = androidView.findViewTreeLifecycleOwner()
+        val obs = LifecycleEventObserver { _, ev ->
+            when (ev) {
+                Lifecycle.Event.ON_RESUME -> view?.onResume()
+                Lifecycle.Event.ON_PAUSE -> view?.onPause()
+                else -> {}
+            }
+        }
+        owner?.lifecycle?.addObserver(obs)
+        onDispose { owner?.lifecycle?.removeObserver(obs) }
+    }
+}
+
+@Composable
+private fun AndroidViewHost(onView: (CuboGLView) -> Unit) {
+    AndroidView(
+        factory = { ctx -> CuboGLView(ctx).also(onView) },
+        modifier = Modifier.fillMaxSize(),
+    )
+}
+
+@Composable
+private fun Header(vm: CuboViewModel) {
+    Row(
+        Modifier.fillMaxWidth().padding(bottom = 10.dp),
+        horizontalArrangement = Arrangement.SpaceBetween,
+        verticalAlignment = Alignment.CenterVertically,
+    ) {
+        Column {
+            Text("cubo · 2048 en 3D", color = NeonVerde, fontSize = 18.sp, fontWeight = FontWeight.Bold)
+            Text(
+                if (vm.won) "✦ 2048 alcanzado" else "arrastra para rotar",
+                color = if (vm.won) NeonCian else MuyTenue,
+                fontSize = 12.sp,
+            )
+        }
+        Column(horizontalAlignment = Alignment.End) {
+            Text("PUNTOS", color = MuyTenue, fontSize = 10.sp, letterSpacing = 2.sp)
+            Text("${vm.score}", color = Color.White, fontSize = 26.sp, fontWeight = FontWeight.Bold)
+            Text("mejor ${vm.mejorScore}", color = Tenue, fontSize = 11.sp)
+        }
+    }
+}
+
+@Composable
+private fun Transparencia(
+    alpha: Float, onAlpha: (Float) -> Unit,
+    wire: Boolean, onWire: (Boolean) -> Unit,
+    axes: Boolean, onAxes: (Boolean) -> Unit,
+) {
+    Row(verticalAlignment = Alignment.CenterVertically) {
+        Text("ver dentro", color = Tenue, fontSize = 12.sp, modifier = Modifier.width(78.dp))
+        Slider(
+            value = alpha,
+            onValueChange = onAlpha,
+            valueRange = 0.18f..1f,
+            colors = SliderDefaults.colors(thumbColor = NeonCian, activeTrackColor = NeonCian),
+            modifier = Modifier.weight(1f),
+        )
+        Spacer(Modifier.width(8.dp))
+        Toggle("caja", wire) { onWire(!wire) }
+        Spacer(Modifier.width(6.dp))
+        Toggle("ejes", axes) { onAxes(!axes) }
+    }
+}
+
+@Composable
+private fun Toggle(label: String, on: Boolean, onClick: () -> Unit) {
+    Surface(
+        color = if (on) SuperficieAlta else Vacio,
+        shape = RoundedCornerShape(50),
+        modifier = Modifier.clip(RoundedCornerShape(50)).clickable(onClick = onClick),
+    ) {
+        Text(
+            label,
+            color = if (on) NeonVerde else MuyTenue,
+            fontSize = 12.sp,
+            modifier = Modifier.padding(horizontal = 12.dp, vertical = 7.dp),
+        )
+    }
+}
+
+/** Las seis caras: un par de botones por eje, coloreados como el gnomon. */
+@Composable
+private fun DirectionPad(enabled: Boolean, onMove: (axis: Int, dir: Int) -> Unit) {
+    Row(
+        Modifier.fillMaxWidth(),
+        horizontalArrangement = Arrangement.spacedBy(10.dp),
+    ) {
+        AxisColumn("X", NeonRojo, enabled, 0, onMove, Modifier.weight(1f))
+        AxisColumn("Y", NeonVerde, enabled, 1, onMove, Modifier.weight(1f))
+        AxisColumn("Z", NeonCian, enabled, 2, onMove, Modifier.weight(1f))
+    }
+}
+
+@Composable
+private fun AxisColumn(
+    label: String, color: Color, enabled: Boolean, axis: Int,
+    onMove: (Int, Int) -> Unit, modifier: Modifier = Modifier,
+) {
+    Column(modifier, horizontalAlignment = Alignment.CenterHorizontally) {
+        DirButton("$label +", color, enabled) { onMove(axis, +1) }
+        Spacer(Modifier.height(8.dp))
+        DirButton("$label −", color, enabled) { onMove(axis, -1) }
+    }
+}
+
+@Composable
+private fun DirButton(text: String, color: Color, enabled: Boolean, onClick: () -> Unit) {
+    Surface(
+        color = SuperficieAlta,
+        shape = RoundedCornerShape(14.dp),
+        modifier = Modifier
+            .fillMaxWidth()
+            .height(52.dp)
+            .clip(RoundedCornerShape(14.dp))
+            .then(if (enabled) Modifier.clickable(onClick = onClick) else Modifier),
+    ) {
+        Box(contentAlignment = Alignment.Center) {
+            Text(
+                text,
+                color = if (enabled) color else MuyTenue,
+                fontSize = 18.sp,
+                fontWeight = FontWeight.Bold,
+            )
+        }
+    }
+}
+
+@Composable
+private fun Banner(vm: CuboViewModel, onReset: () -> Unit) {
+    Box(Modifier.fillMaxSize().padding(20.dp), contentAlignment = Alignment.BottomCenter) {
+        Surface(color = SuperficieAlta, shape = RoundedCornerShape(18.dp), modifier = Modifier.fillMaxWidth()) {
+            Row(
+                Modifier.fillMaxWidth().padding(20.dp),
+                horizontalArrangement = Arrangement.SpaceBetween,
+                verticalAlignment = Alignment.CenterVertically,
+            ) {
+                Column {
+                    Text(
+                        if (vm.won) "¡2048!" else "sin movimientos",
+                        color = if (vm.won) NeonVerde else NeonRojo,
+                        fontSize = 20.sp,
+                        fontWeight = FontWeight.Bold,
+                    )
+                    Text("${vm.score} puntos · baldosa ${vm.maxTile}", color = Tenue, fontSize = 13.sp)
+                }
+                Text(
+                    "reiniciar  ↻",
+                    color = NeonVerde,
+                    fontSize = 16.sp,
+                    fontWeight = FontWeight.Bold,
+                    modifier = Modifier
+                        .clip(RoundedCornerShape(50))
+                        .clickable(onClick = onReset)
+                        .padding(horizontal = 14.dp, vertical = 8.dp),
+                )
+            }
+        }
+    }
+}
diff --git a/cubo/src/main/java/com/rizoma/cubo/ui/theme/Theme.kt b/cubo/src/main/java/com/rizoma/cubo/ui/theme/Theme.kt
new file mode 100644
index 0000000..a9ea98d
--- /dev/null
+++ b/cubo/src/main/java/com/rizoma/cubo/ui/theme/Theme.kt
@@ -0,0 +1,37 @@
+package com.rizoma.cubo.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 Matiz/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 NeonRojo = Color(0xFFFF2E57)
+val Tenue = Color(0xB3FFFFFF)
+val MuyTenue = Color(0x66FFFFFF)
+
+private val EsquemaCubo = 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 CuboTheme(content: @Composable () -> Unit) {
+    MaterialTheme(colorScheme = EsquemaCubo, content = content)
+}
diff --git a/cubo/src/main/res/drawable/ic_launcher_fg.xml b/cubo/src/main/res/drawable/ic_launcher_fg.xml
new file mode 100644
index 0000000..b711740
--- /dev/null
+++ b/cubo/src/main/res/drawable/ic_launcher_fg.xml
@@ -0,0 +1,11 @@
+<?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">
+    <!-- cubo isométrico: tres caras neón -->
+    <path android:fillColor="#00E5FF" android:pathData="M54 24 L84 42 L54 60 L24 42 Z" />
+    <path android:fillColor="#FF2E88" android:pathData="M24 42 L54 60 L54 96 L24 78 Z" />
+    <path android:fillColor="#7CFF4F" android:pathData="M84 42 L54 60 L54 96 L84 78 Z" />
+</vector>
diff --git a/cubo/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/cubo/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..088fcce
--- /dev/null
+++ b/cubo/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/cubo/src/main/res/values/colors.xml b/cubo/src/main/res/values/colors.xml
new file mode 100644
index 0000000..917256d
--- /dev/null
+++ b/cubo/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/cubo/src/main/res/values/strings.xml b/cubo/src/main/res/values/strings.xml
new file mode 100644
index 0000000..11fe2e1
--- /dev/null
+++ b/cubo/src/main/res/values/strings.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <string name="app_name">cubo</string>
+</resources>
diff --git a/cubo/src/main/res/values/themes.xml b/cubo/src/main/res/values/themes.xml
new file mode 100644
index 0000000..ae82498
--- /dev/null
+++ b/cubo/src/main/res/values/themes.xml
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <style name="Theme.Cubo" 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 190a27e..1b2b56e 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -26,3 +26,4 @@ include(":consola")
 include(":matiz")
 include(":nimbo")
 include(":notas")
+include(":cubo")