Update avatar_layers implementation, assets and documentation

This commit is contained in:
cthangover committed 2026-10-31 15:28:48 +03:00
commit 1753766a5d
21 files changed
+757

No files matched your search

+30
View File
@@ -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
+38
View File
@@ -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
+123
View File
@@ -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/<id>` as string | Exact PNG path, full-canvas rectangle, Order 0 |
| `layers/<id>` as object | AvatarLayerDefinition settings below |
| `layers/<id>` 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<string, string> { ["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.
+42
View File
@@ -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/<id>`: путь к 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<string, string> { ["eyes_state"] = "closed" });
renderer.Apply(avatar.Resolve());
// До уничтожения host или выгрузки мода:
renderer.Dispose();
```
Пресеты переключают картинки дискретно, даже если передано время перехода.
Положение, прозрачность и отражение также задаются целевым состоянием.
Плавные деформации, маски, анимации моргания и интеграция с диалогами пока не реализованы.
Рендерер переиспользует неизменившиеся PNG и освобождает ненужные текстуры после
удаления старых узлов. Ошибочное визуальное состояние не заменяет предыдущее.
Логическое состояние аватара хранится отдельно. Владелец явно применяет изменения
и освобождает рендерер. Все визуальные операции выполняются на главном потоке Godot.
Тестовый набор `--itest=avatar_layers_contracts` содержит синтетические PNG и проверяет
настоящую отрисовку по цветам пикселей, переключение пресетов и освобождение ресурсов.
Для пиксельных проверок нужен оконный рендерер. В обычной игре тестовые ресурсы не загружаются.
+2
View File
@@ -0,0 +1,2 @@
mod_avatar_layers_name = Layered Avatars
mod_avatar_layers_desc = Display preset-controlled avatars from layered PNG images.
+2
View File
@@ -0,0 +1,2 @@
mod_avatar_layers_name = Аватары из слоёв
mod_avatar_layers_desc = Отображение аватаров из PNG-слоёв с управлением через пресеты.
+25
View File
@@ -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"
]
}
]
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

+7
View File
@@ -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
+11
View File
@@ -0,0 +1,11 @@
namespace Cthangover.Mods.AvatarLayers
{
/// <summary>Logical canvas size; the host can scale the root to fit its portrait slot.</summary>
public sealed class AvatarCanvasDefinition
{
/// <summary>Positive finite logical width, defaults to 512 pixels.</summary>
public float Width { get; set; } = 512;
/// <summary>Positive finite logical height, defaults to 1024 pixels.</summary>
public float Height { get; set; } = 1024;
}
}
+27
View File
@@ -0,0 +1,27 @@
namespace Cthangover.Mods.AvatarLayers
{
/// <summary>One composited image in logical canvas pixels. Omitted size fills the canvas.</summary>
public sealed class AvatarLayerDefinition
{
/// <summary>Exact PNG path relative to a content mod's avatar_layers directory, including extension.</summary>
public string Image { get; set; }
/// <summary>Ascending draw order; equal orders use ordinal binding IDs.</summary>
public int Order { get; set; }
/// <summary>Horizontal offset in logical canvas pixels.</summary>
public float X { get; set; }
/// <summary>Vertical offset in logical canvas pixels.</summary>
public float Y { get; set; }
/// <summary>Image rectangle width; zero uses the canvas width.</summary>
public float Width { get; set; }
/// <summary>Image rectangle height; zero uses the canvas height.</summary>
public float Height { get; set; }
/// <summary>False removes the visual layer without loading its image.</summary>
public bool Visible { get; set; } = true;
/// <summary>Layer alpha in [0,1].</summary>
public float Opacity { get; set; } = 1;
/// <summary>Mirror the image inside its rectangle horizontally.</summary>
public bool FlipH { get; set; }
/// <summary>Mirror the image inside its rectangle vertically.</summary>
public bool FlipV { get; set; }
}
}
+26
View File
@@ -0,0 +1,26 @@
using System;
using Cthangover.Core.Mods;
using Cthangover.Core.Mods.Resolvers;
using Godot;
namespace Cthangover.Mods.AvatarLayers
{
/// <summary>Loads exact PNG paths through mod providers, so ZIP assets and overrides work alike.</summary>
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);
}
}
}
+170
View File
@@ -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
{
/// <summary>
/// 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.
/// </summary>
public sealed class LayeredAvatarRenderer : IAvatarRenderer
{
private static readonly JsonSerializerOptions Options = new() { UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow };
private readonly Control root;
private Dictionary<string, Texture2D> textures = new(StringComparer.Ordinal);
private bool disposed;
/// <summary>Creates the visual root from the mod prefab. Requires the Godot main thread.</summary>
public LayeredAvatarRenderer()
{
RequireRuntime();
root = new ModScenePrefab("scenes/avatar_layers/view.tscn").Instantiate<Control>()
?? throw new InvalidOperationException("Layered avatar view prefab is unavailable.");
}
/// <inheritdoc/>
public Control View => root;
/// <summary>
/// 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.
/// </summary>
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<string, AvatarLayerDefinition>(StringComparer.Ordinal);
foreach (var binding in target.Values)
{
if (binding.Key == "canvas")
canvas = binding.Value.Deserialize<AvatarCanvasDefinition>(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<AvatarLayerDefinition>(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<TextureRect>();
var nextTextures = new Dictionary<string, Texture2D>(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();
}
}
}
/// <summary>Detaches and frees the owned visual tree before releasing owned textures. Idempotent.</summary>
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<string, AvatarLayerDefinition> layer) => layer.Value.Order;
private static string Name(KeyValuePair<string, AvatarLayerDefinition> layer) => layer.Key;
}
}
+13
View File
@@ -0,0 +1,13 @@
using Cthangover.Mods.Avatars;
namespace Cthangover.Mods.AvatarLayers
{
/// <summary>Discovered by avatar_base through the normal mod assembly consumer lifecycle.</summary>
public sealed class LayeredAvatarRendererFactory : IAvatarRendererFactory
{
/// <inheritdoc/>
public string Id => "layers";
/// <inheritdoc/>
public IAvatarRenderer Create() => new LayeredAvatarRenderer();
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 77 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 B

+62
View File
@@ -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
}
}
}
}
}
}
}
]
}
+12
View File
@@ -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"
]
}
@@ -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
{
/// <summary>Real mod image loading and node composition, including a rendered pixel comparison.</summary>
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<TextureRect>(0); var eyes = view.GetChild<TextureRect>(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<string, string> { ["eyes_state"] = "closed" });
renderer.Apply(avatar.Resolve(), 0.15);
ModAssert.True(ReferenceEquals(bodyTexture, view.GetChild<TextureRect>(0).Texture), "unchanged images reused");
using (var image = view.GetChild<TextureRect>(1).Texture.GetImage())
ModAssert.CloseTo(1, image.GetPixel(0, 0).B, what: "closed preset selects blue PNG");
avatar.ApplyPresets(new Dictionary<string, string> { ["eyes_state"] = "absent" });
renderer.Apply(avatar.Resolve());
ModAssert.Equal(1, view.GetChildCount(), "null binding removes prior layer");
avatar.ApplyPresets(new Dictionary<string, string> { ["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<TextureRect>(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<TextureRect>(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<Dictionary<string, JsonElement>>(
"{\"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<TextureRect>(0).Size.X);
ModAssert.CloseTo(3, renderer.View.GetChild<TextureRect>(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<TextureRect>(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<string, string> { ["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<Dictionary<string, JsonElement>>(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");
}
}
}
+26
View File
@@ -0,0 +1,26 @@
using System.Text.Json;
using Cthangover.Core.Mods.Testing;
namespace Cthangover.Mods.AvatarLayers.Tests
{
/// <summary>Layer and canvas authoring DTOs preserve every visual property through JSON.</summary>
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<AvatarLayerDefinition>(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<AvatarCanvasDefinition>(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);
}
}
}