Working on dialog editor, on dialogs, on phone

This commit is contained in:
Vladislav Khorev 2026-06-05 21:17:51 +03:00
parent 0e27521bea
commit 8f7797789d
63 changed files with 9502 additions and 1459 deletions

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

View File

@ -0,0 +1,291 @@
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
import {
DialogueFile,
Dialogue,
DialogueNode,
ValidationIssue,
EndNode,
} from '../types/dialogue';
import { computeLayout, PositionMap } from '../utils/layout';
import { validateDialogue } from '../utils/validation';
import { applyMobileTransforms } from '../utils/mobileMode';
import { serializeDialogueFile, triggerDownload } from '../utils/fileIO';
import { makeNodeId } from '../utils/idGen';
interface NodePosition { x: number; y: number }
interface DialogueStore {
file: DialogueFile | null;
fileName: string;
selectedDialogueId: string | null;
selectedNodeId: string | null;
positions: Record<string, Record<string, NodePosition>>;
playModeActive: boolean;
playState: { currentNodeId: string; flags: Record<string, number | string> } | null;
persistentFlags: Record<string, number | string>;
validationIssues: ValidationIssue[];
loadFile: (file: DialogueFile, name: string) => void;
exportFile: () => void;
selectDialogue: (id: string) => void;
createDialogue: (id: string) => void;
deleteDialogue: (id: string) => void;
duplicateDialogue: (id: string) => void;
setDialogueMobileMode: (id: string, value: boolean) => void;
selectNode: (id: string | null) => void;
updateNode: (dialogueId: string, nodeId: string, patch: Partial<DialogueNode>) => void;
addNode: (dialogueId: string, node: DialogueNode, afterNodeId?: string) => void;
deleteNode: (dialogueId: string, nodeId: string) => void;
setNodePosition: (dialogueId: string, nodeId: string, pos: NodePosition) => void;
applyAutoLayout: (dialogueId: string) => void;
startPlay: () => void;
stopPlay: () => void;
advancePlay: (nextNodeId: string) => void;
setPlayFlag: (flag: string, value: number | string) => void;
resetFlags: () => void;
}
function getDialogue(file: DialogueFile | null, id: string | null): Dialogue | undefined {
if (!file || !id) return undefined;
return file.dialogues.find(d => d.id === id);
}
function revalidate(state: DialogueStore): void {
const d = getDialogue(state.file, state.selectedDialogueId);
state.validationIssues = d ? validateDialogue(d) : [];
}
export const useDialogueStore = create<DialogueStore>()(
immer((set, get) => ({
file: null,
fileName: 'dialogues.json',
selectedDialogueId: null,
selectedNodeId: null,
positions: {},
playModeActive: false,
playState: null,
persistentFlags: {},
validationIssues: [],
loadFile: (file, name) => set(state => {
state.file = file;
state.fileName = name;
state.selectedDialogueId = file.dialogues[0]?.id ?? null;
state.selectedNodeId = null;
state.positions = {};
state.playModeActive = false;
state.playState = null;
revalidate(state);
}),
exportFile: () => {
const { file, fileName } = get();
if (!file) return;
const clone = structuredClone(file);
clone.dialogues = clone.dialogues.map(d =>
d.mobileMode ? applyMobileTransforms(d) : d
);
const json = serializeDialogueFile(clone);
triggerDownload(json, fileName);
},
selectDialogue: (id) => set(state => {
state.selectedDialogueId = id;
state.selectedNodeId = null;
revalidate(state);
}),
createDialogue: (id) => set(state => {
if (!state.file) return;
const endNode: EndNode = { id: 'end_1', type: 'End' };
const newDialogue: Dialogue = {
id,
start: 'line_1',
nodes: [
{ id: 'line_1', type: 'Line', speaker: 'Бекзат', portrait: 'resources/dialogue/portrait_hero_neutral.png', text: '', next: 'end_1' },
endNode,
],
};
state.file.dialogues.push(newDialogue);
state.selectedDialogueId = id;
state.selectedNodeId = null;
revalidate(state);
}),
duplicateDialogue: (id) => {
// Read raw data outside immer so structuredClone works on plain objects
const { file, positions } = get();
if (!file) return;
const source = file.dialogues.find(d => d.id === id);
if (!source) return;
const existingIds = new Set(file.dialogues.map(d => d.id));
const match = source.id.match(/^(.*?)(\d+)$/);
let newId: string;
if (match) {
const base = match[1];
const numStr = match[2];
let n = parseInt(numStr, 10) + 1;
const pad = numStr.length;
while (existingIds.has(`${base}${String(n).padStart(pad, '0')}`)) n++;
newId = `${base}${String(n).padStart(pad, '0')}`;
} else {
let n = 2;
while (existingIds.has(`${source.id}_${n}`)) n++;
newId = `${source.id}_${n}`;
}
const clonedDialogue = structuredClone(source);
clonedDialogue.id = newId;
const clonedPositions = positions[id] ? structuredClone(positions[id]) : undefined;
set(state => {
state.file!.dialogues.push(clonedDialogue);
if (clonedPositions) state.positions[newId] = clonedPositions;
state.selectedDialogueId = newId;
state.selectedNodeId = null;
revalidate(state);
});
},
deleteDialogue: (id) => set(state => {
if (!state.file) return;
state.file.dialogues = state.file.dialogues.filter(d => d.id !== id);
if (state.selectedDialogueId === id) {
state.selectedDialogueId = state.file.dialogues[0]?.id ?? null;
state.selectedNodeId = null;
}
delete state.positions[id];
revalidate(state);
}),
setDialogueMobileMode: (id, value) => set(state => {
if (!state.file) return;
const d = state.file.dialogues.find(x => x.id === id);
if (d) d.mobileMode = value;
}),
selectNode: (id) => set(state => {
state.selectedNodeId = id;
}),
updateNode: (dialogueId, nodeId, patch) => set(state => {
if (!state.file) return;
const d = state.file.dialogues.find(x => x.id === dialogueId);
if (!d) return;
const idx = d.nodes.findIndex(n => n.id === nodeId);
if (idx === -1) return;
Object.assign(d.nodes[idx], patch);
revalidate(state);
}),
addNode: (dialogueId, node, afterNodeId) => set(state => {
if (!state.file) return;
const d = state.file.dialogues.find(x => x.id === dialogueId);
if (!d) return;
// Ensure unique ID
const existingIds = new Set(d.nodes.map(n => n.id));
if (existingIds.has(node.id)) {
node = { ...node, id: makeNodeId(node.type, existingIds) };
}
if (afterNodeId) {
const idx = d.nodes.findIndex(n => n.id === afterNodeId);
if (idx !== -1) {
const prev = d.nodes[idx];
// Wire: prev.next → new node → old prev.next
if (
prev.type === 'Line' ||
prev.type === 'SetFlag' ||
prev.type === 'CutsceneStart'
) {
const oldNext = prev.next;
(d.nodes[idx] as typeof prev).next = node.id;
if (
(node.type === 'Line' ||
node.type === 'SetFlag' ||
node.type === 'CutsceneStart') &&
oldNext
) {
(node as typeof prev).next = oldNext;
}
}
d.nodes.splice(idx + 1, 0, node);
} else {
d.nodes.push(node);
}
} else {
d.nodes.push(node);
}
// Place new node just below the anchor node in the graph
const anchorId = afterNodeId ?? null;
if (anchorId) {
const anchorPos = state.positions[dialogueId]?.[anchorId];
if (anchorPos) {
if (!state.positions[dialogueId]) state.positions[dialogueId] = {};
state.positions[dialogueId][node.id] = { x: anchorPos.x, y: anchorPos.y + 200 };
}
}
revalidate(state);
}),
deleteNode: (dialogueId, nodeId) => set(state => {
if (!state.file) return;
const d = state.file.dialogues.find(x => x.id === dialogueId);
if (!d) return;
d.nodes = d.nodes.filter(n => n.id !== nodeId);
if (state.selectedNodeId === nodeId) state.selectedNodeId = null;
delete state.positions[dialogueId]?.[nodeId];
revalidate(state);
}),
setNodePosition: (dialogueId, nodeId, pos) => set(state => {
if (!state.positions[dialogueId]) state.positions[dialogueId] = {};
state.positions[dialogueId][nodeId] = pos;
}),
applyAutoLayout: (dialogueId) => set(state => {
if (!state.file) return;
const d = state.file.dialogues.find(x => x.id === dialogueId);
if (!d) return;
const layout: PositionMap = computeLayout(d.nodes);
if (!state.positions[dialogueId]) state.positions[dialogueId] = {};
Object.assign(state.positions[dialogueId], layout);
}),
startPlay: () => set(state => {
const d = getDialogue(state.file, state.selectedDialogueId);
if (!d) return;
state.playModeActive = true;
state.playState = { currentNodeId: d.start, flags: { ...state.persistentFlags } };
}),
stopPlay: () => set(state => {
state.playModeActive = false;
state.playState = null;
}),
advancePlay: (nextNodeId) => set(state => {
if (!state.playState) return;
state.playState.currentNodeId = nextNodeId;
}),
setPlayFlag: (flag, value) => set(state => {
if (!state.playState) return;
state.playState.flags[flag] = value;
state.persistentFlags[flag] = value;
}),
resetFlags: () => set(state => {
state.persistentFlags = {};
if (state.playState) state.playState.flags = {};
}),
}))
);

View File

@ -0,0 +1,107 @@
export type ChatBubble = 'in' | 'out';
export type ConditionOp = 'Equals' | 'NotEquals' | 'GreaterThan' | 'LessThan';
export type ChoiceKind = 'Main' | 'Optional';
export type ValidationSeverity = 'error' | 'warning';
export interface ChoiceOption {
id: string;
kind: ChoiceKind;
text: string;
next: string;
}
export interface ConditionClause {
flag: string;
op: ConditionOp;
value: number | string;
}
export interface FlagEffect {
flag: string;
value: number | string;
}
export interface LineNode {
id: string;
type: 'Line';
speaker: string;
portrait: string;
text: string;
next: string;
chatBubble?: ChatBubble;
questUnlock?: string;
objectiveComplete?: string;
objectiveVisible?: string;
questFail?: string;
questComplete?: string;
luaCallback?: string;
}
export interface ChoiceNode {
id: string;
type: 'Choice';
speaker: string;
portrait: string;
text: string;
choices: ChoiceOption[];
}
export interface ConditionNode {
id: string;
type: 'Condition';
conditions: ConditionClause[];
trueNext: string;
falseNext: string;
}
export interface SetFlagNode {
id: string;
type: 'SetFlag';
effects: FlagEffect[];
next: string;
}
export interface CutsceneStartNode {
id: string;
type: 'CutsceneStart';
cutsceneId: string;
next: string;
}
export interface EndNode {
id: string;
type: 'End';
}
export type DialogueNode =
| LineNode
| ChoiceNode
| ConditionNode
| SetFlagNode
| CutsceneStartNode
| EndNode;
export type NodeType = DialogueNode['type'];
export interface Dialogue {
id: string;
start: string;
nodes: DialogueNode[];
mobileMode?: boolean;
}
export interface DialogueFile {
dialogues: Dialogue[];
cutscenes: unknown[];
}
export interface ValidationIssue {
nodeId: string;
severity: ValidationSeverity;
message: string;
}
export interface CharacterPreset {
label: string;
portrait: string;
}

View File

@ -0,0 +1,32 @@
import { DialogueFile, Dialogue } from '../types/dialogue';
export function parseDialogueFile(json: string): DialogueFile {
const parsed = JSON.parse(json);
if (!Array.isArray(parsed.dialogues)) {
throw new Error('Invalid file: missing dialogues array');
}
if (!Array.isArray(parsed.cutscenes)) {
parsed.cutscenes = [];
}
return parsed as DialogueFile;
}
export function serializeDialogueFile(file: DialogueFile): string {
const clone = structuredClone(file);
clone.dialogues.forEach((d: Dialogue & { mobileMode?: boolean }) => {
delete d.mobileMode;
});
return JSON.stringify(clone, null, '\t');
}
export function triggerDownload(content: string, filename: string): void {
const blob = new Blob([content], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}

View File

@ -0,0 +1,14 @@
import { NodeType } from '../types/dialogue';
export function makeNodeId(type: NodeType, existingIds: Set<string>): string {
const base = type.toLowerCase();
let n = 1;
while (existingIds.has(`${base}_${n}`)) n++;
return `${base}_${n}`;
}
export function makeDialogueId(existingIds: Set<string>): string {
let n = 1;
while (existingIds.has(`dialog_new${n}`)) n++;
return `dialog_new${n}`;
}

View File

@ -0,0 +1,53 @@
import dagre from '@dagrejs/dagre';
import { DialogueNode } from '../types/dialogue';
export type PositionMap = Record<string, { x: number; y: number }>;
const NODE_WIDTH = 260;
const NODE_HEIGHT = 130;
export function computeLayout(nodes: DialogueNode[]): PositionMap {
const g = new dagre.graphlib.Graph();
g.setDefaultEdgeLabel(() => ({}));
g.setGraph({ rankdir: 'TB', ranksep: 60, nodesep: 50 });
nodes.forEach(n => {
const h = n.type === 'Choice'
? NODE_HEIGHT + Math.max(0, n.choices.length - 1) * 28
: NODE_HEIGHT;
g.setNode(n.id, { width: NODE_WIDTH, height: h });
});
nodes.forEach(n => {
switch (n.type) {
case 'Line':
case 'SetFlag':
case 'CutsceneStart':
if (n.next) g.setEdge(n.id, n.next);
break;
case 'Choice':
n.choices.forEach(c => { if (c.next) g.setEdge(n.id, c.next); });
break;
case 'Condition':
if (n.trueNext) g.setEdge(n.id, n.trueNext);
if (n.falseNext) g.setEdge(n.id, n.falseNext);
break;
}
});
dagre.layout(g);
const positions: PositionMap = {};
nodes.forEach(n => {
const pos = g.node(n.id);
if (pos) {
const h = n.type === 'Choice'
? NODE_HEIGHT + Math.max(0, n.choices.length - 1) * 28
: NODE_HEIGHT;
positions[n.id] = { x: pos.x - NODE_WIDTH / 2, y: pos.y - h / 2 };
} else {
positions[n.id] = { x: 0, y: 0 };
}
});
return positions;
}

View File

@ -0,0 +1,20 @@
import { Dialogue, DialogueNode } from '../types/dialogue';
import { MAIN_CHARACTER, PHONE_PORTRAIT } from '../constants/characters';
export function applyMobileTransforms(dialogue: Dialogue): Dialogue {
const clone = structuredClone(dialogue);
clone.nodes = clone.nodes.map((node): DialogueNode => {
if (node.type === 'Line') {
return {
...node,
portrait: PHONE_PORTRAIT,
chatBubble: node.chatBubble ?? (node.speaker === MAIN_CHARACTER ? 'out' : 'in'),
};
}
if (node.type === 'Choice') {
return { ...node, portrait: PHONE_PORTRAIT };
}
return node;
});
return clone;
}

View File

@ -0,0 +1,108 @@
import { Dialogue, DialogueNode, ValidationIssue } from '../types/dialogue';
function getOutgoingNodeIds(node: DialogueNode): string[] {
switch (node.type) {
case 'Line':
case 'SetFlag':
case 'CutsceneStart':
return node.next ? [node.next] : [];
case 'Choice':
return node.choices.map(c => c.next).filter(Boolean);
case 'Condition':
return [node.trueNext, node.falseNext].filter(Boolean);
case 'End':
return [];
}
}
function reachableFrom(startId: string, nodeMap: Map<string, DialogueNode>): Set<string> {
const visited = new Set<string>();
const stack = [startId];
while (stack.length > 0) {
const id = stack.pop()!;
if (visited.has(id)) continue;
visited.add(id);
const node = nodeMap.get(id);
if (!node) continue;
for (const next of getOutgoingNodeIds(node)) {
if (!visited.has(next)) stack.push(next);
}
}
return visited;
}
export function validateDialogue(dialogue: Dialogue): ValidationIssue[] {
const issues: ValidationIssue[] = [];
const nodeMap = new Map<string, DialogueNode>();
const duplicates = new Set<string>();
// 1. Duplicate IDs
for (const node of dialogue.nodes) {
if (nodeMap.has(node.id)) {
duplicates.add(node.id);
issues.push({ nodeId: node.id, severity: 'error', message: `Duplicate node ID: "${node.id}"` });
} else {
nodeMap.set(node.id, node);
}
}
// 2. Missing start node
if (dialogue.start && !nodeMap.has(dialogue.start)) {
issues.push({ nodeId: '__dialogue__', severity: 'error', message: `Start node "${dialogue.start}" does not exist` });
}
// 3. Broken references
for (const node of dialogue.nodes) {
if (duplicates.has(node.id)) continue;
if ((node.type === 'Line' || node.type === 'SetFlag' || node.type === 'CutsceneStart') && node.next) {
if (!nodeMap.has(node.next)) {
issues.push({ nodeId: node.id, severity: 'error', message: `"next" points to missing node "${node.next}"` });
}
}
if (node.type === 'Choice') {
for (const choice of node.choices) {
if (choice.next && !nodeMap.has(choice.next)) {
issues.push({ nodeId: node.id, severity: 'error', message: `Choice "${choice.text}" points to missing node "${choice.next}"` });
}
}
}
if (node.type === 'Condition') {
if (node.trueNext && !nodeMap.has(node.trueNext)) {
issues.push({ nodeId: node.id, severity: 'error', message: `"trueNext" points to missing node "${node.trueNext}"` });
}
if (node.falseNext && !nodeMap.has(node.falseNext)) {
issues.push({ nodeId: node.id, severity: 'error', message: `"falseNext" points to missing node "${node.falseNext}"` });
}
}
}
// 4. Reachability from start
if (dialogue.start && nodeMap.has(dialogue.start)) {
const reachable = reachableFrom(dialogue.start, nodeMap);
// No End reachable
const hasEnd = [...reachable].some(id => nodeMap.get(id)?.type === 'End');
if (!hasEnd) {
issues.push({ nodeId: dialogue.start, severity: 'warning', message: 'No End node is reachable from the start' });
}
// Orphaned nodes
for (const node of dialogue.nodes) {
if (!reachable.has(node.id) && !duplicates.has(node.id)) {
issues.push({ nodeId: node.id, severity: 'warning', message: 'Node is not reachable from the start' });
}
}
}
// 5. Empty text warnings
for (const node of dialogue.nodes) {
if ((node.type === 'Line' || node.type === 'Choice') && !node.text.trim()) {
issues.push({ nodeId: node.id, severity: 'warning', message: 'Node has empty text' });
}
}
return issues;
}

1
dialogEditor/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

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

View File

@ -0,0 +1,6 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
});

View File

@ -20,6 +20,13 @@
"description": "Это ключ от учительской, который я получил от Айпери.", "description": "Это ключ от учительской, который я получил от Айпери.",
"icon": "resources/w/ui/img/inv/ItemKey001.png", "icon": "resources/w/ui/img/inv/ItemKey001.png",
"selectedIcon": "resources/w/ui/img/inv/ItemSelKey001.png" "selectedIcon": "resources/w/ui/img/inv/ItemSelKey001.png"
},
{
"id": "all_keys",
"name": "Ключи от универа",
"description": "Это ключи от всех комнат в универе, которые я получил от Айпери.",
"icon": "resources/w/ui/img/inv/ItemKey001.png",
"selectedIcon": "resources/w/ui/img/inv/ItemSelKey001.png"
}, },
{ {
"id": "knife", "id": "knife",

View File

@ -189,6 +189,7 @@
}, },
{ {
"id": "end_1", "id": "end_1",
"luaCallback" : "on_aiperi_dialog_over",
"type": "End" "type": "End"
} }
] ]

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -25,7 +25,7 @@ journal_picked_up = false
function on_npc_interact(npc_index) function on_npc_interact(npc_index)
if npc_index == 2 then if npc_index == 2 then
local player_alik_aware = game_api.getIntValue("player_alik_aware") local player_alik_aware = game_api.getIntValue("alik_aware")
local player_container_aware = game_api.getIntValue("player_container_aware") local player_container_aware = game_api.getIntValue("player_container_aware")
if player_container_aware == 1 then if player_container_aware == 1 then
game_api.start_dialogue("dialog_alik003") game_api.start_dialogue("dialog_alik003")
@ -198,6 +198,37 @@ game_api.set_enter_night_callback(
end end
) )
local chat0_opened = false
local chat1_opened = false
local chat2_opened = false
game_api.set_chat_callbacks(
function()
if not chat0_opened then
chat0_opened = true
game_api.start_dialogue("dialog_chat_aiperi001")
end
end,
function()
if not chat1_opened then
chat1_opened = true
game_api.start_dialogue("dialog_chat_parents001")
end
end,
function()
if not chat2_opened then
chat2_opened = true
game_api.start_dialogue("dialog_chat_news001")
end
end
)
function on_aiperi_dialog_over()
print("on_aiperi_dialog_over called")
game_api.close_phone()
end
game_api.set_location_callbacks( game_api.set_location_callbacks(
function() function()
print("Enter location dorm") print("Enter location dorm")

View File

@ -16,7 +16,7 @@ player_left_darklands = false
morning_can_open_door_index = 0 morning_can_open_door_index = 0
morning_did_open_door_index = 0 morning_did_open_door_index = 0
player_ghost_aware = false --player_ghost_aware = false
ghost_gone = false ghost_gone = false
@ -143,6 +143,7 @@ function on_bookshelf_clicked()
end end
function on_npc_interact(npc_index) function on_npc_interact(npc_index)
local player_ghost_aware = game_api.getIntValue("ghost_aware")
print("[Lua] NPC interaction! Index: " .. tostring(npc_index)) print("[Lua] NPC interaction! Index: " .. tostring(npc_index))
if npc_index == 1 then if npc_index == 1 then
local day = game_api.getIntValue("day") local day = game_api.getIntValue("day")
@ -154,6 +155,8 @@ function on_npc_interact(npc_index)
game_api.start_dialogue("knife_dialog001") game_api.start_dialogue("knife_dialog001")
end end
else else
game_api.start_dialogue("dialog_aiperi_morning001")
--[[
if (player_ghost_aware) then if (player_ghost_aware) then
local player_alik_aware = game_api.getIntValue("player_alik_aware") local player_alik_aware = game_api.getIntValue("player_alik_aware")
if player_alik_aware == 1 then if player_alik_aware == 1 then
@ -164,7 +167,7 @@ function on_npc_interact(npc_index)
end end
else else
game_api.start_dialogue("aiperi_dialog003") game_api.start_dialogue("aiperi_dialog003")
end end]]
end end
end end
if npc_index == 0 then if npc_index == 0 then
@ -190,16 +193,11 @@ function on_npc_interact(npc_index)
end end
end end
if npc_index == 2 then if npc_index == 2 then
if (player_ghost_aware) then local report_card_signed = game_api.getIntValue("report_card_signed")
local report_card_signed = game_api.getIntValue("report_card_signed") if (report_card_signed == 1) then
if (report_card_signed == 1) then game_api.start_dialogue("dialog_with_ghost003")
game_api.start_dialogue("dialog_with_ghost003")
else
game_api.start_dialogue("dialog_with_ghost002")
end
else else
game_api.start_dialogue("dialog_with_ghost001") game_api.start_dialogue("dialog_with_ghost001")
--player_ghost_aware = true
end end
end end
end end
@ -213,11 +211,12 @@ function on_quest_over()
game_api.quest_set_objective_completed("ghost_release", "ghost_release_show") game_api.quest_set_objective_completed("ghost_release", "ghost_release_show")
end end
--[[
function on_first_ghost_dialog_over() function on_first_ghost_dialog_over()
print("on_first_ghost_dialog_over") print("on_first_ghost_dialog_over")
player_ghost_aware = true player_ghost_aware = true
game_api.quest_unlock("ghost_lore") game_api.quest_unlock("ghost_lore")
end end]]
function on_darklands_over() function on_darklands_over()
game_api.set_player_hp(10) game_api.set_player_hp(10)
@ -244,7 +243,7 @@ function on_library_door_click()
end end
end end
else else
if (morning_can_open_door_index == 5) then --[[if (morning_can_open_door_index == 5) then
if (morning_did_open_door_index == 0) then if (morning_did_open_door_index == 0) then
morning_did_open_door_index = 5 morning_did_open_door_index = 5
game_api.start_dialogue("door_unlock_dialog001") game_api.start_dialogue("door_unlock_dialog001")
@ -256,29 +255,21 @@ function on_library_door_click()
end end
else else
game_api.start_dialogue("door_dialog001") game_api.start_dialogue("door_dialog001")
end end]]
end if (game_api.is_night()) then
if (not game.is_dawn() or not (morning_did_open_door_index == 4)) then
--[[ game_api.start_dialogue("door_dialog001")
--if (player_left_darklands) then
if (morning_can_open_door_index == 5) then
if (morning_did_open_door_index == 0) then
morning_did_open_door_index = 5
game_api.start_dialogue("door_unlock_dialog001")
game_api.rotate_object("Room_N_2_Leaf001", -90, 0.5, nil)
game_api.fade_object("Room_Cover_Corridor_001", 0, 0.5, function()
game_api.deactivate_interactive_object("Room_Cover_Corridor_001")
end)
game_api.switch_navigation(12)
end end
elseif (night_time && day == 0) then end
game_api.start_dialogue("door_night_dialog001") --[[
else if (game_api.is_night() and not game.is_dawn()) then
if (not lection_is_over) then
game_api.start_dialogue("door_dialog001") game_api.start_dialogue("door_dialog001")
end end
end]] if (game_api.is_night() and game.is_dawn() and not (morning_did_open_door_index == 4) then
game_api.start_dialogue("door_dialog001")
end]]
end
end end
function on_teachers_door_click() function on_teachers_door_click()
@ -290,7 +281,6 @@ function on_teachers_door_click()
print(day) print(day)
if (day == 0) then if (day == 0) then
if (player_hold_key) then if (player_hold_key) then
if (not teacher_door_opened) then if (not teacher_door_opened) then
teacher_door_opened = true teacher_door_opened = true
@ -304,7 +294,6 @@ function on_teachers_door_click()
game_api.start_dialogue("door_teacher_dialog001") game_api.start_dialogue("door_teacher_dialog001")
end end
else else
print("morning_can_open_door_index is") print("morning_can_open_door_index is")
print(morning_can_open_door_index) print(morning_can_open_door_index)
if (morning_can_open_door_index == 3) then if (morning_can_open_door_index == 3) then
@ -369,72 +358,53 @@ function on_hall_door_click()
end) end)
game_api.set_npc_enabled(0, true) game_api.set_npc_enabled(0, true)
end end
else else
if (morning_can_open_door_index == 6) then if (game_api.is_night()) then
if (morning_did_open_door_index == 0) then if not (morning_did_open_door_index == 6) then
morning_did_open_door_index = 6 game_api.start_dialogue("door_dialog001")
game_api.start_dialogue("door_unlock_dialog001")
game_api.rotate_object("Hall_Leaf001", 90, 0.5, nil)
game_api.fade_object("Room_Cover_Corridor_001", 0, 0.5, function()
game_api.deactivate_interactive_object("Room_Cover_Corridor_001")
end)
game_api.switch_navigation(13)
end end
else --[[
game_api.start_dialogue("door_dialog001") if (morning_can_open_door_index == 6) then
if (morning_did_open_door_index == 0) then
morning_did_open_door_index = 6
game_api.start_dialogue("door_unlock_dialog001")
game_api.rotate_object("Hall_Leaf001", 90, 0.5, nil)
game_api.fade_object("Room_Cover_Corridor_001", 0, 0.5, function()
game_api.deactivate_interactive_object("Room_Cover_Corridor_001")
end)
game_api.switch_navigation(13)
end
else
game_api.start_dialogue("door_dialog001")
end]]
end end
end end
--[[
if (player_left_darklands) then
if (morning_can_open_door_index == 6) then
if (morning_did_open_door_index == 0) then
morning_did_open_door_index = 6
game_api.start_dialogue("door_unlock_dialog001")
game_api.rotate_object("Hall_Leaf001", 90, 0.5, nil)
game_api.fade_object("Room_Cover_Corridor_001", 0, 0.5, function()
game_api.deactivate_interactive_object("Room_Cover_Corridor_001")
end)
game_api.switch_navigation(13)
end
else
game_api.start_dialogue("door_dialog001")
end
elseif (not hall_door_opened) then
hall_door_opened = true
game_api.switch_navigation(1)
game_api.rotate_object("Hall_Leaf001", 90, 0.5, nil)
game_api.fade_object("Room_Cover_Main_Hall_001", 0, 0.5, function()
game_api.deactivate_interactive_object("Room_Cover_Main_Hall_001")
end)
game_api.set_npc_enabled(0, true)
end]]
end end
function on_s1_door_click() function on_s1_door_click()
--if (player_left_darklands) then --if (player_left_darklands) then
if (morning_can_open_door_index == 1) then if (game_api.is_night()) then
if (morning_did_open_door_index == 0) then if not (morning_did_open_door_index == 1) then
morning_did_open_door_index = 1 game_api.start_dialogue("door_dialog001")
game_api.start_dialogue("door_unlock_dialog001") end
end
--[[
if (morning_can_open_door_index == 1) then
if (morning_did_open_door_index == 0) then
morning_did_open_door_index = 1
game_api.start_dialogue("door_unlock_dialog001")
game_api.rotate_object("Room_S_0_Leaf001", 90, 0.5, nil) game_api.rotate_object("Room_S_0_Leaf001", 90, 0.5, nil)
game_api.fade_object("Room_Cover_Corridor_001", 0, 0.5, function() game_api.fade_object("Room_Cover_Corridor_001", 0, 0.5, function()
game_api.deactivate_interactive_object("Room_Cover_Corridor_001") game_api.deactivate_interactive_object("Room_Cover_Corridor_001")
end) end)
game_api.switch_navigation(8) game_api.switch_navigation(8)
end
else
game_api.start_dialogue("door_dialog001")
end end
else
game_api.start_dialogue("door_dialog001")
end
]]
--else --else
-- game_api.start_dialogue("door_dialog001") -- game_api.start_dialogue("door_dialog001")
--end --end
@ -442,52 +412,62 @@ end
function on_s2_door_click() function on_s2_door_click()
--if (player_left_darklands) then --if (player_left_darklands) then
if (morning_can_open_door_index == 2) then
if (morning_did_open_door_index == 0) then
morning_did_open_door_index = 2
game_api.start_dialogue("door_unlock_dialog001")
game_api.rotate_object("Room_S_1_Leaf001", 90, 0.5, nil) if (game_api.is_night()) then
game_api.fade_object("Room_Cover_Corridor_001", 0, 0.5, function() if not (morning_did_open_door_index == 2) then
game_api.deactivate_interactive_object("Room_Cover_Corridor_001") game_api.start_dialogue("door_dialog001")
end) end
game_api.switch_navigation(9) end
end
else --[[
game_api.start_dialogue("door_dialog001") if (morning_can_open_door_index == 2) then
if (morning_did_open_door_index == 0) then
morning_did_open_door_index = 2
game_api.start_dialogue("door_unlock_dialog001")
game_api.rotate_object("Room_S_1_Leaf001", 90, 0.5, nil)
game_api.fade_object("Room_Cover_Corridor_001", 0, 0.5, function()
game_api.deactivate_interactive_object("Room_Cover_Corridor_001")
end)
game_api.switch_navigation(9)
end end
--else else
-- game_api.start_dialogue("door_dialog001") game_api.start_dialogue("door_dialog001")
--end end
]]
end end
function on_n2_door_click() function on_n2_door_click()
print("player_left_darklands") print("player_left_darklands")
print(player_left_darklands) print(player_left_darklands)
print("morning_can_open_door_index") print("morning_can_open_door_index")
print(morning_can_open_door_index) print(morning_can_open_door_index)
print("morning_did_open_door_index") print("morning_did_open_door_index")
print(morning_did_open_door_index) print(morning_did_open_door_index)
--if (player_left_darklands) then
if (morning_can_open_door_index == 4) then
if (morning_did_open_door_index == 0) then
morning_did_open_door_index = 4
game_api.start_dialogue("door_unlock_dialog001")
game_api.rotate_object("Room_N_1_Leaf001", -90, 0.5, nil) if (game_api.is_night()) then
game_api.fade_object("Room_Cover_Corridor_001", 0, 0.5, function() if not (morning_did_open_door_index == 4) then
game_api.deactivate_interactive_object("Room_Cover_Corridor_001") game_api.start_dialogue("door_dialog001")
end) end
game_api.switch_navigation(11) end
else
--game_api.start_dialogue("door_opened_dialog001") --[[
end if (morning_can_open_door_index == 4) then
else if (morning_did_open_door_index == 0) then
game_api.start_dialogue("door_dialog001") morning_did_open_door_index = 4
game_api.start_dialogue("door_unlock_dialog001")
game_api.rotate_object("Room_N_1_Leaf001", -90, 0.5, nil)
game_api.fade_object("Room_Cover_Corridor_001", 0, 0.5, function()
game_api.deactivate_interactive_object("Room_Cover_Corridor_001")
end)
game_api.switch_navigation(11)
end end
--else else
-- game_api.start_dialogue("door_dialog001") game_api.start_dialogue("door_dialog001")
--end end]]
end end
function on_teacher_arrived() function on_teacher_arrived()
@ -605,6 +585,7 @@ function on_note_pickup()
end end
function on_report_card_pickup() function on_report_card_pickup()
local player_ghost_aware = game_api.getIntValue("ghost_aware")
if player_ghost_aware then if player_ghost_aware then
local player_has_report_card = game_api.getIntValue("player_has_report_card") local player_has_report_card = game_api.getIntValue("player_has_report_card")
local report_card_signed = game_api.getIntValue("report_card_signed") local report_card_signed = game_api.getIntValue("report_card_signed")
@ -934,7 +915,136 @@ game_api.set_enter_night_callback(
) )
game_api.set_chat_callbacks(
function()
if (game_api.is_night()) then
if (game_api.is_dawn()) then
if not (morning_can_open_door_index == 0) and not (morning_can_open_door_index==3) then
game_api.start_dialogue("phone_morning_aiperi001")
else
--TODO: add aiperi that she already came to the uni
end
else
local asked_help_locked = game_api.getIntValue("asked_help_locked")
if not (asked_help_locked == 1) then
game_api.start_dialogue("phone_night_aiperi001")
end
end
end
end,
nil,
nil
)
--[[function on_aiperi_give_keys()
game_api.pickup_item("all_keys")
end]]
function on_aiperi_opens_door()
print("on_aiperi_opens_door")
game_api.close_phone()
if (morning_can_open_door_index == 6) then
if (morning_did_open_door_index == 0) then
morning_did_open_door_index = 6
game_api.start_dialogue("door_unlock_dialog001")
game_api.rotate_object("Hall_Leaf001", 90, 0.5, nil)
game_api.fade_object("Room_Cover_Corridor_001", 0, 0.5, function()
game_api.deactivate_interactive_object("Room_Cover_Corridor_001")
end)
game_api.switch_navigation(13)
--Aiperi incoming
game_api.npc_set_position(1, 0, 0, 6.5)
game_api.set_npc_enabled(1, true)
game_api.npc_walk_to(1, 0.0, 0, 9.1, function()
game_api.start_dialogue("dialog_aiperi_morning001")
end)
game_api.player_walk_to(0.0, 0.0, 10.0, nil)
end
elseif (morning_can_open_door_index == 5) then
if (morning_did_open_door_index == 0) then
morning_did_open_door_index = 5
game_api.start_dialogue("door_unlock_dialog001")
game_api.rotate_object("Room_N_2_Leaf001", -90, 0.5, nil)
game_api.fade_object("Room_Cover_Corridor_001", 0, 0.5, function()
game_api.deactivate_interactive_object("Room_Cover_Corridor_001")
end)
game_api.switch_navigation(12)
--Aiperi incoming
game_api.npc_set_position(1, 0.0, 0, 6.43818)
game_api.set_npc_enabled(1, true)
game_api.npc_walk_to(1, 2.34349, 0, 6.31229, function()
game_api.start_dialogue("dialog_aiperi_morning001")
end)
game_api.player_walk_to(3.52174, 0, 6.28993, nil)
end
elseif (morning_can_open_door_index == 4) then
if (morning_did_open_door_index == 0) then
morning_did_open_door_index = 4
game_api.start_dialogue("door_unlock_dialog001")
game_api.rotate_object("Room_N_1_Leaf001", -90, 0.5, nil)
game_api.fade_object("Room_Cover_Corridor_001", 0, 0.5, function()
game_api.deactivate_interactive_object("Room_Cover_Corridor_001")
end)
game_api.switch_navigation(11)
--Aiperi incoming
game_api.npc_set_position(1, 0, 0, -1.7911)
game_api.set_npc_enabled(1, true)
game_api.npc_walk_to(1, 2.11898, 0, -1.7074, function()
game_api.start_dialogue("dialog_aiperi_morning001")
end)
game_api.player_walk_to(3.36055, 0, -2.0345, nil)
end
elseif (morning_can_open_door_index == 2) then
if (morning_did_open_door_index == 0) then
morning_did_open_door_index = 2
game_api.start_dialogue("door_unlock_dialog001")
game_api.rotate_object("Room_S_1_Leaf001", 90, 0.5, nil)
game_api.fade_object("Room_Cover_Corridor_001", 0, 0.5, function()
game_api.deactivate_interactive_object("Room_Cover_Corridor_001")
end)
game_api.switch_navigation(9)
--Aiperi incoming
game_api.npc_set_position(1, 0, 0, -1.7911)
game_api.set_npc_enabled(1, true)
game_api.npc_walk_to(1, -2.454, 0, -1.61871, function()
game_api.start_dialogue("dialog_aiperi_morning001")
end)
game_api.player_walk_to(-3.63363, 0, -1.50727, nil)
end
elseif (morning_can_open_door_index == 1) then
if (morning_did_open_door_index == 0) then
morning_did_open_door_index = 1
game_api.start_dialogue("door_unlock_dialog001")
game_api.rotate_object("Room_S_0_Leaf001", 90, 0.5, nil)
game_api.fade_object("Room_Cover_Corridor_001", 0, 0.5, function()
game_api.deactivate_interactive_object("Room_Cover_Corridor_001")
end)
game_api.switch_navigation(8)
--Aiperi incoming
game_api.npc_set_position(1, 0, 0, -9.77948)
game_api.set_npc_enabled(1, true)
game_api.npc_walk_to(1, -2.53977, 0, -9.77852, function()
game_api.start_dialogue("dialog_aiperi_morning001")
end)
game_api.player_walk_to(-3.64494, 0, -9.66711, nil)
end
end
end
function on_aiperi_morning_dialog_finished()
game_api.npc_walk_to(1, 0.764049, 0, -8.5, nil)
end
game_api.set_location_callbacks( game_api.set_location_callbacks(
function() function()

View File

@ -200,7 +200,7 @@ namespace ZL
LocationSetup uniInteriorParams; LocationSetup uniInteriorParams;
uniInteriorParams.gameObjectsJsonPath = "resources/config2/gameobjects_uni_interior.json"; uniInteriorParams.gameObjectsJsonPath = "resources/config2/gameobjects_uni_interior.json";
uniInteriorParams.npcsJsonPath = "resources/config2/npcs_uni_interior.json"; uniInteriorParams.npcsJsonPath = "resources/config2/npcs_uni_interior.json";
uniInteriorParams.dialoguesJsonPath = "resources/dialogue/uni_interior_dialogues.json"; uniInteriorParams.dialoguesJsonPath = "resources/dialogue/uni_interior_dialogues_003.json";
/* /*
@ -242,6 +242,7 @@ namespace ZL
}; };
*/ */
uniInteriorParams.navigationJsonPaths = { uniInteriorParams.navigationJsonPaths = {
"resources/navigation/uni_interior3_all_locked.json", "resources/navigation/uni_interior3_all_locked.json",
"resources/navigation/uni_interior3_hall.json", "resources/navigation/uni_interior3_hall.json",
@ -258,6 +259,23 @@ namespace ZL
"resources/navigation/uni_interior3_unlocked_n3.json", "resources/navigation/uni_interior3_unlocked_n3.json",
"resources/navigation/uni_interior3_unlocked_hall.json" "resources/navigation/uni_interior3_unlocked_hall.json"
}; };
/*
uniInteriorParams.navigationJsonPaths = {
"resources/navigation/uni_interior3_darklands_all_open.json",
"resources/navigation/uni_interior3_darklands_all_open.json",
"resources/navigation/uni_interior3_darklands_all_open.json",
"resources/navigation/uni_interior3_darklands_all_open.json",
"resources/navigation/uni_interior3_darklands_all_open.json",
"resources/navigation/uni_interior3_darklands_all_open.json",
"resources/navigation/uni_interior3_darklands_all_open.json", //6
"resources/navigation/uni_interior3_darklands_all_open.json",
"resources/navigation/uni_interior3_darklands_all_open.json",
"resources/navigation/uni_interior3_darklands_all_open.json",
"resources/navigation/uni_interior3_darklands_all_open.json",
"resources/navigation/uni_interior3_darklands_all_open.json",
"resources/navigation/uni_interior3_darklands_all_open.json",
"resources/navigation/uni_interior3_darklands_all_open.json"
};*/
uniInteriorParams.teleportsJsonPath = "resources/config2/teleports_uni_interior.json"; uniInteriorParams.teleportsJsonPath = "resources/config2/teleports_uni_interior.json";
uniInteriorParams.triggerZonesJsonPath = "resources/config2/trigger_zones_uni_interior.json"; uniInteriorParams.triggerZonesJsonPath = "resources/config2/trigger_zones_uni_interior.json";
@ -273,6 +291,7 @@ namespace ZL
locations["uni_interior"]->requestNightDayTransition = [this](bool isNight, bool isDawn) { this->menuManager.isNight = isNight; this->menuManager.isDawn = isDawn; }; locations["uni_interior"]->requestNightDayTransition = [this](bool isNight, bool isDawn) { this->menuManager.isNight = isNight; this->menuManager.isDawn = isDawn; };
locations["uni_interior"]->requestDarklandsTransition = [this]() { return startDarklandsTransition(); }; locations["uni_interior"]->requestDarklandsTransition = [this]() { return startDarklandsTransition(); };
locations["uni_interior"]->requestAdvanceDarklandsHud = [this]() { menuManager.advanceUniIntDarklandsHud(); }; locations["uni_interior"]->requestAdvanceDarklandsHud = [this]() { menuManager.advanceUniIntDarklandsHud(); };
locations["uni_interior"]->requestClosePhone = [this]() { menuManager.closePhoneEntirely(); };
if (locations["uni_interior"]->player) if (locations["uni_interior"]->player)
locations["uni_interior"]->player->onDeathAnimComplete = [this]() { startDarklandsTransition(); }; locations["uni_interior"]->player->onDeathAnimComplete = [this]() { startDarklandsTransition(); };
for (auto& npc : locations["uni_interior"]->npcs) { for (auto& npc : locations["uni_interior"]->npcs) {
@ -301,6 +320,7 @@ namespace ZL
locations["uni_exterior"]->scriptEngine.setGlobalFloatStore(&globalFloats); locations["uni_exterior"]->scriptEngine.setGlobalFloatStore(&globalFloats);
locations["uni_exterior"]->requestNightDayTransition = [this](bool isNight, bool isDawn) { this->menuManager.isNight = isNight; this->menuManager.isDawn = isDawn; }; locations["uni_exterior"]->requestNightDayTransition = [this](bool isNight, bool isDawn) { this->menuManager.isNight = isNight; this->menuManager.isDawn = isDawn; };
locations["uni_exterior"]->requestDarklandsTransition = [this]() { return startDarklandsTransition(); }; locations["uni_exterior"]->requestDarklandsTransition = [this]() { return startDarklandsTransition(); };
locations["uni_exterior"]->requestClosePhone = [this]() { menuManager.closePhoneEntirely(); };
if (locations["uni_exterior"]->player) if (locations["uni_exterior"]->player)
locations["uni_exterior"]->player->onDeathAnimComplete = [this]() { startDarklandsTransition(); }; locations["uni_exterior"]->player->onDeathAnimComplete = [this]() { startDarklandsTransition(); };
@ -362,6 +382,7 @@ namespace ZL
locations["location_dorm"]->scriptEngine.setGlobalFloatStore(&globalFloats); locations["location_dorm"]->scriptEngine.setGlobalFloatStore(&globalFloats);
locations["location_dorm"]->requestNightDayTransition = [this](bool isNight, bool isDawn) { this->menuManager.isNight = isNight; this->menuManager.isDawn = isDawn; }; locations["location_dorm"]->requestNightDayTransition = [this](bool isNight, bool isDawn) { this->menuManager.isNight = isNight; this->menuManager.isDawn = isDawn; };
locations["location_dorm"]->requestDarklandsTransition = [this]() { return startDarklandsTransition(); }; locations["location_dorm"]->requestDarklandsTransition = [this]() { return startDarklandsTransition(); };
locations["location_dorm"]->requestClosePhone = [this]() { menuManager.closePhoneEntirely(); };
if (locations["location_dorm"]->player) if (locations["location_dorm"]->player)
locations["location_dorm"]->player->onDeathAnimComplete = [this]() { startDarklandsTransition(); }; locations["location_dorm"]->player->onDeathAnimComplete = [this]() { startDarklandsTransition(); };
@ -391,6 +412,12 @@ namespace ZL
locations["uni_interior"]->onTeleport = teleportCallback; locations["uni_interior"]->onTeleport = teleportCallback;
locations["location_dorm"]->onTeleport = teleportCallback; locations["location_dorm"]->onTeleport = teleportCallback;
// Share the global int store with all dialogue runtimes so flags set via
// dialogue JSON and via Lua set_dialogue_flag all see the same state.
for (auto& [name, loc] : locations) {
loc->dialogueSystem.setGlobalFlagStore(&globalInts);
}
// Wire tutorial advance callbacks for all locations. // Wire tutorial advance callbacks for all locations.
// advanceTutorialStep() guards against double-advancing, so sharing is safe. // advanceTutorialStep() guards against double-advancing, so sharing is safe.
for (auto& [name, loc] : locations) { for (auto& [name, loc] : locations) {
@ -423,6 +450,11 @@ namespace ZL
startNightTransition(); startNightTransition();
}; };
menuManager.chatOpenCallback = [this](int chatIndex) {
if (currentLocation)
currentLocation->scriptEngine.callChatOpenCallback(chatIndex);
};
// Wire chat-bubble callback so dynamic bubbles appear as dialogue lines are shown. // Wire chat-bubble callback so dynamic bubbles appear as dialogue lines are shown.
for (auto& [name, loc] : locations) { for (auto& [name, loc] : locations) {
loc->dialogueSystem.setOnChatBubbleReady([this](const std::string& text, bool incoming) { loc->dialogueSystem.setOnChatBubbleReady([this](const std::string& text, bool incoming) {

View File

@ -116,6 +116,8 @@ namespace ZL
// from step12 to step13 (trigger-zone encounter hint). // from step12 to step13 (trigger-zone encounter hint).
std::function<void()> requestAdvanceDarklandsHud; std::function<void()> requestAdvanceDarklandsHud;
std::function<void()> requestClosePhone;
// Navigation editor — toggle with 'N', save with 'B', right-click to finalize polygon // Navigation editor — toggle with 'N', save with 'B', right-click to finalize polygon
EditorMode editorMode = EditorMode::None; EditorMode editorMode = EditorMode::None;
LocationEditor editor; LocationEditor editor;

View File

@ -347,15 +347,15 @@ namespace ZL {
uiManager.setButtonCallback("phoneMain", [this](const std::string&) {}); uiManager.setButtonCallback("phoneMain", [this](const std::string&) {});
uiManager.setTextButtonCallback("chat1button", [this](const std::string&) { uiManager.setTextButtonCallback("chat1button", [this](const std::string&) {
chatUnread_[0] = false; chatUnread_[0] = false;
openPhoneChatFromList(0, phoneChat1Root, "dialog_chat_aiperi001"); openPhoneChatFromList(0, phoneChat1Root);
}); });
uiManager.setTextButtonCallback("chat2button", [this](const std::string&) { uiManager.setTextButtonCallback("chat2button", [this](const std::string&) {
chatUnread_[1] = false; chatUnread_[1] = false;
openPhoneChatFromList(1, phoneChat2Root, "dialog_chat_parents001"); openPhoneChatFromList(1, phoneChat2Root);
}); });
uiManager.setTextButtonCallback("chat3button", [this](const std::string&) { uiManager.setTextButtonCallback("chat3button", [this](const std::string&) {
chatUnread_[2] = false; chatUnread_[2] = false;
openPhoneChatFromList(2, phoneChat3Root, "dialog_chat_news001"); openPhoneChatFromList(2, phoneChat3Root);
}); });
} }
@ -436,13 +436,11 @@ namespace ZL {
}); });
} }
void MenuManager::openPhoneChatFromList(int chatIndex, std::shared_ptr<UiNode> chatRoot, const std::string& dialogueId) { void MenuManager::openPhoneChatFromList(int chatIndex, std::shared_ptr<UiNode> chatRoot) {
activeChatIndex_ = chatIndex; activeChatIndex_ = chatIndex;
phoneChatVisibleBubbles_.clear(); phoneChatVisibleBubbles_.clear();
uiManager.pushMenuFromSavedRoot(chatRoot); uiManager.pushMenuFromSavedRoot(chatRoot);
const bool firstOpen = dialogueId.empty() || startedDialogues_.find(dialogueId) == startedDialogues_.end();
rebuildChatBubblesFromHistory(chatIndex); rebuildChatBubblesFromHistory(chatIndex);
uiManager.setButtonCallback("phoneExitButton", [this](const std::string&) { uiManager.setButtonCallback("phoneExitButton", [this](const std::string&) {
@ -453,9 +451,8 @@ namespace ZL {
returnToPhoneChatList(); returnToPhoneChatList();
}); });
if (firstOpen && startDialogueFunc && !dialogueId.empty()) { if (chatOpenCallback) {
startedDialogues_.insert(dialogueId); chatOpenCallback(chatIndex);
startDialogueFunc(dialogueId);
} }
} }

View File

@ -8,7 +8,6 @@
#include <vector> #include <vector>
#include <string> #include <string>
#include <memory> #include <memory>
#include <unordered_set>
namespace ZL { namespace ZL {
@ -69,6 +68,7 @@ namespace ZL {
std::function<void(const std::string&)> startDialogueFunc; std::function<void(const std::string&)> startDialogueFunc;
std::function<void()> startDarklandsTransitionFunc; std::function<void()> startDarklandsTransitionFunc;
std::function<void()> startNightTransitionFunc; std::function<void()> startNightTransitionFunc;
std::function<void(int)> chatOpenCallback;
// Called when a chat message bubble should be shown (text + direction) // Called when a chat message bubble should be shown (text + direction)
void onChatBubbleReady(const std::string& text, bool incoming); void onChatBubbleReady(const std::string& text, bool incoming);
@ -104,7 +104,7 @@ namespace ZL {
void refreshChatUnreadIndicators(); void refreshChatUnreadIndicators();
void resetPhoneChatNodes(); void resetPhoneChatNodes();
void recomputePhoneChatPositions(); void recomputePhoneChatPositions();
void openPhoneChatFromList(int chatIndex, std::shared_ptr<UiNode> chatRoot, const std::string& dialogueId); void openPhoneChatFromList(int chatIndex, std::shared_ptr<UiNode> chatRoot);
void returnToPhoneChatList(); void returnToPhoneChatList();
void closePhoneScreenFromChat(); void closePhoneScreenFromChat();
void applyUniIntHud(); void applyUniIntHud();
@ -166,9 +166,6 @@ namespace ZL {
int selectedQuestIndex = -1; int selectedQuestIndex = -1;
std::vector<std::string> visibleQuestIds; std::vector<std::string> visibleQuestIds;
// Dialogues that have been started at least once; re-opening a chat won't restart them
std::unordered_set<std::string> startedDialogues_;
bool chatUnread_[3] = { true, true, true }; bool chatUnread_[3] = { true, true, true };
int money_ = 5500; int money_ = 5500;

View File

@ -26,6 +26,7 @@ namespace ZL {
sol::protected_function darklandsEnterCallback; sol::protected_function darklandsEnterCallback;
sol::protected_function darklandsExitCallback; sol::protected_function darklandsExitCallback;
sol::protected_function triggerNightEnterCallback; sol::protected_function triggerNightEnterCallback;
sol::protected_function chatOpenCallbacks[3];
}; };
ScriptEngine::ScriptEngine() = default; ScriptEngine::ScriptEngine() = default;
@ -65,6 +66,44 @@ namespace ZL {
npcs[index]->setTarget(Eigen::Vector3f(x, y, z), std::move(cb)); npcs[index]->setTarget(Eigen::Vector3f(x, y, z), std::move(cb));
}); });
api.set_function("player_walk_to",
[game](float x, float y, float z, sol::object on_arrived) {
std::function<void()> cb;
if (on_arrived.is<sol::protected_function>()) {
sol::protected_function fn = on_arrived.as<sol::protected_function>();
cb = [fn]() mutable {
auto result = fn();
if (!result.valid()) {
sol::error err = result;
std::cerr << "[script] on_arrived error: " << err.what() << "\n";
}
};
}
game->player->homePosition = Eigen::Vector3f(x, 0.f, z);
game->player->setTarget(Eigen::Vector3f(x, y, z), std::move(cb));
});
api.set_function("has_item", [inventory](const std::string& itemId) {
const Item* item = ItemRegistry::instance().findById(itemId);
if (item) {
if (inventory->hasItem(itemId)) {
std::cout << "[script] has_item: " << item->name << " returns true" << std::endl;
return true;
}
else
{
std::cout << "[script] has_item: " << item->name << " returns false" << std::endl;
return false;
}
}
else {
std::cerr << "[script] has_item: item '" << itemId << "' not found in ItemRegistry\n";
return false;
}
});
// pickup_item(item_id) // pickup_item(item_id)
api.set_function("pickup_item", [inventory](const std::string& itemId) { api.set_function("pickup_item", [inventory](const std::string& itemId) {
const Item* item = ItemRegistry::instance().findById(itemId); const Item* item = ItemRegistry::instance().findById(itemId);
@ -229,6 +268,11 @@ namespace ZL {
return game->isNight; return game->isNight;
}); });
api.set_function("is_dawn",
[game]() {
return game->isDawn;
});
// advance_darklands_hud() // advance_darklands_hud()
// Advances the uni_interior darklands HUD from step12 to step13. // Advances the uni_interior darklands HUD from step12 to step13.
// Call when the player enters the ghost trigger zone in darklands. // Call when the player enters the ghost trigger zone in darklands.
@ -319,6 +363,19 @@ namespace ZL {
this_impl->darklandsExitCallback = onExit.as<sol::protected_function>(); this_impl->darklandsExitCallback = onExit.as<sol::protected_function>();
}); });
api.set_function("close_phone",
[game]() {
if (game->requestClosePhone)
game->requestClosePhone();
});
api.set_function("set_chat_callbacks",
[this_impl = impl.get()](sol::object cb0, sol::object cb1, sol::object cb2) {
if (cb0.is<sol::protected_function>()) this_impl->chatOpenCallbacks[0] = cb0.as<sol::protected_function>();
if (cb1.is<sol::protected_function>()) this_impl->chatOpenCallbacks[1] = cb1.as<sol::protected_function>();
if (cb2.is<sol::protected_function>()) this_impl->chatOpenCallbacks[2] = cb2.as<sol::protected_function>();
});
api.set_function("set_enter_night_callback", api.set_function("set_enter_night_callback",
[this_impl = impl.get()](sol::object onEnter) { [this_impl = impl.get()](sol::object onEnter) {
if (onEnter.is<sol::protected_function>()) if (onEnter.is<sol::protected_function>())
@ -357,6 +414,7 @@ namespace ZL {
} }
Eigen::Vector3f pos(x, y, z); Eigen::Vector3f pos(x, y, z);
npcs[index]->position = pos; npcs[index]->position = pos;
npcs[index]->homePosition = pos;
npcs[index]->setTarget(pos); npcs[index]->setTarget(pos);
}); });
@ -790,6 +848,18 @@ namespace ZL {
} }
} }
void ScriptEngine::callChatOpenCallback(int chatIndex) {
if (!impl) return;
if (chatIndex < 0 || chatIndex > 2) return;
auto& fn = impl->chatOpenCallbacks[chatIndex];
if (!fn.valid()) return;
auto result = fn();
if (!result.valid()) {
sol::error err = result;
std::cerr << "[SCRIPT] chat open callback error for chat " << chatIndex << ": " << err.what() << "\n";
}
}
void ScriptEngine::callNpcBumpsPlayerCallback(int npcIndex) { void ScriptEngine::callNpcBumpsPlayerCallback(int npcIndex) {
if (!impl) return; if (!impl) return;
auto it = impl->npcBumpsPlayerCallbacks.find(npcIndex); auto it = impl->npcBumpsPlayerCallbacks.find(npcIndex);

View File

@ -48,6 +48,8 @@ public:
void callCutsceneCompleteCallback(const std::string& cutsceneId); void callCutsceneCompleteCallback(const std::string& cutsceneId);
void callChatOpenCallback(int chatIndex);
private: private:
struct Impl; struct Impl;
std::unique_ptr<Impl> impl; std::unique_ptr<Impl> impl;

View File

@ -307,12 +307,17 @@ void DialogueRuntime::skipCurrentCutscene() {
} }
void DialogueRuntime::setFlag(const std::string& name, int value) { void DialogueRuntime::setFlag(const std::string& name, int value) {
flags[name] = value; if (flagStore) (*flagStore)[name] = value;
} }
int DialogueRuntime::getFlag(const std::string& name) const { int DialogueRuntime::getFlag(const std::string& name) const {
auto it = flags.find(name); if (!flagStore) return 0;
return (it != flags.end()) ? it->second : 0; auto it = flagStore->find(name);
return (it != flagStore->end()) ? it->second : 0;
}
void DialogueRuntime::setGlobalFlagStore(std::unordered_map<std::string, int>* store) {
flagStore = store;
} }
void DialogueRuntime::setQuestJournal(Quest::QuestJournal* journal) { void DialogueRuntime::setQuestJournal(Quest::QuestJournal* journal) {
@ -368,10 +373,10 @@ void DialogueRuntime::applyEffects(const std::vector<Effect>& effects) {
continue; continue;
} }
if (effect.relative) { if (effect.relative) {
flags[effect.flag] += effect.value; setFlag(effect.flag, getFlag(effect.flag) + effect.value);
} }
else { else {
flags[effect.flag] = effect.value; setFlag(effect.flag, effect.value);
} }
} }
} }
@ -424,6 +429,8 @@ bool DialogueRuntime::enterNode(const std::string& nodeId) {
return true; return true;
case NodeType::End: case NodeType::End:
if (!node.luaCallback.empty() && onDialogueLineStarted)
onDialogueLineStarted(node.luaCallback);
stop(); stop();
return true; return true;
@ -907,7 +914,7 @@ int DialogueRuntime::computeCameraTrackDurationMs(const StaticCutsceneDefinition
} }
return total; return total;
} }
/*
DialogueRuntime::json DialogueRuntime::buildSaveState() const { DialogueRuntime::json DialogueRuntime::buildSaveState() const {
json result; json result;
result["active"] = isActive(); result["active"] = isActive();
@ -917,7 +924,6 @@ DialogueRuntime::json DialogueRuntime::buildSaveState() const {
result["selectedChoice"] = selectedChoice; result["selectedChoice"] = selectedChoice;
result["currentCutsceneLine"] = currentCutsceneLine; result["currentCutsceneLine"] = currentCutsceneLine;
result["cutsceneTimerMs"] = cutsceneTimerMs; result["cutsceneTimerMs"] = cutsceneTimerMs;
result["flags"] = flags;
result["consumedChoices"] = consumedChoices; result["consumedChoices"] = consumedChoices;
return result; return result;
} }
@ -930,10 +936,7 @@ bool DialogueRuntime::restoreSaveState(const json& state) {
flags.clear(); flags.clear();
consumedChoices.clear(); consumedChoices.clear();
if (state.contains("flags")) { if (state.contains("consumedChoices")) {
flags = state["flags"].get<std::unordered_map<std::string, int>>();
}
if (state.contains("consumedChoices")) {
consumedChoices = state["consumedChoices"].get<std::unordered_set<std::string>>(); consumedChoices = state["consumedChoices"].get<std::unordered_set<std::string>>();
} }
@ -963,5 +966,5 @@ bool DialogueRuntime::restoreSaveState(const json& state) {
} }
return ok; return ok;
} }
*/
} // namespace ZL::Dialogue } // namespace ZL::Dialogue

View File

@ -43,10 +43,12 @@ public:
void setFlag(const std::string& name, int value); void setFlag(const std::string& name, int value);
int getFlag(const std::string& name) const; int getFlag(const std::string& name) const;
void setGlobalFlagStore(std::unordered_map<std::string, int>* store);
void setQuestJournal(Quest::QuestJournal* journal); void setQuestJournal(Quest::QuestJournal* journal);
json buildSaveState() const; //json buildSaveState() const;
bool restoreSaveState(const json& state); //bool restoreSaveState(const json& state);
private: private:
enum class Mode { enum class Mode {
@ -69,7 +71,7 @@ private:
const DialogueDefinition* activeDialogue = nullptr; const DialogueDefinition* activeDialogue = nullptr;
const StaticCutsceneDefinition* activeCutscene = nullptr; const StaticCutsceneDefinition* activeCutscene = nullptr;
std::unordered_map<std::string, int> flags; std::unordered_map<std::string, int>* flagStore = nullptr;
std::unordered_set<std::string> consumedChoices; std::unordered_set<std::string> consumedChoices;
std::string currentNodeId; std::string currentNodeId;

View File

@ -38,6 +38,8 @@ public:
void setFlag(const std::string& name, int value) { runtime.setFlag(name, value); } void setFlag(const std::string& name, int value) { runtime.setFlag(name, value); }
int getFlag(const std::string& name) const { return runtime.getFlag(name); } int getFlag(const std::string& name) const { return runtime.getFlag(name); }
void setGlobalFlagStore(std::unordered_map<std::string, int>* store) { runtime.setGlobalFlagStore(store); }
void setQuestJournal(Quest::QuestJournal* journal) { runtime.setQuestJournal(journal); } void setQuestJournal(Quest::QuestJournal* journal) { runtime.setQuestJournal(journal); }
private: private: