v10 全量评审:修复 TD-06 至 TD-12(InputReader 移除资产扫描回退 / EmergencySave 解除 LocalFileStorage 直接依赖 / AccessibilityManager 注册 IAccessibilityService / HUDController HP/SpringIcon SetActive 复用 / MovingPlatform 缓存 WaitForSeconds / RewardSO IRewardTarget 解耦 Quest←Player 依赖 / CrashReporter 频率限制崩溃日志)

This commit is contained in:
2026-05-12 16:18:46 +08:00
parent ebbbb7332e
commit 9284278578
27 changed files with 1697 additions and 125 deletions

View File

@@ -2,26 +2,55 @@
// 进入 LiquidZone 时切换水下 DSP 处理Architecture 21_LiquidPuzzleModule §3.4
using UnityEngine;
using UnityEngine.Audio;
using BaseGames.Core.Events;
using BaseGames.World.Liquid;
namespace BaseGames.Audio
{
/// <summary>
/// 挂载于 PlayerController 所在 GameObject。
/// 由 LiquidZone.OnTriggerEnter2D / OnTriggerExit2D 直接调用
/// 订阅 EVT_LiquidEntered / EVT_LiquidExited 事件频道(与 WaterDangerState、UnderwaterPostProcessingController 一致)
/// 切换 AudioMixer Snapshot 以应用/解除水下 DSP 处理。
/// 仅响应 Water 类型液体Acid / Lava 不切换水下音频。
/// </summary>
public class UnderwaterAudioController : MonoBehaviour
{
[SerializeField] private AudioMixer _mixer;
[SerializeField] private float _transitionDuration = 0.3f;
[SerializeField] private AudioMixer _mixer;
[SerializeField] private float _transitionDuration = 0.3f;
/// <summary>玩家进入 Water 类型液体时调用。</summary>
[Header("Event Channels")]
[SerializeField] private LiquidEventChannelSO _onLiquidEntered; // EVT_LiquidEntered
[SerializeField] private LiquidEventChannelSO _onLiquidExited; // EVT_LiquidExited
private readonly CompositeDisposable _subs = new();
private void OnEnable()
{
_onLiquidEntered?.Subscribe(OnLiquidEntered).AddTo(_subs);
_onLiquidExited?.Subscribe(OnLiquidExited).AddTo(_subs);
}
private void OnDisable() => _subs.Clear();
private void OnLiquidEntered(LiquidEvent evt)
{
if (evt.LiquidType == nameof(LiquidType.Water))
EnterWater();
}
private void OnLiquidExited(LiquidEvent evt)
{
if (evt.LiquidType == nameof(LiquidType.Water))
ExitWater();
}
/// <summary>切换至水下 AudioMixer Snapshot。</summary>
public void EnterWater()
{
_mixer?.FindSnapshot("Underwater")?.TransitionTo(_transitionDuration);
}
/// <summary>玩家离开液体时调用。</summary>
/// <summary>切换回默认 AudioMixer Snapshot。</summary>
public void ExitWater()
{
_mixer?.FindSnapshot("Default")?.TransitionTo(_transitionDuration);

View File

@@ -33,6 +33,7 @@ namespace BaseGames.Core.Assets
// ── Collectibles ─────────────────────────────────────────────────
public const string PrefabCollectibleGeo = "COL_Geo";
public const string PrefabCollectibleItem = "COL_Item";
public const string PrefabCollectibleHPOrb = "COL_HPOrb";
// ── Weapons ──────────────────────────────────────────────────────

View File

@@ -37,6 +37,10 @@ namespace BaseGames.Core.Pool
private void OnDestroy()
{
// 释放所有通过 Addressables.LoadAssetAsync 加载的预制件引用
foreach (var pfx in _prefabCache.Values)
Addressables.Release(pfx);
_prefabCache.Clear();
ServiceLocator.Unregister<IObjectPoolService>(this);
}

View File

@@ -7,13 +7,18 @@ namespace BaseGames.Core.Save
/// <summary>
/// 崩溃检测与诊断日志写入。
/// 监听 Unity 异常日志并在 OnApplicationPause 非正常退出时触发紧急存档(槽 99
/// 日志文件最多保留 <see cref="MaxLogFiles"/> 个,超出时删除最旧文件。
/// </summary>
public class CrashReporter : MonoBehaviour
{
[SerializeField] private SaveManager _saveManager;
[SerializeField] private EmergencySaveService _emergencyService;
[SerializeField, Min(1)] private int _maxLogFiles = 10;
private bool _cleanExit;
private bool _cleanExit;
private int _logsWrittenThisSession;
private const int MaxLogsPerSession = 5; // 同一运行期内最多写 5 个诊断文件,防止异常风暴
private void OnEnable()
{
@@ -39,7 +44,10 @@ namespace BaseGames.Core.Save
private void OnLogMessage(string condition, string stackTrace, LogType type)
{
if (type == LogType.Exception || type == LogType.Error)
{
if (_logsWrittenThisSession >= MaxLogsPerSession) return;
WriteDiagnosticLog(condition, stackTrace);
}
}
/// <summary>检查是否存在上次崩溃或意外退出留下的紧急存档。</summary>
@@ -55,11 +63,25 @@ namespace BaseGames.Core.Save
string logPath = Path.Combine(Application.persistentDataPath, $"crash_{timestamp}.log");
string content = $"[{DateTime.UtcNow:o}]\n{condition}\n\n{stackTrace}";
File.WriteAllText(logPath, content);
_logsWrittenThisSession++;
// 保留最新 N 个日志文件,超出时删除最旧文件
PruneOldLogFiles(Application.persistentDataPath, _maxLogFiles);
}
catch
{
// 日志写入失败不能再抛异常,否则会造成无限递归
}
}
private static void PruneOldLogFiles(string directory, int maxFiles)
{
var files = Directory.GetFiles(directory, "crash_*.log");
if (files.Length <= maxFiles) return;
Array.Sort(files); // 按文件名(含时间戳)升序,最旧的在前
int deleteCount = files.Length - maxFiles;
for (int i = 0; i < deleteCount; i++)
File.Delete(files[i]);
}
}
}

View File

@@ -42,12 +42,7 @@ namespace BaseGames.Core.Save
public async Task PromoteToSlot(int targetSlot)
{
if (_saveManager == null) return;
var storage = new LocalFileStorage();
if (!storage.Exists(EmergencySlot)) return;
string json = await storage.ReadAsync(EmergencySlot);
if (json == null) return;
await storage.WriteAsync(targetSlot, json);
await storage.DeleteAsync(EmergencySlot);
await _saveManager.PromoteEmergencyToSlotAsync(targetSlot, EmergencySlot);
}
}
}

View File

@@ -212,6 +212,19 @@ namespace BaseGames.Core.Save
if (_currentSlot == slotIndex) _current = null;
}
/// <summary>
/// 将紧急存档槽的数据复制到目标槽,并删除紧急存档。
/// 由 <see cref="EmergencySaveService"/> 调用,确保所有 IO 操作通过统一的 ISaveStorage 进行。
/// </summary>
public async Task PromoteEmergencyToSlotAsync(int targetSlot, int emergencySlot)
{
if (!_storage.Exists(emergencySlot)) return;
string json = await _storage.ReadAsync(emergencySlot);
if (json == null) return;
await _storage.WriteAsync(targetSlot, json);
await _storage.DeleteAsync(emergencySlot);
}
// ── 私有工具 ──────────────────────────────────────────────────────────
private string ComputeChecksum(string json)
{

View File

@@ -25,6 +25,7 @@ namespace BaseGames.Cutscene
private PlayableDirector _director;
private readonly CompositeDisposable _subs = new();
private System.Action _onCompletedCallback;
/// <summary>是否正在播放过场。</summary>
public bool IsPlaying => _director != null && _director.state == PlayState.Playing;
@@ -58,10 +59,15 @@ namespace BaseGames.Cutscene
Debug.LogWarning($"[CutsceneManager] 找不到 cutsceneId='{cutsceneId}'");
}
/// <summary>播放指定过场 SO。</summary>
public void PlayCutscene(CutsceneSO cutscene)
/// <summary>
/// 播放指定过场 SO。
/// <paramref name="onCompleted"/>过场完全结束PlayableDirector.stopped后调用
/// 可用于写入存档 flag 等需要在播完后执行的逻辑。
/// </summary>
public void PlayCutscene(CutsceneSO cutscene, System.Action onCompleted = null)
{
if (cutscene == null || IsPlaying) return;
_onCompletedCallback = onCompleted;
_director.playableAsset = cutscene.Timeline;
// 应用 Track → GameObject 绑定
@@ -102,6 +108,10 @@ namespace BaseGames.Cutscene
_director.stopped -= OnCutsceneStopped;
_inputReader?.EnableGameplayInput();
_onCutsceneEnded?.Raise();
// 取出并调用完成回调(清空后调用,防止回调内再次触发 PlayCutscene 时覆盖)
var cb = _onCompletedCallback;
_onCompletedCallback = null;
cb?.Invoke();
}
}
}

View File

@@ -71,8 +71,15 @@ namespace BaseGames.Cutscene
&& _worldState.HasFlag($"cutscene_played_{_cutscene.cutsceneId}"))
return;
_cutsceneManager.PlayCutscene(_cutscene);
_worldState?.SetFlag($"cutscene_played_{_cutscene.cutsceneId}");
// 捕获局部变量,避免回调内通过 this 访问被销毁的对象
var cutsceneId = _cutscene.cutsceneId;
var worldState = _worldState;
_cutsceneManager.PlayCutscene(_cutscene, onCompleted: () =>
{
// 过场完全结束后才写入 flag确保异常中断时可重触
worldState?.SetFlag($"cutscene_played_{cutsceneId}");
});
// 区域触发后禁用自身,防止重入
if (_mode == TriggerMode.OnEnter) enabled = false;

View File

@@ -79,7 +79,8 @@ namespace BaseGames.Input
private void OnEnable()
{
Debug.Assert(_onPauseRequested != null,
"[InputReaderSO] _onPauseRequested 未赋值,请在 Inspector 中指定 EVT_PauseRequested。", this);
// Reset private state on every OnEnable so stale ScriptableObject
// references from a previous Play session don't cause
// 'Map must be contained in state' errors.
@@ -200,36 +201,10 @@ namespace BaseGames.Input
private void HandlePause()
{
if (_onPauseRequested == null)
{
_onPauseRequested = FindPauseChannelByName();
if (_onPauseRequested == null)
Debug.LogError("[InputReaderSO.HandlePause] Could not find EVT_PauseRequested asset!");
}
PauseEvent?.Invoke();
_onPauseRequested?.Raise();
}
private static VoidEventChannelSO FindPauseChannelByName()
{
VoidEventChannelSO[] channels = Resources.FindObjectsOfTypeAll<VoidEventChannelSO>();
foreach (VoidEventChannelSO channel in channels)
{
if (channel != null && channel.name == "EVT_PauseRequested")
return channel;
}
return null;
}
private static void BindStarted(InputActionMap map, string name, Action callback)
{
var action = map.FindAction(name, throwIfNotFound: false);

View File

@@ -3,13 +3,15 @@ using UnityEngine;
using BaseGames.Core;
using BaseGames.Core.Events;
using BaseGames.Core.Save;
using BaseGames.Quest;
namespace BaseGames.Player
{
/// <summary>
/// 玩家数值管理组件。负责 HP、灵魂、灵气、弹簧充能、Geo、能力解锁与存档读写。
/// 实现 <see cref="IRewardTarget"/> 供 RewardSO 使用,避免 Quest 程序集直接依赖 Player 程序集。
/// </summary>
public class PlayerStats : MonoBehaviour, ISaveable, BaseGames.Core.IRestoreOnSave
public class PlayerStats : MonoBehaviour, ISaveable, BaseGames.Core.IRestoreOnSave, IRewardTarget
{
[Header("配置")]
[SerializeField] private PlayerStatsSO _config;
@@ -284,6 +286,9 @@ namespace BaseGames.Player
_onAbilityUnlocked?.Raise(ability);
}
/// <summary>IRewardTarget 实现:以 uint 位掩码解锁能力(避免跨程序集枚举引用)。</summary>
void IRewardTarget.UnlockAbilityFlag(uint abilityFlag) => UnlockAbility((AbilityType)abilityFlag);
public void LockAbility(AbilityType ability)
=> _unlockedAbilities &= ~ability;

View File

@@ -0,0 +1,20 @@
namespace BaseGames.Quest
{
/// <summary>
/// 奖励接收目标接口(架构 22_QuestChallengeModule §4
/// 由 <see cref="RewardSO.Apply"/> 调用,解除 BaseGames.Quest 对 BaseGames.Player 的直接依赖。
/// PlayerStats 实现此接口QuestManager 持有 IRewardTarget 引用。
/// 能力类型以 uint 位掩码传递(与 Player.AbilityType : uint 一致),避免跨程序集枚举引用。
/// </summary>
public interface IRewardTarget
{
/// <summary>增加 Geo货币。</summary>
void AddGeo(int amount);
/// <summary>增加灵魂力量上限。</summary>
void AddSoulPower(int amount);
/// <summary>解锁指定能力abilityFlag 为 AbilityType 的 uint 位掩码值)。</summary>
void UnlockAbilityFlag(uint abilityFlag);
}
}

View File

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

View File

@@ -2,7 +2,6 @@ using System.Collections.Generic;
using UnityEngine;
using BaseGames.Core.Events;
using BaseGames.Core.Save;
using BaseGames.Player;
using QuestStateEnum = BaseGames.Core.Events.QuestState;
namespace BaseGames.Quest
@@ -86,11 +85,11 @@ namespace BaseGames.Quest
}
/// <summary>NPC 完成任务时调用。</summary>
public void CompleteQuest(string questId, PlayerStats player)
public void CompleteQuest(string questId, IRewardTarget rewardTarget)
{
if (!IsReadyToComplete(questId)) return;
var quest = GetQuestSO(questId);
quest.reward?.Apply(player);
quest.reward?.Apply(rewardTarget);
_questStates[questId] = QuestStateEnum.Completed;
_onQuestCompleted?.Raise(questId);

View File

@@ -1,5 +1,4 @@
using UnityEngine;
using BaseGames.Player;
using BaseGames.Core.Events;
namespace BaseGames.Quest
@@ -20,20 +19,24 @@ namespace BaseGames.Quest
[Tooltip("是否解锁能力AbilityType 无 None 值,用 bool 标识)")]
public bool unlocksAbility; // ⚠️ AbilityType 无 None用 bool 标识
public AbilityType unlockedAbility; // 仅当 unlocksAbility == true 有效
public uint unlockedAbilityFlag; // AbilityType 的 uint 位掩码值(仅当 unlocksAbility == true 有效
[Header("物品发放事件")]
[Tooltip("EVT_CollectiblePickup向 QuestManager/EquipmentManager 广播 itemId")]
[SerializeField] private StringEventChannelSO _onCollectiblePickup;
/// <summary>将奖励应用到游戏状态(由 QuestManager.CompleteQuest 调用)。</summary>
public void Apply(PlayerStats player)
/// <summary>
/// 将奖励应用到游戏状态(由 QuestManager.CompleteQuest 调用)。
/// 通过 <see cref="IRewardTarget"/> 接口操作,避免直接依赖 BaseGames.Player 程序集。
/// </summary>
public void Apply(IRewardTarget target)
{
if (player == null) return;
if (target == null) return;
if (geo > 0) player.AddGeo(geo);
if (soulBonus > 0) player.AddSoulPower(soulBonus);
if (unlocksAbility) player.UnlockAbility(unlockedAbility);
if (geo > 0) target.AddGeo(geo);
if (soulBonus > 0) target.AddSoulPower(soulBonus);
if (unlocksAbility && unlockedAbilityFlag != 0)
target.UnlockAbilityFlag(unlockedAbilityFlag);
// 通过 EVT_CollectiblePickup 事件频道广播每个物品 ID
if (itemIds != null && _onCollectiblePickup != null)

View File

@@ -1,13 +1,14 @@
using UnityEngine;
using BaseGames.Core;
using BaseGames.Core.Events;
namespace BaseGames.Support.Accessibility
{
/// <summary>
/// 无障碍功能管理器(架构 16_SupportingModules §6.1)。
/// 响应设置变更,广播色盲模式事件;提供 CanPlayScreenShake() 供 FeedbackSystem 查询。
/// 响应设置变更,广播色盲模式事件;通过 ServiceLocator 提供 IAccessibilityService 查询接口
/// </summary>
public class AccessibilityManager : MonoBehaviour
public class AccessibilityManager : MonoBehaviour, IAccessibilityService
{
[Header("设置资产")]
[SerializeField] private AccessibilitySettingsSO _settings;
@@ -16,21 +17,20 @@ public class AccessibilityManager : MonoBehaviour
[SerializeField] private ColorblindModeEventChannelSO _onColorblindModeChanged;
[SerializeField] private BoolEventChannelSO _onScreenShakeChanged;
private static AccessibilityManager _instance;
// ── 静态查询接口(供 FeedbackSystem 使用) ───────────────────────────────
public static bool CanPlayScreenShake()
=> _instance == null || (_instance._settings != null && _instance._settings.ScreenShake);
// ── IAccessibilityService ─────────────────────────────────────────────
/// <summary>当前设置是否允许播放屏幕震动效果。</summary>
public bool CanPlayScreenShake()
=> _settings == null || _settings.ScreenShake;
private void Awake()
{
if (_instance != null && _instance != this)
if (ServiceLocator.GetOrDefault<IAccessibilityService>() != null)
{
Debug.LogWarning("[AccessibilityManager] 已存在实例,请确保本组件仅放置在 Persistent 场景中。", this);
Destroy(this);
return;
}
_instance = this;
ServiceLocator.Register<IAccessibilityService>(this);
if (_settings != null)
_settings.Load();
}
@@ -44,7 +44,7 @@ public class AccessibilityManager : MonoBehaviour
private void OnDestroy()
{
if (_instance == this) _instance = null;
ServiceLocator.Unregister<IAccessibilityService>(this);
}
/// <summary>应用新的设置并持久化。</summary>

View File

@@ -0,0 +1,12 @@
namespace BaseGames.Support.Accessibility
{
/// <summary>
/// 无障碍功能查询接口(架构 16_SupportingModules §6.1)。
/// 由 <see cref="AccessibilityManager"/> 实现,通过 ServiceLocator 注册。
/// </summary>
public interface IAccessibilityService
{
/// <summary>当前设置是否允许播放屏幕震动效果。</summary>
bool CanPlayScreenShake();
}
}

View File

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

View File

@@ -18,14 +18,20 @@ namespace BaseGames.UI
[SerializeField] private float _floatDistance = 1.5f;
[SerializeField] private float _duration = 0.8f;
private UnityEngine.Camera _cam;
/// <summary>
/// 父级 Canvas用于 RectTransformUtility 坐标转换)。
/// 适配所有 Canvas 渲染模式Overlay / Camera / World Space
/// Screen Space - Overlay 时可传 null会自动 fallback 到 null camera
/// </summary>
[SerializeField] private Canvas _parentCanvas;
private RectTransform _rectTransform;
private Coroutine _animCoroutine;
private void Awake()
{
_rectTransform = (RectTransform)transform;
_cam = UnityEngine.Camera.main;
// 不在 Awake 缓存 Camera.main避免 Boss 过场切换主摄像机后引用过期
}
/// <summary>
@@ -39,16 +45,36 @@ namespace BaseGames.UI
_text.text = damage.ToString();
_text.color = GetColorForType(type);
// 将世界坐标转为屏幕坐标
if (_cam != null)
{
Vector2 screenPos = _cam.WorldToScreenPoint(worldPosition);
_rectTransform.anchoredPosition = screenPos;
}
SetAnchoredPosition(worldPosition);
_animCoroutine = StartCoroutine(FloatAndFade(worldPosition));
}
private void SetAnchoredPosition(Vector2 worldPosition)
{
var cam = (_parentCanvas != null && _parentCanvas.renderMode == RenderMode.ScreenSpaceCamera)
? _parentCanvas.worldCamera
: UnityEngine.Camera.main;
var screenPoint = UnityEngine.Camera.main != null
? (Vector2)UnityEngine.Camera.main.WorldToScreenPoint(worldPosition)
: Vector2.zero;
var canvasRect = _parentCanvas != null
? (RectTransform)_parentCanvas.transform
: null;
if (canvasRect != null)
{
RectTransformUtility.ScreenPointToLocalPointInRectangle(
canvasRect, screenPoint, cam, out var localPoint);
_rectTransform.anchoredPosition = localPoint;
}
else
{
_rectTransform.anchoredPosition = screenPoint;
}
}
private IEnumerator FloatAndFade(Vector2 startWorld)
{
float elapsed = 0f;
@@ -59,10 +85,7 @@ namespace BaseGames.UI
{
float t = elapsed / _duration;
// 向上飘动(屏幕坐标)
Vector2 currentWorld = startWorld + new Vector2(0, _floatDistance * t);
if (_cam != null)
_rectTransform.anchoredPosition = (Vector2)_cam.WorldToScreenPoint(currentWorld);
SetAnchoredPosition(startWorld + new Vector2(0, _floatDistance * t));
// alpha 淡出(后半段开始)
_text.color = new Color(color.r, color.g, color.b,

View File

@@ -66,12 +66,17 @@ namespace BaseGames.UI.HUD
private void RebuildHPCells(int max)
{
foreach (var cell in _hpCells)
if (cell != null) Destroy(cell);
_hpCells.Clear();
if (_hpContainer == null || _hpCellPrefab == null) return;
// 复用现有 Cell仅在数量不足时 Instantiate 补充,超出时 SetActive(false) 而非 Destroy
for (int i = 0; i < max; i++)
_hpCells.Add(Instantiate(_hpCellPrefab, _hpContainer));
{
if (i < _hpCells.Count)
_hpCells[i].SetActive(true);
else
_hpCells.Add(Instantiate(_hpCellPrefab, _hpContainer));
}
for (int i = max; i < _hpCells.Count; i++)
if (_hpCells[i] != null) _hpCells[i].SetActive(false);
}
private void UpdateSoul(int val)
@@ -91,12 +96,17 @@ namespace BaseGames.UI.HUD
private void RebuildSpringIcons(int charges)
{
foreach (var icon in _springIcons)
if (icon != null) Destroy(icon);
_springIcons.Clear();
if (_springContainer == null || _springIconPrefab == null) return;
// 复用已有图标,超出数量时 SetActive(false)
for (int i = 0; i < charges; i++)
_springIcons.Add(Instantiate(_springIconPrefab, _springContainer));
{
if (i < _springIcons.Count)
_springIcons[i].SetActive(true);
else
_springIcons.Add(Instantiate(_springIconPrefab, _springContainer));
}
for (int i = charges; i < _springIcons.Count; i++)
if (_springIcons[i] != null) _springIcons[i].SetActive(false);
}
private void UpdateFormIcon(int formIndex)

View File

@@ -1,4 +1,5 @@
using System.Collections.Generic;
using BaseGames.Core.Events;
using UnityEngine;
namespace BaseGames.World
@@ -14,17 +15,25 @@ namespace BaseGames.World
[SerializeField, Min(1)] private int _maxCrumbs = 20;
[SerializeField, Min(0.1f)] private float _minMoveDistance = 1f;
private readonly Queue<Vector2> _crumbs = new();
private Vector2 _lastPos;
private float _timer;
[Header("事件频道")]
[SerializeField] private TransformEventChannelSO _onPlayerSpawned;
private readonly Queue<Vector2> _crumbs = new();
private readonly CompositeDisposable _subs = new();
private Vector2 _lastPos;
private float _timer;
private Transform _playerTransform;
// ── Unity 生命周期 ────────────────────────────────────────────────
private void Awake()
private void OnEnable()
{
var go = GameObject.FindWithTag("Player");
if (go != null) _playerTransform = go.transform;
_onPlayerSpawned?.Subscribe(t => _playerTransform = t).AddTo(_subs);
}
private void OnDisable()
{
_subs.Clear();
}
private void Update()

View File

@@ -71,7 +71,12 @@ namespace BaseGames.World
private void Despawn()
{
gameObject.SetActive(false);
// 若由对象池创建PooledObject 存在),归还到池;否则直接停用(场景内静态放置的 Collectible
var po = GetComponent<Core.Pool.PooledObject>();
if (po != null)
po.ReturnToPool();
else
gameObject.SetActive(false);
}
// ── 运行时配置(由 CollectibleSpawner 在实例化后调用)────────────────

View File

@@ -1,16 +1,20 @@
using UnityEngine;
using BaseGames.Core;
using BaseGames.Core.Assets;
namespace BaseGames.World
{
/// <summary>
/// 可收集物生成器(静态工具类)。
/// 封装 Geo / 道具 Collectible 的实例化逻辑,供 LootResolver 等调用。
/// Prefab 引用通过 CollectibleSpawnerConfig SO 注入,避免 Resources.Load。
/// 封装 Geo / 道具 Collectible 的 Spawn 逻辑,供 LootResolver 等调用。
/// 优先通过 IObjectPoolService 从对象池取用(需预热 COL_Geo / COL_Item
/// 池服务不可用时退回 Object.Instantiate仅限编辑器 / 单元测试场景)。
/// Prefab 引用通过 CollectibleSpawnerConfig 注入,避免 Resources.Load。
/// </summary>
public static class CollectibleSpawner
{
/// <summary>
/// 全局配置引用(由 CollectibleSpawnerConfig.Initialize() 在游戏启动时设置)。
/// 全局配置引用(由 CollectibleSpawnerConfig.Awake() 注册)。
/// </summary>
private static CollectibleSpawnerConfig _config;
@@ -19,40 +23,42 @@ namespace BaseGames.World
/// <summary>
/// 在世界坐标生成 Geo 拾取物。
/// 若配置未注册则仅输出日志(编辑器 / 测试场景兜底)。
/// 优先从 GlobalObjectPool 取用key = AddressKeys.PrefabCollectibleGeo
/// 池服务不可用时退回 Object.Instantiate。
/// </summary>
public static void SpawnGeo(Vector2 position, int amount)
{
if (_config == null || _config.GeoPrefab == null)
{
Debug.LogWarning($"[CollectibleSpawner] GeoPrefab 未配置Geo x{amount} 无法生成 at {position}");
return;
}
var go = Object.Instantiate(_config.GeoPrefab, position, Quaternion.identity);
if (go.TryGetComponent<Collectible>(out var c))
{
var go = SpawnFromPool(AddressKeys.PrefabCollectibleGeo, position)
?? InstantiateFallback(_config?.GeoPrefab, position,
$"[CollectibleSpawner] GeoPrefab 未配置Geo x{amount} 无法生成 at {position}");
if (go != null && go.TryGetComponent<Collectible>(out var c))
c.SetGeo(amount);
}
}
/// <summary>
/// 在世界坐标生成道具拾取物(通过 itemId 广播 EVT_CollectiblePickup
/// 若配置未注册则仅输出日志。
/// 优先从 GlobalObjectPool 取用key = AddressKeys.PrefabCollectibleItem
/// 池服务不可用时退回 Object.Instantiate。
/// </summary>
public static void SpawnItem(Vector2 position, string itemId)
{
if (_config == null || _config.ItemPrefab == null)
{
Debug.LogWarning($"[CollectibleSpawner] ItemPrefab 未配置,物品 {itemId} 无法生成 at {position}");
return;
}
var go = Object.Instantiate(_config.ItemPrefab, position, Quaternion.identity);
if (go.TryGetComponent<Collectible>(out var c))
{
var go = SpawnFromPool(AddressKeys.PrefabCollectibleItem, position)
?? InstantiateFallback(_config?.ItemPrefab, position,
$"[CollectibleSpawner] ItemPrefab 未配置,物品 {itemId} 无法生成 at {position}");
if (go != null && go.TryGetComponent<Collectible>(out var c))
c.SetItem(itemId);
}
}
// ── 内部工具 ──────────────────────────────────────────────────────
private static GameObject SpawnFromPool(string key, Vector2 position)
=> ServiceLocator.GetOrDefault<IObjectPoolService>()
?.Spawn(key, position, Quaternion.identity);
private static GameObject InstantiateFallback(GameObject prefab, Vector2 position, string warnMsg)
{
if (prefab == null) { Debug.LogWarning(warnMsg); return null; }
return Object.Instantiate(prefab, position, Quaternion.identity);
}
}
}

View File

@@ -32,6 +32,7 @@ namespace BaseGames.World
private bool _movingForward = true;
private bool _triggered;
private bool _waiting;
private WaitForSeconds _waitForEndpoint;
private readonly CompositeDisposable _subs = new();
private void Awake()
@@ -39,6 +40,7 @@ namespace BaseGames.World
_rb = GetComponent<Rigidbody2D>();
_rb.bodyType = RigidbodyType2D.Kinematic;
_rb.interpolation = RigidbodyInterpolation2D.Interpolate;
_waitForEndpoint = new WaitForSeconds(_waitAtEndpoint);
}
private void OnEnable()
@@ -73,7 +75,7 @@ namespace BaseGames.World
private IEnumerator WaitAndAdvance()
{
_waiting = true;
yield return new WaitForSeconds(_waitAtEndpoint);
yield return _waitForEndpoint;
AdvanceWaypoint();
_waiting = false;
}