space-game001/src/dialogue/DialogueOverlay.cpp

678 lines
23 KiB
C++

#include "dialogue/DialogueOverlay.h"
#include "dialogue/DialogueTypes.h"
#include "GameConstants.h"
#include "Environment.h"
#include <algorithm>
#include <array>
#include <cmath>
namespace ZL::Dialogue {
void DialogueOverlay::TexturedQuad::rebuild(const UiRect& newRect) {
rect = newRect;
mesh.data = CreateRect2D(
{ rect.x + rect.w * 0.5f, rect.y + rect.h * 0.5f },
{ rect.w * 0.5f, rect.h * 0.5f },
0.0f
);
mesh.RefreshVBO();
initialized = true;
}
void DialogueOverlay::TexturedQuad::rebuildWithUV(
const UiRect& newRect,
const Eigen::Vector2f& uvBottomLeft,
const Eigen::Vector2f& uvTopLeft,
const Eigen::Vector2f& uvTopRight,
const Eigen::Vector2f& uvBottomRight
) {
rect = newRect;
const float x0 = rect.x;
const float y0 = rect.y;
const float x1 = rect.x + rect.w;
const float y1 = rect.y + rect.h;
VertexDataStruct data;
data.PositionData = {
{ x0, y0, 0.0f }, // bottom-left
{ x0, y1, 0.0f }, // top-left
{ x1, y1, 0.0f }, // top-right
{ x1, y1, 0.0f }, // top-right
{ x1, y0, 0.0f }, // bottom-right
{ x0, y0, 0.0f } // bottom-left
};
data.TexCoordData = {
uvBottomLeft,
uvTopLeft,
uvTopRight,
uvTopRight,
uvBottomRight,
uvBottomLeft
};
mesh.AssignFrom(data);
initialized = true;
}
bool DialogueOverlay::init(Renderer& renderer, const std::string& zipFile) {
rendererRef = &renderer;
zipFilename = zipFile;
textboxTexture = std::make_shared<Texture>(CreateTextureDataFromPng("resources/dialogue/textbox_bg.png", zipFile));
portraitFrameTexture = std::make_shared<Texture>(CreateTextureDataFromPng("resources/dialogue/portrait_frame.png", zipFile));
choiceMainTexture = std::make_shared<Texture>(CreateTextureDataFromPng("resources/dialogue/choice_main.png", zipFile));
choiceOptionalTexture = std::make_shared<Texture>(CreateTextureDataFromPng("resources/dialogue/choice_optional.png", zipFile));
choiceSelectedTexture = std::make_shared<Texture>(CreateTextureDataFromPng("resources/dialogue/choice_selected.png", zipFile));
cutsceneSubtitleTexture = std::make_shared<Texture>(CreateTextureDataFromPng("resources/dialogue/cutscene_subtitle_bg.png", zipFile));
nameRenderer = std::make_unique<TextRenderer>();
bodyRenderer = std::make_unique<TextRenderer>();
choiceRenderer = std::make_unique<TextRenderer>();
cutsceneRenderer = std::make_unique<TextRenderer>();
const bool ok =
nameRenderer->init(renderer, "resources/fonts/DroidSans.ttf", 28, zipFile) &&
bodyRenderer->init(renderer, "resources/fonts/DroidSans.ttf", 24, zipFile) &&
choiceRenderer->init(renderer, "resources/fonts/DroidSans.ttf", 22, zipFile) &&
cutsceneRenderer->init(renderer, "resources/fonts/DroidSans.ttf", 24, zipFile);
return ok;
}
void DialogueOverlay::update(const PresentationModel& model, int deltaMs) {
if (model.mode == PresentationMode::Hidden) {
hoveredChoiceIndex = -1;
cutsceneSkipHintVisible = false;
cutsceneSkipArmed = false;
cutsceneSkipHolding = false;
cutsceneSkipTriggered = false;
cutsceneSkipHintRemainingMs = 0;
cutsceneSkipHoldElapsedMs = 0;
lastChoiceRects.clear();
lastDialogueAdvanceRect = {};
lastCutsceneAdvanceRect = {};
return;
}
if (model.mode != PresentationMode::Choice) {
hoveredChoiceIndex = -1;
}
if (model.mode != PresentationMode::Cutscene || !model.cutsceneSkippable) {
cutsceneSkipHintVisible = false;
cutsceneSkipArmed = false;
cutsceneSkipHolding = false;
cutsceneSkipTriggered = false;
cutsceneSkipHintRemainingMs = 0;
cutsceneSkipHoldElapsedMs = 0;
return;
}
const int safeDeltaMs = max(deltaMs, 0);
if (cutsceneSkipHintVisible) {
cutsceneSkipHintRemainingMs -= safeDeltaMs;
if (cutsceneSkipHintRemainingMs <= 0) {
cutsceneSkipHintVisible = false;
cutsceneSkipArmed = false;
cutsceneSkipHolding = false;
cutsceneSkipHintRemainingMs = 0;
cutsceneSkipHoldElapsedMs = 0;
}
}
if (cutsceneSkipHolding && cutsceneSkipArmed) {
cutsceneSkipHoldElapsedMs += safeDeltaMs;
if (cutsceneSkipHoldElapsedMs >= CutsceneSkipHoldDurationMs) {
cutsceneSkipTriggered = true;
cutsceneSkipHolding = false;
cutsceneSkipHoldElapsedMs = CutsceneSkipHoldDurationMs;
}
}
}
void DialogueOverlay::draw(Renderer& renderer, const PresentationModel& model) {
if (model.mode == PresentationMode::Hidden) {
lastChoiceRects.clear();
lastDialogueAdvanceRect = {};
lastCutsceneAdvanceRect = {};
cutsceneSkipHintVisible = false;
cutsceneSkipArmed = false;
cutsceneSkipHolding = false;
cutsceneSkipHintRemainingMs = 0;
cutsceneSkipHoldElapsedMs = 0;
return;
}
if (model.mode == PresentationMode::Cutscene) {
drawCutscene(renderer, model);
}
else {
drawDialogue(renderer, model);
}
}
void DialogueOverlay::drawDialogue(Renderer& renderer, const PresentationModel& model) {
const float W = Environment::projectionWidth;
// const float H = Environment::projectionHeight;
const UiRect portraitRect{ 24.0f, 24.0f, 182.0f, 182.0f };
const UiRect textboxRect{ 220.0f, 24.0f, max(200.0f, W - 244.0f), 182.0f };
lastDialogueAdvanceRect = { portraitRect.x, portraitRect.y, textboxRect.x + textboxRect.w - portraitRect.x, textboxRect.h };
lastCutsceneAdvanceRect = {};
cutsceneSkipHintVisible = false;
cutsceneSkipArmed = false;
cutsceneSkipHolding = false;
cutsceneSkipHintRemainingMs = 0;
cutsceneSkipHoldElapsedMs = 0;
if (!portraitQuad.initialized || portraitQuad.rect.w != portraitRect.w || portraitQuad.rect.h != portraitRect.h ||
portraitQuad.rect.x != portraitRect.x || portraitQuad.rect.y != portraitRect.y) {
portraitQuad.rebuild(portraitRect);
}
if (!textboxQuad.initialized || textboxQuad.rect.w != textboxRect.w || textboxQuad.rect.h != textboxRect.h ||
textboxQuad.rect.x != textboxRect.x || textboxQuad.rect.y != textboxRect.y) {
textboxQuad.rebuild(textboxRect);
}
glEnable(GL_BLEND);
renderer.shaderManager.PushShader(defaultShaderName);
renderer.RenderUniform1i(textureUniformName, 0);
renderer.PushProjectionMatrix(0.0f, W, 0.0f, Environment::projectionHeight, -10.0f, 10.0f);
renderer.PushMatrix();
renderer.LoadIdentity();
drawQuad(renderer, textboxQuad, textboxTexture);
drawQuad(renderer, portraitQuad, model.portraitPath.empty() ? portraitFrameTexture : loadTextureCached(model.portraitPath));
drawQuad(renderer, portraitQuad, portraitFrameTexture);
renderer.PopMatrix();
renderer.PopProjectionMatrix();
renderer.shaderManager.PopShader();
const float nameX = textboxRect.x + 24.0f;
const float nameY = textboxRect.y + textboxRect.h - 38.0f;
const float bodyX = textboxRect.x + 24.0f;
const float bodyY = textboxRect.y + textboxRect.h - 78.0f;
if (!model.speaker.empty()) {
nameRenderer->drawText(model.speaker, nameX, nameY, 1.0f, false, { 1.0f, 0.88f, 0.45f, 1.0f });
}
const std::string wrappedBody = wrapText(model.visibleText, 56);
bodyRenderer->drawText(wrappedBody, bodyX, bodyY, 1.0f, false, { 1.0f, 1.0f, 1.0f, 1.0f });
lastChoiceRects.clear();
if (model.mode == PresentationMode::Choice) {
const float choiceStartY = textboxRect.y + 56.0f;
const float choiceHeight = 30.0f;
const float choiceSpacing = 8.0f;
const float choiceWidth = textboxRect.w - 48.0f;
if (choiceQuads.size() < model.choices.size()) {
choiceQuads.resize(model.choices.size());
}
renderer.shaderManager.PushShader(defaultShaderName);
renderer.RenderUniform1i(textureUniformName, 0);
renderer.PushProjectionMatrix(0.0f, W, 0.0f, Environment::projectionHeight, -10.0f, 10.0f);
renderer.PushMatrix();
renderer.LoadIdentity();
for (size_t i = 0; i < model.choices.size(); ++i) {
const float y = choiceStartY + (choiceHeight + choiceSpacing) * static_cast<float>(model.choices.size() - 1 - i);
UiRect rect{ textboxRect.x + 20.0f, y, choiceWidth, choiceHeight };
lastChoiceRects.push_back(rect);
choiceQuads[i].rebuild(rect);
const bool isHighlighted = static_cast<int>(i) == hoveredChoiceIndex || static_cast<int>(i) == model.selectedChoice;
std::shared_ptr<Texture> choiceTexture = (model.choices[i].kind == ChoiceKind::Optional) ? choiceOptionalTexture : choiceMainTexture;
if (isHighlighted) {
choiceTexture = choiceSelectedTexture;
}
drawQuad(renderer, choiceQuads[i], choiceTexture);
}
renderer.PopMatrix();
renderer.PopProjectionMatrix();
renderer.shaderManager.PopShader();
for (size_t i = 0; i < model.choices.size(); ++i) {
const UiRect& rect = lastChoiceRects[i];
const bool isHighlighted = static_cast<int>(i) == hoveredChoiceIndex || static_cast<int>(i) == model.selectedChoice;
const std::array<float, 4> color = (model.choices[i].kind == ChoiceKind::Optional)
? std::array<float, 4>{0.82f, 0.82f, 0.82f, 1.0f}
: std::array<float, 4>{ 1.0f, 0.93f, 0.65f, 1.0f };
choiceRenderer->drawText(
wrapText(model.choices[i].text, 52),
rect.x + 14.0f,
rect.y + 9.0f,
1.0f,
false,
isHighlighted ? std::array<float, 4>{1.0f, 1.0f, 1.0f, 1.0f} : color
);
}
}
glDisable(GL_BLEND);
}
float DialogueOverlay::lerpFloat(float a, float b, float t) {
return a + (b - a) * t;
}
DialogueOverlay::ResolvedViewport DialogueOverlay::resolveViewportPose(
const CutsceneCameraPose& pose,
float texW,
float texH,
float screenW,
float screenH
) {
ResolvedViewport out{};
const float safeTexW = max(texW, 1.0f);
const float safeTexH = max(texH, 1.0f);
const float safeScreenW = max(screenW, 1.0f);
const float safeScreenH = max(screenH, 1.0f);
const float screenAspect = safeScreenW / safeScreenH;
const float imageAspect = safeTexW / safeTexH;
float baseViewportW = 0.0f;
float baseViewportH = 0.0f;
if (imageAspect >= screenAspect) {
baseViewportH = safeTexH;
baseViewportW = safeTexH * screenAspect;
}
else {
baseViewportW = safeTexW;
baseViewportH = safeTexW / screenAspect;
}
const float zoom = max(pose.zoom, 0.01f);
const float viewportW = baseViewportW / zoom;
const float viewportH = baseViewportH / zoom;
const float rotationRad = pose.rotationDeg * 3.14159265358979323846f / 180.0f;
const float c = std::cos(rotationRad);
const float s = std::sin(rotationRad);
// Bounding box повернутого viewport внутри source image.
const float halfRotatedW = std::abs((viewportW * 0.5f) * c) + std::abs((viewportH * 0.5f) * s);
const float halfRotatedH = std::abs((viewportW * 0.5f) * s) + std::abs((viewportH * 0.5f) * c);
float centerX = safeTexW * 0.5f;
float centerY = safeTexH * 0.5f;
switch (pose.anchor) {
case CutsceneAnchor::TopLeft:
centerX = halfRotatedW;
centerY = safeTexH - halfRotatedH;
break;
case CutsceneAnchor::TopRight:
centerX = safeTexW - halfRotatedW;
centerY = safeTexH - halfRotatedH;
break;
case CutsceneAnchor::BottomRight:
centerX = safeTexW - halfRotatedW;
centerY = halfRotatedH;
break;
case CutsceneAnchor::BottomLeft:
centerX = halfRotatedW;
centerY = halfRotatedH;
break;
case CutsceneAnchor::Custom:
// centerY: 0 = top, 1 = bottom
centerX = std::clamp(pose.centerX, 0.0f, 1.0f) * safeTexW;
centerY = (1.0f - std::clamp(pose.centerY, 0.0f, 1.0f)) * safeTexH;
centerX = std::clamp(centerX, halfRotatedW, safeTexW - halfRotatedW);
centerY = std::clamp(centerY, halfRotatedH, safeTexH - halfRotatedH);
break;
case CutsceneAnchor::Center:
default:
centerX = safeTexW * 0.5f;
centerY = safeTexH * 0.5f;
break;
}
out.centerXPx = centerX;
out.centerYPx = centerY;
out.widthPx = viewportW;
out.heightPx = viewportH;
out.rotationDeg = pose.rotationDeg;
return out;
}
DialogueOverlay::ResolvedViewport DialogueOverlay::blendViewport(
const ResolvedViewport& from,
const ResolvedViewport& to,
float t
) {
ResolvedViewport out;
out.centerXPx = lerpFloat(from.centerXPx, to.centerXPx, t);
out.centerYPx = lerpFloat(from.centerYPx, to.centerYPx, t);
out.widthPx = lerpFloat(from.widthPx, to.widthPx, t);
out.heightPx = lerpFloat(from.heightPx, to.heightPx, t);
out.rotationDeg = lerpFloat(from.rotationDeg, to.rotationDeg, t);
return out;
}
void DialogueOverlay::drawCutscene(Renderer& renderer, const PresentationModel& model) {
const float W = Environment::projectionWidth;
const float H = Environment::projectionHeight;
const UiRect subtitleRect{ W * 0.12f, 22.0f, W * 0.76f, 110.0f };
lastDialogueAdvanceRect = {};
lastCutsceneAdvanceRect = subtitleRect;
glEnable(GL_BLEND);
renderer.shaderManager.PushShader("cutsceneFade");
renderer.RenderUniform1i(textureUniformName, 0);
renderer.PushProjectionMatrix(0.0f, W, 0.0f, H, -10.0f, 10.0f);
renderer.PushMatrix();
renderer.LoadIdentity();
const UiRect screenRect{ 0.0f, 0.0f, W, H };
std::vector<PresentedCutsceneImage> imageLayers = model.cutsceneImages;
if (imageLayers.empty() && !model.backgroundPath.empty()) {
imageLayers.push_back({ model.backgroundPath, 1.0f });
}
for (const PresentedCutsceneImage& layer : imageLayers) {
const auto texture = loadTextureCached(layer.path);
if (!texture) {
continue;
}
const float texW = static_cast<float>(texture->getWidth());
const float texH = static_cast<float>(texture->getHeight());
ResolvedViewport layerViewport{};
if (model.cutsceneCamera.active) {
const ResolvedViewport fromViewport = resolveViewportPose(model.cutsceneCamera.from, texW, texH, W, H);
const ResolvedViewport toViewport = resolveViewportPose(model.cutsceneCamera.to, texW, texH, W, H);
layerViewport = blendViewport(
fromViewport,
toViewport,
std::clamp(model.cutsceneCamera.t, 0.0f, 1.0f)
);
}
else {
layerViewport = resolveViewportPose(CutsceneCameraPose{}, texW, texH, W, H);
}
const float halfW = layerViewport.widthPx * 0.5f;
const float halfH = layerViewport.heightPx * 0.5f;
const float rotationRad = layerViewport.rotationDeg * 3.14159265358979323846f / 180.0f;
const float c = std::cos(rotationRad);
const float s = std::sin(rotationRad);
auto rotatePoint = [&](float x, float y) -> Eigen::Vector2f {
return {
layerViewport.centerXPx + x * c - y * s,
layerViewport.centerYPx + x * s + y * c
};
};
// Source viewport corners in image pixel space (origin = bottom-left)
const Eigen::Vector2f srcBL = rotatePoint(-halfW, -halfH);
const Eigen::Vector2f srcTL = rotatePoint(-halfW, +halfH);
const Eigen::Vector2f srcTR = rotatePoint(+halfW, +halfH);
const Eigen::Vector2f srcBR = rotatePoint(+halfW, -halfH);
auto toUV = [&](const Eigen::Vector2f& p) -> Eigen::Vector2f {
return {
std::clamp(p.x() / max(texW, 1.0f), 0.0f, 1.0f),
std::clamp(p.y() / max(texH, 1.0f), 0.0f, 1.0f)
};
};
backgroundQuad.rebuildWithUV(
screenRect,
toUV(srcBL),
toUV(srcTL),
toUV(srcTR),
toUV(srcBR)
);
renderer.RenderUniform1f("uAlpha", std::clamp(layer.alpha, 0.0f, 1.0f));
drawQuad(renderer, backgroundQuad, texture);
}
renderer.PopMatrix();
renderer.PopProjectionMatrix();
renderer.shaderManager.PopShader();
// UI quads over the image: subtitle panel and skip progress hint background.
renderer.shaderManager.PushShader(defaultShaderName);
renderer.RenderUniform1i(textureUniformName, 0);
renderer.PushProjectionMatrix(0.0f, W, 0.0f, H, -10.0f, 10.0f);
renderer.PushMatrix();
renderer.LoadIdentity();
if (model.showCutsceneSubtitle) {
subtitleQuad.rebuild(subtitleRect);
drawQuad(renderer, subtitleQuad, cutsceneSubtitleTexture);
}
if (model.cutsceneSkippable && cutsceneSkipHintVisible) {
const UiRect hintBg{ W - 250.0f, H - 62.0f, 226.0f, 42.0f };
skipHintBgQuad.rebuild(hintBg);
drawQuad(renderer, skipHintBgQuad, choiceOptionalTexture);
const UiRect progressBg{ W - 232.0f, H - 34.0f, 190.0f, 7.0f };
skipProgressBgQuad.rebuild(progressBg);
drawQuad(renderer, skipProgressBgQuad, choiceOptionalTexture);
if (cutsceneSkipHolding) {
const float progress = std::clamp(
static_cast<float>(cutsceneSkipHoldElapsedMs) / static_cast<float>(CutsceneSkipHoldDurationMs),
0.0f,
1.0f
);
const UiRect progressFill{ progressBg.x, progressBg.y, progressBg.w * progress, progressBg.h };
skipProgressFillQuad.rebuild(progressFill);
drawQuad(renderer, skipProgressFillQuad, choiceMainTexture);
}
}
renderer.PopMatrix();
renderer.PopProjectionMatrix();
renderer.shaderManager.PopShader();
if (model.showCutsceneSubtitle) {
if (!model.speaker.empty()) {
nameRenderer->drawText(
model.speaker,
subtitleRect.x + 24.0f,
subtitleRect.y + subtitleRect.h - 32.0f,
1.0f,
false,
{ 1.0f, 0.88f, 0.45f, 1.0f }
);
}
cutsceneRenderer->drawText(
wrapText(model.visibleText, 62),
subtitleRect.x + 24.0f,
subtitleRect.y + 30.0f,
1.0f,
false,
{ 1.0f, 1.0f, 1.0f, 1.0f }
);
}
if (model.cutsceneSkippable && cutsceneSkipHintVisible) {
choiceRenderer->drawText(
cutsceneSkipHolding ? "Hold to skip..." : "Hold LMB to skip",
W - 232.0f,
H - 50.0f,
0.85f,
false,
{ 1.0f, 1.0f, 1.0f, 0.95f }
);
}
glDisable(GL_BLEND);
}
bool DialogueOverlay::consumeSkipRequested() {
const bool result = cutsceneSkipTriggered;
cutsceneSkipTriggered = false;
if (result) {
cutsceneSkipHintVisible = false;
cutsceneSkipArmed = false;
cutsceneSkipHolding = false;
cutsceneSkipHintRemainingMs = 0;
cutsceneSkipHoldElapsedMs = 0;
}
return result;
}
void DialogueOverlay::handlePointerDown(float x, float y, const PresentationModel& model) {
(void)x;
(void)y;
if (model.mode == PresentationMode::Choice) {
handlePointerMoved(x, y, model);
return;
}
if (model.mode == PresentationMode::Cutscene && model.cutsceneSkippable) {
// First click/tap only arms skip and shows the hint for a short time.
// It does not immediately start skipping, to avoid accidental skip.
if (!cutsceneSkipArmed) {
cutsceneSkipHintVisible = true;
cutsceneSkipArmed = true;
cutsceneSkipHolding = false;
cutsceneSkipTriggered = false;
cutsceneSkipHintRemainingMs = CutsceneSkipHintDurationMs;
cutsceneSkipHoldElapsedMs = 0;
return;
}
// Once armed, holding anywhere on the screen starts skip progress.
cutsceneSkipHintVisible = true;
cutsceneSkipHintRemainingMs = CutsceneSkipHintDurationMs;
cutsceneSkipHolding = true;
cutsceneSkipTriggered = false;
cutsceneSkipHoldElapsedMs = 0;
return;
}
}
void DialogueOverlay::handlePointerMoved(float x, float y, const PresentationModel& model) {
if (model.mode == PresentationMode::Choice) {
hoveredChoiceIndex = -1;
for (size_t i = 0; i < lastChoiceRects.size(); ++i) {
if (rectContains(lastChoiceRects[i], x, y)) {
hoveredChoiceIndex = static_cast<int>(i);
break;
}
}
return;
}
hoveredChoiceIndex = -1;
}
bool DialogueOverlay::handlePointerReleased(float x, float y, const PresentationModel& model, int& outChoiceIndex, bool& outAdvanceDialogue) {
outChoiceIndex = -1;
outAdvanceDialogue = false;
if (model.mode == PresentationMode::Choice) {
for (size_t i = 0; i < lastChoiceRects.size(); ++i) {
if (rectContains(lastChoiceRects[i], x, y)) {
outChoiceIndex = static_cast<int>(i);
return true;
}
}
return false;
}
if (model.mode == PresentationMode::Dialogue) {
outAdvanceDialogue = rectContains(lastDialogueAdvanceRect, x, y);
return outAdvanceDialogue;
}
if (model.mode == PresentationMode::Cutscene) {
if (cutsceneSkipHolding && cutsceneSkipHoldElapsedMs < CutsceneSkipHoldDurationMs) {
cutsceneSkipHolding = false;
cutsceneSkipHoldElapsedMs = 0;
}
return true;
}
return false;
}
std::shared_ptr<Texture> DialogueOverlay::loadTextureCached(const std::string& path) {
if (path.empty()) {
return nullptr;
}
auto it = textureCache.find(path);
if (it != textureCache.end()) {
return it->second;
}
auto texture = std::make_shared<Texture>(CreateTextureDataFromPng(path, zipFilename));
textureCache[path] = texture;
return texture;
}
void DialogueOverlay::drawQuad(Renderer& renderer, const TexturedQuad& quad, const std::shared_ptr<Texture>& texture) const {
if (!texture) {
return;
}
glBindTexture(GL_TEXTURE_2D, texture->getTexID());
renderer.DrawVertexRenderStruct(quad.mesh);
}
std::string DialogueOverlay::wrapText(const std::string& input, size_t maxLineLength) {
if (input.size() <= maxLineLength) {
return input;
}
std::string output;
size_t lineStart = 0;
while (lineStart < input.size()) {
size_t lineEnd = min(lineStart + maxLineLength, input.size());
if (lineEnd < input.size()) {
const size_t split = input.rfind(' ', lineEnd);
if (split != std::string::npos && split > lineStart) {
lineEnd = split;
}
}
output.append(input.substr(lineStart, lineEnd - lineStart));
if (lineEnd < input.size()) {
output.push_back('\n');
lineStart = lineEnd + (input[lineEnd] == ' ' ? 1 : 0);
}
else {
lineStart = lineEnd;
}
}
return output;
}
bool DialogueOverlay::rectContains(const UiRect& rect, float x, float y) {
return x >= rect.x && x <= rect.x + rect.w && y >= rect.y && y <= rect.y + rect.h;
}
} // namespace ZL::Dialogue