Article

C# 实战课 09:配置驱动技能系统(让策划可调、程序可控、线上可追踪)

系列定位:Unity 老版本兼容(.NET 3.5 / C# 4)+ 项目驱动。
本章目标:把“硬编码技能逻辑”升级为“配置 + 执行引擎”的技能系统,为后续 Unity 战斗章节做核心基建。

学习目标

完成本章后,你应该能做到:

  1. 设计技能配置模型(伤害、冷却、施法时间、消耗、目标类型)。
  2. 实现技能执行管线(请求 -> 校验 -> 施法 -> 生效 -> 收尾)。
  3. 将技能系统与命令系统、FSM、Scheduler、EventBus 对齐。
  4. 对每次施法输出可追踪日志,支撑回放与线上排障。

问题背景

很多项目初期会这样写技能:

if (Input.GetKeyDown(KeyCode.Q))
{
    if (mp >= 30)
    {
        mp -= 30;
        target.Hp -= 120;
        qCooldown = 8f;
    }
}

短期可用,但会快速失控:

  1. 技能参数散落代码中,策划改数值要改程序。
  2. 冷却、蓝耗、距离、前摇等规则重复实现,Bug 高发。
  3. 日志缺失,无法回答“为什么这次没放出来”。
  4. 复用困难,AI 与玩家技能逻辑分叉。

核心思路:技能行为固定在引擎,技能差异写在配置

技能系统最小架构

1. 配置定义(静态)

public sealed class SkillConfig
{
    public int Id;
    public string Name;

    public float CooldownSeconds;
    public float CastTimeSeconds;
    public int ManaCost;
    public float Range;
    public int Damage;

    public SkillTargetType TargetType;
    public string EffectKey;
}

public enum SkillTargetType
{
    Self = 0,
    EnemySingle = 1,
    EnemyArea = 2
}

2. 运行态(动态)

public sealed class SkillRuntime
{
    public readonly SkillConfig Config;
    public float CooldownRemain;

    public SkillRuntime(SkillConfig config)
    {
        Config = config;
        CooldownRemain = 0f;
    }
}

3. 施法请求

public struct CastRequest
{
    public int CasterEntityId;
    public int SkillId;
    public int TargetEntityId;
    public float RequestTime;
}

项目驱动:做一个可上线的技能引擎

目标场景:角色有 3 个技能(火球、冲刺、护盾),同一套系统服务玩家与 AI。

第一步:技能仓库(配置加载)

public sealed class SkillRepository
{
    private readonly Dictionary<int, SkillConfig> _configs = new Dictionary<int, SkillConfig>(64);

    public void Add(SkillConfig config)
    {
        if (config == null)
        {
            throw new ArgumentNullException("config");
        }

        if (_configs.ContainsKey(config.Id))
        {
            throw new InvalidOperationException("Duplicate skill id=" + config.Id);
        }

        _configs.Add(config.Id, config);
    }

    public SkillConfig Get(int skillId)
    {
        SkillConfig cfg;
        _configs.TryGetValue(skillId, out cfg);
        return cfg;
    }
}

第二步:施法校验器

public sealed class CastValidator
{
    public bool Validate(Actor caster, Actor target, SkillRuntime runtime, out string reason)
    {
        if (caster == null)
        {
            reason = "caster_null";
            return false;
        }

        if (runtime == null)
        {
            reason = "skill_not_found";
            return false;
        }

        if (caster.Hp <= 0f)
        {
            reason = "caster_dead";
            return false;
        }

        if (runtime.CooldownRemain > 0f)
        {
            reason = "skill_cooldown";
            return false;
        }

        if (caster.Mana < runtime.Config.ManaCost)
        {
            reason = "mana_not_enough";
            return false;
        }

        if (runtime.Config.TargetType == SkillTargetType.EnemySingle)
        {
            if (target == null)
            {
                reason = "target_null";
                return false;
            }

            var distance = caster.DistanceTo(target);
            if (distance > runtime.Config.Range)
            {
                reason = "target_out_of_range";
                return false;
            }
        }

        reason = "ok";
        return true;
    }
}

第三步:执行器(施法管线)

public sealed class SkillExecutor
{
    private readonly Scheduler _scheduler;
    private readonly EventBus _eventBus;

    public SkillExecutor(Scheduler scheduler, EventBus eventBus)
    {
        _scheduler = scheduler;
        _eventBus = eventBus;
    }

    public void Execute(Actor caster, Actor target, SkillRuntime runtime, float now)
    {
        var cfg = runtime.Config;

        caster.Mana -= cfg.ManaCost;
        runtime.CooldownRemain = cfg.CooldownSeconds;

        FoundationLog.Info("Skill", "CastStart skill=" + cfg.Name + " caster=" + caster.Id + " t=" + now);
        _eventBus.Publish("SkillCastStart", cfg.Id);

        if (cfg.CastTimeSeconds <= 0f)
        {
            ApplyEffect(caster, target, runtime);
            return;
        }

        _scheduler.Delay(now, cfg.CastTimeSeconds, delegate
        {
            ApplyEffect(caster, target, runtime);
        });
    }

    private void ApplyEffect(Actor caster, Actor target, SkillRuntime runtime)
    {
        var cfg = runtime.Config;

        if (cfg.TargetType == SkillTargetType.EnemySingle && target != null)
        {
            target.Hp -= cfg.Damage;
        }
        else if (cfg.TargetType == SkillTargetType.Self)
        {
            caster.Hp += cfg.Damage;
            if (caster.Hp > caster.MaxHp)
            {
                caster.Hp = caster.MaxHp;
            }
        }

        FoundationLog.Info("Skill", "CastApply skill=" + cfg.Name + " effect=" + cfg.EffectKey);
        _eventBus.Publish("SkillCastApply", cfg.Id);
    }
}

第四步:技能组件(角色侧入口)

public sealed class SkillComponent
{
    private readonly Dictionary<int, SkillRuntime> _skills = new Dictionary<int, SkillRuntime>(8);
    private readonly CastValidator _validator;
    private readonly SkillExecutor _executor;

    public SkillComponent(CastValidator validator, SkillExecutor executor)
    {
        _validator = validator;
        _executor = executor;
    }

    public void AddSkill(SkillConfig config)
    {
        _skills[config.Id] = new SkillRuntime(config);
    }

    public bool TryCast(Actor caster, Actor target, int skillId, float now, out string reason)
    {
        SkillRuntime runtime;
        _skills.TryGetValue(skillId, out runtime);

        if (!_validator.Validate(caster, target, runtime, out reason))
        {
            FoundationLog.Warn("Skill", "CastReject skillId=" + skillId + " reason=" + reason);
            return false;
        }

        _executor.Execute(caster, target, runtime, now);
        reason = "ok";
        return true;
    }

    public void Tick(float dt)
    {
        foreach (var kv in _skills)
        {
            var rt = kv.Value;
            if (rt.CooldownRemain > 0f)
            {
                rt.CooldownRemain -= dt;
                if (rt.CooldownRemain < 0f)
                {
                    rt.CooldownRemain = 0f;
                }
            }
        }
    }
}

与命令系统 / FSM 串联

1. 命令触发技能

玩家按键不再直接改数值,而是投递 CastSkillCommand

public sealed class CastSkillCommand : ICommand
{
    public int Sequence { get; set; }
    public float Timestamp { get; set; }
    public string Name { get { return "CastSkill"; } }

    public int SkillId;
    public int TargetId;

    public bool Execute(CommandContext context)
    {
        var world = (BattleWorld)context.Host;
        string reason;
        return world.PlayerSkills.TryCast(world.Player, world.GetActor(TargetId), SkillId, Timestamp, out reason);
    }

    public void Reset()
    {
        Sequence = 0;
        Timestamp = 0f;
        SkillId = 0;
        TargetId = 0;
    }
}

2. FSM 控制“是否可施法”

例如:DeadStateHitState 禁止主动施法;AttackState 可按战术触发指定技能。
这样能保证“行为约束”在状态层,“数值规则”在技能层,职责不冲突。

配置示例(可给策划维护)

[
  {
    "Id": 1001,
    "Name": "FireBall",
    "CooldownSeconds": 6.0,
    "CastTimeSeconds": 0.4,
    "ManaCost": 25,
    "Range": 12.0,
    "Damage": 140,
    "TargetType": 1,
    "EffectKey": "fx_fireball"
  },
  {
    "Id": 1002,
    "Name": "Dash",
    "CooldownSeconds": 4.0,
    "CastTimeSeconds": 0.0,
    "ManaCost": 10,
    "Range": 0.0,
    "Damage": 0,
    "TargetType": 0,
    "EffectKey": "fx_dash"
  },
  {
    "Id": 1003,
    "Name": "Shield",
    "CooldownSeconds": 12.0,
    "CastTimeSeconds": 0.2,
    "ManaCost": 30,
    "Range": 0.0,
    "Damage": 80,
    "TargetType": 0,
    "EffectKey": "fx_shield"
  }
]

验收标准

至少达到以下结果:

  1. 新增一个技能只改配置,不改技能引擎代码。
  2. 冷却、蓝耗、距离校验全部统一走 CastValidator
  3. 技能触发可由玩家命令与 AI 行为共用。
  4. 日志能完整追踪一次施法生命周期:CastStart -> CastApply

常见坑

坑 1:把“技能效果逻辑”硬塞进同一个大 switch

短期快,长期不可维护。按效果类型拆分处理器(后续可升级策略模式)。

坑 2:冷却只在客户端倒计时,不在逻辑层校验

会出现 UI 显示可用但逻辑不可用。必须以逻辑层为准。

坑 3:施法中断没有定义

如果角色死亡、眩晕、位移打断时没有策略,线上会出现“技能吞没”与“资源扣了没效果”。

本月作业

实现“技能中断系统”并接入日志:

  1. 新增中断原因:死亡、控制、超距。
  2. 施法中断时回滚策略可配置(返还蓝耗/不返还)。
  3. 输出 CastInterrupted 事件与结构化日志。

完成后,你将拥有可持续扩展的技能主干。下一章进入“Buff/效果栈系统”,把持续伤害、加速、护盾、减益统一管理。