diff --git a/src/Blocktavius.AppDQB2/ScriptNodes/HillDesigners/HillType.cs b/src/Blocktavius.AppDQB2/ScriptNodes/HillDesigners/HillType.cs index 76a365a..a10bc9f 100644 --- a/src/Blocktavius.AppDQB2/ScriptNodes/HillDesigners/HillType.cs +++ b/src/Blocktavius.AppDQB2/ScriptNodes/HillDesigners/HillType.cs @@ -40,6 +40,7 @@ static HillType() Register(); Register(); Register(); + Register(); } public sealed class PropGridItemsSource : IItemsSource diff --git a/src/Blocktavius.AppDQB2/ScriptNodes/HillDesigners/NewHillDesigner.cs b/src/Blocktavius.AppDQB2/ScriptNodes/HillDesigners/NewHillDesigner.cs new file mode 100644 index 0000000..c55bc37 --- /dev/null +++ b/src/Blocktavius.AppDQB2/ScriptNodes/HillDesigners/NewHillDesigner.cs @@ -0,0 +1,110 @@ +using Blocktavius.AppDQB2.Persistence; +using Blocktavius.Core; +using Blocktavius.Core.Generators.Hills; +using Blocktavius.DQB2; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Blocktavius.AppDQB2.ScriptNodes.HillDesigners; + +sealed class NewHillDesigner : ShellBasedHillDesigner +{ + public override IPersistentHillDesigner ToPersistModel() + { + throw new NotImplementedException(); + } + + private int _minRunLength = 4; + public int MinRunLength + { + get => _minRunLength; + set => ChangeProperty(ref _minRunLength, value); + } + + private int _maxRunLength = 15; + public int MaxRunLength + { + get => _maxRunLength; + set => ChangeProperty(ref _maxRunLength, value); + } + + private int _coveragePerTier = 80; + public int CoveragePerTier + { + get => _coveragePerTier; + set => ChangeProperty(ref _coveragePerTier, Math.Clamp(value, 0, 100)); + } + + private int _minDrop = 1; + public int MinDrop + { + get => _minDrop; + set => ChangeProperty(ref _minDrop, value); + } + + private int _maxDrop = 3; + public int MaxDrop + { + get => _maxDrop; + set => ChangeProperty(ref _maxDrop, value); + } + + protected override StageMutation? CreateMutation(HillDesignContext context, Shell shell) + { + if (shell.IsHole) + { + return null; + } + + var settings = new NewHill.Settings() + { + MaxElevation = 80, + MinElevation = 14, + MinRunLength = MinRunLength, + RunLengthRand = Math.Max(0, 1 + MaxRunLength - MinRunLength), + RequiredCoveragePerTier = CoveragePerTier, + PRNG = context.Prng.AdvanceAndClone(), + MinDrop = MinDrop, + DropRand = Math.Max(0, 1 + MaxDrop - MinDrop), + }; + var hill = NewHill.BuildNewHill(settings, shell); + return new Mutation { Sampler = hill }; + } + + class Mutation : StageMutation + { + public required I2DSampler Sampler { get; init; } + + public override void Apply(IMutableStage stage) + { + foreach (var chunk in Enumerate(Sampler.Bounds, stage)) + { + foreach (var xz in chunk.Offset.Bounds.Intersection(Sampler.Bounds).Enumerate()) + { + var item = Sampler.Sample(xz); + if (item.Elevation > 0) + { + ushort topperId; + if (item.Slab != null) + { + topperId = Convert.ToUInt16(item.Slab.AncestorCount % 2 + 4); + } + else + { + topperId = 3; + } + + for (int y = 1; y < item.Elevation; y++) + { + chunk.SetBlock(new Point(xz, y), 5); + } + chunk.SetBlock(new Point(xz, item.Elevation), topperId); + } + } + } + } + } +} diff --git a/src/Blocktavius.Core/Generators/Hills/NewHill.cs b/src/Blocktavius.Core/Generators/Hills/NewHill.cs new file mode 100644 index 0000000..dce2bd1 --- /dev/null +++ b/src/Blocktavius.Core/Generators/Hills/NewHill.cs @@ -0,0 +1,257 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Blocktavius.Core.Generators.Hills; + +/// +/// TODO - Similar to the CornerPusherHill, but we allow overlapping slabs in the same tier... +/// But maybe they are really the same algorithm with different config settings?? +/// +public static class NewHill +{ + public sealed record Settings + { + public required PRNG PRNG { get; init; } + public required int MaxElevation { get; init; } + public required int MinElevation { get; init; } + public int MinRunLength { get; init; } = 4; + public int RunLengthRand { get; init; } = 4; + public int RequiredCoveragePerTier { get; set; } = 100; + public int MinDrop { get; set; } = 1; + public int DropRand { get; set; } = 1; + } + + const int emptyValue = -1; + + public static I2DSampler BuildNewHill(Settings settings, Shell shell) + { + var tier = Tier.CreateFirstTier(shell, settings); + while (tier.MinElevation > settings.MinElevation) + { + tier = tier.CreateNextTier(); + } + return tier.Array.Cropped; + } + + public record struct HillItem + { + public required int Elevation { get; init; } + public required Slab? Slab { get; init; } + } + + public sealed class Slab + { + public required IReadOnlyList XZs { get; init; } + public required IReadOnlyList ParentSlabs { get; init; } + + /// + /// FUTURE - We may allow elevation to vary within the slab, but not yet. + /// + public required int MinElevation { get; init; } + + /// + /// If no parent slabs exist, set to 0. + /// Else set to 1 + ParentSlabs.Max(x => x.AncestorCount). + /// + public required int AncestorCount { get; init; } + } + + sealed class HillItemArray + { + private readonly MutableList2D array; + + public HillItemArray(Rect bounds) + { + this.array = new MutableList2D(new HillItem { Elevation = emptyValue, Slab = null }, bounds); + } + + public void Put(XZ xz, HillItem hillItem) + { + array.Put(xz, hillItem); + } + + public I2DSampler Cropped => array; + + public I2DSampler AsArea() => array.Project(item => item.Elevation > emptyValue); + } + + /// + /// Each tier is basically "add slabs until previous tier is fully covered." + /// + sealed class Tier + { + public required Tier? ParentTier { get; init; } + public required Settings Settings { get; init; } + public required IReadOnlyList Slabs { get; init; } + public required HillItemArray Array { get; init; } + + /// + /// Must be set to Slabs.Min(slab => slab.MinElevation) when any slabs exist. + /// + public required int MinElevation { get; init; } + + public required IReadOnlyList NextTierShell { get; init; } + + public static Tier CreateFirstTier(Shell shell, Settings settings) + { + if (settings.MinElevation >= settings.MaxElevation) + { + throw new ArgumentException("MinElevation must be less than MaxElevation"); + } + + var array = new HillItemArray(shell.IslandArea.Bounds); + + foreach (var xz in shell.IslandArea.Bounds.Enumerate()) + { + if (shell.IslandArea.Sample(xz)) + { + array.Put(xz, new HillItem { Elevation = settings.MaxElevation, Slab = null }); + } + } + + return new Tier + { + ParentTier = null, + Settings = settings, + Slabs = [], + Array = array, + MinElevation = settings.MaxElevation, + NextTierShell = shell.ShellItems, + }; + } + + public Tier CreateNextTier() + { + if (NextTierShell.Count < 1) + { + throw new Exception("Assert fail - empty shell should be impossible"); + } + + var slabs = new List(); + var uncoveredShellPoints = NextTierShell.Select(item => item.XZ).ToHashSet(); + int neededCoverage = uncoveredShellPoints.Count * Math.Clamp(Settings.RequiredCoveragePerTier, 0, 100) / 100; + int stopAt = uncoveredShellPoints.Count - neededCoverage; + stopAt = Math.Clamp(stopAt, 0, uncoveredShellPoints.Count - 1); + + var currentShellItems = NextTierShell.ToList(); + + while (uncoveredShellPoints.Count > stopAt) + { + var shellItems = currentShellItems; + + // For quick lookups of a shell item's indices, handling corners correctly. + var shellItemsIndexMap = new Dictionary>(); + for (int i = 0; i < shellItems.Count; i++) + { + var xz = shellItems[i].XZ; + if (!shellItemsIndexMap.TryGetValue(xz, out var indices)) + { + indices = new List(); + shellItemsIndexMap[xz] = indices; + } + indices.Add(i); + } + + // Pick a random point from the original shell that we haven't covered yet. + var seedXZ = uncoveredShellPoints.ElementAt(Settings.PRNG.NextInt32(uncoveredShellPoints.Count)); + + // If this seed point is no longer on the current shell's boundary, it means an overlapping + // slab has already covered it and made it an "internal" point. We can consider it covered. + if (!shellItemsIndexMap.TryGetValue(seedXZ, out var seedIndices)) + { + uncoveredShellPoints.Remove(seedXZ); + continue; + } + + // Determine the run shape based on unique XZ coordinates. + int uniqueXzCount = Settings.MinRunLength + Settings.PRNG.NextInt32(Settings.RunLengthRand); + int seedPositionInRun = Settings.PRNG.NextInt32(uniqueXzCount); + int uniqueXzsBeforeSeed = seedPositionInRun; + + // Find the start of the run by walking backwards from the seed. + int seedShellIndex = seedIndices[0]; + int startShellIndex = seedShellIndex; + var uniqueXzsFound = new HashSet { seedXZ }; + + for (int i = 0; i < shellItems.Count && uniqueXzsFound.Count <= uniqueXzsBeforeSeed; i++) + { + startShellIndex = (seedShellIndex - i + shellItems.Count) % shellItems.Count; + uniqueXzsFound.Add(shellItems[startShellIndex].XZ); + } + + // Collect the full run of shell items by walking forwards from the start. + var slabRunItems = new List(); + var uniqueXzsInRun = new HashSet(); + for (int i = 0; i < shellItems.Count && uniqueXzsInRun.Count < uniqueXzCount; i++) + { + int currentIndex = (startShellIndex + i) % shellItems.Count; + var currentItem = shellItems[currentIndex]; + slabRunItems.Add(currentItem); + uniqueXzsInRun.Add(currentItem.XZ); + } + + // Find parent slabs by looking at the neighbors of the run items. + var parentSlabs = slabRunItems + .Select(item => Array.Cropped.Sample(item.XZ.Step(item.InsideDirection))) + .Select(hillItem => hillItem.Slab) + .WhereNotNull() + .Distinct() + .ToList(); + + int slabDrop = Settings.MinDrop + Settings.PRNG.NextInt32(Settings.DropRand); + int ancestorCount; + int slabElevation; + if (parentSlabs.Any()) + { + ancestorCount = parentSlabs.Max(x => x.AncestorCount) + 1; + slabElevation = parentSlabs.Min(x => x.MinElevation) - slabDrop; + } + else + { + ancestorCount = 0; + slabElevation = this.MinElevation - slabDrop; + } + + var slabXZs = slabRunItems.Select(x => x.XZ).ToList(); + var newSlab = new Slab + { + AncestorCount = ancestorCount, + MinElevation = slabElevation, + ParentSlabs = parentSlabs, + XZs = slabXZs, + }; + slabs.Add(newSlab); + + // Apply the new slab to the work-in-progress array. + var newHillItem = new HillItem { Elevation = slabElevation, Slab = newSlab }; + foreach (var xz in uniqueXzsInRun) // Use unique XZs for updating array and coverage + { + // It's possible for this to overwrite a HillItem from a previous slab in this same tier. This is intentional. + Array.Put(xz, newHillItem); + uncoveredShellPoints.Remove(xz); + } + + currentShellItems = ShellLogic.WalkShellFromPoint(Array.AsArea(), uniqueXzsInRun.First()); + } + + if (slabs.Count == 0) + { + throw new Exception("Assert fail - empty tier should be impossible"); + } + + return new Tier + { + ParentTier = this, + Settings = Settings, + Slabs = slabs, + Array = Array, + MinElevation = Math.Min(this.MinElevation, slabs.Min(slab => slab.MinElevation)), + NextTierShell = currentShellItems, + }; + } + } +} diff --git a/src/Blocktavius.Core/MutableList2D.cs b/src/Blocktavius.Core/MutableList2D.cs index aac67ca..44b323f 100644 --- a/src/Blocktavius.Core/MutableList2D.cs +++ b/src/Blocktavius.Core/MutableList2D.cs @@ -41,7 +41,7 @@ public T Sample(XZ xz) return defaultValue; } - public void Set(XZ xz, T value) + public void Put(XZ xz, T value) { if (!xLookup.TryGetValue(xz.X, out var zLookup)) { diff --git a/src/Blocktavius.Core/Shell.cs b/src/Blocktavius.Core/Shell.cs index 34e5e54..046b8f3 100644 --- a/src/Blocktavius.Core/Shell.cs +++ b/src/Blocktavius.Core/Shell.cs @@ -97,7 +97,7 @@ public static class ShellLogic /// readonly record struct WalkState(XZ shellPosition, Direction insideDir) { - public WalkState Advance(IArea area, List itemCollector) + public WalkState Advance(I2DSampler area, List itemCollector) { itemCollector.Add(new ShellItem() { @@ -108,7 +108,6 @@ public WalkState Advance(IArea area, List itemCollector) var aheadDir = insideDir.TurnLeft90; var aheadPos = shellPosition.Add(aheadDir.Step); - if (area.InArea(aheadPos)) { // inside corner, stay at the same position and turn left @@ -147,7 +146,55 @@ public WalkState Advance(IArea area, List itemCollector) } } - static bool TryBuildShell(IArea area, IslandInfo island, out List items) + private static WalkState FindFirstWalkState(I2DSampler area, XZ pointInArea) + { + var queue = new Queue(); + var visited = new HashSet(); + + if (area.InArea(pointInArea)) + { + queue.Enqueue(pointInArea); + visited.Add(pointInArea); + } + + while (queue.Count > 0) + { + var current = queue.Dequeue(); + foreach (var dir in cardinalDirections) + { + var neighbor = current.Add(dir.Step); + if (area.InArea(neighbor)) + { + if (visited.Add(neighbor)) + { + queue.Enqueue(neighbor); + } + } + else + { + return new WalkState(neighbor, dir.Turn180); + } + } + } + + throw new Exception("Could not find any edge for the given area."); + } + + public static List WalkShellFromPoint(I2DSampler area, XZ pointInArea) + { + var startState = FindFirstWalkState(area, pointInArea); + var items = new List(); + var current = startState; + do + { + current = current.Advance(area, items); + } + while (current != startState); + + return items; + } + + static bool TryBuildShell(I2DSampler area, IslandInfo island, out List items) { items = new(); if (island.MustIncludeStates.Count == 0) @@ -182,12 +229,11 @@ static bool TryBuildShell(IArea area, IslandInfo island, out List ite /// Most of this logic must use the expanded bounds, since shell items /// can be outside of the area's bounds. /// - private static Rect ExpandBounds(IArea area) - { - return new Rect(area.Bounds.start.Add(-1, -1), area.Bounds.end.Add(1, 1)); - } + private static Rect ExpandBounds(I2DSampler area) => area.Bounds.Expand(1); + + public static IReadOnlyList ComputeShells(IArea area) => ComputeShells(area.AsSampler()); - public static IReadOnlyList ComputeShells(IArea area) + public static IReadOnlyList ComputeShells(I2DSampler area) { List shells = new(); @@ -228,7 +274,7 @@ sealed class IslandInfo public required I2DSampler IslandArea { get; init; } } - private static List FindIslands(IArea area) + private static List FindIslands(I2DSampler area) { List infos = new(); @@ -294,7 +340,7 @@ private static List FindIslands(IArea area) /// Finds all points that are not inside the area which can "escape" to the border. /// Used for hole detection. /// - private static HashSet ComputeOutsidePoints(IArea area) + private static HashSet ComputeOutsidePoints(I2DSampler area) { var searchBounds = ExpandBounds(area); var outsidePoints = new HashSet(); diff --git a/src/Blocktavius.Core/Util.cs b/src/Blocktavius.Core/Util.cs index d4e7a7e..10d061c 100644 --- a/src/Blocktavius.Core/Util.cs +++ b/src/Blocktavius.Core/Util.cs @@ -22,6 +22,8 @@ public static IEnumerable WhereNotNull(this IEnumerable sequence) public static IEnumerable EmptyIfNull(this IEnumerable? sequence) => sequence ?? Enumerable.Empty(); + public static bool InArea(this I2DSampler area, XZ xz) => area.Sample(xz); + sealed class Translator : I2DSampler { private readonly I2DSampler sampler; diff --git a/src/Blocktavius.DQB2/Mutations/PutHillMutation.cs b/src/Blocktavius.DQB2/Mutations/PutHillMutation.cs index 99fc664..aa33e40 100644 --- a/src/Blocktavius.DQB2/Mutations/PutHillMutation.cs +++ b/src/Blocktavius.DQB2/Mutations/PutHillMutation.cs @@ -13,7 +13,7 @@ public sealed class PutHillMutation : StageMutation public required ushort Block { get; init; } public int? YFloor { get; init; } = null; - internal override void Apply(IMutableStage stage) + public override void Apply(IMutableStage stage) { int yFloor = this.YFloor ?? 1; diff --git a/src/Blocktavius.DQB2/Mutations/RepairSeaMutation.cs b/src/Blocktavius.DQB2/Mutations/RepairSeaMutation.cs index c113266..c468cb4 100644 --- a/src/Blocktavius.DQB2/Mutations/RepairSeaMutation.cs +++ b/src/Blocktavius.DQB2/Mutations/RepairSeaMutation.cs @@ -13,7 +13,7 @@ public sealed class RepairSeaMutation : StageMutation public required int SeaLevel { get; init; } public LiquidFamily LiquidFamily { get; init; } = LiquidFamily.Seawater; - internal override void Apply(IMutableStage stage) + public override void Apply(IMutableStage stage) { var p = new RepairSeaOperation.Params { diff --git a/src/Blocktavius.DQB2/StageMutation.cs b/src/Blocktavius.DQB2/StageMutation.cs index f7da719..8df2e2e 100644 --- a/src/Blocktavius.DQB2/StageMutation.cs +++ b/src/Blocktavius.DQB2/StageMutation.cs @@ -9,7 +9,7 @@ namespace Blocktavius.DQB2; public abstract class StageMutation { - internal abstract void Apply(IMutableStage stage); + public abstract void Apply(IMutableStage stage); protected static IEnumerable Enumerate(Rect bounds, IMutableStage stage) { @@ -45,7 +45,7 @@ sealed class CompositeMutation : StageMutation public required IReadOnlyList Mutations { get; init; } public required ColumnCleanupMode? ColumnCleanupMode { get; init; } - internal override void Apply(IMutableStage stage) + public override void Apply(IMutableStage stage) { foreach (var m in Mutations) { diff --git a/src/Blocktavius.Tests/SnapshotTests.cs b/src/Blocktavius.Tests/SnapshotTests.cs index 7aad843..f596eec 100644 --- a/src/Blocktavius.Tests/SnapshotTests.cs +++ b/src/Blocktavius.Tests/SnapshotTests.cs @@ -60,6 +60,32 @@ public void CornerPusher01() AssertMatches(hill, "CornerPusher01.png"); } + [TestMethod] + public void NewHill01() + { + var prng = PRNG.Deserialize("12345-67890-12345-67890-12345-67890"); + + var area = TestUtil.CreateAreaFromAscii(@" +xxxx +x__x +x__x +xxxx"); + + // The NewHill generator only works on a single outer shell. + var shell = ShellLogic.ComputeShells(area).Where(s => !s.IsHole).Single(); + + var settings = new NewHill.Settings + { + MaxElevation = 30, + MinElevation = 10, + PRNG = prng, + }; + + var hill = NewHill.BuildNewHill(settings, shell).Project(i => i.Elevation); + + AssertMatches(hill, "NewHill01.png"); + } + private static void AssertMatches(I2DSampler cliff, string imageName) { var snapshotImage = CreateImageFromSampler(cliff); diff --git a/src/Blocktavius.Tests/Snapshots/ElevationImages/NewHill01.png b/src/Blocktavius.Tests/Snapshots/ElevationImages/NewHill01.png new file mode 100644 index 0000000..60856ff Binary files /dev/null and b/src/Blocktavius.Tests/Snapshots/ElevationImages/NewHill01.png differ diff --git a/src/Blocktavius.Tests/Test1.cs b/src/Blocktavius.Tests/Test1.cs index 6e26ba8..dd9f272 100644 --- a/src/Blocktavius.Tests/Test1.cs +++ b/src/Blocktavius.Tests/Test1.cs @@ -1,6 +1,7 @@ using Blocktavius.Core; using Blocktavius.Core.Generators; using Blocktavius.Core.Generators.BasicHill; +using Blocktavius.Core.Generators.Hills; namespace Blocktavius.Tests { @@ -66,6 +67,33 @@ public void AdamantCliffKnownBug() Assert.Fail("ATTENTION: Is the bug in Adamant Cliff fixed? Or is it just fixed for this particular seed?"); } + [TestMethod] + public void ExerciseNewHill() + { + var prng = PRNG.Create(new Random()); + Console.WriteLine(prng.Serialize()); + + var area = TestUtil.CreateAreaFromAscii(@" +xxxxxx +x____x +x_____ +x____x +xxxxxx"); + + var shell = ShellLogic.ComputeShells(area).Single(); + var settings = new NewHill.Settings + { + MaxElevation = 30, + MinElevation = 10, + PRNG = prng, + }; + + for (int i = 0; i < 1000; i++) + { + NewHill.BuildNewHill(settings, shell); + } + } + [TestMethod] public void ExerciseTileTagger() {