From 25571df2b6e2bf5f6855d96024beed8f1e779598 Mon Sep 17 00:00:00 2001 From: Ashley Newson Date: Thu, 26 Jun 2025 22:41:58 +0100 Subject: [PATCH] Refactor ExperimentalMapGenerator into reusable methods This change lifts large portions of ExperimentalMapGenerator's logic into a new "Terraformer" class with highly documented methods that can theoretically be used by alternative map generator classes. Some additional refactoring may occur in future, subject to the practical needs of additional map generators. ClearMapGenerator is also simplified. This is not a pure refactor and contains some algorithmic and behavioral changes, as well as few minor bug fixes. Notably: - The logic for obstructing unreachable water has been somewhat replaced. - In CnC, where water is already unplayable, EMG no longer obstructs water. (RA still does, as water is playable there.) - Mountain (cliff) generation is now somewhat more effective. This increases the number of cliffs seen on many presets. Settings should no longer use "Mountains: 1000". - PlayableSpace logic has been consolidated into Terraformer. - PlayableSpace.Playability no longer exists as PartiallyPlayable became redundant. Its uses have been replaced with a simple boolean. Consequently, some defunct code and configuration has been removed. - Adjust MultiBrush replaceability contract painting behavior to be more intuitive. - Fix off-by-one error in map bounds computation. - Fix some usages of mixed up MersenneTwisters. --- OpenRA.Mods.Common/MapGenerator/ActorPlan.cs | 11 + .../MapGenerator/CellLayerUtils.cs | 173 +- .../MapGenerator/MatrixUtils.cs | 252 ++- OpenRA.Mods.Common/MapGenerator/MultiBrush.cs | 12 +- OpenRA.Mods.Common/MapGenerator/NoiseUtils.cs | 15 + .../MapGenerator/PlayableSpace.cs | 147 -- .../MapGenerator/Terraformer.cs | 1752 +++++++++++++++++ OpenRA.Mods.Common/MapGenerator/TilingPath.cs | 87 + .../Traits/World/ClearMapGenerator.cs | 40 +- .../Traits/World/ExperimentalMapGenerator.cs | 1503 +++----------- mods/cnc/rules/map-generators.yaml | 11 +- mods/ra/rules/map-generators.yaml | 11 +- 12 files changed, 2534 insertions(+), 1480 deletions(-) delete mode 100644 OpenRA.Mods.Common/MapGenerator/PlayableSpace.cs create mode 100644 OpenRA.Mods.Common/MapGenerator/Terraformer.cs diff --git a/OpenRA.Mods.Common/MapGenerator/ActorPlan.cs b/OpenRA.Mods.Common/MapGenerator/ActorPlan.cs index 4d36c42bd9..1a12aa39aa 100644 --- a/OpenRA.Mods.Common/MapGenerator/ActorPlan.cs +++ b/OpenRA.Mods.Common/MapGenerator/ActorPlan.cs @@ -145,5 +145,16 @@ namespace OpenRA.Mods.Common.MapGenerator return CellLayerUtils.CVecToWVec(new CVec(left + right, top + bottom), Map.Grid.Type) / 2; } + + /// Return the larger of the width or height of the actor's footprint. + public int MaxSpan() + { + var footprint = Footprint().Select(kv => kv.Key).ToList(); + var minX = footprint.Min(cpos => cpos.X); + var minY = footprint.Min(cpos => cpos.Y); + var maxX = footprint.Max(cpos => cpos.X); + var maxY = footprint.Max(cpos => cpos.Y); + return Math.Max(maxX - minX, maxY - minY) + 1; + } } } diff --git a/OpenRA.Mods.Common/MapGenerator/CellLayerUtils.cs b/OpenRA.Mods.Common/MapGenerator/CellLayerUtils.cs index 746641e70a..8f9b74c287 100644 --- a/OpenRA.Mods.Common/MapGenerator/CellLayerUtils.cs +++ b/OpenRA.Mods.Common/MapGenerator/CellLayerUtils.cs @@ -60,6 +60,25 @@ namespace OpenRA.Mods.Common.MapGenerator } } + public static WPos Center(Map map) + { + return Center(map.Tiles); + } + + /// + /// Return the radius of the largest circle that can be contained in the cell layer. + /// + public static WDist Radius(CellLayer cellLayer) + { + var center = Center(cellLayer); + return new WDist(Math.Min(center.X, center.Y)); + } + + public static WDist Radius(Map map) + { + return Radius(map.Tiles); + } + /// Get the WPos of the -X-Y corner of a CPos cell. public static WPos CornerToWPos(CPos cpos, MapGridType gridType) { @@ -218,7 +237,7 @@ namespace OpenRA.Mods.Common.MapGenerator public static void OverCircle( CellLayer cellLayer, WPos wCenter, - int wRadius, + WDist wRadius, bool outside, Action action) { @@ -243,12 +262,12 @@ namespace OpenRA.Mods.Common.MapGenerator switch (gridType) { case MapGridType.Rectangular: - mRadiusU = wRadius / 1024 + 1; - mRadiusV = wRadius / 1024 + 1; + mRadiusU = wRadius.Length / 1024 + 1; + mRadiusV = wRadius.Length / 1024 + 1; break; case MapGridType.RectangularIsometric: - mRadiusU = wRadius / 1448 + 2; - mRadiusV = wRadius / 724 + 2; + mRadiusU = wRadius.Length / 1448 + 2; + mRadiusV = wRadius.Length / 724 + 2; break; default: throw new NotImplementedException(); @@ -260,7 +279,7 @@ namespace OpenRA.Mods.Common.MapGenerator maxV = Math.Min(mCenter.V + mRadiusV, cellLayer.Size.Height - 1); } - var wRadiusSquared = (long)wRadius * wRadius; + var wRadiusSquared = wRadius.LengthSquared; for (var v = minV; v <= maxV; v++) for (var u = minU; u <= maxU; u++) { @@ -288,8 +307,10 @@ namespace OpenRA.Mods.Common.MapGenerator } /// - /// Uniformally add to or subtract from all cells such that count out of every outOf cells, - /// are no greater than the given target value. + /// Uniformally add to or subtract from all cells such that the quantile (count/outOf) has at the target value. + /// For example, (target: 0, count: 25, outOf: 75) where there are 401 cells would mean + /// that 100 cells are no greater than 0, 300 cells are no less than 0, and at least 1 cell + /// is 0. /// public static void CalibrateQuantileInPlace(CellLayer cellLayer, int target, int count, int outOf) { @@ -300,6 +321,32 @@ namespace OpenRA.Mods.Common.MapGenerator cellLayer[mpos] += adjustment; } + /// + /// Return a boolean CellLayer where true correlates with the largest values in the input, + /// such that the fraction of true cells is at least (but approximately) count/outOf. + /// + public static CellLayer CalibratedBooleanThreshold(CellLayer input, int count, int outOf) + { + var output = new CellLayer(input.GridType, input.Size); + if (count <= 0) + { + return output; + } + else if (count >= outOf) + { + output.Clear(true); + return output; + } + + var sorted = Entries(input); + Array.Sort(sorted); + var threshold = sorted[(long)sorted.Length * (outOf - count) / outOf]; + foreach (var mpos in input.CellRegion.MapCoords) + output[mpos] = input[mpos] >= threshold; + + return output; + } + /// /// Get the smallest CPos rectangle that contains all cells for the specified grid. /// @@ -419,10 +466,10 @@ namespace OpenRA.Mods.Common.MapGenerator /// Returns world distances (1024ths). /// public static void WalkingDistances( - CellLayer distances, + CellLayer distances, CellLayer passable, IEnumerable seeds, - int maxDistance) + WDist maxDistance) { var passableMatrix = ToMatrix(passable, false); var cellBounds = CellBounds(passable); @@ -532,5 +579,111 @@ namespace OpenRA.Mods.Common.MapGenerator } } } + + /// + /// Simple flood fill that propagates, starting from seed cells, throughout a masked area. + /// The fillAction is run once (in a consistent order) for each filled cell. + /// + public static void SimpleFloodFill( + CellLayer mask, + CellLayer seeds, + Action fillAction, + ImmutableArray spread) + { + if (!AreSameShape(mask, seeds)) + throw new ArgumentException("mask and seeds did not have same shape"); + + var available = Clone(mask); + + bool? Filler(CPos cpos, bool _) + { + if (!available[cpos]) + return null; + + fillAction(cpos); + available[cpos] = false; + return true; + } + + FloodFill( + available, + seeds.CellRegion + .Where(cpos => seeds[cpos] && mask[cpos]) + .Select(cpos => (cpos, true)), + Filler, + spread); + } + + /// Return logical AND / conjunction / intersection of layers. + public static CellLayer Intersect(IEnumerable> layers) + { + return Aggregate(layers, (a, b) => a && b); + } + + /// + /// Return the difference of layers. Each cell is true if and only if something appears + /// only in the first layer. + /// + public static CellLayer Subtract(IEnumerable> layers) + { + return Aggregate(layers, (a, b) => a && !b); + } + + public static CellLayer Aggregate( + IEnumerable> layers, + Func aggregator) + { + var layersArray = layers.ToArray(); + if (layersArray.Length == 0) + throw new ArgumentException("No layers were supplied"); + + var accumulator = new CellLayer(layersArray[0].GridType, layersArray[0].Size); + accumulator.CopyValuesFrom(layersArray[0]); + foreach (var layer in layersArray.Skip(1)) + { + if (!AreSameShape(accumulator, layer)) + throw new ArgumentException("Layers are not the same shape"); + foreach (var mpos in accumulator.CellRegion.MapCoords) + accumulator[mpos] = aggregator(accumulator[mpos], layer[mpos]); + } + + return accumulator; + } + + /// Create a shallow copy of a CellLayer. + public static CellLayer Clone(CellLayer input) + { + var output = new CellLayer(input.GridType, input.Size); + output.CopyValuesFrom(input); + return output; + } + + public static CellLayer Map(CellLayer input, Func func) + { + var output = new CellLayer(input.GridType, input.Size); + foreach (var mpos in input.CellRegion.MapCoords) + output[mpos] = func(input[mpos]); + return output; + } + + /// Create and initialize a CellLayer according to the given function. + public static CellLayer Create(Map map, Func func) + { + var layer = new CellLayer(map); + foreach (var mpos in map.AllCells.MapCoords) + layer[mpos] = func(mpos); + + return layer; + } + + /// Create and initialize a CellLayer according to the given function. + public static CellLayer Create(Map map, Func func) + { + var layer = new CellLayer(map); + foreach (var cpos in map.AllCells) + layer[cpos] = func(cpos); + + return layer; + } } } diff --git a/OpenRA.Mods.Common/MapGenerator/MatrixUtils.cs b/OpenRA.Mods.Common/MapGenerator/MatrixUtils.cs index e7fb300daa..2d686002f5 100644 --- a/OpenRA.Mods.Common/MapGenerator/MatrixUtils.cs +++ b/OpenRA.Mods.Common/MapGenerator/MatrixUtils.cs @@ -23,6 +23,164 @@ namespace OpenRA.Mods.Common.MapGenerator { public const int MaxBinomialKernelRadius = 10; + public enum DumpAdjustment + { + /// Make no adjustment. + None, + + /// Normalize the matrix amplitude to the color range. + Normalize, + + /// + /// Normalize the matrix amplitude, but uniformally extend away from zero by a small + /// amount to help identify the sign of martix values. + /// + Emphasize, + } + + public enum GraphMode + { + /// + /// The plotted value is the latest sequence touching a cell + 1. + /// + Identifier, + + /// + /// The plotted value is the (latest) point index in the (latest) sequence touching a + /// cell. + /// + Gradient, + + /// The plotted value is the count of points touching a cell. + Accumulate, + } + + /// + /// + /// Debugging method that prints a matrix to stderr using color only (not value listing). + /// + /// + /// Orange < -255, -255 <= Red < 0, Black == 0, 0 < Blue <= 255, + /// 255 < Cyan. Faint green is used for distance markings. + /// + /// + /// The matrix can optionally be preprocessed for easier visual interpretation using a + /// DumpAdjustment. + /// + /// + public static void ColorDump2d( + string label, + Matrix matrix, + DumpAdjustment adjustment = DumpAdjustment.None) + { + Console.Error.WriteLine($"{label}: {matrix.Size.X} by {matrix.Size.Y}, {matrix.Data.Min()} to {matrix.Data.Max()}"); + + switch (adjustment) + { + case DumpAdjustment.Normalize: + matrix = NormalizeRangeInPlace(matrix.Clone(), 255); + break; + case DumpAdjustment.Emphasize: + matrix = NormalizeRangeInPlace(matrix.Clone(), 224) + .Map(v => v += Math.Sign(v) * 31); + break; + default: + break; + } + + for (var y = 0; y < matrix.Size.Y; y++) + { + for (var x = 0; x < matrix.Size.X; x++) + { + var v = matrix[x, y]; + int r = 0, g = 0, b = 0; + + if (v < -255) + { + r = 255; + g = 192; + } + else if (v < 0) + { + r = -v; + } + else if (v == 0) + { + } + else if (v <= 255) + { + b = v; + g = v / 4; + } + else + { + // v > 255 + b = 255; + g = 192; + } + + g += (((x & 4) != (y & 4)) ? 1 : 0) * (((x & 16) != (y & 16)) ? 48 : 32); + + Console.Error.Write(string.Format(NumberFormatInfo.InvariantInfo, "\u001b[48;2;{0};{1};{2}m ", r, g, b)); + } + + Console.Error.Write("\u001b[0m\n"); + } + + Console.Error.WriteLine(""); + Console.Error.Flush(); + } + + public static void ColorDump2d( + string label, + Matrix matrix) + { + ColorDump2d(label, matrix.Map(v => v ? 255 : -255)); + } + + /// + /// Debugging method that prints a matrix of enum-like values to stderr, where values are + /// mapped to one of 27 different colors. Red, green, and blue values represent base-3 + /// digits of increasing significance. Unmappable values produce white. A corresponding + /// letter of the latin alphabet is also written in the right of cells greater than zero. + /// E.g., 21_base10 = 210_base3 = bright blue + medium green + no red, letter U. + /// + public static void EnumDump2d(string label, Matrix matrix) + { + Console.Error.WriteLine($"{label}: {matrix.Size.X} by {matrix.Size.Y}, {matrix.Data.Min()} to {matrix.Data.Max()}"); + for (var y = 0; y < matrix.Size.Y; y++) + { + for (var x = 0; x < matrix.Size.X; x++) + { + var v = matrix[x, y]; + if (v < 0 || v > 26) + v = 26; + + var r = 127 * (v / 1 % 3); + var g = 127 * (v / 3 % 3); + var b = 127 * (v / 9 % 3); + var f = (r + g + b <= 127) ? 37 : 30; + var c = v > 0 ? (char)(64 + v) : '.'; + Console.Error.Write(string.Format(NumberFormatInfo.InvariantInfo, "\u001b[{0};48;2;{1};{2};{3}m {4}", f, r, g, b, c)); + + // if (v < 0 || v >= 15) + // v = 15; + // var code = (v < 8 ? 40 : 92) + v; + // Console.Error.Write(string.Format(NumberFormatInfo.InvariantInfo, "\u001b[{0}m .", code)); + } + + Console.Error.Write("\u001b[0m\n"); + } + + Console.Error.WriteLine(""); + Console.Error.Flush(); + } + + public static void EnumDump2d(string label, Matrix matrix) where T : Enum + { + EnumDump2d(label, matrix.Map(v => Convert.ToInt32(v, NumberFormatInfo.InvariantInfo))); + } + /// /// Debugging method that prints a matrix to stderr. /// @@ -96,6 +254,55 @@ namespace OpenRA.Mods.Common.MapGenerator Console.Error.Flush(); } + /// + /// Plot multiple point sequences onto a matrix for debugging visualization. The matrix is + /// fit to the shape of all the path. + /// + public static Matrix GraphPoints( + IEnumerable> pointArrays, + GraphMode mode = GraphMode.Identifier) + { + var pointArrayArray = pointArrays.Select(a => a.ToArray()).ToArray(); + var allPoints = pointArrayArray.SelectMany(p => p).ToArray(); + if (allPoints.Length == 0) + return new Matrix(1, 1).Fill(int.MinValue); + + var topLeft = new int2(allPoints.Min(p => p.X), allPoints.Min(p => p.Y)); + var bottomRight = new int2(allPoints.Max(p => p.X), allPoints.Max(p => p.Y)); + var size = bottomRight - topLeft + new int2(1, 1); + var matrix = new Matrix(size).Fill(mode == GraphMode.Gradient ? -1 : 0); + for (var j = 0; j < pointArrayArray.Length; j++) + { + var pointArray = pointArrayArray[j]; + for (var i = 0; i < pointArray.Length; i++) + switch (mode) + { + case GraphMode.Identifier: + matrix[pointArray[i] - topLeft] = j + 1; + break; + case GraphMode.Gradient: + matrix[pointArray[i] - topLeft] = i; + break; + case GraphMode.Accumulate: + matrix[pointArray[i] - topLeft]++; + break; + } + } + + return matrix; + } + + /// + /// Plot a point sequence onto a matrix for debugging visualization. The matrix is fit to + /// the shape of the path. + /// + public static Matrix GraphPoints( + IEnumerable points, + GraphMode mode = GraphMode.Identifier) + { + return GraphPoints([points], mode); + } + /// /// /// Perform a generic flood fill starting at seeds [(xy, prop), ...]. @@ -166,12 +373,12 @@ namespace OpenRA.Mods.Common.MapGenerator /// int.MaxValue. /// /// - public static Matrix WalkingDistances(Matrix passable, IEnumerable seeds, int maxDistance) + public static Matrix WalkingDistances(Matrix passable, IEnumerable seeds, WDist maxDistance) { const int Diagonal = 1448; const int Straight = 1024; - var output = new Matrix(passable.Size).Fill(int.MaxValue); + var output = new Matrix(passable.Size).Fill(WDist.MaxValue); var unprocessed = new PriorityArray(passable.Size.X * passable.Size.Y, int.MaxValue); foreach (var seed in seeds) unprocessed[passable.Index(seed)] = 0; @@ -182,11 +389,11 @@ namespace OpenRA.Mods.Common.MapGenerator var distance = unprocessed[i]; var xy = passable.XY(i); - if (distance > maxDistance) + if (distance > maxDistance.Length) break; - if (distance <= maxDistance && output.ContainsXY(xy)) - output[xy] = distance; + if (distance <= maxDistance.Length && output.ContainsXY(xy)) + output[xy] = new WDist(distance); unprocessed[i] = int.MaxValue; foreach (var (offset, direction) in DirectionExts.Spread8D) @@ -196,7 +403,7 @@ namespace OpenRA.Mods.Common.MapGenerator continue; if (!passable[nextXY]) continue; - if (output[nextXY] != int.MaxValue) + if (output[nextXY] != WDist.MaxValue) continue; int nextDistance; if (direction.IsDiagonal()) @@ -712,8 +919,10 @@ namespace OpenRA.Mods.Common.MapGenerator } /// - /// Uniformally add to or subtract from all cells such that count out of every outOf cells, - /// are no greater than the given target value. + /// Uniformally add to or subtract from all cells such that the quantile (count/outOf) has at the target value. + /// For example, (target: 0, count: 25, outOf: 75) where there are 401 cells would mean + /// that 100 cells are no greater than 0, 300 cells are no less than 0, and at least 1 cell + /// is 0. /// public static void CalibrateQuantileInPlace(Matrix matrix, int target, int count, int outOf) { @@ -724,6 +933,23 @@ namespace OpenRA.Mods.Common.MapGenerator matrix[i] += adjustment; } + /// + /// Return a boolean matrix where true correlates with the largest values in the input, + /// such that the fraction of true cells is at least (but approximately) count/outOf. + /// + public static Matrix CalibratedBooleanThreshold(Matrix input, int count, int outOf) + { + if (count <= 0) + return new Matrix(input.Size); + else if (count >= outOf) + return new Matrix(input.Size).Fill(true); + + var sorted = (int[])input.Data.Clone(); + Array.Sort(sorted); + var threshold = sorted[(long)sorted.Length * (outOf - count) / outOf]; + return input.Map(v => v >= threshold); + } + /// /// For true cells, gives the Chebyshev distance to the closest false cell. /// For false cells, gives the Chebyshev distance to the closest true cell as a negative. @@ -785,13 +1011,16 @@ namespace OpenRA.Mods.Common.MapGenerator /// /// Given a set of grid-intersection point arrays, creates a matrix where each cell /// identifies whether the closest points are wrapping around it clockwise or - /// counter-clockwise (as defined in MapUtils.Direction). + /// counter-clockwise (as defined in MapGenerator.Direction). /// /// /// Positive output values indicate the points are wrapping around it clockwise. /// Negative output values indicate the points are wrapping around it counter-clockwise. /// Outputs can be zero or non-unit magnitude if there are fighting point arrays. /// + /// + /// If no points are on or close enough to the matrix area, returns null. + /// /// public static Matrix PointsChirality(int2 size, IEnumerable pointArrayArray) { @@ -809,6 +1038,7 @@ namespace OpenRA.Mods.Common.MapGenerator } foreach (var pointArray in pointArrayArray) + { for (var i = 1; i < pointArray.Length; i++) { var from = pointArray[i - 1]; @@ -838,6 +1068,10 @@ namespace OpenRA.Mods.Common.MapGenerator throw new ArgumentException("Unsupported direction for chirality"); } } + } + + if (seeds.Count == 0) + return null; int? FillChirality(int2 point, int prop) { diff --git a/OpenRA.Mods.Common/MapGenerator/MultiBrush.cs b/OpenRA.Mods.Common/MapGenerator/MultiBrush.cs index 574db23aeb..806a9e21f6 100644 --- a/OpenRA.Mods.Common/MapGenerator/MultiBrush.cs +++ b/OpenRA.Mods.Common/MapGenerator/MultiBrush.cs @@ -538,22 +538,24 @@ namespace OpenRA.Mods.Common.MapGenerator case Replaceability.None: throw new ArgumentException("Cannot paint: Replaceability.None"); case Replaceability.Any: - if (this.actorPlans.Count > 0) - PaintActors(map, actorPlans, paintAt); - else if (tiles.Count > 0) - PaintTiles(map, paintAt, random); - else + if (this.actorPlans.Count == 0 && tiles.Count == 0) throw new ArgumentException("Cannot paint: no tiles or actors"); + + PaintTiles(map, paintAt, random); + PaintActors(map, actorPlans, paintAt); + break; case Replaceability.Tile: if (tiles.Count == 0) throw new ArgumentException("Cannot paint: no tiles"); + PaintTiles(map, paintAt, random); PaintActors(map, actorPlans, paintAt); break; case Replaceability.Actor: if (this.actorPlans.Count == 0) throw new ArgumentException("Cannot paint: no actors"); + PaintActors(map, actorPlans, paintAt); break; } diff --git a/OpenRA.Mods.Common/MapGenerator/NoiseUtils.cs b/OpenRA.Mods.Common/MapGenerator/NoiseUtils.cs index 651e16b9b1..b0b02edcf2 100644 --- a/OpenRA.Mods.Common/MapGenerator/NoiseUtils.cs +++ b/OpenRA.Mods.Common/MapGenerator/NoiseUtils.cs @@ -20,9 +20,24 @@ namespace OpenRA.Mods.Common.MapGenerator const int Scale = 1024; const int ScaledSqrt2 = 1448; + /// Amplitude is the same for all wavelengths. + public static int WhiteAmplitude(int wavelength) => 1; + /// Amplitude proportional to wavelength. public static int PinkAmplitude(int wavelength) => wavelength; + /// + /// amplitude = wavelength ** (1 / (2 ** clumpiness)) + /// Setting clumpiness to 0 is equivalent to pink noise. + /// + public static int ClumpinessAmplitude(int wavelength, int clumpiness) + { + var amplitude = wavelength; + for (var i = 0; i < clumpiness; i++) + amplitude = Exts.ISqrt(amplitude); + return amplitude; + } + /// /// /// Create noise by combining multiple layers of Perlin noise of halving wavelengths. diff --git a/OpenRA.Mods.Common/MapGenerator/PlayableSpace.cs b/OpenRA.Mods.Common/MapGenerator/PlayableSpace.cs deleted file mode 100644 index 0f31fd1391..0000000000 --- a/OpenRA.Mods.Common/MapGenerator/PlayableSpace.cs +++ /dev/null @@ -1,147 +0,0 @@ -#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.Collections.Generic; - -namespace OpenRA.Mods.Common.MapGenerator -{ - public static class PlayableSpace - { - public enum Playability - { - /// Area is unplayable by land/naval units. - Unplayable = 0, - - /// - /// Area is unplayable by land/naval units, but should count as - /// being "within" a playable region. This usually applies to random - /// rock or river tiles in largely passable templates. - /// - Partial = 1, - - /// - /// Area is playable by either land or naval units. - /// - Playable = 2, - } - - /// - /// Additional data for a region containing playable space. - /// The shape of a region is specified separately via a region mask. - /// - public sealed class Region - { - /// Area of playable and partially playable space. - public int Area; - - /// Area of fully playable space. - public int PlayableArea; - - /// Region ID. - public int Id; - } - - /// Sentinel indicating a position isn't assigned to a region. - public const int NullRegion = -1; - - /// - /// - /// Analyses a given map's tiles and ActorPlans and determines the playable space within it. - /// - /// - /// Requires a playabilityMap which specifies whether certain tiles are considered playable - /// or not. Actors are always considered partially playable. - /// - /// - /// RegionMap contains the mapping of map positions to Regions. If a map position is not - /// within a region, the value is NullRegion. - /// - /// - public static (Region[] Regions, CellLayer RegionMap, CellLayer Playable) FindPlayableRegions( - Map map, - List actorPlans, - Dictionary playabilityMap) - { - var regions = new List(); - var regionMap = new CellLayer(map); - regionMap.Clear(NullRegion); - var playable = new CellLayer(map); - playable.Clear(Playability.Unplayable); - foreach (var mpos in map.AllCells.MapCoords) - if (map.Contains(mpos)) - playable[mpos] = playabilityMap[map.Tiles[mpos]]; - - foreach (var actorPlan in actorPlans) - foreach (var cpos in actorPlan.Footprint().Keys) - if (map.AllCells.Contains(cpos)) - { - var mpos = cpos.ToMPos(map); - if (playable[mpos] == Playability.Playable) - playable[mpos] = Playability.Partial; - } - - void Fill(Region region, CPos start) - { - void AddToRegion(CPos cpos, bool fullyPlayable) - { - var mpos = cpos.ToMPos(map); - regionMap[mpos] = region.Id; - region.Area++; - if (fullyPlayable) - region.PlayableArea++; - } - - bool? Filler(CPos cpos, bool fullyPlayable) - { - var mpos = cpos.ToMPos(map); - if (regionMap[mpos] == NullRegion) - { - if (fullyPlayable && playable[mpos] == Playability.Playable) - { - AddToRegion(cpos, true); - return true; - } - else if (playable[mpos] == Playability.Partial) - { - AddToRegion(cpos, false); - return false; - } - } - - return null; - } - - CellLayerUtils.FloodFill( - playable, - [(start, true)], - Filler, - DirectionExts.Spread4CVec); - } - - foreach (var mpos in map.AllCells.MapCoords) - if (regionMap[mpos] == NullRegion && playable[mpos] == Playability.Playable) - { - var region = new Region() - { - Area = 0, - PlayableArea = 0, - Id = regions.Count, - }; - - regions.Add(region); - var cpos = mpos.ToCPos(map); - Fill(region, cpos); - } - - return (regions.ToArray(), regionMap, playable); - } - } -} diff --git a/OpenRA.Mods.Common/MapGenerator/Terraformer.cs b/OpenRA.Mods.Common/MapGenerator/Terraformer.cs new file mode 100644 index 0000000000..5c0ea80908 --- /dev/null +++ b/OpenRA.Mods.Common/MapGenerator/Terraformer.cs @@ -0,0 +1,1752 @@ +#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 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.Mirror Mirror; + 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; + Mirror = mirror; + 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. + /// + public void BakeMap() + { + var playerCount = ActorsOfType("mpspawn").Count(); + Map.PlayerDefinitions = new MapPlayers(Map.Rules, playerCount).ToMiniYaml(); + Map.ActorDefinitions = ActorPlans + .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, + Mirror, + (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; + } + + public void ZoneFromOutOfBounds(CellLayer zoneable, T value) + { + foreach (var mpos in Map.AllCells.MapCoords) + if (!Map.Contains(mpos)) + 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 || Mirror != Symmetry.Mirror.None) + { + // 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, + Mirror, + (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, + Mirror, + (CPos[] sources, CPos 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, + Mirror, + 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, Mirror); + 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) + { + brush.Paint(Map, ActorPlans, CPos.Zero, 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, + Mirror, + 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, + Mirror, + 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, + Mirror, + 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; + } + + /// + /// 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. + public CellLayer PaintLoopsAndFill( + MersenneTwister random, + IReadOnlyList tilingPaths, + Side fallback, + IReadOnlyList outside, + IReadOnlyList inside, + CellLayer replaceMask = 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, 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 mpos) => + sides[mpos] == fillSide && + !mask[mpos] && + Map.Contains(mpos)); + 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, + Mirror); + 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 = + Mirror != Symmetry.Mirror.None || + 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, Mirror)) + .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)))) + .ToImmutableHashSet(); + + 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, + (byte)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, Mirror)) + 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, + Mirror, + featureSize, + NoiseUtils.WhiteAmplitude); + + var densityNoise = new CellLayer(Map); + NoiseUtils.SymmetricFractalNoiseIntoCellLayer( + random, + densityNoise, + Rotations, + Mirror, + 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; + } + } +} diff --git a/OpenRA.Mods.Common/MapGenerator/TilingPath.cs b/OpenRA.Mods.Common/MapGenerator/TilingPath.cs index a81f8b939d..5feba7a985 100644 --- a/OpenRA.Mods.Common/MapGenerator/TilingPath.cs +++ b/OpenRA.Mods.Common/MapGenerator/TilingPath.cs @@ -241,6 +241,53 @@ namespace OpenRA.Mods.Common.MapGenerator Brushes = permittedTemplates; } + /// + /// Convenience method to create TilingPaths using common settings. + /// Start and end terminal types are the same. + /// PermittedSegments are derived from brushes and inner/terminal types. + /// Loops will automatically use only the inner type. + /// Uses automatic end deviation and loop optimization. + /// + public static TilingPath QuickCreate( + Map map, + IReadOnlyList brushes, + CPos[] points, + int maxDeviation, + string innerSegmentType, + string terminalSegmentType) + { + var nonLoopedRoadPermittedTemplates = + PermittedSegments.FromInnerAndTerminalTypes( + brushes, [innerSegmentType], [terminalSegmentType]); + var loopedRoadPermittedTemplates = + PermittedSegments.FromType(brushes, [innerSegmentType]); + + var isLoop = points[0] == points[^1]; + TilingPath path; + if (isLoop) + path = new TilingPath( + map, + points, + maxDeviation, + innerSegmentType, + innerSegmentType, + loopedRoadPermittedTemplates); + else + path = new TilingPath( + map, + points, + maxDeviation, + terminalSegmentType, + terminalSegmentType, + nonLoopedRoadPermittedTemplates); + + path + .SetAutoEndDeviation() + .OptimizeLoop(); + + return path; + } + sealed class TilingSegment { public readonly MultiBrush MultiBrush; @@ -1360,6 +1407,46 @@ namespace OpenRA.Mods.Common.MapGenerator return true; } + /// Applies StraightenEndsPathPoints to this TilingPath, returning this. + public TilingPath StraightenEnds( + int shrink, + int grow, + int minimumLength, + int growthInertialRange) + { + Points = StraightenEndsPathPoints( + Points, + CellLayerUtils.CellBounds(Map), + shrink, + grow, + minimumLength, + growthInertialRange); + return this; + } + + /// + /// Straighten the start and end of a path by shrinking and regrowing a straight section. + /// + /// Points of the path. + /// Map bounds, used to identify paths touching edges. + /// Distance to shrink path ends (before regrowing them). + /// Distance to regrow path ends with straightening (after shrinking). + /// The minimum length (after shrinking, before growth) that paths may be. + /// How many points are used to decide the regrowth direction. + public static CPos[] StraightenEndsPathPoints( + CPos[] points, + Rectangle bounds, + int shrink, + int grow, + int minimumLength, + int growthInertialRange) + { + points = ExtendEdgePathPoints(points, bounds, 2 * shrink + minimumLength); + points = ShrinkPathPoints(points, shrink, minimumLength); + points = InertiallyExtendPathPoints(points, grow, growthInertialRange); + return points; + } + /// Set MaxEndDeviation. public TilingPath SetMaxEndDeviation(int maxEndDeviation) { diff --git a/OpenRA.Mods.Common/Traits/World/ClearMapGenerator.cs b/OpenRA.Mods.Common/Traits/World/ClearMapGenerator.cs index 63a40590ed..d2381a4f0b 100644 --- a/OpenRA.Mods.Common/Traits/World/ClearMapGenerator.cs +++ b/OpenRA.Mods.Common/Traits/World/ClearMapGenerator.cs @@ -9,11 +9,9 @@ */ #endregion -using System; using System.Collections.Generic; using System.Linq; using OpenRA.Mods.Common.MapGenerator; -using OpenRA.Mods.Common.Terrain; using OpenRA.Support; using OpenRA.Traits; @@ -76,43 +74,21 @@ namespace OpenRA.Mods.Common.Traits var random = new MersenneTwister(); var terrainInfo = modData.DefaultTerrainInfo[args.Tileset]; - var map = new Map(modData, terrainInfo, args.Size); - var maxTerrainHeight = map.Grid.MaximumTerrainHeight; - var tl = new PPos(1, 1 + maxTerrainHeight); - var br = new PPos(args.Size.Width - 1, args.Size.Height + maxTerrainHeight - 1); - map.SetBounds(tl, br); - if (!Exts.TryParseUshortInvariant(args.Settings.NodeWithKey("Tile").Value.Value, out var tileType)) throw new YamlException("Illegal tile type"); - var tile = new TerrainTile(tileType, 0); - if (!terrainInfo.TryGetTerrainInfo(tile, out var _)) + if (!terrainInfo.TryGetTerrainInfo(new TerrainTile(tileType, 0), out var _)) throw new MapGenerationException("Illegal tile type"); - // If the default terrain tile is part of a PickAny template, pick - // a random tile index. Otherwise, just use the default tile. - Func tilePicker; - if (map.Rules.TerrainInfo is ITemplatedTerrainInfo templatedTerrainInfo && - templatedTerrainInfo.Templates.TryGetValue(tileType, out var template) && - template.PickAny) - { - tilePicker = () => new TerrainTile(tileType, (byte)random.Next(0, template.TilesCount)); - } - else - { - tilePicker = () => tile; - } + var map = new Map(modData, terrainInfo, args.Size); + var terraformer = new Terraformer(args, map, modData, [], Symmetry.Mirror.None, 1); - foreach (var cell in map.AllCells) - { - var mpos = cell.ToMPos(map); - map.Tiles[mpos] = tilePicker(); - map.Resources[mpos] = new ResourceTile(0, 0); - map.Height[mpos] = 0; - } + terraformer.InitMap(); - map.PlayerDefinitions = new MapPlayers(map.Rules, 0).ToMiniYaml(); - map.ActorDefinitions = []; + foreach (var mpos in map.AllCells.MapCoords) + map.Tiles[mpos] = terraformer.PickTile(random, tileType); + + terraformer.BakeMap(); return map; } diff --git a/OpenRA.Mods.Common/Traits/World/ExperimentalMapGenerator.cs b/OpenRA.Mods.Common/Traits/World/ExperimentalMapGenerator.cs index d586337feb..1871e4ceeb 100644 --- a/OpenRA.Mods.Common/Traits/World/ExperimentalMapGenerator.cs +++ b/OpenRA.Mods.Common/Traits/World/ExperimentalMapGenerator.cs @@ -15,7 +15,6 @@ using System.Collections.Immutable; using System.Linq; using OpenRA.Mods.Common.MapGenerator; using OpenRA.Mods.Common.Terrain; -using OpenRA.Primitives; using OpenRA.Support; using OpenRA.Traits; using static OpenRA.Mods.Common.Traits.ResourceLayerInfo; @@ -67,7 +66,7 @@ namespace OpenRA.Mods.Common.Traits .Options.SelectMany(o => o.GetFluentReferences()).ToList(); } - const int FractionMax = 1000; + const int FractionMax = Terraformer.FractionMax; const int EntityBonusMax = 1000000; sealed class Parameters @@ -200,15 +199,9 @@ namespace OpenRA.Mods.Common.Traits [FieldLoader.Ignore] public readonly IReadOnlyDictionary> RepaintTiles; - [FieldLoader.Ignore] - public readonly IReadOnlyDictionary ResourceTypes; [FieldLoader.Ignore] public readonly ResourceTypeInfo DefaultResource; [FieldLoader.Ignore] - public readonly IReadOnlyDictionary ResourceValues; - [FieldLoader.Ignore] - public readonly IReadOnlySet<(ResourceTypeInfo, byte)> AllowedTerrainResourceCombos; - [FieldLoader.Ignore] public readonly IReadOnlyDictionary ResourceSpawnSeeds; [FieldLoader.LoadUsing(nameof(ResourceSpawnWeightsLoader))] public readonly IReadOnlyDictionary ResourceSpawnWeights = default; @@ -218,16 +211,10 @@ namespace OpenRA.Mods.Common.Traits [FieldLoader.Ignore] public readonly IReadOnlySet PlayableTerrain; [FieldLoader.Ignore] - public readonly IReadOnlySet PartiallyPlayableTerrain; - [FieldLoader.Ignore] - public readonly IReadOnlySet UnplayableTerrain; - [FieldLoader.Ignore] public readonly IReadOnlySet DominantTerrain; [FieldLoader.Ignore] public readonly IReadOnlySet ZoneableTerrain; [FieldLoader.Ignore] - public readonly IReadOnlySet PartiallyPlayableCategories; - [FieldLoader.Ignore] public readonly IReadOnlyList ClearSegmentTypes; [FieldLoader.Ignore] public readonly IReadOnlyList BeachSegmentTypes; @@ -256,22 +243,15 @@ namespace OpenRA.Mods.Common.Traits v => MultiBrush.LoadCollection(map, v.Value) as IReadOnlyList); RepaintTiles ??= ImmutableDictionary>.Empty; - ResourceTypes = map.Rules.Actors[SystemActors.World].TraitInfoOrDefault().ResourceTypes; - if (!ResourceTypes.TryGetValue(my.NodeWithKey("DefaultResource").Value.Value, out DefaultResource)) + var resourceTypes = map.Rules.Actors[SystemActors.World].TraitInfoOrDefault().ResourceTypes; + if (!resourceTypes.TryGetValue(my.NodeWithKey("DefaultResource").Value.Value, out DefaultResource)) throw new YamlException("DefaultResource is not valid"); var playerResourcesInfo = map.Rules.Actors[SystemActors.Player].TraitInfoOrDefault(); - ResourceValues = playerResourcesInfo.ResourceValues - .ToDictionary(kv => ResourceTypes[kv.Key], kv => kv.Value); - AllowedTerrainResourceCombos = ResourceTypes - .Values - .SelectMany(resourceTypeInfo => resourceTypeInfo.AllowedTerrainTypes - .Select(terrainName => (resourceTypeInfo, terrainInfo.GetTerrainIndex(terrainName)))) - .ToImmutableHashSet(); try { ResourceSpawnSeeds = my.NodeWithKey("ResourceSpawnSeeds").Value .ToDictionary(subMy => subMy.Value) - .ToDictionary(kv => kv.Key, kv => ResourceTypes[kv.Value]); + .ToDictionary(kv => kv.Key, kv => resourceTypes[kv.Value]); } catch (KeyNotFoundException e) { @@ -306,15 +286,9 @@ namespace OpenRA.Mods.Common.Traits ClearTerrain = ParseTerrainIndexes("ClearTerrain"); PlayableTerrain = ParseTerrainIndexes("PlayableTerrain"); - PartiallyPlayableTerrain = ParseTerrainIndexes("PartiallyPlayableTerrain"); - UnplayableTerrain = ParseTerrainIndexes("UnplayableTerrain"); DominantTerrain = ParseTerrainIndexes("DominantTerrain"); ZoneableTerrain = ParseTerrainIndexes("ZoneableTerrain"); - PartiallyPlayableCategories = my.NodeWithKey("PartiallyPlayableCategories").Value.Value - .Split(',', StringSplitOptions.RemoveEmptyEntries) - .ToImmutableHashSet(); - ClearSegmentTypes = ParseSegmentTypes("ClearSegmentTypes"); BeachSegmentTypes = ParseSegmentTypes("BeachSegmentTypes"); CliffSegmentTypes = ParseSegmentTypes("CliffSegmentTypes"); @@ -475,18 +449,6 @@ namespace OpenRA.Mods.Common.Traits if (Players % symmetryCount != 0) throw new MapGenerationException($"Total number of players must be a multiple of {symmetryCount}"); } - - public static (T[] Types, int[] Weights) SplitWeights(IReadOnlyDictionary typeWeights) - { - var types = typeWeights - .Select(kv => kv.Key) - .Order() - .ToArray(); - var weights = types - .Select(type => typeWeights[type]) - .ToArray(); - return (types, weights); - } } public IMapGeneratorSettings GetSettings() @@ -496,83 +458,41 @@ namespace OpenRA.Mods.Common.Traits public Map Generate(ModData modData, MapGenerationArgs args) { - const int ExternalBias = 4096; - var terrainInfo = modData.DefaultTerrainInfo[args.Tileset]; var size = args.Size; var map = new Map(modData, terrainInfo, size); - var maxTerrainHeight = map.Grid.MaximumTerrainHeight; - var tl = new PPos(1, 1 + maxTerrainHeight); - var br = new PPos(size.Width - 1, size.Height + maxTerrainHeight - 1); - map.SetBounds(tl, br); - map.Title = args.Title; - map.Author = args.Author; - map.RequiresMod = modData.Manifest.Id; - - var minSpan = Math.Min(size.Width, size.Height); - var mapCenter1024ths = new int2(size.Width * 512, size.Height * 512); - var wMapCenter = CellLayerUtils.Center(map.Tiles); - var matrixMapCenter1024ths = CellLayerUtils.CellBounds(map).Size.ToInt2() * 512; - var cellBounds = CellLayerUtils.CellBounds(map); - var minCSpan = Math.Min(cellBounds.Size.Width, cellBounds.Size.Height); - var gridType = map.Grid.Type; - var actorPlans = new List(); var param = new Parameters(map, args.Settings); - var externalCircleRadius = minCSpan / 2 - (param.MinimumLandSeaThickness + param.MinimumMountainThickness); - if (externalCircleRadius <= 0) + var terraformer = new Terraformer(args, map, modData, actorPlans, param.Mirror, param.Rotations); + + var waterIsPlayable = param.PlayableTerrain.Contains(terrainInfo.GetTerrainIndex(new TerrainTile(param.WaterTile, 0))); + + var externalCircleRadius = CellLayerUtils.Radius(map) - new WDist((param.MinimumLandSeaThickness + param.MinimumMountainThickness) * 1024); + if (param.ExternalCircularBias != 0 && externalCircleRadius.Length <= 0) throw new MapGenerationException("map is too small for circular shaping"); - var beachPermittedTemplates = TilingPath.PermittedSegments.FromType(param.SegmentedBrushes, param.BeachSegmentTypes); - var replaceabilityMap = new Dictionary(); - var playabilityMap = new Dictionary(); - - var templatedTerrainInfo = (ITemplatedTerrainInfo)terrainInfo; - foreach (var kv in templatedTerrainInfo.Templates) + CellLayer PlayableToReplaceable() { - var id = kv.Key; - var template = kv.Value; - for (var ti = 0; ti < template.TilesCount; ti++) - { - if (template[ti] == null) - continue; - var tile = new TerrainTile(id, (byte)ti); - var type = terrainInfo.GetTerrainIndex(tile); - - if (param.PlayableTerrain.Contains(type)) - playabilityMap[tile] = PlayableSpace.Playability.Playable; - else if (param.PartiallyPlayableTerrain.Contains(type)) - playabilityMap[tile] = PlayableSpace.Playability.Partial; - else if (param.UnplayableTerrain.Contains(type)) - playabilityMap[tile] = PlayableSpace.Playability.Unplayable; - else - throw new MapGenerationException($"Terrain index {type} has unknown playability."); - - if (id == param.LandTile) + var playable = terraformer.CheckSpace(param.PlayableTerrain, true); + var basicLand = terraformer.CheckSpace(param.LandTile); + var replace = new CellLayer(map); + foreach (var mpos in map.AllCells.MapCoords) + if (playable[mpos]) { - replaceabilityMap[tile] = MultiBrush.Replaceability.Any; - } - else if (id == param.WaterTile) - { - replaceabilityMap[tile] = MultiBrush.Replaceability.Tile; - } - else - { - if (playabilityMap[tile] == PlayableSpace.Playability.Unplayable) - replaceabilityMap[tile] = MultiBrush.Replaceability.None; + if (basicLand[mpos]) + replace[mpos] = MultiBrush.Replaceability.Any; else - replaceabilityMap[tile] = MultiBrush.Replaceability.Actor; - - if (param.PartiallyPlayableCategories.Overlaps(template.Categories) - && playabilityMap[tile] == PlayableSpace.Playability.Unplayable) - { - playabilityMap[tile] = PlayableSpace.Playability.Partial; - } + replace[mpos] = MultiBrush.Replaceability.Actor; } - } + else + { + replace[mpos] = MultiBrush.Replaceability.None; + } + + return replace; } // Use `random` to derive separate independent random number generators. @@ -585,7 +505,7 @@ namespace OpenRA.Mods.Common.Traits // random.Next(). All generators should be created unconditionally. var random = new MersenneTwister(param.Seed); - var waterRandom = new MersenneTwister(random.Next()); + var elevationRandom = new MersenneTwister(random.Next()); var beachTilingRandom = new MersenneTwister(random.Next()); var cliffTilingRandom = new MersenneTwister(random.Next()); var forestRandom = new MersenneTwister(random.Next()); @@ -603,197 +523,91 @@ namespace OpenRA.Mods.Common.Traits var decorationTilingRandom = new MersenneTwister(random.Next()); var pickAnyRandom = new MersenneTwister(random.Next()); - TerrainTile PickTile(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); - } + terraformer.InitMap(); - foreach (var cell in map.AllCells) - { - var mpos = cell.ToMPos(gridType); - map.Tiles[mpos] = PickTile(param.LandTile); - map.Resources[mpos] = new ResourceTile(0, 0); - map.Height[mpos] = 0; - } + foreach (var mpos in map.AllCells.MapCoords) + map.Tiles[mpos] = terraformer.PickTile(pickAnyRandom, param.LandTile); - var elevation = NoiseUtils.SymmetricFractalNoise( - waterRandom, - cellBounds.Size.ToInt2(), - param.Rotations, - param.Mirror, + var elevation = terraformer.ElevationNoiseMatrix( + elevationRandom, param.TerrainFeatureSize, - NoiseUtils.PinkAmplitude); - MatrixUtils.NormalizeRangeInPlace(elevation, 1024); + param.TerrainSmoothing); - if (param.TerrainSmoothing > 0) + Matrix mapShape; + if (param.ExternalCircularBias == 0) + mapShape = new Matrix(CellLayerUtils.CellBounds(map).Size.ToInt2()).Fill(true); + else + mapShape = CellLayerUtils.ToMatrix(terraformer.CenteredCircle(true, false, externalCircleRadius), false); + + var landPlan = terraformer.SliceElevation(elevation, mapShape, FractionMax - param.Water); + + if (param.ExternalCircularBias > 0) { - var radius = param.TerrainSmoothing; - elevation = MatrixUtils.BinomialBlur(elevation, radius); + for (var n = 0; n < landPlan.Data.Length; n++) + landPlan[n] |= !mapShape[n]; + var ring = terraformer.CenteredCircle(false, true, externalCircleRadius + new WDist(param.MinimumMountainThickness * 1024)); + var path = TilingPath.QuickCreate( + map, + param.SegmentedBrushes, + CellLayerUtils.BordersToPoints(ring)[0], + (param.MinimumMountainThickness - 1) / 2, + param.CliffSegmentTypes[0], + param.CliffSegmentTypes[0]); + var brush = path.Tile(cliffTilingRandom) + ?? throw new MapGenerationException("Could not fit tiles for exterior circle cliffs"); + terraformer.PaintTiling(pickAnyRandom, brush); } - MatrixUtils.CalibrateQuantileInPlace( - elevation, - 0, - param.Water, FractionMax); - - if (param.ExternalCircularBias != 0) - MatrixUtils.OverCircle( - matrix: elevation, - centerIn1024ths: mapCenter1024ths, - radiusIn1024ths: externalCircleRadius * 1024, - outside: true, - action: (xy, _) => elevation[xy] = param.ExternalCircularBias * ExternalBias); - - var landPlan = MatrixUtils.BooleanBlotch( - elevation.Map(v => v >= 0), + landPlan = MatrixUtils.BooleanBlotch( + landPlan, param.TerrainSmoothing, - param.SmoothingThreshold, FractionMax, + param.SmoothingThreshold, /*smoothingThresholdOutOf=*/FractionMax, param.MinimumLandSeaThickness, - /*bias=*/param.Water < FractionMax / 2); + /*bias=*/param.Water <= FractionMax / 2); var beaches = CellLayerUtils.FromMatrixPoints( MatrixUtils.BordersToPoints(landPlan), map.Tiles); - var beachesShape = new HashSet(); - if (beaches.Length > 0) - { - var tiledBeaches = new CPos[beaches.Length][]; - for (var i = 0; i < beaches.Length; i++) - { - var beachPath = new TilingPath( - map, - beaches[i], - (param.MinimumLandSeaThickness - 1) / 2, - param.BeachSegmentTypes[0], - param.BeachSegmentTypes[0], - beachPermittedTemplates); - beachPath - .ExtendEdge(4) - .SetAutoEndDeviation() - .OptimizeLoop(); - var brush = beachPath.Tile(beachTilingRandom) - ?? throw new MapGenerationException("Could not fit tiles for beach"); - brush.Paint(map, actorPlans, CPos.Zero, MultiBrush.Replaceability.Tile, pickAnyRandom); - tiledBeaches[i] = brush.Segment.Points.Select(vec => CPos.Zero + vec).ToArray(); - foreach (var cvec in brush.Shape) - beachesShape.Add(CPos.Zero + cvec); - } - - var beachChiralityMatrix = MatrixUtils.PointsChirality( - landPlan.Size, - CellLayerUtils.ToMatrixPoints(tiledBeaches, map.Tiles)); - var beachChirality = new CellLayer(map); - CellLayerUtils.FromMatrix(beachChirality, beachChiralityMatrix); - foreach (var mpos in map.AllCells.MapCoords) - { - // `map.Tiles[mpos].Type == param.LandTile` avoids overwriting beach tiles. - if (beachChirality[mpos] < 0 && !beachesShape.Contains(mpos.ToCPos(map))) - map.Tiles[mpos] = PickTile(param.WaterTile); - } - } - else - { - // There weren't any coastlines - var tileType = landPlan[0] ? param.LandTile : param.WaterTile; - foreach (var cell in map.AllCells) - { - var mpos = cell.ToMPos(gridType); - map.Tiles[mpos] = PickTile(tileType); - } - } - - var nonLoopedCliffPermittedTemplates = - TilingPath.PermittedSegments.FromInnerAndTerminalTypes( - param.SegmentedBrushes, param.CliffSegmentTypes, param.ClearSegmentTypes); - var loopedCliffPermittedTemplates = - TilingPath.PermittedSegments.FromType( - param.SegmentedBrushes, param.CliffSegmentTypes); - if (param.ExternalCircularBias > 0) - { - var cliffRing = new CellLayer(map); - CellLayerUtils.OverCircle( - cellLayer: cliffRing, - wCenter: wMapCenter, - wRadius: (externalCircleRadius + param.MinimumMountainThickness) * 1024, - outside: true, - action: (mpos, _, _, _) => cliffRing[mpos] = true); - foreach (var cliff in CellLayerUtils.BordersToPoints(cliffRing)) - { - var isLoop = cliff[0] == cliff[^1]; - TilingPath cliffPath; - if (isLoop) - cliffPath = new TilingPath( + var beachPaths = beaches + .Select(beach => + TilingPath.QuickCreate( map, - cliff, - (param.MinimumMountainThickness - 1) / 2, - param.CliffSegmentTypes[0], - param.CliffSegmentTypes[0], - loopedCliffPermittedTemplates); - else - cliffPath = new TilingPath( - map, - cliff, - (param.MinimumMountainThickness - 1) / 2, - param.ClearSegmentTypes[0], - param.ClearSegmentTypes[0], - nonLoopedCliffPermittedTemplates); - cliffPath - .ExtendEdge(4) - .SetAutoEndDeviation() - .OptimizeLoop(); - var brush = cliffPath.Tile(cliffTilingRandom) - ?? throw new MapGenerationException("Could not fit tiles for exterior circle cliffs"); - brush.Paint(map, actorPlans, CPos.Zero, MultiBrush.Replaceability.Tile, pickAnyRandom); - } - } + param.SegmentedBrushes, + beach, + (param.MinimumLandSeaThickness - 1) / 2, + param.BeachSegmentTypes[0], + param.BeachSegmentTypes[0]) + .ExtendEdge(4)) + .ToArray(); + var landBeachWater = terraformer.PaintLoopsAndFill( + beachTilingRandom, + beachPaths, + landPlan[0] ? Terraformer.Side.In : Terraformer.Side.Out, + [new MultiBrush().WithTemplate(map, param.WaterTile, CVec.Zero)], + null) + ?? throw new MapGenerationException("Could not fit tiles for beach"); - if (param.Mountains > 0 || param.ExternalCircularBias == 1) + if (param.Mountains > 0) { var roughnessMatrix = MatrixUtils.GridVariance( elevation, param.RoughnessRadius); - MatrixUtils.CalibrateQuantileInPlace( + var cliffMask = MatrixUtils.CalibratedBooleanThreshold( roughnessMatrix, - 0, - FractionMax - param.Roughness, FractionMax); - var cliffMask = roughnessMatrix.Map(v => v >= 0); - var mountainElevation = elevation.Clone(); - var cliffPlan = landPlan; - if (param.ExternalCircularBias > 0) - MatrixUtils.OverCircle( - matrix: cliffPlan, - centerIn1024ths: matrixMapCenter1024ths, - radiusIn1024ths: externalCircleRadius * 1024, - outside: true, - action: (xy, _) => cliffPlan[xy] = false); + param.Roughness, FractionMax); + var cliffPlan = Matrix.Zip(landPlan, mapShape, (a, b) => a && b); - for (var altitude = 1; altitude <= param.MaximumAltitude; altitude++) + for (var altitude = 0; altitude < param.MaximumAltitude; altitude++) { - // Limit mountain area to the existing mountain space (starting with all available land) - var roominess = MatrixUtils.ChebyshevRoom(cliffPlan, true); - var available = 0; - var total = size.Width * size.Height; - for (var n = 0; n < mountainElevation.Data.Length; n++) - { - if (roominess.Data[n] < param.MinimumTerrainContourSpacing) - mountainElevation.Data[n] = -1; - else - available++; - - total++; - } - - MatrixUtils.CalibrateQuantileInPlace( - mountainElevation, - 0, - total - available * param.Mountains / FractionMax, total); + cliffPlan = terraformer.SliceElevation( + elevation, + cliffPlan, + param.Mountains, + param.MinimumTerrainContourSpacing); cliffPlan = MatrixUtils.BooleanBlotch( - mountainElevation.Map(v => v >= 0), + cliffPlan, param.TerrainSmoothing, - param.SmoothingThreshold, FractionMax, + param.SmoothingThreshold, /*smoothingThresholdOutOf=*/FractionMax, param.MinimumMountainThickness, /*bias=*/false); var unmaskedCliffs = MatrixUtils.BordersToPoints(cliffPlan); @@ -804,543 +618,129 @@ namespace OpenRA.Mods.Common.Traits break; foreach (var cliff in cliffs) { - var isLoop = cliff[0] == cliff[^1]; - TilingPath cliffPath; - if (isLoop) - cliffPath = new TilingPath( - map, - cliff, - (param.MinimumMountainThickness - 1) / 2, - param.CliffSegmentTypes[0], - param.CliffSegmentTypes[0], - loopedCliffPermittedTemplates); - else - cliffPath = new TilingPath( - map, - cliff, - (param.MinimumMountainThickness - 1) / 2, - param.ClearSegmentTypes[0], - param.ClearSegmentTypes[0], - nonLoopedCliffPermittedTemplates); - cliffPath - .ExtendEdge(4) - .SetAutoEndDeviation() - .OptimizeLoop(); + var cliffPath = TilingPath.QuickCreate( + map, + param.SegmentedBrushes, + cliff, + (param.MinimumMountainThickness - 1) / 2, + param.CliffSegmentTypes[0], + param.ClearSegmentTypes[0]) + .ExtendEdge(4); var brush = cliffPath.Tile(cliffTilingRandom) - ?? throw new MapGenerationException("Could not fit tiles for cliffs"); - brush.Paint(map, actorPlans, CPos.Zero, MultiBrush.Replaceability.Tile, pickAnyRandom); + ?? throw new MapGenerationException("Could not fit tiles for cliffs"); + terraformer.PaintTiling(pickAnyRandom, brush); } } } if (param.Forests > 0) { - var forestNoise = new CellLayer(map); - NoiseUtils.SymmetricFractalNoiseIntoCellLayer( + var space = terraformer.CheckSpace(param.ClearTerrain); + var passages = terraformer.PlanPassages( + topologyRandom, + terraformer.ImproveSymmetry(space, true, (a, b) => a && b), + param.ForestCutout, + param.MaximumCutoutSpacing); + var forestNoise = terraformer.BooleanNoise( forestRandom, - forestNoise, - param.Rotations, - param.Mirror, param.ForestFeatureSize, - wavelength => ClumpinessAmplitude(wavelength, param.ForestClumpiness)); - CellLayerUtils.CalibrateQuantileInPlace( - forestNoise, - 0, - FractionMax - param.Forests, FractionMax); - - var forestPlan = new CellLayer(map); + param.Forests, + param.ForestClumpiness); + var replace = PlayableToReplaceable(); foreach (var mpos in map.AllCells.MapCoords) - if (param.ClearTerrain.Contains(map.GetTerrainIndex(mpos)) && forestNoise[mpos] >= 0) - forestPlan[mpos] = true; - - if (param.ForestCutout > 0) - { - var space = new CellLayer(map); - foreach (var mpos in map.AllCells.MapCoords) - space[mpos] = param.ClearTerrain.Contains(map.GetTerrainIndex(mpos)); - - // Improve symmetry. - { - var newSpace = new CellLayer(map); - Symmetry.RotateAndMirrorOverCPos( - space, - param.Rotations, - param.Mirror, - (sources, destination) - => newSpace[destination] = - sources.All(source => !space.TryGetValue(source, out var value) || value)); - space = newSpace; - } - - if (param.MaximumCutoutSpacing > 0) - { - var roominess = new CellLayer(map); - CellLayerUtils.ChebyshevRoom(roominess, space, false); - foreach (var mpos in map.AllCells.MapCoords) - roominess[mpos] = Math.Min( - param.MaximumCutoutSpacing, - roominess[mpos]); - - while (true) - { - var (chosenMPos, room) = CellLayerUtils.FindRandomBest( - roominess, - topologyRandom, - (a, b) => a.CompareTo(b)); - if (room < param.MaximumCutoutSpacing) - break; - - var projections = Symmetry.RotateAndMirrorCPos( - chosenMPos.ToCPos(map), - space, - param.Rotations, - param.Mirror); - foreach (var projection in projections) - { - if (space.Contains(projection)) - space[projection] = false; - var minX = projection.X - 2 * param.MaximumCutoutSpacing + 1; - var minY = projection.Y - 2 * param.MaximumCutoutSpacing + 1; - var maxX = projection.X + 2 * param.MaximumCutoutSpacing - 1; - var maxY = projection.Y + 2 * param.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 * param.ForestCutout, 2 * param.ForestCutout).Fill(true); - var inflated = MatrixUtils.KernelDilateOrErode(deflated.Map(v => v != 0), kernel, new int2(param.ForestCutout - 1, param.ForestCutout - 1), true); - var cutout = new CellLayer(map); - CellLayerUtils.FromMatrix(cutout, inflated, true); - foreach (var mpos in map.AllCells.MapCoords) - if (cutout[mpos]) - forestPlan[mpos] = false; - } - - var replaceable = IdentifyReplaceableTiles(map, replaceabilityMap, null); - var forestReplace = new CellLayer(map); - foreach (var mpos in map.AllCells.MapCoords) - if (forestPlan[mpos]) - forestReplace[mpos] = replaceable[mpos]; - else - forestReplace[mpos] = MultiBrush.Replaceability.None; - MultiBrush.PaintArea(map, actorPlans, forestReplace, param.ForestObstacles, forestTilingRandom); + if (!forestNoise[mpos] || !space[mpos] || passages[mpos]) + replace[mpos] = MultiBrush.Replaceability.None; + terraformer.PaintArea(forestTilingRandom, replace, param.ForestObstacles); } if (param.EnforceSymmetry != 0) { - // This is not commutative. - bool CheckCompatibility(byte main, byte other) - { - if (main == other) - return true; - else if (param.DominantTerrain.Contains(main)) - return true; - else if (param.DominantTerrain.Contains(other)) - return false; - else - return param.EnforceSymmetry < 2; - } - - var replace = new CellLayer(map); - Symmetry.RotateAndMirrorOverCPos( - replace, - param.Rotations, - param.Mirror, - (CPos[] sources, CPos destination) => - { - var main = templatedTerrainInfo.GetTerrainIndex(map.Tiles[destination]); - var compatible = sources - .Where(replace.Contains) - .Select(source => templatedTerrainInfo.GetTerrainIndex(map.Tiles[source])) - .All(source => CheckCompatibility(main, source)); - replace[destination] = compatible ? MultiBrush.Replaceability.None : MultiBrush.Replaceability.Actor; - }); - MultiBrush.PaintArea(map, actorPlans, replace, param.ForestObstacles, symmetryTilingRandom); + var asymmetries = terraformer.FindAsymmetries(param.DominantTerrain, true, param.EnforceSymmetry == 2); + terraformer.PaintActors(symmetryTilingRandom, asymmetries, param.ForestObstacles); } - var playableArea = new CellLayer(map); + CellLayer playable; { - var (regions, regionMask, playability) = PlayableSpace.FindPlayableRegions(map, actorPlans, playabilityMap); - PlayableSpace.Region largest = null; - var disqualifications = new HashSet(); - // For circle-in-mountains, the outside is unplayable and should never count as // the largest/preferred region. + CellLayer poison = null; if (param.ExternalCircularBias > 0) - { - if (map.Grid.Type != MapGridType.Rectangular) - throw new NotImplementedException(); - CellLayerUtils.OverCircle( - cellLayer: regionMask, - wCenter: wMapCenter, - wRadius: (minSpan - 2) * 512, - outside: true, - action: (mpos, _, _, _) => - { - if (regionMask[mpos] != PlayableSpace.NullRegion) - disqualifications.Add(regionMask[mpos]); - }); - } + poison = terraformer.CenteredCircle( + false, true, CellLayerUtils.Radius(map.Tiles) - new WDist(1024)); - // Disqualify regions that violate any symmetry requirements. - { - var symmetryScore = new int[regions.Length]; - void TestSymmetry(CPos[] sources, CPos destination) - { - var id = regionMask[destination]; - if (playability[destination] != PlayableSpace.Playability.Playable) - return; - if (sources.All(source => regionMask.TryGetValue(source, out var sourceId) && sourceId == id)) - symmetryScore[id]++; - } - - Symmetry.RotateAndMirrorOverCPos( - regionMask, - param.Rotations, - param.Mirror, - TestSymmetry); - - for (var id = 0; id < symmetryScore.Length; id++) - if (symmetryScore[id] < regions[id].PlayableArea / 2) - disqualifications.Add(id); - } - - foreach (var region in regions) - { - if (disqualifications.Contains(region.Id)) - continue; - if (largest == null || region.PlayableArea > largest.PlayableArea) - largest = region; - } - - if (largest == null) - throw new MapGenerationException("could not find a playable region"); + playable = terraformer.ChoosePlayableRegion( + terraformer.CheckSpace(param.PlayableTerrain, true, false, true), + poison) + ?? throw new MapGenerationException("could not find a playable region"); var minimumPlayableSpace = (int)(param.Players * Math.PI * param.SpawnBuildSize * param.SpawnBuildSize); - if (largest.PlayableArea < minimumPlayableSpace) + if (playable.Count(p => p) < minimumPlayableSpace) throw new MapGenerationException("playable space is too small"); - bool? AdoptPartiallyPlayableIntoLargest(CPos cpos, bool first) - { - if (first) - return false; - else if (regionMask[cpos] == largest.Id || playability[cpos] != PlayableSpace.Playability.Partial) - return null; - - regionMask[cpos] = largest.Id; - largest.Area++; - return false; - } - - // Adopt any partially playable tiles connected to the largest region into the largest region. - // This avoids potentially debris-filling connected oceans for mods where water is unplayable. - CellLayerUtils.FloodFill( - regionMask, - map.AllCells.Where(cpos => regionMask[cpos] == largest.Id).Select(cpos => (cpos, true)), - AdoptPartiallyPlayableIntoLargest, - DirectionExts.Spread4CVec); - if (param.DenyWalledAreas) { // Beach tiles are particularly problematic. If they're for unplayable bodies // of water, they should be obliterated. If they're just surrounded by rocks, // trees, etc, they should be filled in with actors. + if (waterIsPlayable) { - var unplayableWater = new HashSet(); - foreach (var mpos in map.AllCells.MapCoords) - if (map.Contains(mpos) && - map.Tiles[mpos].Type == param.WaterTile && - regionMask[mpos] != largest.Id) - { - var cpos = mpos.ToCPos(gridType); - var projections = Symmetry.RotateAndMirrorCPos( - cpos, map.Tiles, param.Rotations, param.Mirror); - foreach (var projection in projections) - if (map.Tiles.Contains(projection) && map.Tiles[projection].Type == param.WaterTile) - unplayableWater.Add(projection); - } - - bool? ClearWaterBody(CPos cpos, bool _) - { - var mpos = cpos.ToMPos(gridType); - var propagate = - beachesShape.Remove(cpos) || - map.Tiles[mpos].Type == param.WaterTile; - map.Tiles[mpos] = PickTile(param.LandTile); - regionMask[mpos] = PlayableSpace.NullRegion; - return propagate ? false : null; - } - - CellLayerUtils.FloodFill( - map.Tiles, - unplayableWater.Select(cpos => (cpos, false)), - ClearWaterBody, - DirectionExts.Spread4CVec); + var mask = CellLayerUtils.Clone(playable); + terraformer.ZoneFromOutOfBounds(mask, true); + terraformer.FillUnmaskedSideAndBorder( + mask, + landBeachWater, + Terraformer.Side.Out, + cpos => map.Tiles[cpos] = terraformer.PickTile(pickAnyRandom, param.LandTile)); } - var replaceable = IdentifyReplaceableTiles(map, replaceabilityMap, actorPlans); - var replace = new CellLayer(map); + var replace = PlayableToReplaceable(); foreach (var mpos in map.AllCells.MapCoords) - if (regionMask[mpos] == largest.Id || !map.Contains(mpos)) + if (playable[mpos] || !map.Contains(mpos)) replace[mpos] = MultiBrush.Replaceability.None; - else - replace[mpos] = replaceable[mpos]; - MultiBrush.PaintArea(map, actorPlans, replace, param.UnplayableObstacles, debrisTilingRandom); + terraformer.PaintArea(debrisTilingRandom, replace, param.UnplayableObstacles); } - - foreach (var mpos in map.AllCells.MapCoords) - playableArea[mpos] = playability[mpos] == PlayableSpace.Playability.Playable && regionMask[mpos] == largest.Id; } if (param.Roads) { - // For awkward symmetries, we try harder to make sure roads are fairer. - // This can degrade the quantity of roads, though. - var imperfectSymmetry = - param.Mirror != Symmetry.Mirror.None || - param.Rotations == 3 || - param.Rotations >= 5; - - // 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] = param.ClearTerrain.Contains(templatedTerrainInfo.GetTerrainIndex(map.Tiles[cpos])); - - foreach (var actorPlan in actorPlans) - foreach (var (cpos, _) in actorPlan.Footprint()) - if (space.Contains(cpos + enlargedOffset)) - space[cpos + enlargedOffset] = false; - - // Improve symmetry. - { - var newSpace = new CellLayer(gridType, enlargedSize); - Symmetry.RotateAndMirrorOverCPos( - space, - param.Rotations, - param.Mirror, - (sources, destination) - => newSpace[destination] = - sources.All(source => !space.TryGetValue(source, out var value) || value)); - space = newSpace; - } - - // TODO: Move to configuration + // TODO: Move or collapse into configuration + const int RoadMinimumShrinkLength = 12; const int RoadStraightenShrink = 4; const int RoadStraightenGrow = 2; - const int RoadMinimumShrinkLength = 12; const int RoadInertialRange = 8; - var roadTotalShrink = RoadStraightenShrink + param.RoadShrink; - var minimumRoadLengthForPruning = RoadMinimumShrinkLength + 2 * roadTotalShrink; - var matrixSpace = CellLayerUtils.ToMatrix(space, true); - var kernel = new Matrix(param.RoadSpacing * 2 + 1, param.RoadSpacing * 2 + 1); - MatrixUtils.OverCircle( - matrix: kernel, - centerIn1024ths: kernel.Size * 512, - radiusIn1024ths: param.RoadSpacing * 1024, - outside: false, - action: (xy, _) => kernel[xy] = true); - var dilated = MatrixUtils.KernelDilateOrErode( - matrixSpace, - kernel, - new int2(param.RoadSpacing, param.RoadSpacing), - false); - var deflated = MatrixUtils.DeflateSpace(dilated, true); - - if (imperfectSymmetry) + var roadPaths = terraformer.PlanRoads( + terraformer.CheckSpace(param.ClearTerrain, true, false), + param.RoadSpacing, + RoadMinimumShrinkLength + 2 * (RoadStraightenShrink + param.RoadShrink)); + foreach (var roadPath in roadPaths) { - 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 < minimumRoadLengthForPruning) - { - 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, param.Rotations, param.Mirror)) - .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: minimumRoadLengthForPruning, - minimumJunctionSeparation: 6, - preserveEdgePaths: true); - var pointArrays = CellLayerUtils.FromMatrixPoints(matrixPointArrays, space); - pointArrays = TilingPath.RetainDisjointPaths(pointArrays); - pointArrays = pointArrays.Select(a => a.Select(p => p - enlargedOffset).ToArray()).ToArray(); - - var nonLoopedRoadPermittedTemplates = - TilingPath.PermittedSegments.FromInnerAndTerminalTypes( - param.SegmentedBrushes, param.RoadSegmentTypes, param.ClearSegmentTypes); - var loopedRoadPermittedTemplates = - TilingPath.PermittedSegments.FromType( - param.SegmentedBrushes, param.RoadSegmentTypes); - - foreach (var pointArray in pointArrays) - { - var isLoop = pointArray[0] == pointArray[^1]; - TilingPath path; - if (isLoop) - path = new TilingPath( - map, - pointArray, - param.RoadSpacing - 1, - param.RoadSegmentTypes[0], - param.RoadSegmentTypes[0], - loopedRoadPermittedTemplates); - else - path = new TilingPath( - map, - pointArray, - param.RoadSpacing - 1, - param.ClearSegmentTypes[0], - param.ClearSegmentTypes[0], - nonLoopedRoadPermittedTemplates); - - path - .ChirallyNormalize(cvec => CellLayerUtils.CornerToWPos(cvec, gridType) - wMapCenter) - .ExtendEdge(2 * roadTotalShrink + RoadMinimumShrinkLength) - .Shrink(roadTotalShrink, RoadMinimumShrinkLength) - .InertiallyExtend(RoadStraightenGrow, RoadInertialRange) - .SetAutoEndDeviation() - .OptimizeLoop() - .RetainIfValid(); - - // Shrinking may have deleted the path. - if (path.Points == null) + var tilingPath = TilingPath.QuickCreate( + map, + param.SegmentedBrushes, + roadPath, + param.RoadSpacing - 1, + param.RoadSegmentTypes[0], + param.ClearSegmentTypes[0]) + .StraightenEnds( + RoadStraightenShrink + param.RoadShrink, + RoadStraightenGrow, + RoadMinimumShrinkLength, + RoadInertialRange) + .RetainIfValid(); + if (tilingPath.Points == null) continue; - var brush = path.Tile(roadTilingRandom) + var brush = tilingPath.Tile(roadTilingRandom) ?? throw new MapGenerationException("Could not fit tiles for roads"); - brush.Paint(map, actorPlans, CPos.Zero, MultiBrush.Replaceability.Tile, pickAnyRandom); + terraformer.PaintTiling(pickAnyRandom, brush); } } if (param.CreateEntities) { - var (buildingTypes, buildingWeights) = Parameters.SplitWeights(param.BuildingWeights); - var (resourceSpawnTypes, resourceSpawnWeights) = Parameters.SplitWeights(param.ResourceSpawnWeights); - - var projectionSpacing = new CellLayer(map); - Symmetry.RotateAndMirrorOverCPos( - projectionSpacing, - param.Rotations, - param.Mirror, - (projections, cpos) => - projectionSpacing[cpos] = Symmetry.ProjectionProximity(projections) / 2); - - // Spawn bias tries to move spawns away from the map center and their symmetry - // projections. - var spawnBiasRadius = Math.Max(1, minSpan * param.CentralSpawnReservationFraction / FractionMax); - var spawnBias = new CellLayer(map); - spawnBias.Clear(spawnBiasRadius); - CellLayerUtils.OverCircle( - cellLayer: spawnBias, - wCenter: wMapCenter, - wRadius: 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]); - - var zoneable = new CellLayer(map); - foreach (var mpos in map.AllCells.MapCoords) - zoneable[mpos] = playableArea[mpos] && param.ZoneableTerrain.Contains(templatedTerrainInfo.GetTerrainIndex(map.Tiles[mpos])); - - foreach (var actorPlan in actorPlans) - foreach (var cpos in actorPlan.Footprint().Keys) - if (map.AllCells.Contains(cpos)) - zoneable[cpos] = false; - - // Improve symmetry. - { - var newZoneable = new CellLayer(map); - Symmetry.RotateAndMirrorOverCPos( - zoneable, - param.Rotations, - param.Mirror, - (sources, destination) - => newZoneable[destination] = - sources.All(source => zoneable.TryGetValue(source, out var value) && value)); - zoneable = newZoneable; - } - - if (param.Rotations > 1 || param.Mirror != Symmetry.Mirror.None) - { - // Reserve the center of the map - otherwise it will mess with rotations - CellLayerUtils.OverCircle( - cellLayer: zoneable, - wCenter: wMapCenter, - wRadius: 1024, - outside: false, - action: (mpos, _, _, _) => zoneable[mpos] = false); - } + var zoneable = terraformer.GetZoneable(param.ZoneableTerrain, playable); var zoneableArea = zoneable.Count(v => v); var symmetryCount = Symmetry.RotateAndMirrorProjectionCount(param.Rotations, param.Mirror); @@ -1353,90 +753,36 @@ namespace OpenRA.Mods.Common.Traits var symmetryPlayers = param.Players / symmetryCount; for (var iteration = 0; iteration < symmetryPlayers; iteration++) { - var spawnPreference = new CellLayer(map); - CellLayerUtils.ChebyshevRoom(spawnPreference, zoneable, false); - foreach (var mpos in map.AllCells.MapCoords) - if (spawnPreference[mpos] >= param.MinimumSpawnRadius && - projectionSpacing[mpos] * 2 >= param.SpawnReservation + param.MinimumSpawnRadius) - { - spawnPreference[mpos] = spawnBias[mpos] * Math.Min(param.SpawnRegionSize, spawnPreference[mpos]); - } - else - { - spawnPreference[mpos] = 0; - } - - var (chosenMPos, chosenValue) = CellLayerUtils.FindRandomBest( - spawnPreference, + var chosenCPos = terraformer.ChooseSpawnInZoneable( playerRandom, - (a, b) => a.CompareTo(b)); - - if (chosenValue < 1) - throw new MapGenerationException("Not enough room for player spawns"); + zoneable, + param.CentralSpawnReservationFraction, + param.MinimumSpawnRadius, + param.SpawnRegionSize, + param.SpawnReservation) + ?? throw new MapGenerationException("Not enough room for player spawns"); var spawn = new ActorPlan(map, "mpspawn") { - Location = chosenMPos.ToCPos(gridType), + Location = chosenCPos, }; - var preferedRange1024ths = (param.SpawnBuildSize + param.SpawnRegionSize * 2) * 512; - var resourceSpawnPreferences = new CellLayer(map); - CellLayerUtils.WalkingDistances( - resourceSpawnPreferences, + var resourceSpawnPreferences = terraformer.TargetWalkingDistance( + terraformer.CheckSpace(param.PlayableTerrain, true), + terraformer.ErodeZones(zoneable, 1), + [chosenCPos], + new WDist((param.SpawnBuildSize + param.SpawnRegionSize * 2) * 512), + new WDist(param.SpawnRegionSize * 1024)); + terraformer.AddDistributedActors( + playerRandom, zoneable, - [chosenMPos.ToCPos(gridType)], - param.SpawnRegionSize * 1024); - foreach (var mpos in map.AllCells.MapCoords) - { - var v = resourceSpawnPreferences[mpos]; - resourceSpawnPreferences[mpos] = - ((v > preferedRange1024ths ? 2 * preferedRange1024ths - v : v) + 1023) / 1024; - } + resourceSpawnPreferences, + param.ResourceSpawnWeights, + param.SpawnResourceSpawns, + false, + new WDist(param.ResourceSpawnReservation * 1024)); - var resourceSpawns = new List(); - for (var resourceSpawn = 0; resourceSpawn < param.SpawnResourceSpawns; resourceSpawn++) - { - var (mpos, value) = CellLayerUtils.FindRandomBest( - resourceSpawnPreferences, - playerRandom, - (a, b) => a.CompareTo(b)); - if (value <= 1) - break; - - var resourceSpawnType = resourceSpawnTypes[playerRandom.PickWeighted(resourceSpawnWeights)]; - var resourceSpawnPlan = - new ActorPlan(map, resourceSpawnType) - { - Location = mpos.ToCPos(gridType) - }; - resourceSpawns.Add(resourceSpawnPlan); - CellLayerUtils.OverCircle( - cellLayer: resourceSpawnPreferences, - wCenter: resourceSpawnPlan.WPosLocation, - wRadius: 1024, - outside: false, - action: (mpos, _, _, _) => resourceSpawnPreferences[mpos] = 0); - } - - var projectedSpawns = Symmetry.RotateAndMirrorActorPlan(spawn, param.Rotations, param.Mirror); - actorPlans.AddRange(projectedSpawns); - foreach (var projectedSpawn in projectedSpawns) - CellLayerUtils.OverCircle( - cellLayer: zoneable, - wCenter: projectedSpawn.WPosLocation, - wRadius: param.SpawnReservation * 1024, - outside: false, - action: (mpos, _, _, _) => zoneable[mpos] = false); - - var projectedResourceSpawns = Symmetry.RotateAndMirrorActorPlans(resourceSpawns, param.Rotations, param.Mirror); - actorPlans.AddRange(projectedResourceSpawns); - foreach (var projectedResourceSpawn in projectedResourceSpawns) - CellLayerUtils.OverCircle( - cellLayer: zoneable, - wCenter: projectedResourceSpawn.WPosLocation, - wRadius: param.ResourceSpawnReservation * 1024, - outside: false, - action: (mpos, _, _, _) => zoneable[mpos] = false); + terraformer.ProjectPlaceDezoneActor(spawn, zoneable, new WDist(param.SpawnReservation * 1024)); } // Expansions @@ -1444,480 +790,115 @@ namespace OpenRA.Mods.Common.Traits var resourceSpawnsRemaining = (int)(param.MaximumExpansionResourceSpawns * perSymmetryEntityMultiplier / EntityBonusMax); while (resourceSpawnsRemaining > 0) { - var roominess = new CellLayer(map); - CellLayerUtils.ChebyshevRoom(roominess, zoneable, false); - foreach (var mpos in map.AllCells.MapCoords) - roominess[mpos] = Math.Min( - param.MaximumExpansionSize + param.ExpansionBorder, - Math.Min(roominess[mpos], projectionSpacing[mpos])); - var (chosenMPos, chosenValue) = CellLayerUtils.FindRandomBest( - roominess, + var added = terraformer.AddActorCluster( expansionRandom, - (a, b) => a.CompareTo(b)); - var room = chosenValue - 1; - var radius2 = room - param.ExpansionBorder; - if (radius2 < param.MinimumExpansionSize) + zoneable, + param.ResourceSpawnWeights, + Math.Min(resourceSpawnsRemaining, expansionRandom.Next(param.MaximumResourceSpawnsPerExpansion) + 1), + param.ExpansionInner, + param.MinimumExpansionSize, + param.MaximumExpansionSize, + param.ExpansionBorder, + true, + new WDist(param.ResourceSpawnReservation * 1024)); + resourceSpawnsRemaining -= added; + if (added == 0) break; - if (radius2 > param.MaximumExpansionSize) - radius2 = param.MaximumExpansionSize; - var radius1 = Math.Min(Math.Min(param.ExpansionInner, room), radius2); - var resourceSpawnCount = Math.Min(resourceSpawnsRemaining, expansionRandom.Next(param.MaximumResourceSpawnsPerExpansion) + 1); - resourceSpawnsRemaining -= resourceSpawnCount; - - if (radius1 < 1) - break; - - var resourceSpawns = new List(); - var resourceSpawnPreferences = new CellLayer(map); - var radius1Sq = radius1 * radius1; - CellLayerUtils.OverCircle( - cellLayer: resourceSpawnPreferences, - wCenter: CellLayerUtils.MPosToWPos(chosenMPos, gridType), - wRadius: radius2 * 1024, - outside: false, - action: (mpos, _, _, wrSq) => - { - var rSq = (int)(wrSq / (1024 * 1024)); - resourceSpawnPreferences[mpos] = - rSq >= radius1Sq ? rSq : 0; - }); - for (var resourceSpawn = 0; resourceSpawn < resourceSpawnCount; resourceSpawn++) - { - var mpos = CellLayerUtils.PickWeighted(resourceSpawnPreferences, expansionRandom); - var resourceSpawnType = resourceSpawnTypes[playerRandom.PickWeighted(resourceSpawnWeights)]; - var resourceSpawnPlan = - new ActorPlan(map, resourceSpawnType) - { - Location = mpos.ToCPos(gridType) - }; - resourceSpawns.Add(resourceSpawnPlan); - CellLayerUtils.OverCircle( - cellLayer: resourceSpawnPreferences, - wCenter: resourceSpawnPlan.WPosLocation, - wRadius: 1024, - outside: false, - action: (mpos, _, _, _) => resourceSpawnPreferences[mpos] = 0); - } - - var projectedResourceSpawns = Symmetry.RotateAndMirrorActorPlans(resourceSpawns, param.Rotations, param.Mirror); - actorPlans.AddRange(projectedResourceSpawns); - foreach (var projectedResourceSpawn in projectedResourceSpawns) - CellLayerUtils.OverCircle( - cellLayer: zoneable, - wCenter: projectedResourceSpawn.WPosLocation, - wRadius: param.ResourceSpawnReservation * 1024, - outside: false, - action: (mpos, _, _, _) => zoneable[mpos] = false); } } // Neutral buildings { + var (buildingTypes, buildingWeights) = Terraformer.SplitDictionary(param.BuildingWeights); var targetBuildingCount = (param.MaximumBuildings != 0) - ? expansionRandom.Next( + ? buildingRandom.Next( (int)(param.MinimumBuildings * perSymmetryEntityMultiplier / EntityBonusMax), (int)(param.MaximumBuildings * perSymmetryEntityMultiplier / EntityBonusMax) + 1) : 0; for (var i = 0; i < targetBuildingCount; i++) - { - var roominess = new CellLayer(map); - CellLayerUtils.ChebyshevRoom(roominess, zoneable, false); - foreach (var mpos in map.AllCells.MapCoords) - roominess[mpos] = Math.Min( - 3, - Math.Min(roominess[mpos], projectionSpacing[mpos])); - var (chosenMPos, chosenValue) = CellLayerUtils.FindRandomBest( - roominess, + terraformer.AddActor( buildingRandom, - (a, b) => a.CompareTo(b)); - if (chosenValue < 3) - break; - var chosenCPos = chosenMPos.ToCPos(gridType); - var typeChoice = buildingRandom.PickWeighted(buildingWeights); - var type = buildingTypes[typeChoice]; - var actorPlan = new ActorPlan(map, type) - { - WPosCenterLocation = CellLayerUtils.CPosToWPos(chosenCPos, gridType), - }; - - var projectedBuildings = Symmetry.RotateAndMirrorActorPlan(actorPlan, param.Rotations, param.Mirror); - actorPlans.AddRange(projectedBuildings); - foreach (var projectedBuilding in projectedBuildings) - CellLayerUtils.OverCircle( - cellLayer: zoneable, - wCenter: projectedBuilding.WPosLocation, - wRadius: 2048, - outside: false, - action: (mpos, _, _, _) => zoneable[mpos] = false); - } + zoneable, + buildingTypes[buildingRandom.PickWeighted(buildingWeights)]); } // Grow resources + var targetResourceValue = param.ResourcesPerPlayer * entityMultiplier / EntityBonusMax; + if (targetResourceValue > 0) { - var pattern1024ths = new CellLayer(map); - NoiseUtils.SymmetricFractalNoiseIntoCellLayer( + var resourcePattern = terraformer.ResourceNoise( resourceRandom, - pattern1024ths, - param.Rotations, - param.Mirror, param.ResourceFeatureSize, - wavelength => ClumpinessAmplitude(wavelength, param.OreClumpiness)); - { - CellLayerUtils.CalibrateQuantileInPlace( - pattern1024ths, - 0, - 0, 1); - var max1024ths = pattern1024ths.Max(); - var uniformity1024ths = param.OreUniformity * 1024 / FractionMax; - foreach (var mpos in map.AllCells.MapCoords) - pattern1024ths[mpos] = uniformity1024ths + 1024 * pattern1024ths[mpos] / max1024ths; - } - - var strengths1024ths = new Dictionary>(); - foreach (var actorPlan in actorPlans) - { - var type = actorPlan.Reference.Type; - if (param.ResourceSpawnWeights.ContainsKey(type)) - { - var resource = param.ResourceSpawnSeeds[type]; - if (!strengths1024ths.TryGetValue(resource, out var strength1024ths)) - { - strength1024ths = new CellLayer(map); - strength1024ths.Clear(1); - strengths1024ths.Add(resource, strength1024ths); - } - - CellLayerUtils.OverCircle( - cellLayer: strength1024ths, - wCenter: actorPlan.WPosLocation, - wRadius: 16 * 1024, - outside: false, - action: (mpos, _, _, wrSq) => - strength1024ths[mpos] += - (int)(1024 * 1024 / (1024 + Exts.ISqrt(wrSq)))); - } - } - - var maxStrength1024ths = new CellLayer(map); - maxStrength1024ths.Clear(1); - var bestResource = new CellLayer(map); - bestResource.Clear(param.DefaultResource); - foreach (var resourceStrength in strengths1024ths) - { - 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] = pattern1024ths[mpos] * maxStrength1024ths[mpos]; + param.OreClumpiness, + param.OreUniformity * 1024 / FractionMax); + var resourceBiases = new List(); var wSpawnBuildSizeSq = (long)param.SpawnBuildSize * param.SpawnBuildSize * 1024 * 1024; - foreach (var actorPlan in actorPlans) - if (actorPlan.Reference.Type == "mpspawn") - CellLayerUtils.OverCircle( - cellLayer: plan, - wCenter: actorPlan.WPosLocation, - wRadius: param.SpawnRegionSize * 2 * 1024, - outside: false, - action: (mpos, _, _, rSq) => - plan[mpos] += (int)(plan[mpos] * param.SpawnResourceBias * wSpawnBuildSizeSq / Math.Max(rSq, 1024 * 1024) / FractionMax)); - foreach (var mpos in map.AllCells.MapCoords) - if (!playableArea[mpos] || !param.AllowedTerrainResourceCombos.Contains((bestResource[mpos], map.GetTerrainIndex(mpos)))) - plan[mpos] = -int.MaxValue; - - foreach (var actorPlan in actorPlans) - if (actorPlan.Reference.Type == "mpspawn") - CellLayerUtils.OverCircle( - cellLayer: plan, - wCenter: actorPlan.WPosLocation, - wRadius: param.SpawnBuildSize * 1024, - outside: false, - action: (mpos, _, _, _) => plan[mpos] = -int.MaxValue); - - foreach (var actorPlan in actorPlans) - foreach (var (cpos, _) in actorPlan.Footprint()) - if (plan.Contains(cpos)) - plan[cpos] = -int.MaxValue; - - // Improve symmetry. + // Bias towards resource spawns + foreach (var (actorType, resourceType) in param.ResourceSpawnSeeds.OrderBy(kv => kv.Key)) { - var newPlan = new CellLayer(map); - Symmetry.RotateAndMirrorOverCPos( - plan, - param.Rotations, - param.Mirror, - (sources, destination) - => newPlan[destination] = - sources.Min(source => plan.TryGetValue(source, out var value) ? value : -int.MaxValue)); - plan = newPlan; + resourceBiases.AddRange( + terraformer.ActorsOfType(actorType) + .Select(a => new Terraformer.ResourceBias(a) + { + BiasRadius = new WDist(16 * 1024), + Bias = (value, rSq) => value + (int)(1024 * 1024 / (1024 + Exts.ISqrt(rSq))), + ResourceType = resourceType, + })); } - var remaining = param.ResourcesPerPlayer * entityMultiplier / EntityBonusMax; + // Bias towards player spawns, but also reserve an area for base building. + resourceBiases.AddRange( + terraformer.ActorsOfType("mpspawn") + .Select(a => new Terraformer.ResourceBias(a) + { + ExclusionRadius = new WDist(param.SpawnBuildSize * 1024), + BiasRadius = new WDist(param.SpawnRegionSize * 2 * 1024), + Bias = (value, rSq) => value + (int)(value * param.SpawnResourceBias * wSpawnBuildSizeSq / Math.Max(rSq, 1024 * 1024) / FractionMax), + })); - // 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 = bestResource[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 param.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; - } - - // Set and return change in overall value. - int AddResource(CPos cpos) - { - var mpos = cpos.ToMPos(gridType); - priorities[PriorityIndex(mpos)] = int.MaxValue; - zoneable[mpos] = false; - - // Generally shouldn't happen, but perhaps a rotation/mirror related inaccuracy. - if (map.Resources[mpos].Type != 0) - return 0; - - var resourceType = bestResource[mpos]; - var oldValue = CheckValue3By3(cpos); - map.Resources[mpos] = new ResourceTile( - resourceType.ResourceIndex, - (byte)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, param.Rotations, param.Mirror)) - if (map.Resources.Contains(cpos)) - remaining -= AddResource(cpos); - } + var (plan, typePlan) = terraformer.PlanResources( + resourcePattern, + CellLayerUtils.Intersect([playable, terraformer.CheckSpace(null, true)]), + param.DefaultResource, + resourceBiases); + terraformer.GrowResources( + plan, + typePlan, + targetResourceValue); + terraformer.ZoneFromResources(zoneable, false); } // CivilianBuildings if (param.CivilianBuildings > 0) { - var space = new CellLayer(map); - foreach (var mpos in map.AllCells.MapCoords) - space[mpos] = param.PlayableTerrain.Contains(templatedTerrainInfo.GetTerrainIndex(map.Tiles[mpos])); - - foreach (var actorPlan in actorPlans) - foreach (var (cpos, _) in actorPlan.Footprint()) - if (space.Contains(cpos)) - space[cpos] = false; - - 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( + var decorationNoise = terraformer.DecorationPattern( decorationRandom, - decorationNoise, - param.Rotations, - param.Mirror, + terraformer.CheckSpace(param.PlayableTerrain, true), + CellLayerUtils.Intersect([zoneable, terraformer.CheckSpace(param.LandTile)]), + param.CivilianBuildings, param.CivilianBuildingsFeatureSize, - wavelength => 1); - - var densityNoise = new CellLayer(map); - NoiseUtils.SymmetricFractalNoiseIntoCellLayer( - decorationRandom, - densityNoise, - param.Rotations, - param.Mirror, - 1024, - NoiseUtils.PinkAmplitude); - CellLayerUtils.CalibrateQuantileInPlace( - densityNoise, - 0, - FractionMax - param.CivilianBuildingDensity, FractionMax); - - var decorable = new CellLayer(map); - var totalDecorable = 0; - foreach (var mpos in map.AllCells.MapCoords) - { - var isDecorable = - map.Tiles[mpos].Type == param.LandTile - && zoneable[mpos] && space[mpos] && !reserved[mpos] && densityNoise[mpos] >= 0; - decorable[mpos] = isDecorable; - if (isDecorable) - totalDecorable++; - else - decorationNoise[mpos] = -1024 * 1024; - } - - var mapArea = map.MapSize.Width * map.MapSize.Height; - CellLayerUtils.CalibrateQuantileInPlace( - decorationNoise, - 0, - mapArea - totalDecorable * param.CivilianBuildings / FractionMax, mapArea); - foreach (var mpos in map.AllCells.MapCoords) - if (decorationNoise[mpos] < 0) - decorable[mpos] = false; - - for (var i = 0; i < 8; i++) - { - var (blurred, changes) = MatrixUtils.BooleanBlur( - CellLayerUtils.ToMatrix(decorable, false), - param.CivilianBuildingDensityRadius, - FractionMax - param.MinimumCivilianBuildingDensity, FractionMax); - if (changes == 0) - break; - - var densityFilter = new CellLayer(map); - CellLayerUtils.FromMatrix(densityFilter, blurred); - - foreach (var mpos in map.AllCells.MapCoords) - if (!densityFilter[mpos]) - decorable[mpos] = false; - } - - // Improve symmetry. - { - var newDecorable = new CellLayer(map); - Symmetry.RotateAndMirrorOverCPos( - decorable, - param.Rotations, - param.Mirror, - (sources, destination) - => newDecorable[destination] = - sources.All(source => decorable.TryGetValue(source, out var value) && value)); - decorable = newDecorable; - } - - var replace = new CellLayer(map); - foreach (var mpos in map.AllCells.MapCoords) - replace[mpos] = decorable[mpos] ? MultiBrush.Replaceability.Actor : MultiBrush.Replaceability.None; - - MultiBrush.PaintArea( - map, - actorPlans, - replace, - param.CivilianBuildingsObstacles, + param.CivilianBuildingDensity, + param.MinimumCivilianBuildingDensity, + param.CivilianBuildingDensityRadius); + terraformer.PaintActors( decorationTilingRandom, + decorationNoise, + param.CivilianBuildingsObstacles, alwaysPreferLargerBrushes: true); } } // Cosmetically repaint tiles - foreach (var (tile, collection) in param.RepaintTiles.OrderBy(kv => kv.Key)) - { - var replace = new CellLayer(map); - foreach (var mpos in replace.CellRegion.MapCoords) - replace[mpos] = - map.Tiles[mpos].Type == tile - ? MultiBrush.Replaceability.Any - : MultiBrush.Replaceability.None; + terraformer.RepaintTiles(repaintRandom, param.RepaintTiles); - MultiBrush.PaintArea(map, actorPlans, replace, collection, repaintRandom); - } - - map.PlayerDefinitions = new MapPlayers(map.Rules, param.Players).ToMiniYaml(); - map.ActorDefinitions = actorPlans - .Select((plan, i) => new MiniYamlNode($"Actor{i}", plan.Reference.Save())) - .ToImmutableArray(); + terraformer.BakeMap(); return map; } - static CellLayer IdentifyReplaceableTiles( - Map map, - Dictionary replaceabilityMap, - IEnumerable actorPlans) - { - var output = new CellLayer(map); - - foreach (var mpos in map.AllCells.MapCoords) - { - var tile = map.Tiles[mpos]; - var replaceability = MultiBrush.Replaceability.Any; - if (replaceabilityMap.TryGetValue(tile, out var value)) - replaceability = value; - output[mpos] = replaceability; - } - - if (actorPlans != null) - foreach (var actorPlan in actorPlans) - foreach (var cpos in actorPlan.Footprint().Keys) - if (map.AllCells.Contains(cpos)) - output[cpos] = MultiBrush.Replaceability.None; - - return output; - } - - static int ClumpinessAmplitude(int wavelength, int clumpiness) - { - var amplitude = wavelength; - for (var i = 0; i < clumpiness; i++) - amplitude = Exts.ISqrt(amplitude); - return amplitude; - } - string IEditorToolInfo.Label => Name; string IEditorToolInfo.PanelWidget => PanelWidget; } diff --git a/mods/cnc/rules/map-generators.yaml b/mods/cnc/rules/map-generators.yaml index 145fb1e908..e3bcf18a6c 100644 --- a/mods/cnc/rules/map-generators.yaml +++ b/mods/cnc/rules/map-generators.yaml @@ -23,7 +23,7 @@ MaximumAltitude: 8 RoughnessRadius: 5 Roughness: 500 - MinimumTerrainContourSpacing: 6 + MinimumTerrainContourSpacing: 5 MinimumCliffLength: 10 ForestClumpiness: 1 DenyWalledAreas: True @@ -60,11 +60,8 @@ splitblue: BlueTiberium ClearTerrain: Clear PlayableTerrain: Beach,BlueTiberium,Bridge,Clear,Road,Rough,Tiberium,Wall - PartiallyPlayableTerrain: River,Tree,Water - UnplayableTerrain: Rock DominantTerrain: River,Rock,Tree,Water ZoneableTerrain: Clear,Road - PartiallyPlayableCategories: Beach,Road ClearSegmentTypes: Clear BeachSegmentTypes: Beach CliffSegmentTypes: Cliff @@ -158,16 +155,14 @@ Label: label-cnc-map-generator-choice-terrain-type-mountains Settings: Water: 0 - Mountains: 1000 + Mountains: 900 Roughness: 600 - MinimumTerrainContourSpacing: 5 Choice@MountainLakes: Label: label-cnc-map-generator-choice-terrain-type-mountain-lakes Settings: Water: 200 - Mountains: 1000 + Mountains: 900 Roughness: 850 - MinimumTerrainContourSpacing: 5 MultiChoiceOption@Shape: Label: label-cnc-map-generator-option-shape Default: Square diff --git a/mods/ra/rules/map-generators.yaml b/mods/ra/rules/map-generators.yaml index 10bb5b45f0..95a983ebd2 100644 --- a/mods/ra/rules/map-generators.yaml +++ b/mods/ra/rules/map-generators.yaml @@ -23,7 +23,7 @@ MaximumAltitude: 8 RoughnessRadius: 5 Roughness: 500 - MinimumTerrainContourSpacing: 6 + MinimumTerrainContourSpacing: 5 MinimumCliffLength: 10 ForestClumpiness: 1 DenyWalledAreas: True @@ -59,11 +59,8 @@ gmine: Gems ClearTerrain: Clear PlayableTerrain: Beach,Bridge,Clear,Gems,Ore,Road,Rough,Wall,Water - PartiallyPlayableTerrain: Tree - UnplayableTerrain: River,Rock DominantTerrain: River,Rock,Tree,Water ZoneableTerrain: Clear,Road - PartiallyPlayableCategories: Beach,Road ClearSegmentTypes: Clear BeachSegmentTypes: Beach CliffSegmentTypes: Cliff @@ -156,16 +153,14 @@ Label: label-ra-map-generator-choice-terrain-type-mountains Settings: Water: 0 - Mountains: 1000 + Mountains: 900 Roughness: 600 - MinimumTerrainContourSpacing: 5 Choice@MountainLakes: Label: label-ra-map-generator-choice-terrain-type-mountain-lakes Settings: Water: 200 - Mountains: 1000 + Mountains: 900 Roughness: 850 - MinimumTerrainContourSpacing: 5 Choice@Oceanic: Label: label-ra-map-generator-choice-terrain-type-oceanic Settings: