12 KiB
Cutscene System
Cutscenes are defined in JSON and loaded by CutsceneDatabase. Each cutscene is a self-contained object with an array of animated image layers and optional subtitle lines.
The file can contain multiple cutscenes:
{
"cutscenes": [
{ "id": "intro", ... },
{ "id": "ending", ... }
]
}
Cutscenes and dialogues are loaded from separate files:
dialogueSystem.loadDatabase("resources/dialogue/uni_interior.json"); // dialogues
dialogueSystem.loadCutsceneDatabase("resources/dialogue/cutscenes.json"); // cutscenes
Cutscene object
{
"id": "intro_cutscene",
"skippable": true,
"durationMs": 8000,
"fadeOutMs": 500,
"fadeInMs": 500,
"endFadeOutMs": 500,
"endFadeInMs": 500,
"onFadeInCallback": "",
"imageSegments": [ ... ],
"lines": [ ... ]
}
| Property | Type | Default | Description |
|---|---|---|---|
id |
string | — | Unique identifier used to start the cutscene from C++ or dialogue (required) |
skippable |
bool | true |
Whether the player can skip by holding LMB / touch |
durationMs |
int | 0 |
Minimum content duration in ms. The cutscene will not end before this time even if all subtitle lines have finished. 0 means duration is determined solely by subtitle lines or imageSegments.endMs |
fadeOutMs |
int | 0 |
Duration of the opening fade — game world fades to black before the cutscene images appear |
fadeInMs |
int | 0 |
Duration of the opening reveal — cutscene images fade in from black after fadeOutMs |
endFadeOutMs |
int | 0 |
Duration of the closing fade — cutscene fades to black at the end of content |
endFadeInMs |
int | 0 |
Duration of the closing reveal — game world fades back in from black |
onFadeInCallback |
string | "" |
Lua function name called once the opening fade-in completes (fired after fadeOutMs + fadeInMs ms) |
imageSegments |
array | [] |
Image layers with motion — see Image segments |
lines |
array | [] |
Subtitle lines shown sequentially — see Subtitle lines |
Timing model
The total cutscene duration is:
contentDuration = max(durationMs, max(segment.endMs for all segments))
totalDuration = contentDuration + endFadeOutMs + endFadeInMs
The full timeline looks like this:
|-- fadeOutMs --|-- fadeInMs --|--- content plays (images + subtitles) ---|-- endFadeOutMs --|-- endFadeInMs --|
world→black black→images images→black black→world
Image segments
Each entry in imageSegments describes one image layer: when it is visible, how it fades in/out, and how it animates from a start pose to an end pose.
{
"path": "resources/cutscenes/bg_layer.png",
"width": 1280,
"height": 720,
"startMs": 0,
"endMs": 8000,
"fadeInMs": 300,
"fadeOutMs": 300,
"easing": "EaseInOutSine",
"from": { "centerX": 0.4, "centerY": 0.5, "scale": 1.1 },
"to": { "centerX": 0.6, "centerY": 0.5, "scale": 1.0 }
}
| Property | Type | Default | Description |
|---|---|---|---|
path |
string | — | Path to the PNG image (required) |
width |
int | 0 |
Logical width used for all UV and aspect-ratio math. 0 uses the actual texture pixel width |
height |
int | 0 |
Logical height. 0 uses the actual texture pixel height |
startMs |
int | 0 |
Time (ms from cutscene start) when this layer becomes active |
endMs |
int | 0 |
Time (ms) when this layer stops being active. Must be > startMs |
fadeInMs |
int | 0 |
Alpha fades from 0 → 1 over this many ms after startMs. 0 = instant |
fadeOutMs |
int | 0 |
Alpha fades from 1 → 0 over this many ms before endMs. 0 = instant |
easing |
string | "Linear" |
Easing applied to the pose interpolation — see Easing types |
from |
pose object | center/1.0 | Pose at startMs — see Image pose |
to |
pose object | same as from |
Pose at endMs. If omitted, the layer stays at from the whole time |
Multiple segments can be active at the same time. They are rendered in declaration order (first = bottom layer, last = top layer), which enables parallax layering.
Image pose
A pose defines how an image is framed on screen at a given moment.
{ "centerX": 0.5, "centerY": 0.5, "scale": 1.0 }
| Property | Type | Default | Description |
|---|---|---|---|
centerX |
float | 0.5 |
Normalized X position (0 = left edge of image, 1 = right edge) of the point that is placed at the horizontal center of the screen |
centerY |
float | 0.5 |
Normalized Y position (0 = top edge, 1 = bottom edge) placed at the screen center |
scale |
float | 1.0 |
Zoom level. 1.0 = the image fills the screen exactly (aspect-ratio corrected). 2.0 = zoomed in 2×, showing half the image area |
The runtime interpolates all three values independently from from to to using the chosen easing.
Coordinate clamping: centerX/centerY are automatically clamped so the viewport never shows area outside the image. For a zoomed-in segment (scale > 1) you therefore have more freedom to pan; for scale = 1.0 the center is locked to 0.5/0.5.
Pose intuition
| Goal | Config |
|---|---|
| Centered, no zoom | { "centerX": 0.5, "centerY": 0.5, "scale": 1.0 } |
| Slightly zoomed in on center | { "centerX": 0.5, "centerY": 0.5, "scale": 1.2 } |
| Pan left to right | from: { "centerX": 0.3, "scale": 1.2 } → to: { "centerX": 0.7, "scale": 1.2 } |
| Zoom out from close-up | from: { "scale": 1.8 } → to: { "scale": 1.0 } |
| Look at top portion | { "centerY": 0.2, "scale": 1.3 } |
Easing types
Controls the interpolation curve applied to pose animation between from and to.
| Value | Description |
|---|---|
"Linear" |
Constant speed (default) |
"EaseInSine" |
Slow start, fast end |
"EaseOutSine" |
Fast start, slow end |
"EaseInOutSine" |
Slow start and end, fast middle |
"EaseInQuad" |
Quadratic slow start |
"EaseOutQuad" |
Quadratic slow end |
"EaseInOutQuad" |
Quadratic slow start and end |
"EaseInCubic" |
Cubic slow start |
"EaseOutCubic" |
Cubic slow end |
"EaseInOutCubic" |
Cubic slow start and end |
For cinematic camera motion "EaseInOutSine" or "EaseInOutCubic" give the most natural feel.
Subtitle lines
Lines are displayed sequentially on top of the cutscene images. Each line shows until its duration expires (or until the player advances, if waitForConfirm is set).
{
"speaker": "Аида Дженибековна",
"text": "Здравствуйте, студенты.",
"durationMs": 3000,
"waitForConfirm": false,
"luaCallback": ""
}
| Property | Type | Default | Description |
|---|---|---|---|
speaker |
string | "" |
Speaker name shown above the subtitle text. Empty = no name bar |
text |
string | "" |
Subtitle text. Supports Cyrillic and any codepoint in resources/symbols.txt |
durationMs |
int | 0 |
How long this line is displayed in ms. 0 = auto-computed from text length (~17 chars/sec, minimum 1500 ms) |
waitForConfirm |
bool | false |
When true, the line waits for player input (tap/click/Enter) before advancing. No timer runs |
luaCallback |
string | "" |
Lua function name called when this line begins. Useful for triggering SFX, spawning effects, etc. |
Subtitle lines run on their own timer that is independent of the image segments. The cutscene ends when both subtitle lines are exhausted and contentDuration has elapsed.
C++ API
Starting a cutscene
// Standalone cutscene (not part of a dialogue):
dialogueSystem.startCutscene("intro_cutscene");
// Skip the currently playing cutscene:
dialogueSystem.skipCutscene();
Callbacks
// Called when a cutscene begins:
dialogueSystem.setOnCutsceneStarted([]() { /* hide HUD, etc. */ });
// Called when a cutscene ends (receives the cutscene id):
dialogueSystem.setOnCutsceneFinished([](const std::string& id) {
// id == "intro_cutscene"
});
// Called when a subtitle line begins (receives luaCallback value):
dialogueSystem.setOnCutsceneLineStarted([](const std::string& fn) {
scriptEngine.callActivateFunction(fn);
});
// Called when the opening fade-in completes (receives onFadeInCallback value):
dialogueSystem.setOnCutsceneFadeInComplete([](const std::string& fn) {
scriptEngine.callActivateFunction(fn);
});
Triggering from dialogue
A dialogue node of type CutsceneStart embeds a cutscene mid-conversation. Dialogue resumes at next when the cutscene ends.
{
"id": "node_cutscene",
"type": "CutsceneStart",
"cutsceneId": "intro_cutscene",
"next": "node_after_cutscene"
}
Full examples
Minimal — static image, timed
{
"id": "simple",
"durationMs": 4000,
"fadeOutMs": 300,
"fadeInMs": 300,
"endFadeOutMs": 300,
"endFadeInMs": 300,
"imageSegments": [
{
"path": "resources/cutscenes/city.png",
"width": 1280,
"height": 720,
"startMs": 0,
"endMs": 4000
}
]
}
Two-layer parallax pan
Background moves slowly left-to-right; foreground character moves faster, creating depth.
{
"id": "classroom_intro",
"durationMs": 8000,
"fadeOutMs": 500,
"fadeInMs": 500,
"endFadeOutMs": 500,
"endFadeInMs": 500,
"imageSegments": [
{
"path": "resources/cutscenes/classroom_bg.png",
"width": 1920,
"height": 1080,
"startMs": 0,
"endMs": 8000,
"fadeInMs": 400,
"easing": "EaseInOutSine",
"from": { "centerX": 0.4, "centerY": 0.5, "scale": 1.1 },
"to": { "centerX": 0.6, "centerY": 0.5, "scale": 1.0 }
},
{
"path": "resources/cutscenes/classroom_teacher.png",
"width": 1920,
"height": 1080,
"startMs": 0,
"endMs": 8000,
"easing": "EaseInOutSine",
"from": { "centerX": 0.35, "centerY": 0.5, "scale": 1.0 },
"to": { "centerX": 0.65, "centerY": 0.5, "scale": 1.0 }
}
],
"lines": [
{
"speaker": "Аида Дженибековна",
"text": "Здравствуйте, студенты.",
"durationMs": 3000
},
{
"speaker": "Аида Дженибековна",
"text": "Рассаживайтесь.",
"durationMs": 2500
}
]
}
Zoom-in reveal with a second image appearing mid-way
{
"id": "letter_reveal",
"durationMs": 7000,
"fadeOutMs": 400,
"fadeInMs": 600,
"endFadeOutMs": 600,
"endFadeInMs": 400,
"imageSegments": [
{
"path": "resources/cutscenes/desk_bg.png",
"width": 1280,
"height": 720,
"startMs": 0,
"endMs": 7000
},
{
"path": "resources/cutscenes/letter_closeup.png",
"width": 1280,
"height": 720,
"startMs": 2000,
"endMs": 7000,
"fadeInMs": 800,
"easing": "EaseOutCubic",
"from": { "centerX": 0.5, "centerY": 0.5, "scale": 2.5 },
"to": { "centerX": 0.5, "centerY": 0.5, "scale": 1.2 }
}
],
"lines": [
{
"text": "Среди бумаг на столе лежит конверт.",
"durationMs": 2500
},
{
"speaker": "Главный герой",
"text": "«Явитесь в деканат немедленно».",
"durationMs": 3000
}
]
}