Saltar al contingut principal
Tornar enrere

Godot #5: Trigonometria de Combat

#godot #gdscript #math #trigonometry #gamedev

Enemics que ondulen, orbiten i executen patrons matemàtics. Aprèn a usar Sinus i Cosinus per a moviments clàssics d'arcade.

Enemics Predictibles són Enemics Avorrits

Fins ara, els nostres enemics només es mouen en línia recta. Funciona, però no impressiona. Els shooters clàssics com Galaga, Gradius o R-Type tenen enemics que ondulen, orbiten i executen patrons coreografiats.

La clau d’aquests moviments són dues funcions matemàtiques: Sinus i Cosinus.

1. El Sinus: L’Ona

El Sinus (sin) és una funció que espera que li passis un angle en radians i retorna valors entre -1 i 1 de forma cíclica. Això no vol dir que li hagis de passar necessàriament un angle, pots passar-li qualsevol nombre real i et retornarà un valor entre -1 i 1. Per tant, podem passar-li el temps, per exemple, i obtindrem un valor que oscil·la suaument entre -1 i 1.

Si grafiquem sin(x) on x va de 0 a 2π (una volta completa), obtenim una ona:

Valor de xsin(x)
00
π/2 (90°)1 (màxim)
π (180°)0
3π/2 (270°)-1 (mínim)
2π (360°)0

Després de 2π, el cicle es repeteix infinitament.

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

Per a què serveix en un joc?

Si usem el temps com a entrada del sinus, obtenim un valor que oscil·la suaument:

gdscript
var offset_y = sin(temps) # Oscil·la entre -1 i 1

Usant el valor que retorna sin, i multiplicant-lo per una amplitud, valgui la redundància, ampliem el rang de valors, ja no oscil·la entre -1 i 1, sinó entre amplitud negatiu i amplitud positiu. Per tant, si volem que es mogui en un rang entre -50 a 50 píxels, multiplicarem per 50.

gdscript
var offset_y = sin(temps) * 50 # Oscil·la entre -50 i 50 píxels

2. Un Nou Enemic: L’Helicòpter

Deixarem el nostre avió bàsic (enemy_plane.gd) com està, movent-se recte. Per a aquests nous patrons, crearem un nou enemic, l’Helicòpter, del qual farem 2 versions:

  1. Ona: Es mou fent onades.
  2. Òrbita: Gira en cercles perfectes.

Sprite de l'Helicòpter

Clic dret -> Desa imatge.
Crèdits: Assets Free Laser Bullets Pack 2020 per Wenrexa (CC0 1.0).

Pas 1: L’Helicòpter d’Ona

  1. Crea una nova escena, afegeix el node Area2D i anomena’l EnemyWave, afegeix també un Sprite2D amb l’sprite de l’helicòpter i un CollisionShape2D.
  2. Afegeix-li un script anomenat enemy_wave.gd al EnemyWave.
  3. Desa l’escena com enemy_wave.tscn.

L’Script de l’Ona

Volem que aquest enemic onduli verticalment mentre avança.

Per començar, copia i enganxa tot el codi de enemy_plane.gd dins el teu nou script enemy_wave.gd. A partir d’aquesta base, anirem afegint els canvis pas a pas.

1. Variables de Control

Necessitem definir quant es mou l’ona (amplitud) i com de ràpid (freqüència).

gdscript
@export var wave_amplitude = 100.0  # Alçada de l'ona (en píxels)
@export var wave_frequency = 3.0    # Velocitat de l'oscil·lació

També necessitem rastrejar el temps (per alimentar la funció sin) i recordar la nostra alçada inicial (per oscil·lar al voltant d’ella, no anar-nos-en volant).

gdscript
var time = 0.0        # Acumulador de temps (rellotge intern)
var start_y = 0.0     # La nostra "línia base" a l'eix Y

2. Desar la posició inicial

Al _ready(), hem de desar la posició Y inicial de l’enemic.

Per què? Perquè la funció sin() oscil·la al voltant de 0 (puja a +1, baixa a -1). Si apliquéssim això directament a position.y, l’enemic saltaria a la part superior de la pantalla (coordenada Y=0).

En desar start_y, establim un nou “centre” per a l’oscil·lació. Així, l’enemic oscil·larà cap amunt i cap avall relatiu a on el vam col·locar a l’editor.

gdscript
func _ready():
  start_y = position.y  # Desem l'alçada on el vam posar a l'editor
  area_entered.connect(_on_area_entered)

3. El Moviment (La Fórmula)

Ves a la teva funció _process(delta). Busca el bloc else (que s’executa quan l’enemic no està morint) i reemplaça el seu contingut per aquest:

gdscript
func _process(delta):
  if is_dying:
      # ... (deixar com estava o veure script final) ...
  else:
      # 1. ACUMULAR TEMPS
      time += delta
      # 2. MOVIMENT HORITZONTAL (Mantenim el de sempre)
      position.x -= speed * delta
      # 3. MOVIMENT VERTICAL (La novetat)
      # Y = Centre + sin(temps * freqüència) * amplitud
      position.y = start_y + sin(time * wave_frequency) * wave_amplitude

L’Script Complet

Ajuntant tot això amb el codi base de dany i mort que ja teníem de l’avió:

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 l'ona
      position.y = start_y + sin(time * wave_frequency) * wave_amplitude
      # 1. start_y farà que parteixi de la posició original o relativa.
      # 2. usem el temps en sin() com a valor d'angle, el que ens retornarà 
      # valors entre -1 i 1.
      # 3. Ho multipliquem per la freqüència per donar-li velocitat.
      # 4. Al resultat li podem, a més, multiplicar l'amplitud per donar-li mida.
      # **Recorda** l'ordre de les operacions (multiplicació abans que la funció).

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

  if hp <= 0:
      die()

func hit_flash():
  var sprite = $Sprite2D
  var shader_material = sprite.material
  if shader_material is ShaderMaterial:
      shader_material.set_shader_parameter("flash_active", true)
      await get_tree().create_timer(0.1).timeout
      shader_material.set_shader_parameter("flash_active", false)

func die():
  is_dying = true
  $CollisionShape2D.set_deferred("disabled", true)
  await get_tree().create_timer(1.0).timeout
  queue_free()

3. Moviment Circular (Òrbita)

Para aconseguir un moviment circular, necessitem presentar al germà del Sinus: el Cosinus.

El Cosinus (cos) és idèntic al Sinus, però en comptes d’oscil·lar a l’eix Y, es mou a l’eix X.

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

Per què és important? Cos sempre està desfasat respecte a Sen en 90º, així que mai valen 0 alhora. Quan sin val 0, cos val 1. Quan cos val 0, sin val 1. Aquesta alternança suau crea el moviment continu del cercle.


Implementació Pràctica

Per a aquests enemics orbitals, seguirem usant el nostre Helicòpter. ⚠️ Segueix aquests passos, o si no sense voler podries veure’t editant l’script que no toca si no tens cura.

  1. Duplica l’escena enemy_wave.tscn i anomena-la enemy_orbital.tscn.
  2. Canvia el nom del node pare EnemyWave a EnemyOrbital.
  3. Duplica l’script enemy_wave.gd, anomena’l enemy_orbital.gd i assigna’l al node EnemyOrbital.

Ara, editem enemy_orbital.gd pas a pas:

1. Variables: D’Ona a Cercle

Com hem copiat l’script de l’Ona (enemy_wave.gd), tenim variables que ja no serveixen.

Elimina aquestes variables:

  • speed (no volem que avanci)
  • wave_amplitude i wave_frequency (són de l’ona)
  • start_y (usarem vector complet)
  • fall_speed (usarem inèrcia vectorial)

Mantingues aquestes variables:

  • hp (la vida)
  • is_dying (estat)
  • time (rellotge intern)

Afegeix les noves variables pel cercle:

  • orbit_radius: Radi del cercle.
  • orbit_speed: Velocitat angular.
  • velocity: Per calcular la inèrcia (substitueix a fall_speed).
  • center: Punt central de l’òrbita (substitueix a start_y).

A partir d’aquí veuràs fàcilment quins blocs de codi substituir perquè en eliminar les variables, ara l’editor et marcarà errors.

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

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

2. Ready: Desar el Centre

A l’Ona desàvem start_y (float), però el Cercle necessita un centre 2D (X i Y).

gdscript
func _ready():
  center = position  # Desem la posició (X, Y) inicial com a centre
  area_entered.connect(_on_area_entered)

3. Process: El Gir

Ves a el teu _process(delta).

  1. Dins del if is_dying: Reemplaça la lògica de caiguda simple per aquesta d’inèrcia (necessària perquè ara ens movem en 2D).
  2. Dins del else: Reemplaça tota la lògica de l’ona per la del cercle.

He numerat els blocs de codi perquè puguis seguir millor la lògica que seguirà.

gdscript
func _process(delta):
  if is_dying:
      # 3. FASE MORT: Usem la inèrcia calculeada
      # Ja no calculem 'cos/sin'. Usem l'última 'velocity' coneguda 
      # per seguir movent-nos en aquesta direcció, i li sumem gravetat.
      velocity.y += 1000 * delta
      position += velocity * delta
  else:
      # 1. FASE VIVA: Desem posició abans de moure'ns
      var prev_pos = position
      time += delta

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

      # CÀLCUL D'INÈRCIA (Per usar-la quan mori)
      # Com movem 'position' a mà, Godot no sap a quina velocitat anem.
      # La calculem nosaltres: Velocitat = Espai / Temps
      velocity = (position - prev_pos) / delta

Si bé jo t’estic deixant anar el codi, és aconsellable que cada cop que escriguis o enganxis codi intentis entendre què fa aquest codi i per què l’incloem.

L’Script Complet (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()

func hit_flash():
  var sprite = $Sprite2D
  var shader_material = sprite.material
  if shader_material is ShaderMaterial:
      shader_material.set_shader_parameter("flash_active", true)
      await get_tree().create_timer(0.1).timeout
      shader_material.set_shader_parameter("flash_active", false)

func die():
  is_dying = true
  $CollisionShape2D.set_deferred("disabled", true)
  await get_tree().create_timer(1.0).timeout
  queue_free()

Aquest enemic es queda girant al lloc. En el futur, usarem això per crear esquadrons que entren i es queden orbitant en formació.


4. Taula de Referència

PatróFórmula XFórmula Y
Ona verticalx -= speed * deltastart_y + sin(t * freq) * amp
Cerclecentre_x + cos(t) * radicentre_y + sin(t) * radi

Diferents patrons extra que podries realitzar amb les funcions trigonomètriques:

PatróFórmula XFórmula Y
Ona horitzontalstart_x + sin(t * freq) * ampy -= speed * delta
El·lipsecentre_x + cos(t) * radi_xcentre_y + sin(t) * radi_y
Espiralcentre_x + cos(t) * (radi + t*k)centre_y + sin(t) * (radi + t*k)

Provant al teu Món

  1. Obre world.tscn.
  2. Arrossega enemy_wave.tscn o enemy_orbital.tscn al viewport.

Jo com a detall “molón” he girat els helicòpters -30º perquè es vegin més naturals.

screenshot_4.webp

Tingues en compte que trobaràs 2 problemes: els enemics moren en xocar i a més no fan el hit_flash correctament. Això ho solucionarem en el pròxim capítol.

  1. Executa (F5) i observa.

Experimenta amb diferents valors:

  • wave_amplitude = 100, wave_frequency = 2 → Ondulació lenta i àmplia.
  • wave_amplitude = 30, wave_frequency = 8 → Vibració ràpida i curta.

Repassem l’après

  1. sin(x): Funció que oscil·la entre -1 i 1. Ideal per moviment ondulatori.
  2. cos(x): Igual que sin, però desfasat 90°.
  3. Temps com entrada: Usar time += delta per alimentar les funcions.
  4. Amplitud: Multiplicador que defineix el rang del moviment.
  5. Freqüència: Multiplicador que defineix la velocitat del cicle.
  6. Estats: Combinar patrons amb match i temporitzadors.

En el pròxim capítol, aprendrem a crear Formacions de Combat: grups d’enemics que es mouen coordinadament.