Fork from OpenRA/OpenRA with one-click launch script (start-ra.cmd) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
977 lines
37 KiB
C#
977 lines
37 KiB
C#
#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.Frozen;
|
|
using System.Collections.Generic;
|
|
using System.Collections.Immutable;
|
|
using System.Linq;
|
|
using OpenRA.Mods.Common.MapGenerator;
|
|
using OpenRA.Mods.Common.Terrain;
|
|
using OpenRA.Support;
|
|
using OpenRA.Traits;
|
|
using static OpenRA.Mods.Common.Traits.ResourceLayerInfo;
|
|
|
|
namespace OpenRA.Mods.Common.Traits
|
|
{
|
|
[TraitLocation(SystemActors.EditorWorld)]
|
|
public sealed class ExperimentalMapGeneratorInfo : TraitInfo, IEditorMapGeneratorInfo
|
|
{
|
|
[FieldLoader.Require]
|
|
public readonly string Type = null;
|
|
|
|
[FieldLoader.Require]
|
|
[FluentReference]
|
|
public readonly string Name = null;
|
|
|
|
[FieldLoader.Require]
|
|
[Desc("Tilesets that are compatible with this map generator.")]
|
|
public readonly ImmutableArray<string> Tilesets = default;
|
|
|
|
[FluentReference]
|
|
[Desc("The title to use for generated maps.")]
|
|
public readonly string MapTitle = "label-random-map";
|
|
|
|
[Desc("The widget tree to open when the tool is selected.")]
|
|
public readonly string PanelWidget = "MAP_GENERATOR_TOOL_PANEL";
|
|
|
|
// This is purely of interest to the linter.
|
|
[FieldLoader.LoadUsing(nameof(FluentReferencesLoader))]
|
|
[FluentReference]
|
|
public readonly ImmutableArray<string> FluentReferences = default;
|
|
|
|
[FieldLoader.LoadUsing(nameof(SettingsLoader))]
|
|
public readonly MiniYaml Settings;
|
|
|
|
string IMapGeneratorInfo.Type => Type;
|
|
string IMapGeneratorInfo.Name => Name;
|
|
string IMapGeneratorInfo.MapTitle => MapTitle;
|
|
ImmutableArray<string> IEditorMapGeneratorInfo.Tilesets => Tilesets;
|
|
|
|
static MiniYaml SettingsLoader(MiniYaml my)
|
|
{
|
|
return my.NodeWithKey("Settings").Value;
|
|
}
|
|
|
|
static object FluentReferencesLoader(MiniYaml my)
|
|
{
|
|
return new MapGeneratorSettings(null, my.NodeWithKey("Settings").Value)
|
|
.Options.SelectMany(o => o.GetFluentReferences()).ToImmutableArray();
|
|
}
|
|
|
|
const int FractionMax = Terraformer.FractionMax;
|
|
const int EntityBonusMax = 1000000;
|
|
|
|
sealed class Parameters
|
|
{
|
|
[FieldLoader.Require]
|
|
public readonly int Seed = default;
|
|
[FieldLoader.Require]
|
|
public readonly int Rotations = default;
|
|
[FieldLoader.LoadUsing(nameof(MirrorLoader))]
|
|
public readonly Symmetry.Mirror Mirror = default;
|
|
[FieldLoader.Require]
|
|
public readonly int Players = default;
|
|
[FieldLoader.Require]
|
|
public readonly int TerrainFeatureSize = default;
|
|
[FieldLoader.Require]
|
|
public readonly int ForestFeatureSize = default;
|
|
[FieldLoader.Require]
|
|
public readonly int ResourceFeatureSize = default;
|
|
[FieldLoader.Require]
|
|
public readonly int CivilianBuildingsFeatureSize = default;
|
|
[FieldLoader.Require]
|
|
public readonly int Water = default;
|
|
[FieldLoader.Require]
|
|
public readonly int Mountains = default;
|
|
[FieldLoader.Require]
|
|
public readonly int Forests = default;
|
|
[FieldLoader.Require]
|
|
public readonly int ForestCutout = default;
|
|
[FieldLoader.Require]
|
|
public readonly int MaximumCutoutSpacing = default;
|
|
[FieldLoader.Require]
|
|
public readonly int ExternalCircularBias = default;
|
|
[FieldLoader.Require]
|
|
public readonly int TerrainSmoothing = default;
|
|
[FieldLoader.Require]
|
|
public readonly int SmoothingThreshold = default;
|
|
public readonly int MinimumCoastStraight = -1;
|
|
[FieldLoader.Require]
|
|
public readonly int MinimumLandSeaThickness = default;
|
|
[FieldLoader.Require]
|
|
public readonly int MinimumMountainThickness = default;
|
|
[FieldLoader.Require]
|
|
public readonly int MaximumAltitude = default;
|
|
[FieldLoader.Require]
|
|
public readonly int RoughnessRadius = default;
|
|
[FieldLoader.Require]
|
|
public readonly int Roughness = default;
|
|
public readonly int WaterRoughness = 0;
|
|
[FieldLoader.Require]
|
|
public readonly int MinimumTerrainContourSpacing = default;
|
|
public readonly int MinimumBeachLength = 0;
|
|
public readonly int MinimumWaterCliffLength = 0;
|
|
[FieldLoader.Require]
|
|
public readonly int MinimumCliffLength = default;
|
|
[FieldLoader.Require]
|
|
public readonly int ForestClumpiness = default;
|
|
[FieldLoader.Require]
|
|
public readonly bool DenyWalledAreas = default;
|
|
[FieldLoader.Require]
|
|
public readonly int EnforceSymmetry = default;
|
|
[FieldLoader.Require]
|
|
public readonly bool Roads = default;
|
|
[FieldLoader.Require]
|
|
public readonly int RoadSpacing = default;
|
|
[FieldLoader.Require]
|
|
public readonly int RoadShrink = default;
|
|
[FieldLoader.Require]
|
|
public readonly bool CreateEntities = default;
|
|
[FieldLoader.Require]
|
|
public readonly int AreaEntityBonus = default;
|
|
[FieldLoader.Require]
|
|
public readonly int PlayerCountEntityBonus = default;
|
|
[FieldLoader.Require]
|
|
public readonly int CentralSpawnReservationFraction = default;
|
|
[FieldLoader.Require]
|
|
public readonly int ResourceSpawnReservation = default;
|
|
[FieldLoader.Require]
|
|
public readonly int SpawnRegionSize = default;
|
|
[FieldLoader.Require]
|
|
public readonly int SpawnBuildSize = default;
|
|
[FieldLoader.Require]
|
|
public readonly int MinimumSpawnRadius = default;
|
|
[FieldLoader.Require]
|
|
public readonly int SpawnResourceSpawns = default;
|
|
[FieldLoader.Require]
|
|
public readonly int SpawnReservation = default;
|
|
[FieldLoader.Require]
|
|
public readonly int SpawnResourceBias = default;
|
|
[FieldLoader.Require]
|
|
public readonly int ResourcesPerPlayer = default;
|
|
[FieldLoader.Require]
|
|
public readonly int OreUniformity = default;
|
|
[FieldLoader.Require]
|
|
public readonly int OreClumpiness = default;
|
|
[FieldLoader.Require]
|
|
public readonly int MaximumExpansionResourceSpawns = default;
|
|
[FieldLoader.Require]
|
|
public readonly int MaximumResourceSpawnsPerExpansion = default;
|
|
[FieldLoader.Require]
|
|
public readonly int MinimumExpansionSize = default;
|
|
[FieldLoader.Require]
|
|
public readonly int MaximumExpansionSize = default;
|
|
[FieldLoader.Require]
|
|
public readonly int ExpansionInner = default;
|
|
[FieldLoader.Require]
|
|
public readonly int ExpansionBorder = default;
|
|
[FieldLoader.Require]
|
|
public readonly int MinimumBuildings = default;
|
|
[FieldLoader.Require]
|
|
public readonly int MaximumBuildings = default;
|
|
[FieldLoader.LoadUsing(nameof(BuildingWeightsLoader))]
|
|
public readonly IReadOnlyDictionary<string, int> BuildingWeights = default;
|
|
[FieldLoader.Require]
|
|
public readonly int CivilianBuildings = default;
|
|
[FieldLoader.Require]
|
|
public readonly int CivilianBuildingDensity = default;
|
|
[FieldLoader.Require]
|
|
public readonly int MinimumCivilianBuildingDensity = default;
|
|
[FieldLoader.Require]
|
|
public readonly int CivilianBuildingDensityRadius = default;
|
|
|
|
[FieldLoader.Require]
|
|
public readonly ushort LandTile = default;
|
|
[FieldLoader.Require]
|
|
public readonly ushort WaterTile = default;
|
|
[FieldLoader.Ignore]
|
|
public readonly IReadOnlyList<MultiBrush> SegmentedBrushes;
|
|
[FieldLoader.Ignore]
|
|
public readonly IReadOnlyList<MultiBrush> ForestObstacles;
|
|
[FieldLoader.Ignore]
|
|
public readonly IReadOnlyList<MultiBrush> UnplayableObstacles;
|
|
[FieldLoader.Ignore]
|
|
public readonly IReadOnlyList<MultiBrush> CivilianBuildingsObstacles;
|
|
[FieldLoader.Ignore]
|
|
public readonly IReadOnlyDictionary<ushort, IReadOnlyList<MultiBrush>> RepaintTiles;
|
|
|
|
[FieldLoader.Ignore]
|
|
public readonly ResourceTypeInfo DefaultResource;
|
|
[FieldLoader.Ignore]
|
|
public readonly IReadOnlyDictionary<string, ResourceTypeInfo> ResourceSpawnSeeds;
|
|
[FieldLoader.LoadUsing(nameof(ResourceSpawnWeightsLoader))]
|
|
public readonly IReadOnlyDictionary<string, int> ResourceSpawnWeights = default;
|
|
|
|
[FieldLoader.Ignore]
|
|
public readonly IReadOnlySet<byte> ClearTerrain;
|
|
[FieldLoader.Ignore]
|
|
public readonly IReadOnlySet<byte> PlayableTerrain;
|
|
[FieldLoader.Ignore]
|
|
public readonly IReadOnlySet<byte> DominantTerrain;
|
|
[FieldLoader.Ignore]
|
|
public readonly IReadOnlySet<byte> ZoneableTerrain;
|
|
[FieldLoader.Ignore]
|
|
public readonly IReadOnlyList<string> ClearSegmentTypes;
|
|
[FieldLoader.Ignore]
|
|
public readonly IReadOnlyList<string> BeachSegmentTypes;
|
|
[FieldLoader.Ignore]
|
|
public readonly IReadOnlyList<string> WaterCliffSegmentTypes;
|
|
[FieldLoader.Ignore]
|
|
public readonly IReadOnlyList<string> CliffSegmentTypes;
|
|
[FieldLoader.Ignore]
|
|
public readonly IReadOnlyList<string> RoadSegmentTypes;
|
|
|
|
public Parameters(Map map, MiniYaml my)
|
|
{
|
|
FieldLoader.Load(this, my);
|
|
|
|
var terrainInfo = (ITemplatedTerrainInfo)map.Rules.TerrainInfo;
|
|
SegmentedBrushes = MultiBrush.LoadCollection(map, "Segmented");
|
|
ForestObstacles = MultiBrush.LoadCollection(map, my.NodeWithKey("ForestObstacles").Value.Value);
|
|
UnplayableObstacles = MultiBrush.LoadCollection(map, my.NodeWithKey("UnplayableObstacles").Value.Value);
|
|
CivilianBuildingsObstacles = MultiBrush.LoadCollection(map, my.NodeWithKey("CivilianBuildingsObstacles").Value.Value);
|
|
RepaintTiles = my.NodeWithKeyOrDefault("RepaintTiles")?.Value.ToDictionary(
|
|
k =>
|
|
{
|
|
if (Exts.TryParseUshortInvariant(k, out var tile))
|
|
return tile;
|
|
else
|
|
throw new YamlException($"RepaintTile {k} is not a ushort");
|
|
},
|
|
v => MultiBrush.LoadCollection(map, v.Value) as IReadOnlyList<MultiBrush>);
|
|
RepaintTiles ??= ImmutableDictionary<ushort, IReadOnlyList<MultiBrush>>.Empty;
|
|
|
|
var resourceTypes = map.Rules.Actors[SystemActors.World].TraitInfoOrDefault<ResourceLayerInfo>().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<PlayerResourcesInfo>();
|
|
try
|
|
{
|
|
ResourceSpawnSeeds = my.NodeWithKey("ResourceSpawnSeeds").Value
|
|
.ToDictionary(subMy => subMy.Value)
|
|
.ToDictionary(kv => kv.Key, kv => resourceTypes[kv.Value]);
|
|
}
|
|
catch (KeyNotFoundException e)
|
|
{
|
|
throw new YamlException("Bad ResourceSpawnSeeds resource: " + e);
|
|
}
|
|
|
|
switch (Rotations)
|
|
{
|
|
case 1:
|
|
case 2:
|
|
case 4:
|
|
break;
|
|
default:
|
|
EnforceSymmetry = 0;
|
|
break;
|
|
}
|
|
|
|
IReadOnlySet<byte> ParseTerrainIndexes(string key)
|
|
{
|
|
return my.NodeWithKey(key).Value.Value
|
|
.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
|
.Select(terrainInfo.GetTerrainIndex)
|
|
.ToFrozenSet();
|
|
}
|
|
|
|
IReadOnlyList<string> ParseSegmentTypes(string key)
|
|
{
|
|
return my.NodeWithKey(key).Value.Value
|
|
.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
|
.ToImmutableArray();
|
|
}
|
|
|
|
ClearTerrain = ParseTerrainIndexes("ClearTerrain");
|
|
PlayableTerrain = ParseTerrainIndexes("PlayableTerrain");
|
|
DominantTerrain = ParseTerrainIndexes("DominantTerrain");
|
|
ZoneableTerrain = ParseTerrainIndexes("ZoneableTerrain");
|
|
|
|
ClearSegmentTypes = ParseSegmentTypes("ClearSegmentTypes");
|
|
BeachSegmentTypes = ParseSegmentTypes("BeachSegmentTypes");
|
|
if (WaterRoughness > 0)
|
|
WaterCliffSegmentTypes = ParseSegmentTypes("WaterCliffSegmentTypes");
|
|
|
|
CliffSegmentTypes = ParseSegmentTypes("CliffSegmentTypes");
|
|
RoadSegmentTypes = ParseSegmentTypes("RoadSegmentTypes");
|
|
|
|
Validate(terrainInfo);
|
|
}
|
|
|
|
static object MirrorLoader(MiniYaml my)
|
|
{
|
|
if (Symmetry.TryParseMirror(my.NodeWithKey("Mirror").Value.Value, out var mirror))
|
|
return mirror;
|
|
else
|
|
throw new YamlException($"Invalid Mirror value `{my.NodeWithKey("Mirror").Value.Value}`");
|
|
}
|
|
|
|
static IReadOnlyDictionary<string, int> BuildingWeightsLoader(MiniYaml my)
|
|
{
|
|
return my.NodeWithKey("BuildingWeights").Value.ToDictionary(subMy =>
|
|
{
|
|
if (Exts.TryParseInt32Invariant(subMy.Value, out var f))
|
|
return f;
|
|
else
|
|
throw new YamlException($"Invalid building weight `{subMy.Value}`");
|
|
});
|
|
}
|
|
|
|
static IReadOnlyDictionary<string, int> ResourceSpawnWeightsLoader(MiniYaml my)
|
|
{
|
|
return my.NodeWithKey("ResourceSpawnWeights").Value.ToDictionary(subMy =>
|
|
{
|
|
if (Exts.TryParseInt32Invariant(subMy.Value, out var f))
|
|
return f;
|
|
else
|
|
throw new YamlException($"Invalid resource spawn weight `{subMy.Value}`");
|
|
});
|
|
}
|
|
|
|
public void Validate(ITemplatedTerrainInfo terrainInfo)
|
|
{
|
|
if (Rotations < 1)
|
|
throw new MapGenerationException("Rotations must be >= 1");
|
|
if (TerrainFeatureSize < 1)
|
|
throw new MapGenerationException("TerrainFeatureSize must be >= 1");
|
|
if (ForestFeatureSize < 1)
|
|
throw new MapGenerationException("ForestFeatureSize must be >= 1");
|
|
if (ResourceFeatureSize < 1)
|
|
throw new MapGenerationException("ResourceFeatureSize must be >= 1");
|
|
if (CivilianBuildingsFeatureSize < 1)
|
|
throw new MapGenerationException("CivilianBuildingsFeatureSize must be >= 1");
|
|
if (TerrainSmoothing < 0 || TerrainSmoothing > MatrixUtils.MaxBinomialKernelRadius)
|
|
throw new MapGenerationException($"TerrainSmoothing must be between 0 and {MatrixUtils.MaxBinomialKernelRadius} inclusive");
|
|
if (WaterRoughness > 0 && MinimumCoastStraight < 0)
|
|
throw new MapGenerationException("MinimumCoastStraight must be >= 0");
|
|
if (SmoothingThreshold < (FractionMax + 1) / 2 || SmoothingThreshold > FractionMax)
|
|
throw new MapGenerationException($"SmoothingThreshold must be between {(FractionMax + 1) / 2} and {FractionMax} inclusive");
|
|
if (MinimumLandSeaThickness < 1)
|
|
throw new MapGenerationException("MinimumLandSeaThickness must be >= 1");
|
|
if (MinimumMountainThickness < 1)
|
|
throw new MapGenerationException("MinimumMountainThickness must be >= 1");
|
|
if (Water < 0 || Water > FractionMax)
|
|
throw new MapGenerationException($"Water must be between 0 and {FractionMax} inclusive");
|
|
if (Forests < 0 || Forests > FractionMax)
|
|
throw new MapGenerationException($"Forest must be between 0 and {FractionMax} inclusive");
|
|
if (ForestCutout < 0)
|
|
throw new MapGenerationException("ForestCutout must be >= 0");
|
|
if (MaximumCutoutSpacing < 0)
|
|
throw new MapGenerationException("TopologyAugmentationThreshold must be >= 0");
|
|
if (ForestClumpiness < 0)
|
|
throw new MapGenerationException("ForestClumpiness must be >= 0");
|
|
if (Mountains < 0 || Mountains > FractionMax)
|
|
throw new MapGenerationException($"Mountains must be between 0 and {FractionMax} inclusive");
|
|
if (Roughness < 0 || Roughness > FractionMax)
|
|
throw new MapGenerationException("Roughness must be between 0 and {FractionMax}");
|
|
if (WaterRoughness < 0 || WaterRoughness > FractionMax)
|
|
throw new MapGenerationException("WaterRoughness must be between 0 and {FractionMax}");
|
|
if (RoughnessRadius < 1)
|
|
throw new MapGenerationException("RoughnessRadius must be >= 1");
|
|
if (MaximumAltitude < 0)
|
|
throw new MapGenerationException("MaximumAltitude must be >= 0");
|
|
if (MinimumTerrainContourSpacing < 0)
|
|
throw new MapGenerationException("MinimumTerrainContourSpacing must be >= 0");
|
|
if (WaterRoughness > 0 && MinimumBeachLength < 1)
|
|
throw new MapGenerationException("MinimumBeachLength must be >= 1");
|
|
if (WaterRoughness > 0 && MinimumCliffLength < 1)
|
|
throw new MapGenerationException("MinimumWaterCliffLength must be >= 1");
|
|
if (MinimumCliffLength < 1)
|
|
throw new MapGenerationException("MinimumCliffLength must be >= 1");
|
|
if (RoadSpacing < 0)
|
|
throw new MapGenerationException("RoadSpacing must be >= 0");
|
|
if (RoadShrink < 0)
|
|
throw new MapGenerationException("RoadShrink must be >= 0");
|
|
if (Players < 0)
|
|
throw new MapGenerationException("Players must be >= 0");
|
|
if (CentralSpawnReservationFraction < 0)
|
|
throw new MapGenerationException("CentralSpawnReservationFraction must be >= 0");
|
|
if (AreaEntityBonus < 0)
|
|
throw new MapGenerationException("PlayableAreaDensityBonus must be >= 0");
|
|
if (PlayerCountEntityBonus < 0)
|
|
throw new MapGenerationException("PlayerCountDensityBonus must be >= 0");
|
|
if (SpawnRegionSize < 1)
|
|
throw new MapGenerationException("SpawnRegionSize must be >= 1");
|
|
if (SpawnReservation < 1)
|
|
throw new MapGenerationException("SpawnReservation must be >= 1");
|
|
if (SpawnBuildSize < 1)
|
|
throw new MapGenerationException("SpawnBuildSize must be >= 1");
|
|
if (MinimumSpawnRadius < 1)
|
|
throw new MapGenerationException("MinimumSpawnRadius must be >= 1");
|
|
if (SpawnResourceSpawns < 0)
|
|
throw new MapGenerationException("SpawnResourceSpawns must be >= 0");
|
|
if (ResourceSpawnReservation < 1)
|
|
throw new MapGenerationException("ResourceSpawnReservation must be >= 1");
|
|
if (MaximumExpansionResourceSpawns < 0)
|
|
throw new MapGenerationException("MaximumExpansionResourceSpawns must be >= 0");
|
|
if (MinimumExpansionSize < 1)
|
|
throw new MapGenerationException("MinimumExpansionSize must be >= 1");
|
|
if (MaximumExpansionSize < 1)
|
|
throw new MapGenerationException("MaximumExpansionSize must be >= 1");
|
|
if (MinimumExpansionSize > MaximumExpansionSize)
|
|
throw new MapGenerationException("MinimumExpansionSize must be <= maximumExpansionSize");
|
|
if (ExpansionBorder < 1)
|
|
throw new MapGenerationException("ExpansionBorder must be >= 1");
|
|
if (ExpansionInner < 1)
|
|
throw new MapGenerationException("ExpansionInner must be >= 1");
|
|
if (MaximumResourceSpawnsPerExpansion < 1)
|
|
throw new MapGenerationException("MaximumResourceSpawnsPerExpansion must be >= 1");
|
|
if (MinimumBuildings < 0)
|
|
throw new MapGenerationException("MinimumBuildings must be >= 0");
|
|
if (MaximumBuildings < 0)
|
|
throw new MapGenerationException("MaximumBuildings must be >= 0");
|
|
if (MinimumBuildings > MaximumBuildings)
|
|
throw new MapGenerationException("MinimumBuildings must be <= maximumBuildings");
|
|
if (CivilianBuildings < 0 || CivilianBuildings > FractionMax)
|
|
throw new MapGenerationException($"CivilianBuildings must be between 0 and {FractionMax} inclusive");
|
|
if (CivilianBuildingDensity < 0 || CivilianBuildingDensity > FractionMax)
|
|
throw new MapGenerationException($"CivilianBuildingDensity must be between 0 and {FractionMax} inclusive");
|
|
if (MinimumCivilianBuildingDensity < 0 || MinimumCivilianBuildingDensity > FractionMax)
|
|
throw new MapGenerationException($"MinimumCivilianBuildingDensity must be between 0 and {FractionMax} inclusive");
|
|
if (CivilianBuildingDensityRadius < 0)
|
|
throw new MapGenerationException("CivilianBuildingDensityRadius must be >= 0");
|
|
if (ResourcesPerPlayer < 0)
|
|
throw new MapGenerationException("ResourcesPerPlayer must be >= 0");
|
|
if (OreUniformity < 0)
|
|
throw new MapGenerationException("OreUniformity must be >= 0");
|
|
if (OreClumpiness < 0)
|
|
throw new MapGenerationException("OreClumpiness must be >= 0");
|
|
foreach (var kv in BuildingWeights)
|
|
if (kv.Value < 0)
|
|
throw new MapGenerationException("BuildingWeights.* must be >= 0");
|
|
foreach (var kv in ResourceSpawnWeights)
|
|
if (kv.Value < 0)
|
|
throw new MapGenerationException("ResourceSpawnWeights.* must be >= 0");
|
|
foreach (var kv in ResourceSpawnWeights)
|
|
if (!ResourceSpawnSeeds.ContainsKey(kv.Key))
|
|
throw new MapGenerationException($"ResourceSpawnSeeds does not contain possible resource spawn `{kv.Key}`");
|
|
|
|
if (!(terrainInfo.Templates.TryGetValue(LandTile, out var landTemplate) && landTemplate.Contains(0)))
|
|
throw new MapGenerationException("LandTile is not valid");
|
|
if (!(terrainInfo.Templates.TryGetValue(LandTile, out var waterTemplate) && waterTemplate.Contains(0)))
|
|
throw new MapGenerationException("WaterTile is not valid");
|
|
|
|
if (Players > 32)
|
|
throw new MapGenerationException("Total number of players must not exceed 32");
|
|
|
|
var symmetryCount = Symmetry.RotateAndMirrorProjectionCount(Rotations, Mirror);
|
|
if (Players % symmetryCount != 0)
|
|
throw new MapGenerationException($"Total number of players must be a multiple of {symmetryCount}");
|
|
}
|
|
}
|
|
|
|
public IMapGeneratorSettings GetSettings()
|
|
{
|
|
return new MapGeneratorSettings(this, Settings);
|
|
}
|
|
|
|
public Map Generate(ModData modData, MapGenerationArgs args)
|
|
{
|
|
var terrainInfo = modData.DefaultTerrainInfo[args.Tileset];
|
|
var size = args.Size;
|
|
|
|
var map = new Map(modData, terrainInfo, size);
|
|
var actorPlans = new List<ActorPlan>();
|
|
|
|
var param = new Parameters(map, args.Settings);
|
|
|
|
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");
|
|
|
|
CellLayer<MultiBrush.Replaceability> PlayableToReplaceable()
|
|
{
|
|
var playable = terraformer.CheckSpace(param.PlayableTerrain, true);
|
|
var basicLand = terraformer.CheckSpace(param.LandTile);
|
|
var replace = new CellLayer<MultiBrush.Replaceability>(map);
|
|
foreach (var mpos in map.AllCells.MapCoords)
|
|
if (playable[mpos])
|
|
{
|
|
if (basicLand[mpos])
|
|
replace[mpos] = MultiBrush.Replaceability.Any;
|
|
else
|
|
replace[mpos] = MultiBrush.Replaceability.Actor;
|
|
}
|
|
else
|
|
{
|
|
replace[mpos] = MultiBrush.Replaceability.None;
|
|
}
|
|
|
|
return replace;
|
|
}
|
|
|
|
// Use `random` to derive separate independent random number generators.
|
|
//
|
|
// This prevents changes in one part of the algorithm from affecting randomness in
|
|
// other parts and provides flexibility for future parallel processing.
|
|
//
|
|
// In order to maximize stability, additions should be appended only. Disused
|
|
// derivatives may be deleted but should be replaced with their unused call to
|
|
// random.Next(). All generators should be created unconditionally.
|
|
var random = new MersenneTwister(param.Seed);
|
|
|
|
var elevationRandom = new MersenneTwister(random.Next());
|
|
var coastTilingRandom = new MersenneTwister(random.Next());
|
|
var cliffTilingRandom = new MersenneTwister(random.Next());
|
|
var forestRandom = new MersenneTwister(random.Next());
|
|
var forestTilingRandom = new MersenneTwister(random.Next());
|
|
var symmetryTilingRandom = new MersenneTwister(random.Next());
|
|
var debrisTilingRandom = new MersenneTwister(random.Next());
|
|
var resourceRandom = new MersenneTwister(random.Next());
|
|
var roadTilingRandom = new MersenneTwister(random.Next());
|
|
var playerRandom = new MersenneTwister(random.Next());
|
|
var expansionRandom = new MersenneTwister(random.Next());
|
|
var buildingRandom = new MersenneTwister(random.Next());
|
|
var topologyRandom = new MersenneTwister(random.Next());
|
|
var repaintRandom = new MersenneTwister(random.Next());
|
|
var decorationRandom = new MersenneTwister(random.Next());
|
|
var decorationTilingRandom = new MersenneTwister(random.Next());
|
|
var pickAnyRandom = new MersenneTwister(random.Next());
|
|
|
|
terraformer.InitMap();
|
|
|
|
foreach (var mpos in map.AllCells.MapCoords)
|
|
map.Tiles[mpos] = terraformer.PickTile(pickAnyRandom, param.LandTile);
|
|
|
|
var elevation = terraformer.ElevationNoiseMatrix(
|
|
elevationRandom,
|
|
param.TerrainFeatureSize,
|
|
param.TerrainSmoothing);
|
|
var roughnessMatrix = MatrixUtils.GridVariance(
|
|
elevation,
|
|
param.RoughnessRadius);
|
|
|
|
Matrix<bool> mapShape;
|
|
if (param.ExternalCircularBias == 0)
|
|
mapShape = new Matrix<bool>(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)
|
|
{
|
|
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);
|
|
}
|
|
|
|
landPlan = MatrixUtils.BooleanBlotch(
|
|
landPlan,
|
|
param.TerrainSmoothing,
|
|
param.SmoothingThreshold, /*smoothingThresholdOutOf=*/FractionMax,
|
|
param.MinimumLandSeaThickness,
|
|
/*bias=*/param.Water <= FractionMax / 2);
|
|
|
|
var coast = MatrixUtils.BordersToPoints(landPlan);
|
|
List<TilingPath> coastPaths;
|
|
if (param.WaterRoughness > 0)
|
|
{
|
|
var beachZone = new Terraformer.PathPartitionZone()
|
|
{
|
|
RequiredSomewhere = true,
|
|
SegmentType = param.BeachSegmentTypes[0],
|
|
MinimumLength = param.MinimumBeachLength,
|
|
MaximumDeviation = param.MinimumLandSeaThickness - 1,
|
|
};
|
|
var waterCliffZone = new Terraformer.PathPartitionZone()
|
|
{
|
|
SegmentType = param.WaterCliffSegmentTypes[0],
|
|
MinimumLength = param.MinimumCliffLength,
|
|
MaximumDeviation = param.MinimumLandSeaThickness - 1,
|
|
};
|
|
|
|
var waterCliffMask = MatrixUtils.CalibratedBooleanThreshold(
|
|
roughnessMatrix,
|
|
param.WaterRoughness, FractionMax);
|
|
var partitionMask = waterCliffMask.Map(masked => masked ? waterCliffZone : beachZone);
|
|
coastPaths = terraformer.PartitionPaths(
|
|
coast,
|
|
[beachZone, waterCliffZone],
|
|
partitionMask,
|
|
param.SegmentedBrushes,
|
|
param.MinimumCoastStraight);
|
|
|
|
foreach (var coastPath in coastPaths)
|
|
coastPath
|
|
.OptimizeLoop()
|
|
.ExtendEdge(4);
|
|
}
|
|
else
|
|
{
|
|
coastPaths = CellLayerUtils.FromMatrixPoints(coast, map.Tiles)
|
|
.Select(beach =>
|
|
TilingPath.QuickCreate(
|
|
map,
|
|
param.SegmentedBrushes,
|
|
beach,
|
|
param.MinimumLandSeaThickness - 1,
|
|
param.BeachSegmentTypes[0],
|
|
param.BeachSegmentTypes[0])
|
|
.ExtendEdge(4))
|
|
.ToList();
|
|
}
|
|
|
|
var landCoastWater = terraformer.PaintLoopsAndFill(
|
|
coastTilingRandom,
|
|
coastPaths,
|
|
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 coast");
|
|
|
|
if (param.Mountains > 0)
|
|
{
|
|
var cliffMask = MatrixUtils.CalibratedBooleanThreshold(
|
|
roughnessMatrix,
|
|
param.Roughness, FractionMax);
|
|
var cliffPlan = Matrix<bool>.Zip(landPlan, mapShape, (a, b) => a && b);
|
|
|
|
for (var altitude = 0; altitude < param.MaximumAltitude; altitude++)
|
|
{
|
|
cliffPlan = terraformer.SliceElevation(
|
|
elevation,
|
|
cliffPlan,
|
|
param.Mountains,
|
|
param.MinimumTerrainContourSpacing);
|
|
cliffPlan = MatrixUtils.BooleanBlotch(
|
|
cliffPlan,
|
|
param.TerrainSmoothing,
|
|
param.SmoothingThreshold, /*smoothingThresholdOutOf=*/FractionMax,
|
|
param.MinimumMountainThickness,
|
|
/*bias=*/false);
|
|
var unmaskedCliffs = MatrixUtils.BordersToPoints(cliffPlan);
|
|
var maskedCliffs = MatrixUtils.MaskPathPoints(unmaskedCliffs, cliffMask);
|
|
var cliffs = CellLayerUtils.FromMatrixPoints(maskedCliffs, map.Tiles)
|
|
.Where(cliff => cliff.Length >= param.MinimumCliffLength).ToArray();
|
|
if (cliffs.Length == 0)
|
|
break;
|
|
foreach (var cliff in cliffs)
|
|
{
|
|
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");
|
|
terraformer.PaintTiling(pickAnyRandom, brush);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (param.Forests > 0)
|
|
{
|
|
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,
|
|
param.ForestFeatureSize,
|
|
param.Forests,
|
|
param.ForestClumpiness);
|
|
var replace = PlayableToReplaceable();
|
|
foreach (var mpos in map.AllCells.MapCoords)
|
|
if (!forestNoise[mpos] || !space[mpos] || passages[mpos])
|
|
replace[mpos] = MultiBrush.Replaceability.None;
|
|
terraformer.PaintArea(forestTilingRandom, replace, param.ForestObstacles);
|
|
}
|
|
|
|
if (param.EnforceSymmetry != 0)
|
|
{
|
|
var asymmetries = terraformer.FindAsymmetries(param.DominantTerrain, true, param.EnforceSymmetry == 2);
|
|
terraformer.PaintActors(symmetryTilingRandom, asymmetries, param.ForestObstacles);
|
|
}
|
|
|
|
CellLayer<bool> playable;
|
|
{
|
|
// For circle-in-mountains, the outside is unplayable and should never count as
|
|
// the largest/preferred region.
|
|
CellLayer<bool> poison = null;
|
|
if (param.ExternalCircularBias > 0)
|
|
poison = terraformer.CenteredCircle(
|
|
false, true, CellLayerUtils.Radius(map.Tiles) - new WDist(1024));
|
|
|
|
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 (playable.Count(p => p) < minimumPlayableSpace)
|
|
throw new MapGenerationException("playable space is too small");
|
|
|
|
if (param.DenyWalledAreas)
|
|
{
|
|
// Coast 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 mask = CellLayerUtils.Clone(playable);
|
|
terraformer.ZoneFromOutOfBounds(mask, true);
|
|
terraformer.FillUnmaskedSideAndBorder(
|
|
mask,
|
|
landCoastWater,
|
|
Terraformer.Side.Out,
|
|
cpos => map.Tiles[cpos] = terraformer.PickTile(pickAnyRandom, param.LandTile));
|
|
}
|
|
|
|
var replace = PlayableToReplaceable();
|
|
foreach (var mpos in map.AllCells.MapCoords)
|
|
if (playable[mpos] || !map.Bounds.Contains(mpos.U, mpos.V))
|
|
replace[mpos] = MultiBrush.Replaceability.None;
|
|
|
|
terraformer.PaintArea(debrisTilingRandom, replace, param.UnplayableObstacles);
|
|
}
|
|
}
|
|
|
|
if (param.Roads)
|
|
{
|
|
// TODO: Move or collapse into configuration
|
|
const int RoadMinimumShrinkLength = 12;
|
|
const int RoadStraightenShrink = 4;
|
|
const int RoadStraightenGrow = 2;
|
|
const int RoadInertialRange = 8;
|
|
|
|
var roadPaths = terraformer.PlanRoads(
|
|
terraformer.CheckSpace(param.ClearTerrain, true, false),
|
|
param.RoadSpacing,
|
|
RoadMinimumShrinkLength + 2 * (RoadStraightenShrink + param.RoadShrink));
|
|
foreach (var roadPath in roadPaths)
|
|
{
|
|
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 = tilingPath.Tile(roadTilingRandom)
|
|
?? throw new MapGenerationException("Could not fit tiles for roads");
|
|
terraformer.PaintTiling(pickAnyRandom, brush);
|
|
}
|
|
}
|
|
|
|
if (param.CreateEntities)
|
|
{
|
|
var zoneable = terraformer.GetZoneable(param.ZoneableTerrain, playable);
|
|
|
|
var zoneableArea = zoneable.Count(v => v);
|
|
var symmetryCount = Symmetry.RotateAndMirrorProjectionCount(param.Rotations, param.Mirror);
|
|
var entityMultiplier =
|
|
(long)zoneableArea * param.AreaEntityBonus +
|
|
(long)param.Players * param.PlayerCountEntityBonus;
|
|
var perSymmetryEntityMultiplier = entityMultiplier / symmetryCount;
|
|
|
|
// Spawn generation
|
|
var symmetryPlayers = param.Players / symmetryCount;
|
|
for (var iteration = 0; iteration < symmetryPlayers; iteration++)
|
|
{
|
|
var chosenCPos = terraformer.ChooseSpawnInZoneable(
|
|
playerRandom,
|
|
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 = chosenCPos,
|
|
};
|
|
|
|
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,
|
|
resourceSpawnPreferences,
|
|
param.ResourceSpawnWeights,
|
|
param.SpawnResourceSpawns,
|
|
false,
|
|
new WDist(param.ResourceSpawnReservation * 1024));
|
|
|
|
terraformer.ProjectPlaceDezoneActor(spawn, zoneable, new WDist(param.SpawnReservation * 1024));
|
|
}
|
|
|
|
// Expansions
|
|
{
|
|
var resourceSpawnsRemaining = (int)(param.MaximumExpansionResourceSpawns * perSymmetryEntityMultiplier / EntityBonusMax);
|
|
while (resourceSpawnsRemaining > 0)
|
|
{
|
|
var added = terraformer.AddActorCluster(
|
|
expansionRandom,
|
|
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;
|
|
}
|
|
}
|
|
|
|
// Neutral buildings
|
|
{
|
|
var (buildingTypes, buildingWeights) = Terraformer.SplitDictionary(param.BuildingWeights);
|
|
var targetBuildingCount =
|
|
(param.MaximumBuildings != 0)
|
|
? buildingRandom.Next(
|
|
(int)(param.MinimumBuildings * perSymmetryEntityMultiplier / EntityBonusMax),
|
|
(int)(param.MaximumBuildings * perSymmetryEntityMultiplier / EntityBonusMax) + 1)
|
|
: 0;
|
|
for (var i = 0; i < targetBuildingCount; i++)
|
|
terraformer.AddActor(
|
|
buildingRandom,
|
|
zoneable,
|
|
buildingTypes[buildingRandom.PickWeighted(buildingWeights)]);
|
|
}
|
|
|
|
// Grow resources
|
|
var targetResourceValue = param.ResourcesPerPlayer * entityMultiplier / EntityBonusMax;
|
|
if (targetResourceValue > 0)
|
|
{
|
|
var resourcePattern = terraformer.ResourceNoise(
|
|
resourceRandom,
|
|
param.ResourceFeatureSize,
|
|
param.OreClumpiness,
|
|
param.OreUniformity * 1024 / FractionMax);
|
|
|
|
var resourceBiases = new List<Terraformer.ResourceBias>();
|
|
var wSpawnBuildSizeSq = (long)param.SpawnBuildSize * param.SpawnBuildSize * 1024 * 1024;
|
|
|
|
// Bias towards resource spawns
|
|
foreach (var (actorType, resourceType) in param.ResourceSpawnSeeds.OrderBy(kv => kv.Key))
|
|
{
|
|
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,
|
|
}));
|
|
}
|
|
|
|
// 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),
|
|
}));
|
|
|
|
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 decorationNoise = terraformer.DecorationPattern(
|
|
decorationRandom,
|
|
terraformer.CheckSpace(param.PlayableTerrain, true),
|
|
CellLayerUtils.Intersect([zoneable, terraformer.CheckSpace(param.LandTile)]),
|
|
param.CivilianBuildings,
|
|
param.CivilianBuildingsFeatureSize,
|
|
param.CivilianBuildingDensity,
|
|
param.MinimumCivilianBuildingDensity,
|
|
param.CivilianBuildingDensityRadius);
|
|
terraformer.PaintActors(
|
|
decorationTilingRandom,
|
|
decorationNoise,
|
|
param.CivilianBuildingsObstacles,
|
|
alwaysPreferLargerBrushes: true);
|
|
}
|
|
}
|
|
|
|
// Cosmetically repaint tiles
|
|
terraformer.RepaintTiles(repaintRandom, param.RepaintTiles);
|
|
|
|
terraformer.BakeMap();
|
|
|
|
return map;
|
|
}
|
|
|
|
public override object Create(ActorInitializer init)
|
|
{
|
|
return new ExperimentalMapGenerator(init, this);
|
|
}
|
|
}
|
|
|
|
public class ExperimentalMapGenerator : IEditorTool
|
|
{
|
|
public string Label { get; }
|
|
public string PanelWidget { get; }
|
|
public TraitInfo TraitInfo { get; }
|
|
public bool IsEnabled { get; }
|
|
|
|
public ExperimentalMapGenerator(ActorInitializer init, ExperimentalMapGeneratorInfo info)
|
|
{
|
|
Label = info.Name;
|
|
PanelWidget = info.PanelWidget;
|
|
TraitInfo = info;
|
|
IsEnabled = info.Tilesets.Contains(init.Self.World.Map.Tileset);
|
|
}
|
|
}
|
|
}
|