Files
orni-attack/source/core/graphics/playfield.cpp
T

306 lines
13 KiB
C++

// playfield.cpp - Implementació del fons del playfield
// © 2026 JailDesigner
#include "core/graphics/playfield.hpp"
#include <algorithm>
#include <cmath>
#include <cstdlib>
#include <limits>
#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<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};
}
} // 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<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) {
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<float>(Defaults::Playfield::COLUMNS);
const float CELL_H = zona.h / static_cast<float>(Defaults::Playfield::ROWS);
const float SUB_W = CELL_W / static_cast<float>(Defaults::Playfield::SUBDIVISIONS);
const float SUB_H = CELL_H / static_cast<float>(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<Line> verticals;
std::vector<Line> horizontals;
// Verticals: posicions i ∈ [1, SUB_VERTS-1].
for (int i = 1; i < SUB_VERTS; i++) {
const float X = zona.x + (static_cast<float>(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<float>(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<int>(verticals.size());
const int NUM_H = static_cast<int>(horizontals.size());
const float INTERVAL_V = (NUM_V > 1) ? SPAWN_WINDOW / static_cast<float>(NUM_V - 1) : 0.0F;
const float INTERVAL_H = (NUM_H > 1) ? SPAWN_WINDOW / static_cast<float>(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<float>(i) * INTERVAL_V;
lines_.push_back(verticals[i]);
}
for (int i = 0; i < NUM_H; i++) {
horizontals[i].spawn_time_s = static_cast<float>(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<int>(START_X),
static_cast<int>(START_Y),
static_cast<int>(CURRENT_X),
static_cast<int>(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<int>(HEAD_X),
static_cast<int>(HEAD_Y),
static_cast<int>(CURRENT_X),
static_cast<int>(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<int>(P1_X),
static_cast<int>(P1_Y),
static_cast<int>(P2_X),
static_cast<int>(P2_Y),
1.0F,
0.0F,
SEG_COLOR);
}
}
}
} // namespace Graphics