From c1956e00280e493157e41bdb5109dfbe1e85f4c6 Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Thu, 28 May 2026 12:01:12 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat(demo):=20attract=20mode=20amb=20pilot?= =?UTF-8?q?=20IA,=20escenaris=20curats=20i=20m=C3=BAsica=20cont=C3=ADnua?= =?UTF-8?q?=20del=20t=C3=ADtol?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- source/core/system/scene_context.hpp | 14 ++ source/game/entities/ship.cpp | 14 +- source/game/entities/ship.hpp | 3 + source/game/scenes/game_scene.cpp | 111 +++++++++++--- source/game/scenes/game_scene.hpp | 13 ++ source/game/scenes/title_scene.cpp | 29 +++- source/game/scenes/title_scene.hpp | 5 + source/game/stage_system/stage_manager.cpp | 17 +++ source/game/stage_system/stage_manager.hpp | 5 + source/game/systems/demo_pilot.cpp | 162 +++++++++++++++++++++ source/game/systems/demo_pilot.hpp | 58 ++++++++ 11 files changed, 404 insertions(+), 27 deletions(-) create mode 100644 source/game/systems/demo_pilot.cpp create mode 100644 source/game/systems/demo_pilot.hpp diff --git a/source/core/system/scene_context.hpp b/source/core/system/scene_context.hpp index 7cf3c30..93485cb 100644 --- a/source/core/system/scene_context.hpp +++ b/source/core/system/scene_context.hpp @@ -70,10 +70,24 @@ namespace SceneManager { return match_config_; } + // Nombre d'escenaris demo curats (cicle del attract mode). + static constexpr std::uint8_t DEMO_SCENARIO_COUNT = 3; + + // Índex de l'escenari demo actual. Persisteix entre transicions (el + // SceneContext el posseeix el Director), així cada entrada al mode demo + // mostra el següent escenari de la llista curada. + [[nodiscard]] auto demoScenarioIndex() const -> std::uint8_t { + return demo_scenario_index_; + } + void advanceDemoScenario() { + demo_scenario_index_ = (demo_scenario_index_ + 1) % DEMO_SCENARIO_COUNT; + } + private: SceneType next_scene_{SceneType::LOGO}; // SceneType a la qual transicionar Option option_{Option::NONE}; // Opción específica per l'escena GameConfig::MatchConfig match_config_; // Configuración de match (jugadors active, mode) + std::uint8_t demo_scenario_index_{0}; // Índex de l'escenari demo (attract mode) }; // Variable global inline per gestionar l'escena actual (backward compatibility) diff --git a/source/game/entities/ship.cpp b/source/game/entities/ship.cpp index 3292238..daf99d6 100644 --- a/source/game/entities/ship.cpp +++ b/source/game/entities/ship.cpp @@ -85,16 +85,24 @@ void Ship::processInput(float delta_time, uint8_t player_id) { ? input->checkActionPlayer1(InputAction::THRUST, Input::ALLOW_REPEAT) : input->checkActionPlayer2(InputAction::THRUST, Input::ALLOW_REPEAT); - if (ROTATE_RIGHT) { + applyMovement(ROTATE_LEFT, ROTATE_RIGHT, THRUST, delta_time); +} + +void Ship::applyMovement(bool rotate_left, bool rotate_right, bool thrust, float delta_time) { + if (is_hit_) { + return; + } + + if (rotate_right) { body_.angle += config_.physics.rotation_speed * delta_time; } - if (ROTATE_LEFT) { + if (rotate_left) { body_.angle -= config_.physics.rotation_speed * delta_time; } // Thrust: fuerza vectorial en la dirección de la nariz. // angle - PI/2 porque angle=0 apunta hacia arriba (eje Y negativo SDL). - if (THRUST) { + if (thrust) { const float DIR_X = std::cos(body_.angle - (Constants::PI / 2.0F)); const float DIR_Y = std::sin(body_.angle - (Constants::PI / 2.0F)); const float MAGNITUDE = body_.mass * config_.physics.acceleration; diff --git a/source/game/entities/ship.hpp b/source/game/entities/ship.hpp index c9e4b4b..73d89ee 100644 --- a/source/game/entities/ship.hpp +++ b/source/game/entities/ship.hpp @@ -22,6 +22,9 @@ class Ship : public Entities::Entity { void init() override { init(nullptr, false); } void init(const Vec2* spawn_point, bool activar_invulnerabilitat = false); void processInput(float delta_time, uint8_t player_id); + // Aplica rotació/empenta des de booleans de control (mateixa física que + // processInput, però sense llegir Input). Usat pel pilot IA del mode demo. + void applyMovement(bool rotate_left, bool rotate_right, bool thrust, float delta_time); void update(float delta_time) override; void postUpdate(float delta_time) override; void draw() const override; diff --git a/source/game/scenes/game_scene.cpp b/source/game/scenes/game_scene.cpp index 69ff513..8d55e37 100644 --- a/source/game/scenes/game_scene.cpp +++ b/source/game/scenes/game_scene.cpp @@ -4,6 +4,7 @@ #include "game_scene.hpp" #include +#include #include #include #include @@ -12,6 +13,7 @@ #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" @@ -29,6 +31,22 @@ using SceneManager::SceneContext; using SceneType = SceneContext::SceneType; using Option = SceneContext::Option; +namespace { + // Attract mode: duració màxima de la demo i marge perquè es vegi l'explosió + // de la nau abans de saltar al logo (menor que DEATH_DURATION=3s per evitar + // la seqüència de respawn/continue). + constexpr float DEMO_DURATION = 35.0F; + constexpr float DEMO_DEATH_LINGER = 2.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}; +} // namespace + GameScene::GameScene(SDLManager& sdl, SceneContext& context) : sdl_(sdl), context_(context), @@ -139,7 +157,17 @@ GameScene::GameScene(SDLManager& sdl, SceneContext& context) // Initialize stage manager stage_manager_ = std::make_unique(stage_config_.get()); - stage_manager_->init(); + 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. + const uint8_t DEMO_STAGE = Systems::Demo::scenarioStage(context_.demoScenarioIndex()); + context_.advanceDemoScenario(); + stage_manager_->initDemo(DEMO_STAGE); + demo_timer_ = DEMO_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()); @@ -226,7 +254,12 @@ void GameScene::update(float delta_time) { // mantener update() legible y reducir complejidad cognitiva. stepPhysics(delta_time); - if (game_over_state_ == GameOverState::NONE) { + 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(); } @@ -299,6 +332,56 @@ void GameScene::stepShootingInput() { } } +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, la P1 es mou amb el pilot IA (control calculat a stepDemo); + // la resta de casos llegeixen Input com sempre. + if (DEMO && i == 0) { + ships_[0].applyMovement(demo_ctrl_.left, demo_ctrl_.right, demo_ctrl_.thrust, delta_time); + } else { + ships_[i].processInput(delta_time, i); + } + ships_[i].update(delta_time); + } +} + +auto GameScene::stepDemo(float delta_time) -> bool { + // 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; + } + + // En morir la nau, escurçar el timer perquè es vegi l'explosió i després + // saltar al logo (sense passar per CONTINUE/GAME_OVER ni parar la música). + if (hit_timer_per_player_[0] > 0.0F) { + demo_timer_ = std::min(demo_timer_, DEMO_DEATH_LINGER); + } + + demo_timer_ -= delta_time; + if (demo_timer_ <= 0.0F) { + endDemo(); + return true; + } + + // Control del pilot per al frame; el disparo el dispara GameScene. + demo_ctrl_ = demo_pilot_.compute(ships_[0], enemies_, Defaults::Zones::PLAYAREA, delta_time); + if (demo_ctrl_.shoot) { + fireBullet(0); + } + 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) { @@ -496,13 +579,7 @@ void GameScene::runStageLevelStart(float delta_time) { stage_manager_->update(delta_time); // Ambas naves pueden moverse y disparar durante el intro. - 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) { - ships_[i].processInput(delta_time, i); - ships_[i].update(delta_time); - } - } + updateShipsControl(delta_time); for (auto& bullet : bullets_) { bullet.update(delta_time); } @@ -524,13 +601,7 @@ void GameScene::runStagePlaying(float delta_time) { } // Gameplay normal: ships activos + entidades + colisiones + efectos. - 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) { - ships_[i].processInput(delta_time, i); - ships_[i].update(delta_time); - } - } + 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); @@ -552,13 +623,7 @@ void GameScene::runStagePlaying(float delta_time) { void GameScene::runStageLevelCompleted(float delta_time) { stage_manager_->update(delta_time); - 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) { - ships_[i].processInput(delta_time, i); - ships_[i].update(delta_time); - } - } + updateShipsControl(delta_time); for (auto& bullet : bullets_) { bullet.update(delta_time); } diff --git a/source/game/scenes/game_scene.hpp b/source/game/scenes/game_scene.hpp index a13f5a8..a5fbcc6 100644 --- a/source/game/scenes/game_scene.hpp +++ b/source/game/scenes/game_scene.hpp @@ -29,6 +29,7 @@ #include "game/stage_system/stage_config.hpp" #include "game/stage_system/stage_manager.hpp" #include "game/systems/collision_system.hpp" +#include "game/systems/demo_pilot.hpp" #include "game/systems/init_hud_animator.hpp" // Game over state machine @@ -101,6 +102,11 @@ class GameScene final : public Scene { // Control de sons de animación INIT_HUD bool init_hud_rect_sound_played_{false}; // Flag para evitar repetir sonido del rectángulo + // Attract mode (mode DEMO): un pilot IA controla la nau P1. + Systems::Demo::DemoPilot demo_pilot_; + Systems::Demo::Control demo_ctrl_{}; // Control del pilot per al frame actual + float demo_timer_{0.0F}; // Temps restant de la demo (→ LOGO en esgotar-se) + // Funciones privades // bullet_velocity: velocitat de la bala que ha causat la mort (Vec2{} si no // ve d'una bala). Es passa al debris perquè els fragments volin en direcció @@ -138,6 +144,13 @@ class GameScene final : public Scene { void stepPhysics(float delta_time); void stepShootingInput(); void stepMidGameJoin(); + // Mueve las naves activas: en mode DEMO la P1 usa el pilot IA (demo_ctrl_), + // el resto usa processInput (Input). Compartido por los 3 estados jugables. + void updateShipsControl(float delta_time); + // Mode DEMO: gestiona salida (input→título, timeout/muerte→logo) y calcula + // el control + disparo del pilot. Devuelve true si la escena transiciona. + [[nodiscard]] auto stepDemo(float delta_time) -> bool; + void endDemo(); // Devuelven true si el frame debe salir tras esta sección. [[nodiscard]] auto stepContinueScreen(float delta_time) -> bool; [[nodiscard]] auto stepGameOver(float delta_time) -> bool; diff --git a/source/game/scenes/title_scene.cpp b/source/game/scenes/title_scene.cpp index e064d2f..1464444 100644 --- a/source/game/scenes/title_scene.cpp +++ b/source/game/scenes/title_scene.cpp @@ -94,7 +94,15 @@ TitleScene::TitleScene(SDLManager& sdl, SceneContext& context) } TitleScene::~TitleScene() { - Audio::get()->stopMusic(); + // 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() { @@ -340,6 +348,25 @@ void TitleScene::update(float delta_time) { 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) { + GameConfig::MatchConfig demo_cfg; + demo_cfg.player1_active = true; + demo_cfg.player2_active = false; + demo_cfg.mode = GameConfig::Mode::DEMO; + context_.setMatchConfig(demo_cfg); + context_.setNextScene(SceneType::GAME); + } + } } void TitleScene::updateStarfieldFadeInState(float delta_time) { diff --git a/source/game/scenes/title_scene.hpp b/source/game/scenes/title_scene.hpp index ac26c7d..9dafdb8 100644 --- a/source/game/scenes/title_scene.hpp +++ b/source/game/scenes/title_scene.hpp @@ -79,6 +79,7 @@ class TitleScene final : public Scene { void drawFlashes(); TitleState current_state_{TitleState::STARFIELD_FADE_IN}; float temps_acumulat_{0.0F}; + float idle_timer_{0.0F}; // Attract mode: inactivitat acumulada al state MAIN std::vector letters_orni_; std::vector letters_attack_; @@ -110,6 +111,10 @@ class TitleScene final : public Scene { static constexpr float DURATION_BLACK_SCREEN = 2.0F; static constexpr int MUSIC_FADE = 1500; + // Attract mode: temps d'inactivitat al títol (state MAIN) abans de saltar + // a la demo. Qualsevol input el reseteja. + static constexpr float TITLE_DEMO_TIMEOUT = 20.0F; + static constexpr float ORBIT_AMPLITUDE_X = 4.0F; static constexpr float ORBIT_AMPLITUDE_Y = 3.0F; static constexpr float ORBIT_FREQUENCY_X = 0.8F; diff --git a/source/game/stage_system/stage_manager.cpp b/source/game/stage_system/stage_manager.cpp index 0b42138..d13e151 100644 --- a/source/game/stage_system/stage_manager.cpp +++ b/source/game/stage_system/stage_manager.cpp @@ -31,6 +31,23 @@ namespace StageSystem { << '\n'; } + void StageManager::initDemo(uint8_t stage_id) { + if (config_ == nullptr) { + std::cerr << "[StageManager] Error: config es null, no es pot iniciar la demo\n"; + return; + } + // Clamp al rang vàlid: si l'escenari demana un stage inexistent, cau a 1. + if (config_->findStage(stage_id) == nullptr) { + stage_id = 1; + } + stage_actual_ = stage_id; + loadStage(stage_actual_); + changeState(EstatStage::PLAYING); + + std::cout << "[StageManager] Demo inicialitzada a stage " + << static_cast(stage_actual_) << '\n'; + } + void StageManager::update(float delta_time, bool pause_spawn) { switch (estat_) { case EstatStage::INIT_HUD: diff --git a/source/game/stage_system/stage_manager.hpp b/source/game/stage_system/stage_manager.hpp index 252cbfd..650b33e 100644 --- a/source/game/stage_system/stage_manager.hpp +++ b/source/game/stage_system/stage_manager.hpp @@ -25,6 +25,11 @@ namespace StageSystem { // Lifecycle void init(); // Reset to stage 1 + // Arranque para el mode demo (attract): carga stage_id y entra directamente + // en PLAYING, saltando INIT_HUD y "ENEMY INCOMING" para mostrar una partida + // ya en marcha. No reproduce game.ogg (changeState solo lo hace en LEVEL_START), + // así la música del título sigue sonando durante el ciclo atrae. + void initDemo(uint8_t stage_id); void update(float delta_time, bool pause_spawn = false); // Stage progression diff --git a/source/game/systems/demo_pilot.cpp b/source/game/systems/demo_pilot.cpp new file mode 100644 index 0000000..2078e74 --- /dev/null +++ b/source/game/systems/demo_pilot.cpp @@ -0,0 +1,162 @@ +// demo_pilot.cpp - Implementación de la IA del attract mode +// © 2026 JailDesigner + +#include "game/systems/demo_pilot.hpp" + +#include +#include + +#include "core/types.hpp" + +namespace Systems::Demo { + + namespace { + + constexpr float PI = Constants::PI; + constexpr float TWO_PI = 2.0F * PI; + + // Cadencia y reacción (segundos). + constexpr float RETARGET_INTERVAL = 0.15F; // re-evaluar objetivo (reacción humana) + constexpr float FIRE_COOLDOWN = 0.32F; // entre disparos + + // Apuntado. + constexpr float ROTATE_DEADZONE = 0.05F; // rad: zona muerta para no oscilar + constexpr float FIRE_TOLERANCE = 0.18F; // rad: error máximo para disparar + constexpr float AIM_JITTER_MAX = 0.10F; // rad: error de apuntado humano + constexpr float LEAD_TIME = 0.30F; // s: anticipación sobre el enemigo + + // Distancias (px). + constexpr float DANGER_RADIUS = 95.0F; // por debajo: maniobra de esquiva + constexpr float APPROACH_RADIUS = 250.0F; // por encima: acercarse al objetivo + constexpr float WALL_MARGIN = 60.0F; // px desde el borde: sesgo al centro + + constexpr float WALL_BIAS = 0.6F; // peso del empuje hacia el centro al esquivar + + // [-1, 1] aleatorio (estética: jitter de apuntado; no afecta a la simulación). + auto randSigned() -> float { + return (static_cast(std::rand()) / static_cast(RAND_MAX) * 2.0F) - 1.0F; + } + + // Envuelve a [-PI, PI]. + auto wrapPi(float angle) -> float { + return std::remainder(angle, TWO_PI); + } + + struct Nearest { + int index{-1}; + float distance{0.0F}; + Vec2 center{}; + Vec2 velocity{}; + }; + + auto findNearest(const Vec2& from, + const std::array& enemies) -> Nearest { + Nearest best; + float best_d2 = 0.0F; + for (std::size_t i = 0; i < enemies.size(); ++i) { + if (!enemies[i].isActive()) { + continue; + } + const Vec2 C = enemies[i].getCenter(); + const float D2 = (C - from).lengthSquared(); + if (best.index == -1 || D2 < best_d2) { + best.index = static_cast(i); + best_d2 = D2; + best.center = C; + best.velocity = enemies[i].getVelocityVector(); + } + } + if (best.index != -1) { + best.distance = std::sqrt(best_d2); + } + return best; + } + + } // namespace + + auto DemoPilot::compute(const Ship& ship, + const std::array& enemies, + const SDL_FRect& play_area, + float delta_time) -> Control { + Control ctrl; + + if (fire_cooldown_ > 0.0F) { + fire_cooldown_ -= delta_time; + } + retarget_timer_ -= delta_time; + if (retarget_timer_ <= 0.0F) { + retarget_timer_ = RETARGET_INTERVAL; + aim_jitter_ = randSigned() * AIM_JITTER_MAX; + } + + if (!ship.isActive()) { + return ctrl; // nave muerta: sin control + } + + const Vec2 SHIP_POS = ship.getCenter(); + const Nearest TARGET = findNearest(SHIP_POS, enemies); + target_idx_ = TARGET.index; + + // Sin enemigos: deriva tranquila (gira despacio, sin empuje ni disparo). + if (TARGET.index == -1) { + ctrl.right = true; + return ctrl; + } + + // Centro de la zona de juego (sesgo anti-pared). + const Vec2 PLAY_CENTRE{ + .x = play_area.x + (play_area.w / 2.0F), + .y = play_area.y + (play_area.h / 2.0F)}; + const bool NEAR_WALL = + SHIP_POS.x < play_area.x + WALL_MARGIN || + SHIP_POS.x > play_area.x + play_area.w - WALL_MARGIN || + SHIP_POS.y < play_area.y + WALL_MARGIN || + SHIP_POS.y > play_area.y + play_area.h - WALL_MARGIN; + + Vec2 desired_dir; + const bool DANGER = TARGET.distance < DANGER_RADIUS; + if (DANGER) { + // Esquiva: alejarse del enemigo, con sesgo hacia el centro para no + // quedar atrapada contra la pared. Empuje activo para crear espacio. + const Vec2 AWAY = (SHIP_POS - TARGET.center).normalized(); + const Vec2 TO_CENTRE = (PLAY_CENTRE - SHIP_POS).normalized(); + desired_dir = (AWAY + (TO_CENTRE * WALL_BIAS)).normalized(); + ctrl.thrust = true; + } else { + // Combate: apuntar al enemigo con lead + jitter. + const Vec2 PREDICTED = TARGET.center + (TARGET.velocity * LEAD_TIME); + desired_dir = (PREDICTED - SHIP_POS).normalized(); + } + + // Ángulo deseado de la nariz; la nariz apunta hacia (angle - PI/2). + float desired_angle = std::atan2(desired_dir.y, desired_dir.x) + aim_jitter_; + const float NOSE_ANGLE = ship.getAngle() - (PI / 2.0F); + const float ERROR = wrapPi(desired_angle - NOSE_ANGLE); + + // RIGHT incrementa ship.angle (→ nariz); LEFT lo decrementa. + if (ERROR > ROTATE_DEADZONE) { + ctrl.right = true; + } else if (ERROR < -ROTATE_DEADZONE) { + ctrl.left = true; + } + + if (!DANGER) { + // Acercarse si el objetivo está lejos (mantiene la nave cazando), + // pero no empujar de cara a una pared. + const bool FACING_WALL = NEAR_WALL && + (PLAY_CENTRE - SHIP_POS).normalized().dot(desired_dir) < 0.0F; + if (TARGET.distance > APPROACH_RADIUS && !FACING_WALL && + std::fabs(ERROR) < FIRE_TOLERANCE) { + ctrl.thrust = true; + } + // Disparar cuando está bien encarada y el cooldown lo permite. + if (std::fabs(ERROR) < FIRE_TOLERANCE && fire_cooldown_ <= 0.0F) { + ctrl.shoot = true; + fire_cooldown_ = FIRE_COOLDOWN; + } + } + + return ctrl; + } + +} // namespace Systems::Demo diff --git a/source/game/systems/demo_pilot.hpp b/source/game/systems/demo_pilot.hpp new file mode 100644 index 0000000..c1ad50f --- /dev/null +++ b/source/game/systems/demo_pilot.hpp @@ -0,0 +1,58 @@ +// demo_pilot.hpp - IA de piloto para el attract mode (demo jugándose sola) +// © 2026 JailDesigner +// +// Calcula, cada frame, los controles de la nave (rotar/empujar/disparar) leyendo +// el estado de juego en solo-lectura. No lee Input ni muta entidades: GameScene +// aplica el Control resultante via Ship::applyMovement + fireBullet. +// +// El comportamiento busca parecer "humano", no óptimo: apuntado con lead y un +// pequeño error, cadencia de disparo con cooldown, esquiva por radio de peligro +// y sesgo hacia el centro para no pegarse a las paredes. + +#pragma once + +#include + +#include +#include + +#include "core/system/scene_context.hpp" +#include "game/constants.hpp" +#include "game/entities/enemy.hpp" +#include "game/entities/ship.hpp" + +namespace Systems::Demo { + + // Stages de arranque de cada escenario curado (se ciclan en attract mode). + // Elegidos por variedad visual: pinwheels, stars, squares/mix. + inline constexpr std::array + SCENARIO_STAGES = {2, 5, 7}; + + // Stage de arranque para el índice de escenario dado. + [[nodiscard]] inline auto scenarioStage(std::uint8_t index) -> std::uint8_t { + return SCENARIO_STAGES.at(index % SCENARIO_STAGES.size()); + } + + // Control de la nave para un frame. + struct Control { + bool left{false}; + bool right{false}; + bool thrust{false}; + bool shoot{false}; + }; + + class DemoPilot { + public: + [[nodiscard]] auto compute(const Ship& ship, + const std::array& enemies, + const SDL_FRect& play_area, + float delta_time) -> Control; + + private: + float retarget_timer_{0.0F}; // re-evalúa objetivo cada RETARGET_INTERVAL + float fire_cooldown_{0.0F}; // tiempo restante hasta poder disparar + float aim_jitter_{0.0F}; // error de apuntado, refrescado al retargetar + int target_idx_{-1}; // índice del enemigo objetivo (o -1) + }; + +} // namespace Systems::Demo From 92f76d091d574f1378a852c9d856b6a46dd5186f Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Thu, 28 May 2026 13:13:36 +0200 Subject: [PATCH 2/3] =?UTF-8?q?fix(col=C2=B7lisions):=20les=20bales=20d'en?= =?UTF-8?q?emic=20deixen=20de=20comptar=20com=20a=20foc=20amic=20(out-of-b?= =?UTF-8?q?ounds=20a=20lives=5Fper=5Fplayer)=20i=20maten=20d'un=20toc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- source/game/systems/collision_system.cpp | 25 ++++++++++-------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/source/game/systems/collision_system.cpp b/source/game/systems/collision_system.cpp index 1db2c63..6db6382 100644 --- a/source/game/systems/collision_system.cpp +++ b/source/game/systems/collision_system.cpp @@ -145,6 +145,12 @@ namespace Systems::Collision { continue; } const uint8_t BULLET_OWNER = bullet.getOwnerId(); + // Les bales d'enemic NO són friendly fire: les gestiona + // detectEnemyBulletShip. Sense aquest filtre, owner >= ENEMY_OWNER_BASE + // entraria ací i faria un out-of-bounds a lives_per_player[owner]. + if (BULLET_OWNER >= Defaults::Entities::ENEMY_OWNER_BASE) { + continue; + } for (uint8_t player_id = 0; player_id < 2; player_id++) { // Una bala mai no impacta al seu propi shooter: les bales d'aquest joc no @@ -208,22 +214,11 @@ namespace Systems::Collision { } // *** BALA D'ENEMIC → SHIP *** - // Regla "cos XOR trossos": l'impuls de la bala s'aplica al cos - // només si el ship sobreviu (fereix). Si el ship mor, el bullet - // va directament als trossos (via tocado) i el cos no rep impuls - // — els trossos ja porten la força de la bala, qualsevol impuls - // afegit al cos seria double-count. + // Mata d'un sol toc (decisió de disseny). El bullet va als trossos + // via tocado; el cos no rep impuls (seria double-count amb la + // velocitat que ja hereten els trossos). const Vec2 BULLET_VEL = bullet.getBody().velocity; - if (ctx.ships[player_id].isHurt()) { - // Segon impacte durant HURT → mort. - ctx.on_player_hit(player_id, BULLET_VEL); - } else { - // Fereix: el cos sobreviu, rep l'impuls. No hi ha debris encara. - const Vec2 IMPULSE = BULLET_VEL * - (bullet.getBody().mass * bullet.getConfig().physics.impact_momentum_factor); - ctx.ships[player_id].getBody().applyImpulse(IMPULSE); - ctx.ships[player_id].hurt(); - } + ctx.on_player_hit(player_id, BULLET_VEL); breakBullet(ctx.debris_manager, bullet); break; // una bala impacta una vegada per frame } From 455b7a68936f6719074cd6a4c8bb878470d4bcda Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Thu, 28 May 2026 13:14:19 +0200 Subject: [PATCH 3/3] feat(demo): demos a 1 i 2 jugadors, esquiva de bales enemigues i vides infinites --- source/core/system/scene_context.hpp | 2 +- source/game/scenes/game_scene.cpp | 58 ++++++++------ source/game/scenes/game_scene.hpp | 10 +-- source/game/scenes/title_scene.cpp | 6 +- source/game/systems/demo_pilot.cpp | 109 ++++++++++++++++++++++----- source/game/systems/demo_pilot.hpp | 28 +++++-- 6 files changed, 158 insertions(+), 55 deletions(-) diff --git a/source/core/system/scene_context.hpp b/source/core/system/scene_context.hpp index 93485cb..67ca5c5 100644 --- a/source/core/system/scene_context.hpp +++ b/source/core/system/scene_context.hpp @@ -71,7 +71,7 @@ namespace SceneManager { } // Nombre d'escenaris demo curats (cicle del attract mode). - static constexpr std::uint8_t DEMO_SCENARIO_COUNT = 3; + static constexpr std::uint8_t DEMO_SCENARIO_COUNT = 4; // Índex de l'escenari demo actual. Persisteix entre transicions (el // SceneContext el posseeix el Director), així cada entrada al mode demo diff --git a/source/game/scenes/game_scene.cpp b/source/game/scenes/game_scene.cpp index 8d55e37..7e2b40f 100644 --- a/source/game/scenes/game_scene.cpp +++ b/source/game/scenes/game_scene.cpp @@ -32,11 +32,9 @@ using SceneType = SceneContext::SceneType; using Option = SceneContext::Option; namespace { - // Attract mode: duració màxima de la demo i marge perquè es vegi l'explosió - // de la nau abans de saltar al logo (menor que DEATH_DURATION=3s per evitar - // la seqüència de respawn/continue). + // 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; - constexpr float DEMO_DEATH_LINGER = 2.0F; // Qualsevol d'aquestes accions trenca la demo i torna al títol. constexpr std::array DEMO_EXIT_ACTIONS = { @@ -160,10 +158,11 @@ GameScene::GameScene(SDLManager& sdl, SceneContext& context) 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. - const uint8_t DEMO_STAGE = Systems::Demo::scenarioStage(context_.demoScenarioIndex()); + // 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(DEMO_STAGE); + stage_manager_->initDemo(SC.stage); demo_timer_ = DEMO_DURATION; } else { stage_manager_->init(); @@ -339,10 +338,10 @@ void GameScene::updateShipsControl(float delta_time) { if (!ACTIU || hit_timer_per_player_[i] != 0.0F) { continue; } - // En demo, la P1 es mou amb el pilot IA (control calculat a stepDemo); - // la resta de casos llegeixen Input com sempre. - if (DEMO && i == 0) { - ships_[0].applyMovement(demo_ctrl_.left, demo_ctrl_.right, demo_ctrl_.thrust, delta_time); + // 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); } @@ -357,22 +356,30 @@ auto GameScene::stepDemo(float delta_time) -> bool { return true; } - // En morir la nau, escurçar el timer perquè es vegi l'explosió i després - // saltar al logo (sense passar per CONTINUE/GAME_OVER ni parar la música). - if (hit_timer_per_player_[0] > 0.0F) { - demo_timer_ = std::min(demo_timer_, DEMO_DEATH_LINGER); - } - + // 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 del pilot per al frame; el disparo el dispara GameScene. - demo_ctrl_ = demo_pilot_.compute(ships_[0], enemies_, Defaults::Zones::PLAYAREA, delta_time); - if (demo_ctrl_.shoot) { - fireBullet(0); + // 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; } @@ -491,6 +498,15 @@ void GameScene::stepDeathSequence(float delta_time) { } // *** 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); diff --git a/source/game/scenes/game_scene.hpp b/source/game/scenes/game_scene.hpp index a5fbcc6..e2d2cc4 100644 --- a/source/game/scenes/game_scene.hpp +++ b/source/game/scenes/game_scene.hpp @@ -102,10 +102,10 @@ class GameScene final : public Scene { // Control de sons de animación INIT_HUD bool init_hud_rect_sound_played_{false}; // Flag para evitar repetir sonido del rectángulo - // Attract mode (mode DEMO): un pilot IA controla la nau P1. - Systems::Demo::DemoPilot demo_pilot_; - Systems::Demo::Control demo_ctrl_{}; // Control del pilot per al frame actual - float demo_timer_{0.0F}; // Temps restant de la demo (→ LOGO en esgotar-se) + // Attract mode (mode DEMO): un pilot IA per nau activa (1 o 2 jugadors). + std::array demo_pilots_; + std::array demo_ctrls_{}; // Control per nau al frame actual + float demo_timer_{0.0F}; // Temps restant de la demo (→ LOGO) // Funciones privades // bullet_velocity: velocitat de la bala que ha causat la mort (Vec2{} si no @@ -144,7 +144,7 @@ class GameScene final : public Scene { void stepPhysics(float delta_time); void stepShootingInput(); void stepMidGameJoin(); - // Mueve las naves activas: en mode DEMO la P1 usa el pilot IA (demo_ctrl_), + // Mueve las naves activas: en mode DEMO cada nau usa su pilot IA (demo_ctrls_), // el resto usa processInput (Input). Compartido por los 3 estados jugables. void updateShipsControl(float delta_time); // Mode DEMO: gestiona salida (input→título, timeout/muerte→logo) y calcula diff --git a/source/game/scenes/title_scene.cpp b/source/game/scenes/title_scene.cpp index 1464444..d8a1968 100644 --- a/source/game/scenes/title_scene.cpp +++ b/source/game/scenes/title_scene.cpp @@ -20,6 +20,7 @@ #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; @@ -359,9 +360,12 @@ void TitleScene::update(float delta_time) { 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 = false; + demo_cfg.player2_active = (SC.players >= 2); demo_cfg.mode = GameConfig::Mode::DEMO; context_.setMatchConfig(demo_cfg); context_.setNextScene(SceneType::GAME); diff --git a/source/game/systems/demo_pilot.cpp b/source/game/systems/demo_pilot.cpp index 2078e74..468907e 100644 --- a/source/game/systems/demo_pilot.cpp +++ b/source/game/systems/demo_pilot.cpp @@ -32,6 +32,10 @@ namespace Systems::Demo { constexpr float WALL_BIAS = 0.6F; // peso del empuje hacia el centro al esquivar + // Esquiva de balas enemigas. + constexpr float DODGE_SCAN_RADIUS = 190.0F; // px: distancia a la que reacciona + constexpr float DODGE_HEADING_MIN = 0.25F; // dot mínimo: la bala viene hacia la nave + // [-1, 1] aleatorio (estética: jitter de apuntado; no afecta a la simulación). auto randSigned() -> float { return (static_cast(std::rand()) / static_cast(RAND_MAX) * 2.0F) - 1.0F; @@ -72,10 +76,71 @@ namespace Systems::Demo { return best; } + struct BulletThreat { + bool found{false}; + Vec2 position{}; + Vec2 velocity{}; + }; + + // Bala enemiga más cercana que viene hacia la nave (dentro del radio de + // reacción). Solo balas de enemic (owner_id >= ENEMY_OWNER_BASE). + auto findBulletThreat(const Vec2& ship_pos, + const std::array(Defaults::Entities::MAX_BULLETS_TOTAL)>& bullets) + -> BulletThreat { + BulletThreat threat; + float best_d2 = 0.0F; + for (const auto& bullet : bullets) { + if (!bullet.isActive() || + bullet.getOwnerId() < Defaults::Entities::ENEMY_OWNER_BASE) { + continue; + } + const Vec2 BPOS = bullet.getCenter(); + const Vec2 TO_SHIP = ship_pos - BPOS; + const float D2 = TO_SHIP.lengthSquared(); + if (D2 > DODGE_SCAN_RADIUS * DODGE_SCAN_RADIUS) { + continue; + } + const Vec2 BVEL = bullet.getBody().velocity; + if (BVEL.lengthSquared() < 1.0F) { + continue; + } + // ¿La bala se dirige hacia la nave? + if (BVEL.normalized().dot(TO_SHIP.normalized()) < DODGE_HEADING_MIN) { + continue; + } + if (!threat.found || D2 < best_d2) { + threat.found = true; + best_d2 = D2; + threat.position = BPOS; + threat.velocity = BVEL; + } + } + return threat; + } + + // Error angular (rad, [-PI,PI]) entre la nariz de la nave y desired_dir, + // con el jitter de apuntado aplicado. La nariz apunta hacia (angle - PI/2). + auto steerError(const Ship& ship, const Vec2& desired_dir, float jitter) -> float { + const float DESIRED = std::atan2(desired_dir.y, desired_dir.x) + jitter; + const float NOSE = ship.getAngle() - (PI / 2.0F); + return wrapPi(DESIRED - NOSE); + } + + // Setea rotación según el error (RIGHT incrementa ship.angle; LEFT lo + // decrementa), con zona muerta para no oscilar. + void applyRotation(Control& ctrl, float error) { + if (error > ROTATE_DEADZONE) { + ctrl.right = true; + } else if (error < -ROTATE_DEADZONE) { + ctrl.left = true; + } + } + } // namespace auto DemoPilot::compute(const Ship& ship, const std::array& enemies, + const std::array(Defaults::Entities::MAX_BULLETS_TOTAL)>& bullets, const SDL_FRect& play_area, float delta_time) -> Control { Control ctrl; @@ -94,6 +159,26 @@ namespace Systems::Demo { } const Vec2 SHIP_POS = ship.getCenter(); + const Vec2 PLAY_CENTRE{ + .x = play_area.x + (play_area.w / 2.0F), + .y = play_area.y + (play_area.h / 2.0F)}; + const Vec2 TO_CENTRE = (PLAY_CENTRE - SHIP_POS).normalized(); + + // Prioridad 1: esquivar una bala enemiga entrante. Se mueve perpendicular + // a la trayectoria de la bala (con sesgo al centro) y no dispara. + const BulletThreat THREAT = findBulletThreat(SHIP_POS, bullets); + if (THREAT.found) { + const Vec2 BV = THREAT.velocity.normalized(); + Vec2 perp{.x = -BV.y, .y = BV.x}; + if ((SHIP_POS - THREAT.position).dot(perp) < 0.0F) { + perp = -perp; // hacia el lado en que ya está la nave + } + const Vec2 ESCAPE = (perp + (TO_CENTRE * WALL_BIAS)).normalized(); + applyRotation(ctrl, steerError(ship, ESCAPE, aim_jitter_)); + ctrl.thrust = true; + return ctrl; + } + const Nearest TARGET = findNearest(SHIP_POS, enemies); target_idx_ = TARGET.index; @@ -103,10 +188,6 @@ namespace Systems::Demo { return ctrl; } - // Centro de la zona de juego (sesgo anti-pared). - const Vec2 PLAY_CENTRE{ - .x = play_area.x + (play_area.w / 2.0F), - .y = play_area.y + (play_area.h / 2.0F)}; const bool NEAR_WALL = SHIP_POS.x < play_area.x + WALL_MARGIN || SHIP_POS.x > play_area.x + play_area.w - WALL_MARGIN || @@ -116,10 +197,8 @@ namespace Systems::Demo { Vec2 desired_dir; const bool DANGER = TARGET.distance < DANGER_RADIUS; if (DANGER) { - // Esquiva: alejarse del enemigo, con sesgo hacia el centro para no - // quedar atrapada contra la pared. Empuje activo para crear espacio. + // Prioridad 2: alejarse del enemigo pegado, con sesgo al centro. const Vec2 AWAY = (SHIP_POS - TARGET.center).normalized(); - const Vec2 TO_CENTRE = (PLAY_CENTRE - SHIP_POS).normalized(); desired_dir = (AWAY + (TO_CENTRE * WALL_BIAS)).normalized(); ctrl.thrust = true; } else { @@ -128,23 +207,13 @@ namespace Systems::Demo { desired_dir = (PREDICTED - SHIP_POS).normalized(); } - // Ángulo deseado de la nariz; la nariz apunta hacia (angle - PI/2). - float desired_angle = std::atan2(desired_dir.y, desired_dir.x) + aim_jitter_; - const float NOSE_ANGLE = ship.getAngle() - (PI / 2.0F); - const float ERROR = wrapPi(desired_angle - NOSE_ANGLE); - - // RIGHT incrementa ship.angle (→ nariz); LEFT lo decrementa. - if (ERROR > ROTATE_DEADZONE) { - ctrl.right = true; - } else if (ERROR < -ROTATE_DEADZONE) { - ctrl.left = true; - } + const float ERROR = steerError(ship, desired_dir, aim_jitter_); + applyRotation(ctrl, ERROR); if (!DANGER) { // Acercarse si el objetivo está lejos (mantiene la nave cazando), // pero no empujar de cara a una pared. - const bool FACING_WALL = NEAR_WALL && - (PLAY_CENTRE - SHIP_POS).normalized().dot(desired_dir) < 0.0F; + const bool FACING_WALL = NEAR_WALL && TO_CENTRE.dot(desired_dir) < 0.0F; if (TARGET.distance > APPROACH_RADIUS && !FACING_WALL && std::fabs(ERROR) < FIRE_TOLERANCE) { ctrl.thrust = true; diff --git a/source/game/systems/demo_pilot.hpp b/source/game/systems/demo_pilot.hpp index c1ad50f..c1bb816 100644 --- a/source/game/systems/demo_pilot.hpp +++ b/source/game/systems/demo_pilot.hpp @@ -14,23 +14,36 @@ #include #include +#include #include +#include "core/defaults/entities.hpp" #include "core/system/scene_context.hpp" #include "game/constants.hpp" +#include "game/entities/bullet.hpp" #include "game/entities/enemy.hpp" #include "game/entities/ship.hpp" namespace Systems::Demo { - // Stages de arranque de cada escenario curado (se ciclan en attract mode). - // Elegidos por variedad visual: pinwheels, stars, squares/mix. - inline constexpr std::array - SCENARIO_STAGES = {2, 5, 7}; + // Escenari curat del attract mode: stage de arranque + nombre de naus IA. + // Tots inclouen estrelles (que disparen) per donar feina al pilot. + struct Scenario { + std::uint8_t stage; // stage_id de stages.yaml on arrenca la demo + std::uint8_t players; // 1 o 2 naus controlades per la IA + }; - // Stage de arranque para el índice de escenario dado. - [[nodiscard]] inline auto scenarioStage(std::uint8_t index) -> std::uint8_t { - return SCENARIO_STAGES.at(index % SCENARIO_STAGES.size()); + inline constexpr std::array + SCENARIOS = {{ + {.stage = 5, .players = 1}, // estrelles + mix, 1 jugador + {.stage = 8, .players = 1}, // pressió alta, 1 jugador + {.stage = 6, .players = 2}, // densitat alta, 2 jugadors (foc amic ON) + {.stage = 10, .players = 2}, // repte final, 2 jugadors (foc amic ON) + }}; + + // Escenari per a l'índex donat (cíclic). + [[nodiscard]] inline auto scenario(std::uint8_t index) -> Scenario { + return SCENARIOS.at(index % SCENARIOS.size()); } // Control de la nave para un frame. @@ -45,6 +58,7 @@ namespace Systems::Demo { public: [[nodiscard]] auto compute(const Ship& ship, const std::array& enemies, + const std::array(Defaults::Entities::MAX_BULLETS_TOTAL)>& bullets, const SDL_FRect& play_area, float delta_time) -> Control;