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

+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);
}
}
}