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 x | sin(x) |
|---|---|
| 0 | 0 |
| π/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.
0.00 rad1.0000.000¿Para qué sirve en un juego?
Si usamos el tiempo como entrada del seno, obtenemos un valor que oscila suavemente:
var offset_y = sin(tiempo) # Oscila entre -1 y 1var offset_y = sin(tiempo) # Oscila entre -1 y 1Usando 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.
var offset_y = sin(tiempo) * 50 # Oscila entre -50 y 50 píxelesvar offset_y = sin(tiempo) * 50 # Oscila entre -50 y 50 píxeles2. 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:
- Onda: Se mueve haciendo olas.
- Órbita: Gira en círculos perfectos.

Clic derecho -> Guardar imagen.
Créditos: Assets Free Laser Bullets Pack 2020 por Wenrexa (CC0 1.0).
Paso 1: El Helicóptero de Onda
- Crea una nueva escena, añade el nodo
Area2Dy llámaloEnemyWave, añade también unSprite2Dcon el sprite del helicóptero y unCollisionShape2D. - Agrégale un script llamado
enemy_wave.gdalEnemyWave. - 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).
@export var wave_amplitude = 100.0 # Altura de la onda (en píxeles)
@export var wave_frequency = 3.0 # Velocidad de la oscilación@export var wave_amplitude = 100.0 # Altura de la onda (en píxeles)
@export var wave_frequency = 3.0 # Velocidad de la oscilaciónTambié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).
var time = 0.0 # Acumulador de tiempo (reloj interno)
var start_y = 0.0 # Nuestra "línea base" en el eje Yvar time = 0.0 # Acumulador de tiempo (reloj interno)
var start_y = 0.0 # Nuestra "línea base" en el eje Y2. 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.
func _ready():
start_y = position.y # Guardamos la altura donde nos pusieron en el editor
area_entered.connect(_on_area_entered)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:
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_amplitudefunc _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_amplitudeEl Script Completo
Juntando todo esto con el código base de daño y muerte que ya teníamos del avión:
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)
passextends 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)
passNota: He omitido el contenido de
hit_flashydieen 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 x | sin(x) (Eje Y) | cos(x) (Eje X) |
|---|---|---|
| 0 | 0 | 1 |
| π/2 | 1 | 0 |
| π | 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.
- Duplica la escena
enemy_wave.tscny llámalaenemy_orbital.tscn. - Cambia el nombre del nodo padre
EnemyWaveaEnemyOrbital. - Duplica el script
enemy_wave.gd, llámaloenemy_orbital.gdy asígnalo al nodoEnemyOrbital.
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_amplitudeywave_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 afall_speed).center: Punto central de la órbita (sustituye astart_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.
@export var orbit_radius = 100.0
@export var orbit_speed = 2.0
var velocity = Vector2.ZERO
var center = Vector2.ZERO@export var orbit_radius = 100.0
@export var orbit_speed = 2.0
var velocity = Vector2.ZERO
var center = Vector2.ZERO2. Ready: Guardar el Centro
En la Onda guardábamos start_y (float), pero el Círculo necesita un centro 2D (X e Y).
func _ready():
center = position # Guardamos la posición (X, Y) inicial como centro
area_entered.connect(_on_area_entered)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).
- Dentro del
if is_dying: Reemplaza la lógica de caída simple por esta de inercia (necesaria porque ahora nos movemos en 2D). - 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á.
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) / deltafunc _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) / deltaSi 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)
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)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ón | Fórmula X | Fórmula Y |
|---|---|---|
| Onda vertical | x -= speed * delta | start_y + sin(t * freq) * amp |
| Círculo | centro_x + cos(t) * radio | centro_y + sin(t) * radio |
Diferentes patrones extra que podrías realizar con las funciones trigonométricas:
| Patrón | Fórmula X | Fórmula Y |
|---|---|---|
| Onda horizontal | start_x + sin(t * freq) * amp | y -= speed * delta |
| Elipse | centro_x + cos(t) * radio_x | centro_y + sin(t) * radio_y |
| Espiral | centro_x + cos(t) * (radio + t*k) | centro_y + sin(t) * (radio + t*k) |
Probando en tu Mundo
- Abre
world.tscn. - Arrastra
enemy_wave.tscnoenemy_orbital.tscnal viewport.
Yo como detalle molón he girado los helicópteros -30º para que se vean más naturales.

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.
- 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
sin(x): Función que oscila entre -1 y 1. Ideal para movimiento ondulatorio.cos(x): Igual que sin, pero desfasado 90°.- Tiempo como entrada: Usar
time += deltapara alimentar las funciones. - Amplitud: Multiplicador que define el rango del movimiento.
- Frecuencia: Multiplicador que define la velocidad del ciclo.
- Estados: Combinar patrones con
matchy temporizadores.
En el próximo capítulo, aprenderemos a crear Formaciones de Combate: grupos de enemigos que se mueven coordinadamente.