Merge branch 'feat/enemy-health-system': sistema d'HP declaratiu, big_pentagon i ajustos visuals

This commit is contained in:
2026-05-25 22:47:54 +02:00
23 changed files with 355 additions and 93 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
+6 -2
View File
@@ -55,10 +55,14 @@ colors:
score: 100 score: 100
events: events:
# Comportament clàssic: dos impactes per matar (set_hurt entra wounded; # HP=1 (default): decrement → on_no_health → set_hurt wounded → mort.
# el segon hit detecta wounded i destrueix automàticament). # 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: on_hit:
- action: decrease_health
- action: apply_impulse - action: apply_impulse
on_no_health:
- action: set_hurt - action: set_hurt
on_hurt_end: on_hurt_end:
- action: destroy - action: destroy
+3
View File
@@ -55,8 +55,11 @@ colors:
score: 200 score: 200
events: events:
# HP=1 (default): decrement → on_no_health → set_hurt → wounded → mort.
on_hit: on_hit:
- action: decrease_health
- action: apply_impulse - action: apply_impulse
on_no_health:
- action: set_hurt - action: set_hurt
on_hurt_end: on_hurt_end:
- action: destroy - action: destroy
+3
View File
@@ -56,8 +56,11 @@ colors:
score: 150 score: 150
events: events:
# HP=1 (default): decrement → on_no_health → set_hurt → wounded → mort.
on_hit: on_hit:
- action: decrease_health
- action: apply_impulse - action: apply_impulse
on_no_health:
- action: set_hurt - action: set_hurt
on_hurt_end: on_hurt_end:
- action: destroy - action: destroy
+3 -1
View File
@@ -66,8 +66,10 @@ score: 100
events: events:
# STAR: mor al primer impacte, sense passar per wounded. # STAR: mor al primer impacte, sense passar per wounded.
# HP=1 (default): decrement → on_no_health → destroy directe (sense wounded).
on_hit: on_hit:
- action: apply_impulse - action: decrease_health
on_no_health:
- action: destroy - action: destroy
on_destroy: on_destroy:
- action: add_score - action: add_score
+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" description: "Progressive difficulty curve from novice to expert"
stages: 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 - stage_id: 1
total_enemies: 50 total_enemies: 50
spawn_config: spawn_config:
@@ -15,10 +15,10 @@ stages:
initial_delay: 0.3 initial_delay: 0.3
spawn_interval: 0.4 spawn_interval: 0.4
enemy_distribution: enemy_distribution:
pentagon: 25 pentagon: 30
cuadrado: 25 cuadrado: 25
molinillo: 25 molinillo: 25
star: 25 big_pentagon: 20
difficulty_multipliers: difficulty_multipliers:
speed_multiplier: 0.7 speed_multiplier: 0.7
rotation_multiplier: 0.8 rotation_multiplier: 0.8
+27
View File
@@ -16,3 +16,30 @@ namespace Defaults::Enemies::Spawn {
constexpr int MAX_SPAWN_ATTEMPTS = 50; constexpr int MAX_SPAWN_ATTEMPTS = 50;
} // namespace Defaults::Enemies::Spawn } // 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
+21 -10
View File
@@ -58,7 +58,8 @@ namespace Effects {
float lifetime, float lifetime,
float friction, float friction,
int segment_multiplier, int segment_multiplier,
const Vec2& bullet_impulse_velocity) { const Vec2& bullet_impulse_velocity,
float piece_scale) {
if (!shape || !shape->isValid()) { if (!shape || !shape->isValid()) {
return; return;
} }
@@ -85,7 +86,7 @@ namespace Effects {
Vec2 world_p2 = transformPoint(local_p2, shape_centre, centro, angle, scale); 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 // 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, bullet_impulse_velocity)) { 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; return;
} }
} }
@@ -111,26 +112,36 @@ namespace Effects {
return segments; 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, const Vec2& bullet_impulse_velocity) -> 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(); Debris* debris = findFreeSlot();
if (debris == nullptr) { if (debris == nullptr) {
std::cerr << "[DebrisManager] Warning: no debris slots disponibles\n"; std::cerr << "[DebrisManager] Warning: no debris slots disponibles\n";
return false; 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. // Geometria autoritaritzada: centro + original_angle + original_half_length.
// p1/p2 es reconstrueixen cada frame en update() des d'aquestes dades. // p1/p2 es reconstrueixen cada frame en update() des d'aquestes dades.
const float DX = world_p2.x - world_p1.x; const float DX = WORLD_P2.x - WORLD_P1.x;
const float DY = world_p2.y - world_p1.y; const float DY = WORLD_P2.y - WORLD_P1.y;
debris->centro = {.x = (world_p1.x + world_p2.x) / 2.0F, debris->centro = {.x = (WORLD_P1.x + WORLD_P2.x) / 2.0F,
.y = (world_p1.y + world_p2.y) / 2.0F}; .y = (WORLD_P1.y + WORLD_P2.y) / 2.0F};
debris->original_angle = std::atan2(DY, DX); debris->original_angle = std::atan2(DY, DX);
debris->original_half_length = std::sqrt((DX * DX) + (DY * DY)) / 2.0F; debris->original_half_length = std::sqrt((DX * DX) + (DY * DY)) / 2.0F;
debris->p1 = world_p1; debris->p1 = WORLD_P1;
debris->p2 = world_p2; debris->p2 = WORLD_P2;
// Direcció radial (desde el centro hacia el segment) // 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). // velocitat de la bala escalada per BULLET_IMPULSE_FACTOR).
+6 -2
View File
@@ -52,6 +52,9 @@ namespace Effects {
// Defaults::Physics::Debris::BULLET_IMPULSE_FACTOR, independent de // Defaults::Physics::Debris::BULLET_IMPULSE_FACTOR, independent de
// velocitat_objecte. Permet que els trossos "salten amb la força de la bala" // 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. // 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, void explode(const std::shared_ptr<Graphics::Shape>& shape,
const Vec2& centro, const Vec2& centro,
float angle, float angle,
@@ -66,7 +69,8 @@ namespace Effects {
float lifetime = Defaults::Physics::Debris::TEMPS_VIDA, float lifetime = Defaults::Physics::Debris::TEMPS_VIDA,
float friction = Defaults::Physics::Debris::ACCELERACIO, 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}); const Vec2& bullet_impulse_velocity = {.x = 0.0F, .y = 0.0F},
float piece_scale = 1.0F);
// Actualitzar todos los fragments active // Actualitzar todos los fragments active
void update(float delta_time); void update(float delta_time);
@@ -103,7 +107,7 @@ namespace Effects {
-> std::vector<std::pair<Vec2, Vec2>>; -> std::vector<std::pair<Vec2, Vec2>>;
// Inicialitza un debris en un slot lliure i el deixa actiu. Retorna // 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). // 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, const Vec2& bullet_impulse_velocity) -> 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 applyAngularVelocity(Debris& debris, const Vec2& direccio, float velocitat_angular);
static void applyVisualRotation(Debris& debris, float velocitat_angular, float factor_herencia_visual); static void applyVisualRotation(Debris& debris, float velocitat_angular, float factor_herencia_visual);
}; };
+18 -1
View File
@@ -133,6 +133,9 @@ void Enemy::init(EnemyType type, const Vec2* ship_pos) {
invulnerability_timer_ = cfg.spawn.invulnerability_duration; invulnerability_timer_ = cfg.spawn.invulnerability_duration;
brightness_ = cfg.spawn.invulnerability_brightness_start; brightness_ = cfg.spawn.invulnerability_brightness_start;
health_ = cfg.health;
flash_timer_ = 0.0F;
is_active_ = true; is_active_ = true;
} }
@@ -150,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) { if (invulnerability_timer_ > 0.0F) {
invulnerability_timer_ -= delta_time; invulnerability_timer_ -= delta_time;
invulnerability_timer_ = std::max(invulnerability_timer_, 0.0F); invulnerability_timer_ = std::max(invulnerability_timer_, 0.0F);
@@ -192,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() { void Enemy::destroy() {
@@ -203,6 +219,7 @@ void Enemy::destroy() {
wounded_timer_ = 0.0F; wounded_timer_ = 0.0F;
wound_expired_this_frame_ = false; wound_expired_this_frame_ = false;
last_hit_by_ = 0xFF; last_hit_by_ = 0xFF;
flash_timer_ = 0.0F;
} }
void Enemy::hurt(uint8_t shooter_id) { void Enemy::hurt(uint8_t shooter_id) {
+30 -4
View File
@@ -8,6 +8,7 @@
#include <cstdint> #include <cstdint>
#include <vector> #include <vector>
#include "core/defaults/enemies.hpp"
#include "core/entities/entity.hpp" #include "core/entities/entity.hpp"
#include "core/types.hpp" #include "core/types.hpp"
#include "game/entities/enemy_ai.hpp" #include "game/entities/enemy_ai.hpp"
@@ -16,10 +17,11 @@ class Ship;
// Tipo de enemy // Tipo de enemy
enum class EnemyType : uint8_t { enum class EnemyType : uint8_t {
PENTAGON = 0, // Pentágono esquivador (zigzag) PENTAGON = 0, // Pentágono esquivador (zigzag)
SQUARE = 1, // Square perseguidor (tracks ship) SQUARE = 1, // Square perseguidor (tracks ship)
PINWHEEL = 2, // Molinillo agresivo (rápido, girando) PINWHEEL = 2, // Molinillo agresivo (rápido, girando)
STAR = 3 // Estrella de 5 puntes (clone visual de Pentagon, comportament zigzag) 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. // Forward declaration — EnemyConfig viu a enemy_config.hpp i s'inclou només a enemy.cpp.
@@ -116,6 +118,20 @@ class Enemy : public Entities::Entity {
void consumeWoundExpired() { wound_expired_this_frame_ = false; } void consumeWoundExpired() { wound_expired_this_frame_ = false; }
[[nodiscard]] auto getLastHitBy() const -> uint8_t { return last_hit_by_; } [[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. // Aplica un impulso (cambio inmediato de velocidad mass-aware) al cuerpo físico.
void applyImpulse(const Vec2& impulse); void applyImpulse(const Vec2& impulse);
@@ -154,6 +170,16 @@ class Enemy : public Entities::Entity {
bool wound_expired_this_frame_{false}; bool wound_expired_this_frame_{false};
uint8_t last_hit_by_{0xFF}; 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 // Métodos privados
void updateAnimation(float delta_time); void updateAnimation(float delta_time);
void updatePulse(float delta_time); void updatePulse(float delta_time);
+25
View File
@@ -30,6 +30,7 @@ namespace {
if (s == "square") { return EnemyType::SQUARE; } if (s == "square") { return EnemyType::SQUARE; }
if (s == "pinwheel") { return EnemyType::PINWHEEL; } if (s == "pinwheel") { return EnemyType::PINWHEEL; }
if (s == "star") { return EnemyType::STAR; } if (s == "star") { return EnemyType::STAR; }
if (s == "big_pentagon") { return EnemyType::BIG_PENTAGON; }
return std::nullopt; return std::nullopt;
} }
@@ -177,13 +178,25 @@ namespace {
return true; 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> { auto actionTypeFromString(const std::string& s) -> std::optional<EnemyActionType> {
if (s == "set_hurt") { return EnemyActionType::SET_HURT; } if (s == "set_hurt") { return EnemyActionType::SET_HURT; }
if (s == "destroy") { return EnemyActionType::DESTROY; } if (s == "destroy") { return EnemyActionType::DESTROY; }
if (s == "add_score") { return EnemyActionType::ADD_SCORE; } if (s == "add_score") { return EnemyActionType::ADD_SCORE; }
if (s == "create_debris") { return EnemyActionType::CREATE_DEBRIS; } 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") { return EnemyActionType::CREATE_FIREWORKS; }
if (s == "create_fireworks_small") { return EnemyActionType::CREATE_FIREWORKS_SMALL; }
if (s == "apply_impulse") { return EnemyActionType::APPLY_IMPULSE; } 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; return std::nullopt;
} }
@@ -340,6 +353,13 @@ namespace {
out.movement.rotation_proximity_multiplier = legacy.rotation_proximity_multiplier; out.movement.rotation_proximity_multiplier = legacy.rotation_proximity_multiplier;
out.movement.proximity_distance = legacy.proximity_distance; out.movement.proximity_distance = legacy.proximity_distance;
break; 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;
} }
} }
@@ -371,6 +391,10 @@ namespace {
if (e.contains("on_hit") && !parseActionList(e["on_hit"], name, "on_hit", out.on_hit)) { if (e.contains("on_hit") && !parseActionList(e["on_hit"], name, "on_hit", out.on_hit)) {
return false; 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") && if (e.contains("on_hurt_end") &&
!parseActionList(e["on_hurt_end"], name, "on_hurt_end", out.on_hurt_end)) { !parseActionList(e["on_hurt_end"], name, "on_hurt_end", out.on_hurt_end)) {
return false; return false;
@@ -407,6 +431,7 @@ auto EnemyConfig::fromYaml(const fkyaml::node& node, EnemyType expected_ai_type)
if (!parseSpawn(node, cfg.name, cfg.spawn)) { return std::nullopt; } if (!parseSpawn(node, cfg.name, cfg.spawn)) { return std::nullopt; }
if (!parseColors(node, cfg.name, cfg.colors)) { return std::nullopt; } if (!parseColors(node, cfg.name, cfg.colors)) { return std::nullopt; }
if (!parseScore(node, cfg.name, cfg.score)) { 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 (!parseEvents(node, cfg.name, cfg.events)) { return std::nullopt; }
if (!parseAi(node, cfg.name, cfg.ai_type, cfg.behavior, cfg.ai)) { return std::nullopt; } if (!parseAi(node, cfg.name, cfg.ai_type, cfg.behavior, cfg.ai)) { return std::nullopt; }
+4
View File
@@ -100,6 +100,10 @@ struct EnemyConfig {
SpawnCfg spawn; SpawnCfg spawn;
ColorsCfg colors; ColorsCfg colors;
int score; 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; EnemyEventConfig events;
EnemyAiConfig ai; EnemyAiConfig ai;
+17 -9
View File
@@ -11,18 +11,23 @@
#include <vector> #include <vector>
enum class EnemyEventType : uint8_t { enum class EnemyEventType : uint8_t {
ON_HIT, // Impactat per una bala ON_HIT, // Impactat per una bala
ON_HURT_END, // Timer wounded ha expirat aquest frame ON_NO_HEALTH, // health ha arribat a 0 o menys aquest frame (via DECREASE_HEALTH)
ON_DESTROY, // L'acció destroy s'està executant (efectes col·laterals) 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 { enum class EnemyActionType : uint8_t {
SET_HURT, // Entra estat wounded (o destrueix si ja era wounded) SET_HURT, // Entra estat wounded (o destrueix si ja era wounded)
DESTROY, // Dispara on_destroy + desactiva físicament DESTROY, // Dispara on_destroy + desactiva físicament
ADD_SCORE, // Suma config.score al shooter + floating score ADD_SCORE, // Suma config.score al shooter + floating score
CREATE_DEBRIS, // Explosió de debris amb herència de velocitat CREATE_DEBRIS, // Explosió de debris amb herència de velocitat
CREATE_FIREWORKS, // Burst radial de firework CREATE_DEBRIS_PARTIAL, // Debris de xip parcial (trossos a escala 0.3, per hits HP>1)
APPLY_IMPULSE, // Aplica l'impuls de la bala impactant 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 { struct EnemyAction {
@@ -31,6 +36,7 @@ struct EnemyAction {
struct EnemyEventConfig { struct EnemyEventConfig {
std::vector<EnemyAction> on_hit; std::vector<EnemyAction> on_hit;
std::vector<EnemyAction> on_no_health;
std::vector<EnemyAction> on_hurt_end; std::vector<EnemyAction> on_hurt_end;
std::vector<EnemyAction> on_destroy; std::vector<EnemyAction> on_destroy;
@@ -38,6 +44,8 @@ struct EnemyEventConfig {
switch (event) { switch (event) {
case EnemyEventType::ON_HIT: case EnemyEventType::ON_HIT:
return on_hit; return on_hit;
case EnemyEventType::ON_NO_HEALTH:
return on_no_health;
case EnemyEventType::ON_HURT_END: case EnemyEventType::ON_HURT_END:
return on_hurt_end; return on_hurt_end;
case EnemyEventType::ON_DESTROY: case EnemyEventType::ON_DESTROY:
+6 -2
View File
@@ -13,6 +13,7 @@ EnemyConfig EnemyRegistry::pentagon_config;
EnemyConfig EnemyRegistry::square_config; EnemyConfig EnemyRegistry::square_config;
EnemyConfig EnemyRegistry::pinwheel_config; EnemyConfig EnemyRegistry::pinwheel_config;
EnemyConfig EnemyRegistry::star_config; EnemyConfig EnemyRegistry::star_config;
EnemyConfig EnemyRegistry::big_pentagon_config;
bool EnemyRegistry::loaded = false; bool EnemyRegistry::loaded = false;
namespace { namespace {
@@ -38,10 +39,11 @@ auto EnemyRegistry::loadAll() -> bool {
const bool OK = loadOne("pentagon", EnemyType::PENTAGON, pentagon_config) && const bool OK = loadOne("pentagon", EnemyType::PENTAGON, pentagon_config) &&
loadOne("square", EnemyType::SQUARE, square_config) && loadOne("square", EnemyType::SQUARE, square_config) &&
loadOne("pinwheel", EnemyType::PINWHEEL, pinwheel_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; loaded = OK;
if (OK) { if (OK) {
std::cout << "[EnemyRegistry] 4 configuracions d'enemic carregades.\n"; std::cout << "[EnemyRegistry] 5 configuracions d'enemic carregades.\n";
} }
return OK; return OK;
} }
@@ -60,6 +62,8 @@ auto EnemyRegistry::get(EnemyType type) -> const EnemyConfig& {
return pinwheel_config; return pinwheel_config;
case EnemyType::STAR: case EnemyType::STAR:
return star_config; return star_config;
case EnemyType::BIG_PENTAGON:
return big_pentagon_config;
} }
std::cerr << "[EnemyRegistry] FATAL: tipus desconegut\n"; std::cerr << "[EnemyRegistry] FATAL: tipus desconegut\n";
std::exit(EXIT_FAILURE); std::exit(EXIT_FAILURE);
+1
View File
@@ -27,5 +27,6 @@ class EnemyRegistry {
static EnemyConfig square_config; static EnemyConfig square_config;
static EnemyConfig pinwheel_config; static EnemyConfig pinwheel_config;
static EnemyConfig star_config; static EnemyConfig star_config;
static EnemyConfig big_pentagon_config;
static bool loaded; static bool loaded;
}; };
@@ -129,17 +129,21 @@ namespace StageSystem {
// Weighted random selection based on distribution // Weighted random selection based on distribution
int rand_val = std::rand() % 100; 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; return EnemyType::PENTAGON;
} }
if (rand_val < config_->distribucio.pentagon + config_->distribucio.cuadrado) { if (rand_val < d.pentagon + d.cuadrado) {
return EnemyType::SQUARE; 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::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) { 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) // Distribució de type de enemigos (percentatges)
struct DistribucioEnemics { struct DistribucioEnemics {
uint8_t pentagon; // 0-100 uint8_t pentagon; // 0-100
uint8_t cuadrado; // 0-100 uint8_t cuadrado; // 0-100
uint8_t molinillo; // 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 star{0}; // 0-100 (opcional al YAML; default 0 per compat amb stages antics)
// Suma ha de ser 100, validat en StageLoader uint8_t big_pentagon{0}; // 0-100 (opcional; enemic gegant HP=10)
// Suma ha de ser 100, validat en StageLoader
}; };
// Multiplicadors de dificultat // Multiplicadors de dificultat
@@ -60,7 +61,7 @@ namespace StageSystem {
// el tipo; basta con confirmar que no es 0 (sentinela "sin asignar"). // el tipo; basta con confirmar que no es 0 (sentinela "sin asignar").
return stage_id >= 1 && return stage_id >= 1 &&
total_enemies > 0 && total_enemies <= 200 && 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.pentagon = yaml["pentagon"].get_value<uint8_t>();
dist.cuadrado = yaml["cuadrado"].get_value<uint8_t>(); dist.cuadrado = yaml["cuadrado"].get_value<uint8_t>();
dist.molinillo = yaml["molinillo"].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.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 // 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) { if (sum != 100) {
std::cerr << "[StageLoader] Error: distribució no suma 100 (suma=" << sum << ")" << '\n'; std::cerr << "[StageLoader] Error: distribució no suma 100 (suma=" << sum << ")" << '\n';
return false; return false;
+3 -31
View File
@@ -83,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) { void detectShipEnemy(Context& ctx) {
constexpr float AMPLIFIER = Defaults::Game::COLLISION_SHIP_ENEMY_AMPLIFIER; constexpr float AMPLIFIER = Defaults::Game::COLLISION_SHIP_ENEMY_AMPLIFIER;
@@ -263,7 +233,9 @@ namespace Systems::Collision {
void detectAll(Context& ctx) { void detectAll(Context& ctx) {
processWoundedDeaths(ctx); // expiran ANTES de ser tocadas por bala este frame processWoundedDeaths(ctx); // expiran ANTES de ser tocadas por bala este frame
detectBulletEnemy(ctx); 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); detectShipEnemy(ctx);
detectBulletPlayer(ctx); detectBulletPlayer(ctx);
detectEnemyBulletShip(ctx); detectEnemyBulletShip(ctx);
-6
View File
@@ -56,12 +56,6 @@ namespace Systems::Collision {
// al `last_hit_by_` del enemy (si está set). // al `last_hit_by_` del enemy (si está set).
void processWoundedDeaths(Context& ctx); 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). // Detecta colisiones ship → enemy. Si hit, llama on_player_hit(player_id).
void detectShipEnemy(Context& ctx); void detectShipEnemy(Context& ctx);
+47 -10
View File
@@ -24,7 +24,10 @@ namespace Systems::EnemyEvents {
ctx.floating_score_manager.crear(POINTS, enemy.getCenter()); ctx.floating_score_manager.crear(POINTS, enemy.getCenter());
} }
void doCreateDebris(Systems::Collision::Context& ctx, const Enemy& enemy, const Bullet* bullet) { // 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; constexpr float SPEED_EXPLOSIO = 80.0F;
const Vec2 INHERITED_VEL = enemy.getVelocityVector() * const Vec2 INHERITED_VEL = enemy.getVelocityVector() *
Defaults::Physics::Debris::ENEMY_VELOCITY_INHERITANCE; Defaults::Physics::Debris::ENEMY_VELOCITY_INHERITANCE;
@@ -40,21 +43,25 @@ namespace Systems::EnemyEvents {
0.0F, 0.0F,
0.0F, 0.0F,
Defaults::Sound::EXPLOSION, Defaults::Sound::EXPLOSION,
enemy.getConfig().colors.normal, color,
Defaults::Physics::Debris::ENEMY_LIFETIME, Defaults::Physics::Debris::ENEMY_LIFETIME,
Defaults::Physics::Debris::ENEMY_FRICTION, Defaults::Physics::Debris::ENEMY_FRICTION,
Defaults::Physics::Debris::ENEMY_SEGMENT_MULTIPLIER, Defaults::Physics::Debris::ENEMY_SEGMENT_MULTIPLIER,
BULLET_VEL); 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(), ctx.firework_manager.spawn(enemy.getCenter(),
Defaults::FX::Firework::DEFAULT_COLOR, line_color,
Defaults::FX::Firework::SPEED, initial_speed,
Defaults::FX::Firework::N_POINTS, n_points,
Defaults::FX::Firework::INITIAL_BRIGHTNESS, Defaults::FX::Firework::INITIAL_BRIGHTNESS,
/*glow=*/true, /*glow=*/true,
enemy.getConfig().colors.wounded); glow_color);
} }
void doApplyImpulse(Enemy& enemy, const Bullet* bullet) { void doApplyImpulse(Enemy& enemy, const Bullet* bullet) {
@@ -88,6 +95,13 @@ namespace Systems::EnemyEvents {
} }
for (const auto& action : actions) { 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) { switch (action.type) {
case EnemyActionType::SET_HURT: case EnemyActionType::SET_HURT:
if (enemy.isWounded()) { if (enemy.isWounded()) {
@@ -106,16 +120,39 @@ namespace Systems::EnemyEvents {
doAddScore(ctx, enemy, shooter_id); doAddScore(ctx, enemy, shooter_id);
break; break;
case EnemyActionType::CREATE_DEBRIS: case EnemyActionType::CREATE_DEBRIS:
doCreateDebris(ctx, enemy, bullet); // 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; break;
case EnemyActionType::CREATE_FIREWORKS: 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; break;
case EnemyActionType::APPLY_IMPULSE: case EnemyActionType::APPLY_IMPULSE:
if (!will_die) { if (!will_die) {
doApplyImpulse(enemy, bullet); doApplyImpulse(enemy, bullet);
} }
break; 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;
} }
} }
} }