feat: expand masks, structural filters, and solution similarity dedupe
This commit is contained in:
627
Program.cs
627
Program.cs
@@ -10,6 +10,7 @@ using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Text;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
@@ -24,6 +25,17 @@ internal static class Program
|
||||
BoxCountHigh = 2,
|
||||
MinAllowedPushes = 4,
|
||||
MinAllowedTurns = 2,
|
||||
MinAllowedBranching = 0,
|
||||
MinGoalDistance = 2,
|
||||
MinBoxDistance = 2,
|
||||
MinWallDistance = 1,
|
||||
ReverseDepthScale = 1.0,
|
||||
ReverseBreadthScale = 1.0,
|
||||
PocketCarveMin = 0,
|
||||
PocketCarveMax = 1,
|
||||
PocketMaxRadius = 1,
|
||||
MaskPadMin = -1,
|
||||
MaskPadMax = 1,
|
||||
ShapeMasks = MaskLibrary.Microban.Take(8).ToList()
|
||||
},
|
||||
new LevelBandConfig
|
||||
@@ -34,7 +46,18 @@ internal static class Program
|
||||
BoxCountHigh = 2,
|
||||
MinAllowedPushes = 7,
|
||||
MinAllowedTurns = 3,
|
||||
ShapeMasks = MaskLibrary.Microban.ToList()
|
||||
MinAllowedBranching = 1,
|
||||
MinGoalDistance = 3,
|
||||
MinBoxDistance = 3,
|
||||
MinWallDistance = 1,
|
||||
ReverseDepthScale = 1.15,
|
||||
ReverseBreadthScale = 1.2,
|
||||
PocketCarveMin = 1,
|
||||
PocketCarveMax = 2,
|
||||
PocketMaxRadius = 2,
|
||||
MaskPadMin = -1,
|
||||
MaskPadMax = 2,
|
||||
ShapeMasks = MaskLibrary.Microban.Concat(MaskLibrary.Medium).ToList()
|
||||
},
|
||||
new LevelBandConfig
|
||||
{
|
||||
@@ -44,14 +67,26 @@ internal static class Program
|
||||
BoxCountHigh = 3,
|
||||
MinAllowedPushes = 9,
|
||||
MinAllowedTurns = 4,
|
||||
ShapeMasks = MaskLibrary.Microban.ToList()
|
||||
MinAllowedBranching = 2,
|
||||
MinGoalDistance = 3,
|
||||
MinBoxDistance = 3,
|
||||
MinWallDistance = 1,
|
||||
ReverseDepthScale = 1.35,
|
||||
ReverseBreadthScale = 1.4,
|
||||
PocketCarveMin = 1,
|
||||
PocketCarveMax = 3,
|
||||
PocketMaxRadius = 2,
|
||||
MaskPadMin = 0,
|
||||
MaskPadMax = 2,
|
||||
ShapeMasks = MaskLibrary.Medium.Concat(MaskLibrary.Large).ToList()
|
||||
}
|
||||
};
|
||||
|
||||
private static readonly GenerationTuning Tuning = new GenerationTuning
|
||||
{
|
||||
MaxAttemptsPerLevel = 2000,
|
||||
MaxMillisecondsPerLevel = 40_000,
|
||||
MaxAttemptsPerLevel = 10_000,
|
||||
MaxMillisecondsPerLevel = 200_000,
|
||||
RelaxationSteps = 3,
|
||||
PushLimitPadding = 2,
|
||||
PushLimitScale = 0.35,
|
||||
DynamicGrowthWindow = 12,
|
||||
@@ -74,7 +109,8 @@ internal static class Program
|
||||
|
||||
var rng = new Random(seed);
|
||||
var levelBands = LoadLevelBands();
|
||||
var generator = new LevelGenerator(rng, Tuning, levelBands);
|
||||
var status = new StatusReporter();
|
||||
var generator = new LevelGenerator(rng, Tuning, levelBands, status);
|
||||
|
||||
var startId = levelBands.Min(b => b.StartId);
|
||||
var endId = levelBands.Max(b => b.EndId);
|
||||
@@ -94,7 +130,9 @@ internal static class Program
|
||||
(startId, endId) = (endId, startId);
|
||||
}
|
||||
|
||||
var totalWatch = Stopwatch.StartNew();
|
||||
var levels = generator.BuildRange(startId, endId);
|
||||
totalWatch.Stop();
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
@@ -102,6 +140,9 @@ internal static class Program
|
||||
};
|
||||
|
||||
Console.WriteLine(JsonSerializer.Serialize(levels, options));
|
||||
var duration = StatusReporter.FormatDuration(totalWatch.Elapsed);
|
||||
Console.Error.Write($"\r레벨 {startId}~레벨 {endId} 생성완료 ({duration}) \n");
|
||||
Console.Error.WriteLine($"총 소요시간 {duration}");
|
||||
}
|
||||
|
||||
private static LevelBandConfig[] LoadLevelBands()
|
||||
@@ -111,7 +152,8 @@ internal static class Program
|
||||
if (File.Exists(LevelBalancePath))
|
||||
{
|
||||
var json = File.ReadAllText(LevelBalancePath);
|
||||
var config = JsonSerializer.Deserialize<LevelBalanceFile>(json);
|
||||
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
|
||||
var config = JsonSerializer.Deserialize<LevelBalanceFile>(json, options);
|
||||
if (config?.Bands != null && config.Bands.Count > 0)
|
||||
{
|
||||
return config.Bands.Select(ConvertBand).ToArray();
|
||||
@@ -128,11 +170,9 @@ internal static class Program
|
||||
|
||||
private static LevelBandConfig ConvertBand(LevelBandJson input)
|
||||
{
|
||||
var masks = MaskLibrary.Microban;
|
||||
if (input.MaskTake > 0)
|
||||
{
|
||||
masks = masks.Take(input.MaskTake).ToList();
|
||||
}
|
||||
var masks = ResolveMasks(input);
|
||||
var padMin = Math.Min(input.MaskPadMin, input.MaskPadMax);
|
||||
var padMax = Math.Max(input.MaskPadMin, input.MaskPadMax);
|
||||
return new LevelBandConfig
|
||||
{
|
||||
StartId = input.StartId,
|
||||
@@ -142,9 +182,68 @@ internal static class Program
|
||||
MinAllowedPushes = input.MinAllowedPushes,
|
||||
MinAllowedTurns = input.MinAllowedTurns,
|
||||
MinAllowedBranching = input.MinAllowedBranching,
|
||||
ShapeMasks = masks.ToList()
|
||||
MinGoalDistance = input.MinGoalDistance > 0 ? input.MinGoalDistance : 1,
|
||||
MinBoxDistance = input.MinBoxDistance > 0 ? input.MinBoxDistance : 1,
|
||||
MinWallDistance = input.MinWallDistance >= 0 ? input.MinWallDistance : 0,
|
||||
ReverseDepthScale = input.ReverseDepthScale > 0 ? input.ReverseDepthScale : 1.0,
|
||||
ReverseBreadthScale = input.ReverseBreadthScale > 0 ? input.ReverseBreadthScale : 1.0,
|
||||
PocketCarveMin = input.PocketCarveMin,
|
||||
PocketCarveMax = input.PocketCarveMax,
|
||||
PocketMaxRadius = input.PocketMaxRadius,
|
||||
MaskPadMin = padMin,
|
||||
MaskPadMax = padMax,
|
||||
ShapeMasks = masks
|
||||
};
|
||||
}
|
||||
|
||||
private static List<string[]> ResolveMasks(LevelBandJson input)
|
||||
{
|
||||
var resolved = new List<string[]>();
|
||||
var seen = new HashSet<string>();
|
||||
var maskSets = input.MaskSets ?? new List<string>();
|
||||
var hasExplicitSets = maskSets.Count > 0;
|
||||
|
||||
if (hasExplicitSets)
|
||||
{
|
||||
foreach (var raw in maskSets)
|
||||
{
|
||||
var name = raw?.Trim().ToLowerInvariant();
|
||||
switch (name)
|
||||
{
|
||||
case "micro":
|
||||
case "small":
|
||||
AddMasks(resolved, seen, MaskLibrary.Microban, input.MaskTake);
|
||||
break;
|
||||
case "medium":
|
||||
AddMasks(resolved, seen, MaskLibrary.Medium);
|
||||
break;
|
||||
case "large":
|
||||
AddMasks(resolved, seen, MaskLibrary.Large);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (resolved.Count == 0)
|
||||
{
|
||||
AddMasks(resolved, seen, MaskLibrary.Microban, input.MaskTake);
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private static void AddMasks(List<string[]> sink, HashSet<string> seen, List<string[]> source, int take = 0)
|
||||
{
|
||||
var picked = take > 0 ? source.Take(take) : source;
|
||||
foreach (var mask in picked)
|
||||
{
|
||||
var key = string.Join("\n", mask);
|
||||
if (seen.Add(key))
|
||||
{
|
||||
sink.Add(mask);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class LevelGenerator
|
||||
@@ -154,12 +253,15 @@ internal sealed class LevelGenerator
|
||||
private readonly IReadOnlyList<LevelBandConfig> _bands;
|
||||
private readonly bool _trace;
|
||||
private readonly HashSet<string> _seenLayouts = new();
|
||||
private readonly HashSet<string> _seenPatterns = new();
|
||||
private readonly StatusReporter _status;
|
||||
|
||||
public LevelGenerator(Random rng, GenerationTuning tuning, IReadOnlyList<LevelBandConfig> bands)
|
||||
public LevelGenerator(Random rng, GenerationTuning tuning, IReadOnlyList<LevelBandConfig> bands, StatusReporter status)
|
||||
{
|
||||
_rng = rng;
|
||||
_tuning = tuning;
|
||||
_bands = bands;
|
||||
_status = status;
|
||||
_trace = Environment.GetEnvironmentVariable("NEKOBAN_DEBUG") == "1";
|
||||
}
|
||||
|
||||
@@ -170,7 +272,7 @@ internal sealed class LevelGenerator
|
||||
if (band.ShapeMasksExpanded == null || band.ShapeMasksExpanded.Count == 0)
|
||||
{
|
||||
var baseMasks = band.ShapeMasks.Count > 0 ? band.ShapeMasks : MaskLibrary.Microban;
|
||||
band.ShapeMasksExpanded = MaskLibrary.ExpandWithTransforms(baseMasks);
|
||||
band.ShapeMasksExpanded = MaskLibrary.ExpandWithTransforms(baseMasks, band.MaskPadMin, band.MaskPadMax);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,96 +287,151 @@ internal sealed class LevelGenerator
|
||||
return output;
|
||||
}
|
||||
|
||||
private PocketSettings ResolvePockets(LevelBandConfig band)
|
||||
{
|
||||
var min = band.PocketCarveMin >= 0 ? band.PocketCarveMin : _tuning.PocketCarveMin;
|
||||
var max = band.PocketCarveMax >= 0 ? band.PocketCarveMax : _tuning.PocketCarveMax;
|
||||
var radius = band.PocketMaxRadius > 0 ? band.PocketMaxRadius : _tuning.PocketMaxRadius;
|
||||
if (max < min) max = min;
|
||||
if (radius < 1) radius = 1;
|
||||
return new PocketSettings(min, max, radius);
|
||||
}
|
||||
|
||||
private GeneratedLevel BuildSingle(int id, LevelBandConfig band)
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var attempts = 0;
|
||||
var failReasons = _trace ? new Dictionary<string, int>() : null;
|
||||
var baseMasks = band.ShapeMasks.Count > 0 ? band.ShapeMasks : MaskLibrary.Microban;
|
||||
band.ShapeMasksExpanded ??= MaskLibrary.ExpandWithTransforms(baseMasks, band.MaskPadMin, band.MaskPadMax);
|
||||
var pockets = ResolvePockets(band);
|
||||
|
||||
while (attempts < _tuning.MaxAttemptsPerLevel &&
|
||||
stopwatch.ElapsedMilliseconds < _tuning.MaxMillisecondsPerLevel)
|
||||
for (var relax = 0; relax < _tuning.RelaxationSteps; relax++)
|
||||
{
|
||||
attempts++;
|
||||
var attemptsLimit = _tuning.MaxAttemptsPerLevel + relax * 2000;
|
||||
var timeLimit = _tuning.MaxMillisecondsPerLevel + relax * 20_000;
|
||||
var minPush = Math.Max(1, band.MinAllowedPushes - relax * 1);
|
||||
var minTurns = Math.Max(0, band.MinAllowedTurns - relax * 1);
|
||||
var minBranch = Math.Max(0, band.MinAllowedBranching - relax * 1);
|
||||
var minGoalDistance = Math.Max(1, band.MinGoalDistance - relax);
|
||||
var minBoxDistance = Math.Max(1, band.MinBoxDistance - relax);
|
||||
var minWallDistance = Math.Max(0, band.MinWallDistance - relax);
|
||||
var depthScale = band.ReverseDepthScale * (1.0 + 0.2 * relax);
|
||||
var breadthScale = band.ReverseBreadthScale * (1.0 + 0.25 * relax);
|
||||
var reverseDepth = Math.Max(16, (int)Math.Round(_tuning.ReverseSearchMaxDepth * depthScale));
|
||||
var reverseBreadth = Math.Max(200, (int)Math.Round(_tuning.ReverseSearchBreadth * breadthScale));
|
||||
|
||||
var baseMasks = band.ShapeMasks.Count > 0 ? band.ShapeMasks : MaskLibrary.Microban;
|
||||
var prepped = band.ShapeMasksExpanded ?? MaskLibrary.ExpandWithTransforms(baseMasks);
|
||||
band.ShapeMasksExpanded ??= prepped;
|
||||
var mask = MaskLibrary.CreateVariant(
|
||||
MaskLibrary.PickRandom(_rng, prepped),
|
||||
_rng,
|
||||
_tuning.ApplyMaskTransforms,
|
||||
_tuning.MaskWallJitter);
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var attempts = 0;
|
||||
_status.Show($"레벨 {id} 생성중...");
|
||||
|
||||
var canvas = LayoutFactory.FromMask(mask, _rng, _tuning);
|
||||
|
||||
var boxCount = _rng.Next(band.BoxCountLow, band.BoxCountHigh + 1);
|
||||
if (boxCount <= 0)
|
||||
while (attempts < attemptsLimit &&
|
||||
stopwatch.ElapsedMilliseconds < timeLimit)
|
||||
{
|
||||
boxCount = 1;
|
||||
attempts++;
|
||||
var mask = MaskLibrary.CreateVariant(
|
||||
MaskLibrary.PickRandom(_rng, band.ShapeMasksExpanded),
|
||||
_rng,
|
||||
_tuning.ApplyMaskTransforms,
|
||||
_tuning.MaskWallJitter);
|
||||
|
||||
var canvas = LayoutFactory.FromMask(mask, _rng, _tuning, pockets);
|
||||
|
||||
var boxCount = _rng.Next(band.BoxCountLow, band.BoxCountHigh + 1);
|
||||
if (boxCount <= 0) boxCount = 1;
|
||||
|
||||
if (!PiecePlacer.TryPlace(canvas, _rng, boxCount, out var goals, out var boxes, out var player))
|
||||
{
|
||||
NoteFail(failReasons, "place");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!LayoutFilters.CheckSpacing(goals, canvas.Width, canvas.Height, minGoalDistance, minWallDistance))
|
||||
{
|
||||
NoteFail(failReasons, "goal_spacing");
|
||||
continue;
|
||||
}
|
||||
|
||||
var board = Board.FromCanvas(canvas);
|
||||
var reverse = new ReverseSearch(board, reverseDepth, reverseBreadth);
|
||||
var startState = reverse.FindStartState(goals, minPush, _rng);
|
||||
if (startState == null)
|
||||
{
|
||||
NoteFail(failReasons, "reverse_not_found");
|
||||
continue;
|
||||
}
|
||||
|
||||
var solver = new SokobanSolver(board);
|
||||
var solve = solver.SolveDetailed(startState.Player, startState.Boxes, goals);
|
||||
if (solve.IsFail)
|
||||
{
|
||||
NoteFail(failReasons, "verify_unsolvable");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (solve.Pushes < minPush || solve.Turns < minTurns || solve.Branching < minBranch)
|
||||
{
|
||||
NoteFail(failReasons, "too_easy");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!LayoutFilters.CheckSpacing(startState.Boxes, board.Width, board.Height, minBoxDistance, minWallDistance))
|
||||
{
|
||||
NoteFail(failReasons, "box_spacing");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (_tuning.FilterCornerDeadlocks && LayoutFilters.HasCornerDeadlock(startState.Boxes, goals, board))
|
||||
{
|
||||
NoteFail(failReasons, "corner_deadlock");
|
||||
continue;
|
||||
}
|
||||
|
||||
var pushLimit = solve.Pushes + Math.Max(_tuning.PushLimitPadding,
|
||||
(int)Math.Ceiling(solve.Pushes * _tuning.PushLimitScale));
|
||||
|
||||
canvas.ClearDynamic();
|
||||
canvas.Overlay(goals, 'G');
|
||||
canvas.Overlay(startState.Boxes, '$');
|
||||
canvas.Set(startState.Player, '@');
|
||||
|
||||
// Final wall normalization; reject if tokens end up on the edge.
|
||||
if (!LayoutFactory.NormalizeOuterWallsStrict(canvas, failOnTokens: true))
|
||||
{
|
||||
NoteFail(failReasons, "boundary");
|
||||
continue;
|
||||
}
|
||||
|
||||
var lines = canvas.ToLines();
|
||||
var layoutKey = string.Join("|", lines);
|
||||
if (_seenLayouts.Contains(layoutKey))
|
||||
{
|
||||
NoteFail(failReasons, "duplicate");
|
||||
continue;
|
||||
}
|
||||
_seenLayouts.Add(layoutKey);
|
||||
|
||||
var patternKey = solve.PatternSignature;
|
||||
if (!string.IsNullOrEmpty(patternKey))
|
||||
{
|
||||
if (_seenPatterns.Contains(patternKey))
|
||||
{
|
||||
NoteFail(failReasons, "pattern_duplicate");
|
||||
continue;
|
||||
}
|
||||
_seenPatterns.Add(patternKey);
|
||||
}
|
||||
|
||||
_status.Show($"레벨 {id} 생성완료 ({StatusReporter.FormatDuration(stopwatch.Elapsed)})");
|
||||
return new GeneratedLevel
|
||||
{
|
||||
Id = id,
|
||||
Grid = lines,
|
||||
LowestPush = solve.Pushes,
|
||||
PushLimit = pushLimit
|
||||
};
|
||||
}
|
||||
|
||||
if (!PiecePlacer.TryPlace(canvas, _rng, boxCount, out var goals, out var boxes, out var player))
|
||||
{
|
||||
NoteFail(failReasons, "place");
|
||||
continue;
|
||||
}
|
||||
|
||||
var board = Board.FromCanvas(canvas);
|
||||
var reverse = new ReverseSearch(board, _tuning.ReverseSearchMaxDepth, _tuning.ReverseSearchBreadth);
|
||||
var startState = reverse.FindStartState(goals, band.MinAllowedPushes, _rng);
|
||||
if (startState == null)
|
||||
{
|
||||
NoteFail(failReasons, "reverse_not_found");
|
||||
continue;
|
||||
}
|
||||
|
||||
var solver = new SokobanSolver(board);
|
||||
var solve = solver.SolveDetailed(startState.Player, startState.Boxes, goals);
|
||||
if (solve.IsFail)
|
||||
{
|
||||
NoteFail(failReasons, "verify_unsolvable");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (solve.Pushes < band.MinAllowedPushes || solve.Turns < band.MinAllowedTurns || solve.Branching < band.MinAllowedBranching)
|
||||
{
|
||||
NoteFail(failReasons, "too_easy");
|
||||
continue;
|
||||
}
|
||||
|
||||
var pushLimit = solve.Pushes + Math.Max(_tuning.PushLimitPadding,
|
||||
(int)Math.Ceiling(solve.Pushes * _tuning.PushLimitScale));
|
||||
|
||||
canvas.ClearDynamic();
|
||||
canvas.Overlay(goals, 'G');
|
||||
canvas.Overlay(startState.Boxes, '$');
|
||||
canvas.Set(startState.Player, '@');
|
||||
|
||||
// Final wall normalization; reject if tokens end up on the edge.
|
||||
if (!LayoutFactory.NormalizeOuterWallsStrict(canvas, failOnTokens: true))
|
||||
{
|
||||
NoteFail(failReasons, "boundary");
|
||||
continue;
|
||||
}
|
||||
|
||||
var lines = canvas.ToLines();
|
||||
var layoutKey = string.Join("|", lines);
|
||||
if (_seenLayouts.Contains(layoutKey))
|
||||
{
|
||||
NoteFail(failReasons, "duplicate");
|
||||
continue;
|
||||
}
|
||||
_seenLayouts.Add(layoutKey);
|
||||
|
||||
return new GeneratedLevel
|
||||
{
|
||||
Id = id,
|
||||
Grid = lines,
|
||||
LowestPush = solve.Pushes,
|
||||
PushLimit = pushLimit
|
||||
};
|
||||
}
|
||||
|
||||
_status.Show($"레벨 {id} 생성 실패");
|
||||
if (_trace && failReasons != null)
|
||||
{
|
||||
Console.Error.WriteLine($"[trace] level {id} failed. Reasons:");
|
||||
@@ -284,7 +441,7 @@ internal sealed class LevelGenerator
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"레벨 {id} 생성 실패 (attempts {attempts}).");
|
||||
throw new InvalidOperationException($"레벨 {id} 생성 실패 (attempts {_tuning.MaxAttemptsPerLevel})");
|
||||
}
|
||||
|
||||
private LevelBandConfig ResolveBand(int id)
|
||||
@@ -307,7 +464,20 @@ internal sealed class LevelGenerator
|
||||
BoxCountLow = last.BoxCountLow + growthStep / 2,
|
||||
BoxCountHigh = last.BoxCountHigh + growthStep / 2,
|
||||
MinAllowedPushes = last.MinAllowedPushes + growthStep * _tuning.DynamicPushIncrement,
|
||||
ShapeMasks = last.ShapeMasks
|
||||
MinAllowedTurns = last.MinAllowedTurns,
|
||||
MinAllowedBranching = last.MinAllowedBranching,
|
||||
MinGoalDistance = last.MinGoalDistance,
|
||||
MinBoxDistance = last.MinBoxDistance,
|
||||
MinWallDistance = last.MinWallDistance,
|
||||
ReverseDepthScale = last.ReverseDepthScale,
|
||||
ReverseBreadthScale = last.ReverseBreadthScale,
|
||||
PocketCarveMin = last.PocketCarveMin,
|
||||
PocketCarveMax = last.PocketCarveMax,
|
||||
PocketMaxRadius = last.PocketMaxRadius,
|
||||
MaskPadMin = last.MaskPadMin,
|
||||
MaskPadMax = last.MaskPadMax,
|
||||
ShapeMasks = last.ShapeMasks,
|
||||
ShapeMasksExpanded = last.ShapeMasksExpanded
|
||||
};
|
||||
}
|
||||
|
||||
@@ -335,6 +505,16 @@ internal sealed class LevelBandConfig
|
||||
public int MinAllowedPushes { get; init; }
|
||||
public int MinAllowedTurns { get; init; } = 0;
|
||||
public int MinAllowedBranching { get; init; } = 0;
|
||||
public int MinGoalDistance { get; init; } = 1;
|
||||
public int MinBoxDistance { get; init; } = 1;
|
||||
public int MinWallDistance { get; init; } = 0;
|
||||
public double ReverseDepthScale { get; init; } = 1.0;
|
||||
public double ReverseBreadthScale { get; init; } = 1.0;
|
||||
public int PocketCarveMin { get; init; } = -1;
|
||||
public int PocketCarveMax { get; init; } = -1;
|
||||
public int PocketMaxRadius { get; init; } = -1;
|
||||
public int MaskPadMin { get; init; } = -1;
|
||||
public int MaskPadMax { get; init; } = 1;
|
||||
public List<string[]> ShapeMasks { get; init; } = new();
|
||||
public List<string[]>? ShapeMasksExpanded { get; set; }
|
||||
}
|
||||
@@ -353,6 +533,17 @@ internal sealed class LevelBandJson
|
||||
public int MinAllowedPushes { get; set; }
|
||||
public int MinAllowedTurns { get; set; }
|
||||
public int MinAllowedBranching { get; set; }
|
||||
public int MinGoalDistance { get; set; } = 1;
|
||||
public int MinBoxDistance { get; set; } = 1;
|
||||
public int MinWallDistance { get; set; } = 0;
|
||||
public double ReverseDepthScale { get; set; } = 1.0;
|
||||
public double ReverseBreadthScale { get; set; } = 1.0;
|
||||
public int PocketCarveMin { get; set; } = -1;
|
||||
public int PocketCarveMax { get; set; } = -1;
|
||||
public int PocketMaxRadius { get; set; } = -1;
|
||||
public int MaskPadMin { get; set; } = -1;
|
||||
public int MaskPadMax { get; set; } = 1;
|
||||
public List<string> MaskSets { get; set; } = new();
|
||||
public int MaskTake { get; set; } = 0;
|
||||
}
|
||||
|
||||
@@ -360,6 +551,7 @@ internal sealed class GenerationTuning
|
||||
{
|
||||
public int MaxAttemptsPerLevel { get; init; }
|
||||
public int MaxMillisecondsPerLevel { get; init; }
|
||||
public int RelaxationSteps { get; init; } = 3;
|
||||
public int PushLimitPadding { get; init; }
|
||||
public double PushLimitScale { get; init; }
|
||||
public int DynamicGrowthWindow { get; init; }
|
||||
@@ -371,6 +563,7 @@ internal sealed class GenerationTuning
|
||||
public int PocketCarveMin { get; init; } = 0;
|
||||
public int PocketCarveMax { get; init; } = 0;
|
||||
public int PocketMaxRadius { get; init; } = 1;
|
||||
public bool FilterCornerDeadlocks { get; init; } = true;
|
||||
}
|
||||
|
||||
internal sealed class GridCanvas
|
||||
@@ -436,9 +629,11 @@ internal sealed class GridCanvas
|
||||
}
|
||||
}
|
||||
|
||||
internal readonly record struct PocketSettings(int Min, int Max, int Radius);
|
||||
|
||||
internal static class LayoutFactory
|
||||
{
|
||||
public static GridCanvas FromMask(string[] mask, Random rng, GenerationTuning tuning)
|
||||
public static GridCanvas FromMask(string[] mask, Random rng, GenerationTuning tuning, PocketSettings pockets)
|
||||
{
|
||||
var height = mask.Length;
|
||||
var width = mask.Max(row => row.Length);
|
||||
@@ -456,9 +651,13 @@ internal static class LayoutFactory
|
||||
|
||||
NormalizeOuterWalls(canvas);
|
||||
|
||||
if (tuning.PocketCarveMax > 0)
|
||||
var pocketMin = pockets.Min >= 0 ? pockets.Min : tuning.PocketCarveMin;
|
||||
var pocketMax = pockets.Max >= 0 ? pockets.Max : tuning.PocketCarveMax;
|
||||
var pocketRadius = pockets.Radius > 0 ? pockets.Radius : tuning.PocketMaxRadius;
|
||||
|
||||
if (pocketMax > 0)
|
||||
{
|
||||
AddPockets(canvas, rng, tuning);
|
||||
AddPockets(canvas, rng, pocketMin, pocketMax, pocketRadius);
|
||||
NormalizeOuterWalls(canvas);
|
||||
}
|
||||
|
||||
@@ -565,12 +764,12 @@ internal static class LayoutFactory
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void AddPockets(GridCanvas canvas, Random rng, GenerationTuning tuning)
|
||||
private static void AddPockets(GridCanvas canvas, Random rng, int pocketMin, int pocketMax, int pocketRadius)
|
||||
{
|
||||
var pockets = rng.Next(tuning.PocketCarveMin, tuning.PocketCarveMax + 1);
|
||||
var pockets = rng.Next(pocketMin, pocketMax + 1);
|
||||
for (var i = 0; i < pockets; i++)
|
||||
{
|
||||
var radius = rng.Next(1, tuning.PocketMaxRadius + 1);
|
||||
var radius = rng.Next(1, pocketRadius + 1);
|
||||
var x = rng.Next(1, canvas.Width - 1);
|
||||
var y = rng.Next(1, canvas.Height - 1);
|
||||
for (var yy = y - radius; yy <= y + radius; yy++)
|
||||
@@ -966,7 +1165,8 @@ internal sealed class SokobanSolver
|
||||
current = entry2.Parent;
|
||||
}
|
||||
|
||||
return new SolveResult(pushes, turns, branching);
|
||||
var signature = SolutionSignature.Build(path.Select(p => p.Dir), pushes, turns, branching, _board.Width);
|
||||
return new SolveResult(pushes, turns, branching, signature);
|
||||
}
|
||||
|
||||
private List<int> ReachableWithoutPushing(int start, int[] boxes)
|
||||
@@ -1013,9 +1213,9 @@ internal sealed class SokobanSolver
|
||||
|
||||
internal readonly record struct SolverState(int Player, int[] Boxes);
|
||||
|
||||
internal readonly record struct SolveResult(int Pushes, int Turns, int Branching)
|
||||
internal readonly record struct SolveResult(int Pushes, int Turns, int Branching, string PatternSignature)
|
||||
{
|
||||
public static SolveResult Fail => new(-1, -1, -1);
|
||||
public static SolveResult Fail => new(-1, -1, -1, string.Empty);
|
||||
public bool IsFail => Pushes < 0;
|
||||
}
|
||||
|
||||
@@ -1049,3 +1249,222 @@ internal sealed class StateComparer : IEqualityComparer<SolverState>
|
||||
}
|
||||
|
||||
internal sealed record ScrambledState(int Player, int[] Boxes);
|
||||
|
||||
internal static class SolutionSignature
|
||||
{
|
||||
public static string Build(IEnumerable<int> dirs, int pushes, int turns, int branching, int boardWidth)
|
||||
{
|
||||
var dirList = dirs.ToList();
|
||||
if (pushes <= 0 || dirList.Count == 0) return string.Empty;
|
||||
|
||||
var chars = dirList.Select(d => DirToChar(d, boardWidth)).Where(c => c != '?').ToList();
|
||||
if (chars.Count == 0) return string.Empty;
|
||||
|
||||
var lenBin = pushes / 5;
|
||||
var turnBin = turns / 2;
|
||||
var branchBin = branching / 2;
|
||||
var dirMix = DirectionMix(chars, pushes);
|
||||
var runSig = RunSignature(chars, 8);
|
||||
var turnSig = TurnPattern(chars, 12);
|
||||
return $"{lenBin}:{turnBin}:{branchBin}:{dirMix}:{runSig}:{turnSig}";
|
||||
}
|
||||
|
||||
private static string DirectionMix(List<char> chars, int pushes)
|
||||
{
|
||||
var counts = new Dictionary<char, int> { ['U'] = 0, ['D'] = 0, ['L'] = 0, ['R'] = 0 };
|
||||
foreach (var c in chars)
|
||||
{
|
||||
if (counts.ContainsKey(c)) counts[c]++;
|
||||
}
|
||||
|
||||
var bins = new List<int>(4);
|
||||
foreach (var key in new[] { 'U', 'D', 'L', 'R' })
|
||||
{
|
||||
var ratio = (double)counts[key] / Math.Max(1, pushes);
|
||||
var bucket = (int)Math.Round(ratio * 5);
|
||||
bins.Add(Math.Clamp(bucket, 0, 5));
|
||||
}
|
||||
return string.Join("", bins);
|
||||
}
|
||||
|
||||
private static string RunSignature(List<char> chars, int maxParts)
|
||||
{
|
||||
if (chars.Count == 0) return string.Empty;
|
||||
var parts = new List<string>();
|
||||
var current = chars[0];
|
||||
var count = 1;
|
||||
void Flush()
|
||||
{
|
||||
if (parts.Count >= maxParts) return;
|
||||
parts.Add($"{current}{BucketLength(count)}");
|
||||
}
|
||||
|
||||
for (var i = 1; i < chars.Count; i++)
|
||||
{
|
||||
if (chars[i] == current)
|
||||
{
|
||||
count++;
|
||||
}
|
||||
else
|
||||
{
|
||||
Flush();
|
||||
current = chars[i];
|
||||
count = 1;
|
||||
}
|
||||
}
|
||||
Flush();
|
||||
|
||||
var truncated = parts.Take(maxParts).ToList();
|
||||
if (parts.Count > maxParts)
|
||||
{
|
||||
truncated.Add($"+{parts.Count - maxParts}");
|
||||
}
|
||||
return string.Join("", truncated);
|
||||
}
|
||||
|
||||
private static string TurnPattern(List<char> chars, int maxParts)
|
||||
{
|
||||
if (chars.Count < 2) return string.Empty;
|
||||
var turns = new List<char>();
|
||||
for (var i = 1; i < chars.Count; i++)
|
||||
{
|
||||
turns.Add(RelativeTurn(chars[i - 1], chars[i]));
|
||||
}
|
||||
|
||||
var sampled = turns.Take(maxParts).ToList();
|
||||
if (turns.Count > maxParts) sampled.Add('+');
|
||||
return new string(sampled.ToArray());
|
||||
}
|
||||
|
||||
private static char RelativeTurn(char prev, char next)
|
||||
{
|
||||
var dirIndex = new Dictionary<char, int> { ['U'] = 0, ['R'] = 1, ['D'] = 2, ['L'] = 3 };
|
||||
if (!dirIndex.ContainsKey(prev) || !dirIndex.ContainsKey(next)) return '?';
|
||||
var delta = (dirIndex[next] - dirIndex[prev] + 4) % 4;
|
||||
return delta switch
|
||||
{
|
||||
0 => 'S', // straight
|
||||
1 => 'R',
|
||||
3 => 'L',
|
||||
2 => 'B',
|
||||
_ => '?'
|
||||
};
|
||||
}
|
||||
|
||||
private static char BucketLength(int len)
|
||||
{
|
||||
if (len <= 1) return '1';
|
||||
if (len == 2) return '2';
|
||||
if (len <= 4) return '4';
|
||||
if (len <= 6) return '6';
|
||||
if (len <= 9) return '9';
|
||||
if (len <= 12) return 'c';
|
||||
return 'm';
|
||||
}
|
||||
|
||||
private static char DirToChar(int dir, int width)
|
||||
{
|
||||
if (dir == -width) return 'U';
|
||||
if (dir == width) return 'D';
|
||||
if (dir == -1) return 'L';
|
||||
if (dir == 1) return 'R';
|
||||
return '?';
|
||||
}
|
||||
}
|
||||
|
||||
internal static class LayoutFilters
|
||||
{
|
||||
public static bool CheckSpacing(IEnumerable<int> indices, int width, int height, int minDistance, int minWallDistance)
|
||||
{
|
||||
var arr = indices as int[] ?? indices.ToArray();
|
||||
if (arr.Length == 0) return true;
|
||||
if (minDistance > 1 && arr.Length > 1 && MinPairwiseDistance(arr, width) < minDistance)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (minWallDistance > 0 && MinDistanceToWall(arr, width, height) < minWallDistance)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static int MinPairwiseDistance(IReadOnlyList<int> indices, int width)
|
||||
{
|
||||
if (indices.Count <= 1) return int.MaxValue;
|
||||
var min = int.MaxValue;
|
||||
for (var i = 0; i < indices.Count; i++)
|
||||
{
|
||||
for (var j = i + 1; j < indices.Count; j++)
|
||||
{
|
||||
var dist = Manhattan(indices[i], indices[j], width);
|
||||
if (dist < min) min = dist;
|
||||
}
|
||||
}
|
||||
return min;
|
||||
}
|
||||
|
||||
private static int Manhattan(int a, int b, int width)
|
||||
{
|
||||
var ax = a % width;
|
||||
var ay = a / width;
|
||||
var bx = b % width;
|
||||
var by = b / width;
|
||||
return Math.Abs(ax - bx) + Math.Abs(ay - by);
|
||||
}
|
||||
|
||||
public static int MinDistanceToWall(IReadOnlyList<int> indices, int width, int height)
|
||||
{
|
||||
if (indices.Count == 0) return int.MaxValue;
|
||||
var min = int.MaxValue;
|
||||
foreach (var idx in indices)
|
||||
{
|
||||
var x = idx % width;
|
||||
var y = idx / width;
|
||||
var dist = Math.Min(Math.Min(x, width - 1 - x), Math.Min(y, height - 1 - y));
|
||||
if (dist < min) min = dist;
|
||||
}
|
||||
return min;
|
||||
}
|
||||
|
||||
public static bool HasCornerDeadlock(IEnumerable<int> boxes, HashSet<int> goals, Board board)
|
||||
{
|
||||
foreach (var box in boxes)
|
||||
{
|
||||
if (goals.Contains(box)) continue;
|
||||
if (IsCorner(box, board)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsCorner(int idx, Board board)
|
||||
{
|
||||
var w = board.Width;
|
||||
var h = board.Height;
|
||||
var x = idx % w;
|
||||
var y = idx / w;
|
||||
|
||||
// Out of bounds safety
|
||||
if (x < 0 || y < 0 || x >= w || y >= h) return false;
|
||||
|
||||
bool Solid(int xx, int yy)
|
||||
{
|
||||
if (xx < 0 || yy < 0 || xx >= w || yy >= h) return true;
|
||||
return board.IsSolid(yy * w + xx);
|
||||
}
|
||||
|
||||
var north = Solid(x, y - 1);
|
||||
var south = Solid(x, y + 1);
|
||||
var west = Solid(x - 1, y);
|
||||
var east = Solid(x + 1, y);
|
||||
|
||||
var cornerNW = north && west;
|
||||
var cornerNE = north && east;
|
||||
var cornerSW = south && west;
|
||||
var cornerSE = south && east;
|
||||
|
||||
return cornerNW || cornerNE || cornerSW || cornerSE;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,9 +16,9 @@ dotnet run -- <seed> [startId] [endId] > output.json
|
||||
- 진행 상태는 stderr에 한 줄로 갱신됩니다(벽 생성중/박스 배치중/검증중/생성완료).
|
||||
|
||||
## 설정
|
||||
- `levelbalance.json`: 레벨 밴드 설정(레벨 범위, 박스 수, 최소 푸시/턴/브랜칭, 사용할 마스크 개수). 이 파일을 수정해 난이도/범위를 조정합니다.
|
||||
- `mask_library.cs`: Microban/Novoban 스타일의 외곽 마스크 모음. 회전/반전/스케일 변형과 소규모 벽 지터가 자동 적용됩니다. 마스크를 추가해 형태 다양성을 늘릴 수 있습니다.
|
||||
- `Program.cs`: 생성 튜닝(`GenerationTuning`)에 시도 횟수, 시간 한도, 역탐색 깊이/폭, 외벽 정규화 등이 정의되어 있습니다.
|
||||
- `levelbalance.json`: 레벨 밴드 설정(레벨 범위, 박스 수, 최소 푸시/턴/브랜칭, 마스크 세트/확장 범위, 역탐색 깊이/폭 스케일, 목표/박스 간 최소 거리, 주머니(포켓) 리라이팅). `maskSets`(micro/medium/large), `maskPadMin/max`, `reverseDepthScale/breadthScale`, `minAllowedBranching`, `minGoal/Box/WallDistance`, `pocketCarveMin/Max/Radius`를 밴드별로 조정해 난이도·길이감을 설계합니다.
|
||||
- `mask_library.cs`: Microban/Novoban 스타일의 외곽 마스크 모음 + 중형/대형 마스크. 회전/반전/스케일 변형과 소규모 벽 지터가 자동 적용됩니다. 마스크를 추가해 형태 다양성을 늘릴 수 있습니다.
|
||||
- `Program.cs`: 생성 튜닝(`GenerationTuning`)에 시도 횟수, 시간 한도, 릴랙스 단계 수, 역탐색 기본 깊이/폭, 외벽 정규화, 코너 데드락 필터 옵션 등이 정의되어 있습니다.
|
||||
|
||||
## 출력 형식
|
||||
`output.json`은 다음 형태의 배열입니다.
|
||||
@@ -49,4 +49,5 @@ dotnet run -- <seed> [startId] [endId] > output.json
|
||||
## 메모
|
||||
- 외벽은 항상 `#`로 둘러지며, `0`은 외부에서만 사용됩니다.
|
||||
- 중복 레이아웃은 제거됩니다.
|
||||
- 해법 패턴(푸시/턴/방향 런 요약) 해시를 이용해 유사 해법 레벨도 추가로 걸러냅니다.
|
||||
- 더 느리게 돌려야 하면 `GenerationTuning`의 시도/시간 한도를 조정하세요.
|
||||
|
||||
63
StatusReporter.cs
Normal file
63
StatusReporter.cs
Normal file
@@ -0,0 +1,63 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
|
||||
internal sealed class StatusReporter
|
||||
{
|
||||
private int _dotCounter = 0;
|
||||
private string _lastMessage = string.Empty;
|
||||
private DateTime _lastWriteTime = DateTime.MinValue;
|
||||
|
||||
public void Show(string message, bool withDots = false)
|
||||
{
|
||||
if (withDots)
|
||||
{
|
||||
_dotCounter++;
|
||||
message = $"{message}{Dots()}";
|
||||
}
|
||||
Write(message, newline: false);
|
||||
}
|
||||
|
||||
public string Dots()
|
||||
{
|
||||
var dots = (_dotCounter % 3) + 1;
|
||||
return new string('.', dots);
|
||||
}
|
||||
|
||||
public void ShowLine(string message)
|
||||
{
|
||||
Write(message, newline: true);
|
||||
}
|
||||
|
||||
private void Write(string message, bool newline)
|
||||
{
|
||||
if (_lastMessage == message && !newline) return;
|
||||
var now = DateTime.UtcNow;
|
||||
if (!newline && (now - _lastWriteTime).TotalMilliseconds < 200)
|
||||
{
|
||||
return;
|
||||
}
|
||||
_lastWriteTime = now;
|
||||
var pad = string.Empty;
|
||||
_lastMessage = message;
|
||||
var output = $"\r\u001b[K{message}{pad}";
|
||||
if (newline)
|
||||
{
|
||||
Console.Error.WriteLine(output);
|
||||
_lastMessage = string.Empty;
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.Error.Write(output);
|
||||
}
|
||||
}
|
||||
|
||||
public static string FormatDuration(TimeSpan elapsed)
|
||||
{
|
||||
if (elapsed.TotalMinutes >= 1)
|
||||
{
|
||||
return $"{(int)elapsed.TotalMinutes}m {elapsed.Seconds}s";
|
||||
}
|
||||
return $"{elapsed.Seconds}s";
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,17 @@
|
||||
"minAllowedPushes": 4,
|
||||
"minAllowedTurns": 2,
|
||||
"minAllowedBranching": 0,
|
||||
"minGoalDistance": 2,
|
||||
"minBoxDistance": 2,
|
||||
"minWallDistance": 1,
|
||||
"reverseDepthScale": 1.0,
|
||||
"reverseBreadthScale": 1.0,
|
||||
"pocketCarveMin": 0,
|
||||
"pocketCarveMax": 1,
|
||||
"pocketMaxRadius": 1,
|
||||
"maskPadMin": -1,
|
||||
"maskPadMax": 1,
|
||||
"maskSets": ["micro"],
|
||||
"maskTake": 8
|
||||
},
|
||||
{
|
||||
@@ -17,7 +28,18 @@
|
||||
"boxCountHigh": 2,
|
||||
"minAllowedPushes": 7,
|
||||
"minAllowedTurns": 3,
|
||||
"minAllowedBranching": 0,
|
||||
"minAllowedBranching": 1,
|
||||
"minGoalDistance": 3,
|
||||
"minBoxDistance": 3,
|
||||
"minWallDistance": 1,
|
||||
"reverseDepthScale": 1.15,
|
||||
"reverseBreadthScale": 1.2,
|
||||
"pocketCarveMin": 1,
|
||||
"pocketCarveMax": 2,
|
||||
"pocketMaxRadius": 2,
|
||||
"maskPadMin": -1,
|
||||
"maskPadMax": 2,
|
||||
"maskSets": ["micro", "medium"],
|
||||
"maskTake": 0
|
||||
},
|
||||
{
|
||||
@@ -27,7 +49,18 @@
|
||||
"boxCountHigh": 3,
|
||||
"minAllowedPushes": 9,
|
||||
"minAllowedTurns": 4,
|
||||
"minAllowedBranching": 0,
|
||||
"minAllowedBranching": 2,
|
||||
"minGoalDistance": 3,
|
||||
"minBoxDistance": 3,
|
||||
"minWallDistance": 1,
|
||||
"reverseDepthScale": 1.35,
|
||||
"reverseBreadthScale": 1.4,
|
||||
"pocketCarveMin": 1,
|
||||
"pocketCarveMax": 3,
|
||||
"pocketMaxRadius": 2,
|
||||
"maskPadMin": 0,
|
||||
"maskPadMax": 2,
|
||||
"maskSets": ["medium", "large"],
|
||||
"maskTake": 0
|
||||
}
|
||||
]
|
||||
|
||||
179
mask_library.cs
179
mask_library.cs
@@ -1,3 +1,4 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
@@ -209,19 +210,161 @@ internal static class MaskLibrary
|
||||
}
|
||||
};
|
||||
|
||||
// 중형(약 11~13) 마스크: Microban보다 넓은 홀과 복도 길이를 제공.
|
||||
public static readonly List<string[]> Medium = new()
|
||||
{
|
||||
new[]{
|
||||
"00000000000",
|
||||
"00#######00",
|
||||
"00#.....#00",
|
||||
"0##.###.##0",
|
||||
"0#..#.#..#0",
|
||||
"0#.......#0",
|
||||
"0#..#.#..#0",
|
||||
"0##.###.##0",
|
||||
"00#.....#00",
|
||||
"00#######00",
|
||||
"00000000000"
|
||||
},
|
||||
new[]{
|
||||
"0000000000000",
|
||||
"000#######000",
|
||||
"00##.....##00",
|
||||
"00#..###..#00",
|
||||
"0##.......##0",
|
||||
"0#..#...#..#0",
|
||||
"0##.......##0",
|
||||
"00#..###..#00",
|
||||
"00##.....##00",
|
||||
"000#######000",
|
||||
"0000000000000"
|
||||
},
|
||||
new[]{
|
||||
"000000000000",
|
||||
"00########00",
|
||||
"00#......#00",
|
||||
"0##.####.##0",
|
||||
"0#..#..#..#0",
|
||||
"0#..#..#..#0",
|
||||
"0##.####.##0",
|
||||
"00#......#00",
|
||||
"00###..###00",
|
||||
"000#....#000",
|
||||
"000######000",
|
||||
"000000000000"
|
||||
},
|
||||
new[]{
|
||||
"000000000000",
|
||||
"000######000",
|
||||
"000#....#000",
|
||||
"0###.##.###0",
|
||||
"0#......#.#0",
|
||||
"0#.##.##..#0",
|
||||
"0#..#.....#0",
|
||||
"0###.##.###0",
|
||||
"000#....#000",
|
||||
"000######000",
|
||||
"000000000000"
|
||||
},
|
||||
new[]{
|
||||
"0000000000000",
|
||||
"000#######000",
|
||||
"000#.....#000",
|
||||
"0###.###.###0",
|
||||
"0#...#.#...#0",
|
||||
"0#...#.#...#0",
|
||||
"0###.###.###0",
|
||||
"000#.....#000",
|
||||
"000#######000",
|
||||
"0000000000000"
|
||||
}
|
||||
};
|
||||
|
||||
// 대형(약 15~16) 마스크: 입구/포켓이 늘어나고, 복도 길이가 길다.
|
||||
public static readonly List<string[]> Large = new()
|
||||
{
|
||||
new[]{
|
||||
"000000000000000",
|
||||
"000#########000",
|
||||
"000#.......#000",
|
||||
"00##.#####.##00",
|
||||
"00#..#...#..#00",
|
||||
"0##..#...#..##0",
|
||||
"0#...#...#...#0",
|
||||
"0#...#####...#0",
|
||||
"0#...........#0",
|
||||
"0##..#...#..##0",
|
||||
"00#..#...#..#00",
|
||||
"00##.#####.##00",
|
||||
"000#.......#000",
|
||||
"000#########000",
|
||||
"000000000000000"
|
||||
},
|
||||
new[]{
|
||||
"000000000000000",
|
||||
"000#########000",
|
||||
"000#.......#000",
|
||||
"00##.#####.##00",
|
||||
"00#..#...#..#00",
|
||||
"0##..#.#.#..##0",
|
||||
"0#...##.##...#0",
|
||||
"0#...#...#...#0",
|
||||
"0#...##.##...#0",
|
||||
"0##..#.#.#..##0",
|
||||
"00#..#...#..#00",
|
||||
"00##.#####.##00",
|
||||
"000#.......#000",
|
||||
"000#########000",
|
||||
"000000000000000"
|
||||
},
|
||||
new[]{
|
||||
"0000000000000000",
|
||||
"0000#########000",
|
||||
"0000#.......#000",
|
||||
"00###.#####.###0",
|
||||
"00#..#.....#..#0",
|
||||
"0##..##...##..##",
|
||||
"0#....#...#....#",
|
||||
"0#.#..#####..#.#",
|
||||
"0#....#...#....#",
|
||||
"0##..##...##..##",
|
||||
"00#..#.....#..#0",
|
||||
"00###.#####.###0",
|
||||
"0000#.......#000",
|
||||
"0000#########000",
|
||||
"0000000000000000"
|
||||
},
|
||||
new[]{
|
||||
"000000000000000",
|
||||
"000##########00",
|
||||
"000#........#00",
|
||||
"00##.######.#00",
|
||||
"00#..#....#.#00",
|
||||
"0##..#....#..##",
|
||||
"0#...#.##.#...#",
|
||||
"0#...#....#...#",
|
||||
"0##..#....#..##",
|
||||
"00#..#....#.#00",
|
||||
"00##.######.#00",
|
||||
"000#........#00",
|
||||
"000##########00",
|
||||
"000000000000000"
|
||||
}
|
||||
};
|
||||
|
||||
public static string[] PickRandom(Random rng, List<string[]> masks)
|
||||
{
|
||||
if (masks.Count == 0) throw new System.InvalidOperationException("No masks provided.");
|
||||
return masks[rng.Next(masks.Count)];
|
||||
}
|
||||
|
||||
public static List<string[]> ExpandWithTransforms(IEnumerable<string[]> baseMasks, bool includeScaled = true)
|
||||
public static List<string[]> ExpandWithTransforms(IEnumerable<string[]> baseMasks, int padMin = -1, int padMax = 1)
|
||||
{
|
||||
var seen = new HashSet<string>();
|
||||
var output = new List<string[]>();
|
||||
foreach (var mask in baseMasks)
|
||||
{
|
||||
var seeds = includeScaled ? ScaleVariants(mask) : new List<string[]> { mask };
|
||||
var seeds = ScaleVariants(mask, padMin, padMax);
|
||||
foreach (var seed in seeds)
|
||||
{
|
||||
foreach (var variant in Variants(seed))
|
||||
@@ -345,14 +488,34 @@ internal static class MaskLibrary
|
||||
}
|
||||
}
|
||||
|
||||
private static List<string[]> ScaleVariants(string[] mask)
|
||||
private static List<string[]> ScaleVariants(string[] mask, int padMin, int padMax)
|
||||
{
|
||||
var scaled = new List<string[]>();
|
||||
scaled.Add(mask);
|
||||
var padded = Pad(mask, 1);
|
||||
if (padded != null) scaled.Add(padded);
|
||||
var trimmed = Pad(mask, -1);
|
||||
if (trimmed != null) scaled.Add(trimmed);
|
||||
var from = Math.Min(padMin, padMax);
|
||||
var to = Math.Max(padMin, padMax);
|
||||
var addedBase = false;
|
||||
|
||||
for (var delta = from; delta <= to; delta++)
|
||||
{
|
||||
if (delta == 0)
|
||||
{
|
||||
scaled.Add(mask);
|
||||
addedBase = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
var padded = Pad(mask, delta);
|
||||
if (padded != null)
|
||||
{
|
||||
scaled.Add(padded);
|
||||
}
|
||||
}
|
||||
|
||||
if (!addedBase)
|
||||
{
|
||||
scaled.Insert(0, mask);
|
||||
}
|
||||
|
||||
return scaled;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user