多轮审查和修复
This commit is contained in:
60
Assets/Scripts/World/AbilityGate.cs
Normal file
60
Assets/Scripts/World/AbilityGate.cs
Normal file
@@ -0,0 +1,60 @@
|
||||
using UnityEngine;
|
||||
using BaseGames.Core.Events;
|
||||
using BaseGames.Player;
|
||||
|
||||
namespace BaseGames.World
|
||||
{
|
||||
/// <summary>
|
||||
/// 能力门禁(架构 09_ProgressionModule §2)。
|
||||
/// 使用 PlayerStats.HasAbility() 检测玩家是否持有所需能力;
|
||||
/// 订阅 AbilityTypeEventChannelSO 实时响应能力解锁事件。
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(Collider2D))]
|
||||
public class AbilityGate : MonoBehaviour
|
||||
{
|
||||
[SerializeField] protected AbilityType _requiredAbility;
|
||||
[SerializeField] private GameObject _blockingObject; // 关卡障碍物 GO(禁/启用)
|
||||
[SerializeField] private GameObject _hintUI; // 提示 UI(能力图标 + "???")
|
||||
[SerializeField] private string _gateId; // 存档用(预留)
|
||||
|
||||
[Header("引用")]
|
||||
[SerializeField] protected PlayerStats _playerStats; // Inspector 注入
|
||||
[SerializeField] private AbilityTypeEventChannelSO _onAbilityUnlocked; // EVT_AbilityUnlocked
|
||||
|
||||
private readonly CompositeDisposable _subs = new();
|
||||
|
||||
/// <summary>子类可重写以实现额外通行条件检测。</summary>
|
||||
protected virtual bool EvaluateAccess()
|
||||
=> _playerStats != null && _playerStats.HasAbility(_requiredAbility);
|
||||
|
||||
private void Start()
|
||||
{
|
||||
ApplyState(EvaluateAccess());
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
_onAbilityUnlocked?.Subscribe(OnAbilityUnlocked).AddTo(_subs);
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
_subs.Clear();
|
||||
}
|
||||
|
||||
private void OnAbilityUnlocked(AbilityType ability)
|
||||
{
|
||||
if (ability != _requiredAbility) return;
|
||||
ApplyState(true);
|
||||
}
|
||||
|
||||
/// <summary>外部调用:强制开启门禁(不检查条件)。</summary>
|
||||
public void Open() => ApplyState(true);
|
||||
|
||||
private void ApplyState(bool unlocked)
|
||||
{
|
||||
if (_blockingObject != null) _blockingObject.SetActive(!unlocked);
|
||||
if (_hintUI != null) _hintUI.SetActive(!unlocked);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/World/AbilityGate.cs.meta
Normal file
11
Assets/Scripts/World/AbilityGate.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6b0688b021b5f484e97e37c1df2a78cf
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
57
Assets/Scripts/World/AbilityUnlock.cs
Normal file
57
Assets/Scripts/World/AbilityUnlock.cs
Normal file
@@ -0,0 +1,57 @@
|
||||
using System.Collections;
|
||||
using UnityEngine;
|
||||
using MoreMountains.Tools;
|
||||
using BaseGames.Player;
|
||||
|
||||
namespace BaseGames.World
|
||||
{
|
||||
/// <summary>
|
||||
/// 能力解锁触发物(Prefab)。
|
||||
/// 玩家进入触发区后播放解锁演出,然后调用 PlayerStats.UnlockAbility()。
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(Collider2D))]
|
||||
public class AbilityUnlock : MonoBehaviour
|
||||
{
|
||||
[Header("解锁配置")]
|
||||
[SerializeField] private AbilityType _abilityToUnlock;
|
||||
[SerializeField] private bool _destroyAfterUnlock = true;
|
||||
|
||||
[Header("演出配置")]
|
||||
[SerializeField] private Component _unlockFeedback; // Assign MMF_Player or compatible component
|
||||
[SerializeField] private float _cutsceneDuration = 1.5f;
|
||||
|
||||
[Header("Event Channel")]
|
||||
[SerializeField] private AbilityTypeEventChannelSO _onAbilityUnlocked; // EVT_AbilityUnlocked
|
||||
|
||||
private bool _used;
|
||||
|
||||
private void OnTriggerEnter2D(Collider2D other)
|
||||
{
|
||||
if (_used || !other.CompareTag("Player")) return;
|
||||
var stats = other.GetComponentInParent<PlayerStats>();
|
||||
if (stats == null || stats.HasAbility(_abilityToUnlock)) return;
|
||||
StartCoroutine(UnlockSequence(stats));
|
||||
}
|
||||
|
||||
private IEnumerator UnlockSequence(PlayerStats stats)
|
||||
{
|
||||
_used = true;
|
||||
|
||||
PlayFeedback(_unlockFeedback);
|
||||
yield return new WaitForSeconds(_cutsceneDuration);
|
||||
|
||||
stats.UnlockAbility(_abilityToUnlock);
|
||||
_onAbilityUnlocked?.Raise(_abilityToUnlock);
|
||||
|
||||
if (_destroyAfterUnlock)
|
||||
Destroy(gameObject);
|
||||
}
|
||||
|
||||
private static void PlayFeedback(Component feedback)
|
||||
{
|
||||
if (feedback == null) return;
|
||||
var method = feedback.GetType().GetMethod("PlayFeedbacks", System.Type.EmptyTypes);
|
||||
method?.Invoke(feedback, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/World/AbilityUnlock.cs.meta
Normal file
11
Assets/Scripts/World/AbilityUnlock.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 771e9fdd9e24d4c439ea4ee76fc6720a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -10,7 +10,15 @@
|
||||
"references": [
|
||||
"BaseGames.Core",
|
||||
"BaseGames.Core.Events",
|
||||
"BaseGames.Core.Save"
|
||||
"BaseGames.Core.Save",
|
||||
"BaseGames.Input",
|
||||
"BaseGames.Combat",
|
||||
"BaseGames.Player",
|
||||
"BaseGames.Camera",
|
||||
"BaseGames.VFX",
|
||||
"MoreMountains.Tools",
|
||||
"Kybernetik.Animancer",
|
||||
"Unity.RenderPipelines.Core.Runtime"
|
||||
],
|
||||
"autoReferenced": true,
|
||||
"overrideReferences": false,
|
||||
|
||||
61
Assets/Scripts/World/BreadcrumbTracker.cs
Normal file
61
Assets/Scripts/World/BreadcrumbTracker.cs
Normal file
@@ -0,0 +1,61 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.World
|
||||
{
|
||||
/// <summary>
|
||||
/// 玩家面包屑轨迹追踪器(架构 21_LiquidPuzzleModule §15)。
|
||||
/// 每 RecordInterval 秒记录一次玩家位置(仅当移动距离超过 MinMoveDistance 时)。
|
||||
/// 最多保存 MaxCrumbs 个历史位置,超出后移除最旧的。
|
||||
/// </summary>
|
||||
public class BreadcrumbTracker : MonoBehaviour
|
||||
{
|
||||
[SerializeField, Min(0.1f)] private float _recordInterval = 2f;
|
||||
[SerializeField, Min(1)] private int _maxCrumbs = 20;
|
||||
[SerializeField, Min(0.1f)] private float _minMoveDistance = 1f;
|
||||
|
||||
private readonly Queue<Vector2> _crumbs = new();
|
||||
private Vector2 _lastPos;
|
||||
private float _timer;
|
||||
|
||||
private Transform _playerTransform;
|
||||
|
||||
// ── Unity 生命周期 ────────────────────────────────────────────────
|
||||
private void Awake()
|
||||
{
|
||||
var go = GameObject.FindWithTag("Player");
|
||||
if (go != null) _playerTransform = go.transform;
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (_playerTransform == null) return;
|
||||
|
||||
_timer += Time.deltaTime;
|
||||
if (_timer < _recordInterval) return;
|
||||
_timer = 0f;
|
||||
|
||||
Vector2 pos = _playerTransform.position;
|
||||
if (_crumbs.Count > 0 && Vector2.Distance(pos, _lastPos) < _minMoveDistance)
|
||||
return;
|
||||
|
||||
_crumbs.Enqueue(pos);
|
||||
_lastPos = pos;
|
||||
|
||||
while (_crumbs.Count > _maxCrumbs)
|
||||
_crumbs.Dequeue();
|
||||
}
|
||||
|
||||
// ── 公共 API ──────────────────────────────────────────────────────
|
||||
/// <summary>返回最近 count 个面包屑位置(从旧到新排列)。</summary>
|
||||
public IReadOnlyList<Vector2> GetRecentCrumbs(int count)
|
||||
{
|
||||
var result = new List<Vector2>(_crumbs);
|
||||
if (result.Count <= count) return result;
|
||||
return result.GetRange(result.Count - count, count);
|
||||
}
|
||||
|
||||
/// <summary>清空全部轨迹记录(如死亡后重生时调用)。</summary>
|
||||
public void Clear() => _crumbs.Clear();
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/World/BreadcrumbTracker.cs.meta
Normal file
11
Assets/Scripts/World/BreadcrumbTracker.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d24c8921af6b81a41a5ad4abdb6e7bad
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
95
Assets/Scripts/World/Collectible.cs
Normal file
95
Assets/Scripts/World/Collectible.cs
Normal file
@@ -0,0 +1,95 @@
|
||||
using BaseGames.Core.Events;
|
||||
using BaseGames.Player;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.World
|
||||
{
|
||||
public enum CollectibleType { Geo, Item, HPOrb }
|
||||
|
||||
/// <summary>
|
||||
/// 可收集物(Geo / 道具 / HP 球)。玩家进入触发器自动拾取。
|
||||
/// _isPersistent = true 时拾取后广播 ID 供存档记录。
|
||||
/// </summary>
|
||||
public class Collectible : MonoBehaviour
|
||||
{
|
||||
[Header("配置")]
|
||||
[SerializeField] private CollectibleType _type;
|
||||
[SerializeField] private int _geoAmount;
|
||||
[SerializeField] private string _itemId;
|
||||
[SerializeField] private bool _isPersistent; // false = 敌人掉落;true = 固定位置(存档)
|
||||
[SerializeField] private string _collectibleId; // 持久化唯一 ID(_isPersistent = true 时填写)
|
||||
|
||||
[Header("物理")]
|
||||
[SerializeField] private float _bounceForce = 5f;
|
||||
|
||||
[Header("事件频道")]
|
||||
[SerializeField] private StringEventChannelSO _onCollectiblePickup;
|
||||
|
||||
private bool _collected;
|
||||
|
||||
private void Start()
|
||||
{
|
||||
// 给予初始弹跳冲力
|
||||
var rb = GetComponent<Rigidbody2D>();
|
||||
if (rb != null)
|
||||
{
|
||||
var dir = new Vector2(Random.Range(-0.5f, 0.5f), 1f).normalized;
|
||||
rb.AddForce(dir * _bounceForce, ForceMode2D.Impulse);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnTriggerEnter2D(Collider2D other)
|
||||
{
|
||||
if (_collected) return;
|
||||
if (!other.CompareTag("Player")) return;
|
||||
|
||||
var stats = other.GetComponentInParent<PlayerStats>();
|
||||
if (stats == null) return;
|
||||
|
||||
_collected = true;
|
||||
|
||||
switch (_type)
|
||||
{
|
||||
case CollectibleType.Geo:
|
||||
stats.AddGeo(_geoAmount);
|
||||
break;
|
||||
|
||||
case CollectibleType.Item:
|
||||
_onCollectiblePickup?.Raise(_itemId);
|
||||
break;
|
||||
|
||||
case CollectibleType.HPOrb:
|
||||
stats.HealHP(1);
|
||||
break;
|
||||
}
|
||||
|
||||
if (_isPersistent && !string.IsNullOrEmpty(_collectibleId))
|
||||
_onCollectiblePickup?.Raise(_collectibleId);
|
||||
|
||||
Despawn();
|
||||
}
|
||||
|
||||
private void Despawn()
|
||||
{
|
||||
gameObject.SetActive(false);
|
||||
}
|
||||
|
||||
// ── 运行时配置(由 CollectibleSpawner 在实例化后调用)────────────────
|
||||
|
||||
/// <summary>将此 Collectible 配置为 Geo 掉落(实例化后由 CollectibleSpawner 调用)。</summary>
|
||||
public void SetGeo(int amount)
|
||||
{
|
||||
_type = CollectibleType.Geo;
|
||||
_geoAmount = amount;
|
||||
_isPersistent = false;
|
||||
}
|
||||
|
||||
/// <summary>将此 Collectible 配置为道具掉落(实例化后由 CollectibleSpawner 调用)。</summary>
|
||||
public void SetItem(string itemId)
|
||||
{
|
||||
_type = CollectibleType.Item;
|
||||
_itemId = itemId;
|
||||
_isPersistent = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/World/Collectible.cs.meta
Normal file
11
Assets/Scripts/World/Collectible.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bc6127aab8dccfd44908a8b790f37fd1
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
58
Assets/Scripts/World/CollectibleSpawner.cs
Normal file
58
Assets/Scripts/World/CollectibleSpawner.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.World
|
||||
{
|
||||
/// <summary>
|
||||
/// 可收集物生成器(静态工具类)。
|
||||
/// 封装 Geo / 道具 Collectible 的实例化逻辑,供 LootResolver 等调用。
|
||||
/// Prefab 引用通过 CollectibleSpawnerConfig SO 注入,避免 Resources.Load。
|
||||
/// </summary>
|
||||
public static class CollectibleSpawner
|
||||
{
|
||||
/// <summary>
|
||||
/// 全局配置引用(由 CollectibleSpawnerConfig.Initialize() 在游戏启动时设置)。
|
||||
/// </summary>
|
||||
private static CollectibleSpawnerConfig _config;
|
||||
|
||||
/// <summary>由 CollectibleSpawnerConfig.Awake() 注册自身。</summary>
|
||||
internal static void Register(CollectibleSpawnerConfig config) => _config = config;
|
||||
|
||||
/// <summary>
|
||||
/// 在世界坐标生成 Geo 拾取物。
|
||||
/// 若配置未注册则仅输出日志(编辑器 / 测试场景兜底)。
|
||||
/// </summary>
|
||||
public static void SpawnGeo(Vector2 position, int amount)
|
||||
{
|
||||
if (_config == null || _config.GeoPrefab == null)
|
||||
{
|
||||
Debug.LogWarning($"[CollectibleSpawner] GeoPrefab 未配置,Geo x{amount} 无法生成 at {position}");
|
||||
return;
|
||||
}
|
||||
|
||||
var go = Object.Instantiate(_config.GeoPrefab, position, Quaternion.identity);
|
||||
if (go.TryGetComponent<Collectible>(out var c))
|
||||
{
|
||||
c.SetGeo(amount);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在世界坐标生成道具拾取物(通过 itemId 广播 EVT_CollectiblePickup)。
|
||||
/// 若配置未注册则仅输出日志。
|
||||
/// </summary>
|
||||
public static void SpawnItem(Vector2 position, string itemId)
|
||||
{
|
||||
if (_config == null || _config.ItemPrefab == null)
|
||||
{
|
||||
Debug.LogWarning($"[CollectibleSpawner] ItemPrefab 未配置,物品 {itemId} 无法生成 at {position}");
|
||||
return;
|
||||
}
|
||||
|
||||
var go = Object.Instantiate(_config.ItemPrefab, position, Quaternion.identity);
|
||||
if (go.TryGetComponent<Collectible>(out var c))
|
||||
{
|
||||
c.SetItem(itemId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/World/CollectibleSpawner.cs.meta
Normal file
11
Assets/Scripts/World/CollectibleSpawner.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 639f83b55ae027245a4ad6fc8c1f23ae
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
18
Assets/Scripts/World/CollectibleSpawnerConfig.cs
Normal file
18
Assets/Scripts/World/CollectibleSpawnerConfig.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.World
|
||||
{
|
||||
/// <summary>
|
||||
/// CollectibleSpawner 的 Prefab 配置 SO。
|
||||
/// 挂 MonoBehaviour 包装以便在 Awake 时向静态 CollectibleSpawner 注册。
|
||||
/// 挂在 Persistent 场景的 [World] GameObject 上。
|
||||
/// </summary>
|
||||
public class CollectibleSpawnerConfig : MonoBehaviour
|
||||
{
|
||||
[Header("掉落物 Prefab")]
|
||||
[SerializeField] internal GameObject GeoPrefab;
|
||||
[SerializeField] internal GameObject ItemPrefab;
|
||||
|
||||
private void Awake() => CollectibleSpawner.Register(this);
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/World/CollectibleSpawnerConfig.cs.meta
Normal file
11
Assets/Scripts/World/CollectibleSpawnerConfig.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1cdcd375086213841a7c0024e0ea8d23
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
69
Assets/Scripts/World/CrumblePlatform.cs
Normal file
69
Assets/Scripts/World/CrumblePlatform.cs
Normal file
@@ -0,0 +1,69 @@
|
||||
using System.Collections;
|
||||
using MoreMountains.Feedbacks;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.World
|
||||
{
|
||||
/// <summary>
|
||||
/// 碎裂平台。玩家踩上后经历 Warning → Crumbling → Gone → (Recovering) 四态。
|
||||
/// _isOneShot = true 时碎裂后永久消失;_respawnDelay > 0 则在延迟后恢复。
|
||||
/// </summary>
|
||||
[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;
|
||||
[SerializeField] private MMF_Player _crumbleFeedback; // 预警震动 + 碎裂粒子 + 音效
|
||||
[SerializeField] private BoxCollider2D _passengerSensor; // IsTrigger,检测玩家踩踏
|
||||
|
||||
private BoxCollider2D _col;
|
||||
private SpriteRenderer _sr;
|
||||
private bool _isCrumbling;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
_col = GetComponent<BoxCollider2D>();
|
||||
_sr = GetComponent<SpriteRenderer>();
|
||||
}
|
||||
|
||||
private void OnTriggerEnter2D(Collider2D other)
|
||||
{
|
||||
if (_isCrumbling) return;
|
||||
if (!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;
|
||||
if (_passengerSensor != null)
|
||||
_passengerSensor.enabled = false;
|
||||
|
||||
if (_isOneShot || _respawnDelay <= 0f)
|
||||
yield break; // 永久消失
|
||||
|
||||
// 4. Respawn
|
||||
yield return new WaitForSeconds(_respawnDelay);
|
||||
|
||||
_col.enabled = true;
|
||||
_sr.enabled = true;
|
||||
if (_passengerSensor != null)
|
||||
_passengerSensor.enabled = true;
|
||||
|
||||
_isCrumbling = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/World/CrumblePlatform.cs.meta
Normal file
11
Assets/Scripts/World/CrumblePlatform.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 622e141966c211140a90d864526e2f2a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
50
Assets/Scripts/World/DeathShade.cs
Normal file
50
Assets/Scripts/World/DeathShade.cs
Normal file
@@ -0,0 +1,50 @@
|
||||
using UnityEngine;
|
||||
using BaseGames.Core.Events;
|
||||
|
||||
namespace BaseGames.World
|
||||
{
|
||||
/// <summary>
|
||||
/// 死亡遗骸(Death Shade)。玩家死亡时在死亡地点留下遗骸,
|
||||
/// 再次到达并交互后回收存储的 Geo。
|
||||
/// 实现 <see cref="IInteractable"/> 接口以接入通用交互系统。
|
||||
/// </summary>
|
||||
public class DeathShade : MonoBehaviour, IInteractable
|
||||
{
|
||||
[SerializeField] private IntEventChannelSO _onGeoRecovered;
|
||||
[SerializeField] private StringEventChannelSO _onShadeCollected;
|
||||
|
||||
private int _storedGeo;
|
||||
private string _sceneId;
|
||||
|
||||
// ── IInteractable ────────────────────────────────────────────────
|
||||
public bool CanInteract => true;
|
||||
public string InteractPrompt => "回收遗骸";
|
||||
|
||||
/// <summary>
|
||||
/// 由死亡系统调用:设定存储 Geo 数量、所在场景 ID,并移动到死亡坐标。
|
||||
/// </summary>
|
||||
public void Initialize(int geo, string sceneId, Vector2 position)
|
||||
{
|
||||
_storedGeo = geo;
|
||||
_sceneId = sceneId;
|
||||
transform.position = position;
|
||||
}
|
||||
|
||||
public void OnPlayerEnterRange(Transform player) { }
|
||||
|
||||
public void OnPlayerExitRange() { }
|
||||
|
||||
/// <summary>
|
||||
/// 玩家交互:广播 Geo 回收事件和场景标记,然后销毁自身。
|
||||
/// PlayerStats 订阅 _onGeoRecovered 事件并自行添加 Geo,保持零耦合。
|
||||
/// </summary>
|
||||
public void Interact(Transform player)
|
||||
{
|
||||
if (_storedGeo > 0)
|
||||
_onGeoRecovered?.Raise(_storedGeo);
|
||||
|
||||
_onShadeCollected?.Raise(_sceneId);
|
||||
Destroy(gameObject);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/World/DeathShade.cs.meta
Normal file
11
Assets/Scripts/World/DeathShade.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ca56784b1763aa6428c18b7b56e33c46
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
63
Assets/Scripts/World/DestructibleTile.cs
Normal file
63
Assets/Scripts/World/DestructibleTile.cs
Normal file
@@ -0,0 +1,63 @@
|
||||
using BaseGames.Combat;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.World
|
||||
{
|
||||
/// <summary>
|
||||
/// 可破坏物(墙壁/地块)。实现 IDamageable,受击后破坏并在 WorldStateRegistry 记录状态。
|
||||
/// </summary>
|
||||
public class DestructibleTile : MonoBehaviour, IDamageable
|
||||
{
|
||||
[SerializeField] private int _maxHP = 1;
|
||||
[SerializeField] private string _destructedId;
|
||||
/// <summary>
|
||||
/// ScriptableObject 注入(非静态 Instance)。
|
||||
/// 在 Inspector 中手动拖入场景级 WorldStateRegistry 资产。
|
||||
/// </summary>
|
||||
[SerializeField] private WorldStateRegistry _worldState;
|
||||
|
||||
private bool _isDestroyed;
|
||||
|
||||
// ── IDamageable ───────────────────────────────────────────────────────
|
||||
public bool IsInvincible => _isDestroyed;
|
||||
public int Defense => 0;
|
||||
|
||||
public void TakeDamage(DamageInfo info)
|
||||
{
|
||||
if (_isDestroyed) return;
|
||||
if (!CheckDestroyCondition(info)) return;
|
||||
|
||||
_isDestroyed = true;
|
||||
DestroyTile();
|
||||
}
|
||||
|
||||
// ── Virtual Hook ──────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 子类覆盖此方法添加额外破坏条件(如方向校验)。
|
||||
/// </summary>
|
||||
protected virtual bool CheckDestroyCondition(DamageInfo info) => true;
|
||||
|
||||
// ── Implementation ────────────────────────────────────────────────────
|
||||
|
||||
private void DestroyTile()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_destructedId))
|
||||
_worldState?.MarkDestroyed(_destructedId);
|
||||
|
||||
// 播放破坏 VFX、移除 Tilemap 格子等由子类扩展实现
|
||||
gameObject.SetActive(false);
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
// 读档恢复:若已被摧毁则直接隐藏
|
||||
if (!string.IsNullOrEmpty(_destructedId) && _worldState != null
|
||||
&& _worldState.IsDestroyed(_destructedId))
|
||||
{
|
||||
_isDestroyed = true;
|
||||
gameObject.SetActive(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/World/DestructibleTile.cs.meta
Normal file
11
Assets/Scripts/World/DestructibleTile.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1a1d610c8b247b640866fa908138df23
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
55
Assets/Scripts/World/DirectionalDestructible.cs
Normal file
55
Assets/Scripts/World/DirectionalDestructible.cs
Normal file
@@ -0,0 +1,55 @@
|
||||
using BaseGames.Combat;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.World
|
||||
{
|
||||
/// <summary>
|
||||
/// 单向可破坏物。在 DestructibleTile 基础上增加攻击方向校验。
|
||||
/// 使用 AttackSide.Any 等效于普通 DestructibleTile。
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
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()
|
||||
{
|
||||
Vector2 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
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/World/DirectionalDestructible.cs.meta
Normal file
11
Assets/Scripts/World/DirectionalDestructible.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bbd427384359cad42865af9fe164a68d
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
119
Assets/Scripts/World/DirectionalInteractable.cs
Normal file
119
Assets/Scripts/World/DirectionalInteractable.cs
Normal file
@@ -0,0 +1,119 @@
|
||||
using BaseGames.Core.Events;
|
||||
using MoreMountains.Feedbacks;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.World
|
||||
{
|
||||
/// <summary>
|
||||
/// 单向触发机关。支持玩家按键/玩家接触/攻击三种触发方式。
|
||||
/// 通过 VoidEventChannelSO 零耦合激活/停用目标(门/升降台/灯光等)。
|
||||
/// _isOneShot = true 时激活状态持久化到 WorldStateRegistry。
|
||||
/// </summary>
|
||||
[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;
|
||||
|
||||
[Header("事件频道")]
|
||||
[SerializeField] private VoidEventChannelSO _activationChannel;
|
||||
[SerializeField] private VoidEventChannelSO _deactivationChannel;
|
||||
|
||||
[Header("反馈")]
|
||||
[SerializeField] private MMF_Player _activateFeedback;
|
||||
|
||||
[Header("持久化")]
|
||||
[SerializeField] private WorldStateRegistry _worldState;
|
||||
|
||||
private bool _activated;
|
||||
|
||||
// ── IInteractable ─────────────────────────────────────────────────────
|
||||
public bool CanInteract => !(_isOneShot && _activated);
|
||||
public string InteractPrompt => _activated ? "已激活" : "交互";
|
||||
|
||||
public void Interact(Transform player)
|
||||
{
|
||||
if (_triggerCondition != TriggerCondition.InteractKey) return;
|
||||
if (!CheckSide(player.position)) return;
|
||||
TryActivate();
|
||||
}
|
||||
|
||||
public void OnPlayerEnterRange(Transform player) { }
|
||||
public void OnPlayerExitRange() { }
|
||||
|
||||
// ── Physics Triggers ──────────────────────────────────────────────────
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
// ── Damage-based Trigger(由 HurtBox/TileDamageReceiver 调用)────────
|
||||
|
||||
public void TryInteractFromDamage(BaseGames.Combat.DamageInfo info)
|
||||
{
|
||||
if (_triggerCondition != TriggerCondition.PlayerAttack) return;
|
||||
if (!CheckSide(info.SourcePosition)) return;
|
||||
TryActivate();
|
||||
}
|
||||
|
||||
// ── Core ──────────────────────────────────────────────────────────────
|
||||
|
||||
protected void TryActivate()
|
||||
{
|
||||
if (_isOneShot && _activated) return;
|
||||
|
||||
_activated = true;
|
||||
_activateFeedback?.PlayFeedbacks();
|
||||
_activationChannel?.Raise();
|
||||
|
||||
if (_isOneShot && !string.IsNullOrEmpty(_interactableId))
|
||||
_worldState?.SetFlag("mechanism_" + _interactableId);
|
||||
}
|
||||
|
||||
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)
|
||||
&& _worldState != null
|
||||
&& _worldState.HasFlag("mechanism_" + _interactableId))
|
||||
{
|
||||
_activated = true;
|
||||
_activationChannel?.Raise();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/World/DirectionalInteractable.cs.meta
Normal file
11
Assets/Scripts/World/DirectionalInteractable.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 51f66b7afcb92d94a8be9c55e1294d85
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
95
Assets/Scripts/World/FalseWall.cs
Normal file
95
Assets/Scripts/World/FalseWall.cs
Normal file
@@ -0,0 +1,95 @@
|
||||
using BaseGames.Combat;
|
||||
using MoreMountains.Feedbacks;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.World
|
||||
{
|
||||
/// <summary>
|
||||
/// 假墙(秘密通道)。外观与普通墙相同,可通过攻击/接近揭示并穿越。
|
||||
/// 揭示后禁用碰撞体(不销毁),状态持久化到 WorldSaveData。
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(Collider2D))]
|
||||
public class FalseWall : MonoBehaviour, IDamageable
|
||||
{
|
||||
public enum RevealCondition { Proximity, AttackOnce, AlwaysOpen }
|
||||
|
||||
[Header("识别")]
|
||||
[SerializeField] private string _wallId;
|
||||
|
||||
[Header("揭示条件")]
|
||||
[SerializeField] private RevealCondition _revealCondition = RevealCondition.AttackOnce;
|
||||
[SerializeField] private float _proximityRadius = 2.0f;
|
||||
|
||||
[Header("组件引用")]
|
||||
[SerializeField] private Collider2D _wallCollider;
|
||||
[SerializeField] private SpriteRenderer _renderer;
|
||||
[SerializeField] private MMF_Player _revealFeedback;
|
||||
|
||||
private bool _isRevealed;
|
||||
|
||||
// ── IDamageable ───────────────────────────────────────────────────────
|
||||
public bool IsInvincible => _isRevealed;
|
||||
public int Defense => 0;
|
||||
|
||||
public void TakeDamage(DamageInfo info)
|
||||
{
|
||||
if (_isRevealed || _revealCondition != RevealCondition.AttackOnce) return;
|
||||
Reveal();
|
||||
}
|
||||
|
||||
// ── Unity Lifecycle ───────────────────────────────────────────────────
|
||||
|
||||
private void Start()
|
||||
{
|
||||
if (_revealCondition == RevealCondition.AlwaysOpen)
|
||||
{
|
||||
SetPassThroughImmediate();
|
||||
return;
|
||||
}
|
||||
|
||||
// 读档恢复(SaveManager 集成后接入 WorldSaveData.RevealedFalseWalls)
|
||||
// 示例:bool revealed = ServiceLocator.GetOrDefault<SaveManager>()?.CurrentSave?.World?.RevealedFalseWalls?.Contains(_wallId) ?? false;
|
||||
// if (revealed) SetPassThroughImmediate();
|
||||
}
|
||||
|
||||
private void OnTriggerEnter2D(Collider2D other)
|
||||
{
|
||||
if (_isRevealed || _revealCondition != RevealCondition.Proximity) return;
|
||||
if (!other.CompareTag("Player")) return;
|
||||
// Proximity 模式:仅播放 Shimmer 暗示,碰撞仍启用
|
||||
_revealFeedback?.PlayFeedbacks();
|
||||
}
|
||||
|
||||
// ── Implementation ────────────────────────────────────────────────────
|
||||
|
||||
private void Reveal()
|
||||
{
|
||||
_isRevealed = true;
|
||||
_revealFeedback?.PlayFeedbacks();
|
||||
SetPassThroughImmediate();
|
||||
}
|
||||
|
||||
private void SetPassThroughImmediate()
|
||||
{
|
||||
if (_wallCollider != null)
|
||||
_wallCollider.enabled = false;
|
||||
// 切换 Sprite 到透明/揭示帧(由子类或动画处理)
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
private void OnDrawGizmosSelected()
|
||||
{
|
||||
Gizmos.color = new Color(0.6f, 0.2f, 1f, 0.8f);
|
||||
var col = GetComponent<Collider2D>();
|
||||
if (col != null)
|
||||
Gizmos.DrawWireCube(transform.position, col.bounds.size);
|
||||
|
||||
if (_revealCondition == RevealCondition.Proximity)
|
||||
{
|
||||
Gizmos.color = new Color(0.6f, 0.2f, 1f, 0.2f);
|
||||
Gizmos.DrawWireSphere(transform.position, _proximityRadius);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/World/FalseWall.cs.meta
Normal file
11
Assets/Scripts/World/FalseWall.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6c40925c7c82dfc4da17d02b1beca36c
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
42
Assets/Scripts/World/HazardZone.cs
Normal file
42
Assets/Scripts/World/HazardZone.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
using BaseGames.Player;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.World
|
||||
{
|
||||
/// <summary>
|
||||
/// 危险区域。玩家进入时触发即死或持续伤害。
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
private void OnDrawGizmos()
|
||||
{
|
||||
Gizmos.color = new Color(1f, 0f, 0f, 0.3f);
|
||||
var col = GetComponent<Collider2D>();
|
||||
if (col != null)
|
||||
Gizmos.DrawCube(transform.position, col.bounds.size);
|
||||
Gizmos.color = new Color(1f, 0f, 0f, 0.8f);
|
||||
if (col != null)
|
||||
Gizmos.DrawWireCube(transform.position, col.bounds.size);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/World/HazardZone.cs.meta
Normal file
11
Assets/Scripts/World/HazardZone.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7a6fc54c1f0cdab4eb82445806fa8335
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
81
Assets/Scripts/World/InteractableDetector.cs
Normal file
81
Assets/Scripts/World/InteractableDetector.cs
Normal file
@@ -0,0 +1,81 @@
|
||||
using BaseGames.Core.Events;
|
||||
using BaseGames.Input;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.World
|
||||
{
|
||||
/// <summary>
|
||||
/// 挂在 Player 上,检测附近可交互物,驱动 UI 提示显示/隐藏。
|
||||
/// 通过 InputReaderSO.InteractEvent 绑定交互输入。
|
||||
/// </summary>
|
||||
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()
|
||||
{
|
||||
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()
|
||||
{
|
||||
if (_nearest != null && _nearest.CanInteract)
|
||||
_nearest.Interact(transform);
|
||||
}
|
||||
|
||||
private IInteractable FindNearest(Collider2D[] hits)
|
||||
{
|
||||
IInteractable best = null;
|
||||
float bestDist = float.MaxValue;
|
||||
|
||||
foreach (var col in hits)
|
||||
{
|
||||
var interactable = col.GetComponentInParent<IInteractable>();
|
||||
if (interactable == null || !interactable.CanInteract) continue;
|
||||
|
||||
float dist = Vector2.Distance(transform.position, col.transform.position);
|
||||
if (dist < bestDist)
|
||||
{
|
||||
bestDist = dist;
|
||||
best = interactable;
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
private void OnDrawGizmosSelected()
|
||||
{
|
||||
Gizmos.color = Color.cyan;
|
||||
Gizmos.DrawWireSphere(transform.position, _detectRadius);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/World/InteractableDetector.cs.meta
Normal file
11
Assets/Scripts/World/InteractableDetector.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 50b55c5d6664b9f4b8fd1104f272bb8f
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
Assets/Scripts/World/Liquid.meta
Normal file
8
Assets/Scripts/World/Liquid.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3fe06102bf661d547a12bcc6aa10d23f
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
37
Assets/Scripts/World/Liquid/LiquidPhysicsConfigSO.cs
Normal file
37
Assets/Scripts/World/Liquid/LiquidPhysicsConfigSO.cs
Normal file
@@ -0,0 +1,37 @@
|
||||
// Assets/Scripts/World/Liquid/LiquidPhysicsConfigSO.cs
|
||||
using UnityEngine;
|
||||
using UnityEngine.Rendering;
|
||||
|
||||
namespace BaseGames.World.Liquid
|
||||
{
|
||||
[CreateAssetMenu(menuName = "World/LiquidPhysicsConfig")]
|
||||
public class LiquidPhysicsConfigSO : ScriptableObject
|
||||
{
|
||||
[Header("水下物理")]
|
||||
[Range(0f, 1f)]
|
||||
public float GravityScale = 0.3f; // 水下重力系数(越小越漂浮)
|
||||
[Range(0f, 1f)]
|
||||
public float BuoyancyForce = 0.5f; // 上浮力(每帧施加的向上力)
|
||||
public float MaxSwimSpeed = 4.0f; // 最大游泳速度 (m/s)
|
||||
public float SwimAcceleration = 8.0f; // 游泳加速度
|
||||
public float SurfaceExitSpeed = 5.0f; // 跃出水面时的冲量
|
||||
public float SinkSpeed = 2.0f; // 无游泳能力时自然下沉速度 (m/s)
|
||||
public float DiveSpeedMultiplier = 1.5f; // 主动下潜时的速度倍率
|
||||
|
||||
[Header("浅水/泥水速度缩放")]
|
||||
[Range(0.1f, 1.0f)]
|
||||
public float ShallowSpeedScale = 0.65f; // ShallowWater 类型水平移动速度倍率
|
||||
[Range(0.1f, 1.0f)]
|
||||
public float MudSpeedScale = 0.50f; // Mud 类型水平移动速度倍率
|
||||
|
||||
[Header("溺死计时(无游泳能力时)")]
|
||||
public float DrownTime = 3.0f; // 屏气倒计时(秒),倒计时结束则触发死亡
|
||||
|
||||
[Header("进出液体")]
|
||||
public float SplashEntryDelay = 0.05f; // 溅水特效延迟(配合动画)
|
||||
public float DragCoefficient = 3.0f; // 水下阻力系数(减缓水平移动)
|
||||
|
||||
[Header("视觉")]
|
||||
public VolumeProfile WaterVolumeProfile; // 水下后处理 Profile(可为 null)
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/World/Liquid/LiquidPhysicsConfigSO.cs.meta
Normal file
11
Assets/Scripts/World/Liquid/LiquidPhysicsConfigSO.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 79a8b38fe4021c44b8cb86900cbb8a5f
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
12
Assets/Scripts/World/Liquid/LiquidType.cs
Normal file
12
Assets/Scripts/World/Liquid/LiquidType.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
// Assets/Scripts/World/Liquid/LiquidType.cs
|
||||
namespace BaseGames.World.Liquid
|
||||
{
|
||||
public enum LiquidType
|
||||
{
|
||||
Water, // 可游泳(需 Swim 能力)
|
||||
ShallowWater, // 浅水(水中慢走,无需游泳能力,速度 ×0.65)
|
||||
Mud, // 泥水(移动极慢,无需游泳能力,速度 ×0.50)
|
||||
Acid, // 接触即死(HazardZone 处理)
|
||||
Lava, // 接触即死(HazardZone 处理)
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/World/Liquid/LiquidType.cs.meta
Normal file
11
Assets/Scripts/World/Liquid/LiquidType.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 87548f201d0ce4c4d9e94f38f429b689
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
54
Assets/Scripts/World/Liquid/LiquidZone.cs
Normal file
54
Assets/Scripts/World/Liquid/LiquidZone.cs
Normal file
@@ -0,0 +1,54 @@
|
||||
// Assets/Scripts/World/Liquid/LiquidZone.cs
|
||||
using BaseGames.Core.Events;
|
||||
using MoreMountains.Feedbacks;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.World.Liquid
|
||||
{
|
||||
/// <summary>
|
||||
/// 挂在液态区域根 GameObject 上。
|
||||
/// 酸液/熔岩时需同时挂载 HazardZone(InstantKill 类型)。
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(Collider2D))]
|
||||
public class LiquidZone : MonoBehaviour
|
||||
{
|
||||
[Header("液体类型")]
|
||||
[SerializeField] private LiquidType _liquidType = LiquidType.Water;
|
||||
|
||||
[Header("区域标识(存档/跨系统识别)")]
|
||||
[SerializeField] private string _zoneId;
|
||||
|
||||
[Header("伤害(Water 类型专用;Acid/Lava 由子节点 HazardZone 处理)")]
|
||||
[SerializeField] private bool _dealsDrowningDamage = false;
|
||||
[SerializeField] private float _drowningDamagePerSecond = 5f;
|
||||
|
||||
[Header("物理配置")]
|
||||
[SerializeField] private LiquidPhysicsConfigSO _physicsConfig;
|
||||
|
||||
[Header("Event Channels")]
|
||||
[SerializeField] private LiquidEventChannelSO _onPlayerEntered;
|
||||
[SerializeField] private LiquidEventChannelSO _onPlayerExited;
|
||||
|
||||
[Header("Feedback")]
|
||||
[SerializeField] private MMFeedbacks _splashEnterFeedback;
|
||||
[SerializeField] private MMFeedbacks _splashExitFeedback;
|
||||
|
||||
public LiquidType Type => _liquidType;
|
||||
public LiquidPhysicsConfigSO Physics => _physicsConfig;
|
||||
public string ZoneId => _zoneId;
|
||||
|
||||
private void OnTriggerEnter2D(Collider2D other)
|
||||
{
|
||||
if (!other.CompareTag("Player")) return;
|
||||
_splashEnterFeedback?.PlayFeedbacks();
|
||||
_onPlayerEntered?.Raise(new LiquidEvent(_zoneId, _liquidType.ToString()));
|
||||
}
|
||||
|
||||
private void OnTriggerExit2D(Collider2D other)
|
||||
{
|
||||
if (!other.CompareTag("Player")) return;
|
||||
_splashExitFeedback?.PlayFeedbacks();
|
||||
_onPlayerExited?.Raise(new LiquidEvent(_zoneId, _liquidType.ToString()));
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/World/Liquid/LiquidZone.cs.meta
Normal file
11
Assets/Scripts/World/Liquid/LiquidZone.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e7a2e8fb64e1e3a4fb0e2cd6f5c3ccc1
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,56 @@
|
||||
// Assets/Scripts/World/Liquid/UnderwaterPostProcessingController.cs
|
||||
using System.Collections;
|
||||
using BaseGames.Core.Events;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Rendering;
|
||||
|
||||
namespace BaseGames.World.Liquid
|
||||
{
|
||||
public class UnderwaterPostProcessingController : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private Volume _underwaterVolume; // 水下专属 Volume(WeightMode)
|
||||
[SerializeField] private float _blendInDuration = 0.3f;
|
||||
[SerializeField] private float _blendOutDuration = 0.3f;
|
||||
[SerializeField] private LiquidEventChannelSO _onLiquidEntered; // EVT_LiquidEntered
|
||||
[SerializeField] private LiquidEventChannelSO _onLiquidExited; // EVT_LiquidExited
|
||||
|
||||
private Coroutine _blendCoroutine;
|
||||
private readonly CompositeDisposable _subs = new();
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
_onLiquidEntered?.Subscribe(OnLiquidEntered).AddTo(_subs);
|
||||
_onLiquidExited?.Subscribe(OnLiquidExited).AddTo(_subs);
|
||||
}
|
||||
|
||||
private void OnDisable() => _subs.Clear();
|
||||
|
||||
private void OnLiquidEntered(LiquidEvent evt)
|
||||
{
|
||||
if (evt.LiquidType != nameof(LiquidType.Water)) return;
|
||||
BlendVolume(1f, _blendInDuration);
|
||||
}
|
||||
|
||||
private void OnLiquidExited(LiquidEvent evt) => BlendVolume(0f, _blendOutDuration);
|
||||
|
||||
private void BlendVolume(float target, float duration)
|
||||
{
|
||||
if (_blendCoroutine != null) StopCoroutine(_blendCoroutine);
|
||||
_blendCoroutine = StartCoroutine(BlendRoutine(target, duration));
|
||||
}
|
||||
|
||||
private IEnumerator BlendRoutine(float target, float duration)
|
||||
{
|
||||
if (_underwaterVolume == null) yield break;
|
||||
float start = _underwaterVolume.weight;
|
||||
float elapsed = 0f;
|
||||
while (elapsed < duration)
|
||||
{
|
||||
elapsed += Time.deltaTime;
|
||||
_underwaterVolume.weight = Mathf.Lerp(start, target, elapsed / duration);
|
||||
yield return null;
|
||||
}
|
||||
_underwaterVolume.weight = target;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6d973b7199e1c854685e66fab26091bc
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
69
Assets/Scripts/World/Liquid/WaterDangerState.cs
Normal file
69
Assets/Scripts/World/Liquid/WaterDangerState.cs
Normal file
@@ -0,0 +1,69 @@
|
||||
// Assets/Scripts/World/Liquid/WaterDangerState.cs
|
||||
|
||||
using BaseGames.Core.Events;
|
||||
using BaseGames.Player;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.World.Liquid
|
||||
{
|
||||
/// <summary>
|
||||
/// 当玩家进入 Water 类型液体且未解锁游泳能力时,触发溺水倒计时。
|
||||
/// 订阅 EVT_LiquidEntered / EVT_LiquidExited 事件频道。
|
||||
/// </summary>
|
||||
public class WaterDangerState : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private LiquidPhysicsConfigSO _config;
|
||||
[SerializeField] private PlayerStats _playerStats; // 检查 Swim 能力
|
||||
[SerializeField] private LiquidEventChannelSO _onLiquidEntered; // EVT_LiquidEntered
|
||||
[SerializeField] private LiquidEventChannelSO _onLiquidExited; // EVT_LiquidExited
|
||||
[SerializeField] private FloatEventChannelSO _onDrownProgress; // 0~1 倒计时进度(HUD 用)
|
||||
[SerializeField] private VoidEventChannelSO _onPlayerDrowned; // 触发死亡
|
||||
|
||||
private float _drownTimer;
|
||||
private bool _isActive;
|
||||
private readonly CompositeDisposable _subs = new();
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
Debug.Assert(_config != null, "[WaterDangerState] _config 未赋值,请在 Inspector 中指定 LiquidPhysicsConfigSO。", this);
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
_onLiquidEntered?.Subscribe(OnEnterLiquid).AddTo(_subs);
|
||||
_onLiquidExited?.Subscribe(OnExitLiquid).AddTo(_subs);
|
||||
}
|
||||
|
||||
private void OnDisable() => _subs.Clear();
|
||||
|
||||
public void OnEnterLiquid(LiquidEvent evt)
|
||||
{
|
||||
if (evt.LiquidType != nameof(LiquidType.Water)) return;
|
||||
if (_playerStats != null && _playerStats.HasAbility(AbilityType.Swim)) return;
|
||||
_isActive = true;
|
||||
_drownTimer = _config.DrownTime;
|
||||
}
|
||||
|
||||
public void OnExitLiquid(LiquidEvent evt)
|
||||
{
|
||||
_isActive = false;
|
||||
_drownTimer = _config.DrownTime;
|
||||
_onDrownProgress?.Raise(0f);
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (!_isActive) return;
|
||||
_drownTimer -= Time.deltaTime;
|
||||
|
||||
float drownTime = _config.DrownTime;
|
||||
_onDrownProgress?.Raise(1f - (_drownTimer / drownTime));
|
||||
|
||||
if (_drownTimer <= 0f)
|
||||
{
|
||||
_isActive = false;
|
||||
_onPlayerDrowned?.Raise();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/World/Liquid/WaterDangerState.cs.meta
Normal file
11
Assets/Scripts/World/Liquid/WaterDangerState.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8dc9fa2201584e04e93b12f7dc7bc6e6
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
26
Assets/Scripts/World/MagicWall.cs
Normal file
26
Assets/Scripts/World/MagicWall.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.World
|
||||
{
|
||||
/// <summary>
|
||||
/// 魔法墙标记组件。仅用于 Gizmo 可视化,穿越逻辑通过
|
||||
/// Physics Layer Matrix 实现(Ghost 层 vs MagicWall 层 = IgnoreCollision)。
|
||||
/// SkillManager 在太虚斩激活/结束时切换玩家 Layer(Ghost ↔ Player)。
|
||||
/// </summary>
|
||||
[ExecuteAlways]
|
||||
public class MagicWall : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private Color _normalColor = new Color(0.4f, 0.2f, 1f, 0.8f);
|
||||
[SerializeField] private Color _ghostColor = new Color(0.4f, 0.2f, 1f, 0.15f);
|
||||
|
||||
#if UNITY_EDITOR
|
||||
private void OnDrawGizmos()
|
||||
{
|
||||
Gizmos.color = _normalColor;
|
||||
var col = GetComponent<Collider2D>();
|
||||
if (col != null)
|
||||
Gizmos.DrawWireCube(transform.position, col.bounds.size);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/World/MagicWall.cs.meta
Normal file
11
Assets/Scripts/World/MagicWall.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a1fc25502241cf940841e0a242b97ee2
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -9,7 +9,9 @@
|
||||
"rootNamespace": "BaseGames.World.Map",
|
||||
"references": [
|
||||
"BaseGames.World",
|
||||
"BaseGames.Core.Save"
|
||||
"BaseGames.Core",
|
||||
"BaseGames.Core.Save",
|
||||
"BaseGames.Core.Events"
|
||||
],
|
||||
"autoReferenced": true,
|
||||
"overrideReferences": false,
|
||||
|
||||
14
Assets/Scripts/World/Map/IMapService.cs
Normal file
14
Assets/Scripts/World/Map/IMapService.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
// Assets/Scripts/World/Map/IMapService.cs
|
||||
// 地图服务接口,通过 ServiceLocator 注册与查询。
|
||||
// MapManager 实现此接口;MapPanel 等调用方通过接口解耦。
|
||||
|
||||
namespace BaseGames.World.Map
|
||||
{
|
||||
public interface IMapService
|
||||
{
|
||||
bool IsExplored(string roomId);
|
||||
bool IsMapped(string roomId);
|
||||
void SetMapped(string roomId);
|
||||
MapDatabaseSO Database { get; }
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/World/Map/IMapService.cs.meta
Normal file
11
Assets/Scripts/World/Map/IMapService.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ce82de7829d7e0141b6811c5f70373b0
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
96
Assets/Scripts/World/Map/MapManager.cs
Normal file
96
Assets/Scripts/World/Map/MapManager.cs
Normal file
@@ -0,0 +1,96 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using UnityEngine;
|
||||
using BaseGames.Core;
|
||||
using BaseGames.Core.Events;
|
||||
using BaseGames.Core.Save;
|
||||
|
||||
namespace BaseGames.World.Map
|
||||
{
|
||||
/// <summary>
|
||||
/// 运行时地图管理器(架构 15_MapShopModule §1.2)。
|
||||
/// 挂在 Persistent 场景 [GameManagers] 下,通过事件驱动记录已探索/已完整地图的房间。
|
||||
/// 实现 ISaveable 持久化探索进度。
|
||||
/// </summary>
|
||||
[DefaultExecutionOrder(-700)]
|
||||
public class MapManager : MonoBehaviour, ISaveable, IMapService
|
||||
{
|
||||
[SerializeField] private MapDatabaseSO _database;
|
||||
|
||||
[Header("Event Channels")]
|
||||
[SerializeField] private StringEventChannelSO _onRoomEntered; // 订阅 EVT_RoomEntered
|
||||
[SerializeField] private StringEventChannelSO _onMapUpdated; // 发布:房间发现时
|
||||
|
||||
// 三级可见性:
|
||||
// Unknown → 未进入过(默认)
|
||||
// Explored → 进入过(显示轮廓/格子)
|
||||
// Mapped → 完整地图信息(购买 MapFragment 或存档点揭示)
|
||||
private HashSet<string> _exploredRooms = new();
|
||||
private HashSet<string> _mappedRooms = new();
|
||||
private readonly CompositeDisposable _subs = new();
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (ServiceLocator.GetOrDefault<IMapService>() != null) { Destroy(gameObject); return; }
|
||||
ServiceLocator.Register<IMapService>(this);
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
_onRoomEntered?.Subscribe(OnRoomEntered).AddTo(_subs);
|
||||
ServiceLocator.GetOrDefault<ISaveableRegistry>()?.Register(this);
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
_subs.Clear();
|
||||
ServiceLocator.GetOrDefault<ISaveableRegistry>()?.Unregister(this);
|
||||
}
|
||||
|
||||
// ── ISaveable ─────────────────────────────────────────────────────────
|
||||
|
||||
public void OnSave(SaveData data)
|
||||
{
|
||||
data.Map.ExploredRooms ??= new List<string>();
|
||||
data.Map.ExploredRooms.Clear();
|
||||
data.Map.ExploredRooms.AddRange(_exploredRooms);
|
||||
data.Map.MappedRooms ??= new List<string>();
|
||||
data.Map.MappedRooms.Clear();
|
||||
data.Map.MappedRooms.AddRange(_mappedRooms);
|
||||
}
|
||||
|
||||
public void OnLoad(SaveData data)
|
||||
{
|
||||
_exploredRooms = new HashSet<string>(data.Map.ExploredRooms ?? new System.Collections.Generic.List<string>());
|
||||
_mappedRooms = new HashSet<string>(data.Map.MappedRooms ?? new System.Collections.Generic.List<string>());
|
||||
}
|
||||
|
||||
// ── 事件驱动房间发现 ──────────────────────────────────────────────────
|
||||
|
||||
private void OnRoomEntered(string roomId)
|
||||
{
|
||||
bool changed = _exploredRooms.Add(roomId);
|
||||
if (changed) _onMapUpdated?.Raise(roomId);
|
||||
}
|
||||
|
||||
/// <summary>标记为已完整获取地图信息(购买 MapFragment SO 触发)。</summary>
|
||||
public void SetMapped(string roomId)
|
||||
{
|
||||
_exploredRooms.Add(roomId);
|
||||
if (_mappedRooms.Add(roomId))
|
||||
_onMapUpdated?.Raise(roomId);
|
||||
}
|
||||
|
||||
// ── 查询 API ──────────────────────────────────────────────────────────
|
||||
|
||||
public bool IsExplored(string roomId) => _exploredRooms.Contains(roomId);
|
||||
public bool IsMapped(string roomId) => _mappedRooms.Contains(roomId);
|
||||
|
||||
public MapDatabaseSO Database => _database;
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
ServiceLocator.Unregister<IMapService>(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/World/Map/MapManager.cs.meta
Normal file
11
Assets/Scripts/World/Map/MapManager.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6a752b1e0e60f8e41a3b5f7483894d5a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
123
Assets/Scripts/World/Map/MapPanel.cs
Normal file
123
Assets/Scripts/World/Map/MapPanel.cs
Normal file
@@ -0,0 +1,123 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using BaseGames.Core;
|
||||
using BaseGames.Core.Events;
|
||||
|
||||
namespace BaseGames.World.Map
|
||||
{
|
||||
/// <summary>
|
||||
/// 全屏地图 UI 面板(架构 15_MapShopModule §1.3)。
|
||||
/// 由 UIManager PanelStack 管理开关;OnEnable 时重建格子并订阅更新事件。
|
||||
/// </summary>
|
||||
public class MapPanel : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private MapDatabaseSO _database;
|
||||
[SerializeField] private RectTransform _roomContainer; // 格子图放置根节点
|
||||
[SerializeField] private MapRoomCellUI _cellPrefab; // 地图格子预制
|
||||
|
||||
[Header("图标 Sprites")]
|
||||
[SerializeField] private Sprite _iconSavePoint;
|
||||
[SerializeField] private Sprite _iconBossRoom;
|
||||
[SerializeField] private Sprite _iconShop;
|
||||
[SerializeField] private Sprite _iconPlayerPos;
|
||||
|
||||
[Header("颜色")]
|
||||
[SerializeField] private Color _colorDiscovered = Color.white;
|
||||
[SerializeField] private Color _colorUndiscovered = Color.black;
|
||||
|
||||
[Header("Event Channels")]
|
||||
[SerializeField] private StringEventChannelSO _onMapUpdated; // 房间发现时刷新
|
||||
|
||||
private Dictionary<string, MapRoomCellUI> _cells = new();
|
||||
private readonly CompositeDisposable _subs = new();
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
BuildGrid();
|
||||
_onMapUpdated?.Subscribe(OnMapUpdated).AddTo(_subs);
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
_subs.Clear();
|
||||
|
||||
// 清理动态生成的格子
|
||||
foreach (var cell in _cells.Values)
|
||||
if (cell != null) Destroy(cell.gameObject);
|
||||
_cells.Clear();
|
||||
}
|
||||
|
||||
// ── 内部 ──────────────────────────────────────────────────────────────
|
||||
|
||||
private void BuildGrid()
|
||||
{
|
||||
if (_database == null || _database.AllRooms == null) return;
|
||||
|
||||
var mapManager = ServiceLocator.GetOrDefault<IMapService>();
|
||||
foreach (var room in _database.AllRooms)
|
||||
{
|
||||
if (room == null) continue;
|
||||
var cell = Instantiate(_cellPrefab, _roomContainer);
|
||||
bool discovered = mapManager != null && mapManager.IsExplored(room.RoomId);
|
||||
cell.Setup(room, discovered, ChooseIcon(room));
|
||||
_cells[room.RoomId] = cell;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnMapUpdated(string roomId)
|
||||
{
|
||||
if (_cells.TryGetValue(roomId, out var cell))
|
||||
cell.SetDiscovered(true);
|
||||
}
|
||||
|
||||
private Sprite ChooseIcon(MapRoomDataSO room)
|
||||
{
|
||||
if (room.MapIconOverride != null) return room.MapIconOverride;
|
||||
if (room.IsSavePoint) return _iconSavePoint;
|
||||
if (room.IsBossRoom) return _iconBossRoom;
|
||||
if (room.IsShop) return _iconShop;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 单个地图格子 UI ─────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>地图面板中每个房间对应的格子 UI 组件。</summary>
|
||||
public class MapRoomCellUI : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private Image _bg;
|
||||
[SerializeField] private Image _icon;
|
||||
|
||||
private static readonly Color Discovered = Color.white;
|
||||
private static readonly Color Undiscovered = Color.black;
|
||||
|
||||
/// <summary>初始化格子(位置、颜色、图标)。</summary>
|
||||
public void Setup(MapRoomDataSO room, bool discovered, Sprite icon)
|
||||
{
|
||||
// 根据 GridPosition/GridSize 设置 RectTransform 位置与大小
|
||||
if (TryGetComponent<RectTransform>(out var rt))
|
||||
{
|
||||
rt.anchoredPosition = new Vector2(
|
||||
room.GridPosition.x * 32f,
|
||||
room.GridPosition.y * 32f);
|
||||
rt.sizeDelta = new Vector2(
|
||||
room.GridSize.x * 32f,
|
||||
room.GridSize.y * 32f);
|
||||
}
|
||||
|
||||
SetDiscovered(discovered);
|
||||
|
||||
if (_icon != null)
|
||||
{
|
||||
_icon.sprite = icon;
|
||||
_icon.enabled = icon != null;
|
||||
}
|
||||
}
|
||||
|
||||
public void SetDiscovered(bool v)
|
||||
{
|
||||
if (_bg != null) _bg.color = v ? Discovered : Undiscovered;
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/World/Map/MapPanel.cs.meta
Normal file
11
Assets/Scripts/World/Map/MapPanel.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c9677e71b6b3d4d43aa3680aa8990a83
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
52
Assets/Scripts/World/Map/MapPin.cs
Normal file
52
Assets/Scripts/World/Map/MapPin.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using BaseGames.Core;
|
||||
using BaseGames.Core.Save;
|
||||
|
||||
namespace BaseGames.World.Map
|
||||
{
|
||||
/// <summary>
|
||||
/// 地图自定义标记管理器(架构 15_MapShopModule §1.5)。
|
||||
/// 实现 ISaveable,通过 SaveManager 持久化玩家地图标记。
|
||||
/// MapPin/PinType 数据类定义在 SaveData.cs(BaseGames.Core.Save)中,避免循环依赖。
|
||||
/// </summary>
|
||||
public class MapPinManager : MonoBehaviour, ISaveable
|
||||
{
|
||||
private List<MapPin> _pins = new();
|
||||
|
||||
public IReadOnlyList<MapPin> Pins => _pins;
|
||||
|
||||
private void OnEnable() => ServiceLocator.GetOrDefault<ISaveableRegistry>()?.Register(this);
|
||||
private void OnDisable() => ServiceLocator.GetOrDefault<ISaveableRegistry>()?.Unregister(this);
|
||||
|
||||
// ── 公共 API ──────────────────────────────────────────────────────────
|
||||
|
||||
public void AddPin(MapPin pin)
|
||||
{
|
||||
if (pin != null) _pins.Add(pin);
|
||||
}
|
||||
|
||||
public void RemovePin(MapPin pin) => _pins.Remove(pin);
|
||||
|
||||
/// <summary>便捷方法:用枚举类型创建并添加标记。</summary>
|
||||
public MapPin CreatePin(string roomId, float normX, float normY,
|
||||
PinType type = PinType.Marker, string note = "")
|
||||
{
|
||||
var pin = new MapPin
|
||||
{
|
||||
RoomId = roomId,
|
||||
NormalizedPosX = normX,
|
||||
NormalizedPosY = normY,
|
||||
PinTypeInt = (int)type,
|
||||
Note = note,
|
||||
};
|
||||
AddPin(pin);
|
||||
return pin;
|
||||
}
|
||||
|
||||
// ── ISaveable ─────────────────────────────────────────────────────────
|
||||
|
||||
public void OnSave(SaveData data) => data.Map.Pins = _pins;
|
||||
public void OnLoad(SaveData data) => _pins = data.Map.Pins ?? new List<MapPin>();
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/World/Map/MapPin.cs.meta
Normal file
11
Assets/Scripts/World/Map/MapPin.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0d5b16698a16d38428ae6c836d5c4536
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
51
Assets/Scripts/World/Map/MapPlayerTracker.cs
Normal file
51
Assets/Scripts/World/Map/MapPlayerTracker.cs
Normal file
@@ -0,0 +1,51 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.World.Map
|
||||
{
|
||||
/// <summary>
|
||||
/// 将玩家世界坐标转换为地图格子坐标,供 MapPanel 显示玩家位置图标(架构 15_MapShopModule §1.4)。
|
||||
/// 挂在 Player GameObject 上(LateUpdate 每帧计算)。
|
||||
/// </summary>
|
||||
public class MapPlayerTracker : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private Transform _playerTransform;
|
||||
[SerializeField] private MapDatabaseSO _database;
|
||||
|
||||
[Header("世界坐标 → 格子坐标换算参数")]
|
||||
[SerializeField] private float _worldUnitsPerCell = 18f; // 1 格 = N 世界单位
|
||||
|
||||
/// <summary>玩家当前所在房间 ID(用于地图高亮当前房间)。</summary>
|
||||
public string CurrentRoomId { get; private set; }
|
||||
|
||||
/// <summary>玩家在当前格子房间内的归一化坐标(0~1)。</summary>
|
||||
public Vector2 NormalizedPositionInRoom { get; private set; }
|
||||
|
||||
private void LateUpdate()
|
||||
{
|
||||
if (_playerTransform == null || _database?.AllRooms == null) return;
|
||||
|
||||
Vector2 worldPos = _playerTransform.position;
|
||||
Vector2Int cellPos = WorldToCell(worldPos);
|
||||
|
||||
foreach (var room in _database.AllRooms)
|
||||
{
|
||||
if (room == null) continue;
|
||||
var rect = new RectInt(room.GridPosition, room.GridSize);
|
||||
if (rect.Contains(cellPos))
|
||||
{
|
||||
CurrentRoomId = room.RoomId;
|
||||
Vector2 inRoom = (Vector2)(cellPos - room.GridPosition);
|
||||
NormalizedPositionInRoom = new Vector2(
|
||||
inRoom.x / Mathf.Max(1, room.GridSize.x),
|
||||
inRoom.y / Mathf.Max(1, room.GridSize.y));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Vector2Int WorldToCell(Vector2 worldPos)
|
||||
=> new(
|
||||
Mathf.FloorToInt(worldPos.x / _worldUnitsPerCell),
|
||||
Mathf.FloorToInt(worldPos.y / _worldUnitsPerCell));
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/World/Map/MapPlayerTracker.cs.meta
Normal file
11
Assets/Scripts/World/Map/MapPlayerTracker.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3f3715a3378c2004a89bc0ee56ca25c6
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
72
Assets/Scripts/World/Map/MapRoomDataSO.cs
Normal file
72
Assets/Scripts/World/Map/MapRoomDataSO.cs
Normal file
@@ -0,0 +1,72 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.World.Map
|
||||
{
|
||||
/// <summary>
|
||||
/// 单个房间的地图数据 SO(架构 15_MapShopModule §1.1)。
|
||||
/// 资产路径: Assets/ScriptableObjects/Map/Room_{RoomId}.asset
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "World/Map/RoomData")]
|
||||
public class MapRoomDataSO : ScriptableObject
|
||||
{
|
||||
[Header("基础信息")]
|
||||
public string RoomId; // 与场景名一致,如 "Room_Forest_01"
|
||||
public string RegionId; // 所属区域,如 "Forest"
|
||||
public string DisplayName; // 可选,地图 Tooltip
|
||||
|
||||
[Header("地图布局(格子坐标,单位:格)")]
|
||||
public Vector2Int GridPosition; // 左下角坐标
|
||||
public Vector2Int GridSize; // 宽×高(格)
|
||||
|
||||
[Header("房间轮廓纹理")]
|
||||
public Texture2D RoomOutlineTex; // 用于地图 UI 显示房间形状(可空,回退到矩形格子)
|
||||
|
||||
[Header("出口信息")]
|
||||
public RoomExitData[] Exits; // 该房间所有出口定义
|
||||
|
||||
[Header("特殊标记")]
|
||||
public bool IsBossRoom;
|
||||
public bool IsSavePoint;
|
||||
public bool IsShop;
|
||||
public Sprite MapIconOverride; // null = 按 isXxx 自动选择图标
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public struct RoomExitData
|
||||
{
|
||||
public string TargetRoomId; // 连接的目标房间 ID
|
||||
public Vector2Int ExitGridPos; // 出口在格子地图上的位置
|
||||
public ExitDirection Direction; // 出口方向
|
||||
}
|
||||
|
||||
public enum ExitDirection { Up, Down, Left, Right }
|
||||
|
||||
// ─── 全局地图数据库 ──────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 全局地图数据库 SO(编辑器配置一次;架构 15_MapShopModule §1.1)。
|
||||
/// 资产路径: Assets/ScriptableObjects/Map/MapDatabase.asset
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "World/Map/MapDatabase")]
|
||||
public class MapDatabaseSO : ScriptableObject
|
||||
{
|
||||
public MapRoomDataSO[] AllRooms;
|
||||
|
||||
private Dictionary<string, MapRoomDataSO> _index;
|
||||
|
||||
/// <summary>运行时快速查找(首次调用时建立索引)。</summary>
|
||||
public MapRoomDataSO GetRoom(string roomId)
|
||||
{
|
||||
if (_index == null)
|
||||
_index = AllRooms.Where(r => r != null)
|
||||
.ToDictionary(r => r.RoomId);
|
||||
_index.TryGetValue(roomId, out var r);
|
||||
return r;
|
||||
}
|
||||
|
||||
private void OnDisable() => _index = null; // SO 卸载时清理缓存
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/World/Map/MapRoomDataSO.cs.meta
Normal file
11
Assets/Scripts/World/Map/MapRoomDataSO.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 00cffb59dd3827e41acf0e7697861b11
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
144
Assets/Scripts/World/MovingPlatform.cs
Normal file
144
Assets/Scripts/World/MovingPlatform.cs
Normal file
@@ -0,0 +1,144 @@
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using BaseGames.Core.Events;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.World
|
||||
{
|
||||
/// <summary>
|
||||
/// 移动平台。Kinematic Rigidbody2D,支持三种移动模式。
|
||||
/// 乘客跟随:OnTriggerEnter2D 时 SetParent,离开时还原并附加速度。
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(Rigidbody2D))]
|
||||
public class MovingPlatform : MonoBehaviour
|
||||
{
|
||||
public enum MoveType { LinearAB, WayPoints, TriggeredLinear }
|
||||
|
||||
[Header("移动配置")]
|
||||
[SerializeField] private MoveType _moveType = MoveType.LinearAB;
|
||||
[SerializeField] private Transform[] _wayPoints;
|
||||
[SerializeField] private float _speed = 3f;
|
||||
[SerializeField] private float _waitAtEndpoint = 0.5f;
|
||||
|
||||
[Header("TriggeredLinear 模式")]
|
||||
[SerializeField] private VoidEventChannelSO _activationChannel;
|
||||
|
||||
[Header("乘客检测")]
|
||||
[SerializeField] private BoxCollider2D _passengerSensor; // IsTrigger,用于乘客 SetParent
|
||||
|
||||
private Rigidbody2D _rb;
|
||||
private List<Transform> _passengers = new();
|
||||
private int _waypointIndex;
|
||||
private bool _movingForward = true;
|
||||
private bool _triggered;
|
||||
private bool _waiting;
|
||||
private readonly CompositeDisposable _subs = new();
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
_rb = GetComponent<Rigidbody2D>();
|
||||
_rb.bodyType = RigidbodyType2D.Kinematic;
|
||||
_rb.interpolation = RigidbodyInterpolation2D.Interpolate;
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
_activationChannel?.Subscribe(OnTriggered).AddTo(_subs);
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
_subs.Clear();
|
||||
}
|
||||
|
||||
private void FixedUpdate()
|
||||
{
|
||||
if (_wayPoints == null || _wayPoints.Length == 0) return;
|
||||
if (_moveType == MoveType.TriggeredLinear && !_triggered) return;
|
||||
if (_waiting) return;
|
||||
|
||||
MoveTowardsNextWaypoint();
|
||||
}
|
||||
|
||||
private void MoveTowardsNextWaypoint()
|
||||
{
|
||||
var target = (Vector2)_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()
|
||||
{
|
||||
_waiting = true;
|
||||
yield return new WaitForSeconds(_waitAtEndpoint);
|
||||
AdvanceWaypoint();
|
||||
_waiting = false;
|
||||
}
|
||||
|
||||
private void AdvanceWaypoint()
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnTriggered() => _triggered = true;
|
||||
|
||||
// ── Passenger Pattern ─────────────────────────────────────────────────
|
||||
|
||||
private void OnTriggerEnter2D(Collider2D other)
|
||||
{
|
||||
if (!IsPassenger(other.gameObject)) 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);
|
||||
|
||||
// 离开时附加平台速度,避免卡顿
|
||||
var passengerRb = other.GetComponentInParent<Rigidbody2D>();
|
||||
if (passengerRb != null)
|
||||
passengerRb.AddForce(_rb.velocity, ForceMode2D.Impulse);
|
||||
}
|
||||
|
||||
private static bool IsPassenger(GameObject go)
|
||||
{
|
||||
return go.CompareTag("Player") || go.CompareTag("Enemy");
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
private void OnDrawGizmos()
|
||||
{
|
||||
if (_wayPoints == null || _wayPoints.Length < 2) return;
|
||||
Gizmos.color = new Color(1f, 0.8f, 0f, 0.8f);
|
||||
for (int i = 0; i < _wayPoints.Length - 1; i++)
|
||||
{
|
||||
if (_wayPoints[i] != null && _wayPoints[i + 1] != null)
|
||||
Gizmos.DrawLine(_wayPoints[i].position, _wayPoints[i + 1].position);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/World/MovingPlatform.cs.meta
Normal file
11
Assets/Scripts/World/MovingPlatform.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ae997ac2469ff6b4cb58cf825ed67397
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
19
Assets/Scripts/World/PhantomInteractable.cs
Normal file
19
Assets/Scripts/World/PhantomInteractable.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.World
|
||||
{
|
||||
/// <summary>
|
||||
/// 幻影可交互机关。继承 DirectionalInteractable,额外响应 PhantomBody 层(太虚斩形态)。
|
||||
/// </summary>
|
||||
public class PhantomInteractable : DirectionalInteractable
|
||||
{
|
||||
private void OnTriggerEnter2D(Collider2D other)
|
||||
{
|
||||
bool isPlayer = other.CompareTag("Player");
|
||||
bool isPhantom = other.gameObject.layer == LayerMask.NameToLayer("PhantomBody");
|
||||
|
||||
if (!isPlayer && !isPhantom) return;
|
||||
TryActivate();
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/World/PhantomInteractable.cs.meta
Normal file
11
Assets/Scripts/World/PhantomInteractable.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a5ec3c5dc4fe87c4c99447587538f32d
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
74
Assets/Scripts/World/PhantomPlate.cs
Normal file
74
Assets/Scripts/World/PhantomPlate.cs
Normal file
@@ -0,0 +1,74 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.World
|
||||
{
|
||||
/// <summary>
|
||||
/// 幻象板(Phantom Plate):玩家可从下方穿过,从上方站立的单向平台。
|
||||
/// 使用 PlatformEffector2D + Collider2D 实现;支持下蹲跌落(按下 + 跳跃)。
|
||||
///
|
||||
/// 挂载要求:同一 GameObject 上需有 Collider2D 和 PlatformEffector2D。
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(Collider2D))]
|
||||
[RequireComponent(typeof(PlatformEffector2D))]
|
||||
public class PhantomPlate : MonoBehaviour
|
||||
{
|
||||
[Header("下蹲跌落")]
|
||||
[Tooltip("按住下方向 + 跳跃时临时禁用碰撞器,允许玩家向下穿过平台")]
|
||||
[SerializeField] private bool _allowDropThrough = true;
|
||||
|
||||
[Tooltip("禁用碰撞器的持续时间(秒)")]
|
||||
[SerializeField] private float _dropDisableDuration = 0.3f;
|
||||
|
||||
private Collider2D _col;
|
||||
private PlatformEffector2D _effector;
|
||||
private float _reEnableTimer;
|
||||
private bool _isDisabled;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
_col = GetComponent<Collider2D>();
|
||||
_effector = GetComponent<PlatformEffector2D>();
|
||||
|
||||
// 确保 PlatformEffector2D 配置为单向平台
|
||||
_effector.useOneWay = true;
|
||||
_effector.surfaceArc = 170f;
|
||||
_col.usedByEffector = true;
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (!_isDisabled) return;
|
||||
|
||||
_reEnableTimer -= Time.deltaTime;
|
||||
if (_reEnableTimer <= 0f)
|
||||
{
|
||||
_col.enabled = true;
|
||||
_isDisabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 由玩家状态机调用:触发下蹲跌落,临时禁用碰撞器。
|
||||
/// </summary>
|
||||
public void TriggerDropThrough()
|
||||
{
|
||||
if (!_allowDropThrough || _isDisabled) return;
|
||||
|
||||
_col.enabled = false;
|
||||
_isDisabled = true;
|
||||
_reEnableTimer = _dropDisableDuration;
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
private void OnDrawGizmos()
|
||||
{
|
||||
// 绘制蓝色轮廓以便与实体地面区分
|
||||
if (TryGetComponent<Collider2D>(out var col))
|
||||
{
|
||||
Gizmos.color = new Color(0.3f, 0.6f, 1f, 0.4f);
|
||||
Gizmos.DrawWireCube(col.bounds.center, col.bounds.size);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/World/PhantomPlate.cs.meta
Normal file
11
Assets/Scripts/World/PhantomPlate.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c1483f09f23da6b469f288d63b2f52b5
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
27
Assets/Scripts/World/PlayerSpawnPoint.cs
Normal file
27
Assets/Scripts/World/PlayerSpawnPoint.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.World
|
||||
{
|
||||
/// <summary>
|
||||
/// 玩家出生点标记。RoomController 通过 transitionId 查找匹配的出生点。
|
||||
/// </summary>
|
||||
public class PlayerSpawnPoint : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private string _transitionId;
|
||||
|
||||
[Tooltip("+1 = 朝右出生,-1 = 朝左出生")]
|
||||
[SerializeField] private int _facingDirection = 1;
|
||||
|
||||
public string TransitionId => _transitionId;
|
||||
public Vector2 SpawnPosition => transform.position;
|
||||
/// <summary>玩家出生时的朝向(+1 右,-1 左)。</summary>
|
||||
public int FacingDirection => _facingDirection;
|
||||
|
||||
private void OnDrawGizmos()
|
||||
{
|
||||
Gizmos.color = Color.green;
|
||||
Gizmos.DrawWireSphere(transform.position, 0.3f);
|
||||
Gizmos.DrawLine(transform.position, transform.position + Vector3.up * 0.5f);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/World/PlayerSpawnPoint.cs.meta
Normal file
11
Assets/Scripts/World/PlayerSpawnPoint.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8a1d59da85ab992449bebeb2ecf696d7
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
Assets/Scripts/World/Puzzle.meta
Normal file
8
Assets/Scripts/World/Puzzle.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9bb5b82fd54c8f841a3511e0fb2847f1
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
18
Assets/Scripts/World/Puzzle/PuzzleDoor.cs
Normal file
18
Assets/Scripts/World/Puzzle/PuzzleDoor.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
// Assets/Scripts/World/Puzzle/PuzzleDoor.cs
|
||||
// 门型接收器——PuzzleReceiver 子类(Architecture 21_LiquidPuzzleModule §10)
|
||||
using Animancer;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Puzzle
|
||||
{
|
||||
/// <summary>谜题门:激活时播放开门动画,停用时播放关门动画。</summary>
|
||||
public class PuzzleDoor : PuzzleReceiver
|
||||
{
|
||||
[SerializeField] private AnimancerComponent _animancer;
|
||||
[SerializeField] private AnimationClip _openClip;
|
||||
[SerializeField] private AnimationClip _closeClip;
|
||||
|
||||
protected override void OnActivate() => _animancer?.Play(_openClip);
|
||||
protected override void OnDeactivate() => _animancer?.Play(_closeClip);
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/World/Puzzle/PuzzleDoor.cs.meta
Normal file
11
Assets/Scripts/World/Puzzle/PuzzleDoor.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 872d675c9ad51954e89048ab1718ec2d
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
33
Assets/Scripts/World/Puzzle/PuzzleInterfaces.cs
Normal file
33
Assets/Scripts/World/Puzzle/PuzzleInterfaces.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
// Assets/Scripts/World/Puzzle/PuzzleInterfaces.cs
|
||||
// 谜题系统核心接口(Architecture 21_LiquidPuzzleModule §8)
|
||||
using System;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Puzzle
|
||||
{
|
||||
/// <summary>任何可被切换激活/停用状态的谜题元素。</summary>
|
||||
public interface ISwitchable
|
||||
{
|
||||
bool IsActive { get; }
|
||||
event Action<bool> OnStateChanged;
|
||||
|
||||
/// <summary>SaveData 恢复时调用,强制设置状态不触发副作用逻辑。</summary>
|
||||
void ForceState(bool active);
|
||||
}
|
||||
|
||||
/// <summary>可被玩家推动的物件(需 Rigidbody2D)。</summary>
|
||||
public interface IMovable
|
||||
{
|
||||
bool CanBePushed { get; }
|
||||
void OnPushStart(Vector2 direction);
|
||||
void OnPushEnd();
|
||||
}
|
||||
|
||||
/// <summary>接受激活信号后改变自身状态的物件。</summary>
|
||||
public interface IActivatable
|
||||
{
|
||||
void Activate();
|
||||
void Deactivate();
|
||||
bool IsActivated { get; }
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/World/Puzzle/PuzzleInterfaces.cs.meta
Normal file
11
Assets/Scripts/World/Puzzle/PuzzleInterfaces.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 60e23ba6f1db851418d4bc83031b7aa3
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
57
Assets/Scripts/World/Puzzle/PuzzleReceiver.cs
Normal file
57
Assets/Scripts/World/Puzzle/PuzzleReceiver.cs
Normal file
@@ -0,0 +1,57 @@
|
||||
// Assets/Scripts/World/Puzzle/PuzzleReceiver.cs
|
||||
using BaseGames.World;
|
||||
using MoreMountains.Feedbacks;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Puzzle
|
||||
{
|
||||
/// <summary>
|
||||
/// 谜题接收器,由 PuzzleWire 驱动。
|
||||
/// 挂在谜题目标物件上(门/平台等),实现 IActivatable。
|
||||
/// 子类覆写 OnActivate / OnDeactivate 实现具体行为。
|
||||
/// </summary>
|
||||
public class PuzzleReceiver : MonoBehaviour, IActivatable
|
||||
{
|
||||
[SerializeField] private bool _startsActivated = false;
|
||||
[SerializeField] private string _receiverId; // 持久化唯一 ID(空串则不持久化)
|
||||
[SerializeField] private MMFeedbacks _activateFeedback;
|
||||
[SerializeField] private MMFeedbacks _deactivateFeedback;
|
||||
|
||||
[Header("持久化(SO 注入,非 Instance 单例)")]
|
||||
[SerializeField] private WorldStateRegistry _worldState;
|
||||
|
||||
private bool _isActivated;
|
||||
public bool IsActivated => _isActivated;
|
||||
|
||||
protected virtual void Start()
|
||||
{
|
||||
_isActivated = _startsActivated;
|
||||
if (_isActivated) OnActivate();
|
||||
}
|
||||
|
||||
public void Activate()
|
||||
{
|
||||
if (_isActivated) return;
|
||||
_isActivated = true;
|
||||
_activateFeedback?.PlayFeedbacks();
|
||||
OnActivate();
|
||||
|
||||
if (!string.IsNullOrEmpty(_receiverId))
|
||||
_worldState?.SetFlag("receiver_" + _receiverId);
|
||||
}
|
||||
|
||||
public void Deactivate()
|
||||
{
|
||||
if (!_isActivated) return;
|
||||
_isActivated = false;
|
||||
_deactivateFeedback?.PlayFeedbacks();
|
||||
OnDeactivate();
|
||||
|
||||
if (!string.IsNullOrEmpty(_receiverId))
|
||||
_worldState?.ClearFlag("receiver_" + _receiverId);
|
||||
}
|
||||
|
||||
protected virtual void OnActivate() { }
|
||||
protected virtual void OnDeactivate() { }
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/World/Puzzle/PuzzleReceiver.cs.meta
Normal file
11
Assets/Scripts/World/Puzzle/PuzzleReceiver.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 900ec271407dc2c4782ba8e474c1400d
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
98
Assets/Scripts/World/Puzzle/PuzzleSwitch.cs
Normal file
98
Assets/Scripts/World/Puzzle/PuzzleSwitch.cs
Normal file
@@ -0,0 +1,98 @@
|
||||
// Assets/Scripts/World/Puzzle/PuzzleSwitch.cs
|
||||
using System;
|
||||
using Animancer;
|
||||
using BaseGames.World;
|
||||
using MoreMountains.Feedbacks;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Puzzle
|
||||
{
|
||||
public enum SwitchTriggerMode
|
||||
{
|
||||
InteractOnce, // 玩家交互一次,永久激活
|
||||
InteractToggle, // 玩家交互切换开关
|
||||
Pressure, // 踩上激活,离开停用
|
||||
Hold, // 按住交互键持续激活
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 通用谜题开关,支持三种触发模式。
|
||||
/// 实现 ISwitchable + IInteractable(玩家手动触发)。
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(Collider2D))]
|
||||
public class PuzzleSwitch : MonoBehaviour, ISwitchable, IInteractable
|
||||
{
|
||||
[Header("触发模式")]
|
||||
[SerializeField] private SwitchTriggerMode _mode = SwitchTriggerMode.InteractOnce;
|
||||
|
||||
[Header("状态")]
|
||||
[SerializeField] private bool _startsActive = false;
|
||||
[SerializeField] private string _switchId; // 持久化唯一 ID(空串则不持久化)
|
||||
|
||||
[Header("视觉")]
|
||||
[SerializeField] private AnimancerComponent _animancer;
|
||||
[SerializeField] private AnimationClip _activeClip;
|
||||
[SerializeField] private AnimationClip _inactiveClip;
|
||||
[SerializeField] private MMFeedbacks _activateFeedback;
|
||||
|
||||
[Header("持久化(SO 注入,非 Instance 单例)")]
|
||||
[SerializeField] private WorldStateRegistry _worldState;
|
||||
|
||||
private bool _isActive;
|
||||
|
||||
public bool IsActive => _isActive;
|
||||
public event Action<bool> OnStateChanged;
|
||||
|
||||
private void Start() => _isActive = _startsActive;
|
||||
|
||||
// ── IInteractable ────────────────────────────────────────────────────
|
||||
public string InteractPrompt => _mode == SwitchTriggerMode.Hold ? "按住交互" : "交互";
|
||||
public bool CanInteract => true;
|
||||
|
||||
public void Interact(Transform player)
|
||||
{
|
||||
if (_mode == SwitchTriggerMode.InteractOnce && _isActive) return;
|
||||
SetState(!_isActive);
|
||||
}
|
||||
|
||||
public void OnPlayerEnterRange(Transform player) { }
|
||||
public void OnPlayerExitRange() { }
|
||||
|
||||
// ── ISwitchable ──────────────────────────────────────────────────────
|
||||
public void ForceState(bool active) => SetState(active);
|
||||
|
||||
// ── 压板模式 ──────────────────────────────────────────────────────────
|
||||
private void OnTriggerEnter2D(Collider2D col)
|
||||
{
|
||||
if (_mode != SwitchTriggerMode.Pressure) return;
|
||||
if (col.CompareTag("Player") || col.CompareTag("PushBox"))
|
||||
SetState(true);
|
||||
}
|
||||
|
||||
private void OnTriggerExit2D(Collider2D col)
|
||||
{
|
||||
if (_mode != SwitchTriggerMode.Pressure) return;
|
||||
if (col.CompareTag("Player") || col.CompareTag("PushBox"))
|
||||
SetState(false);
|
||||
}
|
||||
|
||||
private void SetState(bool active)
|
||||
{
|
||||
if (_isActive == active) return;
|
||||
_isActive = active;
|
||||
|
||||
if (active) _animancer?.Play(_activeClip);
|
||||
else _animancer?.Play(_inactiveClip);
|
||||
|
||||
_activateFeedback?.PlayFeedbacks();
|
||||
OnStateChanged?.Invoke(active);
|
||||
|
||||
// 持久化到 WorldStateRegistry(激活添加标记;停用清除标记)
|
||||
if (!string.IsNullOrEmpty(_switchId))
|
||||
{
|
||||
if (active) _worldState?.SetFlag("switch_" + _switchId);
|
||||
else _worldState?.ClearFlag("switch_" + _switchId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/World/Puzzle/PuzzleSwitch.cs.meta
Normal file
11
Assets/Scripts/World/Puzzle/PuzzleSwitch.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 49b0e5504832e054888d1568aa970a7b
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
51
Assets/Scripts/World/Puzzle/PuzzleWire.cs
Normal file
51
Assets/Scripts/World/Puzzle/PuzzleWire.cs
Normal file
@@ -0,0 +1,51 @@
|
||||
// Assets/Scripts/World/Puzzle/PuzzleWire.cs
|
||||
// 逻辑连接器:将一组 PuzzleSwitch 连接到 PuzzleReceiver(Architecture 21_LiquidPuzzleModule §11)
|
||||
// 支持 AND / OR / XOR 激活逻辑,Inspector 中配置,无需代码
|
||||
using System.Linq;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Puzzle
|
||||
{
|
||||
public enum LogicType { AND, OR, XOR }
|
||||
|
||||
/// <summary>
|
||||
/// 连接一个或多个 PuzzleSwitch 到 PuzzleReceiver。
|
||||
/// 支持 AND / OR / XOR 激活逻辑。
|
||||
/// 关卡设计师在 Inspector 中配置,无需编写代码。
|
||||
/// </summary>
|
||||
public class PuzzleWire : MonoBehaviour
|
||||
{
|
||||
[Header("输入开关")]
|
||||
[SerializeField] private PuzzleSwitch[] _switches;
|
||||
|
||||
[Header("激活逻辑")]
|
||||
[SerializeField] private LogicType _logic = LogicType.AND;
|
||||
|
||||
[Header("目标接收器")]
|
||||
[SerializeField] private PuzzleReceiver _receiver;
|
||||
|
||||
private void Start()
|
||||
{
|
||||
if (_switches != null)
|
||||
foreach (var sw in _switches)
|
||||
if (sw != null) sw.OnStateChanged += _ => Evaluate();
|
||||
Evaluate(); // 初始求值
|
||||
}
|
||||
|
||||
private void Evaluate()
|
||||
{
|
||||
if (_switches == null || _receiver == null) return;
|
||||
|
||||
bool shouldActivate = _logic switch
|
||||
{
|
||||
LogicType.AND => System.Array.TrueForAll(_switches, s => s != null && s.IsActive),
|
||||
LogicType.OR => System.Array.Exists(_switches, s => s != null && s.IsActive),
|
||||
LogicType.XOR => _switches.Count(s => s != null && s.IsActive) % 2 == 1,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if (shouldActivate) _receiver.Activate();
|
||||
else _receiver.Deactivate();
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/World/Puzzle/PuzzleWire.cs.meta
Normal file
11
Assets/Scripts/World/Puzzle/PuzzleWire.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 371d40df91ac7314c96eaea30272441f
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
38
Assets/Scripts/World/RoomController.cs
Normal file
38
Assets/Scripts/World/RoomController.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
using BaseGames.Camera;
|
||||
using BaseGames.Core;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.World
|
||||
{
|
||||
/// <summary>
|
||||
/// 房间控制器。挂在每个房间场景的 [RoomRoot] 下。
|
||||
/// Start 时切换摄像机到该房间的 RoomCamera,并提供出生点查询。
|
||||
/// </summary>
|
||||
public class RoomController : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private string _roomId;
|
||||
[SerializeField] private PlayerSpawnPoint[] _spawnPoints;
|
||||
[SerializeField] private RoomCamera _roomCamera; // 该房间的虚拟相机
|
||||
|
||||
public string RoomId => _roomId;
|
||||
|
||||
private void Start()
|
||||
{
|
||||
if (_roomCamera != null)
|
||||
ServiceLocator.GetOrDefault<ICameraService>()?.SwitchRoom(_roomCamera);
|
||||
}
|
||||
|
||||
/// <summary>通过 transitionId 查找对应的出生点。</summary>
|
||||
public PlayerSpawnPoint GetSpawnPoint(string transitionId)
|
||||
{
|
||||
if (_spawnPoints == null) return null;
|
||||
foreach (var sp in _spawnPoints)
|
||||
{
|
||||
if (sp != null && sp.TransitionId == transitionId)
|
||||
return sp;
|
||||
}
|
||||
// Fallback:返回第一个出生点
|
||||
return _spawnPoints.Length > 0 ? _spawnPoints[0] : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/World/RoomController.cs.meta
Normal file
11
Assets/Scripts/World/RoomController.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 78aa6d3a73e834a4499f5988e78f28a6
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
83
Assets/Scripts/World/RoomTransition.cs
Normal file
83
Assets/Scripts/World/RoomTransition.cs
Normal file
@@ -0,0 +1,83 @@
|
||||
using BaseGames.Core.Events;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.World
|
||||
{
|
||||
/// <summary>
|
||||
/// 房间传送点。玩家进入触发器或按交互键时,广播 <see cref="SceneLoadRequest"/>,
|
||||
/// 由 SceneLoader 监听并执行 Additive 场景加载/卸载。
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(Collider2D))]
|
||||
public class RoomTransition : MonoBehaviour, IInteractable
|
||||
{
|
||||
[Header("本传送门标识")]
|
||||
[SerializeField] private string _transitionId; // 本出口唯一 ID(供 SceneLoader 写入复活点)
|
||||
|
||||
[Header("目标")]
|
||||
[SerializeField] private string _targetSceneAddress; // Addressable key(目标场景)
|
||||
[SerializeField] private string _targetTransitionId; // 目标房间出生点 ID
|
||||
|
||||
[Header("触发方式")]
|
||||
[SerializeField] private bool _autoTrigger = true; // true = 玩家进入触发器自动触发
|
||||
|
||||
[Header("钥匙物品校验")]
|
||||
[SerializeField] private bool _requiresKeyItem; // 是否需要持有指定钥匙物品
|
||||
[SerializeField] private string _requiredItemId; // 钥匙物品 ID(_requiresKeyItem = true 时生效)
|
||||
|
||||
[Header("事件频道")]
|
||||
[SerializeField] private SceneLoadRequestEventChannelSO _onSceneLoadRequest;
|
||||
|
||||
[Header("世界状态")]
|
||||
[SerializeField] private WorldStateRegistry _worldState;
|
||||
|
||||
// ── IInteractable ─────────────────────────────────────────────────────
|
||||
public bool CanInteract => !_autoTrigger;
|
||||
public string InteractPrompt => "前往下一区域";
|
||||
|
||||
public void Interact(Transform player) => RequestTransition();
|
||||
|
||||
public void OnPlayerEnterRange(Transform player) { }
|
||||
public void OnPlayerExitRange() { }
|
||||
|
||||
// ── Auto Trigger ──────────────────────────────────────────────────────
|
||||
private void OnTriggerEnter2D(Collider2D other)
|
||||
{
|
||||
if (!_autoTrigger) return;
|
||||
if (!other.CompareTag("Player")) return;
|
||||
RequestTransition();
|
||||
}
|
||||
|
||||
private void RequestTransition()
|
||||
{
|
||||
if (_requiresKeyItem && !HasItem(_requiredItemId)) return;
|
||||
|
||||
_onSceneLoadRequest?.Raise(new SceneLoadRequest
|
||||
{
|
||||
SceneName = _targetSceneAddress,
|
||||
EntryTransitionId = _targetTransitionId,
|
||||
ShowLoadingScreen = true,
|
||||
IsRespawn = false,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>检查玩家是否持有指定物品(通过 WorldStateRegistry.IsCollected 检查)。</summary>
|
||||
private bool HasItem(string itemId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(itemId)) return true;
|
||||
if (_worldState == null)
|
||||
{
|
||||
Debug.LogWarning($"[RoomTransition] WorldStateRegistry 未配置,销 {itemId} 检查跳过");
|
||||
return false;
|
||||
}
|
||||
return _worldState.IsCollected(itemId);
|
||||
}
|
||||
|
||||
private void OnDrawGizmos()
|
||||
{
|
||||
Gizmos.color = new Color(0f, 1f, 0.5f, 0.6f);
|
||||
var col = GetComponent<Collider2D>();
|
||||
if (col != null)
|
||||
Gizmos.DrawWireCube(transform.position, col.bounds.size);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/World/RoomTransition.cs.meta
Normal file
11
Assets/Scripts/World/RoomTransition.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c9143d583dc167f4b9a1ef912723fae3
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -9,7 +9,11 @@
|
||||
"rootNamespace": "BaseGames.World.Shop",
|
||||
"references": [
|
||||
"BaseGames.World",
|
||||
"BaseGames.Core.Events"
|
||||
"BaseGames.Core",
|
||||
"BaseGames.Core.Events",
|
||||
"BaseGames.Core.Save",
|
||||
"BaseGames.Equipment",
|
||||
"BaseGames.Dialogue"
|
||||
],
|
||||
"autoReferenced": true,
|
||||
"overrideReferences": false,
|
||||
|
||||
179
Assets/Scripts/World/Shop/ShopController.cs
Normal file
179
Assets/Scripts/World/Shop/ShopController.cs
Normal file
@@ -0,0 +1,179 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using UnityEngine;
|
||||
using BaseGames.Core;
|
||||
using BaseGames.Core.Events;
|
||||
using BaseGames.Core.Save;
|
||||
|
||||
namespace BaseGames.World.Shop
|
||||
{
|
||||
/// <summary>
|
||||
/// 商店流程控制器(架构 15_MapShopModule §2.3)。
|
||||
/// 管理库存可见性、购买逻辑、补货策略,并通过 ISaveable 持久化购买记录。
|
||||
/// </summary>
|
||||
public class ShopController : MonoBehaviour, ISaveable
|
||||
{
|
||||
[SerializeField] private ShopInventorySO _inventory;
|
||||
[SerializeField] private ShopPanel _shopPanel; // UI 面板(P4 UI 模块实现)
|
||||
|
||||
[Header("Event Channels(广播)")]
|
||||
[SerializeField] private StringEventChannelSO _onShopOpen; // EVT_ShopOpened(shopId)
|
||||
[SerializeField] private VoidEventChannelSO _onShopClosed; // EVT_ShopClosed
|
||||
[SerializeField] private ShopPurchaseEventChannelSO _onItemPurchased; // EVT_ItemPurchased
|
||||
|
||||
[Header("Event Channels(订阅补货触发)")]
|
||||
[SerializeField] private StringEventChannelSO _onBossDefeated; // EVT_BossDefeated
|
||||
[SerializeField] private VoidEventChannelSO _onSavePointActivated; // EVT_SavePointActivated
|
||||
|
||||
// key = itemId,value = 已购次数
|
||||
private Dictionary<string, int> _purchaseCounts = new();
|
||||
private HashSet<string> _soldUniqueItems = new();
|
||||
private List<ShopItemSO> _availableItemsCache;
|
||||
private bool _isDirty = true;
|
||||
private readonly CompositeDisposable _subs = new();
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
if (_inventory == null) return;
|
||||
if (_inventory.RestockPolicy == RestockPolicy.OnBossDefeat)
|
||||
_onBossDefeated?.Subscribe(OnBossDefeated).AddTo(_subs);
|
||||
if (_inventory.RestockPolicy == RestockPolicy.OnSavePoint)
|
||||
_onSavePointActivated?.Subscribe(OnSavePointActivated).AddTo(_subs);
|
||||
ServiceLocator.GetOrDefault<ISaveableRegistry>()?.Register(this);
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
_subs.Clear();
|
||||
ServiceLocator.GetOrDefault<ISaveableRegistry>()?.Unregister(this);
|
||||
}
|
||||
|
||||
private void OnBossDefeated(string _) => Restock();
|
||||
private void OnSavePointActivated() => Restock();
|
||||
|
||||
// ── 公共 API ──────────────────────────────────────────────────────────
|
||||
|
||||
public void Open()
|
||||
{
|
||||
_shopPanel?.Show(GetAvailableItems(), this);
|
||||
_onShopOpen?.Raise(_inventory.ShopId);
|
||||
}
|
||||
|
||||
public void Close()
|
||||
{
|
||||
_shopPanel?.Hide();
|
||||
_onShopClosed?.Raise();
|
||||
}
|
||||
|
||||
/// <summary>返回当前可购买的商品列表(过滤已售唯一品及超限商品)。结果在库存变更时才重建。</summary>
|
||||
public List<ShopItemSO> GetAvailableItems()
|
||||
{
|
||||
if (!_isDirty) return _availableItemsCache;
|
||||
if (_inventory?.DefaultInventory == null)
|
||||
{
|
||||
_availableItemsCache = new List<ShopItemSO>();
|
||||
_isDirty = false;
|
||||
return _availableItemsCache;
|
||||
}
|
||||
_availableItemsCache = _inventory.DefaultInventory
|
||||
.Take(_inventory.MaxDisplaySlots)
|
||||
.Where(item => item != null
|
||||
&& !_soldUniqueItems.Contains(item.ItemId)
|
||||
&& (item.MaxPurchaseCount < 0 || GetPurchaseCount(item.ItemId) < item.MaxPurchaseCount))
|
||||
.ToList();
|
||||
_isDirty = false;
|
||||
return _availableItemsCache;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 按 RestockPolicy 补货:重置非唯一商品的购买次数(已售唯一品不恢复)。
|
||||
/// </summary>
|
||||
public void Restock()
|
||||
{
|
||||
if (_inventory?.DefaultInventory == null) return;
|
||||
var nonUniqueIds = _inventory.DefaultInventory
|
||||
.Where(i => i != null && !i.IsUnique)
|
||||
.Select(i => i.ItemId);
|
||||
foreach (var id in nonUniqueIds)
|
||||
_purchaseCounts.Remove(id);
|
||||
_isDirty = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 尝试购买商品。由 ShopPanel 的购买按钮调用。
|
||||
/// </summary>
|
||||
/// <returns>购买成功返回 true;资金不足或商品不可购返回 false。</returns>
|
||||
public bool TryPurchase(ShopItemSO item, int playerGeo)
|
||||
{
|
||||
if (item == null) return false;
|
||||
int effectivePrice = GetEffectivePrice(item);
|
||||
if (playerGeo < effectivePrice) return false;
|
||||
if (_soldUniqueItems.Contains(item.ItemId)) return false;
|
||||
if (item.MaxPurchaseCount >= 0 && GetPurchaseCount(item.ItemId) >= item.MaxPurchaseCount)
|
||||
return false;
|
||||
|
||||
// 通过事件频道扣 Geo(PlayerStats 监听 EVT_ItemPurchased)
|
||||
_onItemPurchased?.Raise(new ShopPurchaseEvent
|
||||
{
|
||||
ItemId = item.ItemId,
|
||||
Price = effectivePrice,
|
||||
});
|
||||
|
||||
// 更新库存状态
|
||||
_purchaseCounts[item.ItemId] = GetPurchaseCount(item.ItemId) + 1;
|
||||
if (item.IsUnique) _soldUniqueItems.Add(item.ItemId);
|
||||
_isDirty = true;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 返回应用难度乘数后的实际价格。UI 层可通过此方法显示正确标价。
|
||||
/// </summary>
|
||||
public int GetEffectivePrice(ShopItemSO item)
|
||||
{
|
||||
var scaler = ServiceLocator.GetOrDefault<IDifficultyService>()?.CurrentScaler;
|
||||
if (scaler == null) return item.BasePrice;
|
||||
return Mathf.Max(1, Mathf.RoundToInt(item.BasePrice * scaler.ShopPriceMultiplier));
|
||||
}
|
||||
|
||||
private int GetPurchaseCount(string id)
|
||||
=> _purchaseCounts.TryGetValue(id, out var c) ? c : 0;
|
||||
|
||||
// ── ISaveable ─────────────────────────────────────────────────────────
|
||||
|
||||
public void OnSave(SaveData data)
|
||||
{
|
||||
if (_inventory == null) return;
|
||||
if (!data.Shops.ShopRecords.ContainsKey(_inventory.ShopId))
|
||||
data.Shops.ShopRecords[_inventory.ShopId] = new ShopRecord();
|
||||
|
||||
var record = data.Shops.ShopRecords[_inventory.ShopId];
|
||||
record.SoldUniqueItems = _soldUniqueItems.ToList();
|
||||
record.PurchaseCounts = new Dictionary<string, int>(_purchaseCounts);
|
||||
}
|
||||
|
||||
public void OnLoad(SaveData data)
|
||||
{
|
||||
if (_inventory == null) return;
|
||||
if (data.Shops.ShopRecords.TryGetValue(_inventory.ShopId, out var record))
|
||||
{
|
||||
_soldUniqueItems = new HashSet<string>(record.SoldUniqueItems ?? new List<string>());
|
||||
_purchaseCounts = record.PurchaseCounts ?? new Dictionary<string, int>();
|
||||
_isDirty = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── ShopPanel ──────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 商店 UI 面板基类。
|
||||
/// ShopController 通过此接口调用面板显示/隐藏,已解耦具体 UI 实现。
|
||||
/// </summary>
|
||||
public class ShopPanel : MonoBehaviour
|
||||
{
|
||||
public virtual void Show(List<ShopItemSO> items, ShopController controller) { }
|
||||
public virtual void Hide() { }
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/World/Shop/ShopController.cs.meta
Normal file
11
Assets/Scripts/World/Shop/ShopController.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 519d4cd7bb9c7df408bb5dce39476ce5
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
36
Assets/Scripts/World/Shop/ShopInventorySO.cs
Normal file
36
Assets/Scripts/World/Shop/ShopInventorySO.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.World.Shop
|
||||
{
|
||||
/// <summary>
|
||||
/// 商店库存 SO(架构 15_MapShopModule §2.2)。
|
||||
/// 资产路径: Assets/ScriptableObjects/Shop/Inventory_{ShopId}.asset
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "World/Shop/ShopInventory")]
|
||||
public class ShopInventorySO : ScriptableObject
|
||||
{
|
||||
[Header("标识")]
|
||||
public string ShopId; // 全局唯一,如 "Shop_Village"
|
||||
|
||||
[Header("库存")]
|
||||
public List<ShopItemSO> DefaultInventory = new(); // 初始商品列表
|
||||
public int MaxDisplaySlots = 6; // UI 最多同时显示的商品格数
|
||||
|
||||
[Header("补货策略")]
|
||||
public RestockPolicy RestockPolicy = RestockPolicy.Never;
|
||||
|
||||
[Header("老板信息")]
|
||||
public Sprite KeeperPortrait;
|
||||
public string KeeperName;
|
||||
}
|
||||
|
||||
/// <summary>库存补货时机策略。</summary>
|
||||
public enum RestockPolicy
|
||||
{
|
||||
Never, // 永不补货(唯一商品卖完即消失)
|
||||
OnSavePoint, // 激活存档点时补货
|
||||
OnBossDefeat, // 击败 Boss 后补货
|
||||
Periodic, // 周期性补货(由 ShopController 定时或条件检查)
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/World/Shop/ShopInventorySO.cs.meta
Normal file
11
Assets/Scripts/World/Shop/ShopInventorySO.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ea3e80e8c87da3b438ba8ef432ca30d7
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
42
Assets/Scripts/World/Shop/ShopItemSO.cs
Normal file
42
Assets/Scripts/World/Shop/ShopItemSO.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
using UnityEngine;
|
||||
using BaseGames.Equipment;
|
||||
|
||||
namespace BaseGames.World.Shop
|
||||
{
|
||||
/// <summary>
|
||||
/// 商店单品 SO(架构 15_MapShopModule §2.1)。
|
||||
/// 资产路径: Assets/ScriptableObjects/Shop/Item_{ItemId}.asset
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "World/Shop/ShopItem")]
|
||||
public class ShopItemSO : ScriptableObject
|
||||
{
|
||||
[Header("标识")]
|
||||
public string ItemId;
|
||||
public string DisplayName;
|
||||
[TextArea(2, 5)]
|
||||
public string Description;
|
||||
public Sprite Icon;
|
||||
|
||||
[Header("价格")]
|
||||
public int BasePrice;
|
||||
public bool IsUnique; // 购买一次后永久从库存移除
|
||||
|
||||
[Header("商品类型")]
|
||||
public ShopItemType ItemType;
|
||||
|
||||
// 按 ItemType 填写以下字段(其余留空)
|
||||
public int HealthRestoreAmount; // HealthRestoration 类型
|
||||
public CharmSO CharmReference; // CharmItem 类型
|
||||
public string KeyItemId; // KeyItem 类型
|
||||
public int MaxPurchaseCount = -1; // -1 = 无限次
|
||||
}
|
||||
|
||||
public enum ShopItemType
|
||||
{
|
||||
HealthRestoration,
|
||||
CharmItem,
|
||||
KeyItem,
|
||||
ConsumableBuff,
|
||||
MapFragment,
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/World/Shop/ShopItemSO.cs.meta
Normal file
11
Assets/Scripts/World/Shop/ShopItemSO.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2d01bb739dd4fae40a4a5391030c8802
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
62
Assets/Scripts/World/Shop/ShopNPC.cs
Normal file
62
Assets/Scripts/World/Shop/ShopNPC.cs
Normal file
@@ -0,0 +1,62 @@
|
||||
using UnityEngine;
|
||||
using BaseGames.World;
|
||||
using BaseGames.Core.Events;
|
||||
using BaseGames.Dialogue;
|
||||
|
||||
namespace BaseGames.World.Shop
|
||||
{
|
||||
/// <summary>
|
||||
/// 商店 NPC 交互组件(架构 15_MapShopModule §2.4)。
|
||||
/// 实现 IInteractable;玩家与其交互时先触发招呼对话,对话结束后打开商店面板。
|
||||
/// 若无招呼对话,则直接打开商店。
|
||||
/// </summary>
|
||||
public class ShopNPC : MonoBehaviour, IInteractable
|
||||
{
|
||||
[Header("组件引用")]
|
||||
[SerializeField] private ShopController _shopController;
|
||||
|
||||
[Header("招呼对话(可空,留空则直接开店)")]
|
||||
[SerializeField] private DialogueDataSO _greetDialogue;
|
||||
|
||||
[Header("Event Channels")]
|
||||
[SerializeField] private DialogueEventChannelSO _dialogueChannel; // EVT_DialogueStart
|
||||
[SerializeField] private VoidEventChannelSO _onDialogueEnded; // EVT_DialogueEnded
|
||||
|
||||
[Header("交互提示")]
|
||||
[SerializeField] private string _interactPrompt = "购买物品";
|
||||
|
||||
private EventSubscription _greetSub;
|
||||
|
||||
// ── IInteractable ─────────────────────────────────────────────────────
|
||||
|
||||
public bool CanInteract => _shopController != null;
|
||||
public string InteractPrompt => _interactPrompt;
|
||||
|
||||
public void Interact(Transform player)
|
||||
{
|
||||
if (_greetDialogue != null && _dialogueChannel != null)
|
||||
{
|
||||
_greetSub.Dispose();
|
||||
_greetSub = _onDialogueEnded.Subscribe(OnGreetDialogueEnded);
|
||||
_dialogueChannel.Raise(_greetDialogue);
|
||||
}
|
||||
else
|
||||
{
|
||||
OpenShop();
|
||||
}
|
||||
}
|
||||
|
||||
public void OnPlayerEnterRange(Transform player) { }
|
||||
public void OnPlayerExitRange() { }
|
||||
|
||||
// ── 内部 ──────────────────────────────────────────────────────────────
|
||||
|
||||
private void OnGreetDialogueEnded()
|
||||
{
|
||||
_greetSub.Dispose();
|
||||
OpenShop();
|
||||
}
|
||||
|
||||
private void OpenShop() => _shopController?.Open();
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/World/Shop/ShopNPC.cs.meta
Normal file
11
Assets/Scripts/World/Shop/ShopNPC.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d7f9d506a48350f43964575c094de208
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
13
Assets/Scripts/World/SoftTerrain.cs
Normal file
13
Assets/Scripts/World/SoftTerrain.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.World
|
||||
{
|
||||
/// <summary>
|
||||
/// 软质地形标记组件(无逻辑)。GroundDiveState 通过 GetComponent<SoftTerrain>()
|
||||
/// 检测当前瓦片是否为软质,决定是否消耗灵魂。
|
||||
/// </summary>
|
||||
public class SoftTerrain : MonoBehaviour
|
||||
{
|
||||
// Marker 组件,无逻辑代码。
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/World/SoftTerrain.cs.meta
Normal file
11
Assets/Scripts/World/SoftTerrain.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 23512647f03fa4b4daa81ca625067783
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
68
Assets/Scripts/World/WorldMarker.cs
Normal file
68
Assets/Scripts/World/WorldMarker.cs
Normal file
@@ -0,0 +1,68 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.World
|
||||
{
|
||||
/// <summary>
|
||||
/// 世界标记点(架构 21_LiquidPuzzleModule §14)。
|
||||
/// 用于在地图/小地图上标注目标、NPC、兴趣点等。
|
||||
/// 通过事件频道通知 UI 层显示/隐藏图标。
|
||||
/// </summary>
|
||||
public class WorldMarker : MonoBehaviour
|
||||
{
|
||||
[Header("标记配置")]
|
||||
[SerializeField] private WorldMarkerType _markerType = WorldMarkerType.Objective;
|
||||
[SerializeField] private string _markerId = "";
|
||||
[SerializeField] private string _labelKey = ""; // 本地化 Key
|
||||
|
||||
[Header("事件频道")]
|
||||
[SerializeField] private WorldMarkerEventChannelSO _onMarkerActivated;
|
||||
[SerializeField] private WorldMarkerEventChannelSO _onMarkerDeactivated;
|
||||
|
||||
public WorldMarkerType MarkerType => _markerType;
|
||||
public string MarkerId => _markerId;
|
||||
public string LabelKey => _labelKey;
|
||||
|
||||
private bool _isActive;
|
||||
public bool IsActive => _isActive;
|
||||
|
||||
// ── 公共 API ──────────────────────────────────────────────────────
|
||||
public void Activate()
|
||||
{
|
||||
if (_isActive) return;
|
||||
_isActive = true;
|
||||
_onMarkerActivated?.Raise(this);
|
||||
}
|
||||
|
||||
public void Deactivate()
|
||||
{
|
||||
if (!_isActive) return;
|
||||
_isActive = false;
|
||||
_onMarkerDeactivated?.Raise(this);
|
||||
}
|
||||
|
||||
// ── 编辑器辅助 ────────────────────────────────────────────────────
|
||||
private void OnDrawGizmosSelected()
|
||||
{
|
||||
Gizmos.color = _markerType switch
|
||||
{
|
||||
WorldMarkerType.Objective => Color.yellow,
|
||||
WorldMarkerType.NPC => Color.cyan,
|
||||
WorldMarkerType.PointOfInterest => Color.green,
|
||||
WorldMarkerType.Exit => Color.blue,
|
||||
WorldMarkerType.Secret => Color.magenta,
|
||||
_ => Color.white,
|
||||
};
|
||||
Gizmos.DrawWireSphere(transform.position, 0.4f);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>世界标记类型(架构 21_LiquidPuzzleModule §14)。</summary>
|
||||
public enum WorldMarkerType
|
||||
{
|
||||
Objective,
|
||||
NPC,
|
||||
PointOfInterest,
|
||||
Exit,
|
||||
Secret,
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/World/WorldMarker.cs.meta
Normal file
11
Assets/Scripts/World/WorldMarker.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c7bce095c08d05b42bc206aa13020319
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
12
Assets/Scripts/World/WorldMarkerEventChannelSO.cs
Normal file
12
Assets/Scripts/World/WorldMarkerEventChannelSO.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using UnityEngine;
|
||||
using BaseGames.Core.Events;
|
||||
|
||||
namespace BaseGames.World
|
||||
{
|
||||
/// <summary>
|
||||
/// WorldMarker 事件频道(架构 21_LiquidPuzzleModule §14)。
|
||||
/// 携带激活/停用的 WorldMarker 引用。
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "Events/WorldMarkerEvent")]
|
||||
public class WorldMarkerEventChannelSO : BaseEventChannelSO<WorldMarker> { }
|
||||
}
|
||||
11
Assets/Scripts/World/WorldMarkerEventChannelSO.cs.meta
Normal file
11
Assets/Scripts/World/WorldMarkerEventChannelSO.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d2f394ea4ee643842a45e75b53f21116
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
116
Assets/Scripts/World/WorldStateRegistry.cs
Normal file
116
Assets/Scripts/World/WorldStateRegistry.cs
Normal file
@@ -0,0 +1,116 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using BaseGames.Core.Save;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.World
|
||||
{
|
||||
/// <summary>世界对象的分类枚举,作为 WorldStateRegistry 的泛化 key。</summary>
|
||||
public enum WorldObjectCategory
|
||||
{
|
||||
Collectible,
|
||||
SavePoint,
|
||||
Door,
|
||||
Destroyed,
|
||||
Flag,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 运行时世界状态缓存。ScriptableObject,通过 [SerializeField] 注入各组件。
|
||||
/// SaveManager.SaveAsync 调用 GetAllFlags();
|
||||
/// SaveManager.LoadAsync 调用 LoadFromSave(data.World)。
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "World/WorldStateRegistry")]
|
||||
public class WorldStateRegistry : ScriptableObject
|
||||
{
|
||||
// ── 统一状态字典 ─────────────────────────────────────────────────────
|
||||
private readonly Dictionary<WorldObjectCategory, HashSet<string>> _states = new();
|
||||
|
||||
/// <summary>
|
||||
/// 状态变更时广播:(类别, id)。UI / 测试代码可订阅此事件做响应式刷新。
|
||||
/// </summary>
|
||||
public event Action<WorldObjectCategory, string> OnStateChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Editor 重新进入 Play Mode 时 ScriptableObject 保留上一次运行的状态,
|
||||
/// OnEnable 在域重载(Domain Reload)和每次 Play 开始时都会调用,确保状态干净。
|
||||
/// </summary>
|
||||
private void OnEnable() => _states.Clear();
|
||||
|
||||
// ── 泛化 API ─────────────────────────────────────────────────────────
|
||||
/// <summary>检查指定类别中 id 是否已标记。</summary>
|
||||
public bool IsMarked(WorldObjectCategory category, string id)
|
||||
=> _states.TryGetValue(category, out var set) && set.Contains(id);
|
||||
|
||||
/// <summary>标记指定类别中的 id(幂等)。</summary>
|
||||
public void Mark(WorldObjectCategory category, string id)
|
||||
{
|
||||
if (!_states.TryGetValue(category, out var set))
|
||||
{
|
||||
set = new HashSet<string>();
|
||||
_states[category] = set;
|
||||
}
|
||||
if (set.Add(id))
|
||||
OnStateChanged?.Invoke(category, id);
|
||||
}
|
||||
|
||||
// ── 语义化具名 API(泛化方法快捷方式)───────────────────────────────
|
||||
public bool IsCollected(string id) => IsMarked(WorldObjectCategory.Collectible, id);
|
||||
public void MarkCollected(string id) => Mark(WorldObjectCategory.Collectible, id);
|
||||
|
||||
public bool IsSavePointActivated(string id) => IsMarked(WorldObjectCategory.SavePoint, id);
|
||||
public void MarkSavePointActivated(string id) => Mark(WorldObjectCategory.SavePoint, id);
|
||||
|
||||
public bool IsDestroyed(string id) => IsMarked(WorldObjectCategory.Destroyed, id);
|
||||
public void MarkDestroyed(string id) => Mark(WorldObjectCategory.Destroyed, id);
|
||||
|
||||
public bool IsDoorOpened(string id) => IsMarked(WorldObjectCategory.Door, id);
|
||||
public void MarkDoorOpened(string id) => Mark(WorldObjectCategory.Door, id);
|
||||
|
||||
public bool HasFlag(string key) => IsMarked(WorldObjectCategory.Flag, key);
|
||||
public void SetFlag(string key) => Mark(WorldObjectCategory.Flag, key);
|
||||
public void ClearFlag(string key)
|
||||
{
|
||||
if (_states.TryGetValue(WorldObjectCategory.Flag, out var set) && set.Remove(key))
|
||||
OnStateChanged?.Invoke(WorldObjectCategory.Flag, key);
|
||||
}
|
||||
|
||||
// ── Persistence ───────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>从存档数据恢复全部状态。由 SaveManager.LoadAsync 调用。</summary>
|
||||
public void LoadFromSave(WorldSaveData data)
|
||||
{
|
||||
_states.Clear();
|
||||
|
||||
if (data == null) return;
|
||||
|
||||
foreach (var id in data.CollectedIds) Mark(WorldObjectCategory.Collectible, id);
|
||||
foreach (var id in data.ActivatedSavePoints) Mark(WorldObjectCategory.SavePoint, id);
|
||||
foreach (var id in data.OpenedDoors) Mark(WorldObjectCategory.Door, id);
|
||||
foreach (var id in data.DestroyedObjectIds) Mark(WorldObjectCategory.Destroyed, id);
|
||||
|
||||
if (data.Switches != null)
|
||||
foreach (var kv in data.Switches)
|
||||
if (kv.Value) Mark(WorldObjectCategory.Flag, kv.Key);
|
||||
}
|
||||
|
||||
/// <summary>获取所有通用标记,供 SaveManager 持久化。</summary>
|
||||
public HashSet<string> GetAllFlags()
|
||||
{
|
||||
if (_states.TryGetValue(WorldObjectCategory.Flag, out var flags))
|
||||
return new HashSet<string>(flags);
|
||||
return new HashSet<string>();
|
||||
}
|
||||
|
||||
/// <summary>获取所有已摧毁对象 ID,供 SaveManager 持久化。</summary>
|
||||
public HashSet<string> GetAllDestroyedIds()
|
||||
{
|
||||
if (_states.TryGetValue(WorldObjectCategory.Destroyed, out var set))
|
||||
return new HashSet<string>(set);
|
||||
return new HashSet<string>();
|
||||
}
|
||||
|
||||
/// <summary>重置所有状态(开始新游戏时调用)。</summary>
|
||||
public void Reset() => _states.Clear();
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/World/WorldStateRegistry.cs.meta
Normal file
11
Assets/Scripts/World/WorldStateRegistry.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 06e6792743ec77a49a0211dc97b08333
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user