init mod
This commit is contained in:
commit
ee6fdc3576
32 files changed
+4117
No files matched your search
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"id": "frontend",
|
||||
"kind": "frontend",
|
||||
"name": "Front-end",
|
||||
"author": "ct",
|
||||
"version": "1.0.0",
|
||||
"description": "Loaded with every game and on its own without one: the main menu, settings, save browser and the mod manager.",
|
||||
"locale": [
|
||||
{ "lang": "ru", "name": "Оболочка", "description": "Загружается с каждой игрой и без неё: главное меню, настройки, сохранения и менеджер модов." }
|
||||
],
|
||||
"mechanics": [
|
||||
{
|
||||
"id": "menu",
|
||||
"name": "Main menu",
|
||||
"role": "ui",
|
||||
"description": "Main menu, settings and the save/load panels.",
|
||||
"locale": [
|
||||
{ "lang": "ru", "name": "Главное меню", "description": "Главное меню, настройки и панели сохранения/загрузки." }
|
||||
],
|
||||
"mods": ["base_menu>=1", "interface>=1", "audio_base>=1"]
|
||||
},
|
||||
{
|
||||
"id": "mods",
|
||||
"name": "Mod manager",
|
||||
"role": "tools",
|
||||
"description": "Catalog, installation and the game constructor.",
|
||||
"locale": [
|
||||
{ "lang": "ru", "name": "Менеджер модов", "description": "Каталог, установка и конструктор игр." }
|
||||
],
|
||||
"mods": ["mod_manager>=1"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
mod_base_menu_name = Base Menu
|
||||
mod_base_menu_desc = Registry-driven main menu: built-in buttons, settings and save/load panels moved out of the core.
|
||||
|
||||
settings/saves/purge_dormant = Data of disabled mechanics
|
||||
settings/saves/purge_dormant_button = Purge
|
||||
settings/saves/purge_dormant_confirm = Remove the data of mechanics that are not part of the games from every save? A mechanic turned on again will start from scratch.
|
||||
settings/saves/purge_dormant_done = Data blocks removed: {0}
|
||||
|
||||
menu/continue = Continue
|
||||
menu/play = Play
|
||||
menu/play/title = Choose a game
|
||||
menu/play/no_games = No games yet. Build one in the mod manager.
|
||||
menu/play/new_game = New game
|
||||
menu/play/continue = Continue
|
||||
menu/play/browse_saves = Saves
|
||||
menu/play/install = Install missing
|
||||
menu/play/installing = Installing…
|
||||
menu/play/configure = Configure
|
||||
menu/play/install_done = Mods installed. They load when you enter the game.
|
||||
menu/play/install_failed = Could not install the mods — see the log.
|
||||
menu/play/no_saves = No saves
|
||||
menu/play/saves = Saves: {0} · last: {1}
|
||||
menu/play/status_ready = Ready to play
|
||||
menu/play/status_missing = Missing mods: {0}
|
||||
menu/play/status_mismatch = Incompatible versions: {0}
|
||||
menu/play/status_problems = Problems in the game composition: {0}
|
||||
menu/play/status_install = To install: {0}, to enable: {1}
|
||||
menu/load/empty = No saves yet
|
||||
menu/load/legacy_group = Saves without a game
|
||||
menu/load/legacy_title = Save without a game
|
||||
menu/load/legacy_text = This save was made before game collections existed. Choose the game it belongs to — the file moves into that game's saves.
|
||||
menu/load/legacy_ok = Move and load
|
||||
menu/load/legacy_no_games = There is no ready game to assign the save to.
|
||||
menu/load/diff_title = The game has changed
|
||||
menu/load/diff_intro = The game's composition changed since this save.
|
||||
menu/load/diff_added = Added (starts from scratch):
|
||||
menu/load/diff_removed = Removed (data is kept and returns if turned on again):
|
||||
menu/load/diff_updated = Updated:
|
||||
menu/load/diff_downgrade = (older than the save — that mod's data is set aside)
|
||||
menu/load/diff_backup = A backup of the save is made before loading.
|
||||
menu/load/diff_ok = Load
|
||||
|
||||
settings/back = Back
|
||||
settings/audio = Audio
|
||||
settings/sounds = Sounds
|
||||
settings/music = Music
|
||||
settings/volume = Volume
|
||||
settings/language = Language
|
||||
settings/lang_ru = Russian
|
||||
settings/lang_en = English
|
||||
settings/lang_zh = 中文
|
||||
settings/tab_general = General
|
||||
settings/tab_audio = Sound
|
||||
settings/tab_display = Display
|
||||
settings/display = Display
|
||||
settings/window_mode = Window Mode
|
||||
settings/window_mode_windowed = Windowed
|
||||
settings/window_mode_fullscreen = Fullscreen
|
||||
settings/window_mode_borderless = Borderless
|
||||
settings/resolution = Resolution
|
||||
settings/monitor = Monitor
|
||||
settings/monitor_screen = Screen {0} ({1}x{2})
|
||||
settings/vsync = V-Sync
|
||||
settings/scale = Scale
|
||||
settings/scale_1x = 1x
|
||||
settings/scale_2x = 2x
|
||||
settings/scale_3x = 3x
|
||||
settings/scale_4x = 4x
|
||||
settings/tab_mods = Mod Settings
|
||||
settings/mod_list_title = Mods
|
||||
settings/mods_no_settings = No mod settings available
|
||||
settings/mods_reset = Reset to Defaults
|
||||
@@ -0,0 +1,72 @@
|
||||
mod_base_menu_name = Базовое меню
|
||||
mod_base_menu_desc = Главное меню на реестрах: встроенные кнопки, настройки и панели сохранения/загрузки, вынесенные из ядра.
|
||||
|
||||
settings/saves/purge_dormant = Данные отключённых механик
|
||||
settings/saves/purge_dormant_button = Очистить
|
||||
settings/saves/purge_dormant_confirm = Удалить из всех сохранений данные механик, которые сейчас не входят в игры? Если включить механику снова, она начнёт с нуля.
|
||||
settings/saves/purge_dormant_done = Удалено блоков данных: {0}
|
||||
|
||||
menu/continue = Продолжить
|
||||
menu/play = Играть
|
||||
menu/play/title = Выбор игры
|
||||
menu/play/no_games = Нет ни одной игры. Соберите игру в менеджере модов.
|
||||
menu/play/new_game = Новая игра
|
||||
menu/play/continue = Продолжить
|
||||
menu/play/browse_saves = Сохранения
|
||||
menu/play/install = Установить недостающее
|
||||
menu/play/installing = Установка…
|
||||
menu/play/configure = Настроить
|
||||
menu/play/install_done = Моды установлены. Они загрузятся при входе в игру.
|
||||
menu/play/install_failed = Не удалось установить моды — подробности в логе.
|
||||
menu/play/no_saves = Сохранений нет
|
||||
menu/play/saves = Сохранений: {0} · последнее: {1}
|
||||
menu/play/status_ready = Готова к игре
|
||||
menu/play/status_missing = Не хватает модов: {0}
|
||||
menu/play/status_mismatch = Несовместимые версии: {0}
|
||||
menu/play/status_problems = Ошибки в составе игры: {0}
|
||||
menu/play/status_install = Нужно установить: {0}, включить: {1}
|
||||
menu/load/empty = Сохранений пока нет
|
||||
menu/load/legacy_group = Сохранения без игры
|
||||
menu/load/legacy_title = Сохранение без игры
|
||||
menu/load/legacy_text = Это сохранение сделано до появления игр-коллекций. Выберите игру, к которой оно относится, — файл переедет в её сохранения.
|
||||
menu/load/legacy_ok = Перенести и загрузить
|
||||
menu/load/legacy_no_games = Нет готовых игр, к которым можно отнести сохранение.
|
||||
menu/load/diff_title = Состав игры изменился
|
||||
menu/load/diff_intro = С момента сохранения состав игры изменился.
|
||||
menu/load/diff_added = Добавлено (начнёт с нуля):
|
||||
menu/load/diff_removed = Убрано (данные сохранятся и вернутся, если включить снова):
|
||||
menu/load/diff_updated = Обновлено:
|
||||
menu/load/diff_downgrade = (версия старше установленной — данные мода будут отложены)
|
||||
menu/load/diff_backup = Перед загрузкой будет сделана резервная копия сохранения.
|
||||
menu/load/diff_ok = Загрузить
|
||||
|
||||
settings/back = Назад
|
||||
settings/audio = Аудио
|
||||
settings/sounds = Звуки
|
||||
settings/music = Музыка
|
||||
settings/volume = Громкость
|
||||
settings/language = Язык
|
||||
settings/lang_ru = Русский
|
||||
settings/lang_en = English
|
||||
settings/lang_zh = 中文
|
||||
settings/tab_general = Основное
|
||||
settings/tab_audio = Звук
|
||||
settings/tab_display = Экран
|
||||
settings/display = Экран
|
||||
settings/window_mode = Режим окна
|
||||
settings/window_mode_windowed = Оконный
|
||||
settings/window_mode_fullscreen = Полный экран
|
||||
settings/window_mode_borderless = Без рамки
|
||||
settings/resolution = Разрешение
|
||||
settings/monitor = Монитор
|
||||
settings/monitor_screen = Экран {0} ({1}x{2})
|
||||
settings/vsync = Вертикальная синхронизация
|
||||
settings/scale = Масштаб
|
||||
settings/scale_1x = 1x
|
||||
settings/scale_2x = 2x
|
||||
settings/scale_3x = 3x
|
||||
settings/scale_4x = 4x
|
||||
settings/tab_mods = Настройки модов
|
||||
settings/mod_list_title = Моды
|
||||
settings/mods_no_settings = Нет доступных настроек модов
|
||||
settings/mods_reset = Сбросить настройки
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"id": "base_menu",
|
||||
"name": "Base Menu",
|
||||
"version": "1.0.0",
|
||||
"author": "ct",
|
||||
"description": "Registry-driven main menu: built-in buttons, settings and save/load panels moved out of the core.",
|
||||
"category": "ui",
|
||||
"sources": [
|
||||
"src/**/*.cs"
|
||||
],
|
||||
"depends": ["interface>=1"],
|
||||
"test_suites": [
|
||||
{"id": "menu_flow", "activate": ["--itest=menu_flow"]},
|
||||
{"id": "save_dormancy", "activate": ["--itest=save_dormancy"]},
|
||||
{"id": "new_game", "activate": ["--itest=new_game"]},
|
||||
{"id": "hot_reload", "activate": ["--itest=hot_reload"]}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"Scene": "MainMenu",
|
||||
"Musics": [
|
||||
{
|
||||
"MusicType": "Ambient",
|
||||
"MusicNames": [
|
||||
"Serpent's Lament"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
[gd_scene format=3]
|
||||
|
||||
[ext_resource type="Script" path="src/MainMenu.cs" id="MainMenuScript"]
|
||||
[ext_resource type="Script" uid="uid://dvk5pel3k4k0b" path="res://Core/UI/Base/ModSpriteLoader.cs" id="ModSpriteLoaderScript"]
|
||||
|
||||
[node name="MainMenuScene" type="Control" unique_id=721126382]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
script = ExtResource("MainMenuScript")
|
||||
|
||||
[node name="Background" type="TextureRect" parent="." unique_id=802580433]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
|
||||
[node name="SpriteLoader" type="Node" parent="Background" unique_id=1771974973]
|
||||
script = ExtResource("ModSpriteLoaderScript")
|
||||
TextureId = "base/dark"
|
||||
|
||||
[node name="MenuContainer" type="VBoxContainer" parent="." unique_id=196926241]
|
||||
layout_mode = 1
|
||||
anchors_preset = 4
|
||||
anchor_top = 0.5
|
||||
anchor_bottom = 0.5
|
||||
offset_left = 100.0
|
||||
offset_top = -120.0
|
||||
offset_right = 482.0
|
||||
offset_bottom = 294.0
|
||||
grow_vertical = 2
|
||||
theme_override_constants/separation = 12
|
||||
|
||||
[node name="GameButtons" type="VBoxContainer" parent="MenuContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 12
|
||||
|
||||
[node name="ModMenuButtons" type="VBoxContainer" parent="MenuContainer" unique_id=484218081]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 12
|
||||
|
||||
[node name="space1" type="Control" parent="MenuContainer" unique_id=484218079]
|
||||
custom_minimum_size = Vector2(0, 20)
|
||||
layout_mode = 2
|
||||
|
||||
[node name="ExitButtons" type="VBoxContainer" parent="MenuContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 12
|
||||
|
||||
[node name="space2" type="Control" parent="MenuContainer" unique_id=1261364549]
|
||||
custom_minimum_size = Vector2(0, 20)
|
||||
layout_mode = 2
|
||||
|
||||
[node name="ToolMenuButtons" type="VBoxContainer" parent="MenuContainer" unique_id=43694906]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 12
|
||||
|
||||
[node name="CenterContainer" type="AspectRatioContainer" parent="." unique_id=1751426695]
|
||||
layout_mode = 1
|
||||
anchors_preset = -1
|
||||
anchor_left = 0.324
|
||||
anchor_top = 0.12200001
|
||||
anchor_right = 0.90900004
|
||||
anchor_bottom = 0.86
|
||||
offset_left = -0.08001709
|
||||
offset_top = 0.07199097
|
||||
offset_right = -0.2800293
|
||||
offset_bottom = 0.35998535
|
||||
|
||||
[node name="Icon" type="TextureRect" parent="CenterContainer" unique_id=705739778]
|
||||
layout_mode = 2
|
||||
expand_mode = 1
|
||||
|
||||
[node name="SpriteLoader" type="Node" parent="CenterContainer/Icon" unique_id=1000000002]
|
||||
script = ExtResource("ModSpriteLoaderScript")
|
||||
TextureId = "icon"
|
||||
|
||||
[node name="TelegramLink" type="RichTextLabel" parent="." unique_id=1972736152]
|
||||
layout_mode = 1
|
||||
anchors_preset = -1
|
||||
anchor_left = 0.86847913
|
||||
anchor_top = 0.9023047
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_left = 25.52002
|
||||
offset_top = 33.039978
|
||||
offset_right = -27.0
|
||||
offset_bottom = -24.0
|
||||
grow_horizontal = 0
|
||||
grow_vertical = 0
|
||||
bbcode_enabled = true
|
||||
text = "[url=t.me/cthangover]t.me/cthangover[/url]"
|
||||
fit_content = true
|
||||
scroll_active = false
|
||||
shortcut_keys_enabled = false
|
||||
vertical_alignment = 1
|
||||
metadata/_edit_use_anchors_ = true
|
||||
|
||||
[node name="TgIcon" type="TextureRect" parent="TelegramLink" unique_id=59083692]
|
||||
custom_minimum_size = Vector2(32, 32)
|
||||
layout_mode = 1
|
||||
anchors_preset = -1
|
||||
anchor_left = 0.71500003
|
||||
anchor_top = 0.163
|
||||
anchor_right = 0.87000006
|
||||
anchor_bottom = 0.90700006
|
||||
offset_left = -1.0
|
||||
offset_top = -0.009012699
|
||||
offset_right = -1.5258789e-05
|
||||
offset_bottom = -0.0010147095
|
||||
expand_mode = 1
|
||||
|
||||
[node name="SpriteLoader" type="Node" parent="TelegramLink/TgIcon" unique_id=1275078805]
|
||||
script = ExtResource("ModSpriteLoaderScript")
|
||||
TextureId = "tg"
|
||||
@@ -0,0 +1,84 @@
|
||||
[gd_scene load_steps=3 format=3]
|
||||
|
||||
[ext_resource type="Script" path="src/SaveLoadMenu.cs" id="SaveLoadMenuScript"]
|
||||
[ext_resource type="Script" path="src/SaveSlotList.cs" id="SaveSlotListScript"]
|
||||
|
||||
[node name="SaveLoadMenu" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 1
|
||||
script = ExtResource("SaveLoadMenuScript")
|
||||
|
||||
[node name="Bg" type="ColorRect" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
color = Color(0, 0, 0, 0.65)
|
||||
|
||||
[node name="Panel" type="Panel" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 8
|
||||
anchor_left = 0.5
|
||||
anchor_top = 0.5
|
||||
anchor_right = 0.5
|
||||
anchor_bottom = 0.5
|
||||
offset_left = -420.0
|
||||
offset_top = -280.0
|
||||
offset_right = 420.0
|
||||
offset_bottom = 280.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
|
||||
[node name="Margin" type="MarginContainer" parent="Panel"]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
theme_override_constants/margin_left = 28
|
||||
theme_override_constants/margin_top = 20
|
||||
theme_override_constants/margin_right = 28
|
||||
theme_override_constants/margin_bottom = 20
|
||||
|
||||
[node name="VBox" type="VBoxContainer" parent="Panel/Margin"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 14
|
||||
|
||||
[node name="Title" type="Label" parent="Panel/Margin/VBox"]
|
||||
layout_mode = 2
|
||||
text = "Save/Load"
|
||||
horizontal_alignment = 1
|
||||
theme_override_font_sizes/font_size = 24
|
||||
|
||||
[node name="GameSelect" type="OptionButton" parent="Panel/Margin/VBox"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="Scroll" type="ScrollContainer" parent="Panel/Margin/VBox"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 3
|
||||
custom_minimum_size = Vector2(0, 380)
|
||||
horizontal_scroll_mode = 0
|
||||
|
||||
[node name="SlotGrid" type="Control" parent="Panel/Margin/VBox/Scroll"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
script = ExtResource("SaveSlotListScript")
|
||||
|
||||
[node name="EmptyLabel" type="Label" parent="Panel/Margin/VBox"]
|
||||
layout_mode = 2
|
||||
horizontal_alignment = 1
|
||||
visible = false
|
||||
|
||||
[node name="BackBtn" type="Button" parent="Panel/Margin/VBox"]
|
||||
layout_mode = 2
|
||||
text = "Back"
|
||||
size_flags_horizontal = 8
|
||||
@@ -0,0 +1,89 @@
|
||||
[gd_scene format=3]
|
||||
|
||||
[ext_resource type="Script" path="src/SaveSlotItem.cs" id="SaveSlotItemScript"]
|
||||
|
||||
[node name="SaveSlotItem" type="Control" unique_id=2135443384]
|
||||
custom_minimum_size = Vector2(120, 120)
|
||||
layout_mode = 3
|
||||
anchors_preset = 0
|
||||
script = ExtResource("SaveSlotItemScript")
|
||||
|
||||
[node name="Background" type="ColorRect" parent="." unique_id=255218239]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
color = Color(0.12, 0.12, 0.12, 0.9)
|
||||
|
||||
[node name="ScreenshotRect" type="TextureRect" parent="." unique_id=1290008068]
|
||||
layout_mode = 1
|
||||
anchors_preset = -1
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 0.55
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
expand_mode = 1
|
||||
stretch_mode = 5
|
||||
|
||||
[node name="DateLabel" type="Label" parent="." unique_id=408317888]
|
||||
layout_mode = 1
|
||||
anchors_preset = -1
|
||||
anchor_left = 0.02
|
||||
anchor_top = 0.57
|
||||
anchor_right = 0.98
|
||||
anchor_bottom = 0.68
|
||||
grow_horizontal = 2
|
||||
theme_override_font_sizes/font_size = 12
|
||||
|
||||
[node name="SceneLabel" type="Label" parent="." unique_id=1968956898]
|
||||
layout_mode = 1
|
||||
anchors_preset = -1
|
||||
anchor_left = 0.02
|
||||
anchor_top = 0.70
|
||||
anchor_right = 0.98
|
||||
anchor_bottom = 0.80
|
||||
grow_horizontal = 2
|
||||
theme_override_font_sizes/font_size = 11
|
||||
|
||||
[node name="TimeLabel" type="Label" parent="." unique_id=698436659]
|
||||
layout_mode = 1
|
||||
anchors_preset = -1
|
||||
anchor_left = 0.02
|
||||
anchor_top = 0.83
|
||||
anchor_right = 0.56
|
||||
anchor_bottom = 0.95
|
||||
grow_horizontal = 2
|
||||
theme_override_font_sizes/font_size = 10
|
||||
|
||||
[node name="StatsLabel" type="Label" parent="." unique_id=400000001]
|
||||
layout_mode = 1
|
||||
anchors_preset = -1
|
||||
anchor_left = 0.58
|
||||
anchor_top = 0.83
|
||||
anchor_right = 0.98
|
||||
anchor_bottom = 0.95
|
||||
grow_horizontal = 2
|
||||
theme_override_font_sizes/font_size = 10
|
||||
horizontal_alignment = 2
|
||||
|
||||
[node name="EmptyLabel" type="Label" parent="." unique_id=1262712091]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
theme_override_font_sizes/font_size = 14
|
||||
horizontal_alignment = 1
|
||||
vertical_alignment = 1
|
||||
|
||||
[node name="SlotButton" type="Button" parent="." unique_id=1877010914]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
flat = true
|
||||
@@ -0,0 +1,370 @@
|
||||
[gd_scene format=3]
|
||||
|
||||
[ext_resource type="Script" path="src/SettingsMenu.cs" id="SettingsMenuScript"]
|
||||
|
||||
[node name="SettingsMenu" type="Control" unique_id=894644832]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 1
|
||||
script = ExtResource("SettingsMenuScript")
|
||||
|
||||
[node name="Bg" type="ColorRect" parent="." unique_id=21945756]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
color = Color(0, 0, 0, 1)
|
||||
|
||||
[node name="Panel" type="Panel" parent="." unique_id=1554260654]
|
||||
layout_mode = 1
|
||||
anchors_preset = -1
|
||||
anchor_left = 0.1
|
||||
anchor_top = 0.1
|
||||
anchor_right = 0.9
|
||||
anchor_bottom = 0.9
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
|
||||
[node name="Margin" type="MarginContainer" parent="Panel" unique_id=1929120286]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
theme_override_constants/margin_left = 28
|
||||
theme_override_constants/margin_top = 20
|
||||
theme_override_constants/margin_right = 28
|
||||
theme_override_constants/margin_bottom = 20
|
||||
|
||||
[node name="VBox" type="VBoxContainer" parent="Panel/Margin" unique_id=150548585]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 14
|
||||
|
||||
[node name="Title" type="Label" parent="Panel/Margin/VBox" unique_id=86160906]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 26
|
||||
text = "Settings"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="Tabs" type="TabContainer" parent="Panel/Margin/VBox" unique_id=1524671125]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
tab_alignment = 1
|
||||
current_tab = 0
|
||||
|
||||
[node name="GeneralMargin" type="MarginContainer" parent="Panel/Margin/VBox/Tabs"]
|
||||
layout_mode = 2
|
||||
metadata/_tab_index = 0
|
||||
theme_override_constants/margin_left = 16
|
||||
theme_override_constants/margin_top = 12
|
||||
theme_override_constants/margin_right = 16
|
||||
theme_override_constants/margin_bottom = 12
|
||||
|
||||
[node name="General" type="VBoxContainer" parent="Panel/Margin/VBox/Tabs/GeneralMargin" unique_id=2028800899]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 12
|
||||
|
||||
[node name="Spacer" type="Control" parent="Panel/Margin/VBox/Tabs/GeneralMargin/General" unique_id=1203872148]
|
||||
custom_minimum_size = Vector2(0, 12)
|
||||
layout_mode = 2
|
||||
|
||||
[node name="LangBox" type="HBoxContainer" parent="Panel/Margin/VBox/Tabs/GeneralMargin/General" unique_id=684741721]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 16
|
||||
|
||||
[node name="LangLabel" type="Label" parent="Panel/Margin/VBox/Tabs/GeneralMargin/General/LangBox" unique_id=1707108944]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
text = "Language"
|
||||
|
||||
[node name="LangSelect" type="OptionButton" parent="Panel/Margin/VBox/Tabs/GeneralMargin/General/LangBox" unique_id=2037857401]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="AudioMargin" type="MarginContainer" parent="Panel/Margin/VBox/Tabs"]
|
||||
layout_mode = 2
|
||||
visible = false
|
||||
metadata/_tab_index = 1
|
||||
theme_override_constants/margin_left = 16
|
||||
theme_override_constants/margin_top = 12
|
||||
theme_override_constants/margin_right = 16
|
||||
theme_override_constants/margin_bottom = 12
|
||||
|
||||
[node name="Audio" type="VBoxContainer" parent="Panel/Margin/VBox/Tabs/AudioMargin" unique_id=564523157]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 4
|
||||
|
||||
[node name="SoundsBox" type="VBoxContainer" parent="Panel/Margin/VBox/Tabs/AudioMargin/Audio" unique_id=508426867]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 4
|
||||
|
||||
[node name="ToggleRow" type="HBoxContainer" parent="Panel/Margin/VBox/Tabs/AudioMargin/Audio/SoundsBox" unique_id=274422939]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 12
|
||||
|
||||
[node name="SoundsLabel" type="Label" parent="Panel/Margin/VBox/Tabs/AudioMargin/Audio/SoundsBox/ToggleRow" unique_id=1483011032]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
text = "Sounds"
|
||||
|
||||
[node name="SoundsToggle" type="CheckButton" parent="Panel/Margin/VBox/Tabs/AudioMargin/Audio/SoundsBox/ToggleRow" unique_id=126270437]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="VolumeRow" type="HBoxContainer" parent="Panel/Margin/VBox/Tabs/AudioMargin/Audio/SoundsBox" unique_id=585263148]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 12
|
||||
|
||||
[node name="SoundsVolumeLabel" type="Label" parent="Panel/Margin/VBox/Tabs/AudioMargin/Audio/SoundsBox/VolumeRow" unique_id=1327120426]
|
||||
layout_mode = 2
|
||||
text = "Volume"
|
||||
|
||||
[node name="SoundsVolume" type="HSlider" parent="Panel/Margin/VBox/Tabs/AudioMargin/Audio/SoundsBox/VolumeRow" unique_id=2061615116]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
value = 100.0
|
||||
|
||||
[node name="SoundsVolumeValue" type="Label" parent="Panel/Margin/VBox/Tabs/AudioMargin/Audio/SoundsBox/VolumeRow" unique_id=1079108664]
|
||||
custom_minimum_size = Vector2(48, 0)
|
||||
layout_mode = 2
|
||||
text = "100%"
|
||||
horizontal_alignment = 2
|
||||
|
||||
[node name="Sep" type="HSeparator" parent="Panel/Margin/VBox/Tabs/AudioMargin/Audio" unique_id=988886928]
|
||||
custom_minimum_size = Vector2(0, 8)
|
||||
layout_mode = 2
|
||||
|
||||
[node name="MusicBox" type="VBoxContainer" parent="Panel/Margin/VBox/Tabs/AudioMargin/Audio" unique_id=1316259016]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 4
|
||||
|
||||
[node name="ToggleRow2" type="HBoxContainer" parent="Panel/Margin/VBox/Tabs/AudioMargin/Audio/MusicBox" unique_id=553618696]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 12
|
||||
|
||||
[node name="MusicLabel" type="Label" parent="Panel/Margin/VBox/Tabs/AudioMargin/Audio/MusicBox/ToggleRow2" unique_id=1559600377]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
text = "Music"
|
||||
|
||||
[node name="MusicToggle" type="CheckButton" parent="Panel/Margin/VBox/Tabs/AudioMargin/Audio/MusicBox/ToggleRow2" unique_id=901979875]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="VolumeRow2" type="HBoxContainer" parent="Panel/Margin/VBox/Tabs/AudioMargin/Audio/MusicBox" unique_id=1411284591]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 12
|
||||
|
||||
[node name="MusicVolumeLabel" type="Label" parent="Panel/Margin/VBox/Tabs/AudioMargin/Audio/MusicBox/VolumeRow2" unique_id=1952498749]
|
||||
layout_mode = 2
|
||||
text = "Volume"
|
||||
|
||||
[node name="MusicVolume" type="HSlider" parent="Panel/Margin/VBox/Tabs/AudioMargin/Audio/MusicBox/VolumeRow2" unique_id=504218348]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
value = 5.0
|
||||
|
||||
[node name="MusicVolumeValue" type="Label" parent="Panel/Margin/VBox/Tabs/AudioMargin/Audio/MusicBox/VolumeRow2" unique_id=1866810087]
|
||||
custom_minimum_size = Vector2(48, 0)
|
||||
layout_mode = 2
|
||||
text = "5%"
|
||||
horizontal_alignment = 2
|
||||
|
||||
[node name="Sep2" type="HSeparator" parent="Panel/Margin/VBox/Tabs/AudioMargin/Audio" unique_id=1486966851]
|
||||
custom_minimum_size = Vector2(0, 8)
|
||||
layout_mode = 2
|
||||
|
||||
[node name="AmbientBox" type="VBoxContainer" parent="Panel/Margin/VBox/Tabs/AudioMargin/Audio" unique_id=127969403]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 4
|
||||
|
||||
[node name="ToggleRow3" type="HBoxContainer" parent="Panel/Margin/VBox/Tabs/AudioMargin/Audio/AmbientBox" unique_id=338401886]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 12
|
||||
|
||||
[node name="AmbientLabel" type="Label" parent="Panel/Margin/VBox/Tabs/AudioMargin/Audio/AmbientBox/ToggleRow3" unique_id=872436692]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
text = "Ambient"
|
||||
|
||||
[node name="AmbientToggle" type="CheckButton" parent="Panel/Margin/VBox/Tabs/AudioMargin/Audio/AmbientBox/ToggleRow3" unique_id=1160929014]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="VolumeRow3" type="HBoxContainer" parent="Panel/Margin/VBox/Tabs/AudioMargin/Audio/AmbientBox" unique_id=1139865718]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 12
|
||||
|
||||
[node name="AmbientVolumeLabel" type="Label" parent="Panel/Margin/VBox/Tabs/AudioMargin/Audio/AmbientBox/VolumeRow3" unique_id=1610793395]
|
||||
layout_mode = 2
|
||||
text = "Volume"
|
||||
|
||||
[node name="AmbientVolume" type="HSlider" parent="Panel/Margin/VBox/Tabs/AudioMargin/Audio/AmbientBox/VolumeRow3" unique_id=1343104331]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
value = 5.0
|
||||
|
||||
[node name="AmbientVolumeValue" type="Label" parent="Panel/Margin/VBox/Tabs/AudioMargin/Audio/AmbientBox/VolumeRow3" unique_id=1909728919]
|
||||
custom_minimum_size = Vector2(48, 0)
|
||||
layout_mode = 2
|
||||
text = "5%"
|
||||
horizontal_alignment = 2
|
||||
|
||||
[node name="DisplayMargin" type="MarginContainer" parent="Panel/Margin/VBox/Tabs"]
|
||||
layout_mode = 2
|
||||
visible = false
|
||||
metadata/_tab_index = 2
|
||||
theme_override_constants/margin_left = 16
|
||||
theme_override_constants/margin_top = 12
|
||||
theme_override_constants/margin_right = 16
|
||||
theme_override_constants/margin_bottom = 12
|
||||
|
||||
[node name="Display" type="VBoxContainer" parent="Panel/Margin/VBox/Tabs/DisplayMargin" unique_id=1371435162]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="Spacer2" type="Control" parent="Panel/Margin/VBox/Tabs/DisplayMargin/Display" unique_id=270871165]
|
||||
custom_minimum_size = Vector2(0, 12)
|
||||
layout_mode = 2
|
||||
|
||||
[node name="ModeBox" type="HBoxContainer" parent="Panel/Margin/VBox/Tabs/DisplayMargin/Display" unique_id=455596205]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 16
|
||||
|
||||
[node name="ModeLabel" type="Label" parent="Panel/Margin/VBox/Tabs/DisplayMargin/Display/ModeBox" unique_id=1346202262]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
text = "Window Mode"
|
||||
|
||||
[node name="ModeSelect" type="OptionButton" parent="Panel/Margin/VBox/Tabs/DisplayMargin/Display/ModeBox" unique_id=631713497]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="ResBox" type="HBoxContainer" parent="Panel/Margin/VBox/Tabs/DisplayMargin/Display" unique_id=603217917]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 16
|
||||
|
||||
[node name="ResLabel" type="Label" parent="Panel/Margin/VBox/Tabs/DisplayMargin/Display/ResBox" unique_id=1714673968]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
text = "Resolution"
|
||||
|
||||
[node name="ResSelect" type="OptionButton" parent="Panel/Margin/VBox/Tabs/DisplayMargin/Display/ResBox" unique_id=651322592]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="MonitorBox" type="HBoxContainer" parent="Panel/Margin/VBox/Tabs/DisplayMargin/Display" unique_id=284981749]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 16
|
||||
|
||||
[node name="MonitorLabel" type="Label" parent="Panel/Margin/VBox/Tabs/DisplayMargin/Display/MonitorBox" unique_id=1905911340]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
text = "Monitor"
|
||||
|
||||
[node name="MonitorSelect" type="OptionButton" parent="Panel/Margin/VBox/Tabs/DisplayMargin/Display/MonitorBox" unique_id=1334336966]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="ScaleBox" type="HBoxContainer" parent="Panel/Margin/VBox/Tabs/DisplayMargin/Display" unique_id=930383631]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 16
|
||||
|
||||
[node name="ScaleLabel" type="Label" parent="Panel/Margin/VBox/Tabs/DisplayMargin/Display/ScaleBox" unique_id=1924008447]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
text = "Scale"
|
||||
|
||||
[node name="ScaleSelect" type="OptionButton" parent="Panel/Margin/VBox/Tabs/DisplayMargin/Display/ScaleBox" unique_id=2096132155]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="VSyncRow" type="HBoxContainer" parent="Panel/Margin/VBox/Tabs/DisplayMargin/Display" unique_id=348516587]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 12
|
||||
|
||||
[node name="VSyncLabel" type="Label" parent="Panel/Margin/VBox/Tabs/DisplayMargin/Display/VSyncRow" unique_id=1204827790]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
text = "VSync"
|
||||
|
||||
[node name="VSyncToggle" type="CheckButton" parent="Panel/Margin/VBox/Tabs/DisplayMargin/Display/VSyncRow" unique_id=1909935663]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="ModSettingsMargin" type="MarginContainer" parent="Panel/Margin/VBox/Tabs"]
|
||||
layout_mode = 2
|
||||
visible = false
|
||||
metadata/_tab_index = 3
|
||||
theme_override_constants/margin_left = 16
|
||||
theme_override_constants/margin_top = 12
|
||||
theme_override_constants/margin_right = 16
|
||||
theme_override_constants/margin_bottom = 12
|
||||
|
||||
[node name="ModSettings" type="HBoxContainer" parent="Panel/Margin/VBox/Tabs/ModSettingsMargin" unique_id=2038671463]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
theme_override_constants/separation = 12
|
||||
|
||||
[node name="LeftPanel" type="VBoxContainer" parent="Panel/Margin/VBox/Tabs/ModSettingsMargin/ModSettings" unique_id=1856089080]
|
||||
custom_minimum_size = Vector2(240, 0)
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 6
|
||||
|
||||
[node name="ModsTitle" type="Label" parent="Panel/Margin/VBox/Tabs/ModSettingsMargin/ModSettings/LeftPanel" unique_id=1639725410]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 15
|
||||
text = "Mods"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="ModScroll" type="ScrollContainer" parent="Panel/Margin/VBox/Tabs/ModSettingsMargin/ModSettings/LeftPanel" unique_id=1412822434]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
horizontal_scroll_mode = 0
|
||||
|
||||
[node name="ModList" type="VBoxContainer" parent="Panel/Margin/VBox/Tabs/ModSettingsMargin/ModSettings/LeftPanel/ModScroll" unique_id=543902025]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="Sep" type="VBoxContainer" parent="Panel/Margin/VBox/Tabs/ModSettingsMargin/ModSettings" unique_id=950619413]
|
||||
custom_minimum_size = Vector2(10, 0)
|
||||
layout_mode = 2
|
||||
|
||||
[node name="RightPanel" type="VBoxContainer" parent="Panel/Margin/VBox/Tabs/ModSettingsMargin/ModSettings" unique_id=272738827]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="TopBar" type="HBoxContainer" parent="Panel/Margin/VBox/Tabs/ModSettingsMargin/ModSettings/RightPanel" unique_id=678746437]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 12
|
||||
|
||||
[node name="SelectedModName" type="Label" parent="Panel/Margin/VBox/Tabs/ModSettingsMargin/ModSettings/RightPanel/TopBar" unique_id=393931159]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_font_sizes/font_size = 16
|
||||
|
||||
[node name="ResetBtn" type="Button" parent="Panel/Margin/VBox/Tabs/ModSettingsMargin/ModSettings/RightPanel/TopBar" unique_id=531057889]
|
||||
layout_mode = 2
|
||||
text = "Reset"
|
||||
|
||||
[node name="SettingsScroll" type="ScrollContainer" parent="Panel/Margin/VBox/Tabs/ModSettingsMargin/ModSettings/RightPanel" unique_id=674333894]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
horizontal_scroll_mode = 0
|
||||
|
||||
[node name="SettingsContainer" type="VBoxContainer" parent="Panel/Margin/VBox/Tabs/ModSettingsMargin/ModSettings/RightPanel/SettingsScroll" unique_id=1664189823]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 6
|
||||
|
||||
[node name="Placeholder" type="Label" parent="Panel/Margin/VBox/Tabs/ModSettingsMargin/ModSettings/RightPanel/SettingsScroll/SettingsContainer" unique_id=1501445323]
|
||||
layout_mode = 2
|
||||
text = "No mod settings available"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="BackBtn" type="Button" parent="Panel/Margin/VBox" unique_id=249196016]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 8
|
||||
text = "Back"
|
||||
@@ -0,0 +1,149 @@
|
||||
using System;
|
||||
using Cthangover.Core.Games;
|
||||
using Cthangover.Core.Mods;
|
||||
using Cthangover.Core.Scenes;
|
||||
using Cthangover.Core.Settings;
|
||||
using Cthangover.Core.UI.Menu;
|
||||
using Godot;
|
||||
|
||||
namespace Cthangover.BaseMenu
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers the built-in main-menu buttons and the settings/save-load panels
|
||||
/// in <see cref="MenuRegistry"/>. Runs from
|
||||
/// <c>ModInitializerRegistry.NotifyResourcesReady</c>, which fires after the
|
||||
/// registry itself is built, so these entries are appended to live registry
|
||||
/// state rather than collected from a manifest.
|
||||
/// </summary>
|
||||
public class BaseMenuInitializer : IModInitializer
|
||||
{
|
||||
private const string ModId = "base_menu";
|
||||
|
||||
public void OnModLoaded(string modId)
|
||||
{
|
||||
}
|
||||
|
||||
public void OnModResourcesReady()
|
||||
{
|
||||
MenuRegistry.RegisterMenuPanel(new MenuPanelEntry
|
||||
{
|
||||
Id = "settings",
|
||||
ModId = ModId,
|
||||
ScenePath = "scenes/menu/settings_menu.tscn",
|
||||
Locale = "menu/settings",
|
||||
});
|
||||
MenuRegistry.RegisterMenuPanel(new MenuPanelEntry
|
||||
{
|
||||
Id = "save_load",
|
||||
ModId = ModId,
|
||||
ScenePath = "scenes/menu/save_load_menu.tscn",
|
||||
});
|
||||
|
||||
MenuRegistry.RegisterMenuButton(new MenuButtonEntry
|
||||
{
|
||||
Id = "continue",
|
||||
ModId = ModId,
|
||||
Section = "game",
|
||||
Locale = "menu/continue",
|
||||
Variation = "primary",
|
||||
Index = 5,
|
||||
Visible = GameFlow.HasContinue,
|
||||
Handler = BaseMenuActions.Continue,
|
||||
});
|
||||
MenuRegistry.RegisterMenuButton(new MenuButtonEntry
|
||||
{
|
||||
Id = "play",
|
||||
ModId = ModId,
|
||||
Section = "game",
|
||||
Locale = "menu/play",
|
||||
Variation = "primary",
|
||||
Index = 10,
|
||||
Handler = BaseMenuActions.Play,
|
||||
});
|
||||
// A new game of the running game; from the front-end games start
|
||||
// from the picker instead.
|
||||
MenuRegistry.RegisterMenuButton(new MenuButtonEntry
|
||||
{
|
||||
Id = "new_game",
|
||||
ModId = ModId,
|
||||
Section = "game",
|
||||
Locale = "menu/newgame",
|
||||
Index = 15,
|
||||
Visible = () => !ActiveGame.IsFrontendOnly,
|
||||
Handler = BaseMenuActions.NewGame,
|
||||
});
|
||||
MenuRegistry.RegisterMenuButton(new MenuButtonEntry
|
||||
{
|
||||
Id = "load",
|
||||
ModId = ModId,
|
||||
Section = "game",
|
||||
Locale = "ui/menu/load",
|
||||
Index = 20,
|
||||
Handler = BaseMenuActions.Load,
|
||||
});
|
||||
MenuRegistry.RegisterMenuButton(new MenuButtonEntry
|
||||
{
|
||||
Id = "settings",
|
||||
ModId = ModId,
|
||||
Section = "game",
|
||||
Locale = "menu/settings",
|
||||
Index = 30,
|
||||
Handler = BaseMenuActions.Settings,
|
||||
});
|
||||
MenuRegistry.RegisterMenuButton(new MenuButtonEntry
|
||||
{
|
||||
Id = "exit",
|
||||
ModId = ModId,
|
||||
Section = "exit",
|
||||
Locale = "menu/exit",
|
||||
Variation = "danger",
|
||||
Index = 1000,
|
||||
Handler = BaseMenuActions.Exit,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handlers for the built-in menu buttons. Each receives the menu root control
|
||||
/// so it can locate the panels instantiated as named children by
|
||||
/// <see cref="MainMenu"/>.
|
||||
/// </summary>
|
||||
internal static class BaseMenuActions
|
||||
{
|
||||
/// <summary>
|
||||
/// Starts a new game at the active game's entry scene (<c>start_scene</c>
|
||||
/// when no game collection is active) through <see cref="GameSession"/>.
|
||||
/// </summary>
|
||||
public static void NewGame(Control host)
|
||||
{
|
||||
GameSession.StartNew(host);
|
||||
}
|
||||
|
||||
/// <summary>Loads the last save of the last played game (restarting into it when needed).</summary>
|
||||
public static void Continue(Control host) => GameFlow.Continue(host);
|
||||
|
||||
/// <summary>Opens the game picker.</summary>
|
||||
public static void Play(Control host)
|
||||
{
|
||||
host.GetNodeOrNull<GamePickerPanel>("GamePickerPanel")?.Open();
|
||||
}
|
||||
|
||||
/// <summary>Opens the save/load panel in load mode.</summary>
|
||||
public static void Load(Control host)
|
||||
{
|
||||
host.GetNodeOrNull<SaveLoadMenu>("SaveLoadPanel")?.OpenForLoad();
|
||||
}
|
||||
|
||||
/// <summary>Shows the settings overlay.</summary>
|
||||
public static void Settings(Control host)
|
||||
{
|
||||
host.GetNodeOrNull<SettingsMenu>("SettingsPanel")?.Open();
|
||||
}
|
||||
|
||||
/// <summary>Quits the application immediately via <c>SceneTree.Quit</c>.</summary>
|
||||
public static void Exit(Control host)
|
||||
{
|
||||
host.GetTree().Quit();
|
||||
}
|
||||
}
|
||||
}
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using Cthangover.Core.Games;
|
||||
using Cthangover.Core.Mods;
|
||||
using Cthangover.Core.Settings;
|
||||
using Cthangover.Core.UI.Theming;
|
||||
using Godot;
|
||||
|
||||
namespace Cthangover.BaseMenu
|
||||
{
|
||||
/// <summary>
|
||||
/// Menu-side rules for entering games: "Continue", starting a game and
|
||||
/// loading a save. A game other than the running one (or any game from
|
||||
/// the front-end) is entered by replacing the mod set in-process
|
||||
/// (<see cref="GameSession.SwitchToAsync"/>). Before a save is loaded into a changed composition
|
||||
/// the player sees what changes (added mechanics start fresh, removed ones
|
||||
/// keep their data dormant) and a backup is taken.
|
||||
/// </summary>
|
||||
internal static class GameFlow
|
||||
{
|
||||
/// <summary>Set after installing mods in this process: entering any game, even the running one, reloads the mod set to pick them up.</summary>
|
||||
public static bool ReloadRequired { get; set; }
|
||||
|
||||
public static bool HasContinue() => LastSessionStore.Load() != null;
|
||||
|
||||
public static void Continue(Node host)
|
||||
{
|
||||
var last = LastSessionStore.Load();
|
||||
if (last != null)
|
||||
RequestLoad(host, last.GameId, last.FileName);
|
||||
}
|
||||
|
||||
public static void StartGame(Node host, string gameId)
|
||||
{
|
||||
if (IsRunning(gameId) && !ReloadRequired)
|
||||
GameSession.StartNew(host);
|
||||
else
|
||||
Switch(host, gameId, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads <paramref name="fileName"/> of <paramref name="gameId"/> (null =
|
||||
/// a save made without a game), comparing compositions first.
|
||||
/// </summary>
|
||||
public static void RequestLoad(Node host, string gameId, string fileName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(gameId))
|
||||
{
|
||||
// Development launch with every mod: the save belongs to that
|
||||
// mod set, load it as before.
|
||||
if (!ActiveGame.IsActive && !ActiveGame.IsFrontendOnly)
|
||||
{
|
||||
GameSession.LoadAndEnter(host, fileName);
|
||||
return;
|
||||
}
|
||||
|
||||
ChooseGameForSave(host, fileName);
|
||||
return;
|
||||
}
|
||||
|
||||
var package = SaveService.ReadPackage(gameId, fileName);
|
||||
var diff = SaveCompositionDiff.Compute(package?.Game?.Mods, GameCatalog.Composition(gameId));
|
||||
if (diff.IsEmpty)
|
||||
{
|
||||
Proceed(host, gameId, fileName, backup: false);
|
||||
return;
|
||||
}
|
||||
|
||||
Confirm(host,
|
||||
TranslationServer.Translate("menu/load/diff_title"),
|
||||
DescribeDiff(diff),
|
||||
TranslationServer.Translate("menu/load/diff_ok"),
|
||||
() => Proceed(host, gameId, fileName, backup: true));
|
||||
}
|
||||
|
||||
private static void Proceed(Node host, string gameId, string fileName, bool backup)
|
||||
{
|
||||
if (backup)
|
||||
SaveService.BackupSave(gameId, fileName);
|
||||
|
||||
if (IsRunning(gameId) && !ReloadRequired)
|
||||
GameSession.LoadAndEnter(host, fileName);
|
||||
else
|
||||
Switch(host, gameId, fileName);
|
||||
}
|
||||
|
||||
private static async void Switch(Node host, string gameId, string fileName)
|
||||
{
|
||||
if (await GameSession.SwitchToAsync(host, gameId, fileName))
|
||||
ReloadRequired = false;
|
||||
}
|
||||
|
||||
private static bool IsRunning(string gameId)
|
||||
=> ActiveGame.IsActive && string.Equals(ActiveGame.Id, gameId, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Lists what a composition change does to the save, in player terms.</summary>
|
||||
public static string DescribeDiff(SaveCompositionDiff diff)
|
||||
{
|
||||
var text = new StringBuilder(TranslationServer.Translate("menu/load/diff_intro"));
|
||||
void Section(string key, IEnumerable<string> lines)
|
||||
{
|
||||
var list = lines.ToList();
|
||||
if (list.Count == 0)
|
||||
return;
|
||||
text.Append("\n\n").Append(TranslationServer.Translate(key));
|
||||
foreach (var line in list)
|
||||
text.Append("\n • ").Append(line);
|
||||
}
|
||||
|
||||
Section("menu/load/diff_added", diff.Added.Select(ModTitle));
|
||||
Section("menu/load/diff_removed", diff.Removed.Select(ModTitle));
|
||||
Section("menu/load/diff_updated", diff.Updated.Select(u =>
|
||||
$"{ModTitle(u.ModId)}: {u.From} → {u.To}" + (u.Downgrade ? " " + TranslationServer.Translate("menu/load/diff_downgrade") : string.Empty)));
|
||||
text.Append("\n\n").Append(TranslationServer.Translate("menu/load/diff_backup"));
|
||||
return text.ToString();
|
||||
}
|
||||
|
||||
private static string ModTitle(string modId)
|
||||
=> ModRegistry.Instance.AllDiscoveredMods.TryGetValue(modId, out var info) && !string.IsNullOrEmpty(info.DisplayTitle)
|
||||
? $"{info.DisplayTitle} ({modId})"
|
||||
: modId;
|
||||
|
||||
/// <summary>A save made before games existed: the player picks the game it belongs to; it moves there.</summary>
|
||||
private static void ChooseGameForSave(Node host, string fileName)
|
||||
{
|
||||
var games = GameCatalog.Games().Where(g => g.IsReady).ToList();
|
||||
if (games.Count == 0)
|
||||
{
|
||||
Confirm(host, TranslationServer.Translate("menu/load/legacy_title"),
|
||||
TranslationServer.Translate("menu/load/legacy_no_games"), null, null);
|
||||
return;
|
||||
}
|
||||
|
||||
var select = new OptionButton();
|
||||
foreach (var game in games)
|
||||
select.AddItem(game.Name ?? game.Collection.Id);
|
||||
|
||||
var content = new VBoxContainer();
|
||||
content.AddChild(new Label
|
||||
{
|
||||
Text = TranslationServer.Translate("menu/load/legacy_text"),
|
||||
AutowrapMode = TextServer.AutowrapMode.WordSmart,
|
||||
CustomMinimumSize = new Vector2(420, 0),
|
||||
});
|
||||
content.AddChild(select);
|
||||
|
||||
var dialog = new ConfirmationDialog
|
||||
{
|
||||
Title = TranslationServer.Translate("menu/load/legacy_title"),
|
||||
OkButtonText = TranslationServer.Translate("menu/load/legacy_ok"),
|
||||
CancelButtonText = TranslationServer.Translate("ui/save/cancel_button"),
|
||||
Exclusive = true,
|
||||
};
|
||||
dialog.AddChild(content);
|
||||
dialog.Confirmed += () =>
|
||||
{
|
||||
var gameId = games[Math.Max(0, select.Selected)].Collection.Id;
|
||||
dialog.QueueFree();
|
||||
var slot = SaveService.AdoptLegacySave(fileName, gameId);
|
||||
RequestLoad(host, gameId, slot);
|
||||
};
|
||||
dialog.Canceled += dialog.QueueFree;
|
||||
host.AddChild(dialog);
|
||||
dialog.PopupCentered();
|
||||
}
|
||||
|
||||
/// <summary>Message or confirmation dialog; without <paramref name="onConfirm"/> it is a plain notice.</summary>
|
||||
public static void Confirm(Node host, string title, string text, string okText, Action onConfirm)
|
||||
{
|
||||
AcceptDialog dialog = onConfirm == null
|
||||
? new AcceptDialog()
|
||||
: new ConfirmationDialog { CancelButtonText = TranslationServer.Translate("ui/save/cancel_button") };
|
||||
dialog.Title = title;
|
||||
dialog.DialogText = text;
|
||||
dialog.Exclusive = true;
|
||||
if (!string.IsNullOrEmpty(okText))
|
||||
dialog.OkButtonText = okText;
|
||||
dialog.Confirmed += () =>
|
||||
{
|
||||
dialog.QueueFree();
|
||||
onConfirm?.Invoke();
|
||||
};
|
||||
dialog.Canceled += dialog.QueueFree;
|
||||
host.AddChild(dialog);
|
||||
if (onConfirm != null)
|
||||
CthangoverTheme.ApplyPrimary(dialog.GetOkButton());
|
||||
dialog.PopupCentered();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Cthangover.Core.Games;
|
||||
using Cthangover.Core.Mods;
|
||||
using Cthangover.Core.Settings;
|
||||
using Cthangover.Core.UI.Menu;
|
||||
using Cthangover.Core.UI.Theming;
|
||||
using Cthangover.Mods.Interface.Menu;
|
||||
using Cthangover.Mods.Interface.Theming;
|
||||
using Godot;
|
||||
|
||||
namespace Cthangover.BaseMenu
|
||||
{
|
||||
/// <summary>
|
||||
/// "Play": every game collection as a card — cover, name, description,
|
||||
/// readiness (what is missing and what can be installed), saves and the
|
||||
/// last play date — with New game / Continue / Saves / Install / Configure.
|
||||
/// Data comes from <see cref="GameCatalog"/> against all installed mods, so
|
||||
/// it works from the front-end where only the menu is loaded.
|
||||
/// </summary>
|
||||
public partial class GamePickerPanel : Control
|
||||
{
|
||||
private VBoxContainer _list;
|
||||
private readonly HashSet<string> _sessionInstalled = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Raised when the player opens a game's saves; the menu shows its save browser.</summary>
|
||||
public event Action<string> SavesRequested;
|
||||
|
||||
public event Action Closed;
|
||||
|
||||
public override void _Ready()
|
||||
{
|
||||
SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
|
||||
MouseFilter = MouseFilterEnum.Stop;
|
||||
|
||||
var backdrop = new ColorRect { Color = new Color(0, 0, 0, 0.65f) };
|
||||
backdrop.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
|
||||
AddChild(backdrop);
|
||||
|
||||
var panel = new PanelContainer();
|
||||
panel.AddThemeStyleboxOverride("panel", UiKit.Frame(UiTokens.RadiusLg, UiTokens.Surface1, UiTokens.Line));
|
||||
panel.AnchorLeft = panel.AnchorRight = panel.AnchorTop = panel.AnchorBottom = 0.5f;
|
||||
panel.OffsetLeft = -460;
|
||||
panel.OffsetRight = 460;
|
||||
panel.OffsetTop = -310;
|
||||
panel.OffsetBottom = 310;
|
||||
panel.GrowHorizontal = GrowDirection.Both;
|
||||
panel.GrowVertical = GrowDirection.Both;
|
||||
AddChild(panel);
|
||||
|
||||
var margin = new MarginContainer();
|
||||
foreach (var side in new[] { "left", "right", "top", "bottom" })
|
||||
margin.AddThemeConstantOverride($"margin_{side}", UiTokens.Space6);
|
||||
panel.AddChild(margin);
|
||||
|
||||
var column = UiKit.Column(UiTokens.Space4);
|
||||
column.MouseFilter = MouseFilterEnum.Pass;
|
||||
margin.AddChild(column);
|
||||
|
||||
column.AddChild(UiKit.Label(TranslationServer.Translate("menu/play/title"), UiTokens.FontH2, UiTokens.InkBright));
|
||||
|
||||
var scroll = new ScrollContainer
|
||||
{
|
||||
SizeFlagsVertical = SizeFlags.ExpandFill,
|
||||
HorizontalScrollMode = ScrollContainer.ScrollMode.Disabled,
|
||||
};
|
||||
column.AddChild(scroll);
|
||||
|
||||
_list = UiKit.Column(UiTokens.Space3);
|
||||
_list.MouseFilter = MouseFilterEnum.Pass;
|
||||
_list.SizeFlagsHorizontal = SizeFlags.ExpandFill;
|
||||
scroll.AddChild(_list);
|
||||
|
||||
var back = UiKit.Button(TranslationServer.Translate("settings/back"));
|
||||
back.SizeFlagsHorizontal = SizeFlags.ShrinkEnd;
|
||||
back.Pressed += Close;
|
||||
column.AddChild(back);
|
||||
|
||||
Visible = false;
|
||||
}
|
||||
|
||||
public void Open()
|
||||
{
|
||||
Visible = true;
|
||||
Rebuild();
|
||||
}
|
||||
|
||||
private void Close()
|
||||
{
|
||||
Visible = false;
|
||||
Closed?.Invoke();
|
||||
}
|
||||
|
||||
private void Rebuild()
|
||||
{
|
||||
foreach (var child in _list.GetChildren())
|
||||
child.QueueFree();
|
||||
|
||||
var games = GameCatalog.Games(_sessionInstalled);
|
||||
if (games.Count == 0)
|
||||
{
|
||||
_list.AddChild(UiKit.Label(TranslationServer.Translate("menu/play/no_games"), UiTokens.FontBody, UiTokens.Muted));
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var game in games)
|
||||
_list.AddChild(BuildCard(game));
|
||||
}
|
||||
|
||||
private Control BuildCard(ModCollectionGameSummary game)
|
||||
{
|
||||
var collection = game.Collection;
|
||||
var saves = SaveService.ListSaves(collection.Id);
|
||||
var newest = saves.OrderByDescending(s => s.SaveTime).FirstOrDefault();
|
||||
|
||||
var card = new PanelContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
|
||||
card.AddThemeStyleboxOverride("panel", UiKit.Frame(UiTokens.RadiusMd, UiTokens.Surface2,
|
||||
ActiveGame.Id == collection.Id ? UiTokens.Accent : UiTokens.LineCard));
|
||||
|
||||
var row = UiKit.Row(UiTokens.Space4);
|
||||
row.MouseFilter = MouseFilterEnum.Pass;
|
||||
card.AddChild(row);
|
||||
|
||||
var cover = GameCatalog.LoadCover(collection);
|
||||
row.AddChild(new TextureRect
|
||||
{
|
||||
Texture = cover,
|
||||
CustomMinimumSize = new Vector2(176, 99),
|
||||
ExpandMode = TextureRect.ExpandModeEnum.IgnoreSize,
|
||||
StretchMode = TextureRect.StretchModeEnum.KeepAspectCovered,
|
||||
SizeFlagsVertical = SizeFlags.ShrinkCenter,
|
||||
});
|
||||
|
||||
var info = UiKit.Column(UiTokens.Space1);
|
||||
info.SizeFlagsHorizontal = SizeFlags.ExpandFill;
|
||||
row.AddChild(info);
|
||||
info.AddChild(UiKit.Label(game.Name ?? collection.Id, UiTokens.FontH3, UiTokens.InkBright));
|
||||
var description = UiKit.Label(game.Description ?? string.Empty, UiTokens.FontBodySmall, UiTokens.Body);
|
||||
description.AutowrapMode = TextServer.AutowrapMode.WordSmart;
|
||||
info.AddChild(description);
|
||||
|
||||
var (statusText, statusColor) = Status(game);
|
||||
info.AddChild(UiKit.Label(statusText, UiTokens.FontSmall, statusColor));
|
||||
info.AddChild(UiKit.Label(saves.Count == 0
|
||||
? TranslationServer.Translate("menu/play/no_saves")
|
||||
: string.Format(TranslationServer.Translate("menu/play/saves"), saves.Count, newest.SaveTime.ToLocalTime().ToString("dd.MM.yy HH:mm")),
|
||||
UiTokens.FontTiny, UiTokens.Muted));
|
||||
|
||||
var buttons = UiKit.Column(UiTokens.Space2);
|
||||
buttons.MouseFilter = MouseFilterEnum.Pass;
|
||||
buttons.CustomMinimumSize = new Vector2(190, 0);
|
||||
row.AddChild(buttons);
|
||||
|
||||
var start = UiKit.Button(TranslationServer.Translate("menu/play/new_game"), ButtonKind.Primary);
|
||||
start.Disabled = !game.IsReady;
|
||||
start.Pressed += () => GameFlow.StartGame(this, collection.Id);
|
||||
buttons.AddChild(start);
|
||||
|
||||
if (newest != null)
|
||||
{
|
||||
var resume = UiKit.Button(TranslationServer.Translate("menu/play/continue"));
|
||||
resume.Disabled = !game.IsReady;
|
||||
resume.Pressed += () => GameFlow.RequestLoad(this, collection.Id, newest.FileName);
|
||||
buttons.AddChild(resume);
|
||||
|
||||
var browse = UiKit.Button(TranslationServer.Translate("menu/play/browse_saves"));
|
||||
browse.Pressed += () => SavesRequested?.Invoke(collection.Id);
|
||||
buttons.AddChild(browse);
|
||||
}
|
||||
|
||||
if (!game.IsReady && game.MissingIds.Count == 0 && game.MismatchIds.Count == 0
|
||||
&& game.ProblemsCount == 0 && game.PlanInstallCount + game.PlanEnableCount > 0)
|
||||
{
|
||||
var install = UiKit.Button(TranslationServer.Translate("menu/play/install"));
|
||||
install.Pressed += () => Install(collection, install);
|
||||
buttons.AddChild(install);
|
||||
}
|
||||
|
||||
if (MenuRegistry.GetButtonById(ModsHubId) != null)
|
||||
{
|
||||
var configure = UiKit.Button(TranslationServer.Translate("menu/play/configure"));
|
||||
configure.Pressed += () => OpenEditor(collection.Id);
|
||||
buttons.AddChild(configure);
|
||||
}
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
private static (string Text, Color Color) Status(ModCollectionGameSummary game)
|
||||
{
|
||||
if (game.IsReady)
|
||||
return (TranslationServer.Translate("menu/play/status_ready"), UiTokens.SuccessText);
|
||||
if (game.MissingIds.Count > 0)
|
||||
return (string.Format(TranslationServer.Translate("menu/play/status_missing"), string.Join(", ", game.MissingIds)), UiTokens.DangerText);
|
||||
if (game.MismatchIds.Count > 0)
|
||||
return (string.Format(TranslationServer.Translate("menu/play/status_mismatch"), string.Join(", ", game.MismatchIds)), UiTokens.DangerText);
|
||||
if (game.ProblemsCount > 0)
|
||||
return (string.Format(TranslationServer.Translate("menu/play/status_problems"), game.ProblemsCount), UiTokens.Warning);
|
||||
return (string.Format(TranslationServer.Translate("menu/play/status_install"), game.PlanInstallCount, game.PlanEnableCount), UiTokens.Warning);
|
||||
}
|
||||
|
||||
private const string ModsHubId = "mods_hub";
|
||||
|
||||
/// <summary>Installs/enables the missing mods; entering the game afterwards reloads the mod set to pick them up.</summary>
|
||||
private async void Install(ModCollection collection, Button button)
|
||||
{
|
||||
button.Disabled = true;
|
||||
button.Text = TranslationServer.Translate("menu/play/installing");
|
||||
|
||||
var collections = GameCatalog.LoadCollections();
|
||||
var catalog = CatalogService.CachedEntries ?? new List<CatalogExportEntry>();
|
||||
var graph = ModCollectionResolver.Resolve(collection, collections, ModRegistry.Instance.AllDiscoveredMods,
|
||||
DisabledModsStore.Load(), catalog, _sessionInstalled);
|
||||
var ok = await ModCollectionInstaller.ApplyAsync(ModCollectionPlan.Build(graph, catalog), _sessionInstalled);
|
||||
if (!IsInstanceValid(this))
|
||||
return;
|
||||
|
||||
if (ok)
|
||||
GameFlow.ReloadRequired = true;
|
||||
GameFlow.Confirm(this,
|
||||
TranslationServer.Translate("menu/play/install"),
|
||||
TranslationServer.Translate(ok ? "menu/play/install_done" : "menu/play/install_failed"),
|
||||
null, null);
|
||||
Rebuild();
|
||||
}
|
||||
|
||||
/// <summary>Opens the mod manager on the game's scheme (loose coupling through the menu registry).</summary>
|
||||
private void OpenEditor(string collectionId)
|
||||
{
|
||||
var entry = MenuRegistry.GetButtonById(ModsHubId);
|
||||
var host = GetParent() as Control ?? this;
|
||||
var hub = MenuBuilder.OpenScene(host, entry?.ScenePath, ModsHubId);
|
||||
if (hub != null && hub.HasMethod("OpenCollectionEditor"))
|
||||
hub.CallDeferred("OpenCollectionEditor", collectionId);
|
||||
Close();
|
||||
}
|
||||
}
|
||||
}
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
using System.Collections.Generic;
|
||||
using Cthangover.Core.Scenes;
|
||||
using Cthangover.Core.Settings;
|
||||
using Cthangover.Core.UI.Menu;
|
||||
using Cthangover.Core.UI.Theming;
|
||||
using Cthangover.Core.UI.Tool;
|
||||
using Cthangover.Core.Utils;
|
||||
using Godot;
|
||||
|
||||
using Cthangover.Mods.Interface.Menu;
|
||||
namespace Cthangover.BaseMenu
|
||||
{
|
||||
/// <summary>
|
||||
/// Title screen main menu, fully driven by <see cref="MenuRegistry"/>. The scene
|
||||
/// only defines named section containers (game, mods, exit, tools); every button —
|
||||
/// built-in or mod-provided — is created from registry entries, so mods can add,
|
||||
/// replace or re-order entries without touching this scene. Below the sections,
|
||||
/// every registered tool appends its own launcher button via
|
||||
/// <see cref="IToolProvider.AddMenuButton"/>, so no tool list is hardcoded here.
|
||||
/// If GameLogger reports mod compilation errors, a popup lists them. The
|
||||
/// SettingsMenu and SaveLoadMenu panels are instantiated at ready time but stay
|
||||
/// hidden; they are registered in <see cref="MenuRegistry"/> as panels "settings"
|
||||
/// and "save_load", so other mods can open them by id.
|
||||
/// </summary>
|
||||
public partial class MainMenu : Control
|
||||
{
|
||||
private SettingsMenu _settingsMenu;
|
||||
private SaveLoadMenu _saveLoadMenu;
|
||||
private GamePickerPanel _gamePicker;
|
||||
private readonly Dictionary<Button, string> _btnKeys = new();
|
||||
|
||||
public override void _Ready()
|
||||
{
|
||||
try
|
||||
{
|
||||
BuildUi();
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
GameLogger.Log("MAIN_MENU",
|
||||
$"_Ready failed: {ex.GetType().Name}: {ex.Message}{System.Environment.NewLine}{ex.StackTrace}",
|
||||
LogLevel.Error);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Assembles the runtime UI: theme, hidden settings and save/load panels,
|
||||
/// telegram link wiring, scene manager initialization and registry/tool
|
||||
/// driven buttons.
|
||||
/// </summary>
|
||||
private void BuildUi()
|
||||
{
|
||||
CthangoverTheme.Apply(this);
|
||||
|
||||
_settingsMenu = new ModScenePrefab("scenes/menu/settings_menu.tscn").Instantiate<SettingsMenu>();
|
||||
_settingsMenu.Name = "SettingsPanel";
|
||||
_settingsMenu.Visible = false;
|
||||
_settingsMenu.LanguageChanged += RefreshButtons;
|
||||
AddChild(_settingsMenu);
|
||||
|
||||
_saveLoadMenu = new ModScenePrefab("scenes/menu/save_load_menu.tscn").Instantiate<SaveLoadMenu>();
|
||||
_saveLoadMenu.Name = "SaveLoadPanel";
|
||||
_saveLoadMenu.Visible = false;
|
||||
AddChild(_saveLoadMenu);
|
||||
|
||||
_gamePicker = new GamePickerPanel { Name = "GamePickerPanel" };
|
||||
AddChild(_gamePicker);
|
||||
_gamePicker.SavesRequested += gameId =>
|
||||
{
|
||||
_gamePicker.Visible = false;
|
||||
_saveLoadMenu.OpenForGame(gameId);
|
||||
};
|
||||
|
||||
var telegramLink = GetNode<RichTextLabel>("TelegramLink");
|
||||
telegramLink.MetaClicked += OnTelegramLinkClicked;
|
||||
|
||||
var sceneManager = GetNode<SceneManager>("/root/SceneManager");
|
||||
sceneManager.Initialize();
|
||||
|
||||
BuildRegistryButtons();
|
||||
BuildToolButtons();
|
||||
CheckModCompilationErrors();
|
||||
|
||||
RefreshButtons();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates one button per <see cref="MenuRegistry"/> entry, routed to the
|
||||
/// section container matching its <c>Section</c> ("game", "mods", "exit",
|
||||
/// "tools"). Runs after <see cref="SceneManager.Initialize"/> because the
|
||||
/// registry is built during boot and filled by mod initializers; the
|
||||
/// defensive Initialize call keeps direct scene launches working. Each
|
||||
/// button's translation key is registered so <see cref="RefreshButtons"/>
|
||||
/// keeps it localized.
|
||||
/// </summary>
|
||||
private void BuildRegistryButtons()
|
||||
{
|
||||
var sections = new Dictionary<string, Container>
|
||||
{
|
||||
{ "game", GetNode<Container>("MenuContainer/GameButtons") },
|
||||
{ MenuButtonEntry.SectionMods, GetNode<Container>("MenuContainer/ModMenuButtons") },
|
||||
{ "exit", GetNode<Container>("MenuContainer/ExitButtons") },
|
||||
{ "tools", GetNode<Container>("MenuContainer/ToolMenuButtons") },
|
||||
};
|
||||
MenuBuilder.Build(this, sections);
|
||||
|
||||
foreach (var container in sections.Values)
|
||||
MenuBuilder.CollectLocaleKeys(container, _btnKeys);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lets every registered tool append its launcher button to the tool block
|
||||
/// below Exit (<c>MenuContainer/ToolMenuButtons</c>). Tools register
|
||||
/// themselves in <see cref="ToolFactory"/>, so the block stays empty when no
|
||||
/// tools are installed and needs no changes when a mod introduces a new tool.
|
||||
/// Each button carries its translation key in metadata, keeping
|
||||
/// <see cref="RefreshButtons"/> aware of it.
|
||||
/// </summary>
|
||||
private void BuildToolButtons()
|
||||
{
|
||||
var container = GetNodeOrNull<VBoxContainer>("MenuContainer/ToolMenuButtons");
|
||||
if (container == null)
|
||||
return;
|
||||
|
||||
foreach (var tool in ToolFactory.Instance.GetAll())
|
||||
tool.AddMenuButton(container);
|
||||
|
||||
MenuBuilder.CollectLocaleKeys(container, _btnKeys);
|
||||
|
||||
GameLogger.Log("MAIN_MENU", $"Built {container.GetChildCount()} tool button(s)");
|
||||
}
|
||||
|
||||
private void CheckModCompilationErrors()
|
||||
{
|
||||
if (GameLogger.CompilationErrors.Count == 0)
|
||||
return;
|
||||
|
||||
var errorText = string.Join("\r\n", GameLogger.CompilationErrors);
|
||||
ShowModErrorPopup(errorText);
|
||||
}
|
||||
|
||||
private void RefreshButtons()
|
||||
{
|
||||
foreach (var kv in _btnKeys)
|
||||
kv.Key.Text = TranslationServer.Translate(kv.Value);
|
||||
}
|
||||
|
||||
private void ShowModErrorPopup(string errorText)
|
||||
{
|
||||
var dialog = new AcceptDialog();
|
||||
dialog.Title = "Mod Compilation Error";
|
||||
dialog.DialogText = errorText;
|
||||
dialog.OkButtonText = "OK";
|
||||
dialog.Exclusive = true;
|
||||
AddChild(dialog);
|
||||
dialog.PopupCentered();
|
||||
}
|
||||
|
||||
private void OnTelegramLinkClicked(Variant meta)
|
||||
{
|
||||
OS.ShellOpen($"https://{meta.AsString()}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Cthangover.Core.Games;
|
||||
using Cthangover.Core.Mods;
|
||||
using Cthangover.Core.Scenes;
|
||||
using Cthangover.Core.Settings;
|
||||
using Cthangover.Core.UI.Menu;
|
||||
using Cthangover.Core.UI.Theming;
|
||||
using Godot;
|
||||
|
||||
namespace Cthangover.BaseMenu
|
||||
{
|
||||
/// <summary>
|
||||
/// Save/load browser with unlimited slots. The grid shows 4×3 slots per
|
||||
/// screen and scrolls past that. Saves belong to games: in a game the
|
||||
/// panel shows that game's slots (save mode appends one empty "new"
|
||||
/// slot); from the front-end the selector at the top switches between
|
||||
/// games with saves and the saves made before games existed. Loading goes
|
||||
/// through <see cref="GameFlow.RequestLoad"/> — composition comparison,
|
||||
/// backup, restart into another game, adoption of game-less saves.
|
||||
/// </summary>
|
||||
public partial class SaveLoadMenu : Control, ISaveLoadPanel
|
||||
{
|
||||
private Label _titleLabel;
|
||||
private OptionButton _gameSelect;
|
||||
private SaveSlotList _slotList;
|
||||
private Label _emptyLabel;
|
||||
private Button _backBtn;
|
||||
|
||||
private bool _isLoadMode;
|
||||
private string _gameId;
|
||||
private readonly List<string> _gameIds = new();
|
||||
private List<SaveSlotInfo> _currentSlots;
|
||||
|
||||
/// <summary>
|
||||
/// Raised when the player clicks the back button to dismiss the save/load screen.
|
||||
/// </summary>
|
||||
public event Action Closed;
|
||||
|
||||
public override void _Ready()
|
||||
{
|
||||
_titleLabel = GetNode<Label>("Panel/Margin/VBox/Title");
|
||||
_gameSelect = GetNode<OptionButton>("Panel/Margin/VBox/GameSelect");
|
||||
_slotList = GetNode<SaveSlotList>("Panel/Margin/VBox/Scroll/SlotGrid");
|
||||
_emptyLabel = GetNode<Label>("Panel/Margin/VBox/EmptyLabel");
|
||||
_backBtn = GetNode<Button>("Panel/Margin/VBox/BackBtn");
|
||||
CthangoverTheme.ApplyIcon(_backBtn);
|
||||
|
||||
_slotList.SlotPressed += OnSlotPressed;
|
||||
_gameSelect.ItemSelected += OnGameSelected;
|
||||
_backBtn.Pressed += OnBackPressed;
|
||||
_backBtn.Text = TranslationServer.Translate("settings/back");
|
||||
Visible = false;
|
||||
}
|
||||
|
||||
/// <summary>Load mode: the running game's saves, or every game's from the front-end.</summary>
|
||||
public void OpenForLoad() => OpenFor(true, ActiveGame.Id);
|
||||
|
||||
/// <summary>Save mode: always the running game's slots plus a free one.</summary>
|
||||
public void OpenForSave() => OpenFor(false, ActiveGame.Id);
|
||||
|
||||
/// <summary>Load mode preselected on <paramref name="gameId"/> (game picker → "Saves").</summary>
|
||||
public void OpenForGame(string gameId) => OpenFor(true, gameId);
|
||||
|
||||
/// <inheritdoc cref="IMenuPanel" />
|
||||
public void Open() => OpenForLoad();
|
||||
|
||||
private void OpenFor(bool load, string gameId)
|
||||
{
|
||||
_isLoadMode = load;
|
||||
_titleLabel.Text = TranslationServer.Translate(load ? "ui/load/title" : "ui/save/title");
|
||||
BuildGameSelect(gameId);
|
||||
Visible = true;
|
||||
CallDeferred(nameof(RefreshSlots));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Games offered by the selector: in save mode only the running game;
|
||||
/// in load mode every game with saves (the running/preselected one first)
|
||||
/// and, last, the saves made without a game.
|
||||
/// </summary>
|
||||
private void BuildGameSelect(string preselected)
|
||||
{
|
||||
_gameIds.Clear();
|
||||
_gameSelect.Clear();
|
||||
|
||||
if (!_isLoadMode)
|
||||
{
|
||||
AddGame(ActiveGame.Id, ActiveGame.Collection?.DisplayTitle);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!string.IsNullOrEmpty(preselected))
|
||||
AddGame(preselected, GameName(preselected));
|
||||
foreach (var game in SaveService.ListGamesWithSaves())
|
||||
{
|
||||
if (!_gameIds.Contains(game.GameId))
|
||||
AddGame(game.GameId, game.GameId == null ? null : GameName(game.GameId) ?? game.GameName);
|
||||
}
|
||||
if (_gameIds.Count == 0)
|
||||
AddGame(preselected, GameName(preselected));
|
||||
}
|
||||
|
||||
_gameId = _gameIds.Count > 0 ? _gameIds[0] : null;
|
||||
_gameSelect.Selected = _gameIds.Count > 0 ? 0 : -1;
|
||||
_gameSelect.Visible = _gameIds.Count > 1 || (_gameIds.Count == 1 && _gameIds[0] == null && ActiveGame.IsActive);
|
||||
}
|
||||
|
||||
private void AddGame(string gameId, string name)
|
||||
{
|
||||
_gameIds.Add(gameId);
|
||||
_gameSelect.AddItem(gameId == null
|
||||
? TranslationServer.Translate("menu/load/legacy_group")
|
||||
: name ?? gameId);
|
||||
}
|
||||
|
||||
private static string GameName(string gameId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(gameId))
|
||||
return null;
|
||||
if (ActiveGame.Id == gameId)
|
||||
return ActiveGame.Collection?.GetLocalizedName(TranslationServer.GetLocale());
|
||||
return GameCatalog.LoadCollections().TryGetValue(gameId, out var collection)
|
||||
? CollectionText.Resolve(collection.GetLocalizedName(TranslationServer.GetLocale()))
|
||||
: null;
|
||||
}
|
||||
|
||||
private void OnGameSelected(long index)
|
||||
{
|
||||
_gameId = index >= 0 && index < _gameIds.Count ? _gameIds[(int)index] : null;
|
||||
RefreshSlots();
|
||||
}
|
||||
|
||||
/// <summary>Lists the selected game's saves; save mode appends the next free slot.</summary>
|
||||
public void RefreshSlots()
|
||||
{
|
||||
_currentSlots = SaveService.ListSaves(_gameId);
|
||||
if (!_isLoadMode)
|
||||
_currentSlots.Add(SaveService.EmptySlot(_gameId, SaveService.NextSlotName(_gameId)));
|
||||
|
||||
_emptyLabel.Visible = _currentSlots.Count == 0;
|
||||
_emptyLabel.Text = TranslationServer.Translate("menu/load/empty");
|
||||
_slotList.SetSlots(_currentSlots);
|
||||
}
|
||||
|
||||
private void OnSlotPressed(string fileName)
|
||||
{
|
||||
var slot = FindSlot(fileName);
|
||||
|
||||
if (_isLoadMode)
|
||||
{
|
||||
if (slot == null || slot.IsEmpty)
|
||||
return;
|
||||
|
||||
GameFlow.RequestLoad(this, _gameId, fileName);
|
||||
return;
|
||||
}
|
||||
|
||||
if (slot == null || slot.IsEmpty)
|
||||
{
|
||||
SaveScreenshotService.CaptureAndSave(fileName);
|
||||
SaveService.Save(fileName);
|
||||
RefreshSlots();
|
||||
}
|
||||
else
|
||||
{
|
||||
ShowOverwriteConfirm(fileName);
|
||||
}
|
||||
}
|
||||
|
||||
private SaveSlotInfo FindSlot(string fileName)
|
||||
=> _currentSlots?.Find(s => s.FileName == fileName);
|
||||
|
||||
private void ShowOverwriteConfirm(string fileName)
|
||||
{
|
||||
var dialog = new ConfirmationDialog();
|
||||
dialog.Title = TranslationServer.Translate("ui/save/overwrite_title");
|
||||
dialog.DialogText = TranslationServer.Translate("ui/save/overwrite_text");
|
||||
dialog.OkButtonText = TranslationServer.Translate("ui/save/save_button");
|
||||
dialog.CancelButtonText = TranslationServer.Translate("ui/save/cancel_button");
|
||||
dialog.Exclusive = true;
|
||||
AddChild(dialog);
|
||||
CthangoverTheme.ApplyPrimary(dialog.GetOkButton());
|
||||
|
||||
dialog.Confirmed += () =>
|
||||
{
|
||||
SaveScreenshotService.CaptureAndSave(fileName);
|
||||
SaveService.Save(fileName);
|
||||
RefreshSlots();
|
||||
};
|
||||
|
||||
dialog.PopupCentered();
|
||||
}
|
||||
|
||||
private void OnBackPressed()
|
||||
{
|
||||
Visible = false;
|
||||
Closed?.Invoke();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using System.Collections.Generic;
|
||||
using Cthangover.Core.Mods;
|
||||
using Cthangover.Core.Settings;
|
||||
using Godot;
|
||||
|
||||
namespace Cthangover.BaseMenu
|
||||
{
|
||||
/// <summary>
|
||||
/// Save settings shown under the base menu in the mods settings tab.
|
||||
/// Games keep the data of mechanics the player turned off (dormant slots,
|
||||
/// see <see cref="SaveDormancy"/>) so it returns with the mechanic; this
|
||||
/// is where the player drops that data from every save on purpose.
|
||||
/// </summary>
|
||||
public class SaveSettings : IModSettings, IModSettingsActions
|
||||
{
|
||||
public const string PurgeDormantKey = "purge_dormant";
|
||||
|
||||
public IReadOnlyList<ModSettingDefinition> GetDefinitions() => new List<ModSettingDefinition>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Key = PurgeDormantKey,
|
||||
Name = "settings/saves/purge_dormant",
|
||||
Type = SettingType.Action,
|
||||
ActionText = "settings/saves/purge_dormant_button",
|
||||
ConfirmText = "settings/saves/purge_dormant_confirm",
|
||||
},
|
||||
};
|
||||
|
||||
public void WriteValues(DataBlob blob)
|
||||
{
|
||||
}
|
||||
|
||||
public void ReadValues(DataBlob blob)
|
||||
{
|
||||
}
|
||||
|
||||
public string RunAction(string key)
|
||||
{
|
||||
if (key != PurgeDormantKey)
|
||||
return null;
|
||||
|
||||
var purged = SaveService.PurgeDormantData();
|
||||
return string.Format(TranslationServer.Translate("settings/saves/purge_dormant_done"), purged);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
using System;
|
||||
using Cthangover.Core.Settings;
|
||||
using Cthangover.Mods.Interface.Base.Lists;
|
||||
using Godot;
|
||||
|
||||
namespace Cthangover.BaseMenu
|
||||
{
|
||||
/// <summary>
|
||||
/// A single save slot card in the save/load grid. Extends ListItem with
|
||||
/// SaveSlotInfo as the model. Renders differently for empty vs. occupied
|
||||
/// slots: empty slots show a placeholder label; occupied slots display the
|
||||
/// screenshot, in-game date (from TimeData), real-world save timestamp, and
|
||||
/// scene name. The stats label shows party size. Button click emits SlotPressed
|
||||
/// with the file name for parent handling. Destruct disconnects the button
|
||||
/// signal before QueueFree to avoid phantom callbacks.
|
||||
/// </summary>
|
||||
public partial class SaveSlotItem : ListItem<SaveSlotInfo>
|
||||
{
|
||||
private TextureRect screenshotRect;
|
||||
private Label dateLabel;
|
||||
private Label timeLabel;
|
||||
private Label sceneLabel;
|
||||
private Label emptyLabel;
|
||||
private Button slotButton;
|
||||
private Label statsLabel;
|
||||
|
||||
/// <summary>
|
||||
/// Raised when this specific slot is clicked, carrying the save file name so the parent
|
||||
/// <see cref="SaveSlotList"/> can forward it to <see cref="SaveLoadMenu"/>. A plain C#
|
||||
/// event because mod assemblies are compiled without the Godot source generators.
|
||||
/// </summary>
|
||||
public event Action<string> SlotPressed;
|
||||
|
||||
public override void _Ready()
|
||||
{
|
||||
screenshotRect = GetNode<TextureRect>("ScreenshotRect");
|
||||
dateLabel = GetNode<Label>("DateLabel");
|
||||
timeLabel = GetNode<Label>("TimeLabel");
|
||||
sceneLabel = GetNode<Label>("SceneLabel");
|
||||
emptyLabel = GetNode<Label>("EmptyLabel");
|
||||
slotButton = GetNode<Button>("SlotButton");
|
||||
statsLabel = GetNodeOrNull<Label>("StatsLabel");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Populates the slot visuals from the <see cref="SaveSlotInfo"/> model. For occupied slots,
|
||||
/// loads the screenshot texture, formats the game date from <see cref="SaveSlotInfo.GameTime"/>,
|
||||
/// shows the real-world save timestamp, and displays party size. Empty slots show only a
|
||||
/// localized "empty" label. Wires the button click signal to emit <see cref="SlotPressed"/>.
|
||||
/// </summary>
|
||||
public override void Construct(SaveSlotInfo model)
|
||||
{
|
||||
base.Construct(model);
|
||||
|
||||
if (model.IsEmpty)
|
||||
{
|
||||
screenshotRect.Visible = false;
|
||||
dateLabel.Visible = false;
|
||||
timeLabel.Visible = false;
|
||||
sceneLabel.Visible = false;
|
||||
emptyLabel.Visible = true;
|
||||
if (statsLabel != null) statsLabel.Visible = false;
|
||||
emptyLabel.Text = TranslationServer.Translate("ui/save/empty");
|
||||
}
|
||||
else
|
||||
{
|
||||
emptyLabel.Visible = false;
|
||||
screenshotRect.Visible = true;
|
||||
dateLabel.Visible = true;
|
||||
timeLabel.Visible = true;
|
||||
sceneLabel.Visible = true;
|
||||
if (statsLabel != null) statsLabel.Visible = true;
|
||||
|
||||
var tex = SaveScreenshotService.LoadScreenshotFromPath(model.ScreenshotPath);
|
||||
if (tex != null)
|
||||
screenshotRect.Texture = tex;
|
||||
|
||||
var gameTimeObj = new TimeData(model.GameTime);
|
||||
dateLabel.Text = TranslationServer.Translate("ui/save/game_day").ToString()
|
||||
.Replace("{day}", (gameTimeObj.Days + 1).ToString())
|
||||
.Replace("{time}", gameTimeObj.Text);
|
||||
|
||||
timeLabel.Text = model.SaveTime != System.DateTime.MinValue
|
||||
? model.SaveTime.ToLocalTime().ToString("dd.MM.yy HH:mm")
|
||||
: "";
|
||||
|
||||
sceneLabel.Text = model.SceneName;
|
||||
|
||||
if (statsLabel != null)
|
||||
statsLabel.Text = TranslationServer.Translate("ui/save/party_size").ToString()
|
||||
.Replace("{count}", model.CharacterCount.ToString());
|
||||
}
|
||||
|
||||
slotButton.Pressed += OnPressed;
|
||||
}
|
||||
|
||||
private void OnPressed()
|
||||
{
|
||||
SlotPressed?.Invoke(Model.FileName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disconnects the button signal to prevent callbacks on freed nodes, then queues
|
||||
/// this item for deletion. Called by the <see cref="SaveSlotList"/> when rebuilding
|
||||
/// the grid, ensuring no dangling signal references.
|
||||
/// </summary>
|
||||
public override void Destruct()
|
||||
{
|
||||
if (slotButton != null && GodotObject.IsInstanceValid(slotButton))
|
||||
slotButton.Pressed -= OnPressed;
|
||||
QueueFree();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Cthangover.Core.Scenes;
|
||||
using Cthangover.Core.Settings;
|
||||
using Godot;
|
||||
|
||||
using Cthangover.Mods.Interface.Base.Lists;
|
||||
using Cthangover.Mods.Interface.Base.Lists.Impls;
|
||||
namespace Cthangover.BaseMenu
|
||||
{
|
||||
/// <summary>
|
||||
/// Save slot grid layout with configurable column/row count and padding.
|
||||
/// Does NOT use the ListWidget/ColumnCellListWidget framework — manually
|
||||
/// calculates cell dimensions from the control size and instantiates
|
||||
/// SaveSlotItem prefabs in a flat grid. This is intentional: the save grid
|
||||
/// has a fixed visual size and doesn't need scrolling or dynamic content
|
||||
/// sizing. OnResize refreshes if slots were cleared (e.g. initial render
|
||||
/// when size wasn't known yet).
|
||||
/// </summary>
|
||||
public partial class SaveSlotList : Control
|
||||
{
|
||||
private ModScenePrefab _itemScene;
|
||||
private readonly List<SaveSlotItem> _items = new();
|
||||
|
||||
/// <summary>
|
||||
/// Raised when any child <see cref="SaveSlotItem"/> is clicked, forwarding the save file
|
||||
/// name. A plain C# event because mod assemblies are compiled without the Godot source
|
||||
/// generators.
|
||||
/// </summary>
|
||||
public event Action<string> SlotPressed;
|
||||
|
||||
/// <summary>Number of columns in the save slot grid. Editor-configurable.</summary>
|
||||
[Export]
|
||||
public int ColumnCount { get; set; } = 4;
|
||||
|
||||
/// <summary>Number of rows in the save slot grid. Editor-configurable.</summary>
|
||||
[Export]
|
||||
public int RowCount { get; set; } = 3;
|
||||
|
||||
/// <summary>Spacing between adjacent slots in pixels. Editor-configurable.</summary>
|
||||
[Export]
|
||||
public float SlotPadding { get; set; } = 8f;
|
||||
|
||||
private List<SaveSlotInfo> _slotModels;
|
||||
|
||||
/// <summary>Width kept free for the vertical scroll bar of the parent <see cref="ScrollContainer"/>.</summary>
|
||||
private const float ScrollBarReserve = 14f;
|
||||
|
||||
public override void _Ready()
|
||||
{
|
||||
_itemScene = new ModScenePrefab("scenes/menu/save_slot_item.tscn");
|
||||
Resized += OnResized;
|
||||
if (GetParent() is Control viewport)
|
||||
viewport.Resized += OnResized;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Area one screen of slots occupies: the parent scroll viewport when
|
||||
/// the list scrolls (slots are unlimited), otherwise the list itself.
|
||||
/// </summary>
|
||||
private Vector2 ViewportSize()
|
||||
=> GetParent() is ScrollContainer scroll
|
||||
? new Vector2(scroll.Size.X - ScrollBarReserve, scroll.Size.Y)
|
||||
: Size;
|
||||
|
||||
/// <summary>
|
||||
/// Stores the slot data models and triggers a full grid rebuild via <see cref="Refresh"/>.
|
||||
/// </summary>
|
||||
public void SetSlots(List<SaveSlotInfo> slots)
|
||||
{
|
||||
_slotModels = slots;
|
||||
Refresh();
|
||||
}
|
||||
|
||||
private void ClearItems()
|
||||
{
|
||||
foreach (var item in _items)
|
||||
{
|
||||
if (GodotObject.IsInstanceValid(item))
|
||||
item.QueueFree();
|
||||
}
|
||||
_items.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Destroys all existing <see cref="SaveSlotItem"/> children and rebuilds the grid
|
||||
/// from <c>_slotModels</c>. Calculates cell dimensions as (total - gaps) / column|row count,
|
||||
/// then positions each item using flat coordinates (no <c>GridContainer</c> dependency).
|
||||
/// Skips rendering if the control has zero size (e.g. called before first layout pass).
|
||||
/// </summary>
|
||||
public void Refresh()
|
||||
{
|
||||
ClearItems();
|
||||
|
||||
if (_slotModels == null || _itemScene == null)
|
||||
return;
|
||||
|
||||
var contentSize = ViewportSize();
|
||||
if (contentSize.X <= 0 || contentSize.Y <= 0)
|
||||
return;
|
||||
|
||||
// One screen holds ColumnCount × RowCount slots; more rows extend
|
||||
// the list and the parent ScrollContainer scrolls.
|
||||
var cellWidth = (contentSize.X - (ColumnCount - 1) * SlotPadding) / ColumnCount;
|
||||
var cellHeight = (contentSize.Y - (RowCount - 1) * SlotPadding) / RowCount;
|
||||
var rows = Math.Max(1, (_slotModels.Count + ColumnCount - 1) / ColumnCount);
|
||||
CustomMinimumSize = new Vector2(0, rows * cellHeight + (rows - 1) * SlotPadding);
|
||||
|
||||
for (int i = 0; i < _slotModels.Count; i++)
|
||||
{
|
||||
var model = _slotModels[i];
|
||||
|
||||
var item = _itemScene.Instantiate<SaveSlotItem>();
|
||||
AddChild(item);
|
||||
|
||||
var col = i % ColumnCount;
|
||||
var row = i / ColumnCount;
|
||||
|
||||
item.Position = new Vector2(
|
||||
col * (cellWidth + SlotPadding),
|
||||
row * (cellHeight + SlotPadding)
|
||||
);
|
||||
item.Size = new Vector2(cellWidth, cellHeight);
|
||||
|
||||
item.Construct(model);
|
||||
item.SlotPressed += OnItemSlotPressed;
|
||||
_items.Add(item);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnResized()
|
||||
{
|
||||
var view = ViewportSize();
|
||||
if (_slotModels != null && view.X > 0 && view.Y > 0
|
||||
&& (_items.Count == 0 || Math.Abs(view.X - _laidOutFor.X) > 1 || Math.Abs(view.Y - _laidOutFor.Y) > 1))
|
||||
{
|
||||
_laidOutFor = view;
|
||||
Refresh();
|
||||
}
|
||||
}
|
||||
|
||||
private Vector2 _laidOutFor;
|
||||
|
||||
private void OnItemSlotPressed(string fileName)
|
||||
{
|
||||
SlotPressed?.Invoke(fileName);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,803 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Cthangover.Core.Localization;
|
||||
using Cthangover.Core.Mods;
|
||||
using Cthangover.Core.Settings;
|
||||
using Cthangover.Core.UI.Menu;
|
||||
using Cthangover.Core.UI.Theming;
|
||||
using Cthangover.Core.Utils;
|
||||
using Godot;
|
||||
|
||||
using Cthangover.Mods.Interface.Menu;
|
||||
namespace Cthangover.BaseMenu
|
||||
{
|
||||
/// <summary>
|
||||
/// Audio, language, and display settings panel with tabbed layout. Uses an
|
||||
/// _initialized guard to prevent save-triggered callbacks from firing during
|
||||
/// UI population (LoadSettingsToUI → widget changes → callbacks → premature
|
||||
/// saves). Language changes call LocaleLoader.LoadCurrentLanguage() for
|
||||
/// immediate effect, then emit LanguageChanged so parent menus can refresh
|
||||
/// their translated button text. Sliders become non-editable when their
|
||||
/// toggle is off, enforcing the "disabled means off" semantics visually.
|
||||
/// Display changes (window mode, resolution, monitor, V-Sync, scale) are
|
||||
/// applied live via DisplayServer and ProjectSettings.
|
||||
/// </summary>
|
||||
public partial class SettingsMenu : Control, ISettingsPanel
|
||||
{
|
||||
/// <summary>
|
||||
/// Raised after the player selects a different language and it has been applied.
|
||||
/// Parent menus (<see cref="MainMenu"/>, <see cref="GameMenu"/>) subscribe to
|
||||
/// refresh their translated button text.
|
||||
/// </summary>
|
||||
public event Action LanguageChanged;
|
||||
private TabContainer _tabs;
|
||||
private CheckButton _soundsToggle;
|
||||
private CheckButton _musicToggle;
|
||||
private CheckButton _ambientToggle;
|
||||
private HSlider _soundsVolume;
|
||||
private HSlider _musicVolume;
|
||||
private HSlider _ambientVolume;
|
||||
private Label _soundsVolumeValue;
|
||||
private Label _musicVolumeValue;
|
||||
private Label _ambientVolumeValue;
|
||||
private OptionButton _langSelect;
|
||||
private Button _backBtn;
|
||||
|
||||
private VBoxContainer _modListContainer;
|
||||
private Label _selectedModName;
|
||||
private Button _resetModSettingsBtn;
|
||||
private VBoxContainer _settingsContainer;
|
||||
private string _selectedModId;
|
||||
private readonly Dictionary<Control, string> _controlKeys = new();
|
||||
|
||||
private OptionButton _windowModeSelect;
|
||||
private OptionButton _resolutionSelect;
|
||||
private OptionButton _monitorSelect;
|
||||
private OptionButton _scaleSelect;
|
||||
private CheckButton _vsyncToggle;
|
||||
|
||||
private readonly Dictionary<Label, string> _labelKeys = new();
|
||||
private bool _initialized;
|
||||
|
||||
private static readonly Vector2I[] CommonResolutions =
|
||||
{
|
||||
new(1280, 720),
|
||||
new(1366, 768),
|
||||
new(1600, 900),
|
||||
new(1920, 1080),
|
||||
new(2560, 1440),
|
||||
new(3840, 2160),
|
||||
};
|
||||
|
||||
private static readonly int[] ScaleOptions = { 1, 2, 3, 4 };
|
||||
|
||||
public override void _Ready()
|
||||
{
|
||||
_tabs = GetNode<TabContainer>("Panel/Margin/VBox/Tabs");
|
||||
|
||||
_langSelect = GetNode<OptionButton>("Panel/Margin/VBox/Tabs/GeneralMargin/General/LangBox/LangSelect");
|
||||
|
||||
_soundsToggle = GetNode<CheckButton>("Panel/Margin/VBox/Tabs/AudioMargin/Audio/SoundsBox/ToggleRow/SoundsToggle");
|
||||
_soundsVolume = GetNode<HSlider>("Panel/Margin/VBox/Tabs/AudioMargin/Audio/SoundsBox/VolumeRow/SoundsVolume");
|
||||
_soundsVolumeValue = GetNode<Label>("Panel/Margin/VBox/Tabs/AudioMargin/Audio/SoundsBox/VolumeRow/SoundsVolumeValue");
|
||||
|
||||
_musicToggle = GetNode<CheckButton>("Panel/Margin/VBox/Tabs/AudioMargin/Audio/MusicBox/ToggleRow2/MusicToggle");
|
||||
_musicVolume = GetNode<HSlider>("Panel/Margin/VBox/Tabs/AudioMargin/Audio/MusicBox/VolumeRow2/MusicVolume");
|
||||
_musicVolumeValue = GetNode<Label>("Panel/Margin/VBox/Tabs/AudioMargin/Audio/MusicBox/VolumeRow2/MusicVolumeValue");
|
||||
|
||||
_ambientToggle = GetNode<CheckButton>("Panel/Margin/VBox/Tabs/AudioMargin/Audio/AmbientBox/ToggleRow3/AmbientToggle");
|
||||
_ambientVolume = GetNode<HSlider>("Panel/Margin/VBox/Tabs/AudioMargin/Audio/AmbientBox/VolumeRow3/AmbientVolume");
|
||||
_ambientVolumeValue = GetNode<Label>("Panel/Margin/VBox/Tabs/AudioMargin/Audio/AmbientBox/VolumeRow3/AmbientVolumeValue");
|
||||
|
||||
_windowModeSelect = GetNode<OptionButton>("Panel/Margin/VBox/Tabs/DisplayMargin/Display/ModeBox/ModeSelect");
|
||||
_resolutionSelect = GetNode<OptionButton>("Panel/Margin/VBox/Tabs/DisplayMargin/Display/ResBox/ResSelect");
|
||||
_monitorSelect = GetNode<OptionButton>("Panel/Margin/VBox/Tabs/DisplayMargin/Display/MonitorBox/MonitorSelect");
|
||||
_scaleSelect = GetNode<OptionButton>("Panel/Margin/VBox/Tabs/DisplayMargin/Display/ScaleBox/ScaleSelect");
|
||||
_vsyncToggle = GetNode<CheckButton>("Panel/Margin/VBox/Tabs/DisplayMargin/Display/VSyncRow/VSyncToggle");
|
||||
|
||||
_backBtn = GetNode<Button>("Panel/Margin/VBox/BackBtn");
|
||||
CthangoverTheme.ApplyIcon(_backBtn);
|
||||
|
||||
_modListContainer = GetNode<VBoxContainer>("Panel/Margin/VBox/Tabs/ModSettingsMargin/ModSettings/LeftPanel/ModScroll/ModList");
|
||||
_selectedModName = GetNode<Label>("Panel/Margin/VBox/Tabs/ModSettingsMargin/ModSettings/RightPanel/TopBar/SelectedModName");
|
||||
_resetModSettingsBtn = GetNode<Button>("Panel/Margin/VBox/Tabs/ModSettingsMargin/ModSettings/RightPanel/TopBar/ResetBtn");
|
||||
CthangoverTheme.ApplyDanger(_resetModSettingsBtn);
|
||||
_settingsContainer = GetNode<VBoxContainer>("Panel/Margin/VBox/Tabs/ModSettingsMargin/ModSettings/RightPanel/SettingsScroll/SettingsContainer");
|
||||
|
||||
RegisterLabel(GetNode<Label>("Panel/Margin/VBox/Title"), "menu/settings");
|
||||
RegisterLabel(GetNode<Label>("Panel/Margin/VBox/Tabs/GeneralMargin/General/LangBox/LangLabel"), "settings/language");
|
||||
RegisterLabel(GetNode<Label>("Panel/Margin/VBox/Tabs/AudioMargin/Audio/SoundsBox/ToggleRow/SoundsLabel"), "settings/sounds");
|
||||
RegisterLabel(GetNode<Label>("Panel/Margin/VBox/Tabs/AudioMargin/Audio/SoundsBox/VolumeRow/SoundsVolumeLabel"), "settings/volume");
|
||||
RegisterLabel(GetNode<Label>("Panel/Margin/VBox/Tabs/AudioMargin/Audio/MusicBox/ToggleRow2/MusicLabel"), "settings/music");
|
||||
RegisterLabel(GetNode<Label>("Panel/Margin/VBox/Tabs/AudioMargin/Audio/MusicBox/VolumeRow2/MusicVolumeLabel"), "settings/volume");
|
||||
RegisterLabel(GetNode<Label>("Panel/Margin/VBox/Tabs/AudioMargin/Audio/AmbientBox/ToggleRow3/AmbientLabel"), "settings/ambient");
|
||||
RegisterLabel(GetNode<Label>("Panel/Margin/VBox/Tabs/AudioMargin/Audio/AmbientBox/VolumeRow3/AmbientVolumeLabel"), "settings/volume");
|
||||
RegisterLabel(GetNode<Label>("Panel/Margin/VBox/Tabs/DisplayMargin/Display/ModeBox/ModeLabel"), "settings/window_mode");
|
||||
RegisterLabel(GetNode<Label>("Panel/Margin/VBox/Tabs/DisplayMargin/Display/ResBox/ResLabel"), "settings/resolution");
|
||||
RegisterLabel(GetNode<Label>("Panel/Margin/VBox/Tabs/DisplayMargin/Display/MonitorBox/MonitorLabel"), "settings/monitor");
|
||||
RegisterLabel(GetNode<Label>("Panel/Margin/VBox/Tabs/DisplayMargin/Display/ScaleBox/ScaleLabel"), "settings/scale");
|
||||
RegisterLabel(GetNode<Label>("Panel/Margin/VBox/Tabs/DisplayMargin/Display/VSyncRow/VSyncLabel"), "settings/vsync");
|
||||
RegisterLabel(GetNode<Label>("Panel/Margin/VBox/Tabs/ModSettingsMargin/ModSettings/LeftPanel/ModsTitle"), "settings/mod_list_title");
|
||||
RegisterLabel(GetNode<Label>("Panel/Margin/VBox/Tabs/ModSettingsMargin/ModSettings/RightPanel/SettingsScroll/SettingsContainer/Placeholder"), "settings/mods_no_settings");
|
||||
|
||||
_soundsToggle.Toggled += OnSoundsToggled;
|
||||
_musicToggle.Toggled += OnMusicToggled;
|
||||
_ambientToggle.Toggled += OnAmbientToggled;
|
||||
_soundsVolume.ValueChanged += OnSoundsVolumeChanged;
|
||||
_musicVolume.ValueChanged += OnMusicVolumeChanged;
|
||||
_ambientVolume.ValueChanged += OnAmbientVolumeChanged;
|
||||
_langSelect.ItemSelected += OnLanguageSelected;
|
||||
_backBtn.Pressed += OnBackPressed;
|
||||
_backBtn.Text = TranslationServer.Translate("settings/back");
|
||||
|
||||
_windowModeSelect.ItemSelected += OnDisplaySettingChanged;
|
||||
_resolutionSelect.ItemSelected += OnDisplaySettingChanged;
|
||||
_monitorSelect.ItemSelected += OnDisplaySettingChanged;
|
||||
_scaleSelect.ItemSelected += OnDisplaySettingChanged;
|
||||
_vsyncToggle.Toggled += OnDisplayToggleChanged;
|
||||
_resetModSettingsBtn.Pressed += OnResetModSettings;
|
||||
|
||||
LoadSettingsToUI();
|
||||
BuildModList();
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
private void RegisterLabel(Label label, string key)
|
||||
{
|
||||
if (label != null)
|
||||
_labelKeys[label] = key;
|
||||
}
|
||||
|
||||
private void RefreshUIText()
|
||||
{
|
||||
foreach (var kv in _labelKeys)
|
||||
{
|
||||
if (IsInstanceValid(kv.Key))
|
||||
kv.Key.Text = TranslationServer.Translate(kv.Value);
|
||||
}
|
||||
_backBtn.Text = TranslationServer.Translate("settings/back");
|
||||
_tabs.SetTabTitle(0, TranslationServer.Translate("settings/tab_general"));
|
||||
_tabs.SetTabTitle(1, TranslationServer.Translate("settings/tab_audio"));
|
||||
_tabs.SetTabTitle(2, TranslationServer.Translate("settings/tab_display"));
|
||||
_tabs.SetTabTitle(3, TranslationServer.Translate("settings/tab_mods"));
|
||||
_resetModSettingsBtn.Text = TranslationServer.Translate("settings/mods_reset");
|
||||
PopulateLangOptions();
|
||||
PopulateDisplayOptions();
|
||||
}
|
||||
|
||||
private void LoadSettingsToUI()
|
||||
{
|
||||
var s = GameData.Instance?.Settings;
|
||||
if (s == null) return;
|
||||
|
||||
_soundsToggle.ButtonPressed = s.SoundsEnabled;
|
||||
_musicToggle.ButtonPressed = s.MusicsEnabled;
|
||||
_ambientToggle.ButtonPressed = s.AmbientEnabled;
|
||||
_soundsVolume.Value = s.SoundsVolume;
|
||||
_musicVolume.Value = s.MusicsVolume;
|
||||
_ambientVolume.Value = s.AmbientVolume;
|
||||
_soundsVolumeValue.Text = $"{s.SoundsVolume}%";
|
||||
_musicVolumeValue.Text = $"{s.MusicsVolume}%";
|
||||
_ambientVolumeValue.Text = $"{s.AmbientVolume}%";
|
||||
|
||||
_vsyncToggle.ButtonPressed = s.VsyncEnabled;
|
||||
_windowModeSelect.Selected = WindowModeToIndex(s.WindowMode);
|
||||
|
||||
UpdateSlidersEditable();
|
||||
PopulateLangOptions();
|
||||
PopulateDisplayOptions();
|
||||
RefreshUIText();
|
||||
|
||||
SelectCurrentResolution(s);
|
||||
SelectCurrentMonitor(s);
|
||||
SelectCurrentScale(s);
|
||||
}
|
||||
|
||||
private void PopulateLangOptions()
|
||||
{
|
||||
_langSelect.Clear();
|
||||
_langSelect.AddItem(TranslationServer.Translate("settings/lang_ru"));
|
||||
_langSelect.AddItem(TranslationServer.Translate("settings/lang_en"));
|
||||
_langSelect.AddItem(TranslationServer.Translate("settings/lang_zh"));
|
||||
|
||||
var s = GameData.Instance?.Settings;
|
||||
if (s == null) return;
|
||||
_langSelect.Selected = s.Language switch { "en" => 1, "zh" => 2, _ => 0 };
|
||||
}
|
||||
|
||||
private void PopulateDisplayOptions()
|
||||
{
|
||||
var previousWindow = _windowModeSelect.Selected;
|
||||
var previousRes = _resolutionSelect.Selected;
|
||||
var previousMon = _monitorSelect.Selected;
|
||||
var previousScale = _scaleSelect.Selected;
|
||||
|
||||
_windowModeSelect.Clear();
|
||||
_windowModeSelect.AddItem(TranslationServer.Translate("settings/window_mode_windowed"));
|
||||
_windowModeSelect.AddItem(TranslationServer.Translate("settings/window_mode_borderless"));
|
||||
_windowModeSelect.AddItem(TranslationServer.Translate("settings/window_mode_fullscreen"));
|
||||
|
||||
_resolutionSelect.Clear();
|
||||
foreach (var r in CommonResolutions)
|
||||
_resolutionSelect.AddItem($"{r.X}×{r.Y}");
|
||||
|
||||
_monitorSelect.Clear();
|
||||
var count = DisplayServer.GetScreenCount();
|
||||
if (count <= 0) count = 1;
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var size = DisplayServer.ScreenGetSize(i);
|
||||
var label = string.Format(TranslationServer.Translate("settings/monitor_screen"), i + 1, size.X, size.Y);
|
||||
_monitorSelect.AddItem(label);
|
||||
}
|
||||
|
||||
_scaleSelect.Clear();
|
||||
foreach (var sc in ScaleOptions)
|
||||
_scaleSelect.AddItem(TranslationServer.Translate($"settings/scale_{sc}x"));
|
||||
|
||||
_windowModeSelect.Selected = Mathf.Clamp(previousWindow, 0, 2);
|
||||
_resolutionSelect.Selected = Mathf.Clamp(previousRes, 0, CommonResolutions.Length - 1);
|
||||
_monitorSelect.Selected = Mathf.Clamp(previousMon, 0, count - 1);
|
||||
_scaleSelect.Selected = Mathf.Clamp(previousScale, 0, ScaleOptions.Length - 1);
|
||||
}
|
||||
|
||||
private void SelectCurrentResolution(SettingsData s)
|
||||
{
|
||||
for (var i = 0; i < CommonResolutions.Length; i++)
|
||||
{
|
||||
if (CommonResolutions[i].X == s.ResolutionWidth && CommonResolutions[i].Y == s.ResolutionHeight)
|
||||
{
|
||||
_resolutionSelect.Selected = i;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void SelectCurrentMonitor(SettingsData s)
|
||||
{
|
||||
var count = DisplayServer.GetScreenCount();
|
||||
if (s.CurrentScreen >= 0 && s.CurrentScreen < count)
|
||||
_monitorSelect.Selected = s.CurrentScreen;
|
||||
}
|
||||
|
||||
private void SelectCurrentScale(SettingsData s)
|
||||
{
|
||||
for (var i = 0; i < ScaleOptions.Length; i++)
|
||||
{
|
||||
if (ScaleOptions[i] == s.Scale)
|
||||
{
|
||||
_scaleSelect.Selected = i;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyAndSave()
|
||||
{
|
||||
if (!_initialized) return;
|
||||
var s = GameData.Instance?.Settings;
|
||||
if (s == null) return;
|
||||
|
||||
s.SoundsEnabled = _soundsToggle.ButtonPressed;
|
||||
s.MusicsEnabled = _musicToggle.ButtonPressed;
|
||||
s.AmbientEnabled = _ambientToggle.ButtonPressed;
|
||||
s.SoundsVolume = (int)_soundsVolume.Value;
|
||||
s.MusicsVolume = (int)_musicVolume.Value;
|
||||
s.AmbientVolume = (int)_ambientVolume.Value;
|
||||
s.Save();
|
||||
}
|
||||
|
||||
private void ApplyDisplayAndSave()
|
||||
{
|
||||
if (!_initialized) return;
|
||||
var s = GameData.Instance?.Settings;
|
||||
if (s == null) return;
|
||||
|
||||
var windowMode = IndexToWindowMode(_windowModeSelect.Selected);
|
||||
var resIdx = Mathf.Clamp(_resolutionSelect.Selected, 0, CommonResolutions.Length - 1);
|
||||
var resolution = CommonResolutions[resIdx];
|
||||
var monitorIdx = _monitorSelect.Selected;
|
||||
var scaleIdx = Mathf.Clamp(_scaleSelect.Selected, 0, ScaleOptions.Length - 1);
|
||||
var scale = ScaleOptions[scaleIdx];
|
||||
var vsync = _vsyncToggle.ButtonPressed;
|
||||
|
||||
if (monitorIdx >= 0 && monitorIdx < DisplayServer.GetScreenCount())
|
||||
DisplayServer.WindowSetCurrentScreen(monitorIdx);
|
||||
|
||||
DisplayServer.WindowSetMode(windowMode);
|
||||
|
||||
if (windowMode == DisplayServer.WindowMode.Windowed)
|
||||
DisplayServer.WindowSetSize(resolution);
|
||||
|
||||
DisplayServer.WindowSetVsyncMode(vsync
|
||||
? DisplayServer.VSyncMode.Enabled
|
||||
: DisplayServer.VSyncMode.Disabled);
|
||||
|
||||
ProjectSettings.SetSetting("display/window/stretch/scale", (float)scale);
|
||||
|
||||
s.WindowMode = (int)windowMode;
|
||||
s.ResolutionWidth = resolution.X;
|
||||
s.ResolutionHeight = resolution.Y;
|
||||
s.CurrentScreen = monitorIdx;
|
||||
s.VsyncEnabled = vsync;
|
||||
s.Scale = scale;
|
||||
s.Save();
|
||||
}
|
||||
|
||||
private void OnSoundsToggled(bool on) { UpdateSlidersEditable(); ApplyAndSave(); }
|
||||
private void OnMusicToggled(bool on) { UpdateSlidersEditable(); ApplyAndSave(); }
|
||||
private void OnAmbientToggled(bool on) { UpdateSlidersEditable(); ApplyAndSave(); }
|
||||
|
||||
private void OnSoundsVolumeChanged(double v) { _soundsVolumeValue.Text = $"{(int)v}%"; ApplyAndSave(); }
|
||||
private void OnMusicVolumeChanged(double v) { _musicVolumeValue.Text = $"{(int)v}%"; ApplyAndSave(); }
|
||||
private void OnAmbientVolumeChanged(double v) { _ambientVolumeValue.Text = $"{(int)v}%"; ApplyAndSave(); }
|
||||
|
||||
private void OnDisplaySettingChanged(long idx) => ApplyDisplayAndSave();
|
||||
private void OnDisplayToggleChanged(bool on) => ApplyDisplayAndSave();
|
||||
|
||||
private void OnLanguageSelected(long idx)
|
||||
{
|
||||
if (!_initialized) return;
|
||||
var s = GameData.Instance?.Settings;
|
||||
if (s == null) return;
|
||||
|
||||
var lang = idx switch { 1 => "en", 2 => "zh", _ => "ru-ru" };
|
||||
if (s.Language == lang) return;
|
||||
|
||||
s.Language = lang;
|
||||
s.Save();
|
||||
LocaleLoader.LoadCurrentLanguage();
|
||||
RefreshUIText();
|
||||
LanguageChanged?.Invoke();
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="IMenuPanel" />
|
||||
public void Open() => Visible = true;
|
||||
|
||||
private void OnBackPressed() => Visible = false;
|
||||
|
||||
private void UpdateSlidersEditable()
|
||||
{
|
||||
_soundsVolume.Editable = _soundsToggle.ButtonPressed;
|
||||
_musicVolume.Editable = _musicToggle.ButtonPressed;
|
||||
_ambientVolume.Editable = _ambientToggle.ButtonPressed;
|
||||
}
|
||||
|
||||
private static int WindowModeToIndex(int mode)
|
||||
{
|
||||
return mode switch
|
||||
{
|
||||
3 => 1,
|
||||
4 => 2,
|
||||
_ => 0,
|
||||
};
|
||||
}
|
||||
|
||||
private static DisplayServer.WindowMode IndexToWindowMode(int index)
|
||||
{
|
||||
return index switch
|
||||
{
|
||||
1 => DisplayServer.WindowMode.Fullscreen,
|
||||
2 => DisplayServer.WindowMode.ExclusiveFullscreen,
|
||||
_ => DisplayServer.WindowMode.Windowed,
|
||||
};
|
||||
}
|
||||
|
||||
private void BuildModList()
|
||||
{
|
||||
foreach (var child in _modListContainer.GetChildren())
|
||||
child.QueueFree();
|
||||
|
||||
var modsWithSettings = ModSettingsRegistry.GetModsWithSettings();
|
||||
|
||||
if (modsWithSettings.Count == 0)
|
||||
return;
|
||||
|
||||
foreach (var modId in modsWithSettings)
|
||||
{
|
||||
var btn = new Button
|
||||
{
|
||||
Text = ModSettingsRegistry.GetDisplayTitle(modId),
|
||||
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
|
||||
};
|
||||
btn.Pressed += () => OnModSelected(modId);
|
||||
_modListContainer.AddChild(btn);
|
||||
}
|
||||
|
||||
var firstMod = modsWithSettings[0];
|
||||
OnModSelected(firstMod);
|
||||
}
|
||||
|
||||
private void OnModSelected(string modId)
|
||||
{
|
||||
_selectedModId = modId;
|
||||
_selectedModName.Text = ModSettingsRegistry.GetDisplayTitle(modId);
|
||||
BuildModSettingsPanel(modId);
|
||||
}
|
||||
|
||||
private void BuildModSettingsPanel(string modId)
|
||||
{
|
||||
_controlKeys.Clear();
|
||||
|
||||
foreach (var child in _settingsContainer.GetChildren())
|
||||
child.QueueFree();
|
||||
|
||||
_settingsContainer.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
||||
|
||||
var definitions = ModSettingsRegistry.GetDefinitions(modId);
|
||||
if (definitions.Count == 0)
|
||||
{
|
||||
var placeholder = new Label
|
||||
{
|
||||
Text = TranslationServer.Translate("settings/mods_no_settings"),
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
};
|
||||
_settingsContainer.AddChild(placeholder);
|
||||
return;
|
||||
}
|
||||
|
||||
var currentBlob = ModSettingsRegistry.WriteCurrentValues(modId);
|
||||
|
||||
foreach (var def in definitions)
|
||||
{
|
||||
var row = new HBoxContainer
|
||||
{
|
||||
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
|
||||
CustomMinimumSize = new Vector2(0, 40),
|
||||
};
|
||||
row.AddThemeConstantOverride("separation", 12);
|
||||
|
||||
var label = new Label
|
||||
{
|
||||
Text = TranslationServer.Translate(def.Name),
|
||||
CustomMinimumSize = new Vector2(180, 0),
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
};
|
||||
row.AddChild(label);
|
||||
|
||||
if (def.Type == SettingType.Action)
|
||||
{
|
||||
AddActionControls(row, modId, def);
|
||||
_settingsContainer.AddChild(row);
|
||||
continue;
|
||||
}
|
||||
|
||||
Control control = CreateControlForType(def);
|
||||
ApplyControlValue(control, def, currentBlob);
|
||||
_controlKeys[control] = def.Key;
|
||||
|
||||
switch (def.Type)
|
||||
{
|
||||
case SettingType.Bool:
|
||||
if (control is CheckButton checkbox)
|
||||
checkbox.Toggled += _ => OnModSettingChanged();
|
||||
break;
|
||||
case SettingType.Integer:
|
||||
case SettingType.Float:
|
||||
if (control is SpinBox spin)
|
||||
spin.ValueChanged += _ => OnModSettingChanged();
|
||||
break;
|
||||
case SettingType.String:
|
||||
if (control is LineEdit lineEdit)
|
||||
lineEdit.TextChanged += _ => OnModSettingChanged();
|
||||
break;
|
||||
case SettingType.Enum:
|
||||
if (control is OptionButton option)
|
||||
option.ItemSelected += _ => OnModSettingChanged();
|
||||
break;
|
||||
case SettingType.Slider:
|
||||
if (control is HSlider slider)
|
||||
{
|
||||
slider.ValueChanged += _ =>
|
||||
{
|
||||
var val = (double)slider.GetMeta("step", 1.0).AsDouble();
|
||||
var step = val;
|
||||
var stepped = Mathf.Round(slider.Value / step) * step;
|
||||
slider.Value = stepped;
|
||||
OnModSettingChanged();
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
row.AddChild(control);
|
||||
|
||||
if (def.Type == SettingType.Slider)
|
||||
{
|
||||
var valLabel = new Label
|
||||
{
|
||||
Text = FormatSliderValue(currentBlob, def),
|
||||
CustomMinimumSize = new Vector2(48, 0),
|
||||
HorizontalAlignment = HorizontalAlignment.Right,
|
||||
};
|
||||
row.AddChild(valLabel);
|
||||
}
|
||||
|
||||
_settingsContainer.AddChild(row);
|
||||
}
|
||||
|
||||
var timer = new Timer { OneShot = true, Autostart = true, WaitTime = 0.01 };
|
||||
timer.Timeout += () =>
|
||||
{
|
||||
timer.QueueFree();
|
||||
LogModSettingsSizes();
|
||||
};
|
||||
_settingsContainer.AddChild(timer);
|
||||
}
|
||||
|
||||
private void LogModSettingsSizes()
|
||||
{
|
||||
if (_settingsContainer == null)
|
||||
return;
|
||||
|
||||
GameLogger.Log("MODS_SETTINGS_UI", $"SettingsContainer size: {_settingsContainer.Size}", LogLevel.Debug);
|
||||
foreach (var child in _settingsContainer.GetChildren())
|
||||
{
|
||||
if (child is HBoxContainer row)
|
||||
{
|
||||
GameLogger.Log("MODS_SETTINGS_UI", $" Row size: {row.Size}", LogLevel.Debug);
|
||||
foreach (var rowChild in row.GetChildren())
|
||||
{
|
||||
if (rowChild is HSlider slider)
|
||||
GameLogger.Log("MODS_SETTINGS_UI", $" HSlider size: {slider.Size}, min_size: {slider.CustomMinimumSize}", LogLevel.Debug);
|
||||
else if (rowChild is Control c)
|
||||
GameLogger.Log("MODS_SETTINGS_UI", $" {rowChild.GetType().Name} size: {c.Size}", LogLevel.Debug);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Action setting: a button (with an optional confirmation) that runs
|
||||
/// <see cref="ModSettingsRegistry.RunAction"/> and shows the result next
|
||||
/// to it. Actions store no value, so they stay out of <c>_controlKeys</c>.
|
||||
/// </summary>
|
||||
private void AddActionControls(HBoxContainer row, string modId, ModSettingDefinition def)
|
||||
{
|
||||
var button = new Button { Text = TranslationServer.Translate(def.ActionText ?? def.Name) };
|
||||
var result = new Label { VerticalAlignment = VerticalAlignment.Center, SizeFlagsHorizontal = Control.SizeFlags.ExpandFill };
|
||||
|
||||
void Run() => result.Text = TranslationServer.Translate(ModSettingsRegistry.RunAction(modId, def.Key) ?? string.Empty);
|
||||
|
||||
button.Pressed += () =>
|
||||
{
|
||||
if (string.IsNullOrEmpty(def.ConfirmText))
|
||||
{
|
||||
Run();
|
||||
return;
|
||||
}
|
||||
|
||||
var dialog = new ConfirmationDialog
|
||||
{
|
||||
DialogText = TranslationServer.Translate(def.ConfirmText),
|
||||
Exclusive = true,
|
||||
};
|
||||
dialog.Confirmed += () =>
|
||||
{
|
||||
Run();
|
||||
dialog.QueueFree();
|
||||
};
|
||||
dialog.Canceled += dialog.QueueFree;
|
||||
AddChild(dialog);
|
||||
dialog.PopupCentered();
|
||||
};
|
||||
|
||||
row.AddChild(button);
|
||||
row.AddChild(result);
|
||||
}
|
||||
|
||||
private static Control CreateControlForType(ModSettingDefinition def)
|
||||
{
|
||||
switch (def.Type)
|
||||
{
|
||||
case SettingType.Bool:
|
||||
return new CheckButton();
|
||||
case SettingType.Integer:
|
||||
return new SpinBox
|
||||
{
|
||||
AllowGreater = true,
|
||||
AllowLesser = true,
|
||||
Step = 1.0,
|
||||
MinValue = int.MinValue,
|
||||
MaxValue = int.MaxValue,
|
||||
};
|
||||
case SettingType.Float:
|
||||
return new SpinBox
|
||||
{
|
||||
AllowGreater = true,
|
||||
AllowLesser = true,
|
||||
Step = 0.1,
|
||||
MinValue = double.MinValue,
|
||||
MaxValue = double.MaxValue,
|
||||
};
|
||||
case SettingType.String:
|
||||
return new LineEdit();
|
||||
case SettingType.Enum:
|
||||
{
|
||||
var opt = new OptionButton();
|
||||
if (def.Options != null)
|
||||
{
|
||||
foreach (var kv in def.Options)
|
||||
opt.AddItem(TranslationServer.Translate(kv.Value));
|
||||
}
|
||||
return opt;
|
||||
}
|
||||
case SettingType.Slider:
|
||||
{
|
||||
var slider = new HSlider
|
||||
{
|
||||
MinValue = def.Min,
|
||||
MaxValue = def.Max,
|
||||
Step = 0.01,
|
||||
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
|
||||
SizeFlagsVertical = Control.SizeFlags.ExpandFill,
|
||||
};
|
||||
slider.SetMeta("step", Variant.From(def.Step > 0 ? def.Step : 1.0));
|
||||
return slider;
|
||||
}
|
||||
default:
|
||||
return new HBoxContainer();
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyControlValue(Control control, ModSettingDefinition def, DataBlob blob)
|
||||
{
|
||||
switch (def.Type)
|
||||
{
|
||||
case SettingType.Bool:
|
||||
if (control is CheckButton checkbox)
|
||||
checkbox.ButtonPressed = blob.GetString(def.Key, def.DefaultValue ?? "false").ToLowerInvariant() == "true";
|
||||
break;
|
||||
case SettingType.Integer:
|
||||
if (control is SpinBox spin)
|
||||
{
|
||||
var defVal = int.TryParse(blob.GetString(def.Key, def.DefaultValue ?? "0"), out var parsed) ? parsed : 0;
|
||||
spin.Value = blob.GetInt(def.Key, defVal);
|
||||
}
|
||||
break;
|
||||
case SettingType.Float:
|
||||
if (control is SpinBox spinFloat)
|
||||
{
|
||||
var defVal = float.TryParse(blob.GetString(def.Key, def.DefaultValue ?? "0"), out var parsed) ? parsed : 0f;
|
||||
spinFloat.Value = blob.GetFloat(def.Key, defVal);
|
||||
}
|
||||
break;
|
||||
case SettingType.String:
|
||||
if (control is LineEdit lineEdit)
|
||||
lineEdit.Text = blob.GetString(def.Key, def.DefaultValue ?? "");
|
||||
break;
|
||||
case SettingType.Enum:
|
||||
if (control is OptionButton opt)
|
||||
{
|
||||
var current = blob.GetString(def.Key, def.DefaultValue ?? "");
|
||||
opt.Selected = GetOptionIndex(def, current);
|
||||
}
|
||||
break;
|
||||
case SettingType.Slider:
|
||||
if (control is HSlider slider)
|
||||
{
|
||||
var defVal = float.TryParse(blob.GetString(def.Key, def.DefaultValue ?? "0"), out var parsed) ? parsed : 0f;
|
||||
slider.Value = blob.GetFloat(def.Key, defVal);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ReadControlValue(Control control, SettingType type, ModSettingDefinition def)
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case SettingType.Bool:
|
||||
return control is CheckButton checkbox ? checkbox.ButtonPressed.ToString().ToLowerInvariant() : "false";
|
||||
case SettingType.Integer:
|
||||
return control is SpinBox spin ? ((int)spin.Value).ToString() : "0";
|
||||
case SettingType.Float:
|
||||
return control is SpinBox spinFloat ? spinFloat.Value.ToString("F4") : "0";
|
||||
case SettingType.String:
|
||||
return control is LineEdit lineEdit ? lineEdit.Text : "";
|
||||
case SettingType.Enum:
|
||||
if (control is OptionButton opt && def.Options != null && opt.Selected >= 0 && opt.Selected < def.Options.Count)
|
||||
{
|
||||
var idx = 0;
|
||||
foreach (var kv in def.Options)
|
||||
{
|
||||
if (idx == opt.Selected)
|
||||
return kv.Key;
|
||||
idx++;
|
||||
}
|
||||
}
|
||||
return def.DefaultValue ?? "";
|
||||
case SettingType.Slider:
|
||||
return control is HSlider slider ? slider.Value.ToString("F4") : "0";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatSliderValue(DataBlob blob, ModSettingDefinition def)
|
||||
{
|
||||
var value = blob.GetFloat(def.Key, float.TryParse(def.DefaultValue, out var d) ? d : 0f);
|
||||
var step = def.Step > 0 ? def.Step : 1f;
|
||||
if (step >= 1f)
|
||||
return $"{(int)value}";
|
||||
return $"{value:F2}";
|
||||
}
|
||||
|
||||
private static int GetOptionIndex(ModSettingDefinition def, string key)
|
||||
{
|
||||
if (def.Options == null)
|
||||
return 0;
|
||||
var idx = 0;
|
||||
foreach (var kv in def.Options)
|
||||
{
|
||||
if (kv.Key == key)
|
||||
return idx;
|
||||
idx++;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private void OnModSettingChanged()
|
||||
{
|
||||
if (!_initialized || string.IsNullOrEmpty(_selectedModId))
|
||||
return;
|
||||
|
||||
var blob = new DataBlob();
|
||||
var definitions = ModSettingsRegistry.GetDefinitions(_selectedModId);
|
||||
|
||||
foreach (var def in definitions)
|
||||
{
|
||||
var control = FindControlForKey(def.Key);
|
||||
if (control == null)
|
||||
continue;
|
||||
var value = ReadControlValue(control, def.Type, def);
|
||||
blob.SetString(def.Key, value);
|
||||
|
||||
if (def.Type == SettingType.Slider)
|
||||
UpdateSliderValueLabel(control);
|
||||
}
|
||||
|
||||
ModSettingsRegistry.SaveModSettings(_selectedModId, blob);
|
||||
ModSettingsRegistry.ApplyValues(_selectedModId, blob);
|
||||
}
|
||||
|
||||
private Control FindControlForKey(string key)
|
||||
{
|
||||
foreach (var kv in _controlKeys)
|
||||
{
|
||||
if (kv.Value == key)
|
||||
return kv.Key;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void UpdateSliderValueLabel(Control slider)
|
||||
{
|
||||
if (!(slider is HSlider hslider))
|
||||
return;
|
||||
|
||||
var parent = slider.GetParent();
|
||||
if (parent == null)
|
||||
return;
|
||||
|
||||
var lastChild = parent.GetChild(parent.GetChildCount() - 1);
|
||||
if (lastChild is Label valLabel)
|
||||
{
|
||||
var step = (double)slider.GetMeta("step", 1.0).AsDouble();
|
||||
if (step >= 1.0)
|
||||
valLabel.Text = $"{(int)hslider.Value}";
|
||||
else
|
||||
valLabel.Text = $"{hslider.Value:F2}";
|
||||
}
|
||||
}
|
||||
|
||||
private void OnResetModSettings()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_selectedModId))
|
||||
return;
|
||||
|
||||
ModSettingsRegistry.DeleteModSettings(_selectedModId);
|
||||
BuildModSettingsPanel(_selectedModId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "Hot Reload Test",
|
||||
"description": "Hot reload in a running front-end: writes two probe mods (a library and a mod depending on it) into user://mods, loads them in place, edits the library source and lets the file watcher reload both (old contexts collected, the front-end kept), checks that a compile error is rejected while the previous code keeps running, fixes it, and finally drops the probes from the set. Activated only by --itest=hot_reload (with --frontend --hot-reload).",
|
||||
"sources": [
|
||||
"src/*.cs"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Cthangover.Core.Bootstrap;
|
||||
using Cthangover.Core.Events;
|
||||
using Cthangover.Core.Games;
|
||||
using Cthangover.Core.Mods;
|
||||
using Cthangover.Core.Mods.HotReload;
|
||||
using Cthangover.Core.Utils;
|
||||
using Godot;
|
||||
|
||||
namespace Mods.HotReloadTest
|
||||
{
|
||||
/// <summary>
|
||||
/// Hot reload in the running front-end, driven through the real file
|
||||
/// watcher. Two probe mods are written into <c>user://mods</c>: a library
|
||||
/// returning a version string and an app mod (depending on it) whose
|
||||
/// initializer puts a node with that string into <c>/root</c>.
|
||||
/// <list type="number">
|
||||
/// <item>v1 is loaded by adding the probes to the selection and writing the files.</item>
|
||||
/// <item>Editing the library to v2 reloads both probes (the dependent
|
||||
/// cascades), the old contexts are collected, the front-end stays.</item>
|
||||
/// <item>A compile error in the library is rejected: v2 keeps running.</item>
|
||||
/// <item>Fixing it (v3) reloads again.</item>
|
||||
/// <item>Dropping the probes from the selection unloads them and their node.</item>
|
||||
/// </list>
|
||||
/// Reload outcomes are read from <see cref="ModSetReloadedEvent"/>s, since
|
||||
/// the watcher may add no-op reloads of its own. Prints
|
||||
/// <c>[TEST_RESULT] name=hot_reload</c>.
|
||||
/// </summary>
|
||||
public partial class HotReloadTestDriverNode : Node
|
||||
{
|
||||
public const string Flag = "--itest=hot_reload";
|
||||
|
||||
private const string LibId = "hot_reload_probe_lib";
|
||||
private const string AppId = "hot_reload_probe_app";
|
||||
private const string ProbeNodeName = "HotReloadProbeNode";
|
||||
private const float TimeoutSeconds = 300f;
|
||||
|
||||
private readonly List<string> failures = new();
|
||||
private readonly List<ModSetReloadedEvent> reloads = new();
|
||||
private int passed;
|
||||
private int step;
|
||||
private double elapsed;
|
||||
private double stepAt;
|
||||
private bool done;
|
||||
private GameLaunchArgs startSelection;
|
||||
private string modsRoot;
|
||||
|
||||
public override void _Ready()
|
||||
{
|
||||
ProcessMode = ProcessModeEnum.Always;
|
||||
ModEventBus.Subscribe<ModSetReloadedEvent>(OnReloaded);
|
||||
modsRoot = ProjectSettings.GlobalizePath("user://mods");
|
||||
}
|
||||
|
||||
public override void _ExitTree()
|
||||
{
|
||||
ModEventBus.Unsubscribe<ModSetReloadedEvent>(OnReloaded);
|
||||
}
|
||||
|
||||
private void OnReloaded(ModSetReloadedEvent e) => reloads.Add(e);
|
||||
|
||||
public override void _Process(double delta)
|
||||
{
|
||||
if (done)
|
||||
return;
|
||||
|
||||
elapsed += delta;
|
||||
if (elapsed > TimeoutSeconds)
|
||||
{
|
||||
failures.Add($"timeout at step {step} (probe='{ProbeValue()}', reloads={reloads.Count})");
|
||||
Finish();
|
||||
return;
|
||||
}
|
||||
|
||||
if (elapsed - stepAt < 0.25)
|
||||
return;
|
||||
stepAt = elapsed;
|
||||
|
||||
try
|
||||
{
|
||||
RunStep();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failures.Add($"step {step}: {ex.GetType().Name}: {ex.Message}");
|
||||
Finish();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool Busy => GameSession.IsSwitching || ModManager.Instance.IsReloading || ModHotReloadService.IsReloadRunning;
|
||||
|
||||
private void RunStep()
|
||||
{
|
||||
switch (step)
|
||||
{
|
||||
case 0:
|
||||
if (!Boot.IsReady)
|
||||
return;
|
||||
// First launch shows the launcher screen; go on like a player.
|
||||
if (GetTree().CurrentScene?.FindChild("LauncherPlay", true, false) is Button launcherPlay)
|
||||
{
|
||||
launcherPlay.EmitSignal(BaseButton.SignalName.Pressed);
|
||||
return;
|
||||
}
|
||||
if (GetTree().CurrentScene?.FindChild("play", true, false) == null)
|
||||
return;
|
||||
Check("hot reload watcher is installed", ModHotReloadService.Instance != null);
|
||||
startSelection = GameLaunchArgs.Current;
|
||||
GameLaunchArgs.SetCurrent(GameLaunchArgs.Parse(startSelection.UserArgs.Concat(new[] { GameLaunchArgs.WithModsPrefix + AppId })));
|
||||
reloads.Clear();
|
||||
WriteProbes("v1");
|
||||
break;
|
||||
|
||||
case 1:
|
||||
if (Busy || ProbeValue() != "v1")
|
||||
return;
|
||||
Check("probes loaded by the watcher", AnyApplied(LoadedBoth));
|
||||
CheckNoLeaks("v1");
|
||||
reloads.Clear();
|
||||
WriteLib("v2", compiles: true);
|
||||
break;
|
||||
|
||||
case 2:
|
||||
if (Busy || ProbeValue() != "v2")
|
||||
return;
|
||||
Check("editing the library reloads it and its dependent", AnyApplied(UnloadedAndLoadedBoth));
|
||||
Check("the front-end stays loaded", !AnyApplied(UnloadedBaseMenu));
|
||||
CheckNoLeaks("v2");
|
||||
reloads.Clear();
|
||||
WriteLib("v2", compiles: false);
|
||||
break;
|
||||
|
||||
case 3:
|
||||
if (Busy || !reloads.Any(IsRejectedForLib))
|
||||
return;
|
||||
Check("a compile error rejects the reload", true);
|
||||
Check("the previous code keeps running", ProbeValue() == "v2");
|
||||
reloads.Clear();
|
||||
WriteLib("v3", compiles: true);
|
||||
break;
|
||||
|
||||
case 4:
|
||||
if (Busy || ProbeValue() != "v3")
|
||||
return;
|
||||
Check("fixing the error reloads again", AnyApplied(LoadedBoth));
|
||||
CheckNoLeaks("v3");
|
||||
reloads.Clear();
|
||||
GameLaunchArgs.SetCurrent(startSelection);
|
||||
StartManualReload();
|
||||
break;
|
||||
|
||||
case 5:
|
||||
if (Busy || ProbeValue() != null || !AnyApplied(UnloadedBoth))
|
||||
return;
|
||||
Check("dropping the probes unloads them and their node", true);
|
||||
CheckNoLeaks("removal");
|
||||
DeleteProbes();
|
||||
Finish();
|
||||
return;
|
||||
}
|
||||
|
||||
step++;
|
||||
}
|
||||
|
||||
private static async void StartManualReload() => await ModHotReloadService.ReloadNowAsync();
|
||||
|
||||
private string ProbeValue()
|
||||
{
|
||||
var node = GetTree().Root.GetNodeOrNull(ProbeNodeName);
|
||||
return node != null && IsInstanceValid(node) && node.HasMeta("value") ? node.GetMeta("value").AsString() : null;
|
||||
}
|
||||
|
||||
private bool AnyApplied(Func<ModSetReloadedEvent, bool> predicate)
|
||||
{
|
||||
foreach (var reload in reloads)
|
||||
{
|
||||
if (reload.Applied && predicate(reload))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool LoadedBoth(ModSetReloadedEvent e) => e.Loaded.Contains(LibId) && e.Loaded.Contains(AppId);
|
||||
|
||||
private static bool UnloadedBoth(ModSetReloadedEvent e) => e.Unloaded.Contains(LibId) && e.Unloaded.Contains(AppId);
|
||||
|
||||
private static bool UnloadedAndLoadedBoth(ModSetReloadedEvent e) => UnloadedBoth(e) && LoadedBoth(e);
|
||||
|
||||
private static bool UnloadedBaseMenu(ModSetReloadedEvent e) => e.Unloaded.Contains("base_menu");
|
||||
|
||||
private static bool IsRejectedForLib(ModSetReloadedEvent e) => !e.Applied && e.Errors.Any(IsLibError);
|
||||
|
||||
private static bool IsLibError(string error) => error.Contains(LibId);
|
||||
|
||||
private void CheckNoLeaks(string stage)
|
||||
{
|
||||
var leaked = new List<string>();
|
||||
foreach (var reload in reloads)
|
||||
leaked.AddRange(reload.Leaked);
|
||||
Check($"every released context collected ({stage})", leaked.Count == 0);
|
||||
if (leaked.Count > 0)
|
||||
GameLogger.Log("TEST", $"HotReloadTest: leaked: {string.Join(", ", leaked)}", LogLevel.Error);
|
||||
}
|
||||
|
||||
private void WriteProbes(string version)
|
||||
{
|
||||
var app = Path.Combine(modsRoot, AppId);
|
||||
Directory.CreateDirectory(Path.Combine(app, "src"));
|
||||
File.WriteAllText(Path.Combine(app, "manifest.json"),
|
||||
"{ \"id\": \"" + AppId + "\", \"name\": \"Hot reload probe app\", \"version\": \"1.0.0\", \"author\": \"test\", " +
|
||||
"\"sources\": [\"src/*.cs\"], \"depends\": [\"" + LibId + ">=1\"] }");
|
||||
File.WriteAllText(Path.Combine(app, "src", "ProbeApp.cs"), ProbeAppSource);
|
||||
WriteLib(version, compiles: true);
|
||||
}
|
||||
|
||||
private void WriteLib(string version, bool compiles)
|
||||
{
|
||||
var lib = Path.Combine(modsRoot, LibId);
|
||||
Directory.CreateDirectory(Path.Combine(lib, "src"));
|
||||
File.WriteAllText(Path.Combine(lib, "manifest.json"),
|
||||
"{ \"id\": \"" + LibId + "\", \"name\": \"Hot reload probe library\", \"version\": \"1.0.0\", \"author\": \"test\", \"sources\": [\"src/*.cs\"] }");
|
||||
var body = compiles ? "\"" + version + "\";" : "\"" + version + "\"";
|
||||
File.WriteAllText(Path.Combine(lib, "src", "ProbeLib.cs"),
|
||||
"namespace HotReloadProbe\n{\n public static class ProbeLib\n {\n public static string Value() => " + body + "\n }\n}\n");
|
||||
}
|
||||
|
||||
private void DeleteProbes()
|
||||
{
|
||||
foreach (var id in new[] { LibId, AppId })
|
||||
{
|
||||
var dir = Path.Combine(modsRoot, id);
|
||||
if (Directory.Exists(dir))
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
private const string ProbeAppSource = @"using Godot;
|
||||
|
||||
namespace HotReloadProbeApp
|
||||
{
|
||||
public partial class ProbeNode : Node
|
||||
{
|
||||
}
|
||||
|
||||
public class ProbeAppInitializer : Cthangover.Core.Mods.IModInitializer
|
||||
{
|
||||
public void OnModLoaded(string modId) { }
|
||||
|
||||
public void OnModResourcesReady()
|
||||
{
|
||||
var root = (Engine.GetMainLoop() as SceneTree)?.Root;
|
||||
if (root == null)
|
||||
return;
|
||||
root.GetNodeOrNull(""HotReloadProbeNode"")?.Free();
|
||||
var node = new ProbeNode { Name = ""HotReloadProbeNode"" };
|
||||
node.SetMeta(""value"", HotReloadProbe.ProbeLib.Value());
|
||||
root.AddChild(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
";
|
||||
|
||||
private void Check(string name, bool ok)
|
||||
{
|
||||
if (ok)
|
||||
{
|
||||
passed++;
|
||||
GameLogger.Log("TEST", $"HotReloadTest: [OK] {name}");
|
||||
}
|
||||
else
|
||||
{
|
||||
failures.Add(name);
|
||||
GameLogger.Log("TEST", $"HotReloadTest: [FAIL] {name}", LogLevel.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void Finish()
|
||||
{
|
||||
if (done)
|
||||
return;
|
||||
done = true;
|
||||
foreach (var failure in failures)
|
||||
GameLogger.Log("TEST", $"HotReloadTest: failed: {failure}");
|
||||
GameLogger.Log("TEST", $"[TEST_RESULT] name=hot_reload passed={passed} failed={failures.Count}");
|
||||
GetTree().Quit(failures.Count == 0 ? 0 : 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.Linq;
|
||||
using Cthangover.Core.Mods;
|
||||
using Cthangover.Core.Utils;
|
||||
using Godot;
|
||||
|
||||
namespace Mods.HotReloadTest
|
||||
{
|
||||
/// <summary>Queues <see cref="HotReloadTestDriverNode"/> only for <c>--itest=hot_reload</c>.</summary>
|
||||
public class HotReloadTestInitializer : IModInitializer
|
||||
{
|
||||
private static bool queued;
|
||||
|
||||
public void OnModLoaded(string modId) { }
|
||||
|
||||
public void OnModResourcesReady()
|
||||
{
|
||||
if (queued || !OS.GetCmdlineUserArgs().Contains(HotReloadTestDriverNode.Flag))
|
||||
return;
|
||||
|
||||
queued = true;
|
||||
GameLogger.Log("TEST", "HotReloadTest: activating");
|
||||
(Godot.Engine.GetMainLoop() as SceneTree)?.Root?.CallDeferred("add_child", new HotReloadTestDriverNode());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "Menu Flow Test",
|
||||
"description": "Integration test mod for the front-end: boots only the menu collections, checks the Play/Continue/New game buttons, the game picker cards, the per-game save browser and the composition comparison. Activated only by --itest=menu_flow (with --frontend).",
|
||||
"sources": [
|
||||
"src/*.cs"
|
||||
],
|
||||
"depends": [
|
||||
"mod_manager>=1"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using Cthangover.BaseMenu;
|
||||
using Cthangover.Core.Bootstrap;
|
||||
using Cthangover.Core.Games;
|
||||
using Cthangover.Core.Mods;
|
||||
using Cthangover.Core.Settings;
|
||||
using Cthangover.Core.Utils;
|
||||
using Godot;
|
||||
|
||||
namespace Mods.MenuFlowTest
|
||||
{
|
||||
/// <summary>
|
||||
/// Front-end walk-through, one step per tick so deferred UI work settles:
|
||||
/// only the menu mods are loaded; the menu offers Play (no New game
|
||||
/// without a game); Play opens a card per game; the save browser lists a
|
||||
/// game's save; a player's own game can be deleted from the collection
|
||||
/// editor (a built-in one cannot) while its mods stay; the composition of
|
||||
/// a game is computed without loading it; Continue sees the last session. Prints <c>[TEST_RESULT] name=menu_flow</c>.
|
||||
/// </summary>
|
||||
public partial class MenuFlowTestDriverNode : Node
|
||||
{
|
||||
private const string GameId = "card_novel";
|
||||
private const string Slot = "slot_942";
|
||||
private const string DeletableId = "menu_flow_deletable";
|
||||
private const float TimeoutSeconds = 90f;
|
||||
|
||||
private readonly List<string> failures = new();
|
||||
private int passed;
|
||||
private int step;
|
||||
private double elapsed;
|
||||
private double stepAt;
|
||||
private bool done;
|
||||
private Node hub;
|
||||
|
||||
public override void _Process(double delta)
|
||||
{
|
||||
if (done)
|
||||
return;
|
||||
|
||||
elapsed += delta;
|
||||
if (elapsed > TimeoutSeconds)
|
||||
{
|
||||
failures.Add($"timeout at step {step}");
|
||||
Finish();
|
||||
return;
|
||||
}
|
||||
|
||||
if (elapsed - stepAt < 0.5)
|
||||
return;
|
||||
stepAt = elapsed;
|
||||
|
||||
try
|
||||
{
|
||||
RunStep();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failures.Add($"step {step}: {ex.GetType().Name}: {ex.Message}");
|
||||
Finish();
|
||||
}
|
||||
}
|
||||
|
||||
private Control Menu => GetTree().CurrentScene as Control;
|
||||
|
||||
private void RunStep()
|
||||
{
|
||||
switch (step)
|
||||
{
|
||||
case 0:
|
||||
if (!Boot.IsReady)
|
||||
return;
|
||||
// First launch shows the launcher screen; go on like a player.
|
||||
if (GetTree().CurrentScene?.FindChild("LauncherPlay", true, false) is Button launcherPlay)
|
||||
{
|
||||
launcherPlay.EmitSignal(BaseButton.SignalName.Pressed);
|
||||
return;
|
||||
}
|
||||
if (GetTree().CurrentScene is not MainMenu)
|
||||
return;
|
||||
Check("front-end only", ActiveGame.IsFrontendOnly && !ActiveGame.IsActive);
|
||||
Check("menu mods loaded", ModRegistry.Instance.Mods.ContainsKey("base_menu") && ModRegistry.Instance.Mods.ContainsKey("mod_manager"));
|
||||
Check("game content not loaded", !ModRegistry.Instance.Mods.ContainsKey("visual_novel_core"));
|
||||
Check("Play button shown", Menu.FindChild("play", true, false) is Button);
|
||||
Check("New game hidden without a game", Menu.FindChild("new_game", true, false) == null);
|
||||
break;
|
||||
|
||||
case 1:
|
||||
((Button)Menu.FindChild("play", true, false)).EmitSignal(BaseButton.SignalName.Pressed);
|
||||
break;
|
||||
|
||||
case 2:
|
||||
{
|
||||
var picker = Menu.GetNodeOrNull<GamePickerPanel>("GamePickerPanel");
|
||||
Check("Play opens the game picker", picker?.Visible == true);
|
||||
var games = GameCatalog.Games();
|
||||
var newGameText = TranslationServer.Translate("menu/play/new_game").ToString();
|
||||
var startButtons = Buttons(picker).Where(b => b.Text == newGameText).ToList();
|
||||
Check($"one card per game ({games.Count})", games.Count >= 2 && startButtons.Count == games.Count);
|
||||
var ready = games.Count(g => g.IsReady);
|
||||
Check("ready games can start", startButtons.Count(b => !b.Disabled) == ready && ready > 0);
|
||||
var configureText = TranslationServer.Translate("menu/play/configure").ToString();
|
||||
var configure = Buttons(picker).FirstOrDefault(b => b.Text == configureText);
|
||||
Check("cards offer Configure", configure != null);
|
||||
configure?.EmitSignal(BaseButton.SignalName.Pressed);
|
||||
break;
|
||||
}
|
||||
|
||||
case 3:
|
||||
{
|
||||
hub = Menu.FindChildren("*", "", true, false).FirstOrDefault(n => n.HasMethod("OpenCollectionEditor"));
|
||||
Check("Configure opens the mod manager", hub != null);
|
||||
var duplicateText = TranslationServer.Translate("mod_manager/collection_duplicate").ToString();
|
||||
Check("editor opened with Duplicate", hub != null && Buttons(hub).Any(b => b.Text == duplicateText && b.IsVisibleInTree()));
|
||||
Check("a built-in game offers no Delete", DeleteButton() == null);
|
||||
|
||||
var user = ModCollectionStore.LoadUser();
|
||||
user.Add(new ModCollection { Id = DeletableId, Name = "Deletable", Kind = ModCollectionKind.Game });
|
||||
ModCollectionStore.SaveUser(user);
|
||||
hub?.Call("OpenCollectionEditor", DeletableId);
|
||||
break;
|
||||
}
|
||||
|
||||
case 4:
|
||||
{
|
||||
var delete = DeleteButton();
|
||||
Check("a player's own game offers Delete", delete != null);
|
||||
delete?.EmitSignal(BaseButton.SignalName.Pressed);
|
||||
break;
|
||||
}
|
||||
|
||||
case 5:
|
||||
{
|
||||
var dialog = hub?.FindChildren("*", "ConfirmationDialog", true, false).OfType<ConfirmationDialog>().FirstOrDefault(d => d.Visible);
|
||||
var expected = string.Format(TranslationServer.Translate("mod_manager/collection_delete_confirm"), "Deletable", 0);
|
||||
Check("Delete warns that the mods stay", dialog?.DialogText == expected);
|
||||
dialog?.EmitSignal(AcceptDialog.SignalName.Confirmed);
|
||||
Check("the collection is deleted", ModCollectionStore.LoadAll().All(c => c.Id != DeletableId));
|
||||
Check("its mods stay in the system", ModRegistry.Instance.AllDiscoveredMods.ContainsKey("card_battle"));
|
||||
hub?.QueueFree();
|
||||
break;
|
||||
}
|
||||
|
||||
case 6:
|
||||
WriteFakeSave();
|
||||
Menu.GetNode<SaveLoadMenu>("SaveLoadPanel").OpenForGame(GameId);
|
||||
break;
|
||||
|
||||
case 7:
|
||||
{
|
||||
var browser = Menu.GetNode<SaveLoadMenu>("SaveLoadPanel");
|
||||
var slots = browser.FindChildren("*", "", true, false).OfType<SaveSlotItem>().ToList();
|
||||
Check("save browser lists the game's save", slots.Any(s => s.Model?.FileName == Slot));
|
||||
Check("save list carries the game id", SaveService.ListSaves(GameId).Any(s => s.FileName == Slot && s.GameId == GameId));
|
||||
browser.Visible = false;
|
||||
|
||||
var composition = GameCatalog.Composition(GameId);
|
||||
Check("composition computed without loading the game", composition?.Any(m => m.Id == "card_battle") == true);
|
||||
var diff = SaveCompositionDiff.Compute(
|
||||
new[] { new SaveGameMod { Id = "visual_novel_core", Version = "2.0.0" }, new SaveGameMod { Id = "ghost_mechanic", Version = "1.0.0" } },
|
||||
composition);
|
||||
Check("comparison reports removed and added mechanics", diff.Removed.Contains("ghost_mechanic") && diff.Added.Contains("card_battle"));
|
||||
|
||||
LastSessionStore.Record(GameId, Slot);
|
||||
Check("Continue sees the last session", LastSessionStore.Load()?.GameId == GameId);
|
||||
Cleanup();
|
||||
Finish();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
step++;
|
||||
}
|
||||
|
||||
private Button DeleteButton()
|
||||
{
|
||||
var text = TranslationServer.Translate("mod_manager/collection_delete").ToString();
|
||||
return Buttons(hub).FirstOrDefault(b => b.Text == text && b.IsVisibleInTree());
|
||||
}
|
||||
|
||||
private static IEnumerable<Button> Buttons(Node root)
|
||||
=> root == null ? Enumerable.Empty<Button>() : root.FindChildren("*", "Button", true, false).OfType<Button>();
|
||||
|
||||
private static void WriteFakeSave()
|
||||
{
|
||||
var package = new SavePackage
|
||||
{
|
||||
SaveId = Slot,
|
||||
Timestamp = DateTime.UtcNow,
|
||||
CoreVersion = "0.1.0",
|
||||
Game = new SaveGameInfo { Id = GameId, Name = "Card Novel", Version = "1.0.0" },
|
||||
};
|
||||
package.ModData[SavePackage.CoreSlotKey] = new ModSlot();
|
||||
var dir = ProjectSettings.GlobalizePath(SaveService.GameDirectory(GameId));
|
||||
Directory.CreateDirectory(dir);
|
||||
File.WriteAllText(Path.Combine(dir, Slot + ".json"), JsonSerializer.Serialize(package, JsonOptionsProvider.WriteCompactOptions));
|
||||
}
|
||||
|
||||
private static void Cleanup()
|
||||
{
|
||||
var save = Path.Combine(ProjectSettings.GlobalizePath(SaveService.GameDirectory(GameId)), Slot + ".json");
|
||||
if (File.Exists(save))
|
||||
File.Delete(save);
|
||||
ModCollectionStore.RemoveUser(DeletableId);
|
||||
var last = ProjectSettings.GlobalizePath("user://last_session.json");
|
||||
if (File.Exists(last))
|
||||
File.Delete(last);
|
||||
}
|
||||
|
||||
private void Check(string name, bool ok)
|
||||
{
|
||||
if (ok)
|
||||
{
|
||||
passed++;
|
||||
GameLogger.Log("TEST", $"MenuFlowTest: [OK] {name}");
|
||||
}
|
||||
else
|
||||
{
|
||||
failures.Add(name);
|
||||
GameLogger.Log("TEST", $"MenuFlowTest: [FAIL] {name}", LogLevel.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void Finish()
|
||||
{
|
||||
if (done)
|
||||
return;
|
||||
done = true;
|
||||
foreach (var failure in failures)
|
||||
GameLogger.Log("TEST", $"MenuFlowTest: failed: {failure}");
|
||||
GameLogger.Log("TEST", $"[TEST_RESULT] name=menu_flow passed={passed} failed={failures.Count}");
|
||||
GetTree().Quit(failures.Count == 0 ? 0 : 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.Linq;
|
||||
using Cthangover.Core.Mods;
|
||||
using Cthangover.Core.Utils;
|
||||
using Godot;
|
||||
|
||||
namespace Mods.MenuFlowTest
|
||||
{
|
||||
/// <summary>Queues <see cref="MenuFlowTestDriverNode"/> only for <c>--itest=menu_flow</c>.</summary>
|
||||
public class MenuFlowTestInitializer : IModInitializer
|
||||
{
|
||||
private static bool queued;
|
||||
|
||||
public void OnModLoaded(string modId) { }
|
||||
|
||||
public void OnModResourcesReady()
|
||||
{
|
||||
if (queued || !OS.GetCmdlineUserArgs().Contains("--itest=menu_flow"))
|
||||
return;
|
||||
|
||||
queued = true;
|
||||
GameLogger.Log("TEST", "MenuFlowTest: activating");
|
||||
(Godot.Engine.GetMainLoop() as SceneTree)?.Root?.CallDeferred("add_child", new MenuFlowTestDriverNode());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "New Game Flow Test",
|
||||
"description": "End-to-end play like a player in one process: front-end menu → Play → the cthangover card → New game → in-process mod-set switch → the prologue in prolog_alley with Marao's dialog → in-game hot reload of starter_party (session restored) → menu → card_novel → menu → cthangover again; every released mod context must be collected. Activated only by --itest=new_game (with --frontend).",
|
||||
"sources": [
|
||||
"src/*.cs"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Cthangover.Core.Bootstrap;
|
||||
using Cthangover.Core.Events;
|
||||
using Cthangover.Core.Games;
|
||||
using Cthangover.Core.Mods;
|
||||
using Cthangover.Core.Mods.HotReload;
|
||||
using Cthangover.Core.Scenes;
|
||||
using Cthangover.Core.UI.Dialog;
|
||||
using Cthangover.Core.Utils;
|
||||
using Godot;
|
||||
|
||||
namespace Mods.NewGameTest
|
||||
{
|
||||
/// <summary>
|
||||
/// Plays the games the way a player does, in ONE process: no restart
|
||||
/// happens anywhere, every game switch replaces the mod set in place.
|
||||
/// <list type="number">
|
||||
/// <item>Front-end menu → Play → the <c>cthangover</c> card → New game: the
|
||||
/// process switches to the game, enters <c>prolog_alley</c> and the opening
|
||||
/// dialog about the hungover Marao is clicked through.</item>
|
||||
/// <item>Hot reload inside the game (forced reload of <c>starter_party</c>):
|
||||
/// the session is parked, the mod reloads into a new context and the game
|
||||
/// comes back in <c>prolog_alley</c>.</item>
|
||||
/// <item>Back to the menu → <c>card_novel</c>: mods outside that game are
|
||||
/// unloaded and a scene of the game is entered.</item>
|
||||
/// <item>Back to the menu → <c>cthangover</c> again → <c>prolog_alley</c>.</item>
|
||||
/// </list>
|
||||
/// After every switch the released mod contexts must be collected
|
||||
/// (<c>ModManager.LastReload.Leaked</c> empty). Scene entries are taken from
|
||||
/// <see cref="SceneEnteredEvent"/>, not by polling the current scene name: a
|
||||
/// game's entry scene may move on to the next scene within a frame.
|
||||
/// Prints <c>[TEST_RESULT] name=new_game</c>.
|
||||
/// </summary>
|
||||
public partial class NewGameTestDriverNode : Node
|
||||
{
|
||||
public const string Flag = "--itest=new_game";
|
||||
|
||||
private const string MainGame = "cthangover";
|
||||
private const string SecondGame = "card_novel";
|
||||
private const string PrologScene = "prolog_alley";
|
||||
private const string HotReloadedMod = "starter_party";
|
||||
private const float TimeoutSeconds = 420f;
|
||||
|
||||
private readonly List<string> failures = new();
|
||||
private readonly List<string> enteredScenes = new();
|
||||
private int passed;
|
||||
private int step;
|
||||
private double elapsed;
|
||||
private double stepAt;
|
||||
private bool done;
|
||||
private bool sawMarao;
|
||||
private int clicks;
|
||||
private bool hotReloadDone;
|
||||
|
||||
public override void _Ready()
|
||||
{
|
||||
// The driver must keep ticking across every mod-set switch of the run.
|
||||
ProcessMode = ProcessModeEnum.Always;
|
||||
ModEventBus.Subscribe<SceneEnteredEvent>(OnSceneEntered);
|
||||
}
|
||||
|
||||
public override void _ExitTree()
|
||||
{
|
||||
ModEventBus.Unsubscribe<SceneEnteredEvent>(OnSceneEntered);
|
||||
}
|
||||
|
||||
public override void _Process(double delta)
|
||||
{
|
||||
if (done)
|
||||
return;
|
||||
|
||||
elapsed += delta;
|
||||
if (elapsed > TimeoutSeconds)
|
||||
{
|
||||
failures.Add($"timeout at step {step} (scene='{SceneManager.GetCurrentSceneName()}', game='{ActiveGame.Id}')");
|
||||
Finish();
|
||||
return;
|
||||
}
|
||||
|
||||
if (elapsed - stepAt < 0.25)
|
||||
return;
|
||||
stepAt = elapsed;
|
||||
|
||||
try
|
||||
{
|
||||
RunStep();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failures.Add($"step {step}: {ex.GetType().Name}: {ex.Message}");
|
||||
Finish();
|
||||
}
|
||||
}
|
||||
|
||||
private Control Menu => GetTree().CurrentScene as Control;
|
||||
|
||||
private static bool Busy => GameSession.IsSwitching || ModManager.Instance.IsReloading || ModHotReloadService.IsReloadRunning;
|
||||
|
||||
private void OnSceneEntered(SceneEnteredEvent e) => enteredScenes.Add(e.SceneName);
|
||||
|
||||
private void RunStep()
|
||||
{
|
||||
switch (step)
|
||||
{
|
||||
case 0:
|
||||
if (!Boot.IsReady || !PressMenuPlay())
|
||||
return;
|
||||
Check("front-end menu offers Play", ActiveGame.IsFrontendOnly);
|
||||
break;
|
||||
|
||||
case 1:
|
||||
if (!StartFromPicker(MainGame))
|
||||
return;
|
||||
break;
|
||||
|
||||
case 2:
|
||||
if (Busy || ActiveGame.Id != MainGame || !enteredScenes.Contains(PrologScene))
|
||||
return;
|
||||
CheckSwitch(MainGame, expectUnload: false);
|
||||
Check($"entered '{PrologScene}'", true);
|
||||
break;
|
||||
|
||||
case 3:
|
||||
{
|
||||
var box = DialogBox();
|
||||
if (box == null || !box.IsRunning)
|
||||
return;
|
||||
Check("prologue dialog runs", true);
|
||||
break;
|
||||
}
|
||||
|
||||
case 4:
|
||||
{
|
||||
// Click through the opening like a player, watching the text for Marao.
|
||||
var box = DialogBox();
|
||||
sawMarao |= DialogMentionsMarao();
|
||||
if (box != null && !box.IsDialogEnded)
|
||||
{
|
||||
if (box.IsWaitingAnswer)
|
||||
box.TrySelectVariant(0);
|
||||
else
|
||||
box.NextAction();
|
||||
clicks++;
|
||||
return;
|
||||
}
|
||||
Check("prologue text is about Marao", sawMarao);
|
||||
Check("opening dialog has several lines", clicks >= 5);
|
||||
Check($"still in '{PrologScene}' after the opening", SceneManager.GetCurrentSceneName() == PrologScene);
|
||||
enteredScenes.Clear();
|
||||
StartHotReload();
|
||||
break;
|
||||
}
|
||||
|
||||
case 5:
|
||||
{
|
||||
if (!hotReloadDone || Busy || !enteredScenes.Contains(PrologScene))
|
||||
return;
|
||||
var reload = ModManager.Instance.LastReload;
|
||||
Check("in-game hot reload applied", reload?.Applied == true);
|
||||
Check($"hot reload reloaded '{HotReloadedMod}'", reload?.Plan?.Load.Contains(HotReloadedMod) == true);
|
||||
Check("hot reload kept the front-end loaded", reload?.Plan?.Keep.Contains("base_menu") == true);
|
||||
Check("hot reload released every old context", reload?.Leaked.Count == 0);
|
||||
Check($"hot reload kept the game '{MainGame}'", ActiveGame.Id == MainGame);
|
||||
Check($"session came back in '{PrologScene}'", true);
|
||||
GetTree().Root.GetNode<GodotSceneService>("GodotSceneService").SwitchToMenu();
|
||||
break;
|
||||
}
|
||||
|
||||
case 6:
|
||||
if (Busy || !PressMenuPlay())
|
||||
return;
|
||||
enteredScenes.Clear();
|
||||
break;
|
||||
|
||||
case 7:
|
||||
if (!StartFromPicker(SecondGame))
|
||||
return;
|
||||
break;
|
||||
|
||||
case 8:
|
||||
if (Busy || ActiveGame.Id != SecondGame || enteredScenes.Count == 0)
|
||||
return;
|
||||
CheckSwitch(SecondGame, expectUnload: true);
|
||||
Check($"entered a scene of '{SecondGame}' ('{enteredScenes[0]}')", true);
|
||||
GetTree().Root.GetNode<GodotSceneService>("GodotSceneService").SwitchToMenu();
|
||||
break;
|
||||
|
||||
case 9:
|
||||
if (Busy || !PressMenuPlay())
|
||||
return;
|
||||
enteredScenes.Clear();
|
||||
break;
|
||||
|
||||
case 10:
|
||||
if (!StartFromPicker(MainGame))
|
||||
return;
|
||||
break;
|
||||
|
||||
case 11:
|
||||
if (Busy || ActiveGame.Id != MainGame || !enteredScenes.Contains(PrologScene))
|
||||
return;
|
||||
CheckSwitch(MainGame, expectUnload: false);
|
||||
Check($"back in '{PrologScene}' after switching games twice", true);
|
||||
Finish();
|
||||
return;
|
||||
}
|
||||
|
||||
step++;
|
||||
}
|
||||
|
||||
private async void StartHotReload()
|
||||
{
|
||||
await ModHotReloadService.ReloadNowAsync(new[] { HotReloadedMod });
|
||||
hotReloadDone = true;
|
||||
}
|
||||
|
||||
/// <summary>The launcher's or the main menu's Play button, pressed; false while neither is on screen.</summary>
|
||||
private bool PressMenuPlay()
|
||||
{
|
||||
if (GetTree().CurrentScene?.FindChild("LauncherPlay", true, false) is Button launcherPlay)
|
||||
{
|
||||
launcherPlay.EmitSignal(BaseButton.SignalName.Pressed);
|
||||
return false;
|
||||
}
|
||||
if (Menu?.FindChild("play", true, false) is not Button play)
|
||||
return false;
|
||||
play.EmitSignal(BaseButton.SignalName.Pressed);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Presses New game on the game's card in the picker; false until the picker is open.</summary>
|
||||
private bool StartFromPicker(string gameId)
|
||||
{
|
||||
var picker = Menu?.FindChild("GamePickerPanel", true, false) as Control;
|
||||
if (picker?.Visible != true)
|
||||
return false;
|
||||
var game = FindGame(gameId);
|
||||
Check($"game '{gameId}' is listed and ready", game?.IsReady == true);
|
||||
var start = StartButton(picker, game?.Name ?? gameId);
|
||||
Check($"'{gameId}' card has an enabled New game button", start is { Disabled: false });
|
||||
if (start == null || start.Disabled || failures.Count > 0)
|
||||
{
|
||||
Finish();
|
||||
return false;
|
||||
}
|
||||
|
||||
start.EmitSignal(BaseButton.SignalName.Pressed);
|
||||
Check($"New game of '{gameId}' switches in-process, without a restart", !OS.IsRestartOnExitSet());
|
||||
return true;
|
||||
}
|
||||
|
||||
private void CheckSwitch(string gameId, bool expectUnload)
|
||||
{
|
||||
var reload = ModManager.Instance.LastReload;
|
||||
Check($"switch into '{gameId}' applied", reload?.Applied == true);
|
||||
Check($"switch into '{gameId}' kept the front-end loaded", reload?.Plan?.Keep.Contains("base_menu") == true);
|
||||
if (expectUnload)
|
||||
Check($"switch into '{gameId}' unloaded mods outside the game", reload?.Plan?.Unload.Count > 0);
|
||||
Check($"switch into '{gameId}' released every old context", reload?.Leaked.Count == 0);
|
||||
if (reload?.Leaked.Count > 0)
|
||||
GameLogger.Log("TEST", $"NewGameTest: leaked contexts: {string.Join(", ", reload.Leaked)}", LogLevel.Error);
|
||||
}
|
||||
|
||||
private static ModCollectionGameSummary FindGame(string gameId)
|
||||
{
|
||||
foreach (var game in GameCatalog.Games())
|
||||
{
|
||||
if (game.Collection.Id == gameId)
|
||||
return game;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>The New game button on the card titled <paramref name="title"/>.</summary>
|
||||
private static Button StartButton(Node picker, string title)
|
||||
{
|
||||
if (picker == null)
|
||||
return null;
|
||||
var newGameText = TranslationServer.Translate("menu/play/new_game").ToString();
|
||||
// A card is the innermost panel holding exactly one New game button (the picker frame holds them all).
|
||||
foreach (var card in picker.FindChildren("*", "PanelContainer", true, false))
|
||||
{
|
||||
Button start = null;
|
||||
var starts = 0;
|
||||
foreach (var child in card.FindChildren("*", "Button", true, false))
|
||||
{
|
||||
if (child is Button button && button.Text == newGameText)
|
||||
{
|
||||
start = button;
|
||||
starts++;
|
||||
}
|
||||
}
|
||||
if (starts == 1 && HasLabel(card, title))
|
||||
return start;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool HasLabel(Node card, string title)
|
||||
{
|
||||
foreach (var child in card.FindChildren("*", "Label", true, false))
|
||||
{
|
||||
if (child is Label label && label.Text == title)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private IDialogBox DialogBox() => GetTree().Root.FindChild("DialogBox", true, false) as IDialogBox;
|
||||
|
||||
private bool DialogMentionsMarao()
|
||||
{
|
||||
if (DialogBox() is not Node node)
|
||||
return false;
|
||||
foreach (var child in node.FindChildren("*", "", true, false))
|
||||
{
|
||||
var text = child switch
|
||||
{
|
||||
RichTextLabel r => r.Text,
|
||||
Label l => l.Text,
|
||||
_ => null,
|
||||
};
|
||||
if (text != null && (text.Contains("Марао") || text.Contains("Marao")))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void Check(string name, bool ok)
|
||||
{
|
||||
if (ok)
|
||||
{
|
||||
passed++;
|
||||
GameLogger.Log("TEST", $"NewGameTest: [OK] {name}");
|
||||
}
|
||||
else
|
||||
{
|
||||
failures.Add(name);
|
||||
GameLogger.Log("TEST", $"NewGameTest: [FAIL] {name}", LogLevel.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void Finish()
|
||||
{
|
||||
if (done)
|
||||
return;
|
||||
done = true;
|
||||
foreach (var failure in failures)
|
||||
GameLogger.Log("TEST", $"NewGameTest: failed: {failure}");
|
||||
GameLogger.Log("TEST", $"[TEST_RESULT] name=new_game passed={passed} failed={failures.Count}");
|
||||
GetTree().Quit(failures.Count == 0 ? 0 : 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.Linq;
|
||||
using Cthangover.Core.Mods;
|
||||
using Cthangover.Core.Utils;
|
||||
using Godot;
|
||||
|
||||
namespace Mods.NewGameTest
|
||||
{
|
||||
/// <summary>Queues <see cref="NewGameTestDriverNode"/> only for <c>--itest=new_game</c>.</summary>
|
||||
public class NewGameTestInitializer : IModInitializer
|
||||
{
|
||||
private static bool queued;
|
||||
|
||||
public void OnModLoaded(string modId) { }
|
||||
|
||||
public void OnModResourcesReady()
|
||||
{
|
||||
if (queued || !OS.GetCmdlineUserArgs().Contains(NewGameTestDriverNode.Flag))
|
||||
return;
|
||||
|
||||
queued = true;
|
||||
GameLogger.Log("TEST", "NewGameTest: activating");
|
||||
(Godot.Engine.GetMainLoop() as SceneTree)?.Root?.CallDeferred("add_child", new NewGameTestDriverNode());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "Save Dormancy Test",
|
||||
"description": "Integration test mod: inside a game collection it saves, injects data of a mod the game runs without, loads, re-saves and purges — verifying per-game save folders, dormant slots carried forward, the joined-game hook and the purge action. Activated only by --itest=save_dormancy.",
|
||||
"sources": [
|
||||
"src/*.cs"
|
||||
],
|
||||
"depends": []
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using Cthangover.Core.Mods;
|
||||
using Cthangover.Core.Settings;
|
||||
|
||||
namespace Mods.SaveDormancyTest
|
||||
{
|
||||
/// <summary>
|
||||
/// Saveable of the test mod: records whether a load restored its slot or
|
||||
/// told it that it joined an existing game without data.
|
||||
/// </summary>
|
||||
public class SaveDormancyProbe : IModSaveable
|
||||
{
|
||||
public static bool Joined { get; set; }
|
||||
|
||||
public static bool Restored { get; set; }
|
||||
|
||||
public void OnSave(ModSlot slot)
|
||||
{
|
||||
slot.Data["probe"] = new DataBlob();
|
||||
}
|
||||
|
||||
public bool OnLoad(ModSlot slot, out string errorMessage)
|
||||
{
|
||||
Restored = true;
|
||||
errorMessage = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
public void OnJoinedExistingGame()
|
||||
{
|
||||
Joined = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using Cthangover.Core.Bootstrap;
|
||||
using Cthangover.Core.Games;
|
||||
using Cthangover.Core.Mods;
|
||||
using Cthangover.Core.Settings;
|
||||
using Cthangover.Core.Utils;
|
||||
using Godot;
|
||||
|
||||
namespace Mods.SaveDormancyTest
|
||||
{
|
||||
/// <summary>
|
||||
/// Runs the composition-change round trip once the game is running and
|
||||
/// prints <c>[TEST_RESULT] name=save_dormancy</c>: a save of the active
|
||||
/// game lands in its own folder; data of a mod the game runs without
|
||||
/// survives a load and the next save as a dormant slot; a mod without data
|
||||
/// in the save gets <c>OnJoinedExistingGame</c>; the purge drops dormant data.
|
||||
/// </summary>
|
||||
public partial class SaveDormancyTestDriverNode : Node
|
||||
{
|
||||
private const string SlotA = "itest_dormancy_a";
|
||||
private const string SlotB = "itest_dormancy_b";
|
||||
private const string AbsentMod = "recruiting";
|
||||
private const float TimeoutSeconds = 90f;
|
||||
|
||||
private readonly List<string> failures = new();
|
||||
private int passed;
|
||||
private double elapsed;
|
||||
private bool done;
|
||||
|
||||
public override void _Process(double delta)
|
||||
{
|
||||
if (done)
|
||||
return;
|
||||
|
||||
elapsed += delta;
|
||||
if (elapsed > TimeoutSeconds)
|
||||
{
|
||||
failures.Add("timeout waiting for the game");
|
||||
Finish();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Boot.IsReady || GameData.Instance?.Runtime == null || GetTree().CurrentScene == null || elapsed < 3)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
Run();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failures.Add($"unhandled {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
|
||||
Finish();
|
||||
}
|
||||
|
||||
private void Run()
|
||||
{
|
||||
Check("a game collection is active", ActiveGame.IsActive);
|
||||
Check($"'{AbsentMod}' is not part of the game", !ActiveGame.ModIds.Contains(AbsentMod));
|
||||
|
||||
SaveService.Save(SlotA);
|
||||
var pathA = FilePath(SlotA);
|
||||
Check("save lands in the game's folder", pathA.Contains($"saves{Path.DirectorySeparatorChar}{ActiveGame.Id}", StringComparison.Ordinal) || pathA.Contains($"saves/{ActiveGame.Id}", StringComparison.Ordinal));
|
||||
var package = Read(pathA);
|
||||
Check("save records the game id", package?.Game?.Id == ActiveGame.Id);
|
||||
Check("save records the composition", package?.Game?.Mods?.Count > 0);
|
||||
|
||||
// Simulate a save written while the game still had the mechanic, and
|
||||
// before this test mod joined the game.
|
||||
var absentSlot = new ModSlot { Metadata = new ModMetadata { ModId = AbsentMod, ModVersion = "1.1.0" } };
|
||||
absentSlot.Data["marker"] = new DataBlob(1, new Dictionary<string, string> { ["value"] = "42" });
|
||||
package.ModData[AbsentMod] = absentSlot;
|
||||
package.ModData.Remove("base_menu__save_dormancy");
|
||||
File.WriteAllText(pathA, JsonSerializer.Serialize(package, JsonOptionsProvider.WriteCompactOptions));
|
||||
|
||||
SaveDormancyProbe.Joined = false;
|
||||
SaveDormancyProbe.Restored = false;
|
||||
Check("load succeeds despite a missing mod", SaveService.Load(SlotA));
|
||||
Check("missing mod's data went dormant", SaveService.LastLoadDormancy?.Dormant.ContainsKey(AbsentMod) == true);
|
||||
Check("mod without data joined the game", SaveDormancyProbe.Joined && !SaveDormancyProbe.Restored);
|
||||
|
||||
SaveService.Save(SlotB);
|
||||
var resaved = Read(FilePath(SlotB));
|
||||
var carried = resaved?.Dormant != null && resaved.Dormant.TryGetValue(AbsentMod, out var slot) ? slot : null;
|
||||
Check("dormant slot carried into the next save", carried?.Metadata?.Dormant == true);
|
||||
Check("dormant data unchanged", carried?.Data != null && carried.Data.TryGetValue("marker", out var marker)
|
||||
&& marker.ReadJson<Dictionary<string, string>>().GetValueOrDefault("value") == "42");
|
||||
Check("live mods still saved", resaved?.ModData?.ContainsKey(SavePackage.CoreSlotKey) == true);
|
||||
|
||||
var message = ModSettingsRegistry.RunAction("base_menu", "purge_dormant");
|
||||
Check("purge action runs from the save settings", !string.IsNullOrEmpty(message));
|
||||
Check("purge removed dormant data from the save", Read(FilePath(SlotB))?.Dormant == null);
|
||||
|
||||
SaveService.DeleteSave(SlotA);
|
||||
SaveService.DeleteSave(SlotB);
|
||||
}
|
||||
|
||||
private static string FilePath(string slot)
|
||||
=> ProjectSettings.GlobalizePath($"{SaveService.SaveDirectory}/{slot}.json");
|
||||
|
||||
private static SavePackage Read(string path)
|
||||
=> File.Exists(path) ? JsonSerializer.Deserialize<SavePackage>(File.ReadAllText(path), JsonOptionsProvider.ReadOptions) : null;
|
||||
|
||||
private void Check(string name, bool ok)
|
||||
{
|
||||
if (ok)
|
||||
{
|
||||
passed++;
|
||||
GameLogger.Log("TEST", $"SaveDormancyTest: [OK] {name}");
|
||||
}
|
||||
else
|
||||
{
|
||||
failures.Add(name);
|
||||
GameLogger.Log("TEST", $"SaveDormancyTest: [FAIL] {name}", LogLevel.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void Finish()
|
||||
{
|
||||
if (done)
|
||||
return;
|
||||
done = true;
|
||||
foreach (var failure in failures)
|
||||
GameLogger.Log("TEST", $"SaveDormancyTest: failed: {failure}");
|
||||
GameLogger.Log("TEST", $"[TEST_RESULT] name=save_dormancy passed={passed} failed={failures.Count}");
|
||||
GetTree().Quit(failures.Count == 0 ? 0 : 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.Linq;
|
||||
using Cthangover.Core.Mods;
|
||||
using Cthangover.Core.Utils;
|
||||
using Godot;
|
||||
|
||||
namespace Mods.SaveDormancyTest
|
||||
{
|
||||
/// <summary>Queues <see cref="SaveDormancyTestDriverNode"/> only for <c>--itest=save_dormancy</c>.</summary>
|
||||
public class SaveDormancyTestInitializer : IModInitializer
|
||||
{
|
||||
private static bool queued;
|
||||
|
||||
public void OnModLoaded(string modId) { }
|
||||
|
||||
public void OnModResourcesReady()
|
||||
{
|
||||
if (queued || !OS.GetCmdlineUserArgs().Contains("--itest=save_dormancy"))
|
||||
return;
|
||||
|
||||
queued = true;
|
||||
GameLogger.Log("TEST", "SaveDormancyTest: activating");
|
||||
(Godot.Engine.GetMainLoop() as SceneTree)?.Root?.CallDeferred("add_child", new SaveDormancyTestDriverNode());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user