// game_scene.cpp - Implementació de la lógica del juego // © 2026 JailDesigner #include "game_scene.hpp" #include #include #include #include #include #include #include "core/audio/audio.hpp" #include "core/entities/entity_loader.hpp" #include "core/input/input.hpp" #include "core/input/input_types.hpp" #include "core/locale/locale.hpp" #include "core/system/scene_context.hpp" #include "core/system/service_menu.hpp" #include "game/entities/bullet_registry.hpp" #include "game/entities/enemy_registry.hpp" #include "game/entities/player_config.hpp" #include "game/stage_system/stage_loader.hpp" #include "game/systems/collision_system.hpp" #include "game/systems/continue_system.hpp" #include "game/systems/enemy_ai_system.hpp" #include "game/systems/init_hud_animator.hpp" // Using declarations per simplificar el codi using SceneManager::SceneContext; using SceneType = SceneContext::SceneType; using Option = SceneContext::Option; namespace { // Attract mode: durada fixa de la demo. Amb vides infinites, sempre dura // això (les morts respawnegen); només input o aquest timeout la tallen. constexpr float DEMO_DURATION = 35.0F; // Qualsevol d'aquestes accions trenca la demo i torna al títol. constexpr std::array DEMO_EXIT_ACTIONS = { InputAction::LEFT, InputAction::RIGHT, InputAction::THRUST, InputAction::SHOOT, InputAction::START}; // Color de les frases d'inici/fi de fase (àmbar neon). És propi del joc i // independent del "PULSA START" del títol (ara blanc): abans compartien la // mateixa constant i en posar el títol en blanc aquestes frases també ho feien. constexpr SDL_Color STAGE_MESSAGE_COLOR = {.r = 255, .g = 200, .b = 70, .a = 255}; } // namespace GameScene::GameScene(SDLManager& sdl, SceneContext& context) : sdl_(sdl), context_(context), debris_manager_(sdl.getRenderer()), firework_manager_(sdl.getRenderer()), floating_score_manager_(sdl.getRenderer()), trail_manager_(sdl.getRenderer()), text_(sdl.getRenderer()), starfield_parallax_(sdl.getRenderer()), playfield_(sdl.getRenderer()), border_(sdl.getRenderer()), curtain_(sdl.getRenderer()) { // Recuperar configuración de match des del context match_config_ = context_.getMatchConfig(); // Debug output de la configuración std::cout << "[GameScene] Configuración de match - P1: " << (match_config_.player1_active ? "ACTIU" : "INACTIU") << ", P2: " << (match_config_.player2_active ? "ACTIU" : "INACTIU") << '\n'; // Consumir opciones (preparació per MODE_DEMO futur) auto option = context_.consumeOption(); (void)option; // Suprimir warning de variable no usada // Carregar la configuració del player des de YAML. Sense fallback: si // falla, abortem (la nau no és construïble sense paràmetres). auto player_yaml = Entities::EntityLoader::load("player"); if (!player_yaml) { std::cerr << "[GameScene] FATAL: no s'ha pogut carregar data/entities/player/player.yaml\n"; std::exit(EXIT_FAILURE); } auto player_config = PlayerConfig::fromYaml(*player_yaml); if (!player_config) { std::cerr << "[GameScene] FATAL: player.yaml mal format\n"; std::exit(EXIT_FAILURE); } // Carregar les configuracions dels 3 enemics. Sense fallback: si falla, // abortem (els enemics no es poden construir sense els seus paràmetres). if (!EnemyRegistry::loadAll()) { std::cerr << "[GameScene] FATAL: no s'han pogut carregar els enemics YAML\n"; std::exit(EXIT_FAILURE); } // Carregar la configuració de la bala. Cal abans de construir el pool de // bullets, ja que cada Bullet llegeix el registry al seu ctor. if (!BulletRegistry::load()) { std::cerr << "[GameScene] FATAL: no s'ha pogut carregar bullet.yaml\n"; std::exit(EXIT_FAILURE); } // Inicialitzar naves: P1 amb el shape del YAML, P2 amb override visual. ships_[0] = Ship(sdl.getRenderer(), *player_config); // Jugador 1: nau estàndard ships_[1] = Ship(sdl.getRenderer(), *player_config, "ship/wedge.shp"); // Jugador 2: triangle amb cercle central // Inicialitzar balas con renderer std::ranges::fill(bullets_, Bullet(sdl.getRenderer())); // Inicialitzar enemigos con renderer std::ranges::fill(enemies_, Enemy(sdl.getRenderer())); // Inicialitzar generador de números aleatoris // Basat en el codi Pascal original: line 376 std::srand(static_cast(std::time(nullptr))); // Configurar el mundo físico con los límites de la zone de juego. physics_world_.clear(); physics_world_.setBounds(Defaults::Zones::PLAYAREA); // Connectar els impactes contra les parets al border (bump + flash). physics_world_.setBoundsHitCallback([this](const Physics::BoundsHit& hit) { if (hit.impact_speed < Defaults::Border::BUMP_MIN_VELOCITY) { return; } const float STRENGTH = std::min( 1.0F, hit.impact_speed / Defaults::Border::BUMP_VELOCITY_REFERENCE); border_.bumpAt(hit.contact_point, STRENGTH); }); // Fireworks generen una ripple gran al playfield (ona d'aigua centrada al burst). firework_manager_.setSpawnCallback([this](Vec2 origin) { playfield_.notifyExplosion(origin); }); // Explosions properes a una paret també generen bump (falloff lineal amb la distància). debris_manager_.setExplosionCallback([this](Vec2 center) { const SDL_FRect& zone = Defaults::Zones::PLAYAREA; const float DIST_LEFT = std::abs(center.x - zone.x); const float DIST_RIGHT = std::abs((zone.x + zone.w) - center.x); const float DIST_TOP = std::abs(center.y - zone.y); const float DIST_BOTTOM = std::abs((zone.y + zone.h) - center.y); const float MIN_DIST = std::min({DIST_LEFT, DIST_RIGHT, DIST_TOP, DIST_BOTTOM}); if (MIN_DIST > Defaults::Border::EXPLOSION_FALLOFF_PX) { return; } const float FALLOFF = 1.0F - (MIN_DIST / Defaults::Border::EXPLOSION_FALLOFF_PX); border_.bumpAt(center, Defaults::Border::EXPLOSION_BASE_STRENGTH * FALLOFF); }); // Load stage configuration stage_config_ = StageSystem::StageLoader::load("data/stages/stages.yaml"); if (!stage_config_) { std::cerr << "[GameScene] Error: no s'ha pogut load stages.yaml" << '\n'; // Continue without stage system (will crash, but helps debugging) } // Initialize stage manager stage_manager_ = std::make_unique(stage_config_.get()); if (match_config_.mode == GameConfig::Mode::DEMO) { // Attract mode: arrencar directament en PLAYING a l'escenari curat // actual (partida "ja començada") i avançar l'índex perquè la pròxima // demo mostri un escenari diferent. El nombre de jugadors ja l'ha fixat // TitleScene al match_config llegint el mateix escenari. const Systems::Demo::Scenario SC = Systems::Demo::scenario(context_.demoScenarioIndex()); context_.advanceDemoScenario(); stage_manager_->initDemo(SC.stage); demo_timer_ = DEMO_DURATION; // Silenciar només els SFX de joc (Group::GAME) durant la demo: la música // i els sons del menu de servei (Group::INTERFACE) segueixen sonant. No // toquem el volum global ni la preferència de l'usuari. Audio::get()->silenceGroup(Audio::Group::GAME, true); // El fons (graella) ha d'aparèixer ja muntat: la demo és una partida en marxa. playfield_.completeBuild(); // La cortinilla arrenca tapant i cau per destapar la demo (continua el // moviment iniciat al títol, que va acabar amb la pantalla negra). curtain_.reveal(Defaults::Game::Curtain::REVEAL_DURATION); } else { stage_manager_->init(); } // Set ship position reference for safe spawn (P1 for now, TODO: dual tracking) stage_manager_->getWaveRunner().setShipPosition(&ships_[0].getCenter()); // Inicialitzar timers de muerte per player hit_timer_per_player_[0] = 0.0F; hit_timer_per_player_[1] = 0.0F; // Initialize lives and game over state (independent lives per player) lives_per_player_[0] = Defaults::Game::STARTING_LIVES; lives_per_player_[1] = Defaults::Game::STARTING_LIVES; game_over_state_ = GameOverState::NONE; continue_counter_ = 0; continue_tick_timer_ = 0.0F; continues_used_ = 0; game_over_timer_ = 0.0F; // Initialize scores (separate per player) score_per_player_[0] = 0; score_per_player_[1] = 0; floating_score_manager_.reset(); // Inicialitzar naves segons configuración (solo jugadors active) for (uint8_t i = 0; i < 2; i++) { bool player_active = (i == 0) ? match_config_.player1_active : match_config_.player2_active; if (player_active) { // Jugador active: init normalment Vec2 spawn_pos = getSpawnPoint(i); ships_[i].init(&spawn_pos, false); // No invulnerability at start // Registrar el cuerpo físico de la nave en el mundo physics_world_.addBody(&ships_[i].getBody()); std::cout << "[GameScene] Jugador " << (i + 1) << " inicialitzat\n"; } else { // Jugador inactiu: marcar como a mort permanent ships_[i].markHit(); hit_timer_per_player_[i] = Defaults::Game::HIT_TIMER_INACTIVE_PLAYER; lives_per_player_[i] = 0; // Sin vides std::cout << "[GameScene] Jugador " << (i + 1) << " inactiu\n"; } } // Initialize enemies as inactive (stage system will spawn them). // Registramos el body al world incluso inactivo: con radius=0 no colisiona // ni se mueve, y al init() del stage system se activa sin re-registrar. for (auto& enemy : enemies_) { enemy.setShips(ships_.data(), &ships_[1]); physics_world_.addBody(&enemy.getBody()); // DON'T call enemy.init() here - stage system handles spawning } // Inicialitzar balas. // Se registran en el physics_world para integración cinemática. // Como su body_.radius=0, no colisionan físicamente con nadie (las // colisiones de gameplay se gestionan en detectar_col·lisions_*). for (auto& bullet : bullets_) { bullet.init(); physics_world_.addBody(&bullet.getBody()); } // Reset flag de sons de animación init_hud_rect_sound_played_ = false; } GameScene::~GameScene() { // En sortir de la demo, primer parem qualsevol SFX encara sonant (p. ex. la // veu de "fase completa" que la demo va llançar muteada): si no, en restaurar // el volum del grup el motor reaplicaria la ganancia al canal viu i el so es // colaria a la pantalla de títol. Després restaurem el grup GAME per al // pròxim joc real. if (match_config_.mode == GameConfig::Mode::DEMO) { Audio::get()->stopAllSounds(); Audio::get()->silenceGroup(Audio::Group::GAME, false); } } auto GameScene::isFinished() const -> bool { return context_.nextScene() != SceneType::GAME; } void GameScene::handleEvent(const SDL_Event& event) { // GameScene no procesa eventos puntuales SDL: la lógica de input se // resuelve en update() consultando Input::checkAction. (void)event; } void GameScene::update(float delta_time) { // Pausa global: mentre el menu de servei esta obert, congelem la lògica // de joc. El draw() segueix executant-se per a mantenir l'escena visible // sota el menu. if (const auto* menu = System::ServiceMenu::get(); menu != nullptr && menu->isOpen()) { return; } // Orquestador delgado: cada paso vive en su propia función para // mantener update() legible y reducir complejidad cognitiva. stepPhysics(delta_time); if (match_config_.mode == GameConfig::Mode::DEMO) { // Mode demo (attract): salida por input/timeout/muerte + control del pilot. if (stepDemo(delta_time)) { return; } } else if (game_over_state_ == GameOverState::NONE) { stepShootingInput(); stepMidGameJoin(); } if (stepContinueScreen(delta_time)) { return; } if (stepGameOver(delta_time)) { return; } stepDeathSequence(delta_time); stepStageStateMachine(delta_time); } void GameScene::stepPhysics(float delta_time) { // Las fuerzas aplicadas en el frame N-1 por processInput/AI se integran // ahora; postUpdate sincroniza los mirrors (center_/angle_) antes de la // lógica de juego que los lee. physics_world_.update(delta_time); for (auto& ship : ships_) { ship.postUpdate(delta_time); } for (auto& enemy : enemies_) { enemy.postUpdate(delta_time); } for (auto& bullet : bullets_) { bullet.postUpdate(delta_time); } trail_manager_.update(delta_time, ships_); // Starfield: world_velocity = -mitjana_de_naus_actives. Si dues naus van en // sentits oposats, es cancel·len → estrelles quietes (cap jugador "guanya"). // Si només n'hi ha una activa, segueix la seva velocitat. Vec2 ship_vel_avg{.x = 0.0F, .y = 0.0F}; int n_active = 0; for (const auto& ship : ships_) { if (ship.isActive()) { const Vec2 V = ship.getVelocityVector(); ship_vel_avg.x += V.x; ship_vel_avg.y += V.y; n_active++; } } if (n_active > 0) { ship_vel_avg.x /= static_cast(n_active); ship_vel_avg.y /= static_cast(n_active); } starfield_parallax_.update(delta_time, Vec2{.x = -ship_vel_avg.x, .y = -ship_vel_avg.y}); playfield_.update(delta_time); border_.update(delta_time); // Notificar al playfield que la nau es mou (genera ripples petites a cadència). for (std::size_t id = 0; id < ships_.size(); id++) { if (ships_[id].isActive()) { playfield_.notifyShipMoving(static_cast(id), ships_[id].getCenter(), ships_[id].getSpeed(), delta_time); } } } void GameScene::stepShootingInput() { auto* input = Input::get(); if (match_config_.player1_active && input->checkActionPlayer1(InputAction::SHOOT, Input::DO_NOT_ALLOW_REPEAT)) { fireBullet(0); } if (match_config_.player2_active && input->checkActionPlayer2(InputAction::SHOOT, Input::DO_NOT_ALLOW_REPEAT)) { fireBullet(1); } } void GameScene::updateShipsControl(float delta_time) { const bool DEMO = (match_config_.mode == GameConfig::Mode::DEMO); for (uint8_t i = 0; i < 2; i++) { const bool ACTIU = (i == 0) ? match_config_.player1_active : match_config_.player2_active; if (!ACTIU || hit_timer_per_player_[i] != 0.0F) { continue; } // En demo, cada nau activa es mou amb el seu pilot IA (control calculat // a stepDemo); la resta de casos llegeixen Input com sempre. if (DEMO) { ships_[i].applyMovement(demo_ctrls_[i].left, demo_ctrls_[i].right, demo_ctrls_[i].thrust, delta_time); } else { ships_[i].processInput(delta_time, i); } ships_[i].update(delta_time); } } auto GameScene::stepDemo(float delta_time) -> bool { curtain_.update(delta_time); // cortinilla que destapa la demo // Qualsevol input trenca la demo i torna al títol (música intacta). if (Input::get()->checkAnyPlayerAction(DEMO_EXIT_ACTIONS)) { context_.setNextScene(SceneType::TITLE, Option::JUMP_TO_TITLE_MAIN); return true; } // Vides infinites: la demo dura sempre el temps fix; en morir, stepDeathSequence // respawneja (no acaba ni passa per CONTINUE/GAME_OVER). demo_timer_ -= delta_time; if (demo_timer_ <= 0.0F) { endDemo(); return true; } // Control de cada nau activa per al frame; el disparo el dispara GameScene. for (uint8_t i = 0; i < 2; i++) { const bool ACTIU = (i == 0) ? match_config_.player1_active : match_config_.player2_active; if (!ACTIU || hit_timer_per_player_[i] != 0.0F) { demo_ctrls_[i] = {}; // nau inactiva/morta: sense control continue; } demo_ctrls_[i] = demo_pilots_[i].compute( ships_[i], enemies_, bullets_, Defaults::Zones::PLAYAREA, delta_time); if (demo_ctrls_[i].shoot) { fireBullet(i); } } return false; } void GameScene::endDemo() { // No parem la música: title.ogg segueix sonant durant el cicle atrae. context_.setNextScene(SceneType::LOGO); } void GameScene::stepMidGameJoin() { // Permitir join solo durante PLAYING. if (stage_manager_->getState() != StageSystem::EstatStage::PLAYING) { return; } // Solo se permite join si hay al menos un jugador vivo (no se puede // hacer join en pantalla vacía). const bool ALGU_VIU = (match_config_.player1_active && hit_timer_per_player_[0] != Defaults::Game::HIT_TIMER_INACTIVE_PLAYER) || (match_config_.player2_active && hit_timer_per_player_[1] != Defaults::Game::HIT_TIMER_INACTIVE_PLAYER); if (!ALGU_VIU) { return; } auto* input = Input::get(); for (uint8_t pid = 0; pid < 2; pid++) { const bool ACTIU = (pid == 0) ? match_config_.player1_active : match_config_.player2_active; const bool MUERTO_SIN_VIDAS = hit_timer_per_player_[pid] == Defaults::Game::HIT_TIMER_INACTIVE_PLAYER; if (ACTIU && !MUERTO_SIN_VIDAS) { continue; // jugador ya está jugando } const bool START_PRESSED = (pid == 0) ? input->checkActionPlayer1(InputAction::START, Input::DO_NOT_ALLOW_REPEAT) : input->checkActionPlayer2(InputAction::START, Input::DO_NOT_ALLOW_REPEAT); if (START_PRESSED) { joinPlayer(pid); } } } auto GameScene::stepContinueScreen(float delta_time) -> bool { if (game_over_state_ != GameOverState::CONTINUE) { return false; } Systems::ContinueScreen::Context cont_ctx{ .state = game_over_state_, .counter = continue_counter_, .tick_timer = continue_tick_timer_, .continues_used = continues_used_, .game_over_timer = game_over_timer_, .lives_per_player = lives_per_player_, .score_per_player = score_per_player_, .hit_timer_per_player = hit_timer_per_player_, .ships = ships_, .match_config = match_config_, .get_spawn_point = [this](uint8_t pid) { return getSpawnPoint(pid); }, }; Systems::ContinueScreen::update(cont_ctx, delta_time); Systems::ContinueScreen::processInput(cont_ctx); // Enemies, bullets y efectos siguen moviéndose en background. for (auto& enemy : enemies_) { Systems::EnemyAi::move(enemy, delta_time); enemy.update(delta_time); } for (auto& bullet : bullets_) { bullet.update(delta_time); } Systems::Collision::desactivateOutOfBoundsBullets(bullets_, debris_manager_); debris_manager_.update(delta_time); firework_manager_.update(delta_time); floating_score_manager_.update(delta_time); return true; } auto GameScene::stepGameOver(float delta_time) -> bool { if (game_over_state_ != GameOverState::GAME_OVER) { return false; } game_over_timer_ -= delta_time; if (game_over_timer_ <= 0.0F) { Audio::get()->stopMusic(); context_.setNextScene(SceneType::LOGO); return true; } // Enemies, bullets y efectos siguen moviéndose como fondo. for (auto& enemy : enemies_) { Systems::EnemyAi::move(enemy, delta_time); enemy.update(delta_time); } for (auto& bullet : bullets_) { bullet.update(delta_time); } Systems::Collision::desactivateOutOfBoundsBullets(bullets_, debris_manager_); debris_manager_.update(delta_time); firework_manager_.update(delta_time); floating_score_manager_.update(delta_time); return true; } void GameScene::stepDeathSequence(float delta_time) { bool algun_mort = false; for (uint8_t i = 0; i < 2; i++) { if (hit_timer_per_player_[i] <= 0.0F || hit_timer_per_player_[i] >= Defaults::Game::HIT_TIMER_INACTIVE_PLAYER) { continue; } algun_mort = true; hit_timer_per_player_[i] += delta_time; if (hit_timer_per_player_[i] < Defaults::Game::DEATH_DURATION) { continue; } // *** PHASE 3: RESPAWN OR GAME OVER *** // Mode demo: vides infinites. Respawn sempre, sense decrementar vides ni // passar mai per CONTINUE/GAME_OVER — la demo dura el seu temps fix. if (match_config_.mode == GameConfig::Mode::DEMO) { Vec2 spawn_pos = getSpawnPoint(i); ships_[i].init(&spawn_pos, /*activar_invulnerabilitat=*/true); hit_timer_per_player_[i] = 0.0F; continue; } lives_per_player_[i]--; if (lives_per_player_[i] > 0) { Vec2 spawn_pos = getSpawnPoint(i); ships_[i].init(&spawn_pos, /*activar_invulnerabilitat=*/true); hit_timer_per_player_[i] = 0.0F; continue; } // Sin vidas: marcar definitivamente muerto y comprobar transición a CONTINUE. hit_timer_per_player_[i] = Defaults::Game::HIT_TIMER_INACTIVE_PLAYER; const bool P1_DEAD = !match_config_.player1_active || lives_per_player_[0] <= 0; const bool P2_DEAD = !match_config_.player2_active || lives_per_player_[1] <= 0; if (P1_DEAD && P2_DEAD) { game_over_state_ = GameOverState::CONTINUE; continue_counter_ = Defaults::Game::CONTINUE_COUNT_START; continue_tick_timer_ = Defaults::Game::CONTINUE_TICK_DURATION; } } // Si hay algún muerto, los enemigos/balas/efectos siguen actualizándose // aunque otros jugadores aún jueguen. if (algun_mort) { for (auto& enemy : enemies_) { Systems::EnemyAi::move(enemy, delta_time); enemy.update(delta_time); } for (auto& bullet : bullets_) { bullet.update(delta_time); } debris_manager_.update(delta_time); firework_manager_.update(delta_time); floating_score_manager_.update(delta_time); } // El bool 'algun_mort' es puramente interno: no aporta nada al caller // (la stage state machine sigue corriendo aunque haya jugadores muriendo), // así que la función no devuelve nada. } void GameScene::stepStageStateMachine(float delta_time) { const StageSystem::EstatStage STATE = stage_manager_->getState(); switch (STATE) { case StageSystem::EstatStage::INIT_HUD: runStageInitHud(delta_time); break; case StageSystem::EstatStage::LEVEL_START: runStageLevelStart(delta_time); break; case StageSystem::EstatStage::PLAYING: runStagePlaying(delta_time); break; case StageSystem::EstatStage::LEVEL_COMPLETED: runStageLevelCompleted(delta_time); break; } } void GameScene::runStageInitHud(float delta_time) { // Update stage manager timer (puede cambiar el state). stage_manager_->update(delta_time); // Si el state cambió, salir para no usar el timer del nuevo state. if (stage_manager_->getState() != StageSystem::EstatStage::INIT_HUD) { return; } float global_progress = 1.0F - (stage_manager_->getTransitionTimer() / Defaults::Game::INIT_HUD_DURATION); global_progress = std::min(1.0F, global_progress); const float SHIP1_P = Systems::InitHud::computeRangeProgress( global_progress, Defaults::Game::INIT_HUD_SHIP1_RATIO_INIT, Defaults::Game::INIT_HUD_SHIP1_RATIO_END); const float SHIP2_P = Systems::InitHud::computeRangeProgress( global_progress, Defaults::Game::INIT_HUD_SHIP2_RATIO_INIT, Defaults::Game::INIT_HUD_SHIP2_RATIO_END); if (match_config_.player1_active && SHIP1_P < 1.0F) { ships_[0].setCenter(Systems::InitHud::computeShipPosition(SHIP1_P, getSpawnPoint(0))); } if (match_config_.player2_active && SHIP2_P < 1.0F) { ships_[1].setCenter(Systems::InitHud::computeShipPosition(SHIP2_P, getSpawnPoint(1))); } } void GameScene::runStageLevelStart(float delta_time) { stage_manager_->update(delta_time); // Ambas naves pueden moverse y disparar durante el intro. updateShipsControl(delta_time); for (auto& bullet : bullets_) { bullet.update(delta_time); } Systems::Collision::desactivateOutOfBoundsBullets(bullets_, debris_manager_); debris_manager_.update(delta_time); firework_manager_.update(delta_time); } void GameScene::runStagePlaying(float delta_time) { const bool PAUSE_SPAWN = (hit_timer_per_player_[0] > 0.0F && hit_timer_per_player_[1] > 0.0F); stage_manager_->getWaveRunner().update(delta_time, enemies_, PAUSE_SPAWN); // Stage completado: cuando al menos un jugador está vivo y todas las onades emeses y arena buida. const bool ALGU_VIU = (hit_timer_per_player_[0] == 0.0F || hit_timer_per_player_[1] == 0.0F); if (ALGU_VIU && stage_manager_->getWaveRunner().stageComplete(enemies_)) { stage_manager_->markStageCompleted(); Audio::get()->playSound(Defaults::Sound::GOOD_JOB_COMMANDER, Audio::Group::GAME); return; } // Gameplay normal: ships activos + entidades + colisiones + efectos. updateShipsControl(delta_time); auto ai_ctx = buildCollisionContext(); for (std::size_t i = 0; i < enemies_.size(); ++i) { Systems::EnemyAi::tick(ai_ctx, enemies_[i], i, delta_time); enemies_[i].update(delta_time); } // Col·lisions primer, després desactivació per fora-de-zone: així una bala que // el mateix frame xoca amb un enemic i alhora surt del PLAYAREA es compta com a // impacte abans no se la trenqui per sortir. runCollisionDetections(); for (auto& bullet : bullets_) { bullet.update(delta_time); } Systems::Collision::desactivateOutOfBoundsBullets(bullets_, debris_manager_); debris_manager_.update(delta_time); firework_manager_.update(delta_time); floating_score_manager_.update(delta_time); } void GameScene::runStageLevelCompleted(float delta_time) { stage_manager_->update(delta_time); updateShipsControl(delta_time); for (auto& bullet : bullets_) { bullet.update(delta_time); } Systems::Collision::desactivateOutOfBoundsBullets(bullets_, debris_manager_); debris_manager_.update(delta_time); firework_manager_.update(delta_time); floating_score_manager_.update(delta_time); } auto GameScene::buildCollisionContext() -> Systems::Collision::Context { return Systems::Collision::Context{ .ships = ships_, .enemies = enemies_, .bullets = bullets_, .hit_timer_per_player = hit_timer_per_player_, .score_per_player = score_per_player_, .lives_per_player = lives_per_player_, .debris_manager = debris_manager_, .firework_manager = firework_manager_, .floating_score_manager = floating_score_manager_, .match_config = match_config_, .on_player_hit = [this](uint8_t pid, const Vec2& bv) { tocado(pid, bv); }, }; } void GameScene::runCollisionDetections() { auto col_ctx = buildCollisionContext(); Systems::Collision::detectAll(col_ctx); } void GameScene::draw() { if (game_over_state_ == GameOverState::CONTINUE) { drawContinueState(); return; } if (game_over_state_ == GameOverState::GAME_OVER) { drawGameOverState(); return; } switch (stage_manager_->getState()) { case StageSystem::EstatStage::INIT_HUD: drawInitHudState(); break; case StageSystem::EstatStage::LEVEL_START: drawLevelStartState(); break; case StageSystem::EstatStage::PLAYING: drawPlayingState(); break; case StageSystem::EstatStage::LEVEL_COMPLETED: drawLevelCompletedState(); break; } // Cortinilla d'entrada de la demo: per damunt de tot. No-op fora del mode // DEMO (curtain_ mai s'arrenca) i quan ja ha sortit per baix. curtain_.draw(); } void GameScene::drawEnemies() const { for (const auto& enemy : enemies_) { enemy.draw(); } } void GameScene::drawBullets() const { for (const auto& bullet : bullets_) { bullet.draw(); } } void GameScene::drawActiveShipsAlive() const { for (uint8_t i = 0; i < 2; i++) { bool player_active = (i == 0) ? match_config_.player1_active : match_config_.player2_active; if (player_active && hit_timer_per_player_[i] == 0.0F) { ships_[i].draw(); } } } void GameScene::drawContinueState() { starfield_parallax_.draw(); border_.draw(); drawEnemies(); drawBullets(); debris_manager_.draw(); firework_manager_.draw(); floating_score_manager_.draw(); drawScoreboard(); drawContinue(); } void GameScene::drawGameOverState() { starfield_parallax_.draw(); border_.draw(); drawEnemies(); drawBullets(); debris_manager_.draw(); firework_manager_.draw(); floating_score_manager_.draw(); const std::string GAME_OVER_TEXT = Locale::get().text("game_screen.game_over"); constexpr float SCALE = Defaults::Game::GameOverScreen::TEXT_SCALE; constexpr float SPACING = Defaults::Game::GameOverScreen::TEXT_SPACING; const SDL_FRect& play_area = Defaults::Zones::PLAYAREA; float center_x = play_area.x + (play_area.w / 2.0F); float center_y = play_area.y + (play_area.h / 2.0F); text_.renderCentered(GAME_OVER_TEXT, {.x = center_x, .y = center_y}, SCALE, SPACING); drawScoreboard(); } void GameScene::drawInitHudState() { float timer = stage_manager_->getTransitionTimer(); float total_time = Defaults::Game::INIT_HUD_DURATION; float global_progress = 1.0F - (timer / total_time); float rect_progress = Systems::InitHud::computeRangeProgress( global_progress, Defaults::Game::INIT_HUD_RECT_RATIO_INIT, Defaults::Game::INIT_HUD_RECT_RATIO_END); float score_progress = Systems::InitHud::computeRangeProgress( global_progress, Defaults::Game::INIT_HUD_SCORE_RATIO_INIT, Defaults::Game::INIT_HUD_SCORE_RATIO_END); float ship1_progress = Systems::InitHud::computeRangeProgress( global_progress, Defaults::Game::INIT_HUD_SHIP1_RATIO_INIT, Defaults::Game::INIT_HUD_SHIP1_RATIO_END); float ship2_progress = Systems::InitHud::computeRangeProgress( global_progress, Defaults::Game::INIT_HUD_SHIP2_RATIO_INIT, Defaults::Game::INIT_HUD_SHIP2_RATIO_END); // Capa de fons més profunda: estrelles 2D (apareixen senceres des del frame 0). starfield_parallax_.draw(); // Graella de fons al darrere (timer intern propi, cobreix tot l'INIT_HUD). playfield_.draw(); if (rect_progress > 0.0F) { if (!init_hud_rect_sound_played_) { Audio::get()->playSound(Defaults::Sound::INIT_HUD, Audio::Group::GAME); init_hud_rect_sound_played_ = true; } Systems::InitHud::drawBordersAnimated(sdl_.getRenderer(), rect_progress); } if (score_progress > 0.0F) { Systems::InitHud::drawScoreboardAnimated(sdl_.getRenderer(), text_, buildScoreboardData(), score_progress); } if (ship1_progress > 0.0F && match_config_.player1_active && ships_[0].isActive()) { ships_[0].draw(); } if (ship2_progress > 0.0F && match_config_.player2_active && ships_[1].isActive()) { ships_[1].draw(); } } void GameScene::drawLevelStartState() { starfield_parallax_.draw(); playfield_.draw(); border_.draw(); trail_manager_.draw(); drawActiveShipsAlive(); drawBullets(); debris_manager_.draw(); firework_manager_.draw(); floating_score_manager_.draw(); drawStageMessage(stage_manager_->getLevelStartMessage()); drawScoreboard(); } void GameScene::drawPlayingState() { starfield_parallax_.draw(); playfield_.draw(); border_.draw(); trail_manager_.draw(); drawActiveShipsAlive(); drawEnemies(); drawBullets(); debris_manager_.draw(); firework_manager_.draw(); floating_score_manager_.draw(); drawScoreboard(); } void GameScene::drawLevelCompletedState() { starfield_parallax_.draw(); playfield_.draw(); border_.draw(); trail_manager_.draw(); drawActiveShipsAlive(); drawBullets(); debris_manager_.draw(); firework_manager_.draw(); floating_score_manager_.draw(); drawStageMessage(Locale::get().text("stage.completed")); drawScoreboard(); } void GameScene::tocado(uint8_t player_id, const Vec2& bullet_velocity) { // Death sequence: 3 phases // Phase 1: First call (hit_timer_per_player_[player_id] == 0) - trigger explosion // Phase 2: Animation (0 < itocado_ < 3.0s) - debris animation // Phase 3: Respawn or game over (itocado_ >= 3.0s) - handled in update() if (hit_timer_per_player_[player_id] == 0.0F) { // *** PHASE 1: TRIGGER DEATH *** // Capturar velocitat ABANS del markHit (que la reseteja a zero). // Sense això, els debris no hereten cap inèrcia de la nau. const Vec2 SHIP_VEL_PRE_DEATH = ships_[player_id].getVelocityVector(); const Vec2 SHIP_POS = ships_[player_id].getCenter(); const float SHIP_ANGLE = ships_[player_id].getAngle(); const float SHIP_BRIGHT = ships_[player_id].getBrightness(); // Mark ship as dead (stops rendering and input) ships_[player_id].markHit(); const Vec2 INHERITED_VEL = SHIP_VEL_PRE_DEATH * Defaults::Physics::Debris::SHIP_VELOCITY_INHERITANCE; // Mateixa dispersió i efecte que els debris d'enemic (lifetime, // friction, segment_multiplier alineats); només canvien sound i color. // bullet_velocity arriba a explode() com a impuls extra independent // de la inèrcia del cos del ship — els trossos volen amb la força // de la bala encara que el ship estiga quiet. debris_manager_.explode( ships_[player_id].getShape(), SHIP_POS, SHIP_ANGLE, 1.0F, Defaults::Physics::Debris::SPEED_BASE, SHIP_BRIGHT, INHERITED_VEL, 0.0F, // sense herència angular 0.0F, // sin herencia visual Defaults::Sound::PLAYER_EXPLOSION, ships_[player_id].getConfig().colors.normal, Defaults::Physics::Debris::ENEMY_LIFETIME, Defaults::Physics::Debris::ENEMY_FRICTION, Defaults::Physics::Debris::ENEMY_SEGMENT_MULTIPLIER, bullet_velocity); // Start death timer (non-zero to avoid re-triggering) hit_timer_per_player_[player_id] = Defaults::Game::HIT_TIMER_TRIGGER_DEATH; } // Phase 2 is automatic (debris updates in update()) // Phase 3 is handled in update() when hit_timer_per_player_ >= DEATH_DURATION } void GameScene::drawScoreboard() { const float SCALE = Defaults::Hud::SCOREBOARD_TEXT_SCALE; const float SPACING = Defaults::Hud::SCOREBOARD_TEXT_SPACING; const SDL_FRect& scoreboard_zone = Defaults::Zones::SCOREBOARD; const Vec2 CENTER = { .x = scoreboard_zone.w / 2.0F, .y = scoreboard_zone.y + (scoreboard_zone.h / 2.0F), }; // En mode demo (attract) el marcador no té sentit: substituïm puntuacions i // vides per un rètol que indica que és una demo i convida a jugar. if (match_config_.mode == GameConfig::Mode::DEMO) { text_.renderCentered(Locale::get().text("demo.banner"), CENTER, SCALE, SPACING); return; } Systems::InitHud::drawScoreboardAt(sdl_.getRenderer(), text_, buildScoreboardData(), CENTER.y, SCALE, SPACING); } auto GameScene::buildScoreboardData() const -> Systems::InitHud::ScoreboardData { Systems::InitHud::ScoreboardData out; // Puntuació a 6 dígits amb zeros a l'esquerra (inactiu → tot zeros, 0 vides). const auto FORMAT_SCORE = [](int score) { const std::string S = std::to_string(score); return std::string(6 - std::min(6, static_cast(S.length())), '0') + S; }; out.p1_active = match_config_.player1_active; out.p2_active = match_config_.player2_active; out.score_p1 = match_config_.player1_active ? FORMAT_SCORE(score_per_player_[0]) : "000000"; out.lives_p1 = match_config_.player1_active ? lives_per_player_[0] : 0; out.score_p2 = match_config_.player2_active ? FORMAT_SCORE(score_per_player_[1]) : "000000"; out.lives_p2 = match_config_.player2_active ? lives_per_player_[1] : 0; // Shapes de les naus per a les icones de vides (reutilitza la geometria ja // carregada de cada Ship). out.shape_p1 = ships_[0].getShape(); out.shape_p2 = ships_[1].getShape(); // Nivell: etiqueta localitzada + número a 2 dígits (separats per pintar-los // amb tonalitats distintes). const uint8_t STAGE_NUM = stage_manager_->getCurrentStage(); out.level_label = Locale::get().text("hud.level"); out.level_value = (STAGE_NUM < 10) ? "0" + std::to_string(STAGE_NUM) : std::to_string(STAGE_NUM); return out; } // [NEW] Stage system helper methods void GameScene::drawStageMessage(const std::string& message) { constexpr float BASE_SCALE = 1.0F; constexpr float SPACING = 2.0F; const SDL_FRect& play_area = Defaults::Zones::PLAYAREA; const float MAX_WIDTH = play_area.w * Defaults::Game::STAGE_MESSAGE_MAX_WIDTH_RATIO; // ========== TYPEWRITER EFFECT (PARAMETRIZED) ========== // Get state-specific timing configuration float total_time; float typing_ratio; if (stage_manager_->getState() == StageSystem::EstatStage::LEVEL_START) { total_time = Defaults::Game::LEVEL_START_DURATION; typing_ratio = Defaults::Game::LEVEL_START_TYPING_RATIO; } else { // LEVEL_COMPLETED total_time = Defaults::Game::LEVEL_COMPLETED_DURATION; typing_ratio = Defaults::Game::LEVEL_COMPLETED_TYPING_RATIO; } // Calculate progress from timer (0.0 at start → 1.0 at end) float remaining_time = stage_manager_->getTransitionTimer(); float progress = 1.0F - (remaining_time / total_time); // Determine how many characters to show size_t visible_chars; if (typing_ratio > 0.0F && progress < typing_ratio) { // Typewriter phase: show partial text float typing_progress = progress / typing_ratio; // Normalize to 0.0-1.0 visible_chars = static_cast(message.length() * typing_progress); if (visible_chars == 0 && progress > 0.0F) { visible_chars = 1; // Show at least 1 character after first frame } } else { // Display phase: show complete text // (Either after typing phase, or immediately if typing_ratio == 0.0) visible_chars = message.length(); } // Create partial message (substring for typewriter) std::string partial_message = message.substr(0, visible_chars); // =================================================== // Calculate text width at base scale (using FULL message for position calculation) float text_width_at_base = Graphics::VectorText::getTextWidth(message, BASE_SCALE, SPACING); // Auto-scale if text exceeds max width float scale = (text_width_at_base <= MAX_WIDTH) ? BASE_SCALE : MAX_WIDTH / text_width_at_base; // Recalculate dimensions with final scale (using FULL message for centering) float full_text_width = Graphics::VectorText::getTextWidth(message, scale, SPACING); float text_height = Graphics::VectorText::getTextHeight(scale); // Calculate position as if FULL text was there (for fixed position typewriter) float x = play_area.x + ((play_area.w - full_text_width) / 2.0F); float y = play_area.y + (play_area.h * Defaults::Game::STAGE_MESSAGE_Y_RATIO) - (text_height / 2.0F); // Render only the partial message (typewriter effect) amb el color // ambre neon del "PRESS START" del títol — unifica el feel dels missatges. Vec2 pos = {.x = x, .y = y}; text_.render(partial_message, pos, scale, SPACING, 1.0F, STAGE_MESSAGE_COLOR); } // ======================================== // Helper methods for 2-player support // ======================================== auto GameScene::getSpawnPoint(uint8_t player_id) const -> Vec2 { const SDL_FRect& zone = Defaults::Zones::PLAYAREA; float x_ratio; if (match_config_.isSinglePlayer()) { // Un sol player: spawn al centro (50%) x_ratio = 0.5F; } else { // Dos jugadors: spawn a posicions separades x_ratio = (player_id == 0) ? Defaults::Game::P1_SPAWN_X_RATIO // 33% : Defaults::Game::P2_SPAWN_X_RATIO; // 67% } return { .x = zone.x + (zone.w * x_ratio), .y = zone.y + (zone.h * Defaults::Game::SPAWN_Y_RATIO)}; } void GameScene::fireBullet(uint8_t player_id) { // Verificar que el player está vivo if (hit_timer_per_player_[player_id] > 0.0F) { return; } if (!ships_[player_id].isActive()) { return; } // Calcular posición en la punta de la nave const Vec2& ship_centre = ships_[player_id].getCenter(); float ship_angle = ships_[player_id].getAngle(); constexpr float LOCAL_TIP_X = Defaults::Hud::Tips::LOCAL_X; constexpr float LOCAL_TIP_Y = Defaults::Hud::Tips::LOCAL_Y; float cos_a = std::cos(ship_angle); float sin_a = std::sin(ship_angle); float tip_x = (LOCAL_TIP_X * cos_a) - (LOCAL_TIP_Y * sin_a) + ship_centre.x; float tip_y = (LOCAL_TIP_X * sin_a) + (LOCAL_TIP_Y * cos_a) + ship_centre.y; Vec2 fire_position = {.x = tip_x, .y = tip_y}; // Buscar primera bullet inactiva en el pool del player. // El pool global té MAX_BULLETS slots per jugador (P1=[0..MAX-1], P2=[MAX..2*MAX-1]). constexpr int SLOTS_PER_PLAYER = Defaults::Entities::MAX_BULLETS; const int START_IDX = player_id * SLOTS_PER_PLAYER; for (int i = START_IDX; i < START_IDX + SLOTS_PER_PLAYER; i++) { if (!bullets_[i].isActive()) { bullets_[i].fire(fire_position, ship_angle, player_id, ships_[player_id].getConfig().weapon.bullet_speed); break; } } } void GameScene::drawContinue() { const SDL_FRect& play_area = Defaults::Zones::PLAYAREA; constexpr float SPACING = 4.0F; // "CONTINUE" text (using constants) const std::string CONTINUE_TEXT = Locale::get().text("game_screen.continue"); float escala_continue = Defaults::Game::ContinueScreen::CONTINUE_TEXT_SCALE; float y_ratio_continue = Defaults::Game::ContinueScreen::CONTINUE_TEXT_Y_RATIO; float center_x = play_area.x + (play_area.w / 2.0F); float centre_y_continue = play_area.y + (play_area.h * y_ratio_continue); text_.renderCentered(CONTINUE_TEXT, {.x = center_x, .y = centre_y_continue}, escala_continue, SPACING); // Countdown number (using constants) const std::string COUNTER_STR = std::to_string(continue_counter_); float escala_counter = Defaults::Game::ContinueScreen::COUNTER_TEXT_SCALE; float y_ratio_counter = Defaults::Game::ContinueScreen::COUNTER_TEXT_Y_RATIO; float centre_y_counter = play_area.y + (play_area.h * y_ratio_counter); text_.renderCentered(COUNTER_STR, {.x = center_x, .y = centre_y_counter}, escala_counter, SPACING); // "CONTINUES LEFT" (conditional + using constants) if (!Defaults::Game::INFINITE_CONTINUES) { const std::string CONTINUES_TEXT = localeSubstitute( Locale::get().text("game_screen.continues_left"), "{n}", std::to_string(Defaults::Game::MAX_CONTINUES - continues_used_)); float escala_info = Defaults::Game::ContinueScreen::INFO_TEXT_SCALE; float y_ratio_info = Defaults::Game::ContinueScreen::INFO_TEXT_Y_RATIO; float centre_y_info = play_area.y + (play_area.h * y_ratio_info); text_.renderCentered(CONTINUES_TEXT, {.x = center_x, .y = centre_y_info}, escala_info, SPACING); } } void GameScene::joinPlayer(uint8_t player_id) { // Activate player if (player_id == 0) { match_config_.player1_active = true; } else { match_config_.player2_active = true; } // Reset stats lives_per_player_[player_id] = Defaults::Game::STARTING_LIVES; score_per_player_[player_id] = 0; hit_timer_per_player_[player_id] = 0.0F; // Spawn with invulnerability Vec2 spawn_pos = getSpawnPoint(player_id); ships_[player_id].init(&spawn_pos, true); // Registrar el cos físic al món. Si el jugador començà inactiu, el // constructor no l'havia afegit; sense això, applyForce s'acumula // però mai s'integra → la nau no es desplaça. physics_world_.addBody(&ships_[player_id].getBody()); std::cout << "[GameScene] Jugador " << (int)(player_id + 1) << " s'ha unit a la match!" << '\n'; }