没想到,上一篇这个系列的文章 居然是 2023年6月,现在2026年1月了,又一次破了我鸽的记录!
这次想起来更新这个卡牌游戏是因为我最近刷POE2非常上头,无数的天赋树和装备的组合,给这个游戏带来了无限的灵活性。如果有了解过游戏开发,一定知道虚幻引擎,它有一套 GAS 系统,通过这套系统能够让游戏的技能系统做起来又快又灵活,所以我想把这套系统的部分设计引入到我的卡牌游戏里来。

关于GAS

GAS就是Gameplay Ability System,游戏玩法技能系统,但我不介绍这个完整的系统,我只介绍这个系统中的一部分,其实拆开来看,就是几个经典的设计模式组合在一起的产物。
这个GAS里有两个很重要的概念,Tag和Effect,Tag顾名思义就是标签,描述一件衣服,就可以用各种Tag来表示,比如红色、格子、长袖、尼龙等。

如果有这套Tag系统,那么我只要多实现一些Tag然后进行排列组合,就能得到全新的一件物品,比如我把红色改为黑色,那么我将得到黑色的衬衫:

然后就是Effect效果,从Tag可以推理出来,如果Tag对应的是状态,那么Effect对应的是改变状态的方法,如果我开发了一个“折叠效果”,对应的方法的内容是:

  1. 添加 “不可折叠” Tag、添加 “可展开” Tag
  2. 改变面积为原来的四分之一

我就可以将这个Effect应用到衣服上,这时衣服的属性就会自动发生改变,并且神奇的是,这个Effect也可以应用到一切有“服装”Tag的物品上,我以后开发了“裤子”、“裙子”、“毛衣”全部都可以直接用。

在这些内容里,你可以看到命令模式、策略模式、状态模式等等经典设计模式的影子,他将开发的“复利”效应做到了极致,POE就是这样设计的,同样你可以在Dota2里也看到这样的设计

// 这是一个非常简单的技能,他是一个被动技能,给单位添加了一个粒子特效。
"fx_test_ability"
{
    // General
    //---------------------------------------------------------
    "BaseClass"             "ability_datadriven"
    "AbilityBehavior"       "DOTA_ABILITY_BEHAVIOR_PASSIVE"
    "AbilityTextureName"    "axe_battle_hunger"

    // Modifiers
    //---------------------------------------------------------
    "Modifiers"
    {
        "fx_test_modifier"
        {
            "Passive" "1"
            "OnCreated"
            {
                "AttachEffect"
                {
                    "Target" "CASTER"
                    "EffectName" "particles/econ/generic/generic_buff_1/generic_buff_1.vpcf"
                    "EffectAttachType" "follow_overhead"
                    "EffectLifeDurationScale" "1"
                    "EffectColorA" "255 255 0"
                }
            }
        }
    }
}

通过定义 bahavior、modifier、”OnCreated”这样的钩子、”AttachEffect” 这样的Effect,就能配置出一个技能,无需编写代码,并且添加或者修改、组合部分属性,就是一个全新的技能。

我的设计

我的卡牌过去是这样实现的:

{
    id: 13,
    name: "没毕业的天才程序员",
    cardType: CardType.CHARACTER,
    cost: 3,
    content: `每回合结束时,获得+1/+1`,
    attack: 1,
    life: 1,
    attackBase: 1,
    lifeBase: 1,
    type: [""],
    onMyTurnEnd: function ({thisCard, specialMethod, position}) {
        if (position === CardPosition.TABLE) {
            thisCard.attack += 1;
            thisCard.life += 1;
            specialMethod.buffCardAnimation(true, -1, -1, thisCard, thisCard)
        }
    }
}

并且有效果的牌都是这么实现的,可以看到每个效果都需要我单独的去写函数,或者是在对象上添加属性比如 isStrong 这种,对于代码实现不方便不优雅(还好用的是javascript),而且无法很好的保存到数据库里,只能存在代码文件中来硬编码定义。

所以想要做到像POE、Dota2那样进行配置,理想的情况下卡牌应该是这样的:

{
    "id": 13,
    "name": "没毕业的天才程序员",
    "cost": 3,
    "content": "每回合结束时,获得+1/+1",
    "attack": 1,
    "life": 1,
    "attackBase": 1,
    "lifeBase": 1,
    "types": [],
    "tags": ["Tags.Character"],
    "effects": {
        "onMyTurnEnd": [
            {
                "type": "ModifyAttribute",
                "target": {
                    "type": "self"
                },
                "params": {
                    "attribute": "attack",
                    "value": 1,
                    "operation": "add"
                },
                "conditions": [
                    {
                        "type": "cardPosition",
                        "params": {
                            "position": "TABLE"
                        }
                    }
                ]
            },
            {
                "type": "ModifyAttribute",
                "target": {
                    "type": "self"
                },
                "params": {
                    "attribute": "life",
                    "value": 1,
                    "operation": "add"
                },
                "conditions": [
                    {
                        "type": "cardPosition",
                        "params": {
                            "position": "TABLE"
                        }
                    }
                ]
            }
        ]
    }
}

虽然说看起来内容复杂了,没有代码那么一眼看上去逻辑清晰,可是这些配置是完全能够可视化的,也就是说以后可以通过卡牌编辑器来可视化的进行卡牌配置,但是代码就无法有效的进行可视化了(对于不懂编码的人来说,比如说策划或者爱好者社区这很重要)。

所以设计任务: 1. 原来的各种属性抽象为Tag。 2. 原来的各种钩子的效果整理好,抽象为各种 Effect。

{
    id: 13,
    name: "卡牌名称",
    cardType: CardType.CHARACTER, // tag
    cost: 3,
    content: `卡牌内容描述`,
    attack: 1,
    life: 1,
    attackBase: 1,
    lifeBase: 1,
    isStrong: true, // tag
    isFullOfEnergy: true, // tag
    type: ["类型"],
    onMyTurnEnd: function ({thisCard, specialMethod, position}) { // effect
        if (position === CardPosition.TABLE) {
            thisCard.attack += 1;
            thisCard.life += 1;
            specialMethod.buffCardAnimation(true, -1, -1, thisCard, thisCard)
        }
    }
}

按照 Effect 的设想:

{
    "onMyTurnEnd": [
        {
            "type": "ModifyAttribute",
            "target": {
                "type": "self"
            },
            "params": {
                "attribute": "attack",
                "value": 1,
                "operation": "add"
            },
            "conditions": [
                {
                    "type": "cardPosition",
                    "params": {
                        "position": "TABLE"
                    }
                }
            ]
        },
        {
            "type": "ModifyAttribute",
            "target": {
                "type": "self"
            },
            "params": {
                "attribute": "life",
                "value": 1,
                "operation": "add"
            },
            "conditions": [
                {
                    "type": "cardPosition",
                    "params": {
                        "position": "TABLE"
                    }
                }
            ]
        }
    ]
}

还是需要有过去的钩子定义,比如 onMyTurnEnd、onStart,方便我们在每个流程阶段检查卡牌是否配置了对应阶段执行的 Effect,接收 Effect 数组,方便我们进行 Effect 组合来实现非常灵活的效果。

需要把所有的函数过一遍,总结一下所有的函数实现的效果,然后抽象成简单的 Effect 和对应的参数。

这样在各种流程里,比如出牌阶段,刚打出的时候可以触发卡牌的 onStart 效果

// 示意伪代码
function outCard() {
    // ...
    triggerCardEffect(card, 'onStart', baseContext);
    // ...
}

triggerCardEffect 内部简单来看可以这样设计:

// 示意伪代码
function triggerCardEffect(card, trigger, baseContext) {
    // 获取卡牌的效果配置
    const effectConfigs = card.effects?.[trigger] || [];

    // 构建完整上下文
    const context = {
        ...baseContext,
        thisCard: card,
        // 还需要加各种 effect 和 tag 相关的系统环境数据
        // 比如 effectRegistry、tagRegistry 用于快速获取注册了的 effect和tag,比如 effectEngine 用于执行 effect
    };

    // 依次执行效果
    effectConfigs.forEach(config => {
        this.executeEffect(config, context);
    });
}

executeEffect 内部主要就是利用命令模式、策略模式来把我们实现的各种 Effect 执行逻辑应用在卡牌上:

// 示意伪代码
function executeEffect(config, context) {
    // 获取实现的 effect 函数
    const EffectClass = this.effectTypes.get(config.type);
    if (!EffectClass) {
        console.warn(`Unknown effect type: ${config.type}`);
        return null;
    }

    const effect = new EffectClass(config);

    // 检查条件
    if (!effect.canExecute(context)) {
        return;
    }

    effect.execute(context, targets);

    // 处理后续效果链
    if (effectConfig.then) {
        effectConfig.then.forEach(nextConfig => {
            this.executeEffect(nextConfig, context);
        });
    }
}

所有的 Effect 就只需要实现预定义的各种函数,然后注册起来:

/**
 * BaseEffect - 效果基类
 * 所有具体效果类型都继承自此类
 */
class BaseEffect {
    constructor(config) {
    }

    /**
     * 检查效果是否可以执行
     * @param {object} context - 执行上下文
     * @returns {boolean}
     */
    canExecute(context) {
        return false;
    }

    /**
     * 执行效果
     * @param {object} context - 执行上下文
     * @param {Array} targets - 目标列表
     */
    execute(context, targets) {
        throw new Error("Must be implemented by subclass");
    }

    /**
     * 获取效果描述文本
     * @returns {string}
     */
    getDescription() {
        return this.type;
    }
}

module.exports = BaseEffect;

不过,都到了2026年了,我就不分享具体的代码实现过程了,我分享点别的。

实现过程

既然现在都用AI来辅助开发了,那么实际上我可以直接分享我的Prompt,这样就能够一步步的让AI在你的环境也实现相同的功能而不用是完全相同的代码。(如果AI给力的话)
我的代码编写基本是用 Claude Code(Opus4.5)和 Copilot(Opus4.5),查询资料用 Kagi Assistant 的 Research。

第一条prompt,先用Plan模式让AI做好计划:

当前的卡牌存储在 @card-game-server/cards.js 里面,可以看到卡牌的效果基本上都是用代码写的,比如 onStart onMyTurnStart 等等,这些都是钩子函数。
这种设计有一个问题,要添加卡牌,必须要能够写代码,现在需要将这个设计修改,请使用UE5的GAS的 effect tag 设计,把各种基础效果改为已经实现好的 effect 和 tag,之后的卡牌只要配置对应的 effect tag就能够实现各种能力,而新增effect tag能够让每个tag添加进来和别的tag组合成为成倍数增加的新能力。
设计需要尽可能的全面和优雅。

如果你的AI不知道UE5 GAS,那么你可能需要查询一些UE5 GAS相关的设计内容总结给AI。

AI的计划大概如下(肯定都会有不同,但没事):

可以看到 Tag 带有层级,这是 组合模式 的应用,能够让我们方便的多层级进行判断,比如我们有时候只想要知道 State.Stunned 眩晕状态来让卡牌无法行动,至于是 State.Stunned.Light 还是 State.Stunned.Heavy 不重要。 Effect 也支持链式效果,能够让我们多 Effect 组合的同时,也能够链式的递归进行 Effect 调用。

这个Plan阶段可以和AI多轮讨论达到自己想要的最好情况,后续其实也能修改,但这个阶段讨论清楚是最好的。

后续就直接可以让AI开始修改,如果你是使用的Opus4.5,那么大概率这一把就能够改出八成,有可能出现下面的几种问题,这也是我碰到的。

卡牌数据没有完全迁移到新结构

有可能会出现 cards.js 还是有很多遗留数据,或者甚至就完全没有迁移,所以:

现在的卡牌已经转为使用 effect tag 来进行配置了,但是卡牌的数据 cards.js 还是使用的函数。 请把这些卡牌数据转为使用effect和tag,因为这里的数据过大,请分批次转为多个json(按照卡牌职业分类)。

AI将会分批次把 cards.js 里面的硬编码的卡牌转为新的配置的json文件:

文件命名不规范

有的 Effect 会单独有一个文件夹,或者相类似能力的 Effect 会放到一起,但有的不相干的 Effect 又全部集合到了一个文件里,所以:

新增了 UtilityEffect.js 但别的effect都是单独的文件,最好这几个也用对应的文件放在一起,以后能够通过文件名来寻找对应的effect。并且请把level中的卡牌也改为effect配置的。

AI有时候因为上下文过长的问题,会出现部分代码不遵守规范的情况,这个情况也可以让AI做一份 CLAUDE.md / AGENTS.md,然后在文件里加上这个规范。

偷懒不实现缺失的Effect

AI为了偷懒,创建了一个 LegacyHookEffect ,来支持用过去那种老的用函数定义效果的方法。

/**
 * LegacyHookEffect
 * 允许在 Effect 配置中调用旧版函数式卡牌效果
 */
class LegacyHookEffect extends BaseEffect {
    constructor(config) {
        super(config);
        this.type = "LegacyHook";
    }

    execute(context) {
        const { handler } = this.params;
        const fn = legacyCardEffects[handler];

        if (typeof fn === 'function') {
            fn(context);
        } else {
            console.warn(`[LegacyHookEffect] handler not found: ${handler}`);
        }
    }
}
LegacyHookEffect 是偷懒的行为,把这些函数定义改为effect配置就是不希望再有使用函数配置的卡牌了。  
如果当前的effect设计不足以做到把所有卡牌都改为可配置effect的json表达,请告诉我哪里需要修改。如果当前的effect不足,需要新增,请告诉我要增加哪些。

并且,AI如果不够聪明,可能会出现胡乱设计的情况,所以在用AI修改的时候一定要审阅他的输出计划,并且自己对代码一定要有理解,然后及时纠正AI的错误:

isStrong 等属性没有真正的用 Tag 来重构

Review代码的时候发现,AI并没有理解 Tag 这种设计(不过我们也没说),改的不够彻底,所以:

现在代码里的模仿GAS的tag系统似乎没有真正用起来,现在的tag是用ApplyTag这个Effect来模拟之前的属性做法,给卡牌加上 isStrong 这样的属性,我希望你能彻底修改为tag的做法,只需要判断卡牌的tag是否有 Status.Buff.Strong,而不需要再转回原来的属性模式,所以原来的属性模式相关的逻辑也要改为GAS的tag模式。
那这样的话,是否 cardType 其实也不需要了,直接用 tag 来实现就可以了。

这样AI会把过去的属性移除,然后用新的Tag来重构,过去判断 isStrong 的地方,都变成 hasTag(card, “Status.Buff.Strong”)。

其他内容

本次还修复了一些bug,然后还增加了SQLite模式,这样大家在跑这个项目的时候就不需要再启动一个MongoDB了。

AI

实现这个 Effect 和 Tag,增加SQLite支持,外加测试和一些其他的修复bug,总共耗时1天。本来半天其实就差不多了,但是AI写的代码总是有一些不优雅,所以需要自己去微调,可以手动,也可以让AI继续改。比如 Status.Buff.Strong 这类的字符串,可以抽取常量,比如 hasTag 这类的工具方法,还有一些过去我实现过可以直接用的系统。
不过如果没有AI,这个修改起码要花好几天,大部分的代码质量也不一定有AI做的好。

下一步

翻了翻上次发的文章,说是要把AI加进去,确实可以,而且应该很简单,然后还可以做一个卡牌编辑器。
就是不知道这次要鸽多久,视频稍晚一点做完也会发。


0 条评论

发表回复

Avatar placeholder

您的邮箱地址不会被公开。 必填项已用 * 标注