447 lines
17 KiB
C++
447 lines
17 KiB
C++
#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/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
|
||
|
||
// ── 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 TEXT_SIZE = 6;
|
||
constexpr int PADDING_IN_V = TEXT_SIZE / 2;
|
||
return static_cast<float>((TEXT_SIZE * (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 = 6; // 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) {
|
||
const std::string TEST = current_line.empty() ? word : (current_line + ' ' + 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 TEXT_SIZE = 6;
|
||
constexpr int PADDING_IN_H = TEXT_SIZE;
|
||
constexpr int PADDING_IN_V = TEXT_SIZE / 2;
|
||
|
||
auto previous_renderer = Screen::get()->getRendererSurface();
|
||
Screen::get()->setRendererSurface(surface_);
|
||
|
||
// Fondo y borde
|
||
surface_->clear(BG_COLOR);
|
||
SDL_FRect rect = {.x = 0, .y = 0, .w = WIDTH, .h = height_};
|
||
surface_->drawRectBorder(&rect, BORDER_COLOR);
|
||
|
||
// 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 += TEXT_SIZE;
|
||
}
|
||
|
||
// Línea de input (siempre la última)
|
||
const bool SHOW_CURSOR = cursor_visible_ && (static_cast<int>(input_line_.size()) < MAX_LINE_CHARS);
|
||
const std::string INPUT_STR = "> " + input_line_ + (SHOW_CURSOR ? "_" : "");
|
||
text_->writeColored(PADDING_IN_H, y_pos, INPUT_STR, BORDER_COLOR);
|
||
|
||
Screen::get()->setRendererSurface(previous_renderer);
|
||
}
|
||
|
||
// Actualiza la animación de la consola
|
||
void Console::update(float delta_time) {
|
||
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_) {
|
||
const float PREV_HEIGHT = height_;
|
||
if (height_ < target_height_) {
|
||
height_ = std::min(height_ + SLIDE_SPEED * delta_time, target_height_);
|
||
} else {
|
||
height_ = std::max(height_ - SLIDE_SPEED * delta_time, target_height_);
|
||
}
|
||
// 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();
|
||
|
||
switch (status_) {
|
||
case Status::RISING: {
|
||
y_ += SLIDE_SPEED * delta_time;
|
||
if (y_ >= 0.0F) {
|
||
y_ = 0.0F;
|
||
status_ = Status::ACTIVE;
|
||
}
|
||
break;
|
||
}
|
||
case Status::VANISHING: {
|
||
y_ -= SLIDE_SPEED * delta_time;
|
||
if (y_ <= -height_) {
|
||
y_ = -height_;
|
||
status_ = Status::HIDDEN;
|
||
// Resetear el mensaje una vez completamente oculta
|
||
msg_lines_ = {std::string(CONSOLE_NAME) + " " + std::string(CONSOLE_VERSION)};
|
||
target_height_ = calcTargetHeight(static_cast<int>(msg_lines_.size()));
|
||
}
|
||
break;
|
||
}
|
||
default:
|
||
break;
|
||
}
|
||
|
||
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_;
|
||
status_ = Status::RISING;
|
||
input_line_.clear();
|
||
cursor_timer_ = 0.0F;
|
||
cursor_visible_ = true;
|
||
// El mensaje inicial ("JDD Console v1.0") 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
|
||
status_ = Status::VANISHING;
|
||
target_height_ = height_; // No animar 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) {
|
||
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; }
|
||
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)) {
|
||
tab_matches_.emplace_back(base_cmd + " " + arg);
|
||
}
|
||
}
|
||
}
|
||
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_);
|
||
}
|