From 417f7872941cbe9afe9c64828113e760c6bd8142 Mon Sep 17 00:00:00 2001 From: Ashley Newson Date: Tue, 7 Jan 2025 22:48:08 +0000 Subject: [PATCH] Add experimental RA procedural map generator Add an experimental procedural map generator for the Red Alert mod, along with supporting code that may assist in the development of map generators for other mods. Map generation may be accessed as a tool in the Map Editor. This change does not presently introduce direct lobby options for generated maps. Features: - Terrain with land, water, beaches, cliffs, roads, debris, and trees. - Placement of mpspawns, neutral buildings, and resources. - Rotational and mirror symmetry options. - Various configurable parameters with presets. - Deterministic with configurable seed. - Performant. --- AUTHORS | 1 + OpenRA.Game/Exts.cs | 37 + OpenRA.Game/Map/CellLayer.cs | 7 + OpenRA.Game/Map/Map.cs | 13 +- OpenRA.Game/Map/TileReference.cs | 15 + OpenRA.Game/Primitives/PriorityArray.cs | 130 ++ OpenRA.Game/Primitives/Size.cs | 10 + OpenRA.Game/Support/MersenneTwister.cs | 87 +- OpenRA.Mods.Common/Lint/CheckMultiBrushes.cs | 51 + OpenRA.Mods.Common/MapGenerator/ActorPlan.cs | 149 ++ .../MapGenerator/CellLayerUtils.cs | 500 +++++ OpenRA.Mods.Common/MapGenerator/Direction.cs | 296 +++ .../MapGenerator/MapGeneratorSettings.cs | 364 ++++ OpenRA.Mods.Common/MapGenerator/Matrix.cs | 186 ++ .../MapGenerator/MatrixUtils.cs | 1412 +++++++++++++++ OpenRA.Mods.Common/MapGenerator/MultiBrush.cs | 455 +++++ OpenRA.Mods.Common/MapGenerator/NoiseUtils.cs | 177 ++ .../MapGenerator/PlayableSpace.cs | 148 ++ OpenRA.Mods.Common/MapGenerator/Symmetry.cs | 319 ++++ OpenRA.Mods.Common/MapGenerator/TilingPath.cs | 1195 ++++++++++++ OpenRA.Mods.Common/Terrain/DefaultTerrain.cs | 22 + OpenRA.Mods.Common/Terrain/TemplateSegment.cs | 63 + OpenRA.Mods.Common/Terrain/TerrainInfo.cs | 19 + .../Traits/World/ClearMapGenerator.cs | 115 ++ .../Traits/World/RaMapGenerator.cs | 1603 +++++++++++++++++ OpenRA.Mods.Common/TraitsInterfaces.cs | 37 + .../Logic/Editor/MapGeneratorToolLogic.cs | 355 ++++ .../Widgets/Logic/Editor/MapToolsLogic.cs | 21 +- OpenRA.Test/OpenRA.Game/PriorityArrayTest.cs | 66 + mods/cnc/chrome/editor.yaml | 79 + mods/cnc/fluent/chrome.ftl | 4 + mods/common/chrome/editor.yaml | 80 + mods/common/fluent/chrome.ftl | 4 + mods/common/fluent/common.ftl | 6 + mods/ra/fluent/rules.ftl | 71 + mods/ra/mod.yaml | 1 + mods/ra/rules/map-generators.yaml | 431 +++++ mods/ra/rules/world.yaml | 1 + mods/ra/tilesets/desert.yaml | 987 ++++++++++ mods/ra/tilesets/snow.yaml | 1129 ++++++++++++ mods/ra/tilesets/temperat.yaml | 1156 ++++++++++++ 41 files changed, 11795 insertions(+), 7 deletions(-) create mode 100644 OpenRA.Game/Primitives/PriorityArray.cs create mode 100644 OpenRA.Mods.Common/Lint/CheckMultiBrushes.cs create mode 100644 OpenRA.Mods.Common/MapGenerator/ActorPlan.cs create mode 100644 OpenRA.Mods.Common/MapGenerator/CellLayerUtils.cs create mode 100644 OpenRA.Mods.Common/MapGenerator/Direction.cs create mode 100644 OpenRA.Mods.Common/MapGenerator/MapGeneratorSettings.cs create mode 100644 OpenRA.Mods.Common/MapGenerator/Matrix.cs create mode 100644 OpenRA.Mods.Common/MapGenerator/MatrixUtils.cs create mode 100644 OpenRA.Mods.Common/MapGenerator/MultiBrush.cs create mode 100644 OpenRA.Mods.Common/MapGenerator/NoiseUtils.cs create mode 100644 OpenRA.Mods.Common/MapGenerator/PlayableSpace.cs create mode 100644 OpenRA.Mods.Common/MapGenerator/Symmetry.cs create mode 100644 OpenRA.Mods.Common/MapGenerator/TilingPath.cs create mode 100644 OpenRA.Mods.Common/Terrain/TemplateSegment.cs create mode 100644 OpenRA.Mods.Common/Traits/World/ClearMapGenerator.cs create mode 100644 OpenRA.Mods.Common/Traits/World/RaMapGenerator.cs create mode 100644 OpenRA.Mods.Common/Widgets/Logic/Editor/MapGeneratorToolLogic.cs create mode 100644 OpenRA.Test/OpenRA.Game/PriorityArrayTest.cs create mode 100644 mods/ra/rules/map-generators.yaml diff --git a/AUTHORS b/AUTHORS index ba1de28b2a..35bb05e335 100644 --- a/AUTHORS +++ b/AUTHORS @@ -36,6 +36,7 @@ Also thanks to: * Andreas Beck (baxtor) * Ang Soon Li (asl97) * Arik Lirette (Angusm3) + * Ashley Newson * Barnaby Smith (mvi) * Bellator * Bernd Stellwag (burned42) diff --git a/OpenRA.Game/Exts.cs b/OpenRA.Game/Exts.cs index f450b4201f..28e232fd82 100644 --- a/OpenRA.Game/Exts.cs +++ b/OpenRA.Game/Exts.cs @@ -510,6 +510,11 @@ namespace OpenRA return byte.Parse(s, NumberStyles.Integer, NumberFormatInfo.InvariantInfo); } + public static ushort ParseUshortInvariant(string s) + { + return ushort.Parse(s, NumberStyles.Integer, NumberFormatInfo.InvariantInfo); + } + public static short ParseInt16Invariant(string s) { return short.Parse(s, NumberStyles.Integer, NumberFormatInfo.InvariantInfo); @@ -520,6 +525,22 @@ namespace OpenRA return int.Parse(s, NumberStyles.Integer, NumberFormatInfo.InvariantInfo); } + public static float ParseFloatOrPercentInvariant(string s) + { + var f = float.Parse(s.Replace("%", ""), NumberStyles.Float, NumberFormatInfo.InvariantInfo); + return f * (s.Contains('%') ? 0.01f : 1f); + } + + public static bool TryParseByteInvariant(string s, out byte i) + { + return byte.TryParse(s, NumberStyles.Integer, NumberFormatInfo.InvariantInfo, out i); + } + + public static bool TryParseUshortInvariant(string s, out ushort i) + { + return ushort.TryParse(s, NumberStyles.Integer, NumberFormatInfo.InvariantInfo, out i); + } + public static bool TryParseInt32Invariant(string s, out int i) { return int.TryParse(s, NumberStyles.Integer, NumberFormatInfo.InvariantInfo, out i); @@ -530,6 +551,17 @@ namespace OpenRA return long.TryParse(s, NumberStyles.Integer, NumberFormatInfo.InvariantInfo, out i); } + public static bool TryParseFloatOrPercentInvariant(string s, out float f) + { + if (float.TryParse(s.Replace("%", ""), NumberStyles.Float, NumberFormatInfo.InvariantInfo, out f)) + { + f *= s.Contains('%') ? 0.01f : 1f; + return true; + } + + return false; + } + public static string ToStringInvariant(this byte i) { return i.ToString(NumberFormatInfo.InvariantInfo); @@ -545,6 +577,11 @@ namespace OpenRA return i.ToString(NumberFormatInfo.InvariantInfo); } + public static string ToStringInvariant(this float f) + { + return f.ToString(NumberFormatInfo.InvariantInfo); + } + public static string ToStringInvariant(this int i, string format) { return i.ToString(format, NumberFormatInfo.InvariantInfo); diff --git a/OpenRA.Game/Map/CellLayer.cs b/OpenRA.Game/Map/CellLayer.cs index 409d377d6c..6c5d6fae5c 100644 --- a/OpenRA.Game/Map/CellLayer.cs +++ b/OpenRA.Game/Map/CellLayer.cs @@ -63,12 +63,16 @@ namespace OpenRA var u = (x - y) / 2; var v = x + y; + if (!Bounds.Contains(u, v)) + throw new IndexOutOfRangeException(); return v * Size.Width + u; } // Resolve an array index from map coordinates int Index(MPos uv) { + if (!Bounds.Contains(uv.U, uv.V)) + throw new IndexOutOfRangeException(); return uv.V * Size.Width + uv.U; } @@ -145,6 +149,9 @@ namespace OpenRA { return uv.Clamp(new Rectangle(0, 0, Size.Width - 1, Size.Height - 1)); } + + public CellRegion CellRegion => + new(GridType, new MPos(0, 0), new MPos(Size.Width - 1, Size.Height - 1)); } // Helper functions diff --git a/OpenRA.Game/Map/Map.cs b/OpenRA.Game/Map/Map.cs index 2457675980..64f4b6a2c6 100644 --- a/OpenRA.Game/Map/Map.cs +++ b/OpenRA.Game/Map/Map.cs @@ -1145,6 +1145,11 @@ namespace OpenRA } public byte GetTerrainIndex(CPos cell) + { + return GetTerrainIndex(cell.ToMPos(this)); + } + + public byte GetTerrainIndex(MPos uv) { // Lazily initialize a cache for terrain indexes. if (cachedTerrainIndexes == null) @@ -1153,7 +1158,6 @@ namespace OpenRA cachedTerrainIndexes.Clear(InvalidCachedTerrainIndex); } - var uv = cell.ToMPos(this); var terrainIndex = cachedTerrainIndexes[uv]; // PERF: Cache terrain indexes per cell on demand. @@ -1168,7 +1172,12 @@ namespace OpenRA public TerrainTypeInfo GetTerrainInfo(CPos cell) { - return Rules.TerrainInfo.TerrainTypes[GetTerrainIndex(cell)]; + return GetTerrainInfo(cell.ToMPos(this)); + } + + public TerrainTypeInfo GetTerrainInfo(MPos uv) + { + return Rules.TerrainInfo.TerrainTypes[GetTerrainIndex(uv)]; } public CPos Clamp(CPos cell) diff --git a/OpenRA.Game/Map/TileReference.cs b/OpenRA.Game/Map/TileReference.cs index f26978ba47..1d3c44d4a1 100644 --- a/OpenRA.Game/Map/TileReference.cs +++ b/OpenRA.Game/Map/TileReference.cs @@ -25,6 +25,21 @@ namespace OpenRA public override int GetHashCode() { return Type.GetHashCode() ^ Index.GetHashCode(); } public override string ToString() { return Type + "," + Index; } + + public static bool TryParse(string s, out TerrainTile tt) + { + var split = s.Split(','); + if (split.Length == 2 && + Exts.TryParseUshortInvariant(split[0], out var type) && + Exts.TryParseByteInvariant(split[1], out var index)) + { + tt = new TerrainTile(type, index); + return true; + } + + tt = default; + return false; + } } public readonly struct ResourceTile diff --git a/OpenRA.Game/Primitives/PriorityArray.cs b/OpenRA.Game/Primitives/PriorityArray.cs new file mode 100644 index 0000000000..72225435aa --- /dev/null +++ b/OpenRA.Game/Primitives/PriorityArray.cs @@ -0,0 +1,130 @@ +#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; + +namespace OpenRA.Primitives +{ + /// + /// A random-access array which keeps track of the minimum item. + /// + public sealed class PriorityArray where T : IComparable + { + readonly T[] items; + + readonly int[] itemIndexToHeapIndex; + readonly int[] heapOfItemIndices; + + /// + /// Create a new PriorityArray of given size with all values preset to the given init value. + /// + public PriorityArray(int size, T init) + { + items = new T[size]; + itemIndexToHeapIndex = new int[size]; + heapOfItemIndices = new int[size]; + Array.Fill(items, init); + for (var i = 0; i < size; i++) + { + items[i] = init; + itemIndexToHeapIndex[i] = i; + heapOfItemIndices[i] = i; + } + } + + public int Length => items.Length; + + /// Get the index of the minimum element. + public int GetMinIndex() => heapOfItemIndices[0]; + + public T this[int itemIndex] + { + get => items[itemIndex]; + set + { + items[itemIndex] = value; + Bubble(itemIndexToHeapIndex[itemIndex]); + } + } + + void Bubble(int heapIndex) + { + if (!BubbleUp(heapIndex)) + { + BubbleDown(heapIndex); + } + } + + bool BubbleUp(int heapIndex) + { + if (heapIndex == 0) + return false; + var itemIndex = heapOfItemIndices[heapIndex]; + var upHeapIndex = ((heapIndex + 1) >> 1) - 1; + var upItemIndex = heapOfItemIndices[upHeapIndex]; + if (items[itemIndex].CompareTo(items[upItemIndex]) >= 0) + return false; + Swap(heapIndex, upHeapIndex, itemIndex, upItemIndex); + BubbleUp(upHeapIndex); + return true; + } + + bool BubbleDown(int heapIndex) + { + var itemIndex = heapOfItemIndices[heapIndex]; + var leftDownHeapIndex = ((heapIndex + 1) << 1) - 1; + var rightDownHeapIndex = leftDownHeapIndex + 1; + if (leftDownHeapIndex >= Length) + return false; + var item = items[itemIndex]; + if (rightDownHeapIndex < Length) + { + var leftDownItemIndex = heapOfItemIndices[leftDownHeapIndex]; + var rightDownItemIndex = heapOfItemIndices[rightDownHeapIndex]; + var leftDownItem = items[leftDownItemIndex]; + var rightDownItem = items[rightDownItemIndex]; + if (item.CompareTo(leftDownItem) <= 0 && item.CompareTo(rightDownItem) <= 0) + return false; + if (leftDownItem.CompareTo(rightDownItem) <= 0) + { + Swap(heapIndex, leftDownHeapIndex, itemIndex, leftDownItemIndex); + BubbleDown(leftDownHeapIndex); + } + else + { + Swap(heapIndex, rightDownHeapIndex, itemIndex, rightDownItemIndex); + BubbleDown(rightDownHeapIndex); + } + + return true; + } + else + { + // Just the left down one exists. + var leftDownItemIndex = heapOfItemIndices[leftDownHeapIndex]; + var leftDownItem = items[leftDownItemIndex]; + if (item.CompareTo(leftDownItem) <= 0) + return false; + Swap(heapIndex, leftDownHeapIndex, itemIndex, leftDownItemIndex); + BubbleDown(leftDownHeapIndex); + return true; + } + } + + void Swap(int heapIndex1, int heapIndex2, int itemIndex1, int itemIndex2) + { + (itemIndexToHeapIndex[itemIndex1], itemIndexToHeapIndex[itemIndex2]) = + (itemIndexToHeapIndex[itemIndex2], itemIndexToHeapIndex[itemIndex1]); + (heapOfItemIndices[heapIndex1], heapOfItemIndices[heapIndex2]) = + (heapOfItemIndices[heapIndex2], heapOfItemIndices[heapIndex1]); + } + } +} diff --git a/OpenRA.Game/Primitives/Size.cs b/OpenRA.Game/Primitives/Size.cs index a1611c6864..2693d662a2 100644 --- a/OpenRA.Game/Primitives/Size.cs +++ b/OpenRA.Game/Primitives/Size.cs @@ -46,6 +46,16 @@ namespace OpenRA.Primitives public bool IsEmpty => Width == 0 && Height == 0; + public int2 ToInt2() + { + return new(Width, Height); + } + + public static Size FromInt2(int2 xy) + { + return new(xy.X, xy.Y); + } + public bool Equals(Size other) { return this == other; diff --git a/OpenRA.Game/Support/MersenneTwister.cs b/OpenRA.Game/Support/MersenneTwister.cs index 9c217a79a5..e83097c224 100644 --- a/OpenRA.Game/Support/MersenneTwister.cs +++ b/OpenRA.Game/Support/MersenneTwister.cs @@ -10,6 +10,8 @@ #endregion using System; +using System.Collections.Generic; +using System.Linq; namespace OpenRA.Support { @@ -32,7 +34,10 @@ namespace OpenRA.Support mt[i] = 1812433253u * (mt[i - 1] ^ (mt[i - 1] >> 30)) + i; } - public int Next() + /// + /// Produces an unsigned integer between -0x80000000 and 0x7fffffff inclusive. + /// + public uint NextUint() { if (index == 0) Generate(); @@ -45,6 +50,24 @@ namespace OpenRA.Support index = (index + 1) % 624; TotalCount++; Last = (int)(y % int.MaxValue); + return y; + } + + /// + /// Produces an unsigned integer between -0x80000000 and 0x7fffffff inclusive. + /// + public ulong NextUlong() + { + return (ulong)NextUint() << 32 | NextUint(); + } + + /// + /// Produces signed integers between -0x7fffffff and 0x7fffffff inclusive. + /// 0 is twice as likely as any other number. + /// + public int Next() + { + NextUint(); return Last; } @@ -65,11 +88,73 @@ namespace OpenRA.Support return Next(0, high); } + /// + /// Produces random 32-bit floats between 0 inclusive and 1 inclusive. + /// Note that whilst floats are 32-bit (23-bit mantissa), the entropy is not. Lower numbers preserve more entropy. + /// public float NextFloat() { return Math.Abs(Next() / (float)0x7fffffff); } + /// + /// Produces uniformally distributed random floats between 0 inclusive and 1 exclusive. + /// Note that whilst floats are 32-bit (23-bit mantissa), each output contains exactly 23 bits of entropy. + /// + public float NextFloatExclusive() + { + return (NextUint() & 0x7fffff) / (float)0x800000; + } + + /// + /// Produces uniformally distributed random doubles between 0 inclusive and 1 exclusive. + /// Note that whilst doubles are 64-bit (52-bit mantissa), each output contains exactly 52 bits of entropy. + /// + public double NextDoubleExclusive() + { + return (NextUlong() & 0xfffffffffffffL) / (double)0x10000000000000L; + } + + /// + /// Pick a random an index from a list of weights. + /// + public int PickWeighted(IReadOnlyList weights) + { + var total = weights.Sum(); + var spin = NextFloatExclusive() * total; + int i; + float acc = 0; + for (i = 0; i < weights.Count; i++) + { + acc += weights[i]; + if (spin < acc) + return i; + } + + // This might be possible due to floating point precision loss + // (in rare cases). Or we might have been given rubbish + // weights. Return anything > 0. + for (i = 0; i < weights.Count; i++) + if (weights[i] > 0) + return i; + + // All <= 0! + return Next(0, weights.Count); + } + + /// + /// Shuffle a portion of a list list in place. Has minor biases. + /// + public void ShuffleInPlace(IList list, int start, int len) + { + for (var i = len; i > 1; i--) + { + var swap = Next(i); + (list[start + i - 1], list[start + swap]) = + (list[start + swap], list[start + i - 1]); + } + } + void Generate() { unchecked diff --git a/OpenRA.Mods.Common/Lint/CheckMultiBrushes.cs b/OpenRA.Mods.Common/Lint/CheckMultiBrushes.cs new file mode 100644 index 0000000000..e573611411 --- /dev/null +++ b/OpenRA.Mods.Common/Lint/CheckMultiBrushes.cs @@ -0,0 +1,51 @@ +#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 OpenRA.Mods.Common.MapGenerator; +using OpenRA.Mods.Common.Terrain; + +namespace OpenRA.Mods.Common.Lint +{ + public class CheckMultiBrushes : ILintPass + { + public void Run(Action emitError, Action emitWarning, ModData modData) + { + foreach (var (terrainInfoName, terrainInfo) in modData.DefaultTerrainInfo) + { + if (terrainInfo is ITemplatedTerrainInfo templatedTerrainInfo && templatedTerrainInfo.MultiBrushCollections.Count > 0) + { + var map = new Map(modData, terrainInfo, 1, 1); + foreach (var (collectionName, collection) in templatedTerrainInfo.MultiBrushCollections) + foreach (var info in collection) + { + try + { + // Includes validation of actor types and template IDs. + var multiBrush = new MultiBrush(map, info); + + // Validates there is at least something in the MultiBrush. + multiBrush.Contract(); + + foreach (var (_, tile) in multiBrush.Tiles) + if (!templatedTerrainInfo.TryGetTerrainInfo(tile, out var _)) + emitError($"Tileset {terrainInfoName} has invalid MultiBrush collection `{collectionName}`: tileset does not have tile {tile.Type},{tile.Index}"); + } + catch (Exception e) when (e is ArgumentException || e is InvalidOperationException) + { + emitError($"Tileset {terrainInfoName} has invalid MultiBrush collection `{collectionName}`: {e.Message}"); + } + } + } + } + } + } +} diff --git a/OpenRA.Mods.Common/MapGenerator/ActorPlan.cs b/OpenRA.Mods.Common/MapGenerator/ActorPlan.cs new file mode 100644 index 0000000000..4d36c42bd9 --- /dev/null +++ b/OpenRA.Mods.Common/MapGenerator/ActorPlan.cs @@ -0,0 +1,149 @@ +#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.Linq; +using OpenRA.Mods.Common.Traits; +using OpenRA.Traits; + +namespace OpenRA.Mods.Common.MapGenerator +{ + /// Description of an actor to add to a map. + public sealed class ActorPlan + { + public readonly Map Map; + public readonly ActorInfo Info; + public readonly ActorReference Reference; + + public CPos Location + { + get => Reference.Get().Value; + set + { + Reference.RemoveAll(); + Reference.Add(new LocationInit(value)); + } + } + + public WPos WPosLocation + { + get => CellLayerUtils.CPosToWPos(Location, Map.Grid.Type); + set => Location = CellLayerUtils.WPosToCPos(value, Map.Grid.Type); + } + + /// + /// WPos representation of actor's center. + /// For example, A 1x2 actor on a Rectangular grid will have +WVec(0, 512, 0) + /// offset to its WPosLocation. + /// + public WPos WPosCenterLocation + { + get => WPosLocation + WVecCenterOffset(); + set => WPosLocation = value - WVecCenterOffset(); + } + + /// + /// Create an ActorPlan from a reference. The referenced actor becomes owned. + /// + public ActorPlan(Map map, ActorReference reference) + { + Map = map; + Reference = reference; + if (!map.Rules.Actors.TryGetValue(Reference.Type.ToLowerInvariant(), out Info)) + throw new ArgumentException($"MultiBrush Actor of unknown type `{Reference.Type.ToLowerInvariant()}`"); + } + + /// + /// Create an ActorPlan containing a new Neutral-owned actor of the given type. + /// + public ActorPlan(Map map, string type) + : this(map, ActorFromType(type)) + { } + + /// + /// Create a cloned actor plan, cloning the underlying ActorReference. + /// + public ActorPlan Clone() + { + return new ActorPlan(Map, Reference.Clone()); + } + + static ActorReference ActorFromType(string type) + { + return new ActorReference(type) + { + new LocationInit(default), + new OwnerInit("Neutral"), + }; + } + + /// + /// The footprint of the actor (influenced by its location). + /// + public IReadOnlyDictionary Footprint() + { + var location = Location; + var ios = Info.TraitInfoOrDefault(); + var subCellInit = Reference.GetOrDefault(); + var subCell = subCellInit != null ? subCellInit.Value : SubCell.Any; + + var occupiedCells = ios?.OccupiedCells(Info, location, subCell); + if (occupiedCells == null || occupiedCells.Count == 0) + return new Dictionary() { { location, SubCell.FullCell } }; + else + return occupiedCells; + } + + /// + /// Relocates the actor such that the top-most, left-most footprint + /// square is at (0, 0). + /// + public ActorPlan AlignFootprint() + { + var footprint = Footprint(); + var first = footprint.Select(kv => kv.Key).OrderBy(cpos => (cpos.Y, cpos.X)).First(); + Location -= new CVec(first.X, first.Y); + return this; + } + + /// + /// + /// Return a WVec center offset (from its WPosLocation) for the actor. + /// + /// + /// For example, for a 1x2 actor on a Rectangular grid, this would be WVec(0, 512, 0). + /// + /// + WVec WVecCenterOffset() + { + var bi = Info.TraitInfoOrDefault(); + if (bi == null) + return new WVec(0, 0, 0); + + var left = int.MaxValue; + var right = int.MinValue; + var top = int.MaxValue; + var bottom = int.MinValue; + foreach (var (cvec, type) in bi.Footprint) + { + if (type == FootprintCellType.Empty) + continue; + left = Math.Min(left, cvec.X); + top = Math.Min(top, cvec.Y); + right = Math.Max(right, cvec.X); + bottom = Math.Max(bottom, cvec.Y); + } + + return CellLayerUtils.CVecToWVec(new CVec(left + right, top + bottom), Map.Grid.Type) / 2; + } + } +} diff --git a/OpenRA.Mods.Common/MapGenerator/CellLayerUtils.cs b/OpenRA.Mods.Common/MapGenerator/CellLayerUtils.cs new file mode 100644 index 0000000000..00afee5935 --- /dev/null +++ b/OpenRA.Mods.Common/MapGenerator/CellLayerUtils.cs @@ -0,0 +1,500 @@ +#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.Primitives; +using OpenRA.Support; + +namespace OpenRA.Mods.Common.MapGenerator +{ + public static class CellLayerUtils + { + static int FloorDiv(int a, int b) + { + var q = Math.DivRem(a, b, out var r); + if (r < 0) + return q - 1; + else + return q; + } + + /// Return true iff a and b have the same grid type and size. + public static bool AreSameShape(CellLayer a, CellLayer b) + { + return a.Size == b.Size && a.GridType == b.GridType; + } + + /// + /// Returns the half-way point between the centers of the top-left (first) and bottom-right + /// (last) MPos cells. This will either lie in the exact center of a cell, an edge between + /// two cells, or a corner between four cells. Note that this might not fit all reasonable + /// or intuitive definitions of a map center, but has convenient properties. + /// + public static WPos Center(CellLayer cellLayer) + { + switch (cellLayer.GridType) + { + case MapGridType.Rectangular: + return new WPos( + cellLayer.Size.Width * 512, + cellLayer.Size.Height * 512, + 0); + case MapGridType.RectangularIsometric: + return new WPos( + (cellLayer.Size.Width * 2 + (~cellLayer.Size.Height & 1)) * 362, + (cellLayer.Size.Height + 1) * 362, + 0); + default: + throw new NotImplementedException(); + } + } + + /// Get the WPos of the -X-Y corner of a CPos cell. + public static WPos CornerToWPos(CPos xy, MapGridType gridType) + { + switch (gridType) + { + case MapGridType.Rectangular: + return new WPos( + xy.X * 1024, + xy.Y * 1024, + 0); + case MapGridType.RectangularIsometric: + return new WPos( + (xy.X - xy.Y) * 724 + 724, + (xy.X + xy.Y) * 724, + 0); + default: + throw new NotImplementedException(); + } + } + + /// Get the WVec representing the same translation as the given CVec. + public static WVec CVecToWVec(CVec cvec, MapGridType gridType) + { + switch (gridType) + { + case MapGridType.Rectangular: + return new WVec( + cvec.X * 1024, + cvec.Y * 1024, + 0); + case MapGridType.RectangularIsometric: + return new WVec( + (cvec.X - cvec.Y) * 724, + (cvec.X + cvec.Y) * 724, + 0); + default: + throw new NotImplementedException(); + } + } + + /// Get the WPos center of a CPos cell. + public static WPos CPosToWPos(CPos cpos, MapGridType gridType) + { + var wvec = CVecToWVec(new CVec(cpos.X, cpos.Y), gridType); + switch (gridType) + { + case MapGridType.Rectangular: + return new WPos(512, 512, 0) + wvec; + case MapGridType.RectangularIsometric: + return new WPos(724, 724, 0) + wvec; + default: + throw new NotImplementedException(); + } + } + + /// + /// Find the CPos cell in which a WPos position lies. WPos positions on + /// an edge or corner match the CPos with higher X and/or Y positions. + /// + public static CPos WPosToCPos(WPos wpos, MapGridType gridType) + { + switch (gridType) + { + case MapGridType.Rectangular: + return new CPos( + FloorDiv(wpos.X, 1024), + FloorDiv(wpos.Y, 1024), + 0); + case MapGridType.RectangularIsometric: + return new CPos( + FloorDiv(wpos.Y + wpos.X - 724, 1448), + FloorDiv(wpos.Y - wpos.X + 724, 1448), + 0); + default: + throw new NotImplementedException(); + } + } + + /// Get the WPos center of an MPos cell. + public static WPos MPosToWPos(MPos mpos, MapGridType gridType) + { + return CPosToWPos(mpos.ToCPos(gridType), gridType); + } + + /// + /// Find the MPos cell in which a WPos position lies. WPos positions on + /// an edge or corner match the CPos (not necessarily MPos) with higher + /// X and/or Y positions. + /// + public static MPos WPosToMPos(WPos wpos, MapGridType gridType) + { + return WPosToCPos(wpos, gridType).ToMPos(gridType); + } + + /// + /// Translates CPos-like positions to zero-based positions. + /// + public static int2[][] ToMatrixPoints( + IEnumerable pointArrayArray, CellLayer cellLayer) + { + var cellBounds = CellBounds(cellLayer); + return pointArrayArray + .Select(xys => xys + .Select(xy => new int2(xy.X - cellBounds.Left, xy.Y - cellBounds.Top)) + .ToArray()) + .ToArray(); + } + + /// + /// Translates zero-based positions to CPos-like positions. + /// + public static CPos[][] FromMatrixPoints( + IEnumerable pointArrayArray, CellLayer cellLayer) + { + var cellBounds = CellBounds(cellLayer); + return pointArrayArray + .Select(xys => xys + .Select(xy => new CPos(xy.X + cellBounds.Left, xy.Y + cellBounds.Top)) + .ToArray()) + .ToArray(); + } + + /// + /// + /// Run an action over the inside or outside of a circle of given center and radius in + /// world coordinates. The action is called with cells' MPos, CPos, WPos center, and the + /// squared distance to the WPos center from the circle's center. + /// If outside is true, the action is run for cells outside of the circle instead of the + /// inside. + /// + /// + /// A cell is inside the circle if its center is <= wRadius from wCenter. + /// Coordinates outside of the CellLayer are ignored. + /// + /// + public static void OverCircle( + CellLayer cellLayer, + WPos wCenter, + int wRadius, + bool outside, + Action action) + { + var gridType = cellLayer.GridType; + int minU; + int minV; + int maxU; + int maxV; + if (outside) + { + minU = 0; + minV = 0; + maxU = cellLayer.Size.Width - 1; + maxV = cellLayer.Size.Height - 1; + } + else + { + var mCenter = WPosToMPos(wCenter, gridType); + + var mRadiusU = wRadius / 1448 + 2; + var mRadiusV = wRadius / 724 + 1; + + minU = Math.Max(mCenter.U - mRadiusU, 0); + minV = Math.Max(mCenter.V - mRadiusV, 0); + maxU = Math.Min(mCenter.U + mRadiusU, cellLayer.Size.Width - 1); + maxV = Math.Min(mCenter.V + mRadiusV, cellLayer.Size.Height - 1); + } + + var wRadiusSquared = (long)wRadius * wRadius; + for (var v = minV; v <= maxV; v++) + for (var u = minU; u <= maxU; u++) + { + var mpos = new MPos(u, v); + var cpos = mpos.ToCPos(gridType); + var wpos = CPosToWPos(cpos, gridType); + var offset = wCenter - wpos; + var thisRadiusSquared = offset.LengthSquared; + if (thisRadiusSquared <= wRadiusSquared != outside) + action(mpos, cpos, wpos, thisRadiusSquared); + } + } + + /// + /// Return a linear copy of all entries in a CellLayer, ordered v * width + u, similar to + /// MPos(0, 0), MPos(1, 0), MPos(2, 0), ..., MPos(0, 1), MPos(1, 1), MPos(2, 1), ... + /// + public static T[] Entries(CellLayer cellLayer) + { + var i = 0; + var entries = new T[cellLayer.Size.Width * cellLayer.Size.Height]; + foreach (var value in cellLayer) + entries[i++] = value; + return entries; + } + + /// + /// Uniformally add to or subtract from all matrix cells such that the given quantile, + /// fraction, has the given target value. + /// + public static void CalibrateQuantileInPlace(CellLayer cellLayer, float target, float fraction) + { + var sorted = Entries(cellLayer); + Array.Sort(sorted); + var adjustment = target - MatrixUtils.ArrayQuantile(sorted, fraction); + foreach (var mpos in cellLayer.CellRegion.MapCoords) + cellLayer[mpos] += adjustment; + } + + /// + /// Get the smallest CPos rectangle that contains all cells for the specified grid. + /// + public static Rectangle CellBounds(Size size, MapGridType gridType) + { + switch (gridType) + { + case MapGridType.Rectangular: + return new Rectangle(0, 0, size.Width, size.Height); + + case MapGridType.RectangularIsometric: + { + var maxCX = + new MPos(size.Width - 1, size.Height - 1) + .ToCPos(gridType).X; + var minCY = + new MPos(size.Width - 1, 0) + .ToCPos(gridType).Y; + var maxCY = + new MPos(0, size.Height - 1) + .ToCPos(gridType).Y; + return Rectangle.FromLTRB(0, minCY, maxCX + 1, maxCY + 1); + } + + default: + throw new NotImplementedException(); + } + } + + /// + /// Get the smallest CPos rectangle that contains all cells in a CellLayer. + /// + public static Rectangle CellBounds(CellLayer cellLayer) + { + return CellBounds(cellLayer.Size, cellLayer.GridType); + } + + /// + /// Get the smallest CPos rectangle that contains all cells in a map. + /// + public static Rectangle CellBounds(Map map) + { + return CellBounds(new Size(map.MapSize.X, map.MapSize.Y), map.Grid.Type); + } + + /// + /// Copies a CPos-aligned Matrix into a CellLayer. Depending on the grid type, this may + /// discard data for cells that don't exist in the CellLayer. + /// + public static void FromMatrix( + CellLayer cellLayer, + Matrix matrix, + bool allowOversizedMatrix = false) + { + var cellBounds = CellBounds(cellLayer); + var size = cellBounds.Size.ToInt2(); + if (allowOversizedMatrix) + { + if (matrix.Size.X < size.X || matrix.Size.Y < size.Y) + throw new ArgumentException("source Matrix does not cover destination CellLayer"); + } + else if (matrix.Size != size) + { + throw new ArgumentException("destination and source have incompatible sizes"); + } + + foreach (var cpos in cellLayer.CellRegion) + cellLayer[cpos] = matrix[cpos.X - cellBounds.Left, cpos.Y - cellBounds.Top]; + } + + /// + /// Copies a CellLayer into a CPos-aligned Matrix. Depending on the grid type, this may + /// fill the matrix with some default values for cells that don't exist in the CellLayer. + /// + public static Matrix ToMatrix(CellLayer cellLayer, T defaultValue) + { + var cellBounds = CellBounds(cellLayer); + var matrix = new Matrix(cellBounds.Size.ToInt2()).Fill(defaultValue); + foreach (var cpos in cellLayer.CellRegion) + matrix[cpos.X - cellBounds.Left, cpos.Y - cellBounds.Top] = cellLayer[cpos]; + return matrix; + } + + /// Wrapper around MatrixUtils.BordersToPoints in CPos space. + public static CPos[][] BordersToPoints(CellLayer cellLayer, CellLayer mask = null) + { + if (mask != null) + { + if (!AreSameShape(cellLayer, mask)) + throw new ArgumentException("cellLayer and mask must have same shape."); + } + else + { + mask = new CellLayer(cellLayer.GridType, cellLayer.Size); + mask.Clear(true); + } + + var matrix = ToMatrix(cellLayer, false); + var maskMatrix = ToMatrix(mask, false); + var matrixPoints = MatrixUtils.BordersToPoints(matrix, maskMatrix); + return FromMatrixPoints(matrixPoints, cellLayer); + } + + /// Wrapper around MatrixUtils.ChebyshevRoom in CPos space. + public static void ChebyshevRoom( + CellLayer output, + CellLayer input, + bool outsideValue) + { + var matrix = ToMatrix(input, outsideValue); + var roominess = MatrixUtils.ChebyshevRoom(matrix, outsideValue); + FromMatrix(output, roominess); + } + + /// Wrapper around MatrixUtils.WalkingDistance in CPos space. + public static void WalkingDistances( + CellLayer distances, + CellLayer passable, + IEnumerable seeds, + float maxDistance) + { + var passableMatrix = ToMatrix(passable, false); + var cellBounds = CellBounds(passable); + var int2Seeds = seeds + .Select(cpos => new int2(cpos.X - cellBounds.Left, cpos.Y - cellBounds.Top)); + var distancesMatrix = MatrixUtils.WalkingDistances(passableMatrix, int2Seeds, maxDistance); + FromMatrix(distances, distancesMatrix); + } + + /// + /// Rank all cell values and select the best (greatest compared) value. + /// If there are equally good best candidates, choose one at random. + /// + public static (MPos MPos, T Value) FindRandomBest( + CellLayer cellLayer, + MersenneTwister random, + Comparison comparison) + { + var candidates = new List(); + var best = cellLayer[new MPos(0, 0)]; + foreach (var mpos in cellLayer.CellRegion.MapCoords) + { + var rank = comparison(cellLayer[mpos], best); + if (rank > 0) + { + best = cellLayer[mpos]; + candidates.Clear(); + } + + if (rank >= 0) + candidates.Add(mpos); + } + + var choice = candidates[random.Next(candidates.Count)]; + return (choice, best); + } + + /// + /// Pick a random MPos position in a CellLayer where each cell is a + /// selection weight. + /// + public static MPos PickWeighted(CellLayer weights, MersenneTwister random) + { + var entries = Entries(weights); + var choice = random.PickWeighted(entries); + var v = Math.DivRem(choice, weights.Size.Width, out var u); + return new MPos(u, v); + } + + /// + /// + /// Perform a generic flood fill starting at seeds [(cpos, prop), ...]. + /// + /// + /// For each point being considered for fill, filler(cpos, prop) is + /// called with the current position (cpos) and propagation value (prop). + /// filler should return the value to be propagated or null if not to be + /// propagated. Propagation happens to all neighbours (offsets) defined + /// by spread, regardless of whether they have previously been visited, + /// so filler is responsible for terminating propagation by returning + /// nulls. Usually, Direction.Spread4CVec or Direction.Spread8CVec + /// is appropriate as a spread pattern. + /// + /// + /// filler should capture and manipulate any necessary input and output + /// arrays. + /// + /// + /// Each call to filler will have either an equal or greater + /// growth/propagation distance from their seed value than all calls + /// before it. (You can think of this as them being called in ordered + /// growth layers.) + /// + /// + /// Note that filler may be called multiple times for the same spot, + /// perhaps with different propagation values. Within the same + /// growth/propagation distance, filler will be called from values + /// propagated from earlier seeds before values propagated from later + /// seeds. + /// + /// + /// filler is not called for positions outside of cellLayer EXCEPT for + /// points being processed as seed values. + /// + /// + public static void FloodFill( + CellLayer cellLayer, + IEnumerable<(CPos CPos, P Prop)> seeds, + Func filler, + ImmutableArray spread) where P : struct + { + var next = seeds.ToList(); + while (next.Count != 0) + { + var current = next; + next = new List<(CPos, P)>(); + foreach (var (source, prop) in current) + { + var newProp = filler(source, prop); + if (newProp != null) + foreach (var offset in spread) + { + var destination = source + offset; + if (cellLayer.Contains(destination)) + next.Add((destination, (P)newProp)); + } + } + } + } + } +} diff --git a/OpenRA.Mods.Common/MapGenerator/Direction.cs b/OpenRA.Mods.Common/MapGenerator/Direction.cs new file mode 100644 index 0000000000..08398f50ac --- /dev/null +++ b/OpenRA.Mods.Common/MapGenerator/Direction.cs @@ -0,0 +1,296 @@ +#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.Immutable; +using System.Linq; + +namespace OpenRA.Mods.Common.MapGenerator +{ + /// + /// Utilities for simple directions and adjacency. Note that coordinate systems might not agree + /// as to which directions are conceptually left/right or up/down. + /// + public static class Direction + { + /// No direction. + public const int None = -1; + + /// +X ("right"). + public const int R = 0; + + /// +X+Y ("right down"). + public const int RD = 1; + + /// +Y ("down"). + public const int D = 2; + + /// -X+Y ("left down"). + public const int LD = 3; + + /// -X ("left"). + public const int L = 4; + + /// -X-Y ("left up"). + public const int LU = 5; + + /// -Y ("up"). + public const int U = 6; + + /// +X-Y ("right up"). + public const int RU = 7; + + /// Bitmask right. + public const int MR = 1 << R; + + /// Bitmask right-down. + public const int MRD = 1 << RD; + + /// Bitmask down. + public const int MD = 1 << D; + + /// Bitmask left-down. + public const int MLD = 1 << LD; + + /// Bitmask left. + public const int ML = 1 << L; + + /// Bitmask left-up. + public const int MLU = 1 << LU; + + /// Bitmask up. + public const int MU = 1 << U; + + /// Bitmask right-up. + public const int MRU = 1 << RU; + + /// Adjacent offsets with directions, excluding diagonals. + public static readonly ImmutableArray<(int2, int)> Spread4D = ImmutableArray.Create(new[] + { + (new int2(1, 0), R), + (new int2(0, 1), D), + (new int2(-1, 0), L), + (new int2(0, -1), U) + }); + + /// Adjacent offsets, excluding diagonals. + public static readonly ImmutableArray Spread4 = + Spread4D.Select(((int2 XY, int _) v) => v.XY).ToImmutableArray(); + + /// + /// Adjacent offsets, excluding diagonals. Assumes that CVec(1, 0) + /// corresponds to Direction.R. + /// + public static readonly ImmutableArray Spread4CVec = + Spread4.Select(xy => new CVec(xy.X, xy.Y)).ToImmutableArray(); + + /// Adjacent offsets with directions, including diagonals. + public static readonly ImmutableArray<(int2, int)> Spread8D = ImmutableArray.Create(new[] + { + (new int2(1, 0), R), + (new int2(1, 1), RD), + (new int2(0, 1), D), + (new int2(-1, 1), LD), + (new int2(-1, 0), L), + (new int2(-1, -1), LU), + (new int2(0, -1), U), + (new int2(1, -1), RU) + }); + + /// Adjacent offsets, including diagonals. + public static readonly ImmutableArray Spread8 = + Spread8D.Select(((int2 XY, int _) v) => v.XY).ToImmutableArray(); + + /// + /// Adjacent offsets, including diagonals. Assumes that CVec(1, 0) + /// corresponds to Direction.R. + /// + public static readonly ImmutableArray Spread8CVec = + Spread8.Select(xy => new CVec(xy.X, xy.Y)).ToImmutableArray(); + + /// Convert a non-none direction to an int2 offset. + public static int2 ToInt2(int d) + { + if (d >= 0 && d < 8) + return Spread8[d]; + else + throw new ArgumentException("bad direction"); + } + + /// + /// Convert a non-none direction to a CVec offset. Assumes that + /// CVec(1, 0) corresponds to Direction.R. + /// + public static CVec ToCVec(int d) + { + if (d >= 0 && d < 8) + return Spread8CVec[d]; + else + throw new ArgumentException("bad direction"); + } + + /// + /// Convert an offset (of arbitrary non-zero magnitude) to a direction. + /// Supplying a zero-offset will throw. + /// + public static int FromOffset(int dx, int dy) + { + if (dx > 0) + { + if (dy > 0) + return RD; + else if (dy < 0) + return RU; + else + return R; + } + else if (dx < 0) + { + if (dy > 0) + return LD; + else if (dy < 0) + return LU; + else + return L; + } + else + { + if (dy > 0) + return D; + else if (dy < 0) + return U; + else + throw new ArgumentException("Bad direction"); + } + } + + /// + /// Convert an offset (of arbitrary non-zero magnitude) to a direction. + /// Supplying a zero-offset will throw. + /// + public static int FromInt2(int2 delta) + => FromOffset(delta.X, delta.Y); + + /// + /// Convert an offset (of arbitrary non-zero magnitude) to a direction. + /// Supplying a zero-offset will throw. Assumes that CVec(1, 0) + /// corresponds to Direction.R. + /// + public static int FromCVec(CVec delta) + => FromOffset(delta.X, delta.Y); + + /// + /// Convert an offset (of arbitrary non-zero magnitude) to a non-diagonal direction. + /// Supplying a zero-offset will throw. + /// + public static int FromOffsetNonDiagonal(int dx, int dy) + { + if (dx - dy > 0 && dx + dy >= 0) + return R; + if (dy + dx > 0 && dy - dx >= 0) + return D; + if (-dx + dy > 0 && -dx - dy >= 0) + return L; + if (-dy - dx > 0 && -dy + dx >= 0) + return U; + throw new ArgumentException("bad direction"); + } + + /// + /// Convert an offset (of arbitrary non-zero magnitude) to a + /// non-diagonal direction. Supplying a zero-offset will throw. + /// + public static int FromInt2NonDiagonal(int2 delta) + => FromOffsetNonDiagonal(delta.X, delta.Y); + + /// + /// Convert an offset (of arbitrary non-zero magnitude) to a + /// non-diagonal direction. Supplying a zero-offset will throw. Assumes + /// that CVec(1, 0) corresponds to Direction.R. + /// + public static int FromCVecNonDiagonal(CVec delta) + => FromOffsetNonDiagonal(delta.X, delta.Y); + + /// Return the opposite direction. + public static int Reverse(int direction) + { + if (direction == None) + return None; + return direction ^ 4; + } + + /// Convert a direction to a short string, like "None", "R", "RD", etc. + public static string ToString(int direction) + { + switch (direction) + { + case None: return "None"; + case R: return "R"; + case RD: return "RD"; + case D: return "D"; + case LD: return "LD"; + case L: return "L"; + case LU: return "LU"; + case U: return "U"; + case RU: return "RU"; + default: throw new ArgumentException("bad direction"); + } + } + + /// Count the number of set bits in a direction mask. + public static int Count(int dm) + { + var count = 0; + for (var m = dm; m != 0; m >>= 1) + if ((m & 1) == 1) + count++; + + return count; + } + + /// Finds the only direction set in a direction mask or returns NONE. + public static int FromMask(int mask) + { + switch (mask) + { + case MR: return R; + case MRD: return RD; + case MD: return D; + case MLD: return LD; + case ML: return L; + case MLU: return LU; + case MU: return U; + case MRU: return RU; + default: return None; + } + } + + /// True if diagonal, false if horizontal/vertical, throws otherwise. + public static bool IsDiagonal(int direction) + { + switch (direction) + { + case R: + case D: + case L: + case U: + return false; + case RD: + case LD: + case LU: + case RU: + return true; + default: + throw new ArgumentException("NONE or bad direction"); + } + } + } +} diff --git a/OpenRA.Mods.Common/MapGenerator/MapGeneratorSettings.cs b/OpenRA.Mods.Common/MapGenerator/MapGeneratorSettings.cs new file mode 100644 index 0000000000..64e7700b5a --- /dev/null +++ b/OpenRA.Mods.Common/MapGenerator/MapGeneratorSettings.cs @@ -0,0 +1,364 @@ +#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; + +namespace OpenRA.Mods.Common.MapGenerator +{ + public sealed class MapGeneratorSettings + { + /// How an option should be treated for UI purposes. + public enum UiType + { + Hidden, + DropDown, + Checkbox, + Integer, + Float, + String, + } + + /// Represents a bunch of settings, along with UI information. + public sealed class Choice + { + /// Uniquely identifies a Choice within an Option. + [FieldLoader.Ignore] + public readonly string Id; + + /// The label to use for UI selection. Post-fluent. + [FieldLoader.Ignore] + public readonly string Label = null; + + /// Only offer the Choice for this tileset. (If null, show for all.) + public readonly string Tileset = null; + + /// (Partial) settings to combine into the final overall settings. + [FieldLoader.LoadUsing(nameof(SettingsLoader))] + public readonly MiniYaml Settings; + + public Choice(string id, MiniYaml my) + { + Id = id; + FieldLoader.Load(this, my); + var label = my.NodeWithKeyOrDefault("Label")?.Value.Value; + if (label != null) + Label = FluentProvider.GetMessage(label); + } + + /// Create a choice that represents a top-level setting with a given value. + public Choice(string setting, string value) + { + Id = value; + Label = value; + Tileset = null; + Settings = new MiniYaml(null, new[] { new MiniYamlNode(setting, value) }); + } + + public static void DumpFluent(MiniYaml my, List references) + { + var label = my.NodeWithKeyOrDefault("Label")?.Value.Value; + if (label != null) + references.Add(label); + } + + static MiniYaml SettingsLoader(MiniYaml my) => my.NodeWithKey("Settings").Value; + + /// Check whether this choice is permitted for this map. + public bool Allowed(Map map) + { + if (Tileset != null && map.Tileset != Tileset) + return false; + + return true; + } + + /// For single-setting choices, creates a new choice for that setting with a given value. + public Choice NewValue(string value) + { + if (Settings.Nodes.Length != 1) + throw new InvalidOperationException("NewValue can only be used on single-setting Choices"); + return new(Settings.Nodes[0].Key, value); + } + } + + public sealed class Option + { + /// Unique identifier for this Option. + [FieldLoader.Ignore] + public readonly string Id; + + /// The label to use for UI selection. Post-fluent. + [FieldLoader.Ignore] + public readonly string Label = null; + + /// + /// For multiple-choice options (dropdowns/checkboxes) holds the allowed Choices. + /// For text entry Options, contains the default value. + /// If this is empty, the map is not compatible with the generator. + /// + [FieldLoader.Ignore] + public readonly IReadOnlyList Choices; + + /// + /// The default Choice for this option. + /// Default will be ignored if it is not valid for the map. + /// + [FieldLoader.Ignore] + public readonly Choice Default; + + public readonly double Min = double.NegativeInfinity; + public readonly double Max = double.PositiveInfinity; + + /// Whether the Option can be randomized. + public readonly bool Random = false; + + /// How an option should be treated for UI purposes. + [FieldLoader.Ignore] + public readonly UiType Ui; + + /// Settings layering priority. Higher overrides lower. + public readonly int Priority = 0; + + public Option(string id, MiniYaml my, Map map) + { + Id = id; + FieldLoader.Load(this, my); + var label = my.NodeWithKeyOrDefault("Label")?.Value.Value; + if (label != null) + Label = FluentProvider.GetMessage(label); + + var choices = new List(); + Choices = choices; + + var integerSetting = my.NodeWithKeyOrDefault("Integer"); + var floatSetting = my.NodeWithKeyOrDefault("Float"); + var stringSetting = my.NodeWithKeyOrDefault("String"); + var checkboxSetting = my.NodeWithKeyOrDefault("Checkbox"); + var simpleChoiceSetting = my.NodeWithKeyOrDefault("SimpleChoice"); + var textSetting = integerSetting ?? floatSetting ?? stringSetting; + if (textSetting != null) + { + var setting = textSetting.Value.Value; + var value = my.NodeWithKeyOrDefault("Default")?.Value.Value ?? ""; + var choice = new Choice(setting, value); + choices.Add(choice); + Default = choice; + Ui = + integerSetting != null ? UiType.Integer : + floatSetting != null ? UiType.Float : + UiType.String; + + if (Ui == UiType.Integer) + { + if (Min == double.NegativeInfinity) + Min = int.MinValue; + if (Max == double.PositiveInfinity) + Max = int.MaxValue; + } + } + else if (checkboxSetting != null) + { + var setting = checkboxSetting.Value.Value; + var falseChoice = new Choice(setting, "False"); + var trueChoice = new Choice(setting, "True"); + choices.Add(falseChoice); + choices.Add(trueChoice); + var enabled = (my.NodeWithKeyOrDefault("Default")?.Value.Value ?? "False") == "True"; + Default = enabled ? trueChoice : falseChoice; + Ui = UiType.Checkbox; + } + else + { + if (simpleChoiceSetting != null) + { + var setting = simpleChoiceSetting.Value.Value; + var values = simpleChoiceSetting.Value + .NodeWithKey("Values").Value.Value + .Split(','); + foreach (var value in values) + choices.Add(new Choice(setting, value)); + } + else + { + foreach (var node in my.Nodes) + { + var split = node.Key.Split('@'); + if (split[0] == "Choice") + { + string choiceId = null; + if (split.Length >= 2) + choiceId = split[1]; + var choice = new Choice(choiceId, node.Value); + if (choice.Allowed(map)) + choices.Add(choice); + } + } + } + + if (Choices.Count > 0) + { + var defaultNode = my.NodeWithKeyOrDefault("Default"); + if (defaultNode != null) + { + Default = Choices.FirstOrDefault(choice => choice.Id == defaultNode.Value.Value); + if (Default == null) + throw new YamlException($"Option `{id}` default choice `{defaultNode.Value.Value}` is not valid"); + } + else + { + Default = Choices[0]; + } + } + + Ui = Label == null ? UiType.Hidden : UiType.DropDown; + } + } + + public static void DumpFluent(MiniYaml my, List references) + { + var label = my.NodeWithKeyOrDefault("Label")?.Value.Value; + if (label != null) + references.Add(label); + foreach (var node in my.Nodes) + if (node.Key.Split('@')[0] == "Choice") + Choice.DumpFluent(node.Value, references); + } + + public bool ValidateChoice(Choice choice) + { + switch (Ui) + { + case UiType.Integer: + { + var valid = long.TryParse(choice.Id, out var value); + if (value < Min || value > Max) + valid = false; + return valid; + } + + case UiType.Float: + { + var valid = double.TryParse(choice.Id, out var value); + if (value < Min || value > Max) + valid = false; + return valid; + } + + case UiType.String: + return true; + + default: + return Choices.Contains(choice); + } + } + + public Choice RandomChoice() + { + if (Random) + { + var random = (long)Guid.NewGuid().GetHashCode() - int.MinValue; + switch (Ui) + { + case UiType.Integer: + var intRandom = (int)(random % (long)(Max - Min + 1) + (long)Min); + return Default.NewValue(intRandom.ToStringInvariant()); + case UiType.Float: + var floatRandom = (float)((double)0xffffffff * random / (Max - Min) + Min); + return Default.NewValue(floatRandom.ToStringInvariant()); + case UiType.Checkbox: + case UiType.DropDown: + if (Choices.Count == 0) + throw new InvalidOperationException($"Map is not compatible with Option `{Id}`"); + return Choices[(int)(random % Choices.Count)]; + default: + throw new InvalidOperationException($"Option `{Id}` does not have a randomizable type"); + } + } + else + { + return Default; + } + } + } + + public readonly IReadOnlyList