Compare commits

..

13 Commits

Author SHA1 Message Date
JailDesigner 06e383fe2c Merge branch 'feat/enemy-health-system': sistema d'HP declaratiu, big_pentagon i ajustos visuals 2026-05-25 22:47:54 +02:00
JailDesigner dc5b31087a Merge branch 'feat/debris-bullet-impulse': la bala impacta al cos O als trossos 2026-05-25 22:47:54 +02:00
JailDesigner 9e745dc3fc tune(enemy): trossos parcials i firework petit en color wounded 2026-05-25 22:47:31 +02:00
JailDesigner 14b10c663e tune(enemy): big_pentagon orb circular, firework petit per hit, sense wounded chain 2026-05-25 22:28:36 +02:00
JailDesigner f64c72f9a6 feat(enemy): sistema d'HP declaratiu i nou enemic big_pentagon 2026-05-25 21:46:48 +02:00
JailDesigner 610eaf257e refactor(debris): la bala impacta al cos O als trossos, mai a tots dos 2026-05-25 21:26:32 +02:00
JailDesigner b511740d93 Merge branch 'feat/enemy-ai-shoot': els enemics poden disparar bales declaratives des del YAML 2026-05-25 20:23:30 +02:00
JailDesigner b0643b6f62 Merge branch 'feat/enemy-ai-wander-chase-flee': afegir WANDER/CHASE/FLEE i target multi-ship 2026-05-25 20:23:25 +02:00
JailDesigner 7e8d79222c Merge branch 'feat/enemy-ai-movement-migration': moviment dels enemics a un sistema d'IA declaratiu 2026-05-25 20:23:02 +02:00
JailDesigner 14295ce859 feat(enemy): els enemics poden disparar bales via tick d'IA 2026-05-25 20:05:01 +02:00
JailDesigner 5ad433e63a feat(enemy): afegir behaviors WANDER/CHASE/FLEE i target multi-ship 2026-05-25 18:08:11 +02:00
JailDesigner 61e40e88f4 feat(enemy): migrar el moviment dels enemics a un sistema d'IA declaratiu 2026-05-25 17:45:30 +02:00
JailDesigner 410955de3c Merge branch 'feat/entity-event-system': sistema d'events declaratius per a enemics 2026-05-25 13:44:06 +02:00
36 changed files with 1229 additions and 267 deletions
@@ -0,0 +1,82 @@
name: big_pentagon
ai_type: big_pentagon # Validat contra el directori; mapeja a EnemyType::BIG_PENTAGON.
# Shape circular pròpia (anell exterior + anell interior + 6 radis + nucli),
# pensada per llegir-se com a "reactor / orb" amb més detall que els enemics
# petits.
shape:
path: enemy_big_orb.shp
scale: 1.5
collision_factor: 1.0
physics:
mass: 50.0 # Molt pesat: una bala el frena un poc però no el "envia a passejar".
speed: 50.0 # Avança decidit cap al ship (no és lent passiu, és amenaça constant).
rotation_delta_min: 0.3
rotation_delta_max: 1.5
restitution: 1.0
linear_damping: 0.0
angular_damping: 0.0
ai:
# Persecució contínua del ship més proper. chase_strength alt (1.0 = ~1s
# per realinear-se) perquè, encara que una bala l'empentja lateralment,
# ràpidament torna a posar la seua proa cap al jugador.
movement:
type: chase
chase_strength: 1.0
animation:
pulse:
trigger_prob_per_second: 0.01
duration_min: 1.0
duration_max: 3.0
amplitude_min: 0.08
amplitude_max: 0.20
frequency_min: 1.5
frequency_max: 3.0
rotation_accel:
trigger_prob_per_second: 0.02
duration_min: 3.0
duration_max: 8.0
multiplier_min: 0.3
multiplier_max: 4.0
wounded:
duration: 1.5 # Una mica més llarg que els altres (és un boss).
blink_hz: 10.0
spawn:
invulnerability_duration: 3.0
invulnerability_brightness_start: 0.3
invulnerability_brightness_end: 0.7
invulnerability_scale_start: 0.0
invulnerability_scale_end: 1.0
safety_distance: 54.0 # 1.5× del normal (alineat amb scale 1.5).
colors:
normal: [66, 195, 208] # #42C3D0 — turquesa-cyan distintiu per al boss.
wounded: [255, 220, 60]
score: 500 # 5x un enemic normal: aguanta 10x més.
# Estrenant el sistema HP: 10 unitats. Cada bala fa decrease_health + flash
# + create_debris_partial (xip a 0.3x) + create_fireworks_small (espurna).
# Al 10è hit (HP=0), on_no_health encadena destroy directe — sense passar
# per wounded (com Star). 10 HP ja és prou dificultat sense afegir un hit
# extra.
health: 10
events:
on_hit:
- action: decrease_health # primer: si arriba a 0 dispara on_no_health
#- action: flash # feedback visual de damage parcial
- action: create_debris_partial # xip a 0.3x mida (sense ser letal)
- action: create_fireworks_small # espurna a cada hit (12 punts, lent)
- action: apply_impulse # empenta el cos (skip si will_die)
on_no_health:
- action: destroy # mort directa, sense wounded
on_destroy:
- action: add_score
- action: create_debris # explosió completa
- action: create_fireworks
@@ -0,0 +1,19 @@
name: bullet_long
# Variant de bala més llarga, pensada per a bales d'enemic: més visible per al
# jugador i amb prou marge per reaccionar. La velocitat NO viu aquí: es passa
# a Bullet::fire() i la decideix qui dispara (l'AiTickAction).
shape:
path: bullet_long.shp
scale: 1.0
collision_factor: 0.5
physics:
mass: 0.5
restitution: 0.0
linear_damping: 0.0
angular_damping: 0.0
impact_momentum_factor: 3.0
colors:
normal: [255, 100, 100] # roig clar — diferencia visualment del verd laser del player
+6 -2
View File
@@ -55,10 +55,14 @@ colors:
score: 100
events:
# Comportament clàssic: dos impactes per matar (set_hurt entra wounded;
# el segon hit detecta wounded i destrueix automàticament).
# HP=1 (default): decrement → on_no_health → set_hurt wounded → mort.
# decrease_health primer perquè si la mort cau aquí (segon hit durant wounded),
# el dispatcher salta la resta del chain (incloent apply_impulse) sobre el
# cos ja destruït.
on_hit:
- action: decrease_health
- action: apply_impulse
on_no_health:
- action: set_hurt
on_hurt_end:
- action: destroy
+3
View File
@@ -55,8 +55,11 @@ colors:
score: 200
events:
# HP=1 (default): decrement → on_no_health → set_hurt → wounded → mort.
on_hit:
- action: decrease_health
- action: apply_impulse
on_no_health:
- action: set_hurt
on_hurt_end:
- action: destroy
+8 -4
View File
@@ -15,10 +15,11 @@ physics:
linear_damping: 0.0
angular_damping: 0.0
behavior:
# Square: tracking discret cap a la nau cada N segons.
tracking_strength: 0.5 # Interpolació LERP cap a la direcció desitjada (0..1)
tracking_interval: 1.0 # segons entre updates d'angle
ai:
# Square: persecució contínua del ship més proper (steering suau, "tanc lent").
movement:
type: chase
chase_strength: 0.5 # Força/segon de la LERP cap a la direcció ideal (1.0 = ~1s per realinear)
animation:
pulse:
@@ -55,8 +56,11 @@ colors:
score: 150
events:
# HP=1 (default): decrement → on_no_health → set_hurt → wounded → mort.
on_hit:
- action: decrease_health
- action: apply_impulse
on_no_health:
- action: set_hurt
on_hurt_end:
- action: destroy
+17 -5
View File
@@ -15,10 +15,20 @@ physics:
linear_damping: 0.0
angular_damping: 0.0
behavior:
# Hereta el comportament de Pentagon (zigzag esquivador).
angle_change_max: 1.0
zigzag_prob_per_second: 0.8
ai:
# Movement: zigzag esquivador (com Pentagon).
movement:
type: zigzag
angle_change_max: 1.0
zigzag_prob_per_second: 0.8
# Accions periòdiques: cada ~2.5s dispara una bala apuntada al ship més proper.
tick:
- action: shoot
interval: 2.5
aim_mode: aimed # apunta al ship més proper (atan2)
jitter_rad: 0.0 # sense soroll: tret perfecte
bullet: bullet_long # variant més visible per al jugador
bullet_speed: 150.0 # px/s — prou lenta per reaccionar
animation:
pulse:
@@ -56,8 +66,10 @@ score: 100
events:
# STAR: mor al primer impacte, sense passar per wounded.
# HP=1 (default): decrement → on_no_health → destroy directe (sense wounded).
on_hit:
- action: apply_impulse
- action: decrease_health
on_no_health:
- action: destroy
on_destroy:
- action: add_score
+20 -16
View File
@@ -1,28 +1,32 @@
# bullet_long.shp - Bala allargada (dos octàgons tangents + tapes superior i inferior)
# bullet_long.shp - Bala allargada vertical (dos mig-octàgons + dos costats)
# © 2026 JailDesigner
#
# Dos cercles (octàgons radi 3) tangents externament al punt (0,0), units
# per una línia horitzontal superior i una d'inferior. La silueta resultant
# és una càpsula amb la separació visible dels dos cercles al centre.
# Càpsula orientada al llarg de l'eix Y: la bala viatja segons el seu angle
# de moviment (angle=0 = Y negatiu), i així s'estira en la direcció de vol.
# Es dibuixen només els segments exteriors per evitar veure la unió interna
# dels dos cercles; el resultat visual són dos "mig-octàgons" separats per
# un petit gap al centre, units pels dos costats verticals.
#
# Geometria:
# Centre octàgon esquerre: (-3, 0)
# Centre octàgon dret: ( 3, 0)
# Punt de tangència: ( 0, 0)
# Bounding radius natiu ≈ 6 (extrem horitzontal a x=±6).
# Mig-octàgon superior (radi 3) centrat a (0, -3)
# Mig-octàgon inferior (radi 3) centrat a (0, 3)
# Punt extrem superior: (0, -6)
# Punt extrem inferior: (0, 6)
# Bounding radius natiu = 6 (extrem vertical a y=±6).
# collision_factor al YAML compensa el bounding doble (0.5 → hitbox ≈ 3).
name: bullet_long
scale: 1.0
center: 0, 0
# Octàgon esquerre (centre x=-3, radi 3)
polyline: -3,-3 -0.88,-2.12 0,0 -0.88,2.12 -3,3 -5.12,2.12 -6,0 -5.12,-2.12 -3,-3
# Mig-octàgon superior (5 vèrtexs: del cantó dret cap al punt extrem i a l'esquerre)
polyline: 3,-3 2.12,-5.12 0,-6 -2.12,-5.12 -3,-3
# Octàgon dret (centre x=3, radi 3)
polyline: 3,-3 5.12,-2.12 6,0 5.12,2.12 3,3 0.88,2.12 0,0 0.88,-2.12 3,-3
# Mig-octàgon inferior
polyline: 3,3 2.12,5.12 0,6 -2.12,5.12 -3,3
# Tapa superior: uneix el cim de l'octàgon esquerre amb el del dret
polyline: -3,-3 3,-3
# Costat dret (uneix extrem inferior del mig superior amb extrem superior del mig inferior)
polyline: 3,-3 3,3
# Tapa inferior: uneix la base de l'octàgon esquerre amb la del dret
polyline: -3,3 3,3
# Costat esquerre
polyline: -3,-3 -3,3
+32
View File
@@ -0,0 +1,32 @@
# enemy_big_orb.shp - ORNI enemic gegant (orb circular, doble anell amb radis)
# © 2026 JailDesigner
#
# Forma "reactor / boss circular" — més detall que els enemics petits perquè
# es renderitza a escala 1.5x i ha de llegir-se com a amenaça gran.
# - Anell exterior: dodecàgon (12 vèrtexs) — aparença circular suau, radi 20.
# - Anell interior: hexàgon (6 vèrtexs, rotat 30°) — radi 10.
# - 6 radis curts que connecten l'anell interior amb l'exterior.
# - Petit "+" central com a nucli.
# Bounding radius natiu = 20 (alineat amb la resta d'enemics).
name: enemy_big_orb
scale: 1.0
center: 0, 0
# Anell exterior (dodecàgon, vèrtex apuntant amunt)
polyline: 0,-20 10,-17.32 17.32,-10 20,0 17.32,10 10,17.32 0,20 -10,17.32 -17.32,10 -20,0 -17.32,-10 -10,-17.32 0,-20
# Anell interior (hexàgon, vèrtex apuntant a la dreta — rotat 30° respecte l'exterior)
polyline: 5,-8.66 10,0 5,8.66 -5,8.66 -10,0 -5,-8.66 5,-8.66
# 6 radis: del vèrtex de l'hexàgon interior al vèrtex corresponent del dodecàgon exterior
line: 5,-8.66 10,-17.32
line: 10,0 20,0
line: 5,8.66 10,17.32
line: -5,8.66 -10,17.32
line: -10,0 -20,0
line: -5,-8.66 -10,-17.32
# Nucli central: petit "+" (2 segments creuats, radi 3)
line: -3,0 3,0
line: 0,-3 0,3
+3 -3
View File
@@ -7,7 +7,7 @@ metadata:
description: "Progressive difficulty curve from novice to expert"
stages:
# STAGE 1: Tutorial - Mix de tots 4 tipus al 25% per mostrar-los junts
# STAGE 1: Tutorial - 4 tipus (sense star: les bales fan injugable el test).
- stage_id: 1
total_enemies: 50
spawn_config:
@@ -15,10 +15,10 @@ stages:
initial_delay: 0.3
spawn_interval: 0.4
enemy_distribution:
pentagon: 25
pentagon: 30
cuadrado: 25
molinillo: 25
star: 25
big_pentagon: 20
difficulty_multipliers:
speed_multiplier: 0.7
rotation_multiplier: 0.8
+27
View File
@@ -16,3 +16,30 @@ namespace Defaults::Enemies::Spawn {
constexpr int MAX_SPAWN_ATTEMPTS = 50;
} // namespace Defaults::Enemies::Spawn
namespace Defaults::Enemies::Visual {
// Duració del "flash" que dispara l'acció FLASH (feedback per impacte
// parcial en enemics HP>1). Curt: l'efecte ha de llegir-se com un cop,
// no com una transició.
constexpr float FLASH_DURATION = 0.08F;
} // namespace Defaults::Enemies::Visual
namespace Defaults::Enemies::Debris {
// Escala dels fragments per a l'acció CREATE_DEBRIS_PARTIAL (xip d'impacte
// en enemics HP>1). 0.3 = trossos petits, com de "casc esquerdat".
constexpr float PARTIAL_PIECE_SCALE = 0.3F;
} // namespace Defaults::Enemies::Debris
namespace Defaults::Enemies::Fireworks {
// Paràmetres del firework "petit" per a l'acció CREATE_FIREWORKS_SMALL
// (feedback per impacte parcial en enemics HP>1). Pocs punts i baixa
// velocitat: una espurna breu, no una explosió.
constexpr int SMALL_N_POINTS = 20;
constexpr float SMALL_SPEED = 250.0F;
} // namespace Defaults::Enemies::Fireworks
+15 -1
View File
@@ -3,10 +3,24 @@
#pragma once
#include <cstdint>
namespace Defaults::Entities {
constexpr int MAX_ORNIS = 15;
constexpr int MAX_BULLETS = 50;
constexpr int MAX_BULLETS = 50; // per jugador (P1 + P2 = 2× aquest valor)
constexpr int MAX_ENEMY_BULLETS = 50; // pool reservat per a bales d'enemic
// Total real de slots a l'array global bullets_: zona P1, zona P2 i zona enemic.
// Reservar zones impedeix que les bales d'enemic ocupin slots del jugador.
constexpr int MAX_BULLETS_TOTAL = (MAX_BULLETS * 2) + MAX_ENEMY_BULLETS;
constexpr int ENEMY_BULLET_START_IDX = MAX_BULLETS * 2;
// Convenció d'owner_id per a Bullet::fire:
// 0..1 = players (P1, P2)
// ENEMY_OWNER_BASE + index = enemic concret (per identificar el seu autoimpacte)
// Una bala mata a qualsevol col·lisió excepte amb el seu propi creador.
constexpr uint8_t ENEMY_OWNER_BASE = 128;
// SHIP_RADIUS / ENEMY_RADIUS / BULLET_RADIUS han migrat: ara cada entitat
// calcula el seu collision_radius com a
+7
View File
@@ -43,6 +43,13 @@ namespace Defaults::Physics::Debris {
// 1.0 = inèrcia completa; >1.0 amplifica la deriva; <1.0 la atenua.
constexpr float ENEMY_VELOCITY_INHERITANCE = 1.0F;
// Velocitat de la bala traspassada a cada fragment de debris al moment
// de l'impacte. Separat de la inèrcia del cos (velocitat_objecte): permet
// que els trossos volin "amb la força de la bala" encara que el cos pesi
// molt i amb prou feines es mogui. 0.4 a 700 px/s = ~280 px/s extra per
// fragment, molt visible sense ser excessiu.
constexpr float BULLET_IMPULSE_FACTOR = 0.4F;
// Tuneig específic de l'explosió d'enemic (overrides als defaults
// que es passen com a paràmetres opcionals a explode()).
constexpr float ENEMY_LIFETIME = 2.5F; // Vida mínima del debris (s) — els que segueixen movent-se viuen més
+28 -13
View File
@@ -57,7 +57,9 @@ namespace Effects {
SDL_Color color,
float lifetime,
float friction,
int segment_multiplier) {
int segment_multiplier,
const Vec2& bullet_impulse_velocity,
float piece_scale) {
if (!shape || !shape->isValid()) {
return;
}
@@ -84,7 +86,7 @@ namespace Effects {
Vec2 world_p2 = transformPoint(local_p2, shape_centre, centro, angle, scale);
// Si el pool es ple, no té sentit continuar amb la resta de segments
if (!spawnDebris(world_p1, world_p2, centro, velocitat_base, brightness, velocitat_objecte, velocitat_angular, factor_herencia_visual, color, lifetime, friction)) {
if (!spawnDebris(world_p1, world_p2, centro, velocitat_base, brightness, velocitat_objecte, velocitat_angular, factor_herencia_visual, color, lifetime, friction, bullet_impulse_velocity, piece_scale)) {
return;
}
}
@@ -110,34 +112,47 @@ namespace Effects {
return segments;
}
auto DebrisManager::spawnDebris(const Vec2& world_p1, const Vec2& world_p2, const Vec2& centro, float velocitat_base, float brightness, const Vec2& velocitat_objecte, float velocitat_angular, float factor_herencia_visual, SDL_Color color, float lifetime, float friction) -> bool {
auto DebrisManager::spawnDebris(const Vec2& world_p1_in, const Vec2& world_p2_in, const Vec2& centro, float velocitat_base, float brightness, const Vec2& velocitat_objecte, float velocitat_angular, float factor_herencia_visual, SDL_Color color, float lifetime, float friction, const Vec2& bullet_impulse_velocity, float piece_scale) -> bool {
Debris* debris = findFreeSlot();
if (debris == nullptr) {
std::cerr << "[DebrisManager] Warning: no debris slots disponibles\n";
return false;
}
// Escala el segment al voltant del seu punt mitjà segons piece_scale
// (1.0 = original; 0.3 = "esquerda petita"). La resta del càlcul (angle,
// half_length, p1/p2) en deriva naturalment.
const Vec2 MID = {.x = (world_p1_in.x + world_p2_in.x) / 2.0F,
.y = (world_p1_in.y + world_p2_in.y) / 2.0F};
const Vec2 WORLD_P1 = {.x = MID.x + ((world_p1_in.x - MID.x) * piece_scale),
.y = MID.y + ((world_p1_in.y - MID.y) * piece_scale)};
const Vec2 WORLD_P2 = {.x = MID.x + ((world_p2_in.x - MID.x) * piece_scale),
.y = MID.y + ((world_p2_in.y - MID.y) * piece_scale)};
// Geometria autoritaritzada: centro + original_angle + original_half_length.
// p1/p2 es reconstrueixen cada frame en update() des d'aquestes dades.
const float DX = world_p2.x - world_p1.x;
const float DY = world_p2.y - world_p1.y;
debris->centro = {.x = (world_p1.x + world_p2.x) / 2.0F,
.y = (world_p1.y + world_p2.y) / 2.0F};
const float DX = WORLD_P2.x - WORLD_P1.x;
const float DY = WORLD_P2.y - WORLD_P1.y;
debris->centro = {.x = (WORLD_P1.x + WORLD_P2.x) / 2.0F,
.y = (WORLD_P1.y + WORLD_P2.y) / 2.0F};
debris->original_angle = std::atan2(DY, DX);
debris->original_half_length = std::sqrt((DX * DX) + (DY * DY)) / 2.0F;
debris->p1 = world_p1;
debris->p2 = world_p2;
debris->p1 = WORLD_P1;
debris->p2 = WORLD_P2;
// Direcció radial (desde el centro hacia el segment)
Vec2 direccio = computeExplosionDirection(world_p1, world_p2, centro);
Vec2 direccio = computeExplosionDirection(WORLD_P1, WORLD_P2, centro);
// Velocidad inicial (base ± variació aleatòria + velocity heretada de l'objecte)
// Velocidad inicial (base ± variació aleatòria + velocity heretada de l'objecte +
// velocitat de la bala escalada per BULLET_IMPULSE_FACTOR).
float speed =
velocitat_base +
(((std::rand() / static_cast<float>(RAND_MAX)) * 2.0F - 1.0F) *
Defaults::Physics::Debris::VARIACIO_SPEED);
debris->velocity.x = (direccio.x * speed) + velocitat_objecte.x;
debris->velocity.y = (direccio.y * speed) + velocitat_objecte.y;
debris->velocity.x = (direccio.x * speed) + velocitat_objecte.x +
(bullet_impulse_velocity.x * Defaults::Physics::Debris::BULLET_IMPULSE_FACTOR);
debris->velocity.y = (direccio.y * speed) + velocitat_objecte.y +
(bullet_impulse_velocity.y * Defaults::Physics::Debris::BULLET_IMPULSE_FACTOR);
debris->acceleration = friction;
// Rotación de trayectoria (con conversió a tangencial si excedeix cap)
+12 -2
View File
@@ -47,6 +47,14 @@ namespace Effects {
// - lifetime: temps de vida del debris (s, per defecte TEMPS_VIDA = 2s)
// - friction: desacceleració del debris (px/s², per defecte ACCELERACIO = -60)
// - segment_multiplier: nombre de còpies per segment (per defecte 1 = sense duplicar)
// - bullet_impulse_velocity: velocitat de la bala que ha causat l'impacte (px/s,
// per defecte 0). S'aplica a cada fragment escalada per
// Defaults::Physics::Debris::BULLET_IMPULSE_FACTOR, independent de
// velocitat_objecte. Permet que els trossos "salten amb la força de la bala"
// encara que el cos sigui pesat i amb prou feines es mogui.
// - piece_scale: multiplicador de la longitud de cada fragment al spawn
// (per defecte 1.0). Útil per a debris "parcial" d'impactes no letals
// en enemics HP>1 (trossos petits, com d'esquerda).
void explode(const std::shared_ptr<Graphics::Shape>& shape,
const Vec2& centro,
float angle,
@@ -60,7 +68,9 @@ namespace Effects {
SDL_Color color = {0, 0, 0, 0}, // alpha==0 → fragmentos usan oscilador global
float lifetime = Defaults::Physics::Debris::TEMPS_VIDA,
float friction = Defaults::Physics::Debris::ACCELERACIO,
int segment_multiplier = 1);
int segment_multiplier = 1,
const Vec2& bullet_impulse_velocity = {.x = 0.0F, .y = 0.0F},
float piece_scale = 1.0F);
// Actualitzar todos los fragments active
void update(float delta_time);
@@ -97,7 +107,7 @@ namespace Effects {
-> std::vector<std::pair<Vec2, Vec2>>;
// Inicialitza un debris en un slot lliure i el deixa actiu. Retorna
// false si el pool está ple (la cridadora ha d'aturar el bucle).
auto spawnDebris(const Vec2& world_p1, const Vec2& world_p2, const Vec2& centro, float velocitat_base, float brightness, const Vec2& velocitat_objecte, float velocitat_angular, float factor_herencia_visual, SDL_Color color, float lifetime, float friction) -> bool;
auto spawnDebris(const Vec2& world_p1, const Vec2& world_p2, const Vec2& centro, float velocitat_base, float brightness, const Vec2& velocitat_objecte, float velocitat_angular, float factor_herencia_visual, SDL_Color color, float lifetime, float friction, const Vec2& bullet_impulse_velocity, float piece_scale) -> bool;
static void applyAngularVelocity(Debris& debris, const Vec2& direccio, float velocitat_angular);
static void applyVisualRotation(Debris& debris, float velocitat_angular, float factor_herencia_visual);
};
+20 -1
View File
@@ -54,10 +54,29 @@ void Bullet::init() {
body_.clearAccumulators();
}
void Bullet::fire(const Vec2& position, float angle, uint8_t owner_id, float bullet_speed) {
void Bullet::fire(const Vec2& position, float angle, uint8_t owner_id, float bullet_speed, const BulletConfig* cfg) {
is_active_ = true;
owner_id_ = owner_id;
// Si no es passa cfg, restaurem al config per defecte (BulletRegistry::get):
// els slots són reutilitzables i una bala que abans ha estat disparada amb
// una variant (p.ex. bullet_long d'enemic) ha de tornar al bullet.shp del
// player quan aquest la reutilitza.
const BulletConfig* effective = (cfg != nullptr) ? cfg : &BulletRegistry::get();
if (effective != config_) {
config_ = effective;
shape_ = Graphics::ShapeLoader::load(config_->shape.path);
if (!shape_ || !shape_->isValid()) {
std::cerr << "[Bullet] Error: no s'ha pogut carregar " << config_->shape.path << '\n';
}
const float BOUNDING = (shape_ != nullptr) ? shape_->getBoundingRadius() : 0.0F;
collision_radius_ = BOUNDING * config_->shape.scale * config_->shape.collision_factor;
body_.setMass(config_->physics.mass);
body_.restitution = config_->physics.restitution;
body_.linear_damping = config_->physics.linear_damping;
body_.angular_damping = config_->physics.angular_damping;
}
center_ = position;
prev_position_ = position; // spawn: swept degenera a punt-cercle
angle_ = angle;
+5 -1
View File
@@ -19,7 +19,11 @@ class Bullet : public Entities::Entity {
explicit Bullet(Rendering::Renderer* renderer);
void init() override;
void fire(const Vec2& position, float angle, uint8_t owner_id, float bullet_speed);
// cfg = nullptr → manté el config actual (per defecte: BulletRegistry::get()).
// cfg != nullptr → substitueix el config per a aquesta bala (recarrega
// shape, recalcula collision_radius_, mass, etc.). Útil per a bales
// d'enemic amb shape pròpia.
void fire(const Vec2& position, float angle, uint8_t owner_id, float bullet_speed, const BulletConfig* cfg = nullptr);
void update(float delta_time) override;
void postUpdate(float delta_time) override;
void draw() const override;
+32 -13
View File
@@ -1,4 +1,4 @@
// bullet_registry.cpp - Implementació del registre de bala
// bullet_registry.cpp - Implementació del registre de bales
// © 2026 JailDesigner
#include "game/entities/bullet_registry.hpp"
@@ -8,23 +8,34 @@
#include "core/entities/entity_loader.hpp"
BulletConfig BulletRegistry::config;
std::unordered_map<std::string, BulletConfig> BulletRegistry::configs;
bool BulletRegistry::loaded = false;
namespace {
// Tria comú: carrega el YAML d'un name, parseja a BulletConfig i el guarda
// al map. Retorna punter al config inserit, o nullptr si falla.
auto loadInto(std::unordered_map<std::string, BulletConfig>& configs, const std::string& name) -> const BulletConfig* {
auto yaml = Entities::EntityLoader::load(name);
if (!yaml) {
std::cerr << "[BulletRegistry] Error: no s'ha pogut carregar " << name << ".yaml\n";
return nullptr;
}
auto cfg = BulletConfig::fromYaml(*yaml);
if (!cfg) {
std::cerr << "[BulletRegistry] Error: format invàlid a " << name << ".yaml\n";
return nullptr;
}
auto [it, _] = configs.insert_or_assign(name, *cfg);
return &it->second;
}
} // namespace
auto BulletRegistry::load() -> bool {
auto yaml = Entities::EntityLoader::load("bullet");
if (!yaml) {
std::cerr << "[BulletRegistry] Error: no s'ha pogut carregar bullet.yaml\n";
if (loadInto(configs, "bullet") == nullptr) {
return false;
}
auto cfg = BulletConfig::fromYaml(*yaml);
if (!cfg) {
std::cerr << "[BulletRegistry] Error: format invàlid a bullet.yaml\n";
return false;
}
config = *cfg;
loaded = true;
std::cout << "[BulletRegistry] Configuració de bala carregada.\n";
std::cout << "[BulletRegistry] Configuració de bala 'bullet' carregada.\n";
return true;
}
@@ -33,5 +44,13 @@ auto BulletRegistry::get() -> const BulletConfig& {
std::cerr << "[BulletRegistry] FATAL: get() abans de load()\n";
std::exit(EXIT_FAILURE);
}
return config;
return configs.at("bullet");
}
auto BulletRegistry::get(const std::string& name) -> const BulletConfig* {
auto it = configs.find(name);
if (it != configs.end()) {
return &it->second;
}
return loadInto(configs, name);
}
+16 -8
View File
@@ -1,26 +1,34 @@
// bullet_registry.hpp - Registre estàtic de la configuració de la bala
// bullet_registry.hpp - Registre estàtic de configuracions de bala
// © 2026 JailDesigner
//
// Una única instància per a tota la sessió. Es manté el patró registry
// (paral·lel a EnemyRegistry) tot i ser una sola entitat: si el dia de demà
// hi ha més tipus de bala (laser/plasma/etc.) només cal estendre-ho.
// Diverses configuracions per nom (data/entities/<name>/<name>.yaml). El
// config "bullet" es manté com a default històric (player) i es carrega via
// load(). Els altres (ex. "bullet_long" per a bales d'enemic) es carreguen
// peresosament la primera vegada que algú els demana per nom.
#pragma once
#include <string>
#include <unordered_map>
#include "game/entities/bullet_config.hpp"
class BulletRegistry {
public:
BulletRegistry() = delete;
// Carrega data/entities/bullet/bullet.yaml. Retorna false si falla.
// Carrega data/entities/bullet/bullet.yaml com a default. Retorna false si falla.
static auto load() -> bool;
// Accés a la configuració. Avorta amb log fatal si load() no s'ha cridat
// o ha fallat.
// Accés a la configuració per defecte ("bullet"). Avorta amb log fatal si
// load() no s'ha cridat o ha fallat. Mantingut per a backwards compat del Bullet ctor.
static auto get() -> const BulletConfig&;
// Accés a una configuració per nom. Lazy-load: si no està al map, intenta
// carregar data/entities/<name>/<name>.yaml. Retorna nullptr si no es pot.
static auto get(const std::string& name) -> const BulletConfig*;
private:
static BulletConfig config;
static std::unordered_map<std::string, BulletConfig> configs;
static bool loaded;
};
+29 -83
View File
@@ -28,14 +28,6 @@ namespace {
};
}
// Recupera el "ángulo equivalente" de un body en movimiento (para zigzag).
auto velocityToAngle(const Vec2& velocity) -> float {
if (velocity.lengthSquared() < 0.0001F) {
return 0.0F;
}
return std::atan2(velocity.y, velocity.x) + (Constants::PI / 2.0F);
}
// Random float [0..1).
auto randFloat01() -> float {
return static_cast<float>(std::rand()) / static_cast<float>(RAND_MAX);
@@ -62,9 +54,14 @@ void Enemy::init(EnemyType type, const Vec2* ship_pos) {
config_ = &EnemyRegistry::get(type);
const EnemyConfig& cfg = *config_;
if (type_ == EnemyType::SQUARE) {
tracking_timer_ = 0.0F;
tracking_strength_ = cfg.behavior.tracking_strength;
ai_state_ = EnemyAiState{};
ai_state_.tracking_strength = cfg.ai.movement.tracking_strength;
// Timers paral·lels a tick: random [0, interval) per evitar que tots els
// enemics del mateix tipus disparin sincronitzats al spawn.
ai_tick_timers_.resize(cfg.ai.tick.size());
for (std::size_t i = 0; i < cfg.ai.tick.size(); ++i) {
ai_tick_timers_[i] = randFloat01() * cfg.ai.tick[i].interval;
}
shape_ = Graphics::ShapeLoader::load(cfg.shape.path);
@@ -136,7 +133,8 @@ void Enemy::init(EnemyType type, const Vec2* ship_pos) {
invulnerability_timer_ = cfg.spawn.invulnerability_duration;
brightness_ = cfg.spawn.invulnerability_brightness_start;
direction_change_timer_ = 0.0F;
health_ = cfg.health;
flash_timer_ = 0.0F;
is_active_ = true;
}
@@ -155,6 +153,11 @@ void Enemy::update(float delta_time) {
}
}
if (flash_timer_ > 0.0F) {
flash_timer_ -= delta_time;
flash_timer_ = std::max(flash_timer_, 0.0F);
}
if (invulnerability_timer_ > 0.0F) {
invulnerability_timer_ -= delta_time;
invulnerability_timer_ = std::max(invulnerability_timer_, 0.0F);
@@ -167,22 +170,9 @@ void Enemy::update(float delta_time) {
brightness_ = START + ((END - START) * SMOOTH_T);
}
if (!isWounded()) {
switch (type_) {
case EnemyType::PENTAGON:
case EnemyType::STAR:
// STAR reusa el zigzag esquivador de Pentagon. Si en el futur
// vol comportament propi, separa-li el cas.
behaviorPentagon(delta_time);
break;
case EnemyType::SQUARE:
behaviorSquare(delta_time);
break;
case EnemyType::PINWHEEL:
behaviorPinwheel(delta_time);
break;
}
}
// El moviment es delega a Systems::EnemyAi::tick, invocat des de l'scene
// ABANS d'aquest update (manté l'ordre: AI escriu velocity/rotation_delta,
// després animation pot modular rotation_delta via rotation_accel).
updateAnimation(delta_time);
@@ -210,7 +200,15 @@ void Enemy::draw() const {
}
}
Rendering::renderShape(renderer_, shape_, center_, rotation_, SCALE, 1.0F, brightness_, color);
// Flash d'impacte parcial (HP>1): força el color a blanc i el brillo a
// 1.0 durant la finestra de flash. Té prioritat sobre el blink wounded.
float effective_brightness = brightness_;
if (flash_timer_ > 0.0F) {
color = SDL_Color{.r = 255, .g = 255, .b = 255, .a = 255};
effective_brightness = 1.0F;
}
Rendering::renderShape(renderer_, shape_, center_, rotation_, SCALE, 1.0F, effective_brightness, color);
}
void Enemy::destroy() {
@@ -221,6 +219,7 @@ void Enemy::destroy() {
wounded_timer_ = 0.0F;
wound_expired_this_frame_ = false;
last_hit_by_ = 0xFF;
flash_timer_ = 0.0F;
}
void Enemy::hurt(uint8_t shooter_id) {
@@ -246,59 +245,6 @@ void Enemy::setVelocityFromAngle(float angle_movement, float speed) {
body_.velocity = angleToDirection(angle_movement) * speed;
}
// PENTAGON: zigzag esquivador. Canvis de direcció periòdics (probabilístics)
// en lloc de detectar parets; el rebot contra murs el fa PhysicsWorld.
void Enemy::behaviorPentagon(float delta_time) {
direction_change_timer_ += delta_time;
if (randFloat01() < config_->behavior.zigzag_prob_per_second * delta_time) {
const float CURRENT_ANGLE = velocityToAngle(body_.velocity);
const float DELTA = randFloat01() * config_->behavior.angle_change_max;
const float NEW_ANGLE = CURRENT_ANGLE + ((std::rand() % 2 == 0) ? DELTA : -DELTA);
const float SPEED = body_.velocity.length();
setVelocityFromAngle(NEW_ANGLE, SPEED);
direction_change_timer_ = 0.0F;
}
}
// SQUARE: tracking discret cap a la nau cada N segons.
void Enemy::behaviorSquare(float delta_time) {
tracking_timer_ += delta_time;
if (tracking_timer_ >= config_->behavior.tracking_interval && ship_position_ != nullptr) {
tracking_timer_ = 0.0F;
const Vec2 TO_SHIP = *ship_position_ - center_;
const float DIST = TO_SHIP.length();
if (DIST > 0.0F) {
const Vec2 DESIRED_DIR = TO_SHIP / DIST;
const float SPEED = body_.velocity.length();
const Vec2 DESIRED_VEL = DESIRED_DIR * SPEED;
body_.velocity = (body_.velocity * (1.0F - tracking_strength_)) +
(DESIRED_VEL * tracking_strength_);
const float NEW_SPEED = body_.velocity.length();
if (NEW_SPEED > 0.0F) {
body_.velocity = body_.velocity * (SPEED / NEW_SPEED);
}
}
}
}
// PINWHEEL: movement rectilini + boost de rotació visual prop del ship.
void Enemy::behaviorPinwheel(float /*delta_time*/) {
if (ship_position_ != nullptr) {
const Vec2 TO_SHIP = *ship_position_ - center_;
const float DIST = TO_SHIP.length();
if (DIST < config_->behavior.proximity_distance) {
rotation_delta_ = animation_.rotation_delta_base * config_->behavior.rotation_proximity_multiplier;
} else {
rotation_delta_ = animation_.rotation_delta_base;
}
}
}
void Enemy::updateAnimation(float delta_time) {
updatePulse(delta_time);
updateRotationAcceleration(delta_time);
@@ -373,7 +319,7 @@ auto Enemy::getBaseRotation() const -> float {
void Enemy::setTrackingStrength(float strength) {
if (type_ == EnemyType::SQUARE) {
tracking_strength_ = strength;
ai_state_.tracking_strength = strength;
}
}
+57 -18
View File
@@ -4,17 +4,24 @@
#pragma once
#include <SDL3/SDL.h>
#include <array>
#include <cstdint>
#include <vector>
#include "core/defaults/enemies.hpp"
#include "core/entities/entity.hpp"
#include "core/types.hpp"
#include "game/entities/enemy_ai.hpp"
class Ship;
// Tipo de enemy
enum class EnemyType : uint8_t {
PENTAGON = 0, // Pentágono esquivador (zigzag)
SQUARE = 1, // Square perseguidor (tracks ship)
PINWHEEL = 2, // Molinillo agresivo (rápido, girando)
STAR = 3 // Estrella de 5 puntes (clone visual de Pentagon, comportament zigzag)
PENTAGON = 0, // Pentágono esquivador (zigzag)
SQUARE = 1, // Square perseguidor (tracks ship)
PINWHEEL = 2, // Molinillo agresivo (rápido, girando)
STAR = 3, // Estrella de 5 puntes (clone visual de Pentagon, comportament zigzag)
BIG_PENTAGON = 4, // Pentàgon gegant tough (HP=10, chase lent — primer enemic HP>1)
};
// Forward declaration — EnemyConfig viu a enemy_config.hpp i s'inclou només a enemy.cpp.
@@ -70,8 +77,18 @@ class Enemy : public Entities::Entity {
// ha estat inicialitzat almenys un cop; abans és nullptr.
[[nodiscard]] auto getConfig() const -> const EnemyConfig& { return *config_; }
// Set ship position reference for tracking behavior
void setShipPosition(const Vec2* ship_pos) { ship_position_ = ship_pos; }
// Referències als 2 ships per a AI de tracking/proximity/chase/flee.
// nullptr = ship inexistent al match. El sistema d'IA filtra per ship->isActive().
void setShips(const Ship* p1, const Ship* p2) { ships_ = {p1, p2}; }
[[nodiscard]] auto getShips() const -> const std::array<const Ship*, 2>& { return ships_; }
// Accessors per al sistema d'IA (Systems::EnemyAi).
[[nodiscard]] auto getAiState() -> EnemyAiState& { return ai_state_; }
[[nodiscard]] auto getAiTickTimers() -> std::vector<float>& { return ai_tick_timers_; }
[[nodiscard]] auto getRotationBase() const -> float { return animation_.rotation_delta_base; }
void setRotationDelta(float rot) { rotation_delta_ = rot; }
// Public: el sistema d'IA reorienta la velocitat des d'un angle.
void setVelocityFromAngle(float angle_movement, float speed);
// Stage system API (base stats)
[[nodiscard]] auto getBaseVelocity() const -> float;
@@ -101,6 +118,20 @@ class Enemy : public Entities::Entity {
void consumeWoundExpired() { wound_expired_this_frame_ = false; }
[[nodiscard]] auto getLastHitBy() const -> uint8_t { return last_hit_by_; }
// Salut: decrementada per l'acció DECREASE_HEALTH al dispatcher d'events.
// Quan arriba a 0 o menys, el dispatcher dispara ON_NO_HEALTH (que
// típicament encadena SET_HURT o DESTROY al YAML). last_hit_by s'actualitza
// al decrement perquè la mort posterior atribueixi correctament el kill.
[[nodiscard]] auto getHealth() const -> int { return health_; }
void decrementHealth(uint8_t shooter_id = 0xFF) {
--health_;
last_hit_by_ = shooter_id;
}
// Flash visual: brief impacto-feedback quan rep un hit no letal (HP>1).
// Disparat per l'acció FLASH; el render alça la lluminositat mentre dura.
void triggerFlash() { flash_timer_ = Defaults::Enemies::Visual::FLASH_DURATION; }
// Aplica un impulso (cambio inmediato de velocidad mass-aware) al cuerpo físico.
void applyImpulse(const Vec2& impulse);
@@ -120,11 +151,16 @@ class Enemy : public Entities::Entity {
EnemyType type_{EnemyType::PENTAGON};
EnemyAnimation animation_;
// Comportamiento type-specific
float tracking_timer_{0.0F};
const Vec2* ship_position_{nullptr};
float tracking_strength_{0.0F};
float direction_change_timer_{0.0F};
// Estat per-instància que la primitiva de moviment manté entre frames.
EnemyAiState ai_state_;
// Timers paral·lels a config_->ai.tick: timers_[i] és el temps restant
// (en segons) fins a la pròxima execució de l'acció i. Re-dimensionat a
// init() segons la mida de config_->ai.tick.
std::vector<float> ai_tick_timers_;
// Referències als 2 ships per a AI de tracking/proximity/chase/flee.
std::array<const Ship*, 2> ships_{nullptr, nullptr};
// Invulnerabilidad post-spawn
float invulnerability_timer_{0.0F};
@@ -134,18 +170,21 @@ class Enemy : public Entities::Entity {
bool wound_expired_this_frame_{false};
uint8_t last_hit_by_{0xFF};
// Salut per-instància. Reseteja a config_->health a init(); el dispatcher
// d'events la decrementa via DECREASE_HEALTH i dispara ON_NO_HEALTH quan
// creua zero. Permet enemics tough (HP>1) sense canvis al motor.
int health_{1};
// Flash visual temporitzat per a feedback d'impacte parcial (HP>1).
// L'acció FLASH el reseteja a FLASH_DURATION; draw() alça la lluminositat
// mentre dura, i update() el decrementa.
float flash_timer_{0.0F};
// Métodos privados
void updateAnimation(float delta_time);
void updatePulse(float delta_time);
void updateRotationAcceleration(float delta_time);
void behaviorPentagon(float delta_time);
void behaviorSquare(float delta_time);
void behaviorPinwheel(float delta_time);
[[nodiscard]] auto computeCurrentScale() const -> float;
// Static: passa els paràmetres com a args per no acoblar a *this.
static auto attemptSafeSpawn(const Vec2& ship_pos, float collision_radius, float safety_distance, float& out_x, float& out_y) -> bool;
// Helper: setear body_.velocity desde un ángulo y magnitud.
// angle_movement=0 apunta hacia arriba (eje Y negativo SDL).
void setVelocityFromAngle(float angle_movement, float speed);
};
+84
View File
@@ -0,0 +1,84 @@
// enemy_ai.hpp - Sistema declaratiu d'IA per a enemics
// © 2026 JailDesigner
//
// Cada enemic declara al seu YAML quin movement primitiu fa servir i, opcional-
// ment, una llista d'accions periòdiques (tick). El motor només dispatcha; el
// comportament viu a les dades. Patró paral·lel al d'events declaratius
// (enemy_event.hpp).
#pragma once
#include <cstdint>
#include <string>
#include <vector>
// Primitiva de moviment activa per a un enemic. Substitueix el switch
// hardcoded sobre EnemyType.
enum class MovementType : uint8_t {
ZIGZAG, // Canvi de direcció probabilístic agressiu (Pentagon/Star)
TRACKING, // LERP discret cap al ship cada N segons (Square)
RECTILINEAR_PROXIMITY, // Rectilini + boost rotació visual prop del ship (Pinwheel)
WANDER, // Canvi de direcció probabilístic suau, sense target
CHASE, // Steering continu cap al ship més proper
FLEE, // Steering continu allunyant-se del ship més proper
};
// Accions que s'executen periòdicament (un timer per acció). Futur (Fase C):
// SHOOT amb aim_mode/jitter/bullet config.
enum class AiActionType : uint8_t {
SHOOT,
};
enum class AimMode : uint8_t {
RANDOM, // Angle uniformement aleatori
AIMED, // atan2(nearest_ship - center) + soroll gaussià (jitter_rad)
};
// Camps de tots els movements; només el subset rellevant per al type actiu
// s'usa. Els altres queden a 0.0F. Mateixa filosofia que la BehaviorCfg
// llegacy però amb el type explícit dins.
struct MovementConfig {
MovementType type{MovementType::ZIGZAG};
// ZIGZAG i WANDER (canvi de direcció probabilístic; comparteixen camps).
float angle_change_max{0.0F};
float zigzag_prob_per_second{0.0F};
// TRACKING
float tracking_strength{0.0F};
float tracking_interval{0.0F};
// RECTILINEAR_PROXIMITY
float rotation_proximity_multiplier{0.0F};
float proximity_distance{0.0F};
// CHASE / FLEE: força del steering per segon (LERP velocity ↔ direcció ideal).
// 1.0 = en ~1s la velocitat queda totalment realineada cap al target.
float chase_strength{0.0F};
float flee_strength{0.0F};
};
// Acció periòdica. interval = segons entre disparades; el dispatcher manté un
// timer per acció (paral·lel a aquesta llista) i dispara quan arriba a 0.
struct AiTickAction {
AiActionType type{AiActionType::SHOOT};
float interval{1.0F};
AimMode aim_mode{AimMode::RANDOM};
float jitter_rad{0.0F};
float bullet_speed{200.0F}; // px/s; la magnitud la decideix l'enemic, no el bullet config
std::string bullet_config_name; // bullet config a usar (lazy-load des de BulletRegistry)
};
struct EnemyAiConfig {
MovementConfig movement;
std::vector<AiTickAction> tick;
};
// Estat per-instància que la primitiva de moviment manté entre frames (timers
// d'interval, contadors de canvi de direcció...). Es viu dins de Enemy i el
// sistema d'IA hi escriu via getAiState().
struct EnemyAiState {
float direction_change_timer{0.0F}; // ZIGZAG
float tracking_timer{0.0F}; // TRACKING
float tracking_strength{0.0F}; // TRACKING (cau de cfg, mutable per dificultat)
};
+166
View File
@@ -30,6 +30,7 @@ namespace {
if (s == "square") { return EnemyType::SQUARE; }
if (s == "pinwheel") { return EnemyType::PINWHEEL; }
if (s == "star") { return EnemyType::STAR; }
if (s == "big_pentagon") { return EnemyType::BIG_PENTAGON; }
return std::nullopt;
}
@@ -177,13 +178,25 @@ namespace {
return true;
}
// health és opcional: si el YAML no l'inclou, el default {1} de l'struct
// ja cobreix el comportament de tots els enemics actuals (1 hit → mort).
void parseHealth(const fkyaml::node& node, int& out) {
if (node.contains("health")) {
out = node["health"].get_value<int>();
}
}
auto actionTypeFromString(const std::string& s) -> std::optional<EnemyActionType> {
if (s == "set_hurt") { return EnemyActionType::SET_HURT; }
if (s == "destroy") { return EnemyActionType::DESTROY; }
if (s == "add_score") { return EnemyActionType::ADD_SCORE; }
if (s == "create_debris") { return EnemyActionType::CREATE_DEBRIS; }
if (s == "create_debris_partial") { return EnemyActionType::CREATE_DEBRIS_PARTIAL; }
if (s == "create_fireworks") { return EnemyActionType::CREATE_FIREWORKS; }
if (s == "create_fireworks_small") { return EnemyActionType::CREATE_FIREWORKS_SMALL; }
if (s == "apply_impulse") { return EnemyActionType::APPLY_IMPULSE; }
if (s == "decrease_health") { return EnemyActionType::DECREASE_HEALTH; }
if (s == "flash") { return EnemyActionType::FLASH; }
return std::nullopt;
}
@@ -222,6 +235,153 @@ namespace {
};
}
auto movementTypeFromString(const std::string& s) -> std::optional<MovementType> {
if (s == "zigzag") { return MovementType::ZIGZAG; }
if (s == "tracking") { return MovementType::TRACKING; }
if (s == "rectilinear_proximity") { return MovementType::RECTILINEAR_PROXIMITY; }
if (s == "wander") { return MovementType::WANDER; }
if (s == "chase") { return MovementType::CHASE; }
if (s == "flee") { return MovementType::FLEE; }
return std::nullopt;
}
auto aiActionTypeFromString(const std::string& s) -> std::optional<AiActionType> {
if (s == "shoot") { return AiActionType::SHOOT; }
return std::nullopt;
}
auto aimModeFromString(const std::string& s) -> std::optional<AimMode> {
if (s == "random") { return AimMode::RANDOM; }
if (s == "aimed") { return AimMode::AIMED; }
return std::nullopt;
}
auto parseMovement(const fkyaml::node& mv_node, const std::string& enemy_name, MovementConfig& out) -> bool {
if (!mv_node.contains("type")) {
std::cerr << "[EnemyConfig] Error: falta 'ai.movement.type' a " << enemy_name << '\n';
return false;
}
const auto TYPE_STR = mv_node["type"].get_value<std::string>();
const auto PARSED = movementTypeFromString(TYPE_STR);
if (!PARSED) {
std::cerr << "[EnemyConfig] Error: movement type desconegut '" << TYPE_STR
<< "' a " << enemy_name << '\n';
return false;
}
out.type = *PARSED;
const auto READ_OPT = [&mv_node](const char* key, float& dst) {
if (mv_node.contains(key)) {
dst = mv_node[key].get_value<float>();
}
};
READ_OPT("angle_change_max", out.angle_change_max);
READ_OPT("zigzag_prob_per_second", out.zigzag_prob_per_second);
READ_OPT("tracking_strength", out.tracking_strength);
READ_OPT("tracking_interval", out.tracking_interval);
READ_OPT("rotation_proximity_multiplier", out.rotation_proximity_multiplier);
READ_OPT("proximity_distance", out.proximity_distance);
READ_OPT("chase_strength", out.chase_strength);
READ_OPT("flee_strength", out.flee_strength);
return true;
}
auto parseTickList(const fkyaml::node& list_node, const std::string& enemy_name, std::vector<AiTickAction>& out) -> bool {
if (!list_node.is_sequence()) {
std::cerr << "[EnemyConfig] Error: 'ai.tick' ha de ser una llista a " << enemy_name << '\n';
return false;
}
for (const auto& item : list_node) {
if (!item.contains("action")) {
std::cerr << "[EnemyConfig] Error: entrada sense 'action' a ai.tick ("
<< enemy_name << ")\n";
return false;
}
const auto STR = item["action"].get_value<std::string>();
const auto PARSED = aiActionTypeFromString(STR);
if (!PARSED) {
std::cerr << "[EnemyConfig] Error: acció d'ai desconeguda '" << STR
<< "' a " << enemy_name << '\n';
return false;
}
AiTickAction action;
action.type = *PARSED;
if (item.contains("interval")) {
action.interval = item["interval"].get_value<float>();
}
if (item.contains("aim_mode")) {
const auto AIM_STR = item["aim_mode"].get_value<std::string>();
const auto AIM = aimModeFromString(AIM_STR);
if (!AIM) {
std::cerr << "[EnemyConfig] Error: aim_mode desconegut '" << AIM_STR
<< "' a ai.tick (" << enemy_name << ")\n";
return false;
}
action.aim_mode = *AIM;
}
if (item.contains("jitter_rad")) {
action.jitter_rad = item["jitter_rad"].get_value<float>();
}
if (item.contains("bullet_speed")) {
action.bullet_speed = item["bullet_speed"].get_value<float>();
}
if (item.contains("bullet")) {
action.bullet_config_name = item["bullet"].get_value<std::string>();
}
out.push_back(action);
}
return true;
}
// Migració progressiva: si el YAML no porta secció `ai:`, derivem el
// movement a partir de l'ai_type i copiem els paràmetres de la BehaviorCfg
// ja parsejada. Comportament idèntic al hardcoded actual.
void fillLegacyAiDefaults(EnemyType ai_type, const EnemyConfig::BehaviorCfg& legacy, EnemyAiConfig& out) {
switch (ai_type) {
case EnemyType::PENTAGON:
case EnemyType::STAR:
out.movement.type = MovementType::ZIGZAG;
out.movement.angle_change_max = legacy.angle_change_max;
out.movement.zigzag_prob_per_second = legacy.zigzag_prob_per_second;
break;
case EnemyType::SQUARE:
out.movement.type = MovementType::TRACKING;
out.movement.tracking_strength = legacy.tracking_strength;
out.movement.tracking_interval = legacy.tracking_interval;
break;
case EnemyType::PINWHEEL:
out.movement.type = MovementType::RECTILINEAR_PROXIMITY;
out.movement.rotation_proximity_multiplier = legacy.rotation_proximity_multiplier;
out.movement.proximity_distance = legacy.proximity_distance;
break;
case EnemyType::BIG_PENTAGON:
// Sense legacy fallback: el YAML del big_pentagon ha de definir
// ai.movement explícitament. Default chase lent perquè el switch
// siga exhaustiu i no falli si algú omet el bloc ai.
out.movement.type = MovementType::CHASE;
out.movement.chase_strength = 0.3F;
break;
}
}
auto parseAi(const fkyaml::node& node, const std::string& name, EnemyType ai_type, const EnemyConfig::BehaviorCfg& legacy, EnemyAiConfig& out) -> bool {
if (!node.contains("ai")) {
fillLegacyAiDefaults(ai_type, legacy, out);
return true;
}
const auto& ai = node["ai"];
if (!ai.contains("movement")) {
std::cerr << "[EnemyConfig] Error: falta 'ai.movement' a " << name << '\n';
return false;
}
if (!parseMovement(ai["movement"], name, out.movement)) {
return false;
}
if (ai.contains("tick") && !parseTickList(ai["tick"], name, out.tick)) {
return false;
}
return true;
}
auto parseEvents(const fkyaml::node& node, const std::string& name, EnemyEventConfig& out) -> bool {
if (!node.contains("events")) {
fillLegacyDefaults(out);
@@ -231,6 +391,10 @@ namespace {
if (e.contains("on_hit") && !parseActionList(e["on_hit"], name, "on_hit", out.on_hit)) {
return false;
}
if (e.contains("on_no_health") &&
!parseActionList(e["on_no_health"], name, "on_no_health", out.on_no_health)) {
return false;
}
if (e.contains("on_hurt_end") &&
!parseActionList(e["on_hurt_end"], name, "on_hurt_end", out.on_hurt_end)) {
return false;
@@ -267,7 +431,9 @@ auto EnemyConfig::fromYaml(const fkyaml::node& node, EnemyType expected_ai_type)
if (!parseSpawn(node, cfg.name, cfg.spawn)) { return std::nullopt; }
if (!parseColors(node, cfg.name, cfg.colors)) { return std::nullopt; }
if (!parseScore(node, cfg.name, cfg.score)) { return std::nullopt; }
parseHealth(node, cfg.health);
if (!parseEvents(node, cfg.name, cfg.events)) { return std::nullopt; }
if (!parseAi(node, cfg.name, cfg.ai_type, cfg.behavior, cfg.ai)) { return std::nullopt; }
return cfg;
} catch (const std::exception& e) {
+6
View File
@@ -14,6 +14,7 @@
#include "external/fkyaml_node.hpp"
#include "game/entities/enemy.hpp" // EnemyType
#include "game/entities/enemy_ai.hpp"
#include "game/entities/enemy_event.hpp"
struct EnemyConfig {
@@ -99,7 +100,12 @@ struct EnemyConfig {
SpawnCfg spawn;
ColorsCfg colors;
int score;
// Salut inicial: per defecte 1 (un balazo → on_no_health). Els YAMLs poden
// pujar-lo (p.ex. 10 per a un enemic tough). El sistema d'events és qui
// decideix què passa quan la salut arriba a 0 via on_no_health.
int health{1};
EnemyEventConfig events;
EnemyAiConfig ai;
// Parseja un descriptor d'enemic. expected_ai_type valida que ai_type del
// YAML coincideix amb el tipus que el caller espera (segons el directori).
+17 -9
View File
@@ -11,18 +11,23 @@
#include <vector>
enum class EnemyEventType : uint8_t {
ON_HIT, // Impactat per una bala
ON_HURT_END, // Timer wounded ha expirat aquest frame
ON_DESTROY, // L'acció destroy s'està executant (efectes col·laterals)
ON_HIT, // Impactat per una bala
ON_NO_HEALTH, // health ha arribat a 0 o menys aquest frame (via DECREASE_HEALTH)
ON_HURT_END, // Timer wounded ha expirat aquest frame
ON_DESTROY, // L'acció destroy s'està executant (efectes col·laterals)
};
enum class EnemyActionType : uint8_t {
SET_HURT, // Entra estat wounded (o destrueix si ja era wounded)
DESTROY, // Dispara on_destroy + desactiva físicament
ADD_SCORE, // Suma config.score al shooter + floating score
CREATE_DEBRIS, // Explosió de debris amb herència de velocitat
CREATE_FIREWORKS, // Burst radial de firework
APPLY_IMPULSE, // Aplica l'impuls de la bala impactant
SET_HURT, // Entra estat wounded (o destrueix si ja era wounded)
DESTROY, // Dispara on_destroy + desactiva físicament
ADD_SCORE, // Suma config.score al shooter + floating score
CREATE_DEBRIS, // Explosió de debris amb herència de velocitat
CREATE_DEBRIS_PARTIAL, // Debris de xip parcial (trossos a escala 0.3, per hits HP>1)
CREATE_FIREWORKS, // Burst radial de firework
CREATE_FIREWORKS_SMALL, // Burst petit (pocs punts, poca velocitat) — feedback per hit
APPLY_IMPULSE, // Aplica l'impuls de la bala impactant
DECREASE_HEALTH, // Decrementa health_; si <=0, dispatcha ON_NO_HEALTH
FLASH, // Flash visual breu (feedback per impacte parcial)
};
struct EnemyAction {
@@ -31,6 +36,7 @@ struct EnemyAction {
struct EnemyEventConfig {
std::vector<EnemyAction> on_hit;
std::vector<EnemyAction> on_no_health;
std::vector<EnemyAction> on_hurt_end;
std::vector<EnemyAction> on_destroy;
@@ -38,6 +44,8 @@ struct EnemyEventConfig {
switch (event) {
case EnemyEventType::ON_HIT:
return on_hit;
case EnemyEventType::ON_NO_HEALTH:
return on_no_health;
case EnemyEventType::ON_HURT_END:
return on_hurt_end;
case EnemyEventType::ON_DESTROY:
+6 -2
View File
@@ -13,6 +13,7 @@ EnemyConfig EnemyRegistry::pentagon_config;
EnemyConfig EnemyRegistry::square_config;
EnemyConfig EnemyRegistry::pinwheel_config;
EnemyConfig EnemyRegistry::star_config;
EnemyConfig EnemyRegistry::big_pentagon_config;
bool EnemyRegistry::loaded = false;
namespace {
@@ -38,10 +39,11 @@ auto EnemyRegistry::loadAll() -> bool {
const bool OK = loadOne("pentagon", EnemyType::PENTAGON, pentagon_config) &&
loadOne("square", EnemyType::SQUARE, square_config) &&
loadOne("pinwheel", EnemyType::PINWHEEL, pinwheel_config) &&
loadOne("star", EnemyType::STAR, star_config);
loadOne("star", EnemyType::STAR, star_config) &&
loadOne("big_pentagon", EnemyType::BIG_PENTAGON, big_pentagon_config);
loaded = OK;
if (OK) {
std::cout << "[EnemyRegistry] 4 configuracions d'enemic carregades.\n";
std::cout << "[EnemyRegistry] 5 configuracions d'enemic carregades.\n";
}
return OK;
}
@@ -60,6 +62,8 @@ auto EnemyRegistry::get(EnemyType type) -> const EnemyConfig& {
return pinwheel_config;
case EnemyType::STAR:
return star_config;
case EnemyType::BIG_PENTAGON:
return big_pentagon_config;
}
std::cerr << "[EnemyRegistry] FATAL: tipus desconegut\n";
std::exit(EXIT_FAILURE);
+1
View File
@@ -27,5 +27,6 @@ class EnemyRegistry {
static EnemyConfig square_config;
static EnemyConfig pinwheel_config;
static EnemyConfig star_config;
static EnemyConfig big_pentagon_config;
static bool loaded;
};
+22 -8
View File
@@ -21,6 +21,7 @@
#include "game/stage_system/stage_loader.hpp"
#include "game/systems/collision_system.hpp"
#include "game/systems/continue_system.hpp"
#include "game/systems/enemy_ai_system.hpp"
#include "game/systems/init_hud_animator.hpp"
// Using declarations per simplificar el codi
@@ -185,7 +186,7 @@ GameScene::GameScene(SDLManager& sdl, SceneContext& context)
// Registramos el body al world incluso inactivo: con radius=0 no colisiona
// ni se mueve, y al init() del stage system se activa sin re-registrar.
for (auto& enemy : enemies_) {
enemy.setShipPosition(&ships_[0].getCenter()); // Set ship reference (P1 for now)
enemy.setShips(ships_.data(), &ships_[1]);
physics_world_.addBody(&enemy.getBody());
// DON'T call enemy.init() here - stage system handles spawning
}
@@ -353,6 +354,7 @@ auto GameScene::stepContinueScreen(float delta_time) -> bool {
// Enemies, bullets y efectos siguen moviéndose en background.
for (auto& enemy : enemies_) {
Systems::EnemyAi::move(enemy, delta_time);
enemy.update(delta_time);
}
for (auto& bullet : bullets_) {
@@ -379,6 +381,7 @@ auto GameScene::stepGameOver(float delta_time) -> bool {
// Enemies, bullets y efectos siguen moviéndose como fondo.
for (auto& enemy : enemies_) {
Systems::EnemyAi::move(enemy, delta_time);
enemy.update(delta_time);
}
for (auto& bullet : bullets_) {
@@ -428,6 +431,7 @@ void GameScene::stepDeathSequence(float delta_time) {
// aunque otros jugadores aún jueguen.
if (algun_mort) {
for (auto& enemy : enemies_) {
Systems::EnemyAi::move(enemy, delta_time);
enemy.update(delta_time);
}
for (auto& bullet : bullets_) {
@@ -527,8 +531,10 @@ void GameScene::runStagePlaying(float delta_time) {
ships_[i].update(delta_time);
}
}
for (auto& enemy : enemies_) {
enemy.update(delta_time);
auto ai_ctx = buildCollisionContext();
for (std::size_t i = 0; i < enemies_.size(); ++i) {
Systems::EnemyAi::tick(ai_ctx, enemies_[i], i, delta_time);
enemies_[i].update(delta_time);
}
// Col·lisions primer, després desactivació per fora-de-zone: així una bala que
@@ -562,8 +568,8 @@ void GameScene::runStageLevelCompleted(float delta_time) {
floating_score_manager_.update(delta_time);
}
void GameScene::runCollisionDetections() {
Systems::Collision::Context col_ctx{
auto GameScene::buildCollisionContext() -> Systems::Collision::Context {
return Systems::Collision::Context{
.ships = ships_,
.enemies = enemies_,
.bullets = bullets_,
@@ -574,8 +580,12 @@ void GameScene::runCollisionDetections() {
.firework_manager = firework_manager_,
.floating_score_manager = floating_score_manager_,
.match_config = match_config_,
.on_player_hit = [this](uint8_t pid) { tocado(pid); },
.on_player_hit = [this](uint8_t pid, const Vec2& bv) { tocado(pid, bv); },
};
}
void GameScene::runCollisionDetections() {
auto col_ctx = buildCollisionContext();
Systems::Collision::detectAll(col_ctx);
}
@@ -754,7 +764,7 @@ void GameScene::drawLevelCompletedState() {
drawScoreboard();
}
void GameScene::tocado(uint8_t player_id) {
void GameScene::tocado(uint8_t player_id, const Vec2& bullet_velocity) {
// Death sequence: 3 phases
// Phase 1: First call (hit_timer_per_player_[player_id] == 0) - trigger explosion
// Phase 2: Animation (0 < itocado_ < 3.0s) - debris animation
@@ -778,6 +788,9 @@ void GameScene::tocado(uint8_t player_id) {
// Mateixa dispersió i efecte que els debris d'enemic (lifetime,
// friction, segment_multiplier alineats); només canvien sound i color.
// bullet_velocity arriba a explode() com a impuls extra independent
// de la inèrcia del cos del ship — els trossos volen amb la força
// de la bala encara que el ship estiga quiet.
debris_manager_.explode(
ships_[player_id].getShape(),
SHIP_POS,
@@ -792,7 +805,8 @@ void GameScene::tocado(uint8_t player_id) {
ships_[player_id].getConfig().colors.normal,
Defaults::Physics::Debris::ENEMY_LIFETIME,
Defaults::Physics::Debris::ENEMY_FRICTION,
Defaults::Physics::Debris::ENEMY_SEGMENT_MULTIPLIER);
Defaults::Physics::Debris::ENEMY_SEGMENT_MULTIPLIER,
bullet_velocity);
// Start death timer (non-zero to avoid re-triggering)
hit_timer_per_player_[player_id] = Defaults::Game::HIT_TIMER_TRIGGER_DEATH;
+10 -2
View File
@@ -28,6 +28,7 @@
#include "game/entities/ship.hpp"
#include "game/stage_system/stage_config.hpp"
#include "game/stage_system/stage_manager.hpp"
#include "game/systems/collision_system.hpp"
// Game over state machine
enum class GameOverState : uint8_t {
@@ -67,7 +68,7 @@ class GameScene final : public Scene {
std::array<Enemy, Constants::MAX_ORNIS> enemies_;
// 6 balas: P1=[0,1,2], P2=[3,4,5]. El cast a size_t evita la
// widening conversion implícita que detecta clang-tidy.
std::array<Bullet, static_cast<std::size_t>(Constants::MAX_BULLETS) * 2> bullets_;
std::array<Bullet, static_cast<std::size_t>(Defaults::Entities::MAX_BULLETS_TOTAL)> bullets_;
std::array<float, 2> hit_timer_per_player_; // Death timers per player (seconds)
// Lives and game over system
@@ -100,7 +101,10 @@ class GameScene final : public Scene {
bool init_hud_rect_sound_played_{false}; // Flag para evitar repetir sonido del rectángulo
// Funciones privades
void tocado(uint8_t player_id);
// bullet_velocity: velocitat de la bala que ha causat la mort (Vec2{} si no
// ve d'una bala). Es passa al debris perquè els fragments volin en direcció
// de la bala (independent de la inèrcia del cos del ship).
void tocado(uint8_t player_id, const Vec2& bullet_velocity = {.x = 0.0F, .y = 0.0F});
void drawScoreboard(); // Dibuixar marcador de puntuación
void fireBullet(uint8_t player_id); // Shoot bullet from player
[[nodiscard]] auto getSpawnPoint(uint8_t player_id) const -> Vec2; // Get spawn position for player
@@ -144,6 +148,10 @@ class GameScene final : public Scene {
void runStageLevelStart(float delta_time);
void runStagePlaying(float delta_time);
void runStageLevelCompleted(float delta_time);
// Construeix el Collision::Context del frame actual (referències a tots els
// pools/managers + on_player_hit). Reutilitzat tant per al tick d'IA com
// per a runCollisionDetections.
[[nodiscard]] auto buildCollisionContext() -> Systems::Collision::Context;
// Helper: ejecuta colisiones de gameplay con el Context preparado.
void runCollisionDetections();
};
@@ -129,17 +129,21 @@ namespace StageSystem {
// Weighted random selection based on distribution
int rand_val = std::rand() % 100;
const auto& d = config_->distribucio;
if (std::cmp_less(rand_val, config_->distribucio.pentagon)) {
if (std::cmp_less(rand_val, d.pentagon)) {
return EnemyType::PENTAGON;
}
if (rand_val < config_->distribucio.pentagon + config_->distribucio.cuadrado) {
if (rand_val < d.pentagon + d.cuadrado) {
return EnemyType::SQUARE;
}
if (rand_val < config_->distribucio.pentagon + config_->distribucio.cuadrado + config_->distribucio.molinillo) {
if (rand_val < d.pentagon + d.cuadrado + d.molinillo) {
return EnemyType::PINWHEEL;
}
return EnemyType::STAR;
if (rand_val < d.pentagon + d.cuadrado + d.molinillo + d.star) {
return EnemyType::STAR;
}
return EnemyType::BIG_PENTAGON;
}
void SpawnController::spawnEnemy(Enemy& enemy, EnemyType type, const Vec2* ship_pos) {
+7 -6
View File
@@ -25,11 +25,12 @@ namespace StageSystem {
// Distribució de type de enemigos (percentatges)
struct DistribucioEnemics {
uint8_t pentagon; // 0-100
uint8_t cuadrado; // 0-100
uint8_t molinillo; // 0-100
uint8_t star{0}; // 0-100 (opcional al YAML; default 0 per compat amb stages antics)
// Suma ha de ser 100, validat en StageLoader
uint8_t pentagon; // 0-100
uint8_t cuadrado; // 0-100
uint8_t molinillo; // 0-100
uint8_t star{0}; // 0-100 (opcional al YAML; default 0 per compat amb stages antics)
uint8_t big_pentagon{0}; // 0-100 (opcional; enemic gegant HP=10)
// Suma ha de ser 100, validat en StageLoader
};
// Multiplicadors de dificultat
@@ -60,7 +61,7 @@ namespace StageSystem {
// el tipo; basta con confirmar que no es 0 (sentinela "sin asignar").
return stage_id >= 1 &&
total_enemies > 0 && total_enemies <= 200 &&
distribucio.pentagon + distribucio.cuadrado + distribucio.molinillo + distribucio.star == 100;
distribucio.pentagon + distribucio.cuadrado + distribucio.molinillo + distribucio.star + distribucio.big_pentagon == 100;
}
};
+3 -2
View File
@@ -171,11 +171,12 @@ namespace StageSystem {
dist.pentagon = yaml["pentagon"].get_value<uint8_t>();
dist.cuadrado = yaml["cuadrado"].get_value<uint8_t>();
dist.molinillo = yaml["molinillo"].get_value<uint8_t>();
// 'star' és opcional per compatibilitat amb stages antics (default 0).
// 'star' i 'big_pentagon' són opcionals per compatibilitat amb stages antics (default 0).
dist.star = yaml.contains("star") ? yaml["star"].get_value<uint8_t>() : 0;
dist.big_pentagon = yaml.contains("big_pentagon") ? yaml["big_pentagon"].get_value<uint8_t>() : 0;
// Validar que suma 100
int sum = dist.pentagon + dist.cuadrado + dist.molinillo + dist.star;
int sum = dist.pentagon + dist.cuadrado + dist.molinillo + dist.star + dist.big_pentagon;
if (sum != 100) {
std::cerr << "[StageLoader] Error: distribució no suma 100 (suma=" << sum << ")" << '\n';
return false;
+76 -43
View File
@@ -41,20 +41,32 @@ namespace Systems::Collision {
void detectBulletEnemy(Context& ctx) {
constexpr float AMPLIFIER = Defaults::Game::COLLISION_BULLET_ENEMY_AMPLIFIER;
constexpr uint8_t NO_SHOOTER = 0xFF;
constexpr uint8_t ENEMY_BASE = Defaults::Entities::ENEMY_OWNER_BASE;
for (auto& bullet : ctx.bullets) {
if (!bullet.isActive()) {
continue;
}
for (auto& enemy : ctx.enemies) {
const uint8_t BULLET_OWNER = bullet.getOwnerId();
const bool IS_ENEMY_BULLET = BULLET_OWNER >= ENEMY_BASE;
for (std::size_t i = 0; i < ctx.enemies.size(); ++i) {
Enemy& enemy = ctx.enemies[i];
// Self-shot: una bala d'enemic mai no impacta el seu propi creador.
if (IS_ENEMY_BULLET && BULLET_OWNER == static_cast<uint8_t>(ENEMY_BASE + i)) {
continue;
}
if (!Physics::checkCollisionSwept(bullet.getPrevPosition(), bullet.getCenter(), bullet.getCollisionRadius(), enemy, AMPLIFIER)) {
continue;
}
// *** COLISIÓN bullet → enemy ***
// La cadena d'efectes (impulse, hurt, destroy, debris, score...) viu
// al YAML de l'enemic via la secció `events:`. Aquí només dispatchem.
Systems::EnemyEvents::dispatchEvent(ctx, enemy, EnemyEventType::ON_HIT, bullet.getOwnerId(), &bullet);
// al YAML de l'enemic via la secció `events:`. Si la bala és d'enemic,
// no atribuïm el kill a cap player (NO_SHOOTER).
const uint8_t SHOOTER = IS_ENEMY_BULLET ? NO_SHOOTER : BULLET_OWNER;
Systems::EnemyEvents::dispatchEvent(ctx, enemy, EnemyEventType::ON_HIT, SHOOTER, &bullet);
breakBullet(ctx.debris_manager, bullet);
break;
}
@@ -71,36 +83,6 @@ namespace Systems::Collision {
}
}
void detectWoundedChain(Context& ctx) {
const std::size_t N = ctx.enemies.size();
for (std::size_t i = 0; i < N; i++) {
Enemy& a = ctx.enemies[i];
if (!a.isCollidable()) {
continue;
}
for (std::size_t j = i + 1; j < N; j++) {
Enemy& b = ctx.enemies[j];
if (!b.isCollidable()) {
continue;
}
const bool A_WOUNDED = a.isWounded();
const bool B_WOUNDED = b.isWounded();
if (A_WOUNDED == B_WOUNDED) {
continue; // ambos sanos o ambos heridos: nada que propagar
}
if (!Physics::checkCollision(a, b, Defaults::Game::COLLISION_WOUNDED_CHAIN_AMPLIFIER)) {
continue;
}
// El sano queda herido, propagando el shooter original.
if (A_WOUNDED) {
b.hurt(a.getLastHitBy());
} else {
a.hurt(b.getLastHitBy());
}
}
}
}
void detectShipEnemy(Context& ctx) {
constexpr float AMPLIFIER = Defaults::Game::COLLISION_SHIP_ENEMY_AMPLIFIER;
@@ -138,7 +120,9 @@ namespace Systems::Collision {
const float DEATH_FACTOR = ctx.ships[i].getConfig().physics.death_impact_factor;
const Vec2 IMPULSE = SHIP_VEL * (ctx.ships[i].getBody().mass * DEATH_FACTOR);
touched_enemy->applyImpulse(IMPULSE);
ctx.on_player_hit(i);
// Sense bala: cap impuls de bala per als debris (mort per
// col·lisió cos-cos). Els debris hereten la inèrcia del ship.
ctx.on_player_hit(i, Vec2{});
} else {
// Primer impacte → estat HURT (rebot físic ja resolt per PhysicsWorld;
// l'enemic no rep dany per decisió de disseny).
@@ -185,13 +169,12 @@ namespace Systems::Collision {
}
// *** TEAMMATE HIT (friendly fire) ***
// Víctima perd 1 vida, atacant en guanya 1. Apliquem l'impuls
// de la bala a la nau ABANS de on_player_hit perquè tocado()
// captura la velocitat per als debris (si no, queden quiets).
const Vec2 BULLET_IMPULSE = bullet.getBody().velocity *
(bullet.getBody().mass * bullet.getConfig().physics.impact_momentum_factor);
ctx.ships[player_id].getBody().applyImpulse(BULLET_IMPULSE);
ctx.on_player_hit(player_id);
// Víctima perd 1 vida, atacant en guanya 1. Friendly fire sempre
// mata: el bullet va als debris (via tocado) i NO al cos del ship
// — el cos està a punt de desactivar-se, qualsevol impuls seria
// double-count amb la velocitat que ja reben els trossos.
const Vec2 BULLET_VEL = bullet.getBody().velocity;
ctx.on_player_hit(player_id, BULLET_VEL);
ctx.lives_per_player[BULLET_OWNER]++;
Audio::get()->playSound(Defaults::Sound::FRIENDLY_FIRE_HIT, Audio::Group::GAME);
breakBullet(ctx.debris_manager, bullet);
@@ -200,16 +183,66 @@ namespace Systems::Collision {
}
}
void detectEnemyBulletShip(Context& ctx) {
constexpr float AMPLIFIER = Defaults::Game::COLLISION_BULLET_PLAYER_AMPLIFIER;
constexpr uint8_t ENEMY_BASE = Defaults::Entities::ENEMY_OWNER_BASE;
for (auto& bullet : ctx.bullets) {
if (!bullet.isActive() || bullet.getOwnerId() < ENEMY_BASE) {
continue;
}
for (uint8_t player_id = 0; player_id < 2; player_id++) {
if (ctx.hit_timer_per_player[player_id] > 0.0F ||
!ctx.ships[player_id].isActive() ||
ctx.ships[player_id].isInvulnerable()) {
continue;
}
const bool JUGADOR_ACTIU = (player_id == 0)
? ctx.match_config.player1_active
: ctx.match_config.player2_active;
if (!JUGADOR_ACTIU) {
continue;
}
if (!Physics::checkCollisionSwept(bullet.getPrevPosition(), bullet.getCenter(), bullet.getCollisionRadius(), ctx.ships[player_id], AMPLIFIER)) {
continue;
}
// *** BALA D'ENEMIC → SHIP ***
// Regla "cos XOR trossos": l'impuls de la bala s'aplica al cos
// només si el ship sobreviu (fereix). Si el ship mor, el bullet
// va directament als trossos (via tocado) i el cos no rep impuls
// — els trossos ja porten la força de la bala, qualsevol impuls
// afegit al cos seria double-count.
const Vec2 BULLET_VEL = bullet.getBody().velocity;
if (ctx.ships[player_id].isHurt()) {
// Segon impacte durant HURT → mort.
ctx.on_player_hit(player_id, BULLET_VEL);
} else {
// Fereix: el cos sobreviu, rep l'impuls. No hi ha debris encara.
const Vec2 IMPULSE = BULLET_VEL *
(bullet.getBody().mass * bullet.getConfig().physics.impact_momentum_factor);
ctx.ships[player_id].getBody().applyImpulse(IMPULSE);
ctx.ships[player_id].hurt();
}
breakBullet(ctx.debris_manager, bullet);
break; // una bala impacta una vegada per frame
}
}
}
void detectAll(Context& ctx) {
processWoundedDeaths(ctx); // expiran ANTES de ser tocadas por bala este frame
detectBulletEnemy(ctx);
detectWoundedChain(ctx); // un herit pot ferir a un sa al fregar-lo
// Wounded chain desactivat: era massa fàcil que un enemic ferit topés
// amb el big_pentagon (10 HP) i el matés instantàniament. La regla
// "ferit-toca-sa → ferit" queda permanentment fora.
detectShipEnemy(ctx);
detectBulletPlayer(ctx);
detectEnemyBulletShip(ctx);
}
void desactivateOutOfBoundsBullets(
std::array<Bullet, static_cast<std::size_t>(Defaults::Entities::MAX_BULLETS) * 2>& bullets,
std::array<Bullet, static_cast<std::size_t>(Defaults::Entities::MAX_BULLETS_TOTAL)>& bullets,
Effects::DebrisManager& debris_manager) {
float min_x;
float max_x;
+11 -10
View File
@@ -32,7 +32,7 @@ namespace Systems::Collision {
struct Context {
std::array<Ship, 2>& ships;
std::array<Enemy, Defaults::Entities::MAX_ORNIS>& enemies;
std::array<Bullet, static_cast<std::size_t>(Defaults::Entities::MAX_BULLETS) * 2>& bullets;
std::array<Bullet, static_cast<std::size_t>(Defaults::Entities::MAX_BULLETS_TOTAL)>& bullets;
std::array<float, 2>& hit_timer_per_player;
std::array<int, 2>& score_per_player;
std::array<int, 2>& lives_per_player;
@@ -40,8 +40,11 @@ namespace Systems::Collision {
Effects::FireworkManager& firework_manager;
Effects::FloatingScoreManager& floating_score_manager;
const GameConfig::MatchConfig& match_config;
// Trigger de muerte del jugador (GameScene::tocado).
std::function<void(uint8_t /*player_id*/)> on_player_hit;
// Trigger de muerte del jugador (GameScene::tocado). bullet_velocity es
// la velocitat de la bala que ha causat la mort (Vec2{} si la mort no
// ve d'una bala — col·lisió ship-enemy, etc.). Es passa al debris perquè
// els trossos volin en direcció de la bala.
std::function<void(uint8_t /*player_id*/, const Vec2& /*bullet_velocity*/)> on_player_hit;
};
// Detecta colisiones bullet → enemy. Si hit:
@@ -53,12 +56,6 @@ namespace Systems::Collision {
// al `last_hit_by_` del enemy (si está set).
void processWoundedDeaths(Context& ctx);
// Si un enemy herido colisiona con uno sano (ni herido ni invulnerable),
// el sano también queda herido (efecto cadena). Propaga `last_hit_by_` para
// que el shooter original siga acreditándose la muerte en cascada. El rebote
// físico ya lo resuelve PhysicsWorld; aquí solo propagamos el estado.
void detectWoundedChain(Context& ctx);
// Detecta colisiones ship → enemy. Si hit, llama on_player_hit(player_id).
void detectShipEnemy(Context& ctx);
@@ -67,6 +64,10 @@ namespace Systems::Collision {
// atacante gana 1. En ambos casos, llama on_player_hit y desactiva bullet.
void detectBulletPlayer(Context& ctx);
// Bales d'enemic (owner_id ≥ ENEMY_OWNER_BASE) impactant un ship: aplica
// impulse, primer hit → hurt(), segon hit durant HURT → on_player_hit.
void detectEnemyBulletShip(Context& ctx);
// Las tres en orden lógico del frame.
void detectAll(Context& ctx);
@@ -74,7 +75,7 @@ namespace Systems::Collision {
// (8 fragments de l'octàgon) i el so HIT. Cal cridar-la després de detectAll()
// perquè una bala que el mateix frame xoca i alhora surt es comptabilitzi com a impacte.
void desactivateOutOfBoundsBullets(
std::array<Bullet, static_cast<std::size_t>(Defaults::Entities::MAX_BULLETS) * 2>& bullets,
std::array<Bullet, static_cast<std::size_t>(Defaults::Entities::MAX_BULLETS_TOTAL)>& bullets,
Effects::DebrisManager& debris_manager);
} // namespace Systems::Collision
+275
View File
@@ -0,0 +1,275 @@
// enemy_ai_system.cpp - Implementació del dispatcher de moviment d'enemics
// © 2026 JailDesigner
#include "game/systems/enemy_ai_system.hpp"
#include <cmath>
#include <cstdlib>
#include "core/defaults.hpp"
#include "core/types.hpp"
#include "game/constants.hpp"
#include "game/entities/bullet.hpp"
#include "game/entities/bullet_config.hpp"
#include "game/entities/bullet_registry.hpp"
#include "game/entities/enemy.hpp"
#include "game/entities/enemy_ai.hpp"
#include "game/entities/enemy_config.hpp"
#include "game/entities/ship.hpp"
namespace Systems::EnemyAi {
namespace {
auto randFloat01() -> float {
return static_cast<float>(std::rand()) / static_cast<float>(RAND_MAX);
}
auto velocityToAngle(const Vec2& velocity) -> float {
if (velocity.lengthSquared() < 0.0001F) {
return 0.0F;
}
return std::atan2(velocity.y, velocity.x) + (Constants::PI / 2.0F);
}
// Retorna el centre del ship actiu més proper a l'enemic, o nullptr si
// no n'hi ha cap viu. Els ships destruïts (is_hit_) i els slots nullptr
// (player no participant al match) queden filtrats.
auto findNearestShipPosition(const Enemy& enemy) -> const Vec2* {
const Vec2& self = enemy.getCenter();
const Vec2* best = nullptr;
float best_dist_sq = 0.0F;
for (const Ship* ship : enemy.getShips()) {
if (ship == nullptr || !ship->isActive()) {
continue;
}
const Vec2& pos = ship->getCenter();
const Vec2 DELTA = pos - self;
const float DIST_SQ = DELTA.lengthSquared();
if (best == nullptr || DIST_SQ < best_dist_sq) {
best = &pos;
best_dist_sq = DIST_SQ;
}
}
return best;
}
// ZIGZAG: canvi de direcció probabilístic. Còpia literal del legacy
// Enemy::behaviorPentagon.
void moveZigzag(Enemy& enemy, float delta_time) {
const auto& mv = enemy.getConfig().ai.movement;
EnemyAiState& state = enemy.getAiState();
state.direction_change_timer += delta_time;
if (randFloat01() < mv.zigzag_prob_per_second * delta_time) {
const Vec2 VEL = enemy.getBody().velocity;
const float CURRENT_ANGLE = velocityToAngle(VEL);
const float DELTA = randFloat01() * mv.angle_change_max;
const float NEW_ANGLE = CURRENT_ANGLE + ((std::rand() % 2 == 0) ? DELTA : -DELTA);
const float SPEED = VEL.length();
enemy.setVelocityFromAngle(NEW_ANGLE, SPEED);
state.direction_change_timer = 0.0F;
}
}
// TRACKING: cada N segons, interpola la velocitat actual cap a la
// direcció del ship mantenint la mateixa magnitud. Còpia literal del
// legacy Enemy::behaviorSquare.
void moveTracking(Enemy& enemy, float delta_time) {
const auto& mv = enemy.getConfig().ai.movement;
EnemyAiState& state = enemy.getAiState();
state.tracking_timer += delta_time;
const Vec2* ship_pos = findNearestShipPosition(enemy);
if (state.tracking_timer < mv.tracking_interval || ship_pos == nullptr) {
return;
}
state.tracking_timer = 0.0F;
const Vec2 TO_SHIP = *ship_pos - enemy.getCenter();
const float DIST = TO_SHIP.length();
if (DIST <= 0.0F) {
return;
}
const Vec2 DESIRED_DIR = TO_SHIP / DIST;
const float SPEED = enemy.getBody().velocity.length();
const Vec2 DESIRED_VEL = DESIRED_DIR * SPEED;
const float STRENGTH = state.tracking_strength;
Vec2 new_vel = (enemy.getBody().velocity * (1.0F - STRENGTH)) +
(DESIRED_VEL * STRENGTH);
const float NEW_SPEED = new_vel.length();
if (NEW_SPEED > 0.0F) {
new_vel = new_vel * (SPEED / NEW_SPEED);
}
enemy.getBody().velocity = new_vel;
}
// CHASE / FLEE comparteixen lògica: steering continu cap a (o lluny de)
// la direcció ideal, preservant la magnitud de velocitat. La força és
// strength * dt clampejada a 1 (LERP frame-independent simple).
void steerTowards(Enemy& enemy, const Vec2& desired_dir, float strength, float delta_time) {
const float SPEED = enemy.getBody().velocity.length();
if (SPEED <= 0.0F) {
return;
}
const Vec2 DESIRED_VEL = desired_dir * SPEED;
const float T = std::min(1.0F, strength * delta_time);
Vec2 new_vel = (enemy.getBody().velocity * (1.0F - T)) + (DESIRED_VEL * T);
const float NEW_SPEED = new_vel.length();
if (NEW_SPEED > 0.0F) {
new_vel = new_vel * (SPEED / NEW_SPEED);
}
enemy.getBody().velocity = new_vel;
}
void moveChase(Enemy& enemy, float delta_time) {
const Vec2* ship_pos = findNearestShipPosition(enemy);
if (ship_pos == nullptr) {
return;
}
const Vec2 TO_SHIP = *ship_pos - enemy.getCenter();
const float DIST = TO_SHIP.length();
if (DIST <= 0.0F) {
return;
}
steerTowards(enemy, TO_SHIP / DIST, enemy.getConfig().ai.movement.chase_strength, delta_time);
}
void moveFlee(Enemy& enemy, float delta_time) {
const Vec2* ship_pos = findNearestShipPosition(enemy);
if (ship_pos == nullptr) {
return;
}
const Vec2 AWAY = enemy.getCenter() - *ship_pos;
const float DIST = AWAY.length();
if (DIST <= 0.0F) {
return;
}
steerTowards(enemy, AWAY / DIST, enemy.getConfig().ai.movement.flee_strength, delta_time);
}
// RECTILINEAR_PROXIMITY: rectilini (cap modificació a velocity); boost
// de rotació visual quan distància al ship < proximity_distance. Còpia
// literal del legacy Enemy::behaviorPinwheel.
void moveRectilinearProximity(Enemy& enemy, float /*delta_time*/) {
const auto& mv = enemy.getConfig().ai.movement;
const Vec2* ship_pos = findNearestShipPosition(enemy);
if (ship_pos == nullptr) {
return;
}
const Vec2 TO_SHIP = *ship_pos - enemy.getCenter();
const float DIST = TO_SHIP.length();
const float BASE = enemy.getRotationBase();
if (DIST < mv.proximity_distance) {
enemy.setRotationDelta(BASE * mv.rotation_proximity_multiplier);
} else {
enemy.setRotationDelta(BASE);
}
}
// SHOOT: cerca slot lliure a ctx.bullets i el dispara amb el bullet config
// referenciat per nom (lazy-load via BulletRegistry). Angle segons aim_mode +
// jitter. owner_id = ENEMY_OWNER_BASE + enemy_index per al filtre d'autoimpacte.
void doShoot(Systems::Collision::Context& ctx, const Enemy& enemy, std::size_t enemy_index, const AiTickAction& action) {
if (action.bullet_config_name.empty()) {
return;
}
const BulletConfig* cfg = BulletRegistry::get(action.bullet_config_name);
if (cfg == nullptr) {
return;
}
// Cerca slot dins la zona reservada per a enemics: així no robem
// slots als pools de player (que iteren [0, MAX_BULLETS) i [MAX_BULLETS, 2*MAX_BULLETS)).
Bullet* slot = nullptr;
constexpr std::size_t START = Defaults::Entities::ENEMY_BULLET_START_IDX;
constexpr std::size_t END = START + Defaults::Entities::MAX_ENEMY_BULLETS;
for (std::size_t i = START; i < END; ++i) {
if (!ctx.bullets[i].isActive()) {
slot = &ctx.bullets[i];
break;
}
}
if (slot == nullptr) {
return; // pool d'enemic ple
}
float angle = 0.0F;
if (action.aim_mode == AimMode::AIMED) {
const Vec2* target = findNearestShipPosition(enemy);
if (target == nullptr) {
// Sense ship viu: degrada a random per no congelar el dispar.
angle = randFloat01() * 2.0F * Constants::PI;
} else {
const Vec2 TO = *target - enemy.getCenter();
// angle=0 apunta amunt (eix Y negatiu SDL): atan2 + PI/2.
angle = std::atan2(TO.y, TO.x) + (Constants::PI / 2.0F);
}
} else {
angle = randFloat01() * 2.0F * Constants::PI;
}
if (action.jitter_rad > 0.0F) {
angle += (randFloat01() - 0.5F) * 2.0F * action.jitter_rad;
}
const auto OWNER = static_cast<uint8_t>(Defaults::Entities::ENEMY_OWNER_BASE + enemy_index);
slot->fire(enemy.getCenter(), angle, OWNER, action.bullet_speed, cfg);
}
void runMovement(Enemy& enemy, float delta_time) {
switch (enemy.getConfig().ai.movement.type) {
case MovementType::ZIGZAG:
case MovementType::WANDER:
// WANDER reusa la mecànica de canvi de direcció probabilístic;
// l'única diferència és semàntica i el tunning dels paràmetres.
moveZigzag(enemy, delta_time);
break;
case MovementType::TRACKING:
moveTracking(enemy, delta_time);
break;
case MovementType::RECTILINEAR_PROXIMITY:
moveRectilinearProximity(enemy, delta_time);
break;
case MovementType::CHASE:
moveChase(enemy, delta_time);
break;
case MovementType::FLEE:
moveFlee(enemy, delta_time);
break;
}
}
} // namespace
void move(Enemy& enemy, float delta_time) {
if (!enemy.isActive() || enemy.isWounded()) {
return;
}
runMovement(enemy, delta_time);
}
void tick(Systems::Collision::Context& ctx, Enemy& enemy, std::size_t enemy_index, float delta_time) {
if (!enemy.isActive() || enemy.isWounded()) {
return;
}
runMovement(enemy, delta_time);
// Accions periòdiques: decrementa timer, dispara quan ≤0.
auto& timers = enemy.getAiTickTimers();
const auto& actions = enemy.getConfig().ai.tick;
for (std::size_t i = 0; i < actions.size() && i < timers.size(); ++i) {
timers[i] -= delta_time;
if (timers[i] > 0.0F) {
continue;
}
timers[i] = actions[i].interval;
switch (actions[i].type) {
case AiActionType::SHOOT:
doShoot(ctx, enemy, enemy_index, actions[i]);
break;
}
}
}
} // namespace Systems::EnemyAi
+29
View File
@@ -0,0 +1,29 @@
// enemy_ai_system.hpp - Executa la IA d'un enemic (moviment + accions tick)
// © 2026 JailDesigner
//
// Llegeix `enemy.getConfig().ai` i aplica la primitiva de moviment activa i,
// opcionalment, les accions periòdiques declarades (tick). Reemplaça el switch
// per type hardcoded de l'antic Enemy::update.
#pragma once
#include <cstddef>
#include "game/systems/collision_system.hpp"
class Enemy;
namespace Systems::EnemyAi {
// Aplica només la primitiva de moviment activa de l'enemic (sense disparar
// ni cap altra acció tick). S'usa als steps secundaris (continue/gameover/
// death) on els enemics han de continuar movent-se de fons però no actuar.
void move(Enemy& enemy, float delta_time);
// Aplica moviment + accions periòdiques (SHOOT, etc.). enemy_index és
// l'índex de l'enemic dins ctx.enemies; s'usa per construir el seu
// owner_id de bala (ENEMY_OWNER_BASE + index), de manera que el sistema
// de col·lisions pugui filtrar el self-shot.
void tick(Collision::Context& ctx, Enemy& enemy, std::size_t enemy_index, float delta_time);
} // namespace Systems::EnemyAi
+70 -11
View File
@@ -24,10 +24,14 @@ namespace Systems::EnemyEvents {
ctx.floating_score_manager.crear(POINTS, enemy.getCenter());
}
void doCreateDebris(Systems::Collision::Context& ctx, const Enemy& enemy) {
// Helper compartit per CREATE_DEBRIS i CREATE_DEBRIS_PARTIAL: única
// crida a explode(), paràmetres alineats; només canvien piece_scale
// (1.0 explosió, 0.3 xip) i el color (cos vs hit-feedback).
void spawnDebrisForEnemy(Systems::Collision::Context& ctx, const Enemy& enemy, const Bullet* bullet, float piece_scale, SDL_Color color) {
constexpr float SPEED_EXPLOSIO = 80.0F;
const Vec2 INHERITED_VEL = enemy.getVelocityVector() *
Defaults::Physics::Debris::ENEMY_VELOCITY_INHERITANCE;
const Vec2 BULLET_VEL = (bullet != nullptr) ? bullet->getBody().velocity : Vec2{};
ctx.debris_manager.explode(
enemy.getShape(),
enemy.getCenter(),
@@ -39,20 +43,25 @@ namespace Systems::EnemyEvents {
0.0F,
0.0F,
Defaults::Sound::EXPLOSION,
enemy.getConfig().colors.normal,
color,
Defaults::Physics::Debris::ENEMY_LIFETIME,
Defaults::Physics::Debris::ENEMY_FRICTION,
Defaults::Physics::Debris::ENEMY_SEGMENT_MULTIPLIER);
Defaults::Physics::Debris::ENEMY_SEGMENT_MULTIPLIER,
BULLET_VEL,
piece_scale);
}
void doCreateFireworks(Systems::Collision::Context& ctx, const Enemy& enemy) {
// Helper compartit per CREATE_FIREWORKS i CREATE_FIREWORKS_SMALL:
// mateixa crida a spawn(); els callers decideixen line_color, glow_color,
// n_points i initial_speed segons el "tamany" del burst (mort vs hit).
void spawnFireworksForEnemy(Systems::Collision::Context& ctx, const Enemy& enemy, int n_points, float initial_speed, SDL_Color line_color, SDL_Color glow_color) {
ctx.firework_manager.spawn(enemy.getCenter(),
Defaults::FX::Firework::DEFAULT_COLOR,
Defaults::FX::Firework::SPEED,
Defaults::FX::Firework::N_POINTS,
line_color,
initial_speed,
n_points,
Defaults::FX::Firework::INITIAL_BRIGHTNESS,
/*glow=*/true,
enemy.getConfig().colors.wounded);
glow_color);
}
void doApplyImpulse(Enemy& enemy, const Bullet* bullet) {
@@ -67,7 +76,32 @@ namespace Systems::EnemyEvents {
void dispatchEvent(Systems::Collision::Context& ctx, Enemy& enemy, EnemyEventType event, uint8_t shooter_id, const Bullet* bullet) {
const auto& actions = enemy.getConfig().events.getActions(event);
// Pre-scan: aquest event matarà l'enemic? Si sí, l'impuls de la bala
// va directament als debris (via doCreateDebris) i NO s'aplica al cos
// — així evitem el "double-count" on els trossos hereten la velocitat
// del cos (boostat per la bala) I a més el seu propi impuls de bala.
// Regla: el bullet impacta al cos O als trossos, mai a tots dos.
bool will_die = false;
for (const auto& action : actions) {
if (action.type == EnemyActionType::DESTROY) {
will_die = true;
break;
}
if (action.type == EnemyActionType::SET_HURT && enemy.isWounded()) {
will_die = true;
break;
}
}
for (const auto& action : actions) {
// Si una acció prèvia d'aquest chain ha destruït l'enemic
// (típicament DECREASE_HEALTH→ON_NO_HEALTH→SET_HURT-wounded→DESTROY),
// saltem la resta — no té sentit aplicar APPLY_IMPULSE o FLASH a un
// cos ja inactiu.
if (!enemy.isActive()) {
break;
}
switch (action.type) {
case EnemyActionType::SET_HURT:
if (enemy.isWounded()) {
@@ -86,13 +120,38 @@ namespace Systems::EnemyEvents {
doAddScore(ctx, enemy, shooter_id);
break;
case EnemyActionType::CREATE_DEBRIS:
doCreateDebris(ctx, enemy);
// Explosió de mort: trossos en color cos (correcte físicament).
spawnDebrisForEnemy(ctx, enemy, bullet, 1.0F, enemy.getConfig().colors.normal);
break;
case EnemyActionType::CREATE_DEBRIS_PARTIAL:
// Xip d'impacte: trossos en color wounded (daurat) per
// diferenciar-los visualment del cos i marcar "damage".
spawnDebrisForEnemy(ctx, enemy, bullet, Defaults::Enemies::Debris::PARTIAL_PIECE_SCALE, enemy.getConfig().colors.wounded);
break;
case EnemyActionType::CREATE_FIREWORKS:
doCreateFireworks(ctx, enemy);
// Burst de mort: línia blanca + glow wounded (daurat) per
// marcar la mort com a esdeveniment "calent" i lluminós.
spawnFireworksForEnemy(ctx, enemy, Defaults::FX::Firework::N_POINTS, Defaults::FX::Firework::SPEED, Defaults::FX::Firework::DEFAULT_COLOR, enemy.getConfig().colors.wounded);
break;
case EnemyActionType::CREATE_FIREWORKS_SMALL:
// Espurna d'impacte: línia + glow tots dos en wounded
// (daurat) per contrastar amb el cos i unificar la "tema
// de damage" amb el debris parcial.
spawnFireworksForEnemy(ctx, enemy, Defaults::Enemies::Fireworks::SMALL_N_POINTS, Defaults::Enemies::Fireworks::SMALL_SPEED, enemy.getConfig().colors.wounded, enemy.getConfig().colors.wounded);
break;
case EnemyActionType::APPLY_IMPULSE:
doApplyImpulse(enemy, bullet);
if (!will_die) {
doApplyImpulse(enemy, bullet);
}
break;
case EnemyActionType::DECREASE_HEALTH:
enemy.decrementHealth(shooter_id);
if (enemy.getHealth() <= 0) {
dispatchEvent(ctx, enemy, EnemyEventType::ON_NO_HEALTH, shooter_id, bullet);
}
break;
case EnemyActionType::FLASH:
enemy.triggerFlash();
break;
}
}