Saltar al contenido principal
Volver atrás

Godot #5: Trigonometría de Combate

#godot #gdscript #math #trigonometry #gamedev

Enemigos que ondulan, orbitan y ejecutan patrones matemáticos. Aprende a usar Seno y Coseno para movimientos clásicos de arcade.

Enemigos Predecibles son Enemigos Aburridos

Hasta ahora, nuestros enemigos solo se mueven en línea recta. Funciona, pero no impresiona. Los shooters clásicos como Galaga, Gradius o R-Type tienen enemigos que ondulan, orbitan y ejecutan patrones coreografiados.

La clave de estos movimientos son dos funciones matemáticas: Seno y Coseno.

1. El Seno: La Onda

El Seno (sin) es una función que espera que le pases un ángulo en radianes y devuelve valores entre -1 y 1 de forma cíclica. Eso no quiere decir que le tengas que pasar necesariamente un ángulo, puedes pasarle cualquier número real y te devolverá un valor entre -1 y 1. Por lo tanto, podemos pasarle el tiempo, por ejemplo, y obtendremos un valor que oscila suavemente entre -1 y 1.

Si graficamos sin(x) donde x va de 0 a 2π (una vuelta completa), obtenemos una onda:

Valor de xsin(x)
00
π/2 (90°)1 (máximo)
π (180°)0
3π/2 (270°)-1 (mínimo)
2π (360°)0

Después de 2π, el ciclo se repite infinitamente.

cossinθ
Animación
Ángulo (θ)
0.00 rad
cos(θ)
1.000
sin(θ)
0.000

¿Para qué sirve en un juego?

Si usamos el tiempo como entrada del seno, obtenemos un valor que oscila suavemente:

gdscript
var offset_y = sin(tiempo) # Oscila entre -1 y 1

Usando el valor que devuelve sin, y multiplicándolo por una amplitud, valga la redundancia, ampliamos el rango de valores, ya no oscila entre -1 y 1, sino entre amplitud negativo y amplitud positivo. Por lo tanto, si queremos que se mueva en un rango entre -50 a 50 píxeles, multiplicaremos por 50.

gdscript
var offset_y = sin(tiempo) * 50 # Oscila entre -50 y 50 píxeles

2. Un Nuevo Enemigo: El Helicóptero

Vamos a dejar nuestro avión básico (enemy_plane.gd) como está, moviéndose recto. Para estos nuevos patrones, crearemos un nuevo enemigo, el Helicóptero, del cual haremos 2 versiones:

  1. Onda: Se mueve haciendo olas.
  2. Órbita: Gira en círculos perfectos.

Sprite del Helicóptero

Clic derecho -> Guardar imagen.
Créditos: Assets Free Laser Bullets Pack 2020 por Wenrexa (CC0 1.0).

Paso 1: El Helicóptero de Onda

  1. Crea una nueva escena, añade el nodo Area2D y llámalo EnemyWave, añade también un Sprite2D con el sprite del helicóptero y un CollisionShape2D.
  2. Agrégale un script llamado enemy_wave.gd al EnemyWave.
  3. Guarda la escena como enemy_wave.tscn.

El Script de la Onda

Queremos que este enemigo ondule verticalmente mientras avanza.

Para empezar, copia y pega todo el código de enemy_plane.gd dentro tu nuevo script enemy_wave.gd. A partir de esa base, vamos a añadir los cambios paso a paso.

1. Variables de Control

Necesitamos definir cuánto se mueve la onda (amplitud) y cómo de rápido (frecuencia).

gdscript
@export var wave_amplitude = 100.0  # Altura de la onda (en píxeles)
@export var wave_frequency = 3.0    # Velocidad de la oscilación

También necesitamos rastrear el tiempo (para alimentar a la función sin) y recordar nuestra altura inicial (para oscilar alrededor de ella, no irnos volando).

gdscript
var time = 0.0        # Acumulador de tiempo (reloj interno)
var start_y = 0.0     # Nuestra "línea base" en el eje Y

2. Guardar la posición inicial

En el _ready(), debemos guardar la posición Y inicial del enemigo.

¿Por qué? Porque la función sin() oscila alrededor de 0 (sube a +1, baja a -1). Si aplicáramos eso directamente a position.y, el enemigo saltaría a la parte superior de la pantalla (coordenada Y=0).

Al guardar start_y, establecemos un nuevo “centro” para la oscilación. Así, el enemigo oscilará hacia arriba y abajo relativo a donde lo colocamos en el editor.

gdscript
func _ready():
  start_y = position.y  # Guardamos la altura donde nos pusieron en el editor
  area_entered.connect(_on_area_entered)

3. El Movimiento (La Fórmula)

Ve a tu función _process(delta). Busca el bloque else (que se ejecuta cuando el enemigo no está muriendo) y reemplaza su contenido por este:

gdscript
func _process(delta):
  if is_dying:
      # ... (dejar como estaba o ver script final) ...
  else:
      # 1. ACUMULAR TIEMPO
      time += delta
      # 2. MOVIMIENTO HORIZONTAL (Mantenemos el de siempre)
      position.x -= speed * delta
      # 3. MOVIMIENTO VERTICAL (La novedad)
      # Y = Centro + sen(tiempo * frecuencia) * amplitud
      position.y = start_y + sin(time * wave_frequency) * wave_amplitude

El Script Completo

Juntando todo esto con el código base de daño y muerte que ya teníamos del avión:

gdscript
extends Area2D

@export var speed = 200
@export var hp = 3
@export var wave_amplitude = 100.0
@export var wave_frequency = 3.0

var is_dying = false
var fall_speed = 0.0
var time = 0.0
var start_y = 0.0

func _ready():
  start_y = position.y
  area_entered.connect(_on_area_entered)

func _process(delta):
  if is_dying:
      fall_speed += 500 * delta
      position.y += fall_speed * delta
      position.x -= speed * 0.5 * delta 
  else:
      time += delta
      position.x -= speed * delta
        
      # La fórmula de la onda
      position.y = start_y + sin(time * wave_frequency) * wave_amplitude
      # 1. start_y hará que parta de la posición original o relativa.
      # 2. usamos el tiempo en sin() como valor de ángulo lo que nos devolverá 
      # valores entre -1 y 1.
      # 3. Lo multiplicamos por la frecuencia para darle velocidad.
      # 4. Al resultado le podemos además multiplicar la amplitud para darle tamaño.
      # **Recuerda** el orden de las operaciones (multiplicación antes que la función).

func _on_area_entered(area):
  if is_dying:
      return
  
  hp -= 1
  area.queue_free()
  hit_flash()

  if hp <= 0:
      die()

func hit_flash():
  # ... (Mismo código de shader que en enemy_plane)
  pass 

func die():
  # ... (Mismo código de muerte)
  pass

Nota: He omitido el contenido de hit_flash y die en el bloque de arriba para ahorrar espacio visual, pero recuerda copiarlos completos de tu script anterior o usar la referencia completa abajo.


3. Movimiento Circular (Órbita)

Para lograr un movimiento circular, necesitamos presentar al hermano del Seno: el Coseno.

El Coseno (cos) es idéntico al Seno, pero pero en vez de oscilar en el eje Y, se mueve en el eje X.

Valor de xsin(x) (Eje Y)cos(x) (Eje X)
001
π/210
π0-1

¿Por qué es importante? Cos siempre está desfasado respecto a Sen en 90º, así que nunca valen 0 a la vez. Cuando sin vale 0, cos vale 1. Cuando cos vale 0, sin vale 1. Esta alternancia suave crea el movimiento continuo del círculo.


Implementación Práctica

Para estos enemigos orbitales, seguiremos usando nuestro Helicóptero.
⚠️ Sigue estos pasos, o sino sin querer podrías verte editando el script que no toca si no tienes cuidado.

  1. Duplica la escena enemy_wave.tscn y llámala enemy_orbital.tscn.
  2. Cambia el nombre del nodo padre EnemyWave a EnemyOrbital.
  3. Duplica el script enemy_wave.gd, llámalo enemy_orbital.gd y asígnalo al nodo EnemyOrbital.

Ahora, editemos enemy_orbital.gd paso a paso:

1. Variables: De Onda a Círculo

Como hemos copiado el script de la Onda (enemy_wave.gd), tenemos variables que ya no sirven.

Elimina estas variables:

  • speed (no queremos que avance)
  • wave_amplitude y wave_frequency (son de la onda)
  • start_y (usaremos vector completo)
  • fall_speed (usaremos inercia vectorial)

Añade las nuevas variables para el círculo:

  • orbit_radius: Radio del círculo.
  • orbit_speed: Velocidad angular.
  • velocity: Para calcular la inercia (sustituye a fall_speed).
  • center: Punto central de la órbita (sustituye a start_y).

A partir de aquí verás fácilmente qué bloques de código sustituir porque al eliminar las variables ahora el editor te marcará errores.

gdscript
@export var orbit_radius = 100.0
@export var orbit_speed = 2.0

var velocity = Vector2.ZERO
var center = Vector2.ZERO

2. Ready: Guardar el Centro

En la Onda guardábamos start_y (float), pero el Círculo necesita un centro 2D (X e Y).

gdscript
func _ready():
  center = position  # Guardamos la posición (X, Y) inicial como centro
  area_entered.connect(_on_area_entered)

3. Process: El Giro

Ve a tu _process(delta).

  1. Dentro del if is_dying: Reemplaza la lógica de caída simple por esta de inercia (necesaria porque ahora nos movemos en 2D).
  2. Dentro del else: Reemplaza toda la lógica de la onda por la del círculo.

He numerado los bloques de código para que puedas seguir mejor la lógica que seguirá.

gdscript
func _process(delta):
  if is_dying:
      # 3. FASE MUERTE: Usamos la inercia calculada
      # Ya no calculamos 'cos/sin'. Usamos la última 'velocity' conocida 
      # para seguir moviéndonos en esa dirección, y le sumamos gravedad.
      velocity.y += 1000 * delta
      position += velocity * delta
  else:
      # 1. FASE VIVA: Guardamos posición antes de movernos
      var prev_pos = position
      time += delta

      # 2. MOVIMIENTO CIRCULAR
      position.x = center.x + cos(time * orbit_speed) * orbit_radius
      position.y = center.y + sin(time * orbit_speed) * orbit_radius

      # CÁLCULO DE INERCIA (Para usarla cuando muera)
      # Como movemos 'position' a mano, Godot no sabe a qué velocidad vamos.
      # La calculamos nosotros: Velocidad = Espacio / Tiempo
      velocity = (position - prev_pos) / delta

Si bien yo te estoy soltando el código, lo aconsejable es que cada vez que escribas o pegues código intentes entender qué hace ese código y por qué lo incluimos.

El Script Completo (Orbit)

gdscript
extends Area2D

@export var hp = 3
@export var orbit_radius = 100.0
@export var orbit_speed = 2.0

var is_dying = false
var time = 0.0
var velocity = Vector2.ZERO
var center = Vector2.ZERO

func _ready():
  center = position
  area_entered.connect(_on_area_entered)

func _process(delta):
  if is_dying:
      velocity.y += 1000 * delta
      position += velocity * delta
  else:
      var prev_pos = position
      time += delta

      position.x = center.x + cos(time * orbit_speed) * orbit_radius
      position.y = center.y + sin(time * orbit_speed) * orbit_radius
      
      velocity = (position - prev_pos) / delta

func _on_area_entered(area):
  if is_dying:
      return
    
  hp -= 1
  area.queue_free()
  hit_flash()

  if hp <= 0:
      die()

# ... (El resto funciones hit_flash y die son iguales)

Este enemigo se queda girando en el sitio. En el futuro, usaremos esto para crear escuadrones que entran y se quedan orbitando en formación.


4. Tabla de Referencia

PatrónFórmula XFórmula Y
Onda verticalx -= speed * deltastart_y + sin(t * freq) * amp
Círculocentro_x + cos(t) * radiocentro_y + sin(t) * radio

Diferentes patrones extra que podrías realizar con las funciones trigonométricas:

PatrónFórmula XFórmula Y
Onda horizontalstart_x + sin(t * freq) * ampy -= speed * delta
Elipsecentro_x + cos(t) * radio_xcentro_y + sin(t) * radio_y
Espiralcentro_x + cos(t) * (radio + t*k)centro_y + sin(t) * (radio + t*k)

Probando en tu Mundo

  1. Abre world.tscn.
  2. Arrastra enemy_wave.tscn o enemy_orbital.tscn al viewport.

Yo como detalle molón he girado los helicópteros -30º para que se vean más naturales.

screenshot_4.webp

Ten en cuenta que encontrarás 2 problemas: los enemigos mueren al chocarse y además no hacen el hit_flash correctamente. Eso lo solucionaremos en el próximo capítulo.

  1. Ejecuta (F5) y observa.

Experimenta con diferentes valores:

  • wave_amplitude = 100, wave_frequency = 2 → Ondulación lenta y amplia.
  • wave_amplitude = 30, wave_frequency = 8 → Vibración rápida y corta.

Repasemos lo aprendido

  1. sin(x): Función que oscila entre -1 y 1. Ideal para movimiento ondulatorio.
  2. cos(x): Igual que sin, pero desfasado 90°.
  3. Tiempo como entrada: Usar time += delta para alimentar las funciones.
  4. Amplitud: Multiplicador que define el rango del movimiento.
  5. Frecuencia: Multiplicador que define la velocidad del ciclo.
  6. Estados: Combinar patrones con match y temporizadores.

En el próximo capítulo, aprenderemos a crear Formaciones de Combate: grupos de enemigos que se mueven coordinadamente.