diff --git a/Content.Client/Entry/EntryPoint.cs b/Content.Client/Entry/EntryPoint.cs index 3a1f2e1cd3..4b30700524 100644 --- a/Content.Client/Entry/EntryPoint.cs +++ b/Content.Client/Entry/EntryPoint.cs @@ -111,6 +111,10 @@ namespace Content.Client.Entry _prototypeManager.RegisterIgnore("salvageMap"); _prototypeManager.RegisterIgnore("salvageFaction"); _prototypeManager.RegisterIgnore("gamePreset"); + _prototypeManager.RegisterIgnore("noiseChannel"); + _prototypeManager.RegisterIgnore("spaceBiome"); + _prototypeManager.RegisterIgnore("worldgenConfig"); + _prototypeManager.RegisterIgnore("gcQueue"); _prototypeManager.RegisterIgnore("gameRule"); _prototypeManager.RegisterIgnore("worldSpell"); _prototypeManager.RegisterIgnore("entitySpell"); diff --git a/Content.IntegrationTests/PoolManager.cs b/Content.IntegrationTests/PoolManager.cs index 7c7f442ef5..9c25c15417 100644 --- a/Content.IntegrationTests/PoolManager.cs +++ b/Content.IntegrationTests/PoolManager.cs @@ -57,6 +57,7 @@ public static class PoolManager (CCVars.ArrivalsShuttles.Name, "false"), (CCVars.EmergencyShuttleEnabled.Name, "false"), (CCVars.ProcgenPreload.Name, "false"), + (CCVars.WorldgenEnabled.Name, "false"), // @formatter:on }; diff --git a/Content.IntegrationTests/Tests/CargoTest.cs b/Content.IntegrationTests/Tests/CargoTest.cs index 06fc46c3ae..617e89b7d2 100644 --- a/Content.IntegrationTests/Tests/CargoTest.cs +++ b/Content.IntegrationTests/Tests/CargoTest.cs @@ -74,6 +74,7 @@ public sealed class CargoTest var protoIds = protoManager.EnumeratePrototypes() .Where(p=>!p.Abstract) + .Where(p => !p.Components.ContainsKey("MapGrid")) // Grids are not for sale. .Select(p => p.ID) .ToList(); diff --git a/Content.IntegrationTests/Tests/EntityTest.cs b/Content.IntegrationTests/Tests/EntityTest.cs index 0c095b2614..27adeeef4b 100644 --- a/Content.IntegrationTests/Tests/EntityTest.cs +++ b/Content.IntegrationTests/Tests/EntityTest.cs @@ -39,6 +39,7 @@ namespace Content.IntegrationTests.Tests var protoIds = prototypeMan .EnumeratePrototypes() .Where(p=>!p.Abstract) + .Where(p => !p.Components.ContainsKey("MapGrid")) // This will smash stuff otherwise. .Select(p => p.ID) .ToList(); foreach (var protoId in protoIds) @@ -87,6 +88,7 @@ namespace Content.IntegrationTests.Tests var protoIds = prototypeMan .EnumeratePrototypes() .Where(p=>!p.Abstract) + .Where(p => !p.Components.ContainsKey("MapGrid")) // This will smash stuff otherwise. .Select(p => p.ID) .ToList(); foreach (var protoId in protoIds) @@ -133,6 +135,7 @@ namespace Content.IntegrationTests.Tests var protoIds = prototypeMan .EnumeratePrototypes() .Where(p => !p.Abstract) + .Where(p => !p.Components.ContainsKey("MapGrid")) // This will smash stuff otherwise. .Select(p => p.ID) .ToList(); @@ -193,6 +196,10 @@ namespace Content.IntegrationTests.Tests "MapGrid", "StationData", // errors when removed mid-round "Actor", // We aren't testing actor components, those need their player session set. + "BlobFloorPlanBuilder", // Implodes if unconfigured. + "DebrisFeaturePlacerController", // Above. + "LoadedChunk", // Worldgen chunk loading malding. + "BiomeSelection", // Whaddya know, requires config. }; var testEntity = @" @@ -289,6 +296,10 @@ namespace Content.IntegrationTests.Tests "MapGrid", "StationData", // errors when deleted mid-round "Actor", // We aren't testing actor components, those need their player session set. + "BlobFloorPlanBuilder", // Implodes if unconfigured. + "DebrisFeaturePlacerController", // Above. + "LoadedChunk", // Worldgen chunk loading malding. + "BiomeSelection", // Whaddya know, requires config. }; var testEntity = @" diff --git a/Content.IntegrationTests/Tests/PrototypeSaveTest.cs b/Content.IntegrationTests/Tests/PrototypeSaveTest.cs index 7881b624e9..cf87ae84f5 100644 --- a/Content.IntegrationTests/Tests/PrototypeSaveTest.cs +++ b/Content.IntegrationTests/Tests/PrototypeSaveTest.cs @@ -86,6 +86,10 @@ public sealed class PrototypeSaveTest if (prototype.Abstract) continue; + // Yea this test just doesn't work with this, it parents a grid to another grid and causes game logic to explode. + if (prototype.Components.ContainsKey("MapGrid")) + continue; + // Currently mobs and such can't be serialized, but they aren't flagged as serializable anyways. if (!prototype.MapSavable) continue; diff --git a/Content.Server/IoC/ServerContentIoC.cs b/Content.Server/IoC/ServerContentIoC.cs index 675fedc807..106c995d44 100644 --- a/Content.Server/IoC/ServerContentIoC.cs +++ b/Content.Server/IoC/ServerContentIoC.cs @@ -19,6 +19,7 @@ using Content.Server.Preferences.Managers; using Content.Server.ServerInfo; using Content.Server.ServerUpdates; using Content.Server.Voting.Managers; +using Content.Server.Worldgen.Tools; using Content.Shared.Administration; using Content.Shared.Administration.Logs; using Content.Shared.Administration.Managers; @@ -58,6 +59,7 @@ namespace Content.Server.IoC IoCManager.Register(); IoCManager.Register(); IoCManager.Register(); + IoCManager.Register(); } } } diff --git a/Content.Server/Worldgen/Components/BiomeSelectionComponent.cs b/Content.Server/Worldgen/Components/BiomeSelectionComponent.cs new file mode 100644 index 0000000000..b82b8eb6b6 --- /dev/null +++ b/Content.Server/Worldgen/Components/BiomeSelectionComponent.cs @@ -0,0 +1,21 @@ +using Content.Server.Worldgen.Systems.Biomes; +using Content.Server.Worldgen.Prototypes; +using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype.List; + +namespace Content.Server.Worldgen.Components; + +/// +/// This is used for selecting the biome(s) to be used during world generation. +/// +[RegisterComponent] +[Access(typeof(BiomeSelectionSystem))] +public sealed class BiomeSelectionComponent : Component +{ + /// + /// The list of biomes available to this selector. + /// + /// This is always sorted by priority after ComponentStartup. + [DataField("biomes", required: true, + customTypeSerializer: typeof(PrototypeIdListSerializer))] public List Biomes = new(); +} + diff --git a/Content.Server/Worldgen/Components/Carvers/NoiseRangeCarverComponent.cs b/Content.Server/Worldgen/Components/Carvers/NoiseRangeCarverComponent.cs new file mode 100644 index 0000000000..164f8ff059 --- /dev/null +++ b/Content.Server/Worldgen/Components/Carvers/NoiseRangeCarverComponent.cs @@ -0,0 +1,27 @@ +using Content.Server.Worldgen.Prototypes; +using Content.Server.Worldgen.Systems.Carvers; +using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype; + +namespace Content.Server.Worldgen.Components.Carvers; + +/// +/// This is used for carving out empty space in the game world, providing byways through the debris field. +/// +[RegisterComponent] +[Access(typeof(NoiseRangeCarverSystem))] +public sealed class NoiseRangeCarverComponent : Component +{ + /// + /// The noise channel to use as a density controller. + /// + /// This noise channel should be mapped to exactly the range [0, 1] unless you want a lot of warnings in the log. + [DataField("noiseChannel", customTypeSerializer: typeof(PrototypeIdSerializer))] + public string NoiseChannel { get; } = default!; + + /// + /// The index of ranges in which to cut debris generation. + /// + [DataField("ranges", required: true)] + public List Ranges { get; } = default!; +} + diff --git a/Content.Server/Worldgen/Components/Debris/BlobFloorPlanBuilderComponent.cs b/Content.Server/Worldgen/Components/Debris/BlobFloorPlanBuilderComponent.cs new file mode 100644 index 0000000000..70861bfca6 --- /dev/null +++ b/Content.Server/Worldgen/Components/Debris/BlobFloorPlanBuilderComponent.cs @@ -0,0 +1,37 @@ +using Content.Server.Worldgen.Systems.Debris; +using Content.Shared.Maps; +using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype.List; + +namespace Content.Server.Worldgen.Components.Debris; + +/// +/// This is used for constructing asteroid debris. +/// +[RegisterComponent] +[Access(typeof(BlobFloorPlanBuilderSystem))] +public sealed class BlobFloorPlanBuilderComponent : Component +{ + /// + /// The probability that placing a floor tile will add up to three-four neighboring tiles as well. + /// + [DataField("blobDrawProb")] public float BlobDrawProb; + + /// + /// The maximum radius for the structure. + /// + [DataField("radius", required: true)] public float Radius; + + /// + /// The tiles to be used for the floor plan. + /// + [DataField("floorTileset", required: true, + customTypeSerializer: typeof(PrototypeIdListSerializer))] + public List FloorTileset { get; } = default!; + + /// + /// The number of floor tiles to place when drawing the asteroid layout. + /// + [DataField("floorPlacements", required: true)] + public int FloorPlacements { get; } +} + diff --git a/Content.Server/Worldgen/Components/Debris/DebrisFeaturePlacerControllerComponent.cs b/Content.Server/Worldgen/Components/Debris/DebrisFeaturePlacerControllerComponent.cs new file mode 100644 index 0000000000..1d59960f2e --- /dev/null +++ b/Content.Server/Worldgen/Components/Debris/DebrisFeaturePlacerControllerComponent.cs @@ -0,0 +1,42 @@ +using Content.Server.Worldgen.Prototypes; +using Content.Server.Worldgen.Systems.Debris; +using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype; + +namespace Content.Server.Worldgen.Components.Debris; + +/// +/// This is used for controlling the debris feature placer. +/// +[RegisterComponent] +[Access(typeof(DebrisFeaturePlacerSystem))] +public sealed class DebrisFeaturePlacerControllerComponent : Component +{ + /// + /// Whether or not to clip debris that would spawn at a location that has a density of zero. + /// + [DataField("densityClip")] public bool DensityClip = true; + + /// + /// Whether or not entities are already spawned. + /// + public bool DoSpawns = true; + + [DataField("ownedDebris")] public Dictionary OwnedDebris = new(); + + /// + /// The chance spawning a piece of debris will just be cancelled randomly. + /// + [DataField("randomCancelChance")] public float RandomCancellationChance = 0.1f; + + /// + /// Radius in which there should be no objects for debris to spawn. + /// + [DataField("safetyZoneRadius")] public float SafetyZoneRadius = 16.0f; + + /// + /// The noise channel to use as a density controller. + /// + [DataField("densityNoiseChannel", customTypeSerializer: typeof(PrototypeIdSerializer))] + public string DensityNoiseChannel { get; } = default!; +} + diff --git a/Content.Server/Worldgen/Components/Debris/NoiseDrivenDebrisSelectorComponent.cs b/Content.Server/Worldgen/Components/Debris/NoiseDrivenDebrisSelectorComponent.cs new file mode 100644 index 0000000000..9690bc5f6e --- /dev/null +++ b/Content.Server/Worldgen/Components/Debris/NoiseDrivenDebrisSelectorComponent.cs @@ -0,0 +1,44 @@ +using Content.Server.Worldgen.Prototypes; +using Content.Server.Worldgen.Systems.Debris; +using Content.Server.Worldgen.Tools; +using Content.Shared.Storage; +using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype; + +namespace Content.Server.Worldgen.Components.Debris; + +/// +/// This is used for selecting debris with a probability determined by a noise channel. +/// Takes priority over SimpleDebrisSelectorComponent and should likely be used in combination. +/// +[RegisterComponent] +[Access(typeof(NoiseDrivenDebrisSelectorSystem))] +public sealed class NoiseDrivenDebrisSelectorComponent : Component +{ + private EntitySpawnCollectionCache? _cache; + + /// + /// The prototype-facing debris table entries. + /// + [DataField("debrisTable", required: true)] + private List _entries = default!; + + /// + /// The debris entity spawn collection. + /// + public EntitySpawnCollectionCache CachedDebrisTable + { + get + { + _cache ??= new EntitySpawnCollectionCache(_entries); + return _cache; + } + } + + /// + /// The noise channel to use as a density controller. + /// + /// This noise channel should be mapped to exactly the range [0, 1] unless you want a lot of warnings in the log. + [DataField("noiseChannel", customTypeSerializer: typeof(PrototypeIdSerializer))] + public string NoiseChannel { get; } = default!; +} + diff --git a/Content.Server/Worldgen/Components/Debris/OwnedDebrisComponent.cs b/Content.Server/Worldgen/Components/Debris/OwnedDebrisComponent.cs new file mode 100644 index 0000000000..73a6cf8975 --- /dev/null +++ b/Content.Server/Worldgen/Components/Debris/OwnedDebrisComponent.cs @@ -0,0 +1,23 @@ +using Content.Server.Worldgen.Systems.Debris; + +namespace Content.Server.Worldgen.Components.Debris; + +/// +/// This is used for attaching a piece of debris to it's owning controller. +/// Mostly just syncs deletion. +/// +[RegisterComponent] +[Access(typeof(DebrisFeaturePlacerSystem))] +public sealed class OwnedDebrisComponent : Component +{ + /// + /// The last location in the controller's internal structure for this debris. + /// + [DataField("lastKey")] public Vector2 LastKey; + + /// + /// The DebrisFeaturePlacerController-having entity that owns this. + /// + [DataField("owningController")] public EntityUid OwningController; +} + diff --git a/Content.Server/Worldgen/Components/Debris/SimpleDebrisSelectorComponent.cs b/Content.Server/Worldgen/Components/Debris/SimpleDebrisSelectorComponent.cs new file mode 100644 index 0000000000..16b9f3973a --- /dev/null +++ b/Content.Server/Worldgen/Components/Debris/SimpleDebrisSelectorComponent.cs @@ -0,0 +1,34 @@ +using Content.Server.Worldgen.Systems.Debris; +using Content.Server.Worldgen.Tools; +using Content.Shared.Storage; + +namespace Content.Server.Worldgen.Components.Debris; + +/// +/// This is used for a very simple debris selection for simple biomes. Just uses a spawn table. +/// +[RegisterComponent] +[Access(typeof(DebrisFeaturePlacerSystem))] +public sealed class SimpleDebrisSelectorComponent : Component +{ + private EntitySpawnCollectionCache? _cache; + + /// + /// The prototype-facing debris table entries. + /// + [DataField("debrisTable", required: true)] + private List _entries = default!; + + /// + /// The debris entity spawn collection. + /// + public EntitySpawnCollectionCache CachedDebrisTable + { + get + { + _cache ??= new EntitySpawnCollectionCache(_entries); + return _cache; + } + } +} + diff --git a/Content.Server/Worldgen/Components/Debris/SimpleFloorPlanPopulatorComponent.cs b/Content.Server/Worldgen/Components/Debris/SimpleFloorPlanPopulatorComponent.cs new file mode 100644 index 0000000000..eab15c8c8d --- /dev/null +++ b/Content.Server/Worldgen/Components/Debris/SimpleFloorPlanPopulatorComponent.cs @@ -0,0 +1,47 @@ +using System.Linq; +using Content.Server.Worldgen.Systems.Debris; +using Content.Server.Worldgen.Tools; +using Content.Shared.Maps; +using Content.Shared.Storage; +using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype.Dictionary; + +namespace Content.Server.Worldgen.Components.Debris; + +/// +/// This is used for populating a grid with random entities automatically. +/// +[RegisterComponent] +[Access(typeof(SimpleFloorPlanPopulatorSystem))] +public sealed class SimpleFloorPlanPopulatorComponent : Component +{ + private Dictionary? _caches; + + /// + /// The prototype facing floor plan populator entries. + /// + [DataField("entries", required: true, + customTypeSerializer: typeof(PrototypeIdDictionarySerializer, ContentTileDefinition>))] + private Dictionary> _entries = default!; + + /// + /// The spawn collections used to place entities on different tile types. + /// + [ViewVariables] + public Dictionary Caches + { + get + { + if (_caches is null) + { + _caches = _entries + .Select(x => + new KeyValuePair(x.Key, + new EntitySpawnCollectionCache(x.Value))) + .ToDictionary(x => x.Key, x => x.Value); + } + + return _caches; + } + } +} + diff --git a/Content.Server/Worldgen/Components/GC/GCAbleObjectComponent.cs b/Content.Server/Worldgen/Components/GC/GCAbleObjectComponent.cs new file mode 100644 index 0000000000..3957c7d8b1 --- /dev/null +++ b/Content.Server/Worldgen/Components/GC/GCAbleObjectComponent.cs @@ -0,0 +1,21 @@ +using Content.Server.Worldgen.Prototypes; +using Content.Server.Worldgen.Systems.GC; +using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype; + +namespace Content.Server.Worldgen.Components.GC; + +/// +/// This is used for whether or not a GCable object is "dirty". Firing GCDirtyEvent on the object is the correct way to +/// set this up. +/// +[RegisterComponent] +[Access(typeof(GCQueueSystem))] +public sealed class GCAbleObjectComponent : Component +{ + /// + /// Which queue to insert this object into when GCing + /// + [DataField("queue", required: true, customTypeSerializer: typeof(PrototypeIdSerializer))] + public string Queue = default!; +} + diff --git a/Content.Server/Worldgen/Components/LoadedChunkComponent.cs b/Content.Server/Worldgen/Components/LoadedChunkComponent.cs new file mode 100644 index 0000000000..cebabaece6 --- /dev/null +++ b/Content.Server/Worldgen/Components/LoadedChunkComponent.cs @@ -0,0 +1,17 @@ +using Content.Server.Worldgen.Systems; + +namespace Content.Server.Worldgen.Components; + +/// +/// This is used for marking a chunk as loaded. +/// +[RegisterComponent] +[Access(typeof(WorldControllerSystem))] +public sealed class LoadedChunkComponent : Component +{ + /// + /// The current list of entities loading this chunk. + /// + [ViewVariables] public List? Loaders = null; +} + diff --git a/Content.Server/Worldgen/Components/LocalityLoaderComponent.cs b/Content.Server/Worldgen/Components/LocalityLoaderComponent.cs new file mode 100644 index 0000000000..e0b0eb2865 --- /dev/null +++ b/Content.Server/Worldgen/Components/LocalityLoaderComponent.cs @@ -0,0 +1,19 @@ +using Content.Server.Worldgen.Systems; + +namespace Content.Server.Worldgen.Components; + +/// +/// This is used for sending a signal to the entity it's on to load contents whenever a loader gets close enough. +/// Does not support unloading. +/// +[RegisterComponent] +[Access(typeof(LocalityLoaderSystem))] +public sealed class LocalityLoaderComponent : Component +{ + /// + /// The maximum distance an entity can be from the loader for it to not load. + /// Once a loader is closer than this, the event is fired and this component removed. + /// + [DataField("loadingDistance")] public int LoadingDistance = 32; +} + diff --git a/Content.Server/Worldgen/Components/NoiseIndexComponent.cs b/Content.Server/Worldgen/Components/NoiseIndexComponent.cs new file mode 100644 index 0000000000..ed6d44f537 --- /dev/null +++ b/Content.Server/Worldgen/Components/NoiseIndexComponent.cs @@ -0,0 +1,20 @@ +using Content.Server.Worldgen.Prototypes; +using Content.Server.Worldgen.Systems; + +namespace Content.Server.Worldgen.Components; + +/// +/// This is used for containing configured noise generators. +/// +[RegisterComponent] +[Access(typeof(NoiseIndexSystem))] +public sealed class NoiseIndexComponent : Component +{ + /// + /// An index of generators, to avoid having to recreate them every time a noise channel is used. + /// Keyed by noise generator prototype ID. + /// + [Access(typeof(NoiseIndexSystem), Friend = AccessPermissions.ReadWriteExecute, Other = AccessPermissions.None)] + public Dictionary Generators { get; } = new(); +} + diff --git a/Content.Server/Worldgen/Components/WorldChunkComponent.cs b/Content.Server/Worldgen/Components/WorldChunkComponent.cs new file mode 100644 index 0000000000..4b5e294eb9 --- /dev/null +++ b/Content.Server/Worldgen/Components/WorldChunkComponent.cs @@ -0,0 +1,22 @@ +using Content.Server.Worldgen.Systems; + +namespace Content.Server.Worldgen.Components; + +/// +/// This is used for marking an entity as being a world chunk. +/// +[RegisterComponent] +[Access(typeof(WorldControllerSystem))] +public sealed class WorldChunkComponent : Component +{ + /// + /// The coordinates of the chunk, in chunk space. + /// + [DataField("coordinates")] public Vector2i Coordinates; + + /// + /// The map this chunk belongs to. + /// + [DataField("map")] public EntityUid Map; +} + diff --git a/Content.Server/Worldgen/Components/WorldControllerComponent.cs b/Content.Server/Worldgen/Components/WorldControllerComponent.cs new file mode 100644 index 0000000000..9d1bcc7ca2 --- /dev/null +++ b/Content.Server/Worldgen/Components/WorldControllerComponent.cs @@ -0,0 +1,25 @@ +using Content.Server.Worldgen.Systems; +using Robust.Shared.Prototypes; +using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype; + +namespace Content.Server.Worldgen.Components; + +/// +/// This is used for controlling overall world loading, containing an index of all chunks in the map. +/// +[RegisterComponent] +[Access(typeof(WorldControllerSystem))] +public sealed class WorldControllerComponent : Component +{ + /// + /// The prototype to use for chunks on this world map. + /// + [DataField("chunkProto", customTypeSerializer: typeof(PrototypeIdSerializer))] + public string ChunkProto = "WorldChunk"; + + /// + /// An index of chunks owned by the controller. + /// + [DataField("chunks")] public Dictionary Chunks = new(); +} + diff --git a/Content.Server/Worldgen/Components/WorldLoaderComponent.cs b/Content.Server/Worldgen/Components/WorldLoaderComponent.cs new file mode 100644 index 0000000000..43c990cd27 --- /dev/null +++ b/Content.Server/Worldgen/Components/WorldLoaderComponent.cs @@ -0,0 +1,18 @@ +using Content.Server.Worldgen.Systems; + +namespace Content.Server.Worldgen.Components; + +/// +/// This is used for allowing some objects to load the game world. +/// +[RegisterComponent] +[Access(typeof(WorldControllerSystem))] +public sealed class WorldLoaderComponent : Component +{ + /// + /// The radius in which the loader loads the world. + /// + [ViewVariables(VVAccess.ReadWrite)] [DataField("radius")] + public int Radius = 128; +} + diff --git a/Content.Server/Worldgen/GridPointsNearEnumerator.cs b/Content.Server/Worldgen/GridPointsNearEnumerator.cs new file mode 100644 index 0000000000..24b710626b --- /dev/null +++ b/Content.Server/Worldgen/GridPointsNearEnumerator.cs @@ -0,0 +1,59 @@ +using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.Contracts; + +namespace Content.Server.Worldgen; + +/// +/// A struct enumerator of points on a grid within the given radius. +/// +public struct GridPointsNearEnumerator +{ + private readonly int _radius; + private readonly Vector2i _center; + private int _x; + private int _y; + + /// + /// Initializes a new enumerator with the given center and radius. + /// + public GridPointsNearEnumerator(Vector2i center, int radius) + { + _radius = radius; + _center = center; + _x = -_radius; + _y = -_radius; + } + + /// + /// Gets the next point in the enumeration. + /// + /// The computed point, if any + /// Success + [Pure] + public bool MoveNext([NotNullWhen(true)] out Vector2i? chunk) + { + while (!(_x * _x + _y * _y <= _radius * _radius)) + { + if (_y > _radius) + { + chunk = null; + return false; + } + + if (_x > _radius) + { + _x = -_radius; + _y++; + } + else + { + _x++; + } + } + + chunk = _center + new Vector2i(_x, _y); + _x++; + return true; + } +} + diff --git a/Content.Server/Worldgen/Prototypes/BiomePrototype.cs b/Content.Server/Worldgen/Prototypes/BiomePrototype.cs new file mode 100644 index 0000000000..b76d4547f6 --- /dev/null +++ b/Content.Server/Worldgen/Prototypes/BiomePrototype.cs @@ -0,0 +1,61 @@ +using Robust.Shared.Prototypes; +using Robust.Shared.Serialization.Manager; +using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype.Array; + +namespace Content.Server.Worldgen.Prototypes; + +/// +/// This is a prototype for biome selection, allowing the component list of a chunk to be amended based on the output +/// of noise channels at that location. +/// +[Prototype("spaceBiome")] +public sealed class BiomePrototype : IPrototype, IInheritingPrototype +{ + /// + [ParentDataField(typeof(AbstractPrototypeIdArraySerializer))] + public string[]? Parents { get; } + + /// + [NeverPushInheritance] + [AbstractDataField] + public bool Abstract { get; } + + /// + [IdDataField] + public string ID { get; } = default!; + + /// + /// The valid ranges of noise values under which this biome can be picked. + /// + [DataField("noiseRanges", required: true)] + public Dictionary> NoiseRanges = default!; + + /// + /// Higher priority biomes get picked before lower priority ones. + /// + [DataField("priority", required: true)] + public int Priority { get; } + + /// + /// The components that get added to the target map. + /// + [DataField("chunkComponents")] + [AlwaysPushInheritance] + public EntityPrototype.ComponentRegistry ChunkComponents { get; } = new(); + + //TODO: Get someone to make this a method on componentregistry that does it Correctly. + /// + /// Applies the worldgen config to the given target (presumably a map.) + /// + public void Apply(EntityUid target, ISerializationManager serialization, IEntityManager entityManager) + { + // Add all components required by the prototype. Engine update for this whenst. + foreach (var data in ChunkComponents.Values) + { + var comp = (Component) serialization.CreateCopy(data.Component, notNullableOverride: true); + comp.Owner = target; // look im sorry ok this .owner has to live until engine api exists + entityManager.AddComponent(target, comp); + } + } +} + diff --git a/Content.Server/Worldgen/Prototypes/GCQueuePrototype.cs b/Content.Server/Worldgen/Prototypes/GCQueuePrototype.cs new file mode 100644 index 0000000000..94e6cf5fa3 --- /dev/null +++ b/Content.Server/Worldgen/Prototypes/GCQueuePrototype.cs @@ -0,0 +1,41 @@ +using Robust.Shared.Prototypes; + +namespace Content.Server.Worldgen.Prototypes; + +/// +/// This is a prototype for a GC queue. +/// +[Prototype("gcQueue")] +public sealed class GCQueuePrototype : IPrototype +{ + /// + [IdDataField] + public string ID { get; } = default!; + + /// + /// How deep the GC queue is at most. If this value is ever exceeded entities get processed automatically regardless of + /// tick-time cap. + /// + [DataField("depth", required: true)] + public int Depth { get; } + + /// + /// The maximum amount of time that can be spent processing this queue. + /// + [DataField("maximumTickTime")] + public TimeSpan MaximumTickTime { get; } = TimeSpan.FromMilliseconds(1); + + /// + /// The minimum depth before entities in the queue actually get processed for deletion. + /// + [DataField("minDepthToProcess", required: true)] + public int MinDepthToProcess { get; } + + /// + /// Whether or not the GC should fire an event on the entity to see if it's eligible to skip the queue. + /// Useful for making it so only objects a player has actually interacted with get put in the collection queue. + /// + [DataField("trySkipQueue")] + public bool TrySkipQueue { get; } +} + diff --git a/Content.Server/Worldgen/Prototypes/NoiseChannelPrototype.cs b/Content.Server/Worldgen/Prototypes/NoiseChannelPrototype.cs new file mode 100644 index 0000000000..0d1ff77569 --- /dev/null +++ b/Content.Server/Worldgen/Prototypes/NoiseChannelPrototype.cs @@ -0,0 +1,169 @@ +using Robust.Shared.Noise; +using Robust.Shared.Prototypes; +using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype.Array; + +namespace Content.Server.Worldgen.Prototypes; + +/// +/// This is a config for noise channels, used by worldgen. +/// +[Virtual] +public class NoiseChannelConfig +{ + /// + /// The noise type used by the noise generator. + /// + [DataField("noiseType")] + public FastNoiseLite.NoiseType NoiseType { get; } = FastNoiseLite.NoiseType.Cellular; + + /// + /// The fractal type used by the noise generator. + /// + [DataField("fractalType")] + public FastNoiseLite.FractalType FractalType { get; } = FastNoiseLite.FractalType.FBm; + + /// + /// Multiplied by pi in code when used. + /// + [DataField("fractalLacunarityByPi")] + public float FractalLacunarityByPi { get; } = 2.0f / 3.0f; + + /// + /// Ranges of values that get clamped down to the "clipped" value. + /// + [DataField("clippingRanges")] + public List ClippingRanges { get; } = new(); + + /// + /// The value clipped chunks are set to. + /// + [DataField("clippedValue")] + public float ClippedValue { get; } + + /// + /// A value the output is multiplied by. + /// + [DataField("outputMultiplier")] + public float OutputMultiplier { get; } = 1.0f; + + /// + /// A value the input is multiplied by. + /// + [DataField("inputMultiplier")] + public float InputMultiplier { get; } = 1.0f; + + /// + /// Remaps the output of the noise function from the range (-1, 1) to (0, 1). This is done before all other output + /// transformations. + /// + [DataField("remapTo0Through1")] + public bool RemapTo0Through1 { get; } + + /// + /// For when the transformation you need is too complex to describe in YAML. + /// + [DataField("noisePostProcess")] + public NoisePostProcess? NoisePostProcess { get; } + + /// + /// For when you need a complex transformation of the input coordinates. + /// + [DataField("noiseCoordinateProcess")] + public NoiseCoordinateProcess? NoiseCoordinateProcess { get; } + + /// + /// The "center" of the range of values. Or the minimum if mapped 0 through 1. + /// + [DataField("minimum")] + public float Minimum { get; } +} + +[Prototype("noiseChannel")] +public sealed class NoiseChannelPrototype : NoiseChannelConfig, IPrototype, IInheritingPrototype +{ + /// + [ParentDataField(typeof(AbstractPrototypeIdArraySerializer))] + public string[]? Parents { get; } + + /// + [NeverPushInheritance] + [AbstractDataField] + public bool Abstract { get; } + + /// + [IdDataField] + public string ID { get; } = default!; +} + +/// +/// A wrapper around FastNoise's noise generation, using noise channel configs. +/// +public struct NoiseGenerator +{ + private readonly NoiseChannelConfig _config; + private readonly FastNoiseLite _noise; + + /// + /// Produces a new noise generator from the given channel config and rng seed. + /// + public NoiseGenerator(NoiseChannelConfig config, int seed) + { + _config = config; + _noise = new FastNoiseLite(); + _noise.SetSeed(seed); + _noise.SetNoiseType(_config.NoiseType); + _noise.SetFractalType(_config.FractalType); + _noise.SetFractalLacunarity(_config.FractalLacunarityByPi * MathF.PI); + } + + /// + /// Evaluates the noise generator at the provided coordinates. + /// + /// Coordinates to use as input + /// Computed noise value + public float Evaluate(Vector2 coords) + { + var finCoords = coords * _config.InputMultiplier; + + if (_config.NoiseCoordinateProcess is not null) + finCoords = _config.NoiseCoordinateProcess.Process(finCoords); + + var value = _noise.GetNoise(finCoords.X, finCoords.Y); + + if (_config.RemapTo0Through1) + value = (value + 1.0f) / 2.0f; + + foreach (var range in _config.ClippingRanges) + { + if (range.X < value && value < range.Y) + { + value = _config.ClippedValue; + break; + } + } + + if (_config.NoisePostProcess is not null) + value = _config.NoisePostProcess.Process(value); + value *= _config.OutputMultiplier; + return value + _config.Minimum; + } +} + +/// +/// A processing class that adjusts the input coordinate space to a noise channel. +/// +[ImplicitDataDefinitionForInheritors] +public abstract class NoiseCoordinateProcess +{ + public abstract Vector2 Process(Vector2 inp); +} + +/// +/// A processing class that adjusts the final result of the noise channel. +/// +[ImplicitDataDefinitionForInheritors] +public abstract class NoisePostProcess +{ + public abstract float Process(float inp); +} + diff --git a/Content.Server/Worldgen/Prototypes/WorldgenConfigPrototype.cs b/Content.Server/Worldgen/Prototypes/WorldgenConfigPrototype.cs new file mode 100644 index 0000000000..b2c1230975 --- /dev/null +++ b/Content.Server/Worldgen/Prototypes/WorldgenConfigPrototype.cs @@ -0,0 +1,38 @@ +using Robust.Shared.Prototypes; +using Robust.Shared.Serialization.Manager; + +namespace Content.Server.Worldgen.Prototypes; + +/// +/// This is a prototype for controlling overall world generation. +/// The components included are applied to the map that world generation is configured on. +/// +[Prototype("worldgenConfig")] +public sealed class WorldgenConfigPrototype : IPrototype +{ + /// + [IdDataField] + public string ID { get; } = default!; + + /// + /// The components that get added to the target map. + /// + [DataField("components", required: true)] + public EntityPrototype.ComponentRegistry Components { get; } = default!; + + //TODO: Get someone to make this a method on componentregistry that does it Correctly. + /// + /// Applies the worldgen config to the given target (presumably a map.) + /// + public void Apply(EntityUid target, ISerializationManager serialization, IEntityManager entityManager) + { + // Add all components required by the prototype. Engine update for this whenst. + foreach (var data in Components.Values) + { + var comp = (Component) serialization.CreateCopy(data.Component, notNullableOverride: true); + comp.Owner = target; // look im sorry ok this .owner has to live until engine api exists + entityManager.AddComponent(target, comp); + } + } +} + diff --git a/Content.Server/Worldgen/Systems/BaseWorldSystem.cs b/Content.Server/Worldgen/Systems/BaseWorldSystem.cs new file mode 100644 index 0000000000..ea6a5eaa97 --- /dev/null +++ b/Content.Server/Worldgen/Systems/BaseWorldSystem.cs @@ -0,0 +1,58 @@ +using Content.Server.Worldgen.Components; +using JetBrains.Annotations; + +namespace Content.Server.Worldgen.Systems; + +/// +/// This provides some additional functions for world generation systems. +/// Exists primarily for convenience and to avoid code duplication. +/// +[PublicAPI] +public abstract class BaseWorldSystem : EntitySystem +{ + [Dependency] private readonly WorldControllerSystem _worldController = default!; + + /// + /// Gets a chunk's coordinates in chunk space as an integer value. + /// + /// + /// + /// Chunk space coordinates + [Pure] + public Vector2i GetChunkCoords(EntityUid ent, TransformComponent? xform = null) + { + if (!Resolve(ent, ref xform)) + throw new Exception("Failed to resolve transform, somehow."); + + return WorldGen.WorldToChunkCoords(xform.WorldPosition).Floored(); + } + + /// + /// Gets a chunk's coordinates in chunk space as a floating point value. + /// + /// + /// + /// Chunk space coordinates + [Pure] + public Vector2 GetFloatingChunkCoords(EntityUid ent, TransformComponent? xform = null) + { + if (!Resolve(ent, ref xform)) + throw new Exception("Failed to resolve transform, somehow."); + + return WorldGen.WorldToChunkCoords(xform.WorldPosition); + } + + /// + /// Attempts to get a chunk, creating it if it doesn't exist. + /// + /// Chunk coordinates to get the chunk entity for. + /// Map the chunk is in. + /// The controller this chunk belongs to. + /// A chunk, if available. + [Pure] + public EntityUid? GetOrCreateChunk(Vector2i chunk, EntityUid map, WorldControllerComponent? controller = null) + { + return _worldController.GetOrCreateChunk(chunk, map, controller); + } +} + diff --git a/Content.Server/Worldgen/Systems/Biomes/BiomeSelectionSystem.cs b/Content.Server/Worldgen/Systems/Biomes/BiomeSelectionSystem.cs new file mode 100644 index 0000000000..3fff584217 --- /dev/null +++ b/Content.Server/Worldgen/Systems/Biomes/BiomeSelectionSystem.cs @@ -0,0 +1,75 @@ +using System.Linq; +using Content.Server.Worldgen.Components; +using Content.Server.Worldgen.Prototypes; +using Robust.Shared.Prototypes; +using Robust.Shared.Serialization.Manager; + +namespace Content.Server.Worldgen.Systems.Biomes; + +/// +/// This handles biome selection, evaluating which biome to apply to a chunk based on noise channels. +/// +public sealed class BiomeSelectionSystem : BaseWorldSystem +{ + [Dependency] private readonly NoiseIndexSystem _noiseIdx = default!; + [Dependency] private readonly IPrototypeManager _proto = default!; + [Dependency] private readonly ISerializationManager _ser = default!; + + /// + public override void Initialize() + { + SubscribeLocalEvent(OnBiomeSelectionStartup); + SubscribeLocalEvent(OnWorldChunkAdded); + } + + private void OnWorldChunkAdded(EntityUid uid, BiomeSelectionComponent component, ref WorldChunkAddedEvent args) + { + var coords = args.Coords; + foreach (var biomeId in component.Biomes) + { + var biome = _proto.Index(biomeId); + if (!CheckBiomeValidity(args.Chunk, biome, coords)) + continue; + + biome.Apply(args.Chunk, _ser, EntityManager); + return; + } + + Logger.Error($"Biome selection ran out of biomes to select? See biomes list: {component.Biomes}"); + } + + private void OnBiomeSelectionStartup(EntityUid uid, BiomeSelectionComponent component, ComponentStartup args) + { + // surely this can't be THAAAAAAAAAAAAAAAT bad right???? + var sorted = component.Biomes + .Select(x => (Id: x, _proto.Index(x).Priority)) + .OrderByDescending(x => x.Priority) + .Select(x => x.Id) + .ToList(); + + component.Biomes = sorted; // my hopes and dreams rely on this being pre-sorted by priority. + } + + private bool CheckBiomeValidity(EntityUid chunk, BiomePrototype biome, Vector2i coords) + { + foreach (var (noise, ranges) in biome.NoiseRanges) + { + var value = _noiseIdx.Evaluate(chunk, noise, coords); + var anyValid = false; + foreach (var range in ranges) + { + if (range.X < value && value < range.Y) + { + anyValid = true; + break; + } + } + + if (!anyValid) + return false; + } + + return true; + } +} + diff --git a/Content.Server/Worldgen/Systems/Carvers/NoiseRangeCarverSystem.cs b/Content.Server/Worldgen/Systems/Carvers/NoiseRangeCarverSystem.cs new file mode 100644 index 0000000000..aed32bc89c --- /dev/null +++ b/Content.Server/Worldgen/Systems/Carvers/NoiseRangeCarverSystem.cs @@ -0,0 +1,35 @@ +using Content.Server.Worldgen.Components.Carvers; +using Content.Server.Worldgen.Systems.Debris; + +namespace Content.Server.Worldgen.Systems.Carvers; + +/// +/// This handles carving out holes in world generation according to a noise channel. +/// +public sealed class NoiseRangeCarverSystem : EntitySystem +{ + [Dependency] private readonly NoiseIndexSystem _index = default!; + + /// + public override void Initialize() + { + SubscribeLocalEvent(OnPrePlaceDebris); + } + + private void OnPrePlaceDebris(EntityUid uid, NoiseRangeCarverComponent component, + ref PrePlaceDebrisFeatureEvent args) + { + var coords = WorldGen.WorldToChunkCoords(args.Coords.ToMapPos(EntityManager)); + var val = _index.Evaluate(uid, component.NoiseChannel, coords); + + foreach (var (low, high) in component.Ranges) + { + if (low > val || high < val) + continue; + + args.Handled = true; + return; + } + } +} + diff --git a/Content.Server/Worldgen/Systems/Debris/BlobFloorPlanBuilderSystem.cs b/Content.Server/Worldgen/Systems/Debris/BlobFloorPlanBuilderSystem.cs new file mode 100644 index 0000000000..c69f53d3f4 --- /dev/null +++ b/Content.Server/Worldgen/Systems/Debris/BlobFloorPlanBuilderSystem.cs @@ -0,0 +1,85 @@ +using System.Linq; +using Content.Server.Worldgen.Components.Debris; +using Content.Shared.Maps; +using Robust.Shared.Map; +using Robust.Shared.Map.Components; +using Robust.Shared.Random; + +namespace Content.Server.Worldgen.Systems.Debris; + +/// +/// This handles building the floor plans for "blobby" debris. +/// +public sealed class BlobFloorPlanBuilderSystem : BaseWorldSystem +{ + [Dependency] private readonly IRobustRandom _random = default!; + [Dependency] private readonly ITileDefinitionManager _tileDefinition = default!; + + /// + public override void Initialize() + { + SubscribeLocalEvent(OnBlobFloorPlanBuilderStartup); + } + + private void OnBlobFloorPlanBuilderStartup(EntityUid uid, BlobFloorPlanBuilderComponent component, + ComponentStartup args) + { + PlaceFloorplanTiles(component, Comp(uid)); + } + + private void PlaceFloorplanTiles(BlobFloorPlanBuilderComponent comp, MapGridComponent grid) + { + // NO MORE THAN TWO ALLOCATIONS THANK YOU VERY MUCH. + var spawnPoints = new HashSet(comp.FloorPlacements * 6); + var taken = new Dictionary(comp.FloorPlacements * 5); + + void PlaceTile(Vector2i point) + { + // Assume we already know that the spawn point is safe. + spawnPoints.Remove(point); + var north = point.Offset(Direction.North); + var south = point.Offset(Direction.South); + var east = point.Offset(Direction.East); + var west = point.Offset(Direction.West); + var radsq = Math.Pow(comp.Radius, + 2); // I'd put this outside but i'm not 100% certain caching it between calls is a gain. + + // The math done is essentially a fancy way of comparing the distance from 0,0 to the radius, + // and skipping the sqrt normally needed for dist. + if (!taken.ContainsKey(north) && Math.Pow(north.X, 2) + Math.Pow(north.Y, 2) <= radsq) + spawnPoints.Add(north); + if (!taken.ContainsKey(south) && Math.Pow(south.X, 2) + Math.Pow(south.Y, 2) <= radsq) + spawnPoints.Add(south); + if (!taken.ContainsKey(east) && Math.Pow(east.X, 2) + Math.Pow(east.Y, 2) <= radsq) + spawnPoints.Add(east); + if (!taken.ContainsKey(west) && Math.Pow(west.X, 2) + Math.Pow(west.Y, 2) <= radsq) + spawnPoints.Add(west); + + var tileDef = _tileDefinition[_random.Pick(comp.FloorTileset)]; + taken.Add(point, new Tile(tileDef.TileId, 0, _random.Pick(((ContentTileDefinition)tileDef).PlacementVariants))); + } + + PlaceTile(Vector2i.Zero); + + for (var i = 0; i < comp.FloorPlacements; i++) + { + var point = _random.Pick(spawnPoints); + PlaceTile(point); + + if (comp.BlobDrawProb > 0.0f) + { + if (!taken.ContainsKey(point.Offset(Direction.North)) && _random.Prob(comp.BlobDrawProb)) + PlaceTile(point.Offset(Direction.North)); + if (!taken.ContainsKey(point.Offset(Direction.South)) && _random.Prob(comp.BlobDrawProb)) + PlaceTile(point.Offset(Direction.South)); + if (!taken.ContainsKey(point.Offset(Direction.East)) && _random.Prob(comp.BlobDrawProb)) + PlaceTile(point.Offset(Direction.East)); + if (!taken.ContainsKey(point.Offset(Direction.West)) && _random.Prob(comp.BlobDrawProb)) + PlaceTile(point.Offset(Direction.West)); + } + } + + grid.SetTiles(taken.Select(x => (x.Key, x.Value)).ToList()); + } +} + diff --git a/Content.Server/Worldgen/Systems/Debris/DebrisFeaturePlacerSystem.cs b/Content.Server/Worldgen/Systems/Debris/DebrisFeaturePlacerSystem.cs new file mode 100644 index 0000000000..8c8360af31 --- /dev/null +++ b/Content.Server/Worldgen/Systems/Debris/DebrisFeaturePlacerSystem.cs @@ -0,0 +1,264 @@ +using System.Linq; +using Content.Server.Worldgen.Components; +using Content.Server.Worldgen.Components.Debris; +using Content.Server.Worldgen.Systems.GC; +using Content.Server.Worldgen.Tools; +using JetBrains.Annotations; +using Robust.Server.GameObjects; +using Robust.Shared.Map; +using Robust.Shared.Map.Components; +using Robust.Shared.Random; + +namespace Content.Server.Worldgen.Systems.Debris; + +/// +/// This handles placing debris within the world evenly with rng, primarily for structures like asteroid fields. +/// +public sealed class DebrisFeaturePlacerSystem : BaseWorldSystem +{ + [Dependency] private readonly GCQueueSystem _gc = default!; + [Dependency] private readonly NoiseIndexSystem _noiseIndex = default!; + [Dependency] private readonly PoissonDiskSampler _sampler = default!; + [Dependency] private readonly TransformSystem _xformSys = default!; + [Dependency] private readonly ILogManager _logManager = default!; + [Dependency] private readonly IMapManager _mapManager = default!; + [Dependency] private readonly IRobustRandom _random = default!; + + private ISawmill _sawmill = default!; + + /// + public override void Initialize() + { + _sawmill = _logManager.GetSawmill("world.debris.feature_placer"); + SubscribeLocalEvent(OnChunkLoaded); + SubscribeLocalEvent(OnChunkUnloaded); + SubscribeLocalEvent(OnDebrisShutdown); + SubscribeLocalEvent(OnDebrisMove); + SubscribeLocalEvent(OnTryCancelGC); + SubscribeLocalEvent( + OnTryGetPlacableDebrisEvent); + } + + /// + /// Handles GC cancellation in case the chunk is still loaded. + /// + private void OnTryCancelGC(EntityUid uid, OwnedDebrisComponent component, ref TryCancelGC args) + { + args.Cancelled |= HasComp(component.OwningController); + } + + /// + /// Handles debris moving, and making sure it stays parented to a chunk for loading purposes. + /// + private void OnDebrisMove(EntityUid uid, OwnedDebrisComponent component, ref MoveEvent args) + { + if (!HasComp(component.OwningController)) + return; // Redundant logic, prolly needs it's own handler for your custom system. + + var placer = Comp(component.OwningController); + var xform = Transform(uid); + var ownerXform = Transform(component.OwningController); + if (xform.MapUid is null || ownerXform.MapUid is null) + return; // not our problem + + if (xform.MapUid != ownerXform.MapUid) + { + _sawmill.Error($"Somehow debris {uid} left it's expected map! Unparenting it to avoid issues."); + RemCompDeferred(uid); + placer.OwnedDebris.Remove(component.LastKey); + return; + } + + placer.OwnedDebris.Remove(component.LastKey); + var newChunk = GetOrCreateChunk(GetChunkCoords(uid), xform.MapUid!.Value); + if (newChunk is null || !TryComp(newChunk, out var newPlacer)) + { + // Whelp. + RemCompDeferred(uid); + return; + } + + newPlacer.OwnedDebris[_xformSys.GetWorldPosition(xform)] = uid; // Change our owner. + component.OwningController = newChunk.Value; + } + + /// + /// Handles debris shutdown/detach. + /// + private void OnDebrisShutdown(EntityUid uid, OwnedDebrisComponent component, ComponentShutdown args) + { + if (!TryComp(component.OwningController, out var placer)) + return; + + placer.OwnedDebris[component.LastKey] = null; + if (Terminating(uid)) + placer.OwnedDebris.Remove(component.LastKey); + } + + /// + /// Queues all debris owned by the placer for garbage collection. + /// + private void OnChunkUnloaded(EntityUid uid, DebrisFeaturePlacerControllerComponent component, + ref WorldChunkUnloadedEvent args) + { + foreach (var (_, debris) in component.OwnedDebris) + { + if (debris is not null) + _gc.TryGCEntity(debris.Value); // gonb. + } + + component.DoSpawns = true; + } + + /// + /// Handles providing a debris type to place for SimpleDebrisSelectorComponent. + /// This randomly picks a debris type from the EntitySpawnCollectionCache. + /// + private void OnTryGetPlacableDebrisEvent(EntityUid uid, SimpleDebrisSelectorComponent component, + ref TryGetPlaceableDebrisFeatureEvent args) + { + if (args.DebrisProto is not null) + return; + + var l = new List(1); + component.CachedDebrisTable.GetSpawns(_random, ref l); + + switch (l.Count) + { + case 0: + return; + case > 1: + _sawmill.Warning($"Got more than one possible debris type from {uid}. List: {string.Join(", ", l)}"); + break; + } + + args.DebrisProto = l[0]; + } + + /// + /// Handles loading in debris. This does the following: + /// - Checks if the debris is currently supposed to do spawns, if it isn't, aborts immediately. + /// - Evaluates the density value to be used for placement, if it's zero, aborts. + /// - Generates the points to generate debris at, if and only if they've not been selected already by a prior load. + /// - Does the following in a loop over all generated points: + /// - Raises an event to check if something else wants to intercept debris placement, if the event is handled, + /// continues to the next point without generating anything. + /// - Raises an event to get the debris type that should be used for generation. + /// - Spawns the given debris at the point, adding it to the placer's index. + /// + private void OnChunkLoaded(EntityUid uid, DebrisFeaturePlacerControllerComponent component, + ref WorldChunkLoadedEvent args) + { + if (component.DoSpawns == false) + return; + + component.DoSpawns = false; // Don't repeat yourself if this crashes. + + var chunk = Comp(args.Chunk); + var densityChannel = component.DensityNoiseChannel; + var density = _noiseIndex.Evaluate(uid, densityChannel, chunk.Coordinates + new Vector2(0.5f, 0.5f)); + if (density == 0) + return; + + List? points = null; + + // If we've been loaded before, reuse the same coordinates. + if (component.OwnedDebris.Count != 0) + { + //TODO: Remove LINQ. + points = component.OwnedDebris + .Where(x => !Deleted(x.Value)) + .Select(static x => x.Key) + .ToList(); + } + + points ??= GeneratePointsInChunk(args.Chunk, density, chunk.Coordinates, chunk.Map); + + var safetyBounds = Box2.UnitCentered.Enlarged(component.SafetyZoneRadius); + var failures = 0; // Avoid severe log spam. + foreach (var point in points) + { + var pointDensity = _noiseIndex.Evaluate(uid, densityChannel, WorldGen.WorldToChunkCoords(point)); + if (pointDensity == 0 && component.DensityClip || _random.Prob(component.RandomCancellationChance)) + continue; + + var coords = new EntityCoordinates(chunk.Map, point); + + if (_mapManager + .FindGridsIntersecting(Comp(chunk.Map).MapId, safetyBounds.Translated(point)).Any()) + continue; // Oops, gonna collide. + + var preEv = new PrePlaceDebrisFeatureEvent(coords, args.Chunk); + RaiseLocalEvent(uid, ref preEv); + if (uid != args.Chunk) + RaiseLocalEvent(args.Chunk, ref preEv); + + if (preEv.Handled) + continue; + + var debrisFeatureEv = new TryGetPlaceableDebrisFeatureEvent(coords, args.Chunk); + RaiseLocalEvent(uid, ref debrisFeatureEv); + + if (debrisFeatureEv.DebrisProto == null) + { + // Try on the chunk...? + if (uid != args.Chunk) + RaiseLocalEvent(args.Chunk, ref debrisFeatureEv); + + if (debrisFeatureEv.DebrisProto == null) + { + // Nope. + failures++; + continue; + } + } + + var ent = Spawn(debrisFeatureEv.DebrisProto, coords); + component.OwnedDebris.Add(point, ent); + + var owned = EnsureComp(ent); + owned.OwningController = uid; + owned.LastKey = point; + } + + if (failures > 0) + _sawmill.Error($"Failed to place {failures} debris at chunk {args.Chunk}"); + } + + /// + /// Generates the points to put into a chunk using a poisson disk sampler. + /// + private List GeneratePointsInChunk(EntityUid chunk, float density, Vector2 coords, EntityUid map) + { + var offs = (int) ((WorldGen.ChunkSize - WorldGen.ChunkSize / 8.0f) / 2.0f); + var topLeft = (-offs, -offs); + var lowerRight = (offs, offs); + var enumerator = _sampler.SampleRectangle(topLeft, lowerRight, density); + var debrisPoints = new List(); + + var realCenter = WorldGen.ChunkToWorldCoordsCentered(coords.Floored()); + + while (enumerator.MoveNext(out var debrisPoint)) + { + debrisPoints.Add(realCenter + debrisPoint.Value); + } + + return debrisPoints; + } +} + +/// +/// Fired directed on the debris feature placer controller and the chunk, ahead of placing a debris piece. +/// +[ByRefEvent] +[PublicAPI] +public record struct PrePlaceDebrisFeatureEvent(EntityCoordinates Coords, EntityUid Chunk, bool Handled = false); + +/// +/// Fired directed on the debris feature placer controller and the chunk, to select which debris piece to place. +/// +[ByRefEvent] +[PublicAPI] +public record struct TryGetPlaceableDebrisFeatureEvent(EntityCoordinates Coords, EntityUid Chunk, + string? DebrisProto = null); + diff --git a/Content.Server/Worldgen/Systems/Debris/NoiseDrivenDebrisSelectorSystem.cs b/Content.Server/Worldgen/Systems/Debris/NoiseDrivenDebrisSelectorSystem.cs new file mode 100644 index 0000000000..8d8ca1b6c7 --- /dev/null +++ b/Content.Server/Worldgen/Systems/Debris/NoiseDrivenDebrisSelectorSystem.cs @@ -0,0 +1,59 @@ +using Content.Server.Worldgen.Components.Debris; +using Robust.Server.GameObjects; +using Robust.Shared.Random; + +namespace Content.Server.Worldgen.Systems.Debris; + +/// +/// This handles selecting debris with probability decided by a noise channel. +/// +public sealed class NoiseDrivenDebrisSelectorSystem : BaseWorldSystem +{ + [Dependency] private readonly NoiseIndexSystem _index = default!; + [Dependency] private readonly TransformSystem _xformSys = default!; + [Dependency] private readonly ILogManager _logManager = default!; + [Dependency] private readonly IRobustRandom _random = default!; + + private ISawmill _sawmill = default!; + + /// + public override void Initialize() + { + _sawmill = _logManager.GetSawmill("world.debris.noise_debris_selector"); + // Event is forcibly ordered to always be handled after the simple selector. + SubscribeLocalEvent(OnSelectDebrisKind, + after: new[] {typeof(DebrisFeaturePlacerSystem)}); + } + + private void OnSelectDebrisKind(EntityUid uid, NoiseDrivenDebrisSelectorComponent component, + ref TryGetPlaceableDebrisFeatureEvent args) + { + var coords = WorldGen.WorldToChunkCoords(args.Coords.ToMapPos(EntityManager, _xformSys)); + var prob = _index.Evaluate(uid, component.NoiseChannel, coords); + + if (prob is < 0 or > 1) + { + _sawmill.Error( + $"Sampled a probability of {prob}, which is outside the [0, 1] range, at {coords} aka {args.Coords}."); + return; + } + + if (!_random.Prob(prob)) + return; + + var l = new List(1); + component.CachedDebrisTable.GetSpawns(_random, ref l); + + switch (l.Count) + { + case 0: + return; + case > 1: + _sawmill.Warning($"Got more than one possible debris type from {uid}. List: {string.Join(", ", l)}"); + break; + } + + args.DebrisProto = l[0]; + } +} + diff --git a/Content.Server/Worldgen/Systems/Debris/SimpleFloorPlanPopulatorSystem.cs b/Content.Server/Worldgen/Systems/Debris/SimpleFloorPlanPopulatorSystem.cs new file mode 100644 index 0000000000..ae1c6b5c00 --- /dev/null +++ b/Content.Server/Worldgen/Systems/Debris/SimpleFloorPlanPopulatorSystem.cs @@ -0,0 +1,49 @@ +using Content.Server.Worldgen.Components.Debris; +using Content.Shared.Maps; +using Robust.Shared.Map; +using Robust.Shared.Map.Components; +using Robust.Shared.Random; + +namespace Content.Server.Worldgen.Systems.Debris; + +/// +/// This handles populating simple structures, simply using a loot table for each tile. +/// +public sealed class SimpleFloorPlanPopulatorSystem : BaseWorldSystem +{ + [Dependency] private readonly IRobustRandom _random = default!; + [Dependency] private readonly ITileDefinitionManager _tileDefinition = default!; + + /// + public override void Initialize() + { + SubscribeLocalEvent(OnFloorPlanBuilt); + } + + private void OnFloorPlanBuilt(EntityUid uid, SimpleFloorPlanPopulatorComponent component, + LocalStructureLoadedEvent args) + { + var placeables = new List(4); + var grid = Comp(uid); + var enumerator = grid.GetAllTilesEnumerator(); + while (enumerator.MoveNext(out var tile)) + { + var coords = grid.GridTileToLocal(tile.Value.GridIndices); + var selector = tile.Value.Tile.GetContentTileDefinition(_tileDefinition).ID; + if (!component.Caches.TryGetValue(selector, out var cache)) + continue; + + placeables.Clear(); + cache.GetSpawns(_random, ref placeables); + + foreach (var proto in placeables) + { + if (proto is null) + continue; + + Spawn(proto, coords); + } + } + } +} + diff --git a/Content.Server/Worldgen/Systems/GC/GCQueueSystem.cs b/Content.Server/Worldgen/Systems/GC/GCQueueSystem.cs new file mode 100644 index 0000000000..98d17afd2b --- /dev/null +++ b/Content.Server/Worldgen/Systems/GC/GCQueueSystem.cs @@ -0,0 +1,124 @@ +using System.Linq; +using Content.Server.Worldgen.Components.GC; +using Content.Server.Worldgen.Prototypes; +using Content.Shared.CCVar; +using JetBrains.Annotations; +using Robust.Shared.Configuration; +using Robust.Shared.Prototypes; +using Robust.Shared.Random; +using Robust.Shared.Timing; + +namespace Content.Server.Worldgen.Systems.GC; + +/// +/// This handles delayed garbage collection of entities, to avoid overloading the tick in particularly expensive cases. +/// +public sealed class GCQueueSystem : EntitySystem +{ + [Dependency] private readonly IConfigurationManager _cfg = default!; + [Dependency] private readonly IPrototypeManager _proto = default!; + [Dependency] private readonly IRobustRandom _random = default!; + + [ViewVariables] private TimeSpan _maximumProcessTime = TimeSpan.Zero; + + [ViewVariables] private readonly Dictionary> _queues = new(); + + /// + public override void Initialize() + { + _cfg.OnValueChanged(CCVars.GCMaximumTimeMs, s => _maximumProcessTime = TimeSpan.FromMilliseconds(s), + true); + } + + /// CCVars + public override void Update(float frameTime) + { + var overallWatch = new Stopwatch(); + var queueWatch = new Stopwatch(); + var queues = _queues.ToList(); + _random.Shuffle(queues); // Avert resource starvation by always processing in random order. + overallWatch.Start(); + foreach (var (pId, queue) in queues) + { + if (overallWatch.Elapsed > _maximumProcessTime) + return; + + var proto = _proto.Index(pId); + if (queue.Count < proto.MinDepthToProcess) + continue; + + queueWatch.Restart(); + while (queueWatch.Elapsed < proto.MaximumTickTime && queue.Count >= proto.MinDepthToProcess && + overallWatch.Elapsed < _maximumProcessTime) + { + var e = queue.Dequeue(); + if (!Deleted(e)) + { + var ev = new TryCancelGC(); + RaiseLocalEvent(e, ref ev); + + if (!ev.Cancelled) + Del(e); + } + } + } + } + + /// + /// Attempts to GC an entity. This functions as QueueDel if it can't. + /// + /// Entity to GC. + public void TryGCEntity(EntityUid e) + { + if (!TryComp(e, out var comp)) + { + QueueDel(e); // not our problem :) + return; + } + + if (!_queues.TryGetValue(comp.Queue, out var queue)) + { + queue = new Queue(); + _queues[comp.Queue] = queue; + } + + var proto = _proto.Index(comp.Queue); + if (queue.Count > proto.Depth) + { + QueueDel(e); // whelp, too full. + return; + } + + if (proto.TrySkipQueue) + { + var ev = new TryGCImmediately(); + RaiseLocalEvent(e, ref ev); + if (!ev.Cancelled) + { + QueueDel(e); + return; + } + } + + queue.Enqueue(e); + } +} + +/// +/// Fired by GCQueueSystem to check if it can simply immediately GC an entity, for example if it was never fully +/// loaded. +/// +/// Whether or not the immediate deletion attempt was cancelled. +[ByRefEvent] +[PublicAPI] +public record struct TryGCImmediately(bool Cancelled = false); + +/// +/// Fired by GCQueueSystem to check if the collection of the given entity should be cancelled, for example it's chunk +/// being loaded again. +/// +/// Whether or not the deletion attempt was cancelled. +[ByRefEvent] +[PublicAPI] +public record struct TryCancelGC(bool Cancelled = false); + diff --git a/Content.Server/Worldgen/Systems/LocalityLoaderSystem.cs b/Content.Server/Worldgen/Systems/LocalityLoaderSystem.cs new file mode 100644 index 0000000000..97ed4c50be --- /dev/null +++ b/Content.Server/Worldgen/Systems/LocalityLoaderSystem.cs @@ -0,0 +1,59 @@ +using Content.Server.Worldgen.Components; +using Robust.Server.GameObjects; + +namespace Content.Server.Worldgen.Systems; + +/// +/// This handles loading in objects based on distance from player, using some metadata on chunks. +/// +public sealed class LocalityLoaderSystem : BaseWorldSystem +{ + [Dependency] private readonly TransformSystem _xformSys = default!; + + /// + public override void Update(float frameTime) + { + var e = EntityQueryEnumerator(); + var loadedQuery = GetEntityQuery(); + var xformQuery = GetEntityQuery(); + var controllerQuery = GetEntityQuery(); + + while (e.MoveNext(out var uid, out var loadable, out var xform)) + { + if (!controllerQuery.TryGetComponent(xform.MapUid, out var controller)) + return; + + var coords = GetChunkCoords(uid, xform); + var done = false; + for (var i = -1; i < 2 && !done; i++) + { + for (var j = -1; j < 2 && !done; j++) + { + var chunk = GetOrCreateChunk(coords + (i, j), xform.MapUid!.Value, controller); + if (!loadedQuery.TryGetComponent(chunk, out var loaded) || loaded.Loaders is null) + continue; + + foreach (var loader in loaded.Loaders) + { + if (!xformQuery.TryGetComponent(loader, out var loaderXform)) + continue; + + if ((_xformSys.GetWorldPosition(loaderXform) - _xformSys.GetWorldPosition(xform)).Length > loadable.LoadingDistance) + continue; + + RaiseLocalEvent(uid, new LocalStructureLoadedEvent()); + RemCompDeferred(uid); + done = true; + break; + } + } + } + } + } +} + +/// +/// A directed fired on a loadable entity when a local loader enters it's vicinity. +/// +public record struct LocalStructureLoadedEvent; + diff --git a/Content.Server/Worldgen/Systems/NoiseIndexSystem.cs b/Content.Server/Worldgen/Systems/NoiseIndexSystem.cs new file mode 100644 index 0000000000..59de257c0b --- /dev/null +++ b/Content.Server/Worldgen/Systems/NoiseIndexSystem.cs @@ -0,0 +1,46 @@ +using Content.Server.Worldgen.Components; +using Content.Server.Worldgen.Prototypes; +using Robust.Shared.Prototypes; +using Robust.Shared.Random; + +namespace Content.Server.Worldgen.Systems; + +/// +/// This handles the noise index. +/// +public sealed class NoiseIndexSystem : EntitySystem +{ + [Dependency] private readonly IPrototypeManager _prototype = default!; + [Dependency] private readonly IRobustRandom _random = default!; + + /// + /// Gets a particular noise channel from the index on the given entity. + /// + /// The holder of the index + /// The channel prototype ID + /// An initialized noise generator + public NoiseGenerator Get(EntityUid holder, string protoId) + { + var idx = EnsureComp(holder); + if (idx.Generators.TryGetValue(protoId, out var generator)) + return generator; + var proto = _prototype.Index(protoId); + var gen = new NoiseGenerator(proto, _random.Next()); + idx.Generators[protoId] = gen; + return gen; + } + + /// + /// Attempts to evaluate the given noise channel using the generator on the given entity. + /// + /// The holder of the index + /// The channel prototype ID + /// The coordinates to evaluate at + /// The result of evaluation + public float Evaluate(EntityUid holder, string protoId, Vector2 coords) + { + var gen = Get(holder, protoId); + return gen.Evaluate(coords); + } +} + diff --git a/Content.Server/Worldgen/Systems/WorldControllerSystem.cs b/Content.Server/Worldgen/Systems/WorldControllerSystem.cs new file mode 100644 index 0000000000..84c12cd858 --- /dev/null +++ b/Content.Server/Worldgen/Systems/WorldControllerSystem.cs @@ -0,0 +1,278 @@ +using System.Linq; +using Content.Server.Ghost.Components; +using Content.Server.Mind.Components; +using Content.Server.Worldgen.Components; +using JetBrains.Annotations; +using Robust.Server.GameObjects; +using Robust.Shared.Map; +using Robust.Shared.Timing; + +namespace Content.Server.Worldgen.Systems; + +/// +/// This handles putting together chunk entities and notifying them about important changes. +/// +public sealed class WorldControllerSystem : EntitySystem +{ + [Dependency] private readonly TransformSystem _xformSys = default!; + [Dependency] private readonly IGameTiming _gameTiming = default!; + [Dependency] private readonly ILogManager _logManager = default!; + + private const int PlayerLoadRadius = 2; + + private ISawmill _sawmill = default!; + + /// + public override void Initialize() + { + _sawmill = _logManager.GetSawmill("world"); + SubscribeLocalEvent(OnChunkLoadedCore); + SubscribeLocalEvent(OnChunkUnloadedCore); + SubscribeLocalEvent(OnChunkShutdown); + } + + /// + /// Handles deleting chunks properly. + /// + private void OnChunkShutdown(EntityUid uid, WorldChunkComponent component, ComponentShutdown args) + { + if (!TryComp(component.Map, out var controller)) + return; + + if (HasComp(uid)) + { + var ev = new WorldChunkUnloadedEvent(uid, component.Coordinates); + RaiseLocalEvent(component.Map, ref ev); + RaiseLocalEvent(uid, ref ev, broadcast: true); + } + + controller.Chunks.Remove(component.Coordinates); + } + + /// + /// Handles the inner logic of loading a chunk, i.e. events. + /// + private void OnChunkLoadedCore(EntityUid uid, LoadedChunkComponent component, ComponentStartup args) + { + if (!TryComp(uid, out var chunk)) + return; + + var ev = new WorldChunkLoadedEvent(uid, chunk.Coordinates); + RaiseLocalEvent(chunk.Map, ref ev); + RaiseLocalEvent(uid, ref ev, broadcast: true); + //_sawmill.Debug($"Loaded chunk {ToPrettyString(uid)} at {chunk.Coordinates}"); + } + + /// + /// Handles the inner logic of unloading a chunk, i.e. events. + /// + private void OnChunkUnloadedCore(EntityUid uid, LoadedChunkComponent component, ComponentShutdown args) + { + if (!TryComp(uid, out var chunk)) + return; + + if (Terminating(uid)) + return; // SAFETY: This is in case a loaded chunk gets deleted, to avoid double unload. + + var ev = new WorldChunkUnloadedEvent(uid, chunk.Coordinates); + RaiseLocalEvent(chunk.Map, ref ev); + RaiseLocalEvent(uid, ref ev); + //_sawmill.Debug($"Unloaded chunk {ToPrettyString(uid)} at {coords}"); + } + + /// + public override void Update(float frameTime) + { + //there was a to-do here about every frame alloc but it turns out it's a nothing burger here. + var chunksToLoad = new Dictionary>>(); + + var controllerEnum = EntityQueryEnumerator(); + while (controllerEnum.MoveNext(out var uid, out _)) + { + chunksToLoad[uid] = new Dictionary>(); + } + + if (chunksToLoad.Count == 0) + return; // Just bail early. + + var loaderEnum = EntityQueryEnumerator(); + + while (loaderEnum.MoveNext(out var uid, out var worldLoader, out var xform)) + { + var mapOrNull = xform.MapUid; + if (mapOrNull is null) + continue; + var map = mapOrNull.Value; + if (!chunksToLoad.ContainsKey(map)) + continue; + + var wc = _xformSys.GetWorldPosition(xform); + var coords = WorldGen.WorldToChunkCoords(wc); + var chunks = new GridPointsNearEnumerator(coords.Floored(), + (int) Math.Ceiling(worldLoader.Radius / (float) WorldGen.ChunkSize) + 1); + + var set = chunksToLoad[map]; + + while (chunks.MoveNext(out var chunk)) + { + if (!set.TryGetValue(chunk.Value, out _)) + set[chunk.Value] = new List(4); + set[chunk.Value].Add(uid); + } + } + + var mindEnum = EntityQueryEnumerator(); + var ghostQuery = GetEntityQuery(); + + // Mindful entities get special privilege as they're always a player and we don't want the illusion being broken around them. + while (mindEnum.MoveNext(out var uid, out var mind, out var xform)) + { + if (!mind.HasMind) + continue; + if (ghostQuery.HasComponent(uid)) + continue; + var mapOrNull = xform.MapUid; + if (mapOrNull is null) + continue; + var map = mapOrNull.Value; + if (!chunksToLoad.ContainsKey(map)) + continue; + + var wc = _xformSys.GetWorldPosition(xform); + var coords = WorldGen.WorldToChunkCoords(wc); + var chunks = new GridPointsNearEnumerator(coords.Floored(), PlayerLoadRadius); + + var set = chunksToLoad[map]; + + while (chunks.MoveNext(out var chunk)) + { + if (!set.TryGetValue(chunk.Value, out _)) + set[chunk.Value] = new List(4); + set[chunk.Value].Add(uid); + } + } + + var loadedEnum = EntityQueryEnumerator(); + var chunksUnloaded = 0; + + // Make sure these chunks get unloaded at the end of the tick. + while (loadedEnum.MoveNext(out var uid, out var _, out var chunk)) + { + var coords = chunk.Coordinates; + + if (!chunksToLoad[chunk.Map].ContainsKey(coords)) + { + RemCompDeferred(uid); + chunksUnloaded++; + } + } + + if (chunksUnloaded > 0) + _sawmill.Debug($"Queued {chunksUnloaded} chunks for unload."); + + if (chunksToLoad.All(x => x.Value.Count == 0)) + return; + + var startTime = _gameTiming.RealTime; + var count = 0; + var loadedQuery = GetEntityQuery(); + var controllerQuery = GetEntityQuery(); + foreach (var (map, chunks) in chunksToLoad) + { + var controller = controllerQuery.GetComponent(map); + foreach (var (chunk, loaders) in chunks) + { + var ent = GetOrCreateChunk(chunk, map, controller); // Ensure everything loads. + LoadedChunkComponent? c = null; + if (ent is not null && !loadedQuery.TryGetComponent(ent.Value, out c)) + { + c = AddComp(ent.Value); + count += 1; + } + + if (c is not null) + c.Loaders = loaders; + } + } + + if (count > 0) + { + var timeSpan = _gameTiming.RealTime - startTime; + _sawmill.Debug($"Loaded {count} chunks in {timeSpan.TotalMilliseconds:N2}ms."); + } + } + + /// + /// Attempts to get a chunk, creating it if it doesn't exist. + /// + /// Chunk coordinates to get the chunk entity for. + /// Map the chunk is in. + /// The controller this chunk belongs to. + /// A chunk, if available. + [Pure] + public EntityUid? GetOrCreateChunk(Vector2i chunk, EntityUid map, WorldControllerComponent? controller = null) + { + if (!Resolve(map, ref controller)) + throw new Exception($"Tried to use {ToPrettyString(map)} as a world map, without actually being one."); + + if (controller.Chunks.TryGetValue(chunk, out var ent)) + return ent; + return CreateChunkEntity(chunk, map, controller); + } + + /// + /// Constructs a new chunk entity, attaching it to the map. + /// + /// The coordinates the new chunk should be initialized for. + /// + /// + /// + private EntityUid CreateChunkEntity(Vector2i chunkCoords, EntityUid map, WorldControllerComponent controller) + { + var chunk = Spawn(controller.ChunkProto, MapCoordinates.Nullspace); + StartupChunkEntity(chunk, chunkCoords, map, controller); + var md = MetaData(chunk); + md.EntityName = $"Chunk {chunkCoords.X}/{chunkCoords.Y}"; + return chunk; + } + + private void StartupChunkEntity(EntityUid chunk, Vector2i coords, EntityUid map, + WorldControllerComponent controller) + { + if (!TryComp(chunk, out var chunkComponent)) + { + _sawmill.Error($"Chunk {ToPrettyString(chunk)} is missing WorldChunkComponent."); + return; + } + + ref var chunks = ref controller.Chunks; + + chunks[coords] = chunk; // Add this entity to chunk index. + chunkComponent.Coordinates = coords; + chunkComponent.Map = map; + var ev = new WorldChunkAddedEvent(chunk, coords); + RaiseLocalEvent(map, ref ev, broadcast: true); + } +} + +/// +/// A directed event fired when a chunk is initially set up in the world. The chunk is not loaded at this point. +/// +[ByRefEvent] +[PublicAPI] +public readonly record struct WorldChunkAddedEvent(EntityUid Chunk, Vector2i Coords); + +/// +/// A directed event fired when a chunk is loaded into the world, i.e. a player or other world loader has entered vicinity. +/// +[ByRefEvent] +[PublicAPI] +public readonly record struct WorldChunkLoadedEvent(EntityUid Chunk, Vector2i Coords); + +/// +/// A directed event fired when a chunk is unloaded from the world, i.e. no world loaders remain nearby. +/// +[ByRefEvent] +[PublicAPI] +public readonly record struct WorldChunkUnloadedEvent(EntityUid Chunk, Vector2i Coords); + diff --git a/Content.Server/Worldgen/Systems/WorldgenConfigSystem.cs b/Content.Server/Worldgen/Systems/WorldgenConfigSystem.cs new file mode 100644 index 0000000000..70db152b4e --- /dev/null +++ b/Content.Server/Worldgen/Systems/WorldgenConfigSystem.cs @@ -0,0 +1,85 @@ +using Content.Server.Administration; +using Content.Server.GameTicking; +using Content.Server.GameTicking.Events; +using Content.Server.Worldgen.Components; +using Content.Server.Worldgen.Prototypes; +using Content.Shared.Administration; +using Content.Shared.CCVar; +using Robust.Shared.Configuration; +using Robust.Shared.Console; +using Robust.Shared.Map; +using Robust.Shared.Prototypes; +using Robust.Shared.Serialization.Manager; +using Robust.Shared.Utility; + +namespace Content.Server.Worldgen.Systems; + +/// +/// This handles configuring world generation during round start. +/// +public sealed class WorldgenConfigSystem : EntitySystem +{ + [Dependency] private readonly GameTicker _gameTicker = default!; + [Dependency] private readonly IConfigurationManager _cfg = default!; + [Dependency] private readonly IConsoleHost _conHost = default!; + [Dependency] private readonly IMapManager _map = default!; + [Dependency] private readonly IPrototypeManager _proto = default!; + [Dependency] private readonly ISerializationManager _ser = default!; + + private bool _enabled; + private string _worldgenConfig = default!; + + /// + public override void Initialize() + { + SubscribeLocalEvent(OnLoadingMaps); + _conHost.RegisterCommand("applyworldgenconfig", Loc.GetString("cmd-applyworldgenconfig-description"), Loc.GetString("cmd-applyworldgenconfig-help"), ApplyWorldgenConfigCommand); + _cfg.OnValueChanged(CCVars.WorldgenEnabled, b => _enabled = b, true); + _cfg.OnValueChanged(CCVars.WorldgenConfig, s => _worldgenConfig = s, true); + } + + [AdminCommand(AdminFlags.Mapping)] + private void ApplyWorldgenConfigCommand(IConsoleShell shell, string argstr, string[] args) + { + if (args.Length != 2) + { + shell.WriteError(Loc.GetString("shell-wrong-arguments-number-need-specific", ("properAmount", 2), ("currentAmount", args.Length))); + return; + } + + if (!int.TryParse(args[0], out var mapInt) || !_map.MapExists(new MapId(mapInt))) + { + shell.WriteError(Loc.GetString("shell-invalid-map-id")); + return; + } + + var map = _map.GetMapEntityId(new MapId(mapInt)); + + if (!_proto.TryIndex(args[1], out var proto)) + { + shell.WriteError(Loc.GetString("shell-argument-must-be-prototype", ("index", 2), ("prototypeName", "cmd-applyworldgenconfig-prototype"))); + return; + } + + proto.Apply(map, _ser, EntityManager); + shell.WriteLine(Loc.GetString("cmd-applyworldgenconfig-success")); + } + + /// + /// Applies the world config to the default map if enabled. + /// + private void OnLoadingMaps(RoundStartingEvent ev) + { + if (_enabled == false) + return; + + var target = _map.GetMapEntityId(_gameTicker.DefaultMap); + Logger.Debug($"Trying to configure {_gameTicker.DefaultMap}, aka {ToPrettyString(target)} aka {target}"); + var cfg = _proto.Index(_worldgenConfig); + + cfg.Apply(target, _ser, EntityManager); // Apply the config to the map. + + DebugTools.Assert(HasComp(target)); + } +} + diff --git a/Content.Server/Worldgen/Tools/EntitySpawnCollectionCache.cs b/Content.Server/Worldgen/Tools/EntitySpawnCollectionCache.cs new file mode 100644 index 0000000000..5480575427 --- /dev/null +++ b/Content.Server/Worldgen/Tools/EntitySpawnCollectionCache.cs @@ -0,0 +1,96 @@ +using System.Linq; +using Content.Shared.Storage; +using Robust.Shared.Random; + +namespace Content.Server.Worldgen.Tools; + +/// +/// A faster version of EntitySpawnCollection that requires caching to work. +/// +public sealed class EntitySpawnCollectionCache +{ + [ViewVariables] private readonly Dictionary _orGroups = new(); + + public EntitySpawnCollectionCache(IEnumerable entries) + { + // collect groups together, create singular items that pass probability + foreach (var entry in entries) + { + if (!_orGroups.TryGetValue(entry.GroupId ?? string.Empty, out var orGroup)) + { + orGroup = new OrGroup(); + _orGroups.Add(entry.GroupId ?? string.Empty, orGroup); + } + + orGroup.Entries.Add(entry); + orGroup.CumulativeProbability += entry.SpawnProbability; + } + } + + /// + /// Using a collection of entity spawn entries, picks a random list of entity prototypes to spawn from that collection. + /// + /// + /// This does not spawn the entities. The caller is responsible for doing so, since it may want to do something + /// special to those entities (offset them, insert them into storage, etc) + /// + /// Resolve param. + /// List that spawned entities are inserted into. + /// A list of entity prototypes that should be spawned. + /// This is primarily useful if you're calling it many times over, as it lets you reuse the list repeatedly. + public void GetSpawns(IRobustRandom random, ref List spawned) + { + // handle orgroup spawns + foreach (var spawnValue in _orGroups.Values) + { + //HACK: This doesn't seem to work without this if there's only a single orgroup entry. Not sure how to fix the original math properly, but it works in every other case. + if (spawnValue.Entries.Count == 1) + { + var entry = spawnValue.Entries.First(); + var amount = entry.Amount; + + if (entry.MaxAmount > amount) + amount = random.Next(amount, entry.MaxAmount); + + for (var index = 0; index < amount; index++) + { + spawned.Add(entry.PrototypeId); + } + + continue; + } + + // For each group use the added cumulative probability to roll a double in that range + var diceRoll = random.NextDouble() * spawnValue.CumulativeProbability; + // Add the entry's spawn probability to this value, if equals or lower, spawn item, otherwise continue to next item. + var cumulative = 0.0; + foreach (var entry in spawnValue.Entries) + { + cumulative += entry.SpawnProbability; + if (diceRoll > cumulative) + continue; + // Dice roll succeeded, add item and break loop + + var amount = entry.Amount; + + if (entry.MaxAmount > amount) + amount = random.Next(amount, entry.MaxAmount); + + for (var index = 0; index < amount; index++) + { + spawned.Add(entry.PrototypeId); + } + + break; + } + } + } + + private sealed class OrGroup + { + [ViewVariables] public List Entries { get; } = new(); + + [ViewVariables] public float CumulativeProbability { get; set; } + } +} + diff --git a/Content.Server/Worldgen/Tools/PoissonDiskSampler.cs b/Content.Server/Worldgen/Tools/PoissonDiskSampler.cs new file mode 100644 index 0000000000..e372b4fbd5 --- /dev/null +++ b/Content.Server/Worldgen/Tools/PoissonDiskSampler.cs @@ -0,0 +1,243 @@ +using System.Diagnostics.CodeAnalysis; +using Robust.Shared.Random; +using Robust.Shared.Utility; + +namespace Content.Server.Worldgen.Tools; + +/// +/// An implementation of Poisson Disk Sampling, for evenly spreading points across a given area. +/// +public sealed class PoissonDiskSampler +{ + public const int DefaultPointsPerIteration = 30; + [Dependency] private readonly IRobustRandom _random = default!; + + /// + /// Samples for points within the given circle. + /// + /// Center of the sample + /// Radius of the sample + /// Minimum distance between points. Must be above 0! + /// The number of points placed per iteration of the algorithm + /// An enumerator of points + public SampleEnumerator SampleCircle(Vector2 center, float radius, float minimumDistance, + int pointsPerIteration = DefaultPointsPerIteration) + { + return Sample(center - new Vector2(radius, radius), center + new Vector2(radius, radius), radius, + minimumDistance, pointsPerIteration); + } + + /// + /// Samples for points within the given rectangle. + /// + /// The top left of the rectangle + /// The bottom right of the rectangle + /// Minimum distance between points. Must be above 0! + /// The number of points placed per iteration of the algorithm + /// An enumerator of points + public SampleEnumerator SampleRectangle(Vector2 topLeft, Vector2 lowerRight, float minimumDistance, + int pointsPerIteration = DefaultPointsPerIteration) + { + return Sample(topLeft, lowerRight, null, minimumDistance, pointsPerIteration); + } + + /// + /// Samples for points within the given rectangle, with an optional rejection distance. + /// + /// The top left of the rectangle + /// The bottom right of the rectangle + /// The distance at which points will be discarded, if any + /// Minimum distance between points. Must be above 0! + /// The number of points placed per iteration of the algorithm + /// An enumerator of points + public SampleEnumerator Sample(Vector2 topLeft, Vector2 lowerRight, float? rejectionDistance, + float minimumDistance, int pointsPerIteration) + { + // This still doesn't guard against dangerously low but non-zero distances, but this will do for now. + DebugTools.Assert(minimumDistance > 0, "Minimum distance must be above 0, or else an infinite number of points would be generated."); + + var settings = new SampleSettings + { + TopLeft = topLeft, LowerRight = lowerRight, + Dimensions = lowerRight - topLeft, + Center = (topLeft + lowerRight) / 2, + CellSize = minimumDistance / (float) Math.Sqrt(2), + MinimumDistance = minimumDistance, + RejectionSqDistance = rejectionDistance * rejectionDistance + }; + + settings.GridWidth = (int) (settings.Dimensions.X / settings.CellSize) + 1; + settings.GridHeight = (int) (settings.Dimensions.Y / settings.CellSize) + 1; + + var state = new State + { + Grid = new Vector2?[settings.GridWidth, settings.GridHeight], + ActivePoints = new List() + }; + + return new SampleEnumerator(this, state, settings, pointsPerIteration); + } + + private Vector2 AddFirstPoint(ref SampleSettings settings, ref State state) + { + while (true) + { + var d = _random.NextDouble(); + var xr = settings.TopLeft.X + settings.Dimensions.X * d; + + d = _random.NextDouble(); + var yr = settings.TopLeft.Y + settings.Dimensions.Y * d; + + var p = new Vector2((float) xr, (float) yr); + if (settings.RejectionSqDistance != null && + (settings.Center - p).LengthSquared > settings.RejectionSqDistance) + continue; + + var index = Denormalize(p, settings.TopLeft, settings.CellSize); + + state.Grid[(int) index.X, (int) index.Y] = p; + + state.ActivePoints.Add(p); + return p; + } + } + + private Vector2? AddNextPoint(Vector2 point, ref SampleSettings settings, ref State state) + { + var q = GenerateRandomAround(point, settings.MinimumDistance); + + if (q.X >= settings.TopLeft.X && q.X < settings.LowerRight.X && + q.Y > settings.TopLeft.Y && q.Y < settings.LowerRight.Y && + (settings.RejectionSqDistance == null || + (settings.Center - q).LengthSquared <= settings.RejectionSqDistance)) + { + var qIndex = Denormalize(q, settings.TopLeft, settings.CellSize); + var tooClose = false; + + for (var i = (int) Math.Max(0, qIndex.X - 2); + i < Math.Min(settings.GridWidth, qIndex.X + 3) && !tooClose; + i++) + for (var j = (int) Math.Max(0, qIndex.Y - 2); + j < Math.Min(settings.GridHeight, qIndex.Y + 3) && !tooClose; + j++) + { + if (state.Grid[i, j].HasValue && (state.Grid[i, j]!.Value - q).Length < settings.MinimumDistance) + tooClose = true; + } + + if (!tooClose) + { + state.ActivePoints.Add(q); + state.Grid[(int) qIndex.X, (int) qIndex.Y] = q; + return q; + } + } + + return null; + } + + private Vector2 GenerateRandomAround(Vector2 center, float minimumDistance) + { + var d = _random.NextDouble(); + var radius = minimumDistance + minimumDistance * d; + + d = _random.NextDouble(); + var angle = Math.PI * 2 * d; + + var newX = radius * Math.Sin(angle); + var newY = radius * Math.Cos(angle); + + return new Vector2((float) (center.X + newX), (float) (center.Y + newY)); + } + + private static Vector2 Denormalize(Vector2 point, Vector2 origin, double cellSize) + { + return new Vector2((int) ((point.X - origin.X) / cellSize), (int) ((point.Y - origin.Y) / cellSize)); + } + + public struct SampleEnumerator + { + private PoissonDiskSampler _pds; + private State _state; + private SampleSettings _settings; + // These variables make up the state machine. + private bool _returnedFirstPoint; + private int _pointsPerIteration; + private int _iterationListIndex; + private bool _iterationFound; + private int _iterationPosition; + + // This has internal access because C# nested type access is being weird. + internal SampleEnumerator(PoissonDiskSampler pds, State state, SampleSettings settings, int ppi) + { + _pds = pds; + _state = state; + _settings = settings; + _pointsPerIteration = ppi; + } + + public bool MoveNext([NotNullWhen(true)] out Vector2? point) + { + // First point is chosen via a very particular method. + if (!_returnedFirstPoint) + { + _returnedFirstPoint = true; + point = _pds.AddFirstPoint(ref _settings, ref _state); + return true; + } + + // Remaining points have to be fed out carefully. + // We can be interrupted (by a successful point) mid-stream. + while (_state.ActivePoints.Count != 0) + { + if (_iterationPosition == 0) + { + // First point of iteration. + _iterationListIndex = _pds._random.Next(_state.ActivePoints.Count); + _iterationFound = false; + } + + var basePoint = _state.ActivePoints[_iterationListIndex]; + + point = _pds.AddNextPoint(basePoint, ref _settings, ref _state); + + // Set this now, return later after processing is complete. + _iterationFound |= point != null; + + // Iteration loop advance. + _iterationPosition++; + if (_iterationPosition == _pointsPerIteration) + { + // Reached end of this iteration. + _iterationPosition = 0; + if (!_iterationFound) + _state.ActivePoints.RemoveAt(_iterationListIndex); + } + + if (point != null) + return true; + } + point = null; + return false; + } + } + + internal struct State + { + public Vector2?[,] Grid; + public List ActivePoints; + } + + internal struct SampleSettings + { + public Vector2 TopLeft, LowerRight, Center; + public Vector2 Dimensions; + public float? RejectionSqDistance; + public float MinimumDistance; + public float CellSize; + public int GridWidth, GridHeight; + } +} + + + diff --git a/Content.Server/Worldgen/WorldGen.cs b/Content.Server/Worldgen/WorldGen.cs new file mode 100644 index 0000000000..4dc64c5657 --- /dev/null +++ b/Content.Server/Worldgen/WorldGen.cs @@ -0,0 +1,71 @@ +using System.Diagnostics.Contracts; + +namespace Content.Server.Worldgen; + +/// +/// Contains a few world-generation related constants and static functions. +/// +public static class WorldGen +{ + /// + /// The size of each chunk (isn't that self-explanatory.) + /// Be careful about how small you make this. + /// + public const int ChunkSize = 128; + + /// + /// Converts world coordinates to chunk coordinates. + /// + /// World coordinates + /// Chunk coordinates + [Pure] + public static Vector2i WorldToChunkCoords(Vector2i inp) + { + return ((Vector2) inp * (1.0f / ChunkSize, 1.0f / ChunkSize)).Floored(); + } + + /// + /// Converts world coordinates to chunk coordinates. + /// + /// World coordinates + /// Chunk coordinates + [Pure] + public static Vector2 WorldToChunkCoords(Vector2 inp) + { + return inp * (1.0f / ChunkSize, 1.0f / ChunkSize); + } + + /// + /// Converts chunk coordinates to world coordinates. + /// + /// Chunk coordinates + /// World coordinates + [Pure] + public static Vector2 ChunkToWorldCoords(Vector2i inp) + { + return inp * ChunkSize; + } + + /// + /// Converts chunk coordinates to world coordinates. + /// + /// Chunk coordinates + /// World coordinates + [Pure] + public static Vector2 ChunkToWorldCoords(Vector2 inp) + { + return inp * ChunkSize; + } + + /// + /// Converts chunk coordinates to world coordinates, getting the center of the chunk. + /// + /// Chunk coordinates + /// World coordinates + [Pure] + public static Vector2 ChunkToWorldCoordsCentered(Vector2i inp) + { + return inp * ChunkSize + Vector2i.One * (ChunkSize / 2); + } +} + diff --git a/Content.Shared/CCVar/CCVars.cs b/Content.Shared/CCVar/CCVars.cs index 1d2e58761e..bd7954ee08 100644 --- a/Content.Shared/CCVar/CCVars.cs +++ b/Content.Shared/CCVar/CCVars.cs @@ -1589,5 +1589,26 @@ namespace Content.Shared.CCVar /// public static readonly CVarDef ConfigPresetDebug = CVarDef.Create("config.preset_debug", true, CVar.SERVERONLY); + + /* + * World Generation + */ + /// + /// Whether or not world generation is enabled. + /// + public static readonly CVarDef WorldgenEnabled = + CVarDef.Create("worldgen.enabled", false, CVar.SERVERONLY); + + /// + /// The worldgen config to use. + /// + public static readonly CVarDef WorldgenConfig = + CVarDef.Create("worldgen.worldgen_config", "Default", CVar.SERVERONLY); + + /// + /// The maximum amount of time the entity GC can process, in ms. + /// + public static readonly CVarDef GCMaximumTimeMs = + CVarDef.Create("entgc.maximum_time_ms", 5, CVar.SERVERONLY); } } diff --git a/Resources/Locale/en-US/shell.ftl b/Resources/Locale/en-US/shell.ftl index aa76563d54..48120503ac 100644 --- a/Resources/Locale/en-US/shell.ftl +++ b/Resources/Locale/en-US/shell.ftl @@ -37,7 +37,7 @@ shell-invalid-color-hex = Invalid color hex! shell-target-player-does-not-exist = Target player does not exist! shell-target-entity-does-not-have-message = Target entity does not have {INDEFINITE($missing)} {$missing}! shell-timespan-minutes-must-be-correct = {$span} is not a valid minutes timespan. -shell-argument-must-be-prototype = Argument {$index} must be a ${prototypeName}! +shell-argument-must-be-prototype = Argument {$index} must be a {LOC($prototypeName)}! shell-argument-number-must-be-between = Argument {$index} must be a number between {$lower} and {$upper}! shell-argument-station-id-invalid = Argument {$index} must be a valid station id! shell-argument-map-id-invalid = Argument {$index} must be a valid map id! diff --git a/Resources/Locale/en-US/worldgen/applyworldgenconfig.ftl b/Resources/Locale/en-US/worldgen/applyworldgenconfig.ftl new file mode 100644 index 0000000000..a2144d08b2 --- /dev/null +++ b/Resources/Locale/en-US/worldgen/applyworldgenconfig.ftl @@ -0,0 +1,4 @@ +cmd-applyworldgenconfig-description = Applies the given worldgen configuration to a map, setting it up for chunk loading/etc. +cmd-applyworldgenconfig-help = applyworldgenconfig +cmd-applyworldgenconfig-prototype = worldgen config prototype +cmd-applyworldgenconfig-success = Config applied successfully. Do not rerun this command on this map. diff --git a/Resources/Prototypes/Entities/Structures/Machines/Computers/computers.yml b/Resources/Prototypes/Entities/Structures/Machines/Computers/computers.yml index 6d80d44472..b1edc2c24e 100644 --- a/Resources/Prototypes/Entities/Structures/Machines/Computers/computers.yml +++ b/Resources/Prototypes/Entities/Structures/Machines/Computers/computers.yml @@ -55,6 +55,8 @@ - key: enum.ShuttleConsoleUiKey.Key type: ShuttleConsoleBoundUserInterface - type: RadarConsole + - type: WorldLoader + radius: 256 - type: PointLight radius: 1.5 energy: 1.6 @@ -102,6 +104,8 @@ - Syndicate - type: RadarConsole maxRange: 1536 + - type: WorldLoader + radius: 1536 - type: PointLight radius: 1.5 energy: 1.6 diff --git a/Resources/Prototypes/Entities/World/Debris/asteroids.yml b/Resources/Prototypes/Entities/World/Debris/asteroids.yml new file mode 100644 index 0000000000..0c55e9060f --- /dev/null +++ b/Resources/Prototypes/Entities/World/Debris/asteroids.yml @@ -0,0 +1,76 @@ +- type: entity + id: BaseAsteroidDebris + parent: BaseDebris + name: Asteroid Debris + abstract: true + components: + - type: MapGrid + - type: BlobFloorPlanBuilder + floorTileset: + - FloorAsteroidCoarseSand0 + blobDrawProb: 0.5 + radius: 6 + floorPlacements: 16 + - type: SimpleFloorPlanPopulator + entries: + FloorAsteroidCoarseSand0: + - id: WallRock + prob: 0.5 + - id: WallRockGold + prob: 0.01 + - id: WallRockSilver + prob: 0.04 + - id: WallRockPlasma + prob: 0.09 + - id: WallRockTin + prob: 0.2 + - id: WallRockUranium + prob: 0.07 + - id: WallRockQuartz + prob: 0.2 + - type: GCAbleObject + queue: SpaceDebris + - type: IFF + flags: HideLabel + color: "#d67e27" + +- type: entity + id: AsteroidDebrisSmall + parent: BaseAsteroidDebris + name: Asteroid Debris Small + noSpawn: true + components: + - type: MapGrid + - type: BlobFloorPlanBuilder + floorPlacements: 8 + +- type: entity + id: AsteroidDebrisMedium + parent: BaseAsteroidDebris + name: Asteroid Debris Medium + noSpawn: true + components: + - type: MapGrid + - type: BlobFloorPlanBuilder + floorPlacements: 16 + +- type: entity + id: AsteroidDebrisLarge + parent: BaseAsteroidDebris + name: Asteroid Debris Large + noSpawn: true + components: + - type: MapGrid + - type: BlobFloorPlanBuilder + floorPlacements: 24 + +- type: entity + id: AsteroidDebrisLarger + parent: BaseAsteroidDebris + name: Asteroid Debris Larger + noSpawn: true + components: + - type: MapGrid + - type: BlobFloorPlanBuilder + radius: 12 + floorPlacements: 36 diff --git a/Resources/Prototypes/Entities/World/Debris/base_debris.yml b/Resources/Prototypes/Entities/World/Debris/base_debris.yml new file mode 100644 index 0000000000..c125d991fd --- /dev/null +++ b/Resources/Prototypes/Entities/World/Debris/base_debris.yml @@ -0,0 +1,6 @@ +- type: entity + id: BaseDebris + abstract: true + components: + - type: OwnedDebris + - type: LocalityLoader diff --git a/Resources/Prototypes/Entities/World/Debris/wrecks.yml b/Resources/Prototypes/Entities/World/Debris/wrecks.yml new file mode 100644 index 0000000000..a3cfaf9788 --- /dev/null +++ b/Resources/Prototypes/Entities/World/Debris/wrecks.yml @@ -0,0 +1,82 @@ +- type: entity + id: BaseScrapDebris + parent: BaseDebris + name: Scrap Debris + abstract: true + components: + - type: MapGrid + - type: BlobFloorPlanBuilder + floorTileset: + - Plating + - Plating + - Plating + - FloorSteel + - Lattice + blobDrawProb: 0.5 + radius: 6 + floorPlacements: 16 + - type: SimpleFloorPlanPopulator + entries: + Plating: + - prob: 3 # Intentional blank. + - id: SalvageMaterialCrateSpawner + prob: 1 + - id: SalvageCanisterSpawner + prob: 0.2 + - id: SalvageMobSpawner + prob: 0.7 + - id: WallSolid + prob: 1 + - id: Grille + prob: 0.5 + Lattice: + - prob: 2 + - id: Grille + prob: 0.2 + - id: SalvageMaterialCrateSpawner + prob: 0.3 + - id: SalvageCanisterSpawner + prob: 0.2 + FloorSteel: + - prob: 3 # Intentional blank. + - id: SalvageMaterialCrateSpawner + prob: 1 + - id: SalvageCanisterSpawner + prob: 0.2 + - id: SalvageMobSpawner + prob: 0.7 + - type: GCAbleObject + queue: SpaceDebris + - type: IFF + flags: HideLabel + color: "#88b0d1" + +- type: entity + id: ScrapDebrisSmall + parent: BaseScrapDebris + name: Scrap Debris Small + noSpawn: true + components: + - type: MapGrid + - type: BlobFloorPlanBuilder + floorPlacements: 8 + +- type: entity + id: ScrapDebrisMedium + parent: BaseScrapDebris + name: Scrap Debris Medium + noSpawn: true + components: + - type: MapGrid + - type: BlobFloorPlanBuilder + floorPlacements: 16 + +- type: entity + id: ScrapDebrisLarge + parent: BaseScrapDebris + name: Scrap Debris Large + noSpawn: true + components: + - type: MapGrid + - type: BlobFloorPlanBuilder + floorPlacements: 24 diff --git a/Resources/Prototypes/Entities/World/chunk.yml b/Resources/Prototypes/Entities/World/chunk.yml new file mode 100644 index 0000000000..83a4d65a88 --- /dev/null +++ b/Resources/Prototypes/Entities/World/chunk.yml @@ -0,0 +1,15 @@ +- type: entity + id: WorldChunk + parent: MarkerBase + name: World Chunk + description: | + It's rude to stare. + It's also a bit odd you're looking at the abstract representation of the grid of reality. + noSpawn: true + components: + - type: WorldChunk + - type: Sprite + sprite: Markers/cross.rsi + netsync: false + layers: + - state: blue diff --git a/Resources/Prototypes/GC/world.yml b/Resources/Prototypes/GC/world.yml new file mode 100644 index 0000000000..b58a68158c --- /dev/null +++ b/Resources/Prototypes/GC/world.yml @@ -0,0 +1,4 @@ +- type: gcQueue + id: SpaceDebris + depth: 512 # So there's a decent bit of time before roids unload. + minDepthToProcess: 256 diff --git a/Resources/Prototypes/World/Biomes/basic.yml b/Resources/Prototypes/World/Biomes/basic.yml new file mode 100644 index 0000000000..5ecd85bbc4 --- /dev/null +++ b/Resources/Prototypes/World/Biomes/basic.yml @@ -0,0 +1,26 @@ +- type: spaceBiome + id: AsteroidsStandard + priority: 0 # This probably shouldn't get selected. + noiseRanges: {} + chunkComponents: + - type: DebrisFeaturePlacerController + densityNoiseChannel: Density + - type: SimpleDebrisSelector + debrisTable: + - id: AsteroidDebrisSmall + - id: AsteroidDebrisMedium + - id: AsteroidDebrisLarge + prob: 0.7 + - id: AsteroidDebrisLarger + prob: 0.4 + - type: NoiseDrivenDebrisSelector + noiseChannel: Wreck + debrisTable: + - id: ScrapDebrisSmall + - id: ScrapDebrisMedium + - id: ScrapDebrisLarge + prob: 0.5 + - type: NoiseRangeCarver + ranges: + - 0.4, 0.6 + noiseChannel: Carver diff --git a/Resources/Prototypes/World/Biomes/failsafes.yml b/Resources/Prototypes/World/Biomes/failsafes.yml new file mode 100644 index 0000000000..5e3c50b44c --- /dev/null +++ b/Resources/Prototypes/World/Biomes/failsafes.yml @@ -0,0 +1,21 @@ +- type: spaceBiome + id: Failsafe + priority: -999999 # This DEFINITELY shouldn't get selected! + noiseRanges: {} + +- type: spaceBiome + id: AsteroidsFallback + priority: -999998 # This probably shouldn't get selected. + noiseRanges: {} + chunkComponents: + - type: DebrisFeaturePlacerController + densityNoiseChannel: Density + - type: SimpleDebrisSelector + debrisTable: + - id: AsteroidDebrisSmall + - id: AsteroidDebrisMedium + - id: AsteroidDebrisLarge + prob: 0.7 + - id: AsteroidDebrisLarger + prob: 0.4 + diff --git a/Resources/Prototypes/World/noise_channels.yml b/Resources/Prototypes/World/noise_channels.yml new file mode 100644 index 0000000000..668b338dd3 --- /dev/null +++ b/Resources/Prototypes/World/noise_channels.yml @@ -0,0 +1,44 @@ +- type: noiseChannel + id: Density + noiseType: Perlin + fractalLacunarityByPi: 0.666666666 + remapTo0Through1: true + clippingRanges: + - 0.4, 0.6 + clippedValue: 1.658 # magic number for chunk size. + inputMultiplier: 6 # Makes density hopefully low noise in the local area while still being interesting at scale. + outputMultiplier: 50.0 # We scale density up significantly for more human-friendly numbers. + minimum: 45.0 + +- type: noiseChannel + id: DensityUnclipped + noiseType: Perlin + fractalLacunarityByPi: 0.666666666 + remapTo0Through1: true + inputMultiplier: 6 # Makes density hopefully low noise in the local area while still being interesting at scale. + outputMultiplier: 50.0 # We scale density up significantly for more human-friendly numbers. + minimum: 45.0 + +- type: noiseChannel + id: Carver + noiseType: Perlin + fractalLacunarityByPi: 0.666666666 + remapTo0Through1: true + inputMultiplier: 6 + +- type: noiseChannel + id: Wreck + noiseType: Perlin + fractalLacunarityByPi: 0.666666666 + clippingRanges: + - 0.0, 0.4 + clippedValue: 0 + remapTo0Through1: true + inputMultiplier: 16 # Makes wreck concentration very low noise at scale. + +- type: noiseChannel + id: Temperature + noiseType: Perlin + fractalLacunarityByPi: 0.666666666 + remapTo0Through1: true + inputMultiplier: 6 # Makes wreck concentration very low noise at scale. diff --git a/Resources/Prototypes/World/worldgen_default.yml b/Resources/Prototypes/World/worldgen_default.yml new file mode 100644 index 0000000000..af52c30cf1 --- /dev/null +++ b/Resources/Prototypes/World/worldgen_default.yml @@ -0,0 +1,9 @@ +- type: worldgenConfig + id: Default + components: + - type: WorldController + - type: BiomeSelection + biomes: + - AsteroidsFallback + - Failsafe + - AsteroidsStandard