20 KiB
20 KiB
37 · 工具槽系统(Tool Slot System)
命名空间
BaseGames.Tools
所属文档集 ← 返回索引 · 总览
依赖BaseGames.Core.Events·BaseGames.Player(PlayerStats、AbilitySystem)·BaseGames.UI(ToolHUD)·BaseGames.World(SaveManager)
目录
- 系统总览
- ToolSO — 工具数据
- ToolEffect — 效果策略
- ToolSlotManager — 核心管理器
- 内置工具清单
- 工具 HUD
- 工具解锁与升级
- SaveData 集成
- 事件频道
- 编辑器友好设计
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 |
| 补充消耗品库存 | ToolPickup(amount) |
场景中散落的小熊陷阱补充包 |
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 Inspector(Play Mode)
┌─ ToolSlotManager ────────────────────────────────────────┐
│ 激活槽: 0 │
│ ┌──────────────────────────────────────────────────────┐│
│ │ 槽 0 [★] Tool_Slingshot CD: 0.00s ─── ││
│ │ 槽 1 Tool_TrapBear 库存: 2/10 ││
│ └──────────────────────────────────────────────────────┘│
│ [模拟使用] [切换工具] [拾取工具 ▼] │
└──────────────────────────────────────────────────────────┘
新增工具 SOP(零代码)
- 右键
Assets/Data/Tools/→Create → Tools/Tool,填写toolId - 在
effects数组中添加已有ToolEffectSO,或创建新的子类 SO - 在场景中放置
ToolPickup预制件,引用该 ToolSO - 在
ToolSlotManager._defaultSlot或_altSlot引用(若默认持有)
ToolEffect 子类开发规范
继承 ToolEffect → 实现 Execute(ToolUseContext)
CreateAssetMenu 菜单:Create → Tools/Effects/{EffectName}
完全数据驱动,所有参数在 SO 字段配置
通过 ctx.PlayerTransform / ctx.Stats 访问玩家
禁止 FindObjectOfType / Singleton 访问