Code commit
This commit is contained in:
535
CustomizePlus/Armatures/Services/ArmatureManager.cs
Normal file
535
CustomizePlus/Armatures/Services/ArmatureManager.cs
Normal file
@@ -0,0 +1,535 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Dalamud.Plugin.Services;
|
||||
using OtterGui.Log;
|
||||
using OtterGui.Classes;
|
||||
using Penumbra.GameData.Actors;
|
||||
using System.Numerics;
|
||||
using CustomizePlus.Core.Data;
|
||||
using CustomizePlus.Armatures.Events;
|
||||
using CustomizePlus.Armatures.Data;
|
||||
using CustomizePlus.Profiles;
|
||||
using CustomizePlus.Profiles.Data;
|
||||
using CustomizePlus.Game.Services;
|
||||
using CustomizePlus.Templates.Events;
|
||||
using CustomizePlus.Profiles.Events;
|
||||
using CustomizePlus.Core.Extensions;
|
||||
using CustomizePlus.GameData.Data;
|
||||
using CustomizePlus.GameData.Services;
|
||||
using CustomizePlus.GameData.Extensions;
|
||||
|
||||
namespace CustomizePlus.Armatures.Services;
|
||||
|
||||
public unsafe sealed class ArmatureManager : IDisposable
|
||||
{
|
||||
private readonly ProfileManager _profileManager;
|
||||
private readonly IObjectTable _objectTable;
|
||||
private readonly GameObjectService _gameObjectService;
|
||||
private readonly TemplateChanged _templateChangedEvent;
|
||||
private readonly ProfileChanged _profileChangedEvent;
|
||||
private readonly Logger _logger;
|
||||
private readonly FrameworkManager _framework;
|
||||
private readonly ObjectManager _objectManager;
|
||||
private readonly ActorService _actorService;
|
||||
private readonly ArmatureChanged _event;
|
||||
|
||||
public Dictionary<ActorIdentifier, Armature> Armatures { get; private set; } = new();
|
||||
|
||||
public ArmatureManager(
|
||||
ProfileManager profileManager,
|
||||
IObjectTable objectTable,
|
||||
GameObjectService gameObjectService,
|
||||
TemplateChanged templateChangedEvent,
|
||||
ProfileChanged profileChangedEvent,
|
||||
Logger logger,
|
||||
FrameworkManager framework,
|
||||
ObjectManager objectManager,
|
||||
ActorService actorService,
|
||||
ArmatureChanged @event)
|
||||
{
|
||||
_profileManager = profileManager;
|
||||
_objectTable = objectTable;
|
||||
_gameObjectService = gameObjectService;
|
||||
_templateChangedEvent = templateChangedEvent;
|
||||
_profileChangedEvent = profileChangedEvent;
|
||||
_logger = logger;
|
||||
_framework = framework;
|
||||
_objectManager = objectManager;
|
||||
_actorService = actorService;
|
||||
_event = @event;
|
||||
|
||||
_templateChangedEvent.Subscribe(OnTemplateChange, TemplateChanged.Priority.ArmatureManager);
|
||||
_profileChangedEvent.Subscribe(OnProfileChange, ProfileChanged.Priority.ArmatureManager);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_templateChangedEvent.Unsubscribe(OnTemplateChange);
|
||||
_profileChangedEvent.Unsubscribe(OnProfileChange);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Main rendering function, called from rendering hook
|
||||
/// </summary>
|
||||
public void OnRender()
|
||||
{
|
||||
try
|
||||
{
|
||||
RefreshArmatures();
|
||||
ApplyArmatureTransforms();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Exception while rendering armatures:\n\t{ex}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Function called when game object movement is detected
|
||||
/// </summary>
|
||||
public void OnGameObjectMove(Actor actor)
|
||||
{
|
||||
if (!actor.Identifier(_actorService.AwaitedService, out var identifier))
|
||||
return;
|
||||
|
||||
if (Armatures.TryGetValue(identifier, out var armature) && armature.IsBuilt && armature.IsVisible)
|
||||
ApplyRootTranslation(armature, actor);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes armatures which no longer have actor associated with them and creates armatures for new actors
|
||||
/// </summary>
|
||||
private void RefreshArmatures()
|
||||
{
|
||||
_objectManager.Update();
|
||||
|
||||
var currentTime = DateTime.UtcNow;
|
||||
foreach (var kvPair in Armatures.ToList())
|
||||
{
|
||||
var armature = kvPair.Value;
|
||||
if (!_objectManager.ContainsKey(kvPair.Value.ActorIdentifier) &&
|
||||
currentTime > armature.ProtectedUntil) //Only remove armatures which are no longer protected
|
||||
{
|
||||
_logger.Debug($"Removing armature {armature} because {kvPair.Key.IncognitoDebug()} is gone");
|
||||
RemoveArmature(armature, ArmatureChanged.DeletionReason.Gone);
|
||||
}
|
||||
}
|
||||
|
||||
Profile? GetProfileForActor(ActorIdentifier identifier)
|
||||
{
|
||||
foreach (var profile in _profileManager.GetEnabledProfilesByActor(identifier))
|
||||
{
|
||||
if (profile.LimitLookupToOwnedObjects &&
|
||||
identifier.Type == IdentifierType.Owned &&
|
||||
identifier.PlayerName != _objectManager.PlayerData.Identifier.PlayerName)
|
||||
continue;
|
||||
|
||||
return profile;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var obj in _objectManager)
|
||||
{
|
||||
if (!Armatures.ContainsKey(obj.Key))
|
||||
{
|
||||
var activeProfile = GetProfileForActor(obj.Key);
|
||||
if (activeProfile == null)
|
||||
continue;
|
||||
|
||||
var newArm = new Armature(obj.Key, activeProfile);
|
||||
TryLinkSkeleton(newArm);
|
||||
Armatures.Add(obj.Key, newArm);
|
||||
_logger.Debug($"Added '{newArm}' for {obj.Key.IncognitoDebug()} to cache");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
var armature = Armatures[obj.Key];
|
||||
|
||||
if (armature.IsPendingProfileRebind)
|
||||
{
|
||||
_logger.Debug($"Armature {armature} is pending profile rebind, rebinding...");
|
||||
armature.IsPendingProfileRebind = false;
|
||||
|
||||
var activeProfile = GetProfileForActor(obj.Key);
|
||||
if (activeProfile == armature.Profile)
|
||||
continue;
|
||||
|
||||
if (activeProfile == null)
|
||||
{
|
||||
_logger.Debug($"Removing armature {armature} because it doesn't have any active profiles");
|
||||
RemoveArmature(armature, ArmatureChanged.DeletionReason.NoActiveProfiles);
|
||||
continue;
|
||||
}
|
||||
|
||||
armature.Profile.Armatures.Remove(armature);
|
||||
armature.Profile = activeProfile;
|
||||
activeProfile.Armatures.Add(armature);
|
||||
armature.RebuildBoneTemplateBinding();
|
||||
|
||||
}
|
||||
|
||||
armature.IsVisible = armature.Profile.Enabled && TryLinkSkeleton(armature); //todo: remove armatures which are not visible?
|
||||
}
|
||||
}
|
||||
|
||||
private unsafe void ApplyArmatureTransforms()
|
||||
{
|
||||
foreach (var kvPair in Armatures)
|
||||
{
|
||||
var armature = kvPair.Value;
|
||||
if (armature.IsBuilt && armature.IsVisible && _objectManager.ContainsKey(armature.ActorIdentifier))
|
||||
{
|
||||
foreach (var actor in _objectManager[armature.ActorIdentifier].Objects)
|
||||
ApplyPiecewiseTransformation(armature, actor, armature.ActorIdentifier);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns whether or not a link can be established between the armature and an in-game object.
|
||||
/// If unbuilt, the armature will be rebuilded.
|
||||
/// </summary>
|
||||
private bool TryLinkSkeleton(Armature armature, bool forceRebuild = false)
|
||||
{
|
||||
_objectManager.Update();
|
||||
|
||||
try
|
||||
{
|
||||
if (!_objectManager.ContainsKey(armature.ActorIdentifier))
|
||||
return false;
|
||||
|
||||
var actor = _objectManager[armature.ActorIdentifier].Objects[0];
|
||||
|
||||
if (!armature.IsBuilt || forceRebuild)
|
||||
{
|
||||
armature.RebuildSkeleton(actor.Model.AsCharacterBase);
|
||||
}
|
||||
else if (armature.NewBonesAvailable(actor.Model.AsCharacterBase))
|
||||
{
|
||||
armature.AugmentSkeleton(actor.Model.AsCharacterBase);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// This is on wait until isse #191 on Github responds. Keeping it in code, delete it if I forget and this is longer then a month ago.
|
||||
|
||||
// Disabling this if its any Default Profile due to Log spam. A bit crazy but hey, if its for me id Remove Default profiles all together so this is as much as ill do for now! :)
|
||||
//if(!(Profile.CharacterName.Equals(Constants.DefaultProfileCharacterName) || Profile.CharacterName.Equals("DefaultCutscene"))) {
|
||||
_logger.Error($"Error occured while attempting to link skeleton: {armature}");
|
||||
throw;
|
||||
//}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Iterate through the skeleton of the given character base, and apply any transformations
|
||||
/// for which this armature contains corresponding model bones. This method of application
|
||||
/// is safer but more computationally costly
|
||||
/// </summary>
|
||||
private void ApplyPiecewiseTransformation(Armature armature, Actor actor, ActorIdentifier actorIdentifier)
|
||||
{
|
||||
var cBase = actor.Model.AsCharacterBase;
|
||||
|
||||
var isMount = actorIdentifier.Type == IdentifierType.Owned &&
|
||||
actorIdentifier.Kind == Dalamud.Game.ClientState.Objects.Enums.ObjectKind.MountType;
|
||||
|
||||
Actor? mountOwner = null;
|
||||
Armature? mountOwnerArmature = null;
|
||||
if (isMount)
|
||||
{
|
||||
(var ident, mountOwner) = _gameObjectService.FindActorsByName(actorIdentifier.PlayerName.ToString()).FirstOrDefault();
|
||||
Armatures.TryGetValue(ident, out mountOwnerArmature);
|
||||
}
|
||||
|
||||
if (cBase != null)
|
||||
{
|
||||
for (var pSkeleIndex = 0; pSkeleIndex < cBase->Skeleton->PartialSkeletonCount; ++pSkeleIndex)
|
||||
{
|
||||
var currentPose = cBase->Skeleton->PartialSkeletons[pSkeleIndex].GetHavokPose(Constants.TruePoseIndex);
|
||||
|
||||
if (currentPose != null)
|
||||
{
|
||||
for (var boneIndex = 0; boneIndex < currentPose->Skeleton->Bones.Length; ++boneIndex)
|
||||
{
|
||||
if (armature.GetBoneAt(pSkeleIndex, boneIndex) is ModelBone mb
|
||||
&& mb != null
|
||||
&& mb.BoneName == currentPose->Skeleton->Bones[boneIndex].Name.String)
|
||||
{
|
||||
if (mb == armature.MainRootBone)
|
||||
{
|
||||
if (_gameObjectService.IsActorHasScalableRoot(actor) && mb.IsModifiedScale())
|
||||
{
|
||||
cBase->DrawObject.Object.Scale = mb.CustomizedTransform!.Scaling;
|
||||
|
||||
//Fix mount owner's scale if needed
|
||||
//todo: always keep owner's scale proper instead of scaling with mount if no armature found
|
||||
if (isMount && mountOwner != null && mountOwnerArmature != null)
|
||||
{
|
||||
var ownerDrawObject = cBase->DrawObject.Object.ChildObject;
|
||||
|
||||
//limit to only modified scales because that is just easier to handle
|
||||
//because we don't need to hook into dismount code to reset character scale
|
||||
//todo: hook into dismount
|
||||
//https://github.com/Cytraen/SeatedSidekickSpectator/blob/main/SetModeHook.cs?
|
||||
if (cBase->DrawObject.Object.ChildObject == mountOwner.Value.Model &&
|
||||
mountOwnerArmature.MainRootBone.IsModifiedScale())
|
||||
{
|
||||
var baseScale = mountOwnerArmature.MainRootBone.CustomizedTransform!.Scaling;
|
||||
|
||||
ownerDrawObject->Scale = new Vector3(Math.Abs(baseScale.X / cBase->DrawObject.Object.Scale.X),
|
||||
Math.Abs(baseScale.Y / cBase->DrawObject.Object.Scale.Y),
|
||||
Math.Abs(baseScale.Z / cBase->DrawObject.Object.Scale.Z));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
mb.ApplyModelTransform(cBase);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyRootTranslation(Armature arm, Actor actor)
|
||||
{
|
||||
//I'm honestly not sure if we should or even can check if cBase->DrawObject or cBase->DrawObject.Object is a valid object
|
||||
//So for now let's assume we don't need to check for that
|
||||
|
||||
var cBase = actor.Model.AsCharacterBase;
|
||||
if (cBase != null)
|
||||
{
|
||||
var rootBoneTransform = arm.GetAppliedBoneTransform("n_root");
|
||||
if (rootBoneTransform == null)
|
||||
return;
|
||||
|
||||
if (rootBoneTransform.Translation.X == 0 &&
|
||||
rootBoneTransform.Translation.Y == 0 &&
|
||||
rootBoneTransform.Translation.Z == 0)
|
||||
return;
|
||||
|
||||
if (!cBase->DrawObject.IsVisible)
|
||||
return;
|
||||
|
||||
var newPosition = new FFXIVClientStructs.FFXIV.Common.Math.Vector3
|
||||
{
|
||||
X = cBase->DrawObject.Object.Position.X + MathF.Max(rootBoneTransform.Translation.X, 0.01f),
|
||||
Y = cBase->DrawObject.Object.Position.Y + MathF.Max(rootBoneTransform.Translation.Y, 0.01f),
|
||||
Z = cBase->DrawObject.Object.Position.Z + MathF.Max(rootBoneTransform.Translation.Z, 0.01f)
|
||||
};
|
||||
|
||||
cBase->DrawObject.Object.Position = newPosition;
|
||||
}
|
||||
}
|
||||
|
||||
private void RemoveArmature(Armature armature, ArmatureChanged.DeletionReason reason)
|
||||
{
|
||||
armature.Profile.Armatures.Remove(armature);
|
||||
Armatures.Remove(armature.ActorIdentifier);
|
||||
_logger.Debug($"Armature {armature} removed from cache");
|
||||
|
||||
_event.Invoke(ArmatureChanged.Type.Deleted, armature, reason);
|
||||
}
|
||||
|
||||
private void OnTemplateChange(TemplateChanged.Type type, Templates.Data.Template? template, object? arg3)
|
||||
{
|
||||
if (type is not TemplateChanged.Type.NewBone &&
|
||||
type is not TemplateChanged.Type.DeletedBone &&
|
||||
type is not TemplateChanged.Type.EditorCharacterChanged &&
|
||||
type is not TemplateChanged.Type.EditorEnabled &&
|
||||
type is not TemplateChanged.Type.EditorDisabled)
|
||||
return;
|
||||
|
||||
if (type == TemplateChanged.Type.NewBone ||
|
||||
type == TemplateChanged.Type.DeletedBone) //type == TemplateChanged.Type.EditorCharacterChanged?
|
||||
{
|
||||
//In case a lot of events are triggered at the same time for the same template this should limit the amount of times bindings are unneccessary rebuilt
|
||||
_framework.RegisterImportant($"TemplateRebuild @ {template.UniqueId}", () =>
|
||||
{
|
||||
foreach (var profile in _profileManager.GetProfilesUsingTemplate(template))
|
||||
{
|
||||
_logger.Debug($"ArmatureManager.OnTemplateChange New/Deleted bone or character changed: {type}, template: {template.Name.Text.Incognify()}, profile: {profile.Name.Text.Incognify()}->{profile.Enabled}->{profile.Armatures.Count} armatures");
|
||||
if (!profile.Enabled || profile.Armatures.Count == 0)
|
||||
continue;
|
||||
|
||||
profile.Armatures.ForEach(x => x.RebuildBoneTemplateBinding());
|
||||
}
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (type == TemplateChanged.Type.EditorCharacterChanged)
|
||||
{
|
||||
(var characterName, var profile) = ((string, Profile))arg3;
|
||||
|
||||
foreach (var armature in GetArmaturesForCharacterName(characterName))
|
||||
{
|
||||
armature.IsPendingProfileRebind = true;
|
||||
_logger.Debug($"ArmatureManager.OnTemplateChange Editor profile character name changed, armature rebind scheduled: {type}, {armature}");
|
||||
}
|
||||
|
||||
if (profile.Armatures.Count == 0)
|
||||
return;
|
||||
|
||||
//Rebuild armatures for previous character
|
||||
foreach (var armature in profile.Armatures)
|
||||
armature.IsPendingProfileRebind = true;
|
||||
|
||||
_logger.Debug($"ArmatureManager.OnTemplateChange Editor profile character name changed, armature rebind scheduled: {type}, profile: {profile.Name.Text.Incognify()}->{profile.Enabled}, new name: {characterName.Incognify()}");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (type == TemplateChanged.Type.EditorEnabled ||
|
||||
type == TemplateChanged.Type.EditorDisabled)
|
||||
{
|
||||
foreach (var armature in GetArmaturesForCharacterName((string)arg3!))
|
||||
{
|
||||
armature.IsPendingProfileRebind = true;
|
||||
_logger.Debug($"ArmatureManager.OnTemplateChange template editor enabled/disabled: {type}, pending profile set for {armature}");
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnProfileChange(ProfileChanged.Type type, Profile? profile, object? arg3)
|
||||
{
|
||||
if (type is not ProfileChanged.Type.AddedTemplate &&
|
||||
type is not ProfileChanged.Type.RemovedTemplate &&
|
||||
type is not ProfileChanged.Type.MovedTemplate &&
|
||||
type is not ProfileChanged.Type.ChangedTemplate &&
|
||||
type is not ProfileChanged.Type.Toggled &&
|
||||
type is not ProfileChanged.Type.Deleted &&
|
||||
type is not ProfileChanged.Type.TemporaryProfileAdded &&
|
||||
type is not ProfileChanged.Type.TemporaryProfileDeleted &&
|
||||
type is not ProfileChanged.Type.ChangedCharacterName &&
|
||||
type is not ProfileChanged.Type.ChangedDefaultProfile &&
|
||||
type is not ProfileChanged.Type.LimitLookupToOwnedChanged)
|
||||
return;
|
||||
|
||||
if (type == ProfileChanged.Type.ChangedDefaultProfile)
|
||||
{
|
||||
var oldProfile = (Profile?)arg3;
|
||||
|
||||
if (oldProfile == null || oldProfile.Armatures.Count == 0)
|
||||
return;
|
||||
|
||||
foreach (var armature in oldProfile.Armatures)
|
||||
armature.IsPendingProfileRebind = true;
|
||||
|
||||
_logger.Debug($"ArmatureManager.OnProfileChange Profile no longer default, armatures rebind scheduled: {type}, old profile: {oldProfile.Name.Text.Incognify()}->{oldProfile.Enabled}");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (profile == null)
|
||||
{
|
||||
_logger.Error($"ArmatureManager.OnProfileChange Invalid input for event: {type}, profile is null.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (type == ProfileChanged.Type.Toggled)
|
||||
{
|
||||
if (!profile.Enabled && profile.Armatures.Count == 0)
|
||||
return;
|
||||
|
||||
if (profile == _profileManager.DefaultProfile)
|
||||
{
|
||||
foreach (var kvPair in Armatures)
|
||||
{
|
||||
var armature = kvPair.Value;
|
||||
if (armature.Profile == profile)
|
||||
armature.IsPendingProfileRebind = true;
|
||||
|
||||
_logger.Debug($"ArmatureManager.OnProfileChange default profile toggled, planning rebind for armature {armature}");
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(profile.CharacterName))
|
||||
return;
|
||||
|
||||
foreach (var armature in GetArmaturesForCharacterName(profile.CharacterName))
|
||||
{
|
||||
armature.IsPendingProfileRebind = true;
|
||||
_logger.Debug($"ArmatureManager.OnProfileChange profile {profile} toggled, planning rebind for armature {armature}");
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (type == ProfileChanged.Type.TemporaryProfileAdded)
|
||||
{
|
||||
if (!profile.TemporaryActor.IsValid || !Armatures.ContainsKey(profile.TemporaryActor))
|
||||
return;
|
||||
|
||||
var armature = Armatures[profile.TemporaryActor];
|
||||
if (armature.Profile == profile)
|
||||
return;
|
||||
|
||||
armature.ProtectFromRemoval();
|
||||
|
||||
armature.IsPendingProfileRebind = true;
|
||||
|
||||
_logger.Debug($"ArmatureManager.OnProfileChange TemporaryProfileAdded, calling rebind for existing armature: {type}, data payload: {arg3?.ToString()}, profile: {profile.Name.Text.Incognify()}->{profile.Enabled}");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (type == ProfileChanged.Type.ChangedCharacterName ||
|
||||
type == ProfileChanged.Type.Deleted ||
|
||||
type == ProfileChanged.Type.TemporaryProfileDeleted ||
|
||||
type == ProfileChanged.Type.LimitLookupToOwnedChanged)
|
||||
{
|
||||
if (profile.Armatures.Count == 0)
|
||||
return;
|
||||
|
||||
foreach (var armature in profile.Armatures)
|
||||
{
|
||||
if (type == ProfileChanged.Type.TemporaryProfileDeleted)
|
||||
armature.ProtectFromRemoval(); //just to be safe
|
||||
|
||||
armature.IsPendingProfileRebind = true;
|
||||
}
|
||||
|
||||
_logger.Debug($"ArmatureManager.OnProfileChange CCN/DEL/TPD/LLTOC, armature rebind scheduled: {type}, data payload: {arg3?.ToString()?.Incognify()}, profile: {profile.Name.Text.Incognify()}->{profile.Enabled}");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
//todo: shouldn't happen, but happens sometimes? I think?
|
||||
if (profile.Armatures.Count == 0)
|
||||
return;
|
||||
|
||||
_logger.Debug($"ArmatureManager.OnProfileChange Added/Deleted/Moved/Changed template: {type}, data payload: {arg3?.ToString()}, profile: {profile.Name}->{profile.Enabled}->{profile.Armatures.Count} armatures");
|
||||
|
||||
profile!.Armatures.ForEach(x => x.RebuildBoneTemplateBinding());
|
||||
}
|
||||
|
||||
private IEnumerable<Armature> GetArmaturesForCharacterName(string characterName)
|
||||
{
|
||||
var actors = _gameObjectService.FindActorsByName(characterName).ToList();
|
||||
if (actors.Count == 0)
|
||||
yield break;
|
||||
|
||||
foreach (var actorData in actors)
|
||||
{
|
||||
if (!Armatures.TryGetValue(actorData.Item1, out var armature))
|
||||
continue;
|
||||
|
||||
yield return armature;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user