Files
zeling_v2/Docs/Design/37_ToolSystem.md
2026-05-08 11:04:00 +08:00

20 KiB
Raw Permalink Blame History

37 · 工具槽系统Tool Slot System

命名空间 BaseGames.Tools
所属文档集 ← 返回索引 · 总览
依赖 BaseGames.Core.Events · BaseGames.PlayerPlayerStats、AbilitySystem· BaseGames.UIToolHUD· BaseGames.WorldSaveManager


目录

  1. 系统总览
  2. ToolSO — 工具数据
  3. ToolEffect — 效果策略
  4. ToolSlotManager — 核心管理器
  5. 内置工具清单
  6. 工具 HUD
  7. 工具解锁与升级
  8. SaveData 集成
  9. 事件频道
  10. 编辑器友好设计

1. 系统总览

工具槽系统为玩家提供主动使用型道具(有限消耗品或冷却型工具),是对标《丝之歌》工具机制的核心设计。与魅力(被动)系统互补,工具需要玩家主动使用,消耗资源或进入冷却。

工具槽系统职责:
  ├─ ToolSO              → 工具数据 SO外观/冷却/消耗量/效果链)
  ├─ ToolEffect          → 效果策略基类(抽象 + 多种内置实现)
  ├─ ToolSlotManager     → 持有两个工具槽L/R处理使用/冷却/切换
  ├─ ToolHUD             → 屏幕左下角的工具槽 UI图标+冷却进度+库存)
  └─ ToolPickup          → 世界中可拾取的工具实体

设计原则

  • 两个主动槽(默认槽 + 备用槽),玩家可随时切换
  • 消耗型工具有库存上限;冷却型工具无限使用但需等待冷却
  • 工具效果通过 SO 策略组合,无需改代码即可新增工具
  • 工具不打断玩家基础移动仅在使用瞬间锁定方向0.1s

2. ToolSO — 工具数据

[CreateAssetMenu(menuName = "Tools/Tool")]
public class ToolSO : ScriptableObject
{
    [Header("基础信息")]
    public string       toolId;           // 唯一 ID如 "Tool_Slingshot"
    public string       displayName;
    [TextArea(1, 3)]
    public string       description;
    public Sprite       icon;
    public Sprite       useAnimation;     // 角色使用该工具时的 Sprite可选覆盖

    [Header("资源消耗")]
    public ToolCostType costType;         // None冷却型/ Soul / Consumable有限次
    public int          costAmount;       // Soul 消耗量,或初始库存量

    [Header("冷却(冷却型工具专用)")]
    public float        cooldown;         // 秒0 = 无冷却

    [Header("效果链")]
    public ToolEffect[] effects;          // 按顺序执行的效果列表

    [Header("解锁条件")]
    public bool         unlockedByDefault;// 是否初始持有
    public string       unlockAbilityId;  // 击败 Boss / 探索解锁(对应 AbilityType 字符串)

    [Header("UI")]
    public Color        hudColor = Color.white; // 工具槽背景色调
}

public enum ToolCostType
{
    None,           // 冷却型(无限使用,需等冷却)
    Soul,           // 消耗灵魂值
    Consumable,     // 消耗品(有库存上限)
}

3. ToolEffect — 效果策略

所有工具效果继承 ToolEffect,通过 SO 组合在 ToolSO.effects 数组中。

3.1 基类

/// <summary>
/// 工具效果抽象基类。每次工具使用时 Execute() 被调用。
/// </summary>
public abstract class ToolEffect : ScriptableObject
{
    /// <param name="context">使用工具时的上下文(方向、玩家位置、引用等)</param>
    public abstract void Execute(ToolUseContext context);
}

public struct ToolUseContext
{
    public Transform      PlayerTransform;
    public Vector2        AimDirection;   // 归一化,默认 = PlayerFacingDirection
    public PlayerStats    Stats;
    public int            FacingDir;      // +1 = 右, -1 = 左
}

3.2 内置效果类型

效果类 关键参数 描述
SpawnProjectileEffect projectilePrefab, launchSpeed, angle 发射弹射物(弹弓)
SpawnObjectEffect prefab, offset, lifetime 在指定位置生成 GameObject放置小熊
AnchorRopeEffect ropeLength, swingGravity 射出绳索,成功命中表面后开始钟摆
AreaHealEffect healAmount, radius 范围治疗(祛毒/恢复药)
CreatePlatformEffect platformPrefab, duration 在玩家前方生成临时平台
BlindEffect stunDuration, radius 对范围内敌人施加眩晕
MagnetEffect attractRadius, duration 吸引范围内 Geo 和掉落物
PlayFeedbackEffect feedback 播放 MMF_Player视觉/音效演出)

4. ToolSlotManager — 核心管理器

namespace BaseGames.Tools
{
    public class ToolSlotManager : MonoBehaviour
    {
        [Header("初始工具")]
        [SerializeField] ToolSO          _defaultSlot;
        [SerializeField] ToolSO          _altSlot;

        [Header("事件频道")]
        [SerializeField] VoidEventChannelSO      _onToolUsed;      // 发布:工具已使用
        [SerializeField] ToolEventChannelSO      _onToolChanged;   // 发布:当前槽位切换
        [SerializeField] VoidEventChannelSO      _onUseToolInput;  // 订阅:玩家按下工具键
        [SerializeField] VoidEventChannelSO      _onSwapToolInput; // 订阅:玩家切换工具

        // 运行时状态
        readonly ToolSlot[] _slots = new ToolSlot[2];
        int _activeSlot = 0;

        void Awake()
        {
            _slots[0] = new ToolSlot(_defaultSlot);
            _slots[1] = new ToolSlot(_altSlot);
        }

        void OnEnable()
        {
            _onUseToolInput.OnEventRaised  += TryUseTool;
            _onSwapToolInput.OnEventRaised += SwapTool;
        }

        void OnDisable()
        {
            _onUseToolInput.OnEventRaised  -= TryUseTool;
            _onSwapToolInput.OnEventRaised -= SwapTool;
        }

        void Update()
        {
            // 更新所有槽的冷却
            foreach (var slot in _slots)
                slot.UpdateCooldown(Time.deltaTime);
        }

        void TryUseTool()
        {
            var slot = _slots[_activeSlot];
            if (slot.Tool == null) return;
            if (!slot.CanUse) return;

            var tool = slot.Tool;

            // 检查资源
            if (tool.costType == ToolCostType.Soul)
            {
                if (!_stats.TrySpendSoul(tool.costAmount)) return;
            }
            else if (tool.costType == ToolCostType.Consumable)
            {
                if (slot.Stock <= 0) return;
                slot.Stock--;
            }

            // 构建上下文并执行效果
            var ctx = new ToolUseContext
            {
                PlayerTransform = _playerTransform,
                AimDirection    = GetAimDirection(),
                Stats           = _stats,
                FacingDir       = _movement.FacingDir,
            };

            foreach (var effect in tool.effects)
                effect.Execute(ctx);

            // 开始冷却
            if (tool.costType == ToolCostType.None && tool.cooldown > 0)
                slot.StartCooldown(tool.cooldown);

            _onToolUsed.Raise();
            SaveCurrentState();
        }

        void SwapTool()
        {
            _activeSlot = (_activeSlot + 1) % 2;
            _onToolChanged.Raise(_slots[_activeSlot].Tool);
        }

        Vector2 GetAimDirection()
        {
            // 优先使用右摇杆(手柄),其次使用角色朝向
            var aim = _inputReader.AimInput;
            return aim.sqrMagnitude > 0.1f ? aim.normalized : new Vector2(_movement.FacingDir, 0);
        }

        // 供外部系统(物品拾取)调用
        public void PickupTool(ToolSO tool, int amount = 1)
        {
            // 优先填充有对应工具的槽;否则填充空槽;否则替换非主动槽
            for (int i = 0; i < _slots.Length; i++)
            {
                if (_slots[i].Tool == tool)
                {
                    _slots[i].Stock = Mathf.Min(_slots[i].Stock + amount, MaxStock(tool));
                    _onToolChanged.Raise(tool);
                    return;
                }
            }

            // 没有该工具,替换非激活槽
            int target = (_activeSlot + 1) % 2;
            _slots[target] = new ToolSlot(tool, amount);
            _onToolChanged.Raise(tool);
        }

        // Inspector 序列化注入
        [SerializeField] Transform         _playerTransform;
        [SerializeField] PlayerStats       _stats;
        [SerializeField] PlayerMovement    _movement;
        [SerializeField] InputReaderSO     _inputReader;

        static int MaxStock(ToolSO tool) => tool.costAmount * 5; // 库存上限 = 初始量 × 5

        void SaveCurrentState() => SaveManager.Instance.SetToolState(_slots);
    }

    public class ToolSlot
    {
        public ToolSO Tool      { get; private set; }
        public int    Stock     { get; set; }
        public float  Cooldown  { get; private set; }
        public bool   CanUse    => Cooldown <= 0;

        public ToolSlot(ToolSO tool, int stock = -1)
        {
            Tool  = tool;
            Stock = stock < 0 ? (tool?.costAmount ?? 0) : stock;
        }

        public void StartCooldown(float duration) => Cooldown = duration;
        public void UpdateCooldown(float dt)       => Cooldown = Mathf.Max(0, Cooldown - dt);
    }
}

5. 内置工具清单

5.1 弹弓Slingshot— 冷却型

ToolSO: Tool_Slingshot
  costType   = None冷却型
  cooldown   = 0.4s
  effects:
    [0] SpawnProjectileEffect
          projectilePrefab = Proj_SlingshotBall
          launchSpeed      = 22 units/s
          angle            = 0°水平朝向
    [1] PlayFeedbackEffect
          feedback = FB_Slingshot弹射音效 + 短暂相机冲量)

玩法定位:远程骚扰,速度快、冷却短;命中触发目标普通敌人的 Stagger不能弹反不打断 Boss。

5.2 小熊陷阱Trap Bear— 消耗型

ToolSO: Tool_TrapBear
  costType   = Consumable
  costAmount = 3初始 3 个,可在商店购买)
  effects:
    [0] SpawnObjectEffect
          prefab   = TrapBear_Prefab
          offset   = (0.8 × FacingDir, -0.3)  ← 脚前方放置
          lifetime = 15s超时自毁
    [1] PlayFeedbackEffect
          feedback = FB_TrapBear放置音效

TrapBear 组件行为

  • 侦测 Radius 1.5 内有敌人时弹起,对范围内敌人造成 基础 × 1.5 伤害 + Stagger
  • 对玩家无伤害
  • 触发一次后销毁

5.3 钩爪绳索Grapple Rope— 解锁型P1

ToolSO: Tool_GrappleRope
  costType        = None冷却型
  cooldown        = 0.8s
  unlockedByDefault = false
  unlockAbilityId = "Ability_GrappleRope"
  effects:
    [0] AnchorRopeEffect
          ropeLength    = 8 units
          swingGravity  = 12比普通重力 9.8 稍强)
          targetLayers  = 地面/墙体

AnchorRopeEffect 执行逻辑

public class AnchorRopeEffect : ToolEffect
{
    public float ropeLength   = 8f;
    public float swingGravity = 12f;
    public LayerMask targetLayers;

    public override void Execute(ToolUseContext ctx)
    {
        var origin  = ctx.PlayerTransform.position;
        var dir     = ctx.AimDirection == Vector2.zero
            ? new Vector2(ctx.FacingDir, 0.5f).normalized   // 默认斜上方
            : ctx.AimDirection;

        // Raycast 检测挂点
        if (!Physics2D.Raycast(origin, dir, ropeLength, targetLayers, out var hit))
            return;   // 无挂点,工具使用失败(不消耗冷却)

        // 通知 PlayerMovement 进入 SwingState
        ctx.Stats.GetComponent<PlayerMovement>()
           .BeginSwing(hit.point, ropeLength, swingGravity);
    }
}

SwingState 行为

  • 玩家以挂点为圆心钟摆运动,保留水平惯性
  • 按跳跃键 → 以当前速度弹飞,退出 SwingState
  • 按工具键再次使用 → 解除绳索(如同松手)
  • 绳索长度由 Configurable Joint 2D 模拟

5.4 祛毒药Antidote— 消耗型

ToolSO: Tool_Antidote
  costType   = Consumable
  costAmount = 2
  effects:
    [0] AreaHealEffect
          healAmount = 0   ← 不恢复 HP
          radius     = 0   ← 仅作用于自身
    [1] PlayFeedbackEffect
          feedback = FB_Antidote

AreaHealEffect 执行时额外调用 PlayerStats.ClearStatusEffects()(清除中毒/减速/燃烧)。

5.5 光晕炸弹Flash Bomb— 消耗型

ToolSO: Tool_FlashBomb
  costType   = Consumable
  costAmount = 2
  effects:
    [0] SpawnProjectileEffect
          projectilePrefab = Proj_FlashBomb
          launchSpeed      = 14
          angle            = 30°斜抛
    [1] PlayFeedbackEffect
          feedback = FB_FlashThrow

Proj_FlashBomb 行为:落地/命中时爆炸,BlindEffect 半径 4 内的敌人进入 Stunned 状态(BlindDuration 2.5s),打断 Boss 普通攻击(不能打断 Boss 阶段技能)。


6. 工具 HUD

屏幕左下角双槽 HUD响应式更新

布局(左对齐,底部偏移 20px:
┌──────────────────────────────┐
│  [A槽_图标]  [B槽_图标]      │
│  ████░░ CD  × 3 库存         │
└──────────────────────────────┘
public class ToolHUD : MonoBehaviour
{
    [SerializeField] ToolEventChannelSO _onToolChanged;

    [SerializeField] ToolSlotUI[] _slotUIs;      // 长度2

    void OnEnable()  => _onToolChanged.OnEventRaised += Refresh;
    void OnDisable() => _onToolChanged.OnEventRaised -= Refresh;

    void Refresh(ToolSO tool)
    {
        var slots = ToolSlotManager.Instance.GetSlots();
        for (int i = 0; i < _slotUIs.Length; i++)
            _slotUIs[i].Bind(slots[i], i == ToolSlotManager.Instance.ActiveSlotIndex);
    }
}

public class ToolSlotUI : MonoBehaviour
{
    [SerializeField] Image    _icon;
    [SerializeField] Image    _cooldownOverlay;   // radial fill
    [SerializeField] TMP_Text _stockText;
    [SerializeField] Image    _activeFrame;       // 激活槽高亮边框

    public void Bind(ToolSlot slot, bool isActive)
    {
        _icon.sprite         = slot.Tool?.icon;
        _icon.enabled        = slot.Tool != null;
        _activeFrame.enabled = isActive;

        if (slot.Tool?.costType == ToolCostType.None)
        {
            // 冷却型:显示 radial 冷却遮罩
            _cooldownOverlay.fillAmount = slot.Cooldown / slot.Tool.cooldown;
            _stockText.text = string.Empty;
        }
        else if (slot.Tool?.costType == ToolCostType.Consumable)
        {
            _cooldownOverlay.fillAmount = 0;
            _stockText.text = slot.Stock > 0 ? $"×{slot.Stock}" : "空";
        }
        else
        {
            _cooldownOverlay.fillAmount = 0;
            _stockText.text = string.Empty;
        }
    }
}

Update 驱动冷却 UI:每帧刷新激活槽的冷却 fillAmount(仅冷却型工具)。为减少 SetDirty 开销,使用 CanvasGroup.alpha 控制显隐,不销毁/重建 GameObject。


7. 工具解锁与升级

解锁方式

方式 实现 示例
世界拾取(首次获得) ToolPickup 世界物件 → ToolSlotManager.PickupTool() 探索隐藏房间获得弹弓
击败 Boss 奖励 EventChainSO.SpawnToolAction 击败森林 Boss 获得光晕炸弹
商店购买 ShopNPC 出售 ToolSO 引用 旅馆商人售卖小熊陷阱 ×3
补充消耗品库存 ToolPickupamount 场景中散落的小熊陷阱补充包

ToolPickup 世界物件

public class ToolPickup : MonoBehaviour, IInteractable
{
    [SerializeField] ToolSO _tool;
    [SerializeField] int    _amount = 1;
    [SerializeField] bool   _isFirstTimeUnlock;   // true = 解锁新工具false = 补充库存
    [SerializeField] string _pickupId;            // 用于 SaveData 记录已拾取

    public bool CanInteract => true;

    public void Interact()
    {
        if (!string.IsNullOrEmpty(_pickupId) &&
            SaveManager.Instance.IsCollected(_pickupId))
            return;

        ToolSlotManager.Instance.PickupTool(_tool, _amount);

        if (!string.IsNullOrEmpty(_pickupId))
            SaveManager.Instance.SetCollected(_pickupId);

        // 首次解锁 → 弹出工具介绍面板
        if (_isFirstTimeUnlock)
            UIManager.Instance.ShowToolUnlockPanel(_tool);

        Destroy(gameObject);
    }
}

8. SaveData 集成

"tools": {
  "slots": [
    { "toolId": "Tool_Slingshot", "stock": -1, "cooldownRemaining": 0.0 },
    { "toolId": "Tool_TrapBear",  "stock": 2,  "cooldownRemaining": 0.0 }
  ],
  "activeSlot": 0,
  "ownedTools": ["Tool_Slingshot", "Tool_TrapBear"]
}
// SaveManager 扩展
public void SetToolState(ToolSlot[] slots)
{
    _saveData.tools.slots = slots.Select(s => new ToolSlotData
    {
        toolId            = s.Tool?.toolId,
        stock             = s.Stock,
        cooldownRemaining = s.Cooldown,
    }).ToArray();
    WriteDirty();
}

9. 事件频道

频道资产 类型 发布方 主要订阅方
OnToolUsed.asset VoidEventChannelSO ToolSlotManager AchievementManager(使用计数成就)
OnToolChanged.asset ToolEventChannelSO ToolSlotManager ToolHUD(刷新 UI
OnToolPickedUp.asset ToolEventChannelSO ToolPickup UIManager(首次解锁面板)、AchievementManager
[CreateAssetMenu(menuName = "Events/ToolEventChannel")]
public class ToolEventChannelSO : ScriptableObject
{
    public event Action<ToolSO> OnEventRaised;
    public void Raise(ToolSO tool) => OnEventRaised?.Invoke(tool);
}

10. 编辑器友好设计

ToolSlotManager InspectorPlay Mode

┌─ ToolSlotManager ────────────────────────────────────────┐
│  激活槽: 0                                               │
│  ┌──────────────────────────────────────────────────────┐│
│  │ 槽 0 [★] Tool_Slingshot  CD: 0.00s  ───            ││
│  │ 槽 1     Tool_TrapBear   库存: 2/10                  ││
│  └──────────────────────────────────────────────────────┘│
│  [模拟使用] [切换工具] [拾取工具 ▼]                      │
└──────────────────────────────────────────────────────────┘

新增工具 SOP零代码

  1. 右键 Assets/Data/Tools/Create → Tools/Tool,填写 toolId
  2. effects 数组中添加已有 ToolEffect SO或创建新的子类 SO
  3. 在场景中放置 ToolPickup 预制件,引用该 ToolSO
  4. ToolSlotManager._defaultSlot_altSlot 引用(若默认持有)

ToolEffect 子类开发规范

继承 ToolEffect → 实现 Execute(ToolUseContext)
CreateAssetMenu 菜单Create → Tools/Effects/{EffectName}
完全数据驱动,所有参数在 SO 字段配置
通过 ctx.PlayerTransform / ctx.Stats 访问玩家
禁止 FindObjectOfType / Singleton 访问