主题
GameSystemUI 卡牌控件
GameSystemUI.CardSystemUI 是一套客户端卡牌 UI 和拖拽框架,适合做手牌、技能卡、装备卡槽、场景拖放释放等表现。它只负责显示、拖拽、放置目标检测和视觉反馈。
它不包含完整卡牌玩法规则。以下内容需要项目自己实现:
- 卡组、抽牌、弃牌、冷却、费用、回合等玩法规则。
- 服务端权威校验和同步。
- 卡牌效果执行、扣资源、发技能或发送自定义请求。
ICard运行时实例对象。
常用命名空间:
csharp
using GameData;
using GameSystemUI.CardSystem;
using GameSystemUI.CardSystem.Data;
#if CLIENT
using GameCore.EntitySystem;
using GameSystemUI.CardSystemUI.Advanced;
using GameSystemUI.CardSystemUI.Data;
using GameUI.Enum;
#endif推荐公开入口
| 类型 | 作用 | 推荐用法 |
|---|---|---|
ICard | 项目运行时卡牌实例契约 | 必须由项目实现 |
GameDataCard | 卡牌静态配置 | 放名称、图标、费用、类型、场景释放配置 |
CardContainer | 默认水平手牌容器 | 做手牌栏或卡牌列表 |
CardUI | 单张卡牌默认 UI | 一般由 CardContainer 创建,也可单独显示 |
CardSlotDropTarget | 单卡槽放置目标 | 做装备槽、技能槽、构筑槽 |
CardManager | 拖拽和场景放置管理器 | 配置场景拖放、验证和执行回调 |
CardDropTargetBase | 自定义放置目标基类 | 做复杂 UI 放置区域 |
如果只是做普通手牌和拖放,先用 CardContainer、CardSlotDropTarget 和 CardManager。只有默认卡牌外观不够时,再实现 ICardUI 或继承 CardContainerBase。
定义卡牌数据
GameDataCard 是卡牌静态配置。项目通常在 GameData 初始化阶段创建:
csharp
using GameData;
using GameSystemUI.CardSystem.Data;
public static class MyCards
{
public static readonly GameLink<GameDataCard, GameDataCard> Fireball =
new("Fireball"u8);
}
private static void OnGameDataInitialization()
{
_ = new GameDataCard(MyCards.Fireball)
{
Name = "火球",
Description = "拖到地面后在目标点释放火球。",
IconPath = "image/cards/fireball.png",
BackgroundPath = "image/cards/card_bg.png",
BorderPath = "image/cards/card_border.png",
Type = CardType.Skill,
Cost = 2,
CanDropToScene = true,
SceneTargetType = CardSceneTargetType.Point,
AbilityLink = MyAbilities.Fireball,
};
}GameDataCard 只描述“这是什么卡”。同一张卡在玩家手里可能有多个实例,所以运行时还需要实现 ICard。
实现运行时 ICard
ICard.InstanceId 用来区分同类型卡牌的多个实例。IsUsable 应该反映项目自己的费用、冷却、回合或状态判断。
csharp
using GameData;
using GameSystemUI.CardSystem;
using GameSystemUI.CardSystem.Data;
public sealed class RuntimeCard : ICard
{
private static long nextId = 1;
public long InstanceId { get; } = nextId++;
public IGameLink<GameDataCard> CardLink { get; }
public GameDataCard? CardData => CardLink.Data;
public string Name => CardData?.Name ?? string.Empty;
public int CurrentCost => CardData?.Cost ?? 0;
public int OwnerResource { get; set; }
public bool IsOnCooldown { get; set; }
public bool IsUsable => !IsOnCooldown && OwnerResource >= CurrentCost;
public RuntimeCard(IGameLink<GameDataCard> cardLink)
{
CardLink = cardLink;
}
}如果同一张卡费用会被 Buff 或局内规则改变,重写 CurrentCost。如果卡牌是否可用需要服务端判断,客户端的 IsUsable 只能做预测和表现,最终仍要由服务端校验。
创建手牌容器
CardContainer 是默认水平布局容器,适合直接放在 HUD 底部:
csharp
#if CLIENT
private static CardContainer? handContainer;
private static void CreateHandUI(List<RuntimeCard> handCards)
{
handContainer = new CardContainer
{
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Bottom,
Spacing = 16,
ZIndex = 860,
};
handContainer.AddToRoot();
handContainer.AddCards(handCards);
handContainer.SetAllCardsDraggable(true);
handContainer.OnCardClicked += (_, card) =>
{
handContainer.SelectCard(card);
};
}
#endifCardContainer 只管理 UI 列表。抽牌、移除手牌、弃牌、洗牌等规则应在你的手牌模型里完成,再调用 AddCard、RemoveCard、ClearCards 或 Refresh 同步到 UI。
卡槽放置目标
CardSlotDropTarget 用于单卡槽,内置类型、费用、是否允许交换等常见限制:
csharp
#if CLIENT
private static CardSlotDropTarget? skillSlot;
private static void CreateSkillSlot()
{
skillSlot = new CardSlotDropTarget("skill_slot_1", "技能槽")
{
AllowedCardType = CardType.Skill,
MaxAllowedCost = 5,
AllowSwap = true,
AllowDragFromSlot = true,
ZIndex = 861,
};
skillSlot.AddToRoot();
skillSlot.OnPlacedCardChanged += (_, card) =>
{
Game.Logger.LogInformation("Placed card: {Card}", card?.Name ?? "<empty>");
};
}
#endif如果需要多格卡槽、区域规则或特殊高亮,继承 CardDropTargetBase 并重写 ValidateDrop / HandleDrop。
csharp
#if CLIENT
public sealed class OnlyConsumableTarget : CardDropTargetBase
{
public override string TargetId => "consume_target";
public override string TargetName => "消耗品区";
public override CardDropValidation ValidateDrop(ICard card)
{
if (card.GetCardType() != CardType.Consumable)
{
return CardDropValidation.Failure("这里只能放消耗品卡。");
}
return CardDropValidation.Success("放入消耗品区");
}
protected override CardDropResult HandleDrop(ICard card)
{
// 更新项目自己的数据模型,再返回结果。
return CardDropResult.Success;
}
}
#endif拖到场景释放
卡牌能不能拖到场景由两层决定:
GameDataCard.CanDropToScene = trueCardManager.Instance.SceneDropEnabled = true
然后在 CardManager 上配置验证和执行逻辑:
csharp
#if CLIENT
private static void ConfigureCardSceneDrop(Unit? casterUnit)
{
var manager = CardManager.Instance;
manager.SceneDropEnabled = true;
manager.CasterUnit = casterUnit;
manager.ValidateSceneDropCallback = (card, args) =>
{
if (!card.IsUsable)
{
return CardDropValidation.Failure("卡牌当前不可用。");
}
if (card.GetSceneTargetType() == CardSceneTargetType.Point &&
args.WorldPosition == null)
{
return CardDropValidation.Failure("没有有效释放点。");
}
return CardDropValidation.Success("释放");
};
manager.ExecuteSceneDropCallback = (card, args) =>
{
// 推荐在这里发送服务端请求,由服务端扣资源并执行效果。
SendUseCardRequest(card.InstanceId, args.WorldPosition, args.TargetUnit);
return CardDropResult.Success;
};
}
#endif默认执行器 DefaultCardSceneDropHandler 可以基于 AbilityLink 做默认技能释放,但正式联机玩法仍建议把资源扣除、目标合法性和效果执行放到服务端。客户端拖放结果只作为输入请求和本地反馈。
事件
常用事件:
| 事件 | 来源 | 用途 |
|---|---|---|
CardContainer.OnCardClicked | 手牌容器 | 选中、查看详情 |
CardContainer.OnCardAdded / OnCardRemoved | 手牌容器 | UI 同步或音效 |
CardSlotDropTarget.OnPlacedCardChanged | 卡槽 | 构筑槽、装备槽变化 |
CardManager.OnDragStarted / OnDragEnded | 全局拖拽管理 | 播放拖拽音效、显示/隐藏放置区域 |
CardManager.OnSceneDrop | 场景放置 | 最终执行前拦截或记录 |
OnSceneDrop 在执行回调前触发,可以通过设置 args.Result 取消后续执行。不要在多个地方同时执行同一张卡的业务效果,避免重复扣费或重复释放。
UI 配置
GameDataCardConfig 控制默认卡牌尺寸、缩放、透明度、颜色和默认资源。通常项目不用改;需要统一换卡牌视觉时,改全局配置比复制 CardUI 更稳。
常用字段:
| 字段 | 作用 |
|---|---|
CardWidth / CardHeight | 默认卡牌尺寸 |
HoverScale / PressedScale / DragScale | 悬停、按下、拖拽缩放 |
DragOpacity | 拖拽中透明度 |
CancelDragThreshold | 拖拽结束时离起点过近则取消 |
DefaultIconPath / DefaultBackgroundPath / DefaultBorderPath | 缺省资源 |
单张卡牌的图标、背景和边框优先来自 GameDataCard.IconPath、BackgroundPath、BorderPath。
清理
离开玩法模式或重建 HUD 时,移除 UI 并清理回调:
csharp
#if CLIENT
private static void CleanupCardUI()
{
skillSlot?.RemoveFromParent();
skillSlot = null;
handContainer?.RemoveFromParent();
handContainer = null;
var manager = CardManager.Instance;
manager.SceneDropEnabled = false;
manager.ValidateSceneDropCallback = null;
manager.ExecuteSceneDropCallback = null;
manager.CasterUnit = null;
}
#endifCardManager.Release() 会释放全局单例。只有在确认当前客户端不再使用任何卡牌拖拽 UI 时才调用;普通界面切换通常只需要关闭 SceneDropEnabled 并移除自己的容器。
常见问题
| 现象 | 常见原因 | 处理 |
|---|---|---|
| 卡牌显示为空白 | ICard.CardLink 指向的数据不存在,或 IconPath 等资源路径错误 | 检查 GameDataCard 和资源路径 |
| 拖不动卡牌 | 没有调用 SetAllCardsDraggable(true),或 CardUI.IsDraggable = false | 打开拖拽开关 |
| 拖到场景没有反应 | CanDropToScene 或 SceneDropEnabled 没开 | 同时打开数据和 CardManager 开关 |
| 卡牌在客户端看似可用但服务端拒绝 | IsUsable 只是客户端预测 | 把最终校验放在服务端,并把失败结果同步回客户端 |
| 卡槽不接收卡牌 | AllowedCardType、MaxAllowedCost 或自定义 ValidateDrop 不通过 | 查看验证失败原因 |
| 多次进出模式后事件重复触发 | 没有清理 UI 或回调 | 退出时 RemoveFromParent() 并清空 CardManager 回调 |