Compare commits

..

278 Commits

Author SHA1 Message Date
Vladislav Khorev
a11c4614ea Resize Android even more, fixing crash when changing languages on Android 2026-08-01 20:18:18 +03:00
Vladislav Khorev
5cbe7a345e Android version bug fix 2026-08-01 19:42:21 +03:00
Vladislav Khorev
2add42c1cb Minor changes for the Windows non-steam version 2026-07-25 17:27:21 +03:00
Vladislav Khorev
4039c24372 change permissions 2026-07-25 16:58:18 +03:00
Vladislav Khorev
7df68febca Fixing minor stuff, working on Web version 2026-07-25 16:56:02 +03:00
Vladislav Khorev
5ecf5fb9d7 Adapting music to web, fixing scripts, fixing bug in initial state 2026-07-24 23:58:29 +03:00
Vladislav Khorev
c3f449b999 Prepare a version for Steam and fixing bugs 2026-07-22 10:59:20 +03:00
Vladislav Khorev
8a0a8390b2 Windows, make portable version + NSIS installer 2026-07-20 21:54:43 +03:00
Vladislav Khorev
d39f5ca68b Working on adapting for Windows 2026-07-20 20:48:41 +03:00
Vladislav Khorev
1c20333f30 Fixing UI bugs, some clean up 2026-07-20 18:09:16 +03:00
Vladislav Khorev
1d8e103690 Android build is successfully uploaded to Google Play 2026-07-19 23:21:23 +03:00
Vladislav Khorev
4c9ecb31d8 Fixing shadows going apeshit in dorm on Android 2026-07-19 22:34:03 +03:00
Vladislav Khorev
2e749ed26e Fixing Android stuff 2026-07-19 21:38:22 +03:00
Vladislav Khorev
fc93eb6680 Fixing android bugs and more 2026-07-19 19:49:50 +03:00
Vladislav Khorev
5b21ba615e Added localization to Android 2026-07-19 13:58:26 +03:00
Vladislav Khorev
02ba86331b Working on shadow bug 2026-07-19 13:29:35 +03:00
Vladislav Khorev
6a79e8e41e Fixing screen resize bugs 2026-07-19 13:07:39 +03:00
Vladislav Khorev
6406c4f258 Fixing bug with save game, sounds, and some hints are out of screen on Android 2026-07-19 12:19:28 +03:00
Vladislav Khorev
9f812f0318 Android fixing logs 2026-07-18 20:44:54 +03:00
Vladislav Khorev
adf9871861 Remove accidentally added files 2026-07-18 20:22:49 +03:00
Vladislav Khorev
825a56a4a4 Added music and perfom bug fixes 2026-07-18 20:21:31 +03:00
Vladislav Khorev
e5e3d7bb28 Working on Android build 2026-07-18 17:46:05 +03:00
Vladislav Khorev
e773b51985 Working on Android version 2026-07-18 13:19:08 +03:00
Vladislav Khorev
0975f3b9cd Some clean up, web version prepare 2026-07-17 21:27:47 +03:00
Vladislav Khorev
e9c888e2bf Fixing compilation bug 2026-07-16 16:49:52 +03:00
Vladislav Khorev
a743f653dd Prepare for release002, not tested yet though 2026-07-16 10:32:27 +03:00
Vladislav Khorev
3d3522b905 Fixing last bugs, prepare for store release 2026-07-15 22:35:06 +03:00
Vladislav Khorev
b6cd17449f Fixing minor bugs, added about screen 2026-07-15 20:22:12 +03:00
Vladislav Khorev
159fac02f0 Fixing bugs in UI animations, added credits 2026-07-15 18:14:01 +03:00
Vladislav Khorev
9f17d51fc8 Fixing bugs, change dialogue translations 2026-07-15 14:28:31 +03:00
Vladislav Khorev
ca73148f4b Fixing translations 2026-07-15 12:44:29 +03:00
Vladislav Khorev
0c24d52e6d Working in dialogs and some texture fixing 2026-07-15 12:34:42 +03:00
Vladislav Khorev
beb4fee0d8 Working on Linux/Steam OS version 2026-07-14 22:47:49 +03:00
Vladislav Khorev
a7d17d9afd Fixing logger and windows version - remove terminal 2026-07-14 20:39:41 +03:00
Vladislav Khorev
76287dda46 Added Steam support for Windows 2026-07-14 19:41:36 +03:00
Vladislav Khorev
9da0ae1016 Fixing small bugs 2026-07-14 18:39:44 +03:00
Vladislav Khorev
804e34ce12 Linux 2026-07-14 20:14:30 +06:00
Vladislav Khorev
34a55611d5 Some clean up 2026-07-14 14:23:00 +03:00
Vladislav Khorev
35a2a91178 Working on taxi car 2026-07-14 14:07:53 +03:00
Vladislav Khorev
38e45d67b8 Working on viewing distance for enemies 2026-07-12 19:09:26 +03:00
Vladislav Khorev
33c2b313af Improving the battle, fixing bugs 2026-07-12 18:35:46 +03:00
Vladislav Khorev
92929118d5 Fixing some stuff related to attacks 2026-07-12 16:55:23 +03:00
Vladislav Khorev
9d03b52c2a Fixing localization bugs, working with graphics settings 2026-07-12 16:02:09 +03:00
Vladislav Khorev
d4365728fe Fixing minor bugs in texts, encoding, UI 2026-07-12 12:07:58 +03:00
Vladislav Khorev
79413e7319 Fixing lots of UI bugs 2026-07-10 21:30:59 +03:00
Vladislav Khorev
8e745ce0cd Working on fixing scripting bugs' 2026-07-10 17:00:10 +03:00
Vladislav Khorev
7dadfd8ed8 Fixing dorm script bugs 2026-07-10 15:08:38 +03:00
Vladislav Khorev
07f45ad1e1 Working on stuff 2026-07-10 14:09:31 +03:00
Vladislav Khorev
17d903fd52 Rewrite callbacks from scripts 2026-07-10 13:24:23 +03:00
Vladislav Khorev
2d3aa22521 Working on icons and pics 2026-07-10 12:26:08 +03:00
Vladislav Khorev
5cde7078f9 Fixing books rotation and books appearance in darklands 2026-07-09 22:12:24 +03:00
Vladislav Khorev
44f5136f1d Working on loading screen 2026-07-09 20:32:57 +03:00
Vladislav Khorev
1c2c80f0ea Working on reset state and the end game 2026-07-08 16:24:06 +03:00
Vladislav Khorev
124208f2a4 Added sounds 2026-07-07 23:05:01 +03:00
Vladislav Khorev
c6ba8bd008 Working on translations and minor bugs fix 2026-07-07 20:27:46 +03:00
Vladislav Khorev
a40a582b5a Working on translations in portrait mode 2026-07-07 19:56:08 +03:00
Vladislav Khorev
cdaebc3c0c Working more on translations, fixing tree bugs 2026-07-07 19:03:58 +03:00
Vladislav Khorev
e7dc3279ae Adding translations for English for UI' 2026-07-07 16:39:04 +03:00
Vladislav Khorev
08eac60657 Started working on UI localization 2026-07-07 13:46:28 +03:00
Vladislav Khorev
ef5b142425 Added simple localizations for locations 2026-07-07 12:11:34 +03:00
Vladislav Khorev
e2d9105b02 Working on translations 2026-07-07 11:24:21 +03:00
Vladislav Khorev
9d8ca865c7 Minor refactoring 2026-07-07 09:46:15 +03:00
Vladislav Khorev
794a820be9 Working on translations for dialogs and cutscenes 2026-07-05 21:52:37 +03:00
Vladislav Khorev
a98f71676d Preparing for translations 2026-07-05 17:53:13 +03:00
Vladislav Khorev
bd994a8690 Mainly dialogues are completed 2026-07-05 17:01:56 +03:00
Vladislav Khorev
2d8f80b675 Fixing dialogues 2026-07-05 14:48:46 +03:00
Vladislav Khorev
8a6366098c Working on new dialogues 2026-07-04 22:45:47 +03:00
Vladislav Khorev
f7e63f933b Working on minor stuff, arts, settings, cutscenes 2026-07-04 17:33:01 +03:00
Vladislav Khorev
5516d02e2c Fixing ui bugs, added cutscenes 2026-06-27 22:05:19 +03:00
Vladislav Khorev
9aeb0b1194 Working on hints on portrait mode 2026-06-27 17:21:31 +03:00
Vladislav Khorev
40e7e0ee44 Fixing hints for portrait mode 2026-06-27 15:14:03 +03:00
Vladislav Khorev
763ba9eeac Fixing clicking on phone 2026-06-27 13:50:39 +03:00
Vladislav Khorev
bdad5fa107 Portrait mode for inventory screen 2026-06-27 13:07:16 +03:00
Vladislav Khorev
1b172460e0 Working on journal portrait mode 2026-06-27 11:50:09 +03:00
Vladislav Khorev
b41e312511 Working on portrait mode 2026-06-26 21:25:53 +03:00
Vladislav Khorev
8c42179576 Working on messenger in portrait mode 2026-06-26 20:04:08 +03:00
Vladislav Khorev
0b4df8ab2d Working on portrait mode 2026-06-25 17:30:58 +03:00
Vladislav Khorev
6b39cfca3b Working on portrait mode 2026-06-25 12:13:02 +03:00
Vladislav Khorev
b8d79c7b7b Working on music cross fade and settings 2026-06-24 20:58:31 +03:00
Vladislav Khorev
84f9fa37a8 Added music 2026-06-21 21:27:27 +03:00
Vladislav Khorev
7db9ca8f70 3d model optimization 2026-06-21 20:25:27 +03:00
Vladislav Khorev
51977a9eea Fixing bugs, navigations, adding cutscenes 2026-06-21 20:00:04 +03:00
Vladislav Khorev
130e4411cb New meshes and new trees 2026-06-21 15:15:40 +03:00
Vladislav Khorev
d7db0faea3 Working on save-load system and UI 2026-06-20 21:41:07 +03:00
Vladislav Khorev
901142385d Working on new UI 2026-06-20 12:42:10 +03:00
Vladislav Khorev
2807d82d51 Working on game state 2026-06-19 18:39:10 +03:00
Vladislav Khorev
b01e970554 Working on state for location 2026-06-18 21:48:27 +03:00
Vladislav Khorev
e0968b5522 Working on game states 2026-06-18 20:51:44 +03:00
Vladislav Khorev
0ca00f965d Minor code change for CharacterState 2026-06-18 20:22:25 +03:00
Vladislav Khorev
0eb9d0be25 Adapt for web, make CharacterState class 2026-06-18 20:02:59 +03:00
Vladislav Khorev
c891161d69 Working on fixing dorm bugs and some uni 2026-06-16 14:05:18 +03:00
Vladislav Khorev
939136c705 Fixing bug with night light in the dorms 2026-06-15 22:42:53 +03:00
Vladislav Khorev
bba493ee7c Fixing lights and shadows 2026-06-15 21:25:54 +03:00
Vladislav Khorev
9f4d60036d Working on camera positioning 2026-06-15 12:06:03 +03:00
Vladislav Khorev
020f31e1e4 Working on cutscenes and pathfinding 2026-06-14 22:31:11 +03:00
Vladislav Khorev
80825e3220 Working on 3d models in uni interior location 2026-06-14 21:29:33 +03:00
Vladislav Khorev
b80e3fdc5e Working on web version and also improving dorm 2026-06-14 19:02:06 +03:00
Vladislav Khorev
5d427af9f7 Working on script to reduce bones 2026-06-14 14:11:44 +03:00
Vladislav Khorev
db456bde09 Minor dialogue and UI bug fixing 2026-06-13 20:58:44 +03:00
Vladislav Khorev
fff5a9d4aa Fixing dialogues and cutscenes, minor bug fixing 2026-06-13 15:07:09 +03:00
Vladislav Khorev
2b32fce5e0 Working on UI fixing 2026-06-12 21:04:19 +03:00
Vladislav Khorev
7031a21922 Fixing UI bugs, updating web UI 2026-06-12 20:18:43 +03:00
Vladislav Khorev
dee9615483 Working on UI fixing 2026-06-12 14:40:41 +03:00
Vladislav Khorev
a4032d769e Adapting for web 2026-06-12 10:28:12 +03:00
Vladislav Khorev
a9f4025333 Fixing inconsistency between day and nights on uni_int location 2026-06-11 21:19:39 +03:00
Vladislav Khorev
f52b6872b0 Working on navigation 2026-06-10 12:21:49 +03:00
Vladislav Khorev
87fb6d4655 Fixing minor bugs in scripts 2026-06-09 23:57:27 +03:00
Vladislav Khorev
80133aa8c8 Working on main menu and tutorials 2026-06-09 17:20:03 +03:00
Vladislav Khorev
0ed7021e90 Working on teleports 2026-06-09 14:43:04 +03:00
Vladislav Khorev
b94f02bd77 Working on taxi logic 2026-06-09 14:06:43 +03:00
Vladislav Khorev
77c8471636 Fixing navigation 2026-06-09 12:51:11 +03:00
Vladislav Khorev
dcdeae75bc fixing minor bug 2026-06-08 22:30:33 +03:00
Vladislav Khorev
619de45221 Clean up UI, work on tutorial 2026-06-08 22:09:24 +03:00
Vladislav Khorev
cfbd29d5b1 Working on cutscenes, minor fixing 2026-06-07 15:50:14 +03:00
Vladislav Khorev
6349859e66 working on cutscenes 2026-06-07 14:40:15 +03:00
Vladislav Khorev
54cc118df7 Some base for cutscene 2026-06-06 23:00:22 +03:00
Vladislav Khorev
3ba8a2f44b Cleanup 2026-06-06 22:20:08 +03:00
Vladislav Khorev
2e5988f8f4 Refactoring cutscenes 2026-06-06 22:04:42 +03:00
Vladislav Khorev
7011bbb754 Working on cutscene skip 2026-06-06 21:21:56 +03:00
Vladislav Khorev
c991b5dbc1 Added shadow, working on UI adapting 2026-06-06 20:32:09 +03:00
Vladislav Khorev
f61a12f874 Fixing bug in inventory 2026-06-06 18:40:40 +03:00
Vladislav Khorev
0cc384028f Working on web version 2026-06-06 16:37:11 +03:00
Vladislav Khorev
597ed04fdc Working on optimizations and navigation 2026-06-06 14:24:16 +03:00
Vladislav Khorev
e96c9f171f Web version clean up 2026-06-06 12:44:06 +03:00
Vladislav Khorev
1a00e3d4a2 Working on scripts, make game finished 2026-06-05 22:02:40 +03:00
Vladislav Khorev
8f7797789d Working on dialog editor, on dialogs, on phone 2026-06-05 21:17:51 +03:00
Vladislav Khorev
0e27521bea Minor bug fixing 2026-06-05 12:31:00 +03:00
Vladislav Khorev
1fc8120ee0 Working on messenger 2026-06-05 12:13:02 +03:00
Vladislav Khorev
9221ab68ac Working on night mode and how player goes in 2026-06-04 21:41:32 +03:00
Vladislav Khorev
1d3bea452c Added some dialogs for transitions 2026-06-04 19:12:18 +03:00
Vladislav Khorev
59e0b2d77a Working with phone and time 2026-06-04 18:21:58 +03:00
Vladislav Khorev
c8d743b932 Added dawn 2026-06-04 18:01:51 +03:00
Vladislav Khorev
83b92e9881 Working on night and day transition 2026-06-04 17:14:10 +03:00
Vladislav Khorev
ed8d6f2e92 Working with lights and shadows 2026-06-04 16:35:01 +03:00
Vladislav Khorev
e3542b59f7 Working on lights 2026-06-04 15:14:18 +03:00
Vladislav Khorev
442d3a7945 Working on the night mode 2026-06-04 13:27:52 +03:00
Vladislav Khorev
e6bfe85e2e Working on phone and bank account 2026-06-03 22:53:47 +03:00
Vladislav Khorev
1b1ceac2fe Working on phone 2026-06-03 22:27:18 +03:00
Vladislav Khorev
7a02665305 Working on phone screens 2026-06-03 21:38:12 +03:00
Vladislav Khorev
8694b0087d Journal completed 2026-06-03 16:42:38 +03:00
Vladislav Khorev
e7ae148ead Working on quest system 2026-06-03 14:37:09 +03:00
Vladislav Khorev
aaae368a07 Working on doors interactive objects in dorm 2026-06-02 21:25:10 +03:00
Vladislav Khorev
65d19c577a Working on minor object fixing 2026-06-02 20:55:45 +03:00
Vladislav Khorev
89cbc293fc Working on objects 2026-06-02 15:47:05 +03:00
Vladislav Khorev
60b0ba69ba Working on updating positions for dorm location 2026-06-02 13:29:47 +03:00
Vladislav Khorev
4b868f47c2 Added editor functionality to edit bound boxes 2026-06-02 12:11:37 +03:00
Vladislav Khorev
22b0e1992f Working on health bar 2026-06-01 22:03:05 +03:00
Vladislav Khorev
8552725dfc Working on UI hints 2026-06-01 19:02:03 +03:00
Vladislav Khorev
3d4560bb51 Working on darklands mode 2026-05-31 21:57:18 +03:00
Vladislav Khorev
5f7e66f765 Fixing inventory and HUD screen 2026-05-31 21:11:58 +03:00
Vladislav Khorev
03681bedaf Working on UI, navigation, minor fixing 2026-05-31 18:33:39 +03:00
Vladislav Khorev
a0550daf4b Working on UI 2026-05-30 22:27:09 +03:00
Vladislav Khorev
9425f9b19a Working on phone messages 2026-05-30 21:27:25 +03:00
Vladislav Khorev
cdab525858 Working on chart 2026-05-30 15:43:53 +03:00
Vladislav Khorev
3f5c4a381b Working on phone ui 2026-05-29 21:18:44 +03:00
Vladislav Khorev
31130ec248 Working on stuff 2026-05-29 14:05:16 +03:00
Vladislav Khorev
c659293bf8 Working on UI 2026-05-29 12:44:55 +03:00
Vladislav Khorev
9adcde5c05 Working on UI journal 2026-05-28 20:30:36 +03:00
Vladislav Khorev
90c57932ec Working on inventory 2026-05-28 17:16:10 +03:00
Vladislav Khorev
dda9f34277 Working on inventory 2026-05-28 14:43:05 +03:00
Vladislav Khorev
586ffc99b6 Working on ui 2026-05-27 22:35:05 +03:00
Vladislav Khorev
1eee415b41 Working on new character 2026-05-27 18:14:21 +03:00
Vladislav Khorev
80af0e61ae Working on UI improvement 2026-05-27 17:30:41 +03:00
Vladislav Khorev
0da1ae124c Working on UI part 2026-05-26 22:21:05 +03:00
Vladislav Khorev
6c8fcf17b6 Updated dorm interior 2026-05-26 15:39:04 +03:00
Vladislav Khorev
cfafcfc944 Working on dorm env 2026-05-24 19:11:56 +03:00
Vladislav Khorev
26f596a149 Working on navigation 2026-05-24 14:08:52 +03:00
Vladislav Khorev
e1a191634c Adding furn to the uni interior 2026-05-23 22:44:43 +03:00
Vladislav Khorev
84c8a7a27a Dialog fixing 2026-05-22 22:32:05 +03:00
Vladislav Khorev
8c200974c2 Working on dorm navigation 2026-05-22 21:06:09 +03:00
Vladislav Khorev
715f2e8c31 Working on navigation 2026-05-21 22:00:11 +03:00
Vladislav Khorev
ffd5bd01a4 Working on location exterior 2026-05-21 20:30:49 +03:00
Vladislav Khorev
3a73f3fbde Improved pathfinding logic 2026-05-21 11:48:18 +03:00
Vladislav Khorev
2659951d8e Working on the player respawn correctly 2026-05-18 21:10:55 +03:00
Vladislav Khorev
27b4b19df4 Updating scripts 2026-05-18 15:21:42 +03:00
Vladislav Khorev
21bf3f0710 Fixing hp bugs 2026-05-18 09:17:05 +03:00
Vladislav Khorev
49501e32ce Working with scripts, testing new chair 2026-05-17 20:28:18 +03:00
Vladislav Khorev
4044acefb2 Working on scripts 2026-05-17 14:39:02 +03:00
Vladislav Khorev
c086fe1ef0 Added darklands 2026-05-16 18:32:23 +03:00
Vladislav Khorev
4e9eef1863 Fixing minor bugs 2026-05-15 21:32:09 +03:00
Vladislav Khorev
b973d714cc Navigation adjusting 2026-05-15 20:50:19 +03:00
Vladislav Khorev
e618c91415 Added covers, cross-fade 2026-05-15 14:59:58 +03:00
Vladislav Khorev
0f2b2e55af Working on doors and scripting 2026-05-14 21:28:41 +03:00
Vladislav Khorev
774397f690 adding doors 2026-05-14 19:54:58 +03:00
Vladislav Khorev
d6417eba1b Some new scripts, dialogs and animations 2026-05-14 16:43:06 +03:00
Vladislav Khorev
2cae9998a0 Major refactoring for game objects 2026-05-12 21:19:29 +03:00
Vladislav Khorev
efb85f7328 Some minor fixing 2026-05-12 19:32:57 +03:00
Vladislav Khorev
001077a1d6 More items 2026-05-11 22:29:26 +03:00
Vladislav Khorev
be05643b70 Some improvements, added cutscene stub 2026-05-09 23:31:49 +03:00
Vladislav Khorev
72e05cdc28 little cleanup 2026-05-09 20:28:25 +03:00
Vladislav Khorev
13ef92160d Added startup scripts 2026-05-09 14:14:04 +03:00
Vladislav Khorev
a479fa4ac1 Working on navigation system 2026-05-08 20:26:15 +03:00
Vladislav Khorev
d4bc73f732 Initial dialog, updated girl model 2026-05-08 19:17:47 +03:00
Vladislav Khorev
8214342248 Working on teleports 2026-05-07 20:53:20 +03:00
Vladislav Khorev
7e94009217 Working on new locations 2026-05-07 19:49:41 +03:00
Vladislav Khorev
fb7b42ddf9 Some clean up 2026-05-02 20:40:29 +03:00
Vladislav Khorev
6149182f02 Fixing bugs, added girl 2026-05-02 20:34:43 +03:00
Vladislav Khorev
d24748df1e Model optimization 2026-05-01 21:47:54 +03:00
Vladislav Khorev
be6a8cca9e working on texture manager 2026-05-01 21:00:51 +03:00
Vladislav Khorev
e04fcbb9bd Clean up 2026-05-01 20:26:50 +03:00
Vladislav Khorev
519d780b3c Clean up 2026-05-01 20:01:06 +03:00
Vladislav Khorev
16d250a51d merge 2026-05-01 19:03:13 +03:00
Vladislav Khorev
5231b50aab Merge branch 'witcher001-path' into witcher001 2026-05-01 16:53:25 +03:00
8b0a18a6cf corrected collision of tree 2026-05-01 16:01:32 +06:00
Vlad
4de8f78eca merge 2026-04-30 15:15:11 +06:00
Vlad
5dd964c740 speed 2026-04-30 14:20:10 +06:00
Vladislav Khorev
b98631a8c0 Fixing stuff 2026-04-30 11:08:06 +03:00
d78286a9c2 add quest journal (QuestJournal + split UI view) 2026-04-30 03:20:52 +06:00
Vlad
635960007c added slow-mo 2026-04-29 20:02:35 +06:00
dbf0af2cb2 implement dynamic text wrapping based on pixel width 2026-04-29 13:30:02 +06:00
cebbb8bb5d Added collision 2026-04-26 22:18:42 +06:00
Vladislav Khorev
2ee288cbe0 Added full screen mode 2026-04-25 21:17:40 +03:00
Vladislav Khorev
348aa632dd Code works more or less good 2026-04-25 21:11:07 +03:00
Vladislav Khorev
3495d28edf Fixing minor bugs 2026-04-25 20:30:27 +03:00
Vladislav Khorev
c7b3f04cd4 Working on fog, 2nd level, locations, dialogs 2026-04-25 19:56:00 +03:00
Vladislav Khorev
eac914e073 minor fixing, adapting for mobile web 2026-04-25 14:39:27 +03:00
Vladislav Khorev
44cc0fba67 Added hp and attack actions, and labels 2026-04-25 12:07:52 +03:00
0a073243ad safe LMB hold-to-skip cutscenes and multiple images with crossfade 2026-04-25 01:32:16 +06:00
Vladislav Khorev
393ebcd831 Added sparks 2026-04-24 19:53:18 +03:00
Vladislav Khorev
2bb7da2e37 Added hp 2026-04-24 18:40:51 +03:00
Vladislav Khorev
b4b41e8620 Added more animations 2026-04-24 17:39:32 +03:00
Vladislav Khorev
3241ab3735 Working on more animations 2026-04-23 22:24:33 +03:00
Vladislav Khorev
c655678486 Some strange shit happens 2026-04-22 22:38:20 +03:00
Vladislav Khorev
4d9a5b0a6e Set new type of animation 2026-04-22 20:45:40 +03:00
Vladislav Khorev
52c4ad8ee0 Working on new animations 2026-04-22 18:53:27 +03:00
Vladislav Khorev
fe01a5d1b2 Working on audio in web part 2 2026-04-17 17:14:44 +03:00
Vladislav Khorev
b6e0422d71 Working on audio in web part 1 2026-04-17 17:13:55 +03:00
Vladislav Khorev
3778a603f2 merge 2026-04-17 16:30:09 +03:00
Vladislav Khorev
e310822462 Working on web version 2026-04-17 16:13:56 +03:00
Vladislav Khorev
3d3c3b2a36 merge 2026-04-17 13:42:45 +03:00
Vlad
b5bcd3a812 added music 2026-04-17 14:23:56 +06:00
16e7580418 Merge branch 'witcher001-cutscene' of gitea.fishrungames.com:salmon-engine-projects/space-game001 into witcher001-cutscene 2026-04-17 02:33:56 +06:00
be4dcd4cf9 fix: make choice options the only clickable area in dialogue choices 2026-04-17 02:04:36 +06:00
Vladislav Khorev
a1dda3fd50 Major refactoring 2026-04-16 20:24:27 +03:00
Vladislav Khorev
92ba3f2b60 Some refactoring 2026-04-16 20:06:03 +03:00
Vladislav Khorev
f2f56125f0 Refactoring 2026-04-16 18:07:22 +03:00
Vladislav Khorev
2ef2e456f7 GPU skinning refactoring 2026-04-16 17:48:23 +03:00
Vladislav Khorev
83cceecccd Refactoring 2026-04-16 15:37:48 +03:00
Vladislav Khorev
24851a89e8 Merge 2026-04-16 15:16:45 +03:00
Vladislav Khorev
d47cfd1b6b Minor refactoring 2026-04-16 15:07:59 +03:00
Vladislav Khorev
71346b4227 Remove unused files 2026-04-16 14:03:52 +03:00
Vladislav Khorev
08e1174eaf Fixing minor bugs 2026-04-16 13:44:40 +03:00
Vladislav Khorev
cc2eba1352 merge 2026-04-16 13:24:04 +03:00
Vladislav Khorev
cfee60d71a merge 2026-04-16 13:11:01 +03:00
Vladislav Khorev
dc76986cd3 Added path showing 2026-04-16 11:39:14 +03:00
Vladislav Khorev
61665434ab Fixing minor bugs 2026-04-16 09:37:36 +03:00
2494a44e4a refactor: switch cutscene camera to viewport/UV-based system 2026-04-16 01:29:01 +06:00
Vladislav Khorev
d8795ec076 Added shadow maps 2026-04-15 19:54:21 +03:00
ceebb13719 improve dialogue advance and add flexible cutscene camera system 2026-04-15 20:42:41 +06:00
Vladislav Khorev
a47306533e Working on stuff 2026-04-15 16:51:55 +03:00
Vladislav Khorev
f708843747 Working on optimization: GPU skinning and binary animations 2026-04-15 16:39:33 +03:00
Vladislav Khorev
a589765b7e prepare to add anims 2026-04-15 16:38:30 +03:00
4eaefd25d4 added finding path with A* and json file to know where character can walk 2026-04-15 18:36:27 +06:00
Vladislav Khorev
eff7322140 Working on dialog portraits 2026-04-15 11:56:38 +03:00
Vlad
44dd09fcb5 fix inventory 2026-04-15 14:50:46 +06:00
Vladislav Khorev
de2e18e38f Fixing rotation issues in battle mode 2026-04-15 10:34:55 +03:00
Vladislav Khorev
984d7489d5 Working on enemies and attack 2026-04-15 10:00:16 +03:00
Vlad
f7e6f063d1 added dialog start radius and background by time in cutscenes 2026-04-14 18:07:46 +06:00
Vlad
db8b9b7a18 fix timetyper and dialog start by click on npc 2026-04-14 17:41:28 +06:00
Vladislav Khorev
493eb5a5d7 Working on battle animation 2026-04-13 23:19:03 +03:00
Vlad
422ff1fbe3 merge 2026-04-13 15:08:38 +06:00
6465549c3b fix: dialogue text now displays correctly 2026-04-13 02:16:25 +06:00
Vlad
34759326b9 added npc's gift 2026-04-12 14:52:37 +06:00
7125674308 add dialogue and cutscene system 2026-04-12 01:46:33 +06:00
Vlad
bc2997d86f added activeFunc for active obj 2026-04-11 16:18:59 +06:00
Vlad
943b6b4d21 added load npcs from json 2026-04-11 15:30:29 +06:00
Vlad
b3fda88784 added inventory & active object 2026-04-09 15:33:55 +06:00
Vlad
e1b491be73 added load from json game objects 2026-04-07 20:17:04 +06:00
Vladislav Khorev
178f69967b Working on web, change python to lua 2026-04-04 14:29:20 +03:00
Vladislav Khorev
90e2a369f4 Added attack animation for ghots 2026-04-03 21:54:40 +03:00
Vladislav Khorev
403b93e7f5 Added more scripts 2026-04-03 20:06:33 +03:00
Vladislav Khorev
63c9d7b35b Adding scripts 2026-04-03 19:46:43 +03:00
Vladislav Khorev
995e2c8e5b NPC code 2026-04-02 21:03:55 +03:00
Vladislav Khorev
2e4a7ab467 Some cleanup 2026-04-02 19:24:15 +03:00
Vladislav Khorev
a96d0b207a Added ai generated animation 2026-04-01 19:07:21 +03:00
Vladislav Khorev
7840bfdb4e Added viola 2026-04-01 13:02:39 +03:00
Vladislav Khorev
56ce826205 Remove unused references 2026-04-01 12:03:35 +03:00
Vladislav Khorev
5364b97235 Working on new scene 2026-04-01 12:03:16 +03:00
1188 changed files with 154201 additions and 43180 deletions

7
.gitattributes vendored
View File

@ -1,3 +1,10 @@
*.bmp filter=lfs diff=lfs merge=lfs -text
*.png filter=lfs diff=lfs merge=lfs -text
*.jpg filter=lfs diff=lfs merge=lfs -text
*.anim filter=lfs diff=lfs merge=lfs -text
*.wav filter=lfs diff=lfs merge=lfs -text
*.ogg filter=lfs diff=lfs merge=lfs -text
*.mp3 filter=lfs diff=lfs merge=lfs -text
*.bin filter=lfs diff=lfs merge=lfs -text
*.dll filter=lfs diff=lfs merge=lfs -text
*.so filter=lfs diff=lfs merge=lfs -text

9
.gitignore vendored
View File

@ -405,3 +405,12 @@ thirdparty
proj-web/build
proj-windows/build
public
web_resources/
pc_resources/
resources_hd/
web_resources_x2/
android_resources/
.artifacts/
*.zip

351
CUTSCENES.md Normal file
View File

@ -0,0 +1,351 @@
# Cutscene System
Cutscenes are defined in JSON and loaded by `CutsceneDatabase`. Each cutscene is a self-contained object with an array of animated image layers and optional subtitle lines.
The file can contain multiple cutscenes:
```json
{
"cutscenes": [
{ "id": "intro", ... },
{ "id": "ending", ... }
]
}
```
Cutscenes and dialogues are loaded from **separate files**:
```cpp
dialogueSystem.loadDatabase("resources/dialogue/uni_interior.json"); // dialogues
dialogueSystem.loadCutsceneDatabase("resources/dialogue/cutscenes.json"); // cutscenes
```
---
## Cutscene object
```json
{
"id": "intro_cutscene",
"skippable": true,
"durationMs": 8000,
"fadeOutMs": 500,
"fadeInMs": 500,
"endFadeOutMs": 500,
"endFadeInMs": 500,
"onFadeInCallback": "",
"imageSegments": [ ... ],
"lines": [ ... ]
}
```
| Property | Type | Default | Description |
|---|---|---|---|
| `id` | string | — | Unique identifier used to start the cutscene from C++ or dialogue (**required**) |
| `skippable` | bool | `true` | Whether the player can skip by holding LMB / touch |
| `durationMs` | int | `0` | Minimum content duration in ms. The cutscene will not end before this time even if all subtitle lines have finished. `0` means duration is determined solely by subtitle lines or `imageSegments.endMs` |
| `fadeOutMs` | int | `0` | Duration of the **opening fade** — game world fades to black before the cutscene images appear |
| `fadeInMs` | int | `0` | Duration of the **opening reveal** — cutscene images fade in from black after `fadeOutMs` |
| `endFadeOutMs` | int | `0` | Duration of the **closing fade** — cutscene fades to black at the end of content |
| `endFadeInMs` | int | `0` | Duration of the **closing reveal** — game world fades back in from black |
| `onFadeInCallback` | string | `""` | Lua function name called once the opening fade-in completes (fired after `fadeOutMs + fadeInMs` ms) |
| `imageSegments` | array | `[]` | Image layers with motion — see [Image segments](#image-segments) |
| `lines` | array | `[]` | Subtitle lines shown sequentially — see [Subtitle lines](#subtitle-lines) |
### Timing model
The total cutscene duration is:
```
contentDuration = max(durationMs, max(segment.endMs for all segments))
totalDuration = contentDuration + endFadeOutMs + endFadeInMs
```
The full timeline looks like this:
```
|-- fadeOutMs --|-- fadeInMs --|--- content plays (images + subtitles) ---|-- endFadeOutMs --|-- endFadeInMs --|
world→black black→images images→black black→world
```
---
## Image segments
Each entry in `imageSegments` describes one image layer: when it is visible, how it fades in/out, and how it animates from a start pose to an end pose.
```json
{
"path": "resources/cutscenes/bg_layer.png",
"width": 1280,
"height": 720,
"startMs": 0,
"endMs": 8000,
"fadeInMs": 300,
"fadeOutMs": 300,
"easing": "EaseInOutSine",
"from": { "centerX": 0.4, "centerY": 0.5, "scale": 1.1 },
"to": { "centerX": 0.6, "centerY": 0.5, "scale": 1.0 }
}
```
| Property | Type | Default | Description |
|---|---|---|---|
| `path` | string | — | Path to the PNG image (**required**) |
| `width` | int | `0` | Logical width used for all UV and aspect-ratio math. `0` uses the actual texture pixel width |
| `height` | int | `0` | Logical height. `0` uses the actual texture pixel height |
| `startMs` | int | `0` | Time (ms from cutscene start) when this layer becomes active |
| `endMs` | int | `0` | Time (ms) when this layer stops being active. Must be > `startMs` |
| `fadeInMs` | int | `0` | Alpha fades from 0 → 1 over this many ms after `startMs`. `0` = instant |
| `fadeOutMs` | int | `0` | Alpha fades from 1 → 0 over this many ms before `endMs`. `0` = instant |
| `easing` | string | `"Linear"` | Easing applied to the pose interpolation — see [Easing types](#easing-types) |
| `from` | pose object | center/1.0 | Pose at `startMs` — see [Image pose](#image-pose) |
| `to` | pose object | same as `from` | Pose at `endMs`. If omitted, the layer stays at `from` the whole time |
Multiple segments can be active at the same time. They are rendered **in declaration order** (first = bottom layer, last = top layer), which enables parallax layering.
---
## Image pose
A pose defines how an image is framed on screen at a given moment.
```json
{ "centerX": 0.5, "centerY": 0.5, "scale": 1.0 }
```
| Property | Type | Default | Description |
|---|---|---|---|
| `centerX` | float | `0.5` | Normalized X position (0 = left edge of image, 1 = right edge) of the point that is placed at the horizontal center of the screen |
| `centerY` | float | `0.5` | Normalized Y position (0 = top edge, 1 = bottom edge) placed at the screen center |
| `scale` | float | `1.0` | Zoom level. `1.0` = the image fills the screen exactly (aspect-ratio corrected). `2.0` = zoomed in 2×, showing half the image area |
The runtime interpolates all three values independently from `from` to `to` using the chosen easing.
**Coordinate clamping:** `centerX`/`centerY` are automatically clamped so the viewport never shows area outside the image. For a zoomed-in segment (`scale > 1`) you therefore have more freedom to pan; for `scale = 1.0` the center is locked to `0.5/0.5`.
### Pose intuition
| Goal | Config |
|---|---|
| Centered, no zoom | `{ "centerX": 0.5, "centerY": 0.5, "scale": 1.0 }` |
| Slightly zoomed in on center | `{ "centerX": 0.5, "centerY": 0.5, "scale": 1.2 }` |
| Pan left to right | `from: { "centerX": 0.3, "scale": 1.2 }``to: { "centerX": 0.7, "scale": 1.2 }` |
| Zoom out from close-up | `from: { "scale": 1.8 }``to: { "scale": 1.0 }` |
| Look at top portion | `{ "centerY": 0.2, "scale": 1.3 }` |
---
## Easing types
Controls the interpolation curve applied to pose animation between `from` and `to`.
| Value | Description |
|---|---|
| `"Linear"` | Constant speed (default) |
| `"EaseInSine"` | Slow start, fast end |
| `"EaseOutSine"` | Fast start, slow end |
| `"EaseInOutSine"` | Slow start and end, fast middle |
| `"EaseInQuad"` | Quadratic slow start |
| `"EaseOutQuad"` | Quadratic slow end |
| `"EaseInOutQuad"` | Quadratic slow start and end |
| `"EaseInCubic"` | Cubic slow start |
| `"EaseOutCubic"` | Cubic slow end |
| `"EaseInOutCubic"` | Cubic slow start and end |
For cinematic camera motion `"EaseInOutSine"` or `"EaseInOutCubic"` give the most natural feel.
---
## Subtitle lines
Lines are displayed sequentially on top of the cutscene images. Each line shows until its duration expires (or until the player advances, if `waitForConfirm` is set).
```json
{
"speaker": "Аида Дженибековна",
"text": "Здравствуйте, студенты.",
"durationMs": 3000,
"waitForConfirm": false,
"luaCallback": ""
}
```
| Property | Type | Default | Description |
|---|---|---|---|
| `speaker` | string | `""` | Speaker name shown above the subtitle text. Empty = no name bar |
| `text` | string | `""` | Subtitle text. Supports Cyrillic and any codepoint in `resources/symbols.txt` |
| `durationMs` | int | `0` | How long this line is displayed in ms. `0` = auto-computed from text length (~17 chars/sec, minimum 1500 ms) |
| `waitForConfirm` | bool | `false` | When `true`, the line waits for player input (tap/click/Enter) before advancing. No timer runs |
| `luaCallback` | string | `""` | Lua function name called when this line begins. Useful for triggering SFX, spawning effects, etc. |
Subtitle lines run on their own timer that is **independent** of the image segments. The cutscene ends when **both** subtitle lines are exhausted **and** `contentDuration` has elapsed.
---
## C++ API
### Starting a cutscene
```cpp
// Standalone cutscene (not part of a dialogue):
dialogueSystem.startCutscene("intro_cutscene");
// Skip the currently playing cutscene:
dialogueSystem.skipCutscene();
```
### Callbacks
```cpp
// Called when a cutscene begins:
dialogueSystem.setOnCutsceneStarted([]() { /* hide HUD, etc. */ });
// Called when a cutscene ends (receives the cutscene id):
dialogueSystem.setOnCutsceneFinished([](const std::string& id) {
// id == "intro_cutscene"
});
// Called when a subtitle line begins (receives luaCallback value):
dialogueSystem.setOnCutsceneLineStarted([](const std::string& fn) {
scriptEngine.callActivateFunction(fn);
});
// Called when the opening fade-in completes (receives onFadeInCallback value):
dialogueSystem.setOnCutsceneFadeInComplete([](const std::string& fn) {
scriptEngine.callActivateFunction(fn);
});
```
### Triggering from dialogue
A dialogue node of type `CutsceneStart` embeds a cutscene mid-conversation. Dialogue resumes at `next` when the cutscene ends.
```json
{
"id": "node_cutscene",
"type": "CutsceneStart",
"cutsceneId": "intro_cutscene",
"next": "node_after_cutscene"
}
```
---
## Full examples
### Minimal — static image, timed
```json
{
"id": "simple",
"durationMs": 4000,
"fadeOutMs": 300,
"fadeInMs": 300,
"endFadeOutMs": 300,
"endFadeInMs": 300,
"imageSegments": [
{
"path": "resources/cutscenes/city.png",
"width": 1280,
"height": 720,
"startMs": 0,
"endMs": 4000
}
]
}
```
### Two-layer parallax pan
Background moves slowly left-to-right; foreground character moves faster, creating depth.
```json
{
"id": "classroom_intro",
"durationMs": 8000,
"fadeOutMs": 500,
"fadeInMs": 500,
"endFadeOutMs": 500,
"endFadeInMs": 500,
"imageSegments": [
{
"path": "resources/cutscenes/classroom_bg.png",
"width": 1920,
"height": 1080,
"startMs": 0,
"endMs": 8000,
"fadeInMs": 400,
"easing": "EaseInOutSine",
"from": { "centerX": 0.4, "centerY": 0.5, "scale": 1.1 },
"to": { "centerX": 0.6, "centerY": 0.5, "scale": 1.0 }
},
{
"path": "resources/cutscenes/classroom_teacher.png",
"width": 1920,
"height": 1080,
"startMs": 0,
"endMs": 8000,
"easing": "EaseInOutSine",
"from": { "centerX": 0.35, "centerY": 0.5, "scale": 1.0 },
"to": { "centerX": 0.65, "centerY": 0.5, "scale": 1.0 }
}
],
"lines": [
{
"speaker": "Аида Дженибековна",
"text": "Здравствуйте, студенты.",
"durationMs": 3000
},
{
"speaker": "Аида Дженибековна",
"text": "Рассаживайтесь.",
"durationMs": 2500
}
]
}
```
### Zoom-in reveal with a second image appearing mid-way
```json
{
"id": "letter_reveal",
"durationMs": 7000,
"fadeOutMs": 400,
"fadeInMs": 600,
"endFadeOutMs": 600,
"endFadeInMs": 400,
"imageSegments": [
{
"path": "resources/cutscenes/desk_bg.png",
"width": 1280,
"height": 720,
"startMs": 0,
"endMs": 7000
},
{
"path": "resources/cutscenes/letter_closeup.png",
"width": 1280,
"height": 720,
"startMs": 2000,
"endMs": 7000,
"fadeInMs": 800,
"easing": "EaseOutCubic",
"from": { "centerX": 0.5, "centerY": 0.5, "scale": 2.5 },
"to": { "centerX": 0.5, "centerY": 0.5, "scale": 1.2 }
}
],
"lines": [
{
"text": "Среди бумаг на столе лежит конверт.",
"durationMs": 2500
},
{
"speaker": "Главный герой",
"text": "«Явитесь в деканат немедленно».",
"durationMs": 3000
}
]
}
```

260
PATHFINDING.md Normal file
View File

@ -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 |

View File

@ -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
```
@ -172,4 +184,45 @@ make -j$(nproc) -C build #Компилируем
Для постройки без звука
rm -rf build #Очищаем build папку
cmake -B build -DAUDIO=1 #Пересоздаём конфигурацию CMake
cmake -B build -DAUDIO=1 #Пересоздаём конфигурацию CMake
# Cmake Build NSIS and Portable for Windows:
```
cmake --build . --config Release
cpack -C Release
```
Если есть такая ошибка:
CPack Error: Cannot find NSIS compiler makensis: likely it is not installed, or not in your PATH
CPack Error: Could not read NSIS registry value. This is usually caused by NSIS not being installed. Please install NSIS from http://nsis.sourceforge.net
CPack Error: Cannot initialize the generator NSIS
То нужно установить nsis отсюда: https://nsis.sourceforge.io/Download
# Steam windows
```
cmake -DSTEAMSDK=ON ..
cmake --build . --config Release
```
# Steam Linux
```
docker run -it --rm -v "${PWD}:/work2" -w /work2 registry.gitlab.steamos.cloud/steamrt/sniper/sdk:latest bash
apt-get update
apt-get install libboost-dev libeigen3-dev liblua5.4-dev libzip-dev libglu1-mesa-dev
cmake -DSTEAMSDK=ON -DCMAKE_BUILD_TYPE=Release ..
cmake --build . -j 4
```

527
UI.md Normal file
View File

@ -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);
});
```

View File

@ -0,0 +1,65 @@
import bpy
def append_layered_action_5_0(source_obj_name, target_obj_name):
source_obj = bpy.data.objects.get(source_obj_name)
target_obj = bpy.data.objects.get(target_obj_name)
if not (source_obj and target_obj):
print("Ошибка: Объекты не найдены")
return
src_action = source_obj.animation_data.action
tgt_action = target_obj.animation_data.action
# 1. Получаем слои (обычно первый)
src_layer = src_action.layers[0]
tgt_layer = tgt_action.layers[0]
# 2. Получаем стрипы
src_strip = src_layer.strips[0]
tgt_strip = tgt_layer.strips[0]
# Смещение (опираемся на конец диапазона целевого экшена)
offset = tgt_action.frame_range[1]
# 3. Итерируемся по channelbags в исходном стрипе
for src_bag in src_strip.channelbags:
# Ищем или создаем соответствующий bag в целевом стрипе
# Обычно они сопоставляются по имени или типу (например, 'Keyframe Channel Bag')
# В простейшем случае берем первый или сопоставляем по индексу
dst_bag = None
if len(tgt_strip.channelbags) > 0:
# Пытаемся найти по названию (если оно есть) или берем тот же индекс
dst_bag = tgt_strip.channelbags[0]
if not dst_bag:
# Если в целевом стрипе нет сумок, это странно, но можно создать
# (Метод создания может зависеть от конкретного подтипа стрипа в 5.0)
continue
#print(f"Обработка channelbag: {src_bag.name}, кривых: {len(src_bag.fcurves)}")
# 4. Итерируемся по fcurves внутри сумки
for src_fcurve in src_bag.fcurves:
dst_fcurve = dst_bag.fcurves.find(src_fcurve.data_path, index=src_fcurve.array_index)
if not dst_fcurve:
dst_fcurve = dst_bag.fcurves.new(data_path=src_fcurve.data_path, index=src_fcurve.array_index)
# 5. Копируем ключи с офсетом
for keyframe in src_fcurve.keyframe_points:
new_frame = keyframe.co[0] + offset
new_value = keyframe.co[1]
new_kp = dst_fcurve.keyframe_points.insert(new_frame, new_value, options={'FAST'})
new_kp.interpolation = keyframe.interpolation
# Обновляем интерполяцию
for fc in dst_bag.fcurves:
fc.update()
print(f"Анимация успешно дозаписана. Новый конец: {tgt_action.frame_range[1]}")
append_layered_action_5_0('Armature.001', 'Armature')

View File

@ -0,0 +1,192 @@
import bpy
import bmesh
# Имена mesh и арматуры
mesh_name = "arm"
armature_name = "Reference"
# Находим объект mesh по имени
mesh_obj = bpy.data.objects.get(mesh_name)
# Находим объект арматуры по имени
armature_obj = bpy.data.objects.get(armature_name)
# Устанавливаем текущий кадр на 0
bpy.context.scene.frame_set(0)
# Принудительно обновляем сцену, чтобы применить анимацию
bpy.context.view_layer.update()
# Открываем файл для записи
with open("C:\\Work\\Projects\\witcher001\\resources\\w\\zombie002.txt", "w") as file:
# Обработка арматуры и анимации
if armature_obj and armature_obj.type == 'ARMATURE':
file.write("=== Armature Matrix ===\n")
for row in armature_obj.matrix_world:
file.write(f"{row}\n")
file.write(f"=== Armature Bones: {len(armature_obj.data.bones)}\n")
for bone in armature_obj.data.bones:
# Записываем имя кости, длину и связи
file.write(f"Bone: {bone.name}\n")
file.write(f" HEAD_LOCAL: {bone.head_local}\n")
file.write(f" TAIL_LOCAL: {bone.tail_local}\n")
file.write(f" Length: {(bone.tail_local - bone.head_local).length}\n")
for row in bone.matrix:
file.write(f" {row}\n")
file.write(f" Parent: {bone.parent.name if bone.parent else 'None'}\n")
file.write(f" Children: {[child.name for child in bone.children]}\n")
# Обработка mesh
if mesh_obj and mesh_obj.type == 'MESH':
# Создаем копию mesh, чтобы не изменять оригинал
mesh_copy = mesh_obj.copy()
mesh_copy.data = mesh_obj.data.copy()
bpy.context.collection.objects.link(mesh_copy)
# Убедимся, что объект активен
bpy.context.view_layer.objects.active = mesh_copy
mesh_copy.select_set(True)
# Применяем модификатор Armature (если он есть)
for modifier in mesh_copy.modifiers:
if modifier.type == 'ARMATURE':
# Включаем модификатор, если он отключен
if not modifier.show_viewport:
modifier.show_viewport = True
if not modifier.show_render:
modifier.show_render = True
# Проверяем, что модификатор связан с арматурой
if modifier.object is None:
print(f"Модификатор Armature на объекте {mesh_copy.name} не связан с арматурой. Пропускаем.")
continue
# Временно применяем модификатор, чтобы получить правильные координаты вершин
try:
bpy.ops.object.modifier_apply(modifier=modifier.name)
except RuntimeError as e:
print(f"Ошибка при применении модификатора Armature: {e}")
continue
# Переходим в режим редактирования
bpy.ops.object.mode_set(mode='EDIT')
# Получаем BMesh представление mesh
bm = bmesh.from_edit_mesh(mesh_copy.data)
# Записываем список вершин
file.write(f"===Vertices: {len(bm.verts)}\n")
for vertex in bm.verts:
file.write(f"Vertex {vertex.index}: {vertex.co}\n")
# Убедимся, что у меша есть UV слой
uv_layer = bm.loops.layers.uv.active
if not uv_layer:
file.write("UV слой не найден.\n")
if uv_layer:
file.write(f"===UV Coordinates:\n")
file.write(f"Face count: {len(bm.faces)}\n")
for face in bm.faces:
file.write(f"Face {face.index}\n")
file.write(f"UV Count: {len(face.loops)}\n")
for loop in face.loops:
uv_coords = loop[uv_layer].uv
file.write(f" UV {uv_coords}\n")
# Записываем нормали
file.write(f"===Normals:\n")
for vertex in bm.verts:
file.write(f"Vertex {vertex.index}: Normal {vertex.normal}\n")
# Записываем треугольники (индексы вершин)
file.write(f"===Triangles: {len(bm.faces)}\n")
for face in bm.faces:
if len(face.verts) == 3: # Проверяем, что это треугольник
verts_indices = [vert.index for vert in face.verts]
file.write(f"Triangle: {verts_indices}\n")
# Возвращаемся в объектный режим
bpy.ops.object.mode_set(mode='OBJECT')
# Записываем веса вершин
file.write("=== Vertex Weights ===\n")
for vertex in mesh_copy.data.vertices:
file.write(f"Vertex {vertex.index}:\n")
file.write(f"Vertex groups: {len(vertex.groups)}\n")
for group in vertex.groups:
group_name = mesh_copy.vertex_groups[group.group].name
file.write(f" Group: '{group_name}', Weight: {group.weight}\n")
# Удаляем временную копию mesh
bpy.data.objects.remove(mesh_copy)
else:
file.write(f"Объект с именем '{mesh_name}' не найден или не является mesh.\n")
# Обработка арматуры и анимации
if armature_obj and armature_obj.type == 'ARMATURE':
# Получаем все ключевые кадры для арматуры
file.write("=== Animation Keyframes ===\n")
if armature_obj.animation_data and armature_obj.animation_data.action:
action = armature_obj.animation_data.action
# Собираем все уникальные ключевые кадры
keyframes = set()
# Логика для Blender 5.0 (Strip-based / ChannelBag structure)
if hasattr(action, "layers"):
for layer in action.layers:
if hasattr(layer, "strips"):
for strip in layer.strips:
# Проверяем наличие channelbags (согласно вашему dir(strip))
if hasattr(strip, "channelbags"):
for bag in strip.channelbags:
for fcurve in bag.fcurves:
for keyframe in fcurve.keyframe_points:
keyframes.add(int(keyframe.co[0]))
# На случай, если в этой версии используется единственное число
elif hasattr(strip, "channelbag") and strip.channelbag:
for fcurve in strip.channelbag.fcurves:
for keyframe in fcurve.keyframe_points:
keyframes.add(int(keyframe.co[0]))
# Фоллбек для Legacy экшенов
if not keyframes and hasattr(action, "fcurves"):
for fcurve in action.fcurves:
for keyframe in fcurve.keyframe_points:
keyframes.add(int(keyframe.co[0]))
keyframes = sorted(keyframes)
# Сортируем ключевые кадры
keyframes = sorted(keyframes)
# Сохраняем координаты и матрицы поворота для каждой кости на каждом ключевом кадре
file.write("=== Bone Transforms per Keyframe ===\n")
file.write(f"Keyframes: {len(keyframes)}\n")
for frame in keyframes:
# Устанавливаем текущий кадр
bpy.context.scene.frame_set(frame)
bpy.context.view_layer.update() # Обновляем сцену
file.write(f"Frame: {frame}\n")
for bone in armature_obj.pose.bones:
# Получаем координаты и матрицу поворота кости в мировом пространстве
matrix = bone.matrix
location = matrix.translation
rotation = matrix.to_euler()
# Записываем данные
file.write(f" Bone: {bone.name}\n")
file.write(f" Location: {location}\n")
file.write(f" Rotation: {rotation}\n")
file.write(f" Matrix:\n")
for row in matrix:
file.write(f" {row}\n")
else:
file.write(f"Объект с именем '{armature_name}' не найден или не является арматурой.\n")
print("Данные сохранены в файл 'mesh_armature_and_animation_data.txt'")

View File

@ -0,0 +1,228 @@
import bpy
import bmesh
#!
# Имена mesh и арматуры
mesh_name = "Joined"
armature_name = "Armature"
# Находим объект mesh по имени
mesh_obj = bpy.data.objects.get(mesh_name)
# Находим объект арматуры по имени
armature_obj = bpy.data.objects.get(armature_name)
# Устанавливаем текущий кадр на 0
bpy.context.scene.frame_set(0)
# Принудительно обновляем сцену, чтобы применить анимацию
bpy.context.view_layer.update()
# Открываем файл для записи
with open("C:\\Work\\Media\\witcher\\2026-04-13\\output\\gg_stand_idle001.txt", "w") as file:
# Обработка арматуры и анимации
if armature_obj and armature_obj.type == 'ARMATURE':
file.write("=== Armature Matrix ===\n")
for row in armature_obj.matrix_world:
file.write(f"{row}\n")
file.write(f"=== Armature Bones: {len(armature_obj.data.bones)}\n")
for bone in armature_obj.data.bones:
# Записываем имя кости, длину и связи
file.write(f"Bone: {bone.name}\n")
file.write(f" HEAD_LOCAL: {bone.head_local}\n")
file.write(f" TAIL_LOCAL: {bone.tail_local}\n")
file.write(f" Length: {(bone.tail_local - bone.head_local).length}\n")
for row in bone.matrix:
file.write(f" {row}\n")
file.write(f" Parent: {bone.parent.name if bone.parent else 'None'}\n")
file.write(f" Children: {[child.name for child in bone.children]}\n")
# Обработка mesh
if mesh_obj and mesh_obj.type == 'MESH':
# Создаем копию mesh, чтобы не изменять оригинал
mesh_copy = mesh_obj.copy()
mesh_copy.data = mesh_obj.data.copy()
bpy.context.collection.objects.link(mesh_copy)
# Убедимся, что объект активен
bpy.context.view_layer.objects.active = mesh_copy
mesh_copy.select_set(True)
# Применяем модификатор Armature (если он есть)
for modifier in mesh_copy.modifiers:
if modifier.type == 'ARMATURE':
# Включаем модификатор, если он отключен
if not modifier.show_viewport:
modifier.show_viewport = True
if not modifier.show_render:
modifier.show_render = True
# Проверяем, что модификатор связан с арматурой
if modifier.object is None:
print(f"Модификатор Armature на объекте {mesh_copy.name} не связан с арматурой. Пропускаем.")
continue
# Временно применяем модификатор, чтобы получить правильные координаты вершин
try:
bpy.ops.object.modifier_apply(modifier=modifier.name)
except RuntimeError as e:
print(f"Ошибка при применении модификатора Armature: {e}")
continue
# Переходим в режим редактирования
bpy.ops.object.mode_set(mode='EDIT')
# Получаем BMesh представление mesh
bm = bmesh.from_edit_mesh(mesh_copy.data)
# Записываем список вершин
file.write(f"===Vertices: {len(bm.verts)}\n")
for vertex in bm.verts:
file.write(f"Vertex {vertex.index}: {vertex.co}\n")
# Убедимся, что у меша есть UV слой
uv_layer = bm.loops.layers.uv.active
if not uv_layer:
file.write("UV слой не найден.\n")
if uv_layer:
file.write(f"===UV Coordinates:\n")
file.write(f"Face count: {len(bm.faces)}\n")
for face in bm.faces:
file.write(f"Face {face.index}\n")
file.write(f"UV Count: {len(face.loops)}\n")
for loop in face.loops:
uv_coords = loop[uv_layer].uv
file.write(f" UV {uv_coords}\n")
# Записываем нормали
file.write(f"===Normals:\n")
for vertex in bm.verts:
file.write(f"Vertex {vertex.index}: Normal {vertex.normal}\n")
# Записываем треугольники (индексы вершин)
file.write(f"===Triangles: {len(bm.faces)}\n")
for face in bm.faces:
if len(face.verts) == 3: # Проверяем, что это треугольник
verts_indices = [vert.index for vert in face.verts]
file.write(f"Triangle: {verts_indices}\n")
# Возвращаемся в объектный режим
bpy.ops.object.mode_set(mode='OBJECT')
# Записываем веса вершин
file.write("=== Vertex Weights (Max 5 bones per vertex) ===\n")
MAX_BONES = 5
for vertex in mesh_copy.data.vertices:
# Извлекаем все группы и веса для текущей вершины
all_weights = []
for group_element in vertex.groups:
all_weights.append({
'index': group_element.group,
'weight': group_element.weight
})
# Если костей больше лимита, фильтруем и перераспределяем
if len(all_weights) > MAX_BONES:
# Сортируем по весу (от большего к меньшему)
all_weights.sort(key=lambda x: x['weight'], reverse=True)
# Берем только топ-5
kept_weights = all_weights[:MAX_BONES]
# Считаем сумму весов оставшихся костей для нормализации
total_weight = sum(gw['weight'] for gw in kept_weights)
if total_weight > 0:
for gw in kept_weights:
gw['weight'] /= total_weight
else:
# На случай, если у всех веса были по 0.0 (редкий баг меша)
kept_weights[0]['weight'] = 1.0
final_weights = kept_weights
else:
final_weights = all_weights
file.write(f"Vertex {vertex.index}:\n")
file.write(f"Vertex groups: {len(final_weights)}\n")
for gw in final_weights:
group_name = mesh_copy.vertex_groups[gw['index']].name
file.write(f" Group: '{group_name}', Weight: {gw['weight']:.6f}\n")
# Удаляем временную копию mesh
bpy.data.objects.remove(mesh_copy)
else:
file.write(f"Объект с именем '{mesh_name}' не найден или не является mesh.\n")
# Обработка арматуры и анимации
if armature_obj and armature_obj.type == 'ARMATURE':
# Получаем все ключевые кадры для арматуры
file.write("=== Animation Keyframes ===\n")
if armature_obj.animation_data and armature_obj.animation_data.action:
action = armature_obj.animation_data.action
# Собираем все уникальные ключевые кадры
keyframes = set()
# Логика для Blender 5.0 (Strip-based / ChannelBag structure)
if hasattr(action, "layers"):
for layer in action.layers:
if hasattr(layer, "strips"):
for strip in layer.strips:
# Проверяем наличие channelbags (согласно вашему dir(strip))
if hasattr(strip, "channelbags"):
for bag in strip.channelbags:
for fcurve in bag.fcurves:
for keyframe in fcurve.keyframe_points:
keyframes.add(int(keyframe.co[0]))
# На случай, если в этой версии используется единственное число
elif hasattr(strip, "channelbag") and strip.channelbag:
for fcurve in strip.channelbag.fcurves:
for keyframe in fcurve.keyframe_points:
keyframes.add(int(keyframe.co[0]))
# Фоллбек для Legacy экшенов
if not keyframes and hasattr(action, "fcurves"):
for fcurve in action.fcurves:
for keyframe in fcurve.keyframe_points:
keyframes.add(int(keyframe.co[0]))
keyframes = sorted(keyframes)
# Сортируем ключевые кадры
keyframes = sorted(keyframes)
# Сохраняем координаты и матрицы поворота для каждой кости на каждом ключевом кадре
file.write("=== Bone Transforms per Keyframe ===\n")
file.write(f"Keyframes: {len(keyframes)}\n")
for frame in keyframes:
# Устанавливаем текущий кадр
bpy.context.scene.frame_set(frame)
bpy.context.view_layer.update() # Обновляем сцену
file.write(f"Frame: {frame}\n")
for bone in armature_obj.pose.bones:
# Получаем координаты и матрицу поворота кости в мировом пространстве
matrix = bone.matrix
location = matrix.translation
rotation = matrix.to_euler()
# Записываем данные
file.write(f" Bone: {bone.name}\n")
file.write(f" Location: {location}\n")
file.write(f" Rotation: {rotation}\n")
file.write(f" Matrix:\n")
for row in matrix:
file.write(f" {row}\n")
else:
file.write(f"Объект с именем '{armature_name}' не найден или не является арматурой.\n")
print("Данные сохранены в файл 'mesh_armature_and_animation_data.txt'")

View File

@ -0,0 +1,111 @@
import bpy
import bmesh
import mathutils
import random
import math
class SolidTreeGenerator:
def __init__(self, levels=5, length=3.0, radius=0.3):
self.levels = levels
self.base_length = length
self.base_radius = radius
# Хранилище для данных: (start_pos, end_pos, radius_start, radius_end)
self.branches_data = []
def calculate_tree(self, start_pos, direction, length, radius, level):
if level <= 0 or length < 0.1:
return
end_pos = start_pos + direction * length
# Сохраняем данные сегмента
self.branches_data.append({
'start': start_pos.copy(),
'end': end_pos.copy(),
'r_start': radius,
'r_end': radius * 0.7
})
# 1. Основной ствол (продолжение)
trunk_dir = (direction + self.get_random_vector(0.1)).normalized()
self.calculate_tree(end_pos, trunk_dir, length * 0.8, radius * 0.7, level - 1)
# 2. Боковые ветки (ветвление)
if level > 1:
num_sides = random.randint(2, 3) # Минимум 2 ветки для видимости
for _ in range(num_sides):
# Создаем вектор, сильно отклоненный от ствола (30-60 градусов)
axis = self.get_random_vector(1.0).normalized()
angle = math.radians(random.uniform(30, 60))
side_dir = direction.copy()
side_dir.rotate(mathutils.Quaternion(axis, angle))
# Боковые ветки короче
self.calculate_tree(end_pos, side_dir, length * 0.6, radius * 0.5, level - 1)
def get_random_vector(self, intensity):
return mathutils.Vector((
random.uniform(-intensity, intensity),
random.uniform(-intensity, intensity),
random.uniform(-intensity, intensity)
))
def build_mesh(self):
mesh = bpy.data.meshes.new("TreeMesh")
obj = bpy.data.objects.new("Tree", mesh)
bpy.context.collection.objects.link(obj)
bm = bmesh.new()
skin_layer = bm.verts.layers.skin.verify()
# Словарь для предотвращения дублирования вершин в одной точке
# Ключ - кортеж координат, Значение - объект вершины BMesh
vert_map = {}
for b in self.branches_data:
# Превращаем координаты в кортежи для словаря
s_key = tuple(round(v, 4) for v in b['start'])
e_key = tuple(round(v, 4) for v in b['end'])
# Получаем или создаем начальную вершину
if s_key not in vert_map:
v_start = bm.verts.new(b['start'])
v_start[skin_layer].radius = (b['r_start'], b['r_start'])
vert_map[s_key] = v_start
else:
v_start = vert_map[s_key]
# Получаем или создаем конечную вершину
if e_key not in vert_map:
v_end = bm.verts.new(b['end'])
v_end[skin_layer].radius = (b['r_end'], b['r_end'])
vert_map[e_key] = v_end
else:
v_end = vert_map[e_key]
# Создаем ребро, если его еще нет
if not bm.edges.get((v_start, v_end)):
bm.edges.new((v_start, v_end))
# Находим корень (самую нижнюю точку) и помечаем его
root_v = min(bm.verts, key=lambda v: v.co.z)
root_v[skin_layer].use_root = True
bm.to_mesh(mesh)
bm.free()
# Модификаторы
obj.modifiers.new(name="Skin", type='SKIN')
sub = obj.modifiers.new(name="Subdiv", type='SUBSURF')
sub.levels = 1 # Для начала 1, чтобы не тормозило
# Очистка сцены
bpy.ops.object.select_all(action='SELECT')
bpy.ops.object.delete()
# Запуск
generator = SolidTreeGenerator(levels=5, length=3.0, radius=0.4)
generator.calculate_tree(mathutils.Vector((0,0,0)), mathutils.Vector((0,0,1)), 3.0, 0.4, 5)
generator.build_mesh()

View File

@ -25,7 +25,7 @@ macro(check_and_download URL ARCHIVE_NAME EXTRACTED_DIR_NAME CHECK_FILE)
endmacro()
# 1) ZLIB (Нужна только для инклудов, если не используете emscripten порты)
check_and_download("https://www.zlib.net/zlib131.zip" "zlib131.zip" "zlib-1.3.1" "CMakeLists.txt")
check_and_download("https://www.zlib.net/zlib132.zip" "zlib132.zip" "zlib-1.3.2" "CMakeLists.txt")
# 2) SDL2
check_and_download("https://github.com/libsdl-org/SDL/archive/refs/tags/release-2.32.10.zip" "release-2.32.10.zip" "SDL-release-2.32.10" "CMakeLists.txt")
@ -47,3 +47,12 @@ check_and_download("https://download.savannah.gnu.org/releases/freetype/freetype
# 8) SDL_ttf
check_and_download("https://github.com/libsdl-org/SDL_ttf/archive/refs/tags/release-2.24.0.zip" "release-2.24.0.zip" "SDL_ttf-release-2.24.0" "CMakeLists.txt")
# 9) Lua
check_and_download("https://github.com/lua/lua/archive/refs/tags/v5.4.8.zip" "lua-v5.4.8.zip" "lua-5.4.8" "lapi.c")
# 10) 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")
# 11) SDL2_mixer
check_and_download("https://github.com/libsdl-org/SDL_mixer/archive/refs/tags/release-2.8.0.zip" "SDL_mixer-release-2.8.0.zip" "SDL_mixer-release-2.8.0" "CMakeLists.txt")

View File

@ -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")

View File

@ -8,11 +8,16 @@ endmacro()
set(BUILD_CONFIGS Debug Release)
# Map MinSizeRel and RelWithDebInfo to Release libs for all imported targets.
# Without this CMake warns that IMPORTED_LOCATION is missing for those configs.
set(CMAKE_MAP_IMPORTED_CONFIG_MINSIZEREL Release)
set(CMAKE_MAP_IMPORTED_CONFIG_RELWITHDEBINFO Release)
# ===========================================
# 1) ZLIB (zlib131.zip zlib-1.3.1) - без изменений
# 1) ZLIB (zlib131.zip zlib-1.3.2) - без изменений
# ===========================================
set(ZLIB_SRC_DIR "${THIRDPARTY_DIR}/zlib-1.3.1")
set(ZLIB_SRC_DIR "${THIRDPARTY_DIR}/zlib-1.3.2")
set(ZLIB_BUILD_DIR "${ZLIB_SRC_DIR}/build")
set(ZLIB_INSTALL_DIR "${ZLIB_SRC_DIR}/install")
@ -76,8 +81,8 @@ set_target_properties(zlib_external_lib PROPERTIES
#IMPORTED_LOCATION_RELEASE "${ZLIB_INSTALL_DIR}/lib/zlib.lib"
# Можно также указать статические библиотеки, если вы хотите их использовать
IMPORTED_LOCATION_DEBUG "${ZLIB_INSTALL_DIR}/lib/zlibstaticd.lib"
IMPORTED_LOCATION_RELEASE "${ZLIB_INSTALL_DIR}/lib/zlibstatic.lib"
IMPORTED_LOCATION_DEBUG "${ZLIB_INSTALL_DIR}/lib/zsd.lib"
IMPORTED_LOCATION_RELEASE "${ZLIB_INSTALL_DIR}/lib/zs.lib"
INTERFACE_INCLUDE_DIRECTORIES "${ZLIB_INSTALL_DIR}/include"
)
@ -546,4 +551,178 @@ if(NOT TARGET boost_external_lib)
add_library(boost_external_lib INTERFACE)
# Boost заголовки находятся непосредственно в корне распакованной папки
target_include_directories(boost_external_lib INTERFACE "${BOOST_SRC_DIR}")
endif()
endif()
# ===========================================
# 9) Lua (5.5.0) - embedded scripting language
# ===========================================
set(LUA_SRC_DIR "${THIRDPARTY_DIR}/lua-5.4.8")
if(NOT TARGET lua_static)
file(GLOB LUA_SOURCES "${LUA_SRC_DIR}/*.c")
# Exclude the standalone interpreter, compiler, and unity-build wrapper.
# onelua.c #includes all other .c files compiling it alongside them
# causes every symbol to be defined twice.
list(REMOVE_ITEM LUA_SOURCES
"${LUA_SRC_DIR}/lua.c"
"${LUA_SRC_DIR}/luac.c"
"${LUA_SRC_DIR}/onelua.c"
)
add_library(lua_static STATIC ${LUA_SOURCES})
target_include_directories(lua_static PUBLIC "${LUA_SRC_DIR}")
target_compile_definitions(lua_static PRIVATE _CRT_SECURE_NO_WARNINGS)
endif()
# ===========================================
# 10) sol2 (3.3.0) - header-only C++ bindings for Lua
# ===========================================
set(SOL2_SRC_DIR "${THIRDPARTY_DIR}/sol2-3.3.0")
# Apply patch for Clang/Emscripten compatibility in optional<T&>::emplace().
# The sentinel file prevents re-applying on subsequent cmake runs.
set(_sol2_sentinel "${SOL2_SRC_DIR}/.patched")
if(NOT EXISTS "${_sol2_sentinel}")
find_package(Git QUIET)
if(GIT_FOUND)
execute_process(
COMMAND ${GIT_EXECUTABLE} apply --ignore-whitespace
"${CMAKE_CURRENT_LIST_DIR}/patches/sol2-3.3.0-clang-optional.patch"
WORKING_DIRECTORY "${SOL2_SRC_DIR}"
RESULT_VARIABLE _sol2_patch_res
)
if(_sol2_patch_res EQUAL 0)
file(WRITE "${_sol2_sentinel}" "patched\n")
message(STATUS "Applied sol2 Clang optional patch")
else()
message(WARNING "sol2 patch failed (exit ${_sol2_patch_res}) — Clang/Emscripten builds may not compile")
endif()
else()
message(WARNING "Git not found — cannot apply sol2 patch automatically. "
"Apply cmake/patches/sol2-3.3.0-clang-optional.patch manually.")
endif()
endif()
if(NOT TARGET sol2_external_lib)
add_library(sol2_external_lib INTERFACE)
target_include_directories(sol2_external_lib INTERFACE "${SOL2_SRC_DIR}/include")
target_link_libraries(sol2_external_lib INTERFACE lua_static)
endif()
# ===========================================
# 11) SDL2_mixer (2.8.0) сборка из исходников
# ===========================================
set(SDL2MIXER_SRC_DIR "${THIRDPARTY_DIR}/SDL_mixer-release-2.8.0")
set(SDL2MIXER_BASE_DIR "${SDL2MIXER_SRC_DIR}/install")
set(SDL2MIXER_BASE_DIR "${SDL2MIXER_BASE_DIR}" CACHE PATH "SDL2_mixer install base directory" FORCE)
set(_have_sdl2mixer TRUE)
foreach(cfg IN LISTS BUILD_CONFIGS)
if(NOT EXISTS "${SDL2MIXER_BASE_DIR}-${cfg}/lib/SDL2_mixer.lib" AND
NOT EXISTS "${SDL2MIXER_BASE_DIR}-${cfg}/lib/SDL2_mixerd.lib")
set(_have_sdl2mixer FALSE)
endif()
endforeach()
if(NOT _have_sdl2mixer)
foreach(cfg IN LISTS BUILD_CONFIGS)
if(cfg STREQUAL "Debug")
set(_SDL2_LIB "${SDL2_INSTALL_DIR}/lib/SDL2d.lib")
else()
set(_SDL2_LIB "${SDL2_INSTALL_DIR}/lib/SDL2.lib")
endif()
log("Configuring SDL2_mixer (${cfg}) ...")
execute_process(
COMMAND ${CMAKE_COMMAND}
-G "${CMAKE_GENERATOR}"
-S "${SDL2MIXER_SRC_DIR}"
-B "${SDL2MIXER_SRC_DIR}/build-${cfg}"
-DCMAKE_INSTALL_PREFIX=${SDL2MIXER_BASE_DIR}-${cfg}
-DCMAKE_PREFIX_PATH=${SDL2_INSTALL_DIR}
-DSDL2_LIBRARY=${_SDL2_LIB}
-DSDL2_INCLUDE_DIR=${SDL2_INSTALL_DIR}/include/SDL2
-DSDL2MIXER_DEPS_SHARED=OFF
-DSDL2MIXER_VENDORED=ON
-DSDL2MIXER_SAMPLES=OFF
-DSDL2MIXER_MUSIC_CMD=OFF
-DSDL2MIXER_MOD=OFF
-DSDL2MIXER_MIDI=OFF
-DSDL2MIXER_OPUS=OFF
-DSDL2MIXER_WAVPACK=OFF
-DSDL2MIXER_MP3_MPG123=OFF
-DSDL2MIXER_MP3_DRMP3=ON
-DSDL2MIXER_FLAC_DRFLAC=ON
-DSDL2MIXER_OGG_STB=ON
-DCMAKE_DISABLE_FIND_PACKAGE_OGG=TRUE
-DCMAKE_DISABLE_FIND_PACKAGE_Vorbis=TRUE
-DCMAKE_DISABLE_FIND_PACKAGE_FLAC=TRUE
-DCMAKE_DISABLE_FIND_PACKAGE_MPG123=TRUE
-DCMAKE_DISABLE_FIND_PACKAGE_LibModPlug=TRUE
-DCMAKE_DISABLE_FIND_PACKAGE_FluidLite=TRUE
RESULT_VARIABLE _mixer_cfg_res
OUTPUT_VARIABLE _mixer_cfg_out
ERROR_VARIABLE _mixer_cfg_err
)
if(NOT _mixer_cfg_res EQUAL 0)
message(STATUS "SDL2_mixer configure stdout: ${_mixer_cfg_out}")
message(STATUS "SDL2_mixer configure stderr: ${_mixer_cfg_err}")
message(FATAL_ERROR "SDL2_mixer configure failed for ${cfg}")
endif()
log("Building SDL2_mixer (${cfg}) ...")
execute_process(
COMMAND ${CMAKE_COMMAND}
--build "${SDL2MIXER_SRC_DIR}/build-${cfg}" --config ${cfg}
RESULT_VARIABLE _mixer_build_res
)
if(NOT _mixer_build_res EQUAL 0)
message(FATAL_ERROR "SDL2_mixer build failed for ${cfg}")
endif()
log("Installing SDL2_mixer (${cfg}) ...")
execute_process(
COMMAND ${CMAKE_COMMAND}
--install "${SDL2MIXER_SRC_DIR}/build-${cfg}" --config ${cfg}
RESULT_VARIABLE _mixer_inst_res
)
if(NOT _mixer_inst_res EQUAL 0)
message(FATAL_ERROR "SDL2_mixer install failed for ${cfg}")
endif()
endforeach()
endif()
set(_mixer_debug_lib "")
foreach(cand
"${SDL2MIXER_BASE_DIR}-Debug/lib/SDL2_mixerd.lib"
"${SDL2MIXER_BASE_DIR}-Debug/lib/SDL2_mixer.lib"
)
if(EXISTS "${cand}")
set(_mixer_debug_lib "${cand}")
break()
endif()
endforeach()
set(_mixer_release_lib "")
foreach(cand
"${SDL2MIXER_BASE_DIR}-Release/lib/SDL2_mixer.lib"
)
if(EXISTS "${cand}")
set(_mixer_release_lib "${cand}")
break()
endif()
endforeach()
if(_mixer_debug_lib STREQUAL "" OR _mixer_release_lib STREQUAL "")
message(FATAL_ERROR "SDL2_mixer libs not found in ${SDL2MIXER_BASE_DIR}-Debug/Release")
endif()
add_library(SDL2_mixer_external_lib UNKNOWN IMPORTED GLOBAL)
set_target_properties(SDL2_mixer_external_lib PROPERTIES
IMPORTED_LOCATION_DEBUG "${_mixer_debug_lib}"
IMPORTED_LOCATION_RELEASE "${_mixer_release_lib}"
INTERFACE_INCLUDE_DIRECTORIES
"$<IF:$<CONFIG:Debug>,${SDL2MIXER_BASE_DIR}-Debug/include,${SDL2MIXER_BASE_DIR}-Release/include>"
INTERFACE_LINK_LIBRARIES
"SDL2_external_lib"
)

View File

@ -0,0 +1,14 @@
--- a/include/sol/optional_implementation.hpp
+++ b/include/sol/optional_implementation.hpp
@@ -2189,7 +2189,10 @@
template <class... Args>
T& emplace(Args&&... args) noexcept {
static_assert(std::is_constructible<T, Args&&...>::value, "T must be constructible with Args");
*this = nullopt;
- this->construct(std::forward<Args>(args)...);
+ // Reference specialization stores a pointer; set it directly.
+ // construct() only exists in the non-reference specialization.
+ m_value = std::addressof(std::forward<Args>(args)...);
+ return *m_value;
}

View File

@ -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

View File

@ -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;
}
}

View File

@ -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;
};

View File

@ -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;
}

View File

@ -1,156 +0,0 @@
{
"root": {
"type": "FrameLayout",
"x": 0,
"y": 0,
"width": 1280,
"height": 720,
"children": [
{
"type": "FrameLayout",
"name": "leftPanel",
"x": 100,
"y": 100,
"width": 320,
"height": 400,
"children": [
{
"type": "LinearLayout",
"name": "mainButtons",
"orientation": "vertical",
"spacing": 10,
"x": 0,
"y": 0,
"width": 300,
"height": 300,
"children": [
{
"type": "Button",
"name": "playButton",
"x": 100,
"y": 300,
"width": 200,
"height": 50,
"animations": {
"buttonsExit": {
"repeat": false,
"steps": [
{
"type": "move",
"to": [
-400,
0
],
"duration": 1.0,
"easing": "easein"
}
]
}
},
"textures": {
"normal": "./resources/button.png",
"hover": "./resources/sand.png",
"pressed": "./resources/button.png"
}
},
{
"type": "Button",
"name": "settingsButton",
"x": 100,
"y": 200,
"width": 200,
"height": 50,
"animations": {
"buttonsExit": {
"repeat": false,
"steps": [
{
"type": "wait",
"duration": 0.5
},
{
"type": "move",
"to": [
-400,
0
],
"duration": 1.0,
"easing": "easein"
}
]
}
},
"textures": {
"normal": "./resources/sand.png",
"hover": "./resources/button.png",
"pressed": "./resources/sand.png"
}
},
{
"type": "Button",
"name": "exitButton",
"x": 100,
"y": 100,
"width": 200,
"height": 50,
"animations": {
"buttonsExit": {
"repeat": false,
"steps": [
{
"type": "wait",
"duration": 1.0
},
{
"type": "move",
"to": [
-400,
0
],
"duration": 1.0,
"easing": "easein"
}
]
},
"bgScroll": {
"repeat": true,
"steps": [
{
"type": "move",
"to": [
1280,
0
],
"duration": 5.0,
"easing": "linear"
}
]
}
},
"textures": {
"normal": "./resources/rock.png",
"hover": "./resources/button.png",
"pressed": "./resources/rock.png"
}
}
]
}
]
},
{
"type": "Slider",
"name": "musicVolumeSlider",
"x": 1140,
"y": 100,
"width": 10,
"height": 500,
"value": 0.5,
"orientation": "vertical",
"textures": {
"track": "./resources/musicVolumeBarTexture.png",
"knob": "./resources/musicVolumeBarButton.png"
}
}
]
}
}

323
convert_anim_to_binary.py Normal file
View File

@ -0,0 +1,323 @@
#!/usr/bin/env python3
"""
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 v2) -- all values little-endian:
HEADER
4 bytes magic "BSAF"
uint32 version (2)
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 (v2+)
per bone:
uint32 nameLen
nameLen bytes UTF-8 name (no 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
# --- Skip armature matrix (5 lines) ---
for _ in range(5):
next_line()
# --- 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]]
# --- Vertices ---
line = next_line() # "===Vertices: 5140"
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: 8602"
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) # 6 floats
# --- Normals ---
next_line() # "===Normals:"
normals = []
for _ in range(num_vertices):
normals.append(parse_floats(next_line())[:3])
# --- Triangles ---
line = next_line() # "===Triangles: 8602"
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 ..."
vertex_weights = []
for _ in range(num_vertices):
next_line() # "Vertex N:"
line = next_line() # "Vertex groups: 2"
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)
# --- Animation Keyframes ---
next_line() # "=== Animation Keyframes ==="
next_line() # "=== Bone Transforms per Keyframe ==="
line = next_line() # "Keyframes: 32"
num_keyframes = parse_first_int(line)
keyframes = []
for _ in range(num_keyframes):
line = next_line() # "Frame: 0"
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'BSAF')
out.write(struct.pack('<I', 2))
# 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 (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:
out.write(struct.pack('<3f', *v))
# UV Coordinates
out.write(struct.pack('<I', num_faces))
for uv in uvs:
out.write(struct.pack('<6f', *uv))
# Normals
for n in normals:
out.write(struct.pack('<3f', *n))
# Triangles
out.write(struct.pack('<I', num_triangles))
for t in triangles:
out.write(struct.pack('<3i', *t))
# Vertex Weights
for vw in 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}, Vertices: {num_vertices}, Faces: {num_faces}, "
f"Triangles: {num_triangles}, Keyframes: {num_keyframes}")
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])

View File

@ -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])

63
convert_config_meshes.py Normal file
View File

@ -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])

142
convert_model_to_binary.py Normal file
View File

@ -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)

View File

@ -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)

46
cutsceneEditor/.gitignore vendored Normal file
View File

@ -0,0 +1,46 @@
.DS_STORE
node_modules
scripts/flow/*/.flowconfig
.flowconfig
*~
*.pyc
.grunt
_SpecRunner.html
__benchmarks__
build/
remote-repo/
coverage/
.module-cache
fixtures/dom/public/react-dom.js
fixtures/dom/public/react.js
test/the-files-to-test.generated.js
*.log*
chrome-user-data
*.sublime-project
*.sublime-workspace
.idea
*.iml
.vscode
.zed
*.swp
*.swo
/tmp
/.worktrees
.claude/*.local.*
packages/react-devtools-core/dist
packages/react-devtools-extensions/chrome/build
packages/react-devtools-extensions/chrome/*.crx
packages/react-devtools-extensions/chrome/*.pem
packages/react-devtools-extensions/firefox/build
packages/react-devtools-extensions/firefox/*.xpi
packages/react-devtools-extensions/firefox/*.pem
packages/react-devtools-extensions/shared/build
packages/react-devtools-extensions/.tempUserDataDir
packages/react-devtools-fusebox/dist
packages/react-devtools-inline/dist
packages/react-devtools-shell/dist
packages/react-devtools-timeline/dist
resources

351
cutsceneEditor/CUTSCENES.md Normal file
View File

@ -0,0 +1,351 @@
# Cutscene System
Cutscenes are defined in JSON and loaded by `CutsceneDatabase`. Each cutscene is a self-contained object with an array of animated image layers and optional subtitle lines.
The file can contain multiple cutscenes:
```json
{
"cutscenes": [
{ "id": "intro", ... },
{ "id": "ending", ... }
]
}
```
Cutscenes and dialogues are loaded from **separate files**:
```cpp
dialogueSystem.loadDatabase("resources/dialogue/uni_interior.json"); // dialogues
dialogueSystem.loadCutsceneDatabase("resources/dialogue/cutscenes.json"); // cutscenes
```
---
## Cutscene object
```json
{
"id": "intro_cutscene",
"skippable": true,
"durationMs": 8000,
"fadeOutMs": 500,
"fadeInMs": 500,
"endFadeOutMs": 500,
"endFadeInMs": 500,
"onFadeInCallback": "",
"imageSegments": [ ... ],
"lines": [ ... ]
}
```
| Property | Type | Default | Description |
|---|---|---|---|
| `id` | string | — | Unique identifier used to start the cutscene from C++ or dialogue (**required**) |
| `skippable` | bool | `true` | Whether the player can skip by holding LMB / touch |
| `durationMs` | int | `0` | Minimum content duration in ms. The cutscene will not end before this time even if all subtitle lines have finished. `0` means duration is determined solely by subtitle lines or `imageSegments.endMs` |
| `fadeOutMs` | int | `0` | Duration of the **opening fade** — game world fades to black before the cutscene images appear |
| `fadeInMs` | int | `0` | Duration of the **opening reveal** — cutscene images fade in from black after `fadeOutMs` |
| `endFadeOutMs` | int | `0` | Duration of the **closing fade** — cutscene fades to black at the end of content |
| `endFadeInMs` | int | `0` | Duration of the **closing reveal** — game world fades back in from black |
| `onFadeInCallback` | string | `""` | Lua function name called once the opening fade-in completes (fired after `fadeOutMs + fadeInMs` ms) |
| `imageSegments` | array | `[]` | Image layers with motion — see [Image segments](#image-segments) |
| `lines` | array | `[]` | Subtitle lines shown sequentially — see [Subtitle lines](#subtitle-lines) |
### Timing model
The total cutscene duration is:
```
contentDuration = max(durationMs, max(segment.endMs for all segments))
totalDuration = contentDuration + endFadeOutMs + endFadeInMs
```
The full timeline looks like this:
```
|-- fadeOutMs --|-- fadeInMs --|--- content plays (images + subtitles) ---|-- endFadeOutMs --|-- endFadeInMs --|
world→black black→images images→black black→world
```
---
## Image segments
Each entry in `imageSegments` describes one image layer: when it is visible, how it fades in/out, and how it animates from a start pose to an end pose.
```json
{
"path": "resources/cutscenes/bg_layer.png",
"width": 1280,
"height": 720,
"startMs": 0,
"endMs": 8000,
"fadeInMs": 300,
"fadeOutMs": 300,
"easing": "EaseInOutSine",
"from": { "centerX": 0.4, "centerY": 0.5, "scale": 1.1 },
"to": { "centerX": 0.6, "centerY": 0.5, "scale": 1.0 }
}
```
| Property | Type | Default | Description |
|---|---|---|---|
| `path` | string | — | Path to the PNG image (**required**) |
| `width` | int | `0` | Logical width used for all UV and aspect-ratio math. `0` uses the actual texture pixel width |
| `height` | int | `0` | Logical height. `0` uses the actual texture pixel height |
| `startMs` | int | `0` | Time (ms from cutscene start) when this layer becomes active |
| `endMs` | int | `0` | Time (ms) when this layer stops being active. Must be > `startMs` |
| `fadeInMs` | int | `0` | Alpha fades from 0 → 1 over this many ms after `startMs`. `0` = instant |
| `fadeOutMs` | int | `0` | Alpha fades from 1 → 0 over this many ms before `endMs`. `0` = instant |
| `easing` | string | `"Linear"` | Easing applied to the pose interpolation — see [Easing types](#easing-types) |
| `from` | pose object | center/1.0 | Pose at `startMs` — see [Image pose](#image-pose) |
| `to` | pose object | same as `from` | Pose at `endMs`. If omitted, the layer stays at `from` the whole time |
Multiple segments can be active at the same time. They are rendered **in declaration order** (first = bottom layer, last = top layer), which enables parallax layering.
---
## Image pose
A pose defines how an image is framed on screen at a given moment.
```json
{ "centerX": 0.5, "centerY": 0.5, "scale": 1.0 }
```
| Property | Type | Default | Description |
|---|---|---|---|
| `centerX` | float | `0.5` | Normalized X position (0 = left edge of image, 1 = right edge) of the point that is placed at the horizontal center of the screen |
| `centerY` | float | `0.5` | Normalized Y position (0 = top edge, 1 = bottom edge) placed at the screen center |
| `scale` | float | `1.0` | Zoom level. `1.0` = the image fills the screen exactly (aspect-ratio corrected). `2.0` = zoomed in 2×, showing half the image area |
The runtime interpolates all three values independently from `from` to `to` using the chosen easing.
**Coordinate clamping:** `centerX`/`centerY` are automatically clamped so the viewport never shows area outside the image. For a zoomed-in segment (`scale > 1`) you therefore have more freedom to pan; for `scale = 1.0` the center is locked to `0.5/0.5`.
### Pose intuition
| Goal | Config |
|---|---|
| Centered, no zoom | `{ "centerX": 0.5, "centerY": 0.5, "scale": 1.0 }` |
| Slightly zoomed in on center | `{ "centerX": 0.5, "centerY": 0.5, "scale": 1.2 }` |
| Pan left to right | `from: { "centerX": 0.3, "scale": 1.2 }``to: { "centerX": 0.7, "scale": 1.2 }` |
| Zoom out from close-up | `from: { "scale": 1.8 }``to: { "scale": 1.0 }` |
| Look at top portion | `{ "centerY": 0.2, "scale": 1.3 }` |
---
## Easing types
Controls the interpolation curve applied to pose animation between `from` and `to`.
| Value | Description |
|---|---|
| `"Linear"` | Constant speed (default) |
| `"EaseInSine"` | Slow start, fast end |
| `"EaseOutSine"` | Fast start, slow end |
| `"EaseInOutSine"` | Slow start and end, fast middle |
| `"EaseInQuad"` | Quadratic slow start |
| `"EaseOutQuad"` | Quadratic slow end |
| `"EaseInOutQuad"` | Quadratic slow start and end |
| `"EaseInCubic"` | Cubic slow start |
| `"EaseOutCubic"` | Cubic slow end |
| `"EaseInOutCubic"` | Cubic slow start and end |
For cinematic camera motion `"EaseInOutSine"` or `"EaseInOutCubic"` give the most natural feel.
---
## Subtitle lines
Lines are displayed sequentially on top of the cutscene images. Each line shows until its duration expires (or until the player advances, if `waitForConfirm` is set).
```json
{
"speaker": "Аида Дженибековна",
"text": "Здравствуйте, студенты.",
"durationMs": 3000,
"waitForConfirm": false,
"luaCallback": ""
}
```
| Property | Type | Default | Description |
|---|---|---|---|
| `speaker` | string | `""` | Speaker name shown above the subtitle text. Empty = no name bar |
| `text` | string | `""` | Subtitle text. Supports Cyrillic and any codepoint in `resources/symbols.txt` |
| `durationMs` | int | `0` | How long this line is displayed in ms. `0` = auto-computed from text length (~17 chars/sec, minimum 1500 ms) |
| `waitForConfirm` | bool | `false` | When `true`, the line waits for player input (tap/click/Enter) before advancing. No timer runs |
| `luaCallback` | string | `""` | Lua function name called when this line begins. Useful for triggering SFX, spawning effects, etc. |
Subtitle lines run on their own timer that is **independent** of the image segments. The cutscene ends when **both** subtitle lines are exhausted **and** `contentDuration` has elapsed.
---
## C++ API
### Starting a cutscene
```cpp
// Standalone cutscene (not part of a dialogue):
dialogueSystem.startCutscene("intro_cutscene");
// Skip the currently playing cutscene:
dialogueSystem.skipCutscene();
```
### Callbacks
```cpp
// Called when a cutscene begins:
dialogueSystem.setOnCutsceneStarted([]() { /* hide HUD, etc. */ });
// Called when a cutscene ends (receives the cutscene id):
dialogueSystem.setOnCutsceneFinished([](const std::string& id) {
// id == "intro_cutscene"
});
// Called when a subtitle line begins (receives luaCallback value):
dialogueSystem.setOnCutsceneLineStarted([](const std::string& fn) {
scriptEngine.callActivateFunction(fn);
});
// Called when the opening fade-in completes (receives onFadeInCallback value):
dialogueSystem.setOnCutsceneFadeInComplete([](const std::string& fn) {
scriptEngine.callActivateFunction(fn);
});
```
### Triggering from dialogue
A dialogue node of type `CutsceneStart` embeds a cutscene mid-conversation. Dialogue resumes at `next` when the cutscene ends.
```json
{
"id": "node_cutscene",
"type": "CutsceneStart",
"cutsceneId": "intro_cutscene",
"next": "node_after_cutscene"
}
```
---
## Full examples
### Minimal — static image, timed
```json
{
"id": "simple",
"durationMs": 4000,
"fadeOutMs": 300,
"fadeInMs": 300,
"endFadeOutMs": 300,
"endFadeInMs": 300,
"imageSegments": [
{
"path": "resources/cutscenes/city.png",
"width": 1280,
"height": 720,
"startMs": 0,
"endMs": 4000
}
]
}
```
### Two-layer parallax pan
Background moves slowly left-to-right; foreground character moves faster, creating depth.
```json
{
"id": "classroom_intro",
"durationMs": 8000,
"fadeOutMs": 500,
"fadeInMs": 500,
"endFadeOutMs": 500,
"endFadeInMs": 500,
"imageSegments": [
{
"path": "resources/cutscenes/classroom_bg.png",
"width": 1920,
"height": 1080,
"startMs": 0,
"endMs": 8000,
"fadeInMs": 400,
"easing": "EaseInOutSine",
"from": { "centerX": 0.4, "centerY": 0.5, "scale": 1.1 },
"to": { "centerX": 0.6, "centerY": 0.5, "scale": 1.0 }
},
{
"path": "resources/cutscenes/classroom_teacher.png",
"width": 1920,
"height": 1080,
"startMs": 0,
"endMs": 8000,
"easing": "EaseInOutSine",
"from": { "centerX": 0.35, "centerY": 0.5, "scale": 1.0 },
"to": { "centerX": 0.65, "centerY": 0.5, "scale": 1.0 }
}
],
"lines": [
{
"speaker": "Аида Дженибековна",
"text": "Здравствуйте, студенты.",
"durationMs": 3000
},
{
"speaker": "Аида Дженибековна",
"text": "Рассаживайтесь.",
"durationMs": 2500
}
]
}
```
### Zoom-in reveal with a second image appearing mid-way
```json
{
"id": "letter_reveal",
"durationMs": 7000,
"fadeOutMs": 400,
"fadeInMs": 600,
"endFadeOutMs": 600,
"endFadeInMs": 400,
"imageSegments": [
{
"path": "resources/cutscenes/desk_bg.png",
"width": 1280,
"height": 720,
"startMs": 0,
"endMs": 7000
},
{
"path": "resources/cutscenes/letter_closeup.png",
"width": 1280,
"height": 720,
"startMs": 2000,
"endMs": 7000,
"fadeInMs": 800,
"easing": "EaseOutCubic",
"from": { "centerX": 0.5, "centerY": 0.5, "scale": 2.5 },
"to": { "centerX": 0.5, "centerY": 0.5, "scale": 1.2 }
}
],
"lines": [
{
"text": "Среди бумаг на столе лежит конверт.",
"durationMs": 2500
},
{
"speaker": "Главный герой",
"text": "«Явитесь в деканат немедленно».",
"durationMs": 3000
}
]
}
```

View File

@ -0,0 +1,65 @@
{
"cutscenes": [
{
"id": "test_cutscene_01",
"background": "resources/black.png",
"durationMs": 5000,
"fadeOutMs": 500,
"fadeInMs": 500,
"endFadeOutMs": 500,
"endFadeInMs": 500,
"imageSegments": [
{
"path": "resources/w/cutscenes/cutscene1/cutscene1_wall_x.png",
"startMs": 0,
"endMs": 8000,
"fadeInMs": 0,
"width": 1280,
"height": 720,
"from": {
"centerX": 0.3, "scale": 1.2
},
"to": {
"centerX": 0.7, "scale": 1.2
},
"easing": "Linear"
},
{
"path": "resources/w/cutscenes/cutscene1/cutscene1_aida1_x.png",
"startMs": 0,
"endMs": 8000,
"width": 1280,
"height": 720,
"from": {
"centerX": 0.3,
"centerY": 0.5,
"scale": 1.0
},
"to": {
"centerX": 0.7,
"centerY": 0.5,
"scale": 1.0
}
}
],
"lines": [
{
"speaker": "Аида Дженибековна",
"text": "Здравствуйте, студенты. Кого я вижу, где вы были весь семестр?",
"durationMs": 3000
},
{
"speaker": "Аида Дженибековна",
"text": "В эпизоде \"Семетей\" трилогии \"Манас\", изменники Канчоро и Кыяз захватывают власть над кыргызами.",
"durationMs": 3000
},
{
"speaker": "Аида Дженибековна",
"text": "На сегодня лекция завершена. Домашнее задание - к практическому занятию вы должны подготовить презентации, каждый по своей теме.",
"durationMs": 2000
}
]
}
]
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

13
cutsceneEditor/dist/index.html vendored Normal file
View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Cutscene Editor</title>
<script type="module" crossorigin src="/assets/index-D_5Tak8P.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B96C0g1n.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

12
cutsceneEditor/index.html Normal file
View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Cutscene Editor</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

1890
cutsceneEditor/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,24 @@
{
"name": "cutscene-editor",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"immer": "^10.1.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"zustand": "^5.0.3"
},
"devDependencies": {
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.2",
"vite": "^6.0.5"
}
}

View File

@ -0,0 +1,6 @@
.app {
display: flex;
width: 100%;
height: 100%;
overflow: hidden;
}

View File

@ -0,0 +1,14 @@
import styles from './App.module.css';
import LeftPanel from './components/LeftPanel/LeftPanel';
import CenterPanel from './components/CenterPanel/CenterPanel';
import RightPanel from './components/RightPanel/RightPanel';
export default function App() {
return (
<div className={styles.app}>
<LeftPanel />
<CenterPanel />
<RightPanel />
</div>
);
}

View File

@ -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;
}

View File

@ -0,0 +1,16 @@
import styles from './CenterPanel.module.css';
import Preview from '../Preview/Preview';
import Controls from '../Controls/Controls';
import Timeline from '../Timeline/Timeline';
export default function CenterPanel() {
return (
<div className={styles.panel}>
<div className={styles.previewArea}>
<Preview />
</div>
<Controls />
<Timeline />
</div>
);
}

View File

@ -0,0 +1,94 @@
.controls {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
background: #1e1e1e;
border-top: 1px solid #333;
border-bottom: 1px solid #333;
flex-shrink: 0;
}
.btn {
background: #2d2d2d;
border: 1px solid #404040;
color: #ccc;
border-radius: 4px;
padding: 4px 10px;
font-size: 13px;
transition: background 0.1s;
min-width: 32px;
flex-shrink: 0;
}
.btn:hover:not(:disabled) { background: #3a3a3a; color: #fff; }
.btn:disabled { opacity: 0.35; cursor: default; }
.active {
color: #5ba3e0;
border-color: #4a7aaa;
}
/* ── Scrubber ───────────────────────────────────────────────── */
.scrubber {
flex: 1;
min-width: 0;
height: 4px;
-webkit-appearance: none;
appearance: none;
border-radius: 2px;
outline: none;
cursor: pointer;
border: none;
padding: 0;
/* filled portion via CSS variable set inline */
background: linear-gradient(
to right,
#5b9bd5 0%,
#5b9bd5 var(--progress, 0%),
#3a3a3a var(--progress, 0%),
#3a3a3a 100%
);
}
.scrubber:disabled {
opacity: 0.3;
cursor: default;
}
.scrubber::-webkit-slider-thumb {
-webkit-appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: #5b9bd5;
cursor: pointer;
border: 2px solid #1e1e1e;
transition: transform 0.1s;
}
.scrubber:not(:disabled)::-webkit-slider-thumb:hover {
transform: scale(1.3);
}
.scrubber::-moz-range-thumb {
width: 12px;
height: 12px;
border-radius: 50%;
background: #5b9bd5;
cursor: pointer;
border: 2px solid #1e1e1e;
}
.scrubber::-moz-range-track {
height: 4px;
border-radius: 2px;
background: #3a3a3a;
}
.time {
flex-shrink: 0;
font-size: 11px;
color: #888;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}

View File

@ -0,0 +1,67 @@
import { useCutsceneStore, useSelectedCutscene } from '../../store/cutsceneStore';
import { usePlayback } from '../../hooks/usePlayback';
import styles from './Controls.module.css';
function formatMs(ms: number) {
const s = Math.floor(ms / 1000);
const frac = Math.floor((ms % 1000) / 10).toString().padStart(2, '0');
return `${s}.${frac}s`;
}
export default function Controls() {
usePlayback();
const { playState, currentTimeMs, setPlayState, setCurrentTime } = useCutsceneStore();
const cutscene = useSelectedCutscene();
const totalMs = cutscene
? Math.max(
cutscene.durationMs,
cutscene.imageSegments.reduce((m, s) => Math.max(m, s.endMs), 0)
) + cutscene.endFadeOutMs + cutscene.endFadeInMs
: 0;
function play() {
if (playState === 'stopped' || currentTimeMs >= totalMs) setCurrentTime(0);
setPlayState('playing');
}
function pause() { setPlayState('paused'); }
function stop() { setPlayState('stopped'); setCurrentTime(0); }
function handleScrub(e: React.ChangeEvent<HTMLInputElement>) {
const ms = Number(e.target.value);
setCurrentTime(ms);
if (playState === 'playing') setPlayState('paused');
}
const progress = totalMs > 0 ? currentTimeMs / totalMs : 0;
return (
<div className={styles.controls}>
<button className={styles.btn} onClick={stop} title="Stop" disabled={!cutscene}></button>
<button className={styles.btn} onClick={() => setCurrentTime(0)} title="Rewind" disabled={!cutscene}></button>
{playState === 'playing' ? (
<button className={`${styles.btn} ${styles.active}`} onClick={pause} title="Pause" disabled={!cutscene}></button>
) : (
<button className={`${styles.btn} ${styles.active}`} onClick={play} title="Play" disabled={!cutscene}></button>
)}
<input
className={styles.scrubber}
type="range"
min={0}
max={totalMs || 1}
step={16}
value={currentTimeMs}
onChange={handleScrub}
disabled={!cutscene}
style={{ '--progress': `${progress * 100}%` } as React.CSSProperties}
/>
<div className={styles.time}>
{formatMs(currentTimeMs)} / {formatMs(totalMs)}
</div>
</div>
);
}

View File

@ -0,0 +1,100 @@
.panel {
width: 200px;
min-width: 180px;
background: #252525;
border-right: 1px solid #333;
display: flex;
flex-direction: column;
overflow: hidden;
}
.header {
padding: 10px 12px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
color: #888;
border-bottom: 1px solid #333;
}
.actions {
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px;
border-bottom: 1px solid #333;
}
.btn {
background: #333;
color: #ddd;
border: 1px solid #444;
border-radius: 4px;
padding: 5px 8px;
text-align: center;
transition: background 0.15s;
}
.btn:hover:not(:disabled) { background: #3d3d3d; }
.btn:disabled { opacity: 0.4; cursor: default; }
.btnPrimary {
composes: btn;
background: #2a4a6e;
border-color: #3a6090;
color: #a8d0f0;
}
.btnPrimary:hover { background: #2e5480; }
.list {
flex: 1;
overflow-y: auto;
padding: 4px 0;
}
.empty {
padding: 16px 12px;
color: #555;
font-size: 11px;
line-height: 1.5;
}
.item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 7px 12px;
cursor: pointer;
user-select: none;
border-left: 3px solid transparent;
transition: background 0.1s;
}
.item:hover { background: #2e2e2e; }
.selected {
background: #1e3a55;
border-left-color: #5b9bd5;
}
.itemId {
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.deleteBtn {
background: none;
border: none;
color: #666;
padding: 2px 4px;
border-radius: 3px;
font-size: 10px;
opacity: 0;
transition: opacity 0.1s, color 0.1s;
}
.item:hover .deleteBtn { opacity: 1; }
.deleteBtn:hover { color: #e06c6c; }

View File

@ -0,0 +1,76 @@
import { useRef } from 'react';
import { useCutsceneStore } from '../../store/cutsceneStore';
import { parseFile, triggerDownload } from '../../utils/fileIO';
import styles from './LeftPanel.module.css';
export default function LeftPanel() {
const fileInputRef = useRef<HTMLInputElement>(null);
const { file, selectedCutsceneId, loadFile, addCutscene, deleteCutscene, selectCutscene, getExportData } = useCutsceneStore();
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const f = e.target.files?.[0];
if (!f) return;
const reader = new FileReader();
reader.onload = (ev) => {
try {
const json = JSON.parse(ev.target!.result as string);
loadFile(parseFile(json));
} catch {
alert('Invalid JSON file');
}
};
reader.readAsText(f);
e.target.value = '';
}
function handleSave() {
const data = getExportData();
if (data) triggerDownload(data);
}
function handleDelete(id: string) {
if (confirm(`Delete cutscene "${id}"?`)) deleteCutscene(id);
}
return (
<div className={styles.panel}>
<div className={styles.header}>Cutscenes</div>
<div className={styles.actions}>
<button className={styles.btnPrimary} onClick={() => fileInputRef.current?.click()}>
Load JSON
</button>
<input ref={fileInputRef} type="file" accept=".json" style={{ display: 'none' }} onChange={handleFileChange} />
<button className={styles.btn} onClick={handleSave} disabled={!file}>
Save JSON
</button>
<button className={styles.btn} onClick={addCutscene}>
+ New
</button>
</div>
<div className={styles.list}>
{!file || file.cutscenes.length === 0 ? (
<div className={styles.empty}>No cutscenes. Load a JSON or create new.</div>
) : (
file.cutscenes.map(c => (
<div
key={c.id}
className={`${styles.item} ${c.id === selectedCutsceneId ? styles.selected : ''}`}
onClick={() => selectCutscene(c.id)}
>
<span className={styles.itemId}>{c.id}</span>
<button
className={styles.deleteBtn}
onClick={(e) => { e.stopPropagation(); handleDelete(c.id); }}
title="Delete"
>
</button>
</div>
))
)}
</div>
</div>
);
}

View File

@ -0,0 +1,51 @@
.wrapper {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.viewport {
position: relative;
aspect-ratio: 16 / 9;
width: 100%;
max-height: 100%;
background: #000;
overflow: hidden;
}
.empty {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: #444;
font-size: 13px;
}
.subtitleBar {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 10px 20px 14px;
background: linear-gradient(transparent, rgba(0,0,0,0.75));
text-align: center;
}
.speaker {
font-size: 11px;
font-weight: 600;
color: #f0c060;
margin-bottom: 4px;
text-shadow: 0 1px 3px rgba(0,0,0,0.8);
}
.text {
font-size: 14px;
color: #fff;
line-height: 1.5;
text-shadow: 0 1px 4px rgba(0,0,0,0.9);
}

View File

@ -0,0 +1,85 @@
import { useRef, useEffect, useState } from 'react';
import { useCutsceneStore, useSelectedCutscene } from '../../store/cutsceneStore';
import { computeSegmentState, poseToStyle } from '../../utils/rendering';
import styles from './Preview.module.css';
const LOGICAL_W = 1280;
const LOGICAL_H = 720;
function computeSubtitle(cutscene: ReturnType<typeof useSelectedCutscene>, currentMs: number) {
if (!cutscene) return null;
let elapsed = 0;
for (const line of cutscene.lines) {
const dur = line.durationMs > 0
? line.durationMs
: Math.max(1500, Math.round((line.text.length / 17) * 1000));
if (currentMs >= elapsed && currentMs < elapsed + dur) return line;
elapsed += dur;
}
return null;
}
export default function Preview() {
const containerRef = useRef<HTMLDivElement>(null);
const [containerSize, setContainerSize] = useState({ w: LOGICAL_W, h: LOGICAL_H });
const currentTimeMs = useCutsceneStore(s => s.currentTimeMs);
const cutscene = useSelectedCutscene();
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const ro = new ResizeObserver(entries => {
const e = entries[0];
if (e) setContainerSize({ w: e.contentRect.width, h: e.contentRect.height });
});
ro.observe(el);
return () => ro.disconnect();
}, []);
const subtitle = computeSubtitle(cutscene, currentTimeMs);
return (
<div className={styles.wrapper}>
<div className={styles.viewport} ref={containerRef}>
{!cutscene ? (
<div className={styles.empty}>Select or create a cutscene</div>
) : (
<>
{cutscene.imageSegments.map((seg, i) => {
const state = computeSegmentState(seg, currentTimeMs);
if (!state) return null;
const imgStyle = poseToStyle(
state.pose,
seg.width || LOGICAL_W,
seg.height || LOGICAL_H,
containerSize.w,
containerSize.h,
);
return (
<img
key={i}
src={`/${seg.path}`}
alt=""
style={{ ...imgStyle, opacity: state.alpha }}
draggable={false}
/>
);
})}
{subtitle && (
<div className={styles.subtitleBar}>
{subtitle.speaker && (
<div className={styles.speaker}>{subtitle.speaker}</div>
)}
<div className={styles.text}>{subtitle.text}</div>
</div>
)}
</>
)}
</div>
</div>
);
}

View File

@ -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>
);
}

View File

@ -0,0 +1,132 @@
import { useCutsceneStore, useSelectedCutscene } from '../../store/cutsceneStore';
import { useShallow } from 'zustand/react/shallow';
import { AVAILABLE_IMAGES } from '../../constants/images';
import { EASING_OPTIONS } from '../../constants/easings';
import type { ImagePose } from '../../types/cutscene';
import styles from './RightPanel.module.css';
function NumField({ label, value, onChange, min, step }: {
label: string; value: number; onChange: (v: number) => void; min?: number; step?: number;
}) {
return (
<div className={styles.field}>
<label>{label}</label>
<input
type="number"
value={value}
min={min}
step={step ?? 1}
onChange={e => onChange(Number(e.target.value))}
/>
</div>
);
}
function PoseFields({ label, pose, onChange }: {
label: string;
pose: ImagePose;
onChange: (patch: Partial<ImagePose>) => void;
}) {
return (
<div className={styles.poseGroup}>
<div className={styles.poseTitle}>{label}</div>
<div className={styles.poseRow}>
<div className={styles.field}>
<label>centerX</label>
<input type="number" value={pose.centerX} step={0.01} min={0} max={1}
onChange={e => onChange({ centerX: Number(e.target.value) })} />
</div>
<div className={styles.field}>
<label>centerY</label>
<input type="number" value={pose.centerY} step={0.01} min={0} max={1}
onChange={e => onChange({ centerY: Number(e.target.value) })} />
</div>
<div className={styles.field}>
<label>scale</label>
<input type="number" value={pose.scale} step={0.05} min={0.1}
onChange={e => onChange({ scale: Number(e.target.value) })} />
</div>
</div>
</div>
);
}
export default function LayerProperties() {
const cutscene = useSelectedCutscene();
const { selectedLayerIndex, updateLayer, updateLayerFrom, updateLayerTo } = useCutsceneStore(useShallow(s => ({
selectedLayerIndex: s.selectedLayerIndex,
updateLayer: s.updateLayer,
updateLayerFrom: s.updateLayerFrom,
updateLayerTo: s.updateLayerTo,
})));
if (!cutscene || selectedLayerIndex === null) return null;
const seg = cutscene.imageSegments[selectedLayerIndex];
if (!seg) return null;
return (
<div className={styles.section}>
<div className={styles.sectionTitle}>Layer {selectedLayerIndex + 1}</div>
<div className={styles.field}>
<label>Image</label>
<select
value={AVAILABLE_IMAGES.includes(seg.path) ? seg.path : '__custom__'}
onChange={e => {
if (e.target.value !== '__custom__') updateLayer(selectedLayerIndex, { path: e.target.value });
}}
>
{AVAILABLE_IMAGES.map(img => (
<option key={img} value={img}>{img.split('/').pop()}</option>
))}
{!AVAILABLE_IMAGES.includes(seg.path) && (
<option value="__custom__">(custom)</option>
)}
</select>
</div>
<div className={styles.field}>
<label>Path (manual)</label>
<input
type="text"
value={seg.path}
onChange={e => updateLayer(selectedLayerIndex, { path: e.target.value })}
placeholder="resources/..."
/>
</div>
<div className={styles.row2}>
<NumField label="Width" value={seg.width} onChange={v => updateLayer(selectedLayerIndex, { width: v })} min={1} />
<NumField label="Height" value={seg.height} onChange={v => updateLayer(selectedLayerIndex, { height: v })} min={1} />
</div>
<div className={styles.row2}>
<NumField label="Start (ms)" value={seg.startMs} onChange={v => updateLayer(selectedLayerIndex, { startMs: v })} min={0} />
<NumField label="End (ms)" value={seg.endMs} onChange={v => updateLayer(selectedLayerIndex, { endMs: v })} min={0} />
</div>
<div className={styles.row2}>
<NumField label="Fade in (ms)" value={seg.fadeInMs} onChange={v => updateLayer(selectedLayerIndex, { fadeInMs: v })} min={0} />
<NumField label="Fade out (ms)" value={seg.fadeOutMs} onChange={v => updateLayer(selectedLayerIndex, { fadeOutMs: v })} min={0} />
</div>
<div className={styles.field}>
<label>Easing</label>
<select value={seg.easing} onChange={e => updateLayer(selectedLayerIndex, { easing: e.target.value as typeof seg.easing })}>
{EASING_OPTIONS.map(e => <option key={e} value={e}>{e}</option>)}
</select>
</div>
<PoseFields
label="From"
pose={seg.from}
onChange={patch => updateLayerFrom(selectedLayerIndex, patch)}
/>
<PoseFields
label="To"
pose={seg.to}
onChange={patch => updateLayerTo(selectedLayerIndex, patch)}
/>
</div>
);
}

View File

@ -0,0 +1,208 @@
.panel {
width: 260px;
min-width: 240px;
background: #252525;
border-left: 1px solid #333;
display: flex;
flex-direction: column;
overflow: hidden;
}
.tabs {
display: flex;
border-bottom: 1px solid #333;
flex-shrink: 0;
}
.tab {
flex: 1;
padding: 8px 6px;
background: none;
border: none;
color: #777;
font-size: 11px;
border-bottom: 2px solid transparent;
transition: color 0.1s;
}
.tab:hover { color: #bbb; }
.tabActive {
color: #5b9bd5;
border-bottom-color: #5b9bd5;
}
.scroll {
flex: 1;
overflow-y: auto;
padding-bottom: 20px;
}
/* ── Sections ──────────────────────────────────────────────────── */
.section {
padding: 10px;
border-bottom: 1px solid #2e2e2e;
}
.sectionTitle {
font-size: 11px;
font-weight: 600;
color: #888;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 8px;
}
.sectionHeader {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.addBtn {
background: #2a4a6e;
border: 1px solid #3a6090;
color: #a8d0f0;
border-radius: 3px;
padding: 2px 8px;
font-size: 11px;
}
.addBtn:hover { background: #2e5480; }
/* ── Fields ────────────────────────────────────────────────────── */
.field {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 5px;
gap: 6px;
}
.field label {
flex-shrink: 0;
width: 100px;
text-align: right;
}
.field input[type="text"],
.field input[type="number"],
.field select,
.field textarea {
flex: 1;
min-width: 0;
width: 100%;
}
.field textarea {
resize: vertical;
min-height: 40px;
}
.field input[type="checkbox"] {
width: auto;
margin: 0;
}
.row2 {
display: flex;
gap: 6px;
}
.row2 .field {
flex: 1;
flex-direction: column;
align-items: flex-start;
}
.row2 .field label {
width: auto;
text-align: left;
margin-bottom: 2px;
}
/* ── Pose groups ───────────────────────────────────────────────── */
.poseGroup {
margin-top: 8px;
background: #1e1e1e;
border-radius: 4px;
padding: 6px 8px;
}
.poseTitle {
font-size: 10px;
font-weight: 600;
color: #666;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 5px;
}
.poseRow {
display: flex;
gap: 6px;
}
.poseRow .field {
flex: 1;
flex-direction: column;
align-items: flex-start;
margin-bottom: 0;
}
.poseRow .field label {
width: auto;
text-align: left;
margin-bottom: 2px;
}
/* ── Subtitle line cards ───────────────────────────────────────── */
.lineCard {
background: #1e1e1e;
border: 1px solid #2e2e2e;
border-radius: 4px;
padding: 8px;
margin-bottom: 6px;
}
.lineHeader {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 6px;
}
.lineIndex {
font-size: 10px;
color: #666;
flex: 1;
}
.iconBtn {
background: none;
border: 1px solid #3a3a3a;
color: #888;
border-radius: 3px;
padding: 1px 5px;
font-size: 11px;
}
.iconBtn:hover:not(:disabled) { background: #333; color: #ccc; }
.iconBtn:disabled { opacity: 0.3; cursor: default; }
.iconBtnDanger {
composes: iconBtn;
color: #a05050;
border-color: #5a2a2a;
}
.iconBtnDanger:hover { background: #3a2020; color: #e06c6c; }
.emptyLines {
color: #555;
font-size: 11px;
padding: 4px 0;
}
.empty {
padding: 20px;
color: #555;
font-size: 11px;
text-align: center;
}

View File

@ -0,0 +1,41 @@
import { useCutsceneStore } from '../../store/cutsceneStore';
import { useShallow } from 'zustand/react/shallow';
import CutsceneProperties from './CutsceneProperties';
import LayerProperties from './LayerProperties';
import SubtitleLines from './SubtitleLines';
import styles from './RightPanel.module.css';
export default function RightPanel() {
const { selectedLayerIndex, selectLayer } = useCutsceneStore(useShallow(s => ({
selectedLayerIndex: s.selectedLayerIndex,
selectLayer: s.selectLayer,
})));
return (
<div className={styles.panel}>
{/* Tab strip */}
<div className={styles.tabs}>
<button
className={`${styles.tab} ${selectedLayerIndex === null ? styles.tabActive : ''}`}
onClick={() => selectLayer(null)}
>
Cutscene
</button>
{selectedLayerIndex !== null && (
<button className={`${styles.tab} ${styles.tabActive}`}>
Layer {selectedLayerIndex + 1}
</button>
)}
</div>
<div className={styles.scroll}>
{selectedLayerIndex !== null ? (
<LayerProperties />
) : (
<CutsceneProperties />
)}
<SubtitleLines />
</div>
</div>
);
}

View File

@ -0,0 +1,89 @@
import { useCutsceneStore, useSelectedCutscene } from '../../store/cutsceneStore';
import { useShallow } from 'zustand/react/shallow';
import styles from './RightPanel.module.css';
export default function SubtitleLines() {
const cutscene = useSelectedCutscene();
const { addLine, removeLine, moveLineUp, moveLineDown, updateLine } = useCutsceneStore(useShallow(s => ({
addLine: s.addLine,
removeLine: s.removeLine,
moveLineUp: s.moveLineUp,
moveLineDown: s.moveLineDown,
updateLine: s.updateLine,
})));
if (!cutscene) return null;
return (
<div className={styles.section}>
<div className={styles.sectionHeader}>
<div className={styles.sectionTitle}>Subtitle Lines</div>
<button className={styles.addBtn} onClick={addLine}>+ Add</button>
</div>
{cutscene.lines.length === 0 && (
<div className={styles.emptyLines}>No lines. Click + Add.</div>
)}
{cutscene.lines.map((line, i) => (
<div key={i} className={styles.lineCard}>
<div className={styles.lineHeader}>
<span className={styles.lineIndex}>#{i + 1}</span>
<button className={styles.iconBtn} onClick={() => moveLineUp(i)} disabled={i === 0} title="Move up"></button>
<button className={styles.iconBtn} onClick={() => moveLineDown(i)} disabled={i === cutscene.lines.length - 1} title="Move down"></button>
<button className={styles.iconBtnDanger} onClick={() => removeLine(i)} title="Remove"></button>
</div>
<div className={styles.field}>
<label>Speaker</label>
<input
type="text"
value={line.speaker}
onChange={e => updateLine(i, { speaker: e.target.value })}
placeholder="(none)"
/>
</div>
<div className={styles.field}>
<label>Text</label>
<textarea
value={line.text}
rows={2}
onChange={e => updateLine(i, { text: e.target.value })}
/>
</div>
<div className={styles.row2}>
<div className={styles.field}>
<label>Duration (ms)</label>
<input
type="number"
value={line.durationMs}
min={0}
onChange={e => updateLine(i, { durationMs: Number(e.target.value) })}
/>
</div>
<div className={styles.field}>
<label>Wait confirm</label>
<input
type="checkbox"
checked={line.waitForConfirm}
onChange={e => updateLine(i, { waitForConfirm: e.target.checked })}
/>
</div>
</div>
<div className={styles.field}>
<label>Lua callback</label>
<input
type="text"
value={line.luaCallback}
onChange={e => updateLine(i, { luaCallback: e.target.value })}
placeholder="function name"
/>
</div>
</div>
))}
</div>
);
}

View File

@ -0,0 +1,192 @@
.container {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
background: #1a1a1a;
border-top: 1px solid #333;
overflow: hidden;
}
.toolbar {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
background: #222;
border-bottom: 1px solid #333;
flex-shrink: 0;
}
.tbBtn {
background: #2d2d2d;
border: 1px solid #404040;
color: #bbb;
border-radius: 3px;
padding: 3px 8px;
font-size: 11px;
transition: background 0.1s;
}
.tbBtn:hover:not(:disabled) { background: #3a3a3a; color: #fff; }
.tbBtn:disabled { opacity: 0.3; cursor: default; }
.spacer { flex: 1; }
.zoomLabel { font-size: 11px; color: #666; margin-right: 2px; }
.scroll {
flex: 1;
overflow: auto;
position: relative;
cursor: default;
}
/* ── Ruler ────────────────────────────────────────────────────── */
.ruler {
display: flex;
height: 22px;
background: #212121;
border-bottom: 1px solid #333;
position: sticky;
top: 0;
z-index: 10;
user-select: none;
cursor: crosshair;
}
.tick {
position: absolute;
top: 0;
bottom: 0;
width: 1px;
background: #444;
}
.tickLabel {
position: absolute;
top: 3px;
left: 3px;
font-size: 9px;
color: #666;
white-space: nowrap;
}
/* ── Layers area ──────────────────────────────────────────────── */
.layersArea {
position: relative;
}
.playhead {
position: absolute;
top: 0;
bottom: 0;
width: 2px;
background: #e05050;
z-index: 20;
pointer-events: none;
}
.gridLine {
position: absolute;
top: 0;
bottom: 0;
width: 1px;
background: rgba(255,255,255,0.04);
pointer-events: none;
}
/* ── Layer row ────────────────────────────────────────────────── */
.row {
position: absolute;
left: 0;
right: 0;
height: 32px;
display: flex;
align-items: center;
border-bottom: 1px solid #2a2a2a;
cursor: pointer;
transition: background 0.1s;
}
.row:hover { background: rgba(255,255,255,0.03); }
.rowSelected { background: rgba(91,155,213,0.08); }
.rowLabel {
position: absolute;
left: 0;
width: 140px;
height: 100%;
display: flex;
align-items: center;
padding: 0 8px;
background: #1e1e1e;
border-right: 1px solid #2a2a2a;
z-index: 5;
overflow: hidden;
flex-shrink: 0;
}
.layerName {
font-size: 11px;
color: #bbb;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ── Segment bar ──────────────────────────────────────────────── */
.bar {
position: absolute;
height: 22px;
border-radius: 3px;
cursor: grab;
top: 5px;
border: 1px solid rgba(255,255,255,0.15);
box-sizing: border-box;
min-width: 4px;
}
.bar:active { cursor: grabbing; }
.handle {
position: absolute;
top: 0;
bottom: 0;
width: 6px;
cursor: ew-resize;
z-index: 2;
}
.handleLeft { left: 0; border-radius: 3px 0 0 3px; background: rgba(255,255,255,0.15); }
.handleRight { right: 0; border-radius: 0 3px 3px 0; background: rgba(255,255,255,0.15); }
/* ── Subtitle row ─────────────────────────────────────────────── */
.subtitleRow {
position: absolute;
left: 0;
right: 0;
height: 14px;
border-bottom: 1px solid #2a2a2a;
}
.subtitleLabel {
position: absolute;
left: 0;
width: 140px;
height: 100%;
background: #1e1e1e;
border-right: 1px solid #2a2a2a;
display: flex;
align-items: center;
padding: 0 8px;
font-size: 9px;
color: #555;
z-index: 5;
}
.subtitleBlock {
position: absolute;
top: 2px;
height: 10px;
background: #8a6040;
border-radius: 2px;
border: 1px solid #aa7050;
opacity: 0.8;
}

View File

@ -0,0 +1,285 @@
import { useRef, useCallback, useState, useEffect } from 'react';
import { useCutsceneStore, useSelectedCutscene } from '../../store/cutsceneStore';
import { useShallow } from 'zustand/react/shallow';
import styles from './Timeline.module.css';
const LABEL_WIDTH = 140;
const ROW_HEIGHT = 32;
const SUBTITLE_ROW_HEIGHT = 14;
const MIN_ZOOM = 20; // px per second
const MAX_ZOOM = 400;
const LAYER_COLORS = ['#4a7fc1', '#c17a4a', '#4ac17a', '#c14a7a', '#7a4ac1', '#c1b44a', '#4ac1c1'];
function layerColor(i: number) { return LAYER_COLORS[i % LAYER_COLORS.length]; }
function basename(path: string) {
return path.split('/').pop() ?? path;
}
export default function Timeline() {
const cutscene = useSelectedCutscene();
const { selectedLayerIndex, currentTimeMs, playState } = useCutsceneStore(useShallow(s => ({
selectedLayerIndex: s.selectedLayerIndex,
currentTimeMs: s.currentTimeMs,
playState: s.playState,
})));
const { selectLayer, addLayer, removeLayer, moveLayerUp, moveLayerDown, updateLayer, setCurrentTime, setPlayState } = useCutsceneStore(useShallow(s => ({
selectLayer: s.selectLayer,
addLayer: s.addLayer,
removeLayer: s.removeLayer,
moveLayerUp: s.moveLayerUp,
moveLayerDown: s.moveLayerDown,
updateLayer: s.updateLayer,
setCurrentTime: s.setCurrentTime,
setPlayState: s.setPlayState,
})));
const [pxPerSec, setPxPerSec] = useState(60);
const scrollRef = useRef<HTMLDivElement>(null);
const isDraggingPlayhead = useRef(false);
const dragState = useRef<{
type: 'move' | 'left' | 'right';
layerIndex: number;
startX: number;
startMs: number;
endMs: number;
durationMs: number;
} | null>(null);
const msToX = useCallback((ms: number) => (ms / 1000) * pxPerSec, [pxPerSec]);
const xToMs = useCallback((x: number) => Math.max(0, Math.round((x / pxPerSec) * 1000)), [pxPerSec]);
const totalMs = cutscene
? Math.max(
cutscene.durationMs,
cutscene.imageSegments.reduce((m, s) => Math.max(m, s.endMs), 0),
10000,
) + 2000
: 12000;
const rulerWidth = Math.ceil(msToX(totalMs));
// Ruler ticks
const tickStepMs = pxPerSec >= 100 ? 500 : pxPerSec >= 50 ? 1000 : 2000;
const labelStepMs = pxPerSec >= 100 ? 1000 : pxPerSec >= 50 ? 2000 : 4000;
const ticks: number[] = [];
for (let ms = 0; ms <= totalMs; ms += tickStepMs) ticks.push(ms);
// Scroll wheel zoom
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
function onWheel(e: WheelEvent) {
if (!e.ctrlKey && !e.metaKey) return;
e.preventDefault();
setPxPerSec(prev => Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, prev * (e.deltaY < 0 ? 1.15 : 0.87))));
}
el.addEventListener('wheel', onWheel, { passive: false });
return () => el.removeEventListener('wheel', onWheel);
}, []);
// Auto-scroll playhead into view while playing
useEffect(() => {
if (playState !== 'playing') return;
const el = scrollRef.current;
if (!el) return;
const x = msToX(currentTimeMs) + LABEL_WIDTH;
const { scrollLeft, clientWidth } = el;
if (x > scrollLeft + clientWidth - 40) {
el.scrollLeft = x - clientWidth + 80;
}
}, [currentTimeMs, playState, msToX]);
// ── Playhead drag ──────────────────────────────────────────────────────────
function onRulerMouseDown(e: React.MouseEvent) {
if (e.button !== 0) return;
isDraggingPlayhead.current = true;
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const x = e.clientX - rect.left - LABEL_WIDTH + (scrollRef.current?.scrollLeft ?? 0);
setCurrentTime(xToMs(x));
if (playState === 'playing') setPlayState('paused');
function onMove(ev: MouseEvent) {
const x2 = ev.clientX - rect.left - LABEL_WIDTH + (scrollRef.current?.scrollLeft ?? 0);
setCurrentTime(xToMs(x2));
}
function onUp() {
isDraggingPlayhead.current = false;
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
}
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
}
// ── Segment bar drag ───────────────────────────────────────────────────────
function onBarMouseDown(e: React.MouseEvent, layerIndex: number, type: 'move' | 'left' | 'right') {
e.preventDefault();
e.stopPropagation();
selectLayer(layerIndex);
const seg = cutscene!.imageSegments[layerIndex];
dragState.current = {
type,
layerIndex,
startX: e.clientX,
startMs: seg.startMs,
endMs: seg.endMs,
durationMs: seg.endMs - seg.startMs,
};
function onMove(ev: MouseEvent) {
if (!dragState.current) return;
const dx = ev.clientX - dragState.current.startX;
const dMs = Math.round((dx / pxPerSec) * 1000);
const { type, layerIndex: li, startMs, endMs, durationMs } = dragState.current;
if (type === 'move') {
const newStart = Math.max(0, startMs + dMs);
updateLayer(li, { startMs: newStart, endMs: newStart + durationMs });
} else if (type === 'left') {
const newStart = Math.max(0, Math.min(endMs - 100, startMs + dMs));
updateLayer(li, { startMs: newStart });
} else {
const newEnd = Math.max(startMs + 100, endMs + dMs);
updateLayer(li, { endMs: newEnd });
}
}
function onUp() {
dragState.current = null;
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
}
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
}
const playheadX = msToX(currentTimeMs) + LABEL_WIDTH;
// Subtitle timing for display
let subtitleBlocks: { x: number; w: number; text: string }[] = [];
if (cutscene) {
let elapsed = 0;
for (const line of cutscene.lines) {
const dur = line.durationMs > 0
? line.durationMs
: Math.max(1500, Math.round((line.text.length / 17) * 1000));
subtitleBlocks.push({ x: msToX(elapsed), w: msToX(dur), text: line.text || '…' });
elapsed += dur;
}
}
const layerCount = cutscene?.imageSegments.length ?? 0;
const totalHeight = layerCount * ROW_HEIGHT + SUBTITLE_ROW_HEIGHT + 20;
return (
<div className={styles.container}>
{/* Toolbar */}
<div className={styles.toolbar}>
<button className={styles.tbBtn} onClick={addLayer} disabled={!cutscene} title="Add layer">+ Layer</button>
{selectedLayerIndex !== null && cutscene && (
<>
<button className={styles.tbBtn} onClick={() => moveLayerUp(selectedLayerIndex!)} disabled={selectedLayerIndex! <= 0} title="Move up"></button>
<button className={styles.tbBtn} onClick={() => moveLayerDown(selectedLayerIndex!)} disabled={selectedLayerIndex! >= layerCount - 1} title="Move down"></button>
<button className={styles.tbBtn} onClick={() => removeLayer(selectedLayerIndex!)} title="Remove layer" style={{ color: '#e06c6c' }}> Layer</button>
</>
)}
<div className={styles.spacer} />
<span className={styles.zoomLabel}>Zoom:</span>
<button className={styles.tbBtn} onClick={() => setPxPerSec(p => Math.max(MIN_ZOOM, p * 0.75))}></button>
<button className={styles.tbBtn} onClick={() => setPxPerSec(p => Math.min(MAX_ZOOM, p * 1.33))}>+</button>
<button className={styles.tbBtn} onClick={() => setPxPerSec(60)}>Reset</button>
</div>
{/* Scrollable area */}
<div className={styles.scroll} ref={scrollRef}>
{/* Ruler */}
<div
className={styles.ruler}
style={{ width: LABEL_WIDTH + rulerWidth }}
onMouseDown={onRulerMouseDown}
>
<div style={{ width: LABEL_WIDTH, flexShrink: 0 }} />
<div style={{ position: 'relative', flex: 1 }}>
{ticks.map(ms => (
<div
key={ms}
className={styles.tick}
style={{ left: msToX(ms) }}
>
{ms % labelStepMs === 0 && (
<span className={styles.tickLabel}>{ms / 1000}s</span>
)}
</div>
))}
</div>
</div>
{/* Layers + playhead overlay */}
<div
className={styles.layersArea}
style={{ width: LABEL_WIDTH + rulerWidth, minHeight: totalHeight }}
>
{/* Playhead */}
<div className={styles.playhead} style={{ left: playheadX }} />
{/* Grid lines */}
{ticks.filter(ms => ms % labelStepMs === 0).map(ms => (
<div key={ms} className={styles.gridLine} style={{ left: LABEL_WIDTH + msToX(ms) }} />
))}
{/* Layer rows */}
{cutscene?.imageSegments.map((seg, i) => {
const isSelected = selectedLayerIndex === i;
const color = layerColor(i);
const barX = LABEL_WIDTH + msToX(seg.startMs);
const barW = Math.max(4, msToX(seg.endMs) - msToX(seg.startMs));
return (
<div
key={i}
className={`${styles.row} ${isSelected ? styles.rowSelected : ''}`}
style={{ top: i * ROW_HEIGHT }}
onClick={() => selectLayer(i)}
>
{/* Label */}
<div className={styles.rowLabel} style={{ borderLeft: `3px solid ${color}` }}>
<span className={styles.layerName}>{basename(seg.path) || `Layer ${i + 1}`}</span>
</div>
{/* Bar */}
<div
className={styles.bar}
style={{ left: barX, width: barW, background: color + (isSelected ? 'cc' : '88') }}
onMouseDown={e => onBarMouseDown(e, i, 'move')}
>
<div className={`${styles.handle} ${styles.handleLeft}`}
onMouseDown={e => onBarMouseDown(e, i, 'left')} />
<div className={`${styles.handle} ${styles.handleRight}`}
onMouseDown={e => onBarMouseDown(e, i, 'right')} />
</div>
</div>
);
})}
{/* Subtitle blocks row */}
{cutscene && (
<div
className={styles.subtitleRow}
style={{ top: layerCount * ROW_HEIGHT }}
>
<div className={styles.subtitleLabel}>Subtitles</div>
{subtitleBlocks.map((b, i) => (
<div
key={i}
className={styles.subtitleBlock}
style={{ left: LABEL_WIDTH + b.x, width: Math.max(2, b.w) }}
title={b.text}
/>
))}
</div>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,25 @@
import type { EasingType } from '../types/cutscene';
export const EASING_OPTIONS: EasingType[] = [
'Linear',
'EaseInSine', 'EaseOutSine', 'EaseInOutSine',
'EaseInQuad', 'EaseOutQuad', 'EaseInOutQuad',
'EaseInCubic', 'EaseOutCubic', 'EaseInOutCubic',
];
export function applyEasing(t: number, easing: EasingType): number {
const pi = Math.PI;
switch (easing) {
case 'Linear': return t;
case 'EaseInSine': return 1 - Math.cos((t * pi) / 2);
case 'EaseOutSine': return Math.sin((t * pi) / 2);
case 'EaseInOutSine': return -(Math.cos(pi * t) - 1) / 2;
case 'EaseInQuad': return t * t;
case 'EaseOutQuad': return 1 - (1 - t) * (1 - t);
case 'EaseInOutQuad': return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
case 'EaseInCubic': return t * t * t;
case 'EaseOutCubic': return 1 - Math.pow(1 - t, 3);
case 'EaseInOutCubic':return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
default: return t;
}
}

View File

@ -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',
];

View File

@ -0,0 +1,52 @@
import { useEffect, useRef } from 'react';
import { useCutsceneStore, useSelectedCutscene } from '../store/cutsceneStore';
export function usePlayback() {
const { playState, currentTimeMs, setPlayState, setCurrentTime } = useCutsceneStore();
const cutscene = useSelectedCutscene();
const rafRef = useRef<number | null>(null);
const lastTsRef = useRef<number | null>(null);
useEffect(() => {
if (playState !== 'playing') {
lastTsRef.current = null;
if (rafRef.current !== null) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
return;
}
function totalDuration() {
if (!cutscene) return 5000;
const segMax = cutscene.imageSegments.reduce((m, s) => Math.max(m, s.endMs), 0);
const content = Math.max(cutscene.durationMs, segMax);
return content + cutscene.endFadeOutMs + cutscene.endFadeInMs;
}
function tick(ts: number) {
if (lastTsRef.current === null) lastTsRef.current = ts;
const delta = ts - lastTsRef.current;
lastTsRef.current = ts;
const newTime = useCutsceneStore.getState().currentTimeMs + delta;
const total = totalDuration();
if (newTime >= total) {
setCurrentTime(total);
setPlayState('stopped');
return;
}
setCurrentTime(newTime);
rafRef.current = requestAnimationFrame(tick);
}
rafRef.current = requestAnimationFrame(tick);
return () => {
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
};
}, [playState, cutscene, setPlayState, setCurrentTime]);
return null;
}

View File

@ -0,0 +1,52 @@
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body, #root {
height: 100%;
width: 100%;
overflow: hidden;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: 13px;
background: #1a1a1a;
color: #e0e0e0;
}
button {
cursor: pointer;
font-family: inherit;
font-size: 12px;
}
input, select, textarea {
font-family: inherit;
font-size: 12px;
background: #2a2a2a;
color: #e0e0e0;
border: 1px solid #444;
border-radius: 3px;
padding: 3px 6px;
}
input:focus, select:focus, textarea:focus {
outline: none;
border-color: #5b9bd5;
}
label {
color: #aaa;
font-size: 11px;
}
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track { background: #1a1a1a; }
::-webkit-scrollbar-thumb { background: #444; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #666; }

View File

@ -0,0 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>
);

View File

@ -0,0 +1,242 @@
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
import type { CutsceneFile, Cutscene, ImageSegment, SubtitleLine, ImagePose } from '../types/cutscene';
export type PlayState = 'stopped' | 'playing' | 'paused';
interface CutsceneStore {
file: CutsceneFile | null;
selectedCutsceneId: string | null;
selectedLayerIndex: number | null;
playState: PlayState;
currentTimeMs: number;
// File I/O
loadFile: (file: CutsceneFile) => void;
getExportData: () => CutsceneFile | null;
// Cutscene CRUD
addCutscene: () => void;
deleteCutscene: (id: string) => void;
selectCutscene: (id: string | null) => void;
updateCutscene: (id: string, patch: Partial<Omit<Cutscene, 'imageSegments' | 'lines'>>) => void;
// Layer CRUD
selectLayer: (index: number | null) => void;
addLayer: () => void;
removeLayer: (index: number) => void;
moveLayerUp: (index: number) => void;
moveLayerDown: (index: number) => void;
updateLayer: (index: number, patch: Partial<ImageSegment>) => void;
updateLayerFrom: (index: number, patch: Partial<ImagePose>) => void;
updateLayerTo: (index: number, patch: Partial<ImagePose>) => void;
// Subtitle lines
addLine: () => void;
removeLine: (index: number) => void;
moveLineUp: (index: number) => void;
moveLineDown: (index: number) => void;
updateLine: (index: number, patch: Partial<SubtitleLine>) => void;
// Playback
setPlayState: (state: PlayState) => void;
setCurrentTime: (ms: number) => void;
}
function newCutscene(id: string): Cutscene {
return {
id,
skippable: true,
durationMs: 5000,
fadeOutMs: 500,
fadeInMs: 500,
endFadeOutMs: 500,
endFadeInMs: 500,
onFadeInCallback: '',
imageSegments: [],
lines: [],
};
}
function newSegment(): ImageSegment {
return {
path: '',
width: 1280,
height: 720,
startMs: 0,
endMs: 5000,
fadeInMs: 0,
fadeOutMs: 0,
easing: 'Linear',
from: { centerX: 0.5, centerY: 0.5, scale: 1.0 },
to: { centerX: 0.5, centerY: 0.5, scale: 1.0 },
};
}
function newLine(): SubtitleLine {
return {
speaker: '',
text: '',
durationMs: 3000,
waitForConfirm: false,
luaCallback: '',
};
}
function getSelected(file: CutsceneFile | null, id: string | null): Cutscene | null {
if (!file || !id) return null;
return file.cutscenes.find(c => c.id === id) ?? null;
}
export const useCutsceneStore = create<CutsceneStore>()(
immer((set, get) => ({
file: null,
selectedCutsceneId: null,
selectedLayerIndex: null,
playState: 'stopped',
currentTimeMs: 0,
loadFile: (file) => set(s => {
s.file = file;
s.selectedCutsceneId = file.cutscenes[0]?.id ?? null;
s.selectedLayerIndex = null;
s.playState = 'stopped';
s.currentTimeMs = 0;
}),
getExportData: () => get().file,
addCutscene: () => set(s => {
if (!s.file) s.file = { cutscenes: [] };
let base = 'cutscene_new';
let n = 1;
const ids = new Set(s.file.cutscenes.map(c => c.id));
while (ids.has(`${base}_${n}`)) n++;
const id = `${base}_${n}`;
s.file.cutscenes.push(newCutscene(id));
s.selectedCutsceneId = id;
s.selectedLayerIndex = null;
}),
deleteCutscene: (id) => set(s => {
if (!s.file) return;
const idx = s.file.cutscenes.findIndex(c => c.id === id);
if (idx === -1) return;
s.file.cutscenes.splice(idx, 1);
if (s.selectedCutsceneId === id) {
s.selectedCutsceneId = s.file.cutscenes[0]?.id ?? null;
s.selectedLayerIndex = null;
}
}),
selectCutscene: (id) => set(s => {
s.selectedCutsceneId = id;
s.selectedLayerIndex = null;
s.playState = 'stopped';
s.currentTimeMs = 0;
}),
updateCutscene: (id, patch) => set(s => {
if (!s.file) return;
const c = s.file.cutscenes.find(c => c.id === id);
if (!c) return;
Object.assign(c, patch);
}),
selectLayer: (index) => set(s => { s.selectedLayerIndex = index; }),
addLayer: () => set(s => {
const cutscene = getSelected(s.file, s.selectedCutsceneId);
if (!cutscene) return;
cutscene.imageSegments.push(newSegment());
s.selectedLayerIndex = cutscene.imageSegments.length - 1;
}),
removeLayer: (index) => set(s => {
const cutscene = getSelected(s.file, s.selectedCutsceneId);
if (!cutscene) return;
cutscene.imageSegments.splice(index, 1);
if (s.selectedLayerIndex === index) s.selectedLayerIndex = null;
else if (s.selectedLayerIndex !== null && s.selectedLayerIndex > index) s.selectedLayerIndex--;
}),
moveLayerUp: (index) => set(s => {
const cutscene = getSelected(s.file, s.selectedCutsceneId);
if (!cutscene || index <= 0) return;
const segs = cutscene.imageSegments;
[segs[index - 1], segs[index]] = [segs[index], segs[index - 1]];
if (s.selectedLayerIndex === index) s.selectedLayerIndex = index - 1;
else if (s.selectedLayerIndex === index - 1) s.selectedLayerIndex = index;
}),
moveLayerDown: (index) => set(s => {
const cutscene = getSelected(s.file, s.selectedCutsceneId);
if (!cutscene) return;
const segs = cutscene.imageSegments;
if (index >= segs.length - 1) return;
[segs[index], segs[index + 1]] = [segs[index + 1], segs[index]];
if (s.selectedLayerIndex === index) s.selectedLayerIndex = index + 1;
else if (s.selectedLayerIndex === index + 1) s.selectedLayerIndex = index;
}),
updateLayer: (index, patch) => set(s => {
const cutscene = getSelected(s.file, s.selectedCutsceneId);
if (!cutscene) return;
Object.assign(cutscene.imageSegments[index], patch);
}),
updateLayerFrom: (index, patch) => set(s => {
const cutscene = getSelected(s.file, s.selectedCutsceneId);
if (!cutscene) return;
Object.assign(cutscene.imageSegments[index].from, patch);
}),
updateLayerTo: (index, patch) => set(s => {
const cutscene = getSelected(s.file, s.selectedCutsceneId);
if (!cutscene) return;
Object.assign(cutscene.imageSegments[index].to, patch);
}),
addLine: () => set(s => {
const cutscene = getSelected(s.file, s.selectedCutsceneId);
if (!cutscene) return;
cutscene.lines.push(newLine());
}),
removeLine: (index) => set(s => {
const cutscene = getSelected(s.file, s.selectedCutsceneId);
if (!cutscene) return;
cutscene.lines.splice(index, 1);
}),
moveLineUp: (index) => set(s => {
const cutscene = getSelected(s.file, s.selectedCutsceneId);
if (!cutscene || index <= 0) return;
const lines = cutscene.lines;
[lines[index - 1], lines[index]] = [lines[index], lines[index - 1]];
}),
moveLineDown: (index) => set(s => {
const cutscene = getSelected(s.file, s.selectedCutsceneId);
if (!cutscene) return;
const lines = cutscene.lines;
if (index >= lines.length - 1) return;
[lines[index], lines[index + 1]] = [lines[index + 1], lines[index]];
}),
updateLine: (index, patch) => set(s => {
const cutscene = getSelected(s.file, s.selectedCutsceneId);
if (!cutscene) return;
Object.assign(cutscene.lines[index], patch);
}),
setPlayState: (state) => set(s => { s.playState = state; }),
setCurrentTime: (ms) => set(s => { s.currentTimeMs = ms; }),
}))
);
export function useSelectedCutscene() {
return useCutsceneStore(s =>
s.file?.cutscenes.find(c => c.id === s.selectedCutsceneId) ?? null
);
}

View File

@ -0,0 +1,49 @@
export type EasingType =
| 'Linear'
| 'EaseInSine' | 'EaseOutSine' | 'EaseInOutSine'
| 'EaseInQuad' | 'EaseOutQuad' | 'EaseInOutQuad'
| 'EaseInCubic' | 'EaseOutCubic' | 'EaseInOutCubic';
export interface ImagePose {
centerX: number;
centerY: number;
scale: number;
}
export interface ImageSegment {
path: string;
width: number;
height: number;
startMs: number;
endMs: number;
fadeInMs: number;
fadeOutMs: number;
easing: EasingType;
from: ImagePose;
to: ImagePose;
}
export interface SubtitleLine {
speaker: string;
text: string;
durationMs: number;
waitForConfirm: boolean;
luaCallback: string;
}
export interface Cutscene {
id: string;
skippable: boolean;
durationMs: number;
fadeOutMs: number;
fadeInMs: number;
endFadeOutMs: number;
endFadeInMs: number;
onFadeInCallback: string;
imageSegments: ImageSegment[];
lines: SubtitleLine[];
}
export interface CutsceneFile {
cutscenes: Cutscene[];
}

View File

@ -0,0 +1,76 @@
import type { CutsceneFile, Cutscene, ImageSegment, SubtitleLine } from '../types/cutscene';
function parseSegment(raw: Record<string, unknown>): ImageSegment {
const from = (raw.from as Record<string, number> | undefined) ?? {};
const to = (raw.to as Record<string, number> | undefined) ?? {};
return {
path: String(raw.path ?? ''),
width: Number(raw.width ?? 1280),
height: Number(raw.height ?? 720),
startMs: Number(raw.startMs ?? 0),
endMs: Number(raw.endMs ?? 5000),
fadeInMs: Number(raw.fadeInMs ?? 0),
fadeOutMs: Number(raw.fadeOutMs ?? 0),
easing: String(raw.easing ?? 'Linear') as ImageSegment['easing'],
from: {
centerX: Number(from.centerX ?? 0.5),
centerY: Number(from.centerY ?? 0.5),
scale: Number(from.scale ?? 1.0),
},
to: {
centerX: Number(to.centerX ?? from.centerX ?? 0.5),
centerY: Number(to.centerY ?? from.centerY ?? 0.5),
scale: Number(to.scale ?? from.scale ?? 1.0),
},
};
}
function parseLine(raw: Record<string, unknown>): SubtitleLine {
return {
speaker: String(raw.speaker ?? ''),
text: String(raw.text ?? ''),
durationMs: Number(raw.durationMs ?? 0),
waitForConfirm: Boolean(raw.waitForConfirm ?? false),
luaCallback: String(raw.luaCallback ?? ''),
};
}
function parseCutscene(raw: Record<string, unknown>): Cutscene {
const segments = Array.isArray(raw.imageSegments)
? (raw.imageSegments as Record<string, unknown>[]).map(parseSegment)
: [];
const lines = Array.isArray(raw.lines)
? (raw.lines as Record<string, unknown>[]).map(parseLine)
: [];
return {
id: String(raw.id ?? 'untitled'),
skippable: raw.skippable !== false,
durationMs: Number(raw.durationMs ?? 0),
fadeOutMs: Number(raw.fadeOutMs ?? 0),
fadeInMs: Number(raw.fadeInMs ?? 0),
endFadeOutMs: Number(raw.endFadeOutMs ?? 0),
endFadeInMs: Number(raw.endFadeInMs ?? 0),
onFadeInCallback: String(raw.onFadeInCallback ?? ''),
imageSegments: segments,
lines,
};
}
export function parseFile(json: unknown): CutsceneFile {
const raw = json as Record<string, unknown>;
const cutscenes = Array.isArray(raw.cutscenes)
? (raw.cutscenes as Record<string, unknown>[]).map(parseCutscene)
: [];
return { cutscenes };
}
export function triggerDownload(file: CutsceneFile, filename = 'cutscenes.json') {
const json = JSON.stringify(file, null, 4);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}

View File

@ -0,0 +1,79 @@
import type { ImageSegment, ImagePose } from '../types/cutscene';
import { applyEasing } from '../constants/easings';
function clamp(v: number, min: number, max: number) {
return Math.max(min, Math.min(max, v));
}
function lerp(a: number, b: number, t: number) {
return a + (b - a) * t;
}
export interface SegmentRenderState {
alpha: number;
pose: ImagePose;
}
export function computeSegmentState(seg: ImageSegment, currentMs: number): SegmentRenderState | null {
if (currentMs < seg.startMs || currentMs > seg.endMs) return null;
const duration = seg.endMs - seg.startMs;
const localMs = currentMs - seg.startMs;
const t = duration > 0 ? clamp(localMs / duration, 0, 1) : 1;
const tEased = applyEasing(t, seg.easing);
const pose: ImagePose = {
centerX: lerp(seg.from.centerX, seg.to.centerX, tEased),
centerY: lerp(seg.from.centerY, seg.to.centerY, tEased),
scale: lerp(seg.from.scale, seg.to.scale, tEased),
};
let alpha = 1;
if (seg.fadeInMs > 0 && localMs < seg.fadeInMs) {
alpha = localMs / seg.fadeInMs;
} else if (seg.fadeOutMs > 0 && localMs > duration - seg.fadeOutMs) {
alpha = (duration - localMs) / seg.fadeOutMs;
}
return { alpha: clamp(alpha, 0, 1), pose };
}
/**
* Returns CSS for an absolutely-positioned <img> inside the viewport div.
*
* The image is stretched (object-fit: fill) to exactly logicalW × logicalH
* logical pixels on screen matching how the game engine maps the full
* texture quad to those dimensions, regardless of the file's natural size.
*
* At scale=1 the logical area fills the viewport (aspect-ratio corrected).
* The viewport's own overflow:hidden clips anything that extends beyond.
*/
export function poseToStyle(
pose: ImagePose,
logicalW: number, // segment.width (e.g. 1280)
logicalH: number, // segment.height (e.g. 720)
containerW: number,
containerH: number,
): React.CSSProperties {
const baseScale = Math.max(containerW / logicalW, containerH / logicalH);
const renderW = logicalW * baseScale * pose.scale;
const renderH = logicalH * baseScale * pose.scale;
const maxOffsetX = Math.max(0, (renderW - containerW) / 2);
const maxOffsetY = Math.max(0, (renderH - containerH) / 2);
const offsetX = clamp((0.5 - pose.centerX) * renderW, -maxOffsetX, maxOffsetX);
// Y axis is inverted: centerY=0 → bottom of image, centerY=1 → top (Y-up convention)
const offsetY = clamp((pose.centerY - 0.5) * renderH, -maxOffsetY, maxOffsetY);
return {
position: 'absolute',
top: '50%',
left: '50%',
width: renderW,
height: renderH,
// Default object-fit (fill) stretches the full texture to these logical
// dimensions, exactly as the game engine renders the quad.
objectFit: 'fill',
transform: `translate(calc(-50% + ${offsetX}px), calc(-50% + ${offsetY}px))`,
};
}

6
cutsceneEditor/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,6 @@
/// <reference types="vite/client" />
declare module '*.module.css' {
const classes: Record<string, string>;
export default classes;
}

View File

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

View File

@ -0,0 +1,26 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import fs from 'fs';
import path from 'path';
export default defineConfig({
plugins: [
react(),
{
name: 'serve-resources',
configureServer(server) {
server.middlewares.use('/resources', (req, res, next) => {
const filePath = path.join(process.cwd(), 'resources', decodeURIComponent(req.url ?? ''));
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
const ext = path.extname(filePath).toLowerCase();
const mime = ext === '.png' ? 'image/png' : ext === '.jpg' ? 'image/jpeg' : 'application/octet-stream';
res.setHeader('Content-Type', mime);
fs.createReadStream(filePath).pipe(res as import('stream').Writable);
} else {
next();
}
});
},
},
],
});

43
dialogEditor/.gitignore vendored Normal file
View File

@ -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

View File

@ -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
}
]
}
]
}

12
dialogEditor/index.html Normal file
View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Dialogue Editor</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2157
dialogEditor/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
dialogEditor/package.json Normal file
View File

@ -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"
}
}

View File

@ -0,0 +1,501 @@
{
"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": [
{
"id": "line_1",
"type": "Line",
"speaker": "Ghost",
"portrait": "resources/ghost_avatar.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": "Ghost",
"portrait": "resources/ghost_avatar.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": "ghost_choice_dialogue",
"start": "line_1",
"nodes": [
{
"id": "line_1",
"type": "Line",
"speaker": "Беспокойный Призрак",
"portrait": "resources/w/avatar_ghost.png",
"text": "Нечасто я вижу смертных, готовых разговаривать со мной.",
"next": "choice_1"
},
{
"id": "choice_1",
"type": "Choice",
"speaker": "Hero",
"portrait": "resources/w/gg/gg2_s_podsvetkoy5.png",
"text": "",
"choices": [
{
"id": "main_1",
"kind": "Main",
"text": "Не мешай студентам учиться!",
"next": "line_goods"
},
{
"id": "optional_1",
"kind": "Optional",
"text": "Почему ты появился здесь?",
"next": "line_who"
}
]
},
{
"id": "line_goods",
"type": "Line",
"speaker": "Беспокойный Призрак",
"portrait": "resources/w/avatar_ghost.png",
"text": "Это моя месть студентам за то что они призвали меня.",
"next": "end_1"
},
{
"id": "line_who",
"type": "Line",
"speaker": "Беспокойный Призрак",
"portrait": "resources/w/avatar_ghost.png",
"text": "Группа студентов совершила ритуал и призвала меня сюда. Пока проклятие не спадет, я всегда буду здесь обитать.",
"next": "choice_1"
},
{
"id": "end_1",
"type": "End"
}
]
},
{
"id": "test_condition_dialogue",
"start": "set_flag_1",
"nodes": [
{
"id": "set_flag_1",
"type": "SetFlag",
"effects": [
{ "flag": "met_ghost", "value": 1 }
],
"next": "condition_1"
},
{
"id": "condition_1",
"type": "Condition",
"conditions": [
{ "flag": "met_ghost", "op": "Equals", "value": 1 }
],
"trueNext": "line_true",
"falseNext": "line_false"
},
{
"id": "line_true",
"type": "Line",
"speaker": "Ghost",
"portrait": "resources/ghost_avatar.png",
"text": "Now you know who I am.",
"next": "end_1"
},
{
"id": "line_false",
"type": "Line",
"speaker": "Ghost",
"portrait": "resources/ghost_avatar.png",
"text": "You should not hear this line.",
"next": "end_1"
},
{
"id": "end_1",
"type": "End"
}
]
},
{
"id": "test_cutscene_dialogue",
"start": "cutscene_start",
"nodes": [
{
"id": "cutscene_start",
"type": "CutsceneStart",
"cutsceneId": "test_cutscene_01",
"next": "end_1"
},
{
"id": "end_1",
"type": "End"
}
]
},
{
"id": "test_silent_cutscene_dialogue",
"start": "cutscene_start",
"nodes": [
{
"id": "cutscene_start",
"type": "CutsceneStart",
"cutsceneId": "test_cutscene_silent_01",
"next": "end_1"
},
{
"id": "end_1",
"type": "End"
}
]
},
{
"id": "test_cutscene_pan_dialogue",
"start": "cutscene_start",
"nodes": [
{
"id": "cutscene_start",
"type": "CutsceneStart",
"cutsceneId": "test_cutscene_pan_01",
"next": "end_1"
},
{
"id": "end_1",
"type": "End"
}
]
},
{
"id": "test_cutscene_pan_dialogue_silent",
"start": "cutscene_start",
"nodes": [
{
"id": "cutscene_start",
"type": "CutsceneStart",
"cutsceneId": "test_cutscene_pan_02",
"next": "end_1"
},
{
"id": "end_1",
"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": [
{
"id": "test_cutscene_01",
"background": "resources/first_cutscene.png",
"durationMs": 6800,
"cameraTrack": [
{
"durationMs": 2400,
"from": { "focusX": 0.50, "focusY": 0.55, "zoom": 1.00, "rotationDeg": 0.0 },
"to": { "focusX": 0.63, "focusY": 0.58, "zoom": 1.16, "rotationDeg": -1.0 },
"easing": "EaseInOutSine"
},
{
"durationMs": 2200,
"from": { "focusX": 0.63, "focusY": 0.58, "zoom": 1.16, "rotationDeg": -1.0 },
"to": { "focusX": 0.74, "focusY": 0.52, "zoom": 1.30, "rotationDeg": -2.4 },
"easing": "EaseInOutCubic"
},
{
"durationMs": 2200,
"from": { "focusX": 0.74, "focusY": 0.52, "zoom": 1.30, "rotationDeg": -2.4 },
"to": { "focusX": 0.58, "focusY": 0.46, "zoom": 1.10, "rotationDeg": -0.6 },
"easing": "EaseOutSine"
}
],
"lines": [
{
"speaker": "Narrator",
"portrait": "resources/hero.png",
"text": "The air in the room turned cold.",
"durationMs": 2200
},
{
"speaker": "Ghost",
"portrait": "resources/w/avatar_ghost.png",
"text": "Some memories never fade.",
"durationMs": 2600,
"background": "resources/loading.png"
}
]
},
{
"id": "test_cutscene_silent_01",
"background": "resources/first_cutscene.png",
"durationMs": 5200,
"cameraTrack": [
{
"durationMs": 2600,
"from": { "focusX": 0.40, "focusY": 0.54, "zoom": 1.00, "rotationDeg": 0.0 },
"to": { "focusX": 0.58, "focusY": 0.54, "zoom": 1.22, "rotationDeg": 0.8 },
"easing": "EaseInOutSine"
},
{
"durationMs": 2600,
"from": { "focusX": 0.58, "focusY": 0.54, "zoom": 1.22, "rotationDeg": 0.8 },
"to": { "focusX": 0.72, "focusY": 0.48, "zoom": 1.34, "rotationDeg": -0.5 },
"easing": "EaseOutCubic"
}
],
"lines": []
},
{
"id": "test_cutscene_pan_01",
"background": "resources/first_cutscene.png",
"durationMs": 12000,
"cameraTrack": [
{
"durationMs": 1200,
"from": { "anchor": "Center", "zoom": 1.00, "rotationDeg": 0.0 },
"to": { "anchor": "Center", "zoom": 1.00, "rotationDeg": 0.0 },
"easing": "Linear"
},
{
"durationMs": 2500,
"from": { "anchor": "Center", "zoom": 1.00, "rotationDeg": 0.0 },
"to": { "anchor": "TopLeft", "zoom": 1.55, "rotationDeg": 0.0 },
"easing": "EaseInOutSine"
},
{
"durationMs": 2600,
"from": { "anchor": "TopLeft", "zoom": 1.55, "rotationDeg": 0.0 },
"to": { "anchor": "TopRight", "zoom": 1.55, "rotationDeg": 0.0 },
"easing": "EaseInOutSine"
},
{
"durationMs": 1800,
"from": { "anchor": "TopRight", "zoom": 1.55, "rotationDeg": 0.0 },
"to": { "anchor": "BottomRight", "zoom": 1.72, "rotationDeg": 0.0 },
"easing": "EaseInCubic"
},
{
"durationMs": 3900,
"from": { "anchor": "BottomRight", "zoom": 1.72, "rotationDeg": 0.0 },
"to": { "anchor": "BottomLeft", "zoom": 1.55, "rotationDeg": 0.0 },
"easing": "EaseInOutSine"
}
],
"lines": [
{
"speaker": "Narrator",
"portrait": "resources/hero.png",
"text": "The memory begins in silence.",
"durationMs": 2200
},
{
"speaker": "Narrator",
"portrait": "resources/hero.png",
"text": "Something is drawing your eyes across the whole scene.",
"durationMs": 2800
},
{
"speaker": "Ghost",
"portrait": "resources/ghost_avatar.png",
"text": "Do not look away.",
"durationMs": 2400
}
]
},
{
"id": "test_cutscene_pan_02",
"background": "resources/first_cutscene.png",
"durationMs": 12000,
"cameraTrack": [
{
"durationMs": 1200,
"from": { "anchor": "Center", "zoom": 1.00, "rotationDeg": 0.0 },
"to": { "anchor": "Center", "zoom": 1.00, "rotationDeg": 0.0 },
"easing": "Linear"
},
{
"durationMs": 2500,
"from": { "anchor": "Center", "zoom": 1.00, "rotationDeg": 0.0 },
"to": { "anchor": "TopLeft", "zoom": 1.55, "rotationDeg": 0.0 },
"easing": "EaseInOutSine"
},
{
"durationMs": 2600,
"from": { "anchor": "TopLeft", "zoom": 1.55, "rotationDeg": 0.0 },
"to": { "anchor": "TopRight", "zoom": 1.55, "rotationDeg": 0.0 },
"easing": "EaseInOutSine"
},
{
"durationMs": 1800,
"from": { "anchor": "TopRight", "zoom": 1.55, "rotationDeg": 0.0 },
"to": { "anchor": "BottomRight", "zoom": 1.72, "rotationDeg": 0.0 },
"easing": "EaseInCubic"
},
{
"durationMs": 3900,
"from": { "anchor": "BottomRight", "zoom": 1.72, "rotationDeg": 0.0 },
"to": { "anchor": "BottomLeft", "zoom": 1.55, "rotationDeg": 0.0 },
"easing": "EaseInOutSine"
}
],
"lines": []
}
]
}

View File

@ -0,0 +1,5 @@
.app {
display: flex;
height: 100%;
overflow: hidden;
}

19
dialogEditor/src/App.tsx Normal file
View File

@ -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>
);
}

View File

@ -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;
}

View File

@ -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>
);
}

View File

@ -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;
}

View File

@ -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>
);
}

View File

@ -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;
}

View File

@ -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>
);
}

View File

@ -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;
}

View File

@ -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>
);
}

View File

@ -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;
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -0,0 +1,26 @@
import { useAutoSave } from '../../../hooks/useAutoSave';
import styles from '../RightPanel.module.css';
interface Props {
value: string;
label: string;
placeholder?: string;
onSave: (value: string) => void;
rows?: number;
}
export function AutoSaveTextArea({ value, label, placeholder, onSave, rows = 4 }: Props) {
const [local, handleChange] = useAutoSave(value, onSave);
return (
<div>
<label className={styles.label}>{label}</label>
<textarea
className={styles.textarea}
value={local}
placeholder={placeholder}
rows={rows}
onChange={e => handleChange(e.target.value)}
/>
</div>
);
}

View File

@ -0,0 +1,65 @@
import { memo } from 'react';
import { Handle, Position, NodeProps } from '@xyflow/react';
import { ChoiceNode as ChoiceNodeData } from '../../types/dialogue';
import { useValidation } from '../../hooks/useValidation';
import { useDialogueStore } from '../../store/dialogueStore';
import styles from './nodes.module.css';
export const ChoiceNode = memo(({ data, selected }: NodeProps & { data: ChoiceNodeData }) => {
const { hasError, hasWarning } = useValidation();
const startId = useDialogueStore(s => {
const id = s.selectedDialogueId;
return s.file?.dialogues.find(d => d.id === id)?.start;
});
const isStart = data.id === startId;
const error = hasError(data.id);
const warning = !error && hasWarning(data.id);
return (
<div
className={[
styles.node,
styles.choiceNode,
selected ? styles.selected : '',
error ? styles.hasError : '',
warning ? styles.hasWarning : '',
].join(' ')}
>
{isStart && <div className={styles.startBadge}> START</div>}
<Handle type="target" position={Position.Top} id="target" className={styles.handle} />
<div className={styles.nodeHeader}>
<span className={styles.speakerName}>{data.speaker || '(no speaker)'}</span>
<span className={styles.nodeType}>Choice</span>
</div>
<div className={styles.nodeId}>{data.id}</div>
<div className={styles.nodeBody}>
{data.choices.length === 0 ? (
<span className={styles.emptyText}>(no choices)</span>
) : (
data.choices.map((choice, i) => (
<div key={choice.id} className={styles.choiceRow} style={{ position: 'relative' }}>
<span className={[
styles.choiceKindBadge,
choice.kind === 'Main' ? styles.kindMain : styles.kindOptional,
].join(' ')}>
{choice.kind}
</span>
<span className={styles.choiceText}>{choice.text || '(empty)'}</span>
<Handle
type="source"
position={Position.Bottom}
id={choice.id}
className={styles.choiceHandle}
style={{
left: `${((i + 1) / (data.choices.length + 1)) * 100}%`,
bottom: -8,
}}
/>
</div>
))
)}
</div>
</div>
);
});

View File

@ -0,0 +1,60 @@
import { memo } from 'react';
import { Handle, Position, NodeProps } from '@xyflow/react';
import { ConditionNode as ConditionNodeData } from '../../types/dialogue';
import { useValidation } from '../../hooks/useValidation';
import styles from './nodes.module.css';
export const ConditionNode = memo(({ data, selected }: NodeProps & { data: ConditionNodeData }) => {
const { hasError, hasWarning } = useValidation();
const error = hasError(data.id);
const warning = !error && hasWarning(data.id);
return (
<div
className={[
styles.node,
styles.conditionNode,
selected ? styles.selected : '',
error ? styles.hasError : '',
warning ? styles.hasWarning : '',
].join(' ')}
>
<Handle type="target" position={Position.Top} id="target" className={styles.handle} />
<div className={styles.nodeHeader}>
<span className={styles.speakerName}>Condition</span>
<span className={styles.nodeType}>If</span>
</div>
<div className={styles.nodeId}>{data.id}</div>
<div className={styles.nodeBody}>
{data.conditions.map((c, i) => (
<div key={i} className={styles.conditionRow}>
<span className={styles.flagPill}>{c.flag}</span>
<span className={styles.opBadge}>{c.op}</span>
<span className={styles.valuePill}>{String(c.value)}</span>
</div>
))}
{data.conditions.length === 0 && (
<span className={styles.emptyText}>(no conditions)</span>
)}
</div>
<div className={styles.conditionHandles}>
<span className={styles.trueLabel}>TRUE</span>
<span className={styles.falseLabel}>FALSE</span>
</div>
<Handle
type="source"
position={Position.Bottom}
id="true"
className={[styles.handle, styles.trueHandle].join(' ')}
style={{ left: '30%' }}
/>
<Handle
type="source"
position={Position.Bottom}
id="false"
className={[styles.handle, styles.falseHandle].join(' ')}
style={{ left: '70%' }}
/>
</div>
);
});

View File

@ -0,0 +1,34 @@
import { memo } from 'react';
import { Handle, Position, NodeProps } from '@xyflow/react';
import { CutsceneStartNode as CutsceneStartNodeData } from '../../types/dialogue';
import { useValidation } from '../../hooks/useValidation';
import styles from './nodes.module.css';
export const CutsceneStartNode = memo(({ data, selected }: NodeProps & { data: CutsceneStartNodeData }) => {
const { hasError, hasWarning } = useValidation();
const error = hasError(data.id);
const warning = !error && hasWarning(data.id);
return (
<div
className={[
styles.node,
styles.cutsceneNode,
selected ? styles.selected : '',
error ? styles.hasError : '',
warning ? styles.hasWarning : '',
].join(' ')}
>
<Handle type="target" position={Position.Top} id="target" className={styles.handle} />
<div className={styles.nodeHeader}>
<span className={styles.speakerName}>Cutscene</span>
<span className={styles.nodeType}>Scene</span>
</div>
<div className={styles.nodeId}>{data.id}</div>
<div className={styles.nodeBody}>
<span className={styles.textSnippet}>{data.cutsceneId || '(no cutscene ID)'}</span>
</div>
<Handle type="source" position={Position.Bottom} id="source" className={styles.handle} />
</div>
);
});

View File

@ -0,0 +1,20 @@
import { memo } from 'react';
import { Handle, Position, NodeProps } from '@xyflow/react';
import { EndNode as EndNodeData } from '../../types/dialogue';
import styles from './nodes.module.css';
export const EndNode = memo(({ data, selected }: NodeProps & { data: EndNodeData }) => {
return (
<div
className={[
styles.node,
styles.endNode,
selected ? styles.selected : '',
].join(' ')}
>
<Handle type="target" position={Position.Top} id="target" className={styles.handle} />
<div className={styles.endLabel}>END</div>
<div className={styles.nodeId}>{data.id}</div>
</div>
);
});

View File

@ -0,0 +1,66 @@
import { memo } from 'react';
import { Handle, Position, NodeProps } from '@xyflow/react';
import { LineNode as LineNodeData } from '../../types/dialogue';
import { useValidation } from '../../hooks/useValidation';
import { useDialogueStore } from '../../store/dialogueStore';
import { MAIN_CHARACTER } from '../../constants/characters';
import styles from './nodes.module.css';
export const LineNode = memo(({ data, selected }: NodeProps & { data: LineNodeData }) => {
const { hasError, hasWarning } = useValidation();
const startId = useDialogueStore(s => {
const id = s.selectedDialogueId;
return s.file?.dialogues.find(d => d.id === id)?.start;
});
const isMain = data.speaker === MAIN_CHARACTER;
const isStart = data.id === startId;
const error = hasError(data.id);
const warning = !error && hasWarning(data.id);
const triggers = [
data.chatBubble && `💬 ${data.chatBubble}`,
data.questUnlock && `🔓 quest`,
data.objectiveComplete && `✅ obj`,
data.objectiveVisible && `👁 obj`,
data.questFail && `❌ quest`,
data.questComplete && `🏁 quest`,
data.luaCallback && `⚡ lua`,
].filter(Boolean);
return (
<div
className={[
styles.node,
styles.lineNode,
isMain ? styles.mainChar : styles.otherChar,
selected ? styles.selected : '',
error ? styles.hasError : '',
warning ? styles.hasWarning : '',
].join(' ')}
>
{isStart && <div className={styles.startBadge}> START</div>}
<Handle type="target" position={Position.Top} id="target" className={styles.handle} />
<div className={styles.nodeHeader}>
<span className={styles.speakerName}>{data.speaker || '(no speaker)'}</span>
<span className={styles.nodeType}>Line</span>
</div>
<div className={styles.nodeId}>{data.id}</div>
<div className={styles.nodeBody}>
{data.text ? (
<span className={styles.textSnippet}>{data.text}</span>
) : (
<span className={styles.emptyText}>(empty)</span>
)}
</div>
{triggers.length > 0 && (
<div className={styles.triggerRow}>
{triggers.map((t, i) => (
<span key={i} className={styles.triggerPill}>{t}</span>
))}
</div>
)}
<Handle type="source" position={Position.Bottom} id="source" className={styles.handle} />
</div>
);
});

View File

@ -0,0 +1,43 @@
import { memo } from 'react';
import { Handle, Position, NodeProps } from '@xyflow/react';
import { SetFlagNode as SetFlagNodeData } from '../../types/dialogue';
import { useValidation } from '../../hooks/useValidation';
import styles from './nodes.module.css';
export const SetFlagNode = memo(({ data, selected }: NodeProps & { data: SetFlagNodeData }) => {
const { hasError, hasWarning } = useValidation();
const error = hasError(data.id);
const warning = !error && hasWarning(data.id);
return (
<div
className={[
styles.node,
styles.setFlagNode,
selected ? styles.selected : '',
error ? styles.hasError : '',
warning ? styles.hasWarning : '',
].join(' ')}
>
<Handle type="target" position={Position.Top} id="target" className={styles.handle} />
<div className={styles.nodeHeader}>
<span className={styles.speakerName}>Set Flag</span>
<span className={styles.nodeType}>Flag</span>
</div>
<div className={styles.nodeId}>{data.id}</div>
<div className={styles.nodeBody}>
{data.effects.map((e, i) => (
<div key={i} className={styles.conditionRow}>
<span className={styles.flagPill}>{e.flag}</span>
<span className={styles.opBadge}>=</span>
<span className={styles.valuePill}>{String(e.value)}</span>
</div>
))}
{data.effects.length === 0 && (
<span className={styles.emptyText}>(no effects)</span>
)}
</div>
<Handle type="source" position={Position.Bottom} id="source" className={styles.handle} />
</div>
);
});

View File

@ -0,0 +1,15 @@
import { LineNode } from './LineNode';
import { ChoiceNode } from './ChoiceNode';
import { ConditionNode } from './ConditionNode';
import { SetFlagNode } from './SetFlagNode';
import { CutsceneStartNode } from './CutsceneStartNode';
import { EndNode } from './EndNode';
export const nodeTypes = {
Line: LineNode,
Choice: ChoiceNode,
Condition: ConditionNode,
SetFlag: SetFlagNode,
CutsceneStart: CutsceneStartNode,
End: EndNode,
} as const;

View File

@ -0,0 +1,253 @@
.node {
border-radius: 6px;
border: 2px solid transparent;
background: #1e1e2e;
color: #cdd6f4;
font-family: 'Segoe UI', system-ui, sans-serif;
font-size: 12px;
min-width: 220px;
max-width: 260px;
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
position: relative;
cursor: pointer;
}
.node.selected {
border-color: #89b4fa;
box-shadow: 0 0 0 3px rgba(137,180,250,0.25);
}
.node.hasError {
border-left: 4px solid #f38ba8;
}
.node.hasWarning {
border-left: 4px solid #f9e2af;
}
/* ---- Header colors ---- */
.nodeHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 10px;
border-radius: 4px 4px 0 0;
}
.lineNode.mainChar .nodeHeader { background: #1e66f5; }
.lineNode.otherChar .nodeHeader { background: #40a02b; }
.choiceNode .nodeHeader { background: #df8e1d; }
.conditionNode .nodeHeader { background: #8839ef; }
.setFlagNode .nodeHeader { background: #179299; }
.cutsceneNode .nodeHeader { background: #4a4a5a; }
.endNode .nodeHeader { display: none; }
.speakerName {
font-weight: 600;
font-size: 12px;
color: #fff;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 160px;
}
.nodeType {
font-size: 10px;
color: rgba(255,255,255,0.7);
font-weight: 500;
flex-shrink: 0;
}
.nodeId {
font-size: 10px;
color: #6c7086;
padding: 2px 10px 0;
font-family: monospace;
}
/* ---- Body ---- */
.nodeBody {
padding: 6px 10px 8px;
min-height: 32px;
}
.textSnippet {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.4;
color: #cdd6f4;
}
.emptyText {
color: #6c7086;
font-style: italic;
}
/* ---- Trigger pills ---- */
.triggerRow {
display: flex;
flex-wrap: wrap;
gap: 3px;
padding: 0 8px 7px;
}
.triggerPill {
background: rgba(255,255,255,0.08);
border-radius: 3px;
padding: 1px 5px;
font-size: 10px;
color: #a6adc8;
}
/* ---- Choice rows ---- */
.choiceRow {
display: flex;
align-items: center;
gap: 6px;
padding: 3px 0;
border-top: 1px solid rgba(255,255,255,0.06);
}
.choiceKindBadge {
font-size: 9px;
font-weight: 700;
padding: 1px 5px;
border-radius: 3px;
flex-shrink: 0;
text-transform: uppercase;
}
.kindMain {
background: #fe640b;
color: #fff;
}
.kindOptional {
background: transparent;
border: 1px solid #fe640b;
color: #fe640b;
}
.choiceText {
font-size: 11px;
color: #cdd6f4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
/* ---- Condition rows ---- */
.conditionRow {
display: flex;
align-items: center;
gap: 4px;
padding: 2px 0;
}
.flagPill {
background: rgba(137,180,250,0.15);
color: #89b4fa;
border-radius: 3px;
padding: 1px 5px;
font-size: 10px;
font-family: monospace;
}
.opBadge {
color: #cba6f7;
font-size: 10px;
font-weight: 600;
}
.valuePill {
background: rgba(166,227,161,0.15);
color: #a6e3a1;
border-radius: 3px;
padding: 1px 5px;
font-size: 10px;
font-family: monospace;
}
/* ---- Condition handles ---- */
.conditionHandles {
display: flex;
justify-content: space-between;
padding: 2px 14px 6px;
}
.trueLabel {
font-size: 9px;
font-weight: 700;
color: #a6e3a1;
text-transform: uppercase;
margin-left: 12%;
}
.falseLabel {
font-size: 9px;
font-weight: 700;
color: #f38ba8;
text-transform: uppercase;
margin-right: 12%;
}
/* ---- End node ---- */
.endNode {
min-width: 100px;
max-width: 120px;
display: flex;
flex-direction: column;
align-items: center;
background: #2a0e14;
border: 2px solid #f38ba8;
border-radius: 50px;
padding: 10px 16px;
}
.endLabel {
font-size: 14px;
font-weight: 700;
color: #f38ba8;
letter-spacing: 2px;
}
/* ---- Start badge ---- */
.startBadge {
position: absolute;
top: -22px;
left: 10px;
background: #a6e3a1;
color: #1e1e2e;
font-size: 10px;
font-weight: 700;
padding: 2px 8px;
border-radius: 4px 4px 0 0;
}
/* ---- Handles ---- */
.handle {
width: 10px;
height: 10px;
background: #6c7086;
border: 2px solid #1e1e2e;
}
.trueHandle {
background: #a6e3a1 !important;
}
.falseHandle {
background: #f38ba8 !important;
}
.choiceHandle {
width: 8px;
height: 8px;
background: #fab387 !important;
border: 2px solid #1e1e2e;
position: absolute !important;
}

View File

@ -0,0 +1,23 @@
import { CharacterPreset } from '../types/dialogue';
export const MAIN_CHARACTER = 'Бекзат';
export const PHONE_PORTRAIT = 'resources/dialogue/portrait_phone.png';
export const CHARACTER_PRESETS: CharacterPreset[] = [
{ label: 'Бекзат', portrait: 'resources/dialogue/portrait_hero_neutral.png' },
{ label: 'Аида Джаныбекова', portrait: 'resources/dialogue/portrait_teacher.png' },
{ label: 'Айпери', portrait: 'resources/dialogue/portrait_aiperi.png' },
{ label: 'Призрак', portrait: 'resources/dialogue/portrait_ghost.png' },
{ label: 'Алик', portrait: 'resources/dialogue/portrait_student_boy.png' },
{ label: 'Студент', portrait: 'resources/dialogue/portrait_student_boy.png' },
{ label: 'Студентка', portrait: 'resources/dialogue/portrait_student_girl.png' },
{ label: 'Бермет', portrait: 'resources/dialogue/portrait_student_girl.png' },
{ label: 'Алтынай', portrait: 'resources/dialogue/portrait_student_girl.png' },
];
export const CUSTOM_CHARACTER_LABEL = '(custom)';
export function getPortraitForSpeaker(speaker: string): string {
const preset = CHARACTER_PRESETS.find(p => p.label === speaker);
return preset?.portrait ?? '';
}

View File

@ -0,0 +1,31 @@
import { useState, useEffect, useRef } from 'react';
export function useAutoSave(
value: string,
onSave: (value: string) => void,
delay = 600
): [string, (v: string) => void] {
const [local, setLocal] = useState(value);
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const savedValueRef = useRef(value);
useEffect(() => {
if (value !== savedValueRef.current) {
savedValueRef.current = value;
setLocal(value);
}
}, [value]);
const handleChange = (v: string) => {
setLocal(v);
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
savedValueRef.current = v;
onSave(v);
}, delay);
};
useEffect(() => () => clearTimeout(timerRef.current), []);
return [local, handleChange];
}

View File

@ -0,0 +1,28 @@
import { useMemo } from 'react';
import { useDialogueStore } from '../store/dialogueStore';
import { ValidationIssue } from '../types/dialogue';
export function useValidation(): {
issuesByNodeId: Record<string, ValidationIssue[]>;
hasError: (nodeId: string) => boolean;
hasWarning: (nodeId: string) => boolean;
} {
const issues = useDialogueStore(s => s.validationIssues);
const issuesByNodeId = useMemo(() => {
const map: Record<string, ValidationIssue[]> = {};
for (const issue of issues) {
if (!map[issue.nodeId]) map[issue.nodeId] = [];
map[issue.nodeId].push(issue);
}
return map;
}, [issues]);
const hasError = (nodeId: string) =>
issuesByNodeId[nodeId]?.some(i => i.severity === 'error') ?? false;
const hasWarning = (nodeId: string) =>
issuesByNodeId[nodeId]?.some(i => i.severity === 'warning') ?? false;
return { issuesByNodeId, hasError, hasWarning };
}

View File

@ -0,0 +1,31 @@
*, *::before, *::after {
box-sizing: border-box;
}
html, body, #root {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden;
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
background: #1e1e2e;
color: #cdd6f4;
}
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: #181825;
}
::-webkit-scrollbar-thumb {
background: #45475a;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #585b70;
}

11
dialogEditor/src/main.tsx Normal file
View File

@ -0,0 +1,11 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import '@xyflow/react/dist/style.css';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

Some files were not shown because too many files have changed in this diff Show More