init mod
This commit is contained in:
commit
7a088b25a9
58 files changed
+6053
No files matched your search
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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"]}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Cthangover.Mods.ScenarioStudio
|
||||
{
|
||||
/// <summary>An answer of the current question with its resolved outcome (<see cref="Target"/>: scene or action id).</summary>
|
||||
public sealed class StudioBranch
|
||||
{
|
||||
public StudioCodeLine Line { get; set; }
|
||||
public StudioOutcomeKind Kind { get; set; }
|
||||
public string Target { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace Cthangover.Mods.ScenarioStudio
|
||||
{
|
||||
/// <summary>
|
||||
/// 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. <see cref="Reason"/> spells out a skip or a shadowing (another
|
||||
/// mod won the same scenario name) in words.
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace Cthangover.Mods.ScenarioStudio
|
||||
{
|
||||
/// <summary>Outcome of a scene candidate as the dock shows it, the answer to "why did (not) it start".</summary>
|
||||
public enum StudioCandidateStatus
|
||||
{
|
||||
Running,
|
||||
Queued,
|
||||
Skipped,
|
||||
Completed,
|
||||
Shadowed,
|
||||
Failed,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace Cthangover.Mods.ScenarioStudio
|
||||
{
|
||||
/// <summary>
|
||||
/// One line of the scenario shown in the "Now" tab. <see cref="Text"/> is the
|
||||
/// DSL source for the author mode; the novice mode reads the structure
|
||||
/// instead: <see cref="Say"/> is what the player sees (a line, a question,
|
||||
/// an answer) and <see cref="Target"/> 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. <see cref="StudioMoment"/> 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.
|
||||
/// </summary>
|
||||
public sealed class StudioCodeLine
|
||||
{
|
||||
/// <summary>1-based line number in the scenario text (header included), as in <c>SourceLine</c> of actions.</summary>
|
||||
public int Number { get; set; }
|
||||
/// <summary>Number of this line in the text the running dialog was built from; 0 for a line added in the studio.</summary>
|
||||
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; }
|
||||
/// <summary>The running dialog has an action of this line, so the ▶ marker may be dragged here.</summary>
|
||||
public bool CanRunNext { get; set; }
|
||||
public bool IsNew { get; set; }
|
||||
public StudioText Error { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace Cthangover.Mods.ScenarioStudio
|
||||
{
|
||||
/// <summary>
|
||||
/// What a scenario line does, as <see cref="StudioLineReader"/> classifies it.
|
||||
/// The novice view only shows <see cref="Line"/>, <see cref="Choice"/> and
|
||||
/// <see cref="Option"/>; the rest is service lines it follows or hides.
|
||||
/// </summary>
|
||||
public enum StudioLineKind
|
||||
{
|
||||
Header,
|
||||
Comment,
|
||||
Light,
|
||||
Background,
|
||||
Line,
|
||||
Choice,
|
||||
Option,
|
||||
Label,
|
||||
Goto,
|
||||
Jump,
|
||||
Action,
|
||||
End,
|
||||
Command,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace Cthangover.Mods.ScenarioStudio
|
||||
{
|
||||
/// <summary>Category of a studio log entry, shown as its badge.</summary>
|
||||
public enum StudioLogCategory
|
||||
{
|
||||
Scene,
|
||||
Skip,
|
||||
Scenario,
|
||||
Stop,
|
||||
End,
|
||||
Edit,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Cthangover.Mods.ScenarioStudio
|
||||
{
|
||||
/// <summary>One row of the studio event log; <see cref="Time"/> is game-session time as text.</summary>
|
||||
public sealed class StudioLogEntry
|
||||
{
|
||||
public string Time { get; set; }
|
||||
public StudioLogCategory Category { get; set; }
|
||||
public StudioText Text { get; set; }
|
||||
public bool IsWarning { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace Cthangover.Mods.ScenarioStudio
|
||||
{
|
||||
/// <summary>A mod together with the way the studio may touch it; rendered as a scope badge.</summary>
|
||||
public sealed class StudioModScope
|
||||
{
|
||||
public string ModId { get; }
|
||||
public StudioScopeKind Kind { get; }
|
||||
|
||||
public StudioModScope(string modId, StudioScopeKind kind)
|
||||
{
|
||||
ModId = modId;
|
||||
Kind = kind;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace Cthangover.Mods.ScenarioStudio
|
||||
{
|
||||
/// <summary>
|
||||
/// Presentation mode of the studio dock. Both modes show the same session;
|
||||
/// only the "Now" tab renders it differently: <see cref="Author"/> as script
|
||||
/// text with a gutter, <see cref="Novice"/> as one card per line with plain
|
||||
/// wording, so a player who never saw the DSL can still follow and extend it.
|
||||
/// </summary>
|
||||
public enum StudioMode
|
||||
{
|
||||
Author,
|
||||
Novice,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
namespace Cthangover.Mods.ScenarioStudio
|
||||
{
|
||||
using System.Collections.Generic;
|
||||
|
||||
/// <summary>
|
||||
/// What the novice mode shows instead of a listing: the line on screen now
|
||||
/// (<see cref="Say"/>), the lines said right before it, and — for a
|
||||
/// question — every answer with the place it leads to. Service lines
|
||||
/// (lights, labels, <c>end</c>) never appear; an answer's
|
||||
/// <c>-> :label</c> is followed to the first meaningful line after the
|
||||
/// label (through <c>goto</c> hops), so <c>option → :exit → switch_scene</c>
|
||||
/// reads as "goes to the scene". An answer without a target or with a
|
||||
/// missing label is <see cref="StudioOutcomeKind.Broken"/>. When no dialog
|
||||
/// runs, the moment is the scenario's first line or question.
|
||||
/// </summary>
|
||||
public sealed class StudioMoment
|
||||
{
|
||||
public StudioCodeLine Say { get; private set; }
|
||||
public List<StudioCodeLine> Before { get; } = new();
|
||||
public List<StudioBranch> 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<StudioCodeLine> 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<StudioCodeLine> 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<StudioCodeLine> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Cthangover.Mods.ScenarioStudio
|
||||
{
|
||||
/// <summary>Role of a scene on the studio map relative to where the player is.</summary>
|
||||
public enum StudioNodeState
|
||||
{
|
||||
Here,
|
||||
Visited,
|
||||
Next,
|
||||
New,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace Cthangover.Mods.ScenarioStudio
|
||||
{
|
||||
/// <summary>Where an answer leads once its label jump is followed, as shown to a novice.</summary>
|
||||
public enum StudioOutcomeKind
|
||||
{
|
||||
Scene,
|
||||
Action,
|
||||
End,
|
||||
Continue,
|
||||
Broken,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace Cthangover.Mods.ScenarioStudio
|
||||
{
|
||||
/// <summary>The ways the dock can restart what it is debugging (the restart menu, in menu order).</summary>
|
||||
public enum StudioRestartMode
|
||||
{
|
||||
/// <summary>The scenario again from its first line; game state stays as it is now.</summary>
|
||||
Scenario,
|
||||
/// <summary>Game state rolled back to the scenario's start, then the scenario again.</summary>
|
||||
Rollback,
|
||||
/// <summary>The whole scene re-entered: its composition runs again.</summary>
|
||||
Scene,
|
||||
/// <summary>The scenario again from the label above the current line.</summary>
|
||||
FromLabel,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace Cthangover.Mods.ScenarioStudio
|
||||
{
|
||||
/// <summary>State of the dialog runtime as the dock header shows it.</summary>
|
||||
public enum StudioRunState
|
||||
{
|
||||
/// <summary>A scenario runs freely.</summary>
|
||||
Running,
|
||||
/// <summary>Pause is on: the next action will stop before it runs.</summary>
|
||||
Paused,
|
||||
/// <summary>Stopped before an action (pause or breakpoint) until continue/step.</summary>
|
||||
Held,
|
||||
/// <summary>No scenario dialog is running.</summary>
|
||||
Idle,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Cthangover.Mods.ScenarioStudio
|
||||
{
|
||||
/// <summary>A scene on the studio map with the mod that defines it; <see cref="Via"/> names the link (a command or a choice).</summary>
|
||||
public sealed class StudioSceneNode
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public StudioNodeState State { get; set; }
|
||||
public StudioModScope Scope { get; set; }
|
||||
public StudioText Via { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace Cthangover.Mods.ScenarioStudio
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public enum StudioScopeKind
|
||||
{
|
||||
/// <summary>Folder mod: edits are written into the mod itself.</summary>
|
||||
Own,
|
||||
/// <summary>Zip mod: read-only, edits go to the studio layer over it.</summary>
|
||||
Overlay,
|
||||
/// <summary>The studio layer of the current game that holds the player's edits.</summary>
|
||||
Layer,
|
||||
/// <summary>Compiled code of a mod: shown, never edited from the studio.</summary>
|
||||
ReadOnly,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
namespace Cthangover.Mods.ScenarioStudio
|
||||
{
|
||||
using System.Collections.Generic;
|
||||
|
||||
/// <summary>
|
||||
/// Everything the dock shows at one moment, built by
|
||||
/// <see cref="StudioSessionBuilder"/> 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.
|
||||
/// </summary>
|
||||
public sealed class StudioSession
|
||||
{
|
||||
public string SceneName { get; set; }
|
||||
public string ScenarioName { get; set; }
|
||||
|
||||
/// <summary>Label the current line belongs to; null before the first label.</summary>
|
||||
public string LabelName { get; set; }
|
||||
|
||||
public StudioRunState RunState { get; set; }
|
||||
|
||||
/// <summary>Mod (or layer) that owns the open scenario file; null without a scenario.</summary>
|
||||
public StudioModScope FileScope { get; set; }
|
||||
|
||||
/// <summary>Where edits are written: the file's own folder mod, or the studio layer.</summary>
|
||||
public StudioModScope EditScope { get; set; }
|
||||
|
||||
/// <summary>Unapplied edits of the open scenario.</summary>
|
||||
public int PendingCount { get; set; }
|
||||
|
||||
public List<StudioCodeLine> Lines { get; } = new();
|
||||
public List<StudioCandidate> Candidates { get; } = new();
|
||||
public List<StudioStateEntry> State { get; } = new();
|
||||
public List<StudioLogEntry> Log { get; } = new();
|
||||
public List<StudioSceneNode> Nodes { get; } = new();
|
||||
|
||||
public bool HasScenario => !string.IsNullOrEmpty(ScenarioName);
|
||||
|
||||
/// <summary>Index of the line the dialog stands on, or -1.</summary>
|
||||
public int CurrentIndex => Lines.FindIndex(IsCurrentLine);
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>select</c> and its options would
|
||||
/// cut the question short in the parser. Without a current line: the end.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Cthangover.Mods.ScenarioStudio
|
||||
{
|
||||
/// <summary>How a value of the "State" tab can be changed in place; <see cref="None"/> is read-only.</summary>
|
||||
public enum StudioStateEditor
|
||||
{
|
||||
None,
|
||||
DialogVariable,
|
||||
Inventory,
|
||||
Quest,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace Cthangover.Mods.ScenarioStudio
|
||||
{
|
||||
/// <summary>
|
||||
/// A key/value of game state in the "State" tab, grouped by its owner (time,
|
||||
/// quests, inventory…). Groups compare by <see cref="StudioText.Key"/>.
|
||||
/// <see cref="Id"/> is what <see cref="Editor"/> writes to (a variable name,
|
||||
/// item id, quest id).
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace Cthangover.Mods.ScenarioStudio
|
||||
{
|
||||
/// <summary>Tabs of the studio dock body, in the order of the tab row.</summary>
|
||||
public enum StudioTab
|
||||
{
|
||||
Now,
|
||||
Scene,
|
||||
State,
|
||||
Log,
|
||||
Map,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
namespace Cthangover.Mods.ScenarioStudio
|
||||
{
|
||||
using Cthangover.Core.Utils;
|
||||
using Godot;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <see cref="StudioText"/> (e.g. a scenario's own <c>key=</c> inside a
|
||||
/// studio phrase) and are resolved recursively; plain arguments are data —
|
||||
/// scene, scenario, mod and quest ids, numbers. <see cref="Raw"/> wraps such
|
||||
/// data where a text is expected.
|
||||
/// </summary>
|
||||
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<object>();
|
||||
}
|
||||
|
||||
private StudioText(string raw, bool _)
|
||||
{
|
||||
_raw = raw;
|
||||
Args = System.Array.Empty<object>();
|
||||
}
|
||||
|
||||
/// <summary>Untranslated data (an id or a number) used where a text is expected.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
namespace Cthangover.Mods.ScenarioStudio
|
||||
{
|
||||
using Cthangover.Core.UI.Theming;
|
||||
using Cthangover.Mods.Interface.Theming;
|
||||
using Godot;
|
||||
|
||||
/// <summary>
|
||||
/// "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".
|
||||
/// </summary>
|
||||
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";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
namespace Cthangover.Mods.ScenarioStudio
|
||||
{
|
||||
using System;
|
||||
using Cthangover.Core.UI.Theming;
|
||||
using Cthangover.Mods.Interface.Theming;
|
||||
using Godot;
|
||||
|
||||
/// <summary>
|
||||
/// "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.
|
||||
/// </summary>
|
||||
public static class StudioMapPage
|
||||
{
|
||||
public static void Build(VBoxContainer content, StudioSession session, Action<StudioActionButton> go, Action<StudioActionButton> newScene, Action<StudioActionButton> link, Action<StudioActionButton> 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<StudioActionButton> 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";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
namespace Cthangover.Mods.ScenarioStudio
|
||||
{
|
||||
using System;
|
||||
using Cthangover.Core.UI.Theming;
|
||||
using Cthangover.Mods.Interface.Theming;
|
||||
using Godot;
|
||||
|
||||
/// <summary>
|
||||
/// "Now" tab in the novice mode: the moment of the story rather than the
|
||||
/// script (<see cref="StudioMoment"/>) — 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.
|
||||
/// </summary>
|
||||
public sealed class StudioNovicePage
|
||||
{
|
||||
/// <summary>A new line is requested at the given text index.</summary>
|
||||
public event Action<int> LineRequested;
|
||||
|
||||
/// <summary>A new answer is requested at the given text index.</summary>
|
||||
public event Action<int> OptionRequested;
|
||||
|
||||
/// <summary>"Edit" on a line, question or answer.</summary>
|
||||
public event Action<StudioActionButton> EditRequested;
|
||||
|
||||
/// <summary>"Choose where" on an answer.</summary>
|
||||
public event Action<StudioActionButton> 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// "Now" tab in the author mode: the running scenario as script with a
|
||||
/// gutter (<see cref="StudioCodeLineView"/>) 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
|
||||
/// (<see cref="StudioNovicePage"/>) shows the same plate.
|
||||
/// <para/>
|
||||
/// Dragging the ▶ marker of the current line highlights the line under the
|
||||
/// mouse when the running dialog can continue from it
|
||||
/// (<see cref="StudioCodeLine.CanRunNext"/>) and on release asks for it
|
||||
/// with <see cref="NextLineRequested"/>; a drop anywhere else does nothing.
|
||||
/// <para/>
|
||||
/// One instance lives as long as the dock; <see cref="Build"/> refills the
|
||||
/// body and wires the controls to this instance's handlers.
|
||||
/// </summary>
|
||||
public sealed class StudioNowPage
|
||||
{
|
||||
/// <summary>A gutter click changed a breakpoint.</summary>
|
||||
public event Action<StudioCodeLine> BreakpointToggled;
|
||||
|
||||
/// <summary>One of the insert buttons was pressed.</summary>
|
||||
public event Action<StudioLineKind> InsertRequested;
|
||||
|
||||
/// <summary>The ▶ marker was dropped on a line the dialog can continue from.</summary>
|
||||
public event Action<StudioCodeLine> NextLineRequested;
|
||||
|
||||
private readonly List<StudioCodeLineView> _views = new();
|
||||
private StudioCodeLineView _dropTarget;
|
||||
private bool _dragging;
|
||||
|
||||
/// <summary>True while the ▶ marker is dragged: a redraw would drop the drag.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inline editor of the whole scenario text (the footer's "Edit text"),
|
||||
/// filling the body; the dock reads the text back when editing ends.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>"This change affects" plate: the edit scope and, when it differs, the mod it overrides.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>The row under <paramref name="at"/> (canvas coordinates) the dialog can continue from, or null.</summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
namespace Cthangover.Mods.ScenarioStudio
|
||||
{
|
||||
using System;
|
||||
using Cthangover.Core.UI.Theming;
|
||||
using Cthangover.Mods.Interface.Theming;
|
||||
using Godot;
|
||||
|
||||
/// <summary>
|
||||
/// "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.
|
||||
/// </summary>
|
||||
public static class StudioScenePage
|
||||
{
|
||||
public static void Build(VBoxContainer content, StudioSession session, Action<StudioActionButton> run, Action<StudioActionButton> reset, Action<StudioActionButton> 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<StudioActionButton> run, Action<StudioActionButton> 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";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
namespace Cthangover.Mods.ScenarioStudio
|
||||
{
|
||||
using System;
|
||||
using Cthangover.Core.UI.Theming;
|
||||
using Cthangover.Mods.Interface.Theming;
|
||||
using Godot;
|
||||
|
||||
/// <summary>
|
||||
/// "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.
|
||||
/// </summary>
|
||||
public static class StudioStatePage
|
||||
{
|
||||
public static void Build(VBoxContainer content, StudioSession session, Action<StudioStateEntry, string> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// The studio's <see cref="IDialogDebugger"/>: pause, step and breakpoints
|
||||
/// for scenario dialogs. Only dialogs shown in the <see cref="DialogBox"/>
|
||||
/// with a <see cref="ScenarioEvent"/> 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.
|
||||
/// <para/>
|
||||
/// "Pause" holds the dialog before the next action; "continue" and "step"
|
||||
/// let exactly one action pass the hold (<c>allowOnce</c>), step then holds
|
||||
/// again. The first action of every new scenario event raises
|
||||
/// <see cref="ScenarioStarted"/> before anything runs, which is when the
|
||||
/// rollback snapshot is taken.
|
||||
/// </summary>
|
||||
public sealed class StudioDebugger : IDialogDebugger
|
||||
{
|
||||
private readonly Dictionary<string, HashSet<int>> _breakpoints = new();
|
||||
private readonly HashSet<int> _executed = new();
|
||||
private int _allowOnce;
|
||||
|
||||
/// <summary>The scenario event the debugger last saw run.</summary>
|
||||
public ScenarioEvent Event { get; private set; }
|
||||
|
||||
public bool IsPaused { get; private set; }
|
||||
|
||||
/// <summary>Scenario line the dialog is held at, 0 when not held.</summary>
|
||||
public int HeldLine { get; private set; }
|
||||
|
||||
/// <summary>Raised before the first action of a new scenario event runs.</summary>
|
||||
public event Action<ScenarioEvent> ScenarioStarted;
|
||||
|
||||
/// <summary>Raised when the dialog is held at a line (pause or breakpoint).</summary>
|
||||
public event Action<int, bool> Held;
|
||||
|
||||
/// <summary>Raised after any change worth redrawing (an action ran, a hold, a toggle).</summary>
|
||||
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<int>();
|
||||
if (!lines.Remove(line))
|
||||
lines.Add(line);
|
||||
Changed?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>Turns pause on: the next action of a scenario dialog will be held.</summary>
|
||||
public void Pause()
|
||||
{
|
||||
IsPaused = true;
|
||||
Changed?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Turns pause off and lets the held action run (<paramref name="box"/>
|
||||
/// resumes it); without a hold it only clears the pause.
|
||||
/// </summary>
|
||||
public void Continue(DialogBox box)
|
||||
{
|
||||
IsPaused = false;
|
||||
ReleaseHold(box);
|
||||
Changed?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// "Set next statement" of an IDE: the action of scenario line
|
||||
/// <paramref name="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).
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Forgets the current event, e.g. before restarting it.</summary>
|
||||
public void Reset()
|
||||
{
|
||||
Event = null;
|
||||
HeldLine = 0;
|
||||
_allowOnce = 0;
|
||||
_executed.Clear();
|
||||
}
|
||||
|
||||
/// <summary>Queue index of the first action built from scenario line <paramref name="line"/>, -1 when none.</summary>
|
||||
public static int IndexOf(List<IActionCommand> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
namespace Cthangover.Mods.ScenarioStudio
|
||||
{
|
||||
using System.Collections.Generic;
|
||||
using Cthangover.Core.Events;
|
||||
using Cthangover.Core.Scenes;
|
||||
using Godot;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="StudioLive"/>; times are session time (mm:ss since
|
||||
/// the studio started).
|
||||
/// </summary>
|
||||
public sealed class StudioJournal
|
||||
{
|
||||
private const int MaxEntries = 300;
|
||||
|
||||
private readonly ulong _startMsec = Time.GetTicksMsec();
|
||||
private readonly Dictionary<string, SceneComposedEvent> _composed = new();
|
||||
|
||||
public List<StudioLogEntry> Entries { get; } = new();
|
||||
|
||||
/// <summary>Scenes in the order they were entered (repeats included).</summary>
|
||||
public List<string> 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);
|
||||
}
|
||||
|
||||
/// <summary>Last composition report of <paramref name="scene"/>, or null.</summary>
|
||||
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}";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// The studio's edit layer: a folder per game (<c>user://studio/<game>/</c>,
|
||||
/// or <c>--studio-layer=<dir></c>) laid out like a mod (<c>scenarios/</c>,
|
||||
/// <c>scenes/</c>) and mounted as content overlay <see cref="Id"/> through
|
||||
/// <see cref="ModOverlays"/>. 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.
|
||||
/// <para/>
|
||||
/// Edits of folder mods go into the mod itself (the author's workflow)
|
||||
/// unless <c>--studio-write=layer</c> forces the layer — tests use it to
|
||||
/// keep the repository untouched. <see cref="Export"/> turns the layer into
|
||||
/// a regular mod in <c>user://mods/</c>.
|
||||
/// </summary>
|
||||
public sealed class StudioLayer
|
||||
{
|
||||
public const string Id = "my_studio";
|
||||
private const string LayerArg = "--studio-layer=";
|
||||
private const string WriteArg = "--studio-write=layer";
|
||||
|
||||
/// <summary>Absolute path of the layer folder.</summary>
|
||||
public string RootPath { get; }
|
||||
|
||||
/// <summary>True when every edit goes to the layer, folder mods included.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Creates the folder if needed, mounts it as overlay and refreshes scenario and scene tables.</summary>
|
||||
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}");
|
||||
}
|
||||
|
||||
/// <summary>Writes a layer file (mod-relative path) and returns its absolute path.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates scene <paramref name="name"/> with the given backgrounds and a
|
||||
/// first scenario for it; returns the scenario name.
|
||||
/// </summary>
|
||||
public string CreateScene(string name, IReadOnlyList<string> 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);
|
||||
}
|
||||
|
||||
/// <summary>Creates a new scenario for scene <paramref name="scene"/> with one line; returns its name.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Scene names defined by the layer itself.</summary>
|
||||
public List<string> SceneNames()
|
||||
{
|
||||
var result = new List<string>();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Copies the layer into <c>user://mods/<modId>/</c> with a manifest, so
|
||||
/// the edits become a regular mod that can be added to a game's collection.
|
||||
/// Returns the folder written.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Makes the next scene composition and the scene table see changed scenario and scene files.</summary>
|
||||
public static void RefreshContent()
|
||||
{
|
||||
ScenarioDefs.Invalidate();
|
||||
ModManager.Instance.GetResolver<SceneDefinitionResolver>()?.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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
namespace Cthangover.Mods.ScenarioStudio
|
||||
{
|
||||
using System.Collections.Generic;
|
||||
using Cthangover.Mods.Dialog.Scenarios;
|
||||
|
||||
/// <summary>
|
||||
/// Reads scenario text into <see cref="StudioCodeLine"/>s for the dock: one
|
||||
/// entry per non-empty line, numbered like the parser numbers
|
||||
/// <c>SourceLine</c> (1-based, header included), classified by command
|
||||
/// with the player-visible text (<see cref="StudioCodeLine.Say"/>: the
|
||||
/// literal, or the line's own <c>key=</c> translated when shown) and the
|
||||
/// line's target. Lines are split by <see cref="ScenarioParser.SplitLine"/>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static class StudioLineReader
|
||||
{
|
||||
public static List<StudioCodeLine> Read(string text)
|
||||
{
|
||||
var result = new List<StudioCodeLine>();
|
||||
if (string.IsNullOrEmpty(text))
|
||||
return result;
|
||||
|
||||
var lines = text.Replace("\r\n", "\n").Split('\n');
|
||||
var headerEnd = HeaderEnd(lines);
|
||||
var labels = new HashSet<string>();
|
||||
|
||||
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<string>();
|
||||
var named = new Dictionary<string, string>();
|
||||
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<string> positional, Dictionary<string, string> 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<string> positional, Dictionary<string, string> 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<string> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// The studio's connection to the running game, parked at <c>/root</c> for
|
||||
/// the whole session (the dock comes and goes, this stays). It installs
|
||||
/// the <see cref="StudioDebugger"/> into the dialog runtime, keeps the
|
||||
/// <see cref="StudioJournal"/> fed from the event bus, owns the edit
|
||||
/// <see cref="StudioLayer"/>, the per-scenario <see cref="StudioWorkingCopy"/>s
|
||||
/// and the rollback <see cref="StudioSnapshot"/> 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.
|
||||
/// <para/>
|
||||
/// <see cref="Version"/> 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.
|
||||
/// </summary>
|
||||
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<string, StudioWorkingCopy> _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; }
|
||||
|
||||
/// <summary>Grows on every change worth redrawing.</summary>
|
||||
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<SceneEnteredEvent>(OnSceneEntered, priority: 50);
|
||||
ModEventBus.Subscribe<SceneComposedEvent>(OnSceneComposed);
|
||||
ModEventBus.Subscribe<DialogEndedEvent>(OnDialogEnded);
|
||||
}
|
||||
|
||||
public override void _ExitTree()
|
||||
{
|
||||
if (DialogRuntime.Debugger == Debugger)
|
||||
DialogRuntime.Debugger = null;
|
||||
ModEventBus.Unsubscribe<SceneEnteredEvent>(OnSceneEntered);
|
||||
ModEventBus.Unsubscribe<SceneComposedEvent>(OnSceneComposed);
|
||||
ModEventBus.Unsubscribe<DialogEndedEvent>(OnDialogEnded);
|
||||
if (Instance == this)
|
||||
Instance = null;
|
||||
}
|
||||
|
||||
// ── what is running ────────────────────────────────────────
|
||||
|
||||
public DialogBox Box => SceneContextNode.FindNode<DialogBox>("DialogBox");
|
||||
|
||||
public static string SceneName => SceneManager.GetCurrentSceneName();
|
||||
|
||||
/// <summary>The scenario in the dialog box, or the last one the debugger saw while it still exists.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>True while <see cref="CurrentEvent"/> is the dialog in the box.</summary>
|
||||
public bool IsCurrentRunning
|
||||
{
|
||||
get
|
||||
{
|
||||
var box = Box;
|
||||
return box != null && box.IsRunning && box.Locker == CurrentEvent;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Scenario line of the action the running dialog stands on; 0 when nothing runs.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>True when the running dialog has an action built from scenario line <paramref name="line"/>, so it can run next.</summary>
|
||||
public bool CanRunFrom(int line)
|
||||
{
|
||||
var queue = IsCurrentRunning ? Box.Runtime.Dialog?.Queue : null;
|
||||
return line > 0 && queue != null && StudioDebugger.IndexOf(queue, line) >= 0;
|
||||
}
|
||||
|
||||
/// <summary>Makes scenario line <paramref name="line"/> the next one to run (<see cref="StudioDebugger.SetNext"/>) and logs it.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Current text of a scenario event's file (edits applied so far), or its built-in text.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>The mod (or layer) a scenario file belongs to and how the studio may touch it.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Where an edit of a file in <paramref name="fileScope"/> is written.</summary>
|
||||
public StudioModScope EditScopeFor(StudioModScope fileScope)
|
||||
{
|
||||
if (fileScope.Kind == StudioScopeKind.Own && !Layer.ForceLayer)
|
||||
return fileScope;
|
||||
return new StudioModScope(StudioLayer.Id, StudioScopeKind.Layer);
|
||||
}
|
||||
|
||||
// ── edits ──────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Pending edits of a scenario, or null.</summary>
|
||||
public StudioWorkingCopy Copy(string scenarioName) =>
|
||||
scenarioName != null && _copies.TryGetValue(scenarioName, out var copy) ? copy : null;
|
||||
|
||||
/// <summary>Pending edits of the current scenario, created from its file on first use; null without a scenario.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Inserts lines before 0-based line <paramref name="index"/> of the current scenario.</summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>Points the answer at 0-based line <paramref name="index"/> to a scene (a label block leading there is added).</summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>Points the answer at 0-based line <paramref name="index"/> to a label of the scenario.</summary>
|
||||
public void TargetLabel(int index, string label)
|
||||
{
|
||||
var copy = EnsureCopy();
|
||||
if (copy == null)
|
||||
return;
|
||||
copy.Replace(index, StudioScriptEdits.WithTarget(copy.Line(index), label));
|
||||
Touch();
|
||||
}
|
||||
|
||||
/// <summary>Points the answer at 0-based line <paramref name="index"/> to the end of the scenario.</summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Starts scenario <paramref name="name"/> of the current scene now, stopping the running dialog.</summary>
|
||||
public bool RunNow(string name)
|
||||
{
|
||||
StopDialog();
|
||||
ForgetCompleted(name);
|
||||
return RunScenario(name, null, null);
|
||||
}
|
||||
|
||||
/// <summary>Removes <paramref name="name"/> from the one-run completion history.</summary>
|
||||
public void ForgetCompleted(string name)
|
||||
{
|
||||
var runtime = GameData.Instance?.Runtime;
|
||||
if (runtime == null)
|
||||
return;
|
||||
runtime.CompletedScenarioIds.Remove(name);
|
||||
runtime.LoadedCompletedScenarioIds?.Remove(name);
|
||||
Touch();
|
||||
}
|
||||
|
||||
/// <summary>Leaves the running dialog and enters <paramref name="scene"/> (a debugging teleport).</summary>
|
||||
public void GoToScene(string scene)
|
||||
{
|
||||
if (string.IsNullOrEmpty(scene))
|
||||
return;
|
||||
StopDialog();
|
||||
Debugger.Reset();
|
||||
GetNodeOrNull<SceneManager>("/root/SceneManager")?.SwitchScene(scene);
|
||||
Touch();
|
||||
}
|
||||
|
||||
private void StopDialog()
|
||||
{
|
||||
Debugger.Continue(null);
|
||||
Box?.Abort();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a fresh event for scenario <paramref name="name"/> from the
|
||||
/// current definitions (edits included), puts it on the scene's chain in
|
||||
/// place of <paramref name="replaced"/> 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>True when a manual checkpoint was saved in this session.</summary>
|
||||
public bool HasCheckpoint => _checkpoint != null;
|
||||
|
||||
/// <summary>Remembers the whole game state and the scene now, to come back to with <see cref="RestoreCheckpoint"/>.</summary>
|
||||
public void SaveCheckpoint()
|
||||
{
|
||||
_checkpoint = StudioSnapshot.Capture(null, SceneName);
|
||||
Journal.Add(StudioLogCategory.Edit, new StudioText("studio/log_text_checkpoint_saved", SceneName));
|
||||
Touch();
|
||||
}
|
||||
|
||||
/// <summary>Brings back the saved checkpoint: game state, then its scene entered again.</summary>
|
||||
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 ────────────────────────────────────────────
|
||||
|
||||
/// <summary>Creates a scene in the layer (backgrounds of the current scene) with a first scenario; returns false on a bad name.</summary>
|
||||
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<string> 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;
|
||||
}
|
||||
|
||||
/// <summary>Creates a new scenario for the current scene in the layer; returns its name.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Exports the layer as mod <paramref name="modId"/>; null on a bad id.</summary>
|
||||
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 ──────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Writes a value edited in the "State" tab back into the game.</summary>
|
||||
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++;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
namespace Cthangover.Mods.ScenarioStudio
|
||||
{
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using Cthangover.Mods.Dialog.Scenarios;
|
||||
using Godot;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <see cref="ScenarioParser.SplitLine"/> and keep its other arguments, so
|
||||
/// changing an answer's text does not lose its target and vice versa;
|
||||
/// a literal replaces a <c>key=</c>, because the new text is no longer the
|
||||
/// translated one.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary><paramref name="line"/> with its player-visible text replaced by <paramref name="text"/>.</summary>
|
||||
public static string WithSay(string line, string text)
|
||||
{
|
||||
var positional = new List<string>();
|
||||
var named = new Dictionary<string, string>();
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary><paramref name="line"/> (an answer or goto) pointing at label <paramref name="label"/>.</summary>
|
||||
public static string WithTarget(string line, string label)
|
||||
{
|
||||
var positional = new List<string>();
|
||||
var named = new Dictionary<string, string>();
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Makes sure the scenario has a label block that leads to scene
|
||||
/// <paramref name="scene"/> (<c>:to_scene</c> → <c>switch_scene</c> → <c>end</c>),
|
||||
/// appending it at the end when missing; returns the label name.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Makes sure the scenario has a label that just ends it; returns the label name.</summary>
|
||||
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<string> positional, Dictionary<string, string> 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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the <see cref="StudioSession"/> 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.
|
||||
/// </summary>
|
||||
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<string> QuestIds(List<StudioCodeLine> lines)
|
||||
{
|
||||
var seen = new HashSet<string>();
|
||||
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<string>();
|
||||
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<string>();
|
||||
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<string, List<SceneDefinition>> 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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
namespace Cthangover.Mods.ScenarioStudio
|
||||
{
|
||||
using System.Text.Json;
|
||||
using Cthangover.Core.Settings;
|
||||
using Cthangover.Core.Utils;
|
||||
|
||||
/// <summary>
|
||||
/// Game state at the moment a scenario started: a full save package
|
||||
/// (<see cref="SaveService.BuildPackage"/>) 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.
|
||||
/// </summary>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>Brings the game state back; false when the package could not be applied.</summary>
|
||||
public bool Restore()
|
||||
{
|
||||
var package = JsonSerializer.Deserialize<SavePackage>(_json, JsonOptionsProvider.ReadOptions);
|
||||
return package != null && SaveService.ApplyPackage(package, "studio snapshot of " + ScenarioName);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
namespace Cthangover.Mods.ScenarioStudio
|
||||
{
|
||||
using System.Collections.Generic;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed class StudioWorkingCopy
|
||||
{
|
||||
private readonly List<string> _lines;
|
||||
private readonly List<bool> _changed;
|
||||
private readonly List<int> _origin;
|
||||
|
||||
public string ScenarioName { get; }
|
||||
|
||||
public StudioWorkingCopy(string scenarioName, string text)
|
||||
{
|
||||
ScenarioName = scenarioName;
|
||||
_lines = new List<string>((text ?? string.Empty).Replace("\r\n", "\n").Split('\n'));
|
||||
_changed = new List<bool>(new bool[_lines.Count]);
|
||||
_origin = new List<int>(_lines.Count);
|
||||
for (var i = 0; i < _lines.Count; i++)
|
||||
_origin.Add(i + 1);
|
||||
}
|
||||
|
||||
/// <summary>Original (1-based) number of the displayed line <paramref name="number"/>; 0 for an added line.</summary>
|
||||
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;
|
||||
|
||||
/// <summary>Number of added or changed lines.</summary>
|
||||
public int PendingCount
|
||||
{
|
||||
get
|
||||
{
|
||||
var count = 0;
|
||||
foreach (var changed in _changed)
|
||||
{
|
||||
if (changed)
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Whether the line at 0-based <paramref name="index"/> was added or changed.</summary>
|
||||
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;
|
||||
|
||||
/// <summary>Inserts lines before 0-based <paramref name="index"/> (clamped to the text).</summary>
|
||||
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]);
|
||||
}
|
||||
|
||||
/// <summary>Appends lines at the end of the text.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>0-based index of the label line <c>:name</c>, or -1.</summary>
|
||||
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<bool> NewFlags(int count)
|
||||
{
|
||||
for (var i = 0; i < count; i++)
|
||||
yield return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Waits until boot is done and the game shell (its <c>TopView</c> toolbar)
|
||||
/// is in the tree, toggles the <c>studio</c> UI entry exactly like the
|
||||
/// toolbar button, then frees itself. Started by <see cref="StudioLaunchInitializer"/>.
|
||||
/// </summary>
|
||||
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<Node>("TopView") == null)
|
||||
return;
|
||||
|
||||
AppServices.Current.Ui.ToggleEntry(EntryId);
|
||||
GameLogger.Log("UI", "Scenario studio: dock opened by --studio");
|
||||
QueueFree();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
namespace Cthangover.Mods.ScenarioStudio
|
||||
{
|
||||
using System.Linq;
|
||||
using Cthangover.Core.Mods;
|
||||
using Cthangover.Core.Utils;
|
||||
using Godot;
|
||||
|
||||
/// <summary>
|
||||
/// Lifecycle entry of the studio mod. When resources are ready it mounts
|
||||
/// the edit layer (<see cref="StudioLayer"/>) and parks <see cref="StudioLive"/>
|
||||
/// at <c>/root</c>, so the debugger, the journal and the rollback snapshots
|
||||
/// work from the first scene on, before the dock is ever opened. The launch
|
||||
/// flag <c>--studio</c> additionally opens the dock with the first scene
|
||||
/// (<c>debug_scenario_studio.bat</c>). Nodes are added deferred because
|
||||
/// resources-ready fires before the shell scene exists.
|
||||
/// </summary>
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// The studio dock: a non-modal panel at the right edge over the running
|
||||
/// game (manifest <c>ui</c> entry <c>studio</c>, own toolbar icon
|
||||
/// <c>ui/icons/studio.png</c>). The root control spans the screen but
|
||||
/// ignores the mouse, so the game on the left keeps its input; only the
|
||||
/// <c>Dock</c> panel catches clicks. The dock sits in a <c>Frame</c> whose
|
||||
/// header and edge/corner grips move and resize it (<see cref="StudioDockDrag"/>);
|
||||
/// 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 <c>scenes/studio_panel.tscn</c>; this behaviour dresses
|
||||
/// it with the studio styles and refills the body for the active tab and
|
||||
/// mode from a <see cref="StudioSession"/> built off <see cref="StudioLive"/>.
|
||||
/// <para/>
|
||||
/// The dock polls <see cref="StudioLive.Version"/> 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 <see cref="StudioLive"/>; the dock keeps only
|
||||
/// view state (mode, tab, footer status) and the context of an open prompt.
|
||||
/// </summary>
|
||||
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<StudioDockDrag> _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<string> _pickLabels = new();
|
||||
private List<string> _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;
|
||||
|
||||
/// <summary>Currently shown tab.</summary>
|
||||
public StudioTab Tab => _tab;
|
||||
|
||||
/// <summary>Currently shown mode.</summary>
|
||||
public StudioMode Mode => _mode;
|
||||
|
||||
/// <summary>The session the dock shows now.</summary>
|
||||
public StudioSession Session => _session;
|
||||
|
||||
private static StudioLive Live => StudioLive.Instance;
|
||||
|
||||
protected override void OnceConstruct()
|
||||
{
|
||||
MouseFilter = MouseFilterEnum.Ignore;
|
||||
|
||||
var frame = GetNode<Control>("Frame");
|
||||
var dock = GetNode<PanelContainer>("Frame/Dock");
|
||||
dock.AddThemeStyleboxOverride("panel", StudioStyles.Dock());
|
||||
_drags.Add(new StudioDockDrag(frame, dock, GetNode<Control>(Root + "Header"), StudioDockEdge.Move));
|
||||
_drags.Add(new StudioDockDrag(frame, dock, GetNode<Control>("Frame/LeftGrip"), StudioDockEdge.Left));
|
||||
_drags.Add(new StudioDockDrag(frame, dock, GetNode<Control>("Frame/RightGrip"), StudioDockEdge.Right));
|
||||
_drags.Add(new StudioDockDrag(frame, dock, GetNode<Control>("Frame/TopGrip"), StudioDockEdge.Top));
|
||||
_drags.Add(new StudioDockDrag(frame, dock, GetNode<Control>("Frame/BottomGrip"), StudioDockEdge.Bottom));
|
||||
_drags.Add(new StudioDockDrag(frame, dock, GetNode<Control>("Frame/TopLeftGrip"), StudioDockEdge.Top | StudioDockEdge.Left));
|
||||
_drags.Add(new StudioDockDrag(frame, dock, GetNode<Control>("Frame/TopRightGrip"), StudioDockEdge.Top | StudioDockEdge.Right));
|
||||
_drags.Add(new StudioDockDrag(frame, dock, GetNode<Control>("Frame/BottomLeftGrip"), StudioDockEdge.Bottom | StudioDockEdge.Left));
|
||||
_drags.Add(new StudioDockDrag(frame, dock, GetNode<Control>("Frame/BottomRightGrip"), StudioDockEdge.Bottom | StudioDockEdge.Right));
|
||||
foreach (var rule in new[] { "RuleHeader", "RuleTransport", "RuleContext", "RuleTabs", "RuleFooter" })
|
||||
GetNode<ColorRect>(Root + rule).Color = UiTokens.Line;
|
||||
|
||||
StudioStyles.Title(GetNode<Label>(Root + "Header/Row/Title"), Tr("studio/title"));
|
||||
_runState = GetNode<PanelContainer>(Root + "Header/Row/RunState");
|
||||
_runStateText = GetNode<Label>(Root + "Header/Row/RunState/Text");
|
||||
|
||||
GetNode<PanelContainer>(Root + "Header/Row/ModeSwitch").AddThemeStyleboxOverride("panel", StudioStyles.SegmentWell());
|
||||
_authorButton = GetNode<Button>(Root + "Header/Row/ModeSwitch/Items/AuthorButton");
|
||||
_noviceButton = GetNode<Button>(Root + "Header/Row/ModeSwitch/Items/NoviceButton");
|
||||
StudioStyles.Segment(_authorButton, Tr("studio/mode_author"));
|
||||
StudioStyles.Segment(_noviceButton, Tr("studio/mode_novice"));
|
||||
_authorButton.Pressed += OnAuthorPressed;
|
||||
_noviceButton.Pressed += OnNovicePressed;
|
||||
|
||||
var close = GetNode<Button>(Root + "Header/Row/CloseButton");
|
||||
CthangoverTheme.ApplyIcon(close);
|
||||
close.TooltipText = Tr("studio/close");
|
||||
close.Pressed += OnClosePressed;
|
||||
|
||||
_pauseButton = GetNode<Button>(Root + "Transport/Row/PauseButton");
|
||||
StudioStyles.Transport(_pauseButton, Tr("studio/pause"));
|
||||
_pauseButton.Pressed += OnPausePressed;
|
||||
_stepButton = GetNode<Button>(Root + "Transport/Row/StepButton");
|
||||
StudioStyles.Transport(_stepButton, Tr("studio/step"));
|
||||
_stepButton.Pressed += OnStepPressed;
|
||||
_continueButton = GetNode<Button>(Root + "Transport/Row/ContinueButton");
|
||||
StudioStyles.Transport(_continueButton, Tr("studio/continue"));
|
||||
_continueButton.Pressed += OnContinuePressed;
|
||||
_restartButton = GetNode<MenuButton>(Root + "Transport/Row/RestartButton");
|
||||
BuildRestartMenu(_restartButton);
|
||||
|
||||
_crumbs = GetNode<HFlowContainer>(Root + "Context/Column/Crumbs");
|
||||
_scope = GetNode<HFlowContainer>(Root + "Context/Column/Scope");
|
||||
|
||||
_tabs = new[]
|
||||
{
|
||||
GetNode<Button>(Root + "Tabs/Row/NowTab"),
|
||||
GetNode<Button>(Root + "Tabs/Row/SceneTab"),
|
||||
GetNode<Button>(Root + "Tabs/Row/StateTab"),
|
||||
GetNode<Button>(Root + "Tabs/Row/LogTab"),
|
||||
GetNode<Button>(Root + "Tabs/Row/MapTab"),
|
||||
};
|
||||
StudioStyles.SmallTab(_tabs[(int)StudioTab.Now], Tr("studio/tab_now"));
|
||||
StudioStyles.SmallTab(_tabs[(int)StudioTab.Scene], Tr("studio/tab_scene"));
|
||||
StudioStyles.SmallTab(_tabs[(int)StudioTab.State], Tr("studio/tab_state"));
|
||||
StudioStyles.SmallTab(_tabs[(int)StudioTab.Log], Tr("studio/tab_log"));
|
||||
StudioStyles.SmallTab(_tabs[(int)StudioTab.Map], Tr("studio/tab_map"));
|
||||
_tabs[(int)StudioTab.Now].Pressed += OnNowTabPressed;
|
||||
_tabs[(int)StudioTab.Scene].Pressed += OnSceneTabPressed;
|
||||
_tabs[(int)StudioTab.State].Pressed += OnStateTabPressed;
|
||||
_tabs[(int)StudioTab.Log].Pressed += OnLogTabPressed;
|
||||
_tabs[(int)StudioTab.Map].Pressed += OnMapTabPressed;
|
||||
|
||||
_content = GetNode<VBoxContainer>(Root + "Body/Scroll/Content");
|
||||
_nowPage.BreakpointToggled += OnBreakpointToggled;
|
||||
_nowPage.InsertRequested += OnInsertRequested;
|
||||
_nowPage.NextLineRequested += OnNextLineRequested;
|
||||
_novicePage.LineRequested += OnNoviceLineRequested;
|
||||
_novicePage.OptionRequested += OnNoviceOptionRequested;
|
||||
_novicePage.EditRequested += OnEditLineRequested;
|
||||
_novicePage.TargetRequested += OnTargetRequested;
|
||||
|
||||
_statusLabel = GetNode<Label>(Root + "Footer/Row/Status");
|
||||
_statusLabel.AddThemeFontSizeOverride("font_size", UiTokens.FontBodySmall);
|
||||
_statusLabel.AddThemeColorOverride("font_color", UiTokens.Faint);
|
||||
_editButton = GetNode<Button>(Root + "Footer/Row/EditButton");
|
||||
StudioStyles.SmallButton(_editButton, Tr("studio/edit_text"));
|
||||
_editButton.Pressed += OnEditPressed;
|
||||
_applyButton = GetNode<Button>(Root + "Footer/Row/ApplyButton");
|
||||
StudioStyles.SmallButton(_applyButton, Tr("studio/apply"), primary: true);
|
||||
_applyButton.Pressed += OnApplyPressed;
|
||||
|
||||
var timer = new Timer { WaitTime = PollSeconds, Autostart = true };
|
||||
AddChild(timer);
|
||||
timer.Timeout += OnPoll;
|
||||
|
||||
Refresh();
|
||||
}
|
||||
|
||||
/// <summary>Redraws at once whenever the dock is shown.</summary>
|
||||
protected override void ShowConstruct()
|
||||
{
|
||||
_shownVersion = -1;
|
||||
if (_content != null)
|
||||
Refresh();
|
||||
}
|
||||
|
||||
private void BuildRestartMenu(MenuButton button)
|
||||
{
|
||||
StudioStyles.Transport(button, Tr("studio/restart"));
|
||||
var popup = button.GetPopup();
|
||||
CthangoverTheme.Apply(popup);
|
||||
popup.AddItem(Tr("studio/restart_scenario"), (int)StudioRestartMode.Scenario);
|
||||
popup.AddItem(Tr("studio/restart_rollback"), (int)StudioRestartMode.Rollback);
|
||||
popup.AddItem(Tr("studio/restart_scene"), (int)StudioRestartMode.Scene);
|
||||
popup.AddItem(Tr("studio/restart_from_label"), (int)StudioRestartMode.FromLabel);
|
||||
popup.AddSeparator(Tr("studio/checkpoint"));
|
||||
popup.AddItem(Tr("studio/checkpoint_save"), CheckpointSave);
|
||||
popup.AddItem(Tr("studio/checkpoint_restore"), CheckpointRestore);
|
||||
popup.IdPressed += OnRestartPicked;
|
||||
}
|
||||
|
||||
// ── drawing ────────────────────────────────────────────────
|
||||
|
||||
private void OnPoll()
|
||||
{
|
||||
if (!Visible || Live == null || Live.Version == _shownVersion || IsBusy())
|
||||
return;
|
||||
Refresh();
|
||||
}
|
||||
|
||||
/// <summary>True while the user types, answers a prompt or drags the ▶ marker: redrawing would drop it.</summary>
|
||||
private bool IsBusy()
|
||||
{
|
||||
if (_editor != null || _nowPage.IsDragging || (_prompt != null && IsInstanceValid(_prompt)))
|
||||
return true;
|
||||
var focus = GetViewport()?.GuiGetFocusOwner();
|
||||
return focus is LineEdit or TextEdit && _content.IsAncestorOf(focus);
|
||||
}
|
||||
|
||||
/// <summary>Rebuilds the session from the live game and re-renders every dynamic part of the dock.</summary>
|
||||
public void Refresh()
|
||||
{
|
||||
if (Live == null)
|
||||
return;
|
||||
|
||||
_shownVersion = Live.Version;
|
||||
_session = StudioSessionBuilder.Build(Live);
|
||||
|
||||
RefreshHeader();
|
||||
RefreshContext();
|
||||
RefreshBody();
|
||||
RefreshFooter();
|
||||
}
|
||||
|
||||
private void RefreshHeader()
|
||||
{
|
||||
var state = _session.RunState;
|
||||
var stopped = state == StudioRunState.Paused || state == StudioRunState.Held;
|
||||
StudioStyles.RunState(_runState, _runStateText, state, Tr(RunStateKey(state)));
|
||||
_pauseButton.SetPressedNoSignal(Live.Debugger.IsPaused);
|
||||
_stepButton.Disabled = !_session.HasScenario;
|
||||
_continueButton.Disabled = !stopped;
|
||||
var restartMenu = _restartButton.GetPopup();
|
||||
for (var i = 0; i < restartMenu.ItemCount; i++)
|
||||
{
|
||||
var id = restartMenu.GetItemId(i);
|
||||
if (id < CheckpointSave && !restartMenu.IsItemSeparator(i))
|
||||
restartMenu.SetItemDisabled(i, !_session.HasScenario);
|
||||
else if (id == CheckpointRestore)
|
||||
restartMenu.SetItemDisabled(i, !Live.HasCheckpoint);
|
||||
}
|
||||
_authorButton.SetPressedNoSignal(_mode == StudioMode.Author);
|
||||
_noviceButton.SetPressedNoSignal(_mode == StudioMode.Novice);
|
||||
for (var i = 0; i < _tabs.Length; i++)
|
||||
_tabs[i].SetPressedNoSignal(i == (int)_tab);
|
||||
_tabs[(int)StudioTab.Scene].Text = _session.Candidates.Count > 0
|
||||
? $"{Tr("studio/tab_scene")} {_session.Candidates.Count}"
|
||||
: Tr("studio/tab_scene");
|
||||
}
|
||||
|
||||
private static string RunStateKey(StudioRunState state)
|
||||
{
|
||||
switch (state)
|
||||
{
|
||||
case StudioRunState.Running:
|
||||
return "studio/state_running";
|
||||
case StudioRunState.Paused:
|
||||
return "studio/state_paused";
|
||||
case StudioRunState.Held:
|
||||
return "studio/state_held";
|
||||
default:
|
||||
return "studio/state_idle";
|
||||
}
|
||||
}
|
||||
|
||||
private void RefreshContext()
|
||||
{
|
||||
Clear(_crumbs);
|
||||
AddCrumb(Tr("studio/crumb_scene"), _session.SceneName ?? "—");
|
||||
if (_session.HasScenario)
|
||||
AddCrumb(Tr("studio/crumb_scenario"), _session.ScenarioName);
|
||||
if (!string.IsNullOrEmpty(_session.LabelName))
|
||||
AddCrumb(null, _session.LabelName);
|
||||
|
||||
Clear(_scope);
|
||||
if (_session.FileScope == null)
|
||||
return;
|
||||
_scope.AddChild(StudioStyles.Scope(_session.FileScope));
|
||||
if (_session.EditScope != null && _session.EditScope.ModId != _session.FileScope.ModId)
|
||||
{
|
||||
var hint = StudioStyles.Text(string.Format(Tr("studio/edits_go_to"), _session.EditScope.ModId), UiTokens.FontSmall, UiTokens.Faint);
|
||||
hint.VerticalAlignment = VerticalAlignment.Center;
|
||||
_scope.AddChild(hint);
|
||||
}
|
||||
}
|
||||
|
||||
private void AddCrumb(string key, string value)
|
||||
{
|
||||
if (_crumbs.GetChildCount() > 0)
|
||||
_crumbs.AddChild(StudioStyles.Text("›", UiTokens.FontBodySmall, UiTokens.Faint));
|
||||
if (key != null)
|
||||
_crumbs.AddChild(StudioStyles.Text(key, UiTokens.FontSmall, UiTokens.Faint));
|
||||
_crumbs.AddChild(StudioStyles.Text(value, UiTokens.FontBodySmall, UiTokens.Body, mono: true));
|
||||
}
|
||||
|
||||
private void RefreshBody()
|
||||
{
|
||||
if (_editor != null)
|
||||
return;
|
||||
|
||||
Clear(_content);
|
||||
switch (_tab)
|
||||
{
|
||||
case StudioTab.Now when _mode == StudioMode.Author:
|
||||
_nowPage.Build(_content, _session);
|
||||
break;
|
||||
case StudioTab.Now:
|
||||
_novicePage.Build(_content, _session);
|
||||
break;
|
||||
case StudioTab.Scene:
|
||||
StudioScenePage.Build(_content, _session, OnRunCandidate, OnResetCandidate, OnNewScenario);
|
||||
break;
|
||||
case StudioTab.State:
|
||||
StudioStatePage.Build(_content, _session, OnStateSubmitted);
|
||||
break;
|
||||
case StudioTab.Log:
|
||||
StudioLogPage.Build(_content, _session);
|
||||
break;
|
||||
case StudioTab.Map:
|
||||
StudioMapPage.Build(_content, _session, OnGoToScene, OnNewScene, OnLinkScene, OnExport);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void RefreshFooter()
|
||||
{
|
||||
var pending = _session.PendingCount;
|
||||
_statusLabel.Text = _status ?? (pending == 0 ? Tr("studio/no_pending") : string.Format(Tr("studio/pending"), pending));
|
||||
_applyButton.Disabled = pending == 0 && _editor == null;
|
||||
_editButton.Visible = _mode == StudioMode.Author;
|
||||
_editButton.Disabled = !_session.HasScenario;
|
||||
_editButton.Text = Tr(_editor != null ? "studio/edit_done" : "studio/edit_text");
|
||||
}
|
||||
|
||||
private static void Clear(Node container)
|
||||
{
|
||||
foreach (var child in container.GetChildren())
|
||||
{
|
||||
container.RemoveChild(child);
|
||||
child.QueueFree();
|
||||
}
|
||||
}
|
||||
|
||||
private void SetStatus(string text)
|
||||
{
|
||||
_status = text;
|
||||
_statusLabel.Text = text;
|
||||
}
|
||||
|
||||
private void Changed()
|
||||
{
|
||||
_status = null;
|
||||
Refresh();
|
||||
}
|
||||
|
||||
// ── header, tabs, transport ────────────────────────────────
|
||||
|
||||
private void ShowTab(StudioTab tab)
|
||||
{
|
||||
CloseEditor();
|
||||
_tab = tab;
|
||||
Changed();
|
||||
}
|
||||
|
||||
private void ShowMode(StudioMode mode)
|
||||
{
|
||||
CloseEditor();
|
||||
_mode = mode;
|
||||
_tab = StudioTab.Now;
|
||||
Changed();
|
||||
}
|
||||
|
||||
private void OnAuthorPressed() => ShowMode(StudioMode.Author);
|
||||
|
||||
private void OnNovicePressed() => ShowMode(StudioMode.Novice);
|
||||
|
||||
private void OnNowTabPressed() => ShowTab(StudioTab.Now);
|
||||
|
||||
private void OnSceneTabPressed() => ShowTab(StudioTab.Scene);
|
||||
|
||||
private void OnStateTabPressed() => ShowTab(StudioTab.State);
|
||||
|
||||
private void OnLogTabPressed() => ShowTab(StudioTab.Log);
|
||||
|
||||
private void OnMapTabPressed() => ShowTab(StudioTab.Map);
|
||||
|
||||
private void OnClosePressed() => Hide();
|
||||
|
||||
private void OnPausePressed()
|
||||
{
|
||||
if (Live.Debugger.IsPaused)
|
||||
Live.Debugger.Continue(Live.Box);
|
||||
else
|
||||
Live.Debugger.Pause();
|
||||
Changed();
|
||||
}
|
||||
|
||||
private void OnStepPressed()
|
||||
{
|
||||
var box = Live.Box;
|
||||
if (box != null && box.IsWaitingAnswer && !box.Runtime.IsHeld)
|
||||
{
|
||||
SetStatus(Tr("studio/step_needs_answer"));
|
||||
return;
|
||||
}
|
||||
Live.Debugger.Step(box);
|
||||
Changed();
|
||||
}
|
||||
|
||||
private void OnContinuePressed()
|
||||
{
|
||||
Live.Debugger.Continue(Live.Box);
|
||||
Changed();
|
||||
}
|
||||
|
||||
private void OnRestartPicked(long id)
|
||||
{
|
||||
CloseEditor();
|
||||
switch (id)
|
||||
{
|
||||
case CheckpointSave:
|
||||
Live.SaveCheckpoint();
|
||||
_status = Tr("studio/checkpoint_saved");
|
||||
Refresh();
|
||||
return;
|
||||
case CheckpointRestore:
|
||||
_status = Live.RestoreCheckpoint() ? Tr("studio/checkpoint_restored") : Tr("studio/checkpoint_none");
|
||||
Refresh();
|
||||
return;
|
||||
default:
|
||||
Live.Restart((StudioRestartMode)id);
|
||||
Changed();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ── author edits ───────────────────────────────────────────
|
||||
|
||||
private void OnBreakpointToggled(StudioCodeLine line)
|
||||
{
|
||||
if (line.SourceLine > 0)
|
||||
Live.Debugger.ToggleBreakpoint(_session.ScenarioName, line.SourceLine);
|
||||
Changed();
|
||||
}
|
||||
|
||||
private void OnNextLineRequested(StudioCodeLine line)
|
||||
{
|
||||
if (Live.SetNextLine(line.SourceLine))
|
||||
Changed();
|
||||
else
|
||||
SetStatus(Tr("studio/next_line_failed"));
|
||||
}
|
||||
|
||||
private void OnInsertRequested(StudioLineKind kind)
|
||||
{
|
||||
var at = _session.InsertionTextIndexAfterCurrent();
|
||||
switch (kind)
|
||||
{
|
||||
case StudioLineKind.Choice:
|
||||
Live.Insert(at, StudioScriptEdits.NewQuestion(), StudioScriptEdits.NewAnswer());
|
||||
Changed();
|
||||
break;
|
||||
case StudioLineKind.Jump:
|
||||
_promptLine = new StudioCodeLine { Number = at };
|
||||
OpenScenePick(OnInsertJumpPicked);
|
||||
break;
|
||||
default:
|
||||
Live.Insert(at, StudioScriptEdits.NewLine());
|
||||
Changed();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnInsertJumpPicked(long id)
|
||||
{
|
||||
var scene = PickedScene(id);
|
||||
if (scene != null && _promptLine != null)
|
||||
Live.Insert(_promptLine.Number, StudioScriptEdits.Jump(scene));
|
||||
_prompt = null;
|
||||
Changed();
|
||||
}
|
||||
|
||||
private void OnEditPressed()
|
||||
{
|
||||
if (_editor != null)
|
||||
{
|
||||
CloseEditor();
|
||||
Changed();
|
||||
return;
|
||||
}
|
||||
|
||||
var copy = Live.EnsureCopy();
|
||||
if (copy == null)
|
||||
return;
|
||||
_tab = StudioTab.Now;
|
||||
Clear(_content);
|
||||
_editor = StudioNowPage.BuildEditor(_content, copy.Text);
|
||||
_editor.GrabFocus();
|
||||
RefreshFooter();
|
||||
}
|
||||
|
||||
/// <summary>Takes the inline editor's text into the working copy and closes it.</summary>
|
||||
private void CloseEditor()
|
||||
{
|
||||
if (_editor == null)
|
||||
return;
|
||||
var text = _editor.Text;
|
||||
_editor = null;
|
||||
Live.ReplaceText(text);
|
||||
}
|
||||
|
||||
private void OnApplyPressed()
|
||||
{
|
||||
CloseEditor();
|
||||
var scope = Live.Apply();
|
||||
_status = scope == null ? Tr("studio/no_pending") : string.Format(Tr("studio/applied_to"), scope.ModId);
|
||||
Refresh();
|
||||
}
|
||||
|
||||
// ── novice edits ───────────────────────────────────────────
|
||||
|
||||
private void OnNoviceLineRequested(int at)
|
||||
{
|
||||
Live.Insert(at, StudioScriptEdits.NewLine());
|
||||
Changed();
|
||||
}
|
||||
|
||||
private void OnNoviceOptionRequested(int at)
|
||||
{
|
||||
Live.Insert(at, StudioScriptEdits.NewAnswer());
|
||||
Changed();
|
||||
}
|
||||
|
||||
private void OnEditLineRequested(StudioActionButton button)
|
||||
{
|
||||
_promptLine = button.Line;
|
||||
_prompt = StudioPrompt.AskText(this, Tr("studio/prompt_edit_title"), button.Line.Say?.Resolve(), OnLineTextEntered);
|
||||
}
|
||||
|
||||
private void OnLineTextEntered(string text)
|
||||
{
|
||||
if (_promptLine != null && !string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
var index = _promptLine.Number - 1;
|
||||
var copy = Live.EnsureCopy();
|
||||
if (copy != null)
|
||||
Live.ReplaceLine(index, StudioScriptEdits.WithSay(copy.Line(index), text.Trim()));
|
||||
}
|
||||
_prompt = null;
|
||||
Changed();
|
||||
}
|
||||
|
||||
/// <summary>"Choose where" for an answer: the end, the scenario's labels or any scene.</summary>
|
||||
private void OnTargetRequested(StudioActionButton button)
|
||||
{
|
||||
_promptLine = button.Line;
|
||||
var menu = StudioPrompt.Pick(this, OnTargetPicked);
|
||||
menu.AddItem(Tr("studio/target_end"), (int)TargetEnd);
|
||||
|
||||
_pickLabels = new List<string>();
|
||||
foreach (var line in _session.Lines)
|
||||
{
|
||||
if (line.Kind == StudioLineKind.Label)
|
||||
_pickLabels.Add(line.Target);
|
||||
}
|
||||
if (_pickLabels.Count > 0)
|
||||
menu.AddSeparator(Tr("studio/target_labels"));
|
||||
for (var i = 0; i < _pickLabels.Count; i++)
|
||||
menu.AddItem(":" + _pickLabels[i], (int)(TargetLabelBase + i));
|
||||
|
||||
AddScenes(menu);
|
||||
_prompt = menu;
|
||||
StudioPrompt.Open(menu);
|
||||
}
|
||||
|
||||
private void OnTargetPicked(long id)
|
||||
{
|
||||
if (_promptLine != null)
|
||||
{
|
||||
var index = _promptLine.Number - 1;
|
||||
if (id == TargetEnd)
|
||||
Live.TargetEnd(index);
|
||||
else if (id >= TargetSceneBase)
|
||||
Live.TargetScene(index, PickedScene(id));
|
||||
else if (id >= TargetLabelBase && id - TargetLabelBase < _pickLabels.Count)
|
||||
Live.TargetLabel(index, _pickLabels[(int)(id - TargetLabelBase)]);
|
||||
}
|
||||
_prompt = null;
|
||||
Changed();
|
||||
}
|
||||
|
||||
private void OpenScenePick(PopupMenu.IdPressedEventHandler picked)
|
||||
{
|
||||
var menu = StudioPrompt.Pick(this, picked);
|
||||
AddScenes(menu);
|
||||
_prompt = menu;
|
||||
StudioPrompt.Open(menu);
|
||||
}
|
||||
|
||||
private void AddScenes(PopupMenu menu)
|
||||
{
|
||||
_pickScenes = new List<string>();
|
||||
var scenes = AppServices.Current.SceneDefs.CollectAll();
|
||||
if (scenes != null)
|
||||
_pickScenes.AddRange(scenes.Keys);
|
||||
_pickScenes.Sort(System.StringComparer.Ordinal);
|
||||
menu.AddSeparator(Tr("studio/target_scenes"));
|
||||
for (var i = 0; i < _pickScenes.Count; i++)
|
||||
menu.AddItem(string.Format(Tr("studio/target_scene"), _pickScenes[i]), (int)(TargetSceneBase + i));
|
||||
}
|
||||
|
||||
private string PickedScene(long id)
|
||||
{
|
||||
var index = (int)(id - TargetSceneBase);
|
||||
return index >= 0 && index < _pickScenes.Count ? _pickScenes[index] : null;
|
||||
}
|
||||
|
||||
// ── scene, state, map ──────────────────────────────────────
|
||||
|
||||
private void OnRunCandidate(StudioActionButton button)
|
||||
{
|
||||
var started = Live.RunNow(button.Payload);
|
||||
_tab = StudioTab.Now;
|
||||
_status = started ? null : Tr("studio/run_failed");
|
||||
Refresh();
|
||||
}
|
||||
|
||||
private void OnResetCandidate(StudioActionButton button)
|
||||
{
|
||||
Live.ForgetCompleted(button.Payload);
|
||||
Changed();
|
||||
}
|
||||
|
||||
private void OnNewScenario(StudioActionButton button)
|
||||
{
|
||||
var name = Live.CreateScenario();
|
||||
_status = name == null ? null : string.Format(Tr("studio/scenario_created"), name);
|
||||
Refresh();
|
||||
}
|
||||
|
||||
private void OnStateSubmitted(StudioStateEntry entry, string value)
|
||||
{
|
||||
GetViewport()?.GuiReleaseFocus();
|
||||
Live.EditState(entry, value);
|
||||
Changed();
|
||||
}
|
||||
|
||||
private void OnGoToScene(StudioActionButton button)
|
||||
{
|
||||
Live.GoToScene(button.Payload);
|
||||
Changed();
|
||||
}
|
||||
|
||||
private void OnNewScene(StudioActionButton button)
|
||||
{
|
||||
_prompt = StudioPrompt.AskText(this, Tr("studio/prompt_scene_title"), "", OnSceneNameEntered);
|
||||
}
|
||||
|
||||
private void OnSceneNameEntered(string name)
|
||||
{
|
||||
_prompt = null;
|
||||
_status = Live.CreateScene(name) ? string.Format(Tr("studio/scene_created"), name.Trim().ToLowerInvariant()) : Tr("studio/bad_name");
|
||||
Refresh();
|
||||
}
|
||||
|
||||
private void OnLinkScene(StudioActionButton button)
|
||||
{
|
||||
_promptLine = new StudioCodeLine { Number = _session.InsertionTextIndexAfterCurrent() };
|
||||
OpenScenePick(OnInsertJumpPicked);
|
||||
}
|
||||
|
||||
private void OnExport(StudioActionButton button)
|
||||
{
|
||||
_prompt = StudioPrompt.AskText(this, Tr("studio/prompt_export_title"), "my_story", OnExportEntered);
|
||||
}
|
||||
|
||||
private void OnExportEntered(string modId)
|
||||
{
|
||||
_prompt = null;
|
||||
var path = Live.Export(modId);
|
||||
_status = path == null ? Tr("studio/bad_name") : string.Format(Tr("studio/exported"), modId.Trim().ToLowerInvariant());
|
||||
Refresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
namespace Cthangover.Mods.ScenarioStudio
|
||||
{
|
||||
using System;
|
||||
using Godot;
|
||||
|
||||
/// <summary>
|
||||
/// Small studio button that carries what it acts on — a scenario line, a
|
||||
/// scenario or scene name — and reports itself when pressed, so one dock
|
||||
/// handler serves every row without a closure per button.
|
||||
/// </summary>
|
||||
public partial class StudioActionButton : Button
|
||||
{
|
||||
/// <summary>Name the action applies to (scenario, scene), if any.</summary>
|
||||
public string Payload { get; set; }
|
||||
|
||||
/// <summary>Scenario line the action applies to, if any.</summary>
|
||||
public StudioCodeLine Line { get; set; }
|
||||
|
||||
/// <summary>Raised with the button itself when pressed.</summary>
|
||||
public event Action<StudioActionButton> Chosen;
|
||||
|
||||
public StudioActionButton()
|
||||
{
|
||||
Pressed += OnPressed;
|
||||
}
|
||||
|
||||
/// <summary>Small studio-styled button with a localized caption.</summary>
|
||||
public static StudioActionButton Create(string key, Action<StudioActionButton> chosen, bool primary = false)
|
||||
{
|
||||
var button = new StudioActionButton();
|
||||
StudioStyles.SmallButton(button, TranslationServer.Translate(key), primary);
|
||||
button.Chosen += chosen;
|
||||
return button;
|
||||
}
|
||||
|
||||
private void OnPressed() => Chosen?.Invoke(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
namespace Cthangover.Mods.ScenarioStudio
|
||||
{
|
||||
using System;
|
||||
using Cthangover.Core.UI.Theming;
|
||||
using Cthangover.Mods.Interface.Theming;
|
||||
using Godot;
|
||||
|
||||
/// <summary>
|
||||
/// One answer of the current question for the novice (<c>.ct-branch</c>):
|
||||
/// the answer text and, under it, where it leads — a scene, an action, the
|
||||
/// end of the scenario — with the label hop already followed. Every answer
|
||||
/// can be redirected ("Choose where"); a broken one says so in words and
|
||||
/// offers only that, as the gold action.
|
||||
/// </summary>
|
||||
public partial class StudioBranchView : PanelContainer
|
||||
{
|
||||
public bool IsBroken { get; }
|
||||
|
||||
public StudioBranchView(StudioBranch branch, Action<StudioActionButton> edit, Action<StudioActionButton> chooseTarget)
|
||||
{
|
||||
IsBroken = branch.Kind == StudioOutcomeKind.Broken;
|
||||
var isNew = branch.Line.IsNew;
|
||||
MouseFilter = MouseFilterEnum.Ignore;
|
||||
AddThemeStyleboxOverride("panel", StudioStyles.Branch(IsBroken, isNew));
|
||||
|
||||
var row = UiKit.Row(StudioStyles.BarGap);
|
||||
AddChild(row);
|
||||
if (isNew)
|
||||
row.AddChild(StudioStyles.Bar(UiTokens.Success));
|
||||
|
||||
var body = UiKit.Column(UiTokens.Space1);
|
||||
body.SizeFlagsHorizontal = SizeFlags.ExpandFill;
|
||||
row.AddChild(body);
|
||||
|
||||
var text = UiKit.Label(StudioSayView.Quote(branch.Line), UiTokens.FontBodyLarge, UiTokens.Ink);
|
||||
text.AutowrapMode = TextServer.AutowrapMode.WordSmart;
|
||||
body.AddChild(text);
|
||||
body.AddChild(BuildOutcome(branch));
|
||||
|
||||
var actions = UiKit.Column(UiTokens.Space1);
|
||||
actions.SizeFlagsVertical = SizeFlags.ShrinkCenter;
|
||||
row.AddChild(actions);
|
||||
|
||||
var target = StudioActionButton.Create("studio/choose_target", chooseTarget, primary: IsBroken);
|
||||
target.Line = branch.Line;
|
||||
actions.AddChild(target);
|
||||
if (IsBroken)
|
||||
return;
|
||||
|
||||
var editText = StudioActionButton.Create("studio/edit", edit);
|
||||
editText.Line = branch.Line;
|
||||
actions.AddChild(editText);
|
||||
}
|
||||
|
||||
private static Control BuildOutcome(StudioBranch branch)
|
||||
{
|
||||
var color = StudioStyles.OutcomeColor(branch.Kind);
|
||||
var row = UiKit.Row(UiTokens.Space2);
|
||||
row.AddChild(StudioStyles.Text("→", UiTokens.FontBodySmall, UiTokens.Faint));
|
||||
row.AddChild(StudioStyles.Text(TranslationServer.Translate(OutcomeKey(branch.Kind)), UiTokens.FontBodySmall, color));
|
||||
if (!string.IsNullOrEmpty(branch.Target))
|
||||
row.AddChild(StudioStyles.Text(branch.Target, UiTokens.FontBodySmall, color, mono: true));
|
||||
return row;
|
||||
}
|
||||
|
||||
private static string OutcomeKey(StudioOutcomeKind kind)
|
||||
{
|
||||
switch (kind)
|
||||
{
|
||||
case StudioOutcomeKind.Scene:
|
||||
return "studio/outcome_scene";
|
||||
case StudioOutcomeKind.Action:
|
||||
return "studio/outcome_action";
|
||||
case StudioOutcomeKind.End:
|
||||
return "studio/outcome_end";
|
||||
case StudioOutcomeKind.Continue:
|
||||
return "studio/outcome_continue";
|
||||
default:
|
||||
return "studio/outcome_broken";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
namespace Cthangover.Mods.ScenarioStudio
|
||||
{
|
||||
using System;
|
||||
using Cthangover.Core.UI.Theming;
|
||||
using Godot;
|
||||
|
||||
/// <summary>
|
||||
/// One script line of the author mode (<c>.ct-code__line</c>): gutter with
|
||||
/// the breakpoint dot, line number and the ▶ marker of the current line,
|
||||
/// then the coloured source. A click on the gutter toggles the breakpoint,
|
||||
/// like in an IDE. The ▶ marker of the current line is a handle of its own:
|
||||
/// dragging it onto another line is the IDE's "set next statement" — the
|
||||
/// view only reports where the mouse is (<see cref="MarkerDragged"/>,
|
||||
/// <see cref="MarkerDropped"/> in canvas coordinates); the page decides
|
||||
/// which line is under it. Godot keeps sending motion and release to the
|
||||
/// control the press started on, so the marker sees the whole drag even
|
||||
/// over other lines. A parse error is shown as a faint comment after the
|
||||
/// source so the line keeps its place. The source label has a fixed line
|
||||
/// height instead of fit-content: a fitted label without wrapping demands its
|
||||
/// full text width and would widen the whole dock for one long line.
|
||||
/// </summary>
|
||||
public partial class StudioCodeLineView : PanelContainer
|
||||
{
|
||||
private const int GutterWidth = 58;
|
||||
private const int LineHeight = 20;
|
||||
|
||||
private readonly StudioCodeLine _line;
|
||||
private readonly Control _gutter;
|
||||
private readonly Label _marker;
|
||||
private bool _dragging;
|
||||
|
||||
/// <summary>The line this row shows.</summary>
|
||||
public StudioCodeLine Line => _line;
|
||||
|
||||
/// <summary>The clickable gutter (breakpoint dot, number, marker).</summary>
|
||||
public Control Gutter => _gutter;
|
||||
|
||||
/// <summary>The ▶ marker of the current line, the handle dragged to choose the next line.</summary>
|
||||
public Control Marker => _marker;
|
||||
|
||||
/// <summary>Raised when the gutter is clicked.</summary>
|
||||
public event Action<StudioCodeLine> BreakpointToggled;
|
||||
|
||||
/// <summary>Raised while the marker is dragged, with the mouse position in canvas coordinates.</summary>
|
||||
public event Action<Vector2> MarkerDragged;
|
||||
|
||||
/// <summary>Raised when the dragged marker is released, with the mouse position in canvas coordinates.</summary>
|
||||
public event Action<Vector2> MarkerDropped;
|
||||
|
||||
public StudioCodeLineView(StudioCodeLine line)
|
||||
{
|
||||
_line = line;
|
||||
MouseFilter = MouseFilterEnum.Pass;
|
||||
AddThemeStyleboxOverride("panel", StudioStyles.CodeLine(line));
|
||||
|
||||
var row = new HBoxContainer { MouseFilter = MouseFilterEnum.Ignore };
|
||||
row.AddThemeConstantOverride("separation", 0);
|
||||
AddChild(row);
|
||||
|
||||
_gutter = new HBoxContainer { CustomMinimumSize = new Vector2(GutterWidth, 0), MouseFilter = MouseFilterEnum.Stop };
|
||||
_gutter.AddThemeConstantOverride("separation", 0);
|
||||
_gutter.MouseDefaultCursorShape = CursorShape.PointingHand;
|
||||
_gutter.GuiInput += OnGutterInput;
|
||||
row.AddChild(_gutter);
|
||||
|
||||
var dotSlot = new CenterContainer { CustomMinimumSize = new Vector2(14, 0), MouseFilter = MouseFilterEnum.Ignore };
|
||||
var dot = new Panel { CustomMinimumSize = new Vector2(8, 8), MouseFilter = MouseFilterEnum.Ignore };
|
||||
dot.AddThemeStyleboxOverride("panel", StudioStyles.Breakpoint(line.HasBreakpoint));
|
||||
dotSlot.AddChild(dot);
|
||||
_gutter.AddChild(dotSlot);
|
||||
|
||||
var current = line.IsCurrent;
|
||||
var number = StudioStyles.Text(line.Number.ToString(), UiTokens.FontBodySmall, current ? UiTokens.AccentBright : UiTokens.Faint, mono: true);
|
||||
number.CustomMinimumSize = new Vector2(28, 0);
|
||||
number.HorizontalAlignment = HorizontalAlignment.Right;
|
||||
_gutter.AddChild(number);
|
||||
|
||||
_marker = StudioStyles.Text(current ? " ▶" : "", UiTokens.FontTiny, UiTokens.AccentBright);
|
||||
_marker.CustomMinimumSize = new Vector2(16, 0);
|
||||
_marker.VerticalAlignment = VerticalAlignment.Center;
|
||||
if (current)
|
||||
{
|
||||
_marker.MouseFilter = MouseFilterEnum.Stop;
|
||||
_marker.MouseDefaultCursorShape = CursorShape.Drag;
|
||||
_marker.TooltipText = TranslationServer.Translate("studio/drag_next_line");
|
||||
_marker.GuiInput += OnMarkerInput;
|
||||
}
|
||||
_gutter.AddChild(_marker);
|
||||
|
||||
var source = new RichTextLabel
|
||||
{
|
||||
BbcodeEnabled = true,
|
||||
FitContent = false,
|
||||
CustomMinimumSize = new Vector2(0, LineHeight),
|
||||
ScrollActive = false,
|
||||
AutowrapMode = TextServer.AutowrapMode.Off,
|
||||
ClipContents = true,
|
||||
SizeFlagsHorizontal = SizeFlags.ExpandFill,
|
||||
MouseFilter = MouseFilterEnum.Ignore,
|
||||
Text = StudioSyntax.ToBbCode(line.Text) + ErrorSuffix(line),
|
||||
};
|
||||
source.AddThemeFontOverride("normal_font", StudioStyles.Mono);
|
||||
source.AddThemeFontSizeOverride("normal_font_size", UiTokens.FontBodySmall);
|
||||
source.AddThemeColorOverride("default_color", line.IsDone ? UiTokens.Muted : UiTokens.Body);
|
||||
if (line.IsDone)
|
||||
source.Modulate = new Color(1f, 1f, 1f, 0.6f);
|
||||
row.AddChild(source);
|
||||
}
|
||||
|
||||
private static string ErrorSuffix(StudioCodeLine line)
|
||||
{
|
||||
if (line.Error == null)
|
||||
return string.Empty;
|
||||
return $" [color=#{UiTokens.DangerText.ToHtml(false)}]# {line.Error.Resolve().Replace("[", "[lb]")}[/color]";
|
||||
}
|
||||
|
||||
/// <summary>Marks the row as the place the dragged marker would land (or clears the mark).</summary>
|
||||
public void ShowDropTarget(bool shown)
|
||||
{
|
||||
AddThemeStyleboxOverride("panel", shown ? StudioStyles.CodeLineDropTarget() : StudioStyles.CodeLine(_line));
|
||||
}
|
||||
|
||||
private void OnMarkerInput(InputEvent @event)
|
||||
{
|
||||
switch (@event)
|
||||
{
|
||||
case InputEventMouseButton { ButtonIndex: MouseButton.Left } mouse when mouse.Pressed:
|
||||
_dragging = true;
|
||||
_marker.AcceptEvent();
|
||||
break;
|
||||
case InputEventMouseButton { ButtonIndex: MouseButton.Left } mouse when _dragging:
|
||||
_dragging = false;
|
||||
_marker.AcceptEvent();
|
||||
MarkerDropped?.Invoke(_marker.GetGlobalTransform() * mouse.Position);
|
||||
break;
|
||||
case InputEventMouseMotion motion when _dragging:
|
||||
_marker.AcceptEvent();
|
||||
MarkerDragged?.Invoke(_marker.GetGlobalTransform() * motion.Position);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnGutterInput(InputEvent @event)
|
||||
{
|
||||
if (@event is not InputEventMouseButton mouse || !mouse.Pressed || mouse.ButtonIndex != MouseButton.Left)
|
||||
return;
|
||||
|
||||
_gutter.AcceptEvent();
|
||||
BreakpointToggled?.Invoke(_line);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
namespace Cthangover.Mods.ScenarioStudio
|
||||
{
|
||||
using Godot;
|
||||
|
||||
/// <summary>
|
||||
/// Mouse drag on one handle of the dock (<c>Frame</c> in
|
||||
/// <c>scenes/studio_panel.tscn</c>): the header moves the frame, the edge and
|
||||
/// corner grips resize it. The handles and their cursors live in the scene;
|
||||
/// this only listens to the handle's <c>GuiInput</c>.
|
||||
/// <para/>
|
||||
/// Each motion is measured from the press, not from the previous motion, so
|
||||
/// a drag that hits a limit (screen border, smallest size) does not leave
|
||||
/// the frame lagging behind the mouse once it comes back. The frame keeps
|
||||
/// its anchors (right edge, full height): <c>Position</c>/<c>Size</c> rewrite
|
||||
/// only the offsets, so a moved dock still follows the right edge when the
|
||||
/// window is resized. The smallest size is <see cref="StudioStyles.DockMinSize"/>
|
||||
/// or what the dock's content demands, whichever is larger — a narrower
|
||||
/// frame would let the panel overflow it.
|
||||
/// </summary>
|
||||
public sealed class StudioDockDrag
|
||||
{
|
||||
private readonly Control _frame;
|
||||
private readonly Control _dock;
|
||||
private readonly Control _handle;
|
||||
private readonly StudioDockEdge _edges;
|
||||
private bool _dragging;
|
||||
private Vector2 _pressedAt;
|
||||
private Rect2 _startRect;
|
||||
|
||||
public StudioDockDrag(Control frame, Control dock, Control handle, StudioDockEdge edges)
|
||||
{
|
||||
_frame = frame;
|
||||
_dock = dock;
|
||||
_handle = handle;
|
||||
_edges = edges;
|
||||
handle.GuiInput += OnInput;
|
||||
}
|
||||
|
||||
private void OnInput(InputEvent @event)
|
||||
{
|
||||
switch (@event)
|
||||
{
|
||||
case InputEventMouseButton { ButtonIndex: MouseButton.Left } mouse when mouse.Pressed:
|
||||
_dragging = true;
|
||||
_pressedAt = _handle.GetGlobalTransform() * mouse.Position;
|
||||
_startRect = new Rect2(_frame.Position, _frame.Size);
|
||||
_handle.AcceptEvent();
|
||||
break;
|
||||
case InputEventMouseButton { ButtonIndex: MouseButton.Left } when _dragging:
|
||||
_dragging = false;
|
||||
_handle.AcceptEvent();
|
||||
break;
|
||||
case InputEventMouseMotion motion when _dragging:
|
||||
Apply(_handle.GetGlobalTransform() * motion.Position - _pressedAt);
|
||||
_handle.AcceptEvent();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void Apply(Vector2 delta)
|
||||
{
|
||||
var bounds = _frame.GetParentAreaSize();
|
||||
var min = StudioStyles.DockMinSize.Max(_dock.GetCombinedMinimumSize());
|
||||
var left = _startRect.Position.X;
|
||||
var top = _startRect.Position.Y;
|
||||
var right = _startRect.End.X;
|
||||
var bottom = _startRect.End.Y;
|
||||
|
||||
if (_edges == StudioDockEdge.Move)
|
||||
{
|
||||
var dx = Mathf.Clamp(delta.X, -left, bounds.X - right);
|
||||
var dy = Mathf.Clamp(delta.Y, -top, bounds.Y - bottom);
|
||||
left += dx;
|
||||
right += dx;
|
||||
top += dy;
|
||||
bottom += dy;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (_edges.HasFlag(StudioDockEdge.Left))
|
||||
left = Mathf.Clamp(left + delta.X, 0, right - min.X);
|
||||
if (_edges.HasFlag(StudioDockEdge.Right))
|
||||
right = Mathf.Clamp(right + delta.X, left + min.X, bounds.X);
|
||||
if (_edges.HasFlag(StudioDockEdge.Top))
|
||||
top = Mathf.Clamp(top + delta.Y, 0, bottom - min.Y);
|
||||
if (_edges.HasFlag(StudioDockEdge.Bottom))
|
||||
bottom = Mathf.Clamp(bottom + delta.Y, top + min.Y, bounds.Y);
|
||||
}
|
||||
|
||||
_frame.Position = new Vector2(left, top);
|
||||
_frame.Size = new Vector2(right - left, bottom - top);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace Cthangover.Mods.ScenarioStudio
|
||||
{
|
||||
using System;
|
||||
|
||||
/// <summary>
|
||||
/// Edges of the dock a <see cref="StudioDockDrag"/> handle moves: one edge
|
||||
/// or a corner resizes, all four at once (<see cref="Move"/>) shift the
|
||||
/// dock without changing its size.
|
||||
/// </summary>
|
||||
[Flags]
|
||||
public enum StudioDockEdge
|
||||
{
|
||||
None = 0,
|
||||
Left = 1,
|
||||
Top = 2,
|
||||
Right = 4,
|
||||
Bottom = 8,
|
||||
Move = Left | Top | Right | Bottom,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
namespace Cthangover.Mods.ScenarioStudio
|
||||
{
|
||||
using System;
|
||||
using Cthangover.Core.UI.Theming;
|
||||
using Godot;
|
||||
|
||||
/// <summary>
|
||||
/// The dock's small modal questions: a one-line text input (edit a line,
|
||||
/// name a scene or an exported mod) and a pick list (where an answer
|
||||
/// leads, which scene to link). They are plain Godot popups themed like the
|
||||
/// game, added under the dock and freed when closed.
|
||||
/// </summary>
|
||||
public static class StudioPrompt
|
||||
{
|
||||
/// <summary>Asks for a line of text; <paramref name="done"/> gets it on OK.</summary>
|
||||
public static ConfirmationDialog AskText(Node parent, string title, string initial, Action<string> done)
|
||||
{
|
||||
var dialog = new ConfirmationDialog { Title = title, MinSize = new Vector2I(460, 0) };
|
||||
CthangoverTheme.Apply(dialog);
|
||||
dialog.OkButtonText = TranslationServer.Translate("studio/ok");
|
||||
dialog.CancelButtonText = TranslationServer.Translate("studio/cancel");
|
||||
|
||||
var field = new LineEdit { Text = initial ?? string.Empty, SelectAllOnFocus = true };
|
||||
dialog.AddChild(field);
|
||||
dialog.RegisterTextEnter(field);
|
||||
|
||||
var relay = new StudioPromptRelay(field, done);
|
||||
dialog.Confirmed += relay.OnConfirmed;
|
||||
dialog.Confirmed += dialog.QueueFree;
|
||||
dialog.Canceled += dialog.QueueFree;
|
||||
dialog.AddChild(relay);
|
||||
|
||||
parent.AddChild(dialog);
|
||||
dialog.PopupCentered();
|
||||
field.GrabFocus();
|
||||
return dialog;
|
||||
}
|
||||
|
||||
/// <summary>Shows a pick list under the mouse; <paramref name="picked"/> gets the item id.</summary>
|
||||
public static PopupMenu Pick(Node parent, PopupMenu.IdPressedEventHandler picked)
|
||||
{
|
||||
var menu = new PopupMenu();
|
||||
CthangoverTheme.Apply(menu);
|
||||
menu.IdPressed += picked;
|
||||
menu.PopupHide += menu.QueueFree;
|
||||
parent.AddChild(menu);
|
||||
return menu;
|
||||
}
|
||||
|
||||
/// <summary>Opens a pick list built with <see cref="Pick"/> at the mouse position.</summary>
|
||||
public static void Open(PopupMenu menu)
|
||||
{
|
||||
menu.ResetSize();
|
||||
menu.Position = (Vector2I)menu.GetViewport().GetMousePosition();
|
||||
menu.Popup();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace Cthangover.Mods.ScenarioStudio
|
||||
{
|
||||
using System;
|
||||
using Godot;
|
||||
|
||||
/// <summary>
|
||||
/// Hands the text field of a <see cref="StudioPrompt.AskText"/> dialog to its
|
||||
/// callback on OK. A node child of the dialog, so it lives and dies with it
|
||||
/// and the dialog needs no closure over the field.
|
||||
/// </summary>
|
||||
public partial class StudioPromptRelay : Node
|
||||
{
|
||||
private readonly LineEdit _field;
|
||||
private readonly Action<string> _done;
|
||||
|
||||
public StudioPromptRelay(LineEdit field, Action<string> done)
|
||||
{
|
||||
_field = field;
|
||||
_done = done;
|
||||
}
|
||||
|
||||
public void OnConfirmed() => _done?.Invoke(_field.Text);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
namespace Cthangover.Mods.ScenarioStudio
|
||||
{
|
||||
using System;
|
||||
using Cthangover.Core.UI.Theming;
|
||||
using Cthangover.Mods.Interface.Theming;
|
||||
using Godot;
|
||||
|
||||
/// <summary>
|
||||
/// Novice card of a line the player reads (<c>.ct-say</c>): what kind of line
|
||||
/// it is (a question or a line, "new" while unapplied) and its text, with
|
||||
/// "Edit" on the right. The line on screen is the gold card; lines said
|
||||
/// right before it are plain cards with the green bar.
|
||||
/// </summary>
|
||||
public partial class StudioSayView : PanelContainer
|
||||
{
|
||||
public StudioSayView(StudioCodeLine line, bool before, Action<StudioActionButton> edit)
|
||||
{
|
||||
MouseFilter = MouseFilterEnum.Ignore;
|
||||
AddThemeStyleboxOverride("panel", StudioStyles.Say(before));
|
||||
|
||||
var row = UiKit.Row(StudioStyles.BarGap);
|
||||
AddChild(row);
|
||||
row.AddChild(StudioStyles.Bar(before ? UiTokens.Success : UiTokens.Accent));
|
||||
|
||||
var body = UiKit.Column(UiTokens.Space1);
|
||||
body.SizeFlagsHorizontal = SizeFlags.ExpandFill;
|
||||
row.AddChild(body);
|
||||
|
||||
var kind = TranslationServer.Translate(line.Kind == StudioLineKind.Choice ? "studio/say_question" : "studio/say_line").ToString();
|
||||
if (line.IsNew)
|
||||
kind = string.Format(TranslationServer.Translate("studio/say_new"), kind);
|
||||
var caption = StudioStyles.Text(kind, UiTokens.FontTiny, before ? UiTokens.Faint : UiTokens.AccentDim);
|
||||
caption.Uppercase = true;
|
||||
body.AddChild(caption);
|
||||
|
||||
var text = UiKit.Label(Quote(line), before ? UiTokens.FontBodyLarge : UiTokens.FontLead, before ? UiTokens.Ink : UiTokens.InkBright);
|
||||
text.AutowrapMode = TextServer.AutowrapMode.WordSmart;
|
||||
body.AddChild(text);
|
||||
|
||||
var button = StudioActionButton.Create("studio/edit", edit);
|
||||
button.Line = line;
|
||||
button.SizeFlagsVertical = SizeFlags.ShrinkBegin;
|
||||
row.AddChild(button);
|
||||
}
|
||||
|
||||
/// <summary>The player-visible text in quotes, or an empty placeholder for a line that has none.</summary>
|
||||
public static string Quote(StudioCodeLine line)
|
||||
{
|
||||
var text = line.Say?.Resolve();
|
||||
return string.Format(TranslationServer.Translate("studio/quote"), string.IsNullOrEmpty(text) ? "…" : text);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
namespace Cthangover.Mods.ScenarioStudio
|
||||
{
|
||||
using System;
|
||||
using Cthangover.Core.UI.Theming;
|
||||
using Godot;
|
||||
|
||||
/// <summary>
|
||||
/// Key/value row of the "State" tab. Editable values (dialog variables,
|
||||
/// item counts, quest status) are written back when the field is submitted
|
||||
/// with Enter; the rest is shown read-only.
|
||||
/// </summary>
|
||||
public partial class StudioStateRow : HBoxContainer
|
||||
{
|
||||
private const int KeyWidth = 170;
|
||||
|
||||
private readonly StudioStateEntry _entry;
|
||||
|
||||
/// <summary>Raised with the entry and the submitted text.</summary>
|
||||
public event Action<StudioStateEntry, string> Submitted;
|
||||
|
||||
public StudioStateRow(StudioStateEntry entry)
|
||||
{
|
||||
_entry = entry;
|
||||
MouseFilter = MouseFilterEnum.Ignore;
|
||||
AddThemeConstantOverride("separation", UiTokens.Space3);
|
||||
|
||||
var key = StudioStyles.Text(entry.Key.Resolve(), UiTokens.FontBodySmall, UiTokens.ChipKey, mono: true);
|
||||
key.CustomMinimumSize = new Vector2(KeyWidth, 0);
|
||||
key.ClipText = true;
|
||||
key.VerticalAlignment = VerticalAlignment.Center;
|
||||
AddChild(key);
|
||||
|
||||
var value = new LineEdit
|
||||
{
|
||||
Text = entry.Value.Resolve(),
|
||||
Editable = entry.Editor != StudioStateEditor.None,
|
||||
SizeFlagsHorizontal = SizeFlags.ExpandFill,
|
||||
};
|
||||
value.AddThemeFontOverride("font", StudioStyles.Mono);
|
||||
value.AddThemeFontSizeOverride("font_size", UiTokens.FontBodySmall);
|
||||
value.TextSubmitted += OnSubmitted;
|
||||
AddChild(value);
|
||||
}
|
||||
|
||||
private void OnSubmitted(string text) => Submitted?.Invoke(_entry, text);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
namespace Cthangover.Mods.ScenarioStudio
|
||||
{
|
||||
using Cthangover.Core.UI.Theming;
|
||||
using Cthangover.Mods.Interface.Theming;
|
||||
using Cthangover.Mods.Interface.Tool;
|
||||
using Godot;
|
||||
|
||||
/// <summary>
|
||||
/// Godot counterparts of the studio components of the style guide
|
||||
/// (<c>mockups/style_guide</c>, section <c>#studio</c>: <c>.ct-dock</c>,
|
||||
/// <c>.ct-scope</c>, <c>.ct-code</c>, <c>.ct-block</c>, <c>.ct-cand</c>…).
|
||||
/// Every colour comes from <see cref="UiTokens"/>; mod scenes ignore theme
|
||||
/// overrides, so the dock skeleton from the scene is dressed here and the
|
||||
/// data rows are built with the same helpers.
|
||||
/// <para/>
|
||||
/// Two deliberate deviations from the HTML guide, both recorded there:
|
||||
/// <c>StyleBoxFlat</c> has no dashed border, so the insert bar and new map
|
||||
/// nodes use a solid low-alpha border instead of a dashed one.
|
||||
/// </summary>
|
||||
public static class StudioStyles
|
||||
{
|
||||
/// <summary>Width of the dock; the scene offsets use the same value plus the 8px edge gap.</summary>
|
||||
public const int DockWidth = 480;
|
||||
|
||||
/// <summary>Smallest size the dock can be dragged to; its content may still demand more.</summary>
|
||||
public static readonly Vector2 DockMinSize = new(360, 320);
|
||||
|
||||
public static Font Mono => ToolWindow.GetMonospaceFont();
|
||||
|
||||
/// <summary>Dock surface: window surface, line border, large radius and the window shadow, without any backdrop.</summary>
|
||||
public static StyleBoxFlat Dock()
|
||||
{
|
||||
var style = UiKit.Frame(UiTokens.RadiusLg, UiTokens.Surface0, UiTokens.Line);
|
||||
style.ShadowColor = UiTokens.Alpha(Colors.Black, 0.6f);
|
||||
style.ShadowSize = 24;
|
||||
style.ShadowOffset = new Vector2(0, 12);
|
||||
return style;
|
||||
}
|
||||
|
||||
public static Label Title(Label label, string text)
|
||||
{
|
||||
label.Text = text.ToUpperInvariant();
|
||||
label.AddThemeFontSizeOverride("font_size", UiTokens.FontLead);
|
||||
label.AddThemeColorOverride("font_color", UiTokens.InkBright);
|
||||
return label;
|
||||
}
|
||||
|
||||
/// <summary>Monospace state plate: running green, paused and held warning yellow, idle muted.</summary>
|
||||
public static void RunState(PanelContainer plate, Label text, StudioRunState state, string caption)
|
||||
{
|
||||
var color = state switch
|
||||
{
|
||||
StudioRunState.Running => UiTokens.Success,
|
||||
StudioRunState.Idle => UiTokens.Muted,
|
||||
_ => UiTokens.Warning,
|
||||
};
|
||||
var style = state == StudioRunState.Idle
|
||||
? Chip(Colors.Transparent, UiTokens.Line)
|
||||
: Chip(UiTokens.BadgeFill(color), UiTokens.BadgeLine(color));
|
||||
plate.AddThemeStyleboxOverride("panel", style);
|
||||
text.Text = caption.ToUpperInvariant();
|
||||
text.AddThemeFontOverride("font", Mono);
|
||||
text.AddThemeFontSizeOverride("font_size", UiTokens.FontTiny);
|
||||
text.AddThemeColorOverride("font_color", color);
|
||||
}
|
||||
|
||||
/// <summary>Well behind the segmented mode switch.</summary>
|
||||
public static StyleBoxFlat SegmentWell()
|
||||
{
|
||||
var style = UiKit.Frame(UiTokens.RadiusSm, UiTokens.Well, UiTokens.Line);
|
||||
Pad(style, 2, 2);
|
||||
return style;
|
||||
}
|
||||
|
||||
/// <summary>Segment of the mode switch: transparent, the pressed one looks like the active tab.</summary>
|
||||
public static void Segment(Button button, string text)
|
||||
{
|
||||
button.Text = text;
|
||||
var normal = UiKit.Frame(UiTokens.RadiusXs, Colors.Transparent);
|
||||
Pad(normal, 10, 3);
|
||||
var hover = UiKit.Frame(UiTokens.RadiusXs, UiTokens.WhiteTrace);
|
||||
Pad(hover, 10, 3);
|
||||
var active = UiKit.Frame(UiTokens.RadiusXs, UiTokens.AccentSoft, UiTokens.AccentLine);
|
||||
Pad(active, 10, 3);
|
||||
ApplyStates(button, normal, hover, active);
|
||||
button.AddThemeFontSizeOverride("font_size", UiTokens.FontSmall);
|
||||
button.AddThemeColorOverride("font_color", UiTokens.Muted);
|
||||
button.AddThemeColorOverride("font_hover_color", UiTokens.Ink);
|
||||
button.AddThemeColorOverride("font_pressed_color", UiTokens.AccentBright);
|
||||
button.AddThemeColorOverride("font_hover_pressed_color", UiTokens.AccentBright);
|
||||
}
|
||||
|
||||
/// <summary>Transport button (pause/step/continue/restart): a compact secondary button; pressed means "on".</summary>
|
||||
public static void Transport(Button button, string text)
|
||||
{
|
||||
button.Text = text;
|
||||
var normal = UiKit.Frame(UiTokens.RadiusSm, UiTokens.Surface3, UiTokens.Line);
|
||||
Pad(normal, 10, 5);
|
||||
var hover = UiKit.Frame(UiTokens.RadiusSm, UiTokens.BtnHover, UiTokens.AccentLine);
|
||||
Pad(hover, 10, 5);
|
||||
var on = UiKit.Frame(UiTokens.RadiusSm, UiTokens.BtnPrimary, UiTokens.AccentLine);
|
||||
Pad(on, 10, 5);
|
||||
ApplyStates(button, normal, hover, on);
|
||||
button.AddThemeFontSizeOverride("font_size", UiTokens.FontBodySmall);
|
||||
button.AddThemeColorOverride("font_color", UiTokens.Body);
|
||||
button.AddThemeColorOverride("font_hover_color", UiTokens.InkBright);
|
||||
button.AddThemeColorOverride("font_pressed_color", UiTokens.AccentBright);
|
||||
button.AddThemeColorOverride("font_hover_pressed_color", UiTokens.AccentBright);
|
||||
}
|
||||
|
||||
/// <summary>Small tab (<c>.ct-tab--sm</c>): the tab variation with the compact paddings.</summary>
|
||||
public static void SmallTab(Button button, string text)
|
||||
{
|
||||
button.Text = text;
|
||||
var normal = UiKit.Frame(UiTokens.RadiusXs, Colors.Transparent);
|
||||
Pad(normal, 9, 4);
|
||||
var hover = UiKit.Frame(UiTokens.RadiusXs, UiTokens.WhiteTrace);
|
||||
Pad(hover, 9, 4);
|
||||
var active = UiKit.Frame(UiTokens.RadiusXs, UiTokens.AccentSoft, UiTokens.AccentLine);
|
||||
Pad(active, 9, 4);
|
||||
ApplyStates(button, normal, hover, active);
|
||||
button.AddThemeFontSizeOverride("font_size", UiTokens.FontSmall);
|
||||
button.AddThemeColorOverride("font_color", UiTokens.Muted);
|
||||
button.AddThemeColorOverride("font_hover_color", UiTokens.Ink);
|
||||
button.AddThemeColorOverride("font_pressed_color", UiTokens.AccentBright);
|
||||
button.AddThemeColorOverride("font_hover_pressed_color", UiTokens.AccentBright);
|
||||
}
|
||||
|
||||
/// <summary>Small action button inside the body (<c>.ct-btn--sm</c>); primary turns it gold.</summary>
|
||||
public static Button SmallButton(Button button, string text, bool primary = false)
|
||||
{
|
||||
button.Text = text;
|
||||
var background = primary ? UiTokens.BtnPrimary : UiTokens.Surface3;
|
||||
var border = primary ? UiTokens.AccentLine : UiTokens.Line;
|
||||
var normal = UiKit.Frame(UiTokens.RadiusSm, background, border);
|
||||
Pad(normal, 12, 6);
|
||||
var hover = UiKit.Frame(UiTokens.RadiusSm, primary ? UiTokens.BtnPrimaryHover : UiTokens.BtnHover, primary ? UiTokens.AccentLineStrong : UiTokens.AccentLine);
|
||||
Pad(hover, 12, 6);
|
||||
ApplyStates(button, normal, hover, hover);
|
||||
button.FocusMode = Control.FocusModeEnum.None;
|
||||
button.AddThemeFontSizeOverride("font_size", UiTokens.FontBodySmall);
|
||||
button.AddThemeColorOverride("font_color", primary ? UiTokens.AccentBright : UiTokens.Body);
|
||||
button.AddThemeColorOverride("font_hover_color", primary ? UiTokens.AccentHover : UiTokens.InkBright);
|
||||
button.AddThemeColorOverride("font_pressed_color", primary ? UiTokens.AccentHover : UiTokens.InkBright);
|
||||
return button;
|
||||
}
|
||||
|
||||
public static Label Text(string text, int size, Color color, bool mono = false)
|
||||
{
|
||||
var label = UiKit.Label(text, size, color);
|
||||
if (mono)
|
||||
label.AddThemeFontOverride("font", Mono);
|
||||
return label;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mod scope badge (<c>.ct-scope</c>): caps kind caption plus the mono mod id,
|
||||
/// coloured by how the studio may touch the mod.
|
||||
/// </summary>
|
||||
public static Control Scope(StudioModScope scope)
|
||||
{
|
||||
var color = ScopeColor(scope.Kind);
|
||||
var style = scope.Kind == StudioScopeKind.ReadOnly
|
||||
? Chip(Colors.Transparent, UiTokens.Line)
|
||||
: Chip(UiTokens.BadgeFill(color), UiTokens.BadgeLine(color));
|
||||
|
||||
var badge = new PanelContainer { MouseFilter = Control.MouseFilterEnum.Ignore };
|
||||
badge.AddThemeStyleboxOverride("panel", style);
|
||||
|
||||
var row = UiKit.Row(UiTokens.Space2);
|
||||
var kind = UiKit.Label(TranslationServer.Translate(ScopeKey(scope.Kind)), UiTokens.FontMicro, color);
|
||||
kind.Uppercase = true;
|
||||
kind.VerticalAlignment = VerticalAlignment.Center;
|
||||
row.AddChild(kind);
|
||||
row.AddChild(Text(scope.ModId, UiTokens.FontSmall, color, mono: true));
|
||||
badge.AddChild(row);
|
||||
return badge;
|
||||
}
|
||||
|
||||
public static Color ScopeColor(StudioScopeKind kind)
|
||||
{
|
||||
switch (kind)
|
||||
{
|
||||
case StudioScopeKind.Own:
|
||||
return UiTokens.SuccessText;
|
||||
case StudioScopeKind.Overlay:
|
||||
return UiTokens.Info;
|
||||
case StudioScopeKind.Layer:
|
||||
return UiTokens.TypeTarget;
|
||||
default:
|
||||
return UiTokens.Faint;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ScopeKey(StudioScopeKind kind)
|
||||
{
|
||||
switch (kind)
|
||||
{
|
||||
case StudioScopeKind.Own:
|
||||
return "studio/scope_own";
|
||||
case StudioScopeKind.Overlay:
|
||||
return "studio/scope_overlay";
|
||||
case StudioScopeKind.Layer:
|
||||
return "studio/scope_layer";
|
||||
default:
|
||||
return "studio/scope_readonly";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>"This change affects" plate: warning-tinted, lists every mod the action writes to or overrides.</summary>
|
||||
public static StyleBoxFlat ScopeBanner()
|
||||
{
|
||||
var style = UiKit.Frame(UiTokens.RadiusSm, UiTokens.BadgeFill(UiTokens.Warning), UiTokens.BadgeLine(UiTokens.Warning));
|
||||
Pad(style, 12, 8);
|
||||
return style;
|
||||
}
|
||||
|
||||
/// <summary>Well behind the script lines (<c>.ct-code</c>).</summary>
|
||||
public static StyleBoxFlat CodeWell()
|
||||
{
|
||||
var style = UiKit.Frame(UiTokens.RadiusSm, UiTokens.Well, UiTokens.Line);
|
||||
Pad(style, 0, 4);
|
||||
return style;
|
||||
}
|
||||
|
||||
/// <summary>Row of one script line: a 2px left bar and a tint carry current / new / error.</summary>
|
||||
public static StyleBoxFlat CodeLine(StudioCodeLine line)
|
||||
{
|
||||
Color background = Colors.Transparent;
|
||||
Color bar = Colors.Transparent;
|
||||
if (line.IsCurrent)
|
||||
{
|
||||
background = UiTokens.AccentSoft;
|
||||
bar = UiTokens.Accent;
|
||||
}
|
||||
else if (line.Error != null)
|
||||
{
|
||||
background = UiTokens.DangerSoft;
|
||||
bar = UiTokens.Danger;
|
||||
}
|
||||
else if (line.IsNew)
|
||||
{
|
||||
background = UiTokens.SuccessSoft;
|
||||
bar = UiTokens.Success;
|
||||
}
|
||||
|
||||
var style = new StyleBoxFlat { BgColor = background, BorderColor = bar, BorderWidthLeft = 2 };
|
||||
Pad(style, 0, 1);
|
||||
style.ContentMarginRight = UiTokens.Space4;
|
||||
return style;
|
||||
}
|
||||
|
||||
/// <summary>Row the dragged ▶ marker would land on: the current-line tint with a full accent outline.</summary>
|
||||
public static StyleBoxFlat CodeLineDropTarget()
|
||||
{
|
||||
var style = new StyleBoxFlat { BgColor = UiTokens.AccentSoft, BorderColor = UiTokens.AccentBright };
|
||||
style.SetBorderWidthAll(1);
|
||||
style.BorderWidthLeft = 2;
|
||||
Pad(style, 0, 1);
|
||||
style.ContentMarginRight = UiTokens.Space4;
|
||||
return style;
|
||||
}
|
||||
|
||||
/// <summary>Breakpoint dot of the gutter; transparent when the line has none.</summary>
|
||||
public static StyleBoxFlat Breakpoint(bool on)
|
||||
{
|
||||
var style = UiKit.Frame(4, on ? UiTokens.Danger : Colors.Transparent);
|
||||
if (on)
|
||||
{
|
||||
style.ShadowColor = UiTokens.DangerSoft;
|
||||
style.ShadowSize = 2;
|
||||
}
|
||||
return style;
|
||||
}
|
||||
|
||||
/// <summary>Section caption of the novice moment (<c>.ct-moment__caption</c>).</summary>
|
||||
public static Label Caption(string text)
|
||||
{
|
||||
var label = Text(text, UiTokens.FontTiny, UiTokens.Faint);
|
||||
label.Uppercase = true;
|
||||
return label;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Card of what is on screen (<c>.ct-say</c>): selected surface with the gold
|
||||
/// bar; a line added before it (<c>.ct-say--before</c>) is a plain card
|
||||
/// with the success bar.
|
||||
/// </summary>
|
||||
public static StyleBoxFlat Say(bool before)
|
||||
{
|
||||
var style = before
|
||||
? UiKit.Frame(UiTokens.RadiusMd, UiTokens.Surface2, UiTokens.Line)
|
||||
: UiKit.Frame(UiTokens.RadiusMd, UiTokens.SurfaceSelected, UiTokens.AccentLine);
|
||||
Pad(style, 12, 10);
|
||||
style.ContentMarginLeft = 0;
|
||||
return style;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Coloured 3px left bar of a card (gold for the line on screen, green for
|
||||
/// new lines). StyleBoxFlat has a single border colour, so the bar is its
|
||||
/// own strip at the start of the card row; the card style then has no left
|
||||
/// content margin and the bar is followed by <see cref="BarGap"/>.
|
||||
/// </summary>
|
||||
public static ColorRect Bar(Color color) => new()
|
||||
{
|
||||
Color = color,
|
||||
CustomMinimumSize = new Vector2(3, 0),
|
||||
MouseFilter = Control.MouseFilterEnum.Ignore,
|
||||
};
|
||||
|
||||
/// <summary>Space between a <see cref="Bar"/> and the card content.</summary>
|
||||
public const int BarGap = 9;
|
||||
|
||||
/// <summary>Answer row (<c>.ct-branch</c>): broken answers are red-tinted, new ones get the success bar.</summary>
|
||||
public static StyleBoxFlat Branch(bool broken, bool isNew)
|
||||
{
|
||||
var style = broken
|
||||
? UiKit.Frame(UiTokens.RadiusMd, UiTokens.DangerSoft, UiTokens.BadgeLine(UiTokens.TypeEnemy))
|
||||
: UiKit.Frame(UiTokens.RadiusMd, UiTokens.Surface2, UiTokens.Line);
|
||||
Pad(style, 12, 8);
|
||||
if (isNew)
|
||||
style.ContentMarginLeft = 0;
|
||||
return style;
|
||||
}
|
||||
|
||||
public static Color OutcomeColor(StudioOutcomeKind kind)
|
||||
{
|
||||
switch (kind)
|
||||
{
|
||||
case StudioOutcomeKind.Scene:
|
||||
return UiTokens.TypeTarget;
|
||||
case StudioOutcomeKind.Action:
|
||||
return UiTokens.TypeUsed;
|
||||
case StudioOutcomeKind.Broken:
|
||||
return UiTokens.DangerText;
|
||||
default:
|
||||
return UiTokens.Muted;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Insert bar (<c>.ct-insert</c>); solid accent 20% border because StyleBoxFlat cannot dash.</summary>
|
||||
public static StyleBoxFlat InsertBar()
|
||||
{
|
||||
var style = UiKit.Frame(UiTokens.RadiusSm, Colors.Transparent, UiTokens.AccentLineWeak);
|
||||
Pad(style, 8, 6);
|
||||
return style;
|
||||
}
|
||||
|
||||
/// <summary>Scene candidate card (<c>.ct-cand</c>); the running one gets the accent border.</summary>
|
||||
public static StyleBoxFlat Candidate(bool running)
|
||||
{
|
||||
var style = UiKit.Frame(UiTokens.RadiusMd, running ? UiTokens.SurfaceSelected : UiTokens.Surface2, running ? UiTokens.AccentLine : UiTokens.Line);
|
||||
Pad(style, 12, 8);
|
||||
return style;
|
||||
}
|
||||
|
||||
public static Color StatusColor(StudioCandidateStatus status)
|
||||
{
|
||||
switch (status)
|
||||
{
|
||||
case StudioCandidateStatus.Running:
|
||||
return UiTokens.Success;
|
||||
case StudioCandidateStatus.Queued:
|
||||
return UiTokens.Warning;
|
||||
case StudioCandidateStatus.Failed:
|
||||
return UiTokens.Danger;
|
||||
case StudioCandidateStatus.Skipped:
|
||||
return UiTokens.TypeEnemy;
|
||||
case StudioCandidateStatus.Completed:
|
||||
return UiTokens.TypeResource;
|
||||
default:
|
||||
return UiTokens.Muted;
|
||||
}
|
||||
}
|
||||
|
||||
public static Color LogColor(StudioLogCategory category)
|
||||
{
|
||||
switch (category)
|
||||
{
|
||||
case StudioLogCategory.Scene:
|
||||
return UiTokens.TypeTarget;
|
||||
case StudioLogCategory.Skip:
|
||||
return UiTokens.TypeUsed;
|
||||
case StudioLogCategory.Stop:
|
||||
return UiTokens.TypeEnemy;
|
||||
case StudioLogCategory.End:
|
||||
return UiTokens.Muted;
|
||||
case StudioLogCategory.Edit:
|
||||
return UiTokens.Success;
|
||||
default:
|
||||
return UiTokens.Accent;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Map node card (<c>.ct-node</c>): here glows gold, visited is strong-lined, new is teal.</summary>
|
||||
public static StyleBoxFlat Node(StudioNodeState state)
|
||||
{
|
||||
StyleBoxFlat style;
|
||||
switch (state)
|
||||
{
|
||||
case StudioNodeState.Here:
|
||||
style = UiKit.Frame(UiTokens.RadiusMd, UiTokens.SurfaceSelected, UiTokens.AccentLineStrong);
|
||||
style.ShadowColor = UiTokens.Alpha(UiTokens.Accent, 0.35f);
|
||||
style.ShadowSize = 8;
|
||||
break;
|
||||
case StudioNodeState.Visited:
|
||||
style = UiKit.Frame(UiTokens.RadiusMd, UiTokens.Surface2, UiTokens.LineStrong);
|
||||
break;
|
||||
case StudioNodeState.New:
|
||||
style = UiKit.Frame(UiTokens.RadiusMd, UiTokens.Surface2, UiTokens.BadgeLine(UiTokens.TypeTarget));
|
||||
break;
|
||||
default:
|
||||
style = UiKit.Frame(UiTokens.RadiusMd, UiTokens.Surface2, UiTokens.Line);
|
||||
break;
|
||||
}
|
||||
Pad(style, 12, 8);
|
||||
return style;
|
||||
}
|
||||
|
||||
/// <summary>Rows of the state tab: inset well like the inventory quantity block.</summary>
|
||||
public static StyleBoxFlat Well()
|
||||
{
|
||||
var style = UiKit.Frame(UiTokens.RadiusMd, UiTokens.Surface2, UiTokens.Line);
|
||||
Pad(style, 12, 10);
|
||||
return style;
|
||||
}
|
||||
|
||||
/// <summary>Bottom hairline between log rows.</summary>
|
||||
public static StyleBoxFlat LogRow(bool warning)
|
||||
{
|
||||
var style = new StyleBoxFlat
|
||||
{
|
||||
BgColor = warning ? UiTokens.BadgeFill(UiTokens.Warning) : Colors.Transparent,
|
||||
BorderColor = UiTokens.LineSoft,
|
||||
BorderWidthBottom = 1,
|
||||
};
|
||||
Pad(style, 8, 5);
|
||||
return style;
|
||||
}
|
||||
|
||||
private static StyleBoxFlat Chip(Color fill, Color border)
|
||||
{
|
||||
var style = UiKit.Frame(UiTokens.RadiusXs, fill, border);
|
||||
Pad(style, 7, 2);
|
||||
return style;
|
||||
}
|
||||
|
||||
private static void ApplyStates(Button button, StyleBox normal, StyleBox hover, StyleBox pressed)
|
||||
{
|
||||
button.AddThemeStyleboxOverride("normal", normal);
|
||||
button.AddThemeStyleboxOverride("hover", hover);
|
||||
button.AddThemeStyleboxOverride("pressed", pressed);
|
||||
button.AddThemeStyleboxOverride("hover_pressed", pressed);
|
||||
button.AddThemeStyleboxOverride("focus", new StyleBoxEmpty());
|
||||
}
|
||||
|
||||
private static void Pad(StyleBoxFlat style, int horizontal, int vertical)
|
||||
{
|
||||
style.ContentMarginLeft = horizontal;
|
||||
style.ContentMarginRight = horizontal;
|
||||
style.ContentMarginTop = vertical;
|
||||
style.ContentMarginBottom = vertical;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
namespace Cthangover.Mods.ScenarioStudio
|
||||
{
|
||||
using System.Text;
|
||||
using Cthangover.Core.UI.Theming;
|
||||
using Cthangover.Mods.Dialog.Scenarios;
|
||||
using Godot;
|
||||
|
||||
/// <summary>
|
||||
/// Colours one scenario DSL line as BBCode with the studio palette of the
|
||||
/// style guide: command word gold, quoted text ink, <c>:labels</c> teal,
|
||||
/// <c>name=</c> keys chip-key, comments faint. A light single-pass scanner
|
||||
/// is enough for display: it never fails on malformed lines, it just
|
||||
/// colours less of them, and the real parser stays the only judge of
|
||||
/// validity.
|
||||
/// </summary>
|
||||
public static class StudioSyntax
|
||||
{
|
||||
/// <summary>
|
||||
/// Highlighter for the inline text editor with the same palette: every
|
||||
/// registered command word (plus <c>select</c>/<c>option</c>) gold, quoted
|
||||
/// text ink, comments faint, <c>:labels</c> teal.
|
||||
/// </summary>
|
||||
public static CodeHighlighter CreateHighlighter()
|
||||
{
|
||||
var highlighter = new CodeHighlighter
|
||||
{
|
||||
NumberColor = UiTokens.Body,
|
||||
SymbolColor = UiTokens.Muted,
|
||||
FunctionColor = UiTokens.Body,
|
||||
MemberVariableColor = UiTokens.Body,
|
||||
};
|
||||
foreach (var command in ScenarioCommandStrategyFactory.GetAllCommandNames())
|
||||
highlighter.AddKeywordColor(command, UiTokens.AccentBright);
|
||||
highlighter.AddKeywordColor("select", UiTokens.AccentBright);
|
||||
highlighter.AddKeywordColor("option", UiTokens.AccentBright);
|
||||
highlighter.AddColorRegion("\"", "\"", UiTokens.Ink);
|
||||
highlighter.AddColorRegion("#", "", UiTokens.Faint, true);
|
||||
highlighter.AddColorRegion(":", " ", UiTokens.TypeTarget, true);
|
||||
return highlighter;
|
||||
}
|
||||
|
||||
public static string ToBbCode(string line)
|
||||
{
|
||||
var text = line ?? string.Empty;
|
||||
var trimmed = text.TrimStart();
|
||||
var indent = text.Substring(0, text.Length - trimmed.Length);
|
||||
var result = new StringBuilder(Escape(indent));
|
||||
|
||||
if (trimmed.StartsWith('#'))
|
||||
return result.Append(Paint(trimmed, UiTokens.Faint)).ToString();
|
||||
if (trimmed.StartsWith(':'))
|
||||
return result.Append(Paint(trimmed, UiTokens.TypeTarget)).ToString();
|
||||
|
||||
var i = 0;
|
||||
var first = true;
|
||||
while (i < trimmed.Length)
|
||||
{
|
||||
var c = trimmed[i];
|
||||
if (c == ' ')
|
||||
{
|
||||
result.Append(' ');
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c == '#')
|
||||
{
|
||||
result.Append(Paint(trimmed.Substring(i), UiTokens.Faint));
|
||||
break;
|
||||
}
|
||||
|
||||
var end = c == '"' ? CloseQuote(trimmed, i) : NextSpace(trimmed, i);
|
||||
var token = trimmed.Substring(i, end - i);
|
||||
result.Append(PaintToken(token, first));
|
||||
first = false;
|
||||
i = end;
|
||||
}
|
||||
|
||||
return result.ToString();
|
||||
}
|
||||
|
||||
private static string PaintToken(string token, bool first)
|
||||
{
|
||||
if (first)
|
||||
return Paint(token, UiTokens.AccentBright);
|
||||
if (token.StartsWith('"'))
|
||||
return Paint(token, UiTokens.Ink);
|
||||
if (token.StartsWith(':'))
|
||||
return Paint(token, UiTokens.TypeTarget);
|
||||
if (token == "->")
|
||||
return Paint(token, UiTokens.Muted);
|
||||
|
||||
var eq = token.IndexOf('=');
|
||||
if (eq > 0)
|
||||
return Paint(token.Substring(0, eq + 1), UiTokens.ChipKey) + Paint(token.Substring(eq + 1), UiTokens.Body);
|
||||
return Paint(token, UiTokens.Body);
|
||||
}
|
||||
|
||||
private static int CloseQuote(string text, int start)
|
||||
{
|
||||
var close = text.IndexOf('"', start + 1);
|
||||
return close < 0 ? text.Length : close + 1;
|
||||
}
|
||||
|
||||
private static int NextSpace(string text, int start)
|
||||
{
|
||||
var space = text.IndexOf(' ', start);
|
||||
return space < 0 ? text.Length : space;
|
||||
}
|
||||
|
||||
private static string Paint(string text, Color color) => $"[color=#{color.ToHtml(false)}]{Escape(text)}[/color]";
|
||||
|
||||
private static string Escape(string text) => text.Replace("[", "[lb]");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "Scenario Studio Live",
|
||||
"description": "GUI test of the scenario studio on the real kitchen scenario: opens the dock like the toolbar button, checks the current line, pause and continue, the novice moment, adds and redirects an answer, inserts a line, applies the edits into a test layer and checks the rollback restart plays them without touching the mod, holds at a breakpoint, walks the other tabs and cleans the layer. --shots=<dir> saves screenshots. Activated by --itest=studio_live.",
|
||||
"sources": [
|
||||
"src/*.cs"
|
||||
],
|
||||
"depends": [
|
||||
"cthangover_game>=1",
|
||||
"cooking>=1"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,583 @@
|
||||
namespace Cthangover.Mods.ScenarioStudio.Tests.Live
|
||||
{
|
||||
using System.IO;
|
||||
using Cthangover.Core.Bootstrap;
|
||||
using Cthangover.Core.Mods;
|
||||
using Cthangover.Core.Scenes;
|
||||
using Cthangover.Core.UI;
|
||||
using Cthangover.Core.Utils;
|
||||
using Cthangover.Mods.Dialog;
|
||||
using Cthangover.Mods.Dialog.Action.Impls;
|
||||
using Cthangover.Mods.Dialog.Executable;
|
||||
using Godot;
|
||||
|
||||
/// <summary>
|
||||
/// Drives the studio on the real kitchen scenario the way a player would,
|
||||
/// one step per tick, checking after each step what it must have changed:
|
||||
/// <list type="number">
|
||||
/// <item>waits for the kitchen scenario's question, opens the dock with the
|
||||
/// <c>studio</c> UI toggle (the toolbar button) and checks the current
|
||||
/// line is the running action's line;</item>
|
||||
/// <item>widens the dock with its left grip and moves it by the header;</item>
|
||||
/// <item>pause and continue;</item>
|
||||
/// <item>novice mode: the question and its answers with outcomes; adds an
|
||||
/// answer (broken), redirects it to a scene through "Choose where",
|
||||
/// inserts a line before the question;</item>
|
||||
/// <item>applies: the scenario restarts with rollback and first shows the
|
||||
/// inserted line, the edit is in the test layer and the cooking mod's
|
||||
/// file is untouched; the question then has the new answer;</item>
|
||||
/// <item>breakpoint on the question, restart, hold before it; drags the
|
||||
/// ▶ marker back onto the inserted line, continues from there and is held
|
||||
/// by the breakpoint again, continue;</item>
|
||||
/// <item>the Scene, State (a checkpoint is saved there), Log and Map tabs;</item>
|
||||
/// <item>creates a scene in the layer, goes there and sees its scenario run
|
||||
/// from the layer, exports the layer as a mod, returns to the checkpoint
|
||||
/// and closes the dock.</item>
|
||||
/// </list>
|
||||
/// The test layer is deleted at the end. Ends with
|
||||
/// <c>[TEST_RESULT] name=studio_live passed=N failed=M</c>.
|
||||
/// </summary>
|
||||
public partial class StudioLiveDriverNode : Node
|
||||
{
|
||||
private const string ResultName = "studio_live";
|
||||
private const string EntryId = "studio";
|
||||
private const string Root = "Frame/Dock/Column/";
|
||||
private const float TickSeconds = 0.5f;
|
||||
private static readonly Vector2 Widen = new(-100, 0);
|
||||
private static readonly Vector2 Shift = new(-60, -40);
|
||||
private const float TimeoutSeconds = 150f;
|
||||
private const string NewScene = "studio_test_yard";
|
||||
private const string ExportId = "studio_test_export";
|
||||
|
||||
private readonly string _shotsDir;
|
||||
private StudioPanel _panel;
|
||||
private string _scenario;
|
||||
private string _sourceMod;
|
||||
private string _sourcePath;
|
||||
private string _originalText;
|
||||
private int _questionLine;
|
||||
private int _answers;
|
||||
private int _movedLine;
|
||||
private Rect2 _frameRect;
|
||||
private int _step = -1;
|
||||
private int _passed;
|
||||
private int _failed;
|
||||
private double _elapsed;
|
||||
private bool _finished;
|
||||
|
||||
public StudioLiveDriverNode(string shotsDir)
|
||||
{
|
||||
_shotsDir = shotsDir;
|
||||
var timer = new Timer { WaitTime = TickSeconds, Autostart = true };
|
||||
AddChild(timer);
|
||||
timer.Timeout += Tick;
|
||||
}
|
||||
|
||||
private static StudioLive Live => StudioLive.Instance;
|
||||
|
||||
private DialogBox Box => Live?.Box;
|
||||
|
||||
private void Tick()
|
||||
{
|
||||
if (_finished)
|
||||
return;
|
||||
|
||||
_elapsed += TickSeconds;
|
||||
if (_elapsed >= TimeoutSeconds)
|
||||
{
|
||||
Fail($"timeout ({TimeoutSeconds:0}s) at step {_step}");
|
||||
Finish();
|
||||
return;
|
||||
}
|
||||
|
||||
if (_step < 0 && !QuestionIsUp())
|
||||
return;
|
||||
|
||||
_step++;
|
||||
switch (_step)
|
||||
{
|
||||
case 0:
|
||||
var scenario = (ScenarioEvent)Box.Locker;
|
||||
_scenario = scenario.ID;
|
||||
_sourceMod = scenario.SourceModId;
|
||||
_sourcePath = scenario.SourcePath;
|
||||
_originalText = ModManager.Instance.ReadFileText(_sourceMod, _sourcePath);
|
||||
_questionLine = CurrentAction()?.SourceLine ?? 0;
|
||||
_answers = Box.AnswerVariantCount;
|
||||
Check(Live != null && Live.Layer.ForceLayer, "studio live is up with the layer forced");
|
||||
Check(_sourceMod != null && _sourcePath != null, $"running scenario '{_scenario}' knows its file ({_sourceMod}:{_sourcePath})");
|
||||
Check(_questionLine > 0, $"question action carries its source line ({_questionLine})");
|
||||
AppServices.Current.Ui.ToggleEntry(EntryId);
|
||||
break;
|
||||
case 1:
|
||||
Check(UiHost.Instance.TryGetInstance(EntryId, out var control) && control is StudioPanel, "dock opened by the toggle");
|
||||
_panel = control as StudioPanel;
|
||||
if (_panel == null)
|
||||
{
|
||||
Finish();
|
||||
return;
|
||||
}
|
||||
var session = _panel.Session;
|
||||
Check(session.ScenarioName == _scenario, $"dock shows the running scenario ('{session.ScenarioName}')");
|
||||
Check(IsCurrentLine(session, _questionLine), "current line is the question's line");
|
||||
Check(session.FileScope?.ModId == _sourceMod, $"file scope is the scenario's mod ({session.FileScope?.ModId})");
|
||||
Check(session.EditScope?.Kind == StudioScopeKind.Layer, "edits go to the studio layer");
|
||||
Check(Mathf.Abs(_panel.GetNode<Control>("Frame/Dock").Size.X - StudioStyles.DockWidth) < 1, "dock keeps its width");
|
||||
Shot("author");
|
||||
_frameRect = FrameRect();
|
||||
Drag("Frame/LeftGrip", Widen);
|
||||
Press(Root + "Transport/Row/PauseButton");
|
||||
break;
|
||||
case 2:
|
||||
var widened = FrameRect();
|
||||
Check(Mathf.Abs(widened.Size.X - (_frameRect.Size.X - Widen.X)) < 1 && Mathf.Abs(widened.End.X - _frameRect.End.X) < 1,
|
||||
$"left grip widens the dock to the left ({_frameRect} → {widened})");
|
||||
Check(Mathf.Abs(_panel.GetNode<Control>("Frame/Dock").Size.X - widened.Size.X) < 1, "the panel fills the widened frame");
|
||||
_frameRect = widened;
|
||||
Drag(Root + "Header", Shift);
|
||||
Check(Live.Debugger.IsPaused && _panel.Session.RunState == StudioRunState.Paused, "pause is on");
|
||||
Press(Root + "Transport/Row/ContinueButton");
|
||||
Press(Root + "Header/Row/ModeSwitch/Items/NoviceButton");
|
||||
break;
|
||||
case 3:
|
||||
var moved = FrameRect();
|
||||
Check(moved.Position.DistanceTo(_frameRect.Position + Shift) < 1 && moved.Size.DistanceTo(_frameRect.Size) < 1,
|
||||
$"header drag moves the dock ({_frameRect} → {moved})");
|
||||
Check(!Live.Debugger.IsPaused, "continue turns pause off");
|
||||
Check(_panel.Mode == StudioMode.Novice, "novice mode selected");
|
||||
Check(CountOf<StudioSayView>() == 1, "novice: the question on screen");
|
||||
Check(CountOf<StudioBranchView>() == _answers, $"novice: {_answers} answers");
|
||||
Check(Broken() == 0, "novice: every answer leads somewhere");
|
||||
PressText("studio/add_option");
|
||||
break;
|
||||
case 4:
|
||||
Check(CountOf<StudioBranchView>() == _answers + 1, "new answer added");
|
||||
Check(Broken() == 1, "the new answer leads nowhere yet");
|
||||
PressBrokenTarget();
|
||||
break;
|
||||
case 5:
|
||||
PickScene("home_vestibule");
|
||||
break;
|
||||
case 6:
|
||||
Check(Broken() == 0, "the new answer now leads to a scene");
|
||||
PressText("studio/add_line_before");
|
||||
break;
|
||||
case 7:
|
||||
Check(CountOf<StudioSayView>() == 2, "new line shown before the question");
|
||||
Check(_panel.Session.PendingCount > 0, $"pending edits counted ({_panel.Session.PendingCount})");
|
||||
Shot("novice");
|
||||
Press(Root + "Footer/Row/ApplyButton");
|
||||
break;
|
||||
case 8:
|
||||
var line = CurrentAction() as ActionText;
|
||||
Check(Box.IsRunning && Box.Locker is ScenarioEvent { ID: var id } && id == _scenario, "scenario restarted after apply");
|
||||
Check(line != null && line.Text == TranslationServer.Translate("studio/new_line_text"), $"restart shows the inserted line first ('{line?.Text}')");
|
||||
Check(File.Exists(Path.Combine(Live.Layer.RootPath, _sourcePath)), "edit written to the studio layer");
|
||||
Check(ModManager.Instance.ReadFileText(_sourceMod, _sourcePath) == _originalText, "the mod's own file is untouched");
|
||||
Shot("applied");
|
||||
Box.NextAction();
|
||||
break;
|
||||
case 9:
|
||||
Check(Box.IsWaitingAnswer && Box.AnswerVariantCount == _answers + 1, $"question now has the new answer ({Box.AnswerVariantCount})");
|
||||
_questionLine = CurrentAction()?.SourceLine ?? 0;
|
||||
Press(Root + "Header/Row/ModeSwitch/Items/AuthorButton");
|
||||
break;
|
||||
case 10:
|
||||
ToggleBreakpoint(_questionLine);
|
||||
break;
|
||||
case 11:
|
||||
Check(Live.Debugger.HasBreakpoint(_scenario, _questionLine), $"breakpoint set on line {_questionLine}");
|
||||
Restart(StudioRestartMode.Scenario);
|
||||
break;
|
||||
case 12:
|
||||
Box.NextAction();
|
||||
break;
|
||||
case 13:
|
||||
Check(Box.Runtime.IsHeld && Box.AnswerVariantCount == 0, "held at the breakpoint before the question runs");
|
||||
Check(_panel.Session.RunState == StudioRunState.Held, "dock shows the hold");
|
||||
Shot("breakpoint");
|
||||
DragMarkerBack();
|
||||
break;
|
||||
case 14:
|
||||
Check(Box.Runtime.IsHeld && Live.CurrentLine() == _movedLine && Box.AnswerVariantCount == 0,
|
||||
$"marker drag makes line {_movedLine} the next one (now {Live.CurrentLine()})");
|
||||
Check(IsCurrentLine(_panel.Session, _movedLine), "dock shows the moved line as current");
|
||||
Press(Root + "Transport/Row/ContinueButton");
|
||||
break;
|
||||
case 15:
|
||||
var rerun = CurrentAction() as ActionText;
|
||||
Check(!Box.Runtime.IsHeld && rerun != null && rerun.Text == TranslationServer.Translate("studio/new_line_text"),
|
||||
$"continue runs from the moved line ('{rerun?.Text}')");
|
||||
Box.NextAction();
|
||||
break;
|
||||
case 16:
|
||||
Check(Box.Runtime.IsHeld && Live.CurrentLine() == _questionLine, "the breakpoint holds the question again");
|
||||
Press(Root + "Transport/Row/ContinueButton");
|
||||
break;
|
||||
case 17:
|
||||
Check(!Box.Runtime.IsHeld && Box.AnswerVariantCount == _answers + 1, "continue runs the question");
|
||||
Press(Root + "Tabs/Row/SceneTab");
|
||||
break;
|
||||
case 18:
|
||||
Check(_panel.Tab == StudioTab.Scene && IsRunningCandidate(_panel.Session, _scenario), "scene tab: the scenario is the running candidate");
|
||||
Shot("scene");
|
||||
Press(Root + "Tabs/Row/StateTab");
|
||||
break;
|
||||
case 19:
|
||||
Check(_panel.Session.State.Count > 0 && CountOf<StudioStateRow>() == _panel.Session.State.Count, "state tab: a row per state value");
|
||||
Shot("state");
|
||||
MenuPick(100);
|
||||
Check(Live.HasCheckpoint, "checkpoint remembered from the restart menu");
|
||||
Press(Root + "Tabs/Row/LogTab");
|
||||
break;
|
||||
case 20:
|
||||
Check(HasEditLog(_panel.Session), "log tab: the applied edit is logged");
|
||||
Shot("log");
|
||||
Press(Root + "Tabs/Row/MapTab");
|
||||
break;
|
||||
case 21:
|
||||
Check(HasNode(_panel.Session, StudioNodeState.Here, "home_kitchen"), "map tab: you are in the kitchen");
|
||||
Check(HasNode(_panel.Session, StudioNodeState.Next, "home_vestibule"), "map tab: the vestibule is an exit");
|
||||
Shot("map");
|
||||
PressText("studio/map_new_scene");
|
||||
break;
|
||||
case 22:
|
||||
AnswerPrompt(NewScene);
|
||||
break;
|
||||
case 23:
|
||||
Check(File.Exists(Path.Combine(Live.Layer.RootPath, "scenes", NewScene + ".json")), "new scene written to the layer");
|
||||
Check(GetNodeOrNull<SceneManager>("/root/SceneManager")?.HasScene(NewScene) == true, "new scene is known to the scene manager");
|
||||
PressPayload(NewScene);
|
||||
break;
|
||||
case 24:
|
||||
if (SceneManager.GetCurrentSceneName() != NewScene || Box?.Locker is not ScenarioEvent)
|
||||
{
|
||||
_step--;
|
||||
break;
|
||||
}
|
||||
Check(((ScenarioEvent)Box.Locker).SourceModId == StudioLayer.Id, $"the new scene's scenario runs from the layer ('{Box.Locker.ID}')");
|
||||
Shot("new_scene");
|
||||
PressText("studio/map_export");
|
||||
break;
|
||||
case 25:
|
||||
AnswerPrompt(ExportId);
|
||||
break;
|
||||
case 26:
|
||||
var exported = Path.Combine(ProjectSettings.GlobalizePath("user://mods/"), ExportId);
|
||||
Check(File.Exists(Path.Combine(exported, "manifest.json")) && File.Exists(Path.Combine(exported, "scenes", NewScene + ".json")), "layer exported as a mod with a manifest");
|
||||
if (Directory.Exists(exported))
|
||||
Directory.Delete(exported, true);
|
||||
MenuPick(101);
|
||||
break;
|
||||
case 27:
|
||||
if (SceneManager.GetCurrentSceneName() != "home_kitchen")
|
||||
{
|
||||
_step--;
|
||||
break;
|
||||
}
|
||||
Check(true, "back at the checkpoint's scene");
|
||||
Press(Root + "Header/Row/CloseButton");
|
||||
break;
|
||||
default:
|
||||
Check(!_panel.Visible, "close hides the dock");
|
||||
Cleanup();
|
||||
Finish();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsCurrentLine(StudioSession session, int number)
|
||||
{
|
||||
foreach (var line in session.Lines)
|
||||
{
|
||||
if (line.IsCurrent && line.Number == number)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsRunningCandidate(StudioSession session, string name)
|
||||
{
|
||||
foreach (var candidate in session.Candidates)
|
||||
{
|
||||
if (candidate.Status == StudioCandidateStatus.Running && candidate.Name == name)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool HasEditLog(StudioSession session)
|
||||
{
|
||||
foreach (var entry in session.Log)
|
||||
{
|
||||
if (entry.Category == StudioLogCategory.Edit)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool HasNode(StudioSession session, StudioNodeState state, string name)
|
||||
{
|
||||
foreach (var node in session.Nodes)
|
||||
{
|
||||
if (node.State == state && node.Name == name)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool QuestionIsUp()
|
||||
{
|
||||
var tree = GetTree();
|
||||
if (!Boot.IsReady || tree.CurrentScene == null || UiHost.Instance == null || Live == null)
|
||||
return false;
|
||||
var box = Box;
|
||||
return box != null && box.IsRunning && box.Locker is ScenarioEvent && box.IsWaitingAnswer;
|
||||
}
|
||||
|
||||
private Dialog.Action.IActionCommand CurrentAction()
|
||||
{
|
||||
var queue = Box?.Runtime.Dialog?.Queue;
|
||||
var index = Box?.Runtime.Index ?? -1;
|
||||
return queue != null && index >= 0 && index < queue.Count ? queue[index] : null;
|
||||
}
|
||||
|
||||
private void Press(string path)
|
||||
{
|
||||
var button = _panel.GetNodeOrNull<Button>(path);
|
||||
Check(button != null, $"button {path} found");
|
||||
button?.EmitSignal(BaseButton.SignalName.Pressed);
|
||||
}
|
||||
|
||||
private void PressText(string key)
|
||||
{
|
||||
var button = FindButton(_panel, TranslationServer.Translate(key));
|
||||
Check(button != null, $"button '{key}' found");
|
||||
button?.EmitSignal(BaseButton.SignalName.Pressed);
|
||||
}
|
||||
|
||||
private void PressBrokenTarget()
|
||||
{
|
||||
foreach (var node in Content().GetChildren())
|
||||
{
|
||||
if (node is StudioBranchView { IsBroken: true } branch)
|
||||
{
|
||||
var button = FindButton(branch, TranslationServer.Translate("studio/choose_target"));
|
||||
Check(button != null, "broken answer offers 'choose where'");
|
||||
button?.EmitSignal(BaseButton.SignalName.Pressed);
|
||||
return;
|
||||
}
|
||||
}
|
||||
Fail("no broken answer to redirect");
|
||||
}
|
||||
|
||||
private void PickScene(string scene)
|
||||
{
|
||||
PopupMenu menu = null;
|
||||
foreach (var child in _panel.GetChildren())
|
||||
{
|
||||
if (child is PopupMenu popup && popup.Visible)
|
||||
menu = popup;
|
||||
}
|
||||
Check(menu != null, "target menu opened");
|
||||
if (menu == null)
|
||||
return;
|
||||
|
||||
var caption = string.Format(TranslationServer.Translate("studio/target_scene"), scene);
|
||||
for (var i = 0; i < menu.ItemCount; i++)
|
||||
{
|
||||
if (menu.GetItemText(i) != caption)
|
||||
continue;
|
||||
menu.EmitSignal(PopupMenu.SignalName.IdPressed, (long)menu.GetItemId(i));
|
||||
menu.Hide();
|
||||
return;
|
||||
}
|
||||
Fail($"scene {scene} not offered as a target");
|
||||
}
|
||||
|
||||
private void PressPayload(string payload)
|
||||
{
|
||||
foreach (var button in Descendants<StudioActionButton>(Content()))
|
||||
{
|
||||
if (button.Payload != payload)
|
||||
continue;
|
||||
button.EmitSignal(BaseButton.SignalName.Pressed);
|
||||
return;
|
||||
}
|
||||
Fail($"no button for '{payload}'");
|
||||
}
|
||||
|
||||
/// <summary>Types <paramref name="text"/> into the open studio prompt and confirms it.</summary>
|
||||
private void AnswerPrompt(string text)
|
||||
{
|
||||
foreach (var child in _panel.GetChildren())
|
||||
{
|
||||
if (child is not ConfirmationDialog dialog || !dialog.Visible)
|
||||
continue;
|
||||
foreach (var field in Descendants<LineEdit>(dialog))
|
||||
field.Text = text;
|
||||
dialog.EmitSignal(AcceptDialog.SignalName.Confirmed);
|
||||
return;
|
||||
}
|
||||
Fail("no studio prompt open");
|
||||
}
|
||||
|
||||
private void ToggleBreakpoint(int line)
|
||||
{
|
||||
foreach (var node in Descendants<StudioCodeLineView>(Content()))
|
||||
{
|
||||
if (node.Line.SourceLine != line)
|
||||
continue;
|
||||
var click = new InputEventMouseButton { ButtonIndex = MouseButton.Left, Pressed = true };
|
||||
node.Gutter.EmitSignal(Control.SignalName.GuiInput, click);
|
||||
return;
|
||||
}
|
||||
Fail($"no script line {line} in the author view");
|
||||
}
|
||||
|
||||
private Rect2 FrameRect()
|
||||
{
|
||||
var frame = _panel.GetNode<Control>("Frame");
|
||||
return new Rect2(frame.Position, frame.Size);
|
||||
}
|
||||
|
||||
/// <summary>Presses the left button on a dock handle, moves the mouse by <paramref name="delta"/> and releases.</summary>
|
||||
private void Drag(string path, Vector2 delta)
|
||||
{
|
||||
var handle = _panel.GetNodeOrNull<Control>(path);
|
||||
Check(handle != null, $"handle {path} found");
|
||||
if (handle != null)
|
||||
DragFrom(handle, delta);
|
||||
}
|
||||
|
||||
private static void DragFrom(Control handle, Vector2 delta)
|
||||
{
|
||||
handle.EmitSignal(Control.SignalName.GuiInput, new InputEventMouseButton { ButtonIndex = MouseButton.Left, Pressed = true });
|
||||
handle.EmitSignal(Control.SignalName.GuiInput, new InputEventMouseMotion { Position = delta, ButtonMask = MouseButtonMask.Left });
|
||||
handle.EmitSignal(Control.SignalName.GuiInput, new InputEventMouseButton { ButtonIndex = MouseButton.Left, Position = delta });
|
||||
}
|
||||
|
||||
/// <summary>Drags the ▶ marker of the current line onto the nearest line above it the dialog can run from.</summary>
|
||||
private void DragMarkerBack()
|
||||
{
|
||||
StudioCodeLineView current = null;
|
||||
StudioCodeLineView target = null;
|
||||
foreach (var view in Descendants<StudioCodeLineView>(Content()))
|
||||
{
|
||||
if (view.Line.IsCurrent)
|
||||
current = view;
|
||||
else if (current == null && view.Line.CanRunNext)
|
||||
target = view;
|
||||
}
|
||||
Check(current != null && target != null, "a current line and a runnable line above it");
|
||||
if (current == null || target == null)
|
||||
return;
|
||||
_movedLine = target.Line.SourceLine;
|
||||
DragFrom(current.Marker, target.GetGlobalRect().GetCenter() - current.Marker.GlobalPosition);
|
||||
}
|
||||
|
||||
private void Restart(StudioRestartMode mode) => MenuPick((long)mode);
|
||||
|
||||
private void MenuPick(long id)
|
||||
{
|
||||
var popup = _panel.GetNode<MenuButton>(Root + "Transport/Row/RestartButton").GetPopup();
|
||||
popup.EmitSignal(PopupMenu.SignalName.IdPressed, id);
|
||||
}
|
||||
|
||||
private int Broken()
|
||||
{
|
||||
var broken = 0;
|
||||
foreach (var node in Content().GetChildren())
|
||||
{
|
||||
if (node is StudioBranchView { IsBroken: true })
|
||||
broken++;
|
||||
}
|
||||
return broken;
|
||||
}
|
||||
|
||||
private VBoxContainer Content() => _panel.GetNode<VBoxContainer>(Root + "Body/Scroll/Content");
|
||||
|
||||
private int CountOf<T>() where T : Node => Descendants<T>(Content()).Count;
|
||||
|
||||
private static System.Collections.Generic.List<T> Descendants<T>(Node node) where T : Node
|
||||
{
|
||||
var result = new System.Collections.Generic.List<T>();
|
||||
foreach (var child in node.GetChildren())
|
||||
{
|
||||
if (child is T typed)
|
||||
result.Add(typed);
|
||||
result.AddRange(Descendants<T>(child));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Button FindButton(Node node, string text)
|
||||
{
|
||||
if (node is Button button && button.Text == text)
|
||||
return button;
|
||||
foreach (var child in node.GetChildren())
|
||||
{
|
||||
var found = FindButton(child, text);
|
||||
if (found != null)
|
||||
return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void Cleanup()
|
||||
{
|
||||
var root = Live?.Layer.RootPath;
|
||||
if (string.IsNullOrEmpty(root) || !Directory.Exists(root))
|
||||
return;
|
||||
ModOverlays.Unmount(StudioLayer.Id);
|
||||
StudioLayer.RefreshContent();
|
||||
Directory.Delete(root, true);
|
||||
var parent = Path.GetDirectoryName(root);
|
||||
if (parent != null && Directory.GetFileSystemEntries(parent).Length == 0)
|
||||
Directory.Delete(parent);
|
||||
Log($"test layer removed: {root}");
|
||||
}
|
||||
|
||||
private void Shot(string id)
|
||||
{
|
||||
if (string.IsNullOrEmpty(_shotsDir) || DisplayServer.GetName() == "headless")
|
||||
return;
|
||||
|
||||
var dir = ProjectSettings.GlobalizePath(_shotsDir);
|
||||
Directory.CreateDirectory(dir);
|
||||
var path = Path.Combine(dir, id + ".png");
|
||||
GetViewport().GetTexture().GetImage().SavePng(path);
|
||||
Log($"screenshot {path}");
|
||||
}
|
||||
|
||||
private void Check(bool ok, string name)
|
||||
{
|
||||
if (ok)
|
||||
{
|
||||
_passed++;
|
||||
Log($"[PASS] {name}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Fail(name);
|
||||
}
|
||||
}
|
||||
|
||||
private void Fail(string message)
|
||||
{
|
||||
_failed++;
|
||||
Log($"[FAIL] {message}");
|
||||
}
|
||||
|
||||
private void Finish()
|
||||
{
|
||||
if (_finished)
|
||||
return;
|
||||
_finished = true;
|
||||
GameLogger.Log("TEST", $"[TEST_RESULT] name={ResultName} passed={_passed} failed={_failed}");
|
||||
GetTree().Quit(_failed == 0 ? 0 : 1);
|
||||
}
|
||||
|
||||
private static void Log(string message) => GameLogger.Log("TEST", message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
namespace Cthangover.Mods.ScenarioStudio.Tests.Live
|
||||
{
|
||||
using System.Linq;
|
||||
using Cthangover.Core.Mods;
|
||||
using Cthangover.Core.Utils;
|
||||
using Godot;
|
||||
|
||||
/// <summary>
|
||||
/// Entry point of the studio live test, active only with
|
||||
/// <c>--itest=studio_live</c>; <c>--shots=<dir></c> also saves a
|
||||
/// screenshot per checked state (needs a real display, e.g. xvfb). The
|
||||
/// test expects <c>--studio-write=layer</c> and its own
|
||||
/// <c>--studio-layer=</c> so it never writes into a mod. The driver is added
|
||||
/// deferred because resources-ready fires before the shell scene exists.
|
||||
/// </summary>
|
||||
public class StudioLiveTestInitializer : IModInitializer
|
||||
{
|
||||
private static bool driverQueued;
|
||||
|
||||
public void OnModLoaded(string modId) { }
|
||||
|
||||
public void OnModResourcesReady()
|
||||
{
|
||||
if (driverQueued)
|
||||
return;
|
||||
|
||||
var args = OS.GetCmdlineUserArgs();
|
||||
if (!args.Any(a => a == "--itest=studio_live"))
|
||||
return;
|
||||
|
||||
var shots = args
|
||||
.Where(a => a.StartsWith("--shots=", System.StringComparison.Ordinal))
|
||||
.Select(a => a.Substring("--shots=".Length))
|
||||
.FirstOrDefault();
|
||||
|
||||
driverQueued = true;
|
||||
GameLogger.Log("TEST", $"StudioLiveTestInitializer: activating studio live test, shots='{shots ?? ""}'");
|
||||
(Godot.Engine.GetMainLoop() as SceneTree)?.Root?.CallDeferred("add_child", new StudioLiveDriverNode(shots));
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
Reference in new issue
Block a user