commit 7f0f43b5fb14d90c5b76f0fb4a9a9e9ae21e6462 Author: beezm Date: Wed Aug 5 01:45:41 2026 -0400 baseline: working salvaged prototype on Godot 4.7.1 (pre-F1) Co-Authored-By: Claude Opus 5 (1M context) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..01996d3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,58 @@ +# --------------------------------------------------------------------------- +# IslaApocalypse β€” Godot 4 / C# .gitignore +# +# Rule of thumb: commit SOURCE (what you wrote), ignore GENERATED (what a tool +# can rebuild). Everything below is rebuilt automatically by Godot or the C# +# compiler, so keeping it out of git keeps the repo small and avoids conflicts. +# --------------------------------------------------------------------------- + +# Godot's editor cache: imported textures, shader cache, the generated C# glue. +# Regenerated on first open. This is the big one (~9 MB here, and it grows). +.godot/ +.import/ + +# C# / .NET build output. Rebuilt by any build. +bin/ +obj/ +*.dll +*.pdb +*.exe +*.so +*.dylib +*.nupkg + +# Old Mono-era folder, kept out in case an older tool recreates it. +.mono/ + +# Per-developer IDE/editor settings β€” not project settings, so not shared. +*.user +*.userprefs +.vs/ +.vscode/ +.idea/ + +# Exported game builds β€” artifacts of a release, not source. +/build/ +/builds/ +/export/ +/exports/ +*.pck +*.zip + +# Crash dumps. +mono_crash.*.json +logs/ + +# OS clutter. +.DS_Store +Thumbs.db + +# --------------------------------------------------------------------------- +# Deliberately NOT ignored (these are source and MUST be committed): +# *.cs *.tscn project.godot *.csproj *.sln ServerConfig.json +# icon.svg + icon.svg.import READMEs Data/ and Resources/ contents +# +# Note on generated world data: the map blueprint (MapData_Seed_*.dat, ~512 MB) +# and its PNG are written to Godot's `user://` folder, which lives OUTSIDE this +# project directory β€” so they can never be committed by accident. Nothing to do. +# --------------------------------------------------------------------------- diff --git a/Client/README.md b/Client/README.md new file mode 100644 index 0000000..69a5213 --- /dev/null +++ b/Client/README.md @@ -0,0 +1,19 @@ +# Client Module - IslaApocalypse + +**Phase 2 Status:** Active (Mesh Rendering & Vertex Painting Online) + +## Overview +The `Client` directory handles the visual representation of the 3D Voxel Engine. It is strictly responsible for receiving pre-calculated mathematical data from the `Server` and `Core` modules and translating it into Godot-native visual nodes (`MeshInstance3D`). It does not calculate collisions, densities, or terrain generation. + +## Key Architecture & Components + +### 1. The Visualizer (`ChunkRenderer.cs`) +A script attached to a `MeshInstance3D` node that physically draws a single 32x32 chunk of the world. +* **Mesh Generation:** Calls `MarchingCubes.GenerateMesh()` from the `Core` module, passing in the `Densities` and `BlockIDs` scalar fields. +* **Vertex Painting:** Dynamically creates a `StandardMaterial3D` and enables `VertexColorUseAsAlbedo = true`. This tells Godot to ignore standard texture maps and instead color the mesh using the exact RGB values we assigned to the vertices based on their `BlockID` (e.g., Green for Grass, Brown for Wasteland). +* **World Positioning:** Multiplies the `ChunkPosition` (e.g., [1, 0]) by `Constants.CHUNK_SIZE` and `Constants.VOXEL_SCALE` (1.0f) to perfectly align the chunk in the global 3D space. +* **Backface Rendering:** `CullMode` is explicitly disabled to ensure players don't see through the bottom of the map if they clip inside a mountain. + +## Next Immediate Steps (Phase 2, Step 4) +* Currently, the renderer draws the exact topography. Once the `Server` implements **Road Carving**, the `Client` will automatically inherit those flattened vertices and paint them with the `ASPHALT` vertex color without needing any code changes here. +* Future implementations will include reading `BlockIDs` to paint different UV maps/textures instead of just raw RGB vertex colors. \ No newline at end of file diff --git a/Client/Scripts/ChunkRenderer.cs b/Client/Scripts/ChunkRenderer.cs new file mode 100644 index 0000000..f8b3811 --- /dev/null +++ b/Client/Scripts/ChunkRenderer.cs @@ -0,0 +1,31 @@ +using Godot; +using IslaApocalypse.Core; + +namespace IslaApocalypse.Client +{ + // Notice this inherits from MeshInstance3D, which is Godot's visual 3D node! + public partial class ChunkRenderer : MeshInstance3D + { + public void RenderChunk(ChunkData data) + { + // 1. Pass the BlockIDs array to the generator! + Mesh = MarchingCubes.GenerateMesh(data.Densities, data.BlockIDs, 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.Roughness = 0.8f; + mat.CullMode = BaseMaterial3D.CullModeEnum.Disabled; + + MaterialOverride = mat; + + Position = new Vector3( + data.ChunkPosition.X * Constants.CHUNK_SIZE_X * Constants.VOXEL_SCALE, + 0, + data.ChunkPosition.Y * Constants.CHUNK_SIZE_Z * Constants.VOXEL_SCALE + ); + } + } +} diff --git a/Client/Scripts/ChunkRenderer.cs.uid b/Client/Scripts/ChunkRenderer.cs.uid new file mode 100644 index 0000000..01e9a53 --- /dev/null +++ b/Client/Scripts/ChunkRenderer.cs.uid @@ -0,0 +1 @@ +uid://8que4lnxlubg diff --git a/Core/README.md b/Core/README.md new file mode 100644 index 0000000..d4ab269 --- /dev/null +++ b/Core/README.md @@ -0,0 +1,27 @@ +# Core Module - IslaApocalypse + +**Phase 2 Status:** Active (Voxel Math & Parsing Online) + +## Overview +The `Core` directory contains the mathematical foundation, data structures, and parser required to bridge the 2D procedural generation with the 3D Voxel Engine. It is strictly logic-driven and contains no Godot Nodes or visual elements. + +## Key Architecture & Components + +### 1. The Data Bridge (`MapDataParser.cs`) +Responsible for deserializing the highly compressed binary `.dat` file generated by the `/Tools` pipeline. +* **Outputs:** A `WorldBlueprint` object held in RAM. +* **Capabilities:** Reads the 2D heightmap, biome map, and town locations. Crucially, it parses all four tiers of A* road vectors (Highways, Branch Roads, Rugged Roads, Trails) for the Server to use in terrain flattening. + +### 2. Voxel Data Containers (`ChunkData.cs` & `Constants.cs`) +* **Chunk Dimensions:** Defined in `Constants.cs`. Currently optimized to `32x32` horizontal with a massive `256` vertical height limit to allow for true AAA-scale mountain ranges without heightmap compression. +* **Seamless Borders:** `ChunkData.cs` expands its scalar field arrays (`Densities` and `BlockIDs`) by `+1` on all axes. This allows the Marching Cubes algorithm to sample the neighboring chunk's data, ensuring meshes connect perfectly without gaps. + +### 3. The Math Engine (`MarchingCubes.cs`) +Transforms the raw `ChunkData` scalar fields into physical Godot `ArrayMesh` geometry. +* **Analytical Normals:** Instead of flat shading, the algorithm calculates exact 3D slope gradients at the corners of each voxel. This produces smooth, realistic lighting across curved terrain. +* **Vertex Painting:** Reads the `BlockID` assigned to each voxel corner and applies physical RGB colors directly to the mesh vertices before committing the geometry. + +### 4. Biome & Material Mapping (`BiomePalette.cs`, `BlockRegistry.cs`) +Translates the 2D world into physical 3D materials. +* **BlockRegistry:** A byte-based lookup table defining physical materials (`SAND`, `STONE`, `WASTELAND_DIRT`, `ASPHALT`). +* **BiomePalette Logic:** Uses the 2D `Biome` pixel combined with the 3D `currentY` depth to determine the block type. For example, `Biome.Wasteland` combined with a depth of 0 yields `WASTELAND_DIRT`, while a depth > 4 yields `STONE`. \ No newline at end of file diff --git a/Core/Scripts/BiomePalette.cs b/Core/Scripts/BiomePalette.cs new file mode 100644 index 0000000..5f25e07 --- /dev/null +++ b/Core/Scripts/BiomePalette.cs @@ -0,0 +1,63 @@ +using Godot; + +namespace IslaApocalypse.Core +{ + public static class BiomePalette + { + /// + /// Determines the exact Block ID for a specific voxel in the 3D world based on its depth and biome. + /// + /// The 2D biome pixel from the MapData. + /// The maximum height of the terrain at this X/Z coordinate. + /// The Y coordinate of the specific voxel we are painting. + /// Is this coordinate part of the highway/branch road network? + public static byte GetVoxelID(Biome biome, int surfaceHeight, int currentY, bool isRoad) + { + // 1. Air Check: If we are above the terrain, it's Air. + if (currentY > surfaceHeight) return BlockRegistry.AIR; + + // 2. Bedrock Check: The very bottom of the world is indestructible. + if (currentY == 0) return BlockRegistry.BEDROCK; + + // 3. Infrastructure Check: If this is a road, and we are exactly on the surface, paint asphalt. + if (isRoad && currentY == surfaceHeight) return BlockRegistry.ASPHALT; + + int depthBelowSurface = surfaceHeight - currentY; + + // 4. Subsurface Logic (Dirt layer vs Stone layer) + // The top 4 blocks are usually dirt/sand, everything below that is solid stone. + if (depthBelowSurface > 4) return BlockRegistry.STONE; + + // 5. Surface & Shallow Subsurface Logic (Based on Biome) + switch (biome) + { + case Biome.Beach: + case Biome.Crater: + return BlockRegistry.SAND; // Craters and Beaches are deep sand + + case Biome.Snow: + case Biome.Mountain: + if (depthBelowSurface == 0) return BlockRegistry.SNOW; // Only the top layer is snow + return BlockRegistry.STONE; // Mountains have shallow topsoil, mostly stone + + case Biome.Wasteland: + return BlockRegistry.WASTELAND_DIRT; + + case Biome.Jungle: + if (depthBelowSurface == 0) return BlockRegistry.GRASS_JUNGLE; + return BlockRegistry.DIRT; + + case Biome.Paradise: + if (depthBelowSurface == 0) return BlockRegistry.GRASS_PARADISE; + return BlockRegistry.DIRT; + + case Biome.Tropical: + if (depthBelowSurface == 0) return BlockRegistry.GRASS_TROPICAL; + return BlockRegistry.DIRT; + + default: + return BlockRegistry.DIRT; // Fallback + } + } + } +} diff --git a/Core/Scripts/BiomePalette.cs.uid b/Core/Scripts/BiomePalette.cs.uid new file mode 100644 index 0000000..8a26f76 --- /dev/null +++ b/Core/Scripts/BiomePalette.cs.uid @@ -0,0 +1 @@ +uid://dx8hj4h8v0f2j diff --git a/Core/Scripts/BlockData.cs b/Core/Scripts/BlockData.cs new file mode 100644 index 0000000..8855ddf --- /dev/null +++ b/Core/Scripts/BlockData.cs @@ -0,0 +1,20 @@ +using Godot; + +namespace IslaApocalypse.Core +{ + public struct BlockData + { + public byte ID; + public string Name; + public bool IsSolid; + public Color BaseColor; // We use color for now; later we replace this with Texture Array IDs for the Shader + + public BlockData(byte id, string name, bool isSolid, Color baseColor) + { + ID = id; + Name = name; + IsSolid = isSolid; + BaseColor = baseColor; + } + } +} diff --git a/Core/Scripts/BlockData.cs.uid b/Core/Scripts/BlockData.cs.uid new file mode 100644 index 0000000..8688524 --- /dev/null +++ b/Core/Scripts/BlockData.cs.uid @@ -0,0 +1 @@ +uid://bjhct6ctt0j46 diff --git a/Core/Scripts/BlockRegistry.cs b/Core/Scripts/BlockRegistry.cs new file mode 100644 index 0000000..f435a9c --- /dev/null +++ b/Core/Scripts/BlockRegistry.cs @@ -0,0 +1,53 @@ +using Godot; +using System.Collections.Generic; + +namespace IslaApocalypse.Core +{ + public static class BlockRegistry + { + // A dictionary holding all the block types in the game, searchable by their byte ID + public static readonly Dictionary Blocks = new Dictionary(); + + // Hardcoded IDs for easy reference in code + public const byte AIR = 0; + public const byte BEDROCK = 1; + public const byte STONE = 2; + public const byte DIRT = 3; + public const byte SAND = 4; + public const byte GRASS_TROPICAL = 5; + public const byte GRASS_JUNGLE = 6; + public const byte GRASS_PARADISE = 7; + public const byte SNOW = 8; + public const byte WASTELAND_DIRT = 9; + public const byte ASPHALT = 10; // For our roads! + + // This static constructor runs automatically the first time the registry is accessed + static BlockRegistry() + { + // ID 0 is ALWAYS Air in voxel engines. + Blocks.Add(AIR, new BlockData(AIR, "Air", false, Colors.Transparent)); + + // The Foundation Blocks + Blocks.Add(BEDROCK, new BlockData(BEDROCK, "Bedrock", true, new Color(0.1f, 0.1f, 0.1f))); + Blocks.Add(STONE, new BlockData(STONE, "Stone", true, new Color(0.5f, 0.5f, 0.5f))); + Blocks.Add(DIRT, new BlockData(DIRT, "Dirt", true, new Color(0.4f, 0.3f, 0.2f))); + Blocks.Add(SAND, new BlockData(SAND, "Sand", true, new Color(0.8f, 0.7f, 0.4f))); + + // The Biome Surface Blocks + Blocks.Add(GRASS_TROPICAL, new BlockData(GRASS_TROPICAL, "Tropical Grass", true, new Color(0.2f, 0.6f, 0.2f))); + Blocks.Add(GRASS_JUNGLE, new BlockData(GRASS_JUNGLE, "Jungle Grass", true, new Color(0.1f, 0.4f, 0.1f))); + Blocks.Add(GRASS_PARADISE, new BlockData(GRASS_PARADISE, "Paradise Grass", true, new Color(0.4f, 0.8f, 0.4f))); + Blocks.Add(SNOW, new BlockData(SNOW, "Snow", true, new Color(0.9f, 0.9f, 0.9f))); + Blocks.Add(WASTELAND_DIRT, new BlockData(WASTELAND_DIRT, "Wasteland Dirt", true, new Color(0.5f, 0.2f, 0.5f))); + + // Infrastructure + Blocks.Add(ASPHALT, new BlockData(ASPHALT, "Asphalt", true, new Color(0.15f, 0.15f, 0.15f))); + } + + public static BlockData GetBlock(byte id) + { + if (Blocks.TryGetValue(id, out BlockData data)) return data; + return Blocks[AIR]; // Safe fallback so the game doesn't crash if an ID is missing + } + } +} diff --git a/Core/Scripts/BlockRegistry.cs.uid b/Core/Scripts/BlockRegistry.cs.uid new file mode 100644 index 0000000..4d7d1c0 --- /dev/null +++ b/Core/Scripts/BlockRegistry.cs.uid @@ -0,0 +1 @@ +uid://c2145lfqj00uk diff --git a/Core/Scripts/ChunkData.cs b/Core/Scripts/ChunkData.cs new file mode 100644 index 0000000..81927f6 --- /dev/null +++ b/Core/Scripts/ChunkData.cs @@ -0,0 +1,29 @@ +using Godot; + +namespace IslaApocalypse.Core +{ + public class ChunkData + { + public Vector2I ChunkPosition; // The X/Z coordinate of this chunk (e.g., 0,0 or 1,0) + + // We need Width+1 and Depth+1 so the Marching Cubes mesh connects seamlessly to the neighbor chunks! + // public float[,,] Densities = new float[Constants.CHUNK_SIZE_X + 1, Constants.CHUNK_HEIGHT, Constants.CHUNK_SIZE_Z + 1]; + // public byte[,,] BlockIDs = new byte[Constants.CHUNK_SIZE_X + 1, Constants.CHUNK_HEIGHT, Constants.CHUNK_SIZE_Z + 1]; + // OLD: + // Densities = new float[Constants.CHUNK_SIZE_X, Constants.CHUNK_HEIGHT, Constants.CHUNK_SIZE_Z]; + // BlockIDs = new int[Constants.CHUNK_SIZE_X, Constants.CHUNK_HEIGHT, Constants.CHUNK_SIZE_Z]; + + // NEW (Fix 3): We expand by 1 to sample the neighbor's border perfectly without seams! + public float[,,] Densities = new float[Constants.CHUNK_SIZE_X + 1, Constants.CHUNK_HEIGHT + 1, Constants.CHUNK_SIZE_Z + 1]; + public byte[,,] BlockIDs = new byte[Constants.CHUNK_SIZE_X + 1, Constants.CHUNK_HEIGHT + 1, Constants.CHUNK_SIZE_Z + 1]; + + // For our future delta-save system + public bool IsPlayerProtected = false; + public bool NeedsSaving = false; + + public ChunkData(Vector2I position) + { + ChunkPosition = position; + } + } +} diff --git a/Core/Scripts/ChunkData.cs.uid b/Core/Scripts/ChunkData.cs.uid new file mode 100644 index 0000000..b1f4f6d --- /dev/null +++ b/Core/Scripts/ChunkData.cs.uid @@ -0,0 +1 @@ +uid://cturk2xl2lod diff --git a/Core/Scripts/ConfigManager.cs b/Core/Scripts/ConfigManager.cs new file mode 100644 index 0000000..bb03d72 --- /dev/null +++ b/Core/Scripts/ConfigManager.cs @@ -0,0 +1,111 @@ + +using Godot; +using Godot.Collections; // Required for Godot's built-in JSON parser + +namespace IslaApocalypse.Core // Change this if your namespace is different +{ + public static class ConfigManager + { + // Default Fallbacks + public static int WorldSeed = 1063685222; + // public static int WorldSeed = (int)GD.Randi(); if 0 also randomizes + public static int MapSize = 8192; + public static float CraterRadius = 400f; + public static float DensityMultiplier = 1.0f; // <-- Replaces TownCount + public static int ChunkRadius = 24; // Default chunk size, can be overridden by config + + public static void LoadConfig() + { + string path = "res://ServerConfig.json"; + + if (!FileAccess.FileExists(path)) + { + GD.PrintErr("[ConfigManager] ServerConfig.json not found! Defaulting to 8K."); + return; + } + + // Read the file + using var file = FileAccess.Open(path, FileAccess.ModeFlags.Read); + string content = file.GetAsText(); + + // Parse the JSON + var json = new Json(); + var error = json.Parse(content); + if (error != Error.Ok) + { + GD.PrintErr($"[ConfigManager] JSON Parse Error: {json.GetErrorMessage()}"); + return; + } + + var data = (Dictionary)json.Data; + + // Extract the Seed + if (data.ContainsKey("WorldSeed")) + { + WorldSeed = (int)data["WorldSeed"]; + } + + // Extract the MapProfile and run your Switch/Case logic! + string profile = "8K"; + if (data.ContainsKey("MapProfile")) + { + profile = (string)data["MapProfile"]; + } + + // Extract the TownDensity and run your Switch/Case logic! + string townDensity = "Normal"; + if (data.ContainsKey("TownDensity")) + { + townDensity = (string)data["TownDensity"]; + } + // Extract the ChunkRadius + if (data.ContainsKey("ChunkRadius")) { + ChunkRadius = (int)data["ChunkRadius"]; + } + + switch (profile) + { + case "4K": + MapSize = 4096; + CraterRadius = 400f; // Scaled down crater + break; + case "6K": + MapSize = 6144; + CraterRadius = 600f; + break; + case "8K": + MapSize = 8192; + CraterRadius = 800f; // Your original crater size spread over an 8K map + break; + case "10K": + MapSize = 10240; + CraterRadius = 1000f; + break; + default: + GD.PrintErr($"[ConfigManager] Unknown MapProfile '{profile}'. Defaulting to 8K."); + MapSize = 8192; + CraterRadius = 800f; + break; + } + + switch (townDensity) + { + case "Sparse": + DensityMultiplier = 0.5f; + break; + case "Normal": + DensityMultiplier = 1.0f; + break; + case "Dense": + DensityMultiplier = 2.0f; + break; + default: + GD.PrintErr($"[ConfigManager] Unknown TownDensity '{townDensity}'. Defaulting to Normal."); + DensityMultiplier = 1.0f; + break; + } + + GD.Print($"[ConfigManager] Successfully loaded {profile} Profile. MapSize set to {MapSize}."); + } + } +} diff --git a/Core/Scripts/ConfigManager.cs.uid b/Core/Scripts/ConfigManager.cs.uid new file mode 100644 index 0000000..40c4103 --- /dev/null +++ b/Core/Scripts/ConfigManager.cs.uid @@ -0,0 +1 @@ +uid://dttxbmvvhlvkl diff --git a/Core/Scripts/Constants.cs b/Core/Scripts/Constants.cs new file mode 100644 index 0000000..7de2266 --- /dev/null +++ b/Core/Scripts/Constants.cs @@ -0,0 +1,14 @@ +namespace IslaApocalypse.Core +{ + public static class Constants + { + // Chunk Dimensions + public const int CHUNK_SIZE_X = 24; + public const int CHUNK_SIZE_Z = 24; + public const int CHUNK_HEIGHT = 256; // Our agreed-upon depth! + + // World Generation + public const float ISO_LEVEL = 0.0f; // The threshold for Marching Cubes + public const float VOXEL_SCALE = 1.0f; // 1 Pixel = 1 Meter + } +} \ No newline at end of file diff --git a/Core/Scripts/Constants.cs.uid b/Core/Scripts/Constants.cs.uid new file mode 100644 index 0000000..b2dbef3 --- /dev/null +++ b/Core/Scripts/Constants.cs.uid @@ -0,0 +1 @@ +uid://dhnft3c7f6ek2 diff --git a/Core/Scripts/Enums.cs b/Core/Scripts/Enums.cs new file mode 100644 index 0000000..f4e95fa --- /dev/null +++ b/Core/Scripts/Enums.cs @@ -0,0 +1,8 @@ +namespace IslaApocalypse.Core +{ + // The master list of shared identifiers for the game + public enum Biome { Ocean, Lake, Beach, Paradise, Tropical, Jungle, Wasteland, Crater, Mountain, Snow } + public enum TownTier { Village, Hub, Capitol, Outpost, IslandLoot, MiniPOI } + public enum MapHalf { Any, Left, Right } + +} diff --git a/Core/Scripts/Enums.cs.uid b/Core/Scripts/Enums.cs.uid new file mode 100644 index 0000000..dd6669e --- /dev/null +++ b/Core/Scripts/Enums.cs.uid @@ -0,0 +1 @@ +uid://b573cexjylgn8 diff --git a/Core/Scripts/MATH_MARCHING_CUBES.md b/Core/Scripts/MATH_MARCHING_CUBES.md new file mode 100644 index 0000000..4b3f75f --- /dev/null +++ b/Core/Scripts/MATH_MARCHING_CUBES.md @@ -0,0 +1,55 @@ +# πŸ“ The Math of Marching Cubes (Smooth Voxels) + +## Overview +In a standard blocky game like *Minecraft* (Greedy Meshing), if a coordinate has a block, you draw a square. If it doesn't, you draw nothing. + +In a smooth voxel game like *7 Days to Die*, we use an algorithm called **Marching Cubes**. Instead of looking at a single block, the algorithm looks at the **8 corners** of an invisible 3D grid cell (a cube) and asks: *"Which of these 8 corners are underground, and which are in the air?"* + +Based on the answer, it draws a specific set of triangles *through* the invisible cube to create a smooth surface. + +--- + +## πŸ”‘ Key Concepts + +### 1. Density (The "Float" Value) +In smooth voxels, a point in space isn't just "Solid" (1) or "Empty" (0). It has a continuous **Density** value. +* ` 1.0` = Deep underground (Solid Rock). +* ` 0.5` = Slightly underground (Dirt). +* ` 0.0` = The exact surface of the ground. +* `-0.5` = Slightly above ground (Air). +* `-1.0` = High in the sky (Empty Space). + +### 2. The Iso-Level (The Threshold) +The Iso-Level is the exact density value where the "skin" of the world is drawn. Usually, this is `0.0`. +* If a corner's density is **greater** than `0.0`, it is "Inside" the terrain. +* If a corner's density is **less** than `0.0`, it is "Outside" in the air. + +### 3. The Triangulation Table (The Magic Array) +A cube has 8 corners. Each corner can either be Inside or Outside. +That means there are **2^8 = 256 possible combinations**. + +Instead of writing 256 `if/else` statements, the algorithm uses a hardcoded **Lookup Table** (an array of integers). +* *Example:* If corner 0 is inside, but corners 1-7 are outside, the table instantly says: *"Draw a single small triangle slicing off corner 0."* +* *Example:* If corners 0, 1, 2, and 3 are inside (the whole bottom half), the table says: *"Draw a flat quad (two triangles) straight across the middle of the cube."* + +### 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. + +--- + +## βš™οΈ The Execution Loop (What our C# Script will do) + +When the Server tells the Client to render a 16x16x64 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`). +4. Plugs that byte into the **Triangulation Table**. +5. The table spits out a list of edges. +6. The script calculates the exact interpolated point on those edges. +7. It connects those points into triangles and adds them to Godot's `ArrayMesh`. +8. *It Marches* to the next cube and repeats. diff --git a/Core/Scripts/MapDataParser.cs b/Core/Scripts/MapDataParser.cs new file mode 100644 index 0000000..8703cac --- /dev/null +++ b/Core/Scripts/MapDataParser.cs @@ -0,0 +1,129 @@ +using Godot; +using System.IO; +using System.Collections.Generic; + +namespace IslaApocalypse.Core +{ + // 1. The Data Container + // This holds the unpacked data in RAM so the Server can ask it questions + // without having to re-read the hard drive every single frame. + public struct TownLocation + { + public Vector2 Position; + public TownTier Tier; + } + + public class WorldBlueprint + { + public int MapSize; + public float[,] HeightMap; + public Biome[,] BiomeMap; + public List Towns = new List(); + + // All 4 road tiers! + public List Highways = new List(); + public List BranchRoads = new List(); + public List RuggedRoads = new List(); + public List TrailRoads = new List(); + } + + // 2. The Parser Utility + public static class MapDataParser + { + public static WorldBlueprint LoadMapData(string seedStr) + { + string filePath = ProjectSettings.GlobalizePath($"user://MapData_Seed_{seedStr}.dat"); + + if (!File.Exists(filePath)) + { + GD.PrintErr($"[MapDataParser] CRITICAL ERROR: Map file not found at {filePath}"); + return null; + } + + WorldBlueprint blueprint = new WorldBlueprint(); + + using (FileStream stream = File.OpenRead(filePath)) + { + using (BinaryReader reader = new BinaryReader(stream)) + { + // 1. Header Validation (CRITICAL: Must read string first to align bytes!) + string header = reader.ReadString(); + if (header != "ISLA_V1") + { + GD.PrintErr("[MapDataParser] ERROR: Invalid map version or corrupted file."); + return null; + } + + // 2. Read Map Dimensions & Initialize Arrays + blueprint.MapSize = reader.ReadInt32(); + blueprint.HeightMap = new float[blueprint.MapSize, blueprint.MapSize]; + blueprint.BiomeMap = new Biome[blueprint.MapSize, blueprint.MapSize]; + + // 3. Read Raw Voxel Data (Height & Biome) + for (int x = 0; x < blueprint.MapSize; x++) + { + for (int y = 0; y < blueprint.MapSize; y++) + { + blueprint.HeightMap[x, y] = reader.ReadSingle(); + blueprint.BiomeMap[x, y] = (Biome)reader.ReadInt32(); + } + } + + // 4. Read Logistical Anchors (Towns & POIs) + int townCount = reader.ReadInt32(); + for (int i = 0; i < townCount; i++) + { + TownLocation town = new TownLocation(); + town.Position = new Vector2(reader.ReadSingle(), reader.ReadSingle()); + town.Tier = (TownTier)reader.ReadInt32(); + blueprint.Towns.Add(town); + } + + // 5. Read Road Networks + // Highway + int highwayCount = reader.ReadInt32(); + for (int i = 0; i < highwayCount; i++) + { + int pathLength = reader.ReadInt32(); + Vector2[] path = new Vector2[pathLength]; + for (int p = 0; p < pathLength; p++) { path[p] = new Vector2(reader.ReadSingle(), reader.ReadSingle()); } + blueprint.Highways.Add(path); + } + + // Branch + int branchCount = reader.ReadInt32(); + for (int i = 0; i < branchCount; i++) + { + int pathLength = reader.ReadInt32(); + Vector2[] path = new Vector2[pathLength]; + for (int p = 0; p < pathLength; p++) { path[p] = new Vector2(reader.ReadSingle(), reader.ReadSingle()); } + blueprint.BranchRoads.Add(path); + } + + // Rugged + int ruggedCount = reader.ReadInt32(); + for (int i = 0; i < ruggedCount; i++) + { + int pathLength = reader.ReadInt32(); + Vector2[] path = new Vector2[pathLength]; + for (int p = 0; p < pathLength; p++) { path[p] = new Vector2(reader.ReadSingle(), reader.ReadSingle()); } + blueprint.RuggedRoads.Add(path); + } + + // Trails + int trailCount = reader.ReadInt32(); + for (int i = 0; i < trailCount; i++) + { + int pathLength = reader.ReadInt32(); + Vector2[] path = new Vector2[pathLength]; + for (int p = 0; p < pathLength; p++) { path[p] = new Vector2(reader.ReadSingle(), reader.ReadSingle()); } + blueprint.TrailRoads.Add(path); + } + } + } + + GD.Print($"[MapDataParser] Successfully loaded Blueprint for Seed {seedStr}. Dimension: {blueprint.MapSize}x{blueprint.MapSize}."); + return blueprint; + } + } +} \ No newline at end of file diff --git a/Core/Scripts/MapDataParser.cs.uid b/Core/Scripts/MapDataParser.cs.uid new file mode 100644 index 0000000..1ba8b61 --- /dev/null +++ b/Core/Scripts/MapDataParser.cs.uid @@ -0,0 +1 @@ +uid://6qednxmubxia diff --git a/Core/Scripts/MarchingCubes.cs b/Core/Scripts/MarchingCubes.cs new file mode 100644 index 0000000..220b883 --- /dev/null +++ b/Core/Scripts/MarchingCubes.cs @@ -0,0 +1,522 @@ +using Godot; +using System; +using System.Collections.Generic; + +namespace IslaApocalypse.Core +{ + public static class MarchingCubes + { + /// + /// The Edge Table (256 values). + /// This array uses an 8-bit index (representing the 8 corners of a cube) + /// to look up a 12-bit value. Each bit in the returned value corresponds to + /// one of the 12 edges of the cube, telling the engine if that edge is intersected. + /// + public static readonly int[] EdgeTable = new int[256] { + 0x0 , 0x109, 0x203, 0x30a, 0x406, 0x50f, 0x605, 0x70c, + 0x80c, 0x905, 0xa0f, 0xb06, 0xc0a, 0xd03, 0xe09, 0xf00, + 0x190, 0x99 , 0x393, 0x29a, 0x596, 0x49f, 0x795, 0x69c, + 0x99c, 0x895, 0xb9f, 0xa96, 0xd9a, 0xc93, 0xf99, 0xe90, + 0x230, 0x339, 0x33 , 0x13a, 0x636, 0x73f, 0x435, 0x53c, + 0xa3c, 0xb35, 0x83f, 0x936, 0xe3a, 0xf33, 0xc39, 0xd30, + 0x3a0, 0x2a9, 0x1a3, 0xaa , 0x7a6, 0x6af, 0x5a5, 0x4ac, + 0xbac, 0xaa5, 0x9af, 0x8a6, 0xfaa, 0xea3, 0xda9, 0xca0, + 0x460, 0x569, 0x663, 0x76a, 0x66 , 0x16f, 0x265, 0x36c, + 0xc6c, 0xd65, 0xe6f, 0xf66, 0x86a, 0x963, 0xa69, 0xb60, + 0x5f0, 0x4f9, 0x7f3, 0x6fa, 0x1f6, 0xff , 0x3f5, 0x2fc, + 0xdfc, 0xcf5, 0xfff, 0xef6, 0x9fa, 0x8f3, 0xbf9, 0xaf0, + 0x650, 0x759, 0x453, 0x55a, 0x256, 0x35f, 0x55 , 0x15c, + 0xe5c, 0xf55, 0xc5f, 0xd56, 0xa5a, 0xb53, 0x859, 0x950, + 0x7c0, 0x6c9, 0x5c3, 0x4ca, 0x3c6, 0x2cf, 0x1c5, 0xcc , + 0xfcc, 0xec5, 0xdcf, 0xcc6, 0xbca, 0xac3, 0x9c9, 0x8c0, + 0x8c0, 0x9c9, 0xac3, 0xbca, 0xcc6, 0xdcf, 0xec5, 0xfcc, + 0xcc , 0x1c5, 0x2cf, 0x3c6, 0x4ca, 0x5c3, 0x6c9, 0x7c0, + 0x950, 0x859, 0xb53, 0xa5a, 0xd56, 0xc5f, 0xf55, 0xe5c, + 0x15c, 0x55 , 0x35f, 0x256, 0x55a, 0x453, 0x759, 0x650, + 0xaf0, 0xbf9, 0x8f3, 0x9fa, 0xef6, 0xfff, 0xcf5, 0xdfc, + 0x2fc, 0x3f5, 0xff , 0x1f6, 0x6fa, 0x7f3, 0x4f9, 0x5f0, + 0xb60, 0xa69, 0x963, 0x86a, 0xf66, 0xe6f, 0xd65, 0xc6c, + 0x36c, 0x265, 0x16f, 0x66 , 0x76a, 0x663, 0x569, 0x460, + 0xca0, 0xda9, 0xea3, 0xfaa, 0x8a6, 0x9af, 0xaa5, 0xbac, + 0x4ac, 0x5a5, 0x6af, 0x7a6, 0xaa , 0x1a3, 0x2a9, 0x3a0, + 0xd30, 0xc39, 0xf33, 0xe3a, 0x936, 0x83f, 0xb35, 0xa3c, + 0x53c, 0x435, 0x73f, 0x636, 0x13a, 0x33 , 0x339, 0x230, + 0xe90, 0xf99, 0xc93, 0xd9a, 0xa96, 0xb9f, 0x895, 0x99c, + 0x69c, 0x795, 0x49f, 0x596, 0x29a, 0x393, 0x99 , 0x190, + 0xf00, 0xe09, 0xd03, 0xc0a, 0xb06, 0xa0f, 0x905, 0x80c, + 0x70c, 0x605, 0x50f, 0x406, 0x30a, 0x203, 0x109, 0x0 + }; + + /// + /// The Triangulation Table (256 rows, 16 columns). + /// Once we know which edges are intersected, this table tells us exactly + /// how to connect those intersections to draw up to 5 triangles. + /// A value of -1 means "stop drawing triangles". + /// + public static readonly int[,] TriTable = new int[256, 16] { + {-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {0, 8, 3, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {0, 1, 9, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {1, 8, 3, 9, 8, 1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {1, 2, 10, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {0, 8, 3, 1, 2, 10, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {9, 2, 10, 0, 2, 9, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {2, 8, 3, 2, 10, 8, 10, 9, 8, -1, -1, -1, -1, -1, -1, -1}, + {3, 11, 2, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {0, 11, 2, 8, 11, 0, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {1, 9, 0, 2, 3, 11, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {1, 11, 2, 1, 9, 11, 9, 8, 11, -1, -1, -1, -1, -1, -1, -1}, + {3, 10, 1, 11, 10, 3, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {0, 10, 1, 0, 8, 10, 8, 11, 10, -1, -1, -1, -1, -1, -1, -1}, + {3, 9, 0, 3, 11, 9, 11, 10, 9, -1, -1, -1, -1, -1, -1, -1}, + {9, 8, 10, 10, 8, 11, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {4, 7, 8, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {4, 3, 0, 7, 3, 4, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {0, 1, 9, 8, 4, 7, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {4, 1, 9, 4, 7, 1, 7, 3, 1, -1, -1, -1, -1, -1, -1, -1}, + {1, 2, 10, 8, 4, 7, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {3, 4, 7, 3, 0, 4, 1, 2, 10, -1, -1, -1, -1, -1, -1, -1}, + {9, 2, 10, 9, 0, 2, 8, 4, 7, -1, -1, -1, -1, -1, -1, -1}, + {2, 10, 9, 2, 9, 7, 2, 7, 3, 7, 9, 4, -1, -1, -1, -1}, + {8, 4, 7, 3, 11, 2, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {11, 4, 7, 11, 2, 4, 2, 0, 4, -1, -1, -1, -1, -1, -1, -1}, + {9, 0, 1, 8, 4, 7, 2, 3, 11, -1, -1, -1, -1, -1, -1, -1}, + {4, 7, 11, 9, 4, 11, 9, 11, 2, 9, 2, 1, -1, -1, -1, -1}, + {3, 10, 1, 3, 11, 10, 7, 8, 4, -1, -1, -1, -1, -1, -1, -1}, + {1, 11, 10, 1, 4, 11, 1, 0, 4, 7, 11, 4, -1, -1, -1, -1}, + {4, 7, 8, 9, 0, 11, 9, 11, 10, 11, 0, 3, -1, -1, -1, -1}, + {4, 7, 11, 4, 11, 9, 9, 11, 10, -1, -1, -1, -1, -1, -1, -1}, + {9, 5, 4, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {9, 5, 4, 0, 8, 3, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {0, 5, 4, 1, 5, 0, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {8, 5, 4, 8, 3, 5, 3, 1, 5, -1, -1, -1, -1, -1, -1, -1}, + {1, 2, 10, 9, 5, 4, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {3, 0, 8, 1, 2, 10, 4, 9, 5, -1, -1, -1, -1, -1, -1, -1}, + {5, 2, 10, 5, 4, 2, 4, 0, 2, -1, -1, -1, -1, -1, -1, -1}, + {2, 10, 5, 3, 2, 5, 3, 5, 4, 3, 4, 8, -1, -1, -1, -1}, + {9, 5, 4, 2, 3, 11, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {0, 11, 2, 0, 8, 11, 4, 9, 5, -1, -1, -1, -1, -1, -1, -1}, + {0, 5, 4, 0, 1, 5, 2, 3, 11, -1, -1, -1, -1, -1, -1, -1}, + {2, 1, 5, 2, 5, 8, 2, 8, 11, 4, 8, 5, -1, -1, -1, -1}, + {10, 3, 11, 10, 1, 3, 9, 5, 4, -1, -1, -1, -1, -1, -1, -1}, + {4, 9, 5, 0, 8, 1, 8, 10, 1, 8, 11, 10, -1, -1, -1, -1}, + {5, 4, 0, 5, 0, 11, 5, 11, 10, 11, 0, 3, -1, -1, -1, -1}, + {5, 4, 8, 5, 8, 10, 10, 8, 11, -1, -1, -1, -1, -1, -1, -1}, + {9, 7, 8, 5, 7, 9, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {9, 3, 0, 9, 5, 3, 5, 7, 3, -1, -1, -1, -1, -1, -1, -1}, + {0, 7, 8, 0, 1, 7, 1, 5, 7, -1, -1, -1, -1, -1, -1, -1}, + {1, 5, 3, 3, 5, 7, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {9, 7, 8, 9, 5, 7, 10, 1, 2, -1, -1, -1, -1, -1, -1, -1}, + {10, 1, 2, 9, 5, 0, 5, 3, 0, 5, 7, 3, -1, -1, -1, -1}, + {8, 0, 2, 8, 2, 5, 8, 5, 7, 10, 5, 2, -1, -1, -1, -1}, + {2, 10, 5, 2, 5, 3, 3, 5, 7, -1, -1, -1, -1, -1, -1, -1}, + {7, 9, 5, 7, 8, 9, 3, 11, 2, -1, -1, -1, -1, -1, -1, -1}, + {9, 5, 7, 9, 7, 2, 9, 2, 0, 2, 7, 11, -1, -1, -1, -1}, + {2, 3, 11, 0, 1, 8, 1, 7, 8, 1, 5, 7, -1, -1, -1, -1}, + {11, 2, 1, 11, 1, 7, 7, 1, 5, -1, -1, -1, -1, -1, -1, -1}, + {9, 5, 8, 8, 5, 7, 10, 1, 3, 10, 3, 11, -1, -1, -1, -1}, + {5, 7, 0, 5, 0, 9, 7, 11, 0, 1, 0, 10, 11, 10, 0, -1}, + {11, 10, 0, 11, 0, 3, 10, 5, 0, 8, 0, 7, 5, 7, 0, -1}, + {11, 10, 5, 7, 11, 5, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {10, 6, 5, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {0, 8, 3, 5, 10, 6, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {9, 0, 1, 5, 10, 6, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {1, 8, 3, 1, 9, 8, 5, 10, 6, -1, -1, -1, -1, -1, -1, -1}, + {1, 6, 5, 2, 6, 1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {1, 6, 5, 1, 2, 6, 3, 0, 8, -1, -1, -1, -1, -1, -1, -1}, + {9, 6, 5, 9, 0, 6, 0, 2, 6, -1, -1, -1, -1, -1, -1, -1}, + {5, 9, 8, 5, 8, 2, 5, 2, 6, 3, 2, 8, -1, -1, -1, -1}, + {2, 3, 11, 10, 6, 5, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {11, 0, 8, 11, 2, 0, 10, 6, 5, -1, -1, -1, -1, -1, -1, -1}, + {0, 1, 9, 2, 3, 11, 5, 10, 6, -1, -1, -1, -1, -1, -1, -1}, + {5, 10, 6, 1, 9, 2, 9, 11, 2, 9, 8, 11, -1, -1, -1, -1}, + {6, 3, 11, 6, 5, 3, 5, 1, 3, -1, -1, -1, -1, -1, -1, -1}, + {0, 8, 11, 0, 11, 5, 0, 5, 1, 5, 11, 6, -1, -1, -1, -1}, + {3, 11, 6, 0, 3, 6, 0, 6, 5, 0, 5, 9, -1, -1, -1, -1}, + {6, 5, 9, 6, 9, 11, 11, 9, 8, -1, -1, -1, -1, -1, -1, -1}, + {5, 10, 6, 4, 7, 8, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {4, 3, 0, 4, 7, 3, 6, 5, 10, -1, -1, -1, -1, -1, -1, -1}, + {1, 9, 0, 5, 10, 6, 8, 4, 7, -1, -1, -1, -1, -1, -1, -1}, + {10, 6, 5, 1, 9, 7, 1, 7, 3, 7, 9, 4, -1, -1, -1, -1}, + {6, 1, 2, 6, 5, 1, 4, 7, 8, -1, -1, -1, -1, -1, -1, -1}, + {1, 2, 5, 5, 2, 6, 3, 0, 4, 3, 4, 7, -1, -1, -1, -1}, + {8, 4, 7, 9, 0, 5, 0, 6, 5, 0, 2, 6, -1, -1, -1, -1}, + {7, 3, 9, 7, 9, 4, 3, 2, 9, 5, 9, 6, 2, 6, 9, -1}, + {3, 11, 2, 7, 8, 4, 10, 6, 5, -1, -1, -1, -1, -1, -1, -1}, + {5, 10, 6, 4, 7, 2, 4, 2, 0, 2, 7, 11, -1, -1, -1, -1}, + {0, 1, 9, 4, 7, 8, 2, 3, 11, 5, 10, 6, -1, -1, -1, -1}, + {9, 2, 1, 9, 11, 2, 9, 4, 11, 7, 11, 4, 5, 10, 6, -1}, + {8, 4, 7, 3, 11, 5, 3, 5, 1, 5, 11, 6, -1, -1, -1, -1}, + {5, 1, 11, 5, 11, 6, 1, 0, 11, 7, 11, 4, 0, 4, 11, -1}, + {0, 5, 9, 0, 6, 5, 0, 3, 6, 11, 6, 3, 8, 4, 7, -1}, + {6, 5, 9, 6, 9, 11, 4, 7, 9, 7, 11, 9, -1, -1, -1, -1}, + {10, 4, 9, 6, 4, 10, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {4, 10, 6, 4, 9, 10, 0, 8, 3, -1, -1, -1, -1, -1, -1, -1}, + {10, 0, 1, 10, 6, 0, 6, 4, 0, -1, -1, -1, -1, -1, -1, -1}, + {8, 3, 1, 8, 1, 6, 8, 6, 4, 6, 1, 10, -1, -1, -1, -1}, + {1, 4, 9, 1, 2, 4, 2, 6, 4, -1, -1, -1, -1, -1, -1, -1}, + {3, 0, 8, 1, 2, 9, 2, 4, 9, 2, 6, 4, -1, -1, -1, -1}, + {0, 2, 4, 4, 2, 6, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {8, 3, 2, 8, 2, 4, 4, 2, 6, -1, -1, -1, -1, -1, -1, -1}, + {10, 4, 9, 10, 6, 4, 11, 2, 3, -1, -1, -1, -1, -1, -1, -1}, + {0, 8, 2, 2, 8, 11, 4, 9, 10, 4, 10, 6, -1, -1, -1, -1}, + {3, 11, 2, 0, 1, 6, 0, 6, 4, 6, 1, 10, -1, -1, -1, -1}, + {6, 4, 1, 6, 1, 10, 4, 8, 1, 2, 1, 11, 8, 11, 1, -1}, + {9, 6, 4, 9, 3, 6, 9, 1, 3, 11, 6, 3, -1, -1, -1, -1}, + {8, 11, 1, 8, 1, 0, 11, 6, 1, 9, 1, 4, 6, 4, 1, -1}, + {3, 11, 6, 3, 6, 0, 0, 6, 4, -1, -1, -1, -1, -1, -1, -1}, + {6, 4, 8, 11, 6, 8, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {7, 10, 6, 7, 8, 10, 8, 9, 10, -1, -1, -1, -1, -1, -1, -1}, + {0, 7, 3, 0, 10, 7, 0, 9, 10, 6, 7, 10, -1, -1, -1, -1}, + {10, 6, 7, 1, 10, 7, 1, 7, 8, 1, 8, 0, -1, -1, -1, -1}, + {10, 6, 7, 10, 7, 1, 1, 7, 3, -1, -1, -1, -1, -1, -1, -1}, + {1, 2, 6, 1, 6, 8, 1, 8, 9, 8, 6, 7, -1, -1, -1, -1}, + {2, 6, 9, 2, 9, 1, 6, 7, 9, 0, 9, 3, 7, 3, 9, -1}, + {7, 8, 0, 7, 0, 6, 6, 0, 2, -1, -1, -1, -1, -1, -1, -1}, + {7, 3, 2, 6, 7, 2, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {2, 3, 11, 10, 6, 8, 10, 8, 9, 8, 6, 7, -1, -1, -1, -1}, + {2, 0, 7, 2, 7, 11, 0, 9, 7, 6, 7, 10, 9, 10, 7, -1}, + {1, 8, 0, 1, 7, 8, 1, 10, 7, 6, 7, 10, 2, 3, 11, -1}, + {11, 2, 1, 11, 1, 7, 10, 6, 1, 6, 7, 1, -1, -1, -1, -1}, + {8, 9, 6, 8, 6, 7, 9, 1, 6, 11, 6, 3, 1, 3, 6, -1}, + {0, 9, 1, 11, 6, 7, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {7, 8, 0, 7, 0, 6, 3, 11, 0, 11, 6, 0, -1, -1, -1, -1}, + {7, 11, 6, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {7, 6, 11, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {3, 0, 8, 11, 7, 6, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {0, 1, 9, 11, 7, 6, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {8, 1, 9, 8, 3, 1, 11, 7, 6, -1, -1, -1, -1, -1, -1, -1}, + {10, 1, 2, 6, 11, 7, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {1, 2, 10, 3, 0, 8, 6, 11, 7, -1, -1, -1, -1, -1, -1, -1}, + {2, 9, 0, 2, 10, 9, 6, 11, 7, -1, -1, -1, -1, -1, -1, -1}, + {6, 11, 7, 2, 10, 3, 10, 8, 3, 10, 9, 8, -1, -1, -1, -1}, + {7, 2, 3, 6, 2, 7, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {7, 0, 8, 7, 6, 0, 6, 2, 0, -1, -1, -1, -1, -1, -1, -1}, + {2, 7, 6, 2, 3, 7, 0, 1, 9, -1, -1, -1, -1, -1, -1, -1}, + {1, 6, 2, 1, 8, 6, 1, 9, 8, 8, 7, 6, -1, -1, -1, -1}, + {10, 7, 6, 10, 1, 7, 1, 3, 7, -1, -1, -1, -1, -1, -1, -1}, + {10, 7, 6, 1, 7, 10, 1, 8, 7, 1, 0, 8, -1, -1, -1, -1}, + {0, 3, 7, 0, 7, 10, 0, 10, 9, 6, 10, 7, -1, -1, -1, -1}, + {7, 6, 10, 7, 10, 8, 8, 10, 9, -1, -1, -1, -1, -1, -1, -1}, + {6, 8, 4, 11, 8, 6, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {3, 6, 11, 3, 0, 6, 0, 4, 6, -1, -1, -1, -1, -1, -1, -1}, + {8, 6, 11, 8, 4, 6, 9, 0, 1, -1, -1, -1, -1, -1, -1, -1}, + {9, 4, 6, 9, 6, 3, 9, 3, 1, 11, 3, 6, -1, -1, -1, -1}, + {6, 8, 4, 6, 11, 8, 2, 10, 1, -1, -1, -1, -1, -1, -1, -1}, + {1, 2, 10, 3, 0, 11, 0, 6, 11, 0, 4, 6, -1, -1, -1, -1}, + {4, 11, 8, 4, 6, 11, 0, 2, 9, 2, 10, 9, -1, -1, -1, -1}, + {10, 9, 3, 10, 3, 2, 9, 4, 3, 11, 3, 6, 4, 6, 3, -1}, + {8, 2, 3, 8, 4, 2, 4, 6, 2, -1, -1, -1, -1, -1, -1, -1}, + {0, 4, 2, 4, 6, 2, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {1, 9, 0, 2, 3, 4, 2, 4, 6, 4, 3, 8, -1, -1, -1, -1}, + {1, 9, 4, 1, 4, 2, 2, 4, 6, -1, -1, -1, -1, -1, -1, -1}, + {8, 1, 3, 8, 6, 1, 8, 4, 6, 6, 10, 1, -1, -1, -1, -1}, + {10, 1, 0, 10, 0, 6, 6, 0, 4, -1, -1, -1, -1, -1, -1, -1}, + {4, 6, 3, 4, 3, 8, 6, 10, 3, 0, 3, 9, 10, 9, 3, -1}, + {10, 9, 4, 6, 10, 4, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {4, 9, 5, 7, 6, 11, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {0, 8, 3, 4, 9, 5, 11, 7, 6, -1, -1, -1, -1, -1, -1, -1}, + {5, 0, 1, 5, 4, 0, 7, 6, 11, -1, -1, -1, -1, -1, -1, -1}, + {11, 7, 6, 8, 3, 4, 3, 5, 4, 3, 1, 5, -1, -1, -1, -1}, + {9, 5, 4, 10, 1, 2, 7, 6, 11, -1, -1, -1, -1, -1, -1, -1}, + {6, 11, 7, 1, 2, 10, 0, 8, 3, 4, 9, 5, -1, -1, -1, -1}, + {7, 6, 11, 5, 4, 10, 4, 2, 10, 4, 0, 2, -1, -1, -1, -1}, + {3, 4, 8, 3, 5, 4, 3, 2, 5, 10, 5, 2, 11, 7, 6, -1}, + {7, 2, 3, 7, 6, 2, 5, 4, 9, -1, -1, -1, -1, -1, -1, -1}, + {9, 5, 4, 0, 8, 6, 0, 6, 2, 6, 8, 7, -1, -1, -1, -1}, + {3, 6, 2, 3, 7, 6, 1, 5, 0, 5, 4, 0, -1, -1, -1, -1}, + {6, 2, 8, 6, 8, 7, 2, 1, 8, 4, 8, 5, 1, 5, 8, -1}, + {9, 5, 4, 10, 1, 6, 1, 7, 6, 1, 3, 7, -1, -1, -1, -1}, + {1, 6, 10, 1, 7, 6, 1, 0, 7, 8, 7, 0, 9, 5, 4, -1}, + {4, 0, 10, 4, 10, 5, 0, 3, 10, 6, 10, 7, 3, 7, 10, -1}, + {7, 6, 10, 7, 10, 8, 5, 4, 10, 4, 8, 10, -1, -1, -1, -1}, + {6, 9, 5, 6, 11, 9, 11, 8, 9, -1, -1, -1, -1, -1, -1, -1}, + {3, 6, 11, 0, 6, 3, 0, 5, 6, 0, 9, 5, -1, -1, -1, -1}, + {0, 11, 8, 0, 5, 11, 0, 1, 5, 5, 6, 11, -1, -1, -1, -1}, + {6, 11, 3, 6, 3, 5, 5, 3, 1, -1, -1, -1, -1, -1, -1, -1}, + {1, 2, 10, 9, 5, 11, 9, 11, 8, 11, 5, 6, -1, -1, -1, -1}, + {0, 11, 3, 0, 6, 11, 0, 9, 6, 5, 6, 9, 1, 2, 10, -1}, + {11, 8, 5, 11, 5, 6, 8, 0, 5, 10, 5, 2, 0, 2, 5, -1}, + {6, 11, 3, 6, 3, 5, 2, 10, 3, 10, 5, 3, -1, -1, -1, -1}, + {5, 8, 9, 5, 2, 8, 5, 6, 2, 3, 8, 2, -1, -1, -1, -1}, + {9, 5, 6, 9, 6, 0, 0, 6, 2, -1, -1, -1, -1, -1, -1, -1}, + {1, 5, 8, 1, 8, 0, 5, 6, 8, 3, 8, 2, 6, 2, 8, -1}, + {1, 5, 6, 2, 1, 6, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {1, 3, 6, 1, 6, 10, 3, 8, 6, 5, 6, 9, 8, 9, 6, -1}, + {10, 1, 0, 10, 0, 6, 9, 5, 0, 5, 6, 0, -1, -1, -1, -1}, + {0, 3, 8, 5, 6, 10, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {10, 5, 6, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {11, 5, 10, 7, 5, 11, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {11, 5, 10, 11, 7, 5, 8, 3, 0, -1, -1, -1, -1, -1, -1, -1}, + {5, 11, 7, 5, 10, 11, 1, 9, 0, -1, -1, -1, -1, -1, -1, -1}, + {10, 7, 5, 10, 11, 7, 9, 8, 1, 8, 3, 1, -1, -1, -1, -1}, + {11, 1, 2, 11, 7, 1, 7, 5, 1, -1, -1, -1, -1, -1, -1, -1}, + {0, 8, 3, 1, 2, 7, 1, 7, 5, 7, 2, 11, -1, -1, -1, -1}, + {9, 7, 5, 9, 2, 7, 9, 0, 2, 2, 11, 7, -1, -1, -1, -1}, + {7, 5, 2, 7, 2, 11, 5, 9, 2, 3, 2, 8, 9, 8, 2, -1}, + {2, 5, 10, 2, 3, 5, 3, 7, 5, -1, -1, -1, -1, -1, -1, -1}, + {8, 2, 0, 8, 5, 2, 8, 7, 5, 10, 2, 5, -1, -1, -1, -1}, + {9, 0, 1, 5, 10, 3, 5, 3, 7, 3, 10, 2, -1, -1, -1, -1}, + {9, 8, 2, 9, 2, 1, 8, 7, 2, 10, 2, 5, 7, 5, 2, -1}, + {1, 3, 5, 3, 7, 5, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {0, 8, 7, 0, 7, 1, 1, 7, 5, -1, -1, -1, -1, -1, -1, -1}, + {9, 0, 3, 9, 3, 5, 5, 3, 7, -1, -1, -1, -1, -1, -1, -1}, + {9, 8, 7, 5, 9, 7, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {5, 8, 4, 5, 10, 8, 10, 11, 8, -1, -1, -1, -1, -1, -1, -1}, + {5, 0, 4, 5, 11, 0, 5, 10, 11, 11, 3, 0, -1, -1, -1, -1}, + {0, 1, 9, 8, 4, 10, 8, 10, 11, 10, 4, 5, -1, -1, -1, -1}, + {10, 11, 4, 10, 4, 5, 11, 3, 4, 9, 4, 1, 3, 1, 4, -1}, + {2, 5, 1, 2, 8, 5, 2, 11, 8, 4, 5, 8, -1, -1, -1, -1}, + {0, 4, 11, 0, 11, 3, 4, 5, 11, 2, 11, 1, 5, 1, 11, -1}, + {0, 2, 5, 0, 5, 9, 2, 11, 5, 4, 5, 8, 11, 8, 5, -1}, + {9, 4, 5, 2, 11, 3, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {2, 5, 10, 3, 5, 2, 3, 4, 5, 3, 8, 4, -1, -1, -1, -1}, + {5, 10, 2, 5, 2, 4, 4, 2, 0, -1, -1, -1, -1, -1, -1, -1}, + {3, 10, 2, 3, 5, 10, 3, 8, 5, 4, 5, 8, 0, 1, 9, -1}, + {5, 10, 2, 5, 2, 4, 1, 9, 2, 9, 4, 2, -1, -1, -1, -1}, + {8, 4, 5, 8, 5, 3, 3, 5, 1, -1, -1, -1, -1, -1, -1, -1}, + {0, 4, 5, 1, 0, 5, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {8, 4, 5, 8, 5, 3, 9, 0, 5, 0, 3, 5, -1, -1, -1, -1}, + {9, 4, 5, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {4, 11, 7, 4, 9, 11, 9, 10, 11, -1, -1, -1, -1, -1, -1, -1}, + {0, 8, 3, 4, 9, 7, 9, 11, 7, 9, 10, 11, -1, -1, -1, -1}, + {1, 10, 11, 1, 11, 4, 1, 4, 0, 7, 4, 11, -1, -1, -1, -1}, + {3, 1, 4, 3, 4, 8, 1, 10, 4, 7, 4, 11, 10, 11, 4, -1}, + {4, 11, 7, 9, 11, 4, 9, 2, 11, 9, 1, 2, -1, -1, -1, -1}, + {9, 7, 4, 9, 11, 7, 9, 1, 11, 2, 11, 1, 0, 8, 3, -1}, + {11, 7, 4, 11, 4, 2, 2, 4, 0, -1, -1, -1, -1, -1, -1, -1}, + {11, 7, 4, 11, 4, 2, 8, 3, 4, 3, 2, 4, -1, -1, -1, -1}, + {2, 9, 10, 2, 7, 9, 2, 3, 7, 7, 4, 9, -1, -1, -1, -1}, + {9, 10, 7, 9, 7, 4, 10, 2, 7, 8, 7, 0, 2, 0, 7, -1}, + {3, 7, 10, 3, 10, 2, 7, 4, 10, 1, 10, 0, 4, 0, 10, -1}, + {1, 10, 2, 8, 7, 4, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {4, 9, 1, 4, 1, 7, 7, 1, 3, -1, -1, -1, -1, -1, -1, -1}, + {4, 9, 1, 4, 1, 7, 0, 8, 1, 8, 7, 1, -1, -1, -1, -1}, + {4, 0, 3, 7, 4, 3, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {4, 8, 7, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {9, 10, 8, 10, 11, 8, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {3, 0, 9, 3, 9, 11, 11, 9, 10, -1, -1, -1, -1, -1, -1, -1}, + {0, 1, 10, 0, 10, 8, 8, 10, 11, -1, -1, -1, -1, -1, -1, -1}, + {3, 1, 10, 11, 3, 10, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {1, 2, 11, 1, 11, 9, 9, 11, 8, -1, -1, -1, -1, -1, -1, -1}, + {3, 0, 9, 3, 9, 11, 1, 2, 9, 2, 11, 9, -1, -1, -1, -1}, + {0, 2, 11, 8, 0, 11, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {3, 2, 11, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {2, 3, 8, 2, 8, 10, 10, 8, 9, -1, -1, -1, -1, -1, -1, -1}, + {9, 10, 2, 0, 9, 2, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {2, 3, 8, 2, 8, 10, 0, 1, 8, 1, 10, 8, -1, -1, -1, -1}, + {1, 10, 2, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {1, 3, 8, 9, 1, 8, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {0, 9, 1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {0, 3, 8, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1} + }; + + /// + /// The local offsets of the 8 corners of a single voxel cube. + /// + public static readonly Vector3[] CornerOffsets = new Vector3[8] { + new Vector3(0, 0, 0), new Vector3(1, 0, 0), new Vector3(1, 0, 1), new Vector3(0, 0, 1), + new Vector3(0, 1, 0), new Vector3(1, 1, 0), new Vector3(1, 1, 1), new Vector3(0, 1, 1) + }; + + /// + /// Defines which two corners make up each of the 12 edges. + /// + public static readonly int[,] EdgeConnections = new int[12, 2] { + {0,1}, {1,2}, {2,3}, {3,0}, // Bottom face edges + {4,5}, {5,6}, {6,7}, {7,4}, // Top face edges + {0,4}, {1,5}, {2,6}, {3,7} // Vertical pillars + }; + + /// + /// Maps block IDs to vertex colors for the generated mesh. You can customize this to match your BiomePalette or block types! + /// + /// + /// + private static Color GetBlockColor(byte blockID) + { + // These map your BiomePalette selections to actual visual colors! + if (blockID == BlockRegistry.SAND) return new Color(0.8f, 0.7f, 0.4f); + if (blockID == BlockRegistry.SNOW) return new Color(0.9f, 0.9f, 0.9f); + if (blockID == BlockRegistry.STONE) return new Color(0.4f, 0.4f, 0.4f); + if (blockID == BlockRegistry.WASTELAND_DIRT) return new Color(0.35f, 0.25f, 0.25f); + if (blockID == BlockRegistry.ASPHALT) return new Color(0.15f, 0.15f, 0.15f); + + // Grass variations + if (blockID == BlockRegistry.GRASS_JUNGLE) return new Color(0.1f, 0.4f, 0.1f); + if (blockID == BlockRegistry.GRASS_PARADISE) return new Color(0.4f, 0.8f, 0.4f); + if (blockID == BlockRegistry.GRASS_TROPICAL) return new Color(0.2f, 0.6f, 0.2f); + + // Default Dirt + return new Color(0.4f, 0.3f, 0.15f); + } + + /// + /// A deterministic, integer-based key for our Dictionary welder. + /// + private struct EdgeKey : IEquatable + { + public Vector3I P1, P2; + public EdgeKey(Vector3I p1, Vector3I p2) + { + // Always order them from lowest to highest so neighboring chunks generate the exact same key! + if (p1.X < p2.X || (p1.X == p2.X && p1.Y < p2.Y) || (p1.X == p2.X && p1.Y == p2.Y && p1.Z < p2.Z)) { + P1 = p1; P2 = p2; + } else { + P1 = p2; P2 = p1; + } + } + public bool Equals(EdgeKey other) => P1.Equals(other.P1) && P2.Equals(other.P2); + public override int GetHashCode() => HashCode.Combine(P1, P2); + } + + public static Vector3 VertexInterp(float isolevel, Vector3 p1, Vector3 p2, float valp1, float valp2) + { + if (Mathf.Abs(isolevel - valp1) < 0.00001f) return p1; + if (Mathf.Abs(isolevel - valp2) < 0.00001f) return p2; + if (Mathf.Abs(valp1 - valp2) < 0.00001f) return p1; + + float mu = (isolevel - valp1) / (valp2 - valp1); + + return new Vector3( + p1.X + mu * (p2.X - p1.X), + p1.Y + mu * (p2.Y - p1.Y), + p1.Z + mu * (p2.Z - p1.Z) + ); + } + /// + /// Calculates the exact 3D slope (gradient) of the terrain at a specific grid coordinate. + /// + public static Vector3 GetDensityGradient(float[,,] field, int x, int y, int z) + { + // Clamp to valid array indices so we don't crash when checking the chunk borders + int x0 = Math.Max(0, x - 1), x1 = Math.Min(field.GetLength(0) - 1, x + 1); + int y0 = Math.Max(0, y - 1), y1 = Math.Min(field.GetLength(1) - 1, y + 1); + int z0 = Math.Max(0, z - 1), z1 = Math.Min(field.GetLength(2) - 1, z + 1); + + // Calculate the difference in density across the X, Y, and Z axes + float gx = field[x1, y, z] - field[x0, y, z]; + float gy = field[x, y1, z] - field[x, y0, z]; + float gz = field[x, y, z1] - field[x, y, z0]; + + return new Vector3(gx, gy, gz).Normalized(); + } + + public static ArrayMesh GenerateMesh(float[,,] scalarField, byte[,,] blockIDs, float isolevel = 0.0f, float voxelScale = 1.0f) + { + var st = new SurfaceTool(); + st.Begin(Mesh.PrimitiveType.Triangles); + st.SetSmoothGroup(1); + + int width = scalarField.GetLength(0) - 1; + int height = scalarField.GetLength(1) - 1; + int depth = scalarField.GetLength(2) - 1; + + float[] cornerDensities = new float[8]; + int[] edgeVertexIndices = new int[12]; // We now store the integer index, not the raw Vector3! + + // The bulletproof integer welder + Dictionary vertexCache = new Dictionary(); + int currentIndex = 0; + int totalIndicesDrawn = 0; // For our diagnostic ratio + + for (int x = 0; x < width; x++) + { + for (int y = 0; y < height; y++) + { + for (int z = 0; z < depth; z++) + { + cornerDensities[0] = scalarField[x, y, z]; + cornerDensities[1] = scalarField[x + 1, y, z]; + cornerDensities[2] = scalarField[x + 1, y, z + 1]; + cornerDensities[3] = scalarField[x, y, z + 1]; + cornerDensities[4] = scalarField[x, y + 1, z]; + cornerDensities[5] = scalarField[x + 1, y + 1, z]; + cornerDensities[6] = scalarField[x + 1, y + 1, z + 1]; + cornerDensities[7] = scalarField[x, y + 1, z + 1]; + + int cubeIndex = 0; + if (cornerDensities[0] < isolevel) cubeIndex |= 1; + if (cornerDensities[1] < isolevel) cubeIndex |= 2; + if (cornerDensities[2] < isolevel) cubeIndex |= 4; + if (cornerDensities[3] < isolevel) cubeIndex |= 8; + if (cornerDensities[4] < isolevel) cubeIndex |= 16; + if (cornerDensities[5] < isolevel) cubeIndex |= 32; + if (cornerDensities[6] < isolevel) cubeIndex |= 64; + if (cornerDensities[7] < isolevel) cubeIndex |= 128; + + if (EdgeTable[cubeIndex] == 0) continue; + + Vector3 basePos = new Vector3(x, y, z) * voxelScale; + + // Calculate the 12 edges + for (int i = 0; i < 12; i++) + { + if ((EdgeTable[cubeIndex] & (1 << i)) != 0) + { + int cornerA = EdgeConnections[i, 0]; + int cornerB = EdgeConnections[i, 1]; + + // Create the flawless integer key + Vector3I gridPosA = new Vector3I(x + (int)CornerOffsets[cornerA].X, y + (int)CornerOffsets[cornerA].Y, z + (int)CornerOffsets[cornerA].Z); + Vector3I gridPosB = new Vector3I(x + (int)CornerOffsets[cornerB].X, y + (int)CornerOffsets[cornerB].Y, z + (int)CornerOffsets[cornerB].Z); + EdgeKey key = new EdgeKey(gridPosA, gridPosB); + + // If this edge hasn't been drawn by a neighbor yet, calculate it and cache it! + if (!vertexCache.TryGetValue(key, out int vertIdx)) + { + // 1. Calculate precise position + Vector3 exactPos = basePos + VertexInterp(isolevel, CornerOffsets[cornerA] * voxelScale, CornerOffsets[cornerB] * voxelScale, cornerDensities[cornerA], cornerDensities[cornerB]); + + // 2. ANALYTICAL NORMALS: Calculate the exact 3D slope at both corners + Vector3 gradA = GetDensityGradient(scalarField, x + (int)CornerOffsets[cornerA].X, y + (int)CornerOffsets[cornerA].Y, z + (int)CornerOffsets[cornerA].Z); + Vector3 gradB = GetDensityGradient(scalarField, x + (int)CornerOffsets[cornerB].X, y + (int)CornerOffsets[cornerB].Y, z + (int)CornerOffsets[cornerB].Z); + + // 3. Interpolate the normal using the exact same fraction used for the position + float valA = cornerDensities[cornerA]; + float valB = cornerDensities[cornerB]; + float mu = (isolevel - valA) / (valB - valA); + Vector3 exactNormal = (gradA + mu * (gradB - gradA)).Normalized(); + + // 4. THE COLOR FIX: Grab the BlockID from CornerA and paint the vertex! + // (We just reuse the gridPosA we already declared above the if-statement!) + byte blockID = blockIDs[gridPosA.X, gridPosA.Y, gridPosA.Z]; + + st.SetColor(GetBlockColor(blockID)); + + // 5. Feed the perfect normal AND the position to Godot! + // (SetColor and SetNormal MUST be called immediately before AddVertex) + st.SetNormal(exactNormal); + st.AddVertex(exactPos); + + // 6. Cache it so neighbors don't redraw it! + vertIdx = currentIndex++; + vertexCache[key] = vertIdx; + } + + // Save the index for this specific cube to use in the TriTable + edgeVertexIndices[i] = vertIdx; + } + } + + // Connect the cached indices! + for (int i = 0; TriTable[cubeIndex, i] != -1; i += 3) + { + st.AddIndex(edgeVertexIndices[TriTable[cubeIndex, i + 2]]); + st.AddIndex(edgeVertexIndices[TriTable[cubeIndex, i + 1]]); + st.AddIndex(edgeVertexIndices[TriTable[cubeIndex, i]]); + totalIndicesDrawn += 3; + } + } + } + } + + // Diagnostic Print + // GD.Print($"[MarchingCubes] Vertices: {currentIndex}, Indices: {totalIndicesDrawn}, Ratio: {(float)totalIndicesDrawn / Math.Max(1, currentIndex):F2}"); + + return st.Commit(); + } + } + +} \ No newline at end of file diff --git a/Core/Scripts/MarchingCubes.cs.uid b/Core/Scripts/MarchingCubes.cs.uid new file mode 100644 index 0000000..2ac4f79 --- /dev/null +++ b/Core/Scripts/MarchingCubes.cs.uid @@ -0,0 +1 @@ +uid://4p2co3ctt0is diff --git a/Core/Scripts/README.md b/Core/Scripts/README.md new file mode 100644 index 0000000..3d33fb7 --- /dev/null +++ b/Core/Scripts/README.md @@ -0,0 +1,46 @@ +# πŸ“œ Core Scripts Directory - Isla Apocalypse + +## Overview +The `/Core/Scripts` directory is the dedicated code repository for all pure C# data structures, universal enumerations, and static mathematical utilities. Everything in this folder is compiled into the shared assembly used by both the authoritative `/Server` and the rendering `/Client`. + +**Core Philosophy: "Pure Data, Zero State."** +Scripts in this folder define *what* things are and *how* to calculate them, but they never remember *current* game events. They do not track who is online, what chunks are loaded, or what time of day it is. They are stateless, universal blueprints. + +--- + +## 🧩 Current Systems (Phase 2) + +### 1. The Voxel Palette System +* **`BlockData.cs`**: The fundamental `struct` defining the properties of a single voxel (ID, Name, IsSolid, BaseColor). +* **`BlockRegistry.cs`**: The static master dictionary. Contains the hardcoded IDs for every block in the game (Air, Bedrock, Dirt, Asphalt) and provides a safe lookup method (`GetBlock`) for both the mesher and the physics engine. +* **`BiomePalette.cs`**: The translation layer between 2D and 3D. Contains the logic that dictates the vertical stacking of blocks based on biome and depth (e.g., ensuring mountains have stone beneath snow, and roads are topped with asphalt). + +### 2. Universal Identifiers +* **`Enums.cs`**: The master repository for global enums (`Biome`, `TownTier`, `MapHalf`). By keeping these here, the offline Map Generator, the Server, and the Client are guaranteed to use the exact same integer values for biomes and POIs. + +--- + +## πŸš€ Future Roadmap & Planned Additions (Phase 2 & 3) + +As we build the 3D Chunk Manager and multiplayer networking, this folder will expand to include: + +### 3. The Blueprint Parser +* **`MapDataParser.cs`** *(Next Step)*: A static utility to open and decode the `MapData_Seed_[X].dat` binary file into usable C# arrays for the Server to ingest. + +### 4. Chunk Data Structures +* **`ChunkData.cs`**: The raw 3D array (`byte[,,]`) that holds the voxel IDs for a specific 16x16x64 area. +* *Note:* This structure will include the `IsPlayerProtected` boolean flag to support the "Land Claim" delta-backup system, preventing player bases from being wiped out by chunk corruption. + +### 5. Math & Coordinate Utilities +* **`VoxelMath.cs`**: Static helper functions for translating massive 3D World Coordinates into local Chunk Coordinates (e.g., finding out which specific chunk file to load when a player walks to X: 5000, Z: -200). + +### 6. Network Packet Definitions +* Structs that define the exact byte layout of multiplayer messages (e.g., `PlayerDigPacket`, `ChatPacket`) to ensure precise Server/Client synchronization. + +--- + +## ⚠️ Directory Rules & Best Practices + +1. **No Godot Node Inheritance:** Scripts here should rarely, if ever, inherit from `Node` or `Node3D`. They are pure C# classes, structs, or static utilities. +2. **No `_Process` or `_Ready`:** Because these are not attached to active objects in the game world, they do not use Godot's frame-by-frame loop functions. +3. **Strictly Independent:** A script in `/Core/Scripts` cannot reference anything in `/Client` or `/Server`. The dependency flows one way: the Client and Server look *in* to the Core; the Core never looks *out*. \ No newline at end of file diff --git a/Data/README.md b/Data/README.md new file mode 100644 index 0000000..b06c2cb --- /dev/null +++ b/Data/README.md @@ -0,0 +1,49 @@ +# πŸ—„οΈ Data Directory - Isla Apocalypse + +## Overview +The `/Data` directory is the static repository for all generated world files, visual previews, and configuration structures. It acts as the handoff point between the offline `/Tools` (which write the data) and the live `/Server` (which reads the data). + +**Core Philosophy: "The Immutable Source of Truth."** +Files stored in this primary directory are considered *Base Blueprints*. They are the starting point of the world before any player interacts with it. The Game Client should almost never read from this folder directly; it receives this data via the Server. + +--- + +## πŸ“¦ Current Contents (Phase 1) + +### 1. Binary Blueprints (`.dat`) +* **Format:** Custom serialized binary (`MapData_Seed_[X].dat`). +* **Purpose:** Contains the highly compressed, raw mathematical output of the 2D Generator (Map Size, Height floats, Biome IDs, POI coordinates, Highway vectors). +* **Usage:** Read by the Server's Chunk Manager at startup to construct the untouched 3D voxel world. + +### 2. Map Previews (`.png`) +* **Format:** 1024x1024 RGBA Images (`Map_Seed_[X]_[Time].png`). +* **Purpose:** High-resolution visual snapshots of the generated seeds. +* **Usage:** Used by the developer/server admin to visually review and select the best island layout before spinning up the 3D server. + +--- + +## πŸš€ Future Roadmap & Planned Additions + +As the game expands, this folder will be subdivided to organize different types of static game data: + +### 3. /Prefabs (Planned - Phase 2/3) +* **Format:** `.json` or custom `.prefab` binaries. +* **Purpose:** 3D building templates (e.g., `GasStation_Tier3.json`) created by the Prefab Editor. The Server uses these files to spawn structures at the coordinates dictated by the master `.dat` blueprint. + +### 4. /Configs (Planned - Phase 3) +* **Format:** `.json` or `.cfg`. +* **Purpose:** Human-readable configuration files for game balance. + * `LootTables.json`: Defines what items spawn in which containers based on POI Tier. + * `VoxelPalette.json`: Maps the 2D Biome IDs to 3D Voxel types. + * `SpawnRates.json`: Defines zombie/animal spawn limits and Blood Moon horde scaling. + +--- + +## ⚠️ Directory Rules & Best Practices + +1. **NO Scripts or Code:** This directory must strictly contain data files (`.dat`, `.png`, `.json`, `.csv`). It contains zero executable logic. +2. **Live Saves vs. Base Data:** * This folder holds the **Base Game Data**. + * *Live Player Saves* and *Terrain Deformation Diff Files* should generally be saved to Godot's safe `user://` path on the host machine, NOT in this project folder. This prevents live server saves from accidentally being bundled into the game's base code. +3. **Version Control (Git) Warnings:** + * `.dat` and `.png` files can become very large. + * **Rule:** Only commit the "Official Release" seed files to your repository. Use a `.gitignore` file to ignore random test generations so you don't bloat your project repository with gigabytes of discarded test maps. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..6cccda1 --- /dev/null +++ b/README.md @@ -0,0 +1,28 @@ +# IslaApocalypse - Voxel Engine Architecture + +**Current Version:** v0.0.1 (Phase 2 - Voxel Engine Online, v0.0.x Prototyping / Tech Demo) +**Engine:** Godot 4 (C#) + +## Project Overview +IslaApocalypse is a procedurally generated, multiplayer-ready survival/driving game. The engine uses a two-phase generation system: first building a massive 4096x4096 2D topographical blueprint, and then dynamically translating that data into a 3D Marching Cubes voxel world at runtime. + +## Development State +* **Phase 1: 2D Blueprint Engine** -> **[COMPLETE]** +* **Phase 2: 3D Voxel Engine** -> **[IN PROGRESS - Basic Rendering Online]** + +### Current Capabilities (What works right now): +1. **The 2D Blueprint (`MapGenerator.cs`):** Generates a 4096x4096 map using `FastNoiseLite`. It features dynamic sea levels, a massive impact crater, tiered city placements (Capitol, Hubs, Villages), and a heavily optimized A* pathfinding network (Highways, Branch Roads, Trails) that utilizes Chaikin's smoothing algorithm. +2. **Binary Serialization:** Exports the entire 16-million pixel array and path vectors into a lightweight, byte-aligned `.dat` file. +3. **The Data Bridge (`MapDataParser.cs`):** Seamlessly deserializes the `.dat` file back into RAM as a `WorldBlueprint` for the server to read. +4. **Authoritative Chunk Management (`ServerChunkManager.cs`):** Dynamically locates the Capitol City, converts pixel coordinates to 3D chunk coordinates, and generates a grid of `ChunkData` objects (32x32x256 meters) around the player. +5. **Marching Cubes Rendering (`MarchingCubes.cs`):** Translates 2D heightmaps into 3D geometry. Features analytical normals for smooth lighting and dynamically paints Vertex Colors based on the 2D `BiomeMap` (Wasteland, Sand, Grass, Stone). + +## Directory Architecture +* `/Tools/`: Contains the v1.0 2D Map Generator and preview scenes. Run `MapCaptureTool.tscn` to generate a new world seed. +* `/Core/`: The math and data foundation. Contains the Binary Parser, Marching Cubes algorithm, Biome Palettes, and `ChunkData` structs. +* `/Server/`: The Authoritative Server logic. Manages chunk loading, calculates voxel densities, and handles chunk culling. +* `/Client/`: The visual layer. Takes raw `ChunkData` and translates it into Godot `MeshInstance3D` objects with vertex-colored materials. + +## Next Immediate Steps (Phase 2, Step 4) +* **Road Carving:** Inject A* road vector data into the `ServerChunkManager` to override the natural heightmap, physically flattening the 3D terrain and painting Asphalt blocks to create drivable highways through the mountains. +* **Chunk Seams:** Implement normal-blending across chunk borders to remove grid-line lighting artifacts. diff --git a/Resources/README.md b/Resources/README.md new file mode 100644 index 0000000..21b56bd --- /dev/null +++ b/Resources/README.md @@ -0,0 +1,49 @@ +# πŸ“¦ Resources Directory - Isla Apocalypse + +## Overview +The `/Resources` directory acts as the game's central library for all static assets and Godot-specific data containers (primarily `.tres` files, 3D models, textures, and audio). + +**Core Philosophy: "The Reusable Building Blocks."** +If the Server is the brain and the Client is the eyes, Resources are the memories. They define the *properties* of things in the world. If there are 50 zombies on screen, you don't want 50 copies of their texture and max health data in your RAM. Instead, all 50 zombies reference a single `ZombieBase.tres` resource. + +--- + +## 🧩 Core Systems (Phase 2 & 3) + +### 1. Voxel & Block Definitions +* **Format:** Custom `.tres` files based on a `BlockData` C# script (located in `/Core`). +* **Function:** Defines the properties of every voxel in the game. +* **Examples:** `Dirt.tres` (Texture: brown_dirt.png, Durability: 100, StepSound: dirt_crunch.ogg), `Asphalt.tres` (Texture: road.png, Durability: 500, StepSound: hard_step.ogg). Both the Client and Server read these to know how a block looks and behaves. + +### 2. Item & Inventory Data +* **Format:** Custom `.tres` files for `ItemData`. +* **Function:** Defines every item a player can hold, craft, or drop. +* **Examples:** `StoneAxe.tres` (Icon: axe.png, Damage: 15, BlockDamage: 40, MaxStack: 1). The Server uses this to calculate damage; the Client uses this to draw the icon in your hotbar. + +### 3. Audio & Visual Assets +* **Materials & Shaders:** The `.material` and `.gdshader` files used by the Client's chunk mesher (e.g., `TerrainMaterial.tres`). +* **3D Models:** The `.glb` or `.gltf` files for weapons, dropped items, and entities. +* **Audio Streams:** The `.wav` or `.ogg` files for ambient wind, UI clicks, and zombie attacks. + +### 4. UI Themes & Fonts +* Godot `Theme` resources that dictate the colors, fonts, and border styles of the entire user interface, ensuring the inventory menus match the main menu. + +--- + +## πŸš€ Directory Organization Strategy + +To prevent this folder from turning into a massive junk drawer, it must be strictly subdivided: + +* `/Resources/Blocks/` +* `/Resources/Items/` +* `/Resources/Models/` +* `/Resources/Audio/` +* `/Resources/UI/` + +--- + +## ⚠️ Directory Rules & Best Practices + +1. **Text Over Binary:** Always save custom Godot resources as `.tres` (Text Resource) rather than `.res` (Binary Resource). Text resources can be read by version control (Git), allowing you to see exactly when someone changed the damage of a Stone Axe from 15 to 20. +2. **Stateless Data Only:** A Resource should never track live game state. For example, `Pistol.tres` holds the *Max Ammo* capacity (15), but it should NEVER hold the *Current Ammo* (7). Current ammo is a live state tracked by the Server. +3. **Shared Access:** Because Resources are just data, they are completely safe to be referenced by both the `/Server` and the `/Client`. \ No newline at end of file diff --git a/Scenes/Main.tscn b/Scenes/Main.tscn new file mode 100644 index 0000000..1631ce9 --- /dev/null +++ b/Scenes/Main.tscn @@ -0,0 +1,17 @@ +[gd_scene format=3 uid="uid://pkacw81eqg5w"] + +[ext_resource type="Script" uid="uid://c1dxqbgohxr6k" path="res://Server/Scripts/ServerChunkManager.cs" id="1_r150o"] + +[sub_resource type="Environment" id="Environment_r150o"] +ambient_light_color = Color(0.37245482, 0.5677467, 1, 1) + +[node name="World" type="Node3D" unique_id=412138339] +script = ExtResource("1_r150o") + +[node name="DirectionalLight3D" type="DirectionalLight3D" parent="." unique_id=1569534216] + +[node name="Camera3D" type="Camera3D" parent="." unique_id=1064925728] +transform = Transform3D(1, 0, 0, 0, 0.49999997, -0.86602545, 0, 0.86602545, 0.49999997, 2559.9224, 640.90497, -171.46881) + +[node name="WorldEnvironment" type="WorldEnvironment" parent="." unique_id=1968388012] +environment = SubResource("Environment_r150o") diff --git a/Scenes/MapCaptureTool.tscn b/Scenes/MapCaptureTool.tscn new file mode 100644 index 0000000..4f76871 --- /dev/null +++ b/Scenes/MapCaptureTool.tscn @@ -0,0 +1,13 @@ +[gd_scene format=3 uid="uid://cu28aj74lxhjn"] + +[ext_resource type="PackedScene" uid="uid://croq62eckpnvx" path="res://Tools/Scenes/MapPreview.tscn" id="1_gvjeg"] + +[node name="MapCaptureTool" type="SubViewportContainer" unique_id=69177420] +custom_minimum_size = Vector2(4096, 4096) +offset_right = 40.0 +offset_bottom = 40.0 + +[node name="SubViewport" type="SubViewport" parent="." unique_id=1249553404] +size = Vector2i(4096, 4096) + +[node name="MapPreview" parent="SubViewport" unique_id=971733442 instance=ExtResource("1_gvjeg")] diff --git a/Scenes/README.md b/Scenes/README.md new file mode 100644 index 0000000..d80cdf1 --- /dev/null +++ b/Scenes/README.md @@ -0,0 +1,37 @@ +# 🌍 /Scenes - The Playable Game + +## Overview +This directory contains the core runtime scenes for *Isla Apocalypse*. Unlike the `/Tools` directory, the scenes here are exactly what the end-user (or the Dedicated Server) will run. + +**Core Philosophy:** These scenes do **not** generate the world from scratch. They strictly load and interpret the immutable `MapData.dat` blueprint provided by the developers. This ensures lightning-fast server boot times and complete parity between the server and the clients. + +--- + +## πŸ—οΈ Core Scenes + +### `Main.tscn` (The Entry Point) +**Status:** In Transition (Prepping for 1:1 Scale Ingestion) + +This is the master root scene. Currently, it acts as the Dedicated Server environment, responsible for reading the 2D blueprint and translating it into a physical 3D voxel world using the `ServerChunkManager`. + +#### βš”οΈ The Great 4096 Struggle (The 3D Ramifications) +Our decision to upgrade the master blueprint from 1024x1024 to a massive 4096x4096 (1:1 scale) fundamentally changed how `Main.tscn` must operate. + +While the 2D generator struggled with math and UI layouts, the 3D server must now deal with **Memory Management**. +* **The Scale Shift:** In the old 1024 version, 1 data pixel equaled 4 meters of 3D space. Now, 1 pixel = 1 meter. This perfectly accommodates human-scale base building (like *7 Days to Die*), but it means the total data footprint of the world is 16 times larger. +* **The Chunking Mandate:** `Main.tscn` can no longer afford to load the entire map into RAM at startup. The `ServerChunkManager` must operate strictly on a localized, dynamic gridβ€”only loading the 1-meter chunks immediately surrounding active players. + +#### πŸ›‘οΈ The Arsenal is Ready +Despite the massive scale increase, `Main.tscn` is structurally prepared for the 4096 blueprint thanks to heavy engine optimizations built during Phase 1: +1. **The EdgeKey Welder:** Completely eliminates floating-point drift and mesh seams across the new high-density chunk borders. +2. **Analytical Normals:** Bypasses Godot's slow native normal calculation, using mathematical gradients to light the massive 1:1 voxel world instantly. +3. **The +1 Border Fix:** Chunk arrays are padded by exactly 1 unit to flawlessly sample neighbor data without requiring expensive cross-chunk blurring. + +--- + +## πŸš€ The Immediate Roadmap +When Phase 2 resumes, `Main.tscn` will undergo the following upgrades to ingest the new 4096 map: + +1. **Re-syncing the Parser:** Uncommenting the Logistical Network logic in `MapDataParser.cs` so the server can read the newly fixed roads and towns. +2. **Dynamic Chunk Loading:** Scrapping the hardcoded `10x10` test grid in favor of a proximity-based chunk loader. +3. **Terrain Flattening:** Implementing logic for the voxel engine to read the road and town data and automatically flatten the 3D terrain beneath them. \ No newline at end of file diff --git a/Server/README.md b/Server/README.md new file mode 100644 index 0000000..e30a8df --- /dev/null +++ b/Server/README.md @@ -0,0 +1,22 @@ +# Server Module - IslaApocalypse + +**Phase 2 Status:** Active (Authoritative Chunk Management Online) + +## Overview +The `Server` directory contains the authoritative logic for the 3D Voxel Engine. It acts as the bridge between the static 2D `WorldBlueprint` residing in RAM and the physical 3D environment generated around the player. This architecture ensures that in a future multiplayer environment, the server dictates the terrain and sends chunk data to the clients. + +## Key Architecture & Components + +### 1. The Authoritative Node (`ServerChunkManager.cs`) +Attached to the root of the `Main.tscn` world scene, this script manages the lifecycle of the entire 3D environment. +* **Initialization:** Upon boot, it reads the `WorldBlueprint` and dynamically locates the exact X/Y pixel coordinates of the `Capitol` city to use as the initial world spawn point. +* **Coordinate Translation:** Converts 2D pixel coordinates into 3D chunk grid coordinates (e.g., Map Pixel [2048, 2048] becomes Chunk [64, 64]). +* **Memory Management:** Maintains an `_activeChunks` dictionary to track which chunks are currently loaded in RAM, preventing duplicate generation and allowing for future chunk culling/unloading when the player moves away. + +### 2. Terrain Math & Generation +* **True Vertical Scaling:** Height calculation directly multiplies the raw `FastNoiseLite` float (0.0 to 1.0) against `Constants.CHUNK_HEIGHT` (256 meters). This avoids heightmap compression and allows for towering, AAA-scale mountain cliffs. +* **Density Calculation:** Uses exact surface interpolation to calculate the signed 3D distance of every voxel relative to the surface. This prevents terrain collapsing on horizontal edges and creates mathematically perfect slopes for the Marching Cubes algorithm. +* **Biome Injection:** Samples the `BiomeMap` at the exact X/Z world coordinate to assign a specific `BlockID` (via `BiomePalette`) to every voxel in the chunk. + +## Next Immediate Steps (Phase 2, Step 4) +* **A* Road Carving:** The Server Chunk Manager needs to be updated to cross-reference the `Highways` and `BranchRoads` vectors from the blueprint. If a chunk contains a road, the server must mathematically flatten the `exactSurfaceY` and override the `BlockID` to `ASPHALT`. \ No newline at end of file diff --git a/Server/Scripts/ServerChunkManager.cs b/Server/Scripts/ServerChunkManager.cs new file mode 100644 index 0000000..e29bb46 --- /dev/null +++ b/Server/Scripts/ServerChunkManager.cs @@ -0,0 +1,225 @@ +using Godot; +using System.Collections.Generic; +using IslaApocalypse.Core; + +namespace IslaApocalypse.Server +{ + public partial class ServerChunkManager : Node + { + private WorldBlueprint _blueprint; + private Dictionary _activeChunks = new Dictionary(); + public int chunkSize = 24; + + 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) + { + 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 + + + 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)); + } + } + + // 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; + +// --- NEW: SPATIAL CULLING FOR ROADS --- + // Create a bounding box for this chunk, plus a 15-meter padding to account for the road's dirt shoulders + Rect2 chunkBounds = new Rect2(startX - 15, startZ - 15, Constants.CHUNK_SIZE_X + 30, Constants.CHUNK_SIZE_Z + 30); + List localRoadSegments = new List(); + + // Filter Highways: Only save the segments that actually cross this specific chunk! + foreach (var highway in _blueprint.Highways) { + for (int i = 0; i < highway.Length - 1; i++) { + Vector2 a = highway[i]; + Vector2 b = highway[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 Vector2[] { a, b }); + } + } + } + // Filter Branch Roads + foreach (var branch in _blueprint.BranchRoads) { + for (int i = 0; i < branch.Length - 1; i++) { + Vector2 a = branch[i]; + Vector2 b = branch[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 Vector2[] { a, b }); + } + } + } + // --------------------------------------- + + 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 bool isRoad) + { + isRoad = false; + 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; + float minDist = 9999f; + Vector2 currentPos = new Vector2(gX, gZ); + + float roadRadius = 4.0f; // The flat asphalt part (8 meters total width) + float shoulderRadius = 12.0f; // The sloped dirt/rock carving into the mountain + + float closestRoadElevation = baseHeight; + + foreach (var seg in localRoadSegments) + { + float dist = DistanceToLineSegment(currentPos, seg[0], seg[1]); + if (dist < minDist) + { + minDist = dist; + if (dist <= shoulderRadius) + { + // Sample the heightmap precisely at the center of the road segment + // so the whole road stays at a uniform elevation, ignoring the sloped mountain under it. + Vector2 midPoint = (seg[0] + seg[1]) / 2.0f; + float roadRaw = _blueprint.HeightMap[(int)midPoint.X, (int)midPoint.Y]; + closestRoadElevation = Mathf.Clamp(roadRaw * (Constants.CHUNK_HEIGHT - 5), 2.0f, Constants.CHUNK_HEIGHT - 2.0f); + } + } + } + + // THE BULLDOZER: Carve the terrain + if (minDist <= roadRadius) { + finalHeight = closestRoadElevation; // Flatten it completely! + isRoad = true; + } else if (minDist <= shoulderRadius) { + // Smoothly interpolate from the flat road up/down to the natural mountain height + float t = (minDist - roadRadius) / (shoulderRadius - roadRadius); + t = t * t * (3f - 2f * t); // SmoothStep equation for a beautiful curved slope + finalHeight = Mathf.Lerp(closestRoadElevation, baseHeight, t); + } + + return finalHeight; + } + + // Pass the boolean out for the main voxel so we can paint it! + bool isMainRoad = false; + float exactSurfaceY = GetExactSurface(globalX, globalZ, out isMainRoad); + + // 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]; + + 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 isMainRoad into the Biome Palette! + newChunk.BlockIDs[x, y, z] = BiomePalette.GetVoxelID(columnBiome, blockY, y, isMainRoad); + } + } // End of Z loop + } // End of X loop + + _activeChunks.Add(chunkCoord, newChunk); + + var renderer = new IslaApocalypse.Client.ChunkRenderer(); + AddChild(renderer); + renderer.RenderChunk(newChunk); + } + + /// + /// 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) + { + float l2 = v.DistanceSquaredTo(w); + if (l2 == 0) return point.DistanceTo(v); // v == w case + + // 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 + float 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); + } + + } +} diff --git a/Server/Scripts/ServerChunkManager.cs.uid b/Server/Scripts/ServerChunkManager.cs.uid new file mode 100644 index 0000000..307fe1e --- /dev/null +++ b/Server/Scripts/ServerChunkManager.cs.uid @@ -0,0 +1 @@ +uid://c1dxqbgohxr6k diff --git a/ServerConfig.json b/ServerConfig.json new file mode 100644 index 0000000..90d629a --- /dev/null +++ b/ServerConfig.json @@ -0,0 +1,6 @@ +{ + "WorldSeed": 1063685222, + "MapProfile": "8K", + "TownDensity": "Normal", + "ChunkRadius": 32 +} diff --git a/Tools/README.md b/Tools/README.md new file mode 100644 index 0000000..a5b3498 --- /dev/null +++ b/Tools/README.md @@ -0,0 +1,52 @@ +# 🧰 Tools Directory - Isla Apocalypse + +## Overview +The `/Tools` directory houses all offline developer utilities, administrative scenes, and generation scripts. + +**Core Philosophy:** Nothing in this folder is shipped to the end-user. These tools are used exclusively by the developers or server admins to generate the immutable "Source Data" (blueprints, prefabs, configs) that the `/Server` and `/Client` will later ingest. By keeping this logic completely isolated, we ensure the game client remains lightweight and secure from reverse-engineered world-generation exploits. + +--- + +## πŸ› οΈ Current Tools + +### 1. The 2D World Generator (`MapCaptureTool.tscn` & `MapPreview.tscn`) +**Status:** Active / Upgraded to 1:1 Scale Architecture + +This tool handles the heavy mathematical lifting of procedural generation. It creates the topographical and logistical foundation of the island before any 3D rendering occurs. + +* **Primary Script:** `MapGenerator.cs` +* **Resolution:** 4096 x 4096 pixels (1:1 Scale. 1 Pixel = 1 In-Game Meter / 1 Voxel footprint). +* **Capture Architecture:** Uses a dynamic, C#-driven offscreen `SubViewport` and a custom `MapDrawProxy`. This bypasses Godot's UI layout engine, allowing the generation of massive, high-resolution maps regardless of the developer's physical monitor size. +* **Key Sub-Systems:** + * *Topography:* Uses `FastNoiseLite` and distance-falloff math to guarantee a mainland island shape with cold/mountainous northern regions. + * *Logistics:* A* Pathfinding that generates looping coastal highways, connecting branch roads, and rugged mountain trails. Pathing is dynamically scaled to the map resolution. + * *Zoning:* Biome mapping and hierarchical Town/POI placement based on slope, temperature, and proximity. + +**Outputs (Saved to `user://`):** +1. **The Blueprint (`MapData_Seed_[X].dat`):** A heavily compressed binary file containing the map size, heightmap floats, biome enums, road vectors, and POI coordinates. This is the master file read by the Dedicated Server. +2. **The Snapshot (`Map_Seed_[X].png`):** A high-resolution 1:1 visual render of the map layout (including all drawn logistical routes) for admin review and seed selection. Timestamps have been removed to prevent directory bloating. + +--- + +## πŸš€ Future Roadmap & Planned Additions + +As we move into Phase 2 (3D Voxels) and Phase 3 (Gameplay), this directory will expand to include the following standalone utilities: + +### 2. The Prefab Editor (Planned - Phase 3) +A standalone 3D sandbox scene used to build custom Points of Interest (POIs) block-by-block (e.g., houses, bunkers, gas stations). +* **Function:** Allows the developer to build structures visually and define "Loot Container" or "Zombie Spawn" block locations. +* **Output:** Generates lightweight `.prefab` or `.json` files that the Server's Chunk Manager reads to spawn buildings at the coordinates dictated by the `MapData.dat` file. + +### 3. Voxel Palette Manager (Planned - Phase 2) +A data-entry tool or inspector script to map 2D Biome Enums to 3D Voxel IDs. +* **Function:** Defines that `Biome.Jungle` at `Height 0.5` should spawn "Voxel ID 12 (Jungle Grass)", while `Height 0.2` should spawn "Voxel ID 4 (Dirt)". + +### 4. Loot & Balance Tweaker (Planned - Phase 3) +A simple UI tool to adjust spawn weights and loot tables without having to dig through massive JSON files manually. +* **Output:** Bakes final configuration files for the Server to use when rolling loot table RNG. + +--- + +## ⚠️ Directory Rules +1. **No Client Dependencies:** Scripts in this folder must *never* reference UI, Player Controllers, or Shaders located in the `/Client` folder. +2. **Data Agnostic:** Tools here should output raw data (`.dat`, `.json`, `.png`). They should not directly modify the live server's save state to avoid corruption. \ No newline at end of file diff --git a/Tools/Scenes/MapPreview.tscn b/Tools/Scenes/MapPreview.tscn new file mode 100644 index 0000000..b5bf254 --- /dev/null +++ b/Tools/Scenes/MapPreview.tscn @@ -0,0 +1,7 @@ +[gd_scene format=3 uid="uid://croq62eckpnvx"] + +[ext_resource type="Script" uid="uid://bl6pk71fngm8p" path="res://Tools/Scripts/MapGenerator.cs" id="1_56pfr"] + +[node name="MapPreview" type="TextureRect" unique_id=971733442] +custom_minimum_size = Vector2(4096, 4096) +script = ExtResource("1_56pfr") diff --git a/Tools/Scenes/README.md b/Tools/Scenes/README.md new file mode 100644 index 0000000..1b5407c --- /dev/null +++ b/Tools/Scenes/README.md @@ -0,0 +1,47 @@ +# 🎬 /Tools/Scenes - Developer Environments + +## Overview +This directory contains the standalone Godot scenes (`.tscn` files) used to execute our offline generation scripts. These scenes act as isolated "sandboxes" for the developers to build the world data. + +**CRITICAL RULE:** These scenes must **never** be added to the main game's build export or scene tree. They are run strictly inside the Godot Editor by pressing **F6 (Play Current Scene)**. + +--- + +## πŸ—οΈ Core Scenes + +### 1. `MapPreview.tscn` +The original 2D map generation scene. +* **Root Node:** `TextureRect` +* **Attached Script:** `MapGenerator.cs` +* **Function:** Runs the FastNoiseLite math, builds the `_heightMap`, and executes the `_Draw()` commands to visually plot the A* road networks and town circles. + +### 2. `MapCaptureTool.tscn` (The Wrapper) +The master scene used to actually execute and capture the map generation. +* **Root Node:** `SubViewportContainer` +* **Child Nodes:** `SubViewport` -> `MapPreview` +* **Function:** Acts as a massive, invisible "virtual monitor" to capture the high-resolution PNG of the map without the Godot UI layout engine interfering. + +--- + +## βš”οΈ The Great 4096 Struggle (Beating the UI Engine) + +When we upgraded *Isla Apocalypse* from a 1024 to a 4096 (1:1 scale) map, we encountered a massive rendering roadblock. Godot's UI Layout Engine is designed to make things fit on a player's physical monitor. + +When we asked `MapPreview` to render a 4096x4096 texture, Godot panicked. It saw that the developer's monitor was only 1080p, so it violently crushed the map down to fit the screen, resulting in exported PNGs that were either 1KB postage stamps or completely black. + +#### The "Virtual Monitor" Solution +To bypass the UI engine, we had to build `MapCaptureTool.tscn`. +1. **The Container Trap:** We initially used a `SubViewportContainer` with "Stretch" enabled. This was a trap. It locked the resolution to the editor window. We had to forcefully disable `Stretch` in the C# code. +2. **The `SubViewport`:** We placed `MapPreview` inside a `SubViewport` set explicitly to 4096x4096. This acts as an offscreen virtual monitor that doesn't care about the physical screen size. +3. **The `MapDrawProxy`:** We realized that `Texture.GetImage()` only grabs the base pixels, completely ignoring the colored roads and towns drawn by `_Draw()`. We had to dynamically instantiate a `Control` proxy node to stamp the roads onto the virtual monitor *before* taking the picture. +4. **The Flash Timing:** Generating a 4096 map takes time. We had to implement `await ToSignal(GetTree(), "process_frame");` to let Godot unlock the scene tree, and `await ToSignal(RenderingServer... FramePostDraw)` to force the camera to wait until the GPU physically finished painting the roads before snapping the photo. + +--- + +## πŸš€ How to Generate a New World + +1. Open `MapCaptureTool.tscn` in the Godot Editor. +2. Ensure you do **not** click the main Play button. +3. Press **F6** (Play Current Scene). +4. The editor will freeze for 10-30 seconds while the `ExportMapData` function writes the binary `.dat` file. +5. Once the console prints the success message, check your `user://` folder for the massive, full-resolution `.png` and the `.dat` blueprint. \ No newline at end of file diff --git a/Tools/Scripts/MapGenerator.cs b/Tools/Scripts/MapGenerator.cs new file mode 100644 index 0000000..9cd2f17 --- /dev/null +++ b/Tools/Scripts/MapGenerator.cs @@ -0,0 +1,975 @@ +using Godot; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using IslaApocalypse.Core; + + +public partial class MapGenerator : TextureRect +{ + public int MapSize = 4096; + [Export] public float FalloffStrength = 1.0f; // [Export] adds to the "inspector" in Godot so we can easily tweak this value! + + public struct TownData + { + public Vector2 Position; + public TownTier Tier; + public bool IsHighwayNode; + } + + private FastNoiseLite _noise; // The one and only original detailed noise! + private Vector2 _impactCenter; + private float _impactRadius; + + private float[,] _heightMap; + private float[,] _tempMap; + private Biome[,] _biomeMap; + private bool[,] _isTrueOcean; + private bool[,] _isMainland; + internal List _towns = new List(); + + internal List _highwayPaths = new List(); + internal List _branchPaths = new List(); + internal List _ruggedPaths = new List(); + internal List _trailPaths = new List(); + + public override async void _Ready() + { + // 1. LOAD CONFIGURATION + ConfigManager.LoadConfig(); + MapSize = ConfigManager.MapSize; + _impactRadius = ConfigManager.CraterRadius; + + // LOCK THE GLOBAL RNG TO OUR SEED! + GD.Seed((ulong)ConfigManager.WorldSeed); + + // 2. OVERRIDE GODOT INSPECTOR + this.CustomMinimumSize = new Vector2(MapSize, MapSize); + + _heightMap = new float[MapSize, MapSize]; + _tempMap = new float[MapSize, MapSize]; + _biomeMap = new Biome[MapSize, MapSize]; + _isTrueOcean = new bool[MapSize, MapSize]; + _isMainland = new bool[MapSize, MapSize]; + + float scaleFactor = MapSize / 1024f; // Equals 4 + + // --- MASTER SEED & DETAILED NOISE (Restored!) --- + _noise = new FastNoiseLite(); + _noise.Seed = ConfigManager.WorldSeed == 0 ? (int)GD.Randi() : ConfigManager.WorldSeed; + _noise.NoiseType = FastNoiseLite.NoiseTypeEnum.Simplex; + _noise.Frequency = 0.004f / scaleFactor; + + // THE CRATER FIX: Push it into the ocean (scales via percentage of MapSize!) + // Supposedly! We will have to test this manually on other map sizes to confirm the crater is properly scaled and submerged on the north coast! + float randomX = (float)GD.RandRange(0.35f, 0.65f); + float randomY = (float)GD.RandRange(0.05f, 0.12f); + _impactCenter = new Vector2(MapSize * randomX, MapSize * randomY); + + GenerateTopography(); + CalculateTrueOcean(); + CalculateMainland(); + AssignBiomesAndDraw(); + GenerateTowns(); + + // NEW: We await the roads so the engine doesn't freeze! + await GenerateRoadsAsync(); + + ExportMapData(); + SaveMapSnapshot(); + } + + private async void SaveMapSnapshot() + { + // 1. THE FIX: Wait exactly one frame to let Godot finish _Ready() and unlock the Scene Tree! + await ToSignal(GetTree(), "process_frame"); + + GD.Print("Starting offscreen map capture..."); + + // 2. Build the invisible, offscreen 4096 monitor + var offscreenVP = new SubViewport(); + offscreenVP.Size = new Vector2I(MapSize, MapSize); + offscreenVP.RenderTargetUpdateMode = SubViewport.UpdateMode.Always; + offscreenVP.TransparentBg = false; + GetTree().Root.AddChild(offscreenVP); // The tree is now unlocked, so this is safe! + + // 3. Stamp the raw terrain texture onto it + var bgRect = new TextureRect(); + bgRect.Texture = this.Texture; + bgRect.CustomMinimumSize = new Vector2(MapSize, MapSize); + offscreenVP.AddChild(bgRect); + + // 4. Attach the proxy to draw the roads and towns on top + var drawProxy = new MapDrawProxy(); + drawProxy.Source = this; + drawProxy.CustomMinimumSize = new Vector2(MapSize, MapSize); + offscreenVP.AddChild(drawProxy); + + // 5. WAIT FOR THE GPU! (Wait 2 render frames to guarantee the framebuffer is painted) + await ToSignal(RenderingServer.Singleton, RenderingServer.SignalName.FramePostDraw); + await ToSignal(RenderingServer.Singleton, RenderingServer.SignalName.FramePostDraw); + + // 6. Pull the image from the invisible monitor and save it! + Image capture = offscreenVP.GetTexture().GetImage(); + string seedStr = _noise.Seed.ToString(); + string fileName = $"user://Map_Seed_{seedStr}.png"; + + Error saveResult = capture.SavePng(fileName); + + if (saveResult == Error.Ok) + GD.Print($"Map saved successfully to: {ProjectSettings.GlobalizePath(fileName)}"); + else + GD.PrintErr($"Failed to save map. Godot Error code: {saveResult}"); + + // 7. Delete the invisible monitor to free up RAM + offscreenVP.QueueFree(); + } + + private void ExportMapData() + { + string seedStr = _noise.Seed.ToString(); + string filePath = $"user://MapData_Seed_{seedStr}.dat"; + + using (FileStream stream = File.Open(ProjectSettings.GlobalizePath(filePath), FileMode.Create)) + { + using (BinaryWriter writer = new BinaryWriter(stream)) + { + // 1. Header & Versioning + writer.Write("ISLA_V1"); + writer.Write(MapSize); + + // 2. RAW DATA (Height & Temp) + for (int x = 0; x < MapSize; x++) { + for (int y = 0; y < MapSize; y++) { + writer.Write(_heightMap[x, y]); + writer.Write((int)_biomeMap[x, y]); + } + } + + // 3. LOGISTICAL ANCHORS (Towns & POIs) + writer.Write(_towns.Count); + foreach (var town in _towns) { + writer.Write(town.Position.X); + writer.Write(town.Position.Y); + writer.Write((int)town.Tier); + } + + // 4. ROAD NETWORKS (Exported by Tier for Voxel Painting!) + + // Highway (Paved) + writer.Write(_highwayPaths.Count); + foreach (var path in _highwayPaths) { + writer.Write(path.Length); + foreach (Vector2 p in path) { writer.Write(p.X); writer.Write(p.Y); } + } + + // Branch (Paved/Gravel) + writer.Write(_branchPaths.Count); + foreach (var path in _branchPaths) { + writer.Write(path.Length); + foreach (Vector2 p in path) { writer.Write(p.X); writer.Write(p.Y); } + } + + // Rugged (Dirt) + writer.Write(_ruggedPaths.Count); + foreach (var path in _ruggedPaths) { + writer.Write(path.Length); + foreach (Vector2 p in path) { writer.Write(p.X); writer.Write(p.Y); } + } + + // Trails (Faded Dirt) + writer.Write(_trailPaths.Count); + foreach (var path in _trailPaths) { + writer.Write(path.Length); + foreach (Vector2 p in path) { writer.Write(p.X); writer.Write(p.Y); } + } + } + } + GD.Print("Binary Data Exported to: " + ProjectSettings.GlobalizePath(filePath)); + } + + // --- THE ORIGINAL MAP GENERATION CODE (Restored!) --- + private void GenerateTopography() + { + Vector2 center = new Vector2(MapSize / 2.0f, MapSize / 2.0f); + for (int x = 0; x < MapSize; x++) + { + for (int y = 0; y < MapSize; y++) + { + // --- 1. TEMPERATURE --- + float temperature = (float)y / MapSize; + float tempNoise = (_noise.GetNoise2D(x + 1000, y + 1000) + 1.0f) / 2.0f; + temperature += (tempNoise * 0.2f) - 0.1f; + _tempMap[x, y] = temperature; + + // --- 2. THE ORIGINAL ISLAND FALLOFF --- + float nx = Mathf.Abs(x - center.X) / (MapSize / 2.0f * 1.15f); + float ny = Mathf.Abs(y - center.Y) / (MapSize / 2.0f * 0.90f); + float squircleFalloff = Mathf.Max(nx, ny); + + Vector2 ellipticalPos = new Vector2((x - center.X) / 1.15f, (y - center.Y) / 0.90f); + float ellipticalFalloff = ellipticalPos.Length() / (MapSize / 1.3f); + + float finalFalloff = Mathf.Lerp(ellipticalFalloff, squircleFalloff, 0.5f); + + // The beautiful jagged edge noise! + float edgeNoise = (_noise.GetNoise2D(x * 2.5f, y * 2.5f) + 1.0f) / 2.0f; + finalFalloff += (edgeNoise * 0.15f) * squircleFalloff; + + // --- NEW: THE SOUTHERN ARCHIPELAGO FIX --- + // If we are in the bottom 25% of the map, apply extra sinking pressure + float southThreshold = MapSize * 0.75f; + if (y > southThreshold) + { + float southDepth = (y - southThreshold) / (MapSize - southThreshold); + finalFalloff += southDepth * 0.6f; // Sink the stretched land bridges! + } + + finalFalloff = Mathf.Pow(finalFalloff, 2.5f); + + float distX = Mathf.Abs(x - center.X) / (MapSize / 2.0f); + float distY = Mathf.Abs(y - center.Y) / (MapSize / 2.0f); + if (distX > 0.90f) finalFalloff += (distX - 0.90f) * 15.0f; + if (distY > 0.90f) finalFalloff += (distY - 0.90f) * 15.0f; + + // --- 3. THE MOUNTAIN SPINE --- + float mountainSpine = 0f; + if (temperature < 0.65f) + { + float distanceToCenterX = Mathf.Abs(x - center.X) / (MapSize / 2.0f * 1.15f); + mountainSpine = 1.0f - distanceToCenterX; + float southernFade = Mathf.Clamp((0.65f - temperature) * 4.0f, 0.0f, 1.0f); + mountainSpine = Mathf.Pow(mountainSpine, 3.0f) * southernFade * 0.6f; + } + + // --- 4. COMBINE HEIGHT --- + float rawBase = (_noise.GetNoise2D(x, y) + 1.0f) / 2.0f; + float finalH = rawBase + mountainSpine - (finalFalloff * FalloffStrength); + + + // --- 5. CARVE THE CRATER (The Flooded Bay & Landbridge Fix!) --- + float distToCrater = new Vector2(x, y).DistanceTo(_impactCenter); + + // We only carve the physical hole at 80% of the radius to guarantee a landbridge! + float physicalCraterRadius = _impactRadius * 0.80f; + if (distToCrater < physicalCraterRadius) + { + float craterDepth = 1.0f - (distToCrater / physicalCraterRadius); + // Dialed back to -0.15f as per your excellent instinct! + finalH = Mathf.Lerp(finalH, GetSeaLevel(temperature) - 0.15f, craterDepth * 0.9f); + } + + _heightMap[x, y] = finalH; + } + } + } + + private void CalculateTrueOcean() + { + Queue queue = new Queue(); + queue.Enqueue(new Vector2I(0, 0)); + _isTrueOcean[0, 0] = true; + + Vector2I[] directions = { Vector2I.Up, Vector2I.Down, Vector2I.Left, Vector2I.Right }; + + while (queue.Count > 0) + { + Vector2I current = queue.Dequeue(); + foreach (var dir in directions) + { + Vector2I neighbor = current + dir; + if (neighbor.X >= 0 && neighbor.X < MapSize && neighbor.Y >= 0 && neighbor.Y < MapSize) + { + if (!_isTrueOcean[neighbor.X, neighbor.Y] && _heightMap[neighbor.X, neighbor.Y] < GetSeaLevel(_tempMap[neighbor.X, neighbor.Y])) + { + _isTrueOcean[neighbor.X, neighbor.Y] = true; + queue.Enqueue(neighbor); + } + } + } + } + } + + private void CalculateMainland() + { + Queue queue = new Queue(); + Vector2I center = new Vector2I(MapSize / 2, MapSize / 2); + + if (_heightMap[center.X, center.Y] >= GetSeaLevel(_tempMap[center.X, center.Y])) + { + queue.Enqueue(center); + _isMainland[center.X, center.Y] = true; + } + + Vector2I[] directions = { Vector2I.Up, Vector2I.Down, Vector2I.Left, Vector2I.Right }; + + while (queue.Count > 0) + { + Vector2I current = queue.Dequeue(); + foreach (var dir in directions) + { + Vector2I neighbor = current + dir; + if (neighbor.X >= 0 && neighbor.X < MapSize && neighbor.Y >= 0 && neighbor.Y < MapSize) + { + if (!_isMainland[neighbor.X, neighbor.Y] && _heightMap[neighbor.X, neighbor.Y] >= GetSeaLevel(_tempMap[neighbor.X, neighbor.Y])) + { + _isMainland[neighbor.X, neighbor.Y] = true; + queue.Enqueue(neighbor); + } + } + } + } + } + + private void AssignBiomesAndDraw() + { + Image mapImage = Image.CreateEmpty(MapSize, MapSize, false, Image.Format.Rgba8); + for (int x = 0; x < MapSize; x++) + { + for (int y = 0; y < MapSize; y++) + { + float h = _heightMap[x, y]; + float t = _tempMap[x, y]; + Biome b; + + float currentSeaLevel = GetSeaLevel(t); + + if (h < currentSeaLevel) b = _isTrueOcean[x, y] ? Biome.Ocean : Biome.Lake; + else + { + float baseDist = new Vector2(x, y).DistanceTo(_impactCenter); + float craterNoise = _noise.GetNoise2D(x * 2.5f, y * 2.5f) * 25.0f; + float warpedDist = baseDist + craterNoise; + + if (warpedDist < _impactRadius * 0.5f) b = Biome.Crater; + else + { + float beachThickness = Mathf.Lerp(0.04f, 0.10f, Mathf.Clamp(t, 0f, 1f)); + if (h < currentSeaLevel + beachThickness) b = Biome.Beach; + else + { + float snowDetail = (_noise.GetNoise2D(x * 5.0f, y * 5.0f) + 1.0f) / 2.0f; + float wastelandFray = _noise.GetNoise2D(x * 1.5f + 5000, y * 1.5f + 5000) * 60.0f; // Add a separate, jagged noise layer just for the wasteland boundary + if (h > 0.88f || (h > 0.80f && snowDetail > 0.6f)) b = Biome.Snow; + else if (h > 0.72f) b = Biome.Mountain; + else if (warpedDist < _impactRadius + wastelandFray + 20.0f) b = Biome.Wasteland; + else if (warpedDist < _impactRadius * 1.35f || t < 0.35f) b = Biome.Jungle; + else if (t > 0.65f) b = Biome.Paradise; + else b = Biome.Tropical; + } + } + } + + _biomeMap[x, y] = b; + + Color c = Colors.Black; + switch(b) { + case Biome.Ocean: c = new Color(0.1f, 0.4f, 0.7f); break; + case Biome.Lake: c = new Color(0.2f, 0.6f, 0.8f); break; + case Biome.Beach: c = new Color(0.8f, 0.7f, 0.4f); break; + case Biome.Snow: c = new Color(0.9f, 0.9f, 0.9f); break; + case Biome.Mountain: c = new Color(0.5f, 0.5f, 0.5f); break; + case Biome.Crater: c = new Color(0.3f, 0.1f, 0.3f); break; + case Biome.Wasteland: c = new Color(0.5f, 0.2f, 0.5f); break; + case Biome.Jungle: c = new Color(0.1f, 0.4f, 0.1f); break; + case Biome.Paradise: c = new Color(0.4f, 0.8f, 0.4f); break; + case Biome.Tropical: c = new Color(0.2f, 0.6f, 0.2f); break; + } + mapImage.SetPixel(x, y, c); + } + } + Texture = ImageTexture.CreateFromImage(mapImage); + } + + private void GenerateTowns() + { + _towns.Clear(); + + // 1. Calculate Multipliers (Used ONLY for lower-tier towns now!) + float sizeRatio = MapSize / 4096f; + float areaMulti = Mathf.Max(1.0f, sizeRatio * sizeRatio); + float densityMulti = ConfigManager.DensityMultiplier; + + int Calc(int baseCount) { + return Mathf.Max(1, Mathf.RoundToInt(baseCount * areaMulti * densityMulti)); + } + + // --- 2. THE HIGHWAY ANCHORS (Strictly 1 per biome, ignoring multipliers) --- + PlaceCapitol(); // Capitol is automatically a Highway Node + + bool southIsLeft = GD.Randi() % 2 == 0; + MapHalf southHalf = southIsLeft ? MapHalf.Left : MapHalf.Right; + MapHalf midHalf = southIsLeft ? MapHalf.Right : MapHalf.Left; + + // Spawn exactly 1 Hub per green biome + PlaceTownNodes(1, TownTier.Hub, Biome.Paradise, false, true, true, 0.04f, southHalf); + PlaceTownNodes(1, TownTier.Hub, Biome.Tropical, false, true, true, 0.04f, midHalf, true, false, 0.40f, 0.60f); + PlaceTownNodes(1, TownTier.Hub, Biome.Jungle, true, false, false, 0.04f, MapHalf.Any); + + // Safety check: Force all currently spawned Hubs to be active Highway Nodes + for (int i = 0; i < _towns.Count; i++) { + var t = _towns[i]; + t.IsHighwayNode = true; + _towns[i] = t; + } + + // --- 3. THE MOUNTAIN BOSS (Strictly 1, Center Biased, NOT a Highway Node) --- + int beforeSnowCount = _towns.Count; + + // We use `true` for the 9th parameter (centerBias) to push it deep into the frozen mountains + PlaceTownNodes(1, TownTier.Hub, Biome.Snow, true, false, false, 0.15f, MapHalf.Any, true); + + // Safety check: Force the Snow Hub to be excluded from the Highway Loop + for (int i = beforeSnowCount; i < _towns.Count; i++) { + var t = _towns[i]; + t.IsHighwayNode = false; + _towns[i] = t; + } + + // --- 4. THE RUGGED NETWORK & POIs (Scaled by Config Multipliers) --- + PlaceTownNodes(Calc(1), TownTier.Village, Biome.Paradise, false, true, false, 0.04f, MapHalf.Any); + PlaceTownNodes(Calc(2), TownTier.Village, Biome.Paradise, true, false, false, 0.04f, MapHalf.Any); + PlaceTownNodes(Calc(1), TownTier.Village, Biome.Tropical, false, true, false, 0.04f, MapHalf.Any); + PlaceTownNodes(Calc(3), TownTier.Village, Biome.Tropical, true, false, false, 0.04f, MapHalf.Any); + PlaceTownNodes(Calc(2), TownTier.Village, Biome.Jungle, true, false, false, 0.05f, MapHalf.Any); + + PlaceTownNodes(Calc(1), TownTier.Village, Biome.Snow, true, false, false, 0.05f, MapHalf.Any); + PlaceTownNodes(Calc(2), TownTier.Outpost, Biome.Snow, true, false, false, 0.12f, MapHalf.Any); + PlaceTownNodes(Calc(1), TownTier.Outpost, Biome.Mountain, true, false, false, 0.15f, MapHalf.Any); + + PlaceTownNodes(Calc(1), TownTier.IslandLoot, Biome.Paradise, false, false, false, 0.08f, MapHalf.Any, false, true); + PlaceTownNodes(Calc(1), TownTier.IslandLoot, Biome.Tropical, false, false, false, 0.08f, MapHalf.Any, false, true); + + PlaceTownNodes(Calc(3), TownTier.MiniPOI, Biome.Jungle, true, false, false, 0.1f, MapHalf.Any); + PlaceTownNodes(Calc(3), TownTier.MiniPOI, Biome.Tropical, true, false, false, 0.1f, MapHalf.Any); + PlaceTownNodes(Calc(2), TownTier.MiniPOI, Biome.Paradise, true, false, false, 0.1f, MapHalf.Any); + + QueueRedraw(); + } + + private void PlaceCapitol() // This method is a bit more complex than the others because of the unique requirements for the Capitol's location. We want it to be in the Wasteland biome, on the coast (but not too high elevation), and safely outside the blast radius. To achieve this, we'll use a tiered approach with multiple fallback strategies. + { + bool TestCandidate(int cx, int cy, bool requireCoastal) + { + if (!_isMainland[cx, cy]) return false; // MUST be mainland + + float localSeaLevel = GetSeaLevel(_tempMap[cx, cy]); + if (_heightMap[cx, cy] < localSeaLevel) return false; // Must be land + if (_biomeMap[cx, cy] != Biome.Wasteland) return false; // Must be Wasteland + + if (requireCoastal && _heightMap[cx, cy] >= localSeaLevel + 0.15f) return false; + + return true; + } + + float minDist = _impactRadius + 30f; + float maxDist = _impactRadius + 150f; + + // TIER 1: Ideal (Wasteland + Coastal + Mainland) + for (int i = 0; i < 5000; i++) + { + float angle = (float)GD.RandRange(0, Mathf.Tau); + float dist = (float)GD.RandRange(minDist, maxDist); + Vector2 pos = _impactCenter + new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)) * dist; + int cx = Mathf.Clamp((int)pos.X, 0, MapSize - 1); + int cy = Mathf.Clamp((int)pos.Y, 0, MapSize - 1); + + if (TestCandidate(cx, cy, requireCoastal: true)) { + CommitCapitol(cx, cy); + return; + } + } + + // TIER 2: Relax coastal requirement + for (int i = 0; i < 10000; i++) + { + float angle = (float)GD.RandRange(0, Mathf.Tau); + float dist = (float)GD.RandRange(minDist, _impactRadius + 400f); + Vector2 pos = _impactCenter + new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)) * dist; + int cx = Mathf.Clamp((int)pos.X, 0, MapSize - 1); + int cy = Mathf.Clamp((int)pos.Y, 0, MapSize - 1); + + if (TestCandidate(cx, cy, requireCoastal: false)) { + CommitCapitol(cx, cy); + return; + } + } + + // TIER 3: Deterministic Scan (Find closest valid pixel) + GD.PrintErr("Capitol: Random tiers failed. Running deterministic wasteland scan..."); + var candidates = new List<(int x, int y, float dist)>(); + for (int x = 0; x < MapSize; x++) { + for (int y = 0; y < MapSize; y++) { + if (_isMainland[x, y] && _biomeMap[x, y] == Biome.Wasteland) { + candidates.Add((x, y, new Vector2(x, y).DistanceTo(_impactCenter))); + } + } + } + + if (candidates.Count > 0) { + candidates.Sort((a, b) => a.dist.CompareTo(b.dist)); + int pickIdx = (int)GD.RandRange(0, Mathf.Max(1, candidates.Count / 20)); + CommitCapitol(candidates[pickIdx].x, candidates[pickIdx].y); + return; + } + + // TIER 4: Doomsday (Walk south until we hit mainland) + GD.PrintErr("Capitol: No mainland wasteland found! Placing at fallback offset."); + int fx = Mathf.Clamp((int)_impactCenter.X, 0, MapSize - 1); + int fy = Mathf.Clamp((int)(_impactCenter.Y + _impactRadius + 50f), 0, MapSize - 1); + while (fy < MapSize - 1 && !_isMainland[fx, fy]) fy++; + CommitCapitol(fx, fy); + } + + private void CommitCapitol(int cx, int cy) + { + _towns.Add(new TownData { + Position = new Vector2(cx, cy), + Tier = TownTier.Capitol, + IsHighwayNode = true + }); + GD.Print($"Capitol placed at ({cx},{cy})."); + } + + private void PlaceTownNodes(int count, TownTier tier, Biome targetBiome, bool requireInland, bool requireCoastal, bool isHighway, float maxSlope, MapHalf halfConstraint, bool requireMainland = true, bool requireIsland = false, float minTemp = 0f, float maxTemp = 1f) + { + int placed = 0; + int attempts = 0; + + // --- TUNED SPACING --- + // Big Hubs/Capitol need 120px (1.8km) spacing. Villages/POIs only need 60px (900m). + float minDistance = (tier == TownTier.Hub || tier == TownTier.Capitol) ? MapSize * 0.12f : MapSize * 0.06f; + + bool ignoreBiome = false; + + while (placed < count && attempts < 80000) + { + attempts++; + + if (attempts == 35000) + { + if (requireIsland) ignoreBiome = true; + else { + if (!isHighway) requireCoastal = false; + requireInland = false; + maxSlope = 0.08f; + minTemp = 0f; + maxTemp = 1f; + } + } + + if (attempts == 65000 && requireIsland) { + requireIsland = false; + requireMainland = true; + requireCoastal = true; + } + + int rx = (int)GD.RandRange(20, MapSize - 20); + int ry = (int)GD.RandRange(20, MapSize - 20); + + if (halfConstraint == MapHalf.Left && rx > MapSize / 2) continue; + if (halfConstraint == MapHalf.Right && rx < MapSize / 2) continue; + + float t = _tempMap[rx, ry]; + float currentSeaLevel = GetSeaLevel(t); + if (_heightMap[rx, ry] < currentSeaLevel) continue; + + if (!ignoreBiome && _biomeMap[rx, ry] != targetBiome) continue; + if (requireMainland && !_isMainland[rx, ry]) continue; + if (requireIsland && _isMainland[rx, ry]) continue; + if (t < minTemp || t > maxTemp) continue; + + float centerH = _heightMap[rx, ry]; + // Sample slope a bit wider (10px) due to higher resolution + int slopeRadius = (int)(MapSize * 0.005f); // Automatically scales! + if (Mathf.Abs(_heightMap[rx+slopeRadius, ry] - centerH) > maxSlope) continue; + if (Mathf.Abs(_heightMap[rx-slopeRadius, ry] - centerH) > maxSlope) continue; + if (Mathf.Abs(_heightMap[rx, ry+slopeRadius] - centerH) > maxSlope) continue; + if (Mathf.Abs(_heightMap[rx, ry-slopeRadius] - centerH) > maxSlope) continue; + + bool nearTrueOcean = false; + bool nearAnyWater = false; + + // --- TUNED WATER DETECTION --- + // Increased to 24px (approx 375m) to ensure coastal towns are actually near the surf + int waterSearch = (int)(MapSize * 0.015f); // Automatically scales! + for(int i = -waterSearch; i <= waterSearch; i += 2) { + for(int j = -waterSearch; j <= waterSearch; j += 2) { + int checkX = Mathf.Clamp(rx + i, 0, MapSize - 1); + int checkY = Mathf.Clamp(ry + j, 0, MapSize - 1); + float checkSeaLevel = GetSeaLevel(_tempMap[checkX, checkY]); + if (_isTrueOcean[checkX, checkY]) nearTrueOcean = true; + if (_heightMap[checkX, checkY] < checkSeaLevel) nearAnyWater = true; + } + } + + if (!requireIsland) { + if (requireCoastal && !nearTrueOcean) continue; + if (requireInland && nearAnyWater) continue; + } + + bool tooClose = false; + foreach (var town in _towns) { + if (new Vector2(rx, ry).DistanceTo(town.Position) < minDistance) tooClose = true; + } + if (tooClose) continue; + + _towns.Add(new TownData { Position = new Vector2(rx, ry), Tier = tier, IsHighwayNode = isHighway }); + placed++; + } + } + + // --- LOGISTICS & ROAD GENERATION --- + private async Task GenerateRoadsAsync() + { + GD.Print($"[A*] Starting Logistics Pathfinding for {MapSize}x{MapSize} world..."); + _highwayPaths.Clear(); _branchPaths.Clear(); _ruggedPaths.Clear(); _trailPaths.Clear(); + + AStarGrid2D astar = new AStarGrid2D(); + astar.Region = new Rect2I(0, 0, MapSize, MapSize); + astar.CellSize = new Vector2I(1, 1); + astar.DiagonalMode = AStarGrid2D.DiagonalModeEnum.OnlyIfNoObstacles; + astar.Update(); + + SetBaseAStarWeights(astar); + + int loopRepulsion = (int)(120 * (MapSize / 4096f)); + Vector2 center = new Vector2(MapSize / 2f, MapSize / 2f); + HashSet highwayPixels = new HashSet(); + + // --- 1. THE CONTINENTAL LOOP --- + List highwayNodes = _towns.FindAll(t => t.IsHighwayNode); + highwayNodes.Sort((a, b) => { + float angleA = Mathf.Atan2(a.Position.Y - center.Y, a.Position.X - center.X); + float angleB = Mathf.Atan2(b.Position.Y - center.Y, b.Position.X - center.X); + return angleA.CompareTo(angleB); + }); + + GD.Print($"[A*] 1/4: Plotting the {highwayNodes.Count}-Node Continental Loop..."); + await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame); // BREATHE + + if (highwayNodes.Count >= 3) { + for (int i = 0; i < highwayNodes.Count; i++) { + 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), + new Vector2I((int)endPos.X, (int)endPos.Y) + ); + + if (rawPath.Length > 1) { + _highwayPaths.Add(SmoothPath(rawPath)); + foreach(var p in rawPath) highwayPixels.Add(new Vector2I((int)p.X, (int)p.Y)); + PenalizePath(astar, rawPath, loopRepulsion); + } + await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame); // BREATHE + } + } + + // --- 2. THE MOUNTAIN BOSS BRANCH --- + GD.Print("[A*] 2/4: Connecting the Snow Boss..."); + await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame); // BREATHE + + List connectedTowns = new List(highwayNodes); + TownData snowBoss = _towns.Find(t => t.Tier == TownTier.Hub && !t.IsHighwayNode); + + if (snowBoss.Tier == TownTier.Hub) { + Vector2I bestPixel = new Vector2I((int)center.X, (int)center.Y); + float closestDist = float.MaxValue; + + int skip = 0; + foreach (var pixel in highwayPixels) { + if (skip++ % 5 != 0) continue; + float d = snowBoss.Position.DistanceSquaredTo(new Vector2(pixel.X, pixel.Y)); + if (d < closestDist) { + closestDist = d; + bestPixel = pixel; + } + } + + Vector2[] bossPath = astar.GetPointPath( + new Vector2I((int)snowBoss.Position.X, (int)snowBoss.Position.Y), bestPixel + ); + + if (bossPath.Length > 1) { + _branchPaths.Add(SmoothPath(bossPath)); + PenalizePath(astar, bossPath, loopRepulsion); + } + connectedTowns.Add(snowBoss); + } + + // --- 3 & 4. COUNTY ROADS (The Daisy-Chain Protocol) --- + GD.Print("[A*] 3/4 & 4/4: Weaving County Roads (Daisy-Chaining)..."); + await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame); // BREATHE + + List unconnectedTowns = _towns.FindAll(t => !connectedTowns.Contains(t) && t.Tier != TownTier.IslandLoot); + + int count = 0; + int totalToConnect = unconnectedTowns.Count; + + // Prim's Algorithm: Organically grow the network out from the Hubs + while (unconnectedTowns.Count > 0) + { + TownData bestUnconnected = unconnectedTowns[0]; + TownData bestConnected = connectedTowns[0]; + float shortestDist = float.MaxValue; + + // Find the single closest gap between the Unconnected wilderness and the Connected grid + foreach (var unconn in unconnectedTowns) { + foreach (var conn in connectedTowns) { + float d = unconn.Position.DistanceSquaredTo(conn.Position); + if (d < shortestDist) { + shortestDist = d; + bestUnconnected = unconn; + bestConnected = conn; + } + } + } + + count++; + float actualDist = Mathf.Sqrt(shortestDist); + + // NEW: Live Diagnostics! Print the exact town we are testing BEFORE we calculate. + GD.Print($" -> ({count}/{totalToConnect}) Pathing {bestUnconnected.Tier} to grid. Dist: {actualDist:F1}px"); + await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame); // BREATHE! + + Vector2[] countyPath = new Vector2[0]; + + // THE ABANDON PROTOCOL: If it's more than 8% of the map away, it's a hermit camp. Leave it! + if (actualDist > (MapSize * 0.08f)) + { + GD.Print($" [!] Too isolated. Abandoning road."); + } + 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), + new Vector2I((int)bestConnected.Position.X, (int)bestConnected.Position.Y) + ); + } + + // If a path was found, draw it and penalize the pixels + if (countyPath.Length > 1) { + if (bestUnconnected.Tier == TownTier.Village) _ruggedPaths.Add(SmoothPath(countyPath)); + else _trailPaths.Add(SmoothPath(countyPath)); + + PenalizePath(astar, countyPath, loopRepulsion / 2); + } + else if (actualDist <= (MapSize * 0.08f)) + { + GD.Print($" [!] Path impossible (Trapped by terrain). Abandoning road."); + } + + unconnectedTowns.Remove(bestUnconnected); + connectedTowns.Add(bestUnconnected); + } + + GD.Print("[A*] Logistics Network Complete!"); + } + + private float GetSeaLevel(float t) => Mathf.Lerp(0.26f, 0.15f, Mathf.Clamp(t, 0f, 1f)); + + private Vector2I FindClosestPixel(Vector2 pos, HashSet set) { + Vector2I closest = new Vector2I(0,0); float min = float.MaxValue; + foreach(var p in set) { float d = pos.DistanceSquaredTo(p); if(d < min){ min = d; closest = p; }} + return closest; + } + + private float GetShortestDistToSet(Vector2 pos, HashSet set) { + float shortest = float.MaxValue; + Vector2I pInt = new Vector2I((int)pos.X, (int)pos.Y); + foreach (var p in set) { + float dist = pInt.DistanceSquaredTo(p); + if (dist < shortest) shortest = dist; + } + return shortest; + } + + private void SetBaseAStarWeights(AStarGrid2D astar) { + for (int x = 0; x < MapSize; x++) { + for (int y = 0; y < MapSize; y++) { + Biome b = _biomeMap[x, y]; + + // 1. CRITICAL FIX: Make the crater physically impassable first! + if (b == Biome.Crater) { + astar.SetPointSolid(new Vector2I(x, y), true); + continue; // "Skip the rest, go to next pixel" + } + + // 2. Existing water/mainland checks + if (b == Biome.Ocean || b == Biome.Lake || !_isMainland[x, y]) { + astar.SetPointSolid(new Vector2I(x, y), true); + continue; // "Skip the rest, go to next pixel" + } + + // 3. We only reach this point if it's mainland AND not a crater! + float h = _heightMap[x, y]; + float localSea = GetSeaLevel(_tempMap[x, y]); + float normalizedElevation = Mathf.Clamp((h - localSea) / (1.0f - localSea), 0.0f, 1.0f); + + // Simple, steep curve to avoid mountains + float weight = 1.0f + Mathf.Pow(normalizedElevation, 3.0f) * 400.0f; + + // Push off the beach (not an insane wall, just enough to prefer grass) + if (b == Biome.Beach) weight += 15.0f; + + // Let it path through the wasteland normally + if (b == Biome.Wasteland) weight += 3.0f; + + astar.SetPointWeightScale(new Vector2I(x, y), weight); + } + } + } + + private void PenalizePath(AStarGrid2D astar, Vector2[] path, int radius) + { + int step = Mathf.Max(1, radius / 2); + for (int i = 0; i < path.Length; i += step) { + Vector2 p = path[i]; + for (int dx = -radius; dx <= radius; dx++) { + for (int dy = -radius; dy <= radius; dy++) { + int nx = Mathf.Clamp((int)p.X + dx, 0, MapSize - 1); + 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); + } + } + } + } + } + + private Vector2[] SmoothPath(Vector2[] raw) + { + if (raw.Length < 3) return raw; + + List decimated = RamerDouglasPeucker(raw, 4.0f); + if (decimated.Count < 3) return raw; + + List smoothed = decimated; + for (int pass = 0; pass < 4; pass++) smoothed = ChaikinPass(smoothed, true); + + return smoothed.ToArray(); + } + + private List RamerDouglasPeucker(Vector2[] points, float tolerance) + { + if (points.Length <= 2) return new List(points); + + float maxDist = 0f; + int maxIdx = 0; + Vector2 lineStart = points[0]; + Vector2 lineEnd = points[points.Length - 1]; + + for (int i = 1; i < points.Length - 1; i++) { + float dist = PerpendicularDistance(points[i], lineStart, lineEnd); + if (dist > maxDist) { maxDist = dist; maxIdx = i; } + } + + if (maxDist > tolerance) { + var left = RamerDouglasPeucker(points[..(maxIdx + 1)], tolerance); + var right = RamerDouglasPeucker(points[maxIdx..], tolerance); + left.RemoveAt(left.Count - 1); + left.AddRange(right); + return left; + } else { + return new List { lineStart, lineEnd }; + } + } + + private float PerpendicularDistance(Vector2 point, Vector2 lineStart, Vector2 lineEnd) + { + Vector2 line = lineEnd - lineStart; + float len = line.Length(); + if (len < 0.0001f) return point.DistanceTo(lineStart); + return Mathf.Abs((lineEnd.X - lineStart.X) * (lineStart.Y - point.Y) - (lineStart.X - point.X) * (lineEnd.Y - lineStart.Y)) / len; + } + + private List ChaikinPass(List points, bool open) + { + var result = new List(); + if (open) result.Add(points[0]); + + for (int i = 0; i < points.Count - 1; i++) { + Vector2 p0 = points[i]; + Vector2 p1 = points[(i + 1) % points.Count]; + result.Add(p0 * 0.75f + p1 * 0.25f); + result.Add(p0 * 0.25f + p1 * 0.75f); + } + + if (open) result.Add(points[points.Count - 1]); + return result; + } + + // --- REFINED DRAWING FOR SCALE --- + public override void _Draw() + { + float scaleMod = MapSize / 1024f; // equals 4 at 4096 + + // Trails: Thin brown + foreach (var path in _trailPaths) DrawPolyline(path, new Color(0.5f, 0.4f, 0.3f, 0.7f), 1.0f * scaleMod, true); + + // Rugged: Thicker dark brown + foreach (var path in _ruggedPaths) DrawPolyline(path, new Color(0.35f, 0.25f, 0.15f), 1.5f * scaleMod, true); + + // Main Roads: Solid Black + foreach (var path in _branchPaths) DrawPolyline(path, Colors.Black, 1.0f * scaleMod, true); + + // Highway: Thick Red + foreach (var path in _highwayPaths) DrawPolyline(path, new Color(0.9f, 0.1f, 0.1f), 2.0f * scaleMod, true); + + // Draw Towns + foreach (var town in _towns) { + float radius = (town.Tier == TownTier.Capitol) ? 12f : (town.Tier == TownTier.Hub ? 8f : 4f); + radius *= scaleMod; // Scale the circles up! + + Color c = town.IsHighwayNode ? Colors.Yellow : (town.Tier == TownTier.Outpost ? Colors.Cyan : Colors.Orange); + if (town.Tier == TownTier.IslandLoot) c = Colors.Red; + if (town.Tier == TownTier.MiniPOI) { radius = 2.5f * scaleMod; c = Colors.SaddleBrown; } + + DrawCircle(town.Position, radius + (1.5f * scaleMod), Colors.Black); + DrawCircle(town.Position, radius, c); + } + } +} + +// Place this at the very bottom of the file! +public partial class MapDrawProxy : Control +{ + public MapGenerator Source; + + public override void _Draw() + { + if (Source == null) return; + + float scaleMod = Source.MapSize / 1024f; + + foreach (var path in Source._trailPaths) + DrawPolyline(path, new Color(0.5f, 0.4f, 0.3f, 0.7f), 1.0f * scaleMod, true); + + foreach (var path in Source._ruggedPaths) + DrawPolyline(path, new Color(0.35f, 0.25f, 0.15f), 1.5f * scaleMod, true); + + foreach (var path in Source._branchPaths) + DrawPolyline(path, Colors.Black, 1.0f * scaleMod, true); + + foreach (var path in Source._highwayPaths) + DrawPolyline(path, new Color(0.9f, 0.1f, 0.1f), 2.0f * scaleMod, true); + + foreach (var town in Source._towns) + { + float radius = (town.Tier == TownTier.Capitol) ? 12f : (town.Tier == TownTier.Hub ? 8f : 4f); + radius *= scaleMod; + + Color c = town.IsHighwayNode ? Colors.Yellow : (town.Tier == TownTier.Outpost ? Colors.Cyan : Colors.Orange); + if (town.Tier == TownTier.IslandLoot) c = Colors.Red; + if (town.Tier == TownTier.MiniPOI) { radius = 2.5f * scaleMod; c = Colors.SaddleBrown; } + + DrawCircle(town.Position, radius + (1.5f * scaleMod), Colors.Black); + DrawCircle(town.Position, radius, c); + } + } +} \ No newline at end of file diff --git a/Tools/Scripts/MapGenerator.cs.uid b/Tools/Scripts/MapGenerator.cs.uid new file mode 100644 index 0000000..28c1180 --- /dev/null +++ b/Tools/Scripts/MapGenerator.cs.uid @@ -0,0 +1 @@ +uid://bl6pk71fngm8p diff --git a/Tools/Scripts/MapGenerator.md b/Tools/Scripts/MapGenerator.md new file mode 100644 index 0000000..49e7ae4 --- /dev/null +++ b/Tools/Scripts/MapGenerator.md @@ -0,0 +1,47 @@ +# MapGenerator.cs - Architecture & Historical Decisions + +**Date:** March 2026 +**Purpose:** Generates a 4096x4096 2D topographical blueprint (Topography, Biomes, Cities, and Road Networks) to be parsed and translated into a 3D Voxel World. + +--- + +## 1. Topography & The Impact Crater +* **Base Generation:** Uses `FastNoiseLite` to generate standard heightmaps and temperature maps, which drive dynamic biomes. +* **Dynamic Sea Level:** Sea level is calculated based on the temperature map (simulating ice caps/equatorial swelling). +* **The Crater:** A massive (radius 400) blast zone is forced along the northern coast. Surrounding it is a `Biome.Wasteland` with a noise-driven "fray" on its borders. +* **Design Choice:** We allowed the crater to occasionally spawn slightly inland rather than strictly on the beach. This creates natural topographical "pinch points" that will result in highly memorable, claustrophobic 3D driving mechanics. + +## 2. City Placement & The Capitol Tether +* **The Challenge:** The Capitol City must spawn inside the radioactive Wasteland, but it MUST be connected to the contiguous mainland for the A* highway to reach it. Early iterations spawned the Capitol underwater or stranded it on offshore islands caused by the Wasteland noise bleeding over the ocean. +* **The Solution (Smart Tiered Spawner):** We abandoned a rigid `while` loop in favor of a tiered attempt system. + * **Tier 1:** Searches for ideal (Wasteland + Coastal + Mainland). + * **Tier 2:** Relaxes the coastal requirement. + * **Tier 3:** Scans the entire array for the closest valid pixel to the impact center. + * **Tier 4 (Doomsday):** Walks strictly south until it hits the mainland. + +## 3. The Pathfinding Wars (A* Road Optimization) +The road network generation was the hardest logistical hurdle. Standard grid-based `AStarGrid2D` math does not scale cleanly to a 4096 map. + +### Phase 1: The 16-Minute Bottleneck +* **The Flaw:** To force the highway to create a large loop connecting our 3 Hubs, we used an `ApplyRepulsion` function that penalized pixels in a 400px radius around the first drawn road. +* **The Math:** Repelling a 2,000-point path with a 400px radius resulted in over **1.3 Billion** nested loop iterations. The CPU choked, and generation took 11–16 minutes. +* **The Result:** Unusable generation times, extreme stair-stepping (zig-zagging), and roads cutting straight over mountains. + +### Phase 2: The Speedrun & The Double-Back +* **The Flaw:** We ripped out the repulsion completely to save CPU time, dropping the render to **1 minute**. +* **The Result:** Because A* is deterministic, the pathfinder just used the exact same pixels to return to the start, completely breaking the 3-point loop and causing the highway to double back on itself. + +### Phase 3: The Over-Engineered Spaghetti (The LLM Trap) +* **The Flaw:** We brought in an external LLM to fix the pathing. It introduced a "Midland Sweet Spot" to force roads off the coast, and a "Daisy-Chain" mechanic for branch roads. +* **The Result:** The highway started tracing narrow elevation contour lines like a drunk driver. The branch roads turned into a game of "Snake," meandering wildly to connect to each other instead of taking logical paths to the highway. Furthermore, adding a soft weight to the crater combined with our loop repulsion created a "Wasteland Phobia" where the A* algorithm mathematically folded and refused to complete the loop. + +### Phase 4: The Tactical Revert (Our Final State) +We executed a deliberate tactical revert, stripping out the over-engineered LLM spaghetti and keeping only robust, simple math: +1. **Direct Branches:** Black roads path directly to the closest highway pixel. No daisy-chaining. +2. **The Iron Curtain Repulsion:** We restored a `+10000f` penalty to old highway segments with a radius of 120px to physically force the 3-point loop. We optimized it by only applying the penalty to every `step` (half the radius) pixel. Render time remains at ~1 minute. +3. **Simple Elevation Weights:** Instead of a complex U-curve, we use `Mathf.Pow(normalizedElevation, 3.0f) * 400.0f` to aggressively punish mountains, and a flat `+15.0f` penalty to gently push roads off the beach sand. + +## 4. The AAA Smoothing Pipeline +Standard A* creates jagged, octagonal stair-steps that look terrible in a 3D voxel world. We completely eradicated this using a 2-step geometric smoothing pipeline: +1. **Ramer-Douglas-Peucker (RDP):** Decimates the raw A* path, removing thousands of useless straight-line grid points and leaving only the major turns. +2. **Chaikin's Algorithm:** Performs 4 passes of corner-cutting on the decimated points, resulting in buttery-smooth, sweeping, AAA-style highway curves ready for 3D mesh generation. \ No newline at end of file diff --git a/Tools/Scripts/README.md b/Tools/Scripts/README.md new file mode 100644 index 0000000..6ceb899 --- /dev/null +++ b/Tools/Scripts/README.md @@ -0,0 +1,31 @@ +# πŸ“œ /Tools/Scripts - Generation Logic + +## Overview +This directory contains the raw C# backend logic for all of our offline developer tools. These scripts are strictly for generating source data (Maps, Prefabs, Balance Configs) and are completely decoupled from the live game server and client. + +--- + +## πŸ—οΈ Core Scripts + +### `MapGenerator.cs` +**The Master World Builder.** This script is responsible for generating the 2D topographical and logistical blueprint of *Isla Apocalypse*. It runs FastNoiseLite math, calculates distance falloffs, plots A* road networks, and zones the biomes. + +#### βš”οΈ The Great 4096 Struggle (The 1:1 Scale Transition) +Originally, this script generated a 1024x1024 map where 1 pixel equaled 1 chunk (4 meters). To accommodate industry-standard human-scale base building, we transitioned the engine to a 1:1 scale (1 pixel = 1 meter), effectively quadrupling the map size to 4096x4096. + +This transition broke almost everything, requiring a massive architectural overhaul: + +1. **The "TV Static" Math Fix:** Multiplying the map size by 4 caused the noise generator to pack 4 kilometers of terrain into 1 kilometer of space, creating visual static. We introduced a dynamic `scaleFactor` (`MapSize / 1024f`) to stretch the noise frequency back out, preserving the massive, sweeping "chonky" biomes while maintaining the 1:1 scale. +2. **Eradicating Magic Numbers:** Hardcoded pixel checks (e.g., "check 10 pixels for water") were completely broken by the upscaling. We refactored the logic to use dynamic map percentages for slope checking, water detection, and A* highway repulsion. +3. **The 1KB Postage Stamp (Defeating the UI Engine):** Trying to capture a 4096 image inside a standard Godot UI layout node resulted in the engine crushing the map into a tiny 1KB square. To fix this, `SaveMapSnapshot()` was rewritten to build an invisible, offscreen `SubViewport` entirely in C#. +4. **The Draw Proxy:** Because raw `Texture.GetImage()` does not capture Godot's `_Draw()` functions, we created `MapDrawProxy`. This stamps the colored roads and towns onto the offscreen monitor. We then implemented asynchronous thread-locking (`await ToSignal(RenderingServer...)`) to force the code to wait for the GPU to physically paint the roads before snapping the PNG. + +**Outputs:** +* `MapData_Seed_[X].dat` - The binary map data for the Server. +* `Map_Seed_[X].png` - The 4096 high-resolution visual render. + +--- + +## ⚠️ Scripting Rules +1. **No Magic Numbers:** Any distance, radius, or length must be calculated as a percentage of `MapSize` or use the `scaleFactor`. +2. **GPU Respect:** Any script exporting visual data must use `CallDeferred` or `await` frames to ensure the Godot rendering engine has finished processing. \ No newline at end of file diff --git a/icon.svg b/icon.svg new file mode 100644 index 0000000..c6bbb7d --- /dev/null +++ b/icon.svg @@ -0,0 +1 @@ + diff --git a/icon.svg.import b/icon.svg.import new file mode 100644 index 0000000..c81988f --- /dev/null +++ b/icon.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dsbi1fbmyn4o1" +path="res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://icon.svg" +dest_files=["res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/islaApocolypse.csproj b/islaApocolypse.csproj new file mode 100644 index 0000000..b879631 --- /dev/null +++ b/islaApocolypse.csproj @@ -0,0 +1,7 @@ + + + net8.0 + net9.0 + true + + \ No newline at end of file diff --git a/islaApocolypse.csproj.old b/islaApocolypse.csproj.old new file mode 100644 index 0000000..6f5a596 --- /dev/null +++ b/islaApocolypse.csproj.old @@ -0,0 +1,7 @@ + + + net8.0 + net9.0 + true + + \ No newline at end of file diff --git a/islaApocolypse.sln b/islaApocolypse.sln new file mode 100644 index 0000000..b7e82e4 --- /dev/null +++ b/islaApocolypse.sln @@ -0,0 +1,19 @@ +ο»ΏMicrosoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio 2012 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "islaApocolypse", "islaApocolypse.csproj", "{627E1C36-76D1-4AFE-A400-580FC002DDED}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + ExportDebug|Any CPU = ExportDebug|Any CPU + ExportRelease|Any CPU = ExportRelease|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {627E1C36-76D1-4AFE-A400-580FC002DDED}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {627E1C36-76D1-4AFE-A400-580FC002DDED}.Debug|Any CPU.Build.0 = Debug|Any CPU + {627E1C36-76D1-4AFE-A400-580FC002DDED}.ExportDebug|Any CPU.ActiveCfg = ExportDebug|Any CPU + {627E1C36-76D1-4AFE-A400-580FC002DDED}.ExportDebug|Any CPU.Build.0 = ExportDebug|Any CPU + {627E1C36-76D1-4AFE-A400-580FC002DDED}.ExportRelease|Any CPU.ActiveCfg = ExportRelease|Any CPU + {627E1C36-76D1-4AFE-A400-580FC002DDED}.ExportRelease|Any CPU.Build.0 = ExportRelease|Any CPU + EndGlobalSection +EndGlobal diff --git a/project.godot b/project.godot new file mode 100644 index 0000000..539601d --- /dev/null +++ b/project.godot @@ -0,0 +1,34 @@ +; Engine configuration file. +; It's best edited using the editor UI and not directly, +; since the parameters that go here are not all obvious. +; +; Format: +; [section] ; section goes between [] +; param=value ; assign values to parameters + +config_version=5 + +[application] + +config/name="islaApocolypse" +run/main_scene="uid://croq62eckpnvx" +config/features=PackedStringArray("4.7", "C#", "Forward Plus") +config/icon="res://icon.svg" + +[display] + +window/size/viewport_width=1024 +window/size/viewport_height=768 +window/stretch/mode="viewport" + +[dotnet] + +project/assembly_name="islaApocolypse" + +[physics] + +3d/physics_engine="Jolt Physics" + +[rendering] + +rendering_device/driver.windows="d3d12"