This commit is contained in:
iisaev committed 2026-10-18 12:41:41 +03:00
commit ee6fdc3576
32 files changed
+4117

No files matched your search

+33
View File
@@ -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"]
}
]
}
+72
View File
@@ -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
+72
View File
@@ -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 = Сбросить настройки
+18
View File
@@ -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.
+11
View File
@@ -0,0 +1,11 @@
{
"Scene": "MainMenu",
"Musics": [
{
"MusicType": "Ambient",
"MusicNames": [
"Serpent's Lament"
]
}
]
}
+120
View File
@@ -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"
+84
View File
@@ -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
+89
View File
@@ -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
+370
View File
@@ -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"
+149
View File
@@ -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
View File
@@ -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();
}
}
}
+239
View File
@@ -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
View File
@@ -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()}");
}
}
}
+201
View File
@@ -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();
}
}
}
+47
View File
@@ -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);
}
}
}
+114
View File
@@ -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();
}
}
}
+149
View File
@@ -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);
}
}
}
+803
View File
@@ -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);
}
}
}
+7
View File
@@ -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());
}
}
}
+10
View File
@@ -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());
}
}
}
+7
View File
@@ -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());
}
}
}
+8
View File
@@ -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());
}
}
}