Merge branch 'feature/attract-mode': attract mode (demo jugant-se sola) amb IA, 1P/2P, vides infinites i música contínua
This commit is contained in:
@@ -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 = 4;
|
||||
|
||||
// Í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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,20 @@ 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<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 +155,18 @@ GameScene::GameScene(SDLManager& sdl, SceneContext& context)
|
||||
|
||||
// Initialize stage manager
|
||||
stage_manager_ = std::make_unique<StageSystem::StageManager>(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. 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;
|
||||
} 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 +253,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 +331,64 @@ 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, 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 {
|
||||
// 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) {
|
||||
@@ -408,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);
|
||||
@@ -496,13 +595,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 +617,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 +639,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);
|
||||
}
|
||||
|
||||
@@ -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 per nau activa (1 o 2 jugadors).
|
||||
std::array<Systems::Demo::DemoPilot, 2> demo_pilots_;
|
||||
std::array<Systems::Demo::Control, 2> 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
|
||||
// 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 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
|
||||
// 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;
|
||||
|
||||
@@ -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;
|
||||
@@ -94,7 +95,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 +349,28 @@ 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) {
|
||||
// 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 = (SC.players >= 2);
|
||||
demo_cfg.mode = GameConfig::Mode::DEMO;
|
||||
context_.setMatchConfig(demo_cfg);
|
||||
context_.setNextScene(SceneType::GAME);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void TitleScene::updateStarfieldFadeInState(float delta_time) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
// 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
|
||||
|
||||
// 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<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;
|
||||
}
|
||||
|
||||
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<Bullet, static_cast<std::size_t>(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<Enemy, Constants::MAX_ORNIS>& enemies,
|
||||
const std::array<Bullet, static_cast<std::size_t>(Defaults::Entities::MAX_BULLETS_TOTAL)>& bullets,
|
||||
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 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;
|
||||
|
||||
// Sin enemigos: deriva tranquila (gira despacio, sin empuje ni disparo).
|
||||
if (TARGET.index == -1) {
|
||||
ctrl.right = true;
|
||||
return ctrl;
|
||||
}
|
||||
|
||||
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) {
|
||||
// Prioridad 2: alejarse del enemigo pegado, con sesgo al centro.
|
||||
const Vec2 AWAY = (SHIP_POS - TARGET.center).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();
|
||||
}
|
||||
|
||||
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 && TO_CENTRE.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
|
||||
@@ -0,0 +1,72 @@
|
||||
// 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 <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
#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 {
|
||||
|
||||
// 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
|
||||
};
|
||||
|
||||
inline constexpr std::array<Scenario, SceneManager::SceneContext::DEMO_SCENARIO_COUNT>
|
||||
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.
|
||||
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 std::array<Bullet, static_cast<std::size_t>(Defaults::Entities::MAX_BULLETS_TOTAL)>& bullets,
|
||||
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
|
||||
Reference in New Issue
Block a user