Gondwana Game Engine Development (master) - 2.6.1
Gondwana is a cross-platform 2.5D game and rendering engine written in C#/.NET 8. It provides fine-grained control over rendering, timing, and scene composition, with built-in support for parallax, z-ordering, pixel overhang, collision detection, and particle effects. Gondwana targets desktop and web platforms using SkiaSharp for graphics and pluggable audio backends for desktop and browser targets.
Loading...
Searching...
No Matches
EngineState.cs
1using System.IO.Compression;
12using Gondwana.Scenes;
14using Microsoft.Extensions.Logging;
15using Newtonsoft.Json;
16using Newtonsoft.Json.Linq;
17
18namespace Gondwana;
19
26[JsonObject(IsReference = true)]
27public sealed partial class EngineState
28{
29 [JsonConverter(typeof(SpriteStateEntryConverter))]
30 private sealed class SpriteStateEntry : DefinitionStateEntry
31 {
35 public string? GsprPath { get; set; }
39 public SpriteDefinition? Definition { get; set; }
43 [JsonIgnore] public List<Sprite>? LegacySprites { get; set; }
44 }
45
46 private sealed class SpriteStateEntryConverter : JsonConverter<SpriteStateEntry>
47 {
49 public override SpriteStateEntry? ReadJson(JsonReader reader, Type objectType,
50 SpriteStateEntry? existingValue, bool hasExistingValue, JsonSerializer serializer)
51 {
52 if (reader.TokenType == JsonToken.Null) return null;
53 var token = JToken.Load(reader);
54 // PreserveReferencesHandling.All wrapped historical arrays in $values.
55 if (token is JArray || token["$values"] is not null)
56 return new() { LegacySprites = token.ToObject<List<Sprite>>(serializer) };
57 if (token is not JObject obj)
58 throw new JsonSerializationException("Invalid Sprite state entry.");
59 return new()
60 {
61 AssetsFilePath = obj.GetValue("AssetsFilePath", StringComparison.OrdinalIgnoreCase)?.ToObject<string>(serializer),
62 AssetEntryName = obj.GetValue("AssetEntryName", StringComparison.OrdinalIgnoreCase)?.ToObject<string>(serializer),
63 GsprPath = obj.GetValue("GsprPath", StringComparison.OrdinalIgnoreCase)?.ToObject<string>(serializer),
64 Definition = obj.GetValue("Definition", StringComparison.OrdinalIgnoreCase)?.ToObject<SpriteDefinition>(serializer)
65 };
66 }
68 public override bool CanWrite => false;
70 public override void WriteJson(JsonWriter writer, SpriteStateEntry? value, JsonSerializer serializer) => throw new NotSupportedException();
71 }
72
76 private sealed class TilesheetStateEntry : DefinitionStateEntry
77 {
81 [JsonProperty]
82 public string? GtsPath { get; set; }
83
87 [JsonProperty]
88 public TilesheetDefinition? Definition { get; set; }
89 }
90
91
96 private sealed class AnimationStateEntry : DefinitionStateEntry
97 {
101 [JsonProperty]
102 public string? GaniPath { get; set; }
103
107 [JsonProperty]
108 public AnimationDefinition? Definition { get; set; }
109 }
110
115 private sealed class AudioStateEntry : DefinitionStateEntry
116 {
120 [JsonProperty]
121 public string? GsndPath { get; set; }
122
126 [JsonProperty]
127 public AudioDefinition? Definition { get; set; }
128 }
129
134 [JsonConverter(typeof(SceneStateEntryConverter))]
135 private sealed class SceneStateEntry : DefinitionStateEntry
136 {
140 [JsonProperty]
141 public string? GscnPath { get; set; }
142
146 [JsonProperty]
147 public SceneDefinition? Definition { get; set; }
148
153 [JsonIgnore]
154 public Scene? LegacyScene { get; set; }
155 }
156
161 private sealed class SceneStateEntryConverter : JsonConverter<SceneStateEntry>
162 {
164 public override SceneStateEntry? ReadJson(
165 JsonReader reader,
166 Type objectType,
167 SceneStateEntry? existingValue,
168 bool hasExistingValue,
169 JsonSerializer serializer)
170 {
171 if (reader.TokenType == JsonToken.Null)
172 return null;
173
174 var obj = JObject.Load(reader);
175
176 if (obj.Property(nameof(SceneStateEntry.GscnPath), StringComparison.OrdinalIgnoreCase) is not null ||
177 obj.Property(nameof(SceneStateEntry.Definition), StringComparison.OrdinalIgnoreCase) is not null ||
178 obj.Property("AssetsFilePath", StringComparison.OrdinalIgnoreCase) is not null ||
179 obj.Property("AssetEntryName", StringComparison.OrdinalIgnoreCase) is not null)
180 {
181 return new SceneStateEntry
182 {
183 AssetsFilePath = obj.GetValue("AssetsFilePath", StringComparison.OrdinalIgnoreCase)?.ToObject<string>(serializer),
184 AssetEntryName = obj.GetValue("AssetEntryName", StringComparison.OrdinalIgnoreCase)?.ToObject<string>(serializer),
185 GscnPath = obj.GetValue(
186 nameof(SceneStateEntry.GscnPath),
187 StringComparison.OrdinalIgnoreCase)?.ToObject<string>(serializer),
188 Definition = obj.GetValue(
189 nameof(SceneStateEntry.Definition),
190 StringComparison.OrdinalIgnoreCase)?.ToObject<SceneDefinition>(serializer)
191 };
192 }
193
194 // Backward compatibility: pre-GSCN EngineState files stored Scene objects
195 // directly in the Scenes list. Keep the deserialized instance intact so any
196 // preserved references from other legacy objects still resolve to it.
197 var legacyScene = obj.ToObject<Scene>(serializer)
198 ?? throw new JsonSerializationException(
199 "Legacy EngineState scene entry deserialized to null.");
200
201 return new SceneStateEntry
202 {
203 LegacyScene = legacyScene
204 };
205 }
206
208 public override void WriteJson(
209 JsonWriter writer,
210 SceneStateEntry? value,
211 JsonSerializer serializer) =>
212 throw new NotSupportedException();
213
215 public override bool CanWrite => false;
216 }
217
226 {
227 TypeNameHandling = TypeNameHandling.Auto,
228 Formatting = Formatting.Indented,
229 PreserveReferencesHandling = PreserveReferencesHandling.All,
230 Converters =
231 {
232 new FrameJsonConverter()
233 }
234 };
235
241 [JsonProperty]
242 public IEnumerable<AssetsFile> AssetsFiles => AssetsFile.AllAssetsFiles;
243
249 [JsonProperty]
250 public IDictionary<string, TilesheetDefinition> Tilesheets => CaptureTilesheetDefinitions(baseDirectory: null);
251
257 [JsonProperty]
258 public Dictionary<string, Cycle> Cycles => Cycle._cycles;
259
265 [JsonProperty]
266 public List<Scene> Scenes => Scene._allScenes;
267
273 [JsonProperty]
274 public List<Sprite> Sprites => SpriteManager.Instance._spriteList;
275
282 [JsonProperty]
283 public Dictionary<string, AudioResource> SoundResources => AudioResourceManager.Instance.GetAll();
284
316 [JsonIgnore]
317 public TypedValueBag ValueBag { get; set; } = new();
318
325 internal void Clear()
326 {
333 ValueBag.Clear();
334 }
335
375 public void SaveToFile(string path,
376 bool compress = false,
377 bool separateGtsFiles = false,
379 bool separateGscnFiles = false,
380 bool separateGaniFiles = false,
381 bool separateGsndFile = false,
382 bool separateGsprFile = false)
384 {
385 Compress = compress,
386 Parts = parts,
388 Scenes = separateGscnFiles ? DefinitionPersistence.Loose : DefinitionPersistence.Inline,
389 Cycles = separateGaniFiles ? DefinitionPersistence.Loose : DefinitionPersistence.Inline,
390 Audio = separateGsndFile ? DefinitionPersistence.Loose : DefinitionPersistence.Inline,
392 });
393
397 public void SaveToFile(string path, EngineStateSaveOptions options)
398 {
399 if (string.IsNullOrWhiteSpace(path))
400 throw new ArgumentException("Engine state path must be a non-empty string.", nameof(path));
401
402 var fullPath = Path.GetFullPath(path);
403 var baseDirectory = Path.GetDirectoryName(fullPath);
404
405 ArgumentNullException.ThrowIfNull(options);
406 foreach (var mode in new[] { options.Tilesheets, options.Cycles, options.Scenes, options.Audio, options.Sprites })
407 if (!Enum.IsDefined(mode)) throw new ArgumentOutOfRangeException(nameof(options));
408 var parts = options.Parts;
409 if (parts.HasFlag(EngineStateParts.Sprites) && options.Sprites == DefinitionPersistence.PreserveSource)
411 if (parts.HasFlag(EngineStateParts.Scenes) && options.Scenes == DefinitionPersistence.PreserveSource)
412 parts |= EngineStateParts.Cycles | EngineStateParts.Tilesheets;
413 var snapshot = BuildSnapshot(NormalizeParts(parts), baseDirectory, fullPath, options);
414
415 var json = JsonConvert.SerializeObject(snapshot, JsonSerializerSettings);
416
417 if (options.Compress)
418 {
419 using var file = File.Create(fullPath);
420 using var zip = new GZipStream(file, CompressionMode.Compress);
421 using var writer = new StreamWriter(zip);
422 writer.Write(json);
423 }
424 else
425 {
426 File.WriteAllText(fullPath, json);
427 }
428 }
429
450 public static void LoadFromFile(string path, bool compressed = false, EngineStateParts parts = EngineStateParts.All)
451 {
452 if (string.IsNullOrWhiteSpace(path))
453 throw new ArgumentException("Engine state path must be a non-empty string.", nameof(path));
454
455 var fullPath = Path.GetFullPath(path);
456 var baseDirectory = Path.GetDirectoryName(fullPath);
457
458 string json = ReadJsonFile(fullPath, compressed);
459
460 // Important: EngineState's collections are mostly getter-only proxies over registries,
461 // so deserialize into a snapshot DTO with setters.
462 var snapshot =
463 JsonConvert.DeserializeObject<EngineStateSnapshot>(json, JsonSerializerSettings)
464 ?? new EngineStateSnapshot();
465
466 // Merge into the live engine state (registries)
467 ApplySnapshot(snapshot, clearExisting: true, overwriteExisting: true, parts, baseDirectory);
468 }
469
494 public static void MergeFromFile(string path, bool compressed = false, bool overwriteExisting = false, EngineStateParts parts = EngineStateParts.All)
495 {
496 if (string.IsNullOrWhiteSpace(path))
497 throw new ArgumentException("Engine state path must be a non-empty string.", nameof(path));
498
499 var fullPath = Path.GetFullPath(path);
500 var baseDirectory = Path.GetDirectoryName(fullPath);
501
502 string json = ReadJsonFile(fullPath, compressed);
503
504 var snapshot =
505 JsonConvert.DeserializeObject<EngineStateSnapshot>(json, JsonSerializerSettings)
506 ?? new EngineStateSnapshot();
507
508 // Merge into the live engine state (registries)
509 ApplySnapshot(snapshot, clearExisting: false, overwriteExisting: overwriteExisting, parts, baseDirectory);
510 }
511
512 #region deserialization helpers
513
514 private sealed class EngineStateSnapshot
515 {
519 [JsonProperty(TypeNameHandling = TypeNameHandling.None, ItemTypeNameHandling = TypeNameHandling.None)]
520 public List<AssetsFileStateEntry>? AssetsFiles { get; set; }
524 [JsonProperty] public Dictionary<string, TilesheetStateEntry>? Tilesheets { get; set; }
528 [JsonProperty] public Dictionary<string, AnimationStateEntry>? Cycles { get; set; }
532 [JsonProperty] public List<SceneStateEntry>? Scenes { get; set; }
536 [JsonProperty] public SpriteStateEntry? Sprites { get; set; }
540 [JsonProperty] public AudioStateEntry? Audio { get; set; }
541
542 // Legacy compatibility: pre-GSND EngineState files serialized runtime
543 // AudioResource objects directly under SoundResources.
547 [JsonProperty] public Dictionary<string, AudioResource>? SoundResources { get; set; }
548 }
549
550 private static EngineStateParts NormalizeParts(EngineStateParts parts, EngineStateSnapshot? snapshot = null)
551 {
552 // Expand content dependencies for packed definitions without changing legacy
553 // sprite-only and scene-only merges that deliberately use existing registries.
554 if (parts.HasFlag(EngineStateParts.Sprites) && snapshot?.Sprites?.AssetsFilePath is not null)
555 {
556 if (snapshot.Scenes is not null) parts |= EngineStateParts.Scenes;
557 if (snapshot.Cycles is not null) parts |= EngineStateParts.Cycles;
558 if (snapshot.Tilesheets is not null) parts |= EngineStateParts.Tilesheets;
559 }
560 if (parts.HasFlag(EngineStateParts.Scenes) && snapshot?.Scenes?.Any(entry => entry?.AssetsFilePath is not null) == true)
561 {
562 if (snapshot.Cycles is not null) parts |= EngineStateParts.Cycles;
563 if (snapshot.Tilesheets is not null) parts |= EngineStateParts.Tilesheets;
564 }
565
566 // GANI definitions resolve Frame references through the TilesheetRegistry.
567 if (parts.HasFlag(EngineStateParts.Cycles))
568 parts |= EngineStateParts.Tilesheets;
569
570 // Tilesheets and Audio may depend on AssetsFiles for AssetIdentifier.Data.
571 if (parts.HasFlag(EngineStateParts.Tilesheets) ||
572 parts.HasFlag(EngineStateParts.Audio) ||
573 (parts.HasFlag(EngineStateParts.Scenes) && snapshot?.Scenes?.Any(entry => entry?.AssetsFilePath is not null) == true) ||
574 (parts.HasFlag(EngineStateParts.Sprites) && snapshot?.Sprites?.AssetsFilePath is not null))
575 {
576 parts |= EngineStateParts.AssetsFiles;
577 }
578
579 return parts;
580 }
581
582 private EngineStateSnapshot BuildSnapshot(EngineStateParts parts,
583 string? baseDirectory,
584 string engineStatePath,
585 EngineStateSaveOptions options)
586 {
587 return new EngineStateSnapshot
588 {
589 AssetsFiles = parts.HasFlag(EngineStateParts.AssetsFiles)
590 ? AssetsFiles.Where(file => !string.IsNullOrWhiteSpace(file.SourcePath))
591 .Select(file => new AssetsFileStateEntry
592 {
593 FilePath = MakeRelativePath(file.SourcePath!, baseDirectory),
594 Password = file.Password,
595 UseEncryption = file.UseEncryption
596 }).ToList()
597 : null,
598
599 Tilesheets = parts.HasFlag(EngineStateParts.Tilesheets)
600 ? CaptureTilesheetEntries(
601 baseDirectory,
602 engineStatePath,
603 options.Tilesheets)
604 : null,
605
606 Cycles = parts.HasFlag(EngineStateParts.Cycles)
607 ? CaptureAnimationEntries(
608 baseDirectory,
609 engineStatePath,
610 options.Cycles)
611 : null,
612
613 Scenes = parts.HasFlag(EngineStateParts.Scenes)
614 ? CaptureSceneEntries(
615 baseDirectory,
616 engineStatePath,
617 options.Scenes)
618 : null,
619
620 Sprites = parts.HasFlag(EngineStateParts.Sprites)
621 ? CaptureSpriteEntry(baseDirectory, engineStatePath, options.Sprites)
622 : null,
623
624 Audio = parts.HasFlag(EngineStateParts.Audio)
625 ? CaptureAudioEntry(
626 baseDirectory,
627 engineStatePath,
628 options.Audio)
629 : null,
630
631 // New saves use the clean GSND definition shape. This member remains
632 // only so older EngineState files can still be read.
633 SoundResources = null,
634 };
635 }
636
637 private SpriteStateEntry CaptureSpriteEntry(string? baseDirectory, string engineStatePath, DefinitionPersistence persistence)
638 {
639 var definition = SpriteDefinitionSerializer.FromSprites(SpriteManager.Instance.AllSprites);
640 var entry = new SpriteStateEntry();
641 if (TryPreserve(entry, SpriteManager.Instance.DefinitionProvenance, definition, persistence, baseDirectory, out var loosePath))
642 {
643 entry.GsprPath = loosePath;
644 return entry;
645 }
646 if (persistence != DefinitionPersistence.Loose) return new() { Definition = definition };
647 var path = Path.Combine(Path.GetDirectoryName(engineStatePath)!,
648 Path.GetFileNameWithoutExtension(engineStatePath) + ".sprites.gspr");
649 SpriteDefinitionSerializer.Save(path, definition);
650 return new() { GsprPath = MakeRelativePath(path, baseDirectory) };
651 }
652
653 private static AudioStateEntry CaptureAudioEntry(
654 string? baseDirectory,
655 string engineStatePath,
656 DefinitionPersistence persistence)
657 {
658 var entry = new AudioStateEntry();
659 var currentAudio = AudioDefinitionSerializer.FromManager(AudioResourceManager.Instance);
660 if (TryPreserve(entry, AudioResourceManager.Instance.DefinitionProvenance,
661 currentAudio, persistence, baseDirectory, out var loosePath))
662 {
663 entry.GsndPath = loosePath;
664 return entry;
665 }
666 // Anonymous stream packages cannot be named in a reloadable GSND. Export their
667 // audio payloads to loose files; do not invent or mutate an archive.
668 var anonymousAudio = currentAudio.Resources.Where(resource =>
669 resource.SourceKind == AudioResourceSourceKind.PackedAsset &&
670 string.IsNullOrWhiteSpace(resource.AssetsFilePath)).ToList();
671 if (anonymousAudio.Count != 0)
672 {
673 var directory = GetAudioStateDirectory(engineStatePath);
674 Directory.CreateDirectory(directory);
675 for (int i = 0; i < anonymousAudio.Count; i++)
676 {
677 var resource = anonymousAudio[i];
678 var runtime = AudioResourceManager.Instance.Get(resource.Key)!;
679 var extension = Path.GetExtension(runtime.SourceExtension ?? ".wav");
680 var mediaPath = Path.Combine(directory, $"{i}-{SanitizeFileName(resource.Key)}{extension}");
681 using (var source = runtime.AssetIdentifier!.Data)
682 using (var output = File.Create(mediaPath))
683 source.CopyTo(output);
684 resource.SourceKind = AudioResourceSourceKind.LooseFile;
685 resource.FilePath = mediaPath;
686 resource.AssetsFilePath = null;
687 resource.AssetEntryName = null;
688 }
689 var definitionPath = Path.Combine(directory, "audio.gsnd");
690 AudioDefinitionSerializer.Save(definitionPath, currentAudio);
691 return new AudioStateEntry { GsndPath = MakeRelativePath(definitionPath, baseDirectory) };
692 }
693 if (persistence == DefinitionPersistence.Loose)
694 {
695 var gsndDirectory = GetAudioStateDirectory(engineStatePath);
696 Directory.CreateDirectory(gsndDirectory);
697
698 var gsndFullPath = Path.Combine(gsndDirectory, "audio.gsnd");
699 AudioDefinitionSerializer.Save(
700 gsndFullPath,
701 AudioResourceManager.Instance);
702
703 return new AudioStateEntry
704 {
705 GsndPath = MakeRelativePath(gsndFullPath, baseDirectory)
706 };
707 }
708
709 return new AudioStateEntry
710 {
711 Definition = AudioDefinitionSerializer.FromManager(
712 AudioResourceManager.Instance,
713 baseDirectory,
714 makePathsRelative: !string.IsNullOrWhiteSpace(baseDirectory))
715 };
716 }
717
718 private static Dictionary<string, AnimationStateEntry> CaptureAnimationEntries(
719 string? baseDirectory,
720 string engineStatePath,
721 DefinitionPersistence persistence)
722 {
723 var result = new Dictionary<string, AnimationStateEntry>(StringComparer.Ordinal);
724 var usedFileNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
725
726 // Work from a stable registry snapshot in case definitions are changed while
727 // the EngineState snapshot is being assembled.
728 foreach (var (key, cycle) in Cycle._cycles.ToList())
729 {
730 if (cycle is null)
731 continue;
732
733 var preserved = new AnimationStateEntry();
734 if (cycle.DefinitionProvenance is { } provenance &&
735 TryPreserve(preserved, provenance, AnimationDefinitionSerializer.FromCycle(cycle), persistence, baseDirectory, out var loosePath))
736 {
737 preserved.GaniPath = loosePath;
738 result[key] = preserved;
739 continue;
740 }
741
742 if (persistence == DefinitionPersistence.Loose)
743 {
744 var ganiDirectory = GetAnimationStateDirectory(engineStatePath);
745 Directory.CreateDirectory(ganiDirectory);
746
747 var baseFileName = SanitizeFileName(key);
748 var ganiFileName = $"{baseFileName}.gani";
749 int suffix = 2;
750
751 while (!usedFileNames.Add(ganiFileName))
752 ganiFileName = $"{baseFileName}_{suffix++}.gani";
753
754 var ganiFullPath = Path.Combine(ganiDirectory, ganiFileName);
755
756 // Use the standalone serializer so .gani files do not acquire
757 // EngineState-specific $id/$ref metadata.
758 AnimationDefinitionSerializer.Save(ganiFullPath, cycle);
759
760 result[key] = new AnimationStateEntry
761 {
762 GaniPath = MakeRelativePath(ganiFullPath, baseDirectory)
763 };
764 }
765 else
766 {
767 result[key] = new AnimationStateEntry
768 {
769 Definition = AnimationDefinitionSerializer.FromCycle(cycle)
770 };
771 }
772 }
773
774 return result;
775 }
776
777 private static List<SceneStateEntry> CaptureSceneEntries(
778 string? baseDirectory,
779 string engineStatePath,
780 DefinitionPersistence persistence)
781 {
782 var result = new List<SceneStateEntry>();
783 var usedFileNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
784
785 // Work from a stable snapshot. Scene construction/disposal may occur on
786 // other threads while a state file is being assembled.
787 foreach (var scene in Scene._allScenes.ToList())
788 {
789 if (scene is null)
790 continue;
791
792 var preserved = new SceneStateEntry();
793 if (scene.DefinitionProvenance is { } provenance &&
794 TryPreserve(preserved, provenance, SceneDefinitionSerializer.FromScene(scene), persistence, baseDirectory, out var loosePath))
795 {
796 preserved.GscnPath = loosePath;
797 result.Add(preserved);
798 continue;
799 }
800
801 if (persistence == DefinitionPersistence.Loose)
802 {
803 var gscnDirectory = GetSceneStateDirectory(engineStatePath);
804 Directory.CreateDirectory(gscnDirectory);
805
806 var baseFileName = SanitizeFileName(scene.ID);
807 var gscnFileName = $"{baseFileName}.gscn";
808 int suffix = 2;
809
810 while (!usedFileNames.Add(gscnFileName))
811 gscnFileName = $"{baseFileName}_{suffix++}.gscn";
812
813 var gscnFullPath = Path.Combine(gscnDirectory, gscnFileName);
814
815 // Use the standalone GSCN serializer so the external file remains a
816 // clean scene definition without EngineState $id/$ref metadata.
817 SceneDefinitionSerializer.Save(gscnFullPath, scene);
818
819 result.Add(new SceneStateEntry
820 {
821 GscnPath = MakeRelativePath(
822 gscnFullPath,
823 baseDirectory)
824 });
825 }
826 else
827 {
828 result.Add(new SceneStateEntry
829 {
830 Definition = SceneDefinitionSerializer.FromScene(scene)
831 });
832 }
833 }
834
835 return result;
836 }
837
838 private static string ReadJsonFile(string path, bool compressed)
839 {
840 if (compressed)
841 {
842 using var file = File.OpenRead(path);
843 using var zip = new GZipStream(file, CompressionMode.Decompress);
844 using var reader = new StreamReader(zip);
845 return reader.ReadToEnd();
846 }
847 else
848 {
849 return File.ReadAllText(path);
850 }
851 }
852
862 private static void ApplySnapshot(
863 EngineStateSnapshot snapshot,
864 bool clearExisting,
865 bool overwriteExisting,
866 EngineStateParts parts,
867 string? baseDirectory)
868 {
869 parts = NormalizeParts(parts, snapshot);
870
871 // Legacy raw objects may register themselves while the snapshot DTO is
872 // deserialized. Detach those incoming instances before clearing or merging so
873 // they are treated as snapshot data rather than pre-existing live state.
874 DetachLegacySnapshotScenes(snapshot.Scenes);
875 DetachSnapshotSprites(snapshot.Sprites?.LegacySprites);
876
877 ValidateSelectedSources(snapshot, parts);
878
879 // clear only what we're about to load.
880 if (clearExisting)
881 ClearSelected(parts);
882
883 if (parts.HasFlag(EngineStateParts.AssetsFiles))
884 LoadAssetsFiles(snapshot.AssetsFiles ?? Enumerable.Empty<AssetsFileStateEntry>(), overwriteExisting, baseDirectory, PackedEntries(snapshot, parts));
885
886 if (parts.HasFlag(EngineStateParts.Audio))
887 MergeAudio(
888 snapshot.AssetsFiles?.Select(raw => FindAssetsFile(ResolvePath(raw.FilePath, baseDirectory)))
889 .OfType<AssetsFile>().ToList(),
890 snapshot.Audio,
891 snapshot.SoundResources,
892 overwriteExisting,
893 baseDirectory);
894
895 if (parts.HasFlag(EngineStateParts.Tilesheets))
896 MergeTilesheets(snapshot.Tilesheets, overwriteExisting, baseDirectory);
897
898 if (parts.HasFlag(EngineStateParts.Cycles))
899 MergeAnimations(snapshot.Cycles, overwriteExisting, baseDirectory);
900
901 if (parts.HasFlag(EngineStateParts.Scenes))
902 MergeScenes(snapshot.Scenes, overwriteExisting, baseDirectory);
903
904 if (parts.HasFlag(EngineStateParts.Sprites))
905 {
906 var entry = snapshot.Sprites;
907 if (entry is not null)
908 {
909 var incoming = entry.LegacySprites;
910 if (incoming is null)
911 {
912 var definition = entry.AssetsFilePath is not null
913 ? LoadPacked(entry, baseDirectory, AssetTypes.SpriteDefinition, SpriteDefinitionSerializer.Load)
914 : !string.IsNullOrWhiteSpace(entry.GsprPath)
915 ? SpriteDefinitionSerializer.Load(ResolvePath(entry.GsprPath, baseDirectory))
916 : entry.Definition ?? throw new InvalidDataException("Sprite state entry requires GsprPath or Definition.");
917 incoming = WithDefinitionContext(entry, baseDirectory,
918 () => SpriteDefinitionSerializer.ToSprites(definition, allowDuplicateNicknames: true));
919 DetachSnapshotSprites(incoming);
920 }
921 MergeSprites(incoming, overwriteExisting);
922 }
923 }
924 }
925
926 private static void ClearSelected(EngineStateParts parts)
927 {
928 if (parts.HasFlag(EngineStateParts.AssetsFiles))
929 {
930 AssetsFile.ClearAll();
931 SvgResourceManager.Instance.Clear();
932 }
933
934 if (parts.HasFlag(EngineStateParts.Tilesheets))
935 TilesheetRegistry.Instance.Clear();
936
937 if (parts.HasFlag(EngineStateParts.Cycles))
938 Cycle.ClearAllAnimationCycles();
939
940 if (parts.HasFlag(EngineStateParts.Scenes))
941 Scene.ClearAllScenes();
942
943 if (parts.HasFlag(EngineStateParts.Sprites))
944 {
945 SpriteManager.Instance.DefinitionProvenance = null;
946 // EngineState replacement is synchronous; normal game-loop disposal is deferred.
947 foreach (var sprite in SpriteManager.Instance.AllSprites)
948 {
949 SpriteManager.Instance._spriteList.Remove(sprite);
950 sprite.DisposeImmediate();
951 }
952 }
953
954 if (parts.HasFlag(EngineStateParts.Audio))
955 AudioResourceManager.Instance.Dispose();
956 }
957
958 private static void LoadAssetsFiles(IEnumerable<AssetsFileStateEntry> resourceFiles, bool overwriteExisting, string? baseDirectory, IEnumerable<DefinitionStateEntry> packedEntries)
959 {
960 // Replace raw deserialized resource files with proper loaded instances
961 if (resourceFiles.Any())
962 {
963 foreach (var raw in resourceFiles)
964 {
965 try
966 {
967 var path = ResolvePath(raw.FilePath, baseDirectory);
968 var loaded = FindAssetsFile(path);
969 if (loaded is null)
970 {
971 if (!File.Exists(path)) throw new FileNotFoundException($"EngineState assets file not found: {path}", path);
972 loaded = AssetsFile.LoadOrCreate(path, raw.Password, raw.UseEncryption);
973 }
974
975 if (overwriteExisting)
976 {
977 foreach (var entry in loaded.GetAllEntries().Where(e => e.AssetType == AssetTypes.Svg))
978 SvgResourceManager.Instance.Unload(entry.AssetName);
979 }
980
981 SvgResourceManager.Instance.LoadFromEngineAssetsFile(loaded);
982 }
983 catch (Exception ex)
984 {
985 Engine.Logger.LogError(ex, "Failed to load resource file '{FilePath}'", raw.FilePath);
986 var dependency = packedEntries.FirstOrDefault(entry => string.Equals(
987 ResolvePath(entry.AssetsFilePath!, baseDirectory), ResolvePath(raw.FilePath, baseDirectory),
988 OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal));
989 if (dependency is not null)
990 throw new InvalidDataException($"Cannot load packed definition '{dependency.AssetEntryName}' from GAF '{ResolvePath(raw.FilePath, baseDirectory)}'.", ex);
991 throw;
992 }
993 }
994 }
995 }
996
997 private static void MergeAudio(
998 List<AssetsFile>? assetsFiles,
999 AudioStateEntry? audio,
1000 Dictionary<string, AudioResource>? soundSpecs,
1001 bool overwriteExisting,
1002 string? baseDirectory)
1003 {
1004 if (audio is not null)
1005 {
1006 AudioDefinition definition;
1007
1008 if (audio.AssetsFilePath is not null)
1009 {
1010 definition = LoadPacked(audio, baseDirectory, AssetTypes.AudioDefinition, AudioDefinitionSerializer.Load);
1011 }
1012 else if (!string.IsNullOrWhiteSpace(audio.GsndPath))
1013 {
1014 definition = AudioDefinitionSerializer.Load(
1015 ResolvePath(audio.GsndPath, baseDirectory));
1016 }
1017 else if (audio.Definition is not null)
1018 {
1019 definition = audio.Definition;
1020 }
1021 else
1022 {
1023 throw new InvalidDataException(
1024 "Audio state entry does not contain a GSND path or inline definition.");
1025 }
1026
1027 WithDefinitionContext(audio, baseDirectory, () => AudioDefinitionSerializer.LoadIntoManager(
1028 definition,
1029 overwriteExisting,
1030 audio.Definition is not null ? baseDirectory :
1031 audio.GsndPath is not null ? Path.GetDirectoryName(ResolvePath(audio.GsndPath, baseDirectory)) : null));
1032 return;
1033 }
1034
1035 // Legacy EngineState compatibility: load old asset packs first, then
1036 // apply the serialized AudioResource specs / overrides.
1037 if (assetsFiles is not null)
1038 {
1039 foreach (var af in assetsFiles)
1040 {
1041 if (overwriteExisting)
1042 {
1043 foreach (var entry in af.GetAllEntries())
1044 {
1045 if (entry.AssetType == AssetTypes.Audio)
1046 AudioResourceManager.Instance.Unload(entry.AssetName);
1047 }
1048 }
1049
1050 AudioResourceManager.Instance.LoadFromEngineAssetsFile(af);
1051 }
1052 }
1053
1054 // 2) Apply loose-file specs / overrides
1055 if (soundSpecs is null)
1056 return;
1057
1058 foreach (var (key, spec) in soundSpecs)
1059 {
1060 if (AudioResourceManager.Instance.Contains(key))
1061 {
1062 if (!overwriteExisting)
1063 {
1064 var existing = AudioResourceManager.Instance.Get(key);
1065 if (existing is not null)
1066 {
1067 existing.Volume = spec.Volume;
1068 existing.Pan = spec.Pan;
1069 existing.PlaybackSpeed = spec.PlaybackSpeed;
1070 existing.IsLooping = spec.IsLooping;
1071 }
1072 continue;
1073 }
1074
1075 AudioResourceManager.Instance.Unload(key);
1076 }
1077
1078 // Ensure the audio spec is (re)created/registered in the manager.
1079 spec.ReloadIntoManager();
1080 }
1081 }
1082
1083 private static Dictionary<string, TilesheetDefinition> CaptureTilesheetDefinitions(string? baseDirectory)
1084 {
1085 return TilesheetRegistry.Instance
1086 .GetAll()
1087 .ToDictionary(
1088 kvp => kvp.Key,
1089 kvp => TilesheetDefinitionSerializer.FromTilesheet(
1090 kvp.Value,
1091 baseDirectory,
1092 makePathsRelative: !string.IsNullOrWhiteSpace(baseDirectory)));
1093 }
1094
1095 private static Dictionary<string, TilesheetStateEntry> CaptureTilesheetEntries(
1096 string? baseDirectory,
1097 string engineStatePath,
1098 DefinitionPersistence persistence)
1099 {
1100 var result = new Dictionary<string, TilesheetStateEntry>(StringComparer.Ordinal);
1101
1102 foreach (var (key, tilesheet) in TilesheetRegistry.Instance.GetAll())
1103 {
1104 var preserved = new TilesheetStateEntry();
1105 if (tilesheet.DefinitionProvenance is { } provenance &&
1106 TryPreserve(preserved, provenance, TilesheetDefinitionSerializer.FromTilesheet(tilesheet), persistence, baseDirectory, out var loosePath))
1107 {
1108 preserved.GtsPath = loosePath;
1109 result[key] = preserved;
1110 continue;
1111 }
1112
1113 if (persistence == DefinitionPersistence.Loose ||
1114 (persistence == DefinitionPersistence.PreserveSource &&
1115 string.IsNullOrWhiteSpace(tilesheet.ImageFilePath) &&
1116 string.IsNullOrWhiteSpace(tilesheet.AssetIdentifier?.AssetsFile.SourcePath)))
1117 {
1118 var gtsDirectory = GetTilesheetStateDirectory(engineStatePath);
1119 Directory.CreateDirectory(gtsDirectory);
1120
1121 var gtsFileName = $"{SanitizeFileName(key)}.gts";
1122 var gtsFullPath = Path.Combine(gtsDirectory, gtsFileName);
1123
1124 // Important:
1125 // Save the GTS using the GTS serializer, not EngineState.JsonSerializerSettings.
1126 // This avoids $id/$values noise in the .gts file.
1127 TilesheetDefinitionSerializer.Save(
1128 gtsFullPath,
1129 tilesheet,
1130 makePathsRelative: true);
1131
1132 var gtsPathForState = MakeRelativePath(
1133 gtsFullPath,
1134 baseDirectory);
1135
1136 result[key] = new TilesheetStateEntry
1137 {
1138 GtsPath = gtsPathForState
1139 };
1140 }
1141 else
1142 {
1143 result[key] = new TilesheetStateEntry
1144 {
1145 Definition = TilesheetDefinitionSerializer.FromTilesheet(
1146 tilesheet,
1147 baseDirectory,
1148 makePathsRelative: !string.IsNullOrWhiteSpace(baseDirectory))
1149 };
1150 }
1151 }
1152
1153 return result;
1154 }
1155
1156 private static string GetTilesheetStateDirectory(string engineStatePath)
1157 {
1158 var directory = Path.GetDirectoryName(engineStatePath) ?? string.Empty;
1159 var fileName = Path.GetFileNameWithoutExtension(engineStatePath);
1160
1161 return Path.Combine(directory, $"{fileName}.tilesheets");
1162 }
1163
1164
1165 private static string GetAnimationStateDirectory(string engineStatePath)
1166 {
1167 var directory = Path.GetDirectoryName(engineStatePath) ?? string.Empty;
1168 var fileName = Path.GetFileNameWithoutExtension(engineStatePath);
1169
1170 return Path.Combine(directory, $"{fileName}.animations");
1171 }
1172
1173
1174 private static string GetAudioStateDirectory(string engineStatePath)
1175 {
1176 var directory = Path.GetDirectoryName(engineStatePath) ?? string.Empty;
1177 var fileName = Path.GetFileNameWithoutExtension(engineStatePath);
1178
1179 return Path.Combine(directory, $"{fileName}.audio");
1180 }
1181
1182
1183 private static string GetSceneStateDirectory(string engineStatePath)
1184 {
1185 var directory = Path.GetDirectoryName(engineStatePath) ?? string.Empty;
1186 var fileName = Path.GetFileNameWithoutExtension(engineStatePath);
1187
1188 return Path.Combine(directory, $"{fileName}.scenes");
1189 }
1190
1191 private static string MakeRelativePath(string path, string? baseDirectory)
1192 {
1193 if (string.IsNullOrWhiteSpace(baseDirectory))
1194 return path;
1195
1196 return Path.GetRelativePath(
1197 Path.GetFullPath(baseDirectory),
1198 Path.GetFullPath(path));
1199 }
1200
1201 private static string SanitizeFileName(string value)
1202 {
1203 if (string.IsNullOrWhiteSpace(value))
1204 return Guid.NewGuid().ToString("N");
1205
1206 var invalidChars = Path.GetInvalidFileNameChars();
1207 var sanitized = new string(
1208 value.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray());
1209
1210 return string.IsNullOrWhiteSpace(sanitized)
1211 ? Guid.NewGuid().ToString("N")
1212 : sanitized;
1213 }
1214
1215 private static void MergeTilesheets(
1216 Dictionary<string, TilesheetStateEntry>? tilesheets,
1217 bool overwriteExisting,
1218 string? baseDirectory)
1219 {
1220 if (tilesheets is null || tilesheets.Count == 0)
1221 return;
1222
1223 var registry = TilesheetRegistry.Instance.GetAll();
1224
1225 foreach (var (key, entry) in tilesheets)
1226 {
1227 if (entry is null)
1228 continue;
1229
1230 var existingKey = key;
1231
1232 if (!overwriteExisting && registry.ContainsKey(existingKey))
1233 continue;
1234
1235 Tilesheet rebuilt;
1236
1237 if (entry.AssetsFilePath is not null)
1238 {
1239 rebuilt = LoadPacked(entry, baseDirectory, AssetTypes.TilesheetDefinition, TilesheetFactory.FromDefinitionAsset);
1240 }
1241 else if (!string.IsNullOrWhiteSpace(entry.GtsPath))
1242 {
1243 var gtsPath = ResolvePath(entry.GtsPath, baseDirectory);
1244
1245 rebuilt = TilesheetFactory.FromDefinitionFile(gtsPath);
1246 }
1247 else if (entry.Definition is not null)
1248 {
1249 if (string.IsNullOrWhiteSpace(entry.Definition.Name))
1250 entry.Definition.Name = key;
1251
1252 rebuilt = TilesheetFactory.FromDefinition(
1253 entry.Definition,
1254 baseDirectory);
1255 }
1256 else
1257 {
1258 throw new InvalidDataException(
1259 $"Tilesheet state entry '{key}' does not contain a GTS path or inline definition.");
1260 }
1261
1262 TilesheetRegistry.Instance.Register(
1263 rebuilt,
1264 disposeReplaced: overwriteExisting);
1265 }
1266 }
1267
1268 private static string ResolvePath(string path, string? baseDirectory)
1269 {
1270 if (Path.IsPathRooted(path))
1271 return path;
1272
1273 if (string.IsNullOrWhiteSpace(baseDirectory))
1274 return path;
1275
1276 return Path.GetFullPath(Path.Combine(baseDirectory, path));
1277 }
1278
1279 private static void MergeAnimations(
1280 Dictionary<string, AnimationStateEntry>? animations,
1281 bool overwriteExisting,
1282 string? baseDirectory)
1283 {
1284 if (animations is null || animations.Count == 0)
1285 return;
1286
1287 var definitions = new List<AnimationDefinition>();
1288 var sourceEntries = new Dictionary<AnimationDefinition, AnimationStateEntry>();
1289
1290 foreach (var (key, entry) in animations)
1291 {
1292 if (entry is null)
1293 continue;
1294
1295 if (!overwriteExisting && Cycle._cycles.ContainsKey(key))
1296 continue;
1297
1298 AnimationDefinition definition;
1299
1300 if (entry.AssetsFilePath is not null)
1301 {
1302 definition = LoadPacked(entry, baseDirectory, AssetTypes.AnimationDefinition, AnimationDefinitionSerializer.Load);
1303 }
1304 else if (!string.IsNullOrWhiteSpace(entry.GaniPath))
1305 {
1306 var ganiPath = ResolvePath(entry.GaniPath, baseDirectory);
1307 definition = AnimationDefinitionSerializer.Load(ganiPath);
1308 }
1309 else if (entry.Definition is not null)
1310 {
1311 definition = entry.Definition;
1312 }
1313 else
1314 {
1315 throw new InvalidDataException(
1316 $"Animation state entry '{key}' does not contain a GANI path or inline definition.");
1317 }
1318
1319 if (string.IsNullOrWhiteSpace(definition.Key))
1320 {
1321 definition.Key = key;
1322 }
1323 else if (!string.Equals(definition.Key, key, StringComparison.Ordinal))
1324 {
1325 throw DefinitionError(entry, baseDirectory, new InvalidDataException(
1326 $"Animation state entry '{key}' contains definition key '{definition.Key}'."));
1327 }
1328
1329 definitions.Add(definition);
1330 sourceEntries.Add(definition, entry);
1331 }
1332
1333 // Validate next-cycle identities before mutating the registry. Two-phase
1334 // materialization allows circular transitions such as idle -> blink -> idle.
1335 var availableKeys = new HashSet<string>(
1336 Cycle._cycles.Keys,
1337 StringComparer.Ordinal);
1338
1339 foreach (var definition in definitions)
1340 availableKeys.Add(definition.Key);
1341
1342 foreach (var definition in definitions)
1343 {
1344 if (!string.IsNullOrWhiteSpace(definition.NextCycleKey) &&
1345 !string.Equals(
1346 definition.NextCycleKey,
1347 definition.Key,
1348 StringComparison.Ordinal) &&
1349 !availableKeys.Contains(definition.NextCycleKey))
1350 {
1351 throw DefinitionError(sourceEntries[definition], baseDirectory, new InvalidDataException(
1352 $"GANI animation '{definition.Key}' references next cycle '{definition.NextCycleKey}', but no matching animation is available."));
1353 }
1354 }
1355
1356 var materialized = new List<(Cycle Cycle, AnimationDefinition Definition)>();
1357
1358 foreach (var definition in definitions)
1359 {
1360 var cycle = WithDefinitionContext(sourceEntries[definition], baseDirectory,
1361 () => AnimationDefinitionSerializer.MaterializeCycle(definition));
1362 materialized.Add((cycle, definition));
1363 }
1364
1365 foreach (var (cycle, definition) in materialized)
1366 WithDefinitionContext(sourceEntries[definition], baseDirectory, () =>
1367 {
1368 AnimationDefinitionSerializer.ApplyNextCycle(cycle, definition);
1369 return cycle;
1370 });
1371 }
1372
1373 private static void DetachLegacySnapshotScenes(List<SceneStateEntry>? scenes)
1374 {
1375 if (scenes is null)
1376 return;
1377
1378 foreach (var entry in scenes)
1379 {
1380 if (entry?.LegacyScene is { } legacyScene)
1381 Scene._allScenes.Remove(legacyScene);
1382 }
1383 }
1384
1385 private static void MergeScenes(
1386 List<SceneStateEntry>? scenes,
1387 bool overwriteExisting,
1388 string? baseDirectory)
1389 {
1390 if (scenes is null || scenes.Count == 0)
1391 return;
1392
1393 var existingIndexById = new Dictionary<string, int>(StringComparer.Ordinal);
1394 for (int i = 0; i < Scene._allScenes.Count; i++)
1395 {
1396 var id = Scene._allScenes[i].ID;
1397 if (!string.IsNullOrWhiteSpace(id) && !existingIndexById.ContainsKey(id))
1398 existingIndexById.Add(id, i);
1399 }
1400
1401 // Avoid duplicating the same incoming ID twice. A duplicate within one
1402 // snapshot is treated as an overwrite so the last incoming entry wins.
1403 var seenIncoming = new HashSet<string>(StringComparer.Ordinal);
1404
1405 foreach (var entry in scenes)
1406 {
1407 if (entry is null)
1408 continue;
1409
1410 Scene incoming;
1411 bool materializedFromDefinition = false;
1412
1413 if (entry.LegacyScene is not null)
1414 {
1415 incoming = entry.LegacyScene;
1416 }
1417 else if (entry.AssetsFilePath is not null)
1418 {
1419 incoming = LoadPacked(entry, baseDirectory, AssetTypes.SceneDefinition, SceneDefinitionSerializer.LoadScene);
1420 materializedFromDefinition = true;
1421 }
1422 else if (!string.IsNullOrWhiteSpace(entry.GscnPath))
1423 {
1424 var gscnPath = ResolvePath(entry.GscnPath, baseDirectory);
1425 incoming = SceneDefinitionSerializer.LoadScene(gscnPath);
1426 materializedFromDefinition = true;
1427 }
1428 else if (entry.Definition is not null)
1429 {
1430 incoming = SceneDefinitionSerializer.ToScene(entry.Definition);
1431 materializedFromDefinition = true;
1432 }
1433 else
1434 {
1435 throw new InvalidDataException(
1436 "Scene state entry does not contain a GSCN path or inline definition.");
1437 }
1438
1439 // Scene materialization registers runtime scenes immediately. Detach newly
1440 // materialized definitions so this method remains the single merge owner.
1441 if (materializedFromDefinition)
1442 Scene._allScenes.Remove(incoming);
1443
1444 if (string.IsNullOrWhiteSpace(incoming.ID))
1445 incoming.ID = Guid.NewGuid().ToString();
1446
1447 bool duplicateIncoming = !seenIncoming.Add(incoming.ID);
1448
1449 if (existingIndexById.TryGetValue(incoming.ID, out int existingIndex))
1450 {
1451 if (!overwriteExisting && !duplicateIncoming)
1452 {
1453 if (materializedFromDefinition)
1454 incoming.Dispose();
1455
1456 continue;
1457 }
1458
1459 Scene._allScenes[existingIndex] = incoming;
1460 }
1461 else
1462 {
1463 existingIndexById[incoming.ID] = Scene._allScenes.Count;
1464 Scene._allScenes.Add(incoming);
1465 }
1466 }
1467 }
1468
1469 private static void RebindSpriteSceneLayer(Sprite sprite)
1470 {
1471 var layerId = sprite.SerializedSceneLayerIdForBinding;
1472 if (string.IsNullOrWhiteSpace(layerId))
1473 {
1474 if (!ReferenceEquals(sprite.SceneLayer, SceneLayer.Empty))
1475 return;
1476
1477 throw new InvalidDataException(
1478 $"Sprite '{sprite.Nickname ?? sprite.Id.ToString()}' does not contain a SceneLayer identity.");
1479 }
1480
1481 SceneLayer? targetLayer = null;
1482 var sceneId = sprite.SerializedSceneIdForBinding;
1483
1484 if (!string.IsNullOrWhiteSpace(sceneId))
1485 {
1486 var scene = Scene._allScenes.FirstOrDefault(
1487 candidate => string.Equals(candidate.ID, sceneId, StringComparison.Ordinal));
1488
1489 targetLayer = scene?.GetSceneLayerByID(layerId);
1490 }
1491 else
1492 {
1493 // Older data may have a layer ID but no scene ID. Layer IDs are normally
1494 // GUIDs; accept a unique match and reject ambiguity.
1495 var matches = Scene._allScenes
1496 .SelectMany(scene => scene.SceneLayers)
1497 .Where(layer => string.Equals(layer.ID, layerId, StringComparison.Ordinal))
1498 .Take(2)
1499 .ToList();
1500
1501 if (matches.Count == 1)
1502 targetLayer = matches[0];
1503 else if (matches.Count > 1)
1504 throw new InvalidDataException(
1505 $"Sprite '{sprite.Nickname ?? sprite.Id.ToString()}' references ambiguous SceneLayer ID '{layerId}'.");
1506 }
1507
1508 if (targetLayer is null)
1509 {
1510 throw new InvalidDataException(
1511 $"Sprite '{sprite.Nickname ?? sprite.Id.ToString()}' could not resolve SceneLayer '{layerId}'" +
1512 (string.IsNullOrWhiteSpace(sceneId) ? "." : $" in Scene '{sceneId}'."));
1513 }
1514
1515 sprite.RebindSceneLayerAfterDeserialization(targetLayer);
1516 }
1517
1518 private static void DetachSnapshotSprites(List<Sprite>? sprites)
1519 {
1520 if (sprites is null)
1521 return;
1522
1523 foreach (var sprite in sprites)
1524 {
1525 if (sprite is not null)
1526 SpriteManager.Instance._spriteList.Remove(sprite);
1527 }
1528 }
1529
1530 private static void MergeSprites(List<Sprite>? sprites, bool overwriteExisting)
1531 {
1532 if (sprites is null || sprites.Count == 0)
1533 return;
1534
1535 var existingIndexById = new Dictionary<string, int>(StringComparer.Ordinal);
1536 for (int i = 0; i < SpriteManager.Instance._spriteList.Count; i++)
1537 {
1538 var id = SpriteManager.Instance._spriteList[i].Nickname;
1539 if (!string.IsNullOrWhiteSpace(id) && !existingIndexById.ContainsKey(id))
1540 existingIndexById.Add(id, i);
1541 }
1542
1543 var seenIncoming = new HashSet<string>(StringComparer.Ordinal);
1544
1545 foreach (var incoming in sprites)
1546 {
1547 if (incoming is null)
1548 continue;
1549
1550 RebindSpriteSceneLayer(incoming);
1551
1552 if (string.IsNullOrWhiteSpace(incoming.Nickname))
1553 incoming.Nickname = Guid.NewGuid().ToString();
1554
1555 bool duplicateIncoming = !seenIncoming.Add(incoming.Nickname);
1556
1557 if (existingIndexById.TryGetValue(incoming.Nickname, out int existingIndex))
1558 {
1559 if (!overwriteExisting && !duplicateIncoming)
1560 {
1561 incoming.DisposeImmediate();
1562 continue;
1563 }
1564
1565 SpriteManager.Instance._spriteList[existingIndex].DisposeImmediate();
1566 SpriteManager.Instance._spriteList[existingIndex] = incoming;
1567 }
1568 else
1569 {
1570 existingIndexById[incoming.Nickname] = SpriteManager.Instance._spriteList.Count;
1571 SpriteManager.Instance.AddSprite(incoming);
1572 }
1573 }
1574 }
1575
1576 #endregion deserialization helpers
1577}
Represents an asset file used by the engine, providing functionality to load, manage,...
Definition AssetsFile.cs:18
static void ClearAll()
Releases all assets held by the application and clears the internal collection of asset files.
Definition AssetsFile.cs:29
static IReadOnlyList< AssetsFile > AllAssetsFiles
Gets a read-only list of registered AssetsFile instances.
Definition AssetsFile.cs:24
Manages the lifecycle of audio resources independently of the playback backend.
void Dispose()
Disposes the manager and all loaded audio resources. This method is idempotent.
Dictionary< string, AudioResource > GetAll()
Returns a snapshot dictionary of all loaded audio resources.
static AudioResourceManager Instance
Gets the singleton AudioResourceManager instance.
Root definition for the GSND (Gondwana Sound) file format.
References a FrameSequence object, along with a particular Throttle value for animating through Frame...
Definition Cycle.cs:14
static void ClearAllAnimationCycles()
Removes and disposes all registered animation cycles.
Definition Cycle.cs:246
Represents the root definition of an animation in the GANI (Gondwana Animation) file format.
string Key
Gets or sets the globally unique animation key used by Cycle.
A collection of authored sprites. Empty collections are valid.
Manages the lifecycle, lookup, and per-frame updates of all active Sprite instances.
void Clear()
Removes and disposes all sprites currently managed by the sprite manager.
static SpriteManager Instance
Gets the singleton instance of the sprite manager.
override SceneLayer SceneLayer
Definition Sprite.cs:624
Represents the root definition of a tilesheet in the GTS (Gondwana Tilesheet) file format.
Thread-safe singleton registry for Tilesheet instances.
static TilesheetRegistry Instance
Gets the singleton instance of the TilesheetRegistry.
void Clear()
Removes all tilesheets from the registry and disposes them.
Persistence choices for an EngineState save.
DefinitionPersistence Tilesheets
How to persist tilesheet definitions.
Represents the complete serializable state of the game engine, including assets, scenes,...
static void MergeFromFile(string path, bool compressed=false, bool overwriteExisting=false, EngineStateParts parts=EngineStateParts.All)
Loads engine state from a file and merges it with the current engine state, optionally overwriting ex...
Dictionary< string, Cycle > Cycles
Gets the dictionary of all registered animation cycles, keyed by their unique identifiers....
static JsonSerializerSettings JsonSerializerSettings
Gets or sets the JSON serializer settings used for serializing and deserializing engine state....
List< Sprite > Sprites
Gets the list of all active sprites currently managed by the sprite manager. Sprites are visual game ...
Dictionary< string, AudioResource > SoundResources
Gets the dictionary of all registered audio resources, keyed by their unique identifiers....
static void LoadFromFile(string path, bool compressed=false, EngineStateParts parts=EngineStateParts.All)
Loads engine state from a file, replacing the current engine state with the saved state....
List< Scene > Scenes
Gets the list of all scenes currently registered with the engine. Scenes represent distinct game loca...
void SaveToFile(string path, bool compress=false, bool separateGtsFiles=false, EngineStateParts parts=EngineStateParts.All, bool separateGscnFiles=false, bool separateGaniFiles=false, bool separateGsndFile=false, bool separateGsprFile=false)
Saves the current engine state to a file in JSON format with optional compression and selective state...
TypedValueBag ValueBag
Stores extensible, project-specific state data associated with this engine state.
void SaveToFile(string path, EngineStateSaveOptions options)
Saves selected state using per-format definition persistence choices.
IDictionary< string, TilesheetDefinition > Tilesheets
Gets a dictionary of all registered tilesheets, keyed by their unique identifiers....
IEnumerable< AssetsFile > AssetsFiles
Gets the collection of all loaded asset files (resource archives) currently registered with the engin...
Represents the root definition of a scene in the GSCN (Gondwana Scene) file format.
Represents a game scene containing multiple layers of tiles, sprites, and game objects....
Definition Scene.cs:34
static void ClearAllScenes()
Disposes all active scenes in the global scene collection, releasing all resources.
Definition Scene.cs:918
string ID
Gets or sets the unique identifier for this scene.
Definition Scene.cs:196
virtual void Dispose()
Releases all resources used by the Scene and removes it from the global scene collection.
Definition Scene.cs:845
A flexible, strongly-typed value container intended for runtime-attached metadata and ephemeral engin...
void Clear()
Removes all values from the bag.
AssetTypes
Defines the types of assets that can be stored and managed by the engine.
Definition AssetTypes.cs:8
@ AnimationDefinition
Represents an animation definition file (.gani) for Gondwana.Drawing.Animation.GANI....
Definition AssetTypes.cs:57
@ Audio
Represents the audio media type for Gondwana.Audio.AudioResourceManager
Definition AssetTypes.cs:17
@ AudioDefinition
Represents an audio definition file (.gsnd) for Gondwana.Audio.GSND.AudioDefinition.
Definition AssetTypes.cs:62
AudioResourceSourceKind
Identifies how a GSND audio resource locates its underlying media.
@ SceneLayer
Performs direct drawing within a scene layer using world-space coordinates.
@ Scene
Displays scene layer, grid-cell, and active tile-animation counts.
EngineStateParts
Represents different parts of the engine state that can be managed, saved, or loaded independently....
@ AssetsFiles
Represents the asset files state, including loaded file references, asset metadata,...
@ Scenes
Represents the scenes state, including scene definitions, layer configurations, entity placements,...
@ Cycles
Represents the animation cycles state, including sprite animations, frame sequences,...
DefinitionPersistence
Controls how EngineState persists a definition.
@ Inline
Embed the current definition in the state file.
@ Loose
Write the current definition to a loose file beside the state.