Update cthangover_game implementation, assets and documentation

This commit is contained in:
cthangover committed 2026-10-31 15:28:50 +03:00
1 parent b42b35d79e
commit 65eb187bb2
42 files changed
+977 -2

No files matched your search

+28 -2
View File
@@ -5,8 +5,34 @@
"author": "ct",
"description": "The story content of Cthangover: scenes, scenarios, characters, items, quests and the world map of Marao's very bad night. The machinery lives in engine mods.",
"category": "story",
"depends": ["shell2d_base>=1", "dialog_base>=1", "interactive_base>=1", "skills_base>=1", "map_base>=1", "ui_base>=1", "characters_base>=1", "items_base>=1", "quests_base>=1", "town_pack>=1", "music_pack>=1"],
"depends": [
"shell2d_base>=1",
"dialog_base>=1",
"interactive_base>=1",
"skills_base>=1",
"map_base>=1",
"ui_base>=1",
"characters_base>=1",
"items_base>=1",
"quests_base>=1",
"town_pack>=1",
"music_pack>=1"
],
"sources": [
"src/**/*.cs"
],
"test_suites": [
{"id": "site_shots", "activate": ["--itest=site_shots"]}
{
"id": "site_shots",
"activate": [
"--itest=site_shots"
]
},
{
"id": "marao_avatars",
"activate": [
"--itest=marao_avatars"
]
}
]
}
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 43 KiB

+69
View File
@@ -0,0 +1,69 @@
using Cthangover.Core.Events;
using Cthangover.Core.Mods;
using Cthangover.Core.Settings;
using Cthangover.Mods.Skills;
namespace Cthangover.Mods.StarterParty
{
/// <summary>
/// Populates the initial player party with the default starter
/// character <c>"Marao"</c> when the cthangover_party mod finishes loading.
///
/// The party can only be populated during <c>OnModResourcesReady</c>,
/// not earlier. <c>OnModLoaded</c> and the constructor fire inside
/// <c>ModRegistry.Initialize</c> → <c>ModCompiler.LoadModCode</c>,
/// where mod JSON resources are not yet guaranteed to be accessible.
/// <c>AddCharacterToParty</c> chains into <c>CharacterFactory.Get</c>,
/// which triggers the shared entity resolver's first scan — this requires
/// file providers to be fully set up, which only happens after the
/// entire <c>ModRegistry.Initialize</c> pipeline completes.
///
/// <c>OnModResourcesReady</c> is invoked by <c>ModInitializerRegistry
/// .NotifyResourcesReady</c> at the end of <c>SceneManager.Initialize</c>,
/// after <c>ModRegistry</c> finishes discovery, <c>SceneEventRegistry</c>
/// is primed, and scene definitions are collected. At that point all
/// factories can safely read mod JSON data.
///
/// Mods that introduce additional starter characters should implement
/// their own <c>IModInitializer</c> and call <c>AddCharacterToParty</c>
/// from <c>OnModResourcesReady</c> for their character IDs.
/// </summary>
public class StarterPartyInitializer : IModInitializer
{
private static bool newGameHookInstalled;
/// <summary>
/// No-op: cross-mod lifecycle notifications are not needed for
/// starter party setup. Each mod's <c>OnModResourcesReady</c>
/// handles its own default characters independently.
/// </summary>
public void OnModLoaded(string modId) { }
/// <summary>
/// Adds the cthangover_party mod's default starter character <c>"Marao"</c>
/// to both <c>Characters</c> and <c>BattleSet</c> via
/// <c>CharacterData.AddCharacterToParty</c>. Called once, after
/// all mod file providers and JSON resources are guaranteed to
/// be accessible. Also installs the one-time
/// <see cref="NewGameStartedEvent"/> hook so the starter party is
/// restored after <c>ResetRuntime</c> wipes it at the start of a
/// new game. Both operations are idempotent.
/// </summary>
public void OnModResourcesReady()
{
SeedStarterParty();
if (newGameHookInstalled)
return;
newGameHookInstalled = true;
ModEventBus.Subscribe<NewGameStartedEvent>(_ => SeedStarterParty());
}
private static void SeedStarterParty()
{
GameData.Instance.Runtime.CharacterData.AddCharacterToParty("Marao");
SkillService.GrantSkill("slave_master");
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

@@ -0,0 +1,220 @@
{
"Kind": "avatar",
"Items": [
{
"Id": "cthangover/al_azif_test",
"Renderer": "layers",
"BaseValues": {
"canvas": {
"Width": 512,
"Height": 768
},
"layers/character": {
"Image": "cthangover/al_azif_test/01_front_master.png",
"Order": 0,
"X": 0.0,
"Y": 143.0471771075019,
"Width": 512.0,
"Height": 481.9056457849962
}
},
"Groups": {
"emotion": {
"Default": "auto",
"Priority": 20,
"Presets": {
"auto": {},
"neutral": {
"Values": {
"layers/character": {
"Image": "cthangover/al_azif_test/01_front_master.png",
"Order": 0,
"X": 0.0,
"Y": 143.0471771075019,
"Width": 512.0,
"Height": 481.9056457849962
}
}
},
"open_mouth": {
"Values": {
"layers/character": {
"Image": "cthangover/al_azif_test/05_emotion_open_mouth.png",
"Order": 0,
"X": 0.0,
"Y": 143.24516627996906,
"Width": 512.0,
"Height": 481.5096674400619
}
}
},
"eye_roll": {
"Values": {
"layers/character": {
"Image": "cthangover/al_azif_test/06_emotion_eye_roll.png",
"Order": 0,
"X": 0.0,
"Y": 143.24516627996906,
"Width": 512.0,
"Height": 481.5096674400619
}
}
},
"confused": {
"Values": {
"layers/character": {
"Image": "cthangover/al_azif_test/07_emotion_confused.png",
"Order": 0,
"X": 0.0,
"Y": 143.24516627996906,
"Width": 512.0,
"Height": 481.5096674400619
}
}
},
"angry_scream": {
"Values": {
"layers/character": {
"Image": "cthangover/al_azif_test/08_emotion_angry_scream.png",
"Order": 0,
"X": 0.0,
"Y": 143.24516627996906,
"Width": 512.0,
"Height": 481.5096674400619
}
}
},
"fear": {
"Values": {
"layers/character": {
"Image": "cthangover/al_azif_test/09_emotion_fear.png",
"Order": 0,
"X": 0.0,
"Y": 143.24516627996906,
"Width": 512.0,
"Height": 481.5096674400619
}
}
},
"embarrassed": {
"Values": {
"layers/character": {
"Image": "cthangover/al_azif_test/10_emotion_embarrassed.png",
"Order": 0,
"X": 0.0,
"Y": 143.24516627996906,
"Width": 512.0,
"Height": 481.5096674400619
}
}
},
"gloating": {
"Values": {
"layers/character": {
"Image": "cthangover/al_azif_test/11_emotion_gloating.png",
"Order": 0,
"X": 0.0,
"Y": 143.24516627996906,
"Width": 512.0,
"Height": 481.5096674400619
}
}
},
"squeamish": {
"Values": {
"layers/character": {
"Image": "cthangover/al_azif_test/12_emotion_squeamish.png",
"Order": 0,
"X": 0.0,
"Y": 143.24516627996906,
"Width": 512.0,
"Height": 481.5096674400619
}
}
},
"disgust": {
"Values": {
"layers/character": {
"Image": "cthangover/al_azif_test/13_emotion_disgust.png",
"Order": 0,
"X": 0.0,
"Y": 143.24516627996906,
"Width": 512.0,
"Height": 481.5096674400619
}
}
},
"talking_a": {
"Values": {
"layers/character": {
"Image": "cthangover/al_azif_test/14_talking_a.png",
"Order": 0,
"X": 0.0,
"Y": 143.24516627996906,
"Width": 512.0,
"Height": 481.5096674400619
}
}
},
"talking_b": {
"Values": {
"layers/character": {
"Image": "cthangover/al_azif_test/15_talking_b.png",
"Order": 0,
"X": 0.0,
"Y": 143.43122102009275,
"Width": 512.0,
"Height": 481.1375579598145
}
}
}
}
},
"view": {
"Default": "auto",
"Priority": 100,
"Presets": {
"auto": {},
"front": {},
"angled": {
"Values": {
"layers/character": {
"Image": "cthangover/al_azif_test/02_closed_angle.png",
"Order": 0,
"X": 0.0,
"Y": 143.24516627996906,
"Width": 512.0,
"Height": 481.5096674400619
}
}
},
"back": {
"Values": {
"layers/character": {
"Image": "cthangover/al_azif_test/03_back_spine.png",
"Order": 0,
"X": 0.0,
"Y": 143.24516627996906,
"Width": 512.0,
"Height": 481.5096674400619
}
}
},
"open": {
"Values": {
"layers/character": {
"Image": "cthangover/al_azif_test/04_open.png",
"Order": 0,
"X": 0.0,
"Y": 185.34831460674158,
"Width": 512.0,
"Height": 397.30337078651684
}
}
}
}
}
}
}
]
}
@@ -0,0 +1,294 @@
{
"Kind": "avatar",
"Items": [
{
"Id": "cthangover/marao_test",
"Renderer": "layers",
"BaseValues": {
"canvas": {
"Width": 512,
"Height": 768
}
},
"Groups": {
"reaction": {
"Default": "neutral",
"Priority": 0,
"Presets": {
"neutral": {
"Values": {
"layers/character": {
"Image": "cthangover/marao_test/neutral.png",
"Order": 0,
"X": 0.0,
"Y": 0.0,
"Width": 512.0,
"Height": 768.0
}
}
},
"hangover": {
"Values": {
"layers/character": {
"Image": "cthangover/marao_test/hangover.png",
"Order": 0,
"X": 0.0,
"Y": 0.0,
"Width": 512.0,
"Height": 768.0
}
}
},
"pants_panic": {
"Values": {
"layers/character": {
"Image": "cthangover/marao_test/pants_panic.png",
"Order": 0,
"X": 0.0,
"Y": 0.0,
"Width": 512.0,
"Height": 768.0
}
}
},
"rag_emperor": {
"Values": {
"layers/character": {
"Image": "cthangover/marao_test/rag_emperor.png",
"Order": 0,
"X": 0.0,
"Y": 0.0,
"Width": 512.0,
"Height": 768.0
}
}
},
"deadpan_azif": {
"Values": {
"layers/character": {
"Image": "cthangover/marao_test/deadpan_azif.png",
"Order": 0,
"X": 0.0,
"Y": 256.0,
"Width": 512.0,
"Height": 512.0
}
}
},
"poetry_reader": {
"Values": {
"layers/character": {
"Image": "cthangover/marao_test/poetry_reader.png",
"Order": 0,
"X": 0.0,
"Y": 256.0,
"Width": 512.0,
"Height": 512.0
}
}
},
"police_panic": {
"Values": {
"layers/character": {
"Image": "cthangover/marao_test/police_panic.png",
"Order": 0,
"X": 0.0,
"Y": 256.0,
"Width": 512.0,
"Height": 512.0
}
}
},
"suspicious": {
"Values": {
"layers/character": {
"Image": "cthangover/marao_test/suspicious.png",
"Order": 0,
"X": 0.0,
"Y": 256.0,
"Width": 512.0,
"Height": 512.0
}
}
},
"bad_smell": {
"Values": {
"layers/character": {
"Image": "cthangover/marao_test/bad_smell.png",
"Order": 0,
"X": 0.0,
"Y": 256.0,
"Width": 512.0,
"Height": 512.0
}
}
},
"protective": {
"Values": {
"layers/character": {
"Image": "cthangover/marao_test/protective.png",
"Order": 0,
"X": 0.0,
"Y": 256.0,
"Width": 512.0,
"Height": 512.0
}
}
}
}
},
"emotion": {
"Default": "auto",
"Priority": 20,
"Presets": {
"auto": {},
"neutral": {
"Values": {
"layers/character": {
"Image": "cthangover/marao_test/neutral.png",
"Order": 0,
"X": 0.0,
"Y": 0.0,
"Width": 512.0,
"Height": 768.0
}
}
},
"open_mouth": {
"Values": {
"layers/character": {
"Image": "cthangover/marao_test/emotion_open_mouth.png",
"Order": 0,
"X": 0.0,
"Y": 256.0,
"Width": 512.0,
"Height": 512.0
}
}
},
"eye_roll": {
"Values": {
"layers/character": {
"Image": "cthangover/marao_test/emotion_eye_roll.png",
"Order": 0,
"X": 0.0,
"Y": 256.0,
"Width": 512.0,
"Height": 512.0
}
}
},
"confused": {
"Values": {
"layers/character": {
"Image": "cthangover/marao_test/emotion_confused.png",
"Order": 0,
"X": 0.0,
"Y": 256.0,
"Width": 512.0,
"Height": 512.0
}
}
},
"angry_scream": {
"Values": {
"layers/character": {
"Image": "cthangover/marao_test/pants_panic.png",
"Order": 0,
"X": 0.0,
"Y": 0.0,
"Width": 512.0,
"Height": 768.0
}
}
},
"fear": {
"Values": {
"layers/character": {
"Image": "cthangover/marao_test/police_panic.png",
"Order": 0,
"X": 0.0,
"Y": 256.0,
"Width": 512.0,
"Height": 512.0
}
}
},
"embarrassed": {
"Values": {
"layers/character": {
"Image": "cthangover/marao_test/emotion_embarrassed.png",
"Order": 0,
"X": 0.0,
"Y": 256.0,
"Width": 512.0,
"Height": 512.0
}
}
},
"gloating": {
"Values": {
"layers/character": {
"Image": "cthangover/marao_test/rag_emperor.png",
"Order": 0,
"X": 0.0,
"Y": 0.0,
"Width": 512.0,
"Height": 768.0
}
}
},
"squeamish": {
"Values": {
"layers/character": {
"Image": "cthangover/marao_test/emotion_squeamish.png",
"Order": 0,
"X": 0.0,
"Y": 256.0,
"Width": 512.0,
"Height": 512.0
}
}
},
"disgust": {
"Values": {
"layers/character": {
"Image": "cthangover/marao_test/bad_smell.png",
"Order": 0,
"X": 0.0,
"Y": 256.0,
"Width": 512.0,
"Height": 512.0
}
}
},
"talking_a": {
"Values": {
"layers/character": {
"Image": "cthangover/marao_test/talking_a.png",
"Order": 0,
"X": 0.0,
"Y": 256.0,
"Width": 512.0,
"Height": 512.0
}
}
},
"talking_b": {
"Values": {
"layers/character": {
"Image": "cthangover/marao_test/talking_b.png",
"Order": 0,
"X": 0.0,
"Y": 256.0,
"Width": 512.0,
"Height": 512.0
}
}
}
}
}
}
}
]
}
+57
View File
@@ -0,0 +1,57 @@
# Marao reaction auditions
Opt-in content sample for `avatar_base` + `avatar_layers`, activated by `--itest=marao_avatars`.
The virtual mod `cthangover_game__marao_avatars` supplies `cthangover/marao_test` with ten named
`reaction` presets. Four are full-body images; six are waist-up portraits, fitted without
stretching into a 512x768 canvas and aligned at the bottom. Every PNG has actual alpha.
This is an audition set of complete acting poses, not a modular facial rig. A reaction selects
one complete character layer. Eyes, mouth, clothes and pose cannot be independently combined
in these particular assets. The underlying renderer remains capable of multiple independent layers.
Sources, exact cloud prompts, rejected-request records, story references and the offline preview
are archived outside repositories in `art_sources/cthangover/marao_avatar_test_20261030/`.
Source images are copied byte-for-byte into the test suite. No production dialogue references change.
```csharp
IAvatar avatar = AvatarCatalog.Create("cthangover/marao_test");
avatar.ApplyPresets(new Dictionary<string, string> { ["reaction"] = "deadpan_azif" });
IAvatarRenderer renderer = AvatarRenderers.Create(avatar);
host.AddChild(renderer.View);
// Dispose renderer before destroying host.
```
Presets: neutral, hangover, pants_panic, rag_emperor, deadpan_azif, poetry_reader,
police_panic, suspicious, bad_smell, protective.
Runtime audition and comparison export (windowed, via the workspace Windows job bridge):
```text
godot --path . --rendering-method gl_compatibility -- --frontend --with-mods=cthangover_game --itest=marao_avatars --run-tests --mod=cthangover_game__marao_avatars
```
The two runtime tests apply every preset, verify distinct rendered output and alpha, round-trip
saved selections and export a comparison sheet plus individual normalized PNGs under
`gen/marao_avatar_test_20261030/`. Raw generated images are retained separately.
## Paired Al-Azif update
The suite also supplies `cthangover/al_azif_test`. Both avatars expose `emotion` with
`auto`, `neutral`, `open_mouth`, `eye_roll`, `confused`, `angry_scream`, `fear`, `embarrassed`,
`gloating`, `squeamish`, `disgust`, `talking_a`, `talking_b`. Default `auto` contributes nothing:
Marao keeps the selected story reaction, Al-Azif uses its neutral baseline. An explicit emotion
at priority 20 replaces the complete character image; select auto to return to story reactions.
Al-Azif additionally exposes `view`: auto/front keep emotion control; angled/back/open at
priority 100 select the requested structural view, temporarily overriding the emotional image.
Select view=auto to return to the chosen emotion. No mirroring is applied to book images.
The two talking presets are not an automatically played or pixel-aligned animation.
The four full-body Marao poses now use softer legs and loose white shorts. Both held-book
portraits use the canonical Al-Azif design. Selected source PNGs and the updated offline gallery
live in `art_sources/cthangover/marao_azif_avatar_update_20261030/`. The original generated
versions remain archived in the earlier source batch. Three page-pulling story frames are
standalone illustrations in the updated source batch; they do not replace production scenes.
Additional runtime tests exercise the shared emotion vocabulary, all Al-Azif views, JSON
restore and export `gen/marao_azif_avatar_update_20261030/emotion_comparison.png`.
+42
View File
@@ -0,0 +1,42 @@
# Пробы реакций Марао
Тестовый набор включается флагом `--itest=marao_avatars`. Ресурс аватара:
`cthangover/marao_test`, группа пресетов: `reaction`.
| Пресет | Реакция |
| --- | --- |
| neutral | Спокойный, собранный Марао |
| hangover | «Кузнец в голове»: похмельная гримаса |
| pants_panic | «Где мои штаны?!»: возмущённое недоумение |
| rag_emperor | Самодовольный император красной тряпки |
| deadpan_azif | «Ты бумажка»: равнодушие перед Аль-Азифом |
| poetry_reader | Уверенный чтец «казахских стихов» |
| police_panic | «Я же одет… почти»: комическая паника |
| suspicious | Подозрение у виллы «Забвение» |
| bad_smell | «Ну хоть не падалью»: отвращение к запаху |
| protective | Серьёзная решимость защитить другого |
Первые четыре варианта полноростовые, остальные шесть — поясные. Каждый пресет
выбирает целую позу с выражением лица: это подборка для выбора образов, а не
раздельные детали глаз и рта. PNG прозрачные, исходные диалоговые аватарки не заменены.
[Формат и запуск проверки](en.md). Галерея с сюжетными цитатами находится вне
репозитория: `art_sources/cthangover/marao_avatar_test_20261030/index.html`.
## Обновление: общий набор с Аль-Азифом
Добавлен аватар `cthangover/al_azif_test`. Оба аватара поддерживают группу `emotion`:
`auto`, `neutral`, `open_mouth`, `eye_roll`, `confused`, `angry_scream`, `fear`, `embarrassed`,
`gloating`, `squeamish`, `disgust`, `talking_a`, `talking_b`.
По умолчанию emotion=auto сохраняет сюжетную реакцию Марао или спокойную книгу.
Явная эмоция выбирает готовое изображение целиком и перекрывает сюжетную реакцию;
для возврата нужно снова выбрать auto. Два кадра речи не образуют готовый lip sync.
У Аль-Азифа есть группа `view`: auto/front оставляют управление эмоции, angled/back/open
показывают другой ракурс и перекрывают эмоциональную картинку. Для возврата — view=auto.
Четыре полноростовые позы Марао обновлены: ноги мягче, одежда — свободные белые шорты.
В двух портретах исправлена книга. Обновлённая галерея:
`art_sources/cthangover/marao_azif_avatar_update_20261030/index.html`.
Три кадра попытки вырвать страницу находятся там же как отдельные иллюстрации.
+13
View File
@@ -0,0 +1,13 @@
{
"id": "cthangover_game__marao_avatars",
"name": "Marao avatar auditions",
"version": "1.0.0",
"author": "ct",
"description": "Ten story-inspired anime reaction presets for the layered avatar renderer.",
"depends": [
"avatar_layers>=1"
],
"test_sources": [
"tests/*.cs"
]
}
@@ -0,0 +1,120 @@
using System;
using System.Collections.Generic;
using System.Security.Cryptography;
using System.Text.Json;
using System.Threading.Tasks;
using Cthangover.Core.Mods.Resolvers;
using Cthangover.Core.Mods.Testing;
using Cthangover.Core.Utils;
using Cthangover.Mods.Avatars;
using Godot;
namespace Cthangover.Mods.MaraoAvatars.Tests
{
/// <summary>Auditions the actual story PNGs through preset state and the installed layered renderer.</summary>
public sealed class MaraoAvatarTests
{
private const string AvatarId = "cthangover/marao_test";
private const string Output = "res://gen/marao_avatar_test_20261030/";
private static string OutputFile(string name)
{
// The picture folder may be missing on a fresh checkout (the host has no gen/ subfolder).
var dir = ProjectSettings.GlobalizePath(Output);
DirAccess.MakeDirRecursiveAbsolute(dir);
return dir + name;
}
[ModTest("marao_avatars/all_ten_reactions_render_and_restore", TimeoutSeconds = 45)]
public static async Task Reactions(ModTestContext ctx)
{
ModAssert.True(GameEnvironment.IsRuntime, "visual audition requires the Godot runtime");
var tree = (SceneTree)Engine.GetMainLoop();
var viewport = new SubViewport { Size = new Vector2I(512, 768), TransparentBg = true,
Disable3D = true, RenderTargetUpdateMode = SubViewport.UpdateMode.Always };
var avatar = AvatarCatalog.Create(AvatarId);
var independent = AvatarCatalog.Create(AvatarId);
var definition = Entities.Collect<AvatarDefinition>()[AvatarId];
ModAssert.Equal(10, definition.Groups["reaction"].Presets.Count, "all requested reactions installed");
using var renderer = AvatarRenderers.Create(avatar);
var hashes = new HashSet<string>(StringComparer.Ordinal);
try
{
tree.Root.AddChild(viewport);
viewport.AddChild(renderer.View);
foreach (var preset in definition.Groups["reaction"].Presets.Keys)
{
avatar.ApplyPresets(new Dictionary<string, string> { ["reaction"] = preset });
renderer.Apply(avatar.Resolve());
await tree.ToSignal(RenderingServer.Singleton, RenderingServer.SignalName.FramePostDraw);
using var image = viewport.GetTexture().GetImage();
var visible = 0; var transparent = 0;
for (var y = 0; y < image.GetHeight(); y += 16)
for (var x = 0; x < image.GetWidth(); x += 16)
{
var alpha = image.GetPixel(x, y).A;
if (alpha > 0.9f) visible++;
if (alpha < 0.01f) transparent++;
}
ModAssert.True(visible > 100, preset + ": character has visible pixels");
ModAssert.True(transparent > 100, preset + ": transparent surroundings preserved");
ModAssert.True(hashes.Add(Convert.ToHexString(SHA256.HashData(image.GetData()))),
preset + ": reaction produces a distinct rendered image");
ModAssert.True(image.SavePng(OutputFile(preset + ".render.png")) == Error.Ok,
preset + ": rendered audition exported");
var saved = JsonSerializer.Deserialize<AvatarState>(JsonSerializer.Serialize(avatar.CaptureState()));
var restored = AvatarCatalog.Create(AvatarId, saved);
ModAssert.Equal(preset, restored.CaptureState().Presets["reaction"], "JSON save restores selection");
}
ModAssert.Equal("neutral", independent.CaptureState().Presets["reaction"], "another instance stays unchanged");
}
finally
{
renderer.Dispose(); viewport.GetParent()?.RemoveChild(viewport); viewport.Free();
}
}
[ModTest("marao_avatars/comparison_sheet", TimeoutSeconds = 45)]
public static async Task Comparison(ModTestContext ctx)
{
ModAssert.True(GameEnvironment.IsRuntime, "comparison sheet requires the Godot runtime");
var tree = (SceneTree)Engine.GetMainLoop();
var viewport = new SubViewport { Size = new Vector2I(1440, 960), TransparentBg = false,
Disable3D = true, RenderTargetUpdateMode = SubViewport.UpdateMode.Always };
var renderers = new List<IAvatarRenderer>();
try
{
tree.Root.AddChild(viewport);
viewport.AddChild(new ColorRect { Color = new Color("e4ded4"), Size = new Vector2(1440, 960) });
var index = 0;
foreach (var preset in Entities.Collect<AvatarDefinition>()[AvatarId].Groups["reaction"].Presets.Keys)
{
var x = (index % 5) * 288; var y = (index / 5) * 480;
viewport.AddChild(new ColorRect { Color = new Color(index % 2 == 0 ? "e4ded4" : "c5c6c5"),
Position = new Vector2(x, y), Size = new Vector2(288, 480) });
var avatar = AvatarCatalog.Create(AvatarId);
avatar.ApplyPresets(new Dictionary<string, string> { ["reaction"] = preset });
var renderer = AvatarRenderers.Create(avatar); renderers.Add(renderer);
viewport.AddChild(renderer.View);
renderer.View.Scale = new Vector2(0.5f, 0.5f);
renderer.View.Position = new Vector2(x + 16, y + 24);
var label = new Label { Text = (index + 1) + ". " + preset, Position = new Vector2(x + 12, y + 426),
Size = new Vector2(264, 36), HorizontalAlignment = HorizontalAlignment.Center };
label.AddThemeColorOverride("font_color", new Color("24232b"));
label.AddThemeFontSizeOverride("font_size", 18);
viewport.AddChild(label);
index++;
}
await tree.ToSignal(RenderingServer.Singleton, RenderingServer.SignalName.FramePostDraw);
using var sheet = viewport.GetTexture().GetImage();
ModAssert.True(sheet.SavePng(OutputFile("comparison.png")) == Error.Ok,
"comparison sheet exported from actual avatar renderer");
}
finally
{
foreach (var renderer in renderers) renderer.Dispose();
viewport.GetParent()?.RemoveChild(viewport); viewport.Free();
}
}
}
}
@@ -0,0 +1,134 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using Cthangover.Core.Mods.Resolvers;
using Cthangover.Core.Mods.Testing;
using Cthangover.Core.Utils;
using Cthangover.Mods.Avatars;
using Godot;
namespace Cthangover.Mods.MaraoAvatars.Tests
{
/// <summary>Both content avatars share the same named emotions while retaining independent view/pose state.</summary>
public sealed class PairedAvatarTests
{
private const string Marao = "cthangover/marao_test";
private const string Book = "cthangover/al_azif_test";
private const string Output = "res://gen/marao_azif_avatar_update_20261030/";
private static string OutputFile(string name)
{
// The picture folder may be missing on a fresh checkout (the host has no gen/ subfolder).
var dir = ProjectSettings.GlobalizePath(Output);
DirAccess.MakeDirRecursiveAbsolute(dir);
return dir + name;
}
[ModTest("paired_avatars/common_emotions_render_and_restore", TimeoutSeconds = 60)]
public static async Task CommonEmotions(ModTestContext ctx)
{
ModAssert.True(GameEnvironment.IsRuntime);
var definitions = Entities.Collect<AvatarDefinition>();
var states = definitions[Marao].Groups["emotion"].Presets.Keys.OrderBy(Identity, StringComparer.Ordinal).ToArray();
var bookStates = definitions[Book].Groups["emotion"].Presets.Keys.OrderBy(Identity, StringComparer.Ordinal).ToArray();
ModAssert.Equal(13, states.Length, "twelve shared states plus auto");
ModAssert.Equal(string.Join(",", states), string.Join(",", bookStates), "same public emotion names");
var tree = (SceneTree)Engine.GetMainLoop();
var viewport = new SubViewport { Size = new Vector2I(512, 768), TransparentBg = true,
Disable3D = true, RenderTargetUpdateMode = SubViewport.UpdateMode.Always };
try
{
tree.Root.AddChild(viewport);
foreach (var id in new[] { Marao, Book })
{
var avatar = AvatarCatalog.Create(id);
using var renderer = AvatarRenderers.Create(avatar);
viewport.AddChild(renderer.View);
foreach (var state in states)
{
avatar.ApplyPresets(new Dictionary<string, string> { ["emotion"] = state });
renderer.Apply(avatar.Resolve());
await tree.ToSignal(RenderingServer.Singleton, RenderingServer.SignalName.FramePostDraw);
using var image = viewport.GetTexture().GetImage();
var visible = 0; var transparent = 0;
for (var y = 0; y < 768; y += 24)
for (var x = 0; x < 512; x += 24)
{
var alpha = image.GetPixel(x, y).A;
if (alpha > 0.9f) visible++;
if (alpha < 0.01f) transparent++;
}
ModAssert.True(visible > 40 && transparent > 40, id + "/" + state + ": rendered with alpha");
var saved = JsonSerializer.Deserialize<AvatarState>(JsonSerializer.Serialize(avatar.CaptureState()));
var restored = AvatarCatalog.Create(id, saved);
ModAssert.Equal(state, restored.CaptureState().Presets["emotion"]);
}
}
var book = AvatarCatalog.Create(Book);
foreach (var view in new[] { "front", "angled", "back", "open" })
{
book.ApplyPresets(new Dictionary<string, string> { ["view"] = view, ["emotion"] = "angry_scream" });
using var renderer = AvatarRenderers.Create(book);
viewport.AddChild(renderer.View);
await tree.ToSignal(RenderingServer.Singleton, RenderingServer.SignalName.FramePostDraw);
using var image = viewport.GetTexture().GetImage();
ModAssert.True(image.SavePng(OutputFile("al_azif_" + view + ".render.png")) == Error.Ok);
var state = JsonSerializer.Deserialize<AvatarState>(JsonSerializer.Serialize(book.CaptureState()));
ModAssert.Equal(view, AvatarCatalog.Create(Book, state).CaptureState().Presets["view"]);
}
}
finally { viewport.GetParent()?.RemoveChild(viewport); viewport.Free(); }
}
[ModTest("paired_avatars/emotion_comparison_sheet", TimeoutSeconds = 60)]
public static async Task Comparison(ModTestContext ctx)
{
ModAssert.True(GameEnvironment.IsRuntime);
var tree = (SceneTree)Engine.GetMainLoop();
var viewport = new SubViewport { Size = new Vector2I(1920, 1920), TransparentBg = false,
Disable3D = true, RenderTargetUpdateMode = SubViewport.UpdateMode.Always };
var renderers = new List<IAvatarRenderer>();
try
{
tree.Root.AddChild(viewport);
viewport.AddChild(new ColorRect { Size = new Vector2(1920, 1920), Color = new Color("e4ded4") });
var index = 0;
foreach (var emotion in Entities.Collect<AvatarDefinition>()[Marao].Groups["emotion"].Presets.Keys)
{
if (emotion == "auto") continue;
var x = (index % 3) * 640; var y = (index / 3) * 480;
viewport.AddChild(new ColorRect { Position = new Vector2(x, y), Size = new Vector2(640, 480),
Color = new Color(index % 2 == 0 ? "e4ded4" : "c6c7c5") });
var side = 0;
foreach (var id in new[] { Marao, Book })
{
var avatar = AvatarCatalog.Create(id);
avatar.ApplyPresets(new Dictionary<string, string> { ["emotion"] = emotion });
var renderer = AvatarRenderers.Create(avatar); renderers.Add(renderer);
viewport.AddChild(renderer.View);
renderer.View.Scale = new Vector2(0.5f, 0.5f);
renderer.View.Position = new Vector2(x + 24 + side * 320, y + 24);
side++;
}
var label = new Label { Text = emotion, Position = new Vector2(x + 12, y + 428),
Size = new Vector2(616, 36), HorizontalAlignment = HorizontalAlignment.Center };
label.AddThemeColorOverride("font_color", new Color("24232b"));
label.AddThemeFontSizeOverride("font_size", 22);
viewport.AddChild(label); index++;
}
await tree.ToSignal(RenderingServer.Singleton, RenderingServer.SignalName.FramePostDraw);
using var image = viewport.GetTexture().GetImage();
ModAssert.True(image.SavePng(OutputFile("emotion_comparison.png")) == Error.Ok);
}
finally
{
foreach (var renderer in renderers) renderer.Dispose();
viewport.GetParent()?.RemoveChild(viewport); viewport.Free();
}
}
private static string Identity(string value) => value;
}
}