Files
orni-attack/source/game/scenes/title_scene.cpp
T

805 lines
32 KiB
C++

// title_scene.cpp - Implementació de l'escena de títol 3D real
// © 2026 JailDesigner
#include "title_scene.hpp"
#include <algorithm>
#include <cfloat>
#include <cmath>
#include <iostream>
#include <numbers>
#include <string>
#include "core/audio/audio.hpp"
#include "core/defaults.hpp"
#include "core/graphics/shape_loader.hpp"
#include "core/input/define_inputs.hpp"
#include "core/input/input.hpp"
#include "core/locale/locale.hpp"
#include "core/math/easing.hpp"
#include "core/rendering/shape_renderer.hpp"
#include "core/system/scene_context.hpp"
#include "core/system/service_menu.hpp"
#include "game/systems/demo_pilot.hpp"
#include "project.h"
using SceneManager::SceneContext;
using SceneType = SceneContext::SceneType;
using Option = SceneContext::Option;
namespace {
// Botons per iniciar partida des de MAIN (només START).
constexpr std::array<InputAction, 1> START_GAME_BUTTONS = {InputAction::START};
} // namespace
TitleScene::TitleScene(SDLManager& sdl, SceneContext& context)
: sdl_(sdl),
context_(context),
text_(sdl.getRenderer()),
curtain_(sdl.getRenderer()) {
std::cout << "SceneType Titol: Inicialitzant...\n";
match_config_.player1_active = false;
match_config_.player2_active = false;
match_config_.mode = GameConfig::Mode::NORMAL;
auto option = context_.consumeOption();
if (option == Option::JUMP_TO_TITLE_MAIN) {
std::cout << "SceneType Titol: Opció JUMP_TO_TITLE_MAIN activada\n";
current_state_ = TitleState::MAIN;
state_time_main_ = 0.0F;
}
// Càmera 3D: posicionada a l'origen, mirant cap a +Z, amb Y cap amunt.
camera_ = std::make_unique<Graphics::Camera3D>(
Vec3{.x = 0.0F, .y = 0.0F, .z = 0.0F},
Vec3{.x = 0.0F, .y = 0.0F, .z = 1.0F},
Vec3{.x = 0.0F, .y = 1.0F, .z = 0.0F},
CAMERA_FOV_Y_RAD,
static_cast<float>(Defaults::Game::WIDTH),
static_cast<float>(Defaults::Game::HEIGHT));
starfield_ = std::make_unique<Graphics::Starfield>(
sdl_.getRenderer(),
camera_.get(),
200);
starfield_->setColor(Defaults::Title::Colors::STARFIELD);
if (current_state_ == TitleState::MAIN) {
starfield_->setBrightness(BRIGHTNESS_STARFIELD);
} else {
starfield_->setBrightness(0.0F);
}
ship_animator_ = std::make_unique<Title::ShipAnimator>(sdl_.getRenderer(), camera_.get());
ship_animator_->init();
// Les naus comencen invisibles; updateMainState() les dispara al moment
// correcte de la intro coreografiada (també quan venim de JUMP_TO_TITLE_MAIN).
ship_animator_->setVisible(false);
// Flash que tapa el "pop" final de la nau al VP. Es spawneja al centre
// de pantalla (= projecció del VP) quan ship_animator avisa.
flash_shape_ = Graphics::ShapeLoader::load("effect/title_flash.shp");
ship_animator_->setOnShipDisappear([this](int /*player_id*/) {
triggerFlash(Vec2{
.x = static_cast<float>(Defaults::Window::WIDTH) / 2.0F,
.y = static_cast<float>(Defaults::Window::HEIGHT) / 2.0F});
});
initTitle();
inicialitzarJailgames();
if (Audio::getMusicState() != Audio::MusicState::PLAYING) {
Audio::get()->playMusic("title.ogg");
}
}
TitleScene::~TitleScene() {
// Attract mode: si saltem a la demo, NO parem la música — ha de seguir
// sonant durant tot el cicle TÍTOL→DEMO→LOGO→TÍTOL. La resta de sortides
// (partida normal, EXIT) sí paren.
const bool ENTERING_DEMO =
context_.nextScene() == SceneType::GAME &&
context_.getMatchConfig().mode == GameConfig::Mode::DEMO;
if (!ENTERING_DEMO) {
Audio::get()->stopMusic();
}
}
void TitleScene::initTitle() {
using namespace Graphics;
const std::vector<std::string> FITXERS_ORNI = {
"title/letra_o.shp",
"title/letra_r.shp",
"title/letra_n.shp",
"title/letra_i.shp"};
float total_width_orni = 0.0F;
for (const auto& file : FITXERS_ORNI) {
auto shape = ShapeLoader::load(file);
if (!shape || !shape->isValid()) {
std::cerr << "[TitleScene] Error carregant " << file << '\n';
continue;
}
float min_x = FLT_MAX;
float max_x = -FLT_MAX;
float min_y = FLT_MAX;
float max_y = -FLT_MAX;
for (const auto& prim : shape->getPrimitives()) {
for (const auto& point : prim.points) {
min_x = std::min(min_x, point.x);
max_x = std::max(max_x, point.x);
min_y = std::min(min_y, point.y);
max_y = std::max(max_y, point.y);
}
}
const float WIDTH = (max_x - min_x) * Defaults::Title::Layout::LOGO_SCALE;
const float HEIGHT = (max_y - min_y) * Defaults::Title::Layout::LOGO_SCALE;
const float CENTER_OFFSET = (shape->getCenter().x - min_x) * Defaults::Title::Layout::LOGO_SCALE;
letters_orni_.push_back({shape, {.x = 0.0F, .y = 0.0F}, WIDTH, HEIGHT, CENTER_OFFSET});
total_width_orni += WIDTH;
}
total_width_orni += LETTER_SPACING * static_cast<float>(letters_orni_.size() - 1);
float x_actual = (Defaults::Game::WIDTH - total_width_orni) / 2.0F;
for (auto& letter : letters_orni_) {
letter.position.x = x_actual + letter.center_offset;
letter.position.y = Defaults::Game::HEIGHT * Defaults::Title::Layout::LOGO_POS;
x_actual += letter.width + LETTER_SPACING;
}
const float ORNI_HEIGHT = letters_orni_.empty() ? 50.0F : letters_orni_[0].height;
const float Y_ORNI = Defaults::Game::HEIGHT * Defaults::Title::Layout::LOGO_POS;
const float SEPARACION = Defaults::Game::HEIGHT * Defaults::Title::Layout::LOGO_LINE_SPACING;
dynamic_attack_y_ = Y_ORNI + ORNI_HEIGHT + SEPARACION;
const std::vector<std::string> FITXERS_ATTACK = {
"title/letra_a.shp",
"title/letra_t.shp",
"title/letra_t.shp",
"title/letra_a.shp",
"title/letra_c.shp",
"title/letra_k.shp",
"title/letra_exclamacion.shp"};
float total_width_attack = 0.0F;
for (const auto& file : FITXERS_ATTACK) {
auto shape = ShapeLoader::load(file);
if (!shape || !shape->isValid()) {
std::cerr << "[TitleScene] Error carregant " << file << '\n';
continue;
}
float min_x = FLT_MAX;
float max_x = -FLT_MAX;
float min_y = FLT_MAX;
float max_y = -FLT_MAX;
for (const auto& prim : shape->getPrimitives()) {
for (const auto& point : prim.points) {
min_x = std::min(min_x, point.x);
max_x = std::max(max_x, point.x);
min_y = std::min(min_y, point.y);
max_y = std::max(max_y, point.y);
}
}
const float WIDTH = (max_x - min_x) * Defaults::Title::Layout::LOGO_SCALE;
const float HEIGHT = (max_y - min_y) * Defaults::Title::Layout::LOGO_SCALE;
const float CENTER_OFFSET = (shape->getCenter().x - min_x) * Defaults::Title::Layout::LOGO_SCALE;
letters_attack_.push_back({shape, {.x = 0.0F, .y = 0.0F}, WIDTH, HEIGHT, CENTER_OFFSET});
total_width_attack += WIDTH;
}
total_width_attack += LETTER_SPACING * static_cast<float>(letters_attack_.size() - 1);
x_actual = (Defaults::Game::WIDTH - total_width_attack) / 2.0F;
for (auto& letter : letters_attack_) {
letter.position.x = x_actual + letter.center_offset;
letter.position.y = dynamic_attack_y_;
x_actual += letter.width + LETTER_SPACING;
}
original_positions_orni_.clear();
for (const auto& letter : letters_orni_) {
original_positions_orni_.push_back(letter.position);
}
original_positions_attack_.clear();
for (const auto& letter : letters_attack_) {
original_positions_attack_.push_back(letter.position);
}
}
void TitleScene::inicialitzarJailgames() {
using namespace Graphics;
const std::vector<std::string> FITXERS = {
"logo/letra_j.shp",
"logo/letra_a.shp",
"logo/letra_i.shp",
"logo/letra_l.shp",
"logo/letra_g.shp",
"logo/letra_a.shp",
"logo/letra_m.shp",
"logo/letra_e.shp",
"logo/letra_s.shp"};
constexpr float SCALE = Defaults::Title::Layout::JAILGAMES_SCALE;
float total_width = 0.0F;
float max_height = 0.0F;
for (const auto& file : FITXERS) {
auto shape = ShapeLoader::load(file);
if (!shape || !shape->isValid()) {
std::cerr << "[TitleScene] Error carregant " << file << '\n';
continue;
}
float min_x = FLT_MAX;
float max_x = -FLT_MAX;
float min_y = FLT_MAX;
float max_y = -FLT_MAX;
for (const auto& prim : shape->getPrimitives()) {
for (const auto& point : prim.points) {
min_x = std::min(min_x, point.x);
max_x = std::max(max_x, point.x);
min_y = std::min(min_y, point.y);
max_y = std::max(max_y, point.y);
}
}
const float WIDTH = (max_x - min_x) * SCALE;
const float HEIGHT = (max_y - min_y) * SCALE;
const float CENTER_OFFSET = (shape->getCenter().x - min_x) * SCALE;
letters_jailgames_.push_back({shape, {.x = 0.0F, .y = 0.0F}, WIDTH, HEIGHT, CENTER_OFFSET});
total_width += WIDTH;
max_height = std::max(max_height, HEIGHT);
}
constexpr float JAILGAMES_SPACING = LETTER_SPACING * SCALE;
if (!letters_jailgames_.empty()) {
total_width += JAILGAMES_SPACING * static_cast<float>(letters_jailgames_.size() - 1);
}
const float Y_COPY = Defaults::Game::HEIGHT * Defaults::Title::Layout::COPYRIGHT1_POS;
const float GAP = Defaults::Game::HEIGHT * Defaults::Title::Layout::JAILGAMES_COPYRIGHT_GAP;
const float Y_CENTRE = Y_COPY - GAP - (max_height / 2.0F);
const float X_INICIAL = (Defaults::Game::WIDTH - total_width) / 2.0F;
float x_actual = X_INICIAL;
for (auto& letter : letters_jailgames_) {
letter.position.x = x_actual + letter.center_offset;
letter.position.y = Y_CENTRE;
x_actual += letter.width + JAILGAMES_SPACING;
}
}
void TitleScene::dibuixarPeuTitol(float spacing) const {
namespace S = Defaults::Title::Sequence;
// Pivot al centre de pantalla (= projecció VP). Cada element s'expandeix
// des d'aquí mentre s_factor passa de SCALE_START (gran, prop de l'usuari)
// a 1.0 (a la mida i posició finals, "lluny" al VP).
const float SCREEN_CENTRE_X = Defaults::Game::WIDTH / 2.0F;
const float SCREEN_CENTRE_Y = Defaults::Game::HEIGHT / 2.0F;
// dive_zoom_ (attract) afegeix el zoom del dive per travessar el peu.
const float JAILGAMES_S = dive_zoom_ * std::lerp(S::FOOTER_INTRO_SCALE_START, 1.0F, Easing::easeOutQuad(intro_jailgames_progress_));
const float COPYRIGHT_S = dive_zoom_ * std::lerp(S::FOOTER_INTRO_SCALE_START, 1.0F, Easing::easeOutQuad(intro_copyright_progress_));
const float JAILGAMES_RENDER_SCALE = Defaults::Title::Layout::JAILGAMES_SCALE * JAILGAMES_S;
for (const auto& letter : letters_jailgames_) {
const Vec2 POS{
.x = SCREEN_CENTRE_X + (JAILGAMES_S * (letter.position.x - SCREEN_CENTRE_X)),
.y = SCREEN_CENTRE_Y + (JAILGAMES_S * (letter.position.y - SCREEN_CENTRE_Y)),
};
Rendering::renderShape(sdl_.getRenderer(), letter.shape, POS, 0.0F, JAILGAMES_RENDER_SCALE, 1.0F, 1.0F, Defaults::Title::Colors::JAILGAMES_LOGO);
}
std::string copyright = Project::COPYRIGHT;
for (char& c : copyright) {
if (c >= 'a' && c <= 'z') {
c = static_cast<char>(c - 32);
}
}
const float Y_COPY_FINAL = Defaults::Game::HEIGHT * Defaults::Title::Layout::COPYRIGHT1_POS;
const float COPY_X = SCREEN_CENTRE_X; // ja al centre
const float COPY_Y = SCREEN_CENTRE_Y + (COPYRIGHT_S * (Y_COPY_FINAL - SCREEN_CENTRE_Y));
const float COPY_RENDER_SCALE = Defaults::Title::Layout::COPYRIGHT_SCALE * COPYRIGHT_S;
text_.renderCentered(copyright, {.x = COPY_X, .y = COPY_Y}, COPY_RENDER_SCALE, spacing, 1.0F, Defaults::Title::Colors::COPYRIGHT);
}
auto TitleScene::isFinished() const -> bool {
return context_.nextScene() != SceneType::TITLE;
}
void TitleScene::update(float delta_time) {
if (starfield_) {
starfield_->update(delta_time);
}
if (ship_animator_ &&
(current_state_ == TitleState::STARFIELD_FADE_IN ||
current_state_ == TitleState::STARFIELD ||
current_state_ == TitleState::MAIN ||
current_state_ == TitleState::PLAYER_JOIN_PHASE)) {
ship_animator_->update(delta_time);
}
updateFlashes(delta_time);
switch (current_state_) {
case TitleState::STARFIELD_FADE_IN:
updateStarfieldFadeInState(delta_time);
break;
case TitleState::STARFIELD:
updateStarfieldState(delta_time);
break;
case TitleState::MAIN:
updateMainState(delta_time);
break;
case TitleState::PLAYER_JOIN_PHASE:
updatePlayerJoinPhaseState(delta_time);
break;
case TitleState::BLACK_SCREEN:
updateBlackScreenState(delta_time);
break;
case TitleState::DEMO_DIVE:
updateDemoDiveState(delta_time);
break;
case TitleState::DEMO_CURTAIN:
updateDemoCurtainState(delta_time);
break;
}
// Les animacions segueixen pero els inputs es bloquegen mentre el menu
// de servei o l'overlay de redefinicio estiguin actius: en cas contrari,
// SDL_GetKeyboardState i SDL_GetGamepadButton segueixen veient les tecles
// pulsades i podrien disparar handleSkipInput/handleStartInput sense
// intencio. Mateixa logica: per a GameScene tota la pausa es global,
// pero a TitleScene nomes guardem els polls d'input.
const auto* menu = System::ServiceMenu::get();
const auto* di = System::DefineInputs::get();
const bool INPUT_BLOCKED = (menu != nullptr && menu->isOpen()) ||
(di != nullptr && di->isActive());
if (!INPUT_BLOCKED) {
handleSkipInput();
handleStartInput();
}
// Attract mode: al state MAIN, acumular inactivitat; qualsevol botó
// arcade la reseteja. En esgotar el timeout, saltar a la demo (mode DEMO,
// P1 actiu) sense fer fadeOut de la música (a diferència del START real).
if (current_state_ == TitleState::MAIN && !INPUT_BLOCKED) {
if (Input::get()->checkAnyPlayerAction(ARCADE_BUTTONS, Input::ALLOW_REPEAT)) {
idle_timer_ = 0.0F;
} else {
idle_timer_ += delta_time;
}
if (idle_timer_ >= TITLE_DEMO_TIMEOUT) {
// L'escenari curat (mateix índex que llegirà GameScene) decideix
// quants jugadors IA hi ha. No avancem l'índex ací: ho fa GameScene.
const Systems::Demo::Scenario SC = Systems::Demo::scenario(context_.demoScenarioIndex());
GameConfig::MatchConfig demo_cfg;
demo_cfg.player1_active = true;
demo_cfg.player2_active = (SC.players >= 2);
demo_cfg.mode = GameConfig::Mode::DEMO;
context_.setMatchConfig(demo_cfg);
// No saltem en sec: primer un "dive" de càmera cap al punt de fuga
// (deixa enrere títol/naus/logo) i després la cortinilla. L'estat
// deixa de ser MAIN, així que ni es re-dispara la demo ni s'accepta
// START durant la transició. Amaguem ja el "PREMEU START".
press_start_visible_ = false;
current_state_ = TitleState::DEMO_DIVE;
dive_time_ = 0.0F;
dive_zoom_ = 1.0F;
temps_acumulat_ = 0.0F;
}
}
}
void TitleScene::updateStarfieldFadeInState(float delta_time) {
temps_acumulat_ += delta_time;
const float PROGRESS = std::min(1.0F, temps_acumulat_ / DURATION_FADE_IN);
starfield_->setBrightness(PROGRESS * BRIGHTNESS_STARFIELD);
if (temps_acumulat_ >= DURATION_FADE_IN) {
current_state_ = TitleState::STARFIELD;
temps_acumulat_ = 0.0F;
starfield_->setBrightness(BRIGHTNESS_STARFIELD);
}
}
void TitleScene::updateStarfieldState(float delta_time) {
temps_acumulat_ += delta_time;
if (temps_acumulat_ >= DURATION_INIT) {
current_state_ = TitleState::MAIN;
state_time_main_ = 0.0F;
animation_active_ = false;
lerp_factor_ = 0.0F;
}
}
void TitleScene::updateMainState(float delta_time) {
state_time_main_ += delta_time;
namespace S = Defaults::Title::Sequence;
namespace Sh = Defaults::Title::Ships;
// Thresholds derivats de la coreografia (vegeu Defaults::Title::Sequence).
constexpr float T_FOOTER_START = S::LOGO_ENTRY_DURATION;
constexpr float T_COPY_START = T_FOOTER_START + S::COPYRIGHT_STAGGER;
constexpr float T_JAILGAMES_END = T_FOOTER_START + S::JAILGAMES_ENTRY_DURATION;
constexpr float T_COPYRIGHT_END = T_COPY_START + S::COPYRIGHT_ENTRY_DURATION;
constexpr float T_FOOTER_END = std::max(T_JAILGAMES_END, T_COPYRIGHT_END);
constexpr float T_SHIPS_START = T_FOOTER_END + S::SHIPS_DELAY_AFTER_FOOTER;
constexpr float T_SHIPS_LANDED = T_SHIPS_START + Sh::ENTRY_DURATION + Sh::P2_ENTRY_DELAY;
constexpr float T_PRESS_START_VISIBLE = T_SHIPS_LANDED + S::PRESS_START_DELAY_AFTER_SHIPS;
intro_logo_progress_ = std::clamp(state_time_main_ / S::LOGO_ENTRY_DURATION, 0.0F, 1.0F);
intro_jailgames_progress_ = std::clamp(
(state_time_main_ - T_FOOTER_START) / S::JAILGAMES_ENTRY_DURATION,
0.0F,
1.0F);
intro_copyright_progress_ = std::clamp(
(state_time_main_ - T_COPY_START) / S::COPYRIGHT_ENTRY_DURATION,
0.0F,
1.0F);
if (!ships_intro_launched_ && state_time_main_ >= T_SHIPS_START &&
ship_animator_ != nullptr) {
ship_animator_->setVisible(true);
ship_animator_->startEntryAnimation();
ships_intro_launched_ = true;
}
if (!press_start_visible_ && state_time_main_ >= T_PRESS_START_VISIBLE) {
press_start_visible_ = true;
}
// L'oscil·lació suau del logo arrenca quan el logo ha aterrat. Així
// l'amplitud creix gradualment (lerp) durant la resta de la intro.
if (state_time_main_ < S::LOGO_ENTRY_DURATION) {
lerp_factor_ = 0.0F;
animation_active_ = false;
} else if (state_time_main_ < S::LOGO_ENTRY_DURATION + DURATION_LERP) {
lerp_factor_ = (state_time_main_ - S::LOGO_ENTRY_DURATION) / DURATION_LERP;
animation_active_ = true;
} else {
lerp_factor_ = 1.0F;
animation_active_ = true;
}
updateLogoAnimation(delta_time);
}
void TitleScene::updatePlayerJoinPhaseState(float delta_time) {
temps_acumulat_ += delta_time;
updateLogoAnimation(delta_time);
const bool P1_ABANS = match_config_.player1_active;
const bool P2_ABANS = match_config_.player2_active;
if (checkStartGameButtonPressed()) {
context_.setMatchConfig(match_config_);
triggerExitForJoinedPlayers(P1_ABANS, P2_ABANS, "late join - ");
Audio::get()->playSound(Defaults::Sound::START, Audio::Group::GAME);
temps_acumulat_ = 0.0F;
}
if (temps_acumulat_ >= DURATION_TRANSITION) {
current_state_ = TitleState::BLACK_SCREEN;
temps_acumulat_ = 0.0F;
}
}
void TitleScene::updateBlackScreenState(float delta_time) {
temps_acumulat_ += delta_time;
if (temps_acumulat_ >= DURATION_BLACK_SCREEN) {
context_.setNextScene(SceneType::GAME);
}
}
void TitleScene::advanceDive(float delta_time) {
namespace D = Defaults::Game::Dive;
dive_time_ += delta_time;
// T SENSE topall: easeInQuad (=T²) fa que la posició creixi quadràticament,
// és a dir, la velocitat creix linealment → acceleració constant que no
// s'atura. CAMERA_DISTANCE i ZOOM_MAX deixen de ser límits: són el valor
// assolit en l'instant de referència (T=1, quan cau la cortinilla) i a
// partir d'ací el moviment continua accelerant (std::lerp extrapola).
const float T = dive_time_ / D::DURATION;
const float EASED = Easing::easeInQuad(T);
// Càmera 3D real cap a +Z: starfield i naus es projecten amb la càmera, així
// que les estrelles es rasguen i les naus creixen i s'escapen pels costats.
// IMPORTANT: cal moure posició I target alhora; si només es mou la posició,
// forward = (target - position) s'inverteix en passar el target i la càmera
// gira cap enrere (tot apareix invertit i mirant a l'espectador).
if (camera_ != nullptr) {
const float Z = EASED * D::CAMERA_DISTANCE;
camera_->setPosition(Vec3{.x = 0.0F, .y = 0.0F, .z = Z});
camera_->setTarget(Vec3{.x = 0.0F, .y = 0.0F, .z = Z + 1.0F});
}
// Logo i peu són 2D: els fakegem el dive amb un zoom des del centre.
dive_zoom_ = std::lerp(1.0F, D::ZOOM_MAX, EASED);
}
void TitleScene::updateDemoDiveState(float delta_time) {
advanceDive(delta_time);
// En l'instant de referència (dive_time_ == DURATION) cau la cortinilla,
// però la càmera NO s'atura: segueix accelerant a DEMO_CURTAIN.
if (dive_time_ >= Defaults::Game::Dive::DURATION) {
current_state_ = TitleState::DEMO_CURTAIN;
curtain_.cover(Defaults::Game::Curtain::COVER_DURATION);
}
}
void TitleScene::updateDemoCurtainState(float delta_time) {
advanceDive(delta_time); // la càmera continua accelerant sota la tela
curtain_.update(delta_time);
if (curtain_.isDone()) {
context_.setNextScene(SceneType::GAME);
}
}
void TitleScene::handleSkipInput() {
if (current_state_ != TitleState::STARFIELD_FADE_IN && current_state_ != TitleState::STARFIELD) {
return;
}
if (!checkSkipButtonPressed()) {
return;
}
current_state_ = TitleState::MAIN;
starfield_->setBrightness(BRIGHTNESS_STARFIELD);
// Saltar la intro coreografiada: deixar tots els elements ja in-place.
namespace S = Defaults::Title::Sequence;
namespace Sh = Defaults::Title::Ships;
constexpr float T_FOOTER_END = std::max(
S::LOGO_ENTRY_DURATION + S::JAILGAMES_ENTRY_DURATION,
S::LOGO_ENTRY_DURATION + S::COPYRIGHT_STAGGER + S::COPYRIGHT_ENTRY_DURATION);
constexpr float T_PRESS_START_VISIBLE = T_FOOTER_END + S::SHIPS_DELAY_AFTER_FOOTER +
Sh::ENTRY_DURATION + Sh::P2_ENTRY_DELAY + S::PRESS_START_DELAY_AFTER_SHIPS;
state_time_main_ = T_PRESS_START_VISIBLE;
intro_logo_progress_ = 1.0F;
intro_jailgames_progress_ = 1.0F;
intro_copyright_progress_ = 1.0F;
press_start_visible_ = true;
ships_intro_launched_ = true;
if (ship_animator_ != nullptr) {
ship_animator_->setVisible(true);
ship_animator_->startEntryAnimation();
}
}
void TitleScene::handleStartInput() {
if (current_state_ != TitleState::MAIN) {
return;
}
// No acceptar START fins que la intro coreografiada haja conclòs i el
// text "PRESS START TO PLAY" siga visible.
if (!press_start_visible_) {
return;
}
const bool P1_ABANS = match_config_.player1_active;
const bool P2_ABANS = match_config_.player2_active;
if (!checkStartGameButtonPressed()) {
return;
}
if (ship_animator_ && !ship_animator_->isVisible()) {
ship_animator_->setVisible(true);
ship_animator_->skipToFloatingState();
}
context_.setMatchConfig(match_config_);
current_state_ = TitleState::PLAYER_JOIN_PHASE;
temps_acumulat_ = 0.0F;
triggerExitForJoinedPlayers(P1_ABANS, P2_ABANS, "");
Audio::get()->fadeOutMusic(MUSIC_FADE);
Audio::get()->playSound(Defaults::Sound::START, Audio::Group::GAME);
}
void TitleScene::triggerExitForJoinedPlayers(bool p1_was_active, bool p2_was_active, const char* log_prefix) {
if (ship_animator_ == nullptr) {
return;
}
if (match_config_.player1_active && !p1_was_active) {
ship_animator_->triggerExitAnimationForPlayer(1);
std::cout << "[TitleScene] P1 " << log_prefix << "ship exiting\n";
}
if (match_config_.player2_active && !p2_was_active) {
ship_animator_->triggerExitAnimationForPlayer(2);
std::cout << "[TitleScene] P2 " << log_prefix << "ship exiting\n";
}
}
void TitleScene::updateLogoAnimation(float delta_time) {
if (!animation_active_) {
return;
}
animation_time_ += delta_time * lerp_factor_;
const float TWO_PI = 2.0F * Defaults::Math::PI;
const float OFFSET_X = ORBIT_AMPLITUDE_X * std::sin(TWO_PI * ORBIT_FREQUENCY_X * animation_time_);
const float OFFSET_Y = ORBIT_AMPLITUDE_Y * std::sin((TWO_PI * ORBIT_FREQUENCY_Y * animation_time_) + ORBIT_PHASE_OFFSET);
for (std::size_t i = 0; i < letters_orni_.size(); ++i) {
letters_orni_[i].position.x = original_positions_orni_[i].x + std::round(OFFSET_X);
letters_orni_[i].position.y = original_positions_orni_[i].y + std::round(OFFSET_Y);
}
for (std::size_t i = 0; i < letters_attack_.size(); ++i) {
letters_attack_[i].position.x = original_positions_attack_[i].x + std::round(OFFSET_X);
letters_attack_[i].position.y = original_positions_attack_[i].y + std::round(OFFSET_Y);
}
}
void TitleScene::draw() {
if (starfield_ && current_state_ != TitleState::BLACK_SCREEN) {
starfield_->draw();
}
if (ship_animator_ &&
(current_state_ == TitleState::STARFIELD_FADE_IN ||
current_state_ == TitleState::STARFIELD ||
current_state_ == TitleState::MAIN ||
current_state_ == TitleState::PLAYER_JOIN_PHASE ||
current_state_ == TitleState::DEMO_DIVE ||
current_state_ == TitleState::DEMO_CURTAIN)) {
ship_animator_->draw();
}
drawFlashes();
if (current_state_ == TitleState::STARFIELD_FADE_IN || current_state_ == TitleState::STARFIELD) {
return;
}
// DEMO_DIVE/DEMO_CURTAIN es pinten com MAIN (logo + peu) perquè el títol
// segueixi visible sota el dive i la cortinilla.
if (current_state_ != TitleState::MAIN &&
current_state_ != TitleState::PLAYER_JOIN_PHASE &&
current_state_ != TitleState::DEMO_DIVE &&
current_state_ != TitleState::DEMO_CURTAIN) {
curtain_.draw(); // BLACK_SCREEN i altres: només la cortinilla (si activa)
return;
}
// Factor d'escala+posició per simular un moviment 3D des de l'usuari (prop,
// sprite gran i posició projectada extrema) cap al VP (lluny, sprite a la
// seva mida i posició finals). Pivot: centre de pantalla (= projecció VP).
// El dive (attract) hi afegeix un zoom extra (dive_zoom_) per travessar-los.
const float LOGO_S = dive_zoom_ * std::lerp(Defaults::Title::Sequence::LOGO_INTRO_SCALE_START, 1.0F, Easing::easeOutQuad(intro_logo_progress_));
const float SCREEN_CENTRE_X = Defaults::Game::WIDTH / 2.0F;
const float SCREEN_CENTRE_Y = Defaults::Game::HEIGHT / 2.0F;
const float LOGO_RENDER_SCALE = Defaults::Title::Layout::LOGO_SCALE * LOGO_S;
if (animation_active_) {
float temps_shadow = std::max(0.0F, animation_time_ - SHADOW_DELAY);
const float TWO_PI = 2.0F * Defaults::Math::PI;
const float SHADOW_OX = (ORBIT_AMPLITUDE_X * std::sin(TWO_PI * ORBIT_FREQUENCY_X * temps_shadow)) + SHADOW_OFFSET_X;
const float SHADOW_OY = (ORBIT_AMPLITUDE_Y * std::sin((TWO_PI * ORBIT_FREQUENCY_Y * temps_shadow) + ORBIT_PHASE_OFFSET)) + SHADOW_OFFSET_Y;
for (std::size_t i = 0; i < letters_orni_.size(); ++i) {
const float BASE_X = original_positions_orni_[i].x + std::round(SHADOW_OX);
const float BASE_Y = original_positions_orni_[i].y + std::round(SHADOW_OY);
const Vec2 POS_SHADOW{
.x = SCREEN_CENTRE_X + (LOGO_S * (BASE_X - SCREEN_CENTRE_X)),
.y = SCREEN_CENTRE_Y + (LOGO_S * (BASE_Y - SCREEN_CENTRE_Y)),
};
Rendering::renderShape(sdl_.getRenderer(), letters_orni_[i].shape, POS_SHADOW, 0.0F, LOGO_RENDER_SCALE, 1.0F, SHADOW_BRIGHTNESS, Defaults::Title::Colors::LOGO_SHADOW);
}
for (std::size_t i = 0; i < letters_attack_.size(); ++i) {
const float BASE_X = original_positions_attack_[i].x + std::round(SHADOW_OX);
const float BASE_Y = original_positions_attack_[i].y + std::round(SHADOW_OY);
const Vec2 POS_SHADOW{
.x = SCREEN_CENTRE_X + (LOGO_S * (BASE_X - SCREEN_CENTRE_X)),
.y = SCREEN_CENTRE_Y + (LOGO_S * (BASE_Y - SCREEN_CENTRE_Y)),
};
Rendering::renderShape(sdl_.getRenderer(), letters_attack_[i].shape, POS_SHADOW, 0.0F, LOGO_RENDER_SCALE, 1.0F, SHADOW_BRIGHTNESS, Defaults::Title::Colors::LOGO_SHADOW);
}
}
for (const auto& letter : letters_orni_) {
const Vec2 POS{
.x = SCREEN_CENTRE_X + (LOGO_S * (letter.position.x - SCREEN_CENTRE_X)),
.y = SCREEN_CENTRE_Y + (LOGO_S * (letter.position.y - SCREEN_CENTRE_Y)),
};
Rendering::renderShape(sdl_.getRenderer(), letter.shape, POS, 0.0F, LOGO_RENDER_SCALE, 1.0F, 1.0F, Defaults::Title::Colors::LOGO_MAIN);
}
for (const auto& letter : letters_attack_) {
const Vec2 POS{
.x = SCREEN_CENTRE_X + (LOGO_S * (letter.position.x - SCREEN_CENTRE_X)),
.y = SCREEN_CENTRE_Y + (LOGO_S * (letter.position.y - SCREEN_CENTRE_Y)),
};
Rendering::renderShape(sdl_.getRenderer(), letter.shape, POS, 0.0F, LOGO_RENDER_SCALE, 1.0F, 1.0F, Defaults::Title::Colors::LOGO_MAIN);
}
const float SPACING = Defaults::Title::Layout::TEXT_SPACING;
if (press_start_visible_) {
bool mostrar_text = true;
if (current_state_ == TitleState::PLAYER_JOIN_PHASE) {
const float FASE = temps_acumulat_ * BLINK_FREQUENCY * 2.0F * std::numbers::pi_v<float>;
mostrar_text = (std::sin(FASE) > 0.0F);
}
if (mostrar_text) {
const std::string MAIN_TEXT = Locale::get().text("title.press_start");
const float MAIN_SCALE = Defaults::Title::Layout::PRESS_START_SCALE;
const float CENTRE_X = Defaults::Game::WIDTH / 2.0F;
const float CENTRE_Y = Defaults::Game::HEIGHT * Defaults::Title::Layout::PRESS_START_POS;
text_.renderCentered(MAIN_TEXT, {.x = CENTRE_X, .y = CENTRE_Y}, MAIN_SCALE, SPACING, 1.0F, Defaults::Title::Colors::PRESS_START);
}
}
dibuixarPeuTitol(SPACING);
// Cortinilla negra (attract): per damunt de tot. No-op si no està activa.
curtain_.draw();
}
auto TitleScene::checkSkipButtonPressed() -> bool {
return Input::get()->checkAnyPlayerAction(ARCADE_BUTTONS);
}
auto TitleScene::checkStartGameButtonPressed() -> bool {
auto* input = Input::get();
bool any_pressed = false;
for (auto action : START_GAME_BUTTONS) {
if (input->checkActionPlayer1(action, Input::DO_NOT_ALLOW_REPEAT)) {
if (!match_config_.player1_active) {
match_config_.player1_active = true;
any_pressed = true;
}
}
if (input->checkActionPlayer2(action, Input::DO_NOT_ALLOW_REPEAT)) {
if (!match_config_.player2_active) {
match_config_.player2_active = true;
any_pressed = true;
}
}
}
return any_pressed;
}
void TitleScene::handleEvent(const SDL_Event& event) {
(void)event;
}
namespace {
constexpr float FLASH_DURATION = 0.40F;
constexpr float FLASH_MAX_SCALE = 2.5F;
constexpr SDL_Color FLASH_COLOR = {.r = 255, .g = 255, .b = 255, .a = 255};
} // namespace
void TitleScene::triggerFlash(Vec2 pos) {
for (auto& f : flashes_) {
if (!f.active) {
f.active = true;
f.position = pos;
f.timer = 0.0F;
return;
}
}
}
void TitleScene::updateFlashes(float delta_time) {
for (auto& f : flashes_) {
if (!f.active) {
continue;
}
f.timer += delta_time;
if (f.timer >= FLASH_DURATION) {
f.active = false;
}
}
}
void TitleScene::drawFlashes() {
if (!flash_shape_) {
return;
}
for (const auto& f : flashes_) {
if (!f.active) {
continue;
}
// Escala 0 → max al midpoint → 0. Sinus simètric.
const float T_NORM = f.timer / FLASH_DURATION;
const float SCALE = FLASH_MAX_SCALE * std::sin(T_NORM * Defaults::Math::PI);
Rendering::renderShape(sdl_.getRenderer(), flash_shape_, f.position, 0.0F, SCALE, 1.0F, 1.0F, FLASH_COLOR);
}
}