// 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 // 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 // Foc amic: retindre el tret si el company està en la línia de tir. constexpr float FRIENDLY_BLOCK_RANGE = 1200.0F; // px endavant que es vigilen constexpr float FRIENDLY_BLOCK_MARGIN = 22.0F; // px: marge sobre el radi del company // [-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; } 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; } } // ¿El company està en la línia de tir? (davant del morro, dins de l'abast i // a prou poca distància perpendicular de la trajectòria recta de la bala). // La bala ix en la direcció del morro: (cos, sin)(angle - PI/2). auto teammateInLineOfFire(const Ship& ship, const Ship& mate) -> bool { const float NOSE = ship.getAngle() - (PI / 2.0F); const Vec2 FORWARD{.x = std::cos(NOSE), .y = std::sin(NOSE)}; const Vec2 TO_MATE = mate.getCenter() - ship.getCenter(); const float ALONG = TO_MATE.dot(FORWARD); // distància al llarg del tret if (ALONG <= 0.0F || ALONG > FRIENDLY_BLOCK_RANGE) { return false; // darrere de la nau o massa lluny } const float PERP2 = TO_MATE.lengthSquared() - (ALONG * ALONG); const float CLEAR = mate.getCollisionRadius() + FRIENDLY_BLOCK_MARGIN; return PERP2 < CLEAR * CLEAR; } } // namespace auto DemoPilot::compute(const Ship& ship, const Ship* teammate, const std::array& enemies, const std::array(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, però NO // si el company està en la línia de tir (i seria víctima vàlida de foc // amic): es retén el tret sense gastar cooldown, així dispara tan prompte // com l'altra nau isca de la línia. if (std::fabs(ERROR) < FIRE_TOLERANCE && fire_cooldown_ <= 0.0F) { const bool FRIENDLY_BLOCK = (teammate != nullptr) && teammate->isActive() && !teammate->isInvulnerable() && teammateInLineOfFire(ship, *teammate); if (!FRIENDLY_BLOCK) { ctrl.shoot = true; fire_cooldown_ = FIRE_COOLDOWN; } } } return ctrl; } } // namespace Systems::Demo