init mod
This commit is contained in:
commit
ee6fdc3576
32 files changed
+4117
No files matched your search
@@ -0,0 +1,149 @@
|
||||
using System;
|
||||
using Cthangover.Core.Games;
|
||||
using Cthangover.Core.Mods;
|
||||
using Cthangover.Core.Scenes;
|
||||
using Cthangover.Core.Settings;
|
||||
using Cthangover.Core.UI.Menu;
|
||||
using Godot;
|
||||
|
||||
namespace Cthangover.BaseMenu
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers the built-in main-menu buttons and the settings/save-load panels
|
||||
/// in <see cref="MenuRegistry"/>. Runs from
|
||||
/// <c>ModInitializerRegistry.NotifyResourcesReady</c>, which fires after the
|
||||
/// registry itself is built, so these entries are appended to live registry
|
||||
/// state rather than collected from a manifest.
|
||||
/// </summary>
|
||||
public class BaseMenuInitializer : IModInitializer
|
||||
{
|
||||
private const string ModId = "base_menu";
|
||||
|
||||
public void OnModLoaded(string modId)
|
||||
{
|
||||
}
|
||||
|
||||
public void OnModResourcesReady()
|
||||
{
|
||||
MenuRegistry.RegisterMenuPanel(new MenuPanelEntry
|
||||
{
|
||||
Id = "settings",
|
||||
ModId = ModId,
|
||||
ScenePath = "scenes/menu/settings_menu.tscn",
|
||||
Locale = "menu/settings",
|
||||
});
|
||||
MenuRegistry.RegisterMenuPanel(new MenuPanelEntry
|
||||
{
|
||||
Id = "save_load",
|
||||
ModId = ModId,
|
||||
ScenePath = "scenes/menu/save_load_menu.tscn",
|
||||
});
|
||||
|
||||
MenuRegistry.RegisterMenuButton(new MenuButtonEntry
|
||||
{
|
||||
Id = "continue",
|
||||
ModId = ModId,
|
||||
Section = "game",
|
||||
Locale = "menu/continue",
|
||||
Variation = "primary",
|
||||
Index = 5,
|
||||
Visible = GameFlow.HasContinue,
|
||||
Handler = BaseMenuActions.Continue,
|
||||
});
|
||||
MenuRegistry.RegisterMenuButton(new MenuButtonEntry
|
||||
{
|
||||
Id = "play",
|
||||
ModId = ModId,
|
||||
Section = "game",
|
||||
Locale = "menu/play",
|
||||
Variation = "primary",
|
||||
Index = 10,
|
||||
Handler = BaseMenuActions.Play,
|
||||
});
|
||||
// A new game of the running game; from the front-end games start
|
||||
// from the picker instead.
|
||||
MenuRegistry.RegisterMenuButton(new MenuButtonEntry
|
||||
{
|
||||
Id = "new_game",
|
||||
ModId = ModId,
|
||||
Section = "game",
|
||||
Locale = "menu/newgame",
|
||||
Index = 15,
|
||||
Visible = () => !ActiveGame.IsFrontendOnly,
|
||||
Handler = BaseMenuActions.NewGame,
|
||||
});
|
||||
MenuRegistry.RegisterMenuButton(new MenuButtonEntry
|
||||
{
|
||||
Id = "load",
|
||||
ModId = ModId,
|
||||
Section = "game",
|
||||
Locale = "ui/menu/load",
|
||||
Index = 20,
|
||||
Handler = BaseMenuActions.Load,
|
||||
});
|
||||
MenuRegistry.RegisterMenuButton(new MenuButtonEntry
|
||||
{
|
||||
Id = "settings",
|
||||
ModId = ModId,
|
||||
Section = "game",
|
||||
Locale = "menu/settings",
|
||||
Index = 30,
|
||||
Handler = BaseMenuActions.Settings,
|
||||
});
|
||||
MenuRegistry.RegisterMenuButton(new MenuButtonEntry
|
||||
{
|
||||
Id = "exit",
|
||||
ModId = ModId,
|
||||
Section = "exit",
|
||||
Locale = "menu/exit",
|
||||
Variation = "danger",
|
||||
Index = 1000,
|
||||
Handler = BaseMenuActions.Exit,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handlers for the built-in menu buttons. Each receives the menu root control
|
||||
/// so it can locate the panels instantiated as named children by
|
||||
/// <see cref="MainMenu"/>.
|
||||
/// </summary>
|
||||
internal static class BaseMenuActions
|
||||
{
|
||||
/// <summary>
|
||||
/// Starts a new game at the active game's entry scene (<c>start_scene</c>
|
||||
/// when no game collection is active) through <see cref="GameSession"/>.
|
||||
/// </summary>
|
||||
public static void NewGame(Control host)
|
||||
{
|
||||
GameSession.StartNew(host);
|
||||
}
|
||||
|
||||
/// <summary>Loads the last save of the last played game (restarting into it when needed).</summary>
|
||||
public static void Continue(Control host) => GameFlow.Continue(host);
|
||||
|
||||
/// <summary>Opens the game picker.</summary>
|
||||
public static void Play(Control host)
|
||||
{
|
||||
host.GetNodeOrNull<GamePickerPanel>("GamePickerPanel")?.Open();
|
||||
}
|
||||
|
||||
/// <summary>Opens the save/load panel in load mode.</summary>
|
||||
public static void Load(Control host)
|
||||
{
|
||||
host.GetNodeOrNull<SaveLoadMenu>("SaveLoadPanel")?.OpenForLoad();
|
||||
}
|
||||
|
||||
/// <summary>Shows the settings overlay.</summary>
|
||||
public static void Settings(Control host)
|
||||
{
|
||||
host.GetNodeOrNull<SettingsMenu>("SettingsPanel")?.Open();
|
||||
}
|
||||
|
||||
/// <summary>Quits the application immediately via <c>SceneTree.Quit</c>.</summary>
|
||||
public static void Exit(Control host)
|
||||
{
|
||||
host.GetTree().Quit();
|
||||
}
|
||||
}
|
||||
}
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using Cthangover.Core.Games;
|
||||
using Cthangover.Core.Mods;
|
||||
using Cthangover.Core.Settings;
|
||||
using Cthangover.Core.UI.Theming;
|
||||
using Godot;
|
||||
|
||||
namespace Cthangover.BaseMenu
|
||||
{
|
||||
/// <summary>
|
||||
/// Menu-side rules for entering games: "Continue", starting a game and
|
||||
/// loading a save. A game other than the running one (or any game from
|
||||
/// the front-end) is entered by replacing the mod set in-process
|
||||
/// (<see cref="GameSession.SwitchToAsync"/>). Before a save is loaded into a changed composition
|
||||
/// the player sees what changes (added mechanics start fresh, removed ones
|
||||
/// keep their data dormant) and a backup is taken.
|
||||
/// </summary>
|
||||
internal static class GameFlow
|
||||
{
|
||||
/// <summary>Set after installing mods in this process: entering any game, even the running one, reloads the mod set to pick them up.</summary>
|
||||
public static bool ReloadRequired { get; set; }
|
||||
|
||||
public static bool HasContinue() => LastSessionStore.Load() != null;
|
||||
|
||||
public static void Continue(Node host)
|
||||
{
|
||||
var last = LastSessionStore.Load();
|
||||
if (last != null)
|
||||
RequestLoad(host, last.GameId, last.FileName);
|
||||
}
|
||||
|
||||
public static void StartGame(Node host, string gameId)
|
||||
{
|
||||
if (IsRunning(gameId) && !ReloadRequired)
|
||||
GameSession.StartNew(host);
|
||||
else
|
||||
Switch(host, gameId, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads <paramref name="fileName"/> of <paramref name="gameId"/> (null =
|
||||
/// a save made without a game), comparing compositions first.
|
||||
/// </summary>
|
||||
public static void RequestLoad(Node host, string gameId, string fileName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(gameId))
|
||||
{
|
||||
// Development launch with every mod: the save belongs to that
|
||||
// mod set, load it as before.
|
||||
if (!ActiveGame.IsActive && !ActiveGame.IsFrontendOnly)
|
||||
{
|
||||
GameSession.LoadAndEnter(host, fileName);
|
||||
return;
|
||||
}
|
||||
|
||||
ChooseGameForSave(host, fileName);
|
||||
return;
|
||||
}
|
||||
|
||||
var package = SaveService.ReadPackage(gameId, fileName);
|
||||
var diff = SaveCompositionDiff.Compute(package?.Game?.Mods, GameCatalog.Composition(gameId));
|
||||
if (diff.IsEmpty)
|
||||
{
|
||||
Proceed(host, gameId, fileName, backup: false);
|
||||
return;
|
||||
}
|
||||
|
||||
Confirm(host,
|
||||
TranslationServer.Translate("menu/load/diff_title"),
|
||||
DescribeDiff(diff),
|
||||
TranslationServer.Translate("menu/load/diff_ok"),
|
||||
() => Proceed(host, gameId, fileName, backup: true));
|
||||
}
|
||||
|
||||
private static void Proceed(Node host, string gameId, string fileName, bool backup)
|
||||
{
|
||||
if (backup)
|
||||
SaveService.BackupSave(gameId, fileName);
|
||||
|
||||
if (IsRunning(gameId) && !ReloadRequired)
|
||||
GameSession.LoadAndEnter(host, fileName);
|
||||
else
|
||||
Switch(host, gameId, fileName);
|
||||
}
|
||||
|
||||
private static async void Switch(Node host, string gameId, string fileName)
|
||||
{
|
||||
if (await GameSession.SwitchToAsync(host, gameId, fileName))
|
||||
ReloadRequired = false;
|
||||
}
|
||||
|
||||
private static bool IsRunning(string gameId)
|
||||
=> ActiveGame.IsActive && string.Equals(ActiveGame.Id, gameId, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Lists what a composition change does to the save, in player terms.</summary>
|
||||
public static string DescribeDiff(SaveCompositionDiff diff)
|
||||
{
|
||||
var text = new StringBuilder(TranslationServer.Translate("menu/load/diff_intro"));
|
||||
void Section(string key, IEnumerable<string> lines)
|
||||
{
|
||||
var list = lines.ToList();
|
||||
if (list.Count == 0)
|
||||
return;
|
||||
text.Append("\n\n").Append(TranslationServer.Translate(key));
|
||||
foreach (var line in list)
|
||||
text.Append("\n • ").Append(line);
|
||||
}
|
||||
|
||||
Section("menu/load/diff_added", diff.Added.Select(ModTitle));
|
||||
Section("menu/load/diff_removed", diff.Removed.Select(ModTitle));
|
||||
Section("menu/load/diff_updated", diff.Updated.Select(u =>
|
||||
$"{ModTitle(u.ModId)}: {u.From} → {u.To}" + (u.Downgrade ? " " + TranslationServer.Translate("menu/load/diff_downgrade") : string.Empty)));
|
||||
text.Append("\n\n").Append(TranslationServer.Translate("menu/load/diff_backup"));
|
||||
return text.ToString();
|
||||
}
|
||||
|
||||
private static string ModTitle(string modId)
|
||||
=> ModRegistry.Instance.AllDiscoveredMods.TryGetValue(modId, out var info) && !string.IsNullOrEmpty(info.DisplayTitle)
|
||||
? $"{info.DisplayTitle} ({modId})"
|
||||
: modId;
|
||||
|
||||
/// <summary>A save made before games existed: the player picks the game it belongs to; it moves there.</summary>
|
||||
private static void ChooseGameForSave(Node host, string fileName)
|
||||
{
|
||||
var games = GameCatalog.Games().Where(g => g.IsReady).ToList();
|
||||
if (games.Count == 0)
|
||||
{
|
||||
Confirm(host, TranslationServer.Translate("menu/load/legacy_title"),
|
||||
TranslationServer.Translate("menu/load/legacy_no_games"), null, null);
|
||||
return;
|
||||
}
|
||||
|
||||
var select = new OptionButton();
|
||||
foreach (var game in games)
|
||||
select.AddItem(game.Name ?? game.Collection.Id);
|
||||
|
||||
var content = new VBoxContainer();
|
||||
content.AddChild(new Label
|
||||
{
|
||||
Text = TranslationServer.Translate("menu/load/legacy_text"),
|
||||
AutowrapMode = TextServer.AutowrapMode.WordSmart,
|
||||
CustomMinimumSize = new Vector2(420, 0),
|
||||
});
|
||||
content.AddChild(select);
|
||||
|
||||
var dialog = new ConfirmationDialog
|
||||
{
|
||||
Title = TranslationServer.Translate("menu/load/legacy_title"),
|
||||
OkButtonText = TranslationServer.Translate("menu/load/legacy_ok"),
|
||||
CancelButtonText = TranslationServer.Translate("ui/save/cancel_button"),
|
||||
Exclusive = true,
|
||||
};
|
||||
dialog.AddChild(content);
|
||||
dialog.Confirmed += () =>
|
||||
{
|
||||
var gameId = games[Math.Max(0, select.Selected)].Collection.Id;
|
||||
dialog.QueueFree();
|
||||
var slot = SaveService.AdoptLegacySave(fileName, gameId);
|
||||
RequestLoad(host, gameId, slot);
|
||||
};
|
||||
dialog.Canceled += dialog.QueueFree;
|
||||
host.AddChild(dialog);
|
||||
dialog.PopupCentered();
|
||||
}
|
||||
|
||||
/// <summary>Message or confirmation dialog; without <paramref name="onConfirm"/> it is a plain notice.</summary>
|
||||
public static void Confirm(Node host, string title, string text, string okText, Action onConfirm)
|
||||
{
|
||||
AcceptDialog dialog = onConfirm == null
|
||||
? new AcceptDialog()
|
||||
: new ConfirmationDialog { CancelButtonText = TranslationServer.Translate("ui/save/cancel_button") };
|
||||
dialog.Title = title;
|
||||
dialog.DialogText = text;
|
||||
dialog.Exclusive = true;
|
||||
if (!string.IsNullOrEmpty(okText))
|
||||
dialog.OkButtonText = okText;
|
||||
dialog.Confirmed += () =>
|
||||
{
|
||||
dialog.QueueFree();
|
||||
onConfirm?.Invoke();
|
||||
};
|
||||
dialog.Canceled += dialog.QueueFree;
|
||||
host.AddChild(dialog);
|
||||
if (onConfirm != null)
|
||||
CthangoverTheme.ApplyPrimary(dialog.GetOkButton());
|
||||
dialog.PopupCentered();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Cthangover.Core.Games;
|
||||
using Cthangover.Core.Mods;
|
||||
using Cthangover.Core.Settings;
|
||||
using Cthangover.Core.UI.Menu;
|
||||
using Cthangover.Core.UI.Theming;
|
||||
using Cthangover.Mods.Interface.Menu;
|
||||
using Cthangover.Mods.Interface.Theming;
|
||||
using Godot;
|
||||
|
||||
namespace Cthangover.BaseMenu
|
||||
{
|
||||
/// <summary>
|
||||
/// "Play": every game collection as a card — cover, name, description,
|
||||
/// readiness (what is missing and what can be installed), saves and the
|
||||
/// last play date — with New game / Continue / Saves / Install / Configure.
|
||||
/// Data comes from <see cref="GameCatalog"/> against all installed mods, so
|
||||
/// it works from the front-end where only the menu is loaded.
|
||||
/// </summary>
|
||||
public partial class GamePickerPanel : Control
|
||||
{
|
||||
private VBoxContainer _list;
|
||||
private readonly HashSet<string> _sessionInstalled = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Raised when the player opens a game's saves; the menu shows its save browser.</summary>
|
||||
public event Action<string> SavesRequested;
|
||||
|
||||
public event Action Closed;
|
||||
|
||||
public override void _Ready()
|
||||
{
|
||||
SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
|
||||
MouseFilter = MouseFilterEnum.Stop;
|
||||
|
||||
var backdrop = new ColorRect { Color = new Color(0, 0, 0, 0.65f) };
|
||||
backdrop.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
|
||||
AddChild(backdrop);
|
||||
|
||||
var panel = new PanelContainer();
|
||||
panel.AddThemeStyleboxOverride("panel", UiKit.Frame(UiTokens.RadiusLg, UiTokens.Surface1, UiTokens.Line));
|
||||
panel.AnchorLeft = panel.AnchorRight = panel.AnchorTop = panel.AnchorBottom = 0.5f;
|
||||
panel.OffsetLeft = -460;
|
||||
panel.OffsetRight = 460;
|
||||
panel.OffsetTop = -310;
|
||||
panel.OffsetBottom = 310;
|
||||
panel.GrowHorizontal = GrowDirection.Both;
|
||||
panel.GrowVertical = GrowDirection.Both;
|
||||
AddChild(panel);
|
||||
|
||||
var margin = new MarginContainer();
|
||||
foreach (var side in new[] { "left", "right", "top", "bottom" })
|
||||
margin.AddThemeConstantOverride($"margin_{side}", UiTokens.Space6);
|
||||
panel.AddChild(margin);
|
||||
|
||||
var column = UiKit.Column(UiTokens.Space4);
|
||||
column.MouseFilter = MouseFilterEnum.Pass;
|
||||
margin.AddChild(column);
|
||||
|
||||
column.AddChild(UiKit.Label(TranslationServer.Translate("menu/play/title"), UiTokens.FontH2, UiTokens.InkBright));
|
||||
|
||||
var scroll = new ScrollContainer
|
||||
{
|
||||
SizeFlagsVertical = SizeFlags.ExpandFill,
|
||||
HorizontalScrollMode = ScrollContainer.ScrollMode.Disabled,
|
||||
};
|
||||
column.AddChild(scroll);
|
||||
|
||||
_list = UiKit.Column(UiTokens.Space3);
|
||||
_list.MouseFilter = MouseFilterEnum.Pass;
|
||||
_list.SizeFlagsHorizontal = SizeFlags.ExpandFill;
|
||||
scroll.AddChild(_list);
|
||||
|
||||
var back = UiKit.Button(TranslationServer.Translate("settings/back"));
|
||||
back.SizeFlagsHorizontal = SizeFlags.ShrinkEnd;
|
||||
back.Pressed += Close;
|
||||
column.AddChild(back);
|
||||
|
||||
Visible = false;
|
||||
}
|
||||
|
||||
public void Open()
|
||||
{
|
||||
Visible = true;
|
||||
Rebuild();
|
||||
}
|
||||
|
||||
private void Close()
|
||||
{
|
||||
Visible = false;
|
||||
Closed?.Invoke();
|
||||
}
|
||||
|
||||
private void Rebuild()
|
||||
{
|
||||
foreach (var child in _list.GetChildren())
|
||||
child.QueueFree();
|
||||
|
||||
var games = GameCatalog.Games(_sessionInstalled);
|
||||
if (games.Count == 0)
|
||||
{
|
||||
_list.AddChild(UiKit.Label(TranslationServer.Translate("menu/play/no_games"), UiTokens.FontBody, UiTokens.Muted));
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var game in games)
|
||||
_list.AddChild(BuildCard(game));
|
||||
}
|
||||
|
||||
private Control BuildCard(ModCollectionGameSummary game)
|
||||
{
|
||||
var collection = game.Collection;
|
||||
var saves = SaveService.ListSaves(collection.Id);
|
||||
var newest = saves.OrderByDescending(s => s.SaveTime).FirstOrDefault();
|
||||
|
||||
var card = new PanelContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
|
||||
card.AddThemeStyleboxOverride("panel", UiKit.Frame(UiTokens.RadiusMd, UiTokens.Surface2,
|
||||
ActiveGame.Id == collection.Id ? UiTokens.Accent : UiTokens.LineCard));
|
||||
|
||||
var row = UiKit.Row(UiTokens.Space4);
|
||||
row.MouseFilter = MouseFilterEnum.Pass;
|
||||
card.AddChild(row);
|
||||
|
||||
var cover = GameCatalog.LoadCover(collection);
|
||||
row.AddChild(new TextureRect
|
||||
{
|
||||
Texture = cover,
|
||||
CustomMinimumSize = new Vector2(176, 99),
|
||||
ExpandMode = TextureRect.ExpandModeEnum.IgnoreSize,
|
||||
StretchMode = TextureRect.StretchModeEnum.KeepAspectCovered,
|
||||
SizeFlagsVertical = SizeFlags.ShrinkCenter,
|
||||
});
|
||||
|
||||
var info = UiKit.Column(UiTokens.Space1);
|
||||
info.SizeFlagsHorizontal = SizeFlags.ExpandFill;
|
||||
row.AddChild(info);
|
||||
info.AddChild(UiKit.Label(game.Name ?? collection.Id, UiTokens.FontH3, UiTokens.InkBright));
|
||||
var description = UiKit.Label(game.Description ?? string.Empty, UiTokens.FontBodySmall, UiTokens.Body);
|
||||
description.AutowrapMode = TextServer.AutowrapMode.WordSmart;
|
||||
info.AddChild(description);
|
||||
|
||||
var (statusText, statusColor) = Status(game);
|
||||
info.AddChild(UiKit.Label(statusText, UiTokens.FontSmall, statusColor));
|
||||
info.AddChild(UiKit.Label(saves.Count == 0
|
||||
? TranslationServer.Translate("menu/play/no_saves")
|
||||
: string.Format(TranslationServer.Translate("menu/play/saves"), saves.Count, newest.SaveTime.ToLocalTime().ToString("dd.MM.yy HH:mm")),
|
||||
UiTokens.FontTiny, UiTokens.Muted));
|
||||
|
||||
var buttons = UiKit.Column(UiTokens.Space2);
|
||||
buttons.MouseFilter = MouseFilterEnum.Pass;
|
||||
buttons.CustomMinimumSize = new Vector2(190, 0);
|
||||
row.AddChild(buttons);
|
||||
|
||||
var start = UiKit.Button(TranslationServer.Translate("menu/play/new_game"), ButtonKind.Primary);
|
||||
start.Disabled = !game.IsReady;
|
||||
start.Pressed += () => GameFlow.StartGame(this, collection.Id);
|
||||
buttons.AddChild(start);
|
||||
|
||||
if (newest != null)
|
||||
{
|
||||
var resume = UiKit.Button(TranslationServer.Translate("menu/play/continue"));
|
||||
resume.Disabled = !game.IsReady;
|
||||
resume.Pressed += () => GameFlow.RequestLoad(this, collection.Id, newest.FileName);
|
||||
buttons.AddChild(resume);
|
||||
|
||||
var browse = UiKit.Button(TranslationServer.Translate("menu/play/browse_saves"));
|
||||
browse.Pressed += () => SavesRequested?.Invoke(collection.Id);
|
||||
buttons.AddChild(browse);
|
||||
}
|
||||
|
||||
if (!game.IsReady && game.MissingIds.Count == 0 && game.MismatchIds.Count == 0
|
||||
&& game.ProblemsCount == 0 && game.PlanInstallCount + game.PlanEnableCount > 0)
|
||||
{
|
||||
var install = UiKit.Button(TranslationServer.Translate("menu/play/install"));
|
||||
install.Pressed += () => Install(collection, install);
|
||||
buttons.AddChild(install);
|
||||
}
|
||||
|
||||
if (MenuRegistry.GetButtonById(ModsHubId) != null)
|
||||
{
|
||||
var configure = UiKit.Button(TranslationServer.Translate("menu/play/configure"));
|
||||
configure.Pressed += () => OpenEditor(collection.Id);
|
||||
buttons.AddChild(configure);
|
||||
}
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
private static (string Text, Color Color) Status(ModCollectionGameSummary game)
|
||||
{
|
||||
if (game.IsReady)
|
||||
return (TranslationServer.Translate("menu/play/status_ready"), UiTokens.SuccessText);
|
||||
if (game.MissingIds.Count > 0)
|
||||
return (string.Format(TranslationServer.Translate("menu/play/status_missing"), string.Join(", ", game.MissingIds)), UiTokens.DangerText);
|
||||
if (game.MismatchIds.Count > 0)
|
||||
return (string.Format(TranslationServer.Translate("menu/play/status_mismatch"), string.Join(", ", game.MismatchIds)), UiTokens.DangerText);
|
||||
if (game.ProblemsCount > 0)
|
||||
return (string.Format(TranslationServer.Translate("menu/play/status_problems"), game.ProblemsCount), UiTokens.Warning);
|
||||
return (string.Format(TranslationServer.Translate("menu/play/status_install"), game.PlanInstallCount, game.PlanEnableCount), UiTokens.Warning);
|
||||
}
|
||||
|
||||
private const string ModsHubId = "mods_hub";
|
||||
|
||||
/// <summary>Installs/enables the missing mods; entering the game afterwards reloads the mod set to pick them up.</summary>
|
||||
private async void Install(ModCollection collection, Button button)
|
||||
{
|
||||
button.Disabled = true;
|
||||
button.Text = TranslationServer.Translate("menu/play/installing");
|
||||
|
||||
var collections = GameCatalog.LoadCollections();
|
||||
var catalog = CatalogService.CachedEntries ?? new List<CatalogExportEntry>();
|
||||
var graph = ModCollectionResolver.Resolve(collection, collections, ModRegistry.Instance.AllDiscoveredMods,
|
||||
DisabledModsStore.Load(), catalog, _sessionInstalled);
|
||||
var ok = await ModCollectionInstaller.ApplyAsync(ModCollectionPlan.Build(graph, catalog), _sessionInstalled);
|
||||
if (!IsInstanceValid(this))
|
||||
return;
|
||||
|
||||
if (ok)
|
||||
GameFlow.ReloadRequired = true;
|
||||
GameFlow.Confirm(this,
|
||||
TranslationServer.Translate("menu/play/install"),
|
||||
TranslationServer.Translate(ok ? "menu/play/install_done" : "menu/play/install_failed"),
|
||||
null, null);
|
||||
Rebuild();
|
||||
}
|
||||
|
||||
/// <summary>Opens the mod manager on the game's scheme (loose coupling through the menu registry).</summary>
|
||||
private void OpenEditor(string collectionId)
|
||||
{
|
||||
var entry = MenuRegistry.GetButtonById(ModsHubId);
|
||||
var host = GetParent() as Control ?? this;
|
||||
var hub = MenuBuilder.OpenScene(host, entry?.ScenePath, ModsHubId);
|
||||
if (hub != null && hub.HasMethod("OpenCollectionEditor"))
|
||||
hub.CallDeferred("OpenCollectionEditor", collectionId);
|
||||
Close();
|
||||
}
|
||||
}
|
||||
}
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
using System.Collections.Generic;
|
||||
using Cthangover.Core.Scenes;
|
||||
using Cthangover.Core.Settings;
|
||||
using Cthangover.Core.UI.Menu;
|
||||
using Cthangover.Core.UI.Theming;
|
||||
using Cthangover.Core.UI.Tool;
|
||||
using Cthangover.Core.Utils;
|
||||
using Godot;
|
||||
|
||||
using Cthangover.Mods.Interface.Menu;
|
||||
namespace Cthangover.BaseMenu
|
||||
{
|
||||
/// <summary>
|
||||
/// Title screen main menu, fully driven by <see cref="MenuRegistry"/>. The scene
|
||||
/// only defines named section containers (game, mods, exit, tools); every button —
|
||||
/// built-in or mod-provided — is created from registry entries, so mods can add,
|
||||
/// replace or re-order entries without touching this scene. Below the sections,
|
||||
/// every registered tool appends its own launcher button via
|
||||
/// <see cref="IToolProvider.AddMenuButton"/>, so no tool list is hardcoded here.
|
||||
/// If GameLogger reports mod compilation errors, a popup lists them. The
|
||||
/// SettingsMenu and SaveLoadMenu panels are instantiated at ready time but stay
|
||||
/// hidden; they are registered in <see cref="MenuRegistry"/> as panels "settings"
|
||||
/// and "save_load", so other mods can open them by id.
|
||||
/// </summary>
|
||||
public partial class MainMenu : Control
|
||||
{
|
||||
private SettingsMenu _settingsMenu;
|
||||
private SaveLoadMenu _saveLoadMenu;
|
||||
private GamePickerPanel _gamePicker;
|
||||
private readonly Dictionary<Button, string> _btnKeys = new();
|
||||
|
||||
public override void _Ready()
|
||||
{
|
||||
try
|
||||
{
|
||||
BuildUi();
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
GameLogger.Log("MAIN_MENU",
|
||||
$"_Ready failed: {ex.GetType().Name}: {ex.Message}{System.Environment.NewLine}{ex.StackTrace}",
|
||||
LogLevel.Error);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Assembles the runtime UI: theme, hidden settings and save/load panels,
|
||||
/// telegram link wiring, scene manager initialization and registry/tool
|
||||
/// driven buttons.
|
||||
/// </summary>
|
||||
private void BuildUi()
|
||||
{
|
||||
CthangoverTheme.Apply(this);
|
||||
|
||||
_settingsMenu = new ModScenePrefab("scenes/menu/settings_menu.tscn").Instantiate<SettingsMenu>();
|
||||
_settingsMenu.Name = "SettingsPanel";
|
||||
_settingsMenu.Visible = false;
|
||||
_settingsMenu.LanguageChanged += RefreshButtons;
|
||||
AddChild(_settingsMenu);
|
||||
|
||||
_saveLoadMenu = new ModScenePrefab("scenes/menu/save_load_menu.tscn").Instantiate<SaveLoadMenu>();
|
||||
_saveLoadMenu.Name = "SaveLoadPanel";
|
||||
_saveLoadMenu.Visible = false;
|
||||
AddChild(_saveLoadMenu);
|
||||
|
||||
_gamePicker = new GamePickerPanel { Name = "GamePickerPanel" };
|
||||
AddChild(_gamePicker);
|
||||
_gamePicker.SavesRequested += gameId =>
|
||||
{
|
||||
_gamePicker.Visible = false;
|
||||
_saveLoadMenu.OpenForGame(gameId);
|
||||
};
|
||||
|
||||
var telegramLink = GetNode<RichTextLabel>("TelegramLink");
|
||||
telegramLink.MetaClicked += OnTelegramLinkClicked;
|
||||
|
||||
var sceneManager = GetNode<SceneManager>("/root/SceneManager");
|
||||
sceneManager.Initialize();
|
||||
|
||||
BuildRegistryButtons();
|
||||
BuildToolButtons();
|
||||
CheckModCompilationErrors();
|
||||
|
||||
RefreshButtons();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates one button per <see cref="MenuRegistry"/> entry, routed to the
|
||||
/// section container matching its <c>Section</c> ("game", "mods", "exit",
|
||||
/// "tools"). Runs after <see cref="SceneManager.Initialize"/> because the
|
||||
/// registry is built during boot and filled by mod initializers; the
|
||||
/// defensive Initialize call keeps direct scene launches working. Each
|
||||
/// button's translation key is registered so <see cref="RefreshButtons"/>
|
||||
/// keeps it localized.
|
||||
/// </summary>
|
||||
private void BuildRegistryButtons()
|
||||
{
|
||||
var sections = new Dictionary<string, Container>
|
||||
{
|
||||
{ "game", GetNode<Container>("MenuContainer/GameButtons") },
|
||||
{ MenuButtonEntry.SectionMods, GetNode<Container>("MenuContainer/ModMenuButtons") },
|
||||
{ "exit", GetNode<Container>("MenuContainer/ExitButtons") },
|
||||
{ "tools", GetNode<Container>("MenuContainer/ToolMenuButtons") },
|
||||
};
|
||||
MenuBuilder.Build(this, sections);
|
||||
|
||||
foreach (var container in sections.Values)
|
||||
MenuBuilder.CollectLocaleKeys(container, _btnKeys);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lets every registered tool append its launcher button to the tool block
|
||||
/// below Exit (<c>MenuContainer/ToolMenuButtons</c>). Tools register
|
||||
/// themselves in <see cref="ToolFactory"/>, so the block stays empty when no
|
||||
/// tools are installed and needs no changes when a mod introduces a new tool.
|
||||
/// Each button carries its translation key in metadata, keeping
|
||||
/// <see cref="RefreshButtons"/> aware of it.
|
||||
/// </summary>
|
||||
private void BuildToolButtons()
|
||||
{
|
||||
var container = GetNodeOrNull<VBoxContainer>("MenuContainer/ToolMenuButtons");
|
||||
if (container == null)
|
||||
return;
|
||||
|
||||
foreach (var tool in ToolFactory.Instance.GetAll())
|
||||
tool.AddMenuButton(container);
|
||||
|
||||
MenuBuilder.CollectLocaleKeys(container, _btnKeys);
|
||||
|
||||
GameLogger.Log("MAIN_MENU", $"Built {container.GetChildCount()} tool button(s)");
|
||||
}
|
||||
|
||||
private void CheckModCompilationErrors()
|
||||
{
|
||||
if (GameLogger.CompilationErrors.Count == 0)
|
||||
return;
|
||||
|
||||
var errorText = string.Join("\r\n", GameLogger.CompilationErrors);
|
||||
ShowModErrorPopup(errorText);
|
||||
}
|
||||
|
||||
private void RefreshButtons()
|
||||
{
|
||||
foreach (var kv in _btnKeys)
|
||||
kv.Key.Text = TranslationServer.Translate(kv.Value);
|
||||
}
|
||||
|
||||
private void ShowModErrorPopup(string errorText)
|
||||
{
|
||||
var dialog = new AcceptDialog();
|
||||
dialog.Title = "Mod Compilation Error";
|
||||
dialog.DialogText = errorText;
|
||||
dialog.OkButtonText = "OK";
|
||||
dialog.Exclusive = true;
|
||||
AddChild(dialog);
|
||||
dialog.PopupCentered();
|
||||
}
|
||||
|
||||
private void OnTelegramLinkClicked(Variant meta)
|
||||
{
|
||||
OS.ShellOpen($"https://{meta.AsString()}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Cthangover.Core.Games;
|
||||
using Cthangover.Core.Mods;
|
||||
using Cthangover.Core.Scenes;
|
||||
using Cthangover.Core.Settings;
|
||||
using Cthangover.Core.UI.Menu;
|
||||
using Cthangover.Core.UI.Theming;
|
||||
using Godot;
|
||||
|
||||
namespace Cthangover.BaseMenu
|
||||
{
|
||||
/// <summary>
|
||||
/// Save/load browser with unlimited slots. The grid shows 4×3 slots per
|
||||
/// screen and scrolls past that. Saves belong to games: in a game the
|
||||
/// panel shows that game's slots (save mode appends one empty "new"
|
||||
/// slot); from the front-end the selector at the top switches between
|
||||
/// games with saves and the saves made before games existed. Loading goes
|
||||
/// through <see cref="GameFlow.RequestLoad"/> — composition comparison,
|
||||
/// backup, restart into another game, adoption of game-less saves.
|
||||
/// </summary>
|
||||
public partial class SaveLoadMenu : Control, ISaveLoadPanel
|
||||
{
|
||||
private Label _titleLabel;
|
||||
private OptionButton _gameSelect;
|
||||
private SaveSlotList _slotList;
|
||||
private Label _emptyLabel;
|
||||
private Button _backBtn;
|
||||
|
||||
private bool _isLoadMode;
|
||||
private string _gameId;
|
||||
private readonly List<string> _gameIds = new();
|
||||
private List<SaveSlotInfo> _currentSlots;
|
||||
|
||||
/// <summary>
|
||||
/// Raised when the player clicks the back button to dismiss the save/load screen.
|
||||
/// </summary>
|
||||
public event Action Closed;
|
||||
|
||||
public override void _Ready()
|
||||
{
|
||||
_titleLabel = GetNode<Label>("Panel/Margin/VBox/Title");
|
||||
_gameSelect = GetNode<OptionButton>("Panel/Margin/VBox/GameSelect");
|
||||
_slotList = GetNode<SaveSlotList>("Panel/Margin/VBox/Scroll/SlotGrid");
|
||||
_emptyLabel = GetNode<Label>("Panel/Margin/VBox/EmptyLabel");
|
||||
_backBtn = GetNode<Button>("Panel/Margin/VBox/BackBtn");
|
||||
CthangoverTheme.ApplyIcon(_backBtn);
|
||||
|
||||
_slotList.SlotPressed += OnSlotPressed;
|
||||
_gameSelect.ItemSelected += OnGameSelected;
|
||||
_backBtn.Pressed += OnBackPressed;
|
||||
_backBtn.Text = TranslationServer.Translate("settings/back");
|
||||
Visible = false;
|
||||
}
|
||||
|
||||
/// <summary>Load mode: the running game's saves, or every game's from the front-end.</summary>
|
||||
public void OpenForLoad() => OpenFor(true, ActiveGame.Id);
|
||||
|
||||
/// <summary>Save mode: always the running game's slots plus a free one.</summary>
|
||||
public void OpenForSave() => OpenFor(false, ActiveGame.Id);
|
||||
|
||||
/// <summary>Load mode preselected on <paramref name="gameId"/> (game picker → "Saves").</summary>
|
||||
public void OpenForGame(string gameId) => OpenFor(true, gameId);
|
||||
|
||||
/// <inheritdoc cref="IMenuPanel" />
|
||||
public void Open() => OpenForLoad();
|
||||
|
||||
private void OpenFor(bool load, string gameId)
|
||||
{
|
||||
_isLoadMode = load;
|
||||
_titleLabel.Text = TranslationServer.Translate(load ? "ui/load/title" : "ui/save/title");
|
||||
BuildGameSelect(gameId);
|
||||
Visible = true;
|
||||
CallDeferred(nameof(RefreshSlots));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Games offered by the selector: in save mode only the running game;
|
||||
/// in load mode every game with saves (the running/preselected one first)
|
||||
/// and, last, the saves made without a game.
|
||||
/// </summary>
|
||||
private void BuildGameSelect(string preselected)
|
||||
{
|
||||
_gameIds.Clear();
|
||||
_gameSelect.Clear();
|
||||
|
||||
if (!_isLoadMode)
|
||||
{
|
||||
AddGame(ActiveGame.Id, ActiveGame.Collection?.DisplayTitle);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!string.IsNullOrEmpty(preselected))
|
||||
AddGame(preselected, GameName(preselected));
|
||||
foreach (var game in SaveService.ListGamesWithSaves())
|
||||
{
|
||||
if (!_gameIds.Contains(game.GameId))
|
||||
AddGame(game.GameId, game.GameId == null ? null : GameName(game.GameId) ?? game.GameName);
|
||||
}
|
||||
if (_gameIds.Count == 0)
|
||||
AddGame(preselected, GameName(preselected));
|
||||
}
|
||||
|
||||
_gameId = _gameIds.Count > 0 ? _gameIds[0] : null;
|
||||
_gameSelect.Selected = _gameIds.Count > 0 ? 0 : -1;
|
||||
_gameSelect.Visible = _gameIds.Count > 1 || (_gameIds.Count == 1 && _gameIds[0] == null && ActiveGame.IsActive);
|
||||
}
|
||||
|
||||
private void AddGame(string gameId, string name)
|
||||
{
|
||||
_gameIds.Add(gameId);
|
||||
_gameSelect.AddItem(gameId == null
|
||||
? TranslationServer.Translate("menu/load/legacy_group")
|
||||
: name ?? gameId);
|
||||
}
|
||||
|
||||
private static string GameName(string gameId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(gameId))
|
||||
return null;
|
||||
if (ActiveGame.Id == gameId)
|
||||
return ActiveGame.Collection?.GetLocalizedName(TranslationServer.GetLocale());
|
||||
return GameCatalog.LoadCollections().TryGetValue(gameId, out var collection)
|
||||
? CollectionText.Resolve(collection.GetLocalizedName(TranslationServer.GetLocale()))
|
||||
: null;
|
||||
}
|
||||
|
||||
private void OnGameSelected(long index)
|
||||
{
|
||||
_gameId = index >= 0 && index < _gameIds.Count ? _gameIds[(int)index] : null;
|
||||
RefreshSlots();
|
||||
}
|
||||
|
||||
/// <summary>Lists the selected game's saves; save mode appends the next free slot.</summary>
|
||||
public void RefreshSlots()
|
||||
{
|
||||
_currentSlots = SaveService.ListSaves(_gameId);
|
||||
if (!_isLoadMode)
|
||||
_currentSlots.Add(SaveService.EmptySlot(_gameId, SaveService.NextSlotName(_gameId)));
|
||||
|
||||
_emptyLabel.Visible = _currentSlots.Count == 0;
|
||||
_emptyLabel.Text = TranslationServer.Translate("menu/load/empty");
|
||||
_slotList.SetSlots(_currentSlots);
|
||||
}
|
||||
|
||||
private void OnSlotPressed(string fileName)
|
||||
{
|
||||
var slot = FindSlot(fileName);
|
||||
|
||||
if (_isLoadMode)
|
||||
{
|
||||
if (slot == null || slot.IsEmpty)
|
||||
return;
|
||||
|
||||
GameFlow.RequestLoad(this, _gameId, fileName);
|
||||
return;
|
||||
}
|
||||
|
||||
if (slot == null || slot.IsEmpty)
|
||||
{
|
||||
SaveScreenshotService.CaptureAndSave(fileName);
|
||||
SaveService.Save(fileName);
|
||||
RefreshSlots();
|
||||
}
|
||||
else
|
||||
{
|
||||
ShowOverwriteConfirm(fileName);
|
||||
}
|
||||
}
|
||||
|
||||
private SaveSlotInfo FindSlot(string fileName)
|
||||
=> _currentSlots?.Find(s => s.FileName == fileName);
|
||||
|
||||
private void ShowOverwriteConfirm(string fileName)
|
||||
{
|
||||
var dialog = new ConfirmationDialog();
|
||||
dialog.Title = TranslationServer.Translate("ui/save/overwrite_title");
|
||||
dialog.DialogText = TranslationServer.Translate("ui/save/overwrite_text");
|
||||
dialog.OkButtonText = TranslationServer.Translate("ui/save/save_button");
|
||||
dialog.CancelButtonText = TranslationServer.Translate("ui/save/cancel_button");
|
||||
dialog.Exclusive = true;
|
||||
AddChild(dialog);
|
||||
CthangoverTheme.ApplyPrimary(dialog.GetOkButton());
|
||||
|
||||
dialog.Confirmed += () =>
|
||||
{
|
||||
SaveScreenshotService.CaptureAndSave(fileName);
|
||||
SaveService.Save(fileName);
|
||||
RefreshSlots();
|
||||
};
|
||||
|
||||
dialog.PopupCentered();
|
||||
}
|
||||
|
||||
private void OnBackPressed()
|
||||
{
|
||||
Visible = false;
|
||||
Closed?.Invoke();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using System.Collections.Generic;
|
||||
using Cthangover.Core.Mods;
|
||||
using Cthangover.Core.Settings;
|
||||
using Godot;
|
||||
|
||||
namespace Cthangover.BaseMenu
|
||||
{
|
||||
/// <summary>
|
||||
/// Save settings shown under the base menu in the mods settings tab.
|
||||
/// Games keep the data of mechanics the player turned off (dormant slots,
|
||||
/// see <see cref="SaveDormancy"/>) so it returns with the mechanic; this
|
||||
/// is where the player drops that data from every save on purpose.
|
||||
/// </summary>
|
||||
public class SaveSettings : IModSettings, IModSettingsActions
|
||||
{
|
||||
public const string PurgeDormantKey = "purge_dormant";
|
||||
|
||||
public IReadOnlyList<ModSettingDefinition> GetDefinitions() => new List<ModSettingDefinition>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Key = PurgeDormantKey,
|
||||
Name = "settings/saves/purge_dormant",
|
||||
Type = SettingType.Action,
|
||||
ActionText = "settings/saves/purge_dormant_button",
|
||||
ConfirmText = "settings/saves/purge_dormant_confirm",
|
||||
},
|
||||
};
|
||||
|
||||
public void WriteValues(DataBlob blob)
|
||||
{
|
||||
}
|
||||
|
||||
public void ReadValues(DataBlob blob)
|
||||
{
|
||||
}
|
||||
|
||||
public string RunAction(string key)
|
||||
{
|
||||
if (key != PurgeDormantKey)
|
||||
return null;
|
||||
|
||||
var purged = SaveService.PurgeDormantData();
|
||||
return string.Format(TranslationServer.Translate("settings/saves/purge_dormant_done"), purged);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
using System;
|
||||
using Cthangover.Core.Settings;
|
||||
using Cthangover.Mods.Interface.Base.Lists;
|
||||
using Godot;
|
||||
|
||||
namespace Cthangover.BaseMenu
|
||||
{
|
||||
/// <summary>
|
||||
/// A single save slot card in the save/load grid. Extends ListItem with
|
||||
/// SaveSlotInfo as the model. Renders differently for empty vs. occupied
|
||||
/// slots: empty slots show a placeholder label; occupied slots display the
|
||||
/// screenshot, in-game date (from TimeData), real-world save timestamp, and
|
||||
/// scene name. The stats label shows party size. Button click emits SlotPressed
|
||||
/// with the file name for parent handling. Destruct disconnects the button
|
||||
/// signal before QueueFree to avoid phantom callbacks.
|
||||
/// </summary>
|
||||
public partial class SaveSlotItem : ListItem<SaveSlotInfo>
|
||||
{
|
||||
private TextureRect screenshotRect;
|
||||
private Label dateLabel;
|
||||
private Label timeLabel;
|
||||
private Label sceneLabel;
|
||||
private Label emptyLabel;
|
||||
private Button slotButton;
|
||||
private Label statsLabel;
|
||||
|
||||
/// <summary>
|
||||
/// Raised when this specific slot is clicked, carrying the save file name so the parent
|
||||
/// <see cref="SaveSlotList"/> can forward it to <see cref="SaveLoadMenu"/>. A plain C#
|
||||
/// event because mod assemblies are compiled without the Godot source generators.
|
||||
/// </summary>
|
||||
public event Action<string> SlotPressed;
|
||||
|
||||
public override void _Ready()
|
||||
{
|
||||
screenshotRect = GetNode<TextureRect>("ScreenshotRect");
|
||||
dateLabel = GetNode<Label>("DateLabel");
|
||||
timeLabel = GetNode<Label>("TimeLabel");
|
||||
sceneLabel = GetNode<Label>("SceneLabel");
|
||||
emptyLabel = GetNode<Label>("EmptyLabel");
|
||||
slotButton = GetNode<Button>("SlotButton");
|
||||
statsLabel = GetNodeOrNull<Label>("StatsLabel");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Populates the slot visuals from the <see cref="SaveSlotInfo"/> model. For occupied slots,
|
||||
/// loads the screenshot texture, formats the game date from <see cref="SaveSlotInfo.GameTime"/>,
|
||||
/// shows the real-world save timestamp, and displays party size. Empty slots show only a
|
||||
/// localized "empty" label. Wires the button click signal to emit <see cref="SlotPressed"/>.
|
||||
/// </summary>
|
||||
public override void Construct(SaveSlotInfo model)
|
||||
{
|
||||
base.Construct(model);
|
||||
|
||||
if (model.IsEmpty)
|
||||
{
|
||||
screenshotRect.Visible = false;
|
||||
dateLabel.Visible = false;
|
||||
timeLabel.Visible = false;
|
||||
sceneLabel.Visible = false;
|
||||
emptyLabel.Visible = true;
|
||||
if (statsLabel != null) statsLabel.Visible = false;
|
||||
emptyLabel.Text = TranslationServer.Translate("ui/save/empty");
|
||||
}
|
||||
else
|
||||
{
|
||||
emptyLabel.Visible = false;
|
||||
screenshotRect.Visible = true;
|
||||
dateLabel.Visible = true;
|
||||
timeLabel.Visible = true;
|
||||
sceneLabel.Visible = true;
|
||||
if (statsLabel != null) statsLabel.Visible = true;
|
||||
|
||||
var tex = SaveScreenshotService.LoadScreenshotFromPath(model.ScreenshotPath);
|
||||
if (tex != null)
|
||||
screenshotRect.Texture = tex;
|
||||
|
||||
var gameTimeObj = new TimeData(model.GameTime);
|
||||
dateLabel.Text = TranslationServer.Translate("ui/save/game_day").ToString()
|
||||
.Replace("{day}", (gameTimeObj.Days + 1).ToString())
|
||||
.Replace("{time}", gameTimeObj.Text);
|
||||
|
||||
timeLabel.Text = model.SaveTime != System.DateTime.MinValue
|
||||
? model.SaveTime.ToLocalTime().ToString("dd.MM.yy HH:mm")
|
||||
: "";
|
||||
|
||||
sceneLabel.Text = model.SceneName;
|
||||
|
||||
if (statsLabel != null)
|
||||
statsLabel.Text = TranslationServer.Translate("ui/save/party_size").ToString()
|
||||
.Replace("{count}", model.CharacterCount.ToString());
|
||||
}
|
||||
|
||||
slotButton.Pressed += OnPressed;
|
||||
}
|
||||
|
||||
private void OnPressed()
|
||||
{
|
||||
SlotPressed?.Invoke(Model.FileName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disconnects the button signal to prevent callbacks on freed nodes, then queues
|
||||
/// this item for deletion. Called by the <see cref="SaveSlotList"/> when rebuilding
|
||||
/// the grid, ensuring no dangling signal references.
|
||||
/// </summary>
|
||||
public override void Destruct()
|
||||
{
|
||||
if (slotButton != null && GodotObject.IsInstanceValid(slotButton))
|
||||
slotButton.Pressed -= OnPressed;
|
||||
QueueFree();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Cthangover.Core.Scenes;
|
||||
using Cthangover.Core.Settings;
|
||||
using Godot;
|
||||
|
||||
using Cthangover.Mods.Interface.Base.Lists;
|
||||
using Cthangover.Mods.Interface.Base.Lists.Impls;
|
||||
namespace Cthangover.BaseMenu
|
||||
{
|
||||
/// <summary>
|
||||
/// Save slot grid layout with configurable column/row count and padding.
|
||||
/// Does NOT use the ListWidget/ColumnCellListWidget framework — manually
|
||||
/// calculates cell dimensions from the control size and instantiates
|
||||
/// SaveSlotItem prefabs in a flat grid. This is intentional: the save grid
|
||||
/// has a fixed visual size and doesn't need scrolling or dynamic content
|
||||
/// sizing. OnResize refreshes if slots were cleared (e.g. initial render
|
||||
/// when size wasn't known yet).
|
||||
/// </summary>
|
||||
public partial class SaveSlotList : Control
|
||||
{
|
||||
private ModScenePrefab _itemScene;
|
||||
private readonly List<SaveSlotItem> _items = new();
|
||||
|
||||
/// <summary>
|
||||
/// Raised when any child <see cref="SaveSlotItem"/> is clicked, forwarding the save file
|
||||
/// name. A plain C# event because mod assemblies are compiled without the Godot source
|
||||
/// generators.
|
||||
/// </summary>
|
||||
public event Action<string> SlotPressed;
|
||||
|
||||
/// <summary>Number of columns in the save slot grid. Editor-configurable.</summary>
|
||||
[Export]
|
||||
public int ColumnCount { get; set; } = 4;
|
||||
|
||||
/// <summary>Number of rows in the save slot grid. Editor-configurable.</summary>
|
||||
[Export]
|
||||
public int RowCount { get; set; } = 3;
|
||||
|
||||
/// <summary>Spacing between adjacent slots in pixels. Editor-configurable.</summary>
|
||||
[Export]
|
||||
public float SlotPadding { get; set; } = 8f;
|
||||
|
||||
private List<SaveSlotInfo> _slotModels;
|
||||
|
||||
/// <summary>Width kept free for the vertical scroll bar of the parent <see cref="ScrollContainer"/>.</summary>
|
||||
private const float ScrollBarReserve = 14f;
|
||||
|
||||
public override void _Ready()
|
||||
{
|
||||
_itemScene = new ModScenePrefab("scenes/menu/save_slot_item.tscn");
|
||||
Resized += OnResized;
|
||||
if (GetParent() is Control viewport)
|
||||
viewport.Resized += OnResized;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Area one screen of slots occupies: the parent scroll viewport when
|
||||
/// the list scrolls (slots are unlimited), otherwise the list itself.
|
||||
/// </summary>
|
||||
private Vector2 ViewportSize()
|
||||
=> GetParent() is ScrollContainer scroll
|
||||
? new Vector2(scroll.Size.X - ScrollBarReserve, scroll.Size.Y)
|
||||
: Size;
|
||||
|
||||
/// <summary>
|
||||
/// Stores the slot data models and triggers a full grid rebuild via <see cref="Refresh"/>.
|
||||
/// </summary>
|
||||
public void SetSlots(List<SaveSlotInfo> slots)
|
||||
{
|
||||
_slotModels = slots;
|
||||
Refresh();
|
||||
}
|
||||
|
||||
private void ClearItems()
|
||||
{
|
||||
foreach (var item in _items)
|
||||
{
|
||||
if (GodotObject.IsInstanceValid(item))
|
||||
item.QueueFree();
|
||||
}
|
||||
_items.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Destroys all existing <see cref="SaveSlotItem"/> children and rebuilds the grid
|
||||
/// from <c>_slotModels</c>. Calculates cell dimensions as (total - gaps) / column|row count,
|
||||
/// then positions each item using flat coordinates (no <c>GridContainer</c> dependency).
|
||||
/// Skips rendering if the control has zero size (e.g. called before first layout pass).
|
||||
/// </summary>
|
||||
public void Refresh()
|
||||
{
|
||||
ClearItems();
|
||||
|
||||
if (_slotModels == null || _itemScene == null)
|
||||
return;
|
||||
|
||||
var contentSize = ViewportSize();
|
||||
if (contentSize.X <= 0 || contentSize.Y <= 0)
|
||||
return;
|
||||
|
||||
// One screen holds ColumnCount × RowCount slots; more rows extend
|
||||
// the list and the parent ScrollContainer scrolls.
|
||||
var cellWidth = (contentSize.X - (ColumnCount - 1) * SlotPadding) / ColumnCount;
|
||||
var cellHeight = (contentSize.Y - (RowCount - 1) * SlotPadding) / RowCount;
|
||||
var rows = Math.Max(1, (_slotModels.Count + ColumnCount - 1) / ColumnCount);
|
||||
CustomMinimumSize = new Vector2(0, rows * cellHeight + (rows - 1) * SlotPadding);
|
||||
|
||||
for (int i = 0; i < _slotModels.Count; i++)
|
||||
{
|
||||
var model = _slotModels[i];
|
||||
|
||||
var item = _itemScene.Instantiate<SaveSlotItem>();
|
||||
AddChild(item);
|
||||
|
||||
var col = i % ColumnCount;
|
||||
var row = i / ColumnCount;
|
||||
|
||||
item.Position = new Vector2(
|
||||
col * (cellWidth + SlotPadding),
|
||||
row * (cellHeight + SlotPadding)
|
||||
);
|
||||
item.Size = new Vector2(cellWidth, cellHeight);
|
||||
|
||||
item.Construct(model);
|
||||
item.SlotPressed += OnItemSlotPressed;
|
||||
_items.Add(item);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnResized()
|
||||
{
|
||||
var view = ViewportSize();
|
||||
if (_slotModels != null && view.X > 0 && view.Y > 0
|
||||
&& (_items.Count == 0 || Math.Abs(view.X - _laidOutFor.X) > 1 || Math.Abs(view.Y - _laidOutFor.Y) > 1))
|
||||
{
|
||||
_laidOutFor = view;
|
||||
Refresh();
|
||||
}
|
||||
}
|
||||
|
||||
private Vector2 _laidOutFor;
|
||||
|
||||
private void OnItemSlotPressed(string fileName)
|
||||
{
|
||||
SlotPressed?.Invoke(fileName);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,803 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Cthangover.Core.Localization;
|
||||
using Cthangover.Core.Mods;
|
||||
using Cthangover.Core.Settings;
|
||||
using Cthangover.Core.UI.Menu;
|
||||
using Cthangover.Core.UI.Theming;
|
||||
using Cthangover.Core.Utils;
|
||||
using Godot;
|
||||
|
||||
using Cthangover.Mods.Interface.Menu;
|
||||
namespace Cthangover.BaseMenu
|
||||
{
|
||||
/// <summary>
|
||||
/// Audio, language, and display settings panel with tabbed layout. Uses an
|
||||
/// _initialized guard to prevent save-triggered callbacks from firing during
|
||||
/// UI population (LoadSettingsToUI → widget changes → callbacks → premature
|
||||
/// saves). Language changes call LocaleLoader.LoadCurrentLanguage() for
|
||||
/// immediate effect, then emit LanguageChanged so parent menus can refresh
|
||||
/// their translated button text. Sliders become non-editable when their
|
||||
/// toggle is off, enforcing the "disabled means off" semantics visually.
|
||||
/// Display changes (window mode, resolution, monitor, V-Sync, scale) are
|
||||
/// applied live via DisplayServer and ProjectSettings.
|
||||
/// </summary>
|
||||
public partial class SettingsMenu : Control, ISettingsPanel
|
||||
{
|
||||
/// <summary>
|
||||
/// Raised after the player selects a different language and it has been applied.
|
||||
/// Parent menus (<see cref="MainMenu"/>, <see cref="GameMenu"/>) subscribe to
|
||||
/// refresh their translated button text.
|
||||
/// </summary>
|
||||
public event Action LanguageChanged;
|
||||
private TabContainer _tabs;
|
||||
private CheckButton _soundsToggle;
|
||||
private CheckButton _musicToggle;
|
||||
private CheckButton _ambientToggle;
|
||||
private HSlider _soundsVolume;
|
||||
private HSlider _musicVolume;
|
||||
private HSlider _ambientVolume;
|
||||
private Label _soundsVolumeValue;
|
||||
private Label _musicVolumeValue;
|
||||
private Label _ambientVolumeValue;
|
||||
private OptionButton _langSelect;
|
||||
private Button _backBtn;
|
||||
|
||||
private VBoxContainer _modListContainer;
|
||||
private Label _selectedModName;
|
||||
private Button _resetModSettingsBtn;
|
||||
private VBoxContainer _settingsContainer;
|
||||
private string _selectedModId;
|
||||
private readonly Dictionary<Control, string> _controlKeys = new();
|
||||
|
||||
private OptionButton _windowModeSelect;
|
||||
private OptionButton _resolutionSelect;
|
||||
private OptionButton _monitorSelect;
|
||||
private OptionButton _scaleSelect;
|
||||
private CheckButton _vsyncToggle;
|
||||
|
||||
private readonly Dictionary<Label, string> _labelKeys = new();
|
||||
private bool _initialized;
|
||||
|
||||
private static readonly Vector2I[] CommonResolutions =
|
||||
{
|
||||
new(1280, 720),
|
||||
new(1366, 768),
|
||||
new(1600, 900),
|
||||
new(1920, 1080),
|
||||
new(2560, 1440),
|
||||
new(3840, 2160),
|
||||
};
|
||||
|
||||
private static readonly int[] ScaleOptions = { 1, 2, 3, 4 };
|
||||
|
||||
public override void _Ready()
|
||||
{
|
||||
_tabs = GetNode<TabContainer>("Panel/Margin/VBox/Tabs");
|
||||
|
||||
_langSelect = GetNode<OptionButton>("Panel/Margin/VBox/Tabs/GeneralMargin/General/LangBox/LangSelect");
|
||||
|
||||
_soundsToggle = GetNode<CheckButton>("Panel/Margin/VBox/Tabs/AudioMargin/Audio/SoundsBox/ToggleRow/SoundsToggle");
|
||||
_soundsVolume = GetNode<HSlider>("Panel/Margin/VBox/Tabs/AudioMargin/Audio/SoundsBox/VolumeRow/SoundsVolume");
|
||||
_soundsVolumeValue = GetNode<Label>("Panel/Margin/VBox/Tabs/AudioMargin/Audio/SoundsBox/VolumeRow/SoundsVolumeValue");
|
||||
|
||||
_musicToggle = GetNode<CheckButton>("Panel/Margin/VBox/Tabs/AudioMargin/Audio/MusicBox/ToggleRow2/MusicToggle");
|
||||
_musicVolume = GetNode<HSlider>("Panel/Margin/VBox/Tabs/AudioMargin/Audio/MusicBox/VolumeRow2/MusicVolume");
|
||||
_musicVolumeValue = GetNode<Label>("Panel/Margin/VBox/Tabs/AudioMargin/Audio/MusicBox/VolumeRow2/MusicVolumeValue");
|
||||
|
||||
_ambientToggle = GetNode<CheckButton>("Panel/Margin/VBox/Tabs/AudioMargin/Audio/AmbientBox/ToggleRow3/AmbientToggle");
|
||||
_ambientVolume = GetNode<HSlider>("Panel/Margin/VBox/Tabs/AudioMargin/Audio/AmbientBox/VolumeRow3/AmbientVolume");
|
||||
_ambientVolumeValue = GetNode<Label>("Panel/Margin/VBox/Tabs/AudioMargin/Audio/AmbientBox/VolumeRow3/AmbientVolumeValue");
|
||||
|
||||
_windowModeSelect = GetNode<OptionButton>("Panel/Margin/VBox/Tabs/DisplayMargin/Display/ModeBox/ModeSelect");
|
||||
_resolutionSelect = GetNode<OptionButton>("Panel/Margin/VBox/Tabs/DisplayMargin/Display/ResBox/ResSelect");
|
||||
_monitorSelect = GetNode<OptionButton>("Panel/Margin/VBox/Tabs/DisplayMargin/Display/MonitorBox/MonitorSelect");
|
||||
_scaleSelect = GetNode<OptionButton>("Panel/Margin/VBox/Tabs/DisplayMargin/Display/ScaleBox/ScaleSelect");
|
||||
_vsyncToggle = GetNode<CheckButton>("Panel/Margin/VBox/Tabs/DisplayMargin/Display/VSyncRow/VSyncToggle");
|
||||
|
||||
_backBtn = GetNode<Button>("Panel/Margin/VBox/BackBtn");
|
||||
CthangoverTheme.ApplyIcon(_backBtn);
|
||||
|
||||
_modListContainer = GetNode<VBoxContainer>("Panel/Margin/VBox/Tabs/ModSettingsMargin/ModSettings/LeftPanel/ModScroll/ModList");
|
||||
_selectedModName = GetNode<Label>("Panel/Margin/VBox/Tabs/ModSettingsMargin/ModSettings/RightPanel/TopBar/SelectedModName");
|
||||
_resetModSettingsBtn = GetNode<Button>("Panel/Margin/VBox/Tabs/ModSettingsMargin/ModSettings/RightPanel/TopBar/ResetBtn");
|
||||
CthangoverTheme.ApplyDanger(_resetModSettingsBtn);
|
||||
_settingsContainer = GetNode<VBoxContainer>("Panel/Margin/VBox/Tabs/ModSettingsMargin/ModSettings/RightPanel/SettingsScroll/SettingsContainer");
|
||||
|
||||
RegisterLabel(GetNode<Label>("Panel/Margin/VBox/Title"), "menu/settings");
|
||||
RegisterLabel(GetNode<Label>("Panel/Margin/VBox/Tabs/GeneralMargin/General/LangBox/LangLabel"), "settings/language");
|
||||
RegisterLabel(GetNode<Label>("Panel/Margin/VBox/Tabs/AudioMargin/Audio/SoundsBox/ToggleRow/SoundsLabel"), "settings/sounds");
|
||||
RegisterLabel(GetNode<Label>("Panel/Margin/VBox/Tabs/AudioMargin/Audio/SoundsBox/VolumeRow/SoundsVolumeLabel"), "settings/volume");
|
||||
RegisterLabel(GetNode<Label>("Panel/Margin/VBox/Tabs/AudioMargin/Audio/MusicBox/ToggleRow2/MusicLabel"), "settings/music");
|
||||
RegisterLabel(GetNode<Label>("Panel/Margin/VBox/Tabs/AudioMargin/Audio/MusicBox/VolumeRow2/MusicVolumeLabel"), "settings/volume");
|
||||
RegisterLabel(GetNode<Label>("Panel/Margin/VBox/Tabs/AudioMargin/Audio/AmbientBox/ToggleRow3/AmbientLabel"), "settings/ambient");
|
||||
RegisterLabel(GetNode<Label>("Panel/Margin/VBox/Tabs/AudioMargin/Audio/AmbientBox/VolumeRow3/AmbientVolumeLabel"), "settings/volume");
|
||||
RegisterLabel(GetNode<Label>("Panel/Margin/VBox/Tabs/DisplayMargin/Display/ModeBox/ModeLabel"), "settings/window_mode");
|
||||
RegisterLabel(GetNode<Label>("Panel/Margin/VBox/Tabs/DisplayMargin/Display/ResBox/ResLabel"), "settings/resolution");
|
||||
RegisterLabel(GetNode<Label>("Panel/Margin/VBox/Tabs/DisplayMargin/Display/MonitorBox/MonitorLabel"), "settings/monitor");
|
||||
RegisterLabel(GetNode<Label>("Panel/Margin/VBox/Tabs/DisplayMargin/Display/ScaleBox/ScaleLabel"), "settings/scale");
|
||||
RegisterLabel(GetNode<Label>("Panel/Margin/VBox/Tabs/DisplayMargin/Display/VSyncRow/VSyncLabel"), "settings/vsync");
|
||||
RegisterLabel(GetNode<Label>("Panel/Margin/VBox/Tabs/ModSettingsMargin/ModSettings/LeftPanel/ModsTitle"), "settings/mod_list_title");
|
||||
RegisterLabel(GetNode<Label>("Panel/Margin/VBox/Tabs/ModSettingsMargin/ModSettings/RightPanel/SettingsScroll/SettingsContainer/Placeholder"), "settings/mods_no_settings");
|
||||
|
||||
_soundsToggle.Toggled += OnSoundsToggled;
|
||||
_musicToggle.Toggled += OnMusicToggled;
|
||||
_ambientToggle.Toggled += OnAmbientToggled;
|
||||
_soundsVolume.ValueChanged += OnSoundsVolumeChanged;
|
||||
_musicVolume.ValueChanged += OnMusicVolumeChanged;
|
||||
_ambientVolume.ValueChanged += OnAmbientVolumeChanged;
|
||||
_langSelect.ItemSelected += OnLanguageSelected;
|
||||
_backBtn.Pressed += OnBackPressed;
|
||||
_backBtn.Text = TranslationServer.Translate("settings/back");
|
||||
|
||||
_windowModeSelect.ItemSelected += OnDisplaySettingChanged;
|
||||
_resolutionSelect.ItemSelected += OnDisplaySettingChanged;
|
||||
_monitorSelect.ItemSelected += OnDisplaySettingChanged;
|
||||
_scaleSelect.ItemSelected += OnDisplaySettingChanged;
|
||||
_vsyncToggle.Toggled += OnDisplayToggleChanged;
|
||||
_resetModSettingsBtn.Pressed += OnResetModSettings;
|
||||
|
||||
LoadSettingsToUI();
|
||||
BuildModList();
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
private void RegisterLabel(Label label, string key)
|
||||
{
|
||||
if (label != null)
|
||||
_labelKeys[label] = key;
|
||||
}
|
||||
|
||||
private void RefreshUIText()
|
||||
{
|
||||
foreach (var kv in _labelKeys)
|
||||
{
|
||||
if (IsInstanceValid(kv.Key))
|
||||
kv.Key.Text = TranslationServer.Translate(kv.Value);
|
||||
}
|
||||
_backBtn.Text = TranslationServer.Translate("settings/back");
|
||||
_tabs.SetTabTitle(0, TranslationServer.Translate("settings/tab_general"));
|
||||
_tabs.SetTabTitle(1, TranslationServer.Translate("settings/tab_audio"));
|
||||
_tabs.SetTabTitle(2, TranslationServer.Translate("settings/tab_display"));
|
||||
_tabs.SetTabTitle(3, TranslationServer.Translate("settings/tab_mods"));
|
||||
_resetModSettingsBtn.Text = TranslationServer.Translate("settings/mods_reset");
|
||||
PopulateLangOptions();
|
||||
PopulateDisplayOptions();
|
||||
}
|
||||
|
||||
private void LoadSettingsToUI()
|
||||
{
|
||||
var s = GameData.Instance?.Settings;
|
||||
if (s == null) return;
|
||||
|
||||
_soundsToggle.ButtonPressed = s.SoundsEnabled;
|
||||
_musicToggle.ButtonPressed = s.MusicsEnabled;
|
||||
_ambientToggle.ButtonPressed = s.AmbientEnabled;
|
||||
_soundsVolume.Value = s.SoundsVolume;
|
||||
_musicVolume.Value = s.MusicsVolume;
|
||||
_ambientVolume.Value = s.AmbientVolume;
|
||||
_soundsVolumeValue.Text = $"{s.SoundsVolume}%";
|
||||
_musicVolumeValue.Text = $"{s.MusicsVolume}%";
|
||||
_ambientVolumeValue.Text = $"{s.AmbientVolume}%";
|
||||
|
||||
_vsyncToggle.ButtonPressed = s.VsyncEnabled;
|
||||
_windowModeSelect.Selected = WindowModeToIndex(s.WindowMode);
|
||||
|
||||
UpdateSlidersEditable();
|
||||
PopulateLangOptions();
|
||||
PopulateDisplayOptions();
|
||||
RefreshUIText();
|
||||
|
||||
SelectCurrentResolution(s);
|
||||
SelectCurrentMonitor(s);
|
||||
SelectCurrentScale(s);
|
||||
}
|
||||
|
||||
private void PopulateLangOptions()
|
||||
{
|
||||
_langSelect.Clear();
|
||||
_langSelect.AddItem(TranslationServer.Translate("settings/lang_ru"));
|
||||
_langSelect.AddItem(TranslationServer.Translate("settings/lang_en"));
|
||||
_langSelect.AddItem(TranslationServer.Translate("settings/lang_zh"));
|
||||
|
||||
var s = GameData.Instance?.Settings;
|
||||
if (s == null) return;
|
||||
_langSelect.Selected = s.Language switch { "en" => 1, "zh" => 2, _ => 0 };
|
||||
}
|
||||
|
||||
private void PopulateDisplayOptions()
|
||||
{
|
||||
var previousWindow = _windowModeSelect.Selected;
|
||||
var previousRes = _resolutionSelect.Selected;
|
||||
var previousMon = _monitorSelect.Selected;
|
||||
var previousScale = _scaleSelect.Selected;
|
||||
|
||||
_windowModeSelect.Clear();
|
||||
_windowModeSelect.AddItem(TranslationServer.Translate("settings/window_mode_windowed"));
|
||||
_windowModeSelect.AddItem(TranslationServer.Translate("settings/window_mode_borderless"));
|
||||
_windowModeSelect.AddItem(TranslationServer.Translate("settings/window_mode_fullscreen"));
|
||||
|
||||
_resolutionSelect.Clear();
|
||||
foreach (var r in CommonResolutions)
|
||||
_resolutionSelect.AddItem($"{r.X}×{r.Y}");
|
||||
|
||||
_monitorSelect.Clear();
|
||||
var count = DisplayServer.GetScreenCount();
|
||||
if (count <= 0) count = 1;
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var size = DisplayServer.ScreenGetSize(i);
|
||||
var label = string.Format(TranslationServer.Translate("settings/monitor_screen"), i + 1, size.X, size.Y);
|
||||
_monitorSelect.AddItem(label);
|
||||
}
|
||||
|
||||
_scaleSelect.Clear();
|
||||
foreach (var sc in ScaleOptions)
|
||||
_scaleSelect.AddItem(TranslationServer.Translate($"settings/scale_{sc}x"));
|
||||
|
||||
_windowModeSelect.Selected = Mathf.Clamp(previousWindow, 0, 2);
|
||||
_resolutionSelect.Selected = Mathf.Clamp(previousRes, 0, CommonResolutions.Length - 1);
|
||||
_monitorSelect.Selected = Mathf.Clamp(previousMon, 0, count - 1);
|
||||
_scaleSelect.Selected = Mathf.Clamp(previousScale, 0, ScaleOptions.Length - 1);
|
||||
}
|
||||
|
||||
private void SelectCurrentResolution(SettingsData s)
|
||||
{
|
||||
for (var i = 0; i < CommonResolutions.Length; i++)
|
||||
{
|
||||
if (CommonResolutions[i].X == s.ResolutionWidth && CommonResolutions[i].Y == s.ResolutionHeight)
|
||||
{
|
||||
_resolutionSelect.Selected = i;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void SelectCurrentMonitor(SettingsData s)
|
||||
{
|
||||
var count = DisplayServer.GetScreenCount();
|
||||
if (s.CurrentScreen >= 0 && s.CurrentScreen < count)
|
||||
_monitorSelect.Selected = s.CurrentScreen;
|
||||
}
|
||||
|
||||
private void SelectCurrentScale(SettingsData s)
|
||||
{
|
||||
for (var i = 0; i < ScaleOptions.Length; i++)
|
||||
{
|
||||
if (ScaleOptions[i] == s.Scale)
|
||||
{
|
||||
_scaleSelect.Selected = i;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyAndSave()
|
||||
{
|
||||
if (!_initialized) return;
|
||||
var s = GameData.Instance?.Settings;
|
||||
if (s == null) return;
|
||||
|
||||
s.SoundsEnabled = _soundsToggle.ButtonPressed;
|
||||
s.MusicsEnabled = _musicToggle.ButtonPressed;
|
||||
s.AmbientEnabled = _ambientToggle.ButtonPressed;
|
||||
s.SoundsVolume = (int)_soundsVolume.Value;
|
||||
s.MusicsVolume = (int)_musicVolume.Value;
|
||||
s.AmbientVolume = (int)_ambientVolume.Value;
|
||||
s.Save();
|
||||
}
|
||||
|
||||
private void ApplyDisplayAndSave()
|
||||
{
|
||||
if (!_initialized) return;
|
||||
var s = GameData.Instance?.Settings;
|
||||
if (s == null) return;
|
||||
|
||||
var windowMode = IndexToWindowMode(_windowModeSelect.Selected);
|
||||
var resIdx = Mathf.Clamp(_resolutionSelect.Selected, 0, CommonResolutions.Length - 1);
|
||||
var resolution = CommonResolutions[resIdx];
|
||||
var monitorIdx = _monitorSelect.Selected;
|
||||
var scaleIdx = Mathf.Clamp(_scaleSelect.Selected, 0, ScaleOptions.Length - 1);
|
||||
var scale = ScaleOptions[scaleIdx];
|
||||
var vsync = _vsyncToggle.ButtonPressed;
|
||||
|
||||
if (monitorIdx >= 0 && monitorIdx < DisplayServer.GetScreenCount())
|
||||
DisplayServer.WindowSetCurrentScreen(monitorIdx);
|
||||
|
||||
DisplayServer.WindowSetMode(windowMode);
|
||||
|
||||
if (windowMode == DisplayServer.WindowMode.Windowed)
|
||||
DisplayServer.WindowSetSize(resolution);
|
||||
|
||||
DisplayServer.WindowSetVsyncMode(vsync
|
||||
? DisplayServer.VSyncMode.Enabled
|
||||
: DisplayServer.VSyncMode.Disabled);
|
||||
|
||||
ProjectSettings.SetSetting("display/window/stretch/scale", (float)scale);
|
||||
|
||||
s.WindowMode = (int)windowMode;
|
||||
s.ResolutionWidth = resolution.X;
|
||||
s.ResolutionHeight = resolution.Y;
|
||||
s.CurrentScreen = monitorIdx;
|
||||
s.VsyncEnabled = vsync;
|
||||
s.Scale = scale;
|
||||
s.Save();
|
||||
}
|
||||
|
||||
private void OnSoundsToggled(bool on) { UpdateSlidersEditable(); ApplyAndSave(); }
|
||||
private void OnMusicToggled(bool on) { UpdateSlidersEditable(); ApplyAndSave(); }
|
||||
private void OnAmbientToggled(bool on) { UpdateSlidersEditable(); ApplyAndSave(); }
|
||||
|
||||
private void OnSoundsVolumeChanged(double v) { _soundsVolumeValue.Text = $"{(int)v}%"; ApplyAndSave(); }
|
||||
private void OnMusicVolumeChanged(double v) { _musicVolumeValue.Text = $"{(int)v}%"; ApplyAndSave(); }
|
||||
private void OnAmbientVolumeChanged(double v) { _ambientVolumeValue.Text = $"{(int)v}%"; ApplyAndSave(); }
|
||||
|
||||
private void OnDisplaySettingChanged(long idx) => ApplyDisplayAndSave();
|
||||
private void OnDisplayToggleChanged(bool on) => ApplyDisplayAndSave();
|
||||
|
||||
private void OnLanguageSelected(long idx)
|
||||
{
|
||||
if (!_initialized) return;
|
||||
var s = GameData.Instance?.Settings;
|
||||
if (s == null) return;
|
||||
|
||||
var lang = idx switch { 1 => "en", 2 => "zh", _ => "ru-ru" };
|
||||
if (s.Language == lang) return;
|
||||
|
||||
s.Language = lang;
|
||||
s.Save();
|
||||
LocaleLoader.LoadCurrentLanguage();
|
||||
RefreshUIText();
|
||||
LanguageChanged?.Invoke();
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="IMenuPanel" />
|
||||
public void Open() => Visible = true;
|
||||
|
||||
private void OnBackPressed() => Visible = false;
|
||||
|
||||
private void UpdateSlidersEditable()
|
||||
{
|
||||
_soundsVolume.Editable = _soundsToggle.ButtonPressed;
|
||||
_musicVolume.Editable = _musicToggle.ButtonPressed;
|
||||
_ambientVolume.Editable = _ambientToggle.ButtonPressed;
|
||||
}
|
||||
|
||||
private static int WindowModeToIndex(int mode)
|
||||
{
|
||||
return mode switch
|
||||
{
|
||||
3 => 1,
|
||||
4 => 2,
|
||||
_ => 0,
|
||||
};
|
||||
}
|
||||
|
||||
private static DisplayServer.WindowMode IndexToWindowMode(int index)
|
||||
{
|
||||
return index switch
|
||||
{
|
||||
1 => DisplayServer.WindowMode.Fullscreen,
|
||||
2 => DisplayServer.WindowMode.ExclusiveFullscreen,
|
||||
_ => DisplayServer.WindowMode.Windowed,
|
||||
};
|
||||
}
|
||||
|
||||
private void BuildModList()
|
||||
{
|
||||
foreach (var child in _modListContainer.GetChildren())
|
||||
child.QueueFree();
|
||||
|
||||
var modsWithSettings = ModSettingsRegistry.GetModsWithSettings();
|
||||
|
||||
if (modsWithSettings.Count == 0)
|
||||
return;
|
||||
|
||||
foreach (var modId in modsWithSettings)
|
||||
{
|
||||
var btn = new Button
|
||||
{
|
||||
Text = ModSettingsRegistry.GetDisplayTitle(modId),
|
||||
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
|
||||
};
|
||||
btn.Pressed += () => OnModSelected(modId);
|
||||
_modListContainer.AddChild(btn);
|
||||
}
|
||||
|
||||
var firstMod = modsWithSettings[0];
|
||||
OnModSelected(firstMod);
|
||||
}
|
||||
|
||||
private void OnModSelected(string modId)
|
||||
{
|
||||
_selectedModId = modId;
|
||||
_selectedModName.Text = ModSettingsRegistry.GetDisplayTitle(modId);
|
||||
BuildModSettingsPanel(modId);
|
||||
}
|
||||
|
||||
private void BuildModSettingsPanel(string modId)
|
||||
{
|
||||
_controlKeys.Clear();
|
||||
|
||||
foreach (var child in _settingsContainer.GetChildren())
|
||||
child.QueueFree();
|
||||
|
||||
_settingsContainer.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
||||
|
||||
var definitions = ModSettingsRegistry.GetDefinitions(modId);
|
||||
if (definitions.Count == 0)
|
||||
{
|
||||
var placeholder = new Label
|
||||
{
|
||||
Text = TranslationServer.Translate("settings/mods_no_settings"),
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
};
|
||||
_settingsContainer.AddChild(placeholder);
|
||||
return;
|
||||
}
|
||||
|
||||
var currentBlob = ModSettingsRegistry.WriteCurrentValues(modId);
|
||||
|
||||
foreach (var def in definitions)
|
||||
{
|
||||
var row = new HBoxContainer
|
||||
{
|
||||
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
|
||||
CustomMinimumSize = new Vector2(0, 40),
|
||||
};
|
||||
row.AddThemeConstantOverride("separation", 12);
|
||||
|
||||
var label = new Label
|
||||
{
|
||||
Text = TranslationServer.Translate(def.Name),
|
||||
CustomMinimumSize = new Vector2(180, 0),
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
};
|
||||
row.AddChild(label);
|
||||
|
||||
if (def.Type == SettingType.Action)
|
||||
{
|
||||
AddActionControls(row, modId, def);
|
||||
_settingsContainer.AddChild(row);
|
||||
continue;
|
||||
}
|
||||
|
||||
Control control = CreateControlForType(def);
|
||||
ApplyControlValue(control, def, currentBlob);
|
||||
_controlKeys[control] = def.Key;
|
||||
|
||||
switch (def.Type)
|
||||
{
|
||||
case SettingType.Bool:
|
||||
if (control is CheckButton checkbox)
|
||||
checkbox.Toggled += _ => OnModSettingChanged();
|
||||
break;
|
||||
case SettingType.Integer:
|
||||
case SettingType.Float:
|
||||
if (control is SpinBox spin)
|
||||
spin.ValueChanged += _ => OnModSettingChanged();
|
||||
break;
|
||||
case SettingType.String:
|
||||
if (control is LineEdit lineEdit)
|
||||
lineEdit.TextChanged += _ => OnModSettingChanged();
|
||||
break;
|
||||
case SettingType.Enum:
|
||||
if (control is OptionButton option)
|
||||
option.ItemSelected += _ => OnModSettingChanged();
|
||||
break;
|
||||
case SettingType.Slider:
|
||||
if (control is HSlider slider)
|
||||
{
|
||||
slider.ValueChanged += _ =>
|
||||
{
|
||||
var val = (double)slider.GetMeta("step", 1.0).AsDouble();
|
||||
var step = val;
|
||||
var stepped = Mathf.Round(slider.Value / step) * step;
|
||||
slider.Value = stepped;
|
||||
OnModSettingChanged();
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
row.AddChild(control);
|
||||
|
||||
if (def.Type == SettingType.Slider)
|
||||
{
|
||||
var valLabel = new Label
|
||||
{
|
||||
Text = FormatSliderValue(currentBlob, def),
|
||||
CustomMinimumSize = new Vector2(48, 0),
|
||||
HorizontalAlignment = HorizontalAlignment.Right,
|
||||
};
|
||||
row.AddChild(valLabel);
|
||||
}
|
||||
|
||||
_settingsContainer.AddChild(row);
|
||||
}
|
||||
|
||||
var timer = new Timer { OneShot = true, Autostart = true, WaitTime = 0.01 };
|
||||
timer.Timeout += () =>
|
||||
{
|
||||
timer.QueueFree();
|
||||
LogModSettingsSizes();
|
||||
};
|
||||
_settingsContainer.AddChild(timer);
|
||||
}
|
||||
|
||||
private void LogModSettingsSizes()
|
||||
{
|
||||
if (_settingsContainer == null)
|
||||
return;
|
||||
|
||||
GameLogger.Log("MODS_SETTINGS_UI", $"SettingsContainer size: {_settingsContainer.Size}", LogLevel.Debug);
|
||||
foreach (var child in _settingsContainer.GetChildren())
|
||||
{
|
||||
if (child is HBoxContainer row)
|
||||
{
|
||||
GameLogger.Log("MODS_SETTINGS_UI", $" Row size: {row.Size}", LogLevel.Debug);
|
||||
foreach (var rowChild in row.GetChildren())
|
||||
{
|
||||
if (rowChild is HSlider slider)
|
||||
GameLogger.Log("MODS_SETTINGS_UI", $" HSlider size: {slider.Size}, min_size: {slider.CustomMinimumSize}", LogLevel.Debug);
|
||||
else if (rowChild is Control c)
|
||||
GameLogger.Log("MODS_SETTINGS_UI", $" {rowChild.GetType().Name} size: {c.Size}", LogLevel.Debug);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Action setting: a button (with an optional confirmation) that runs
|
||||
/// <see cref="ModSettingsRegistry.RunAction"/> and shows the result next
|
||||
/// to it. Actions store no value, so they stay out of <c>_controlKeys</c>.
|
||||
/// </summary>
|
||||
private void AddActionControls(HBoxContainer row, string modId, ModSettingDefinition def)
|
||||
{
|
||||
var button = new Button { Text = TranslationServer.Translate(def.ActionText ?? def.Name) };
|
||||
var result = new Label { VerticalAlignment = VerticalAlignment.Center, SizeFlagsHorizontal = Control.SizeFlags.ExpandFill };
|
||||
|
||||
void Run() => result.Text = TranslationServer.Translate(ModSettingsRegistry.RunAction(modId, def.Key) ?? string.Empty);
|
||||
|
||||
button.Pressed += () =>
|
||||
{
|
||||
if (string.IsNullOrEmpty(def.ConfirmText))
|
||||
{
|
||||
Run();
|
||||
return;
|
||||
}
|
||||
|
||||
var dialog = new ConfirmationDialog
|
||||
{
|
||||
DialogText = TranslationServer.Translate(def.ConfirmText),
|
||||
Exclusive = true,
|
||||
};
|
||||
dialog.Confirmed += () =>
|
||||
{
|
||||
Run();
|
||||
dialog.QueueFree();
|
||||
};
|
||||
dialog.Canceled += dialog.QueueFree;
|
||||
AddChild(dialog);
|
||||
dialog.PopupCentered();
|
||||
};
|
||||
|
||||
row.AddChild(button);
|
||||
row.AddChild(result);
|
||||
}
|
||||
|
||||
private static Control CreateControlForType(ModSettingDefinition def)
|
||||
{
|
||||
switch (def.Type)
|
||||
{
|
||||
case SettingType.Bool:
|
||||
return new CheckButton();
|
||||
case SettingType.Integer:
|
||||
return new SpinBox
|
||||
{
|
||||
AllowGreater = true,
|
||||
AllowLesser = true,
|
||||
Step = 1.0,
|
||||
MinValue = int.MinValue,
|
||||
MaxValue = int.MaxValue,
|
||||
};
|
||||
case SettingType.Float:
|
||||
return new SpinBox
|
||||
{
|
||||
AllowGreater = true,
|
||||
AllowLesser = true,
|
||||
Step = 0.1,
|
||||
MinValue = double.MinValue,
|
||||
MaxValue = double.MaxValue,
|
||||
};
|
||||
case SettingType.String:
|
||||
return new LineEdit();
|
||||
case SettingType.Enum:
|
||||
{
|
||||
var opt = new OptionButton();
|
||||
if (def.Options != null)
|
||||
{
|
||||
foreach (var kv in def.Options)
|
||||
opt.AddItem(TranslationServer.Translate(kv.Value));
|
||||
}
|
||||
return opt;
|
||||
}
|
||||
case SettingType.Slider:
|
||||
{
|
||||
var slider = new HSlider
|
||||
{
|
||||
MinValue = def.Min,
|
||||
MaxValue = def.Max,
|
||||
Step = 0.01,
|
||||
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
|
||||
SizeFlagsVertical = Control.SizeFlags.ExpandFill,
|
||||
};
|
||||
slider.SetMeta("step", Variant.From(def.Step > 0 ? def.Step : 1.0));
|
||||
return slider;
|
||||
}
|
||||
default:
|
||||
return new HBoxContainer();
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyControlValue(Control control, ModSettingDefinition def, DataBlob blob)
|
||||
{
|
||||
switch (def.Type)
|
||||
{
|
||||
case SettingType.Bool:
|
||||
if (control is CheckButton checkbox)
|
||||
checkbox.ButtonPressed = blob.GetString(def.Key, def.DefaultValue ?? "false").ToLowerInvariant() == "true";
|
||||
break;
|
||||
case SettingType.Integer:
|
||||
if (control is SpinBox spin)
|
||||
{
|
||||
var defVal = int.TryParse(blob.GetString(def.Key, def.DefaultValue ?? "0"), out var parsed) ? parsed : 0;
|
||||
spin.Value = blob.GetInt(def.Key, defVal);
|
||||
}
|
||||
break;
|
||||
case SettingType.Float:
|
||||
if (control is SpinBox spinFloat)
|
||||
{
|
||||
var defVal = float.TryParse(blob.GetString(def.Key, def.DefaultValue ?? "0"), out var parsed) ? parsed : 0f;
|
||||
spinFloat.Value = blob.GetFloat(def.Key, defVal);
|
||||
}
|
||||
break;
|
||||
case SettingType.String:
|
||||
if (control is LineEdit lineEdit)
|
||||
lineEdit.Text = blob.GetString(def.Key, def.DefaultValue ?? "");
|
||||
break;
|
||||
case SettingType.Enum:
|
||||
if (control is OptionButton opt)
|
||||
{
|
||||
var current = blob.GetString(def.Key, def.DefaultValue ?? "");
|
||||
opt.Selected = GetOptionIndex(def, current);
|
||||
}
|
||||
break;
|
||||
case SettingType.Slider:
|
||||
if (control is HSlider slider)
|
||||
{
|
||||
var defVal = float.TryParse(blob.GetString(def.Key, def.DefaultValue ?? "0"), out var parsed) ? parsed : 0f;
|
||||
slider.Value = blob.GetFloat(def.Key, defVal);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ReadControlValue(Control control, SettingType type, ModSettingDefinition def)
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case SettingType.Bool:
|
||||
return control is CheckButton checkbox ? checkbox.ButtonPressed.ToString().ToLowerInvariant() : "false";
|
||||
case SettingType.Integer:
|
||||
return control is SpinBox spin ? ((int)spin.Value).ToString() : "0";
|
||||
case SettingType.Float:
|
||||
return control is SpinBox spinFloat ? spinFloat.Value.ToString("F4") : "0";
|
||||
case SettingType.String:
|
||||
return control is LineEdit lineEdit ? lineEdit.Text : "";
|
||||
case SettingType.Enum:
|
||||
if (control is OptionButton opt && def.Options != null && opt.Selected >= 0 && opt.Selected < def.Options.Count)
|
||||
{
|
||||
var idx = 0;
|
||||
foreach (var kv in def.Options)
|
||||
{
|
||||
if (idx == opt.Selected)
|
||||
return kv.Key;
|
||||
idx++;
|
||||
}
|
||||
}
|
||||
return def.DefaultValue ?? "";
|
||||
case SettingType.Slider:
|
||||
return control is HSlider slider ? slider.Value.ToString("F4") : "0";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatSliderValue(DataBlob blob, ModSettingDefinition def)
|
||||
{
|
||||
var value = blob.GetFloat(def.Key, float.TryParse(def.DefaultValue, out var d) ? d : 0f);
|
||||
var step = def.Step > 0 ? def.Step : 1f;
|
||||
if (step >= 1f)
|
||||
return $"{(int)value}";
|
||||
return $"{value:F2}";
|
||||
}
|
||||
|
||||
private static int GetOptionIndex(ModSettingDefinition def, string key)
|
||||
{
|
||||
if (def.Options == null)
|
||||
return 0;
|
||||
var idx = 0;
|
||||
foreach (var kv in def.Options)
|
||||
{
|
||||
if (kv.Key == key)
|
||||
return idx;
|
||||
idx++;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private void OnModSettingChanged()
|
||||
{
|
||||
if (!_initialized || string.IsNullOrEmpty(_selectedModId))
|
||||
return;
|
||||
|
||||
var blob = new DataBlob();
|
||||
var definitions = ModSettingsRegistry.GetDefinitions(_selectedModId);
|
||||
|
||||
foreach (var def in definitions)
|
||||
{
|
||||
var control = FindControlForKey(def.Key);
|
||||
if (control == null)
|
||||
continue;
|
||||
var value = ReadControlValue(control, def.Type, def);
|
||||
blob.SetString(def.Key, value);
|
||||
|
||||
if (def.Type == SettingType.Slider)
|
||||
UpdateSliderValueLabel(control);
|
||||
}
|
||||
|
||||
ModSettingsRegistry.SaveModSettings(_selectedModId, blob);
|
||||
ModSettingsRegistry.ApplyValues(_selectedModId, blob);
|
||||
}
|
||||
|
||||
private Control FindControlForKey(string key)
|
||||
{
|
||||
foreach (var kv in _controlKeys)
|
||||
{
|
||||
if (kv.Value == key)
|
||||
return kv.Key;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void UpdateSliderValueLabel(Control slider)
|
||||
{
|
||||
if (!(slider is HSlider hslider))
|
||||
return;
|
||||
|
||||
var parent = slider.GetParent();
|
||||
if (parent == null)
|
||||
return;
|
||||
|
||||
var lastChild = parent.GetChild(parent.GetChildCount() - 1);
|
||||
if (lastChild is Label valLabel)
|
||||
{
|
||||
var step = (double)slider.GetMeta("step", 1.0).AsDouble();
|
||||
if (step >= 1.0)
|
||||
valLabel.Text = $"{(int)hslider.Value}";
|
||||
else
|
||||
valLabel.Text = $"{hslider.Value:F2}";
|
||||
}
|
||||
}
|
||||
|
||||
private void OnResetModSettings()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_selectedModId))
|
||||
return;
|
||||
|
||||
ModSettingsRegistry.DeleteModSettings(_selectedModId);
|
||||
BuildModSettingsPanel(_selectedModId);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user