第10章 Ui架构
UI架构是游戏开发中至关重要的部分。本章介绍MVVM模式在Godot中的实现、UI状态机和弹窗栈管理,帮助开发者构建清晰、可维护的UI系统。
10.1 MVVM在Godot中的实现
10.1.0 设计原理与思路
MVVM模式的核心概念
MVVM(Model-View-ViewModel)是一种将UI逻辑与业务逻辑分离的架构模式,其设计理念可用以下层次结构表示:
MVVM架构层次
┌─────────────────────────────────────┐
│ View(视图) │
│ - Godot Control节点 │
│ - 负责UI布局和视觉效果 │
│ - 不包含业务逻辑 │
└──────────────┬──────────────────────┘
│ 数据绑定
▼
┌─────────────────────────────────────┐
│ ViewModel(视图模型) │
│ - 业务逻辑的抽象 │
│ - 数据转换和格式化 │
│ - 属性变更通知(PropertyChanged) │
└──────────────┬──────────────────────┘
│ 调用
▼
┌─────────────────────────────────────┐
│ Model(模型) │
│ - 游戏核心数据和状态 │
│ - 数据库/存档访问 │
│ - 纯业务逻辑 │
└─────────────────────────────────────┘
为什么在游戏开发中使用MVVM?
传统UI代码往往存在以下问题:
传统UI开发的问题
1. 代码重复
- 多个界面显示相同数据时,复制粘贴更新逻辑
- 一处修改,多处遗漏
2. 测试困难
- UI逻辑与业务逻辑混合
- 需要启动完整游戏才能测试
3. 维护成本高
- 界面调整时可能破坏业务逻辑
- 新成员理解代码门槛高
MVVM通过数据绑定机制解决这些问题:
- View只关心如何显示数据,不关心数据来源
- ViewModel只提供数据和命令,不关心UI细节
- Model只管理业务状态,不知道UI存在
数据绑定的实现机制
本框架实现的数据绑定基于C#的INotifyPropertyChanged模式,但在Godot环境中做了适配:
数据绑定流程
1. View订阅ViewModel的PropertyChanged事件
viewModel.PropertyChanged += OnPropertyChanged;
2. ViewModel数据变更时触发事件
SetProperty(value) -> OnPropertyChanged(nameof(Property))
3. View响应事件更新UI
OnPropertyChanged -> 更新对应控件的显示
4. 双向绑定时,View输入触发ViewModel更新
UI事件 -> UpdateSource -> 更新ViewModel属性
UI状态管理的复杂性
游戏UI状态管理面临独特挑战:
游戏UI状态复杂性示例
主菜单
├── 开始游戏
│ └── 存档选择 -> 载入游戏 -> 游戏场景
├── 设置
│ ├── 图形设置 -> 分辨率/画质
│ ├── 音频设置 -> 音量调节
│ └── 控制设置 -> 键位绑定
├── 成就
│ └── 成就详情弹窗
└── 退出 -> 确认弹窗
游戏场景
├── HUD(始终显示)
├── 暂停菜单(模态)
│ ├── 继续
│ ├── 设置(子页面)
│ └── 返回主菜单(确认弹窗)
├── 背包(可叠加)
└── 任务日志(可叠加)
状态管理挑战:
1. 状态嵌套:设置界面可从主菜单和暂停菜单进入
2. 弹窗叠加:背包和任务日志可同时打开
3. 返回逻辑:从子页面返回而非直接关闭
4. 状态恢复:从设置返回时恢复之前的位置
10.1.1 使用场景分析
MVVM模式适用场景
复杂数据界面
- 角色属性面板:多属性实时更新
- 装备对比界面:左右分栏,数据同步
- 背包系统:物品数据与格子UI绑定
为什么适合MVVM:数据与显示分离,一处数据变更自动更新所有相关UI
配置/设置界面
- 图形设置选项与配置文件双向绑定
- 滑块值实时预览,确认后才保存
数据驱动的列表
- 排行榜列表
- 任务列表
- 商店商品列表
为什么适合MVVM:列表项数据变化自动刷新,无需手动管理UI更新
UI状态机适用场景
多页面流程
- 新手引导:步骤1 -> 步骤2 -> 步骤3
- 装备打造:选择材料 -> 确认属性 -> 打造动画 -> 结果展示
- 剧情对话:对话1 -> 选项 -> 对话2A/对话2B
游戏状态切换
- 主菜单 -> 加载 -> 游戏场景
- 游戏场景 -> 暂停 -> 设置 -> 暂停 -> 游戏场景
- 战斗开始 -> 玩家回合 -> 敌人回合 -> 回合结束
弹窗栈适用场景
模态框管理
- 游戏中打开暂停菜单
- 暂停菜单中打开设置弹窗
- 设置中点击恢复默认弹出确认对话框
通知队列
- 成就解锁提示(多个排队显示)
- 任务完成通知
- 系统公告
层级覆盖
- 底层:游戏场景
- 中层:HUD
- 上层:菜单
- 顶层:弹窗/通知
10.1.2 实现细节讲解
属性变更通知机制
ViewModelBase的核心是属性变更通知,实现细节如下:
// CallerMemberName自动获取调用属性的名称
protected bool SetProperty<T>(T value, [CallerMemberName] string propertyName = "")
{
// 1. 检查值是否真正改变(避免无意义的通知)
if (_propertyValues.TryGetValue(propertyName, out var oldValue))
{
if (EqualityComparer<T>.Default.Equals((T)oldValue, value))
{
return false; // 值未改变,不通知
}
}
// 2. 存储新值
_propertyValues[propertyName] = value;
// 3. 批量更新模式优化
if (_batchUpdate)
{
_changedProperties.Add(propertyName); // 延迟通知
}
else
{
OnPropertyChanged(propertyName); // 立即通知
}
return true;
}
为什么需要批量更新模式?
// 场景:一次性修改多个相关属性
public void LevelUp()
{
BeginBatchUpdate(); // 开始批量更新
Level++; // 不立即通知
MaxHealth += 10; // 不立即通知
Health = MaxHealth; // 不立即通知
Attack += 5; // 不立即通知
EndBatchUpdate(); // 一次性通知所有变更
// 避免4次UI刷新,合并为1次
}
UI状态机的层次设计
状态机支持层次化状态管理:
层次状态结构
StateMachine(根)
├── MainMenuState(主菜单)
│ ├── MainMenu_Main(主界面)
│ ├── MainMenu_Settings(设置页面)
│ └── MainMenu_Credits(制作名单)
├── GameplayState(游戏场景)
│ ├── Gameplay_Normal(正常游戏)
│ ├── Gameplay_Pause(暂停)
│ │ ├── Pause_Main(暂停主界面)
│ │ └── Pause_Settings(暂停中的设置)
│ └── Gameplay_Dialog(对话中)
└── LoadingState(加载中)
设计要点:
1. 子状态继承父状态的规则
2. 状态转换可配置条件(如"游戏中不能打开主菜单")
3. 状态栈支持任意层级返回
弹窗优先级管理
弹窗栈的优先级系统确保重要弹窗不被忽略:
// 优先级定义
public enum PopupPriority
{
Lowest = -100, // 普通提示
Low = -50, // 成就通知
Normal = 0, // 普通弹窗
High = 50, // 警告提示
Critical = 100 // 错误/确认对话框
}
// 优先级管理逻辑
public void ShowPopup(IPopup popup)
{
if (_popupStack.Count >= MaxVisiblePopups)
{
// 如果新弹窗优先级更高,移除优先级最低的
if (!TryMakeSpaceForPopup(popup))
{
_popupQueue.Enqueue(popup); // 进入等待队列
return;
}
}
// ...显示弹窗
}
10.1.3 性能与权衡
数据绑定的开销
数据绑定带来便利的同时也有性能成本:
绑定性能分析
单次绑定开销:
- 内存:存储绑定关系对象(约50-100字节)
- CPU:订阅事件 + 初始值同步
运行时开销:
- 属性变更时触发事件(委托调用)
- 反射获取属性值(如使用反射绑定)
优化策略:
1. 避免过度绑定
- 静态文本不需要绑定
- 一次性初始化的数据使用OneTime绑定
2. 批量更新
- 使用BeginBatchUpdate/EndBatchUpdate
3. 延迟更新
- 不在每帧更新绑定数据
- 只在数据真正变化时更新
UI更新频率控制
// 反模式:每帧更新绑定数据
public override void _Process(double delta)
{
// 错误!每帧触发PropertyChanged
ViewModel.Health = Player.CurrentHealth;
}
// 正确做法:仅在值变化时更新
public void OnPlayerDamaged(float damage)
{
// 只在受伤时触发更新
ViewModel.Health = Player.CurrentHealth;
}
与命令模式的结合
MVVM与命令模式结合实现可撤销操作:
// 命令基类
public abstract class UICommand
{
public abstract void Execute();
public abstract void Undo();
}
// ViewModel中的命令
public class PlayerViewModel : ViewModelBase
{
// 命令属性
public ICommand LevelUpCommand { get; }
public PlayerViewModel()
{
LevelUpCommand = new RelayCommand(
execute: () => LevelUp(),
canExecute: () => Experience >= MaxExperience
);
}
}
// 可撤销的命令实现
public class ChangeEquipmentCommand : UICommand
{
private readonly PlayerViewModel _vm;
private readonly string _oldEquipment;
private readonly string _newEquipment;
public override void Execute()
{
_vm.EquippedWeapon = _newEquipment;
}
public override void Undo()
{
_vm.EquippedWeapon = _oldEquipment;
}
}
// 命令历史管理(实现撤销功能)
public class CommandHistory
{
private Stack<UICommand> _undoStack = new();
public void ExecuteCommand(UICommand command)
{
command.Execute();
_undoStack.Push(command);
}
public void Undo()
{
if (_undoStack.Count > 0)
{
_undoStack.Pop().Undo();
}
}
}
10.1.4 实战指南
实施步骤
- 创建ViewModel类
// 1. 继承ViewModelBase
public partial class InventoryViewModel : ViewModelBase
{
// 2. 定义可绑定属性
public int Gold
{
get => GetProperty<int>();
set => SetProperty(value);
}
// 3. 定义计算属性
public string GoldText => $"金币: {Gold}";
// 4. 实现初始化
public override void Initialize()
{
Gold = 1000;
}
}
- 创建View并绑定
public partial class InventoryPanel : BoundControl
{
private Label _goldLabel;
private InventoryViewModel _vm;
public override void _Ready()
{
_goldLabel = GetNode<Label>("GoldLabel");
// 创建并设置ViewModel
_vm = new InventoryViewModel();
_vm.Initialize();
SetViewModel(_vm);
}
protected override void SetupBindings()
{
// 订阅属性变更事件
_vm.PropertyChanged += (sender, e) =>
{
if (e.PropertyName == nameof(InventoryViewModel.GoldText))
{
_goldLabel.Text = _vm.GoldText;
}
};
// 初始更新
_goldLabel.Text = _vm.GoldText;
}
}
- 在View中响应用户输入
// 购买按钮点击
private void OnBuyButtonPressed()
{
// 调用ViewModel方法,不直接操作数据
_vm.SpendGold(100);
}
// ViewModel中处理业务逻辑
public void SpendGold(int amount)
{
if (Gold >= amount)
{
Gold -= amount;
// PropertyChanged自动触发,UI自动更新
}
}
常见错误:内存泄漏
// 错误示例:未取消事件订阅
public partial class PlayerPanel : Control
{
private PlayerViewModel _vm;
public override void _Ready()
{
_vm = new PlayerViewModel();
_vm.PropertyChanged += OnPropertyChanged; // 订阅
}
// 缺少 _ExitTree 取消订阅!
}
// 正确做法:在_ExitTree中取消订阅
public override void _ExitTree()
{
if (_vm != null)
{
_vm.PropertyChanged -= OnPropertyChanged;
}
}
使用BoundControl基类自动管理:
// BoundControl已封装订阅管理
public abstract partial class BoundControl : Control
{
public override void _ExitTree()
{
if (ViewModel != null)
{
ViewModel.PropertyChanged -= OnViewModelPropertyChanged;
BindingManager.Instance?.UnbindViewModel(ViewModel);
}
}
}
动画性能优化
// 反模式:使用SetProcess每帧更新动画
public override void _Process(double delta)
{
// 错误!CPU消耗大
Position += Velocity * delta;
}
// 正确做法:使用Tween
public void AnimateShow()
{
var tween = CreateTween();
tween.SetTrans(Tween.TransitionType.Quad);
tween.SetEase(Tween.EaseType.Out);
tween.TweenProperty(this, "position", targetPosition, 0.3f);
// Tween由引擎优化,性能更好
}
// 使用AnimationPlayer复用动画
public override void _Ready()
{
var animator = GetNode<AnimationPlayer>("AnimationPlayer");
animator.Play("Show"); // 复用预定义动画
}
UI元素池化
对于频繁创建销毁的UI元素(如列表项),使用对象池:
public class UIObjectPool<T> where T : Control
{
private Queue<T> _pool = new();
private PackedScene _prefab;
public T Get()
{
if (_pool.Count > 0)
{
return _pool.Dequeue();
}
return _prefab.Instantiate<T>();
}
public void Return(T obj)
{
obj.Hide();
_pool.Enqueue(obj);
}
}
// 使用示例
public partial class ItemList : Control
{
private UIObjectPool<ItemSlot> _slotPool;
public void RefreshList(List<ItemData> items)
{
// 回收旧槽位
foreach (var slot in _activeSlots)
{
_slotPool.Return(slot);
}
_activeSlots.Clear();
// 获取新槽位
foreach (var item in items)
{
var slot = _slotPool.Get();
slot.SetData(item);
slot.Show();
_activeSlots.Add(slot);
}
}
}
MVVM(Model-View-ViewModel)模式将UI逻辑与业务逻辑分离,提高代码的可测试性和可维护性。
10.1.1 ViewModelBase 实现
设计思路与原理
ViewModelBase是MVVM模式的基础抽象类,提供属性变更通知的核心机制,实现View与ViewModel的数据绑定。
核心设计原则:
- INotifyPropertyChanged:实现标准接口,让UI能够监听属性变化
- 自动通知:
SetProperty方法在值改变时自动触发事件 - 批量更新:
BeginUpdate/EndUpdate支持批量属性更新,减少UI刷新次数 - 属性名自动获取:使用
CallerMemberName自动获取调用属性名
核心实现要点
- 属性变更事件:
PropertyChanged事件是数据绑定的核心 - 泛型Set方法:
SetProperty<T>提供类型安全的属性设置 - 更新状态跟踪:
_isUpdating标志防止批量更新期间的重复通知 - 延迟通知:批量更新结束后统一触发通知,提升性能
使用说明与最佳 practices
- 适用场景:需要数据绑定的ViewModel基类
- 注意事项:
- 属性setter必须调用
SetProperty而非直接赋值 - 计算属性需在依赖属性变化时手动触发通知
- 避免在属性变更事件中执行耗时操作
- 属性setter必须调用
- 性能考虑:大量属性同时变更时使用批量更新模式
- 扩展建议:可添加
ICommand接口支持、验证错误集合等
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using Godot;
namespace GameFramework.UI.MVVM
{
/// <summary>
/// 属性变更事件参数
/// </summary>
public class PropertyChangedEventArgs : EventArgs
{
public string PropertyName { get; }
public PropertyChangedEventArgs(string propertyName)
{
PropertyName = propertyName;
}
}
/// <summary>
/// ViewModel基类
/// 实现属性变更通知机制
/// </summary>
public abstract class ViewModelBase : RefCounted
{
// 属性变更事件
public event EventHandler<PropertyChangedEventArgs> PropertyChanged;
// 属性值存储
private readonly Dictionary<string, object> _propertyValues = new();
// 批量更新模式
private bool _batchUpdate = false;
private readonly HashSet<string> _changedProperties = new();
/// <summary>
/// 获取属性值
/// </summary>
protected T GetProperty<T>([CallerMemberName] string propertyName = "")
{
if (_propertyValues.TryGetValue(propertyName, out var value))
{
return (T)value;
}
return default;
}
/// <summary>
/// 设置属性值并触发变更通知
/// </summary>
protected bool SetProperty<T>(T value, [CallerMemberName] string propertyName = "")
{
if (_propertyValues.TryGetValue(propertyName, out var oldValue))
{
if (EqualityComparer<T>.Default.Equals((T)oldValue, value))
{
return false;
}
}
_propertyValues[propertyName] = value;
if (_batchUpdate)
{
_changedProperties.Add(propertyName);
}
else
{
OnPropertyChanged(propertyName);
}
return true;
}
/// <summary>
/// 触发属性变更事件
/// </summary>
protected virtual void OnPropertyChanged(string propertyName)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
/// <summary>
/// 开始批量更新
/// </summary>
public void BeginBatchUpdate()
{
_batchUpdate = true;
_changedProperties.Clear();
}
/// <summary>
/// 结束批量更新
/// </summary>
public void EndBatchUpdate()
{
_batchUpdate = false;
// 触发所有变更的属性
foreach (var propertyName in _changedProperties)
{
OnPropertyChanged(propertyName);
}
_changedProperties.Clear();
}
/// <summary>
/// 验证ViewModel
/// </summary>
public virtual bool Validate()
{
return true;
}
/// <summary>
/// 初始化ViewModel
/// </summary>
public virtual void Initialize() { }
/// <summary>
/// 清理ViewModel
/// </summary>
public virtual void Cleanup() { }
}
/// <summary>
/// 带验证的ViewModel基类
/// </summary>
public abstract class ValidatableViewModelBase : ViewModelBase
{
// 错误信息
private readonly Dictionary<string, string> _errors = new();
// 是否有错误
public bool HasErrors => _errors.Count > 0;
// 错误变更事件
public event EventHandler<DataErrorsChangedEventArgs> ErrorsChanged;
/// <summary>
/// 获取属性的错误信息
/// </summary>
public string GetError(string propertyName)
{
return _errors.GetValueOrDefault(propertyName, null);
}
/// <summary>
/// 设置属性的错误信息
/// </summary>
protected void SetError(string propertyName, string error)
{
if (string.IsNullOrEmpty(error))
{
_errors.Remove(propertyName);
}
else
{
_errors[propertyName] = error;
}
ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(propertyName));
}
/// <summary>
/// 清除所有错误
/// </summary>
protected void ClearErrors()
{
var properties = new List<string>(_errors.Keys);
_errors.Clear();
foreach (var property in properties)
{
ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(property));
}
}
}
/// <summary>
/// 数据错误变更事件参数
/// </summary>
public class DataErrorsChangedEventArgs : EventArgs
{
public string PropertyName { get; }
public DataErrorsChangedEventArgs(string propertyName)
{
PropertyName = propertyName;
}
}
}
10.1.2 数据绑定
设计思路与原理
BindingSystem提供ViewModel属性与Godot UI控件之间的自动同步机制。
绑定模式设计:
- OneWay:ViewModel变化自动更新UI(如显示玩家名称)
- TwoWay:双向同步,UI输入也同步回ViewModel(如输入框)
- OneTime:仅初始化时绑定一次(如静态标题)
转换器支持:
- 类型转换:bool转Visibility、float转string等
- 格式化:数字格式化、日期格式化
- 条件转换:根据值返回不同结果(如HP<30%变红色)
核心实现要点
- 泛型绑定:
Bind<T>支持不同类型属性的类型安全绑定 - Godot信号集成:利用Godot的
ValueChanged信号实现双向绑定 - 弱引用:使用
WeakReference避免内存泄漏 - 自动清理:节点退出场景树时自动解除绑定
使用说明与最佳 practices
- 适用场景:需要动态更新的UI元素(血条、分数、状态显示)
- 注意事项:
- 双向绑定注意避免循环触发(值变化→更新UI→触发事件→更新值)
- 复杂转换逻辑建议放在ViewModel中而非转换器
- 列表数据使用
ObservableCollection实现自动更新
- 性能考虑:频繁变化的数值考虑节流(Throttling)
- 扩展建议:可添加验证绑定、错误提示绑定
using System;
using System.Collections.Generic;
using Godot;
namespace GameFramework.UI.MVVM
{
/// <summary>
/// 绑定模式
/// </summary>
public enum BindingMode
{
/// <summary>
/// 单向绑定:ViewModel -> View
/// </summary>
OneWay,
/// <summary>
/// 双向绑定:ViewModel <-> View
/// </summary>
TwoWay,
/// <summary>
/// 单向到源:View -> ViewModel
/// </summary>
OneWayToSource,
/// <summary>
/// 一次性绑定
/// </summary>
OneTime
}
/// <summary>
/// 绑定表达式
/// </summary>
public class BindingExpression
{
public string PropertyName { get; }
public BindingMode Mode { get; }
public Func<object, object> Converter { get; }
public Func<object, object> ConverterBack { get; }
public BindingExpression(string propertyName, BindingMode mode = BindingMode.OneWay,
Func<object, object> converter = null, Func<object, object> converterBack = null)
{
PropertyName = propertyName;
Mode = mode;
Converter = converter;
ConverterBack = converterBack;
}
}
/// <summary>
/// 数据绑定管理器
/// </summary>
public partial class BindingManager : Node
{
private static BindingManager _instance;
public static BindingManager Instance => _instance;
// 存储所有绑定关系
private readonly List<BindingEntry> _bindings = new();
// 绑定条目
private class BindingEntry
{
public ViewModelBase ViewModel { get; set; }
public Node Target { get; set; }
public string ViewModelProperty { get; set; }
public string TargetProperty { get; set; }
public BindingMode Mode { get; set; }
public Func<object, object> Converter { get; set; }
public Func<object, object> ConverterBack { get; set; }
}
public override void _EnterTree()
{
if (_instance == null)
{
_instance = this;
}
}
/// <summary>
/// 创建绑定
/// </summary>
public void Bind(ViewModelBase viewModel, Node target,
string viewModelProperty, string targetProperty,
BindingMode mode = BindingMode.OneWay,
Func<object, object> converter = null,
Func<object, object> converterBack = null)
{
var entry = new BindingEntry
{
ViewModel = viewModel,
Target = target,
ViewModelProperty = viewModelProperty,
TargetProperty = targetProperty,
Mode = mode,
Converter = converter,
ConverterBack = converterBack
};
_bindings.Add(entry);
// 订阅ViewModel属性变更
viewModel.PropertyChanged += (sender, e) =>
{
if (e.PropertyName == viewModelProperty)
{
UpdateTarget(entry);
}
};
// 初始更新
UpdateTarget(entry);
}
/// <summary>
/// 解除绑定
/// </summary>
public void Unbind(ViewModelBase viewModel, Node target)
{
_bindings.RemoveAll(b => b.ViewModel == viewModel && b.Target == target);
}
/// <summary>
/// 解除ViewModel的所有绑定
/// </summary>
public void UnbindViewModel(ViewModelBase viewModel)
{
_bindings.RemoveAll(b => b.ViewModel == viewModel);
}
/// <summary>
/// 解除目标的所有绑定
/// </summary>
public void UnbindTarget(Node target)
{
_bindings.RemoveAll(b => b.Target == target);
}
/// <summary>
/// 更新目标(ViewModel -> View)
/// </summary>
private void UpdateTarget(BindingEntry entry)
{
if (entry.Target == null || !GodotObject.IsInstanceValid(entry.Target))
{
return;
}
var value = entry.ViewModel.GetType().GetProperty(entry.ViewModelProperty)?.GetValue(entry.ViewModel);
if (entry.Converter != null)
{
value = entry.Converter(value);
}
// 使用Godot的Set方法设置属性
entry.Target.Set(entry.TargetProperty, Variant.From(value));
}
/// <summary>
/// 更新源(View -> ViewModel)
/// </summary>
public void UpdateSource(BindingEntry entry, object value)
{
if (entry.Mode == BindingMode.TwoWay || entry.Mode == BindingMode.OneWayToSource)
{
if (entry.ConverterBack != null)
{
value = entry.ConverterBack(value);
}
var property = entry.ViewModel.GetType().GetProperty(entry.ViewModelProperty);
if (property != null && property.CanWrite)
{
property.SetValue(entry.ViewModel, value);
}
}
}
}
/// <summary>
/// 绑定的控件基类
/// </summary>
public abstract partial class BoundControl : Control
{
protected ViewModelBase ViewModel { get; set; }
/// <summary>
/// 设置ViewModel
/// </summary>
public virtual void SetViewModel(ViewModelBase viewModel)
{
// 解除旧绑定
if (ViewModel != null)
{
BindingManager.Instance?.UnbindViewModel(ViewModel);
}
ViewModel = viewModel;
if (viewModel != null)
{
viewModel.PropertyChanged += OnViewModelPropertyChanged;
SetupBindings();
}
}
/// <summary>
/// 设置数据绑定
/// </summary>
protected abstract void SetupBindings();
/// <summary>
/// ViewModel属性变更回调
/// </summary>
protected virtual void OnViewModelPropertyChanged(object sender, PropertyChangedEventArgs e)
{
// 子类重写
}
public override void _ExitTree()
{
if (ViewModel != null)
{
ViewModel.PropertyChanged -= OnViewModelPropertyChanged;
BindingManager.Instance?.UnbindViewModel(ViewModel);
}
}
}
}
10.1.3 MVVM使用示例
设计思路与原理
本示例展示一个完整的玩家状态面板MVVM实现,包含ViewModel定义、数据属性和对应的Godot场景。
示例结构:
- PlayerViewModel:封装玩家数据的ViewModel
- 属性设计:名称、等级、经验、生命值等游戏核心数据
- 计算属性:经验百分比、健康状态描述等派生值
- View场景:Godot场景树中的UI控件绑定
核心实现要点
- 属性封装:使用
SetProperty实现属性变更通知 - 命令模式:
ICommand封装加经验、受伤等操作 - 场景绑定:在
_Ready中建立ViewModel与控件的绑定关系 - 生命周期:
_ExitTree中清理绑定防止内存泄漏
使用说明与最佳 practices
- 适用场景:角色状态面板、背包界面、设置面板等复杂UI
- 注意事项:
- ViewModel应独立于Godot节点,便于单元测试
- 业务逻辑放在ViewModel,视图逻辑放在View
- 避免在ViewModel中直接操作节点引用
- 性能考虑:大量数据变化时考虑批量更新
- 扩展建议:可添加数据验证、Undo/Redo支持
using Godot;
using GameFramework.UI.MVVM;
namespace GameFramework.Examples
{
/// <summary>
/// 玩家ViewModel
/// </summary>
public partial class PlayerViewModel : ViewModelBase
{
private string _playerName;
private int _level;
private int _experience;
private int _maxExperience;
private float _health;
private float _maxHealth;
private int _gold;
public string PlayerName
{
get => GetProperty<string>();
set
{
if (SetProperty(value))
{
// 触发相关属性变更
OnPropertyChanged(nameof(DisplayName));
}
}
}
public int Level
{
get => GetProperty<int>();
set
{
if (SetProperty(value))
{
OnPropertyChanged(nameof(DisplayLevel));
}
}
}
public int Experience
{
get => GetProperty<int>();
set => SetProperty(value);
}
public int MaxExperience
{
get => GetProperty<int>();
set
{
if (SetProperty(value))
{
OnPropertyChanged(nameof(ExperiencePercent));
}
}
}
public float Health
{
get => GetProperty<float>();
set
{
if (SetProperty(value))
{
OnPropertyChanged(nameof(HealthPercent));
OnPropertyChanged(nameof(HealthText));
}
}
}
public float MaxHealth
{
get => GetProperty<float>();
set
{
if (SetProperty(value))
{
OnPropertyChanged(nameof(HealthPercent));
OnPropertyChanged(nameof(HealthText));
}
}
}
public int Gold
{
get => GetProperty<int>();
set => SetProperty(value);
}
// 计算属性
public string DisplayName => $"Lv.{Level} {PlayerName}";
public string DisplayLevel => $"等级 {Level}";
public float ExperiencePercent => MaxExperience > 0 ? (float)Experience / MaxExperience : 0;
public float HealthPercent => MaxHealth > 0 ? Health / MaxHealth : 0;
public string HealthText => $"{Health:F0}/{MaxHealth:F0}";
public override void Initialize()
{
// 初始化默认值
PlayerName = "冒险者";
Level = 1;
Experience = 0;
MaxExperience = 100;
Health = 100;
MaxHealth = 100;
Gold = 0;
}
// 命令
public void LevelUp()
{
Level++;
MaxExperience = Level * 100;
Experience = 0;
MaxHealth = 100 + Level * 10;
Health = MaxHealth;
}
public void TakeDamage(float damage)
{
Health = Mathf.Max(0, Health - damage);
}
public void Heal(float amount)
{
Health = Mathf.Min(MaxHealth, Health + amount);
}
public void AddExperience(int exp)
{
Experience += exp;
if (Experience >= MaxExperience)
{
LevelUp();
}
}
}
/// <summary>
/// 玩家状态面板View
/// </summary>
public partial class PlayerStatusPanel : BoundControl
{
// UI元素
private Label _nameLabel;
private Label _levelLabel;
private ProgressBar _experienceBar;
private ProgressBar _healthBar;
private Label _healthText;
private Label _goldLabel;
public override void _Ready()
{
// 获取UI元素引用
_nameLabel = GetNode<Label>("NameLabel");
_levelLabel = GetNode<Label>("LevelLabel");
_experienceBar = GetNode<ProgressBar>("ExperienceBar");
_healthBar = GetNode<ProgressBar>("HealthBar");
_healthText = GetNode<Label>("HealthBar/HealthText");
_goldLabel = GetNode<Label>("GoldLabel");
// 创建ViewModel并设置
var viewModel = new PlayerViewModel();
viewModel.Initialize();
SetViewModel(viewModel);
}
protected override void SetupBindings()
{
if (ViewModel == null) return;
var playerVm = ViewModel as PlayerViewModel;
// 使用Godot的信号进行绑定
playerVm.PropertyChanged += (sender, e) =>
{
CallDeferred(nameof(UpdateUI), e.PropertyName);
};
// 初始更新
UpdateAllUI();
}
private void UpdateAllUI()
{
var vm = ViewModel as PlayerViewModel;
if (vm == null) return;
_nameLabel.Text = vm.DisplayName;
_levelLabel.Text = vm.DisplayLevel;
_experienceBar.Value = vm.ExperiencePercent * 100;
_healthBar.Value = vm.HealthPercent * 100;
_healthText.Text = vm.HealthText;
_goldLabel.Text = $"金币: {vm.Gold}";
}
private void UpdateUI(string propertyName)
{
var vm = ViewModel as PlayerViewModel;
if (vm == null) return;
switch (propertyName)
{
case nameof(PlayerViewModel.DisplayName):
_nameLabel.Text = vm.DisplayName;
break;
case nameof(PlayerViewModel.DisplayLevel):
_levelLabel.Text = vm.DisplayLevel;
break;
case nameof(PlayerViewModel.ExperiencePercent):
_experienceBar.Value = vm.ExperiencePercent * 100;
break;
case nameof(PlayerViewModel.HealthPercent):
_healthBar.Value = vm.HealthPercent * 100;
break;
case nameof(PlayerViewModel.HealthText):
_healthText.Text = vm.HealthText;
break;
case nameof(PlayerViewModel.Gold):
_goldLabel.Text = $"金币: {vm.Gold}";
break;
}
}
protected override void OnViewModelPropertyChanged(object sender, PropertyChangedEventArgs e)
{
UpdateUI(e.PropertyName);
}
}
}
10.1.5 MVVM在Godot中的特殊性
Godot的节点系统与传统UI框架(如WPF、UWP)有很大不同,需要特别考虑如何适配MVVM模式。
Godot信号系统与数据绑定
Godot的信号(Signal)系统可以用来实现数据绑定:
传统MVVM数据绑定 vs Godot信号绑定
传统MVVM(WPF):
View ←→ 数据绑定 ←→ ViewModel
- 基于属性的自动同步
- 需要INotifyPropertyChanged
Godot信号绑定:
View ←→ 信号订阅 ←→ ViewModel
- 基于事件的显式连接
- 使用Godot信号或C#事件
场景树生命周期对ViewModel的影响
Godot的节点生命周期需要特别处理:
- 实例化时:ViewModel可能需要异步初始化
- _EnterTree:建立信号连接
- _Ready:完成ViewModel绑定
- _ExitTree:断开信号,释放引用
- QueueFree:确保ViewModel正确清理
导出属性与依赖属性的对比
| 特性 | Godot [Export] | WPF DependencyProperty |
|---|---|---|
| 序列化 | 自动 | 需配置 |
| 编辑器支持 | 内置 | 需附加属性 |
| 变更通知 | 需手动 | 自动 |
| 绑定支持 | 需额外实现 | 原生支持 |
| 性能 | 轻量 | 较重 |
Godot MVVM最佳实践
- 使用部分类分离逻辑:将UI逻辑放在.cs文件中,场景文件保持纯净
- 延迟ViewModel初始化:在_Ready中而非构造函数中初始化
- 正确处理信号连接:在_EnterTree连接,在_ExitTree断开
- 避免循环引用:ViewModel不应直接持有View的强引用
10.2 UI状态机
UI状态机用于管理界面状态之间的转换,特别适合菜单系统和游戏状态切换。
10.2.0 设计原理与思路
为什么UI需要状态机
游戏UI通常包含多个界面(主菜单、设置、背包、商店等),这些界面之间的导航关系复杂:
UI导航示例:
主菜单
├── 开始游戏 → 选择存档 → 加载游戏 → 游戏主界面
├── 设置
│ ├── 音频设置
│ ├── 图形设置
│ ├── 控制设置
│ └── 返回主菜单
├── 成就系统
│ └── 返回主菜单
└── 退出游戏
不使用状态机的问题:
- 界面切换逻辑分散在各个UI类中
- 返回键处理复杂,难以管理导航历史
- 界面堆叠关系混乱
- 状态转换条件不明确
状态机 vs 页面导航
| 特性 | 页面导航 | 状态机 |
|---|---|---|
| 适用场景 | 简单线性流程 | 复杂多分支流程 |
| 历史管理 | 需要额外实现 | 内置支持 |
| 状态检查 | 手动判断 | 自动验证CanEnter/CanExit |
| 代码组织 | 分散 | 集中管理 |
| 可扩展性 | 差 | 好 |
UI状态机的层次设计
复杂UI需要分层状态机:
UI状态机层次:
顶层:游戏状态
├── MainMenuState(主菜单状态)
├── PlayingState(游戏状态)
└── PausedState(暂停状态)
中层:菜单状态(以MainMenu为例)
├── MainScreenState(主界面)
├── SettingsState(设置界面)
├── AchievementsState(成就界面)
└── CreditsState( credits界面)
底层:子界面状态(以Settings为例)
├── AudioSettingsState
├── GraphicsSettingsState
└── ControlsSettingsState
历史记录与后退栈管理
UI状态机需要支持"返回"操作:
- 栈式历史:后进先出,适合线性导航
- 图式历史:记录完整路径,支持任意跳转
- 混合模式:主要路径用栈,特殊跳转用图
10.2.1 UIStateMachine 实现
设计思路与原理
UIStateMachine是专为游戏UI设计的状态机,管理界面之间的切换逻辑和层级关系。
与游戏状态机的区别:
- 层级结构:UI状态有父子关系(如菜单下有子菜单)
- 覆盖关系:新状态可能覆盖而非完全替换旧状态(如弹窗)
- 返回逻辑:支持返回上一状态(如点击返回按钮)
- 动画协调:状态切换时触发过渡动画
核心组件:
- IUIState接口:定义状态的生命周期方法
- UIStateStack:管理状态栈,支持Push/Pop操作
- 状态工厂:根据状态名创建对应状态实例
核心实现要点
- 栈式管理:使用
Stack<IUIState>实现后进先出的状态切换 - 生命周期:Enter→Update→Exit的完整生命周期管理
- 数据传递:
OnEnter参数支持状态间传递数据 - 遮罩控制:自动管理下层状态的交互遮罩
使用说明与最佳 practices
- 适用场景:多层级菜单、游戏流程、界面导航系统
- 注意事项:
- 避免状态循环引用(A→B→A导致无法返回)
- 状态切换时及时清理事件监听防止内存泄漏
- 考虑异常情况的状态回退(如加载失败)
- 性能考虑:频繁切换的状态考虑对象池复用
- 扩展建议:可添加状态历史记录支持多步返回
using System;
using System.Collections.Generic;
using Godot;
namespace GameFramework.UI.StateMachine
{
/// <summary>
/// UI状态接口
/// </summary>
public interface IUIState
{
/// <summary>
/// 状态名称
/// </summary>
string StateName { get; }
/// <summary>
/// 进入状态
/// </summary>
void OnEnter(IUIState previousState, object data = null);
/// <summary>
/// 退出状态
/// </summary>
void OnExit(IUIState nextState);
/// <summary>
/// 状态更新
/// </summary>
void OnUpdate(double deltaTime);
/// <summary>
/// 是否可以进入此状态
/// </summary>
bool CanEnter(IUIState currentState);
/// <summary>
/// 是否可以从此状态退出
/// </summary>
bool CanExit(IUIState nextState);
}
/// <summary>
/// UI状态基类
/// </summary>
public abstract class UIStateBase : IUIState
{
public abstract string StateName { get; }
public virtual void OnEnter(IUIState previousState, object data = null) { }
public virtual void OnExit(IUIState nextState) { }
public virtual void OnUpdate(double deltaTime) { }
public virtual bool CanEnter(IUIState currentState) => true;
public virtual bool CanExit(IUIState nextState) => true;
}
/// <summary>
/// UI状态转换
/// </summary>
public class UIStateTransition
{
public string FromState { get; set; }
public string ToState { get; set; }
public Func<bool> Condition { get; set; }
public Action OnTransition { get; set; }
public float TransitionDuration { get; set; }
}
/// <summary>
/// UI状态机
/// </summary>
public partial class UIStateMachine : Node
{
// 状态字典
private readonly Dictionary<string, IUIState> _states = new();
// 状态转换规则
private readonly List<UIStateTransition> _transitions = new();
// 当前状态
public IUIState CurrentState { get; private set; }
// 状态栈(用于返回功能)
private readonly Stack<IUIState> _stateStack = new();
// 事件
[Signal]
public delegate void StateChangedEventHandler(string fromState, string toState);
[Signal]
public delegate void StateChangeFailedEventHandler(string fromState, string toState, string reason);
// 是否在转换中
public bool IsTransitioning { get; private set; }
// 转换动画节点
private AnimationPlayer _transitionAnimator;
public override void _Ready()
{
_transitionAnimator = new AnimationPlayer();
AddChild(_transitionAnimator);
}
public override void _Process(double delta)
{
if (CurrentState != null)
{
CurrentState.OnUpdate(delta);
}
}
/// <summary>
/// 注册状态
/// </summary>
public void RegisterState(IUIState state)
{
if (state == null)
{
GD.PushError("无法注册null状态");
return;
}
_states[state.StateName] = state;
}
/// <summary>
/// 注销状态
/// </summary>
public void UnregisterState(string stateName)
{
if (CurrentState?.StateName == stateName)
{
GD.PushWarning("无法注销当前状态");
return;
}
_states.Remove(stateName);
}
/// <summary>
/// 添加状态转换规则
/// </summary>
public void AddTransition(string fromState, string toState, Func<bool> condition = null, Action onTransition = null)
{
_transitions.Add(new UIStateTransition
{
FromState = fromState,
ToState = toState,
Condition = condition,
OnTransition = onTransition
});
}
/// <summary>
/// 添加任意状态到指定状态的转换
/// </summary>
public void AddAnyTransition(string toState, Func<bool> condition = null, Action onTransition = null)
{
_transitions.Add(new UIStateTransition
{
FromState = "*",
ToState = toState,
Condition = condition,
OnTransition = onTransition
});
}
/// <summary>
/// 切换到指定状态
/// </summary>
public bool ChangeState(string stateName, object data = null, bool pushToStack = true)
{
if (IsTransitioning)
{
EmitSignal(SignalName.StateChangeFailed,
CurrentState?.StateName ?? "None", stateName, "正在转换中");
return false;
}
if (!_states.TryGetValue(stateName, out var newState))
{
EmitSignal(SignalName.StateChangeFailed,
CurrentState?.StateName ?? "None", stateName, "状态不存在");
return false;
}
// 检查转换规则
if (!CanTransitionTo(newState))
{
EmitSignal(SignalName.StateChangeFailed,
CurrentState?.StateName ?? "None", stateName, "转换条件不满足");
return false;
}
// 检查状态自身条件
if (CurrentState != null && !newState.CanEnter(CurrentState))
{
EmitSignal(SignalName.StateChangeFailed,
CurrentState?.StateName ?? "None", stateName, "目标状态拒绝进入");
return false;
}
if (CurrentState != null && !CurrentState.CanExit(newState))
{
EmitSignal(SignalName.StateChangeFailed,
CurrentState?.StateName ?? "None", stateName, "当前状态拒绝退出");
return false;
}
// 执行状态切换
PerformStateChange(newState, data, pushToStack);
return true;
}
/// <summary>
/// 返回上一个状态
/// </summary>
public bool GoBack(object data = null)
{
if (_stateStack.Count == 0)
{
return false;
}
var previousState = _stateStack.Pop();
return ChangeState(previousState.StateName, data, false);
}
/// <summary>
/// 检查是否可以转换到指定状态
/// </summary>
private bool CanTransitionTo(IUIState newState)
{
string currentStateName = CurrentState?.StateName ?? "";
foreach (var transition in _transitions)
{
bool fromMatch = transition.FromState == currentStateName ||
transition.FromState == "*";
bool toMatch = transition.ToState == newState.StateName;
if (fromMatch && toMatch)
{
return transition.Condition?.Invoke() ?? true;
}
}
// 默认允许(如果没有特定规则)
return true;
}
/// <summary>
/// 执行状态切换
/// </summary>
private void PerformStateChange(IUIState newState, object data, bool pushToStack)
{
IsTransitioning = true;
string fromStateName = CurrentState?.StateName ?? "None";
// 退出当前状态
if (CurrentState != null)
{
CurrentState.OnExit(newState);
if (pushToStack)
{
_stateStack.Push(CurrentState);
}
}
var previousState = CurrentState;
CurrentState = newState;
// 进入新状态
CurrentState.OnEnter(previousState, data);
IsTransitioning = false;
EmitSignal(SignalName.StateChanged, fromStateName, newState.StateName);
GD.Print($"UI状态切换: {fromStateName} -> {newState.StateName}");
}
/// <summary>
/// 获取状态
/// </summary>
public IUIState GetState(string stateName)
{
return _states.GetValueOrDefault(stateName);
}
/// <summary>
/// 检查是否处于某状态
/// </summary>
public bool IsInState(string stateName)
{
return CurrentState?.StateName == stateName;
}
/// <summary>
/// 清空状态栈
/// </summary>
public void ClearStack()
{
_stateStack.Clear();
}
/// <summary>
/// 重置状态机
/// </summary>
public void Reset()
{
CurrentState?.OnExit(null);
CurrentState = null;
ClearStack();
IsTransitioning = false;
}
}
}
10.2.2 界面切换动画
设计思路与原理
AnimatedUIState扩展基础状态类,集成Godot的Tween动画系统,实现流畅的界面切换效果。
动画设计原则:
- 一致性:同类界面使用相同动画(如所有弹窗都淡入淡出)
- 流畅性:动画时长通常0.2-0.5秒,太快突兀、太慢拖沓
- 方向感:新界面从右滑入暗示前进,从下滑入暗示弹出
- 取消处理:快速切换时旧动画应优雅取消
动画类型:
- 淡入淡出:最通用,适合大多数界面
- 滑动:暗示层级关系,如菜单侧滑
- 缩放:强调弹窗、提示的重要性
- 组合:多种效果叠加创造更丰富的体验
核心实现要点
- Tween动画:使用Godot的Tween系统实现平滑过渡
- 回调保证:动画完成后执行状态切换回调
- 状态保护:动画播放期间防止重复触发
- 自定义扩展:虚方法允许子类定义特定动画
使用说明与最佳 practices
- 适用场景:所有需要过渡效果的UI状态
- 注意事项:
- 动画期间应屏蔽用户输入防止误操作
- 低性能设备可考虑减少动画或降低帧率
- 提供设置选项允许玩家关闭动画
- 性能考虑:使用Tween而非AnimationPlayer减少内存占用
- 扩展建议:可添加音效配合、粒子效果等增强反馈
using Godot;
using GameFramework.UI.StateMachine;
namespace GameFramework.UI.StateMachine
{
/// <summary>
/// 带动画的UI状态
/// </summary>
public partial class AnimatedUIState : UIStateBase
{
// 对应的UI面板
public Control Panel { get; private set; }
// 动画配置
public float EnterDuration { get; set; } = 0.3f;
public float ExitDuration { get; set; } = 0.2f;
public Tween.TransitionType TransitionType { get; set; } = Tween.TransitionType.Quad;
public Tween.EaseType EaseType { get; set; } = Tween.EaseType.Out;
// 进入动画类型
public EnterAnimationType EnterAnimation { get; set; } = EnterAnimationType.FadeIn;
public ExitAnimationType ExitAnimation { get; set; } = ExitAnimationType.FadeOut;
public enum EnterAnimationType
{
FadeIn,
SlideFromLeft,
SlideFromRight,
SlideFromTop,
SlideFromBottom,
ScaleIn,
PopIn
}
public enum ExitAnimationType
{
FadeOut,
SlideToLeft,
SlideToRight,
SlideToTop,
SlideToBottom,
ScaleOut,
PopOut
}
public AnimatedUIState(string stateName, Control panel)
{
_stateName = stateName;
Panel = panel;
}
private string _stateName;
public override string StateName => _stateName;
private Tween _activeTween;
public override void OnEnter(IUIState previousState, object data = null)
{
base.OnEnter(previousState, data);
if (Panel == null) return;
// 停止之前的动画
_activeTween?.Kill();
// 准备进入
Panel.Show();
Panel.Modulate = new Color(1, 1, 1, 1);
Panel.Scale = Vector2.One;
Panel.Position = Vector2.Zero;
// 创建进入动画
_activeTween = Panel.CreateTween();
_activeTween.SetTrans(TransitionType);
_activeTween.SetEase(EaseType);
SetupEnterAnimation(_activeTween);
_activeTween.Play();
}
public override void OnExit(IUIState nextState)
{
base.OnExit(nextState);
if (Panel == null) return;
// 停止之前的动画
_activeTween?.Kill();
// 创建退出动画
_activeTween = Panel.CreateTween();
_activeTween.SetTrans(TransitionType);
_activeTween.SetEase(EaseType);
SetupExitAnimation(_activeTween);
_activeTween.Finished += () =>
{
Panel.Hide();
};
_activeTween.Play();
}
private void SetupEnterAnimation(Tween tween)
{
var screenSize = Panel.GetViewportRect().Size;
switch (EnterAnimation)
{
case EnterAnimationType.FadeIn:
Panel.Modulate = new Color(1, 1, 1, 0);
tween.TweenProperty(Panel, "modulate", new Color(1, 1, 1, 1), EnterDuration);
break;
case EnterAnimationType.SlideFromLeft:
Panel.Position = new Vector2(-screenSize.X, 0);
tween.TweenProperty(Panel, "position", Vector2.Zero, EnterDuration);
break;
case EnterAnimationType.SlideFromRight:
Panel.Position = new Vector2(screenSize.X, 0);
tween.TweenProperty(Panel, "position", Vector2.Zero, EnterDuration);
break;
case EnterAnimationType.SlideFromTop:
Panel.Position = new Vector2(0, -screenSize.Y);
tween.TweenProperty(Panel, "position", Vector2.Zero, EnterDuration);
break;
case EnterAnimationType.SlideFromBottom:
Panel.Position = new Vector2(0, screenSize.Y);
tween.TweenProperty(Panel, "position", Vector2.Zero, EnterDuration);
break;
case EnterAnimationType.ScaleIn:
Panel.Scale = Vector2.Zero;
Panel.PivotOffset = Panel.Size / 2;
tween.TweenProperty(Panel, "scale", Vector2.One, EnterDuration);
break;
case EnterAnimationType.PopIn:
Panel.Scale = Vector2.Zero;
Panel.PivotOffset = Panel.Size / 2;
tween.TweenProperty(Panel, "scale", new Vector2(1.1f, 1.1f), EnterDuration * 0.6f);
tween.TweenProperty(Panel, "scale", Vector2.One, EnterDuration * 0.4f);
break;
}
}
private void SetupExitAnimation(Tween tween)
{
var screenSize = Panel.GetViewportRect().Size;
switch (ExitAnimation)
{
case ExitAnimationType.FadeOut:
tween.TweenProperty(Panel, "modulate", new Color(1, 1, 1, 0), ExitDuration);
break;
case ExitAnimationType.SlideToLeft:
tween.TweenProperty(Panel, "position", new Vector2(-screenSize.X, 0), ExitDuration);
break;
case ExitAnimationType.SlideToRight:
tween.TweenProperty(Panel, "position", new Vector2(screenSize.X, 0), ExitDuration);
break;
case ExitAnimationType.SlideToTop:
tween.TweenProperty(Panel, "position", new Vector2(0, -screenSize.Y), ExitDuration);
break;
case ExitAnimationType.SlideToBottom:
tween.TweenProperty(Panel, "position", new Vector2(0, screenSize.Y), ExitDuration);
break;
case ExitAnimationType.ScaleOut:
Panel.PivotOffset = Panel.Size / 2;
tween.TweenProperty(Panel, "scale", Vector2.Zero, ExitDuration);
break;
case ExitAnimationType.PopOut:
Panel.PivotOffset = Panel.Size / 2;
tween.TweenProperty(Panel, "scale", new Vector2(1.1f, 1.1f), ExitDuration * 0.3f);
tween.TweenProperty(Panel, "scale", Vector2.Zero, ExitDuration * 0.7f);
break;
}
}
}
/// <summary>
/// 过渡效果管理器
/// </summary>
public partial class TransitionManager : CanvasLayer
{
[Export] public Color FadeColor = new Color(0, 0, 0, 1);
[Export] public float DefaultDuration = 0.5f;
private ColorRect _fadeRect;
private AnimationPlayer _animator;
public override void _Ready()
{
Layer = 100; // 确保在最上层
_fadeRect = new ColorRect();
_fadeRect.Color = new Color(FadeColor, 0);
_fadeRect.SetAnchorsPreset(Control.LayoutPreset.FullRect);
AddChild(_fadeRect);
_animator = new AnimationPlayer();
AddChild(_animator);
// 创建淡入淡出动画
CreateFadeAnimations();
}
private void CreateFadeAnimations()
{
// 淡入动画
var fadeIn = new Animation();
fadeIn.ResourceName = "FadeIn";
fadeIn.Length = 1.0f;
var fadeInTrack = fadeIn.AddTrack(Animation.TrackType.Value);
fadeIn.TrackSetPath(fadeInTrack, new NodePath("ColorRect:color"));
fadeIn.TrackInsertKey(fadeInTrack, 0, new Color(FadeColor, 0));
fadeIn.TrackInsertKey(fadeInTrack, 1, FadeColor);
// 淡出动画
var fadeOut = new Animation();
fadeOut.ResourceName = "FadeOut";
fadeOut.Length = 1.0f;
var fadeOutTrack = fadeOut.AddTrack(Animation.TrackType.Value);
fadeOut.TrackSetPath(fadeOutTrack, new NodePath("ColorRect:color"));
fadeOut.TrackInsertKey(fadeOutTrack, 0, FadeColor);
fadeOut.TrackInsertKey(fadeOutTrack, 1, new Color(FadeColor, 0));
_animator.AddAnimationLibrary("", new AnimationLibrary());
_animator.GetAnimationLibrary("").AddAnimation("FadeIn", fadeIn);
_animator.GetAnimationLibrary("").AddAnimation("FadeOut", fadeOut);
}
/// <summary>
/// 播放淡入效果
/// </summary>
public void FadeIn(float duration = -1)
{
if (duration > 0)
{
_animator.SpeedScale = 1.0f / duration;
}
_animator.Play("FadeIn");
}
/// <summary>
/// 播放淡出效果
/// </summary>
public void FadeOut(float duration = -1)
{
if (duration > 0)
{
_animator.SpeedScale = 1.0f / duration;
}
_animator.Play("FadeOut");
}
/// <summary>
/// 播放过渡效果
/// </summary>
public async void PlayTransition(Action middleAction, float fadeDuration = -1)
{
float duration = fadeDuration > 0 ? fadeDuration : DefaultDuration;
FadeIn(duration);
await ToSignal(_animator, AnimationPlayer.SignalName.AnimationFinished);
middleAction?.Invoke();
FadeOut(duration);
}
}
}
10.3 弹窗栈管理
弹窗栈管理用于处理游戏中弹窗的显示顺序和返回逻辑。
10.3.1 PopupStack 实现
设计思路与原理
PopupStack管理弹窗的层级关系,确保后弹出的窗口显示在上层,支持按顺序关闭。
弹窗管理挑战:
- 层级冲突:多个弹窗同时显示时的Z-Index管理
- 返回键处理:Android/手柄的返回键应该关闭最上层弹窗
- 焦点控制:弹窗出现时背景UI应失去交互焦点
- 动画叠加:多个弹窗动画需要协调播放
栈式解决方案:
- LIFO顺序:后打开的弹窗先关闭
- 自动层级:根据栈深度自动计算Z-Index
- 背景遮罩:自动管理遮罩层的显示/隐藏
- 事件拦截:最上层弹窗拦截点击和返回键
核心实现要点
- 泛型栈结构:
Stack<IPopup>存储弹窗实例 - 优先级支持:紧急弹窗可插队显示
- 模态控制:模态弹窗阻止下层交互
- 异步等待:
ShowPopupAsync支持等待弹窗关闭
使用说明与最佳 practices
- 适用场景:确认对话框、提示消息、设置面板、商店界面
- 注意事项:
- 避免弹窗嵌套过深(超过3层体验差)
- 模态弹窗应明确告诉用户如何关闭
- 考虑弹窗在屏幕边缘的适配
- 性能考虑:弹窗对象池复用减少实例化开销
- 扩展建议:可添加弹窗队列,避免同时弹出多个
using System.Collections.Generic;
using Godot;
namespace GameFramework.UI.Popup
{
/// <summary>
/// 弹窗接口
/// </summary>
public interface IPopup
{
/// <summary>
/// 弹窗ID
/// </summary>
string PopupId { get; }
/// <summary>
/// 是否为模态弹窗
/// </summary>
bool IsModal { get; }
/// <summary>
/// 是否可以被其他弹窗覆盖
/// </summary>
bool CanBeOverridden { get; }
/// <summary>
/// 优先级(越高越优先显示)
/// </summary>
int Priority { get; }
/// <summary>
/// 显示弹窗
/// </summary>
void ShowPopup(object data = null);
/// <summary>
/// 隐藏弹窗
/// </summary>
void HidePopup();
/// <summary>
/// 关闭弹窗
/// </summary>
void ClosePopup();
/// <summary>
/// 弹窗关闭回调
/// </summary>
void OnPopupClosed();
}
/// <summary>
/// 弹窗基类
/// </summary>
public abstract partial class PopupBase : Control, IPopup
{
[Export] public string PopupId { get; set; }
[Export] public bool IsModal { get; set; } = true;
[Export] public bool CanBeOverridden { get; set; } = true;
[Export] public int Priority { get; set; } = 0;
// 关闭按钮
protected Button CloseButton { get; set; }
// 背景遮罩
protected ColorRect Backdrop { get; set; }
// 是否正在显示
public bool IsShowing { get; protected set; }
// 弹窗结果
public object Result { get; protected set; }
public override void _Ready()
{
// 创建背景遮罩
if (IsModal)
{
CreateBackdrop();
}
// 设置默认ID
if (string.IsNullOrEmpty(PopupId))
{
PopupId = Name;
}
// 初始隐藏
Hide();
IsShowing = false;
// 连接关闭按钮
CloseButton = GetNodeOrNull<Button>("CloseButton");
if (CloseButton != null)
{
CloseButton.Pressed += OnCloseButtonPressed;
}
// 连接输入事件
GuiInput += OnGuiInput;
}
/// <summary>
/// 创建背景遮罩
/// </summary>
private void CreateBackdrop()
{
Backdrop = new ColorRect();
Backdrop.Color = new Color(0, 0, 0, 0.7f);
Backdrop.SetAnchorsPreset(Control.LayoutPreset.FullRect);
Backdrop.ZIndex = -1;
// 点击遮罩关闭
Backdrop.GuiInput += (@event) =>
{
if (@event is InputEventMouseButton mouseButton &&
mouseButton.ButtonIndex == MouseButton.Left &&
mouseButton.Pressed)
{
if (CanBeOverridden)
{
ClosePopup();
}
}
};
AddChild(Backdrop);
}
public virtual void ShowPopup(object data = null)
{
Show();
IsShowing = true;
// 播放显示动画
PlayShowAnimation();
OnPopupShown(data);
}
public virtual void HidePopup()
{
IsShowing = false;
// 播放隐藏动画
PlayHideAnimation();
OnPopupHidden();
}
public virtual void ClosePopup()
{
PopupStack.Instance?.ClosePopup(this);
}
public virtual void OnPopupClosed()
{
Hide();
IsShowing = false;
}
/// <summary>
/// 弹窗显示时调用
/// </summary>
protected virtual void OnPopupShown(object data) { }
/// <summary>
/// 弹窗隐藏时调用
/// </summary>
protected virtual void OnPopupHidden() { }
/// <summary>
/// 关闭按钮按下
/// </summary>
private void OnCloseButtonPressed()
{
ClosePopup();
}
/// <summary>
/// GUI输入事件
/// </summary>
private void OnGuiInput(InputEvent @event)
{
if (@event is InputEventKey keyEvent && keyEvent.Pressed)
{
if (keyEvent.Keycode == Key.Escape && CanBeOverridden)
{
ClosePopup();
}
}
}
/// <summary>
/// 播放显示动画
/// </summary>
protected virtual void PlayShowAnimation()
{
Modulate = new Color(1, 1, 1, 0);
Scale = new Vector2(0.9f, 0.9f);
var tween = CreateTween();
tween.SetParallel(true);
tween.TweenProperty(this, "modulate", new Color(1, 1, 1, 1), 0.2f);
tween.TweenProperty(this, "scale", Vector2.One, 0.2f);
}
/// <summary>
/// 播放隐藏动画
/// </summary>
protected virtual void PlayHideAnimation()
{
var tween = CreateTween();
tween.SetParallel(true);
tween.TweenProperty(this, "modulate", new Color(1, 1, 1, 0), 0.15f);
tween.TweenProperty(this, "scale", new Vector2(0.9f, 0.9f), 0.15f);
tween.Finished += () =>
{
Hide();
};
}
/// <summary>
/// 设置结果
/// </summary>
protected void SetResult(object result)
{
Result = result;
}
}
/// <summary>
/// 弹窗栈管理器
/// </summary>
public partial class PopupStack : CanvasLayer
{
private static PopupStack _instance;
public static PopupStack Instance => _instance;
// 弹窗栈
private readonly Stack<IPopup> _popupStack = new();
// 弹窗队列(等待显示的弹窗)
private readonly Queue<IPopup> _popupQueue = new();
// 弹窗容器
private Control _popupContainer;
// 最大同时显示的弹窗数
[Export] public int MaxVisiblePopups = 3;
// 是否暂停游戏
[Export] public bool PauseGameOnPopup = true;
// 当前是否有模态弹窗
public bool HasModalPopup
{
get
{
foreach (var popup in _popupStack)
{
if (popup.IsModal)
return true;
}
return false;
}
}
public override void _EnterTree()
{
if (_instance == null)
{
_instance = this;
}
}
public override void _Ready()
{
Layer = 50; // UI层之上
_popupContainer = new Control();
_popupContainer.SetAnchorsPreset(Control.LayoutPreset.FullRect);
_popupContainer.MouseFilter = Control.MouseFilterEnum.Pass;
AddChild(_popupContainer);
}
/// <summary>
/// 显示弹窗
/// </summary>
public void ShowPopup(IPopup popup, object data = null)
{
if (popup == null) return;
// 检查是否已在栈中
if (_popupStack.Contains(popup))
{
GD.PushWarning($"弹窗 {popup.PopupId} 已在显示中");
return;
}
// 检查是否超出最大显示数
if (_popupStack.Count >= MaxVisiblePopups)
{
// 如果新弹窗优先级更高,移除优先级最低的
if (!TryMakeSpaceForPopup(popup))
{
_popupQueue.Enqueue(popup);
return;
}
}
// 添加到栈
_popupStack.Push(popup);
// 添加到容器
if (popup is Control control)
{
if (control.GetParent() != _popupContainer)
{
_popupContainer.AddChild(control);
}
}
// 暂停下层交互
UpdateInputPassthrough();
// 显示弹窗
popup.ShowPopup(data);
// 暂停游戏
if (PauseGameOnPopup && popup.IsModal)
{
GetTree().Paused = true;
}
GD.Print($"显示弹窗: {popup.PopupId},当前栈大小: {_popupStack.Count}");
}
/// <summary>
/// 关闭指定弹窗
/// </summary>
public void ClosePopup(IPopup popup)
{
if (popup == null) return;
// 如果目标弹窗不在栈顶,先关闭栈顶的
if (_popupStack.Count > 0 && _popupStack.Peek() != popup)
{
// 找到并移除目标弹窗
var tempStack = new Stack<IPopup>();
while (_popupStack.Count > 0)
{
var current = _popupStack.Pop();
if (current == popup)
{
current.OnPopupClosed();
break;
}
tempStack.Push(current);
}
// 恢复其他弹窗
while (tempStack.Count > 0)
{
_popupStack.Push(tempStack.Pop());
}
}
else if (_popupStack.Count > 0)
{
// 目标弹窗在栈顶
_popupStack.Pop();
popup.OnPopupClosed();
}
// 从队列中移除
var queueList = new List<IPopup>(_popupQueue);
queueList.Remove(popup);
_popupQueue.Clear();
foreach (var queuedPopup in queueList)
{
_popupQueue.Enqueue(queuedPopup);
}
// 更新输入传递
UpdateInputPassthrough();
// 恢复游戏
if (PauseGameOnPopup && !HasModalPopup)
{
GetTree().Paused = false;
}
// 检查队列
ProcessQueue();
GD.Print($"关闭弹窗: {popup.PopupId},当前栈大小: {_popupStack.Count}");
}
/// <summary>
/// 关闭栈顶弹窗
/// </summary>
public void CloseTopPopup()
{
if (_popupStack.Count > 0)
{
var popup = _popupStack.Peek();
if (popup.CanBeOverridden)
{
ClosePopup(popup);
}
}
}
/// <summary>
/// 关闭所有弹窗
/// </summary>
public void CloseAllPopups()
{
while (_popupStack.Count > 0)
{
var popup = _popupStack.Pop();
popup.OnPopupClosed();
}
_popupQueue.Clear();
UpdateInputPassthrough();
if (PauseGameOnPopup)
{
GetTree().Paused = false;
}
}
/// <summary>
/// 获取栈顶弹窗
/// </summary>
public IPopup GetTopPopup()
{
return _popupStack.Count > 0 ? _popupStack.Peek() : null;
}
/// <summary>
/// 检查弹窗是否在栈中
/// </summary>
public bool IsPopupInStack(string popupId)
{
foreach (var popup in _popupStack)
{
if (popup.PopupId == popupId)
return true;
}
return false;
}
/// <summary>
/// 尝试为新弹窗腾出空间
/// </summary>
private bool TryMakeSpaceForPopup(IPopup newPopup)
{
// 找到优先级最低的可以被覆盖的弹窗
var tempStack = new Stack<IPopup>();
IPopup lowestPriorityPopup = null;
while (_popupStack.Count > 0)
{
var current = _popupStack.Pop();
if (current.CanBeOverridden && (lowestPriorityPopup == null || current.Priority < lowestPriorityPopup.Priority))
{
if (lowestPriorityPopup != null)
{
tempStack.Push(lowestPriorityPopup);
}
lowestPriorityPopup = current;
}
else
{
tempStack.Push(current);
}
}
// 恢复栈
while (tempStack.Count > 0)
{
_popupStack.Push(tempStack.Pop());
}
// 如果新弹窗优先级更高,关闭最低优先级的
if (lowestPriorityPopup != null && newPopup.Priority > lowestPriorityPopup.Priority)
{
ClosePopup(lowestPriorityPopup);
return true;
}
return false;
}
/// <summary>
/// 处理等待队列
/// </summary>
private void ProcessQueue()
{
while (_popupQueue.Count > 0 && _popupStack.Count < MaxVisiblePopups)
{
var popup = _popupQueue.Dequeue();
ShowPopup(popup);
}
}
/// <summary>
/// 更新输入传递
/// </summary>
private void UpdateInputPassthrough()
{
if (_popupStack.Count == 0)
{
_popupContainer.MouseFilter = Control.MouseFilterEnum.Pass;
return;
}
// 只有栈顶的模态弹窗需要拦截输入
var topPopup = _popupStack.Peek();
if (topPopup.IsModal)
{
_popupContainer.MouseFilter = Control.MouseFilterEnum.Stop;
}
else
{
_popupContainer.MouseFilter = Control.MouseFilterEnum.Pass;
}
}
public override void _Input(InputEvent @event)
{
// ESC键关闭顶层弹窗
if (@event is InputEventKey keyEvent && keyEvent.Pressed && keyEvent.Keycode == Key.Escape)
{
CloseTopPopup();
}
}
}
}
10.3.2 模态框管理
设计思路与原理
模态框是一种强制用户必须处理的弹窗,会阻止用户与背后内容的交互,适用于重要确认和选择。
模态设计原则:
- 明确目的:模态框应该让用户清楚需要做什么决定
- 简洁文案:标题和描述要简短明了
- 行动突出:主要操作按钮应醒目(如蓝色)
- 安全出口:取消/关闭按钮要明确,防止用户 trapped
常用模态类型:
- 确认对话框:删除、退出等不可逆操作确认
- 输入对话框:需要用户输入文本或数值
- 选择对话框:从多个选项中选择一个
- 通知对话框:重要信息提示,只有一个确定按钮
核心实现要点
- 回调机制:使用
Action<bool>或Task<bool>返回用户选择 - 默认值:确定/取消的默认焦点应根据场景设置
- 快捷操作:支持Enter确认、ESC取消的键盘操作
- 视觉区分:确认和取消按钮使用不同颜色区分重要性
使用说明与最佳 practices
- 适用场景:删除确认、退出确认、重要设置变更、错误提示
- 注意事项:
- 不要滥用模态框,频繁打断用户很烦人
- 避免模态框上再弹模态框
- 移动端要考虑屏幕大小适配
- 性能考虑:模态框通常较轻量,无需对象池
- 扩展建议:可添加图标支持、倒计时自动关闭等特性
using Godot;
using GameFramework.UI.Popup;
namespace GameFramework.UI.Popup
{
/// <summary>
/// 确认对话框
/// </summary>
public partial class ConfirmDialog : PopupBase
{
[Export] public string Title { get; set; } = "确认";
[Export] public string Message { get; set; } = "确定要执行此操作吗?";
[Export] public string ConfirmText { get; set; } = "确定";
[Export] public string CancelText { get; set; } = "取消";
private Label _titleLabel;
private Label _messageLabel;
private Button _confirmButton;
private Button _cancelButton;
// 回调
public delegate void ConfirmCallback(bool confirmed);
public ConfirmCallback OnConfirm;
public override void _Ready()
{
base._Ready();
// 创建UI
CreateUI();
// 设置文本
UpdateText();
}
private void CreateUI()
{
// 面板容器
var panel = new PanelContainer();
panel.SetAnchorsPreset(Control.LayoutPreset.Center);
panel.CustomMinimumSize = new Vector2(400, 200);
AddChild(panel);
// 垂直布局
var vbox = new VBoxContainer();
vbox.SetAnchorsPreset(Control.LayoutPreset.FullRect);
vbox.AddThemeConstantOverride("separation", 20);
vbox.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
vbox.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
panel.AddChild(vbox);
// 标题
_titleLabel = new Label();
_titleLabel.HorizontalAlignment = HorizontalAlignment.Center;
_titleLabel.AddThemeFontSizeOverride("font_size", 24);
vbox.AddChild(_titleLabel);
// 消息
_messageLabel = new Label();
_messageLabel.HorizontalAlignment = HorizontalAlignment.Center;
_messageLabel.AutowrapMode = TextServer.AutowrapMode.Word;
_messageLabel.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
vbox.AddChild(_messageLabel);
// 按钮容器
var hbox = new HBoxContainer();
hbox.Alignment = BoxContainer.AlignmentMode.Center;
hbox.AddThemeConstantOverride("separation", 20);
vbox.AddChild(hbox);
// 确定按钮
_confirmButton = new Button();
_confirmButton.Pressed += OnConfirmPressed;
hbox.AddChild(_confirmButton);
// 取消按钮
_cancelButton = new Button();
_cancelButton.Pressed += OnCancelPressed;
hbox.AddChild(_cancelButton);
}
private void UpdateText()
{
_titleLabel.Text = Title;
_messageLabel.Text = Message;
_confirmButton.Text = ConfirmText;
_cancelButton.Text = CancelText;
}
private void OnConfirmPressed()
{
SetResult(true);
OnConfirm?.Invoke(true);
ClosePopup();
}
private void OnCancelPressed()
{
SetResult(false);
OnConfirm?.Invoke(false);
ClosePopup();
}
/// <summary>
/// 静态方法:显示确认对话框
/// </summary>
public static void Show(string title, string message, ConfirmCallback callback,
string confirmText = "确定", string cancelText = "取消")
{
var dialog = new ConfirmDialog
{
Title = title,
Message = message,
ConfirmText = confirmText,
CancelText = cancelText,
OnConfirm = callback
};
PopupStack.Instance?.ShowPopup(dialog);
}
}
/// <summary>
/// 提示对话框
/// </summary>
public partial class AlertDialog : PopupBase
{
[Export] public string Title { get; set; } = "提示";
[Export] public string Message { get; set; } = "";
[Export] public string OkText { get; set; } = "确定";
[Export] public AlertType Type { get; set; } = AlertType.Info;
public enum AlertType
{
Info,
Warning,
Error,
Success
}
private Label _titleLabel;
private Label _messageLabel;
private Button _okButton;
private TextureRect _iconRect;
public delegate void AlertCallback();
public AlertCallback OnOk;
public override void _Ready()
{
base._Ready();
CreateUI();
UpdateText();
UpdateStyle();
}
private void CreateUI()
{
var panel = new PanelContainer();
panel.SetAnchorsPreset(Control.LayoutPreset.Center);
panel.CustomMinimumSize = new Vector2(350, 180);
AddChild(panel);
var margin = new MarginContainer();
margin.AddThemeConstantOverride("margin_left", 20);
margin.AddThemeConstantOverride("margin_right", 20);
margin.AddThemeConstantOverride("margin_top", 20);
margin.AddThemeConstantOverride("margin_bottom", 20);
panel.AddChild(margin);
var vbox = new VBoxContainer();
vbox.AddThemeConstantOverride("separation", 15);
margin.AddChild(vbox);
// 标题行
var titleHBox = new HBoxContainer();
titleHBox.AddThemeConstantOverride("separation", 10);
vbox.AddChild(titleHBox);
_iconRect = new TextureRect();
_iconRect.CustomMinimumSize = new Vector2(32, 32);
_iconRect.ExpandMode = TextureRect.ExpandModeEnum.FitWidthProportional;
titleHBox.AddChild(_iconRect);
_titleLabel = new Label();
_titleLabel.AddThemeFontSizeOverride("font_size", 22);
titleHBox.AddChild(_titleLabel);
// 消息
_messageLabel = new Label();
_messageLabel.AutowrapMode = TextServer.AutowrapMode.Word;
_messageLabel.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
vbox.AddChild(_messageLabel);
// 确定按钮
_okButton = new Button();
_okButton.Pressed += OnOkPressed;
_okButton.SizeFlagsHorizontal = Control.SizeFlags.ShrinkCenter;
vbox.AddChild(_okButton);
}
private void UpdateText()
{
_titleLabel.Text = Title;
_messageLabel.Text = Message;
_okButton.Text = OkText;
}
private void UpdateStyle()
{
// 根据类型设置颜色和图标
Color color;
string iconPath;
switch (Type)
{
case AlertType.Warning:
color = Colors.Yellow;
iconPath = "res://assets/icons/warning.png";
break;
case AlertType.Error:
color = Colors.Red;
iconPath = "res://assets/icons/error.png";
break;
case AlertType.Success:
color = Colors.Green;
iconPath = "res://assets/icons/success.png";
break;
default:
color = Colors.Blue;
iconPath = "res://assets/icons/info.png";
break;
}
_titleLabel.Modulate = color;
// 加载图标
if (ResourceLoader.Exists(iconPath))
{
_iconRect.Texture = ResourceLoader.Load<Texture2D>(iconPath);
}
}
private void OnOkPressed()
{
OnOk?.Invoke();
ClosePopup();
}
public static void Show(string title, string message, AlertType type = AlertType.Info, AlertCallback callback = null)
{
var dialog = new AlertDialog
{
Title = title,
Message = message,
Type = type,
OnOk = callback
};
PopupStack.Instance?.ShowPopup(dialog);
}
public static void ShowInfo(string title, string message, AlertCallback callback = null)
{
Show(title, message, AlertType.Info, callback);
}
public static void ShowWarning(string title, string message, AlertCallback callback = null)
{
Show(title, message, AlertType.Warning, callback);
}
public static void ShowError(string title, string message, AlertCallback callback = null)
{
Show(title, message, AlertType.Error, callback);
}
public static void ShowSuccess(string title, string message, AlertCallback callback = null)
{
Show(title, message, AlertType.Success, callback);
}
}
}
10.4 本章小结
本章介绍了UI架构的三大核心组件:
MVVM模式:通过ViewModelBase实现属性变更通知,使用数据绑定机制将UI与数据分离,提高代码的可测试性和可维护性。
UI状态机:管理界面状态之间的转换,支持动画过渡、状态栈和转换规则,适用于复杂的菜单系统和游戏状态管理。
弹窗栈管理:处理弹窗的显示顺序和生命周期,支持模态弹窗、优先级管理和队列机制。
这些组件可以组合使用:
- MVVM + 状态机:每个UI状态使用MVVM管理数据
- 弹窗 + MVVM:弹窗内部使用MVVM模式
- 状态机 + 弹窗:状态切换时自动处理弹窗
合理运用这些架构模式可以构建出清晰、灵活、易于维护的UI系统。