commit 7a088b25a9bf78f4e764a9abd1579e36d03f3ee7 Author: iisaev Date: Mon Oct 19 20:21:01 2026 +0300 init mod diff --git a/locale/en.properties b/locale/en.properties new file mode 100644 index 0000000..3f78c86 --- /dev/null +++ b/locale/en.properties @@ -0,0 +1,158 @@ +mod_scenario_studio_name = Scenario Studio +mod_scenario_studio_desc = Debug and edit scenarios while the game runs: pause, step, restart, the current line, why scenarios started, game state, an event log and the scene map. Author and Novice modes. +studio/title = Studio +studio/mode_author = Author +studio/mode_novice = Novice +studio/close = Close the studio +studio/state_running = running +studio/state_paused = paused +studio/pause = ‖ Pause +studio/step = ▶| Step +studio/continue = ▶ Continue +studio/restart = ⟲ Restart ▾ +studio/restart_scenario = Scenario again (no rollback) +studio/restart_rollback = Scenario again with state rollback +studio/restart_scene = Scene again +studio/restart_from_label = Continue from the current label +studio/crumb_scene = scene +studio/crumb_scenario = scenario +studio/tab_now = Now +studio/tab_scene = Scene +studio/tab_state = State +studio/tab_log = Log +studio/tab_map = Map +studio/edits_go_to = edits go to the "{0}" layer +studio/after_line = after line {0} +studio/add_line = + Line +studio/add_choice = + Choice +studio/add_jump = + Go to scene +studio/new_line_text = New line +studio/new_choice_text = New choice +studio/affects = This change affects +studio/overrides = overrides +studio/pending = Pending changes: {0} +studio/no_pending = No changes +studio/edit_text = Edit text +studio/apply = Apply ⟲ +studio/scope_own = folder +studio/scope_overlay = zip +studio/scope_layer = layer +studio/scope_readonly = code +studio/cand_running = running +studio/cand_skipped = skipped +studio/cand_completed = completed +studio/cand_shadowed = shadowed +studio/cand_hint = This scene's scenarios in start order: why each one started or not. +studio/run_now = Run +studio/reset_completed = Reset "completed" +studio/log_scene = scene +studio/log_skip = skip +studio/log_scenario = scenario +studio/log_stop = stop +studio/log_edit = edit +studio/state_hint = Values can be changed to test another branch. +studio/map_from = Came from +studio/map_to = Exits from here +studio/map_go = Go +studio/map_new_scene = + New scene +studio/map_link = + Link +studio/node_here = you are here +studio/node_visited = visited +studio/node_next = exit +studio/node_new = new +studio/priority = priority {0} +studio/no_condition = no condition +studio/one_run = one-run +studio/error_label_missing = label {0} not found +studio/reason_completed = one-run, already completed +studio/group_time = Time +studio/group_quests = Quests +studio/group_inventory = Inventory +studio/group_dialog_vars = Dialog variables +studio/group_completed = Completed scenarios +studio/state_day = day +studio/state_hour = hour +studio/value_yes = yes +studio/log_text_enter = entered {0} +studio/log_text_condition = {0}: condition is false +studio/log_text_completed = {0}: already completed +studio/log_text_started = {0} started +studio/log_text_breakpoint = breakpoint, line {0} +studio/log_text_applied = {0}: +{1} → {2} +studio/quote = "{0}" +studio/say_question = Question +studio/say_line = Line +studio/say_new = {0} · new +studio/moment_now = On screen now +studio/moment_answers = Answers +studio/moment_next = Next in the scene +studio/moment_empty = Nothing on screen: no scenario runs or it stands on a service line. +studio/outcome_scene = goes to scene +studio/outcome_action = action +studio/outcome_end = end of scenario +studio/outcome_continue = dialog continues +studio/outcome_broken = leads nowhere +studio/edit = Edit +studio/choose_target = Choose where ▾ +studio/add_line_before = + Line before the question +studio/add_line_after = + Line after +studio/add_option = + Answer +studio/new_option_text = New answer +studio/next_waiting = {0} waits for its turn +studio/next_none = Nothing else is queued in this scene +studio/reason_condition_false = header condition is false +studio/applied_to = Applied to "{0}", scenario restarted with rollback +studio/bad_name = Name: lowercase latin letters, digits and _, starting with a letter +studio/ok = OK +studio/cancel = Cancel +studio/cand_failed = failed +studio/cand_none = The scene was not composed while the studio was on: enter it again. +studio/cand_queued = queued +studio/edit_done = Done +studio/error_no_target = answer leads nowhere +studio/error_unknown_command = unknown command {0} +studio/exported = Layer exported as mod "{0}": add it to the game collection +studio/log_end = end +studio/log_text_ended = {0} ended +studio/log_text_exported = layer exported as mod {0} +studio/log_text_failed = {0}: could not be created +studio/log_text_paused = paused, line {0} +studio/log_text_scenario_created = new scenario {0} for scene {1} +studio/log_text_scene_created = new scene {0} (scenario {1}) +studio/log_text_shadowed = {0}: shadowed ({1}) +studio/log_text_state = {0} = {1} +studio/map_export = Export layer as mod +studio/new_scenario = + New scenario for the scene +studio/no_scenario = No scenario has run yet. Enter a scene or start one on the Scene tab. +studio/prompt_edit_title = New text +studio/prompt_export_title = Id of the new mod +studio/prompt_scene_title = Name of the new scene +studio/reason_failed = event could not be created, see the SCENE log +studio/reason_queued = queued with priority {0} +studio/reason_running = running now +studio/reason_shadowed = shadowed by a same-named scenario of another mod or the layer +studio/run_failed = Could not start: the dialog box is busy +studio/scenario_created = Created scenario "{0}" in the layer: re-enter the scene or start it +studio/scene_created = Created scene "{0}" in the layer +studio/state_held = stop +studio/state_idle = no dialog +studio/step_needs_answer = Pick an answer in the dialog first +studio/target_end = End of scenario +studio/target_labels = Labels +studio/target_scene = → scene {0} +studio/target_scenes = Scenes +studio/via_layer = new layer scene +studio/via_scenario = from {0} +studio/map_here = You are here +studio/map_no_exits = No scenario leads anywhere from this scene yet: "+ Link" adds an exit. +studio/checkpoint = Checkpoint +studio/checkpoint_save = Remember the state +studio/checkpoint_restore = Back to the checkpoint +studio/checkpoint_saved = State remembered +studio/checkpoint_restored = Back at the checkpoint +studio/checkpoint_none = No checkpoint saved +studio/log_text_checkpoint_saved = checkpoint in {0} +studio/log_text_checkpoint_restored = back to the checkpoint in {0} +studio/drag_next_line = Drag to another line to run it next +studio/next_line_failed = The dialog cannot continue from this line +studio/log_text_next_line = next line moved to {0} diff --git a/locale/ru.properties b/locale/ru.properties new file mode 100644 index 0000000..b7efa1b --- /dev/null +++ b/locale/ru.properties @@ -0,0 +1,158 @@ +mod_scenario_studio_name = Студия сценариев +mod_scenario_studio_desc = Отладка и правка сценариев прямо во время игры: пауза, шаг, перезапуск, текущая строка, причины запуска сценариев, состояние игры, журнал и карта сцен. Режимы «Автор» и «Новичок». +studio/title = Студия +studio/mode_author = Автор +studio/mode_novice = Новичок +studio/close = Закрыть студию +studio/state_running = идёт +studio/state_paused = пауза +studio/pause = ‖ Пауза +studio/step = ▶| Шаг +studio/continue = ▶ Дальше +studio/restart = ⟲ Перезапуск ▾ +studio/restart_scenario = Сценарий заново (без отката) +studio/restart_rollback = Сценарий заново с откатом состояния +studio/restart_scene = Сцену заново +studio/restart_from_label = Продолжить с текущей метки +studio/crumb_scene = сцена +studio/crumb_scenario = сценарий +studio/tab_now = Сейчас +studio/tab_scene = Сцена +studio/tab_state = Состояние +studio/tab_log = Журнал +studio/tab_map = Карта +studio/edits_go_to = правки уйдут в слой «{0}» +studio/after_line = после строки {0} +studio/add_line = + Реплика +studio/add_choice = + Выбор +studio/add_jump = + Переход в сцену +studio/new_line_text = Новая реплика +studio/new_choice_text = Новый выбор +studio/affects = Изменение затронет +studio/overrides = перекрывает +studio/pending = Не применено изменений: {0} +studio/no_pending = Изменений нет +studio/edit_text = Править текст +studio/apply = Применить ⟲ +studio/scope_own = папка +studio/scope_overlay = zip +studio/scope_layer = слой +studio/scope_readonly = код +studio/cand_running = идёт +studio/cand_skipped = пропущен +studio/cand_completed = пройден +studio/cand_shadowed = перекрыт +studio/cand_hint = Сценарии этой сцены по порядку запуска: почему каждый запустился или нет. +studio/run_now = Запустить +studio/reset_completed = Сбросить «пройден» +studio/log_scene = сцена +studio/log_skip = пропуск +studio/log_scenario = сценарий +studio/log_stop = стоп +studio/log_edit = правка +studio/state_hint = Значения можно менять, чтобы проверить другую ветку. +studio/map_from = Откуда пришли +studio/map_to = Переходы отсюда +studio/map_go = Перейти +studio/map_new_scene = + Новая сцена +studio/map_link = + Связать +studio/node_here = вы здесь +studio/node_visited = пройдено +studio/node_next = переход +studio/node_new = новая +studio/priority = приоритет {0} +studio/no_condition = без условия +studio/one_run = одноразовый +studio/error_label_missing = метка {0} не найдена +studio/reason_completed = одноразовый, уже пройден +studio/group_time = Время +studio/group_quests = Квесты +studio/group_inventory = Инвентарь +studio/group_dialog_vars = Переменные диалога +studio/group_completed = Пройденные сценарии +studio/state_day = день +studio/state_hour = час +studio/value_yes = да +studio/log_text_enter = вход в {0} +studio/log_text_condition = {0}: условие ложно +studio/log_text_completed = {0}: уже пройден +studio/log_text_started = {0} начался +studio/log_text_breakpoint = точка остановки, строка {0} +studio/log_text_applied = {0}: +{1} → {2} +studio/quote = «{0}» +studio/say_question = Вопрос +studio/say_line = Реплика +studio/say_new = {0} · новое +studio/moment_now = Сейчас на экране +studio/moment_answers = Варианты ответа +studio/moment_next = Дальше в сцене +studio/moment_empty = Сейчас на экране ничего нет: сценарий не идёт или стоит на служебной строке. +studio/outcome_scene = переход в сцену +studio/outcome_action = действие +studio/outcome_end = конец сценария +studio/outcome_continue = диалог продолжается +studio/outcome_broken = никуда не ведёт +studio/edit = Изменить +studio/choose_target = Выбрать, куда ▾ +studio/add_line_before = + Реплика до вопроса +studio/add_line_after = + Реплика после +studio/add_option = + Вариант ответа +studio/new_option_text = Новый вариант +studio/next_waiting = {0} — ждёт своей очереди +studio/next_none = Больше в этой сцене ничего не запланировано +studio/reason_condition_false = условие в заголовке ложно +studio/applied_to = Применено в «{0}», сценарий перезапущен с откатом +studio/bad_name = Имя: латиница в нижнем регистре, цифры и _, начинается с буквы +studio/ok = Готово +studio/cancel = Отмена +studio/cand_failed = ошибка +studio/cand_none = Сцена ещё не собиралась при открытой студии: войдите в неё заново. +studio/cand_queued = в очереди +studio/edit_done = Готово +studio/error_no_target = вариант никуда не ведёт +studio/error_unknown_command = неизвестная команда {0} +studio/exported = Слой выгружен в мод «{0}»: подключите его в коллекции игры +studio/log_end = конец +studio/log_text_ended = {0} закончился +studio/log_text_exported = слой выгружен в мод {0} +studio/log_text_failed = {0}: не удалось создать +studio/log_text_paused = пауза, строка {0} +studio/log_text_scenario_created = новый сценарий {0} для сцены {1} +studio/log_text_scene_created = новая сцена {0} (сценарий {1}) +studio/log_text_shadowed = {0}: перекрыт ({1}) +studio/log_text_state = {0} = {1} +studio/map_export = Выгрузить слой в мод +studio/new_scenario = + Новый сценарий для сцены +studio/no_scenario = Сценарий ещё не запускался. Войдите в сцену или запустите сценарий на вкладке «Сцена». +studio/prompt_edit_title = Новый текст +studio/prompt_export_title = Идентификатор нового мода +studio/prompt_scene_title = Имя новой сцены +studio/reason_failed = не удалось создать событие, см. лог SCENE +studio/reason_queued = в очереди с приоритетом {0} +studio/reason_running = идёт сейчас +studio/reason_shadowed = перекрыт одноимённым сценарием из другого мода или слоя +studio/run_failed = Не удалось запустить: окно диалога занято +studio/scenario_created = Создан сценарий «{0}» в слое: войдите в сцену заново или запустите его +studio/scene_created = Создана сцена «{0}» в слое +studio/state_held = стоп +studio/state_idle = нет диалога +studio/step_needs_answer = Сначала выберите ответ в диалоге +studio/target_end = Конец сценария +studio/target_labels = Метки +studio/target_scene = → сцена {0} +studio/target_scenes = Сцены +studio/via_layer = новая сцена слоя +studio/via_scenario = из {0} +studio/map_here = Вы здесь +studio/map_no_exits = Из этой сцены пока никуда не ведёт ни один сценарий: «+ Связать» добавит переход. +studio/checkpoint = Контрольная точка +studio/checkpoint_save = Запомнить состояние +studio/checkpoint_restore = Вернуться к контрольной точке +studio/checkpoint_saved = Состояние запомнено +studio/checkpoint_restored = Возврат к контрольной точке +studio/checkpoint_none = Контрольная точка не сохранена +studio/log_text_checkpoint_saved = контрольная точка в {0} +studio/log_text_checkpoint_restored = возврат к контрольной точке в {0} +studio/drag_next_line = Перетащите на другую строку, чтобы она выполнилась следующей +studio/next_line_failed = С этой строки диалог продолжить не может +studio/log_text_next_line = следующая строка перенесена на {0} diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..6c2cb4b --- /dev/null +++ b/manifest.json @@ -0,0 +1,25 @@ +{ + "id": "scenario_studio", + "name": "Scenario Studio", + "version": "0.1.0", + "author": "ct", + "description": "In-game scenario debugger and live editor: a non-modal dock over the running game with pause, step, breakpoints and restarts (with state rollback), the current scenario line, scene candidates with the reason they did or did not start, editable game state, an event log and the scene map. Edits go into folder mods or into a per-game studio layer over read-only mods, which can be exported as a mod. Author mode shows the script, novice mode the story moment.", + "category": "tools, editor", + "sources": [ + "src/**/*.cs" + ], + "depends": ["ui_base>=1", "dialog_base>=1"], + "ui": [ + { + "id": "studio", + "kind": "panel", + "slot": "ModBeforeUIPanel", + "scene_path": "scenes/studio_panel.tscn", + "button": { "icon": "studio", "locale": "studio/title", "index": 90 }, + "priority": 30 + } + ], + "test_suites": [ + {"id": "live", "activate": ["--itest=studio_live"]} + ] +} diff --git a/scenes/studio_panel.tscn b/scenes/studio_panel.tscn new file mode 100644 index 0000000..d4cc476 --- /dev/null +++ b/scenes/studio_panel.tscn @@ -0,0 +1,327 @@ +[gd_scene format=3] + +[ext_resource type="Script" path="res://mods/scenario_studio/src/StudioPanel.cs" id="1_script"] + +[node name="StudioPanel" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +script = ExtResource("1_script") + +[node name="Frame" type="Control" parent="."] +layout_mode = 1 +anchors_preset = 11 +anchor_left = 1.0 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = -488.0 +offset_top = 84.0 +offset_right = -8.0 +offset_bottom = -8.0 +grow_horizontal = 0 +grow_vertical = 2 +mouse_filter = 2 + +[node name="Dock" type="PanelContainer" parent="Frame"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="Column" type="VBoxContainer" parent="Frame/Dock"] +layout_mode = 2 +theme_override_constants/separation = 0 + +[node name="Header" type="MarginContainer" parent="Frame/Dock/Column"] +layout_mode = 2 +mouse_filter = 0 +mouse_default_cursor_shape = 13 +theme_override_constants/margin_left = 16 +theme_override_constants/margin_top = 10 +theme_override_constants/margin_right = 12 +theme_override_constants/margin_bottom = 10 + +[node name="Row" type="HBoxContainer" parent="Frame/Dock/Column/Header"] +layout_mode = 2 +mouse_filter = 2 +theme_override_constants/separation = 10 + +[node name="Title" type="Label" parent="Frame/Dock/Column/Header/Row"] +layout_mode = 2 + +[node name="RunState" type="PanelContainer" parent="Frame/Dock/Column/Header/Row"] +layout_mode = 2 +size_flags_vertical = 4 +mouse_filter = 2 + +[node name="Text" type="Label" parent="Frame/Dock/Column/Header/Row/RunState"] +layout_mode = 2 + +[node name="Spacer" type="Control" parent="Frame/Dock/Column/Header/Row"] +layout_mode = 2 +size_flags_horizontal = 3 +mouse_filter = 2 + +[node name="ModeSwitch" type="PanelContainer" parent="Frame/Dock/Column/Header/Row"] +layout_mode = 2 +size_flags_vertical = 4 + +[node name="Items" type="HBoxContainer" parent="Frame/Dock/Column/Header/Row/ModeSwitch"] +layout_mode = 2 +theme_override_constants/separation = 2 + +[node name="AuthorButton" type="Button" parent="Frame/Dock/Column/Header/Row/ModeSwitch/Items"] +layout_mode = 2 +focus_mode = 0 +toggle_mode = true + +[node name="NoviceButton" type="Button" parent="Frame/Dock/Column/Header/Row/ModeSwitch/Items"] +layout_mode = 2 +focus_mode = 0 +toggle_mode = true + +[node name="CloseButton" type="Button" parent="Frame/Dock/Column/Header/Row"] +custom_minimum_size = Vector2(32, 32) +layout_mode = 2 +focus_mode = 0 +text = "✕" + +[node name="RuleHeader" type="ColorRect" parent="Frame/Dock/Column"] +custom_minimum_size = Vector2(0, 1) +layout_mode = 2 +mouse_filter = 2 + +[node name="Transport" type="MarginContainer" parent="Frame/Dock/Column"] +layout_mode = 2 +theme_override_constants/margin_left = 16 +theme_override_constants/margin_top = 10 +theme_override_constants/margin_right = 16 +theme_override_constants/margin_bottom = 10 + +[node name="Row" type="HBoxContainer" parent="Frame/Dock/Column/Transport"] +layout_mode = 2 +theme_override_constants/separation = 6 + +[node name="PauseButton" type="Button" parent="Frame/Dock/Column/Transport/Row"] +layout_mode = 2 +focus_mode = 0 +toggle_mode = true + +[node name="StepButton" type="Button" parent="Frame/Dock/Column/Transport/Row"] +layout_mode = 2 +focus_mode = 0 + +[node name="ContinueButton" type="Button" parent="Frame/Dock/Column/Transport/Row"] +layout_mode = 2 +focus_mode = 0 + +[node name="Spacer" type="Control" parent="Frame/Dock/Column/Transport/Row"] +layout_mode = 2 +size_flags_horizontal = 3 +mouse_filter = 2 + +[node name="RestartButton" type="MenuButton" parent="Frame/Dock/Column/Transport/Row"] +layout_mode = 2 +focus_mode = 0 + +[node name="RuleTransport" type="ColorRect" parent="Frame/Dock/Column"] +custom_minimum_size = Vector2(0, 1) +layout_mode = 2 +mouse_filter = 2 + +[node name="Context" type="MarginContainer" parent="Frame/Dock/Column"] +layout_mode = 2 +theme_override_constants/margin_left = 16 +theme_override_constants/margin_top = 10 +theme_override_constants/margin_right = 16 +theme_override_constants/margin_bottom = 10 + +[node name="Column" type="VBoxContainer" parent="Frame/Dock/Column/Context"] +layout_mode = 2 +theme_override_constants/separation = 8 + +[node name="Crumbs" type="HFlowContainer" parent="Frame/Dock/Column/Context/Column"] +layout_mode = 2 +theme_override_constants/h_separation = 6 + +[node name="Scope" type="HFlowContainer" parent="Frame/Dock/Column/Context/Column"] +layout_mode = 2 +theme_override_constants/h_separation = 8 + +[node name="RuleContext" type="ColorRect" parent="Frame/Dock/Column"] +custom_minimum_size = Vector2(0, 1) +layout_mode = 2 +mouse_filter = 2 + +[node name="Tabs" type="MarginContainer" parent="Frame/Dock/Column"] +layout_mode = 2 +theme_override_constants/margin_left = 16 +theme_override_constants/margin_top = 6 +theme_override_constants/margin_right = 16 +theme_override_constants/margin_bottom = 6 + +[node name="Row" type="HBoxContainer" parent="Frame/Dock/Column/Tabs"] +layout_mode = 2 +theme_override_constants/separation = 4 + +[node name="NowTab" type="Button" parent="Frame/Dock/Column/Tabs/Row"] +layout_mode = 2 +focus_mode = 0 +toggle_mode = true + +[node name="SceneTab" type="Button" parent="Frame/Dock/Column/Tabs/Row"] +layout_mode = 2 +focus_mode = 0 +toggle_mode = true + +[node name="StateTab" type="Button" parent="Frame/Dock/Column/Tabs/Row"] +layout_mode = 2 +focus_mode = 0 +toggle_mode = true + +[node name="LogTab" type="Button" parent="Frame/Dock/Column/Tabs/Row"] +layout_mode = 2 +focus_mode = 0 +toggle_mode = true + +[node name="MapTab" type="Button" parent="Frame/Dock/Column/Tabs/Row"] +layout_mode = 2 +focus_mode = 0 +toggle_mode = true + +[node name="RuleTabs" type="ColorRect" parent="Frame/Dock/Column"] +custom_minimum_size = Vector2(0, 1) +layout_mode = 2 +mouse_filter = 2 + +[node name="Body" type="MarginContainer" parent="Frame/Dock/Column"] +layout_mode = 2 +size_flags_vertical = 3 +theme_override_constants/margin_left = 16 +theme_override_constants/margin_top = 12 +theme_override_constants/margin_right = 10 +theme_override_constants/margin_bottom = 12 + +[node name="Scroll" type="ScrollContainer" parent="Frame/Dock/Column/Body"] +layout_mode = 2 +horizontal_scroll_mode = 0 + +[node name="Content" type="VBoxContainer" parent="Frame/Dock/Column/Body/Scroll"] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 10 + +[node name="RuleFooter" type="ColorRect" parent="Frame/Dock/Column"] +custom_minimum_size = Vector2(0, 1) +layout_mode = 2 +mouse_filter = 2 + +[node name="Footer" type="MarginContainer" parent="Frame/Dock/Column"] +layout_mode = 2 +theme_override_constants/margin_left = 16 +theme_override_constants/margin_top = 10 +theme_override_constants/margin_right = 16 +theme_override_constants/margin_bottom = 10 + +[node name="Row" type="HBoxContainer" parent="Frame/Dock/Column/Footer"] +layout_mode = 2 +theme_override_constants/separation = 8 + +[node name="Status" type="Label" parent="Frame/Dock/Column/Footer/Row"] +layout_mode = 2 +size_flags_horizontal = 3 +clip_text = true + +[node name="EditButton" type="Button" parent="Frame/Dock/Column/Footer/Row"] +layout_mode = 2 +focus_mode = 0 + +[node name="ApplyButton" type="Button" parent="Frame/Dock/Column/Footer/Row"] +layout_mode = 2 +focus_mode = 0 + +[node name="LeftGrip" type="Control" parent="Frame"] +layout_mode = 1 +anchor_bottom = 1.0 +offset_left = -3.0 +offset_top = 8.0 +offset_right = 3.0 +offset_bottom = -8.0 +mouse_default_cursor_shape = 10 + +[node name="RightGrip" type="Control" parent="Frame"] +layout_mode = 1 +anchor_left = 1.0 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = -3.0 +offset_top = 8.0 +offset_right = 3.0 +offset_bottom = -8.0 +mouse_default_cursor_shape = 10 + +[node name="TopGrip" type="Control" parent="Frame"] +layout_mode = 1 +anchor_right = 1.0 +offset_left = 8.0 +offset_top = -3.0 +offset_right = -8.0 +offset_bottom = 3.0 +mouse_default_cursor_shape = 9 + +[node name="BottomGrip" type="Control" parent="Frame"] +layout_mode = 1 +anchor_top = 1.0 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = 8.0 +offset_top = -3.0 +offset_right = -8.0 +offset_bottom = 3.0 +mouse_default_cursor_shape = 9 + +[node name="TopLeftGrip" type="Control" parent="Frame"] +layout_mode = 1 +offset_left = -3.0 +offset_top = -3.0 +offset_right = 8.0 +offset_bottom = 8.0 +mouse_default_cursor_shape = 12 + +[node name="TopRightGrip" type="Control" parent="Frame"] +layout_mode = 1 +anchor_left = 1.0 +anchor_right = 1.0 +offset_left = -8.0 +offset_top = -3.0 +offset_right = 3.0 +offset_bottom = 8.0 +mouse_default_cursor_shape = 11 + +[node name="BottomLeftGrip" type="Control" parent="Frame"] +layout_mode = 1 +anchor_top = 1.0 +anchor_bottom = 1.0 +offset_left = -3.0 +offset_top = -8.0 +offset_right = 8.0 +offset_bottom = 3.0 +mouse_default_cursor_shape = 11 + +[node name="BottomRightGrip" type="Control" parent="Frame"] +layout_mode = 1 +anchor_left = 1.0 +anchor_top = 1.0 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = -8.0 +offset_top = -8.0 +offset_right = 3.0 +offset_bottom = 3.0 +mouse_default_cursor_shape = 12 diff --git a/src/Model/StudioBranch.cs b/src/Model/StudioBranch.cs new file mode 100644 index 0000000..a024ce8 --- /dev/null +++ b/src/Model/StudioBranch.cs @@ -0,0 +1,10 @@ +namespace Cthangover.Mods.ScenarioStudio +{ + /// An answer of the current question with its resolved outcome (: scene or action id). + public sealed class StudioBranch + { + public StudioCodeLine Line { get; set; } + public StudioOutcomeKind Kind { get; set; } + public string Target { get; set; } + } +} diff --git a/src/Model/StudioCandidate.cs b/src/Model/StudioCandidate.cs new file mode 100644 index 0000000..a4a884a --- /dev/null +++ b/src/Model/StudioCandidate.cs @@ -0,0 +1,21 @@ +namespace Cthangover.Mods.ScenarioStudio +{ + /// + /// A scenario that targets the current scene, as the scene composition saw + /// it: owning mod, priority, header condition (DSL, null when there is none) and its result (null when + /// it was not evaluated), and the final + /// status. spells out a skip or a shadowing (another + /// mod won the same scenario name) in words. + /// + public sealed class StudioCandidate + { + public string Name { get; set; } + public StudioModScope Scope { get; set; } + public int Priority { get; set; } + public string Condition { get; set; } + public bool? ConditionOk { get; set; } + public bool IsOneRun { get; set; } + public StudioCandidateStatus Status { get; set; } + public StudioText Reason { get; set; } + } +} diff --git a/src/Model/StudioCandidateStatus.cs b/src/Model/StudioCandidateStatus.cs new file mode 100644 index 0000000..0009bc6 --- /dev/null +++ b/src/Model/StudioCandidateStatus.cs @@ -0,0 +1,13 @@ +namespace Cthangover.Mods.ScenarioStudio +{ + /// Outcome of a scene candidate as the dock shows it, the answer to "why did (not) it start". + public enum StudioCandidateStatus + { + Running, + Queued, + Skipped, + Completed, + Shadowed, + Failed, + } +} diff --git a/src/Model/StudioCodeLine.cs b/src/Model/StudioCodeLine.cs new file mode 100644 index 0000000..5d78b31 --- /dev/null +++ b/src/Model/StudioCodeLine.cs @@ -0,0 +1,32 @@ +namespace Cthangover.Mods.ScenarioStudio +{ + /// + /// One line of the scenario shown in the "Now" tab. is the + /// DSL source for the author mode; the novice mode reads the structure + /// instead: is what the player sees (a line, a question, + /// an answer) and where the line points — the label of + /// an option or goto, the name of a label, the scene of a switch, the id of + /// an action. follows those links. The flags + /// mirror what the debugger reports: lines already executed, the + /// line the dialog stopped on, the lines it may be moved to, breakpoints, lines added but not yet applied + /// and a parse error message. + /// + public sealed class StudioCodeLine + { + /// 1-based line number in the scenario text (header included), as in SourceLine of actions. + public int Number { get; set; } + /// Number of this line in the text the running dialog was built from; 0 for a line added in the studio. + public int SourceLine { get; set; } + public string Text { get; set; } + public StudioLineKind Kind { get; set; } + public StudioText Say { get; set; } + public string Target { get; set; } + public bool IsDone { get; set; } + public bool IsCurrent { get; set; } + public bool HasBreakpoint { get; set; } + /// The running dialog has an action of this line, so the ▶ marker may be dragged here. + public bool CanRunNext { get; set; } + public bool IsNew { get; set; } + public StudioText Error { get; set; } + } +} diff --git a/src/Model/StudioLineKind.cs b/src/Model/StudioLineKind.cs new file mode 100644 index 0000000..bae4589 --- /dev/null +++ b/src/Model/StudioLineKind.cs @@ -0,0 +1,24 @@ +namespace Cthangover.Mods.ScenarioStudio +{ + /// + /// What a scenario line does, as classifies it. + /// The novice view only shows , and + /// ; the rest is service lines it follows or hides. + /// + public enum StudioLineKind + { + Header, + Comment, + Light, + Background, + Line, + Choice, + Option, + Label, + Goto, + Jump, + Action, + End, + Command, + } +} diff --git a/src/Model/StudioLogCategory.cs b/src/Model/StudioLogCategory.cs new file mode 100644 index 0000000..8c1c30a --- /dev/null +++ b/src/Model/StudioLogCategory.cs @@ -0,0 +1,13 @@ +namespace Cthangover.Mods.ScenarioStudio +{ + /// Category of a studio log entry, shown as its badge. + public enum StudioLogCategory + { + Scene, + Skip, + Scenario, + Stop, + End, + Edit, + } +} diff --git a/src/Model/StudioLogEntry.cs b/src/Model/StudioLogEntry.cs new file mode 100644 index 0000000..578cb5b --- /dev/null +++ b/src/Model/StudioLogEntry.cs @@ -0,0 +1,11 @@ +namespace Cthangover.Mods.ScenarioStudio +{ + /// One row of the studio event log; is game-session time as text. + public sealed class StudioLogEntry + { + public string Time { get; set; } + public StudioLogCategory Category { get; set; } + public StudioText Text { get; set; } + public bool IsWarning { get; set; } + } +} diff --git a/src/Model/StudioModScope.cs b/src/Model/StudioModScope.cs new file mode 100644 index 0000000..ff3f26c --- /dev/null +++ b/src/Model/StudioModScope.cs @@ -0,0 +1,15 @@ +namespace Cthangover.Mods.ScenarioStudio +{ + /// A mod together with the way the studio may touch it; rendered as a scope badge. + public sealed class StudioModScope + { + public string ModId { get; } + public StudioScopeKind Kind { get; } + + public StudioModScope(string modId, StudioScopeKind kind) + { + ModId = modId; + Kind = kind; + } + } +} diff --git a/src/Model/StudioMode.cs b/src/Model/StudioMode.cs new file mode 100644 index 0000000..e1807a1 --- /dev/null +++ b/src/Model/StudioMode.cs @@ -0,0 +1,14 @@ +namespace Cthangover.Mods.ScenarioStudio +{ + /// + /// Presentation mode of the studio dock. Both modes show the same session; + /// only the "Now" tab renders it differently: as script + /// text with a gutter, as one card per line with plain + /// wording, so a player who never saw the DSL can still follow and extend it. + /// + public enum StudioMode + { + Author, + Novice, + } +} diff --git a/src/Model/StudioMoment.cs b/src/Model/StudioMoment.cs new file mode 100644 index 0000000..0795114 --- /dev/null +++ b/src/Model/StudioMoment.cs @@ -0,0 +1,120 @@ +namespace Cthangover.Mods.ScenarioStudio +{ + using System.Collections.Generic; + + /// + /// What the novice mode shows instead of a listing: the line on screen now + /// (), the lines said right before it, and — for a + /// question — every answer with the place it leads to. Service lines + /// (lights, labels, end) never appear; an answer's + /// -> :label is followed to the first meaningful line after the + /// label (through goto hops), so option → :exit → switch_scene + /// reads as "goes to the scene". An answer without a target or with a + /// missing label is . When no dialog + /// runs, the moment is the scenario's first line or question. + /// + public sealed class StudioMoment + { + public StudioCodeLine Say { get; private set; } + public List Before { get; } = new(); + public List Branches { get; } = new(); + + public bool IsQuestion => Say != null && Say.Kind == StudioLineKind.Choice; + + public static StudioMoment Build(StudioSession session) + { + var moment = new StudioMoment(); + var lines = session.Lines; + var index = FindAnchor(lines, session.CurrentIndex); + if (index < 0) + return moment; + + moment.Say = lines[index]; + + for (var i = index - 1; i >= 0 && lines[i].Kind == StudioLineKind.Line; i--) + moment.Before.Insert(0, lines[i]); + + if (!moment.IsQuestion) + return moment; + + for (var next = index + 1; next < lines.Count && lines[next].Kind == StudioLineKind.Option; next++) + moment.Branches.Add(Resolve(lines, lines[next])); + return moment; + } + + private static int FindAnchor(List lines, int current) + { + if (current < 0) + current = 0; + if (lines.Count == 0) + return -1; + + if (lines[current].Kind == StudioLineKind.Option) + { + for (var i = current; i >= 0; i--) + { + if (lines[i].Kind == StudioLineKind.Choice) + return i; + } + return -1; + } + + for (var i = current; i < lines.Count; i++) + { + if (lines[i].Kind == StudioLineKind.Line || lines[i].Kind == StudioLineKind.Choice) + return i; + } + return -1; + } + + private static StudioBranch Resolve(List lines, StudioCodeLine option) + { + var branch = new StudioBranch { Line = option, Kind = StudioOutcomeKind.Broken }; + var label = string.IsNullOrEmpty(option.Target) ? -1 : FindLabel(lines, option.Target); + if (label < 0) + return branch; + + var hops = 0; + for (var i = label + 1; i < lines.Count; i++) + { + var line = lines[i]; + switch (line.Kind) + { + case StudioLineKind.Goto: + i = string.IsNullOrEmpty(line.Target) ? -1 : FindLabel(lines, line.Target); + if (i < 0 || ++hops > 8) + return branch; + continue; + case StudioLineKind.Jump: + branch.Kind = StudioOutcomeKind.Scene; + branch.Target = line.Target; + return branch; + case StudioLineKind.Action: + branch.Kind = StudioOutcomeKind.Action; + branch.Target = line.Target; + return branch; + case StudioLineKind.End: + branch.Kind = StudioOutcomeKind.End; + return branch; + case StudioLineKind.Line: + case StudioLineKind.Choice: + branch.Kind = StudioOutcomeKind.Continue; + return branch; + } + } + + branch.Kind = StudioOutcomeKind.End; + return branch; + } + + private static int FindLabel(List lines, string name) + { + for (var i = 0; i < lines.Count; i++) + { + if (lines[i].Kind == StudioLineKind.Label && lines[i].Target == name) + return i; + } + return -1; + } + } +} diff --git a/src/Model/StudioNodeState.cs b/src/Model/StudioNodeState.cs new file mode 100644 index 0000000..3869c66 --- /dev/null +++ b/src/Model/StudioNodeState.cs @@ -0,0 +1,11 @@ +namespace Cthangover.Mods.ScenarioStudio +{ + /// Role of a scene on the studio map relative to where the player is. + public enum StudioNodeState + { + Here, + Visited, + Next, + New, + } +} diff --git a/src/Model/StudioOutcomeKind.cs b/src/Model/StudioOutcomeKind.cs new file mode 100644 index 0000000..30e8d82 --- /dev/null +++ b/src/Model/StudioOutcomeKind.cs @@ -0,0 +1,12 @@ +namespace Cthangover.Mods.ScenarioStudio +{ + /// Where an answer leads once its label jump is followed, as shown to a novice. + public enum StudioOutcomeKind + { + Scene, + Action, + End, + Continue, + Broken, + } +} diff --git a/src/Model/StudioRestartMode.cs b/src/Model/StudioRestartMode.cs new file mode 100644 index 0000000..b749822 --- /dev/null +++ b/src/Model/StudioRestartMode.cs @@ -0,0 +1,15 @@ +namespace Cthangover.Mods.ScenarioStudio +{ + /// The ways the dock can restart what it is debugging (the restart menu, in menu order). + public enum StudioRestartMode + { + /// The scenario again from its first line; game state stays as it is now. + Scenario, + /// Game state rolled back to the scenario's start, then the scenario again. + Rollback, + /// The whole scene re-entered: its composition runs again. + Scene, + /// The scenario again from the label above the current line. + FromLabel, + } +} diff --git a/src/Model/StudioRunState.cs b/src/Model/StudioRunState.cs new file mode 100644 index 0000000..07e3d65 --- /dev/null +++ b/src/Model/StudioRunState.cs @@ -0,0 +1,15 @@ +namespace Cthangover.Mods.ScenarioStudio +{ + /// State of the dialog runtime as the dock header shows it. + public enum StudioRunState + { + /// A scenario runs freely. + Running, + /// Pause is on: the next action will stop before it runs. + Paused, + /// Stopped before an action (pause or breakpoint) until continue/step. + Held, + /// No scenario dialog is running. + Idle, + } +} diff --git a/src/Model/StudioSceneNode.cs b/src/Model/StudioSceneNode.cs new file mode 100644 index 0000000..836cf12 --- /dev/null +++ b/src/Model/StudioSceneNode.cs @@ -0,0 +1,11 @@ +namespace Cthangover.Mods.ScenarioStudio +{ + /// A scene on the studio map with the mod that defines it; names the link (a command or a choice). + public sealed class StudioSceneNode + { + public string Name { get; set; } + public StudioNodeState State { get; set; } + public StudioModScope Scope { get; set; } + public StudioText Via { get; set; } + } +} diff --git a/src/Model/StudioScopeKind.cs b/src/Model/StudioScopeKind.cs new file mode 100644 index 0000000..2ac567e --- /dev/null +++ b/src/Model/StudioScopeKind.cs @@ -0,0 +1,20 @@ +namespace Cthangover.Mods.ScenarioStudio +{ + /// + /// How the studio may touch content of a mod, shown as the colour and caption + /// of the mod scope badge. Players mostly run zip mods, which are read-only: + /// edits of their scenarios land in the studio layer that overrides them, + /// and the badge pair "layer overrides zip" tells whose content is affected. + /// + public enum StudioScopeKind + { + /// Folder mod: edits are written into the mod itself. + Own, + /// Zip mod: read-only, edits go to the studio layer over it. + Overlay, + /// The studio layer of the current game that holds the player's edits. + Layer, + /// Compiled code of a mod: shown, never edited from the studio. + ReadOnly, + } +} diff --git a/src/Model/StudioSession.cs b/src/Model/StudioSession.cs new file mode 100644 index 0000000..afddb56 --- /dev/null +++ b/src/Model/StudioSession.cs @@ -0,0 +1,66 @@ +namespace Cthangover.Mods.ScenarioStudio +{ + using System.Collections.Generic; + + /// + /// Everything the dock shows at one moment, built by + /// from the live game: where the player is + /// (scene, scenario, label), the run state, which mod owns the scenario and + /// where edits go, the scenario lines (with pending edits) and debugger + /// marks, the scene candidates, game state, the log and the scene map. A + /// plain view model: pages render it and never change the game through it. + /// + public sealed class StudioSession + { + public string SceneName { get; set; } + public string ScenarioName { get; set; } + + /// Label the current line belongs to; null before the first label. + public string LabelName { get; set; } + + public StudioRunState RunState { get; set; } + + /// Mod (or layer) that owns the open scenario file; null without a scenario. + public StudioModScope FileScope { get; set; } + + /// Where edits are written: the file's own folder mod, or the studio layer. + public StudioModScope EditScope { get; set; } + + /// Unapplied edits of the open scenario. + public int PendingCount { get; set; } + + public List Lines { get; } = new(); + public List Candidates { get; } = new(); + public List State { get; } = new(); + public List Log { get; } = new(); + public List Nodes { get; } = new(); + + public bool HasScenario => !string.IsNullOrEmpty(ScenarioName); + + /// Index of the line the dialog stands on, or -1. + public int CurrentIndex => Lines.FindIndex(IsCurrentLine); + + /// + /// Line index (into the scenario text, 0-based) where "insert after the + /// current line" puts a line: right after it, or after the whole answer + /// list of a question — a line between select and its options would + /// cut the question short in the parser. Without a current line: the end. + /// + public int InsertionTextIndexAfterCurrent() + { + var index = CurrentIndex; + if (index < 0) + return Lines.Count == 0 ? 0 : Lines[^1].Number; + + var at = index; + if (Lines[index].Kind == StudioLineKind.Choice) + { + while (at + 1 < Lines.Count && Lines[at + 1].Kind == StudioLineKind.Option) + at++; + } + return Lines[at].Number; + } + + private static bool IsCurrentLine(StudioCodeLine line) => line.IsCurrent; + } +} diff --git a/src/Model/StudioStateEditor.cs b/src/Model/StudioStateEditor.cs new file mode 100644 index 0000000..8a86486 --- /dev/null +++ b/src/Model/StudioStateEditor.cs @@ -0,0 +1,11 @@ +namespace Cthangover.Mods.ScenarioStudio +{ + /// How a value of the "State" tab can be changed in place; is read-only. + public enum StudioStateEditor + { + None, + DialogVariable, + Inventory, + Quest, + } +} diff --git a/src/Model/StudioStateEntry.cs b/src/Model/StudioStateEntry.cs new file mode 100644 index 0000000..c6bc0ee --- /dev/null +++ b/src/Model/StudioStateEntry.cs @@ -0,0 +1,17 @@ +namespace Cthangover.Mods.ScenarioStudio +{ + /// + /// A key/value of game state in the "State" tab, grouped by its owner (time, + /// quests, inventory…). Groups compare by . + /// is what writes to (a variable name, + /// item id, quest id). + /// + public sealed class StudioStateEntry + { + public StudioText Group { get; set; } + public StudioText Key { get; set; } + public StudioText Value { get; set; } + public StudioStateEditor Editor { get; set; } + public string Id { get; set; } + } +} diff --git a/src/Model/StudioTab.cs b/src/Model/StudioTab.cs new file mode 100644 index 0000000..013a9d2 --- /dev/null +++ b/src/Model/StudioTab.cs @@ -0,0 +1,12 @@ +namespace Cthangover.Mods.ScenarioStudio +{ + /// Tabs of the studio dock body, in the order of the tab row. + public enum StudioTab + { + Now, + Scene, + State, + Log, + Map, + } +} diff --git a/src/Model/StudioText.cs b/src/Model/StudioText.cs new file mode 100644 index 0000000..0c3926e --- /dev/null +++ b/src/Model/StudioText.cs @@ -0,0 +1,60 @@ +namespace Cthangover.Mods.ScenarioStudio +{ + using Cthangover.Core.Utils; + using Godot; + + /// + /// Player-facing text of the studio model kept as a localization key plus + /// format arguments and translated only when shown, so the dock follows a + /// language switch and no prose lives in code. Arguments may themselves be + /// (e.g. a scenario's own key= inside a + /// studio phrase) and are resolved recursively; plain arguments are data — + /// scene, scenario, mod and quest ids, numbers. wraps such + /// data where a text is expected. + /// + public sealed class StudioText + { + private readonly string _raw; + + public string Key { get; } + public object[] Args { get; } + + public StudioText(string key, params object[] args) + { + Key = key; + Args = args ?? System.Array.Empty(); + } + + private StudioText(string raw, bool _) + { + _raw = raw; + Args = System.Array.Empty(); + } + + /// Untranslated data (an id or a number) used where a text is expected. + public static StudioText Raw(string value) => new(value, true); + + public string Resolve() + { + if (Key == null) + return _raw ?? string.Empty; + + var format = TranslationServer.Translate(Key).ToString(); + if (Args.Length == 0) + return format; + + var resolved = new object[Args.Length]; + for (var i = 0; i < Args.Length; i++) + resolved[i] = Args[i] is StudioText text ? text.Resolve() : Args[i]; + try + { + return string.Format(format, resolved); + } + catch (System.FormatException) + { + GameLogger.Log("UI", $"Scenario studio: '{Key}' expects other arguments than {Args.Length}", LogLevel.Warning); + return format; + } + } + } +} diff --git a/src/Pages/StudioLogPage.cs b/src/Pages/StudioLogPage.cs new file mode 100644 index 0000000..c26b3b5 --- /dev/null +++ b/src/Pages/StudioLogPage.cs @@ -0,0 +1,62 @@ +namespace Cthangover.Mods.ScenarioStudio +{ + using Cthangover.Core.UI.Theming; + using Cthangover.Mods.Interface.Theming; + using Godot; + + /// + /// "Log" tab: what happened, newest last — scene entries, skipped scenarios + /// with the reason, scenario starts and ends, stops and studio edits. + /// Skips are tinted as warnings because they are the usual answer to + /// "nothing happened". + /// + public static class StudioLogPage + { + private const int TimeWidth = 44; + + public static void Build(VBoxContainer content, StudioSession session) + { + var list = UiKit.Column(0); + content.AddChild(list); + + foreach (var entry in session.Log) + { + var plate = new PanelContainer { MouseFilter = Control.MouseFilterEnum.Ignore }; + plate.AddThemeStyleboxOverride("panel", StudioStyles.LogRow(entry.IsWarning)); + + var row = UiKit.Row(UiTokens.Space3); + plate.AddChild(row); + + var time = StudioStyles.Text(entry.Time, UiTokens.FontSmall, UiTokens.Faint, mono: true); + time.CustomMinimumSize = new Vector2(TimeWidth, 0); + row.AddChild(time); + row.AddChild(UiKit.Badge(TranslationServer.Translate(CategoryKey(entry.Category)), StudioStyles.LogColor(entry.Category))); + + var text = UiKit.Label(entry.Text.Resolve(), UiTokens.FontBodySmall, entry.IsWarning ? UiTokens.Warning : UiTokens.Body); + text.AutowrapMode = TextServer.AutowrapMode.WordSmart; + text.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill; + row.AddChild(text); + list.AddChild(plate); + } + } + + private static string CategoryKey(StudioLogCategory category) + { + switch (category) + { + case StudioLogCategory.Scene: + return "studio/log_scene"; + case StudioLogCategory.Skip: + return "studio/log_skip"; + case StudioLogCategory.Stop: + return "studio/log_stop"; + case StudioLogCategory.End: + return "studio/log_end"; + case StudioLogCategory.Edit: + return "studio/log_edit"; + default: + return "studio/log_scenario"; + } + } + } +} diff --git a/src/Pages/StudioMapPage.cs b/src/Pages/StudioMapPage.cs new file mode 100644 index 0000000..542b54f --- /dev/null +++ b/src/Pages/StudioMapPage.cs @@ -0,0 +1,118 @@ +namespace Cthangover.Mods.ScenarioStudio +{ + using System; + using Cthangover.Core.UI.Theming; + using Cthangover.Mods.Interface.Theming; + using Godot; + + /// + /// "Map" tab: the walk between scenes around the player, laid out as a + /// vertical path because the dock is narrow — where the player came from, + /// where they are, and the exits from here with the command or choice that + /// leads there. Exits can be taken directly (a debugging teleport); new + /// scenes are created in the studio layer, the current scenario can be + /// linked to any scene, and the layer can be exported as a regular mod. + /// + public static class StudioMapPage + { + public static void Build(VBoxContainer content, StudioSession session, Action go, Action newScene, Action link, Action export) + { + var visited = false; + foreach (var node in session.Nodes) + { + if (node.State != StudioNodeState.Visited) + continue; + if (!visited) + content.AddChild(UiKit.SectionTitle(TranslationServer.Translate("studio/map_from"), UiTokens.FontSmall)); + visited = true; + content.AddChild(BuildNode(node, go)); + content.AddChild(Arrow()); + } + + if (!visited) + content.AddChild(UiKit.SectionTitle(TranslationServer.Translate("studio/map_here"), UiTokens.FontSmall)); + foreach (var node in session.Nodes) + { + if (node.State == StudioNodeState.Here) + content.AddChild(BuildNode(node, null)); + } + + content.AddChild(UiKit.SectionTitle(TranslationServer.Translate("studio/map_to"), UiTokens.FontSmall)); + var exits = 0; + foreach (var node in session.Nodes) + { + if (node.State != StudioNodeState.Next && node.State != StudioNodeState.New) + continue; + content.AddChild(BuildNode(node, go)); + exits++; + } + if (exits == 0) + content.AddChild(UiKit.Hint(TranslationServer.Translate("studio/map_no_exits"), UiTokens.FontBodySmall)); + + var actions = new HFlowContainer { MouseFilter = Control.MouseFilterEnum.Ignore }; + actions.AddThemeConstantOverride("h_separation", UiTokens.Space2); + actions.AddThemeConstantOverride("v_separation", UiTokens.Space2); + actions.AddChild(StudioActionButton.Create("studio/map_new_scene", newScene)); + if (session.HasScenario) + actions.AddChild(StudioActionButton.Create("studio/map_link", link)); + actions.AddChild(StudioActionButton.Create("studio/map_export", export)); + content.AddChild(actions); + } + + private static Control BuildNode(StudioSceneNode node, Action go) + { + var card = new PanelContainer { MouseFilter = Control.MouseFilterEnum.Ignore }; + card.AddThemeStyleboxOverride("panel", StudioStyles.Node(node.State)); + + var row = UiKit.Row(UiTokens.Space3); + card.AddChild(row); + + var column = UiKit.Column(UiTokens.Space1); + column.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill; + row.AddChild(column); + + var head = UiKit.Row(UiTokens.Space3); + var nameColor = node.State == StudioNodeState.Here ? UiTokens.AccentBright : UiTokens.Ink; + head.AddChild(StudioStyles.Text(node.Name, UiTokens.FontBodySmall, nameColor, mono: true)); + head.AddChild(StudioStyles.Scope(node.Scope)); + column.AddChild(head); + + var sub = TranslationServer.Translate(StateKey(node.State)); + if (node.Via != null) + sub += " · " + node.Via.Resolve(); + column.AddChild(StudioStyles.Text(sub, UiTokens.FontSmall, UiTokens.Faint)); + + if (node.State != StudioNodeState.Here) + { + var button = StudioActionButton.Create("studio/map_go", go); + button.Payload = node.Name; + button.SizeFlagsVertical = Control.SizeFlags.ShrinkCenter; + row.AddChild(button); + } + + return card; + } + + private static Control Arrow() + { + var arrow = StudioStyles.Text("↓", UiTokens.FontBody, UiTokens.Faint); + arrow.HorizontalAlignment = HorizontalAlignment.Center; + return arrow; + } + + private static string StateKey(StudioNodeState state) + { + switch (state) + { + case StudioNodeState.Here: + return "studio/node_here"; + case StudioNodeState.Visited: + return "studio/node_visited"; + case StudioNodeState.New: + return "studio/node_new"; + default: + return "studio/node_next"; + } + } + } +} diff --git a/src/Pages/StudioNovicePage.cs b/src/Pages/StudioNovicePage.cs new file mode 100644 index 0000000..7f873ba --- /dev/null +++ b/src/Pages/StudioNovicePage.cs @@ -0,0 +1,99 @@ +namespace Cthangover.Mods.ScenarioStudio +{ + using System; + using Cthangover.Core.UI.Theming; + using Cthangover.Mods.Interface.Theming; + using Godot; + + /// + /// "Now" tab in the novice mode: the moment of the story rather than the + /// script () — what is on screen, the lines said + /// right before it, every answer with where it leads, then what else the + /// scene has queued. The only insert actions are the ones that make sense + /// here: a line before the question (or after a plain line) and a new + /// answer, which starts broken until its target is chosen. Requests carry + /// 0-based indices into the scenario text, where the dock inserts. Ends + /// with the same "this change affects" plate as the author mode. + /// + public sealed class StudioNovicePage + { + /// A new line is requested at the given text index. + public event Action LineRequested; + + /// A new answer is requested at the given text index. + public event Action OptionRequested; + + /// "Edit" on a line, question or answer. + public event Action EditRequested; + + /// "Choose where" on an answer. + public event Action TargetRequested; + + private StudioMoment _moment; + + public void Build(VBoxContainer content, StudioSession session) + { + _moment = StudioMoment.Build(session); + if (_moment.Say == null) + { + content.AddChild(UiKit.EmptyState(TranslationServer.Translate(session.HasScenario ? "studio/moment_empty" : "studio/no_scenario"), UiTokens.FontBody)); + return; + } + + content.AddChild(StudioStyles.Caption(TranslationServer.Translate("studio/moment_now"))); + foreach (var line in _moment.Before) + content.AddChild(new StudioSayView(line, true, OnEdit)); + content.AddChild(new StudioSayView(_moment.Say, false, OnEdit)); + + if (_moment.IsQuestion) + { + content.AddChild(StudioStyles.Caption(TranslationServer.Translate("studio/moment_answers"))); + foreach (var branch in _moment.Branches) + content.AddChild(new StudioBranchView(branch, OnEdit, OnTarget)); + } + + var actions = UiKit.Row(UiTokens.Space2); + actions.AddChild(InsertButton(_moment.IsQuestion ? "studio/add_line_before" : "studio/add_line_after", OnLinePressed)); + if (_moment.IsQuestion) + actions.AddChild(InsertButton("studio/add_option", OnOptionPressed)); + content.AddChild(actions); + + content.AddChild(StudioStyles.Caption(TranslationServer.Translate("studio/moment_next"))); + content.AddChild(BuildNext(session)); + content.AddChild(StudioNowPage.BuildScopeBanner(session)); + } + + private static Control BuildNext(StudioSession session) + { + foreach (var candidate in session.Candidates) + { + if (candidate.Status == StudioCandidateStatus.Queued) + return UiKit.Hint(string.Format(TranslationServer.Translate("studio/next_waiting"), candidate.Name), UiTokens.FontBodySmall); + } + return UiKit.Hint(TranslationServer.Translate("studio/next_none"), UiTokens.FontBodySmall); + } + + private static Button InsertButton(string key, Action handler) + { + var button = StudioStyles.SmallButton(new Button(), TranslationServer.Translate(key)); + button.Pressed += handler; + return button; + } + + private void OnLinePressed() + { + var say = _moment.Say.Number - 1; + LineRequested?.Invoke(_moment.IsQuestion ? say : say + 1); + } + + private void OnOptionPressed() + { + var last = _moment.Branches.Count > 0 ? _moment.Branches[^1].Line : _moment.Say; + OptionRequested?.Invoke(last.Number); + } + + private void OnEdit(StudioActionButton button) => EditRequested?.Invoke(button); + + private void OnTarget(StudioActionButton button) => TargetRequested?.Invoke(button); + } +} diff --git a/src/Pages/StudioNowPage.cs b/src/Pages/StudioNowPage.cs new file mode 100644 index 0000000..7d70247 --- /dev/null +++ b/src/Pages/StudioNowPage.cs @@ -0,0 +1,197 @@ +namespace Cthangover.Mods.ScenarioStudio +{ + using System; + using System.Collections.Generic; + using Cthangover.Core.UI.Theming; + using Cthangover.Mods.Interface.Theming; + using Godot; + + /// + /// "Now" tab in the author mode: the running scenario as script with a + /// gutter () and the insert bar for a line + /// after the current one. It ends with the "this change affects" plate + /// naming the mod that receives the edit and the mod it overrides, so + /// nobody changes someone else's content unknowingly; the novice mode + /// () shows the same plate. + /// + /// Dragging the ▶ marker of the current line highlights the line under the + /// mouse when the running dialog can continue from it + /// () and on release asks for it + /// with ; a drop anywhere else does nothing. + /// + /// One instance lives as long as the dock; refills the + /// body and wires the controls to this instance's handlers. + /// + public sealed class StudioNowPage + { + /// A gutter click changed a breakpoint. + public event Action BreakpointToggled; + + /// One of the insert buttons was pressed. + public event Action InsertRequested; + + /// The ▶ marker was dropped on a line the dialog can continue from. + public event Action NextLineRequested; + + private readonly List _views = new(); + private StudioCodeLineView _dropTarget; + private bool _dragging; + + /// True while the ▶ marker is dragged: a redraw would drop the drag. + public bool IsDragging => _dragging; + + public void Build(VBoxContainer content, StudioSession session) + { + _views.Clear(); + _dropTarget = null; + _dragging = false; + if (!session.HasScenario) + { + content.AddChild(UiKit.EmptyState(TranslationServer.Translate("studio/no_scenario"), UiTokens.FontBody)); + return; + } + + var well = new PanelContainer { MouseFilter = Control.MouseFilterEnum.Ignore }; + well.AddThemeStyleboxOverride("panel", StudioStyles.CodeWell()); + content.AddChild(well); + + var lines = UiKit.Column(0); + well.AddChild(lines); + foreach (var line in session.Lines) + { + var view = new StudioCodeLineView(line); + view.BreakpointToggled += OnBreakpointToggled; + view.MarkerDragged += OnMarkerDragged; + view.MarkerDropped += OnMarkerDropped; + lines.AddChild(view); + _views.Add(view); + } + + var at = session.InsertionTextIndexAfterCurrent(); + var caption = at <= 0 ? string.Empty : string.Format(TranslationServer.Translate("studio/after_line"), at); + content.AddChild(BuildInsertBar(caption)); + content.AddChild(BuildScopeBanner(session)); + } + + private Control BuildInsertBar(string caption) + { + var bar = new PanelContainer { MouseFilter = Control.MouseFilterEnum.Ignore }; + bar.AddThemeStyleboxOverride("panel", StudioStyles.InsertBar()); + + var row = new HFlowContainer { MouseFilter = Control.MouseFilterEnum.Ignore }; + row.AddThemeConstantOverride("h_separation", UiTokens.Space2); + row.AddThemeConstantOverride("v_separation", UiTokens.Space2); + bar.AddChild(row); + + if (!string.IsNullOrEmpty(caption)) + { + var label = StudioStyles.Text(caption, UiTokens.FontSmall, UiTokens.Faint); + label.VerticalAlignment = VerticalAlignment.Center; + label.SizeFlagsVertical = Control.SizeFlags.ShrinkCenter; + row.AddChild(label); + } + + row.AddChild(InsertButton("studio/add_line", OnAddLine)); + row.AddChild(InsertButton("studio/add_choice", OnAddChoice)); + row.AddChild(InsertButton("studio/add_jump", OnAddJump)); + return bar; + } + + private static Button InsertButton(string key, Action handler) + { + var button = StudioStyles.SmallButton(new Button(), TranslationServer.Translate(key)); + button.Pressed += handler; + return button; + } + + /// + /// Inline editor of the whole scenario text (the footer's "Edit text"), + /// filling the body; the dock reads the text back when editing ends. + /// + public static CodeEdit BuildEditor(VBoxContainer content, string text) + { + var editor = new CodeEdit + { + Text = text ?? string.Empty, + GuttersDrawLineNumbers = true, + HighlightCurrentLine = true, + WrapMode = TextEdit.LineWrappingMode.None, + SizeFlagsHorizontal = Control.SizeFlags.ExpandFill, + CustomMinimumSize = new Vector2(0, 560), + SyntaxHighlighter = StudioSyntax.CreateHighlighter(), + }; + editor.AddThemeFontOverride("font", StudioStyles.Mono); + editor.AddThemeFontSizeOverride("font_size", UiTokens.FontBodySmall); + content.AddChild(editor); + return editor; + } + + /// "This change affects" plate: the edit scope and, when it differs, the mod it overrides. + public static Control BuildScopeBanner(StudioSession session) + { + var banner = new PanelContainer { MouseFilter = Control.MouseFilterEnum.Ignore }; + banner.AddThemeStyleboxOverride("panel", StudioStyles.ScopeBanner()); + + var column = UiKit.Column(UiTokens.Space2); + banner.AddChild(column); + + var title = StudioStyles.Text(TranslationServer.Translate("studio/affects"), UiTokens.FontTiny, UiTokens.Warning); + title.Uppercase = true; + column.AddChild(title); + + var row = new HFlowContainer { MouseFilter = Control.MouseFilterEnum.Ignore }; + row.AddThemeConstantOverride("h_separation", UiTokens.Space3); + column.AddChild(row); + if (session.EditScope == null) + return banner; + row.AddChild(StudioStyles.Scope(session.EditScope)); + if (session.EditScope.ModId != session.FileScope.ModId) + { + row.AddChild(StudioStyles.Text(TranslationServer.Translate("studio/overrides"), UiTokens.FontSmall, UiTokens.Faint)); + row.AddChild(StudioStyles.Scope(session.FileScope)); + } + + return banner; + } + + private void OnBreakpointToggled(StudioCodeLine line) => BreakpointToggled?.Invoke(line); + + private void OnMarkerDragged(Vector2 at) + { + _dragging = true; + var target = DropTargetAt(at); + if (target == _dropTarget) + return; + _dropTarget?.ShowDropTarget(false); + _dropTarget = target; + _dropTarget?.ShowDropTarget(true); + } + + private void OnMarkerDropped(Vector2 at) + { + _dragging = false; + _dropTarget?.ShowDropTarget(false); + _dropTarget = null; + var target = DropTargetAt(at); + if (target != null) + NextLineRequested?.Invoke(target.Line); + } + + /// The row under (canvas coordinates) the dialog can continue from, or null. + private StudioCodeLineView DropTargetAt(Vector2 at) + { + foreach (var view in _views) + { + if (view.Line.CanRunNext && view.GetGlobalRect().HasPoint(at)) + return view; + } + return null; + } + + private void OnAddLine() => InsertRequested?.Invoke(StudioLineKind.Line); + + private void OnAddChoice() => InsertRequested?.Invoke(StudioLineKind.Choice); + + private void OnAddJump() => InsertRequested?.Invoke(StudioLineKind.Jump); + } +} diff --git a/src/Pages/StudioScenePage.cs b/src/Pages/StudioScenePage.cs new file mode 100644 index 0000000..da3fcfd --- /dev/null +++ b/src/Pages/StudioScenePage.cs @@ -0,0 +1,103 @@ +namespace Cthangover.Mods.ScenarioStudio +{ + using System; + using Cthangover.Core.UI.Theming; + using Cthangover.Mods.Interface.Theming; + using Godot; + + /// + /// "Scene" tab: every scenario the last composition of the current scene + /// considered, in start order, with the owning mod, priority, header + /// condition and its result, and the status with the reason in words. This + /// is the answer to "why did my scenario not start": a false condition, a + /// one-run scenario already completed, or another mod winning the same + /// scenario name. Any candidate can be started now; a completed one can be + /// made fresh again; a new scenario for the scene goes to the studio layer. + /// + public static class StudioScenePage + { + public static void Build(VBoxContainer content, StudioSession session, Action run, Action reset, Action create) + { + var hint = UiKit.Hint(TranslationServer.Translate("studio/cand_hint")); + hint.AutowrapMode = TextServer.AutowrapMode.WordSmart; + content.AddChild(hint); + + if (session.Candidates.Count == 0) + content.AddChild(UiKit.EmptyState(TranslationServer.Translate("studio/cand_none"), UiTokens.FontBody)); + foreach (var candidate in session.Candidates) + content.AddChild(BuildCard(candidate, run, reset)); + + var add = StudioActionButton.Create("studio/new_scenario", create); + add.Payload = session.SceneName; + add.SizeFlagsHorizontal = Control.SizeFlags.ShrinkBegin; + content.AddChild(add); + } + + private static Control BuildCard(StudioCandidate candidate, Action run, Action reset) + { + var running = candidate.Status == StudioCandidateStatus.Running; + var card = new PanelContainer { MouseFilter = Control.MouseFilterEnum.Ignore }; + card.AddThemeStyleboxOverride("panel", StudioStyles.Candidate(running)); + + var column = UiKit.Column(UiTokens.Space2); + card.AddChild(column); + + var head = UiKit.Row(UiTokens.Space3); + head.AddChild(StudioStyles.Text(candidate.Name, UiTokens.FontBody, UiTokens.InkBright, mono: true)); + head.AddChild(UiKit.Spacer()); + head.AddChild(UiKit.Badge(TranslationServer.Translate(StatusKey(candidate.Status)), StudioStyles.StatusColor(candidate.Status))); + column.AddChild(head); + + var meta = new HFlowContainer { MouseFilter = Control.MouseFilterEnum.Ignore }; + meta.AddThemeConstantOverride("h_separation", UiTokens.Space3); + meta.AddChild(StudioStyles.Scope(candidate.Scope)); + meta.AddChild(StudioStyles.Text(string.Format(TranslationServer.Translate("studio/priority"), candidate.Priority), UiTokens.FontSmall, UiTokens.Muted, mono: true)); + var condition = candidate.Condition ?? TranslationServer.Translate("studio/no_condition"); + var mark = candidate.ConditionOk == null ? "" : candidate.ConditionOk.Value ? "✓ " : "✗ "; + var conditionColor = candidate.ConditionOk == false ? UiTokens.DangerText : candidate.ConditionOk == true ? UiTokens.SuccessText : UiTokens.Muted; + meta.AddChild(StudioStyles.Text(mark + condition, UiTokens.FontSmall, conditionColor, mono: true)); + if (candidate.IsOneRun) + meta.AddChild(StudioStyles.Text(TranslationServer.Translate("studio/one_run"), UiTokens.FontSmall, UiTokens.Muted)); + column.AddChild(meta); + + var reason = UiKit.Label(candidate.Reason?.Resolve() ?? string.Empty, UiTokens.FontSmall, UiTokens.Muted); + reason.AutowrapMode = TextServer.AutowrapMode.WordSmart; + column.AddChild(reason); + + if (running || candidate.Status == StudioCandidateStatus.Shadowed) + return card; + + var actions = UiKit.Row(UiTokens.Space2); + var start = StudioActionButton.Create("studio/run_now", run); + start.Payload = candidate.Name; + actions.AddChild(start); + if (candidate.Status == StudioCandidateStatus.Completed) + { + var fresh = StudioActionButton.Create("studio/reset_completed", reset); + fresh.Payload = candidate.Name; + actions.AddChild(fresh); + } + column.AddChild(actions); + return card; + } + + private static string StatusKey(StudioCandidateStatus status) + { + switch (status) + { + case StudioCandidateStatus.Running: + return "studio/cand_running"; + case StudioCandidateStatus.Queued: + return "studio/cand_queued"; + case StudioCandidateStatus.Skipped: + return "studio/cand_skipped"; + case StudioCandidateStatus.Completed: + return "studio/cand_completed"; + case StudioCandidateStatus.Failed: + return "studio/cand_failed"; + default: + return "studio/cand_shadowed"; + } + } + } +} diff --git a/src/Pages/StudioStatePage.cs b/src/Pages/StudioStatePage.cs new file mode 100644 index 0000000..cbd4dda --- /dev/null +++ b/src/Pages/StudioStatePage.cs @@ -0,0 +1,44 @@ +namespace Cthangover.Mods.ScenarioStudio +{ + using System; + using Cthangover.Core.UI.Theming; + using Cthangover.Mods.Interface.Theming; + using Godot; + + /// + /// "State" tab: game state grouped by owner — time, dialog variables of the + /// running dialog, quests the current scenario refers to, inventory and the + /// one-run completion history. Variables, item counts and quest status are + /// editable (Enter writes them into the game), because changing one is the + /// quickest way to test another branch of a scenario. + /// + public static class StudioStatePage + { + public static void Build(VBoxContainer content, StudioSession session, Action submitted) + { + var hint = UiKit.Hint(TranslationServer.Translate("studio/state_hint")); + hint.AutowrapMode = TextServer.AutowrapMode.WordSmart; + content.AddChild(hint); + + string group = null; + VBoxContainer rows = null; + foreach (var entry in session.State) + { + if (entry.Group.Key != group) + { + group = entry.Group.Key; + content.AddChild(UiKit.SectionTitle(entry.Group.Resolve(), UiTokens.FontSmall)); + var well = new PanelContainer { MouseFilter = Control.MouseFilterEnum.Ignore }; + well.AddThemeStyleboxOverride("panel", StudioStyles.Well()); + content.AddChild(well); + rows = UiKit.Column(UiTokens.Space2); + well.AddChild(rows); + } + + var row = new StudioStateRow(entry); + row.Submitted += submitted; + rows.AddChild(row); + } + } + } +} diff --git a/src/Runtime/StudioDebugger.cs b/src/Runtime/StudioDebugger.cs new file mode 100644 index 0000000..26b0b17 --- /dev/null +++ b/src/Runtime/StudioDebugger.cs @@ -0,0 +1,194 @@ +namespace Cthangover.Mods.ScenarioStudio +{ + using System; + using System.Collections.Generic; + using Cthangover.Mods.Dialog; + using Cthangover.Mods.Dialog.Action; + using Cthangover.Mods.Dialog.Executable; + + /// + /// The studio's : pause, step and breakpoints + /// for scenario dialogs. Only dialogs shown in the + /// with a as locker are held — standalone + /// runtimes (hover commands) and click dialogs cannot be resumed and always + /// run through. Breakpoints are keyed by scenario name, the identity that + /// survives an edit moving the file into the studio layer. + /// + /// "Pause" holds the dialog before the next action; "continue" and "step" + /// let exactly one action pass the hold (allowOnce), step then holds + /// again. The first action of every new scenario event raises + /// before anything runs, which is when the + /// rollback snapshot is taken. + /// + public sealed class StudioDebugger : IDialogDebugger + { + private readonly Dictionary> _breakpoints = new(); + private readonly HashSet _executed = new(); + private int _allowOnce; + + /// The scenario event the debugger last saw run. + public ScenarioEvent Event { get; private set; } + + public bool IsPaused { get; private set; } + + /// Scenario line the dialog is held at, 0 when not held. + public int HeldLine { get; private set; } + + /// Raised before the first action of a new scenario event runs. + public event Action ScenarioStarted; + + /// Raised when the dialog is held at a line (pause or breakpoint). + public event Action Held; + + /// Raised after any change worth redrawing (an action ran, a hold, a toggle). + public event Action Changed; + + public bool ShouldHold(DialogRuntime runtime, IActionCommand action) + { + if (runtime.DialogBox == null || runtime.DialogBox.Locker is not ScenarioEvent scenario) + return false; + + if (scenario != Event) + { + Event = scenario; + _executed.Clear(); + HeldLine = 0; + ScenarioStarted?.Invoke(scenario); + } + + if (_allowOnce > 0) + { + _allowOnce--; + return Pass(action); + } + + var breakpoint = action.SourceLine > 0 && HasBreakpoint(scenario.ID, action.SourceLine); + if (!IsPaused && !breakpoint) + return Pass(action); + + IsPaused = true; + HeldLine = action.SourceLine; + Held?.Invoke(action.SourceLine, breakpoint); + Changed?.Invoke(); + return true; + } + + public bool IsExecuted(int line) => _executed.Contains(line); + + public bool HasBreakpoint(string scenario, int line) => + scenario != null && _breakpoints.TryGetValue(scenario, out var lines) && lines.Contains(line); + + public void ToggleBreakpoint(string scenario, int line) + { + if (string.IsNullOrEmpty(scenario)) + return; + if (!_breakpoints.TryGetValue(scenario, out var lines)) + _breakpoints[scenario] = lines = new HashSet(); + if (!lines.Remove(line)) + lines.Add(line); + Changed?.Invoke(); + } + + /// Turns pause on: the next action of a scenario dialog will be held. + public void Pause() + { + IsPaused = true; + Changed?.Invoke(); + } + + /// + /// Turns pause off and lets the held action run ( + /// resumes it); without a hold it only clears the pause. + /// + public void Continue(DialogBox box) + { + IsPaused = false; + ReleaseHold(box); + Changed?.Invoke(); + } + + /// + /// Runs exactly one action and holds before the next. When the dialog is + /// not held but waits for a click, the click is made for the user. + /// + public void Step(DialogBox box) + { + IsPaused = true; + if (box == null || !box.IsRunning) + return; + + if (box.Runtime.IsHeld) + { + ReleaseHold(box); + } + else if (!box.IsWaitingAnswer) + { + _allowOnce = 1; + box.NextAction(); + } + Changed?.Invoke(); + } + + /// + /// "Set next statement" of an IDE: the action of scenario line + /// becomes the next one to run and the dialog is + /// held before it in pause, whether it was held, waited for a click or + /// for an answer. Skipped and re-run lines change nothing in the game + /// state on their own. False when the line has no action in the queue + /// of the running dialog (a comment, an answer, a line added in the studio). + /// + public bool SetNext(DialogBox box, int line) + { + var queue = box?.Runtime.Dialog?.Queue; + if (line <= 0 || queue == null || !box.IsRunning || box.Locker != Event) + return false; + + var target = IndexOf(queue, line); + if (target < 0 || !box.MoveTo(target)) + return false; + + IsPaused = true; + HeldLine = line; + _allowOnce = 0; + Changed?.Invoke(); + return true; + } + + /// Forgets the current event, e.g. before restarting it. + public void Reset() + { + Event = null; + HeldLine = 0; + _allowOnce = 0; + _executed.Clear(); + } + + /// Queue index of the first action built from scenario line , -1 when none. + public static int IndexOf(List queue, int line) + { + for (var i = 0; i < queue.Count; i++) + { + if (queue[i]?.SourceLine == line) + return i; + } + return -1; + } + + private void ReleaseHold(DialogBox box) + { + if (box == null || !box.Runtime.IsHeld) + return; + HeldLine = 0; + _allowOnce = 1; + box.Resume(); + } + + private bool Pass(IActionCommand action) + { + if (action.SourceLine > 0) + _executed.Add(action.SourceLine); + Changed?.Invoke(); + return false; + } + } +} diff --git a/src/Runtime/StudioJournal.cs b/src/Runtime/StudioJournal.cs new file mode 100644 index 0000000..4064422 --- /dev/null +++ b/src/Runtime/StudioJournal.cs @@ -0,0 +1,80 @@ +namespace Cthangover.Mods.ScenarioStudio +{ + using System.Collections.Generic; + using Cthangover.Core.Events; + using Cthangover.Core.Scenes; + using Godot; + + /// + /// What happened in the session, for the "Log", "Scene" and "Map" tabs: + /// log entries (scene entries, skipped candidates with the reason, scenario + /// starts and ends, breakpoints, studio edits), the order of visited scenes + /// and the last composition report of every scene. Fed by the mod event + /// bus and by ; times are session time (mm:ss since + /// the studio started). + /// + public sealed class StudioJournal + { + private const int MaxEntries = 300; + + private readonly ulong _startMsec = Time.GetTicksMsec(); + private readonly Dictionary _composed = new(); + + public List Entries { get; } = new(); + + /// Scenes in the order they were entered (repeats included). + public List SceneHistory { get; } = new(); + + public void Add(StudioLogCategory category, StudioText text, bool warning = false) + { + Entries.Add(new StudioLogEntry { Time = Now(), Category = category, Text = text, IsWarning = warning }); + if (Entries.Count > MaxEntries) + Entries.RemoveAt(0); + } + + /// Last composition report of , or null. + public SceneComposedEvent Composed(string scene) => + scene != null && _composed.TryGetValue(scene, out var report) ? report : null; + + public void OnSceneEntered(SceneEnteredEvent e) + { + SceneHistory.Add(e.SceneName); + Add(StudioLogCategory.Scene, new StudioText("studio/log_text_enter", e.SceneName)); + } + + public void OnSceneComposed(SceneComposedEvent e) + { + _composed[e.SceneName] = e; + foreach (var candidate in e.Candidates) + { + switch (candidate.Outcome) + { + case SceneCandidateOutcome.ConditionFalse: + Add(StudioLogCategory.Skip, new StudioText("studio/log_text_condition", candidate.Id), true); + break; + case SceneCandidateOutcome.Completed: + Add(StudioLogCategory.Skip, new StudioText("studio/log_text_completed", candidate.Id), true); + break; + case SceneCandidateOutcome.Shadowed: + Add(StudioLogCategory.Skip, new StudioText("studio/log_text_shadowed", candidate.Id, candidate.ModId), true); + break; + case SceneCandidateOutcome.Failed: + Add(StudioLogCategory.Skip, new StudioText("studio/log_text_failed", candidate.Id), true); + break; + } + } + } + + public void OnDialogEnded(DialogEndedEvent e) + { + if (!string.IsNullOrEmpty(e.LockerId)) + Add(StudioLogCategory.End, new StudioText("studio/log_text_ended", e.LockerId)); + } + + private string Now() + { + var seconds = (Time.GetTicksMsec() - _startMsec) / 1000; + return $"{seconds / 60:00}:{seconds % 60:00}"; + } + } +} diff --git a/src/Runtime/StudioLayer.cs b/src/Runtime/StudioLayer.cs new file mode 100644 index 0000000..c4a11d3 --- /dev/null +++ b/src/Runtime/StudioLayer.cs @@ -0,0 +1,161 @@ +namespace Cthangover.Mods.ScenarioStudio +{ + using System.Collections.Generic; + using System.IO; + using System.Text; + using Cthangover.Core.Bootstrap; + using Cthangover.Core.Games; + using Cthangover.Core.Mods; + using Cthangover.Core.Mods.Providers; + using Cthangover.Core.Mods.Resolvers; + using Cthangover.Core.Scenes; + using Cthangover.Core.Utils; + using Godot; + + /// + /// The studio's edit layer: a folder per game (user://studio/<game>/, + /// or --studio-layer=<dir>) laid out like a mod (scenarios/, + /// scenes/) and mounted as content overlay through + /// . Scenarios written here override same-named + /// scenarios of any mod, so a player can change content of read-only zip + /// mods without touching them; new scenes and scenarios live here too. + /// + /// Edits of folder mods go into the mod itself (the author's workflow) + /// unless --studio-write=layer forces the layer — tests use it to + /// keep the repository untouched. turns the layer into + /// a regular mod in user://mods/. + /// + public sealed class StudioLayer + { + public const string Id = "my_studio"; + private const string LayerArg = "--studio-layer="; + private const string WriteArg = "--studio-write=layer"; + + /// Absolute path of the layer folder. + public string RootPath { get; } + + /// True when every edit goes to the layer, folder mods included. + public bool ForceLayer { get; } + + public StudioLayer(string[] args) + { + string dir = null; + foreach (var arg in args) + { + if (arg.StartsWith(LayerArg, System.StringComparison.Ordinal)) + dir = arg.Substring(LayerArg.Length); + if (arg == WriteArg) + ForceLayer = true; + } + + dir ??= "user://studio"; + var game = string.IsNullOrEmpty(ActiveGame.Id) ? "dev" : ActiveGame.Id; + RootPath = Path.Combine(ProjectSettings.GlobalizePath(dir), game); + } + + /// Creates the folder if needed, mounts it as overlay and refreshes scenario and scene tables. + public void Mount() + { + Directory.CreateDirectory(Path.Combine(RootPath, "scenarios")); + Directory.CreateDirectory(Path.Combine(RootPath, "scenes")); + ModOverlays.Mount(Id, new FolderModFileProvider(RootPath, Id)); + RefreshContent(); + GameLogger.Log("UI", $"Scenario studio: layer '{Id}' mounted at {RootPath}"); + } + + /// Writes a layer file (mod-relative path) and returns its absolute path. + public string Write(string relativePath, string text) + { + var path = Path.Combine(RootPath, relativePath.Replace('/', Path.DirectorySeparatorChar)); + Directory.CreateDirectory(Path.GetDirectoryName(path)); + File.WriteAllText(path, text); + return path; + } + + /// + /// Creates scene with the given backgrounds and a + /// first scenario for it; returns the scenario name. + /// + public string CreateScene(string name, IReadOnlyList backgrounds, string firstLine) + { + var json = new StringBuilder("{\n \"name\": \"").Append(name).Append('"'); + if (backgrounds != null && backgrounds.Count > 0) + { + json.Append(",\n \"defaultBackground\": ["); + for (var i = 0; i < backgrounds.Count; i++) + json.Append(i == 0 ? "" : ", ").Append('"').Append(backgrounds[i]).Append('"'); + json.Append(']'); + } + json.Append("\n}\n"); + Write($"scenes/{name}.json", json.ToString()); + return CreateScenario(name, firstLine); + } + + /// Creates a new scenario for scene with one line; returns its name. + public string CreateScenario(string scene, string firstLine) + { + var name = scene + "_studio"; + for (var n = 2; File.Exists(ScenarioFile(name)); n++) + name = $"{scene}_studio_{n}"; + + Write($"scenarios/{name}.scenario", $"scene: {scene}\npriority: 50\n---\n{firstLine}\nend\n"); + RefreshContent(); + return name; + } + + /// Scene names defined by the layer itself. + public List SceneNames() + { + var result = new List(); + var dir = Path.Combine(RootPath, "scenes"); + if (!Directory.Exists(dir)) + return result; + foreach (var file in Directory.GetFiles(dir, "*.json")) + result.Add(Path.GetFileNameWithoutExtension(file)); + return result; + } + + /// + /// Copies the layer into user://mods/<modId>/ with a manifest, so + /// the edits become a regular mod that can be added to a game's collection. + /// Returns the folder written. + /// + public string Export(string modId) + { + var target = Path.Combine(ProjectSettings.GlobalizePath("user://mods/"), modId); + CopyTree(RootPath, target); + var manifest = "{\n" + + $" \"id\": \"{modId}\",\n" + + $" \"name\": \"{modId}\",\n" + + " \"version\": \"0.1.0\",\n" + + " \"author\": \"studio\",\n" + + " \"description\": \"Exported from the scenario studio layer.\",\n" + + " \"category\": \"content\",\n" + + " \"depends\": [\"dialog_base>=1\"]\n" + + "}\n"; + File.WriteAllText(Path.Combine(target, "manifest.json"), manifest); + GameLogger.Log("UI", $"Scenario studio: layer exported as mod '{modId}' to {target}"); + return target; + } + + /// Makes the next scene composition and the scene table see changed scenario and scene files. + public static void RefreshContent() + { + ScenarioDefs.Invalidate(); + ModManager.Instance.GetResolver()?.ClearCache(); + AppServices.Current.SceneDefs.Clear(); + SceneManager.RescanScenes(); + } + + private string ScenarioFile(string name) => Path.Combine(RootPath, "scenarios", name + ".scenario"); + + private static void CopyTree(string from, string to) + { + Directory.CreateDirectory(to); + foreach (var file in Directory.GetFiles(from)) + File.Copy(file, Path.Combine(to, Path.GetFileName(file)), true); + foreach (var dir in Directory.GetDirectories(from)) + CopyTree(dir, Path.Combine(to, Path.GetFileName(dir))); + } + } +} diff --git a/src/Runtime/StudioLineReader.cs b/src/Runtime/StudioLineReader.cs new file mode 100644 index 0000000..c90705f --- /dev/null +++ b/src/Runtime/StudioLineReader.cs @@ -0,0 +1,151 @@ +namespace Cthangover.Mods.ScenarioStudio +{ + using System.Collections.Generic; + using Cthangover.Mods.Dialog.Scenarios; + + /// + /// Reads scenario text into s for the dock: one + /// entry per non-empty line, numbered like the parser numbers + /// SourceLine (1-based, header included), classified by command + /// with the player-visible text (: the + /// literal, or the line's own key= translated when shown) and the + /// line's target. Lines are split by + /// so the dock never disagrees with the engine; on top of the parser it + /// reports what the parser silently skips — unknown commands, answers and + /// jumps to missing labels. + /// + public static class StudioLineReader + { + public static List Read(string text) + { + var result = new List(); + if (string.IsNullOrEmpty(text)) + return result; + + var lines = text.Replace("\r\n", "\n").Split('\n'); + var headerEnd = HeaderEnd(lines); + var labels = new HashSet(); + + for (var i = 0; i < lines.Length; i++) + { + var trimmed = lines[i].Trim(); + if (trimmed.Length == 0) + continue; + + var line = new StudioCodeLine { Number = i + 1, Text = lines[i].TrimEnd() }; + if (i <= headerEnd) + line.Kind = StudioLineKind.Header; + else + Classify(line, trimmed); + + if (line.Kind == StudioLineKind.Label) + labels.Add(line.Target); + result.Add(line); + } + + foreach (var line in result) + CheckTarget(line, labels); + return result; + } + + private static int HeaderEnd(string[] lines) + { + for (var i = 0; i < lines.Length; i++) + { + if (lines[i].Trim() == "---") + return i; + } + return -1; + } + + private static void Classify(StudioCodeLine line, string trimmed) + { + if (trimmed.StartsWith('#')) + { + line.Kind = StudioLineKind.Comment; + return; + } + + if (trimmed.StartsWith(':')) + { + line.Kind = StudioLineKind.Label; + line.Target = trimmed.Substring(1).Trim(); + return; + } + + var positional = new List(); + var named = new Dictionary(); + var cmd = ScenarioParser.SplitLine(trimmed, positional, named, out var arrow); + switch (cmd) + { + case "text": + case "ptext": + line.Kind = StudioLineKind.Line; + line.Say = SayOf(positional, named); + return; + case "select": + line.Kind = StudioLineKind.Choice; + line.Say = SayOf(positional, named); + return; + case "option": + line.Kind = StudioLineKind.Option; + line.Say = SayOf(positional, named); + line.Target = OptionTarget(positional, named, arrow); + return; + case "goto": + line.Kind = StudioLineKind.Goto; + line.Target = !string.IsNullOrEmpty(arrow) ? arrow : named.GetValueOrDefault("target"); + return; + case "switch_scene": + line.Kind = StudioLineKind.Jump; + line.Target = positional.Count > 0 ? positional[0] : null; + return; + case "action": + line.Kind = StudioLineKind.Action; + line.Target = positional.Count > 0 ? positional[0] : null; + return; + case "end": + line.Kind = StudioLineKind.End; + return; + case "light_set": + case "light_use_time": + line.Kind = StudioLineKind.Light; + return; + case "background": + case "foreground": + line.Kind = StudioLineKind.Background; + return; + } + + line.Kind = StudioLineKind.Command; + if (cmd != null && ScenarioCommandStrategyFactory.Get(cmd) == null) + line.Error = new StudioText("studio/error_unknown_command", cmd); + } + + private static StudioText SayOf(List positional, Dictionary named) + { + if (named.TryGetValue("key", out var key) && !string.IsNullOrEmpty(key)) + return new StudioText(key); + return positional.Count > 0 ? StudioText.Raw(positional[0]) : null; + } + + private static string OptionTarget(List positional, Dictionary named, string arrow) + { + if (!string.IsNullOrEmpty(arrow)) + return arrow; + if (named.TryGetValue("goto", out var target)) + return target; + return positional.Count > 1 && positional[1].StartsWith(':') ? positional[1].TrimStart(':') : null; + } + + private static void CheckTarget(StudioCodeLine line, HashSet labels) + { + if (line.Kind != StudioLineKind.Option && line.Kind != StudioLineKind.Goto) + return; + if (string.IsNullOrEmpty(line.Target)) + line.Error ??= new StudioText("studio/error_no_target"); + else if (!labels.Contains(line.Target)) + line.Error ??= new StudioText("studio/error_label_missing", ":" + line.Target); + } + } +} diff --git a/src/Runtime/StudioLive.cs b/src/Runtime/StudioLive.cs new file mode 100644 index 0000000..4351b78 --- /dev/null +++ b/src/Runtime/StudioLive.cs @@ -0,0 +1,571 @@ +namespace Cthangover.Mods.ScenarioStudio +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.Text.RegularExpressions; + using Cthangover.Core.Bootstrap; + using Cthangover.Core.Events; + using Cthangover.Core.Mods; + using Cthangover.Core.Mods.Resolvers; + using Cthangover.Core.Quests; + using Cthangover.Core.Scenes; + using Cthangover.Core.Settings; + using Cthangover.Core.Utils; + using Cthangover.Mods.Dialog; + using Cthangover.Mods.Dialog.Executable; + using Godot; + + /// + /// The studio's connection to the running game, parked at /root for + /// the whole session (the dock comes and goes, this stays). It installs + /// the into the dialog runtime, keeps the + /// fed from the event bus, owns the edit + /// , the per-scenario s + /// and the rollback of the running scenario, and + /// performs every action of the dock: apply edits, the restart modes, run a + /// scene candidate, go to a scene, create scenes and scenarios, change + /// state values, export the layer. + /// + /// grows on every change the dock may want to show; + /// the dock polls it instead of subscribing, so a burst of dialog actions + /// costs one redraw. + /// + public partial class StudioLive : Node + { + private const string ChainGroup = "main_event_chain"; + private static readonly Regex ModIdPattern = new("^[a-z][a-z0-9_]*$"); + + private readonly Dictionary _copies = new(); + private StudioSnapshot _snapshot; + private StudioSnapshot _checkpoint; + + public static StudioLive Instance { get; private set; } + + public StudioDebugger Debugger { get; } = new(); + public StudioJournal Journal { get; } = new(); + public StudioLayer Layer { get; } + + /// Grows on every change worth redrawing. + public int Version { get; private set; } + + public StudioLive(StudioLayer layer) + { + Name = "ScenarioStudioLive"; + Layer = layer; + } + + public override void _EnterTree() + { + Instance = this; + DialogRuntime.Debugger = Debugger; + Debugger.ScenarioStarted += OnScenarioStarted; + Debugger.Held += OnHeld; + Debugger.Changed += Touch; + ModEventBus.Subscribe(OnSceneEntered, priority: 50); + ModEventBus.Subscribe(OnSceneComposed); + ModEventBus.Subscribe(OnDialogEnded); + } + + public override void _ExitTree() + { + if (DialogRuntime.Debugger == Debugger) + DialogRuntime.Debugger = null; + ModEventBus.Unsubscribe(OnSceneEntered); + ModEventBus.Unsubscribe(OnSceneComposed); + ModEventBus.Unsubscribe(OnDialogEnded); + if (Instance == this) + Instance = null; + } + + // ── what is running ──────────────────────────────────────── + + public DialogBox Box => SceneContextNode.FindNode("DialogBox"); + + public static string SceneName => SceneManager.GetCurrentSceneName(); + + /// The scenario in the dialog box, or the last one the debugger saw while it still exists. + public ScenarioEvent CurrentEvent + { + get + { + var box = Box; + if (box != null && box.IsRunning && box.Locker is ScenarioEvent running) + return running; + var last = Debugger.Event; + return last != null && IsInstanceValid(last) ? last : null; + } + } + + /// True while is the dialog in the box. + public bool IsCurrentRunning + { + get + { + var box = Box; + return box != null && box.IsRunning && box.Locker == CurrentEvent; + } + } + + /// Scenario line of the action the running dialog stands on; 0 when nothing runs. + public int CurrentLine() + { + if (!IsCurrentRunning) + return 0; + + var runtime = Box.Runtime; + var queue = runtime.Dialog?.Queue; + var index = runtime.Index; + return queue != null && index >= 0 && index < queue.Count ? queue[index]?.SourceLine ?? 0 : 0; + } + + /// True when the running dialog has an action built from scenario line , so it can run next. + public bool CanRunFrom(int line) + { + var queue = IsCurrentRunning ? Box.Runtime.Dialog?.Queue : null; + return line > 0 && queue != null && StudioDebugger.IndexOf(queue, line) >= 0; + } + + /// Makes scenario line the next one to run () and logs it. + public bool SetNextLine(int line) + { + if (!Debugger.SetNext(Box, line)) + return false; + Journal.Add(StudioLogCategory.Stop, new StudioText("studio/log_text_next_line", line)); + return true; + } + + /// Current text of a scenario event's file (edits applied so far), or its built-in text. + public static string SourceText(ScenarioEvent evt) + { + if (evt == null) + return null; + if (!string.IsNullOrEmpty(evt.SourceModId)) + return ScenarioDefs.ReadText(evt.SourceModId, evt.SourcePath) ?? evt.ScenarioText; + return evt.ScenarioText; + } + + /// The mod (or layer) a scenario file belongs to and how the studio may touch it. + public StudioModScope ScopeOf(string modId, string path) + { + if (string.IsNullOrEmpty(modId)) + return new StudioModScope("inline", StudioScopeKind.ReadOnly); + if (modId == StudioLayer.Id) + return new StudioModScope(modId, StudioScopeKind.Layer); + return string.IsNullOrEmpty(ModManager.Instance.GetFileSystemPath(modId, path)) + ? new StudioModScope(modId, StudioScopeKind.Overlay) + : new StudioModScope(modId, StudioScopeKind.Own); + } + + /// Where an edit of a file in is written. + public StudioModScope EditScopeFor(StudioModScope fileScope) + { + if (fileScope.Kind == StudioScopeKind.Own && !Layer.ForceLayer) + return fileScope; + return new StudioModScope(StudioLayer.Id, StudioScopeKind.Layer); + } + + // ── edits ────────────────────────────────────────────────── + + /// Pending edits of a scenario, or null. + public StudioWorkingCopy Copy(string scenarioName) => + scenarioName != null && _copies.TryGetValue(scenarioName, out var copy) ? copy : null; + + /// Pending edits of the current scenario, created from its file on first use; null without a scenario. + public StudioWorkingCopy EnsureCopy() + { + var evt = CurrentEvent; + if (evt == null) + return null; + if (!_copies.TryGetValue(evt.ID, out var copy)) + _copies[evt.ID] = copy = new StudioWorkingCopy(evt.ID, SourceText(evt)); + return copy; + } + + /// Inserts lines before 0-based line of the current scenario. + public void Insert(int index, params string[] lines) + { + EnsureCopy()?.Insert(index, lines); + Touch(); + } + + public void ReplaceLine(int index, string line) + { + EnsureCopy()?.Replace(index, line); + Touch(); + } + + public void ReplaceText(string text) + { + EnsureCopy()?.ReplaceAll(text); + Touch(); + } + + /// Points the answer at 0-based line to a scene (a label block leading there is added). + public void TargetScene(int index, string scene) + { + var copy = EnsureCopy(); + if (copy == null) + return; + var label = StudioScriptEdits.EnsureSceneExit(copy, scene); + copy.Replace(index, StudioScriptEdits.WithTarget(copy.Line(index), label)); + Touch(); + } + + /// Points the answer at 0-based line to a label of the scenario. + public void TargetLabel(int index, string label) + { + var copy = EnsureCopy(); + if (copy == null) + return; + copy.Replace(index, StudioScriptEdits.WithTarget(copy.Line(index), label)); + Touch(); + } + + /// Points the answer at 0-based line to the end of the scenario. + public void TargetEnd(int index) + { + var copy = EnsureCopy(); + if (copy == null) + return; + var label = StudioScriptEdits.EnsureEnd(copy); + copy.Replace(index, StudioScriptEdits.WithTarget(copy.Line(index), label)); + Touch(); + } + + public void Discard() + { + var evt = CurrentEvent; + if (evt != null && _copies.Remove(evt.ID)) + Touch(); + } + + /// + /// Writes the pending edits of the current scenario — into its folder mod, + /// or into the studio layer for zip mods, layer files and forced layer — + /// and replays the scenario from its start with the game state rolled + /// back. Returns the scope written to, or null when there was nothing. + /// + public StudioModScope Apply() + { + var evt = CurrentEvent; + var copy = Copy(evt?.ID); + if (copy == null || copy.PendingCount == 0) + return null; + + if (string.IsNullOrEmpty(evt.SourceModId)) + { + evt.ScenarioText = copy.Text; + _copies.Remove(evt.ID); + Restart(StudioRestartMode.Scenario); + return ScopeOf(null, null); + } + + var target = EditScopeFor(ScopeOf(evt.SourceModId, evt.SourcePath)); + var pending = copy.PendingCount; + if (target.Kind == StudioScopeKind.Layer) + Layer.Write(evt.SourcePath, copy.Text); + else + File.WriteAllText(ModManager.Instance.GetFileSystemPath(evt.SourceModId, evt.SourcePath), copy.Text); + + _copies.Remove(evt.ID); + StudioLayer.RefreshContent(); + Journal.Add(StudioLogCategory.Edit, new StudioText("studio/log_text_applied", evt.ID, pending, target.ModId)); + GameLogger.Log("UI", $"Scenario studio: applied {pending} change(s) of '{evt.ID}' to '{target.ModId}'"); + Restart(StudioRestartMode.Rollback); + return target; + } + + // ── restarts and navigation ──────────────────────────────── + + public void Restart(StudioRestartMode mode) + { + var evt = CurrentEvent; + if (evt == null) + return; + + var name = evt.ID; + switch (mode) + { + case StudioRestartMode.Scene: + GoToScene(SceneName); + return; + case StudioRestartMode.Rollback when _snapshot != null && _snapshot.ScenarioName == name: + StopDialog(); + if (_snapshot.Restore()) + ForgetCompleted(name); + RunScenario(name, null, evt); + return; + case StudioRestartMode.FromLabel: + var label = LabelAbove(SourceText(evt), CurrentLine()); + StopDialog(); + ForgetCompleted(name); + RunScenario(name, label, evt); + return; + default: + StopDialog(); + ForgetCompleted(name); + RunScenario(name, null, evt); + return; + } + } + + /// Starts scenario of the current scene now, stopping the running dialog. + public bool RunNow(string name) + { + StopDialog(); + ForgetCompleted(name); + return RunScenario(name, null, null); + } + + /// Removes from the one-run completion history. + public void ForgetCompleted(string name) + { + var runtime = GameData.Instance?.Runtime; + if (runtime == null) + return; + runtime.CompletedScenarioIds.Remove(name); + runtime.LoadedCompletedScenarioIds?.Remove(name); + Touch(); + } + + /// Leaves the running dialog and enters (a debugging teleport). + public void GoToScene(string scene) + { + if (string.IsNullOrEmpty(scene)) + return; + StopDialog(); + Debugger.Reset(); + GetNodeOrNull("/root/SceneManager")?.SwitchScene(scene); + Touch(); + } + + private void StopDialog() + { + Debugger.Continue(null); + Box?.Abort(); + } + + /// + /// Builds a fresh event for scenario from the + /// current definitions (edits included), puts it on the scene's chain in + /// place of and starts it at once, so other + /// queued scenarios do not run first. A scenario without a definition + /// (test play, inline) is rebuilt from the replaced event's text. + /// + private bool RunScenario(string name, string startLabel, ScenarioEvent replaced) + { + var chain = GetTree().GetFirstNodeInGroup(ChainGroup) as ExecutableMainEventChain; + var factory = AppServices.Current.SceneEvents.ScenarioEventFactory; + if (chain == null || factory == null) + { + GameLogger.Log("UI", $"Scenario studio: cannot run '{name}' — no event chain or scenario factory", LogLevel.Error); + return false; + } + + var def = FindDefinition(name); + ScenarioEvent evt; + if (def != null) + { + var text = ScenarioDefs.ReadText(def.ModId, def.FilePath); + evt = factory.CreateFromDefinition(text, name, def.Condition, def.LightUseTime, def.IsOneRun, def.ModId, def.FilePath) as ScenarioEvent; + } + else if (replaced != null && IsInstanceValid(replaced)) + { + evt = factory.CreateFromText(replaced.ScenarioText) as ScenarioEvent; + } + else + { + GameLogger.Log("UI", $"Scenario studio: scenario '{name}' not found", LogLevel.Error); + return false; + } + + if (evt == null) + return false; + + evt.StartLabel = startLabel; + if (replaced != null && IsInstanceValid(replaced) && replaced.GetParent() == chain) + chain.RemoveEvent(replaced); + chain.AddEvent(evt); + + var started = evt.RunDialog(); + GameLogger.Log("UI", $"Scenario studio: run '{name}'" + (startLabel != null ? $" from :{startLabel}" : "") + (started ? "" : " — dialog box busy")); + Touch(); + return started; + } + + public static ScenarioDefinition FindDefinition(string name) + { + var defs = ScenarioDefs.CollectDefinitions(); + if (defs == null) + return null; + foreach (var list in defs.Values) + { + foreach (var def in list) + { + if (def.Name == name) + return def; + } + } + return null; + } + + private static string LabelAbove(string text, int line) + { + if (text == null || line <= 0) + return null; + var lines = text.Replace("\r\n", "\n").Split('\n'); + for (var i = Math.Min(line, lines.Length) - 1; i >= 0; i--) + { + var trimmed = lines[i].Trim(); + if (trimmed.StartsWith(':')) + return trimmed.Substring(1).Trim(); + } + return null; + } + + /// True when a manual checkpoint was saved in this session. + public bool HasCheckpoint => _checkpoint != null; + + /// Remembers the whole game state and the scene now, to come back to with . + public void SaveCheckpoint() + { + _checkpoint = StudioSnapshot.Capture(null, SceneName); + Journal.Add(StudioLogCategory.Edit, new StudioText("studio/log_text_checkpoint_saved", SceneName)); + Touch(); + } + + /// Brings back the saved checkpoint: game state, then its scene entered again. + public bool RestoreCheckpoint() + { + if (_checkpoint == null) + return false; + + StopDialog(); + Debugger.Reset(); + if (!_checkpoint.Restore()) + return false; + Journal.Add(StudioLogCategory.Edit, new StudioText("studio/log_text_checkpoint_restored", _checkpoint.SceneName)); + GoToScene(_checkpoint.SceneName); + return true; + } + + // ── new content ──────────────────────────────────────────── + + /// Creates a scene in the layer (backgrounds of the current scene) with a first scenario; returns false on a bad name. + public bool CreateScene(string name) + { + name = (name ?? string.Empty).Trim().ToLowerInvariant(); + if (!ModIdPattern.IsMatch(name)) + return false; + + var current = SceneName; + var defs = AppServices.Current.SceneDefs.CollectAll(); + List backgrounds = null; + if (current != null && defs != null && defs.TryGetValue(current, out var list) && list.Count > 0) + backgrounds = list[0].DefaultBackground; + + var scenario = Layer.CreateScene(name, backgrounds, StudioScriptEdits.NewLine()); + StudioLayer.RefreshContent(); + Journal.Add(StudioLogCategory.Edit, new StudioText("studio/log_text_scene_created", name, scenario)); + Touch(); + return true; + } + + /// Creates a new scenario for the current scene in the layer; returns its name. + public string CreateScenario() + { + var scene = SceneName; + if (string.IsNullOrEmpty(scene)) + return null; + var name = Layer.CreateScenario(scene, StudioScriptEdits.NewLine()); + Journal.Add(StudioLogCategory.Edit, new StudioText("studio/log_text_scenario_created", name, scene)); + Touch(); + return name; + } + + /// Exports the layer as mod ; null on a bad id. + public string Export(string modId) + { + modId = (modId ?? string.Empty).Trim().ToLowerInvariant(); + if (!ModIdPattern.IsMatch(modId)) + return null; + var path = Layer.Export(modId); + Journal.Add(StudioLogCategory.Edit, new StudioText("studio/log_text_exported", modId)); + Touch(); + return path; + } + + // ── state ────────────────────────────────────────────────── + + /// Writes a value edited in the "State" tab back into the game. + public void EditState(StudioStateEntry entry, string value) + { + switch (entry.Editor) + { + case StudioStateEditor.DialogVariable: + Box?.Runtime.SetVariable(entry.Id, value); + break; + case StudioStateEditor.Inventory: + var inventory = GameData.Instance?.Runtime.Inventory; + if (inventory == null || !int.TryParse(value, out var count) || count < 0) + return; + var delta = count - inventory.CheckCount(entry.Id); + if (delta > 0) + inventory.Add(entry.Id, delta); + else if (delta < 0) + inventory.Remove(entry.Id, -delta); + break; + case StudioStateEditor.Quest: + QuestServiceRegistry.Provider?.SetStatus(entry.Id, value); + break; + default: + return; + } + + Journal.Add(StudioLogCategory.Edit, new StudioText("studio/log_text_state", entry.Id, value)); + Touch(); + } + + // ── bus and debugger callbacks ───────────────────────────── + + private void OnScenarioStarted(ScenarioEvent evt) + { + try + { + _snapshot = StudioSnapshot.Capture(evt.ID, SceneName); + } + catch (Exception ex) + { + _snapshot = null; + GameLogger.Log("UI", $"Scenario studio: snapshot of '{evt.ID}' failed: {ex.Message}", LogLevel.Warning); + } + Journal.Add(StudioLogCategory.Scenario, new StudioText("studio/log_text_started", evt.ID)); + } + + private void OnHeld(int line, bool breakpoint) + { + Journal.Add(StudioLogCategory.Stop, new StudioText(breakpoint ? "studio/log_text_breakpoint" : "studio/log_text_paused", line)); + } + + private void OnSceneEntered(SceneEnteredEvent e) + { + Journal.OnSceneEntered(e); + Touch(); + } + + private void OnSceneComposed(SceneComposedEvent e) + { + Journal.OnSceneComposed(e); + Touch(); + } + + private void OnDialogEnded(DialogEndedEvent e) + { + Journal.OnDialogEnded(e); + Touch(); + } + + private void Touch() => Version++; + } +} diff --git a/src/Runtime/StudioScriptEdits.cs b/src/Runtime/StudioScriptEdits.cs new file mode 100644 index 0000000..c8d8d67 --- /dev/null +++ b/src/Runtime/StudioScriptEdits.cs @@ -0,0 +1,106 @@ +namespace Cthangover.Mods.ScenarioStudio +{ + using System.Collections.Generic; + using System.Text; + using Cthangover.Mods.Dialog.Scenarios; + using Godot; + + /// + /// The DSL the dock writes. New lines are written in the current language + /// as literals (what an author would type; the locale atlas convention for + /// scenario text). Edits rewrite one line through + /// and keep its other arguments, so + /// changing an answer's text does not lose its target and vice versa; + /// a literal replaces a key=, because the new text is no longer the + /// translated one. + /// + public static class StudioScriptEdits + { + public static string NewLine() => "text " + Quote(Tr("studio/new_line_text")); + + public static string NewQuestion() => "select " + Quote(Tr("studio/new_choice_text")); + + public static string NewAnswer() => "option " + Quote(Tr("studio/new_option_text")); + + public static string Jump(string scene) => "switch_scene " + scene; + + /// with its player-visible text replaced by . + public static string WithSay(string line, string text) + { + var positional = new List(); + var named = new Dictionary(); + var cmd = ScenarioParser.SplitLine(line.Trim(), positional, named, out var arrow); + if (cmd == null) + return line; + + named.Remove("key"); + if (positional.Count > 0) + positional[0] = text; + else + positional.Insert(0, text); + return Indent(line) + Compose(cmd, positional, named, arrow); + } + + /// (an answer or goto) pointing at label . + public static string WithTarget(string line, string label) + { + var positional = new List(); + var named = new Dictionary(); + var cmd = ScenarioParser.SplitLine(line.Trim(), positional, named, out _); + if (cmd == null) + return line; + + named.Remove("goto"); + named.Remove("target"); + if (positional.Count > 1 && positional[1].StartsWith(':')) + positional.RemoveAt(1); + return Indent(line) + Compose(cmd, positional, named, label); + } + + /// + /// Makes sure the scenario has a label block that leads to scene + /// (:to_scene → switch_scene → end), + /// appending it at the end when missing; returns the label name. + /// + public static string EnsureSceneExit(StudioWorkingCopy copy, string scene) + { + var label = "to_" + scene; + if (copy.FindLabel(label) < 0) + copy.Append("", ":" + label, Jump(scene), "end"); + return label; + } + + /// Makes sure the scenario has a label that just ends it; returns the label name. + public static string EnsureEnd(StudioWorkingCopy copy) + { + const string label = "finish"; + if (copy.FindLabel(label) < 0) + copy.Append("", ":" + label, "end"); + return label; + } + + public static string Quote(string text) => "\"" + (text ?? string.Empty).Replace("\"", "\\\"") + "\""; + + private static string Compose(string cmd, List positional, Dictionary named, string arrow) + { + var result = new StringBuilder(cmd); + var sayCommand = cmd == "text" || cmd == "ptext" || cmd == "select" || cmd == "option"; + for (var i = 0; i < positional.Count; i++) + { + var value = positional[i]; + result.Append(' ').Append((i == 0 && sayCommand) || NeedsQuotes(value) ? Quote(value) : value); + } + foreach (var pair in named) + result.Append(' ').Append(pair.Key).Append('=').Append(pair.Value); + if (!string.IsNullOrEmpty(arrow)) + result.Append(" -> :").Append(arrow); + return result.ToString(); + } + + private static bool NeedsQuotes(string value) => value.Length == 0 || value.Contains(' ') || value.Contains('"') || value.Contains('='); + + private static string Indent(string line) => line.Substring(0, line.Length - line.TrimStart().Length); + + private static string Tr(string key) => TranslationServer.Translate(key).ToString(); + } +} diff --git a/src/Runtime/StudioSessionBuilder.cs b/src/Runtime/StudioSessionBuilder.cs new file mode 100644 index 0000000..e711290 --- /dev/null +++ b/src/Runtime/StudioSessionBuilder.cs @@ -0,0 +1,249 @@ +namespace Cthangover.Mods.ScenarioStudio +{ + using System.Collections.Generic; + using Cthangover.Core.Bootstrap; + using Cthangover.Core.Mods.Resolvers; + using Cthangover.Core.Quests; + using Cthangover.Core.Scenes; + using Cthangover.Core.Settings; + using Cthangover.Mods.Dialog.Executable; + using Godot; + + /// + /// Builds the the dock renders from the live + /// game: the current scenario's lines (pending edits included) with the + /// debugger's marks, its file and edit scopes, the scene candidates of the + /// last composition merged with what is true now (running, completed), the + /// game state worth seeing while debugging, the log and the scene map + /// around the player. Read-only: nothing here changes the game. + /// + public static class StudioSessionBuilder + { + private const int VisitedOnMap = 4; + + public static StudioSession Build(StudioLive live) + { + var session = new StudioSession { SceneName = StudioLive.SceneName }; + var evt = live.CurrentEvent; + session.RunState = RunState(live); + + if (evt != null) + FillScenario(session, live, evt); + + FillCandidates(session, live, evt); + FillState(session, live); + session.Log.AddRange(live.Journal.Entries); + FillMap(session, live); + return session; + } + + private static StudioRunState RunState(StudioLive live) + { + var box = live.Box; + if (box != null && box.IsRunning && box.Runtime.IsHeld) + return StudioRunState.Held; + if (live.Debugger.IsPaused) + return StudioRunState.Paused; + return live.IsCurrentRunning ? StudioRunState.Running : StudioRunState.Idle; + } + + private static void FillScenario(StudioSession session, StudioLive live, ScenarioEvent evt) + { + session.ScenarioName = evt.ID; + session.FileScope = live.ScopeOf(evt.SourceModId, evt.SourcePath); + session.EditScope = live.EditScopeFor(session.FileScope); + + var copy = live.Copy(evt.ID); + session.PendingCount = copy?.PendingCount ?? 0; + var lines = StudioLineReader.Read(copy != null ? copy.Text : StudioLive.SourceText(evt)); + + var running = live.IsCurrentRunning && live.Debugger.Event == evt; + var current = live.IsCurrentRunning ? live.CurrentLine() : 0; + string label = null; + foreach (var line in lines) + { + line.SourceLine = copy != null ? copy.OriginalNumber(line.Number) : line.Number; + line.IsNew = copy != null && copy.IsChanged(line.Number - 1); + line.HasBreakpoint = line.SourceLine > 0 && live.Debugger.HasBreakpoint(evt.ID, line.SourceLine); + line.IsCurrent = current > 0 && line.SourceLine == current; + line.IsDone = running && !line.IsCurrent && line.SourceLine > 0 && live.Debugger.IsExecuted(line.SourceLine); + line.CanRunNext = running && !line.IsCurrent && live.CanRunFrom(line.SourceLine); + if (line.Kind == StudioLineKind.Label && current > 0 && line.SourceLine > 0 && line.SourceLine < current) + label = ":" + line.Target; + } + session.LabelName = label; + session.Lines.AddRange(lines); + } + + private static void FillCandidates(StudioSession session, StudioLive live, ScenarioEvent evt) + { + var report = live.Journal.Composed(session.SceneName); + if (report == null) + return; + + var completed = GameData.Instance?.Runtime.CompletedScenarioIds; + foreach (var candidate in report.Candidates) + { + var item = new StudioCandidate + { + Name = candidate.Id, + Scope = live.ScopeOf(candidate.ModId, candidate.ScenarioPath), + Priority = candidate.Priority, + Condition = string.IsNullOrEmpty(candidate.Condition) ? null : candidate.Condition, + IsOneRun = candidate.IsOneRun, + }; + + switch (candidate.Outcome) + { + case SceneCandidateOutcome.ConditionFalse: + item.ConditionOk = false; + item.Status = StudioCandidateStatus.Skipped; + item.Reason = new StudioText("studio/reason_condition_false"); + break; + case SceneCandidateOutcome.Completed: + item.Status = StudioCandidateStatus.Completed; + item.Reason = new StudioText("studio/reason_completed"); + break; + case SceneCandidateOutcome.Shadowed: + item.Status = StudioCandidateStatus.Shadowed; + item.Reason = new StudioText("studio/reason_shadowed"); + break; + case SceneCandidateOutcome.Failed: + item.Status = StudioCandidateStatus.Failed; + item.Reason = new StudioText("studio/reason_failed"); + break; + default: + item.ConditionOk = true; + item.Status = StudioCandidateStatus.Queued; + item.Reason = new StudioText("studio/reason_queued", candidate.Priority); + break; + } + + if (evt != null && live.IsCurrentRunning && evt.ID == candidate.Id) + { + item.Status = StudioCandidateStatus.Running; + item.Reason = new StudioText("studio/reason_running"); + } + else if (completed != null && candidate.IsOneRun && completed.Contains(candidate.Id)) + { + item.Status = StudioCandidateStatus.Completed; + item.Reason = new StudioText("studio/reason_completed"); + } + + session.Candidates.Add(item); + } + } + + private static void FillState(StudioSession session, StudioLive live) + { + var runtime = GameData.Instance?.Runtime; + if (runtime == null) + return; + + var time = runtime.Time; + Add(session, "studio/group_time", new StudioText("studio/state_day"), StudioText.Raw(time.Days.ToString()), StudioStateEditor.None, null); + Add(session, "studio/group_time", new StudioText("studio/state_hour"), StudioText.Raw($"{time.Hours:00}:{time.Minutes:00}"), StudioStateEditor.None, null); + + var box = live.Box; + if (box != null && box.IsRunning) + { + foreach (var variable in box.Runtime.Variables) + Add(session, "studio/group_dialog_vars", StudioText.Raw("${" + variable.Key + "}"), StudioText.Raw(variable.Value), StudioStateEditor.DialogVariable, variable.Key); + } + + var quests = QuestServiceRegistry.Provider; + if (quests != null) + { + foreach (var id in QuestIds(session.Lines)) + { + var quest = quests.Get(id); + if (quest != null) + Add(session, "studio/group_quests", StudioText.Raw(id), StudioText.Raw(quest.Status.ToString()), StudioStateEditor.Quest, id); + } + } + + var inventory = runtime.Inventory; + if (inventory?.Items != null) + { + foreach (var slot in inventory.Items) + { + if (slot?.Item != null) + Add(session, "studio/group_inventory", StudioText.Raw(slot.Item.ID), StudioText.Raw(slot.Count.ToString()), StudioStateEditor.Inventory, slot.Item.ID); + } + } + + foreach (var id in runtime.CompletedScenarioIds) + Add(session, "studio/group_completed", StudioText.Raw(id), new StudioText("studio/value_yes"), StudioStateEditor.None, id); + } + + private static IEnumerable QuestIds(List lines) + { + var seen = new HashSet(); + foreach (var line in lines) + { + var at = line.Text.IndexOf("quest_id=", System.StringComparison.Ordinal); + if (at < 0) + continue; + var start = at + "quest_id=".Length; + var end = line.Text.IndexOf(' ', start); + var id = (end < 0 ? line.Text.Substring(start) : line.Text.Substring(start, end - start)).Trim('"'); + if (id.Length > 0 && seen.Add(id)) + yield return id; + } + } + + private static void Add(StudioSession session, string group, StudioText key, StudioText value, StudioStateEditor editor, string id) + { + session.State.Add(new StudioStateEntry { Group = new StudioText(group), Key = key, Value = value, Editor = editor, Id = id }); + } + + private static void FillMap(StudioSession session, StudioLive live) + { + var here = session.SceneName; + var scenes = AppServices.Current.SceneDefs.CollectAll(); + + var visited = new List(); + var history = live.Journal.SceneHistory; + for (var i = history.Count - 1; i >= 0 && visited.Count < VisitedOnMap; i--) + { + if (history[i] != here && !visited.Contains(history[i])) + visited.Insert(0, history[i]); + } + foreach (var scene in visited) + session.Nodes.Add(Node(live, scenes, scene, StudioNodeState.Visited, null)); + + if (string.IsNullOrEmpty(here)) + return; + session.Nodes.Add(Node(live, scenes, here, StudioNodeState.Here, null)); + + var exits = new HashSet(); + var defs = ScenarioDefs.CollectDefinitions(); + if (defs != null && defs.TryGetValue(here, out var scenarios)) + { + foreach (var def in scenarios) + { + foreach (var line in StudioLineReader.Read(ScenarioDefs.ReadText(def.ModId, def.FilePath))) + { + if (line.Kind == StudioLineKind.Jump && !string.IsNullOrEmpty(line.Target) && exits.Add(line.Target)) + session.Nodes.Add(Node(live, scenes, line.Target, StudioNodeState.Next, new StudioText("studio/via_scenario", def.Name))); + } + } + } + + foreach (var scene in live.Layer.SceneNames()) + { + if (scene != here && exits.Add(scene)) + session.Nodes.Add(Node(live, scenes, scene, StudioNodeState.New, new StudioText("studio/via_layer"))); + } + } + + private static StudioSceneNode Node(StudioLive live, Dictionary> scenes, string name, StudioNodeState state, StudioText via) + { + string modId = null; + if (scenes != null && scenes.TryGetValue(name, out var list) && list.Count > 0) + modId = list[0].ModId; + var scope = modId == null ? new StudioModScope("?", StudioScopeKind.ReadOnly) : live.ScopeOf(modId, $"scenes/{name}.json"); + return new StudioSceneNode { Name = name, State = state, Scope = scope, Via = via }; + } + } +} diff --git a/src/Runtime/StudioSnapshot.cs b/src/Runtime/StudioSnapshot.cs new file mode 100644 index 0000000..ce48fdc --- /dev/null +++ b/src/Runtime/StudioSnapshot.cs @@ -0,0 +1,43 @@ +namespace Cthangover.Mods.ScenarioStudio +{ + using System.Text.Json; + using Cthangover.Core.Settings; + using Cthangover.Core.Utils; + + /// + /// Game state at the moment a scenario started: a full save package + /// () kept serialized in memory, plus + /// the scenario and scene it belongs to. Restoring it is the "restart with + /// rollback": quests, inventory, time and completion history go back to + /// the scenario's start. It is kept as JSON because applying a package may + /// reuse its slots, and one snapshot is restored again and again while a + /// scenario is being polished. + /// + public sealed class StudioSnapshot + { + private readonly string _json; + + public string ScenarioName { get; } + public string SceneName { get; } + + private StudioSnapshot(string scenarioName, string sceneName, string json) + { + ScenarioName = scenarioName; + SceneName = sceneName; + _json = json; + } + + public static StudioSnapshot Capture(string scenarioName, string sceneName) + { + var package = SaveService.BuildPackage("studio_snapshot"); + return new StudioSnapshot(scenarioName, sceneName, JsonSerializer.Serialize(package, JsonOptionsProvider.WriteCompactOptions)); + } + + /// Brings the game state back; false when the package could not be applied. + public bool Restore() + { + var package = JsonSerializer.Deserialize(_json, JsonOptionsProvider.ReadOptions); + return package != null && SaveService.ApplyPackage(package, "studio snapshot of " + ScenarioName); + } + } +} diff --git a/src/Runtime/StudioWorkingCopy.cs b/src/Runtime/StudioWorkingCopy.cs new file mode 100644 index 0000000..a56c57a --- /dev/null +++ b/src/Runtime/StudioWorkingCopy.cs @@ -0,0 +1,129 @@ +namespace Cthangover.Mods.ScenarioStudio +{ + using System.Collections.Generic; + + /// + /// Unapplied edits of one scenario: its text as lines plus which lines were + /// added or changed since the copy was taken. Every dock edit (insert, edit + /// a line's text, choose an answer's target, edit the whole text) goes here + /// first and reaches the game only on Apply, so an author can prepare + /// several changes and replay them at once. Every line remembers its number + /// in the original text (0 for added lines): the running dialog's actions + /// carry original numbers, so the debugger's marks and breakpoints are + /// mapped through it while edits shift the displayed numbering. + /// + public sealed class StudioWorkingCopy + { + private readonly List _lines; + private readonly List _changed; + private readonly List _origin; + + public string ScenarioName { get; } + + public StudioWorkingCopy(string scenarioName, string text) + { + ScenarioName = scenarioName; + _lines = new List((text ?? string.Empty).Replace("\r\n", "\n").Split('\n')); + _changed = new List(new bool[_lines.Count]); + _origin = new List(_lines.Count); + for (var i = 0; i < _lines.Count; i++) + _origin.Add(i + 1); + } + + /// Original (1-based) number of the displayed line ; 0 for an added line. + public int OriginalNumber(int number) => number >= 1 && number <= _origin.Count ? _origin[number - 1] : 0; + + public string Text => string.Join("\n", _lines); + + public int LineCount => _lines.Count; + + /// Number of added or changed lines. + public int PendingCount + { + get + { + var count = 0; + foreach (var changed in _changed) + { + if (changed) + count++; + } + return count; + } + } + + /// Whether the line at 0-based was added or changed. + public bool IsChanged(int index) => index >= 0 && index < _changed.Count && _changed[index]; + + public string Line(int index) => index >= 0 && index < _lines.Count ? _lines[index] : string.Empty; + + /// Inserts lines before 0-based (clamped to the text). + public void Insert(int index, params string[] lines) + { + index = System.Math.Clamp(index, 0, _lines.Count); + _lines.InsertRange(index, lines); + _changed.InsertRange(index, NewFlags(lines.Length)); + _origin.InsertRange(index, new int[lines.Length]); + } + + /// Appends lines at the end of the text. + public void Append(params string[] lines) => Insert(_lines.Count, lines); + + public void Replace(int index, string line) + { + if (index < 0 || index >= _lines.Count || _lines[index] == line) + return; + _lines[index] = line; + _changed[index] = true; + } + + /// + /// Replaces the whole text (the inline text editor). Lines that differ from + /// the line at the same index before the edit are marked changed; this is a + /// display hint, not a diff, so an inserted line marks the shifted tail too. + /// + public void ReplaceAll(string text) + { + var lines = (text ?? string.Empty).Replace("\r\n", "\n").Split('\n'); + for (var i = 0; i < lines.Length; i++) + { + var changed = i >= _lines.Count || _lines[i] != lines[i] || _changed[i]; + if (i < _lines.Count) + { + _lines[i] = lines[i]; + _changed[i] = changed; + } + else + { + _lines.Add(lines[i]); + _changed.Add(true); + _origin.Add(0); + } + } + + if (_lines.Count > lines.Length) + { + _lines.RemoveRange(lines.Length, _lines.Count - lines.Length); + _changed.RemoveRange(lines.Length, _changed.Count - lines.Length); + _origin.RemoveRange(lines.Length, _origin.Count - lines.Length); + } + } + + /// 0-based index of the label line :name, or -1. + public int FindLabel(string name) + { + for (var i = 0; i < _lines.Count; i++) + { + if (_lines[i].Trim() == ":" + name) + return i; + } + return -1; + } + + private static IEnumerable NewFlags(int count) + { + for (var i = 0; i < count; i++) + yield return true; + } + } +} diff --git a/src/StudioAutoOpenNode.cs b/src/StudioAutoOpenNode.cs new file mode 100644 index 0000000..c8f5370 --- /dev/null +++ b/src/StudioAutoOpenNode.cs @@ -0,0 +1,36 @@ +namespace Cthangover.Mods.ScenarioStudio +{ + using Cthangover.Core.Bootstrap; + using Cthangover.Core.Scenes; + using Cthangover.Core.UI; + using Cthangover.Core.Utils; + using Godot; + + /// + /// Waits until boot is done and the game shell (its TopView toolbar) + /// is in the tree, toggles the studio UI entry exactly like the + /// toolbar button, then frees itself. Started by . + /// + public partial class StudioAutoOpenNode : Node + { + private const string EntryId = "studio"; + private const float PollSeconds = 0.5f; + + public StudioAutoOpenNode() + { + var timer = new Timer { WaitTime = PollSeconds, Autostart = true }; + AddChild(timer); + timer.Timeout += OnPoll; + } + + private void OnPoll() + { + if (!Boot.IsReady || UiHost.Instance == null || SceneContextNode.FindNode("TopView") == null) + return; + + AppServices.Current.Ui.ToggleEntry(EntryId); + GameLogger.Log("UI", "Scenario studio: dock opened by --studio"); + QueueFree(); + } + } +} diff --git a/src/StudioLaunchInitializer.cs b/src/StudioLaunchInitializer.cs new file mode 100644 index 0000000..842f17c --- /dev/null +++ b/src/StudioLaunchInitializer.cs @@ -0,0 +1,41 @@ +namespace Cthangover.Mods.ScenarioStudio +{ + using System.Linq; + using Cthangover.Core.Mods; + using Cthangover.Core.Utils; + using Godot; + + /// + /// Lifecycle entry of the studio mod. When resources are ready it mounts + /// the edit layer () and parks + /// at /root, so the debugger, the journal and the rollback snapshots + /// work from the first scene on, before the dock is ever opened. The launch + /// flag --studio additionally opens the dock with the first scene + /// (debug_scenario_studio.bat). Nodes are added deferred because + /// resources-ready fires before the shell scene exists. + /// + public class StudioLaunchInitializer : IModInitializer + { + private const string Flag = "--studio"; + + public void OnModLoaded(string modId) { } + + public void OnModResourcesReady() + { + if (StudioLive.Instance != null) + return; + + var args = OS.GetCmdlineUserArgs(); + var layer = new StudioLayer(args); + layer.Mount(); + + var root = (Godot.Engine.GetMainLoop() as SceneTree)?.Root; + root?.CallDeferred("add_child", new StudioLive(layer)); + + if (!args.Contains(Flag)) + return; + GameLogger.Log("UI", "Scenario studio: --studio given, the dock opens with the first scene"); + root?.CallDeferred("add_child", new StudioAutoOpenNode()); + } + } +} diff --git a/src/StudioPanel.cs b/src/StudioPanel.cs new file mode 100644 index 0000000..418417a --- /dev/null +++ b/src/StudioPanel.cs @@ -0,0 +1,693 @@ +namespace Cthangover.Mods.ScenarioStudio +{ + using System.Collections.Generic; + using Cthangover.Core.Bootstrap; + using Cthangover.Core.UI.Theming; + using Cthangover.Mods.Interface.Base.Lists; + using Cthangover.Mods.Interface.Theming; + using Godot; + + /// + /// The studio dock: a non-modal panel at the right edge over the running + /// game (manifest ui entry studio, own toolbar icon + /// ui/icons/studio.png). The root control spans the screen but + /// ignores the mouse, so the game on the left keeps its input; only the + /// Dock panel catches clicks. The dock sits in a Frame whose + /// header and edge/corner grips move and resize it (); + /// the geometry lives as long as the dock. The skeleton — header with the mode + /// switch, transport row, "where we are", tab row, scrolling body and + /// footer — lives in scenes/studio_panel.tscn; this behaviour dresses + /// it with the studio styles and refills the body for the active tab and + /// mode from a built off . + /// + /// The dock polls and redraws only when it + /// changed and the user is not typing (a focused field, the inline text + /// editor or an open prompt), so a running dialog never steals an edit. + /// Every action goes through ; the dock keeps only + /// view state (mode, tab, footer status) and the context of an open prompt. + /// + public partial class StudioPanel : Widget + { + private const string Root = "Frame/Dock/Column/"; + private const float PollSeconds = 0.4f; + private const long TargetEnd = 0; + private const long TargetLabelBase = 1000; + private const long TargetSceneBase = 2000; + private const int CheckpointSave = 100; + private const int CheckpointRestore = 101; + + private readonly StudioNowPage _nowPage = new(); + private readonly StudioNovicePage _novicePage = new(); + private readonly List _drags = new(); + private StudioMode _mode = StudioMode.Author; + private StudioTab _tab = StudioTab.Now; + private StudioSession _session = new(); + private string _status; + private int _shownVersion = -1; + private CodeEdit _editor; + private Node _prompt; + + private StudioCodeLine _promptLine; + private List _pickLabels = new(); + private List _pickScenes = new(); + + private PanelContainer _runState; + private Label _runStateText; + private Button _authorButton; + private Button _noviceButton; + private Button _pauseButton; + private Button _stepButton; + private Button _continueButton; + private MenuButton _restartButton; + private HFlowContainer _crumbs; + private HFlowContainer _scope; + private Button[] _tabs; + private VBoxContainer _content; + private Label _statusLabel; + private Button _editButton; + private Button _applyButton; + + /// Currently shown tab. + public StudioTab Tab => _tab; + + /// Currently shown mode. + public StudioMode Mode => _mode; + + /// The session the dock shows now. + public StudioSession Session => _session; + + private static StudioLive Live => StudioLive.Instance; + + protected override void OnceConstruct() + { + MouseFilter = MouseFilterEnum.Ignore; + + var frame = GetNode("Frame"); + var dock = GetNode("Frame/Dock"); + dock.AddThemeStyleboxOverride("panel", StudioStyles.Dock()); + _drags.Add(new StudioDockDrag(frame, dock, GetNode(Root + "Header"), StudioDockEdge.Move)); + _drags.Add(new StudioDockDrag(frame, dock, GetNode("Frame/LeftGrip"), StudioDockEdge.Left)); + _drags.Add(new StudioDockDrag(frame, dock, GetNode("Frame/RightGrip"), StudioDockEdge.Right)); + _drags.Add(new StudioDockDrag(frame, dock, GetNode("Frame/TopGrip"), StudioDockEdge.Top)); + _drags.Add(new StudioDockDrag(frame, dock, GetNode("Frame/BottomGrip"), StudioDockEdge.Bottom)); + _drags.Add(new StudioDockDrag(frame, dock, GetNode("Frame/TopLeftGrip"), StudioDockEdge.Top | StudioDockEdge.Left)); + _drags.Add(new StudioDockDrag(frame, dock, GetNode("Frame/TopRightGrip"), StudioDockEdge.Top | StudioDockEdge.Right)); + _drags.Add(new StudioDockDrag(frame, dock, GetNode("Frame/BottomLeftGrip"), StudioDockEdge.Bottom | StudioDockEdge.Left)); + _drags.Add(new StudioDockDrag(frame, dock, GetNode("Frame/BottomRightGrip"), StudioDockEdge.Bottom | StudioDockEdge.Right)); + foreach (var rule in new[] { "RuleHeader", "RuleTransport", "RuleContext", "RuleTabs", "RuleFooter" }) + GetNode(Root + rule).Color = UiTokens.Line; + + StudioStyles.Title(GetNode