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>
This commit is contained in:
2026-06-13 21:24:55 -04:00
co-authored by Claude Opus 4.8
parent b3044bd182
commit 3ae5fa52dc
19 changed files with 1181 additions and 0 deletions
+50
View File
@@ -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)
}
+24
View File
@@ -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>
@@ -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) }
}
}
}
@@ -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()
}
}
}
}
@@ -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")
}
}
@@ -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()
}
@@ -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
}
}
@@ -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
}
}
@@ -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; }
"""
}
}
@@ -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)
}
}
@@ -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)]
}
@@ -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),
)
}
}
}
}
@@ -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)
}
@@ -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>
@@ -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>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_bg">#050507</color>
</resources>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">cubo</string>
</resources>
+9
View File
@@ -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>
+1
View File
@@ -26,3 +26,4 @@ include(":consola")
include(":matiz")
include(":nimbo")
include(":notas")
include(":cubo")