- Add SaveRegistry and SaveAttribute - Add Save, which handles Saving / Loading - Add SpawnRegistry and SpawnAttribute Allows spawning objects by ID or Type - PropertyDeSerializer handles de/serialization of any [Sync] and [Save] properties - Removing objects now done by removing it from the scene tree - Add "World" tab to EscapeMenu - Modify Heartbit font's 1 and V charactersmain
parent
7861e524ab
commit
fdf1782069
26 changed files with 837 additions and 222 deletions
Binary file not shown.
@ -0,0 +1,133 @@ |
||||
using System; |
||||
using System.Text; |
||||
using Godot; |
||||
using Path = System.IO.Path; |
||||
using File = System.IO.File; |
||||
using Directory = System.IO.Directory; |
||||
using System.Linq; |
||||
using static Godot.NetworkedMultiplayerPeer; |
||||
|
||||
public class EscapeMenuWorld : CenterContainer |
||||
{ |
||||
[Export] public NodePath FilenamePath { get; set; } |
||||
[Export] public NodePath LastSavedPath { get; set; } |
||||
[Export] public NodePath PlaytimePath { get; set; } |
||||
[Export] public NodePath QuickSavePath { get; set; } |
||||
[Export] public NodePath SaveAsPath { get; set; } |
||||
[Export] public NodePath SaveFileDialogPath { get; set; } |
||||
[Export] public NodePath LoadFileDialogPath { get; set; } |
||||
|
||||
public Label FilenameLabel { get; private set; } |
||||
public Label LastSavedLabel { get; private set; } |
||||
public Label PlaytimeLabel { get; private set; } |
||||
public Button QuickSaveButton { get; private set; } |
||||
public Button SaveAsButton { get; private set; } |
||||
public FileDialog SaveFileDialog { get; private set; } |
||||
public FileDialog LoadFileDialog { get; private set; } |
||||
|
||||
private Node _world; |
||||
private TimeSpan _playtime; |
||||
private string _currentWorld; |
||||
|
||||
public override void _Ready() |
||||
{ |
||||
_world = this.GetClient().GetNode("World"); |
||||
|
||||
FilenameLabel = GetNode<Label>(FilenamePath); |
||||
LastSavedLabel = GetNode<Label>(LastSavedPath); |
||||
PlaytimeLabel = GetNode<Label>(PlaytimePath); |
||||
QuickSaveButton = GetNode<Button>(QuickSavePath); |
||||
SaveAsButton = GetNode<Button>(SaveAsPath); |
||||
SaveFileDialog = GetNode<FileDialog>(SaveFileDialogPath); |
||||
LoadFileDialog = GetNode<FileDialog>(LoadFileDialogPath); |
||||
|
||||
// TODO: Reset this when going back to singleplayer after having connected to a multiplayer server. |
||||
QuickSaveButton.Visible = false; |
||||
SaveAsButton.Text = "Save World As..."; |
||||
SaveFileDialog.GetOk().Text = "Save"; |
||||
|
||||
var worldsFolder = OS.GetUserDataDir() + "/worlds/"; |
||||
Directory.CreateDirectory(worldsFolder); |
||||
SaveFileDialog.CurrentPath = worldsFolder; |
||||
LoadFileDialog.CurrentPath = worldsFolder; |
||||
|
||||
this.GetClient().StatusChanged += OnStatusChanged; |
||||
} |
||||
|
||||
public override void _Process(float delta) |
||||
{ |
||||
if (!GetTree().Paused || (_world.PauseMode != PauseModeEnum.Stop)) |
||||
_playtime += TimeSpan.FromSeconds(delta); |
||||
|
||||
var b = new StringBuilder(); |
||||
if (_playtime.Days > 0) b.Append(_playtime.Days).Append("d "); |
||||
if (_playtime.Hours > 0) b.Append(_playtime.Hours).Append("h "); |
||||
if (_playtime.Minutes < 10) b.Append('0'); b.Append(_playtime.Minutes).Append("m "); |
||||
if (_playtime.Seconds < 10) b.Append('0'); b.Append(_playtime.Seconds).Append("s"); |
||||
PlaytimeLabel.Text = b.ToString(); |
||||
} |
||||
|
||||
private void OnStatusChanged(ConnectionStatus status) |
||||
{ |
||||
var server = this.GetClient().GetNode<IntegratedServer>(nameof(IntegratedServer)); |
||||
GetParent<TabContainer>().SetTabDisabled(GetIndex(), server == null); |
||||
} |
||||
|
||||
|
||||
#pragma warning disable IDE0051 |
||||
#pragma warning disable IDE1006 |
||||
|
||||
private void _on_QuickSave_pressed() |
||||
=> _on_SaveFileDialog_file_selected(_currentWorld); |
||||
|
||||
private void _on_SaveAs_pressed() |
||||
{ |
||||
SaveFileDialog.Invalidate(); |
||||
SaveFileDialog.PopupCenteredClamped(new Vector2(480, 320), 0.85F); |
||||
} |
||||
|
||||
private void _on_SaveFileDialog_file_selected(string path) |
||||
{ |
||||
var server = this.GetClient().GetNode<IntegratedServer>(nameof(IntegratedServer)).Server; |
||||
var save = Save.CreateFromWorld(server, _playtime); |
||||
save.WriteToFile(path + ".tmp"); |
||||
File.Delete(path); // TODO: In later .NET, there is a File.Move(source, dest, overwrite). |
||||
File.Move(path + ".tmp", path); |
||||
|
||||
_currentWorld = path; |
||||
FilenameLabel.Text = Path.GetFileName(path); |
||||
LastSavedLabel.Text = save.LastSaved.ToString("yyyy-MM-dd HH:mm"); |
||||
QuickSaveButton.Visible = true; |
||||
SaveAsButton.Text = "Save As..."; |
||||
} |
||||
|
||||
private void _on_LoadFrom_pressed() |
||||
{ |
||||
LoadFileDialog.Invalidate(); |
||||
LoadFileDialog.PopupCenteredClamped(new Vector2(480, 320), 0.85F); |
||||
} |
||||
|
||||
private void _on_LoadFileDialog_file_selected(string path) |
||||
{ |
||||
var server = this.GetClient().GetNode<IntegratedServer>(nameof(IntegratedServer)).Server; |
||||
var save = Save.ReadFromFile(path); |
||||
|
||||
// Clear out all objects that have a SaveAttribute. |
||||
var objectsToRemove = server.Objects.Select(x => x.Item2) |
||||
.Where(x => SaveRegistry.GetOrNull(x.GetType()) != null).ToArray(); |
||||
foreach (var obj in objectsToRemove) obj.RemoveFromParent(); |
||||
|
||||
// Reset players' positions. |
||||
foreach (var (id, player) in server.Players) |
||||
player.RPC(new []{ id }, player.ResetPosition, Vector2.Zero); |
||||
|
||||
save.AddToWorld(server); |
||||
_playtime = save.Playtime; |
||||
|
||||
_currentWorld = path; |
||||
FilenameLabel.Text = Path.GetFileName(path); |
||||
LastSavedLabel.Text = save.LastSaved.ToString("yyyy-MM-dd HH:mm"); |
||||
QuickSaveButton.Visible = true; |
||||
SaveAsButton.Text = "Save As..."; |
||||
} |
||||
} |
@ -0,0 +1,48 @@ |
||||
using System; |
||||
using System.Reflection; |
||||
|
||||
public interface IPropertyDeSerializer |
||||
{ |
||||
PropertyInfo Property { get; } |
||||
Type Type { get; } |
||||
string Name { get; } |
||||
string FullName { get; } |
||||
int HashID { get; } |
||||
|
||||
IDeSerializer DeSerializer { get; } |
||||
object Get(object obj); |
||||
void Set(object obj, object value); |
||||
} |
||||
|
||||
public class PropertyDeSerializer<TObj, TProp> |
||||
: IPropertyDeSerializer |
||||
{ |
||||
public PropertyInfo Property { get; } |
||||
public Type Type => Property.PropertyType; |
||||
public string Name => Property.Name; |
||||
public string FullName { get; } |
||||
public int HashID { get; } |
||||
|
||||
public IDeSerializer<TProp> DeSerializer { get; } |
||||
public Func<TObj, TProp> Getter { get; } |
||||
public Action<TObj, TProp> Setter { get; } |
||||
|
||||
public PropertyDeSerializer(PropertyInfo property) |
||||
{ |
||||
if ((property.GetMethod == null) || (property.SetMethod == null)) throw new Exception( |
||||
$"Property {property.DeclaringType}.{property.Name} must have a getter and setter defined"); |
||||
|
||||
Property = property; |
||||
FullName = $"{Property.DeclaringType.FullName}.{Property.Name}"; |
||||
HashID = FullName.GetDeterministicHashCode(); |
||||
|
||||
DeSerializer = DeSerializerRegistry.Get<TProp>(true); |
||||
Getter = (Func<TObj, TProp>)Property.GetMethod.CreateDelegate(typeof(Func<TObj, TProp>)); |
||||
Setter = (Action<TObj, TProp>)Property.SetMethod.CreateDelegate(typeof(Action<TObj, TProp>)); |
||||
} |
||||
|
||||
// IPropertyDeSerializer implementation |
||||
IDeSerializer IPropertyDeSerializer.DeSerializer => DeSerializer; |
||||
object IPropertyDeSerializer.Get(object obj) => Getter((TObj)obj); |
||||
void IPropertyDeSerializer.Set(object obj, object value) => Setter((TObj)obj, (TProp)value); |
||||
} |
@ -1,7 +1,103 @@ |
||||
using System; |
||||
using System.Collections.Generic; |
||||
using System.IO; |
||||
using System.Linq; |
||||
using Godot; |
||||
using File = System.IO.File; |
||||
|
||||
[AttributeUsage(AttributeTargets.Property)] |
||||
public class SaveAttribute : Attribute |
||||
public class Save |
||||
{ |
||||
public const string FILE_EXT = ".yf5"; |
||||
public const int MAGIC_NUMBER = 0x59463573; // "YF5s" |
||||
public const int CURRENT_VERSION = 0; |
||||
|
||||
public int Version { get; private set; } |
||||
public DateTime LastSaved { get; private set; } |
||||
public TimeSpan Playtime { get; set; } |
||||
|
||||
public List<(SaveObjectInfo, List<object>)> Objects { get; private set; } |
||||
|
||||
|
||||
public static Save ReadFromFile(string path) |
||||
{ |
||||
var save = new Save { 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 support multiple versions. |
||||
save.Version = reader.ReadUInt16(); |
||||
if (save.Version != CURRENT_VERSION) throw new IOException( |
||||
$"Version does not match ({save.Version} != {CURRENT_VERSION})"); |
||||
|
||||
save.Playtime = TimeSpan.FromSeconds(reader.ReadUInt32()); |
||||
|
||||
var numObjects = reader.ReadInt32(); |
||||
save.Objects = new List<(SaveObjectInfo, List<object>)>(numObjects); |
||||
for (var i = 0; i < numObjects; i++) { |
||||
var hashID = reader.ReadInt32(); |
||||
var objInfo = SaveRegistry.GetOrThrow(hashID); |
||||
var props = objInfo.PropertiesByID.Select(x => x.DeSerializer.Deserialize(null, reader)).ToList(); |
||||
save.Objects.Add((objInfo, props)); |
||||
} |
||||
} |
||||
} |
||||
return save; |
||||
} |
||||
|
||||
public void WriteToFile(string path) |
||||
{ |
||||
using (var stream = File.OpenWrite(path)) { |
||||
using (var writer = new BinaryWriter(stream)) { |
||||
writer.Write(MAGIC_NUMBER); |
||||
writer.Write((ushort)CURRENT_VERSION); |
||||
writer.Write((uint)Playtime.TotalSeconds); |
||||
|
||||
writer.Write(Objects.Count); |
||||
foreach (var (objInfo, props) in Objects) { |
||||
writer.Write(objInfo.HashID); |
||||
for (var i = 0; i < objInfo.PropertiesByID.Count; i++) { |
||||
var propInfo = objInfo.PropertiesByID[i]; |
||||
var propValue = props[i]; |
||||
propInfo.DeSerializer.Serialize(null, writer, propValue); |
||||
} |
||||
} |
||||
} |
||||
} |
||||
LastSaved = File.GetLastAccessTime(path); |
||||
} |
||||
|
||||
|
||||
public static Save CreateFromWorld(Game game, TimeSpan playtime) |
||||
{ |
||||
var save = new Save { |
||||
Playtime = playtime, |
||||
Objects = new List<(SaveObjectInfo, List<object>)>(), |
||||
}; |
||||
foreach (var (id, obj) in game.Objects) { |
||||
var objInfo = SaveRegistry.GetOrNull(obj.GetType()); |
||||
if (objInfo == null) continue; |
||||
|
||||
var props = objInfo.PropertiesByID.Select(x => x.Get(obj)).ToList(); |
||||
save.Objects.Add((objInfo, props)); |
||||
} |
||||
return save; |
||||
} |
||||
|
||||
public void AddToWorld(Server server) |
||||
{ |
||||
foreach (var (objInfo, props) in Objects) { |
||||
var obj = objInfo.SpawnInfo.Scene.Init<Node>(); |
||||
server.GetNode("World").AddChild(obj, true); |
||||
server.Objects.Add(null, obj); |
||||
|
||||
for (var i = 0; i < objInfo.PropertiesByID.Count; i++) { |
||||
var propInfo = objInfo.PropertiesByID[i]; |
||||
var propValue = props[i]; |
||||
propInfo.Set(obj, propValue); |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
@ -0,0 +1,69 @@ |
||||
using System; |
||||
using System.Collections.Generic; |
||||
using System.Linq; |
||||
using System.Reflection; |
||||
using Godot; |
||||
|
||||
public static class SaveRegistry |
||||
{ |
||||
private static readonly Dictionary<int, SaveObjectInfo> _byID = new Dictionary<int, SaveObjectInfo>(); |
||||
private static readonly Dictionary<Type, SaveObjectInfo> _byType = new Dictionary<Type, SaveObjectInfo>(); |
||||
|
||||
static SaveRegistry() |
||||
{ |
||||
foreach (var type in typeof(SyncRegistry).Assembly.GetTypes()) { |
||||
var syncAttr = type.GetCustomAttribute<SaveAttribute>(); |
||||
if (syncAttr == null) continue; |
||||
|
||||
if (!typeof(Node).IsAssignableFrom(type)) throw new Exception( |
||||
$"Type {type} with {nameof(SyncAttribute)} must be a subclass of {nameof(Node)}"); |
||||
|
||||
var spawnInfo = SpawnRegistry.Get(type); |
||||
var objInfo = new SaveObjectInfo(type, spawnInfo); |
||||
|
||||
foreach (var property in type.GetProperties()) { |
||||
if (property.GetCustomAttribute<SaveAttribute>() == null) continue; |
||||
var propType = typeof(PropertyDeSerializer<,>).MakeGenericType(type, property.PropertyType); |
||||
var propDeSerializer = (IPropertyDeSerializer)Activator.CreateInstance(propType, property); |
||||
objInfo.PropertiesByName.Add(propDeSerializer.Name, propDeSerializer); |
||||
} |
||||
objInfo.PropertiesByID.AddRange(objInfo.PropertiesByName.Values.OrderBy(x => x.HashID)); |
||||
|
||||
_byID.Add(objInfo.HashID, objInfo); |
||||
_byType.Add(objInfo.Type, objInfo); |
||||
} |
||||
} |
||||
|
||||
public static SaveObjectInfo GetOrNull(int hashID) |
||||
=> _byID.TryGetValue(hashID, out var value) ? value : null; |
||||
public static SaveObjectInfo GetOrThrow(int hashID) => GetOrNull(hashID) |
||||
?? throw new Exception($"Unknown {nameof(SaveObjectInfo)} with HashID {hashID}"); |
||||
|
||||
public static SaveObjectInfo GetOrNull<T>() => GetOrNull(typeof(T)); |
||||
public static SaveObjectInfo GetOrNull(Type type) => _byType.TryGetValue(type, out var value) ? value : null; |
||||
|
||||
public static SaveObjectInfo GetOrThrow<T>() => GetOrThrow(typeof(T)); |
||||
public static SaveObjectInfo GetOrThrow(Type type) => GetOrNull(type) ?? throw new Exception( |
||||
$"No {nameof(SaveObjectInfo)} found for type {type} (missing {nameof(SyncAttribute)}?)"); |
||||
} |
||||
|
||||
public class SaveObjectInfo |
||||
{ |
||||
public Type Type { get; } |
||||
public int HashID { get; } |
||||
public SpawnInfo SpawnInfo { get; } |
||||
public string Name => Type.Name; |
||||
|
||||
public List<IPropertyDeSerializer> PropertiesByID { get; } = new List<IPropertyDeSerializer>(); |
||||
public Dictionary<string, IPropertyDeSerializer> PropertiesByName { get; } = new Dictionary<string, IPropertyDeSerializer>(); |
||||
|
||||
public SaveObjectInfo(Type type, SpawnInfo spawnInfo) |
||||
{ |
||||
Type = type; |
||||
HashID = type.FullName.GetDeterministicHashCode(); |
||||
SpawnInfo = spawnInfo; |
||||
} |
||||
} |
||||
|
||||
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Property)] |
||||
public class SaveAttribute : Attribute { } |
@ -0,0 +1,70 @@ |
||||
using System; |
||||
using System.Collections.Generic; |
||||
using System.Reflection; |
||||
using Godot; |
||||
|
||||
public static class SpawnRegistry |
||||
{ |
||||
private static readonly Dictionary<int, SpawnInfo> _byID = new Dictionary<int, SpawnInfo>(); |
||||
private static readonly Dictionary<Type, SpawnInfo> _byType = new Dictionary<Type, SpawnInfo>(); |
||||
|
||||
static SpawnRegistry() |
||||
{ |
||||
foreach (var type in typeof(SpawnRegistry).Assembly.GetTypes()) { |
||||
var objAttr = type.GetCustomAttribute<SpawnAttribute>(); |
||||
if (objAttr == null) continue; |
||||
|
||||
if (!typeof(Node).IsAssignableFrom(type)) throw new Exception( |
||||
$"Type {type} with {nameof(SpawnAttribute)} must be a subclass of {nameof(Node)}"); |
||||
|
||||
var objInfo = new SpawnInfo(type); |
||||
_byID.Add(objInfo.HashID, objInfo); |
||||
_byType.Add(objInfo.Type, objInfo); |
||||
} |
||||
} |
||||
|
||||
public static T Spawn<T>(this Server server) |
||||
where T : Node |
||||
{ |
||||
var info = Get<T>(); |
||||
var obj = info.Scene.Init<T>(); |
||||
server.GetNode("World").AddChild(obj, true); |
||||
server.Objects.Add(null, obj); |
||||
return obj; |
||||
} |
||||
|
||||
public static SpawnInfo Get(int id) |
||||
=> _byID.TryGetValue(id, out var value) ? value : throw new Exception( |
||||
$"No {nameof(SpawnInfo)} found with ID {id}"); |
||||
|
||||
public static SpawnInfo Get<T>() |
||||
=> Get(typeof(T)); |
||||
public static SpawnInfo Get(Type type) |
||||
=> _byType.TryGetValue(type, out var value) ? value : throw new Exception( |
||||
$"No {nameof(SpawnInfo)} found for type {type} (missing {nameof(SpawnAttribute)}?)"); |
||||
} |
||||
|
||||
public class SpawnInfo |
||||
{ |
||||
public Type Type { get; } |
||||
public int HashID { get; } |
||||
public PackedScene Scene { get; } |
||||
|
||||
public SpawnInfo(Type type) |
||||
{ |
||||
Type = type; |
||||
HashID = type.FullName.GetDeterministicHashCode(); |
||||
|
||||
var sceneStr = Type.GetCustomAttribute<SpawnAttribute>().Scene; |
||||
if (sceneStr == null) sceneStr = $"res://scene/{Type.Name}.tscn"; |
||||
Scene = GD.Load<PackedScene>(sceneStr); |
||||
} |
||||
} |
||||
|
||||
[AttributeUsage(AttributeTargets.Class)] |
||||
public class SpawnAttribute : Attribute |
||||
{ |
||||
public string Scene { get; } |
||||
public SpawnAttribute(string scene = null) |
||||
=> Scene = scene; |
||||
} |
Loading…
Reference in new issue