Saltar al contenido principal
Volver atrás

Godot #6: El SpawnPoint - Formaciones Lineales

#godot #gdscript #spawner #patterns #gamedev

Creamos un sistema de spawn configurable que controla el movimiento de los enemigos. Patrones LINE y WAVE con aviones.

El Problema: Enemigos que Se Mandan Solos

En los capítulos anteriores, cada enemigo tenía su propio script de movimiento. El avión se movía recto, el helicóptero ondulaba u orbitaba. Cada uno decidía cómo moverse.

Esto funciona para demos, pero tiene un problema fundamental: no hay coordinación.

Si queremos crear una oleada de 5 aviones que entren en formación, ¿quién decide dónde van? ¿Cada avión por su cuenta? Eso es caos, no una formación.

La solución: Un director de orquesta que controle el movimiento de todos sus enemigos. En lugar de que el enemigo sepa moverse, será un nodo externo (el SpawnPoint) quien le diga dónde ir.

El Cambio de Paradigma

AntesDespués
Enemigo decide su movimientoEnemigo es “tonto”, solo tiene stats
Múltiples scripts de movimientoUn solo SpawnPoint con patrones configurables
Movimiento hardcodeadoMovimiento inyectado desde fuera

Este capítulo lo dedicaremos a construir ese sistema. Pero antes, necesitamos pulir algunas cosas.


1. Mejorando: Todos los Enemigos Flashean a la Vez

Ahora si instancias varios helicópteros del mismo tipo como por ejemplo enemy_orbital.tscn y disparas a uno solo, todos ejecutan el efecto de hit_flash simultáneamente. ¿Por qué?

La Causa: Recursos Compartidos

Cuando creas una escena en el Editor de Godot y le asignas un ShaderMaterial al Sprite2D, ese material se guarda como un recurso único en el archivo .tscn.

Al usar enemy_scene.instantiate(), Godot no duplica los recursos. Solo crea referencias al mismo archivo.

enemy.tscn
   └── Sprite2D
        └── Material: ShaderMaterial (archivo único)
               ↑        ↑        ↑
          Enemigo 1  Enemigo 2  Enemigo 3
          (todos apuntan al MISMO material)

Cuando uno llama a set_shader_parameter("flash_active", true), cambia la variable para todos porque es literalmente el mismo objeto en memoria.

La Solución: Duplicar el Material

La solución es hacer una copia independiente del material en el _ready() de cada enemigo:

gdscript
func _ready():
# Creamos una copia ÚNICA del material para ESTA instancia
$Sprite2D.material = $Sprite2D.material.duplicate()

# Resto del código...
area_entered.connect(_on_area_entered)

¿Qué hace .duplicate()?

El método duplicate() crea una copia profunda del recurso. Ahora cada enemigo tiene su propio ShaderMaterial independiente:

Enemigo 1 → Material Copia A (independiente)
Enemigo 2 → Material Copia B (independiente)
Enemigo 3 → Material Copia C (independiente)

Cuando disparas a uno, solo su material recibe el cambio.

Alternativa sin código: Podrías ir al Inspector del ShaderMaterial y activar Resource → Local to Scene. Godot duplicará automáticamente el material al instanciar. Ambos métodos (duplicate() y Local to Scene) crean copias en memoria, así que la eficiencia es la misma—solo cambia si prefieres hacerlo por código o por Inspector.

Para proyectos grandes: instance uniform

Tanto duplicate() como Local to Scene crean copias del material en memoria (100 enemigos = 100 materiales).

En Godot 4 existe instance uniform: un solo shader compartido por todas las instancias, pero con valores individuales (como flash_intensity). Es mucho más eficiente en memoria y GPU. Lo veremos en el capítulo de optimización.

Para este curso, duplicate() es suficiente y más explícito didácticamente.

Aplica este cambio a todos tus scripts de enemigos (enemy_plane.gd, enemy_wave.gd, enemy_orbital.gd, etc.) añadiendo la línea de duplicate() al inicio de _ready().


2. Mejorando: Colisiones Entre Enemigos

Si los enemigos vuelan muy cerca unos de otros, pueden chocar entre sí y destruirse. Podríamos usar grupos (is_in_group("enemy")) para filtrar colisiones en código, pero hay una solución mejor y más eficiente: Collision Layers.

¿Qué son las Collision Layers?

Godot tiene un sistema de capas de colisión que funciona como filtros. Cada objeto tiene:

  • Layer (capa): “Yo soy de esta capa”
  • Mask (máscara): “Yo detecto objetos de estas capas”

El evento area_entered solo se dispara si la capa del objeto entrante coincide con la máscara del objeto receptor.

Límite de 32 capas

Godot usa un bitmask de 32 bits, lo que limita a 32 capas por sistema. En la práctica es más que suficiente si usas categorías genéricas (player, enemy, bullet) en lugar de una capa por tipo específico. Si necesitas más granularidad, puedes combinar Layers con Groups.

Configuración para Nuestro Juego

Vamos a definir 3 capas:

BitNombreQuién está aquí
1PlayerLa nave del jugador
2EnemyAviones, helicópteros, todos los enemigos
3PlayerBulletLas balas que dispara el jugador

Configuración de cada objeto:

ObjetoLayer (soy)Mask (detecto)
Player1-
Enemy23 (solo balas del player)
PlayerBullet32 (solo enemigos)

Con esta configuración:

  • Los enemigos solo detectan balas del player (capa 3).
  • Si un enemigo toca a otro enemigo (ambos en capa 2), no pasa nada porque la máscara del enemigo no incluye la capa 2.
  • Por ahora Player no detecta colisiones con enemigos porque por ahora es invencible, si detectásemos la colisión todavía no tendríamos nada que hacer con ella.

Cómo Configurarlo en Godot

Paso 1: Nombrar las capas

  1. Ve a Project → Project Settings → Layer Names → 2D Physics.
  2. Nombra las capas:
    • Layer 1: player
    • Layer 2: enemy
    • Layer 3: player_bullet

Paso 2: Configurar el Enemigo

  1. Abre cada escena de cada enemigo (enemy_plane.tscn, enemy_wave.tscn, enemy_orbital.tscn) y repite estos pasos.
  2. Selecciona el nodo raíz (Area2D).
  3. En el Inspector, busca Collision → Layer.
  4. Desactiva la capa 1, activa solo la capa 2 (enemy).
  5. En Collision → Mask, desactiva la capa 1, activa solo la capa 3 (player_bullet).

Paso 3: Configurar la Bala

  1. Abre tu escena de bala (bullet.tscn).
  2. Selecciona el nodo raíz (Area2D).
  3. Layer: Solo capa 3 (player_bullet).
  4. Mask: Solo capa 2 (enemy).

Resultado: Con esta configuración, la señal area_entered del enemigo solo se dispara cuando una bala del player lo toca. No necesitas verificar manualmente qué tipo de objeto colisionó; el motor ya lo filtró por ti.

Tu función _on_area_entered del Capítulo 3 queda limpia:

gdscript
func _on_area_entered(area):
if is_dying:
  return

# Gracias a Collision Layers, aquí solo llegan balas del player
# El motor ya filtró las colisiones por nosotros

hp -= 1
area.queue_free()
hit_flash()  # Definido en Capítulo 4

if hp <= 0:
  die()  # Definido en Capítulo 3

3. El SpawnPoint: Estructura Base

Ahora que los enemigos están preparados, vamos a crear el SpawnPoint. Este será un Node2D que:

  1. Usaremos una escena de enemigo (PackedScene).
  2. Instanciaremos N enemigos al inicio.
  3. Controlaremos su movimiento cada frame según el patrón seleccionado.

Teoría: Definiendo los Patrones con Enum

Primero, necesitamos una forma de seleccionar qué patrón usar. En GDScript, usamos un enum:

gdscript
enum MovementPattern {
  LINE,          # Fila india
  WAVE,          # Fila con ondulación
  ORBIT,         # Movimiento circular (Part 7)
  LINE_TO_ORBIT  # Transición (Part 7)
}

¿Qué es un enum?

Un enum (enumeración) es una forma de definir un conjunto de constantes con nombre. Internamente, cada valor es un número:

NombreValor Interno
LINE0
WAVE1
ORBIT2
LINE_TO_ORBIT3

La ventaja es que en el código escribes MovementPattern.WAVE en lugar de recordar qué significa el número 1. Además, en el Inspector de Godot aparece un desplegable con los nombres.

Ahora vamos a construir el SpawnPoint paso a paso, empezando con el patrón más simple.


4. Patrón LINE

El patrón más simple. Los enemigos entran en fila india (uno detrás del otro) y avanzan hacia la izquierda.

← enemy_0 ← enemy_1 ← enemy_2 ← enemy_3 ← enemy_4
   (todos en línea horizontal, moviéndose a la izquierda)

Patrón de movimiento LINE: fila india

Preparando el Escenario

Antes de escribir código, crea el nodo que usaremos:

  1. Crea una Nueva Escena y selecciona Node2D como raíz.
  2. Renómbralo a SpawnPoint.
  3. Guárdalo como spawn_point.tscn.
  4. Adjúntale un nuevo script llamado spawn_point.gd.
  5. Borra todo el código por defecto. ¡Empezamos!

Variables del SpawnPoint

Antes de escribir funciones, necesitamos definir qué datos controlará el SpawnPoint. Usaremos @export para poder configurarlos desde el Inspector:

gdscript
extends Node2D

@export var enemy_scene: PackedScene    # La escena del enemigo a instanciar
@export var formation_size: int = 5     # Cuántos enemigos crear
@export var spawn_spacing: float = 150.0  # Distancia entre enemigos
@export var speed: float = 150.0        # Velocidad de movimiento

var enemies: Array = []  # Guardamos referencias a los enemigos creados
  • enemy_scene: Una PackedScene es una escena empaquetada (.tscn) que podemos instanciar por código. Arrastramos enemy_plane.tscn aquí desde el Inspector.
  • formation_size: Cuántos enemigos queremos en la formación.
  • spawn_spacing: Distancia en píxeles entre cada enemigo de la fila.
  • speed: Velocidad a la que avanzan (píxeles por segundo).
  • enemies: Un array vacío donde guardaremos las referencias a los enemigos. Lo necesitamos para poder moverlos en _process().

Ciclo de Vida: _ready() y _process()

El corazón de nuestro script. Aquí definimos cuándo ocurren las cosas:

gdscript
func _ready():
  spawn_formation()

func _process(delta):
  for enemy in enemies:
      if is_instance_valid(enemy):
          enemy.position.x -= speed * delta
  1. _ready(): Se ejecuta una vez al inicio. Simplemente llama a nuestra función personalizada spawn_formation() para crear los enemigos.

  2. _process(delta): Se ejecuta en cada frame (~60 veces/seg).

    • Recorre el array enemies.
    • is_instance_valid(enemy): Verifica que el enemigo siga vivo (que no haya sido destruido).
    • Mueve al enemigo hacia la izquierda restando a position.x.

La función spawn_formation()

Esta es la función que hace el trabajo sucio de crear las instancias:

gdscript
func spawn_formation():
  for i in range(formation_size):
      var enemy = enemy_scene.instantiate()
      enemy.position = Vector2(i * spawn_spacing, 0)
      enemies.append(enemy)
      call_deferred("add_child", enemy)
  1. for i in range(formation_size): Itera formation_size veces.
  2. enemy_scene.instantiate(): Crea una nueva copia del enemigo.
  3. enemy.position: Los coloca en fila india (i=0 → X=0; i=1 → X=150…).
  4. enemies.append(enemy): Guarda la referencia en el array para que _process sepa a quién mover.
  5. call_deferred("add_child", enemy): Añade el enemigo a la escena de forma segura.
¿Por qué call_deferred?

Estamos creando hijos dentro de _ready(), que es parte de la inicialización del nodo. Para evitar conflictos con el motor, usamos call_deferred para que los nodos se añadan al final del frame actual.

Script Completo

Ahora que entendemos cada parte, aquí está el script completo para copiar:

gdscript
extends Node2D

@export var enemy_scene: PackedScene
@export var formation_size: int = 5
@export var spawn_spacing: float = 150.0
@export var speed: float = 150.0

var enemies: Array = []

func _ready():
  spawn_formation()

func spawn_formation():
  for i in range(formation_size):
      var enemy = enemy_scene.instantiate()
      enemy.position = Vector2(i * spawn_spacing, 0)
      enemies.append(enemy)
      call_deferred("add_child", enemy)

func _process(delta):
  for enemy in enemies:
      if is_instance_valid(enemy):
          enemy.position.x -= speed * delta

Probando el Patrón LINE

  1. Abre tu escena level.tscn.
  2. Arrastra el archivo spawn_point.tscn a la escena (o pulsa Ctrl+Shift+A para instanciar).
  3. Selecciona el nodo SpawnPoint que acabas de crear.
  4. En el Inspector:
    • Enemy Scene: Arrastra enemy_plane.tscn.
    • Formation Size: 5.
    • Spawn Spacing: 150.
    • Speed: 150.
  5. Posiciona el SpawnPoint a la derecha de la pantalla (ej: X=1200, Y=300).
  6. Ejecuta (F5).

Resultado esperado: 5 aviones en fila india avanzan hacia la izquierda.


5. Patrón WAVE

Ahora queremos que la fila india ondule verticalmente mientras avanza. El truco es que cada enemigo debe seguir exactamente la misma ruta que el que tiene delante, como los segmentos de una serpiente.

Patrón de movimiento WAVE: serpiente

El Secreto: Ondulación Basada en Posición X

La clave está en cómo calculamos la posición Y. En lugar de usar el tiempo (TIME), usamos la posición X del enemigo:

gdscript
enemy.position.y = sin(enemy.position.x * wave_frequency) * wave_amplitude

¿Por qué funciona esto? Porque todos los enemigos pasan por las mismas coordenadas X (solo que en momentos diferentes). El enemigo 0 pasa por X=500 primero; un segundo después, el enemigo 1 pasa por X=500. Ambos calculan la misma Y para esa X, así que siguen la misma curva.

Si usáramos TIME, cada enemigo calcularía su Y basándose en el tiempo global, no en su posición. La formación se desincronizaría.

Nuevas Variables

Para controlar la ondulación, añadimos dos parámetros configurables:

gdscript
# Parámetros WAVE
@export var wave_amplitude: float = 80.0   # Altura de la onda (píxeles)
@export var wave_frequency: float = 0.02   # "Densidad" de la onda
  • wave_amplitude: Cuántos píxeles sube/baja la onda. Un valor de 80 significa que el enemigo oscila 80 píxeles arriba y 80 abajo desde el centro.
  • wave_frequency: Controla cuántas ondas hay por píxel de desplazamiento. Un valor pequeño (0.01) = ondas largas y suaves. Un valor grande (0.05) = ondas cortas y rápidas.

Ciclo de Vida y el bucle match

Ahora necesitamos actualizar nuestro bucle principal para que decida qué movimiento aplicar.

gdscript
func _ready():
  spawn_formation()  # Esto no cambia, seguimos creando enemigos igual

func _process(delta):
  for enemy in enemies:
      if not is_instance_valid(enemy):
          continue
      
      match pattern:
          MovementPattern.LINE:
              move_line(enemy, delta)
          MovementPattern.WAVE:
              move_wave(enemy, delta)
  1. _ready(): Se mantiene idéntico. Solo instanciamos y posicionamos.

  2. _process(delta): Aquí viene el cambio importante.

    • match pattern: Es como un “semáforo”. Mira el valor de la variable pattern que elegimos en el Inspector.
    • Si es LINE, llama a move_line().
    • Si es WAVE, llama a move_wave().
    • Así, cada frame, Godot sabe exactamente qué matemática aplicar a cada enemigo.
  3. if not ... continue: Una pequeña mejora. Si el enemigo murió, usamos continue para saltar al siguiente del bucle inmediatamente, evitando ejecutar código innecesario.

Las Funciones de Movimiento

Cada patrón tiene su propia función. Esto mantiene el código organizado y fácil de extender:

gdscript
func move_line(enemy: Node2D, delta: float):
  enemy.position.x -= speed * delta

move_line() es simple: solo mueve hacia la izquierda.

gdscript
func move_wave(enemy: Node2D, delta: float):
  enemy.position.x -= speed * delta
  enemy.position.y = sin(enemy.position.x * wave_frequency) * wave_amplitude

move_wave() hace lo mismo, pero además reasigna la Y cada frame basándose en la X actual.

Desglosando la fórmula:

  • enemy.position.x * wave_frequency: Convierte la posición X en un ángulo para sin(). Si X=500 y frequency=0.02, el ángulo es 10.
  • sin(...): Devuelve un valor entre -1 y 1.
  • * wave_amplitude: Escala ese -1/1 a píxeles reales (ej: -80 a 80).

La Y se sobrescribe cada frame, no se acumula. Por eso no hay problema de “volar al infinito”.

Script Completo (LINE + WAVE)

Ahora que entendemos cada parte, aquí está el script actualizado con ambos patrones:

gdscript
extends Node2D

enum MovementPattern { LINE, WAVE }

@export var enemy_scene: PackedScene
@export var pattern: MovementPattern = MovementPattern.LINE
@export var formation_size: int = 5
@export var spawn_spacing: float = 60.0
@export var speed: float = 150.0

# Parámetros WAVE
@export var wave_amplitude: float = 80.0
@export var wave_frequency: float = 0.02

var enemies: Array = []

func _ready():
  spawn_formation()

func spawn_formation():
  for i in range(formation_size):
      var enemy = enemy_scene.instantiate()
      enemy.position = Vector2(i * spawn_spacing, 0)
      enemies.append(enemy)
      call_deferred("add_child", enemy)

func _process(delta):
  for enemy in enemies:
      if not is_instance_valid(enemy):
          continue
      
      match pattern:
          MovementPattern.LINE:
              move_line(enemy, delta)
          MovementPattern.WAVE:
              move_wave(enemy, delta)

func move_line(enemy: Node2D, delta: float):
  enemy.position.x -= speed * delta

func move_wave(enemy: Node2D, delta: float):
  enemy.position.x -= speed * delta
  enemy.position.y = sin(enemy.position.x * wave_frequency) * wave_amplitude

Probando el Patrón WAVE

  1. Cambia el Pattern a WAVE en el Inspector.
  2. Configura:
    • Formation Size: 8
    • Spawn Spacing: 120
    • Speed: 50
    • Wave Amplitude: 150
    • Wave Frequency: 0.01
  3. Ejecuta (F5).

Resultado: Los enemigos avanzan ondulando arriba y abajo, todos siguiendo la misma curva.

Experimenta:

  • wave_frequency = 0.01: Ondas largas y suaves.
  • wave_frequency = 0.05: Ondas cortas y rápidas.
  • wave_amplitude = 150: Ondas más pronunciadas.

Repasemos lo aprendido

  1. Mejora del Shader: Los recursos se comparten entre instancias. Solución: material.duplicate().
  2. Mejora de la colisión: Filtrar colisiones a nivel de motor es más eficiente que hacerlo en código.
  3. SpawnPoint: Un nodo que controla el spawn y movimiento de sus enemigos.
  4. Enum: Constantes con nombre para seleccionar opciones en el Inspector.
  5. Patrón LINE: Fila india avanzando con position.x -= speed * delta.
  6. Patrón WAVE: Ondulación basada en posición X: position.y = sin(position.x * freq) * amp. Todos siguen la misma curva.

En el próximo capítulo, añadiremos el patrón ORBIT (helicópteros girando) y LINE_TO_ORBIT (transición de fila a órbita).