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:
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_bg">#050507</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">cubo</string>
|
||||
</resources>
|
||||
@@ -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>
|
||||
@@ -26,3 +26,4 @@ include(":consola")
|
||||
include(":matiz")
|
||||
include(":nimbo")
|
||||
include(":notas")
|
||||
include(":cubo")
|
||||
|
||||
Reference in New Issue
Block a user