Files
vibe3_physics/source/engine.cpp
Sergio Valor e7dc8f6d13 feat: añadir cambio de dirección de gravedad en modo LOGO
El modo LOGO ahora incluye cambios automáticos de dirección de
gravedad como parte de sus variaciones, aumentando la diversidad
visual de la demostración.

Cambios:
- Nueva acción en modo LOGO (PHYSICS): cambiar dirección gravedad (16%)
- Rebalanceo de probabilidades existentes:
  • PHYSICS → SHAPE: 60% → 50%
  • Gravedad ON: 20% → 18%
  • Gravedad OFF: 20% → 16%
  • Dirección gravedad: nuevo 16%
- Al cambiar dirección, se fuerza gravedad ON para visibilidad

Antes el modo LOGO solo alternaba entre figura/física y gravedad
on/off, pero nunca cambiaba la dirección. Ahora tiene las mismas
capacidades de variación que los modos DEMO y DEMO_LITE.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-16 17:11:22 +02:00

1794 lines
74 KiB
C++

#include "engine.h"
#include <SDL3/SDL_error.h> // for SDL_GetError
#include <SDL3/SDL_events.h> // for SDL_Event, SDL_PollEvent
#include <SDL3/SDL_init.h> // for SDL_Init, SDL_Quit, SDL_INIT_VIDEO
#include <SDL3/SDL_keycode.h> // for SDL_Keycode
#include <SDL3/SDL_render.h> // for SDL_SetRenderDrawColor, SDL_RenderPresent
#include <SDL3/SDL_timer.h> // for SDL_GetTicks
#include <SDL3/SDL_video.h> // for SDL_CreateWindow, SDL_DestroyWindow, SDL_GetDisplayBounds
#include <algorithm> // for std::min, std::max, std::sort
#include <cmath> // for sqrtf, acosf, cosf, sinf (funciones matemáticas)
#include <cstdlib> // for rand, srand
#include <cstring> // for strlen
#include <ctime> // for time
#include <filesystem> // for path operations
#include <iostream> // for cout
#include <string> // for string
#ifdef _WIN32
#include <windows.h> // for GetModuleFileName
#endif
#include "ball.h" // for Ball
#include "external/mouse.h" // for Mouse namespace
#include "external/texture.h" // for Texture
#include "shapes/atom_shape.h" // for AtomShape
#include "shapes/cube_shape.h" // for CubeShape
#include "shapes/cylinder_shape.h" // for CylinderShape
#include "shapes/helix_shape.h" // for HelixShape
#include "shapes/icosahedron_shape.h" // for IcosahedronShape
#include "shapes/lissajous_shape.h" // for LissajousShape
#include "shapes/png_shape.h" // for PNGShape
#include "shapes/sphere_shape.h" // for SphereShape
#include "shapes/torus_shape.h" // for TorusShape
// getExecutableDirectory() ya está definido en defines.h como inline
// Implementación de métodos públicos
bool Engine::initialize(int width, int height, int zoom, bool fullscreen) {
bool success = true;
// Obtener resolución de pantalla para validación
if (!SDL_Init(SDL_INIT_VIDEO)) {
std::cout << "¡SDL no se pudo inicializar! Error de SDL: " << SDL_GetError() << std::endl;
return false;
}
int num_displays = 0;
SDL_DisplayID* displays = SDL_GetDisplays(&num_displays);
const auto* dm = (displays && num_displays > 0) ? SDL_GetCurrentDisplayMode(displays[0]) : nullptr;
int screen_w = dm ? dm->w : 1920; // Fallback si falla
int screen_h = dm ? dm->h - WINDOW_DECORATION_HEIGHT : 1080;
if (displays) SDL_free(displays);
// Usar parámetros o valores por defecto
int logical_width = (width > 0) ? width : DEFAULT_SCREEN_WIDTH;
int logical_height = (height > 0) ? height : DEFAULT_SCREEN_HEIGHT;
int window_zoom = (zoom > 0) ? zoom : DEFAULT_WINDOW_ZOOM;
// VALIDACIÓN 1: Si resolución > pantalla → reset a default
if (logical_width > screen_w || logical_height > screen_h) {
std::cout << "Advertencia: Resolución " << logical_width << "x" << logical_height
<< " excede pantalla " << screen_w << "x" << screen_h
<< ". Usando default " << DEFAULT_SCREEN_WIDTH << "x" << DEFAULT_SCREEN_HEIGHT << "\n";
logical_width = DEFAULT_SCREEN_WIDTH;
logical_height = DEFAULT_SCREEN_HEIGHT;
window_zoom = DEFAULT_WINDOW_ZOOM; // Reset zoom también
}
// VALIDACIÓN 2: Calcular max_zoom y ajustar si es necesario
int max_zoom = std::min(screen_w / logical_width, screen_h / logical_height);
if (window_zoom > max_zoom) {
std::cout << "Advertencia: Zoom " << window_zoom << " excede máximo " << max_zoom
<< " para " << logical_width << "x" << logical_height << ". Ajustando a " << max_zoom << "\n";
window_zoom = max_zoom;
}
// Si se especificaron parámetros CLI y zoom no se especificó, usar zoom=1
if ((width > 0 || height > 0) && zoom == 0) {
window_zoom = 1;
}
// Guardar zoom calculado ANTES de crear la ventana (para F1/F2/F3/F4)
current_window_zoom_ = window_zoom;
// Calcular tamaño de ventana
int window_width = logical_width * window_zoom;
int window_height = logical_height * window_zoom;
// Guardar resolución base (configurada por CLI o default)
base_screen_width_ = logical_width;
base_screen_height_ = logical_height;
current_screen_width_ = logical_width;
current_screen_height_ = logical_height;
// SDL ya inicializado arriba para validación
{
// Crear ventana principal (fullscreen si se especifica)
// NOTA: SDL_WINDOW_HIGH_PIXEL_DENSITY removido por incompatibilidad con STRETCH mode (F4)
// El DPI se detectará manualmente con SDL_GetWindowSizeInPixels()
Uint32 window_flags = SDL_WINDOW_OPENGL;
if (fullscreen) {
window_flags |= SDL_WINDOW_FULLSCREEN;
}
window_ = SDL_CreateWindow(WINDOW_CAPTION, window_width, window_height, window_flags);
if (window_ == nullptr) {
std::cout << "¡No se pudo crear la ventana! Error de SDL: " << SDL_GetError() << std::endl;
success = false;
} else {
// Centrar ventana en pantalla si no está en fullscreen
if (!fullscreen) {
SDL_SetWindowPosition(window_, SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED);
}
// Crear renderizador
renderer_ = SDL_CreateRenderer(window_, nullptr);
if (renderer_ == nullptr) {
std::cout << "¡No se pudo crear el renderizador! Error de SDL: " << SDL_GetError() << std::endl;
success = false;
} else {
// Establecer color inicial del renderizador
SDL_SetRenderDrawColor(renderer_, 0xFF, 0xFF, 0xFF, 0xFF);
// Establecer tamaño lógico para el renderizado (resolución interna)
SDL_SetRenderLogicalPresentation(renderer_, logical_width, logical_height, SDL_LOGICAL_PRESENTATION_INTEGER_SCALE);
// Configurar V-Sync inicial
SDL_SetRenderVSync(renderer_, vsync_enabled_ ? 1 : 0);
}
}
}
// Inicializar otros componentes si SDL se inicializó correctamente
if (success) {
// Cargar todas las texturas disponibles desde data/balls/
std::string resources_dir = getResourcesDirectory();
std::string balls_dir = resources_dir + "/data/balls";
struct TextureInfo {
std::string name;
std::shared_ptr<Texture> texture;
int width;
};
std::vector<TextureInfo> texture_files;
// Buscar todas las texturas PNG en data/balls/
namespace fs = std::filesystem;
if (fs::exists(balls_dir) && fs::is_directory(balls_dir)) {
// Cargar todas las texturas desde disco
for (const auto& entry : fs::directory_iterator(balls_dir)) {
if (entry.is_regular_file() && entry.path().extension() == ".png") {
std::string filename = entry.path().stem().string();
std::string fullpath = entry.path().string();
// Cargar textura y obtener dimensiones
auto texture = std::make_shared<Texture>(renderer_, fullpath);
int width = texture->getWidth();
texture_files.push_back({filename, texture, width});
}
}
} else {
// Fallback: cargar texturas desde pack usando la lista del ResourcePack
if (Texture::isPackLoaded()) {
auto pack_resources = Texture::getPackResourceList();
// Filtrar solo los recursos en balls/ con extensión .png
for (const auto& resource : pack_resources) {
if (resource.substr(0, 6) == "balls/" && resource.substr(resource.size() - 4) == ".png") {
std::string tex_name = resource.substr(6); // Quitar "balls/"
std::string name = tex_name.substr(0, tex_name.find('.')); // Quitar extensión
auto texture = std::make_shared<Texture>(renderer_, resource);
int width = texture->getWidth();
texture_files.push_back({name, texture, width});
}
}
}
}
// Ordenar por tamaño (grande → pequeño): big(16) → normal(10) → small(6) → tiny(4)
std::sort(texture_files.begin(), texture_files.end(), [](const TextureInfo& a, const TextureInfo& b) {
return a.width > b.width; // Descendente por tamaño
});
// Guardar texturas ya cargadas en orden (0=big, 1=normal, 2=small, 3=tiny)
for (const auto& info : texture_files) {
textures_.push_back(info.texture);
texture_names_.push_back(info.name);
}
// Verificar que se cargaron texturas
if (textures_.empty()) {
std::cerr << "ERROR: No se pudieron cargar texturas" << std::endl;
success = false;
}
// Buscar índice de "normal" para usarlo como textura inicial (debería ser índice 1)
current_texture_index_ = 0; // Fallback
for (size_t i = 0; i < texture_names_.size(); i++) {
if (texture_names_[i] == "normal") {
current_texture_index_ = i; // Iniciar en "normal" (índice 1)
break;
}
}
texture_ = textures_[current_texture_index_];
current_ball_size_ = texture_->getWidth(); // Obtener tamaño dinámicamente
srand(static_cast<unsigned>(time(nullptr)));
// Inicializar InputHandler (sin estado)
input_handler_ = std::make_unique<InputHandler>();
// Inicializar ThemeManager PRIMERO (requerido por Notifier y SceneManager)
theme_manager_ = std::make_unique<ThemeManager>();
theme_manager_->initialize();
// Inicializar SceneManager (gestión de bolas y física)
scene_manager_ = std::make_unique<SceneManager>(current_screen_width_, current_screen_height_);
scene_manager_->initialize(0, texture_, theme_manager_.get()); // Escenario 0 (10 bolas) por defecto
// Calcular tamaño físico de ventana ANTES de inicializar UIManager
// NOTA: No llamar a updatePhysicalWindowSize() aquí porque ui_manager_ aún no existe
// Calcular manualmente para poder pasar valores al constructor de UIManager
int window_w = 0, window_h = 0;
SDL_GetWindowSizeInPixels(window_, &window_w, &window_h);
physical_window_width_ = window_w;
physical_window_height_ = window_h;
// Inicializar UIManager (HUD, FPS, notificaciones)
// NOTA: Debe llamarse DESPUÉS de calcular physical_window_* y ThemeManager
ui_manager_ = std::make_unique<UIManager>();
ui_manager_->initialize(renderer_, theme_manager_.get(), physical_window_width_, physical_window_height_);
// Inicializar ShapeManager (gestión de figuras 3D)
shape_manager_ = std::make_unique<ShapeManager>();
shape_manager_->initialize(this, scene_manager_.get(), ui_manager_.get(), nullptr,
current_screen_width_, current_screen_height_);
// Inicializar StateManager (gestión de estados DEMO/LOGO)
state_manager_ = std::make_unique<StateManager>();
state_manager_->initialize(this); // Callback al Engine
// Actualizar ShapeManager con StateManager (dependencia circular - StateManager debe existir primero)
shape_manager_->initialize(this, scene_manager_.get(), ui_manager_.get(), state_manager_.get(),
current_screen_width_, current_screen_height_);
// Inicializar BoidManager (gestión de comportamiento de enjambre)
boid_manager_ = std::make_unique<BoidManager>();
boid_manager_->initialize(this, scene_manager_.get(), ui_manager_.get(), state_manager_.get(),
current_screen_width_, current_screen_height_);
}
return success;
}
void Engine::run() {
while (!should_exit_) {
calculateDeltaTime();
// Procesar eventos de entrada (teclado, ratón, ventana)
if (input_handler_->processEvents(*this)) {
should_exit_ = true;
}
update();
render();
}
}
void Engine::shutdown() {
// Limpiar recursos SDL
if (renderer_) {
SDL_DestroyRenderer(renderer_);
renderer_ = nullptr;
}
if (window_) {
SDL_DestroyWindow(window_);
window_ = nullptr;
}
SDL_Quit();
}
// Métodos privados - esqueleto básico por ahora
void Engine::calculateDeltaTime() {
Uint64 current_time = SDL_GetTicks();
// En el primer frame, inicializar el tiempo anterior
if (last_frame_time_ == 0) {
last_frame_time_ = current_time;
delta_time_ = 1.0f / 60.0f; // Asumir 60 FPS para el primer frame
return;
}
// Calcular delta time en segundos
delta_time_ = (current_time - last_frame_time_) / 1000.0f;
last_frame_time_ = current_time;
// Limitar delta time para evitar saltos grandes (pausa larga, depuración, etc.)
if (delta_time_ > 0.05f) { // Máximo 50ms (20 FPS mínimo)
delta_time_ = 1.0f / 60.0f; // Fallback a 60 FPS
}
}
void Engine::update() {
// Actualizar visibilidad del cursor (auto-ocultar tras inactividad)
Mouse::updateCursorVisibility();
// Obtener tiempo actual
Uint64 current_time = SDL_GetTicks();
// Actualizar UI (FPS, notificaciones, texto obsoleto) - delegado a UIManager
ui_manager_->update(current_time, delta_time_);
// Bifurcar actualización según modo activo
if (current_mode_ == SimulationMode::PHYSICS) {
// Modo física normal: actualizar física de cada pelota (delegado a SceneManager)
scene_manager_->update(delta_time_);
} else if (current_mode_ == SimulationMode::SHAPE) {
// Modo Figura 3D: actualizar figura polimórfica
updateShape();
} else if (current_mode_ == SimulationMode::BOIDS) {
// Modo Boids: actualizar comportamiento de enjambre (delegado a BoidManager)
boid_manager_->update(delta_time_);
}
// Actualizar Modo DEMO/LOGO (delegado a StateManager)
state_manager_->update(delta_time_, shape_convergence_, active_shape_.get());
// Actualizar transiciones de temas (delegado a ThemeManager)
theme_manager_->update(delta_time_);
}
// === IMPLEMENTACIÓN DE MÉTODOS PÚBLICOS PARA INPUT HANDLER ===
// Gravedad y física
void Engine::handleGravityToggle() {
// Si estamos en modo boids, salir a modo física CON GRAVEDAD OFF
// Según RULES.md: "BOIDS a PHYSICS: Pulsando la tecla G: Gravedad OFF"
if (current_mode_ == SimulationMode::BOIDS) {
toggleBoidsMode(); // Cambiar a PHYSICS (preserva inercia, gravedad ya está OFF desde activateBoids)
// NO llamar a forceBallsGravityOff() porque aplica impulsos que destruyen la inercia de BOIDS
// La gravedad ya está desactivada por BoidManager::activateBoids() y se mantiene al salir
showNotificationForAction("Modo Física - Gravedad Off");
return;
}
// Si estamos en modo figura, salir a modo física SIN GRAVEDAD
if (current_mode_ == SimulationMode::SHAPE) {
toggleShapeModeInternal(false); // Desactivar figura sin forzar gravedad ON
showNotificationForAction("Gravedad Off");
} else {
scene_manager_->switchBallsGravity(); // Toggle normal en modo física
// Determinar estado actual de gravedad (gravity_force_ != 0.0f significa ON)
const Ball* first_ball = scene_manager_->getFirstBall();
bool gravity_on = (first_ball == nullptr) ? true : (first_ball->getGravityForce() != 0.0f);
showNotificationForAction(gravity_on ? "Gravedad On" : "Gravedad Off");
}
}
void Engine::handleGravityDirectionChange(GravityDirection direction, const char* notification_text) {
// Si estamos en modo boids, salir a modo física primero
if (current_mode_ == SimulationMode::BOIDS) {
toggleBoidsMode(); // Esto cambia a PHYSICS y activa gravedad
// Continuar para aplicar la dirección de gravedad
}
// Si estamos en modo figura, salir a modo física CON gravedad
if (current_mode_ == SimulationMode::SHAPE) {
toggleShapeModeInternal(); // Desactivar figura (activa gravedad automáticamente)
} else {
scene_manager_->enableBallsGravityIfDisabled(); // Reactivar gravedad si estaba OFF
}
scene_manager_->changeGravityDirection(direction);
showNotificationForAction(notification_text);
}
// Display y depuración
void Engine::toggleDebug() {
ui_manager_->toggleDebug();
}
void Engine::toggleHelp() {
ui_manager_->toggleHelp();
}
// Figuras 3D
void Engine::toggleShapeMode() {
toggleShapeModeInternal();
// Mostrar notificación según el modo actual después del toggle
if (current_mode_ == SimulationMode::PHYSICS) {
showNotificationForAction("Modo Física");
} else {
// Mostrar nombre de la figura actual (orden debe coincidir con enum ShapeType)
// Índices: 0=NONE, 1=SPHERE, 2=CUBE, 3=HELIX, 4=TORUS, 5=LISSAJOUS, 6=CYLINDER, 7=ICOSAHEDRON, 8=ATOM, 9=PNG_SHAPE
const char* shape_names[] = {"Ninguna", "Esfera", "Cubo", "Hélice", "Toroide", "Lissajous", "Cilindro", "Icosaedro", "Átomo", "Forma PNG"};
showNotificationForAction(shape_names[static_cast<int>(current_shape_type_)]);
}
}
void Engine::activateShape(ShapeType type, const char* notification_text) {
activateShapeInternal(type);
showNotificationForAction(notification_text);
}
void Engine::handleShapeScaleChange(bool increase) {
if (current_mode_ == SimulationMode::SHAPE) {
if (increase) {
shape_scale_factor_ += SHAPE_SCALE_STEP;
} else {
shape_scale_factor_ -= SHAPE_SCALE_STEP;
}
clampShapeScale();
showNotificationForAction("Escala " + std::to_string(static_cast<int>(shape_scale_factor_ * 100.0f + 0.5f)) + "%");
}
}
void Engine::resetShapeScale() {
if (current_mode_ == SimulationMode::SHAPE) {
shape_scale_factor_ = SHAPE_SCALE_DEFAULT;
showNotificationForAction("Escala 100%");
}
}
void Engine::toggleDepthZoom() {
if (current_mode_ == SimulationMode::SHAPE) {
depth_zoom_enabled_ = !depth_zoom_enabled_;
showNotificationForAction(depth_zoom_enabled_ ? "Profundidad On" : "Profundidad Off");
}
}
// Boids (comportamiento de enjambre)
void Engine::toggleBoidsMode() {
if (current_mode_ == SimulationMode::BOIDS) {
// Salir del modo boids
current_mode_ = SimulationMode::PHYSICS;
boid_manager_->deactivateBoids();
} else {
// Entrar al modo boids (desde PHYSICS o SHAPE)
if (current_mode_ == SimulationMode::SHAPE) {
// Si estamos en modo shape, salir primero sin forzar gravedad
current_mode_ = SimulationMode::PHYSICS;
// Desactivar atracción de figuras
auto& balls = scene_manager_->getBallsMutable();
for (auto& ball : balls) {
ball->enableShapeAttraction(false);
ball->setDepthScale(1.0f);
}
}
// Activar modo boids
current_mode_ = SimulationMode::BOIDS;
boid_manager_->activateBoids();
}
}
// Temas de colores
void Engine::cycleTheme(bool forward) {
if (forward) {
theme_manager_->cycleTheme();
} else {
theme_manager_->cyclePrevTheme();
}
showNotificationForAction(theme_manager_->getCurrentThemeNameES());
}
void Engine::switchThemeByNumpad(int numpad_key) {
// Mapear tecla numpad a índice de tema según página actual
int theme_index = -1;
if (theme_page_ == 0) {
// Página 0: Temas 0-9 (estáticos + SUNRISE)
if (numpad_key >= 0 && numpad_key <= 9) {
theme_index = (numpad_key == 0) ? 9 : (numpad_key - 1);
}
} else {
// Página 1: Temas 10-14 (dinámicos)
if (numpad_key >= 1 && numpad_key <= 5) {
theme_index = 9 + numpad_key;
}
}
if (theme_index != -1) {
theme_manager_->switchToTheme(theme_index);
showNotificationForAction(theme_manager_->getCurrentThemeNameES());
}
}
void Engine::toggleThemePage() {
theme_page_ = (theme_page_ == 0) ? 1 : 0;
showNotificationForAction((theme_page_ == 0) ? "Página 1" : "Página 2");
}
void Engine::pauseDynamicTheme() {
theme_manager_->pauseDynamic();
}
// Sprites/Texturas
void Engine::switchTexture() {
switchTextureInternal(true); // Mostrar notificación en modo manual
}
// Escenarios (número de pelotas)
void Engine::changeScenario(int scenario_id, const char* notification_text) {
// Pasar el modo actual al SceneManager para inicialización correcta
scene_manager_->changeScenario(scenario_id, current_mode_);
// Si estamos en modo SHAPE, regenerar la figura con nuevo número de pelotas
if (current_mode_ == SimulationMode::SHAPE) {
generateShape();
// Activar atracción física en las bolas nuevas (crítico tras changeScenario)
auto& balls = scene_manager_->getBallsMutable();
for (auto& ball : balls) {
ball->enableShapeAttraction(true);
}
}
showNotificationForAction(notification_text);
}
// Zoom y fullscreen
void Engine::handleZoomIn() {
if (!fullscreen_enabled_ && !real_fullscreen_enabled_) {
zoomIn();
}
}
void Engine::handleZoomOut() {
if (!fullscreen_enabled_ && !real_fullscreen_enabled_) {
zoomOut();
}
}
// Modos de aplicación (DEMO/LOGO) - Delegados a StateManager
void Engine::toggleDemoMode() {
AppMode prev_mode = state_manager_->getCurrentMode();
state_manager_->toggleDemoMode(current_screen_width_, current_screen_height_);
AppMode new_mode = state_manager_->getCurrentMode();
// Mostrar notificación según el modo resultante
if (new_mode == AppMode::SANDBOX && prev_mode != AppMode::SANDBOX) {
showNotificationForAction("MODO SANDBOX");
} else if (new_mode == AppMode::DEMO && prev_mode != AppMode::DEMO) {
showNotificationForAction("MODO DEMO");
}
}
void Engine::toggleDemoLiteMode() {
AppMode prev_mode = state_manager_->getCurrentMode();
state_manager_->toggleDemoLiteMode(current_screen_width_, current_screen_height_);
AppMode new_mode = state_manager_->getCurrentMode();
// Mostrar notificación según el modo resultante
if (new_mode == AppMode::SANDBOX && prev_mode != AppMode::SANDBOX) {
showNotificationForAction("MODO SANDBOX");
} else if (new_mode == AppMode::DEMO_LITE && prev_mode != AppMode::DEMO_LITE) {
showNotificationForAction("MODO DEMO LITE");
}
}
void Engine::toggleLogoMode() {
AppMode prev_mode = state_manager_->getCurrentMode();
state_manager_->toggleLogoMode(current_screen_width_, current_screen_height_, scene_manager_->getBallCount());
AppMode new_mode = state_manager_->getCurrentMode();
// Mostrar notificación según el modo resultante
if (new_mode == AppMode::SANDBOX && prev_mode != AppMode::SANDBOX) {
showNotificationForAction("MODO SANDBOX");
} else if (new_mode == AppMode::LOGO && prev_mode != AppMode::LOGO) {
showNotificationForAction("MODO LOGO");
}
}
void Engine::render() {
// Limpiar framebuffer completamente (evita artefactos en barras negras al cambiar modos)
SDL_SetRenderDrawColor(renderer_, 0, 0, 0, 255); // Negro para barras de letterbox/integer
SDL_RenderClear(renderer_);
// Renderizar fondo degradado (delegado a ThemeManager)
{
float top_r, top_g, top_b, bottom_r, bottom_g, bottom_b;
theme_manager_->getBackgroundColors(top_r, top_g, top_b, bottom_r, bottom_g, bottom_b);
// Crear quad de pantalla completa con degradado
SDL_Vertex bg_vertices[4];
// Vértice superior izquierdo
bg_vertices[0].position = {0, 0};
bg_vertices[0].tex_coord = {0.0f, 0.0f};
bg_vertices[0].color = {top_r, top_g, top_b, 1.0f};
// Vértice superior derecho
bg_vertices[1].position = {static_cast<float>(current_screen_width_), 0};
bg_vertices[1].tex_coord = {1.0f, 0.0f};
bg_vertices[1].color = {top_r, top_g, top_b, 1.0f};
// Vértice inferior derecho
bg_vertices[2].position = {static_cast<float>(current_screen_width_), static_cast<float>(current_screen_height_)};
bg_vertices[2].tex_coord = {1.0f, 1.0f};
bg_vertices[2].color = {bottom_r, bottom_g, bottom_b, 1.0f};
// Vértice inferior izquierdo
bg_vertices[3].position = {0, static_cast<float>(current_screen_height_)};
bg_vertices[3].tex_coord = {0.0f, 1.0f};
bg_vertices[3].color = {bottom_r, bottom_g, bottom_b, 1.0f};
// Índices para 2 triángulos
int bg_indices[6] = {0, 1, 2, 2, 3, 0};
// Renderizar sin textura (nullptr)
SDL_RenderGeometry(renderer_, nullptr, bg_vertices, 4, bg_indices, 6);
}
// Limpiar batches del frame anterior
batch_vertices_.clear();
batch_indices_.clear();
// Obtener referencia a las bolas desde SceneManager
const auto& balls = scene_manager_->getBalls();
if (current_mode_ == SimulationMode::SHAPE) {
// MODO FIGURA 3D: Ordenar por profundidad Z (Painter's Algorithm)
// Las pelotas con menor depth_brightness (más lejos/oscuras) se renderizan primero
// Crear vector de índices para ordenamiento
std::vector<size_t> render_order;
render_order.reserve(balls.size());
for (size_t i = 0; i < balls.size(); i++) {
render_order.push_back(i);
}
// Ordenar índices por profundidad Z (menor primero = fondo primero)
std::sort(render_order.begin(), render_order.end(), [&balls](size_t a, size_t b) {
return balls[a]->getDepthBrightness() < balls[b]->getDepthBrightness();
});
// Renderizar en orden de profundidad (fondo → frente)
for (size_t idx : render_order) {
SDL_FRect pos = balls[idx]->getPosition();
Color color = theme_manager_->getInterpolatedColor(idx); // Usar color interpolado (LERP)
float brightness = balls[idx]->getDepthBrightness();
float depth_scale = balls[idx]->getDepthScale();
// Mapear brightness de 0-1 a rango MIN-MAX
float brightness_factor = (ROTOBALL_MIN_BRIGHTNESS + brightness * (ROTOBALL_MAX_BRIGHTNESS - ROTOBALL_MIN_BRIGHTNESS)) / 255.0f;
// Aplicar factor de brillo al color
int r_mod = static_cast<int>(color.r * brightness_factor);
int g_mod = static_cast<int>(color.g * brightness_factor);
int b_mod = static_cast<int>(color.b * brightness_factor);
addSpriteToBatch(pos.x, pos.y, pos.w, pos.h, r_mod, g_mod, b_mod, depth_scale);
}
} else {
// MODO PHYSICS: Renderizar en orden normal del vector (sin escala de profundidad)
const auto& balls = scene_manager_->getBalls();
size_t idx = 0;
for (auto& ball : balls) {
SDL_FRect pos = ball->getPosition();
Color color = theme_manager_->getInterpolatedColor(idx); // Usar color interpolado (LERP)
addSpriteToBatch(pos.x, pos.y, pos.w, pos.h, color.r, color.g, color.b, 1.0f);
idx++;
}
}
// Renderizar todas las bolas en una sola llamada
if (!batch_vertices_.empty()) {
SDL_RenderGeometry(renderer_, texture_->getSDLTexture(), batch_vertices_.data(), static_cast<int>(batch_vertices_.size()), batch_indices_.data(), static_cast<int>(batch_indices_.size()));
}
// SISTEMA DE TEXTO ANTIGUO DESHABILITADO
// Reemplazado completamente por el sistema de notificaciones (Notifier)
// El doble renderizado causaba que aparecieran textos duplicados detrás de las notificaciones
/*
if (show_text_) {
// Obtener datos del tema actual (delegado a ThemeManager)
int text_color_r, text_color_g, text_color_b;
theme_manager_->getCurrentThemeTextColor(text_color_r, text_color_g, text_color_b);
const char* theme_name_es = theme_manager_->getCurrentThemeNameES();
// Calcular espaciado dinámico
int line_height = text_renderer_.getTextHeight();
int margin = 8;
// Texto del número de pelotas con color del tema
text_renderer_.printPhysical(text_pos_, margin, text_.c_str(), text_color_r, text_color_g, text_color_b, text_scale_x, text_scale_y);
// Mostrar nombre del tema en castellano debajo del número de pelotas
// (solo si text_ NO es ya el nombre del tema, para evitar duplicación)
if (theme_name_es != nullptr && text_ != theme_name_es) {
int theme_text_width = text_renderer_.getTextWidth(theme_name_es);
int theme_x = (current_screen_width_ - theme_text_width) / 2; // Centrar horizontalmente
int theme_y = margin + line_height; // Espaciado dinámico
// Texto del nombre del tema con el mismo color
text_renderer_.printPhysical(theme_x, theme_y, theme_name_es, text_color_r, text_color_g, text_color_b, text_scale_x, text_scale_y);
}
}
*/
// Renderizar UI (debug HUD, texto obsoleto, notificaciones) - delegado a UIManager
ui_manager_->render(renderer_, this, scene_manager_.get(), current_mode_, state_manager_->getCurrentMode(),
active_shape_.get(), shape_convergence_,
physical_window_width_, physical_window_height_, current_screen_width_);
SDL_RenderPresent(renderer_);
}
void Engine::showNotificationForAction(const std::string& text) {
// IMPORTANTE: Esta función solo se llama desde handlers de teclado (acciones manuales)
// NUNCA se llama desde código automático (DEMO/LOGO), por lo tanto siempre mostramos notificación
// Delegar a UIManager
ui_manager_->showNotification(text, NOTIFICATION_DURATION);
}
void Engine::pushBallsAwayFromGravity() {
scene_manager_->pushBallsAwayFromGravity();
}
void Engine::toggleVSync() {
vsync_enabled_ = !vsync_enabled_;
// Actualizar texto en UIManager
ui_manager_->updateVSyncText(vsync_enabled_);
// Aplicar el cambio de V-Sync al renderizador
SDL_SetRenderVSync(renderer_, vsync_enabled_ ? 1 : 0);
}
void Engine::toggleFullscreen() {
// Si está en modo real fullscreen, primero salir de él
if (real_fullscreen_enabled_) {
toggleRealFullscreen(); // Esto lo desactiva
}
fullscreen_enabled_ = !fullscreen_enabled_;
SDL_SetWindowFullscreen(window_, fullscreen_enabled_);
// Actualizar dimensiones físicas después del cambio
updatePhysicalWindowSize();
}
void Engine::toggleRealFullscreen() {
// Si está en modo fullscreen normal, primero desactivarlo
if (fullscreen_enabled_) {
fullscreen_enabled_ = false;
SDL_SetWindowFullscreen(window_, false);
}
real_fullscreen_enabled_ = !real_fullscreen_enabled_;
if (real_fullscreen_enabled_) {
// Obtener resolución del escritorio
int num_displays = 0;
SDL_DisplayID* displays = SDL_GetDisplays(&num_displays);
if (displays != nullptr && num_displays > 0) {
const auto* dm = SDL_GetCurrentDisplayMode(displays[0]);
if (dm != nullptr) {
// Cambiar a resolución nativa del escritorio
current_screen_width_ = dm->w;
current_screen_height_ = dm->h;
// Recrear ventana con nueva resolución
SDL_SetWindowSize(window_, current_screen_width_, current_screen_height_);
SDL_SetWindowFullscreen(window_, true);
// Actualizar presentación lógica del renderizador
SDL_SetRenderLogicalPresentation(renderer_, current_screen_width_, current_screen_height_, SDL_LOGICAL_PRESENTATION_INTEGER_SCALE);
// Actualizar tamaño físico de ventana y fuentes
updatePhysicalWindowSize();
// Reinicar la escena con nueva resolución
scene_manager_->updateScreenSize(current_screen_width_, current_screen_height_);
scene_manager_->changeScenario(scene_manager_->getCurrentScenario(), current_mode_);
// Actualizar tamaño de pantalla para boids (wrapping boundaries)
boid_manager_->updateScreenSize(current_screen_width_, current_screen_height_);
}
SDL_free(displays);
}
} else {
// Volver a resolución base (configurada por CLI o default)
current_screen_width_ = base_screen_width_;
current_screen_height_ = base_screen_height_;
// Restaurar ventana normal con el zoom actual (no hardcoded)
SDL_SetWindowFullscreen(window_, false);
SDL_SetWindowSize(window_, base_screen_width_ * current_window_zoom_, base_screen_height_ * current_window_zoom_);
SDL_SetWindowPosition(window_, SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED);
// Restaurar presentación lógica base
SDL_SetRenderLogicalPresentation(renderer_, base_screen_width_, base_screen_height_, SDL_LOGICAL_PRESENTATION_INTEGER_SCALE);
// Actualizar tamaño físico de ventana y fuentes
updatePhysicalWindowSize();
// Reinicar la escena con resolución original
scene_manager_->updateScreenSize(current_screen_width_, current_screen_height_);
scene_manager_->changeScenario(scene_manager_->getCurrentScenario(), current_mode_);
}
}
void Engine::toggleIntegerScaling() {
// Solo permitir cambio si estamos en modo fullscreen normal (F3)
if (!fullscreen_enabled_) {
return; // No hacer nada si no estamos en fullscreen
}
// Ciclar entre los 3 modos: INTEGER → LETTERBOX → STRETCH → INTEGER
switch (current_scaling_mode_) {
case ScalingMode::INTEGER:
current_scaling_mode_ = ScalingMode::LETTERBOX;
break;
case ScalingMode::LETTERBOX:
current_scaling_mode_ = ScalingMode::STRETCH;
break;
case ScalingMode::STRETCH:
current_scaling_mode_ = ScalingMode::INTEGER;
break;
}
// Aplicar el nuevo modo de escalado
SDL_RendererLogicalPresentation presentation = SDL_LOGICAL_PRESENTATION_INTEGER_SCALE;
const char* mode_name = "INTEGER";
switch (current_scaling_mode_) {
case ScalingMode::INTEGER:
presentation = SDL_LOGICAL_PRESENTATION_INTEGER_SCALE;
mode_name = "INTEGER";
break;
case ScalingMode::LETTERBOX:
presentation = SDL_LOGICAL_PRESENTATION_LETTERBOX;
mode_name = "LETTERBOX";
break;
case ScalingMode::STRETCH:
presentation = SDL_LOGICAL_PRESENTATION_STRETCH;
mode_name = "STRETCH";
break;
}
SDL_SetRenderLogicalPresentation(renderer_, current_screen_width_, current_screen_height_, presentation);
// Mostrar notificación del cambio
std::string notification = std::string("Escalado: ") + mode_name;
ui_manager_->showNotification(notification);
}
void Engine::addSpriteToBatch(float x, float y, float w, float h, int r, int g, int b, float scale) {
int vertex_index = static_cast<int>(batch_vertices_.size());
// Crear 4 vértices para el quad (2 triángulos)
SDL_Vertex vertices[4];
// Convertir colores de int (0-255) a float (0.0-1.0)
float rf = r / 255.0f;
float gf = g / 255.0f;
float bf = b / 255.0f;
// Aplicar escala al tamaño (centrado en el punto x, y)
float scaled_w = w * scale;
float scaled_h = h * scale;
float offset_x = (w - scaled_w) / 2.0f; // Offset para centrar
float offset_y = (h - scaled_h) / 2.0f;
// Vértice superior izquierdo
vertices[0].position = {x + offset_x, y + offset_y};
vertices[0].tex_coord = {0.0f, 0.0f};
vertices[0].color = {rf, gf, bf, 1.0f};
// Vértice superior derecho
vertices[1].position = {x + offset_x + scaled_w, y + offset_y};
vertices[1].tex_coord = {1.0f, 0.0f};
vertices[1].color = {rf, gf, bf, 1.0f};
// Vértice inferior derecho
vertices[2].position = {x + offset_x + scaled_w, y + offset_y + scaled_h};
vertices[2].tex_coord = {1.0f, 1.0f};
vertices[2].color = {rf, gf, bf, 1.0f};
// Vértice inferior izquierdo
vertices[3].position = {x + offset_x, y + offset_y + scaled_h};
vertices[3].tex_coord = {0.0f, 1.0f};
vertices[3].color = {rf, gf, bf, 1.0f};
// Añadir vértices al batch
for (int i = 0; i < 4; i++) {
batch_vertices_.push_back(vertices[i]);
}
// Añadir índices para 2 triángulos
batch_indices_.push_back(vertex_index + 0);
batch_indices_.push_back(vertex_index + 1);
batch_indices_.push_back(vertex_index + 2);
batch_indices_.push_back(vertex_index + 2);
batch_indices_.push_back(vertex_index + 3);
batch_indices_.push_back(vertex_index + 0);
}
// Sistema de zoom dinámico
int Engine::calculateMaxWindowZoom() const {
// Obtener información del display usando el método de Coffee Crisis
int num_displays = 0;
SDL_DisplayID* displays = SDL_GetDisplays(&num_displays);
if (displays == nullptr || num_displays == 0) {
return WINDOW_ZOOM_MIN; // Fallback si no se puede obtener
}
// Obtener el modo de display actual
const auto* dm = SDL_GetCurrentDisplayMode(displays[0]);
if (dm == nullptr) {
SDL_free(displays);
return WINDOW_ZOOM_MIN;
}
// Calcular zoom máximo usando la fórmula de Coffee Crisis
const int MAX_ZOOM = std::min(dm->w / base_screen_width_, (dm->h - WINDOW_DECORATION_HEIGHT) / base_screen_height_);
SDL_free(displays);
// Aplicar límites
return std::max(WINDOW_ZOOM_MIN, std::min(MAX_ZOOM, WINDOW_ZOOM_MAX));
}
void Engine::setWindowZoom(int new_zoom) {
// Validar zoom
int max_zoom = calculateMaxWindowZoom();
new_zoom = std::max(WINDOW_ZOOM_MIN, std::min(new_zoom, max_zoom));
if (new_zoom == current_window_zoom_) {
return; // No hay cambio
}
// Obtener posición actual del centro de la ventana
int current_x, current_y;
SDL_GetWindowPosition(window_, &current_x, &current_y);
int current_center_x = current_x + (base_screen_width_ * current_window_zoom_) / 2;
int current_center_y = current_y + (base_screen_height_ * current_window_zoom_) / 2;
// Calcular nuevo tamaño
int new_width = base_screen_width_ * new_zoom;
int new_height = base_screen_height_ * new_zoom;
// Calcular nueva posición (centrada en el punto actual)
int new_x = current_center_x - new_width / 2;
int new_y = current_center_y - new_height / 2;
// Obtener límites del escritorio para no salirse
SDL_Rect display_bounds;
if (SDL_GetDisplayBounds(SDL_GetPrimaryDisplay(), &display_bounds) == 0) {
// Aplicar márgenes
int min_x = WINDOW_DESKTOP_MARGIN;
int min_y = WINDOW_DESKTOP_MARGIN;
int max_x = display_bounds.w - new_width - WINDOW_DESKTOP_MARGIN;
int max_y = display_bounds.h - new_height - WINDOW_DESKTOP_MARGIN - WINDOW_DECORATION_HEIGHT;
// Limitar posición
new_x = std::max(min_x, std::min(new_x, max_x));
new_y = std::max(min_y, std::min(new_y, max_y));
}
// Aplicar cambios
SDL_SetWindowSize(window_, new_width, new_height);
SDL_SetWindowPosition(window_, new_x, new_y);
current_window_zoom_ = new_zoom;
// Actualizar tamaño físico de ventana y fuentes
updatePhysicalWindowSize();
}
void Engine::zoomIn() {
setWindowZoom(current_window_zoom_ + 1);
}
void Engine::zoomOut() {
setWindowZoom(current_window_zoom_ - 1);
}
void Engine::updatePhysicalWindowSize() {
if (real_fullscreen_enabled_) {
// En fullscreen real (F4), usar resolución del display
physical_window_width_ = current_screen_width_;
physical_window_height_ = current_screen_height_;
} else if (fullscreen_enabled_) {
// En fullscreen F3, obtener tamaño REAL del display (no del framebuffer lógico)
int num_displays = 0;
SDL_DisplayID* displays = SDL_GetDisplays(&num_displays);
if (displays != nullptr && num_displays > 0) {
const auto* dm = SDL_GetCurrentDisplayMode(displays[0]);
if (dm != nullptr) {
physical_window_width_ = dm->w;
physical_window_height_ = dm->h;
}
SDL_free(displays);
}
} else {
// En modo ventana, obtener tamaño FÍSICO real del framebuffer
int window_w = 0, window_h = 0;
SDL_GetWindowSizeInPixels(window_, &window_w, &window_h);
physical_window_width_ = window_w;
physical_window_height_ = window_h;
}
// Notificar a UIManager del cambio de tamaño (delegado)
ui_manager_->updatePhysicalWindowSize(physical_window_width_, physical_window_height_);
}
// ============================================================================
// CALLBACKS PARA STATEMANAGER
// ============================================================================
// StateManager coordina los estados y timers, Engine proporciona implementación
// Estos callbacks permiten que StateManager ejecute acciones complejas que
// requieren acceso a múltiples componentes (SceneManager, ThemeManager, etc.)
// Este enfoque es pragmático y mantiene la separación de responsabilidades
// Callback para ejecutar acciones de LOGO MODE (máquina de estados compleja)
void Engine::performLogoAction(bool logo_waiting_for_flip) {
// Verificar si algún modo demo está activo (DEMO, DEMO_LITE o LOGO)
if (state_manager_->getCurrentMode() == AppMode::SANDBOX) return;
// Actualizar timer
demo_timer_ += delta_time_;
// Determinar si es hora de ejecutar acción (depende del modo)
bool should_trigger = false;
if (state_manager_->getCurrentMode() == AppMode::LOGO) {
// LOGO MODE: Dos caminos posibles
if (logo_waiting_for_flip_) {
// CAMINO B: Esperando a que ocurran flips
// Obtener referencia a PNGShape si está activa
PNGShape* png_shape = nullptr;
if (active_shape_ && current_mode_ == SimulationMode::SHAPE) {
png_shape = dynamic_cast<PNGShape*>(active_shape_.get());
}
if (png_shape) {
int current_flip_count = png_shape->getFlipCount();
// Detectar nuevo flip completado
if (current_flip_count > logo_current_flip_count_) {
logo_current_flip_count_ = current_flip_count;
}
// Si estamos EN o DESPUÉS del flip objetivo
// +1 porque queremos actuar DURANTE el flip N, no después de completarlo
if (logo_current_flip_count_ + 1 >= logo_target_flip_number_) {
// Monitorear progreso del flip actual
if (png_shape->isFlipping()) {
float flip_progress = png_shape->getFlipProgress();
if (flip_progress >= logo_target_flip_percentage_) {
should_trigger = true; // ¡Trigger durante el flip!
}
}
}
}
} else {
// CAMINO A: Esperar convergencia + tiempo (comportamiento original)
bool min_time_reached = demo_timer_ >= logo_min_time_;
bool max_time_reached = demo_timer_ >= logo_max_time_;
bool convergence_ok = shape_convergence_ >= logo_convergence_threshold_;
should_trigger = (min_time_reached && convergence_ok) || max_time_reached;
}
} else {
// DEMO/DEMO_LITE: Timer simple como antes
should_trigger = demo_timer_ >= demo_next_action_time_;
}
// Si es hora de ejecutar acción
if (should_trigger) {
// MODO LOGO: Sistema de acciones variadas con gravedad dinámica
if (state_manager_->getCurrentMode() == AppMode::LOGO) {
// Elegir acción aleatoria ponderada
int action = rand() % 100;
if (current_mode_ == SimulationMode::SHAPE) {
// Logo quieto (formado) → Decidir camino a seguir
// DECISIÓN BIFURCADA: ¿Cambio inmediato o esperar flips?
if (logo_waiting_for_flip_) {
// Ya estábamos esperando flips, y se disparó el trigger
// → Hacer el cambio SHAPE → PHYSICS ahora (durante el flip)
if (action < 50) {
toggleShapeModeInternal(true); // Con gravedad ON
} else {
toggleShapeModeInternal(false); // Con gravedad OFF
}
// Resetear variables de espera de flips
logo_waiting_for_flip_ = false;
logo_current_flip_count_ = 0;
// Resetear timer
demo_timer_ = 0.0f;
float interval_range = logo_max_time_ - logo_min_time_;
demo_next_action_time_ = logo_min_time_ + (rand() % 1000) / 1000.0f * interval_range;
} else if (rand() % 100 < LOGO_FLIP_WAIT_PROBABILITY) {
// CAMINO B (50%): Esperar a que ocurran 1-3 flips
logo_waiting_for_flip_ = true;
logo_target_flip_number_ = LOGO_FLIP_WAIT_MIN + rand() % (LOGO_FLIP_WAIT_MAX - LOGO_FLIP_WAIT_MIN + 1);
logo_target_flip_percentage_ = LOGO_FLIP_TRIGGER_MIN + (rand() % 1000) / 1000.0f * (LOGO_FLIP_TRIGGER_MAX - LOGO_FLIP_TRIGGER_MIN);
logo_current_flip_count_ = 0;
// Resetear contador de flips en PNGShape
if (active_shape_) {
PNGShape* png_shape = dynamic_cast<PNGShape*>(active_shape_.get());
if (png_shape) {
png_shape->resetFlipCount();
}
}
// NO hacer nada más este frame - esperar a que ocurran los flips
// El trigger se ejecutará en futuras iteraciones cuando se cumplan las condiciones
} else {
// CAMINO A (50%): Cambio inmediato
if (action < 50) {
// 50%: SHAPE → PHYSICS con gravedad ON (caída dramática)
toggleShapeModeInternal(true);
} else {
// 50%: SHAPE → PHYSICS con gravedad OFF (dar vueltas sin caer)
toggleShapeModeInternal(false);
}
// Resetear variables de espera de flips al cambiar a PHYSICS
logo_waiting_for_flip_ = false;
logo_current_flip_count_ = 0;
// Resetear timer con intervalos escalados
demo_timer_ = 0.0f;
float interval_range = logo_max_time_ - logo_min_time_;
demo_next_action_time_ = logo_min_time_ + (rand() % 1000) / 1000.0f * interval_range;
}
} else {
// Logo animado (PHYSICS) → 4 opciones posibles
if (action < 50) {
// 50%: PHYSICS → SHAPE (reconstruir logo y ver rotaciones)
toggleShapeModeInternal(false);
// Resetear variables de espera de flips al volver a SHAPE
logo_waiting_for_flip_ = false;
logo_current_flip_count_ = 0;
} else if (action < 68) {
// 18%: Forzar gravedad ON (empezar a caer mientras da vueltas)
scene_manager_->forceBallsGravityOn();
} else if (action < 84) {
// 16%: Forzar gravedad OFF (flotar mientras da vueltas)
scene_manager_->forceBallsGravityOff();
} else {
// 16%: Cambiar dirección de gravedad (nueva variación)
GravityDirection new_direction = static_cast<GravityDirection>(rand() % 4);
scene_manager_->changeGravityDirection(new_direction);
// Si la gravedad está OFF, activarla para que el cambio sea visible
scene_manager_->forceBallsGravityOn();
}
// Resetear timer con intervalos escalados
demo_timer_ = 0.0f;
float interval_range = logo_max_time_ - logo_min_time_;
demo_next_action_time_ = logo_min_time_ + (rand() % 1000) / 1000.0f * interval_range;
}
// Solo salir automáticamente si la entrada a LOGO fue automática (desde DEMO)
// No salir si el usuario entró manualmente con tecla K
// Probabilidad de salir: 60% en cada acción → sale rápido (relación DEMO:LOGO = 6:1)
if (!state_manager_->getLogoEnteredManually() && rand() % 100 < 60) {
state_manager_->exitLogoMode(true); // Volver a DEMO/DEMO_LITE
}
}
// MODO DEMO/DEMO_LITE: Acciones normales
else {
bool is_lite = (state_manager_->getCurrentMode() == AppMode::DEMO_LITE);
executeDemoAction(is_lite);
// Resetear timer y calcular próximo intervalo aleatorio
demo_timer_ = 0.0f;
// Usar intervalos diferentes según modo
float interval_min = is_lite ? DEMO_LITE_ACTION_INTERVAL_MIN : DEMO_ACTION_INTERVAL_MIN;
float interval_max = is_lite ? DEMO_LITE_ACTION_INTERVAL_MAX : DEMO_ACTION_INTERVAL_MAX;
float interval_range = interval_max - interval_min;
demo_next_action_time_ = interval_min + (rand() % 1000) / 1000.0f * interval_range;
}
}
}
// Callback para StateManager - Ejecutar acción DEMO
void Engine::executeDemoAction(bool is_lite) {
// ============================================
// SALTO AUTOMÁTICO A LOGO MODE (Easter Egg)
// ============================================
if (is_lite) {
// DEMO LITE: Verificar condiciones para salto a Logo Mode
if (static_cast<int>(scene_manager_->getBallCount()) >= LOGO_MODE_MIN_BALLS &&
theme_manager_->getCurrentThemeIndex() == 5) { // MONOCHROME
// 10% probabilidad de saltar a Logo Mode
if (rand() % 100 < LOGO_JUMP_PROBABILITY_FROM_DEMO_LITE) {
state_manager_->enterLogoMode(true, current_screen_width_, current_screen_height_, scene_manager_->getBallCount());
return;
}
}
} else {
// DEMO COMPLETO: Verificar condiciones para salto a Logo Mode
if (static_cast<int>(scene_manager_->getBallCount()) >= LOGO_MODE_MIN_BALLS) {
// 15% probabilidad de saltar a Logo Mode
if (rand() % 100 < LOGO_JUMP_PROBABILITY_FROM_DEMO) {
state_manager_->enterLogoMode(true, current_screen_width_, current_screen_height_, scene_manager_->getBallCount());
return;
}
}
}
// ============================================
// ACCIONES NORMALES DE DEMO/DEMO_LITE
// ============================================
int TOTAL_WEIGHT;
int random_value;
int accumulated_weight = 0;
if (is_lite) {
// DEMO LITE: Solo física/figuras
TOTAL_WEIGHT = DEMO_LITE_WEIGHT_GRAVITY_DIR + DEMO_LITE_WEIGHT_GRAVITY_TOGGLE + DEMO_LITE_WEIGHT_SHAPE + DEMO_LITE_WEIGHT_TOGGLE_PHYSICS + DEMO_LITE_WEIGHT_IMPULSE;
random_value = rand() % TOTAL_WEIGHT;
// Cambiar dirección gravedad (25%)
accumulated_weight += DEMO_LITE_WEIGHT_GRAVITY_DIR;
if (random_value < accumulated_weight) {
GravityDirection new_direction = static_cast<GravityDirection>(rand() % 4);
scene_manager_->changeGravityDirection(new_direction);
return;
}
// Toggle gravedad ON/OFF (20%)
accumulated_weight += DEMO_LITE_WEIGHT_GRAVITY_TOGGLE;
if (random_value < accumulated_weight) {
executeToggleGravityOnOff();
return;
}
// Activar figura 3D (25%) - PNG_SHAPE excluido (reservado para Logo Mode)
accumulated_weight += DEMO_LITE_WEIGHT_SHAPE;
if (random_value < accumulated_weight) {
ShapeType shapes[] = {ShapeType::SPHERE, ShapeType::LISSAJOUS, ShapeType::HELIX, ShapeType::TORUS, ShapeType::CUBE, ShapeType::CYLINDER, ShapeType::ICOSAHEDRON, ShapeType::ATOM};
int shape_index = rand() % 8;
activateShapeInternal(shapes[shape_index]);
return;
}
// Toggle física ↔ figura (20%)
accumulated_weight += DEMO_LITE_WEIGHT_TOGGLE_PHYSICS;
if (random_value < accumulated_weight) {
toggleShapeModeInternal(false); // NO forzar gravedad al salir
return;
}
// Aplicar impulso (10%)
accumulated_weight += DEMO_LITE_WEIGHT_IMPULSE;
if (random_value < accumulated_weight) {
pushBallsAwayFromGravity();
return;
}
} else {
// DEMO COMPLETO: Todas las acciones
TOTAL_WEIGHT = DEMO_WEIGHT_GRAVITY_DIR + DEMO_WEIGHT_GRAVITY_TOGGLE + DEMO_WEIGHT_SHAPE + DEMO_WEIGHT_TOGGLE_PHYSICS + DEMO_WEIGHT_REGENERATE_SHAPE + DEMO_WEIGHT_THEME + DEMO_WEIGHT_SCENARIO + DEMO_WEIGHT_IMPULSE + DEMO_WEIGHT_DEPTH_ZOOM + DEMO_WEIGHT_SHAPE_SCALE + DEMO_WEIGHT_SPRITE;
random_value = rand() % TOTAL_WEIGHT;
// Cambiar dirección gravedad (10%)
accumulated_weight += DEMO_WEIGHT_GRAVITY_DIR;
if (random_value < accumulated_weight) {
GravityDirection new_direction = static_cast<GravityDirection>(rand() % 4);
scene_manager_->changeGravityDirection(new_direction);
return;
}
// Toggle gravedad ON/OFF (8%)
accumulated_weight += DEMO_WEIGHT_GRAVITY_TOGGLE;
if (random_value < accumulated_weight) {
executeToggleGravityOnOff();
return;
}
// Activar figura 3D (20%) - PNG_SHAPE excluido (reservado para Logo Mode)
accumulated_weight += DEMO_WEIGHT_SHAPE;
if (random_value < accumulated_weight) {
ShapeType shapes[] = {ShapeType::SPHERE, ShapeType::LISSAJOUS, ShapeType::HELIX, ShapeType::TORUS, ShapeType::CUBE, ShapeType::CYLINDER, ShapeType::ICOSAHEDRON, ShapeType::ATOM};
int shape_index = rand() % 8;
activateShapeInternal(shapes[shape_index]);
return;
}
// Toggle física ↔ figura (12%)
accumulated_weight += DEMO_WEIGHT_TOGGLE_PHYSICS;
if (random_value < accumulated_weight) {
toggleShapeModeInternal(false); // NO forzar gravedad al salir
return;
}
// Re-generar misma figura (8%)
accumulated_weight += DEMO_WEIGHT_REGENERATE_SHAPE;
if (random_value < accumulated_weight) {
if (current_mode_ == SimulationMode::SHAPE && active_shape_) {
generateShape(); // Re-generar sin cambiar tipo
}
return;
}
// Cambiar tema (15%)
accumulated_weight += DEMO_WEIGHT_THEME;
if (random_value < accumulated_weight) {
// Elegir entre TODOS los 15 temas (9 estáticos + 6 dinámicos)
int random_theme_index = rand() % 15;
theme_manager_->switchToTheme(random_theme_index);
return;
}
// Cambiar escenario (10%) - EXCLUIR índices 0, 6, 7 (1, 50K, 100K pelotas)
accumulated_weight += DEMO_WEIGHT_SCENARIO;
if (random_value < accumulated_weight) {
// Escenarios válidos: índices 1, 2, 3, 4, 5 (10, 100, 500, 1000, 10000 pelotas)
int valid_scenarios[] = {1, 2, 3, 4, 5};
int new_scenario = valid_scenarios[rand() % 5];
scene_manager_->changeScenario(new_scenario, current_mode_);
return;
}
// Aplicar impulso (10%)
accumulated_weight += DEMO_WEIGHT_IMPULSE;
if (random_value < accumulated_weight) {
pushBallsAwayFromGravity();
return;
}
// Toggle profundidad (3%)
accumulated_weight += DEMO_WEIGHT_DEPTH_ZOOM;
if (random_value < accumulated_weight) {
if (current_mode_ == SimulationMode::SHAPE) {
depth_zoom_enabled_ = !depth_zoom_enabled_;
}
return;
}
// Cambiar escala de figura (2%)
accumulated_weight += DEMO_WEIGHT_SHAPE_SCALE;
if (random_value < accumulated_weight) {
if (current_mode_ == SimulationMode::SHAPE) {
int scale_action = rand() % 3;
if (scale_action == 0) {
shape_scale_factor_ += SHAPE_SCALE_STEP;
} else if (scale_action == 1) {
shape_scale_factor_ -= SHAPE_SCALE_STEP;
} else {
shape_scale_factor_ = SHAPE_SCALE_DEFAULT;
}
clampShapeScale();
generateShape();
}
return;
}
// Cambiar sprite (2%)
accumulated_weight += DEMO_WEIGHT_SPRITE;
if (random_value < accumulated_weight) {
switchTextureInternal(false); // Suprimir notificación en modo automático
return;
}
}
}
// Callback para StateManager - Randomizar estado al iniciar DEMO
void Engine::executeRandomizeOnDemoStart(bool is_lite) {
// Si venimos de LOGO con PNG_SHAPE, cambiar figura obligatoriamente
// PNG_SHAPE es exclusivo del modo LOGO y no debe aparecer en DEMO/DEMO_LITE
if (current_shape_type_ == ShapeType::PNG_SHAPE) {
ShapeType shapes[] = {ShapeType::SPHERE, ShapeType::LISSAJOUS, ShapeType::HELIX,
ShapeType::TORUS, ShapeType::CUBE, ShapeType::CYLINDER,
ShapeType::ICOSAHEDRON, ShapeType::ATOM};
activateShapeInternal(shapes[rand() % 8]);
}
if (is_lite) {
// DEMO LITE: Solo randomizar física/figura + gravedad
// Elegir aleatoriamente entre modo física o figura
if (rand() % 2 == 0) {
// Modo física
if (current_mode_ == SimulationMode::SHAPE) {
toggleShapeModeInternal(false); // Salir a física sin forzar gravedad
}
} else {
// Modo figura: elegir figura aleatoria (excluir PNG_SHAPE - es logo especial)
ShapeType shapes[] = {ShapeType::SPHERE, ShapeType::LISSAJOUS, ShapeType::HELIX, ShapeType::TORUS, ShapeType::CUBE, ShapeType::CYLINDER, ShapeType::ICOSAHEDRON, ShapeType::ATOM};
activateShapeInternal(shapes[rand() % 8]);
}
// Randomizar gravedad: dirección + ON/OFF
GravityDirection new_direction = static_cast<GravityDirection>(rand() % 4);
scene_manager_->changeGravityDirection(new_direction);
if (rand() % 2 == 0) {
executeToggleGravityOnOff(); // 50% probabilidad de desactivar gravedad
}
} else {
// DEMO COMPLETO: Randomizar TODO
// 1. Escenario (excluir índices 0, 6, 7)
int valid_scenarios[] = {1, 2, 3, 4, 5};
int new_scenario = valid_scenarios[rand() % 5];
scene_manager_->changeScenario(new_scenario, current_mode_);
// 2. Tema (elegir entre TODOS los 15 temas)
int random_theme_index = rand() % 15;
theme_manager_->switchToTheme(random_theme_index);
// 3. Sprite
if (rand() % 2 == 0) {
switchTextureInternal(false); // Suprimir notificación al activar modo DEMO
}
// 4. Física o Figura
if (rand() % 2 == 0) {
// Modo física
if (current_mode_ == SimulationMode::SHAPE) {
toggleShapeModeInternal(false);
}
} else {
// Modo figura: elegir figura aleatoria (excluir PNG_SHAPE - es logo especial)
ShapeType shapes[] = {ShapeType::SPHERE, ShapeType::LISSAJOUS, ShapeType::HELIX, ShapeType::TORUS, ShapeType::CUBE, ShapeType::CYLINDER, ShapeType::ICOSAHEDRON, ShapeType::ATOM};
activateShapeInternal(shapes[rand() % 8]);
// 5. Profundidad (solo si estamos en figura)
if (rand() % 2 == 0) {
depth_zoom_enabled_ = !depth_zoom_enabled_;
}
// 6. Escala de figura (aleatoria entre 0.5x y 2.0x)
shape_scale_factor_ = 0.5f + (rand() % 1500) / 1000.0f;
clampShapeScale();
generateShape();
}
// 7. Gravedad: dirección + ON/OFF
GravityDirection new_direction = static_cast<GravityDirection>(rand() % 4);
scene_manager_->changeGravityDirection(new_direction);
if (rand() % 3 == 0) { // 33% probabilidad de desactivar gravedad
executeToggleGravityOnOff();
}
}
}
// Callback para StateManager - Toggle gravedad ON/OFF para todas las pelotas
void Engine::executeToggleGravityOnOff() {
// Alternar entre activar/desactivar gravedad
bool first_ball_gravity_enabled = (!scene_manager_->hasBalls() || scene_manager_->getFirstBall()->getGravityForce() > 0.0f);
if (first_ball_gravity_enabled) {
// Desactivar gravedad
scene_manager_->forceBallsGravityOff();
} else {
// Activar gravedad
scene_manager_->forceBallsGravityOn();
}
}
// ============================================================================
// CALLBACKS PARA STATEMANAGER - LOGO MODE
// ============================================================================
// Callback para StateManager - Configuración visual al entrar a LOGO MODE
void Engine::executeEnterLogoMode(size_t ball_count) {
// Verificar mínimo de pelotas
if (static_cast<int>(ball_count) < LOGO_MODE_MIN_BALLS) {
// Ajustar a 5000 pelotas automáticamente
scene_manager_->changeScenario(5, current_mode_); // Escenario 5000 pelotas (índice 5 en BALL_COUNT_SCENARIOS)
}
// Guardar estado previo (para restaurar al salir)
logo_previous_theme_ = theme_manager_->getCurrentThemeIndex();
logo_previous_texture_index_ = current_texture_index_;
logo_previous_shape_scale_ = shape_scale_factor_;
// Buscar índice de textura "small"
size_t small_index = current_texture_index_; // Por defecto mantener actual
for (size_t i = 0; i < texture_names_.size(); i++) {
if (texture_names_[i] == "small") {
small_index = i;
break;
}
}
// Aplicar configuración fija del Modo Logo
if (small_index != current_texture_index_) {
current_texture_index_ = small_index;
texture_ = textures_[current_texture_index_];
int new_size = texture_->getWidth();
current_ball_size_ = new_size;
scene_manager_->updateBallTexture(texture_, new_size);
}
// Cambiar a tema aleatorio entre: MONOCHROME, LAVENDER, CRIMSON, ESMERALDA
int logo_themes[] = {5, 6, 7, 8}; // MONOCHROME, LAVENDER, CRIMSON, ESMERALDA
int random_theme = logo_themes[rand() % 4];
theme_manager_->switchToTheme(random_theme);
// Establecer escala a 120%
shape_scale_factor_ = LOGO_MODE_SHAPE_SCALE;
clampShapeScale();
// Activar PNG_SHAPE (el logo)
activateShapeInternal(ShapeType::PNG_SHAPE);
// Configurar PNG_SHAPE en modo LOGO (flip intervals más largos)
if (active_shape_) {
PNGShape* png_shape = dynamic_cast<PNGShape*>(active_shape_.get());
if (png_shape) {
png_shape->setLogoMode(true);
png_shape->resetFlipCount(); // Resetear contador de flips
}
}
}
void Engine::executeExitLogoMode() {
// Restaurar estado visual previo
theme_manager_->switchToTheme(logo_previous_theme_);
if (logo_previous_texture_index_ != current_texture_index_ &&
logo_previous_texture_index_ < textures_.size()) {
current_texture_index_ = logo_previous_texture_index_;
texture_ = textures_[current_texture_index_];
int new_size = texture_->getWidth();
current_ball_size_ = new_size;
scene_manager_->updateBallTexture(texture_, new_size);
}
shape_scale_factor_ = logo_previous_shape_scale_;
clampShapeScale();
generateShape();
// Desactivar modo LOGO en PNG_SHAPE (volver a flip intervals normales)
if (active_shape_) {
PNGShape* png_shape = dynamic_cast<PNGShape*>(active_shape_.get());
if (png_shape) {
png_shape->setLogoMode(false);
}
}
// Si la figura activa es PNG_SHAPE, cambiar a otra figura aleatoria
if (current_shape_type_ == ShapeType::PNG_SHAPE) {
ShapeType shapes[] = {ShapeType::SPHERE, ShapeType::LISSAJOUS, ShapeType::HELIX,
ShapeType::TORUS, ShapeType::CUBE, ShapeType::CYLINDER,
ShapeType::ICOSAHEDRON, ShapeType::ATOM};
activateShapeInternal(shapes[rand() % 8]);
}
}
// Toggle manual del Modo Logo (tecla K)
// Sistema de cambio de sprites dinámico
void Engine::switchTextureInternal(bool show_notification) {
if (textures_.empty()) return;
// Cambiar a siguiente textura (ciclar)
current_texture_index_ = (current_texture_index_ + 1) % textures_.size();
texture_ = textures_[current_texture_index_];
// Obtener nuevo tamaño de la textura
int new_size = texture_->getWidth();
current_ball_size_ = new_size;
// Actualizar texturas y tamaños de todas las pelotas (delegado a SceneManager)
scene_manager_->updateBallTexture(texture_, new_size);
// Mostrar notificación con el nombre de la textura (solo si se solicita)
if (show_notification) {
std::string texture_name = texture_names_[current_texture_index_];
std::transform(texture_name.begin(), texture_name.end(), texture_name.begin(), ::toupper);
showNotificationForAction("Sprite: " + texture_name);
}
}
// ============================================================================
// Sistema de Figuras 3D - IMPLEMENTACIÓN PARA CALLBACKS DEMO/LOGO
// ============================================================================
// NOTA: Engine mantiene implementación de figuras usada por callbacks
// ShapeManager tiene implementación paralela para controles manuales del usuario
// Este enfoque permite que DEMO/LOGO manipulen figuras sin afectar el estado manual
// Alternar entre modo física y última figura (usado por performLogoAction)
void Engine::toggleShapeModeInternal(bool force_gravity_on_exit) {
if (current_mode_ == SimulationMode::PHYSICS) {
// Cambiar a modo figura (usar última figura seleccionada)
activateShapeInternal(last_shape_type_);
// Si estamos en modo LOGO y la figura es PNG_SHAPE, restaurar configuración LOGO
if (state_manager_->getCurrentMode() == AppMode::LOGO && last_shape_type_ == ShapeType::PNG_SHAPE) {
if (active_shape_) {
PNGShape* png_shape = dynamic_cast<PNGShape*>(active_shape_.get());
if (png_shape) {
png_shape->setLogoMode(true);
}
}
}
// Si estamos en LOGO MODE, generar threshold aleatorio de convergencia (75-100%)
if (state_manager_->getCurrentMode() == AppMode::LOGO) {
logo_convergence_threshold_ = LOGO_CONVERGENCE_MIN +
(rand() % 1000) / 1000.0f * (LOGO_CONVERGENCE_MAX - LOGO_CONVERGENCE_MIN);
shape_convergence_ = 0.0f; // Reset convergencia al entrar
}
} else {
// Volver a modo física normal
current_mode_ = SimulationMode::PHYSICS;
// Desactivar atracción y resetear escala de profundidad
auto& balls = scene_manager_->getBallsMutable();
for (auto& ball : balls) {
ball->enableShapeAttraction(false);
ball->setDepthScale(1.0f); // Reset escala a 100% (evita "pop" visual)
}
// Activar gravedad al salir (solo si se especifica)
if (force_gravity_on_exit) {
scene_manager_->forceBallsGravityOn();
}
// Mostrar notificación (solo si NO estamos en modo demo o logo)
if (state_manager_->getCurrentMode() == AppMode::SANDBOX) {
ui_manager_->showNotification("Modo Física");
}
}
}
// Activar figura específica (llamado por teclas Q/W/E/R/Y/U/I o por toggleShapeMode)
void Engine::activateShapeInternal(ShapeType type) {
// Guardar como última figura seleccionada
last_shape_type_ = type;
current_shape_type_ = type;
// Cambiar a modo figura
current_mode_ = SimulationMode::SHAPE;
// Desactivar gravedad al entrar en modo figura
scene_manager_->forceBallsGravityOff();
// Crear instancia polimórfica de la figura correspondiente
switch (type) {
case ShapeType::SPHERE:
active_shape_ = std::make_unique<SphereShape>();
break;
case ShapeType::CUBE:
active_shape_ = std::make_unique<CubeShape>();
break;
case ShapeType::HELIX:
active_shape_ = std::make_unique<HelixShape>();
break;
case ShapeType::TORUS:
active_shape_ = std::make_unique<TorusShape>();
break;
case ShapeType::LISSAJOUS:
active_shape_ = std::make_unique<LissajousShape>();
break;
case ShapeType::CYLINDER:
active_shape_ = std::make_unique<CylinderShape>();
break;
case ShapeType::ICOSAHEDRON:
active_shape_ = std::make_unique<IcosahedronShape>();
break;
case ShapeType::ATOM:
active_shape_ = std::make_unique<AtomShape>();
break;
case ShapeType::PNG_SHAPE:
active_shape_ = std::make_unique<PNGShape>("data/shapes/jailgames.png");
break;
default:
active_shape_ = std::make_unique<SphereShape>(); // Fallback
break;
}
// Generar puntos de la figura
generateShape();
// Activar atracción física en todas las pelotas
auto& balls = scene_manager_->getBallsMutable();
for (auto& ball : balls) {
ball->enableShapeAttraction(true);
}
// Mostrar notificación con nombre de figura (solo si NO estamos en modo demo o logo)
if (active_shape_ && state_manager_->getCurrentMode() == AppMode::SANDBOX) {
std::string notification = std::string("Modo ") + active_shape_->getName();
ui_manager_->showNotification(notification);
}
}
// Generar puntos de la figura activa
void Engine::generateShape() {
if (!active_shape_) return;
int num_points = static_cast<int>(scene_manager_->getBallCount());
active_shape_->generatePoints(num_points, static_cast<float>(current_screen_width_), static_cast<float>(current_screen_height_));
}
// Actualizar figura activa (rotación, animación, etc.)
void Engine::updateShape() {
if (!active_shape_ || current_mode_ != SimulationMode::SHAPE) return;
// Actualizar animación de la figura
active_shape_->update(delta_time_, static_cast<float>(current_screen_width_), static_cast<float>(current_screen_height_));
// Obtener factor de escala para física (base de figura + escala manual)
float scale_factor = active_shape_->getScaleFactor(static_cast<float>(current_screen_height_)) * shape_scale_factor_;
// Centro de la pantalla
float center_x = current_screen_width_ / 2.0f;
float center_y = current_screen_height_ / 2.0f;
// Obtener referencia mutable a las bolas desde SceneManager
auto& balls = scene_manager_->getBallsMutable();
// Actualizar cada pelota con física de atracción
for (size_t i = 0; i < balls.size(); i++) {
// Obtener posición 3D rotada del punto i
float x_3d, y_3d, z_3d;
active_shape_->getPoint3D(static_cast<int>(i), x_3d, y_3d, z_3d);
// Aplicar escala manual a las coordenadas 3D
x_3d *= shape_scale_factor_;
y_3d *= shape_scale_factor_;
z_3d *= shape_scale_factor_;
// Proyección 2D ortográfica (punto objetivo móvil)
float target_x = center_x + x_3d;
float target_y = center_y + y_3d;
// Actualizar target de la pelota para cálculo de convergencia
balls[i]->setShapeTarget2D(target_x, target_y);
// Aplicar fuerza de atracción física hacia el punto rotado
// Usar constantes SHAPE (mayor pegajosidad que ROTOBALL)
float shape_size = scale_factor * 80.0f; // 80px = radio base
balls[i]->applyShapeForce(target_x, target_y, shape_size, delta_time_, SHAPE_SPRING_K, SHAPE_DAMPING_BASE, SHAPE_DAMPING_NEAR, SHAPE_NEAR_THRESHOLD, SHAPE_MAX_FORCE);
// Calcular brillo según profundidad Z para renderizado
// Normalizar Z al rango de la figura (asumiendo simetría ±shape_size)
float z_normalized = (z_3d + shape_size) / (2.0f * shape_size);
z_normalized = std::max(0.0f, std::min(1.0f, z_normalized));
balls[i]->setDepthBrightness(z_normalized);
// Calcular escala según profundidad Z (perspectiva) - solo si está activado
// 0.0 (fondo) → 0.5x, 0.5 (medio) → 1.0x, 1.0 (frente) → 1.5x
float depth_scale = depth_zoom_enabled_ ? (0.5f + z_normalized * 1.0f) : 1.0f;
balls[i]->setDepthScale(depth_scale);
}
// Calcular convergencia en LOGO MODE (% de pelotas cerca de su objetivo)
if (state_manager_->getCurrentMode() == AppMode::LOGO && current_mode_ == SimulationMode::SHAPE) {
int balls_near = 0;
float distance_threshold = LOGO_CONVERGENCE_DISTANCE; // 20px fijo (más permisivo)
for (const auto& ball : balls) {
if (ball->getDistanceToTarget() < distance_threshold) {
balls_near++;
}
}
shape_convergence_ = static_cast<float>(balls_near) / scene_manager_->getBallCount();
// Notificar a la figura sobre el porcentaje de convergencia
// Esto permite que PNGShape decida cuándo empezar a contar para flips
active_shape_->setConvergence(shape_convergence_);
}
}
// Limitar escala de figura para evitar que se salga de pantalla
void Engine::clampShapeScale() {
// Calcular tamaño máximo permitido según resolución actual
// La figura más grande (esfera/cubo) usa ~33% de altura por defecto
// Permitir hasta que la figura ocupe 90% de la dimensión más pequeña
float max_dimension = std::min(current_screen_width_, current_screen_height_);
float base_size_factor = 0.333f; // ROTOBALL_RADIUS_FACTOR o similar
float max_scale_for_screen = (max_dimension * 0.9f) / (max_dimension * base_size_factor);
// Limitar entre SHAPE_SCALE_MIN y el mínimo de (SHAPE_SCALE_MAX, max_scale_for_screen)
float max_allowed = std::min(SHAPE_SCALE_MAX, max_scale_for_screen);
shape_scale_factor_ = std::max(SHAPE_SCALE_MIN, std::min(max_allowed, shape_scale_factor_));
}