diff --git a/scene/Block.tscn b/scene/Block.tscn deleted file mode 100644 index 40b681f..0000000 --- a/scene/Block.tscn +++ /dev/null @@ -1,18 +0,0 @@ -[gd_scene load_steps=4 format=2] - -[ext_resource path="res://gfx/block.png" type="Texture" id=1] -[ext_resource path="res://src/Objects/Block.cs" type="Script" id=2] - -[sub_resource type="RectangleShape2D" id=1] -extents = Vector2( 8, 8 ) - -[node name="Block" type="StaticBody2D"] -collision_layer = 2 -collision_mask = 0 -script = ExtResource( 2 ) - -[node name="RectangleShape" type="CollisionShape2D" parent="."] -shape = SubResource( 1 ) - -[node name="Sprite" type="Sprite" parent="."] -texture = ExtResource( 1 ) diff --git a/scene/GameScene.tscn b/scene/GameScene.tscn index 3eef60a..50c0780 100644 --- a/scene/GameScene.tscn +++ b/scene/GameScene.tscn @@ -1,6 +1,6 @@ [gd_scene load_steps=3 format=2] -[ext_resource path="res://src/World.cs" type="Script" id=1] +[ext_resource path="res://src/World/World.cs" type="Script" id=1] [ext_resource path="res://src/Scenes/Game.cs" type="Script" id=3] [node name="Game" type="Node"] diff --git a/src/Chunk.cs b/src/Chunk.cs deleted file mode 100644 index 5f3f3cc..0000000 --- a/src/Chunk.cs +++ /dev/null @@ -1,72 +0,0 @@ -using System; -using System.Collections.Generic; -using Godot; - -public class Chunk : Node2D -{ - public const int LENGTH = 32; - public const int BIT_SHIFT = 5; - public const int BIT_MASK = ~(~0 << BIT_SHIFT); - - public (int, int) ChunkPosition { get; } - - public Chunk(int x, int y) - { - ChunkPosition = (x, y); - Position = new Vector2(x << (BIT_SHIFT + Block.BIT_SHIFT), y << (BIT_SHIFT + Block.BIT_SHIFT)); - } - - public ChunkLayer GetLayerOrNull() - => GetNodeOrNull>($"{typeof(T).Name}Layer"); - public ChunkLayer GetOrCreateLayer() - { - var layer = GetLayerOrNull(); - if (layer == null) AddChild(layer = new ChunkLayer { Name = $"{typeof(T).Name}Layer" }); - return layer; - } - - // TODO: How should we handle chunk extends? Blocks can go "outside" of the current extends, since they're centered. - // public override void _Draw() - // => DrawRect(new Rect2(Vector2.Zero, Vector2.One * (LENGTH * Block.LENGTH)), Colors.Blue, false); - -} - -public class ChunkLayer : Node2D -{ - private static readonly IEqualityComparer COMPARER = EqualityComparer.Default; - - // TODO: Use one-dimensional array? - private readonly T[,] _data = new T[Chunk.LENGTH, Chunk.LENGTH]; - private int _numNonDefault = 0; - - public T this[BlockPos pos] { - get => this[pos.X, pos.Y]; - set => this[pos.X, pos.Y] = value; - } - public T this[int x, int y] { - get { EnsureWithin(x, y); return _data[x, y]; } - set { - EnsureWithin(x, y); - var previous = _data[x, y]; - if (COMPARER.Equals(value, previous)) return; - - if (!COMPARER.Equals(previous, default)) { - if (previous is Node node) RemoveChild(node); - _numNonDefault--; - } - if (!COMPARER.Equals(value, default)) { - if (value is Node node) AddChild(node); - _numNonDefault++; - } - _data[x, y] = value; - } - } - - public bool IsDefault => _numNonDefault == 0; - - private static void EnsureWithin(int x, int y) - { - if ((x < 0) || (x >= Chunk.LENGTH) || (y < 0) || (y >= Chunk.LENGTH)) throw new ArgumentException( - $"x and y ({x},{y}) must be within chunk boundaries - (0,0) inclusive to ({Chunk.LENGTH},{Chunk.LENGTH}) exclusive"); - } -} diff --git a/src/IO/WorldSave.cs b/src/IO/WorldSave.cs deleted file mode 100644 index d28caad..0000000 --- a/src/IO/WorldSave.cs +++ /dev/null @@ -1,111 +0,0 @@ -using System; -using System.IO; -using System.Linq; -using System.Collections.Generic; -using Godot; -using File = System.IO.File; - -public class WorldSave -{ - public const string FILE_EXT = ".yf5"; - public const int MAGIC_NUMBER = 0x59463573; // "YF5s" - public const int LATEST_VERSION = 1; - - public static readonly string WORLDS_DIR = OS.GetUserDataDir() + "/worlds/"; - - - public DateTime LastSaved { get; private set; } - public int Version { get; private set; } = LATEST_VERSION; - public TimeSpan Playtime { get; set; } = TimeSpan.Zero; - - public Dictionary<(int, int), Dictionary> Chunks { get; private set; } - - - public static WorldSave ReadFromFile(string path) - { - var save = new WorldSave { LastSaved = File.GetLastAccessTime(path) }; - using (var stream = File.OpenRead(path)) { - using (var reader = new BinaryReader(stream)) { - var magic = reader.ReadInt32(); - if (magic != MAGIC_NUMBER) throw new IOException( - $"Magic number does not match ({magic:X8} != {MAGIC_NUMBER:X8})"); - - // TODO: See how to better support multiple versions, improve saving/loading. - save.Version = reader.ReadUInt16(); - save.Playtime = TimeSpan.FromSeconds(reader.ReadUInt32()); - - if (save.Version == 0) { - save.Chunks = new Dictionary<(int, int), Dictionary>(); - var numBlocks = reader.ReadInt32(); - for (var i = 0; i < numBlocks; i++) { - var blockPos = new BlockPos(reader.ReadInt32(), reader.ReadInt32()); - var blockData = (new Color(reader.ReadInt32()), reader.ReadBoolean()); - var chunkPos = blockPos.ToChunkPos(); - if (!save.Chunks.TryGetValue(chunkPos, out var blocks)) - save.Chunks.Add(chunkPos, blocks = new Dictionary()); - blocks.Add(blockPos.GlobalToChunkRel(), blockData); - } - } else if (save.Version == 1) { - var numChunks = reader.ReadInt32(); - save.Chunks = new Dictionary<(int, int), Dictionary>(numChunks); - for (var i = 0; i < numChunks; i++) { - var chunkPos = (reader.ReadInt32(), reader.ReadInt32()); - var numBlocks = (int)reader.ReadUInt16(); - var blocks = new Dictionary(numBlocks); - for (var j = 0; j < numBlocks; j++) - blocks.Add(new BlockPos(reader.ReadByte(), reader.ReadByte()), - (new Color(reader.ReadInt32()), reader.ReadBoolean())); - save.Chunks.Add(chunkPos, blocks); - } - } else throw new IOException($"Version {save.Version} not supported (latest version: {LATEST_VERSION})"); - } - } - return save; - } - - public void WriteToFile(string path) - { - using (var stream = File.OpenWrite(path + ".tmp")) { - using (var writer = new BinaryWriter(stream)) { - writer.Write(MAGIC_NUMBER); - writer.Write((ushort)LATEST_VERSION); - writer.Write((uint)Playtime.TotalSeconds); - - writer.Write(Chunks.Count); - foreach (var ((chunkX, chunkY), blocks) in Chunks) { - writer.Write(chunkX); - writer.Write(chunkY); - writer.Write((ushort)blocks.Count); - foreach (var ((blockX, blockY), (color, unbreakable)) in blocks) { - writer.Write((byte)blockX); - writer.Write((byte)blockY); - writer.Write(color.ToRgba32()); - writer.Write(unbreakable); - } - } - } - } - new Godot.Directory().Rename(path + ".tmp", path); - LastSaved = File.GetLastWriteTime(path); - } - - - public void WriteDataFromWorld(World world) - => Chunks = world.Chunks.ToDictionary( - chunk => chunk.ChunkPosition, - chunk => chunk.GetLayerOrNull() - .GetChildren().ToDictionary( - block => block.ChunkLocalBlockPos, - block => (block.Color, block.Unbreakable))); - - public void ReadDataIntoWorld(World world) - { - RPC.Reliable(world.ClearChunks); - foreach (var (chunkPos, blocks) in Chunks) { - foreach (var (blockPos, (color, unbreakable)) in blocks) { - var (x, y) = blockPos.ChunkRelToGlobal(chunkPos); - world.SpawnBlock(x, y, color, unbreakable); - } - } - } -} diff --git a/src/Items/CreativeBuilding.cs b/src/Items/CreativeBuilding.cs index 7db3ac0..033d512 100644 --- a/src/Items/CreativeBuilding.cs +++ b/src/Items/CreativeBuilding.cs @@ -78,7 +78,7 @@ public class CreativeBuilding : Node2D } var world = this.GetWorld(); - bool IsBlockAt(BlockPos pos) => world.GetBlockAt(pos) != null; + bool IsBlockAt(BlockPos pos) => world.GetBlockDataAt(pos).Block != null; _canBuild = !IsBlockAt(_startPos) && Facings.All.Any(pos => IsBlockAt(_startPos + pos.ToBlockPos())); Update(); // Make sure _Draw is being called. @@ -94,7 +94,7 @@ public class CreativeBuilding : Node2D var world = this.GetWorld(); foreach (var pos in GetBlockPositions(_startPos, _direction, _length)) { - var hasBlock = world.GetBlockAt(pos) != null; + var hasBlock = world.GetBlockDataAt(pos).Block != null; var color = (_currentMode != BuildMode.Breaking) ? ((_canBuild && !hasBlock) ? green : red) : (hasBlock ? black : red); @@ -122,10 +122,10 @@ public class CreativeBuilding : Node2D var start = new BlockPos(x, y); var world = this.GetWorld(); foreach (var pos in GetBlockPositions(start, direction, length)) { - if (world.GetBlockAt(pos) != null) continue; + if (world.GetBlockDataAt(pos).Block != null) continue; var color = Player.Color.Blend(Color.FromHsv(0.0F, 0.0F, GD.Randf(), 0.2F)); RPC.Reliable(world.GetPlayersTracking(pos.ToChunkPos(), true), - world.SpawnBlock, pos.X, pos.Y, color, false); + world.SetBlockData, pos.X, pos.Y, color.ToRgba32()); } } @@ -142,10 +142,10 @@ public class CreativeBuilding : Node2D var start = new BlockPos(x, y); var world = this.GetWorld(); foreach (var pos in GetBlockPositions(start, direction, length)) { - var block = world.GetBlockAt(pos); - if (block?.Unbreakable != false) continue; + var data = world.GetBlockDataAt(pos); + if (data.Block == null) continue; RPC.Reliable(world.GetPlayersTracking(pos.ToChunkPos(), true), - world.DespawnBlock, pos.X, pos.Y); + world.SetBlockData, pos.X, pos.Y, 0); } } } diff --git a/src/Network/IntegratedServer.cs b/src/Network/IntegratedServer.cs index aa7a02e..96c8c9a 100644 --- a/src/Network/IntegratedServer.cs +++ b/src/Network/IntegratedServer.cs @@ -12,18 +12,11 @@ public class IntegratedServer : Node _sceneTree.Root.RenderTargetUpdateMode = Godot.Viewport.UpdateMode.Disabled; // VisualServer.ViewportSetActive(_sceneTree.Root.GetViewportRid(), false); - var scene = GD.Load("res://scene/ServerScene.tscn").Init(); + var scene = GD.Load("res://scene/ServerScene.tscn").Instance(); _sceneTree.Root.AddChild(scene, true); _sceneTree.CurrentScene = scene; Server = _sceneTree.Root.GetChild(0); - // Spawn default blocks. - var world = Server.GetWorld(); - for (var x = -6; x <= 6; x++) { - var color = Color.FromHsv(GD.Randf(), 0.1F, 1.0F); - world.SpawnBlock(x, 3, color, true); - } - var port = Server.StartSingleplayer(); this.GetClient().Connect("127.0.0.1", port); } diff --git a/src/Objects/Block.cs b/src/Objects/Block.cs deleted file mode 100644 index 8d3277d..0000000 --- a/src/Objects/Block.cs +++ /dev/null @@ -1,15 +0,0 @@ -using Godot; - -public class Block : StaticBody2D, IInitializable -{ - public const int LENGTH = 16; - public const int BIT_SHIFT = 4; - - public BlockPos GlobalBlockPos { get => BlockPos.FromVector(GlobalPosition); set => GlobalPosition = value.ToVector(); } - public BlockPos ChunkLocalBlockPos { get => BlockPos.FromVector(Position); set => Position = value.ToVector(); } - public Color Color { get => Sprite.SelfModulate; set => Sprite.SelfModulate = value; } - public bool Unbreakable { get; set; } = false; - - public Sprite Sprite { get; private set; } - public void Initialize() => Sprite = GetNode("Sprite"); -} diff --git a/src/Scenes/Server.cs b/src/Scenes/Server.cs index 7e40f13..d357568 100644 --- a/src/Scenes/Server.cs +++ b/src/Scenes/Server.cs @@ -106,6 +106,6 @@ public class Server : Game // Local player stays around for reconnecting. if (LocalPlayer == player) return; - RPC.Reliable(world.Despawn, world.GetPathTo(player)); + RPC.Reliable(world.Despawn, world.GetPathTo(player), true); } } diff --git a/src/Utility/Extensions.cs b/src/Utility/Extensions.cs index 3e45860..d0fb9a7 100644 --- a/src/Utility/Extensions.cs +++ b/src/Utility/Extensions.cs @@ -5,14 +5,6 @@ using Godot; public static class Extensions { - public static T Init(this PackedScene scene) - where T : Node - { - var node = scene.Instance(); - (node as IInitializable)?.Initialize(); - return node; - } - public static Game GetGame(this Node node) => node.GetTree().Root.GetChild(0); public static Client GetClient(this Node node) @@ -57,8 +49,3 @@ public static class Extensions public static void Deconstruct(this KeyValuePair kvp, out TKey key, out TValue value) { key = kvp.Key; value = kvp.Value; } } - -public interface IInitializable -{ - void Initialize(); -} diff --git a/src/Utility/SceneCache.cs b/src/Utility/SceneCache.cs new file mode 100644 index 0000000..00ba27f --- /dev/null +++ b/src/Utility/SceneCache.cs @@ -0,0 +1,21 @@ +using System; +using Godot; + +public static class SceneCache where T : Node +{ + private static readonly PackedScene SCENE + = GD.Load($"res://scene/{typeof(T).Name}.tscn"); + + public static T Instance(Action initFunc = null) + { + var node = SCENE.Instance(); + (node as IInitializable)?.Initialize(); + initFunc?.Invoke(node); + return node; + } +} + +public interface IInitializable +{ + void Initialize(); +} diff --git a/src/World.cs b/src/World.cs deleted file mode 100644 index b2cc960..0000000 --- a/src/World.cs +++ /dev/null @@ -1,107 +0,0 @@ -using System.Collections.Generic; -using Godot; - -public class World : Node -{ - private static readonly PackedScene BLOCK = GD.Load("res://scene/Block.tscn"); - private static readonly PackedScene PLAYER = GD.Load("res://scene/Player.tscn"); - private static readonly PackedScene LOCAL_PLAYER = GD.Load("res://scene/LocalPlayer.tscn"); - private static readonly PackedScene HIT_DECAL = GD.Load("res://scene/HitDecal.tscn"); - - internal Node PlayerContainer { get; } - internal Node ChunkContainer { get; } - - public World() - { - AddChild(PlayerContainer = new Node { Name = "Players" }); - AddChild(ChunkContainer = new Node { Name = "Chunks" }); - } - - public IEnumerable Players - => PlayerContainer.GetChildren(); - public Player GetPlayer(int networkID) - => PlayerContainer.GetNodeOrNull(networkID.ToString()); - public void ClearPlayers() - { foreach (var player in Players) player.RemoveFromParent(); } - - public IEnumerable Chunks - => ChunkContainer.GetChildren(); - public Chunk GetChunkOrNull((int X, int Y) chunkPos) - => ChunkContainer.GetNodeOrNull($"Chunk ({chunkPos.X}, {chunkPos.Y})"); - public Chunk GetOrCreateChunk((int X, int Y) chunkPos) - => ChunkContainer.GetOrCreateChild($"Chunk ({chunkPos.X}, {chunkPos.Y})", () => new Chunk(chunkPos.X, chunkPos.Y)); - [PuppetSync] public void ClearChunks() - { foreach (var chunk in Chunks) chunk.RemoveFromParent(); } - - - public Block GetBlockAt(BlockPos position) - => GetChunkOrNull(position.ToChunkPos()) - ?.GetLayerOrNull()?[position.GlobalToChunkRel()]; - [PuppetSync] - public void SpawnBlock(int x, int y, Color color, bool unbreakable) - { - var blockPos = new BlockPos(x, y); - var block = BLOCK.Init(); - block.Name = blockPos.ToString(); - block.Color = color; - block.Unbreakable = unbreakable; - block.ChunkLocalBlockPos = blockPos.GlobalToChunkRel(); - - GetOrCreateChunk(blockPos.ToChunkPos()) - .GetOrCreateLayer()[block.ChunkLocalBlockPos] = block; - } - [PuppetSync] - public void DespawnBlock(int x, int y) - { - var blockPos = new BlockPos(x, y); - var blockLayer = GetChunkOrNull(blockPos.ToChunkPos())?.GetLayerOrNull(); - if (blockLayer != null) blockLayer[blockPos.GlobalToChunkRel()] = null; - } - - [PuppetSync] - public void SpawnPlayer(int networkID, Vector2 position) - { - var player = SceneCache.Instance(); - player.NetworkID = networkID; - player.Position = position; - PlayerContainer.AddChild(player); - - if (player.IsLocal) { - player.AddChild(new PlayerMovement { Name = "PlayerMovement" }); - player.AddChild(new Camera2D { Name = "Camera", Current = true }); - this.GetClient().FireLocalPlayerSpawned(player); - } - - if (this.GetGame() is Server) { - player.VisibilityTracker.ChunkTracked += (chunkPos) => { - var chunk = GetChunkOrNull(chunkPos); - if (chunk == null) return; - foreach (var block in chunk.GetLayerOrNull().GetChildren()) - RPC.Reliable(player.NetworkID, SpawnBlock, - block.GlobalBlockPos.X, block.GlobalBlockPos.Y, - block.Color, block.Unbreakable); - }; - player.VisibilityTracker.ChunkUntracked += (chunkPos) => { - var chunk = GetChunkOrNull(chunkPos); - if (chunk == null) return; - RPC.Reliable(player.NetworkID, Despawn, GetPathTo(chunk)); - }; - } - } - - [Puppet] - public void SpawnHit(NodePath spritePath, Vector2 hitPosition, Color color) - { - var hit = HIT_DECAL.Init(); - var sprite = this.GetWorld().GetNode(spritePath); - hit.Add(sprite, hitPosition, color); - } - - [PuppetSync] - public void Despawn(NodePath path) - { - var node = GetNode(path); - node.GetParent().RemoveChild(node); - node.QueueFree(); - } -} diff --git a/src/World/Block/Block.cs b/src/World/Block/Block.cs new file mode 100644 index 0000000..acb22e6 --- /dev/null +++ b/src/World/Block/Block.cs @@ -0,0 +1,19 @@ +using Godot; + +public class Block +{ + public const int LENGTH = 16; + public const int BIT_SHIFT = 4; + + + public static readonly Block DEFAULT = new Block( + GD.Load("res://gfx/block.png"), + new RectangleShape2D { Extents = new Vector2(0.5F, 0.5F) * LENGTH }); + + + public Texture Texture { get; } + public Shape2D Shape { get; } + + public Block(Texture texture, Shape2D shape) + { Texture = texture; Shape = shape; } +} diff --git a/src/World/Chunk/BlockLayer.cs b/src/World/Chunk/BlockLayer.cs new file mode 100644 index 0000000..58fdf98 --- /dev/null +++ b/src/World/Chunk/BlockLayer.cs @@ -0,0 +1,83 @@ +using System.IO; +using Godot; + +public readonly struct BlockData +{ + public Block Block { get; } // TODO: Replace with 2-byte or smaller integer identifier? + public int RawColor { get; } // TODO: Replace with smaller representation? + // Perhaps we can fit this into 4 bytes total? + public Color Color => new Color(RawColor); + public BlockData(Block block, int rawColor) { Block = block; RawColor = rawColor; } + public BlockData(Block block, Color color) { Block = block; RawColor = color.ToRgba32(); } +} + +public class BlockLayer : BasicChunkLayer +{ + private MeshInstance2D _render = null; + private StaticBody2D _collider = null; + + public override void _Process(float delta) + { + if (!Dirty) return; + Dirty = false; + + var st = (SurfaceTool)null; + if (this.GetGame() is Client) { + if (_render == null) AddChild(_render = new MeshInstance2D + { Texture = GD.Load("res://gfx/block.png") }); + st = new SurfaceTool(); + st.Begin(Mesh.PrimitiveType.Triangles); + } + + _collider?.RemoveFromParent(); + if (IsDefault) _collider = null; + else AddChild(_collider = new StaticBody2D()); + + var size = Block.LENGTH; + var index = 0; + for (var i = 0; i < Chunk.LENGTH * Chunk.LENGTH; i++) { + var data = Data[i]; + if (data.Block == null) continue; + + var x = (float)(i & Chunk.BIT_MASK); + var y = (float)(i >> Chunk.BIT_SHIFT); + + if (_render != null) { + st.AddColor(data.Color); + st.AddUv(new Vector2(0, 0)); st.AddVertex(new Vector3(x - 0.5F, y - 0.5F, 0) * size); + st.AddUv(new Vector2(1, 0)); st.AddVertex(new Vector3(x + 0.5F, y - 0.5F, 0) * size); + st.AddUv(new Vector2(1, 1)); st.AddVertex(new Vector3(x + 0.5F, y + 0.5F, 0) * size); + st.AddUv(new Vector2(0, 1)); st.AddVertex(new Vector3(x - 0.5F, y + 0.5F, 0) * size); + + st.AddIndex(index); st.AddIndex(index + 1); st.AddIndex(index + 2); + st.AddIndex(index); st.AddIndex(index + 3); st.AddIndex(index + 2); + index += 4; + } + + var ownerID = _collider.CreateShapeOwner(null); + _collider.ShapeOwnerAddShape(ownerID, data.Block.Shape); + _collider.ShapeOwnerSetTransform(ownerID, Transform2D.Identity.Translated(new Vector2(x, y) * size)); + } + + if (_render != null) + _render.Mesh = st.Commit(); + } + + public override void Read(BinaryReader reader) + { + NonDefaultCount = 0; + for (var i = 0; i < Chunk.LENGTH * Chunk.LENGTH; i++) { + var color = reader.ReadInt32(); + if (color == 0) continue; + Data[i] = new BlockData(Block.DEFAULT, color); + NonDefaultCount++; + } + Dirty = true; + } + + public override void Write(BinaryWriter writer) + { + for (var i = 0; i < Chunk.LENGTH * Chunk.LENGTH; i++) + writer.Write(Data[i].RawColor); // Is 0 if block is not set. + } +} diff --git a/src/World/Chunk/Chunk.cs b/src/World/Chunk/Chunk.cs new file mode 100644 index 0000000..fe0e901 --- /dev/null +++ b/src/World/Chunk/Chunk.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; +using System.Linq; +using Godot; + +public class Chunk : Node2D +{ + public const int LENGTH = 32; + public const int BIT_SHIFT = 5; + public const int BIT_MASK = ~(~0 << BIT_SHIFT); + + public (int X, int Y) ChunkPosition { get; } + public IEnumerable Layers + => GetChildren().OfType(); + + public Chunk((int X, int Y) chunkPos) + { + Name = $"Chunk ({chunkPos})"; + ChunkPosition = chunkPos; + Position = new Vector2(chunkPos.X << (BIT_SHIFT + Block.BIT_SHIFT), + chunkPos.Y << (BIT_SHIFT + Block.BIT_SHIFT)); + } + + public T GetLayerOrNull() => (T)GetLayerOrNull(typeof(T).Name); + public T GetOrCreateLayer() => (T)GetOrCreateLayer(typeof(T).Name); + + public IChunkLayer GetLayerOrNull(string name) + => GetNodeOrNull(name); + public IChunkLayer GetOrCreateLayer(string name) + { + var layer = GetLayerOrNull(name); + if (layer == null) AddChild((Node)(layer = ChunkLayerRegistry.Create(name))); + return layer; + } + + // TODO: How should we handle chunk extends? Blocks can go "outside" of the current extends, since they're centered. + // public override void _Draw() + // => DrawRect(new Rect2(Vector2.Zero, Vector2.One * (LENGTH * Block.LENGTH)), Colors.Blue, false); +} diff --git a/src/World/Chunk/ChunkLayer.cs b/src/World/Chunk/ChunkLayer.cs new file mode 100644 index 0000000..fc7f49f --- /dev/null +++ b/src/World/Chunk/ChunkLayer.cs @@ -0,0 +1,113 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Godot; + +public interface IChunkLayer +{ + bool IsDefault { get; } + void Read(BinaryReader reader); + void Write(BinaryWriter writer); +} + +public interface IChunkLayer : IChunkLayer +{ + T this[BlockPos pos] { get; set; } + T this[int x, int y] { get; set; } +} + + +public static class ChunkLayerRegistry +{ + private static readonly Dictionary _types = new Dictionary(); + + static ChunkLayerRegistry() + => Register(); + + public static void Register() + where T : Node2D, IChunkLayer + => _types.Add(typeof(T).Name, typeof(T)); + + public static IChunkLayer Create(string name) + { + var layer = (IChunkLayer)Activator.CreateInstance(_types[name]); + ((Node)layer).Name = name; + return layer; + } +} + +public static class ChunkLayerExtensions +{ + public static void FromBytes(this IChunkLayer layer, byte[] data) + { + using (var stream = new MemoryStream(data)) + layer.Read(stream); + } + public static void Read(this IChunkLayer layer, Stream stream) + { + using (var reader = new BinaryReader(stream)) + layer.Read(reader); + } + + public static byte[] ToBytes(this IChunkLayer layer) + { + using (var stream = new MemoryStream()) { + layer.Write(stream); + return stream.ToArray(); + } + } + public static void Write(this IChunkLayer layer, Stream stream) + { + using (var writer = new BinaryWriter(stream)) + layer.Write(writer); + } +} + + +public abstract class BasicChunkLayer : Node2D, IChunkLayer +{ + private static readonly IEqualityComparer COMPARER = EqualityComparer.Default; + + protected T[] Data { get; } = new T[Chunk.LENGTH * Chunk.LENGTH]; + protected bool Dirty { get; set; } = true; + + public Chunk Chunk => GetParent(); + public int NonDefaultCount { get; protected set; } = 0; + public bool IsDefault => NonDefaultCount == 0; + + public T this[BlockPos pos] { + get => this[pos.X, pos.Y]; + set => this[pos.X, pos.Y] = value; + } + public T this[int x, int y] { + get { + EnsureWithin(x, y); + return Data[x | y << Chunk.BIT_SHIFT]; + } + set { + EnsureWithin(x, y); + var index = x | y << Chunk.BIT_SHIFT; + var previous = Data[index]; + if (COMPARER.Equals(value, previous)) return; + if (!COMPARER.Equals(previous, default)) { + if (previous is Node node) RemoveChild(node); + NonDefaultCount--; + } + if (!COMPARER.Equals(value, default)) { + if (value is Node node) AddChild(node); + NonDefaultCount++; + } + Data[index] = value; + Dirty = true; + } + } + + private static void EnsureWithin(int x, int y) + { + if ((x < 0) || (x >= Chunk.LENGTH) || (y < 0) || (y >= Chunk.LENGTH)) throw new ArgumentException( + $"x and y ({x},{y}) must be within chunk boundaries - (0,0) inclusive to ({Chunk.LENGTH},{Chunk.LENGTH}) exclusive"); + } + + public abstract void Read(BinaryReader reader); + public abstract void Write(BinaryWriter writer); +} diff --git a/src/World/Generation/GeneratorSimple.cs b/src/World/Generation/GeneratorSimple.cs new file mode 100644 index 0000000..7bc06db --- /dev/null +++ b/src/World/Generation/GeneratorSimple.cs @@ -0,0 +1,41 @@ +using System; +using Godot; + +public class GeneratorSimple : IWorldGenerator +{ + private int _seed; + + public string Name => "Simple"; + + public void SetSeed(int seed) => _seed = seed; + + public void Generate(Chunk chunk) + { + if (chunk.ChunkPosition.Y != 0) return; + + var rnd = new Random(_seed ^ chunk.ChunkPosition.GetHashCode()); + var blocks = chunk.GetOrCreateLayer(); + + for (var x = 0; x < Chunk.LENGTH; x++) { + var grassDepth = rnd.Next(1, 4); + var dirtDepth = rnd.Next(6, 9); + var stoneDepth = rnd.Next(12, 16); + for (var y = 0; y < stoneDepth; y++) { + var color = (y < grassDepth) ? Colors.LawnGreen + : (y < dirtDepth) ? Colors.SaddleBrown + : Colors.Gray; + color = color.Lightened(GD.Randf() * 0.15F); + blocks[x, 3 + y] = new BlockData(Block.DEFAULT, color); + } + } + + // TODO: Make it easier to create "structures" that cover multiple chunks. + // TODO: These are supposed to be unbreakable. + if (chunk.ChunkPosition == (-1, 0)) + for (var x = Chunk.LENGTH - 6; x < Chunk.LENGTH; x++) + blocks[x, 3] = new BlockData(Block.DEFAULT, Color.FromHsv(GD.Randf(), 0.1F, 1.0F)); + else if (chunk.ChunkPosition == (0, 0)) + for (var x = 0; x <= 6; x++) + blocks[x, 3] = new BlockData(Block.DEFAULT, Color.FromHsv(GD.Randf(), 0.1F, 1.0F)); + } +} diff --git a/src/World/Generation/GeneratorVoid.cs b/src/World/Generation/GeneratorVoid.cs new file mode 100644 index 0000000..3fbaaac --- /dev/null +++ b/src/World/Generation/GeneratorVoid.cs @@ -0,0 +1,23 @@ +using Godot; + +public class GeneratorVoid : IWorldGenerator +{ + public string Name => "Void"; + + public void SetSeed(int seed) { } + + public void Generate(Chunk chunk) + { + // TODO: Make it easier to create "structures" that cover multiple chunks. + // TODO: These are supposed to be unbreakable. + if (chunk.ChunkPosition == (-1, 0)) { + var blocks = chunk.GetOrCreateLayer(); + for (var x = Chunk.LENGTH - 6; x < Chunk.LENGTH; x++) + blocks[x, 3] = new BlockData(Block.DEFAULT, Color.FromHsv(GD.Randf(), 0.1F, 1.0F)); + } else if (chunk.ChunkPosition == (0, 0)) { + var blocks = chunk.GetOrCreateLayer(); + for (var x = 0; x <= 6; x++) + blocks[x, 3] = new BlockData(Block.DEFAULT, Color.FromHsv(GD.Randf(), 0.1F, 1.0F)); + } + } +} diff --git a/src/World/Generation/IWorldGenerator.cs b/src/World/Generation/IWorldGenerator.cs new file mode 100644 index 0000000..04b4261 --- /dev/null +++ b/src/World/Generation/IWorldGenerator.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; + +public interface IWorldGenerator +{ + string Name { get; } + void SetSeed(int seed); // TODO: Allow loading additional generator options. + void Generate(Chunk chunk); +} + +public static class WorldGeneratorRegistry +{ + private static readonly Dictionary _generators + = new Dictionary(); + + static WorldGeneratorRegistry() + { + Register(new GeneratorVoid()); + Register(new GeneratorSimple()); + } + + public static void Register(IWorldGenerator generator) + => _generators.Add(generator.Name, generator); + public static IWorldGenerator GetOrNull(string name) + => _generators.TryGetValue(name, out var value) ? value : null; +} diff --git a/src/World/World.cs b/src/World/World.cs new file mode 100644 index 0000000..d8b8426 --- /dev/null +++ b/src/World/World.cs @@ -0,0 +1,133 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Godot; + +public class World : Node +{ + internal Node PlayerContainer { get; } + internal Node ChunkContainer { get; } + + public IWorldGenerator Generator { get; set; } = WorldGeneratorRegistry.GetOrNull("Simple"); + public int Seed { get; set; } = unchecked((int)GD.Randi()); + + public World() + { + AddChild(PlayerContainer = new Node { Name = "Players" }); + AddChild(ChunkContainer = new Node { Name = "Chunks" }); + } + + public IEnumerable Players + => PlayerContainer.GetChildren(); + public Player GetPlayer(int networkID) + => PlayerContainer.GetNodeOrNull(networkID.ToString()); + public void ClearPlayers() + { foreach (var player in Players) player.RemoveFromParent(); } + + public IEnumerable Chunks + => ChunkContainer.GetChildren(); + public Chunk GetChunkOrNull((int X, int Y) chunkPos) + => ChunkContainer.GetNodeOrNull($"Chunk ({chunkPos})"); + public Chunk GetOrCreateChunk((int X, int Y) chunkPos, bool generate = false) + => ChunkContainer.GetOrCreateChild($"Chunk ({chunkPos})", () => { + var chunk = new Chunk(chunkPos); + if (generate) Generator.Generate(chunk); + return chunk; + }); + [PuppetSync] public void ClearChunks() + { foreach (var chunk in Chunks) chunk.RemoveFromParent(); } + + + public BlockData GetBlockDataAt(BlockPos position) + => GetChunkOrNull(position.ToChunkPos()) + ?.GetLayerOrNull()?[position.GlobalToChunkRel()] ?? default; + + [PuppetSync] + public void SetBlockData(int x, int y, int color) + { + var blockPos = new BlockPos(x, y); + GetOrCreateChunk(blockPos.ToChunkPos()) + .GetOrCreateLayer()[blockPos.GlobalToChunkRel()] + = (color != 0) ? new BlockData(Block.DEFAULT, color) : default; + } + + [PuppetSync] + public void SpawnChunk(int chunkX, int chunkY, byte[] data) + { + var chunk = GetOrCreateChunk((chunkX, chunkY)); + using (var stream = new MemoryStream(data)) { + using (var reader = new BinaryReader(stream)) { + var numLayers = reader.ReadByte(); + for (var i = 0; i < numLayers; i++) { + var name = reader.ReadString(); + var layer = chunk.GetOrCreateLayer(name); + layer.Read(reader); + } + } + } + } + + private static byte[] ChunkToBytes(Chunk chunk) + { + using (var stream = new MemoryStream()) { + using (var writer = new BinaryWriter(stream)) { + var layers = chunk.GetChildren() + .OfType() + .Where(layer => !layer.IsDefault) + .ToArray(); + + writer.Write((byte)layers.Length); + foreach (var layer in layers) { + writer.Write(layer.GetType().Name); + layer.Write(writer); + } + } + return stream.ToArray(); + } + } + + [PuppetSync] + public void SpawnPlayer(int networkID, Vector2 position) + { + var player = SceneCache.Instance(); + player.NetworkID = networkID; + player.Position = position; + PlayerContainer.AddChild(player); + + if (player.IsLocal) { + player.AddChild(new PlayerMovement { Name = "PlayerMovement" }); + player.AddChild(new Camera2D { Name = "Camera", Current = true }); + this.GetClient().FireLocalPlayerSpawned(player); + } + + if (this.GetGame() is Server) { + player.VisibilityTracker.ChunkTracked += (chunkPos) => { + var chunk = GetOrCreateChunk(chunkPos, generate: true); + RPC.Reliable(player.NetworkID, SpawnChunk, + chunk.ChunkPosition.X, chunk.ChunkPosition.Y, ChunkToBytes(chunk)); + }; + player.VisibilityTracker.ChunkUntracked += (chunkPos) => { + var chunk = GetChunkOrNull(chunkPos); + if (chunk == null) return; + RPC.Reliable(player.NetworkID, Despawn, GetPathTo(chunk), false); + }; + } + } + + [Puppet] + public void SpawnHit(NodePath spritePath, Vector2 hitPosition, Color color) + { + var hit = SceneCache.Instance(); + var sprite = this.GetWorld().GetNode(spritePath); + hit.Add(sprite, hitPosition, color); + } + + [PuppetSync] + public void Despawn(NodePath path, bool errorIfMissing) + { + var node = GetNode(path); + if ((node == null) && !errorIfMissing) return; + node.GetParent().RemoveChild(node); + node.QueueFree(); + } +} diff --git a/src/World/WorldSave.cs b/src/World/WorldSave.cs new file mode 100644 index 0000000..176a5a4 --- /dev/null +++ b/src/World/WorldSave.cs @@ -0,0 +1,136 @@ +using System; +using System.IO; +using System.Linq; +using System.Collections.Generic; +using Godot; +using File = System.IO.File; + +public class WorldSave +{ + public const string FILE_EXT = ".yf5"; + public const int MAGIC_NUMBER = 0x59463573; // "YF5s" + public const int LATEST_VERSION = 1; + + public static readonly string WORLDS_DIR = OS.GetUserDataDir() + "/worlds/"; + + + public int Version { get; private set; } = LATEST_VERSION; + public TimeSpan Playtime { get; set; } = TimeSpan.Zero; + public DateTime LastSaved { get; private set; } + + public string Generator { get; private set; } + public int Seed { get; private set; } + + public Dictionary<(int X, int Y), Dictionary> ChunkData { get; private set; } + + + public static WorldSave ReadFromFile(string path) + { + var save = new WorldSave { LastSaved = File.GetLastAccessTime(path) }; + using (var stream = File.OpenRead(path)) { + using (var reader = new BinaryReader(stream)) { + var magic = reader.ReadInt32(); + if (magic != MAGIC_NUMBER) throw new IOException( + $"Magic number does not match ({magic:X8} != {MAGIC_NUMBER:X8})"); + + // TODO: See how to better support multiple versions, improve saving/loading. + save.Version = reader.ReadUInt16(); + save.Playtime = TimeSpan.FromSeconds(reader.ReadUInt32()); + + if (save.Version == 0) { + save.Seed = unchecked((int)GD.Randi()); + save.Generator = "Void"; + + var tempBlockLayers = new Dictionary<(int X, int Y), BlockLayer>(); + var numBlocks = reader.ReadInt32(); + for (var i = 0; i < numBlocks; i++) { + var blockPos = new BlockPos(reader.ReadInt32(), reader.ReadInt32()); + var rawColor = reader.ReadInt32(); + var unbreakable = reader.ReadBoolean(); // TODO + + var chunkPos = blockPos.ToChunkPos(); + if (!tempBlockLayers.TryGetValue(chunkPos, out var blocks)) + tempBlockLayers.Add(chunkPos, blocks = new BlockLayer()); + blocks[blockPos.GlobalToChunkRel()] = new BlockData(Block.DEFAULT, rawColor); + } + save.ChunkData = tempBlockLayers.ToDictionary(kvp => kvp.Key, + kvp => new Dictionary { [nameof(BlockLayer)] = kvp.Value.ToBytes() }); + } else if (save.Version == 1) { + save.Generator = reader.ReadString(); + save.Seed = reader.ReadInt32(); + + var numChunks = reader.ReadInt32(); + save.ChunkData = new Dictionary<(int X, int Y), Dictionary>(); + for (var i = 0; i < numChunks; i++) { + var chunkPos = (reader.ReadInt32(), reader.ReadInt32()); + var chunk = new Dictionary(); + save.ChunkData.Add(chunkPos, chunk); + + var numLayers = reader.ReadByte(); + for (var j = 0; j < numLayers; j++) { + var name = reader.ReadString(); + var count = reader.ReadInt32(); + var data = reader.ReadBytes(count); + chunk.Add(name, data); + } + } + } else throw new IOException($"Version {save.Version} not supported (latest version: {LATEST_VERSION})"); + } + } + return save; + } + + public void WriteToFile(string path) + { + using (var stream = File.OpenWrite(path + ".tmp")) { + using (var writer = new BinaryWriter(stream)) { + writer.Write(MAGIC_NUMBER); + writer.Write((ushort)LATEST_VERSION); + writer.Write((uint)Playtime.TotalSeconds); + + writer.Write(Generator); + writer.Write(Seed); + + writer.Write(ChunkData.Count); + foreach (var ((chunkX, chunkY), layers) in ChunkData) { + writer.Write(chunkX); + writer.Write(chunkY); + writer.Write((byte)layers.Count); + foreach (var (name, data) in layers) { + writer.Write(name); + writer.Write(data.Length); + writer.Write(data); + } + } + } + } + new Godot.Directory().Rename(path + ".tmp", path); + LastSaved = File.GetLastWriteTime(path); + } + + + public void WriteDataFromWorld(World world) + { + Generator = world.Generator.Name; + Seed = world.Seed; + + ChunkData = world.Chunks.ToDictionary( + chunk => chunk.ChunkPosition, + chunk => chunk.Layers + .Where(layer => !layer.IsDefault) + .ToDictionary(layer => layer.GetType().Name, layer => layer.ToBytes())); + } + + public void ReadDataIntoWorld(World world) + { + world.Generator = WorldGeneratorRegistry.GetOrNull(Generator); + world.Seed = Seed; + + RPC.Reliable(world.ClearChunks); + foreach (var (chunkPos, layers) in ChunkData) { + var chunk = world.GetOrCreateChunk(chunkPos); + foreach (var (name, data) in layers) + chunk.GetOrCreateLayer(name).FromBytes(data); + } + } +}