Bolitas y pistola - JRuizGil/MeltTheDeep GitHub Wiki
TDD · Documento técnico
El agua no es un fluido: son esferas con física que se fusionan por niveles, como las frutas de Suika Game. Cada nivel tiene el doble de litros que el anterior y un azul más oscuro.
| Nivel | Litros | Radio respecto al nivel 0 |
|---|---|---|
| 0 | 2 | ×1 |
| 1 | 4 | ×1,26 |
| 2 | 8 | ×1,59 |
| 3 | 16 | ×2 |
El radio es proporcional a la raíz cúbica del volumen: una bolita de 16 L tiene ocho veces el volumen de una de 2 L, pero solo el doble de radio.
Las bolitas salen de un ObjectPool<WaterBall> con 600 instancias y un máximo de 400 activas. Instanciar y destruir cientos de objetos con Rigidbody produce picos de memoria, y con el pool las reutilizo. Cuando se alcanza el límite de activas, el volumen nuevo no crea otra bolita: se suma a la bolita más cercana del mismo nivel. Cada bolita guarda sus litros reales, así que al vender no se pierde nada aunque el límite se haya tocado.
La fusión se resuelve en OnCollisionEnter. Dos bolitas se fusionan si son del mismo nivel y no es el máximo. Las dos reciben el callback a la vez, así que decido quién se queda con un criterio fijo: sobrevive la de menor GetInstanceID, que sube de nivel, suma los litros y se coloca en el punto medio. La otra vuelve al pool. Gracias a esa regla una pareja se fusiona una sola vez.
Hay un material por nivel. No uso MaterialPropertyBlock para el color, porque rompe el SRP Batcher y con cientos de bolitas el coste de los draw calls se nota. Ajusto el sleepThreshold del Rigidbody para que las bolitas se duerman pronto cuando quedan quietas, que es el ahorro de física más grande. La matriz de colisiones deja que el agua choque con el agua, el hielo y el botín. El jugador no choca con las bolitas: solo las detecta mediante un trigger.
AccumulationGrid divide el módulo en celdas de 1 m y suma los litros de las bolitas que hay en cada una. El freno del fundido es freno = 1 / (1 + k × litros), donde k es una constante que ajusto con las pruebas. Con la celda vacía el freno vale 1 y no hace nada; con muchas bolitas sobre el hielo, el fundido de esa zona va más despacio, que es lo que me obliga a alternar chorro y aspiradora. La rejilla se calcula con las bolitas activas, como mucho 400, así que cuesta poco.
El chorro también puede apartar bolitas. Lo hace con Physics.OverlapCapsuleNonAlloc a lo largo del haz y les aplica un empuje. Empujar gasta agua caliente igual que fundir y no devuelve nada, mientras que aspirar es gratis y da agua para vender.
WaterGun es una máquina de estados con tres estados, y solo uno está activo a la vez.
stateDiagram-v2
[*] --> Inactivo
Inactivo --> Chorro: clic izquierdo
Chorro --> Inactivo: suelto
Inactivo --> Aspirar: clic derecho
Aspirar --> Inactivo: suelto
| Acción | Control |
|---|---|
| Chorro | Clic izquierdo |
| Aspiradora | Clic derecho |
| Amplitud del chorro | Rueda del ratón |
| Interactuar | E |
La temperatura depende de la amplitud: T = Tmax × (1 − p × amplitud) + estabilizador. Tmax es la temperatura máxima, p es cuánto se enfría el chorro al abrirlo del todo y estabilizador es lo que compensa una mejora. El consumo de agua caliente es proporcional a la potencia. Si el tanque se vacía, el chorro se corta. Las partículas del chorro son solo visuales: lo que funde, empuja o calienta lo decide el raycast y la cápsula, no las partículas.
El fundido de hielo está en Sistema de hielo.
Una MineralVein es un grupo de cristales sobre la capa Mineral, cada uno con su collider. Cuando el raycast del chorro golpea un cristal, o pasa dentro de su radio, le aplico calor:
calor = potencia × temperatura × Δt / resistencia
La resistencia viene de la MineralDefinition del cristal. A medida que el calor acumulado baja su integridad, el shader recibe el parámetro _Crack y se ven las grietas, y el sonido de FMOD sigue la integridad (Sonido con FMOD). Al llegar a 0 el cristal se desprende en uno a tres fragmentos. Cada fragmento es un LootItem con su MineralDefinition (tipo, tamaño y volumen), un Rigidbody y un impulso en la dirección de la normal de la pared, de modo que salen despedidos hacia la cueva y no hacia dentro del hielo.
Cada fotograma físico busco objetivos con Physics.OverlapSphereNonAlloc y me quedo con los que caen dentro de un cono delante de la boquilla, comparando el producto escalar con el coseno del semiángulo. La fuerza sobre cada objeto es proporcional a la potencia entre la distancia, con una distancia mínima para que no se dispare al estar encima.
Lo que ocurre en la boca depende del objeto:
| Objeto | Resultado |
|---|---|
| Bolita | Sus litros pasan al tanque y la bolita vuelve al pool |
| Pez | Se registra en el catálogo de especies |
| Mineral o hueso | Si su tamaño supera la apertura, se queda sujeto en la boca y aparece el aviso "no cabe". Si cabe, entra en el tanque siempre que haya espacio |
Con el tanque lleno la aspiradora deja de ejercer fuerza y el indicador parpadea.
El tanque va sobre un carro que sigue un Spline por el borde del módulo. Cada fotograma lo coloco en el punto de la curva más cercano al jugador. La manguera limita mis movimientos: la distancia en línea recta entre el carro y yo no puede pasar de su longitud, y eso también vale dentro de las cuevas, así que una cueva profunda me pide haber mejorado la manguera. Para dibujarla uso un LineRenderer con puntos que aproximan una catenaria: cuanto más sobra de longitud, más cuelga.
Los valores de esta página (litros por nivel, tope de bolitas, k, p, ángulo del cono) son valores iniciales y los ajusto con las pruebas. Las reglas de juego correspondientes están en Mecánicas.