多轮审查评估

This commit is contained in:
2026-05-13 09:19:54 +08:00
parent 458f344e83
commit 1b37297585
57 changed files with 3019 additions and 218 deletions

View File

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

View File

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

View File

@@ -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()
{

View File

@@ -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)
{

View File

@@ -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>宿主 ManagerOnApply 时注入OnTick/OnExpire 中可访问)。</summary>

View File

@@ -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();

View File

@@ -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";
}
}
}

View File

@@ -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()

View File

@@ -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";

View File

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

View File

@@ -9,37 +9,21 @@ using BaseGames.Core.Events;
namespace BaseGames.Core
{
/// <summary>
/// Addressables 场景加载器。
/// 监听 EVT_SceneLoadRequestAdditive 加载指定场景,完成后发布 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成功后再卸载旧场景
// 顺序保证:若加载失败,旧场景仍保持可用,不会出现无场景的空状态

View File

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

View File

@@ -11,6 +11,7 @@
"BaseGames.Core",
"BaseGames.Core.Events",
"BaseGames.Input",
"BaseGames.Localization",
"BaseGames.World",
"Unity.TextMeshPro",
"Unity.InputSystem"

View File

@@ -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);

View File

@@ -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");

View File

@@ -43,8 +43,6 @@ namespace BaseGames.EventChain
#endif
private readonly HashSet<string> _completedChains = new();
/// <summary>当帧内有任意事件触发时置 trueUpdate 中合并为单次评估,避免同帧多事件重复遍历。</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()
{

View File

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

View File

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

View File

@@ -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&lt;SaveManager&gt;() 读取进度,订阅 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);

View File

@@ -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; // 架构 §12requireNoHit 挑战是否被破坏
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 =>
{

View File

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

View File

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

View File

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

View File

@@ -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)。

View File

@@ -1,7 +1,7 @@
using UnityEngine;
using BaseGames.Player;
namespace BaseGames.Progression
namespace BaseGames.Support.AntiSoftlock
{
/// <summary>
/// 房间逃脱信息 SO架构 16_SupportingModules §5.2)。

View File

@@ -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 已完成则忽略。

View File

@@ -13,6 +13,7 @@
"BaseGames.Core.Save",
"BaseGames.Input",
"BaseGames.Combat",
"BaseGames.Localization",
"Unity.TextMeshPro",
"Unity.InputSystem",
"BaseGames.Equipment"

View File

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

View File

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

View File

@@ -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)。
/// 注:提示文字直接使用字符串 keyP4-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();
}
// ── 公开 APISceneLoader 可直接调用)────────────────────────────────
@@ -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());

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)
{

View File

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

View File

@@ -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)
{

View File

@@ -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();

View File

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

View File

@@ -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 ? "按住交互" : "交互";

View File

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

View File

@@ -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 时回退到 HealthRestorationInspector 未配置的保护值)。
/// </summary>
public ShopItemType ItemType => Effect?.Type ?? ShopItemType.HealthRestoration;
}
public enum ShopItemType

8
Assets/Tests.meta Normal file
View File

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

View File

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

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

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: b3ad3b74ee771f140801100a62fc3ebc
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

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

View File

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