35 KiB
08 · 世界模块
命名空间
BaseGames.World
程序集BaseGames.World
路径Assets/Scripts/World/
依赖BaseGames.Core、BaseGames.Core.Events、BaseGames.Core.Save
(通过IRestoreOnSave(定义于 Core)与玩家层解耦,无需直接引用BaseGames.Player)
目录
- 场景结构规范(总览)
- RoomTransition
- SavePoint
- HazardZone
- Collectible
- AbilityUnlock
- IInteractable 接口
- InteractableDetector
- WorldStateRegistry
- DestructibleTile
- MovingPlatform
- DirectionalDestructible — 单向可破坏墙
- DirectionalInteractable — 单向触发机关
- CrumblePlatform — 碎裂平台
- SkillInteractable — 技能专属交互物
- 世界事件频道清单
1. 场景结构规范
场景命名(见 01_ProjectStructure.md §8)。
房间场景标准层级(详见 Architecture README)。
重要约束:
- 每个房间场景必须包含
RoomController组件(挂在[RoomRoot]GameObject 上) - 必须有至少一个
RoomTransition(出入口) - 玩家出生点:
PlayerSpawnPoint组件,由SceneLoadRequest.EntryTransitionId匹配
2. RoomTransition
// 路径: Assets/Scripts/World/RoomTransition.cs
// 挂在出入口 Trigger Collider2D 上
[RequireComponent(typeof(Collider2D))]
public class RoomTransition : MonoBehaviour
{
[Header("Config")]
[SerializeField] private string _transitionId; // 唯一 ID(目标出口用于匹配出生点)
[SerializeField] private string _targetSceneAddress; // 目标场景 Addressable key(AddressKeys 常量)
[SerializeField] private string _targetTransitionId; // 目标场景中对应出口的 ID
[SerializeField] private bool _autoTrigger = true; // true = 进入触发器自动触发;false = 需交互
[SerializeField] private bool _requiresKeyItem; // 是否需要持有鑰匙物品
[SerializeField] private string _requiredItemId; // 鑰匙物品 ID
[Header("Event Channel")]
[SerializeField] private SceneLoadRequestEventChannelSO _onSceneLoadRequest;
[Header("世界状态")]
[SerializeField] private WorldStateRegistry _worldState; // 持有物品检查
// 玩家进入触发器
private void OnTriggerEnter2D(Collider2D other)
{
if (!other.CompareTag("Player")) return;
if (_requiresKeyItem && !HasItem(_requiredItemId)) return;
_onSceneLoadRequest.Raise(new SceneLoadRequest
{
SceneName = _targetSceneName,
EntryTransitionId = _targetTransitionId,
ShowLoadingScreen = false,
IsRespawn = false
});
}
private bool HasItem(string itemId)
{
if (string.IsNullOrEmpty(itemId)) return true;
if (_worldState == null) return false; // 未配置则拦截(警告日志)
return _worldState.IsCollected(itemId);
}
// Editor:在 Scene View 显示箭头 Gizmo
private void OnDrawGizmos();
}
// 玩家出生点,与 RoomTransition.transitionId 对应
public class PlayerSpawnPoint : MonoBehaviour
{
public string TransitionId;
public Vector2 SpawnPosition => transform.position;
public int FacingDirection = 1; // +1 右, -1 左
private void OnDrawGizmos() { /* 绿色标记 */ }
}
3. SavePoint
// 路径: Assets/Scripts/World/SavePoint.cs
// 实现 IInteractable + ISaveable,玩家交互时触发存档
// 架构决策:通过 IRestoreOnSave(定义于 BaseGames.Core)调用玩家回血/灵泉,
// 避免 World 层反向依赖 BaseGames.Player,World.asmdef 无需引用 Player 程序集。
public class SavePoint : MonoBehaviour, IInteractable, ISaveable
{
[Header("Config")]
[SerializeField] private string _savePointId;
[SerializeField] private bool _restoreSpring = true;
[Header("Event Channels")]
[SerializeField] private StringEventChannelSO _onSavePointActivated;
[SerializeField] private VoidEventChannelSO _onFastTravelOpen;
private bool _isActivated;
// IInteractable(⚠️ 参数为 Transform,与 14_NarrativeModule §1 / 07 §7 对齐)
public bool CanInteract => true;
public string InteractPrompt => _isActivated ? "休息" : "激活";
public void Interact(Transform player)
{
_isActivated = true;
// 1. 通过 IRestoreOnSave 恢复玩家(World 不感知具体 Player 类型)
var restorer = player.GetComponentInChildren<IRestoreOnSave>();
if (restorer != null)
{
restorer.FullRestore();
if (_restoreSpring) restorer.RestoreSpring();
}
// 2. 广播存档点激活(GameManager 响应并调用 SaveManager.SaveAsync)
_onSavePointActivated?.Raise(_savePointId);
// 3. 若该场景已有多个存档点激活,打开快速旅行 UI
// 4. 播放激活动画 / 特效
}
// ISaveable 存档集成
public bool IsActivated => _isActivated;
public void SetActivated(bool val) => _isActivated = val;
}
IRestoreOnSave 接口(
Assets/Scripts/Core/IRestoreOnSave.cs,命名空间BaseGames.Core)
PlayerStats显式实现:FullRestore()→FullHeal(),RestoreSpring()→RestoreSpringCharges()。
同一接口可扩展至其他可被存档点恢复的对象(伙伴、坐骑等),无需修改 SavePoint 本身。
4. HazardZone
// 路径: Assets/Scripts/World/HazardZone.cs
// 即死区域(深坑、岩浆等)
[RequireComponent(typeof(Collider2D))]
public class HazardZone : MonoBehaviour
{
public enum RespawnType { AtLastSavePoint, AtRoomEntry }
[SerializeField] private bool _isInstantKill = true;
[SerializeField] private int _damage = 9999;
[SerializeField] private RespawnType _respawnType = RespawnType.AtLastSavePoint;
private void OnTriggerEnter2D(Collider2D other)
{
if (!other.CompareTag("Player")) return;
var stats = other.GetComponentInParent<PlayerStats>();
if (stats == null) return;
if (_isInstantKill)
stats.TakeDamage(stats.MaxHP * 2); // 确保即死
else
stats.TakeDamage(_damage);
}
}
5. Collectible
// 路径: Assets/Scripts/World/Collectible.cs
// Geo 货币 / 物品掉落
public class Collectible : MonoBehaviour
{
[Header("Config")]
[SerializeField] private CollectibleType _type;
[SerializeField] private int _geoAmount; // type = Geo 时
[SerializeField] private string _itemId; // type = Item 时
[SerializeField] private bool _isPersistent; // false = 敌人掉落(不存档); true = 固定位置(存档)
[Header("Physics")]
[SerializeField] private float _bounceForce = 5f;
[Header("Event Channel")]
[SerializeField] private StringEventChannelSO _onCollectiblePickup;
private string _collectibleId; // 用于存档(持久化 Collectible 专用)
private void OnTriggerEnter2D(Collider2D other)
{
if (!other.CompareTag("Player")) return;
var player = other.GetComponentInParent<PlayerController>();
if (player == null) return;
switch (_type)
{
case CollectibleType.Geo:
player.Stats.AddGeo(_geoAmount);
break;
case CollectibleType.Item:
// 通知 Inventory / QuestManager
_onCollectiblePickup.Raise(_itemId);
break;
}
if (_isPersistent)
_onCollectiblePickup.Raise(_collectibleId); // 存档标记
Despawn();
}
private void Despawn(); // 归还对象池
// ―― 运行时配置(由 CollectibleSpawner 在实例化后调用)――――――――――――――――
/// <summary>将此 Collectible 配置为 Geo 掉落。</summary>
public void SetGeo(int amount);
/// <summary>将此 Collectible 配置为道具掉落。</summary>
public void SetItem(string itemId);
}
public enum CollectibleType { Geo, Item, HPOrb }
5b. CollectibleSpawner
// 路径: Assets/Scripts/World/CollectibleSpawner.cs + CollectibleSpawnerConfig.cs
// 封装 Collectible Prefab 实例化逻辑,供 LootResolver 等静态调用。
// CollectibleSpawnerConfig 挂在 Persistent 场景 [World] GameObject 上持有 Prefab 引用,
// Awake 调用 CollectibleSpawner.Register(this) 注入静态配置。
public static class CollectibleSpawner
{
internal static void Register(CollectibleSpawnerConfig config);
public static void SpawnGeo(Vector2 position, int amount); // 实例化 GeoPrefab 并调用 SetGeo(amount)
public static void SpawnItem(Vector2 position, string itemId); // 实例化 ItemPrefab 并调用 SetItem(itemId)
}
public class CollectibleSpawnerConfig : MonoBehaviour
{
[SerializeField] internal GameObject GeoPrefab;
[SerializeField] internal GameObject ItemPrefab;
private void Awake() => CollectibleSpawner.Register(this);
}
6. AbilityUnlock
// 路径: Assets/Scripts/World/AbilityUnlock.cs
// 世界中固定位置的能力解锁物(获取新技能)
public class AbilityUnlock : MonoBehaviour, IInteractable
{
[SerializeField] private AbilityType _abilityToUnlock;
[SerializeField] private string _unlockId; // 存档用
[Header("Event Channels")]
[SerializeField] private StringEventChannelSO _onCollectiblePickup; // 通知存档已拾取
private bool _isCollected = false;
public bool CanInteract => !_isCollected;
public string InteractPrompt => "获得能力";
public void Interact(Transform player)
{
if (_isCollected) return;
_isCollected = true;
// ⚠️ PlayerController 无 Instance;通过 player 参数获取
player.GetComponent<PlayerController>()?.Stats.UnlockAbility(_abilityToUnlock);
_onCollectiblePickup.Raise(_unlockId);
// 触发解锁演出(Cutscene / UI 提示)
gameObject.SetActive(false);
}
public void SetCollected(bool val)
{
_isCollected = val;
if (val) gameObject.SetActive(false);
}
}
7. IInteractable 接口
// 路径: Assets/Scripts/World/IInteractable.cs
// ⚠️ 与 14_NarrativeModule §1 对齐(权威定义):Transform 参数 + 5 成员
namespace BaseGames.World
{
public interface IInteractable
{
bool CanInteract { get; } // 当前是否可交互
string InteractPrompt { get; } // 显示在交互提示 UI 上的文字
void Interact(Transform player); // 传入玩家 Transform;需要 PlayerController 时通过 player.GetComponent<PlayerController>() 获取(PlayerController 无 Instance)
void OnPlayerEnterRange(Transform player); // 进入检测范围
void OnPlayerExitRange(); // 离开检测范围
}
}
8. InteractableDetector
// 路径: Assets/Scripts/World/InteractableDetector.cs
// 挂在玩家上,检测周围可交互物并驱动交互 UI
public class InteractableDetector : MonoBehaviour
{
[SerializeField] private float _detectRadius = 1.5f;
[SerializeField] private LayerMask _interactableLayer;
[SerializeField] private InputReaderSO _inputReader;
[SerializeField] private StringEventChannelSO _onShowInteractPrompt; // 发布:显示交互提示
[SerializeField] private VoidEventChannelSO _onHideInteractPrompt; // 发布:隐藏交互提示
private IInteractable _nearest;
private IInteractable _previousNearest;
private void OnEnable() => _inputReader.InteractEvent += TryInteract;
private void OnDisable() => _inputReader.InteractEvent -= TryInteract;
private void Update()
{
// OverlapCircle → 找最近 IInteractable
var hits = Physics2D.OverlapCircleAll(transform.position, _detectRadius, _interactableLayer);
_nearest = FindNearest(hits);
if (_nearest != _previousNearest)
{
if (_previousNearest != null) { _previousNearest.OnPlayerExitRange(); _onHideInteractPrompt.Raise(); }
if (_nearest != null) { _nearest.OnPlayerEnterRange(transform); _onShowInteractPrompt.Raise(_nearest.InteractPrompt); }
_previousNearest = _nearest;
}
}
private void TryInteract()
{
_nearest?.Interact(transform); // 传入玩家自身 Transform;IInteractable 内部通过 player.GetComponent<PlayerController>() 获取组件(PlayerController 无 Instance)
}
}
9. WorldStateRegistry
⚠️ 与
14_NarrativeModule §8统一:WorldStateRegistry已改为 ScriptableObject(CreateAssetMenu), 通过[SerializeField]注入,不再使用静态Instance。HasFlag/SetFlag(key)接口与 Architecture 14 §8 保持一致。
// 路径: Assets/Scripts/World/Narrative/WorldStateRegistry.cs
// 管理世界持久化状态(已收集物、已激活存档点、已开门、已销毁对象、通用标志)
// ScriptableObject 形式,各组件通过 [SerializeField] 注入,零耦合;与 14_NarrativeModule §8 统一
namespace BaseGames.World
{
[CreateAssetMenu(menuName = "World/WorldStateRegistry")]
public class WorldStateRegistry : ScriptableObject
{
private HashSet<string> _collectedIds = new();
private HashSet<string> _activatedSavePoints = new();
private HashSet<string> _openedDoors = new();
private HashSet<string> _destroyedObjects = new();
public bool IsCollected(string id) => _collectedIds.Contains(id);
public void MarkCollected(string id) => _collectedIds.Add(id);
public bool IsSavePointActivated(string id) => _activatedSavePoints.Contains(id);
public void MarkSavePointActivated(string id) => _activatedSavePoints.Add(id);
public bool IsDestroyed(string id) => _destroyedObjects.Contains(id);
public void MarkDestroyed(string id) => _destroyedObjects.Add(id);
public bool IsDoorOpened(string id) => _openedDoors.Contains(id);
public void MarkDoorOpened(string id) => _openedDoors.Add(id);
// 通用世界状态标记(过场记录、剧情事件等)
// ⚠️ 接口与 14_NarrativeModule §8 统一:HasFlag(非 IsFlagSet);SetFlag(key) 单参数添加
private HashSet<string> _flags = new();
public bool HasFlag(string key) => _flags.Contains(key);
public void SetFlag(string key) => _flags.Add(key);
// SaveManager 集成(非 ISaveable,由 SaveManager 在 SaveAsync/LoadAsync 中直接调用)
public void LoadFromSave(WorldSaveData data);
public HashSet<string> GetAllFlags();
}
}
SaveManager 集成:
WorldStateRegistry不实现ISaveable接口(ScriptableObject,非 MonoBehaviour)。SaveManager在保存/加载时直接调用:// SaveManager.CollectAllData() 内: saveData.World = WorldStateRegistry.Instance.GetSaveData(); // 通过 SO 引用而非静态 Instance // SaveManager.ApplyLoadedData() 内: WorldStateRegistry.Instance.LoadFromSave(saveData.World);
WorldStateRegistrySO 资产路径:Assets/Data/World/WorldStateRegistry.asset。
10. DestructibleTile
// 路径: Assets/Scripts/World/DestructibleTile.cs
// 可被攻击破坏的地形块(影响导航网格)
public class DestructibleTile : MonoBehaviour, IDamageable
{
[SerializeField] private int _maxHP = 1;
[SerializeField] private string _destructedId; // 存档唯一 ID
private bool _isDestroyed = false;
// IDamageable
public bool IsInvincible => _isDestroyed;
public int Defense => 0;
public void TakeDamage(DamageInfo info)
{
if (_isDestroyed) return;
if (!CheckDestroyCondition(info)) return; // 子类可覆盖(DirectionalDestructible 方向校验)
_isDestroyed = true;
// 禁用 Renderer + 碰撞体
// 通知 PathBerserker2d 重新烘焙局部导航网格
// 记录到 WorldStateRegistry
}
// 子类覆盖以附加销毁前提条件(默认:无条件销毁)
protected virtual bool CheckDestroyCondition(DamageInfo info) => true;
}
11. MovingPlatform
// 路径: Assets/Scripts/World/MovingPlatform.cs
// 动态移动平台:Kinematic Rigidbody2D,乘客自动跟随(Passenger Pattern)
[RequireComponent(typeof(Rigidbody2D))]
public class MovingPlatform : MonoBehaviour
{
public enum MoveType { LinearAB, WayPoints, TriggeredLinear }
[Header("移动配置")]
[SerializeField] private MoveType _moveType = MoveType.LinearAB;
[SerializeField] private Transform[] _wayPoints; // LinearAB 仅用 [0][1]
[SerializeField] private float _speed = 3f; // u/s
[SerializeField] private float _waitAtEndpoint = 0.5f; // 端点停留秒数
[Header("TriggeredLinear 模式")]
[SerializeField] private VoidEventChannelSO _activationChannel; // 接收信号后单程运动
// 乘客检测:顶面上方 0.05f 的 IsTrigger BoxCollider2D,检测到 Player/Enemy 层
[Header("乘客检测")]
[SerializeField] private BoxCollider2D _passengerSensor; // Trigger,仅用于检测
private Rigidbody2D _rb;
private List<Transform> _passengers = new();
private int _waypointIndex;
private bool _movingForward = true;
private bool _triggered;
private void Awake() => _rb = GetComponent<Rigidbody2D>();
private void FixedUpdate()
{
if (_moveType == MoveType.TriggeredLinear && !_triggered) return;
MoveTowardsNextWaypoint();
}
private void MoveTowardsNextWaypoint()
{
var target = _wayPoints[_waypointIndex].position;
var next = Vector2.MoveTowards(_rb.position, target, _speed * Time.fixedDeltaTime);
_rb.MovePosition(next);
if (Vector2.Distance(_rb.position, target) < 0.02f)
StartCoroutine(WaitAndAdvance());
}
private IEnumerator WaitAndAdvance()
{
yield return new WaitForSeconds(_waitAtEndpoint);
AdvanceWaypoint();
}
private void AdvanceWaypoint()
{
// LinearAB: 往返; WayPoints: 环形; TriggeredLinear: 到达终点后停止
if (_moveType == MoveType.TriggeredLinear)
{
_waypointIndex = Mathf.Min(_waypointIndex + 1, _wayPoints.Length - 1);
if (_waypointIndex == _wayPoints.Length - 1) _triggered = false;
return;
}
if (_moveType == MoveType.LinearAB)
{
_movingForward = !_movingForward;
_waypointIndex = _movingForward ? 1 : 0;
}
else // WayPoints
{
_waypointIndex = (_waypointIndex + 1) % _wayPoints.Length;
}
}
// ── 乘客跟随(Passenger Pattern)─────────────────────────────────
private void OnTriggerEnter2D(Collider2D other)
{
if ((1 << other.gameObject.layer & LayerMask.GetMask("Player", "Enemy")) == 0) return;
other.transform.SetParent(transform);
_passengers.Add(other.transform);
}
private void OnTriggerExit2D(Collider2D other)
{
if (!_passengers.Contains(other.transform)) return;
other.transform.SetParent(null);
_passengers.Remove(other.transform);
// 继承平台当前速度(仅玩家)
if (other.CompareTag("Player"))
other.GetComponentInParent<Rigidbody2D>()?.AddForce(
_rb.velocity, ForceMode2D.Impulse);
}
private void OnEnable()
{
if (_activationChannel != null)
_activationChannel.OnEventRaised += OnTriggered;
}
private void OnDisable()
{
if (_activationChannel != null)
_activationChannel.OnEventRaised -= OnTriggered;
}
private void OnTriggered() => _triggered = true;
}
MoveType 说明:
| 类型 | 行为 |
|---|---|
LinearAB |
_wayPoints[0] ↔ [1] 往返循环 |
WayPoints |
按 _wayPoints[] 顺序环形循环 |
TriggeredLinear |
监听 _activationChannel,收到信号后单程 [0]→[n-1],到达后停止 |
NavSurface 联动:每个移动平台挂载独立
LocalNavSurface(局部坐标系),附着其上的敌人 NavAgent 使用该 LocalNavSurface 寻路;参见 Guides/PathBerserker2d_Technical_Evaluation.md §5。
12. DirectionalDestructible — 单向可破坏墙
继承 DestructibleTile,在其基础上增加攻击方向校验:
// 路径: Assets/Scripts/World/DirectionalDestructible.cs
public class DirectionalDestructible : DestructibleTile
{
public enum AttackSide { Left, Right, Top, Bottom, Any }
[SerializeField] private AttackSide _validAttackSide = AttackSide.Any;
protected override bool CheckDestroyCondition(DamageInfo info)
{
if (_validAttackSide == AttackSide.Any)
return base.CheckDestroyCondition(info);
// 判断攻击来源方向(info.SourcePosition 由 HitBox 传入)
var dir = (info.SourcePosition - (Vector2)transform.position).normalized;
bool valid = _validAttackSide switch
{
AttackSide.Left => dir.x < -0.5f,
AttackSide.Right => dir.x > 0.5f,
AttackSide.Top => dir.y > 0.5f,
AttackSide.Bottom => dir.y < -0.5f,
_ => true
};
return valid && base.CheckDestroyCondition(info);
}
#if UNITY_EDITOR
private void OnDrawGizmos()
{
// 有效攻击方向:橙色箭头;无效方向:灰色叉号
var arrow = _validAttackSide switch
{
AttackSide.Left => Vector2.left,
AttackSide.Right => Vector2.right,
AttackSide.Top => Vector2.up,
AttackSide.Bottom => Vector2.down,
_ => Vector2.zero
};
if (arrow == Vector2.zero) return;
Gizmos.color = new Color(1f, 0.5f, 0f, 0.9f);
var origin = (Vector2)transform.position;
Gizmos.DrawLine(origin, origin + arrow * 0.8f);
}
#endif
}
| 典型场景 | 配置 |
|---|---|
| 地板薄板:只能从下方砸穿 | _validAttackSide = Bottom |
| 密室封墙:仅能从房间内打开 | _validAttackSide = Right(依据朝向) |
| 普通脆弱墙 | 基类 DestructibleTile(AnyAttack)即可,不需此子类 |
13. DirectionalInteractable — 单向触发机关
// 路径: Assets/Scripts/World/DirectionalInteractable.cs
// 可从特定方向触发的单向机关(零耦合:通过 SO 事件频道连接受体)
[RequireComponent(typeof(Collider2D))]
public class DirectionalInteractable : MonoBehaviour, IInteractable
{
public enum TriggerSide { Left, Right, Top, Any }
public enum TriggerCondition { PlayerAttack, PlayerBody, InteractKey }
[Header("触发条件")]
[SerializeField] private TriggerSide _triggerSide = TriggerSide.Any;
[SerializeField] private TriggerCondition _triggerCondition = TriggerCondition.InteractKey;
[Header("行为")]
[SerializeField] private bool _isOneShot; // 一次性,触发后永久激活
[SerializeField] private string _interactableId; // 存档用唯一 ID
[Header("事件频道(零耦合连接关卡受体)")]
[SerializeField] private VoidEventChannelSO _activationChannel;
[SerializeField] private VoidEventChannelSO _deactivationChannel; // 非 OneShot 离开时
[Header("反馈")]
[SerializeField] private MMF_Player _activateFeedback;
private bool _activated;
// ── IInteractable(InteractKey 模式)─────────────────────────────
public string InteractPrompt => _activated ? "已激活" : "交互";
public void Interact(Transform player) // ⚠️ Transform 参数(与 §7 IInteractable 对齐)
{
if (_triggerCondition != TriggerCondition.InteractKey) return;
if (!CheckSide(player.position)) return;
TryActivate();
}
// ── PlayerBody / PlayerAttack 模式 ───────────────────────────────
// PlayerBody:OnTriggerEnter2D(Collider IsTrigger)
// PlayerAttack:挂配套 HurtBox → DamageInfo → TryInteractFromDamage
private void OnTriggerEnter2D(Collider2D other)
{
if (_triggerCondition != TriggerCondition.PlayerBody) return;
if (!other.CompareTag("Player")) return;
if (!CheckSide(other.transform.position)) return;
TryActivate();
}
private void OnTriggerExit2D(Collider2D other)
{
if (_triggerCondition != TriggerCondition.PlayerBody) return;
if (!other.CompareTag("Player") || _isOneShot) return;
_activated = false;
_deactivationChannel?.Raise();
}
// 由外部 HurtBox 转发(PlayerAttack 模式)
public void TryInteractFromDamage(DamageInfo info)
{
if (_triggerCondition != TriggerCondition.PlayerAttack) return;
if (!CheckSide(info.SourcePosition)) return;
TryActivate();
}
private void TryActivate()
{
if (_isOneShot && _activated) return;
_activated = true;
_activateFeedback?.PlayFeedbacks();
_activationChannel?.Raise();
if (_isOneShot)
{
// 持久化
SaveManager.Instance?.SetMechanismState(_interactableId, true);
}
}
private bool CheckSide(Vector2 sourcePos)
{
if (_triggerSide == TriggerSide.Any) return true;
var dir = (sourcePos - (Vector2)transform.position).normalized;
return _triggerSide switch
{
TriggerSide.Left => dir.x < -0.4f,
TriggerSide.Right => dir.x > 0.4f,
TriggerSide.Top => dir.y > 0.4f,
_ => true
};
}
private void Start()
{
// 读档恢复
if (_isOneShot && !string.IsNullOrEmpty(_interactableId)
&& (SaveManager.Instance?.GetMechanismState(_interactableId) ?? false))
{
_activated = true;
_activationChannel?.Raise(); // 静默恢复联动状态
}
}
}
零耦合连接示例(Inspector 拖入同一 SO 资产):
Switch_Forest_01._activationChannel ──► MovingPlatform._activationChannel
──► Door_Locked._openChannel
──► HazardZone_Spikes._disableChannel
14. CrumblePlatform — 碎裂平台
// 路径: Assets/Scripts/World/CrumblePlatform.cs
[RequireComponent(typeof(BoxCollider2D))]
public class CrumblePlatform : MonoBehaviour
{
[SerializeField] private float _warningDuration = 0.6f; // 踩上后警告时长(抖动)
[SerializeField] private float _crumbleDuration = 0.3f; // 碎裂动画时长
[SerializeField] private float _respawnDelay = 3.0f; // 0 = 永久消失
[SerializeField] private bool _isOneShot = false; // true = 碎裂后永久消失
[SerializeField] private MMF_Player _crumbleFeedback; // 预警震动 + 碎裂粒子 + 音效
[SerializeField] private BoxCollider2D _passengerSensor; // Trigger,检测玩家踩踏
private BoxCollider2D _col;
private SpriteRenderer _sr;
private bool _isCrumbling;
private static readonly int[] StateFrames = { 0, 1, 2, 3 }; // Idle/Warning/Crumbling/Gone
private void Awake()
{
_col = GetComponent<BoxCollider2D>();
_sr = GetComponent<SpriteRenderer>();
}
private void OnTriggerEnter2D(Collider2D other)
{
if (_isCrumbling || !other.CompareTag("Player")) return;
StartCoroutine(CrumbleSequence());
}
private IEnumerator CrumbleSequence()
{
_isCrumbling = true;
// 1. Warning(抖动)
_crumbleFeedback?.PlayFeedbacks();
yield return new WaitForSeconds(_warningDuration);
// 2. Crumbling
yield return new WaitForSeconds(_crumbleDuration);
// 3. Gone:禁用碰撞体 + 隐藏 Sprite
_col.enabled = false;
_sr.enabled = false;
_passengerSensor.enabled = false;
if (_isOneShot || _respawnDelay <= 0f)
{
yield break; // 永久消失
}
// 4. Respawn
yield return new WaitForSeconds(_respawnDelay);
_col.enabled = true;
_sr.enabled = true;
_passengerSensor.enabled = true;
_isCrumbling = false;
}
}
状态机:
[玩家踩上]
Idle ────────────► Warning ──[warningDuration]──► Crumbling ──[动画]──► Gone
抖动 │
←──[respawnDelay,非 OneShot]─┘
15. SkillInteractable — 技能专属交互物
这类物体不走伤害管线,而是监听角色技能状态或物理层叠加实现交互。
三种类型对应游戏内三个形态的专属技能机关。
15.1 MagicWall — 魔法障壁(太虚斩专属)
太虚斩(命魂 SoulSkill)施放时,玩家进入 PhysicsLayer: Ghost;MagicWall 对 Ghost 层无碰撞,允许穿越。
// 路径: Assets/Scripts/World/MagicWall.cs
// 组件挂法:MagicWall GO 同时挂 TilemapCollider2D / BoxCollider2D
// 不与 Ghost 层碰撞(Physics Layer Matrix 配置,非代码控制)
// 脚本职责:Gizmo 可视化 + 颜色联动(普通/幽灵两态视觉区分)
[ExecuteAlways]
public class MagicWall : MonoBehaviour
{
[SerializeField] private Color _normalColor = new(0.4f, 0.2f, 1f, 0.8f); // 紫色(可见)
[SerializeField] private Color _ghostColor = new(0.4f, 0.2f, 1f, 0.15f); // 淡紫(穿越提示)
// 在 FormSkillSO(太虚斩)canPassMagicWalls = true 时,
// SkillManager 在技能开始/结束时切换玩家的物理层:
// 开始: player.gameObject.layer = LayerMask.NameToLayer("Ghost")
// 结束: player.gameObject.layer = LayerMask.NameToLayer("Player")
// Physics Layer Matrix 设置: Ghost vs MagicWall = IgnoreCollision
// 因此 MagicWall 本身无需额外代码,只靠层矩阵实现穿越。
#if UNITY_EDITOR
private void OnDrawGizmos()
{
Gizmos.color = _normalColor;
var b = GetComponent<Collider2D>();
if (b != null)
Gizmos.DrawWireCube(transform.position, b.bounds.size);
}
#endif
}
Physics Layer Matrix 配置:
| Layer A | Layer B | 碰撞 |
|---|---|---|
Player |
MagicWall |
✅ 碰撞(正常阻挡) |
Ghost |
MagicWall |
❌ 忽略(太虚斩穿越) |
Enemy |
MagicWall |
✅ 碰撞(敌人不能穿越) |
参见 57_PhysicsLayerMatrix.md。
SkillManager.TrySoulSkill()在技能激活/结束时调用SetPlayerLayer("Ghost"/"Player")。
15.2 SoftTerrain — 松软地面(地行术专属)
地行术(地魂 SoulSkill)的 GroundDive 状态中,玩家进入地面移动。SoftTerrain 地块降低地行术灵力消耗速率(松软地面不消耗灵力)。
// 路径: Assets/Scripts/World/SoftTerrain.cs
// 挂在松软地面的 Tilemap/GameObject 上
// GroundDiveState 通过 OverlapPoint 检测当前站立/穿行瓦片,查询是否 IsSoftTerrain
public class SoftTerrain : MonoBehaviour
{
// Marker 组件——无逻辑,仅用于 GetComponent<SoftTerrain>() 检测
// GroundDiveState(PlayerFSM)在每帧对角色脚下 Physics2D.OverlapPoint() 检测:
// 若碰到实现了 SoftTerrain 的 Tilemap → SetSoulDrainRate(0)
// 否则 → SetSoulDrainRate(FormSkillSO.soulCostPerSecond)
}
关卡搭建:
- 在
[Level]下新增Tilemap_SoftGround层,铺设松软地面 Tile - 该 Tilemap GameObject 挂载
SoftTerrain组件 TilemapCollider2D.isTrigger = false(正常地面碰撞,GroundDiveState穿越时物理层切换为Ghost忽略该层)
与 MagicWall 的关键区别:
| MagicWall | SoftTerrain | |
|---|---|---|
| 穿越条件 | 太虚斩激活(Ghost 层) |
地行术激活(另一 Ghost 变体层) |
| 其余情况 | 实体阻挡 | 实体地面 |
| 游戏效果 | 到达秘密区域 / 跑图捷径 | 降低灵力消耗 / 速度加成 |
15.3 PhantomInteractable — 幻影机关(残阴术专属)
残阴术(命魂 SpiritSkill1)在原地留下灵体,灵体可代替玩家触发特定机关。
普通 PressurePlate 仅响应玩家,PhantomInteractable 额外响应 PhantomBody 层。
// 路径: Assets/Scripts/World/PhantomInteractable.cs
// 继承 DirectionalInteractable,额外监听 PhantomBody 层的 Collider 进入
// 用途:需要延迟触发的机关(先放灵体踩住,再操控玩家本体做其他事)
public class PhantomInteractable : DirectionalInteractable
{
// 残阴术(SpiritSkill1)实例化 PhantomBody Prefab,
// PhantomBody 挂载 Rigidbody2D,Layer = "PhantomBody"
// 本组件的 Collider(Trigger)对 PhantomBody 层同样响应
private new void OnTriggerEnter2D(Collider2D other)
{
bool isPlayer = other.CompareTag("Player");
bool isPhantom = other.gameObject.layer ==
LayerMask.NameToLayer("PhantomBody");
if (!isPlayer && !isPhantom) return;
// 方向校验:幻影机关通常 TriggerSide = Any(灵体无方向约束)
TryActivate();
}
}
典型谜题:
场景设置:
PhantomInteractable(PressurePlate 型)── _activationChannel ──► Door
解谜流程:
1. 玩家施放残阴术 → 留下灵体踩住 PhantomInteractable → 门打开
2. 玩家快速通过门洞
3. 残阴术持续时间结束 → 灵体消失 → PhantomInteractable 失活 → 门关闭(若非 OneShot)
16. 世界事件频道清单
| 资产名 | 类型 | Raise 方 | Subscribe 方 |
|---|---|---|---|
EVT_SavePointActivated |
StringEventChannelSO |
SavePoint |
GameManager(触发存档)、HUDController(显示提示) |
EVT_RoomTransitionRequest |
SceneLoadRequestEventChannelSO |
RoomTransition |
SceneLoader |
EVT_CollectiblePickup |
StringEventChannelSO |
Collectible、AbilityUnlock |
WorldStateRegistry、QuestManager、AnalyticsManager |
EVT_FastTravelOpen |
VoidEventChannelSO |
SavePoint |
UIManager(显示 FastTravel 面板) |
EVT_ShowInteractPrompt |
StringEventChannelSO |
InteractableDetector |
HUDController |
EVT_HideInteractPrompt |
VoidEventChannelSO |
InteractableDetector |
HUDController |