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

590 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 |
| 补充消耗品库存 | `ToolPickupamount` | 场景中散落的小熊陷阱补充包 |
### 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 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 访问
```