diff --git a/Theriapolis.Godot/Main.cs b/Theriapolis.Godot/Main.cs index e4f8364..a98cb87 100644 --- a/Theriapolis.Godot/Main.cs +++ b/Theriapolis.Godot/Main.cs @@ -1,4 +1,5 @@ using Godot; +using Theriapolis.GodotHost.Platform; using Theriapolis.GodotHost.Rendering; namespace Theriapolis.GodotHost; @@ -18,6 +19,7 @@ public partial class Main : Node ulong? smokeTestSeed = null; ulong? worldMapSeed = null; + bool runAssetTest = false; for (int i = 0; i < args.Length; i++) { if (args[i] == "--smoke-test") @@ -28,6 +30,11 @@ public partial class Main : Node smokeTestSeed = seed; break; } + if (args[i] == "--asset-test") + { + runAssetTest = true; + break; + } if (args[i] == "--world-map") { ulong seed = 12345UL; @@ -45,6 +52,13 @@ public partial class Main : Node return; } + if (runAssetTest) + { + int code = AssetTest.Run(); + GetTree().Quit(code); + return; + } + if (worldMapSeed.HasValue) { // Replace the M0 hello-world children with the M2 world-map view. diff --git a/Theriapolis.Godot/Platform/AssetTest.cs b/Theriapolis.Godot/Platform/AssetTest.cs new file mode 100644 index 0000000..a8aea5c --- /dev/null +++ b/Theriapolis.Godot/Platform/AssetTest.cs @@ -0,0 +1,78 @@ +using Godot; +using System.IO; +using System.Linq; + +namespace Theriapolis.GodotHost.Platform; + +/// +/// M3 asset pipeline smoke test. Walks Content/Data and Content/Gfx, +/// reports counts, and verifies every PNG under Gfx loads via +/// ContentLoader. Exits non-zero on any failure. +/// +/// Run with: +/// godot --headless --path Theriapolis.Godot -- --asset-test +/// +public static class AssetTest +{ + public static int Run() + { + GD.Print($"[asset-test] Content root: {ContentPaths.ContentRoot}"); + GD.Print($"[asset-test] Data dir: {ContentPaths.DataDir}"); + GD.Print($"[asset-test] Gfx dir: {ContentPaths.GfxDir}"); + + if (!Directory.Exists(ContentPaths.DataDir)) + { + GD.PrintErr($"[asset-test] FAIL: Data directory missing"); + return 1; + } + if (!Directory.Exists(ContentPaths.GfxDir)) + { + GD.PrintErr($"[asset-test] FAIL: Gfx directory missing"); + return 1; + } + + var jsonFiles = Directory.GetFiles(ContentPaths.DataDir, "*.json", SearchOption.AllDirectories); + GD.Print($"[asset-test] Data: {jsonFiles.Length} JSON files"); + + var pngFiles = Directory.GetFiles(ContentPaths.GfxDir, "*.png", SearchOption.AllDirectories); + GD.Print($"[asset-test] Gfx: {pngFiles.Length} PNG files"); + + int loaded = 0; + int failed = 0; + foreach (var absolute in pngFiles) + { + string relative = Path.GetRelativePath(ContentPaths.GfxDir, absolute) + .Replace('\\', '/'); + var tex = ContentLoader.LoadGfx(relative); + if (tex is null) + { + failed++; + } + else + { + loaded++; + } + } + + GD.Print($"[asset-test] Loaded {loaded}/{pngFiles.Length} PNGs " + + $"(cache size {ContentLoader.CacheCount}, failed {failed})"); + + if (failed > 0) + { + GD.PrintErr($"[asset-test] FAIL: {failed} texture(s) failed to load"); + return 1; + } + + // Also report the sub-tree breakdown, matching what M4 will care about + var bySubdir = pngFiles + .GroupBy(f => Path.GetRelativePath(ContentPaths.GfxDir, Path.GetDirectoryName(f)!) + .Replace('\\', '/')) + .OrderBy(g => g.Key); + GD.Print($"[asset-test] PNG breakdown by sub-directory:"); + foreach (var g in bySubdir) + GD.Print($"[asset-test] {g.Key,-24} {g.Count(),3} files"); + + GD.Print($"[asset-test] OK"); + return 0; + } +} diff --git a/Theriapolis.Godot/Platform/ContentLoader.cs b/Theriapolis.Godot/Platform/ContentLoader.cs new file mode 100644 index 0000000..f4184d5 --- /dev/null +++ b/Theriapolis.Godot/Platform/ContentLoader.cs @@ -0,0 +1,55 @@ +using Godot; +using System.Collections.Generic; +using System.IO; + +namespace Theriapolis.GodotHost.Platform; + +/// +/// Loads PNGs from Content/Gfx as Godot ImageTextures and caches them by +/// relative path. Texture filter is set to Nearest (project default for +/// pixel art). +/// +/// This bypasses Godot's res:// import pipeline because Content/ lives +/// outside the project — but for static pixel-art assets at native size +/// the import pipeline doesn't add anything we need. +/// +/// Tactical-view tile rendering (M4) will hit this from chunk streamers, +/// so the cache is per-process and never evicted; the full atlas +/// (~50 PNGs at 32x32) is well under 1 MB. +/// +public static class ContentLoader +{ + private static readonly Dictionary _cache = new(); + + /// + /// Loads a PNG from Content/Gfx/<relativePath>. Returns + /// null and logs an error if the file is missing or unreadable. + /// + public static ImageTexture? LoadGfx(string relativePath) + { + if (_cache.TryGetValue(relativePath, out var cached)) return cached; + + string absolute = Path.Combine(ContentPaths.GfxDir, relativePath); + if (!File.Exists(absolute)) + { + GD.PrintErr($"[ContentLoader] Missing texture: {absolute}"); + return null; + } + + var image = Image.LoadFromFile(absolute); + if (image is null) + { + GD.PrintErr($"[ContentLoader] Image.LoadFromFile failed: {absolute}"); + return null; + } + + var tex = ImageTexture.CreateFromImage(image); + _cache[relativePath] = tex; + return tex; + } + + public static int CacheCount => _cache.Count; + + /// Drops every cached texture. Useful for hot-reload tests. + public static void ClearCache() => _cache.Clear(); +} diff --git a/Theriapolis.Godot/Platform/ContentPaths.cs b/Theriapolis.Godot/Platform/ContentPaths.cs new file mode 100644 index 0000000..33097af --- /dev/null +++ b/Theriapolis.Godot/Platform/ContentPaths.cs @@ -0,0 +1,46 @@ +using Godot; +using System.IO; + +namespace Theriapolis.GodotHost.Platform; + +/// +/// Single source of truth for resolving the project's Content/ directory. +/// Content lives at the repository root (sibling to Theriapolis.Godot/), +/// not inside res://. This avoids duplicating gigabytes of game data, and +/// keeps the same Content/Data and Content/Gfx tree the MonoGame branch +/// and headless Tools all read from. +/// +/// Resolution walks up from res:// looking for Content/Data; once found, +/// the parent of Data is the Content root, used for Gfx too. Cached after +/// first hit so repeated calls are cheap. +/// +public static class ContentPaths +{ + private static string? _cachedContentRoot; + + public static string ContentRoot => _cachedContentRoot ??= ResolveContentRoot(); + public static string DataDir => Path.Combine(ContentRoot, "Data"); + public static string GfxDir => Path.Combine(ContentRoot, "Gfx"); + + private static string ResolveContentRoot() + { + // Try res://../Content first — the canonical layout. + string fromRes = ProjectSettings.GlobalizePath("res://../Content"); + if (Directory.Exists(Path.Combine(fromRes, "Data"))) return fromRes; + + // Walk up further in case Theriapolis.Godot is nested deeper. + string? dir = ProjectSettings.GlobalizePath("res://").TrimEnd('/', '\\'); + for (int i = 0; i < 6; i++) + { + if (string.IsNullOrEmpty(dir)) break; + string candidate = Path.Combine(dir, "Content"); + if (Directory.Exists(Path.Combine(candidate, "Data"))) return candidate; + dir = Path.GetDirectoryName(dir); + } + + // Last resort — return the canonical path even if it doesn't exist; + // callers that care will fail with a useful error. + GD.PushWarning($"[ContentPaths] Content root not found; using {fromRes}"); + return fromRes; + } +} diff --git a/Theriapolis.Godot/Rendering/WorldMapView.cs b/Theriapolis.Godot/Rendering/WorldMapView.cs index abc55a2..ad780fc 100644 --- a/Theriapolis.Godot/Rendering/WorldMapView.cs +++ b/Theriapolis.Godot/Rendering/WorldMapView.cs @@ -6,6 +6,7 @@ using Theriapolis.Core.Util; using Theriapolis.Core.World; using Theriapolis.Core.World.Generation; using Theriapolis.Core.World.Polylines; +using Theriapolis.GodotHost.Platform; namespace Theriapolis.GodotHost.Rendering; @@ -53,7 +54,7 @@ public partial class WorldMapView : Node2D public override void _Ready() { - string dataDir = ResolveDataDir(); + string dataDir = ContentPaths.DataDir; if (!Directory.Exists(dataDir)) { GD.PrintErr($"[world-map] Data directory not found: {dataDir}"); @@ -285,22 +286,6 @@ public partial class WorldMapView : Node2D _ => BiomeId.TemperateGrassland, }; - private static string ResolveDataDir() - { - string fromRes = ProjectSettings.GlobalizePath("res://../Content/Data"); - if (Directory.Exists(fromRes)) return fromRes; - - string? dir = ProjectSettings.GlobalizePath("res://").TrimEnd('/', '\\'); - for (int i = 0; i < 6; i++) - { - if (string.IsNullOrEmpty(dir)) break; - string candidate = Path.Combine(dir, "Content", "Data"); - if (Directory.Exists(candidate)) return candidate; - dir = Path.GetDirectoryName(dir); - } - - return fromRes; - } } /// diff --git a/Theriapolis.Godot/SmokeTest.cs b/Theriapolis.Godot/SmokeTest.cs index 4a707ed..ce0c7c5 100644 --- a/Theriapolis.Godot/SmokeTest.cs +++ b/Theriapolis.Godot/SmokeTest.cs @@ -2,6 +2,7 @@ using Godot; using System.IO; using System.Linq; using Theriapolis.Core.World.Generation; +using Theriapolis.GodotHost.Platform; namespace Theriapolis.GodotHost; @@ -15,7 +16,7 @@ public static class SmokeTest { public static int Run(ulong seed) { - string dataDir = ResolveDataDir(); + string dataDir = ContentPaths.DataDir; if (!Directory.Exists(dataDir)) { GD.PrintErr($"[smoke-test] Data directory not found: {dataDir}"); @@ -39,21 +40,4 @@ public static class SmokeTest GD.Print($"[smoke-test] {kv.Key,-32} = 0x{kv.Value:X16}"); return 0; } - - private static string ResolveDataDir() - { - string fromRes = ProjectSettings.GlobalizePath("res://../Content/Data"); - if (Directory.Exists(fromRes)) return fromRes; - - string? dir = ProjectSettings.GlobalizePath("res://").TrimEnd('/', '\\'); - for (int i = 0; i < 6; i++) - { - if (string.IsNullOrEmpty(dir)) break; - string candidate = Path.Combine(dir, "Content", "Data"); - if (Directory.Exists(candidate)) return candidate; - dir = Path.GetDirectoryName(dir); - } - - return fromRes; - } }