Merge branch 'feat/border-bumps': border amb reaccions a impactes i explosions

This commit is contained in:
2026-05-21 22:49:42 +02:00
12 changed files with 341 additions and 76 deletions
+1
View File
@@ -10,6 +10,7 @@
// IWYU pragma: begin_exports
#include "core/defaults/audio.hpp"
#include "core/defaults/border.hpp"
#include "core/defaults/brightness.hpp"
#include "core/defaults/controls.hpp"
#include "core/defaults/effects.hpp"
+29
View File
@@ -0,0 +1,29 @@
// border.hpp - Configuració del border del playfield (estàtic + reaccions)
// © 2026 JailDesigner
#pragma once
namespace Defaults::Border {
// Desplaçament del border per impactes
constexpr float MAX_DISPLACEMENT_PX = 6.0F; // tope màxim de separació respecte la posició natural
constexpr float DISPLACEMENT_RECOVERY_PER_S = 30.0F; // px/s tornant cap a 0 (ease lineal)
// Flash al impacte. Intensitat proporcional al desplaçament:
// max displacement → color = FLASH_COLOR pur
// 0 displacement → color = oscil·lador (base verd)
// La línia es dibuixa amb el color resultant del lerp; no hi ha sobreposició.
constexpr bool FLASH_ENABLED = true;
constexpr unsigned char FLASH_COLOR_R = 180;
constexpr unsigned char FLASH_COLOR_G = 255;
constexpr unsigned char FLASH_COLOR_B = 180;
// Conversió velocitat d'impacte → strength del bump
constexpr float BUMP_VELOCITY_REFERENCE = 120.0F; // px/s donen strength 1.0
constexpr float BUMP_MIN_VELOCITY = 20.0F; // sota d'açò no genera bump (filtrar fregaments)
// Bump generat per explosions properes a la paret.
constexpr float EXPLOSION_FALLOFF_PX = 80.0F; // més enllà d'aquesta distància, sense bump
constexpr float EXPLOSION_BASE_STRENGTH = 0.7F; // strength màxim (a 0 px de la paret)
} // namespace Defaults::Border
+107
View File
@@ -0,0 +1,107 @@
// border.cpp - Implementació del border del playfield
// © 2026 JailDesigner
#include "core/graphics/border.hpp"
#include <algorithm>
#include <array>
#include "core/defaults.hpp"
#include "core/rendering/line_renderer.hpp"
namespace Graphics {
Border::Border(Rendering::Renderer* renderer)
: renderer_(renderer) {}
void Border::update(float delta_time) {
for (auto& side : sides_) {
// Desplaçament decau cap a 0 amb ritme constant (lineal).
const float DEC = Defaults::Border::DISPLACEMENT_RECOVERY_PER_S * delta_time;
side.displacement_px = std::max(0.0F, side.displacement_px - DEC);
}
}
void Border::bumpAt(Vec2 contact_point, float strength) {
const SDL_FRect& zona = Defaults::Zones::PLAYAREA;
const std::array<float, SIDE_COUNT> DISTANCES = {
/* TOP */ std::abs(contact_point.y - zona.y),
/* RIGHT */ std::abs((zona.x + zona.w) - contact_point.x),
/* BOTTOM */ std::abs((zona.y + zona.h) - contact_point.y),
/* LEFT */ std::abs(contact_point.x - zona.x)};
int closest_idx = 0;
float closest_dist = DISTANCES[0];
for (int i = 1; i < SIDE_COUNT; i++) {
if (DISTANCES[i] < closest_dist) {
closest_dist = DISTANCES[i];
closest_idx = i;
}
}
applyBump(closest_idx, strength);
}
void Border::applyBump(int side_idx, float strength) {
const float S = std::clamp(strength, 0.0F, 1.0F);
SideState& side = sides_[static_cast<std::size_t>(side_idx)];
side.displacement_px = std::min(
Defaults::Border::MAX_DISPLACEMENT_PX,
side.displacement_px + (S * Defaults::Border::MAX_DISPLACEMENT_PX));
}
namespace {
// Lerp de l'oscil·lador (color base actual) cap a un color "flash" en
// funció de f ∈ [0, 1]. Retorna sempre amb alpha>0 perquè el line_renderer
// l'use directament (sense barrejar amb el global).
auto lerpColor(SDL_Color flash, float f) -> SDL_Color {
const float CLAMPED = std::clamp(f, 0.0F, 1.0F);
const SDL_Color BASE = Rendering::getLineColor();
const auto LERP_U8 = [&](unsigned char a, unsigned char b) {
const float OUT = (static_cast<float>(a) * (1.0F - CLAMPED)) + (static_cast<float>(b) * CLAMPED);
return static_cast<unsigned char>(OUT);
};
return SDL_Color{
.r = LERP_U8(BASE.r, flash.r),
.g = LERP_U8(BASE.g, flash.g),
.b = LERP_U8(BASE.b, flash.b),
.a = 255};
}
} // namespace
void Border::draw() const {
const SDL_FRect& zona = Defaults::Zones::PLAYAREA;
const int X1 = static_cast<int>(zona.x);
const int Y1 = static_cast<int>(zona.y);
const int X2 = static_cast<int>(zona.x + zona.w);
const int Y2 = static_cast<int>(zona.y + zona.h);
const int OFF_TOP = static_cast<int>(sides_[SIDE_TOP].displacement_px);
const int OFF_RIGHT = static_cast<int>(sides_[SIDE_RIGHT].displacement_px);
const int OFF_BOTTOM = static_cast<int>(sides_[SIDE_BOTTOM].displacement_px);
const int OFF_LEFT = static_cast<int>(sides_[SIDE_LEFT].displacement_px);
// Color per costat: lerp(oscil·lador → flash) en funció del desplaçament.
const SDL_Color FLASH = {
.r = Defaults::Border::FLASH_COLOR_R,
.g = Defaults::Border::FLASH_COLOR_G,
.b = Defaults::Border::FLASH_COLOR_B,
.a = 255};
const float MAX_D = Defaults::Border::MAX_DISPLACEMENT_PX;
const bool DO_FLASH = Defaults::Border::FLASH_ENABLED;
const SDL_Color C_TOP = DO_FLASH ? lerpColor(FLASH, sides_[SIDE_TOP].displacement_px / MAX_D) : SDL_Color{};
const SDL_Color C_RIGHT = DO_FLASH ? lerpColor(FLASH, sides_[SIDE_RIGHT].displacement_px / MAX_D) : SDL_Color{};
const SDL_Color C_BOTTOM = DO_FLASH ? lerpColor(FLASH, sides_[SIDE_BOTTOM].displacement_px / MAX_D) : SDL_Color{};
const SDL_Color C_LEFT = DO_FLASH ? lerpColor(FLASH, sides_[SIDE_LEFT].displacement_px / MAX_D) : SDL_Color{};
// Una sola línia per costat (brillo 1.0). Si DO_FLASH = false → alpha = 0 → usa
// el color global de l'oscil·lador.
Rendering::linea(renderer_, X1, Y1 - OFF_TOP, X2, Y1 - OFF_TOP, 1.0F, 0.0F, C_TOP);
Rendering::linea(renderer_, X2 + OFF_RIGHT, Y1, X2 + OFF_RIGHT, Y2, 1.0F, 0.0F, C_RIGHT);
Rendering::linea(renderer_, X1, Y2 + OFF_BOTTOM, X2, Y2 + OFF_BOTTOM, 1.0F, 0.0F, C_BOTTOM);
Rendering::linea(renderer_, X1 - OFF_LEFT, Y1, X1 - OFF_LEFT, Y2, 1.0F, 0.0F, C_LEFT);
}
} // namespace Graphics
+52
View File
@@ -0,0 +1,52 @@
// border.hpp - Border del playfield amb estat (desplaçaments i flash per impactes)
// © 2026 JailDesigner
//
// Substitueix el `drawMargins()` inline de GameScene. Cada un dels 4 costats
// té estat propi (desplaçament perpendicular outward + intensitat de flash blanc)
// que decau cap a 0. Esdeveniments externs (col·lisions contra els bounds, etc.)
// criden `bumpAt()` per generar reaccions.
#pragma once
#include <array>
#include <cstdint>
#include "core/rendering/render_context.hpp"
#include "core/types.hpp"
namespace Graphics {
class Border {
public:
explicit Border(Rendering::Renderer* renderer);
// Decae desplaçaments i flash cap a 0.
void update(float delta_time);
// Dibuixa els 4 costats amb el seu estat actual.
void draw() const;
// Aplica un bump al costat més proper al punt de contacte.
// strength ∈ [0, 1]; valors superiors es retallen.
void bumpAt(Vec2 contact_point, float strength);
private:
enum : std::uint8_t {
SIDE_TOP = 0,
SIDE_RIGHT = 1,
SIDE_BOTTOM = 2,
SIDE_LEFT = 3,
SIDE_COUNT = 4
};
struct SideState {
float displacement_px{0.0F}; // outward (sempre ≥ 0); el flash es deriva d'aquí
};
void applyBump(int side_idx, float strength);
Rendering::Renderer* renderer_;
std::array<SideState, SIDE_COUNT> sides_{};
};
} // namespace Graphics
+49 -30
View File
@@ -66,6 +66,51 @@ namespace Physics {
// Rebote contra los 4 bordes del rectángulo bounds_.
// Refleja la componente normal de la velocidad por la restitución.
namespace {
// Resol col·lisió contra un parell paret-axis (mín i màx).
// pos/vel són les referències al component de l'axis actiu (x o y);
// contact_perp és la coordenada del component perpendicular (la fixa de la
// paret a l'eix actiu — usada per al contact_point).
void resolveAxis(float& pos,
float& vel,
float radius,
float min_val,
float max_val,
float restitution,
bool axis_is_x,
float contact_perp,
const PhysicsWorld::BoundsHitCallback& callback) {
// Cara mínima (esquerra o superior)
if (pos - radius < min_val) {
pos = min_val + radius;
if (vel < 0.0F) {
if (callback) {
const Vec2 CONTACT = axis_is_x
? Vec2{.x = min_val, .y = contact_perp}
: Vec2{.x = contact_perp, .y = min_val};
callback(BoundsHit{.contact_point = CONTACT, .impact_speed = -vel});
}
vel = -vel * restitution;
}
}
// Cara màxima (dreta o inferior)
if (pos + radius > max_val) {
pos = max_val - radius;
if (vel > 0.0F) {
if (callback) {
const Vec2 CONTACT = axis_is_x
? Vec2{.x = max_val, .y = contact_perp}
: Vec2{.x = contact_perp, .y = max_val};
callback(BoundsHit{.contact_point = CONTACT, .impact_speed = vel});
}
vel = -vel * restitution;
}
}
}
} // namespace
void PhysicsWorld::resolveBoundsCollisions() {
const float MIN_X = bounds_.x;
const float MAX_X = bounds_.x + bounds_.w;
@@ -76,36 +121,10 @@ namespace Physics {
if (body == nullptr || body->isStatic()) {
continue;
}
const float R = body->radius;
// Pared izquierda
if (body->position.x - R < MIN_X) {
body->position.x = MIN_X + R;
if (body->velocity.x < 0.0F) {
body->velocity.x = -body->velocity.x * body->restitution;
}
}
// Pared derecha
if (body->position.x + R > MAX_X) {
body->position.x = MAX_X - R;
if (body->velocity.x > 0.0F) {
body->velocity.x = -body->velocity.x * body->restitution;
}
}
// Pared superior
if (body->position.y - R < MIN_Y) {
body->position.y = MIN_Y + R;
if (body->velocity.y < 0.0F) {
body->velocity.y = -body->velocity.y * body->restitution;
}
}
// Pared inferior
if (body->position.y + R > MAX_Y) {
body->position.y = MAX_Y - R;
if (body->velocity.y > 0.0F) {
body->velocity.y = -body->velocity.y * body->restitution;
}
}
// Eix X (esquerra/dreta): contact_perp = y actual del cos.
resolveAxis(body->position.x, body->velocity.x, body->radius, MIN_X, MAX_X, body->restitution, /*axis_is_x=*/true, body->position.y, bounds_hit_callback_);
// Eix Y (superior/inferior): contact_perp = x actual (ja clampejada en l'eix X).
resolveAxis(body->position.y, body->velocity.y, body->radius, MIN_Y, MAX_Y, body->restitution, /*axis_is_x=*/false, body->position.x, bounds_hit_callback_);
}
}
+26 -5
View File
@@ -13,14 +13,27 @@
#include <SDL3/SDL.h>
#include <functional>
#include <utility>
#include <vector>
#include "core/types.hpp"
namespace Physics {
struct RigidBody;
struct RigidBody;
// Notificació d'impacte contra un dels bounds del PLAYAREA. impact_speed és
// la magnitud de la component de velocity perpendicular a la paret (≥ 0).
struct BoundsHit {
Vec2 contact_point;
float impact_speed;
};
class PhysicsWorld {
public:
using BoundsHitCallback = std::function<void(const BoundsHit&)>;
class PhysicsWorld {
public:
PhysicsWorld() = default;
// Añade un cuerpo al mundo (no toma ownership).
@@ -41,6 +54,13 @@ class PhysicsWorld {
}
void clearBounds() { has_bounds_ = false; }
// Callback opcional invocat cada vegada que un cos impacta contra
// un dels bounds del PLAYAREA. S'invoca abans de la reflexió de
// velocity perquè impact_speed sigui la magnitud entrant.
void setBoundsHitCallback(BoundsHitCallback callback) {
bounds_hit_callback_ = std::move(callback);
}
// Avanza la simulación dt segundos:
// 1. Integra cada cuerpo (semi-implicit Euler + damping)
// 2. Resuelve colisiones contra los bounds (si configurados)
@@ -51,10 +71,11 @@ class PhysicsWorld {
[[nodiscard]] auto getBodyCount() const -> std::size_t { return bodies_.size(); }
[[nodiscard]] auto getBodies() const -> const std::vector<RigidBody*>& { return bodies_; }
private:
private:
std::vector<RigidBody*> bodies_;
SDL_FRect bounds_{0.0F, 0.0F, 0.0F, 0.0F};
bool has_bounds_{false};
BoundsHitCallback bounds_hit_callback_;
void integrate(float dt);
void resolveBoundsCollisions();
@@ -62,6 +83,6 @@ class PhysicsWorld {
// Resol un únic parell (a, b): correcció posicional + impulso elàstic.
// Estàtic: només toca els dos cossos rebuts, no consulta el world.
static void resolveBodyPair(RigidBody& a, RigidBody& b);
};
};
} // namespace Physics
+2
View File
@@ -47,6 +47,8 @@ namespace Rendering {
void setLineColor(SDL_Color color) { g_current_line_color = color; }
auto getLineColor() -> SDL_Color { return g_current_line_color; }
void setLineThickness(float thickness) {
if (thickness > 0.0F) {
g_current_line_thickness = thickness;
+21 -16
View File
@@ -9,27 +9,32 @@
#pragma once
#include "core/rendering/render_context.hpp"
#include <SDL3/SDL.h>
#include "core/rendering/render_context.hpp"
namespace Rendering {
// Dibuja una línea entre dos puntos en coordenadas lógicas (1280×720).
// brightness: factor de brillo (0.0..1.0, default 1.0 = brillo máximo).
// thickness: grosor en píxeles lógicos. Si <= 0 usa g_current_line_thickness.
// color: si alpha==0, se usa el color global del oscilador; si alpha>0 se
// usa este color directo (paleta semántica por entidad).
void linea(Renderer* renderer,
int x1, int y1, int x2, int y2,
float brightness = 1.0F,
float thickness = 0.0F,
SDL_Color color = {0, 0, 0, 0});
// Dibuja una línea entre dos puntos en coordenadas lógicas (1280×720).
// brightness: factor de brillo (0.0..1.0, default 1.0 = brillo máximo).
// thickness: grosor en píxeles lógicos. Si <= 0 usa g_current_line_thickness.
// color: si alpha==0, se usa el color global del oscilador; si alpha>0 se
// usa este color directo (paleta semántica por entidad).
void linea(Renderer* renderer,
int x1,
int y1,
int x2,
int y2,
float brightness = 1.0F,
float thickness = 0.0F,
SDL_Color color = {0, 0, 0, 0});
// Color global de las líneas (lo actualiza ColorOscillator vía SDLManager).
void setLineColor(SDL_Color color);
// Color global de las líneas (lo actualiza ColorOscillator vía SDLManager).
void setLineColor(SDL_Color color);
[[nodiscard]] auto getLineColor() -> SDL_Color;
// Grosor global por defecto (en píxeles lógicos). Default: 1.5.
void setLineThickness(float thickness);
[[nodiscard]] auto getLineThickness() -> float;
// Grosor global por defecto (en píxeles lógicos). Default: 1.5.
void setLineThickness(float thickness);
[[nodiscard]] auto getLineThickness() -> float;
} // namespace Rendering
+5
View File
@@ -65,6 +65,11 @@ namespace Effects {
// Reproducir sonido de explosión
Audio::get()->playSound(sound, Audio::Group::GAME);
// Notificar als subscriptors (border, playfield, etc.).
if (explosion_callback_) {
explosion_callback_(centro);
}
const Vec2& shape_centre = shape->getCenter();
// Multiplier: cada segment s'emet N vegades amb direccions aleatòries
+11
View File
@@ -6,6 +6,7 @@
#include <SDL3/SDL.h>
#include <array>
#include <functional>
#include <memory>
#include <utility>
#include <vector>
@@ -22,8 +23,17 @@ namespace Effects {
// Manté un pool de objectes Debris i gestiona el seu cicle de vida
class DebrisManager {
public:
// Notificació opcional cada vegada que es genera una explosió. El
// consumidor pot usar-la per fer reaccionar elements del fons (border
// bumps, pulse del playfield, etc.).
using ExplosionCallback = std::function<void(Vec2 center)>;
explicit DebrisManager(Rendering::Renderer* renderer);
void setExplosionCallback(ExplosionCallback callback) {
explosion_callback_ = std::move(callback);
}
// Crear explosión a partir de una shape
// - shape: shape vectorial a explode
// - centro: posición del centro de l'objecte
@@ -66,6 +76,7 @@ namespace Effects {
private:
Rendering::Renderer* renderer_;
ExplosionCallback explosion_callback_;
// Pool de fragments (màxim concurrent)
// Pentàgon 5 línies × 15 enemics × multiplier 3 = 225 trossos només d'enemics.
+34 -24
View File
@@ -11,7 +11,6 @@
#include "core/audio/audio.hpp"
#include "core/input/input.hpp"
#include "core/rendering/line_renderer.hpp"
#include "core/system/scene_context.hpp"
#include "game/stage_system/stage_loader.hpp"
#include "game/systems/collision_system.hpp"
@@ -31,7 +30,8 @@ GameScene::GameScene(SDLManager& sdl, SceneContext& context)
floating_score_manager_(sdl.getRenderer()),
trail_manager_(sdl.getRenderer()),
text_(sdl.getRenderer()),
playfield_(sdl.getRenderer()) {
playfield_(sdl.getRenderer()),
border_(sdl.getRenderer()) {
// Recuperar configuración de match des del context
match_config_ = context_.getMatchConfig();
@@ -64,6 +64,32 @@ GameScene::GameScene(SDLManager& sdl, SceneContext& context)
physics_world_.clear();
physics_world_.setBounds(Defaults::Zones::PLAYAREA);
// Connectar els impactes contra les parets al border (bump + flash).
physics_world_.setBoundsHitCallback([this](const Physics::BoundsHit& hit) {
if (hit.impact_speed < Defaults::Border::BUMP_MIN_VELOCITY) {
return;
}
const float STRENGTH = std::min(
1.0F,
hit.impact_speed / Defaults::Border::BUMP_VELOCITY_REFERENCE);
border_.bumpAt(hit.contact_point, STRENGTH);
});
// Explosions properes a una paret també generen bump (falloff lineal amb la distància).
debris_manager_.setExplosionCallback([this](Vec2 center) {
const SDL_FRect& zona = Defaults::Zones::PLAYAREA;
const float DIST_LEFT = std::abs(center.x - zona.x);
const float DIST_RIGHT = std::abs((zona.x + zona.w) - center.x);
const float DIST_TOP = std::abs(center.y - zona.y);
const float DIST_BOTTOM = std::abs((zona.y + zona.h) - center.y);
const float MIN_DIST = std::min({DIST_LEFT, DIST_RIGHT, DIST_TOP, DIST_BOTTOM});
if (MIN_DIST > Defaults::Border::EXPLOSION_FALLOFF_PX) {
return;
}
const float FALLOFF = 1.0F - (MIN_DIST / Defaults::Border::EXPLOSION_FALLOFF_PX);
border_.bumpAt(center, Defaults::Border::EXPLOSION_BASE_STRENGTH * FALLOFF);
});
// Load stage configuration
stage_config_ = StageSystem::StageLoader::load("data/stages/stages.yaml");
if (!stage_config_) {
@@ -183,6 +209,7 @@ void GameScene::stepPhysics(float delta_time) {
}
trail_manager_.update(delta_time, ships_);
playfield_.update(delta_time);
border_.update(delta_time);
}
void GameScene::stepShootingInput() {
@@ -519,7 +546,7 @@ void GameScene::drawActiveShipsAlive() const {
}
void GameScene::drawContinueState() {
drawMargins();
border_.draw();
drawEnemies();
drawBullets();
debris_manager_.draw();
@@ -530,7 +557,7 @@ void GameScene::drawContinueState() {
}
void GameScene::drawGameOverState() {
drawMargins();
border_.draw();
drawEnemies();
drawBullets();
debris_manager_.draw();
@@ -601,7 +628,7 @@ void GameScene::drawInitHudState() {
void GameScene::drawLevelStartState() {
playfield_.draw();
drawMargins();
border_.draw();
trail_manager_.draw();
drawActiveShipsAlive();
drawBullets();
@@ -614,7 +641,7 @@ void GameScene::drawLevelStartState() {
void GameScene::drawPlayingState() {
playfield_.draw();
drawMargins();
border_.draw();
trail_manager_.draw();
drawActiveShipsAlive();
drawEnemies();
@@ -627,7 +654,7 @@ void GameScene::drawPlayingState() {
void GameScene::drawLevelCompletedState() {
playfield_.draw();
drawMargins();
border_.draw();
trail_manager_.draw();
drawActiveShipsAlive();
drawBullets();
@@ -679,23 +706,6 @@ void GameScene::tocado(uint8_t player_id) {
// Phase 3 is handled in update() when hit_timer_per_player_ >= DEATH_DURATION
}
void GameScene::drawMargins() const {
// Dibuixar rectangle de la zona de juego
const SDL_FRect& zona = Defaults::Zones::PLAYAREA;
// Coordenades dels cantons
int x1 = static_cast<int>(zona.x);
int y1 = static_cast<int>(zona.y);
int x2 = static_cast<int>(zona.x + zona.w);
int y2 = static_cast<int>(zona.y + zona.h);
// 4 línies per formar el rectangle
Rendering::linea(sdl_.getRenderer(), x1, y1, x2, y1); // Top
Rendering::linea(sdl_.getRenderer(), x1, y2, x2, y2); // Bottom
Rendering::linea(sdl_.getRenderer(), x1, y1, x1, y2); // Left
Rendering::linea(sdl_.getRenderer(), x2, y1, x2, y2); // Right
}
void GameScene::drawScoreboard() {
// Construir text del marcador
std::string text = buildScoreboard();
+4 -1
View File
@@ -8,6 +8,7 @@
#include <memory>
#include <string>
#include "core/graphics/border.hpp"
#include "core/graphics/playfield.hpp"
#include "core/graphics/vector_text.hpp"
#include "core/physics/physics_world.hpp"
@@ -84,6 +85,9 @@ class GameScene final : public Scene {
// Fons del playfield (graella + futures capes)
Graphics::Playfield playfield_;
// Border del playfield (4 línies amb desplaçaments i flash per impactes)
Graphics::Border border_;
// [NEW] Stage system
std::unique_ptr<StageSystem::StageSystemConfig> stage_config_;
std::unique_ptr<StageSystem::StageManager> stage_manager_;
@@ -93,7 +97,6 @@ class GameScene final : public Scene {
// Funciones privades
void tocado(uint8_t player_id);
void drawMargins() const; // Dibuixar vores de la zona de juego
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