多轮审查评估
This commit is contained in:
@@ -152,7 +152,10 @@ namespace BaseGames.Audio
|
||||
|
||||
/// <summary>2D 游戏中位置无衰减,统一委托多源池播放。</summary>
|
||||
public void PlaySFXAtPosition(AudioClip clip, Vector2 pos, float volumeScale = 1f)
|
||||
=> PlaySFX(clip, volumeScale);
|
||||
{
|
||||
if (clip == null) return;
|
||||
AudioSource.PlayClipAtPoint(clip, pos, volumeScale);
|
||||
}
|
||||
|
||||
// ── 快照切换 ─────────────────────────────────────────────────────────────
|
||||
/// <summary>切换 AudioMixer 快照(如 Default / Paused / Dead / BossFight)。</summary>
|
||||
|
||||
@@ -55,7 +55,14 @@ namespace BaseGames.Audio
|
||||
{
|
||||
_musicState = MusicState.Boss;
|
||||
var clip = _config.GetBossBGM(_currentRegion);
|
||||
_audioManager.PlayBGM(clip, fadeOutDur: 1f, fadeInDur: 0.5f);
|
||||
if (clip == null)
|
||||
{
|
||||
Debug.LogWarning($"[BGMController] 区域 '{_currentRegion}' 未配置 Boss BGM,将保持当前音乐。", this);
|
||||
}
|
||||
else
|
||||
{
|
||||
_audioManager.PlayBGM(clip, fadeOutDur: 1f, fadeInDur: 0.5f);
|
||||
}
|
||||
_audioManager.TransitionToSnapshot("BossFight", 0.5f);
|
||||
}
|
||||
else
|
||||
@@ -83,6 +90,11 @@ namespace BaseGames.Audio
|
||||
if (_musicState == MusicState.Exploration)
|
||||
{
|
||||
var clip = _config.GetZoneBGM(regionId);
|
||||
if (clip == null)
|
||||
{
|
||||
Debug.LogWarning($"[BGMController] 区域 '{regionId}' 未配置 BGM,将保持当前音乐。", this);
|
||||
return;
|
||||
}
|
||||
_audioManager.PlayBGM(clip, fadeOutDur: 1f, fadeInDur: 1f);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,10 @@ namespace BaseGames.Combat.StatusEffects
|
||||
private const float BaseDuration = 3.0f; // 持续 3 秒
|
||||
private const float DotInterval = 0.5f; // 每 0.5 秒一次
|
||||
|
||||
public override StatusEffectType EffectType => StatusEffectType.Fire;
|
||||
public override int MaxStacks => 1;
|
||||
public override StatusEffectType EffectType => StatusEffectType.Fire;
|
||||
public override int MaxStacks => 1;
|
||||
/// <summary>施加燃烧时移除冻结(火冰互斥)。</summary>
|
||||
public override StatusEffectType[] MutualExclusions => new[] { StatusEffectType.Freeze };
|
||||
|
||||
public FireEffect()
|
||||
{
|
||||
|
||||
@@ -20,6 +20,8 @@ namespace BaseGames.Combat.StatusEffects
|
||||
|
||||
public override StatusEffectType EffectType => StatusEffectType.Stagger;
|
||||
public override int MaxStacks => 1;
|
||||
/// <summary>眼晕(Stun)优先级高于硬直,眼晕状态下硬直不可施加。</summary>
|
||||
public override StatusEffectType[] BlockedBy => new[] { StatusEffectType.Stun };
|
||||
|
||||
public override void OnApply(StatusEffectManager owner)
|
||||
{
|
||||
|
||||
@@ -31,6 +31,19 @@ namespace BaseGames.Combat.StatusEffects
|
||||
/// <summary>是否已过期(由 Manager 每帧检查)。</summary>
|
||||
public virtual bool IsExpired => Duration <= 0f;
|
||||
|
||||
/// <summary>
|
||||
/// 施加此效果时将被净化的互斥效果类型列表。
|
||||
/// 例:FireEffect 返回 [Freeze],表示施加燃烧时会同时移除冻结。
|
||||
/// </summary>
|
||||
public virtual StatusEffectType[] MutualExclusions => System.Array.Empty<StatusEffectType>();
|
||||
|
||||
/// <summary>
|
||||
/// 阻止此效果施加的效果类型列表。
|
||||
/// 宿主当前存在列表中任意效果时,本效果将被拒绝施加。
|
||||
/// 例:StaggerEffect 返回 [Stun],表示眩晕状态下无法再施加硬直。
|
||||
/// </summary>
|
||||
public virtual StatusEffectType[] BlockedBy => System.Array.Empty<StatusEffectType>();
|
||||
|
||||
private float _tickTimer;
|
||||
|
||||
/// <summary>宿主 Manager(OnApply 时注入,OnTick/OnExpire 中可访问)。</summary>
|
||||
|
||||
@@ -82,6 +82,15 @@ namespace BaseGames.Combat.StatusEffects
|
||||
/// <summary>直接施加一个具体效果(供技能/Boss 使用)。</summary>
|
||||
public void ApplyEffect(StatusEffect effect)
|
||||
{
|
||||
// 检查是否被现有效果阻断
|
||||
foreach (var blockerType in effect.BlockedBy)
|
||||
if (_activeIndex.ContainsKey(blockerType)) return;
|
||||
|
||||
// 净化与新效果互斥的现有效果
|
||||
foreach (var excludedType in effect.MutualExclusions)
|
||||
if (_activeIndex.ContainsKey(excludedType))
|
||||
CleanseEffect(excludedType);
|
||||
|
||||
if (_activeIndex.TryGetValue(effect.EffectType, out StatusEffect existing))
|
||||
{
|
||||
existing.OnStack();
|
||||
|
||||
@@ -44,16 +44,16 @@ namespace BaseGames.Core.Assets
|
||||
// ── Config ScriptableObjects ─────────────────────────────────────
|
||||
public const string DataFootstepCatalog = "Config/FootstepCatalog";
|
||||
|
||||
/// <summary>
|
||||
/// Addressable 标签常量(用于批量加载)。
|
||||
/// 注意:这里是标签名称而非资产地址,不会被 AddressKeyValidator 校验。
|
||||
/// </summary>
|
||||
public static class Labels
|
||||
{
|
||||
public const string Enemy = "Enemy";
|
||||
public const string Poolable = "Poolable";
|
||||
public const string BGM = "BGM";
|
||||
public const string Charms = "Charms";
|
||||
}
|
||||
/// <summary>
|
||||
/// Addressable 标签常量(用于批量加载)。
|
||||
/// 注意:这里是标签名称而非资产地址,不会被 AddressKeyValidator 校验。
|
||||
/// </summary>
|
||||
public static class Labels
|
||||
{
|
||||
public const string Enemy = "Enemy";
|
||||
public const string Poolable = "Poolable";
|
||||
public const string BGM = "BGM";
|
||||
public const string Charms = "Charms";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,12 +42,7 @@ namespace BaseGames.Core
|
||||
{
|
||||
yield return new WaitForSeconds(_deathAnimDuration);
|
||||
yield return new WaitForSeconds(_deathScreenDelay);
|
||||
|
||||
// 局部订阅确认事件,不依赖类级 bool 字段
|
||||
bool confirmed = false;
|
||||
var sub = _onDeathScreenConfirmed?.Subscribe(() => confirmed = true);
|
||||
yield return new WaitUntil(() => confirmed);
|
||||
sub?.Dispose();
|
||||
// 确认等待由 GameManager.DeathFlow 统一处理,此处仅负责动画延迟
|
||||
}
|
||||
|
||||
public IEnumerator StartRespawnCoroutine()
|
||||
|
||||
@@ -62,6 +62,7 @@ namespace BaseGames.Core
|
||||
/// <summary>场景/房间标识符,对应 RoomEnteredCondition.sceneName。与 Unity Build Settings 场景名对齐。</summary>
|
||||
public static class Scene
|
||||
{
|
||||
public const string MainMenu = "Scene_MainMenu"; // 与 AddressKeys.SceneMainMenu 对齐
|
||||
public const string Forest = "Scene_Forest";
|
||||
public const string Cave = "Scene_Cave";
|
||||
public const string Castle = "Scene_Castle";
|
||||
|
||||
@@ -19,6 +19,8 @@ namespace BaseGames.Core
|
||||
public bool FullScreen = true;
|
||||
|
||||
public string Language = "zh-CN";
|
||||
|
||||
public bool ShowSpeedrunTimer = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -55,6 +57,7 @@ namespace BaseGames.Core
|
||||
TargetFPS = DefaultTargetFPS,
|
||||
FullScreen = DefaultFullScreen,
|
||||
Language = DefaultLanguage,
|
||||
ShowSpeedrunTimer = ShowSpeedrunTimer,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,37 +9,21 @@ using BaseGames.Core.Events;
|
||||
namespace BaseGames.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// Addressables 场景加载器。
|
||||
/// 监听 EVT_SceneLoadRequest,Additive 加载指定场景,完成后发布 EVT_SceneLoaded。
|
||||
/// 完整实现由 SceneService 包装调用。
|
||||
/// Addressables 场景加载器(纯工具组件,由 SceneService 驱动)。
|
||||
/// 采用"先加载新、再卸载旧"策略,保证加载失败时旧场景仍可用。
|
||||
/// 加载完成后发布 EVT_SceneLoaded 事件。
|
||||
/// 不直接订阅 SceneLoadRequestEventChannelSO;事件分发由 SceneService 负责。
|
||||
/// </summary>
|
||||
[DefaultExecutionOrder(-950)]
|
||||
public class SceneLoader : MonoBehaviour
|
||||
{
|
||||
[Header("Event Channels - Listen")]
|
||||
[SerializeField] private SceneLoadRequestEventChannelSO _onSceneLoadRequest;
|
||||
|
||||
[Header("Event Channels - Raise")]
|
||||
[SerializeField] private StringEventChannelSO _onSceneLoaded;
|
||||
|
||||
private string _currentRoomScene;
|
||||
private AsyncOperationHandle<SceneInstance> _currentHandle;
|
||||
private readonly CompositeDisposable _subs = new();
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
_onSceneLoadRequest?.Subscribe(HandleRequest).AddTo(_subs);
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
_subs.Clear();
|
||||
}
|
||||
|
||||
private void HandleRequest(SceneLoadRequest request)
|
||||
=> StartCoroutine(LoadSceneCoroutine(request));
|
||||
|
||||
private IEnumerator LoadSceneCoroutine(SceneLoadRequest request)
|
||||
public IEnumerator LoadSceneCoroutine(SceneLoadRequest request)
|
||||
{
|
||||
// 先加载新场景(Additive),成功后再卸载旧场景
|
||||
// 顺序保证:若加载失败,旧场景仍保持可用,不会出现无场景的空状态
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using System.Collections;
|
||||
using UnityEngine;
|
||||
using UnityEngine.SceneManagement;
|
||||
using BaseGames.Core.Assets;
|
||||
using BaseGames.Core.Events;
|
||||
|
||||
namespace BaseGames.Core
|
||||
@@ -28,13 +28,12 @@ namespace BaseGames.Core
|
||||
[SerializeField] private SceneLoadRequestEventChannelSO _onSceneLoadRequest;
|
||||
|
||||
[Header("Event Channels - Raise")]
|
||||
[SerializeField] private StringEventChannelSO _onSceneLoaded;
|
||||
[SerializeField] private VoidEventChannelSO _onFadeInRequest;
|
||||
[SerializeField] private VoidEventChannelSO _onFadeOutRequest;
|
||||
[SerializeField] private VoidEventChannelSO _onFadeInRequest;
|
||||
[SerializeField] private VoidEventChannelSO _onFadeOutRequest;
|
||||
|
||||
[SerializeField] private SceneLoader _sceneLoader;
|
||||
[SerializeField] private float _fadeDuration = 0.3f;
|
||||
|
||||
private string _currentRoomScene;
|
||||
private readonly CompositeDisposable _subscriptions = new();
|
||||
|
||||
private void OnEnable()
|
||||
@@ -52,33 +51,25 @@ namespace BaseGames.Core
|
||||
_onFadeOutRequest?.Raise();
|
||||
yield return new WaitForSeconds(_fadeDuration);
|
||||
|
||||
if (!string.IsNullOrEmpty(_currentRoomScene))
|
||||
{
|
||||
var unload = SceneManager.UnloadSceneAsync(_currentRoomScene);
|
||||
yield return new WaitUntil(() => unload.isDone);
|
||||
}
|
||||
if (_sceneLoader != null)
|
||||
yield return StartCoroutine(_sceneLoader.LoadSceneCoroutine(request));
|
||||
else
|
||||
Debug.LogError("[SceneService] _sceneLoader 未赋值,场景加载中断。请在 Inspector 中绑定 SceneLoader 组件。");
|
||||
|
||||
var load = SceneManager.LoadSceneAsync(request.SceneName, LoadSceneMode.Additive);
|
||||
yield return new WaitUntil(() => load.isDone);
|
||||
|
||||
_currentRoomScene = request.SceneName;
|
||||
_onSceneLoaded?.Raise(request.SceneName);
|
||||
_onFadeInRequest?.Raise();
|
||||
}
|
||||
|
||||
public IEnumerator UnloadCurrentRoomCoroutine()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_currentRoomScene)) yield break;
|
||||
var op = SceneManager.UnloadSceneAsync(_currentRoomScene);
|
||||
yield return new WaitUntil(() => op.isDone);
|
||||
_currentRoomScene = null;
|
||||
if (_sceneLoader != null)
|
||||
yield return StartCoroutine(_sceneLoader.UnloadCurrentCoroutine());
|
||||
}
|
||||
|
||||
public IEnumerator LoadMainMenuCoroutine()
|
||||
{
|
||||
yield return LoadSceneCoroutine(new SceneLoadRequest
|
||||
{
|
||||
SceneName = "MainMenu",
|
||||
SceneName = AddressKeys.SceneMainMenu,
|
||||
EntryTransitionId = null,
|
||||
ShowLoadingScreen = false,
|
||||
IsRespawn = false
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"BaseGames.Core",
|
||||
"BaseGames.Core.Events",
|
||||
"BaseGames.Input",
|
||||
"BaseGames.Localization",
|
||||
"BaseGames.World",
|
||||
"Unity.TextMeshPro",
|
||||
"Unity.InputSystem"
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Text;
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using BaseGames.Localization;
|
||||
|
||||
namespace BaseGames.Dialogue
|
||||
{
|
||||
@@ -19,6 +20,7 @@ namespace BaseGames.Dialogue
|
||||
[SerializeField] private GameObject _speakerNamePanel; // 无名称时隐藏整个名称框
|
||||
[SerializeField] private GameObject _continuePrompt; // "▼" 图标,打字完成后显示
|
||||
[SerializeField] private Image _speakerPortrait; // 角色头像框
|
||||
[SerializeField] private AudioSource _voiceSource; // 语音播放源(可不配置)
|
||||
|
||||
private Coroutine _typingCoroutine;
|
||||
private DialogueLine _currentLine;
|
||||
@@ -40,7 +42,7 @@ namespace BaseGames.Dialogue
|
||||
bool hasSpeaker = !string.IsNullOrEmpty(line.speakerNameKey);
|
||||
if (_speakerNamePanel != null) _speakerNamePanel.SetActive(hasSpeaker);
|
||||
if (hasSpeaker && _speakerNameText != null)
|
||||
_speakerNameText.text = line.speakerNameKey;
|
||||
_speakerNameText.text = LocalizationManager.Get(line.speakerNameKey, "Dialogue");
|
||||
|
||||
// 头像
|
||||
if (_speakerPortrait != null)
|
||||
@@ -49,6 +51,17 @@ namespace BaseGames.Dialogue
|
||||
if (line.portraitSprite != null) _speakerPortrait.sprite = line.portraitSprite;
|
||||
}
|
||||
|
||||
// 语音播放
|
||||
if (_voiceSource != null)
|
||||
{
|
||||
_voiceSource.Stop();
|
||||
if (line.voiceClip != null)
|
||||
{
|
||||
_voiceSource.clip = line.voiceClip;
|
||||
_voiceSource.Play();
|
||||
}
|
||||
}
|
||||
|
||||
// 开始打字机协程
|
||||
if (_typingCoroutine != null) StopCoroutine(_typingCoroutine);
|
||||
_typingCoroutine = StartCoroutine(TypeLine(line));
|
||||
@@ -63,8 +76,9 @@ namespace BaseGames.Dialogue
|
||||
StopCoroutine(_typingCoroutine);
|
||||
_typingCoroutine = null;
|
||||
}
|
||||
_voiceSource?.Stop();
|
||||
if (_dialogueText != null)
|
||||
_dialogueText.text = _currentLine.textKey ?? "";
|
||||
_dialogueText.text = LocalizationManager.Get(_currentLine.textKey ?? "", "Dialogue");
|
||||
IsTyping = false;
|
||||
if (_continuePrompt != null) _continuePrompt.SetActive(true);
|
||||
}
|
||||
@@ -81,7 +95,7 @@ namespace BaseGames.Dialogue
|
||||
{
|
||||
IsTyping = true;
|
||||
float delay = line.typewriterDelay > 0f ? line.typewriterDelay : DefaultTypewriterDelay;
|
||||
string text = line.textKey ?? "";
|
||||
string text = LocalizationManager.Get(line.textKey ?? "", "Dialogue");
|
||||
|
||||
// 性能:StringBuilder 避免每帧字符串分配(O(n²) → O(n))
|
||||
var sb = new StringBuilder(text.Length);
|
||||
|
||||
@@ -132,11 +132,10 @@ namespace BaseGames.Editor
|
||||
AssignAsset(gameManager, "_onPlayerRespawned", report, false, "EVT_PlayerRespawned", "EVT_PlayerRespawn");
|
||||
|
||||
AssignAsset(sceneService, "_onSceneLoadRequest", report, false, "EVT_SceneLoadRequest");
|
||||
AssignAsset(sceneService, "_onSceneLoaded", report, false, "EVT_SceneLoaded");
|
||||
AssignAsset(sceneService, "_onFadeInRequest", report, false, "EVT_FadeInRequest");
|
||||
AssignAsset(sceneService, "_onFadeOutRequest", report, false, "EVT_FadeOutRequest");
|
||||
AssignReference(sceneService, "_sceneLoader", sceneLoader);
|
||||
|
||||
AssignAsset(sceneLoader, "_onSceneLoadRequest", report, false, "EVT_SceneLoadRequest");
|
||||
AssignAsset(sceneLoader, "_onSceneLoaded", report, false, "EVT_SceneLoaded");
|
||||
|
||||
AssignAsset(deathRespawnService, "_onRespawnStarted", report, false, "EVT_RespawnStarted");
|
||||
|
||||
@@ -43,8 +43,6 @@ namespace BaseGames.EventChain
|
||||
#endif
|
||||
|
||||
private readonly HashSet<string> _completedChains = new();
|
||||
/// <summary>当帧内有任意事件触发时置 true,Update 中合并为单次评估,避免同帧多事件重复遍历。</summary>
|
||||
private bool _evaluatePending;
|
||||
private readonly CompositeDisposable _subs = new();
|
||||
|
||||
// ── 生命周期 ──────────────────────────────────────────────────────
|
||||
@@ -90,13 +88,8 @@ namespace BaseGames.EventChain
|
||||
}
|
||||
|
||||
// ── 评估逻辑 ──────────────────────────────────────────────────────
|
||||
private void EvaluateAll() => _evaluatePending = true;
|
||||
private void Update()
|
||||
{
|
||||
if (!_evaluatePending) return;
|
||||
_evaluatePending = false;
|
||||
DoEvaluateAll();
|
||||
}
|
||||
/// <summary>收到新事件时立即评估所有链条件(无帧延迟)。</summary>
|
||||
private void EvaluateAll() => DoEvaluateAll();
|
||||
|
||||
private void DoEvaluateAll()
|
||||
{
|
||||
|
||||
@@ -5,52 +5,29 @@ namespace BaseGames.Input
|
||||
/// <summary>
|
||||
/// 在运行时启用 InputReaderSO 的 ActionMap。
|
||||
/// 挂在 Persistent 场景的 InputReaderHolder 上。
|
||||
/// _inputReader 必须在 Inspector 中赋值,框架不提供运行时自动查找回退。
|
||||
/// </summary>
|
||||
public sealed class InputReaderBootstrap : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private InputReaderSO _inputReader;
|
||||
|
||||
private void OnEnable()
|
||||
private void Awake()
|
||||
{
|
||||
|
||||
|
||||
if (_inputReader == null)
|
||||
{
|
||||
_inputReader = FindDefaultInputReader();
|
||||
if (_inputReader == null)
|
||||
Debug.LogError("[InputReaderBootstrap] Could not find InputReaderSO by name or assignment!");
|
||||
|
||||
}
|
||||
Debug.Assert(_inputReader != null,
|
||||
"[InputReaderBootstrap] _inputReader 未在 Inspector 中赋值!请在 Persistent 场景的 InputReaderHolder 上手动指定 InputReaderSO 资产。",
|
||||
this);
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
|
||||
if (_inputReader != null)
|
||||
{
|
||||
_inputReader.LoadBindingOverrides(); // 从 PlayerPrefs 恢复用户自定义绑定
|
||||
_inputReader.EnableGameplayInput();
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogError("[InputReaderBootstrap.Start] _inputReader is NULL!");
|
||||
}
|
||||
if (_inputReader == null) return;
|
||||
_inputReader.LoadBindingOverrides(); // 从 PlayerPrefs 恢复用户自定义绑定
|
||||
_inputReader.EnableGameplayInput();
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
_inputReader?.DisableAllInput();
|
||||
}
|
||||
|
||||
private static InputReaderSO FindDefaultInputReader()
|
||||
{
|
||||
InputReaderSO[] readers = Resources.FindObjectsOfTypeAll<InputReaderSO>();
|
||||
foreach (InputReaderSO reader in readers)
|
||||
{
|
||||
if (reader != null && reader.name == "InputReader")
|
||||
return reader;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,12 +9,13 @@
|
||||
// ]
|
||||
// }
|
||||
//
|
||||
// 用法(静态 Facade,保持调用兼容):
|
||||
// LocalizationManager.Get("ui_start") → "开始游戏"
|
||||
// LocalizationManager.Get("dlg_hero", "Dialogue") → Dialogue 表中的对应文本
|
||||
//
|
||||
// 服务调用(通过 ServiceLocator):
|
||||
// 推荐用法(通过 ServiceLocator 获取 ILocalizationService 实例):
|
||||
// ServiceLocator.GetOrDefault<ILocalizationService>()?.Get("ui_start")
|
||||
// ServiceLocator.GetOrDefault<ILocalizationService>()?.SetLanguage(Language.English)
|
||||
//
|
||||
// 便捷静态方法(内部仍走 ServiceLocator,推荐在热路径之外使用):
|
||||
// LocalizationManager.Get("ui_start")
|
||||
// LocalizationManager.Get("dlg_hero", "Dialogue")
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
@@ -28,7 +29,6 @@ namespace BaseGames.Localization
|
||||
/// 本地化管理器(MonoBehaviour,挂在 Persistent 场景)。
|
||||
/// 实现 ILocalizationService + ISaveable,通过 ServiceLocator 注册。
|
||||
/// 语言偏好持久化到 SaveData.Settings.Language,不使用 PlayerPrefs。
|
||||
/// 保留静态 Get() Facade,现有调用方无需修改。
|
||||
/// </summary>
|
||||
public class LocalizationManager : MonoBehaviour, ILocalizationService, ISaveable
|
||||
{
|
||||
@@ -39,15 +39,12 @@ namespace BaseGames.Localization
|
||||
// 双层缓存:languageKey("ChineseSimplified/UI") → (key → value)
|
||||
private readonly Dictionary<string, Dictionary<string, string>> _cache = new();
|
||||
|
||||
// ── 静态事件代理(向后兼容静态订阅方式)─────────────────────────────────
|
||||
/// <summary>语言切换时触发(静态代理,可通过 LocalizationManager.OnLanguageChanged += 订阅)。</summary>
|
||||
public static event Action<Language> OnLanguageChanged;
|
||||
|
||||
// ILocalizationService 显式实现:委托给静态事件
|
||||
// ILocalizationService 实例事件
|
||||
private event Action<Language> _onLanguageChanged;
|
||||
event Action<Language> ILocalizationService.OnLanguageChanged
|
||||
{
|
||||
add { OnLanguageChanged += value; }
|
||||
remove { OnLanguageChanged -= value; }
|
||||
add => _onLanguageChanged += value;
|
||||
remove => _onLanguageChanged -= value;
|
||||
}
|
||||
|
||||
// ── 生命周期 ──────────────────────────────────────────────────────────
|
||||
@@ -80,7 +77,7 @@ namespace BaseGames.Localization
|
||||
{
|
||||
if (_currentLanguage == language) return;
|
||||
_currentLanguage = language;
|
||||
OnLanguageChanged?.Invoke(language);
|
||||
_onLanguageChanged?.Invoke(language);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -118,7 +115,7 @@ namespace BaseGames.Localization
|
||||
SetLanguage(lang);
|
||||
}
|
||||
|
||||
// ── 静态 Facade(保持现有调用方不变)────────────────────────────────────
|
||||
// ── 静态便捷方法 ─────────────────────────────────────────────────────────
|
||||
/// <summary>
|
||||
/// 静态快捷获取本地化字符串。委托给 ILocalizationService 实例;服务未注册时直接返回 key。
|
||||
/// </summary>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using UnityEngine;
|
||||
using BaseGames.Core;
|
||||
using BaseGames.Core.Events;
|
||||
using BaseGames.Core.Save;
|
||||
|
||||
namespace BaseGames.Progression
|
||||
{
|
||||
@@ -9,7 +10,7 @@ namespace BaseGames.Progression
|
||||
/// 单向/永久性阻挡,需满足特定条件(击败 Boss)才能解锁。
|
||||
/// 通过 ServiceLocator.GetOrDefault<SaveManager>() 读取进度,订阅 BossDefeated 事件实时响应。
|
||||
/// </summary>
|
||||
public class ProgressLock : MonoBehaviour
|
||||
public class ProgressLock : MonoBehaviour, ISaveable
|
||||
{
|
||||
[Header("解锁条件")]
|
||||
[SerializeField] private string _requiredBossId; // 空 = 不检查 Boss
|
||||
@@ -27,6 +28,17 @@ namespace BaseGames.Progression
|
||||
[SerializeField] private StringEventChannelSO _onBossDefeated; // EVT_BossDefeated
|
||||
|
||||
private readonly CompositeDisposable _subs = new();
|
||||
private bool _isUnlocked;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
ServiceLocator.GetOrDefault<ISaveableRegistry>()?.Register(this);
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
ServiceLocator.GetOrDefault<ISaveableRegistry>()?.Unregister(this);
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
@@ -49,6 +61,19 @@ namespace BaseGames.Progression
|
||||
if (CheckUnlocked()) ApplyState(true);
|
||||
}
|
||||
|
||||
// ── ISaveable ─────────────────────────────────────────────────────────
|
||||
public void OnSave(SaveData data)
|
||||
{
|
||||
if (_isUnlocked && !string.IsNullOrEmpty(_lockId)
|
||||
&& !data.World.OpenedDoors.Contains(_lockId))
|
||||
data.World.OpenedDoors.Add(_lockId);
|
||||
}
|
||||
|
||||
public void OnLoad(SaveData data)
|
||||
{
|
||||
// 状态由 CheckUnlocked() 读取 IsDoorOpened(从 SaveData.OpenedDoors),应于 Start() 和 OnBossDefeated() 重新评估
|
||||
}
|
||||
|
||||
private bool CheckUnlocked()
|
||||
{
|
||||
var sm = ServiceLocator.GetOrDefault<ISaveService>();
|
||||
@@ -62,6 +87,7 @@ namespace BaseGames.Progression
|
||||
|
||||
private void ApplyState(bool unlocked)
|
||||
{
|
||||
_isUnlocked = unlocked;
|
||||
if (_blockCollider != null) _blockCollider.enabled = !unlocked;
|
||||
if (_lockedVisuals != null) _lockedVisuals.SetActive(!unlocked);
|
||||
if (_unlockedVisuals != null) _unlockedVisuals.SetActive(unlocked);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.AddressableAssets;
|
||||
using UnityEngine.ResourceManagement.AsyncOperations;
|
||||
using BaseGames.Core;
|
||||
using BaseGames.Core.Events;
|
||||
using BaseGames.Enemies;
|
||||
@@ -25,7 +28,10 @@ namespace BaseGames.Challenge
|
||||
private int _remainingEnemies;
|
||||
private float _elapsedTime;
|
||||
private bool _isRunning;
|
||||
private bool _noHitViolated; // 架构 §12:requireNoHit 挑战是否被破坏
|
||||
private bool _noHitViolated;
|
||||
|
||||
// 预加载句柄(挑战开始前预热全部敌人资源,结束时释放)
|
||||
private readonly List<AsyncOperationHandle<GameObject>> _preloadHandles = new();
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
@@ -35,6 +41,7 @@ namespace BaseGames.Challenge
|
||||
private void OnDisable()
|
||||
{
|
||||
if (_player != null) _player.OnDamaged -= OnPlayerDamaged;
|
||||
ReleasePreloadedAssets();
|
||||
}
|
||||
|
||||
private void OnPlayerDamaged() => _noHitViolated = true;
|
||||
@@ -58,7 +65,51 @@ namespace BaseGames.Challenge
|
||||
_currentEncounterIndex = 0;
|
||||
_elapsedTime = 0f;
|
||||
_noHitViolated = false;
|
||||
SpawnWave(0);
|
||||
|
||||
// 预加载所有敌人资源,全部缓存就绪后再开始生成第一波
|
||||
PreloadEnemyAssets(() => SpawnWave(0));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 预加载本次挑战所有敌人的 Addressable 资源。
|
||||
/// 确保所有资源均已内存驻留后才调用 <paramref name="onComplete"/>.
|
||||
/// 无敌人配置时直接回调。
|
||||
/// </summary>
|
||||
private void PreloadEnemyAssets(Action onComplete)
|
||||
{
|
||||
var keys = new HashSet<string>();
|
||||
if (_challengeData.encounters != null)
|
||||
foreach (var enc in _challengeData.encounters)
|
||||
if (enc.enemies != null)
|
||||
foreach (var entry in enc.enemies)
|
||||
if (!string.IsNullOrEmpty(entry.enemyAddressKey))
|
||||
keys.Add(entry.enemyAddressKey);
|
||||
|
||||
if (keys.Count == 0)
|
||||
{
|
||||
onComplete?.Invoke();
|
||||
return;
|
||||
}
|
||||
|
||||
int remaining = keys.Count;
|
||||
foreach (var key in keys)
|
||||
{
|
||||
var handle = Addressables.LoadAssetAsync<GameObject>(key);
|
||||
_preloadHandles.Add(handle);
|
||||
handle.Completed += _ =>
|
||||
{
|
||||
if (--remaining == 0)
|
||||
onComplete?.Invoke();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>释放预加载的所有资源句柄。</summary>
|
||||
private void ReleasePreloadedAssets()
|
||||
{
|
||||
foreach (var h in _preloadHandles)
|
||||
if (h.IsValid()) Addressables.Release(h);
|
||||
_preloadHandles.Clear();
|
||||
}
|
||||
|
||||
private void SpawnWave(int index)
|
||||
@@ -77,7 +128,16 @@ namespace BaseGames.Challenge
|
||||
for (int i = 0; i < entry.count; i++)
|
||||
{
|
||||
_remainingEnemies++;
|
||||
Vector3 pos = entry.spawnPoint != null ? entry.spawnPoint.position : Vector3.zero;
|
||||
Vector3 pos;
|
||||
if (entry.spawnPoint != null)
|
||||
{
|
||||
pos = entry.spawnPoint.position;
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogWarning($"[ChallengeRoomManager] encounter[{index}] 中的 enemyAddressKey='{entry.enemyAddressKey}' 未配置 spawnPoint,将在 Vector3.zero 生成。请在 ChallengeRoomSO 中补全配置。", this);
|
||||
pos = Vector3.zero;
|
||||
}
|
||||
Addressables.InstantiateAsync(entry.enemyAddressKey, pos, Quaternion.identity)
|
||||
.Completed += handle =>
|
||||
{
|
||||
|
||||
@@ -13,7 +13,7 @@ namespace BaseGames.Quest
|
||||
[Header("标识")]
|
||||
public string objectiveId;
|
||||
[TextArea(1, 4)]
|
||||
public string displayText; // 任务日志中显示的文本
|
||||
public string displayTextKey; // 本地化 key(通过 LocalizationManager.Get(displayTextKey, "Quest") 显示)
|
||||
public bool IsOptional; // 可选目标(完成加奖励但不阻塞任务)
|
||||
|
||||
/// <summary>根据当前进度判断目标是否完成。</summary>
|
||||
|
||||
@@ -1,46 +1,49 @@
|
||||
using UnityEngine;
|
||||
using BaseGames.Core.Events;
|
||||
|
||||
/// <summary>
|
||||
/// 无障碍设置数据 SO(架构 16_SupportingModules §6)。
|
||||
/// 包含屏幕抖动、闪光减弱、色盲模式等辅助功能开关。
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "Settings/AccessibilitySettings", fileName = "SET_Accessibility")]
|
||||
public class AccessibilitySettingsSO : ScriptableObject
|
||||
namespace BaseGames.Support.Accessibility
|
||||
{
|
||||
private const string PrefsPrefix = "accessibility_";
|
||||
|
||||
[Header("屏幕体感")]
|
||||
public bool ScreenShake = true;
|
||||
public bool ReducedFlash = false;
|
||||
|
||||
[Header("色盲模式")]
|
||||
public ColorblindMode ColorblindMode = ColorblindMode.None;
|
||||
|
||||
[Header("文字与 UI")]
|
||||
public bool LargeText = false;
|
||||
public bool HighContrast = false;
|
||||
public float UIScale = 1f;
|
||||
|
||||
// ── 持久化 ──────────────────────────────────────────────────────────────
|
||||
public void Save()
|
||||
/// <summary>
|
||||
/// 无障碍设置数据 SO(架构 16_SupportingModules §6)。
|
||||
/// 包含屏幕抖动、闪光减弱、色盲模式等辅助功能开关。
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "Settings/AccessibilitySettings", fileName = "SET_Accessibility")]
|
||||
public class AccessibilitySettingsSO : ScriptableObject
|
||||
{
|
||||
PlayerPrefs.SetInt (PrefsPrefix + "ScreenShake", ScreenShake ? 1 : 0);
|
||||
PlayerPrefs.SetInt (PrefsPrefix + "ReducedFlash", ReducedFlash ? 1 : 0);
|
||||
PlayerPrefs.SetInt (PrefsPrefix + "ColorblindMode",(int)ColorblindMode);
|
||||
PlayerPrefs.SetInt (PrefsPrefix + "LargeText", LargeText ? 1 : 0);
|
||||
PlayerPrefs.SetInt (PrefsPrefix + "HighContrast", HighContrast ? 1 : 0);
|
||||
PlayerPrefs.SetFloat (PrefsPrefix + "UIScale", UIScale);
|
||||
PlayerPrefs.Save();
|
||||
}
|
||||
private const string PrefsPrefix = "accessibility_";
|
||||
|
||||
public void Load()
|
||||
{
|
||||
ScreenShake = PlayerPrefs.GetInt (PrefsPrefix + "ScreenShake", 1) == 1;
|
||||
ReducedFlash = PlayerPrefs.GetInt (PrefsPrefix + "ReducedFlash", 0) == 1;
|
||||
ColorblindMode = (ColorblindMode)PlayerPrefs.GetInt(PrefsPrefix + "ColorblindMode", 0);
|
||||
LargeText = PlayerPrefs.GetInt (PrefsPrefix + "LargeText", 0) == 1;
|
||||
HighContrast = PlayerPrefs.GetInt (PrefsPrefix + "HighContrast", 0) == 1;
|
||||
UIScale = PlayerPrefs.GetFloat(PrefsPrefix + "UIScale", 1f);
|
||||
[Header("屏幕体感")]
|
||||
public bool ScreenShake = true;
|
||||
public bool ReducedFlash = false;
|
||||
|
||||
[Header("色盲模式")]
|
||||
public ColorblindMode ColorblindMode = ColorblindMode.None;
|
||||
|
||||
[Header("文字与 UI")]
|
||||
public bool LargeText = false;
|
||||
public bool HighContrast = false;
|
||||
public float UIScale = 1f;
|
||||
|
||||
// ── 持久化 ──────────────────────────────────────────────────────────────
|
||||
public void Save()
|
||||
{
|
||||
PlayerPrefs.SetInt (PrefsPrefix + "ScreenShake", ScreenShake ? 1 : 0);
|
||||
PlayerPrefs.SetInt (PrefsPrefix + "ReducedFlash", ReducedFlash ? 1 : 0);
|
||||
PlayerPrefs.SetInt (PrefsPrefix + "ColorblindMode",(int)ColorblindMode);
|
||||
PlayerPrefs.SetInt (PrefsPrefix + "LargeText", LargeText ? 1 : 0);
|
||||
PlayerPrefs.SetInt (PrefsPrefix + "HighContrast", HighContrast ? 1 : 0);
|
||||
PlayerPrefs.SetFloat (PrefsPrefix + "UIScale", UIScale);
|
||||
PlayerPrefs.Save();
|
||||
}
|
||||
|
||||
public void Load()
|
||||
{
|
||||
ScreenShake = PlayerPrefs.GetInt (PrefsPrefix + "ScreenShake", 1) == 1;
|
||||
ReducedFlash = PlayerPrefs.GetInt (PrefsPrefix + "ReducedFlash", 0) == 1;
|
||||
ColorblindMode = (ColorblindMode)PlayerPrefs.GetInt(PrefsPrefix + "ColorblindMode", 0);
|
||||
LargeText = PlayerPrefs.GetInt (PrefsPrefix + "LargeText", 0) == 1;
|
||||
HighContrast = PlayerPrefs.GetInt (PrefsPrefix + "HighContrast", 0) == 1;
|
||||
UIScale = PlayerPrefs.GetFloat(PrefsPrefix + "UIScale", 1f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ using UnityEngine.Rendering;
|
||||
using UnityEngine.Rendering.Universal;
|
||||
using BaseGames.Core.Events;
|
||||
|
||||
namespace BaseGames.Support.Accessibility
|
||||
{
|
||||
/// <summary>
|
||||
/// 色盲滤镜 URP Renderer Feature(架构 16_SupportingModules §6.2)。
|
||||
/// 通过颜色矩阵将画面转换到对应色盲友好的色彩空间。
|
||||
@@ -135,3 +137,4 @@ public class ColorBlindFilter : ScriptableRendererFeature
|
||||
new Vector4(0, 0, 0, 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ using BaseGames.World;
|
||||
using BaseGames.Player;
|
||||
using BaseGames.Core.Save;
|
||||
|
||||
namespace BaseGames.Progression
|
||||
namespace BaseGames.Support.AntiSoftlock
|
||||
{
|
||||
/// <summary>
|
||||
/// 硬性能力门禁(架构 16_SupportingModules §5.3)。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using UnityEngine;
|
||||
using BaseGames.Player;
|
||||
|
||||
namespace BaseGames.Progression
|
||||
namespace BaseGames.Support.AntiSoftlock
|
||||
{
|
||||
/// <summary>
|
||||
/// 房间逃脱信息 SO(架构 16_SupportingModules §5.2)。
|
||||
|
||||
@@ -30,6 +30,16 @@ namespace BaseGames.Tutorial
|
||||
BaseGames.Core.ServiceLocator.Unregister<ITutorialService>(this);
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
BaseGames.Core.ServiceLocator.GetOrDefault<BaseGames.Core.Save.ISaveableRegistry>()?.Register(this);
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
BaseGames.Core.ServiceLocator.GetOrDefault<BaseGames.Core.Save.ISaveableRegistry>()?.Unregister(this);
|
||||
}
|
||||
|
||||
// ── 公共 API ──────────────────────────────────────────────────────
|
||||
/// <summary>
|
||||
/// 显示提示。若 hintId 已完成则忽略。
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"BaseGames.Core.Save",
|
||||
"BaseGames.Input",
|
||||
"BaseGames.Combat",
|
||||
"BaseGames.Localization",
|
||||
"Unity.TextMeshPro",
|
||||
"Unity.InputSystem",
|
||||
"BaseGames.Equipment"
|
||||
|
||||
@@ -55,8 +55,8 @@ namespace BaseGames.UI
|
||||
? _parentCanvas.worldCamera
|
||||
: UnityEngine.Camera.main;
|
||||
|
||||
var screenPoint = UnityEngine.Camera.main != null
|
||||
? (Vector2)UnityEngine.Camera.main.WorldToScreenPoint(worldPosition)
|
||||
var screenPoint = cam != null
|
||||
? (Vector2)cam.WorldToScreenPoint(worldPosition)
|
||||
: Vector2.zero;
|
||||
|
||||
var canvasRect = _parentCanvas != null
|
||||
|
||||
@@ -29,8 +29,8 @@ namespace BaseGames.UI
|
||||
private void SwitchIconSet(bool isGamepad)
|
||||
{
|
||||
Current = isGamepad ? _padIconSet : _kbIconSet;
|
||||
// 通知所有图标 Image 刷新
|
||||
foreach (var img in GetComponentsInChildren<InputIconImage>(includeInactive: true))
|
||||
// 通知场景内所有图标 Image 刷新(包括非本对象子节点的其他 Canvas 区域)
|
||||
foreach (var img in FindObjectsByType<InputIconImage>(FindObjectsInactive.Include, FindObjectsSortMode.None))
|
||||
img.Refresh();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,12 @@ using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using TMPro;
|
||||
using BaseGames.Core.Events;
|
||||
using BaseGames.Localization;
|
||||
|
||||
namespace BaseGames.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// 全屏加载界面:进度条 + 提示文字 + 随机背景图(架构 10_UIModule §7.7)。
|
||||
/// 注:提示文字直接使用字符串 key(P4-5 本地化模块完成后替换为 LocalizationManager.Get)。
|
||||
/// </summary>
|
||||
public class LoadingScreenManager : MonoBehaviour
|
||||
{
|
||||
@@ -16,7 +16,7 @@ namespace BaseGames.UI
|
||||
[SerializeField] private Image _progressFill;
|
||||
[SerializeField] private TMP_Text _tipText;
|
||||
[SerializeField] private Image[] _backgroundArts;
|
||||
[SerializeField] private string[] _tipMessages; // 直接文字(非本地化 key)
|
||||
[SerializeField] private string[] _tipMessages; // 本地化 key(对应 "UI" 表中的条目,如 "tip_explore")
|
||||
[SerializeField] private float _minDisplayTime = 0.5f;
|
||||
|
||||
[Header("Event Channels")]
|
||||
@@ -25,18 +25,17 @@ namespace BaseGames.UI
|
||||
[SerializeField] private FloatEventChannelSO _onLoadingProgressUpdated;
|
||||
|
||||
private float _shownAt;
|
||||
private readonly CompositeDisposable _subs = new();
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
if (_onLoadingStarted != null) _onLoadingStarted.OnEventRaised += Show;
|
||||
if (_onLoadingComplete != null) _onLoadingComplete.OnEventRaised += Hide;
|
||||
if (_onLoadingProgressUpdated != null) _onLoadingProgressUpdated.OnEventRaised += SetProgress;
|
||||
_onLoadingStarted?.Subscribe(Show).AddTo(_subs);
|
||||
_onLoadingComplete?.Subscribe(Hide).AddTo(_subs);
|
||||
_onLoadingProgressUpdated?.Subscribe(SetProgress).AddTo(_subs);
|
||||
}
|
||||
private void OnDisable()
|
||||
{
|
||||
if (_onLoadingStarted != null) _onLoadingStarted.OnEventRaised -= Show;
|
||||
if (_onLoadingComplete != null) _onLoadingComplete.OnEventRaised -= Hide;
|
||||
if (_onLoadingProgressUpdated != null) _onLoadingProgressUpdated.OnEventRaised -= SetProgress;
|
||||
_subs.Clear();
|
||||
}
|
||||
|
||||
// ── 公开 API(SceneLoader 可直接调用)────────────────────────────────
|
||||
@@ -54,9 +53,9 @@ namespace BaseGames.UI
|
||||
_backgroundArts[Random.Range(0, _backgroundArts.Length)].enabled = true;
|
||||
}
|
||||
|
||||
// 随机提示
|
||||
// 随机提示(通过 LocalizationManager 解析 key)
|
||||
if (_tipText != null && _tipMessages != null && _tipMessages.Length > 0)
|
||||
_tipText.text = _tipMessages[Random.Range(0, _tipMessages.Length)];
|
||||
_tipText.text = LocalizationManager.Get(_tipMessages[Random.Range(0, _tipMessages.Length)], "UI");
|
||||
}
|
||||
|
||||
public void Hide() => StartCoroutine(HideAfterMinTime());
|
||||
|
||||
@@ -13,15 +13,21 @@ namespace BaseGames.UI.Menus
|
||||
/// </summary>
|
||||
public class SaveSlotController : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private SaveSlotUI[] _slotUIs; // 3 个存档槽 UI
|
||||
[SerializeField] private SaveSlotUI[] _slotUIs; // 存档槽 UI(数量由 Inspector 决定)
|
||||
[SerializeField] private SaveManager _saveManager;
|
||||
|
||||
[Header("Event Channels")]
|
||||
[SerializeField] private IntEventChannelSO _onSlotConfirmed; // 携带槽索引,供 GameManager 监听
|
||||
|
||||
private async void OnEnable()
|
||||
private void OnEnable()
|
||||
{
|
||||
await RefreshAsync();
|
||||
var task = RefreshAsync();
|
||||
// 捕获 async Task 异常,避免 async void 吞掉未处理异常
|
||||
task.ContinueWith(t =>
|
||||
{
|
||||
if (t.IsFaulted)
|
||||
Debug.LogException(t.Exception?.InnerException ?? t.Exception, this);
|
||||
}, TaskScheduler.FromCurrentSynchronizationContext());
|
||||
}
|
||||
|
||||
private async Task RefreshAsync()
|
||||
@@ -38,7 +44,7 @@ namespace BaseGames.UI.Menus
|
||||
/// <summary>选中指定槽位(新局或继续)。由 SaveSlotUI 内部按钮调用。</summary>
|
||||
public void OnSlotSelected(int slotIndex)
|
||||
{
|
||||
if (slotIndex < 0 || slotIndex >= 3 || _saveManager == null) return;
|
||||
if (slotIndex < 0 || slotIndex >= _slotUIs.Length || _saveManager == null) return;
|
||||
_ = SelectSlotAsync(slotIndex);
|
||||
}
|
||||
|
||||
@@ -55,7 +61,7 @@ namespace BaseGames.UI.Menus
|
||||
/// <summary>删除指定槽位存档并刷新 UI。由 SaveSlotUI 内部按钮调用。</summary>
|
||||
public void OnSlotDeleteRequested(int slotIndex)
|
||||
{
|
||||
if (slotIndex < 0 || slotIndex >= 3 || _saveManager == null) return;
|
||||
if (slotIndex < 0 || slotIndex >= _slotUIs.Length || _saveManager == null) return;
|
||||
_ = DeleteAndRefreshAsync(slotIndex);
|
||||
}
|
||||
|
||||
|
||||
@@ -50,9 +50,13 @@ namespace BaseGames.UI
|
||||
{
|
||||
if (_deathScreenRoot != null) _deathScreenRoot.SetActive(true);
|
||||
}
|
||||
else if (state == GameStates.Cutscene)
|
||||
else
|
||||
{
|
||||
if (_hudRoot != null) _hudRoot.SetActive(false);
|
||||
// 离开 Dead 状态时(复活/重生)隐藏死亡界面
|
||||
if (_deathScreenRoot != null) _deathScreenRoot.SetActive(false);
|
||||
|
||||
if (state == GameStates.Cutscene)
|
||||
if (_hudRoot != null) _hudRoot.SetActive(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,8 @@ namespace BaseGames.World
|
||||
[SerializeField] private float _bounceForce = 5f;
|
||||
|
||||
[Header("事件频道")]
|
||||
[SerializeField] private StringEventChannelSO _onCollectiblePickup;
|
||||
[SerializeField] private StringEventChannelSO _onCollectiblePickup; // 道具获取(EVT_ItemPickup)
|
||||
[SerializeField] private StringEventChannelSO _onCollectibleSaved; // 持久化记录(EVT_CollectibleSaved)
|
||||
|
||||
private bool _collected;
|
||||
|
||||
@@ -64,7 +65,7 @@ namespace BaseGames.World
|
||||
}
|
||||
|
||||
if (_isPersistent && !string.IsNullOrEmpty(_collectibleId))
|
||||
_onCollectiblePickup?.Raise(_collectibleId);
|
||||
_onCollectibleSaved?.Raise(_collectibleId);
|
||||
|
||||
Despawn();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using BaseGames.Combat;
|
||||
using BaseGames.Core;
|
||||
using BaseGames.Core.Save;
|
||||
using MoreMountains.Feedbacks;
|
||||
using UnityEngine;
|
||||
|
||||
@@ -9,7 +11,7 @@ namespace BaseGames.World
|
||||
/// 揭示后禁用碰撞体(不销毁),状态持久化到 WorldSaveData。
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(Collider2D))]
|
||||
public class FalseWall : MonoBehaviour, IDamageable
|
||||
public class FalseWall : MonoBehaviour, IDamageable, ISaveable
|
||||
{
|
||||
public enum RevealCondition { Proximity, AttackOnce, AlwaysOpen }
|
||||
|
||||
@@ -39,17 +41,39 @@ namespace BaseGames.World
|
||||
|
||||
// ── Unity Lifecycle ───────────────────────────────────────────────────
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
ServiceLocator.GetOrDefault<ISaveableRegistry>()?.Register(this);
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
ServiceLocator.GetOrDefault<ISaveableRegistry>()?.Unregister(this);
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
if (_revealCondition == RevealCondition.AlwaysOpen)
|
||||
{
|
||||
SetPassThroughImmediate();
|
||||
_isRevealed = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 读档恢复(SaveManager 集成后接入 WorldSaveData.RevealedFalseWalls)
|
||||
// 示例:bool revealed = ServiceLocator.GetOrDefault<SaveManager>()?.CurrentSave?.World?.RevealedFalseWalls?.Contains(_wallId) ?? false;
|
||||
// if (revealed) SetPassThroughImmediate();
|
||||
// ── ISaveable ─────────────────────────────────────────────────────────
|
||||
public void OnSave(SaveData data)
|
||||
{
|
||||
if (_isRevealed && !string.IsNullOrEmpty(_wallId)
|
||||
&& !data.World.OpenedDoors.Contains(_wallId))
|
||||
data.World.OpenedDoors.Add(_wallId);
|
||||
}
|
||||
|
||||
public void OnLoad(SaveData data)
|
||||
{
|
||||
if (string.IsNullOrEmpty(_wallId)) return;
|
||||
_isRevealed = data.World.OpenedDoors.Contains(_wallId);
|
||||
if (_isRevealed) SetPassThroughImmediate();
|
||||
}
|
||||
|
||||
private void OnTriggerEnter2D(Collider2D other)
|
||||
|
||||
@@ -9,13 +9,8 @@ namespace BaseGames.World
|
||||
[RequireComponent(typeof(Collider2D))]
|
||||
public class HazardZone : MonoBehaviour
|
||||
{
|
||||
public enum RespawnType { AtLastSavePoint, AtRoomEntry }
|
||||
|
||||
[SerializeField] private bool _isInstantKill = true;
|
||||
[SerializeField] private int _damage = 9999;
|
||||
#pragma warning disable CS0414
|
||||
[SerializeField] private RespawnType _respawnType = RespawnType.AtLastSavePoint;
|
||||
#pragma warning restore CS0414
|
||||
[SerializeField] private bool _isInstantKill = true;
|
||||
[SerializeField] private int _damage = 9999;
|
||||
|
||||
private void OnTriggerEnter2D(Collider2D other)
|
||||
{
|
||||
|
||||
@@ -18,11 +18,7 @@ namespace BaseGames.World.Liquid
|
||||
[Header("区域标识(存档/跨系统识别)")]
|
||||
[SerializeField] private string _zoneId;
|
||||
|
||||
[Header("伤害(Water 类型专用;Acid/Lava 由子节点 HazardZone 处理)")]
|
||||
#pragma warning disable CS0414
|
||||
[SerializeField] private bool _dealsDrowningDamage = false;
|
||||
[SerializeField] private float _drowningDamagePerSecond = 5f;
|
||||
#pragma warning restore CS0414
|
||||
[Header("伤害(Acid/Lava 由子节点 HazardZone 处理;Water 类型溺死由 WaterDangerState 处理)")]
|
||||
|
||||
[Header("物理配置")]
|
||||
[SerializeField] private LiquidPhysicsConfigSO _physicsConfig;
|
||||
|
||||
@@ -31,7 +31,11 @@ namespace BaseGames.World.Liquid
|
||||
BlendVolume(1f, _blendInDuration);
|
||||
}
|
||||
|
||||
private void OnLiquidExited(LiquidEvent evt) => BlendVolume(0f, _blendOutDuration);
|
||||
private void OnLiquidExited(LiquidEvent evt)
|
||||
{
|
||||
if (evt.LiquidType != LiquidType.Water) return;
|
||||
BlendVolume(0f, _blendOutDuration);
|
||||
}
|
||||
|
||||
private void BlendVolume(float target, float duration)
|
||||
{
|
||||
|
||||
@@ -7,10 +7,14 @@ namespace BaseGames.World
|
||||
/// </summary>
|
||||
public class PhantomInteractable : DirectionalInteractable
|
||||
{
|
||||
private int _phantomBodyLayer;
|
||||
|
||||
private void Awake() => _phantomBodyLayer = LayerMask.NameToLayer("PhantomBody");
|
||||
|
||||
private void OnTriggerEnter2D(Collider2D other)
|
||||
{
|
||||
bool isPlayer = other.CompareTag("Player");
|
||||
bool isPhantom = other.gameObject.layer == LayerMask.NameToLayer("PhantomBody");
|
||||
bool isPhantom = other.gameObject.layer == _phantomBodyLayer;
|
||||
|
||||
if (!isPlayer && !isPhantom) return;
|
||||
TryActivate();
|
||||
|
||||
@@ -23,9 +23,16 @@ namespace BaseGames.Puzzle
|
||||
private bool _isActivated;
|
||||
public bool IsActivated => _isActivated;
|
||||
|
||||
protected virtual void Awake()
|
||||
{
|
||||
bool savedState = !string.IsNullOrEmpty(_receiverId)
|
||||
&& _worldState != null
|
||||
&& _worldState.HasFlag("receiver_" + _receiverId);
|
||||
_isActivated = savedState || _startsActivated;
|
||||
}
|
||||
|
||||
protected virtual void Start()
|
||||
{
|
||||
_isActivated = _startsActivated;
|
||||
if (_isActivated) OnActivate();
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,19 @@ namespace BaseGames.Puzzle
|
||||
public bool IsActive => _isActive;
|
||||
public event Action<bool> OnStateChanged;
|
||||
|
||||
private void Start() => _isActive = _startsActive;
|
||||
private void Awake()
|
||||
{
|
||||
bool savedState = !string.IsNullOrEmpty(_switchId)
|
||||
&& _worldState != null
|
||||
&& _worldState.HasFlag("switch_" + _switchId);
|
||||
_isActive = savedState || _startsActive;
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
if (_isActive && _activeClip != null) _animancer?.Play(_activeClip);
|
||||
else if (_inactiveClip != null) _animancer?.Play(_inactiveClip);
|
||||
}
|
||||
|
||||
// ── IInteractable ────────────────────────────────────────────────────
|
||||
public string InteractPrompt => _mode == SwitchTriggerMode.Hold ? "按住交互" : "交互";
|
||||
|
||||
@@ -76,10 +76,10 @@ namespace BaseGames.World.Shop
|
||||
return _availableItemsCache;
|
||||
}
|
||||
_availableItemsCache = _inventory.DefaultInventory
|
||||
.Take(_inventory.MaxDisplaySlots)
|
||||
.Where(item => item != null
|
||||
&& !_soldUniqueItems.Contains(item.ItemId)
|
||||
&& (item.MaxPurchaseCount < 0 || GetPurchaseCount(item.ItemId) < item.MaxPurchaseCount))
|
||||
.Take(_inventory.MaxDisplaySlots)
|
||||
.ToList();
|
||||
_isDirty = false;
|
||||
return _availableItemsCache;
|
||||
|
||||
@@ -3,6 +3,57 @@ using BaseGames.Equipment;
|
||||
|
||||
namespace BaseGames.World.Shop
|
||||
{
|
||||
// ─── 商品效果基类及子类 ──────────────────────────────────────────────────
|
||||
// 使用 [SerializeReference] 多态序列化:Inspector 中右键可选择具体效果类型,
|
||||
// 只显示该类型所需字段,消除原平铺字段方案中大量空字段的噪音。
|
||||
|
||||
[System.Serializable]
|
||||
public abstract class ShopItemEffect
|
||||
{
|
||||
public abstract ShopItemType Type { get; }
|
||||
}
|
||||
|
||||
[System.Serializable]
|
||||
public class HealthRestorationEffect : ShopItemEffect
|
||||
{
|
||||
public override ShopItemType Type => ShopItemType.HealthRestoration;
|
||||
/// <summary>恢复的 HP 量。</summary>
|
||||
public int Amount;
|
||||
}
|
||||
|
||||
[System.Serializable]
|
||||
public class CharmItemEffect : ShopItemEffect
|
||||
{
|
||||
public override ShopItemType Type => ShopItemType.CharmItem;
|
||||
/// <summary>购买后解锁/获得的护符。</summary>
|
||||
public CharmSO CharmReference;
|
||||
}
|
||||
|
||||
[System.Serializable]
|
||||
public class KeyItemEffect : ShopItemEffect
|
||||
{
|
||||
public override ShopItemType Type => ShopItemType.KeyItem;
|
||||
/// <summary>授予玩家的关键道具 ID(对应 GameIds.Collectible)。</summary>
|
||||
public string KeyItemId;
|
||||
}
|
||||
|
||||
[System.Serializable]
|
||||
public class ConsumableBuffEffect : ShopItemEffect
|
||||
{
|
||||
public override ShopItemType Type => ShopItemType.ConsumableBuff;
|
||||
// 可按需在此扩展 BuffId、Duration、Magnitude 等字段
|
||||
}
|
||||
|
||||
[System.Serializable]
|
||||
public class MapFragmentEffect : ShopItemEffect
|
||||
{
|
||||
public override ShopItemType Type => ShopItemType.MapFragment;
|
||||
/// <summary>购买后揭示的房间 ID(对应 MapManager.SetMapped)。</summary>
|
||||
public string RoomId;
|
||||
}
|
||||
|
||||
// ─── 商品 SO ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 商店单品 SO(架构 15_MapShopModule §2.1)。
|
||||
/// 资产路径: Assets/ScriptableObjects/Shop/Item_{ItemId}.asset
|
||||
@@ -20,15 +71,17 @@ namespace BaseGames.World.Shop
|
||||
[Header("价格")]
|
||||
public int BasePrice;
|
||||
public bool IsUnique; // 购买一次后永久从库存移除
|
||||
public int MaxPurchaseCount = -1; // -1 = 无限次
|
||||
|
||||
[Header("商品类型")]
|
||||
public ShopItemType ItemType;
|
||||
[Header("商品效果")]
|
||||
[SerializeReference]
|
||||
public ShopItemEffect Effect;
|
||||
|
||||
// 按 ItemType 填写以下字段(其余留空)
|
||||
public int HealthRestoreAmount; // HealthRestoration 类型
|
||||
public CharmSO CharmReference; // CharmItem 类型
|
||||
public string KeyItemId; // KeyItem 类型
|
||||
public int MaxPurchaseCount = -1; // -1 = 无限次
|
||||
/// <summary>
|
||||
/// 便捷属性:从 Effect 类型推导商品分类,供 ShopPanel/UI 按类型渲染图标或提示文字。
|
||||
/// Effect 为 null 时回退到 HealthRestoration(Inspector 未配置的保护值)。
|
||||
/// </summary>
|
||||
public ShopItemType ItemType => Effect?.Type ?? ShopItemType.HealthRestoration;
|
||||
}
|
||||
|
||||
public enum ShopItemType
|
||||
|
||||
8
Assets/Tests.meta
Normal file
8
Assets/Tests.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b32382b534d5f814995e293374c94561
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
Assets/Tests/EditMode.meta
Normal file
8
Assets/Tests/EditMode.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 657af9ec9b4e7434db638e61a16fbe8c
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
25
Assets/Tests/EditMode/BaseGames.Tests.EditMode.asmdef
Normal file
25
Assets/Tests/EditMode/BaseGames.Tests.EditMode.asmdef
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "BaseGames.Tests.EditMode",
|
||||
"rootNamespace": "BaseGames.Tests.EditMode",
|
||||
"references": [
|
||||
"BaseGames.Combat.StatusEffects",
|
||||
"BaseGames.Combat",
|
||||
"BaseGames.Core",
|
||||
"BaseGames.EventChain",
|
||||
"UnityEngine.TestRunner",
|
||||
"UnityEditor.TestRunner"
|
||||
],
|
||||
"includePlatforms": [
|
||||
"Editor"
|
||||
],
|
||||
"excludePlatforms": [],
|
||||
"allowUnsafeCode": false,
|
||||
"overrideReferences": true,
|
||||
"precompiledReferences": [
|
||||
"nunit.framework.dll"
|
||||
],
|
||||
"autoReferenced": false,
|
||||
"defineConstraints": [],
|
||||
"versionDefines": [],
|
||||
"noEngineReferences": false
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b3ad3b74ee771f140801100a62fc3ebc
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
133
Assets/Tests/EditMode/StatusEffectTests.cs
Normal file
133
Assets/Tests/EditMode/StatusEffectTests.cs
Normal file
@@ -0,0 +1,133 @@
|
||||
using NUnit.Framework;
|
||||
using BaseGames.Combat.StatusEffects;
|
||||
|
||||
namespace BaseGames.Tests.EditMode
|
||||
{
|
||||
/// <summary>
|
||||
/// StatusEffect 系统单元测试(EditMode,纯 C# 类,无需 MonoBehaviour)。
|
||||
/// 覆盖:叠加规则、互斥规则、阻断规则、到期检测。
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class StatusEffectTests
|
||||
{
|
||||
// ── 叠加规则 ─────────────────────────────────────────────────────────
|
||||
|
||||
[Test]
|
||||
public void FireEffect_MaxStacks_IsOne()
|
||||
{
|
||||
var effect = new FireEffect();
|
||||
Assert.AreEqual(1, effect.MaxStacks);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void PoisonEffect_MaxStacks_IsThree()
|
||||
{
|
||||
var effect = new PoisonEffect();
|
||||
Assert.AreEqual(3, effect.MaxStacks);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void PoisonEffect_OnStack_IncreasesStackCount()
|
||||
{
|
||||
var effect = new PoisonEffect();
|
||||
effect.OnStack();
|
||||
Assert.AreEqual(2, effect.StackCount);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void PoisonEffect_OnStack_ClampsAtMaxStacks()
|
||||
{
|
||||
var effect = new PoisonEffect();
|
||||
for (int i = 0; i < 10; i++) effect.OnStack();
|
||||
Assert.AreEqual(effect.MaxStacks, effect.StackCount);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void StaggerEffect_MaxStacks_IsOne()
|
||||
{
|
||||
var effect = new StaggerEffect();
|
||||
Assert.AreEqual(1, effect.MaxStacks);
|
||||
}
|
||||
|
||||
// ── 互斥规则 ─────────────────────────────────────────────────────────
|
||||
|
||||
[Test]
|
||||
public void FireEffect_MutualExclusions_ContainsFreeze()
|
||||
{
|
||||
var effect = new FireEffect();
|
||||
Assert.Contains(StatusEffectType.Freeze, effect.MutualExclusions);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void PoisonEffect_MutualExclusions_IsEmpty()
|
||||
{
|
||||
var effect = new PoisonEffect();
|
||||
Assert.IsEmpty(effect.MutualExclusions);
|
||||
}
|
||||
|
||||
// ── 阻断规则 ─────────────────────────────────────────────────────────
|
||||
|
||||
[Test]
|
||||
public void StaggerEffect_BlockedBy_ContainsStun()
|
||||
{
|
||||
var effect = new StaggerEffect();
|
||||
Assert.Contains(StatusEffectType.Stun, effect.BlockedBy);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void FireEffect_BlockedBy_IsEmpty()
|
||||
{
|
||||
var effect = new FireEffect();
|
||||
Assert.IsEmpty(effect.BlockedBy);
|
||||
}
|
||||
|
||||
// ── 到期检测 ─────────────────────────────────────────────────────────
|
||||
|
||||
[Test]
|
||||
public void StatusEffect_IsExpired_AfterDurationDepleted()
|
||||
{
|
||||
var effect = new StaggerEffect(0.5f);
|
||||
effect.OnApply(null); // Owner 可为 null,测试纯时间逻辑
|
||||
|
||||
effect.Update(0.3f);
|
||||
Assert.IsFalse(effect.IsExpired);
|
||||
|
||||
effect.Update(0.3f); // 累计 0.6f > 0.5f
|
||||
Assert.IsTrue(effect.IsExpired);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void FireEffect_OnStack_RefreshDuration()
|
||||
{
|
||||
var effectA = new FireEffect();
|
||||
effectA.OnApply(null);
|
||||
float initialDuration = effectA.Duration;
|
||||
|
||||
effectA.Update(1.0f); // 消耗部分时间
|
||||
Assert.Less(effectA.Duration, initialDuration);
|
||||
|
||||
effectA.OnStack(); // 重叠施加,应刷新持续时间
|
||||
Assert.AreEqual(initialDuration, effectA.Duration, 0.001f);
|
||||
}
|
||||
|
||||
// ── 类型标识 ─────────────────────────────────────────────────────────
|
||||
|
||||
[Test]
|
||||
public void FireEffect_EffectType_IsFire()
|
||||
{
|
||||
Assert.AreEqual(StatusEffectType.Fire, new FireEffect().EffectType);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void PoisonEffect_EffectType_IsPoison()
|
||||
{
|
||||
Assert.AreEqual(StatusEffectType.Poison, new PoisonEffect().EffectType);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void StaggerEffect_EffectType_IsStagger()
|
||||
{
|
||||
Assert.AreEqual(StatusEffectType.Stagger, new StaggerEffect().EffectType);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/Tests/EditMode/StatusEffectTests.cs.meta
Normal file
11
Assets/Tests/EditMode/StatusEffectTests.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2ff55dea49ef78743976736b557b22c6
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user