Lyra 设置界面 3 小结一下

设置界面的基础流程跑起来了,大部分类应该都碰了一下,选中做一个整体的规划。

设置相关文件和类

首先是GameSettings 插件。

插件根目录是一堆GameSettings的子类。

整个设置界面拥有一个数据结构,他是 UGameSettingRegistry 的子类。

这个类拥有一个无限嵌套的树结构,例如总设置分为游戏设置,视频设置,音频设置。

视频设置下面又有分辨率,画质等。

每一个节点都是一个 GameSettings。通过基础这个类,子类添加字段来实现更多自定义的功能。

UObject
└── UGameSetting                              // 所有设置项的抽象基类
    ├── UGameSettingCollection                 // 设置项容器(设置分组/分区)
    │   └── UGameSettingCollectionPage         // 可导航的子页面(选中后跳入独立页面)
    ├── UGameSettingAction                     // 动作按钮(如"自动设置画质")— 没有值
    └── UGameSettingValue                      // 有值的设置项抽象基类
        ├── UGameSettingValueScalar            // 连续值(滑块)抽象
        │   └── UGameSettingValueScalarDynamic // 【具体类】通过属性路径动态读写
        └── UGameSettingValueDiscrete          // 离散选项(旋钮)抽象
            └── UGameSettingValueDiscreteDynamic       // 【具体类】通过属性路径动态读写
                ├── UGameSettingValueDiscreteDynamic_Bool    // 布尔开关 (ON/OFF)
                ├── UGameSettingValueDiscreteDynamic_Number  // 数值列表 (0,1,2,3...)
                ├── UGameSettingValueDiscreteDynamic_Enum    // 枚举选择
                ├── UGameSettingValueDiscreteDynamic_Color   // 颜色
                └── UGameSettingValueDiscreteDynamic_Vector2D // 二维向量

======

Widget目录例如:

还是直接看UI吧

  • SettingScreen 设置主界面

    • ULyraTabListWidgetBase 继承了 UCommonTabListWidgetBase,这是通用的Tab切换控件(这和设置其实没关系)
    • SettingPanel 上面是分页切换,这是具体的设置项,左边若干设置项,右边是详情说明面板。
  • SettingPanel

    • GameSettingListView 左侧是若干设置项,父类是UListView
      • 配置关联了一个VisualsData的DA
    • GameSettingDetailView 详情页面
      • 配置关联了一个VisualsData的DA

这几个就基本包括Widgets文件夹的大部分类了。

接下来我们一个一个看

UGameSettingScreen

UCommonActivatableWidget

他的子类只是添加了几个确认,取消的按钮的回调。所以我们直接看插件里面的这个类。

UCLASS(MinimalAPI, Abstract, meta = (Category = "Settings", DisableNativeTick))
class UGameSettingScreen : public UCommonActivatableWidget
{
    // 监听设置的GameSetings的变更事件,一路广播上来到Tacker
    FGameSettingRegistryChangeTracker ChangeTracker;

    // 设置界面的主题UI
    UPROPERTY(BlueprintReadOnly, meta = (BindWidget, BlueprintProtected = true, AllowPrivateAccess = true))
    TObjectPtr<UGameSettingPanel> Settings_Panel;

    // 设置的数据结构,一个UObject子类,构造了一个无限嵌套的GameSettings树,来维护各种设置。
    UPROPERTY(Transient)
    mutable TObjectPtr<UGameSettingRegistry> Registry;
};

主要流程

创建Registry

创建Registry,然后绑定settings change 事件,在设置给settings panel

UGameSettingRegistry* UGameSettingScreen::GetOrCreateRegistry()
{
    if (Registry == nullptr)
    {
        UGameSettingRegistry* NewRegistry = this->CreateRegistry();
        NewRegistry->OnSettingChangedEvent.AddUObject(this, &ThisClass::HandleSettingChanged);

        Settings_Panel->SetRegistry(NewRegistry);

        Registry = NewRegistry;
    }

    return Registry;
}

子类重写了 CreateRegistry 创建了我们自己的结构。

然后在子类一直初始化设置GameSettings的这颗树。

void UEqZeroGameSettingRegistry::OnInitialize(ULocalPlayer* InLocalPlayer)
{
    UEqZeroLocalPlayer* EqZeroLocalPlayer = Cast<UEqZeroLocalPlayer>(InLocalPlayer);

    VideoSettings = InitializeVideoSettings(EqZeroLocalPlayer);
    if (VideoSettings)
    {
        // InitializeVideoSettings_FrameRates(VideoSettings, EqZeroLocalPlayer);
        RegisterSetting(VideoSettings);
    }

    // ...
}

设置变更

void UGameSettingScreen::ApplyChanges()
{
    if (ChangeTracker.HaveSettingsBeenChanged())
    {
        ChangeTracker.ApplyChanges();
        ClearDirtyState();
        Registry->SaveChanges();
    }
}

void UGameSettingScreen::CancelChanges()
{
    ChangeTracker.RestoreToInitial();
    ClearDirtyState();
}

void UGameSettingScreen::ClearDirtyState()
{
    ChangeTracker.ClearDirtyState();

    OnSettingsDirtyStateChanged(false);
}

这个是留给设置主界面下面的三个按钮,应用设置,取消设置。

他会通过 ChangeTracker 触发到具体的 GameSettings的 ApplyChanges。其他的都是一些事件。

显示的设置内容

比如tab切换的时候,把这个加到过滤器,然后把这一类的设置弄出来。

这里是Array其实点Tab长度通常是1。

void UGameSettingScreen::NavigateToSettings(const TArray<FName>& SettingDevNames)
{
    FGameSettingFilterState FilterState;

    for (const FName SettingDevName : SettingDevNames)
    {
        if (UGameSetting* Setting = GetRegistry()->FindSettingByDevNameChecked<UGameSetting>(SettingDevName))
        {
            FilterState.AddSettingToRootList(Setting);
        }
    }

    Settings_Panel->SetFilterState(FilterState);
}

FGameSettingRegistryChangeTracker

class FGameSettingRegistryChangeTracker : public FNoncopyable
{
private:
    bool bSettingsChanged = false;
    bool bRestoringSettings = false;

    TWeakObjectPtr<UGameSettingRegistry> Registry;
    TMap<FObjectKey, TWeakObjectPtr<UGameSetting>> DirtySettings;
};

这个拿来检测 UGameSettingRegistry 里面的 GameSettings 变化

从 UGameSettingRegistry 里面把 OnSettingChangedEvent 接过来的

void FGameSettingRegistryChangeTracker::WatchRegistry(UGameSettingRegistry* InRegistry)
{
    ClearDirtyState();
    StopWatchingRegistry();

    if (Registry.Get() != InRegistry)
    {
        Registry = InRegistry;
        InRegistry->OnSettingChangedEvent.AddRaw(this, &FGameSettingRegistryChangeTracker::HandleSettingChanged);
    }
}

void FGameSettingRegistryChangeTracker::HandleSettingChanged(UGameSetting* Setting, EGameSettingChangeReason Reason)
{
    if (bRestoringSettings)
    {
        return;
    }

    bSettingsChanged = true;
    DirtySettings.Add(FObjectKey(Setting), Setting);
}

GameSettings 数据改了,这边就回调过来,塞进来。

点击apply的时候,这里就可以找到这些 GameSetting 加 Apply。

这里 TGuardValue 作用域内赋值成某个值,析构的时候又改回去。

void FGameSettingRegistryChangeTracker::RestoreToInitial()
{
    ensure(!bRestoringSettings);
    if (bRestoringSettings)
    {
        return;
    }

    {
        TGuardValue<bool> LocalGuard(bRestoringSettings, true);
        for (auto Entry : DirtySettings)
        {
            if (UGameSettingValue* SettingValue = Cast<UGameSettingValue>(Entry.Value))
            {
                SettingValue->RestoreToInitial();
            }
        }
    }

    ClearDirtyState();
}

另一个Bool bSettingsChanged。就是是否有dirty的东西没应用了。

UGameSettingRegistry

他的子类需要重写 SaveChanges

触发一下 GameUserSettings 和 LocalSaveGame 的保存。

还有一个静态的惰性初始化的 static Get 函数。

UCLASS(MinimalAPI, Abstract, BlueprintType)
class UGameSettingRegistry : public UObject
{
    GENERATED_BODY()

    // 设置的一级分类,比如视频,音频。。。就是最顶上的Tab对于的
    UPROPERTY(Transient)
    TArray<TObjectPtr<UGameSetting>> TopLevelSettings;

    // 已注册的所有设置列表,包含一级分类和二级分类等所有设置。这个列表主要用于根据DevName查找设置。
    UPROPERTY(Transient)
    TArray<TObjectPtr<UGameSetting>> RegisteredSettings;

    UPROPERTY(Transient)
    TObjectPtr<ULocalPlayer> OwningLocalPlayer;
};

这个 UGameSettingRegistry 其实就是维护这个 GameSettings的数据结构。

核心流程,这个数据结构是这样加进来的

void UEqZeroGameSettingRegistry::OnInitialize(ULocalPlayer* InLocalPlayer)
{
    UEqZeroLocalPlayer* EqZeroLocalPlayer = Cast<UEqZeroLocalPlayer>(InLocalPlayer);

    VideoSettings = InitializeVideoSettings(EqZeroLocalPlayer);
    if (VideoSettings)
    {
        // InitializeVideoSettings_FrameRates(VideoSettings, EqZeroLocalPlayer);
        RegisterSetting(VideoSettings);
    }

    GameplaySettings = InitializeGameplaySettings(EqZeroLocalPlayer);
    if (GameplaySettings)
    {
        RegisterSetting(GameplaySettings);
    }
}

这个流程,加到TopLevelSettings

void UGameSettingRegistry::RegisterSetting(UGameSetting* InSetting)
{
    if (InSetting)
    {
        TopLevelSettings.Add(InSetting);
        InSetting->SetRegistry(this);
        RegisterInnerSettings(InSetting);
    }
}

绑定了一些事件,然后递归的把 UGameSetting 子节点全部挂上来

void UGameSettingRegistry::RegisterInnerSettings(UGameSetting* InSetting)
{
    // 绑定一些事件
    InSetting->OnSettingChangedEvent.AddUObject(this, &ThisClass::HandleSettingChanged);
    InSetting->OnSettingAppliedEvent.AddUObject(this, &ThisClass::HandleSettingApplied);
    InSetting->OnSettingEditConditionChangedEvent.AddUObject(this, &ThisClass::HandleSettingEditConditionsChanged);

    // 虽然不喜欢这样,但为了简便起见,汇总动作事件是有道理的。
    if (UGameSettingAction* ActionSetting = Cast<UGameSettingAction>(InSetting))
    {
        ActionSetting->OnExecuteNamedActionEvent.AddUObject(this, &ThisClass::HandleSettingNamedAction);
    }

    // 虽然不喜欢这样,但为了简便起见,汇总导航事件是有道理的。
    else if (UGameSettingCollectionPage* NewPageCollection = Cast<UGameSettingCollectionPage>(InSetting))
    {
        NewPageCollection->OnExecuteNavigationEvent.AddUObject(this, &ThisClass::HandleSettingNavigation);
    }

    RegisteredSettings.Add(InSetting);

    for (UGameSetting* ChildSetting : InSetting->GetChildSettings())
    {
        RegisterInnerSettings(ChildSetting);
    }
}

GetChildSettings 是这样的,这个需要我们自己根据逻辑调用 AddSetting 加进去的

UCLASS(MinimalAPI)
class UGameSettingCollection : public UGameSetting
{
    virtual TArray<UGameSetting*> GetChildSettings() override { return Settings; }
protected:
    UPROPERTY(Transient)
    TArray<TObjectPtr<UGameSetting>> Settings;
}

回顾一下

UObject
└── UGameSetting                              // 所有设置项的抽象基类
    ├── UGameSettingCollection                 // 设置项容器(设置分组/分区)
    │   └── UGameSettingCollectionPage         // 可导航的子页面(选中后跳入独立页面)
    ├── UGameSettingAction                     // 动作按钮(如"自动设置画质")— 没有值
    └── UGameSettingValue                      // 有值的设置项抽象基类

===

UGameSettingPanel

┌─────────────────────────────────────┐
│         UGameSettingPanel            │
│  ┌──────────────┐ ┌──────────────┐  │
│  │ ListView     │ │ DetailView   │  │
│  │ (设置列表)    │ │ (详情面板)    │  │
│  │              │ │              │  │
│  │ - 音量       │ │ 音量         │  │
│  │ - 分辨率  ◄──┼─┤ 描述: ...    │  │
│  │ - 画质       │ │ 当前值: 高   │  │
│  └──────────────┘ └──────────────┘  │
│          ▲                          │
│     UGameSettingRegistry (数据源)     │
└─────────────────────────────────────┘
UCLASS(MinimalAPI, Abstract)
class UGameSettingPanel : public UCommonUserWidget
{
    GENERATED_BODY()

    UPROPERTY(BlueprintReadOnly, meta = (BindWidget, BlueprintProtected = true, AllowPrivateAccess = true))
    TObjectPtr<UGameSettingListView> ListView_Settings;

    UPROPERTY(BlueprintReadOnly, meta = (BindWidgetOptional, BlueprintProtected = true, AllowPrivateAccess = true))
    TObjectPtr<UGameSettingDetailView> Details_Settings;
};

左边一个列表,里面放的是具体设置项。

右边一个详情,对于左侧设置项的详情。

左边的列表是如何加进来项目的

void UGameSettingScreen::NavigateToSettings(const TArray<FName>& SettingDevNames)
{
    FGameSettingFilterState FilterState;

    for (const FName SettingDevName : SettingDevNames)
    {
        if (UGameSetting* Setting = GetRegistry()->FindSettingByDevNameChecked<UGameSetting>(SettingDevName))
        {
            FilterState.AddSettingToRootList(Setting);
        }
    }

    Settings_Panel->SetFilterState(FilterState);
}
void UGameSettingPanel::SetFilterState(const FGameSettingFilterState& InFilterState, bool bClearNavigationStack)
{
    FilterState = InFilterState;

    if (bClearNavigationStack)
    {
        // 过滤状态栈(支持多级导航后退)
        FilterNavigationStack.Reset();
    }

    RefreshSettingsList();
}

FilterNavigationStack 这个好像是存一下过滤数据,在打开独立页面后退回来能恢复吧。(目前还没写道)

UGameSettingCollectionPage 打开的时候会往这Stack里面塞进去。

接下来就是根据过滤,填充要显示的 TArray<TObjectPtr> VisibleSettings;

然后list view调用 SetListItems

void UGameSettingPanel::RefreshSettingsList()
{
    if (RefreshHandle.IsValid())
    {
        return;
    }

    // 注册一个每帧执行的 Ticker 回调(WeakLambda 防止对象销毁后悬空指针)
    RefreshHandle = FTSTicker::GetCoreTicker().AddTicker(FTickerDelegate::CreateWeakLambda(this, [this](float DeltaTime)
    {
        QUICK_SCOPE_CYCLE_COUNTER(STAT_UGameSettingPanel_RefreshSettingsList);

        // Registry 尚未完成初始化时,返回 true 下一帧继续轮询
        if (Registry->IsFinishedInitializing())
        {
            // 根据当前 FilterState 从 Registry 获取可见的设置项列表
            VisibleSettings.Reset();
            Registry->GetSettingsForFilter(FilterState, MutableView(VisibleSettings));

            // 将过滤后的设置项填充到 ListView 控件
            ListView_Settings->SetListItems(VisibleSettings);

            // 重置 Handle,允许后续再次触发刷新
            RefreshHandle.Reset();

            // 如果有指定刷新后需要选中的设置项,在列表中查找并记录其索引
            int32 IndexToSelect = 0;
            if (DesiredSelectionPostRefresh != NAME_None)
            {
                for (int32 SettingIdx = 0; SettingIdx < VisibleSettings.Num(); ++SettingIdx)
                {
                    UGameSetting* Setting = VisibleSettings[SettingIdx];
                    if (Setting->GetDevName() == DesiredSelectionPostRefresh)
                    {
                        IndexToSelect = SettingIdx;
                        break;
                    }
                }
                DesiredSelectionPostRefresh = NAME_None;
            }

            // 刷新后自动导航并选中目标项(支持手柄焦点场景)
            //if (HasUserFocus(GetOwningPlayer()))
            if (bAdjustListViewPostRefresh)
            {
                ListView_Settings->NavigateToIndex(IndexToSelect);
                ListView_Settings->SetSelectedIndex(IndexToSelect);
            }

            // 恢复默认值,下次刷新默认会调整列表视图
            bAdjustListViewPostRefresh = true;

            // 刷新所有可见设置项的可编辑状态(如灰显/禁用等)
            for (int32 SettingIdx = 0; SettingIdx < VisibleSettings.Num(); ++SettingIdx)
            {
                if (UGameSetting* Setting = VisibleSettings[SettingIdx])
                {
                    Setting->RefreshEditableState(false);
                }
            }           

            return false; // 返回 false:执行完毕,停止 tick
        }

        return true; // 返回 true:Registry 未就绪,下一帧继续
    }));
}

UGameSettingListView

上面对着这个东西在set item。

继承自 UE 的 UListView,是一个数据驱动的虚拟化列表。它不直接创建所有 Entry 控件,而是只为屏幕可见区域生成控件,滚动时回收复用 Entry —— 这就是 UE 的 ListView 模式(类似 Android RecyclerView)。

三个重写函数

  1. OnGenerateEntryWidgetInternal — 为每个数据项生成对应的 Entry 控件

数据流:UGameSetting* Item → 查找对应的 Entry 类 → 生成控件 → 设置数据

UGameSettingListView::OnGenerateEntryWidgetInternal(...) 逻辑概括

1. 尝试从 VisualData 查找该 Setting 对应的 Entry 类
   ├─ 先查自定义逻辑 (GetCustomEntryForSetting)
   ├─ 再按 DevName 精确匹配 (EntryWidgetForName)
   └─ 最后按 UClass 继承链匹配 (EntryWidgetForClass)
2. 找不到就回退到默认的 DesiredEntryClass
3. 用 GenerateTypedEntry<T>() 生成/复用控件
4. 应用名称覆盖(NameOverrides),调用 SetSetting() 绑定数据

VisualData(UGameSettingVisualData)是一个 DataAsset

EntryWidgetForClass:
  UGameSettingValueDiscrete → W_SettingEntry_Discrete  (下拉/旋转选择器)
  UGameSettingValueScalar   → W_SettingEntry_Scalar    (滑块)
  UGameSettingAction        → W_SettingEntry_Action    (按钮)
  UGameSettingCollectionPage→ W_SettingEntry_Navigation(子页面入口)

EntryWidgetForName:
  "Screen.Resolution"       → W_SettingEntry_Resolution(特殊定制)
  1. OnIsSelectableOrNavigableInternal — 控制哪些项可以被选中/导航
bool UGameSettingListView::OnIsSelectableOrNavigableInternal(UObject* SelectedItem)
{
    if (const UGameSettingCollection* CollectionItem = Cast<UGameSettingCollection>(SelectedItem))
    {
        return CollectionItem->IsSelectable();
    }
    return true;
}

为什么要重写? 设置列表中有分组标题(UGameSettingCollection),比如"画面"、"音频"这种分类头。这些标题行通常不应该被选中或聚焦(手柄上下导航时应该跳过它们)。

  • 如果是 UGameSettingCollection(分组/集合),由 IsSelectable() 决定
  • 其他普通设置项,直接返回 true(都可选中)
  1. ValidateCompiledDefaults(仅编辑器)— 编译时校验
void UGameSettingListView::ValidateCompiledDefaults(IWidgetCompilerLog& InCompileLog) const
{
    if (!VisualData)
    {
        InCompileLog.Error(...);
    }
}

为什么要重写? 这是 UMG 控件的编译期校验。如果设计师在蓝图里放了这个 ListView 但忘记配置 VisualData,编译时就会报错,而不是运行时才崩溃。属于防御性设计。

整体流程,加item

UGameSettingRegistry (数据源)
       │
       ▼  GetSettingsForFilter()
TArray<UGameSetting*> VisibleSettings
       │
       ▼  SetListItems()
UGameSettingListView (虚拟化列表)
       │
       │  对每个可见项调用:
       ▼  OnGenerateEntryWidgetInternal()
       │
       ├─ UGameSettingValueDiscrete  → UGameSettingListEntrySetting_Discrete (旋转选择器)
       ├─ UGameSettingValueScalar    → UGameSettingListEntrySetting_Scalar   (滑块)
       ├─ UGameSettingAction         → UGameSettingListEntry_Action          (按钮)
       └─ UGameSettingCollection     → 标题行 (不可选中,OnIsSelectable 返回 false)

FGameSettingEditCondition

FGameSettingEditCondition 是一个条件对象基类,用来动态控制某个设置项的可见性、可用性。每个 UGameSetting 可以挂载多个 EditCondition,它们共同决定该设置项当前的编辑状态。

class FGameSettingEditCondition : public TSharedFromThis<FGameSettingEditCondition>
{
    // 初始化时调用
    virtual void Initialize(const ULocalPlayer* InLocalPlayer) {}

    // 设置被应用时调用
    virtual void SettingApplied(const ULocalPlayer*, UGameSetting*) const {}

    // 设置值变化时调用
    virtual void SettingChanged(const ULocalPlayer*, UGameSetting*, EGameSettingChangeReason) const {}

    // ★ 核心方法:收集编辑状态 —— 在这里修改 InOutEditState 来控制显隐/禁用
    virtual void GatherEditState(const ULocalPlayer*, FGameSettingEditableState& InOutEditState) const {}
};
class UGameSetting : public UObject
{
    TArray<TSharedRef<FGameSettingEditCondition>> EditConditions;
}

关键方法是 GatherEditState,它接收一个 FGameSettingEditableState(可读写),条件对象在里面调用 Hide()Disable()Kill() 等来叠加限制。

例如 FWhenPlayingAsPrimaryPlayer : public FGameSettingEditCondition

如果一个setting加了这个,只有主玩家才有这个选项(分屏情况下的主玩家)

void FWhenPlayingAsPrimaryPlayer::GatherEditState(const ULocalPlayer* InLocalPlayer, FGameSettingEditableState& InOutEditState) const
{
    if (!InLocalPlayer->IsPrimaryPlayer())
    {
        InOutEditState.Disable(LOCTEXT("OnlyPrimaryPlayerEditable", "Can only be changed by the primary player."));
    }
}

UGameSetting

这个代表具体的设置项

UObject
└── UGameSetting                              // 所有设置项的抽象基类
    ├── UGameSettingCollection                 // 设置项容器(设置分组/分区)
    │   └── UGameSettingCollectionPage         // 可导航的子页面(选中后跳入独立页面)
    ├── UGameSettingAction                     // 动作按钮(如"自动设置画质")— 没有值
    └── UGameSettingValue                      // 有值的设置项抽象基类
        ├── UGameSettingValueScalar            // 连续值(滑块)抽象
        │   └── UGameSettingValueScalarDynamic // 【具体类】通过属性路径动态读写
        └── UGameSettingValueDiscrete          // 离散选项(旋钮)抽象
            └── UGameSettingValueDiscreteDynamic       // 【具体类】通过属性路径动态读写
                ├── UGameSettingValueDiscreteDynamic_Bool    // 布尔开关 (ON/OFF)
                ├── UGameSettingValueDiscreteDynamic_Number  // 数值列表 (0,1,2,3...)
                ├── UGameSettingValueDiscreteDynamic_Enum    // 枚举选择
                ├── UGameSettingValueDiscreteDynamic_Color   // 颜色
                └── UGameSettingValueDiscreteDynamic_Vector2D // 二维向量

UGameSetting 东西有点多,是一个UObject,包括一些设置的名字,详情的基础信息,还有设置应用,变化的代理定义。

UCLASS(MinimalAPI)
class UGameSettingCollection : public UGameSetting
{
protected:
    UPROPERTY(Transient)
    TArray<TObjectPtr<UGameSetting>> Settings;
};

这个多了一个TArray的Settings。代表设置最大的分类。

UGameSettingValue

/**
 * 所有设置的基类,从概念上讲,这些设置是一个值,可以被更改,因此可以重置或恢复到其初始值。
 */
UCLASS(MinimalAPI, Abstract)
class UGameSettingValue : public UGameSetting
{
    GENERATED_BODY()

public:
    UE_API UGameSettingValue();
    UE_API virtual void StoreInitial() PURE_VIRTUAL(, );
    UE_API virtual void ResetToDefault() PURE_VIRTUAL(, );
    UE_API virtual void RestoreToInitial() PURE_VIRTUAL(, );
protected:
    UE_API virtual void OnInitialized() override;
};

他的子类

UGameSettingValueScalar // 连续值(滑块)抽象

UGameSettingValueDiscrete // 离散选项(旋钮)抽象

连续,例如音量1~100是连续的。

离散,例如 窗口,全屏模式枚举1,2,3是离散的

UCLASS(MinimalAPI)
class UGameSettingValueDiscreteDynamic : public UGameSettingValueDiscrete
{
protected:
    TSharedPtr<FGameSettingDataSource> Getter;
    TSharedPtr<FGameSettingDataSource> Setter;

    TOptional<FString> DefaultValue;
    FString InitialValue;

    TArray<FString> OptionValues;
    TArray<FText> OptionDisplayTexts;
};

Getter,Setter 存储了 字符串,"GetLocalSettings", "GetXXXValue()" 然后通过反射去都叫用。

TArray OptionValues;

TArray OptionDisplayTexts;

则存储这个离散值

例如

OptionValues【枚举1,枚举2,枚举3】

OptionDisplayTexts【枚举1的值,枚举2的值,枚举3的值】

这里的值是一个Text形式的。

连续的暂时没写到,都是除了这个值维护了一个double,其他差不多。

应用设置的调用流程

语言设置的应用

这个语言设置好难找。。。

void UGameSettingScreen::ApplyChanges()
{
    if (ChangeTracker.HaveSettingsBeenChanged())
    {
        ChangeTracker.ApplyChanges();
        ClearDirtyState();
        Registry->SaveChanges();
    }
}

Registry->SaveChanges();

void UEqZeroGameSettingRegistry::SaveChanges()
{
    Super::SaveChanges();

    if (UEqZeroLocalPlayer* LocalPlayer = Cast<UEqZeroLocalPlayer>(OwningLocalPlayer))
    {
        // 游戏用户设置需要应用,以处理分辨率等问题,这会间接节省(资源 / 成本等)
        LocalPlayer->GetLocalSettings()->ApplySettings(false);

        LocalPlayer->GetSharedSettings()->ApplySettings();
        LocalPlayer->GetSharedSettings()->SaveSettings();
    }
}
void UEqZeroSettingsShared::ApplySettings()
{
    ApplySubtitleOptions();
    ApplyBackgroundAudioSettings();
    ApplyCultureSettings();

    if (UEnhancedInputLocalPlayerSubsystem* System = ULocalPlayer::GetSubsystem<UEnhancedInputLocalPlayerSubsystem>(OwningPlayer))
    {
        if (UEnhancedInputUserSettings* InputSettings = System->GetUserSettings())
        {
            InputSettings->ApplySettings();
        }
    }
}

这个 ApplyCultureSettings

void UEqZeroSettingsShared::ApplyCultureSettings()
{
    if (bResetToDefaultCulture)
    {
        // 情况A:恢复系统默认语言
        const FCulturePtr SystemDefaultCulture = FInternationalization::Get().GetDefaultCulture();
        check(SystemDefaultCulture.IsValid());

        const FString CultureToApply = SystemDefaultCulture->GetName();

        // ★ 调用引擎接口切换语言
        if (FInternationalization::Get().SetCurrentCulture(CultureToApply))
        {
            // 从配置文件中移除自定义语言设置
            GConfig->RemoveKey(TEXT("Internationalization"), TEXT("Culture"), GGameUserSettingsIni);
            GConfig->Flush(false, GGameUserSettingsIni);
        }
        bResetToDefaultCulture = false;
    }
    else if (!PendingCulture.IsEmpty())
    {
        // 情况B:切换到指定语言
        // SetCurrentCulture 可能会触发 PendingCulture 被清除(如果文化设置更改被广播),因此我们会复制一份 PendingCulture 来使用。
        const FString CultureToApply = PendingCulture;

        // ★ 调用引擎接口切换语言
        if (FInternationalization::Get().SetCurrentCulture(CultureToApply))
        {
            // 注意:这是有意保存到用户配置中的。
            // 我们需要在玩家登录前以及加载界面的极早期对文本进行本地化处理。
            // 写入配置文件,下次启动时引擎会读取
            GConfig->SetString(TEXT("Internationalization"), TEXT("Culture"), *CultureToApply, GGameUserSettingsIni);
            GConfig->Flush(false, GGameUserSettingsIni);
        }
        ClearPendingCulture();
    }
}

窗口模式的应用

{
    // 窗口模式设置:窗口、窗口全屏、全屏
    UGameSettingValueDiscreteDynamic_Enum* Setting = NewObject<UGameSettingValueDiscreteDynamic_Enum>();
    Setting->SetDevName(TEXT("WindowMode"));
    Setting->SetDisplayName(LOCTEXT("WindowMode_Name", "Window Mode"));
    Setting->SetDescriptionRichText(LOCTEXT("WindowMode_Description", "In Windowed mode you can interact with other windows more easily, and drag the edges of the window to set the size. In Windowed Fullscreen mode you can easily switch between applications. In Fullscreen mode you cannot interact with other windows as easily, but the game will run slightly faster."));

    Setting->SetDynamicGetter(GET_LOCAL_SETTINGS_FUNCTION_PATH(GetFullscreenMode));
    Setting->SetDynamicSetter(GET_LOCAL_SETTINGS_FUNCTION_PATH(SetFullscreenMode));
    Setting->AddEnumOption(EWindowMode::Fullscreen, LOCTEXT("WindowModeFullscreen", "Fullscreen"));
    Setting->AddEnumOption(EWindowMode::WindowedFullscreen, LOCTEXT("WindowModeWindowedFullscreen", "Windowed Fullscreen"));
    Setting->AddEnumOption(EWindowMode::Windowed, LOCTEXT("WindowModeWindowed", "Windowed"));

    Setting->AddEditCondition(FWhenPlatformHasTrait::KillIfMissing(TAG_Platform_Trait_SupportsWindowedMode, TEXT("Platform does not support window mode")));

    WindowModeSetting = Setting;

    Display->AddSetting(Setting);
}

这里有 Setting->SetDynamicSetter(GET_LOCAL_SETTINGS_FUNCTION_PATH(SetFullscreenMode));

他会走 UGameUserSettings::SetFullscreenMode 这个接口设置到窗口模式。

反射的数值获取和修改

FString FGameSettingDataSourceDynamic::GetValueAsString(ULocalPlayer* InLocalPlayer) const
{
    FString OutStringValue;

    const bool bSuccess = PropertyPathHelpers::GetPropertyValueAsString(InLocalPlayer, DynamicPath, OutStringValue);
    ensure(bSuccess);

    return OutStringValue;
}

这个东西和我们的gen.cpp那块知识连起来。总体就是 gen.cpp维护了一个Property的链表。

// 构造时传入路径
FGameSettingDataSourceDynamic({"SharedSettings", "bForceFeedbackEnabled"});

// 读值
PropertyPathHelpers::GetPropertyValueAsString(LocalPlayer, DynamicPath, OutString);
// 写值
PropertyPathHelpers::SetPropertyValueFromString(LocalPlayer, DynamicPath, "true");

主要流程概况

           编译期                                  运行期
    ┌──────────────────┐                ┌──────────────────────────────┐
    │  UHT 解析 .h     │                │  PropertyPathHelpers 查找    │
    │  发现 UPROPERTY  │──gen.cpp──────▶│  FProperty 链表              │
    └──────────────────┘                └──────────────────────────────┘

第 1 步:UHT 在 gen.cpp 中注册 FProperty

UCLASS()
class UEqZeroSettingsShared : public ULocalPlayerSaveGame
{
    UPROPERTY()
    bool bForceFeedbackEnabled = true;
};

我们熟知的gen.cpp

// EqZeroSettingsShared.gen.cpp(自动生成)
const UECodeGen_Private::FBoolPropertyParams NewProp_bForceFeedbackEnabled = {
    "bForceFeedbackEnabled",                          // ← FName
    nullptr,
    ...,
    sizeof(bool),
    STRUCT_OFFSET(UEqZeroSettingsShared, bForceFeedbackEnabled), // ← 内存偏移量
    ...
};

引擎启动时,这些注册代码被执行,在 UEqZeroSettingsShared::StaticClass() 的 UClass 对象上构建一个 FProperty 链表:

UClass(UEqZeroSettingsShared)
  └─ PropertyLink: FBoolProperty("bForceFeedbackEnabled", Offset=0x48)
                 → FObjectProperty("...")
                 → FFloatProperty("...")
                 → ...

每个 FProperty 记录了:名字、类型、在对象内存中的偏移量。

第 2 步:路径解析 — 字符串 → FPropertyPathSegment

FCachedPropertyPath DynamicPath({"SharedSettings", "bForceFeedbackEnabled"});

构造时将路径拆分为片段:

Segment[0]: Name="SharedSettings", ArrayIndex=INDEX_NONE

Segment[1]: Name="bForceFeedbackEnabled", ArrayIndex=INDEX_NONE

第 3 步:Resolve — 在 FProperty 链表中查找

调用 DynamicPath.Resolve(LocalPlayer) 时,对每个片段执行:

FFieldVariant FPropertyPathSegment::Resolve(UStruct* InStruct) const
{
    Field = FindUFieldOrFProperty(InStruct, Name);  // ← 核心!在FProperty链表中按FName查找
    return Field;
}

indUFieldOrFProperty 就是在 UClass 的 FProperty 链表里,按名字匹配。这些 FProperty 正是 gen.cpp 注册的。

第 4 步:递归遍历路径

LocalPlayer (UEqZeroLocalPlayer*)
    │
    ▼  Segment[0]: "SharedSettings"
    FindUFieldOrFProperty(UEqZeroLocalPlayer::StaticClass(), "SharedSettings")
    → 找到 FObjectProperty, Offset=0x120
    → Container + 0x120 → 读出 UEqZeroSettingsShared* 指针
    │
    ▼  Segment[1]: "bForceFeedbackEnabled"  (叶子节点)
    FindUFieldOrFProperty(UEqZeroSettingsShared::StaticClass(), "bForceFeedbackEnabled")
    → 找到 FBoolProperty, Offset=0x48
    → Container + 0x48 → 就是 bool 变量的内存地址

第 5 步:读写值

到达叶子节点后:

操作 调用
FProperty::ContainerPtrToValuePtr<void>(Container)
得到 void*,再 ExportTextItem_Direct()
序列化为字符串
ImportText_Direct()
把字符串 "true"
反序列化,写入 void* 指向的内存

每种 FProperty 子类(FBoolPropertyFIntPropertyFStrProperty...)都知道怎么把自己的类型转为字符串以及反过来。

上一篇