using Godot; using System.Collections.Generic; using IslaApocalypse.Core; namespace IslaApocalypse.Server { /// /// One straight stretch of road, plus which tier it belongs to. The tier is what /// decides how wide and how smoothly it gets carved (D-022) — before this existed, /// every road was bulldozed to the same dimensions regardless of what it was. /// public struct RoadSegment { public Vector2 A; public Vector2 B; public RoadTier Tier; public RoadSegment(Vector2 a, Vector2 b, RoadTier tier) { A = a; B = b; Tier = tier; } } public partial class ServerChunkManager : Node { private WorldBlueprint _blueprint; private Dictionary _activeChunks = new Dictionary(); public int chunkSize = 24; // Water-at-rest diagnostics (task 13), summed across the chunks generated. private long _waterColumnsRendered = 0; private int _chunksWithWater = 0; private float _waterMinY = float.MaxValue, _waterMaxY = float.MinValue, _waterMaxDepth = 0f; public override void _Ready() { // 1. LOAD CONFIGURATION and the seed-based blueprint data from the MapDataParser! ConfigManager.LoadConfig(); chunkSize = ConfigManager.ChunkRadius; // Update chunk radius from config file _blueprint = MapDataParser.LoadMapData(ConfigManager.WorldSeed.ToString()); if (_blueprint != null) { // Params cross-check (v2 blueprints only): the file carries its resolved // generation inputs, so a config edited after generation is detectable — // canon H7's silent desync becomes loud. Warning, not an abort: the world // still loads; the developer decides what to do about the mismatch. if (_blueprint.Params != null) { var p = _blueprint.Params; if (p.WorldSeed != BlueprintFormat.SENTINEL_WORLD_SEED && p.WorldSeed != ConfigManager.WorldSeed) GD.PrintErr($"[Server] ⚠⚠ BLUEPRINT/CONFIG DESYNC: blueprint was generated with seed " + $"{p.WorldSeed} but ServerConfig.json says {ConfigManager.WorldSeed}. " + "The world you load is not the world this config describes."); if (p.MapSize != ConfigManager.MapSize) GD.PrintErr($"[Server] ⚠⚠ BLUEPRINT/CONFIG DESYNC: blueprint MapSize {p.MapSize} " + $"vs config MapSize {ConfigManager.MapSize}."); } GD.Print("[Server] Blueprint loaded. Locating Capitol City..."); // 2. Find the Capitol in the parsed data Vector2 capitolPos = new Vector2(2048, 2048); // Safe fallback center foreach (var town in _blueprint.Towns) { if (town.Tier == TownTier.Capitol) { capitolPos = town.Position; break; } } // Old original png map coords // Vector2 capitolPos = new Vector2(2405, 3296); // hardcoded for now since we know exactly where it is in this seed, which is the "paradise" biome hub // New Manual Test point // Vector2 capitolPos = new Vector2(4487, 4424); GD.Print($"[Server] Capitol found at {capitolPos}. Generating chunks..."); // 3. Convert pixel coordinates to Chunk coordinates int capitolChunkX = (int)(capitolPos.X / Constants.CHUNK_SIZE_X); int capitolChunkZ = (int)(capitolPos.Y / Constants.CHUNK_SIZE_Z); int radius = ConfigManager.ChunkRadius; for (int x = capitolChunkX - radius; x < capitolChunkX + radius; x++) { for (int z = capitolChunkZ - radius; z < capitolChunkZ + radius; z++) { GenerateChunk(new Vector2I(x, z)); } } // Say what was actually drawn. The water is the blueprint's, not the // runtime's, so if this reads zero the question is which of the two // sides went quiet — and this line answers it without a debugger. GD.Print($"[Server] Water at rest: {_waterColumnsRendered} water columns across " + $"{_chunksWithWater} of {_activeChunks.Count} chunks" + (_waterColumnsRendered > 0 ? $"; surface Y {_waterMinY:F1}..{_waterMaxY:F1} m, depth up to {_waterMaxDepth:F1} m." : " — nothing to draw here (check the blueprint carries WBID/WSRF).")); // 5. Teleport the Camera to look down at our creation! Camera3D cam = GetNodeOrNull("Camera3D"); if (cam != null) { // Put the camera 120 meters in the air above the Capitol cam.GlobalPosition = new Vector3(capitolPos.X, 120f, capitolPos.Y); // Point it straight down at the ground cam.LookAt(new Vector3(capitolPos.X, 0, capitolPos.Y)); } } } public void GenerateChunk(Vector2I chunkCoord) { if (_activeChunks.ContainsKey(chunkCoord)) return; ChunkData newChunk = new ChunkData(chunkCoord); int startX = chunkCoord.X * Constants.CHUNK_SIZE_X; int startZ = chunkCoord.Y * Constants.CHUNK_SIZE_Z; // --- SPATIAL CULLING FOR ROADS --- // Bounding box for this chunk, padded so a road running just outside it still // carves its shoulder in. The pad comes from the widest shoulder any tier has // (plus a margin), so widening a road can't silently outgrow this and clip // roads off at chunk edges. float roadPad = Constants.MAX_SHOULDER_RADIUS + Constants.ROAD_CULL_MARGIN; Rect2 chunkBounds = new Rect2( startX - roadPad, startZ - roadPad, Constants.CHUNK_SIZE_X + roadPad * 2, Constants.CHUNK_SIZE_Z + roadPad * 2); List localRoadSegments = new List(); // Keep only the segments that actually reach this chunk, and remember which // TIER each came from — that is what decides how wide and smooth it carves. void CollectSegments(List paths, RoadTier tier) { foreach (var path in paths) { for (int i = 0; i < path.Length - 1; i++) { Vector2 a = path[i]; Vector2 b = path[i+1]; Rect2 segBounds = new Rect2(Mathf.Min(a.X, b.X), Mathf.Min(a.Y, b.Y), Mathf.Abs(b.X - a.X), Mathf.Abs(b.Y - a.Y)); if (chunkBounds.Intersects(segBounds.Grow(1.0f))) { localRoadSegments.Add(new RoadSegment(a, b, tier)); } } } } // All four tiers now carve. Rugged and Trail are the county roads — they were // generated and saved all along, but nothing ever consumed them. CollectSegments(_blueprint.Highways, RoadTier.Highway); CollectSegments(_blueprint.BranchRoads, RoadTier.Branch); CollectSegments(_blueprint.RuggedRoads, RoadTier.Rugged); CollectSegments(_blueprint.TrailRoads, RoadTier.Trail); // --------------------------------------- for (int x = 0; x <= Constants.CHUNK_SIZE_X; x++) { for (int z = 0; z <= Constants.CHUNK_SIZE_Z; z++) { int globalX = startX + x; int globalZ = startZ + z; if (globalX >= _blueprint.MapSize || globalZ >= _blueprint.MapSize || globalX < 0 || globalZ < 0) continue; float GetExactSurface(int gX, int gZ, out byte roadMaterial) { roadMaterial = BlockRegistry.AIR; // 0 == "not a road" if (gX >= _blueprint.MapSize) gX = _blueprint.MapSize - 1; if (gZ >= _blueprint.MapSize) gZ = _blueprint.MapSize - 1; float raw = _blueprint.HeightMap[gX, gZ]; float baseHeight = Mathf.Clamp(raw * (Constants.CHUNK_HEIGHT - 5), 2.0f, Constants.CHUNK_HEIGHT - 2.0f); // If no roads are in this chunk, skip the heavy math entirely! if (localRoadSegments.Count == 0) return baseHeight; float finalHeight = baseHeight; Vector2 currentPos = new Vector2(gX, gZ); // Details of the nearest road that actually REACHES us. Each tier has // its own reach, so "nearest" alone isn't enough — a footpath two // metres away must not shadow a highway whose shoulder still covers // us. We therefore ignore any road we're outside the shoulder of, and // take the closest of what's left. bool foundRoad = false; float bestDist = float.MaxValue; float bestRoadElevation = baseHeight; RoadProfile bestProfile = default; foreach (var seg in localRoadSegments) { RoadProfile profile = Constants.GetRoadProfile(seg.Tier); // 'alongT' tells us HOW FAR ALONG this segment the nearest point is // (0 = at the start point, 1 = at the end point). float dist = DistanceToLineSegment(currentPos, seg.A, seg.B, out float alongT); if (dist > profile.ShoulderRadius) continue; // this road doesn't reach us if (dist >= bestDist) continue; // a closer one already won // F2 FIX — the road elevation is taken at the point on the segment // CLOSEST TO US, not at the segment's midpoint. // // The old code gave every column near a segment that segment's // single midpoint height, so each stretch of road was one flat // plank and consecutive planks stepped up/down like a staircase. // // Two honest ways to read the height at our closest point: // rampElevation - a straight line between this segment's two // end points. Holds a grade; cuts and fills. // landElevation - the actual terrain under that point. // Hugs the land; inherits its bumps. // The tier's GradeSmoothing dials between them (D-021 / D-022): // a highway holds its grade, a trail follows the ground. // // Either way it varies CONTINUOUSLY as we move along the road, // which is what kills the steps. And because neighbouring // segments share an end point, the height matches exactly where // one segment hands over to the next — no seam at the joins. float rampElevation = Mathf.Lerp(HeightAtPixel(seg.A), HeightAtPixel(seg.B), alongT); float landElevation = HeightAtPixel(seg.A.Lerp(seg.B, alongT)); foundRoad = true; bestDist = dist; bestProfile = profile; bestRoadElevation = Mathf.Lerp(landElevation, rampElevation, profile.GradeSmoothing); } // THE BULLDOZER: Carve the terrain, using the winning road's own dimensions. if (foundRoad) { if (bestDist <= bestProfile.RoadRadius) { finalHeight = bestRoadElevation; // Flatten it completely! roadMaterial = bestProfile.SurfaceMaterial; } else { // Smoothly interpolate from the flat road up/down to the natural mountain height float t = (bestDist - bestProfile.RoadRadius) / (bestProfile.ShoulderRadius - bestProfile.RoadRadius); t = t * t * (3f - 2f * t); // SmoothStep equation for a beautiful curved slope finalHeight = Mathf.Lerp(bestRoadElevation, baseHeight, t); } } return finalHeight; } // Pass the road surface out for the main voxel so we can paint it! // 0 (AIR) means this column is not part of any road. byte roadSurface = BlockRegistry.AIR; float exactSurfaceY = GetExactSurface(globalX, globalZ, out roadSurface); // We discard the 'out' variable for the normals using an underscore '_' float surfaceRight = GetExactSurface(globalX + 1, globalZ, out _); float surfaceFwd = GetExactSurface(globalX, globalZ + 1, out _); Biome columnBiome = _blueprint.BiomeMap[globalX, globalZ]; // Hand the renderer the TRUE surface height for this column, plus the // biome and road flag that go with it. The BlockIDs below still use the // rounded height (unchanged) — but the colour no longer has to. newChunk.SurfaceHeights[x, z] = exactSurfaceY; newChunk.ColumnBiomes[x, z] = columnBiome; newChunk.ColumnRoadMaterial[x, z] = roadSurface; // --- WATER AT REST (task 13) --------------------------------- // The blueprint is the AUTHORITY on where water is; the runtime // only draws it. WBID decides presence (it is the water stage's // own classification output, the same set the biome grid's // Ocean/Lake pixels form by construction), WSRF gives the level // per pixel, and the WBTB body table is the fallback if a column // is flagged wet but carries the WSRF no-water sentinel. if (_blueprint.WaterBodyIds != null) { ushort bodyId = _blueprint.WaterBodyIds[globalX, globalZ]; if (bodyId != 0) { float levelRaw = -1f; if (_blueprint.WaterSurfaceQ != null) { ushort q = _blueprint.WaterSurfaceQ[globalX, globalZ]; if (q != 0) levelRaw = BlueprintFormat.DecodeWaterLevel(q); } if (levelRaw < 0f) levelRaw = BodyLevel(bodyId); if (levelRaw >= 0f) { // Same mapping as the terrain, so the sheet and the // seabed cannot drift apart. newChunk.WaterSurfaceY[x, z] = Mathf.Clamp( levelRaw * Constants.HEIGHT_SCALE, 2.0f, Constants.CHUNK_HEIGHT - 2.0f); // TRUE depth, from blueprint units — see ChunkData. newChunk.WaterDepthM[x, z] = Mathf.Max(0f, (levelRaw - _blueprint.HeightMap[globalX, globalZ]) * Constants.HEIGHT_SCALE); newChunk.HasAnyWater = true; _waterColumnsRendered++; float wy = newChunk.WaterSurfaceY[x, z]; if (wy < _waterMinY) _waterMinY = wy; if (wy > _waterMaxY) _waterMaxY = wy; if (newChunk.WaterDepthM[x, z] > _waterMaxDepth) _waterMaxDepth = newChunk.WaterDepthM[x, z]; } } } float hRight = surfaceRight - exactSurfaceY; float hFwd = surfaceFwd - exactSurfaceY; float slopeX = hRight / Constants.VOXEL_SCALE; float slopeZ = hFwd / Constants.VOXEL_SCALE; float len = Mathf.Sqrt(slopeX * slopeX + 1.0f + slopeZ * slopeZ); for (int y = 0; y <= Constants.CHUNK_HEIGHT; y++) { float verticalDist = y - exactSurfaceY; float density = verticalDist / len; newChunk.Densities[x, y, z] = density; int blockY = Mathf.RoundToInt(exactSurfaceY); // THE PAINT: Pass the road surface into the Biome Palette! newChunk.BlockIDs[x, y, z] = BiomePalette.GetVoxelID(columnBiome, blockY, y, roadSurface); } } // End of Z loop } // End of X loop if (newChunk.HasAnyWater) _chunksWithWater++; _activeChunks.Add(chunkCoord, newChunk); var renderer = new IslaApocalypse.Client.ChunkRenderer(); AddChild(renderer); renderer.RenderChunk(newChunk); } /// /// A water body's flat surface level, from the blueprint's WBTB table. Only /// used as the fallback when a column is flagged wet by WBID but its WSRF /// entry is the no-water sentinel — WSRF is the per-pixel authority, this is /// the per-body one. Returns -1 if the body is unknown, which the caller /// reads as "leave this column dry" rather than guessing a level. /// private float BodyLevel(ushort bodyId) { if (_blueprint.WaterBodies == null) return -1f; foreach (var body in _blueprint.WaterBodies) if (body.Id == bodyId) return body.SurfaceLevel; return -1f; } /// /// Turns a map-pixel position into a world surface height, with bounds clamping. /// Same mapping used everywhere else: raw 0-1 heightmap value scaled into the /// usable vertical band of the chunk. /// /// The position handed in is usually FRACTIONAL (road path points land between /// heightmap cells), so we read the four cells around it and blend — bilinear /// interpolation — rather than snapping to whichever cell we happen to land in. /// /// Why that matters: snapping made the height jump by a whole cell's worth the /// instant a point crossed a cell boundary, and hold perfectly flat until it did. /// Along a road whose points sit less than a metre apart, that produced a fine /// metre-scale staircase — the "washboard". Blending makes the height vary /// continuously as the position moves, so the staircase has nothing to stand on. /// private float HeightAtPixel(Vector2 pixel) { int max = _blueprint.MapSize - 1; // The four heightmap cells surrounding this position. // At the very edge of the map x1/y1 clamp back onto x0/y0, which makes the // blend a harmless no-op instead of reading out of bounds. int x0 = Mathf.Clamp(Mathf.FloorToInt(pixel.X), 0, max); int y0 = Mathf.Clamp(Mathf.FloorToInt(pixel.Y), 0, max); int x1 = Mathf.Clamp(x0 + 1, 0, max); int y1 = Mathf.Clamp(y0 + 1, 0, max); // How far between those cells we actually are, 0 to 1 on each axis. // Clamped so a position outside the map can't push the blend past its corners. float fx = Mathf.Clamp(pixel.X - x0, 0.0f, 1.0f); float fy = Mathf.Clamp(pixel.Y - y0, 0.0f, 1.0f); // Blend across X on the near row and the far row, then blend those two down Y. float nearRow = Mathf.Lerp(_blueprint.HeightMap[x0, y0], _blueprint.HeightMap[x1, y0], fx); float farRow = Mathf.Lerp(_blueprint.HeightMap[x0, y1], _blueprint.HeightMap[x1, y1], fx); float raw = Mathf.Lerp(nearRow, farRow, fy); return Mathf.Clamp(raw * (Constants.CHUNK_HEIGHT - 5), 2.0f, Constants.CHUNK_HEIGHT - 2.0f); } /// /// Calculates the shortest distance from a point to a line segment defined by v and w. /// private float DistanceToLineSegment(Vector2 point, Vector2 v, Vector2 w) { return DistanceToLineSegment(point, v, w, out _); } /// /// Same as above, but also reports WHERE along the segment the nearest point falls: /// is 0 at v, 1 at w. The road carving needs this so it can read /// the height at the spot next to us instead of at the segment's midpoint. /// private float DistanceToLineSegment(Vector2 point, Vector2 v, Vector2 w, out float t) { float l2 = v.DistanceSquaredTo(w); if (l2 == 0) // v == w case { t = 0f; return point.DistanceTo(v); } // Consider the line extending the segment, parameterized as v + t (w - v). // We find projection of point p onto the line. // It falls where t = [(p-v) . (w-v)] / |w-v|^2 t = Mathf.Max(0, Mathf.Min(1, (point - v).Dot(w - v) / l2)); // Projection falls on the segment Vector2 projection = v + t * (w - v); return point.DistanceTo(projection); } } }