using System.Collections.Generic; using System.Linq; using AcDream.UI.Abstractions.Input; namespace AcDream.UI.Abstractions.Panels.Settings; /// /// In-game Settings panel — F11 toggle (or View → Settings on the main /// menu bar). Hidden by default. Tabbed: Keybinds (Phase K), then /// Display / Audio / Gameplay / Chat / Character (filling in over the /// L.x sub-phases). /// /// /// Top of the panel: Save / Cancel / Reset-all action buttons (global /// across all tabs). When is /// non-null, a confirmation prompt is rendered above those buttons /// (Yes — Reassign / No — Keep existing). /// /// /// /// Below the action row a tab bar selects between the six categories. /// Only the Keybinds tab is implemented today; the other five render /// "Coming soon" placeholders so the structure the user approved in the /// design brainstorm is visible immediately. /// /// public sealed class SettingsPanel : IPanel { private readonly SettingsVM _vm; public SettingsPanel(SettingsVM vm) { _vm = vm ?? throw new System.ArgumentNullException(nameof(vm)); } /// public string Id => "acdream.settings"; /// public string Title => "Settings"; /// /// Hidden by default — opened via F11 / View menu. public bool IsVisible { get; set; } = false; /// public void Render(PanelContext ctx, IPanelRenderer renderer) { if (!renderer.Begin(Title)) { renderer.End(); return; } // Conflict prompt — modal-ish row at top of the panel. if (_vm.PendingConflict is { } conflict) { renderer.TextWrapped( $"'{ChordLabel(conflict.NewChord)}' is already bound to " + $"{conflict.ConflictingAction}. Reassign it to " + $"{conflict.NewAction}?"); if (renderer.Button("Yes — Reassign")) _vm.ResolveConflict(replace: true); renderer.SameLine(); if (renderer.Button("No — Keep existing")) _vm.ResolveConflict(replace: false); renderer.Separator(); } // Top action buttons. Global across all tabs. if (renderer.Button("Save changes")) _vm.Save(); renderer.SameLine(); if (renderer.Button("Cancel changes")) _vm.Cancel(); renderer.SameLine(); if (renderer.Button("Reset all to retail defaults")) _vm.ResetAllToDefaults(); renderer.Separator(); if (renderer.BeginTabBar("settings.tabs")) { if (renderer.BeginTabItem("Keybinds")) { RenderKeybindsTab(renderer); renderer.EndTabItem(); } if (renderer.BeginTabItem("Display")) { RenderDisplayTab(renderer); renderer.EndTabItem(); } if (renderer.BeginTabItem("Audio")) { RenderAudioTab(renderer); renderer.EndTabItem(); } if (renderer.BeginTabItem("Gameplay")) { RenderGameplayTab(renderer); renderer.EndTabItem(); } if (renderer.BeginTabItem("Chat")) { RenderChatTab(renderer); renderer.EndTabItem(); } if (renderer.BeginTabItem("Character")) { RenderCharacterTab(renderer); renderer.EndTabItem(); } renderer.EndTabBar(); } renderer.End(); } /// /// Render the Keybinds tab — eight collapsing-header sections matching /// the retail keymap categories. Phase K shipped this content; the /// only thing that changed is the wrapping tab item. /// private void RenderKeybindsTab(IPanelRenderer renderer) { RenderSection(renderer, "Movement", new[] { InputAction.MovementForward, InputAction.MovementBackup, InputAction.MovementTurnLeft, InputAction.MovementTurnRight, InputAction.MovementStrafeLeft, InputAction.MovementStrafeRight, InputAction.MovementJump, InputAction.MovementStop, InputAction.MovementWalkMode, InputAction.MovementRunLock, }); RenderSection(renderer, "Postures", new[] { InputAction.Ready, InputAction.Sitting, InputAction.Crouch, InputAction.Sleeping, }); RenderSection(renderer, "Camera", new[] { InputAction.CameraActivateAlternateMode, InputAction.CameraInstantMouseLook, InputAction.CameraRotateLeft, InputAction.CameraRotateRight, InputAction.CameraRotateUp, InputAction.CameraRotateDown, InputAction.CameraMoveToward, InputAction.CameraMoveAway, InputAction.CameraViewDefault, InputAction.CameraViewFirstPerson, InputAction.CameraViewLookDown, InputAction.CameraViewMapMode, }); RenderSection(renderer, "Combat", new[] { InputAction.CombatToggleCombat, InputAction.CombatDecreaseAttackPower, InputAction.CombatIncreaseAttackPower, InputAction.CombatLowAttack, InputAction.CombatMediumAttack, InputAction.CombatHighAttack, InputAction.CombatAimLow, InputAction.CombatAimMedium, InputAction.CombatAimHigh, InputAction.CombatPrevSpellTab, InputAction.CombatNextSpellTab, InputAction.CombatPrevSpell, InputAction.CombatNextSpell, InputAction.CombatCastCurrentSpell, }); RenderSection(renderer, "UI panels", new[] { InputAction.ToggleHelp, InputAction.ToggleAllegiancePanel, InputAction.ToggleFellowshipPanel, InputAction.ToggleSpellbookPanel, InputAction.ToggleSpellComponentsPanel, InputAction.ToggleAttributesPanel, InputAction.ToggleSkillsPanel, InputAction.ToggleWorldPanel, InputAction.ToggleOptionsPanel, InputAction.ToggleInventoryPanel, InputAction.SelectionExamine, InputAction.UseSelected, InputAction.EscapeKey, InputAction.LOGOUT, }); RenderSection(renderer, "Chat", new[] { InputAction.ToggleChatEntry, InputAction.EnterChatMode, InputAction.ToggleFloatingChatWindow1, InputAction.ToggleFloatingChatWindow2, InputAction.ToggleFloatingChatWindow3, InputAction.ToggleFloatingChatWindow4, }); RenderSection(renderer, "Hotbar", new[] { InputAction.UseQuickSlot_1, InputAction.UseQuickSlot_2, InputAction.UseQuickSlot_3, InputAction.UseQuickSlot_4, InputAction.UseQuickSlot_5, InputAction.UseQuickSlot_6, InputAction.UseQuickSlot_7, InputAction.UseQuickSlot_8, InputAction.UseQuickSlot_9, InputAction.CreateShortcut, }); RenderSection(renderer, "Emotes", new[] { InputAction.Cry, InputAction.Laugh, InputAction.Wave, InputAction.Cheer, InputAction.PointState, }); } /// /// Render the Display tab — resolution / fullscreen / vsync / /// FOV / gamma / show-FPS. FOV + Gamma are live-preview sliders; /// the others apply on Save (matches the brainstorm UX agreement — /// resolution change live would be too jarring). /// private void RenderDisplayTab(IPanelRenderer renderer) { var d = _vm.DisplayDraft; // Resolution dropdown. Index falls back to the highest available // option when the persisted resolution isn't one of the presets // (e.g. user hand-edited settings.json with a non-standard size). var resolutions = DisplaySettings.AvailableResolutions.ToArray(); int idx = System.Array.IndexOf(resolutions, d.Resolution); if (idx < 0) idx = resolutions.Length - 1; if (renderer.Combo("Resolution", ref idx, resolutions)) _vm.SetDisplay(d with { Resolution = resolutions[idx] }); bool fullscreen = d.Fullscreen; if (renderer.Checkbox("Fullscreen", ref fullscreen)) _vm.SetDisplay(d with { Fullscreen = fullscreen }); bool vsync = d.VSync; if (renderer.Checkbox("V-Sync", ref vsync)) _vm.SetDisplay(d with { VSync = vsync }); float fov = d.FieldOfView; if (renderer.SliderFloat("Field of View", ref fov, 30f, 120f)) _vm.SetDisplay(d with { FieldOfView = fov }); float gamma = d.Gamma; if (renderer.SliderFloat("Gamma", ref gamma, 0.5f, 2.0f)) _vm.SetDisplay(d with { Gamma = gamma }); bool showFps = d.ShowFps; if (renderer.Checkbox("Show FPS", ref showFps)) _vm.SetDisplay(d with { ShowFps = showFps }); renderer.Spacing(); renderer.TextWrapped( "Resolution / Fullscreen / V-Sync apply on Save. FOV + Gamma " + "preview live as you drag; Cancel reverts to the saved value."); } /// /// Render the Audio tab — Master + SFX volume sliders (live preview /// against the running OpenAL engine). Music + Ambient fields exist /// in and persist round-trip, but their /// sliders are intentionally hidden here because the underlying /// engine paths (PlayMusic / StartAmbient) are stubbed for R5 MIDI /// playback that hasn't shipped yet — exposing the sliders would be /// "moving a knob that does nothing." When R5 lands, restore the /// hidden sliders below and the JSON-persisted values will already /// be in place. /// private void RenderAudioTab(IPanelRenderer renderer) { var a = _vm.AudioDraft; float master = a.Master; if (renderer.SliderFloat("Master", ref master, 0f, 1f)) _vm.SetAudio(a with { Master = master }); float sfx = a.Sfx; if (renderer.SliderFloat("SFX", ref sfx, 0f, 1f)) _vm.SetAudio(a with { Sfx = sfx }); // Music + Ambient hidden until R5 MIDI / ambient-loop engines // exist. AudioSettings still carries the fields so the JSON // round-trips and a future client doesn't drop them on save. // // float music = a.Music; // if (renderer.SliderFloat("Music", ref music, 0f, 1f)) // _vm.SetAudio(a with { Music = music }); // float ambient = a.Ambient; // if (renderer.SliderFloat("Ambient", ref ambient, 0f, 1f)) // _vm.SetAudio(a with { Ambient = ambient }); renderer.Spacing(); renderer.TextWrapped( "Volume changes preview live as you drag. Save persists the " + "values to settings.json; Cancel reverts to the saved values. " + "Music + Ambient mixing arrives with R5 MIDI playback."); } /// /// Render the Gameplay tab — ~14 toggles ported from retail's /// CharacterOption + CharacterOptions2 bitfields. Local-only this /// phase (no server sync). Grouped into Combat / Display / Interface /// for first-run discoverability. /// private void RenderGameplayTab(IPanelRenderer renderer) { var g = _vm.GameplayDraft; renderer.Text("Combat"); renderer.Separator(); bool autoTarget = g.AutoTarget; if (renderer.Checkbox("Auto-target on attack", ref autoTarget)) _vm.SetGameplay(g with { AutoTarget = autoTarget }); bool autoRepeat = g.AutoRepeatAttack; if (renderer.Checkbox("Auto-repeat attacks", ref autoRepeat)) _vm.SetGameplay(g with { AutoRepeatAttack = autoRepeat }); bool toggleRun = g.ToggleRun; if (renderer.Checkbox("Run mode is toggle (vs hold)", ref toggleRun)) _vm.SetGameplay(g with { ToggleRun = toggleRun }); bool advCombat = g.AdvancedCombatUI; if (renderer.Checkbox("Show advanced combat UI", ref advCombat)) _vm.SetGameplay(g with { AdvancedCombatUI = advCombat }); bool vivid = g.VividTargetingIndicator; if (renderer.Checkbox("Vivid targeting indicator", ref vivid)) _vm.SetGameplay(g with { VividTargetingIndicator = vivid }); renderer.Spacing(); renderer.Text("Display"); renderer.Separator(); bool tooltips = g.ShowTooltips; if (renderer.Checkbox("Show item tooltips", ref tooltips)) _vm.SetGameplay(g with { ShowTooltips = tooltips }); bool sideBySide = g.SideBySideVitals; if (renderer.Checkbox("Side-by-side vital orbs", ref sideBySide)) _vm.SetGameplay(g with { SideBySideVitals = sideBySide }); bool coords = g.CoordinatesOnRadar; if (renderer.Checkbox("Show coordinates on radar", ref coords)) _vm.SetGameplay(g with { CoordinatesOnRadar = coords }); bool spellDur = g.SpellDuration; if (renderer.Checkbox("Show spell duration on enchantments", ref spellDur)) _vm.SetGameplay(g with { SpellDuration = spellDur }); bool helm = g.ShowHelm; if (renderer.Checkbox("Show helm on character", ref helm)) _vm.SetGameplay(g with { ShowHelm = helm }); bool cloak = g.ShowCloak; if (renderer.Checkbox("Show cloak on character", ref cloak)) _vm.SetGameplay(g with { ShowCloak = cloak }); renderer.Spacing(); renderer.Text("Interface"); renderer.Separator(); bool allowGive = g.AllowGive; if (renderer.Checkbox("Accept items handed by other players", ref allowGive)) _vm.SetGameplay(g with { AllowGive = allowGive }); bool lockUI = g.LockUI; if (renderer.Checkbox("Lock UI (disable panel drag/resize)", ref lockUI)) _vm.SetGameplay(g with { LockUI = lockUI }); bool mouseTurn = g.UseMouseTurning; if (renderer.Checkbox("Use mouse turning", ref mouseTurn)) _vm.SetGameplay(g with { UseMouseTurning = mouseTurn }); renderer.Spacing(); renderer.TextWrapped( "Local-only this phase — values persist to settings.json but " + "don't yet sync to the server. Server sync arrives in a " + "follow-up phase."); } /// /// Render the Chat tab — channel filters (Hear*Chat), display /// preferences (timestamps / profanity filter / appear offline), /// and a font-size slider. Channel filters affect client-side /// display only this phase — the server still sends every line, /// the client decides what to render. /// private void RenderChatTab(IPanelRenderer renderer) { var c = _vm.ChatDraft; renderer.Text("Channel filters"); renderer.Separator(); bool general = c.HearGeneralChat; if (renderer.Checkbox("General", ref general)) _vm.SetChat(c with { HearGeneralChat = general }); bool trade = c.HearTradeChat; if (renderer.Checkbox("Trade", ref trade)) _vm.SetChat(c with { HearTradeChat = trade }); bool lfg = c.HearLFGChat; if (renderer.Checkbox("LFG (looking for group)", ref lfg)) _vm.SetChat(c with { HearLFGChat = lfg }); bool rp = c.HearRoleplayChat; if (renderer.Checkbox("Roleplay", ref rp)) _vm.SetChat(c with { HearRoleplayChat = rp }); bool society = c.HearSocietyChat; if (renderer.Checkbox("Society (CD / EW / RB)", ref society)) _vm.SetChat(c with { HearSocietyChat = society }); renderer.Spacing(); renderer.Text("Display"); renderer.Separator(); bool timestamps = c.ShowTimestamps; if (renderer.Checkbox("Show timestamps", ref timestamps)) _vm.SetChat(c with { ShowTimestamps = timestamps }); bool profanity = c.FilterProfanity; if (renderer.Checkbox("Filter profanity", ref profanity)) _vm.SetChat(c with { FilterProfanity = profanity }); bool offline = c.AppearOffline; if (renderer.Checkbox("Appear offline (hide from /who)", ref offline)) _vm.SetChat(c with { AppearOffline = offline }); float fontSize = c.FontSize; if (renderer.SliderFloat("Font size (pt)", ref fontSize, 10f, 20f)) _vm.SetChat(c with { FontSize = fontSize }); renderer.Spacing(); renderer.TextWrapped( "Channel filters hide messages from the chat window without " + "changing your server-side subscriptions. Save persists; " + "Cancel reverts."); } /// /// Render the Character tab — per-toon preferences. The host owns /// the toon-name key; the panel just edits whatever bag the host /// loaded into . /// private void RenderCharacterTab(IPanelRenderer renderer) { var c = _vm.CharacterDraft; var channels = CharacterSettings.AvailableChannels.ToArray(); int idx = System.Array.IndexOf(channels, c.DefaultChatChannel); if (idx < 0) idx = 0; if (renderer.Combo("Default chat channel", ref idx, channels)) _vm.SetCharacter(c with { DefaultChatChannel = channels[idx] }); bool autoAttack = c.AutoAttack; if (renderer.Checkbox("Auto-attack (continue swinging until target dies)", ref autoAttack)) _vm.SetCharacter(c with { AutoAttack = autoAttack }); bool confirmSalvage = c.ConfirmSalvage; if (renderer.Checkbox("Confirm before salvaging valuable items", ref confirmSalvage)) _vm.SetCharacter(c with { ConfirmSalvage = confirmSalvage }); bool pickup = c.ShowPickupMessages; if (renderer.Checkbox("Show pickup messages in chat", ref pickup)) _vm.SetCharacter(c with { ShowPickupMessages = pickup }); renderer.Spacing(); renderer.TextWrapped( "Per-character preferences — saved per toon under " + "settings.json's character[\"\"]. Local-only this " + "phase; server-sync arrives later when the protocol " + "round-trip lands."); } private void RenderSection(IPanelRenderer renderer, string label, InputAction[] actions) { // Movement defaults open; other sections collapsed for first-run UX. bool defaultOpen = label == "Movement"; if (!renderer.CollapsingHeader(label, defaultOpen)) return; foreach (var action in actions) { renderer.Text(action.ToString()); renderer.SameLine(); // Current binding(s) summary. var binds = _vm.Draft.ForAction(action).ToList(); string summary = binds.Count == 0 ? "(unbound)" : string.Join(", ", binds.Select(b => ChordLabel(b.Chord))); renderer.Text(summary); renderer.SameLine(); // Rebind button — when a rebind is in progress for THIS // action, the label changes to a "press a key..." prompt. // The "##{action}" suffix gives ImGui a stable per-row id // so multiple "Rebind" buttons don't collide. string buttonLabel = (_vm.RebindInProgress == action) ? $"Press a key... (Esc to cancel)##{action}" : $"Rebind##{action}"; if (renderer.Button(buttonLabel)) { if (binds.Count > 0 && _vm.RebindInProgress is null) _vm.BeginRebind(action, binds[0]); } renderer.SameLine(); if (renderer.Button($"Reset##{action}")) _vm.ResetActionToDefault(action); } } /// /// Render a chord as "Shift+Ctrl+A" / "W" / etc. for the /// row summary + conflict prompt. Joins held modifiers with + /// then the trigger key name. /// private static string ChordLabel(KeyChord chord) { var parts = new List(); if ((chord.Modifiers & ModifierMask.Shift) != 0) parts.Add("Shift"); if ((chord.Modifiers & ModifierMask.Ctrl) != 0) parts.Add("Ctrl"); if ((chord.Modifiers & ModifierMask.Alt) != 0) parts.Add("Alt"); if ((chord.Modifiers & ModifierMask.Win) != 0) parts.Add("Win"); parts.Add(chord.Key.ToString()); return string.Join("+", parts); } }