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:
@@ -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);
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
20
Assets/Scripts/Quest/IRewardTarget.cs
Normal file
20
Assets/Scripts/Quest/IRewardTarget.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Quest/IRewardTarget.cs.meta
Normal file
11
Assets/Scripts/Quest/IRewardTarget.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a5f886592eceaa74b8b3e489e6b669b4
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3cadc76323b714844b522781d7a5d012
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 在实例化后调用)────────────────
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user