#include "cutscene/CutsceneOverlay.h" #include "dialogue/DialogueTypes.h" #include "Environment.h" #include "GameConstants.h" #include #include namespace ZL::Cutscene { bool CutsceneOverlay::init(Renderer& renderer, const std::string& zipFile) { rendererRef = &renderer; zipFilename = zipFile; choiceMainTexture = renderer.textureManager.LoadFromPng("resources/dialogue/choice_main.png", zipFile); choiceOptionalTexture = renderer.textureManager.LoadFromPng("resources/dialogue/choice_optional.png", zipFile); cutsceneSubtitleTexture = renderer.textureManager.LoadFromPng("resources/dialogue/cutscene_subtitle_bg.png", zipFile); nameRenderer = std::make_unique(); cutsceneRenderer = std::make_unique(); choiceRenderer = std::make_unique(); return nameRenderer->init(renderer, "resources/fonts/DroidSans.ttf", 28, zipFile) && cutsceneRenderer->init(renderer, "resources/fonts/DroidSans.ttf", 24, zipFile) && choiceRenderer->init(renderer, "resources/fonts/DroidSans.ttf", 22, zipFile); } void CutsceneOverlay::update(const ZL::Dialogue::PresentationModel& model, int deltaMs) { if (model.mode != ZL::Dialogue::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 CutsceneOverlay::buildImageUV( const CutsceneImagePose& pose, float imgW, float imgH, float screenW, float screenH, Eigen::Vector2f& outBL, Eigen::Vector2f& outTL, Eigen::Vector2f& outTR, Eigen::Vector2f& outBR) { const float safeImgW = max(imgW, 1.0f); const float safeImgH = max(imgH, 1.0f); const float safeScrnW = max(screenW, 1.0f); const float safeScrnH = max(screenH, 1.0f); const float screenAspect = safeScrnW / safeScrnH; const float imageAspect = safeImgW / safeImgH; // Aspect-ratio corrected base viewport at scale = 1: the portion of the image // that fills the screen without stretching. float baseW, baseH; if (imageAspect >= screenAspect) { baseH = safeImgH; baseW = safeImgH * screenAspect; } else { baseW = safeImgW; baseH = safeImgW / screenAspect; } const float scale = max(pose.scale, 0.01f); const float viewportW = baseW / scale; const float viewportH = baseH / scale; const float halfW = viewportW * 0.5f; const float halfH = viewportH * 0.5f; // Map normalized pose center to image pixel coords; clamp so viewport stays inside image. const float rawCX = std::clamp(pose.centerX, 0.0f, 1.0f) * safeImgW; const float rawCY = std::clamp(pose.centerY, 0.0f, 1.0f) * safeImgH; const float cx = std::clamp(rawCX, halfW, safeImgW - halfW); const float cy = std::clamp(rawCY, halfH, safeImgH - halfH); // Viewport corners in image pixel space, then normalized to UV [0..1]. outBL = { (cx - halfW) / safeImgW, (cy - halfH) / safeImgH }; outTL = { (cx - halfW) / safeImgW, (cy + halfH) / safeImgH }; outTR = { (cx + halfW) / safeImgW, (cy + halfH) / safeImgH }; outBR = { (cx + halfW) / safeImgW, (cy - halfH) / safeImgH }; } void CutsceneOverlay::draw(Renderer& renderer, const ZL::Dialogue::PresentationModel& model) { if (model.mode != ZL::Dialogue::PresentationMode::Cutscene) return; const float W = Environment::projectionWidth; const float H = Environment::projectionHeight; const UiRect subtitleRect{ W * 0.12f, 22.0f, W * 0.76f, 110.0f }; lastCutsceneAdvanceRect = subtitleRect; glEnable(GL_BLEND); // --- Image layers --- 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 }; for (const ZL::Cutscene::PresentedCutsceneImage& layer : model.cutsceneImages) { const auto texture = loadTextureCached(layer.path); if (!texture) continue; const float imgW = (layer.width > 0) ? static_cast(layer.width) : static_cast(texture->getWidth()); const float imgH = (layer.height > 0) ? static_cast(layer.height) : static_cast(texture->getHeight()); Eigen::Vector2f uvBL, uvTL, uvTR, uvBR; buildImageUV(layer.pose, imgW, imgH, W, H, uvBL, uvTL, uvTR, uvBR); backgroundQuad.rebuildWithUV(screenRect, uvBL, uvTL, uvTR, uvBR); renderer.RenderUniform1f("uAlpha", std::clamp(layer.alpha * model.cutsceneGlobalFadeAlpha, 0.0f, 1.0f)); glBindTexture(GL_TEXTURE_2D, texture->getTexID()); renderer.DrawVertexRenderStruct(backgroundQuad.mesh); } renderer.PopMatrix(); renderer.PopProjectionMatrix(); renderer.shaderManager.PopShader(); // --- Black fade overlay --- if (model.cutsceneBlackAlpha > 0.001f) { renderer.shaderManager.PushShader("cutsceneBlack"); renderer.PushProjectionMatrix(0.0f, W, 0.0f, H, -10.0f, 10.0f); renderer.PushMatrix(); renderer.LoadIdentity(); backgroundQuad.rebuild(screenRect); renderer.RenderUniform1f("uAlpha", model.cutsceneBlackAlpha); renderer.DrawVertexRenderStruct(backgroundQuad.mesh); renderer.PopMatrix(); renderer.PopProjectionMatrix(); renderer.shaderManager.PopShader(); } // --- UI overlay: subtitle panel and skip hint --- 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); glBindTexture(GL_TEXTURE_2D, cutsceneSubtitleTexture->getTexID()); renderer.DrawVertexRenderStruct(subtitleQuad.mesh); } if (model.cutsceneSkippable && cutsceneSkipHintVisible) { const UiRect hintBg{ W - 250.0f, H - 62.0f, 226.0f, 42.0f }; skipHintBgQuad.rebuild(hintBg); glBindTexture(GL_TEXTURE_2D, choiceOptionalTexture->getTexID()); renderer.DrawVertexRenderStruct(skipHintBgQuad.mesh); const UiRect progressBg{ W - 232.0f, H - 34.0f, 190.0f, 7.0f }; skipProgressBgQuad.rebuild(progressBg); renderer.DrawVertexRenderStruct(skipProgressBgQuad.mesh); 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); glBindTexture(GL_TEXTURE_2D, choiceMainTexture->getTexID()); renderer.DrawVertexRenderStruct(skipProgressFillQuad.mesh); } } renderer.PopMatrix(); renderer.PopProjectionMatrix(); renderer.shaderManager.PopShader(); // --- Text --- if (model.showCutsceneSubtitle) { const std::string wrapped = wrapTextToWidth(model.visibleText, *cutsceneRenderer, subtitleRect.w - 48.0f, 1.0f); 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(wrapped, subtitleRect.x + 24.0f, subtitleRect.y + subtitleRect.h - 60.0f, 1.0f, false, { 1.0f, 1.0f, 1.0f, 1.0f }); } else { cutsceneRenderer->drawText(wrapped, subtitleRect.x + 24.0f, subtitleRect.y + subtitleRect.h - 32.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 CutsceneOverlay::consumeSkipRequested() { const bool result = cutsceneSkipTriggered; cutsceneSkipTriggered = false; if (result) { cutsceneSkipHintVisible = false; cutsceneSkipArmed = false; cutsceneSkipHolding = false; cutsceneSkipHintRemainingMs = 0; cutsceneSkipHoldElapsedMs = 0; } return result; } void CutsceneOverlay::handlePointerDown(float x, float y, const ZL::Dialogue::PresentationModel& model) { (void)x; (void)y; if (model.mode != ZL::Dialogue::PresentationMode::Cutscene || !model.cutsceneSkippable) return; if (!cutsceneSkipArmed) { cutsceneSkipHintVisible = true; cutsceneSkipArmed = true; cutsceneSkipHolding = false; cutsceneSkipTriggered = false; cutsceneSkipHintRemainingMs = CutsceneSkipHintDurationMs; cutsceneSkipHoldElapsedMs = 0; return; } cutsceneSkipHintVisible = true; cutsceneSkipHintRemainingMs = CutsceneSkipHintDurationMs; cutsceneSkipHolding = true; cutsceneSkipTriggered = false; cutsceneSkipHoldElapsedMs = 0; } void CutsceneOverlay::handlePointerMoved(float /*x*/, float /*y*/, const ZL::Dialogue::PresentationModel& /*model*/) { } bool CutsceneOverlay::handlePointerReleased(float /*x*/, float /*y*/, const ZL::Dialogue::PresentationModel& model) { if (model.mode != ZL::Dialogue::PresentationMode::Cutscene) return false; if (cutsceneSkipHolding && cutsceneSkipHoldElapsedMs < CutsceneSkipHoldDurationMs) { cutsceneSkipHolding = false; cutsceneSkipHoldElapsedMs = 0; } return true; } std::shared_ptr CutsceneOverlay::loadTextureCached(const std::string& path) { if (path.empty()) return nullptr; return rendererRef->textureManager.LoadFromPng(path, zipFilename); } std::string CutsceneOverlay::wrapTextToWidth( const std::string& input, const TextRenderer& textRenderer, float maxWidthPx, float scale) { 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 (const char ch : input) { 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 output; } } // namespace ZL::Cutscene