Compare commits

..

3 commits

Author SHA1 Message Date
783ee8b016 docs: fix MATH_MARCHING_CUBES §4 polarity + dims
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) <noreply@anthropic.com>
2026-08-05 21:32:51 -04:00
1f7613439a perf: weight-aware A* heuristic + tamed road repulsion (route-preserving speed fix)
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) <noreply@anthropic.com>
2026-08-05 21:32:51 -04:00
42f04139a2 refactor: extract FindPath seam (no behavior change)
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) <noreply@anthropic.com>
2026-08-05 21:31:04 -04:00
2 changed files with 83 additions and 13 deletions

View file

@ -45,16 +45,17 @@ Instead of writing 256 `if/else` statements, the algorithm uses a hardcoded **Lo
### 4. Linear Interpolation (Why it looks Smooth) ### 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). 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**: To make it perfectly smooth, we **Interpolate**. Remember the sign convention from §1 —
* Corner A has a density of `1.0` (Very solid). negative is underground, positive is air:
* Corner B has a density of `-0.1` (Just barely in the air). * Corner A has a density of `-1.0` (deep underground, very solid).
* 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. * 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) ## ⚙️ 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. 1. Loops through every X, Y, Z coordinate in the chunk.
2. Checks the 8 corners of the current voxel. 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`). 3. Creates an `8-bit integer` (a byte) based on which corners are solid (e.g., `00001111`).

View file

@ -625,7 +625,9 @@ public partial class MapGenerator : TextureRect
GD.Print($"[A*] Starting Logistics Pathfinding for {MapSize}x{MapSize} world..."); GD.Print($"[A*] Starting Logistics Pathfinding for {MapSize}x{MapSize} world...");
_highwayPaths.Clear(); _branchPaths.Clear(); _ruggedPaths.Clear(); _trailPaths.Clear(); _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.Region = new Rect2I(0, 0, MapSize, MapSize);
astar.CellSize = new Vector2I(1, 1); astar.CellSize = new Vector2I(1, 1);
astar.DiagonalMode = AStarGrid2D.DiagonalModeEnum.OnlyIfNoObstacles; astar.DiagonalMode = AStarGrid2D.DiagonalModeEnum.OnlyIfNoObstacles;
@ -653,7 +655,7 @@ public partial class MapGenerator : TextureRect
Vector2 startPos = highwayNodes[i].Position; Vector2 startPos = highwayNodes[i].Position;
Vector2 endPos = highwayNodes[(i + 1) % highwayNodes.Count].Position; Vector2 endPos = highwayNodes[(i + 1) % highwayNodes.Count].Position;
Vector2[] rawPath = astar.GetPointPath( Vector2[] rawPath = FindPath(astar,
new Vector2I((int)startPos.X, (int)startPos.Y), new Vector2I((int)startPos.X, (int)startPos.Y),
new Vector2I((int)endPos.X, (int)endPos.Y) new Vector2I((int)endPos.X, (int)endPos.Y)
); );
@ -688,7 +690,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 new Vector2I((int)snowBoss.Position.X, (int)snowBoss.Position.Y), bestPixel
); );
@ -744,7 +746,7 @@ public partial class MapGenerator : TextureRect
else else
{ {
// Attempt the connection. (If it freezes here, we know exactly which town caused it!) // Attempt the connection. (If it freezes here, we know exactly which town caused it!)
countyPath = astar.GetPointPath( countyPath = FindPath(astar,
new Vector2I((int)bestUnconnected.Position.X, (int)bestUnconnected.Position.Y), new Vector2I((int)bestUnconnected.Position.X, (int)bestUnconnected.Position.Y),
new Vector2I((int)bestConnected.Position.X, (int)bestConnected.Position.Y) new Vector2I((int)bestConnected.Position.X, (int)bestConnected.Position.Y)
); );
@ -769,6 +771,19 @@ public partial class MapGenerator : TextureRect
GD.Print("[A*] Logistics Network Complete!"); GD.Print("[A*] Logistics Network Complete!");
} }
/// <summary>
/// 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.
/// </summary>
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 float GetSeaLevel(float t) => Mathf.Lerp(0.26f, 0.15f, Mathf.Clamp(t, 0f, 1f));
private Vector2I FindClosestPixel(Vector2 pos, HashSet<Vector2I> set) { private Vector2I FindClosestPixel(Vector2 pos, HashSet<Vector2I> set) {
@ -823,6 +838,23 @@ public partial class MapGenerator : TextureRect
} }
} }
/// <summary>
/// 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.
/// </summary>
private const float ROAD_REPULSION_PENALTY = 250f;
private void PenalizePath(AStarGrid2D astar, Vector2[] path, int radius) private void PenalizePath(AStarGrid2D astar, Vector2[] path, int radius)
{ {
int step = Mathf.Max(1, radius / 2); int step = Mathf.Max(1, radius / 2);
@ -834,8 +866,7 @@ public partial class MapGenerator : TextureRect
int ny = Mathf.Clamp((int)p.Y + dy, 0, MapSize - 1); int ny = Mathf.Clamp((int)p.Y + dy, 0, MapSize - 1);
var cell = new Vector2I(nx, ny); var cell = new Vector2I(nx, ny);
if (!astar.IsPointSolid(cell)) { if (!astar.IsPointSolid(cell)) {
// The true Iron Curtain astar.SetPointWeightScale(cell, astar.GetPointWeightScale(cell) + ROAD_REPULSION_PENALTY);
astar.SetPointWeightScale(cell, astar.GetPointWeightScale(cell) + 10000f);
} }
} }
} }
@ -937,6 +968,44 @@ public partial class MapGenerator : TextureRect
} }
// Place this at the very bottom of the file! // Place this at the very bottom of the file!
/// <summary>
/// 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.
/// </summary>
public partial class RoadPathGrid : AStarGrid2D
{
/// <summary>
/// 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.
/// </summary>
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 partial class MapDrawProxy : Control
{ {
public MapGenerator Source; public MapGenerator Source;