- 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; |
||||||
|
using System.Collections.Generic; |
||||||
|
using System.IO; |
||||||
|
using System.Linq; |
||||||
|
using Godot; |
||||||
|
using File = System.IO.File; |
||||||
|
|
||||||
[AttributeUsage(AttributeTargets.Property)] |
public class Save |
||||||
public class SaveAttribute : Attribute |
|
||||||
{ |
{ |
||||||
|
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