commit e81218576d04a91736ef672fcba132c574ccbd1f Author: JiWoong Sul Date: Sat Nov 22 01:08:44 2025 +0900 신규등록 diff --git a/Program copy.cs.bak b/Program copy.cs.bak new file mode 100644 index 0000000..7a34614 --- /dev/null +++ b/Program copy.cs.bak @@ -0,0 +1,989 @@ +// Sokoban map generator (mask-based, reverse search from solved state) +// - Uses small hand-made masks (Microban/Novoban 스타일) to shape the outer walls. +// - Places goals/boxes in solved state, then pulls boxes away from goals (reverse of push) to guarantee solvability. +// - Run: `dotnet run > output.json` (defaults use band config). Optional: `dotnet run [startId] [endId]`. +// Legend: '#' wall, '.' floor, '0' void, 'G' goal, '$' box, '@' player. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text.Json; + +internal static class Program +{ + private static readonly LevelBandConfig[] LevelBands = + { + new LevelBandConfig + { + StartId = 3, + EndId = 20, + BoxCountLow = 1, + BoxCountHigh = 2, + MinAllowedPushes = 4, + MinAllowedTurns = 2, + ShapeMasks = MaskLibrary.Microban.Take(8).ToList() + }, + new LevelBandConfig + { + StartId = 21, + EndId = 40, + BoxCountLow = 1, + BoxCountHigh = 2, + MinAllowedPushes = 7, + MinAllowedTurns = 3, + ShapeMasks = MaskLibrary.Microban.ToList() + }, + new LevelBandConfig + { + StartId = 41, + EndId = 60, + BoxCountLow = 2, + BoxCountHigh = 3, + MinAllowedPushes = 9, + MinAllowedTurns = 4, + ShapeMasks = MaskLibrary.Microban.ToList() + } + }; + + private static readonly GenerationTuning Tuning = new GenerationTuning + { + MaxAttemptsPerLevel = 2000, + MaxMillisecondsPerLevel = 40_000, + PushLimitPadding = 2, + PushLimitScale = 0.35, + DynamicGrowthWindow = 12, + DynamicPushIncrement = 2, + ReverseSearchMaxDepth = 120, + ReverseSearchBreadth = 800, + ApplyMaskTransforms = true, + MaskWallJitter = 2 + }; + + private const int DefaultSeed = 12345; + + public static void Main(string[] args) + { + var seed = DefaultSeed; + if (args.Length > 0 && int.TryParse(args[0], out var parsedSeed)) + { + seed = parsedSeed; + } + + var rng = new Random(seed); + var generator = new LevelGenerator(rng, Tuning, LevelBands); + + var startId = LevelBands.Min(b => b.StartId); + var endId = LevelBands.Max(b => b.EndId); + + if (args.Length >= 2 && int.TryParse(args[1], out var requestedStart)) + { + startId = requestedStart; + } + + if (args.Length >= 3 && int.TryParse(args[2], out var requestedEnd)) + { + endId = requestedEnd; + } + + if (startId > endId) + { + (startId, endId) = (endId, startId); + } + + var levels = generator.BuildRange(startId, endId); + var options = new JsonSerializerOptions + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + Console.WriteLine(JsonSerializer.Serialize(levels, options)); + } +} + +internal sealed class LevelGenerator +{ + private readonly Random _rng; + private readonly GenerationTuning _tuning; + private readonly IReadOnlyList _bands; + private readonly bool _trace; + private readonly HashSet _seenLayouts = new(); + + public LevelGenerator(Random rng, GenerationTuning tuning, IReadOnlyList bands) + { + _rng = rng; + _tuning = tuning; + _bands = bands; + _trace = Environment.GetEnvironmentVariable("NEKOBAN_DEBUG") == "1"; + } + + public List BuildRange(int startId, int endId) + { + foreach (var band in _bands) + { + if (band.ShapeMasksExpanded == null || band.ShapeMasksExpanded.Count == 0) + { + var baseMasks = band.ShapeMasks.Count > 0 ? band.ShapeMasks : MaskLibrary.Microban; + band.ShapeMasksExpanded = MaskLibrary.ExpandWithTransforms(baseMasks); + } + } + + var output = new List(); + for (var id = startId; id <= endId; id++) + { + var band = ResolveBand(id); + var level = BuildSingle(id, band); + output.Add(level); + } + + return output; + } + + private GeneratedLevel BuildSingle(int id, LevelBandConfig band) + { + var stopwatch = Stopwatch.StartNew(); + var attempts = 0; + var failReasons = _trace ? new Dictionary() : null; + + while (attempts < _tuning.MaxAttemptsPerLevel && + stopwatch.ElapsedMilliseconds < _tuning.MaxMillisecondsPerLevel) + { + attempts++; + + 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 canvas = LayoutFactory.FromMask(mask, _rng, _tuning); + + 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; + } + + 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 + }; + } + + if (_trace && failReasons != null) + { + Console.Error.WriteLine($"[trace] level {id} failed. Reasons:"); + foreach (var kv in failReasons.OrderByDescending(kv => kv.Value)) + { + Console.Error.WriteLine($" - {kv.Key}: {kv.Value}"); + } + } + + throw new InvalidOperationException($"레벨 {id} 생성 실패 (attempts {attempts})."); + } + + private LevelBandConfig ResolveBand(int id) + { + foreach (var band in _bands) + { + if (id >= band.StartId && id <= band.EndId) + { + return band; + } + } + + var last = _bands.Last(); + var delta = id - last.EndId; + var growthStep = Math.Max(1, delta / Math.Max(1, _tuning.DynamicGrowthWindow) + 1); + return new LevelBandConfig + { + StartId = id, + EndId = id, + BoxCountLow = last.BoxCountLow + growthStep / 2, + BoxCountHigh = last.BoxCountHigh + growthStep / 2, + MinAllowedPushes = last.MinAllowedPushes + growthStep * _tuning.DynamicPushIncrement, + ShapeMasks = last.ShapeMasks + }; + } + + private static void NoteFail(IDictionary? sink, string reason) + { + if (sink == null) return; + sink[reason] = sink.TryGetValue(reason, out var count) ? count + 1 : 1; + } +} + +internal sealed class GeneratedLevel +{ + public int Id { get; init; } + public List Grid { get; init; } = new(); + public int LowestPush { get; init; } + public int PushLimit { get; init; } +} + +internal sealed class LevelBandConfig +{ + public int StartId { get; init; } + public int EndId { get; init; } + public int BoxCountLow { get; init; } + public int BoxCountHigh { get; init; } + public int MinAllowedPushes { get; init; } + public int MinAllowedTurns { get; init; } = 0; + public int MinAllowedBranching { get; init; } = 0; + public List ShapeMasks { get; init; } = new(); + public List? ShapeMasksExpanded { get; set; } +} + +internal sealed class GenerationTuning +{ + public int MaxAttemptsPerLevel { get; init; } + public int MaxMillisecondsPerLevel { get; init; } + public int PushLimitPadding { get; init; } + public double PushLimitScale { get; init; } + public int DynamicGrowthWindow { get; init; } + public int DynamicPushIncrement { get; init; } + public int ReverseSearchMaxDepth { get; init; } + public int ReverseSearchBreadth { get; init; } + public bool ApplyMaskTransforms { get; init; } + public int MaskWallJitter { get; init; } + public int PocketCarveMin { get; init; } = 0; + public int PocketCarveMax { get; init; } = 0; + public int PocketMaxRadius { get; init; } = 1; +} + +internal sealed class GridCanvas +{ + private readonly char[] _cells; + + public GridCanvas(int width, int height) + { + Width = width; + Height = height; + _cells = Enumerable.Repeat('0', width * height).ToArray(); + } + + public int Width { get; } + public int Height { get; } + + public void Fill(char c) => Array.Fill(_cells, c); + + public void Set(int x, int y, char c) + { + if (!InBounds(x, y)) return; + _cells[y * Width + x] = c; + } + + public void Set(int index, char c) + { + if (index < 0 || index >= _cells.Length) return; + _cells[index] = c; + } + + public char Get(int x, int y) => _cells[y * Width + x]; + public char Get(int index) => _cells[index]; + + public bool InBounds(int x, int y) => x >= 0 && y >= 0 && x < Width && y < Height; + + public void Overlay(IEnumerable indices, char c) + { + foreach (var idx in indices) + { + Set(idx, c); + } + } + + public void ClearDynamic() + { + for (var i = 0; i < _cells.Length; i++) + { + if (_cells[i] == '@' || _cells[i] == '$') + { + _cells[i] = '.'; + } + } + } + + public List ToLines() + { + var lines = new List(Height); + for (var y = 0; y < Height; y++) + { + lines.Add(new string(_cells, y * Width, Width)); + } + return lines; + } +} + +internal static class LayoutFactory +{ + public static GridCanvas FromMask(string[] mask, Random rng, GenerationTuning tuning) + { + var height = mask.Length; + var width = mask.Max(row => row.Length); + var canvas = new GridCanvas(width, height); + canvas.Fill('0'); + + for (var y = 0; y < height; y++) + { + var row = mask[y]; + for (var x = 0; x < row.Length; x++) + { + canvas.Set(x, y, row[x]); + } + } + + NormalizeOuterWalls(canvas); + + if (tuning.PocketCarveMax > 0) + { + AddPockets(canvas, rng, tuning); + NormalizeOuterWalls(canvas); + } + + return canvas; + } + + // Enforce: inside the bounding box of non-void tiles, edges are walls, interior voids become walls. + private static void NormalizeOuterWalls(GridCanvas canvas) + { + var w = canvas.Width; + var h = canvas.Height; + + var minX = w; + var minY = h; + var maxX = -1; + var maxY = -1; + + for (var y = 0; y < h; y++) + { + for (var x = 0; x < w; x++) + { + var c = canvas.Get(x, y); + if (c == '0') continue; + if (x < minX) minX = x; + if (y < minY) minY = y; + if (x > maxX) maxX = x; + if (y > maxY) maxY = y; + } + } + + if (maxX < minX || maxY < minY) + { + return; // nothing to normalize + } + + for (var y = minY; y <= maxY; y++) + { + for (var x = minX; x <= maxX; x++) + { + var isEdge = x == minX || x == maxX || y == minY || y == maxY; + if (isEdge) + { + canvas.Set(x, y, '#'); + } + else if (canvas.Get(x, y) == '0') + { + canvas.Set(x, y, '#'); + } + } + } + + // Outside bounding box remains void. + } + + public static bool NormalizeOuterWallsStrict(GridCanvas canvas, bool failOnTokens) + { + var w = canvas.Width; + var h = canvas.Height; + + var minX = w; + var minY = h; + var maxX = -1; + var maxY = -1; + + for (var y = 0; y < h; y++) + { + for (var x = 0; x < w; x++) + { + var c = canvas.Get(x, y); + if (c == '0') continue; + if (x < minX) minX = x; + if (y < minY) minY = y; + if (x > maxX) maxX = x; + if (y > maxY) maxY = y; + } + } + + if (maxX < minX || maxY < minY) + { + return false; + } + + for (var y = minY; y <= maxY; y++) + { + for (var x = minX; x <= maxX; x++) + { + var isEdge = x == minX || x == maxX || y == minY || y == maxY; + var c = canvas.Get(x, y); + if (isEdge) + { + if (failOnTokens && (c == 'G' || c == '$' || c == '@')) + { + return false; + } + canvas.Set(x, y, '#'); + } + else if (c == '0') + { + canvas.Set(x, y, '#'); + } + } + } + + return true; + } + + private static void AddPockets(GridCanvas canvas, Random rng, GenerationTuning tuning) + { + var pockets = rng.Next(tuning.PocketCarveMin, tuning.PocketCarveMax + 1); + for (var i = 0; i < pockets; i++) + { + var radius = rng.Next(1, tuning.PocketMaxRadius + 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++) + { + for (var xx = x - radius; xx <= x + radius; xx++) + { + if (!canvas.InBounds(xx, yy)) continue; + if (xx == 0 || yy == 0 || xx == canvas.Width - 1 || yy == canvas.Height - 1) continue; + var dx = xx - x; + var dy = yy - y; + if (dx * dx + dy * dy <= radius * radius) + { + if (canvas.Get(xx, yy) == '#') + { + canvas.Set(xx, yy, '.'); + } + } + } + } + } + } +} + +internal static class PiecePlacer +{ + public static bool TryPlace(GridCanvas canvas, Random rng, int boxCount, out HashSet goals, out int[] boxes, out int player) + { + goals = new HashSet(); + boxes = Array.Empty(); + player = -1; + + var candidates = Walkable(canvas).ToList(); + var minRequired = boxCount + 2; + if (candidates.Count < minRequired) + { + return false; + } + + Shuffle(candidates, rng); + + var softGoals = new List(); + foreach (var idx in candidates) + { + if (IsDeadCorner(canvas, idx)) + { + softGoals.Add(idx); + continue; + } + goals.Add(idx); + if (goals.Count == boxCount) break; + } + + if (goals.Count != boxCount) + { + foreach (var idx in softGoals) + { + goals.Add(idx); + if (goals.Count == boxCount) break; + } + } + + if (goals.Count != boxCount) return false; + + var goalSet = new HashSet(goals); + var nonGoals = candidates.Where(i => !goalSet.Contains(i)).ToList(); + Shuffle(nonGoals, rng); + + boxes = goals.ToArray(); + Array.Sort(boxes); + + if (nonGoals.Count == 0) return false; + + var reachable = FloodFrom(goalSet.First(), canvas, Array.Empty()); + if (goalSet.Any(g => !reachable.Contains(g))) return false; + + var playerOptions = nonGoals.Where(reachable.Contains).ToList(); + if (playerOptions.Count == 0) return false; + + player = playerOptions[rng.Next(playerOptions.Count)]; + return true; + } + + private static HashSet FloodFrom(int start, GridCanvas canvas, int[] boxes) + { + var visited = new HashSet(); + if (IsSolid(canvas.Get(start)) || Array.BinarySearch(boxes, start) >= 0) return visited; + + var queue = new Queue(); + visited.Add(start); + queue.Enqueue(start); + + while (queue.Count > 0) + { + var current = queue.Dequeue(); + foreach (var next in NeighborIndices(current, canvas.Width, canvas.Height)) + { + if (next < 0 || next >= canvas.Width * canvas.Height) continue; + if (visited.Contains(next)) continue; + + var tile = canvas.Get(next); + if (IsSolid(tile) || Array.BinarySearch(boxes, next) >= 0) continue; + + visited.Add(next); + queue.Enqueue(next); + } + } + + return visited; + } + + private static IEnumerable Walkable(GridCanvas canvas) + { + for (var i = 0; i < canvas.Width * canvas.Height; i++) + { + var tile = canvas.Get(i); + if (!IsSolid(tile)) + { + yield return i; + } + } + } + + private static IEnumerable NeighborIndices(int index, int width, int height) + { + var x = index % width; + var y = index / width; + + if (y > 0) yield return index - width; + if (y < height - 1) yield return index + width; + if (x > 0) yield return index - 1; + if (x < width - 1) yield return index + 1; + } + + private static bool IsDeadCorner(GridCanvas canvas, int idx) + { + var w = canvas.Width; + var h = canvas.Height; + var x = idx % w; + var y = idx / w; + + var solidNorth = y == 0 || IsSolid(canvas.Get(x, y - 1)); + var solidSouth = y == h - 1 || IsSolid(canvas.Get(x, y + 1)); + var solidWest = x == 0 || IsSolid(canvas.Get(x - 1, y)); + var solidEast = x == w - 1 || IsSolid(canvas.Get(x + 1, y)); + + var verticalBlocked = (solidNorth && solidSouth); + var horizontalBlocked = (solidWest && solidEast); + if (verticalBlocked || horizontalBlocked) return true; + + var cornerNW = solidNorth && solidWest; + var cornerNE = solidNorth && solidEast; + var cornerSW = solidSouth && solidWest; + var cornerSE = solidSouth && solidEast; + return cornerNW || cornerNE || cornerSW || cornerSE; + } + + private static bool IsSolid(char tile) => tile == '#' || tile == '0'; + + private static void Shuffle(IList list, Random rng) + { + for (var i = list.Count - 1; i > 0; i--) + { + var j = rng.Next(i + 1); + (list[i], list[j]) = (list[j], list[i]); + } + } +} + +internal sealed class Board +{ + private readonly bool[] _walls; + + private Board(int width, int height, bool[] walls) + { + Width = width; + Height = height; + _walls = walls; + } + + public int Width { get; } + public int Height { get; } + public int Size => Width * Height; + + public static Board FromCanvas(GridCanvas canvas) + { + var walls = new bool[canvas.Width * canvas.Height]; + for (var i = 0; i < walls.Length; i++) + { + var tile = canvas.Get(i); + walls[i] = tile == '#' || tile == '0'; + } + + return new Board(canvas.Width, canvas.Height, walls); + } + + public bool IsSolid(int index) => _walls[index]; +} + +internal sealed class ReverseSearch +{ + private readonly Board _board; + private readonly int _maxDepth; + private readonly int _breadth; + private readonly int[] _dirs; + + public ReverseSearch(Board board, int maxDepth, int breadth) + { + _board = board; + _maxDepth = maxDepth; + _breadth = breadth; + _dirs = new[] { -board.Width, board.Width, -1, 1 }; + } + + public ScrambledState? FindStartState(HashSet goals, int minPush, Random rng) + { + var boxes = goals.ToArray(); + Array.Sort(boxes); + + var playerStart = FirstFloorNotGoal(goals); + if (playerStart < 0) return null; + + var start = new SolverState(playerStart, boxes); + var queue = new Queue<(SolverState State, int Pushes)>(); + var visited = new HashSet(new StateComparer()) { start }; + queue.Enqueue((start, 0)); + + ScrambledState? best = null; + var bestPush = -1; + + while (queue.Count > 0 && visited.Count < _breadth) + { + var (state, pushes) = queue.Dequeue(); + if (pushes >= minPush && pushes > bestPush) + { + bestPush = pushes; + best = new ScrambledState(state.Player, state.Boxes); + } + + if (pushes >= _maxDepth) continue; + + foreach (var next in Expand(state)) + { + var nextPushes = pushes + (next.Item2 ? 1 : 0); + if (nextPushes > _maxDepth) continue; + + var nextState = next.Item1; + if (visited.Add(nextState)) + { + queue.Enqueue((nextState, nextPushes)); + } + } + } + + return best; + } + + private int FirstFloorNotGoal(HashSet goals) + { + for (var i = 0; i < _board.Size; i++) + { + if (_board.IsSolid(i)) continue; + if (goals.Contains(i)) continue; + return i; + } + return -1; + } + + // Returns (state, isPull) where isPull indicates a box move (counts as push) + private IEnumerable<(SolverState, bool)> Expand(SolverState state) + { + foreach (var dir in _dirs) + { + var step = state.Player + dir; + if (!IndexInBounds(step) || _board.IsSolid(step) || Array.BinarySearch(state.Boxes, step) >= 0) + { + // pull candidate? + var boxPos = state.Player + dir; + var behind = state.Player - dir; + if (!IndexInBounds(boxPos) || !IndexInBounds(behind)) continue; + var boxIndex = Array.BinarySearch(state.Boxes, boxPos); + if (boxIndex < 0) continue; + if (_board.IsSolid(behind) || Array.BinarySearch(state.Boxes, behind) >= 0) continue; + + var nextBoxes = state.Boxes.ToArray(); + nextBoxes[boxIndex] = state.Player; + Array.Sort(nextBoxes); + yield return (new SolverState(behind, nextBoxes), true); + continue; + } + + // player walk + yield return (new SolverState(step, state.Boxes), false); + } + } + + private bool IndexInBounds(int idx) => idx >= 0 && idx < _board.Size; +} + +internal sealed class SokobanSolver +{ + private readonly Board _board; + private readonly StateComparer _comparer = new(); + private readonly int[] _dirs; + + public SokobanSolver(Board board) + { + _board = board; + _dirs = new[] { -board.Width, board.Width, -1, 1 }; + } + + public SolveResult SolveDetailed(int player, int[] boxes, HashSet goals) + { + Array.Sort(boxes); + var start = new SolverState(player, boxes); + var visited = new HashSet(_comparer) { start }; + var parents = new Dictionary(_comparer); + var queue = new Queue(); + queue.Enqueue(start); + + while (queue.Count > 0) + { + var state = queue.Dequeue(); + if (IsSolved(state.Boxes, goals)) + { + return BuildResult(state, parents); + } + + foreach (var (next, dir) in Expand(state)) + { + if (visited.Add(next)) + { + parents[next] = (state, dir); + queue.Enqueue(next); + } + } + } + + return SolveResult.Fail; + } + + private IEnumerable<(SolverState State, int Dir)> Expand(SolverState state) + { + var reachable = ReachableWithoutPushing(state.Player, state.Boxes); + foreach (var pos in reachable) + { + foreach (var dir in _dirs) + { + var boxPos = pos + dir; + var landing = boxPos + dir; + if (!IndexInBounds(boxPos) || !IndexInBounds(landing)) continue; + + var boxIndex = Array.BinarySearch(state.Boxes, boxPos); + if (boxIndex < 0) continue; + if (_board.IsSolid(landing) || Array.BinarySearch(state.Boxes, landing) >= 0) continue; + + var nextBoxes = state.Boxes.ToArray(); + nextBoxes[boxIndex] = landing; + Array.Sort(nextBoxes); + yield return (new SolverState(boxPos, nextBoxes), dir); + } + } + } + + private SolveResult BuildResult(SolverState solved, Dictionary parents) + { + var pushes = 0; + var turns = 0; + var branching = 0; + + var path = new List<(SolverState State, int Dir)>(); + var current = solved; + while (parents.TryGetValue(current, out var entry)) + { + path.Add((current, entry.Dir)); + current = entry.Parent; + } + path.Reverse(); + + var prevDir = 0; + foreach (var (_, dir) in path) + { + pushes++; + if (prevDir != 0 && dir != prevDir) turns++; + prevDir = dir; + } + + // branching: count states along path that had more than one push option + current = solved; + while (parents.TryGetValue(current, out var entry2)) + { + var pushOptions = Expand(entry2.Parent).Count(); + if (pushOptions > 1) branching++; + current = entry2.Parent; + } + + return new SolveResult(pushes, turns, branching); + } + + private List ReachableWithoutPushing(int start, int[] boxes) + { + var visited = new bool[_board.Size]; + var queue = new Queue(); + var output = new List(); + + if (_board.IsSolid(start) || Array.BinarySearch(boxes, start) >= 0) return output; + + visited[start] = true; + queue.Enqueue(start); + output.Add(start); + + while (queue.Count > 0) + { + var current = queue.Dequeue(); + foreach (var dir in _dirs) + { + var next = current + dir; + if (!IndexInBounds(next)) continue; + if (visited[next]) continue; + if (_board.IsSolid(next) || Array.BinarySearch(boxes, next) >= 0) continue; + visited[next] = true; + queue.Enqueue(next); + output.Add(next); + } + } + + return output; + } + + private bool IsSolved(int[] boxes, HashSet goals) + { + foreach (var b in boxes) + { + if (!goals.Contains(b)) return false; + } + return true; + } + + private bool IndexInBounds(int idx) => idx >= 0 && idx < _board.Size; +} + +internal readonly record struct SolverState(int Player, int[] Boxes); + +internal readonly record struct SolveResult(int Pushes, int Turns, int Branching) +{ + public static SolveResult Fail => new(-1, -1, -1); + public bool IsFail => Pushes < 0; +} + +internal sealed class StateComparer : IEqualityComparer +{ + public bool Equals(SolverState x, SolverState y) + { + if (x.Player != y.Player) return false; + var a = x.Boxes; + var b = y.Boxes; + if (a.Length != b.Length) return false; + for (var i = 0; i < a.Length; i++) + { + if (a[i] != b[i]) return false; + } + return true; + } + + public int GetHashCode(SolverState obj) + { + var hash = obj.Player * 397 ^ obj.Boxes.Length; + unchecked + { + for (var i = 0; i < obj.Boxes.Length; i++) + { + hash = hash * 31 + obj.Boxes[i]; + } + } + return hash; + } +} + +internal sealed record ScrambledState(int Player, int[] Boxes); diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..f58da2a --- /dev/null +++ b/Program.cs @@ -0,0 +1,1051 @@ +// Sokoban map generator (mask-based, reverse search from solved state) +// - Uses small hand-made masks (Microban/Novoban 스타일) to shape the outer walls. +// - Places goals/boxes in solved state, then pulls boxes away from goals (reverse of push) to guarantee solvability. +// - Run: `dotnet run > output.json` (defaults use band config). Optional: `dotnet run [startId] [endId]`. +// Legend: '#' wall, '.' floor, '0' void, 'G' goal, '$' box, '@' player. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text.Json; + +internal static class Program +{ + private static readonly string LevelBalancePath = "levelbalance.json"; + private static readonly LevelBandConfig[] DefaultLevelBands = + { + new LevelBandConfig + { + StartId = 3, + EndId = 20, + BoxCountLow = 1, + BoxCountHigh = 2, + MinAllowedPushes = 4, + MinAllowedTurns = 2, + ShapeMasks = MaskLibrary.Microban.Take(8).ToList() + }, + new LevelBandConfig + { + StartId = 21, + EndId = 40, + BoxCountLow = 1, + BoxCountHigh = 2, + MinAllowedPushes = 7, + MinAllowedTurns = 3, + ShapeMasks = MaskLibrary.Microban.ToList() + }, + new LevelBandConfig + { + StartId = 41, + EndId = 60, + BoxCountLow = 2, + BoxCountHigh = 3, + MinAllowedPushes = 9, + MinAllowedTurns = 4, + ShapeMasks = MaskLibrary.Microban.ToList() + } + }; + + private static readonly GenerationTuning Tuning = new GenerationTuning + { + MaxAttemptsPerLevel = 2000, + MaxMillisecondsPerLevel = 40_000, + PushLimitPadding = 2, + PushLimitScale = 0.35, + DynamicGrowthWindow = 12, + DynamicPushIncrement = 2, + ReverseSearchMaxDepth = 120, + ReverseSearchBreadth = 800, + ApplyMaskTransforms = true, + MaskWallJitter = 2 + }; + + private const int DefaultSeed = 12345; + + public static void Main(string[] args) + { + var seed = DefaultSeed; + if (args.Length > 0 && int.TryParse(args[0], out var parsedSeed)) + { + seed = parsedSeed; + } + + var rng = new Random(seed); + var levelBands = LoadLevelBands(); + var generator = new LevelGenerator(rng, Tuning, levelBands); + + var startId = levelBands.Min(b => b.StartId); + var endId = levelBands.Max(b => b.EndId); + + if (args.Length >= 2 && int.TryParse(args[1], out var requestedStart)) + { + startId = requestedStart; + } + + if (args.Length >= 3 && int.TryParse(args[2], out var requestedEnd)) + { + endId = requestedEnd; + } + + if (startId > endId) + { + (startId, endId) = (endId, startId); + } + + var levels = generator.BuildRange(startId, endId); + var options = new JsonSerializerOptions + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + Console.WriteLine(JsonSerializer.Serialize(levels, options)); + } + + private static LevelBandConfig[] LoadLevelBands() + { + try + { + if (File.Exists(LevelBalancePath)) + { + var json = File.ReadAllText(LevelBalancePath); + var config = JsonSerializer.Deserialize(json); + if (config?.Bands != null && config.Bands.Count > 0) + { + return config.Bands.Select(ConvertBand).ToArray(); + } + } + } + catch (Exception ex) + { + Console.Error.WriteLine($"[warn] Failed to read {LevelBalancePath}: {ex.Message}. Falling back to default bands."); + } + + return DefaultLevelBands; + } + + private static LevelBandConfig ConvertBand(LevelBandJson input) + { + var masks = MaskLibrary.Microban; + if (input.MaskTake > 0) + { + masks = masks.Take(input.MaskTake).ToList(); + } + return new LevelBandConfig + { + StartId = input.StartId, + EndId = input.EndId, + BoxCountLow = input.BoxCountLow, + BoxCountHigh = input.BoxCountHigh, + MinAllowedPushes = input.MinAllowedPushes, + MinAllowedTurns = input.MinAllowedTurns, + MinAllowedBranching = input.MinAllowedBranching, + ShapeMasks = masks.ToList() + }; + } +} + +internal sealed class LevelGenerator +{ + private readonly Random _rng; + private readonly GenerationTuning _tuning; + private readonly IReadOnlyList _bands; + private readonly bool _trace; + private readonly HashSet _seenLayouts = new(); + + public LevelGenerator(Random rng, GenerationTuning tuning, IReadOnlyList bands) + { + _rng = rng; + _tuning = tuning; + _bands = bands; + _trace = Environment.GetEnvironmentVariable("NEKOBAN_DEBUG") == "1"; + } + + public List BuildRange(int startId, int endId) + { + foreach (var band in _bands) + { + if (band.ShapeMasksExpanded == null || band.ShapeMasksExpanded.Count == 0) + { + var baseMasks = band.ShapeMasks.Count > 0 ? band.ShapeMasks : MaskLibrary.Microban; + band.ShapeMasksExpanded = MaskLibrary.ExpandWithTransforms(baseMasks); + } + } + + var output = new List(); + for (var id = startId; id <= endId; id++) + { + var band = ResolveBand(id); + var level = BuildSingle(id, band); + output.Add(level); + } + + return output; + } + + private GeneratedLevel BuildSingle(int id, LevelBandConfig band) + { + var stopwatch = Stopwatch.StartNew(); + var attempts = 0; + var failReasons = _trace ? new Dictionary() : null; + + while (attempts < _tuning.MaxAttemptsPerLevel && + stopwatch.ElapsedMilliseconds < _tuning.MaxMillisecondsPerLevel) + { + attempts++; + + 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 canvas = LayoutFactory.FromMask(mask, _rng, _tuning); + + 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; + } + + 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 + }; + } + + if (_trace && failReasons != null) + { + Console.Error.WriteLine($"[trace] level {id} failed. Reasons:"); + foreach (var kv in failReasons.OrderByDescending(kv => kv.Value)) + { + Console.Error.WriteLine($" - {kv.Key}: {kv.Value}"); + } + } + + throw new InvalidOperationException($"레벨 {id} 생성 실패 (attempts {attempts})."); + } + + private LevelBandConfig ResolveBand(int id) + { + foreach (var band in _bands) + { + if (id >= band.StartId && id <= band.EndId) + { + return band; + } + } + + var last = _bands.Last(); + var delta = id - last.EndId; + var growthStep = Math.Max(1, delta / Math.Max(1, _tuning.DynamicGrowthWindow) + 1); + return new LevelBandConfig + { + StartId = id, + EndId = id, + BoxCountLow = last.BoxCountLow + growthStep / 2, + BoxCountHigh = last.BoxCountHigh + growthStep / 2, + MinAllowedPushes = last.MinAllowedPushes + growthStep * _tuning.DynamicPushIncrement, + ShapeMasks = last.ShapeMasks + }; + } + + private static void NoteFail(IDictionary? sink, string reason) + { + if (sink == null) return; + sink[reason] = sink.TryGetValue(reason, out var count) ? count + 1 : 1; + } +} + +internal sealed class GeneratedLevel +{ + public int Id { get; init; } + public List Grid { get; init; } = new(); + public int LowestPush { get; init; } + public int PushLimit { get; init; } +} + +internal sealed class LevelBandConfig +{ + public int StartId { get; init; } + public int EndId { get; init; } + public int BoxCountLow { get; init; } + public int BoxCountHigh { get; init; } + public int MinAllowedPushes { get; init; } + public int MinAllowedTurns { get; init; } = 0; + public int MinAllowedBranching { get; init; } = 0; + public List ShapeMasks { get; init; } = new(); + public List? ShapeMasksExpanded { get; set; } +} + +internal sealed class LevelBalanceFile +{ + public List Bands { get; set; } = new(); +} + +internal sealed class LevelBandJson +{ + public int StartId { get; set; } + public int EndId { get; set; } + public int BoxCountLow { get; set; } + public int BoxCountHigh { get; set; } + public int MinAllowedPushes { get; set; } + public int MinAllowedTurns { get; set; } + public int MinAllowedBranching { get; set; } + public int MaskTake { get; set; } = 0; +} + +internal sealed class GenerationTuning +{ + public int MaxAttemptsPerLevel { get; init; } + public int MaxMillisecondsPerLevel { get; init; } + public int PushLimitPadding { get; init; } + public double PushLimitScale { get; init; } + public int DynamicGrowthWindow { get; init; } + public int DynamicPushIncrement { get; init; } + public int ReverseSearchMaxDepth { get; init; } + public int ReverseSearchBreadth { get; init; } + public bool ApplyMaskTransforms { get; init; } + public int MaskWallJitter { get; init; } + public int PocketCarveMin { get; init; } = 0; + public int PocketCarveMax { get; init; } = 0; + public int PocketMaxRadius { get; init; } = 1; +} + +internal sealed class GridCanvas +{ + private readonly char[] _cells; + + public GridCanvas(int width, int height) + { + Width = width; + Height = height; + _cells = Enumerable.Repeat('0', width * height).ToArray(); + } + + public int Width { get; } + public int Height { get; } + + public void Fill(char c) => Array.Fill(_cells, c); + + public void Set(int x, int y, char c) + { + if (!InBounds(x, y)) return; + _cells[y * Width + x] = c; + } + + public void Set(int index, char c) + { + if (index < 0 || index >= _cells.Length) return; + _cells[index] = c; + } + + public char Get(int x, int y) => _cells[y * Width + x]; + public char Get(int index) => _cells[index]; + + public bool InBounds(int x, int y) => x >= 0 && y >= 0 && x < Width && y < Height; + + public void Overlay(IEnumerable indices, char c) + { + foreach (var idx in indices) + { + Set(idx, c); + } + } + + public void ClearDynamic() + { + for (var i = 0; i < _cells.Length; i++) + { + if (_cells[i] == '@' || _cells[i] == '$') + { + _cells[i] = '.'; + } + } + } + + public List ToLines() + { + var lines = new List(Height); + for (var y = 0; y < Height; y++) + { + lines.Add(new string(_cells, y * Width, Width)); + } + return lines; + } +} + +internal static class LayoutFactory +{ + public static GridCanvas FromMask(string[] mask, Random rng, GenerationTuning tuning) + { + var height = mask.Length; + var width = mask.Max(row => row.Length); + var canvas = new GridCanvas(width, height); + canvas.Fill('0'); + + for (var y = 0; y < height; y++) + { + var row = mask[y]; + for (var x = 0; x < row.Length; x++) + { + canvas.Set(x, y, row[x]); + } + } + + NormalizeOuterWalls(canvas); + + if (tuning.PocketCarveMax > 0) + { + AddPockets(canvas, rng, tuning); + NormalizeOuterWalls(canvas); + } + + return canvas; + } + + // Enforce: inside the bounding box of non-void tiles, edges are walls, interior voids become walls. + private static void NormalizeOuterWalls(GridCanvas canvas) + { + var w = canvas.Width; + var h = canvas.Height; + + var minX = w; + var minY = h; + var maxX = -1; + var maxY = -1; + + for (var y = 0; y < h; y++) + { + for (var x = 0; x < w; x++) + { + var c = canvas.Get(x, y); + if (c == '0') continue; + if (x < minX) minX = x; + if (y < minY) minY = y; + if (x > maxX) maxX = x; + if (y > maxY) maxY = y; + } + } + + if (maxX < minX || maxY < minY) + { + return; // nothing to normalize + } + + for (var y = minY; y <= maxY; y++) + { + for (var x = minX; x <= maxX; x++) + { + var isEdge = x == minX || x == maxX || y == minY || y == maxY; + if (isEdge) + { + canvas.Set(x, y, '#'); + } + else if (canvas.Get(x, y) == '0') + { + canvas.Set(x, y, '#'); + } + } + } + + // Outside bounding box remains void. + } + + public static bool NormalizeOuterWallsStrict(GridCanvas canvas, bool failOnTokens) + { + var w = canvas.Width; + var h = canvas.Height; + + var minX = w; + var minY = h; + var maxX = -1; + var maxY = -1; + + for (var y = 0; y < h; y++) + { + for (var x = 0; x < w; x++) + { + var c = canvas.Get(x, y); + if (c == '0') continue; + if (x < minX) minX = x; + if (y < minY) minY = y; + if (x > maxX) maxX = x; + if (y > maxY) maxY = y; + } + } + + if (maxX < minX || maxY < minY) + { + return false; + } + + for (var y = minY; y <= maxY; y++) + { + for (var x = minX; x <= maxX; x++) + { + var isEdge = x == minX || x == maxX || y == minY || y == maxY; + var c = canvas.Get(x, y); + if (isEdge) + { + if (failOnTokens && (c == 'G' || c == '$' || c == '@')) + { + return false; + } + canvas.Set(x, y, '#'); + } + else if (c == '0') + { + canvas.Set(x, y, '#'); + } + } + } + + return true; + } + + private static void AddPockets(GridCanvas canvas, Random rng, GenerationTuning tuning) + { + var pockets = rng.Next(tuning.PocketCarveMin, tuning.PocketCarveMax + 1); + for (var i = 0; i < pockets; i++) + { + var radius = rng.Next(1, tuning.PocketMaxRadius + 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++) + { + for (var xx = x - radius; xx <= x + radius; xx++) + { + if (!canvas.InBounds(xx, yy)) continue; + if (xx == 0 || yy == 0 || xx == canvas.Width - 1 || yy == canvas.Height - 1) continue; + var dx = xx - x; + var dy = yy - y; + if (dx * dx + dy * dy <= radius * radius) + { + if (canvas.Get(xx, yy) == '#') + { + canvas.Set(xx, yy, '.'); + } + } + } + } + } + } +} + +internal static class PiecePlacer +{ + public static bool TryPlace(GridCanvas canvas, Random rng, int boxCount, out HashSet goals, out int[] boxes, out int player) + { + goals = new HashSet(); + boxes = Array.Empty(); + player = -1; + + var candidates = Walkable(canvas).ToList(); + var minRequired = boxCount + 2; + if (candidates.Count < minRequired) + { + return false; + } + + Shuffle(candidates, rng); + + var softGoals = new List(); + foreach (var idx in candidates) + { + if (IsDeadCorner(canvas, idx)) + { + softGoals.Add(idx); + continue; + } + goals.Add(idx); + if (goals.Count == boxCount) break; + } + + if (goals.Count != boxCount) + { + foreach (var idx in softGoals) + { + goals.Add(idx); + if (goals.Count == boxCount) break; + } + } + + if (goals.Count != boxCount) return false; + + var goalSet = new HashSet(goals); + var nonGoals = candidates.Where(i => !goalSet.Contains(i)).ToList(); + Shuffle(nonGoals, rng); + + boxes = goals.ToArray(); + Array.Sort(boxes); + + if (nonGoals.Count == 0) return false; + + var reachable = FloodFrom(goalSet.First(), canvas, Array.Empty()); + if (goalSet.Any(g => !reachable.Contains(g))) return false; + + var playerOptions = nonGoals.Where(reachable.Contains).ToList(); + if (playerOptions.Count == 0) return false; + + player = playerOptions[rng.Next(playerOptions.Count)]; + return true; + } + + private static HashSet FloodFrom(int start, GridCanvas canvas, int[] boxes) + { + var visited = new HashSet(); + if (IsSolid(canvas.Get(start)) || Array.BinarySearch(boxes, start) >= 0) return visited; + + var queue = new Queue(); + visited.Add(start); + queue.Enqueue(start); + + while (queue.Count > 0) + { + var current = queue.Dequeue(); + foreach (var next in NeighborIndices(current, canvas.Width, canvas.Height)) + { + if (next < 0 || next >= canvas.Width * canvas.Height) continue; + if (visited.Contains(next)) continue; + + var tile = canvas.Get(next); + if (IsSolid(tile) || Array.BinarySearch(boxes, next) >= 0) continue; + + visited.Add(next); + queue.Enqueue(next); + } + } + + return visited; + } + + private static IEnumerable Walkable(GridCanvas canvas) + { + for (var i = 0; i < canvas.Width * canvas.Height; i++) + { + var tile = canvas.Get(i); + if (!IsSolid(tile)) + { + yield return i; + } + } + } + + private static IEnumerable NeighborIndices(int index, int width, int height) + { + var x = index % width; + var y = index / width; + + if (y > 0) yield return index - width; + if (y < height - 1) yield return index + width; + if (x > 0) yield return index - 1; + if (x < width - 1) yield return index + 1; + } + + private static bool IsDeadCorner(GridCanvas canvas, int idx) + { + var w = canvas.Width; + var h = canvas.Height; + var x = idx % w; + var y = idx / w; + + var solidNorth = y == 0 || IsSolid(canvas.Get(x, y - 1)); + var solidSouth = y == h - 1 || IsSolid(canvas.Get(x, y + 1)); + var solidWest = x == 0 || IsSolid(canvas.Get(x - 1, y)); + var solidEast = x == w - 1 || IsSolid(canvas.Get(x + 1, y)); + + var verticalBlocked = (solidNorth && solidSouth); + var horizontalBlocked = (solidWest && solidEast); + if (verticalBlocked || horizontalBlocked) return true; + + var cornerNW = solidNorth && solidWest; + var cornerNE = solidNorth && solidEast; + var cornerSW = solidSouth && solidWest; + var cornerSE = solidSouth && solidEast; + return cornerNW || cornerNE || cornerSW || cornerSE; + } + + private static bool IsSolid(char tile) => tile == '#' || tile == '0'; + + private static void Shuffle(IList list, Random rng) + { + for (var i = list.Count - 1; i > 0; i--) + { + var j = rng.Next(i + 1); + (list[i], list[j]) = (list[j], list[i]); + } + } +} + +internal sealed class Board +{ + private readonly bool[] _walls; + + private Board(int width, int height, bool[] walls) + { + Width = width; + Height = height; + _walls = walls; + } + + public int Width { get; } + public int Height { get; } + public int Size => Width * Height; + + public static Board FromCanvas(GridCanvas canvas) + { + var walls = new bool[canvas.Width * canvas.Height]; + for (var i = 0; i < walls.Length; i++) + { + var tile = canvas.Get(i); + walls[i] = tile == '#' || tile == '0'; + } + + return new Board(canvas.Width, canvas.Height, walls); + } + + public bool IsSolid(int index) => _walls[index]; +} + +internal sealed class ReverseSearch +{ + private readonly Board _board; + private readonly int _maxDepth; + private readonly int _breadth; + private readonly int[] _dirs; + + public ReverseSearch(Board board, int maxDepth, int breadth) + { + _board = board; + _maxDepth = maxDepth; + _breadth = breadth; + _dirs = new[] { -board.Width, board.Width, -1, 1 }; + } + + public ScrambledState? FindStartState(HashSet goals, int minPush, Random rng) + { + var boxes = goals.ToArray(); + Array.Sort(boxes); + + var playerStart = FirstFloorNotGoal(goals); + if (playerStart < 0) return null; + + var start = new SolverState(playerStart, boxes); + var queue = new Queue<(SolverState State, int Pushes)>(); + var visited = new HashSet(new StateComparer()) { start }; + queue.Enqueue((start, 0)); + + ScrambledState? best = null; + var bestPush = -1; + + while (queue.Count > 0 && visited.Count < _breadth) + { + var (state, pushes) = queue.Dequeue(); + if (pushes >= minPush && pushes > bestPush) + { + bestPush = pushes; + best = new ScrambledState(state.Player, state.Boxes); + } + + if (pushes >= _maxDepth) continue; + + foreach (var next in Expand(state)) + { + var nextPushes = pushes + (next.Item2 ? 1 : 0); + if (nextPushes > _maxDepth) continue; + + var nextState = next.Item1; + if (visited.Add(nextState)) + { + queue.Enqueue((nextState, nextPushes)); + } + } + } + + return best; + } + + private int FirstFloorNotGoal(HashSet goals) + { + for (var i = 0; i < _board.Size; i++) + { + if (_board.IsSolid(i)) continue; + if (goals.Contains(i)) continue; + return i; + } + return -1; + } + + // Returns (state, isPull) where isPull indicates a box move (counts as push) + private IEnumerable<(SolverState, bool)> Expand(SolverState state) + { + foreach (var dir in _dirs) + { + var step = state.Player + dir; + if (!IndexInBounds(step) || _board.IsSolid(step) || Array.BinarySearch(state.Boxes, step) >= 0) + { + // pull candidate? + var boxPos = state.Player + dir; + var behind = state.Player - dir; + if (!IndexInBounds(boxPos) || !IndexInBounds(behind)) continue; + var boxIndex = Array.BinarySearch(state.Boxes, boxPos); + if (boxIndex < 0) continue; + if (_board.IsSolid(behind) || Array.BinarySearch(state.Boxes, behind) >= 0) continue; + + var nextBoxes = state.Boxes.ToArray(); + nextBoxes[boxIndex] = state.Player; + Array.Sort(nextBoxes); + yield return (new SolverState(behind, nextBoxes), true); + continue; + } + + // player walk + yield return (new SolverState(step, state.Boxes), false); + } + } + + private bool IndexInBounds(int idx) => idx >= 0 && idx < _board.Size; +} + +internal sealed class SokobanSolver +{ + private readonly Board _board; + private readonly StateComparer _comparer = new(); + private readonly int[] _dirs; + + public SokobanSolver(Board board) + { + _board = board; + _dirs = new[] { -board.Width, board.Width, -1, 1 }; + } + + public SolveResult SolveDetailed(int player, int[] boxes, HashSet goals) + { + Array.Sort(boxes); + var start = new SolverState(player, boxes); + var visited = new HashSet(_comparer) { start }; + var parents = new Dictionary(_comparer); + var queue = new Queue(); + queue.Enqueue(start); + + while (queue.Count > 0) + { + var state = queue.Dequeue(); + if (IsSolved(state.Boxes, goals)) + { + return BuildResult(state, parents); + } + + foreach (var (next, dir) in Expand(state)) + { + if (visited.Add(next)) + { + parents[next] = (state, dir); + queue.Enqueue(next); + } + } + } + + return SolveResult.Fail; + } + + private IEnumerable<(SolverState State, int Dir)> Expand(SolverState state) + { + var reachable = ReachableWithoutPushing(state.Player, state.Boxes); + foreach (var pos in reachable) + { + foreach (var dir in _dirs) + { + var boxPos = pos + dir; + var landing = boxPos + dir; + if (!IndexInBounds(boxPos) || !IndexInBounds(landing)) continue; + + var boxIndex = Array.BinarySearch(state.Boxes, boxPos); + if (boxIndex < 0) continue; + if (_board.IsSolid(landing) || Array.BinarySearch(state.Boxes, landing) >= 0) continue; + + var nextBoxes = state.Boxes.ToArray(); + nextBoxes[boxIndex] = landing; + Array.Sort(nextBoxes); + yield return (new SolverState(boxPos, nextBoxes), dir); + } + } + } + + private SolveResult BuildResult(SolverState solved, Dictionary parents) + { + var pushes = 0; + var turns = 0; + var branching = 0; + + var path = new List<(SolverState State, int Dir)>(); + var current = solved; + while (parents.TryGetValue(current, out var entry)) + { + path.Add((current, entry.Dir)); + current = entry.Parent; + } + path.Reverse(); + + var prevDir = 0; + foreach (var (_, dir) in path) + { + pushes++; + if (prevDir != 0 && dir != prevDir) turns++; + prevDir = dir; + } + + // branching: count states along path that had more than one push option + current = solved; + while (parents.TryGetValue(current, out var entry2)) + { + var pushOptions = Expand(entry2.Parent).Count(); + if (pushOptions > 1) branching++; + current = entry2.Parent; + } + + return new SolveResult(pushes, turns, branching); + } + + private List ReachableWithoutPushing(int start, int[] boxes) + { + var visited = new bool[_board.Size]; + var queue = new Queue(); + var output = new List(); + + if (_board.IsSolid(start) || Array.BinarySearch(boxes, start) >= 0) return output; + + visited[start] = true; + queue.Enqueue(start); + output.Add(start); + + while (queue.Count > 0) + { + var current = queue.Dequeue(); + foreach (var dir in _dirs) + { + var next = current + dir; + if (!IndexInBounds(next)) continue; + if (visited[next]) continue; + if (_board.IsSolid(next) || Array.BinarySearch(boxes, next) >= 0) continue; + visited[next] = true; + queue.Enqueue(next); + output.Add(next); + } + } + + return output; + } + + private bool IsSolved(int[] boxes, HashSet goals) + { + foreach (var b in boxes) + { + if (!goals.Contains(b)) return false; + } + return true; + } + + private bool IndexInBounds(int idx) => idx >= 0 && idx < _board.Size; +} + +internal readonly record struct SolverState(int Player, int[] Boxes); + +internal readonly record struct SolveResult(int Pushes, int Turns, int Branching) +{ + public static SolveResult Fail => new(-1, -1, -1); + public bool IsFail => Pushes < 0; +} + +internal sealed class StateComparer : IEqualityComparer +{ + public bool Equals(SolverState x, SolverState y) + { + if (x.Player != y.Player) return false; + var a = x.Boxes; + var b = y.Boxes; + if (a.Length != b.Length) return false; + for (var i = 0; i < a.Length; i++) + { + if (a[i] != b[i]) return false; + } + return true; + } + + public int GetHashCode(SolverState obj) + { + var hash = obj.Player * 397 ^ obj.Boxes.Length; + unchecked + { + for (var i = 0; i < obj.Boxes.Length; i++) + { + hash = hash * 31 + obj.Boxes[i]; + } + } + return hash; + } +} + +internal sealed record ScrambledState(int Player, int[] Boxes); diff --git a/bin/Debug/net7.0/nekoban_map_gen b/bin/Debug/net7.0/nekoban_map_gen new file mode 100755 index 0000000..f9bdd66 Binary files /dev/null and b/bin/Debug/net7.0/nekoban_map_gen differ diff --git a/bin/Debug/net7.0/nekoban_map_gen.deps.json b/bin/Debug/net7.0/nekoban_map_gen.deps.json new file mode 100644 index 0000000..d5614b4 --- /dev/null +++ b/bin/Debug/net7.0/nekoban_map_gen.deps.json @@ -0,0 +1,23 @@ +{ + "runtimeTarget": { + "name": ".NETCoreApp,Version=v7.0", + "signature": "" + }, + "compilationOptions": {}, + "targets": { + ".NETCoreApp,Version=v7.0": { + "nekoban_map_gen/1.0.0": { + "runtime": { + "nekoban_map_gen.dll": {} + } + } + } + }, + "libraries": { + "nekoban_map_gen/1.0.0": { + "type": "project", + "serviceable": false, + "sha512": "" + } + } +} \ No newline at end of file diff --git a/bin/Debug/net7.0/nekoban_map_gen.dll b/bin/Debug/net7.0/nekoban_map_gen.dll new file mode 100644 index 0000000..5216af6 Binary files /dev/null and b/bin/Debug/net7.0/nekoban_map_gen.dll differ diff --git a/bin/Debug/net7.0/nekoban_map_gen.pdb b/bin/Debug/net7.0/nekoban_map_gen.pdb new file mode 100644 index 0000000..4aa5216 Binary files /dev/null and b/bin/Debug/net7.0/nekoban_map_gen.pdb differ diff --git a/bin/Debug/net7.0/nekoban_map_gen.runtimeconfig.json b/bin/Debug/net7.0/nekoban_map_gen.runtimeconfig.json new file mode 100644 index 0000000..184be8b --- /dev/null +++ b/bin/Debug/net7.0/nekoban_map_gen.runtimeconfig.json @@ -0,0 +1,9 @@ +{ + "runtimeOptions": { + "tfm": "net7.0", + "framework": { + "name": "Microsoft.NETCore.App", + "version": "7.0.0" + } + } +} \ No newline at end of file diff --git a/levelbalance.json b/levelbalance.json new file mode 100644 index 0000000..014a936 --- /dev/null +++ b/levelbalance.json @@ -0,0 +1,34 @@ +{ + "bands": [ + { + "startId": 3, + "endId": 20, + "boxCountLow": 1, + "boxCountHigh": 2, + "minAllowedPushes": 4, + "minAllowedTurns": 2, + "minAllowedBranching": 0, + "maskTake": 8 + }, + { + "startId": 21, + "endId": 40, + "boxCountLow": 1, + "boxCountHigh": 2, + "minAllowedPushes": 7, + "minAllowedTurns": 3, + "minAllowedBranching": 0, + "maskTake": 0 + }, + { + "startId": 41, + "endId": 60, + "boxCountLow": 2, + "boxCountHigh": 3, + "minAllowedPushes": 9, + "minAllowedTurns": 4, + "minAllowedBranching": 0, + "maskTake": 0 + } + ] +} diff --git a/mask_library.cs b/mask_library.cs new file mode 100644 index 0000000..26784be --- /dev/null +++ b/mask_library.cs @@ -0,0 +1,414 @@ +using System.Collections.Generic; +using System.Linq; + +internal static class MaskLibrary +{ + public static readonly List Microban = new() + { + new[]{ + "0000000", + "0####00", + "0#..#00", + "0#..###", + "0#....#", + "0######", + "0000000" + }, + new[]{ + "0000000", + "0#####0", + "0#...#0", + "0#.#.#0", + "0#...#0", + "0#####0", + "0000000" + }, + new[]{ + "0000000", + "0###000", + "0#.#000", + "0#.#000", + "0#..###", + "0#####0", + "0000000" + }, + new[]{ + "00000000", + "0######0", + "0#....#0", + "0###..#0", + "000#..#0", + "000####0", + "00000000" + }, + new[]{ + "000000000", + "00#####00", + "00#...#00", + "0##...##0", + "0#.....#0", + "0##...##0", + "00#...#00", + "00#####00", + "000000000" + }, + new[]{ + "00000000", + "0######0", + "0#....#0", + "0#.####0", + "0#....#0", + "0###..#0", + "000####0", + "00000000" + }, + new[]{ + "00000000", + "0####000", + "0#..###0", + "0#....#0", + "0###..#0", + "000####0", + "00000000" + }, + new[]{ + "0000000", + "0#####0", + "0#...#0", + "0#...#0", + "0#...#0", + "0###.#0", + "000###0", + "0000000" + }, + new[]{ + "0000000", + "0####00", + "0#..#00", + "0#..###", + "0#..#.#", + "0####.#", + "000000#" + }, + new[]{ + "00000000", + "0######0", + "0#....#0", + "0#.#..#0", + "0#.#.##0", + "0#...#00", + "0#####00", + "00000000" + }, + new[]{ + "0000000", + "0#####0", + "0#...#0", + "###.###", + "0#...#0", + "0#...#0", + "0#####0" + }, + new[]{ + "0000000000", + "00#######0", + "00#.....#0", + "###.###.#0", + "0#.....#0", + "0#######0", + "000000000" + }, + // 13: 얇은 리본형 + new[]{ + "000000000", + "00####000", + "00#..###0", + "0##..#.#0", + "0#....#00", + "0##..###0", + "00####000", + "000000000" + }, + // 14: 작은 도넛 + new[]{ + "0000000", + "0#####0", + "0#...#0", + "0#.#.#0", + "0#...#0", + "0#####0", + "0000000" + }, + // 15: ㄴ자 계단형 + new[]{ + "00000000", + "0####000", + "0#..###0", + "0#....#0", + "0###..#0", + "000#..#0", + "000####0", + "00000000" + }, + // 16: 짧은 S자 복도 + new[]{ + "00000000", + "0####000", + "0#..###0", + "0#..#.#0", + "0###..#0", + "000####0", + "00000000" + }, + // 17: 코너 방 + 넓은 홀 + new[]{ + "000000000", + "00#####00", + "00#...#00", + "0##.#.##0", + "0#.....#0", + "0#.#.###0", + "0#...#000", + "0#####000", + "000000000" + }, + // 18: 긴 복도 + 옆 포켓 + new[]{ + "0000000000", + "00#######0", + "00#.....#0", + "0##.###.#0", + "0#.....#00", + "0#.#.###00", + "0#.....#00", + "0#######00", + "0000000000" + }, + // 19: 두꺼운 U자 (안쪽 공간 넓음) + new[]{ + "000000000", + "0#######0", + "0#.....#0", + "0#.....#0", + "0#.....#0", + "0#..#..#0", + "0######0", + "000000000" + }, + // 20: 작은 십자 변형 (팔 길이 짧음) + new[]{ + "000000000", + "00###0000", + "00#.#0000", + "0###.###0", + "0#.....#0", + "0###.###0", + "000#.#000", + "000###000", + "000000000" + } + }; + + public static string[] PickRandom(Random rng, List masks) + { + if (masks.Count == 0) throw new System.InvalidOperationException("No masks provided."); + return masks[rng.Next(masks.Count)]; + } + + public static List ExpandWithTransforms(IEnumerable baseMasks, bool includeScaled = true) + { + var seen = new HashSet(); + var output = new List(); + foreach (var mask in baseMasks) + { + var seeds = includeScaled ? ScaleVariants(mask) : new List { mask }; + foreach (var seed in seeds) + { + foreach (var variant in Variants(seed)) + { + var key = CanonicalKey(variant); + if (seen.Add(key)) + { + output.Add(variant); + } + } + } + } + return output; + } + + public static string[] CreateVariant(string[] mask, Random rng, bool applyTransforms, int wallJitter) + { + var candidates = applyTransforms ? Variants(mask).ToList() : new List { mask }; + var picked = candidates[rng.Next(candidates.Count)]; + + if (wallJitter <= 0) + { + return picked; + } + + var jittered = ApplyWallJitter(picked, rng, wallJitter); + return jittered; + } + + private static IEnumerable Variants(string[] mask) + { + var current = mask; + for (var i = 0; i < 4; i++) + { + yield return current; + yield return FlipHorizontal(current); + current = Rotate90(current); + } + } + + private static string[] Rotate90(string[] mask) + { + var h = mask.Length; + var w = mask.Max(r => r.Length); + var arr = new char[h, w]; + for (var y = 0; y < h; y++) + { + var row = mask[y]; + for (var x = 0; x < w; x++) + { + arr[y, x] = x < row.Length ? row[x] : '0'; + } + } + + var rotated = new string[w]; + for (var y = 0; y < w; y++) + { + var chars = new char[h]; + for (var x = 0; x < h; x++) + { + chars[x] = arr[h - 1 - x, y]; + } + rotated[y] = new string(chars); + } + return rotated; + } + + private static string[] FlipHorizontal(string[] mask) + { + return mask.Select(row => new string(row.Reverse().ToArray())).ToArray(); + } + + private static string CanonicalKey(string[] mask) => string.Join("\n", mask); + + private static string[] ApplyWallJitter(string[] mask, Random rng, int maxJitter) + { + var h = mask.Length; + var w = mask.Max(r => r.Length); + var grid = mask.Select(line => line.PadRight(w, '0').ToCharArray()).ToArray(); + + int changes = rng.Next(1, maxJitter + 1); + var candidates = new List<(int x, int y)>(); + for (var y = 0; y < h; y++) + { + for (var x = 0; x < w; x++) + { + var c = grid[y][x]; + if (c == '0') continue; + // avoid outermost void border flipping into wall + if (y == 0 || x == 0 || y == h - 1 || x == w - 1) continue; + candidates.Add((x, y)); + } + } + + Shuffle(candidates, rng); + var applied = 0; + foreach (var (x, y) in candidates) + { + if (applied >= changes) break; + var c = grid[y][x]; + if (c == '#') grid[y][x] = '.'; + else if (c == '.') grid[y][x] = '#'; + else continue; + applied++; + } + + var result = new string[h]; + for (var y = 0; y < h; y++) + { + result[y] = new string(grid[y]); + } + return result; + } + + private static void Shuffle(IList list, Random rng) + { + for (var i = list.Count - 1; i > 0; i--) + { + var j = rng.Next(i + 1); + (list[i], list[j]) = (list[j], list[i]); + } + } + + private static List ScaleVariants(string[] mask) + { + var scaled = new List(); + 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); + return scaled; + } + + private static string[]? Pad(string[] mask, int delta) + { + if (delta == 0) return mask; + var h = mask.Length; + var w = mask.Max(r => r.Length); + if (delta < 0) + { + if (h + delta * 2 < 3 || w + delta * 2 < 3) return null; + var newH = h + delta * 2; + var newW = w + delta * 2; + var output = new string[newH]; + for (var y = 0; y < newH; y++) + { + var srcY = y - delta; + if (srcY < 0 || srcY >= h) + { + output[y] = new string('0', newW); + continue; + } + var row = mask[srcY]; + var src = row.Skip(delta).Take(newW).ToArray(); + if (src.Length < newW) + { + output[y] = new string(src).PadRight(newW, '0'); + } + else + { + output[y] = new string(src); + } + } + return output; + } + else + { + var newH = h + delta * 2; + var newW = w + delta * 2; + var output = new string[newH]; + for (var y = 0; y < newH; y++) + { + if (y < delta || y >= h + delta) + { + output[y] = new string('0', newW); + continue; + } + var row = mask[y - delta]; + var paddedRow = new string('0', delta) + row.PadRight(w, '0') + new string('0', delta); + if (paddedRow.Length < newW) + { + paddedRow = paddedRow.PadRight(newW, '0'); + } + output[y] = paddedRow; + } + return output; + } + } +} diff --git a/nekoban_map_gen.csproj b/nekoban_map_gen.csproj new file mode 100644 index 0000000..d439800 --- /dev/null +++ b/nekoban_map_gen.csproj @@ -0,0 +1,10 @@ + + + + Exe + net7.0 + enable + enable + + + diff --git a/nekoban_map_gen.sln b/nekoban_map_gen.sln new file mode 100644 index 0000000..576e5ad --- /dev/null +++ b/nekoban_map_gen.sln @@ -0,0 +1,24 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.2.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "nekoban_map_gen", "nekoban_map_gen.csproj", "{736CD653-478C-D946-DC2C-7AC55BE19A51}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {736CD653-478C-D946-DC2C-7AC55BE19A51}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {736CD653-478C-D946-DC2C-7AC55BE19A51}.Debug|Any CPU.Build.0 = Debug|Any CPU + {736CD653-478C-D946-DC2C-7AC55BE19A51}.Release|Any CPU.ActiveCfg = Release|Any CPU + {736CD653-478C-D946-DC2C-7AC55BE19A51}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {1EE2C320-7A55-4F38-BAF1-71E807C9EAC5} + EndGlobalSection +EndGlobal diff --git a/obj/Debug/net7.0/.NETCoreApp,Version=v7.0.AssemblyAttributes.cs b/obj/Debug/net7.0/.NETCoreApp,Version=v7.0.AssemblyAttributes.cs new file mode 100644 index 0000000..d69481d --- /dev/null +++ b/obj/Debug/net7.0/.NETCoreApp,Version=v7.0.AssemblyAttributes.cs @@ -0,0 +1,4 @@ +// +using System; +using System.Reflection; +[assembly: global::System.Runtime.Versioning.TargetFrameworkAttribute(".NETCoreApp,Version=v7.0", FrameworkDisplayName = ".NET 7.0")] diff --git a/obj/Debug/net7.0/apphost b/obj/Debug/net7.0/apphost new file mode 100755 index 0000000..f9bdd66 Binary files /dev/null and b/obj/Debug/net7.0/apphost differ diff --git a/obj/Debug/net7.0/nekoban_map_gen.AssemblyInfo.cs b/obj/Debug/net7.0/nekoban_map_gen.AssemblyInfo.cs new file mode 100644 index 0000000..01c8d1e --- /dev/null +++ b/obj/Debug/net7.0/nekoban_map_gen.AssemblyInfo.cs @@ -0,0 +1,22 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +using System; +using System.Reflection; + +[assembly: System.Reflection.AssemblyCompanyAttribute("nekoban_map_gen")] +[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")] +[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")] +[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0")] +[assembly: System.Reflection.AssemblyProductAttribute("nekoban_map_gen")] +[assembly: System.Reflection.AssemblyTitleAttribute("nekoban_map_gen")] +[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")] + +// MSBuild WriteCodeFragment 클래스에서 생성되었습니다. + diff --git a/obj/Debug/net7.0/nekoban_map_gen.AssemblyInfoInputs.cache b/obj/Debug/net7.0/nekoban_map_gen.AssemblyInfoInputs.cache new file mode 100644 index 0000000..9ac2862 --- /dev/null +++ b/obj/Debug/net7.0/nekoban_map_gen.AssemblyInfoInputs.cache @@ -0,0 +1 @@ +976eaa04cd48be9fbd61396f78b359e9670a79aa diff --git a/obj/Debug/net7.0/nekoban_map_gen.GeneratedMSBuildEditorConfig.editorconfig b/obj/Debug/net7.0/nekoban_map_gen.GeneratedMSBuildEditorConfig.editorconfig new file mode 100644 index 0000000..d7dbdf9 --- /dev/null +++ b/obj/Debug/net7.0/nekoban_map_gen.GeneratedMSBuildEditorConfig.editorconfig @@ -0,0 +1,11 @@ +is_global = true +build_property.TargetFramework = net7.0 +build_property.TargetPlatformMinVersion = +build_property.UsingMicrosoftNETSdkWeb = +build_property.ProjectTypeGuids = +build_property.InvariantGlobalization = +build_property.PlatformNeutralAssembly = +build_property.EnforceExtendedAnalyzerRules = +build_property._SupportedPlatformList = Linux,macOS,Windows +build_property.RootNamespace = nekoban_map_gen +build_property.ProjectDir = /Users/maximilian.j.sul/Documents/Unity/nekoban_map_gen/ diff --git a/obj/Debug/net7.0/nekoban_map_gen.GlobalUsings.g.cs b/obj/Debug/net7.0/nekoban_map_gen.GlobalUsings.g.cs new file mode 100644 index 0000000..8578f3d --- /dev/null +++ b/obj/Debug/net7.0/nekoban_map_gen.GlobalUsings.g.cs @@ -0,0 +1,8 @@ +// +global using global::System; +global using global::System.Collections.Generic; +global using global::System.IO; +global using global::System.Linq; +global using global::System.Net.Http; +global using global::System.Threading; +global using global::System.Threading.Tasks; diff --git a/obj/Debug/net7.0/nekoban_map_gen.assets.cache b/obj/Debug/net7.0/nekoban_map_gen.assets.cache new file mode 100644 index 0000000..2540846 Binary files /dev/null and b/obj/Debug/net7.0/nekoban_map_gen.assets.cache differ diff --git a/obj/Debug/net7.0/nekoban_map_gen.csproj.AssemblyReference.cache b/obj/Debug/net7.0/nekoban_map_gen.csproj.AssemblyReference.cache new file mode 100644 index 0000000..2af78ef Binary files /dev/null and b/obj/Debug/net7.0/nekoban_map_gen.csproj.AssemblyReference.cache differ diff --git a/obj/Debug/net7.0/nekoban_map_gen.csproj.CoreCompileInputs.cache b/obj/Debug/net7.0/nekoban_map_gen.csproj.CoreCompileInputs.cache new file mode 100644 index 0000000..99f441e --- /dev/null +++ b/obj/Debug/net7.0/nekoban_map_gen.csproj.CoreCompileInputs.cache @@ -0,0 +1 @@ +7886d3225e6b0aa6234a1c3da555a0a6b7dccd0b diff --git a/obj/Debug/net7.0/nekoban_map_gen.csproj.FileListAbsolute.txt b/obj/Debug/net7.0/nekoban_map_gen.csproj.FileListAbsolute.txt new file mode 100644 index 0000000..9ea25e7 --- /dev/null +++ b/obj/Debug/net7.0/nekoban_map_gen.csproj.FileListAbsolute.txt @@ -0,0 +1,15 @@ +/Users/maximilian.j.sul/Documents/Unity/nekoban_map_gen/obj/Debug/net7.0/nekoban_map_gen.csproj.AssemblyReference.cache +/Users/maximilian.j.sul/Documents/Unity/nekoban_map_gen/obj/Debug/net7.0/nekoban_map_gen.GeneratedMSBuildEditorConfig.editorconfig +/Users/maximilian.j.sul/Documents/Unity/nekoban_map_gen/obj/Debug/net7.0/nekoban_map_gen.AssemblyInfoInputs.cache +/Users/maximilian.j.sul/Documents/Unity/nekoban_map_gen/obj/Debug/net7.0/nekoban_map_gen.AssemblyInfo.cs +/Users/maximilian.j.sul/Documents/Unity/nekoban_map_gen/obj/Debug/net7.0/nekoban_map_gen.csproj.CoreCompileInputs.cache +/Users/maximilian.j.sul/Documents/Unity/nekoban_map_gen/bin/Debug/net7.0/nekoban_map_gen +/Users/maximilian.j.sul/Documents/Unity/nekoban_map_gen/bin/Debug/net7.0/nekoban_map_gen.deps.json +/Users/maximilian.j.sul/Documents/Unity/nekoban_map_gen/bin/Debug/net7.0/nekoban_map_gen.runtimeconfig.json +/Users/maximilian.j.sul/Documents/Unity/nekoban_map_gen/bin/Debug/net7.0/nekoban_map_gen.dll +/Users/maximilian.j.sul/Documents/Unity/nekoban_map_gen/bin/Debug/net7.0/nekoban_map_gen.pdb +/Users/maximilian.j.sul/Documents/Unity/nekoban_map_gen/obj/Debug/net7.0/nekoban_map_gen.dll +/Users/maximilian.j.sul/Documents/Unity/nekoban_map_gen/obj/Debug/net7.0/refint/nekoban_map_gen.dll +/Users/maximilian.j.sul/Documents/Unity/nekoban_map_gen/obj/Debug/net7.0/nekoban_map_gen.pdb +/Users/maximilian.j.sul/Documents/Unity/nekoban_map_gen/obj/Debug/net7.0/nekoban_map_gen.genruntimeconfig.cache +/Users/maximilian.j.sul/Documents/Unity/nekoban_map_gen/obj/Debug/net7.0/ref/nekoban_map_gen.dll diff --git a/obj/Debug/net7.0/nekoban_map_gen.dll b/obj/Debug/net7.0/nekoban_map_gen.dll new file mode 100644 index 0000000..5216af6 Binary files /dev/null and b/obj/Debug/net7.0/nekoban_map_gen.dll differ diff --git a/obj/Debug/net7.0/nekoban_map_gen.genruntimeconfig.cache b/obj/Debug/net7.0/nekoban_map_gen.genruntimeconfig.cache new file mode 100644 index 0000000..4663319 --- /dev/null +++ b/obj/Debug/net7.0/nekoban_map_gen.genruntimeconfig.cache @@ -0,0 +1 @@ +920ad02553a0ee38d33f26c12d7e932279eb127d diff --git a/obj/Debug/net7.0/nekoban_map_gen.pdb b/obj/Debug/net7.0/nekoban_map_gen.pdb new file mode 100644 index 0000000..4aa5216 Binary files /dev/null and b/obj/Debug/net7.0/nekoban_map_gen.pdb differ diff --git a/obj/Debug/net7.0/ref/nekoban_map_gen.dll b/obj/Debug/net7.0/ref/nekoban_map_gen.dll new file mode 100644 index 0000000..7b73ffc Binary files /dev/null and b/obj/Debug/net7.0/ref/nekoban_map_gen.dll differ diff --git a/obj/Debug/net7.0/refint/nekoban_map_gen.dll b/obj/Debug/net7.0/refint/nekoban_map_gen.dll new file mode 100644 index 0000000..7b73ffc Binary files /dev/null and b/obj/Debug/net7.0/refint/nekoban_map_gen.dll differ diff --git a/obj/nekoban_map_gen.csproj.nuget.dgspec.json b/obj/nekoban_map_gen.csproj.nuget.dgspec.json new file mode 100644 index 0000000..2bd9a8e --- /dev/null +++ b/obj/nekoban_map_gen.csproj.nuget.dgspec.json @@ -0,0 +1,61 @@ +{ + "format": 1, + "restore": { + "/Users/maximilian.j.sul/Documents/Unity/nekoban_map_gen/nekoban_map_gen.csproj": {} + }, + "projects": { + "/Users/maximilian.j.sul/Documents/Unity/nekoban_map_gen/nekoban_map_gen.csproj": { + "version": "1.0.0", + "restore": { + "projectUniqueName": "/Users/maximilian.j.sul/Documents/Unity/nekoban_map_gen/nekoban_map_gen.csproj", + "projectName": "nekoban_map_gen", + "projectPath": "/Users/maximilian.j.sul/Documents/Unity/nekoban_map_gen/nekoban_map_gen.csproj", + "packagesPath": "/Users/maximilian.j.sul/.nuget/packages/", + "outputPath": "/Users/maximilian.j.sul/Documents/Unity/nekoban_map_gen/obj/", + "projectStyle": "PackageReference", + "configFilePaths": [ + "/Users/maximilian.j.sul/.nuget/NuGet/NuGet.Config" + ], + "originalTargetFrameworks": [ + "net7.0" + ], + "sources": { + "https://api.nuget.org/v3/index.json": {} + }, + "frameworks": { + "net7.0": { + "targetAlias": "net7.0", + "projectReferences": {} + } + }, + "warningProperties": { + "warnAsError": [ + "NU1605" + ] + } + }, + "frameworks": { + "net7.0": { + "targetAlias": "net7.0", + "imports": [ + "net461", + "net462", + "net47", + "net471", + "net472", + "net48", + "net481" + ], + "assetTargetFallback": true, + "warn": true, + "frameworkReferences": { + "Microsoft.NETCore.App": { + "privateAssets": "all" + } + }, + "runtimeIdentifierGraphPath": "/usr/local/share/dotnet/sdk/7.0.315/RuntimeIdentifierGraph.json" + } + } + } + } +} \ No newline at end of file diff --git a/obj/nekoban_map_gen.csproj.nuget.g.props b/obj/nekoban_map_gen.csproj.nuget.g.props new file mode 100644 index 0000000..f34edbd --- /dev/null +++ b/obj/nekoban_map_gen.csproj.nuget.g.props @@ -0,0 +1,15 @@ + + + + True + NuGet + $(MSBuildThisFileDirectory)project.assets.json + /Users/maximilian.j.sul/.nuget/packages/ + /Users/maximilian.j.sul/.nuget/packages/ + PackageReference + 6.6.2 + + + + + \ No newline at end of file diff --git a/obj/nekoban_map_gen.csproj.nuget.g.targets b/obj/nekoban_map_gen.csproj.nuget.g.targets new file mode 100644 index 0000000..3dc06ef --- /dev/null +++ b/obj/nekoban_map_gen.csproj.nuget.g.targets @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/obj/project.assets.json b/obj/project.assets.json new file mode 100644 index 0000000..48e6a9f --- /dev/null +++ b/obj/project.assets.json @@ -0,0 +1,66 @@ +{ + "version": 3, + "targets": { + "net7.0": {} + }, + "libraries": {}, + "projectFileDependencyGroups": { + "net7.0": [] + }, + "packageFolders": { + "/Users/maximilian.j.sul/.nuget/packages/": {} + }, + "project": { + "version": "1.0.0", + "restore": { + "projectUniqueName": "/Users/maximilian.j.sul/Documents/Unity/nekoban_map_gen/nekoban_map_gen.csproj", + "projectName": "nekoban_map_gen", + "projectPath": "/Users/maximilian.j.sul/Documents/Unity/nekoban_map_gen/nekoban_map_gen.csproj", + "packagesPath": "/Users/maximilian.j.sul/.nuget/packages/", + "outputPath": "/Users/maximilian.j.sul/Documents/Unity/nekoban_map_gen/obj/", + "projectStyle": "PackageReference", + "configFilePaths": [ + "/Users/maximilian.j.sul/.nuget/NuGet/NuGet.Config" + ], + "originalTargetFrameworks": [ + "net7.0" + ], + "sources": { + "https://api.nuget.org/v3/index.json": {} + }, + "frameworks": { + "net7.0": { + "targetAlias": "net7.0", + "projectReferences": {} + } + }, + "warningProperties": { + "warnAsError": [ + "NU1605" + ] + } + }, + "frameworks": { + "net7.0": { + "targetAlias": "net7.0", + "imports": [ + "net461", + "net462", + "net47", + "net471", + "net472", + "net48", + "net481" + ], + "assetTargetFallback": true, + "warn": true, + "frameworkReferences": { + "Microsoft.NETCore.App": { + "privateAssets": "all" + } + }, + "runtimeIdentifierGraphPath": "/usr/local/share/dotnet/sdk/7.0.315/RuntimeIdentifierGraph.json" + } + } + } +} \ No newline at end of file diff --git a/obj/project.nuget.cache b/obj/project.nuget.cache new file mode 100644 index 0000000..7fbec45 --- /dev/null +++ b/obj/project.nuget.cache @@ -0,0 +1,8 @@ +{ + "version": 2, + "dgSpecHash": "RYMvVqk+r7bCo4lc466IMiUBQNJtUPiAIecPRFNwdnBkz4B3dMDFM04zCDCH3kDAJ+g6+bpsAooJZxgB7X61oA==", + "success": true, + "projectFilePath": "/Users/maximilian.j.sul/Documents/Unity/nekoban_map_gen/nekoban_map_gen.csproj", + "expectedPackageFiles": [], + "logs": [] +} \ No newline at end of file diff --git a/stage.json b/stage.json new file mode 100644 index 0000000..a7b5881 --- /dev/null +++ b/stage.json @@ -0,0 +1,277 @@ +[ + { + "id": 3, + "grid": [ + "00000000000", + "00000000000", + "00#######00", + "00##..###00", + "00#...$@#00", + "00#.....#00", + "00#...G.#00", + "00###.###00", + "00#######00", + "00000000000", + "00000000000" + ], + "lowestPush": 4, + "pushLimit": 6 + }, + { + "id": 4, + "grid": [ + "######0", + "#...##0", + "#...##0", + "#G$..#0", + "#.G$.#0", + "#.#@##0", + "######0" + ], + "lowestPush": 4, + "pushLimit": 6 + }, + { + "id": 5, + "grid": [ + "000000000", + "0#######0", + "0##...##0", + "0#@$..##0", + "0#.G...#0", + "0##$..##0", + "0##.G.##0", + "0#######0", + "000000000" + ], + "lowestPush": 5, + "pushLimit": 7 + }, + { + "id": 6, + "grid": [ + "######0", + "#...##0", + "#$.G##0", + "#G.$@#0", + "#..###0", + "#...##0", + "######0" + ], + "lowestPush": 6, + "pushLimit": 9 + }, + { + "id": 7, + "grid": [ + "000000000", + "0#######0", + "0###..##0", + "0#@$...#0", + "0#G....#0", + "0#.....#0", + "0###.###0", + "0#######0", + "000000000" + ], + "lowestPush": 4, + "pushLimit": 6 + }, + { + "id": 8, + "grid": [ + "######0", + "#...##0", + "#..$@#0", + "#.G..#0", + "#..$##0", + "#....#0", + "######0" + ], + "lowestPush": 4, + "pushLimit": 6 + }, + { + "id": 9, + "grid": [ + "######0", + "#..@##0", + "#.#$##0", + "#....#0", + "#....#0", + "#.G.##0", + "######0" + ], + "lowestPush": 4, + "pushLimit": 6 + }, + { + "id": 10, + "grid": [ + "#######", + "#@$GG.#", + "#.$...#", + "#.....#", + "#######", + "#######", + "0000000" + ], + "lowestPush": 4, + "pushLimit": 6 + }, + { + "id": 11, + "grid": [ + "######0", + "#..@##0", + "##$$##0", + "#.G..#0", + "#G...#0", + "#...##0", + "######0" + ], + "lowestPush": 5, + "pushLimit": 7 + }, + { + "id": 12, + "grid": [ + "0000000", + "0#####0", + "0#...#0", + "0#.$.#0", + "0#G.$#0", + "0#.#@#0", + "0#####0", + "0000000" + ], + "lowestPush": 4, + "pushLimit": 6 + }, + { + "id": 13, + "grid": [ + "######0", + "#...##0", + "#..###0", + "#..$@#0", + "#$.G##0", + "#...##0", + "######0" + ], + "lowestPush": 7, + "pushLimit": 10 + }, + { + "id": 14, + "grid": [ + "0######", + "0##...#", + "0##.$@#", + "0#.G..#", + "0##G$##", + "0##...#", + "0######" + ], + "lowestPush": 5, + "pushLimit": 7 + }, + { + "id": 15, + "grid": [ + "00000000000", + "00000000000", + "00#######00", + "00##...##00", + "00##.$.##00", + "00#@$.G.#00", + "00##..G##00", + "00##...##00", + "00#######00", + "00000000000", + "00000000000" + ], + "lowestPush": 5, + "pushLimit": 7 + }, + { + "id": 16, + "grid": [ + "0######", + "0##.G.#", + "0##$.G#", + "0#....#", + "0#@$.##", + "0##...#", + "0######" + ], + "lowestPush": 8, + "pushLimit": 11 + }, + { + "id": 17, + "grid": [ + "0######", + "0##...#", + "0#...##", + "0#....#", + "0##$.G#", + "0##@..#", + "0######" + ], + "lowestPush": 4, + "pushLimit": 6 + }, + { + "id": 18, + "grid": [ + "00000000000", + "00000000000", + "00#######00", + "00##...##00", + "00##G.$##00", + "00#.G...#00", + "00#...$.#00", + "00##..@##00", + "00#######00", + "00000000000", + "00000000000" + ], + "lowestPush": 7, + "pushLimit": 10 + }, + { + "id": 19, + "grid": [ + "000000000", + "0#######0", + "0##...##0", + "0##G..##0", + "0#.G.$@#0", + "0##.$.##0", + "0##...##0", + "0#######0", + "000000000" + ], + "lowestPush": 5, + "pushLimit": 7 + }, + { + "id": 20, + "grid": [ + "00000000000", + "00000000000", + "00#######00", + "00###@###00", + "00#..$..#00", + "00#...$.#00", + "00#.G.G.#00", + "00###.###00", + "00#######00", + "00000000000", + "00000000000" + ], + "lowestPush": 4, + "pushLimit": 6 + } +]