commit 1753766a5d8049867e8d1f456e2b916af472f653 Author: iisaev Date: Sat Oct 31 15:28:48 2026 +0300 Update avatar_layers implementation, assets and documentation diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..3030c1a --- /dev/null +++ b/.gitattributes @@ -0,0 +1,30 @@ +# Line endings: LF everywhere, on every OS, whatever core.autocrlf says. +* text=auto eol=lf + +# cmd.exe misreads labels and goto in LF batch files, so they stay CRLF. +*.bat text eol=crlf +*.cmd text eol=crlf + +# Binaries: never converted, never diffed as text. +*.png binary +*.jpg binary +*.jpeg binary +*.webp binary +*.svg text +*.ogg binary +*.wav binary +*.mp3 binary +*.ttf binary +*.otf binary +*.woff binary +*.woff2 binary +*.dll binary +*.so binary +*.dylib binary +*.pdb binary +*.exe binary +*.zip binary +*.pck binary +*.moc3 binary +*.res binary +*.scn binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f79a943 --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +# Godot 4.x +.godot/ +.idea/ +sdk/ +bin/ +obj/ + +#ai +.omo/ +.opencode/ + +gen/ +obj/ +bin/ +art_sources/ + +*.tres~ +*.tscn~ + +# GDScript LSP +.godot/ + +# Export +*.exe +*.dmg +*.apk +*.aab +*.pck +*.zip +*.log + +# OS +.DS_Store +Thumbs.db + +# Editor +*.import +*.godot.uid diff --git a/docs/en.md b/docs/en.md new file mode 100644 index 0000000..eae74b4 --- /dev/null +++ b/docs/en.md @@ -0,0 +1,123 @@ +# Layered avatar renderer + +`avatar_layers` implements `IAvatarRenderer` from `avatar_base`, registered as `layers` through +`LayeredAvatarRendererFactory`. Content mods depend on `avatar_layers>=1`; no dependency on +`characters_base`, `dialog_base` or Live2D is required. + +This is a working discrete renderer, not a character-art generator. Authors supply aligned PNG +layers and a `Kind: avatar` definition. For preset semantics and save state see +[avatar_base](../../avatar_base/docs/en.md). + +## Files and bindings + +```text +my_content/ + manifest.json depends: ["avatar_layers>=1"] + avatars/marao.json Kind: avatar, Renderer: layers + avatar_layers/marao/body.png + avatar_layers/marao/eyes_opened.png + avatar_layers/marao/eyes_closed.png +``` + +Image paths include `.png` and are relative to `avatar_layers/`. They are resolved through mod +providers, so ZIP mods and dependent content overrides work without direct filesystem loading. +Use a content-specific prefix such as `marao/` to avoid collisions with unrelated content. + +A complete resource: + +```json +{ + "Kind": "avatar", + "Items": [{ + "Id": "my_content/marao", + "Renderer": "layers", + "BaseValues": { + "canvas": {"Width": 512, "Height": 1024}, + "layers/body": {"Image": "marao/body.png", "Order": 0} + }, + "Groups": { + "eyes_state": { + "Default": "opened", + "Presets": { + "opened": {"Values": { + "layers/eyes": {"Image": "marao/eyes_opened.png", "Order": 10} + }}, + "closed": {"Values": { + "layers/eyes": {"Image": "marao/eyes_closed.png", "Order": 10} + }} + } + } + } + }] +} +``` + +Draw eyes onto transparent images aligned to the same logical canvas as the body. To use cropped +images instead, set `X`, `Y`, `Width`, `Height` on the layer. Coordinates are logical canvas pixels, +not normalized viewport coordinates. The host may scale the visual root to fit a portrait slot. +Root layout belongs to `scenes/avatar_layers/view.tscn`; layer children are data-driven. + +Accepted bindings: + +| Binding | Meaning | +| --- | --- | +| `canvas` | Object with positive finite Width and Height; defaults are 512 and 1024 | +| `layers/` as string | Exact PNG path, full-canvas rectangle, Order 0 | +| `layers/` as object | AvatarLayerDefinition settings below | +| `layers/` as null | No layer | + +Layer properties: + +| Property | Default | Meaning | +| --- | --- | --- | +| Image | required when visible | PNG path including extension | +| Order | 0 | Ascending drawing order; ties use ordinal layer binding ID | +| X, Y | 0 | Top-left position in logical pixels | +| Width, Height | 0 | Rectangle dimensions; zero uses the corresponding canvas dimension | +| Visible | true | False omits the layer, without requiring/loading an image | +| Opacity | 1 | Alpha in [0,1] | +| FlipH, FlipV | false | Image mirroring inside its rectangle | + +Unknown bindings and unknown object properties are rejected to expose typos. Coordinates must be +finite, dimensions nonnegative. A preset replaces a binding object whole: when changing an image, +repeat any non-default bounds/order needed by that layer. Logical binding order and compositing +layer order are separate: group Priority resolves competing targets; layer Order controls drawing. + +## Hosting and changing a preset + +```csharp +IAvatar avatar = AvatarCatalog.Create("my_content/marao"); +IAvatarRenderer renderer = AvatarRenderers.Create(avatar); +host.AddChild(renderer.View); +avatar.ApplyPresets(new Dictionary { ["eyes_state"] = "closed" }); +renderer.Apply(avatar.Resolve()); +// Before freeing host or unloading the renderer mod: +renderer.Dispose(); +``` + +Run visual operations on the Godot runtime main thread. Positive transition times are accepted +but images switch immediately: no interpolated anatomy or crossfade is implied. Rendering or image +validation errors leave the previous valid visual target intact. Logical preset state is separate: +if an owner changed it before a rendering error, it must handle that error and choose another target. + +Each renderer owns its node tree and image cache. Unchanged images are reused across Apply calls; +unused textures are disposed after old nodes release them. No shared engine texture cache is +mutated. Dispose is idempotent, detaches the root and frees nodes before textures. Missing bindings +remove previous layers; hidden layers do not load assets. Applying after disposal is an error. + +Not implemented: per-layer motion/tweens, clipping masks, skeletal deformation, automatic blinking, +scenario commands, editor UI, and automatic dialog integration. + +## Tests + +The opt-in `contracts` suite contains synthetic red/green/blue PNG fixtures, a real avatar entity, +and integration tests for image switching, draw order, positioning, invalid targets, resource +ownership and rendered pixel colors. These fixtures do not mount in normal games. + +```text +godot --headless --path . -- --frontend --with-mods=avatar_layers --run-tests --mod=avatar_layers +godot --path . --rendering-method gl_compatibility -- --frontend --with-mods=avatar_layers --itest=avatar_layers_contracts --run-tests --mod=avatar_layers__contracts +``` + +The second command needs an actual rendering window; the headless dummy renderer cannot verify +pixels. In this workspace execute these commands through the shared Windows watcher job bridge. diff --git a/docs/ru.md b/docs/ru.md new file mode 100644 index 0000000..4e38a05 --- /dev/null +++ b/docs/ru.md @@ -0,0 +1,42 @@ +# Аватары из PNG-слоёв + +`avatar_layers` — рабочая реализация `IAvatarRenderer` с идентификатором `layers`. +Контентный мод зависит от `avatar_layers>=1`, объявляет сущность `Kind: avatar` с +`Renderer: layers` и кладёт прозрачные PNG в свою папку `avatar_layers/`. + +[Полный формат и пример ресурса](en.md). + +## Привязки пресетов + +- `canvas`: объект `Width`, `Height`, по умолчанию 512×1024 логических пикселя. +- `layers/`: путь к PNG с расширением, либо объект настройки слоя. +- `null` вместо слоя удаляет его из изображения. + +Объект слоя содержит `Image`, `Order`, `X`, `Y`, `Width`, `Height`, `Visible`, +`Opacity`, `FlipH`, `FlipV`. Нулевые размеры означают размер холста. Для вырезанных +фрагментов задавай прямоугольник явно; для слоёв одинакового холста достаточно пути. +Порядок отрисовки — по `Order`, при равенстве по ID слоя. Более поздний слой выше. +Неизвестные свойства, отсутствующие PNG и неверные размеры отклоняются. + +```csharp +IAvatar avatar = AvatarCatalog.Create("my_content/marao"); +IAvatarRenderer renderer = AvatarRenderers.Create(avatar); +host.AddChild(renderer.View); +avatar.ApplyPresets(new Dictionary { ["eyes_state"] = "closed" }); +renderer.Apply(avatar.Resolve()); +// До уничтожения host или выгрузки мода: +renderer.Dispose(); +``` + +Пресеты переключают картинки дискретно, даже если передано время перехода. +Положение, прозрачность и отражение также задаются целевым состоянием. +Плавные деформации, маски, анимации моргания и интеграция с диалогами пока не реализованы. + +Рендерер переиспользует неизменившиеся PNG и освобождает ненужные текстуры после +удаления старых узлов. Ошибочное визуальное состояние не заменяет предыдущее. +Логическое состояние аватара хранится отдельно. Владелец явно применяет изменения +и освобождает рендерер. Все визуальные операции выполняются на главном потоке Godot. + +Тестовый набор `--itest=avatar_layers_contracts` содержит синтетические PNG и проверяет +настоящую отрисовку по цветам пикселей, переключение пресетов и освобождение ресурсов. +Для пиксельных проверок нужен оконный рендерер. В обычной игре тестовые ресурсы не загружаются. diff --git a/locale/en.properties b/locale/en.properties new file mode 100644 index 0000000..888add3 --- /dev/null +++ b/locale/en.properties @@ -0,0 +1,2 @@ +mod_avatar_layers_name = Layered Avatars +mod_avatar_layers_desc = Display preset-controlled avatars from layered PNG images. diff --git a/locale/ru.properties b/locale/ru.properties new file mode 100644 index 0000000..9e17e76 --- /dev/null +++ b/locale/ru.properties @@ -0,0 +1,2 @@ +mod_avatar_layers_name = Аватары из слоёв +mod_avatar_layers_desc = Отображение аватаров из PNG-слоёв с управлением через пресеты. diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..0a0d79b --- /dev/null +++ b/manifest.json @@ -0,0 +1,25 @@ +{ + "id": "avatar_layers", + "name": "Layered Avatars", + "version": "1.0.0", + "author": "ct", + "description": "Layered PNG avatar renderer: preset-controlled images, ordering, placement, visibility and opacity.", + "category": "engine, visual", + "sources": [ + "src/**/*.cs" + ], + "test_sources": [ + "tests/*.cs" + ], + "depends": [ + "avatar_base>=1" + ], + "test_suites": [ + { + "id": "contracts", + "activate": [ + "--itest=avatar_layers_contracts" + ] + } + ] +} diff --git a/mod.jpg b/mod.jpg new file mode 100644 index 0000000..0adea7c Binary files /dev/null and b/mod.jpg differ diff --git a/scenes/avatar_layers/view.tscn b/scenes/avatar_layers/view.tscn new file mode 100644 index 0000000..9b1d6f6 --- /dev/null +++ b/scenes/avatar_layers/view.tscn @@ -0,0 +1,7 @@ +[gd_scene format=3] + +[node name="LayeredAvatar" type="Control"] +layout_mode = 0 +offset_right = 512.0 +offset_bottom = 1024.0 +mouse_filter = 2 diff --git a/src/AvatarCanvasDefinition.cs b/src/AvatarCanvasDefinition.cs new file mode 100644 index 0000000..f5e7e08 --- /dev/null +++ b/src/AvatarCanvasDefinition.cs @@ -0,0 +1,11 @@ +namespace Cthangover.Mods.AvatarLayers +{ + /// Logical canvas size; the host can scale the root to fit its portrait slot. + public sealed class AvatarCanvasDefinition + { + /// Positive finite logical width, defaults to 512 pixels. + public float Width { get; set; } = 512; + /// Positive finite logical height, defaults to 1024 pixels. + public float Height { get; set; } = 1024; + } +} diff --git a/src/AvatarLayerDefinition.cs b/src/AvatarLayerDefinition.cs new file mode 100644 index 0000000..ca49110 --- /dev/null +++ b/src/AvatarLayerDefinition.cs @@ -0,0 +1,27 @@ +namespace Cthangover.Mods.AvatarLayers +{ + /// One composited image in logical canvas pixels. Omitted size fills the canvas. + public sealed class AvatarLayerDefinition + { + /// Exact PNG path relative to a content mod's avatar_layers directory, including extension. + public string Image { get; set; } + /// Ascending draw order; equal orders use ordinal binding IDs. + public int Order { get; set; } + /// Horizontal offset in logical canvas pixels. + public float X { get; set; } + /// Vertical offset in logical canvas pixels. + public float Y { get; set; } + /// Image rectangle width; zero uses the canvas width. + public float Width { get; set; } + /// Image rectangle height; zero uses the canvas height. + public float Height { get; set; } + /// False removes the visual layer without loading its image. + public bool Visible { get; set; } = true; + /// Layer alpha in [0,1]. + public float Opacity { get; set; } = 1; + /// Mirror the image inside its rectangle horizontally. + public bool FlipH { get; set; } + /// Mirror the image inside its rectangle vertically. + public bool FlipV { get; set; } + } +} diff --git a/src/AvatarLayerImages.cs b/src/AvatarLayerImages.cs new file mode 100644 index 0000000..7e1f5e3 --- /dev/null +++ b/src/AvatarLayerImages.cs @@ -0,0 +1,26 @@ +using System; +using Cthangover.Core.Mods; +using Cthangover.Core.Mods.Resolvers; +using Godot; + +namespace Cthangover.Mods.AvatarLayers +{ + /// Loads exact PNG paths through mod providers, so ZIP assets and overrides work alike. + internal static class AvatarLayerImages + { + // The renderer owns the returned texture and must dispose it after detaching all node references. + internal static Texture2D Load(string path) + { + if (!path.EndsWith(".png", StringComparison.Ordinal)) + throw new ArgumentException($"Avatar layer image '{path}' must be an exact .png path."); + if (!Files.Collect("avatar_layers").TryGetValue(path, out var file)) + throw new ArgumentException($"Avatar layer image '{path}' was not found in avatar_layers."); + var bytes = ModManager.Instance.ReadFileBinary(file.ModId, file.FullPath); + if (bytes == null) throw new ArgumentException($"Cannot read avatar layer image '{path}'."); + using var image = new Image(); + if (image.LoadPngFromBuffer(bytes) != Error.Ok) + throw new ArgumentException($"Avatar layer image '{path}' is not a valid PNG."); + return ImageTexture.CreateFromImage(image); + } + } +} diff --git a/src/LayeredAvatarRenderer.cs b/src/LayeredAvatarRenderer.cs new file mode 100644 index 0000000..9c3d169 --- /dev/null +++ b/src/LayeredAvatarRenderer.cs @@ -0,0 +1,170 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; +using Cthangover.Core.Scenes; +using Cthangover.Core.Utils; +using Cthangover.Mods.Avatars; +using Godot; + +namespace Cthangover.Mods.AvatarLayers +{ + /// + /// Renders complete preset targets as ordered TextureRects. Assets switch discretely, including + /// for positive transition times. Each instance owns its root and texture cache; unchanged images + /// are reused, and obsolete images are released only after their nodes have been detached. + /// + public sealed class LayeredAvatarRenderer : IAvatarRenderer + { + private static readonly JsonSerializerOptions Options = new() { UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow }; + private readonly Control root; + private Dictionary textures = new(StringComparer.Ordinal); + private bool disposed; + + /// Creates the visual root from the mod prefab. Requires the Godot main thread. + public LayeredAvatarRenderer() + { + RequireRuntime(); + root = new ModScenePrefab("scenes/avatar_layers/view.tscn").Instantiate() + ?? throw new InvalidOperationException("Layered avatar view prefab is unavailable."); + } + + /// + public Control View => root; + + /// + /// Validates and prepares an entire target before replacing visible children. Malformed + /// targets or missing images leave the last valid display intact. Only layers/* and canvas + /// bindings are accepted, making content typos actionable instead of silently ignored. + /// + public void Apply(AvatarFrame target, double transitionSeconds = 0) + { + RequireRuntime(); + if (disposed) throw new ObjectDisposedException(nameof(LayeredAvatarRenderer)); + if (target == null) throw new ArgumentNullException(nameof(target)); + if (target.Renderer != "layers") throw new ArgumentException("Layered renderer requires Renderer=layers."); + if (!double.IsFinite(transitionSeconds) || transitionSeconds < 0) + throw new ArgumentOutOfRangeException(nameof(transitionSeconds)); + var canvas = new AvatarCanvasDefinition(); + var layers = new Dictionary(StringComparer.Ordinal); + foreach (var binding in target.Values) + { + if (binding.Key == "canvas") + canvas = binding.Value.Deserialize(Options) + ?? throw new ArgumentException("Avatar canvas cannot be null."); + else if (binding.Key.StartsWith("layers/", StringComparison.Ordinal) && binding.Key.Length > 7) + { + if (binding.Value.ValueKind == JsonValueKind.Null) continue; + var layer = binding.Value.ValueKind == JsonValueKind.String + ? new AvatarLayerDefinition { Image = binding.Value.GetString() } + : binding.Value.Deserialize(Options); + if (layer == null) throw new ArgumentException($"Invalid avatar layer '{binding.Key}'."); + ValidateLayer(binding.Key, layer); + if (layer.Visible) layers.Add(binding.Key, layer); + } + else throw new ArgumentException($"Unknown layered avatar binding '{binding.Key}'."); + } + if (!float.IsFinite(canvas.Width) || !float.IsFinite(canvas.Height) || canvas.Width <= 0 || canvas.Height <= 0) + throw new ArgumentException("Avatar canvas dimensions must be positive and finite."); + + var pending = new List(); + var nextTextures = new Dictionary(StringComparer.Ordinal); + var committed = false; + try + { + foreach (var entry in layers.OrderBy(Order).ThenBy(Name, StringComparer.Ordinal)) + { + var layer = entry.Value; + if (!nextTextures.TryGetValue(layer.Image, out var texture)) + { + if (!textures.TryGetValue(layer.Image, out texture)) texture = AvatarLayerImages.Load(layer.Image); + nextTextures.Add(layer.Image, texture); + } + var node = new TextureRect(); + pending.Add(node); + node.Name = "Layer" + (pending.Count - 1); + node.MouseFilter = Control.MouseFilterEnum.Ignore; + node.ExpandMode = TextureRect.ExpandModeEnum.IgnoreSize; + node.StretchMode = TextureRect.StretchModeEnum.Scale; + node.Texture = texture; + node.Position = new Vector2(layer.X, layer.Y); + node.Size = new Vector2(layer.Width == 0 ? canvas.Width : layer.Width, + layer.Height == 0 ? canvas.Height : layer.Height); + node.FlipH = layer.FlipH; + node.FlipV = layer.FlipV; + node.Modulate = new Color(1, 1, 1, layer.Opacity); + node.SetMeta("avatar_layer", entry.Key.Substring(7)); + } + ClearChildren(); + var canvasSize = new Vector2(canvas.Width, canvas.Height); + root.CustomMinimumSize = canvasSize; + root.Size = canvasSize; + foreach (var node in pending) root.AddChild(node); + foreach (var old in textures) + if (!nextTextures.ContainsKey(old.Key)) old.Value.Dispose(); + textures = nextTextures; + committed = true; + } + finally + { + if (!committed) + { + foreach (var node in pending) + { + node.GetParent()?.RemoveChild(node); + node.Texture = null; + node.Free(); + } + foreach (var texture in nextTextures) + if (!textures.ContainsKey(texture.Key)) texture.Value.Dispose(); + } + } + } + + /// Detaches and frees the owned visual tree before releasing owned textures. Idempotent. + public void Dispose() + { + RequireRuntime(); + if (disposed) return; + disposed = true; + if (GodotObject.IsInstanceValid(root)) + { + ClearChildren(); + root.GetParent()?.RemoveChild(root); + root.Free(); + } + foreach (var texture in textures.Values) texture.Dispose(); + textures.Clear(); + } + + private void ClearChildren() + { + foreach (var child in root.GetChildren()) + { + root.RemoveChild(child); + if (child is TextureRect image) image.Texture = null; + child.Free(); + } + } + + private static void RequireRuntime() + { + if (!GameEnvironment.IsRuntime || !GameEnvironment.IsMainThread) + throw new InvalidOperationException("Avatar visuals require the Godot runtime main thread."); + } + + private static void ValidateLayer(string id, AvatarLayerDefinition layer) + { + if (layer.Visible && string.IsNullOrWhiteSpace(layer.Image)) + throw new ArgumentException($"Avatar layer '{id}' requires Image."); + if (!float.IsFinite(layer.X) || !float.IsFinite(layer.Y) || + !float.IsFinite(layer.Width) || !float.IsFinite(layer.Height) || layer.Width < 0 || layer.Height < 0 || + !float.IsFinite(layer.Opacity) || layer.Opacity < 0 || layer.Opacity > 1) + throw new ArgumentException($"Avatar layer '{id}' has invalid bounds or opacity."); + } + + private static int Order(KeyValuePair layer) => layer.Value.Order; + private static string Name(KeyValuePair layer) => layer.Key; + } +} diff --git a/src/LayeredAvatarRendererFactory.cs b/src/LayeredAvatarRendererFactory.cs new file mode 100644 index 0000000..5649454 --- /dev/null +++ b/src/LayeredAvatarRendererFactory.cs @@ -0,0 +1,13 @@ +using Cthangover.Mods.Avatars; + +namespace Cthangover.Mods.AvatarLayers +{ + /// Discovered by avatar_base through the normal mod assembly consumer lifecycle. + public sealed class LayeredAvatarRendererFactory : IAvatarRendererFactory + { + /// + public string Id => "layers"; + /// + public IAvatarRenderer Create() => new LayeredAvatarRenderer(); + } +} diff --git a/test_suites/contracts/avatar_layers/test/body.png b/test_suites/contracts/avatar_layers/test/body.png new file mode 100644 index 0000000..0ae370e Binary files /dev/null and b/test_suites/contracts/avatar_layers/test/body.png differ diff --git a/test_suites/contracts/avatar_layers/test/closed.png b/test_suites/contracts/avatar_layers/test/closed.png new file mode 100644 index 0000000..2b4db5d Binary files /dev/null and b/test_suites/contracts/avatar_layers/test/closed.png differ diff --git a/test_suites/contracts/avatar_layers/test/opened.png b/test_suites/contracts/avatar_layers/test/opened.png new file mode 100644 index 0000000..c8ab6b3 Binary files /dev/null and b/test_suites/contracts/avatar_layers/test/opened.png differ diff --git a/test_suites/contracts/avatars/sample.json b/test_suites/contracts/avatars/sample.json new file mode 100644 index 0000000..555fe30 --- /dev/null +++ b/test_suites/contracts/avatars/sample.json @@ -0,0 +1,62 @@ +{ + "Kind": "avatar", + "Items": [ + { + "Id": "avatar_layers_contracts/sample", + "Renderer": "layers", + "BaseValues": { + "canvas": { + "Width": 4, + "Height": 4 + }, + "layers/body": { + "Image": "test/body.png", + "Order": 0 + } + }, + "Groups": { + "eyes_state": { + "Default": "opened", + "Presets": { + "opened": { + "Values": { + "layers/eyes": { + "Image": "test/opened.png", + "Order": 10, + "X": 1, + "Y": 1, + "Width": 2, + "Height": 2 + } + } + }, + "closed": { + "Values": { + "layers/eyes": { + "Image": "test/closed.png", + "Order": 10, + "X": 1, + "Y": 1, + "Width": 2, + "Height": 2 + } + } + }, + "absent": { + "Values": { + "layers/eyes": null + } + }, + "hidden": { + "Values": { + "layers/eyes": { + "Visible": false + } + } + } + } + } + } + } + ] +} diff --git a/test_suites/contracts/manifest.json b/test_suites/contracts/manifest.json new file mode 100644 index 0000000..ede361c --- /dev/null +++ b/test_suites/contracts/manifest.json @@ -0,0 +1,12 @@ +{ + "id": "avatar_layers__contracts", + "name": "Layered avatar rendering contracts", + "version": "1.0.0", + "author": "ct", + "depends": [ + "avatar_layers>=1" + ], + "test_sources": [ + "tests/*.cs" + ] +} diff --git a/test_suites/contracts/tests/LayeredAvatarRendererTests.cs b/test_suites/contracts/tests/LayeredAvatarRendererTests.cs new file mode 100644 index 0000000..207e9be --- /dev/null +++ b/test_suites/contracts/tests/LayeredAvatarRendererTests.cs @@ -0,0 +1,141 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading.Tasks; +using Cthangover.Core.Mods.Testing; +using Cthangover.Mods.Avatars; +using Godot; + +namespace Cthangover.Mods.AvatarLayers.Contracts +{ + /// Real mod image loading and node composition, including a rendered pixel comparison. + public sealed class LayeredAvatarRendererTests + { + [ModTest("avatar_layers/discovery_presets_order_and_removal")] + public static void Composition(ModTestContext ctx) + { + var avatar = AvatarCatalog.Create("avatar_layers_contracts/sample"); + using var renderer = AvatarRenderers.Create(avatar); + var view = renderer.View; + ModAssert.CloseTo(4, view.Size.X); ModAssert.CloseTo(4, view.Size.Y); + ModAssert.Equal(2, view.GetChildCount()); + var body = view.GetChild(0); var eyes = view.GetChild(1); + ModAssert.Equal("body", body.GetMeta("avatar_layer").AsString()); + ModAssert.Equal("eyes", eyes.GetMeta("avatar_layer").AsString()); + ModAssert.CloseTo(1, eyes.Position.X); ModAssert.CloseTo(1, eyes.Position.Y); + ModAssert.CloseTo(2, eyes.Size.X); + var bodyTexture = body.Texture; + avatar.ApplyPresets(new Dictionary { ["eyes_state"] = "closed" }); + renderer.Apply(avatar.Resolve(), 0.15); + ModAssert.True(ReferenceEquals(bodyTexture, view.GetChild(0).Texture), "unchanged images reused"); + using (var image = view.GetChild(1).Texture.GetImage()) + ModAssert.CloseTo(1, image.GetPixel(0, 0).B, what: "closed preset selects blue PNG"); + avatar.ApplyPresets(new Dictionary { ["eyes_state"] = "absent" }); + renderer.Apply(avatar.Resolve()); + ModAssert.Equal(1, view.GetChildCount(), "null binding removes prior layer"); + avatar.ApplyPresets(new Dictionary { ["eyes_state"] = "hidden" }); + renderer.Apply(avatar.Resolve()); + ModAssert.Equal(1, view.GetChildCount(), "invisible layer needs no image"); + } + + [ModTest("avatar_layers/rejects_invalid_targets_without_changing_view")] + public static void InvalidTargets(ModTestContext ctx) + { + using var renderer = AvatarRenderers.Create(AvatarCatalog.Create("avatar_layers_contracts/sample")); + var texture = renderer.View.GetChild(0).Texture; + Reject(renderer, "{\"canvas\":{\"Width\":0,\"Height\":4}}"); + Reject(renderer, "{\"layers/eyes\":{\"Image\":\"test/opened.png\",\"Opacity\":2}}"); + Reject(renderer, "{\"layers/eyes\":{\"Image\":\"test/opened.png\",\"Width\":-1}}"); + Reject(renderer, "{\"layers/eyes\":{\"Image\":\"test/opened.png\",\"Visble\":false}}"); + Reject(renderer, "{\"layers/eyes\":{}}"); + Reject(renderer, "{\"layer/eyes\":\"test/opened.png\"}"); + Reject(renderer, "{\"layers/eyes\":\"test/missing.png\"}"); + Reject(renderer, "{\"layers/body\":\"test/body.png\",\"layers/eyes\":\"test/missing.png\"}"); + var invalidTime = false; + try { renderer.Apply(AvatarCatalog.Create("avatar_layers_contracts/sample").Resolve(), double.NaN); } + catch (ArgumentOutOfRangeException) { invalidTime = true; } + ModAssert.True(invalidTime); + ModAssert.Equal(2, renderer.View.GetChildCount()); + ModAssert.True(ReferenceEquals(texture, renderer.View.GetChild(0).Texture)); + } + + [ModTest("avatar_layers/canvas_can_shrink_after_a_larger_target")] + public static void Resize(ModTestContext ctx) + { + using var renderer = AvatarRenderers.Create(AvatarCatalog.Create("avatar_layers_contracts/sample")); + var definition = new AvatarDefinition { ID = "test/resize", Renderer = "layers", + BaseValues = JsonSerializer.Deserialize>( + "{\"canvas\":{\"Width\":2,\"Height\":3},\"layers/body\":\"test/body.png\"}") }; + renderer.Apply(new AvatarInstance(definition).Resolve()); + ModAssert.CloseTo(2, renderer.View.Size.X); + ModAssert.CloseTo(3, renderer.View.Size.Y); + ModAssert.CloseTo(2, renderer.View.CustomMinimumSize.X); + ModAssert.CloseTo(2, renderer.View.GetChild(0).Size.X); + ModAssert.CloseTo(3, renderer.View.GetChild(0).Size.Y); + } + + [ModTest("avatar_layers/dispose_detaches_root_and_releases_owned_resources")] + public static void Ownership(ModTestContext ctx) + { + var host = new Control(); + var renderer = AvatarRenderers.Create(AvatarCatalog.Create("avatar_layers_contracts/sample")); + var view = renderer.View; + try + { + host.AddChild(view); + var texture = view.GetChild(0).Texture; + renderer.Dispose(); renderer.Dispose(); + ModAssert.Equal(0, host.GetChildCount()); + ModAssert.False(GodotObject.IsInstanceValid(view)); + ModAssert.False(GodotObject.IsInstanceValid(texture)); + var rejected = false; + try { renderer.Apply(AvatarCatalog.Create("avatar_layers_contracts/sample").Resolve()); } + catch (ObjectDisposedException) { rejected = true; } + ModAssert.True(rejected); + } + finally { renderer.Dispose(); host.Free(); } + } + + [ModTest("avatar_layers/rendered_pixels_follow_preset", TimeoutSeconds = 15)] + public static async Task Pixels(ModTestContext ctx) + { + var tree = (SceneTree)Engine.GetMainLoop(); + var viewport = new SubViewport { Size = new Vector2I(4, 4), TransparentBg = true, + Disable3D = true, RenderTargetUpdateMode = SubViewport.UpdateMode.Always }; + var avatar = AvatarCatalog.Create("avatar_layers_contracts/sample"); + using var renderer = AvatarRenderers.Create(avatar); + try + { + tree.Root.AddChild(viewport); + viewport.AddChild(renderer.View); + await tree.ToSignal(RenderingServer.Singleton, RenderingServer.SignalName.FramePostDraw); + using (var image = viewport.GetTexture().GetImage()) + { + ModAssert.CloseTo(1, image.GetPixel(0, 0).R, epsilon: 0.01, what: "red base remains visible"); + ModAssert.CloseTo(1, image.GetPixel(1, 1).G, epsilon: 0.01, what: "green eyes draw above base"); + } + avatar.ApplyPresets(new Dictionary { ["eyes_state"] = "closed" }); + renderer.Apply(avatar.Resolve()); + await tree.ToSignal(RenderingServer.Singleton, RenderingServer.SignalName.FramePostDraw); + using var changed = viewport.GetTexture().GetImage(); + ModAssert.CloseTo(1, changed.GetPixel(1, 1).B, epsilon: 0.01, what: "blue eyes replace green eyes"); + } + finally + { + renderer.Dispose(); + viewport.GetParent()?.RemoveChild(viewport); viewport.Free(); + } + } + + private static void Reject(IAvatarRenderer renderer, string values) + { + var definition = new AvatarDefinition { ID = "test/bad", Renderer = "layers", + BaseValues = JsonSerializer.Deserialize>(values) }; + var rejected = false; + try { renderer.Apply(new AvatarInstance(definition).Resolve()); } + catch (ArgumentException) { rejected = true; } + catch (JsonException) { rejected = true; } + ModAssert.True(rejected, "invalid layered target must fail"); + } + } +} diff --git a/tests/AvatarLayerDefinitionTests.cs b/tests/AvatarLayerDefinitionTests.cs new file mode 100644 index 0000000..bd9f338 --- /dev/null +++ b/tests/AvatarLayerDefinitionTests.cs @@ -0,0 +1,26 @@ +using System.Text.Json; +using Cthangover.Core.Mods.Testing; + +namespace Cthangover.Mods.AvatarLayers.Tests +{ + /// Layer and canvas authoring DTOs preserve every visual property through JSON. + public sealed class AvatarLayerDefinitionTests + { + [ModTest("avatar_layers/layer_and_canvas_json_roundtrip")] + public static void RoundTrip(ModTestContext ctx) + { + var layer = new AvatarLayerDefinition { Image = "sample/eyes.png", Order = -2, X = 3, Y = -4, + Width = 12, Height = 16, Visible = false, Opacity = 0.25f, FlipH = true, FlipV = true }; + var copy = JsonSerializer.Deserialize(JsonSerializer.Serialize(layer)); + ModAssert.Equal(layer.Image, copy.Image); ModAssert.Equal(layer.Order, copy.Order); + ModAssert.CloseTo(layer.X, copy.X); ModAssert.CloseTo(layer.Y, copy.Y); + ModAssert.CloseTo(layer.Width, copy.Width); ModAssert.CloseTo(layer.Height, copy.Height); + ModAssert.False(copy.Visible); ModAssert.CloseTo(layer.Opacity, copy.Opacity); + ModAssert.True(copy.FlipH); ModAssert.True(copy.FlipV); + var canvas = JsonSerializer.Deserialize(JsonSerializer.Serialize(new AvatarCanvasDefinition { Width = 640, Height = 960 })); + ModAssert.CloseTo(640, canvas.Width); ModAssert.CloseTo(960, canvas.Height); + ModAssert.True(new AvatarLayerDefinition().Visible); + ModAssert.CloseTo(1, new AvatarLayerDefinition().Opacity); + } + } +}