feat(demo): attract mode amb pilot IA, escenaris curats i música contínua del títol

This commit is contained in:
2026-05-28 12:01:12 +02:00
parent 491992a4d7
commit c1956e0028
11 changed files with 404 additions and 27 deletions
+14
View File
@@ -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)
+11 -3
View File
@@ -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;
+3
View File
@@ -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;
+87 -22
View File
@@ -4,6 +4,7 @@
#include "game_scene.hpp"
#include <algorithm>
#include <array>
#include <cmath>
#include <cstdlib>
#include <ctime>
@@ -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<InputAction, 5> 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<StageSystem::StageManager>(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.
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);
}
+13
View File
@@ -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;
+27
View File
@@ -94,7 +94,15 @@ TitleScene::TitleScene(SDLManager& sdl, SceneContext& context)
}
TitleScene::~TitleScene() {
// Attract mode: si saltem a la demo, NO parem la música — ha de seguir
// sonant durant tot el cicle TÍTOL→DEMO→LOGO→TÍTOL. La resta de sortides
// (partida normal, EXIT) sí paren.
const bool ENTERING_DEMO =
context_.nextScene() == SceneType::GAME &&
context_.getMatchConfig().mode == GameConfig::Mode::DEMO;
if (!ENTERING_DEMO) {
Audio::get()->stopMusic();
}
}
void TitleScene::initTitle() {
@@ -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) {
+5
View File
@@ -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<LogoLetter> letters_orni_;
std::vector<LogoLetter> 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;
@@ -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<int>(stage_actual_) << '\n';
}
void StageManager::update(float delta_time, bool pause_spawn) {
switch (estat_) {
case EstatStage::INIT_HUD:
@@ -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
+162
View File
@@ -0,0 +1,162 @@
// demo_pilot.cpp - Implementación de la IA del attract mode
// © 2026 JailDesigner
#include "game/systems/demo_pilot.hpp"
#include <cmath>
#include <cstdlib>
#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<float>(std::rand()) / static_cast<float>(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<Enemy, Constants::MAX_ORNIS>& 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<int>(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<Enemy, Constants::MAX_ORNIS>& 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
+58
View File
@@ -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 <SDL3/SDL.h>
#include <array>
#include <cstdint>
#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<std::uint8_t, SceneManager::SceneContext::DEMO_SCENARIO_COUNT>
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<Enemy, Constants::MAX_ORNIS>& 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