refactor: switch cutscene camera to viewport/UV-based system

This commit is contained in:
vottozi 2026-04-16 01:29:01 +06:00
parent ceebb13719
commit 2494a44e4a
10 changed files with 407 additions and 100 deletions

View File

@ -171,6 +171,22 @@
"type": "End" "type": "End"
} }
] ]
},
{
"id": "test_cutscene_pan_dialogue_silent",
"start": "cutscene_start",
"nodes": [
{
"id": "cutscene_start",
"type": "CutsceneStart",
"cutsceneId": "test_cutscene_pan_02",
"next": "end_1"
},
{
"id": "end_1",
"type": "End"
}
]
} }
], ],
"cutscenes": [ "cutscenes": [
@ -240,55 +256,93 @@
"cameraTrack": [ "cameraTrack": [
{ {
"durationMs": 1200, "durationMs": 1200,
"from": { "focusX": 0.50, "focusY": 0.50, "zoom": 1.00, "rotationDeg": 0.0 }, "from": { "anchor": "Center", "zoom": 1.00, "rotationDeg": 0.0 },
"to": { "focusX": 0.50, "focusY": 0.50, "zoom": 1.00, "rotationDeg": 0.0 }, "to": { "anchor": "Center", "zoom": 1.00, "rotationDeg": 0.0 },
"easing": "Linear" "easing": "Linear"
}, },
{ {
"durationMs": 2500, "durationMs": 2500,
"from": { "focusX": 0.50, "focusY": 0.50, "zoom": 1.00, "rotationDeg": 0.0 }, "from": { "anchor": "Center", "zoom": 1.00, "rotationDeg": 0.0 },
"to": { "focusX": 0.18, "focusY": 0.18, "zoom": 1.45, "rotationDeg": 0.0 }, "to": { "anchor": "TopLeft", "zoom": 1.55, "rotationDeg": 0.0 },
"easing": "EaseInOutSine" "easing": "EaseInOutSine"
}, },
{ {
"durationMs": 2600, "durationMs": 2600,
"from": { "focusX": 0.18, "focusY": 0.18, "zoom": 1.45, "rotationDeg": 0.0 }, "from": { "anchor": "TopLeft", "zoom": 1.55, "rotationDeg": 0.0 },
"to": { "focusX": 0.82, "focusY": 0.18, "zoom": 1.48, "rotationDeg": 0.0 }, "to": { "anchor": "TopRight", "zoom": 1.55, "rotationDeg": 0.0 },
"easing": "EaseInOutSine" "easing": "EaseInOutSine"
}, },
{ {
"durationMs": 1800, "durationMs": 1800,
"from": { "focusX": 0.82, "focusY": 0.18, "zoom": 1.48, "rotationDeg": 0.0 }, "from": { "anchor": "TopRight", "zoom": 1.55, "rotationDeg": 0.0 },
"to": { "focusX": 0.84, "focusY": 0.82, "zoom": 1.62, "rotationDeg": 0.0 }, "to": { "anchor": "BottomRight", "zoom": 1.72, "rotationDeg": 0.0 },
"easing": "EaseInCubic" "easing": "EaseInCubic"
}, },
{ {
"durationMs": 3900, "durationMs": 3900,
"from": { "focusX": 0.84, "focusY": 0.82, "zoom": 1.62, "rotationDeg": 0.0 }, "from": { "anchor": "BottomRight", "zoom": 1.72, "rotationDeg": 0.0 },
"to": { "focusX": 0.16, "focusY": 0.84, "zoom": 1.35, "rotationDeg": 0.0 }, "to": { "anchor": "BottomLeft", "zoom": 1.55, "rotationDeg": 0.0 },
"easing": "EaseInOutSine" "easing": "EaseInOutSine"
} }
], ],
"lines": [ "lines": [
{ {
"speaker": "Narrator", "speaker": "Narrator",
"portrait": "", "portrait": "resources/hero.png",
"text": "The memory begins in silence.", "text": "The memory begins in silence.",
"durationMs": 2200 "durationMs": 2200
}, },
{ {
"speaker": "Narrator", "speaker": "Narrator",
"portrait": "", "portrait": "resources/hero.png",
"text": "Something is drawing your eyes across the whole scene.", "text": "Something is drawing your eyes across the whole scene.",
"durationMs": 2800 "durationMs": 2800
}, },
{ {
"speaker": "Ghost", "speaker": "Ghost",
"portrait": "resources/w/ghost_skin001.png", "portrait": "resources/ghost_avatar.png",
"text": "Do not look away.", "text": "Do not look away.",
"durationMs": 2400 "durationMs": 2400
} }
] ]
},
{
"id": "test_cutscene_pan_02",
"background": "resources/first_cutscene.png",
"durationMs": 12000,
"cameraTrack": [
{
"durationMs": 1200,
"from": { "anchor": "Center", "zoom": 1.00, "rotationDeg": 0.0 },
"to": { "anchor": "Center", "zoom": 1.00, "rotationDeg": 0.0 },
"easing": "Linear"
},
{
"durationMs": 2500,
"from": { "anchor": "Center", "zoom": 1.00, "rotationDeg": 0.0 },
"to": { "anchor": "TopLeft", "zoom": 1.55, "rotationDeg": 0.0 },
"easing": "EaseInOutSine"
},
{
"durationMs": 2600,
"from": { "anchor": "TopLeft", "zoom": 1.55, "rotationDeg": 0.0 },
"to": { "anchor": "TopRight", "zoom": 1.55, "rotationDeg": 0.0 },
"easing": "EaseInOutSine"
},
{
"durationMs": 1800,
"from": { "anchor": "TopRight", "zoom": 1.55, "rotationDeg": 0.0 },
"to": { "anchor": "BottomRight", "zoom": 1.72, "rotationDeg": 0.0 },
"easing": "EaseInCubic"
},
{
"durationMs": 3900,
"from": { "anchor": "BottomRight", "zoom": 1.72, "rotationDeg": 0.0 },
"to": { "anchor": "BottomLeft", "zoom": 1.55, "rotationDeg": 0.0 },
"easing": "EaseInOutSine"
}
],
"lines": []
} }
] ]
} }

BIN
resources/hero.png (Stored with Git LFS) Normal file

Binary file not shown.

View File

@ -732,7 +732,7 @@ namespace ZL
if (event.type == SDL_KEYDOWN && event.key.repeat == 0) { if (event.type == SDL_KEYDOWN && event.key.repeat == 0) {
switch (event.key.keysym.sym) { switch (event.key.keysym.sym) {
case SDLK_f: case SDLK_f:
dialogueSystem.startDialogue("test_choice_dialogue"); dialogueSystem.startDialogue("test_cutscene_pan_dialogue_silent");
break; break;
case SDLK_e: case SDLK_e:

View File

@ -42,6 +42,15 @@ EasingType DialogueDatabase::parseEasingType(const std::string& value) {
return EasingType::Linear; return EasingType::Linear;
} }
CutsceneAnchor DialogueDatabase::parseCutsceneAnchor(const std::string& value) {
if (value == "TopLeft") return CutsceneAnchor::TopLeft;
if (value == "TopRight") return CutsceneAnchor::TopRight;
if (value == "BottomRight") return CutsceneAnchor::BottomRight;
if (value == "BottomLeft") return CutsceneAnchor::BottomLeft;
if (value == "Custom") return CutsceneAnchor::Custom;
return CutsceneAnchor::Center;
}
Condition DialogueDatabase::parseCondition(const json& j) { Condition DialogueDatabase::parseCondition(const json& j) {
Condition c; Condition c;
c.flag = j.value("flag", ""); c.flag = j.value("flag", "");
@ -141,8 +150,9 @@ CutsceneLine DialogueDatabase::parseCutsceneLine(const json& j) {
CutsceneCameraPose DialogueDatabase::parseCutsceneCameraPose(const json& j) { CutsceneCameraPose DialogueDatabase::parseCutsceneCameraPose(const json& j) {
CutsceneCameraPose pose; CutsceneCameraPose pose;
pose.focusX = j.value("focusX", 0.5f); pose.anchor = parseCutsceneAnchor(j.value("anchor", "Center"));
pose.focusY = j.value("focusY", 0.5f); pose.centerX = j.value("centerX", 0.5f);
pose.centerY = j.value("centerY", 0.5f);
pose.zoom = j.value("zoom", 1.0f); pose.zoom = j.value("zoom", 1.0f);
pose.rotationDeg = j.value("rotationDeg", 0.0f); pose.rotationDeg = j.value("rotationDeg", 0.0f);
return pose; return pose;

View File

@ -24,6 +24,7 @@ private:
static ChoiceKind parseChoiceKind(const std::string& value); static ChoiceKind parseChoiceKind(const std::string& value);
static ComparisonOp parseComparisonOp(const std::string& value); static ComparisonOp parseComparisonOp(const std::string& value);
static EasingType parseEasingType(const std::string& value); static EasingType parseEasingType(const std::string& value);
static CutsceneAnchor parseCutsceneAnchor(const std::string& value);
static Condition parseCondition(const json& j); static Condition parseCondition(const json& j);
static Effect parseEffect(const json& j); static Effect parseEffect(const json& j);

View File

@ -20,6 +20,45 @@ void DialogueOverlay::TexturedQuad::rebuild(const UiRect& newRect) {
initialized = true; 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) { bool DialogueOverlay::init(Renderer& renderer, const std::string& zipFile) {
rendererRef = &renderer; rendererRef = &renderer;
zipFilename = zipFile; zipFilename = zipFile;
@ -167,10 +206,112 @@ void DialogueOverlay::drawDialogue(Renderer& renderer, const PresentationModel&
glDisable(GL_BLEND); 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) { void DialogueOverlay::drawCutscene(Renderer& renderer, const PresentationModel& model) {
const float W = Environment::projectionWidth; const float W = Environment::projectionWidth;
const float H = Environment::projectionHeight; const float H = Environment::projectionHeight;
const UiRect subtitleRect{ W * 0.12f, 22.0f, W * 0.76f, 110.0f }; const UiRect subtitleRect{ W * 0.12f, 22.0f, W * 0.76f, 110.0f };
lastDialogueAdvanceRect = {}; lastDialogueAdvanceRect = {};
lastCutsceneAdvanceRect = subtitleRect; lastCutsceneAdvanceRect = subtitleRect;
cutsceneAdvanceEnabled = model.showCutsceneSubtitle; cutsceneAdvanceEnabled = model.showCutsceneSubtitle;
@ -187,24 +328,60 @@ void DialogueOverlay::drawCutscene(Renderer& renderer, const PresentationModel&
if (bgTexture) { if (bgTexture) {
const float texW = static_cast<float>(bgTexture->getWidth()); const float texW = static_cast<float>(bgTexture->getWidth());
const float texH = static_cast<float>(bgTexture->getHeight()); const float texH = static_cast<float>(bgTexture->getHeight());
const float baseScale = max(W / max(texW, 1.0f), H / max(texH, 1.0f));
const float zoom = max(model.cutsceneCamera.zoom, 0.01f);
const float drawW = texW * baseScale * zoom;
const float drawH = texH * baseScale * zoom;
const float focusX = std::clamp(model.cutsceneCamera.focusX, 0.0f, 1.0f);
const float focusY = std::clamp(model.cutsceneCamera.focusY, 0.0f, 1.0f);
const float localFocusX = -drawW * 0.5f + drawW * focusX;
const float localFocusY = -drawH * 0.5f + drawH * focusY;
const float rotationRad = model.cutsceneCamera.rotationDeg * 3.14159265358979323846f / 180.0f;
const UiRect backgroundRect{ -drawW * 0.5f, -drawH * 0.5f, drawW, drawH }; ResolvedViewport currentViewport{};
backgroundQuad.rebuild(backgroundRect);
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);
currentViewport = blendViewport(
fromViewport,
toViewport,
std::clamp(model.cutsceneCamera.t, 0.0f, 1.0f)
);
}
else {
currentViewport = resolveViewportPose(CutsceneCameraPose{}, texW, texH, W, H);
}
const float halfW = currentViewport.widthPx * 0.5f;
const float halfH = currentViewport.heightPx * 0.5f;
const float rotationRad = currentViewport.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 {
currentViewport.centerXPx + x * c - y * s,
currentViewport.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)
};
};
const UiRect screenRect{ 0.0f, 0.0f, W, H };
backgroundQuad.rebuildWithUV(
screenRect,
toUV(srcBL),
toUV(srcTL),
toUV(srcTR),
toUV(srcBR)
);
renderer.TranslateMatrix({ W * 0.5f, H * 0.5f, 0.0f });
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(rotationRad, Eigen::Vector3f::UnitZ())));
renderer.TranslateMatrix({ -localFocusX, -localFocusY, 0.0f });
drawQuad(renderer, backgroundQuad, bgTexture); drawQuad(renderer, backgroundQuad, bgTexture);
renderer.LoadIdentity();
} }
if (model.showCutsceneSubtitle) { if (model.showCutsceneSubtitle) {
@ -218,7 +395,14 @@ void DialogueOverlay::drawCutscene(Renderer& renderer, const PresentationModel&
if (model.showCutsceneSubtitle) { if (model.showCutsceneSubtitle) {
if (!model.speaker.empty()) { 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 }); 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( cutsceneRenderer->drawText(
wrapText(model.visibleText, 62), wrapText(model.visibleText, 62),

View File

@ -28,6 +28,21 @@ private:
bool initialized = false; bool initialized = false;
void rebuild(const UiRect& newRect); void rebuild(const UiRect& newRect);
void rebuildWithUV(
const UiRect& newRect,
const Eigen::Vector2f& uvBottomLeft,
const Eigen::Vector2f& uvTopLeft,
const Eigen::Vector2f& uvTopRight,
const Eigen::Vector2f& uvBottomRight
);
};
struct ResolvedViewport {
float centerXPx = 0.0f;
float centerYPx = 0.0f;
float widthPx = 1.0f;
float heightPx = 1.0f;
float rotationDeg = 0.0f;
}; };
Renderer* rendererRef = nullptr; Renderer* rendererRef = nullptr;
@ -65,6 +80,20 @@ private:
void drawQuad(Renderer& renderer, const TexturedQuad& quad, const std::shared_ptr<Texture>& texture) const; void drawQuad(Renderer& renderer, const TexturedQuad& quad, const std::shared_ptr<Texture>& texture) const;
static std::string wrapText(const std::string& input, size_t maxLineLength); static std::string wrapText(const std::string& input, size_t maxLineLength);
static float lerpFloat(float a, float b, float t);
static ResolvedViewport resolveViewportPose(
const CutsceneCameraPose& pose,
float texW,
float texH,
float screenW,
float screenH
);
static ResolvedViewport blendViewport(
const ResolvedViewport& from,
const ResolvedViewport& to,
float t
);
}; };
} // namespace ZL::Dialogue } // namespace ZL::Dialogue

View File

@ -439,29 +439,39 @@ void DialogueRuntime::advanceCutsceneLine() {
refreshCutscenePresentation(); refreshCutscenePresentation();
} }
CutsceneCameraPose DialogueRuntime::evaluateCutsceneCameraPose() const { CutsceneCameraBlendState DialogueRuntime::evaluateCutsceneCameraBlend() const {
CutsceneCameraPose defaultPose{}; CutsceneCameraBlendState result;
result.active = false;
result.from = {};
result.to = {};
result.t = 1.0f;
if (!activeCutscene || activeCutscene->cameraTrack.empty()) { if (!activeCutscene || activeCutscene->cameraTrack.empty()) {
return defaultPose; return result;
} }
int elapsed = cutsceneElapsedMs; int elapsed = cutsceneElapsedMs;
for (const CutsceneCameraSegment& segment : activeCutscene->cameraTrack) { for (const CutsceneCameraSegment& segment : activeCutscene->cameraTrack) {
const int durationMs = std::max(segment.durationMs, 1); const int durationMs = std::max(segment.durationMs, 1);
if (elapsed <= durationMs) { if (elapsed <= durationMs) {
const float rawT = static_cast<float>(elapsed) / static_cast<float>(durationMs); result.active = true;
const float t = applyEasing(segment.easing, std::clamp(rawT, 0.0f, 1.0f)); result.from = segment.from;
CutsceneCameraPose pose; result.to = segment.to;
pose.focusX = segment.from.focusX + (segment.to.focusX - segment.from.focusX) * t; result.t = applyEasing(
pose.focusY = segment.from.focusY + (segment.to.focusY - segment.from.focusY) * t; segment.easing,
pose.zoom = segment.from.zoom + (segment.to.zoom - segment.from.zoom) * t; std::clamp(static_cast<float>(elapsed) / static_cast<float>(durationMs), 0.0f, 1.0f)
pose.rotationDeg = segment.from.rotationDeg + (segment.to.rotationDeg - segment.from.rotationDeg) * t; );
return pose; return result;
} }
elapsed -= durationMs; elapsed -= durationMs;
} }
return activeCutscene->cameraTrack.back().to; result.active = true;
result.from = activeCutscene->cameraTrack.back().to;
result.to = activeCutscene->cameraTrack.back().to;
result.t = 1.0f;
return result;
} }
void DialogueRuntime::refreshCutscenePresentation() { void DialogueRuntime::refreshCutscenePresentation() {
@ -471,15 +481,7 @@ void DialogueRuntime::refreshCutscenePresentation() {
presentation.mode = PresentationMode::Cutscene; presentation.mode = PresentationMode::Cutscene;
presentation.backgroundPath = activeCutscene->background; presentation.backgroundPath = activeCutscene->background;
presentation.cutsceneCamera = evaluateCutsceneCameraPose(); presentation.cutsceneCamera = evaluateCutsceneCameraBlend();
std::cout << "[CUTSCENE] pose focus=("
<< presentation.cutsceneCamera.focusX << ", "
<< presentation.cutsceneCamera.focusY << ") zoom="
<< presentation.cutsceneCamera.zoom
<< " rot=" << presentation.cutsceneCamera.rotationDeg
<< " line=" << currentCutsceneLine
<< std::endl;
presentation.choices.clear(); presentation.choices.clear();
presentation.selectedChoice = 0; presentation.selectedChoice = 0;

View File

@ -77,7 +77,7 @@ private:
void advanceCutsceneLine(); void advanceCutsceneLine();
void refreshCutscenePresentation(); void refreshCutscenePresentation();
CutsceneCameraPose evaluateCutsceneCameraPose() const; CutsceneCameraBlendState evaluateCutsceneCameraBlend() const;
static float applyEasing(EasingType easing, float t); static float applyEasing(EasingType easing, float t);
static int computeFallbackCutsceneDurationMs(const std::string& text); static int computeFallbackCutsceneDurationMs(const std::string& text);

View File

@ -44,6 +44,15 @@ enum class EasingType {
EaseInOutCubic EaseInOutCubic
}; };
enum class CutsceneAnchor {
Center,
TopLeft,
TopRight,
BottomRight,
BottomLeft,
Custom
};
struct Condition { struct Condition {
std::string flag; std::string flag;
ComparisonOp op = ComparisonOp::Exists; ComparisonOp op = ComparisonOp::Exists;
@ -105,8 +114,15 @@ struct CutsceneLine {
}; };
struct CutsceneCameraPose { struct CutsceneCameraPose {
float focusX = 0.5f; CutsceneAnchor anchor = CutsceneAnchor::Center;
float focusY = 0.5f;
// Используется только для Custom.
// Нормализованные координаты 0..1, где:
// centerX: 0 = левый край, 1 = правый край
// centerY: 0 = верхний край, 1 = нижний край
float centerX = 0.5f;
float centerY = 0.5f;
float zoom = 1.0f; float zoom = 1.0f;
float rotationDeg = 0.0f; float rotationDeg = 0.0f;
}; };
@ -141,6 +157,13 @@ enum class PresentationMode {
Cutscene Cutscene
}; };
struct CutsceneCameraBlendState {
bool active = false;
CutsceneCameraPose from;
CutsceneCameraPose to;
float t = 1.0f;
};
struct PresentationModel { struct PresentationModel {
PresentationMode mode = PresentationMode::Hidden; PresentationMode mode = PresentationMode::Hidden;
std::string dialogueId; std::string dialogueId;
@ -153,7 +176,8 @@ struct PresentationModel {
int selectedChoice = 0; int selectedChoice = 0;
bool revealCompleted = true; bool revealCompleted = true;
bool showCutsceneSubtitle = false; bool showCutsceneSubtitle = false;
CutsceneCameraPose cutsceneCamera;
CutsceneCameraBlendState cutsceneCamera;
}; };
struct SaveState { struct SaveState {