using Facepunch.ActionGraphs; using NativeEngine; using Sentry; using System.Text.Json.Nodes; using System.Threading; namespace Sandbox; /// /// Allows you to load a map into the Scene. This can be either a vpk or a scene map. /// [Expose] [Title( "Map Instance" )] [Category( "World" )] [Icon( "public" )] [Alias( "MapComponent" )] public partial class MapInstance : Component, Component.ExecuteInEditor { [Property, Title( "Map" ), MapAssetPath] public string MapName { get; set; } [Property] public bool UseMapFromLaunch { get; set; } [Property, MakeDirty] public bool EnableCollision { get; set; } = true; /// /// True if the map is loaded /// public bool IsLoaded => loadedMap is not null; readonly SemaphoreSlim mapLoadSemaphore = new( 1 ); readonly HashSet tokenSources = new(); /// /// Called when the map has successfully loaded /// [Property] public Action OnMapLoaded { get; set; } /// /// Called when the map has been unloaded /// [Property] public Action OnMapUnloaded { get; set; } SceneMap loadedMap; GameObject _mapPhysics; string loadedMapName; string sceneMapScenePath; public MapInstance() : base() { OnMapLoaded += UpdateDirtyReflections; OnMapUnloaded += UpdateDirtyReflections; } /// /// Get the world bounds of the map /// public BBox Bounds { get { if ( !loadedMap.IsValid() ) return default; return loadedMap.Bounds; } } internal override void OnEnabledInternal() { base.OnEnabledInternal(); Transform.OnTransformChanged += OnTransformChanged; } internal override void OnDisabledInternal() { base.OnDisabledInternal(); Transform.OnTransformChanged -= OnTransformChanged; UnloadMap(); } private void OnTransformChanged() { if ( NoOrigin ) return; var transform = new Transform( WorldPosition ); WorldTransform = transform; if ( loadedMap is not null && loadedMap.IsValid() ) { loadedMap.WorldOrigin = transform.Position; } foreach ( var body in Bodies ) { if ( !body.IsValid() ) continue; body.Transform = transform; } } protected override Task OnLoad() { if ( !Active ) return Task.CompletedTask; return LoadMapAsync(); } /// /// Unload the current map. /// public void UnloadMap() { loadedMapName = null; sceneMapScenePath = null; bool hadMap = loadedMap is not null; loadedMap?.Delete(); loadedMap = null; RemoveCollision(); Physics = null; if ( GameObject.IsValid() && GameObject.Children is not null ) { foreach ( var child in GameObject.Children ) { // In editor, don't delete saved child objects if ( Scene.IsEditor && !child.Flags.Contains( GameObjectFlags.NotSaved ) ) continue; // If I'm a client and this came from the snapshot.. don't fucking delete me if ( Networking.IsClient && child.NetworkMode == NetworkMode.Snapshot ) continue; // If it's a fully networked object and I'm the owner, we can delete if ( !child.Network.Active || child.Network.IsOwner ) child.Destroy(); } } if ( hadMap ) { OnMapUnloaded?.InvokeWithWarning(); g_pWorldRendererMgr.ServiceWorldRequests(); SceneMap.OnMapUpdated -= OnMapUpdated; } } protected override void OnUpdate() { base.OnUpdate(); if ( loadedMapName != MapName ) { _ = LoadMapAsync( MapName ); } } void CancelLoading() { foreach ( var ts in tokenSources ) { ts.Cancel(); } tokenSources.Clear(); } async Task LoadMapAsync() { if ( UseMapFromLaunch && !string.IsNullOrWhiteSpace( LaunchArguments.Map ) ) { MapName = LaunchArguments.Map; await LoadMapAsync( MapName ); return true; } if ( string.IsNullOrWhiteSpace( MapName ) ) return false; return await LoadMapAsync( MapName ); } async Task LoadMapAsync( string mapName ) { if ( loadedMapName == mapName ) return true; SentrySdk.AddBreadcrumb( $"LoadMapAsync {mapName}", "map.load" ); UnloadMap(); CancelLoading(); loadedMapName = mapName; if ( string.IsNullOrWhiteSpace( mapName ) ) { UnloadMap(); return true; } CancellationTokenSource tokenSource = new CancellationTokenSource(); tokenSources.Add( tokenSource ); var token = tokenSource.Token; try { // wait for access await mapLoadSemaphore.WaitAsync( token ); GameObject.Flags |= GameObjectFlags.Loading; token.ThrowIfCancellationRequested(); LoadingScreen.Title = $"Loading Map"; var mapFileName = mapName; if ( mapFileName.EndsWith( ".vmap" ) ) mapFileName = System.IO.Path.ChangeExtension( mapFileName, ".vpk" ); // If this looks like a package ident, then download it if ( !mapFileName.EndsWith( ".vpk" ) && Package.TryParseIdent( mapName, out var parts ) ) { var package = await Package.Fetch( mapName, false ); if ( package is null || !IsValid ) { Log.Warning( $"No package found: {mapName}" ); return false; } if ( package.TypeName != "map" ) { Log.Warning( $"Package {package.FullIdent} is not a map - it's a {package.TypeName}" ); return false; } token.ThrowIfCancellationRequested(); LoadingScreen.Title = $"Loading Map - {package.Title}"; var fs = await package.MountAsync(); if ( !IsValid || fs is null ) return false; mapFileName = package.PrimaryAsset; if ( string.IsNullOrWhiteSpace( mapFileName ) ) { var maps = fs.FindFile( "/", "*.vpk", true ).ToArray(); if ( maps.Length == 0 ) { Log.Warning( $"Package '{mapName}' had no map!" ); return false; } // use shortest name, just trying to avoid loading the skybox vpk mapFileName = maps.OrderBy( x => x.Length ).First(); } else if ( mapFileName.EndsWith( ".scene" ) ) { // Scene maps can be loaded, but we need to do some special work with the GameObjects. sceneMapScenePath = mapFileName; } } token.ThrowIfCancellationRequested(); try { loadedMapName = mapName; SentrySdk.AddBreadcrumb( $"Map Name is {loadedMapName}, filename is {mapFileName}", "map.load" ); using ( Scene.Push() ) { var loader = new MapComponentMapLoader( this, NoOrigin ? 0 : WorldPosition ); loadedMap = new SceneMap( loader.World, mapFileName, loader ); if ( loadedMap.IsValid() ) { var aggregateData = g_pPhysicsSystem.GetAggregateData( $"{loadedMap.MapFolder}/world_physics.vphys" ); if ( aggregateData.IsValid ) { var objectKey = $"{mapFileName}.World Physics"; Physics = new PhysicsGroupDescription( aggregateData ); var go = new GameObject(); // // We don't network this, because it'll be loaded on the client.. but we want the // ID to match between all clients - so we set it deterministically. // go.SetDeterministicId( objectKey.ToGuid() ); go.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.NotNetworked; go.Name = "World Physics"; go.Tags.Add( "world" ); go.SetParent( GameObject, NoOrigin ); Collider = go.Components.Create(); Collider.SetDeterministicId( $"{objectKey}.MapCollider".ToGuid() ); _mapPhysics = go; AddCollision(); } else { Log.Warning( $"Couldn't find map physics: '{loadedMap.MapFolder}/world_physics.vphys'" ); SentrySdk.AddBreadcrumb( $"Couldn't find map physics: '{loadedMap.MapFolder}/world_physics.vphys'", "map.load" ); } } LoadMapSceneGameObjects( mapName ); } } catch ( Exception e ) { SentrySdk.AddBreadcrumb( $"Couldn't load map ({e.Message})", "map.load" ); Log.Warning( e, $"Couldn't load map ({e.Message})" ); return false; } OnMapLoaded?.InvokeWithWarning(); } finally { mapLoadSemaphore.Release(); if ( GameObject.IsValid() ) { GameObject.Flags &= ~GameObjectFlags.Loading; } SceneMap.OnMapUpdated += OnMapUpdated; } tokenSources.Remove( tokenSource ); return true; } /// /// Make sure all cubemaps placed on scene are up-to-date when we /// load/unload a map instance. /// private void UpdateDirtyReflections() { if ( !IsValid ) return; foreach ( var cubemap in Scene.GetAllComponents() ) { if ( cubemap.IsValid() ) { cubemap.Dirty = true; } } } private void LoadMapSceneGameObjects( string mapName ) { // If this is being loaded from a vpk, load scene contents from world.scene_c. // If this is from an actual scene, just use that. var path = string.IsNullOrWhiteSpace( sceneMapScenePath ) ? $"{loadedMap?.MapFolder}/world.scene_c" : sceneMapScenePath + "_c"; var scene = Game.Resources.LoadRawGameResource( path ); if ( scene is not SceneFile sceneFile ) return; // Wouldn't this be nice? Doesn't make sense within a MapInstance, but when we switch away // SceneLoadOptions options = new() { IsAdditive = true }; // options.SetScene( sceneFile ); // Scene.Load( options ); using var optionsScope = ActionGraph.PushSerializationOptions( sceneFile.SerializationOptions with { ForceUpdateCached = Scene.IsEditor } ); using var sceneScope = Scene.Push(); using var batchGroup = CallbackBatch.Batch(); foreach ( var json in sceneFile.GameObjects ) { // Should we ignore this GameObject? if ( ShouldIgnoreGameObject( json ) ) continue; var go = new GameObject( false ); go.Flags |= GameObjectFlags.NotSaved; go.SetMapSource( mapName ); go.SetParent( GameObject, NoOrigin ); go.Deserialize( json ); // This is a failsafe for the above check for existing networked objects if ( Networking.IsClient && go.NetworkMode != NetworkMode.Never ) { go.DestroyImmediate(); continue; } if ( go.NetworkMode == NetworkMode.Object ) { go.NetworkSpawn(); } } } private bool ShouldIgnoreGameObject( JsonObject json ) { // Don't load another MapInstance if this scene already has one. if ( json["Components"] is JsonArray components ) { if ( components.Any( comp => comp["__type"]?.ToString() == "Sandbox.MapInstance" ) ) { return true; } } if ( !Networking.IsClient || !json.TryGetPropertyValue( JsonKeys.Id, out var id ) ) return false; var gameObject = Scene.Directory.FindByGuid( (Guid)id ); if ( !gameObject.IsValid() || gameObject.IsDestroyed || gameObject.Flags.HasFlag( GameObjectFlags.NotNetworked ) ) { return false; } // We already have a GameObject with this id and its networked, so ignore this one return gameObject.NetworkMode != NetworkMode.Never; } private void OnMapUpdated( string mapName ) { UnloadMap(); } internal void OnCreateObjectInternal( GameObject go, MapLoader.ObjectEntry kv ) { try { OnCreateObject( go, kv ); } catch ( Exception e ) { Log.Warning( e, $"Couldn't load map object {kv.TypeName} ({e.Message})" ); return; } } /// /// Override this to add components to a map object. /// Only called for map objects that are not implemented. /// protected virtual void OnCreateObject( GameObject go, MapLoader.ObjectEntry kv ) { } [Property, Hide] public bool NoOrigin { get; set; } public override int ComponentVersion => 1; [Expose, JsonUpgrader( typeof( MapInstance ), 1 )] private static void Upgrader_v1( JsonObject obj ) { obj["NoOrigin"] = true; } /// /// Get the PVS of the loaded map /// internal IPVS GetNetworkPvs() => loadedMap?.PVS ?? default; } file class MapComponentMapLoader : SceneMapLoader { private readonly MapInstance Map; private readonly Dictionary MapObjects = new(); public MapComponentMapLoader( MapInstance mapComponent, Vector3 origin ) : base( mapComponent.Scene.SceneWorld, mapComponent.Scene.PhysicsWorld, origin ) { Map = mapComponent; } // // Install a function that will create the SceneObjects from SceneMapLoader when enabled // (and delete them when disabled). This is a temporary workaround until everything has a // working InitializeFromLegacy function. // void AddMapObjectComponent( GameObject go, ObjectEntry kv ) { var c = go.Components.Create(); c.RecreateMapObjects += () => { SceneObjects.Clear(); base.CreateObject( kv ); if ( SceneObjects.Count > 0 ) { c.AddSceneObjects( SceneObjects ); } }; } void CreateStaticModel( GameObject go, ObjectEntry kv ) { bool isFuncBrush = kv.TypeName == "func_brush"; //bool solidBsp = kv.GetValue( "solidbsp", true ); int BrushSolidities_e = kv.GetValue( "Solidity", 0 ); int SolidType_t = kv.GetValue( "solid", 0 ); // Renderer { var renderer = go.Components.Create(); renderer.Model = kv.GetResource( "model" ); renderer.Tint = kv.GetValue( "rendercolor", Color.White ); } if ( isFuncBrush ) { bool makeSolid = SolidType_t > 0 && BrushSolidities_e != 1; if ( makeSolid ) { var collider = go.Components.Create(); collider.Static = true; // I think func_brush is always static? collider.Model = kv.GetResource( "model" ); } } } void CreateSoundScapeBox( GameObject go, ObjectEntry kv ) { var soundscape = go.Components.Create(); soundscape.Soundscape = GameResource.Load( kv.GetString( "Soundscape" ) ); soundscape.BoxSize = kv.GetValue( "Extents" ) * 0.5f; soundscape.Type = SoundscapeTrigger.TriggerType.Box; soundscape.Enabled = kv.GetValue( "Enabled" ); } void CreateSoundScape( GameObject go, ObjectEntry kv ) { var soundscape = go.Components.Create(); soundscape.Soundscape = GameResource.Load( kv.GetString( "Soundscape" ) ); soundscape.Radius = kv.GetValue( "radius" ); soundscape.Type = SoundscapeTrigger.TriggerType.Sphere; soundscape.Enabled = kv.GetValue( "Enabled" ); } void CreateGradientFog( GameObject go, ObjectEntry kv ) { var fog = go.Components.Create(); fog.Enabled = kv.GetValue( "fogenabled" ); fog.Color = kv.GetValue( "fogcolor" ).WithAlpha( kv.GetValue( "fogmaxopacity" ) ); fog.StartDistance = kv.GetValue( "FogStart" ); fog.EndDistance = kv.GetValue( "FogEnd" ); fog.Height = kv.GetValue( "fogendheight" ); fog.FalloffExponent = kv.GetValue( "fogfalloffexponent" ); fog.VerticalFalloffExponent = kv.GetValue( "fogverticalexponent" ); } void CreateCubemapFog( GameObject go, ObjectEntry kv ) { var fog = go.Components.Create(); fog.Sky = kv.GetResource( "cubemapfogmaterial" ); fog.Blur = kv.GetValue( "cubemapfoglodbiase" ); fog.StartDistance = kv.GetValue( "cubemapfogstartdistance" ); fog.EndDistance = kv.GetValue( "cubemapfogenddistance" ); fog.FalloffExponent = kv.GetValue( "cubemapfogfalloffexponent" ); fog.HeightExponent = kv.GetValue( "cubemapfogheightexponent" ); fog.HeightStart = kv.GetValue( "cubemapfogheightstart" ); fog.HeightWidth = kv.GetValue( "cubemapfogheightwidth" ); } void CreateProp( GameObject go, ObjectEntry kv ) { var model = kv.GetResource( "model" ); if ( model is null || !model.native.IsValid ) return; bool isAnimated = kv.TypeName == "prop_dynamic" || kv.TypeName == "prop_animated"; bool isStatic = isAnimated || kv.GetValue( "static" ); bool isNetworked = !isStatic; // Don't spawn networked props, because they will be spawned by // the network! if ( isNetworked && Networking.IsClient ) { go.Destroy(); return; } Prop prop; prop = go.Components.Create(); if ( prop.IsValid() ) { prop.Model = model; prop.Tint = kv.GetValue( "rendercolor", Color.White ); prop.WorldScale = kv.GetValue( "scales", Vector3.One ); } if ( model.Physics is null || model.Physics.Parts.Count == 0 ) return; if ( isStatic ) { prop.IsStatic = true; go.Tags.Add( "world" ); return; } // Map props are fully networked. Their positions and destroys are networked. prop.GameObject.Network.SetOrphanedMode( NetworkOrphaned.ClearOwner ); prop.GameObject.Network.SetOwnerTransfer( OwnerTransfer.Takeover ); prop.GameObject.NetworkSpawn(); } protected override void CreateObject( ObjectEntry kv ) { var parent = Map.GameObject; if ( !string.IsNullOrWhiteSpace( kv.ParentName ) ) { if ( MapObjects.TryGetValue( kv.ParentName, out var outParent ) ) parent = outParent; } var targetName = kv.TargetName; var prefix = "[PR#]"; if ( !string.IsNullOrWhiteSpace( targetName ) && targetName.StartsWith( prefix ) ) targetName = targetName[prefix.Length..]; var go = new GameObject( false ); go.SetParent( parent ); go.Flags |= GameObjectFlags.NotSaved; go.Name = string.IsNullOrWhiteSpace( targetName ) ? $"{kv.TypeName}" : $"{kv.TypeName} <{targetName}>"; go.WorldTransform = kv.Transform; go.Tags.Add( kv.Tags ); if ( !string.IsNullOrWhiteSpace( kv.TargetName ) ) MapObjects.TryAdd( kv.TargetName, go ); switch ( kv.TypeName ) { case "func_brush": { CreateStaticModel( go, kv ); break; } case "info_player_start": { go.Components.Create(); break; } case "prop_dynamic": case "prop_animated": case "prop_physics": { CreateProp( go, kv ); break; } case "env_sky": { SkyBox2D.InitializeFromLegacy( go, kv ); break; } case "skybox_reference": { MapSkybox3D.InitializeFromLegacy( go, kv ); break; } case "env_volumetric_fog_volume": { VolumetricFogVolume.InitializeFromLegacy( go, kv ); break; } case "env_volumetric_fog_controller": { // We only take the baked fog texture from the legacy component VolumetricFogController.InitializeFromLegacy( go, kv ); break; } case "env_cubemap": case "env_cubemap_box": { EnvmapProbe.InitializeFromLegacy( go, kv ); break; } case "env_combined_light_probe_volume": { EnvmapProbe.InitializeFromLegacy( go, kv ); // create an envmap component AddMapObjectComponent( go, kv ); // create the probe sceneobject (we don't have a component for it) break; } case "snd_soundscape_box": { CreateSoundScapeBox( go, kv ); break; } case "snd_soundscape": { CreateSoundScape( go, kv ); break; } case "env_gradient_fog": { CreateGradientFog( go, kv ); break; } case "env_cubemap_fog": { CreateCubemapFog( go, kv ); break; } } // Give users a chance to override functionality Map.OnCreateObjectInternal( go, kv ); // If no components were added, add our default MapObjectComponent if ( go.Components.Count == 0 ) { AddMapObjectComponent( go, kv ); } go.Enabled = true; using ( CallbackBatch.Batch() ) { go.Components.ForEach( "Loading", true, c => c.OnLoadInternal() ); go.Components.ForEach( "OnValidate", true, c => c.OnValidateInternal() ); } } }