Update avatar_layers implementation, assets and documentation
This commit is contained in:
commit
1753766a5d
21 files changed
+757
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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 и проверяет
|
||||||
|
настоящую отрисовку по цветам пикселей, переключение пресетов и освобождение ресурсов.
|
||||||
|
Для пиксельных проверок нужен оконный рендерер. В обычной игре тестовые ресурсы не загружаются.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
mod_avatar_layers_name = Layered Avatars
|
||||||
|
mod_avatar_layers_desc = Display preset-controlled avatars from layered PNG images.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
mod_avatar_layers_name = Аватары из слоёв
|
||||||
|
mod_avatar_layers_desc = Отображение аватаров из PNG-слоёв с управлением через пресеты.
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 |
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user