From 42f04139a2dfb12ebd1fa918e7411afefabf29f0 Mon Sep 17 00:00:00 2001 From: beezm Date: Wed, 5 Aug 2026 21:31:04 -0400 Subject: [PATCH 1/3] refactor: extract FindPath seam (no behavior change) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every road path search — highway, branch, county — now goes through one function instead of three direct GetPointPath calls. The function body is exactly the call it replaced, so behaviour is unchanged. This exists so the next commit's pathfinding change can be judged on its own: if the generated map changes after this point, it was the heuristic, not a refactor slip. Routing is untouched: which towns connect, loop order, Prim's daisy-chain, tier assignment and the abandon protocol all deal only in town positions and never touch the grid. Co-Authored-By: Claude Opus 5 (1M context) --- Tools/Scripts/MapGenerator.cs | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/Tools/Scripts/MapGenerator.cs b/Tools/Scripts/MapGenerator.cs index 9cd2f17..adc69a5 100644 --- a/Tools/Scripts/MapGenerator.cs +++ b/Tools/Scripts/MapGenerator.cs @@ -653,8 +653,8 @@ public partial class MapGenerator : TextureRect Vector2 startPos = highwayNodes[i].Position; Vector2 endPos = highwayNodes[(i + 1) % highwayNodes.Count].Position; - Vector2[] rawPath = astar.GetPointPath( - new Vector2I((int)startPos.X, (int)startPos.Y), + Vector2[] rawPath = FindPath(astar, + new Vector2I((int)startPos.X, (int)startPos.Y), new Vector2I((int)endPos.X, (int)endPos.Y) ); @@ -688,7 +688,7 @@ public partial class MapGenerator : TextureRect } } - Vector2[] bossPath = astar.GetPointPath( + Vector2[] bossPath = FindPath(astar, new Vector2I((int)snowBoss.Position.X, (int)snowBoss.Position.Y), bestPixel ); @@ -744,8 +744,8 @@ public partial class MapGenerator : TextureRect else { // Attempt the connection. (If it freezes here, we know exactly which town caused it!) - countyPath = astar.GetPointPath( - new Vector2I((int)bestUnconnected.Position.X, (int)bestUnconnected.Position.Y), + countyPath = FindPath(astar, + new Vector2I((int)bestUnconnected.Position.X, (int)bestUnconnected.Position.Y), new Vector2I((int)bestConnected.Position.X, (int)bestConnected.Position.Y) ); } @@ -769,6 +769,19 @@ public partial class MapGenerator : TextureRect GD.Print("[A*] Logistics Network Complete!"); } + /// + /// The one place a road path is actually searched for. + /// + /// Every road — highway, branch, county — comes through here, so this is the single + /// seam where pathfinding can be changed without touching any routing decision + /// (which towns connect, in what order, at which tier). Those all live above and + /// only ever deal in town positions. + /// + private Vector2[] FindPath(AStarGrid2D astar, Vector2I from, Vector2I to) + { + return astar.GetPointPath(from, to); + } + private float GetSeaLevel(float t) => Mathf.Lerp(0.26f, 0.15f, Mathf.Clamp(t, 0f, 1f)); private Vector2I FindClosestPixel(Vector2 pos, HashSet set) { From 1f7613439acec06aa26f15c80640f7780777343b Mon Sep 17 00:00:00 2001 From: beezm Date: Wed, 5 Aug 2026 21:32:51 -0400 Subject: [PATCH 2/3] perf: weight-aware A* heuristic + tamed road repulsion (route-preserving speed fix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A* was searching almost the whole island for every road. Godot's stock estimate-to-goal is plain straight-line distance, which assumes every step costs 1 — but a step through mountains costs up to 401 and a step near an existing road cost 10,001. With the estimate that far below reality, A* cannot rule anything out, so it degenerates toward Dijkstra. Two changes, both aimed at that: 1. RoadPathGrid overrides the estimate to scale it by HEURISTIC_WEIGHT (3.0), i.e. weighted A*. Paths may be up to 3x costlier than the theoretical best in exchange for exploring far less. This does NOT push roads over mountains: a ridge costs hundreds of times more than going around, which a 3x bias nowhere near pays for. 2. ROAD_REPULSION_PENALTY replaces the hardcoded +10000 with 250. The old value made ground near a road effectively infinite, and it compounded — each road drawn made the next search slower. 250 still strongly discourages roads from running alongside each other. Terrain weights are untouched: the mountain curve (1 + elevation^3 * 400), beach and wasteland costs are exactly as before, so mountains remain expensive and roads still avoid them. Routing, tiers, smoothing and grid resolution are also unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- Tools/Scripts/MapGenerator.cs | 62 +++++++++++++++++++++++++++++++++-- 1 file changed, 59 insertions(+), 3 deletions(-) diff --git a/Tools/Scripts/MapGenerator.cs b/Tools/Scripts/MapGenerator.cs index adc69a5..54c3c73 100644 --- a/Tools/Scripts/MapGenerator.cs +++ b/Tools/Scripts/MapGenerator.cs @@ -625,7 +625,9 @@ public partial class MapGenerator : TextureRect GD.Print($"[A*] Starting Logistics Pathfinding for {MapSize}x{MapSize} world..."); _highwayPaths.Clear(); _branchPaths.Clear(); _ruggedPaths.Clear(); _trailPaths.Clear(); - AStarGrid2D astar = new AStarGrid2D(); + // RoadPathGrid is a stock AStarGrid2D with a scaled-up distance estimate, so the + // search stops flood-filling the island for every road. See the class for why. + AStarGrid2D astar = new RoadPathGrid(); astar.Region = new Rect2I(0, 0, MapSize, MapSize); astar.CellSize = new Vector2I(1, 1); astar.DiagonalMode = AStarGrid2D.DiagonalModeEnum.OnlyIfNoObstacles; @@ -836,6 +838,23 @@ public partial class MapGenerator : TextureRect } } + /// + /// How much extra a step costs near an already-drawn road. This is what stops new + /// roads from lying on top of old ones — it is SEPARATION, not terrain avoidance, + /// so it is unrelated to the mountain/beach/wasteland weights. + /// + /// It used to be 10,000, which was far past "discourage" — a normal step costs 1, + /// so it made the ground near a road effectively infinite. That wrecked the search: + /// every later road had to weigh routes costing tens of thousands, which is exactly + /// the situation A* handles by giving up on being clever and searching everything. + /// Worse, it compounded — each road drawn made the next one slower. + /// + /// 250 still means "strongly prefer not to run alongside an existing road" (250x a + /// normal step) without dwarfing the search. Raise it if parallel roads crowd + /// together; lower it if roads take silly detours to avoid each other. + /// + private const float ROAD_REPULSION_PENALTY = 250f; + private void PenalizePath(AStarGrid2D astar, Vector2[] path, int radius) { int step = Mathf.Max(1, radius / 2); @@ -847,8 +866,7 @@ public partial class MapGenerator : TextureRect int ny = Mathf.Clamp((int)p.Y + dy, 0, MapSize - 1); var cell = new Vector2I(nx, ny); if (!astar.IsPointSolid(cell)) { - // The true Iron Curtain - astar.SetPointWeightScale(cell, astar.GetPointWeightScale(cell) + 10000f); + astar.SetPointWeightScale(cell, astar.GetPointWeightScale(cell) + ROAD_REPULSION_PENALTY); } } } @@ -950,6 +968,44 @@ public partial class MapGenerator : TextureRect } // Place this at the very bottom of the file! +/// +/// The road pathfinder's grid, with one change from stock: a scaled-up estimate. +/// +/// WHY THIS EXISTS. A* explores outward from the start until the cheapest route it +/// has found is provably the best. It decides how far to keep looking by comparing +/// what a route has cost so far against an ESTIMATE of what remains. Godot's stock +/// estimate is plain straight-line distance — it assumes every step costs 1. +/// +/// Our steps don't cost 1. A step through mountains costs up to 401, and a step near +/// an existing road used to cost 10,001. So the estimate was wildly optimistic about +/// the remaining journey, and A* responded the only way it can: by refusing to rule +/// anything out and searching almost the whole island for every single road. +/// +/// Multiplying the estimate makes the search commit to a direction sooner. It's a +/// well-known trade ("weighted A*"): paths are allowed to be up to HEURISTIC_WEIGHT +/// times more expensive than the theoretical best, in exchange for exploring far less. +/// We want believable roads, not provably-optimal ones, so that's a good trade. +/// +/// It does NOT make roads climb mountains. Crossing a ridge costs hundreds of times +/// more than walking around it — a 3x bias nowhere near pays for that. +/// +public partial class RoadPathGrid : AStarGrid2D +{ + /// + /// How strongly the search commits toward the goal. + /// 1.0 = stock behaviour: guaranteed-shortest, explores enormously. + /// 3.0 = default: still routes sensibly around terrain, explores far less. + /// higher = faster still, but paths get progressively less considered. + /// Raise it if generation is still slow; lower it if roads start looking careless. + /// + public const float HEURISTIC_WEIGHT = 3.0f; + + public override float _EstimateCost(Vector2I fromId, Vector2I toId) + { + return fromId.DistanceTo(toId) * HEURISTIC_WEIGHT; + } +} + public partial class MapDrawProxy : Control { public MapGenerator Source; From 783ee8b016d267779ba17ab2b5caa684a0bd2196 Mon Sep 17 00:00:00 2001 From: beezm Date: Wed, 5 Aug 2026 21:32:51 -0400 Subject: [PATCH 3/3] =?UTF-8?q?docs:=20fix=20MATH=5FMARCHING=5FCUBES=20?= =?UTF-8?q?=C2=A74=20polarity=20+=20dims?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 17 corrected the density sign convention in §1-§2 but left §4's worked example on the old inverted convention, so the doc contradicted itself. §4 now matches: negative is underground, positive is air. Also corrects a stale 16x16x64 chunk reference to 24x24x256. Co-Authored-By: Claude Opus 5 (1M context) --- Core/Scripts/MATH_MARCHING_CUBES.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/Core/Scripts/MATH_MARCHING_CUBES.md b/Core/Scripts/MATH_MARCHING_CUBES.md index f3bd14b..6f53ad4 100644 --- a/Core/Scripts/MATH_MARCHING_CUBES.md +++ b/Core/Scripts/MATH_MARCHING_CUBES.md @@ -45,16 +45,17 @@ Instead of writing 256 `if/else` statements, the algorithm uses a hardcoded **Lo ### 4. Linear Interpolation (Why it looks Smooth) If the algorithm just drew triangles exactly halfway between the inside and outside corners, the terrain would still look a bit jagged (like a low-poly PS1 game). -To make it perfectly smooth, we **Interpolate**: -* Corner A has a density of `1.0` (Very solid). -* Corner B has a density of `-0.1` (Just barely in the air). -* Because `-0.1` is much closer to our `0.0` Iso-Level than `1.0` is, the algorithm slides the triangle vertex much closer to Corner B. This creates gentle slopes and sharp cliffs dynamically. +To make it perfectly smooth, we **Interpolate**. Remember the sign convention from §1 — +negative is underground, positive is air: +* Corner A has a density of `-1.0` (deep underground, very solid). +* Corner B has a density of `0.1` (just barely up in the air). +* Because `0.1` is much closer to our `0.0` Iso-Level than `-1.0` is, the algorithm slides the triangle vertex much closer to Corner B. This creates gentle slopes and sharp cliffs dynamically. --- ## ⚙️ The Execution Loop (What our C# Script will do) -When the Server tells the Client to render a 16x16x64 chunk, the mesher does this: +When the Server tells the Client to render a 24x24x256 chunk, the mesher does this: 1. Loops through every X, Y, Z coordinate in the chunk. 2. Checks the 8 corners of the current voxel. 3. Creates an `8-bit integer` (a byte) based on which corners are solid (e.g., `00001111`).