Merge branch 'tweak/playfield-grid': ones d'aigua + starfield parallax al fons

This commit is contained in:
2026-05-22 19:52:07 +02:00
9 changed files with 506 additions and 211 deletions
+1
View File
@@ -26,6 +26,7 @@
#include "core/defaults/playfield.hpp"
#include "core/defaults/rendering.hpp"
#include "core/defaults/ship.hpp"
#include "core/defaults/starfield_parallax.hpp"
#include "core/defaults/title.hpp"
#include "core/defaults/trail.hpp"
#include "core/defaults/window.hpp"
+27 -15
View File
@@ -8,7 +8,7 @@ namespace Defaults::Playfield {
// Estructura de la graella (cel·les omplen tota la PLAYAREA)
constexpr int COLUMNS = 16; // cell_w = PLAYAREA.w / 16
constexpr int ROWS = 8; // cell_h = PLAYAREA.h / 8
constexpr int SUBDIVISIONS = 5; // cada cel·la principal es divideix en N subcel·les
constexpr int SUBDIVISIONS = 4; // cada cel·la principal es divideix en N subcel·les
// Brillo respecte al color global (border = 1.0)
constexpr float GRID_BRIGHTNESS = 0.15F;
@@ -25,20 +25,32 @@ namespace Defaults::Playfield {
constexpr float HEAD_LENGTH_PX = 8.0F; // longitud en píxels lògics del tram brillant
constexpr float HEAD_BRIGHTNESS = 0.0F; // brillo del cap (= border)
// Orbit (oscil·lació transversal de la línia quan la nau hi passa a prop).
constexpr float ORBIT_AMPLITUDE_MAX_PX = 3.0F; // desplaçament transversal màxim
constexpr float ORBIT_DECAY_PER_S = 4.0F; // decaiment de l'amplitud (px/s)
constexpr float ORBIT_FREQ_HZ = 8.0F; // freqüència del sin
constexpr float ORBIT_PROXIMITY_PX = 12.0F; // distància max de la línia per excitar-la
constexpr float ORBIT_SHIP_SPEED_THRESHOLD = 60.0F; // velocitat mínima per excitar (px/s)
// Ripples: deformacions circulars que travessen la graella com ones d'aigua.
// Cada ripple desplaça radialment cap a fora els vèrtexs de les línies que
// travessa, amb una envoltant que decau a les vores de l'anell i amb el temps.
namespace Ripple {
constexpr int POOL_SIZE = 32;
// Pulse (reacció a fireworks: punt brillant que es propaga al llarg de la
// línia a partir del punt de spawn).
constexpr int MAX_PULSES_PER_LINE = 2;
constexpr float PULSE_LIFETIME_S = 1.0F; // temps total fins desaparèixer
constexpr float PULSE_SPREAD_PER_S = 300.0F; // px/s de propagació (cap a cada extrem)
constexpr unsigned char PULSE_COLOR_R = 180;
constexpr unsigned char PULSE_COLOR_G = 230;
constexpr unsigned char PULSE_COLOR_B = 255;
// Ones grans (explosions / fireworks).
constexpr float BIG_AMPLITUDE_PX = 10.0F;
constexpr float BIG_SPEED_PX_S = 320.0F;
constexpr float BIG_LIFETIME_S = 1.4F;
constexpr float BIG_THICKNESS_PX = 40.0F;
// Ones petites (pas de nau, cadència estil trail).
constexpr float SMALL_AMPLITUDE_PX = 2.5F;
constexpr float SMALL_SPEED_PX_S = 160.0F;
constexpr float SMALL_LIFETIME_S = 0.55F;
constexpr float SMALL_THICKNESS_PX = 18.0F;
// Cadència "soltar gotetes" per nau (patró TrailManager).
constexpr float SHIP_COOLDOWN_S = 0.10F;
constexpr float SHIP_COOLDOWN_JITTER_S = 0.03F;
constexpr float SHIP_SPEED_THRESHOLD_PX_S = 80.0F;
// Subdivisió de línies quan estan dins una ripple.
constexpr int MAIN_SEGMENTS = 24; // línies principals
constexpr int SUB_SEGMENTS = 12; // sub-graella
} // namespace Ripple
} // namespace Defaults::Playfield
@@ -0,0 +1,36 @@
// starfield_parallax.hpp - Capa de fons del playfield: estrelles 2D amb parallax
// © 2026 JailDesigner
//
// 3 capes de profunditat. Cada capa té estrelles amb brillantor, mida i
// factor parallax propis. Les més properes són més brillants i grans i es
// mouen més ràpid quan el món es desplaça; les més llunyanes són tènues i
// petites i amb prou feines es mouen.
#pragma once
namespace Defaults::StarfieldParallax {
namespace Far {
constexpr int COUNT = 60;
constexpr float BRIGHTNESS = 0.15F;
constexpr float PARALLAX_FACTOR = 0.15F; // multiplicador sobre world_velocity
constexpr int SIZE_PX = 1; // 1 px (punt)
} // namespace Far
namespace Mid {
constexpr int COUNT = 50;
constexpr float BRIGHTNESS = 0.30F;
constexpr float PARALLAX_FACTOR = 0.35F;
constexpr int SIZE_PX = 2; // creu de 3x3 (extensió ±1)
} // namespace Mid
namespace Near {
constexpr int COUNT = 40;
constexpr float BRIGHTNESS = 0.55F;
constexpr float PARALLAX_FACTOR = 0.70F;
constexpr int SIZE_PX = 3; // creu de 5x5 (extensió ±2)
} // namespace Near
constexpr int TOTAL_COUNT = Far::COUNT + Mid::COUNT + Near::COUNT;
} // namespace Defaults::StarfieldParallax
+173 -166
View File
@@ -5,8 +5,8 @@
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <cstdlib>
#include <limits>
#include "core/defaults.hpp"
#include "core/rendering/line_renderer.hpp"
@@ -21,20 +21,38 @@ namespace Graphics {
return 1.0F - (INV * INV * INV);
}
// Lerp del color base actual (oscil·lador) cap a un color destí en
// funció de f ∈ [0, 1]. Alpha > 0 perquè line_renderer l'usi directe.
auto lerpColor(SDL_Color target, 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, target.r),
.g = LERP_U8(BASE.g, target.g),
.b = LERP_U8(BASE.b, target.b),
.a = 255};
auto randUniform(float min_v, float max_v) -> float {
const float NORM = static_cast<float>(std::rand()) / static_cast<float>(RAND_MAX);
return min_v + (NORM * (max_v - min_v));
}
// Desplaçament radial acumulat al punt (px, py) sumant totes les ripples
// que el toquen. Retorna {dx, dy} a sumar a la posició original.
auto computeRippleDisplacement(float px, float py, const Playfield::Ripple* const* hits, int n_hits) -> Vec2 {
float dx_total = 0.0F;
float dy_total = 0.0F;
for (int i = 0; i < n_hits; i++) {
const auto& r = *hits[i];
const float RADIUS = r.age_s * r.speed_px_s;
const float THICKNESS = r.thickness_px;
const float DX = px - r.center.x;
const float DY = py - r.center.y;
const float D = std::sqrt((DX * DX) + (DY * DY));
if (D < 0.001F) {
continue; // centre exacte: no hi ha direcció radial
}
const float PHASE = (D - RADIUS) / THICKNESS;
if (std::fabs(PHASE) >= 1.0F) {
continue; // fora de l'anell d'aquesta ripple
}
const float ENVELOPE = std::cos(PHASE * Defaults::Math::PI * 0.5F);
const float AMP_EFF = r.amplitude_px * (1.0F - (r.age_s / r.lifetime_s));
const float UX = DX / D;
const float UY = DY / D;
dx_total += UX * AMP_EFF * ENVELOPE;
dy_total += UY * AMP_EFF * ENVELOPE;
}
return Vec2{.x = dx_total, .y = dy_total};
}
} // namespace
@@ -46,95 +64,80 @@ namespace Graphics {
void Playfield::update(float delta_time) {
elapsed_s_ += delta_time;
// Decau l'orbit i avança la fase del sin per cada línia.
const float ORBIT_DELTA_PHASE = Defaults::Playfield::ORBIT_FREQ_HZ * 2.0F * Defaults::Math::PI * delta_time;
const float ORBIT_DEC = Defaults::Playfield::ORBIT_DECAY_PER_S * delta_time;
for (auto& line : lines_) {
line.orbit_phase += ORBIT_DELTA_PHASE;
line.orbit_amplitude = std::max(0.0F, line.orbit_amplitude - ORBIT_DEC);
// Avança els pulses; els desactiva quan acaben de vida.
for (auto& pulse : line.pulses) {
if (!pulse.active) {
continue;
}
pulse.age_s += delta_time;
if (pulse.age_s >= Defaults::Playfield::PULSE_LIFETIME_S) {
pulse.active = false;
}
for (auto& ripple : ripples_) {
if (!ripple.active) {
continue;
}
ripple.age_s += delta_time;
if (ripple.age_s >= ripple.lifetime_s) {
ripple.active = false;
}
}
}
void Playfield::spawnPulseAt(Line& line, float center_t) {
for (auto& pulse : line.pulses) {
if (!pulse.active) {
pulse.active = true;
pulse.age_s = 0.0F;
pulse.center_t = std::clamp(center_t, 0.0F, 1.0F);
return;
auto Playfield::findFreeRipple() -> Ripple* {
Ripple* oldest = nullptr;
for (auto& ripple : ripples_) {
if (!ripple.active) {
return &ripple;
}
if (oldest == nullptr || ripple.age_s > oldest->age_s) {
oldest = &ripple;
}
}
// Cap slot lliure: substituïm el més vell.
Pulse* oldest = line.pulses.data();
for (auto& pulse : line.pulses) {
if (pulse.age_s > oldest->age_s) {
oldest = &pulse;
}
}
oldest->active = true;
oldest->age_s = 0.0F;
oldest->center_t = std::clamp(center_t, 0.0F, 1.0F);
return oldest; // pool ple: substituïm la més vella
}
void Playfield::notifyFireworkSpawn(Vec2 pos) {
// Línia vertical més propera (per posició x) i horitzontal més propera (per y).
Line* closest_v = nullptr;
Line* closest_h = nullptr;
float min_dx = std::numeric_limits<float>::max();
float min_dy = std::numeric_limits<float>::max();
for (auto& line : lines_) {
if (line.is_vertical) {
const float DX = std::abs(pos.x - line.start.x);
if (DX < min_dx) {
min_dx = DX;
closest_v = &line;
}
} else {
const float DY = std::abs(pos.y - line.start.y);
if (DY < min_dy) {
min_dy = DY;
closest_h = &line;
}
}
}
if (closest_v != nullptr) {
const float LINE_LEN = closest_v->end.y - closest_v->start.y;
const float CENTER_T = (LINE_LEN > 0.0F) ? (pos.y - closest_v->start.y) / LINE_LEN : 0.5F;
spawnPulseAt(*closest_v, CENTER_T);
}
if (closest_h != nullptr) {
const float LINE_LEN = closest_h->end.x - closest_h->start.x;
const float CENTER_T = (LINE_LEN > 0.0F) ? (pos.x - closest_h->start.x) / LINE_LEN : 0.5F;
spawnPulseAt(*closest_h, CENTER_T);
}
}
void Playfield::notifyShipPass(Vec2 pos, float speed_px_s) {
if (speed_px_s < Defaults::Playfield::ORBIT_SHIP_SPEED_THRESHOLD) {
void Playfield::spawnBig(Vec2 pos) {
Ripple* r = findFreeRipple();
if (r == nullptr) {
return;
}
const float MAX_DIST = Defaults::Playfield::ORBIT_PROXIMITY_PX;
for (auto& line : lines_) {
// Distància perpendicular del punt a la línia (que és horitzontal o vertical).
const float DIST = line.is_vertical
? std::abs(pos.x - line.start.x)
: std::abs(pos.y - line.start.y);
if (DIST < MAX_DIST) {
line.orbit_amplitude = Defaults::Playfield::ORBIT_AMPLITUDE_MAX_PX;
}
r->center = pos;
r->age_s = 0.0F;
r->lifetime_s = Defaults::Playfield::Ripple::BIG_LIFETIME_S;
r->speed_px_s = Defaults::Playfield::Ripple::BIG_SPEED_PX_S;
r->amplitude_px = Defaults::Playfield::Ripple::BIG_AMPLITUDE_PX;
r->thickness_px = Defaults::Playfield::Ripple::BIG_THICKNESS_PX;
r->active = true;
}
void Playfield::spawnSmall(Vec2 pos) {
Ripple* r = findFreeRipple();
if (r == nullptr) {
return;
}
r->center = pos;
r->age_s = 0.0F;
r->lifetime_s = Defaults::Playfield::Ripple::SMALL_LIFETIME_S;
r->speed_px_s = Defaults::Playfield::Ripple::SMALL_SPEED_PX_S;
r->amplitude_px = Defaults::Playfield::Ripple::SMALL_AMPLITUDE_PX;
r->thickness_px = Defaults::Playfield::Ripple::SMALL_THICKNESS_PX;
r->active = true;
}
void Playfield::notifyExplosion(Vec2 pos) {
spawnBig(pos);
}
void Playfield::notifyShipMoving(std::uint8_t player_id, Vec2 pos, float speed_px_s, float delta_time) {
if (player_id >= ship_ripple_cooldown_.size()) {
return;
}
if (speed_px_s < Defaults::Playfield::Ripple::SHIP_SPEED_THRESHOLD_PX_S) {
ship_ripple_cooldown_[player_id] = 0.0F;
return;
}
ship_ripple_cooldown_[player_id] -= delta_time;
if (ship_ripple_cooldown_[player_id] > 0.0F) {
return;
}
spawnSmall(pos);
const float JITTER = randUniform(
-Defaults::Playfield::Ripple::SHIP_COOLDOWN_JITTER_S,
Defaults::Playfield::Ripple::SHIP_COOLDOWN_JITTER_S);
ship_ripple_cooldown_[player_id] =
Defaults::Playfield::Ripple::SHIP_COOLDOWN_S + JITTER;
}
void Playfield::buildLines() {
@@ -161,10 +164,7 @@ namespace Graphics {
.end = {.x = X, .y = zona.y + zona.h},
.brightness = BRIGHTNESS,
.spawn_time_s = 0.0F,
.is_vertical = true,
.orbit_amplitude = 0.0F,
.orbit_phase = 0.0F,
.pulses = {}});
.is_vertical = true});
}
// Horitzontals: posicions j ∈ [1, SUB_HORIZ-1].
@@ -179,10 +179,7 @@ namespace Graphics {
.end = {.x = zona.x + zona.w, .y = Y},
.brightness = BRIGHTNESS,
.spawn_time_s = 0.0F,
.is_vertical = false,
.orbit_amplitude = 0.0F,
.orbit_phase = 0.0F,
.pulses = {}});
.is_vertical = false});
}
// Ona diagonal: la línia esquerra/superior naix a t=0 i les següents
@@ -215,90 +212,100 @@ namespace Graphics {
}
void Playfield::draw() const {
// Recollir ripples actives (punters per accés ràpid al hot loop).
std::array<const Ripple*, Defaults::Playfield::Ripple::POOL_SIZE> active{};
int n_active = 0;
for (const auto& ripple : ripples_) {
if (ripple.active) {
active[n_active++] = &ripple;
}
}
for (const auto& line : lines_) {
const float RAW_P = computeLineProgress(line);
if (RAW_P <= 0.0F) {
drawLine(line, active.data(), n_active);
}
}
void Playfield::drawLine(const Line& line, const Ripple* const* active, int n_active) const {
const float RAW_P = computeLineProgress(line);
if (RAW_P <= 0.0F) {
return;
}
const float P = easeOutCubic(RAW_P);
const float START_X = line.start.x;
const float START_Y = line.start.y;
const float DX = line.end.x - line.start.x;
const float DY = line.end.y - line.start.y;
const float END_X = START_X + (DX * P);
const float END_Y = START_Y + (DY * P);
// AABB de la porció visible de la línia + filtre de ripples.
const float LINE_MIN_X = std::min(START_X, END_X);
const float LINE_MAX_X = std::max(START_X, END_X);
const float LINE_MIN_Y = std::min(START_Y, END_Y);
const float LINE_MAX_Y = std::max(START_Y, END_Y);
std::array<const Ripple*, Defaults::Playfield::Ripple::POOL_SIZE> hits{};
int n_hits = 0;
for (int i = 0; i < n_active; i++) {
const auto& r = *active[i];
const float R_MAX = (r.age_s * r.speed_px_s) + r.thickness_px;
if ((r.center.x + R_MAX) < LINE_MIN_X || (r.center.x - R_MAX) > LINE_MAX_X ||
(r.center.y + R_MAX) < LINE_MIN_Y || (r.center.y - R_MAX) > LINE_MAX_Y) {
continue;
}
const float P = easeOutCubic(RAW_P);
hits[n_hits++] = &r;
}
// Desplaçament perpendicular per orbit (verticals → x, horitzontals → y).
const float ORBIT_OFFSET = line.orbit_amplitude * std::sin(line.orbit_phase);
const float ORBIT_DX = line.is_vertical ? ORBIT_OFFSET : 0.0F;
const float ORBIT_DY = line.is_vertical ? 0.0F : ORBIT_OFFSET;
const float START_X = line.start.x + ORBIT_DX;
const float START_Y = line.start.y + ORBIT_DY;
const float DX = line.end.x - line.start.x;
const float DY = line.end.y - line.start.y;
const float CURRENT_X = START_X + (DX * P);
const float CURRENT_Y = START_Y + (DY * P);
// Tram base (brillo de la línia).
if (n_hits == 0) {
// Camí ràpid: una sola crida com abans.
Rendering::linea(
renderer_,
static_cast<int>(START_X),
static_cast<int>(START_Y),
static_cast<int>(CURRENT_X),
static_cast<int>(CURRENT_Y),
static_cast<int>(END_X),
static_cast<int>(END_Y),
line.brightness);
// Cap brillant mentre creix: l'últim tram de la línia es repinta més brillant.
// Cap brillant mentre creix.
if (P < 1.0F) {
const float LENGTH = std::sqrt((DX * DX) + (DY * DY));
if (LENGTH > 0.0F) {
const float HEAD_T = std::max(0.0F, P - (Defaults::Playfield::HEAD_LENGTH_PX / LENGTH));
const float HEAD_X = START_X + (DX * HEAD_T);
const float HEAD_Y = START_Y + (DY * HEAD_T);
Rendering::linea(
renderer_,
static_cast<int>(HEAD_X),
static_cast<int>(HEAD_Y),
static_cast<int>(CURRENT_X),
static_cast<int>(CURRENT_Y),
static_cast<int>(START_X + (DX * HEAD_T)),
static_cast<int>(START_Y + (DY * HEAD_T)),
static_cast<int>(END_X),
static_cast<int>(END_Y),
Defaults::Playfield::HEAD_BRIGHTNESS);
}
}
return;
}
// Pulses: cada un és un segment brillant centrat a center_t que
// s'expandeix amb el temps i s'apaga.
const float LINE_LENGTH = std::sqrt((DX * DX) + (DY * DY));
if (LINE_LENGTH <= 0.0F) {
continue;
}
const SDL_Color PULSE_TARGET = {
.r = Defaults::Playfield::PULSE_COLOR_R,
.g = Defaults::Playfield::PULSE_COLOR_G,
.b = Defaults::Playfield::PULSE_COLOR_B,
.a = 255};
for (const auto& pulse : line.pulses) {
if (!pulse.active) {
continue;
}
const float HALF_WIDTH_T = (pulse.age_s * Defaults::Playfield::PULSE_SPREAD_PER_S) / LINE_LENGTH;
const float INTENSITY = std::max(
0.0F,
1.0F - (pulse.age_s / Defaults::Playfield::PULSE_LIFETIME_S));
const float T1 = std::clamp(pulse.center_t - HALF_WIDTH_T, 0.0F, 1.0F);
const float T2 = std::clamp(pulse.center_t + HALF_WIDTH_T, 0.0F, 1.0F);
if (T2 <= T1) {
continue;
}
const float P1_X = START_X + (DX * T1);
const float P1_Y = START_Y + (DY * T1);
const float P2_X = START_X + (DX * T2);
const float P2_Y = START_Y + (DY * T2);
const SDL_Color SEG_COLOR = lerpColor(PULSE_TARGET, INTENSITY);
Rendering::linea(
renderer_,
static_cast<int>(P1_X),
static_cast<int>(P1_Y),
static_cast<int>(P2_X),
static_cast<int>(P2_Y),
1.0F,
0.0F,
SEG_COLOR);
}
// Camí deformat: subdividir en N segments i desplaçar cada vèrtex.
const bool IS_MAIN = line.brightness >= Defaults::Playfield::GRID_BRIGHTNESS;
const int N = IS_MAIN
? Defaults::Playfield::Ripple::MAIN_SEGMENTS
: Defaults::Playfield::Ripple::SUB_SEGMENTS;
const Vec2 D0 = computeRippleDisplacement(START_X, START_Y, hits.data(), n_hits);
float prev_x = START_X + D0.x;
float prev_y = START_Y + D0.y;
for (int i = 1; i <= N; i++) {
const float T = static_cast<float>(i) / static_cast<float>(N);
const float X = START_X + (DX * P * T);
const float Y = START_Y + (DY * P * T);
const Vec2 D = computeRippleDisplacement(X, Y, hits.data(), n_hits);
const float NX = X + D.x;
const float NY = Y + D.y;
Rendering::linea(
renderer_,
static_cast<int>(prev_x),
static_cast<int>(prev_y),
static_cast<int>(NX),
static_cast<int>(NY),
line.brightness);
prev_x = NX;
prev_y = NY;
}
}
+34 -24
View File
@@ -5,13 +5,16 @@
// rep un `creation_progress` global ∈ [0, 1] i cada línia computa quina porció
// li toca dibuixar segons el seu slot a la timeline.
//
// Disseny preparat per a futures capacitats:
// - Línies "vives" que reaccionen a explosions / pas de la nau (reaction_intensity).
// - Capes addicionals al fons (estrelles, gradients, scanlines).
// Reaccions disponibles:
// - Ripples: deformacions circulars (ones d'aigua) que travessen la graella.
// Disparades per explosions (grans) i pas de la nau (petites, cadència estil
// trail). Cada vèrtex d'una línia afectada es desplaça radialment cap a fora
// amb una envoltant en cos(·) que decau a les vores de l'anell i amb el temps.
#pragma once
#include <array>
#include <cstdint>
#include <vector>
#include "core/defaults/playfield.hpp"
@@ -24,44 +27,51 @@ namespace Graphics {
public:
explicit Playfield(Rendering::Renderer* renderer);
// Avança timers interns (creació + reaccions).
// Avança timers interns (creació + ripples).
void update(float delta_time);
// Pinta la graella. La porció dibuixada de cada línia depèn del timer intern.
// Pinta la graella. La porció dibuixada de cada línia depèn del timer intern,
// i s'aplica deformació radial per cada ripple activa que afecti la línia.
void draw() const;
// Notifica que una nau ha passat per (pos) a velocitat (speed_px_s).
// Si està prop d'alguna línia i va prou ràpida, la línia entra en orbit.
void notifyShipPass(Vec2 pos, float speed_px_s);
// Notifica que una nau ha passat per (pos) a (speed_px_s). Genera ones
// petites darrere la nau a cadència regular amb jitter (estil TrailManager).
void notifyShipMoving(std::uint8_t player_id, Vec2 pos, float speed_px_s, float delta_time);
// Notifica el spawn d'un firework a (pos). Les línies V i H més properes
// generen un pulse brillant que es propaga.
void notifyFireworkSpawn(Vec2 pos);
// Notifica una explosió a (pos): genera una ripple gran centrada al punt.
void notifyExplosion(Vec2 pos);
private:
struct Pulse {
bool active{false};
float center_t{0.5F}; // posició al llarg de la línia (0..1)
// Pública per accés des d'helpers a l'anonymous namespace del .cpp.
struct Ripple {
Vec2 center{};
float age_s{0.0F};
float lifetime_s{0.0F};
float speed_px_s{0.0F};
float amplitude_px{0.0F};
float thickness_px{0.0F};
bool active{false};
};
private:
struct Line {
Vec2 start; // top (verticals) o left (horitzontals)
Vec2 end; // bottom (verticals) o right (horitzontals)
float brightness; // base (GRID_BRIGHTNESS o SUBGRID_BRIGHTNESS)
float spawn_time_s; // moment de naixement
bool is_vertical; // direcció (per saber el perpendicular de l'orbit)
float orbit_amplitude; // amplitud actual de l'orbit (px, ≥ 0)
float orbit_phase; // fase del sin (avança contínuament)
std::array<Pulse, Defaults::Playfield::MAX_PULSES_PER_LINE> pulses;
Vec2 start; // top (verticals) o left (horitzontals)
Vec2 end; // bottom (verticals) o right (horitzontals)
float brightness; // base (GRID_BRIGHTNESS o SUBGRID_BRIGHTNESS)
float spawn_time_s; // moment de naixement
bool is_vertical; // direcció
};
void buildLines();
void drawLine(const Line& line, const Ripple* const* active, int n_active) const;
[[nodiscard]] auto computeLineProgress(const Line& line) const -> float;
static void spawnPulseAt(Line& line, float center_t);
void spawnBig(Vec2 pos);
void spawnSmall(Vec2 pos);
auto findFreeRipple() -> Ripple*;
Rendering::Renderer* renderer_;
std::vector<Line> lines_;
std::array<Ripple, Defaults::Playfield::Ripple::POOL_SIZE> ripples_{};
std::array<float, 2> ship_ripple_cooldown_{};
float elapsed_s_{0.0F};
};
+145
View File
@@ -0,0 +1,145 @@
// starfield_parallax.cpp - Implementació del starfield 2D amb parallax
// © 2026 JailDesigner
#include "core/graphics/starfield_parallax.hpp"
#include <cstdlib>
#include "core/defaults.hpp"
#include "core/rendering/line_renderer.hpp"
namespace Graphics {
namespace {
auto randUniform(float min_v, float max_v) -> float {
const float NORM = static_cast<float>(std::rand()) / static_cast<float>(RAND_MAX);
return min_v + (NORM * (max_v - min_v));
}
} // namespace
StarfieldParallax::StarfieldParallax(Rendering::Renderer* renderer)
: renderer_(renderer) {
buildStars();
}
void StarfieldParallax::buildStars() {
const SDL_FRect& zona = Defaults::Zones::PLAYAREA;
const float MIN_X = zona.x;
const float MAX_X = zona.x + zona.w;
const float MIN_Y = zona.y;
const float MAX_Y = zona.y + zona.h;
// Tint aleatori entre blanc (255,255,255) i cyan (0,255,255) per estrella.
// T ∈ [0,1]: 0 → blanc; 1 → cyan. R = 255·(1-T), G=B=255.
const auto FILL_LAYER = [&](int layer, int count, int& idx) {
for (int i = 0; i < count; i++) {
const float T = randUniform(0.0F, 1.0F);
stars_[idx++] = Star{
.x = randUniform(MIN_X, MAX_X),
.y = randUniform(MIN_Y, MAX_Y),
.layer = layer,
.color = SDL_Color{
.r = static_cast<Uint8>(255.0F * (1.0F - T)),
.g = 255,
.b = 255,
.a = 255}};
}
};
int idx = 0;
FILL_LAYER(0, Defaults::StarfieldParallax::Far::COUNT, idx);
FILL_LAYER(1, Defaults::StarfieldParallax::Mid::COUNT, idx);
FILL_LAYER(2, Defaults::StarfieldParallax::Near::COUNT, idx);
}
auto StarfieldParallax::layerBrightness(int layer) -> float {
switch (layer) {
case 0:
return Defaults::StarfieldParallax::Far::BRIGHTNESS;
case 1:
return Defaults::StarfieldParallax::Mid::BRIGHTNESS;
case 2:
return Defaults::StarfieldParallax::Near::BRIGHTNESS;
default:
return 0.0F;
}
}
auto StarfieldParallax::layerParallax(int layer) -> float {
switch (layer) {
case 0:
return Defaults::StarfieldParallax::Far::PARALLAX_FACTOR;
case 1:
return Defaults::StarfieldParallax::Mid::PARALLAX_FACTOR;
case 2:
return Defaults::StarfieldParallax::Near::PARALLAX_FACTOR;
default:
return 0.0F;
}
}
auto StarfieldParallax::layerSize(int layer) -> int {
switch (layer) {
case 0:
return Defaults::StarfieldParallax::Far::SIZE_PX;
case 1:
return Defaults::StarfieldParallax::Mid::SIZE_PX;
case 2:
return Defaults::StarfieldParallax::Near::SIZE_PX;
default:
return 1;
}
}
void StarfieldParallax::update(float delta_time, Vec2 world_velocity) {
const SDL_FRect& zona = Defaults::Zones::PLAYAREA;
const float MIN_X = zona.x;
const float MAX_X = zona.x + zona.w;
const float MIN_Y = zona.y;
const float MAX_Y = zona.y + zona.h;
const float W = zona.w;
const float H = zona.h;
for (auto& star : stars_) {
const float FACTOR = layerParallax(star.layer);
star.x += world_velocity.x * FACTOR * delta_time;
star.y += world_velocity.y * FACTOR * delta_time;
// Wraparound (PLAYAREA torica).
while (star.x < MIN_X) {
star.x += W;
}
while (star.x > MAX_X) {
star.x -= W;
}
while (star.y < MIN_Y) {
star.y += H;
}
while (star.y > MAX_Y) {
star.y -= H;
}
}
}
void StarfieldParallax::draw() const {
for (const auto& star : stars_) {
const float B = layerBrightness(star.layer);
const int SIZE = layerSize(star.layer);
const int X = static_cast<int>(star.x);
const int Y = static_cast<int>(star.y);
if (SIZE <= 1) {
// Punt d'1 px: línia degenerada horitzontal de 1 px.
Rendering::linea(renderer_, X, Y, X + 1, Y, B, 0.0F, star.color);
} else {
// Creu "+" amb extensió HALF des del centre en cada direcció.
const int HALF = SIZE - 1; // SIZE=2 → ±1 (creu 3x3); SIZE=3 → ±2 (creu 5x5)
Rendering::linea(renderer_, X - HALF, Y, X + HALF + 1, Y, B, 0.0F, star.color);
Rendering::linea(renderer_, X, Y - HALF, X, Y + HALF + 1, B, 0.0F, star.color);
}
}
}
} // namespace Graphics
@@ -0,0 +1,51 @@
// starfield_parallax.hpp - Capa més profunda del fons: estrelles 2D amb parallax
// © 2026 JailDesigner
//
// Estrelles 2D distribuïdes en 3 capes de profunditat. Cada capa té el seu
// factor parallax: el "món" es desplaça amb world_velocity i les estrelles
// d'una capa es mouen amb world_velocity * parallax_factor. Les més
// properes es mouen més (factor alt) → sensació de profunditat.
// Quan una estrella surt de PLAYAREA, reapareix per la banda oposada
// (wraparound).
#pragma once
#include <SDL3/SDL.h>
#include <array>
#include "core/defaults/starfield_parallax.hpp"
#include "core/rendering/render_context.hpp"
#include "core/types.hpp"
namespace Graphics {
class StarfieldParallax {
public:
explicit StarfieldParallax(Rendering::Renderer* renderer);
// Avança el desplaçament de les estrelles segons world_velocity (vector
// del moviment del món en px/s; típicament = -ship_velocity).
// world_velocity == {0, 0} → estrelles quietes.
void update(float delta_time, Vec2 world_velocity);
void draw() const;
private:
struct Star {
float x{0.0F};
float y{0.0F};
int layer{0}; // 0=Far, 1=Mid, 2=Near
SDL_Color color{}; // tint precomputat entre blanc i cyan
};
void buildStars();
static auto layerBrightness(int layer) -> float;
static auto layerParallax(int layer) -> float;
static auto layerSize(int layer) -> int;
Rendering::Renderer* renderer_;
std::array<Star, Defaults::StarfieldParallax::TOTAL_COUNT> stars_{};
};
} // namespace Graphics
+35 -6
View File
@@ -30,6 +30,7 @@ GameScene::GameScene(SDLManager& sdl, SceneContext& context)
floating_score_manager_(sdl.getRenderer()),
trail_manager_(sdl.getRenderer()),
text_(sdl.getRenderer()),
starfield_parallax_(sdl.getRenderer()),
playfield_(sdl.getRenderer()),
border_(sdl.getRenderer()) {
// Recuperar configuración de match des del context
@@ -75,9 +76,9 @@ GameScene::GameScene(SDLManager& sdl, SceneContext& context)
border_.bumpAt(hit.contact_point, STRENGTH);
});
// Fireworks generen un pulse a les línies V i H més properes del playfield.
// Fireworks generen una ripple gran al playfield (ona d'aigua centrada al burst).
firework_manager_.setSpawnCallback([this](Vec2 origen) {
playfield_.notifyFireworkSpawn(origen);
playfield_.notifyExplosion(origen);
});
// Explosions properes a una paret també generen bump (falloff lineal amb la distància).
@@ -213,13 +214,34 @@ void GameScene::stepPhysics(float delta_time) {
bullet.postUpdate(delta_time);
}
trail_manager_.update(delta_time, ships_);
// Starfield: world_velocity = -mitjana_de_naus_actives. Si dues naus van en
// sentits oposats, es cancel·len → estrelles quietes (cap jugador "guanya").
// Si només n'hi ha una activa, segueix la seva velocitat.
Vec2 ship_vel_avg{.x = 0.0F, .y = 0.0F};
int n_active = 0;
for (const auto& ship : ships_) {
if (ship.isActive()) {
const Vec2 V = ship.getVelocityVector();
ship_vel_avg.x += V.x;
ship_vel_avg.y += V.y;
n_active++;
}
}
if (n_active > 0) {
ship_vel_avg.x /= static_cast<float>(n_active);
ship_vel_avg.y /= static_cast<float>(n_active);
}
starfield_parallax_.update(delta_time, Vec2{.x = -ship_vel_avg.x, .y = -ship_vel_avg.y});
playfield_.update(delta_time);
border_.update(delta_time);
// Notificar al playfield que la nau ha passat (per excitar línies properes).
for (const auto& ship : ships_) {
if (ship.isActive()) {
playfield_.notifyShipPass(ship.getCenter(), ship.getSpeed());
// Notificar al playfield que la nau es mou (genera ripples petites a cadència).
for (std::size_t id = 0; id < ships_.size(); id++) {
if (ships_[id].isActive()) {
playfield_.notifyShipMoving(static_cast<std::uint8_t>(id),
ships_[id].getCenter(),
ships_[id].getSpeed(),
delta_time);
}
}
}
@@ -566,6 +588,7 @@ void GameScene::drawActiveShipsAlive() const {
}
void GameScene::drawContinueState() {
starfield_parallax_.draw();
border_.draw();
drawEnemies();
drawBullets();
@@ -577,6 +600,7 @@ void GameScene::drawContinueState() {
}
void GameScene::drawGameOverState() {
starfield_parallax_.draw();
border_.draw();
drawEnemies();
drawBullets();
@@ -622,6 +646,8 @@ void GameScene::drawInitHudState() {
Defaults::Game::INIT_HUD_SHIP2_RATIO_INIT,
Defaults::Game::INIT_HUD_SHIP2_RATIO_END);
// Capa de fons més profunda: estrelles 2D (apareixen senceres des del frame 0).
starfield_parallax_.draw();
// Graella de fons al darrere (timer intern propi, cobreix tot l'INIT_HUD).
playfield_.draw();
@@ -647,6 +673,7 @@ void GameScene::drawInitHudState() {
}
void GameScene::drawLevelStartState() {
starfield_parallax_.draw();
playfield_.draw();
border_.draw();
trail_manager_.draw();
@@ -660,6 +687,7 @@ void GameScene::drawLevelStartState() {
}
void GameScene::drawPlayingState() {
starfield_parallax_.draw();
playfield_.draw();
border_.draw();
trail_manager_.draw();
@@ -673,6 +701,7 @@ void GameScene::drawPlayingState() {
}
void GameScene::drawLevelCompletedState() {
starfield_parallax_.draw();
playfield_.draw();
border_.draw();
trail_manager_.draw();
+4
View File
@@ -10,6 +10,7 @@
#include "core/graphics/border.hpp"
#include "core/graphics/playfield.hpp"
#include "core/graphics/starfield_parallax.hpp"
#include "core/graphics/vector_text.hpp"
#include "core/physics/physics_world.hpp"
#include "core/rendering/sdl_manager.hpp"
@@ -82,6 +83,9 @@ class GameScene final : public Scene {
// Text vectorial
Graphics::VectorText text_;
// Capa més profunda del fons: estrelles 2D amb parallax (estàtiques de moment).
Graphics::StarfieldParallax starfield_parallax_;
// Fons del playfield (graella + futures capes)
Graphics::Playfield playfield_;