352 lines
12 KiB
Markdown
352 lines
12 KiB
Markdown
# 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:
|
||
|
||
```json
|
||
{
|
||
"cutscenes": [
|
||
{ "id": "intro", ... },
|
||
{ "id": "ending", ... }
|
||
]
|
||
}
|
||
```
|
||
|
||
Cutscenes and dialogues are loaded from **separate files**:
|
||
|
||
```cpp
|
||
dialogueSystem.loadDatabase("resources/dialogue/uni_interior.json"); // dialogues
|
||
dialogueSystem.loadCutsceneDatabase("resources/dialogue/cutscenes.json"); // cutscenes
|
||
```
|
||
|
||
---
|
||
|
||
## Cutscene object
|
||
|
||
```json
|
||
{
|
||
"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](#image-segments) |
|
||
| `lines` | array | `[]` | Subtitle lines shown sequentially — see [Subtitle lines](#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.
|
||
|
||
```json
|
||
{
|
||
"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](#easing-types) |
|
||
| `from` | pose object | center/1.0 | Pose at `startMs` — see [Image pose](#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.
|
||
|
||
```json
|
||
{ "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).
|
||
|
||
```json
|
||
{
|
||
"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
|
||
|
||
```cpp
|
||
// Standalone cutscene (not part of a dialogue):
|
||
dialogueSystem.startCutscene("intro_cutscene");
|
||
|
||
// Skip the currently playing cutscene:
|
||
dialogueSystem.skipCutscene();
|
||
```
|
||
|
||
### Callbacks
|
||
|
||
```cpp
|
||
// 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.
|
||
|
||
```json
|
||
{
|
||
"id": "node_cutscene",
|
||
"type": "CutsceneStart",
|
||
"cutsceneId": "intro_cutscene",
|
||
"next": "node_after_cutscene"
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Full examples
|
||
|
||
### Minimal — static image, timed
|
||
|
||
```json
|
||
{
|
||
"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.
|
||
|
||
```json
|
||
{
|
||
"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
|
||
|
||
```json
|
||
{
|
||
"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
|
||
}
|
||
]
|
||
}
|
||
```
|