diff --git a/Client/Scripts/ChunkRenderer.cs b/Client/Scripts/ChunkRenderer.cs index 2a46655..0988ebe 100644 --- a/Client/Scripts/ChunkRenderer.cs +++ b/Client/Scripts/ChunkRenderer.cs @@ -14,22 +14,149 @@ namespace IslaApocalypse.Client data.Densities, data.BlockIDs, data.SurfaceHeights, data.ColumnBiomes, data.ColumnRoadMaterial, Constants.ISO_LEVEL, Constants.VOXEL_SCALE); - + StandardMaterial3D mat = new StandardMaterial3D(); - + // 2. THE FIX: Tell the material to use the colors we paint on the vertices! - mat.VertexColorUseAsAlbedo = true; + mat.VertexColorUseAsAlbedo = true; mat.Roughness = 0.8f; - mat.CullMode = BaseMaterial3D.CullModeEnum.Disabled; - + mat.CullMode = BaseMaterial3D.CullModeEnum.Disabled; + MaterialOverride = mat; Position = new Vector3( - data.ChunkPosition.X * Constants.CHUNK_SIZE_X * Constants.VOXEL_SCALE, - 0, + data.ChunkPosition.X * Constants.CHUNK_SIZE_X * Constants.VOXEL_SCALE, + 0, data.ChunkPosition.Y * Constants.CHUNK_SIZE_Z * Constants.VOXEL_SCALE ); + + // 3. Water at rest (task 13) — its own mesh, its own material. + BuildWaterSurface(data); + } + + /// + /// The water sheet, as a SEPARATE MeshInstance3D child. + /// + /// Why separate rather than a block in the terrain field: the terrain is a + /// Marching-Cubes iso-surface over ChunkData.Densities. Writing water into + /// that field would not lay a sheet on top of the seabed — it would move the + /// iso-surface, fusing the water into the terrain as if the sea were solid + /// ground. A second surface is the only way to have water sit AT its own + /// level, independent of the ground beneath it. + /// + /// It also makes translucency nearly free, which is why this task ships it + /// rather than deferring: a distinct MeshInstance3D carries its own + /// StandardMaterial3D, so alpha is one flag and Godot sorts transparent + /// surfaces after opaque ones on its own. No mesher change at all. + /// + /// Water at rest is FLAT — one level per cell, taken from the blueprint's own + /// body levels. No waves, no displacement, no animation (those are C2+). + /// + private void BuildWaterSurface(ChunkData data) + { + if (!data.HasAnyWater) return; // most inland chunks: nothing to build + + var st = new SurfaceTool(); + st.Begin(Mesh.PrimitiveType.Triangles); + + int cellsX = Constants.CHUNK_SIZE_X; + int cellsZ = Constants.CHUNK_SIZE_Z; + bool any = false; + + for (int x = 0; x < cellsX; x++) + { + for (int z = 0; z < cellsZ; z++) + { + // A cell is drawn if ANY of its four corners is wet, and it is drawn + // FLAT at the deepest-standing level among those wet corners. + // + // Drawing on a partly-dry cell is deliberate: it carries the sheet + // right up under the shoreline, where the opaque terrain in front of + // it hides the submerged part. The alternative — only fully-wet + // cells — retreats the waterline a metre from shore and leaves a + // visible dry gap all the way around every coast and lake. + float level = Constants.NO_WATER; + float d00 = data.WaterSurfaceY[x, z]; + float d10 = data.WaterSurfaceY[x + 1, z]; + float d01 = data.WaterSurfaceY[x, z + 1]; + float d11 = data.WaterSurfaceY[x + 1, z + 1]; + if (d00 > level) level = d00; + if (d10 > level) level = d10; + if (d01 > level) level = d01; + if (d11 > level) level = d11; + if (level <= Constants.NO_WATER) continue; + + // Depth for colour: the deepest wet corner of the cell, so a shelving + // coast reads as a gradient rather than a staircase of flat tiles. + float depth = Mathf.Max( + Mathf.Max(data.WaterDepthM[x, z], data.WaterDepthM[x + 1, z]), + Mathf.Max(data.WaterDepthM[x, z + 1], data.WaterDepthM[x + 1, z + 1])); + + Color c = DepthColor(depth); + float s = Constants.VOXEL_SCALE; + + var p00 = new Vector3(x * s, level, z * s); + var p10 = new Vector3((x + 1) * s, level, z * s); + var p01 = new Vector3(x * s, level, (z + 1) * s); + var p11 = new Vector3((x + 1) * s, level, (z + 1) * s); + + // Two triangles, wound so the surface faces up. + AddVert(st, c, p00); AddVert(st, c, p01); AddVert(st, c, p11); + AddVert(st, c, p00); AddVert(st, c, p11); AddVert(st, c, p10); + any = true; + } + } + + if (!any) return; + + var water = new MeshInstance3D(); + water.Mesh = st.Commit(); + + var mat = new StandardMaterial3D(); + mat.VertexColorUseAsAlbedo = true; + // Vertex alpha is only honoured when the material is actually transparent. + mat.Transparency = BaseMaterial3D.TransparencyEnum.Alpha; + // Lighting note: the scene's environment is minimal (one default + // DirectionalLight3D, a blue ambient), so a metallic surface reads almost + // black — metal shows its surroundings, and there is little to show. Water + // is therefore non-metallic with moderate roughness: albedo stays visible + // under weak ambient, and it still catches a highlight when a light does + // hit it. Left deliberately in the same lighting world as the terrain so + // the two improve together when the scene gets proper lighting. + mat.Roughness = 0.35f; + mat.Metallic = 0.0f; + // Both faces: the camera starts above, but a player will swim under it. + mat.CullMode = BaseMaterial3D.CullModeEnum.Disabled; + water.MaterialOverride = mat; + + // Child of this chunk renderer, so it inherits the chunk's world offset + // and dies with the chunk. + AddChild(water); + water.Position = Vector3.Zero; + water.Name = "Water"; + } + + private static void AddVert(SurfaceTool st, Color c, Vector3 p) + { + // SetColor/SetNormal must be set immediately before AddVertex — same rule + // the terrain mesher follows. + st.SetColor(c); + st.SetNormal(Vector3.Up); + st.AddVertex(p); + } + + /// + /// Shallow teal to deep navy, with alpha closing up as it deepens, so the + /// shelved coast stays readable and open ocean does not. + /// + private static Color DepthColor(float depthMeters) + { + float t = Mathf.Clamp(depthMeters / Constants.WATER_DEEP_METERS, 0f, 1f); + t = t * t * (3f - 2f * t); // smoothstep: hold the shallows, then fall away + Color c = Constants.WATER_SHALLOW.Lerp(Constants.WATER_DEEP, t); + c.A = Mathf.Lerp(Constants.WATER_ALPHA_SHALLOW, Constants.WATER_ALPHA_DEEP, t); + return c; } } } diff --git a/Core/Scripts/BlockRegistry.cs b/Core/Scripts/BlockRegistry.cs index f435a9c..d4ec90a 100644 --- a/Core/Scripts/BlockRegistry.cs +++ b/Core/Scripts/BlockRegistry.cs @@ -21,6 +21,21 @@ namespace IslaApocalypse.Core public const byte WASTELAND_DIRT = 9; public const byte ASPHALT = 10; // For our roads! + // Water (terrain-water task 13). Wire-safe to add: block IDs are NEVER + // serialized — the blueprint's section table (BLUEPRINT_FORMAT.md) stores + // heights, biome ordinals and water data, never block IDs, and chunks are + // not persisted yet. So this ID is a runtime-only value and appending to + // the table cannot invalidate a .dat. + // + // NOTE this block is not placed into ChunkData.BlockIDs by the terrain + // path. Terrain is a Marching-Cubes iso-surface over a density field, and + // writing WATER into that field would FUSE the water into the terrain + // surface rather than lay a sheet on top of it. Water is its own mesh + // (ChunkRenderer). The entry exists so water has a real identity in the + // registry — a name and a colour — for the renderer and for whatever + // later needs to ask "what is this". + public const byte WATER = 11; + // This static constructor runs automatically the first time the registry is accessed static BlockRegistry() { @@ -42,6 +57,10 @@ namespace IslaApocalypse.Core // Infrastructure Blocks.Add(ASPHALT, new BlockData(ASPHALT, "Asphalt", true, new Color(0.15f, 0.15f, 0.15f))); + + // Water — not solid (you can move through it), shallow-water colour as the + // registry's representative value; the renderer shades by depth from there. + Blocks.Add(WATER, new BlockData(WATER, "Water", false, new Color(0.24f, 0.55f, 0.62f))); } public static BlockData GetBlock(byte id) diff --git a/Core/Scripts/ChunkData.cs b/Core/Scripts/ChunkData.cs index 3c44e63..9a60711 100644 --- a/Core/Scripts/ChunkData.cs +++ b/Core/Scripts/ChunkData.cs @@ -37,6 +37,23 @@ namespace IslaApocalypse.Core // doesn't get painted like a motorway. public byte[,] ColumnRoadMaterial = new byte[Constants.CHUNK_SIZE_X + 1, Constants.CHUNK_SIZE_Z + 1]; + // --- WATER AT REST (terrain-water task 13) --------------------------- + // Per column: the world-space Y of the water SURFACE, or Constants.NO_WATER + // where the blueprint says this column is dry. Filled by the server from + // the blueprint's WSRF/WBID/WBTB — the runtime never decides where water + // is, it only draws what the blueprint already classified. + // + // WaterDepthM is the TRUE depth in metres (water level minus seabed, both + // in blueprint units), kept separately because the rendered seabed is + // clamped at Y=2 and would flatten every deep-ocean column to the same + // value. Colour reads this; geometry reads WaterSurfaceY. + public float[,] WaterSurfaceY = new float[Constants.CHUNK_SIZE_X + 1, Constants.CHUNK_SIZE_Z + 1]; + public float[,] WaterDepthM = new float[Constants.CHUNK_SIZE_X + 1, Constants.CHUNK_SIZE_Z + 1]; + + /// True if any column in this chunk carries water — lets the + /// renderer skip building a water mesh for the many inland chunks. + public bool HasAnyWater = false; + // For our future delta-save system public bool IsPlayerProtected = false; public bool NeedsSaving = false; @@ -44,6 +61,12 @@ namespace IslaApocalypse.Core public ChunkData(Vector2I position) { ChunkPosition = position; + + // float[,] defaults to 0, which is a legal-looking water height. Start + // every column explicitly dry instead. + for (int x = 0; x <= Constants.CHUNK_SIZE_X; x++) + for (int z = 0; z <= Constants.CHUNK_SIZE_Z; z++) + WaterSurfaceY[x, z] = Constants.NO_WATER; } } } diff --git a/Core/Scripts/Constants.cs b/Core/Scripts/Constants.cs index b3fb362..0b2e689 100644 --- a/Core/Scripts/Constants.cs +++ b/Core/Scripts/Constants.cs @@ -75,6 +75,40 @@ namespace IslaApocalypse.Core /// Safety margin added to the road cull, in metres. public const float ROAD_CULL_MARGIN = 3.0f; + // --- WATER AT REST (terrain-water task 13) -------------------------- + // The blueprint has carried water since task 03; this is only about + // DRAWING it. Water is a flat sheet at the body's own surface level — + // no waves, no flow, no animation. Those are C2+. + + /// + /// Sentinel in ChunkData.WaterSurfaceY for "this column has no water". + /// Real surface heights are always >= 2 (the terrain clamp's floor), so a + /// negative value is unambiguous. + /// + public const float NO_WATER = -1.0f; + + /// + /// Raw blueprint height -> world metres. One raw unit is this many metres, + /// and it is exactly the terrain mapping (CHUNK_HEIGHT - 5), named so the + /// water surface and the seabed cannot drift apart if the band is retuned. + /// + public const float HEIGHT_SCALE = CHUNK_HEIGHT - 5; + + // Depth shading. Depth is measured from the BLUEPRINT heights, not the + // rendered geometry: the terrain render clamps its floor at Y=2, so deep + // ocean would otherwise all read as one flat ~36 m and the gradient would + // die exactly where the ocean gets interesting. + public const float WATER_DEEP_METERS = 60.0f; // depth at which the gradient bottoms out + public static readonly Color WATER_SHALLOW = new Color(0.38f, 0.76f, 0.78f); + public static readonly Color WATER_DEEP = new Color(0.05f, 0.17f, 0.42f); + + /// + /// Water alpha, shallow -> deep. Shallows are clearer, so the shelved coast + /// and the seabed under it stay readable; depth closes the surface up. + /// + public const float WATER_ALPHA_SHALLOW = 0.62f; + public const float WATER_ALPHA_DEEP = 0.97f; + /// /// Looks up the carve settings for a road tier. One place to tune. /// diff --git a/Server/Scripts/ServerChunkManager.cs b/Server/Scripts/ServerChunkManager.cs index ded094a..56f1f60 100644 --- a/Server/Scripts/ServerChunkManager.cs +++ b/Server/Scripts/ServerChunkManager.cs @@ -29,6 +29,11 @@ namespace IslaApocalypse.Server 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() { @@ -69,7 +74,8 @@ namespace IslaApocalypse.Server // 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..."); @@ -87,6 +93,15 @@ namespace IslaApocalypse.Server } } + // 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) @@ -253,6 +268,46 @@ namespace IslaApocalypse.Server 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; @@ -275,6 +330,7 @@ namespace IslaApocalypse.Server } // End of Z loop } // End of X loop + if (newChunk.HasAnyWater) _chunksWithWater++; _activeChunks.Add(chunkCoord, newChunk); var renderer = new IslaApocalypse.Client.ChunkRenderer(); @@ -282,6 +338,21 @@ namespace IslaApocalypse.Server 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