working on cutscenes

This commit is contained in:
Vladislav Khorev committed 2026-06-07 14:40:15 +03:00
1 parent 54cc118df7
commit 6349859e66
47 files changed
+6234 -21

No files matched your search

+351
View File
@@ -0,0 +1,351 @@
# 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
}
]
}
```