Code/Spawners/DetailsSpawner.cs
using System;
using System.Collections.Generic;
using System.Linq;
using ApexWorld;
using Sandbox.Mask;
using Sandbox.Spawns;
namespace Sandbox.Spawners;
public class DetailsSpawner : BaseSpawner
{
// ── Settings ────────────────────────────────────────────────────────────
[Property, Group( "Spawn Rules" )]
public bool ShowGizmos { get; set; } = true;
// Spawner-level masks applied globally before any rule mask
[Property, Group( "Spawner Masks" )]
[Editor( "MaskListPropertyEditor" )]
public List<MaskModifier> SpawnerMasks { get; set; } = new();
// Individual spawn rules
[Property, Group( "Spawn Rules" )]
public List<SpawnRule> SpawnRules { get; set; } = new();
// ── Spawn Root ──────────────────────────────────────────────────────────
/// <summary>
/// Hierarchy: DetailsSpawner -> SpawnRoot -> spawned objects.
/// Clearing is simply destroying SpawnRoot's children.
/// </summary>
private GameObject SpawnRoot => GetOrCreateSpawnRoot();
private GameObject GetOrCreateSpawnRoot()
{
foreach ( var child in GameObject.Children )
if ( child.Name == "SpawnRoot" )
return child;
var root = Scene.CreateObject();
root.Name = "SpawnRoot";
root.Parent = GameObject;
return root;
}
// ── BaseSpawner overrides ───────────────────────────────────────────────
public override void OnBeforeGenerate()
{
_isFinished = false;
ClearSpawns();
base.OnBeforeGenerate();
}
public override void Generate()
{
base.Generate();
GenerateDetails();
}
// ── Public buttons ──────────────────────────────────────────────────────
[Button]
public void GenerateDetails()
{
ClearSpawns();
var bounds = GenerateSpawnerBounds();
var terrains = GetTerrainsInBounds( bounds );
if ( terrains == null || terrains.Count == 0 )
{
Log.Warning( $"{this}: no terrains in bounds" );
_isFinished = true;
return;
}
foreach ( var terrain in terrains )
ProcessTerrain( terrain, bounds );
_isFinished = true;
Log.Info( $"{this}: generation done" );
}
[Button]
public void ClearSpawns()
{
foreach ( var child in SpawnRoot.Children.ToList() )
child.Destroy();
}
// ── Core ────────────────────────────────────────────────────────────────
private void ProcessTerrain( Terrain terrain, BBox bounds )
{
if ( terrain?.Storage == null ) return;
// Terrain-local top-left corner of the spawner bounds = WorldOffset for masks
var terrainLocalMins = terrain.WorldTransform.PointToLocal( bounds.Mins );
var worldOffset = new Vector2( terrainLocalMins.x, terrainLocalMins.y );
float worldSize = Range;
// Build spawner-level fitness mask (1.0 everywhere if no masks set)
var spawnerMask = SpawnUtils.BuildCombinedMask( SpawnerMasks, terrain, this );//BuildMask( SpawnerMasks, terrain, worldOffset, worldSize );
foreach ( var rule in SpawnRules )
{
if ( !rule.Enabled) continue;
ProcessRule( rule, terrain, bounds, spawnerMask, worldOffset, worldSize );
}
}
private void ProcessRule(
SpawnRule rule,
Terrain terrain,
BBox bounds,
MaskField spawnerMask,
Vector2 worldOffset,
float worldSize )
{
var ruleMask = rule.GenerateMask( terrain, MaskResolution, worldOffset, worldSize );
var fitness = spawnerMask.Multiply( ruleMask );
var rng = new Random( HashCode.Combine( SpawnerName, rule.RuleName ) );
var placed = new List<Vector3>();
// One batch list per model — supports mixed clutter models within one rule
var clutterBatches = new Dictionary<Model, List<Transform>>();
float step = rule.LocationIncrement;
float jitterAmt = step * (rule.Jitter / 100f);
for ( float wy = bounds.Mins.y; wy <= bounds.Maxs.y; wy += step )
{
for ( float wx = bounds.Mins.x; wx <= bounds.Maxs.x; wx += step )
{
if ( wx < 0 || wx > terrain.Storage.TerrainSize ||
wy < 0 || wy > terrain.Storage.TerrainSize )
continue;
float jx = ((float)rng.NextDouble() * 2f - 1f) * jitterAmt;
float jy = ((float)rng.NextDouble() * 2f - 1f) * jitterAmt;
var pos = new Vector3( wx + jx, wy + jy, 0f );
if ( pos.x < bounds.Mins.x || pos.x > bounds.Maxs.x ||
pos.y < bounds.Mins.y || pos.y > bounds.Maxs.y )
continue;
float fit = SpawnUtils.SampleMaskAt( fitness, terrain, pos );
if ( fit < rule.MinFitness ) continue;
if ( (float)rng.NextDouble() * 100f > rule.SpawnProbabilityRate ) continue;
if ( rule.SelfCollisionCheck && HasCollision( placed, pos, rule.BoundRadius ) )
continue;
var entry = rule.Definition.PickEntry( rng );
if ( entry == null ) continue;
float h = SpawnUtils.GetTerrainHeightAt( terrain, pos );
float yOffset = MathX.Lerp( rule.Definition.MinYOffset, rule.Definition.MaxYOffset, (float)rng.NextDouble() );
var spawnPos = new Vector3( pos.x, pos.y, h + yOffset );
var rot = SpawnUtils.GetSpawnRotation( rule.Definition, terrain, spawnPos, rng );
if ( entry.ObjectType == SpawnObjectType.Clutter )
{
if ( entry.ClutterModel == null ) continue;
float clutterScale = SpawnUtils.GetClutterScale( rule.Definition, fit, rng );
if ( !clutterBatches.TryGetValue( entry.ClutterModel, out var batch ) )
{
batch = new List<Transform>();
clutterBatches[entry.ClutterModel] = batch;
}
batch.Add( new Transform( spawnPos, rot, clutterScale ) );
}
else
{
if ( entry.Prefab == null ) continue;
var goScale = SpawnUtils.GetSpawnScale( rule.Definition, fit, rng );
var go = entry.Prefab.Clone();
go.Parent = SpawnRoot;
go.WorldPosition = spawnPos;
go.WorldRotation = rot;
go.WorldScale = goScale;
go.Enabled = true;
}
placed.Add( pos );
}
}
foreach ( var (model, transforms) in clutterBatches )
{
if ( transforms.Count == 0 ) continue;
var clutterObject = Scene.CreateObject();
clutterObject.Name = $"Clutter_{rule.RuleName}_{model.ResourceName}";
clutterObject.Parent = SpawnRoot;
var clutter = clutterObject.Components.Create<ApexClutterComponent>();
clutter.Model = model;
clutter.BuildFromTransforms( transforms );
}
}
// ── Helpers ─────────────────────────────────────────────────────────────
private MaskField BuildMask( List<MaskModifier> masks, Terrain terrain, Vector2 worldOffset, float worldSize )
{
var field = new MaskField( MaskResolution, worldSize ) { WorldOffset = worldOffset };
field.Fill( 1f );
foreach ( var mask in masks )
{
mask.Terrain = terrain;
var temp = new MaskField( MaskResolution, worldSize ) { WorldOffset = worldOffset };
temp.Fill( 1f );
mask.Apply( temp );
field = field.Multiply( temp );
}
return field;
}
private static bool HasCollision( List<Vector3> placed, Vector3 pos, float radius )
{
float minDist = radius * 2f;
foreach ( var p in placed )
{
float dx = p.x - pos.x;
float dy = p.y - pos.y;
if ( dx * dx + dy * dy < minDist * minDist )
return true;
}
return false;
}
private const int GizmoDensityStep = 12;
private const float GizmoSphereSize = 13f;
private const float GizmoMinValue = 0.01f;
private MaskField _cachedmaskField;
private BBox bounds;
private Terrain cachedTerrain;
[Button]
public void Snapshot()
{
bounds = GenerateSpawnerBounds();
var terrains = GetTerrainsInBounds( bounds );
if ( terrains == null || terrains.Count == 0 )
return;
cachedTerrain = terrains[0];
// Terrain-local top-left corner of the spawner bounds = WorldOffset for masks
var terrainLocalMins = cachedTerrain.WorldTransform.PointToLocal( bounds.Mins );
var worldOffset = new Vector2( terrainLocalMins.x, terrainLocalMins.y );
float worldSize = Range;
// Build spawner-level fitness mask (1.0 everywhere if no masks set)
_cachedmaskField = BuildMask( SpawnerMasks, cachedTerrain, worldOffset, worldSize );
Log.Info( $"{this}: snapshot taken for gizmo visualization" );
}
protected override void DrawGizmos()
{
if (ShowGizmos == false || !Game.IsEditor || cachedTerrain == null || _cachedmaskField == null )
return;
if ( SpawnerMasks == null || SpawnerMasks.Count == 0 )
return;
int resolution = MaskResolution;
var storage = cachedTerrain.Storage;
// Skip samples for performance
for ( int x = 0; x < resolution; x += GizmoDensityStep )
{
for ( int y = 0; y < resolution; y += GizmoDensityStep )
{
float value = _cachedmaskField.Get(
resolution - 1 - x,
y
);
// Ignore empty mask values
if ( value <= GizmoMinValue )
continue;
float u = ((resolution - 1 - x) + 0.5f) / resolution;
float v = (y + 0.5f) / resolution;
float worldX = _cachedmaskField.WorldOffset.x + (u * _cachedmaskField.WorldSize);
float worldY = _cachedmaskField.WorldOffset.y + (v * _cachedmaskField.WorldSize);
// Skip if outside terrain bounds
if ( worldX < 0 || worldX > storage.TerrainSize ||
worldY < 0 || worldY > storage.TerrainSize )
continue;
var worldPos = new Vector3(
worldX - WorldPosition.x,
worldY - WorldPosition.y,
0f
);
var heightSamplePos = new Vector3(
worldX,
worldY,
0f
);
worldPos.z = SpawnUtils.GetTerrainHeightAt(
cachedTerrain,
heightSamplePos
);
worldPos.z += 100;
// Black -> White based on mask value
Gizmo.Draw.Color = Color.Lerp(
Color.Black,
Color.White,
value
);
Gizmo.Draw.SolidSphere(
worldPos,
GizmoSphereSize
);
}
}
}
}