#include "dialogue/DialogueOverlay.h" #include "dialogue/DialogueTypes.h" #include "GameConstants.h" #include "Environment.h" #include #include #include 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(CreateTextureDataFromPng("resources/dialogue/textbox_bg.png", zipFile)); portraitFrameTexture = std::make_shared(CreateTextureDataFromPng("resources/dialogue/portrait_frame.png", zipFile)); choiceMainTexture = std::make_shared(CreateTextureDataFromPng("resources/dialogue/choice_main.png", zipFile)); choiceOptionalTexture = std::make_shared(CreateTextureDataFromPng("resources/dialogue/choice_optional.png", zipFile)); choiceSelectedTexture = std::make_shared(CreateTextureDataFromPng("resources/dialogue/choice_selected.png", zipFile)); cutsceneSubtitleTexture = std::make_shared(CreateTextureDataFromPng("resources/dialogue/cutscene_subtitle_bg.png", zipFile)); nameRenderer = std::make_unique(); bodyRenderer = std::make_unique(); choiceRenderer = std::make_unique(); cutsceneRenderer = std::make_unique(); 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(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(i) == hoveredChoiceIndex || static_cast(i) == model.selectedChoice; std::shared_ptr 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(i) == hoveredChoiceIndex || static_cast(i) == model.selectedChoice; const std::array color = (model.choices[i].kind == ChoiceKind::Optional) ? std::array{0.82f, 0.82f, 0.82f, 1.0f} : std::array{ 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{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 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(texture->getWidth()); const float texH = static_cast(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(cutsceneSkipHoldElapsedMs) / static_cast(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(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(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 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(CreateTextureDataFromPng(path, zipFilename)); textureCache[path] = texture; return texture; } void DialogueOverlay::drawQuad(Renderer& renderer, const TexturedQuad& quad, const std::shared_ptr& 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