Retail's ChatInterface::SetOpacity (0x004F3120) fades the WHOLE composited
window surface with one alpha; UiRenderContext.ApplyAlpha already gated
DrawSprite/DrawRect/DrawFill (since 1da697ec, pre-CH6) but DrawStringDat and
DrawString still passed applyAlpha:false, so text stayed sharp over a
translucent window. Both now route through the same chokepoint.
RetailWindowOpacityController (new) subscribes to a new
RetailWindowManager.WindowRegistered event and drives every registered
window's live Opacity from keyboard-focus state, applied to EVERY window
(chat, floaties, vitals, toolbar, ...) rather than retail's ChatInterface-only
scope — register row AP-190, retiring the stale AP-40 "fixed 0.75, no focus
transition" row in the same commit.
Verified retail's shipped opacity defaults from the decomp (constructor
literals, no cdb needed): the base ChatInterface ctor sets
DefaultOpacity=0.5/ActiveOpacity=1.0, kept unmodified by the four floating
windows; gmMainChatUI's own ctor overrides the main window to 1.0/1.0
(always fully opaque). acdream ships one shared global default (0.5/1.0)
rather than replicating the per-class override — also AP-190. The linking
invariant (raising default above active drags active UP; lowering active
below default drags default DOWN — never a clamp) is ported verbatim as
ChatOpacityLink in AcDream.UI.Abstractions, shared by the live controller
and the new Settings -> Chat tab's two linked opacity sliders.
Persistence: ChatSettings.DefaultOpacity/ActiveOpacity round-trip through
SettingsStore; Save pushes both through IRuntimeSettingsTargets.SetChatOpacity
into the live controller, no restart required.
Rider (CH6a/b re-review): strengthened the grip-media regression guard past
a bare SpriteFile != 0 check — ChatLayoutConformanceTests now drives each
live grip through a real UiRenderContext/TextRenderer (backed by the
in-memory RecordingGpuDevice test double) and asserts the draw call chain
actually queued sprite geometry, via a new TextRenderer.DebugSpriteSegments
test-only accessor.
Full Release suite 12,459 passed / 4 skipped / 0 failed (baseline
12,420/4/0). No subagents, no client launches (session hard constraints);
pending the next connected user gate for visual confirmation.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
563 lines
24 KiB
C#
563 lines
24 KiB
C#
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using AcDream.UI.Abstractions.Input;
|
|
using AcDream.UI.Abstractions.Settings;
|
|
|
|
namespace AcDream.UI.Abstractions.Panels.Settings;
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
///
|
|
/// <para>
|
|
/// Top of the panel: Save / Cancel / Reset-all action buttons (global
|
|
/// across all tabs). When <see cref="SettingsVM.PendingConflict"/> is
|
|
/// non-null, a confirmation prompt is rendered above those buttons
|
|
/// (Yes — Reassign / No — Keep existing).
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// 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.
|
|
/// </para>
|
|
/// </summary>
|
|
public sealed class SettingsPanel : IPanel
|
|
{
|
|
private readonly SettingsVM _vm;
|
|
|
|
public SettingsPanel(SettingsVM vm)
|
|
{
|
|
_vm = vm ?? throw new System.ArgumentNullException(nameof(vm));
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public string Id => "acdream.settings";
|
|
|
|
/// <inheritdoc />
|
|
public string Title => "Settings";
|
|
|
|
/// <inheritdoc />
|
|
/// <remarks>Hidden by default — opened via F11 / View menu.</remarks>
|
|
public bool IsVisible { get; set; } = false;
|
|
|
|
/// <inheritdoc />
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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.SelectQuickSlot_1, InputAction.SelectQuickSlot_2, InputAction.SelectQuickSlot_3,
|
|
InputAction.SelectQuickSlot_4, InputAction.SelectQuickSlot_5, InputAction.SelectQuickSlot_6,
|
|
InputAction.SelectQuickSlot_7, InputAction.SelectQuickSlot_8, InputAction.SelectQuickSlot_9,
|
|
InputAction.UseQuickSlot_14, InputAction.UseQuickSlot_15, InputAction.UseQuickSlot_16,
|
|
InputAction.UseQuickSlot_17, InputAction.UseQuickSlot_18,
|
|
InputAction.CreateShortcut,
|
|
});
|
|
RenderSection(renderer, "Emotes", new[]
|
|
{
|
|
InputAction.Cry, InputAction.Laugh, InputAction.Wave,
|
|
InputAction.Cheer, InputAction.PointState,
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
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 });
|
|
|
|
// A.5 T22.5: Quality preset dropdown. Drives streaming radii, MSAA,
|
|
// anisotropic level, A2C, and max completions-per-frame as a unit.
|
|
// Resolution + anisotropic + A2C + completions apply immediately via
|
|
// ReapplyQualityPreset; MSAA samples require a restart (GL context
|
|
// cannot change sample count at runtime).
|
|
var presets = s_qualityPresetNames;
|
|
int qIdx = (int)d.Quality;
|
|
if (qIdx < 0 || qIdx >= presets.Length) qIdx = (int)QualityPreset.High;
|
|
if (renderer.Combo("Quality", ref qIdx, presets))
|
|
_vm.SetDisplay(d with { Quality = (QualityPreset)qIdx });
|
|
|
|
int particleRangeIndex = (int)d.ParticleRange;
|
|
if (particleRangeIndex < 0 || particleRangeIndex >= s_particleRangeNames.Length)
|
|
particleRangeIndex = (int)DisplaySettings.Default.ParticleRange;
|
|
if (renderer.Combo("Particle Range", ref particleRangeIndex, s_particleRangeNames))
|
|
_vm.SetDisplay(d with { ParticleRange = (ParticleRange)particleRangeIndex });
|
|
|
|
renderer.Spacing();
|
|
renderer.TextWrapped(
|
|
"Resolution / Fullscreen / V-Sync apply on Save. FOV + Gamma "
|
|
+ "preview live as you drag; Cancel reverts to the saved value. "
|
|
+ "Quality preset applies streaming radius, anisotropic, and A2C "
|
|
+ "immediately on Save; MSAA sample count requires a restart. "
|
|
+ "Particle Range defaults to Extended, which doubles authored "
|
|
+ "effect distance; Retail restores the exact client cutoff.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Render the Audio tab — Master, SFX and Ambient volume sliders, all with
|
|
/// live preview against the running engine.
|
|
///
|
|
/// <para>
|
|
/// Ambient was hidden until Campaign A slice A5 because nothing drove it;
|
|
/// the region ambient soundscape now does, so the knob moves something and
|
|
/// is exposed. The Music slider is gone entirely: retail has no music
|
|
/// system, so there was never anything for it to turn down.
|
|
/// </para>
|
|
/// </summary>
|
|
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 });
|
|
|
|
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.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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 viewCombatTarget = g.ViewCombatTarget;
|
|
if (renderer.Checkbox("Keep combat target in view", ref viewCombatTarget))
|
|
_vm.SetGameplay(g with { ViewCombatTarget = viewCombatTarget });
|
|
|
|
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.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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.Text("Window transparency");
|
|
renderer.Separator();
|
|
|
|
// Campaign CH slice CH6c: retail's two linked opacity sliders
|
|
// (gmChatOptionsUI::InitOptions @0x0049FC60's DualHash pair). Dragging
|
|
// Background above Active drags Active UP to match; dragging Active below
|
|
// Background drags Background DOWN — ChatOpacityLink is the shared port of
|
|
// ChatInterface::SetDefaultOpacity/SetActiveOpacity (0x004F3BC0/0x004F3C40)
|
|
// that both this draft and the live RetailWindowOpacityController use, so the
|
|
// sliders track each other exactly like retail's options page.
|
|
float defaultOpacity = c.DefaultOpacity;
|
|
if (renderer.SliderFloat("Background opacity (unfocused)", ref defaultOpacity, 0f, 1f))
|
|
{
|
|
var (def, active) = ChatOpacityLink.SetDefault(c.ActiveOpacity, defaultOpacity);
|
|
_vm.SetChat(c with { DefaultOpacity = def, ActiveOpacity = active });
|
|
}
|
|
|
|
float activeOpacity = c.ActiveOpacity;
|
|
if (renderer.SliderFloat("Active opacity (typing / focused)", ref activeOpacity, 0f, 1f))
|
|
{
|
|
var (def, active) = ChatOpacityLink.SetActive(c.DefaultOpacity, activeOpacity);
|
|
_vm.SetChat(c with { DefaultOpacity = def, ActiveOpacity = active });
|
|
}
|
|
|
|
renderer.Spacing();
|
|
renderer.TextWrapped(
|
|
"Channel filters hide messages from the chat window without "
|
|
+ "changing your server-side subscriptions. Window transparency "
|
|
+ "applies to every retained window (chat, floaties, vitals, "
|
|
+ "toolbar, inventory...) and fades whichever window doesn't "
|
|
+ "currently have keyboard focus; Active opacity can never be "
|
|
+ "lower than Background — dragging one past the other drags "
|
|
+ "the other along. Save persists; Cancel reverts.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Render the Character tab — per-toon preferences. The host owns
|
|
/// the toon-name key; the panel just edits whatever bag the host
|
|
/// loaded into <see cref="SettingsVM.CharacterDraft"/>.
|
|
/// </summary>
|
|
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[\"<toonName>\"]. Local-only this "
|
|
+ "phase; server-sync arrives later when the protocol "
|
|
+ "round-trip lands.");
|
|
}
|
|
|
|
// A.5 T22.5: preset label array parallel to QualityPreset enum values.
|
|
// Order must match the enum (Low=0, Medium=1, High=2, Ultra=3).
|
|
private static readonly string[] s_qualityPresetNames =
|
|
{ "Low", "Medium", "High", "Ultra" };
|
|
|
|
private static readonly string[] s_particleRangeNames =
|
|
{ "Retail", "Extended" };
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Render a chord as <c>"Shift+Ctrl+A"</c> / <c>"W"</c> / etc. for the
|
|
/// row summary + conflict prompt. Joins held modifiers with <c>+</c>
|
|
/// then the trigger key name.
|
|
/// </summary>
|
|
private static string ChordLabel(KeyChord chord)
|
|
{
|
|
var parts = new List<string>();
|
|
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);
|
|
}
|
|
}
|