space-game001/CUTSCENES.md
2026-06-07 14:40:15 +03:00

12 KiB
Raw Blame History

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
        }
    ]
}