Files
shadow-over-bishkek001/src/UiManager.cpp
T

2326 lines
73 KiB
C++

#include "UiManager.h"
#include "utils/Utils.h"
#include "render/TextRenderer.h"
#include <fstream>
#include <iostream>
#include <algorithm>
#include <sstream>
#include <unordered_set>
#include "GameConstants.h"
namespace FRG {
using json = nlohmann::json;
extern float x;
static int countWrappedLines(const std::string& text) {
if (text.empty()) return 0;
int lines = 1;
for (char c : text) {
if (c == '\n') ++lines;
}
return lines;
}
static std::string limitLines(const std::string& text, int maxLines) {
if (maxLines <= 0) return text;
std::string out;
int lines = 1;
for (char c : text) {
if (c == '\n') {
if (lines >= maxLines) {
out += "...";
return out;
}
++lines;
}
out.push_back(c);
}
return out;
}
static std::string wrapTextByPixels(const std::string& input, const TextRenderer& textRenderer, float maxWidthPx, float scale, int maxLines = 0) {
if (input.empty() || maxWidthPx <= 1.0f) return input;
std::string output;
std::string currentLine;
std::string currentWord;
auto flushLine = [&]() {
if (!currentLine.empty()) {
if (!output.empty()) output.push_back('\n');
output += currentLine;
currentLine.clear();
}
};
auto pushWord = [&](const std::string& word) {
if (word.empty()) return;
if (currentLine.empty()) {
currentLine = word;
return;
}
const std::string candidate = currentLine + " " + word;
if (textRenderer.measureTextWidth(candidate, scale) <= maxWidthPx) {
currentLine = candidate;
} else {
flushLine();
currentLine = word;
}
};
for (size_t i = 0; i < input.size(); ++i) {
const char ch = input[i];
if (ch == '\n') {
pushWord(currentWord);
currentWord.clear();
flushLine();
continue;
}
if (ch == ' ' || ch == '\t' || ch == '\r') {
pushWord(currentWord);
currentWord.clear();
continue;
}
currentWord.push_back(ch);
}
pushWord(currentWord);
flushLine();
return limitLines(output, maxLines);
}
static float applyEasing(const std::string& easing, float t) {
if (easing == "easein") {
return t * t;
}
else if (easing == "easeout") {
float inv = 1.0f - t;
return 1.0f - inv * inv;
}
return t;
}
void UiButton::buildMesh() {
mesh.data.PositionData.clear();
mesh.data.TexCoordData.clear();
float x0 = rect.x;
float y0 = rect.y;
float x1 = rect.x + rect.w;
float y1 = rect.y + rect.h;
mesh.data.PositionData.push_back({ x0, y0, 0 });
mesh.data.TexCoordData.push_back({ 0, 0 });
mesh.data.PositionData.push_back({ x0, y1, 0 });
mesh.data.TexCoordData.push_back({ 0, 1 });
mesh.data.PositionData.push_back({ x1, y1, 0 });
mesh.data.TexCoordData.push_back({ 1, 1 });
mesh.data.PositionData.push_back({ x0, y0, 0 });
mesh.data.TexCoordData.push_back({ 0, 0 });
mesh.data.PositionData.push_back({ x1, y1, 0 });
mesh.data.TexCoordData.push_back({ 1, 1 });
mesh.data.PositionData.push_back({ x1, y0, 0 });
mesh.data.TexCoordData.push_back({ 1, 0 });
mesh.RefreshVBO();
}
void UiButton::draw(Renderer& renderer) const {
if (!texNormal) return;
const std::shared_ptr<Texture>* tex = &texNormal;
switch (state) {
case ButtonState::Normal: tex = &texNormal; break;
case ButtonState::Hover: tex = &texHover; break;
case ButtonState::Pressed: tex = &texPressed; break;
case ButtonState::Disabled: tex = &texDisabled; break;
}
if (!(*tex)) return;
renderer.PushMatrix();
renderer.TranslateMatrix({ animOffsetX, animOffsetY, 0.0f });
renderer.ScaleMatrix({ animScaleX, animScaleY, 1.0f });
renderer.RenderUniform1i(textureUniformName, 0);
glBindTexture(GL_TEXTURE_2D, (*tex)->getTexID());
renderer.DrawVertexRenderStruct(mesh);
renderer.PopMatrix();
}
void UiTextButton::buildMesh() {
mesh.data.PositionData.clear();
mesh.data.TexCoordData.clear();
float x0 = rect.x;
float y0 = rect.y;
float x1 = rect.x + rect.w;
float y1 = rect.y + rect.h;
mesh.data.PositionData.push_back({ x0, y0, 0 });
mesh.data.TexCoordData.push_back({ 0, 0 });
mesh.data.PositionData.push_back({ x0, y1, 0 });
mesh.data.TexCoordData.push_back({ 0, 1 });
mesh.data.PositionData.push_back({ x1, y1, 0 });
mesh.data.TexCoordData.push_back({ 1, 1 });
mesh.data.PositionData.push_back({ x0, y0, 0 });
mesh.data.TexCoordData.push_back({ 0, 0 });
mesh.data.PositionData.push_back({ x1, y1, 0 });
mesh.data.TexCoordData.push_back({ 1, 1 });
mesh.data.PositionData.push_back({ x1, y0, 0 });
mesh.data.TexCoordData.push_back({ 1, 0 });
mesh.RefreshVBO();
}
void UiTextButton::draw(Renderer& renderer, float accumX, float accumY) const {
// Draw background texture (optional)
const std::shared_ptr<Texture>* tex = nullptr;
switch (state) {
case ButtonState::Normal: if (texNormal) tex = &texNormal; break;
case ButtonState::Hover: tex = texHover ? &texHover : (texNormal ? &texNormal : nullptr); break;
case ButtonState::Pressed: tex = texPressed ? &texPressed : (texNormal ? &texNormal : nullptr); break;
case ButtonState::Disabled: tex = texDisabled ? &texDisabled : (texNormal ? &texNormal : nullptr); break;
}
glDisable(GL_DEPTH_TEST);
if (tex && *tex) {
renderer.shaderManager.PushShader(defaultShaderName);
glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
renderer.PushMatrix();
renderer.TranslateMatrix({ animOffsetX, animOffsetY, 0.0f });
renderer.ScaleMatrix({ animScaleX, animScaleY, 1.0f });
renderer.RenderUniform1i(textureUniformName, 0);
glBindTexture(GL_TEXTURE_2D, (*tex)->getTexID());
renderer.DrawVertexRenderStruct(mesh);
renderer.PopMatrix();
renderer.shaderManager.PopShader();
glDisable(GL_BLEND);
}
// Draw text on top (uses absolute coords, add anim offset manually)
// use left padding, which is required for inventory/quest lists.
if (textRenderer && !text.empty()) {
const float scale = 1.0f;
const std::string displayText = wrap
? wrapTextByPixels(text, *textRenderer, rect.w - textPaddingX * 2.0f, scale)
: text;
// Прибавляем accumX и accumY
float tx = rect.x + rect.w / 2.0f + animOffsetX + accumX;
if (!textCentered) {
tx = rect.x + textPaddingX + animOffsetX + accumX;
}
float ty;
if (topAligned) {
ty = rect.y + rect.h - textPaddingY - static_cast<float>(fontSize) + animOffsetY + accumY;
}
else {
ty = rect.y + rect.h * 0.5f + textPaddingY + animOffsetY + accumY;
}
textRenderer->drawText(displayText, tx, ty, scale, textCentered, color);
}
glEnable(GL_DEPTH_TEST);
}
void UiTextView::draw(Renderer& renderer, float accumX, float accumY) const {
(void)renderer;
if (!textRenderer || text.empty()) {
return;
}
const float scale = 1.0f;
const bool usesModernRectText = wrap || topAligned || paddingX != 0.0f || paddingY != 0.0f || maxLines > 0;
if (!usesModernRectText) {
textRenderer->drawText(
text,
rect.x + rect.w * 0.5f + accumX,
rect.y + rect.h * 0.5f + accumY,
scale,
textCentered,
color
);
return;
}
const float availableWidth = max(1.0f, rect.w - paddingX * 2.0f);
const std::string finalText = wrap
? wrapTextByPixels(text, *textRenderer, availableWidth, scale, maxLines)
: limitLines(text, maxLines);
float tx = textCentered ? rect.x + rect.w * 0.5f : rect.x + paddingX;
float ty = rect.y + rect.h * 0.5f;
if (topAligned) {
ty = rect.y + rect.h - paddingY - static_cast<float>(fontSize);
}
textRenderer->drawText(finalText, tx + accumX, ty + accumY, scale, textCentered, color);
}
void UiSlider::buildTrackMesh() {
trackMesh.data.PositionData.clear();
trackMesh.data.TexCoordData.clear();
float x0 = rect.x;
float y0 = rect.y;
float y1 = rect.y + rect.h;
float x1, u1;
if (fillMode) {
const float clamped = std::clamp(value, 0.0f, 1.0f);
if (clamped <= 0.0f) {
trackMesh.RefreshVBO();
return;
}
x1 = rect.x + rect.w * clamped;
u1 = clamped;
} else {
x1 = rect.x + rect.w;
u1 = 1.0f;
}
trackMesh.data.PositionData.push_back({ x0, y0, 0 });
trackMesh.data.TexCoordData.push_back({ 0, 0 });
trackMesh.data.PositionData.push_back({ x0, y1, 0 });
trackMesh.data.TexCoordData.push_back({ 0, 1 });
trackMesh.data.PositionData.push_back({ x1, y1, 0 });
trackMesh.data.TexCoordData.push_back({ u1, 1 });
trackMesh.data.PositionData.push_back({ x0, y0, 0 });
trackMesh.data.TexCoordData.push_back({ 0, 0 });
trackMesh.data.PositionData.push_back({ x1, y1, 0 });
trackMesh.data.TexCoordData.push_back({ u1, 1 });
trackMesh.data.PositionData.push_back({ x1, y0, 0 });
trackMesh.data.TexCoordData.push_back({ u1, 0 });
trackMesh.RefreshVBO();
}
void UiSlider::buildKnobMesh() {
knobMesh.data.PositionData.clear();
knobMesh.data.TexCoordData.clear();
// Knob is built centered at the middle of the track rect.
// draw() applies a per-frame translation so the knob tracks the current value
// without needing to rebuild the VBO on every value change.
const float kSize = vertical ? rect.w * 2.0f : rect.h;
const float cx = rect.x + rect.w * 0.5f;
const float cy = rect.y + rect.h * 0.5f;
const float x0 = cx - kSize * 0.5f;
const float y0 = cy - kSize * 0.5f;
const float x1 = cx + kSize * 0.5f;
const float y1 = cy + kSize * 0.5f;
knobMesh.data.PositionData.push_back({ x0, y0, 0 });
knobMesh.data.TexCoordData.push_back({ 0, 0 });
knobMesh.data.PositionData.push_back({ x0, y1, 0 });
knobMesh.data.TexCoordData.push_back({ 0, 1 });
knobMesh.data.PositionData.push_back({ x1, y1, 0 });
knobMesh.data.TexCoordData.push_back({ 1, 1 });
knobMesh.data.PositionData.push_back({ x0, y0, 0 });
knobMesh.data.TexCoordData.push_back({ 0, 0 });
knobMesh.data.PositionData.push_back({ x1, y1, 0 });
knobMesh.data.TexCoordData.push_back({ 1, 1 });
knobMesh.data.PositionData.push_back({ x1, y0, 0 });
knobMesh.data.TexCoordData.push_back({ 1, 0 });
knobMesh.RefreshVBO();
}
void UiSlider::draw(Renderer& renderer) const {
renderer.RenderUniform1i(textureUniformName, 0);
if (texTrack) {
glBindTexture(GL_TEXTURE_2D, texTrack->getTexID());
renderer.DrawVertexRenderStruct(trackMesh);
}
if (texKnob) {
// The knob mesh is built centered at the middle of the track rect.
// Translate it so its center lands on the value position along the track.
const float offsetX = vertical ? 0.0f : (value - 0.5f) * rect.w;
const float offsetY = vertical ? (value - 0.5f) * rect.h : 0.0f;
renderer.PushMatrix();
renderer.TranslateMatrix({ offsetX, offsetY, 0.0f });
glBindTexture(GL_TEXTURE_2D, texKnob->getTexID());
renderer.DrawVertexRenderStruct(knobMesh);
renderer.PopMatrix();
}
}
void UiStaticImage::buildMesh() {
mesh.data.PositionData.clear();
mesh.data.TexCoordData.clear();
float x0 = rect.x;
float y0 = rect.y;
float x1 = rect.x + rect.w;
float y1 = rect.y + rect.h;
mesh.data.PositionData.push_back({ x0, y0, 0 });
mesh.data.TexCoordData.push_back({ 0, 0 });
mesh.data.PositionData.push_back({ x0, y1, 0 });
mesh.data.TexCoordData.push_back({ 0, 1 });
mesh.data.PositionData.push_back({ x1, y1, 0 });
mesh.data.TexCoordData.push_back({ 1, 1 });
mesh.data.PositionData.push_back({ x0, y0, 0 });
mesh.data.TexCoordData.push_back({ 0, 0 });
mesh.data.PositionData.push_back({ x1, y1, 0 });
mesh.data.TexCoordData.push_back({ 1, 1 });
mesh.data.PositionData.push_back({ x1, y0, 0 });
mesh.data.TexCoordData.push_back({ 1, 0 });
mesh.RefreshVBO();
}
void UiStaticImage::draw(Renderer& renderer) const {
if (!texture) return;
const float fadeAlpha = (fadeInEnabled && fadeInDurationMs > 0.0f)
? std::clamp(fadeInElapsedMs / fadeInDurationMs, 0.0f, 1.0f)
: 1.0f;
const float finalAlpha = alpha * fadeAlpha;
renderer.RenderUniform1i(textureUniformName, 0);
renderer.RenderUniform1f("uAlpha", finalAlpha);
glBindTexture(GL_TEXTURE_2D, texture->getTexID());
renderer.DrawVertexRenderStruct(mesh);
if (finalAlpha < 1.0f) {
renderer.RenderUniform1f("uAlpha", 1.0f); // restore for subsequent draws
}
}
void UiTextField::draw(Renderer& renderer) const {
if (textRenderer) {
float textX = rect.x + 10.0f;
float textY = rect.y + rect.h / 2.0f;
if (text.empty()) {
textRenderer->drawText(placeholder, textX, textY, 1.0f, false, placeholderColor);
}
else {
textRenderer->drawText(text, textX, textY, 1.0f, false, color);
}
}
}
std::shared_ptr<UiNode> parseNode(const json& j, Renderer& renderer, const std::string& zipFile) {
auto node = std::make_shared<UiNode>();
// 1. Определяем тип контейнера и ориентацию
std::string typeStr = j.value("type", "FrameLayout"); // По умолчанию FrameLayout
if (typeStr == "LinearLayout") {
node->layoutType = LayoutType::Linear;
}
else {
node->layoutType = LayoutType::Frame;
}
if (j.contains("name")) node->name = j["name"].get<std::string>();
if (j.contains("visible")) node->visible = j["visible"].get<bool>();
// 2. Читаем размеры во временные "локальные" поля
// Это критически важно: мы не пишем сразу в screenRect,
// так как LinearLayout их пересчитает.
node->localX = j.value("x", 0.0f);
node->localY = j.value("y", 0.0f);
if (j.contains("width")) {
if (j["width"].is_string() && j["width"] == "match_parent") {
node->width = -1.0f; // Наш маркер для match_parent
}
else {
node->width = j["width"].get<float>();
}
}
else
{
node->width = 0.0f;
}
if (j.contains("height")) {
if (j["height"].is_string() && j["height"] == "match_parent") {
node->height = -1.0f; // Наш маркер для match_parent
}
else {
node->height = j["height"].get<float>();
}
}
else
{
node->height = 0.0f;
}
// 3. Параметры компоновки
if (j.contains("orientation")) {
std::string orient = j["orientation"].get<std::string>();
node->orientation = (orient == "horizontal") ? Orientation::Horizontal : Orientation::Vertical;
}
node->spacing = j.value("spacing", 0.0f);
if (j.contains("horizontal_align")) {
std::string halign = j["horizontal_align"];
if (halign == "center") node->layoutSettings.hAlign = HorizontalAlign::Center;
else if (halign == "right") node->layoutSettings.hAlign = HorizontalAlign::Right;
}
if (j.contains("vertical_align")) {
std::string valign = j["vertical_align"];
if (valign == "center") node->layoutSettings.vAlign = VerticalAlign::Center;
else if (valign == "bottom") node->layoutSettings.vAlign = VerticalAlign::Bottom;
}
if (j.contains("horizontal_gravity")) {
std::string hg = j["horizontal_gravity"].get<std::string>();
if (hg == "right") node->layoutSettings.hGravity = HorizontalGravity::Right;
else if (hg == "center") node->layoutSettings.hGravity = HorizontalGravity::Center;
else node->layoutSettings.hGravity = HorizontalGravity::Left;
}
// Читаем Vertical Gravity
if (j.contains("vertical_gravity")) {
std::string vg = j["vertical_gravity"].get<std::string>();
if (vg == "bottom") node->layoutSettings.vGravity = VerticalGravity::Bottom;
else if (vg == "center") node->layoutSettings.vGravity = VerticalGravity::Center;
else node->layoutSettings.vGravity = VerticalGravity::Top;
}
// Подготавливаем базовый rect для компонентов (кнопок и т.д.)
// На этапе парсинга мы даем им "желаемый" размер
UiRect initialRect = { node->localX, node->localY, node->width, node->height };
if (typeStr == "Button") {
auto btn = std::make_shared<UiButton>();
btn->name = node->name;
btn->rect = initialRect;
if (!j.contains("textures") || !j["textures"].is_object()) {
std::cerr << "UiManager: Button '" << btn->name << "' missing textures" << std::endl;
throw std::runtime_error("UI button textures missing");
}
auto t = j["textures"];
auto loadTex = [&](const std::string& key)->std::shared_ptr<Texture> {
if (!t.contains(key) || !t[key].is_string()) return nullptr;
std::string path = t[key].get<std::string>();
try {
logger() << "UiManager: loading texture for button '" << btn->name << "' : " << path << " Zip file: " << zipFile << std::endl;
return renderer.textureManager.LoadFromPng(path, zipFile, true);
}
catch (const std::exception& e) {
std::cerr << "UiManager: failed load texture " << path << " : " << e.what() << std::endl;
throw std::runtime_error("UI texture load failed: " + path);
}
};
btn->texNormal = loadTex("normal");
btn->texHover = loadTex("hover");
btn->texPressed = loadTex("pressed");
btn->texDisabled = loadTex("disabled");
btn->border = j.value("border", 0.0f);
if (j.contains("clickZoneWidth")) btn->clickZoneWidth = j["clickZoneWidth"].get<float>();
if (j.contains("clickZoneHeight")) btn->clickZoneHeight = j["clickZoneHeight"].get<float>();
node->button = btn;
}
else if (typeStr == "Slider") {
auto s = std::make_shared<UiSlider>();
s->name = node->name;
s->rect = initialRect;
if (!j.contains("textures") || !j["textures"].is_object()) {
std::cerr << "UiManager: Slider '" << s->name << "' missing textures" << std::endl;
throw std::runtime_error("UI slider textures missing");
}
auto t = j["textures"];
auto loadTex = [&](const std::string& key)->std::shared_ptr<Texture> {
if (!t.contains(key) || !t[key].is_string()) return nullptr;
std::string path = t[key].get<std::string>();
try {
logger() << "UiManager: --loading texture for slider '" << s->name << "' : " << path << " Zip file: " << zipFile << std::endl;
return renderer.textureManager.LoadFromPng(path, zipFile, true);
}
catch (const std::exception& e) {
std::cerr << "UiManager: failed load texture " << path << " : " << e.what() << std::endl;
throw std::runtime_error("UI texture load failed: " + path);
}
};
s->texTrack = loadTex("track");
s->texKnob = loadTex("knob");
if (j.contains("value")) s->value = j["value"].get<float>();
if (j.contains("orientation")) {
std::string orient = j["orientation"].get<std::string>();
std::transform(orient.begin(), orient.end(), orient.begin(), ::tolower);
s->vertical = (orient != "horizontal");
}
if (j.contains("fillMode")) s->fillMode = j["fillMode"].get<bool>();
if (j.contains("interactive")) s->interactive = j["interactive"].get<bool>();
node->slider = s;
}
else if (typeStr == "TextField") {
auto tf = std::make_shared<UiTextField>();
tf->name = node->name;
tf->rect = initialRect;
if (j.contains("placeholder")) tf->placeholder = j["placeholder"].get<std::string>();
if (j.contains("fontPath")) tf->fontPath = j["fontPath"].get<std::string>();
if (j.contains("fontSize")) tf->fontSize = j["fontSize"].get<int>();
if (j.contains("maxLength")) tf->maxLength = j["maxLength"].get<int>();
if (j.contains("color") && j["color"].is_array() && j["color"].size() == 4) {
for (int i = 0; i < 4; ++i) {
tf->color[i] = j["color"][i].get<float>();
}
}
if (j.contains("placeholderColor") && j["placeholderColor"].is_array() && j["placeholderColor"].size() == 4) {
for (int i = 0; i < 4; ++i) {
tf->placeholderColor[i] = j["placeholderColor"][i].get<float>();
}
}
if (j.contains("backgroundColor") && j["backgroundColor"].is_array() && j["backgroundColor"].size() == 4) {
for (int i = 0; i < 4; ++i) {
tf->backgroundColor[i] = j["backgroundColor"][i].get<float>();
}
}
if (j.contains("borderColor") && j["borderColor"].is_array() && j["borderColor"].size() == 4) {
for (int i = 0; i < 4; ++i) {
tf->borderColor[i] = j["borderColor"][i].get<float>();
}
}
tf->textRenderer = std::make_unique<TextRenderer>();
if (!tf->textRenderer->init(renderer, tf->fontPath, tf->fontSize, zipFile)) {
std::cerr << "Failed to init TextRenderer for TextField: " << tf->name << std::endl;
}
node->textField = tf;
}
if (typeStr == "TextButton") {
auto tb = std::make_shared<UiTextButton>();
tb->name = node->name;
tb->rect = initialRect;
tb->border = j.value("border", 0.0f);
// Textures are optional
if (j.contains("textures") && j["textures"].is_object()) {
auto t = j["textures"];
auto loadTex = [&](const std::string& key) -> std::shared_ptr<Texture> {
if (!t.contains(key) || !t[key].is_string()) return nullptr;
std::string path = t[key].get<std::string>();
try {
return renderer.textureManager.LoadFromPng(path, zipFile, true);
}
catch (const std::exception& e) {
std::cerr << "UiManager: TextButton '" << tb->name << "' failed to load texture " << path << ": " << e.what() << std::endl;
return nullptr;
}
};
tb->texNormal = loadTex("normal");
tb->texHover = loadTex("hover");
tb->texPressed = loadTex("pressed");
tb->texDisabled = loadTex("disabled");
}
if (j.contains("text")) tb->text = j["text"].get<std::string>();
if (j.contains("fontPath")) tb->fontPath = j["fontPath"].get<std::string>();
if (j.contains("fontSize")) tb->fontSize = j["fontSize"].get<int>();
if (j.contains("textCentered")) tb->textCentered = j["textCentered"].get<bool>();
if (j.contains("textPaddingX")) tb->textPaddingX = j["textPaddingX"].get<float>();
if (j.contains("textPaddingY")) tb->textPaddingY = j["textPaddingY"].get<float>();
if (j.contains("wrap")) tb->wrap = j["wrap"].get<bool>();
if (j.contains("topAligned")) tb->topAligned = j["topAligned"].get<bool>();
if (j.contains("color") && j["color"].is_array() && j["color"].size() == 4) {
for (int i = 0; i < 4; ++i) tb->color[i] = j["color"][i].get<float>();
}
if (j.contains("clickZoneWidth")) tb->clickZoneWidth = j["clickZoneWidth"].get<float>();
if (j.contains("clickZoneHeight")) tb->clickZoneHeight = j["clickZoneHeight"].get<float>();
tb->textRenderer = std::make_unique<TextRenderer>();
if (!tb->textRenderer->init(renderer, tb->fontPath, tb->fontSize, zipFile)) {
std::cerr << "UiManager: Failed to init TextRenderer for TextButton: " << tb->name << std::endl;
}
node->textButton = tb;
}
if (j.contains("animations") && j["animations"].is_object()) {
for (auto it = j["animations"].begin(); it != j["animations"].end(); ++it) {
std::string animName = it.key();
const auto& animDef = it.value();
UiNode::AnimSequence seq;
if (animDef.contains("repeat") && animDef["repeat"].is_boolean()) seq.repeat = animDef["repeat"].get<bool>();
if (animDef.contains("steps") && animDef["steps"].is_array()) {
for (const auto& step : animDef["steps"]) {
UiNode::AnimStep s;
if (step.contains("type") && step["type"].is_string()) {
s.type = step["type"].get<std::string>();
std::transform(s.type.begin(), s.type.end(), s.type.begin(), ::tolower);
}
if (step.contains("to") && step["to"].is_array() && step["to"].size() >= 2) {
s.toX = step["to"][0].get<float>();
s.toY = step["to"][1].get<float>();
}
if (step.contains("duration")) {
s.durationMs = step["duration"].get<float>() * 1000.0f;
}
if (step.contains("easing") && step["easing"].is_string()) {
s.easing = step["easing"].get<std::string>();
std::transform(s.easing.begin(), s.easing.end(), s.easing.begin(), ::tolower);
}
seq.steps.push_back(s);
}
}
node->animations[animName] = std::move(seq);
}
}
if (typeStr == "StaticImage") {
auto img = std::make_shared<UiStaticImage>();
img->name = node->name;
img->rect = initialRect;
std::string texPath;
if (j.contains("texture") && j["texture"].is_string()) {
texPath = j["texture"].get<std::string>();
}
else if (j.contains("textures") && j["textures"].is_object() && j["textures"].contains("normal")) {
texPath = j["textures"]["normal"].get<std::string>();
}
if (!texPath.empty()) {
try {
img->texture = renderer.textureManager.LoadFromPng(texPath, zipFile, true);
}
catch (const std::exception& e) {
std::cerr << "UiManager: failed load texture for StaticImage '" << img->name << "' : " << e.what() << std::endl;
}
}
node->staticImage = img;
// Optional fade-in on display
if (j.contains("fadeIn") && j["fadeIn"].is_object()) {
const auto& fi = j["fadeIn"];
img->fadeInEnabled = true;
img->fadeInDurationMs = fi.value("durationMs", 1000.0f);
img->fadeInElapsedMs = 0.0f;
}
// Optional pulse-scale animation
if (j.contains("pulse") && j["pulse"].is_object()) {
const auto& p = j["pulse"];
node->pulseEnabled = true;
node->pulseMinScale = p.value("minScale", 0.9f);
node->pulseMaxScale = p.value("maxScale", 1.1f);
node->pulsePeriodMs = p.value("periodMs", 1000.0f);
}
}
if (typeStr == "TextView") {
auto tv = std::make_shared<UiTextView>();
tv->name = node->name;
tv->rect = initialRect;
if (j.contains("text")) tv->text = j["text"].get<std::string>();
if (j.contains("fontPath")) tv->fontPath = j["fontPath"].get<std::string>();
if (j.contains("fontSize")) tv->fontSize = j["fontSize"].get<int>();
if (j.contains("color") && j["color"].is_array() && j["color"].size() == 4) {
for (int i = 0; i < 4; ++i) {
tv->color[i] = j["color"][i].get<float>();
}
}
if (j.contains("textCentered")) tv->textCentered = j["textCentered"].get<bool>();
if (j.contains("wrap")) tv->wrap = j["wrap"].get<bool>();
if (j.contains("topAligned")) tv->topAligned = j["topAligned"].get<bool>();
if (j.contains("paddingX")) tv->paddingX = j["paddingX"].get<float>();
if (j.contains("paddingY")) tv->paddingY = j["paddingY"].get<float>();
if (j.contains("maxLines")) tv->maxLines = j["maxLines"].get<int>();
tv->textRenderer = std::make_unique<TextRenderer>();
if (!tv->textRenderer->init(renderer, tv->fontPath, tv->fontSize, zipFile)) {
std::cerr << "Failed to init TextRenderer for TextView: " << tv->name << std::endl;
}
node->textView = tv;
}
if (j.contains("children") && j["children"].is_array()) {
for (const auto& ch : j["children"]) {
node->children.push_back(parseNode(ch, renderer, zipFile));
}
}
return node;
}
std::shared_ptr<UiNode> loadUiFromFile(const std::string& path, Renderer& renderer, const std::string& zipFile)
{
std::shared_ptr<UiNode> root;
std::string content;
try {
if (zipFile.empty()) {
content = readTextFile(path);
}
else {
auto buf = readFileFromZIP(path, zipFile);
if (buf.empty()) {
logger() << "UiManager: failed to read " << path << " from zip " << zipFile << std::endl;
throw std::runtime_error("Failed to load UI file: " + path);
}
content.assign(buf.begin(), buf.end());
}
}
catch (const std::exception& e) {
logger() << "UiManager: failed to open " << path << " : " << e.what() << std::endl;
throw std::runtime_error("Failed to load UI file: " + path);
}
json j;
try {
j = json::parse(content);
}
catch (const std::exception& e) {
logger() << "UiManager: json parse error: " << e.what() << std::endl;
throw std::runtime_error("Failed to load UI file: " + path);
}
if (!j.contains("root") || !j["root"].is_object()) {
logger() << "UiManager: root node missing or invalid" << std::endl;
throw std::runtime_error("Failed to load UI file: " + path);
}
root = parseNode(j["root"], renderer, zipFile);
return root;
}
// ---- UiChatBubble helpers ----
static void buildQuadMesh(VertexRenderStruct& mesh, float x0, float y0, float x1, float y1) {
mesh.data.PositionData.clear();
mesh.data.TexCoordData.clear();
mesh.data.PositionData.push_back({ x0, y0, 0 });
mesh.data.TexCoordData.push_back({ 0, 0 });
mesh.data.PositionData.push_back({ x0, y1, 0 });
mesh.data.TexCoordData.push_back({ 0, 1 });
mesh.data.PositionData.push_back({ x1, y1, 0 });
mesh.data.TexCoordData.push_back({ 1, 1 });
mesh.data.PositionData.push_back({ x0, y0, 0 });
mesh.data.TexCoordData.push_back({ 0, 0 });
mesh.data.PositionData.push_back({ x1, y1, 0 });
mesh.data.TexCoordData.push_back({ 1, 1 });
mesh.data.PositionData.push_back({ x1, y0, 0 });
mesh.data.TexCoordData.push_back({ 1, 0 });
mesh.RefreshVBO();
}
float UiChatBubble::computeWidthPortrait() const {
if (!textRenderer) return P_MAX_WIDTH;
const float measuredTextW = textRenderer->measureTextWidth(text, 1.0f);
const float required = measuredTextW + P_CORNER_SIZE * 2.0f + P_PAD_X * 2.0f;
return min(required, P_MAX_WIDTH);
}
float UiChatBubble::computeHeightPortrait() const {
if (!textRenderer) return P_CORNER_SIZE * 2 + P_PAD_Y * 2 + static_cast<float>(fontSize);
const float textW = computeWidthPortrait() - P_CORNER_SIZE * 2.0f - P_PAD_X * 2.0f;
const std::string wrapped = wrapTextByPixels(text, *textRenderer, textW, 1.0f);
int lines = 1;
for (char c : wrapped) if (c == '\n') ++lines;
return P_CORNER_SIZE * 2.0f + P_PAD_Y * 2.0f + lines * static_cast<float>(fontSize) * 1.25f;
}
float UiChatBubble::computeWidth() const {
if (!textRenderer) return MAX_WIDTH;
const float measuredTextW = textRenderer->measureTextWidth(text, 1.0f);
const float required = measuredTextW + CORNER_SIZE * 2.0f + PAD_X * 2.0f;
return min(required, MAX_WIDTH);
}
float UiChatBubble::computeHeight() const {
if (!textRenderer) return CORNER_SIZE * 2 + PAD_Y * 2 + static_cast<float>(fontSize);
const float textW = computeWidth() - CORNER_SIZE * 2.0f - PAD_X * 2.0f;
const std::string wrapped = wrapTextByPixels(text, *textRenderer, textW, 1.0f);
int lines = 1;
for (char c : wrapped) if (c == '\n') ++lines;
return CORNER_SIZE * 2.0f + PAD_Y * 2.0f + lines * static_cast<float>(fontSize) * 1.25f;
}
void UiChatBubble::buildMeshes() {
const float x = rect.x;
const float y = rect.y;
const float w = rect.w;
const float h = rect.h;
const float cs = CORNER_SIZE;
const float OFFSET = 0.5f;
// 1. Middle center (full width, between top and bottom corner rows)
buildQuadMesh(meshMiddle, x, y + cs- OFFSET, x + w, y + h - cs +OFFSET);
// 2. Top strip (between corners)
buildQuadMesh(meshTopStrip, x + cs- OFFSET, y + h - cs, x + w - cs+ OFFSET, y + h);
// 3. Bottom strip
buildQuadMesh(meshBotStrip, x + cs- OFFSET, y, x + w - cs+ OFFSET, y + cs);
// Corners
buildQuadMesh(meshCornerLT, x, y + h - cs-OFFSET, x + cs, y + h);
buildQuadMesh(meshCornerRT, x + w - cs, y + h - cs - OFFSET, x + w, y + h);
buildQuadMesh(meshCornerLB, x, y, x + cs, y + cs);
buildQuadMesh(meshCornerRB, x + w - cs, y, x + w, y + cs);
}
void UiChatBubble::draw(Renderer& renderer, float nodeScaleX, float nodeScaleY, float projW, float projH) const {
renderer.RenderUniform1i(textureUniformName, 0);
// Corners with blending (semi-transparent rounded corners)
glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
auto drawCorner = [&](const std::shared_ptr<Texture>& tex, const VertexRenderStruct& mesh) {
if (tex) {
glBindTexture(GL_TEXTURE_2D, tex->getTexID());
renderer.DrawVertexRenderStruct(mesh);
}
};
drawCorner(texCornerLT, meshCornerLT);
drawCorner(texCornerRT, meshCornerRT);
drawCorner(texCornerLB, meshCornerLB);
drawCorner(texCornerRB, meshCornerRB);
glDisable(GL_BLEND);
// Center parts (no blending needed — opaque fill)
if (texCenter) {
glBindTexture(GL_TEXTURE_2D, texCenter->getTexID());
renderer.DrawVertexRenderStruct(meshMiddle);
renderer.DrawVertexRenderStruct(meshTopStrip);
renderer.DrawVertexRenderStruct(meshBotStrip);
}
// Text — position is transformed around the bubble centre to match the renderer's
// scale matrix (which scales meshes around the same centre point).
if (textRenderer && !text.empty()) {
const float textW = rect.w - CORNER_SIZE * 2.0f - PAD_X * 2.0f;
const std::string wrapped = wrapTextByPixels(text, *textRenderer, textW, 1.0f);
const float tx0 = rect.x + CORNER_SIZE + PAD_X;
const float ty0 = rect.y + rect.h - CORNER_SIZE - PAD_Y - static_cast<float>(fontSize);
// Scale around bubble centre, matching how the renderer's ScaleMatrix works
const float cx = rect.x + rect.w * 0.5f;
const float cy = rect.y + rect.h * 0.5f;
const float tx = cx + (tx0 - cx) * nodeScaleX;
const float ty = cy + (ty0 - cy) * nodeScaleY;
textRenderer->drawText(wrapped, tx, ty, nodeScaleX, false, { 1.f, 1.f, 1.f, 1.f }, projW, projH);
}
}
// ---- end UiChatBubble helpers ----
void UiManager::replaceRoot(std::shared_ptr<UiNode> newRoot) {
root = newRoot;
buttons.clear();
textButtons.clear();
allInteractives.clear();
sliders.clear();
textViews.clear();
textFields.clear();
staticImages.clear();
chatBubbles.clear();
pulsingNodes.clear();
popInNodes.clear();
nodeActiveAnims.clear();
if (!root) return;
layoutNode(
root,
0.0f, 0.0f,
Environment::projectionWidth,
Environment::projectionHeight,
root->localX,
root->localY
);
collectButtonsAndSliders(root);
for (auto& b : buttons) {
b->buildMesh();
}
for (auto& tb : textButtons) {
tb->buildMesh();
}
for (auto& s : sliders) {
s->buildTrackMesh();
s->buildKnobMesh();
}
for (auto& img : staticImages) {
img->buildMesh();
}
}
void UiManager::loadFromFile(const std::string& path, Renderer& renderer, const std::string& zipFile) {
std::shared_ptr<UiNode> newRoot = loadUiFromFile(path, renderer, zipFile);
replaceRoot(newRoot);
}
void UiManager::appendFromFile(const std::string& path, Renderer& renderer, const std::string& zipFile) {
std::shared_ptr<UiNode> extraRoot = loadUiFromFile(path, renderer, zipFile);
if (!extraRoot) {
std::cerr << "UiManager: appendFromFile failed: " << path << std::endl;
return;
}
if (!root) {
replaceRoot(extraRoot);
return;
}
for (auto& child : extraRoot->children) {
root->children.push_back(child);
}
replaceRoot(root);
}
void UiManager::layoutNode(const std::shared_ptr<UiNode>& node, float parentX, float parentY, float parentW, float parentH, float finalLocalX, float finalLocalY) {
node->screenRect.w = (node->width < 0) ? parentW : node->width;
node->screenRect.h = (node->height < 0) ? parentH : node->height;
// ТЕПЕРЬ используем переданные координаты, а не node->localX напрямую
node->screenRect.x = parentX + finalLocalX;
node->screenRect.y = parentY + finalLocalY;
float currentW = node->screenRect.w;
float currentH = node->screenRect.h;
if (node->layoutType == LayoutType::Linear) {
float totalContentWidth = 0;
float totalContentHeight = 0;
// Предварительный расчет занимаемого места всеми детьми
for (size_t i = 0; i < node->children.size(); ++i) {
if (node->orientation == Orientation::Vertical) {
totalContentHeight += node->children[i]->height;
if (i < node->children.size() - 1) totalContentHeight += node->spacing;
}
else {
totalContentWidth += node->children[i]->width;
if (i < node->children.size() - 1) totalContentWidth += node->spacing;
}
}
float startX = 0;
float startY = currentH;
if (node->orientation == Orientation::Vertical) {
if (node->layoutSettings.vAlign == VerticalAlign::Center) {
startY = (currentH + totalContentHeight) / 2.0f;
}
else if (node->layoutSettings.vAlign == VerticalAlign::Bottom) {
startY = totalContentHeight;
}
}
// Горизонтальное выравнивание всего блока
if (node->orientation == Orientation::Horizontal) {
if (node->layoutSettings.hAlign == HorizontalAlign::Center) {
startX = (currentW - totalContentWidth) / 2.0f;
}
else if (node->layoutSettings.hAlign == HorizontalAlign::Right) {
startX = currentW - totalContentWidth;
}
}
float cursorX = startX;
float cursorY = startY;
for (auto& child : node->children) {
float childW = (child->width < 0) ? currentW : child->width;
float childH = (child->height < 0) ? currentH : child->height;
if (node->orientation == Orientation::Vertical) {
cursorY -= childH; // используем вычисленный childH
float childX = 0;
float freeSpaceX = currentW - childW;
if (node->layoutSettings.hAlign == HorizontalAlign::Center) childX = freeSpaceX / 2.0f;
else if (node->layoutSettings.hAlign == HorizontalAlign::Right) childX = freeSpaceX;
child->localX = childX;
child->localY = cursorY;
cursorY -= node->spacing;
}
else {
child->localX = cursorX;
// Вертикальное выравнивание внутри "строки" (Cross-axis alignment)
float childY = 0;
float freeSpaceY = currentH - childH;
if (node->layoutSettings.vAlign == VerticalAlign::Center) {
childY = freeSpaceY / 2.0f;
}
else if (node->layoutSettings.vAlign == VerticalAlign::Top) {
childY = freeSpaceY; // Прижимаем к верхнему краю (т.к. Y растет вверх)
}
else if (node->layoutSettings.vAlign == VerticalAlign::Bottom) {
childY = 0; // Прижимаем к нижнему краю
}
child->localY = childY;
// Сдвигаем курсор вправо для следующего элемента
cursorX += childW + node->spacing;
}
layoutNode(child, node->screenRect.x, node->screenRect.y, currentW, currentH, child->localX, child->localY);
}
}
else {
for (auto& child : node->children) {
float childW = (child->width < 0) ? currentW : child->width;
float childH = (child->height < 0) ? currentH : child->height;
float fLX = child->localX;
float fLY = child->localY;
if (child->layoutSettings.hGravity == HorizontalGravity::Right) {
fLX = currentW - childW - child->localX;
}
else if (child->layoutSettings.hGravity == HorizontalGravity::Center) {
fLX = (currentW - childW) / 2.0f + child->localX;
}
if (child->layoutSettings.vGravity == VerticalGravity::Top) {
fLY = currentH - childH - child->localY;
}
else if (child->layoutSettings.vGravity == VerticalGravity::Center) {
fLY = (currentH - childH) / 2.0f + child->localY;
}
// Передаем рассчитанные fLX, fLY в рекурсию
layoutNode(child, node->screenRect.x, node->screenRect.y, currentW, currentH, fLX, fLY);
}
}
// Обновляем меши визуальных компонентов
syncComponentRects(node);
}
void UiManager::syncComponentRects(const std::shared_ptr<UiNode>& node) {
if (!node) return;
// 1. Обновляем кнопку
if (node->button) {
node->button->rect = node->screenRect;
node->button->buildMesh();
}
if (node->textButton) {
node->textButton->rect = node->screenRect;
node->textButton->buildMesh();
}
// 2. Обновляем слайдер
if (node->slider) {
node->slider->rect = node->screenRect;
node->slider->buildTrackMesh();
node->slider->buildKnobMesh();
}
// 3. Обновляем текстовое поле (TextView)
if (node->textView) {
node->textView->rect = node->screenRect;
// Если в TextView реализован кэш меша для текста, его нужно обновить здесь
// node->textView->rebuildText();
}
// 4. Обновляем поле ввода (TextField)
if (node->textField) {
node->textField->rect = node->screenRect;
// Аналогично для курсора и фонового меша
}
// 5. Обновляем статическое изображение
if (node->staticImage) {
node->staticImage->rect = node->screenRect;
node->staticImage->buildMesh();
}
// 6. Chat bubble
if (node->chatBubble) {
node->chatBubble->rect = node->screenRect;
node->chatBubble->buildMeshes();
}
}
void UiManager::updateAllLayouts() {
if (!root) return;
// Запускаем расчет от корня, передавая размеры экрана как "родительские"
layoutNode(
root,
0.0f, 0.0f, // parentX, parentY (экран начинается с 0,0)
Environment::projectionWidth, // parentW
Environment::projectionHeight, // parentH
root->localX, // finalLocalX
root->localY // finalLocalY
);
}
void UiManager::startPopIn(const std::string& nodeName, float durationMs) {
auto node = findNode(nodeName);
if (!node) return;
node->scaleX = 0.0f;
node->scaleY = 0.0f;
node->popInActive = true;
node->popInProgress = 0.0f;
node->popInDurationMs = durationMs;
popInNodes.push_back(node);
}
void UiManager::collectButtonsAndSliders(const std::shared_ptr<UiNode>& node) {
if (node->button) {
buttons.push_back(node->button);
allInteractives.push_back(node->button);
}
if (node->textButton) {
textButtons.push_back(node->textButton);
allInteractives.push_back(node->textButton);
}
if (node->slider) {
sliders.push_back(node->slider);
}
if (node->textView) {
textViews.push_back(node->textView);
}
if (node->textField) {
textFields.push_back(node->textField);
}
if (node->staticImage) {
staticImages.push_back(node->staticImage);
if (node->staticImage->fadeInEnabled) {
node->staticImage->fadeInElapsedMs = 0.0f; // restart fade every time UI is shown
}
}
if (node->pulseEnabled && node->staticImage) {
pulsingNodes.push_back(node);
}
if (node->chatBubble) {
chatBubbles.push_back(node->chatBubble);
}
for (auto& c : node->children) collectButtonsAndSliders(c);
}
bool UiManager::setButtonCallback(const std::string& name, std::function<void(const std::string&)> cb) {
auto b = findButton(name);
if (!b) {
logger() << "UiManager: setButtonCallback failed, button not found: " << name << std::endl;
return false;
}
b->onClick = std::move(cb);
return true;
}
bool UiManager::setButtonPressCallback(const std::string& name, std::function<void(const std::string&)> cb) {
auto b = findButton(name);
if (!b) {
std::cerr << "UiManager: setButtonPressCallback failed, button not found: " << name << std::endl;
return false;
}
b->onPress = std::move(cb);
return true;
}
bool UiManager::addSlider(const std::string& name, const UiRect& rect, Renderer& renderer, const std::string& zipFile,
const std::string& trackPath, const std::string& knobPath, float initialValue, bool vertical) {
auto s = std::make_shared<UiSlider>();
s->name = name;
s->rect = rect;
s->value = std::clamp(initialValue, 0.0f, 1.0f);
s->vertical = vertical;
try {
if (!trackPath.empty())
s->texTrack = renderer.textureManager.LoadFromPng(trackPath, zipFile, true);
if (!knobPath.empty())
s->texKnob = renderer.textureManager.LoadFromPng(knobPath, zipFile, true);
}
catch (const std::exception& e) {
std::cerr << "UiManager: addSlider failed to load textures: " << e.what() << std::endl;
return false;
}
s->buildTrackMesh();
s->buildKnobMesh();
sliders.push_back(s);
return true;
}
std::shared_ptr<UiSlider> UiManager::findSlider(const std::string& name) {
for (auto& s : sliders) if (s->name == name) return s;
return nullptr;
}
bool UiManager::setSliderCallback(const std::string& name, std::function<void(const std::string&, float)> cb) {
auto s = findSlider(name);
if (!s) {
std::cerr << "UiManager: setSliderCallback failed, slider not found: " << name << std::endl;
return false;
}
s->onValueChanged = std::move(cb);
return true;
}
bool UiManager::setSliderValue(const std::string& name, float value) {
auto s = findSlider(name);
if (!s) return false;
value = std::clamp(value, 0.0f, 1.0f);
if (fabs(s->value - value) < 1e-6f) return true;
s->value = value;
s->buildTrackMesh();
if (s->onValueChanged) s->onValueChanged(s->name, s->value);
return true;
}
std::shared_ptr<UiTextField> UiManager::findTextField(const std::string& name) {
for (auto& tf : textFields) if (tf->name == name) return tf;
return nullptr;
}
bool UiManager::setTextFieldCallback(const std::string& name, std::function<void(const std::string&, const std::string&)> cb) {
auto tf = findTextField(name);
if (!tf) {
std::cerr << "UiManager: setTextFieldCallback failed, textfield not found: " << name << std::endl;
return false;
}
tf->onTextChanged = std::move(cb);
return true;
}
std::string UiManager::getTextFieldValue(const std::string& name) {
auto tf = findTextField(name);
if (!tf) return "";
return tf->text;
}
bool UiManager::pushMenuFromSavedRoot(std::shared_ptr<UiNode> newRoot)
{
MenuState prev;
prev.root = root;
prev.buttons = buttons;
prev.textButtons = textButtons;
prev.allInteractives = allInteractives;
prev.sliders = sliders;
prev.textViews = textViews;
prev.textFields = textFields;
prev.staticImages = staticImages;
prev.chatBubbles = chatBubbles;
prev.pulsingNodes = pulsingNodes;
prev.pressedButtons = pressedButtons;
prev.pressedTextButtons = pressedTextButtons;
prev.pressedSliders = pressedSliders;
prev.focusedTextField = focusedTextField;
prev.path = "";
prev.popInNodes = popInNodes;
prev.animCallbacks = animCallbacks;
try {
nodeActiveAnims.clear();
animCallbacks.clear();
focusedTextField = nullptr;
for (auto& b : buttons) {
if (b) {
b->animOffsetX = 0.0f;
b->animOffsetY = 0.0f;
b->animScaleX = 1.0f;
b->animScaleY = 1.0f;
}
}
for (auto& tb : textButtons) {
if (tb) {
tb->animOffsetX = 0.0f;
tb->animOffsetY = 0.0f;
tb->animScaleX = 1.0f;
tb->animScaleY = 1.0f;
}
}
replaceRoot(newRoot);
menuStack.push_back(std::move(prev));
return true;
}
catch (const std::exception& e) {
std::cerr << "UiManager: pushMenuFromFile failed to load from root : " << e.what() << std::endl;
animCallbacks = prev.animCallbacks;
return false;
}
}
bool UiManager::pushMenuFromFile(const std::string& path, Renderer& renderer, const std::string& zipFile) {
auto newRoot = loadUiFromFile(path, renderer, zipFile);
return pushMenuFromSavedRoot(newRoot);
}
bool UiManager::popMenu() {
if (menuStack.empty()) {
std::cerr << "UiManager: popMenu called but menu stack is empty" << std::endl;
return false;
}
auto s = menuStack.back();
menuStack.pop_back();
nodeActiveAnims.clear();
root = s.root;
buttons = s.buttons;
textButtons = s.textButtons;
allInteractives = s.allInteractives;
sliders = s.sliders;
textViews = s.textViews;
textFields = s.textFields;
staticImages = s.staticImages;
chatBubbles = s.chatBubbles;
pulsingNodes = s.pulsingNodes;
popInNodes = s.popInNodes;
pressedButtons = s.pressedButtons;
pressedTextButtons = s.pressedTextButtons;
pressedSliders = s.pressedSliders;
focusedTextField = s.focusedTextField;
animCallbacks = s.animCallbacks;
for (auto& b : buttons) {
if (b) {
b->animOffsetX = 0.0f;
b->animOffsetY = 0.0f;
b->animScaleX = 1.0f;
b->animScaleY = 1.0f;
b->buildMesh();
}
}
for (auto& tb : textButtons) {
if (tb) {
tb->animOffsetX = 0.0f;
tb->animOffsetY = 0.0f;
tb->animScaleX = 1.0f;
tb->animScaleY = 1.0f;
tb->buildMesh();
}
}
for (auto& sl : sliders) {
if (sl) {
sl->buildTrackMesh();
sl->buildKnobMesh();
}
}
return true;
}
void UiManager::clearMenuStack() {
menuStack.clear();
}
int UiManager::menuStackSize() const {
return static_cast<int>(menuStack.size());
}
void UiManager::draw(Renderer& renderer, float projW, float projH) {
if (projW <= 0.0f) projW = Environment::projectionWidth;
if (projH <= 0.0f) projH = Environment::projectionHeight;
renderer.PushProjectionMatrix(projW, projH, -1, 1);
renderer.PushMatrix();
renderer.LoadIdentity();
renderer.RenderUniform1f("uAlpha", 1.0f); // ensure alpha is fully opaque unless a widget overrides it
std::function<void(const std::shared_ptr<UiNode>&, float, float)> drawNode =
[&](const std::shared_ptr<UiNode>& node, float accumX, float accumY) {
if (!node || !node->visible) return;
// Считаем полное смещение для текущего узла и его потомков
float curAccumX = accumX + node->animOffsetX;
float curAccumY = accumY + node->animOffsetY;
renderer.PushMatrix();
renderer.TranslateMatrix({ node->animOffsetX, node->animOffsetY, 0.0f });
renderer.TranslateMatrix({ node->screenRect.x + node->screenRect.w * 0.5f,
node->screenRect.y + node->screenRect.h * 0.5f, 0.0f });
renderer.ScaleMatrix({ node->scaleX, node->scaleY, 1.0f });
renderer.TranslateMatrix({ -(node->screenRect.x + node->screenRect.w * 0.5f),
-(node->screenRect.y + node->screenRect.h * 0.5f), 0.0f });
if (node->staticImage) {
node->staticImage->draw(renderer);
}
if (node->chatBubble) {
node->chatBubble->draw(renderer, node->scaleX, node->scaleY, projW, projH);
}
if (node->button) {
node->button->draw(renderer);
}
if (node->textButton) {
// Передаем накопленные смещения
node->textButton->draw(renderer, curAccumX, curAccumY);
}
if (node->slider) {
node->slider->draw(renderer);
}
if (node->textField) {
node->textField->draw(renderer);
}
if (node->textView) {
// Передаем накопленные смещения
node->textView->draw(renderer, curAccumX, curAccumY);
}
for (auto& child : node->children) {
// Прокидываем сумму дальше
drawNode(child, curAccumX, curAccumY);
}
renderer.PopMatrix();
};
drawNode(root, 0.0f, 0.0f);
renderer.PopMatrix();
renderer.PopProjectionMatrix();
}
// Buttons under a hidden node (the node itself or any ancestor) must not react to touches.
static void collectVisibleInteractives(const std::shared_ptr<UiNode>& node, std::unordered_set<const void*>& out) {
if (!node || !node->visible) return;
if (node->button) out.insert(node->button.get());
if (node->textButton) out.insert(node->textButton.get());
for (auto& c : node->children) collectVisibleInteractives(c, out);
}
static std::shared_ptr<UiNode> findNodeByName(const std::shared_ptr<UiNode>& node, const std::string& name) {
if (!node) return nullptr;
if (!name.empty() && node->name == name) return node;
for (auto& c : node->children) {
auto r = findNodeByName(c, name);
if (r) return r;
}
return nullptr;
}
void UiManager::update(float deltaMs) {
if (!root) return;
// Fade-in animations (StaticImage elements with fadeInEnabled == true)
for (auto& img : staticImages) {
if (!img || !img->fadeInEnabled) continue;
if (img->fadeInElapsedMs < img->fadeInDurationMs) {
img->fadeInElapsedMs += deltaMs;
if (img->fadeInElapsedMs > img->fadeInDurationMs)
img->fadeInElapsedMs = img->fadeInDurationMs;
}
}
// Pulse-scale animations (StaticImage nodes with pulseEnabled == true)
for (auto& node : pulsingNodes) {
if (!node) continue;
node->pulseElapsedMs += deltaMs;
if (node->pulseElapsedMs >= node->pulsePeriodMs)
node->pulseElapsedMs = std::fmod(node->pulseElapsedMs, node->pulsePeriodMs);
const float phase = node->pulseElapsedMs / node->pulsePeriodMs;
const float t = (std::sin(phase * 2.0f * static_cast<float>(M_PI)) + 1.0f) * 0.5f;
const float s = node->pulseMinScale + (node->pulseMaxScale - node->pulseMinScale) * t;
node->scaleX = s;
node->scaleY = s;
}
// Pop-in scale animations (scale 0 → 1 on bubble reveal)
for (auto& node : popInNodes) {
if (!node || !node->popInActive) continue;
node->popInProgress += deltaMs / node->popInDurationMs;
if (node->popInProgress >= 1.0f) {
node->popInProgress = 1.0f;
node->popInActive = false;
node->scaleX = 1.0f;
node->scaleY = 1.0f;
} else {
// ease-out quad
const float t = node->popInProgress;
const float s = 1.0f - (1.0f - t) * (1.0f - t);
node->scaleX = s;
node->scaleY = s;
}
}
popInNodes.erase(std::remove_if(popInNodes.begin(), popInNodes.end(),
[](const auto& n) { return !n || !n->popInActive; }), popInNodes.end());
std::vector<std::pair<std::shared_ptr<UiNode>, size_t>> animationsToRemove;
std::vector<std::function<void()>> pendingCallbacks;
for (auto& kv : nodeActiveAnims) {
auto node = kv.first;
auto& activeList = kv.second;
for (size_t i = 0; i < activeList.size(); ++i) {
auto& act = activeList[i];
if (!act.seq) {
animationsToRemove.push_back({ node, i });
continue;
}
const auto& steps = act.seq->steps;
if (act.stepIndex >= steps.size()) {
if (act.repeat) {
if (node->button) {
node->button->animOffsetX = act.origOffsetX;
node->button->animOffsetY = act.origOffsetY;
node->button->animScaleX = act.origScaleX;
node->button->animScaleY = act.origScaleY;
}
if (node->textButton) {
node->textButton->animOffsetX = act.origOffsetX;
node->textButton->animOffsetY = act.origOffsetY;
node->textButton->animScaleX = act.origScaleX;
node->textButton->animScaleY = act.origScaleY;
}
else
{
//logger() << "[animation] Resetting animation for node: " << node->name << " y=" << act.origOffsetY << std::endl;
node->animOffsetX = act.origOffsetX;
node->animOffsetY = act.origOffsetY;
}
act.stepIndex = 0;
act.elapsedMs = 0.0f;
act.stepStarted = false;
}
else {
if (act.onComplete) {
pendingCallbacks.push_back(act.onComplete);
}
animationsToRemove.push_back({ node, i });
}
continue;
}
const auto& step = steps[act.stepIndex];
if (step.durationMs <= 0.0f) {
if (step.type == "move") {
if (node->button) {
node->button->animOffsetX = step.toX;
node->button->animOffsetY = step.toY;
}
if (node->textButton) {
node->textButton->animOffsetX = step.toX;
node->textButton->animOffsetY = step.toY;
}
else
{
//logger() << "[animation] step.durationMs <= 0.0f: " << node->name << " y=" << step.toY << std::endl;
node->animOffsetX = step.toX;
node->animOffsetY = step.toY;
}
}
else if (step.type == "scale") {
if (node->button) {
node->button->animScaleX = step.toX;
node->button->animScaleY = step.toY;
}
if (node->textButton) {
node->textButton->animScaleX = step.toX;
node->textButton->animScaleY = step.toY;
}
}
act.stepIndex++;
act.elapsedMs = 0.0f;
act.stepStarted = false;
continue;
}
if (!act.stepStarted && act.stepIndex == 0 && act.elapsedMs == 0.0f) {
if (node->button) {
act.origOffsetX = node->button->animOffsetX;
act.origOffsetY = node->button->animOffsetY;
act.origScaleX = node->button->animScaleX;
act.origScaleY = node->button->animScaleY;
}
else if (node->textButton) {
act.origOffsetX = node->textButton->animOffsetX;
act.origOffsetY = node->textButton->animOffsetY;
act.origScaleX = node->textButton->animScaleX;
act.origScaleY = node->textButton->animScaleY;
}
else {
//logger() << "[animation] !act.stepStarted && act.stepIndex == 0 && act.elapsedMs == 0.0f: " << node->name << " y=" << node->animOffsetY << std::endl;
act.origOffsetX = node->animOffsetX;
act.origOffsetY = node->animOffsetY;
}
}
float prevElapsed = act.elapsedMs;
act.elapsedMs += deltaMs;
if (!act.stepStarted && prevElapsed == 0.0f) {
if (node->button) {
act.startOffsetX = node->button->animOffsetX;
act.startOffsetY = node->button->animOffsetY;
act.startScaleX = node->button->animScaleX;
act.startScaleY = node->button->animScaleY;
}
else if (node->textButton) {
act.startOffsetX = node->textButton->animOffsetX;
act.startOffsetY = node->textButton->animOffsetY;
act.startScaleX = node->textButton->animScaleX;
act.startScaleY = node->textButton->animScaleY;
}
else {
//logger() << "[animation] !act.stepStarted && prevElapsed == 0.0f: " << node->name << " y=" << node->animOffsetY << std::endl;
act.startOffsetX = node->animOffsetX;
act.startOffsetY = node->animOffsetY;
act.startScaleX = node->scaleX;
act.startScaleY = node->scaleY;
}
if (step.type == "move") {
act.endOffsetX = step.toX;
act.endOffsetY = step.toY;
}
else if (step.type == "scale") {
act.endScaleX = step.toX;
act.endScaleY = step.toY;
}
act.stepStarted = true;
}
float t = (step.durationMs > 0.0f) ? (act.elapsedMs / step.durationMs) : 1.0f;
if (t > 1.0f) t = 1.0f;
float te = applyEasing(step.easing, t);
if (step.type == "move") {
float nx = act.startOffsetX + (act.endOffsetX - act.startOffsetX) * te;
float ny = act.startOffsetY + (act.endOffsetY - act.startOffsetY) * te;
if (node->button) {
node->button->animOffsetX = nx;
node->button->animOffsetY = ny;
}
if (node->textButton) {
node->textButton->animOffsetX = nx;
node->textButton->animOffsetY = ny;
}
else {
//logger() << "[animation] if (step.type ==move): " << node->name << " y=" << ny << std::endl;
node->animOffsetX = nx;
node->animOffsetY = ny;
}
}
else if (step.type == "scale") {
float sx = act.startScaleX + (act.endScaleX - act.startScaleX) * te;
float sy = act.startScaleY + (act.endScaleY - act.startScaleY) * te;
if (node->button) {
node->button->animScaleX = sx;
node->button->animScaleY = sy;
}
if (node->textButton) {
node->textButton->animScaleX = sx;
node->textButton->animScaleY = sy;
}
}
else if (step.type == "wait") {
//wait
}
if (act.elapsedMs >= step.durationMs) {
act.stepIndex++;
act.elapsedMs = 0.0f;
act.stepStarted = false;
}
}
}
for (auto it = animationsToRemove.rbegin(); it != animationsToRemove.rend(); ++it) {
auto& [node, index] = *it;
if (nodeActiveAnims.find(node) != nodeActiveAnims.end()) {
auto& animList = nodeActiveAnims[node];
if (index < animList.size()) {
animList.erase(animList.begin() + index);
}
if (animList.empty()) {
nodeActiveAnims.erase(node);
}
}
}
for (auto& cb : pendingCallbacks) {
try {
cb();
}
catch (...) {
std::cerr << "UiManager: animation onComplete callback threw exception" << std::endl;
}
}
}
void UiManager::onTouchMove(int64_t fingerId, int x, int y) {
// Hover state updates only make sense for mouse (single pointer)
if (fingerId == MOUSE_FINGER_ID) {
for (auto& b : buttons) {
if (b->state != ButtonState::Disabled)
{
if (b->getClickZoneRect().containsConsideringBorder((float)x, (float)y, b->border)) {
if (b->state != ButtonState::Pressed) b->state = ButtonState::Hover;
}
else {
if (b->state != ButtonState::Pressed) b->state = ButtonState::Normal;
}
}
}
for (auto& tb : textButtons) {
if (tb->state != ButtonState::Disabled)
{
if (tb->getClickZoneRect().containsConsideringBorder((float)x, (float)y, tb->border)) {
if (tb->state != ButtonState::Pressed) tb->state = ButtonState::Hover;
}
else {
if (tb->state != ButtonState::Pressed) tb->state = ButtonState::Normal;
}
}
}
}
auto it = pressedSliders.find(fingerId);
if (it != pressedSliders.end()) {
auto s = it->second;
if (s->interactive) {
float t;
if (s->vertical) {
t = (y - s->rect.y) / s->rect.h;
}
else {
t = (x - s->rect.x) / s->rect.w;
}
if (t < 0.0f) t = 0.0f;
if (t > 1.0f) t = 1.0f;
s->value = t;
s->buildTrackMesh();
if (s->onValueChanged) s->onValueChanged(s->name, s->value);
}
}
}
void UiManager::onTouchDown(int64_t fingerId, int x, int y) {
// Iterate allInteractives in reverse DFS order: later-declared elements have higher priority.
// At most one button or textButton is pressed per touch.
std::unordered_set<const void*> visibleInteractives;
collectVisibleInteractives(root, visibleInteractives);
for (auto it = allInteractives.rbegin(); it != allInteractives.rend(); ++it) {
if (std::holds_alternative<std::shared_ptr<UiButton>>(*it)) {
auto& b = std::get<std::shared_ptr<UiButton>>(*it);
//logger() << "onTouchDown found the button: " << b->name << std::endl;
if (visibleInteractives.count(b.get()) && b->state != ButtonState::Disabled &&
b->getClickZoneRect().containsConsideringBorder((float)x, (float)y, b->border)) {
b->state = ButtonState::Pressed;
pressedButtons[fingerId] = b;
if (b->onPress) b->onPress(b->name);
//logger() << "onTouchDown pressed the button: " << b->name << std::endl;
break;
}
} else {
auto& tb = std::get<std::shared_ptr<UiTextButton>>(*it);
if (visibleInteractives.count(tb.get()) && tb->state != ButtonState::Disabled &&
tb->getClickZoneRect().containsConsideringBorder((float)x, (float)y, tb->border)) {
tb->state = ButtonState::Pressed;
pressedTextButtons[fingerId] = tb;
if (tb->onPress) tb->onPress(tb->name);
break;
}
}
}
for (auto& s : sliders) {
if (!s->interactive) continue;
if (s->rect.contains((float)x, (float)y)) {
pressedSliders[fingerId] = s;
float t;
if (s->vertical) {
t = (y - s->rect.y) / s->rect.h;
}
else {
t = (x - s->rect.x) / s->rect.w;
}
if (t < 0.0f) t = 0.0f;
if (t > 1.0f) t = 1.0f;
s->value = t;
s->buildTrackMesh();
if (s->onValueChanged) s->onValueChanged(s->name, s->value);
break;
}
}
for (auto& tf : textFields) {
if (tf->rect.contains((float)x, (float)y)) {
focusedTextField = tf;
tf->focused = true;
}
else {
tf->focused = false;
}
}
}
void UiManager::onTouchUp(int64_t fingerId, int x, int y) {
std::vector<std::shared_ptr<UiButton>> clicked;
std::vector<std::shared_ptr<UiTextButton>> clickedText;
auto btnIt = pressedButtons.find(fingerId);
if (btnIt != pressedButtons.end()) {
auto b = btnIt->second;
if (b) {
bool contains = b->getClickZoneRect().contains((float)x, (float)y);
if (b->state == ButtonState::Pressed) {
if (contains) {
clicked.push_back(b);
}
// On mouse: leave Hover if still over button. On touch: always Normal.
b->state = (contains && fingerId == MOUSE_FINGER_ID) ? ButtonState::Hover : ButtonState::Normal;
}
}
pressedButtons.erase(btnIt);
}
auto tbIt = pressedTextButtons.find(fingerId);
if (tbIt != pressedTextButtons.end()) {
auto tb = tbIt->second;
if (tb) {
bool contains = tb->getClickZoneRect().contains((float)x, (float)y);
if (tb->state == ButtonState::Pressed) {
if (contains) {
clickedText.push_back(tb);
}
tb->state = (contains && fingerId == MOUSE_FINGER_ID) ? ButtonState::Hover : ButtonState::Normal;
}
}
pressedTextButtons.erase(tbIt);
}
pressedSliders.erase(fingerId);
for (auto& b : clicked) {
if (b->onClick) {
b->onClick(b->name);
}
}
for (auto& tb : clickedText) {
if (tb->onClick) {
tb->onClick(tb->name);
}
}
}
void UiManager::onKeyPress(unsigned char key) {
if (!focusedTextField) return;
if (key >= 32 && key <= 126) {
if (focusedTextField->text.length() < (size_t)focusedTextField->maxLength) {
focusedTextField->text += key;
if (focusedTextField->onTextChanged) {
focusedTextField->onTextChanged(focusedTextField->name, focusedTextField->text);
}
}
}
}
void UiManager::onKeyBackspace() {
if (!focusedTextField) return;
if (!focusedTextField->text.empty()) {
focusedTextField->text.pop_back();
if (focusedTextField->onTextChanged) {
focusedTextField->onTextChanged(focusedTextField->name, focusedTextField->text);
}
}
}
std::shared_ptr<UiButton> UiManager::findButton(const std::string& name) {
for (auto& b : buttons) if (b->name == name) return b;
return nullptr;
}
bool UiManager::startAnimationOnNode(const std::string& nodeName, const std::string& animName) {
if (!root) return false;
auto node = findNodeByName(root, nodeName);
if (!node) return false;
auto it = node->animations.find(animName);
if (it == node->animations.end()) return false;
ActiveAnim aa;
aa.name = animName;
aa.seq = &it->second;
aa.stepIndex = 0;
aa.elapsedMs = 0.0f;
aa.repeat = it->second.repeat;
aa.stepStarted = false;
if (node->button) {
aa.origOffsetX = node->button->animOffsetX;
aa.origOffsetY = node->button->animOffsetY;
aa.origScaleX = node->button->animScaleX;
aa.origScaleY = node->button->animScaleY;
}
else if (node->textButton) {
aa.origOffsetX = node->textButton->animOffsetX;
aa.origOffsetY = node->textButton->animOffsetY;
aa.origScaleX = node->textButton->animScaleX;
aa.origScaleY = node->textButton->animScaleY;
}
else
{
//logger() << "[animation] startAnimationOnNode: " << node->name << " y=" << node->animOffsetY << std::endl;
aa.origOffsetX = node->animOffsetX;
aa.origOffsetY = node->animOffsetY;
}
auto cbIt = animCallbacks.find({ nodeName, animName });
if (cbIt != animCallbacks.end()) aa.onComplete = cbIt->second;
nodeActiveAnims[node].push_back(std::move(aa));
return true;
}
bool UiManager::resetAnimationOnNode(const std::string& nodeName)
{
if (!root) return false;
auto node = findNodeByName(root, nodeName);
if (!node) return false;
if (node->button) {
node->button->animOffsetX = 0;
node->button->animOffsetY = 0;
node->button->animScaleX = 1.0f;
node->button->animScaleY = 1.0f;
}
else if (node->textButton) {
node->textButton->animOffsetX = 0;
node->textButton->animOffsetY = 0;
node->textButton->animScaleX = 1.0f;
node->textButton->animScaleY = 1.0f;
}
else
{
//logger() << "[animation] startAnimationOnNode: " << node->name << " y=" << node->animOffsetY << std::endl;
node->animOffsetX = 0;
node->animOffsetY = 0;
}
return true;
}
bool UiManager::stopAnimationOnNode(const std::string& nodeName, const std::string& animName) {
if (!root) return false;
auto node = findNodeByName(root, nodeName);
if (!node) return false;
auto it = nodeActiveAnims.find(node);
if (it != nodeActiveAnims.end()) {
auto& animList = it->second;
for (auto animIt = animList.begin(); animIt != animList.end(); ) {
if (animIt->name == animName) {
animIt = animList.erase(animIt);
}
else {
++animIt;
}
}
if (animList.empty()) {
nodeActiveAnims.erase(it);
}
return true;
}
return false;
}
void UiManager::startAnimation(const std::string& animName) {
if (!root) return;
std::function<void(const std::shared_ptr<UiNode>&)> traverse = [&](const std::shared_ptr<UiNode>& n) {
if (!n) return;
auto it = n->animations.find(animName);
if (it != n->animations.end()) {
ActiveAnim aa;
aa.name = animName;
aa.seq = &it->second;
aa.stepIndex = 0;
aa.elapsedMs = 0.0f;
aa.repeat = it->second.repeat;
aa.stepStarted = false;
if (n->button) {
aa.origOffsetX = n->button->animOffsetX;
aa.origOffsetY = n->button->animOffsetY;
aa.origScaleX = n->button->animScaleX;
aa.origScaleY = n->button->animScaleY;
}
else if (n->textButton) {
aa.origOffsetX = n->textButton->animOffsetX;
aa.origOffsetY = n->textButton->animOffsetY;
aa.origScaleX = n->textButton->animScaleX;
aa.origScaleY = n->textButton->animScaleY;
}
else
{
//logger() << "[animation] startAnimation: " << n->name << " y=" << n->animOffsetY << std::endl;
aa.origOffsetX = n->animOffsetX;
aa.origOffsetY = n->animOffsetY;
}
auto cbIt = animCallbacks.find({ n->name, animName });
if (cbIt != animCallbacks.end()) aa.onComplete = cbIt->second;
nodeActiveAnims[n].push_back(std::move(aa));
}
for (auto& c : n->children) traverse(c);
};
traverse(root);
}
bool UiManager::setAnimationCallback(const std::string& nodeName, const std::string& animName, std::function<void()> cb) {
animCallbacks[{nodeName, animName}] = std::move(cb);
return true;
}
std::shared_ptr<UiStaticImage> UiManager::findStaticImage(const std::string& name) {
for (auto& img : staticImages) {
if (img->name == name) return img;
}
return nullptr;
}
std::shared_ptr<UiTextView> UiManager::findTextView(const std::string& name) {
for (auto& tv : textViews) {
if (tv->name == name) return tv;
}
return nullptr;
}
bool UiManager::setText(const std::string& name, const std::string& newText) {
auto tv = findTextView(name);
if (!tv) {
return false;
}
tv->text = newText;
return true;
}
bool UiManager::setTextColor(const std::string& name, const std::array<float, 4>& color) {
auto tv = findTextView(name);
if (!tv) {
return false;
}
tv->color = color;
return true;
}
std::shared_ptr<UiTextButton> UiManager::findTextButton(const std::string& name) {
for (auto& tb : textButtons) if (tb->name == name) return tb;
return nullptr;
}
bool UiManager::setTextButtonCallback(const std::string& name, std::function<void(const std::string&)> cb) {
auto tb = findTextButton(name);
if (!tb) {
std::cerr << "UiManager: setTextButtonCallback failed, textButton not found: " << name << std::endl;
return false;
}
tb->onClick = std::move(cb);
return true;
}
bool UiManager::setTextButtonPressCallback(const std::string& name, std::function<void(const std::string&)> cb) {
auto tb = findTextButton(name);
if (!tb) {
std::cerr << "UiManager: setTextButtonPressCallback failed, textButton not found: " << name << std::endl;
return false;
}
tb->onPress = std::move(cb);
return true;
}
bool UiManager::setTextButtonText(const std::string& name, const std::string& newText) {
auto tb = findTextButton(name);
if (!tb) return false;
tb->text = newText;
return true;
}
bool UiManager::setTextButtonColor(const std::string& name, const std::array<float, 4>& color) {
auto tb = findTextButton(name);
if (!tb) return false;
tb->color = color;
return true;
}
std::shared_ptr<UiNode> UiManager::findNode(const std::string& name) {
if (!root) return nullptr;
return findNodeByName(root, name);
}
bool UiManager::setNodeVisible(const std::string& nodeName, bool visible) {
if (!root) return false;
auto node = findNodeByName(root, nodeName);
if (!node) return false;
// Replay the fade-in when a hidden image is revealed, as if its screen was just shown.
if (visible && !node->visible && node->staticImage && node->staticImage->fadeInEnabled)
node->staticImage->fadeInElapsedMs = 0.0f;
node->visible = visible;
return true;
}
bool UiManager::getNodeVisible(const std::string& nodeName) {
if (!root) return false;
auto node = findNodeByName(root, nodeName);
if (!node) return false;
return node->visible;
}
std::string UiManager::addChatBubble(
const std::string& parentNodeName,
const std::string& text, bool incoming,
std::shared_ptr<Texture> texCenter,
std::shared_ptr<Texture> texLT, std::shared_ptr<Texture> texLB,
std::shared_ptr<Texture> texRT, std::shared_ptr<Texture> texRB,
Renderer& renderer, const std::string& fontPath, int fontSize,
bool isPortrait,
const std::string& zipFile)
{
if (!root) return "";
auto parent = findNodeByName(root, parentNodeName);
if (!parent) {
std::cerr << "UiManager::addChatBubble: parent '" << parentNodeName << "' not found\n";
return "";
}
static int bubbleCounter = 0;
const std::string nodeName = "chat_bubble_" + std::to_string(bubbleCounter++);
// Build the chat bubble component
auto bubble = std::make_shared<UiChatBubble>();
bubble->name = nodeName;
bubble->text = text;
bubble->incoming = incoming;
bubble->fontSize = fontSize;
bubble->texCenter = texCenter;
bubble->texCornerLT = texLT;
bubble->texCornerLB = texLB;
bubble->texCornerRT = texRT;
bubble->texCornerRB = texRB;
bubble->textRenderer = std::make_unique<TextRenderer>();
if (!bubble->textRenderer->init(renderer, fontPath, fontSize, zipFile)) {
std::cerr << "UiManager::addChatBubble: TextRenderer init failed\n";
}
// Build the UiNode
auto node = std::make_shared<UiNode>();
node->name = nodeName;
node->width = isPortrait? bubble->computeWidthPortrait() : bubble->computeWidth();
node->height = isPortrait? bubble->computeHeightPortrait() : bubble->computeHeight();
if (isPortrait)
{
if (incoming) {
node->localX = -300 + node->width * 0.5;
}
else
{
node->localX = 300 - node->width * 0.5;
}
}
else
{
if (incoming) {
node->localX = -210 + node->width * 0.5;
}
else
{
node->localX = 210 - node->width * 0.5;
}
}
node->localY = 0.0f;
node->visible = true;
node->chatBubble = bubble;
node->layoutSettings.hGravity = HorizontalGravity::Center;
// Bottom gravity means localY is used as-is for screenRect.y (no parentH transform).
// The original static chat nodes all had "vertical_gravity": "bottom" for the same reason.
node->layoutSettings.vGravity = VerticalGravity::Bottom;
parent->children.push_back(node);
// Re-layout and collect the new node
updateAllLayouts();
bubble->rect = node->screenRect;
bubble->buildMeshes();
chatBubbles.push_back(bubble);
return nodeName;
}
void UiManager::clearChatBubbles(const std::string& parentNodeName) {
if (!root) return;
auto parent = findNodeByName(root, parentNodeName);
if (!parent) return;
// Remove children that have a chatBubble component
auto& ch = parent->children;
ch.erase(std::remove_if(ch.begin(), ch.end(),
[](const std::shared_ptr<UiNode>& n) { return n && n->chatBubble != nullptr; }),
ch.end());
// Remove from chatBubbles collection
chatBubbles.clear();
}
} // namespace FRG