Compare commits
201
Commits
escape001
...
witcher001
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
4de8f78eca | ||
|
|
5dd964c740 | ||
|
|
b98631a8c0 | ||
|
|
d78286a9c2 | ||
|
|
635960007c | ||
|
|
dbf0af2cb2 | ||
|
|
cebbb8bb5d | ||
|
|
2ee288cbe0 | ||
|
|
348aa632dd | ||
|
|
3495d28edf | ||
|
|
c7b3f04cd4 | ||
|
|
eac914e073 | ||
|
|
44cc0fba67 | ||
|
|
0a073243ad | ||
|
|
393ebcd831 | ||
|
|
2bb7da2e37 | ||
|
|
b4b41e8620 | ||
|
|
3241ab3735 | ||
|
|
c655678486 | ||
|
|
4d9a5b0a6e | ||
|
|
52c4ad8ee0 |
No files matched your search
@@ -405,3 +405,7 @@ thirdparty
|
||||
proj-web/build
|
||||
proj-windows/build
|
||||
public
|
||||
web_resources/
|
||||
pc_resources/
|
||||
resources_hd/
|
||||
web_resources_x2/
|
||||
+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
|
||||
```
|
||||
|
||||
|
||||
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
|
||||
|
||||
```
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
```
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -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;
|
||||
}
|
||||
@@ -5,11 +5,11 @@ Convert a text-based bone animation file to the BSAF binary format.
|
||||
Usage:
|
||||
python convert_anim_to_binary.py <input.txt> <output.bin>
|
||||
|
||||
Binary format (BSAF v1) -- all values little-endian:
|
||||
Binary format (BSAF v2) -- all values little-endian:
|
||||
|
||||
HEADER
|
||||
4 bytes magic "BSAF"
|
||||
uint32 version (1)
|
||||
uint32 version (2)
|
||||
|
||||
BONES
|
||||
uint32 numBones
|
||||
@@ -21,6 +21,11 @@ Binary format (BSAF v1) -- all values little-endian:
|
||||
uint32 numChildren
|
||||
numChildren x int32 childIndices
|
||||
|
||||
BONE NAMES (v2+)
|
||||
per bone:
|
||||
uint32 nameLen
|
||||
nameLen bytes UTF-8 name (no terminator)
|
||||
|
||||
VERTICES
|
||||
uint32 numVertices
|
||||
numVertices x 3 x float positions
|
||||
@@ -248,7 +253,7 @@ def convert(input_path, output_path):
|
||||
with open(output_path, 'wb') as out:
|
||||
# Header
|
||||
out.write(b'BSAF')
|
||||
out.write(struct.pack('<I', 1))
|
||||
out.write(struct.pack('<I', 2))
|
||||
|
||||
# Bones
|
||||
out.write(struct.pack('<I', num_bones))
|
||||
@@ -262,6 +267,12 @@ def convert(input_path, output_path):
|
||||
for c in b['children']:
|
||||
out.write(struct.pack('<i', c))
|
||||
|
||||
# Bone names (v2+)
|
||||
for name in bone_names:
|
||||
name_bytes = name.encode('utf-8')
|
||||
out.write(struct.pack('<I', len(name_bytes)))
|
||||
out.write(name_bytes)
|
||||
|
||||
# Vertices
|
||||
out.write(struct.pack('<I', num_vertices))
|
||||
for v in vertices:
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Convert a text-based multi-mesh bone animation file to the BSMF binary format.
|
||||
|
||||
Usage:
|
||||
python convert_anim_to_binary_new.py <input.txt> <output.bin>
|
||||
|
||||
Binary format (BSMF v2) -- all values little-endian:
|
||||
|
||||
HEADER
|
||||
4 bytes magic "BSMF"
|
||||
uint32 version (2)
|
||||
|
||||
ARMATURE MATRIX
|
||||
16 x float 4x4 matrix (row-major)
|
||||
|
||||
BONES
|
||||
uint32 numBones
|
||||
per bone:
|
||||
3 x float boneStartWorld (from HEAD_LOCAL)
|
||||
float boneLength
|
||||
9 x float 3x3 rotation matrix (row-major)
|
||||
int32 parentIndex (-1 if none)
|
||||
uint32 numChildren
|
||||
numChildren x int32 childIndices
|
||||
|
||||
BONE NAMES
|
||||
per bone:
|
||||
uint32 nameLen
|
||||
nameLen bytes UTF-8 name (no terminator)
|
||||
|
||||
MESHES
|
||||
uint32 numMeshes
|
||||
per mesh:
|
||||
uint32 nameLength
|
||||
nameLength x char meshName (UTF-8, no null terminator)
|
||||
|
||||
VERTICES
|
||||
uint32 numVertices
|
||||
numVertices x 3 x float positions
|
||||
|
||||
UV COORDINATES
|
||||
uint32 numFaces
|
||||
numFaces x 6 x float 3 UV pairs per face (u0,v0,u1,v1,u2,v2)
|
||||
|
||||
NORMALS
|
||||
numVertices x 3 x float normals
|
||||
|
||||
TRIANGLES
|
||||
uint32 numTriangles
|
||||
numTriangles x 3 x int32 vertex indices
|
||||
|
||||
VERTEX WEIGHTS
|
||||
per vertex (numVertices):
|
||||
uint32 numGroups
|
||||
numGroups x (int32 boneIndex, float weight)
|
||||
|
||||
ANIMATION KEYFRAMES
|
||||
uint32 numKeyframes
|
||||
per keyframe:
|
||||
int32 frameNumber
|
||||
per bone (numBones, in index order 0..N-1):
|
||||
3 x float location
|
||||
16 x float 4x4 matrix (row-major)
|
||||
"""
|
||||
|
||||
import struct
|
||||
import re
|
||||
import sys
|
||||
|
||||
|
||||
def parse_floats(line):
|
||||
return [float(x) for x in re.findall(r'[-]?\d+\.\d+', line)]
|
||||
|
||||
|
||||
def parse_first_int(line):
|
||||
m = re.search(r'\d+', line)
|
||||
if m:
|
||||
return int(m.group())
|
||||
raise ValueError(f"No integer found in: {line}")
|
||||
|
||||
|
||||
def parse_children(line):
|
||||
return re.findall(r"'([^']+)'", line)
|
||||
|
||||
|
||||
def convert(input_path, output_path):
|
||||
with open(input_path, 'r', encoding='utf-8', errors='replace') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
idx = 0
|
||||
|
||||
def next_line():
|
||||
nonlocal idx
|
||||
line = lines[idx].rstrip()
|
||||
idx += 1
|
||||
return line
|
||||
|
||||
# --- Armature matrix header + 4 rows ---
|
||||
next_line() # "=== Armature Matrix ==="
|
||||
armature_matrix = []
|
||||
for _ in range(4):
|
||||
armature_matrix.extend(parse_floats(next_line())[:4])
|
||||
|
||||
# --- Bone count ---
|
||||
line = next_line() # "=== Armature Bones: 65"
|
||||
num_bones = parse_first_int(line)
|
||||
|
||||
bone_names = []
|
||||
bones = []
|
||||
bone_parent_names = []
|
||||
bone_children_names = []
|
||||
|
||||
for _ in range(num_bones):
|
||||
bone = {}
|
||||
|
||||
# "Bone: mixamorig:Hips"
|
||||
line = next_line()
|
||||
bone_name = line[6:]
|
||||
bone_names.append(bone_name)
|
||||
|
||||
# " HEAD_LOCAL: <Vector (x, y, z)>"
|
||||
line = next_line()
|
||||
bone['head'] = parse_floats(line)[:3]
|
||||
|
||||
# " TAIL_LOCAL: ..." -- skip
|
||||
next_line()
|
||||
|
||||
# " Length: 0.123"
|
||||
line = next_line()
|
||||
bone['length'] = parse_floats(line)[0]
|
||||
|
||||
# 3x3 matrix (3 rows)
|
||||
mat = []
|
||||
for _ in range(3):
|
||||
mat.extend(parse_floats(next_line()))
|
||||
bone['matrix_3x3'] = mat
|
||||
|
||||
# " Parent: None" or " Parent: boneName"
|
||||
line = next_line()
|
||||
if line == " Parent: None":
|
||||
bone_parent_names.append(None)
|
||||
else:
|
||||
bone_parent_names.append(line[10:])
|
||||
|
||||
# " Children: ['a', 'b'] or []"
|
||||
line = next_line()
|
||||
bone_children_names.append(parse_children(line))
|
||||
|
||||
bones.append(bone)
|
||||
|
||||
# Build name -> index map
|
||||
name_to_idx = {name: i for i, name in enumerate(bone_names)}
|
||||
|
||||
# Resolve parent / child indices
|
||||
for i in range(num_bones):
|
||||
if bone_parent_names[i] is None:
|
||||
bones[i]['parent'] = -1
|
||||
else:
|
||||
bones[i]['parent'] = name_to_idx[bone_parent_names[i]]
|
||||
bones[i]['children'] = [name_to_idx[c] for c in bone_children_names[i]]
|
||||
|
||||
# --- Multi-mesh header ---
|
||||
line = next_line() # "=== TOTAL MESHES TO EXPORT: 7 ==="
|
||||
num_meshes = parse_first_int(line)
|
||||
|
||||
meshes = []
|
||||
|
||||
for _ in range(num_meshes):
|
||||
# "=== Mesh Object: Name ==="
|
||||
line = next_line()
|
||||
m = re.match(r"===\s*Mesh Object:\s*(.+?)\s*===$", line)
|
||||
if not m:
|
||||
raise ValueError(f"Invalid mesh header: {line}")
|
||||
mesh_name = m.group(1)
|
||||
|
||||
# --- Vertices ---
|
||||
line = next_line() # "===Vertices: N"
|
||||
num_vertices = parse_first_int(line)
|
||||
|
||||
vertices = []
|
||||
for _ in range(num_vertices):
|
||||
vertices.append(parse_floats(next_line())[:3])
|
||||
|
||||
# --- UV Coordinates ---
|
||||
next_line() # "===UV Coordinates:"
|
||||
line = next_line() # "Face count: M"
|
||||
num_faces = parse_first_int(line)
|
||||
|
||||
uvs = []
|
||||
for _ in range(num_faces):
|
||||
next_line() # "Face N"
|
||||
next_line() # "UV Count: 3"
|
||||
face_uvs = []
|
||||
for _ in range(3):
|
||||
face_uvs.extend(parse_floats(next_line())[:2])
|
||||
uvs.append(face_uvs)
|
||||
|
||||
# --- Normals ---
|
||||
next_line() # "===Normals:"
|
||||
normals = []
|
||||
for _ in range(num_vertices):
|
||||
normals.append(parse_floats(next_line())[:3])
|
||||
|
||||
# --- Triangles ---
|
||||
line = next_line() # "===Triangles: M"
|
||||
num_triangles = parse_first_int(line)
|
||||
|
||||
triangles = []
|
||||
for _ in range(num_triangles):
|
||||
line = next_line()
|
||||
ints = [int(x) for x in re.findall(r'[-]?\d+', line)]
|
||||
triangles.append(ints[:3])
|
||||
|
||||
# --- Vertex Weights ---
|
||||
next_line() # "=== Vertex Weights (Max 5 bones per vertex) ==="
|
||||
vertex_weights = []
|
||||
for _ in range(num_vertices):
|
||||
next_line() # "Vertex N:"
|
||||
line = next_line() # "Vertex groups: K"
|
||||
num_groups = parse_first_int(line)
|
||||
|
||||
groups = []
|
||||
for _ in range(num_groups):
|
||||
line = next_line()
|
||||
m = re.search(r"'([^']+)'.*?([-]?\d+\.\d+)", line)
|
||||
bone_name = m.group(1)
|
||||
weight = float(m.group(2))
|
||||
groups.append((name_to_idx[bone_name], weight))
|
||||
|
||||
vertex_weights.append(groups)
|
||||
|
||||
meshes.append({
|
||||
'name': mesh_name,
|
||||
'num_vertices': num_vertices,
|
||||
'vertices': vertices,
|
||||
'num_faces': num_faces,
|
||||
'uvs': uvs,
|
||||
'normals': normals,
|
||||
'num_triangles': num_triangles,
|
||||
'triangles': triangles,
|
||||
'vertex_weights': vertex_weights,
|
||||
})
|
||||
|
||||
# --- Animation Keyframes ---
|
||||
next_line() # "=== Animation Keyframes ==="
|
||||
next_line() # "=== Bone Transforms per Keyframe ==="
|
||||
line = next_line() # "Keyframes: N"
|
||||
num_keyframes = parse_first_int(line)
|
||||
|
||||
keyframes = []
|
||||
for _ in range(num_keyframes):
|
||||
line = next_line() # "Frame: N"
|
||||
frame_number = parse_first_int(line)
|
||||
|
||||
bone_data = {}
|
||||
for _ in range(num_bones):
|
||||
line = next_line() # " Bone: mixamorig:Hips"
|
||||
bone_name = line.strip()
|
||||
if bone_name.startswith("Bone: "):
|
||||
bone_name = bone_name[6:]
|
||||
bone_idx = name_to_idx[bone_name]
|
||||
|
||||
# Location
|
||||
location = parse_floats(next_line())[:3]
|
||||
|
||||
# Rotation (skip)
|
||||
next_line()
|
||||
|
||||
# " Matrix:" (skip header)
|
||||
next_line()
|
||||
|
||||
# 4 rows of 4 floats
|
||||
matrix = []
|
||||
for _ in range(4):
|
||||
matrix.extend(parse_floats(next_line()))
|
||||
|
||||
bone_data[bone_idx] = {
|
||||
'location': location,
|
||||
'matrix': matrix,
|
||||
}
|
||||
|
||||
keyframes.append((frame_number, bone_data))
|
||||
|
||||
# ================================================================
|
||||
# Write binary file
|
||||
# ================================================================
|
||||
with open(output_path, 'wb') as out:
|
||||
# Header
|
||||
out.write(b'BSMF')
|
||||
out.write(struct.pack('<I', 2))
|
||||
|
||||
# Armature matrix (16 floats, row-major)
|
||||
out.write(struct.pack('<16f', *armature_matrix))
|
||||
|
||||
# Bones
|
||||
out.write(struct.pack('<I', num_bones))
|
||||
for i in range(num_bones):
|
||||
b = bones[i]
|
||||
out.write(struct.pack('<3f', *b['head']))
|
||||
out.write(struct.pack('<f', b['length']))
|
||||
out.write(struct.pack('<9f', *b['matrix_3x3']))
|
||||
out.write(struct.pack('<i', b['parent']))
|
||||
out.write(struct.pack('<I', len(b['children'])))
|
||||
for c in b['children']:
|
||||
out.write(struct.pack('<i', c))
|
||||
|
||||
# Bone names
|
||||
for name in bone_names:
|
||||
name_bytes = name.encode('utf-8')
|
||||
out.write(struct.pack('<I', len(name_bytes)))
|
||||
out.write(name_bytes)
|
||||
|
||||
# Meshes
|
||||
out.write(struct.pack('<I', num_meshes))
|
||||
for md in meshes:
|
||||
name_bytes = md['name'].encode('utf-8')
|
||||
out.write(struct.pack('<I', len(name_bytes)))
|
||||
out.write(name_bytes)
|
||||
|
||||
# Vertices
|
||||
out.write(struct.pack('<I', md['num_vertices']))
|
||||
for v in md['vertices']:
|
||||
out.write(struct.pack('<3f', *v))
|
||||
|
||||
# UV Coordinates
|
||||
out.write(struct.pack('<I', md['num_faces']))
|
||||
for uv in md['uvs']:
|
||||
out.write(struct.pack('<6f', *uv))
|
||||
|
||||
# Normals
|
||||
for n in md['normals']:
|
||||
out.write(struct.pack('<3f', *n))
|
||||
|
||||
# Triangles
|
||||
out.write(struct.pack('<I', md['num_triangles']))
|
||||
for t in md['triangles']:
|
||||
out.write(struct.pack('<3i', *t))
|
||||
|
||||
# Vertex weights
|
||||
for vw in md['vertex_weights']:
|
||||
out.write(struct.pack('<I', len(vw)))
|
||||
for bone_idx, weight in vw:
|
||||
out.write(struct.pack('<if', bone_idx, weight))
|
||||
|
||||
# Animation Keyframes
|
||||
out.write(struct.pack('<I', num_keyframes))
|
||||
for frame_num, bone_data in keyframes:
|
||||
out.write(struct.pack('<i', frame_num))
|
||||
for i in range(num_bones):
|
||||
bd = bone_data[i]
|
||||
out.write(struct.pack('<3f', *bd['location']))
|
||||
out.write(struct.pack('<16f', *bd['matrix']))
|
||||
|
||||
input_size = sum(len(l) for l in lines)
|
||||
import os
|
||||
output_size = os.path.getsize(output_path)
|
||||
print(f"Converted: {input_path} ({input_size:,} bytes text) -> {output_path} ({output_size:,} bytes binary)")
|
||||
print(f" Bones: {num_bones}, Meshes: {num_meshes}, Keyframes: {num_keyframes}")
|
||||
for md in meshes:
|
||||
print(f" - {md['name']}: {md['num_vertices']} verts, "
|
||||
f"{md['num_faces']} faces, {md['num_triangles']} tris")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if len(sys.argv) != 3:
|
||||
print(f"Usage: {sys.argv[0]} <input.txt> <output.bin>")
|
||||
sys.exit(1)
|
||||
|
||||
convert(sys.argv[1], sys.argv[2])
|
||||
@@ -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,54 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Convert an old single-mesh text animation file to the new multi-mesh text format.
|
||||
|
||||
The only structural difference is that the new format wraps the mesh block
|
||||
between these two extra headers before the "===Vertices:" line:
|
||||
|
||||
=== TOTAL MESHES TO EXPORT: 1 ===
|
||||
=== Mesh Object: Body ===
|
||||
|
||||
Usage:
|
||||
python convert_old_anim_to_new.py <input.txt> <output.txt> [mesh_name]
|
||||
|
||||
If mesh_name is omitted, "Body" is used.
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
|
||||
def convert(input_path, output_path, mesh_name="Body"):
|
||||
with open(input_path, 'r', encoding='utf-8', errors='replace') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
# Find the first "===Vertices:" line -- that's where the bone block ends
|
||||
# and the mesh block begins in the old format.
|
||||
insert_at = None
|
||||
for i, line in enumerate(lines):
|
||||
if line.lstrip().startswith("===Vertices:"):
|
||||
insert_at = i
|
||||
break
|
||||
|
||||
if insert_at is None:
|
||||
raise RuntimeError("Could not find '===Vertices:' line in input file")
|
||||
|
||||
header_lines = [
|
||||
"=== TOTAL MESHES TO EXPORT: 1 ===\n",
|
||||
f"=== Mesh Object: {mesh_name} ===\n",
|
||||
]
|
||||
|
||||
out_lines = lines[:insert_at] + header_lines + lines[insert_at:]
|
||||
|
||||
with open(output_path, 'w', encoding='utf-8') as out:
|
||||
out.writelines(out_lines)
|
||||
|
||||
print(f"Converted: {input_path} -> {output_path} (mesh name: {mesh_name})")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if len(sys.argv) not in (3, 4):
|
||||
print(f"Usage: {sys.argv[0]} <input.txt> <output.txt> [mesh_name]")
|
||||
sys.exit(1)
|
||||
|
||||
mesh_name = sys.argv[3] if len(sys.argv) == 4 else "Body"
|
||||
convert(sys.argv[1], sys.argv[2], mesh_name)
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,116 @@
|
||||
{
|
||||
"dialogues": [
|
||||
{
|
||||
"id": "dialog_student",
|
||||
"start": "line_1",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "line_1",
|
||||
"type": "Line",
|
||||
"speaker": "Студент",
|
||||
"portrait": "resources/w/avatar_student.png",
|
||||
"text": "В университете завелись призраки, мне страшно ходить на занятия.",
|
||||
"next": "line_2"
|
||||
},
|
||||
{
|
||||
"id": "line_2",
|
||||
"type": "Line",
|
||||
"speaker": "Hero",
|
||||
"portrait": "resources/w/gg/gg2_s_podsvetkoy5.png",
|
||||
"text": "Можешь рассказать подробнее?",
|
||||
"next": "line_3"
|
||||
},
|
||||
{
|
||||
"id": "line_3",
|
||||
"type": "Line",
|
||||
"speaker": "Студент",
|
||||
"portrait": "resources/w/avatar_student.png",
|
||||
"text": "Спроси у Мухтара байке, он все знает.",
|
||||
"next": "line_4"
|
||||
},
|
||||
{
|
||||
"id": "line_4",
|
||||
"type": "Line",
|
||||
"speaker": "Hero",
|
||||
"portrait": "resources/w/gg/gg2_s_podsvetkoy5.png",
|
||||
"text": "Хорошо.",
|
||||
"next": "end_1"
|
||||
},
|
||||
{
|
||||
"id": "end_1",
|
||||
"type": "End"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "dialog_mukhtar",
|
||||
"start": "line_1",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "line_1",
|
||||
"type": "Line",
|
||||
"speaker": "Мухтар байке",
|
||||
"portrait": "resources/w/avatar_unknown.png",
|
||||
"text": "Здравствуй, мы давно тебя ждем! Ты поможешь нам избавиться от призраков?",
|
||||
"next": "line_2"
|
||||
},
|
||||
{
|
||||
"id": "line_2",
|
||||
"type": "Line",
|
||||
"speaker": "Hero",
|
||||
"portrait": "resources/w/gg/gg2_s_podsvetkoy5.png",
|
||||
"text": "Где их найти?",
|
||||
"next": "line_3"
|
||||
},
|
||||
{
|
||||
"id": "line_3",
|
||||
"type": "Line",
|
||||
"speaker": "Мухтар байке",
|
||||
"portrait": "resources/w/avatar_unknown.png",
|
||||
"text": "Заходи в здание универа и поднимайся на второй этаж.",
|
||||
"next": "line_4"
|
||||
},
|
||||
{
|
||||
"id": "line_4",
|
||||
"type": "Line",
|
||||
"speaker": "Мухтар байке",
|
||||
"portrait": "resources/w/avatar_unknown.png",
|
||||
"text": "Ты их встретишь прямо там.",
|
||||
"next": "line_5"
|
||||
},
|
||||
{
|
||||
"id": "line_4",
|
||||
"type": "Line",
|
||||
"speaker": "Hero",
|
||||
"portrait": "resources/w/gg/gg2_s_podsvetkoy5.png",
|
||||
"text": "Хорошо, я скоро вернусь!",
|
||||
"next": "end_1"
|
||||
},
|
||||
{
|
||||
"id": "end_1",
|
||||
"type": "End"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "dialog_female_student",
|
||||
"start": "line_1",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "line_1",
|
||||
"type": "Line",
|
||||
"speaker": "Студентка",
|
||||
"portrait": "resources/w/avatar_girl.png",
|
||||
"text": "С этими призраками совсем невозможно ходить на лекции!",
|
||||
"next": "end_1"
|
||||
},
|
||||
{
|
||||
"id": "end_1",
|
||||
"type": "End"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "test_line_dialogue",
|
||||
"start": "line_1",
|
||||
"nodes": [
|
||||
@@ -43,34 +153,34 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "test_choice_dialogue",
|
||||
"id": "ghost_choice_dialogue",
|
||||
"start": "line_1",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "line_1",
|
||||
"type": "Line",
|
||||
"speaker": "Merchant",
|
||||
"portrait": "resources/ghost_avatar.png",
|
||||
"text": "What do you want?",
|
||||
"speaker": "Беспокойный Призрак",
|
||||
"portrait": "resources/w/avatar_ghost.png",
|
||||
"text": "Нечасто я вижу смертных, готовых разговаривать со мной.",
|
||||
"next": "choice_1"
|
||||
},
|
||||
{
|
||||
"id": "choice_1",
|
||||
"type": "Choice",
|
||||
"speaker": "Hero",
|
||||
"portrait": "resources/hero.png",
|
||||
"portrait": "resources/w/gg/gg2_s_podsvetkoy5.png",
|
||||
"text": "",
|
||||
"choices": [
|
||||
{
|
||||
"id": "main_1",
|
||||
"kind": "Main",
|
||||
"text": "Show me your goods.",
|
||||
"text": "Не мешай студентам учиться!",
|
||||
"next": "line_goods"
|
||||
},
|
||||
{
|
||||
"id": "optional_1",
|
||||
"kind": "Optional",
|
||||
"text": "Who are you?",
|
||||
"text": "Почему ты появился здесь?",
|
||||
"next": "line_who"
|
||||
}
|
||||
]
|
||||
@@ -78,17 +188,17 @@
|
||||
{
|
||||
"id": "line_goods",
|
||||
"type": "Line",
|
||||
"speaker": "Merchant",
|
||||
"portrait": "resources/ghost_avatar.png",
|
||||
"text": "Take a look.",
|
||||
"speaker": "Беспокойный Призрак",
|
||||
"portrait": "resources/w/avatar_ghost.png",
|
||||
"text": "Это моя месть студентам за то что они призвали меня.",
|
||||
"next": "end_1"
|
||||
},
|
||||
{
|
||||
"id": "line_who",
|
||||
"type": "Line",
|
||||
"speaker": "Merchant",
|
||||
"portrait": "resources/ghost_avatar.png",
|
||||
"text": "Just a trader passing through.",
|
||||
"speaker": "Беспокойный Призрак",
|
||||
"portrait": "resources/w/avatar_ghost.png",
|
||||
"text": "Группа студентов совершила ритуал и призвала меня сюда. Пока проклятие не спадет, я всегда буду здесь обитать.",
|
||||
"next": "choice_1"
|
||||
},
|
||||
{
|
||||
@@ -203,6 +313,32 @@
|
||||
"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": [
|
||||
@@ -239,7 +375,7 @@
|
||||
},
|
||||
{
|
||||
"speaker": "Ghost",
|
||||
"portrait": "resources/ghost_avatar.png",
|
||||
"portrait": "resources/w/avatar_ghost.png",
|
||||
"text": "Some memories never fade.",
|
||||
"durationMs": 2600,
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Loaded 100 of 1005 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user