# 37 · 工具槽系统(Tool Slot System) > **命名空间** `BaseGames.Tools` > **所属文档集** [← 返回索引](./README.md) · [总览](./00_Overview.md) > **依赖** `BaseGames.Core.Events` · `BaseGames.Player`(PlayerStats、AbilitySystem)· `BaseGames.UI`(ToolHUD)· `BaseGames.World`(SaveManager) --- ## 目录 1. [系统总览](#1-系统总览) 2. [ToolSO — 工具数据](#2-toolso--工具数据) 3. [ToolEffect — 效果策略](#3-tooleffect--效果策略) 4. [ToolSlotManager — 核心管理器](#4-toolslotmanager--核心管理器) 5. [内置工具清单](#5-内置工具清单) 6. [工具 HUD](#6-工具-hud) 7. [工具解锁与升级](#7-工具解锁与升级) 8. [SaveData 集成](#8-savedata-集成) 9. [事件频道](#9-事件频道) 10. [编辑器友好设计](#10-编辑器友好设计) --- ## 1. 系统总览 工具槽系统为玩家提供**主动使用型道具**(有限消耗品或冷却型工具),是对标《丝之歌》工具机制的核心设计。与魅力(被动)系统互补,工具需要玩家主动使用,消耗资源或进入冷却。 ``` 工具槽系统职责: ├─ ToolSO → 工具数据 SO(外观/冷却/消耗量/效果链) ├─ ToolEffect → 效果策略基类(抽象 + 多种内置实现) ├─ ToolSlotManager → 持有两个工具槽(L/R),处理使用/冷却/切换 ├─ ToolHUD → 屏幕左下角的工具槽 UI(图标+冷却进度+库存) └─ ToolPickup → 世界中可拾取的工具实体 ``` **设计原则**: - 两个主动槽(默认槽 + 备用槽),玩家可随时切换 - 消耗型工具有库存上限;冷却型工具无限使用但需等待冷却 - 工具效果通过 SO 策略组合,无需改代码即可新增工具 - 工具不打断玩家基础移动,仅在使用瞬间锁定方向(0.1s) --- ## 2. ToolSO — 工具数据 ```csharp [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 基类 ```csharp /// /// 工具效果抽象基类。每次工具使用时 Execute() 被调用。 /// public abstract class ToolEffect : ScriptableObject { /// 使用工具时的上下文(方向、玩家位置、引用等) 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 — 核心管理器 ```csharp 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 执行逻辑**: ```csharp 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() .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 库存 │ └──────────────────────────────┘ ``` ```csharp 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 | | 补充消耗品库存 | `ToolPickup(amount)` | 场景中散落的小熊陷阱补充包 | ### ToolPickup 世界物件 ```csharp 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 集成 ```json "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"] } ``` ```csharp // 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` | ```csharp [CreateAssetMenu(menuName = "Events/ToolEventChannel")] public class ToolEventChannelSO : ScriptableObject { public event Action OnEventRaised; public void Raise(ToolSO tool) => OnEventRaised?.Invoke(tool); } ``` --- ## 10. 编辑器友好设计 ### ToolSlotManager Inspector(Play 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 访问 ```