332 lines
11 KiB
C++
332 lines
11 KiB
C++
#include "cutscene/CutsceneOverlay.h"
|
|
#include "dialogue/DialogueTypes.h"
|
|
|
|
#include "Environment.h"
|
|
#include "GameConstants.h"
|
|
#include <algorithm>
|
|
#include <cmath>
|
|
|
|
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<TextRenderer>();
|
|
cutsceneRenderer = std::make_unique<TextRenderer>();
|
|
choiceRenderer = std::make_unique<TextRenderer>();
|
|
|
|
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<float>(layer.width) : static_cast<float>(texture->getWidth());
|
|
const float imgH = (layer.height > 0) ? static_cast<float>(layer.height) : static_cast<float>(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<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);
|
|
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<Texture> 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
|