working on cutscenes
This commit is contained in:
parent
54cc118df7
commit
6349859e66
351
CUTSCENES.md
Normal file
351
CUTSCENES.md
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
46
cutsceneEditor/.gitignore
vendored
Normal file
46
cutsceneEditor/.gitignore
vendored
Normal file
@ -0,0 +1,46 @@
|
||||
.DS_STORE
|
||||
node_modules
|
||||
scripts/flow/*/.flowconfig
|
||||
.flowconfig
|
||||
*~
|
||||
*.pyc
|
||||
.grunt
|
||||
_SpecRunner.html
|
||||
__benchmarks__
|
||||
build/
|
||||
remote-repo/
|
||||
coverage/
|
||||
.module-cache
|
||||
fixtures/dom/public/react-dom.js
|
||||
fixtures/dom/public/react.js
|
||||
test/the-files-to-test.generated.js
|
||||
*.log*
|
||||
chrome-user-data
|
||||
*.sublime-project
|
||||
*.sublime-workspace
|
||||
.idea
|
||||
*.iml
|
||||
.vscode
|
||||
.zed
|
||||
*.swp
|
||||
*.swo
|
||||
/tmp
|
||||
/.worktrees
|
||||
.claude/*.local.*
|
||||
|
||||
packages/react-devtools-core/dist
|
||||
packages/react-devtools-extensions/chrome/build
|
||||
packages/react-devtools-extensions/chrome/*.crx
|
||||
packages/react-devtools-extensions/chrome/*.pem
|
||||
packages/react-devtools-extensions/firefox/build
|
||||
packages/react-devtools-extensions/firefox/*.xpi
|
||||
packages/react-devtools-extensions/firefox/*.pem
|
||||
packages/react-devtools-extensions/shared/build
|
||||
packages/react-devtools-extensions/.tempUserDataDir
|
||||
packages/react-devtools-fusebox/dist
|
||||
packages/react-devtools-inline/dist
|
||||
packages/react-devtools-shell/dist
|
||||
packages/react-devtools-timeline/dist
|
||||
|
||||
resources
|
||||
|
||||
351
cutsceneEditor/CUTSCENES.md
Normal file
351
cutsceneEditor/CUTSCENES.md
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
65
cutsceneEditor/cutscene_example.json
Normal file
65
cutsceneEditor/cutscene_example.json
Normal file
@ -0,0 +1,65 @@
|
||||
{
|
||||
"cutscenes": [
|
||||
{
|
||||
"id": "test_cutscene_01",
|
||||
"background": "resources/black.png",
|
||||
"durationMs": 5000,
|
||||
"fadeOutMs": 500,
|
||||
"fadeInMs": 500,
|
||||
"endFadeOutMs": 500,
|
||||
"endFadeInMs": 500,
|
||||
"imageSegments": [
|
||||
{
|
||||
"path": "resources/w/cutscenes/cutscene1/cutscene1_wall_x.png",
|
||||
"startMs": 0,
|
||||
"endMs": 8000,
|
||||
"fadeInMs": 0,
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"from": {
|
||||
"centerX": 0.3, "scale": 1.2
|
||||
},
|
||||
"to": {
|
||||
"centerX": 0.7, "scale": 1.2
|
||||
},
|
||||
"easing": "Linear"
|
||||
},
|
||||
{
|
||||
"path": "resources/w/cutscenes/cutscene1/cutscene1_aida1_x.png",
|
||||
"startMs": 0,
|
||||
"endMs": 8000,
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"from": {
|
||||
|
||||
"centerX": 0.3,
|
||||
"centerY": 0.5,
|
||||
"scale": 1.0
|
||||
},
|
||||
"to": {
|
||||
"centerX": 0.7,
|
||||
"centerY": 0.5,
|
||||
"scale": 1.0
|
||||
}
|
||||
}
|
||||
],
|
||||
"lines": [
|
||||
{
|
||||
"speaker": "Аида Дженибековна",
|
||||
"text": "Здравствуйте, студенты. Кого я вижу, где вы были весь семестр?",
|
||||
"durationMs": 3000
|
||||
},
|
||||
{
|
||||
"speaker": "Аида Дженибековна",
|
||||
"text": "В эпизоде \"Семетей\" трилогии \"Манас\", изменники Канчоро и Кыяз захватывают власть над кыргызами.",
|
||||
"durationMs": 3000
|
||||
},
|
||||
{
|
||||
"speaker": "Аида Дженибековна",
|
||||
"text": "На сегодня лекция завершена. Домашнее задание - к практическому занятию вы должны подготовить презентации, каждый по своей теме.",
|
||||
"durationMs": 2000
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
1
cutsceneEditor/dist/assets/index-B96C0g1n.css
vendored
Normal file
1
cutsceneEditor/dist/assets/index-B96C0g1n.css
vendored
Normal file
File diff suppressed because one or more lines are too long
40
cutsceneEditor/dist/assets/index-D_5Tak8P.js
vendored
Normal file
40
cutsceneEditor/dist/assets/index-D_5Tak8P.js
vendored
Normal file
File diff suppressed because one or more lines are too long
13
cutsceneEditor/dist/index.html
vendored
Normal file
13
cutsceneEditor/dist/index.html
vendored
Normal file
@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Cutscene Editor</title>
|
||||
<script type="module" crossorigin src="/assets/index-D_5Tak8P.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-B96C0g1n.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
12
cutsceneEditor/index.html
Normal file
12
cutsceneEditor/index.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Cutscene Editor</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
1890
cutsceneEditor/package-lock.json
generated
Normal file
1890
cutsceneEditor/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
cutsceneEditor/package.json
Normal file
24
cutsceneEditor/package.json
Normal file
@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "cutscene-editor",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"immer": "^10.1.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.1",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.5"
|
||||
}
|
||||
}
|
||||
6
cutsceneEditor/src/App.module.css
Normal file
6
cutsceneEditor/src/App.module.css
Normal file
@ -0,0 +1,6 @@
|
||||
.app {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
14
cutsceneEditor/src/App.tsx
Normal file
14
cutsceneEditor/src/App.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
import styles from './App.module.css';
|
||||
import LeftPanel from './components/LeftPanel/LeftPanel';
|
||||
import CenterPanel from './components/CenterPanel/CenterPanel';
|
||||
import RightPanel from './components/RightPanel/RightPanel';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div className={styles.app}>
|
||||
<LeftPanel />
|
||||
<CenterPanel />
|
||||
<RightPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
.panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.previewArea {
|
||||
flex: 0 1 420px;
|
||||
min-height: 120px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: #111;
|
||||
padding: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
16
cutsceneEditor/src/components/CenterPanel/CenterPanel.tsx
Normal file
16
cutsceneEditor/src/components/CenterPanel/CenterPanel.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import styles from './CenterPanel.module.css';
|
||||
import Preview from '../Preview/Preview';
|
||||
import Controls from '../Controls/Controls';
|
||||
import Timeline from '../Timeline/Timeline';
|
||||
|
||||
export default function CenterPanel() {
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<div className={styles.previewArea}>
|
||||
<Preview />
|
||||
</div>
|
||||
<Controls />
|
||||
<Timeline />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
94
cutsceneEditor/src/components/Controls/Controls.module.css
Normal file
94
cutsceneEditor/src/components/Controls/Controls.module.css
Normal file
@ -0,0 +1,94 @@
|
||||
.controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
background: #1e1e1e;
|
||||
border-top: 1px solid #333;
|
||||
border-bottom: 1px solid #333;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn {
|
||||
background: #2d2d2d;
|
||||
border: 1px solid #404040;
|
||||
color: #ccc;
|
||||
border-radius: 4px;
|
||||
padding: 4px 10px;
|
||||
font-size: 13px;
|
||||
transition: background 0.1s;
|
||||
min-width: 32px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn:hover:not(:disabled) { background: #3a3a3a; color: #fff; }
|
||||
.btn:disabled { opacity: 0.35; cursor: default; }
|
||||
|
||||
.active {
|
||||
color: #5ba3e0;
|
||||
border-color: #4a7aaa;
|
||||
}
|
||||
|
||||
/* ── Scrubber ───────────────────────────────────────────────── */
|
||||
.scrubber {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 4px;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
padding: 0;
|
||||
/* filled portion via CSS variable set inline */
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
#5b9bd5 0%,
|
||||
#5b9bd5 var(--progress, 0%),
|
||||
#3a3a3a var(--progress, 0%),
|
||||
#3a3a3a 100%
|
||||
);
|
||||
}
|
||||
|
||||
.scrubber:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.scrubber::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: #5b9bd5;
|
||||
cursor: pointer;
|
||||
border: 2px solid #1e1e1e;
|
||||
transition: transform 0.1s;
|
||||
}
|
||||
.scrubber:not(:disabled)::-webkit-slider-thumb:hover {
|
||||
transform: scale(1.3);
|
||||
}
|
||||
|
||||
.scrubber::-moz-range-thumb {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: #5b9bd5;
|
||||
cursor: pointer;
|
||||
border: 2px solid #1e1e1e;
|
||||
}
|
||||
|
||||
.scrubber::-moz-range-track {
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: #3a3a3a;
|
||||
}
|
||||
|
||||
.time {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
67
cutsceneEditor/src/components/Controls/Controls.tsx
Normal file
67
cutsceneEditor/src/components/Controls/Controls.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
import { useCutsceneStore, useSelectedCutscene } from '../../store/cutsceneStore';
|
||||
import { usePlayback } from '../../hooks/usePlayback';
|
||||
import styles from './Controls.module.css';
|
||||
|
||||
function formatMs(ms: number) {
|
||||
const s = Math.floor(ms / 1000);
|
||||
const frac = Math.floor((ms % 1000) / 10).toString().padStart(2, '0');
|
||||
return `${s}.${frac}s`;
|
||||
}
|
||||
|
||||
export default function Controls() {
|
||||
usePlayback();
|
||||
|
||||
const { playState, currentTimeMs, setPlayState, setCurrentTime } = useCutsceneStore();
|
||||
const cutscene = useSelectedCutscene();
|
||||
|
||||
const totalMs = cutscene
|
||||
? Math.max(
|
||||
cutscene.durationMs,
|
||||
cutscene.imageSegments.reduce((m, s) => Math.max(m, s.endMs), 0)
|
||||
) + cutscene.endFadeOutMs + cutscene.endFadeInMs
|
||||
: 0;
|
||||
|
||||
function play() {
|
||||
if (playState === 'stopped' || currentTimeMs >= totalMs) setCurrentTime(0);
|
||||
setPlayState('playing');
|
||||
}
|
||||
|
||||
function pause() { setPlayState('paused'); }
|
||||
function stop() { setPlayState('stopped'); setCurrentTime(0); }
|
||||
|
||||
function handleScrub(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const ms = Number(e.target.value);
|
||||
setCurrentTime(ms);
|
||||
if (playState === 'playing') setPlayState('paused');
|
||||
}
|
||||
|
||||
const progress = totalMs > 0 ? currentTimeMs / totalMs : 0;
|
||||
|
||||
return (
|
||||
<div className={styles.controls}>
|
||||
<button className={styles.btn} onClick={stop} title="Stop" disabled={!cutscene}>⏹</button>
|
||||
<button className={styles.btn} onClick={() => setCurrentTime(0)} title="Rewind" disabled={!cutscene}>⏮</button>
|
||||
{playState === 'playing' ? (
|
||||
<button className={`${styles.btn} ${styles.active}`} onClick={pause} title="Pause" disabled={!cutscene}>⏸</button>
|
||||
) : (
|
||||
<button className={`${styles.btn} ${styles.active}`} onClick={play} title="Play" disabled={!cutscene}>▶</button>
|
||||
)}
|
||||
|
||||
<input
|
||||
className={styles.scrubber}
|
||||
type="range"
|
||||
min={0}
|
||||
max={totalMs || 1}
|
||||
step={16}
|
||||
value={currentTimeMs}
|
||||
onChange={handleScrub}
|
||||
disabled={!cutscene}
|
||||
style={{ '--progress': `${progress * 100}%` } as React.CSSProperties}
|
||||
/>
|
||||
|
||||
<div className={styles.time}>
|
||||
{formatMs(currentTimeMs)} / {formatMs(totalMs)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
100
cutsceneEditor/src/components/LeftPanel/LeftPanel.module.css
Normal file
100
cutsceneEditor/src/components/LeftPanel/LeftPanel.module.css
Normal file
@ -0,0 +1,100 @@
|
||||
.panel {
|
||||
width: 200px;
|
||||
min-width: 180px;
|
||||
background: #252525;
|
||||
border-right: 1px solid #333;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 10px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: #888;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
|
||||
.btn {
|
||||
background: #333;
|
||||
color: #ddd;
|
||||
border: 1px solid #444;
|
||||
border-radius: 4px;
|
||||
padding: 5px 8px;
|
||||
text-align: center;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.btn:hover:not(:disabled) { background: #3d3d3d; }
|
||||
.btn:disabled { opacity: 0.4; cursor: default; }
|
||||
|
||||
.btnPrimary {
|
||||
composes: btn;
|
||||
background: #2a4a6e;
|
||||
border-color: #3a6090;
|
||||
color: #a8d0f0;
|
||||
}
|
||||
.btnPrimary:hover { background: #2e5480; }
|
||||
|
||||
.list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 16px 12px;
|
||||
color: #555;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 7px 12px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
border-left: 3px solid transparent;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.item:hover { background: #2e2e2e; }
|
||||
|
||||
.selected {
|
||||
background: #1e3a55;
|
||||
border-left-color: #5b9bd5;
|
||||
}
|
||||
|
||||
.itemId {
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.deleteBtn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #666;
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.1s, color 0.1s;
|
||||
}
|
||||
.item:hover .deleteBtn { opacity: 1; }
|
||||
.deleteBtn:hover { color: #e06c6c; }
|
||||
76
cutsceneEditor/src/components/LeftPanel/LeftPanel.tsx
Normal file
76
cutsceneEditor/src/components/LeftPanel/LeftPanel.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
import { useRef } from 'react';
|
||||
import { useCutsceneStore } from '../../store/cutsceneStore';
|
||||
import { parseFile, triggerDownload } from '../../utils/fileIO';
|
||||
import styles from './LeftPanel.module.css';
|
||||
|
||||
export default function LeftPanel() {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { file, selectedCutsceneId, loadFile, addCutscene, deleteCutscene, selectCutscene, getExportData } = useCutsceneStore();
|
||||
|
||||
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const f = e.target.files?.[0];
|
||||
if (!f) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => {
|
||||
try {
|
||||
const json = JSON.parse(ev.target!.result as string);
|
||||
loadFile(parseFile(json));
|
||||
} catch {
|
||||
alert('Invalid JSON file');
|
||||
}
|
||||
};
|
||||
reader.readAsText(f);
|
||||
e.target.value = '';
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
const data = getExportData();
|
||||
if (data) triggerDownload(data);
|
||||
}
|
||||
|
||||
function handleDelete(id: string) {
|
||||
if (confirm(`Delete cutscene "${id}"?`)) deleteCutscene(id);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<div className={styles.header}>Cutscenes</div>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<button className={styles.btnPrimary} onClick={() => fileInputRef.current?.click()}>
|
||||
Load JSON
|
||||
</button>
|
||||
<input ref={fileInputRef} type="file" accept=".json" style={{ display: 'none' }} onChange={handleFileChange} />
|
||||
<button className={styles.btn} onClick={handleSave} disabled={!file}>
|
||||
Save JSON
|
||||
</button>
|
||||
<button className={styles.btn} onClick={addCutscene}>
|
||||
+ New
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={styles.list}>
|
||||
{!file || file.cutscenes.length === 0 ? (
|
||||
<div className={styles.empty}>No cutscenes. Load a JSON or create new.</div>
|
||||
) : (
|
||||
file.cutscenes.map(c => (
|
||||
<div
|
||||
key={c.id}
|
||||
className={`${styles.item} ${c.id === selectedCutsceneId ? styles.selected : ''}`}
|
||||
onClick={() => selectCutscene(c.id)}
|
||||
>
|
||||
<span className={styles.itemId}>{c.id}</span>
|
||||
<button
|
||||
className={styles.deleteBtn}
|
||||
onClick={(e) => { e.stopPropagation(); handleDelete(c.id); }}
|
||||
title="Delete"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
cutsceneEditor/src/components/Preview/Preview.module.css
Normal file
51
cutsceneEditor/src/components/Preview/Preview.module.css
Normal file
@ -0,0 +1,51 @@
|
||||
.wrapper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.viewport {
|
||||
position: relative;
|
||||
aspect-ratio: 16 / 9;
|
||||
width: 100%;
|
||||
max-height: 100%;
|
||||
background: #000;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.empty {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #444;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.subtitleBar {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 10px 20px 14px;
|
||||
background: linear-gradient(transparent, rgba(0,0,0,0.75));
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.speaker {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #f0c060;
|
||||
margin-bottom: 4px;
|
||||
text-shadow: 0 1px 3px rgba(0,0,0,0.8);
|
||||
}
|
||||
|
||||
.text {
|
||||
font-size: 14px;
|
||||
color: #fff;
|
||||
line-height: 1.5;
|
||||
text-shadow: 0 1px 4px rgba(0,0,0,0.9);
|
||||
}
|
||||
85
cutsceneEditor/src/components/Preview/Preview.tsx
Normal file
85
cutsceneEditor/src/components/Preview/Preview.tsx
Normal file
@ -0,0 +1,85 @@
|
||||
import { useRef, useEffect, useState } from 'react';
|
||||
import { useCutsceneStore, useSelectedCutscene } from '../../store/cutsceneStore';
|
||||
import { computeSegmentState, poseToStyle } from '../../utils/rendering';
|
||||
import styles from './Preview.module.css';
|
||||
|
||||
const LOGICAL_W = 1280;
|
||||
const LOGICAL_H = 720;
|
||||
|
||||
function computeSubtitle(cutscene: ReturnType<typeof useSelectedCutscene>, currentMs: number) {
|
||||
if (!cutscene) return null;
|
||||
let elapsed = 0;
|
||||
for (const line of cutscene.lines) {
|
||||
const dur = line.durationMs > 0
|
||||
? line.durationMs
|
||||
: Math.max(1500, Math.round((line.text.length / 17) * 1000));
|
||||
if (currentMs >= elapsed && currentMs < elapsed + dur) return line;
|
||||
elapsed += dur;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function Preview() {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [containerSize, setContainerSize] = useState({ w: LOGICAL_W, h: LOGICAL_H });
|
||||
|
||||
const currentTimeMs = useCutsceneStore(s => s.currentTimeMs);
|
||||
const cutscene = useSelectedCutscene();
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver(entries => {
|
||||
const e = entries[0];
|
||||
if (e) setContainerSize({ w: e.contentRect.width, h: e.contentRect.height });
|
||||
});
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
const subtitle = computeSubtitle(cutscene, currentTimeMs);
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.viewport} ref={containerRef}>
|
||||
{!cutscene ? (
|
||||
<div className={styles.empty}>Select or create a cutscene</div>
|
||||
) : (
|
||||
<>
|
||||
{cutscene.imageSegments.map((seg, i) => {
|
||||
const state = computeSegmentState(seg, currentTimeMs);
|
||||
if (!state) return null;
|
||||
|
||||
const imgStyle = poseToStyle(
|
||||
state.pose,
|
||||
seg.width || LOGICAL_W,
|
||||
seg.height || LOGICAL_H,
|
||||
containerSize.w,
|
||||
containerSize.h,
|
||||
);
|
||||
|
||||
return (
|
||||
<img
|
||||
key={i}
|
||||
src={`/${seg.path}`}
|
||||
alt=""
|
||||
style={{ ...imgStyle, opacity: state.alpha }}
|
||||
draggable={false}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{subtitle && (
|
||||
<div className={styles.subtitleBar}>
|
||||
{subtitle.speaker && (
|
||||
<div className={styles.speaker}>{subtitle.speaker}</div>
|
||||
)}
|
||||
<div className={styles.text}>{subtitle.text}</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,72 @@
|
||||
import { useCutsceneStore, useSelectedCutscene } from '../../store/cutsceneStore';
|
||||
import type { Cutscene } from '../../types/cutscene';
|
||||
import styles from './RightPanel.module.css';
|
||||
|
||||
type CutscenePatch = Partial<Omit<Cutscene, 'imageSegments' | 'lines'>>;
|
||||
|
||||
function NumField({ label, value, onChange, min }: {
|
||||
label: string; value: number; onChange: (v: number) => void; min?: number;
|
||||
}) {
|
||||
return (
|
||||
<div className={styles.field}>
|
||||
<label>{label}</label>
|
||||
<input
|
||||
type="number"
|
||||
value={value}
|
||||
min={min}
|
||||
onChange={e => onChange(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CutsceneProperties() {
|
||||
const cutscene = useSelectedCutscene();
|
||||
const updateCutscene = useCutsceneStore(s => s.updateCutscene);
|
||||
|
||||
if (!cutscene) return <div className={styles.empty}>No cutscene selected</div>;
|
||||
|
||||
function upd(patch: CutscenePatch) {
|
||||
updateCutscene(cutscene!.id, patch);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionTitle}>Cutscene</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label>ID</label>
|
||||
<input
|
||||
type="text"
|
||||
value={cutscene.id}
|
||||
onChange={e => upd({ id: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label>Skippable</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={cutscene.skippable}
|
||||
onChange={e => upd({ skippable: e.target.checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<NumField label="Duration (ms)" value={cutscene.durationMs} onChange={v => upd({ durationMs: v })} min={0} />
|
||||
<NumField label="Fade out (ms)" value={cutscene.fadeOutMs} onChange={v => upd({ fadeOutMs: v })} min={0} />
|
||||
<NumField label="Fade in (ms)" value={cutscene.fadeInMs} onChange={v => upd({ fadeInMs: v })} min={0} />
|
||||
<NumField label="End fade out (ms)" value={cutscene.endFadeOutMs} onChange={v => upd({ endFadeOutMs: v })} min={0} />
|
||||
<NumField label="End fade in (ms)" value={cutscene.endFadeInMs} onChange={v => upd({ endFadeInMs: v })} min={0} />
|
||||
|
||||
<div className={styles.field}>
|
||||
<label>onFadeIn callback</label>
|
||||
<input
|
||||
type="text"
|
||||
value={cutscene.onFadeInCallback}
|
||||
onChange={e => upd({ onFadeInCallback: e.target.value })}
|
||||
placeholder="lua function name"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
132
cutsceneEditor/src/components/RightPanel/LayerProperties.tsx
Normal file
132
cutsceneEditor/src/components/RightPanel/LayerProperties.tsx
Normal file
@ -0,0 +1,132 @@
|
||||
import { useCutsceneStore, useSelectedCutscene } from '../../store/cutsceneStore';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { AVAILABLE_IMAGES } from '../../constants/images';
|
||||
import { EASING_OPTIONS } from '../../constants/easings';
|
||||
import type { ImagePose } from '../../types/cutscene';
|
||||
import styles from './RightPanel.module.css';
|
||||
|
||||
function NumField({ label, value, onChange, min, step }: {
|
||||
label: string; value: number; onChange: (v: number) => void; min?: number; step?: number;
|
||||
}) {
|
||||
return (
|
||||
<div className={styles.field}>
|
||||
<label>{label}</label>
|
||||
<input
|
||||
type="number"
|
||||
value={value}
|
||||
min={min}
|
||||
step={step ?? 1}
|
||||
onChange={e => onChange(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PoseFields({ label, pose, onChange }: {
|
||||
label: string;
|
||||
pose: ImagePose;
|
||||
onChange: (patch: Partial<ImagePose>) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className={styles.poseGroup}>
|
||||
<div className={styles.poseTitle}>{label}</div>
|
||||
<div className={styles.poseRow}>
|
||||
<div className={styles.field}>
|
||||
<label>centerX</label>
|
||||
<input type="number" value={pose.centerX} step={0.01} min={0} max={1}
|
||||
onChange={e => onChange({ centerX: Number(e.target.value) })} />
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<label>centerY</label>
|
||||
<input type="number" value={pose.centerY} step={0.01} min={0} max={1}
|
||||
onChange={e => onChange({ centerY: Number(e.target.value) })} />
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<label>scale</label>
|
||||
<input type="number" value={pose.scale} step={0.05} min={0.1}
|
||||
onChange={e => onChange({ scale: Number(e.target.value) })} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LayerProperties() {
|
||||
const cutscene = useSelectedCutscene();
|
||||
const { selectedLayerIndex, updateLayer, updateLayerFrom, updateLayerTo } = useCutsceneStore(useShallow(s => ({
|
||||
selectedLayerIndex: s.selectedLayerIndex,
|
||||
updateLayer: s.updateLayer,
|
||||
updateLayerFrom: s.updateLayerFrom,
|
||||
updateLayerTo: s.updateLayerTo,
|
||||
})));
|
||||
|
||||
if (!cutscene || selectedLayerIndex === null) return null;
|
||||
const seg = cutscene.imageSegments[selectedLayerIndex];
|
||||
if (!seg) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionTitle}>Layer {selectedLayerIndex + 1}</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label>Image</label>
|
||||
<select
|
||||
value={AVAILABLE_IMAGES.includes(seg.path) ? seg.path : '__custom__'}
|
||||
onChange={e => {
|
||||
if (e.target.value !== '__custom__') updateLayer(selectedLayerIndex, { path: e.target.value });
|
||||
}}
|
||||
>
|
||||
{AVAILABLE_IMAGES.map(img => (
|
||||
<option key={img} value={img}>{img.split('/').pop()}</option>
|
||||
))}
|
||||
{!AVAILABLE_IMAGES.includes(seg.path) && (
|
||||
<option value="__custom__">(custom)</option>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label>Path (manual)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={seg.path}
|
||||
onChange={e => updateLayer(selectedLayerIndex, { path: e.target.value })}
|
||||
placeholder="resources/..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.row2}>
|
||||
<NumField label="Width" value={seg.width} onChange={v => updateLayer(selectedLayerIndex, { width: v })} min={1} />
|
||||
<NumField label="Height" value={seg.height} onChange={v => updateLayer(selectedLayerIndex, { height: v })} min={1} />
|
||||
</div>
|
||||
|
||||
<div className={styles.row2}>
|
||||
<NumField label="Start (ms)" value={seg.startMs} onChange={v => updateLayer(selectedLayerIndex, { startMs: v })} min={0} />
|
||||
<NumField label="End (ms)" value={seg.endMs} onChange={v => updateLayer(selectedLayerIndex, { endMs: v })} min={0} />
|
||||
</div>
|
||||
|
||||
<div className={styles.row2}>
|
||||
<NumField label="Fade in (ms)" value={seg.fadeInMs} onChange={v => updateLayer(selectedLayerIndex, { fadeInMs: v })} min={0} />
|
||||
<NumField label="Fade out (ms)" value={seg.fadeOutMs} onChange={v => updateLayer(selectedLayerIndex, { fadeOutMs: v })} min={0} />
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label>Easing</label>
|
||||
<select value={seg.easing} onChange={e => updateLayer(selectedLayerIndex, { easing: e.target.value as typeof seg.easing })}>
|
||||
{EASING_OPTIONS.map(e => <option key={e} value={e}>{e}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<PoseFields
|
||||
label="From"
|
||||
pose={seg.from}
|
||||
onChange={patch => updateLayerFrom(selectedLayerIndex, patch)}
|
||||
/>
|
||||
<PoseFields
|
||||
label="To"
|
||||
pose={seg.to}
|
||||
onChange={patch => updateLayerTo(selectedLayerIndex, patch)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
208
cutsceneEditor/src/components/RightPanel/RightPanel.module.css
Normal file
208
cutsceneEditor/src/components/RightPanel/RightPanel.module.css
Normal file
@ -0,0 +1,208 @@
|
||||
.panel {
|
||||
width: 260px;
|
||||
min-width: 240px;
|
||||
background: #252525;
|
||||
border-left: 1px solid #333;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid #333;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
padding: 8px 6px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #777;
|
||||
font-size: 11px;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: color 0.1s;
|
||||
}
|
||||
.tab:hover { color: #bbb; }
|
||||
.tabActive {
|
||||
color: #5b9bd5;
|
||||
border-bottom-color: #5b9bd5;
|
||||
}
|
||||
|
||||
.scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
/* ── Sections ──────────────────────────────────────────────────── */
|
||||
.section {
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid #2e2e2e;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #888;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.addBtn {
|
||||
background: #2a4a6e;
|
||||
border: 1px solid #3a6090;
|
||||
color: #a8d0f0;
|
||||
border-radius: 3px;
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.addBtn:hover { background: #2e5480; }
|
||||
|
||||
/* ── Fields ────────────────────────────────────────────────────── */
|
||||
.field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 5px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.field label {
|
||||
flex-shrink: 0;
|
||||
width: 100px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.field input[type="text"],
|
||||
.field input[type="number"],
|
||||
.field select,
|
||||
.field textarea {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.field textarea {
|
||||
resize: vertical;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.field input[type="checkbox"] {
|
||||
width: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.row2 {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.row2 .field {
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.row2 .field label {
|
||||
width: auto;
|
||||
text-align: left;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
/* ── Pose groups ───────────────────────────────────────────────── */
|
||||
.poseGroup {
|
||||
margin-top: 8px;
|
||||
background: #1e1e1e;
|
||||
border-radius: 4px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.poseTitle {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.poseRow {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.poseRow .field {
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.poseRow .field label {
|
||||
width: auto;
|
||||
text-align: left;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
/* ── Subtitle line cards ───────────────────────────────────────── */
|
||||
.lineCard {
|
||||
background: #1e1e1e;
|
||||
border: 1px solid #2e2e2e;
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.lineHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.lineIndex {
|
||||
font-size: 10px;
|
||||
color: #666;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.iconBtn {
|
||||
background: none;
|
||||
border: 1px solid #3a3a3a;
|
||||
color: #888;
|
||||
border-radius: 3px;
|
||||
padding: 1px 5px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.iconBtn:hover:not(:disabled) { background: #333; color: #ccc; }
|
||||
.iconBtn:disabled { opacity: 0.3; cursor: default; }
|
||||
|
||||
.iconBtnDanger {
|
||||
composes: iconBtn;
|
||||
color: #a05050;
|
||||
border-color: #5a2a2a;
|
||||
}
|
||||
.iconBtnDanger:hover { background: #3a2020; color: #e06c6c; }
|
||||
|
||||
.emptyLines {
|
||||
color: #555;
|
||||
font-size: 11px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 20px;
|
||||
color: #555;
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
}
|
||||
41
cutsceneEditor/src/components/RightPanel/RightPanel.tsx
Normal file
41
cutsceneEditor/src/components/RightPanel/RightPanel.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import { useCutsceneStore } from '../../store/cutsceneStore';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import CutsceneProperties from './CutsceneProperties';
|
||||
import LayerProperties from './LayerProperties';
|
||||
import SubtitleLines from './SubtitleLines';
|
||||
import styles from './RightPanel.module.css';
|
||||
|
||||
export default function RightPanel() {
|
||||
const { selectedLayerIndex, selectLayer } = useCutsceneStore(useShallow(s => ({
|
||||
selectedLayerIndex: s.selectedLayerIndex,
|
||||
selectLayer: s.selectLayer,
|
||||
})));
|
||||
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
{/* Tab strip */}
|
||||
<div className={styles.tabs}>
|
||||
<button
|
||||
className={`${styles.tab} ${selectedLayerIndex === null ? styles.tabActive : ''}`}
|
||||
onClick={() => selectLayer(null)}
|
||||
>
|
||||
Cutscene
|
||||
</button>
|
||||
{selectedLayerIndex !== null && (
|
||||
<button className={`${styles.tab} ${styles.tabActive}`}>
|
||||
Layer {selectedLayerIndex + 1}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.scroll}>
|
||||
{selectedLayerIndex !== null ? (
|
||||
<LayerProperties />
|
||||
) : (
|
||||
<CutsceneProperties />
|
||||
)}
|
||||
<SubtitleLines />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
89
cutsceneEditor/src/components/RightPanel/SubtitleLines.tsx
Normal file
89
cutsceneEditor/src/components/RightPanel/SubtitleLines.tsx
Normal file
@ -0,0 +1,89 @@
|
||||
import { useCutsceneStore, useSelectedCutscene } from '../../store/cutsceneStore';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import styles from './RightPanel.module.css';
|
||||
|
||||
export default function SubtitleLines() {
|
||||
const cutscene = useSelectedCutscene();
|
||||
const { addLine, removeLine, moveLineUp, moveLineDown, updateLine } = useCutsceneStore(useShallow(s => ({
|
||||
addLine: s.addLine,
|
||||
removeLine: s.removeLine,
|
||||
moveLineUp: s.moveLineUp,
|
||||
moveLineDown: s.moveLineDown,
|
||||
updateLine: s.updateLine,
|
||||
})));
|
||||
|
||||
if (!cutscene) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<div className={styles.sectionTitle}>Subtitle Lines</div>
|
||||
<button className={styles.addBtn} onClick={addLine}>+ Add</button>
|
||||
</div>
|
||||
|
||||
{cutscene.lines.length === 0 && (
|
||||
<div className={styles.emptyLines}>No lines. Click + Add.</div>
|
||||
)}
|
||||
|
||||
{cutscene.lines.map((line, i) => (
|
||||
<div key={i} className={styles.lineCard}>
|
||||
<div className={styles.lineHeader}>
|
||||
<span className={styles.lineIndex}>#{i + 1}</span>
|
||||
<button className={styles.iconBtn} onClick={() => moveLineUp(i)} disabled={i === 0} title="Move up">↑</button>
|
||||
<button className={styles.iconBtn} onClick={() => moveLineDown(i)} disabled={i === cutscene.lines.length - 1} title="Move down">↓</button>
|
||||
<button className={styles.iconBtnDanger} onClick={() => removeLine(i)} title="Remove">✕</button>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label>Speaker</label>
|
||||
<input
|
||||
type="text"
|
||||
value={line.speaker}
|
||||
onChange={e => updateLine(i, { speaker: e.target.value })}
|
||||
placeholder="(none)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label>Text</label>
|
||||
<textarea
|
||||
value={line.text}
|
||||
rows={2}
|
||||
onChange={e => updateLine(i, { text: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.row2}>
|
||||
<div className={styles.field}>
|
||||
<label>Duration (ms)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={line.durationMs}
|
||||
min={0}
|
||||
onChange={e => updateLine(i, { durationMs: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<label>Wait confirm</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={line.waitForConfirm}
|
||||
onChange={e => updateLine(i, { waitForConfirm: e.target.checked })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label>Lua callback</label>
|
||||
<input
|
||||
type="text"
|
||||
value={line.luaCallback}
|
||||
onChange={e => updateLine(i, { luaCallback: e.target.value })}
|
||||
placeholder="function name"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
192
cutsceneEditor/src/components/Timeline/Timeline.module.css
Normal file
192
cutsceneEditor/src/components/Timeline/Timeline.module.css
Normal file
@ -0,0 +1,192 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
background: #1a1a1a;
|
||||
border-top: 1px solid #333;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
background: #222;
|
||||
border-bottom: 1px solid #333;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tbBtn {
|
||||
background: #2d2d2d;
|
||||
border: 1px solid #404040;
|
||||
color: #bbb;
|
||||
border-radius: 3px;
|
||||
padding: 3px 8px;
|
||||
font-size: 11px;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.tbBtn:hover:not(:disabled) { background: #3a3a3a; color: #fff; }
|
||||
.tbBtn:disabled { opacity: 0.3; cursor: default; }
|
||||
|
||||
.spacer { flex: 1; }
|
||||
|
||||
.zoomLabel { font-size: 11px; color: #666; margin-right: 2px; }
|
||||
|
||||
.scroll {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ── Ruler ────────────────────────────────────────────────────── */
|
||||
.ruler {
|
||||
display: flex;
|
||||
height: 22px;
|
||||
background: #212121;
|
||||
border-bottom: 1px solid #333;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
user-select: none;
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.tick {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: #444;
|
||||
}
|
||||
|
||||
.tickLabel {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
font-size: 9px;
|
||||
color: #666;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Layers area ──────────────────────────────────────────────── */
|
||||
.layersArea {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.playhead {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: #e05050;
|
||||
z-index: 20;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.gridLine {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: rgba(255,255,255,0.04);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── Layer row ────────────────────────────────────────────────── */
|
||||
.row {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.row:hover { background: rgba(255,255,255,0.03); }
|
||||
.rowSelected { background: rgba(91,155,213,0.08); }
|
||||
|
||||
.rowLabel {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
width: 140px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
background: #1e1e1e;
|
||||
border-right: 1px solid #2a2a2a;
|
||||
z-index: 5;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.layerName {
|
||||
font-size: 11px;
|
||||
color: #bbb;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Segment bar ──────────────────────────────────────────────── */
|
||||
.bar {
|
||||
position: absolute;
|
||||
height: 22px;
|
||||
border-radius: 3px;
|
||||
cursor: grab;
|
||||
top: 5px;
|
||||
border: 1px solid rgba(255,255,255,0.15);
|
||||
box-sizing: border-box;
|
||||
min-width: 4px;
|
||||
}
|
||||
.bar:active { cursor: grabbing; }
|
||||
|
||||
.handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 6px;
|
||||
cursor: ew-resize;
|
||||
z-index: 2;
|
||||
}
|
||||
.handleLeft { left: 0; border-radius: 3px 0 0 3px; background: rgba(255,255,255,0.15); }
|
||||
.handleRight { right: 0; border-radius: 0 3px 3px 0; background: rgba(255,255,255,0.15); }
|
||||
|
||||
/* ── Subtitle row ─────────────────────────────────────────────── */
|
||||
.subtitleRow {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 14px;
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
}
|
||||
|
||||
.subtitleLabel {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
width: 140px;
|
||||
height: 100%;
|
||||
background: #1e1e1e;
|
||||
border-right: 1px solid #2a2a2a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
font-size: 9px;
|
||||
color: #555;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.subtitleBlock {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
height: 10px;
|
||||
background: #8a6040;
|
||||
border-radius: 2px;
|
||||
border: 1px solid #aa7050;
|
||||
opacity: 0.8;
|
||||
}
|
||||
285
cutsceneEditor/src/components/Timeline/Timeline.tsx
Normal file
285
cutsceneEditor/src/components/Timeline/Timeline.tsx
Normal file
@ -0,0 +1,285 @@
|
||||
import { useRef, useCallback, useState, useEffect } from 'react';
|
||||
import { useCutsceneStore, useSelectedCutscene } from '../../store/cutsceneStore';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import styles from './Timeline.module.css';
|
||||
|
||||
const LABEL_WIDTH = 140;
|
||||
const ROW_HEIGHT = 32;
|
||||
const SUBTITLE_ROW_HEIGHT = 14;
|
||||
const MIN_ZOOM = 20; // px per second
|
||||
const MAX_ZOOM = 400;
|
||||
|
||||
const LAYER_COLORS = ['#4a7fc1', '#c17a4a', '#4ac17a', '#c14a7a', '#7a4ac1', '#c1b44a', '#4ac1c1'];
|
||||
|
||||
function layerColor(i: number) { return LAYER_COLORS[i % LAYER_COLORS.length]; }
|
||||
|
||||
function basename(path: string) {
|
||||
return path.split('/').pop() ?? path;
|
||||
}
|
||||
|
||||
export default function Timeline() {
|
||||
const cutscene = useSelectedCutscene();
|
||||
const { selectedLayerIndex, currentTimeMs, playState } = useCutsceneStore(useShallow(s => ({
|
||||
selectedLayerIndex: s.selectedLayerIndex,
|
||||
currentTimeMs: s.currentTimeMs,
|
||||
playState: s.playState,
|
||||
})));
|
||||
const { selectLayer, addLayer, removeLayer, moveLayerUp, moveLayerDown, updateLayer, setCurrentTime, setPlayState } = useCutsceneStore(useShallow(s => ({
|
||||
selectLayer: s.selectLayer,
|
||||
addLayer: s.addLayer,
|
||||
removeLayer: s.removeLayer,
|
||||
moveLayerUp: s.moveLayerUp,
|
||||
moveLayerDown: s.moveLayerDown,
|
||||
updateLayer: s.updateLayer,
|
||||
setCurrentTime: s.setCurrentTime,
|
||||
setPlayState: s.setPlayState,
|
||||
})));
|
||||
|
||||
const [pxPerSec, setPxPerSec] = useState(60);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const isDraggingPlayhead = useRef(false);
|
||||
const dragState = useRef<{
|
||||
type: 'move' | 'left' | 'right';
|
||||
layerIndex: number;
|
||||
startX: number;
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
durationMs: number;
|
||||
} | null>(null);
|
||||
|
||||
const msToX = useCallback((ms: number) => (ms / 1000) * pxPerSec, [pxPerSec]);
|
||||
const xToMs = useCallback((x: number) => Math.max(0, Math.round((x / pxPerSec) * 1000)), [pxPerSec]);
|
||||
|
||||
const totalMs = cutscene
|
||||
? Math.max(
|
||||
cutscene.durationMs,
|
||||
cutscene.imageSegments.reduce((m, s) => Math.max(m, s.endMs), 0),
|
||||
10000,
|
||||
) + 2000
|
||||
: 12000;
|
||||
|
||||
const rulerWidth = Math.ceil(msToX(totalMs));
|
||||
|
||||
// Ruler ticks
|
||||
const tickStepMs = pxPerSec >= 100 ? 500 : pxPerSec >= 50 ? 1000 : 2000;
|
||||
const labelStepMs = pxPerSec >= 100 ? 1000 : pxPerSec >= 50 ? 2000 : 4000;
|
||||
const ticks: number[] = [];
|
||||
for (let ms = 0; ms <= totalMs; ms += tickStepMs) ticks.push(ms);
|
||||
|
||||
// Scroll wheel zoom
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
function onWheel(e: WheelEvent) {
|
||||
if (!e.ctrlKey && !e.metaKey) return;
|
||||
e.preventDefault();
|
||||
setPxPerSec(prev => Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, prev * (e.deltaY < 0 ? 1.15 : 0.87))));
|
||||
}
|
||||
el.addEventListener('wheel', onWheel, { passive: false });
|
||||
return () => el.removeEventListener('wheel', onWheel);
|
||||
}, []);
|
||||
|
||||
// Auto-scroll playhead into view while playing
|
||||
useEffect(() => {
|
||||
if (playState !== 'playing') return;
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const x = msToX(currentTimeMs) + LABEL_WIDTH;
|
||||
const { scrollLeft, clientWidth } = el;
|
||||
if (x > scrollLeft + clientWidth - 40) {
|
||||
el.scrollLeft = x - clientWidth + 80;
|
||||
}
|
||||
}, [currentTimeMs, playState, msToX]);
|
||||
|
||||
// ── Playhead drag ──────────────────────────────────────────────────────────
|
||||
function onRulerMouseDown(e: React.MouseEvent) {
|
||||
if (e.button !== 0) return;
|
||||
isDraggingPlayhead.current = true;
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const x = e.clientX - rect.left - LABEL_WIDTH + (scrollRef.current?.scrollLeft ?? 0);
|
||||
setCurrentTime(xToMs(x));
|
||||
if (playState === 'playing') setPlayState('paused');
|
||||
|
||||
function onMove(ev: MouseEvent) {
|
||||
const x2 = ev.clientX - rect.left - LABEL_WIDTH + (scrollRef.current?.scrollLeft ?? 0);
|
||||
setCurrentTime(xToMs(x2));
|
||||
}
|
||||
function onUp() {
|
||||
isDraggingPlayhead.current = false;
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('mouseup', onUp);
|
||||
}
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
}
|
||||
|
||||
// ── Segment bar drag ───────────────────────────────────────────────────────
|
||||
function onBarMouseDown(e: React.MouseEvent, layerIndex: number, type: 'move' | 'left' | 'right') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
selectLayer(layerIndex);
|
||||
const seg = cutscene!.imageSegments[layerIndex];
|
||||
dragState.current = {
|
||||
type,
|
||||
layerIndex,
|
||||
startX: e.clientX,
|
||||
startMs: seg.startMs,
|
||||
endMs: seg.endMs,
|
||||
durationMs: seg.endMs - seg.startMs,
|
||||
};
|
||||
|
||||
function onMove(ev: MouseEvent) {
|
||||
if (!dragState.current) return;
|
||||
const dx = ev.clientX - dragState.current.startX;
|
||||
const dMs = Math.round((dx / pxPerSec) * 1000);
|
||||
const { type, layerIndex: li, startMs, endMs, durationMs } = dragState.current;
|
||||
|
||||
if (type === 'move') {
|
||||
const newStart = Math.max(0, startMs + dMs);
|
||||
updateLayer(li, { startMs: newStart, endMs: newStart + durationMs });
|
||||
} else if (type === 'left') {
|
||||
const newStart = Math.max(0, Math.min(endMs - 100, startMs + dMs));
|
||||
updateLayer(li, { startMs: newStart });
|
||||
} else {
|
||||
const newEnd = Math.max(startMs + 100, endMs + dMs);
|
||||
updateLayer(li, { endMs: newEnd });
|
||||
}
|
||||
}
|
||||
function onUp() {
|
||||
dragState.current = null;
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('mouseup', onUp);
|
||||
}
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
}
|
||||
|
||||
const playheadX = msToX(currentTimeMs) + LABEL_WIDTH;
|
||||
|
||||
// Subtitle timing for display
|
||||
let subtitleBlocks: { x: number; w: number; text: string }[] = [];
|
||||
if (cutscene) {
|
||||
let elapsed = 0;
|
||||
for (const line of cutscene.lines) {
|
||||
const dur = line.durationMs > 0
|
||||
? line.durationMs
|
||||
: Math.max(1500, Math.round((line.text.length / 17) * 1000));
|
||||
subtitleBlocks.push({ x: msToX(elapsed), w: msToX(dur), text: line.text || '…' });
|
||||
elapsed += dur;
|
||||
}
|
||||
}
|
||||
|
||||
const layerCount = cutscene?.imageSegments.length ?? 0;
|
||||
const totalHeight = layerCount * ROW_HEIGHT + SUBTITLE_ROW_HEIGHT + 20;
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{/* Toolbar */}
|
||||
<div className={styles.toolbar}>
|
||||
<button className={styles.tbBtn} onClick={addLayer} disabled={!cutscene} title="Add layer">+ Layer</button>
|
||||
{selectedLayerIndex !== null && cutscene && (
|
||||
<>
|
||||
<button className={styles.tbBtn} onClick={() => moveLayerUp(selectedLayerIndex!)} disabled={selectedLayerIndex! <= 0} title="Move up">↑</button>
|
||||
<button className={styles.tbBtn} onClick={() => moveLayerDown(selectedLayerIndex!)} disabled={selectedLayerIndex! >= layerCount - 1} title="Move down">↓</button>
|
||||
<button className={styles.tbBtn} onClick={() => removeLayer(selectedLayerIndex!)} title="Remove layer" style={{ color: '#e06c6c' }}>✕ Layer</button>
|
||||
</>
|
||||
)}
|
||||
<div className={styles.spacer} />
|
||||
<span className={styles.zoomLabel}>Zoom:</span>
|
||||
<button className={styles.tbBtn} onClick={() => setPxPerSec(p => Math.max(MIN_ZOOM, p * 0.75))}>−</button>
|
||||
<button className={styles.tbBtn} onClick={() => setPxPerSec(p => Math.min(MAX_ZOOM, p * 1.33))}>+</button>
|
||||
<button className={styles.tbBtn} onClick={() => setPxPerSec(60)}>Reset</button>
|
||||
</div>
|
||||
|
||||
{/* Scrollable area */}
|
||||
<div className={styles.scroll} ref={scrollRef}>
|
||||
{/* Ruler */}
|
||||
<div
|
||||
className={styles.ruler}
|
||||
style={{ width: LABEL_WIDTH + rulerWidth }}
|
||||
onMouseDown={onRulerMouseDown}
|
||||
>
|
||||
<div style={{ width: LABEL_WIDTH, flexShrink: 0 }} />
|
||||
<div style={{ position: 'relative', flex: 1 }}>
|
||||
{ticks.map(ms => (
|
||||
<div
|
||||
key={ms}
|
||||
className={styles.tick}
|
||||
style={{ left: msToX(ms) }}
|
||||
>
|
||||
{ms % labelStepMs === 0 && (
|
||||
<span className={styles.tickLabel}>{ms / 1000}s</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Layers + playhead overlay */}
|
||||
<div
|
||||
className={styles.layersArea}
|
||||
style={{ width: LABEL_WIDTH + rulerWidth, minHeight: totalHeight }}
|
||||
>
|
||||
{/* Playhead */}
|
||||
<div className={styles.playhead} style={{ left: playheadX }} />
|
||||
|
||||
{/* Grid lines */}
|
||||
{ticks.filter(ms => ms % labelStepMs === 0).map(ms => (
|
||||
<div key={ms} className={styles.gridLine} style={{ left: LABEL_WIDTH + msToX(ms) }} />
|
||||
))}
|
||||
|
||||
{/* Layer rows */}
|
||||
{cutscene?.imageSegments.map((seg, i) => {
|
||||
const isSelected = selectedLayerIndex === i;
|
||||
const color = layerColor(i);
|
||||
const barX = LABEL_WIDTH + msToX(seg.startMs);
|
||||
const barW = Math.max(4, msToX(seg.endMs) - msToX(seg.startMs));
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`${styles.row} ${isSelected ? styles.rowSelected : ''}`}
|
||||
style={{ top: i * ROW_HEIGHT }}
|
||||
onClick={() => selectLayer(i)}
|
||||
>
|
||||
{/* Label */}
|
||||
<div className={styles.rowLabel} style={{ borderLeft: `3px solid ${color}` }}>
|
||||
<span className={styles.layerName}>{basename(seg.path) || `Layer ${i + 1}`}</span>
|
||||
</div>
|
||||
|
||||
{/* Bar */}
|
||||
<div
|
||||
className={styles.bar}
|
||||
style={{ left: barX, width: barW, background: color + (isSelected ? 'cc' : '88') }}
|
||||
onMouseDown={e => onBarMouseDown(e, i, 'move')}
|
||||
>
|
||||
<div className={`${styles.handle} ${styles.handleLeft}`}
|
||||
onMouseDown={e => onBarMouseDown(e, i, 'left')} />
|
||||
<div className={`${styles.handle} ${styles.handleRight}`}
|
||||
onMouseDown={e => onBarMouseDown(e, i, 'right')} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Subtitle blocks row */}
|
||||
{cutscene && (
|
||||
<div
|
||||
className={styles.subtitleRow}
|
||||
style={{ top: layerCount * ROW_HEIGHT }}
|
||||
>
|
||||
<div className={styles.subtitleLabel}>Subtitles</div>
|
||||
{subtitleBlocks.map((b, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={styles.subtitleBlock}
|
||||
style={{ left: LABEL_WIDTH + b.x, width: Math.max(2, b.w) }}
|
||||
title={b.text}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
cutsceneEditor/src/constants/easings.ts
Normal file
25
cutsceneEditor/src/constants/easings.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import type { EasingType } from '../types/cutscene';
|
||||
|
||||
export const EASING_OPTIONS: EasingType[] = [
|
||||
'Linear',
|
||||
'EaseInSine', 'EaseOutSine', 'EaseInOutSine',
|
||||
'EaseInQuad', 'EaseOutQuad', 'EaseInOutQuad',
|
||||
'EaseInCubic', 'EaseOutCubic', 'EaseInOutCubic',
|
||||
];
|
||||
|
||||
export function applyEasing(t: number, easing: EasingType): number {
|
||||
const pi = Math.PI;
|
||||
switch (easing) {
|
||||
case 'Linear': return t;
|
||||
case 'EaseInSine': return 1 - Math.cos((t * pi) / 2);
|
||||
case 'EaseOutSine': return Math.sin((t * pi) / 2);
|
||||
case 'EaseInOutSine': return -(Math.cos(pi * t) - 1) / 2;
|
||||
case 'EaseInQuad': return t * t;
|
||||
case 'EaseOutQuad': return 1 - (1 - t) * (1 - t);
|
||||
case 'EaseInOutQuad': return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
|
||||
case 'EaseInCubic': return t * t * t;
|
||||
case 'EaseOutCubic': return 1 - Math.pow(1 - t, 3);
|
||||
case 'EaseInOutCubic':return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
||||
default: return t;
|
||||
}
|
||||
}
|
||||
12
cutsceneEditor/src/constants/images.ts
Normal file
12
cutsceneEditor/src/constants/images.ts
Normal file
@ -0,0 +1,12 @@
|
||||
export const AVAILABLE_IMAGES: string[] = [
|
||||
'resources/w/cutscenes/cutscene1/cutscene1_wall_x.png',
|
||||
'resources/w/cutscenes/cutscene1/cutscene1_aida1_x.png',
|
||||
'resources/w/cutscenes/cutscene1/cutscene1_aida2_x.png',
|
||||
'resources/w/cutscenes/cutscene1/cutscene1_aida3_x.png',
|
||||
'resources/w/cutscenes/cutscene1/cutscene1_heads_x.png',
|
||||
'resources/w/cutscenes/cutscene2/scr1.png',
|
||||
'resources/w/cutscenes/cutscene2/scr2.png',
|
||||
'resources/w/cutscenes/cutscene2/scr3.png',
|
||||
'resources/w/cutscenes/cutscene2/scr4.png',
|
||||
'resources/black.png',
|
||||
];
|
||||
52
cutsceneEditor/src/hooks/usePlayback.ts
Normal file
52
cutsceneEditor/src/hooks/usePlayback.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useCutsceneStore, useSelectedCutscene } from '../store/cutsceneStore';
|
||||
|
||||
export function usePlayback() {
|
||||
const { playState, currentTimeMs, setPlayState, setCurrentTime } = useCutsceneStore();
|
||||
const cutscene = useSelectedCutscene();
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const lastTsRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (playState !== 'playing') {
|
||||
lastTsRef.current = null;
|
||||
if (rafRef.current !== null) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
function totalDuration() {
|
||||
if (!cutscene) return 5000;
|
||||
const segMax = cutscene.imageSegments.reduce((m, s) => Math.max(m, s.endMs), 0);
|
||||
const content = Math.max(cutscene.durationMs, segMax);
|
||||
return content + cutscene.endFadeOutMs + cutscene.endFadeInMs;
|
||||
}
|
||||
|
||||
function tick(ts: number) {
|
||||
if (lastTsRef.current === null) lastTsRef.current = ts;
|
||||
const delta = ts - lastTsRef.current;
|
||||
lastTsRef.current = ts;
|
||||
|
||||
const newTime = useCutsceneStore.getState().currentTimeMs + delta;
|
||||
const total = totalDuration();
|
||||
|
||||
if (newTime >= total) {
|
||||
setCurrentTime(total);
|
||||
setPlayState('stopped');
|
||||
return;
|
||||
}
|
||||
|
||||
setCurrentTime(newTime);
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
return () => {
|
||||
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
|
||||
};
|
||||
}, [playState, cutscene, setPlayState, setCurrentTime]);
|
||||
|
||||
return null;
|
||||
}
|
||||
52
cutsceneEditor/src/index.css
Normal file
52
cutsceneEditor/src/index.css
Normal file
@ -0,0 +1,52 @@
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
font-size: 13px;
|
||||
background: #1a1a1a;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
input, select, textarea {
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
background: #2a2a2a;
|
||||
color: #e0e0e0;
|
||||
border: 1px solid #444;
|
||||
border-radius: 3px;
|
||||
padding: 3px 6px;
|
||||
}
|
||||
|
||||
input:focus, select:focus, textarea:focus {
|
||||
outline: none;
|
||||
border-color: #5b9bd5;
|
||||
}
|
||||
|
||||
label {
|
||||
color: #aaa;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track { background: #1a1a1a; }
|
||||
::-webkit-scrollbar-thumb { background: #444; border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #666; }
|
||||
10
cutsceneEditor/src/main.tsx
Normal file
10
cutsceneEditor/src/main.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
242
cutsceneEditor/src/store/cutsceneStore.ts
Normal file
242
cutsceneEditor/src/store/cutsceneStore.ts
Normal file
@ -0,0 +1,242 @@
|
||||
import { create } from 'zustand';
|
||||
import { immer } from 'zustand/middleware/immer';
|
||||
import type { CutsceneFile, Cutscene, ImageSegment, SubtitleLine, ImagePose } from '../types/cutscene';
|
||||
|
||||
export type PlayState = 'stopped' | 'playing' | 'paused';
|
||||
|
||||
interface CutsceneStore {
|
||||
file: CutsceneFile | null;
|
||||
selectedCutsceneId: string | null;
|
||||
selectedLayerIndex: number | null;
|
||||
playState: PlayState;
|
||||
currentTimeMs: number;
|
||||
|
||||
// File I/O
|
||||
loadFile: (file: CutsceneFile) => void;
|
||||
getExportData: () => CutsceneFile | null;
|
||||
|
||||
// Cutscene CRUD
|
||||
addCutscene: () => void;
|
||||
deleteCutscene: (id: string) => void;
|
||||
selectCutscene: (id: string | null) => void;
|
||||
updateCutscene: (id: string, patch: Partial<Omit<Cutscene, 'imageSegments' | 'lines'>>) => void;
|
||||
|
||||
// Layer CRUD
|
||||
selectLayer: (index: number | null) => void;
|
||||
addLayer: () => void;
|
||||
removeLayer: (index: number) => void;
|
||||
moveLayerUp: (index: number) => void;
|
||||
moveLayerDown: (index: number) => void;
|
||||
updateLayer: (index: number, patch: Partial<ImageSegment>) => void;
|
||||
updateLayerFrom: (index: number, patch: Partial<ImagePose>) => void;
|
||||
updateLayerTo: (index: number, patch: Partial<ImagePose>) => void;
|
||||
|
||||
// Subtitle lines
|
||||
addLine: () => void;
|
||||
removeLine: (index: number) => void;
|
||||
moveLineUp: (index: number) => void;
|
||||
moveLineDown: (index: number) => void;
|
||||
updateLine: (index: number, patch: Partial<SubtitleLine>) => void;
|
||||
|
||||
// Playback
|
||||
setPlayState: (state: PlayState) => void;
|
||||
setCurrentTime: (ms: number) => void;
|
||||
}
|
||||
|
||||
function newCutscene(id: string): Cutscene {
|
||||
return {
|
||||
id,
|
||||
skippable: true,
|
||||
durationMs: 5000,
|
||||
fadeOutMs: 500,
|
||||
fadeInMs: 500,
|
||||
endFadeOutMs: 500,
|
||||
endFadeInMs: 500,
|
||||
onFadeInCallback: '',
|
||||
imageSegments: [],
|
||||
lines: [],
|
||||
};
|
||||
}
|
||||
|
||||
function newSegment(): ImageSegment {
|
||||
return {
|
||||
path: '',
|
||||
width: 1280,
|
||||
height: 720,
|
||||
startMs: 0,
|
||||
endMs: 5000,
|
||||
fadeInMs: 0,
|
||||
fadeOutMs: 0,
|
||||
easing: 'Linear',
|
||||
from: { centerX: 0.5, centerY: 0.5, scale: 1.0 },
|
||||
to: { centerX: 0.5, centerY: 0.5, scale: 1.0 },
|
||||
};
|
||||
}
|
||||
|
||||
function newLine(): SubtitleLine {
|
||||
return {
|
||||
speaker: '',
|
||||
text: '',
|
||||
durationMs: 3000,
|
||||
waitForConfirm: false,
|
||||
luaCallback: '',
|
||||
};
|
||||
}
|
||||
|
||||
function getSelected(file: CutsceneFile | null, id: string | null): Cutscene | null {
|
||||
if (!file || !id) return null;
|
||||
return file.cutscenes.find(c => c.id === id) ?? null;
|
||||
}
|
||||
|
||||
export const useCutsceneStore = create<CutsceneStore>()(
|
||||
immer((set, get) => ({
|
||||
file: null,
|
||||
selectedCutsceneId: null,
|
||||
selectedLayerIndex: null,
|
||||
playState: 'stopped',
|
||||
currentTimeMs: 0,
|
||||
|
||||
loadFile: (file) => set(s => {
|
||||
s.file = file;
|
||||
s.selectedCutsceneId = file.cutscenes[0]?.id ?? null;
|
||||
s.selectedLayerIndex = null;
|
||||
s.playState = 'stopped';
|
||||
s.currentTimeMs = 0;
|
||||
}),
|
||||
|
||||
getExportData: () => get().file,
|
||||
|
||||
addCutscene: () => set(s => {
|
||||
if (!s.file) s.file = { cutscenes: [] };
|
||||
let base = 'cutscene_new';
|
||||
let n = 1;
|
||||
const ids = new Set(s.file.cutscenes.map(c => c.id));
|
||||
while (ids.has(`${base}_${n}`)) n++;
|
||||
const id = `${base}_${n}`;
|
||||
s.file.cutscenes.push(newCutscene(id));
|
||||
s.selectedCutsceneId = id;
|
||||
s.selectedLayerIndex = null;
|
||||
}),
|
||||
|
||||
deleteCutscene: (id) => set(s => {
|
||||
if (!s.file) return;
|
||||
const idx = s.file.cutscenes.findIndex(c => c.id === id);
|
||||
if (idx === -1) return;
|
||||
s.file.cutscenes.splice(idx, 1);
|
||||
if (s.selectedCutsceneId === id) {
|
||||
s.selectedCutsceneId = s.file.cutscenes[0]?.id ?? null;
|
||||
s.selectedLayerIndex = null;
|
||||
}
|
||||
}),
|
||||
|
||||
selectCutscene: (id) => set(s => {
|
||||
s.selectedCutsceneId = id;
|
||||
s.selectedLayerIndex = null;
|
||||
s.playState = 'stopped';
|
||||
s.currentTimeMs = 0;
|
||||
}),
|
||||
|
||||
updateCutscene: (id, patch) => set(s => {
|
||||
if (!s.file) return;
|
||||
const c = s.file.cutscenes.find(c => c.id === id);
|
||||
if (!c) return;
|
||||
Object.assign(c, patch);
|
||||
}),
|
||||
|
||||
selectLayer: (index) => set(s => { s.selectedLayerIndex = index; }),
|
||||
|
||||
addLayer: () => set(s => {
|
||||
const cutscene = getSelected(s.file, s.selectedCutsceneId);
|
||||
if (!cutscene) return;
|
||||
cutscene.imageSegments.push(newSegment());
|
||||
s.selectedLayerIndex = cutscene.imageSegments.length - 1;
|
||||
}),
|
||||
|
||||
removeLayer: (index) => set(s => {
|
||||
const cutscene = getSelected(s.file, s.selectedCutsceneId);
|
||||
if (!cutscene) return;
|
||||
cutscene.imageSegments.splice(index, 1);
|
||||
if (s.selectedLayerIndex === index) s.selectedLayerIndex = null;
|
||||
else if (s.selectedLayerIndex !== null && s.selectedLayerIndex > index) s.selectedLayerIndex--;
|
||||
}),
|
||||
|
||||
moveLayerUp: (index) => set(s => {
|
||||
const cutscene = getSelected(s.file, s.selectedCutsceneId);
|
||||
if (!cutscene || index <= 0) return;
|
||||
const segs = cutscene.imageSegments;
|
||||
[segs[index - 1], segs[index]] = [segs[index], segs[index - 1]];
|
||||
if (s.selectedLayerIndex === index) s.selectedLayerIndex = index - 1;
|
||||
else if (s.selectedLayerIndex === index - 1) s.selectedLayerIndex = index;
|
||||
}),
|
||||
|
||||
moveLayerDown: (index) => set(s => {
|
||||
const cutscene = getSelected(s.file, s.selectedCutsceneId);
|
||||
if (!cutscene) return;
|
||||
const segs = cutscene.imageSegments;
|
||||
if (index >= segs.length - 1) return;
|
||||
[segs[index], segs[index + 1]] = [segs[index + 1], segs[index]];
|
||||
if (s.selectedLayerIndex === index) s.selectedLayerIndex = index + 1;
|
||||
else if (s.selectedLayerIndex === index + 1) s.selectedLayerIndex = index;
|
||||
}),
|
||||
|
||||
updateLayer: (index, patch) => set(s => {
|
||||
const cutscene = getSelected(s.file, s.selectedCutsceneId);
|
||||
if (!cutscene) return;
|
||||
Object.assign(cutscene.imageSegments[index], patch);
|
||||
}),
|
||||
|
||||
updateLayerFrom: (index, patch) => set(s => {
|
||||
const cutscene = getSelected(s.file, s.selectedCutsceneId);
|
||||
if (!cutscene) return;
|
||||
Object.assign(cutscene.imageSegments[index].from, patch);
|
||||
}),
|
||||
|
||||
updateLayerTo: (index, patch) => set(s => {
|
||||
const cutscene = getSelected(s.file, s.selectedCutsceneId);
|
||||
if (!cutscene) return;
|
||||
Object.assign(cutscene.imageSegments[index].to, patch);
|
||||
}),
|
||||
|
||||
addLine: () => set(s => {
|
||||
const cutscene = getSelected(s.file, s.selectedCutsceneId);
|
||||
if (!cutscene) return;
|
||||
cutscene.lines.push(newLine());
|
||||
}),
|
||||
|
||||
removeLine: (index) => set(s => {
|
||||
const cutscene = getSelected(s.file, s.selectedCutsceneId);
|
||||
if (!cutscene) return;
|
||||
cutscene.lines.splice(index, 1);
|
||||
}),
|
||||
|
||||
moveLineUp: (index) => set(s => {
|
||||
const cutscene = getSelected(s.file, s.selectedCutsceneId);
|
||||
if (!cutscene || index <= 0) return;
|
||||
const lines = cutscene.lines;
|
||||
[lines[index - 1], lines[index]] = [lines[index], lines[index - 1]];
|
||||
}),
|
||||
|
||||
moveLineDown: (index) => set(s => {
|
||||
const cutscene = getSelected(s.file, s.selectedCutsceneId);
|
||||
if (!cutscene) return;
|
||||
const lines = cutscene.lines;
|
||||
if (index >= lines.length - 1) return;
|
||||
[lines[index], lines[index + 1]] = [lines[index + 1], lines[index]];
|
||||
}),
|
||||
|
||||
updateLine: (index, patch) => set(s => {
|
||||
const cutscene = getSelected(s.file, s.selectedCutsceneId);
|
||||
if (!cutscene) return;
|
||||
Object.assign(cutscene.lines[index], patch);
|
||||
}),
|
||||
|
||||
setPlayState: (state) => set(s => { s.playState = state; }),
|
||||
setCurrentTime: (ms) => set(s => { s.currentTimeMs = ms; }),
|
||||
}))
|
||||
);
|
||||
|
||||
export function useSelectedCutscene() {
|
||||
return useCutsceneStore(s =>
|
||||
s.file?.cutscenes.find(c => c.id === s.selectedCutsceneId) ?? null
|
||||
);
|
||||
}
|
||||
49
cutsceneEditor/src/types/cutscene.ts
Normal file
49
cutsceneEditor/src/types/cutscene.ts
Normal file
@ -0,0 +1,49 @@
|
||||
export type EasingType =
|
||||
| 'Linear'
|
||||
| 'EaseInSine' | 'EaseOutSine' | 'EaseInOutSine'
|
||||
| 'EaseInQuad' | 'EaseOutQuad' | 'EaseInOutQuad'
|
||||
| 'EaseInCubic' | 'EaseOutCubic' | 'EaseInOutCubic';
|
||||
|
||||
export interface ImagePose {
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
scale: number;
|
||||
}
|
||||
|
||||
export interface ImageSegment {
|
||||
path: string;
|
||||
width: number;
|
||||
height: number;
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
fadeInMs: number;
|
||||
fadeOutMs: number;
|
||||
easing: EasingType;
|
||||
from: ImagePose;
|
||||
to: ImagePose;
|
||||
}
|
||||
|
||||
export interface SubtitleLine {
|
||||
speaker: string;
|
||||
text: string;
|
||||
durationMs: number;
|
||||
waitForConfirm: boolean;
|
||||
luaCallback: string;
|
||||
}
|
||||
|
||||
export interface Cutscene {
|
||||
id: string;
|
||||
skippable: boolean;
|
||||
durationMs: number;
|
||||
fadeOutMs: number;
|
||||
fadeInMs: number;
|
||||
endFadeOutMs: number;
|
||||
endFadeInMs: number;
|
||||
onFadeInCallback: string;
|
||||
imageSegments: ImageSegment[];
|
||||
lines: SubtitleLine[];
|
||||
}
|
||||
|
||||
export interface CutsceneFile {
|
||||
cutscenes: Cutscene[];
|
||||
}
|
||||
76
cutsceneEditor/src/utils/fileIO.ts
Normal file
76
cutsceneEditor/src/utils/fileIO.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import type { CutsceneFile, Cutscene, ImageSegment, SubtitleLine } from '../types/cutscene';
|
||||
|
||||
function parseSegment(raw: Record<string, unknown>): ImageSegment {
|
||||
const from = (raw.from as Record<string, number> | undefined) ?? {};
|
||||
const to = (raw.to as Record<string, number> | undefined) ?? {};
|
||||
return {
|
||||
path: String(raw.path ?? ''),
|
||||
width: Number(raw.width ?? 1280),
|
||||
height: Number(raw.height ?? 720),
|
||||
startMs: Number(raw.startMs ?? 0),
|
||||
endMs: Number(raw.endMs ?? 5000),
|
||||
fadeInMs: Number(raw.fadeInMs ?? 0),
|
||||
fadeOutMs: Number(raw.fadeOutMs ?? 0),
|
||||
easing: String(raw.easing ?? 'Linear') as ImageSegment['easing'],
|
||||
from: {
|
||||
centerX: Number(from.centerX ?? 0.5),
|
||||
centerY: Number(from.centerY ?? 0.5),
|
||||
scale: Number(from.scale ?? 1.0),
|
||||
},
|
||||
to: {
|
||||
centerX: Number(to.centerX ?? from.centerX ?? 0.5),
|
||||
centerY: Number(to.centerY ?? from.centerY ?? 0.5),
|
||||
scale: Number(to.scale ?? from.scale ?? 1.0),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseLine(raw: Record<string, unknown>): SubtitleLine {
|
||||
return {
|
||||
speaker: String(raw.speaker ?? ''),
|
||||
text: String(raw.text ?? ''),
|
||||
durationMs: Number(raw.durationMs ?? 0),
|
||||
waitForConfirm: Boolean(raw.waitForConfirm ?? false),
|
||||
luaCallback: String(raw.luaCallback ?? ''),
|
||||
};
|
||||
}
|
||||
|
||||
function parseCutscene(raw: Record<string, unknown>): Cutscene {
|
||||
const segments = Array.isArray(raw.imageSegments)
|
||||
? (raw.imageSegments as Record<string, unknown>[]).map(parseSegment)
|
||||
: [];
|
||||
const lines = Array.isArray(raw.lines)
|
||||
? (raw.lines as Record<string, unknown>[]).map(parseLine)
|
||||
: [];
|
||||
return {
|
||||
id: String(raw.id ?? 'untitled'),
|
||||
skippable: raw.skippable !== false,
|
||||
durationMs: Number(raw.durationMs ?? 0),
|
||||
fadeOutMs: Number(raw.fadeOutMs ?? 0),
|
||||
fadeInMs: Number(raw.fadeInMs ?? 0),
|
||||
endFadeOutMs: Number(raw.endFadeOutMs ?? 0),
|
||||
endFadeInMs: Number(raw.endFadeInMs ?? 0),
|
||||
onFadeInCallback: String(raw.onFadeInCallback ?? ''),
|
||||
imageSegments: segments,
|
||||
lines,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseFile(json: unknown): CutsceneFile {
|
||||
const raw = json as Record<string, unknown>;
|
||||
const cutscenes = Array.isArray(raw.cutscenes)
|
||||
? (raw.cutscenes as Record<string, unknown>[]).map(parseCutscene)
|
||||
: [];
|
||||
return { cutscenes };
|
||||
}
|
||||
|
||||
export function triggerDownload(file: CutsceneFile, filename = 'cutscenes.json') {
|
||||
const json = JSON.stringify(file, null, 4);
|
||||
const blob = new Blob([json], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
79
cutsceneEditor/src/utils/rendering.ts
Normal file
79
cutsceneEditor/src/utils/rendering.ts
Normal file
@ -0,0 +1,79 @@
|
||||
import type { ImageSegment, ImagePose } from '../types/cutscene';
|
||||
import { applyEasing } from '../constants/easings';
|
||||
|
||||
function clamp(v: number, min: number, max: number) {
|
||||
return Math.max(min, Math.min(max, v));
|
||||
}
|
||||
|
||||
function lerp(a: number, b: number, t: number) {
|
||||
return a + (b - a) * t;
|
||||
}
|
||||
|
||||
export interface SegmentRenderState {
|
||||
alpha: number;
|
||||
pose: ImagePose;
|
||||
}
|
||||
|
||||
export function computeSegmentState(seg: ImageSegment, currentMs: number): SegmentRenderState | null {
|
||||
if (currentMs < seg.startMs || currentMs > seg.endMs) return null;
|
||||
|
||||
const duration = seg.endMs - seg.startMs;
|
||||
const localMs = currentMs - seg.startMs;
|
||||
const t = duration > 0 ? clamp(localMs / duration, 0, 1) : 1;
|
||||
const tEased = applyEasing(t, seg.easing);
|
||||
|
||||
const pose: ImagePose = {
|
||||
centerX: lerp(seg.from.centerX, seg.to.centerX, tEased),
|
||||
centerY: lerp(seg.from.centerY, seg.to.centerY, tEased),
|
||||
scale: lerp(seg.from.scale, seg.to.scale, tEased),
|
||||
};
|
||||
|
||||
let alpha = 1;
|
||||
if (seg.fadeInMs > 0 && localMs < seg.fadeInMs) {
|
||||
alpha = localMs / seg.fadeInMs;
|
||||
} else if (seg.fadeOutMs > 0 && localMs > duration - seg.fadeOutMs) {
|
||||
alpha = (duration - localMs) / seg.fadeOutMs;
|
||||
}
|
||||
|
||||
return { alpha: clamp(alpha, 0, 1), pose };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns CSS for an absolutely-positioned <img> inside the viewport div.
|
||||
*
|
||||
* The image is stretched (object-fit: fill) to exactly logicalW × logicalH
|
||||
* logical pixels on screen — matching how the game engine maps the full
|
||||
* texture quad to those dimensions, regardless of the file's natural size.
|
||||
*
|
||||
* At scale=1 the logical area fills the viewport (aspect-ratio corrected).
|
||||
* The viewport's own overflow:hidden clips anything that extends beyond.
|
||||
*/
|
||||
export function poseToStyle(
|
||||
pose: ImagePose,
|
||||
logicalW: number, // segment.width (e.g. 1280)
|
||||
logicalH: number, // segment.height (e.g. 720)
|
||||
containerW: number,
|
||||
containerH: number,
|
||||
): React.CSSProperties {
|
||||
const baseScale = Math.max(containerW / logicalW, containerH / logicalH);
|
||||
const renderW = logicalW * baseScale * pose.scale;
|
||||
const renderH = logicalH * baseScale * pose.scale;
|
||||
|
||||
const maxOffsetX = Math.max(0, (renderW - containerW) / 2);
|
||||
const maxOffsetY = Math.max(0, (renderH - containerH) / 2);
|
||||
const offsetX = clamp((0.5 - pose.centerX) * renderW, -maxOffsetX, maxOffsetX);
|
||||
// Y axis is inverted: centerY=0 → bottom of image, centerY=1 → top (Y-up convention)
|
||||
const offsetY = clamp((pose.centerY - 0.5) * renderH, -maxOffsetY, maxOffsetY);
|
||||
|
||||
return {
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
width: renderW,
|
||||
height: renderH,
|
||||
// Default object-fit (fill) stretches the full texture to these logical
|
||||
// dimensions, exactly as the game engine renders the quad.
|
||||
objectFit: 'fill',
|
||||
transform: `translate(calc(-50% + ${offsetX}px), calc(-50% + ${offsetY}px))`,
|
||||
};
|
||||
}
|
||||
6
cutsceneEditor/src/vite-env.d.ts
vendored
Normal file
6
cutsceneEditor/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>;
|
||||
export default classes;
|
||||
}
|
||||
20
cutsceneEditor/tsconfig.json
Normal file
20
cutsceneEditor/tsconfig.json
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
26
cutsceneEditor/vite.config.ts
Normal file
26
cutsceneEditor/vite.config.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
{
|
||||
name: 'serve-resources',
|
||||
configureServer(server) {
|
||||
server.middlewares.use('/resources', (req, res, next) => {
|
||||
const filePath = path.join(process.cwd(), 'resources', decodeURIComponent(req.url ?? ''));
|
||||
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const mime = ext === '.png' ? 'image/png' : ext === '.jpg' ? 'image/jpeg' : 'application/octet-stream';
|
||||
res.setHeader('Content-Type', mime);
|
||||
fs.createReadStream(filePath).pipe(res as import('stream').Writable);
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
@ -13,20 +13,16 @@
|
||||
"path": "resources/w/cutscenes/cutscene1/cutscene1_wall_x.png",
|
||||
"startMs": 0,
|
||||
"endMs": 8000,
|
||||
"fadeInMs": 300,
|
||||
"fadeInMs": 0,
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"from": {
|
||||
"centerX": 0.4,
|
||||
"centerY": 0.5,
|
||||
"scale": 1.1
|
||||
"centerX": 0.3, "scale": 1.2
|
||||
},
|
||||
"to": {
|
||||
"centerX": 0.6,
|
||||
"centerY": 0.5,
|
||||
"scale": 1.0
|
||||
"centerX": 0.7, "scale": 1.2
|
||||
},
|
||||
"easing": "EaseInOutSine"
|
||||
"easing": "Linear"
|
||||
},
|
||||
{
|
||||
"path": "resources/w/cutscenes/cutscene1/cutscene1_aida1_x.png",
|
||||
@ -50,22 +46,18 @@
|
||||
"lines": [
|
||||
{
|
||||
"speaker": "Аида Дженибековна",
|
||||
"portrait": "resources/dialogue/portrait_teacher.png",
|
||||
"text": "Здравствуйте, студенты. Кого я вижу, где вы были весь семестр?",
|
||||
"text": "Здравствуйте, студенты.. Кого я вижу, где вы были весь семестр?",
|
||||
"durationMs": 3000
|
||||
},
|
||||
{
|
||||
"speaker": "Аида Дженибековна",
|
||||
"portrait": "resources/dialogue/portrait_teacher.png",
|
||||
"text": "В эпизоде \"Семетей\" трилогии \"Манас\", изменники Канчоро и Кыяз захватывают власть над кыргызами.",
|
||||
"durationMs": 3000
|
||||
},
|
||||
{
|
||||
"speaker": "Аида Дженибековна",
|
||||
"portrait": "resources/dialogue/portrait_teacher.png",
|
||||
"text": "На сегодня лекция завершена. Домашнее задание - к практическому занятию вы должны подготовить презентации, каждый по своей теме.",
|
||||
"durationMs": 2000,
|
||||
"background": "resources/test_cutscene001.png"
|
||||
"durationMs": 2000
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
396
resources/dialogue/cutscenes001.json
Normal file
396
resources/dialogue/cutscenes001.json
Normal file
@ -0,0 +1,396 @@
|
||||
{
|
||||
"cutscenes": [
|
||||
{
|
||||
"id": "test_cutscene_01",
|
||||
"background" : "resources/black.png",
|
||||
"skippable": true,
|
||||
"durationMs": 5000,
|
||||
"fadeOutMs": 500,
|
||||
"fadeInMs": 500,
|
||||
"endFadeOutMs": 500,
|
||||
"endFadeInMs": 500,
|
||||
"onFadeInCallback": "",
|
||||
"imageSegments": [
|
||||
{
|
||||
"path": "resources/w/cutscenes/cutscene1/cutscene1_wall_x.png",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"startMs": 0,
|
||||
"endMs": 3133,
|
||||
"fadeInMs": 0,
|
||||
"fadeOutMs": 2000,
|
||||
"easing": "EaseOutCubic",
|
||||
"from": {
|
||||
"centerX": 0.51,
|
||||
"centerY": 0.5,
|
||||
"scale": 1.2
|
||||
},
|
||||
"to": {
|
||||
"centerX": 0.58,
|
||||
"centerY": 0.5,
|
||||
"scale": 1.15
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "resources/w/cutscenes/cutscene1/cutscene1_aida1_x.png",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"startMs": 0,
|
||||
"endMs": 3150,
|
||||
"fadeInMs": 0,
|
||||
"fadeOutMs": 2000,
|
||||
"easing": "EaseOutCubic",
|
||||
"from": {
|
||||
"centerX": 0.52,
|
||||
"centerY": 0.5,
|
||||
"scale": 1.2
|
||||
},
|
||||
"to": {
|
||||
"centerX": 0.575,
|
||||
"centerY": 0.5,
|
||||
"scale": 1.15
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "resources/w/cutscenes/cutscene1/cutscene1_heads_x.png",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"startMs": 0,
|
||||
"endMs": 3150,
|
||||
"fadeInMs": 0,
|
||||
"fadeOutMs": 2000,
|
||||
"easing": "EaseOutCubic",
|
||||
"from": {
|
||||
"centerX": 0.56,
|
||||
"centerY": 0.64,
|
||||
"scale": 1.3
|
||||
},
|
||||
"to": {
|
||||
"centerX": 0.5,
|
||||
"centerY": 0.63,
|
||||
"scale": 1.4
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "resources/w/cutscenes/cutscene1/cutscene1_wall_x.png",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"startMs": 3050,
|
||||
"endMs": 12150,
|
||||
"fadeInMs": 1000,
|
||||
"fadeOutMs": 1000,
|
||||
"easing": "Linear",
|
||||
"from": {
|
||||
"centerX": 0.56,
|
||||
"centerY": 0.5,
|
||||
"scale": 1.2
|
||||
},
|
||||
"to": {
|
||||
"centerX": 0.5,
|
||||
"centerY": 0.5,
|
||||
"scale": 1.1
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "resources/w/cutscenes/cutscene1/cutscene1_aida3_x.png",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"startMs": 3050,
|
||||
"endMs": 12166,
|
||||
"fadeInMs": 1100,
|
||||
"fadeOutMs": 1000,
|
||||
"easing": "Linear",
|
||||
"from": {
|
||||
"centerX": 0.52,
|
||||
"centerY": 0.5,
|
||||
"scale": 1.2
|
||||
},
|
||||
"to": {
|
||||
"centerX": 0.5,
|
||||
"centerY": 0.5,
|
||||
"scale": 1.1
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "resources/w/cutscenes/cutscene1/cutscene1_heads_x.png",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"startMs": 3033,
|
||||
"endMs": 12050,
|
||||
"fadeInMs": 1000,
|
||||
"fadeOutMs": 1000,
|
||||
"easing": "Linear",
|
||||
"from": {
|
||||
"centerX": 0.5,
|
||||
"centerY": 0.7,
|
||||
"scale": 1.3
|
||||
},
|
||||
"to": {
|
||||
"centerX": 0.58,
|
||||
"centerY": 0.7,
|
||||
"scale": 1.2
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "resources/w/cutscenes/cutscene1/cutscene1_wall_x.png",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"startMs": 11115,
|
||||
"endMs": 18149,
|
||||
"fadeInMs": 1000,
|
||||
"fadeOutMs": 1000,
|
||||
"easing": "Linear",
|
||||
"from": {
|
||||
"centerX": 0.59,
|
||||
"centerY": 0.5,
|
||||
"scale": 1.3
|
||||
},
|
||||
"to": {
|
||||
"centerX": 0.49,
|
||||
"centerY": 0.5,
|
||||
"scale": 1.2
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "resources/w/cutscenes/cutscene1/cutscene1_aida2_x.png",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"startMs": 12032,
|
||||
"endMs": 18165,
|
||||
"fadeInMs": 1000,
|
||||
"fadeOutMs": 1000,
|
||||
"easing": "Linear",
|
||||
"from": {
|
||||
"centerX": 0.53,
|
||||
"centerY": 0.5,
|
||||
"scale": 1.3
|
||||
},
|
||||
"to": {
|
||||
"centerX": 0.49,
|
||||
"centerY": 0.5,
|
||||
"scale": 1.2
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "resources/w/cutscenes/cutscene1/cutscene1_heads_x.png",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"startMs": 11951,
|
||||
"endMs": 18150,
|
||||
"fadeInMs": 1000,
|
||||
"fadeOutMs": 1000,
|
||||
"easing": "Linear",
|
||||
"from": {
|
||||
"centerX": 0.45,
|
||||
"centerY": 0.8,
|
||||
"scale": 1.3
|
||||
},
|
||||
"to": {
|
||||
"centerX": 0.6,
|
||||
"centerY": 0.8,
|
||||
"scale": 1.26
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "resources/w/cutscenes/cutscene1/cutscene1_wall_x.png",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"startMs": 23967,
|
||||
"endMs": 30000,
|
||||
"fadeInMs": 500,
|
||||
"fadeOutMs": 500,
|
||||
"easing": "Linear",
|
||||
"from": {
|
||||
"centerX": 0.64,
|
||||
"centerY": 0.3,
|
||||
"scale": 2
|
||||
},
|
||||
"to": {
|
||||
"centerX": 0.5,
|
||||
"centerY": 0.3,
|
||||
"scale": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "resources/w/cutscenes/cutscene1/cutscene1_aida3_x.png",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"startMs": 24016,
|
||||
"endMs": 30000,
|
||||
"fadeInMs": 500,
|
||||
"fadeOutMs": 500,
|
||||
"easing": "Linear",
|
||||
"from": {
|
||||
"centerX": 0.63,
|
||||
"centerY": 0.3,
|
||||
"scale": 2
|
||||
},
|
||||
"to": {
|
||||
"centerX": 0.51,
|
||||
"centerY": 0.3,
|
||||
"scale": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "resources/w/cutscenes/cutscene1/cutscene1_wall_x.png",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"startMs": 18034,
|
||||
"endMs": 24151,
|
||||
"fadeInMs": 1000,
|
||||
"fadeOutMs": 700,
|
||||
"easing": "EaseOutSine",
|
||||
"from": {
|
||||
"centerX": 0.25,
|
||||
"centerY": 0.2,
|
||||
"scale": 2.3
|
||||
},
|
||||
"to": {
|
||||
"centerX": 0.3,
|
||||
"centerY": 0.2,
|
||||
"scale": 2.3
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "resources/w/cutscenes/cutscene1/cutscene1_wall_x.png",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"startMs": 30000,
|
||||
"endMs": 33683,
|
||||
"fadeInMs": 500,
|
||||
"fadeOutMs": 1000,
|
||||
"easing": "Linear",
|
||||
"from": {
|
||||
"centerX": 0.45,
|
||||
"centerY": 0.5,
|
||||
"scale": 1.1
|
||||
},
|
||||
"to": {
|
||||
"centerX": 0.5,
|
||||
"centerY": 0.5,
|
||||
"scale": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "resources/w/cutscenes/cutscene1/cutscene1_aida2_x.png",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"startMs": 30333,
|
||||
"endMs": 33466,
|
||||
"fadeInMs": 500,
|
||||
"fadeOutMs": 1000,
|
||||
"easing": "Linear",
|
||||
"from": {
|
||||
"centerX": 0.43,
|
||||
"centerY": 0.5,
|
||||
"scale": 1.1
|
||||
},
|
||||
"to": {
|
||||
"centerX": 0.5,
|
||||
"centerY": 0.5,
|
||||
"scale": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "resources/w/cutscenes/cutscene1/cutscene1_heads_x.png",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"startMs": 30400,
|
||||
"endMs": 33483,
|
||||
"fadeInMs": 0,
|
||||
"fadeOutMs": 1000,
|
||||
"easing": "Linear",
|
||||
"from": {
|
||||
"centerX": 0.5,
|
||||
"centerY": 0.65,
|
||||
"scale": 1.2
|
||||
},
|
||||
"to": {
|
||||
"centerX": 0.5,
|
||||
"centerY": 0.6,
|
||||
"scale": 1.2
|
||||
}
|
||||
}
|
||||
],
|
||||
"lines": [
|
||||
{
|
||||
"speaker": "Аида Дженибековна",
|
||||
"text": "Опаздывающие, заходите скорее и занимайте свои места! Лекция начинается!",
|
||||
"durationMs": 3000,
|
||||
"waitForConfirm": false,
|
||||
"luaCallback": ""
|
||||
},
|
||||
{
|
||||
"speaker": "Аида Дженибековна",
|
||||
"text": "В эпизоде \"Семетей\" трилогии \"Манас\", изменники Канчоро и Кыяз захватывают власть над кыргызами.",
|
||||
"durationMs": 3000,
|
||||
"waitForConfirm": false,
|
||||
"luaCallback": ""
|
||||
},
|
||||
{
|
||||
"speaker": "Аида Дженибековна",
|
||||
"text": "Чтобы спасти раненого богатыря Семетея, фея Кёкмончок с помощью заклинания уводит его в иной мир.",
|
||||
"durationMs": 3000,
|
||||
"waitForConfirm": false,
|
||||
"luaCallback": ""
|
||||
},
|
||||
{
|
||||
"speaker": "Аида Дженибековна",
|
||||
"text": "Этот мир описан в эпосе Манас как Кайып или Аль-Гайб, но некоторые ученые называют его миром теней.",
|
||||
"durationMs": 3000,
|
||||
"waitForConfirm": false,
|
||||
"luaCallback": ""
|
||||
},
|
||||
{
|
||||
"speaker": "Аида Дженибековна",
|
||||
"text": "В этом мире обитают феи, духи и джинны. Простым смертным в этот мир дорога закрыта.",
|
||||
"durationMs": 3000,
|
||||
"waitForConfirm": false,
|
||||
"luaCallback": ""
|
||||
},
|
||||
{
|
||||
"speaker": "Аида Дженибековна",
|
||||
"text": "Время там течет по другому - за один день в теневом мире могут пройти годы жизни обычного мира.",
|
||||
"durationMs": 3000,
|
||||
"waitForConfirm": false,
|
||||
"luaCallback": ""
|
||||
},
|
||||
{
|
||||
"speaker": "Аида Дженибековна",
|
||||
"text": "Обычно, мир теней никак не пересекается с нашим миром живых людей.",
|
||||
"durationMs": 3000,
|
||||
"waitForConfirm": false,
|
||||
"luaCallback": ""
|
||||
},
|
||||
{
|
||||
"speaker": "",
|
||||
"text": "Но в критические моменты для народа, обитатели теневого мира могут приходить в наш мир.",
|
||||
"durationMs": 3000,
|
||||
"waitForConfirm": false,
|
||||
"luaCallback": ""
|
||||
},
|
||||
{
|
||||
"speaker": "Аида Дженибековна",
|
||||
"text": "Совсем недавно закончилась пандемия, а сегодня мир захлестнули кровавые войны.",
|
||||
"durationMs": 3000,
|
||||
"waitForConfirm": false,
|
||||
"luaCallback": ""
|
||||
},
|
||||
{
|
||||
"speaker": "Аида Дженибековна",
|
||||
"text": "В такие кризисные моменты истории, грань между мирами становится особенно тонкой.",
|
||||
"durationMs": 3000,
|
||||
"waitForConfirm": false,
|
||||
"luaCallback": ""
|
||||
},
|
||||
{
|
||||
"speaker": "Аида Дженибековна",
|
||||
"text": "На сегодня лекция завершена. Все свободны! Задания на этот модуль вы получите индивидуально!",
|
||||
"durationMs": 3000,
|
||||
"waitForConfirm": false,
|
||||
"luaCallback": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
415
resources/dialogue/cutscenes002.json
Normal file
415
resources/dialogue/cutscenes002.json
Normal file
@ -0,0 +1,415 @@
|
||||
{
|
||||
"cutscenes": [
|
||||
{
|
||||
"id": "test_cutscene_01",
|
||||
"skippable": true,
|
||||
"durationMs": 34000,
|
||||
"fadeOutMs": 500,
|
||||
"fadeInMs": 500,
|
||||
"endFadeOutMs": 0,
|
||||
"endFadeInMs": 0,
|
||||
"onFadeInCallback": "",
|
||||
"imageSegments": [
|
||||
{
|
||||
"path": "resources/black.png",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"startMs": 0,
|
||||
"endMs": 34000,
|
||||
"fadeInMs": 0,
|
||||
"fadeOutMs": 0,
|
||||
"easing": "Linear",
|
||||
"from": {
|
||||
"centerX": 0.5,
|
||||
"centerY": 0.5,
|
||||
"scale": 1
|
||||
},
|
||||
"to": {
|
||||
"centerX": 0.5,
|
||||
"centerY": 0.5,
|
||||
"scale": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "resources/w/cutscenes/cutscene1/cutscene1_wall_x.png",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"startMs": 0,
|
||||
"endMs": 3133,
|
||||
"fadeInMs": 0,
|
||||
"fadeOutMs": 2000,
|
||||
"easing": "EaseOutCubic",
|
||||
"from": {
|
||||
"centerX": 0.51,
|
||||
"centerY": 0.5,
|
||||
"scale": 1.2
|
||||
},
|
||||
"to": {
|
||||
"centerX": 0.58,
|
||||
"centerY": 0.5,
|
||||
"scale": 1.15
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "resources/w/cutscenes/cutscene1/cutscene1_aida1_x.png",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"startMs": 0,
|
||||
"endMs": 3150,
|
||||
"fadeInMs": 0,
|
||||
"fadeOutMs": 2000,
|
||||
"easing": "EaseOutCubic",
|
||||
"from": {
|
||||
"centerX": 0.52,
|
||||
"centerY": 0.5,
|
||||
"scale": 1.2
|
||||
},
|
||||
"to": {
|
||||
"centerX": 0.575,
|
||||
"centerY": 0.5,
|
||||
"scale": 1.15
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "resources/w/cutscenes/cutscene1/cutscene1_heads_x.png",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"startMs": 0,
|
||||
"endMs": 3150,
|
||||
"fadeInMs": 0,
|
||||
"fadeOutMs": 2000,
|
||||
"easing": "EaseOutCubic",
|
||||
"from": {
|
||||
"centerX": 0.56,
|
||||
"centerY": 0.36,
|
||||
"scale": 1.3
|
||||
},
|
||||
"to": {
|
||||
"centerX": 0.5,
|
||||
"centerY": 0.37,
|
||||
"scale": 1.4
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "resources/w/cutscenes/cutscene1/cutscene1_wall_x.png",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"startMs": 3050,
|
||||
"endMs": 12150,
|
||||
"fadeInMs": 1000,
|
||||
"fadeOutMs": 1000,
|
||||
"easing": "Linear",
|
||||
"from": {
|
||||
"centerX": 0.56,
|
||||
"centerY": 0.5,
|
||||
"scale": 1.2
|
||||
},
|
||||
"to": {
|
||||
"centerX": 0.5,
|
||||
"centerY": 0.5,
|
||||
"scale": 1.1
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "resources/w/cutscenes/cutscene1/cutscene1_aida3_x.png",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"startMs": 3050,
|
||||
"endMs": 12166,
|
||||
"fadeInMs": 1100,
|
||||
"fadeOutMs": 1000,
|
||||
"easing": "Linear",
|
||||
"from": {
|
||||
"centerX": 0.52,
|
||||
"centerY": 0.5,
|
||||
"scale": 1.2
|
||||
},
|
||||
"to": {
|
||||
"centerX": 0.5,
|
||||
"centerY": 0.5,
|
||||
"scale": 1.1
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "resources/w/cutscenes/cutscene1/cutscene1_heads_x.png",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"startMs": 3033,
|
||||
"endMs": 12050,
|
||||
"fadeInMs": 1000,
|
||||
"fadeOutMs": 1000,
|
||||
"easing": "Linear",
|
||||
"from": {
|
||||
"centerX": 0.5,
|
||||
"centerY": 0.3,
|
||||
"scale": 1.3
|
||||
},
|
||||
"to": {
|
||||
"centerX": 0.58,
|
||||
"centerY": 0.3,
|
||||
"scale": 1.2
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "resources/w/cutscenes/cutscene1/cutscene1_wall_x.png",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"startMs": 11115,
|
||||
"endMs": 18149,
|
||||
"fadeInMs": 1000,
|
||||
"fadeOutMs": 1000,
|
||||
"easing": "Linear",
|
||||
"from": {
|
||||
"centerX": 0.59,
|
||||
"centerY": 0.5,
|
||||
"scale": 1.3
|
||||
},
|
||||
"to": {
|
||||
"centerX": 0.49,
|
||||
"centerY": 0.5,
|
||||
"scale": 1.2
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "resources/w/cutscenes/cutscene1/cutscene1_aida2_x.png",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"startMs": 12032,
|
||||
"endMs": 18165,
|
||||
"fadeInMs": 1000,
|
||||
"fadeOutMs": 1000,
|
||||
"easing": "Linear",
|
||||
"from": {
|
||||
"centerX": 0.53,
|
||||
"centerY": 0.5,
|
||||
"scale": 1.3
|
||||
},
|
||||
"to": {
|
||||
"centerX": 0.49,
|
||||
"centerY": 0.5,
|
||||
"scale": 1.2
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "resources/w/cutscenes/cutscene1/cutscene1_heads_x.png",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"startMs": 11951,
|
||||
"endMs": 18150,
|
||||
"fadeInMs": 1000,
|
||||
"fadeOutMs": 1000,
|
||||
"easing": "Linear",
|
||||
"from": {
|
||||
"centerX": 0.45,
|
||||
"centerY": 0.2,
|
||||
"scale": 1.3
|
||||
},
|
||||
"to": {
|
||||
"centerX": 0.6,
|
||||
"centerY": 0.2,
|
||||
"scale": 1.26
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "resources/w/cutscenes/cutscene1/cutscene1_wall_x.png",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"startMs": 23967,
|
||||
"endMs": 30000,
|
||||
"fadeInMs": 500,
|
||||
"fadeOutMs": 500,
|
||||
"easing": "Linear",
|
||||
"from": {
|
||||
"centerX": 0.64,
|
||||
"centerY": 0.7,
|
||||
"scale": 2
|
||||
},
|
||||
"to": {
|
||||
"centerX": 0.5,
|
||||
"centerY": 0.7,
|
||||
"scale": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "resources/w/cutscenes/cutscene1/cutscene1_aida3_x.png",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"startMs": 24016,
|
||||
"endMs": 30000,
|
||||
"fadeInMs": 500,
|
||||
"fadeOutMs": 500,
|
||||
"easing": "Linear",
|
||||
"from": {
|
||||
"centerX": 0.63,
|
||||
"centerY": 0.7,
|
||||
"scale": 2
|
||||
},
|
||||
"to": {
|
||||
"centerX": 0.51,
|
||||
"centerY": 0.7,
|
||||
"scale": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "resources/w/cutscenes/cutscene1/cutscene1_wall_x.png",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"startMs": 18067,
|
||||
"endMs": 24184,
|
||||
"fadeInMs": 1000,
|
||||
"fadeOutMs": 700,
|
||||
"easing": "EaseOutSine",
|
||||
"from": {
|
||||
"centerX": 0.25,
|
||||
"centerY": 0.8,
|
||||
"scale": 2.3
|
||||
},
|
||||
"to": {
|
||||
"centerX": 0.3,
|
||||
"centerY": 0.8,
|
||||
"scale": 2.3
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "resources/w/cutscenes/cutscene1/cutscene1_wall_x.png",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"startMs": 30000,
|
||||
"endMs": 33683,
|
||||
"fadeInMs": 500,
|
||||
"fadeOutMs": 1000,
|
||||
"easing": "Linear",
|
||||
"from": {
|
||||
"centerX": 0.45,
|
||||
"centerY": 0.5,
|
||||
"scale": 1.1
|
||||
},
|
||||
"to": {
|
||||
"centerX": 0.5,
|
||||
"centerY": 0.5,
|
||||
"scale": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "resources/w/cutscenes/cutscene1/cutscene1_aida2_x.png",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"startMs": 30333,
|
||||
"endMs": 33466,
|
||||
"fadeInMs": 500,
|
||||
"fadeOutMs": 1000,
|
||||
"easing": "Linear",
|
||||
"from": {
|
||||
"centerX": 0.43,
|
||||
"centerY": 0.5,
|
||||
"scale": 1.1
|
||||
},
|
||||
"to": {
|
||||
"centerX": 0.5,
|
||||
"centerY": 0.5,
|
||||
"scale": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "resources/w/cutscenes/cutscene1/cutscene1_heads_x.png",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"startMs": 30400,
|
||||
"endMs": 33483,
|
||||
"fadeInMs": 0,
|
||||
"fadeOutMs": 1000,
|
||||
"easing": "Linear",
|
||||
"from": {
|
||||
"centerX": 0.5,
|
||||
"centerY": 0.35,
|
||||
"scale": 1.2
|
||||
},
|
||||
"to": {
|
||||
"centerX": 0.5,
|
||||
"centerY": 0.4,
|
||||
"scale": 1.2
|
||||
}
|
||||
}
|
||||
],
|
||||
"lines": [
|
||||
{
|
||||
"speaker": "Аида Дженибековна",
|
||||
"text": "Опаздывающие, заходите скорее и занимайте свои места! Лекция начинается!",
|
||||
"durationMs": 3000,
|
||||
"waitForConfirm": false,
|
||||
"luaCallback": ""
|
||||
},
|
||||
{
|
||||
"speaker": "Аида Дженибековна",
|
||||
"text": "В эпизоде \"Семетей\" трилогии \"Манас\", изменники Канчоро и Кыяз захватывают власть над кыргызами.",
|
||||
"durationMs": 3000,
|
||||
"waitForConfirm": false,
|
||||
"luaCallback": ""
|
||||
},
|
||||
{
|
||||
"speaker": "Аида Дженибековна",
|
||||
"text": "Чтобы спасти раненого богатыря Семетея, фея Кёкмончок с помощью заклинания уводит его в иной мир.",
|
||||
"durationMs": 3000,
|
||||
"waitForConfirm": false,
|
||||
"luaCallback": ""
|
||||
},
|
||||
{
|
||||
"speaker": "Аида Дженибековна",
|
||||
"text": "Этот мир описан в эпосе Манас как Кайып или Аль-Гайб, но некоторые ученые называют его миром теней.",
|
||||
"durationMs": 3000,
|
||||
"waitForConfirm": false,
|
||||
"luaCallback": ""
|
||||
},
|
||||
{
|
||||
"speaker": "Аида Дженибековна",
|
||||
"text": "В этом мире обитают феи, духи и джинны. Простым смертным в этот мир дорога закрыта.",
|
||||
"durationMs": 3000,
|
||||
"waitForConfirm": false,
|
||||
"luaCallback": ""
|
||||
},
|
||||
{
|
||||
"speaker": "Аида Дженибековна",
|
||||
"text": "Время там течет по другому - за один день в теневом мире могут пройти годы жизни обычного мира.",
|
||||
"durationMs": 3000,
|
||||
"waitForConfirm": false,
|
||||
"luaCallback": ""
|
||||
},
|
||||
{
|
||||
"speaker": "Аида Дженибековна",
|
||||
"text": "Обычно, мир теней никак не пересекается с нашим миром живых людей.",
|
||||
"durationMs": 3000,
|
||||
"waitForConfirm": false,
|
||||
"luaCallback": ""
|
||||
},
|
||||
{
|
||||
"speaker": "",
|
||||
"text": "Но в критические моменты для народа, обитатели теневого мира могут приходить в наш мир.",
|
||||
"durationMs": 3000,
|
||||
"waitForConfirm": false,
|
||||
"luaCallback": ""
|
||||
},
|
||||
{
|
||||
"speaker": "Аида Дженибековна",
|
||||
"text": "Совсем недавно закончилась пандемия, а сегодня мир захлестнули кровавые войны.",
|
||||
"durationMs": 3000,
|
||||
"waitForConfirm": false,
|
||||
"luaCallback": ""
|
||||
},
|
||||
{
|
||||
"speaker": "Аида Дженибековна",
|
||||
"text": "В такие кризисные моменты истории, грань между мирами становится особенно тонкой.",
|
||||
"durationMs": 3000,
|
||||
"waitForConfirm": false,
|
||||
"luaCallback": ""
|
||||
},
|
||||
{
|
||||
"speaker": "Аида Дженибековна",
|
||||
"text": "На сегодня лекция завершена. Все свободны! Задания на этот модуль вы получите индивидуально!",
|
||||
"durationMs": 3000,
|
||||
"waitForConfirm": false,
|
||||
"luaCallback": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
415
resources/dialogue/cutscenes003.json
Normal file
415
resources/dialogue/cutscenes003.json
Normal file
@ -0,0 +1,415 @@
|
||||
{
|
||||
"cutscenes": [
|
||||
{
|
||||
"id": "lection_cutscene001",
|
||||
"skippable": true,
|
||||
"durationMs": 37000,
|
||||
"fadeOutMs": 500,
|
||||
"fadeInMs": 500,
|
||||
"endFadeOutMs": 0,
|
||||
"endFadeInMs": 2000,
|
||||
"onFadeInCallback": "",
|
||||
"imageSegments": [
|
||||
{
|
||||
"path": "resources/black.png",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"startMs": 0,
|
||||
"endMs": 37052,
|
||||
"fadeInMs": 0,
|
||||
"fadeOutMs": 0,
|
||||
"easing": "Linear",
|
||||
"from": {
|
||||
"centerX": 0.5,
|
||||
"centerY": 0.5,
|
||||
"scale": 1
|
||||
},
|
||||
"to": {
|
||||
"centerX": 0.5,
|
||||
"centerY": 0.5,
|
||||
"scale": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "resources/w/cutscenes/cutscene1/cutscene1_wall_x.png",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"startMs": 0,
|
||||
"endMs": 6133,
|
||||
"fadeInMs": 0,
|
||||
"fadeOutMs": 2000,
|
||||
"easing": "EaseOutCubic",
|
||||
"from": {
|
||||
"centerX": 0.51,
|
||||
"centerY": 0.5,
|
||||
"scale": 1.2
|
||||
},
|
||||
"to": {
|
||||
"centerX": 0.58,
|
||||
"centerY": 0.5,
|
||||
"scale": 1.15
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "resources/w/cutscenes/cutscene1/cutscene1_aida1_x.png",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"startMs": 0,
|
||||
"endMs": 6150,
|
||||
"fadeInMs": 0,
|
||||
"fadeOutMs": 2000,
|
||||
"easing": "EaseOutCubic",
|
||||
"from": {
|
||||
"centerX": 0.52,
|
||||
"centerY": 0.5,
|
||||
"scale": 1.2
|
||||
},
|
||||
"to": {
|
||||
"centerX": 0.575,
|
||||
"centerY": 0.5,
|
||||
"scale": 1.15
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "resources/w/cutscenes/cutscene1/cutscene1_heads_x.png",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"startMs": 0,
|
||||
"endMs": 6167,
|
||||
"fadeInMs": 0,
|
||||
"fadeOutMs": 2000,
|
||||
"easing": "EaseOutCubic",
|
||||
"from": {
|
||||
"centerX": 0.56,
|
||||
"centerY": 0.36,
|
||||
"scale": 1.3
|
||||
},
|
||||
"to": {
|
||||
"centerX": 0.5,
|
||||
"centerY": 0.37,
|
||||
"scale": 1.4
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "resources/w/cutscenes/cutscene1/cutscene1_wall_x.png",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"startMs": 6000,
|
||||
"endMs": 15100,
|
||||
"fadeInMs": 1000,
|
||||
"fadeOutMs": 1000,
|
||||
"easing": "Linear",
|
||||
"from": {
|
||||
"centerX": 0.56,
|
||||
"centerY": 0.5,
|
||||
"scale": 1.2
|
||||
},
|
||||
"to": {
|
||||
"centerX": 0.5,
|
||||
"centerY": 0.5,
|
||||
"scale": 1.1
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "resources/w/cutscenes/cutscene1/cutscene1_aida3_x.png",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"startMs": 6050,
|
||||
"endMs": 15166,
|
||||
"fadeInMs": 1100,
|
||||
"fadeOutMs": 1000,
|
||||
"easing": "Linear",
|
||||
"from": {
|
||||
"centerX": 0.52,
|
||||
"centerY": 0.5,
|
||||
"scale": 1.2
|
||||
},
|
||||
"to": {
|
||||
"centerX": 0.5,
|
||||
"centerY": 0.5,
|
||||
"scale": 1.1
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "resources/w/cutscenes/cutscene1/cutscene1_heads_x.png",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"startMs": 6066,
|
||||
"endMs": 15083,
|
||||
"fadeInMs": 1000,
|
||||
"fadeOutMs": 1000,
|
||||
"easing": "Linear",
|
||||
"from": {
|
||||
"centerX": 0.5,
|
||||
"centerY": 0.3,
|
||||
"scale": 1.3
|
||||
},
|
||||
"to": {
|
||||
"centerX": 0.58,
|
||||
"centerY": 0.3,
|
||||
"scale": 1.2
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "resources/w/cutscenes/cutscene1/cutscene1_wall_x.png",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"startMs": 14436,
|
||||
"endMs": 21470,
|
||||
"fadeInMs": 1000,
|
||||
"fadeOutMs": 1000,
|
||||
"easing": "Linear",
|
||||
"from": {
|
||||
"centerX": 0.59,
|
||||
"centerY": 0.5,
|
||||
"scale": 1.3
|
||||
},
|
||||
"to": {
|
||||
"centerX": 0.49,
|
||||
"centerY": 0.5,
|
||||
"scale": 1.2
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "resources/w/cutscenes/cutscene1/cutscene1_aida2_x.png",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"startMs": 15348,
|
||||
"endMs": 21481,
|
||||
"fadeInMs": 1000,
|
||||
"fadeOutMs": 1000,
|
||||
"easing": "Linear",
|
||||
"from": {
|
||||
"centerX": 0.53,
|
||||
"centerY": 0.5,
|
||||
"scale": 1.3
|
||||
},
|
||||
"to": {
|
||||
"centerX": 0.49,
|
||||
"centerY": 0.5,
|
||||
"scale": 1.2
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "resources/w/cutscenes/cutscene1/cutscene1_heads_x.png",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"startMs": 15388,
|
||||
"endMs": 21587,
|
||||
"fadeInMs": 1000,
|
||||
"fadeOutMs": 1000,
|
||||
"easing": "Linear",
|
||||
"from": {
|
||||
"centerX": 0.45,
|
||||
"centerY": 0.2,
|
||||
"scale": 1.3
|
||||
},
|
||||
"to": {
|
||||
"centerX": 0.6,
|
||||
"centerY": 0.2,
|
||||
"scale": 1.26
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "resources/w/cutscenes/cutscene1/cutscene1_wall_x.png",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"startMs": 26588,
|
||||
"endMs": 32621,
|
||||
"fadeInMs": 500,
|
||||
"fadeOutMs": 500,
|
||||
"easing": "Linear",
|
||||
"from": {
|
||||
"centerX": 0.64,
|
||||
"centerY": 0.7,
|
||||
"scale": 2
|
||||
},
|
||||
"to": {
|
||||
"centerX": 0.5,
|
||||
"centerY": 0.7,
|
||||
"scale": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "resources/w/cutscenes/cutscene1/cutscene1_aida3_x.png",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"startMs": 26637,
|
||||
"endMs": 32621,
|
||||
"fadeInMs": 500,
|
||||
"fadeOutMs": 500,
|
||||
"easing": "Linear",
|
||||
"from": {
|
||||
"centerX": 0.63,
|
||||
"centerY": 0.7,
|
||||
"scale": 2
|
||||
},
|
||||
"to": {
|
||||
"centerX": 0.51,
|
||||
"centerY": 0.7,
|
||||
"scale": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "resources/w/cutscenes/cutscene1/cutscene1_wall_x.png",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"startMs": 20744,
|
||||
"endMs": 26861,
|
||||
"fadeInMs": 1000,
|
||||
"fadeOutMs": 700,
|
||||
"easing": "EaseOutSine",
|
||||
"from": {
|
||||
"centerX": 0.25,
|
||||
"centerY": 0.8,
|
||||
"scale": 2.3
|
||||
},
|
||||
"to": {
|
||||
"centerX": 0.3,
|
||||
"centerY": 0.8,
|
||||
"scale": 2.3
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "resources/w/cutscenes/cutscene1/cutscene1_wall_x.png",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"startMs": 32546,
|
||||
"endMs": 36229,
|
||||
"fadeInMs": 500,
|
||||
"fadeOutMs": 1000,
|
||||
"easing": "Linear",
|
||||
"from": {
|
||||
"centerX": 0.45,
|
||||
"centerY": 0.5,
|
||||
"scale": 1.1
|
||||
},
|
||||
"to": {
|
||||
"centerX": 0.5,
|
||||
"centerY": 0.5,
|
||||
"scale": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "resources/w/cutscenes/cutscene1/cutscene1_aida2_x.png",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"startMs": 32542,
|
||||
"endMs": 35675,
|
||||
"fadeInMs": 500,
|
||||
"fadeOutMs": 1000,
|
||||
"easing": "Linear",
|
||||
"from": {
|
||||
"centerX": 0.43,
|
||||
"centerY": 0.5,
|
||||
"scale": 1.1
|
||||
},
|
||||
"to": {
|
||||
"centerX": 0.5,
|
||||
"centerY": 0.5,
|
||||
"scale": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "resources/w/cutscenes/cutscene1/cutscene1_heads_x.png",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"startMs": 32497,
|
||||
"endMs": 35580,
|
||||
"fadeInMs": 0,
|
||||
"fadeOutMs": 1000,
|
||||
"easing": "Linear",
|
||||
"from": {
|
||||
"centerX": 0.5,
|
||||
"centerY": 0.35,
|
||||
"scale": 1.2
|
||||
},
|
||||
"to": {
|
||||
"centerX": 0.5,
|
||||
"centerY": 0.4,
|
||||
"scale": 1.2
|
||||
}
|
||||
}
|
||||
],
|
||||
"lines": [
|
||||
{
|
||||
"speaker": "Аида Дженибековна",
|
||||
"text": "Опаздывающие, заходите скорее и занимайте свои места! Лекция начинается!",
|
||||
"durationMs": 4000,
|
||||
"waitForConfirm": false,
|
||||
"luaCallback": ""
|
||||
},
|
||||
{
|
||||
"speaker": "Аида Дженибековна",
|
||||
"text": "В эпизоде \"Семетей\" трилогии \"Манас\", изменники Канчоро и Кыяз захватывают власть над кыргызами.",
|
||||
"durationMs": 3200,
|
||||
"waitForConfirm": false,
|
||||
"luaCallback": ""
|
||||
},
|
||||
{
|
||||
"speaker": "Аида Дженибековна",
|
||||
"text": "Чтобы спасти раненого богатыря Семетея, фея Кёкмончок с помощью заклинания уводит его в иной мир.",
|
||||
"durationMs": 3201,
|
||||
"waitForConfirm": false,
|
||||
"luaCallback": ""
|
||||
},
|
||||
{
|
||||
"speaker": "Аида Дженибековна",
|
||||
"text": "Этот мир описан в эпосе Манас как Кайып или Аль-Гайб, но некоторые ученые называют его миром теней.",
|
||||
"durationMs": 3202,
|
||||
"waitForConfirm": false,
|
||||
"luaCallback": ""
|
||||
},
|
||||
{
|
||||
"speaker": "Аида Дженибековна",
|
||||
"text": "В этом мире обитают феи, духи и джинны. Простым смертным в этот мир дорога закрыта.",
|
||||
"durationMs": 3200,
|
||||
"waitForConfirm": false,
|
||||
"luaCallback": ""
|
||||
},
|
||||
{
|
||||
"speaker": "Аида Дженибековна",
|
||||
"text": "Время там течет по другому - за один день в теневом мире могут пройти годы жизни обычного мира.",
|
||||
"durationMs": 3200,
|
||||
"waitForConfirm": false,
|
||||
"luaCallback": ""
|
||||
},
|
||||
{
|
||||
"speaker": "Аида Дженибековна",
|
||||
"text": "Обычно, мир теней никак не пересекается с нашим миром живых людей.",
|
||||
"durationMs": 3200,
|
||||
"waitForConfirm": false,
|
||||
"luaCallback": ""
|
||||
},
|
||||
{
|
||||
"speaker": "Аида Дженибековна",
|
||||
"text": "Но в критические моменты для народа, обитатели теневого мира могут приходить в наш мир.",
|
||||
"durationMs": 3200,
|
||||
"waitForConfirm": false,
|
||||
"luaCallback": ""
|
||||
},
|
||||
{
|
||||
"speaker": "Аида Дженибековна",
|
||||
"text": "Совсем недавно закончилась пандемия, а сегодня мир захлестнули кровавые войны.",
|
||||
"durationMs": 3200,
|
||||
"waitForConfirm": false,
|
||||
"luaCallback": ""
|
||||
},
|
||||
{
|
||||
"speaker": "Аида Дженибековна",
|
||||
"text": "В такие кризисные моменты истории, грань между мирами становится особенно тонкой.",
|
||||
"durationMs": 3200,
|
||||
"waitForConfirm": false,
|
||||
"luaCallback": ""
|
||||
},
|
||||
{
|
||||
"speaker": "Аида Дженибековна",
|
||||
"text": "На сегодня лекция завершена. Все свободны! Задания на этот модуль вы получите индивидуально!",
|
||||
"durationMs": 4000,
|
||||
"waitForConfirm": false,
|
||||
"luaCallback": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -34,7 +34,7 @@ function lection_hall_zone001_enter_callback()
|
||||
--Start cutscene
|
||||
if (lection_is_over == false) then
|
||||
game_api.player_stop()
|
||||
game_api.start_cutscene("test_cutscene_01")
|
||||
game_api.start_cutscene("lection_cutscene001")
|
||||
game_api.quest_set_objective_completed("study_beginning", "study_beginning_lecture")
|
||||
end
|
||||
end
|
||||
@ -559,7 +559,7 @@ function on_teacher_arrived_intermediate()
|
||||
teacher_arrived = true
|
||||
end
|
||||
|
||||
game_api.set_cutscene_callback("test_cutscene_01", function()
|
||||
game_api.set_cutscene_callback("lection_cutscene001", function()
|
||||
print("Cutscene done!")
|
||||
lection_is_over = true
|
||||
game_api.set_npc_enabled(1, true)
|
||||
|
||||
@ -801,7 +801,7 @@ namespace ZL
|
||||
|
||||
break;
|
||||
case SDLK_e:
|
||||
currentLocation->dialogueSystem.startCutscene("test_cutscene_01"); //.startDialogue("test_cutscene_pan_dialogue");
|
||||
currentLocation->dialogueSystem.startCutscene("lection_cutscene001"); //.startDialogue("test_cutscene_pan_dialogue");
|
||||
break;
|
||||
|
||||
case SDLK_n:
|
||||
|
||||
@ -128,9 +128,10 @@ namespace ZL
|
||||
|
||||
setupNavigation(params.navigationJsonPaths);
|
||||
|
||||
|
||||
dialogueSystem.init(renderer, CONST_ZIP_FILE);
|
||||
dialogueSystem.loadDatabase(params.dialoguesJsonPath);
|
||||
dialogueSystem.loadCutsceneDatabase("resources/dialogue/cutscenes.json");
|
||||
dialogueSystem.loadCutsceneDatabase("resources/dialogue/cutscenes003.json");
|
||||
dialogueSystem.setQuestJournal(journal);
|
||||
|
||||
npcNameText = std::make_unique<TextRenderer>();
|
||||
|
||||
@ -203,6 +203,8 @@ void CutsceneOverlay::draw(Renderer& renderer, const ZL::Dialogue::PresentationM
|
||||
|
||||
// --- Text ---
|
||||
if (model.showCutsceneSubtitle) {
|
||||
const std::string wrapped = wrapTextToWidth(model.visibleText, *cutsceneRenderer, subtitleRect.w - 48.0f, 1.0f);
|
||||
|
||||
if (!model.speaker.empty()) {
|
||||
nameRenderer->drawText(
|
||||
model.speaker,
|
||||
@ -210,11 +212,15 @@ void CutsceneOverlay::draw(Renderer& renderer, const ZL::Dialogue::PresentationM
|
||||
subtitleRect.y + subtitleRect.h - 32.0f,
|
||||
1.0f, false, { 1.0f, 0.88f, 0.45f, 1.0f }
|
||||
);
|
||||
}
|
||||
const std::string wrapped = wrapTextToWidth(model.visibleText, *cutsceneRenderer, subtitleRect.w - 48.0f, 1.0f);
|
||||
cutsceneRenderer->drawText(wrapped, subtitleRect.x + 24.0f, subtitleRect.y + 30.0f,
|
||||
cutsceneRenderer->drawText(wrapped, subtitleRect.x + 24.0f, subtitleRect.y + subtitleRect.h - 60.0f,
|
||||
1.0f, false, { 1.0f, 1.0f, 1.0f, 1.0f });
|
||||
}
|
||||
else
|
||||
{
|
||||
cutsceneRenderer->drawText(wrapped, subtitleRect.x + 24.0f, subtitleRect.y + subtitleRect.h - 32.0f,
|
||||
1.0f, false, { 1.0f, 1.0f, 1.0f, 1.0f });
|
||||
}
|
||||
}
|
||||
|
||||
if (model.cutsceneSkippable && cutsceneSkipHintVisible) {
|
||||
choiceRenderer->drawText(
|
||||
|
||||
Loading…
Reference in New Issue
Block a user