(Feat) Add Remmaping

This commit is contained in:
2026-06-26 16:23:20 +02:00
parent 931bc9c9e2
commit 7bd2aa3120
249 changed files with 23712 additions and 6898 deletions
@@ -0,0 +1,102 @@
using System.Collections.Generic;
using UnityEngine;
namespace Ashwild.Player
{
/// <summary>
/// One rebindable entry: the action it targets, the binding index within that action, and the
/// human label shown in the row. Composite parts (Move up/down/left/right) are four separate
/// entries, each pointing at its own part-binding index.
/// </summary>
[System.Serializable]
public struct RebindableEntry
{
[SerializeField] private string actionName;
[SerializeField] private int bindingIndex;
[SerializeField] private string displayLabel;
public string ActionName => actionName;
public int BindingIndex => bindingIndex;
public string DisplayLabel => displayLabel;
}
/// <summary>
/// One glyph: the layout-aware display name it matches (case-insensitive) and its sprite.
/// </summary>
[System.Serializable]
public struct KeyIcon
{
[SerializeField] private string displayName;
[SerializeField] private Sprite icon;
public string DisplayName => displayName;
public Sprite Icon => icon;
}
/// <summary>
/// One authored binding override: which binding of which action to re-path, and to what control
/// path. Used only for deliberate letter-mnemonic exceptions per layout.
/// </summary>
[System.Serializable]
public struct LayoutOverride
{
[SerializeField] private string actionName;
[SerializeField] private int bindingIndex;
[SerializeField] private string overridePath;
public string ActionName => actionName;
public int BindingIndex => bindingIndex;
public string OverridePath => overridePath;
}
/// <summary>
/// Single authored asset for everything the input UI needs: which bindings are rebindable (and
/// their labels), the key glyphs to show instead of text, and the optional first-launch layout
/// overrides per keyboard layout. One asset keeps all input configuration in one place — the
/// settings panel reads the bindings/icons, the SettingsManager reads the layout overrides.
/// Look is intentionally absent (mouse-only) and gamepad bindings are absent (keyboard only).
/// </summary>
[CreateAssetMenu(fileName = "InputConfig", menuName = "Input/Input Config")]
public class InputConfig : ScriptableObject
{
#region Serialized Fields
[Header("Rebindable bindings (shown in the panel, in order)")]
[SerializeField] private List<RebindableEntry> bindings = new List<RebindableEntry>();
[Header("Key glyphs (display name → sprite; missing falls back to text)")]
[SerializeField] private List<KeyIcon> icons = new List<KeyIcon>();
[Header("First-launch layout overrides (usually empty — physical paths already fit)")]
[SerializeField] private List<LayoutOverride> qwertyOverrides = new List<LayoutOverride>();
[SerializeField] private List<LayoutOverride> azertyOverrides = new List<LayoutOverride>();
#endregion
#region Public API
public IReadOnlyList<RebindableEntry> Bindings => bindings;
/// <summary>
/// Returns the glyph for a display name, or null when none is authored (caller shows text).
/// </summary>
public Sprite GetIcon(string displayName)
{
if (string.IsNullOrEmpty(displayName)) return null;
foreach (KeyIcon entry in icons)
if (string.Equals(entry.DisplayName, displayName, System.StringComparison.OrdinalIgnoreCase))
return entry.Icon;
return null;
}
/// <summary>
/// The first-launch overrides authored for a detected layout (usually empty).
/// </summary>
public IReadOnlyList<LayoutOverride> OverridesFor(KeyboardLayout layout) =>
layout == KeyboardLayout.Azerty ? azertyOverrides : qwertyOverrides;
#endregion
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 418d0aaab949b0b4b9a44aefc6c6ea07
+289 -2
View File
@@ -9,8 +9,11 @@ namespace Ashwild.Player
/// <summary>
/// The single, persistent source of input. It reads the project InputActionAsset and republishes
/// every action onto the static PlayerEvents bus, so gameplay and UI both consume input the same
/// way without depending on a player GameObject. Persists across scenes via its PersistentRoot
/// parent, so it works in every scene. Replaces the old PlayerInput component + PlayerInputRouter.
/// way without depending on a player GameObject. Persists across scenes, so it works everywhere.
///
/// It also owns the binding *mechanism* — interactive rebinds, resets and applying overrides on
/// the shared asset — but never touches disk: persistence (and the layout-profile decision) lives
/// in the SettingsManager, which calls this manager to apply and reads back the overrides JSON.
///
/// The UI map (Cancel) is always active; the Player map is only active in-game (between
/// LocalPlayerSpawned and SessionStopped) so menu typing never triggers gameplay actions.
@@ -25,6 +28,38 @@ namespace Ashwild.Player
#endregion
#region Types
/// <summary>
/// Result of an interactive rebind: applied cleanly, cancelled with no change, or the chosen
/// control already drives another binding in the same map (a conflict the UI must resolve).
/// </summary>
public enum RebindResult
{
Completed,
Cancelled,
Conflict
}
/// <summary>
/// Everything the UI needs to resolve a rebind: what was rebound (its old override/path, to
/// revert or swap) and, on a conflict, which other action/binding already uses the chosen key.
/// On a conflict the new binding is left applied — the UI then calls <see cref="ApplySwap"/>
/// (give the other action our old key) or <see cref="RevertRebind"/> (undo ours).
/// </summary>
public struct RebindOutcome
{
public RebindResult Result;
public string ActionName;
public int BindingIndex;
public string PreviousOverride;
public string PreviousPath;
public string ConflictActionName;
public int ConflictBindingIndex;
}
#endregion
#region Serialized Fields
[Header("Input")]
@@ -40,9 +75,20 @@ namespace Ashwild.Player
/// </summary>
public static InputManager Instance { get; private set; }
/// <summary>
/// The project InputActionAsset this manager reads. Exposed so the settings layer can read
/// binding display strings; the manager itself only consumes it for runtime input.
/// </summary>
public InputActionAsset Actions => actions;
private InputActionMap playerMap;
private InputActionMap uiMap;
/// <summary>
/// The interactive rebind in flight, kept so a new rebind or a panel close can cancel it.
/// </summary>
private InputActionRebindingExtensions.RebindingOperation rebindOperation;
/// <summary>
/// Undo callbacks recorded while wiring actions, invoked on teardown.
/// </summary>
@@ -108,6 +154,9 @@ namespace Ashwild.Player
{
if (Instance == this) Instance = null;
rebindOperation?.Dispose();
rebindOperation = null;
foreach (Action undo in teardown) undo();
teardown.Clear();
@@ -248,5 +297,243 @@ namespace Ashwild.Player
}
#endregion
#region Rebinding API
/// <summary>
/// The layout-aware display string for one binding (what a keybind row shows). On AZERTY this
/// returns ZQSD automatically because Unity resolves physical paths through the OS layout.
/// </summary>
public string GetBindingDisplayString(string actionName, int bindingIndex)
{
InputAction action = actions.FindAction(actionName, false);
if (action == null)
{
Debug.LogWarning($"[InputManager] Cannot display binding — action '{actionName}' not found.", this);
return string.Empty;
}
return action.GetBindingDisplayString(bindingIndex);
}
/// <summary>
/// The resolved control path of one binding (e.g. "leftShift", "leftButton", "w") — a stable,
/// layout-independent key the UI uses to find a glyph when the display string has no icon.
/// </summary>
public string GetBindingControlPath(string actionName, int bindingIndex)
{
InputAction action = actions.FindAction(actionName, false);
if (action == null) return string.Empty;
action.GetBindingDisplayString(bindingIndex, out _, out string controlPath);
return controlPath ?? string.Empty;
}
/// <summary>
/// Starts an interactive rebind for one binding. Disables the action for the duration
/// (required by the Input System), rejects a control already bound elsewhere in the same map
/// (duplicate), and reports the outcome via the callback. Does not persist — the caller
/// (settings panel) saves through the SettingsManager. The action's prior enabled state is
/// restored so a menu rebind never enables gameplay input.
/// </summary>
public void StartInteractiveRebind(string actionName, int bindingIndex, Action<RebindOutcome> onOutcome)
{
InputAction action = actions.FindAction(actionName, false);
if (action == null)
{
Debug.LogError($"[InputManager] Cannot rebind — action '{actionName}' not found.", this);
onOutcome?.Invoke(new RebindOutcome { Result = RebindResult.Cancelled, ActionName = actionName, BindingIndex = bindingIndex });
return;
}
CancelOngoingRebind();
string previousOverride = action.bindings[bindingIndex].overridePath;
string previousPath = action.bindings[bindingIndex].effectivePath;
bool wasEnabled = action.enabled;
action.Disable();
rebindOperation = action.PerformInteractiveRebinding(bindingIndex)
.WithControlsExcluding("<Mouse>/position")
.WithControlsExcluding("<Mouse>/delta")
.WithCancelingThrough("<Keyboard>/escape")
.OnCancel(_ => EndRebind(action, wasEnabled, onOutcome, new RebindOutcome
{
Result = RebindResult.Cancelled, ActionName = actionName, BindingIndex = bindingIndex,
PreviousOverride = previousOverride, PreviousPath = previousPath
}))
.OnComplete(_ => CompleteRebind(action, actionName, bindingIndex, previousOverride, previousPath, wasEnabled, onOutcome))
.Start();
}
/// <summary>
/// Cancels the rebind in flight (e.g. the panel was closed mid-capture), restoring state via
/// its cancel callback. No-op when nothing is running.
/// </summary>
public void CancelOngoingRebind() => rebindOperation?.Cancel();
/// <summary>
/// Restores one binding to its asset default by removing its override. Does not persist —
/// the caller saves through the SettingsManager.
/// </summary>
public void ResetBinding(string actionName, int bindingIndex)
{
InputAction action = actions.FindAction(actionName, false);
if (action == null)
{
Debug.LogError($"[InputManager] Cannot reset — action '{actionName}' not found.", this);
return;
}
action.RemoveBindingOverride(bindingIndex);
}
/// <summary>
/// Clears every binding override on the asset (back to authored defaults). Does not persist.
/// </summary>
public void ResetAllBindings() => actions.RemoveAllBindingOverrides();
/// <summary>
/// Resolves a conflict by SWAPPING the two keys: the new binding keeps the chosen key (already
/// applied) and the other action receives the key this one used before. Does not persist.
/// </summary>
public void ApplySwap(RebindOutcome outcome)
{
InputAction conflict = actions.FindAction(outcome.ConflictActionName, false);
if (conflict != null) conflict.ApplyBindingOverride(outcome.ConflictBindingIndex, outcome.PreviousPath);
}
/// <summary>
/// Undoes a rebind, restoring the binding to the override it had before (used to cancel a
/// conflict, leaving the other action untouched). Does not persist.
/// </summary>
public void RevertRebind(RebindOutcome outcome)
{
InputAction action = actions.FindAction(outcome.ActionName, false);
if (action == null) return;
if (string.IsNullOrEmpty(outcome.PreviousOverride))
action.RemoveBindingOverride(outcome.BindingIndex);
else
action.ApplyBindingOverride(outcome.BindingIndex, outcome.PreviousOverride);
}
#endregion
#region Rebinding Internals
/// <summary>
/// Resolves a completed capture: leaves the new binding applied and reports either a clean
/// completion or a conflict (with the other action that already uses the key) for the UI.
/// </summary>
private void CompleteRebind(InputAction action, string actionName, int bindingIndex, string previousOverride, string previousPath, bool wasEnabled, Action<RebindOutcome> onOutcome)
{
DisposeAndRestore(action, wasEnabled);
string newPath = action.bindings[bindingIndex].effectivePath;
if (FindConflict(action, bindingIndex, newPath, out string conflictAction, out int conflictIndex))
{
onOutcome?.Invoke(new RebindOutcome
{
Result = RebindResult.Conflict,
ActionName = actionName, BindingIndex = bindingIndex,
PreviousOverride = previousOverride, PreviousPath = previousPath,
ConflictActionName = conflictAction, ConflictBindingIndex = conflictIndex
});
return;
}
onOutcome?.Invoke(new RebindOutcome
{
Result = RebindResult.Completed, ActionName = actionName, BindingIndex = bindingIndex,
PreviousOverride = previousOverride, PreviousPath = previousPath
});
}
/// <summary>
/// Disposes the operation, restores the action's prior enabled state and reports the outcome.
/// </summary>
private void EndRebind(InputAction action, bool wasEnabled, Action<RebindOutcome> onOutcome, RebindOutcome outcome)
{
DisposeAndRestore(action, wasEnabled);
onOutcome?.Invoke(outcome);
}
/// <summary>
/// Disposes the rebind operation and restores the action's prior enabled state.
/// </summary>
private void DisposeAndRestore(InputAction action, bool wasEnabled)
{
rebindOperation?.Dispose();
rebindOperation = null;
if (wasEnabled) action.Enable();
}
/// <summary>
/// Finds the first other non-composite binding in the same map that resolves to the chosen
/// control, returning its action name and binding index within that action.
/// </summary>
private bool FindConflict(InputAction action, int bindingIndex, string newPath, out string conflictActionName, out int conflictBindingIndex)
{
conflictActionName = null;
conflictBindingIndex = -1;
if (string.IsNullOrEmpty(newPath)) return false;
Guid editedId = action.bindings[bindingIndex].id;
foreach (InputAction other in action.actionMap.actions)
{
var bindings = other.bindings;
for (int i = 0; i < bindings.Count; i++)
{
InputBinding b = bindings[i];
if (b.isComposite) continue;
if (b.id == editedId) continue;
if (b.effectivePath == newPath)
{
conflictActionName = other.name;
conflictBindingIndex = i;
return true;
}
}
}
return false;
}
#endregion
#region Overrides (no disk I/O)
/// <summary>
/// Applies a layout's authored overrides onto the shared asset. Returns true when at least
/// one override was applied, so the caller knows there is something to persist. Usually a
/// no-op: physical paths already fit every layout (a hook for letter-mnemonic exceptions).
/// </summary>
public bool ApplyLayoutOverrides(IReadOnlyList<LayoutOverride> overrides)
{
if (overrides == null || overrides.Count == 0) return false;
foreach (LayoutOverride o in overrides)
{
InputAction action = actions.FindAction(o.ActionName, false);
if (action == null)
{
Debug.LogWarning($"[InputManager] Layout profile references unknown action '{o.ActionName}'.", this);
continue;
}
action.ApplyBindingOverride(o.BindingIndex, o.OverridePath);
}
return true;
}
/// <summary>
/// The current binding overrides as a portable JSON blob, for the SettingsManager to persist.
/// </summary>
public string GetOverridesJson() => actions.SaveBindingOverridesAsJson();
/// <summary>
/// Applies previously-saved binding overrides (JSON) onto the shared asset.
/// </summary>
public void LoadOverridesJson(string json)
{
if (!string.IsNullOrEmpty(json)) actions.LoadBindingOverridesFromJson(json);
}
#endregion
}
}
@@ -0,0 +1,72 @@
using UnityEngine.InputSystem;
using UnityEngine.InputSystem.Controls;
namespace Ashwild.Player
{
/// <summary>
/// The physical keyboard layouts we tell apart at first launch. Unknown means no keyboard was
/// present or probing was inconclusive — callers treat it like QWERTY (apply no profile).
/// </summary>
public enum KeyboardLayout
{
Unknown,
Qwerty,
Azerty
}
/// <summary>
/// Classifies the current OS keyboard layout by probing <see cref="Keyboard.current"/>. The
/// primary signal is the layout-aware display name of the physical Q/W keys (AZERTY labels them
/// A/Z); the OS-reported layout string is a fallback. Pure and side-effect-free — the caller
/// decides what to do with the result.
/// </summary>
public static class KeyboardLayoutDetector
{
#region Public API
/// <summary>
/// Returns the detected layout, or <see cref="KeyboardLayout.Unknown"/> when no keyboard is
/// connected or the probes disagree with nothing conclusive.
/// </summary>
public static KeyboardLayout Detect()
{
Keyboard keyboard = Keyboard.current;
if (keyboard == null) return KeyboardLayout.Unknown;
string q = Label(keyboard[Key.Q]);
string w = Label(keyboard[Key.W]);
if (q == "a" || w == "z") return KeyboardLayout.Azerty;
if (q == "q" || w == "w") return KeyboardLayout.Qwerty;
return FromLayoutString(keyboard.keyboardLayout);
}
#endregion
#region Internal Helpers
/// <summary>
/// The lower-cased, trimmed display name of a key control, or empty when unavailable.
/// </summary>
private static string Label(KeyControl key)
{
if (key == null) return string.Empty;
string display = key.displayName;
return string.IsNullOrEmpty(display) ? string.Empty : display.Trim().ToLowerInvariant();
}
/// <summary>
/// Last-resort classification from the OS layout name (e.g. "French" / "AZERTY").
/// </summary>
private static KeyboardLayout FromLayoutString(string layout)
{
if (string.IsNullOrEmpty(layout)) return KeyboardLayout.Unknown;
string l = layout.ToLowerInvariant();
if (l.Contains("azerty") || l.Contains("french") || l.Contains("belg")) return KeyboardLayout.Azerty;
return KeyboardLayout.Unknown;
}
#endregion
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6cd8469d76a2ff646885bb09cdb8a635
@@ -0,0 +1,132 @@
using System.Collections.Generic;
using UnityEngine;
namespace Ashwild.Settings
{
/// <summary>
/// Authored data for a float setting driven by a <see cref="SettingSliderWidget"/>: its default
/// value, clamp range, and the label presentation hints the widget needs.
/// </summary>
[System.Serializable]
public struct FloatSetting
{
[SerializeField] private float defaultValue;
[SerializeField] private float min;
[SerializeField] private float max;
[Tooltip("Value is multiplied by this before formatting (e.g. 100 to show a 01 value as a percentage).")]
[SerializeField] private float labelScale;
[Tooltip("Composite format applied to the scaled value, e.g. \"{0:F0}%\" or \"{0:F0}°\".")]
[SerializeField] private string labelFormat;
public float DefaultValue => defaultValue;
public float Min => min;
public float Max => max;
public float LabelScale => labelScale;
public string LabelFormat => labelFormat;
}
/// <summary>
/// One stepper entry: a value plus the text shown for it. Mirrors the (value, label) pair the
/// <see cref="SettingStepperWidget"/> consumes.
/// </summary>
[System.Serializable]
public struct StepperPreset
{
[SerializeField] private int value;
[SerializeField] private string label;
public int Value => value;
public string Label => label;
}
/// <summary>
/// Authored data for a discrete int setting driven by a <see cref="SettingStepperWidget"/>: its
/// default value and the ordered preset list.
/// </summary>
[System.Serializable]
public struct StepperSetting
{
[SerializeField] private int defaultValue;
[SerializeField] private List<StepperPreset> presets;
public int DefaultValue => defaultValue;
public IReadOnlyList<StepperPreset> Presets => presets;
/// <summary>
/// Adapts the authored presets to the (value, label) tuples the stepper widget consumes,
/// keeping the widget decoupled from this settings-data type.
/// </summary>
public IReadOnlyList<(int value, string label)> ToWidgetPresets()
{
List<(int, string)> result = new List<(int, string)>(presets != null ? presets.Count : 0);
if (presets != null)
foreach (StepperPreset preset in presets)
result.Add((preset.Value, preset.Label));
return result;
}
}
/// <summary>
/// Single source of truth for all authored settings data: per-setting defaults, clamp ranges,
/// label formatting and the FPS preset list. Read by <see cref="SettingsManager"/> (defaults +
/// clamps) and by the settings sub-panels (widget configuration), so no magic numbers or UI
/// hints live in code. Resolution/monitor indices are intentionally absent — they are derived
/// from the running hardware and resolved at runtime.
/// </summary>
[CreateAssetMenu(fileName = "SettingsDefinition", menuName = "Settings/Settings Definition")]
public class SettingsDefinition : ScriptableObject
{
#region Display
[Header("Display")]
[SerializeField] private FloatSetting brightness;
[SerializeField] private FullScreenMode defaultFullscreenMode = FullScreenMode.FullScreenWindow;
public FloatSetting Brightness => brightness;
public FullScreenMode DefaultFullscreenMode => defaultFullscreenMode;
#endregion
#region Audio
[Header("Audio")]
[SerializeField] private FloatSetting masterVolume;
[SerializeField] private FloatSetting musicVolume;
[SerializeField] private FloatSetting sfxVolume;
public FloatSetting MasterVolume => masterVolume;
public FloatSetting MusicVolume => musicVolume;
public FloatSetting SfxVolume => sfxVolume;
#endregion
#region Graphics
[Header("Graphics")]
[Tooltip("-1 keeps the project's current quality level on first run.")]
[SerializeField] private int defaultQualityLevel = -1;
[SerializeField] private bool defaultVSync = true;
[SerializeField] private StepperSetting targetFps;
[SerializeField] private FloatSetting fov;
public int DefaultQualityLevel => defaultQualityLevel;
public bool DefaultVSync => defaultVSync;
public StepperSetting TargetFps => targetFps;
public FloatSetting Fov => fov;
#endregion
#region Controls
[Header("Controls")]
[SerializeField] private FloatSetting sensitivity;
[SerializeField] private bool defaultInvertY;
[SerializeField] private bool defaultCrosshairEnabled = true;
public FloatSetting Sensitivity => sensitivity;
public bool DefaultInvertY => defaultInvertY;
public bool DefaultCrosshairEnabled => defaultCrosshairEnabled;
#endregion
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3879537c9eb9b1645a974f917c2ff880
+181 -37
View File
@@ -3,19 +3,47 @@ using UnityEngine;
using UnityEngine.Audio;
using UnityEngine.Events;
using Ashwild.Core;
using Ashwild.Player;
namespace Ashwild.Settings
{
/// <summary>
/// Owns every persisted setting and is the single place that touches disk (via Easy Save 3).
/// Values live in memory and are mirrored into the ES3 cache on change; the cache is flushed to
/// disk on pause/quit so dragging a slider doesn't hammer the file. Also owns input concerns
/// that are settings, not runtime: it persists the keybinding overrides and, on first launch,
/// detects the keyboard layout and asks the InputManager to apply the matching profile.
/// </summary>
public class SettingsManager : MonoBehaviour
{
public static SettingsManager Instance { get; private set; }
[Header("Definition")]
[Tooltip("Authored defaults, clamp ranges and label hints. Must be assigned — read in Awake.")]
[SerializeField] private SettingsDefinition definition;
/// <summary>
/// The authored settings data (defaults, clamps, label hints). Read by the settings
/// sub-panels to configure their widgets without hardcoding ranges or formats.
/// </summary>
public SettingsDefinition Definition => definition;
[Header("Audio Mixer")]
[SerializeField] private AudioMixer audioMixer;
[SerializeField] private string masterParam = "MasterVol";
[SerializeField] private string musicParam = "MusicVol";
[SerializeField] private string sfxParam = "SfxVol";
[Header("Input")]
[Tooltip("Single input config asset: rebindable bindings, key glyphs, first-launch layout overrides.")]
[SerializeField] private InputConfig inputConfig;
/// <summary>
/// The shared input config (rebindable bindings, key glyphs, layout overrides). Read by the
/// keybindings sub-panel so the asset is assigned here only, not duplicated on the panel.
/// </summary>
public InputConfig InputConfig => inputConfig;
[Header("Events")]
public UnityEvent onSettingsChanged;
@@ -41,7 +69,7 @@ namespace Ashwild.Settings
public bool InvertY { get; private set; }
public bool CrosshairEnabled { get; private set; }
// ── PlayerPrefs keys ────────────────────────────────────────
// ── Save keys ───────────────────────────────────────────────
private const string K_RESOLUTION = "settings.display.resolutionIndex";
private const string K_FULLSCREEN = "settings.display.fullscreenMode";
private const string K_MONITOR = "settings.display.monitorIndex";
@@ -56,6 +84,16 @@ namespace Ashwild.Settings
private const string K_SENS = "settings.controls.sensitivity";
private const string K_INVERT_Y = "settings.controls.invertY";
private const string K_CROSSHAIR = "settings.controls.crosshair";
private const string K_INPUT_OVERRIDES = "settings.input.overrides";
private const string K_LAYOUT_INIT = "settings.input.layoutInitialized";
// ── Persistence ─────────────────────────────────────────────
/// <summary>
/// ES3 settings pointing at the in-memory cache, so every Save is a cheap memory write;
/// the cache is flushed to the file on pause/quit (and immediately after keybind changes).
/// </summary>
private ES3Settings cache;
private void Awake()
{
@@ -67,10 +105,42 @@ namespace Ashwild.Settings
Instance = this;
Persistence.Persist(gameObject);
if (definition == null)
{
Debug.LogError($"[SettingsManager] No SettingsDefinition assigned on '{name}' — cannot load defaults.", this);
return;
}
cache = new ES3Settings(ES3.Location.Cache);
if (ES3.FileExists()) ES3.CacheFile();
LoadAll();
ApplyAll();
}
/// <summary>
/// Loads and applies the persisted keybindings once the InputManager exists, then runs the
/// one-time keyboard-layout detection. Done in Start so InputManager.Awake has resolved the
/// asset first.
/// </summary>
private void Start()
{
InitInput();
}
/// <summary>
/// Flushes the settings cache to disk when the app is backgrounded (covers mobile/alt-tab).
/// </summary>
private void OnApplicationPause(bool paused)
{
if (paused) Flush();
}
/// <summary>
/// Flushes the settings cache to disk on quit.
/// </summary>
private void OnApplicationQuit() => Flush();
// ── Public setters ──────────────────────────────────────────
public void SetResolution(int index)
@@ -105,14 +175,14 @@ namespace Ashwild.Settings
public void SetBrightness(float value)
{
Brightness = Mathf.Clamp(value, 0.5f, 1.5f);
Brightness = Mathf.Clamp(value, definition.Brightness.Min, definition.Brightness.Max);
Save();
onSettingsChanged?.Invoke();
}
public void SetMasterVolume(float value)
{
MasterVolume = Mathf.Clamp01(value);
MasterVolume = Mathf.Clamp(value, definition.MasterVolume.Min, definition.MasterVolume.Max);
ApplyMasterVolume();
Save();
onSettingsChanged?.Invoke();
@@ -120,7 +190,7 @@ namespace Ashwild.Settings
public void SetMusicVolume(float value)
{
MusicVolume = Mathf.Clamp01(value);
MusicVolume = Mathf.Clamp(value, definition.MusicVolume.Min, definition.MusicVolume.Max);
ApplyMusicVolume();
Save();
onSettingsChanged?.Invoke();
@@ -128,7 +198,7 @@ namespace Ashwild.Settings
public void SetSfxVolume(float value)
{
SfxVolume = Mathf.Clamp01(value);
SfxVolume = Mathf.Clamp(value, definition.SfxVolume.Min, definition.SfxVolume.Max);
ApplySfxVolume();
Save();
onSettingsChanged?.Invoke();
@@ -150,9 +220,13 @@ namespace Ashwild.Settings
onSettingsChanged?.Invoke();
}
/// <summary>
/// Sets the frame-rate cap. A negative value means "unlimited" (Unity treats
/// <c>targetFrameRate = -1</c> as no cap); any other value is clamped to a sane range.
/// </summary>
public void SetTargetFps(int fps)
{
TargetFps = Mathf.Clamp(fps, 30, 360);
TargetFps = fps < 0 ? -1 : Mathf.Clamp(fps, 30, 360);
Application.targetFrameRate = TargetFps;
Save();
onSettingsChanged?.Invoke();
@@ -160,14 +234,14 @@ namespace Ashwild.Settings
public void SetFov(float fov)
{
Fov = Mathf.Clamp(fov, 60f, 110f);
Fov = Mathf.Clamp(fov, definition.Fov.Min, definition.Fov.Max);
Save();
onSettingsChanged?.Invoke();
}
public void SetSensitivity(float value)
{
Sensitivity = Mathf.Clamp(value, 0.05f, 2f);
Sensitivity = Mathf.Clamp(value, definition.Sensitivity.Min, definition.Sensitivity.Max);
Save();
onSettingsChanged?.Invoke();
}
@@ -186,52 +260,80 @@ namespace Ashwild.Settings
onSettingsChanged?.Invoke();
}
// ── Keybindings persistence ─────────────────────────────────
/// <summary>
/// Persists the InputManager's current binding overrides (called by the rebind panel after a
/// rebind or reset). Flushes immediately since rebinds are infrequent and worth keeping safe.
/// </summary>
public void SaveKeybinds()
{
InputManager input = InputManager.Instance;
if (input == null) return;
ES3.Save(K_INPUT_OVERRIDES, input.GetOverridesJson(), cache);
Flush();
}
// ── Load / Save / Apply ─────────────────────────────────────
private void LoadAll()
{
int defaultResolution = Mathf.Max(0, Screen.resolutions.Length - 1);
int defaultQuality = definition.DefaultQualityLevel < 0
? QualitySettings.GetQualityLevel()
: definition.DefaultQualityLevel;
ResolutionIndex = PlayerPrefs.GetInt(K_RESOLUTION, defaultResolution);
FullscreenMode = (FullScreenMode)PlayerPrefs.GetInt(K_FULLSCREEN, (int)FullScreenMode.FullScreenWindow);
MonitorIndex = PlayerPrefs.GetInt(K_MONITOR, 0);
Brightness = PlayerPrefs.GetFloat(K_BRIGHTNESS, 1f);
ResolutionIndex = ES3.Load(K_RESOLUTION, defaultResolution, cache);
FullscreenMode = (FullScreenMode)ES3.Load(K_FULLSCREEN, (int)definition.DefaultFullscreenMode, cache);
MonitorIndex = ES3.Load(K_MONITOR, 0, cache);
Brightness = ES3.Load(K_BRIGHTNESS, definition.Brightness.DefaultValue, cache);
MasterVolume = PlayerPrefs.GetFloat(K_MASTER, 1f);
MusicVolume = PlayerPrefs.GetFloat(K_MUSIC, 0.8f);
SfxVolume = PlayerPrefs.GetFloat(K_SFX, 1f);
MasterVolume = ES3.Load(K_MASTER, definition.MasterVolume.DefaultValue, cache);
MusicVolume = ES3.Load(K_MUSIC, definition.MusicVolume.DefaultValue, cache);
SfxVolume = ES3.Load(K_SFX, definition.SfxVolume.DefaultValue, cache);
QualityLevel = PlayerPrefs.GetInt(K_QUALITY, QualitySettings.GetQualityLevel());
VSync = PlayerPrefs.GetInt(K_VSYNC, 1) == 1;
TargetFps = PlayerPrefs.GetInt(K_TARGET_FPS, 144);
Fov = PlayerPrefs.GetFloat(K_FOV, 75f);
QualityLevel = ES3.Load(K_QUALITY, defaultQuality, cache);
VSync = ES3.Load(K_VSYNC, definition.DefaultVSync, cache);
TargetFps = ES3.Load(K_TARGET_FPS, definition.TargetFps.DefaultValue, cache);
Fov = ES3.Load(K_FOV, definition.Fov.DefaultValue, cache);
Sensitivity = PlayerPrefs.GetFloat(K_SENS, 0.15f);
InvertY = PlayerPrefs.GetInt(K_INVERT_Y, 0) == 1;
CrosshairEnabled = PlayerPrefs.GetInt(K_CROSSHAIR, 1) == 1;
Sensitivity = ES3.Load(K_SENS, definition.Sensitivity.DefaultValue, cache);
InvertY = ES3.Load(K_INVERT_Y, definition.DefaultInvertY, cache);
CrosshairEnabled = ES3.Load(K_CROSSHAIR, definition.DefaultCrosshairEnabled, cache);
}
/// <summary>
/// Mirrors every setting into the ES3 cache (a cheap memory write). Disk is only touched by
/// <see cref="Flush"/> on pause/quit.
/// </summary>
private void Save()
{
PlayerPrefs.SetInt(K_RESOLUTION, ResolutionIndex);
PlayerPrefs.SetInt(K_FULLSCREEN, (int)FullscreenMode);
PlayerPrefs.SetInt(K_MONITOR, MonitorIndex);
PlayerPrefs.SetFloat(K_BRIGHTNESS, Brightness);
ES3.Save(K_RESOLUTION, ResolutionIndex, cache);
ES3.Save(K_FULLSCREEN, (int)FullscreenMode, cache);
ES3.Save(K_MONITOR, MonitorIndex, cache);
ES3.Save(K_BRIGHTNESS, Brightness, cache);
PlayerPrefs.SetFloat(K_MASTER, MasterVolume);
PlayerPrefs.SetFloat(K_MUSIC, MusicVolume);
PlayerPrefs.SetFloat(K_SFX, SfxVolume);
ES3.Save(K_MASTER, MasterVolume, cache);
ES3.Save(K_MUSIC, MusicVolume, cache);
ES3.Save(K_SFX, SfxVolume, cache);
PlayerPrefs.SetInt(K_QUALITY, QualityLevel);
PlayerPrefs.SetInt(K_VSYNC, VSync ? 1 : 0);
PlayerPrefs.SetInt(K_TARGET_FPS, TargetFps);
PlayerPrefs.SetFloat(K_FOV, Fov);
ES3.Save(K_QUALITY, QualityLevel, cache);
ES3.Save(K_VSYNC, VSync, cache);
ES3.Save(K_TARGET_FPS, TargetFps, cache);
ES3.Save(K_FOV, Fov, cache);
PlayerPrefs.SetFloat(K_SENS, Sensitivity);
PlayerPrefs.SetInt(K_INVERT_Y, InvertY ? 1 : 0);
PlayerPrefs.SetInt(K_CROSSHAIR, CrosshairEnabled ? 1 : 0);
ES3.Save(K_SENS, Sensitivity, cache);
ES3.Save(K_INVERT_Y, InvertY, cache);
ES3.Save(K_CROSSHAIR, CrosshairEnabled, cache);
}
PlayerPrefs.Save();
/// <summary>
/// Writes the in-memory cache out to the save file.
/// </summary>
private void Flush()
{
if (cache != null) ES3.StoreCachedFile();
}
private void ApplyAll()
@@ -262,6 +364,48 @@ namespace Ashwild.Settings
Application.targetFrameRate = TargetFps;
}
// ── Input init ──────────────────────────────────────────────
/// <summary>
/// Restores saved keybindings onto the shared input asset, then applies the first-launch
/// layout profile if this is the very first run and the player has no saved overrides.
/// </summary>
private void InitInput()
{
InputManager input = InputManager.Instance;
if (input == null)
{
Debug.LogWarning("[SettingsManager] InputManager not found — keybindings not restored.", this);
return;
}
if (ES3.KeyExists(K_INPUT_OVERRIDES, cache))
input.LoadOverridesJson(ES3.Load<string>(K_INPUT_OVERRIDES, cache));
if (!ES3.Load(K_LAYOUT_INIT, false, cache))
{
ApplyFirstLaunchLayout(input);
ES3.Save(K_LAYOUT_INIT, true, cache);
Flush();
}
}
/// <summary>
/// Detects the keyboard layout and asks the InputManager to apply the matching profile.
/// Skipped when the player already has saved overrides (a returning user is never stomped).
/// Note: physical key paths are already correct on AZERTY, so profiles are usually empty —
/// the real layout adaptation happens through the layout-aware key labels in the UI.
/// </summary>
private void ApplyFirstLaunchLayout(InputManager input)
{
if (ES3.KeyExists(K_INPUT_OVERRIDES, cache)) return;
if (inputConfig == null) return;
KeyboardLayout layout = KeyboardLayoutDetector.Detect();
if (input.ApplyLayoutOverrides(inputConfig.OverridesFor(layout)))
ES3.Save(K_INPUT_OVERRIDES, input.GetOverridesJson(), cache);
}
// ── Audio helpers ───────────────────────────────────────────
private void ApplyMasterVolume() => SetMixer(masterParam, MasterVolume);
@@ -1,68 +1,59 @@
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace Ashwild.Settings
{
/// <summary>
/// Binds the audio volume widgets (master, music, SFX) to the <see cref="SettingsManager"/>,
/// configuring each slider from the authored <see cref="SettingsDefinition"/>.
/// </summary>
public class AudioSettingsSubPanel : SettingsSubPanel
{
[Header("Master")]
[SerializeField] private Slider masterSlider;
[SerializeField] private TMP_Text masterLabel;
#region Serialized Fields
[Header("Music")]
[SerializeField] private Slider musicSlider;
[SerializeField] private TMP_Text musicLabel;
[Header("Widgets")]
[SerializeField] private SettingSliderWidget masterSlider;
[SerializeField] private SettingSliderWidget musicSlider;
[SerializeField] private SettingSliderWidget sfxSlider;
[Header("SFX")]
[SerializeField] private Slider sfxSlider;
[SerializeField] private TMP_Text sfxLabel;
#endregion
private bool wiring;
#region Unity Lifecycle
/// <summary>
/// Initializes each volume slider from the definition with its apply action.
/// </summary>
private void Awake()
{
masterSlider.onValueChanged.AddListener(OnMasterChanged);
musicSlider.onValueChanged.AddListener(OnMusicChanged);
sfxSlider.onValueChanged.AddListener(OnSfxChanged);
SettingsManager s = SettingsManager.Instance;
if (s == null || s.Definition == null)
{
Debug.LogError($"[AudioSettingsSubPanel] SettingsManager/Definition unavailable on '{name}'.", this);
return;
}
SettingsDefinition def = s.Definition;
masterSlider.Init(def.MasterVolume.Min, def.MasterVolume.Max, def.MasterVolume.LabelScale, def.MasterVolume.LabelFormat, v => s.SetMasterVolume(v));
musicSlider.Init(def.MusicVolume.Min, def.MusicVolume.Max, def.MusicVolume.LabelScale, def.MusicVolume.LabelFormat, v => s.SetMusicVolume(v));
sfxSlider.Init(def.SfxVolume.Min, def.SfxVolume.Max, def.SfxVolume.LabelScale, def.SfxVolume.LabelFormat, v => s.SetSfxVolume(v));
}
#endregion
#region Public API
/// <summary>
/// Pushes the manager's current volumes into the sliders.
/// </summary>
public override void Refresh()
{
SettingsManager s = SettingsManager.Instance;
if (s == null) return;
wiring = true;
masterSlider.SetValueWithoutNotify(s.MasterVolume);
musicSlider.SetValueWithoutNotify(s.MusicVolume);
sfxSlider.SetValueWithoutNotify(s.SfxVolume);
UpdateLabel(masterLabel, s.MasterVolume);
UpdateLabel(musicLabel, s.MusicVolume);
UpdateLabel(sfxLabel, s.SfxVolume);
wiring = false;
masterSlider.SetValue(s.MasterVolume);
musicSlider.SetValue(s.MusicVolume);
sfxSlider.SetValue(s.SfxVolume);
}
private void OnMasterChanged(float v)
{
UpdateLabel(masterLabel, v);
if (!wiring) SettingsManager.Instance.SetMasterVolume(v);
}
private void OnMusicChanged(float v)
{
UpdateLabel(musicLabel, v);
if (!wiring) SettingsManager.Instance.SetMusicVolume(v);
}
private void OnSfxChanged(float v)
{
UpdateLabel(sfxLabel, v);
if (!wiring) SettingsManager.Instance.SetSfxVolume(v);
}
private static void UpdateLabel(TMP_Text label, float value01)
{
if (label != null) label.text = $"{Mathf.RoundToInt(value01 * 100f)}%";
}
#endregion
}
}
@@ -1,43 +1,59 @@
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace Ashwild.Settings
{
/// <summary>
/// Binds the control widgets (sensitivity, invert-Y, crosshair) to the <see cref="SettingsManager"/>,
/// configuring the sensitivity slider from the authored <see cref="SettingsDefinition"/>.
/// </summary>
public class ControlsSettingsSubPanel : SettingsSubPanel
{
[Header("Controls")]
[SerializeField] private Slider sensitivitySlider;
[SerializeField] private TMP_Text sensitivityLabel;
[SerializeField] private Toggle invertYToggle;
[SerializeField] private Toggle crosshairToggle;
#region Serialized Fields
private bool wiring;
[Header("Widgets")]
[SerializeField] private SettingSliderWidget sensitivitySlider;
[SerializeField] private SettingToggleWidget invertYToggle;
[SerializeField] private SettingToggleWidget crosshairToggle;
#endregion
#region Unity Lifecycle
/// <summary>
/// Initializes each control widget from the definition with its apply action.
/// </summary>
private void Awake()
{
sensitivitySlider.onValueChanged.AddListener(OnSensitivityChanged);
invertYToggle.onValueChanged.AddListener(v => { if (!wiring) SettingsManager.Instance.SetInvertY(v); });
crosshairToggle.onValueChanged.AddListener(v => { if (!wiring) SettingsManager.Instance.SetCrosshairEnabled(v); });
SettingsManager s = SettingsManager.Instance;
if (s == null || s.Definition == null)
{
Debug.LogError($"[ControlsSettingsSubPanel] SettingsManager/Definition unavailable on '{name}'.", this);
return;
}
SettingsDefinition def = s.Definition;
sensitivitySlider.Init(def.Sensitivity.Min, def.Sensitivity.Max, def.Sensitivity.LabelScale, def.Sensitivity.LabelFormat, v => s.SetSensitivity(v));
invertYToggle.Init(v => s.SetInvertY(v));
crosshairToggle.Init(v => s.SetCrosshairEnabled(v));
}
#endregion
#region Public API
/// <summary>
/// Pushes the manager's current control values into the widgets.
/// </summary>
public override void Refresh()
{
SettingsManager s = SettingsManager.Instance;
if (s == null) return;
wiring = true;
sensitivitySlider.SetValueWithoutNotify(s.Sensitivity);
if (sensitivityLabel != null) sensitivityLabel.text = s.Sensitivity.ToString("F2");
invertYToggle.SetIsOnWithoutNotify(s.InvertY);
crosshairToggle.SetIsOnWithoutNotify(s.CrosshairEnabled);
wiring = false;
sensitivitySlider.SetValue(s.Sensitivity);
invertYToggle.SetValue(s.InvertY);
crosshairToggle.SetValue(s.CrosshairEnabled);
}
private void OnSensitivityChanged(float v)
{
if (sensitivityLabel != null) sensitivityLabel.text = v.ToString("F2");
if (!wiring) SettingsManager.Instance.SetSensitivity(v);
}
#endregion
}
}
@@ -1,90 +1,115 @@
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace Ashwild.Settings
{
/// <summary>
/// Binds the display widgets (resolution, fullscreen mode, monitor, brightness) to the
/// <see cref="SettingsManager"/>. The panel builds the dropdown option lists from the current
/// hardware and converts the fullscreen dropdown index to and from <see cref="FullScreenMode"/>;
/// the brightness slider is configured from the authored <see cref="SettingsDefinition"/>. The
/// widgets themselves stay pure UI.
/// </summary>
public class DisplaySettingsSubPanel : SettingsSubPanel
{
[Header("Controls")]
[SerializeField] private TMP_Dropdown resolutionDropdown;
[SerializeField] private TMP_Dropdown fullscreenDropdown;
[SerializeField] private TMP_Dropdown monitorDropdown;
[SerializeField] private Slider brightnessSlider;
[SerializeField] private TMP_Text brightnessLabel;
#region Serialized Fields
private bool wiring;
[Header("Widgets")]
[SerializeField] private SettingDropdownWidget resolutionDropdown;
[SerializeField] private SettingDropdownWidget fullscreenDropdown;
[SerializeField] private SettingDropdownWidget monitorDropdown;
[SerializeField] private SettingSliderWidget brightnessSlider;
#endregion
#region Unity Lifecycle
/// <summary>
/// Initializes each display widget with its apply action (brightness range from the definition).
/// </summary>
private void Awake()
{
resolutionDropdown.onValueChanged.AddListener(OnResolutionChanged);
fullscreenDropdown.onValueChanged.AddListener(OnFullscreenChanged);
monitorDropdown.onValueChanged.AddListener(OnMonitorChanged);
brightnessSlider.onValueChanged.AddListener(OnBrightnessChanged);
SettingsManager s = SettingsManager.Instance;
if (s == null || s.Definition == null)
{
Debug.LogError($"[DisplaySettingsSubPanel] SettingsManager/Definition unavailable on '{name}'.", this);
return;
}
SettingsDefinition def = s.Definition;
resolutionDropdown.Init(v => s.SetResolution(v));
fullscreenDropdown.Init(v => s.SetFullscreenMode(IndexToMode(v)));
monitorDropdown.Init(v => s.SetMonitor(v));
brightnessSlider.Init(def.Brightness.Min, def.Brightness.Max, def.Brightness.LabelScale, def.Brightness.LabelFormat, v => s.SetBrightness(v));
}
#endregion
#region Public API
/// <summary>
/// Rebuilds the dropdown options from current hardware and pushes the manager's values.
/// </summary>
public override void Refresh()
{
SettingsManager s = SettingsManager.Instance;
if (s == null) return;
wiring = true;
resolutionDropdown.SetOptions(BuildResolutionOptions());
resolutionDropdown.SetValue(s.ResolutionIndex);
PopulateResolutionDropdown();
resolutionDropdown.value = Mathf.Clamp(s.ResolutionIndex, 0, resolutionDropdown.options.Count - 1);
resolutionDropdown.RefreshShownValue();
fullscreenDropdown.SetOptions(FullscreenOptions);
fullscreenDropdown.SetValue(ModeToIndex(s.FullscreenMode));
PopulateFullscreenDropdown();
fullscreenDropdown.value = FullscreenModeToIndex(s.FullscreenMode);
fullscreenDropdown.RefreshShownValue();
monitorDropdown.SetOptions(BuildMonitorOptions());
monitorDropdown.SetValue(s.MonitorIndex);
PopulateMonitorDropdown();
monitorDropdown.value = Mathf.Clamp(s.MonitorIndex, 0, monitorDropdown.options.Count - 1);
monitorDropdown.RefreshShownValue();
brightnessSlider.SetValueWithoutNotify(s.Brightness);
if (brightnessLabel != null) brightnessLabel.text = $"{Mathf.RoundToInt(s.Brightness * 100f)}%";
wiring = false;
brightnessSlider.SetValue(s.Brightness);
}
private void PopulateResolutionDropdown()
#endregion
#region Internal Helpers
private static readonly string[] FullscreenOptions =
{
"Exclusive Fullscreen",
"Borderless Window",
"Maximized Window",
"Windowed"
};
/// <summary>
/// Builds the human-readable resolution list from the monitor's supported modes.
/// </summary>
private static List<string> BuildResolutionOptions()
{
resolutionDropdown.ClearOptions();
List<string> options = new List<string>();
foreach (Resolution r in Screen.resolutions)
options.Add($"{r.width}×{r.height} @{Mathf.RoundToInt((float)r.refreshRateRatio.value)}Hz");
resolutionDropdown.AddOptions(options);
return options;
}
private void PopulateFullscreenDropdown()
/// <summary>
/// Builds the monitor list from the current display layout.
/// </summary>
private static List<string> BuildMonitorOptions()
{
fullscreenDropdown.ClearOptions();
fullscreenDropdown.AddOptions(new List<string>
{
"Exclusive Fullscreen",
"Borderless Window",
"Maximized Window",
"Windowed"
});
}
private void PopulateMonitorDropdown()
{
monitorDropdown.ClearOptions();
List<DisplayInfo> displays = new List<DisplayInfo>();
Screen.GetDisplayLayout(displays);
List<string> options = new List<string>();
for (int i = 0; i < displays.Count; i++)
{
string name = string.IsNullOrEmpty(displays[i].name) ? $"Monitor {i + 1}" : displays[i].name;
options.Add($"{name} ({displays[i].width}×{displays[i].height})");
string label = string.IsNullOrEmpty(displays[i].name) ? $"Monitor {i + 1}" : displays[i].name;
options.Add($"{label} ({displays[i].width}×{displays[i].height})");
}
monitorDropdown.AddOptions(options);
return options;
}
private static int FullscreenModeToIndex(FullScreenMode mode) => mode switch
/// <summary>
/// Maps a fullscreen mode to its dropdown index.
/// </summary>
private static int ModeToIndex(FullScreenMode mode) => mode switch
{
FullScreenMode.ExclusiveFullScreen => 0,
FullScreenMode.FullScreenWindow => 1,
@@ -93,7 +118,10 @@ namespace Ashwild.Settings
_ => 1
};
private static FullScreenMode IndexToFullscreenMode(int index) => index switch
/// <summary>
/// Maps a dropdown index back to its fullscreen mode.
/// </summary>
private static FullScreenMode IndexToMode(int index) => index switch
{
0 => FullScreenMode.ExclusiveFullScreen,
1 => FullScreenMode.FullScreenWindow,
@@ -102,13 +130,6 @@ namespace Ashwild.Settings
_ => FullScreenMode.FullScreenWindow
};
private void OnResolutionChanged(int v) { if (!wiring) SettingsManager.Instance.SetResolution(v); }
private void OnFullscreenChanged(int v) { if (!wiring) SettingsManager.Instance.SetFullscreenMode(IndexToFullscreenMode(v)); }
private void OnMonitorChanged(int v) { if (!wiring) SettingsManager.Instance.SetMonitor(v); }
private void OnBrightnessChanged(float v)
{
if (brightnessLabel != null) brightnessLabel.text = $"{Mathf.RoundToInt(v * 100f)}%";
if (!wiring) SettingsManager.Instance.SetBrightness(v);
}
#endregion
}
}
@@ -1,64 +1,66 @@
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace Ashwild.Settings
{
/// <summary>
/// Binds the graphics widgets (quality, vsync, FPS cap, FOV) to the <see cref="SettingsManager"/>.
/// The panel is the only layer that knows what each widget means: it reads the authored
/// configuration from the manager's <see cref="SettingsDefinition"/> and initializes each widget
/// with its data and apply action, then pushes the current value on refresh. The widgets stay
/// pure UI.
/// </summary>
public class GraphicsSettingsSubPanel : SettingsSubPanel
{
[Header("Controls")]
[SerializeField] private TMP_Dropdown qualityDropdown;
[SerializeField] private Toggle vsyncToggle;
[SerializeField] private Slider targetFpsSlider;
[SerializeField] private TMP_Text targetFpsLabel;
[SerializeField] private Slider fovSlider;
[SerializeField] private TMP_Text fovLabel;
#region Serialized Fields
private bool wiring;
[Header("Widgets")]
[SerializeField] private SettingDropdownWidget qualityDropdown;
[SerializeField] private SettingToggleWidget vsyncToggle;
[SerializeField] private SettingStepperWidget targetFpsStepper;
[SerializeField] private SettingSliderWidget fovSlider;
#endregion
#region Unity Lifecycle
/// <summary>
/// Initializes each widget from the definition with the action to apply to the manager.
/// </summary>
private void Awake()
{
qualityDropdown.onValueChanged.AddListener(v => { if (!wiring) SettingsManager.Instance.SetQualityLevel(v); });
vsyncToggle.onValueChanged.AddListener(v => { if (!wiring) SettingsManager.Instance.SetVSync(v); });
targetFpsSlider.onValueChanged.AddListener(OnTargetFpsChanged);
fovSlider.onValueChanged.AddListener(OnFovChanged);
SettingsManager s = SettingsManager.Instance;
if (s == null || s.Definition == null)
{
Debug.LogError($"[GraphicsSettingsSubPanel] SettingsManager/Definition unavailable on '{name}'.", this);
return;
}
SettingsDefinition def = s.Definition;
qualityDropdown.Init(v => s.SetQualityLevel(v));
vsyncToggle.Init(v => s.SetVSync(v));
targetFpsStepper.Init(def.TargetFps.ToWidgetPresets(), false, v => s.SetTargetFps(v));
fovSlider.Init(def.Fov.Min, def.Fov.Max, def.Fov.LabelScale, def.Fov.LabelFormat, v => s.SetFov(v));
}
#endregion
#region Public API
/// <summary>
/// Pushes the manager's current graphics values into the widgets.
/// </summary>
public override void Refresh()
{
SettingsManager s = SettingsManager.Instance;
if (s == null) return;
wiring = true;
qualityDropdown.ClearOptions();
qualityDropdown.AddOptions(new List<string>(QualitySettings.names));
qualityDropdown.value = Mathf.Clamp(s.QualityLevel, 0, qualityDropdown.options.Count - 1);
qualityDropdown.RefreshShownValue();
vsyncToggle.SetIsOnWithoutNotify(s.VSync);
targetFpsSlider.SetValueWithoutNotify(s.TargetFps);
if (targetFpsLabel != null) targetFpsLabel.text = $"{s.TargetFps} FPS";
fovSlider.SetValueWithoutNotify(s.Fov);
if (fovLabel != null) fovLabel.text = $"{s.Fov:F0}°";
wiring = false;
qualityDropdown.SetOptions(QualitySettings.names);
qualityDropdown.SetValue(s.QualityLevel);
vsyncToggle.SetValue(s.VSync);
targetFpsStepper.SetValue(s.TargetFps);
fovSlider.SetValue(s.Fov);
}
private void OnTargetFpsChanged(float v)
{
int fps = Mathf.RoundToInt(v);
if (targetFpsLabel != null) targetFpsLabel.text = $"{fps} FPS";
if (!wiring) SettingsManager.Instance.SetTargetFps(fps);
}
private void OnFovChanged(float v)
{
if (fovLabel != null) fovLabel.text = $"{v:F0}°";
if (!wiring) SettingsManager.Instance.SetFov(v);
}
#endregion
}
}
@@ -0,0 +1,303 @@
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using Ashwild.Player;
namespace Ashwild.Settings
{
/// <summary>
/// Settings sub-panel that lists every rebindable binding from the shared InputConfig as rows and
/// drives interactive rebinds. It is the orchestrator: the InputManager performs the rebind on the
/// asset, the SettingsManager persists it, and this panel refreshes the rows. It also owns the
/// modal rebind prompt (a "press a key" overlay that becomes a "X already uses this key →
/// Replace / Cancel" conflict prompt) — the prompt objects live under the SettingsPanel so they
/// render in front of everything. Rows show layout-aware glyphs, so AZERTY reads correctly.
/// </summary>
public class KeybindingsSettingsSubPanel : SettingsSubPanel
{
#region Serialized Fields
[Header("Row List")]
[SerializeField] private Transform rowContainer;
[SerializeField] private GameObject rowPrefab;
[SerializeField] private Button resetAllButton;
[Header("Rebind Prompt")]
[Tooltip("Standalone prompt overlay — its own object under the SettingsPanel, not a child here.")]
[SerializeField] private RebindPromptUI prompt;
#endregion
#region State
private readonly List<KeybindRowUI> rows = new List<KeybindRowUI>();
private bool built;
/// <summary>
/// The shared input config, pulled from the SettingsManager (assigned there, not here).
/// </summary>
private InputConfig config;
/// <summary>
/// The conflict awaiting the player's Replace/Cancel choice, kept so the buttons can resolve it.
/// </summary>
private InputManager.RebindOutcome pendingOutcome;
private bool hasPendingConflict;
private KeybindRowUI activeRow;
#endregion
#region Unity Lifecycle
/// <summary>
/// Wires the reset-all button and the prompt's Replace/Cancel buttons.
/// </summary>
private void Awake()
{
if (resetAllButton != null) resetAllButton.onClick.AddListener(ResetAll);
}
/// <summary>
/// Aborts any rebind/conflict in flight when the panel is hidden (category switch / closed).
/// </summary>
private void OnDisable()
{
if (InputManager.Instance != null) InputManager.Instance.CancelOngoingRebind();
if (hasPendingConflict) OnConflictCancel();
HidePrompt();
}
#endregion
#region Public API
/// <summary>
/// Builds the rows once, then repaints every current key (so reopening shows live bindings).
/// </summary>
public override void Refresh()
{
config = SettingsManager.Instance != null ? SettingsManager.Instance.InputConfig : null;
if (config == null)
{
Debug.LogError("[KeybindingsSettingsSubPanel] No InputConfig available from SettingsManager.", this);
return;
}
HidePrompt();
if (!built) BuildRows();
RefreshAll();
}
#endregion
#region Row Building
/// <summary>
/// Instantiates one row per config binding and binds its callbacks.
/// </summary>
private void BuildRows()
{
if (rowPrefab == null || rowContainer == null)
{
Debug.LogError($"[KeybindingsSettingsSubPanel] Row prefab/container missing on '{name}'.", this);
return;
}
foreach (RebindableEntry entry in config.Bindings)
{
GameObject go = Instantiate(rowPrefab, rowContainer);
KeybindRowUI row = go.GetComponent<KeybindRowUI>();
if (row == null)
{
Debug.LogError("[KeybindingsSettingsSubPanel] Row prefab has no KeybindRowUI.", this);
Destroy(go);
continue;
}
row.Initialize(entry, OnRowRebind, OnRowReset);
rows.Add(row);
}
built = true;
}
#endregion
#region Row Callbacks
/// <summary>
/// Opens the prompt and starts capturing a new key for the clicked row.
/// </summary>
private void OnRowRebind(KeybindRowUI row)
{
if (InputManager.Instance == null) return;
activeRow = row;
ShowListening();
InputManager.Instance.StartInteractiveRebind(row.ActionName, row.BindingIndex, OnRebindOutcome);
}
/// <summary>
/// Handles the capture result: persist + refresh on success, raise the conflict prompt on a
/// clash, or just close on cancel.
/// </summary>
private void OnRebindOutcome(InputManager.RebindOutcome outcome)
{
switch (outcome.Result)
{
case InputManager.RebindResult.Completed:
HidePrompt();
Persist();
RefreshAll();
break;
case InputManager.RebindResult.Conflict:
pendingOutcome = outcome;
hasPendingConflict = true;
ShowConflict(LabelFor(outcome.ConflictActionName));
break;
default:
HidePrompt();
if (activeRow != null) RefreshRow(activeRow);
break;
}
}
/// <summary>
/// Resolves a conflict by swapping the two keys (the other action takes this one's old key).
/// </summary>
private void OnConflictReplace()
{
if (!hasPendingConflict) return;
if (InputManager.Instance != null) InputManager.Instance.ApplySwap(pendingOutcome);
ClearConflict();
HidePrompt();
Persist();
RefreshAll();
}
/// <summary>
/// Resolves a conflict by undoing the new binding (the other action keeps its key).
/// </summary>
private void OnConflictCancel()
{
if (!hasPendingConflict) return;
if (InputManager.Instance != null) InputManager.Instance.RevertRebind(pendingOutcome);
ClearConflict();
HidePrompt();
RefreshAll();
}
/// <summary>
/// Resets the clicked row's binding to default and persists.
/// </summary>
private void OnRowReset(KeybindRowUI row)
{
if (InputManager.Instance == null) return;
InputManager.Instance.ResetBinding(row.ActionName, row.BindingIndex);
Persist();
RefreshRow(row);
}
/// <summary>
/// Resets every binding to default and persists.
/// </summary>
private void ResetAll()
{
if (InputManager.Instance == null) return;
InputManager.Instance.ResetAllBindings();
Persist();
RefreshAll();
}
#endregion
#region Prompt
/// <summary>
/// Shows the prompt in "press a key" mode.
/// </summary>
private void ShowListening()
{
if (prompt != null) prompt.ShowListening();
}
/// <summary>
/// Switches the open prompt to the conflict state for the named action, wiring its buttons.
/// </summary>
private void ShowConflict(string conflictLabel)
{
if (prompt != null) prompt.ShowConflict(conflictLabel, OnConflictReplace, OnConflictCancel);
}
/// <summary>
/// Hides the prompt entirely.
/// </summary>
private void HidePrompt()
{
if (prompt != null) prompt.Hide();
}
/// <summary>
/// Clears the pending-conflict state.
/// </summary>
private void ClearConflict()
{
hasPendingConflict = false;
pendingOutcome = default;
}
#endregion
#region Refresh
/// <summary>
/// Persists the current bindings through the SettingsManager.
/// </summary>
private void Persist()
{
if (SettingsManager.Instance != null) SettingsManager.Instance.SaveKeybinds();
}
/// <summary>
/// Repaints the current key on every row.
/// </summary>
private void RefreshAll()
{
foreach (KeybindRowUI row in rows) RefreshRow(row);
}
/// <summary>
/// Repaints one row's current key, choosing a glyph from the config when available.
/// </summary>
private void RefreshRow(KeybindRowUI row)
{
if (InputManager.Instance == null) return;
string display = InputManager.Instance.GetBindingDisplayString(row.ActionName, row.BindingIndex);
Sprite icon = config != null ? config.GetIcon(display) : null;
if (icon == null && config != null)
{
string controlPath = InputManager.Instance.GetBindingControlPath(row.ActionName, row.BindingIndex);
icon = config.GetIcon(controlPath);
}
row.RefreshDisplay(display, icon);
}
/// <summary>
/// The display label authored for an action (first matching binding), or the raw name.
/// </summary>
private string LabelFor(string actionName)
{
if (config != null)
foreach (RebindableEntry entry in config.Bindings)
if (entry.ActionName == actionName)
return entry.DisplayLabel;
return actionName;
}
#endregion
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 56f3db8af01bac245822790db109b980
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: bb5a12d19cf4ddc4590197dd5792992a
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,87 @@
using System;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
using Ashwild.Player;
namespace Ashwild.Settings
{
/// <summary>
/// One rebindable-key row: an action label, the current key (shown as a glyph when an icon is
/// available, otherwise as text), a Rebind button and a Reset button. Pure UI — it holds the
/// config entry's action name + binding index and raises callbacks; it knows nothing about the
/// Input System. Mirrors SlotUI/RecipeButtonUI: the panel calls Initialize once, then RefreshDisplay.
/// </summary>
public class KeybindRowUI : MonoBehaviour
{
#region Serialized Fields
[Header("References")]
[SerializeField] private TMP_Text labelText;
[SerializeField] private TMP_Text keyText;
[SerializeField] private Image keyIcon;
[SerializeField] private Button rebindButton;
[SerializeField] private Button resetButton;
#endregion
#region State
private string actionName;
private int bindingIndex;
private Action<KeybindRowUI> onRebind;
private Action<KeybindRowUI> onReset;
public string ActionName => actionName;
public int BindingIndex => bindingIndex;
#endregion
#region Public API
/// <summary>
/// Binds this row to its config entry and the panel callbacks. Call once after Instantiate.
/// </summary>
public void Initialize(RebindableEntry entry, Action<KeybindRowUI> onRebind, Action<KeybindRowUI> onReset)
{
actionName = entry.ActionName;
bindingIndex = entry.BindingIndex;
this.onRebind = onRebind;
this.onReset = onReset;
if (labelText != null) labelText.text = entry.DisplayLabel;
if (rebindButton != null)
{
rebindButton.onClick.RemoveAllListeners();
rebindButton.onClick.AddListener(() => this.onRebind?.Invoke(this));
}
if (resetButton != null)
{
resetButton.onClick.RemoveAllListeners();
resetButton.onClick.AddListener(() => this.onReset?.Invoke(this));
}
}
/// <summary>
/// Shows the current key as a glyph when an icon is supplied, otherwise as text.
/// </summary>
public void RefreshDisplay(string keyDisplay, Sprite icon)
{
bool hasIcon = icon != null;
if (keyIcon != null)
{
keyIcon.enabled = hasIcon;
if (hasIcon) keyIcon.sprite = icon;
}
if (keyText != null)
{
keyText.gameObject.SetActive(!hasIcon);
keyText.text = keyDisplay;
}
}
#endregion
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1cf0849fb8494a24ca8a7342bdbfcf84
@@ -0,0 +1,90 @@
using System;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace Ashwild.Settings
{
/// <summary>
/// Standalone modal overlay for rebinding, living as its own object under the SettingsPanel (a
/// sibling of the category sub-panels) so it renders in front of everything. One message label
/// plus two buttons: while capturing it reads "press a key" with the buttons hidden; on a clash
/// it shows "X already uses this key" with Replace / Cancel. Pure UI driven by the keybindings
/// sub-panel, which holds a reference and calls these methods. Buttons are wired per-conflict.
/// </summary>
public class RebindPromptUI : MonoBehaviour
{
#region Serialized Fields
[Header("Root")]
[Tooltip("The overlay object toggled on/off. Leave empty to use this GameObject.")]
[SerializeField] private GameObject root;
[Header("References")]
[SerializeField] private TMP_Text messageText;
[SerializeField] private Button replaceButton;
[SerializeField] private Button cancelButton;
[Header("Text")]
[SerializeField] private string listeningText = "Appuyez sur une touche…";
[Tooltip("{0} = the action that already uses the key.")]
[SerializeField] private string conflictFormat = "« {0} » utilise déjà cette touche.";
#endregion
#region Public API
/// <summary>
/// Shows the overlay in "press a key" mode (buttons hidden).
/// </summary>
public void ShowListening()
{
Target.SetActive(true);
if (messageText != null) messageText.text = listeningText;
SetButtonsActive(false);
}
/// <summary>
/// Switches the open overlay to the conflict prompt, showing and wiring the two buttons.
/// </summary>
public void ShowConflict(string conflictLabel, Action onReplace, Action onCancel)
{
Target.SetActive(true);
if (messageText != null) messageText.text = string.Format(conflictFormat, conflictLabel);
SetButtonsActive(true);
if (replaceButton != null)
{
replaceButton.onClick.RemoveAllListeners();
replaceButton.onClick.AddListener(() => onReplace?.Invoke());
}
if (cancelButton != null)
{
cancelButton.onClick.RemoveAllListeners();
cancelButton.onClick.AddListener(() => onCancel?.Invoke());
}
}
/// <summary>
/// Hides the overlay.
/// </summary>
public void Hide() => Target.SetActive(false);
#endregion
#region Internal Helpers
/// <summary>
/// Shows/hides the Replace and Cancel buttons (only relevant in the conflict state).
/// </summary>
private void SetButtonsActive(bool active)
{
if (replaceButton != null) replaceButton.gameObject.SetActive(active);
if (cancelButton != null) cancelButton.gameObject.SetActive(active);
}
private GameObject Target => root != null ? root : gameObject;
#endregion
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: ce5206c9bb735e247ae12cd858bd0d7b
@@ -0,0 +1,79 @@
using System;
using System.Collections.Generic;
using TMPro;
using UnityEngine;
namespace Ashwild.Settings
{
/// <summary>
/// A reusable dropdown. Pure UI: it knows nothing about which setting it drives. The panel
/// calls <see cref="Init"/> once to register the selection action, supplies the option list via
/// <see cref="SetOptions"/> (resolutions, monitors… are all built by the panel) and selects the
/// current entry through <see cref="SetValue"/>.
/// </summary>
[DisallowMultipleComponent]
public class SettingDropdownWidget : MonoBehaviour
{
#region Serialized Fields
[Header("References")]
[SerializeField] private TMP_Dropdown dropdown;
#endregion
#region State
private Action<int> onChanged;
#endregion
#region Public API
/// <summary>
/// Registers the action to run when the user picks an option. Call once.
/// </summary>
public void Init(Action<int> onChanged)
{
if (dropdown == null)
{
Debug.LogError($"[SettingDropdownWidget] '{name}' has no Dropdown assigned.", this);
return;
}
this.onChanged = onChanged;
dropdown.onValueChanged.RemoveListener(HandleChanged);
dropdown.onValueChanged.AddListener(HandleChanged);
}
/// <summary>
/// Replaces the dropdown's options. Call before <see cref="SetValue"/> on refresh.
/// </summary>
public void SetOptions(IEnumerable<string> options)
{
if (dropdown == null) return;
dropdown.ClearOptions();
dropdown.AddOptions(new List<string>(options));
}
/// <summary>
/// Selects the given index (clamped to the current options) without invoking the action.
/// </summary>
public void SetValue(int index)
{
if (dropdown == null) return;
dropdown.SetValueWithoutNotify(Mathf.Clamp(index, 0, dropdown.options.Count - 1));
dropdown.RefreshShownValue();
}
#endregion
#region Event Handlers
/// <summary>
/// Runs the apply action with the chosen index.
/// </summary>
private void HandleChanged(int index) => onChanged?.Invoke(index);
#endregion
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: bfc37cc18ac6d884a9382a7ffc312d40
@@ -0,0 +1,95 @@
using System;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace Ashwild.Settings
{
/// <summary>
/// A reusable slider + value label. It is pure UI: it knows nothing about which setting it
/// drives. The hosting panel calls <see cref="Init"/> once to hand it its range, label format
/// and the action to run when the user moves it; afterwards the panel pushes the current value
/// through <see cref="SetValue"/>. The range is applied to the slider in <see cref="Init"/>, so
/// a control can never be stuck at the slider's default 01 range.
/// </summary>
[DisallowMultipleComponent]
public class SettingSliderWidget : MonoBehaviour
{
#region Serialized Fields
[Header("References")]
[SerializeField] private Slider slider;
[SerializeField] private TMP_Text valueLabel;
#endregion
#region State
private Action<float> onChanged;
private float labelScale = 1f;
private string labelFormat = "{0:F0}";
#endregion
#region Public API
/// <summary>
/// Configures the slider with its range and label format, and registers the action to run
/// when the user drags it. Call once (the apply action is not invoked by <see cref="SetValue"/>).
/// </summary>
public void Init(float min, float max, float labelScale, string labelFormat, Action<float> onChanged)
{
if (slider == null)
{
Debug.LogError($"[SettingSliderWidget] '{name}' has no Slider assigned.", this);
return;
}
this.labelScale = labelScale;
this.labelFormat = labelFormat;
this.onChanged = onChanged;
slider.minValue = min;
slider.maxValue = max;
slider.onValueChanged.RemoveListener(HandleSliderChanged);
slider.onValueChanged.AddListener(HandleSliderChanged);
}
/// <summary>
/// Displays the given value without invoking the apply action.
/// </summary>
public void SetValue(float value)
{
if (slider == null) return;
slider.SetValueWithoutNotify(value);
UpdateLabel(value);
}
#endregion
#region Event Handlers
/// <summary>
/// Updates the label and runs the apply action with the user-driven value.
/// </summary>
private void HandleSliderChanged(float value)
{
UpdateLabel(value);
onChanged?.Invoke(value);
}
#endregion
#region Internal Helpers
/// <summary>
/// Formats and shows the scaled value, when a label is wired.
/// </summary>
private void UpdateLabel(float value)
{
if (valueLabel != null) valueLabel.text = string.Format(labelFormat, value * labelScale);
}
#endregion
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: edafafa6f8d058845ba74c0ad09e2981
@@ -0,0 +1,136 @@
using System;
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace Ashwild.Settings
{
/// <summary>
/// A "◄ value ►" control that cycles a list of preset values. Pure UI: it knows nothing about
/// which setting it drives. The hosting panel calls <see cref="Init"/> once with the presets
/// (each a value plus its display label) and the action to run when the user steps; afterwards
/// the panel pushes the current value through <see cref="SetValue"/>. Because each preset
/// carries its own label, a value like -1 can read as "Unlimited" rather than a number.
/// </summary>
[DisallowMultipleComponent]
public class SettingStepperWidget : MonoBehaviour
{
#region Serialized Fields
[Header("References")]
[SerializeField] private Button previousButton;
[SerializeField] private Button nextButton;
[SerializeField] private TMP_Text valueLabel;
#endregion
#region State
private IReadOnlyList<(int value, string label)> presets;
private Action<int> onChanged;
private bool wrap;
private int currentIndex;
#endregion
#region Public API
/// <summary>
/// Configures the stepper with its presets and wrap behaviour, and registers the action to
/// run when the user steps. Call once (the action is not invoked by <see cref="SetValue"/>).
/// </summary>
public void Init(IReadOnlyList<(int value, string label)> presets, bool wrap, Action<int> onChanged)
{
if (presets == null || presets.Count == 0)
{
Debug.LogError($"[SettingStepperWidget] '{name}' was initialized with no presets.", this);
return;
}
this.presets = presets;
this.wrap = wrap;
this.onChanged = onChanged;
if (previousButton != null)
{
previousButton.onClick.RemoveListener(StepDown);
previousButton.onClick.AddListener(StepDown);
}
if (nextButton != null)
{
nextButton.onClick.RemoveListener(StepUp);
nextButton.onClick.AddListener(StepUp);
}
}
/// <summary>
/// Selects the preset matching the given value (falling back to the first) and shows its
/// label, without invoking the apply action.
/// </summary>
public void SetValue(int value)
{
if (presets == null) return;
currentIndex = IndexOfValue(value);
UpdateLabel();
}
#endregion
#region Event Handlers
private void StepDown() => Step(-1);
private void StepUp() => Step(+1);
#endregion
#region Internal Helpers
/// <summary>
/// Moves the selection by one step (clamped or wrapped), shows it and runs the apply action.
/// </summary>
private void Step(int direction)
{
if (presets == null || presets.Count == 0) return;
int count = presets.Count;
int next = currentIndex + direction;
if (wrap)
next = (next % count + count) % count;
else
next = Mathf.Clamp(next, 0, count - 1);
if (next == currentIndex) return;
currentIndex = next;
UpdateLabel();
onChanged?.Invoke(presets[currentIndex].value);
}
/// <summary>
/// Returns the preset index whose value equals the given one, or 0 when none matches.
/// </summary>
private int IndexOfValue(int value)
{
for (int i = 0; i < presets.Count; i++)
if (presets[i].value == value) return i;
return 0;
}
/// <summary>
/// Shows the current preset's label and disables arrows that would do nothing.
/// </summary>
private void UpdateLabel()
{
if (valueLabel != null) valueLabel.text = presets[currentIndex].label;
if (!wrap)
{
if (previousButton != null) previousButton.interactable = currentIndex > 0;
if (nextButton != null) nextButton.interactable = currentIndex < presets.Count - 1;
}
}
#endregion
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: ed871655db50611488015c55020523db
@@ -0,0 +1,65 @@
using System;
using UnityEngine;
using UnityEngine.UI;
namespace Ashwild.Settings
{
/// <summary>
/// A reusable toggle. Pure UI: it knows nothing about which bool setting it drives. The panel
/// calls <see cref="Init"/> once to register the action to run when the user flips it, then
/// reflects state through <see cref="SetValue"/>.
/// </summary>
[DisallowMultipleComponent]
public class SettingToggleWidget : MonoBehaviour
{
#region Serialized Fields
[Header("References")]
[SerializeField] private Toggle toggle;
#endregion
#region State
private Action<bool> onChanged;
#endregion
#region Public API
/// <summary>
/// Registers the action to run when the user flips the toggle. Call once.
/// </summary>
public void Init(Action<bool> onChanged)
{
if (toggle == null)
{
Debug.LogError($"[SettingToggleWidget] '{name}' has no Toggle assigned.", this);
return;
}
this.onChanged = onChanged;
toggle.onValueChanged.RemoveListener(HandleToggleChanged);
toggle.onValueChanged.AddListener(HandleToggleChanged);
}
/// <summary>
/// Displays the given state without invoking the apply action.
/// </summary>
public void SetValue(bool value)
{
if (toggle != null) toggle.SetIsOnWithoutNotify(value);
}
#endregion
#region Event Handlers
/// <summary>
/// Runs the apply action with the user-driven state.
/// </summary>
private void HandleToggleChanged(bool value) => onChanged?.Invoke(value);
#endregion
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 856178189b5f23e438619599ec0d1a6c