From f4d3f9aab43cfb0d2a8df15f663350684921985e Mon Sep 17 00:00:00 2001 From: MDietermann Date: Thu, 28 May 2026 11:53:15 +0200 Subject: [PATCH] feat: add block model with IBlock flyweight and registry --- VoxelEngine.Tests/BlockRegistryTests.cs | 52 +++++++++++++++++++++++++ World/BlockFace.cs | 11 ++++++ World/BlockRegistry.cs | 26 +++++++++++++ World/Blocks/AirBlock.cs | 10 +++++ World/Blocks/DirtBlock.cs | 10 +++++ World/Blocks/GrassBlock.cs | 16 ++++++++ World/Blocks/StoneBlock.cs | 10 +++++ World/IBlock.cs | 21 ++++++++++ 8 files changed, 156 insertions(+) create mode 100644 VoxelEngine.Tests/BlockRegistryTests.cs create mode 100644 World/BlockFace.cs create mode 100644 World/BlockRegistry.cs create mode 100644 World/Blocks/AirBlock.cs create mode 100644 World/Blocks/DirtBlock.cs create mode 100644 World/Blocks/GrassBlock.cs create mode 100644 World/Blocks/StoneBlock.cs create mode 100644 World/IBlock.cs diff --git a/VoxelEngine.Tests/BlockRegistryTests.cs b/VoxelEngine.Tests/BlockRegistryTests.cs new file mode 100644 index 0000000..e614d5b --- /dev/null +++ b/VoxelEngine.Tests/BlockRegistryTests.cs @@ -0,0 +1,52 @@ +using VoxelEngine.World; +using VoxelEngine.World.Blocks; +using Xunit; + +namespace VoxelEngine.Tests; + +public class BlockRegistryTests +{ + [Fact] + public void Get_ReturnsRegisteredBlockById() + { + var registry = new BlockRegistry(); + + Assert.IsType(registry.Get(0)); + Assert.IsType(registry.Get(1)); + Assert.IsType(registry.Get(2)); + Assert.IsType(registry.Get(3)); + } + + [Fact] + public void Air_IsNotOpaqueOrSolid_OthersAre() + { + var registry = new BlockRegistry(); + + Assert.False(registry.Get(0).IsOpaque); + Assert.False(registry.Get(0).IsSolid); + Assert.True(registry.Get(1).IsOpaque); + Assert.True(registry.Get(1).IsSolid); + } + + [Fact] + public void Grass_HasDifferentTexturesPerFace() + { + var registry = new BlockRegistry(); + IBlock grass = registry.Get(3); + + Assert.Equal(0, grass.GetTextureIndex(BlockFace.Top)); + Assert.Equal(1, grass.GetTextureIndex(BlockFace.Bottom)); + Assert.Equal(3, grass.GetTextureIndex(BlockFace.North)); + } + + [Fact] + public void NamedAccessors_MatchIds() + { + var registry = new BlockRegistry(); + + Assert.Equal((ushort)0, registry.Air.Id); + Assert.Equal((ushort)1, registry.Stone.Id); + Assert.Equal((ushort)2, registry.Dirt.Id); + Assert.Equal((ushort)3, registry.Grass.Id); + } +} diff --git a/World/BlockFace.cs b/World/BlockFace.cs new file mode 100644 index 0000000..4193936 --- /dev/null +++ b/World/BlockFace.cs @@ -0,0 +1,11 @@ +namespace VoxelEngine.World; + +public enum BlockFace +{ + Top, // +Y + Bottom, // -Y + North, // -Z + South, // +Z + East, // +X + West // -X +} diff --git a/World/BlockRegistry.cs b/World/BlockRegistry.cs new file mode 100644 index 0000000..a9eef96 --- /dev/null +++ b/World/BlockRegistry.cs @@ -0,0 +1,26 @@ +using VoxelEngine.World.Blocks; + +namespace VoxelEngine.World; + +public sealed class BlockRegistry +{ + private readonly IBlock[] _byId; + + public AirBlock Air { get; } = new(); + public StoneBlock Stone { get; } = new(); + public DirtBlock Dirt { get; } = new(); + public GrassBlock Grass { get; } = new(); + + public BlockRegistry() + { + // Index by id. Order does not matter; slot = block.Id. + var blocks = new IBlock[] { Air, Stone, Dirt, Grass }; + _byId = new IBlock[blocks.Length]; + foreach (IBlock block in blocks) + { + _byId[block.Id] = block; + } + } + + public IBlock Get(ushort id) => _byId[id]; +} diff --git a/World/Blocks/AirBlock.cs b/World/Blocks/AirBlock.cs new file mode 100644 index 0000000..0ee446b --- /dev/null +++ b/World/Blocks/AirBlock.cs @@ -0,0 +1,10 @@ +namespace VoxelEngine.World.Blocks; + +public sealed class AirBlock : IBlock +{ + public ushort Id => 0; + public string Name => "Air"; + public bool IsOpaque => false; + public bool IsSolid => false; + public int GetTextureIndex(BlockFace face) => 0; +} diff --git a/World/Blocks/DirtBlock.cs b/World/Blocks/DirtBlock.cs new file mode 100644 index 0000000..ebb3e83 --- /dev/null +++ b/World/Blocks/DirtBlock.cs @@ -0,0 +1,10 @@ +namespace VoxelEngine.World.Blocks; + +public sealed class DirtBlock : IBlock +{ + public ushort Id => 2; + public string Name => "Dirt"; + public bool IsOpaque => true; + public bool IsSolid => true; + public int GetTextureIndex(BlockFace face) => 1; +} diff --git a/World/Blocks/GrassBlock.cs b/World/Blocks/GrassBlock.cs new file mode 100644 index 0000000..f64c421 --- /dev/null +++ b/World/Blocks/GrassBlock.cs @@ -0,0 +1,16 @@ +namespace VoxelEngine.World.Blocks; + +public sealed class GrassBlock : IBlock +{ + public ushort Id => 3; + public string Name => "Grass"; + public bool IsOpaque => true; + public bool IsSolid => true; + + public int GetTextureIndex(BlockFace face) => face switch + { + BlockFace.Top => 0, // grass top + BlockFace.Bottom => 1, // dirt + _ => 3 // grass side + }; +} diff --git a/World/Blocks/StoneBlock.cs b/World/Blocks/StoneBlock.cs new file mode 100644 index 0000000..97aa47a --- /dev/null +++ b/World/Blocks/StoneBlock.cs @@ -0,0 +1,10 @@ +namespace VoxelEngine.World.Blocks; + +public sealed class StoneBlock : IBlock +{ + public ushort Id => 1; + public string Name => "Stone"; + public bool IsOpaque => true; + public bool IsSolid => true; + public int GetTextureIndex(BlockFace face) => 2; +} diff --git a/World/IBlock.cs b/World/IBlock.cs new file mode 100644 index 0000000..d181611 --- /dev/null +++ b/World/IBlock.cs @@ -0,0 +1,21 @@ +namespace VoxelEngine.World; + +public interface IBlock +{ + ushort Id { get; } + string Name { get; } + + // Drives face culling: a face is emitted only if its neighbor is not opaque. + bool IsOpaque { get; } + + // Reserved for future collision (not used in the baseline). + bool IsSolid { get; } + + // Atlas tile index for the given face. + int GetTextureIndex(BlockFace face); + + // Interaction hooks — capability present now, system built post-baseline. + void OnInteract() { } + void OnPlaced() { } + void OnBroken() { } +}