From c1956e00280e493157e41bdb5109dfbe1e85f4c6 Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Thu, 28 May 2026 12:01:12 +0200 Subject: [PATCH] =?UTF-8?q?feat(demo):=20attract=20mode=20amb=20pilot=20IA?= =?UTF-8?q?,=20escenaris=20curats=20i=20m=C3=BAsica=20cont=C3=ADnua=20del?= =?UTF-8?q?=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