590 lines
20 KiB
Markdown
590 lines
20 KiB
Markdown
# 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
|
||
/// <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 — 核心管理器
|
||
|
||
```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<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 库存 │
|
||
└──────────────────────────────┘
|
||
```
|
||
|
||
```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<ToolSO> 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 访问
|
||
```
|