#region Copyright & License Information /* * Copyright (c) The OpenRA Developers and Contributors * This file is part of OpenRA, which is free software. It is made * available to you under the terms of the GNU General Public License * as published by the Free Software Foundation, either version 3 of * the License, or (at your option) any later version. For more * information, see COPYING. */ #endregion using System; using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; using OpenRA.Mods.Common.Terrain; using OpenRA.Mods.Common.Traits; using OpenRA.Primitives; using OpenRA.Support; using static OpenRA.Mods.Common.Traits.ResourceLayerInfo; namespace OpenRA.Mods.Common.MapGenerator { /// Collection of high-level map generation utilities. public class Terraformer { /// Common denominator for fractional arguments. public const int FractionMax = 1000; /// Biases or excludes resources at a location during resource planning. public sealed class ResourceBias { /// The location of the bias. public WPos WPos; /// Resources will not be placed within this distance of the actor. public WDist? ExclusionRadius = null; /// Resources will be biased within this radius. public WDist? BiasRadius = null; /// /// Biasing function, applied either to all resources or the specific ResourceType. /// Maps the original value and the squared-WDist-from-CPos to a new value. /// public Func Bias = null; /// If non-null, encourages resources to become this type. public ResourceTypeInfo ResourceType = null; /// Create a bias at a location. public ResourceBias(WPos wpos) { WPos = wpos; } /// Create a bias at an actor's location. public ResourceBias(ActorPlan actorPlan) : this(actorPlan.WPosCenterLocation) { } } /// /// Metadata for the values in a CellLayer matching an ID. /// public sealed class Region { public const int NullId = -1; /// Region ID. public int Id; /// Area of the region. public int Area; } public sealed class PathPartitionZone { public bool ShouldTile = true; public bool RequiredSomewhere = false; public string SegmentType = null; public int MinimumLength = 1; public int MaximumDeviation = 0; } public enum Side : sbyte { Out = -1, None = 0, In = 1, } public static (T[] Types, U[] Weights) SplitDictionary(IReadOnlyDictionary typeWeights) { var types = typeWeights .Select(kv => kv.Key) .Order() .ToArray(); var weights = types .Select(type => typeWeights[type]) .ToArray(); return (types, weights); } public readonly MapGenerationArgs MapGenerationArgs; public readonly Map Map; public readonly ModData ModData; public readonly List ActorPlans; public readonly Symmetry.WMirror WMirror; public readonly int Rotations; readonly ITerrainInfo terrainInfo; // Will be null if terrainInfo isn't a ITemplatedTerrainInfo. Some methods assume that the // terrainInfo is an ITemplatedTerrainInfo. readonly ITemplatedTerrainInfo templatedTerrainInfo; readonly Lazy> lazyProjectionSpacing; public Terraformer( MapGenerationArgs mapGenerationArgs, Map map, ModData modData, List actorPlans, Symmetry.Mirror mirror, int rotations) { MapGenerationArgs = mapGenerationArgs; Map = map; ModData = modData; ActorPlans = actorPlans; WMirror = new Symmetry.WMirror(mirror, map.Grid.Type); Rotations = rotations; terrainInfo = modData.DefaultTerrainInfo[map.Tileset]; templatedTerrainInfo = terrainInfo as ITemplatedTerrainInfo; lazyProjectionSpacing = new(ProjectionSpacing); } public void CheckHasMapShapeOrNull(CellLayer layer) { if (layer != null) CheckHasMapShape(layer); } public void CheckHasMapShapeOrNull(Matrix layer) { if (layer != null) CheckHasMapShape(layer); } public void CheckHasMapShape(CellLayer layer) { if (!CellLayerUtils.AreSameShape(layer, Map.Tiles)) throw new ArgumentException("CellLayer has different shape to map"); } public void CheckHasMapShape(Matrix matrix) { var cellBounds = CellLayerUtils.CellBounds(Map); var size = cellBounds.Size.ToInt2(); if (matrix.Size != size) throw new ArgumentException("Matrix has different shape to map"); } /// /// Enumerates through all current ActorPlans of the given type. /// public IEnumerable ActorsOfType(string type) { return ActorPlans.Where(a => a.Reference.Type == type); } /// Perform some basic initialization of a map. public void InitMap() { var maxTerrainHeight = Map.Grid.MaximumTerrainHeight; var tl = new PPos(1, 1 + maxTerrainHeight); var br = new PPos(Map.MapSize.Width - 2, Map.MapSize.Height - maxTerrainHeight - 2); Map.SetBounds(tl, br); Map.Title = MapGenerationArgs.Title; Map.Author = MapGenerationArgs.Author; Map.RequiresMod = ModData.Manifest.Id; } /// /// Commits draft data to the map, such as player and actor definitions. /// This may trigger some initialization of map data structures that could become invalid /// if further edits to the map are made. /// public void BakeMap() { var playerCount = ActorsOfType("mpspawn").Count(); Map.PlayerDefinitions = new MapPlayers(Map.Rules, playerCount).ToMiniYaml(); // Return true iff any of the actors projected footprint satisfies Map.Contains(PPos). // Note that this is not the same as Map.Tiles.Contains or Map.Bounds.Contains. // Note that calling this initializes cell projections. bool HasProjectedFootprintInMap(ActorPlan plan) { return plan.Footprint() .SelectMany(f => Map.ProjectedCellsCovering(f.Key.ToMPos(Map))) .Any(Map.Contains); } Map.ActorDefinitions = ActorPlans .Where(HasProjectedFootprintInMap) .Select((plan, i) => new MiniYamlNode($"Actor{i}", plan.Reference.Save())) .ToImmutableArray(); } /// /// Return a new CellLayer produced by aggregating projected cells from an input CellLayer. /// The input does not need to have the same shape as the map. /// public CellLayer ImproveSymmetry( CellLayer layer, T outsideValue, Func aggregator) { var newLayer = new CellLayer(layer.GridType, layer.Size); Symmetry.RotateAndMirrorOverCPos( layer, Rotations, WMirror, (sources, destination) => newLayer[destination] = sources .Select(source => layer.TryGetValue(source, out var value) ? value : outsideValue) .Aggregate(aggregator)); return newLayer; } /// /// Subtract an actor's footprint from zoneable. Optionally, a circle with a given dezone /// radius from the actor center can also be subtracted from zoneable. /// public void DezoneActor( ActorPlan actorPlan, CellLayer zoneable, WDist? dezoneRadius = null) { CheckHasMapShape(zoneable); foreach (var (cpos, _) in actorPlan.Footprint()) if (zoneable.Contains(cpos)) zoneable[cpos] = false; if (dezoneRadius.HasValue) { CellLayerUtils.OverCircle( cellLayer: zoneable, wCenter: actorPlan.WPosCenterLocation, wRadius: dezoneRadius.Value, outside: false, action: (mpos, _, _, _) => zoneable[mpos] = false); } } /// Sets all zoneable cells where the map has actor footprints to false. public void ZoneFromActors(CellLayer zoneable, T value) { foreach (var actorPlan in ActorPlans) foreach (var (cpos, _) in actorPlan.Footprint()) if (zoneable.Contains(cpos)) zoneable[cpos] = value; } /// Sets all zoneable cells where the map has resources to false. public void ZoneFromResources(CellLayer zoneable, T value) { CheckHasMapShape(zoneable); foreach (var mpos in Map.AllCells.MapCoords) if (Map.Resources[mpos].Type != 0) zoneable[mpos] = value; } /// /// Zone based on Map.Bounds.Contains. This is stricter than Map.Contains, ignoring height. /// public void ZoneFromOutOfBounds(CellLayer zoneable, T value) { foreach (var mpos in Map.AllCells.MapCoords) if (!Map.Bounds.Contains(mpos.U, mpos.V)) zoneable[mpos] = value; } /// /// Returns a CellLayer describing whether the space in a map satisfies given terrain types /// (if allowedTerrain is non-null), is free of actors, and/or is free of resources. /// public CellLayer CheckSpace( IReadOnlySet allowedTerrain, bool checkActors = false, bool checkResources = false, bool checkBounds = false) { var space = new CellLayer(Map); if (allowedTerrain != null) { foreach (var mpos in Map.AllCells.MapCoords) space[mpos] = allowedTerrain.Contains(terrainInfo.GetTerrainIndex(Map.Tiles[mpos])); } else { space.Clear(true); } if (checkActors) ZoneFromActors(space, false); if (checkResources) ZoneFromResources(space, false); if (checkBounds) ZoneFromOutOfBounds(space, false); return space; } /// /// Returns a CellLayer describing whether the space in a map has the given tile type and /// is free of actors and/or resources. /// public CellLayer CheckSpace( ushort requiredTile, bool checkActors = false, bool checkResources = false, bool checkBounds = false) { var space = new CellLayer(Map); foreach (var mpos in Map.AllCells.MapCoords) space[mpos] = Map.Tiles[mpos].Type == requiredTile; if (checkActors) ZoneFromActors(space, false); if (checkResources) ZoneFromResources(space, false); if (checkBounds) ZoneFromOutOfBounds(space, false); return space; } /// /// Shrink zoneable areas by a given thickness in cells. Zones will be shrunk even if they /// border the edge of the map. /// public CellLayer ErodeZones(CellLayer zoneable, int amount) { CheckHasMapShape(zoneable); var roominess = new CellLayer(Map); CellLayerUtils.ChebyshevRoom(roominess, zoneable, false); return CellLayerUtils.Map(roominess, r => r > amount); } /// /// Derives a CellLayer identifying the space in a map available for various actors, /// resources, decorations, etc. A mask (usually playable space) can be used to further /// limit the zoneable area. /// public CellLayer GetZoneable( IReadOnlySet zoneableTerrain, CellLayer mask = null) { CheckHasMapShapeOrNull(mask); var zoneable = CheckSpace(zoneableTerrain, true, true, true); if (mask != null) zoneable = CellLayerUtils.Intersect([zoneable, mask]); if (Rotations > 1 || WMirror.HasMirror) { // Reserve the center of the map - otherwise it will mess with symmetries CellLayerUtils.OverCircle( cellLayer: zoneable, wCenter: CellLayerUtils.Center(Map), wRadius: new WDist(1024), outside: false, action: (mpos, _, _, _) => zoneable[mpos] = false); } zoneable = ImproveSymmetry(zoneable, false, (a, b) => a && b); return zoneable; } /// Create map-shaped CellLayer preinitialized with a circle. public CellLayer CenteredCircle(T inside, T outside, WDist radius) { var circle = new CellLayer(Map); circle.Clear(outside); CellLayerUtils.OverCircle( cellLayer: circle, wCenter: CellLayerUtils.Center(Map), wRadius: radius, outside: false, action: (mpos, _, _, _) => circle[mpos] = inside); return circle; } /// /// Return a CellLayer where each cell is half the minimum distances to one of its symmetry /// projections. Can be used to avoid placing actors too close to their own projections. /// public CellLayer ProjectionSpacing() { var projectionSpacing = new CellLayer(Map); Symmetry.RotateAndMirrorOverCPos( projectionSpacing, Rotations, WMirror, (projections, cpos) => projectionSpacing[cpos] = Symmetry.ProjectionProximity(projections) / 2); return projectionSpacing; } /// /// Produce a cell layer which identifies assymetries in the map. /// Cells that are considered recessive but that have dominant projections are marked as /// true in the resulting CellLayer. /// /// Cells matching these terrain types are consided dominant. /// If true, cells covered by actors are considered dominant. /// /// Also mark as true any cells where the terrain types don't match with projections, even /// if they are also all recessive. /// public CellLayer FindAsymmetries( IReadOnlySet dominantTerrain, bool dominantActors, bool strictTerrainTypes) { var terrainTypes = CellLayerUtils.Create(Map, (MPos mpos) => terrainInfo.GetTerrainIndex(Map.Tiles[mpos])); var dominant = CellLayerUtils.Map(terrainTypes, dominantTerrain.Contains); if (dominantActors) ZoneFromActors(dominant, true); var incompatibilities = new CellLayer(Map); Symmetry.RotateAndMirrorOverCPos( incompatibilities, Rotations, WMirror, (sources, destination) => { if (!dominant[destination]) incompatibilities[destination] = sources .Where(incompatibilities.Contains) .Any(source => dominant[source] || (strictTerrainTypes && terrainTypes[destination] != terrainTypes[source])); }); return incompatibilities; } /// /// Given a space CellLayer, identifies the separate true regions. Cells are part of the /// same region if they are connected by an offset in spread. /// public (Region[] Regions, CellLayer RegionMap) FindRegions( CellLayer space, ImmutableArray spread) { CheckHasMapShape(space); var regions = new List(); var regionMap = new CellLayer(Map); regionMap.Clear(Region.NullId); void Fill(Region region, CPos start) { bool? Filler(CPos cpos, bool _) { var mpos = cpos.ToMPos(Map); if (regionMap[mpos] == Region.NullId && space[mpos]) { regionMap[mpos] = region.Id; region.Area++; return true; } return null; } CellLayerUtils.FloodFill( space, [(start, true)], Filler, spread); } foreach (var mpos in Map.AllCells.MapCoords) if (regionMap[mpos] == Region.NullId && space[mpos]) { var region = new Region() { Id = regions.Count, Area = 0, }; regions.Add(region); var cpos = mpos.ToCPos(Map); Fill(region, cpos); } return (regions.ToArray(), regionMap); } /// /// Finds the largest, symmetrical, unpoisoned playable region on the map. /// Returns a CellLayer describing the playable region, or null if there is no suitable /// playable region. /// /// Whether given cells are playable. /// Any regions with a poisoned cell are disqualified. Can be null. public CellLayer ChoosePlayableRegion( CellLayer playable, CellLayer poison = null) { CheckHasMapShapeOrNull(poison); var (regions, regionMask) = FindRegions(playable, DirectionExts.Spread8CVec); var disqualifications = new HashSet(); if (poison != null) foreach (var mpos in Map.AllCells.MapCoords) if (poison[mpos] && regionMask[mpos] != Region.NullId && playable[mpos]) disqualifications.Add(regionMask[mpos]); // Disqualify regions that violate any symmetry requirements. { var symmetryScore = new int[regions.Length]; void TestSymmetry(CPos[] sources, CPos destination) { var id = regionMask[destination]; if (!playable[destination]) return; if (sources.All(source => regionMask.TryGetValue(source, out var sourceId) && sourceId == id)) symmetryScore[id]++; } Symmetry.RotateAndMirrorOverCPos( regionMask, Rotations, WMirror, TestSymmetry); for (var id = 0; id < symmetryScore.Length; id++) if (symmetryScore[id] < regions[id].Area / 2) disqualifications.Add(id); } Region largest = null; foreach (var region in regions) { if (disqualifications.Contains(region.Id)) continue; if (largest == null || region.Area > largest.Area) largest = region; } if (largest == null) return null; return CellLayerUtils.Create(Map, (MPos mpos) => regionMask[mpos] == largest.Id); } /// /// Generate a CellLayer containing scores for the preferability of spawn locations, based /// on separation from symmetry projections and the map center. Higher scores are better. /// /// /// Distance from the map center or symmetry lines inside of which spawns are biased away /// from. Measured as a fraction (out of 1024) of the map's smallest dimension. /// public CellLayer SpawnBias(int centralReservationFraction) { var minSpan = Math.Min(Map.MapSize.Width, Map.MapSize.Height); var projectionSpacing = lazyProjectionSpacing.Value; var spawnBias = new CellLayer(Map); var spawnBiasRadius = Math.Max(1, minSpan * centralReservationFraction / FractionMax); spawnBias.Clear(spawnBiasRadius); CellLayerUtils.OverCircle( cellLayer: spawnBias, wCenter: CellLayerUtils.Center(Map), wRadius: new WDist(1024 * spawnBiasRadius), outside: false, action: (mpos, _, _, wrSq) => spawnBias[mpos] = (int)Exts.ISqrt(wrSq) / 1024); foreach (var mpos in Map.AllCells.MapCoords) spawnBias[mpos] = Math.Min(spawnBias[mpos], projectionSpacing[mpos]); return spawnBias; } /// /// Finds a random suitable mpspawn location, biased away from symmetries and the map /// center. Returns null if nowhere is suitable. /// /// Random source for spawn placement. /// Mask of valid space for spawn (and other object) placement. /// /// Distance from the map center or symmetry lines inside of which spawns are biased away /// from. Measured as a fraction (out of 1024) of the map's smallest dimension. /// /// Minimum space required for a spawn. /// Maximum space used by a spawn, beyond which larger spaces are equally preferable. /// /// Space that spawns are expected to reserve in zoneable. Note that this function does not /// modify zoneable, but this is needed in order to avoid placing symmetry-projected spawns /// with overlapping zone allocations. /// public CPos? ChooseSpawnInZoneable( MersenneTwister random, CellLayer zoneable, int centralReservationFraction, int minimumRadius, int maximumRadius, int zoneRadius) { CheckHasMapShape(zoneable); var projectionSpacing = lazyProjectionSpacing.Value; var spawnBias = SpawnBias(centralReservationFraction); var spawnPreference = new CellLayer(Map); CellLayerUtils.ChebyshevRoom(spawnPreference, zoneable, false); foreach (var mpos in Map.AllCells.MapCoords) if (spawnPreference[mpos] >= minimumRadius && projectionSpacing[mpos] * 2 >= zoneRadius + minimumRadius) { spawnPreference[mpos] = spawnBias[mpos] * Math.Min(maximumRadius, spawnPreference[mpos]); } else { spawnPreference[mpos] = 0; } var (chosenMPos, chosenValue) = CellLayerUtils.FindRandomBest( spawnPreference, random, (a, b) => a.CompareTo(b)); if (chosenValue < 1) return null; return chosenMPos.ToCPos(Map.Grid.Type); } /// /// Find a random cell in zoneable with the most free space. Spaces which are maximumSpace /// or more away from unzoned cells are treated equally. /// Returns the CPos and space (up to maximumSpace) of the chosen cell. /// The space value will be negative if there are no zoned cells. /// public (CPos CPos, int Space) ChooseInZoneable( MersenneTwister random, CellLayer zoneable, int maximumSpace) { CheckHasMapShape(zoneable); var projectionSpacing = lazyProjectionSpacing.Value; var roominess = new CellLayer(Map); CellLayerUtils.ChebyshevRoom(roominess, zoneable, false); foreach (var mpos in Map.AllCells.MapCoords) roominess[mpos] = Math.Min( maximumSpace, Math.Min(roominess[mpos], projectionSpacing[mpos])); var (chosenMPos, chosenValue) = CellLayerUtils.FindRandomBest( roominess, random, (a, b) => a.CompareTo(b)); return (chosenMPos.ToCPos(Map.Grid.Type), chosenValue); } /// /// Generate a CellLayer scoring cells on how close to a target walking distance through /// walkable cells they are from the closest seed point. Higher scores are better. The /// score considers the distance needed to walk around unwalkable cells. Unsuitable cells /// will have a score of -int.MaxValue. /// /// Walkable cells. /// Unmasked cells will have a score of -int.MaxValue. Can be null. /// Points from which to measure walking distance. /// The highest scoring walking distance.. /// Distances greater than this are given a score of -int.MaxValue. public CellLayer TargetWalkingDistance( CellLayer walkable, CellLayer mask, IEnumerable seeds, WDist targetRange, WDist maximumRange) { CheckHasMapShape(walkable); CheckHasMapShapeOrNull(mask); var walkingDistances = new CellLayer(Map); CellLayerUtils.WalkingDistances( walkingDistances, walkable, seeds, maximumRange); var scores = new CellLayer(Map); foreach (var mpos in Map.AllCells.MapCoords) { var v = (mask?[mpos] ?? true) ? walkingDistances[mpos].Length : int.MaxValue; if (v == int.MaxValue) scores[mpos] = -int.MaxValue; else if (v <= targetRange.Length) scores[mpos] = (v + 1023) / 1024; else scores[mpos] = (2 * targetRange.Length - v + 1023) / 1024; } return scores; } /// /// Add an actor and its symmetry projections to the map and subtract its footprint from /// zoneable. Optionally, a circle with a given dezone radius from the actor center can /// also be subtracted from zoneable. /// public void ProjectPlaceDezoneActor( ActorPlan actorPlan, CellLayer zoneable = null, WDist? dezoneRadius = null) { CheckHasMapShapeOrNull(zoneable); var projections = Symmetry.RotateAndMirrorActorPlan( actorPlan, Rotations, WMirror); ActorPlans.AddRange(projections); if (zoneable != null) foreach (var projection in projections) DezoneActor(projection, zoneable, dezoneRadius); } /// /// Chooses a location for an actor within zoneable, and then projects, places, and dezones /// for it. (The zoneable CellLayer is modified.) /// /// True if an actor was placed, false if there was insufficient space. public bool AddActor( MersenneTwister random, CellLayer zoneable, string actorType, WDist? actorDezoneRadius = null) { var actorPlan = new ActorPlan(Map, actorType); var requiredSpace = actorPlan.MaxSpan() * 1024 / 1448 + 2; var (chosenCPos, chosenValue) = ChooseInZoneable( random, zoneable, requiredSpace); if (chosenValue < requiredSpace) return false; actorPlan.WPosCenterLocation = CellLayerUtils.CPosToWPos(chosenCPos, Map.Grid.Type); ProjectPlaceDezoneActor(actorPlan, zoneable, actorDezoneRadius); return true; } /// /// Given a CellLayer of weights/priorities, chooses locations for actors within zoneable, /// and then projects, places, and dezones for them. /// /// Random source for locations and actor type selection. /// Available space for actors. Modified if actors placed. /// Weights or priorities for placing an actor centered on cells. /// Actor types to choose from and their relative weights. /// Number of actors to attempt to place. /// If true, choose actor locations using probabilistic weights instead of best candidate. /// /// Dezone radius for placed actors (in addition to footprint). /// This does not affect spacing within the region. /// /// Number of actors added. 0 indicates none could be added. public int AddDistributedActors( MersenneTwister random, CellLayer zoneable, CellLayer distribution, IReadOnlyDictionary weightedActorTypes, int targetCount, bool weighted, WDist? actorDezoneRadius = null) { CheckHasMapShape(zoneable); CheckHasMapShape(distribution); var (actorTypes, actorTypeWeights) = SplitDictionary(weightedActorTypes); var clusterZoneable = CellLayerUtils.Clone(zoneable); for (var count = 0; count < targetCount; count++) { var actorType = actorTypes[random.PickWeighted(actorTypeWeights)]; var actorPlan = new ActorPlan(Map, actorType); var requiredSpace = actorPlan.MaxSpan() * 1024 / 1448 + 2; var roominess = new CellLayer(Map); CellLayerUtils.ChebyshevRoom(roominess, clusterZoneable, false); var filteredDistribution = CellLayerUtils.Create(Map, (MPos mpos) => roominess[mpos] >= requiredSpace ? distribution[mpos] : 0); MPos mpos; if (weighted) mpos = CellLayerUtils.PickWeighted(filteredDistribution, random); else (mpos, _) = CellLayerUtils.FindRandomBest(filteredDistribution, random, (a, b) => a.CompareTo(b)); if (filteredDistribution[mpos] == 0) return count; actorPlan.Location = mpos.ToCPos(Map.Grid.Type); CellLayerUtils.OverCircle( cellLayer: distribution, wCenter: actorPlan.WPosLocation, wRadius: new WDist(actorPlan.MaxSpan() * 1024), outside: false, action: (mpos, _, _, _) => distribution[mpos] = 0); ProjectPlaceDezoneActor(actorPlan, zoneable, actorDezoneRadius); DezoneActor(actorPlan, clusterZoneable); } return targetCount; } /// /// Chooses a location for a cluster of actors within zoneable, and then projects, places, /// and dezones for them. /// /// Random source for locations and actor type selection. /// Available space for actors. Modified if actors placed. /// Actor types to choose from and their relative weights. /// Number of actors to attempt to place. /// Avoid placing actors' centers within this radius unless it's a last resort. /// Minimum cluster radius for actor center placement. /// Maximum cluster radius for actor center placement. /// Zoneable spacing required beyond radius (that actors' centers will not be placed in). /// If true, choose actor locations using probabilistic weights instead of best candidate. /// /// Dezone radius for placed actors (in addition to footprint). /// This does not affect spacing within the cluster. /// /// /// Calculates location weights or candidate priorities based on distance from the cluster /// center. The input is the WDist.LengthSquared from the cluster center. Location choices /// are biased towards greater outputs. If null, defaults to a function where the weight is /// proportional to the squared distance, thus biasing actors towards the outside. /// /// Number of actors added. 0 indicates none could be added. public int AddActorCluster( MersenneTwister random, CellLayer zoneable, IReadOnlyDictionary weightedActorTypes, int targetCount, int innerReservation, int minimumRadius, int maximumRadius, int outerBorder, bool weighted, WDist? actorDezoneRadius = null, Func distributor = null) { CheckHasMapShape(zoneable); var (chosenCPos, room) = ChooseInZoneable( random, zoneable, maximumRadius + outerBorder); var radius2 = room - outerBorder - 1; if (radius2 < minimumRadius) return 0; if (radius2 > maximumRadius) radius2 = maximumRadius; var radius1 = Math.Min(innerReservation, radius2); if (radius1 < 1) return 0; var distribution = new CellLayer(Map); var wRadius1Sq = radius1 * radius1 * 1024L * 1024L; distributor ??= wrSq => (int)(wrSq / (1024 * 1024)); CellLayerUtils.OverCircle( cellLayer: distribution, wCenter: CellLayerUtils.CPosToWPos(chosenCPos, Map.Grid.Type), wRadius: new WDist(radius2 * 1024), outside: false, action: (mpos, _, _, wrSq) => distribution[mpos] = wrSq >= wRadius1Sq ? distributor(wrSq) : 0); return AddDistributedActors( random, zoneable, distribution, weightedActorTypes, targetCount, weighted, actorDezoneRadius); } /// /// For a 1x1 tile, return a TerrainTile with the given tile type, using a random index if /// it's a PickAny template. /// public TerrainTile PickTile(MersenneTwister random, ushort tileType) { if (templatedTerrainInfo.Templates.TryGetValue(tileType, out var template) && template.PickAny) return new TerrainTile(tileType, (byte)random.Next(0, template.TilesCount)); else return new TerrainTile(tileType, 0); } /// Wrapper around MultiBrush.PaintArea. public void PaintArea( MersenneTwister random, CellLayer replace, IReadOnlyList brushes, bool alwaysPreferLargerBrushes = false) { CheckHasMapShape(replace); MultiBrush.PaintArea( Map, ActorPlans, replace, brushes, random, alwaysPreferLargerBrushes); } /// /// Wrapper around PaintArea that uses Replacibility.Actor for masked cells. /// public void PaintActors( MersenneTwister random, CellLayer mask, IReadOnlyList brushes, bool alwaysPreferLargerBrushes = false) { CheckHasMapShape(mask); var replace = new CellLayer(Map); foreach (var mpos in Map.AllCells.MapCoords) replace[mpos] = mask[mpos] ? MultiBrush.Replaceability.Actor : MultiBrush.Replaceability.None; PaintArea( random, replace, brushes, alwaysPreferLargerBrushes); } /// Wrapper around MultiBrush.Paint for path tiling results. public void PaintTiling( MersenneTwister random, MultiBrush brush, short? heightOffset = null) { brush.Paint(Map, ActorPlans, CPos.Zero, heightOffset, MultiBrush.Replaceability.Any, random); } /// /// Repaint the areas occupied by given tile types using MultiBrushes. /// public void RepaintTiles( MersenneTwister random, IReadOnlyDictionary> rules) { foreach (var (tile, collection) in rules.OrderBy(kv => kv.Key)) { var replace = new CellLayer(Map); foreach (var mpos in Map.AllCells.MapCoords) replace[mpos] = Map.Tiles[mpos].Type == tile ? MultiBrush.Replaceability.Any : MultiBrush.Replaceability.None; MultiBrush.PaintArea(Map, ActorPlans, replace, collection, random); } } /// /// Creates a boolean fractal noise pattern obeying symmetry requirements. /// Random source /// Largest interval for fractal noise. /// Target fraction of true values (from 0 to FractionMax). /// /// The number of times to square root the noise wavelength to arrive at the amplitude. /// In other words, amplitude = wavelength ** (1 / (2 ** clumpiness)) /// Setting to 0 is equivalent to pink noise. /// /// public CellLayer BooleanNoise( MersenneTwister random, int noiseFeatureSize, int fraction, int clumpiness = 0) { var noise = new CellLayer(Map); NoiseUtils.SymmetricFractalNoiseIntoCellLayer( random, noise, Rotations, WMirror, noiseFeatureSize, wavelength => NoiseUtils.ClumpinessAmplitude(wavelength, clumpiness)); return CellLayerUtils.CalibratedBooleanThreshold( noise, fraction, FractionMax); } /// /// Create a matrix containing a generated terrain elevation map. /// /// Random source for terrain noise. /// Largest interval for fractal noise. /// Range in cells for smoothing. public Matrix ElevationNoiseMatrix( MersenneTwister random, int noiseFeatureSize, int smoothing) { var elevation = NoiseUtils.SymmetricFractalNoise( random, CellLayerUtils.CellBounds(Map).Size.ToInt2(), Rotations, WMirror.ForCPos(), noiseFeatureSize, NoiseUtils.PinkAmplitude); MatrixUtils.NormalizeRangeInPlace(elevation, 1024); if (smoothing > 0) elevation = MatrixUtils.BinomialBlur(elevation, smoothing); return elevation; } /// /// /// Produce an unbiased noise pattern for resource growth. /// /// The output noise will have the range [uniformity, uniformity + 1024]. /// /// public CellLayer ResourceNoise( MersenneTwister random, int noiseFeatureSize, int clumpiness, int uniformity) { var pattern = new CellLayer(Map); NoiseUtils.SymmetricFractalNoiseIntoCellLayer( random, pattern, Rotations, WMirror, noiseFeatureSize, wavelength => NoiseUtils.ClumpinessAmplitude(wavelength, clumpiness)); { CellLayerUtils.CalibrateQuantileInPlace( pattern, 0, 0, 1); var max = pattern.Max(); foreach (var mpos in Map.AllCells.MapCoords) pattern[mpos] = uniformity + 1024 * pattern[mpos] / max; } return pattern; } /// /// Given elevation noise, partition it into a boolean Matrix where false represents low /// elevation and true represents high elevation. /// /// Terrain elevation noise. /// /// A mask (usually a previous slice) within which the new slice is constrained to and /// derived from. Can be null to imply all space is available. /// /// Target fraction (out of FractionMax) of masked terrain to be carried over to the new slice. /// Minimum distance between the contours of the mask and the new slice. public Matrix SliceElevation( Matrix elevation, Matrix mask, int fraction, int minimumContourSpacing = 0) { CheckHasMapShape(elevation); CheckHasMapShapeOrNull(mask); if (mask == null) return MatrixUtils.CalibratedBooleanThreshold(elevation, fraction, FractionMax); var filteredElevation = elevation.Clone(); var roominess = MatrixUtils.ChebyshevRoom(mask, true); var available = 0; var total = filteredElevation.Data.Length; for (var n = 0; n < total; n++) { if (mask[n]) available++; else filteredElevation.Data[n] = int.MinValue; } var slice = MatrixUtils.CalibratedBooleanThreshold( filteredElevation, available * fraction / FractionMax, total); // Calibration isn't perfect. Make sure constraints are still met. var minimumRoom = minimumContourSpacing + 1; for (var n = 0; n < total; n++) slice.Data[n] &= roominess.Data[n] >= minimumRoom; return slice; } /// /// If given a looped path, normalizes it such that symmetry projected paths should have /// symmetry projected start/end points. Note that this method isn't meaningful for loops /// which would overlap with their symmetry projections. For non-looped paths, returns the /// input unchanged. /// public int2[] NormalizeLoopStart(int2[] path) { if (path.Length < 2) throw new ArgumentException("path is too short"); if (path[0] != path[^1]) return path; var gridType = Map.Grid.Type; var center = CellLayerUtils.Center(Map); var cpath = CellLayerUtils.FromMatrixPoints([path[0..^1]], Map.Tiles)[0]; var wpath = cpath .Select(cpos => CellLayerUtils.CornerToWPos(cpos, gridType)) .ToList(); // Choose the closest to the map center and makes it the start/end of the loop. // If there are ties, pick the first closest point that follows from the furthest // point(s), ensuring consistency for symmetries. var distances = wpath.ConvertAll(w => (w - center).LengthSquared); var closest = distances.Min(); var furthest = distances.Max(); var closestI = distances.IndexOf(furthest); while (distances[closestI] != closest) if (++closestI == distances.Count) closestI = 0; return path[closestI..^1].Concat(path[0..(closestI + 1)]).ToArray(); } /// /// Given a matrix-style path, divide it into a chain of smaller paths and convert them to /// TilingPaths with segment types that best match a matrix of zones. /// /// The path to be divided. /// The full set of zones, in order of preference for ties. /// /// Matrix which assigns zones to matching points in the path. /// Null values can be used to describe locations with no zoning preference. /// /// Segmented brushes for TilingPath creation. /// /// If greater than zero, sub-paths are only allowed to change over in straight sections /// and the starts/ends must be this number of points deep within a straight section. /// public List PartitionPath( int2[] path, IReadOnlyList allZones, Matrix zoneMask, IReadOnlyList brushes, int minimumStraight) { // Algorithmic Overview: // // First, find the straight-enough sections that can support changes between sub-paths. // We then find a best fit for subpaths that change over in these straights, according // to their minimum lengths and zone matching. // // A best fit is found using a Dijkstra's Algorithm-based best-first search. (Bottom-up // dynamic programming). The sub problems are just spans of the whole path, and are // built up towards the full path by adding on and scoring sub-paths. // // The minimum cost of a sub-path is the minimum possible number of mismatched zones if // an optimal zone is chosen. // // If there are multiple best solutions (with equal costs), there is a preference to // solutions with more sub-paths. if (allZones.Count == 0) throw new ArgumentException("no zones provided"); if (allZones.Count(zone => zone.RequiredSomewhere) > 1) throw new ArgumentException("RequiredSomewhere only supported for at most one zone"); if (path.Length < 2) throw new ArgumentException("path is too short"); if (minimumStraight < 0) throw new ArgumentException("minimumStraight was not >= 0"); var isLoop = path[0] == path[^1]; if (isLoop) path = NormalizeLoopStart(path); var zones = new PathPartitionZone[isLoop ? path.Length - 1 : path.Length]; for (var i = 0; i < zones.Length; i++) { if (zoneMask.ContainsXY(path[i])) zones[i] = zoneMask[path[i]]; } // from must be >= 0. IEnumerable Range(int from, int length) { for (var i = 0; i < length; i++) yield return (from + i) % zones.Length; } // from must be >= 0. IEnumerable ReverseRange(int from, int length) { for (var i = length - 1; i >= 0; i--) yield return (from + i) % zones.Length; } // Can also be used to get lengths int Idx(int i) => (i + zones.Length) % zones.Length; var minimumZoneLength = allZones.Min(z => z.MinimumLength); // To optimize partition vote counting, we pre-sum all the matches for allZones[i] // within zone[0..j] into partitionAcc[i][j]. This means we can quickly count the // matches between a and b by subtracting partitionAcc[i][a] from partitionAcc[i][b]. var partitionAcc = new int[allZones.Count][]; for (var i = 0; i < allZones.Count; i++) { partitionAcc[i] = new int[zones.Length + 1]; var sum = 0; for (var j = 0; j < zones.Length; j++) { if (zones[j] == allZones[i]) sum++; partitionAcc[i][j + 1] = sum; } } // This is declared outside of Vote() to avoid unnecessary re-allocations. // The values are not reused across calls. var voteCounts = new int[allZones.Count]; // Identifies valid zone choices in the given range and returns the cost (amount of // disagreement) for the best choice(s). Optionally provides the winner(s) via the // majorities argument. // // Note that the winner can sometimes be a zone not present within the range if // checkMinLength is enforcing candidates' MinimumLength requirement. int Vote(int from, int length, bool checkMinLength, List majorities = null) { const int Unsuitable = -1; if (checkMinLength && length < minimumZoneLength) return int.MaxValue; from = Idx(from); var to = from + length; if (to > zones.Length) to -= zones.Length; var nonWildcards = 0; var best = Unsuitable; for (var i = 0; i < allZones.Count; i++) { int count; if (to <= from) { count = partitionAcc[i][zones.Length] - partitionAcc[i][from] + partitionAcc[i][to] - partitionAcc[i][0]; } else { count = partitionAcc[i][to] - partitionAcc[i][from]; } nonWildcards += count; if (checkMinLength && length < allZones[i].MinimumLength) { voteCounts[i] = Unsuitable; } else { voteCounts[i] = count; if (count > best) best = count; } } if (best == Unsuitable) return int.MaxValue; if (majorities != null) for (var i = 0; i < allZones.Count; i++) if (voteCounts[i] == best) majorities.Add(allZones[i]); return nonWildcards - best; } PathPartitionZone fallbackPath; List SinglePath(PathPartitionZone zone) { if (zone.ShouldTile) return [ new TilingPath( Map, CellLayerUtils.FromMatrixPoints([path], Map.Tiles)[0], zone.MaximumDeviation, zone.SegmentType, zone.SegmentType, TilingPath.PermittedSegments.FromType(brushes, [zone.SegmentType]))]; else return []; } if (allZones.Any(zone => zone.RequiredSomewhere)) { fallbackPath = allZones.First(zone => zone.RequiredSomewhere); } else { var majorities = new List(); if (Vote(0, zones.Length, false, majorities) == 0) return SinglePath(majorities[0]); fallbackPath = majorities[0]; } var minLength = Math.Max(1, allZones.Min(r => r.MinimumLength)); if (path.Length < minLength) return SinglePath(fallbackPath); var straight = new bool[zones.Length]; for (var i = 0; i < zones.Length; i++) { var a = path[Idx(i - 1)]; var b = path[Idx(i)]; var c = path[Idx(i + 1)]; straight[i] = DirectionExts.FromInt2(b - a) == DirectionExts.FromInt2(c - b); } if (!isLoop) straight[0] = straight[^1] = true; // Note that loops can't be all straight. var validTerminal = new bool[zones.Length]; Array.Fill(validTerminal, true); { // Forward run var run = isLoop ? ReverseRange(zones.Length - minimumStraight, minimumStraight).TakeWhile(i => straight[i]).Count() : 0; foreach (var i in Range(0, zones.Length)) { run = straight[i] ? (run + 1) : 0; validTerminal[i] &= run >= minimumStraight; } // Backward run run = isLoop ? Range(zones.Length, minimumStraight).TakeWhile(i => straight[i]).Count() : 0; foreach (var i in ReverseRange(0, zones.Length)) { run = straight[i] ? (run + 1) : 0; validTerminal[i] &= run >= minimumStraight; } } if (!isLoop) validTerminal[0] = validTerminal[^1] = true; List validStarts; if (isLoop) validStarts = validTerminal .Select((v, i) => (Valid: v, Index: i)) .Where(t => t.Valid) .Select(t => t.Index) .ToList(); else validStarts = [0]; if (validStarts.Count == 0) return SinglePath(fallbackPath); var solutions = new List<(int Cost, List Solution)>(); // An optimization would be to include the start point in a combined search. // This is simpler though. foreach (var offset in validStarts) { var end = path.Length - 1; var costs = new int[end + 1]; Array.Fill(costs, int.MaxValue); // Find costs { var costPriorities = new PriorityArray(end + 1, int.MaxValue); costPriorities[0] = costs[0] = 0; while (true) { var from = costPriorities.GetMinIndex(); var fromCost = costPriorities[from]; if (fromCost == int.MaxValue || from == end) break; costPriorities[from] = int.MaxValue; var maxLength = end - from; for (var length = minLength; length <= maxLength; length++) { var to = from + length; if (!validTerminal[Idx(offset + to)]) continue; var mismatch = Vote(offset + from, length, true); if (mismatch == int.MaxValue) continue; var toCost = fromCost + mismatch; if (toCost >= costs[to]) continue; costPriorities[to] = costs[to] = toCost; } } } if (costs[end] == int.MaxValue) continue; // Work back from costs to solution { List solution = [Idx(offset + end)]; var to = end; while (to != 0) { var toCost = costs[to]; var maxLength = to; for (var length = minLength; length <= maxLength; length++) { var from = to - length; if (!validTerminal[Idx(offset + from)]) continue; var mismatch = Vote(offset + from, length, true); if (mismatch == int.MaxValue) continue; var fromCost = toCost - mismatch; // Use the first found solution. if (fromCost == costs[from]) { solution.Add(Idx(offset + from)); to = from; break; } } } solution.Reverse(); solutions.Add((costs[end], solution)); } } if (solutions.Count == 0) return SinglePath(fallbackPath); var bestCost = solutions.Min(t => t.Cost); var bestCostSolutions = solutions.Where(t => t.Cost == bestCost).ToList(); var mostBoundaries = bestCostSolutions.Max(t => t.Solution.Count); var boundaries = bestCostSolutions.First(t => t.Solution.Count == mostBoundaries).Solution; var ranges = new List<(int Start, int Length, PathPartitionZone Zone)>(); PathPartitionZone lastZone = null; for (var i = 0; i < boundaries.Count - 1; i++) { var from = boundaries[i]; var to = boundaries[i + 1]; if (from == to) return SinglePath(fallbackPath); var length = Idx(to - from); if (length + 1 == path.Length) return SinglePath(fallbackPath); var possibleZones = new List(allZones.Count); Vote(from, length, true, possibleZones); if (possibleZones[0] != lastZone) ranges.Add((from, length, possibleZones[0])); else ranges[^1] = (ranges[^1].Start, ranges[^1].Length + length, lastZone); lastZone = possibleZones[0]; } if (isLoop && ranges.Count >= 2 && ranges[0].Zone == ranges[^1].Zone) { ranges[0] = (ranges[^1].Start, ranges[^1].Length + ranges[0].Length, ranges[^1].Zone); ranges.RemoveAt(ranges.Count - 1); } if (ranges.Count == 1) return SinglePath(fallbackPath); var partitions = new List(); var previousIncludedInterface = isLoop && ranges[^1].Zone.ShouldTile; for (var rangeI = 0; rangeI < ranges.Count; rangeI++) { var (start, length, zone) = ranges[rangeI]; if (!zone.ShouldTile) { previousIncludedInterface = false; continue; } var innerType = zone.SegmentType; var startType = (!previousIncludedInterface && (isLoop || rangeI > 0)) ? ranges[(ranges.Count + rangeI - 1) % ranges.Count].Zone.SegmentType : innerType; var endType = (isLoop || rangeI < ranges.Count - 1) ? ranges[(rangeI + 1) % ranges.Count].Zone.SegmentType : innerType; Direction? startDirection = (isLoop || rangeI > 0) ? DirectionExts.FromInt2( path[(start + 1) % zones.Length] - path[start]) : null; Direction? endDirection = (isLoop || rangeI < ranges.Count - 1) ? DirectionExts.FromInt2( path[(length + start + 1) % zones.Length] - path[(length + start) % zones.Length]) : null; var points = Range(start, length + 1) .Select(i => path[i]) .ToList(); var tilingPath = new TilingPath( Map, CellLayerUtils.FromMatrixPoints([points.ToArray()], Map.Tiles)[0], zone.MaximumDeviation, startType, endType, TilingPath.PermittedSegments.FromTypes(brushes, [startType], [innerType], [endType])); tilingPath.Start.Direction = startDirection; tilingPath.End.Direction = endDirection; partitions.Add(tilingPath); previousIncludedInterface = true; } return partitions; } /// Wrapper around PartitionPath to process multiple paths at once. public List PartitionPaths( IEnumerable paths, IReadOnlyList zones, Matrix partitionMask, IReadOnlyList brushes, int minStraight) { return paths .SelectMany(path => PartitionPath( path, zones, partitionMask, brushes, minStraight)) .ToList(); } /// /// Wrapper around InsideOutside which performs both path tiling and side filling, painting /// the result to the map. If tiling fails, returns null without modifying the map. /// /// Random source used for tiling and filling. /// /// Paths to tile. Note that these are tiled exactly as specified, so if end deviation is /// enabled, this will allow tiling errors. /// /// Side to assume if no paths are contained in the map. /// If non-null, these MultiBrushes are painted over outside regions. /// If non-null, these MultiBrushes are painted over inside regions. /// Optional replaceability constraints for filling. Ignored for path tiling. /// Optional explicit height to paint at. Otherwise a height is picked automatically. public CellLayer PaintLoopsAndFill( MersenneTwister random, IReadOnlyList tilingPaths, Side fallback, IReadOnlyList outside, IReadOnlyList inside, CellLayer replaceMask = null, short? heightOffset = null) { CheckHasMapShapeOrNull(replaceMask); var tilings = new MultiBrush[tilingPaths.Count]; for (var i = 0; i < tilingPaths.Count; i++) { var tiling = tilingPaths[i].Tile(random); if (tiling == null) return null; tilings[i] = tiling; } foreach (var tiling in tilings) tiling.Paint(Map, ActorPlans, CPos.Zero, heightOffset, MultiBrush.Replaceability.Any, random); if (inside == null && outside == null) return null; var sides = InsideOutside(tilings, fallback); foreach (var (brushes, side) in new[] { (inside, Side.In), (outside, Side.Out) }) { if (brushes == null) continue; var replace = new CellLayer(Map); foreach (var mpos in Map.AllCells.MapCoords) replace[mpos] = (sides[mpos] == side) ? (replaceMask?[mpos] ?? MultiBrush.Replaceability.Any) : MultiBrush.Replaceability.None; PaintArea(random, replace, brushes); } return sides; } /// /// Given a collection of path tiling results which form non-nested loops or extend beyond /// or out to the map edge, return a CellLayer identifying whether cells are inside or /// outside of the tiled loops, or Side.None if the cell is covered by a MultiBrush. /// If a loop wraps around a space clockwise, that space is considered inside. /// /// Path tiling results which partition the space. /// Side to assume if no paths are contained in the map. public CellLayer InsideOutside( IReadOnlyList tilings, Side fallback) { var sides = new CellLayer(Map); var tiledPoints = new CPos[tilings.Count][]; var tiledArea = new CellLayer(Map); for (var i = 0; i < tilings.Count; i++) { tiledPoints[i] = tilings[i].Segment.Points .Select(vec => CPos.Zero + vec) .ToArray(); foreach (var cvec in tilings[i].Shape) if (tiledArea.Contains(CPos.Zero + cvec)) tiledArea[CPos.Zero + cvec] = true; } var chiralityMatrix = MatrixUtils.PointsChirality( CellLayerUtils.CellBounds(Map).Size.ToInt2(), CellLayerUtils.ToMatrixPoints(tiledPoints, Map.Tiles)); if (chiralityMatrix == null) { sides.Clear(fallback); return sides; } var chirality = new CellLayer(Map); CellLayerUtils.FromMatrix(chirality, chiralityMatrix); foreach (var mpos in Map.AllCells.MapCoords) { if (!tiledArea[mpos]) { if (chirality[mpos] > 0) sides[mpos] = Side.In; else if (chirality[mpos] < 0) sides[mpos] = Side.Out; } } return sides; } /// /// Fill a CellLayer with a given value to identify or undo the effects of painting sided /// regions. For example, this can be used to un-paint an unplayable body of water along /// with its beaches. /// public void FillUnmaskedSideAndBorder( CellLayer mask, CellLayer sides, Side fillSide, Action fillAction) { CheckHasMapShape(mask); CheckHasMapShape(sides); if (fillSide == Side.None) throw new ArgumentException("fillSide was not In or Out"); var notFillSide = fillSide == Side.In ? Side.Out : Side.In; var fillSeeds = CellLayerUtils.Create(Map, mpos => sides[mpos] == fillSide && !mask[mpos] && Map.Bounds.Contains(mpos.U, mpos.V)); fillSeeds = ImproveSymmetry(fillSeeds, false, (a, b) => a || b); var fillable = CellLayerUtils.Map(sides, side => side != notFillSide); CellLayerUtils.SimpleFloodFill( fillable, fillSeeds, fillAction, DirectionExts.Spread4CVec); } /// /// Plan passageway cutouts that, when subtracted away from obstructions, preserve /// connectivity through a given space. /// /// Random source for carving addition passageways to comply with maximumCutoutSpacing. /// Describes the space through which connectivity needs to be preserved. /// Half-thickness of passageways. /// /// If greater than zero, inserts additional passageways, ensuring that passageways are no /// greater than this distance apart (in Chebyshev distance). /// public CellLayer PlanPassages( MersenneTwister random, CellLayer space, int cutoutRadius, int maximumCutoutSpacing = 0) { CheckHasMapShape(space); var passages = new CellLayer(Map); if (cutoutRadius <= 0) return passages; if (maximumCutoutSpacing > 0) { space = CellLayerUtils.Clone(space); var roominess = new CellLayer(Map); CellLayerUtils.ChebyshevRoom(roominess, space, false); foreach (var mpos in Map.AllCells.MapCoords) roominess[mpos] = Math.Min( maximumCutoutSpacing, roominess[mpos]); while (true) { var (chosenMPos, room) = CellLayerUtils.FindRandomBest( roominess, random, (a, b) => a.CompareTo(b)); if (room < maximumCutoutSpacing) break; var projections = Symmetry.RotateAndMirrorCPos( chosenMPos.ToCPos(Map), space, Rotations, WMirror); foreach (var projection in projections) { if (space.Contains(projection)) space[projection] = false; var minX = projection.X - 2 * maximumCutoutSpacing + 1; var minY = projection.Y - 2 * maximumCutoutSpacing + 1; var maxX = projection.X + 2 * maximumCutoutSpacing - 1; var maxY = projection.Y + 2 * maximumCutoutSpacing - 1; for (var y = minY; y <= maxY; y++) for (var x = minX; x <= maxX; x++) { var mpos = new CPos(x, y).ToMPos(Map); if (roominess.Contains(mpos)) roominess[mpos] = 0; } } } } var matrixSpace = CellLayerUtils.ToMatrix(space, false); // deflated is grid points, not squares. Has a size of `size + 1`. var deflated = MatrixUtils.DeflateSpace(matrixSpace, false); var kernel = new Matrix(2 * cutoutRadius, 2 * cutoutRadius).Fill(true); var inflated = MatrixUtils.KernelDilateOrErode(deflated.Map(v => v != 0), kernel, new int2(cutoutRadius - 1, cutoutRadius - 1), true); CellLayerUtils.FromMatrix(passages, inflated, true); return passages; } /// /// Plan paths for roads that travel through the middle of playable space. /// /// Space in which roads are permitted. /// Minimum distance that roads must be from the edges of available space. /// Roads shorter than this will be merged or pruned. public CPos[][] PlanRoads( CellLayer availableSpace, int minimumSpacing, int minimumLength) { CheckHasMapShape(availableSpace); // For awkward symmetries, we try harder to make sure roads are fairer. // This can degrade the quantity of roads, though. var imperfectSymmetry = WMirror.HasMirror || Rotations == 3 || Rotations >= 5; var gridType = Map.Grid.Type; // Enlargement must increase dimensions by multiple of 4 to maximize compatibility // with IsometricRectangular grids, where a non-multiple of 4 would change how the // center aligns with the grid. var enlargedSize = new Size( Map.MapSize.Width + (Map.MapSize.Width & ~3) + 4, Map.MapSize.Height + (Map.MapSize.Height & ~3) + 4); var space = new CellLayer(gridType, enlargedSize); space.Clear(true); var enlargedOffset = CellLayerUtils.WPosToCPos(CellLayerUtils.Center(space), gridType) - CellLayerUtils.WPosToCPos(CellLayerUtils.Center(Map.Tiles), gridType); foreach (var cpos in Map.AllCells) space[cpos + enlargedOffset] = availableSpace[cpos]; space = ImproveSymmetry(space, true, (a, b) => a && b); var matrixSpace = CellLayerUtils.ToMatrix(space, true); var kernel = new Matrix(minimumSpacing * 2 + 1, minimumSpacing * 2 + 1); MatrixUtils.OverCircle( matrix: kernel, centerIn1024ths: kernel.Size * 512, radiusIn1024ths: minimumSpacing * 1024, outside: false, action: (xy, _) => kernel[xy] = true); var dilated = MatrixUtils.KernelDilateOrErode( matrixSpace, kernel, new int2(minimumSpacing, minimumSpacing), false); var deflated = MatrixUtils.DeflateSpace(dilated, true); if (imperfectSymmetry) { var changing = true; while (changing) { changing = false; // Delete short paths. { MatrixUtils.RemoveStubsFromDirectionMapInPlace(deflated); var paths = MatrixUtils.DirectionMapToPaths(deflated); if (paths.Length == 0) break; var minLength = paths.Min(p => p.Length); if (minLength < minimumLength) { changing = true; var shortPaths = paths .Where(path => path.Length == minLength); foreach (var path in shortPaths) foreach (var point in path) deflated[point] = 0; MatrixUtils.RemoveStubsFromDirectionMapInPlace(deflated); } } // Prune asymmetric paths. { const int Dilation = 3; var nearPath = MatrixUtils.KernelDilateOrErode( deflated.Map(v => v != 0), new Matrix(Dilation * 2 + 1, Dilation * 2 + 1).Fill(true), new int2(Dilation, Dilation), true); var matrixPaths = MatrixUtils.DirectionMapToPaths(deflated); foreach (var path in matrixPaths) { var cposPath = CellLayerUtils.FromMatrixPoints([path], space)[0]; var projectedPoints = cposPath .SelectMany(p => Symmetry.RotateAndMirrorCPos(p, space, Rotations, WMirror)) .ToArray(); var matrixPoints = CellLayerUtils.ToMatrixPoints([projectedPoints], space)[0]; if (!matrixPoints.All(p => !nearPath.ContainsXY(p) || nearPath[p])) { // The path doesn't exist across all symmetries (or isn't consistent enough). changing = true; foreach (var point in path) deflated[point] = 0; } } } } } var matrixPointArrays = MatrixUtils.DirectionMapToPathsWithPruning( input: deflated, minimumLength: minimumLength, minimumJunctionSeparation: 6, preserveEdgePaths: true); var pointArrays = CellLayerUtils.FromMatrixPoints(matrixPointArrays, space); pointArrays = TilingPath.RetainDisjointPaths(pointArrays); pointArrays = pointArrays .Select(a => a.Select(p => p - enlargedOffset).ToArray()) .Select(a => TilingPath.ChirallyNormalizePathPoints(a, cvec => CellLayerUtils.CornerToWPos(cvec, gridType) - CellLayerUtils.Center(Map))) .ToArray(); return pointArrays; } /// /// Given a resource noise pattern, rank cells for resource growth. (Higher is better.) /// Resources will be limited to masked cells. Resources will only be placed on compatible /// terrain tiles and will avoid actor footprints. /// Resources can be biased towards or away from specified actors. Biases are applied in /// the order they are supplied, but all reservations take precedence. /// Resource type will be determined by proximity to resource spawn actors, or a default /// resource. /// public (CellLayer Plan, CellLayer TypePlan) PlanResources( CellLayer pattern, CellLayer mask, ResourceTypeInfo defaultResource, IReadOnlyList resourceBiases) { CheckHasMapShape(pattern); CheckHasMapShape(mask); // IReadOnlyDictionary resourceSpawnSeeds = ...; var resourceTypes = Map.Rules.Actors[SystemActors.World] .TraitInfoOrDefault() .ResourceTypes .OrderBy(kv => kv.Key) .Select(kv => kv.Value) .ToImmutableArray(); var allowedTerrainResourceCombos = resourceTypes .SelectMany(resourceTypeInfo => resourceTypeInfo.AllowedTerrainTypes .Select(terrainName => (resourceTypeInfo, terrainInfo.GetTerrainIndex(terrainName)))) .ToHashSet(); var strengths = new Dictionary>(); foreach (var resourceType in resourceTypes) { var strength = new CellLayer(Map); strength.Clear(1); strengths.Add(resourceType, strength); } foreach (var bias in resourceBiases) { if (bias.Bias == null || bias.BiasRadius == null) continue; IEnumerable types = bias.ResourceType != null ? [bias.ResourceType] : resourceTypes; foreach (var resourceType in types) { var strength = strengths[resourceType]; CellLayerUtils.OverCircle( cellLayer: strength, wCenter: bias.WPos, wRadius: bias.BiasRadius.Value, outside: false, action: (mpos, _, _, wrSq) => strength[mpos] = bias.Bias(strength[mpos], wrSq)); } } var maxStrength1024ths = new CellLayer(Map); maxStrength1024ths.Clear(1); var bestResource = new CellLayer(Map); bestResource.Clear(defaultResource); foreach (var resourceStrength in strengths) { var resource = resourceStrength.Key; var strength1024ths = resourceStrength.Value; foreach (var mpos in Map.AllCells.MapCoords) if (strength1024ths[mpos] > maxStrength1024ths[mpos]) { maxStrength1024ths[mpos] = strength1024ths[mpos]; bestResource[mpos] = resource; } } // Closer to +inf means "more preferable" for plan. var plan = new CellLayer(Map); foreach (var mpos in Map.AllCells.MapCoords) { plan[mpos] = pattern[mpos] >= 0 ? pattern[mpos] * maxStrength1024ths[mpos] : -int.MaxValue; } foreach (var mpos in Map.AllCells.MapCoords) if (!mask[mpos] || !allowedTerrainResourceCombos.Contains((bestResource[mpos], Map.GetTerrainIndex(mpos)))) plan[mpos] = -int.MaxValue; foreach (var bias in resourceBiases) { if (bias.ExclusionRadius == null) continue; foreach (var resourceType in resourceTypes) { CellLayerUtils.OverCircle( cellLayer: plan, wCenter: bias.WPos, wRadius: bias.ExclusionRadius.Value, outside: false, action: (mpos, _, _, wrSq) => plan[mpos] = -int.MaxValue); } } plan = ImproveSymmetry(plan, -int.MaxValue, int.Min); return (plan, bestResource); } /// /// Given a resource plan, place resources onto the map up to a target value. /// Resources are placed first on the pattern cells with the greatest value. /// No resources will be placed on pattern cells with a value less than 0. /// The plan should only contain values >= 0 where resource placement is legal. /// The type of resource placed is specified by typePlan. /// Any previously existing resources on the map will be cleared. /// public void GrowResources( CellLayer plan, CellLayer typePlan, long targetValue) { CheckHasMapShape(plan); CheckHasMapShape(typePlan); var remaining = targetValue; var resourceTypes = Map.Rules.Actors[SystemActors.World].TraitInfoOrDefault().ResourceTypes; var playerResourcesInfo = Map.Rules.Actors[SystemActors.Player].TraitInfoOrDefault(); var resourceValues = playerResourcesInfo.ResourceValues .ToDictionary(kv => resourceTypes[kv.Key], kv => kv.Value); // Closer to -inf means "more preferable" for priorities. var priorities = new PriorityArray( plan.Size.Width * plan.Size.Height, int.MaxValue); { var i = 0; foreach (var v in plan) priorities[i++] = -v; } int PriorityIndex(MPos mpos) => mpos.V * plan.Size.Width + mpos.U; MPos PriorityMPos(int index) { var v = Math.DivRem(index, plan.Size.Width, out var u); return new MPos(u, v); } Map.Resources.Clear(); // Return resource value of a given square. // Matches the logic in ResourceLayer trait. int CheckValue(CPos cpos) { if (!Map.Resources.Contains(cpos)) return 0; var resource = Map.Resources[cpos].Type; if (resource == 0) return 0; var resourceType = typePlan[cpos]; var adjacent = 0; var directions = CVec.Directions; for (var i = 0; i < directions.Length; i++) { var c = cpos + directions[i]; if (Map.Resources.Contains(c) && Map.Resources[c].Type == resource) ++adjacent; } // We need to have at least one resource in the cell. // HACK: we should not be lerping to 9, as maximum adjacent resources is 8. // HACK: it's too disruptive to fix. var density = Math.Max(int2.Lerp(0, resourceType.MaxDensity, adjacent, 9), 1); return resourceValues[resourceType] * density; } int CheckValue3By3(CPos cpos) { var total = 0; for (var y = -1; y <= 1; y++) for (var x = -1; x <= 1; x++) total += CheckValue(cpos + new CVec(x, y)); return total; } var gridType = Map.Grid.Type; // Set and return change in overall value. int AddResource(CPos cpos) { var mpos = cpos.ToMPos(gridType); priorities[PriorityIndex(mpos)] = int.MaxValue; // Generally shouldn't happen, but perhaps a rotation/mirror related inaccuracy. if (Map.Resources[mpos].Type != 0) return 0; var resourceType = typePlan[mpos]; var oldValue = CheckValue3By3(cpos); Map.Resources[mpos] = new ResourceTile( resourceType.ResourceIndex, resourceType.MaxDensity); var newValue = CheckValue3By3(cpos); return newValue - oldValue; } while (remaining > 0) { var n = priorities.GetMinIndex(); if (priorities[n] == int.MaxValue) break; var chosenMPos = PriorityMPos(n); var chosenCPos = chosenMPos.ToCPos(gridType); foreach (var cpos in Symmetry.RotateAndMirrorCPos(chosenCPos, plan, Rotations, WMirror)) if (Map.Resources.Contains(cpos)) remaining -= AddResource(cpos); } } /// /// Create a mask for placing decorations in out-of-the-way locations on a map. /// /// Random source for layout and tiling. /// Space that decorations must not significantly choke. /// Cells where decoration is allowed. /// Maximum fraction of map to cover in decorations. /// Noise feature size for layout. /// Density of decoration layout. /// /// Enforces a minimum local density of decorations. This can, for example, be used to /// ensure that villages have a substantial size, preventing lonely buildings. Decoration /// cells are removed until the minimum density is satisfied for remaining cells. /// /// Enforcement radius of minimum density. public CellLayer DecorationPattern( MersenneTwister random, CellLayer space, CellLayer zoneable, int coverage, int featureSize, int density, int minimumDensity, int minimumDensityRadius) { CheckHasMapShape(space); CheckHasMapShape(zoneable); var matrixSpace = CellLayerUtils.ToMatrix(space, true); var deflated = MatrixUtils.DeflateSpace(matrixSpace, false); var kernel = new Matrix(2, 2).Fill(true); var reservedMatrix = MatrixUtils.KernelDilateOrErode(deflated.Map(v => v != 0), kernel, new int2(0, 0), true); var reserved = new CellLayer(Map); CellLayerUtils.FromMatrix(reserved, reservedMatrix, true); var decorationNoise = new CellLayer(Map); NoiseUtils.SymmetricFractalNoiseIntoCellLayer( random, decorationNoise, Rotations, WMirror, featureSize, NoiseUtils.WhiteAmplitude); var densityNoise = new CellLayer(Map); NoiseUtils.SymmetricFractalNoiseIntoCellLayer( random, densityNoise, Rotations, WMirror, 1024, NoiseUtils.PinkAmplitude); var densityMask = CellLayerUtils.CalibratedBooleanThreshold( densityNoise, density, FractionMax); var decorable = new CellLayer(Map); var totalDecorable = 0; foreach (var mpos in Map.AllCells.MapCoords) { var isDecorable = zoneable[mpos] && space[mpos] && !reserved[mpos] && densityMask[mpos]; decorable[mpos] = isDecorable; if (isDecorable) totalDecorable++; else decorationNoise[mpos] = -1024 * 1024; } var mapArea = Map.MapSize.Width * Map.MapSize.Height; var decorationMask = CellLayerUtils.CalibratedBooleanThreshold( decorationNoise, totalDecorable * coverage / FractionMax, mapArea); foreach (var mpos in Map.AllCells.MapCoords) decorable[mpos] &= decorationMask[mpos]; for (var i = 0; i < 8; i++) { var (blurred, changes) = MatrixUtils.BooleanBlur( CellLayerUtils.ToMatrix(decorable, false), minimumDensityRadius, FractionMax - minimumDensity, FractionMax); if (changes == 0) break; var densityFilter = new CellLayer(Map); CellLayerUtils.FromMatrix(densityFilter, blurred); foreach (var mpos in Map.AllCells.MapCoords) decorable[mpos] &= densityFilter[mpos]; } decorable = ImproveSymmetry(decorable, false, (a, b) => a && b); return decorable; } } }