Fase 5: infraestructura del sistema de fisica vectorial

Crea los componentes base del nuevo motor de fisica sin alterar
todavia el comportamiento del juego. La migracion de Ship/Enemy/
Bullet al nuevo sistema queda para Fase 6.

Nuevos archivos:
- core/physics/rigid_body.hpp - struct POD con:
  * Vec2 position, velocity (cartesianas, NO polares)
  * float angle, angular_velocity
  * mass, inverse_mass (cacheado; 0 = estatico)
  * restitution (elasticidad 0..1)
  * linear_damping, angular_damping (s-1, exponencial)
  * radius (circulo de colision)
  * applyForce / applyImpulse / clearAccumulators
  * setStatic() para paredes/obstaculos
- core/physics/physics_world.hpp/.cpp - mundo fisico:
  * Almacena RigidBody* (no-owning, ownership en entidades)
  * setBounds(SDL_FRect) para paredes implicitas (PLAYAREA)
  * update(dt) = integrate + resolveBoundsCollisions + resolveBodyCollisions
  * Integrador semi-implicito de Euler + damping exponencial
  * Resolucion circulo-circulo con correccion posicional e impulsos elasticos
  * Formula del impulso: j = -(1+e)*(v_rel . n) / (1/m_a + 1/m_b)
  * Broadphase trivial O(n^2): suficiente para ~25 cuerpos del juego

Decisiones de diseno:
- Velocidad en cartesianas (Vec2) en lugar de la representacion polar
  actual (escalar velocidad + cos/sin del angulo cada frame). Adios al
  acoplamiento entre orientacion y direccion de movimiento.
- Composicion sobre herencia: RigidBody es un struct independiente que
  las entidades incrustaran como member en Fase 6, no una clase base.
- El integrador semi-implicito es la version estandar para juegos
  arcade (mas estable que Euler explicito sin coste extra).
- Damping exponencial (exp(-damping*dt)) en lugar de lineal: mantiene
  el feeling consistente independientemente del framerate.
- Sin gravedad: el juego es top-down, no necesita campo de fuerzas
  global. Las entidades aplican sus propias fuerzas (thrust).

Pendiente Fase 6:
- Anadir RigidBody body_ a Entity (member, no pointer)
- Migrar Ship: thrust como applyForce, en lugar de velocity_ escalar
- Migrar Enemy: cambios de direccion via applyImpulse, rebotes los
  hace PhysicsWorld
- Migrar Bullet: lineal sin damping, restitution=0 (no rebotan)
- Anadir PhysicsWorld a GameScene, registrar bodies, llamar update()

Compila y enlaza. Smoke test xvfb OK: el juego arranca igual que antes
(la nueva infraestructura aun no se invoca).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-19 13:12:06 +02:00
parent ed98ef612e
commit 0fd9360029
3 changed files with 317 additions and 0 deletions
+178
View File
@@ -0,0 +1,178 @@
// physics_world.cpp - Implementación del mundo físico
// © 2025 Orni Attack
#include "core/physics/physics_world.hpp"
#include <algorithm>
#include <cmath>
#include "core/physics/rigid_body.hpp"
namespace Physics {
void PhysicsWorld::addBody(RigidBody* body) {
if (body == nullptr) {
return;
}
if (std::ranges::find(bodies_, body) == bodies_.end()) {
bodies_.push_back(body);
}
}
void PhysicsWorld::removeBody(RigidBody* body) {
std::erase(bodies_, body);
}
void PhysicsWorld::update(float dt) {
integrate(dt);
if (has_bounds_) {
resolveBoundsCollisions();
}
resolveBodyCollisions();
}
// Integración semi-implícita de Euler:
// v(t+dt) = v(t) + (F/m) * dt
// x(t+dt) = x(t) + v(t+dt) * dt
// Más estable que Euler explícito para juegos. Damping exponencial.
void PhysicsWorld::integrate(float dt) {
for (auto* body : bodies_) {
if (body == nullptr || body->isStatic()) {
continue;
}
// Aplicar fuerzas acumuladas → aceleración
const Vec2 acceleration = body->force_accumulator * body->inverse_mass;
body->velocity += acceleration * dt;
// Damping exponencial: equivalente a v *= exp(-damping * dt)
// Aproximación lineal cuando damping*dt es pequeño.
if (body->linear_damping > 0.0F) {
const float DAMP = std::exp(-body->linear_damping * dt);
body->velocity *= DAMP;
}
if (body->angular_damping > 0.0F) {
const float DAMP = std::exp(-body->angular_damping * dt);
body->angular_velocity *= DAMP;
}
// Actualizar posición y rotación
body->position += body->velocity * dt;
body->angle += body->angular_velocity * dt;
body->clearAccumulators();
}
}
// Rebote contra los 4 bordes del rectángulo bounds_.
// Refleja la componente normal de la velocidad por la restitución.
void PhysicsWorld::resolveBoundsCollisions() {
const float MIN_X = bounds_.x;
const float MAX_X = bounds_.x + bounds_.w;
const float MIN_Y = bounds_.y;
const float MAX_Y = bounds_.y + bounds_.h;
for (auto* body : bodies_) {
if (body == nullptr || body->isStatic()) {
continue;
}
const float R = body->radius;
// Pared izquierda
if (body->position.x - R < MIN_X) {
body->position.x = MIN_X + R;
if (body->velocity.x < 0.0F) {
body->velocity.x = -body->velocity.x * body->restitution;
}
}
// Pared derecha
if (body->position.x + R > MAX_X) {
body->position.x = MAX_X - R;
if (body->velocity.x > 0.0F) {
body->velocity.x = -body->velocity.x * body->restitution;
}
}
// Pared superior
if (body->position.y - R < MIN_Y) {
body->position.y = MIN_Y + R;
if (body->velocity.y < 0.0F) {
body->velocity.y = -body->velocity.y * body->restitution;
}
}
// Pared inferior
if (body->position.y + R > MAX_Y) {
body->position.y = MAX_Y - R;
if (body->velocity.y > 0.0F) {
body->velocity.y = -body->velocity.y * body->restitution;
}
}
}
}
// Colisiones cuerpo-cuerpo: O(n²) círculo-círculo + resolución por impulso.
// Para 15 enemigos + 6 balas + 2 naves = ~23 cuerpos → 253 pares. Sobra.
//
// Fórmula del impulso elástico (referencia: Chris Hecker / Box2D):
// j = -(1 + e) * (v_rel · n) / (1/m_a + 1/m_b)
// donde n es la normal del contacto (de a hacia b) y v_rel = v_a - v_b.
void PhysicsWorld::resolveBodyCollisions() {
const std::size_t COUNT = bodies_.size();
for (std::size_t i = 0; i < COUNT; ++i) {
for (std::size_t j = i + 1; j < COUNT; ++j) {
auto* a = bodies_[i];
auto* b = bodies_[j];
if (a == nullptr || b == nullptr) {
continue;
}
// Dos cuerpos estáticos no necesitan resolución
if (a->isStatic() && b->isStatic()) {
continue;
}
const Vec2 DELTA = b->position - a->position;
const float DIST_SQ = DELTA.lengthSquared();
const float SUM_R = a->radius + b->radius;
if (DIST_SQ > SUM_R * SUM_R || DIST_SQ <= 0.0F) {
continue;
}
const float DIST = std::sqrt(DIST_SQ);
const Vec2 NORMAL = DELTA / DIST; // de A hacia B
// Corrección posicional (resolver penetración)
const float PENETRATION = SUM_R - DIST;
const float TOTAL_INV_MASS = a->inverse_mass + b->inverse_mass;
if (TOTAL_INV_MASS > 0.0F) {
const Vec2 CORRECTION = NORMAL * (PENETRATION / TOTAL_INV_MASS);
if (!a->isStatic()) {
a->position -= CORRECTION * a->inverse_mass;
}
if (!b->isStatic()) {
b->position += CORRECTION * b->inverse_mass;
}
}
// Velocidad relativa proyectada sobre la normal
const Vec2 V_REL = b->velocity - a->velocity;
const float VEL_ALONG_NORMAL = V_REL.dot(NORMAL);
// Si se están separando, no aplicar impulso
if (VEL_ALONG_NORMAL > 0.0F) {
continue;
}
// Restitución promedio (Box2D usa max; promedio es más permisivo)
const float E = (a->restitution + b->restitution) * 0.5F;
const float J = -(1.0F + E) * VEL_ALONG_NORMAL / TOTAL_INV_MASS;
const Vec2 IMPULSE = NORMAL * J;
if (!a->isStatic()) {
a->velocity -= IMPULSE * a->inverse_mass;
}
if (!b->isStatic()) {
b->velocity += IMPULSE * b->inverse_mass;
}
}
}
}
} // namespace Physics
+64
View File
@@ -0,0 +1,64 @@
// physics_world.hpp - Mundo físico 2D
// © 2025 Orni Attack
//
// Gestiona un conjunto de RigidBody, integra sus movimientos y detecta
// colisiones por frame. Diseño minimalista para arcade: broadphase trivial
// O(n²) suficiente para <50 cuerpos (15 enemigos + balas + paredes).
//
// Los RigidBody viven en las entidades (las entidades poseen sus bodies);
// PhysicsWorld solo guarda punteros no-owning. La entidad es responsable
// de añadir/quitar su body del mundo en init/destroy.
#pragma once
#include <SDL3/SDL.h>
#include <vector>
namespace Physics {
struct RigidBody;
class PhysicsWorld {
public:
PhysicsWorld() = default;
// Añade un cuerpo al mundo (no toma ownership).
void addBody(RigidBody* body);
// Elimina un cuerpo. No-op si no está registrado.
void removeBody(RigidBody* body);
// Vacía la lista (no destruye los cuerpos).
void clear() { bodies_.clear(); }
// Define los límites del mundo (paredes implícitas). Pasa un rect
// PLAYAREA para que los cuerpos reboten contra los bordes según su
// restitution. Vacío = sin paredes.
void setBounds(const SDL_FRect& bounds) {
bounds_ = bounds;
has_bounds_ = true;
}
void clearBounds() { has_bounds_ = false; }
// Avanza la simulación dt segundos:
// 1. Integra cada cuerpo (semi-implicit Euler + damping)
// 2. Resuelve colisiones contra los bounds (si configurados)
// 3. Resuelve colisiones cuerpo-cuerpo (impulsos elásticos)
void update(float dt);
// Consultas
[[nodiscard]] auto getBodyCount() const -> std::size_t { return bodies_.size(); }
[[nodiscard]] auto getBodies() const -> const std::vector<RigidBody*>& { return bodies_; }
private:
std::vector<RigidBody*> bodies_;
SDL_FRect bounds_{0.0F, 0.0F, 0.0F, 0.0F};
bool has_bounds_{false};
void integrate(float dt);
void resolveBoundsCollisions();
void resolveBodyCollisions();
};
} // namespace Physics
+75
View File
@@ -0,0 +1,75 @@
// rigid_body.hpp - Cuerpo rígido 2D para el sistema de física
// © 2025 Orni Attack
//
// Estructura POD-like que encapsula el estado físico de una entidad:
// posición, velocidad lineal/angular, masa, restitución y damping.
// El integrador es semi-implícito de Euler (estable para juegos arcade).
//
// Convenciones:
// - position: coordenadas lógicas (px), donde la entidad está en el mundo
// - angle: radianes; 0 apunta hacia arriba (eje Y negativo en SDL)
// - velocity: px/s en cartesianas (NO polares — adiós a cos/sin por entidad)
// - mass = 0 (inverse_mass = 0) representa un cuerpo estático (masa infinita)
// - restitution 0 = inelástico, 1 = elástico perfecto
// - linear_damping en s⁻¹ (fricción exponencial: v *= exp(-damping * dt))
#pragma once
#include "core/types.hpp"
namespace Physics {
struct RigidBody {
// --- Estado cinemático ---
Vec2 position{}; // Posición del centro (px)
Vec2 velocity{}; // Velocidad lineal (px/s)
float angle{0.0F}; // Orientación (rad)
float angular_velocity{0.0F}; // Velocidad angular (rad/s)
// --- Propiedades físicas ---
float mass{1.0F}; // Masa (kg, escala libre)
float inverse_mass{1.0F}; // 1/mass cacheado (0 = estático)
float restitution{0.5F}; // Elasticidad (0..1)
float linear_damping{0.0F}; // Fricción lineal (s⁻¹)
float angular_damping{0.0F}; // Fricción angular (s⁻¹)
float radius{0.0F}; // Radio de colisión (círculo)
// --- Fuerzas acumuladas (reseteadas tras cada integrate) ---
Vec2 force_accumulator{};
float torque_accumulator{0.0F};
// Configura la masa y precalcula inverse_mass.
// mass <= 0 marca el cuerpo como estático (inmovible por impulsos).
void setMass(float new_mass) {
mass = new_mass;
inverse_mass = (new_mass > 0.0F) ? 1.0F / new_mass : 0.0F;
}
// Marca el cuerpo como estático (paredes, obstáculos fijos).
void setStatic() {
mass = 0.0F;
inverse_mass = 0.0F;
velocity = Vec2{};
angular_velocity = 0.0F;
}
[[nodiscard]] auto isStatic() const -> bool { return inverse_mass == 0.0F; }
// Aplica una fuerza instantánea (acumulada para el siguiente integrate).
void applyForce(const Vec2& force) { force_accumulator += force; }
// Aplica un impulso (cambio inmediato de velocidad: Δv = J / m).
void applyImpulse(const Vec2& impulse) {
if (!isStatic()) {
velocity += impulse * inverse_mass;
}
}
// Resetea los acumuladores tras la integración.
void clearAccumulators() {
force_accumulator = Vec2{};
torque_accumulator = 0.0F;
}
};
} // namespace Physics