Add enemy respawner and related components for room lifecycle management

- Implemented EnemyRespawner to manage enemy spawning and respawning within rooms.
- Added IRoomLifecycle interface for room activation and dormancy handling.
- Created supporting classes and metadata for enemy perception and threat assessment.
- Established streaming system components for room state management and transitions.
- Added necessary metadata files for new scripts to ensure proper integration with Unity.
This commit is contained in:
2026-05-23 21:23:09 +08:00
parent a1b4e629aa
commit 520f84999b
34 changed files with 1710 additions and 63 deletions

View File

@@ -0,0 +1,35 @@
using System.Collections;
namespace BaseGames.Core
{
/// <summary>
/// 场景加载协调器接口。
/// <para>
/// 定义于 <c>BaseGames.Core</c> 以避免 <see cref="SceneService"/> 对
/// <c>BaseGames.World.Streaming</c> 产生直接依赖。
/// </para>
/// <para>
/// 由流式加载系统RoomStreamingManager实现并在 Awake 中向
/// <see cref="ServiceLocator"/> 注册。当注册存在时,<see cref="SceneService"/>
/// 将符合条件的场景加载请求委托给本接口确保房间生命周期Dormant / Active / Cooling
/// 得到完整维护;否则退回到 SceneLoader 原生路径。
/// </para>
/// </summary>
public interface ISceneLoadCoordinator
{
/// <summary>
/// 判断给定场景地址是否应由流式系统管理(而非 SceneLoader 直接加载)。
/// <para>约定:以 <c>"Room_"</c> 前缀开头的地址均属于流式系统管辖范围。</para>
/// </summary>
bool OwnsScene(string sceneName);
/// <summary>
/// 以完整流式路径加载并激活指定房间
/// Load → Dormant → Active同时将前一个 Active 房间送入冷却队列)。
/// </summary>
/// <param name="sceneName">Addressable key等同于 RoomId前缀 "Room_")。</param>
/// <param name="entryTransitionId">目标房间出生点 IDnull 表示使用默认出生点。</param>
/// <param name="isRespawn">true = 复活流程,玩家应在最近存档点出生。</param>
IEnumerator LoadAndActivateCoroutine(string sceneName, string entryTransitionId, bool isRespawn);
}
}

View File

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

View File

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

View File

@@ -104,10 +104,23 @@ namespace BaseGames.Core
if (fadeDuration > 0f)
yield return new WaitForSeconds(fadeDuration);
if (_sceneLoader != null)
// 流式模式优先:若流式协调器已注册且声明对本场景的所有权,委托给流式系统加载。
// 这确保复活 / 快速传送等使用 Room/Scene 类型的路径也能正确触发冷却和卸载生命周期,
// 避免前一房间在 RoomStreamingManager 中永远停留在 Active 状态。
var coordinator = ServiceLocator.GetOrDefault<ISceneLoadCoordinator>();
if (coordinator != null && coordinator.OwnsScene(request.SceneName))
{
yield return StartCoroutine(coordinator.LoadAndActivateCoroutine(
request.SceneName, request.EntryTransitionId, request.IsRespawn));
}
else if (_sceneLoader != null)
{
yield return StartCoroutine(_sceneLoader.LoadSceneCoroutine(request));
}
else
{
Debug.LogError("[SceneService] _sceneLoader 未赋值,场景加载中断。请在 Inspector 中绑定 SceneLoader 组件。");
}
// 通知WorldStateRegistry 已就绪,场景物体应在此帧内从中读取存档状态并应用初始状态。
// 订阅者WorldStateRegistrySaver、各场景 StateApplier 等)会在同一帧同步执行。

View File

@@ -0,0 +1,266 @@
using UnityEditor;
using UnityEngine;
using BaseGames.Enemies;
using BaseGames.World;
namespace BaseGames.Editor
{
/// <summary>
/// <see cref="EnemyRespawner"/> 自定义 Inspector。
/// <list type="bullet">
/// <item>配置校验:生成来源为空、永久击杀 ID 已填但 WorldStateRegistry 未关联时显示警告。</item>
/// <item>运行时状态PlayMode 中显示当前激活实例信息。</item>
/// <item>批量验证菜单BaseGames / Enemies / Validate Respawners。</item>
/// </list>
/// </summary>
[CustomEditor(typeof(EnemyRespawner))]
public class EnemyRespawnerEditor : UnityEditor.Editor
{
// ── 序列化属性引用 ─────────────────────────────────────────────────────────
private SerializedProperty _prefabProp;
private SerializedProperty _poolKeyProp;
private SerializedProperty _respawnDelayProp;
private SerializedProperty _maxRespawnCountProp;
private SerializedProperty _persistentKillIdProp;
private SerializedProperty _worldStateProp;
// 运行时只读字段(反射获取私有字段)
private static readonly System.Reflection.FieldInfo s_ActiveEnemyField =
typeof(EnemyRespawner).GetField("_activeEnemy",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
private static readonly System.Reflection.FieldInfo s_RespawnCountField =
typeof(EnemyRespawner).GetField("_respawnCountThisVisit",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
private static readonly System.Reflection.FieldInfo s_RespawnCoroutineField =
typeof(EnemyRespawner).GetField("_respawnCoroutine",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
private void OnEnable()
{
_prefabProp = serializedObject.FindProperty("_enemyPrefab");
_poolKeyProp = serializedObject.FindProperty("_poolKey");
_respawnDelayProp = serializedObject.FindProperty("_respawnDelay");
_maxRespawnCountProp = serializedObject.FindProperty("_maxRespawnCount");
_persistentKillIdProp = serializedObject.FindProperty("_persistentKillId");
_worldStateProp = serializedObject.FindProperty("_worldState");
}
public override void OnInspectorGUI()
{
serializedObject.Update();
// 手动绘制字段,以便对 _maxRespawnCount 做条件性灰显
DrawPropertiesExcluding(serializedObject, "_maxRespawnCount");
bool hasDelay = _respawnDelayProp != null && _respawnDelayProp.floatValue > 0f;
using (new EditorGUI.DisabledScope(!hasDelay))
{
EditorGUILayout.PropertyField(_maxRespawnCountProp,
new GUIContent("复活上限 (Max Respawn Count)",
_maxRespawnCountProp?.tooltip ?? ""));
}
if (!hasDelay)
EditorGUILayout.HelpBox("Respawn Delay = 0Max Respawn Count 无效(敌人不会在房间内复活)。",
MessageType.None);
serializedObject.ApplyModifiedProperties();
EditorGUILayout.Space(6f);
DrawValidationSection();
if (Application.isPlaying)
{
EditorGUILayout.Space(6f);
DrawRuntimeSection();
}
}
// ── 配置校验区块 ──────────────────────────────────────────────────────────
private void DrawValidationSection()
{
EditorGUILayout.LabelField("配置检查", EditorStyles.boldLabel);
bool hasPrefab = _prefabProp.objectReferenceValue != null;
bool hasPoolKey = !string.IsNullOrWhiteSpace(_poolKeyProp.stringValue);
bool hasPersistentId = !string.IsNullOrWhiteSpace(_persistentKillIdProp.stringValue);
bool hasWorldState = _worldStateProp.objectReferenceValue != null;
// ── 生成来源 ───────────────────────────────────────────────────────
if (!hasPrefab && !hasPoolKey)
{
EditorGUILayout.HelpBox(
"_enemyPrefab 与 _poolKey 均为空,运行时无法生成敌人。\n" +
"请至少填写其中一项。",
MessageType.Error);
}
else if (hasPrefab && !hasPoolKey)
{
EditorGUILayout.HelpBox(
"当前使用直接实例化_enemyPrefab。\n" +
"频繁进出房间时建议改用对象池(填写 _poolKey以减少 GC 压力。",
MessageType.Info);
}
else
{
DrawOk("生成来源配置正常。");
}
// ── 永久击杀 ───────────────────────────────────────────────────────
if (hasPersistentId && !hasWorldState)
{
EditorGUILayout.HelpBox(
"_persistentKillId 已填写,但 _worldState 未关联。\n" +
"击杀记录将无法持久化,存档重载后敌人仍会复活。",
MessageType.Warning);
if (GUILayout.Button("自动查找场景内 WorldStateRegistry"))
{
var found = FindFirstObjectByType<WorldStateRegistry>();
if (found != null)
{
serializedObject.Update();
_worldStateProp.objectReferenceValue = found;
serializedObject.ApplyModifiedProperties();
}
else
{
EditorUtility.DisplayDialog(
"未找到",
"当前场景中没有 WorldStateRegistry 组件。\n" +
"请先在场景内放置 WorldStateRegistry。",
"确定");
}
}
}
else if (!hasPersistentId)
{
DrawOk("普通刷新点(每次进房复活)。");
}
else
{
DrawOk($"永久击杀已关联 WorldStateRegistry。\nID{_persistentKillIdProp.stringValue}");
}
}
// ── 运行时状态区块PlayMode 专用)────────────────────────────────────────
private void DrawRuntimeSection()
{
EditorGUILayout.LabelField("运行时状态", EditorStyles.boldLabel);
var respawner = (EnemyRespawner)target;
var activeEnemy = s_ActiveEnemyField?.GetValue(respawner) as EnemyBase;
int respawnCount = s_RespawnCountField != null
? (int)s_RespawnCountField.GetValue(respawner)
: 0;
bool waitingRespawn = s_RespawnCoroutineField?.GetValue(respawner) != null;
if (activeEnemy == null && !waitingRespawn)
{
EditorGUILayout.LabelField("当前实例", "无(房间休眠或尚未生成)");
}
else if (waitingRespawn)
{
var prevColor = GUI.color;
GUI.color = new Color(0.6f, 0.8f, 1f);
int maxCount = _maxRespawnCountProp?.intValue ?? 0;
string countStr = maxCount == 0
? $"{respawnCount} 次(无上限)"
: $"{respawnCount} / {maxCount} 次";
EditorGUILayout.LabelField("状态", $"⏳ 等待复活中…(已复活 {countStr}");
GUI.color = prevColor;
}
else if (activeEnemy != null)
{
string statusStr = activeEnemy.IsAlive ? "存活" : "死亡动画中";
var prevColor = GUI.color;
GUI.color = activeEnemy.IsAlive
? new Color(0.4f, 0.9f, 0.4f)
: new Color(1f, 0.55f, 0.3f);
EditorGUILayout.LabelField("当前实例", $"{activeEnemy.name} [{statusStr}]");
GUI.color = prevColor;
int maxCount = _maxRespawnCountProp?.intValue ?? 0;
if (_respawnDelayProp?.floatValue > 0f)
{
string countStr = maxCount == 0
? $"{respawnCount} 次(无上限)"
: $"{respawnCount} / {maxCount} 次";
EditorGUILayout.LabelField("本次访问已复活", countStr);
}
if (GUILayout.Button("在 Hierarchy 中定位", EditorStyles.miniButton))
Selection.activeGameObject = activeEnemy.gameObject;
}
// 强制每帧刷新,确保状态即时更新
Repaint();
}
// ── 通用辅助 ──────────────────────────────────────────────────────────────
private static void DrawOk(string message)
{
var prevColor = GUI.color;
GUI.color = new Color(0.4f, 0.9f, 0.4f);
EditorGUILayout.LabelField($"✓ {message}", EditorStyles.wordWrappedMiniLabel);
GUI.color = prevColor;
}
// ── 批量验证菜单 ──────────────────────────────────────────────────────────
[MenuItem("BaseGames/Enemies/Validate Respawners")]
private static void ValidateAllRespawners()
{
var all = FindObjectsByType<EnemyRespawner>(
FindObjectsInactive.Include, FindObjectsSortMode.None);
if (all.Length == 0)
{
EditorUtility.DisplayDialog("验证结果", "当前场景中未找到任何 EnemyRespawner。", "确定");
return;
}
int errors = 0;
int warnings = 0;
foreach (var r in all)
{
var so = new SerializedObject(r);
var prefab = so.FindProperty("_enemyPrefab");
var poolKey = so.FindProperty("_poolKey");
var killId = so.FindProperty("_persistentKillId");
var worldState = so.FindProperty("_worldState");
bool hasPrefab = prefab?.objectReferenceValue != null;
bool hasPoolKey = !string.IsNullOrWhiteSpace(poolKey?.stringValue);
bool hasKillId = !string.IsNullOrWhiteSpace(killId?.stringValue);
bool hasWS = worldState?.objectReferenceValue != null;
string scene = r.gameObject.scene.name;
string path = $"{scene}/{r.gameObject.name}";
if (!hasPrefab && !hasPoolKey)
{
errors++;
Debug.LogError($"[EnemyRespawner] {path}_enemyPrefab 与 _poolKey 均为空,无法生成敌人。", r.gameObject);
}
if (hasKillId && !hasWS)
{
warnings++;
Debug.LogWarning($"[EnemyRespawner] {path}_persistentKillId=\"{killId.stringValue}\" 但 _worldState 未关联,击杀记录无法持久化。", r.gameObject);
}
}
string msg = errors == 0 && warnings == 0
? $"全部 {all.Length} 个 EnemyRespawner 配置正常。"
: $"检查完成:{errors} 个错误,{warnings} 个警告(共 {all.Length} 个)。\n详细信息见 Console 日志。";
EditorUtility.DisplayDialog("验证结果", msg, "确定");
}
}
}

View File

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

View File

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

View File

@@ -388,8 +388,35 @@ namespace BaseGames.Editor
roomSO.ApplyModifiedPropertiesWithoutUndo();
}
// ── MapRoomDataSO 模板(幂等:同名资产已存在时跳过)──────────────
string roomIdHint = SceneManager.GetActiveScene().name;
if (string.IsNullOrEmpty(roomIdHint) || roomIdHint == "Untitled")
roomIdHint = "Room_Unknown";
string roomSoFolder = "Assets/_Game/Data/Map/Rooms";
string roomSoPath = $"{roomSoFolder}/{roomIdHint}.asset";
bool roomSoCreated = false;
if (!System.IO.File.Exists(Application.dataPath + "/../" + roomSoPath))
{
EnsureFolder(roomSoFolder);
var roomData = ScriptableObject.CreateInstance<MapRoomDataSO>();
roomData.RoomId = roomIdHint;
roomData.RegionId = roomIdHint.Contains("_")
? roomIdHint.Split('_')[1]
: "";
AssetDatabase.CreateAsset(roomData, roomSoPath);
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
roomSoCreated = true;
}
// ── Report ─────────────────────────────────────────────────────
report.Add("在 RoomController._roomId 填写唯一房间 ID如 \"Room_Forest_01\")。");
if (roomSoCreated)
report.Add($"已自动创建 MapRoomDataSO 模板:{roomSoPath}。请填写 GridPosition / GridSize / Exits并将其添加到 MapDatabaseSO.AllRooms 中。");
else
report.Add($"MapRoomDataSO 已存在({roomSoPath}),请确认其 RoomId / Exits 与场景保持同步。");
report.Add("绑定 LensConfig SO 后单击 Inspector 中「从可视区域更新限位区域」计算正确的 BoxCollider。");
report.Add("使用 Tile Palette 在 Ground Tilemap 上绘制地形,然后在 NavSurface Inspector 中点击 Bake。");
report.Add("[Transitions] 子节点下使用 BaseGames/Scene/Place/Room Transition 添加过渡点。");
@@ -427,9 +454,10 @@ namespace BaseGames.Editor
AssignReference(streamingMgr, "_budget", budgetConfig);
AssignAsset(streamingMgr, "_onRoomEntered", report, false, "EVT_RoomEntered");
AssignAsset(streamingMgr, "_onRoomPreloaded", report, false, "EVT_RoomPreloaded");
AssignAsset(streamingMgr, "_onRoomActivated", report, false, "EVT_RoomActivated");
// ── TransitionDirector 字段 ───────────────────────────────────────
AssignReference(transitionDir, "_streamingManager", streamingMgr);
AssignReference(transitionDir, "_streamingManagerRef", streamingMgr);
AssignReference(transitionDir, "_mapDatabase", mapDbAsset);
AssignReference(transitionDir, "_budget", budgetConfig);
AssignAsset(transitionDir, "_onFadeOutRequest", report, false, "EVT_FadeOutRequest");

View File

@@ -0,0 +1,175 @@
using UnityEditor;
using UnityEngine;
using BaseGames.Core.Events;
using BaseGames.World;
using BaseGames.World.Map;
namespace BaseGames.Editor
{
/// <summary>
/// RoomTransition 自定义 Inspector。
/// <para>
/// 在默认字段下方附加「MapDatabase 同步」区块:
/// 自动查找场景内的 <see cref="RoomController"/>,定位所属房间在 <see cref="MapDatabaseSO"/>
/// 中对应出口的 <see cref="RoomExitData.PreferredTransitionType"/>,若与组件
/// <c>_transitionType</c> 不一致则显示警告并提供一键同步按钮。
/// </para>
/// </summary>
[CustomEditor(typeof(RoomTransition))]
public class RoomTransitionEditor : UnityEditor.Editor
{
// 每次 Inspector 打开后按需查找一次,避免每帧搜索。
private MapDatabaseSO _cachedMapDb;
private bool _mapDbSearched;
public override void OnInspectorGUI()
{
DrawDefaultInspector();
EditorGUILayout.Space(8f);
DrawSyncSection();
}
private void DrawSyncSection()
{
var targetAddrProp = serializedObject.FindProperty("_targetSceneAddress");
var typeProp = serializedObject.FindProperty("_transitionType");
if (targetAddrProp == null || typeProp == null) return;
string targetAddr = targetAddrProp.stringValue;
if (string.IsNullOrEmpty(targetAddr)) return;
// 在同一场景内找 RoomController多场景 Additive 时仅匹配本对象所在场景)
string sourceRoomId = FindSourceRoomId(((RoomTransition)target).gameObject);
if (string.IsNullOrEmpty(sourceRoomId)) return;
var mapDb = GetMapDatabase();
if (mapDb == null) return;
var roomData = mapDb.GetRoom(sourceRoomId);
if (roomData?.Exits == null) return;
TransitionType? soType = null;
foreach (var exit in roomData.Exits)
{
if (exit.TargetRoomId == targetAddr)
{
soType = exit.PreferredTransitionType;
break;
}
}
if (!soType.HasValue) return;
var currentType = (TransitionType)typeProp.intValue;
EditorGUILayout.LabelField("MapDatabase 同步", EditorStyles.boldLabel);
if (currentType == soType.Value)
{
var prev = GUI.color;
GUI.color = new Color(0.4f, 0.9f, 0.4f);
EditorGUILayout.LabelField($" ✓ 与 MapDatabase 一致({soType.Value}");
GUI.color = prev;
}
else
{
EditorGUILayout.HelpBox(
$"MapDatabase 中出口 → {targetAddr} 的 PreferredTransitionType = {soType.Value}" +
$"但当前组件设置为 {currentType}。两者不一致可能导致关卡编辑时信息有误。",
MessageType.Warning);
if (GUILayout.Button($"从 MapDatabase 同步 → {soType.Value}"))
{
serializedObject.Update();
typeProp.intValue = (int)soType.Value;
serializedObject.ApplyModifiedProperties();
}
}
}
/// <summary>在与 <paramref name="go"/> 相同场景内找到 RoomController 并返回其 RoomId。</summary>
private static string FindSourceRoomId(GameObject go)
{
var scene = go.scene;
if (!scene.IsValid()) return null;
foreach (var root in scene.GetRootGameObjects())
{
var ctrl = root.GetComponentInChildren<RoomController>(true);
if (ctrl != null) return ctrl.RoomId;
}
return null;
}
private MapDatabaseSO GetMapDatabase()
{
if (_mapDbSearched) return _cachedMapDb;
_mapDbSearched = true;
var guids = AssetDatabase.FindAssets("t:MapDatabaseSO");
if (guids.Length > 0)
_cachedMapDb = AssetDatabase.LoadAssetAtPath<MapDatabaseSO>(
AssetDatabase.GUIDToAssetPath(guids[0]));
return _cachedMapDb;
}
// ── 批量验证菜单项 ────────────────────────────────────────────────────────
[MenuItem("BaseGames/Scene/Validate Transition Types")]
private static void ValidateAllTransitionTypes()
{
var guids = AssetDatabase.FindAssets("t:MapDatabaseSO");
if (guids.Length == 0)
{
EditorUtility.DisplayDialog("验证失败", "未找到 MapDatabaseSO 资产,请先创建。", "确定");
return;
}
var mapDb = AssetDatabase.LoadAssetAtPath<MapDatabaseSO>(
AssetDatabase.GUIDToAssetPath(guids[0]));
var transitions = Object.FindObjectsByType<RoomTransition>(
FindObjectsInactive.Include, FindObjectsSortMode.None);
int mismatch = 0;
int skipped = 0;
foreach (var t in transitions)
{
var so = new SerializedObject(t);
var targetProp = so.FindProperty("_targetSceneAddress");
var typeProp = so.FindProperty("_transitionType");
if (targetProp == null || typeProp == null) continue;
string sourceRoomId = FindSourceRoomId(t.gameObject);
if (string.IsNullOrEmpty(sourceRoomId)) { skipped++; continue; }
var roomData = mapDb.GetRoom(sourceRoomId);
if (roomData?.Exits == null) { skipped++; continue; }
string targetAddr = targetProp.stringValue;
TransitionType? soType = null;
foreach (var exit in roomData.Exits)
{
if (exit.TargetRoomId == targetAddr) { soType = exit.PreferredTransitionType; break; }
}
if (!soType.HasValue) { skipped++; continue; }
var currentType = (TransitionType)typeProp.intValue;
if (currentType != soType.Value)
{
mismatch++;
Debug.LogWarning(
$"[TransitionType 不一致] {t.gameObject.scene.name} / {t.gameObject.name}" +
$"MapDatabase={soType.Value} 组件={currentType}",
t.gameObject);
}
}
string msg = mismatch == 0
? $"全部 {transitions.Length} 个 RoomTransition 与 MapDatabase 一致(跳过 {skipped} 个)。"
: $"发现 {mismatch} 处类型不一致(跳过 {skipped} 个)。详细信息见 Console 日志。";
EditorUtility.DisplayDialog("验证结果", msg, "确定");
}
}
}

View File

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

View File

@@ -0,0 +1,625 @@
using System.Collections.Generic;
using System.Linq;
using UnityEditor;
using UnityEditor.AddressableAssets;
using UnityEngine;
using BaseGames.World;
using BaseGames.World.Map;
using BaseGames.World.Streaming;
using BaseGames.Core.Events;
namespace BaseGames.Editor
{
/// <summary>
/// 房间连通性可视化编辑器窗口。
/// <para>
/// 功能:
/// <list type="bullet">
/// <item>读取 <see cref="MapDatabaseSO"/> 绘制全图房间节点(以 GridPosition/GridSize 定位)</item>
/// <item>绘制出口连线,颜色按 <see cref="TransitionType"/> 区分</item>
/// <item>节点边框颜色 = Addressable 注册状态(绿色已注册 / 红色未注册)</item>
/// <item>运行时叠加:在 Play Mode 下按 <see cref="RoomState"/> 着色节点填充</item>
/// <item>单击节点 → Ping SO双击 → 打开对应场景</item>
/// <item>支持鼠标滚轮缩放(向光标缩放)和中键拖拽平移</item>
/// </list>
/// </para>
/// <para>菜单:<c>BaseGames/World/Streaming Graph</c></para>
/// </summary>
public class StreamingGraphWindow : EditorWindow
{
// ── 常量 ───────────────────────────────────────────────────────────────────
private const float ToolbarH = 28f;
private const float StatusBarH = 22f;
private const float CellSizePx = 20f; // 每格默认像素数
private const float MinNodeW = 64f;
private const float MinNodeH = 28f;
private const float LegendRowH = 16f; // 图例行高
// 颜色:过渡类型(连线)
private static readonly Color ColorSeamless = new(0.2f, 0.9f, 0.9f);
private static readonly Color ColorAtmospheric = new(0.8f, 0.4f, 1.0f);
private static readonly Color ColorRoomType = new(0.8f, 0.8f, 0.8f);
private static readonly Color ColorSceneType = new(1.0f, 0.6f, 0.1f);
// 颜色Addressable 注册状态(节点边框)
private static readonly Color ColorRegistered = new(0.2f, 0.9f, 0.2f);
private static readonly Color ColorUnregistered = new(0.9f, 0.2f, 0.2f);
// 颜色:运行时房间状态(节点填充)
private static readonly Color ColorActive = new(0.1f, 0.7f, 0.2f, 0.85f);
private static readonly Color ColorDormant = new(0.2f, 0.4f, 0.7f, 0.75f);
private static readonly Color ColorLoading = new(0.9f, 0.8f, 0.1f, 0.75f);
private static readonly Color ColorCooling = new(0.9f, 0.5f, 0.1f, 0.75f);
private static readonly Color ColorActivating = new(0.3f, 0.8f, 0.4f, 0.75f);
private static readonly Color ColorUnloading = new(0.7f, 0.2f, 0.2f, 0.75f);
private static readonly Color ColorDefaultFill = new(0.25f, 0.25f, 0.28f, 0.95f);
private static readonly Color ColorCanvasBg = new(0.15f, 0.15f, 0.17f, 1.0f);
private static readonly Color ColorBoss = new(1.0f, 0.3f, 0.3f);
// ── 运行时状态 ────────────────────────────────────────────────────────────
private MapDatabaseSO _mapDb;
private List<NodeData> _nodes = new();
private Dictionary<string, NodeData> _nodeIndex = new(); // O(1) roomId→node 索引
private IRoomStreamingManager _cachedMgr; // Play Mode 缓存,避免每帧扫描
private Vector2 _offset = new(20f, 20f);
private float _zoom = 1f;
private bool _isPanning;
private string _regionFilter = "";
private string[] _regions = System.Array.Empty<string>();
private int _regionIndex; // 0 = 全部
private bool _showLegend = true;
private GUIStyle _labelStyle;
// ── 菜单 / 打开 ───────────────────────────────────────────────────────────
[MenuItem("BaseGames/World/Streaming Graph")]
public static void Open()
{
var win = GetWindow<StreamingGraphWindow>("Streaming Graph");
win.minSize = new Vector2(400f, 300f);
win.Refresh();
}
// ── 数据类 ────────────────────────────────────────────────────────────────
private class NodeData
{
public MapRoomDataSO So;
public string RoomId;
public bool IsRegistered;
public RoomState? RuntimeState; // null = 编辑模式或未在流式系统中
}
// ── Unity 回调 ────────────────────────────────────────────────────────────
private void OnEnable()
{
// Play Mode 进入/退出时自动刷新 Addressable + 运行时状态
EditorApplication.playModeStateChanged += OnPlayModeChanged;
Refresh();
}
private void OnDisable()
{
EditorApplication.playModeStateChanged -= OnPlayModeChanged;
}
private void OnPlayModeChanged(PlayModeStateChange change)
{
if (change == PlayModeStateChange.EnteredPlayMode ||
change == PlayModeStateChange.EnteredEditMode)
{
_cachedMgr = null;
Refresh();
}
}
private void OnGUI()
{
InitStyles();
DrawToolbar();
var canvasRect = new Rect(0f, ToolbarH, position.width, position.height - ToolbarH - StatusBarH);
if (_mapDb == null)
{
EditorGUI.DrawRect(canvasRect, ColorCanvasBg);
GUI.Label(
new Rect(canvasRect.center.x - 120f, canvasRect.center.y - 10f, 240f, 20f),
"未找到 MapDatabaseSO请先创建并点击刷新。");
}
else
{
if (Application.isPlaying)
UpdateRuntimeStates();
DrawCanvas(canvasRect);
}
DrawStatusBar();
// 持续 Repaint保持运行时状态颜色实时更新
if (Application.isPlaying)
Repaint();
}
// ── 工具栏 ────────────────────────────────────────────────────────────────
private void DrawToolbar()
{
using var _ = new EditorGUILayout.HorizontalScope(EditorStyles.toolbar,
GUILayout.Height(ToolbarH));
if (GUILayout.Button("刷新", EditorStyles.toolbarButton, GUILayout.Width(48)))
Refresh();
if (GUILayout.Button("居中", EditorStyles.toolbarButton, GUILayout.Width(48)))
CenterView();
GUILayout.Label("区域:", EditorStyles.miniLabel, GUILayout.Width(36));
int newIdx = EditorGUILayout.Popup(_regionIndex, _regions,
EditorStyles.toolbarPopup, GUILayout.Width(100));
if (newIdx != _regionIndex)
{
_regionIndex = newIdx;
_regionFilter = newIdx == 0 ? "" : _regions[newIdx];
BuildNodeCache();
}
GUILayout.FlexibleSpace();
// 缩放显示
GUILayout.Label($"缩放:{_zoom:F2}×", EditorStyles.miniLabel, GUILayout.Width(72));
_showLegend = GUILayout.Toggle(_showLegend, "图例", EditorStyles.toolbarButton,
GUILayout.Width(42));
}
// ── 画布 ──────────────────────────────────────────────────────────────────
private void DrawCanvas(Rect canvasRect)
{
// 背景
if (Event.current.type == EventType.Repaint)
EditorGUI.DrawRect(canvasRect, ColorCanvasBg);
HandleCanvasInput(canvasRect);
if (Event.current.type != EventType.Repaint)
return;
// 先绘制连线(在节点层下面)
Handles.BeginGUI();
DrawEdges(canvasRect);
Handles.EndGUI();
// 绘制节点填充 + 标签GUI.BeginClip 裁剪至画布范围)
GUI.BeginClip(canvasRect);
DrawNodeFills(canvasRect);
GUI.EndClip();
// 绘制节点边框Handles使用绝对坐标
Handles.BeginGUI();
DrawNodeBorders(canvasRect);
Handles.EndGUI();
if (_showLegend)
DrawLegend(canvasRect);
}
private void HandleCanvasInput(Rect canvasRect)
{
var e = Event.current;
if (!canvasRect.Contains(e.mousePosition))
{
if (_isPanning && e.type == EventType.MouseUp)
_isPanning = false;
return;
}
// 滚轮缩放(向光标点缩放)
if (e.type == EventType.ScrollWheel)
{
float prevZoom = _zoom;
_zoom = Mathf.Clamp(_zoom * (1f - e.delta.y * 0.05f), 0.15f, 5f);
var cursor = e.mousePosition - canvasRect.min;
_offset = cursor - (_zoom / prevZoom) * (cursor - _offset);
e.Use();
Repaint();
return;
}
// 中键按下 / 松开
if (e.type == EventType.MouseDown && e.button == 2) { _isPanning = true; e.Use(); }
if (e.type == EventType.MouseUp && e.button == 2) { _isPanning = false; e.Use(); }
// 中键拖拽平移
if (e.type == EventType.MouseDrag && _isPanning)
{
_offset += e.delta;
e.Use();
Repaint();
return;
}
// 左键点击节点
if (e.type == EventType.MouseDown && e.button == 0)
{
foreach (var node in _nodes)
{
var r = GetAbsNodeRect(node, canvasRect);
if (!r.Contains(e.mousePosition)) continue;
if (e.clickCount == 2)
OpenRoomScene(node.RoomId);
else
{
Selection.activeObject = node.So;
EditorGUIUtility.PingObject(node.So);
}
e.Use();
break;
}
}
}
// ── 绘制:连线 ────────────────────────────────────────────────────────────
private void DrawEdges(Rect canvasRect)
{
foreach (var node in _nodes)
{
if (node.So.Exits == null) continue;
foreach (var exit in node.So.Exits)
{
if (!_nodeIndex.TryGetValue(exit.TargetRoomId, out var targetNode)) continue;
// 从出口格子坐标到目标房间中心
var from = canvasRect.min + CanvasLocalPos(exit.ExitGridPos.x, exit.ExitGridPos.y);
var to = GetAbsNodeRect(targetNode, canvasRect).center;
Handles.color = GetTransitionColor(exit.PreferredTransitionType);
Handles.DrawLine(from, to);
DrawArrowHead(from, to, 6f);
}
}
}
private static void DrawArrowHead(Vector2 from, Vector2 to, float size)
{
var dir = (to - from).normalized;
if (dir == Vector2.zero) return;
var perp = new Vector2(-dir.y, dir.x);
var tip = to;
var base1 = tip - dir * size + perp * (size * 0.5f);
var base2 = tip - dir * size - perp * (size * 0.5f);
Handles.DrawLine(tip, base1);
Handles.DrawLine(tip, base2);
}
// ── 绘制节点填充BeginClip 内,画布局部坐标) ─────────────────────────
private void DrawNodeFills(Rect canvasRect)
{
foreach (var node in _nodes)
{
// 在 BeginClip 内:坐标相对于 canvasRect.min
var localRect = GetLocalNodeRect(node);
if (!new Rect(0, 0, canvasRect.width, canvasRect.height).Overlaps(localRect))
continue;
EditorGUI.DrawRect(localRect, GetStateFillColor(node.RuntimeState));
// Boss 房间底部红色条
if (node.So.IsBossRoom)
{
var markerRect = new Rect(localRect.x, localRect.yMax - 3f, localRect.width, 3f);
EditorGUI.DrawRect(markerRect, ColorBoss);
}
// 标签
var label = BuildLabel(node);
GUI.Label(localRect, label, _labelStyle);
}
}
// ── 绘制节点边框Handles绝对坐标 ──────────────────────────────────
private void DrawNodeBorders(Rect canvasRect)
{
foreach (var node in _nodes)
{
var r = GetAbsNodeRect(node, canvasRect);
if (!canvasRect.Overlaps(r)) continue;
var color = node.IsRegistered ? ColorRegistered : ColorUnregistered;
DrawHandleRect(r, color);
}
}
private static void DrawHandleRect(Rect r, Color c)
{
Handles.color = c;
var tl = new Vector3(r.xMin, r.yMin);
var tr = new Vector3(r.xMax, r.yMin);
var br = new Vector3(r.xMax, r.yMax);
var bl = new Vector3(r.xMin, r.yMax);
Handles.DrawLine(tl, tr);
Handles.DrawLine(tr, br);
Handles.DrawLine(br, bl);
Handles.DrawLine(bl, tl);
}
// ── 图例 ─────────────────────────────────────────────────────────────────
private void DrawLegend(Rect canvasRect)
{
const float W = 170f;
const float H = 148f;
const float Px = 8f;
const float Py = 8f;
var legendRect = new Rect(canvasRect.xMax - W - Px, canvasRect.y + Py, W, H);
EditorGUI.DrawRect(legendRect, new Color(0.1f, 0.1f, 0.12f, 0.88f));
float y = legendRect.y + 6f;
LegendRow(ref y, legendRect.x + 6f, "连线颜色(过渡类型)", Color.white, isHeader: true);
LegendRow(ref y, legendRect.x + 6f, " Seamless", ColorSeamless);
LegendRow(ref y, legendRect.x + 6f, " AtmosphericFade", ColorAtmospheric);
LegendRow(ref y, legendRect.x + 6f, " Room", ColorRoomType);
LegendRow(ref y, legendRect.x + 6f, " Scene", ColorSceneType);
LegendRow(ref y, legendRect.x + 6f, "边框Addressable", Color.white, isHeader: true);
LegendRow(ref y, legendRect.x + 6f, " 已注册", ColorRegistered);
LegendRow(ref y, legendRect.x + 6f, " 未注册", ColorUnregistered);
Handles.BeginGUI();
DrawHandleRect(legendRect, new Color(0.4f, 0.4f, 0.45f));
Handles.EndGUI();
}
private static void LegendRow(ref float y, float x, string label, Color swatch,
bool isHeader = false)
{
if (!isHeader)
{
EditorGUI.DrawRect(new Rect(x, y + 3f, 12f, 10f), swatch);
x += 16f;
}
GUI.Label(new Rect(x, y, 160f, LegendRowH), label,
isHeader ? EditorStyles.boldLabel : EditorStyles.label);
y += LegendRowH;
}
// ── 状态栏 ────────────────────────────────────────────────────────────────
private void DrawStatusBar()
{
var statusRect = new Rect(0f, position.height - StatusBarH, position.width, StatusBarH);
EditorGUI.DrawRect(statusRect, new Color(0.12f, 0.12f, 0.14f));
int totalEdges = _nodes.Sum(n => n.So?.Exits?.Length ?? 0);
int unregistered = _nodes.Count(n => !n.IsRegistered);
string playing = Application.isPlaying ? " ● Play Mode" : "";
var rooms = _mapDb?.AllRooms;
int filteredCount = _nodes.Count;
int totalCount = rooms?.Length ?? 0;
string roomsStr = _regionFilter.Length > 0
? $"{filteredCount} / {totalCount} 房间"
: $"{totalCount} 房间";
string text = $" {roomsStr} · {totalEdges} 出口 · {unregistered} 未注册{playing}";
GUI.Label(new Rect(0f, position.height - StatusBarH, position.width, StatusBarH), text);
}
// ── 坐标工具 ──────────────────────────────────────────────────────────────
/// <summary>网格坐标 → 画布局部偏移(不含 canvasRect.min。</summary>
private Vector2 CanvasLocalPos(float gridX, float gridY)
=> _offset + new Vector2(gridX * CellSizePx * _zoom, gridY * CellSizePx * _zoom);
/// <summary>房间节点的画布局部 Rect相对于 canvasRect.min供 BeginClip 内使用)。</summary>
private Rect GetLocalNodeRect(NodeData node)
{
var pos = CanvasLocalPos(node.So.GridPosition.x, node.So.GridPosition.y);
float w = Mathf.Max(node.So.GridSize.x * CellSizePx * _zoom, MinNodeW);
float h = Mathf.Max(node.So.GridSize.y * CellSizePx * _zoom, MinNodeH);
return new Rect(pos.x, pos.y, w, h);
}
/// <summary>房间节点的绝对 Rect含 canvasRect.min供 Handles 使用)。</summary>
private Rect GetAbsNodeRect(NodeData node, Rect canvasRect)
{
var local = GetLocalNodeRect(node);
return new Rect(canvasRect.min + local.min, local.size);
}
// ── 数据刷新 ──────────────────────────────────────────────────────────────
private void Refresh()
{
_mapDb = null;
var guids = AssetDatabase.FindAssets("t:MapDatabaseSO");
if (guids.Length > 0)
_mapDb = AssetDatabase.LoadAssetAtPath<MapDatabaseSO>(
AssetDatabase.GUIDToAssetPath(guids[0]));
BuildRegions();
BuildNodeCache();
CheckAddressableRegistration();
Repaint();
}
private void BuildRegions()
{
var set = new HashSet<string> { "" };
if (_mapDb?.AllRooms != null)
foreach (var r in _mapDb.AllRooms)
if (r != null && !string.IsNullOrEmpty(r.RegionId))
set.Add(r.RegionId);
var list = new List<string>(set);
list.Sort();
int allIdx = list.IndexOf("");
if (allIdx >= 0) list.RemoveAt(allIdx);
list.Insert(0, "全部");
_regions = list.ToArray();
// 第 0 项 = "全部"(对应空字符串 _regionFilter后续是各 RegionId
if (_regionIndex >= _regions.Length) _regionIndex = 0;
}
private void BuildNodeCache()
{
_nodes.Clear();
_nodeIndex.Clear();
if (_mapDb?.AllRooms == null) return;
foreach (var room in _mapDb.AllRooms)
{
if (room == null) continue;
if (!string.IsNullOrEmpty(_regionFilter) && room.RegionId != _regionFilter)
continue;
var node = new NodeData { So = room, RoomId = room.RoomId };
_nodes.Add(node);
_nodeIndex[room.RoomId] = node;
}
}
private void CheckAddressableRegistration()
{
var settings = AddressableAssetSettingsDefaultObject.Settings;
var registered = new HashSet<string>();
if (settings != null)
foreach (var group in settings.groups)
{
if (group == null) continue;
foreach (var entry in group.entries)
registered.Add(entry.address);
}
foreach (var node in _nodes)
node.IsRegistered = registered.Contains(node.RoomId);
}
private void UpdateRuntimeStates()
{
// 缓存 IRoomStreamingManager 引用,避免每帧扫描全场景对象
if (_cachedMgr == null)
_cachedMgr = Object.FindAnyObjectByType<RoomStreamingManager>() as IRoomStreamingManager;
foreach (var node in _nodes)
node.RuntimeState = _cachedMgr?.GetRoomState(node.RoomId);
}
// ── 视图工具 ──────────────────────────────────────────────────────────────
private void CenterView()
{
if (_nodes.Count == 0) return;
int minX = int.MaxValue, minY = int.MaxValue;
int maxX = int.MinValue, maxY = int.MinValue;
foreach (var n in _nodes)
{
minX = Mathf.Min(minX, n.So.GridPosition.x);
minY = Mathf.Min(minY, n.So.GridPosition.y);
maxX = Mathf.Max(maxX, n.So.GridPosition.x + Mathf.Max(n.So.GridSize.x, 1));
maxY = Mathf.Max(maxY, n.So.GridPosition.y + Mathf.Max(n.So.GridSize.y, 1));
}
int totalGridW = maxX - minX;
int totalGridH = maxY - minY;
float canvasW = position.width;
float canvasH = position.height - ToolbarH - StatusBarH;
float zoomX = (canvasW * 0.8f) / Mathf.Max(totalGridW * CellSizePx, 1f);
float zoomY = (canvasH * 0.8f) / Mathf.Max(totalGridH * CellSizePx, 1f);
_zoom = Mathf.Clamp(Mathf.Min(zoomX, zoomY), 0.15f, 3f);
float scaledW = totalGridW * CellSizePx * _zoom;
float scaledH = totalGridH * CellSizePx * _zoom;
_offset = new Vector2(
(canvasW - scaledW) * 0.5f - minX * CellSizePx * _zoom,
(canvasH - scaledH) * 0.5f - minY * CellSizePx * _zoom);
Repaint();
}
// ── 辅助 ──────────────────────────────────────────────────────────────────
private void InitStyles()
{
if (_labelStyle != null) return;
_labelStyle = new GUIStyle(EditorStyles.label)
{
alignment = TextAnchor.MiddleCenter,
fontSize = 9,
wordWrap = true,
normal = { textColor = new Color(0.92f, 0.92f, 0.92f) }
};
}
private static Color GetStateFillColor(RoomState? state)
{
if (!state.HasValue) return ColorDefaultFill;
return state.Value switch
{
RoomState.Active => ColorActive,
RoomState.Activating => ColorActivating,
RoomState.Dormant => ColorDormant,
RoomState.Loading => ColorLoading,
RoomState.Cooling => ColorCooling,
RoomState.Unloading => ColorUnloading,
_ => ColorDefaultFill
};
}
private static Color GetTransitionColor(TransitionType t) => t switch
{
TransitionType.Seamless => ColorSeamless,
TransitionType.AtmosphericFade => ColorAtmospheric,
TransitionType.Scene => ColorSceneType,
_ => ColorRoomType
};
private static string BuildLabel(NodeData node)
{
var id = node.RoomId ?? "";
// 缩短显示:去掉 "Room_" 前缀
if (id.StartsWith("Room_")) id = id[5..];
string stateTag = node.RuntimeState switch
{
RoomState.Active => "\n● Active",
RoomState.Activating => "\n◑ Activating",
RoomState.Dormant => "\n○ Dormant",
RoomState.Loading => "\n⟳ Loading",
RoomState.Cooling => "\n◌ Cooling",
RoomState.Unloading => "\n✕ Unloading",
_ => ""
};
string bossTag = node.So.IsBossRoom ? " [B]" : "";
return id + bossTag + stateTag;
}
private static void OpenRoomScene(string roomId)
{
var guids = AssetDatabase.FindAssets($"t:SceneAsset {roomId}");
if (guids.Length == 0)
{
Debug.LogWarning($"[StreamingGraph] 未找到场景 {roomId}。");
return;
}
var path = AssetDatabase.GUIDToAssetPath(guids[0]);
if (UnityEditor.SceneManagement.EditorSceneManager.SaveCurrentModifiedScenesIfUserWantsTo())
UnityEditor.SceneManagement.EditorSceneManager.OpenScene(path);
}
}
}

View File

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

View File

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

View File

@@ -24,7 +24,7 @@ namespace BaseGames.Enemies.AI
public override void OnAwake()
{
_boss = GetComponent<BossBase>();
_executor = GetComponentInChildren<BossSkillExecutor>();
_executor = transform.GetComponentInChildren<BossSkillExecutor>();
}
public override TaskStatus OnUpdate()

View File

@@ -0,0 +1,246 @@
using System.Collections;
using UnityEngine;
using BaseGames.Core;
using BaseGames.Core.Pool;
using BaseGames.World;
namespace BaseGames.Enemies
{
/// <summary>
/// 场景内的敌人刷新点。实现 <see cref="IRoomLifecycle"/>,与房间流式系统联动:
/// <list type="bullet">
/// <item>房间激活(<see cref="OnRoomActivate"/>)时生成敌人实例。</item>
/// <item>房间休眠(<see cref="OnRoomDormant"/>)时清理仍存活的实例,确保下次进入时刷出新实例。</item>
/// <item>可选 <see cref="_respawnDelay"/>:敌人在房间内死亡后等待若干秒自动复活。</item>
/// <item>可选的 <see cref="_persistentKillId"/>:击杀后写入 <see cref="WorldStateRegistry"/>
/// 使敌人在存档重载后不再复活(适用于 Boss 或仅出现一次的守卫)。</item>
/// </list>
/// <para>
/// 挂载方式:在房间场景中放置空 GameObject 作为刷新点,挂上本组件,
/// 并通过 <see cref="_enemyPrefab"/> 或 <see cref="_poolKey"/> 指定要生成的敌人。
/// </para>
/// </summary>
public class EnemyRespawner : MonoBehaviour, IRoomLifecycle
{
[Header("生成配置")]
[Tooltip("要生成的敌人预制体(直接引用)。当 _poolKey 非空时,预制体作为兜底备选。")]
[SerializeField] private GameObject _enemyPrefab;
[Tooltip("对象池键(对应 GlobalObjectPool 中已预热的 AddressKey 常量)。\n" +
"非空时优先通过 IObjectPoolService 取用对象;池服务不可用时回退至 _enemyPrefab。")]
[SerializeField] private string _poolKey;
[Header("房间内复活")]
[Tooltip("0默认= 敌人死亡后不在房间内复活,仅下次进入房间时重新生成。\n" +
"大于 0 = 死亡后等待 N 秒在原位复活,适用于需要持续巡逻的普通敌人。")]
[Min(0f)]
[SerializeField] private float _respawnDelay = 0f;
[Tooltip("每次进入房间时,敌人最多在房间内复活的次数。\n" +
"0 = 不限次数(仅在 _respawnDelay > 0 时有意义)。\n" +
"示例:设为 2 表示同一次房间访问中敌人最多死而复生 2 次,第 3 次死后不再刷新。")]
[Min(0)]
[SerializeField] private int _maxRespawnCount = 0;
[Header("永久击杀")]
[Tooltip("空 = 每次进入房间均复活。\n" +
"非空 = 击杀后将此 ID 写入 WorldStateRegistry.DestroyedObjectIds存档后不再复活。\n" +
"适用于 Boss 或仅出现一次的关键敌人。建议格式ENM_{EnemyId}_{RoomId}")]
[SerializeField] private string _persistentKillId;
[Tooltip("永久击杀时写入的世界状态注册表。仅在 _persistentKillId 非空时使用。")]
[SerializeField] private WorldStateRegistry _worldState;
// ── 运行时 ────────────────────────────────────────────────────────────────
private EnemyBase _activeEnemy;
/// <summary>本次房间访问中敌人已在房间内复活的次数(不含初始生成)。</summary>
private int _respawnCountThisVisit;
private Coroutine _respawnCoroutine;
// ── IRoomLifecycle ────────────────────────────────────────────────────────
/// <summary>
/// 房间休眠时清理当前存活的敌人实例,并取消待执行的复活协程。
/// 已进入死亡流程(<see cref="EnemyBase.IsAlive"/> = false的实例由
/// <see cref="EnemyBase.Die"/> 自行处理,此处不干预。
/// </summary>
public void OnRoomDormant()
{
// 取消待执行的复活计时
if (_respawnCoroutine != null)
{
StopCoroutine(_respawnCoroutine);
_respawnCoroutine = null;
}
if (_activeEnemy != null)
{
_activeEnemy.OnDied -= OnEnemyDied;
if (_activeEnemy.IsAlive)
{
// 优先归还对象池;池服务不可用或无 PooledObject 时直接销毁
var po = _activeEnemy.GetComponent<PooledObject>();
var pool = !string.IsNullOrEmpty(_poolKey)
? ServiceLocator.GetOrDefault<IObjectPoolService>()
: null;
if (pool != null && po != null)
pool.Despawn(_poolKey, po);
else
Destroy(_activeEnemy.gameObject);
}
// IsAlive=falseEnemyBase.Die() 已通过动画回调调度销毁,自然完成即可
_activeEnemy = null;
}
_respawnCountThisVisit = 0;
}
/// <summary>
/// 房间激活时生成敌人。永久死亡的敌人不会再次生成。
/// </summary>
/// <param name="context">出生上下文(含是否为复活流程等信息)。</param>
public void OnRoomActivate(SpawnContext context)
{
// 永久击杀检查:存档中已标记为 Destroyed不再生成
if (!string.IsNullOrEmpty(_persistentKillId)
&& _worldState != null
&& _worldState.IsDestroyed(_persistentKillId))
return;
// 重置本轮复活计数
_respawnCountThisVisit = 0;
// 快速折返时防止重复生成(上一轮实例尚存活)
if (_activeEnemy != null && _activeEnemy.IsAlive) return;
_activeEnemy = null;
SpawnEnemy();
}
// ── 内部 ──────────────────────────────────────────────────────────────────
private void SpawnEnemy()
{
GameObject go = null;
// 优先:对象池
if (!string.IsNullOrEmpty(_poolKey))
{
var pool = ServiceLocator.GetOrDefault<IObjectPoolService>();
go = pool?.Spawn(_poolKey, transform.position, transform.rotation);
}
// 兜底:直接实例化
if (go == null && _enemyPrefab != null)
go = Instantiate(_enemyPrefab, transform.position, transform.rotation);
if (go == null)
{
Debug.LogError($"[EnemyRespawner] {name}:无法生成敌人,请检查 _enemyPrefab 或 _poolKey 配置。", this);
return;
}
if (!go.TryGetComponent<EnemyBase>(out var enemy))
{
Debug.LogWarning($"[EnemyRespawner] {name}:生成的 GameObject 上未找到 EnemyBase 组件。", this);
return;
}
_activeEnemy = enemy;
// 确保对象池复用路径也能正确重置运行时状态
_activeEnemy.OnSpawn();
_activeEnemy.OnDied += OnEnemyDied;
}
private void OnEnemyDied()
{
// 永久击杀:写入 WorldStateRegistry存档管道持久化至 SaveData.World.DestroyedObjectIds
if (!string.IsNullOrEmpty(_persistentKillId) && _worldState != null)
_worldState.MarkDestroyed(_persistentKillId);
if (_activeEnemy != null)
{
_activeEnemy.OnDied -= OnEnemyDied;
_activeEnemy = null;
}
// 房间内延迟复活_respawnDelay > 0 且未超过复活上限
bool canRespawn = _respawnDelay > 0f
&& (_maxRespawnCount == 0 || _respawnCountThisVisit < _maxRespawnCount);
if (canRespawn)
_respawnCoroutine = StartCoroutine(RespawnAfterDelay());
}
private IEnumerator RespawnAfterDelay()
{
yield return new WaitForSeconds(_respawnDelay);
// 房间可能在等待期间进入休眠OnRoomDormant 会 StopCoroutine此处为双重保险
if (!gameObject.activeInHierarchy) yield break;
// 再次检查永久击杀(等待期间存档状态可能已变化)
if (!string.IsNullOrEmpty(_persistentKillId)
&& _worldState != null
&& _worldState.IsDestroyed(_persistentKillId))
yield break;
_respawnCountThisVisit++;
_respawnCoroutine = null;
SpawnEnemy();
}
private void OnDestroy()
{
// 场景卸载时确保取消订阅,防止野指针回调
if (_activeEnemy != null)
_activeEnemy.OnDied -= OnEnemyDied;
}
// ── OnValidate ────────────────────────────────────────────────────────────
#if UNITY_EDITOR
private void OnValidate()
{
// _persistentKillId 非空但 _worldState 未关联时,尝试自动查找场景内唯一实例
if (!string.IsNullOrEmpty(_persistentKillId) && _worldState == null)
_worldState = FindFirstObjectByType<WorldStateRegistry>();
}
#endif
// ── Editor Gizmos ─────────────────────────────────────────────────────────
#if UNITY_EDITOR
private static readonly Color s_GizmoColorNormal = new(1f, 0.35f, 0.2f, 0.85f);
private static readonly Color s_GizmoColorPersistent = new(1f, 0.85f, 0.1f, 0.85f);
private void OnDrawGizmos()
{
// 未选中时:小球 + 低透明度,便于关卡编辑时总览所有刷新点
Color c = !string.IsNullOrEmpty(_persistentKillId)
? s_GizmoColorPersistent
: s_GizmoColorNormal;
c.a = 0.3f;
Gizmos.color = c;
Gizmos.DrawWireSphere(transform.position, 0.25f);
}
private void OnDrawGizmosSelected()
{
bool isPersistent = !string.IsNullOrEmpty(_persistentKillId);
Gizmos.color = isPersistent ? s_GizmoColorPersistent : s_GizmoColorNormal;
Gizmos.DrawWireSphere(transform.position, 0.4f);
var labelStyle = new GUIStyle(UnityEditor.EditorStyles.miniLabel)
{
normal = { textColor = Gizmos.color }
};
string label = isPersistent ? $"{name} [永久]" : name;
UnityEditor.Handles.Label(transform.position + Vector3.up * 0.65f, label, labelStyle);
}
#endif
}
}

View File

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

View File

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

View File

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

View File

@@ -45,5 +45,11 @@ namespace BaseGames.World
/// 用于非流式冷启动路径(如游戏初始化、快速传送落地)。
/// </summary>
IEnumerator LoadAndActivateRoomCoroutine(string roomId, SpawnContext context);
/// <summary>
/// 获取指定房间的当前流式状态。
/// 若房间不在流式系统中(未加载或 ID 不存在),返回 <see cref="RoomState.Unloaded"/>。
/// </summary>
RoomState GetRoomState(string roomId);
}
}

View File

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

View File

@@ -8,7 +8,7 @@ namespace BaseGames.World.Map
{
/// <summary>
/// 单个房间的地图数据 SO架构 15_MapShopModule §1.1)。
/// 资产路径: Assets/ScriptableObjects/Map/Room_{RoomId}.asset
/// 资产路径: Assets/_Game/Data/Map/Rooms/Room_{RoomId}.asset
/// </summary>
[CreateAssetMenu(menuName = "BaseGames/World/Map/RoomData")]
public class MapRoomDataSO : ScriptableObject
@@ -61,7 +61,7 @@ namespace BaseGames.World.Map
/// <summary>
/// 全局地图数据库 SO编辑器配置一次架构 15_MapShopModule §1.1)。
/// 资产路径: Assets/ScriptableObjects/Map/MapDatabase.asset
/// 资产路径: Assets/_Game/Data/Map/MapDatabase.asset
/// </summary>
[CreateAssetMenu(menuName = "BaseGames/World/Map/MapDatabase")]
public class MapDatabaseSO : ScriptableObject

View File

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

View File

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

View File

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

View File

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

View File

@@ -96,6 +96,13 @@ namespace BaseGames.World.Streaming
{
if (State == RoomState.Unloaded || State == RoomState.Unloading || !_sceneHandle.IsValid()) yield break;
// 若正处于加载中等待加载完成后再卸载Addressables 不支持对未完成的句柄直接卸载)
if (State == RoomState.Loading)
{
while (!_sceneHandle.IsDone)
yield return null;
}
// Active 状态先走 Deactivate再设置 Unloading
bool needsDeactivate = State == RoomState.Active ||
State == RoomState.Activating ||

View File

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

View File

@@ -22,14 +22,16 @@ namespace BaseGames.World.Streaming
/// </para>
/// </summary>
[DefaultExecutionOrder(-800)]
public class RoomStreamingManager : MonoBehaviour, IRoomStreamingManager
public class RoomStreamingManager : MonoBehaviour, IRoomStreamingManager, ISceneLoadCoordinator
{
[Header("配置")]
[SerializeField] private MapDatabaseSO _mapDatabase;
[SerializeField] private StreamingBudgetConfigSO _budget;
[Header("事件频道 - 监听")]
[Tooltip("玩家进入新房间时发布(携带 RoomId 字符串)。")]
[Header("事件频道 - 监听(输入)")]
[Tooltip("外部触发器或非流式加载路径在玩家到达新房间时发布此事件(携带 RoomId)。\n" +
"流式管理器订阅此频道以支持冷启动和外部调试传送。\n" +
"正常流式过渡路径由 ActivateRoomCoroutine 直接更新 _currentRoomId不依赖此订阅。")]
[SerializeField] private StringEventChannelSO _onRoomEntered;
[Header("事件频道 - 发布")]
@@ -37,6 +39,11 @@ namespace BaseGames.World.Streaming
"供 TransitionDirector 检查是否可执行 Seamless 切换。")]
[SerializeField] private StringEventChannelSO _onRoomPreloaded;
[Tooltip("房间激活完成Active后发布携带 RoomId。\n" +
"地图探索、任务系统等应订阅此事件以感知当前房间变更。\n" +
"注意:与 _onRoomEntered输入是不同频道避免流式管理器自身产生事件循环。")]
[SerializeField] private StringEventChannelSO _onRoomActivated;
[Header("格子单位(世界坐标)")]
[Tooltip("每个格子对应的 Unity 世界坐标单位数,与关卡设计网格对齐。")]
[SerializeField] private float _unitsPerGrid = 16f;
@@ -73,8 +80,17 @@ namespace BaseGames.World.Streaming
}
else
{
// 首次进入(非流式路径也可能触发),直接初始化相机
// 首次注册(通常是 SceneLoader 直接加载的初始房间,绕过了流式路径)
controller.SetupCamera();
// 冷启动引导:若流式系统尚无当前房间,以此房间为起点开始预加载邻居。
// 此路径仅在游戏第一帧或从存档热载入时触发一次。
if (string.IsNullOrEmpty(_currentRoomId) && _graph != null)
{
_currentRoomId = controller.RoomId;
RecalculateStreamingSet(controller.RoomId);
Debug.Log($"[RoomStreamingManager] 冷启动:以 {controller.RoomId} 为基准启动流式预加载。");
}
}
}
@@ -89,11 +105,32 @@ namespace BaseGames.World.Streaming
EnqueuePreload(roomId);
}
public RoomState GetRoomState(string roomId)
{
return _handles.TryGetValue(roomId, out var handle)
? handle.State
: RoomState.Unloaded;
}
// ── ISceneLoadCoordinator ─────────────────────────────────────────────────
public bool OwnsScene(string sceneName)
=> !string.IsNullOrEmpty(sceneName) &&
sceneName.StartsWith("Room_", System.StringComparison.Ordinal);
public IEnumerator LoadAndActivateCoroutine(
string sceneName, string entryTransitionId, bool isRespawn)
{
var ctx = new SpawnContext(entryTransitionId, isRespawn);
yield return LoadAndActivateRoomCoroutine(sceneName, ctx);
}
// ── 生命周期 ──────────────────────────────────────────────────────────────
private void Awake()
{
ServiceLocator.Register<IRoomStreamingManager>(this);
ServiceLocator.Register<ISceneLoadCoordinator>(this);
_graph = WorldGraph.Build(_mapDatabase, _unitsPerGrid);
}
@@ -110,6 +147,7 @@ namespace BaseGames.World.Streaming
private void OnDestroy()
{
ServiceLocator.Unregister<IRoomStreamingManager>(this);
ServiceLocator.Unregister<ISceneLoadCoordinator>(this);
}
private void Update()
@@ -160,10 +198,14 @@ namespace BaseGames.World.Streaming
}
}
// 保留集内尚未加载的房间 → 加入预加载队列
foreach (var kv in _hopCache)
// 重建加载队列清除废弃条目keepIds 已缩小时不再需要的房间),
// 按跳数升序重新入队(近邻优先),确保有限并发槽优先服务最近的房间。
// 跳数 = 0 为当前房间本身,已在 Active 路径中处理,跳过以避免冷启动时重复加载。
_loadQueue.Clear();
_queuedRoomIds.Clear();
foreach (var kv in _hopCache.OrderBy(kv => kv.Value))
{
if (kv.Value > hops) continue;
if (kv.Value == 0 || kv.Value > hops) continue;
if (!_handles.ContainsKey(kv.Key))
EnqueuePreload(kv.Key);
}
@@ -206,6 +248,9 @@ namespace BaseGames.World.Streaming
var handle = new RoomHandle(roomId, this, perFrame);
// 从图节点读取内存估算(关卡设计师在 MapRoomDataSO 中填写)
handle.EstimatedMemKB = _graph.GetNode(roomId)?.EstimatedMemoryKB ?? 0;
if (handle.EstimatedMemKB == 0)
Debug.LogWarning($"[RoomStreamingManager] {roomId}: EstimatedMemoryKB 未设置(= 0内存预算检查将跳过此房间。" +
"请在 MapRoomDataSO 中填写 Profiler 测量值。");
_handles[roomId] = handle;
// RoomId 与 Addressable key 相同规范Room_{Region}_{Id}
@@ -231,9 +276,10 @@ namespace BaseGames.World.Streaming
{
if (_budget == null) return;
// 按距离降序 + LRU 时间升序排列所有 Dormant 房间
// 按距离降序 + LRU 时间升序排列所有 Dormant 房间
// 排除已在冷却协程中的房间:它们已被调度为卸载,无需重复发起 UnloadRoomCoroutine。
var dormants = _handles.Values
.Where(h => h.State == RoomState.Dormant)
.Where(h => h.State == RoomState.Dormant && !_roomsInCooling.Contains(h.RoomId))
.OrderByDescending(h => _hopCache.TryGetValue(h.RoomId, out int d) ? d : int.MaxValue)
.ThenBy(h => h.LastActiveTime)
.ToList();
@@ -272,10 +318,10 @@ namespace BaseGames.World.Streaming
// 冷却结束后再次检查是否仍需保留
if (_currentRoomId != null)
{
int hops = _budget != null ? _budget.PreloadLookaheadHops : 2;
var neighbors = _graph.GetNeighborsWithinHops(_currentRoomId, hops);
bool stillNeeded = neighbors.Any(n => n.RoomId == handle.RoomId)
|| handle.RoomId == _currentRoomId;
int hops = _budget != null ? _budget.PreloadLookaheadHops : 2;
// 直接复用 _hopCache已在最近一次 RecalculateStreamingSet 中更新),避免重复 BFS
bool stillNeeded = handle.RoomId == _currentRoomId
|| (_hopCache.TryGetValue(handle.RoomId, out int dist) && dist <= hops);
if (stillNeeded)
{
// 玩家已回到本房间附近:重置为 Dormant可再次被激活
@@ -303,10 +349,13 @@ namespace BaseGames.World.Streaming
// ── TransitionDirector 调用接口 ────────────────────────────────────────────
/// <summary>
/// 查询目标房间是否已处于 Dormant 状态,可执行无等待的切换。
/// 查询目标房间是否已就绪,可执行无等待的切换。
/// Dormant 和 Cooling 均视为就绪Cooling 房间已完成休眠化,
/// 可立即重置为 Dormant 后激活(常见于玩家折返场景)。
/// </summary>
public bool IsRoomDormant(string roomId)
=> _handles.TryGetValue(roomId, out var h) && h.State == RoomState.Dormant;
=> _handles.TryGetValue(roomId, out var h) &&
(h.State == RoomState.Dormant || h.State == RoomState.Cooling);
/// <summary>
/// 激活目标房间Dormant → Active并停用前一个房间。
@@ -322,14 +371,21 @@ namespace BaseGames.World.Streaming
string previousRoomId = _currentRoomId;
// Cooling 房间已完成休眠化,可直接重置为 Dormant 后激活
// 场景:玩家 A→B 后立即 B→A折返A 处于 Cooling 状态
// CoolingCoroutine 超时后检查 _currentRoomId==A → stillNeeded=true → ResetToDormant()=无操作(已不在 Cooling正常结束
if (targetHandle.State == RoomState.Cooling)
targetHandle.ResetToDormant();
// 激活新房间
yield return targetHandle.Activate(context);
_currentRoomId = targetRoomId;
// 旧房间进入冷却(不立即卸载;守卫防止 CoolingCoroutine 重复启动)
// 同时包含 Activating玩家在激活过程中快速折返时也应正确触发冷却
if (!string.IsNullOrEmpty(previousRoomId) &&
_handles.TryGetValue(previousRoomId, out var prevHandle) &&
prevHandle.State == RoomState.Active &&
(prevHandle.State == RoomState.Active || prevHandle.State == RoomState.Activating) &&
!_roomsInCooling.Contains(previousRoomId))
{
prevHandle.BeginCooling();
@@ -339,13 +395,13 @@ namespace BaseGames.World.Streaming
// 重新计算新房间的 StreamingSet
RecalculateStreamingSet(targetRoomId);
// 通知:视同进入新房间(触发地图探索更新等)
_onRoomEntered?.Raise(targetRoomId);
// 通知外部系统(地图探索、任务系统等):新房间已激活
_onRoomActivated?.Raise(targetRoomId);
}
/// <summary>
/// 将目标房间立即加载并激活(用于 Scene 类型的非流式路径首次进入某房间)。
/// 完成后广播 EVT_RoomEntered 以触发 StreamingSet 重新计算
/// 将目标房间立即加载并激活(用于 Room/Scene 类型的快速传送、复活等非预加载路径)。
/// 完成后经由 ActivateRoomCoroutine 发布 EVT_RoomActivated 并重新计算 StreamingSet。
/// </summary>
public IEnumerator LoadAndActivateRoomCoroutine(string roomId, SpawnContext context)
{
@@ -353,9 +409,17 @@ namespace BaseGames.World.Streaming
{
int perFrame = _budget != null ? _budget.LifecycleActivatePerFrame : 8;
var handle = new RoomHandle(roomId, this, perFrame);
handle.EstimatedMemKB = _graph.GetNode(roomId)?.EstimatedMemoryKB ?? 0;
handle.EstimatedMemKB = _graph?.GetNode(roomId)?.EstimatedMemoryKB ?? 0;
_handles[roomId] = handle;
yield return handle.LoadAsync(roomId);
if (handle.State != RoomState.Dormant)
{
// 加载失败:清理僵尸 handle不继续激活前一个房间保持 Active
_handles.Remove(roomId);
Debug.LogError($"[RoomStreamingManager] LoadAndActivate{roomId} 加载失败,取消激活。");
yield break;
}
}
yield return ActivateRoomCoroutine(roomId, context);

View File

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

View File

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

View File

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

View File

@@ -140,43 +140,6 @@ namespace BaseGames.World.Streaming
return node;
}
/// <summary>返回与指定房间直接相邻1 跳)的所有邻居节点。</summary>
public IEnumerable<RoomNode> GetDirectNeighbors(string roomId)
{
if (!_nodes.TryGetValue(roomId, out var node)) yield break;
foreach (var edge in node.Edges)
yield return edge.To;
}
/// <summary>
/// 通过 BFS 获取距离指定房间不超过 <paramref name="maxHops"/> 跳的所有房间节点集合。
/// 结果不包含起始节点本身。
/// </summary>
public HashSet<RoomNode> GetNeighborsWithinHops(string startRoomId, int maxHops)
{
var result = new HashSet<RoomNode>();
var visited = new HashSet<string> { startRoomId };
var queue = new Queue<(string roomId, int depth)>();
queue.Enqueue((startRoomId, 0));
while (queue.Count > 0)
{
var (current, depth) = queue.Dequeue();
if (!_nodes.TryGetValue(current, out var node)) continue;
foreach (var edge in node.Edges)
{
if (visited.Contains(edge.To.RoomId)) continue;
visited.Add(edge.To.RoomId);
result.Add(edge.To);
if (depth + 1 < maxHops)
queue.Enqueue((edge.To.RoomId, depth + 1));
}
}
return result;
}
/// <summary>
/// 计算两个房间之间的最短跳数BFS
/// 若不可达返回 int.MaxValue。

View File

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

View File

@@ -93,6 +93,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PathBerserker2d.Upgrade", "
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lofelt.NiceVibrations.Demo", "Lofelt.NiceVibrations.Demo.csproj", "{14C347B2-CA5C-168F-A3AF-5EB9B2E38865}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BaseGames.World.Streaming", "BaseGames.World.Streaming.csproj", "{8FA0AF4D-7EF6-D3CD-F2A1-9C291AA06F3C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Opsive.Shared.Editor.Import", "Opsive.Shared.Editor.Import.csproj", "{98DF9B7C-303C-762E-38C1-C19D328BF217}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BaseGames.Tutorial", "BaseGames.Tutorial.csproj", "{0A1566C3-6032-C8A1-D015-8EF75B3F7099}"
@@ -309,6 +311,10 @@ Global
{14C347B2-CA5C-168F-A3AF-5EB9B2E38865}.Debug|Any CPU.Build.0 = Debug|Any CPU
{14C347B2-CA5C-168F-A3AF-5EB9B2E38865}.Release|Any CPU.ActiveCfg = Release|Any CPU
{14C347B2-CA5C-168F-A3AF-5EB9B2E38865}.Release|Any CPU.Build.0 = Release|Any CPU
{8FA0AF4D-7EF6-D3CD-F2A1-9C291AA06F3C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8FA0AF4D-7EF6-D3CD-F2A1-9C291AA06F3C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8FA0AF4D-7EF6-D3CD-F2A1-9C291AA06F3C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8FA0AF4D-7EF6-D3CD-F2A1-9C291AA06F3C}.Release|Any CPU.Build.0 = Release|Any CPU
{98DF9B7C-303C-762E-38C1-C19D328BF217}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{98DF9B7C-303C-762E-38C1-C19D328BF217}.Debug|Any CPU.Build.0 = Debug|Any CPU
{98DF9B7C-303C-762E-38C1-C19D328BF217}.Release|Any CPU.ActiveCfg = Release|Any CPU