Compare commits
217
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71c654e5b6 | ||
|
|
570f051281 | ||
|
|
0240721d46 | ||
|
|
e350ec26ac | ||
|
|
96e748715b | ||
|
|
d5ef126264 | ||
|
|
9b71ee447a | ||
|
|
d38a75ea72 | ||
|
|
9fa120d4ad | ||
|
|
ea8f64c940 | ||
|
|
c9c6b7e7fb | ||
|
|
a11c4614ea | ||
|
|
5cbe7a345e | ||
|
|
2add42c1cb | ||
|
|
4039c24372 | ||
|
|
7df68febca | ||
|
|
5ecf5fb9d7 | ||
|
|
c3f449b999 | ||
|
|
8a0a8390b2 | ||
|
|
d39f5ca68b | ||
|
|
1c20333f30 | ||
|
|
1d8e103690 | ||
|
|
4c9ecb31d8 | ||
|
|
2e749ed26e | ||
|
|
fc93eb6680 | ||
|
|
5b21ba615e | ||
|
|
02ba86331b | ||
|
|
6a79e8e41e | ||
|
|
6406c4f258 | ||
|
|
9f812f0318 | ||
|
|
adf9871861 | ||
|
|
825a56a4a4 | ||
|
|
e5e3d7bb28 | ||
|
|
e773b51985 | ||
|
|
0975f3b9cd | ||
|
|
e9c888e2bf | ||
|
|
a743f653dd | ||
|
|
3d3522b905 | ||
|
|
b6cd17449f | ||
|
|
159fac02f0 | ||
|
|
9f17d51fc8 | ||
|
|
ca73148f4b | ||
|
|
0c24d52e6d | ||
|
|
beb4fee0d8 | ||
|
|
a7d17d9afd | ||
|
|
76287dda46 | ||
|
|
9da0ae1016 | ||
|
|
804e34ce12 | ||
|
|
34a55611d5 | ||
|
|
35a2a91178 | ||
|
|
38e45d67b8 | ||
|
|
33c2b313af | ||
|
|
92929118d5 | ||
|
|
9d03b52c2a | ||
|
|
d4365728fe | ||
|
|
79413e7319 | ||
|
|
8e745ce0cd | ||
|
|
7dadfd8ed8 | ||
|
|
07f45ad1e1 | ||
|
|
17d903fd52 | ||
|
|
2d3aa22521 | ||
|
|
5cde7078f9 | ||
|
|
44f5136f1d | ||
|
|
1c2c80f0ea | ||
|
|
124208f2a4 | ||
|
|
c6ba8bd008 | ||
|
|
a40a582b5a | ||
|
|
cdaebc3c0c | ||
|
|
e7dc3279ae | ||
|
|
08eac60657 | ||
|
|
ef5b142425 | ||
|
|
e2d9105b02 | ||
|
|
9d8ca865c7 | ||
|
|
794a820be9 | ||
|
|
a98f71676d | ||
|
|
bd994a8690 | ||
|
|
2d8f80b675 | ||
|
|
8a6366098c | ||
|
|
f7e63f933b | ||
|
|
5516d02e2c | ||
|
|
9aeb0b1194 | ||
|
|
40e7e0ee44 | ||
|
|
763ba9eeac | ||
|
|
bdad5fa107 | ||
|
|
1b172460e0 | ||
|
|
b41e312511 | ||
|
|
8c42179576 | ||
|
|
0b4df8ab2d | ||
|
|
6b39cfca3b | ||
|
|
b8d79c7b7b | ||
|
|
84f9fa37a8 | ||
|
|
7db9ca8f70 | ||
|
|
51977a9eea | ||
|
|
130e4411cb | ||
|
|
d7db0faea3 | ||
|
|
901142385d | ||
|
|
2807d82d51 | ||
|
|
b01e970554 | ||
|
|
e0968b5522 | ||
|
|
0ca00f965d | ||
|
|
0eb9d0be25 | ||
|
|
c891161d69 | ||
|
|
939136c705 | ||
|
|
bba493ee7c | ||
|
|
9f4d60036d | ||
|
|
020f31e1e4 | ||
|
|
80825e3220 | ||
|
|
b80e3fdc5e | ||
|
|
5d427af9f7 | ||
|
|
db456bde09 | ||
|
|
fff5a9d4aa | ||
|
|
2b32fce5e0 | ||
|
|
7031a21922 | ||
|
|
dee9615483 | ||
|
|
a4032d769e | ||
|
|
a9f4025333 | ||
|
|
f52b6872b0 | ||
|
|
87fb6d4655 | ||
|
|
80133aa8c8 | ||
|
|
0ed7021e90 | ||
|
|
b94f02bd77 | ||
|
|
77c8471636 | ||
|
|
dcdeae75bc | ||
|
|
619de45221 | ||
|
|
cfbd29d5b1 | ||
|
|
6349859e66 | ||
|
|
54cc118df7 | ||
|
|
3ba8a2f44b | ||
|
|
2e5988f8f4 | ||
|
|
7011bbb754 | ||
|
|
c991b5dbc1 | ||
|
|
f61a12f874 | ||
|
|
0cc384028f | ||
|
|
597ed04fdc | ||
|
|
e96c9f171f | ||
|
|
1a00e3d4a2 | ||
|
|
8f7797789d | ||
|
|
0e27521bea | ||
|
|
1fc8120ee0 | ||
|
|
9221ab68ac | ||
|
|
1d3bea452c | ||
|
|
59e0b2d77a | ||
|
|
c8d743b932 | ||
|
|
83b92e9881 | ||
|
|
ed8d6f2e92 | ||
|
|
e3542b59f7 | ||
|
|
442d3a7945 | ||
|
|
e6bfe85e2e | ||
|
|
1b1ceac2fe | ||
|
|
7a02665305 | ||
|
|
8694b0087d | ||
|
|
e7ae148ead | ||
|
|
aaae368a07 | ||
|
|
65d19c577a | ||
|
|
89cbc293fc | ||
|
|
60b0ba69ba | ||
|
|
4b868f47c2 | ||
|
|
22b0e1992f | ||
|
|
8552725dfc | ||
|
|
3d4560bb51 | ||
|
|
5f7e66f765 | ||
|
|
03681bedaf | ||
|
|
a0550daf4b | ||
|
|
9425f9b19a | ||
|
|
cdab525858 | ||
|
|
3f5c4a381b | ||
|
|
31130ec248 | ||
|
|
c659293bf8 | ||
|
|
9adcde5c05 | ||
|
|
90c57932ec | ||
|
|
dda9f34277 | ||
|
|
586ffc99b6 | ||
|
|
1eee415b41 | ||
|
|
80af0e61ae | ||
|
|
0da1ae124c | ||
|
|
6c8fcf17b6 | ||
|
|
cfafcfc944 | ||
|
|
26f596a149 | ||
|
|
e1a191634c | ||
|
|
84c8a7a27a | ||
|
|
8c200974c2 | ||
|
|
715f2e8c31 | ||
|
|
ffd5bd01a4 | ||
|
|
3a73f3fbde | ||
|
|
2659951d8e | ||
|
|
27b4b19df4 | ||
|
|
21bf3f0710 | ||
|
|
49501e32ce | ||
|
|
4044acefb2 | ||
|
|
c086fe1ef0 | ||
|
|
4e9eef1863 | ||
|
|
b973d714cc | ||
|
|
e618c91415 | ||
|
|
0f2b2e55af | ||
|
|
774397f690 | ||
|
|
d6417eba1b | ||
|
|
2cae9998a0 | ||
|
|
efb85f7328 | ||
|
|
001077a1d6 | ||
|
|
be05643b70 | ||
|
|
72e05cdc28 | ||
|
|
13ef92160d | ||
|
|
a479fa4ac1 | ||
|
|
d4bc73f732 | ||
|
|
8214342248 | ||
|
|
7e94009217 | ||
|
|
fb7b42ddf9 | ||
|
|
6149182f02 | ||
|
|
d24748df1e | ||
|
|
be6a8cca9e | ||
|
|
e04fcbb9bd | ||
|
|
519d780b3c | ||
|
|
16d250a51d | ||
|
|
5231b50aab | ||
|
|
8b0a18a6cf | ||
|
|
b98631a8c0 | ||
|
|
cebbb8bb5d |
No files matched your search
@@ -5,3 +5,6 @@
|
|||||||
*.wav filter=lfs diff=lfs merge=lfs -text
|
*.wav filter=lfs diff=lfs merge=lfs -text
|
||||||
*.ogg filter=lfs diff=lfs merge=lfs -text
|
*.ogg filter=lfs diff=lfs merge=lfs -text
|
||||||
*.mp3 filter=lfs diff=lfs merge=lfs -text
|
*.mp3 filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.bin filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.dll filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.so filter=lfs diff=lfs merge=lfs -text
|
||||||
@@ -405,3 +405,12 @@ thirdparty
|
|||||||
proj-web/build
|
proj-web/build
|
||||||
proj-windows/build
|
proj-windows/build
|
||||||
public
|
public
|
||||||
|
web_resources/
|
||||||
|
pc_resources/
|
||||||
|
resources_hd/
|
||||||
|
web_resources_x2/
|
||||||
|
android_resources/
|
||||||
|
|
||||||
|
.artifacts/
|
||||||
|
|
||||||
|
*.zip
|
||||||
+351
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
+260
@@ -0,0 +1,260 @@
|
|||||||
|
# Pathfinding System
|
||||||
|
|
||||||
|
This document describes the grid-based pathfinding used for the player and all NPCs, including the collision avoidance and movement quality improvements.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Grid Representation](#1-grid-representation)
|
||||||
|
2. [Building the Walkable Grid](#2-building-the-walkable-grid)
|
||||||
|
3. [A\* Path Search](#3-a-path-search)
|
||||||
|
4. [Path Smoothing](#4-path-smoothing)
|
||||||
|
5. [Approaching Unreachable Destinations](#5-approaching-unreachable-destinations)
|
||||||
|
6. [Dynamic Obstacles](#6-dynamic-obstacles)
|
||||||
|
7. [Path Following](#7-path-following)
|
||||||
|
8. [Character Collision Resolution](#8-character-collision-resolution)
|
||||||
|
9. [Dynamic Replanning](#9-dynamic-replanning)
|
||||||
|
10. [Key Constants Reference](#10-key-constants-reference)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Grid Representation
|
||||||
|
|
||||||
|
The world is divided into a uniform 2D grid in the XZ plane (Y is ignored during pathfinding; all characters walk on a flat floor at `floorY`).
|
||||||
|
|
||||||
|
Each cell is either **walkable** (`1`) or **blocked** (`0`). The grid is stored as a flat `std::vector<unsigned char>` indexed by `z * gridWidth + x`.
|
||||||
|
|
||||||
|
**Parameters** (all configurable in the JSON config file):
|
||||||
|
|
||||||
|
| Parameter | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `cellSize` | 0.4 m | Width and depth of one cell |
|
||||||
|
| `agentRadius` | 0.45 m | Half-width of a character — used to erode free space |
|
||||||
|
| `objectPadding` | 0.25 m | Extra clearance added around obstacle polygons |
|
||||||
|
| `boundaryPadding` | 0.0 m | Inward erosion from the edges of navigation areas |
|
||||||
|
| `floorY` | 0.0 | Y coordinate placed on every path waypoint |
|
||||||
|
|
||||||
|
**Grid bounds** are computed from the union of all navigation area polygons plus a padding margin of `cellSize * 2 + agentRadius + objectPadding` on every side.
|
||||||
|
|
||||||
|
**Cell coordinate conversion:**
|
||||||
|
|
||||||
|
```
|
||||||
|
cell.x = floor((worldX - minX) / cellSize)
|
||||||
|
cell.z = floor((worldZ - minZ) / cellSize)
|
||||||
|
|
||||||
|
cellCenter.x = minX + (cell.x + 0.5) * cellSize
|
||||||
|
cellCenter.z = minZ + (cell.z + 0.5) * cellSize
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Building the Walkable Grid
|
||||||
|
|
||||||
|
The grid can be loaded in two ways.
|
||||||
|
|
||||||
|
### 2a. Pre-computed grid (`.txt` file)
|
||||||
|
|
||||||
|
A plain-text file with a small header followed by rows of `1`/`0` characters:
|
||||||
|
|
||||||
|
```
|
||||||
|
cellSize 0.4
|
||||||
|
agentRadius 0.45
|
||||||
|
floorY 0.0
|
||||||
|
...
|
||||||
|
minX -5.0
|
||||||
|
minZ -5.0
|
||||||
|
gridWidth 50
|
||||||
|
gridDepth 50
|
||||||
|
11111111...
|
||||||
|
10000001...
|
||||||
|
```
|
||||||
|
|
||||||
|
This format is generated by `PathFinder::saveGrid()` after building from polygons and can be loaded much faster than recomputing from geometry.
|
||||||
|
|
||||||
|
### 2b. Polygon-based config (`.json` file)
|
||||||
|
|
||||||
|
The JSON file lists **navigation areas** (convex or concave walkable regions) and **obstacle polygons** (impassable zones within those regions):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"cellSize": 0.4,
|
||||||
|
"areas": [
|
||||||
|
{ "name": "main_room", "available": true, "polygon": [[x,z], ...] }
|
||||||
|
],
|
||||||
|
"obstacles": [
|
||||||
|
{ "name": "table", "polygon": [[x,z], ...] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Build steps:**
|
||||||
|
|
||||||
|
1. **Mark available areas walkable** — every cell whose center lies inside any `available` navigation area polygon gets `walkable = 1`. If `boundaryPadding > 0`, cells too close to the outer edge of the area are left blocked.
|
||||||
|
2. **Mark obstacle polygons blocked** — cells whose center lies inside an obstacle polygon, or within `agentRadius + objectPadding` of its edges, are set to `0`.
|
||||||
|
|
||||||
|
Navigation areas can be toggled at runtime via `PathFinder::setAreaAvailable()`, which rebuilds the entire grid. This is used to open or close doors, gated areas, etc.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. A\* Path Search
|
||||||
|
|
||||||
|
`PathFinder::findPath(start, end)` runs a standard A\* on the walkable grid.
|
||||||
|
|
||||||
|
**Neighbor connectivity:** 8-directional (cardinal + diagonal). Diagonal moves are blocked if either of the two adjacent cardinal cells is unwalkable (no corner-cutting).
|
||||||
|
|
||||||
|
**Step costs:** `1.0` for cardinal, `√2 ≈ 1.414` for diagonal.
|
||||||
|
|
||||||
|
**Heuristic:** Euclidean distance in cell units to the end cell.
|
||||||
|
|
||||||
|
**Start/end snapping:** If the exact cell for `start` or `end` is not walkable, `findNearestWalkableCell` expands a square ring outward (up to radius 8 m) to find the nearest walkable cell. This makes clicking slightly outside the nav mesh still produce a valid path.
|
||||||
|
|
||||||
|
**Path reconstruction:** After A\* completes, the cell chain is walked via `cameFrom[]` from `end` back to `start`, reversed, then smoothed (see §4).
|
||||||
|
|
||||||
|
**First-waypoint trimming:** If the first waypoint is within `cellSize × 0.75` of `start`, it is dropped (the character is already close enough).
|
||||||
|
|
||||||
|
**Last-waypoint precision:** If the requested `end` maps to the same cell as the snapped end cell, the last waypoint is replaced with the exact `end` world position rather than the cell centre.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Path Smoothing
|
||||||
|
|
||||||
|
Raw A\* paths follow the grid diagonals and produce staircase-shaped routes. A **string-pulling** (line-of-sight) pass compresses them:
|
||||||
|
|
||||||
|
```
|
||||||
|
anchor = path[0]
|
||||||
|
result = [anchor]
|
||||||
|
while anchor is not the last cell:
|
||||||
|
find the furthest cell 'next' from anchor with unobstructed line of sight
|
||||||
|
result.append(next)
|
||||||
|
anchor = next
|
||||||
|
```
|
||||||
|
|
||||||
|
Line-of-sight is checked by stepping along the segment in increments of `cellSize / 2` and verifying that each sampled cell is walkable. The result is a minimal set of waypoints connected by straight, obstacle-free segments.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Approaching Unreachable Destinations
|
||||||
|
|
||||||
|
When a player clicks on a point in a disconnected region (e.g., across a thin wall), the original `findPath` returns an empty path and the character does not move. This is surprising — a click on a solid wall sensibly moves the character to the nearest reachable point, but a click into an inaccessible room does nothing.
|
||||||
|
|
||||||
|
**`findPathToNearest`** fixes this with a three-step cascade:
|
||||||
|
|
||||||
|
1. Try `findPath` with dynamic obstacles (stationary characters are avoided).
|
||||||
|
2. If empty, retry `findPath` without dynamic obstacles (an NPC blocking a doorway is ignored).
|
||||||
|
3. If still empty (destination genuinely unreachable), run **nearest-reachable A\***.
|
||||||
|
|
||||||
|
**Nearest-reachable A\***, implemented in `findNearestReachableImpl`:
|
||||||
|
|
||||||
|
- Runs the identical A\* loop against the static walkable grid.
|
||||||
|
- While processing cells, tracks `bestIndex` — the already-visited cell with the smallest Euclidean distance (in cell units) to the end cell.
|
||||||
|
- If A\* exhausts all reachable space without finding `end`, it reconstructs and returns a path to `bestIndex`.
|
||||||
|
- If `bestIndex` is still the start cell (character is completely isolated), an empty path is returned and the character stays put.
|
||||||
|
|
||||||
|
The net effect: clicking anywhere in the world always moves the character as close as possible to the target, matching the behaviour of clicking on a solid wall.
|
||||||
|
|
||||||
|
`findPathToNearest` replaces the direct `findPath` call in `Location::setupNavigation`'s path planner lambda, so it applies equally to the player and all NPCs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Dynamic Obstacles
|
||||||
|
|
||||||
|
When a path is planned, other characters can temporarily mark cells as blocked to make the character walk around them rather than through them.
|
||||||
|
|
||||||
|
**How it works:**
|
||||||
|
|
||||||
|
In `Location::setupNavigation`, every character is given a `PathPlanner` closure. Before calling `findPath`, the closure builds a list of `PathFinder::DynamicObstacle` entries (position + radius) representing nearby characters. `findPath` copies the static walkable grid, stamps zeros in circles around each obstacle, then runs A\* on the modified copy. The static grid is never mutated.
|
||||||
|
|
||||||
|
**Which characters become obstacles:**
|
||||||
|
|
||||||
|
A character is added as a dynamic obstacle only when **all** of these are true:
|
||||||
|
|
||||||
|
- It is not the character currently planning the path (`self`).
|
||||||
|
- It is alive and enabled.
|
||||||
|
- **It is not moving** — a moving character is transparent to pathfinding, so it does not block narrow corridors that it is actively passing through.
|
||||||
|
- Its position lies within `kDynamicObstacleInfluenceDist = 6 m` of the direct line segment from `start` to `end` (distant characters do not affect the search).
|
||||||
|
|
||||||
|
**Obstacle radius:** `character.collisionRadius × 0.6`. Using 60 % of the physical collision radius makes path planning less conservative; physical separation at full radius is still enforced by collision resolution (§8).
|
||||||
|
|
||||||
|
**Fallback when dynamic obstacles block the only path:**
|
||||||
|
|
||||||
|
If step 1 of `findPathToNearest` (with dynamic obstacles) returns empty, step 2 retries without any dynamic obstacles. This handles the common case of an NPC standing in a doorway: the player paths through the NPC's position, and the nudge logic (§8) pushes the NPC aside as the player passes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Path Following
|
||||||
|
|
||||||
|
`Character::setTarget(destination, onArrived)` sets a new walk target. It calls the path planner to generate a waypoint list. The result is stored in `pathWaypoints`; the final destination is also stored in `walkTarget` and `requestedWalkTarget`.
|
||||||
|
|
||||||
|
Each frame in `Character::update`:
|
||||||
|
|
||||||
|
1. **Active target** — if `pathWaypoints` is non-empty, the character moves toward `pathWaypoints[currentWaypointIndex]`; otherwise it moves toward `walkTarget`.
|
||||||
|
2. **Movement** — the character advances along the XZ direction at `walkSpeed` m/s and rotates smoothly toward the movement direction at `rotationSpeed` rad/s.
|
||||||
|
3. **Waypoint advance** — when the character is within `WALK_THRESHOLD = 0.05 m` of the current waypoint, it advances to the next one. When the last waypoint is reached, `pathWaypoints` is cleared and the optional `onArrived` callback is fired.
|
||||||
|
4. **State machine** — the animation state switches between `STAND` and `WALK` based on whether the character is moving.
|
||||||
|
|
||||||
|
`Character::isMoving()` returns `true` if `pathWaypoints` is non-empty or the distance to `walkTarget` exceeds `WALK_THRESHOLD`. This is used by dynamic obstacle filtering and collision nudging.
|
||||||
|
|
||||||
|
**Stopping in place:** `Character::stopInPlace()` sets `walkTarget` and `requestedWalkTarget` to the current position and clears `pathWaypoints`. It is called when an external force (collision resolution) displaces a stationary player so that the player does not walk back to their previous target position.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Character Collision Resolution
|
||||||
|
|
||||||
|
Pathfinding alone does not prevent two characters from occupying the same space — it only steers paths around stationary characters. Physical separation is handled separately each frame by `Location::resolveCharacterCollisions`.
|
||||||
|
|
||||||
|
**Algorithm** (3 iterations per frame):
|
||||||
|
|
||||||
|
For every pair `(A, B)` of living, enabled characters:
|
||||||
|
|
||||||
|
1. Compute the overlap: `penetration = (collisionRadius_A + collisionRadius_B) - distance(A, B)`.
|
||||||
|
2. If `penetration > 0`, compute a push direction (A-to-B normal) and a push magnitude of `penetration / 2` per character.
|
||||||
|
3. Compute candidate new positions `newA` and `newB`.
|
||||||
|
4. Validate against the navigation grid (`PathFinder::isWalkable`). If a pushed position is unwalkable, only the other character is moved.
|
||||||
|
5. **Player stays put:** if the player was not moving (`!isMoving()`) before the push, `stopInPlace()` is called after the push so the player does not walk back to the old target.
|
||||||
|
6. **NPC yielding:** if one character was moving and the other was standing, `nudgeCharacterAside` is called on the standing character.
|
||||||
|
|
||||||
|
**`nudgeCharacterAside(standing, awayFrom)`:**
|
||||||
|
|
||||||
|
Gives the standing NPC a short walk target so it steps out of the way:
|
||||||
|
|
||||||
|
1. Compute the direction from `awayFrom` to the NPC's current position.
|
||||||
|
2. Try four candidate targets at distance `1.2 m` in directions: straight away, +90°, −90°, 180°.
|
||||||
|
3. Use the first candidate that is walkable (per `PathFinder::isWalkable`).
|
||||||
|
4. Call `standing->setTarget(candidate)` — the NPC takes a small step aside, then stands at the new spot.
|
||||||
|
5. The player is never nudged; combat NPCs can be nudged, but their attack AI immediately overrides the yield target on the next tick.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Dynamic Replanning
|
||||||
|
|
||||||
|
When characters move they can displace each other or enter each other's planned paths. `Location::updateDynamicReplans` handles this:
|
||||||
|
|
||||||
|
**Every frame:**
|
||||||
|
|
||||||
|
1. Measure how much each character moved since the last frame. Characters that moved more than `kMovedEps = 0.05 m` are collected as **movers**.
|
||||||
|
2. For each mover, find other characters that are currently walking. If the mover's position is within `kReplanTriggerDist = 1.8 m` of the segment `[walker.position → walker.nextWaypoint]`, trigger a replan for the walker via `forceReplan()`.
|
||||||
|
3. A per-character cooldown of `kReplanCooldownMs = 500 ms` prevents the same character from replanning more often than twice per second.
|
||||||
|
|
||||||
|
**`Character::forceReplan()`** re-runs the path planner from the character's current position to its stored `requestedWalkTarget`, updating `pathWaypoints` in place. If the replanned path is empty, the character stops at its current position.
|
||||||
|
|
||||||
|
The relatively generous trigger distance (1.8 m vs the old 1.1 m) and cooldown (500 ms vs 300 ms) prevent micro-jitter: small position corrections from collision resolution no longer spam replanning events.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Key Constants Reference
|
||||||
|
|
||||||
|
| Constant | Location | Value | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `cellSize` | `PathFinder` config | 0.4 m | Grid cell size |
|
||||||
|
| `agentRadius` | `PathFinder` config | 0.45 m | Character half-width for grid erosion |
|
||||||
|
| `objectPadding` | `PathFinder` config | 0.25 m | Extra clearance around obstacles |
|
||||||
|
| `WALK_THRESHOLD` | `Character.h` | 0.05 m | Distance below which a waypoint is considered reached |
|
||||||
|
| `TARGET_REPLAN_THRESHOLD` | `Character.h` | 0.25 m | Deduplication threshold in `setTarget` |
|
||||||
|
| `kDynamicObstacleInfluenceDist` | `Location.cpp` | 6.0 m | Max distance from path for a character to become an obstacle |
|
||||||
|
| `kDynamicObstacleRadiusFraction` | `Location.cpp` | 0.6× | Fraction of collision radius used for dynamic obstacle footprint |
|
||||||
|
| `kNudgeDist` | `Location.cpp` | 1.2 m | Distance an NPC steps aside when yielding |
|
||||||
|
| `kReplanTriggerDist` | `Location.cpp` | 1.8 m | Mover must be this close to a walker's path to trigger replan |
|
||||||
|
| `kReplanCooldownMs` | `Location.cpp` | 500 ms | Minimum interval between replans for any one character |
|
||||||
|
| `NPC_TALK_DISTANCE` | `Location.cpp` | 1.35 m | Distance at which walking-to-NPC interaction fires |
|
||||||
|
| `kIterations` (collision) | `Location.cpp` | 3 | Push-apart iterations per frame |
|
||||||
@@ -126,6 +126,18 @@ $(pkg-config --cflags --libs vorbis vorbisfile ogg) \
|
|||||||
-lopenal
|
-lopenal
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
Linux new:
|
||||||
|
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install build-essential cmake pkg-config \
|
||||||
|
libsdl2-dev libsdl2-ttf-dev libsdl2-mixer-dev \
|
||||||
|
libgl1-mesa-dev libpng-dev libz-dev libzip-dev \
|
||||||
|
libboost-dev libeigen3-dev liblua5.4-dev
|
||||||
|
|
||||||
|
sudo apt-get install libglu1-mesa-dev
|
||||||
|
|
||||||
|
|
||||||
# Emscripten new
|
# Emscripten new
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -172,4 +184,45 @@ make -j$(nproc) -C build #Компилируем
|
|||||||
|
|
||||||
Для постройки без звука
|
Для постройки без звука
|
||||||
rm -rf build #Очищаем build папку
|
rm -rf build #Очищаем build папку
|
||||||
cmake -B build -DAUDIO=1 #Пересоздаём конфигурацию CMake
|
cmake -B build -DAUDIO=1 #Пересоздаём конфигурацию CMake
|
||||||
|
|
||||||
|
|
||||||
|
# Cmake Build NSIS and Portable for Windows:
|
||||||
|
|
||||||
|
```
|
||||||
|
cmake --build . --config Release
|
||||||
|
cpack -C Release
|
||||||
|
```
|
||||||
|
|
||||||
|
Если есть такая ошибка:
|
||||||
|
|
||||||
|
CPack Error: Cannot find NSIS compiler makensis: likely it is not installed, or not in your PATH
|
||||||
|
CPack Error: Could not read NSIS registry value. This is usually caused by NSIS not being installed. Please install NSIS from http://nsis.sourceforge.net
|
||||||
|
CPack Error: Cannot initialize the generator NSIS
|
||||||
|
|
||||||
|
|
||||||
|
То нужно установить nsis отсюда: https://nsis.sourceforge.io/Download
|
||||||
|
|
||||||
|
|
||||||
|
# Steam windows
|
||||||
|
|
||||||
|
```
|
||||||
|
cmake -DSTEAMSDK=ON ..
|
||||||
|
cmake --build . --config Release
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
# Steam Linux
|
||||||
|
|
||||||
|
```
|
||||||
|
docker run -it --rm -v "${PWD}:/work2" -w /work2 registry.gitlab.steamos.cloud/steamrt/sniper/sdk:latest bash
|
||||||
|
|
||||||
|
|
||||||
|
apt-get update
|
||||||
|
apt-get install libboost-dev libeigen3-dev liblua5.4-dev libzip-dev libglu1-mesa-dev
|
||||||
|
|
||||||
|
|
||||||
|
cmake -DSTEAMSDK=ON -DCMAKE_BUILD_TYPE=Release ..
|
||||||
|
cmake --build . -j 4
|
||||||
|
|
||||||
|
```
|
||||||
@@ -0,0 +1,527 @@
|
|||||||
|
# UI System
|
||||||
|
|
||||||
|
UI layouts are defined in JSON files and loaded at runtime by `UiManager`. Each file has a single `"root"` node that is the top-level container.
|
||||||
|
|
||||||
|
The coordinate system has the origin at the **bottom-left** of the screen. Y increases upward.
|
||||||
|
The virtual canvas size is defined by `Environment::projectionWidth` × `Environment::projectionHeight`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"root": { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Node Properties
|
||||||
|
|
||||||
|
These properties are available on every node type.
|
||||||
|
|
||||||
|
| Property | Type | Default | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `name` | string | `""` | Unique name used to find the node from C++ code |
|
||||||
|
| `x` | float | `0` | Horizontal offset from the parent's origin (or gravity-adjusted position) |
|
||||||
|
| `y` | float | `0` | Vertical offset |
|
||||||
|
| `width` | float \| `"match_parent"` | `0` | Width in virtual pixels. `"match_parent"` fills the parent |
|
||||||
|
| `height` | float \| `"match_parent"` | `0` | Height in virtual pixels |
|
||||||
|
| `horizontal_gravity` | `"left"` \| `"center"` \| `"right"` | `"left"` | Positions the node horizontally inside a **FrameLayout** parent |
|
||||||
|
| `vertical_gravity` | `"bottom"` \| `"center"` \| `"top"` | `"bottom"` | Positions the node vertically inside a **FrameLayout** parent |
|
||||||
|
| `visible` | bool | `true` | Whether the node (and all its children) are rendered and interactive. Can be toggled at runtime via `setNodeVisible` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Containers
|
||||||
|
|
||||||
|
### FrameLayout
|
||||||
|
|
||||||
|
Children are positioned using absolute `x`/`y` offsets and/or `horizontal_gravity` / `vertical_gravity`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "FrameLayout",
|
||||||
|
"name": "hud_root",
|
||||||
|
"width": "match_parent",
|
||||||
|
"height": "match_parent",
|
||||||
|
"children": [ ... ]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Property | Type | Default | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `children` | array | `[]` | Child nodes |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### LinearLayout
|
||||||
|
|
||||||
|
Children are stacked automatically in a row or column. Gravity and align properties control the layout of the block and its children.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "LinearLayout",
|
||||||
|
"orientation": "vertical",
|
||||||
|
"vertical_align": "center",
|
||||||
|
"horizontal_align": "center",
|
||||||
|
"spacing": 10,
|
||||||
|
"width": 400,
|
||||||
|
"height": 600,
|
||||||
|
"children": [ ... ]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Property | Type | Default | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `orientation` | `"vertical"` \| `"horizontal"` | `"vertical"` | Direction children are stacked |
|
||||||
|
| `spacing` | float | `0` | Gap in pixels between consecutive children |
|
||||||
|
| `vertical_align` | `"top"` \| `"center"` \| `"bottom"` | `"top"` | **Vertical** alignment of the child block inside this layout. For vertical orientation, controls how the whole stack is aligned; for horizontal orientation, controls each child's cross-axis alignment |
|
||||||
|
| `horizontal_align` | `"left"` \| `"center"` \| `"right"` | `"left"` | **Horizontal** alignment of the child block. For horizontal orientation, controls how the whole row is aligned; for vertical orientation, controls each child's cross-axis alignment |
|
||||||
|
| `children` | array | `[]` | Child nodes, laid out in order |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Widgets
|
||||||
|
|
||||||
|
### Button
|
||||||
|
|
||||||
|
An image-only clickable button. Swaps textures on hover/press.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "Button",
|
||||||
|
"name": "closeButton",
|
||||||
|
"width": 90,
|
||||||
|
"height": 90,
|
||||||
|
"x": 580,
|
||||||
|
"y": 240,
|
||||||
|
"horizontal_gravity": "center",
|
||||||
|
"vertical_gravity": "center",
|
||||||
|
"textures": {
|
||||||
|
"normal": "resources/w/ui/img/Close001_State=Default.png",
|
||||||
|
"hover": "resources/w/ui/img/Close001_State=Selected.png",
|
||||||
|
"pressed": "resources/w/ui/img/Close001_State=Tap.png",
|
||||||
|
"disabled": "resources/w/ui/img/Close001_State=Disabled.png"
|
||||||
|
},
|
||||||
|
"border": 4,
|
||||||
|
"clickZoneWidth": 80,
|
||||||
|
"clickZoneHeight": 80
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Property | Type | Default | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `textures.normal` | string | — | Texture path shown in the default state (**required**) |
|
||||||
|
| `textures.hover` | string | — | Texture path shown when the mouse hovers |
|
||||||
|
| `textures.pressed` | string | — | Texture path shown while pressed |
|
||||||
|
| `textures.disabled` | string | — | Texture path shown when the button is disabled |
|
||||||
|
| `border` | float | `0` | Inset (pixels) applied to the hit-test zone on all sides |
|
||||||
|
| `clickZoneWidth` | float | `0` | Explicit hit-test width; `0` uses the widget width |
|
||||||
|
| `clickZoneHeight` | float | `0` | Explicit hit-test height; `0` uses the widget height |
|
||||||
|
|
||||||
|
**C++ callbacks:**
|
||||||
|
```cpp
|
||||||
|
uiManager.setButtonCallback("closeButton", [](const std::string&) { /* click */ });
|
||||||
|
uiManager.setButtonPressCallback("closeButton", [](const std::string&) { /* press */ });
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### TextButton
|
||||||
|
|
||||||
|
A button that renders a text label on top of an optional background texture.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "TextButton",
|
||||||
|
"name": "item1name",
|
||||||
|
"width": 270,
|
||||||
|
"height": 60,
|
||||||
|
"text": "Main Quest",
|
||||||
|
"fontSize": 32,
|
||||||
|
"fontPath": "resources/fonts/DroidSans.ttf",
|
||||||
|
"textCentered": false,
|
||||||
|
"topAligned": false,
|
||||||
|
"textPaddingX": 12,
|
||||||
|
"textPaddingY": -8,
|
||||||
|
"wrap": true,
|
||||||
|
"color": [1.0, 1.0, 1.0, 1.0],
|
||||||
|
"textures": {
|
||||||
|
"normal": "resources/w/red.png",
|
||||||
|
"hover": "resources/w/red.png",
|
||||||
|
"pressed": "resources/w/red.png"
|
||||||
|
},
|
||||||
|
"border": 0,
|
||||||
|
"clickZoneWidth": 0,
|
||||||
|
"clickZoneHeight": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Property | Type | Default | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `text` | string | `""` | Label text. Supports Cyrillic and any codepoint in `resources/symbols.txt` |
|
||||||
|
| `fontSize` | int | `32` | Font size in pixels |
|
||||||
|
| `fontPath` | string | `"resources/fonts/DroidSans.ttf"` | Path to the TTF font file |
|
||||||
|
| `textCentered` | bool | `true` | Horizontally centers the text within the widget. When `false`, text starts at `textPaddingX` from the left edge |
|
||||||
|
| `topAligned` | bool | `false` | When `true`, the first text line is placed near the top of the widget; when `false`, the text is vertically centered |
|
||||||
|
| `textPaddingX` | float | `12` | Left padding when `textCentered` is `false`; also used to compute the wrapping width |
|
||||||
|
| `textPaddingY` | float | `0` | Vertical offset applied to the text baseline |
|
||||||
|
| `wrap` | bool | `false` | Wraps text that exceeds `width - textPaddingX * 2` pixels |
|
||||||
|
| `color` | [R, G, B, A] | `[1,1,1,1]` | Text color, each channel 0..1 |
|
||||||
|
| `textures.*` | string | — | Background textures (all optional — button can be text-only) |
|
||||||
|
| `border` | float | `0` | Hit-test inset |
|
||||||
|
| `clickZoneWidth` / `clickZoneHeight` | float | `0` | Explicit hit-test size; `0` uses the widget size |
|
||||||
|
|
||||||
|
**C++ callbacks:**
|
||||||
|
```cpp
|
||||||
|
uiManager.setTextButtonCallback("item1name", [](const std::string&) { /* click */ });
|
||||||
|
uiManager.setTextButtonPressCallback("item1name", [](const std::string&) { /* press */ });
|
||||||
|
|
||||||
|
// Programmatic updates
|
||||||
|
uiManager.setTextButtonText("item1name", "New Quest Name");
|
||||||
|
uiManager.setTextButtonColor("item1name", {1.f, 0.f, 0.f, 1.f});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### TextView
|
||||||
|
|
||||||
|
A non-interactive text display widget.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "TextView",
|
||||||
|
"name": "quest_description",
|
||||||
|
"x": 170,
|
||||||
|
"y": 390,
|
||||||
|
"width": 1000,
|
||||||
|
"height": 300,
|
||||||
|
"text": "Long description here.",
|
||||||
|
"fontSize": 32,
|
||||||
|
"fontPath": "resources/fonts/DroidSans.ttf",
|
||||||
|
"textCentered": false,
|
||||||
|
"topAligned": true,
|
||||||
|
"wrap": true,
|
||||||
|
"paddingX": 0,
|
||||||
|
"paddingY": 4,
|
||||||
|
"maxLines": 10,
|
||||||
|
"color": [1.0, 1.0, 0.0, 1.0]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Property | Type | Default | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `text` | string | `""` | Display text. Supports Cyrillic and any codepoint in `resources/symbols.txt` |
|
||||||
|
| `fontSize` | int | `32` | Font size in pixels |
|
||||||
|
| `fontPath` | string | `"resources/fonts/DroidSans.ttf"` | Path to the TTF font file |
|
||||||
|
| `textCentered` | bool | `true` | Horizontally centers the text when `true`; left-aligns from `paddingX` when `false` |
|
||||||
|
| `topAligned` | bool | `false` | When `true`, the first line is placed near the top edge; when `false`, text is vertically centered |
|
||||||
|
| `wrap` | bool | `false` | Wraps text at `width - paddingX * 2` pixels |
|
||||||
|
| `paddingX` | float | `0` | Left/right padding used for alignment and wrap width |
|
||||||
|
| `paddingY` | float | `0` | Vertical inset applied when `topAligned` is `true` |
|
||||||
|
| `maxLines` | int | `0` | Maximum number of lines to display; `0` means unlimited. Truncated text gets `...` |
|
||||||
|
| `color` | [R, G, B, A] | `[1,1,1,1]` | Text color |
|
||||||
|
|
||||||
|
> **Legacy note:** If none of `wrap`, `topAligned`, `paddingX`, `paddingY`, or `maxLines` are set, the text is drawn centered on `(x + width/2, y + height/2)` for backward compatibility.
|
||||||
|
|
||||||
|
**C++ updates:**
|
||||||
|
```cpp
|
||||||
|
uiManager.setText("quest_description", "New text here.");
|
||||||
|
uiManager.setTextColor("quest_description", {1.f, 1.f, 0.f, 1.f});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### TextField
|
||||||
|
|
||||||
|
An interactive single-line text input field. Receives keyboard input when focused.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "TextField",
|
||||||
|
"name": "playerName",
|
||||||
|
"x": 100,
|
||||||
|
"y": 300,
|
||||||
|
"width": 400,
|
||||||
|
"height": 50,
|
||||||
|
"placeholder": "Enter name...",
|
||||||
|
"fontSize": 28,
|
||||||
|
"fontPath": "resources/fonts/DroidSans.ttf",
|
||||||
|
"maxLength": 64,
|
||||||
|
"color": [1.0, 1.0, 1.0, 1.0],
|
||||||
|
"placeholderColor": [0.5, 0.5, 0.5, 1.0],
|
||||||
|
"backgroundColor": [0.2, 0.2, 0.2, 1.0],
|
||||||
|
"borderColor": [0.5, 0.5, 0.5, 1.0]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Property | Type | Default | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `placeholder` | string | `""` | Text shown when the field is empty |
|
||||||
|
| `fontSize` | int | `32` | Font size in pixels |
|
||||||
|
| `fontPath` | string | `"resources/fonts/DroidSans.ttf"` | Path to the TTF font file |
|
||||||
|
| `maxLength` | int | `256` | Maximum number of characters |
|
||||||
|
| `color` | [R, G, B, A] | `[1,1,1,1]` | Input text color |
|
||||||
|
| `placeholderColor` | [R, G, B, A] | `[0.5,0.5,0.5,1]` | Placeholder text color |
|
||||||
|
| `backgroundColor` | [R, G, B, A] | `[0.2,0.2,0.2,1]` | Field background color |
|
||||||
|
| `borderColor` | [R, G, B, A] | `[0.5,0.5,0.5,1]` | Border color |
|
||||||
|
|
||||||
|
**C++ callbacks and queries:**
|
||||||
|
```cpp
|
||||||
|
uiManager.setTextFieldCallback("playerName", [](const std::string& name, const std::string& value) {
|
||||||
|
// called on every keystroke
|
||||||
|
});
|
||||||
|
std::string current = uiManager.getTextFieldValue("playerName");
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Slider
|
||||||
|
|
||||||
|
A draggable slider that returns a normalized value in the range `[0, 1]`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "Slider",
|
||||||
|
"name": "volumeSlider",
|
||||||
|
"x": 100,
|
||||||
|
"y": 200,
|
||||||
|
"width": 40,
|
||||||
|
"height": 300,
|
||||||
|
"orientation": "vertical",
|
||||||
|
"value": 0.75,
|
||||||
|
"textures": {
|
||||||
|
"track": "resources/w/ui/img/slider_track.png",
|
||||||
|
"knob": "resources/w/ui/img/slider_knob.png"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Property | Type | Default | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `textures.track` | string | — | Texture for the slider track |
|
||||||
|
| `textures.knob` | string | — | Texture for the draggable knob |
|
||||||
|
| `orientation` | `"vertical"` \| `"horizontal"` | `"vertical"` | Drag direction |
|
||||||
|
| `value` | float | `0` | Initial normalized value `[0, 1]` |
|
||||||
|
|
||||||
|
**C++ callbacks:**
|
||||||
|
```cpp
|
||||||
|
uiManager.setSliderCallback("volumeSlider", [](const std::string& name, float value) {
|
||||||
|
// value is 0..1
|
||||||
|
});
|
||||||
|
uiManager.setSliderValue("volumeSlider", 0.5f);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### StaticImage
|
||||||
|
|
||||||
|
A non-interactive image. Supports optional fade-in and pulse-scale animations.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "StaticImage",
|
||||||
|
"name": "background",
|
||||||
|
"width": 1266,
|
||||||
|
"height": 585,
|
||||||
|
"horizontal_gravity": "center",
|
||||||
|
"vertical_gravity": "center",
|
||||||
|
"texture": "resources/w/ui/img/journal/QuestJournal003.png",
|
||||||
|
"fadeIn": {
|
||||||
|
"durationMs": 600
|
||||||
|
},
|
||||||
|
"pulse": {
|
||||||
|
"minScale": 0.92,
|
||||||
|
"maxScale": 1.08,
|
||||||
|
"periodMs": 1500
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Property | Type | Default | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `texture` | string | — | Path to the PNG texture |
|
||||||
|
| `fadeIn.durationMs` | float | — | If present, the image fades in over this many milliseconds each time the UI is shown |
|
||||||
|
| `pulse.minScale` | float | `0.9` | Minimum scale during the pulse cycle |
|
||||||
|
| `pulse.maxScale` | float | `1.1` | Maximum scale during the pulse cycle |
|
||||||
|
| `pulse.periodMs` | float | `1000` | Duration of one full pulse cycle in milliseconds |
|
||||||
|
|
||||||
|
**C++ pop-in animation** (scales the node from 0 → 1, ease-out quad):
|
||||||
|
```cpp
|
||||||
|
uiManager.startPopIn("background", 300.0f); // duration in milliseconds
|
||||||
|
```
|
||||||
|
Typically called immediately after making a node visible. The node is automatically removed from the animation list when the scale reaches 1.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Touch / Click Priority
|
||||||
|
|
||||||
|
All `Button` and `TextButton` nodes in a layout are collected into a single ordered list during `collectButtonsAndSliders` (depth-first traversal of the node tree, which matches JSON declaration order). When a touch or mouse-down event arrives, the list is scanned **in reverse** — later-declared nodes are checked first — and the **first hit wins**. At most one element (button or textButton, regardless of type) fires per touch.
|
||||||
|
|
||||||
|
**Practical rule:** place background "catch-all" elements (e.g. a full-screen transparent exit button) **early** in the JSON, and foreground interactive elements **later**. The later-declared element will always win when they overlap.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "FrameLayout",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"type": "Button",
|
||||||
|
"name": "phoneExitButton", // declared first → lowest priority
|
||||||
|
"width": "match_parent",
|
||||||
|
"height": "match_parent",
|
||||||
|
"textures": { "normal": "resources/transparent.png", ... }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "TextButton",
|
||||||
|
"name": "chat2button", // declared later → wins over phoneExitButton
|
||||||
|
...
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This behaviour is consistent across `Button` and `TextButton` — there is no inherent type priority, only declaration order matters.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Animations
|
||||||
|
|
||||||
|
Animations can be defined on **Button** and **TextButton** nodes and started from C++ code. Each animation is a named sequence of steps.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "Button",
|
||||||
|
"name": "myButton",
|
||||||
|
"width": 100,
|
||||||
|
"height": 100,
|
||||||
|
"textures": { "normal": "resources/w/btn.png" },
|
||||||
|
"animations": {
|
||||||
|
"bounce": {
|
||||||
|
"repeat": false,
|
||||||
|
"steps": [
|
||||||
|
{ "type": "move", "to": [0, 20], "duration": 0.15, "easing": "easeout" },
|
||||||
|
{ "type": "move", "to": [0, 0], "duration": 0.15, "easing": "easein" },
|
||||||
|
{ "type": "wait", "duration": 0.1 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"pulse": {
|
||||||
|
"repeat": true,
|
||||||
|
"steps": [
|
||||||
|
{ "type": "scale", "to": [1.1, 1.1], "duration": 0.4, "easing": "easeout" },
|
||||||
|
{ "type": "scale", "to": [1.0, 1.0], "duration": 0.4, "easing": "easein" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Animation sequence properties
|
||||||
|
|
||||||
|
| Property | Type | Default | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `repeat` | bool | `false` | Whether the sequence loops after the last step |
|
||||||
|
| `steps` | array | — | Ordered list of animation steps |
|
||||||
|
|
||||||
|
### Step properties
|
||||||
|
|
||||||
|
| Property | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `type` | `"move"` \| `"scale"` \| `"wait"` | Step kind |
|
||||||
|
| `to` | [x, y] | Target offset (`move`) or scale factors (`scale`) |
|
||||||
|
| `duration` | float (seconds) | Duration of the step. `0` applies the target instantly |
|
||||||
|
| `easing` | `"linear"` \| `"easein"` \| `"easeout"` | Interpolation curve (default `"linear"`) |
|
||||||
|
|
||||||
|
**C++ control:**
|
||||||
|
```cpp
|
||||||
|
uiManager.startAnimationOnNode("myButton", "bounce");
|
||||||
|
uiManager.stopAnimationOnNode("myButton", "bounce");
|
||||||
|
uiManager.setAnimationCallback("myButton", "bounce", []() {
|
||||||
|
// called when the non-repeating sequence finishes
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## C++ API Quick Reference
|
||||||
|
|
||||||
|
### Loading and navigation
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
uiManager.loadFromFile("resources/w/ui/screen.json", renderer);
|
||||||
|
uiManager.pushMenuFromFile("resources/w/ui/popup.json", renderer); // push on stack
|
||||||
|
uiManager.popMenu(); // restore previous UI
|
||||||
|
uiManager.clearMenuStack();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Finding nodes
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
auto node = uiManager.findNode("myNode");
|
||||||
|
auto btn = uiManager.findButton("myButton");
|
||||||
|
auto tbtn = uiManager.findTextButton("item1name");
|
||||||
|
auto tv = uiManager.findTextView("quest_description");
|
||||||
|
auto img = uiManager.findStaticImage("background");
|
||||||
|
auto slider = uiManager.findSlider("volumeSlider");
|
||||||
|
auto tf = uiManager.findTextField("playerName");
|
||||||
|
```
|
||||||
|
|
||||||
|
### Visibility
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
uiManager.setNodeVisible("hint5", false);
|
||||||
|
bool visible = uiManager.getNodeVisible("hint5");
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pop-in animation
|
||||||
|
|
||||||
|
Scales a node from 0 to 1 using an ease-out curve. Useful for chat bubble reveals and similar "appear" effects.
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
uiManager.startPopIn("messageBubble", 300.0f); // node name, duration ms
|
||||||
|
```
|
||||||
|
|
||||||
|
Set the node's `scaleX`/`scaleY` to `0` and call `setNodeVisible` before calling `startPopIn` to avoid a one-frame flash at full size.
|
||||||
|
|
||||||
|
### Dynamic node repositioning
|
||||||
|
|
||||||
|
`node->localY` (and `localX`) can be modified directly on a node pointer, then a layout recalculation applied:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
auto node = uiManager.findNode("messageBubble");
|
||||||
|
node->localY = 350.0f; // new bottom-Y (for vertical_gravity: bottom nodes)
|
||||||
|
uiManager.updateAllLayouts(); // recomputes screenRect and rebuilds meshes
|
||||||
|
```
|
||||||
|
|
||||||
|
This is how the phone chat manager repositions bubbles as new messages arrive.
|
||||||
|
|
||||||
|
### Per-frame update
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
uiManager.update(deltaMs); // advance animations and fade-ins
|
||||||
|
uiManager.draw(renderer); // render everything
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dialogue → UI integration (phone chat bubbles)
|
||||||
|
|
||||||
|
Dialogue nodes in JSON can carry a `"bubbleSlot"` field naming a `StaticImage` UI node. When the dialogue runtime presents that line, it fires the `onBubbleSlotReady` callback with the slot name, which the game uses to reveal the corresponding bubble image.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "line_1",
|
||||||
|
"type": "Line",
|
||||||
|
"speaker": "Айпери",
|
||||||
|
"text": "...",
|
||||||
|
"next": "line_2",
|
||||||
|
"bubbleSlot": "message01in"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Lines without `"bubbleSlot"` (or with an empty value) do not trigger any UI change — useful for internal monologue lines that have no corresponding chat image.
|
||||||
|
|
||||||
|
**C++ wiring:**
|
||||||
|
```cpp
|
||||||
|
dialogueSystem.setOnBubbleSlotReady([](const std::string& slotName) {
|
||||||
|
// slotName == "message01in" etc.
|
||||||
|
menuManager.revealPhoneChatBubble(slotName);
|
||||||
|
});
|
||||||
|
```
|
||||||
BIN
Binary file not shown.
Binary file not shown.
@@ -25,7 +25,7 @@ macro(check_and_download URL ARCHIVE_NAME EXTRACTED_DIR_NAME CHECK_FILE)
|
|||||||
endmacro()
|
endmacro()
|
||||||
|
|
||||||
# 1) ZLIB (Нужна только для инклудов, если не используете emscripten порты)
|
# 1) ZLIB (Нужна только для инклудов, если не используете emscripten порты)
|
||||||
check_and_download("https://www.zlib.net/zlib132.zip" "zlib132.zip" "zlib-1.3.2" "CMakeLists.txt")
|
check_and_download("https://github.com/madler/zlib/releases/download/v1.3.2/zlib132.zip" "zlib132.zip" "zlib-1.3.2" "CMakeLists.txt")
|
||||||
|
|
||||||
# 2) SDL2
|
# 2) SDL2
|
||||||
check_and_download("https://github.com/libsdl-org/SDL/archive/refs/tags/release-2.32.10.zip" "release-2.32.10.zip" "SDL-release-2.32.10" "CMakeLists.txt")
|
check_and_download("https://github.com/libsdl-org/SDL/archive/refs/tags/release-2.32.10.zip" "release-2.32.10.zip" "SDL-release-2.32.10" "CMakeLists.txt")
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# cmake/FetchDependenciesLinux.cmake
|
||||||
|
|
||||||
|
set(THIRDPARTY_DIR "${CMAKE_CURRENT_LIST_DIR}/../thirdparty")
|
||||||
|
|
||||||
|
if(NOT EXISTS "${THIRDPARTY_DIR}")
|
||||||
|
file(MAKE_DIRECTORY "${THIRDPARTY_DIR}")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
macro(check_and_download URL ARCHIVE_NAME EXTRACTED_DIR_NAME CHECK_FILE)
|
||||||
|
set(ARCHIVE_PATH "${THIRDPARTY_DIR}/${ARCHIVE_NAME}")
|
||||||
|
set(SRC_PATH "${THIRDPARTY_DIR}/${EXTRACTED_DIR_NAME}")
|
||||||
|
|
||||||
|
if(NOT EXISTS "${ARCHIVE_PATH}")
|
||||||
|
message(STATUS "Downloading ${ARCHIVE_NAME}...")
|
||||||
|
file(DOWNLOAD "${URL}" "${ARCHIVE_PATH}" SHOW_PROGRESS)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if(NOT EXISTS "${SRC_PATH}/${CHECK_FILE}")
|
||||||
|
message(STATUS "Extracting ${ARCHIVE_NAME}...")
|
||||||
|
execute_process(
|
||||||
|
COMMAND ${CMAKE_COMMAND} -E tar xvf "${ARCHIVE_PATH}"
|
||||||
|
WORKING_DIRECTORY "${THIRDPARTY_DIR}"
|
||||||
|
)
|
||||||
|
endif()
|
||||||
|
endmacro()
|
||||||
|
|
||||||
|
# 1) sol2 (header-only C++ bindings for Lua)
|
||||||
|
check_and_download("https://github.com/ThePhD/sol2/archive/refs/tags/v3.3.0.zip" "sol2-v3.3.0.zip" "sol2-3.3.0" "include/sol/sol.hpp")
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
cmake_minimum_required(VERSION 3.10)
|
|
||||||
project(AudioPlayer)
|
|
||||||
|
|
||||||
set(CMAKE_CXX_STANDARD 17)
|
|
||||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
|
||||||
|
|
||||||
# Use pkg-config to find Vorbis
|
|
||||||
#find_package(PkgConfig REQUIRED)
|
|
||||||
#pkg_check_modules(VORBIS REQUIRED vorbis vorbisfile)
|
|
||||||
#pkg_check_modules(OGG REQUIRED ogg)
|
|
||||||
find_package(OpenAL REQUIRED)
|
|
||||||
|
|
||||||
add_library(audioplayer
|
|
||||||
src/AudioPlayer.cpp
|
|
||||||
include/AudioPlayer.hpp
|
|
||||||
)
|
|
||||||
|
|
||||||
target_include_directories(audioplayer
|
|
||||||
PUBLIC
|
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/include
|
|
||||||
${OPENAL_INCLUDE_DIR}
|
|
||||||
${VORBIS_INCLUDE_DIRS}
|
|
||||||
${OGG_INCLUDE_DIRS}
|
|
||||||
)
|
|
||||||
|
|
||||||
target_link_libraries(audioplayer
|
|
||||||
PUBLIC
|
|
||||||
${OPENAL_LIBRARY}
|
|
||||||
${VORBIS_LIBRARIES}
|
|
||||||
${OGG_LIBRARIES}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Test executable
|
|
||||||
add_executable(test_audio examples/test_audio.cpp)
|
|
||||||
target_link_libraries(test_audio PRIVATE audioplayer stdc++fs)
|
|
||||||
#git add ../../sounds
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
#include "AudioPlayer.hpp"
|
|
||||||
#include <iostream>
|
|
||||||
#include <thread>
|
|
||||||
#include <chrono>
|
|
||||||
#include <string>
|
|
||||||
|
|
||||||
int main() {
|
|
||||||
try {
|
|
||||||
AudioPlayer player;
|
|
||||||
const std::string filename = "Symphony No.6 (1st movement).ogg";
|
|
||||||
|
|
||||||
std::cout << "🔍 Looking for file: " << filename << " in sounds directory...\n";
|
|
||||||
|
|
||||||
if (!player.playFromSoundsDir(filename)) {
|
|
||||||
std::cout << "❌ Failed to play audio file\n";
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
std::cout << "✅ Playing symphony...\n";
|
|
||||||
|
|
||||||
// Check status for 30 seconds
|
|
||||||
for (int i = 0; i < 30; ++i) {
|
|
||||||
std::cout << "📊 Status: " << (player.isPlaying() ? "Playing ▶️" : "Stopped ⏹️") << "\n";
|
|
||||||
std::this_thread::sleep_for(std::chrono::seconds(1));
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
} catch (const std::exception& e) {
|
|
||||||
std::cerr << "❌ Error: " << e.what() << "\n";
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include <string>
|
|
||||||
#include <AL/al.h>
|
|
||||||
#include <AL/alc.h>
|
|
||||||
#include <vorbis/vorbisfile.h>
|
|
||||||
#include <vector>
|
|
||||||
#include <cstdint>
|
|
||||||
|
|
||||||
class AudioPlayer {
|
|
||||||
public:
|
|
||||||
AudioPlayer();
|
|
||||||
~AudioPlayer();
|
|
||||||
|
|
||||||
// Для музыки с зацикливанием (если filename пустой - продолжает играть текущую)
|
|
||||||
bool playMusic(const std::string& filename = "");
|
|
||||||
|
|
||||||
// Для одноразовых звуковых эффектов
|
|
||||||
bool playSound(const std::string& filename);
|
|
||||||
|
|
||||||
void stop();
|
|
||||||
bool isPlaying() const;
|
|
||||||
|
|
||||||
private:
|
|
||||||
ALCdevice* device;
|
|
||||||
ALCcontext* context;
|
|
||||||
ALuint musicSource; // Источник для музыки
|
|
||||||
ALuint soundSource; // Источник для звуков
|
|
||||||
ALuint musicBuffer; // Буфер для музыки
|
|
||||||
ALuint soundBuffer; // Буфер для звуков
|
|
||||||
bool playing;
|
|
||||||
std::string currentMusic; // Хранит имя текущего музыкального файла
|
|
||||||
|
|
||||||
std::vector<char> loadOgg(const std::string& filename, ALuint buffer);
|
|
||||||
std::string findFileInSounds(const std::string& filename);
|
|
||||||
bool isOggFile(const std::string& filename) const;
|
|
||||||
};
|
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
#include "AudioPlayer.hpp"
|
|
||||||
#include <filesystem>
|
|
||||||
#include <fstream>
|
|
||||||
#include <iostream>
|
|
||||||
#include <stdexcept>
|
|
||||||
#include <cstdint>
|
|
||||||
#include <algorithm>
|
|
||||||
|
|
||||||
AudioPlayer::AudioPlayer() : device(nullptr), context(nullptr),
|
|
||||||
musicSource(0), soundSource(0), musicBuffer(0), soundBuffer(0), playing(false) {
|
|
||||||
device = alcOpenDevice(nullptr);
|
|
||||||
if (!device) {
|
|
||||||
throw std::runtime_error("Failed to open audio device");
|
|
||||||
}
|
|
||||||
|
|
||||||
context = alcCreateContext(device, nullptr);
|
|
||||||
if (!context) {
|
|
||||||
alcCloseDevice(device);
|
|
||||||
throw std::runtime_error("Failed to create audio context");
|
|
||||||
}
|
|
||||||
|
|
||||||
alcMakeContextCurrent(context);
|
|
||||||
alGenSources(1, &musicSource);
|
|
||||||
alGenSources(1, &soundSource);
|
|
||||||
alGenBuffers(1, &musicBuffer);
|
|
||||||
alGenBuffers(1, &soundBuffer);
|
|
||||||
}
|
|
||||||
|
|
||||||
AudioPlayer::~AudioPlayer() {
|
|
||||||
if (musicSource)
|
|
||||||
alDeleteSources(1, &musicSource);
|
|
||||||
if (soundSource)
|
|
||||||
alDeleteSources(1, &soundSource);
|
|
||||||
if (musicBuffer)
|
|
||||||
alDeleteBuffers(1, &musicBuffer);
|
|
||||||
if (soundBuffer)
|
|
||||||
alDeleteBuffers(1, &soundBuffer);
|
|
||||||
|
|
||||||
if (context) {
|
|
||||||
alcMakeContextCurrent(nullptr);
|
|
||||||
alcDestroyContext(context);
|
|
||||||
}
|
|
||||||
if (device)
|
|
||||||
alcCloseDevice(device);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool AudioPlayer::isOggFile(const std::string& filename) const {
|
|
||||||
std::string ext = std::filesystem::path(filename).extension().string();
|
|
||||||
std::transform(ext.begin(), ext.end(), ext.begin(), ::tolower);
|
|
||||||
return ext == ".ogg";
|
|
||||||
}
|
|
||||||
|
|
||||||
std::string AudioPlayer::findFileInSounds(const std::string& filename) {
|
|
||||||
// Primary search path - "sounds" directory next to executable
|
|
||||||
std::filesystem::path soundsDir = std::filesystem::current_path() / "sounds";
|
|
||||||
|
|
||||||
// Alternative search paths
|
|
||||||
std::vector<std::filesystem::path> altPaths = {
|
|
||||||
std::filesystem::current_path() / ".." / "sounds", // One level up
|
|
||||||
std::filesystem::current_path() / ".." / ".." / "sounds", // Two levels up
|
|
||||||
"/home/albert/gay-jam/ZeptoLabTest1/sounds" // Absolute path
|
|
||||||
};
|
|
||||||
|
|
||||||
std::cout << "🔍 Searching for \"" << filename << "\" in:\n";
|
|
||||||
std::cout << " " << soundsDir << "\n";
|
|
||||||
|
|
||||||
if (std::filesystem::exists(soundsDir / filename)) {
|
|
||||||
return (soundsDir / filename).string();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try alternative paths
|
|
||||||
for (const auto& path : altPaths) {
|
|
||||||
std::cout << " " << path << "\n";
|
|
||||||
if (std::filesystem::exists(path / filename)) {
|
|
||||||
return (path / filename).string();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw std::runtime_error("❌ File not found: " + filename);
|
|
||||||
}
|
|
||||||
|
|
||||||
std::vector<char> AudioPlayer::loadOgg(const std::string& filename, ALuint buffer) {
|
|
||||||
FILE* file = fopen(filename.c_str(), "rb");
|
|
||||||
if (!file) {
|
|
||||||
throw std::runtime_error("Cannot open file: " + filename);
|
|
||||||
}
|
|
||||||
|
|
||||||
OggVorbis_File vf;
|
|
||||||
if (ov_open_callbacks(file, &vf, nullptr, 0, OV_CALLBACKS_DEFAULT) < 0) {
|
|
||||||
fclose(file);
|
|
||||||
throw std::runtime_error("Input not an Ogg file: " + filename);
|
|
||||||
}
|
|
||||||
|
|
||||||
vorbis_info* vi = ov_info(&vf, -1);
|
|
||||||
std::vector<char> audioData;
|
|
||||||
char data[4096];
|
|
||||||
int bitstream;
|
|
||||||
long bytes;
|
|
||||||
|
|
||||||
do {
|
|
||||||
bytes = ov_read(&vf, data, sizeof(data), 0, 2, 1, &bitstream);
|
|
||||||
if (bytes > 0) {
|
|
||||||
audioData.insert(audioData.end(), data, data + bytes);
|
|
||||||
}
|
|
||||||
} while (bytes > 0);
|
|
||||||
|
|
||||||
// Setup the buffer with the audio data
|
|
||||||
alBufferData(buffer,
|
|
||||||
(vi->channels == 1) ? AL_FORMAT_MONO16 : AL_FORMAT_STEREO16,
|
|
||||||
audioData.data(),
|
|
||||||
audioData.size(),
|
|
||||||
vi->rate);
|
|
||||||
|
|
||||||
ov_clear(&vf);
|
|
||||||
return audioData;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool AudioPlayer::playMusic(const std::string& filename) {
|
|
||||||
try {
|
|
||||||
// Если filename пустой, просто проверяем играет ли музыка
|
|
||||||
if (filename.empty()) {
|
|
||||||
if (!isPlaying()) {
|
|
||||||
alSourcei(musicSource, AL_LOOPING, AL_TRUE); // Включаем зацикливание
|
|
||||||
alSourcePlay(musicSource);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Если filename не пустой, загружаем новую музыку
|
|
||||||
if (!isOggFile(filename)) {
|
|
||||||
std::cerr << "❌ Error: Music file must be an .ogg file\n";
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
std::string fullPath = findFileInSounds(filename);
|
|
||||||
std::cout << "✅ Found music file: " << fullPath << "\n";
|
|
||||||
|
|
||||||
// Останавливаем текущую музыку
|
|
||||||
alSourceStop(musicSource);
|
|
||||||
|
|
||||||
// Загружаем и настраиваем новую музыку
|
|
||||||
loadOgg(fullPath, musicBuffer);
|
|
||||||
alSourcei(musicSource, AL_BUFFER, musicBuffer);
|
|
||||||
alSourcei(musicSource, AL_LOOPING, AL_TRUE); // Включаем зацикливание
|
|
||||||
|
|
||||||
std::cout << "▶️ Starting music playback... " << musicSource << std::endl;
|
|
||||||
std::cout << "▶️ Music buffer... " << musicBuffer << std::endl;
|
|
||||||
alSourcePlay(musicSource);
|
|
||||||
|
|
||||||
currentMusic = filename;
|
|
||||||
playing = true;
|
|
||||||
return true;
|
|
||||||
} catch (const std::exception& e) {
|
|
||||||
std::cerr << "❌ Error playing music: " << e.what() << std::endl;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bool AudioPlayer::playSound(const std::string& filename) {
|
|
||||||
try {
|
|
||||||
if (!isOggFile(filename)) {
|
|
||||||
std::cerr << "❌ Error: Sound file must be an .ogg file\n";
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
std::string fullPath = findFileInSounds(filename);
|
|
||||||
std::cout << "✅ Found sound file: " << fullPath << "\n";
|
|
||||||
|
|
||||||
// Загружаем и настраиваем звук
|
|
||||||
loadOgg(fullPath, soundBuffer);
|
|
||||||
alSourcei(soundSource, AL_BUFFER, soundBuffer);
|
|
||||||
alSourcei(soundSource, AL_LOOPING, AL_FALSE); // Выключаем зацикливание
|
|
||||||
|
|
||||||
std::cout << "▶️ Playing sound effect...\n";
|
|
||||||
alSourcePlay(soundSource);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch (const std::exception& e) {
|
|
||||||
std::cerr << "❌ Error playing sound: " << e.what() << std::endl;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void AudioPlayer::stop() {
|
|
||||||
alSourceStop(musicSource);
|
|
||||||
alSourceStop(soundSource);
|
|
||||||
playing = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool AudioPlayer::isPlaying() const {
|
|
||||||
ALint state;
|
|
||||||
alGetSourcei(musicSource, AL_SOURCE_STATE, &state);
|
|
||||||
return state == AL_PLAYING;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
convert_config_meshes.py - Bulk-convert 3D mesh files referenced in a game object JSON config.
|
||||||
|
|
||||||
|
Reads <src_config>, converts every .txt mesh to binary (.txt.bin via BSMF format),
|
||||||
|
and writes the updated config to <dst_config> with meshPath values pointing to the
|
||||||
|
.bin files. Works for both regular game object configs and interactive object configs.
|
||||||
|
|
||||||
|
Mesh paths in the JSON are relative to the current working directory — run this
|
||||||
|
script from the project root.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python convert_config_meshes.py <src_config.json> <dst_config.json>
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from convert_model_to_binary import convert
|
||||||
|
|
||||||
|
|
||||||
|
def convert_config(src_path: str, dst_path: str) -> None:
|
||||||
|
with open(src_path, 'r', encoding='utf-8') as f:
|
||||||
|
config = json.load(f)
|
||||||
|
|
||||||
|
objects = config.get("objects", [])
|
||||||
|
|
||||||
|
# Track already-converted paths so shared meshes are only processed once.
|
||||||
|
cache: dict[str, str | None] = {}
|
||||||
|
|
||||||
|
for obj in objects:
|
||||||
|
mesh_path = obj.get("meshPath")
|
||||||
|
if not mesh_path or not mesh_path.lower().endswith(".txt"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if mesh_path not in cache:
|
||||||
|
if not os.path.isfile(mesh_path):
|
||||||
|
print(f" WARNING: mesh not found, skipping: {mesh_path}")
|
||||||
|
cache[mesh_path] = None
|
||||||
|
else:
|
||||||
|
bin_path = mesh_path + ".bin"
|
||||||
|
convert(mesh_path, bin_path)
|
||||||
|
cache[mesh_path] = bin_path
|
||||||
|
|
||||||
|
if cache[mesh_path] is not None:
|
||||||
|
obj["meshPath"] = cache[mesh_path]
|
||||||
|
|
||||||
|
os.makedirs(os.path.dirname(os.path.abspath(dst_path)), exist_ok=True)
|
||||||
|
with open(dst_path, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(config, f, indent=4, ensure_ascii=False)
|
||||||
|
|
||||||
|
converted_count = sum(1 for v in cache.values() if v is not None)
|
||||||
|
print(f"Saved: {dst_path} ({converted_count} mesh(es) converted, "
|
||||||
|
f"{len(cache) - converted_count} skipped)")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
if len(sys.argv) != 3:
|
||||||
|
print(f"Usage: {sys.argv[0]} <src_config.json> <dst_config.json>")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
convert_config(sys.argv[1], sys.argv[2])
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Convert a text-based static mesh file (.txt) to binary format (.txt.bin).
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python convert_model_to_binary.py <input.txt> [<output.bin>]
|
||||||
|
|
||||||
|
If the output path is not given it is derived by appending ".bin" to the input path,
|
||||||
|
e.g. resources/w/firebox.txt -> resources/w/firebox.txt.bin
|
||||||
|
|
||||||
|
Binary format (BSMF v1) -- all values little-endian:
|
||||||
|
|
||||||
|
HEADER
|
||||||
|
4 bytes magic "BSMF"
|
||||||
|
uint32 version (1)
|
||||||
|
uint32 numVertices
|
||||||
|
uint32 numTriangles
|
||||||
|
|
||||||
|
VERTICES (numVertices entries):
|
||||||
|
3 x float position x, y, z -- engine coordinate space
|
||||||
|
3 x float normal x, y, z -- engine coordinate space
|
||||||
|
2 x float UV u, v
|
||||||
|
|
||||||
|
TRIANGLES (numTriangles entries):
|
||||||
|
3 x uint32 vertex indices i0, i1, i2
|
||||||
|
|
||||||
|
The same Blender->engine axis swap applied by LoadFromTextFile02 is baked in here,
|
||||||
|
so the C++ binary loader can read coordinates directly without any post-processing:
|
||||||
|
engine_x = blender_y
|
||||||
|
engine_y = blender_z
|
||||||
|
engine_z = blender_x
|
||||||
|
"""
|
||||||
|
|
||||||
|
import struct
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_floats(text):
|
||||||
|
return [float(x) for x in re.findall(r'[-+]?\d*\.?\d+(?:[eE][-+]?\d+)?', text)]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_ints(text):
|
||||||
|
return [int(x) for x in re.findall(r'[-]?\d+', text)]
|
||||||
|
|
||||||
|
|
||||||
|
def _swap_axes(x, y, z):
|
||||||
|
"""Blender Y-up -> engine coordinate system (mirrors LoadFromTextFile02)."""
|
||||||
|
return y, z, x
|
||||||
|
|
||||||
|
|
||||||
|
def convert(input_path, output_path):
|
||||||
|
with open(input_path, 'r', encoding='utf-8') as f:
|
||||||
|
lines = [line.rstrip('\n') for line in f]
|
||||||
|
|
||||||
|
idx = 0
|
||||||
|
|
||||||
|
def next_line():
|
||||||
|
nonlocal idx
|
||||||
|
while idx < len(lines):
|
||||||
|
line = lines[idx]
|
||||||
|
idx += 1
|
||||||
|
return line
|
||||||
|
raise EOFError("Unexpected end of file while parsing: " + input_path)
|
||||||
|
|
||||||
|
# --- Vertices ---
|
||||||
|
while True:
|
||||||
|
line = next_line()
|
||||||
|
if '===Vertices' in line:
|
||||||
|
break
|
||||||
|
|
||||||
|
m = re.search(r'\d+', line)
|
||||||
|
if not m:
|
||||||
|
raise ValueError("Could not parse vertex count from: " + line)
|
||||||
|
num_vertices = int(m.group())
|
||||||
|
|
||||||
|
positions = []
|
||||||
|
normals = []
|
||||||
|
uvs = []
|
||||||
|
|
||||||
|
for i in range(num_vertices):
|
||||||
|
line = next_line()
|
||||||
|
# V N: Pos(x, y, z) Norm(nx, ny, nz) UV(u, v)
|
||||||
|
nums = _parse_floats(line)
|
||||||
|
# nums[0] = vertex index (float-parsed), then 3 pos, 3 norm, 2 uv
|
||||||
|
if len(nums) < 9:
|
||||||
|
raise ValueError(f"Malformed vertex line {i}: {line}")
|
||||||
|
px, py, pz = _swap_axes(nums[1], nums[2], nums[3])
|
||||||
|
nx, ny, nz = _swap_axes(nums[4], nums[5], nums[6])
|
||||||
|
positions.append((px, py, pz))
|
||||||
|
normals.append((nx, ny, nz))
|
||||||
|
uvs.append((nums[7], nums[8]))
|
||||||
|
|
||||||
|
# --- Triangles ---
|
||||||
|
while True:
|
||||||
|
line = next_line()
|
||||||
|
if '===Triangles' in line:
|
||||||
|
break
|
||||||
|
|
||||||
|
m = re.search(r'\d+', line)
|
||||||
|
if not m:
|
||||||
|
raise ValueError("Could not parse triangle count from: " + line)
|
||||||
|
num_triangles = int(m.group())
|
||||||
|
|
||||||
|
triangles = []
|
||||||
|
for i in range(num_triangles):
|
||||||
|
line = next_line()
|
||||||
|
ints = _parse_ints(line)
|
||||||
|
if len(ints) != 3:
|
||||||
|
raise ValueError(f"Malformed triangle line {i}: {line}")
|
||||||
|
triangles.append(tuple(ints))
|
||||||
|
|
||||||
|
# --- Write binary ---
|
||||||
|
with open(output_path, 'wb') as out:
|
||||||
|
out.write(b'BSMF')
|
||||||
|
out.write(struct.pack('<I', 1))
|
||||||
|
out.write(struct.pack('<I', num_vertices))
|
||||||
|
out.write(struct.pack('<I', num_triangles))
|
||||||
|
|
||||||
|
for i in range(num_vertices):
|
||||||
|
out.write(struct.pack('<3f', *positions[i]))
|
||||||
|
out.write(struct.pack('<3f', *normals[i]))
|
||||||
|
out.write(struct.pack('<2f', *uvs[i]))
|
||||||
|
|
||||||
|
for tri in triangles:
|
||||||
|
out.write(struct.pack('<3I', *tri))
|
||||||
|
|
||||||
|
in_size = os.path.getsize(input_path)
|
||||||
|
out_size = os.path.getsize(output_path)
|
||||||
|
print(f"Converted: {input_path} ({in_size:,} bytes text) -> {output_path} ({out_size:,} bytes binary)")
|
||||||
|
print(f" Vertices: {num_vertices}, Triangles: {num_triangles}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
if len(sys.argv) < 2 or len(sys.argv) > 3:
|
||||||
|
print(f"Usage: {sys.argv[0]} <input.txt> [<output.bin>]")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
input_path = sys.argv[1]
|
||||||
|
output_path = sys.argv[2] if len(sys.argv) == 3 else input_path + '.bin'
|
||||||
|
convert(input_path, output_path)
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
*,*: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}._app_1ibpl_1{display:flex;width:100%;height:100%;overflow:hidden}._panel_e520h_1{width:200px;min-width:180px;background:#252525;border-right:1px solid #333;display:flex;flex-direction:column;overflow:hidden}._header_e520h_11{padding:10px 12px;font-size:12px;font-weight:600;letter-spacing:.05em;text-transform:uppercase;color:#888;border-bottom:1px solid #333}._actions_e520h_21{display:flex;flex-direction:column;gap:6px;padding:10px;border-bottom:1px solid #333}._btn_e520h_29{background:#333;color:#ddd;border:1px solid #444;border-radius:4px;padding:5px 8px;text-align:center;transition:background .15s}._btn_e520h_29:hover:not(:disabled){background:#3d3d3d}._btn_e520h_29:disabled{opacity:.4;cursor:default}._btnPrimary_e520h_42{background:#2a4a6e;border-color:#3a6090;color:#a8d0f0}._btnPrimary_e520h_42:hover{background:#2e5480}._list_e520h_50{flex:1;overflow-y:auto;padding:4px 0}._empty_e520h_56{padding:16px 12px;color:#555;font-size:11px;line-height:1.5}._item_e520h_63{display:flex;align-items:center;justify-content:space-between;padding:7px 12px;cursor:pointer;-webkit-user-select:none;user-select:none;border-left:3px solid transparent;transition:background .1s}._item_e520h_63:hover{background:#2e2e2e}._selected_e520h_76{background:#1e3a55;border-left-color:#5b9bd5}._itemId_e520h_81{font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}._deleteBtn_e520h_89{background:none;border:none;color:#666;padding:2px 4px;border-radius:3px;font-size:10px;opacity:0;transition:opacity .1s,color .1s}._item_e520h_63:hover ._deleteBtn_e520h_89{opacity:1}._deleteBtn_e520h_89:hover{color:#e06c6c}._panel_1kkcd_1{flex:1;display:flex;flex-direction:column;overflow:hidden;min-width:0}._previewArea_1kkcd_9{flex:0 0 auto;display:flex;justify-content:center;align-items:center;background:#111;padding:8px;max-height:60%}._wrapper_1nucj_1{width:100%;display:flex;justify-content:center;align-items:center;background:#111;padding:8px;flex-shrink:0}._viewport_1nucj_11{position:relative;aspect-ratio:16 / 9;width:100%;max-height:100%;background:#000;overflow:hidden}._empty_1nucj_20{position:absolute;top:0;right:0;bottom:0;left:0;display:flex;align-items:center;justify-content:center;color:#444;font-size:13px}._subtitleBar_1nucj_30{position:absolute;bottom:0;left:0;right:0;padding:10px 20px 14px;background:linear-gradient(transparent,#000000bf);text-align:center}._speaker_1nucj_40{font-size:11px;font-weight:600;color:#f0c060;margin-bottom:4px;text-shadow:0 1px 3px rgba(0,0,0,.8)}._text_1nucj_48{font-size:14px;color:#fff;line-height:1.5;text-shadow:0 1px 4px rgba(0,0,0,.9)}._controls_1qoyb_1{display:flex;align-items:center;gap:4px;padding:6px 10px;background:#1e1e1e;border-top:1px solid #333;border-bottom:1px solid #333;flex-shrink:0}._btn_1qoyb_12{background:#2d2d2d;border:1px solid #404040;color:#ccc;border-radius:4px;padding:4px 10px;font-size:13px;transition:background .1s;min-width:32px}._btn_1qoyb_12:hover:not(:disabled){background:#3a3a3a;color:#fff}._btn_1qoyb_12:disabled{opacity:.35;cursor:default}._active_1qoyb_26{color:#5ba3e0;border-color:#4a7aaa}._time_1qoyb_31{margin-left:10px;font-size:11px;color:#888;font-variant-numeric:tabular-nums}._container_uetgi_1{display:flex;flex-direction:column;flex:1;min-height:0;background:#1a1a1a;border-top:1px solid #333;overflow:hidden}._toolbar_uetgi_11{display:flex;align-items:center;gap:4px;padding:4px 8px;background:#222;border-bottom:1px solid #333;flex-shrink:0}._tbBtn_uetgi_21{background:#2d2d2d;border:1px solid #404040;color:#bbb;border-radius:3px;padding:3px 8px;font-size:11px;transition:background .1s}._tbBtn_uetgi_21:hover:not(:disabled){background:#3a3a3a;color:#fff}._tbBtn_uetgi_21:disabled{opacity:.3;cursor:default}._spacer_uetgi_33{flex:1}._zoomLabel_uetgi_35{font-size:11px;color:#666;margin-right:2px}._scroll_uetgi_37{flex:1;overflow:auto;position:relative;cursor:default}._ruler_uetgi_45{display:flex;height:22px;background:#212121;border-bottom:1px solid #333;position:sticky;top:0;z-index:10;-webkit-user-select:none;user-select:none;cursor:crosshair}._tick_uetgi_57{position:absolute;top:0;bottom:0;width:1px;background:#444}._tickLabel_uetgi_65{position:absolute;top:3px;left:3px;font-size:9px;color:#666;white-space:nowrap}._layersArea_Line truncated
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
(function(){const a=document.createElement("link").relList;if(a&&a.supports&&a.supports("modulepreload"))return;for(const p of document.querySelectorAll('link[rel="modulepreload"]'))f(p);new MutationObserver(p=>{for(const g of p)if(g.type==="childList")for(const S of g.addedNodes)S.tagName==="LINK"&&S.rel==="modulepreload"&&f(S)}).observe(document,{childList:!0,subtree:!0});function s(p){const g={};return p.integrity&&(g.integrity=p.integrity),p.referrerPolicy&&(g.referrerPolicy=p.referrerPolicy),p.crossOrigin==="use-credentials"?g.credentials="include":p.crossOrigin==="anonymous"?g.credentials="omit":g.credentials="same-origin",g}function f(p){if(p.ep)return;p.ep=!0;const g=s(p);fetch(p.href,g)}})();function Bd(u){return u&&u.__esModule&&Object.prototype.hasOwnProperty.call(u,"default")?u.default:u}var bu={exports:{}},Or={},eo={exports:{}},G={};/**
|
||||||
|
* @license React
|
||||||
|
* react.production.min.js
|
||||||
|
*
|
||||||
|
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||||
|
*
|
||||||
|
* This source code is licensed under the MIT license found in the
|
||||||
|
* LICENSE file in the root directory of this source tree.
|
||||||
|
*/var pc;function $d(){if(pc)return G;pc=1;var u=Symbol.for("react.element"),a=Symbol.for("react.portal"),s=Symbol.for("react.fragment"),f=Symbol.for("react.strict_mode"),p=Symbol.for("react.profiler"),g=Symbol.for("react.provider"),S=Symbol.for("react.context"),L=Symbol.for("react.forward_ref"),z=Symbol.for("react.suspense"),U=Symbol.for("react.memo"),ee=Symbol.for("react.lazy"),D=Symbol.iterator;function H(h){return h===null||typeof h!="object"?null:(h=D&&h[D]||h["@@iterator"],typeof h=="function"?h:null)}var fe={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},me=Object.assign,re={};function q(h,k,X){this.props=h,this.context=k,this.refs=re,this.updater=X||fe}q.prototype.isReactComponent={},q.prototype.setState=function(h,k){if(typeof h!="object"&&typeof h!="function"&&h!=null)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,h,k,"setState")},q.prototype.forceUpdate=function(h){this.updater.enqueueForceUpdate(this,h,"forceUpdate")};function ge(){}ge.prototype=q.prototype;function Ge(h,k,X){this.props=h,this.context=k,this.refs=re,this.updater=X||fe}var Be=Ge.prototype=new ge;Be.constructor=Ge,me(Be,q.prototype),Be.isPureReactComponent=!0;var ke=Array.isArray,Ze=Object.prototype.hasOwnProperty,Ee={current:null},Ne={key:!0,ref:!0,__self:!0,__source:!0};function $e(h,k,X){var Y,Z={},b=null,oe=null;if(k!=null)for(Y in k.ref!==void 0&&(oe=k.ref),k.key!==void 0&&(b=""+k.key),k)Ze.call(k,Y)&&!Ne.hasOwnProperty(Y)&&(Z[Y]=k[Y]);var J=arguments.length-2;if(J===1)Z.children=X;else if(1<J){for(var de=Array(J),Je=0;Je<J;Je++)de[Je]=arguments[Je+2];Z.children=de}if(h&&h.defaultProps)for(Y in J=h.defaultProps,J)Z[Y]===void 0&&(Z[Y]=J[Y]);return{$$typeof:u,type:h,key:b,ref:oe,props:Z,_owner:Ee.current}}function rt(h,k){return{$$typeof:u,type:h.type,key:k,ref:h.ref,props:h.props,_owner:h._owner}}function pt(h){return typeof h=="object"&&h!==null&&h.$$typeof===u}function kt(h){var k={"=":"=0",":":"=2"};return"$"+h.replace(/[=:]/g,function(X){return k[X]})}var He=/\/+/g;function De(h,k){return typeof h=="object"&&h!==null&&h.key!=null?kt(""+h.key):k.toString(36)}function I(h,k,X,Y,Z){var b=typeof h;(b==="undefined"||b==="boolean")&&(h=null);var oe=!1;if(h===null)oe=!0;else switch(b){case"string":case"number":oe=!0;break;case"object":switch(h.$$typeof){case u:case a:oe=!0}}if(oe)return oe=h,Z=Z(oe),h=Y===""?"."+De(oe,0):Y,ke(Z)?(X="",h!=null&&(X=h.replace(He,"$&/")+"/"),I(Z,k,X,"",function(Je){return Je})):Z!=null&&(pt(Z)&&(Z=rt(Z,X+(!Z.key||oe&&oe.key===Z.key?"":(""+Z.key).replace(He,"$&/")+"/")+h)),k.push(Z)),1;if(oe=0,Y=Y===""?".":Y+":",ke(h))for(var J=0;J<h.length;J++){b=h[J];var de=Y+De(b,J);oe+=I(b,k,X,de,Z)}else if(de=H(h),typeof de=="function")for(h=de.call(h),J=0;!(b=h.next()).done;)b=b.value,de=Y+De(b,J++),oe+=I(b,k,X,de,Z);else if(b==="object")throw k=String(h),Error("Objects are not valid as a React child (found: "+(k==="[object Object]"?"object with keys {"+Object.keys(h).join(", ")+"}":k)+"). If you meant to render a collection of children, use an array instead.");return oe}function Q(h,k,X){if(h==null)return h;var Y=[],Z=0;return I(h,Y,"","",function(b){return k.call(X,b,Z++)}),Y}function le(h){if(h._status===-1){var k=h._result;k=k(),k.then(function(X){(h._status===0||h._status===-1)&&(h._status=1,h._result=X)},function(X){(h._status===0||h._status===-1)&&(h._status=2,h._result=X)}),h._status===-1&&(h._status=0,h._result=k)}if(h._status===1)return h._result.default;throw h._result}var K={current:null},M={transition:null},A={ReactCurrentDispatcher:K,ReactCurrentBatchConfig:M,ReactCurrentOwner:Ee};function P(){throw Error("act(...) is not supported in production builds of React.")}return G.Children={map:Q,forEach:function(h,k,X){Q(h,function(){k.apply(this,arguments)},X)},count:function(h){var k=0;return Q(h,function(){k++}),k},toArray:function(h){return Q(h,function(k){return k})||[]},only:function(h){if(!pt(h))throw Error("React.Children.only expected to receive a single React element child.");return h}},G.Component=q,G.Fragment=s,G.Profiler=p,G.PureComponent=Ge,G.StrictMode=f,G.Suspense=z,G.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED=A,G.act=P,G.cloneElement=function(h,k,X){if(h==null)throw Error("React.cloneElement(...): The argument must be a React element, but you passed "+h+".");var Y=me({},h.props),Z=h.key,b=h.ref,oe=h._owner;if(k!=null){if(k.ref!==void 0&&(b=k.ref,oe=Ee.current),k.key!==void 0&&(Z=""+k.key),h.type&&h.type.defaultProps)var J=h.type.defaultProps;for(de in k)Ze.call(k,de)&&!Ne.hasOwnProperty(de)&&(Y[de]=k[de]===void 0&&J!==void 0?J[de]:k[de])}var de=arguments.length-2;if(de===1)Y.children=X;else if(1<de){J=Array(de);for(var Je=0;Je<de;Je++)J[Je]=arguments[Je+2];Y.children=J}return{$$typeof:u,type:h.type,key:Z,ref:b,props:Y,_owner:oe}},G.createContext=function(h){return h={$$typeof:S,_currLine truncated
|
||||||
|
* @license React
|
||||||
|
* react-jsx-runtime.production.min.js
|
||||||
|
*
|
||||||
|
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||||
|
*
|
||||||
|
* This source code is licensed under the MIT license found in the
|
||||||
|
* LICENSE file in the root directory of this source tree.
|
||||||
|
*/var hc;function Hd(){if(hc)return Or;hc=1;var u=vo(),a=Symbol.for("react.element"),s=Symbol.for("react.fragment"),f=Object.prototype.hasOwnProperty,p=u.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentOwner,g={key:!0,ref:!0,__self:!0,__source:!0};function S(L,z,U){var ee,D={},H=null,fe=null;U!==void 0&&(H=""+U),z.key!==void 0&&(H=""+z.key),z.ref!==void 0&&(fe=z.ref);for(ee in z)f.call(z,ee)&&!g.hasOwnProperty(ee)&&(D[ee]=z[ee]);if(L&&L.defaultProps)for(ee in z=L.defaultProps,z)D[ee]===void 0&&(D[ee]=z[ee]);return{$$typeof:a,type:L,key:H,ref:fe,props:D,_owner:p.current}}return Or.Fragment=s,Or.jsx=S,Or.jsxs=S,Or}var vc;function Wd(){return vc||(vc=1,bu.exports=Hd()),bu.exports}var y=Wd(),ze=vo();const Zl=Bd(ze);var Jl={},to={exports:{}},Ke={},no={exports:{}},ro={};/**
|
||||||
|
* @license React
|
||||||
|
* scheduler.production.min.js
|
||||||
|
*
|
||||||
|
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||||
|
*
|
||||||
|
* This source code is licensed under the MIT license found in the
|
||||||
|
* LICENSE file in the root directory of this source tree.
|
||||||
|
*/var yc;function Vd(){return yc||(yc=1,(function(u){function a(M,A){var P=M.length;M.push(A);e:for(;0<P;){var h=P-1>>>1,k=M[h];if(0<p(k,A))M[h]=A,M[P]=k,P=h;else break e}}function s(M){return M.length===0?null:M[0]}function f(M){if(M.length===0)return null;var A=M[0],P=M.pop();if(P!==A){M[0]=P;e:for(var h=0,k=M.length,X=k>>>1;h<X;){var Y=2*(h+1)-1,Z=M[Y],b=Y+1,oe=M[b];if(0>p(Z,P))b<k&&0>p(oe,Z)?(M[h]=oe,M[b]=P,h=b):(M[h]=Z,M[Y]=P,h=Y);else if(b<k&&0>p(oe,P))M[h]=oe,M[b]=P,h=b;else break e}}return A}function p(M,A){var P=M.sortIndex-A.sortIndex;return P!==0?P:M.id-A.id}if(typeof performance=="object"&&typeof performance.now=="function"){var g=performance;u.unstable_now=function(){return g.now()}}else{var S=Date,L=S.now();u.unstable_now=function(){return S.now()-L}}var z=[],U=[],ee=1,D=null,H=3,fe=!1,me=!1,re=!1,q=typeof setTimeout=="function"?setTimeout:null,ge=typeof clearTimeout=="function"?clearTimeout:null,Ge=typeof setImmediate<"u"?setImmediate:null;typeof navigator<"u"&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function Be(M){for(var A=s(U);A!==null;){if(A.callback===null)f(U);else if(A.startTime<=M)f(U),A.sortIndex=A.expirationTime,a(z,A);else break;A=s(U)}}function ke(M){if(re=!1,Be(M),!me)if(s(z)!==null)me=!0,le(Ze);else{var A=s(U);A!==null&&K(ke,A.startTime-M)}}function Ze(M,A){me=!1,re&&(re=!1,ge($e),$e=-1),fe=!0;var P=H;try{for(Be(A),D=s(z);D!==null&&(!(D.expirationTime>A)||M&&!kt());){var h=D.callback;if(typeof h=="function"){D.callback=null,H=D.priorityLevel;var k=h(D.expirationTime<=A);A=u.unstable_now(),typeof k=="function"?D.callback=k:D===s(z)&&f(z),Be(A)}else f(z);D=s(z)}if(D!==null)var X=!0;else{var Y=s(U);Y!==null&&K(ke,Y.startTime-A),X=!1}return X}finally{D=null,H=P,fe=!1}}var Ee=!1,Ne=null,$e=-1,rt=5,pt=-1;function kt(){return!(u.unstable_now()-pt<rt)}function He(){if(Ne!==null){var M=u.unstable_now();pt=M;var A=!0;try{A=Ne(!0,M)}finally{A?De():(Ee=!1,Ne=null)}}else Ee=!1}var De;if(typeof Ge=="function")De=function(){Ge(He)};else if(typeof MessageChannel<"u"){var I=new MessageChannel,Q=I.port2;I.port1.onmessage=He,De=function(){Q.postMessage(null)}}else De=function(){q(He,0)};function le(M){Ne=M,Ee||(Ee=!0,De())}function K(M,A){$e=q(function(){M(u.unstable_now())},A)}u.unstable_IdlePriority=5,u.unstable_ImmediatePriority=1,u.unstable_LowPriority=4,u.unstable_NormalPriority=3,u.unstable_Profiling=null,u.unstable_UserBlockingPriority=2,u.unstable_cancelCallback=function(M){M.callback=null},u.unstable_continueExecution=function(){me||fe||(me=!0,le(Ze))},u.unstable_forceFrameRate=function(M){0>M||125<M?console.error("forceFrameRate takes a positive int between 0 and 125, forcing frame rates higher than 125 fps is not supported"):rt=0<M?Math.floor(1e3/M):5},u.unstable_getCurrentPriorityLevel=function(){return H},u.unstable_getFirstCallbackNode=function(){return s(z)},u.unstable_next=function(M){switch(H){case 1:case 2:case 3:var A=3;break;default:A=H}var P=H;H=A;try{return M()}finally{H=P}},u.unstable_pauseExecution=function(){},u.unstable_requestPaint=function(){},u.unstable_runWithPriority=function(M,A){switch(M){case 1:case 2:case 3:case 4:case 5:break;default:M=3}var P=H;H=M;try{return A()}finally{H=P}},u.unstable_scheduleCallback=function(M,A,P){var h=u.unstable_now();switch(typeof P=="object"&&P!==null?(P=P.delay,P=typeof P=="number"&&0<P?h+P:h):P=h,M){case 1:var k=-1;break;case 2:k=250;break;case 5:k=1073741823;break;case 4:k=1e4;break;default:k=5e3}return k=P+k,M={id:ee++,callback:A,priorityLevel:M,startTime:P,expirationTime:k,sortIndex:-1},P>h?(M.sortIndex=P,a(U,M),s(z)===null&&M===s(U)&&(re?(ge($e),$e=-1):re=!0,K(ke,P-h))):(M.sortIndex=k,a(z,M),me||fe||(me=!0,le(Ze))),M},u.unstable_shouldYield=kt,u.unstable_wrapCallback=function(M){var A=H;return function(){var P=H;H=A;try{return M.apply(this,arguments)}finally{H=P}}}})(ro)),ro}var gc;function Qd(){return gc||(gc=1,no.exports=Vd()),no.exports}/**
|
||||||
|
* @license React
|
||||||
|
* react-dom.production.min.js
|
||||||
|
*
|
||||||
|
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||||
|
*
|
||||||
|
* This source code is licensed under the MIT license found in the
|
||||||
|
* LICENSE file in the root directory of this source tree.
|
||||||
|
*/var _c;function Xd(){if(_c)return Ke;_c=1;var u=vo(),a=Qd();function s(e){for(var t="https://reactjs.org/docs/error-decoder.html?invariant="+e,n=1;n<arguments.length;n++)t+="&args[]="+encodeURIComponent(arguments[n]);return"Minified React error #"+e+"; visit "+t+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}var f=new Set,p={};function g(e,t){S(e,t),S(e+"Capture",t)}function S(e,t){for(p[e]=t,e=0;e<t.length;e++)f.add(t[e])}var L=!(typeof window>"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),z=Object.prototype.hasOwnProperty,U=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,ee={},D={};function H(e){return z.call(D,e)?!0:z.call(ee,e)?!1:U.test(e)?D[e]=!0:(ee[e]=!0,!1)}function fe(e,t,n,r){if(n!==null&&n.type===0)return!1;switch(typeof t){case"function":case"symbol":return!0;case"boolean":return r?!1:n!==null?!n.acceptsBooleans:(e=e.toLowerCase().slice(0,5),e!=="data-"&&e!=="aria-");default:return!1}}function me(e,t,n,r){if(t===null||typeof t>"u"||fe(e,t,n,r))return!0;if(r)return!1;if(n!==null)switch(n.type){case 3:return!t;case 4:return t===!1;case 5:return isNaN(t);case 6:return isNaN(t)||1>t}return!1}function re(e,t,n,r,l,i,o){this.acceptsBooleans=t===2||t===3||t===4,this.attributeName=r,this.attributeNamespace=l,this.mustUseProperty=n,this.propertyName=e,this.type=t,this.sanitizeURL=i,this.removeEmptyString=o}var q={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(e){q[e]=new re(e,0,!1,e,null,!1,!1)}),[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var t=e[0];q[t]=new re(t,1,!1,e[1],null,!1,!1)}),["contentEditable","draggable","spellCheck","value"].forEach(function(e){q[e]=new re(e,2,!1,e.toLowerCase(),null,!1,!1)}),["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){q[e]=new re(e,2,!1,e,null,!1,!1)}),"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(e){q[e]=new re(e,3,!1,e.toLowerCase(),null,!1,!1)}),["checked","multiple","muted","selected"].forEach(function(e){q[e]=new re(e,3,!0,e,null,!1,!1)}),["capture","download"].forEach(function(e){q[e]=new re(e,4,!1,e,null,!1,!1)}),["cols","rows","size","span"].forEach(function(e){q[e]=new re(e,6,!1,e,null,!1,!1)}),["rowSpan","start"].forEach(function(e){q[e]=new re(e,5,!1,e.toLowerCase(),null,!1,!1)});var ge=/[\-:]([a-z])/g;function Ge(e){return e[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(e){var t=e.replace(ge,Ge);q[t]=new re(t,1,!1,e,null,!1,!1)}),"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(e){var t=e.replace(ge,Ge);q[t]=new re(t,1,!1,e,"http://www.w3.org/1999/xlink",!1,!1)}),["xml:base","xml:lang","xml:space"].forEach(function(e){var t=e.replace(ge,Ge);q[t]=new re(t,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)}),["tabIndex","crossOrigin"].forEach(function(e){q[e]=new re(e,1,!1,e.toLowerCase(),null,!1,!1)}),q.xlinkHref=new re("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1),["src","href","action","formAction"].forEach(function(e){q[e]=new re(e,1,!1,e.toLowerCase(),null,!0,!0)});function Be(e,t,n,r){var l=q.hasOwnProperty(t)?q[t]:null;(l!==null?l.type!==0:r||!(2<t.length)||t[0]!=="o"&
Line truncated
|
||||||
|
`+h+e}var X=!1;function Y(e,t){if(!e||X)return"";X=!0;var n=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{if(t)if(t=function(){throw Error()},Object.defineProperty(t.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(t,[])}catch(w){var r=w}Reflect.construct(e,[],t)}else{try{t.call()}catch(w){r=w}e.call(t.prototype)}else{try{throw Error()}catch(w){r=w}e()}}catch(w){if(w&&r&&typeof w.stack=="string"){for(var l=w.stack.split(`
|
||||||
|
`),i=r.stack.split(`
|
||||||
|
`),o=l.length-1,c=i.length-1;1<=o&&0<=c&&l[o]!==i[c];)c--;for(;1<=o&&0<=c;o--,c--)if(l[o]!==i[c]){if(o!==1||c!==1)do if(o--,c--,0>c||l[o]!==i[c]){var d=`
|
||||||
|
`+l[o].replace(" at new "," at ");return e.displayName&&d.includes("<anonymous>")&&(d=d.replace("<anonymous>",e.displayName)),d}while(1<=o&&0<=c);break}}}finally{X=!1,Error.prepareStackTrace=n}return(e=e?e.displayName||e.name:"")?k(e):""}function Z(e){switch(e.tag){case 5:return k(e.type);case 16:return k("Lazy");case 13:return k("Suspense");case 19:return k("SuspenseList");case 0:case 2:case 15:return e=Y(e.type,!1),e;case 11:return e=Y(e.type.render,!1),e;case 1:return e=Y(e.type,!0),e;default:return""}}function b(e){if(e==null)return null;if(typeof e=="function")return e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case Ne:return"Fragment";case Ee:return"Portal";case rt:return"Profiler";case $e:return"StrictMode";case De:return"Suspense";case I:return"SuspenseList"}if(typeof e=="object")switch(e.$$typeof){case kt:return(e.displayName||"Context")+".Consumer";case pt:return(e._context.displayName||"Context")+".Provider";case He:var t=e.render;return e=e.displayName,e||(e=t.displayName||t.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case Q:return t=e.displayName||null,t!==null?t:b(e.type)||"Memo";case le:t=e._payload,e=e._init;try{return b(e(t))}catch{}}return null}function oe(e){var t=e.type;switch(e.tag){case 24:return"Cache";case 9:return(t.displayName||"Context")+".Consumer";case 10:return(t._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return e=t.render,e=e.displayName||e.name||"",t.displayName||(e!==""?"ForwardRef("+e+")":"ForwardRef");case 7:return"Fragment";case 5:return t;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return b(t);case 8:return t===$e?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if(typeof t=="function")return t.displayName||t.name||null;if(typeof t=="string")return t}return null}function J(e){switch(typeof e){case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function de(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(t==="checkbox"||t==="radio")}function Je(e){var t=de(e)?"checked":"value",n=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),r=""+e[t];if(!e.hasOwnProperty(t)&&typeof n<"u"&&typeof n.get=="function"&&typeof n.set=="function"){var l=n.get,i=n.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return l.call(this)},set:function(o){r=""+o,i.call(this,o)}}),Object.defineProperty(e,t,{enumerable:n.enumerable}),{getValue:function(){return r},setValue:function(o){r=""+o},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function Br(e){e._valueTracker||(e._valueTracker=Je(e))}function _o(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var n=t.getValue(),r="";return e&&(r=de(e)?e.checked?"true":"false":e.value),e=r,e!==n?(t.setValue(e),!0):!1}function $r(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}function ui(e,t){var n=t.checked;return P({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:n??e._wrapperState.initialChecked})}function wo(e,t){var n=t.defaultValue==null?"":t.defaultValue,r=t.checked!=null?t.checked:t.defaultChecked;n=J(t.value!=null?t.value:n),e._wrapperState={initialChecked:r,initialValue:n,controlled:t.type==="checkbox"||t.type==="radio"?t.checked!=null:t.value!=null}}function So(e,t){t=t.checked,t!=null&&Be(e,"checked",t,!1)}function oi(e,t){So(e,t);var n=J(t.value),r=t.type;if(n!=null)r==="number"?(n===0&&e.value===""||e.value!=n)&&(e.value=""+n):e.value!==""+n&&(e.value=""+n);else if(r==="submit"||r==="reset"){e.removeAttribute("value");return}t.hasOwnProperty("value")?si(e,t.type,n):t.hasOwnProperty("defaultValue")&&si(e,t.type,J(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(e.defaultChecked=!!t.defaultChecked)}function ko(e,t,n){if(t.hasOwnProperty("value")||t.hasOwnProperty("defaultValue")){var r=t.type;if(!(r!=="submit"&&r!=="reset"||t.value!==void 0&&t.value!==null))return;t=""+e._wrapperState.initialValue,n||t===e.value||(e.value=t),e.defaultValue=t}n=e.name,n!==""&&(e.name=""),e.defaultChecked=!!e._wrapperState.initialChecked,n!==""&&(e.name=n)}function si(e,t,n){(t!=="number"||$r(e.ownerDocument)!==e)&&(n==null?e.defaultValue=""+e._wrapperState.initialValue:e.defaultValue!==""+n&&(e.defaultValue=""+n))}var Gn=Array.isArray;function Sn(e,t,n,r){if(e=e.options,t){t={};for(var l=0;l<n.length;l++)t["$"+n[l]]=!0;for(n=0;n<e.length;n++)l=t.hasOwnProperty("$"+e[n].value),e[n].selected!==l&&(e[n].selected=l),l&&r&&(e[n].defaultSelected=!0)}else{for(n=""+J(n),t=null,l=0;l<e.length;l++){if(e[l].value===n){e[l].selected=!0,r&&(e[l].defaultSelected=!0);return}t!==null||e[l].disabled||(t=e[l])}t!==null&&(t.selected=!0)}}function ai(e,t){if(t.dangerouslySetInnerHTML!=
Line truncated
|
||||||
|
`).replace(bf,"")}function al(e,t,n){if(t=zs(t),zs(e)!==t&&n)throw Error(s(425))}function cl(){}var Vi=null,Qi=null;function Xi(e,t){return e==="textarea"||e==="noscript"||typeof t.children=="string"||typeof t.children=="number"||typeof t.dangerouslySetInnerHTML=="object"&&t.dangerouslySetInnerHTML!==null&&t.dangerouslySetInnerHTML.__html!=null}var Yi=typeof setTimeout=="function"?setTimeout:void 0,ed=typeof clearTimeout=="function"?clearTimeout:void 0,Ts=typeof Promise=="function"?Promise:void 0,td=typeof queueMicrotask=="function"?queueMicrotask:typeof Ts<"u"?function(e){return Ts.resolve(null).then(e).catch(nd)}:Yi;function nd(e){setTimeout(function(){throw e})}function Ki(e,t){var n=t,r=0;do{var l=n.nextSibling;if(e.removeChild(n),l&&l.nodeType===8)if(n=l.data,n==="/$"){if(r===0){e.removeChild(l),or(t);return}r--}else n!=="$"&&n!=="$?"&&n!=="$!"||r++;n=l}while(n);or(t)}function Wt(e){for(;e!=null;e=e.nextSibling){var t=e.nodeType;if(t===1||t===3)break;if(t===8){if(t=e.data,t==="$"||t==="$!"||t==="$?")break;if(t==="/$")return null}}return e}function Os(e){e=e.previousSibling;for(var t=0;e;){if(e.nodeType===8){var n=e.data;if(n==="$"||n==="$!"||n==="$?"){if(t===0)return e;t--}else n==="/$"&&t++}e=e.previousSibling}return null}var jn=Math.random().toString(36).slice(2),Ct="__reactFiber$"+jn,gr="__reactProps$"+jn,Pt="__reactContainer$"+jn,Gi="__reactEvents$"+jn,rd="__reactListeners$"+jn,ld="__reactHandles$"+jn;function ln(e){var t=e[Ct];if(t)return t;for(var n=e.parentNode;n;){if(t=n[Pt]||n[Ct]){if(n=t.alternate,t.child!==null||n!==null&&n.child!==null)for(e=Os(e);e!==null;){if(n=e[Ct])return n;e=Os(e)}return t}e=n,n=e.parentNode}return null}function _r(e){return e=e[Ct]||e[Pt],!e||e.tag!==5&&e.tag!==6&&e.tag!==13&&e.tag!==3?null:e}function In(e){if(e.tag===5||e.tag===6)return e.stateNode;throw Error(s(33))}function fl(e){return e[gr]||null}var Zi=[],zn=-1;function Vt(e){return{current:e}}function ce(e){0>zn||(e.current=Zi[zn],Zi[zn]=null,zn--)}function se(e,t){zn++,Zi[zn]=e.current,e.current=t}var Qt={},Te=Vt(Qt),We=Vt(!1),un=Qt;function Tn(e,t){var n=e.type.contextTypes;if(!n)return Qt;var r=e.stateNode;if(r&&r.__reactInternalMemoizedUnmaskedChildContext===t)return r.__reactInternalMemoizedMaskedChildContext;var l={},i;for(i in n)l[i]=t[i];return r&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=l),l}function Ve(e){return e=e.childContextTypes,e!=null}function dl(){ce(We),ce(Te)}function Rs(e,t,n){if(Te.current!==Qt)throw Error(s(168));se(Te,t),se(We,n)}function Fs(e,t,n){var r=e.stateNode;if(t=t.childContextTypes,typeof r.getChildContext!="function")return n;r=r.getChildContext();for(var l in r)if(!(l in t))throw Error(s(108,oe(e)||"Unknown",l));return P({},n,r)}function pl(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||Qt,un=Te.current,se(Te,e),se(We,We.current),!0}function Ds(e,t,n){var r=e.stateNode;if(!r)throw Error(s(169));n?(e=Fs(e,t,un),r.__reactInternalMemoizedMergedChildContext=e,ce(We),ce(Te),se(Te,e)):ce(We),se(We,n)}var jt=null,ml=!1,Ji=!1;function As(e){jt===null?jt=[e]:jt.push(e)}function id(e){ml=!0,As(e)}function Xt(){if(!Ji&&jt!==null){Ji=!0;var e=0,t=ue;try{var n=jt;for(ue=1;e<n.length;e++){var r=n[e];do r=r(!0);while(r!==null)}jt=null,ml=!1}catch(l){throw jt!==null&&(jt=jt.slice(e+1)),Bo(_i,Xt),l}finally{ue=t,Ji=!1}}return null}var On=[],Rn=0,hl=null,vl=0,lt=[],it=0,on=null,It=1,zt="";function sn(e,t){On[Rn++]=vl,On[Rn++]=hl,hl=e,vl=t}function Us(e,t,n){lt[it++]=It,lt[it++]=zt,lt[it++]=on,on=e;var r=It;e=zt;var l=32-mt(r)-1;r&=~(1<<l),n+=1;var i=32-mt(t)+l;if(30<i){var o=l-l%5;i=(r&(1<<o)-1).toString(32),r>>=o,l-=o,It=1<<32-mt(t)+l|n<<l|r,zt=i+e}else It=1<<i|n<<l|r,zt=e}function qi(e){e.return!==null&&(sn(e,1),Us(e,1,0))}function bi(e){for(;e===hl;)hl=On[--Rn],On[Rn]=null,vl=On[--Rn],On[Rn]=null;for(;e===on;)on=lt[--it],lt[it]=null,zt=lt[--it],lt[it]=null,It=lt[--it],lt[it]=null}var be=null,et=null,pe=!1,vt=null;function Bs(e,t){var n=at(5,null,null,0);n.elementType="DELETED",n.stateNode=t,n.return=e,t=e.deletions,t===null?(e.deletions=[n],e.flags|=16):t.push(n)}function $s(e,t){switch(e.tag){case 5:var n=e.type;return t=t.nodeType!==1||n.toLowerCase()!==t.nodeName.toLowerCase()?null:t,t!==null?(e.stateNode=t,be=e,et=Wt(t.firstChild),!0):!1;case 6:return t=e.pendingProps===""||t.nodeType!==3?null:t,t!==null?(e.stateNode=t,be=e,et=null,!0):!1;case 13:return t=t.nodeType!==8?null:t,t!==null?(n=on!==null?{id:It,overflow:zt}:null,e.memoizedState={dehydrated:t,treeContext:n,retryLane:1073741824},n=at(18,null,null,0),n.stateNode=t,n.return=e,e.child=n,be=e,et=null,!0):!1;default:return!1}}function eu(e){return(e.mode&1)!==0&&(e.flags&128)===0}function tu(e){if(pe){var t=et;if(t){var n=t;if(!$s(e,t)){if(eu(e))throw Error(s(418));t=Wt(n.nextSibling);var r=be;t&&$s(e,t)?Bs(r,n):(e.flags=e.flags&-4097|2,pe=!1,be=e)}}else{if(eu(e))throw Error(s(418));e.flags=e.flags&-4097|2,pe=!1,be=e}}}function Hs(e){for(e=e.
Line truncated
|
||||||
|
Error generating stack: `+i.message+`
|
||||||
|
`+i.stack}return{value:e,source:t,stack:l,digest:null}}function xu(e,t,n){return{value:e,source:null,stack:n??null,digest:t??null}}function Cu(e,t){try{console.error(t.value)}catch(n){setTimeout(function(){throw n})}}var md=typeof WeakMap=="function"?WeakMap:Map;function ka(e,t,n){n=Ot(-1,n),n.tag=3,n.payload={element:null};var r=t.value;return n.callback=function(){Fl||(Fl=!0,Uu=r),Cu(e,t)},n}function xa(e,t,n){n=Ot(-1,n),n.tag=3;var r=e.type.getDerivedStateFromError;if(typeof r=="function"){var l=t.value;n.payload=function(){return r(l)},n.callback=function(){Cu(e,t)}}var i=e.stateNode;return i!==null&&typeof i.componentDidCatch=="function"&&(n.callback=function(){Cu(e,t),typeof r!="function"&&(Zt===null?Zt=new Set([this]):Zt.add(this));var o=t.stack;this.componentDidCatch(t.value,{componentStack:o!==null?o:""})}),n}function Ca(e,t,n){var r=e.pingCache;if(r===null){r=e.pingCache=new md;var l=new Set;r.set(t,l)}else l=r.get(t),l===void 0&&(l=new Set,r.set(t,l));l.has(n)||(l.add(n),e=Md.bind(null,e,t,n),t.then(e,e))}function Ea(e){do{var t;if((t=e.tag===13)&&(t=e.memoizedState,t=t!==null?t.dehydrated!==null:!0),t)return e;e=e.return}while(e!==null);return null}function Na(e,t,n,r,l){return(e.mode&1)===0?(e===t?e.flags|=65536:(e.flags|=128,n.flags|=131072,n.flags&=-52805,n.tag===1&&(n.alternate===null?n.tag=17:(t=Ot(-1,1),t.tag=2,Kt(n,t,1))),n.lanes|=1),e):(e.flags|=65536,e.lanes=l,e)}var hd=ke.ReactCurrentOwner,Qe=!1;function Ae(e,t,n,r){t.child=e===null?Xs(t,null,n,r):Dn(t,e.child,n,r)}function La(e,t,n,r,l){n=n.render;var i=t.ref;return Un(t,l),r=hu(e,t,n,r,i,l),n=vu(),e!==null&&!Qe?(t.updateQueue=e.updateQueue,t.flags&=-2053,e.lanes&=~l,Rt(e,t,l)):(pe&&n&&qi(t),t.flags|=1,Ae(e,t,r,l),t.child)}function Ma(e,t,n,r,l){if(e===null){var i=n.type;return typeof i=="function"&&!Xu(i)&&i.defaultProps===void 0&&n.compare===null&&n.defaultProps===void 0?(t.tag=15,t.type=i,Pa(e,t,i,r,l)):(e=Hl(n.type,null,r,t,t.mode,l),e.ref=t.ref,e.return=t,t.child=e)}if(i=e.child,(e.lanes&l)===0){var o=i.memoizedProps;if(n=n.compare,n=n!==null?n:pr,n(o,r)&&e.ref===t.ref)return Rt(e,t,l)}return t.flags|=1,e=en(i,r),e.ref=t.ref,e.return=t,t.child=e}function Pa(e,t,n,r,l){if(e!==null){var i=e.memoizedProps;if(pr(i,r)&&e.ref===t.ref)if(Qe=!1,t.pendingProps=r=i,(e.lanes&l)!==0)(e.flags&131072)!==0&&(Qe=!0);else return t.lanes=e.lanes,Rt(e,t,l)}return Eu(e,t,n,r,l)}function ja(e,t,n){var r=t.pendingProps,l=r.children,i=e!==null?e.memoizedState:null;if(r.mode==="hidden")if((t.mode&1)===0)t.memoizedState={baseLanes:0,cachePool:null,transitions:null},se(Wn,tt),tt|=n;else{if((n&1073741824)===0)return e=i!==null?i.baseLanes|n:n,t.lanes=t.childLanes=1073741824,t.memoizedState={baseLanes:e,cachePool:null,transitions:null},t.updateQueue=null,se(Wn,tt),tt|=e,null;t.memoizedState={baseLanes:0,cachePool:null,transitions:null},r=i!==null?i.baseLanes:n,se(Wn,tt),tt|=r}else i!==null?(r=i.baseLanes|n,t.memoizedState=null):r=n,se(Wn,tt),tt|=r;return Ae(e,t,l,n),t.child}function Ia(e,t){var n=t.ref;(e===null&&n!==null||e!==null&&e.ref!==n)&&(t.flags|=512,t.flags|=2097152)}function Eu(e,t,n,r,l){var i=Ve(n)?un:Te.current;return i=Tn(t,i),Un(t,l),n=hu(e,t,n,r,i,l),r=vu(),e!==null&&!Qe?(t.updateQueue=e.updateQueue,t.flags&=-2053,e.lanes&=~l,Rt(e,t,l)):(pe&&r&&qi(t),t.flags|=1,Ae(e,t,n,l),t.child)}function za(e,t,n,r,l){if(Ve(n)){var i=!0;pl(t)}else i=!1;if(Un(t,l),t.stateNode===null)Il(e,t),wa(t,n,r),ku(t,n,r,l),r=!0;else if(e===null){var o=t.stateNode,c=t.memoizedProps;o.props=c;var d=o.context,w=n.contextType;typeof w=="object"&&w!==null?w=ut(w):(w=Ve(n)?un:Te.current,w=Tn(t,w));var C=n.getDerivedStateFromProps,E=typeof C=="function"||typeof o.getSnapshotBeforeUpdate=="function";E||typeof o.UNSAFE_componentWillReceiveProps!="function"&&typeof o.componentWillReceiveProps!="function"||(c!==r||d!==w)&&Sa(t,o,r,w),Yt=!1;var x=t.memoizedState;o.state=x,kl(t,r,o,l),d=t.memoizedState,c!==r||x!==d||We.current||Yt?(typeof C=="function"&&(Su(t,n,C,r),d=t.memoizedState),(c=Yt||_a(t,n,c,r,x,d,w))?(E||typeof o.UNSAFE_componentWillMount!="function"&&typeof o.componentWillMount!="function"||(typeof o.componentWillMount=="function"&&o.componentWillMount(),typeof o.UNSAFE_componentWillMount=="function"&&o.UNSAFE_componentWillMount()),typeof o.componentDidMount=="function"&&(t.flags|=4194308)):(typeof o.componentDidMount=="function"&&(t.flags|=4194308),t.memoizedProps=r,t.memoizedState=d),o.props=r,o.state=d,o.context=w,r=c):(typeof o.componentDidMount=="function"&&(t.flags|=4194308),r=!1)}else{o=t.stateNode,Ks(e,t),c=t.memoizedProps,w=t.type===t.elementType?c:yt(t.type,c),o.props=w,E=t.pendingProps,x=o.context,d=n.contextType,typeof d=="object"&&d!==null?d=ut(d):(d=Ve(n)?un:Te.current,d=Tn(t,d));var j=n.getDerivedStateFromProps;(C=typeof j=="function"||typeof o.getSnapshotBeforeUpdate=="function")||typeof o.UNSAFE_componentWillReceiveProps!="function"&&typeof o.componentWillReceiveProps!="function"||(c!==E||x!==d)&&Sa(t,o,r,d),Yt=!1,x=t.memoizedState,o.state=x,kl(t,r,o,
Line truncated
|
||||||
Vendored
+13
@@ -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>
|
||||||
@@ -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>
|
||||||
Generated
+1890
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
.app {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export const AVAILABLE_IMAGES: string[] = [
|
||||||
|
'resources/w/cutscenes/cutscene2/cs2_background.png',
|
||||||
|
'resources/w/cutscenes/cutscene2/cs2_books.png',
|
||||||
|
'resources/w/cutscenes/cutscene2/cs2_chair.png',
|
||||||
|
'resources/w/cutscenes/cutscene2/cs2_gg001.png',
|
||||||
|
'resources/w/cutscenes/cutscene2/cs2_gg002.png',
|
||||||
|
'resources/w/cutscenes/cutscene2/cs2_gg003.png',
|
||||||
|
'resources/w/cutscenes/cutscene2/cs2_gg004.png',
|
||||||
|
'resources/w/cutscenes/cutscene3/cs2_foreground.png',
|
||||||
|
'resources/black.png',
|
||||||
|
'resources/w/cutscenes/cutscene_exit_darklands/img.png',
|
||||||
|
'resources/w/cutscenes/cutscene_exit_darklands/img2.png',
|
||||||
|
'resources/w/cutscenes/cutscene3/img.png',
|
||||||
|
'resources/w/white.png',
|
||||||
|
];
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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))`,
|
||||||
|
};
|
||||||
|
}
|
||||||
Vendored
+6
@@ -0,0 +1,6 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module '*.module.css' {
|
||||||
|
const classes: Record<string, string>;
|
||||||
|
export default classes;
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
.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
|
||||||
@@ -0,0 +1,711 @@
|
|||||||
|
{
|
||||||
|
"dialogues": [
|
||||||
|
{
|
||||||
|
"id": "dialog_start001",
|
||||||
|
"start": "line_1",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "line_1",
|
||||||
|
"type": "Line",
|
||||||
|
"speaker": "Бекзат",
|
||||||
|
"portrait": "resources/dialogue/portrait_hero_neutral.png",
|
||||||
|
"text": "Новый день! Я проснулся, позавтракал и готов поехать в универ! Надо проверить телефон, и не забыть взять свою записную книжку.",
|
||||||
|
"next": "end_1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "end_1",
|
||||||
|
"type": "End"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "dialog_phone001",
|
||||||
|
"start": "line_1",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "line_1",
|
||||||
|
"type": "Line",
|
||||||
|
"speaker": "Бекзат",
|
||||||
|
"portrait": "resources/dialogue/portrait_hero_neutral.png",
|
||||||
|
"text": "Я не буду никуда идти без своего телефона и записной книжки!",
|
||||||
|
"next": "end_1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "end_1",
|
||||||
|
"type": "End"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "dialog_chat_parents001",
|
||||||
|
"start": "line_1",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "line_1",
|
||||||
|
"type": "Line",
|
||||||
|
"speaker": "Отец",
|
||||||
|
"portrait": "resources/dialogue/portrait_phone.png",
|
||||||
|
"text": "Бекзат, сынок, мы c мамой тебе отправили немного денег, постарайся прожить на эти деньги до конца недели!",
|
||||||
|
"next": "line_2",
|
||||||
|
"chatBubble": "in"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "line_2",
|
||||||
|
"type": "Line",
|
||||||
|
"speaker": "Бекзат",
|
||||||
|
"portrait": "resources/dialogue/portrait_phone.png",
|
||||||
|
"text": "Спасибо!",
|
||||||
|
"next": "end_1",
|
||||||
|
"chatBubble": "out"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "end_1",
|
||||||
|
"type": "End"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "dialog_chat_news001",
|
||||||
|
"start": "line_1",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "line_1",
|
||||||
|
"type": "Line",
|
||||||
|
"speaker": "Отец",
|
||||||
|
"portrait": "resources/dialogue/portrait_phone.png",
|
||||||
|
"text": "Жители Бишкека все чаще жалуются на депрессию и апатию. Смотрите свежее видео об этом на нашем канале!",
|
||||||
|
"next": "end_1",
|
||||||
|
"chatBubble": "in"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "end_1",
|
||||||
|
"type": "End"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "dialog_chat_aiperi001",
|
||||||
|
"start": "line_1",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "line_1",
|
||||||
|
"type": "Line",
|
||||||
|
"speaker": "Айпери",
|
||||||
|
"portrait": "resources/dialogue/portrait_phone.png",
|
||||||
|
"text": "Бекзат, помнишь мы скидывались на торт для Аиды Джаныбековой? Я тогда еще приносила скатерть, тарелки и нож для торта. И я до сих пор не получила назад ничего.",
|
||||||
|
"next": "line_2",
|
||||||
|
"chatBubble": "in"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "line_2",
|
||||||
|
"type": "Line",
|
||||||
|
"speaker": "Бекзат",
|
||||||
|
"portrait": "resources/dialogue/portrait_phone.png",
|
||||||
|
"text": "Скатерть и тарелки вроде бы лежат в студзоне.",
|
||||||
|
"next": "line_3",
|
||||||
|
"chatBubble": "out"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "line_3",
|
||||||
|
"type": "Line",
|
||||||
|
"speaker": "Айпери",
|
||||||
|
"portrait": "resources/dialogue/portrait_phone.png",
|
||||||
|
"text": "А нож?",
|
||||||
|
"next": "line_4",
|
||||||
|
"chatBubble": "in"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "line_4",
|
||||||
|
"type": "Line",
|
||||||
|
"speaker": "Бекзат",
|
||||||
|
"portrait": "resources/dialogue/portrait_phone.png",
|
||||||
|
"text": "Нож, наверное, так и остался в учительской.",
|
||||||
|
"next": "line_5",
|
||||||
|
"chatBubble": "out"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "line_5",
|
||||||
|
"type": "Line",
|
||||||
|
"speaker": "Айпери",
|
||||||
|
"portrait": "resources/dialogue/portrait_phone.png",
|
||||||
|
"text": "А давай не \"наверное\"?",
|
||||||
|
"next": "line_6",
|
||||||
|
"chatBubble": "in"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "line_6",
|
||||||
|
"type": "Line",
|
||||||
|
"speaker": "Айпери",
|
||||||
|
"portrait": "resources/dialogue/portrait_phone.png",
|
||||||
|
"text": "А давай ты приедешь в универ, зайдешь в учительскую, заберешь нож и отдашь мне?",
|
||||||
|
"next": "line_7",
|
||||||
|
"chatBubble": "in"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "line_7",
|
||||||
|
"type": "Line",
|
||||||
|
"speaker": "Айпери",
|
||||||
|
"portrait": "resources/dialogue/portrait_phone.png",
|
||||||
|
"text": "У вас сегодня как раз Аида ведет лекцию. После лекции попросишь у нее ключи от учительской и заберешь нож.",
|
||||||
|
"next": "line_8",
|
||||||
|
"chatBubble": "in"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "line_8",
|
||||||
|
"type": "Line",
|
||||||
|
"speaker": "Бекзат",
|
||||||
|
"portrait": "resources/dialogue/portrait_phone.png",
|
||||||
|
"text": "Почему ты сама не можешь забрать?",
|
||||||
|
"next": "line_9",
|
||||||
|
"chatBubble": "out"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "line_9",
|
||||||
|
"type": "Line",
|
||||||
|
"speaker": "Айпери",
|
||||||
|
"portrait": "resources/dialogue/portrait_phone.png",
|
||||||
|
"text": "Ты же знаешь, если я встречу Аиду, она 100% даст мне какое-нибудь сложное задание.",
|
||||||
|
"next": "line_10",
|
||||||
|
"chatBubble": "in"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "line_10",
|
||||||
|
"type": "Line",
|
||||||
|
"speaker": "Айпери",
|
||||||
|
"portrait": "resources/dialogue/portrait_phone.png",
|
||||||
|
"text": "И потом, это ты у меня брал нож, с чего я должна ходить искать его по всему универу?",
|
||||||
|
"next": "line_11",
|
||||||
|
"chatBubble": "in"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "line_11",
|
||||||
|
"type": "Line",
|
||||||
|
"speaker": "Айпери",
|
||||||
|
"portrait": "resources/dialogue/portrait_phone.png",
|
||||||
|
"text": "Так что жду тебя в универе! Не вздумай прогулять!",
|
||||||
|
"next": "end_1",
|
||||||
|
"chatBubble": "in",
|
||||||
|
"questUnlock" : "aiperi_knife",
|
||||||
|
"luaCallback" : "on_aiperi_dialog_over",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "end_1",
|
||||||
|
"type": "End"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "dialog_no_sleep001",
|
||||||
|
"start": "line_1",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "line_1",
|
||||||
|
"type": "Line",
|
||||||
|
"speaker": "Бекзат",
|
||||||
|
"portrait": "resources/dialogue/portrait_hero_neutral.png",
|
||||||
|
"text": "Я сейчас не хочу спать.",
|
||||||
|
"next": "end_1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "end_1",
|
||||||
|
"type": "End"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "dialog_phone_pickup001",
|
||||||
|
"start": "line_1",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "line_1",
|
||||||
|
"type": "Line",
|
||||||
|
"speaker": "Бекзат",
|
||||||
|
"portrait": "resources/dialogue/portrait_hero_neutral.png",
|
||||||
|
"text": "Отлично, вот и мой телефон! Надо проверить новые сообщения.",
|
||||||
|
"next": "end_1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "end_1",
|
||||||
|
"type": "End"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "door_bathroom_dialog001",
|
||||||
|
"start": "line_1",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "line_1",
|
||||||
|
"type": "Line",
|
||||||
|
"speaker": "Бекзат",
|
||||||
|
"portrait": "resources/dialogue/portrait_hero_neutral.png",
|
||||||
|
"text": "Здесь у меня душ и туалет.",
|
||||||
|
"next": "end_1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "end_1",
|
||||||
|
"type": "End"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "door_bathroom_alik_dialog001",
|
||||||
|
"start": "line_1",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "line_1",
|
||||||
|
"type": "Line",
|
||||||
|
"speaker": "Бекзат",
|
||||||
|
"portrait": "resources/dialogue/portrait_hero_neutral.png",
|
||||||
|
"text": "Я не буду лезть в ванную комнату к Алику.",
|
||||||
|
"next": "end_1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "end_1",
|
||||||
|
"type": "End"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "door_locked_dialog001",
|
||||||
|
"start": "line_1",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "line_1",
|
||||||
|
"type": "Line",
|
||||||
|
"speaker": "Бекзат",
|
||||||
|
"portrait": "resources/dialogue/portrait_hero_neutral.png",
|
||||||
|
"text": "Дверь закрыта. Кажется, сюда все еще никто не заселился.",
|
||||||
|
"next": "end_1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "end_1",
|
||||||
|
"type": "End"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "dialog_journal_pickup001",
|
||||||
|
"start": "line_1",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "line_1",
|
||||||
|
"type": "Line",
|
||||||
|
"speaker": "Бекзат",
|
||||||
|
"portrait": "resources/dialogue/portrait_hero_neutral.png",
|
||||||
|
"text": "Возьму журнал с собой! Там все мои записи.",
|
||||||
|
"next": "end_1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "end_1",
|
||||||
|
"type": "End"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "dialog_taxi001",
|
||||||
|
"start": "line_1",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "line_1",
|
||||||
|
"type": "Line",
|
||||||
|
"speaker": "Бекзат",
|
||||||
|
"portrait": "resources/dialogue/portrait_hero_neutral.png",
|
||||||
|
"text": "Прежде чем выходить наружу, я должен заказать такси до универа.",
|
||||||
|
"next": "end_1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "end_1",
|
||||||
|
"type": "End"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "dialog_taxi002",
|
||||||
|
"start": "line_1",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "line_1",
|
||||||
|
"type": "Line",
|
||||||
|
"speaker": "Бекзат",
|
||||||
|
"portrait": "resources/dialogue/portrait_hero_neutral.png",
|
||||||
|
"text": "Я заказал такси до универа, машина уже ждет!",
|
||||||
|
"next": "end_1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "end_1",
|
||||||
|
"type": "End"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "dialog_taxi004",
|
||||||
|
"start": "line_1",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "line_1",
|
||||||
|
"type": "Line",
|
||||||
|
"speaker": "Бекзат",
|
||||||
|
"portrait": "resources/dialogue/portrait_hero_neutral.png",
|
||||||
|
"text": "Я уже заказал такси, машина уже ждет!",
|
||||||
|
"next": "end_1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "end_1",
|
||||||
|
"type": "End"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "dialog_second_floor001",
|
||||||
|
"start": "line_1",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "line_1",
|
||||||
|
"type": "Line",
|
||||||
|
"speaker": "Бекзат",
|
||||||
|
"portrait": "resources/dialogue/portrait_hero_neutral.png",
|
||||||
|
"text": "На втором этаже женское общежитие, мне там делать нечего.",
|
||||||
|
"next": "end_1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "end_1",
|
||||||
|
"type": "End"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "dialog_female_student001",
|
||||||
|
"start": "line_1",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "line_1",
|
||||||
|
"type": "Line",
|
||||||
|
"speaker": "Бермет",
|
||||||
|
"portrait": "resources/dialogue/portrait_student_girl.png",
|
||||||
|
"text": "Бекзат отстань!",
|
||||||
|
"next": "end_1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "end_1",
|
||||||
|
"type": "End"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "dialog_female_student002",
|
||||||
|
"start": "line_1",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "line_1",
|
||||||
|
"type": "Line",
|
||||||
|
"speaker": "Алтынай",
|
||||||
|
"portrait": "resources/dialogue/portrait_student_girl.png",
|
||||||
|
"text": "Бекзат ты почему на пары не ходишь?!",
|
||||||
|
"next": "end_1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "end_1",
|
||||||
|
"type": "End"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "dialog_alik001",
|
||||||
|
"start": "line_1",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "line_1",
|
||||||
|
"type": "Line",
|
||||||
|
"speaker": "Алик",
|
||||||
|
"portrait": "resources/dialogue/portrait_student_boy.png",
|
||||||
|
"text": "Привет Бекзат! Давно я не видел тебя на парах!",
|
||||||
|
"next": "end_1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "end_1",
|
||||||
|
"type": "End"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "door_alik_dialog001",
|
||||||
|
"start": "line_1",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "line_1",
|
||||||
|
"type": "Line",
|
||||||
|
"speaker": "Бекзат",
|
||||||
|
"portrait": "resources/dialogue/portrait_hero_neutral.png",
|
||||||
|
"text": "Тук тук!",
|
||||||
|
"next": "line_2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "line_2",
|
||||||
|
"type": "Line",
|
||||||
|
"speaker": "Алик",
|
||||||
|
"portrait": "resources/dialogue/portrait_student_boy.png",
|
||||||
|
"text": "Заходи!",
|
||||||
|
"luaCallback" : "on_alik_room_enter",
|
||||||
|
"next": "end_1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "end_1",
|
||||||
|
"type": "End"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "dialog_alik002",
|
||||||
|
"start": "line_1",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "line_1",
|
||||||
|
"type": "Line",
|
||||||
|
"speaker": "Алик",
|
||||||
|
"portrait": "resources/dialogue/portrait_student_boy.png",
|
||||||
|
"text": "Привет Бекзат!",
|
||||||
|
"next": "line_2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "line_2",
|
||||||
|
"type": "Line",
|
||||||
|
"speaker": "Бекзат",
|
||||||
|
"portrait": "resources/dialogue/portrait_hero_neutral.png",
|
||||||
|
"text": "Привет Алик! Разговор есть.",
|
||||||
|
"next": "line_3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "line_3",
|
||||||
|
"type": "Line",
|
||||||
|
"speaker": "Бекзат",
|
||||||
|
"portrait": "resources/dialogue/portrait_hero_neutral.png",
|
||||||
|
"text": "С тобой на курсе училась Бегимай, ты ее помнишь?",
|
||||||
|
"next": "line_4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "line_4",
|
||||||
|
"type": "Line",
|
||||||
|
"speaker": "Алик",
|
||||||
|
"portrait": "resources/dialogue/portrait_student_boy.png",
|
||||||
|
"text": "Конечно помню! Я тебе даже больше расскажу.",
|
||||||
|
"next": "line_5"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "line_5",
|
||||||
|
"type": "Line",
|
||||||
|
"speaker": "Алик",
|
||||||
|
"portrait": "resources/dialogue/portrait_student_boy.png",
|
||||||
|
"text": "В тот день она принесла свою курсовую, чтобы сдать.",
|
||||||
|
"next": "line_6"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "line_6",
|
||||||
|
"type": "Line",
|
||||||
|
"speaker": "Алик",
|
||||||
|
"portrait": "resources/dialogue/portrait_student_boy.png",
|
||||||
|
"text": "Но в тот день в учительской происходила генеральная уборка.",
|
||||||
|
"next": "line_7"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "line_7",
|
||||||
|
"type": "Line",
|
||||||
|
"speaker": "Алик",
|
||||||
|
"portrait": "resources/dialogue/portrait_student_boy.png",
|
||||||
|
"text": "И получилось так, что ее курсовая оказалась в стопке бумаг на выброс.",
|
||||||
|
"next": "line_8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "line_8",
|
||||||
|
"type": "Line",
|
||||||
|
"speaker": "Алик",
|
||||||
|
"portrait": "resources/dialogue/portrait_student_boy.png",
|
||||||
|
"text": "Курсовая работа пропала, Бегимай получила за нее ноль баллов, и не прошла отбор в Германию.",
|
||||||
|
"next": "line_9"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "line_9",
|
||||||
|
"type": "Line",
|
||||||
|
"speaker": "Алик",
|
||||||
|
"portrait": "resources/dialogue/portrait_student_boy.png",
|
||||||
|
"text": "Поэтому с горя она выпрыгнула из окна лекционного зала и убилась.",
|
||||||
|
"next": "line_10"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "line_10",
|
||||||
|
"type": "Line",
|
||||||
|
"speaker": "Бекзат",
|
||||||
|
"portrait": "resources/dialogue/portrait_hero_neutral.png",
|
||||||
|
"text": "А ты откуда все это знаешь?",
|
||||||
|
"next": "line_11"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "line_11",
|
||||||
|
"type": "Line",
|
||||||
|
"speaker": "Алик",
|
||||||
|
"portrait": "resources/dialogue/portrait_student_boy.png",
|
||||||
|
"text": "Я видел как ее курсовую уносили вместе с другой макулатурой из учительской.",
|
||||||
|
"next": "line_12"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "line_12",
|
||||||
|
"type": "Line",
|
||||||
|
"speaker": "Бекзат",
|
||||||
|
"portrait": "resources/dialogue/portrait_hero_neutral.png",
|
||||||
|
"text": "И где сейчас ее курсовая работа?",
|
||||||
|
"next": "line_13"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "line_13",
|
||||||
|
"type": "Line",
|
||||||
|
"speaker": "Алик",
|
||||||
|
"portrait": "resources/dialogue/portrait_student_boy.png",
|
||||||
|
"text": "За зданием универа есть контейнер с кучей бумажного мусора и макулатурой.",
|
||||||
|
"next": "line_14"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "line_14",
|
||||||
|
"type": "Line",
|
||||||
|
"speaker": "Алик",
|
||||||
|
"portrait": "resources/dialogue/portrait_student_boy.png",
|
||||||
|
"text": "Скорее всего, курсовая до сих пор лежит где-то там.",
|
||||||
|
"next": "line_15"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "line_15",
|
||||||
|
"type": "Line",
|
||||||
|
"speaker": "Бекзат",
|
||||||
|
"portrait": "resources/dialogue/portrait_hero_neutral.png",
|
||||||
|
"text": "Спасибо Алик! Ты мне очень помог.",
|
||||||
|
"next": "line_16"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "line_16",
|
||||||
|
"type": "Line",
|
||||||
|
"speaker": "Алик",
|
||||||
|
"portrait": "resources/dialogue/portrait_student_boy.png",
|
||||||
|
"text": "Да без проблем! Обращайся если что.",
|
||||||
|
"objectiveComplete" : "ghost_lore.ghost_lore_alik",
|
||||||
|
"objectiveVisible": "ghost_lore.ghost_lore_alik",
|
||||||
|
"questUnlock": "ghost_coursework",
|
||||||
|
"next": "end_1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "end_1",
|
||||||
|
"type": "End"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "dialog_alik003",
|
||||||
|
"start": "line_1",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "line_1",
|
||||||
|
"type": "Line",
|
||||||
|
"speaker": "Алик",
|
||||||
|
"portrait": "resources/dialogue/portrait_student_boy.png",
|
||||||
|
"text": "Привет Бекзат! Надеюсь ты нашел то что ищешь.",
|
||||||
|
"next": "end_1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "end_1",
|
||||||
|
"type": "End"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "dialog_video001",
|
||||||
|
"start": "line_1",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "line_1",
|
||||||
|
"type": "Line",
|
||||||
|
"speaker": "Бекзат",
|
||||||
|
"portrait": "resources/dialogue/portrait_hero_neutral.png",
|
||||||
|
"text": "Ого, пока я залипал в телефоне, уже наступила ночь!",
|
||||||
|
"next": "end_1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "end_1",
|
||||||
|
"type": "End"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "dialog_video002",
|
||||||
|
"start": "line_1",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "line_1",
|
||||||
|
"type": "Line",
|
||||||
|
"speaker": "Бекзат",
|
||||||
|
"portrait": "resources/dialogue/portrait_hero_neutral.png",
|
||||||
|
"text": "Я не буду сейчас смотреть видеоролики, давай лучше пойдем спать.",
|
||||||
|
"next": "end_1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "end_1",
|
||||||
|
"type": "End"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "dialog_video003",
|
||||||
|
"start": "line_1",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "line_1",
|
||||||
|
"type": "Line",
|
||||||
|
"speaker": "Бекзат",
|
||||||
|
"portrait": "resources/dialogue/portrait_hero_neutral.png",
|
||||||
|
"text": "Мне некогда деградировать, мне нужно сегодня 100% быть на лекции!",
|
||||||
|
"next": "end_1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "end_1",
|
||||||
|
"type": "End"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"cutscenes": [{
|
||||||
|
"id": "sleep_cutscene001",
|
||||||
|
"background": "resources/test_cutscene001.png",
|
||||||
|
"onFadeInCallback": "on_sleep_cutscene",
|
||||||
|
"durationMs": 5000,
|
||||||
|
"fadeOutMs": 500,
|
||||||
|
"fadeInMs": 500,
|
||||||
|
"endFadeOutMs": 500,
|
||||||
|
"endFadeInMs": 500,
|
||||||
|
"cameraTrack": [
|
||||||
|
{
|
||||||
|
"durationMs": 3000,
|
||||||
|
"from": { "focusX": 0.3, "focusY": 0.50, "zoom": 1.10, "rotationDeg": 0.0 },
|
||||||
|
"to": { "focusX": 0.7, "focusY": 0.50, "zoom": 1.00, "rotationDeg": 0.0 },
|
||||||
|
"easing": "EaseInOutSine"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"durationMs": 3000,
|
||||||
|
"from": { "focusX": 0.3, "focusY": 0.50, "zoom": 1.0, "rotationDeg": 0.0 },
|
||||||
|
"to": { "focusX": 0.7, "focusY": 0.50, "zoom": 1.1, "rotationDeg": 0.0 },
|
||||||
|
"easing": "EaseInOutCubic"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"lines": [
|
||||||
|
{
|
||||||
|
"speaker": "Бекзат",
|
||||||
|
"portrait": "resources/dialogue/portrait_hero_neutral.png",
|
||||||
|
"text": "Я завалился спать и уснул.",
|
||||||
|
"durationMs": 3000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"speaker": "Бекзат",
|
||||||
|
"portrait": "resources/dialogue/portrait_hero_neutral.png",
|
||||||
|
"text": "И я проспал аж до обеда.",
|
||||||
|
"durationMs": 3000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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>Dialogue Editor</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+2157
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "dialogue-editor",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@dagrejs/dagre": "^1.1.4",
|
||||||
|
"@xyflow/react": "^12.3.6",
|
||||||
|
"immer": "^10.1.1",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"zustand": "^5.0.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/dagre": "^0.7.52",
|
||||||
|
"@types/react": "^18.3.12",
|
||||||
|
"@types/react-dom": "^18.3.1",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"typescript": "^5.7.2",
|
||||||
|
"vite": "^6.0.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
"id": "line_1",
|
"id": "line_1",
|
||||||
"type": "Line",
|
"type": "Line",
|
||||||
"speaker": "Студент",
|
"speaker": "Студент",
|
||||||
"portrait": "resources/w/avatar_unknown.png",
|
"portrait": "resources/w/avatar_student.png",
|
||||||
"text": "В университете завелись призраки, мне страшно ходить на занятия.",
|
"text": "В университете завелись призраки, мне страшно ходить на занятия.",
|
||||||
"next": "line_2"
|
"next": "line_2"
|
||||||
},
|
},
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
"id": "line_3",
|
"id": "line_3",
|
||||||
"type": "Line",
|
"type": "Line",
|
||||||
"speaker": "Студент",
|
"speaker": "Студент",
|
||||||
"portrait": "resources/w/avatar_unknown.png",
|
"portrait": "resources/w/avatar_student.png",
|
||||||
"text": "Спроси у Мухтара байке, он все знает.",
|
"text": "Спроси у Мухтара байке, он все знает.",
|
||||||
"next": "line_4"
|
"next": "line_4"
|
||||||
},
|
},
|
||||||
@@ -100,7 +100,7 @@
|
|||||||
"id": "line_1",
|
"id": "line_1",
|
||||||
"type": "Line",
|
"type": "Line",
|
||||||
"speaker": "Студентка",
|
"speaker": "Студентка",
|
||||||
"portrait": "resources/w/avatar_unknown.png",
|
"portrait": "resources/w/avatar_girl.png",
|
||||||
"text": "С этими призраками совсем невозможно ходить на лекции!",
|
"text": "С этими призраками совсем невозможно ходить на лекции!",
|
||||||
"next": "end_1"
|
"next": "end_1"
|
||||||
},
|
},
|
||||||
@@ -160,7 +160,7 @@
|
|||||||
"id": "line_1",
|
"id": "line_1",
|
||||||
"type": "Line",
|
"type": "Line",
|
||||||
"speaker": "Беспокойный Призрак",
|
"speaker": "Беспокойный Призрак",
|
||||||
"portrait": "resources/ghost_avatar.png",
|
"portrait": "resources/w/avatar_ghost.png",
|
||||||
"text": "Нечасто я вижу смертных, готовых разговаривать со мной.",
|
"text": "Нечасто я вижу смертных, готовых разговаривать со мной.",
|
||||||
"next": "choice_1"
|
"next": "choice_1"
|
||||||
},
|
},
|
||||||
@@ -189,7 +189,7 @@
|
|||||||
"id": "line_goods",
|
"id": "line_goods",
|
||||||
"type": "Line",
|
"type": "Line",
|
||||||
"speaker": "Беспокойный Призрак",
|
"speaker": "Беспокойный Призрак",
|
||||||
"portrait": "resources/ghost_avatar.png",
|
"portrait": "resources/w/avatar_ghost.png",
|
||||||
"text": "Это моя месть студентам за то что они призвали меня.",
|
"text": "Это моя месть студентам за то что они призвали меня.",
|
||||||
"next": "end_1"
|
"next": "end_1"
|
||||||
},
|
},
|
||||||
@@ -197,7 +197,7 @@
|
|||||||
"id": "line_who",
|
"id": "line_who",
|
||||||
"type": "Line",
|
"type": "Line",
|
||||||
"speaker": "Беспокойный Призрак",
|
"speaker": "Беспокойный Призрак",
|
||||||
"portrait": "resources/ghost_avatar.png",
|
"portrait": "resources/w/avatar_ghost.png",
|
||||||
"text": "Группа студентов совершила ритуал и призвала меня сюда. Пока проклятие не спадет, я всегда буду здесь обитать.",
|
"text": "Группа студентов совершила ритуал и призвала меня сюда. Пока проклятие не спадет, я всегда буду здесь обитать.",
|
||||||
"next": "choice_1"
|
"next": "choice_1"
|
||||||
},
|
},
|
||||||
@@ -313,6 +313,32 @@
|
|||||||
"type": "End"
|
"type": "End"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "dialog_aida",
|
||||||
|
"start": "line_1",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "line_1",
|
||||||
|
"type": "Line",
|
||||||
|
"speaker": "Асель Дженибековна",
|
||||||
|
"portrait": "resources/w/avatar_teacher.png",
|
||||||
|
"text": "Молодой человек, у меня обед! Я принимаю лабораторные работы только после двух!",
|
||||||
|
"next": "line_2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "line_2",
|
||||||
|
"type": "Line",
|
||||||
|
"speaker": "Hero",
|
||||||
|
"portrait": "resources/w/gg/gg2_s_podsvetkoy5.png",
|
||||||
|
"text": "Хорошо, Асель Дженибековна.",
|
||||||
|
"next": "end_1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "end_1",
|
||||||
|
"type": "End"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"cutscenes": [
|
"cutscenes": [
|
||||||
@@ -349,7 +375,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"speaker": "Ghost",
|
"speaker": "Ghost",
|
||||||
"portrait": "resources/ghost_avatar.png",
|
"portrait": "resources/w/avatar_ghost.png",
|
||||||
"text": "Some memories never fade.",
|
"text": "Some memories never fade.",
|
||||||
"durationMs": 2600,
|
"durationMs": 2600,
|
||||||
"background": "resources/loading.png"
|
"background": "resources/loading.png"
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
.app {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { LeftPanel } from './components/LeftPanel/LeftPanel';
|
||||||
|
import { GraphPanel } from './components/GraphPanel/GraphPanel';
|
||||||
|
import { RightPanel } from './components/RightPanel/RightPanel';
|
||||||
|
import { PlayModeOverlay } from './components/PlayMode/PlayModeOverlay';
|
||||||
|
import { useDialogueStore } from './store/dialogueStore';
|
||||||
|
import styles from './App.module.css';
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const playModeActive = useDialogueStore(s => s.playModeActive);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.app}>
|
||||||
|
<LeftPanel />
|
||||||
|
<GraphPanel />
|
||||||
|
<RightPanel />
|
||||||
|
{playModeActive && <PlayModeOverlay />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
.container {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #1e1e2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #1e1e2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyMsg {
|
||||||
|
color: #6c7086;
|
||||||
|
font-size: 14px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { useCallback, useEffect, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
ReactFlow,
|
||||||
|
Background,
|
||||||
|
Controls,
|
||||||
|
MiniMap,
|
||||||
|
NodeChange,
|
||||||
|
Node,
|
||||||
|
Edge,
|
||||||
|
Connection,
|
||||||
|
MarkerType,
|
||||||
|
} from '@xyflow/react';
|
||||||
|
import { useDialogueStore } from '../../store/dialogueStore';
|
||||||
|
import { nodeTypes } from '../nodes/nodeTypes';
|
||||||
|
import { DialogueNode, ChoiceNode } from '../../types/dialogue';
|
||||||
|
import { GraphToolbar } from './GraphToolbar';
|
||||||
|
import styles from './GraphPanel.module.css';
|
||||||
|
|
||||||
|
function buildEdges(nodes: DialogueNode[]): Edge[] {
|
||||||
|
const edges: Edge[] = [];
|
||||||
|
for (const node of nodes) {
|
||||||
|
const base = {
|
||||||
|
markerEnd: { type: MarkerType.ArrowClosed, color: '#6c7086' },
|
||||||
|
style: { stroke: '#6c7086', strokeWidth: 1.5 },
|
||||||
|
animated: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (node.type === 'Line' || node.type === 'SetFlag' || node.type === 'CutsceneStart') {
|
||||||
|
if (node.next) {
|
||||||
|
edges.push({ ...base, id: `${node.id}->source`, source: node.id, target: node.next, sourceHandle: 'source', targetHandle: 'target' });
|
||||||
|
}
|
||||||
|
} else if (node.type === 'Choice') {
|
||||||
|
for (const choice of node.choices) {
|
||||||
|
if (choice.next) {
|
||||||
|
edges.push({ ...base, id: `${node.id}->${choice.id}`, source: node.id, target: choice.next, sourceHandle: choice.id, targetHandle: 'target', label: choice.text.slice(0, 20), labelStyle: { fontSize: 10, fill: '#cdd6f4' }, labelBgStyle: { fill: '#181825' } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (node.type === 'Condition') {
|
||||||
|
if (node.trueNext) {
|
||||||
|
edges.push({ ...base, id: `${node.id}->true`, source: node.id, target: node.trueNext, sourceHandle: 'true', targetHandle: 'target', label: 'TRUE', labelStyle: { fontSize: 10, fill: '#a6e3a1' }, labelBgStyle: { fill: '#181825' }, style: { ...base.style, stroke: '#a6e3a1' }, markerEnd: { type: MarkerType.ArrowClosed, color: '#a6e3a1' } });
|
||||||
|
}
|
||||||
|
if (node.falseNext) {
|
||||||
|
edges.push({ ...base, id: `${node.id}->false`, source: node.id, target: node.falseNext, sourceHandle: 'false', targetHandle: 'target', label: 'FALSE', labelStyle: { fontSize: 10, fill: '#f38ba8' }, labelBgStyle: { fill: '#181825' }, style: { ...base.style, stroke: '#f38ba8' }, markerEnd: { type: MarkerType.ArrowClosed, color: '#f38ba8' } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return edges;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GraphPanel() {
|
||||||
|
const {
|
||||||
|
file,
|
||||||
|
selectedDialogueId,
|
||||||
|
selectedNodeId,
|
||||||
|
positions,
|
||||||
|
selectNode,
|
||||||
|
setNodePosition,
|
||||||
|
applyAutoLayout,
|
||||||
|
updateNode,
|
||||||
|
} = useDialogueStore();
|
||||||
|
|
||||||
|
const dialogue = file?.dialogues.find(d => d.id === selectedDialogueId);
|
||||||
|
const dialoguePositions = positions[selectedDialogueId ?? ''] ?? {};
|
||||||
|
|
||||||
|
const rfNodes: Node[] = useMemo(() => {
|
||||||
|
if (!dialogue) return [];
|
||||||
|
|
||||||
|
return dialogue.nodes.map(node => ({
|
||||||
|
id: node.id,
|
||||||
|
type: node.type,
|
||||||
|
data: node as unknown as Record<string, unknown>,
|
||||||
|
position: dialoguePositions[node.id] ?? { x: 0, y: 0 },
|
||||||
|
selected: node.id === selectedNodeId,
|
||||||
|
}));
|
||||||
|
}, [dialogue, dialoguePositions, selectedNodeId]);
|
||||||
|
|
||||||
|
const rfEdges: Edge[] = useMemo(() => {
|
||||||
|
if (!dialogue) return [];
|
||||||
|
return buildEdges(dialogue.nodes);
|
||||||
|
}, [dialogue]);
|
||||||
|
|
||||||
|
const onNodesChange = useCallback((changes: NodeChange[]) => {
|
||||||
|
if (!selectedDialogueId) return;
|
||||||
|
for (const change of changes) {
|
||||||
|
if (change.type === 'position' && change.position) {
|
||||||
|
setNodePosition(selectedDialogueId, change.id, change.position);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [selectedDialogueId, setNodePosition]);
|
||||||
|
|
||||||
|
const onNodeClick = useCallback((_: React.MouseEvent, node: Node) => {
|
||||||
|
selectNode(node.id);
|
||||||
|
}, [selectNode]);
|
||||||
|
|
||||||
|
const onPaneClick = useCallback(() => {
|
||||||
|
selectNode(null);
|
||||||
|
}, [selectNode]);
|
||||||
|
|
||||||
|
const onConnect = useCallback((connection: Connection) => {
|
||||||
|
if (!selectedDialogueId || !connection.source || !connection.target) return;
|
||||||
|
const dialogue = file?.dialogues.find(d => d.id === selectedDialogueId);
|
||||||
|
if (!dialogue) return;
|
||||||
|
const sourceNode = dialogue.nodes.find(n => n.id === connection.source);
|
||||||
|
if (!sourceNode) return;
|
||||||
|
|
||||||
|
const handle = connection.sourceHandle;
|
||||||
|
|
||||||
|
if (sourceNode.type === 'Line' || sourceNode.type === 'SetFlag' || sourceNode.type === 'CutsceneStart') {
|
||||||
|
updateNode(selectedDialogueId, sourceNode.id, { next: connection.target } as Partial<DialogueNode>);
|
||||||
|
} else if (sourceNode.type === 'Condition') {
|
||||||
|
if (handle === 'true') {
|
||||||
|
updateNode(selectedDialogueId, sourceNode.id, { trueNext: connection.target } as Partial<DialogueNode>);
|
||||||
|
} else if (handle === 'false') {
|
||||||
|
updateNode(selectedDialogueId, sourceNode.id, { falseNext: connection.target } as Partial<DialogueNode>);
|
||||||
|
}
|
||||||
|
} else if (sourceNode.type === 'Choice') {
|
||||||
|
const choices = (sourceNode as ChoiceNode).choices.map(c =>
|
||||||
|
c.id === handle ? { ...c, next: connection.target! } : c
|
||||||
|
);
|
||||||
|
updateNode(selectedDialogueId, sourceNode.id, { choices } as Partial<DialogueNode>);
|
||||||
|
}
|
||||||
|
}, [selectedDialogueId, file, updateNode]);
|
||||||
|
|
||||||
|
// Trigger auto-layout when dialogue is first loaded with no positions
|
||||||
|
useEffect(() => {
|
||||||
|
if (dialogue && dialogue.nodes.length > 0 && !Object.keys(dialoguePositions).length && selectedDialogueId) {
|
||||||
|
applyAutoLayout(selectedDialogueId);
|
||||||
|
}
|
||||||
|
}, [selectedDialogueId]);
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return (
|
||||||
|
<div className={styles.empty}>
|
||||||
|
<div className={styles.emptyMsg}>Load a dialogue JSON file to get started</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dialogue) {
|
||||||
|
return (
|
||||||
|
<div className={styles.empty}>
|
||||||
|
<div className={styles.emptyMsg}>Select a dialogue from the left panel</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<GraphToolbar />
|
||||||
|
<div className={styles.flow}>
|
||||||
|
<ReactFlow
|
||||||
|
key={selectedDialogueId}
|
||||||
|
nodes={rfNodes}
|
||||||
|
edges={rfEdges}
|
||||||
|
nodeTypes={nodeTypes}
|
||||||
|
onNodesChange={onNodesChange}
|
||||||
|
onConnect={onConnect}
|
||||||
|
onNodeClick={onNodeClick}
|
||||||
|
onPaneClick={onPaneClick}
|
||||||
|
fitView
|
||||||
|
fitViewOptions={{ padding: 0.2 }}
|
||||||
|
deleteKeyCode={null}
|
||||||
|
proOptions={{ hideAttribution: true }}
|
||||||
|
>
|
||||||
|
<Background color="#313244" gap={20} />
|
||||||
|
<Controls />
|
||||||
|
<MiniMap
|
||||||
|
nodeColor={(n) => {
|
||||||
|
switch (n.type) {
|
||||||
|
case 'Line': return '#1e66f5';
|
||||||
|
case 'Choice': return '#df8e1d';
|
||||||
|
case 'Condition': return '#8839ef';
|
||||||
|
case 'SetFlag': return '#179299';
|
||||||
|
case 'CutsceneStart': return '#4a4a5a';
|
||||||
|
case 'End': return '#f38ba8';
|
||||||
|
default: return '#6c7086';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{ background: '#181825', border: '1px solid #313244' }}
|
||||||
|
/>
|
||||||
|
</ReactFlow>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: #181825;
|
||||||
|
border-bottom: 1px solid #313244;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #6c7086;
|
||||||
|
margin-right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
background: #313244;
|
||||||
|
color: #cdd6f4;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
background: #45475a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btnPlay {
|
||||||
|
background: #40a02b;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btnPlay:hover {
|
||||||
|
background: #37872b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.separator {
|
||||||
|
width: 1px;
|
||||||
|
height: 20px;
|
||||||
|
background: #313244;
|
||||||
|
margin: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #cdd6f4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle input {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobileBanner {
|
||||||
|
background: #2a1f00;
|
||||||
|
color: #f9e2af;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #f9e2af44;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flagPill {
|
||||||
|
background: rgba(137, 180, 250, 0.15);
|
||||||
|
color: #89b4fa;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-family: monospace;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btnReset {
|
||||||
|
background: #45475a;
|
||||||
|
color: #f9e2af;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btnReset:hover {
|
||||||
|
background: #585b70;
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { useDialogueStore } from '../../store/dialogueStore';
|
||||||
|
import { makeNodeId } from '../../utils/idGen';
|
||||||
|
import { DialogueNode, NodeType } from '../../types/dialogue';
|
||||||
|
import { MAIN_CHARACTER } from '../../constants/characters';
|
||||||
|
import styles from './GraphToolbar.module.css';
|
||||||
|
|
||||||
|
const NODE_DEFAULTS: Record<string, () => Omit<DialogueNode, 'id'>> = {
|
||||||
|
Line: () => ({
|
||||||
|
type: 'Line',
|
||||||
|
speaker: MAIN_CHARACTER,
|
||||||
|
portrait: 'resources/dialogue/portrait_hero_neutral.png',
|
||||||
|
text: '',
|
||||||
|
next: '',
|
||||||
|
}),
|
||||||
|
Choice: () => ({
|
||||||
|
type: 'Choice',
|
||||||
|
speaker: MAIN_CHARACTER,
|
||||||
|
portrait: 'resources/dialogue/portrait_hero_neutral.png',
|
||||||
|
text: '',
|
||||||
|
choices: [],
|
||||||
|
}),
|
||||||
|
Condition: () => ({
|
||||||
|
type: 'Condition',
|
||||||
|
conditions: [],
|
||||||
|
trueNext: '',
|
||||||
|
falseNext: '',
|
||||||
|
}),
|
||||||
|
SetFlag: () => ({
|
||||||
|
type: 'SetFlag',
|
||||||
|
effects: [],
|
||||||
|
next: '',
|
||||||
|
}),
|
||||||
|
CutsceneStart: () => ({
|
||||||
|
type: 'CutsceneStart',
|
||||||
|
cutsceneId: '',
|
||||||
|
next: '',
|
||||||
|
}),
|
||||||
|
End: () => ({ type: 'End' }),
|
||||||
|
};
|
||||||
|
|
||||||
|
export function GraphToolbar() {
|
||||||
|
const { file, selectedDialogueId, selectedNodeId, addNode, applyAutoLayout, startPlay, setDialogueMobileMode, persistentFlags, resetFlags } = useDialogueStore();
|
||||||
|
|
||||||
|
const dialogue = file?.dialogues.find(d => d.id === selectedDialogueId);
|
||||||
|
if (!dialogue) return null;
|
||||||
|
|
||||||
|
function handleAddNode(type: NodeType) {
|
||||||
|
if (!selectedDialogueId || !dialogue) return;
|
||||||
|
const existingIds = new Set(dialogue.nodes.map(n => n.id));
|
||||||
|
const id = makeNodeId(type, existingIds);
|
||||||
|
const node = { id, ...NODE_DEFAULTS[type]() } as DialogueNode;
|
||||||
|
addNode(selectedDialogueId, node, selectedNodeId ?? undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.toolbar}>
|
||||||
|
<div className={styles.group}>
|
||||||
|
<span className={styles.label}>Add:</span>
|
||||||
|
{(['Line', 'Choice', 'Condition', 'SetFlag', 'CutsceneStart', 'End'] as NodeType[]).map(type => (
|
||||||
|
<button key={type} className={styles.btn} onClick={() => handleAddNode(type)}>
|
||||||
|
{type}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className={styles.separator} />
|
||||||
|
<div className={styles.group}>
|
||||||
|
<button className={styles.btn} onClick={() => applyAutoLayout(selectedDialogueId!)}>
|
||||||
|
⬡ Auto Layout
|
||||||
|
</button>
|
||||||
|
<button className={[styles.btn, styles.btnPlay].join(' ')} onClick={startPlay}>
|
||||||
|
▶ Play
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className={styles.separator} />
|
||||||
|
<div className={styles.group}>
|
||||||
|
<label className={styles.toggle}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!!dialogue.mobileMode}
|
||||||
|
onChange={e => setDialogueMobileMode(selectedDialogueId!, e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>📱 Mobile</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{dialogue.mobileMode && (
|
||||||
|
<div className={styles.mobileBanner}>
|
||||||
|
Mobile mode — portraits will be overridden on export
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{Object.keys(persistentFlags).length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className={styles.separator} />
|
||||||
|
<div className={styles.group}>
|
||||||
|
<span className={styles.label}>🚩 Flags:</span>
|
||||||
|
{Object.entries(persistentFlags).map(([k, v]) => (
|
||||||
|
<span key={k} className={styles.flagPill}>{k}={v}</span>
|
||||||
|
))}
|
||||||
|
<button className={styles.btnReset} onClick={resetFlags}>Reset</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
.panel {
|
||||||
|
width: 220px;
|
||||||
|
min-width: 220px;
|
||||||
|
background: #181825;
|
||||||
|
border-right: 1px solid #313244;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
padding: 12px 12px 6px;
|
||||||
|
border-bottom: 1px solid #313244;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #cdd6f4;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileName {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #6c7086;
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileButtons {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-bottom: 1px solid #313244;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
flex: 1;
|
||||||
|
background: #313244;
|
||||||
|
color: #cdd6f4;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
background: #45475a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btnSave {
|
||||||
|
background: #1e66f5;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btnSave:hover {
|
||||||
|
background: #2779e4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btnNew {
|
||||||
|
background: #40a02b;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btnNew:hover {
|
||||||
|
background: #37872b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorMsg {
|
||||||
|
background: #2a0e14;
|
||||||
|
color: #f38ba8;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-top: 1px solid #f38ba8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyMsg {
|
||||||
|
padding: 16px 12px;
|
||||||
|
color: #6c7086;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialogueItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 1px 4px;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialogueItem:hover {
|
||||||
|
background: #313244;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialogueItem.active {
|
||||||
|
background: #1e3a5f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialogueId {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: monospace;
|
||||||
|
color: #cdd6f4;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.errDot { color: #f38ba8; font-size: 9px; }
|
||||||
|
.warnDot { color: #f9e2af; font-size: 9px; }
|
||||||
|
.mobileDot { font-size: 10px; }
|
||||||
|
|
||||||
|
.copyBtn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #6c7086;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copyBtn:hover {
|
||||||
|
color: #89b4fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deleteBtn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #6c7086;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deleteBtn:hover {
|
||||||
|
color: #f38ba8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
padding: 8px;
|
||||||
|
border-top: 1px solid #313244;
|
||||||
|
}
|
||||||
|
|
||||||
|
.createForm {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.createInput {
|
||||||
|
flex: 1;
|
||||||
|
background: #313244;
|
||||||
|
border: 1px solid #45475a;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #cdd6f4;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
min-width: 0;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.createInput:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #89b4fa;
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { useDialogueStore } from '../../store/dialogueStore';
|
||||||
|
import { parseDialogueFile } from '../../utils/fileIO';
|
||||||
|
import { useValidation } from '../../hooks/useValidation';
|
||||||
|
import styles from './LeftPanel.module.css';
|
||||||
|
|
||||||
|
export function LeftPanel() {
|
||||||
|
const { file, fileName, selectedDialogueId, loadFile, selectDialogue, createDialogue, deleteDialogue, duplicateDialogue, exportFile } = useDialogueStore();
|
||||||
|
const { issuesByNodeId } = useValidation();
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [newId, setNewId] = useState('');
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const f = e.target.files?.[0];
|
||||||
|
if (!f) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (ev) => {
|
||||||
|
try {
|
||||||
|
const parsed = parseDialogueFile(ev.target?.result as string);
|
||||||
|
loadFile(parsed, f.name);
|
||||||
|
setError('');
|
||||||
|
} catch (err) {
|
||||||
|
setError(String(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsText(f);
|
||||||
|
e.target.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCreate() {
|
||||||
|
if (!newId.trim()) return;
|
||||||
|
if (file?.dialogues.some(d => d.id === newId.trim())) {
|
||||||
|
setError(`ID "${newId.trim()}" already exists`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
createDialogue(newId.trim());
|
||||||
|
setNewId('');
|
||||||
|
setCreating(false);
|
||||||
|
setError('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function dialogueHasIssues(dialogueId: string) {
|
||||||
|
// Simple check: are there any issues for nodes in this dialogue?
|
||||||
|
// We only have current dialogue issues so we check if this is selected
|
||||||
|
return selectedDialogueId === dialogueId && Object.keys(issuesByNodeId).length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dialogueHasErrors(dialogueId: string) {
|
||||||
|
return selectedDialogueId === dialogueId &&
|
||||||
|
Object.values(issuesByNodeId).some(list => list.some(i => i.severity === 'error'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.panel}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<span className={styles.title}>Dialogues</span>
|
||||||
|
{file && <span className={styles.fileName}>{fileName}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.fileButtons}>
|
||||||
|
<button className={styles.btn} onClick={() => fileInputRef.current?.click()}>
|
||||||
|
📂 Load
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".json"
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
onChange={handleFileChange}
|
||||||
|
/>
|
||||||
|
{file && (
|
||||||
|
<button className={[styles.btn, styles.btnSave].join(' ')} onClick={exportFile}>
|
||||||
|
💾 Save
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className={styles.errorMsg}>{error}</div>}
|
||||||
|
|
||||||
|
<div className={styles.list}>
|
||||||
|
{!file && (
|
||||||
|
<div className={styles.emptyMsg}>Load a JSON file to start</div>
|
||||||
|
)}
|
||||||
|
{file?.dialogues.map(d => {
|
||||||
|
const hasErr = dialogueHasErrors(d.id);
|
||||||
|
const hasWarn = !hasErr && dialogueHasIssues(d.id);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={d.id}
|
||||||
|
className={[
|
||||||
|
styles.dialogueItem,
|
||||||
|
d.id === selectedDialogueId ? styles.active : '',
|
||||||
|
].join(' ')}
|
||||||
|
onClick={() => selectDialogue(d.id)}
|
||||||
|
>
|
||||||
|
<span className={styles.dialogueId}>
|
||||||
|
{hasErr && <span className={styles.errDot} title="Has errors">●</span>}
|
||||||
|
{hasWarn && <span className={styles.warnDot} title="Has warnings">●</span>}
|
||||||
|
{d.mobileMode && <span className={styles.mobileDot} title="Mobile mode">📱</span>}
|
||||||
|
{d.id}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className={styles.copyBtn}
|
||||||
|
title="Duplicate dialogue"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
duplicateDialogue(d.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
⧉
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={styles.deleteBtn}
|
||||||
|
title="Delete dialogue"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (confirm(`Delete "${d.id}"?`)) deleteDialogue(d.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.footer}>
|
||||||
|
{creating ? (
|
||||||
|
<div className={styles.createForm}>
|
||||||
|
<input
|
||||||
|
className={styles.createInput}
|
||||||
|
value={newId}
|
||||||
|
onChange={e => setNewId(e.target.value)}
|
||||||
|
placeholder="dialogue_id"
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === 'Enter') handleCreate();
|
||||||
|
if (e.key === 'Escape') { setCreating(false); setNewId(''); }
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button className={[styles.btn, styles.btnSave].join(' ')} onClick={handleCreate}>✓</button>
|
||||||
|
<button className={styles.btn} onClick={() => { setCreating(false); setNewId(''); }}>✗</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
file && (
|
||||||
|
<button className={[styles.btn, styles.btnNew].join(' ')} onClick={() => setCreating(true)}>
|
||||||
|
+ New Dialogue
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
.overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.75);
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog {
|
||||||
|
background: #1e1e2e;
|
||||||
|
border: 1px solid #313244;
|
||||||
|
border-radius: 12px;
|
||||||
|
width: 520px;
|
||||||
|
max-width: 90vw;
|
||||||
|
padding: 24px;
|
||||||
|
position: relative;
|
||||||
|
box-shadow: 0 20px 60px rgba(0,0,0,0.7);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeBtn {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
right: 14px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #6c7086;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeBtn:hover {
|
||||||
|
color: #f38ba8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speakerRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portraitBox {
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #313244;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portrait {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portraitFallback {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #6c7086;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speakerName {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #89b4fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main character (Бекзат) — blue tones */
|
||||||
|
.speakerRowMain { border-left: 3px solid #1e66f5; padding-left: 10px; }
|
||||||
|
.portraitMain { border: 2px solid #1e66f5; }
|
||||||
|
.speakerMain { color: #89b4fa; }
|
||||||
|
.textBoxMain { border-left: 3px solid #1e66f5; }
|
||||||
|
|
||||||
|
/* Other speakers — green tones */
|
||||||
|
.speakerRowOther { border-left: 3px solid #40a02b; padding-left: 10px; }
|
||||||
|
.portraitOther { border: 2px solid #40a02b; }
|
||||||
|
.speakerOther { color: #a6e3a1; }
|
||||||
|
.textBoxOther { border-left: 3px solid #40a02b; }
|
||||||
|
|
||||||
|
.textBox {
|
||||||
|
background: #11111b;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #cdd6f4;
|
||||||
|
min-height: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nextBtn {
|
||||||
|
background: #1e66f5;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 24px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
align-self: flex-end;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nextBtn:hover {
|
||||||
|
background: #2779e4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.noNextWarning {
|
||||||
|
color: #f9e2af;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choiceList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choiceBtn {
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 11px 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choiceBtn:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choiceMain {
|
||||||
|
background: #1e66f5;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choiceOptional {
|
||||||
|
background: #313244;
|
||||||
|
color: #cdd6f4;
|
||||||
|
border: 1px solid #45475a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.autoAdvance {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditionInfo {
|
||||||
|
color: #cba6f7;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditionClauses {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clausePill {
|
||||||
|
background: #313244;
|
||||||
|
color: #a6adc8;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cutsceneBox {
|
||||||
|
background: #11111b;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cutsceneLabel {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #6c7086;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cutsceneId {
|
||||||
|
font-size: 15px;
|
||||||
|
color: #a6adc8;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.endBox {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.endMsg {
|
||||||
|
font-size: 16px;
|
||||||
|
color: #6c7086;
|
||||||
|
}
|
||||||
|
|
||||||
|
.debugBar {
|
||||||
|
border-top: 1px solid #313244;
|
||||||
|
padding-top: 8px;
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.debugLabel {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #45475a;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.debugFlagPill {
|
||||||
|
background: rgba(137, 180, 250, 0.12);
|
||||||
|
color: #6c7086;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useDialogueStore } from '../../store/dialogueStore';
|
||||||
|
import { ConditionClause } from '../../types/dialogue';
|
||||||
|
import { MAIN_CHARACTER } from '../../constants/characters';
|
||||||
|
import styles from './PlayModeOverlay.module.css';
|
||||||
|
|
||||||
|
function evalConditions(clauses: ConditionClause[], flags: Record<string, number | string>): boolean {
|
||||||
|
return clauses.every(c => {
|
||||||
|
const actual = flags[c.flag] ?? 0;
|
||||||
|
switch (c.op) {
|
||||||
|
case 'Equals': return actual == c.value;
|
||||||
|
case 'NotEquals': return actual != c.value;
|
||||||
|
case 'GreaterThan': return Number(actual) > Number(c.value);
|
||||||
|
case 'LessThan': return Number(actual) < Number(c.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlayModeOverlay() {
|
||||||
|
const { file, selectedDialogueId, playState, advancePlay, setPlayFlag, stopPlay } = useDialogueStore();
|
||||||
|
|
||||||
|
const dialogue = file?.dialogues.find(d => d.id === selectedDialogueId);
|
||||||
|
const node = dialogue?.nodes.find(n => n.id === playState?.currentNodeId);
|
||||||
|
|
||||||
|
// Auto-advance for Condition and SetFlag
|
||||||
|
useEffect(() => {
|
||||||
|
if (!node || !playState) return;
|
||||||
|
|
||||||
|
if (node.type === 'Condition') {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
const passes = evalConditions(node.conditions, playState.flags);
|
||||||
|
advancePlay(passes ? node.trueNext : node.falseNext);
|
||||||
|
}, 300);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === 'SetFlag') {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
for (const effect of node.effects) {
|
||||||
|
setPlayFlag(effect.flag, effect.value);
|
||||||
|
}
|
||||||
|
advancePlay(node.next);
|
||||||
|
}, 100);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [node?.id, node?.type]);
|
||||||
|
|
||||||
|
if (!playState || !node) return null;
|
||||||
|
|
||||||
|
const portraitPath = node.type === 'Line' || node.type === 'Choice'
|
||||||
|
? `../${node.portrait}`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.overlay} onClick={e => e.stopPropagation()}>
|
||||||
|
<div className={styles.dialog}>
|
||||||
|
<button className={styles.closeBtn} onClick={stopPlay} title="Exit play mode">✕</button>
|
||||||
|
|
||||||
|
{node.type === 'Line' && (
|
||||||
|
<>
|
||||||
|
<div className={[
|
||||||
|
styles.speakerRow,
|
||||||
|
node.speaker === MAIN_CHARACTER ? styles.speakerRowMain : styles.speakerRowOther,
|
||||||
|
].join(' ')}>
|
||||||
|
<div className={[
|
||||||
|
styles.portraitBox,
|
||||||
|
node.speaker === MAIN_CHARACTER ? styles.portraitMain : styles.portraitOther,
|
||||||
|
].join(' ')}>
|
||||||
|
<img
|
||||||
|
src={portraitPath!}
|
||||||
|
alt={node.speaker}
|
||||||
|
className={styles.portrait}
|
||||||
|
onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||||
|
/>
|
||||||
|
<span className={styles.portraitFallback}>{node.speaker[0]}</span>
|
||||||
|
</div>
|
||||||
|
<span className={[
|
||||||
|
styles.speakerName,
|
||||||
|
node.speaker === MAIN_CHARACTER ? styles.speakerMain : styles.speakerOther,
|
||||||
|
].join(' ')}>{node.speaker}</span>
|
||||||
|
</div>
|
||||||
|
<div className={[
|
||||||
|
styles.textBox,
|
||||||
|
node.speaker === MAIN_CHARACTER ? styles.textBoxMain : styles.textBoxOther,
|
||||||
|
].join(' ')}>{node.text}</div>
|
||||||
|
{node.next ? (
|
||||||
|
<button className={styles.nextBtn} onClick={() => advancePlay(node.next)}>
|
||||||
|
Next →
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className={styles.noNextWarning}>⚠ No next node set</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{node.type === 'Choice' && (
|
||||||
|
<>
|
||||||
|
<div className={styles.speakerRow}>
|
||||||
|
<div className={styles.portraitBox}>
|
||||||
|
<img
|
||||||
|
src={portraitPath!}
|
||||||
|
alt={node.speaker}
|
||||||
|
className={styles.portrait}
|
||||||
|
onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||||
|
/>
|
||||||
|
<span className={styles.portraitFallback}>{node.speaker[0]}</span>
|
||||||
|
</div>
|
||||||
|
<span className={styles.speakerName}>{node.speaker}</span>
|
||||||
|
</div>
|
||||||
|
{node.text && <div className={styles.textBox}>{node.text}</div>}
|
||||||
|
<div className={styles.choiceList}>
|
||||||
|
{node.choices.map(choice => (
|
||||||
|
<button
|
||||||
|
key={choice.id}
|
||||||
|
className={[styles.choiceBtn, choice.kind === 'Main' ? styles.choiceMain : styles.choiceOptional].join(' ')}
|
||||||
|
onClick={() => advancePlay(choice.next)}
|
||||||
|
>
|
||||||
|
{choice.text || '(empty choice)'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{node.type === 'Condition' && (
|
||||||
|
<div className={styles.autoAdvance}>
|
||||||
|
<div className={styles.conditionInfo}>Evaluating condition...</div>
|
||||||
|
<div className={styles.conditionClauses}>
|
||||||
|
{node.conditions.map((c, i) => (
|
||||||
|
<span key={i} className={styles.clausePill}>{c.flag} {c.op} {c.value}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{node.type === 'SetFlag' && (
|
||||||
|
<div className={styles.autoAdvance}>
|
||||||
|
<div className={styles.conditionInfo}>Setting flags...</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{node.type === 'CutsceneStart' && (
|
||||||
|
<>
|
||||||
|
<div className={styles.cutsceneBox}>
|
||||||
|
<span className={styles.cutsceneLabel}>Cutscene:</span>
|
||||||
|
<span className={styles.cutsceneId}>{node.cutsceneId}</span>
|
||||||
|
</div>
|
||||||
|
<button className={styles.nextBtn} onClick={() => advancePlay(node.next)}>
|
||||||
|
Continue →
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{node.type === 'End' && (
|
||||||
|
<div className={styles.endBox}>
|
||||||
|
<div className={styles.endMsg}>Dialogue ended.</div>
|
||||||
|
<button className={styles.nextBtn} onClick={stopPlay}>Close</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.debugBar}>
|
||||||
|
<span className={styles.debugLabel}>node: {node.id}</span>
|
||||||
|
{Object.entries(playState.flags).map(([k, v]) => (
|
||||||
|
<span key={k} className={styles.debugFlagPill}>{k}={v}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
.panel {
|
||||||
|
width: 300px;
|
||||||
|
min-width: 300px;
|
||||||
|
background: #181825;
|
||||||
|
border-left: 1px solid #313244;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyMsg {
|
||||||
|
padding: 24px 16px;
|
||||||
|
color: #6c7086;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inspector {
|
||||||
|
padding: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inspectorHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodeTypeBadge {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodeIdLabel {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: monospace;
|
||||||
|
color: #6c7086;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deleteNodeBtn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 2px;
|
||||||
|
color: #6c7086;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deleteNodeBtn:hover {
|
||||||
|
color: #f38ba8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
display: block;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #6c7086;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
width: 100%;
|
||||||
|
background: #1e1e2e;
|
||||||
|
border: 1px solid #313244;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #cdd6f4;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #89b4fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea {
|
||||||
|
width: 100%;
|
||||||
|
background: #1e1e2e;
|
||||||
|
border: 1px solid #313244;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #cdd6f4;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
resize: vertical;
|
||||||
|
font-family: inherit;
|
||||||
|
line-height: 1.5;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #89b4fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select {
|
||||||
|
width: 100%;
|
||||||
|
background: #1e1e2e;
|
||||||
|
border: 1px solid #313244;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #cdd6f4;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #89b4fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectSmall {
|
||||||
|
background: #1e1e2e;
|
||||||
|
border: 1px solid #313244;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #cdd6f4;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 3px 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inputSmall {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
background: #1e1e2e;
|
||||||
|
border: 1px solid #313244;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #cdd6f4;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 3px 6px;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inputSmall:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #89b4fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.advancedToggle {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #6c7086;
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 0;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.advancedToggle:hover {
|
||||||
|
color: #cdd6f4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.advancedSection {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
background: #1e1e2e;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #313244;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issueList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issueError {
|
||||||
|
background: #2a0e14;
|
||||||
|
color: #f38ba8;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border-left: 3px solid #f38ba8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issueWarning {
|
||||||
|
background: #2a1f00;
|
||||||
|
color: #f9e2af;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border-left: 3px solid #f9e2af;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Choice editor */
|
||||||
|
.choiceEditorRow {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px;
|
||||||
|
background: #1e1e2e;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #313244;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choiceEditorHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choiceIdLabel {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #6c7086;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addBtn {
|
||||||
|
background: #313244;
|
||||||
|
color: #cdd6f4;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addBtn:hover {
|
||||||
|
background: #45475a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.removeBtn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #6c7086;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.removeBtn:hover {
|
||||||
|
color: #f38ba8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Clause / effect rows */
|
||||||
|
.clauseRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eqLabel {
|
||||||
|
color: #cba6f7;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyHint {
|
||||||
|
color: #6c7086;
|
||||||
|
font-size: 11px;
|
||||||
|
font-style: italic;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dialogue meta panel */
|
||||||
|
.dialogueMeta {
|
||||||
|
padding: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metaHeader {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #cdd6f4;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statRow {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statPill {
|
||||||
|
background: #313244;
|
||||||
|
color: #a6adc8;
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { useDialogueStore } from '../../store/dialogueStore';
|
||||||
|
import { LineInspector } from './inspectors/LineInspector';
|
||||||
|
import { ChoiceInspector } from './inspectors/ChoiceInspector';
|
||||||
|
import { ConditionInspector } from './inspectors/ConditionInspector';
|
||||||
|
import { SetFlagInspector } from './inspectors/SetFlagInspector';
|
||||||
|
import { CutsceneStartInspector, EndInspector } from './inspectors/OtherInspectors';
|
||||||
|
import { DialogueNode } from '../../types/dialogue';
|
||||||
|
import styles from './RightPanel.module.css';
|
||||||
|
|
||||||
|
// Helper to update start node ID
|
||||||
|
function useUpdateStart() {
|
||||||
|
const store = useDialogueStore;
|
||||||
|
return (dialogueId: string, start: string) => {
|
||||||
|
store.setState(state => {
|
||||||
|
const d = state.file?.dialogues.find(x => x.id === dialogueId);
|
||||||
|
if (d) d.start = start;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RightPanel() {
|
||||||
|
const { file, selectedDialogueId, selectedNodeId } = useDialogueStore();
|
||||||
|
const updateStart = useUpdateStart();
|
||||||
|
|
||||||
|
const dialogue = file?.dialogues.find(d => d.id === selectedDialogueId);
|
||||||
|
|
||||||
|
if (!dialogue) {
|
||||||
|
return (
|
||||||
|
<div className={styles.panel}>
|
||||||
|
<div className={styles.emptyMsg}>No dialogue selected</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedNodeId) {
|
||||||
|
return (
|
||||||
|
<div className={styles.panel}>
|
||||||
|
<div className={styles.dialogueMeta}>
|
||||||
|
<div className={styles.metaHeader}>Dialogue Properties</div>
|
||||||
|
<div>
|
||||||
|
<label className={styles.label}>ID</label>
|
||||||
|
<input className={styles.input} value={dialogue.id} readOnly />
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<label className={styles.label}>Start node</label>
|
||||||
|
<input
|
||||||
|
className={styles.input}
|
||||||
|
value={dialogue.start}
|
||||||
|
onChange={e => {
|
||||||
|
if (selectedDialogueId) updateStart(selectedDialogueId, e.target.value);
|
||||||
|
}}
|
||||||
|
list="startNodeList"
|
||||||
|
/>
|
||||||
|
<datalist id="startNodeList">
|
||||||
|
{dialogue.nodes.map(n => <option key={n.id} value={n.id} />)}
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
|
<div className={styles.emptyHint} style={{ marginTop: 16 }}>
|
||||||
|
Click a node in the graph to inspect it.
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 12 }}>
|
||||||
|
<div className={styles.metaHeader}>Node Count</div>
|
||||||
|
<div className={styles.statRow}>
|
||||||
|
{(['Line', 'Choice', 'Condition', 'SetFlag', 'CutsceneStart', 'End'] as DialogueNode['type'][]).map(t => {
|
||||||
|
const count = dialogue.nodes.filter(n => n.type === t).length;
|
||||||
|
return count > 0 ? (
|
||||||
|
<span key={t} className={styles.statPill}>{t}: {count}</span>
|
||||||
|
) : null;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const node = dialogue.nodes.find(n => n.id === selectedNodeId);
|
||||||
|
if (!node) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.panel}>
|
||||||
|
{node.type === 'Line' && (
|
||||||
|
<LineInspector node={node} dialogueId={dialogue.id} />
|
||||||
|
)}
|
||||||
|
{node.type === 'Choice' && (
|
||||||
|
<ChoiceInspector node={node} dialogueId={dialogue.id} />
|
||||||
|
)}
|
||||||
|
{node.type === 'Condition' && (
|
||||||
|
<ConditionInspector node={node} dialogueId={dialogue.id} />
|
||||||
|
)}
|
||||||
|
{node.type === 'SetFlag' && (
|
||||||
|
<SetFlagInspector node={node} dialogueId={dialogue.id} />
|
||||||
|
)}
|
||||||
|
{node.type === 'CutsceneStart' && (
|
||||||
|
<CutsceneStartInspector node={node} dialogueId={dialogue.id} />
|
||||||
|
)}
|
||||||
|
{node.type === 'End' && (
|
||||||
|
<EndInspector node={node} dialogueId={dialogue.id} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { ChoiceNode, ChoiceOption } from '../../../types/dialogue';
|
||||||
|
import { useDialogueStore } from '../../../store/dialogueStore';
|
||||||
|
import { SpeakerField } from '../shared/SpeakerField';
|
||||||
|
import { useValidation } from '../../../hooks/useValidation';
|
||||||
|
import styles from '../RightPanel.module.css';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
node: ChoiceNode;
|
||||||
|
dialogueId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChoiceInspector({ node, dialogueId }: Props) {
|
||||||
|
const { updateNode, deleteNode, file } = useDialogueStore();
|
||||||
|
const { issuesByNodeId } = useValidation();
|
||||||
|
const issues = issuesByNodeId[node.id] ?? [];
|
||||||
|
|
||||||
|
const dialogue = file?.dialogues.find(d => d.id === dialogueId);
|
||||||
|
const nodeIds = dialogue?.nodes.map(n => n.id) ?? [];
|
||||||
|
|
||||||
|
function updateChoice(idx: number, patch: Partial<ChoiceOption>) {
|
||||||
|
const choices = node.choices.map((c, i) => i === idx ? { ...c, ...patch } : c);
|
||||||
|
updateNode(dialogueId, node.id, { choices });
|
||||||
|
}
|
||||||
|
|
||||||
|
function addChoice() {
|
||||||
|
const newId = `choice_${Date.now()}`;
|
||||||
|
const choices: ChoiceOption[] = [...node.choices, { id: newId, kind: 'Optional', text: '', next: '' }];
|
||||||
|
updateNode(dialogueId, node.id, { choices });
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeChoice(idx: number) {
|
||||||
|
const choices = node.choices.filter((_, i) => i !== idx);
|
||||||
|
updateNode(dialogueId, node.id, { choices });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.inspector}>
|
||||||
|
<div className={styles.inspectorHeader}>
|
||||||
|
<span className={styles.nodeTypeBadge} style={{ background: '#df8e1d' }}>Choice</span>
|
||||||
|
<span className={styles.nodeIdLabel}>{node.id}</span>
|
||||||
|
<button className={styles.deleteNodeBtn} onClick={() => deleteNode(dialogueId, node.id)} title="Delete node">🗑</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{issues.length > 0 && (
|
||||||
|
<div className={styles.issueList}>
|
||||||
|
{issues.map((issue, i) => (
|
||||||
|
<div key={i} className={issue.severity === 'error' ? styles.issueError : styles.issueWarning}>
|
||||||
|
{issue.severity === 'error' ? '⚠' : '!'} {issue.message}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className={styles.label}>Node ID</label>
|
||||||
|
<input className={styles.input} value={node.id} readOnly />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SpeakerField
|
||||||
|
speaker={node.speaker}
|
||||||
|
portrait={node.portrait}
|
||||||
|
onSpeakerChange={(s, p) => updateNode(dialogueId, node.id, { speaker: s, portrait: p })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 12 }}>
|
||||||
|
<div className={styles.sectionHeader}>
|
||||||
|
<span className={styles.label}>Choices</span>
|
||||||
|
<button className={styles.addBtn} onClick={addChoice}>+ Add</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{node.choices.map((choice, idx) => (
|
||||||
|
<div key={choice.id} className={styles.choiceEditorRow}>
|
||||||
|
<div className={styles.choiceEditorHeader}>
|
||||||
|
<select
|
||||||
|
className={styles.selectSmall}
|
||||||
|
value={choice.kind}
|
||||||
|
onChange={e => updateChoice(idx, { kind: e.target.value as 'Main' | 'Optional' })}
|
||||||
|
>
|
||||||
|
<option value="Main">Main</option>
|
||||||
|
<option value="Optional">Optional</option>
|
||||||
|
</select>
|
||||||
|
<span className={styles.choiceIdLabel}>#{idx + 1}</span>
|
||||||
|
<button className={styles.removeBtn} onClick={() => removeChoice(idx)}>×</button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
className={styles.input}
|
||||||
|
value={choice.text}
|
||||||
|
placeholder="Choice text..."
|
||||||
|
onChange={e => updateChoice(idx, { text: e.target.value })}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className={styles.input}
|
||||||
|
value={choice.next}
|
||||||
|
placeholder="Next node ID..."
|
||||||
|
onChange={e => updateChoice(idx, { next: e.target.value })}
|
||||||
|
list={`nodelist-choice-${choice.id}`}
|
||||||
|
/>
|
||||||
|
<datalist id={`nodelist-choice-${choice.id}`}>
|
||||||
|
{nodeIds.map(id => <option key={id} value={id} />)}
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{node.choices.length === 0 && (
|
||||||
|
<div className={styles.emptyHint}>No choices yet. Click "+ Add".</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { ConditionNode, ConditionClause, ConditionOp } from '../../../types/dialogue';
|
||||||
|
import { useDialogueStore } from '../../../store/dialogueStore';
|
||||||
|
import { useValidation } from '../../../hooks/useValidation';
|
||||||
|
import styles from '../RightPanel.module.css';
|
||||||
|
|
||||||
|
const OPS: ConditionOp[] = ['Equals', 'NotEquals', 'GreaterThan', 'LessThan'];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
node: ConditionNode;
|
||||||
|
dialogueId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConditionInspector({ node, dialogueId }: Props) {
|
||||||
|
const { updateNode, deleteNode, file } = useDialogueStore();
|
||||||
|
const { issuesByNodeId } = useValidation();
|
||||||
|
const issues = issuesByNodeId[node.id] ?? [];
|
||||||
|
|
||||||
|
const dialogue = file?.dialogues.find(d => d.id === dialogueId);
|
||||||
|
const nodeIds = dialogue?.nodes.map(n => n.id) ?? [];
|
||||||
|
|
||||||
|
function updateClause(idx: number, patch: Partial<ConditionClause>) {
|
||||||
|
const conditions = node.conditions.map((c, i) => i === idx ? { ...c, ...patch } : c);
|
||||||
|
updateNode(dialogueId, node.id, { conditions });
|
||||||
|
}
|
||||||
|
|
||||||
|
function addClause() {
|
||||||
|
const conditions: ConditionClause[] = [...node.conditions, { flag: '', op: 'Equals', value: 1 }];
|
||||||
|
updateNode(dialogueId, node.id, { conditions });
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeClause(idx: number) {
|
||||||
|
const conditions = node.conditions.filter((_, i) => i !== idx);
|
||||||
|
updateNode(dialogueId, node.id, { conditions });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.inspector}>
|
||||||
|
<div className={styles.inspectorHeader}>
|
||||||
|
<span className={styles.nodeTypeBadge} style={{ background: '#8839ef' }}>Condition</span>
|
||||||
|
<span className={styles.nodeIdLabel}>{node.id}</span>
|
||||||
|
<button className={styles.deleteNodeBtn} onClick={() => deleteNode(dialogueId, node.id)} title="Delete node">🗑</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{issues.length > 0 && (
|
||||||
|
<div className={styles.issueList}>
|
||||||
|
{issues.map((issue, i) => (
|
||||||
|
<div key={i} className={issue.severity === 'error' ? styles.issueError : styles.issueWarning}>
|
||||||
|
{issue.severity === 'error' ? '⚠' : '!'} {issue.message}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className={styles.label}>Node ID</label>
|
||||||
|
<input className={styles.input} value={node.id} readOnly />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<div className={styles.sectionHeader}>
|
||||||
|
<span className={styles.label}>Conditions (ALL must pass)</span>
|
||||||
|
<button className={styles.addBtn} onClick={addClause}>+ Add</button>
|
||||||
|
</div>
|
||||||
|
{node.conditions.map((c, idx) => (
|
||||||
|
<div key={idx} className={styles.clauseRow}>
|
||||||
|
<input
|
||||||
|
className={styles.inputSmall}
|
||||||
|
value={c.flag}
|
||||||
|
placeholder="flag"
|
||||||
|
onChange={e => updateClause(idx, { flag: e.target.value })}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
className={styles.selectSmall}
|
||||||
|
value={c.op}
|
||||||
|
onChange={e => updateClause(idx, { op: e.target.value as ConditionOp })}
|
||||||
|
>
|
||||||
|
{OPS.map(op => <option key={op} value={op}>{op}</option>)}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
className={styles.inputSmall}
|
||||||
|
value={String(c.value)}
|
||||||
|
placeholder="value"
|
||||||
|
onChange={e => updateClause(idx, { value: isNaN(Number(e.target.value)) ? e.target.value : Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
<button className={styles.removeBtn} onClick={() => removeClause(idx)}>×</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{node.conditions.length === 0 && <div className={styles.emptyHint}>No conditions. Always true.</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<label className={styles.label}>True → next node</label>
|
||||||
|
<input
|
||||||
|
className={styles.input}
|
||||||
|
value={node.trueNext}
|
||||||
|
placeholder="node_id"
|
||||||
|
onChange={e => updateNode(dialogueId, node.id, { trueNext: e.target.value })}
|
||||||
|
list={`nodelist-true-${node.id}`}
|
||||||
|
/>
|
||||||
|
<datalist id={`nodelist-true-${node.id}`}>
|
||||||
|
{nodeIds.map(id => <option key={id} value={id} />)}
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<label className={styles.label}>False → next node</label>
|
||||||
|
<input
|
||||||
|
className={styles.input}
|
||||||
|
value={node.falseNext}
|
||||||
|
placeholder="node_id"
|
||||||
|
onChange={e => updateNode(dialogueId, node.id, { falseNext: e.target.value })}
|
||||||
|
list={`nodelist-false-${node.id}`}
|
||||||
|
/>
|
||||||
|
<datalist id={`nodelist-false-${node.id}`}>
|
||||||
|
{nodeIds.map(id => <option key={id} value={id} />)}
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { LineNode } from '../../../types/dialogue';
|
||||||
|
import { useDialogueStore } from '../../../store/dialogueStore';
|
||||||
|
import { SpeakerField } from '../shared/SpeakerField';
|
||||||
|
import { AutoSaveTextArea } from '../shared/TextArea';
|
||||||
|
import { useValidation } from '../../../hooks/useValidation';
|
||||||
|
import styles from '../RightPanel.module.css';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
node: LineNode;
|
||||||
|
dialogueId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LineInspector({ node, dialogueId }: Props) {
|
||||||
|
const { updateNode, deleteNode, file } = useDialogueStore();
|
||||||
|
const { issuesByNodeId } = useValidation();
|
||||||
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||||
|
const issues = issuesByNodeId[node.id] ?? [];
|
||||||
|
|
||||||
|
const dialogue = file?.dialogues.find(d => d.id === dialogueId);
|
||||||
|
const nodeIds = dialogue?.nodes.map(n => n.id) ?? [];
|
||||||
|
|
||||||
|
function update(patch: Partial<LineNode>) {
|
||||||
|
updateNode(dialogueId, node.id, patch);
|
||||||
|
}
|
||||||
|
|
||||||
|
function field(label: string, key: keyof LineNode, placeholder?: string) {
|
||||||
|
const val = (node[key] as string) ?? '';
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className={styles.label}>{label}</label>
|
||||||
|
<input
|
||||||
|
className={styles.input}
|
||||||
|
value={val}
|
||||||
|
placeholder={placeholder}
|
||||||
|
onChange={e => update({ [key]: e.target.value } as Partial<LineNode>)}
|
||||||
|
list={key === 'next' ? `nodelist-${node.id}` : undefined}
|
||||||
|
/>
|
||||||
|
{key === 'next' && (
|
||||||
|
<datalist id={`nodelist-${node.id}`}>
|
||||||
|
{nodeIds.map(id => <option key={id} value={id} />)}
|
||||||
|
</datalist>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.inspector}>
|
||||||
|
<div className={styles.inspectorHeader}>
|
||||||
|
<span className={styles.nodeTypeBadge} style={{ background: '#1e66f5' }}>Line</span>
|
||||||
|
<span className={styles.nodeIdLabel}>{node.id}</span>
|
||||||
|
<button className={styles.deleteNodeBtn} onClick={() => deleteNode(dialogueId, node.id)} title="Delete node">🗑</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{issues.length > 0 && (
|
||||||
|
<div className={styles.issueList}>
|
||||||
|
{issues.map((issue, i) => (
|
||||||
|
<div key={i} className={issue.severity === 'error' ? styles.issueError : styles.issueWarning}>
|
||||||
|
{issue.severity === 'error' ? '⚠' : '!'} {issue.message}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{field('Node ID', 'id')}
|
||||||
|
<SpeakerField
|
||||||
|
speaker={node.speaker}
|
||||||
|
portrait={node.portrait}
|
||||||
|
onSpeakerChange={(s, p) => update({ speaker: s, portrait: p })}
|
||||||
|
/>
|
||||||
|
<AutoSaveTextArea
|
||||||
|
label="Text"
|
||||||
|
value={node.text}
|
||||||
|
placeholder="Dialogue line text..."
|
||||||
|
onSave={v => update({ text: v })}
|
||||||
|
/>
|
||||||
|
{field('Next node', 'next', 'node_id')}
|
||||||
|
|
||||||
|
{dialogue?.mobileMode && (
|
||||||
|
<div>
|
||||||
|
<label className={styles.label}>Chat Bubble</label>
|
||||||
|
<select
|
||||||
|
className={styles.select}
|
||||||
|
value={node.chatBubble ?? ''}
|
||||||
|
onChange={e => update({ chatBubble: e.target.value as 'in' | 'out' | undefined || undefined })}
|
||||||
|
>
|
||||||
|
<option value="">(auto)</option>
|
||||||
|
<option value="in">in</option>
|
||||||
|
<option value="out">out</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
className={styles.advancedToggle}
|
||||||
|
onClick={() => setShowAdvanced(v => !v)}
|
||||||
|
>
|
||||||
|
{showAdvanced ? '▼' : '▶'} Advanced triggers
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showAdvanced && (
|
||||||
|
<div className={styles.advancedSection}>
|
||||||
|
{field('questUnlock', 'questUnlock', 'quest_id')}
|
||||||
|
{field('objectiveComplete', 'objectiveComplete', 'group.objective')}
|
||||||
|
{field('objectiveVisible', 'objectiveVisible', 'group.objective')}
|
||||||
|
{field('questFail', 'questFail', 'quest_id')}
|
||||||
|
{field('questComplete', 'questComplete', 'quest_id')}
|
||||||
|
{field('luaCallback', 'luaCallback', 'on_event_name')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { CutsceneStartNode, EndNode } from '../../../types/dialogue';
|
||||||
|
import { useDialogueStore } from '../../../store/dialogueStore';
|
||||||
|
import styles from '../RightPanel.module.css';
|
||||||
|
|
||||||
|
interface CutsceneProps {
|
||||||
|
node: CutsceneStartNode;
|
||||||
|
dialogueId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CutsceneStartInspector({ node, dialogueId }: CutsceneProps) {
|
||||||
|
const { updateNode, deleteNode, file } = useDialogueStore();
|
||||||
|
const dialogue = file?.dialogues.find(d => d.id === dialogueId);
|
||||||
|
const nodeIds = dialogue?.nodes.map(n => n.id) ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.inspector}>
|
||||||
|
<div className={styles.inspectorHeader}>
|
||||||
|
<span className={styles.nodeTypeBadge} style={{ background: '#4a4a5a' }}>CutsceneStart</span>
|
||||||
|
<span className={styles.nodeIdLabel}>{node.id}</span>
|
||||||
|
<button className={styles.deleteNodeBtn} onClick={() => deleteNode(dialogueId, node.id)} title="Delete node">🗑</button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={styles.label}>Node ID</label>
|
||||||
|
<input className={styles.input} value={node.id} readOnly />
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<label className={styles.label}>Cutscene ID</label>
|
||||||
|
<input
|
||||||
|
className={styles.input}
|
||||||
|
value={node.cutsceneId}
|
||||||
|
placeholder="cutscene_id"
|
||||||
|
onChange={e => updateNode(dialogueId, node.id, { cutsceneId: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<label className={styles.label}>Next node</label>
|
||||||
|
<input
|
||||||
|
className={styles.input}
|
||||||
|
value={node.next}
|
||||||
|
placeholder="node_id"
|
||||||
|
onChange={e => updateNode(dialogueId, node.id, { next: e.target.value })}
|
||||||
|
list={`nodelist-cs-${node.id}`}
|
||||||
|
/>
|
||||||
|
<datalist id={`nodelist-cs-${node.id}`}>
|
||||||
|
{nodeIds.map(id => <option key={id} value={id} />)}
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EndProps {
|
||||||
|
node: EndNode;
|
||||||
|
dialogueId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EndInspector({ node, dialogueId }: EndProps) {
|
||||||
|
const { deleteNode } = useDialogueStore();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.inspector}>
|
||||||
|
<div className={styles.inspectorHeader}>
|
||||||
|
<span className={styles.nodeTypeBadge} style={{ background: '#f38ba8', color: '#1e1e2e' }}>End</span>
|
||||||
|
<span className={styles.nodeIdLabel}>{node.id}</span>
|
||||||
|
<button className={styles.deleteNodeBtn} onClick={() => deleteNode(dialogueId, node.id)} title="Delete node">🗑</button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={styles.label}>Node ID</label>
|
||||||
|
<input className={styles.input} value={node.id} readOnly />
|
||||||
|
</div>
|
||||||
|
<div className={styles.emptyHint} style={{ marginTop: 12 }}>
|
||||||
|
This is a terminal node. Dialogue ends here.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { SetFlagNode, FlagEffect } from '../../../types/dialogue';
|
||||||
|
import { useDialogueStore } from '../../../store/dialogueStore';
|
||||||
|
import { useValidation } from '../../../hooks/useValidation';
|
||||||
|
import styles from '../RightPanel.module.css';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
node: SetFlagNode;
|
||||||
|
dialogueId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SetFlagInspector({ node, dialogueId }: Props) {
|
||||||
|
const { updateNode, deleteNode, file } = useDialogueStore();
|
||||||
|
const { issuesByNodeId } = useValidation();
|
||||||
|
const issues = issuesByNodeId[node.id] ?? [];
|
||||||
|
|
||||||
|
const dialogue = file?.dialogues.find(d => d.id === dialogueId);
|
||||||
|
const nodeIds = dialogue?.nodes.map(n => n.id) ?? [];
|
||||||
|
|
||||||
|
function updateEffect(idx: number, patch: Partial<FlagEffect>) {
|
||||||
|
const effects = node.effects.map((e, i) => i === idx ? { ...e, ...patch } : e);
|
||||||
|
updateNode(dialogueId, node.id, { effects });
|
||||||
|
}
|
||||||
|
|
||||||
|
function addEffect() {
|
||||||
|
const effects: FlagEffect[] = [...node.effects, { flag: '', value: 1 }];
|
||||||
|
updateNode(dialogueId, node.id, { effects });
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeEffect(idx: number) {
|
||||||
|
const effects = node.effects.filter((_, i) => i !== idx);
|
||||||
|
updateNode(dialogueId, node.id, { effects });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.inspector}>
|
||||||
|
<div className={styles.inspectorHeader}>
|
||||||
|
<span className={styles.nodeTypeBadge} style={{ background: '#179299' }}>SetFlag</span>
|
||||||
|
<span className={styles.nodeIdLabel}>{node.id}</span>
|
||||||
|
<button className={styles.deleteNodeBtn} onClick={() => deleteNode(dialogueId, node.id)} title="Delete node">🗑</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{issues.length > 0 && (
|
||||||
|
<div className={styles.issueList}>
|
||||||
|
{issues.map((issue, i) => (
|
||||||
|
<div key={i} className={issue.severity === 'error' ? styles.issueError : styles.issueWarning}>
|
||||||
|
{issue.severity === 'error' ? '⚠' : '!'} {issue.message}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className={styles.label}>Node ID</label>
|
||||||
|
<input className={styles.input} value={node.id} readOnly />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<div className={styles.sectionHeader}>
|
||||||
|
<span className={styles.label}>Flag Effects</span>
|
||||||
|
<button className={styles.addBtn} onClick={addEffect}>+ Add</button>
|
||||||
|
</div>
|
||||||
|
{node.effects.map((e, idx) => (
|
||||||
|
<div key={idx} className={styles.clauseRow}>
|
||||||
|
<input
|
||||||
|
className={styles.inputSmall}
|
||||||
|
value={e.flag}
|
||||||
|
placeholder="flag_name"
|
||||||
|
onChange={ev => updateEffect(idx, { flag: ev.target.value })}
|
||||||
|
/>
|
||||||
|
<span className={styles.eqLabel}>=</span>
|
||||||
|
<input
|
||||||
|
className={styles.inputSmall}
|
||||||
|
value={String(e.value)}
|
||||||
|
placeholder="value"
|
||||||
|
onChange={ev => updateEffect(idx, { value: isNaN(Number(ev.target.value)) ? ev.target.value : Number(ev.target.value) })}
|
||||||
|
/>
|
||||||
|
<button className={styles.removeBtn} onClick={() => removeEffect(idx)}>×</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{node.effects.length === 0 && <div className={styles.emptyHint}>No effects defined.</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<label className={styles.label}>Next node</label>
|
||||||
|
<input
|
||||||
|
className={styles.input}
|
||||||
|
value={node.next}
|
||||||
|
placeholder="node_id"
|
||||||
|
onChange={e => updateNode(dialogueId, node.id, { next: e.target.value })}
|
||||||
|
list={`nodelist-sf-${node.id}`}
|
||||||
|
/>
|
||||||
|
<datalist id={`nodelist-sf-${node.id}`}>
|
||||||
|
{nodeIds.map(id => <option key={id} value={id} />)}
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { CHARACTER_PRESETS, CUSTOM_CHARACTER_LABEL } from '../../../constants/characters';
|
||||||
|
import styles from '../RightPanel.module.css';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
speaker: string;
|
||||||
|
portrait: string;
|
||||||
|
onSpeakerChange: (speaker: string, portrait: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SpeakerField({ speaker, portrait, onSpeakerChange }: Props) {
|
||||||
|
const isPreset = CHARACTER_PRESETS.some(p => p.label === speaker);
|
||||||
|
const [isCustom, setIsCustom] = useState(!isPreset);
|
||||||
|
|
||||||
|
function handleSelect(e: React.ChangeEvent<HTMLSelectElement>) {
|
||||||
|
const val = e.target.value;
|
||||||
|
if (val === CUSTOM_CHARACTER_LABEL) {
|
||||||
|
setIsCustom(true);
|
||||||
|
onSpeakerChange(speaker, portrait);
|
||||||
|
} else {
|
||||||
|
setIsCustom(false);
|
||||||
|
const preset = CHARACTER_PRESETS.find(p => p.label === val);
|
||||||
|
onSpeakerChange(val, preset?.portrait ?? '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className={styles.label}>Speaker</label>
|
||||||
|
<select
|
||||||
|
className={styles.select}
|
||||||
|
value={isCustom ? CUSTOM_CHARACTER_LABEL : speaker}
|
||||||
|
onChange={handleSelect}
|
||||||
|
>
|
||||||
|
{CHARACTER_PRESETS.map(p => (
|
||||||
|
<option key={p.label} value={p.label}>{p.label}</option>
|
||||||
|
))}
|
||||||
|
<option value={CUSTOM_CHARACTER_LABEL}>{CUSTOM_CHARACTER_LABEL}</option>
|
||||||
|
</select>
|
||||||
|
{isCustom && (
|
||||||
|
<input
|
||||||
|
className={styles.input}
|
||||||
|
style={{ marginTop: 4 }}
|
||||||
|
value={speaker}
|
||||||
|
placeholder="Speaker name"
|
||||||
|
onChange={e => onSpeakerChange(e.target.value, portrait)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<label className={styles.label} style={{ marginTop: 8 }}>Portrait path</label>
|
||||||
|
<input
|
||||||
|
className={styles.input}
|
||||||
|
value={portrait}
|
||||||
|
placeholder="resources/dialogue/portrait_..."
|
||||||
|
onChange={e => onSpeakerChange(speaker, e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { useAutoSave } from '../../../hooks/useAutoSave';
|
||||||
|
import styles from '../RightPanel.module.css';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
placeholder?: string;
|
||||||
|
onSave: (value: string) => void;
|
||||||
|
rows?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AutoSaveTextArea({ value, label, placeholder, onSave, rows = 4 }: Props) {
|
||||||
|
const [local, handleChange] = useAutoSave(value, onSave);
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className={styles.label}>{label}</label>
|
||||||
|
<textarea
|
||||||
|
className={styles.textarea}
|
||||||
|
value={local}
|
||||||
|
placeholder={placeholder}
|
||||||
|
rows={rows}
|
||||||
|
onChange={e => handleChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
|
import { Handle, Position, NodeProps } from '@xyflow/react';
|
||||||
|
import { ChoiceNode as ChoiceNodeData } from '../../types/dialogue';
|
||||||
|
import { useValidation } from '../../hooks/useValidation';
|
||||||
|
import { useDialogueStore } from '../../store/dialogueStore';
|
||||||
|
import styles from './nodes.module.css';
|
||||||
|
|
||||||
|
export const ChoiceNode = memo(({ data, selected }: NodeProps & { data: ChoiceNodeData }) => {
|
||||||
|
const { hasError, hasWarning } = useValidation();
|
||||||
|
const startId = useDialogueStore(s => {
|
||||||
|
const id = s.selectedDialogueId;
|
||||||
|
return s.file?.dialogues.find(d => d.id === id)?.start;
|
||||||
|
});
|
||||||
|
|
||||||
|
const isStart = data.id === startId;
|
||||||
|
const error = hasError(data.id);
|
||||||
|
const warning = !error && hasWarning(data.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
styles.node,
|
||||||
|
styles.choiceNode,
|
||||||
|
selected ? styles.selected : '',
|
||||||
|
error ? styles.hasError : '',
|
||||||
|
warning ? styles.hasWarning : '',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{isStart && <div className={styles.startBadge}>▶ START</div>}
|
||||||
|
<Handle type="target" position={Position.Top} id="target" className={styles.handle} />
|
||||||
|
<div className={styles.nodeHeader}>
|
||||||
|
<span className={styles.speakerName}>{data.speaker || '(no speaker)'}</span>
|
||||||
|
<span className={styles.nodeType}>Choice</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.nodeId}>{data.id}</div>
|
||||||
|
<div className={styles.nodeBody}>
|
||||||
|
{data.choices.length === 0 ? (
|
||||||
|
<span className={styles.emptyText}>(no choices)</span>
|
||||||
|
) : (
|
||||||
|
data.choices.map((choice, i) => (
|
||||||
|
<div key={choice.id} className={styles.choiceRow} style={{ position: 'relative' }}>
|
||||||
|
<span className={[
|
||||||
|
styles.choiceKindBadge,
|
||||||
|
choice.kind === 'Main' ? styles.kindMain : styles.kindOptional,
|
||||||
|
].join(' ')}>
|
||||||
|
{choice.kind}
|
||||||
|
</span>
|
||||||
|
<span className={styles.choiceText}>{choice.text || '(empty)'}</span>
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Bottom}
|
||||||
|
id={choice.id}
|
||||||
|
className={styles.choiceHandle}
|
||||||
|
style={{
|
||||||
|
left: `${((i + 1) / (data.choices.length + 1)) * 100}%`,
|
||||||
|
bottom: -8,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
|
import { Handle, Position, NodeProps } from '@xyflow/react';
|
||||||
|
import { ConditionNode as ConditionNodeData } from '../../types/dialogue';
|
||||||
|
import { useValidation } from '../../hooks/useValidation';
|
||||||
|
import styles from './nodes.module.css';
|
||||||
|
|
||||||
|
export const ConditionNode = memo(({ data, selected }: NodeProps & { data: ConditionNodeData }) => {
|
||||||
|
const { hasError, hasWarning } = useValidation();
|
||||||
|
const error = hasError(data.id);
|
||||||
|
const warning = !error && hasWarning(data.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
styles.node,
|
||||||
|
styles.conditionNode,
|
||||||
|
selected ? styles.selected : '',
|
||||||
|
error ? styles.hasError : '',
|
||||||
|
warning ? styles.hasWarning : '',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<Handle type="target" position={Position.Top} id="target" className={styles.handle} />
|
||||||
|
<div className={styles.nodeHeader}>
|
||||||
|
<span className={styles.speakerName}>Condition</span>
|
||||||
|
<span className={styles.nodeType}>If</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.nodeId}>{data.id}</div>
|
||||||
|
<div className={styles.nodeBody}>
|
||||||
|
{data.conditions.map((c, i) => (
|
||||||
|
<div key={i} className={styles.conditionRow}>
|
||||||
|
<span className={styles.flagPill}>{c.flag}</span>
|
||||||
|
<span className={styles.opBadge}>{c.op}</span>
|
||||||
|
<span className={styles.valuePill}>{String(c.value)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{data.conditions.length === 0 && (
|
||||||
|
<span className={styles.emptyText}>(no conditions)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={styles.conditionHandles}>
|
||||||
|
<span className={styles.trueLabel}>TRUE</span>
|
||||||
|
<span className={styles.falseLabel}>FALSE</span>
|
||||||
|
</div>
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Bottom}
|
||||||
|
id="true"
|
||||||
|
className={[styles.handle, styles.trueHandle].join(' ')}
|
||||||
|
style={{ left: '30%' }}
|
||||||
|
/>
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Bottom}
|
||||||
|
id="false"
|
||||||
|
className={[styles.handle, styles.falseHandle].join(' ')}
|
||||||
|
style={{ left: '70%' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
|
import { Handle, Position, NodeProps } from '@xyflow/react';
|
||||||
|
import { CutsceneStartNode as CutsceneStartNodeData } from '../../types/dialogue';
|
||||||
|
import { useValidation } from '../../hooks/useValidation';
|
||||||
|
import styles from './nodes.module.css';
|
||||||
|
|
||||||
|
export const CutsceneStartNode = memo(({ data, selected }: NodeProps & { data: CutsceneStartNodeData }) => {
|
||||||
|
const { hasError, hasWarning } = useValidation();
|
||||||
|
const error = hasError(data.id);
|
||||||
|
const warning = !error && hasWarning(data.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
styles.node,
|
||||||
|
styles.cutsceneNode,
|
||||||
|
selected ? styles.selected : '',
|
||||||
|
error ? styles.hasError : '',
|
||||||
|
warning ? styles.hasWarning : '',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<Handle type="target" position={Position.Top} id="target" className={styles.handle} />
|
||||||
|
<div className={styles.nodeHeader}>
|
||||||
|
<span className={styles.speakerName}>Cutscene</span>
|
||||||
|
<span className={styles.nodeType}>Scene</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.nodeId}>{data.id}</div>
|
||||||
|
<div className={styles.nodeBody}>
|
||||||
|
<span className={styles.textSnippet}>{data.cutsceneId || '(no cutscene ID)'}</span>
|
||||||
|
</div>
|
||||||
|
<Handle type="source" position={Position.Bottom} id="source" className={styles.handle} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
|
import { Handle, Position, NodeProps } from '@xyflow/react';
|
||||||
|
import { EndNode as EndNodeData } from '../../types/dialogue';
|
||||||
|
import styles from './nodes.module.css';
|
||||||
|
|
||||||
|
export const EndNode = memo(({ data, selected }: NodeProps & { data: EndNodeData }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
styles.node,
|
||||||
|
styles.endNode,
|
||||||
|
selected ? styles.selected : '',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<Handle type="target" position={Position.Top} id="target" className={styles.handle} />
|
||||||
|
<div className={styles.endLabel}>END</div>
|
||||||
|
<div className={styles.nodeId}>{data.id}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
|
import { Handle, Position, NodeProps } from '@xyflow/react';
|
||||||
|
import { LineNode as LineNodeData } from '../../types/dialogue';
|
||||||
|
import { useValidation } from '../../hooks/useValidation';
|
||||||
|
import { useDialogueStore } from '../../store/dialogueStore';
|
||||||
|
import { MAIN_CHARACTER } from '../../constants/characters';
|
||||||
|
import styles from './nodes.module.css';
|
||||||
|
|
||||||
|
export const LineNode = memo(({ data, selected }: NodeProps & { data: LineNodeData }) => {
|
||||||
|
const { hasError, hasWarning } = useValidation();
|
||||||
|
const startId = useDialogueStore(s => {
|
||||||
|
const id = s.selectedDialogueId;
|
||||||
|
return s.file?.dialogues.find(d => d.id === id)?.start;
|
||||||
|
});
|
||||||
|
|
||||||
|
const isMain = data.speaker === MAIN_CHARACTER;
|
||||||
|
const isStart = data.id === startId;
|
||||||
|
const error = hasError(data.id);
|
||||||
|
const warning = !error && hasWarning(data.id);
|
||||||
|
|
||||||
|
const triggers = [
|
||||||
|
data.chatBubble && `💬 ${data.chatBubble}`,
|
||||||
|
data.questUnlock && `🔓 quest`,
|
||||||
|
data.objectiveComplete && `✅ obj`,
|
||||||
|
data.objectiveVisible && `👁 obj`,
|
||||||
|
data.questFail && `❌ quest`,
|
||||||
|
data.questComplete && `🏁 quest`,
|
||||||
|
data.luaCallback && `⚡ lua`,
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
styles.node,
|
||||||
|
styles.lineNode,
|
||||||
|
isMain ? styles.mainChar : styles.otherChar,
|
||||||
|
selected ? styles.selected : '',
|
||||||
|
error ? styles.hasError : '',
|
||||||
|
warning ? styles.hasWarning : '',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{isStart && <div className={styles.startBadge}>▶ START</div>}
|
||||||
|
<Handle type="target" position={Position.Top} id="target" className={styles.handle} />
|
||||||
|
<div className={styles.nodeHeader}>
|
||||||
|
<span className={styles.speakerName}>{data.speaker || '(no speaker)'}</span>
|
||||||
|
<span className={styles.nodeType}>Line</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.nodeId}>{data.id}</div>
|
||||||
|
<div className={styles.nodeBody}>
|
||||||
|
{data.text ? (
|
||||||
|
<span className={styles.textSnippet}>{data.text}</span>
|
||||||
|
) : (
|
||||||
|
<span className={styles.emptyText}>(empty)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{triggers.length > 0 && (
|
||||||
|
<div className={styles.triggerRow}>
|
||||||
|
{triggers.map((t, i) => (
|
||||||
|
<span key={i} className={styles.triggerPill}>{t}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Handle type="source" position={Position.Bottom} id="source" className={styles.handle} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
|
import { Handle, Position, NodeProps } from '@xyflow/react';
|
||||||
|
import { SetFlagNode as SetFlagNodeData } from '../../types/dialogue';
|
||||||
|
import { useValidation } from '../../hooks/useValidation';
|
||||||
|
import styles from './nodes.module.css';
|
||||||
|
|
||||||
|
export const SetFlagNode = memo(({ data, selected }: NodeProps & { data: SetFlagNodeData }) => {
|
||||||
|
const { hasError, hasWarning } = useValidation();
|
||||||
|
const error = hasError(data.id);
|
||||||
|
const warning = !error && hasWarning(data.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
styles.node,
|
||||||
|
styles.setFlagNode,
|
||||||
|
selected ? styles.selected : '',
|
||||||
|
error ? styles.hasError : '',
|
||||||
|
warning ? styles.hasWarning : '',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<Handle type="target" position={Position.Top} id="target" className={styles.handle} />
|
||||||
|
<div className={styles.nodeHeader}>
|
||||||
|
<span className={styles.speakerName}>Set Flag</span>
|
||||||
|
<span className={styles.nodeType}>Flag</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.nodeId}>{data.id}</div>
|
||||||
|
<div className={styles.nodeBody}>
|
||||||
|
{data.effects.map((e, i) => (
|
||||||
|
<div key={i} className={styles.conditionRow}>
|
||||||
|
<span className={styles.flagPill}>{e.flag}</span>
|
||||||
|
<span className={styles.opBadge}>=</span>
|
||||||
|
<span className={styles.valuePill}>{String(e.value)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{data.effects.length === 0 && (
|
||||||
|
<span className={styles.emptyText}>(no effects)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Handle type="source" position={Position.Bottom} id="source" className={styles.handle} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { LineNode } from './LineNode';
|
||||||
|
import { ChoiceNode } from './ChoiceNode';
|
||||||
|
import { ConditionNode } from './ConditionNode';
|
||||||
|
import { SetFlagNode } from './SetFlagNode';
|
||||||
|
import { CutsceneStartNode } from './CutsceneStartNode';
|
||||||
|
import { EndNode } from './EndNode';
|
||||||
|
|
||||||
|
export const nodeTypes = {
|
||||||
|
Line: LineNode,
|
||||||
|
Choice: ChoiceNode,
|
||||||
|
Condition: ConditionNode,
|
||||||
|
SetFlag: SetFlagNode,
|
||||||
|
CutsceneStart: CutsceneStartNode,
|
||||||
|
End: EndNode,
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
.node {
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
background: #1e1e2e;
|
||||||
|
color: #cdd6f4;
|
||||||
|
font-family: 'Segoe UI', system-ui, sans-serif;
|
||||||
|
font-size: 12px;
|
||||||
|
min-width: 220px;
|
||||||
|
max-width: 260px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node.selected {
|
||||||
|
border-color: #89b4fa;
|
||||||
|
box-shadow: 0 0 0 3px rgba(137,180,250,0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node.hasError {
|
||||||
|
border-left: 4px solid #f38ba8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node.hasWarning {
|
||||||
|
border-left: 4px solid #f9e2af;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Header colors ---- */
|
||||||
|
.nodeHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 4px 4px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lineNode.mainChar .nodeHeader { background: #1e66f5; }
|
||||||
|
.lineNode.otherChar .nodeHeader { background: #40a02b; }
|
||||||
|
.choiceNode .nodeHeader { background: #df8e1d; }
|
||||||
|
.conditionNode .nodeHeader { background: #8839ef; }
|
||||||
|
.setFlagNode .nodeHeader { background: #179299; }
|
||||||
|
.cutsceneNode .nodeHeader { background: #4a4a5a; }
|
||||||
|
.endNode .nodeHeader { display: none; }
|
||||||
|
|
||||||
|
.speakerName {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #fff;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodeType {
|
||||||
|
font-size: 10px;
|
||||||
|
color: rgba(255,255,255,0.7);
|
||||||
|
font-weight: 500;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodeId {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #6c7086;
|
||||||
|
padding: 2px 10px 0;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Body ---- */
|
||||||
|
.nodeBody {
|
||||||
|
padding: 6px 10px 8px;
|
||||||
|
min-height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textSnippet {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: #cdd6f4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyText {
|
||||||
|
color: #6c7086;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Trigger pills ---- */
|
||||||
|
.triggerRow {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 0 8px 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.triggerPill {
|
||||||
|
background: rgba(255,255,255,0.08);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #a6adc8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Choice rows ---- */
|
||||||
|
.choiceRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 3px 0;
|
||||||
|
border-top: 1px solid rgba(255,255,255,0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.choiceKindBadge {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kindMain {
|
||||||
|
background: #fe640b;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kindOptional {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid #fe640b;
|
||||||
|
color: #fe640b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choiceText {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #cdd6f4;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Condition rows ---- */
|
||||||
|
.conditionRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flagPill {
|
||||||
|
background: rgba(137,180,250,0.15);
|
||||||
|
color: #89b4fa;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opBadge {
|
||||||
|
color: #cba6f7;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.valuePill {
|
||||||
|
background: rgba(166,227,161,0.15);
|
||||||
|
color: #a6e3a1;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Condition handles ---- */
|
||||||
|
.conditionHandles {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 2px 14px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trueLabel {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #a6e3a1;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-left: 12%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.falseLabel {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #f38ba8;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-right: 12%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- End node ---- */
|
||||||
|
.endNode {
|
||||||
|
min-width: 100px;
|
||||||
|
max-width: 120px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
background: #2a0e14;
|
||||||
|
border: 2px solid #f38ba8;
|
||||||
|
border-radius: 50px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.endLabel {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #f38ba8;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Start badge ---- */
|
||||||
|
.startBadge {
|
||||||
|
position: absolute;
|
||||||
|
top: -22px;
|
||||||
|
left: 10px;
|
||||||
|
background: #a6e3a1;
|
||||||
|
color: #1e1e2e;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px 4px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Handles ---- */
|
||||||
|
.handle {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
background: #6c7086;
|
||||||
|
border: 2px solid #1e1e2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trueHandle {
|
||||||
|
background: #a6e3a1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.falseHandle {
|
||||||
|
background: #f38ba8 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choiceHandle {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
background: #fab387 !important;
|
||||||
|
border: 2px solid #1e1e2e;
|
||||||
|
position: absolute !important;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { CharacterPreset } from '../types/dialogue';
|
||||||
|
|
||||||
|
export const MAIN_CHARACTER = 'Бекзат';
|
||||||
|
export const PHONE_PORTRAIT = 'resources/dialogue/portrait_phone.png';
|
||||||
|
|
||||||
|
export const CHARACTER_PRESETS: CharacterPreset[] = [
|
||||||
|
{ label: 'Бекзат', portrait: 'resources/dialogue/portrait_hero_neutral.png' },
|
||||||
|
{ label: 'Аида Джаныбекова', portrait: 'resources/dialogue/portrait_teacher.png' },
|
||||||
|
{ label: 'Айпери', portrait: 'resources/dialogue/portrait_aiperi.png' },
|
||||||
|
{ label: 'Призрак', portrait: 'resources/dialogue/portrait_ghost.png' },
|
||||||
|
{ label: 'Алик', portrait: 'resources/dialogue/portrait_student_boy.png' },
|
||||||
|
{ label: 'Студент', portrait: 'resources/dialogue/portrait_student_boy.png' },
|
||||||
|
{ label: 'Студентка', portrait: 'resources/dialogue/portrait_student_girl.png' },
|
||||||
|
{ label: 'Бермет', portrait: 'resources/dialogue/portrait_student_girl.png' },
|
||||||
|
{ label: 'Алтынай', portrait: 'resources/dialogue/portrait_student_girl.png' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const CUSTOM_CHARACTER_LABEL = '(custom)';
|
||||||
|
|
||||||
|
export function getPortraitForSpeaker(speaker: string): string {
|
||||||
|
const preset = CHARACTER_PRESETS.find(p => p.label === speaker);
|
||||||
|
return preset?.portrait ?? '';
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
export function useAutoSave(
|
||||||
|
value: string,
|
||||||
|
onSave: (value: string) => void,
|
||||||
|
delay = 600
|
||||||
|
): [string, (v: string) => void] {
|
||||||
|
const [local, setLocal] = useState(value);
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||||
|
const savedValueRef = useRef(value);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (value !== savedValueRef.current) {
|
||||||
|
savedValueRef.current = value;
|
||||||
|
setLocal(value);
|
||||||
|
}
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
const handleChange = (v: string) => {
|
||||||
|
setLocal(v);
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = setTimeout(() => {
|
||||||
|
savedValueRef.current = v;
|
||||||
|
onSave(v);
|
||||||
|
}, delay);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => () => clearTimeout(timerRef.current), []);
|
||||||
|
|
||||||
|
return [local, handleChange];
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useDialogueStore } from '../store/dialogueStore';
|
||||||
|
import { ValidationIssue } from '../types/dialogue';
|
||||||
|
|
||||||
|
export function useValidation(): {
|
||||||
|
issuesByNodeId: Record<string, ValidationIssue[]>;
|
||||||
|
hasError: (nodeId: string) => boolean;
|
||||||
|
hasWarning: (nodeId: string) => boolean;
|
||||||
|
} {
|
||||||
|
const issues = useDialogueStore(s => s.validationIssues);
|
||||||
|
|
||||||
|
const issuesByNodeId = useMemo(() => {
|
||||||
|
const map: Record<string, ValidationIssue[]> = {};
|
||||||
|
for (const issue of issues) {
|
||||||
|
if (!map[issue.nodeId]) map[issue.nodeId] = [];
|
||||||
|
map[issue.nodeId].push(issue);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [issues]);
|
||||||
|
|
||||||
|
const hasError = (nodeId: string) =>
|
||||||
|
issuesByNodeId[nodeId]?.some(i => i.severity === 'error') ?? false;
|
||||||
|
|
||||||
|
const hasWarning = (nodeId: string) =>
|
||||||
|
issuesByNodeId[nodeId]?.some(i => i.severity === 'warning') ?? false;
|
||||||
|
|
||||||
|
return { issuesByNodeId, hasError, hasWarning };
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
*, *::before, *::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body, #root {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||||
|
background: #1e1e2e;
|
||||||
|
color: #cdd6f4;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: #181825;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #45475a;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #585b70;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import App from './App';
|
||||||
|
import '@xyflow/react/dist/style.css';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { immer } from 'zustand/middleware/immer';
|
||||||
|
import {
|
||||||
|
DialogueFile,
|
||||||
|
Dialogue,
|
||||||
|
DialogueNode,
|
||||||
|
ValidationIssue,
|
||||||
|
EndNode,
|
||||||
|
} from '../types/dialogue';
|
||||||
|
import { computeLayout, PositionMap } from '../utils/layout';
|
||||||
|
import { validateDialogue } from '../utils/validation';
|
||||||
|
import { applyMobileTransforms } from '../utils/mobileMode';
|
||||||
|
import { serializeDialogueFile, triggerDownload } from '../utils/fileIO';
|
||||||
|
import { makeNodeId } from '../utils/idGen';
|
||||||
|
|
||||||
|
interface NodePosition { x: number; y: number }
|
||||||
|
|
||||||
|
interface DialogueStore {
|
||||||
|
file: DialogueFile | null;
|
||||||
|
fileName: string;
|
||||||
|
selectedDialogueId: string | null;
|
||||||
|
selectedNodeId: string | null;
|
||||||
|
positions: Record<string, Record<string, NodePosition>>;
|
||||||
|
playModeActive: boolean;
|
||||||
|
playState: { currentNodeId: string; flags: Record<string, number | string> } | null;
|
||||||
|
persistentFlags: Record<string, number | string>;
|
||||||
|
validationIssues: ValidationIssue[];
|
||||||
|
|
||||||
|
loadFile: (file: DialogueFile, name: string) => void;
|
||||||
|
exportFile: () => void;
|
||||||
|
|
||||||
|
selectDialogue: (id: string) => void;
|
||||||
|
createDialogue: (id: string) => void;
|
||||||
|
deleteDialogue: (id: string) => void;
|
||||||
|
duplicateDialogue: (id: string) => void;
|
||||||
|
setDialogueMobileMode: (id: string, value: boolean) => void;
|
||||||
|
|
||||||
|
selectNode: (id: string | null) => void;
|
||||||
|
updateNode: (dialogueId: string, nodeId: string, patch: Partial<DialogueNode>) => void;
|
||||||
|
addNode: (dialogueId: string, node: DialogueNode, afterNodeId?: string) => void;
|
||||||
|
deleteNode: (dialogueId: string, nodeId: string) => void;
|
||||||
|
setNodePosition: (dialogueId: string, nodeId: string, pos: NodePosition) => void;
|
||||||
|
applyAutoLayout: (dialogueId: string) => void;
|
||||||
|
|
||||||
|
startPlay: () => void;
|
||||||
|
stopPlay: () => void;
|
||||||
|
advancePlay: (nextNodeId: string) => void;
|
||||||
|
setPlayFlag: (flag: string, value: number | string) => void;
|
||||||
|
resetFlags: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDialogue(file: DialogueFile | null, id: string | null): Dialogue | undefined {
|
||||||
|
if (!file || !id) return undefined;
|
||||||
|
return file.dialogues.find(d => d.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function revalidate(state: DialogueStore): void {
|
||||||
|
const d = getDialogue(state.file, state.selectedDialogueId);
|
||||||
|
state.validationIssues = d ? validateDialogue(d) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useDialogueStore = create<DialogueStore>()(
|
||||||
|
immer((set, get) => ({
|
||||||
|
file: null,
|
||||||
|
fileName: 'dialogues.json',
|
||||||
|
selectedDialogueId: null,
|
||||||
|
selectedNodeId: null,
|
||||||
|
positions: {},
|
||||||
|
playModeActive: false,
|
||||||
|
playState: null,
|
||||||
|
persistentFlags: {},
|
||||||
|
validationIssues: [],
|
||||||
|
|
||||||
|
loadFile: (file, name) => set(state => {
|
||||||
|
state.file = file;
|
||||||
|
state.fileName = name;
|
||||||
|
state.selectedDialogueId = file.dialogues[0]?.id ?? null;
|
||||||
|
state.selectedNodeId = null;
|
||||||
|
state.positions = {};
|
||||||
|
state.playModeActive = false;
|
||||||
|
state.playState = null;
|
||||||
|
revalidate(state);
|
||||||
|
}),
|
||||||
|
|
||||||
|
exportFile: () => {
|
||||||
|
const { file, fileName } = get();
|
||||||
|
if (!file) return;
|
||||||
|
const clone = structuredClone(file);
|
||||||
|
clone.dialogues = clone.dialogues.map(d =>
|
||||||
|
d.mobileMode ? applyMobileTransforms(d) : d
|
||||||
|
);
|
||||||
|
const json = serializeDialogueFile(clone);
|
||||||
|
triggerDownload(json, fileName);
|
||||||
|
},
|
||||||
|
|
||||||
|
selectDialogue: (id) => set(state => {
|
||||||
|
state.selectedDialogueId = id;
|
||||||
|
state.selectedNodeId = null;
|
||||||
|
revalidate(state);
|
||||||
|
}),
|
||||||
|
|
||||||
|
createDialogue: (id) => set(state => {
|
||||||
|
if (!state.file) return;
|
||||||
|
const endNode: EndNode = { id: 'end_1', type: 'End' };
|
||||||
|
const newDialogue: Dialogue = {
|
||||||
|
id,
|
||||||
|
start: 'line_1',
|
||||||
|
nodes: [
|
||||||
|
{ id: 'line_1', type: 'Line', speaker: 'Бекзат', portrait: 'resources/dialogue/portrait_hero_neutral.png', text: '', next: 'end_1' },
|
||||||
|
endNode,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
state.file.dialogues.push(newDialogue);
|
||||||
|
state.selectedDialogueId = id;
|
||||||
|
state.selectedNodeId = null;
|
||||||
|
revalidate(state);
|
||||||
|
}),
|
||||||
|
|
||||||
|
duplicateDialogue: (id) => {
|
||||||
|
// Read raw data outside immer so structuredClone works on plain objects
|
||||||
|
const { file, positions } = get();
|
||||||
|
if (!file) return;
|
||||||
|
const source = file.dialogues.find(d => d.id === id);
|
||||||
|
if (!source) return;
|
||||||
|
|
||||||
|
const existingIds = new Set(file.dialogues.map(d => d.id));
|
||||||
|
const match = source.id.match(/^(.*?)(\d+)$/);
|
||||||
|
let newId: string;
|
||||||
|
if (match) {
|
||||||
|
const base = match[1];
|
||||||
|
const numStr = match[2];
|
||||||
|
let n = parseInt(numStr, 10) + 1;
|
||||||
|
const pad = numStr.length;
|
||||||
|
while (existingIds.has(`${base}${String(n).padStart(pad, '0')}`)) n++;
|
||||||
|
newId = `${base}${String(n).padStart(pad, '0')}`;
|
||||||
|
} else {
|
||||||
|
let n = 2;
|
||||||
|
while (existingIds.has(`${source.id}_${n}`)) n++;
|
||||||
|
newId = `${source.id}_${n}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clonedDialogue = structuredClone(source);
|
||||||
|
clonedDialogue.id = newId;
|
||||||
|
const clonedPositions = positions[id] ? structuredClone(positions[id]) : undefined;
|
||||||
|
|
||||||
|
set(state => {
|
||||||
|
state.file!.dialogues.push(clonedDialogue);
|
||||||
|
if (clonedPositions) state.positions[newId] = clonedPositions;
|
||||||
|
state.selectedDialogueId = newId;
|
||||||
|
state.selectedNodeId = null;
|
||||||
|
revalidate(state);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteDialogue: (id) => set(state => {
|
||||||
|
if (!state.file) return;
|
||||||
|
state.file.dialogues = state.file.dialogues.filter(d => d.id !== id);
|
||||||
|
if (state.selectedDialogueId === id) {
|
||||||
|
state.selectedDialogueId = state.file.dialogues[0]?.id ?? null;
|
||||||
|
state.selectedNodeId = null;
|
||||||
|
}
|
||||||
|
delete state.positions[id];
|
||||||
|
revalidate(state);
|
||||||
|
}),
|
||||||
|
|
||||||
|
setDialogueMobileMode: (id, value) => set(state => {
|
||||||
|
if (!state.file) return;
|
||||||
|
const d = state.file.dialogues.find(x => x.id === id);
|
||||||
|
if (d) d.mobileMode = value;
|
||||||
|
}),
|
||||||
|
|
||||||
|
selectNode: (id) => set(state => {
|
||||||
|
state.selectedNodeId = id;
|
||||||
|
}),
|
||||||
|
|
||||||
|
updateNode: (dialogueId, nodeId, patch) => set(state => {
|
||||||
|
if (!state.file) return;
|
||||||
|
const d = state.file.dialogues.find(x => x.id === dialogueId);
|
||||||
|
if (!d) return;
|
||||||
|
const idx = d.nodes.findIndex(n => n.id === nodeId);
|
||||||
|
if (idx === -1) return;
|
||||||
|
Object.assign(d.nodes[idx], patch);
|
||||||
|
revalidate(state);
|
||||||
|
}),
|
||||||
|
|
||||||
|
addNode: (dialogueId, node, afterNodeId) => set(state => {
|
||||||
|
if (!state.file) return;
|
||||||
|
const d = state.file.dialogues.find(x => x.id === dialogueId);
|
||||||
|
if (!d) return;
|
||||||
|
|
||||||
|
// Ensure unique ID
|
||||||
|
const existingIds = new Set(d.nodes.map(n => n.id));
|
||||||
|
if (existingIds.has(node.id)) {
|
||||||
|
node = { ...node, id: makeNodeId(node.type, existingIds) };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (afterNodeId) {
|
||||||
|
const idx = d.nodes.findIndex(n => n.id === afterNodeId);
|
||||||
|
if (idx !== -1) {
|
||||||
|
const prev = d.nodes[idx];
|
||||||
|
// Wire: prev.next → new node → old prev.next
|
||||||
|
if (
|
||||||
|
prev.type === 'Line' ||
|
||||||
|
prev.type === 'SetFlag' ||
|
||||||
|
prev.type === 'CutsceneStart'
|
||||||
|
) {
|
||||||
|
const oldNext = prev.next;
|
||||||
|
(d.nodes[idx] as typeof prev).next = node.id;
|
||||||
|
if (
|
||||||
|
(node.type === 'Line' ||
|
||||||
|
node.type === 'SetFlag' ||
|
||||||
|
node.type === 'CutsceneStart') &&
|
||||||
|
oldNext
|
||||||
|
) {
|
||||||
|
(node as typeof prev).next = oldNext;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
d.nodes.splice(idx + 1, 0, node);
|
||||||
|
} else {
|
||||||
|
d.nodes.push(node);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
d.nodes.push(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Place new node just below the anchor node in the graph
|
||||||
|
const anchorId = afterNodeId ?? null;
|
||||||
|
if (anchorId) {
|
||||||
|
const anchorPos = state.positions[dialogueId]?.[anchorId];
|
||||||
|
if (anchorPos) {
|
||||||
|
if (!state.positions[dialogueId]) state.positions[dialogueId] = {};
|
||||||
|
state.positions[dialogueId][node.id] = { x: anchorPos.x, y: anchorPos.y + 200 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidate(state);
|
||||||
|
}),
|
||||||
|
|
||||||
|
deleteNode: (dialogueId, nodeId) => set(state => {
|
||||||
|
if (!state.file) return;
|
||||||
|
const d = state.file.dialogues.find(x => x.id === dialogueId);
|
||||||
|
if (!d) return;
|
||||||
|
d.nodes = d.nodes.filter(n => n.id !== nodeId);
|
||||||
|
if (state.selectedNodeId === nodeId) state.selectedNodeId = null;
|
||||||
|
delete state.positions[dialogueId]?.[nodeId];
|
||||||
|
revalidate(state);
|
||||||
|
}),
|
||||||
|
|
||||||
|
setNodePosition: (dialogueId, nodeId, pos) => set(state => {
|
||||||
|
if (!state.positions[dialogueId]) state.positions[dialogueId] = {};
|
||||||
|
state.positions[dialogueId][nodeId] = pos;
|
||||||
|
}),
|
||||||
|
|
||||||
|
applyAutoLayout: (dialogueId) => set(state => {
|
||||||
|
if (!state.file) return;
|
||||||
|
const d = state.file.dialogues.find(x => x.id === dialogueId);
|
||||||
|
if (!d) return;
|
||||||
|
const layout: PositionMap = computeLayout(d.nodes);
|
||||||
|
if (!state.positions[dialogueId]) state.positions[dialogueId] = {};
|
||||||
|
Object.assign(state.positions[dialogueId], layout);
|
||||||
|
}),
|
||||||
|
|
||||||
|
startPlay: () => set(state => {
|
||||||
|
const d = getDialogue(state.file, state.selectedDialogueId);
|
||||||
|
if (!d) return;
|
||||||
|
state.playModeActive = true;
|
||||||
|
state.playState = { currentNodeId: d.start, flags: { ...state.persistentFlags } };
|
||||||
|
}),
|
||||||
|
|
||||||
|
stopPlay: () => set(state => {
|
||||||
|
state.playModeActive = false;
|
||||||
|
state.playState = null;
|
||||||
|
}),
|
||||||
|
|
||||||
|
advancePlay: (nextNodeId) => set(state => {
|
||||||
|
if (!state.playState) return;
|
||||||
|
state.playState.currentNodeId = nextNodeId;
|
||||||
|
}),
|
||||||
|
|
||||||
|
setPlayFlag: (flag, value) => set(state => {
|
||||||
|
if (!state.playState) return;
|
||||||
|
state.playState.flags[flag] = value;
|
||||||
|
state.persistentFlags[flag] = value;
|
||||||
|
}),
|
||||||
|
|
||||||
|
resetFlags: () => set(state => {
|
||||||
|
state.persistentFlags = {};
|
||||||
|
if (state.playState) state.playState.flags = {};
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
);
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
export type ChatBubble = 'in' | 'out';
|
||||||
|
export type ConditionOp = 'Equals' | 'NotEquals' | 'GreaterThan' | 'LessThan';
|
||||||
|
export type ChoiceKind = 'Main' | 'Optional';
|
||||||
|
export type ValidationSeverity = 'error' | 'warning';
|
||||||
|
|
||||||
|
export interface ChoiceOption {
|
||||||
|
id: string;
|
||||||
|
kind: ChoiceKind;
|
||||||
|
text: string;
|
||||||
|
next: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConditionClause {
|
||||||
|
flag: string;
|
||||||
|
op: ConditionOp;
|
||||||
|
value: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FlagEffect {
|
||||||
|
flag: string;
|
||||||
|
value: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LineNode {
|
||||||
|
id: string;
|
||||||
|
type: 'Line';
|
||||||
|
speaker: string;
|
||||||
|
portrait: string;
|
||||||
|
text: string;
|
||||||
|
next: string;
|
||||||
|
chatBubble?: ChatBubble;
|
||||||
|
questUnlock?: string;
|
||||||
|
objectiveComplete?: string;
|
||||||
|
objectiveVisible?: string;
|
||||||
|
questFail?: string;
|
||||||
|
questComplete?: string;
|
||||||
|
luaCallback?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChoiceNode {
|
||||||
|
id: string;
|
||||||
|
type: 'Choice';
|
||||||
|
speaker: string;
|
||||||
|
portrait: string;
|
||||||
|
text: string;
|
||||||
|
choices: ChoiceOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConditionNode {
|
||||||
|
id: string;
|
||||||
|
type: 'Condition';
|
||||||
|
conditions: ConditionClause[];
|
||||||
|
trueNext: string;
|
||||||
|
falseNext: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SetFlagNode {
|
||||||
|
id: string;
|
||||||
|
type: 'SetFlag';
|
||||||
|
effects: FlagEffect[];
|
||||||
|
next: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CutsceneStartNode {
|
||||||
|
id: string;
|
||||||
|
type: 'CutsceneStart';
|
||||||
|
cutsceneId: string;
|
||||||
|
next: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EndNode {
|
||||||
|
id: string;
|
||||||
|
type: 'End';
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DialogueNode =
|
||||||
|
| LineNode
|
||||||
|
| ChoiceNode
|
||||||
|
| ConditionNode
|
||||||
|
| SetFlagNode
|
||||||
|
| CutsceneStartNode
|
||||||
|
| EndNode;
|
||||||
|
|
||||||
|
export type NodeType = DialogueNode['type'];
|
||||||
|
|
||||||
|
export interface Dialogue {
|
||||||
|
id: string;
|
||||||
|
start: string;
|
||||||
|
nodes: DialogueNode[];
|
||||||
|
mobileMode?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DialogueFile {
|
||||||
|
dialogues: Dialogue[];
|
||||||
|
cutscenes: unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ValidationIssue {
|
||||||
|
nodeId: string;
|
||||||
|
severity: ValidationSeverity;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CharacterPreset {
|
||||||
|
label: string;
|
||||||
|
portrait: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { DialogueFile, Dialogue } from '../types/dialogue';
|
||||||
|
|
||||||
|
export function parseDialogueFile(json: string): DialogueFile {
|
||||||
|
const parsed = JSON.parse(json);
|
||||||
|
if (!Array.isArray(parsed.dialogues)) {
|
||||||
|
throw new Error('Invalid file: missing dialogues array');
|
||||||
|
}
|
||||||
|
if (!Array.isArray(parsed.cutscenes)) {
|
||||||
|
parsed.cutscenes = [];
|
||||||
|
}
|
||||||
|
return parsed as DialogueFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeDialogueFile(file: DialogueFile): string {
|
||||||
|
const clone = structuredClone(file);
|
||||||
|
clone.dialogues.forEach((d: Dialogue & { mobileMode?: boolean }) => {
|
||||||
|
delete d.mobileMode;
|
||||||
|
});
|
||||||
|
return JSON.stringify(clone, null, '\t');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function triggerDownload(content: string, filename: string): void {
|
||||||
|
const blob = new Blob([content], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { NodeType } from '../types/dialogue';
|
||||||
|
|
||||||
|
export function makeNodeId(type: NodeType, existingIds: Set<string>): string {
|
||||||
|
const base = type.toLowerCase();
|
||||||
|
let n = 1;
|
||||||
|
while (existingIds.has(`${base}_${n}`)) n++;
|
||||||
|
return `${base}_${n}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeDialogueId(existingIds: Set<string>): string {
|
||||||
|
let n = 1;
|
||||||
|
while (existingIds.has(`dialog_new${n}`)) n++;
|
||||||
|
return `dialog_new${n}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import dagre from '@dagrejs/dagre';
|
||||||
|
import { DialogueNode } from '../types/dialogue';
|
||||||
|
|
||||||
|
export type PositionMap = Record<string, { x: number; y: number }>;
|
||||||
|
|
||||||
|
const NODE_WIDTH = 260;
|
||||||
|
const NODE_HEIGHT = 130;
|
||||||
|
|
||||||
|
export function computeLayout(nodes: DialogueNode[]): PositionMap {
|
||||||
|
const g = new dagre.graphlib.Graph();
|
||||||
|
g.setDefaultEdgeLabel(() => ({}));
|
||||||
|
g.setGraph({ rankdir: 'TB', ranksep: 60, nodesep: 50 });
|
||||||
|
|
||||||
|
nodes.forEach(n => {
|
||||||
|
const h = n.type === 'Choice'
|
||||||
|
? NODE_HEIGHT + Math.max(0, n.choices.length - 1) * 28
|
||||||
|
: NODE_HEIGHT;
|
||||||
|
g.setNode(n.id, { width: NODE_WIDTH, height: h });
|
||||||
|
});
|
||||||
|
|
||||||
|
nodes.forEach(n => {
|
||||||
|
switch (n.type) {
|
||||||
|
case 'Line':
|
||||||
|
case 'SetFlag':
|
||||||
|
case 'CutsceneStart':
|
||||||
|
if (n.next) g.setEdge(n.id, n.next);
|
||||||
|
break;
|
||||||
|
case 'Choice':
|
||||||
|
n.choices.forEach(c => { if (c.next) g.setEdge(n.id, c.next); });
|
||||||
|
break;
|
||||||
|
case 'Condition':
|
||||||
|
if (n.trueNext) g.setEdge(n.id, n.trueNext);
|
||||||
|
if (n.falseNext) g.setEdge(n.id, n.falseNext);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
dagre.layout(g);
|
||||||
|
|
||||||
|
const positions: PositionMap = {};
|
||||||
|
nodes.forEach(n => {
|
||||||
|
const pos = g.node(n.id);
|
||||||
|
if (pos) {
|
||||||
|
const h = n.type === 'Choice'
|
||||||
|
? NODE_HEIGHT + Math.max(0, n.choices.length - 1) * 28
|
||||||
|
: NODE_HEIGHT;
|
||||||
|
positions[n.id] = { x: pos.x - NODE_WIDTH / 2, y: pos.y - h / 2 };
|
||||||
|
} else {
|
||||||
|
positions[n.id] = { x: 0, y: 0 };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return positions;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { Dialogue, DialogueNode } from '../types/dialogue';
|
||||||
|
import { MAIN_CHARACTER, PHONE_PORTRAIT } from '../constants/characters';
|
||||||
|
|
||||||
|
export function applyMobileTransforms(dialogue: Dialogue): Dialogue {
|
||||||
|
const clone = structuredClone(dialogue);
|
||||||
|
clone.nodes = clone.nodes.map((node): DialogueNode => {
|
||||||
|
if (node.type === 'Line') {
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
portrait: PHONE_PORTRAIT,
|
||||||
|
chatBubble: node.chatBubble ?? (node.speaker === MAIN_CHARACTER ? 'out' : 'in'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (node.type === 'Choice') {
|
||||||
|
return { ...node, portrait: PHONE_PORTRAIT };
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
});
|
||||||
|
return clone;
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { Dialogue, DialogueNode, ValidationIssue } from '../types/dialogue';
|
||||||
|
|
||||||
|
function getOutgoingNodeIds(node: DialogueNode): string[] {
|
||||||
|
switch (node.type) {
|
||||||
|
case 'Line':
|
||||||
|
case 'SetFlag':
|
||||||
|
case 'CutsceneStart':
|
||||||
|
return node.next ? [node.next] : [];
|
||||||
|
case 'Choice':
|
||||||
|
return node.choices.map(c => c.next).filter(Boolean);
|
||||||
|
case 'Condition':
|
||||||
|
return [node.trueNext, node.falseNext].filter(Boolean);
|
||||||
|
case 'End':
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reachableFrom(startId: string, nodeMap: Map<string, DialogueNode>): Set<string> {
|
||||||
|
const visited = new Set<string>();
|
||||||
|
const stack = [startId];
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const id = stack.pop()!;
|
||||||
|
if (visited.has(id)) continue;
|
||||||
|
visited.add(id);
|
||||||
|
const node = nodeMap.get(id);
|
||||||
|
if (!node) continue;
|
||||||
|
for (const next of getOutgoingNodeIds(node)) {
|
||||||
|
if (!visited.has(next)) stack.push(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return visited;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateDialogue(dialogue: Dialogue): ValidationIssue[] {
|
||||||
|
const issues: ValidationIssue[] = [];
|
||||||
|
const nodeMap = new Map<string, DialogueNode>();
|
||||||
|
const duplicates = new Set<string>();
|
||||||
|
|
||||||
|
// 1. Duplicate IDs
|
||||||
|
for (const node of dialogue.nodes) {
|
||||||
|
if (nodeMap.has(node.id)) {
|
||||||
|
duplicates.add(node.id);
|
||||||
|
issues.push({ nodeId: node.id, severity: 'error', message: `Duplicate node ID: "${node.id}"` });
|
||||||
|
} else {
|
||||||
|
nodeMap.set(node.id, node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Missing start node
|
||||||
|
if (dialogue.start && !nodeMap.has(dialogue.start)) {
|
||||||
|
issues.push({ nodeId: '__dialogue__', severity: 'error', message: `Start node "${dialogue.start}" does not exist` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Broken references
|
||||||
|
for (const node of dialogue.nodes) {
|
||||||
|
if (duplicates.has(node.id)) continue;
|
||||||
|
|
||||||
|
if ((node.type === 'Line' || node.type === 'SetFlag' || node.type === 'CutsceneStart') && node.next) {
|
||||||
|
if (!nodeMap.has(node.next)) {
|
||||||
|
issues.push({ nodeId: node.id, severity: 'error', message: `"next" points to missing node "${node.next}"` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === 'Choice') {
|
||||||
|
for (const choice of node.choices) {
|
||||||
|
if (choice.next && !nodeMap.has(choice.next)) {
|
||||||
|
issues.push({ nodeId: node.id, severity: 'error', message: `Choice "${choice.text}" points to missing node "${choice.next}"` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === 'Condition') {
|
||||||
|
if (node.trueNext && !nodeMap.has(node.trueNext)) {
|
||||||
|
issues.push({ nodeId: node.id, severity: 'error', message: `"trueNext" points to missing node "${node.trueNext}"` });
|
||||||
|
}
|
||||||
|
if (node.falseNext && !nodeMap.has(node.falseNext)) {
|
||||||
|
issues.push({ nodeId: node.id, severity: 'error', message: `"falseNext" points to missing node "${node.falseNext}"` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Reachability from start
|
||||||
|
if (dialogue.start && nodeMap.has(dialogue.start)) {
|
||||||
|
const reachable = reachableFrom(dialogue.start, nodeMap);
|
||||||
|
|
||||||
|
// No End reachable
|
||||||
|
const hasEnd = [...reachable].some(id => nodeMap.get(id)?.type === 'End');
|
||||||
|
if (!hasEnd) {
|
||||||
|
issues.push({ nodeId: dialogue.start, severity: 'warning', message: 'No End node is reachable from the start' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Orphaned nodes
|
||||||
|
for (const node of dialogue.nodes) {
|
||||||
|
if (!reachable.has(node.id) && !duplicates.has(node.id)) {
|
||||||
|
issues.push({ nodeId: node.id, severity: 'warning', message: 'Node is not reachable from the start' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Empty text warnings
|
||||||
|
for (const node of dialogue.nodes) {
|
||||||
|
if ((node.type === 'Line' || node.type === 'Choice') && !node.text.trim()) {
|
||||||
|
issues.push({ nodeId: node.id, severity: 'warning', message: 'Node has empty text' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
Loaded 100 of 1213 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user