Files
projecte_2026/source/game/ui/console.cpp

497 lines
20 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include "game/ui/console.hpp"
#include <SDL3/SDL.h>
#include <algorithm> // Para ranges::transform
#include <cctype> // Para toupper
#include <sstream> // Para std::istringstream
#include <string> // Para string
#include <vector> // Para vector
#include "core/input/key_config.hpp" // Para KeyConfig
#include "core/rendering/screen.hpp" // Para Screen
#include "core/rendering/sprite/sprite.hpp" // Para Sprite
#include "core/rendering/surface.hpp" // Para Surface
#include "core/rendering/text.hpp" // Para Text
#include "core/resources/resource_cache.hpp" // Para Resource
#include "game/options.hpp" // Para Options
#include "game/ui/notifier.hpp" // Para Notifier
#include "utils/easing_functions.hpp" // Para Easing::cubicInOut
// ── Helpers de texto ──────────────────────────────────────────────────────────
// Convierte la entrada a uppercase y la divide en tokens por espacios
static auto parseTokens(const std::string& input) -> std::vector<std::string> {
std::vector<std::string> tokens;
std::string token;
for (unsigned char c : input) {
if (c == ' ') {
if (!token.empty()) {
tokens.push_back(token);
token.clear();
}
} else {
token += static_cast<char>(std::toupper(c));
}
}
if (!token.empty()) {
tokens.push_back(token);
}
return tokens;
}
// ── Helpers ───────────────────────────────────────────────────────────────────
// Calcula la altura total de la consola para N líneas de mensaje (+ 1 línea de input)
static auto calcTargetHeight(int num_msg_lines) -> float {
constexpr int PADDING_IN_V = Console::TEXT_SIZE / 2;
return static_cast<float>((Console::LINE_HEIGHT * (num_msg_lines + 1)) + (PADDING_IN_V * 2));
}
// Divide text en líneas respetando los \n existentes y haciendo word-wrap por ancho en píxeles
auto Console::wrapText(const std::string& text) const -> std::vector<std::string> {
constexpr int PADDING_IN_H = TEXT_SIZE; // Simétrico a ambos lados
const int MAX_PX = static_cast<int>(Options::game.width) - (2 * PADDING_IN_H);
std::vector<std::string> result;
std::istringstream segment_stream(text);
std::string segment;
while (std::getline(segment_stream, segment)) {
if (segment.empty()) {
result.emplace_back();
continue;
}
std::string current_line;
std::istringstream word_stream(segment);
std::string word;
while (word_stream >> word) {
std::string test;
if (current_line.empty()) {
test = word;
} else {
test = current_line;
test += ' ';
test += word;
}
if (text_->length(test) <= MAX_PX) {
current_line = test;
} else {
if (!current_line.empty()) { result.push_back(current_line); }
current_line = word;
}
}
if (!current_line.empty()) { result.push_back(current_line); }
}
if (result.empty()) { result.emplace_back(); }
return result;
}
// ── Singleton ─────────────────────────────────────────────────────────────────
// [SINGLETON]
Console* Console::console = nullptr;
// [SINGLETON]
void Console::init(const std::string& font_name) {
Console::console = new Console(font_name);
}
// [SINGLETON]
void Console::destroy() {
delete Console::console;
Console::console = nullptr;
}
// [SINGLETON]
auto Console::get() -> Console* {
return Console::console;
}
// Constructor
Console::Console(const std::string& font_name)
: text_(Resource::Cache::get()->getText(font_name)) {
msg_lines_ = {std::string(CONSOLE_NAME) + " " + std::string(CONSOLE_VERSION)};
height_ = calcTargetHeight(static_cast<int>(msg_lines_.size()));
target_height_ = height_;
y_ = -height_;
// Cargar comandos desde YAML
registry_.load("data/console/commands.yaml");
buildSurface();
}
// Crea la Surface con el aspecto visual de la consola
void Console::buildSurface() {
const float WIDTH = Options::game.width;
surface_ = std::make_shared<Surface>(WIDTH, height_);
// Posición inicial (fuera de pantalla por arriba)
SDL_FRect sprite_rect = {.x = 0, .y = y_, .w = WIDTH, .h = height_};
sprite_ = std::make_shared<Sprite>(surface_, sprite_rect);
// Dibujo inicial del texto
redrawText();
}
// Redibuja el texto dinámico sobre la surface (fondo + borde + líneas)
void Console::redrawText() {
const float WIDTH = Options::game.width;
constexpr int PADDING_IN_H = TEXT_SIZE;
constexpr int PADDING_IN_V = TEXT_SIZE / 2;
constexpr Uint8 TRANSPARENT_SENTINEL = 255; // Coincide con Surface::transparent_color_ por defecto
auto previous_renderer = Screen::get()->getRendererSurface();
Screen::get()->setRendererSurface(surface_);
// Colores leídos de Options (modificables en caliente con el comando CONSOLE)
const Uint8 BG_COLOR = Options::console.transparent
? TRANSPARENT_SENTINEL
: static_cast<Uint8>(Options::console.bg_color);
const auto MSG_COLOR = static_cast<Uint8>(Options::console.msg_color);
const auto PROMPT_COLOR = static_cast<Uint8>(Options::console.prompt_color);
const auto COMMAND_COLOR = static_cast<Uint8>(Options::console.command_color);
// Fondo y borde
surface_->clear(BG_COLOR);
SDL_FRect rect = {.x = 0, .y = 0, .w = WIDTH, .h = height_};
surface_->drawRectBorder(&rect, PROMPT_COLOR);
// La surface de la fuente tiene transparent_color_=255 por defecto,
// pero sus píxeles de fondo son color 0. Cambiarlo temporalmente a 0
// para que no pinte el fondo negro de los glyphs sobre la consola.
auto* font_surface = text_->getSprite()->getSurface().get();
const Uint8 PREV_TRANSPARENT = font_surface->getTransparentColor();
font_surface->setTransparentColor(0);
// Líneas de mensaje con efecto typewriter (solo muestra los primeros typewriter_chars_)
int y_pos = PADDING_IN_V;
int remaining = typewriter_chars_;
for (const auto& line : msg_lines_) {
if (remaining <= 0) { break; }
const int VISIBLE = std::min(remaining, static_cast<int>(line.size()));
text_->writeColored(PADDING_IN_H, y_pos, line.substr(0, VISIBLE), MSG_COLOR);
remaining -= VISIBLE;
y_pos += LINE_HEIGHT;
}
// Línea de input (siempre la última): prompt en PROMPT_COLOR, comando + cursor en COMMAND_COLOR
const bool SHOW_CURSOR = cursor_visible_ && (static_cast<int>(input_line_.size()) < MAX_LINE_CHARS);
text_->writeColored(PADDING_IN_H, y_pos, prompt_, PROMPT_COLOR);
const int PROMPT_PX = text_->length(prompt_);
const std::string CMD_STR = input_line_ + (SHOW_CURSOR ? "_" : "");
text_->writeColored(PADDING_IN_H + PROMPT_PX, y_pos, CMD_STR, COMMAND_COLOR);
// Restaurar transparent_color_ original de la fuente
font_surface->setTransparentColor(PREV_TRANSPARENT);
Screen::get()->setRendererSurface(previous_renderer);
}
// Actualiza la animación de la consola
void Console::update(float delta_time) { // NOLINT(readability-function-cognitive-complexity)
if (status_ == Status::HIDDEN) {
return;
}
// Parpadeo del cursor (solo cuando activa)
if (status_ == Status::ACTIVE) {
cursor_timer_ += delta_time;
const float THRESHOLD = cursor_visible_ ? CURSOR_ON_TIME : CURSOR_OFF_TIME;
if (cursor_timer_ >= THRESHOLD) {
cursor_timer_ = 0.0F;
cursor_visible_ = !cursor_visible_;
}
}
// Efecto typewriter: revelar letras una a una (solo cuando ACTIVE)
if (status_ == Status::ACTIVE) {
int total_chars = 0;
for (const auto& line : msg_lines_) { total_chars += static_cast<int>(line.size()); }
if (typewriter_chars_ < total_chars) {
typewriter_timer_ += delta_time;
while (typewriter_timer_ >= TYPEWRITER_CHAR_DELAY && typewriter_chars_ < total_chars) {
typewriter_timer_ -= TYPEWRITER_CHAR_DELAY;
++typewriter_chars_;
}
}
}
// Animación de altura (resize cuando msg_lines_ cambia); solo en ACTIVE
if (status_ == Status::ACTIVE && height_ != target_height_) {
if (anim_progress_ == 0.0F) {
// Iniciar animación de resize
anim_start_ = height_;
anim_end_ = target_height_;
}
anim_progress_ = std::min(anim_progress_ + (delta_time / ANIM_DURATION), 1.0F);
const float PREV_HEIGHT = height_;
height_ = anim_start_ + ((anim_end_ - anim_start_) * Easing::cubicInOut(anim_progress_));
if (anim_progress_ >= 1.0F) {
height_ = target_height_;
anim_progress_ = 0.0F;
}
// Actualizar el Notifier incrementalmente con el delta de altura
if (Notifier::get() != nullptr) {
const int DELTA_PX = static_cast<int>(height_) - static_cast<int>(PREV_HEIGHT);
if (DELTA_PX > 0) {
Notifier::get()->addYOffset(DELTA_PX);
notifier_offset_applied_ += DELTA_PX;
} else if (DELTA_PX < 0) {
Notifier::get()->removeYOffset(-DELTA_PX);
notifier_offset_applied_ += DELTA_PX;
}
}
// Reconstruir la Surface al nuevo tamaño (pequeña: 256×~18-72px)
const float WIDTH = Options::game.width;
surface_ = std::make_shared<Surface>(WIDTH, height_);
sprite_->setSurface(surface_);
}
// Redibujar texto cada frame
redrawText();
// Animación de apertura/cierre (basada en tiempo)
if (status_ == Status::RISING || status_ == Status::VANISHING) {
anim_progress_ = std::min(anim_progress_ + (delta_time / ANIM_DURATION), 1.0F);
y_ = anim_start_ + ((anim_end_ - anim_start_) * Easing::cubicInOut(anim_progress_));
if (anim_progress_ >= 1.0F) {
y_ = anim_end_;
anim_progress_ = 0.0F;
if (status_ == Status::RISING) {
status_ = Status::ACTIVE;
} else {
status_ = Status::HIDDEN;
msg_lines_ = {std::string(CONSOLE_NAME) + " " + std::string(CONSOLE_VERSION)};
target_height_ = calcTargetHeight(static_cast<int>(msg_lines_.size()));
}
}
}
SDL_FRect rect = {.x = 0, .y = y_, .w = Options::game.width, .h = height_};
sprite_->setPosition(rect);
sprite_->setClip({.x = 0.0F, .y = 0.0F, .w = Options::game.width, .h = height_});
}
// Renderiza la consola
void Console::render() {
if (status_ == Status::HIDDEN) {
return;
}
sprite_->render();
}
// Activa o desactiva la consola
void Console::toggle() {
switch (status_) {
case Status::HIDDEN:
// Al abrir: la consola siempre empieza con 1 línea de mensaje (altura base)
target_height_ = calcTargetHeight(static_cast<int>(msg_lines_.size()));
height_ = target_height_;
y_ = -height_;
anim_start_ = y_;
anim_end_ = 0.0F;
anim_progress_ = 0.0F;
status_ = Status::RISING;
input_line_.clear();
cursor_timer_ = 0.0F;
cursor_visible_ = true;
// El mensaje inicial (nombre de la consola) aparece completo, sin typewriter
typewriter_chars_ = static_cast<int>(msg_lines_[0].size());
typewriter_timer_ = 0.0F;
SDL_StartTextInput(SDL_GetKeyboardFocus());
if (Notifier::get() != nullptr) {
const int OFFSET = static_cast<int>(height_);
Notifier::get()->addYOffset(OFFSET);
notifier_offset_applied_ = OFFSET;
}
if (on_toggle) { on_toggle(true); }
break;
case Status::ACTIVE:
// Al cerrar: mantener el texto visible hasta que esté completamente oculta
anim_start_ = y_;
anim_end_ = -height_;
anim_progress_ = 0.0F;
status_ = Status::VANISHING;
target_height_ = height_; // No animar altura durante VANISHING
history_index_ = -1;
saved_input_.clear();
SDL_StopTextInput(SDL_GetKeyboardFocus());
if (Notifier::get() != nullptr) {
Notifier::get()->removeYOffset(notifier_offset_applied_);
notifier_offset_applied_ = 0;
}
if (on_toggle) { on_toggle(false); }
break;
default:
// Durante RISING o VANISHING no se hace nada
break;
}
}
// Procesa el evento SDL: entrada de texto, Backspace, Enter
void Console::handleEvent(const SDL_Event& event) { // NOLINT(readability-function-cognitive-complexity)
if (status_ != Status::ACTIVE) { return; }
if (event.type == SDL_EVENT_TEXT_INPUT) {
// Filtrar caracteres de control (tab, newline, etc.)
if (static_cast<unsigned char>(event.text.text[0]) < 32) { return; }
// Ignorar texto si la tecla toggle está pulsada (evita escribir su carácter)
if (KeyConfig::get() != nullptr) {
SDL_Keycode toggle_key = KeyConfig::get()->key("GLOBAL", "console");
SDL_Scancode toggle_sc = SDL_GetScancodeFromKey(toggle_key, nullptr);
if (toggle_sc != SDL_SCANCODE_UNKNOWN) {
const bool* ks = SDL_GetKeyboardState(nullptr);
if (ks[toggle_sc]) { return; }
}
}
if (static_cast<int>(input_line_.size()) < MAX_LINE_CHARS) {
input_line_ += event.text.text;
}
tab_matches_.clear();
return;
}
if (event.type == SDL_EVENT_KEY_DOWN) {
switch (event.key.scancode) {
case SDL_SCANCODE_BACKSPACE:
tab_matches_.clear();
if (!input_line_.empty()) { input_line_.pop_back(); }
break;
case SDL_SCANCODE_RETURN:
case SDL_SCANCODE_KP_ENTER:
processCommand();
break;
case SDL_SCANCODE_UP:
// Navegar hacia atrás en el historial
tab_matches_.clear();
if (history_index_ < static_cast<int>(history_.size()) - 1) {
if (history_index_ == -1) { saved_input_ = input_line_; }
++history_index_;
input_line_ = history_[static_cast<size_t>(history_index_)];
}
break;
case SDL_SCANCODE_DOWN:
// Navegar hacia el presente en el historial
tab_matches_.clear();
if (history_index_ >= 0) {
--history_index_;
input_line_ = (history_index_ == -1)
? saved_input_
: history_[static_cast<size_t>(history_index_)];
}
break;
case SDL_SCANCODE_TAB: {
if (tab_matches_.empty()) {
// Calcular el input actual en mayúsculas
std::string upper;
for (unsigned char c : input_line_) { upper += static_cast<char>(std::toupper(c)); }
const size_t SPACE_POS = upper.rfind(' ');
if (SPACE_POS == std::string::npos) {
// Modo comando: ciclar keywords visibles que empiecen por el prefijo
for (const auto& kw : registry_.getVisibleKeywords()) {
if (upper.empty() || kw.starts_with(upper)) {
tab_matches_.emplace_back(kw);
}
}
} else {
const std::string BASE_CMD = upper.substr(0, SPACE_POS);
const std::string SUB_PREFIX = upper.substr(SPACE_POS + 1);
const auto OPTS = registry_.getCompletions(BASE_CMD);
for (const auto& arg : OPTS) {
if (SUB_PREFIX.empty() || std::string_view{arg}.starts_with(SUB_PREFIX)) {
std::string match = BASE_CMD;
match += ' ';
match += arg;
tab_matches_.emplace_back(std::move(match));
}
}
}
tab_index_ = -1;
}
if (tab_matches_.empty()) { break; }
tab_index_ = (tab_index_ + 1) % static_cast<int>(tab_matches_.size());
std::string result = tab_matches_[static_cast<size_t>(tab_index_)];
for (char& c : result) { c = static_cast<char>(std::tolower(static_cast<unsigned char>(c))); }
input_line_ = result;
break;
}
default:
break;
}
}
}
// Ejecuta el comando introducido y reinicia la línea de input
void Console::processCommand() {
if (!input_line_.empty()) {
// Añadir al historial (sin duplicados consecutivos)
if (history_.empty() || history_.front() != input_line_) {
history_.push_front(input_line_);
if (static_cast<int>(history_.size()) > MAX_HISTORY_SIZE) {
history_.pop_back();
}
}
const auto TOKENS = parseTokens(input_line_);
if (!TOKENS.empty()) {
const std::string& cmd = TOKENS[0];
const std::vector<std::string> ARGS(TOKENS.begin() + 1, TOKENS.end());
std::string result;
bool instant = false;
const auto* def = registry_.findCommand(cmd);
if (def != nullptr) {
result = registry_.execute(cmd, ARGS);
instant = def->instant;
} else {
std::string cmd_lower = cmd;
std::ranges::transform(cmd_lower, cmd_lower.begin(), ::tolower);
result = "Unknown: " + cmd_lower;
}
// Word-wrap automático según el ancho disponible en píxeles
msg_lines_ = wrapText(result);
// Actualizar la altura objetivo para animar el resize
target_height_ = calcTargetHeight(static_cast<int>(msg_lines_.size()));
// Typewriter: instantáneo si el comando lo requiere, letra a letra si no
if (instant) {
int total = 0;
for (const auto& l : msg_lines_) { total += static_cast<int>(l.size()); }
typewriter_chars_ = total;
} else {
typewriter_chars_ = 0;
}
typewriter_timer_ = 0.0F;
}
}
input_line_.clear();
history_index_ = -1;
saved_input_.clear();
tab_matches_.clear();
cursor_timer_ = 0.0F;
cursor_visible_ = true;
}
// Indica si la consola está activa (visible o en animación)
auto Console::isActive() -> bool {
return status_ != Status::HIDDEN;
}
// Devuelve los píxeles visibles de la consola (sincronizado con la animación)
auto Console::getVisibleHeight() -> int {
if (status_ == Status::HIDDEN) { return 0; }
return static_cast<int>(y_ + height_);
}
// Scope de comandos
void Console::setScope(const std::string& scope) { registry_.setScope(scope); }
auto Console::getScope() const -> std::string { return registry_.getScope(); }