#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::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); 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 ZL::Cutscene::PresentedCutsceneImage& layer : imageLayers) { const auto texture = loadTextureCached(layer.path); if (!texture) continue; const float imgW = (model.backgroundWidth > 0) ? static_cast(model.backgroundWidth) : static_cast(texture->getWidth()); const float imgH = (model.backgroundHeight > 0) ? static_cast(model.backgroundHeight) : static_cast(texture->getHeight()); ResolvedViewport layerViewport{}; if (model.cutsceneCamera.active) { const ResolvedViewport fromVP = resolveViewportPose(model.cutsceneCamera.from, imgW, imgH, W, H); const ResolvedViewport toVP = resolveViewportPose(model.cutsceneCamera.to, imgW, imgH, W, H); layerViewport = blendViewport(fromVP, toVP, std::clamp(model.cutsceneCamera.t, 0.0f, 1.0f)); } else { layerViewport = resolveViewportPose(ZL::Cutscene::CutsceneCameraPose{}, imgW, imgH, W, H); } const float halfW = layerViewport.widthPx * 0.5f; const float halfH = layerViewport.heightPx * 0.5f; const float rotRad = layerViewport.rotationDeg * 3.14159265358979323846f / 180.0f; const float c = std::cos(rotRad); const float s = std::sin(rotRad); auto rotatePoint = [&](float x, float y) -> Eigen::Vector2f { return { layerViewport.centerXPx + x * c - y * s, layerViewport.centerYPx + x * s + y * c }; }; 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(imgW, 1.0f), 0.0f, 1.0f), std::clamp(p.y() / max(imgH, 1.0f), 0.0f, 1.0f) }; }; backgroundQuad.rebuildWithUV(screenRect, toUV(srcBL), toUV(srcTL), toUV(srcTR), toUV(srcBR)); 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(); 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(); } 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(); 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 } ); } const std::string wrapped = wrapTextToWidth(model.visibleText, *cutsceneRenderer, subtitleRect.w - 48.0f, 1.0f); cutsceneRenderer->drawText(wrapped, 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 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); } float CutsceneOverlay::lerpFloat(float a, float b, float t) { return a + (b - a) * t; } CutsceneOverlay::ResolvedViewport CutsceneOverlay::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 rotRad = pose.rotationDeg * 3.14159265358979323846f / 180.0f; const float c = std::cos(rotRad); const float s = std::sin(rotRad); 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: 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; } CutsceneOverlay::ResolvedViewport CutsceneOverlay::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; } 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 (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 output; } } // namespace ZL::Cutscene