// playfield.cpp - Implementació del fons del playfield // © 2026 JailDesigner #include "core/graphics/playfield.hpp" #include #include #include #include #include "core/defaults.hpp" #include "core/rendering/line_renderer.hpp" namespace Graphics { namespace { // Easing cubic-out: t → 1 - (1-t)^3. Decelera prop del final. auto easeOutCubic(float t) -> float { const float INV = 1.0F - t; return 1.0F - (INV * INV * INV); } auto randUniform(float min_v, float max_v) -> float { const float NORM = static_cast(std::rand()) / static_cast(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 Playfield::Playfield(Rendering::Renderer* renderer) : renderer_(renderer) { buildLines(); } void Playfield::update(float delta_time) { elapsed_s_ += delta_time; for (auto& ripple : ripples_) { if (!ripple.active) { continue; } ripple.age_s += delta_time; if (ripple.age_s >= ripple.lifetime_s) { ripple.active = false; } } } 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; } } return oldest; // pool ple: substituïm la més vella } void Playfield::spawnBig(Vec2 pos) { Ripple* r = findFreeRipple(); if (r == nullptr) { return; } 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() { const SDL_FRect& zona = Defaults::Zones::PLAYAREA; const float CELL_W = zona.w / static_cast(Defaults::Playfield::COLUMNS); const float CELL_H = zona.h / static_cast(Defaults::Playfield::ROWS); const float SUB_W = CELL_W / static_cast(Defaults::Playfield::SUBDIVISIONS); const float SUB_H = CELL_H / static_cast(Defaults::Playfield::SUBDIVISIONS); const int SUB_VERTS = Defaults::Playfield::COLUMNS * Defaults::Playfield::SUBDIVISIONS; const int SUB_HORIZ = Defaults::Playfield::ROWS * Defaults::Playfield::SUBDIVISIONS; std::vector verticals; std::vector horizontals; // Verticals: posicions i ∈ [1, SUB_VERTS-1]. for (int i = 1; i < SUB_VERTS; i++) { const float X = zona.x + (static_cast(i) * SUB_W); const bool IS_MAIN = (i % Defaults::Playfield::SUBDIVISIONS) == 0; const float BRIGHTNESS = IS_MAIN ? Defaults::Playfield::GRID_BRIGHTNESS : Defaults::Playfield::SUBGRID_BRIGHTNESS; verticals.push_back(Line{ .start = {.x = X, .y = zona.y}, .end = {.x = X, .y = zona.y + zona.h}, .brightness = BRIGHTNESS, .spawn_time_s = 0.0F, .is_vertical = true}); } // Horitzontals: posicions j ∈ [1, SUB_HORIZ-1]. for (int j = 1; j < SUB_HORIZ; j++) { const float Y = zona.y + (static_cast(j) * SUB_H); const bool IS_MAIN = (j % Defaults::Playfield::SUBDIVISIONS) == 0; const float BRIGHTNESS = IS_MAIN ? Defaults::Playfield::GRID_BRIGHTNESS : Defaults::Playfield::SUBGRID_BRIGHTNESS; horizontals.push_back(Line{ .start = {.x = zona.x, .y = Y}, .end = {.x = zona.x + zona.w, .y = Y}, .brightness = BRIGHTNESS, .spawn_time_s = 0.0F, .is_vertical = false}); } // Ona diagonal: la línia esquerra/superior naix a t=0 i les següents // propaguen cap a la dreta/inferior, en paral·lel. Verticals i // horitzontals comparteixen la finestra temporal així el front arriba // a la cantonada inferior-dreta alhora. const float SPAWN_WINDOW = Defaults::Playfield::TOTAL_ANIMATION_DURATION_S - Defaults::Playfield::LINE_GROWTH_DURATION_S; const int NUM_V = static_cast(verticals.size()); const int NUM_H = static_cast(horizontals.size()); const float INTERVAL_V = (NUM_V > 1) ? SPAWN_WINDOW / static_cast(NUM_V - 1) : 0.0F; const float INTERVAL_H = (NUM_H > 1) ? SPAWN_WINDOW / static_cast(NUM_H - 1) : 0.0F; lines_.clear(); lines_.reserve(verticals.size() + horizontals.size()); for (int i = 0; i < NUM_V; i++) { verticals[i].spawn_time_s = static_cast(i) * INTERVAL_V; lines_.push_back(verticals[i]); } for (int i = 0; i < NUM_H; i++) { horizontals[i].spawn_time_s = static_cast(i) * INTERVAL_H; lines_.push_back(horizontals[i]); } } auto Playfield::computeLineProgress(const Line& line) const -> float { const float LINE_ELAPSED = elapsed_s_ - line.spawn_time_s; return std::clamp(LINE_ELAPSED / Defaults::Playfield::LINE_GROWTH_DURATION_S, 0.0F, 1.0F); } void Playfield::draw() const { // Recollir ripples actives (punters per accés ràpid al hot loop). std::array active{}; int n_active = 0; for (const auto& ripple : ripples_) { if (ripple.active) { active[n_active++] = &ripple; } } for (const auto& line : lines_) { 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 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; } hits[n_hits++] = &r; } if (n_hits == 0) { // Camí ràpid: una sola crida com abans. Rendering::linea( renderer_, static_cast(START_X), static_cast(START_Y), static_cast(END_X), static_cast(END_Y), line.brightness); // 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)); Rendering::linea( renderer_, static_cast(START_X + (DX * HEAD_T)), static_cast(START_Y + (DY * HEAD_T)), static_cast(END_X), static_cast(END_Y), Defaults::Playfield::HEAD_BRIGHTNESS); } } return; } // 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(i) / static_cast(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(prev_x), static_cast(prev_y), static_cast(NX), static_cast(NY), line.brightness); prev_x = NX; prev_y = NY; } } } // namespace Graphics