// 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); } // 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(a) * (1.0F - CLAMPED)) + (static_cast(b) * CLAMPED); return static_cast(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}; } } // namespace Playfield::Playfield(Rendering::Renderer* renderer) : renderer_(renderer) { buildLines(); } 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; } } } } 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; } } // 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); } 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::max(); float min_dy = std::numeric_limits::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) { 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; } } } 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, .orbit_amplitude = 0.0F, .orbit_phase = 0.0F, .pulses = {}}); } // 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, .orbit_amplitude = 0.0F, .orbit_phase = 0.0F, .pulses = {}}); } // 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 { for (const auto& line : lines_) { const float RAW_P = computeLineProgress(line); if (RAW_P <= 0.0F) { continue; } const float P = easeOutCubic(RAW_P); // 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). Rendering::linea( renderer_, static_cast(START_X), static_cast(START_Y), static_cast(CURRENT_X), static_cast(CURRENT_Y), line.brightness); // Cap brillant mentre creix: l'últim tram de la línia es repinta més brillant. 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(HEAD_X), static_cast(HEAD_Y), static_cast(CURRENT_X), static_cast(CURRENT_Y), Defaults::Playfield::HEAD_BRIGHTNESS); } } // 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(P1_X), static_cast(P1_Y), static_cast(P2_X), static_cast(P2_Y), 1.0F, 0.0F, SEG_COLOR); } } } } // namespace Graphics