// title_scene.cpp - Implementació de l'escena de títol 3D real // © 2026 JailDesigner #include "title_scene.hpp" #include #include #include #include #include #include #include "core/audio/audio.hpp" #include "core/defaults.hpp" #include "core/graphics/shape_loader.hpp" #include "core/input/input.hpp" #include "core/rendering/shape_renderer.hpp" #include "core/system/scene_context.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 START_GAME_BUTTONS = {InputAction::START}; } // namespace TitleScene::TitleScene(SDLManager& sdl, SceneContext& context) : sdl_(sdl), context_(context), text_(sdl.getRenderer()) { std::cout << "SceneType Titol: Inicialitzant...\n"; match_config_.jugador1_actiu = false; match_config_.jugador2_actiu = 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"; estat_actual_ = TitleState::MAIN; temps_estat_main_ = 0.0F; } // Càmera 3D: posicionada a l'origen, mirant cap a +Z, amb Y cap amunt. camera_ = std::make_unique( 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(Defaults::Game::WIDTH), static_cast(Defaults::Game::HEIGHT)); starfield_ = std::make_unique( sdl_.getRenderer(), camera_.get(), 200); if (estat_actual_ == TitleState::MAIN) { starfield_->setBrightness(BRIGHTNESS_STARFIELD); } else { starfield_->setBrightness(0.0F); } ship_animator_ = std::make_unique(sdl_.getRenderer(), camera_.get()); ship_animator_->init(); if (estat_actual_ == TitleState::MAIN) { ship_animator_->setVisible(true); ship_animator_->startEntryAnimation(); } else { ship_animator_->setVisible(false); } initTitle(); inicialitzarJailgames(); if (Audio::getMusicState() != Audio::MusicState::PLAYING) { Audio::get()->playMusic("title.ogg"); } } TitleScene::~TitleScene() { Audio::get()->stopMusic(); } void TitleScene::initTitle() { using namespace Graphics; const std::vector FITXERS_ORNI = { "title/letra_o.shp", "title/letra_r.shp", "title/letra_n.shp", "title/letra_i.shp"}; float ancho_total_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 ANCHO = (max_x - min_x) * Defaults::Title::Layout::LOGO_SCALE; const float ALTURA = (max_y - min_y) * Defaults::Title::Layout::LOGO_SCALE; const float OFFSET_CENTRE = (shape->getCenter().x - min_x) * Defaults::Title::Layout::LOGO_SCALE; lletres_orni_.push_back({shape, {.x = 0.0F, .y = 0.0F}, ANCHO, ALTURA, OFFSET_CENTRE}); ancho_total_orni += ANCHO; } ancho_total_orni += ESPAI_ENTRE_LLETRES * static_cast(lletres_orni_.size() - 1); float x_actual = (Defaults::Game::WIDTH - ancho_total_orni) / 2.0F; for (auto& lletra : lletres_orni_) { lletra.position.x = x_actual + lletra.offset_centre; lletra.position.y = Defaults::Game::HEIGHT * Defaults::Title::Layout::LOGO_POS; x_actual += lletra.ancho + ESPAI_ENTRE_LLETRES; } const float ALTURA_ORNI = lletres_orni_.empty() ? 50.0F : lletres_orni_[0].altura; const float Y_ORNI = Defaults::Game::HEIGHT * Defaults::Title::Layout::LOGO_POS; const float SEPARACION = Defaults::Game::HEIGHT * Defaults::Title::Layout::LOGO_LINE_SPACING; y_attack_dinamica_ = Y_ORNI + ALTURA_ORNI + SEPARACION; const std::vector 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 ancho_total_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 ANCHO = (max_x - min_x) * Defaults::Title::Layout::LOGO_SCALE; const float ALTURA = (max_y - min_y) * Defaults::Title::Layout::LOGO_SCALE; const float OFFSET_CENTRE = (shape->getCenter().x - min_x) * Defaults::Title::Layout::LOGO_SCALE; lletres_attack_.push_back({shape, {.x = 0.0F, .y = 0.0F}, ANCHO, ALTURA, OFFSET_CENTRE}); ancho_total_attack += ANCHO; } ancho_total_attack += ESPAI_ENTRE_LLETRES * static_cast(lletres_attack_.size() - 1); x_actual = (Defaults::Game::WIDTH - ancho_total_attack) / 2.0F; for (auto& lletra : lletres_attack_) { lletra.position.x = x_actual + lletra.offset_centre; lletra.position.y = y_attack_dinamica_; x_actual += lletra.ancho + ESPAI_ENTRE_LLETRES; } posicions_originals_orni_.clear(); for (const auto& lletra : lletres_orni_) { posicions_originals_orni_.push_back(lletra.position); } posicions_originals_attack_.clear(); for (const auto& lletra : lletres_attack_) { posicions_originals_attack_.push_back(lletra.position); } } void TitleScene::inicialitzarJailgames() { using namespace Graphics; const std::vector 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 ancho_total = 0.0F; float altura_max = 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 ANCHO = (max_x - min_x) * SCALE; const float ALTURA = (max_y - min_y) * SCALE; const float OFFSET_CENTRE = (shape->getCenter().x - min_x) * SCALE; lletres_jailgames_.push_back({shape, {.x = 0.0F, .y = 0.0F}, ANCHO, ALTURA, OFFSET_CENTRE}); ancho_total += ANCHO; altura_max = std::max(altura_max, ALTURA); } constexpr float ESPAI_JAILGAMES = ESPAI_ENTRE_LLETRES * SCALE; if (!lletres_jailgames_.empty()) { ancho_total += ESPAI_JAILGAMES * static_cast(lletres_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 - (altura_max / 2.0F); const float X_INICIAL = (Defaults::Game::WIDTH - ancho_total) / 2.0F; float x_actual = X_INICIAL; for (auto& lletra : lletres_jailgames_) { lletra.position.x = x_actual + lletra.offset_centre; lletra.position.y = Y_CENTRE; x_actual += lletra.ancho + ESPAI_JAILGAMES; } } void TitleScene::dibuixarPeuTitol(float spacing) const { for (const auto& lletra : lletres_jailgames_) { Rendering::renderShape(sdl_.getRenderer(), lletra.shape, lletra.position, 0.0F, Defaults::Title::Layout::JAILGAMES_SCALE, 1.0F); } std::string copyright = Project::COPYRIGHT; for (char& c : copyright) { if (c >= 'a' && c <= 'z') { c = static_cast(c - 32); } } const float Y_COPY = Defaults::Game::HEIGHT * Defaults::Title::Layout::COPYRIGHT1_POS; const float CENTRE_X = Defaults::Game::WIDTH / 2.0F; text_.renderCentered(copyright, {.x = CENTRE_X, .y = Y_COPY}, Defaults::Title::Layout::COPYRIGHT_SCALE, spacing); } 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_ && (estat_actual_ == TitleState::STARFIELD_FADE_IN || estat_actual_ == TitleState::STARFIELD || estat_actual_ == TitleState::MAIN || estat_actual_ == TitleState::PLAYER_JOIN_PHASE)) { ship_animator_->update(delta_time); } switch (estat_actual_) { 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; } handleSkipInput(); handleStartInput(); } void TitleScene::updateStarfieldFadeInState(float delta_time) { temps_acumulat_ += delta_time; const float PROGRESS = std::min(1.0F, temps_acumulat_ / DURACIO_FADE_IN); starfield_->setBrightness(PROGRESS * BRIGHTNESS_STARFIELD); if (temps_acumulat_ >= DURACIO_FADE_IN) { estat_actual_ = TitleState::STARFIELD; temps_acumulat_ = 0.0F; starfield_->setBrightness(BRIGHTNESS_STARFIELD); } } void TitleScene::updateStarfieldState(float delta_time) { temps_acumulat_ += delta_time; if (temps_acumulat_ >= DURACIO_INIT) { estat_actual_ = TitleState::MAIN; temps_estat_main_ = 0.0F; animacio_activa_ = false; factor_lerp_ = 0.0F; } } void TitleScene::updateMainState(float delta_time) { temps_estat_main_ += delta_time; if (temps_estat_main_ >= Defaults::Title::Ships::ENTRANCE_DELAY && ship_animator_ && !ship_animator_->isVisible()) { ship_animator_->setVisible(true); ship_animator_->startEntryAnimation(); } if (temps_estat_main_ < DELAY_INICI_ANIMACIO) { factor_lerp_ = 0.0F; animacio_activa_ = false; } else if (temps_estat_main_ < DELAY_INICI_ANIMACIO + DURACIO_LERP) { const float TEMPS_LERP = temps_estat_main_ - DELAY_INICI_ANIMACIO; factor_lerp_ = TEMPS_LERP / DURACIO_LERP; animacio_activa_ = true; } else { factor_lerp_ = 1.0F; animacio_activa_ = true; } updateLogoAnimation(delta_time); } void TitleScene::updatePlayerJoinPhaseState(float delta_time) { temps_acumulat_ += delta_time; updateLogoAnimation(delta_time); const bool P1_ABANS = match_config_.jugador1_actiu; const bool P2_ABANS = match_config_.jugador2_actiu; 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_ >= DURACIO_TRANSITION) { estat_actual_ = TitleState::BLACK_SCREEN; temps_acumulat_ = 0.0F; } } void TitleScene::updateBlackScreenState(float delta_time) { temps_acumulat_ += delta_time; if (temps_acumulat_ >= DURACIO_BLACK_SCREEN) { context_.setNextScene(SceneType::GAME); } } void TitleScene::handleSkipInput() { if (estat_actual_ != TitleState::STARFIELD_FADE_IN && estat_actual_ != TitleState::STARFIELD) { return; } if (!checkSkipButtonPressed()) { return; } estat_actual_ = TitleState::MAIN; starfield_->setBrightness(BRIGHTNESS_STARFIELD); temps_estat_main_ = 0.0F; } void TitleScene::handleStartInput() { if (estat_actual_ != TitleState::MAIN) { return; } const bool P1_ABANS = match_config_.jugador1_actiu; const bool P2_ABANS = match_config_.jugador2_actiu; if (!checkStartGameButtonPressed()) { return; } if (ship_animator_ && !ship_animator_->isVisible()) { ship_animator_->setVisible(true); ship_animator_->skipToFloatingState(); } context_.setMatchConfig(match_config_); estat_actual_ = 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_.jugador1_actiu && !p1_was_active) { ship_animator_->triggerExitAnimationForPlayer(1); std::cout << "[TitleScene] P1 " << log_prefix << "ship exiting\n"; } if (match_config_.jugador2_actiu && !p2_was_active) { ship_animator_->triggerExitAnimationForPlayer(2); std::cout << "[TitleScene] P2 " << log_prefix << "ship exiting\n"; } } void TitleScene::updateLogoAnimation(float delta_time) { if (!animacio_activa_) { return; } temps_animacio_ += delta_time * factor_lerp_; const float TWO_PI = 2.0F * Defaults::Math::PI; const float OFFSET_X = ORBIT_AMPLITUDE_X * std::sin(TWO_PI * ORBIT_FREQUENCY_X * temps_animacio_); const float OFFSET_Y = ORBIT_AMPLITUDE_Y * std::sin((TWO_PI * ORBIT_FREQUENCY_Y * temps_animacio_) + ORBIT_PHASE_OFFSET); for (std::size_t i = 0; i < lletres_orni_.size(); ++i) { lletres_orni_[i].position.x = posicions_originals_orni_[i].x + std::round(OFFSET_X); lletres_orni_[i].position.y = posicions_originals_orni_[i].y + std::round(OFFSET_Y); } for (std::size_t i = 0; i < lletres_attack_.size(); ++i) { lletres_attack_[i].position.x = posicions_originals_attack_[i].x + std::round(OFFSET_X); lletres_attack_[i].position.y = posicions_originals_attack_[i].y + std::round(OFFSET_Y); } } void TitleScene::draw() { if (starfield_ && estat_actual_ != TitleState::BLACK_SCREEN) { starfield_->draw(); } if (ship_animator_ && (estat_actual_ == TitleState::STARFIELD_FADE_IN || estat_actual_ == TitleState::STARFIELD || estat_actual_ == TitleState::MAIN || estat_actual_ == TitleState::PLAYER_JOIN_PHASE)) { ship_animator_->draw(); } if (estat_actual_ == TitleState::STARFIELD_FADE_IN || estat_actual_ == TitleState::STARFIELD) { return; } if (estat_actual_ != TitleState::MAIN && estat_actual_ != TitleState::PLAYER_JOIN_PHASE) { return; } if (animacio_activa_) { float temps_shadow = std::max(0.0F, temps_animacio_ - 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 < lletres_orni_.size(); ++i) { const Vec2 POS_SHADOW{ .x = posicions_originals_orni_[i].x + std::round(SHADOW_OX), .y = posicions_originals_orni_[i].y + std::round(SHADOW_OY), }; Rendering::renderShape(sdl_.getRenderer(), lletres_orni_[i].shape, POS_SHADOW, 0.0F, Defaults::Title::Layout::LOGO_SCALE, 1.0F, SHADOW_BRIGHTNESS); } for (std::size_t i = 0; i < lletres_attack_.size(); ++i) { const Vec2 POS_SHADOW{ .x = posicions_originals_attack_[i].x + std::round(SHADOW_OX), .y = posicions_originals_attack_[i].y + std::round(SHADOW_OY), }; Rendering::renderShape(sdl_.getRenderer(), lletres_attack_[i].shape, POS_SHADOW, 0.0F, Defaults::Title::Layout::LOGO_SCALE, 1.0F, SHADOW_BRIGHTNESS); } } for (const auto& lletra : lletres_orni_) { Rendering::renderShape(sdl_.getRenderer(), lletra.shape, lletra.position, 0.0F, Defaults::Title::Layout::LOGO_SCALE, 1.0F); } for (const auto& lletra : lletres_attack_) { Rendering::renderShape(sdl_.getRenderer(), lletra.shape, lletra.position, 0.0F, Defaults::Title::Layout::LOGO_SCALE, 1.0F); } const float SPACING = Defaults::Title::Layout::TEXT_SPACING; bool mostrar_text = true; if (estat_actual_ == TitleState::PLAYER_JOIN_PHASE) { const float FASE = temps_acumulat_ * BLINK_FREQUENCY * 2.0F * std::numbers::pi_v; mostrar_text = (std::sin(FASE) > 0.0F); } if (mostrar_text) { const std::string MAIN_TEXT = "PRESS START TO PLAY"; 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); } dibuixarPeuTitol(SPACING); } 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_.jugador1_actiu) { match_config_.jugador1_actiu = true; any_pressed = true; } } if (input->checkActionPlayer2(action, Input::DO_NOT_ALLOW_REPEAT)) { if (!match_config_.jugador2_actiu) { match_config_.jugador2_actiu = true; any_pressed = true; } } } return any_pressed; } void TitleScene::handleEvent(const SDL_Event& event) { (void)event; }