Skip to content

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);
    };
}
#endif

CardContainer 只管理 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 = true
  • CardManager.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;
}
#endif

CardManager.Release() 会释放全局单例。只有在确认当前客户端不再使用任何卡牌拖拽 UI 时才调用;普通界面切换通常只需要关闭 SceneDropEnabled 并移除自己的容器。

常见问题 ​

现象常见原因处理
卡牌显示为空白ICard.CardLink 指向的数据不存在,或 IconPath 等资源路径错误检查 GameDataCard 和资源路径
拖不动卡牌没有调用 SetAllCardsDraggable(true),或 CardUI.IsDraggable = false打开拖拽开关
拖到场景没有反应CanDropToScene 或 SceneDropEnabled 没开同时打开数据和 CardManager 开关
卡牌在客户端看似可用但服务端拒绝IsUsable 只是客户端预测把最终校验放在服务端,并把失败结果同步回客户端
卡槽不接收卡牌AllowedCardType、MaxAllowedCost 或自定义 ValidateDrop 不通过查看验证失败原因
多次进出模式后事件重复触发没有清理 UI 或回调退出时 RemoveFromParent() 并清空 CardManager 回调