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>
Semántico sayk'uy
Mana ima sutichaypas t'ikrakurqanchu: qillqalla, yachachiylla utaq willaylla t'ikrakurqan.
19 qillqakuna · +1181 −0
- cubo/build.gradle.kts +50 −0
- cubo/src/main/AndroidManifest.xml +24 −0
- cubo/src/main/java/com/rizoma/cubo/CuboViewModel.kt +68 −0
- cubo/src/main/java/com/rizoma/cubo/MainActivity.kt +20 −0
- cubo/src/main/java/com/rizoma/cubo/data/Progreso.kt +32 −0
- cubo/src/main/java/com/rizoma/cubo/game/Cubo.kt +106 −0
- cubo/src/main/java/com/rizoma/cubo/gl/Atlas.kt +80 −0
- cubo/src/main/java/com/rizoma/cubo/gl/CuboGLView.kt +40 −0
- cubo/src/main/java/com/rizoma/cubo/gl/CuboRenderer.kt +312 −0
- cubo/src/main/java/com/rizoma/cubo/gl/Geometry.kt +75 −0
- cubo/src/main/java/com/rizoma/cubo/gl/Palette.kt +40 −0
- cubo/src/main/java/com/rizoma/cubo/ui/CuboApp.kt +263 −0
- cubo/src/main/java/com/rizoma/cubo/ui/theme/Theme.kt +37 −0
- cubo/src/main/res/drawable/ic_launcher_fg.xml +11 −0
- cubo/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +5 −0
- cubo/src/main/res/values/colors.xml +4 −0
- cubo/src/main/res/values/strings.xml +4 −0
- cubo/src/main/res/values/themes.xml +9 −0
- settings.gradle.kts +1 −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")