Update avatar_base implementation, assets and documentation
This commit is contained in:
commit
1ac123dd1b
24 files changed
+862
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
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
# Avatar Engine foundation
|
||||
|
||||
`avatar_base` owns renderer-independent avatar definitions and preset state. It has no
|
||||
runtime dependency on dialogs, party characters, UI engine mods or Live2D. A visual novel,
|
||||
a character preview or another UI can use the same logical instance.
|
||||
|
||||
This first version implements resource loading, validated preset selections, deterministic
|
||||
composition and serializable state. `IAvatarRenderer` defines the presentation boundary;
|
||||
The `avatar_layers` mod implements the `layers` renderer and is an optional dependency of content
|
||||
that uses it. Live2D adaptation, scenario actions, a visual editor and automatic save-system
|
||||
integration are not implemented yet. Existing `first=` / `second=` dialog portraits are unaffected.
|
||||
|
||||
## Resource format
|
||||
|
||||
Content mods declare `depends: ["avatar_base>=1"]` (or `avatar_layers>=1` for layered images) and supply normal entity files, for example
|
||||
`avatars/marao.json`. `Kind: avatar` is registered by `AvatarDefinition`. Definitions use the
|
||||
standard entity resolver: folder/ZIP providers, overrides, `Extends` and patches work through
|
||||
that pipeline; the catalog does not parse files or maintain a second resource cache.
|
||||
|
||||
```json
|
||||
{
|
||||
"Kind": "avatar",
|
||||
"Items": [{
|
||||
"Id": "my_game/marao",
|
||||
"Renderer": "layers",
|
||||
"BaseValues": {
|
||||
"layers/body": "marao/body.png",
|
||||
"layers/eyes": "marao/eyes/opened.png"
|
||||
},
|
||||
"Groups": {
|
||||
"emotion": {
|
||||
"Default": "neutral",
|
||||
"Priority": 0,
|
||||
"Presets": {
|
||||
"neutral": {},
|
||||
"angry": {"Values": {"layers/eyes": "marao/eyes/narrowed.png"}}
|
||||
}
|
||||
},
|
||||
"eyes_state": {
|
||||
"Default": "auto",
|
||||
"Priority": 100,
|
||||
"Presets": {
|
||||
"auto": {},
|
||||
"opened": {"Values": {"layers/eyes": "marao/eyes/opened.png"}},
|
||||
"closed": {"Values": {"layers/eyes": "marao/eyes/closed.png"}}
|
||||
}
|
||||
}
|
||||
}
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
Group and preset names are author-defined, case-sensitive and not a fixed engine vocabulary.
|
||||
Each group has one selected preset; `Default` must name an existing preset. Definitions with
|
||||
no groups are valid static avatars. `auto` is an ordinary empty preset here, not a reserved word:
|
||||
it contributes nothing, leaving the baseline and earlier groups in control.
|
||||
|
||||
`Values` is a dictionary of opaque JSON targets. The layers adapter reads PNG paths;
|
||||
a Live2D adapter can read numeric values such as `parameters/ParamEyeLOpen: 1`. The base does
|
||||
not invent a universal numeric range, interpret binding names or load assets. Binding value
|
||||
types, required bindings, compatibility of poses/layers and asset existence are the adapter's
|
||||
responsibility. Values can also be objects or arrays for an adapter's structured configuration.
|
||||
|
||||
## Applying presets
|
||||
|
||||
```csharp
|
||||
IAvatar avatar = AvatarCatalog.Create("my_game/marao");
|
||||
avatar.ApplyPresets(new Dictionary<string, string> {
|
||||
["emotion"] = "angry",
|
||||
["eyes_state"] = "opened"
|
||||
});
|
||||
AvatarFrame target = avatar.Resolve();
|
||||
using IAvatarRenderer renderer = AvatarRenderers.Create(avatar);
|
||||
// Attach renderer.View to the host. After later changes:
|
||||
renderer.Apply(target, transitionSeconds: 0.15);
|
||||
AvatarState saved = avatar.CaptureState();
|
||||
IAvatar restored = AvatarCatalog.Create(saved.AvatarId, saved);
|
||||
```
|
||||
|
||||
`ApplyPresets` is atomic: every supplied group/preset is checked before changing any selection.
|
||||
Unmentioned groups retain their choices. Null, misspellings and unknown presets throw
|
||||
`ArgumentException`; there is no silent fallback. `AvatarCatalog.Create` throws
|
||||
`KeyNotFoundException` for an unknown avatar. Select the group's default explicitly to reset it.
|
||||
|
||||
Every `Resolve` starts from `BaseValues` and applies all selected presets by ascending group
|
||||
`Priority`, then ordinal group name. Later contributions replace earlier values for the same
|
||||
binding. The order of scenario commands and JSON properties never determines precedence.
|
||||
An individual JSON binding is replaced whole, not deep merged; null is a literal target value.
|
||||
Thus explicit `eyes_state` at priority 100 overrides the eye contribution of `emotion` at 0.
|
||||
Selecting `auto` delegates control back to the emotion/baseline on the next composition.
|
||||
|
||||
The output is a complete immutable snapshot, not a delta. Leaving a preset removes its
|
||||
contributions; adapters must reset bindings absent from a later target rather than retaining
|
||||
stale values. Define adapter-required reset targets in `BaseValues`. There is no automatic
|
||||
cross-group compatibility solver in this foundation.
|
||||
|
||||
## Ownership and persistence
|
||||
|
||||
An `AvatarInstance` validates and snapshots its definition. It never changes cached entity DTOs.
|
||||
Two instances of the same definition have independent selections. Frames and captured states
|
||||
are detached; editing the returned save DTO cannot alter a live instance. Active instances keep
|
||||
their definition snapshot after resource changes; the owning host recreates them on reload.
|
||||
|
||||
`AvatarState` stores the avatar ID and selected preset names. JSON round-trip it in the owning
|
||||
subsystem's save data. Missing groups on restore use defaults; a mismatched avatar ID or unknown
|
||||
selection is rejected. Content migrations must explicitly handle removed/renamed presets.
|
||||
There is no global avatar roster or automatic registration with the game's save system.
|
||||
|
||||
`IAvatarRenderer` exposes a renderer-owned `Control` and accepts complete targets with an optional
|
||||
transition duration. Rendering mods implement it and decide how presets become images or model
|
||||
parameters. They must validate compatible targets and finite nonnegative durations. Zero snaps;
|
||||
a discrete adapter may also snap for positive duration. A new animated target interrupts the
|
||||
previous transition from the currently displayed state. The host attaches `View` to its UI and
|
||||
disposes the adapter before freeing that container. The adapter frees owned nodes/animations,
|
||||
never shared cache textures. All visual operations run on the Godot main thread.
|
||||
|
||||
The base owns no Godot objects or update loop. Pure logical instances do not need disposal.
|
||||
`AvatarRenderers` discovers public, parameterless `IAvatarRendererFactory` implementations via
|
||||
`ModTypeConsumer`, including normal unload notifications. Factories declare a unique case-sensitive
|
||||
renderer ID and create an owned adapter. Duplicate IDs are rejected. `AvatarRenderers.Create(avatar)`
|
||||
resolves the backend and applies the initial state, disposing the adapter if initialization fails.
|
||||
The host explicitly applies subsequent `avatar.Resolve()` targets; there is no hidden subscription.
|
||||
The host must dispose active adapters before their mod/scene unloads. Missing backends throw
|
||||
`KeyNotFoundException`, making a missing content dependency visible.
|
||||
|
||||
## Verification
|
||||
|
||||
Tests use the engine's runtime mod compiler, not a separate project referencing mod sources:
|
||||
|
||||
```text
|
||||
godot --headless --path . -- --frontend --with-mods=avatar_base --run-tests --mod=avatar_base
|
||||
godot --headless --path . -- --frontend --with-mods=avatar_base --itest=avatar_contracts --run-tests --mod=avatar_base__contracts
|
||||
```
|
||||
|
||||
In this workspace run these through the shared Windows job bridge. The opt-in `contracts` test
|
||||
suite supplies a sample `Kind: avatar` resource and checks the real catalog and independent
|
||||
instances. Production mods contain no sample character or fake renderer implementation.
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
# Система аватаров: основа
|
||||
|
||||
`avatar_base` реализует независимое состояние аватара: группы, именованные пресеты,
|
||||
композицию целевых значений и JSON-состояние для сохранений. Зависимости от диалогов,
|
||||
боевых персонажей и Live2D нет. Полный формат и контракт: [en.md](en.md).
|
||||
|
||||
## Управление
|
||||
|
||||
```csharp
|
||||
IAvatar avatar = AvatarCatalog.Create("my_game/marao");
|
||||
avatar.ApplyPresets(new Dictionary<string, string> {
|
||||
["emotion"] = "angry",
|
||||
["eyes_state"] = "opened"
|
||||
});
|
||||
AvatarFrame target = avatar.Resolve();
|
||||
AvatarState saved = avatar.CaptureState();
|
||||
```
|
||||
|
||||
Контентный мод зависит от `avatar_base>=1` и объявляет сущности `Kind: avatar`.
|
||||
У определения есть `Id`, `Renderer`, `BaseValues` и `Groups`. У группы — `Default`,
|
||||
`Priority`, `Presets`; у пресета — `Values`. Имена групп и пресетов произвольные,
|
||||
регистрозависимые. Значения пресетов — целевые привязки для конкретного рендера,
|
||||
а не универсальные числа от 0 до 1.
|
||||
|
||||
Каждая группа выбирает один пресет. Изменение группы сохраняет остальные группы.
|
||||
Все изменения проверяются до применения: опечатка не оставляет частично изменённое
|
||||
состояние. Неизвестные значения отклоняются, автоматических подмен нет.
|
||||
|
||||
Композиция начинается с `BaseValues`, затем применяет группы по возрастанию
|
||||
`Priority`, при равенстве — по имени (ordinal). Более поздняя группа перекрывает
|
||||
одноимённую привязку целиком. Порядок команд ничего не меняет. Например, эмоция
|
||||
имеет приоритет 0, явное состояние глаз — 100. Пустой пресет `auto` не вносит
|
||||
привязок и тем самым возвращает управление эмоции. Это обычное имя, не ключевое слово.
|
||||
|
||||
`Resolve()` возвращает полный неизменяемый снимок, без оставшихся значений прошлого
|
||||
пресета. `CaptureState()` — отдельный DTO с ID аватара и выбором пресетов. При
|
||||
восстановлении отсутствующие группы получают значения по умолчанию, неизвестные
|
||||
группы/пресеты и чужой ID отклоняются. Изменения ресурсов не меняют уже созданный
|
||||
экземпляр: владелец пересоздаёт его при перезагрузке.
|
||||
|
||||
## Граница реализации
|
||||
|
||||
`IAvatarRenderer` задаёт контракт визуального адаптера: корневой `Control`, применение
|
||||
полного целевого состояния, время перехода и освобождение принадлежащих ему ресурсов.
|
||||
Мод `avatar_layers` уже выбирает и отображает PNG-слои. Будущий адаптер Live2D будет
|
||||
выбирать параметры модели. `AvatarRenderers.Create(avatar)` находит фабрику по `Renderer`
|
||||
и применяет начальное состояние. После изменений владелец явно вызывает
|
||||
`renderer.Apply(avatar.Resolve())`. Фабрики автоматически регистрируются и удаляются
|
||||
вместе с модами через стандартный `ModTypeConsumer`.
|
||||
|
||||
В этой версии нет адаптера Live2D, сценарных команд, редактора,
|
||||
проверки совместимости картинок и автоматической интеграции с сохранениями.
|
||||
Существующие диалоговые аватарки работают как прежде. JSON-пример и правила владения
|
||||
ресурсами приведены в английской документации. Тесты проверяют независимость
|
||||
экземпляров, приоритеты, атомарность, сериализацию и загрузку настоящего ресурса.
|
||||
@@ -0,0 +1,2 @@
|
||||
mod_avatar_base_name = Avatar Engine
|
||||
mod_avatar_base_desc = Reusable avatars controlled by named presets, independent of rendering technology.
|
||||
@@ -0,0 +1,2 @@
|
||||
mod_avatar_base_name = Система аватаров
|
||||
mod_avatar_base_desc = Переиспользуемые аватары с именованными пресетами и независимым способом отображения.
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"id": "avatar_base",
|
||||
"name": "Avatar Engine",
|
||||
"version": "1.0.0",
|
||||
"author": "ct",
|
||||
"description": "Reusable avatar definitions and independent preset state, with renderer-neutral composition for layered images and animated models.",
|
||||
"category": "engine, library",
|
||||
"sources": [
|
||||
"src/**/*.cs"
|
||||
],
|
||||
"test_sources": [
|
||||
"tests/*.cs"
|
||||
],
|
||||
"depends": [],
|
||||
"test_suites": [
|
||||
{
|
||||
"id": "contracts",
|
||||
"activate": [
|
||||
"--itest=avatar_contracts"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Cthangover.Core.Factories;
|
||||
|
||||
namespace Cthangover.Mods.Avatars
|
||||
{
|
||||
/// <summary>
|
||||
/// Mod entity describing an avatar, not a party character. Presets contain opaque renderer
|
||||
/// bindings; the base never interprets a binding as a texture, a numeric parameter or a motion.
|
||||
/// Entity resolver inheritance and patches apply before an instance snapshots this definition.
|
||||
/// </summary>
|
||||
[EntityKind("avatar")]
|
||||
public sealed class AvatarDefinition : IIdentifiable
|
||||
{
|
||||
/// <summary>Entity identity used by content references and saved instance state.</summary>
|
||||
[JsonPropertyName("Id")]
|
||||
public string ID { get; set; }
|
||||
|
||||
/// <summary>Renderer identifier; naming it does not load or require that renderer.</summary>
|
||||
public string Renderer { get; set; }
|
||||
|
||||
/// <summary>Baseline bindings restored on every composition, before selected presets.</summary>
|
||||
public Dictionary<string, JsonElement> BaseValues { get; set; } = new();
|
||||
|
||||
/// <summary>Author-defined groups, such as eyes_state, emotion or pose; keys are case-sensitive.</summary>
|
||||
public Dictionary<string, AvatarGroupDefinition> Groups { get; set; } = new();
|
||||
|
||||
/// <summary>Rejects incomplete definitions before any instance or renderer can consume them.</summary>
|
||||
public void Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ID)) throw new ArgumentException("Avatar Id is required.");
|
||||
if (string.IsNullOrWhiteSpace(Renderer)) throw new ArgumentException($"Avatar '{ID}': Renderer is required.");
|
||||
ValidateValues(BaseValues, "BaseValues");
|
||||
if (Groups == null) throw new ArgumentException($"Avatar '{ID}': Groups cannot be null.");
|
||||
foreach (var entry in Groups)
|
||||
{
|
||||
var group = entry.Value;
|
||||
if (string.IsNullOrWhiteSpace(entry.Key) || group == null)
|
||||
throw new ArgumentException($"Avatar '{ID}': group names and definitions are required.");
|
||||
if (group.Presets == null || group.Presets.Count == 0 ||
|
||||
string.IsNullOrWhiteSpace(group.Default) || !group.Presets.ContainsKey(group.Default))
|
||||
throw new ArgumentException($"Avatar '{ID}', group '{entry.Key}': Default must name an existing preset.");
|
||||
foreach (var preset in group.Presets)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(preset.Key) || preset.Value == null)
|
||||
throw new ArgumentException($"Avatar '{ID}', group '{entry.Key}': preset names and definitions are required.");
|
||||
ValidateValues(preset.Value.Values, entry.Key + "/" + preset.Key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateValues(Dictionary<string, JsonElement> values, string path)
|
||||
{
|
||||
if (values == null) throw new ArgumentException($"Avatar '{ID}', {path}: values cannot be null.");
|
||||
foreach (var value in values)
|
||||
if (string.IsNullOrWhiteSpace(value.Key) || value.Value.ValueKind == JsonValueKind.Undefined)
|
||||
throw new ArgumentException($"Avatar '{ID}', {path}: binding names and JSON values are required.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Cthangover.Mods.Avatars
|
||||
{
|
||||
/// <summary>Exactly one preset is selected per group. Higher priority groups override lower ones.</summary>
|
||||
public sealed class AvatarGroupDefinition
|
||||
{
|
||||
/// <summary>Initial selection; also the selection to request when resetting this group.</summary>
|
||||
public string Default { get; set; }
|
||||
|
||||
/// <summary>Composition order, ascending; ties use ordinal group names, never command order.</summary>
|
||||
public int Priority { get; set; }
|
||||
|
||||
/// <summary>Named states available to scenario authors. An empty preset can delegate to earlier groups.</summary>
|
||||
public Dictionary<string, AvatarPresetDefinition> Presets { get; set; } = new();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Cthangover.Mods.Avatars
|
||||
{
|
||||
/// <summary>A named state contributes renderer-specific target bindings, not incremental changes.</summary>
|
||||
public sealed class AvatarPresetDefinition
|
||||
{
|
||||
/// <summary>
|
||||
/// Opaque binding values: a layers renderer may read image IDs, a Live2D renderer numbers.
|
||||
/// A key is replaced as a whole; objects are not recursively merged and null is a literal value.
|
||||
/// </summary>
|
||||
public Dictionary<string, JsonElement> Values { get; set; } = new();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Cthangover.Core.Mods.Consumers;
|
||||
|
||||
namespace Cthangover.Mods.Avatars
|
||||
{
|
||||
/// <summary>
|
||||
/// Mod-owned factory registry using normal assembly load/unload notifications. No dependency on
|
||||
/// a concrete backend; duplicate IDs are errors rather than load-order-dependent replacements.
|
||||
/// </summary>
|
||||
public sealed class AvatarRenderers : ModTypeConsumer<IAvatarRendererFactory>
|
||||
{
|
||||
private static readonly Dictionary<string, IAvatarRendererFactory> factories = new(StringComparer.Ordinal);
|
||||
/// <inheritdoc/>
|
||||
public override string Id => "avatar_renderers";
|
||||
|
||||
/// <summary>Creates the selected backend and applies the initial target; caller owns the result.</summary>
|
||||
public static IAvatarRenderer Create(IAvatar avatar)
|
||||
{
|
||||
if (avatar == null) throw new ArgumentNullException(nameof(avatar));
|
||||
var target = avatar.Resolve();
|
||||
if (!factories.TryGetValue(target.Renderer, out var factory))
|
||||
throw new KeyNotFoundException($"Avatar renderer '{target.Renderer}' is not loaded.");
|
||||
var renderer = factory.Create() ?? throw new InvalidOperationException($"Avatar renderer '{target.Renderer}' returned null.");
|
||||
try { renderer.Apply(target); return renderer; }
|
||||
catch { renderer.Dispose(); throw; }
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override void Add(IAvatarRendererFactory instance, string modId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(instance.Id)) throw new ArgumentException($"Mod '{modId}' supplied an empty avatar renderer Id.");
|
||||
if (!factories.TryAdd(instance.Id, instance))
|
||||
throw new InvalidOperationException($"Duplicate avatar renderer '{instance.Id}' from '{modId}'.");
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override void Remove(IAvatarRendererFactory instance, string modId)
|
||||
{
|
||||
if (factories.TryGetValue(instance.Id, out var current) && ReferenceEquals(current, instance))
|
||||
factories.Remove(instance.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using System;
|
||||
using Godot;
|
||||
|
||||
namespace Cthangover.Mods.Avatars
|
||||
{
|
||||
/// <summary>
|
||||
/// Optional presentation adapter implemented by a rendering mod. The caller attaches View to
|
||||
/// its UI container and disposes the adapter before that container is freed. The adapter owns
|
||||
/// its nodes and animations, but must not dispose borrowed cache-owned textures.
|
||||
/// </summary>
|
||||
public interface IAvatarRenderer : IDisposable
|
||||
{
|
||||
/// <summary>Renderer-owned visual root, created on the Godot main thread.</summary>
|
||||
Control View { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Applies a complete compatible target. Seconds must be finite and nonnegative; zero snaps.
|
||||
/// New targets interrupt previous transitions from the currently displayed state. Discrete
|
||||
/// renderers may snap even for positive duration. Missing bindings must not retain stale values.
|
||||
/// </summary>
|
||||
void Apply(AvatarFrame target, double transitionSeconds = 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Cthangover.Mods.Avatars
|
||||
{
|
||||
/// <summary>Public parameterless implementations are discovered and removed with their owning mod.</summary>
|
||||
public interface IAvatarRendererFactory
|
||||
{
|
||||
/// <summary>Renderer identifier used in avatar definitions, for example layers.</summary>
|
||||
string Id { get; }
|
||||
/// <summary>Creates an owned renderer; the caller must dispose it even if applying a target fails.</summary>
|
||||
IAvatarRenderer Create();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using System.Collections.Generic;
|
||||
using Cthangover.Core.Mods.Resolvers;
|
||||
|
||||
namespace Cthangover.Mods.Avatars
|
||||
{
|
||||
/// <summary>Uses the normal entity resolver, including folder/ZIP mods, inheritance and patches.</summary>
|
||||
public static class AvatarCatalog
|
||||
{
|
||||
/// <summary>Creates independent state from the currently resolved resource; unknown IDs throw.</summary>
|
||||
public static IAvatar Create(string id, AvatarState state = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(id) || !Entities.Collect<AvatarDefinition>().TryGetValue(id, out var definition))
|
||||
throw new KeyNotFoundException($"Avatar definition '{id}' was not found.");
|
||||
return new AvatarInstance(definition, state);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Cthangover.Mods.Avatars
|
||||
{
|
||||
/// <summary>Detached target snapshot. Renderers interpolate toward it or switch discrete assets.</summary>
|
||||
public sealed class AvatarFrame
|
||||
{
|
||||
/// <summary>Identity of the avatar producing this frame.</summary>
|
||||
public string AvatarId { get; }
|
||||
/// <summary>Renderer identifier from the definition; consumers must check compatibility.</summary>
|
||||
public string Renderer { get; }
|
||||
/// <summary>All active preset choices, including defaults.</summary>
|
||||
public IReadOnlyDictionary<string, string> Presets { get; }
|
||||
/// <summary>Complete composed targets. An absent binding is absent, not left over from a prior frame.</summary>
|
||||
public IReadOnlyDictionary<string, JsonElement> Values { get; }
|
||||
|
||||
internal AvatarFrame(string id, string renderer, Dictionary<string, string> presets,
|
||||
Dictionary<string, JsonElement> values)
|
||||
{
|
||||
AvatarId = id;
|
||||
Renderer = renderer;
|
||||
Presets = new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(presets));
|
||||
var copy = new Dictionary<string, JsonElement>();
|
||||
foreach (var value in values) copy.Add(value.Key, value.Value.Clone());
|
||||
Values = new ReadOnlyDictionary<string, JsonElement>(copy);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Cthangover.Mods.Avatars
|
||||
{
|
||||
/// <summary>
|
||||
/// Renderer-independent implementation. Owns a definition snapshot and recomposes full targets
|
||||
/// so leaving a preset also removes its contributions. No shared entity data is ever mutated.
|
||||
/// Instances are intended for their owning UI thread; they do not synchronize concurrent writes.
|
||||
/// </summary>
|
||||
public sealed class AvatarInstance : IAvatar
|
||||
{
|
||||
private readonly AvatarDefinition definition;
|
||||
private readonly Dictionary<string, string> selections = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Creates defaults, optionally overlaid with validated saved selections.</summary>
|
||||
public AvatarInstance(AvatarDefinition definition, AvatarState state = null)
|
||||
{
|
||||
if (definition == null) throw new ArgumentNullException(nameof(definition));
|
||||
definition.Validate();
|
||||
this.definition = JsonSerializer.Deserialize<AvatarDefinition>(JsonSerializer.Serialize(definition));
|
||||
foreach (var group in this.definition.Groups) selections.Add(group.Key, group.Value.Default);
|
||||
if (state != null)
|
||||
{
|
||||
if (!string.Equals(state.AvatarId, AvatarId, StringComparison.Ordinal))
|
||||
throw new ArgumentException($"Saved avatar '{state.AvatarId}' does not match '{AvatarId}'.");
|
||||
ApplyPresets(state.Presets);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string AvatarId => definition.ID;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void ApplyPresets(IReadOnlyDictionary<string, string> changes)
|
||||
{
|
||||
if (changes == null) throw new ArgumentNullException(nameof(changes));
|
||||
// Snapshot the caller's map before validation; commit only after the entire patch is valid.
|
||||
var pending = new Dictionary<string, string>(changes, StringComparer.Ordinal);
|
||||
foreach (var change in pending)
|
||||
{
|
||||
if (!definition.Groups.TryGetValue(change.Key, out var group))
|
||||
throw new ArgumentException($"Avatar '{AvatarId}': unknown group '{change.Key}'.");
|
||||
if (change.Value == null || !group.Presets.ContainsKey(change.Value))
|
||||
throw new ArgumentException($"Avatar '{AvatarId}', group '{change.Key}': unknown preset '{change.Value}'.");
|
||||
}
|
||||
foreach (var change in pending) selections[change.Key] = change.Value;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public AvatarState CaptureState() => new()
|
||||
{
|
||||
AvatarId = AvatarId,
|
||||
Presets = new Dictionary<string, string>(selections, StringComparer.Ordinal)
|
||||
};
|
||||
|
||||
/// <inheritdoc/>
|
||||
public AvatarFrame Resolve()
|
||||
{
|
||||
var values = new Dictionary<string, JsonElement>(definition.BaseValues, StringComparer.Ordinal);
|
||||
foreach (var group in definition.Groups.OrderBy(Priority).ThenBy(Name, StringComparer.Ordinal))
|
||||
foreach (var value in group.Value.Presets[selections[group.Key]].Values)
|
||||
values[value.Key] = value.Value;
|
||||
return new AvatarFrame(AvatarId, definition.Renderer, selections, values);
|
||||
}
|
||||
|
||||
private static int Priority(KeyValuePair<string, AvatarGroupDefinition> group) => group.Value.Priority;
|
||||
private static string Name(KeyValuePair<string, AvatarGroupDefinition> group) => group.Key;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Cthangover.Mods.Avatars
|
||||
{
|
||||
/// <summary>
|
||||
/// Serializable logical state only: no nodes, textures or interpolation progress.
|
||||
/// Capture returns a detached copy; restoring a partial map fills missing groups from defaults.
|
||||
/// </summary>
|
||||
public sealed class AvatarState
|
||||
{
|
||||
/// <summary>Definition identity prevents accidentally restoring another avatar's selections.</summary>
|
||||
public string AvatarId { get; set; }
|
||||
|
||||
/// <summary>Selected preset per group. Unknown groups or presets are rejected on restore.</summary>
|
||||
public Dictionary<string, string> Presets { get; set; } = new();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Cthangover.Mods.Avatars
|
||||
{
|
||||
/// <summary>
|
||||
/// One independent logical avatar instance. Named presets are the public control surface;
|
||||
/// rendering and transitions belong to an attached renderer, not to dialog or character data.
|
||||
/// </summary>
|
||||
public interface IAvatar
|
||||
{
|
||||
/// <summary>The resource whose defaults and bindings were snapshotted for this instance.</summary>
|
||||
string AvatarId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Atomically replaces selections for specified groups and retains all other selections.
|
||||
/// Invalid names throw ArgumentException without changing the instance. Null is not a reset.
|
||||
/// </summary>
|
||||
void ApplyPresets(IReadOnlyDictionary<string, string> changes);
|
||||
|
||||
/// <summary>Captures a detached, JSON-serializable logical state for persistence.</summary>
|
||||
AvatarState CaptureState();
|
||||
|
||||
/// <summary>Composes a complete immutable renderer target from baseline and current presets.</summary>
|
||||
AvatarFrame Resolve();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"Kind": "avatar",
|
||||
"Items": [
|
||||
{
|
||||
"Id": "avatar_contracts/sample",
|
||||
"Renderer": "test",
|
||||
"Groups": {
|
||||
"eyes_state": {
|
||||
"Default": "opened",
|
||||
"Presets": {
|
||||
"opened": {
|
||||
"Values": {
|
||||
"eye": 1
|
||||
}
|
||||
},
|
||||
"closed": {
|
||||
"Values": {
|
||||
"eye": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"id": "avatar_base__contracts",
|
||||
"name": "Avatar resource contract tests",
|
||||
"version": "1.0.0",
|
||||
"author": "ct",
|
||||
"depends": [
|
||||
"avatar_base>=1"
|
||||
],
|
||||
"test_sources": [
|
||||
"tests/*.cs"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using System.Collections.Generic;
|
||||
using Cthangover.Core.Mods.Testing;
|
||||
using Cthangover.Mods.Avatars;
|
||||
|
||||
namespace Cthangover.Mods.Avatars.Contracts
|
||||
{
|
||||
/// <summary>Exercises real Kind registration, mod entity resolution and independent catalog instances.</summary>
|
||||
public sealed class AvatarCatalogTests
|
||||
{
|
||||
[ModTest("avatar_catalog/resolved_entity_creates_independent_instances")]
|
||||
public static void Resource(ModTestContext ctx)
|
||||
{
|
||||
var first = AvatarCatalog.Create("avatar_contracts/sample");
|
||||
var second = AvatarCatalog.Create("avatar_contracts/sample");
|
||||
first.ApplyPresets(new Dictionary<string, string> { ["eyes_state"] = "closed" });
|
||||
ModAssert.CloseTo(0, first.Resolve().Values["eye"].GetDouble());
|
||||
ModAssert.CloseTo(1, second.Resolve().Values["eye"].GetDouble());
|
||||
var restored = AvatarCatalog.Create("avatar_contracts/sample", first.CaptureState());
|
||||
ModAssert.Equal("closed", restored.CaptureState().Presets["eyes_state"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using Cthangover.Core.Mods.Testing;
|
||||
|
||||
namespace Cthangover.Mods.Avatars.Tests
|
||||
{
|
||||
/// <summary>Resource serialization preserves opaque renderer values and rejects malformed preset groups.</summary>
|
||||
public sealed class AvatarDefinitionTests
|
||||
{
|
||||
[ModTest("avatar/definition_json_roundtrip_preserves_renderer_data")]
|
||||
public static void RoundTrip(ModTestContext ctx)
|
||||
{
|
||||
var original = AvatarInstanceTests.Definition();
|
||||
original.Groups["eyes_state"].Presets["opened"].Values["layer"] = JsonSerializer.SerializeToElement("eyes/opened");
|
||||
original.BaseValues["options"] = JsonSerializer.SerializeToElement(new { enabled = true, offset = new[] { 1, 2 } });
|
||||
original.BaseValues["hidden"] = JsonSerializer.SerializeToElement<object>(null);
|
||||
var copy = JsonSerializer.Deserialize<AvatarDefinition>(JsonSerializer.Serialize(original));
|
||||
copy.Validate();
|
||||
ModAssert.Equal(original.ID, copy.ID); ModAssert.Equal(original.Renderer, copy.Renderer);
|
||||
ModAssert.Equal(100, copy.Groups["eyes_state"].Priority);
|
||||
ModAssert.Equal("auto", copy.Groups["eyes_state"].Default);
|
||||
ModAssert.Equal("eyes/opened", copy.Groups["eyes_state"].Presets["opened"].Values["layer"].GetString());
|
||||
ModAssert.True(copy.BaseValues["options"].GetProperty("enabled").GetBoolean());
|
||||
ModAssert.Equal(2, copy.BaseValues["options"].GetProperty("offset")[1].GetInt32());
|
||||
ModAssert.True(copy.BaseValues["hidden"].ValueKind == JsonValueKind.Null);
|
||||
ModAssert.CloseTo(0.3, copy.Groups["emotion"].Presets["angry"].Values["eye"].GetDouble());
|
||||
}
|
||||
|
||||
[ModTest("avatar/invalid_definitions_are_rejected_before_instantiation")]
|
||||
public static void Invalid(ModTestContext ctx)
|
||||
{
|
||||
Reject(new AvatarDefinition());
|
||||
var d = AvatarInstanceTests.Definition(); d.Renderer = " "; Reject(d);
|
||||
d = AvatarInstanceTests.Definition(); d.Groups = null; Reject(d);
|
||||
d = AvatarInstanceTests.Definition(); d.BaseValues = null; Reject(d);
|
||||
d = AvatarInstanceTests.Definition(); d.Groups["emotion"].Default = "missing"; Reject(d);
|
||||
d = AvatarInstanceTests.Definition(); d.Groups["emotion"].Presets = null; Reject(d);
|
||||
d = AvatarInstanceTests.Definition(); d.Groups["emotion"] = null; Reject(d);
|
||||
d = AvatarInstanceTests.Definition(); d.Groups["emotion"].Presets["neutral"] = null; Reject(d);
|
||||
d = AvatarInstanceTests.Definition(); d.Groups["emotion"].Presets["neutral"].Values = null; Reject(d);
|
||||
d = AvatarInstanceTests.Definition(); d.BaseValues["bad"] = default; Reject(d);
|
||||
d = AvatarInstanceTests.Definition(); d.Groups[" "] = d.Groups["emotion"]; Reject(d);
|
||||
d = AvatarInstanceTests.Definition(); d.Groups["emotion"].Presets[" "] = new(); Reject(d);
|
||||
d = AvatarInstanceTests.Definition(); d.BaseValues[""] = JsonSerializer.SerializeToElement(1); Reject(d);
|
||||
var nullRejected = false;
|
||||
try { _ = new AvatarInstance(null); } catch (ArgumentNullException) { nullRejected = true; }
|
||||
ModAssert.True(nullRejected);
|
||||
}
|
||||
|
||||
[ModTest("avatar/catalog_unknown_id_is_diagnosed")]
|
||||
public static void Missing(ModTestContext ctx)
|
||||
{
|
||||
var rejected = false;
|
||||
try { _ = AvatarCatalog.Create("avatar_tests/nonexistent"); } catch (KeyNotFoundException) { rejected = true; }
|
||||
ModAssert.True(rejected);
|
||||
}
|
||||
|
||||
private static void Reject(AvatarDefinition definition)
|
||||
{
|
||||
var rejected = false;
|
||||
try { definition.Validate(); } catch (ArgumentException) { rejected = true; }
|
||||
ModAssert.True(rejected, "malformed definition is rejected");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using Cthangover.Core.Mods.Testing;
|
||||
|
||||
namespace Cthangover.Mods.Avatars.Tests
|
||||
{
|
||||
/// <summary>Public preset contract: independent instances, atomic changes and deterministic full targets.</summary>
|
||||
public sealed class AvatarInstanceTests
|
||||
{
|
||||
[ModTest("avatar/defaults_partial_update_and_removed_contributions")]
|
||||
public static void Selections(ModTestContext ctx)
|
||||
{
|
||||
IAvatar avatar = new AvatarInstance(Definition());
|
||||
ModAssert.Equal("test/avatar", avatar.AvatarId);
|
||||
ModAssert.Equal("neutral", avatar.CaptureState().Presets["emotion"]);
|
||||
ModAssert.Equal("test", avatar.Resolve().Renderer);
|
||||
ModAssert.CloseTo(1, avatar.Resolve().Values["eye"].GetDouble());
|
||||
avatar.ApplyPresets(new Dictionary<string, string> { ["emotion"] = "angry" });
|
||||
ModAssert.CloseTo(0.3, avatar.Resolve().Values["eye"].GetDouble());
|
||||
ModAssert.Equal("red", avatar.Resolve().Values["tint"].GetString());
|
||||
avatar.ApplyPresets(new Dictionary<string, string> { ["eyes_state"] = "opened" });
|
||||
ModAssert.Equal("angry", avatar.CaptureState().Presets["emotion"], "unmentioned groups retained");
|
||||
ModAssert.CloseTo(1, avatar.Resolve().Values["eye"].GetDouble(), what: "explicit eyes override emotion");
|
||||
avatar.ApplyPresets(new Dictionary<string, string> { ["emotion"] = "neutral", ["eyes_state"] = "auto" });
|
||||
ModAssert.False(avatar.Resolve().Values.ContainsKey("tint"), "leaving a preset removes its contributions");
|
||||
ModAssert.CloseTo(1, avatar.Resolve().Values["eye"].GetDouble(), what: "baseline restored");
|
||||
}
|
||||
|
||||
[ModTest("avatar/composition_is_independent_of_command_order")]
|
||||
public static void Order(ModTestContext ctx)
|
||||
{
|
||||
var a = new AvatarInstance(Definition()); var b = new AvatarInstance(Definition());
|
||||
a.ApplyPresets(new Dictionary<string, string> { ["emotion"] = "angry" });
|
||||
a.ApplyPresets(new Dictionary<string, string> { ["eyes_state"] = "opened" });
|
||||
b.ApplyPresets(new Dictionary<string, string> { ["eyes_state"] = "opened" });
|
||||
b.ApplyPresets(new Dictionary<string, string> { ["emotion"] = "angry" });
|
||||
ModAssert.CloseTo(a.Resolve().Values["eye"].GetDouble(), b.Resolve().Values["eye"].GetDouble());
|
||||
var tied = Definition(); tied.Groups["emotion"].Priority = 100;
|
||||
var tie = new AvatarInstance(tied);
|
||||
tie.ApplyPresets(new Dictionary<string, string> { ["eyes_state"] = "opened", ["emotion"] = "angry" });
|
||||
ModAssert.CloseTo(1, tie.Resolve().Values["eye"].GetDouble(), what: "ordinal group name breaks equal priorities");
|
||||
}
|
||||
|
||||
[ModTest("avatar/invalid_patch_is_atomic_and_names_are_case_sensitive")]
|
||||
public static void Atomic(ModTestContext ctx)
|
||||
{
|
||||
IAvatar avatar = new AvatarInstance(Definition());
|
||||
Reject(avatar, new Dictionary<string, string> { ["emotion"] = "angry", ["eyes_state"] = "missing" });
|
||||
Reject(avatar, new Dictionary<string, string> { ["Emotion"] = "angry" });
|
||||
Reject(avatar, new Dictionary<string, string> { ["emotion"] = "Angry" });
|
||||
Reject(avatar, new Dictionary<string, string> { ["emotion"] = null });
|
||||
Reject(avatar, null);
|
||||
ModAssert.Equal("neutral", avatar.CaptureState().Presets["emotion"]);
|
||||
ModAssert.CloseTo(1, avatar.Resolve().Values["eye"].GetDouble());
|
||||
}
|
||||
|
||||
[ModTest("avatar/definition_state_and_frame_do_not_alias_instances")]
|
||||
public static void Isolation(ModTestContext ctx)
|
||||
{
|
||||
var definition = Definition();
|
||||
IAvatar a = new AvatarInstance(definition); IAvatar b = new AvatarInstance(definition);
|
||||
var frame = a.Resolve(); var state = a.CaptureState();
|
||||
definition.Groups["emotion"].Presets.Clear(); definition.BaseValues.Clear();
|
||||
state.Presets["emotion"] = "missing";
|
||||
a.ApplyPresets(new Dictionary<string, string> { ["emotion"] = "angry" });
|
||||
ModAssert.Equal("neutral", b.CaptureState().Presets["emotion"]);
|
||||
ModAssert.Equal("neutral", frame.Presets["emotion"]);
|
||||
ModAssert.CloseTo(1, frame.Values["eye"].GetDouble());
|
||||
ModAssert.CloseTo(0.3, a.Resolve().Values["eye"].GetDouble());
|
||||
var readOnly = false;
|
||||
try { ((IDictionary<string, string>)frame.Presets).Add("new", "value"); }
|
||||
catch (NotSupportedException) { readOnly = true; }
|
||||
ModAssert.True(readOnly, "render target selections cannot be mutated");
|
||||
}
|
||||
|
||||
[ModTest("avatar/state_json_roundtrip_and_restore_validation")]
|
||||
public static void Persistence(ModTestContext ctx)
|
||||
{
|
||||
var original = new AvatarInstance(Definition());
|
||||
original.ApplyPresets(new Dictionary<string, string> { ["emotion"] = "angry", ["eyes_state"] = "opened" });
|
||||
var saved = JsonSerializer.Deserialize<AvatarState>(JsonSerializer.Serialize(original.CaptureState()));
|
||||
var restored = new AvatarInstance(Definition(), saved);
|
||||
ModAssert.Equal(original.AvatarId, restored.AvatarId);
|
||||
ModAssert.Equal("angry", restored.CaptureState().Presets["emotion"]);
|
||||
ModAssert.Equal("opened", restored.CaptureState().Presets["eyes_state"]);
|
||||
ModAssert.CloseTo(1, restored.Resolve().Values["eye"].GetDouble());
|
||||
saved.Presets.Clear();
|
||||
ModAssert.Equal("angry", restored.CaptureState().Presets["emotion"], "restored state is copied");
|
||||
var partial = new AvatarState { AvatarId = "test/avatar", Presets = new() { ["emotion"] = "angry" } };
|
||||
ModAssert.Equal("auto", new AvatarInstance(Definition(), partial).CaptureState().Presets["eyes_state"]);
|
||||
RejectState(new AvatarState { AvatarId = "other" });
|
||||
RejectState(new AvatarState { AvatarId = "test/avatar", Presets = new() { ["emotion"] = "missing" } });
|
||||
RejectState(new AvatarState { AvatarId = "test/avatar", Presets = null });
|
||||
}
|
||||
|
||||
internal static AvatarDefinition Definition() => JsonSerializer.Deserialize<AvatarDefinition>(
|
||||
"{\"Id\":\"test/avatar\",\"Renderer\":\"test\",\"BaseValues\":{\"eye\":1},\"Groups\":{" +
|
||||
"\"emotion\":{\"Default\":\"neutral\",\"Presets\":{\"neutral\":{},\"angry\":{\"Values\":{\"eye\":0.3,\"tint\":\"red\"}}}}," +
|
||||
"\"eyes_state\":{\"Default\":\"auto\",\"Priority\":100,\"Presets\":{\"auto\":{},\"opened\":{\"Values\":{\"eye\":1}}}}}}");
|
||||
|
||||
private static void Reject(IAvatar avatar, IReadOnlyDictionary<string, string> changes)
|
||||
{
|
||||
var rejected = false;
|
||||
try { avatar.ApplyPresets(changes); } catch (ArgumentException) { rejected = true; }
|
||||
ModAssert.True(rejected, "invalid changes are rejected");
|
||||
}
|
||||
private static void RejectState(AvatarState state)
|
||||
{
|
||||
var rejected = false;
|
||||
try { _ = new AvatarInstance(Definition(), state); } catch (ArgumentException) { rejected = true; }
|
||||
ModAssert.True(rejected, "invalid restored state is rejected");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user