SharedVRCStuff/Music Player/Editor/MusicPlayer.cs

231 lines
10 KiB
C#

#if UNITY_EDITOR
using UnityEngine;
using System.Collections.Generic;
using VRC.SDK3.Avatars.ScriptableObjects;
using nadena.dev.ndmf;
using gay.lilyy.MusicPlayer.Editor;
using AnimatorAsCode.V1;
using VRC.SDK3.Avatars.Components;
using AnimatorAsCode.V1.ModularAvatar;
using UnityEditor;
using AnimatorAsCode.V1.VRC;
using NUnit.Framework.Constraints;
using System.Linq;
using System.Threading.Tasks;
using nadena.dev.modular_avatar.core;
[assembly: ExportsPlugin(typeof(MusicPlayerPlugin))]
namespace gay.lilyy.MusicPlayer.Editor
{
public static class ParameterNames
{
public const string SongIndex = "Music_SongIndex";
public const string Volume = "Music_Volume";
public const string IsLocal = "Music_IsLocal";
}
public static class LayerNames
{
public const string Volume = "Music_Volume";
public const string Player = "Music_Player";
}
public class MusicPlayerPlugin : Plugin<MusicPlayerPlugin>
{
public override string QualifiedName => "gay.lilyy.MusicPlayer";
public override string DisplayName => "Music Player";
private const string SystemName = "MusicPlayer";
private const bool UseWriteDefaults = true;
protected override void Configure()
{
InPhase(BuildPhase.Generating).Run($"Generate {DisplayName}", Generate);
}
private void Generate(BuildContext ctx)
{
// Find all components of type ExampleToggle in this avatar.
var songs = ctx.AvatarRootTransform.GetComponentsInChildren<MusicPlayerSong>();
if (songs.Length == 0) return; // If there are none in the avatar, skip this entirely.
var songGo = songs[0].gameObject;
int trackNumber = 0;
foreach (var song in songs)
{
if (song.gameObject != songGo)
{
throw new System.Exception("All MusicPlayerSong components must be on the same GameObject.");
}
trackNumber++;
song.TrackNumber = trackNumber;
}
var audioSource = songGo.GetComponent<AudioSource>();
// Initialize Animator As Code.
var modularAvatar = MaAc.Create(songGo);
var controller = CreateController(ctx, modularAvatar, songs, audioSource);
foreach (var song in songs)
{
UnityEngine.Object.DestroyImmediate(song);
}
// Create a new object in the scene. We will add Modular Avatar components inside it.
// By creating a Modular Avatar Merge Animator component,
// our animator controller will be added to the avatar's FX layer.
modularAvatar.NewMergeAnimator(controller.AnimatorController, VRCAvatarDescriptor.AnimLayerType.FX);
Debug.Log("Added music player");
}
private AacFlController CreateController(BuildContext ctx, MaAc modularAvatar, MusicPlayerSong[] songs, AudioSource audioSource)
{
var aac = AacV1.Create(new AacConfiguration
{
SystemName = SystemName,
AnimatorRoot = ctx.AvatarRootTransform,
DefaultValueRoot = ctx.AvatarRootTransform,
AssetKey = GUID.Generate().ToString(),
AssetContainer = ctx.AssetContainer,
ContainerMode = AacConfiguration.Container.OnlyWhenPersistenceRequired,
// (For AAC 1.2.0 and above) The next line is recommended starting from NDMF 1.6.0.
// If you use a lower version of NDMF or if you don't use it, remove that line.
AssetContainerProvider = new NDMFContainerProvider(ctx),
// States will be created with Write Defaults set to ON or OFF based on whether UseWriteDefaults is true or false.
DefaultsProvider = new AacDefaultsProvider(UseWriteDefaults)
});
// Create a new animator controller.
// This will be merged with the rest of the playable layer at the end of this function.
var controller = aac.NewAnimatorController();
var songIndexParam = CreatePlayerLayer(controller, modularAvatar, songs, audioSource);
var volumeParam = CreateVolumeLayer(aac, controller, modularAvatar, audioSource);
CreateMenu(songIndexParam, volumeParam, modularAvatar, songs);
return controller;
}
private void CreateMenu(AacFlIntParameter songIndexParam, AacFlFloatParameter volumeParam, MaAc modularAvatar, MusicPlayerSong[] songs) {
var menuGo = new GameObject("Menu");
menuGo.transform.SetParent(songs[0].transform);
var menuItem = menuGo.AddComponent<ModularAvatarMenuItem>();
menuItem.label = "Music";
menuItem.Control.type = VRCExpressionsMenu.Control.ControlType.SubMenu;
menuItem.MenuSource = SubmenuSource.Children;
menuGo.AddComponent<ModularAvatarMenuInstaller>();
// Dictionary to store folder hierarchy
var folderHierarchy = new Dictionary<string, GameObject>();
foreach (var song in songs)
{
GameObject currentParent = menuGo;
// Create folder hierarchy if specified
if (!string.IsNullOrEmpty(song.Folder))
{
string[] folderPath = song.Folder.Split('/');
// Create nested folder structure
for (int i = 0; i < folderPath.Length; i++)
{
string folderName = folderPath[i];
string fullPath = string.Join("/", folderPath, 0, i + 1);
if (!folderHierarchy.ContainsKey(fullPath))
{
var folderGo = new GameObject($"Folder_{folderName}");
folderGo.transform.SetParent(currentParent.transform);
var folderMenuItem = folderGo.AddComponent<ModularAvatarMenuItem>();
folderMenuItem.label = folderName;
folderMenuItem.Control.type = VRCExpressionsMenu.Control.ControlType.SubMenu;
folderMenuItem.MenuSource = SubmenuSource.Children;
if (song.icon != null) folderMenuItem.Control.icon = song.icon;
folderHierarchy[fullPath] = folderGo;
}
currentParent = folderHierarchy[fullPath];
}
}
// Create the song toggle
var go = new GameObject($"Song_{song.Name}");
go.transform.SetParent(currentParent.transform);
var toggle = modularAvatar.EditMenuItem(go).ToggleSets(songIndexParam, song.TrackNumber).Name(song.Name);
if (song.icon != null) toggle.WithIcon(song.icon);
}
var volumeGo = new GameObject("Volume");
volumeGo.transform.SetParent(menuGo.transform);
modularAvatar.EditMenuItem(volumeGo).Radial(volumeParam).Name("Volume");
}
private AacFlIntParameter CreatePlayerLayer(AacFlController controller, MaAc modularAvatar, MusicPlayerSong[] songs, AudioSource audioSource)
{
var playerLayer = controller.NewLayer(LayerNames.Player);
var songIndexParam = playerLayer.IntParameter(ParameterNames.SongIndex);
modularAvatar.NewParameter(songIndexParam).NotSaved();
var isLocal = playerLayer.Av3().IsLocal;
var nonLocalState = playerLayer.NewState("NonLocal");
var idleState = playerLayer.NewState("Idle");
nonLocalState.TransitionsTo(idleState).When(isLocal.IsEqualTo(true));
// will never appear ingame, useful for testing
idleState.TransitionsTo(nonLocalState).When(isLocal.IsEqualTo(false));
foreach (var song in songs)
{
var songState = playerLayer.NewState($"Song ${song.Name}");
songState.TransitionsTo(idleState).When(songIndexParam.IsNotEqualTo(song.TrackNumber));
// again, will never happen ingame. still useful
songState.TransitionsTo(nonLocalState).When(isLocal.IsEqualTo(false));
idleState.TransitionsTo(songState).When(songIndexParam.IsEqualTo(song.TrackNumber));
songState.Audio(audioSource, (audio) =>
{
audio.SelectsClip(0, new List<AudioClip> { song.song }.ToArray() );
audio.StopsPlayingOnExit();
audio.StartsPlayingOnEnter();
});
}
return songIndexParam;
}
private AacFlFloatParameter CreateVolumeLayer(AacFlBase aac, AacFlController controller, MaAc modularAvatar, AudioSource audioSource) {
var songVolumeLayer = controller.NewLayer(LayerNames.Volume);
var volumeParam = songVolumeLayer.FloatParameter(ParameterNames.Volume);
modularAvatar.NewParameter(volumeParam).WithDefaultValue(0.2f);
var clip = aac.NewClip().Animating((action) => {
var animate = action.Animates(audioSource, "m_Volume");
animate.WithSecondsUnit((seconds) => {
seconds.Linear(0, 0);
seconds.Linear(1, 0.3f);
});
});
var state = songVolumeLayer.NewState("Volume");
state.WithAnimation(clip).WithMotionTime(volumeParam);
return volumeParam;
}
}
internal class NDMFContainerProvider : IAacAssetContainerProvider
{
private readonly BuildContext _ctx;
public NDMFContainerProvider(BuildContext ctx) => _ctx = ctx;
public void SaveAsPersistenceRequired(UnityEngine.Object objectToAdd) => _ctx.AssetSaver.SaveAsset(objectToAdd);
public void SaveAsRegular(UnityEngine.Object objectToAdd) { } // Let NDMF crawl our assets when it finishes
public void ClearPreviousAssets() { } // ClearPreviousAssets is never used in non-destructive contexts
}
}
#endif