多轮审查和修复

This commit is contained in:
2026-05-12 15:34:08 +08:00
parent f55d2a57c3
commit ebbbb7332e
805 changed files with 838724 additions and 1905 deletions

View 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);
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 6b0688b021b5f484e97e37c1df2a78cf
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View 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);
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 771e9fdd9e24d4c439ea4ee76fc6720a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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,

View 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();
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d24c8921af6b81a41a5ad4abdb6e7bad
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View 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;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: bc6127aab8dccfd44908a8b790f37fd1
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View 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);
}
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 639f83b55ae027245a4ad6fc8c1f23ae
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View 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);
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 1cdcd375086213841a7c0024e0ea8d23
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,69 @@
using System.Collections;
using MoreMountains.Feedbacks;
using UnityEngine;
namespace BaseGames.World
{
/// <summary>
/// 碎裂平台。玩家踩上后经历 Warning → Crumbling → Gone → (Recovering) 四态。
/// _isOneShot = true 时碎裂后永久消失_respawnDelay &gt; 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;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 622e141966c211140a90d864526e2f2a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View 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);
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ca56784b1763aa6428c18b7b56e33c46
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View 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);
}
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 1a1d610c8b247b640866fa908138df23
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View 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
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: bbd427384359cad42865af9fe164a68d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View 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();
}
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 51f66b7afcb92d94a8be9c55e1294d85
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View 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
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 6c40925c7c82dfc4da17d02b1beca36c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View 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);
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 7a6fc54c1f0cdab4eb82445806fa8335
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View 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);
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 50b55c5d6664b9f4b8fd1104f272bb8f
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 3fe06102bf661d547a12bcc6aa10d23f
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View 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
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 79a8b38fe4021c44b8cb86900cbb8a5f
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View 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 处理)
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 87548f201d0ce4c4d9e94f38f429b689
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View 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 上。
/// 酸液/熔岩时需同时挂载 HazardZoneInstantKill 类型)。
/// </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()));
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: e7a2e8fb64e1e3a4fb0e2cd6f5c3ccc1
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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; // 水下专属 VolumeWeightMode
[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;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 6d973b7199e1c854685e66fab26091bc
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View 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();
}
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8dc9fa2201584e04e93b12f7dc7bc6e6
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,26 @@
using UnityEngine;
namespace BaseGames.World
{
/// <summary>
/// 魔法墙标记组件。仅用于 Gizmo 可视化,穿越逻辑通过
/// Physics Layer Matrix 实现Ghost 层 vs MagicWall 层 = IgnoreCollision
/// SkillManager 在太虚斩激活/结束时切换玩家 LayerGhost ↔ 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
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: a1fc25502241cf940841e0a242b97ee2
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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,

View 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; }
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ce82de7829d7e0141b6811c5f70373b0
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View 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);
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 6a752b1e0e60f8e41a3b5f7483894d5a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View 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;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c9677e71b6b3d4d43aa3680aa8990a83
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View 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.csBaseGames.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>();
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 0d5b16698a16d38428ae6c836d5c4536
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View 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));
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 3f3715a3378c2004a89bc0ee56ca25c6
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View 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 卸载时清理缓存
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 00cffb59dd3827e41acf0e7697861b11
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View 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
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ae997ac2469ff6b4cb58cf825ed67397
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View 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();
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: a5ec3c5dc4fe87c4c99447587538f32d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View 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
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c1483f09f23da6b469f288d63b2f52b5
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View 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);
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8a1d59da85ab992449bebeb2ecf696d7
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 9bb5b82fd54c8f841a3511e0fb2847f1
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View 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);
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 872d675c9ad51954e89048ab1718ec2d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View 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; }
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 60e23ba6f1db851418d4bc83031b7aa3
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View 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() { }
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 900ec271407dc2c4782ba8e474c1400d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View 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);
}
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 49b0e5504832e054888d1568aa970a7b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,51 @@
// Assets/Scripts/World/Puzzle/PuzzleWire.cs
// 逻辑连接器:将一组 PuzzleSwitch 连接到 PuzzleReceiverArchitecture 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();
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 371d40df91ac7314c96eaea30272441f
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View 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;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 78aa6d3a73e834a4499f5988e78f28a6
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View 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);
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c9143d583dc167f4b9a1ef912723fae3
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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,

View 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_ShopOpenedshopId
[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 = itemIdvalue = 已购次数
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;
// 通过事件频道扣 GeoPlayerStats 监听 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() { }
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 519d4cd7bb9c7df408bb5dce39476ce5
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View 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 定时或条件检查)
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ea3e80e8c87da3b438ba8ef432ca30d7
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View 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,
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 2d01bb739dd4fae40a4a5391030c8802
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View 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();
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d7f9d506a48350f43964575c094de208
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,13 @@
using UnityEngine;
namespace BaseGames.World
{
/// <summary>
/// 软质地形标记组件无逻辑。GroundDiveState 通过 GetComponent&lt;SoftTerrain&gt;()
/// 检测当前瓦片是否为软质,决定是否消耗灵魂。
/// </summary>
public class SoftTerrain : MonoBehaviour
{
// Marker 组件,无逻辑代码。
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 23512647f03fa4b4daa81ca625067783
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View 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,
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c7bce095c08d05b42bc206aa13020319
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View 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> { }
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d2f394ea4ee643842a45e75b53f21116
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View 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();
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 06e6792743ec77a49a0211dc97b08333
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: