Dynamic space world generation and debris. (#15120)

* World generation (squash)

* Test fixes.

* command

* o

* Access cleanup.

* Documentation touchups.

* Use a prototype serializer for BiomeSelectionComponent

* Struct enumerator in SimpleFloorPlanPopulatorSystem

* Safety margins around PoissonDiskSampler, cookie acquisition methodologies

* Struct enumerating PoissonDiskSampler; internal side

* Struct enumerating PoissonDiskSampler: Finish it

* Update WorldgenConfigSystem.cs

awa

---------

Co-authored-by: moonheart08 <moonheart08@users.noreply.github.com>
Co-authored-by: 20kdc <asdd2808@gmail.com>
This commit is contained in:
Moony
2023-05-16 06:36:45 -05:00
committed by GitHub
parent cdb46778dc
commit e91fc652a3
54 changed files with 2748 additions and 1 deletions

View File

@@ -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");

View File

@@ -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
};

View File

@@ -74,6 +74,7 @@ public sealed class CargoTest
var protoIds = protoManager.EnumeratePrototypes<EntityPrototype>()
.Where(p=>!p.Abstract)
.Where(p => !p.Components.ContainsKey("MapGrid")) // Grids are not for sale.
.Select(p => p.ID)
.ToList();

View File

@@ -39,6 +39,7 @@ namespace Content.IntegrationTests.Tests
var protoIds = prototypeMan
.EnumeratePrototypes<EntityPrototype>()
.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<EntityPrototype>()
.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<EntityPrototype>()
.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 = @"

View File

@@ -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;

View File

@@ -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<PlayTimeTrackingManager>();
IoCManager.Register<UserDbDataManager>();
IoCManager.Register<ServerInfoManager>();
IoCManager.Register<PoissonDiskSampler>();
}
}
}

View File

@@ -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;
/// <summary>
/// This is used for selecting the biome(s) to be used during world generation.
/// </summary>
[RegisterComponent]
[Access(typeof(BiomeSelectionSystem))]
public sealed class BiomeSelectionComponent : Component
{
/// <summary>
/// The list of biomes available to this selector.
/// </summary>
/// <remarks>This is always sorted by priority after ComponentStartup.</remarks>
[DataField("biomes", required: true,
customTypeSerializer: typeof(PrototypeIdListSerializer<BiomePrototype>))] public List<string> Biomes = new();
}

View File

@@ -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;
/// <summary>
/// This is used for carving out empty space in the game world, providing byways through the debris field.
/// </summary>
[RegisterComponent]
[Access(typeof(NoiseRangeCarverSystem))]
public sealed class NoiseRangeCarverComponent : Component
{
/// <summary>
/// The noise channel to use as a density controller.
/// </summary>
/// <remarks>This noise channel should be mapped to exactly the range [0, 1] unless you want a lot of warnings in the log.</remarks>
[DataField("noiseChannel", customTypeSerializer: typeof(PrototypeIdSerializer<NoiseChannelPrototype>))]
public string NoiseChannel { get; } = default!;
/// <summary>
/// The index of ranges in which to cut debris generation.
/// </summary>
[DataField("ranges", required: true)]
public List<Vector2> Ranges { get; } = default!;
}

View File

@@ -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;
/// <summary>
/// This is used for constructing asteroid debris.
/// </summary>
[RegisterComponent]
[Access(typeof(BlobFloorPlanBuilderSystem))]
public sealed class BlobFloorPlanBuilderComponent : Component
{
/// <summary>
/// The probability that placing a floor tile will add up to three-four neighboring tiles as well.
/// </summary>
[DataField("blobDrawProb")] public float BlobDrawProb;
/// <summary>
/// The maximum radius for the structure.
/// </summary>
[DataField("radius", required: true)] public float Radius;
/// <summary>
/// The tiles to be used for the floor plan.
/// </summary>
[DataField("floorTileset", required: true,
customTypeSerializer: typeof(PrototypeIdListSerializer<ContentTileDefinition>))]
public List<string> FloorTileset { get; } = default!;
/// <summary>
/// The number of floor tiles to place when drawing the asteroid layout.
/// </summary>
[DataField("floorPlacements", required: true)]
public int FloorPlacements { get; }
}

View File

@@ -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;
/// <summary>
/// This is used for controlling the debris feature placer.
/// </summary>
[RegisterComponent]
[Access(typeof(DebrisFeaturePlacerSystem))]
public sealed class DebrisFeaturePlacerControllerComponent : Component
{
/// <summary>
/// Whether or not to clip debris that would spawn at a location that has a density of zero.
/// </summary>
[DataField("densityClip")] public bool DensityClip = true;
/// <summary>
/// Whether or not entities are already spawned.
/// </summary>
public bool DoSpawns = true;
[DataField("ownedDebris")] public Dictionary<Vector2, EntityUid?> OwnedDebris = new();
/// <summary>
/// The chance spawning a piece of debris will just be cancelled randomly.
/// </summary>
[DataField("randomCancelChance")] public float RandomCancellationChance = 0.1f;
/// <summary>
/// Radius in which there should be no objects for debris to spawn.
/// </summary>
[DataField("safetyZoneRadius")] public float SafetyZoneRadius = 16.0f;
/// <summary>
/// The noise channel to use as a density controller.
/// </summary>
[DataField("densityNoiseChannel", customTypeSerializer: typeof(PrototypeIdSerializer<NoiseChannelPrototype>))]
public string DensityNoiseChannel { get; } = default!;
}

View File

@@ -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;
/// <summary>
/// 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.
/// </summary>
[RegisterComponent]
[Access(typeof(NoiseDrivenDebrisSelectorSystem))]
public sealed class NoiseDrivenDebrisSelectorComponent : Component
{
private EntitySpawnCollectionCache? _cache;
/// <summary>
/// The prototype-facing debris table entries.
/// </summary>
[DataField("debrisTable", required: true)]
private List<EntitySpawnEntry> _entries = default!;
/// <summary>
/// The debris entity spawn collection.
/// </summary>
public EntitySpawnCollectionCache CachedDebrisTable
{
get
{
_cache ??= new EntitySpawnCollectionCache(_entries);
return _cache;
}
}
/// <summary>
/// The noise channel to use as a density controller.
/// </summary>
/// <remarks>This noise channel should be mapped to exactly the range [0, 1] unless you want a lot of warnings in the log.</remarks>
[DataField("noiseChannel", customTypeSerializer: typeof(PrototypeIdSerializer<NoiseChannelPrototype>))]
public string NoiseChannel { get; } = default!;
}

View File

@@ -0,0 +1,23 @@
using Content.Server.Worldgen.Systems.Debris;
namespace Content.Server.Worldgen.Components.Debris;
/// <summary>
/// This is used for attaching a piece of debris to it's owning controller.
/// Mostly just syncs deletion.
/// </summary>
[RegisterComponent]
[Access(typeof(DebrisFeaturePlacerSystem))]
public sealed class OwnedDebrisComponent : Component
{
/// <summary>
/// The last location in the controller's internal structure for this debris.
/// </summary>
[DataField("lastKey")] public Vector2 LastKey;
/// <summary>
/// The DebrisFeaturePlacerController-having entity that owns this.
/// </summary>
[DataField("owningController")] public EntityUid OwningController;
}

View File

@@ -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;
/// <summary>
/// This is used for a very simple debris selection for simple biomes. Just uses a spawn table.
/// </summary>
[RegisterComponent]
[Access(typeof(DebrisFeaturePlacerSystem))]
public sealed class SimpleDebrisSelectorComponent : Component
{
private EntitySpawnCollectionCache? _cache;
/// <summary>
/// The prototype-facing debris table entries.
/// </summary>
[DataField("debrisTable", required: true)]
private List<EntitySpawnEntry> _entries = default!;
/// <summary>
/// The debris entity spawn collection.
/// </summary>
public EntitySpawnCollectionCache CachedDebrisTable
{
get
{
_cache ??= new EntitySpawnCollectionCache(_entries);
return _cache;
}
}
}

View File

@@ -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;
/// <summary>
/// This is used for populating a grid with random entities automatically.
/// </summary>
[RegisterComponent]
[Access(typeof(SimpleFloorPlanPopulatorSystem))]
public sealed class SimpleFloorPlanPopulatorComponent : Component
{
private Dictionary<string, EntitySpawnCollectionCache>? _caches;
/// <summary>
/// The prototype facing floor plan populator entries.
/// </summary>
[DataField("entries", required: true,
customTypeSerializer: typeof(PrototypeIdDictionarySerializer<List<EntitySpawnEntry>, ContentTileDefinition>))]
private Dictionary<string, List<EntitySpawnEntry>> _entries = default!;
/// <summary>
/// The spawn collections used to place entities on different tile types.
/// </summary>
[ViewVariables]
public Dictionary<string, EntitySpawnCollectionCache> Caches
{
get
{
if (_caches is null)
{
_caches = _entries
.Select(x =>
new KeyValuePair<string, EntitySpawnCollectionCache>(x.Key,
new EntitySpawnCollectionCache(x.Value)))
.ToDictionary(x => x.Key, x => x.Value);
}
return _caches;
}
}
}

View File

@@ -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;
/// <summary>
/// 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.
/// </summary>
[RegisterComponent]
[Access(typeof(GCQueueSystem))]
public sealed class GCAbleObjectComponent : Component
{
/// <summary>
/// Which queue to insert this object into when GCing
/// </summary>
[DataField("queue", required: true, customTypeSerializer: typeof(PrototypeIdSerializer<GCQueuePrototype>))]
public string Queue = default!;
}

View File

@@ -0,0 +1,17 @@
using Content.Server.Worldgen.Systems;
namespace Content.Server.Worldgen.Components;
/// <summary>
/// This is used for marking a chunk as loaded.
/// </summary>
[RegisterComponent]
[Access(typeof(WorldControllerSystem))]
public sealed class LoadedChunkComponent : Component
{
/// <summary>
/// The current list of entities loading this chunk.
/// </summary>
[ViewVariables] public List<EntityUid>? Loaders = null;
}

View File

@@ -0,0 +1,19 @@
using Content.Server.Worldgen.Systems;
namespace Content.Server.Worldgen.Components;
/// <summary>
/// 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.
/// </summary>
[RegisterComponent]
[Access(typeof(LocalityLoaderSystem))]
public sealed class LocalityLoaderComponent : Component
{
/// <summary>
/// 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.
/// </summary>
[DataField("loadingDistance")] public int LoadingDistance = 32;
}

View File

@@ -0,0 +1,20 @@
using Content.Server.Worldgen.Prototypes;
using Content.Server.Worldgen.Systems;
namespace Content.Server.Worldgen.Components;
/// <summary>
/// This is used for containing configured noise generators.
/// </summary>
[RegisterComponent]
[Access(typeof(NoiseIndexSystem))]
public sealed class NoiseIndexComponent : Component
{
/// <summary>
/// An index of generators, to avoid having to recreate them every time a noise channel is used.
/// Keyed by noise generator prototype ID.
/// </summary>
[Access(typeof(NoiseIndexSystem), Friend = AccessPermissions.ReadWriteExecute, Other = AccessPermissions.None)]
public Dictionary<string, NoiseGenerator> Generators { get; } = new();
}

View File

@@ -0,0 +1,22 @@
using Content.Server.Worldgen.Systems;
namespace Content.Server.Worldgen.Components;
/// <summary>
/// This is used for marking an entity as being a world chunk.
/// </summary>
[RegisterComponent]
[Access(typeof(WorldControllerSystem))]
public sealed class WorldChunkComponent : Component
{
/// <summary>
/// The coordinates of the chunk, in chunk space.
/// </summary>
[DataField("coordinates")] public Vector2i Coordinates;
/// <summary>
/// The map this chunk belongs to.
/// </summary>
[DataField("map")] public EntityUid Map;
}

View File

@@ -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;
/// <summary>
/// This is used for controlling overall world loading, containing an index of all chunks in the map.
/// </summary>
[RegisterComponent]
[Access(typeof(WorldControllerSystem))]
public sealed class WorldControllerComponent : Component
{
/// <summary>
/// The prototype to use for chunks on this world map.
/// </summary>
[DataField("chunkProto", customTypeSerializer: typeof(PrototypeIdSerializer<EntityPrototype>))]
public string ChunkProto = "WorldChunk";
/// <summary>
/// An index of chunks owned by the controller.
/// </summary>
[DataField("chunks")] public Dictionary<Vector2i, EntityUid> Chunks = new();
}

View File

@@ -0,0 +1,18 @@
using Content.Server.Worldgen.Systems;
namespace Content.Server.Worldgen.Components;
/// <summary>
/// This is used for allowing some objects to load the game world.
/// </summary>
[RegisterComponent]
[Access(typeof(WorldControllerSystem))]
public sealed class WorldLoaderComponent : Component
{
/// <summary>
/// The radius in which the loader loads the world.
/// </summary>
[ViewVariables(VVAccess.ReadWrite)] [DataField("radius")]
public int Radius = 128;
}

View File

@@ -0,0 +1,59 @@
using System.Diagnostics.CodeAnalysis;
using System.Diagnostics.Contracts;
namespace Content.Server.Worldgen;
/// <summary>
/// A struct enumerator of points on a grid within the given radius.
/// </summary>
public struct GridPointsNearEnumerator
{
private readonly int _radius;
private readonly Vector2i _center;
private int _x;
private int _y;
/// <summary>
/// Initializes a new enumerator with the given center and radius.
/// </summary>
public GridPointsNearEnumerator(Vector2i center, int radius)
{
_radius = radius;
_center = center;
_x = -_radius;
_y = -_radius;
}
/// <summary>
/// Gets the next point in the enumeration.
/// </summary>
/// <param name="chunk">The computed point, if any</param>
/// <returns>Success</returns>
[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;
}
}

View File

@@ -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;
/// <summary>
/// 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.
/// </summary>
[Prototype("spaceBiome")]
public sealed class BiomePrototype : IPrototype, IInheritingPrototype
{
/// <inheritdoc />
[ParentDataField(typeof(AbstractPrototypeIdArraySerializer<EntityPrototype>))]
public string[]? Parents { get; }
/// <inheritdoc />
[NeverPushInheritance]
[AbstractDataField]
public bool Abstract { get; }
/// <inheritdoc />
[IdDataField]
public string ID { get; } = default!;
/// <summary>
/// The valid ranges of noise values under which this biome can be picked.
/// </summary>
[DataField("noiseRanges", required: true)]
public Dictionary<string, List<Vector2>> NoiseRanges = default!;
/// <summary>
/// Higher priority biomes get picked before lower priority ones.
/// </summary>
[DataField("priority", required: true)]
public int Priority { get; }
/// <summary>
/// The components that get added to the target map.
/// </summary>
[DataField("chunkComponents")]
[AlwaysPushInheritance]
public EntityPrototype.ComponentRegistry ChunkComponents { get; } = new();
//TODO: Get someone to make this a method on componentregistry that does it Correctly.
/// <summary>
/// Applies the worldgen config to the given target (presumably a map.)
/// </summary>
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);
}
}
}

View File

@@ -0,0 +1,41 @@
using Robust.Shared.Prototypes;
namespace Content.Server.Worldgen.Prototypes;
/// <summary>
/// This is a prototype for a GC queue.
/// </summary>
[Prototype("gcQueue")]
public sealed class GCQueuePrototype : IPrototype
{
/// <inheritdoc />
[IdDataField]
public string ID { get; } = default!;
/// <summary>
/// How deep the GC queue is at most. If this value is ever exceeded entities get processed automatically regardless of
/// tick-time cap.
/// </summary>
[DataField("depth", required: true)]
public int Depth { get; }
/// <summary>
/// The maximum amount of time that can be spent processing this queue.
/// </summary>
[DataField("maximumTickTime")]
public TimeSpan MaximumTickTime { get; } = TimeSpan.FromMilliseconds(1);
/// <summary>
/// The minimum depth before entities in the queue actually get processed for deletion.
/// </summary>
[DataField("minDepthToProcess", required: true)]
public int MinDepthToProcess { get; }
/// <summary>
/// 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.
/// </summary>
[DataField("trySkipQueue")]
public bool TrySkipQueue { get; }
}

View File

@@ -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;
/// <summary>
/// This is a config for noise channels, used by worldgen.
/// </summary>
[Virtual]
public class NoiseChannelConfig
{
/// <summary>
/// The noise type used by the noise generator.
/// </summary>
[DataField("noiseType")]
public FastNoiseLite.NoiseType NoiseType { get; } = FastNoiseLite.NoiseType.Cellular;
/// <summary>
/// The fractal type used by the noise generator.
/// </summary>
[DataField("fractalType")]
public FastNoiseLite.FractalType FractalType { get; } = FastNoiseLite.FractalType.FBm;
/// <summary>
/// Multiplied by pi in code when used.
/// </summary>
[DataField("fractalLacunarityByPi")]
public float FractalLacunarityByPi { get; } = 2.0f / 3.0f;
/// <summary>
/// Ranges of values that get clamped down to the "clipped" value.
/// </summary>
[DataField("clippingRanges")]
public List<Vector2> ClippingRanges { get; } = new();
/// <summary>
/// The value clipped chunks are set to.
/// </summary>
[DataField("clippedValue")]
public float ClippedValue { get; }
/// <summary>
/// A value the output is multiplied by.
/// </summary>
[DataField("outputMultiplier")]
public float OutputMultiplier { get; } = 1.0f;
/// <summary>
/// A value the input is multiplied by.
/// </summary>
[DataField("inputMultiplier")]
public float InputMultiplier { get; } = 1.0f;
/// <summary>
/// Remaps the output of the noise function from the range (-1, 1) to (0, 1). This is done before all other output
/// transformations.
/// </summary>
[DataField("remapTo0Through1")]
public bool RemapTo0Through1 { get; }
/// <summary>
/// For when the transformation you need is too complex to describe in YAML.
/// </summary>
[DataField("noisePostProcess")]
public NoisePostProcess? NoisePostProcess { get; }
/// <summary>
/// For when you need a complex transformation of the input coordinates.
/// </summary>
[DataField("noiseCoordinateProcess")]
public NoiseCoordinateProcess? NoiseCoordinateProcess { get; }
/// <summary>
/// The "center" of the range of values. Or the minimum if mapped 0 through 1.
/// </summary>
[DataField("minimum")]
public float Minimum { get; }
}
[Prototype("noiseChannel")]
public sealed class NoiseChannelPrototype : NoiseChannelConfig, IPrototype, IInheritingPrototype
{
/// <inheritdoc />
[ParentDataField(typeof(AbstractPrototypeIdArraySerializer<EntityPrototype>))]
public string[]? Parents { get; }
/// <inheritdoc />
[NeverPushInheritance]
[AbstractDataField]
public bool Abstract { get; }
/// <inheritdoc />
[IdDataField]
public string ID { get; } = default!;
}
/// <summary>
/// A wrapper around FastNoise's noise generation, using noise channel configs.
/// </summary>
public struct NoiseGenerator
{
private readonly NoiseChannelConfig _config;
private readonly FastNoiseLite _noise;
/// <summary>
/// Produces a new noise generator from the given channel config and rng seed.
/// </summary>
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);
}
/// <summary>
/// Evaluates the noise generator at the provided coordinates.
/// </summary>
/// <param name="coords">Coordinates to use as input</param>
/// <returns>Computed noise value</returns>
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;
}
}
/// <summary>
/// A processing class that adjusts the input coordinate space to a noise channel.
/// </summary>
[ImplicitDataDefinitionForInheritors]
public abstract class NoiseCoordinateProcess
{
public abstract Vector2 Process(Vector2 inp);
}
/// <summary>
/// A processing class that adjusts the final result of the noise channel.
/// </summary>
[ImplicitDataDefinitionForInheritors]
public abstract class NoisePostProcess
{
public abstract float Process(float inp);
}

View File

@@ -0,0 +1,38 @@
using Robust.Shared.Prototypes;
using Robust.Shared.Serialization.Manager;
namespace Content.Server.Worldgen.Prototypes;
/// <summary>
/// This is a prototype for controlling overall world generation.
/// The components included are applied to the map that world generation is configured on.
/// </summary>
[Prototype("worldgenConfig")]
public sealed class WorldgenConfigPrototype : IPrototype
{
/// <inheritdoc />
[IdDataField]
public string ID { get; } = default!;
/// <summary>
/// The components that get added to the target map.
/// </summary>
[DataField("components", required: true)]
public EntityPrototype.ComponentRegistry Components { get; } = default!;
//TODO: Get someone to make this a method on componentregistry that does it Correctly.
/// <summary>
/// Applies the worldgen config to the given target (presumably a map.)
/// </summary>
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);
}
}
}

View File

@@ -0,0 +1,58 @@
using Content.Server.Worldgen.Components;
using JetBrains.Annotations;
namespace Content.Server.Worldgen.Systems;
/// <summary>
/// This provides some additional functions for world generation systems.
/// Exists primarily for convenience and to avoid code duplication.
/// </summary>
[PublicAPI]
public abstract class BaseWorldSystem : EntitySystem
{
[Dependency] private readonly WorldControllerSystem _worldController = default!;
/// <summary>
/// Gets a chunk's coordinates in chunk space as an integer value.
/// </summary>
/// <param name="ent"></param>
/// <param name="xform"></param>
/// <returns>Chunk space coordinates</returns>
[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();
}
/// <summary>
/// Gets a chunk's coordinates in chunk space as a floating point value.
/// </summary>
/// <param name="ent"></param>
/// <param name="xform"></param>
/// <returns>Chunk space coordinates</returns>
[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);
}
/// <summary>
/// Attempts to get a chunk, creating it if it doesn't exist.
/// </summary>
/// <param name="chunk">Chunk coordinates to get the chunk entity for.</param>
/// <param name="map">Map the chunk is in.</param>
/// <param name="controller">The controller this chunk belongs to.</param>
/// <returns>A chunk, if available.</returns>
[Pure]
public EntityUid? GetOrCreateChunk(Vector2i chunk, EntityUid map, WorldControllerComponent? controller = null)
{
return _worldController.GetOrCreateChunk(chunk, map, controller);
}
}

View File

@@ -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;
/// <summary>
/// This handles biome selection, evaluating which biome to apply to a chunk based on noise channels.
/// </summary>
public sealed class BiomeSelectionSystem : BaseWorldSystem
{
[Dependency] private readonly NoiseIndexSystem _noiseIdx = default!;
[Dependency] private readonly IPrototypeManager _proto = default!;
[Dependency] private readonly ISerializationManager _ser = default!;
/// <inheritdoc />
public override void Initialize()
{
SubscribeLocalEvent<BiomeSelectionComponent, ComponentStartup>(OnBiomeSelectionStartup);
SubscribeLocalEvent<BiomeSelectionComponent, WorldChunkAddedEvent>(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<BiomePrototype>(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<BiomePrototype>(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;
}
}

View File

@@ -0,0 +1,35 @@
using Content.Server.Worldgen.Components.Carvers;
using Content.Server.Worldgen.Systems.Debris;
namespace Content.Server.Worldgen.Systems.Carvers;
/// <summary>
/// This handles carving out holes in world generation according to a noise channel.
/// </summary>
public sealed class NoiseRangeCarverSystem : EntitySystem
{
[Dependency] private readonly NoiseIndexSystem _index = default!;
/// <inheritdoc />
public override void Initialize()
{
SubscribeLocalEvent<NoiseRangeCarverComponent, PrePlaceDebrisFeatureEvent>(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;
}
}
}

View File

@@ -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;
/// <summary>
/// This handles building the floor plans for "blobby" debris.
/// </summary>
public sealed class BlobFloorPlanBuilderSystem : BaseWorldSystem
{
[Dependency] private readonly IRobustRandom _random = default!;
[Dependency] private readonly ITileDefinitionManager _tileDefinition = default!;
/// <inheritdoc />
public override void Initialize()
{
SubscribeLocalEvent<BlobFloorPlanBuilderComponent, ComponentStartup>(OnBlobFloorPlanBuilderStartup);
}
private void OnBlobFloorPlanBuilderStartup(EntityUid uid, BlobFloorPlanBuilderComponent component,
ComponentStartup args)
{
PlaceFloorplanTiles(component, Comp<MapGridComponent>(uid));
}
private void PlaceFloorplanTiles(BlobFloorPlanBuilderComponent comp, MapGridComponent grid)
{
// NO MORE THAN TWO ALLOCATIONS THANK YOU VERY MUCH.
var spawnPoints = new HashSet<Vector2i>(comp.FloorPlacements * 6);
var taken = new Dictionary<Vector2i, Tile>(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());
}
}

View File

@@ -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;
/// <summary>
/// This handles placing debris within the world evenly with rng, primarily for structures like asteroid fields.
/// </summary>
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!;
/// <inheritdoc />
public override void Initialize()
{
_sawmill = _logManager.GetSawmill("world.debris.feature_placer");
SubscribeLocalEvent<DebrisFeaturePlacerControllerComponent, WorldChunkLoadedEvent>(OnChunkLoaded);
SubscribeLocalEvent<DebrisFeaturePlacerControllerComponent, WorldChunkUnloadedEvent>(OnChunkUnloaded);
SubscribeLocalEvent<OwnedDebrisComponent, ComponentShutdown>(OnDebrisShutdown);
SubscribeLocalEvent<OwnedDebrisComponent, MoveEvent>(OnDebrisMove);
SubscribeLocalEvent<OwnedDebrisComponent, TryCancelGC>(OnTryCancelGC);
SubscribeLocalEvent<SimpleDebrisSelectorComponent, TryGetPlaceableDebrisFeatureEvent>(
OnTryGetPlacableDebrisEvent);
}
/// <summary>
/// Handles GC cancellation in case the chunk is still loaded.
/// </summary>
private void OnTryCancelGC(EntityUid uid, OwnedDebrisComponent component, ref TryCancelGC args)
{
args.Cancelled |= HasComp<LoadedChunkComponent>(component.OwningController);
}
/// <summary>
/// Handles debris moving, and making sure it stays parented to a chunk for loading purposes.
/// </summary>
private void OnDebrisMove(EntityUid uid, OwnedDebrisComponent component, ref MoveEvent args)
{
if (!HasComp<WorldChunkComponent>(component.OwningController))
return; // Redundant logic, prolly needs it's own handler for your custom system.
var placer = Comp<DebrisFeaturePlacerControllerComponent>(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<OwnedDebrisComponent>(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<DebrisFeaturePlacerControllerComponent>(newChunk, out var newPlacer))
{
// Whelp.
RemCompDeferred<OwnedDebrisComponent>(uid);
return;
}
newPlacer.OwnedDebris[_xformSys.GetWorldPosition(xform)] = uid; // Change our owner.
component.OwningController = newChunk.Value;
}
/// <summary>
/// Handles debris shutdown/detach.
/// </summary>
private void OnDebrisShutdown(EntityUid uid, OwnedDebrisComponent component, ComponentShutdown args)
{
if (!TryComp<DebrisFeaturePlacerControllerComponent>(component.OwningController, out var placer))
return;
placer.OwnedDebris[component.LastKey] = null;
if (Terminating(uid))
placer.OwnedDebris.Remove(component.LastKey);
}
/// <summary>
/// Queues all debris owned by the placer for garbage collection.
/// </summary>
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;
}
/// <summary>
/// Handles providing a debris type to place for SimpleDebrisSelectorComponent.
/// This randomly picks a debris type from the EntitySpawnCollectionCache.
/// </summary>
private void OnTryGetPlacableDebrisEvent(EntityUid uid, SimpleDebrisSelectorComponent component,
ref TryGetPlaceableDebrisFeatureEvent args)
{
if (args.DebrisProto is not null)
return;
var l = new List<string?>(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];
}
/// <summary>
/// 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.
/// </summary>
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<WorldChunkComponent>(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<Vector2>? 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<MapComponent>(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<OwnedDebrisComponent>(ent);
owned.OwningController = uid;
owned.LastKey = point;
}
if (failures > 0)
_sawmill.Error($"Failed to place {failures} debris at chunk {args.Chunk}");
}
/// <summary>
/// Generates the points to put into a chunk using a poisson disk sampler.
/// </summary>
private List<Vector2> 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<Vector2>();
var realCenter = WorldGen.ChunkToWorldCoordsCentered(coords.Floored());
while (enumerator.MoveNext(out var debrisPoint))
{
debrisPoints.Add(realCenter + debrisPoint.Value);
}
return debrisPoints;
}
}
/// <summary>
/// Fired directed on the debris feature placer controller and the chunk, ahead of placing a debris piece.
/// </summary>
[ByRefEvent]
[PublicAPI]
public record struct PrePlaceDebrisFeatureEvent(EntityCoordinates Coords, EntityUid Chunk, bool Handled = false);
/// <summary>
/// Fired directed on the debris feature placer controller and the chunk, to select which debris piece to place.
/// </summary>
[ByRefEvent]
[PublicAPI]
public record struct TryGetPlaceableDebrisFeatureEvent(EntityCoordinates Coords, EntityUid Chunk,
string? DebrisProto = null);

View File

@@ -0,0 +1,59 @@
using Content.Server.Worldgen.Components.Debris;
using Robust.Server.GameObjects;
using Robust.Shared.Random;
namespace Content.Server.Worldgen.Systems.Debris;
/// <summary>
/// This handles selecting debris with probability decided by a noise channel.
/// </summary>
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!;
/// <inheritdoc />
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<NoiseDrivenDebrisSelectorComponent, TryGetPlaceableDebrisFeatureEvent>(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<string?>(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];
}
}

View File

@@ -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;
/// <summary>
/// This handles populating simple structures, simply using a loot table for each tile.
/// </summary>
public sealed class SimpleFloorPlanPopulatorSystem : BaseWorldSystem
{
[Dependency] private readonly IRobustRandom _random = default!;
[Dependency] private readonly ITileDefinitionManager _tileDefinition = default!;
/// <inheritdoc />
public override void Initialize()
{
SubscribeLocalEvent<SimpleFloorPlanPopulatorComponent, LocalStructureLoadedEvent>(OnFloorPlanBuilt);
}
private void OnFloorPlanBuilt(EntityUid uid, SimpleFloorPlanPopulatorComponent component,
LocalStructureLoadedEvent args)
{
var placeables = new List<string?>(4);
var grid = Comp<MapGridComponent>(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);
}
}
}
}

View File

@@ -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;
/// <summary>
/// This handles delayed garbage collection of entities, to avoid overloading the tick in particularly expensive cases.
/// </summary>
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<string, Queue<EntityUid>> _queues = new();
/// <inheritdoc />
public override void Initialize()
{
_cfg.OnValueChanged(CCVars.GCMaximumTimeMs, s => _maximumProcessTime = TimeSpan.FromMilliseconds(s),
true);
}
/// <inheritdoc />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<GCQueuePrototype>(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);
}
}
}
}
/// <summary>
/// Attempts to GC an entity. This functions as QueueDel if it can't.
/// </summary>
/// <param name="e">Entity to GC.</param>
public void TryGCEntity(EntityUid e)
{
if (!TryComp<GCAbleObjectComponent>(e, out var comp))
{
QueueDel(e); // not our problem :)
return;
}
if (!_queues.TryGetValue(comp.Queue, out var queue))
{
queue = new Queue<EntityUid>();
_queues[comp.Queue] = queue;
}
var proto = _proto.Index<GCQueuePrototype>(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);
}
}
/// <summary>
/// Fired by GCQueueSystem to check if it can simply immediately GC an entity, for example if it was never fully
/// loaded.
/// </summary>
/// <param name="Cancelled">Whether or not the immediate deletion attempt was cancelled.</param>
[ByRefEvent]
[PublicAPI]
public record struct TryGCImmediately(bool Cancelled = false);
/// <summary>
/// Fired by GCQueueSystem to check if the collection of the given entity should be cancelled, for example it's chunk
/// being loaded again.
/// </summary>
/// <param name="Cancelled">Whether or not the deletion attempt was cancelled.</param>
[ByRefEvent]
[PublicAPI]
public record struct TryCancelGC(bool Cancelled = false);

View File

@@ -0,0 +1,59 @@
using Content.Server.Worldgen.Components;
using Robust.Server.GameObjects;
namespace Content.Server.Worldgen.Systems;
/// <summary>
/// This handles loading in objects based on distance from player, using some metadata on chunks.
/// </summary>
public sealed class LocalityLoaderSystem : BaseWorldSystem
{
[Dependency] private readonly TransformSystem _xformSys = default!;
/// <inheritdoc />
public override void Update(float frameTime)
{
var e = EntityQueryEnumerator<LocalityLoaderComponent, TransformComponent>();
var loadedQuery = GetEntityQuery<LoadedChunkComponent>();
var xformQuery = GetEntityQuery<TransformComponent>();
var controllerQuery = GetEntityQuery<WorldControllerComponent>();
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<LocalityLoaderComponent>(uid);
done = true;
break;
}
}
}
}
}
}
/// <summary>
/// A directed fired on a loadable entity when a local loader enters it's vicinity.
/// </summary>
public record struct LocalStructureLoadedEvent;

View File

@@ -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;
/// <summary>
/// This handles the noise index.
/// </summary>
public sealed class NoiseIndexSystem : EntitySystem
{
[Dependency] private readonly IPrototypeManager _prototype = default!;
[Dependency] private readonly IRobustRandom _random = default!;
/// <summary>
/// Gets a particular noise channel from the index on the given entity.
/// </summary>
/// <param name="holder">The holder of the index</param>
/// <param name="protoId">The channel prototype ID</param>
/// <returns>An initialized noise generator</returns>
public NoiseGenerator Get(EntityUid holder, string protoId)
{
var idx = EnsureComp<NoiseIndexComponent>(holder);
if (idx.Generators.TryGetValue(protoId, out var generator))
return generator;
var proto = _prototype.Index<NoiseChannelPrototype>(protoId);
var gen = new NoiseGenerator(proto, _random.Next());
idx.Generators[protoId] = gen;
return gen;
}
/// <summary>
/// Attempts to evaluate the given noise channel using the generator on the given entity.
/// </summary>
/// <param name="holder">The holder of the index</param>
/// <param name="protoId">The channel prototype ID</param>
/// <param name="coords">The coordinates to evaluate at</param>
/// <returns>The result of evaluation</returns>
public float Evaluate(EntityUid holder, string protoId, Vector2 coords)
{
var gen = Get(holder, protoId);
return gen.Evaluate(coords);
}
}

View File

@@ -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;
/// <summary>
/// This handles putting together chunk entities and notifying them about important changes.
/// </summary>
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!;
/// <inheritdoc />
public override void Initialize()
{
_sawmill = _logManager.GetSawmill("world");
SubscribeLocalEvent<LoadedChunkComponent, ComponentStartup>(OnChunkLoadedCore);
SubscribeLocalEvent<LoadedChunkComponent, ComponentShutdown>(OnChunkUnloadedCore);
SubscribeLocalEvent<WorldChunkComponent, ComponentShutdown>(OnChunkShutdown);
}
/// <summary>
/// Handles deleting chunks properly.
/// </summary>
private void OnChunkShutdown(EntityUid uid, WorldChunkComponent component, ComponentShutdown args)
{
if (!TryComp<WorldControllerComponent>(component.Map, out var controller))
return;
if (HasComp<LoadedChunkComponent>(uid))
{
var ev = new WorldChunkUnloadedEvent(uid, component.Coordinates);
RaiseLocalEvent(component.Map, ref ev);
RaiseLocalEvent(uid, ref ev, broadcast: true);
}
controller.Chunks.Remove(component.Coordinates);
}
/// <summary>
/// Handles the inner logic of loading a chunk, i.e. events.
/// </summary>
private void OnChunkLoadedCore(EntityUid uid, LoadedChunkComponent component, ComponentStartup args)
{
if (!TryComp<WorldChunkComponent>(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}");
}
/// <summary>
/// Handles the inner logic of unloading a chunk, i.e. events.
/// </summary>
private void OnChunkUnloadedCore(EntityUid uid, LoadedChunkComponent component, ComponentShutdown args)
{
if (!TryComp<WorldChunkComponent>(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}");
}
/// <inheritdoc />
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<EntityUid, Dictionary<Vector2i, List<EntityUid>>>();
var controllerEnum = EntityQueryEnumerator<WorldControllerComponent>();
while (controllerEnum.MoveNext(out var uid, out _))
{
chunksToLoad[uid] = new Dictionary<Vector2i, List<EntityUid>>();
}
if (chunksToLoad.Count == 0)
return; // Just bail early.
var loaderEnum = EntityQueryEnumerator<WorldLoaderComponent, TransformComponent>();
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<EntityUid>(4);
set[chunk.Value].Add(uid);
}
}
var mindEnum = EntityQueryEnumerator<MindComponent, TransformComponent>();
var ghostQuery = GetEntityQuery<GhostComponent>();
// 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<EntityUid>(4);
set[chunk.Value].Add(uid);
}
}
var loadedEnum = EntityQueryEnumerator<LoadedChunkComponent, WorldChunkComponent>();
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<LoadedChunkComponent>(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<LoadedChunkComponent>();
var controllerQuery = GetEntityQuery<WorldControllerComponent>();
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<LoadedChunkComponent>(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.");
}
}
/// <summary>
/// Attempts to get a chunk, creating it if it doesn't exist.
/// </summary>
/// <param name="chunk">Chunk coordinates to get the chunk entity for.</param>
/// <param name="map">Map the chunk is in.</param>
/// <param name="controller">The controller this chunk belongs to.</param>
/// <returns>A chunk, if available.</returns>
[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);
}
/// <summary>
/// Constructs a new chunk entity, attaching it to the map.
/// </summary>
/// <param name="chunkCoords">The coordinates the new chunk should be initialized for.</param>
/// <param name="map"></param>
/// <param name="controller"></param>
/// <returns></returns>
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<WorldChunkComponent>(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);
}
}
/// <summary>
/// A directed event fired when a chunk is initially set up in the world. The chunk is not loaded at this point.
/// </summary>
[ByRefEvent]
[PublicAPI]
public readonly record struct WorldChunkAddedEvent(EntityUid Chunk, Vector2i Coords);
/// <summary>
/// A directed event fired when a chunk is loaded into the world, i.e. a player or other world loader has entered vicinity.
/// </summary>
[ByRefEvent]
[PublicAPI]
public readonly record struct WorldChunkLoadedEvent(EntityUid Chunk, Vector2i Coords);
/// <summary>
/// A directed event fired when a chunk is unloaded from the world, i.e. no world loaders remain nearby.
/// </summary>
[ByRefEvent]
[PublicAPI]
public readonly record struct WorldChunkUnloadedEvent(EntityUid Chunk, Vector2i Coords);

View File

@@ -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;
/// <summary>
/// This handles configuring world generation during round start.
/// </summary>
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!;
/// <inheritdoc />
public override void Initialize()
{
SubscribeLocalEvent<RoundStartingEvent>(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<WorldgenConfigPrototype>(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"));
}
/// <summary>
/// Applies the world config to the default map if enabled.
/// </summary>
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<WorldgenConfigPrototype>(_worldgenConfig);
cfg.Apply(target, _ser, EntityManager); // Apply the config to the map.
DebugTools.Assert(HasComp<WorldControllerComponent>(target));
}
}

View File

@@ -0,0 +1,96 @@
using System.Linq;
using Content.Shared.Storage;
using Robust.Shared.Random;
namespace Content.Server.Worldgen.Tools;
/// <summary>
/// A faster version of EntitySpawnCollection that requires caching to work.
/// </summary>
public sealed class EntitySpawnCollectionCache
{
[ViewVariables] private readonly Dictionary<string, OrGroup> _orGroups = new();
public EntitySpawnCollectionCache(IEnumerable<EntitySpawnEntry> 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;
}
}
/// <summary>
/// Using a collection of entity spawn entries, picks a random list of entity prototypes to spawn from that collection.
/// </summary>
/// <remarks>
/// 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)
/// </remarks>
/// <param name="random">Resolve param.</param>
/// <param name="spawned">List that spawned entities are inserted into.</param>
/// <returns>A list of entity prototypes that should be spawned.</returns>
/// <remarks>This is primarily useful if you're calling it many times over, as it lets you reuse the list repeatedly.</remarks>
public void GetSpawns(IRobustRandom random, ref List<string?> 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<EntitySpawnEntry> Entries { get; } = new();
[ViewVariables] public float CumulativeProbability { get; set; }
}
}

View File

@@ -0,0 +1,243 @@
using System.Diagnostics.CodeAnalysis;
using Robust.Shared.Random;
using Robust.Shared.Utility;
namespace Content.Server.Worldgen.Tools;
/// <summary>
/// An implementation of Poisson Disk Sampling, for evenly spreading points across a given area.
/// </summary>
public sealed class PoissonDiskSampler
{
public const int DefaultPointsPerIteration = 30;
[Dependency] private readonly IRobustRandom _random = default!;
/// <summary>
/// Samples for points within the given circle.
/// </summary>
/// <param name="center">Center of the sample</param>
/// <param name="radius">Radius of the sample</param>
/// <param name="minimumDistance">Minimum distance between points. Must be above 0!</param>
/// <param name="pointsPerIteration">The number of points placed per iteration of the algorithm</param>
/// <returns>An enumerator of points</returns>
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);
}
/// <summary>
/// Samples for points within the given rectangle.
/// </summary>
/// <param name="topLeft">The top left of the rectangle</param>
/// <param name="lowerRight">The bottom right of the rectangle</param>
/// <param name="minimumDistance">Minimum distance between points. Must be above 0!</param>
/// <param name="pointsPerIteration">The number of points placed per iteration of the algorithm</param>
/// <returns>An enumerator of points</returns>
public SampleEnumerator SampleRectangle(Vector2 topLeft, Vector2 lowerRight, float minimumDistance,
int pointsPerIteration = DefaultPointsPerIteration)
{
return Sample(topLeft, lowerRight, null, minimumDistance, pointsPerIteration);
}
/// <summary>
/// Samples for points within the given rectangle, with an optional rejection distance.
/// </summary>
/// <param name="topLeft">The top left of the rectangle</param>
/// <param name="lowerRight">The bottom right of the rectangle</param>
/// <param name="rejectionDistance">The distance at which points will be discarded, if any</param>
/// <param name="minimumDistance">Minimum distance between points. Must be above 0!</param>
/// <param name="pointsPerIteration">The number of points placed per iteration of the algorithm</param>
/// <returns>An enumerator of points</returns>
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<Vector2>()
};
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<Vector2> 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;
}
}

View File

@@ -0,0 +1,71 @@
using System.Diagnostics.Contracts;
namespace Content.Server.Worldgen;
/// <summary>
/// Contains a few world-generation related constants and static functions.
/// </summary>
public static class WorldGen
{
/// <summary>
/// The size of each chunk (isn't that self-explanatory.)
/// Be careful about how small you make this.
/// </summary>
public const int ChunkSize = 128;
/// <summary>
/// Converts world coordinates to chunk coordinates.
/// </summary>
/// <param name="inp">World coordinates</param>
/// <returns>Chunk coordinates</returns>
[Pure]
public static Vector2i WorldToChunkCoords(Vector2i inp)
{
return ((Vector2) inp * (1.0f / ChunkSize, 1.0f / ChunkSize)).Floored();
}
/// <summary>
/// Converts world coordinates to chunk coordinates.
/// </summary>
/// <param name="inp">World coordinates</param>
/// <returns>Chunk coordinates</returns>
[Pure]
public static Vector2 WorldToChunkCoords(Vector2 inp)
{
return inp * (1.0f / ChunkSize, 1.0f / ChunkSize);
}
/// <summary>
/// Converts chunk coordinates to world coordinates.
/// </summary>
/// <param name="inp">Chunk coordinates</param>
/// <returns>World coordinates</returns>
[Pure]
public static Vector2 ChunkToWorldCoords(Vector2i inp)
{
return inp * ChunkSize;
}
/// <summary>
/// Converts chunk coordinates to world coordinates.
/// </summary>
/// <param name="inp">Chunk coordinates</param>
/// <returns>World coordinates</returns>
[Pure]
public static Vector2 ChunkToWorldCoords(Vector2 inp)
{
return inp * ChunkSize;
}
/// <summary>
/// Converts chunk coordinates to world coordinates, getting the center of the chunk.
/// </summary>
/// <param name="inp">Chunk coordinates</param>
/// <returns>World coordinates</returns>
[Pure]
public static Vector2 ChunkToWorldCoordsCentered(Vector2i inp)
{
return inp * ChunkSize + Vector2i.One * (ChunkSize / 2);
}
}

View File

@@ -1589,5 +1589,26 @@ namespace Content.Shared.CCVar
/// </summary>
public static readonly CVarDef<bool> ConfigPresetDebug =
CVarDef.Create("config.preset_debug", true, CVar.SERVERONLY);
/*
* World Generation
*/
/// <summary>
/// Whether or not world generation is enabled.
/// </summary>
public static readonly CVarDef<bool> WorldgenEnabled =
CVarDef.Create("worldgen.enabled", false, CVar.SERVERONLY);
/// <summary>
/// The worldgen config to use.
/// </summary>
public static readonly CVarDef<string> WorldgenConfig =
CVarDef.Create("worldgen.worldgen_config", "Default", CVar.SERVERONLY);
/// <summary>
/// The maximum amount of time the entity GC can process, in ms.
/// </summary>
public static readonly CVarDef<int> GCMaximumTimeMs =
CVarDef.Create("entgc.maximum_time_ms", 5, CVar.SERVERONLY);
}
}

View File

@@ -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!

View File

@@ -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 <mapid> <prototype>
cmd-applyworldgenconfig-prototype = worldgen config prototype
cmd-applyworldgenconfig-success = Config applied successfully. Do not rerun this command on this map.

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,6 @@
- type: entity
id: BaseDebris
abstract: true
components:
- type: OwnedDebris
- type: LocalityLoader

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,4 @@
- type: gcQueue
id: SpaceDebris
depth: 512 # So there's a decent bit of time before roids unload.
minDepthToProcess: 256

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -0,0 +1,9 @@
- type: worldgenConfig
id: Default
components:
- type: WorldController
- type: BiomeSelection
biomes:
- AsteroidsFallback
- Failsafe
- AsteroidsStandard