commit ee6fdc3576d8c0a67347615dd589efbd68ab0f28 Author: iisaev Date: Sun Oct 18 12:41:41 2026 +0300 init mod diff --git a/collections/frontend.json b/collections/frontend.json new file mode 100644 index 0000000..4ba34a5 --- /dev/null +++ b/collections/frontend.json @@ -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"] + } + ] +} diff --git a/locale/en.properties b/locale/en.properties new file mode 100644 index 0000000..ac34bc2 --- /dev/null +++ b/locale/en.properties @@ -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 diff --git a/locale/ru.properties b/locale/ru.properties new file mode 100644 index 0000000..f083d80 --- /dev/null +++ b/locale/ru.properties @@ -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 = Сбросить настройки diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..2f8f89c --- /dev/null +++ b/manifest.json @@ -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"]} + ] +} diff --git a/music/Serpent's Lament.ogg b/music/Serpent's Lament.ogg new file mode 100644 index 0000000..5dafad5 Binary files /dev/null and b/music/Serpent's Lament.ogg differ diff --git a/music/playlists/mainmenu.json b/music/playlists/mainmenu.json new file mode 100644 index 0000000..7b6b3ba --- /dev/null +++ b/music/playlists/mainmenu.json @@ -0,0 +1,11 @@ +{ + "Scene": "MainMenu", + "Musics": [ + { + "MusicType": "Ambient", + "MusicNames": [ + "Serpent's Lament" + ] + } + ] +} diff --git a/scenes/menu/main_menu.tscn b/scenes/menu/main_menu.tscn new file mode 100644 index 0000000..a3c1390 --- /dev/null +++ b/scenes/menu/main_menu.tscn @@ -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" diff --git a/scenes/menu/save_load_menu.tscn b/scenes/menu/save_load_menu.tscn new file mode 100644 index 0000000..dcb2601 --- /dev/null +++ b/scenes/menu/save_load_menu.tscn @@ -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 diff --git a/scenes/menu/save_slot_item.tscn b/scenes/menu/save_slot_item.tscn new file mode 100644 index 0000000..9fd4abf --- /dev/null +++ b/scenes/menu/save_slot_item.tscn @@ -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 diff --git a/scenes/menu/settings_menu.tscn b/scenes/menu/settings_menu.tscn new file mode 100644 index 0000000..9a63f3d --- /dev/null +++ b/scenes/menu/settings_menu.tscn @@ -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" diff --git a/src/BaseMenuInitializer.cs b/src/BaseMenuInitializer.cs new file mode 100644 index 0000000..73e1e7e --- /dev/null +++ b/src/BaseMenuInitializer.cs @@ -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 +{ + /// + /// Registers the built-in main-menu buttons and the settings/save-load panels + /// in . Runs from + /// ModInitializerRegistry.NotifyResourcesReady, which fires after the + /// registry itself is built, so these entries are appended to live registry + /// state rather than collected from a manifest. + /// + 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, + }); + } + } + + /// + /// Handlers for the built-in menu buttons. Each receives the menu root control + /// so it can locate the panels instantiated as named children by + /// . + /// + internal static class BaseMenuActions + { + /// + /// Starts a new game at the active game's entry scene (start_scene + /// when no game collection is active) through . + /// + public static void NewGame(Control host) + { + GameSession.StartNew(host); + } + + /// Loads the last save of the last played game (restarting into it when needed). + public static void Continue(Control host) => GameFlow.Continue(host); + + /// Opens the game picker. + public static void Play(Control host) + { + host.GetNodeOrNull("GamePickerPanel")?.Open(); + } + + /// Opens the save/load panel in load mode. + public static void Load(Control host) + { + host.GetNodeOrNull("SaveLoadPanel")?.OpenForLoad(); + } + + /// Shows the settings overlay. + public static void Settings(Control host) + { + host.GetNodeOrNull("SettingsPanel")?.Open(); + } + + /// Quits the application immediately via SceneTree.Quit. + public static void Exit(Control host) + { + host.GetTree().Quit(); + } + } +} diff --git a/src/GameFlow.cs b/src/GameFlow.cs new file mode 100644 index 0000000..2c3719d --- /dev/null +++ b/src/GameFlow.cs @@ -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 +{ + /// + /// 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 + /// (). 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. + /// + internal static class GameFlow + { + /// Set after installing mods in this process: entering any game, even the running one, reloads the mod set to pick them up. + 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); + } + + /// + /// Loads of (null = + /// a save made without a game), comparing compositions first. + /// + 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); + + /// Lists what a composition change does to the save, in player terms. + public static string DescribeDiff(SaveCompositionDiff diff) + { + var text = new StringBuilder(TranslationServer.Translate("menu/load/diff_intro")); + void Section(string key, IEnumerable 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; + + /// A save made before games existed: the player picks the game it belongs to; it moves there. + 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(); + } + + /// Message or confirmation dialog; without it is a plain notice. + 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(); + } + } +} diff --git a/src/GamePickerPanel.cs b/src/GamePickerPanel.cs new file mode 100644 index 0000000..3b765b2 --- /dev/null +++ b/src/GamePickerPanel.cs @@ -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 +{ + /// + /// "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 against all installed mods, so + /// it works from the front-end where only the menu is loaded. + /// + public partial class GamePickerPanel : Control + { + private VBoxContainer _list; + private readonly HashSet _sessionInstalled = new(StringComparer.OrdinalIgnoreCase); + + /// Raised when the player opens a game's saves; the menu shows its save browser. + public event Action 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"; + + /// Installs/enables the missing mods; entering the game afterwards reloads the mod set to pick them up. + 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(); + 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(); + } + + /// Opens the mod manager on the game's scheme (loose coupling through the menu registry). + 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(); + } + } +} diff --git a/src/MainMenu.cs b/src/MainMenu.cs new file mode 100644 index 0000000..93c497f --- /dev/null +++ b/src/MainMenu.cs @@ -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 +{ + /// + /// Title screen main menu, fully driven by . 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 + /// , 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 as panels "settings" + /// and "save_load", so other mods can open them by id. + /// + public partial class MainMenu : Control + { + private SettingsMenu _settingsMenu; + private SaveLoadMenu _saveLoadMenu; + private GamePickerPanel _gamePicker; + private readonly Dictionary _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; + } + } + + + + /// + /// Assembles the runtime UI: theme, hidden settings and save/load panels, + /// telegram link wiring, scene manager initialization and registry/tool + /// driven buttons. + /// + private void BuildUi() + { + CthangoverTheme.Apply(this); + + _settingsMenu = new ModScenePrefab("scenes/menu/settings_menu.tscn").Instantiate(); + _settingsMenu.Name = "SettingsPanel"; + _settingsMenu.Visible = false; + _settingsMenu.LanguageChanged += RefreshButtons; + AddChild(_settingsMenu); + + _saveLoadMenu = new ModScenePrefab("scenes/menu/save_load_menu.tscn").Instantiate(); + _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("TelegramLink"); + telegramLink.MetaClicked += OnTelegramLinkClicked; + + var sceneManager = GetNode("/root/SceneManager"); + sceneManager.Initialize(); + + BuildRegistryButtons(); + BuildToolButtons(); + CheckModCompilationErrors(); + + RefreshButtons(); + } + + /// + /// Creates one button per entry, routed to the + /// section container matching its Section ("game", "mods", "exit", + /// "tools"). Runs after 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 + /// keeps it localized. + /// + private void BuildRegistryButtons() + { + var sections = new Dictionary + { + { "game", GetNode("MenuContainer/GameButtons") }, + { MenuButtonEntry.SectionMods, GetNode("MenuContainer/ModMenuButtons") }, + { "exit", GetNode("MenuContainer/ExitButtons") }, + { "tools", GetNode("MenuContainer/ToolMenuButtons") }, + }; + MenuBuilder.Build(this, sections); + + foreach (var container in sections.Values) + MenuBuilder.CollectLocaleKeys(container, _btnKeys); + } + + /// + /// Lets every registered tool append its launcher button to the tool block + /// below Exit (MenuContainer/ToolMenuButtons). Tools register + /// themselves in , 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 + /// aware of it. + /// + private void BuildToolButtons() + { + var container = GetNodeOrNull("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()}"); + } + } +} + + diff --git a/src/SaveLoadMenu.cs b/src/SaveLoadMenu.cs new file mode 100644 index 0000000..5faa157 --- /dev/null +++ b/src/SaveLoadMenu.cs @@ -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 +{ + /// + /// 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 — composition comparison, + /// backup, restart into another game, adoption of game-less saves. + /// + 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 _gameIds = new(); + private List _currentSlots; + + /// + /// Raised when the player clicks the back button to dismiss the save/load screen. + /// + public event Action Closed; + + public override void _Ready() + { + _titleLabel = GetNode