diff --git a/client/src/GameLogic/ArenaBuilders.cs b/client/src/GameLogic/ArenaBuilders.cs index a4bd7d0..c599931 100644 --- a/client/src/GameLogic/ArenaBuilders.cs +++ b/client/src/GameLogic/ArenaBuilders.cs @@ -115,7 +115,9 @@ public ArenaLayout Build(int seed) { var (materials, bushes) = ArenaAuthor.Field(); - // Two horizontal lava rivers spanning the field, each with four bridge crossings. + // Two horizontal lava rivers spanning the field, each with four bridge crossings. Every + // crossing is three cells wide — a single cell made brushing lava almost unavoidable + // (owner feedback 2026-07-17). foreach (var riverY in new[] { 13, 32 }) { for (var x = 3; x <= 72; x++) @@ -125,7 +127,10 @@ public ArenaLayout Build(int seed) foreach (var bridgeX in new[] { 12, 28, 46, 62 }) { - materials[bridgeX, riverY] = CellMaterial.Bridge; + for (var dx = -1; dx <= 1; dx++) + { + materials[bridgeX + dx, riverY] = CellMaterial.Bridge; + } } } @@ -135,7 +140,10 @@ public ArenaLayout Build(int seed) materials[38, y] = CellMaterial.Lava; } - materials[38, 22] = CellMaterial.Bridge; + for (var y = 21; y <= 23; y++) + { + materials[38, y] = CellMaterial.Bridge; + } // Basalt outcrops (steel) for hard cover on the rims. foreach (var (cx, cy) in new[] { (18, 6), (58, 6), (18, 39), (58, 39), (38, 6), (38, 39) }) diff --git a/client/src/Presentation/Arena/Terrain3DView.cs b/client/src/Presentation/Arena/Terrain3DView.cs index 804723c..4cd0e14 100644 --- a/client/src/Presentation/Arena/Terrain3DView.cs +++ b/client/src/Presentation/Arena/Terrain3DView.cs @@ -177,23 +177,67 @@ private void AddWater(NVector2 centre, string name) => MaterialOverride = new StandardMaterial3D { AlbedoColor = new Color(0.20f, 0.42f, 0.66f), Roughness = 0.3f }, }); - // A molten lava pool: a glowing red plane sitting near the ground, emissive so it reads as hot even - // in shadow. Flush enough that a bridge tile (drawn just above) sits over it. + // Molten lava: dark crust broken by glowing, slowly-flowing cracks. Two layers of procedural value + // noise scroll in different directions (so the surface churns rather than sliding as one sticker), + // and the crack glow pulses. Sampled in WORLD space so the flow runs seamlessly across neighbouring + // cells. Unshaded with zero texture fetches — cheap enough for the Compatibility renderer + // (WASM/mobile); unshaded also keeps it reading as hot even in shadow. + private const string LavaShaderCode = @" +shader_type spatial; +render_mode unshaded; +uniform float cell_size = 64.0; +varying vec2 wpos; +void vertex() { + wpos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xz; +} +float vhash(vec2 p) { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); +} +float vnoise(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + vec2 u = f * f * (3.0 - 2.0 * f); + return mix(mix(vhash(i), vhash(i + vec2(1.0, 0.0)), u.x), + mix(vhash(i + vec2(0.0, 1.0)), vhash(i + vec2(1.0, 1.0)), u.x), u.y); +} +void fragment() { + vec2 p = wpos / cell_size; + float a = vnoise((p * 2.2) + (TIME * vec2(0.14, 0.05))); + float b = vnoise((p * 5.1) - (TIME * vec2(0.06, 0.11))); + float n = (a * 0.65) + (b * 0.35); + float molten = smoothstep(0.42, 0.72, n); + float pulse = 0.8 + (0.2 * sin((TIME * 1.6) + (n * 12.0))); + vec3 crust = vec3(0.10, 0.03, 0.02); + vec3 hot = mix(vec3(0.95, 0.28, 0.02), vec3(1.0, 0.85, 0.30), smoothstep(0.75, 0.95, n)); + ALBEDO = mix(crust, hot * pulse, molten); +}"; + + private static Shader _lavaShader = null!; + private ShaderMaterial? _lavaMaterial; + + // One material shared by every lava cell in the arena — world-space sampling makes them one + // continuous surface, and sharing lets the renderer batch them. + private ShaderMaterial LavaMaterial() + { + if (_lavaMaterial is null) + { + _lavaShader ??= new Shader { Code = LavaShaderCode }; + _lavaMaterial = new ShaderMaterial { Shader = _lavaShader }; + _lavaMaterial.SetShaderParameter("cell_size", _tileSize); + } + + return _lavaMaterial; + } + + // A molten lava pool sitting near the ground, flush enough that a bridge tile (drawn just above) + // sits over it. private void AddLava(NVector2 centre, string name) => AddChild(new MeshInstance3D { Name = name, Mesh = new PlaneMesh { Size = new Vector2(_tileSize, _tileSize) }, Position = new Vector3(centre.X, 0.6f, centre.Y), - MaterialOverride = new StandardMaterial3D - { - AlbedoColor = new Color(0.85f, 0.18f, 0.05f), - EmissionEnabled = true, - Emission = new Color(0.95f, 0.30f, 0.05f), - EmissionEnergyMultiplier = 2.5f, - Roughness = 0.6f, - SpecularMode = BaseMaterial3D.SpecularModeEnum.Disabled, - }, + MaterialOverride = LavaMaterial(), }); // A flat road tile flush with the ground (the bridge crossing over water). diff --git a/client/tests/GameLogic/ArenaBuildersTests.cs b/client/tests/GameLogic/ArenaBuildersTests.cs index 2b09973..5bbceda 100644 --- a/client/tests/GameLogic/ArenaBuildersTests.cs +++ b/client/tests/GameLogic/ArenaBuildersTests.cs @@ -109,6 +109,45 @@ public void Volcano_HasLava_CrossedByABridgeOverLava() Assert.True(bridgeOverLava, "volcano must have at least one Bridge crossing lava"); } + // Owner feedback: single-cell crossings made it too easy to clip lava — every crossing must be a + // run of at least three Bridge cells along its river, so a tank has room to drive across. + [Fact] + public void Volcano_EveryLavaCrossing_IsAtLeastThreeCellsWide() + { + var layout = BuildOrFail("Volcano"); + var mats = layout.Map.Materials; + for (var x = 0; x < layout.Map.Width; x++) + { + for (var y = 0; y < layout.Map.Height; y++) + { + if (mats[x, y] != CellMaterial.Bridge) + { + continue; + } + + var run = System.Math.Max(RunLength(mats, x, y, 1, 0), RunLength(mats, x, y, 0, 1)); + Assert.True(run >= 3, $"bridge at ({x},{y}) is only {run} cell(s) wide"); + } + } + } + + // The length of the contiguous straight run of Bridge cells through (x, y) along (dx, dy). + private static int RunLength(CellMaterial[,] mats, int x, int y, int dx, int dy) + { + var run = 1; + for (var i = 1; mats[x + (i * dx), y + (i * dy)] == CellMaterial.Bridge; i++) + { + run++; + } + + for (var i = 1; mats[x - (i * dx), y - (i * dy)] == CellMaterial.Bridge; i++) + { + run++; + } + + return run; + } + [Fact] public void City_IsAGridOfBuildingBlocks() {