Wave Function Collapse improvements

This commit is contained in:
haven1433 2025-12-02 18:43:34 -06:00
parent b0172ba7f1
commit 0450227114
4 changed files with 110 additions and 31 deletions

View File

@ -12,6 +12,7 @@ namespace HavenSoft.HexManiac.Core.Models {
public override int GetHashCode() => X * 101 + Y * 37;
public static Point operator *(Point a, int num) => new Point(a.X * num, a.Y * num);
public static Point operator +(Point a, Point b) => new Point(a.X + b.X, a.Y + b.Y);
public static Point operator -(Point a) => new Point(-a.X, -a.Y);
public static Point operator -(Point a, Point b) => new Point(a.X - b.X, a.Y - b.Y);
public static bool operator ==(Point a, Point b) => a.Equals(b);
public static bool operator !=(Point a, Point b) => !a.Equals(b);

View File

@ -11,6 +11,8 @@ using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using static HavenSoft.HexManiac.Core.ViewModels.Map.MapSliderIcons;
namespace HavenSoft.HexManiac.Core.ViewModels.Map {
@ -952,11 +954,13 @@ namespace HavenSoft.HexManiac.Core.ViewModels.Map {
/// If blockIndex is not valid, it's ignored.
/// </summary>
public void DrawBlock(ModelDelta token, int blockIndex, int collisionIndex, double x, double y) {
waveFunctionActive?.Cancel();
var (xx, yy) = ConvertCoordinates(x, y);
DrawBlock(token, blockIndex, collisionIndex, xx, yy);
}
public void DrawBlock(ModelDelta token, int blockIndex, int collisionIndex, int xx, int yy) {
waveFunctionActive?.Cancel();
var layout = GetLayout();
var (width, height) = (layout.GetValue("width"), layout.GetValue("height"));
var border = GetBorderThickness(layout);
@ -987,6 +991,7 @@ namespace HavenSoft.HexManiac.Core.ViewModels.Map {
}
public void Draw9Grid(ModelDelta token, int[,] grid, double x, double y) {
waveFunctionActive?.Cancel();
var layout = GetLayout();
var (width, height) = (layout.GetValue("width"), layout.GetValue("height"));
var (xx, yy) = ConvertCoordinates(x, y);
@ -1138,6 +1143,7 @@ namespace HavenSoft.HexManiac.Core.ViewModels.Map {
private int[,]? innerCornersFor9Grid;
// 9-grid is always drawn 2x2
public void Draw9Grid(ModelDelta token, int[,] grid, int x, int y) {
waveFunctionActive?.Cancel();
var innerCornersFor9Grid = this.innerCornersFor9Grid ?? new[,] { { grid[1, 1], grid[1, 1] }, { grid[1, 1], grid[1, 1] } }; // copy so that there's no reference changing during the method
var layout = GetLayout();
var (width, height) = (layout.GetValue("width"), layout.GetValue("height"));
@ -1166,6 +1172,7 @@ namespace HavenSoft.HexManiac.Core.ViewModels.Map {
}
public void DrawBlocks(ModelDelta token, int[,] tiles, Point source, Point destination) {
waveFunctionActive?.Cancel();
while (Math.Abs(destination.X - source.X) % tiles.GetLength(0) != 0) destination -= new Point(1, 0);
while (Math.Abs(destination.Y - source.Y) % tiles.GetLength(1) != 0) destination -= new Point(0, 1);
@ -1190,6 +1197,7 @@ namespace HavenSoft.HexManiac.Core.ViewModels.Map {
}
public void RepeatBlock(Func<ModelDelta> futureToken, IReadOnlyList<int> blockOptions, int collision, int x, int y, int w, int h, bool refreshScreen) {
waveFunctionActive?.Cancel();
var layout = GetLayout();
var (width, height) = (layout.GetValue("width"), layout.GetValue("height"));
var start = layout.GetAddress("blockmap");
@ -1217,6 +1225,7 @@ namespace HavenSoft.HexManiac.Core.ViewModels.Map {
}
public void RepeatBlocks(Func<ModelDelta> futureToken, int[,] blockValues, int x, int y, int w, int h, bool refreshScreen) {
waveFunctionActive?.Cancel();
var layout = GetLayout();
var (width, height) = (layout.GetValue("width"), layout.GetValue("height"));
var start = layout.GetAddress("blockmap");
@ -1257,6 +1266,7 @@ namespace HavenSoft.HexManiac.Core.ViewModels.Map {
}
public void PaintBlock(ModelDelta token, int blockIndex, int collisionIndex, double x, double y) {
waveFunctionActive?.Cancel();
if (blockIndex == -1) return;
(x, y) = ((x - leftEdge) / spriteScale, (y - topEdge) / spriteScale);
(x, y) = (x / 16, y / 16);
@ -1280,6 +1290,7 @@ namespace HavenSoft.HexManiac.Core.ViewModels.Map {
}
private void PaintBlock(ModelDelta token, Point p, Point size, int start, int before, int after) {
waveFunctionActive?.Cancel();
if (before == after) return;
if (p.X < 0 || p.Y < 0 || p.X >= size.X || p.Y >= size.Y) return;
var address = start + (p.Y * size.X + p.X) * 2;
@ -1292,6 +1303,7 @@ namespace HavenSoft.HexManiac.Core.ViewModels.Map {
}
public void PaintBlockBag(ModelDelta token, List<int> blockIndexes, int collisionIndex, double x, double y) {
waveFunctionActive?.Cancel();
if (blockIndexes.Count < 1) return;
(x, y) = ((x - leftEdge) / spriteScale, (y - topEdge) / spriteScale);
(x, y) = (x / 16, y / 16);
@ -1352,7 +1364,10 @@ namespace HavenSoft.HexManiac.Core.ViewModels.Map {
}
/// <param name="wave">A function that, for a given x/y pair, returns a superposition of possible block probabilities for that cell, based on its known neighbors.</param>
public void PaintWaveFunction(ModelDelta token, double x, double y, Func<int, int, WaveCell> wave) {
public async void PaintWaveFunction(ModelDelta token, double x, double y, Func<int, int, WaveCell> wave, CancellationToken cancelToken) {
waveFunctionActive?.Cancel();
waveFunctionActive = CancellationTokenSource.CreateLinkedTokenSource(cancelToken);
cancelToken = waveFunctionActive.Token;
(x, y) = ((x - leftEdge) / spriteScale, (y - topEdge) / spriteScale);
(x, y) = (x / 16, y / 16);
var layout = GetLayout();
@ -1374,26 +1389,81 @@ namespace HavenSoft.HexManiac.Core.ViewModels.Map {
// initial wave function collapse values
foreach (var cell in allCells) toDraw[cell] = wave(cell.X, cell.Y);
}
// reduction loop: find the most restricted cell, collapse it, then propogate its new restrictions
while (toDraw.Count > 0) {
var cellsWithRestrictions = toDraw.Values.Where(cell => cell.HasRestrictions);
var smallest = cellsWithRestrictions.Select(v => v.Probabilities.Count).Min(); // what is the smallest number of options
// reduction loop: find the most restricted cell, collapse it, then propogate its new restrictions
var stack = new List<CollapseAttempt>();
int delayCount = 0;
var attemptLimit = 10;
if (toDraw.Count < 300) attemptLimit *= 2;
while (toDraw.Count > 0) {
var cellsWithRestrictions = toDraw.Values.Where(cell => cell.HasRestrictions);
var smallest = cellsWithRestrictions.Select(v => v.Probabilities.Count).Min(); // what is the smallest number of options
if (smallest > 0 && (stack.Count == 0 || stack.Sum(item => item.AttemptCount) < attemptLimit) && !cancelToken.IsCancellationRequested) {
// good case: we have a cell to callapse
var restrictedCells = cellsWithRestrictions.Where(cell => cell.Probabilities.Count == smallest).ToList(); // get the cells with that least number of options
var cell = rnd.From(restrictedCells);
var point = toDraw.Keys.Single(key => toDraw[key] == cell);
Fill(point, cell.Collapse(rnd));
var attempt = cell.Collapse(rnd);
var uncollapsedNeighbors = GetUncollapsedNeighbors(point, toDraw);
stack.Add(new(new(point, cell), attempt & 0x3FF, uncollapsedNeighbors));
lock (pixelWriteLock) Fill(point, cell.Collapse(rnd));
toDraw.Remove(point);
foreach (var neighbor in new List<Point> { point - right, point + right, point - down, point + down, point - right - down, point + right - down, point - right + down, point + right + down }) {
if (!toDraw.ContainsKey(neighbor)) continue;
toDraw[neighbor] = wave(neighbor.X, neighbor.Y); // re-evaluate from scratch now that a new neighbor has been found
}
} else {
// bad case:
// (1) last collapse caused a contradiction - need to backtrack
// or
// (2) we've been backtracking too much along this line, we need to back out and try something else.
if (stack.Count == 0) {
viewPort.RaiseError("Wave Function Collapse failed: no valid options remain.");
break;
}
var lastAttempt = stack.Last();
stack.RemoveAt(stack.Count - 1);
// restore the last attempt's cell and neighbors
toDraw[lastAttempt.SpotToCollapse.CollapsedPoint] = lastAttempt.SpotToCollapse.CellBeforeCollapse;
foreach (var neighbor in lastAttempt.NeighborsBeforeCollapse) {
toDraw[neighbor.CollapsedPoint] = neighbor.CellBeforeCollapse;
}
lock (pixelWriteLock) Fill(lastAttempt.SpotToCollapse.CollapsedPoint, 0); // reset to uncollapsed
// remove the chosen collapse from options
var badOption = lastAttempt.SpotToCollapse.CellBeforeCollapse.Probabilities.Single(prob => prob.Block == lastAttempt.ChosenCollapse);
lastAttempt.SpotToCollapse.CellBeforeCollapse.Probabilities.Remove(badOption);
if (stack.Count > 0) stack[^1].AttemptCount += 1;
}
delayCount += 1;
if (delayCount == 50) {
await Task.Delay(5);
delayCount = 0;
ClearPixelCache();
}
}
ClearPixelCache();
}
private static List<WaveSpot> GetUncollapsedNeighbors(Point point, Dictionary<Point, WaveCell> toDraw) {
var results = new List<WaveSpot>();
Point right = new(1, 0), down = new(0, 1);
foreach (var direction in new[] { -right - down, -right, -right + down, down, -down, right + down, right, right - down }) {
if (!toDraw.TryGetValue(point + direction, out var cell)) continue;
results.Add(new(point + direction, cell));
}
return results;
}
private record WaveSpot(Point CollapsedPoint, WaveCell CellBeforeCollapse);
private record CollapseAttempt(WaveSpot SpotToCollapse, int ChosenCollapse, List<WaveSpot> NeighborsBeforeCollapse) {
public int AttemptCount { get; set; }
};
#endregion
#region Events
@ -1576,7 +1646,9 @@ namespace HavenSoft.HexManiac.Core.ViewModels.Map {
}
}
private CancellationTokenSource? waveFunctionActive;
private void ResizeMapData(MapDirection direction, int amount) {
waveFunctionActive?.Cancel();
if (amount == 0) return;
var token = tokenFactory();
var map = GetMapModel();

View File

@ -10,6 +10,7 @@ using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Input;
@ -777,7 +778,7 @@ namespace HavenSoft.HexManiac.Core.ViewModels.Map {
PrimaryMap.SurfConnection.FollowConnection();
return;
}
DrawDown(x, y, click);
if (DrawDown(x, y, click)) waveFunctionActive?.Cancel();
}
public void PrimaryMove(double x, double y) {
@ -796,7 +797,8 @@ namespace HavenSoft.HexManiac.Core.ViewModels.Map {
}
Point drawSource, lastDraw;
private void DrawDown(double x, double y, PrimaryInteractionStart click) {
private bool DrawDown(double x, double y, PrimaryInteractionStart click) {
bool didDraw = true;
interactionType = PrimaryInteractionType.Draw;
if ((click & PrimaryInteractionStart.ControlClick) != 0) interactionType = PrimaryInteractionType.RectangleDraw;
if (use9Grid && IsValid9GridSelection) {
@ -824,17 +826,20 @@ namespace HavenSoft.HexManiac.Core.ViewModels.Map {
lastDraw = drawSource;
if (click == PrimaryInteractionStart.ControlClick) RectangleDrawMove(x, y);
else if (interactionType == PrimaryInteractionType.Draw9Grid) Draw9Grid(x, y);
else if (click == PrimaryInteractionStart.Click) DrawMove(x, y);
else if (click == PrimaryInteractionStart.Click) didDraw = DrawMove(x, y);
}
return didDraw;
}
private void DrawMove(double x, double y) {
private bool DrawMove(double x, double y) {
var didDraw = false;
var map = MapUnderCursor(x, y);
if (map != null) {
using (map.DeferPropertyNotifications()) {
if (drawMultipleTiles && tilesToDraw != null) {
var tilePosition = ToBoundedMapTilePosition(map, x, y, tilesToDraw.GetLength(0), tilesToDraw.GetLength(1));
map.DrawBlocks(history.CurrentChange, tilesToDraw, drawSource, tilePosition);
didDraw = true;
} else {
var tilePosition = ToBoundedMapTilePosition(map, x, y, 1, 1);
if (drawBlockIndex < 0 && collisionIndex < 0) {
@ -843,16 +848,20 @@ namespace HavenSoft.HexManiac.Core.ViewModels.Map {
lastDraw = tilePosition;
FillBackup();
SwapBlocks(lastDraw, drawSource);
didDraw = true;
}
} else if (blockBag.Contains(drawBlockIndex)) {
map.DrawBlock(history.CurrentChange, rnd.From(blockBag), collisionIndex, x, y);
didDraw = true;
} else {
map.DrawBlock(history.CurrentChange, drawBlockIndex, collisionIndex, x, y);
didDraw = true;
}
}
}
}
Hover(x, y);
return didDraw;
}
private void RectangleDrawMove(double x, double y) {
@ -962,9 +971,12 @@ namespace HavenSoft.HexManiac.Core.ViewModels.Map {
eventCreationType = EventCreationType.None;
// user wants to do a wave function collapse at this position
if (map != primaryMap) return;
map.PaintWaveFunction(history.CurrentChange, x, y, RunWaveFunctionCollapseWithCollision);
if (waveFunctionPrimary == null) CalculateWaveCollapseProbabilities();
waveFunctionActive = new();
map.PaintWaveFunction(history.CurrentChange, x, y, RunWaveFunctionCollapseWithCollision, waveFunctionActive.Token);
}
}
private CancellationTokenSource? waveFunctionActive;
#region Rectangle-Drawing helper methods
@ -1971,7 +1983,6 @@ namespace HavenSoft.HexManiac.Core.ViewModels.Map {
// if we still can't find a neighbor after that, leave it blank.
private IList<CollapseProbability> RunWaveFunctionCollapse(int xx, int yy) {
if (waveFunctionPrimary == null) CalculateWaveCollapseProbabilities();
var layout = new LayoutModel(PrimaryMap.GetLayout());
var primary = layout.PrimaryBlockset.Start;
var secondary = layout.SecondaryBlockset.Start;
@ -1994,11 +2005,6 @@ namespace HavenSoft.HexManiac.Core.ViewModels.Map {
if (probabilities.Count == 0) return null; // no known neighbors, no restrictions... yet
// remove all the empty probability sets (they're not adding any restrictions)
for (int i = probabilities.Count - 1; i >= 0; i--) {
if (probabilities[i].Count == 0) probabilities.RemoveAt(i);
}
// combine the list of probabilities down to a single list by merging from all the neighbors.
// the block is constrained in options based on _all_ its neighbors together.
// if the neighbor can only be A/B based on the left and only B/C based on the top, it must be B.
@ -2014,23 +2020,14 @@ namespace HavenSoft.HexManiac.Core.ViewModels.Map {
if (cp2 == null) continue; // no match
merged.Add(new(cp1.Block) { Count = cp1.Count + cp2.Count });
}
if (merged.Count == 0) {
// couldn't find a match
// matching one is better than matching neither
probabilities.Add(rnd.Next(2) == 0 ? last : next);
} else {
if (merged.Count != 0) {
probabilities.Add(merged);
}
}
// possible failure state: no probabilities were found
// pick block '1' which is most likely safe.
if (probabilities.Count == 0 || probabilities[0].Count == 0) {
// no restriction, pick any block
//var (availableBlocks, _) = PrimaryMap.MapRepointer.EstimateBlockCount(layout.Element, true);
//return availableBlocks.Range().Select(i => new CollapseProbability(i)).ToList();
return new List<CollapseProbability> { new CollapseProbability(1) { Count = 1 } };
}
// possible failure state: no probabilities were found. We had probabilities before merging, but none survived.
// we're returning 'null' if there are no restrictions. This is the opposite: the restrictions are so tight as to have no valid results.
if (probabilities.Count == 0) return new List<CollapseProbability>();
// new version that returns the current probabilities, which need collapsing
return probabilities[0];

View File

@ -1714,7 +1714,16 @@
</Popup>
<local:AngleButton Direction="Right" Content="Edit Border Block" DataContext="{Binding PrimaryMap.BorderEditor}" Command="{res:MethodCommand ToggleBorderEditor}" Margin="0,0,5,0" x:Name="EditBorderButton" />
<Grid Width="24" Height="24" Name="WaveFunction" Background="Transparent"
MouseLeftButtonDown="EventTemplateDown" Margin="0,0,5,0" Tag="{x:Static map:EventCreationType.WaveFunction}" ToolTipService.InitialShowDelay="0" ToolTip="Drag to fill an identical-block region with randomized blocks">
MouseLeftButtonDown="EventTemplateDown" Margin="0,0,5,0" Tag="{x:Static map:EventCreationType.WaveFunction}" ToolTipService.InitialShowDelay="0">
<Grid.ToolTip>
<TextBlock>
Drop to auto-fill a region of identical blocks with random blocks.
<LineBreak />
Blocks will attempt to fill at a rate of 1000 per second, but will backtrack if it cannot find a valid fill.
<LineBreak />
Editing the map during auto-fill will cancel the auto-fill.
</TextBlock>
</Grid.ToolTip>
<Rectangle Fill="{DynamicResource Primary}" Margin="2" />
<Path Data="{res:Icon Dice}" Fill="{DynamicResource Secondary}" Stretch="Fill" />
</Grid>