345 lines
20 KiB
C#
345 lines
20 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using UnityEngine;
|
|
|
|
namespace BlockOutReplica.Presentation
|
|
{
|
|
/// <summary>Source sprite layers and curves recovered from the shipped scene/AnimationClips.
|
|
/// Native particle settings/geometry are retained in VisualPrefab. Missing shader programs and
|
|
/// stripped animation-event target lists are handled by explicitly named compatibility adapters.</summary>
|
|
public sealed class OriginalBoosterAnimationData : ScriptableObject
|
|
{
|
|
public string SourceClip;
|
|
public float Duration;
|
|
public float SerializedStopTime;
|
|
public float SampleRate = 60;
|
|
public Node[] Nodes = Array.Empty<Node>();
|
|
public SpriteTrack[] SpriteTracks = Array.Empty<SpriteTrack>();
|
|
public TransformTrack[] TransformTracks = Array.Empty<TransformTrack>();
|
|
public ScalarTrack[] ScalarTracks = Array.Empty<ScalarTrack>();
|
|
public int UnrecoveredParticleSystems;
|
|
public int UnrecoveredSpriteSkins;
|
|
public GameObject VisualPrefab;
|
|
public int RecoveredParticleSystems;
|
|
public ParticleStart[] ParticleStarts = Array.Empty<ParticleStart>();
|
|
public SourceEvent[] SourceEvents = Array.Empty<SourceEvent>();
|
|
public string EventBindingNote;
|
|
public string[] UnresolvedSourcePaths = Array.Empty<string>();
|
|
public Vector3 SourceRootPosition;
|
|
public Vector3 SourceRootScale = Vector3.one;
|
|
public Skin[] Skins = Array.Empty<Skin>();
|
|
|
|
[Serializable] public sealed class ParticleStart { public string Path; public bool PlayOnAwake; }
|
|
[Serializable] public sealed class SourceEvent { public float Time; public string Name; public string[] ReconstructedTargets = Array.Empty<string>(); }
|
|
[Serializable] public sealed class Skin { public string Path; public Mesh Mesh; public Material Material; public string[] BonePaths; }
|
|
|
|
[Serializable] public sealed class Node
|
|
{
|
|
public string Path;
|
|
public Vector3 Position;
|
|
public Quaternion Rotation = Quaternion.identity;
|
|
public Vector3 Scale = Vector3.one;
|
|
public bool Active = true;
|
|
public Sprite Sprite;
|
|
public bool RendererEnabled = true;
|
|
public Color Color = Color.white;
|
|
public int SortingOrder;
|
|
public SpriteDrawMode DrawMode;
|
|
public Vector2 Size;
|
|
public bool FlipX, FlipY;
|
|
}
|
|
|
|
[Serializable] public sealed class SpriteTrack
|
|
{
|
|
public string Path;
|
|
public float[] Times;
|
|
public Sprite[] Sprites;
|
|
public Sprite Evaluate(float time)
|
|
{
|
|
if (Sprites == null || Sprites.Length == 0) return null;
|
|
int index = 0;
|
|
while (index + 1 < Times.Length && time >= Times[index + 1]) index++;
|
|
return Sprites[Mathf.Min(index, Sprites.Length - 1)];
|
|
}
|
|
}
|
|
|
|
[Serializable] public sealed class TransformTrack
|
|
{
|
|
public string Path;
|
|
// Position, Scale, Euler, or Rotation (quaternion).
|
|
public string Property;
|
|
public AnimationCurve X, Y, Z, W;
|
|
}
|
|
|
|
[Serializable] public sealed class ScalarTrack
|
|
{
|
|
public string Path;
|
|
public string Property;
|
|
public AnimationCurve Curve;
|
|
}
|
|
|
|
public sealed class Instance
|
|
{
|
|
public GameObject Root { get; internal set; }
|
|
public readonly Dictionary<string, Transform> Transforms = new Dictionary<string, Transform>();
|
|
public readonly Dictionary<string, SpriteRenderer> Renderers = new Dictionary<string, SpriteRenderer>();
|
|
public readonly Dictionary<string, ParticleSystem> Particles = new Dictionary<string, ParticleSystem>();
|
|
public readonly Dictionary<string, SkinnedMeshRenderer> SkinRenderers = new Dictionary<string, SkinnedMeshRenderer>();
|
|
internal readonly Dictionary<string, string> SourcePaths = new Dictionary<string, string>();
|
|
internal readonly Dictionary<string, bool> ParticleActivated = new Dictionary<string, bool>();
|
|
internal readonly HashSet<string> ExplicitlyTriggered = new HashSet<string>();
|
|
internal readonly HashSet<string> AutoStartParticles = new HashSet<string>();
|
|
internal float SampledTime;
|
|
public int AdaptedBuiltinParticleMaterials { get; internal set; }
|
|
public int LiveParticleCount { get {int count = 0; foreach (var p in Particles.Values) count += p.particleCount; return count;} }
|
|
}
|
|
|
|
/// <summary>All source coordinates are local XY, sprites face local -Z. Parent may rotate to the board plane.
|
|
/// Put board alignment/overall scaling on parent, since Sample resets animated child transforms.</summary>
|
|
public Instance CreateInstance(Transform parent, int sortingOrderBase = 120)
|
|
{
|
|
var instance = new Instance {Root = VisualPrefab != null ? Instantiate(VisualPrefab, parent, false) : new GameObject(name + " Original Sprite Animation")};
|
|
if (VisualPrefab == null) instance.Root.transform.SetParent(parent, false);
|
|
instance.Root.name = name + " Original Recovered Animation";
|
|
instance.Root.SetActive(true);
|
|
instance.Transforms[""] = instance.Root.transform;
|
|
foreach (ParticleStart start in ParticleStarts) if (start.PlayOnAwake) instance.AutoStartParticles.Add(start.Path);
|
|
if (VisualPrefab != null)
|
|
{
|
|
IndexNativeHierarchy(instance, instance.Root.transform, "", "");
|
|
foreach (var group in instance.Root.GetComponentsInChildren<UnityEngine.Rendering.SortingGroup>(true)) group.enabled = false;
|
|
foreach (Renderer renderer in instance.Root.GetComponentsInChildren<Renderer>(true)) renderer.sortingOrder += sortingOrderBase;
|
|
var adaptedSources = new HashSet<string>();
|
|
foreach (var item in instance.Particles)
|
|
{
|
|
var main = item.Value.main; main.playOnAwake = false; main.cullingMode = ParticleSystemCullingMode.AlwaysSimulate;
|
|
item.Value.useAutoRandomSeed = false; item.Value.randomSeed = (uint)(173 + instance.Particles.Count + item.Key.Length * 31);
|
|
item.Value.Stop(false, ParticleSystemStopBehavior.StopEmittingAndClear);
|
|
instance.ParticleActivated[item.Key] = false;
|
|
var sheet = item.Value.textureSheetAnimation;
|
|
var renderer = item.Value.GetComponent<ParticleSystemRenderer>();
|
|
Sprite firstSprite = sheet.mode == ParticleSystemAnimationMode.Sprites && sheet.spriteCount > 0 ? sheet.GetSprite(0) : null;
|
|
Material sourceMaterial = renderer == null ? null : renderer.sharedMaterial;
|
|
bool builtinAdapter = sourceMaterial != null && sourceMaterial.shader != null &&
|
|
!sourceMaterial.shader.name.StartsWith("BlockOut/RecoveredFx", StringComparison.Ordinal);
|
|
if (renderer != null && sourceMaterial != null && (firstSprite != null || builtinAdapter))
|
|
{
|
|
var material = new Material(sourceMaterial);
|
|
if (builtinAdapter)
|
|
{
|
|
// Exported builtin fileID 10308 resolves to an editor material rather than
|
|
// an exported .mat. Merely changing its _MainTex can leave a shader that
|
|
// ignores sprite alpha and displays a solid cyan quad. Keep the original
|
|
// particle geometry, gradients and UV animation, but explicitly adapt the
|
|
// shader just as we do for exported custom materials.
|
|
bool alpha = sourceMaterial.shader.name.IndexOf("Sprites/", StringComparison.OrdinalIgnoreCase) >= 0 ||
|
|
sourceMaterial.shader.name.IndexOf("Alpha", StringComparison.OrdinalIgnoreCase) >= 0;
|
|
Shader compatible = Shader.Find("BlockOut/RecoveredFx" + (alpha ? "Alpha" : "Additive"));
|
|
if (compatible == null) throw new InvalidOperationException("Missing builtin particle shader adapter");
|
|
material.shader = compatible;
|
|
// The source emitter owns the recovered particle color. Do not import the
|
|
// current Editor builtin material's cyan preview tint as original game art.
|
|
material.color = Color.white;
|
|
instance.AdaptedBuiltinParticleMaterials++;
|
|
adaptedSources.Add(sourceMaterial.name + "/" + sourceMaterial.shader.name + " => " + compatible.name);
|
|
}
|
|
if (firstSprite != null) material.mainTexture = firstSprite.texture;
|
|
renderer.sharedMaterial = material;
|
|
var cleanup = instance.Root.GetComponent<OriginalBoosterOwnedMaterials>() ?? instance.Root.AddComponent<OriginalBoosterOwnedMaterials>();
|
|
cleanup.Materials.Add(material);
|
|
}
|
|
}
|
|
if (adaptedSources.Count > 0) Debug.Log("BLOCKOUT_BOOSTER_BUILTIN_MATERIAL_ADAPTER: " + name + ", count=" +
|
|
instance.AdaptedBuiltinParticleMaterials + ", sources=" + string.Join(" | ", adaptedSources));
|
|
foreach (Skin skin in Skins)
|
|
{
|
|
if (!instance.Transforms.TryGetValue(skin.Path, out Transform target)) continue;
|
|
var renderer = target.gameObject.AddComponent<SkinnedMeshRenderer>();
|
|
renderer.sharedMesh = skin.Mesh; renderer.sharedMaterial = skin.Material;
|
|
renderer.updateWhenOffscreen = true;
|
|
var bones = new Transform[skin.BonePaths.Length];
|
|
for (int i = 0; i < bones.Length; i++) bones[i] = instance.Transforms[skin.BonePaths[i]];
|
|
renderer.bones = bones; renderer.rootBone = target;
|
|
if (instance.Renderers.TryGetValue(skin.Path, out SpriteRenderer source))
|
|
{renderer.sortingOrder = source.sortingOrder; source.enabled = false;}
|
|
instance.SkinRenderers[skin.Path] = renderer;
|
|
}
|
|
SamplePose(instance, 0);
|
|
DispatchEvents(instance, -.0001f, 0);
|
|
return instance;
|
|
}
|
|
foreach (Node node in Nodes)
|
|
{
|
|
Transform transform = EnsureNode(instance, node.Path);
|
|
transform.localPosition = node.Position;
|
|
transform.localRotation = node.Rotation;
|
|
transform.localScale = node.Scale;
|
|
transform.gameObject.SetActive(node.Active);
|
|
if (node.Sprite == null) continue;
|
|
var renderer = transform.gameObject.AddComponent<SpriteRenderer>();
|
|
renderer.sprite = node.Sprite;
|
|
renderer.enabled = node.RendererEnabled;
|
|
renderer.color = node.Color;
|
|
renderer.sortingOrder = sortingOrderBase + node.SortingOrder;
|
|
renderer.drawMode = node.DrawMode;
|
|
renderer.size = node.Size;
|
|
renderer.flipX = node.FlipX;
|
|
renderer.flipY = node.FlipY;
|
|
instance.Renderers[node.Path] = renderer;
|
|
}
|
|
Sample(instance, 0);
|
|
return instance;
|
|
}
|
|
|
|
private static void IndexNativeHierarchy(Instance instance, Transform transform, string path, string sourcePath)
|
|
{
|
|
// Native Unity permits same-named siblings. Retain every actual renderer/emitter rather
|
|
// than overwriting the first dictionary entry. Animation paths still address the first,
|
|
// matching Transform.Find; explicit particle cues match the canonical source path.
|
|
if (path.Length > 0 && instance.Transforms.ContainsKey(path))
|
|
{string original = path; int duplicate = 2; while (instance.Transforms.ContainsKey(path)) path = original + "#" + duplicate++;}
|
|
instance.Transforms[path] = transform;
|
|
instance.SourcePaths[path] = sourcePath;
|
|
if (transform.TryGetComponent(out SpriteRenderer sprite)) instance.Renderers[path] = sprite;
|
|
if (transform.TryGetComponent(out ParticleSystem particle)) instance.Particles[path] = particle;
|
|
for (int i = 0; i < transform.childCount; i++)
|
|
{
|
|
Transform child = transform.GetChild(i);
|
|
IndexNativeHierarchy(instance, child, path.Length == 0 ? child.name : path + "/" + child.name,
|
|
sourcePath.Length == 0 ? child.name : sourcePath + "/" + child.name);
|
|
}
|
|
}
|
|
|
|
private static Transform EnsureNode(Instance instance, string path)
|
|
{
|
|
if (instance.Transforms.TryGetValue(path, out Transform found)) return found;
|
|
int slash = path.LastIndexOf('/');
|
|
string parentPath = slash < 0 ? "" : path.Substring(0, slash);
|
|
var result = new GameObject(slash < 0 ? path : path.Substring(slash + 1)).transform;
|
|
result.SetParent(EnsureNode(instance, parentPath), false);
|
|
instance.Transforms[path] = result;
|
|
return result;
|
|
}
|
|
|
|
public void Sample(Instance instance, float time)
|
|
{
|
|
time = Mathf.Clamp(time, 0, Duration);
|
|
if (time + .00001f < instance.SampledTime)
|
|
{
|
|
foreach (var item in instance.Particles)
|
|
{item.Value.Stop(false, ParticleSystemStopBehavior.StopEmittingAndClear); instance.ParticleActivated[item.Key] = false;}
|
|
instance.ExplicitlyTriggered.Clear();
|
|
foreach (Node node in Nodes) if (instance.Transforms.TryGetValue(node.Path, out Transform transform)) transform.gameObject.SetActive(node.Active);
|
|
instance.SampledTime = 0;
|
|
SamplePose(instance, 0);
|
|
DispatchEvents(instance, -.0001f, 0);
|
|
}
|
|
// Explicit substeps retain original activation times and moving-emitter trails even during
|
|
// a slow frame or a deterministic editor seek. Repeated sampling of the same time is paused.
|
|
while (instance.SampledTime + .00001f < time)
|
|
{
|
|
float next = Mathf.Min(time, instance.SampledTime + 1f / 60f);
|
|
SamplePose(instance, next);
|
|
DispatchEvents(instance, instance.SampledTime, next);
|
|
AdvanceParticles(instance, next - instance.SampledTime);
|
|
instance.SampledTime = next;
|
|
}
|
|
SamplePose(instance, time);
|
|
}
|
|
|
|
private void DispatchEvents(Instance instance, float previous, float time)
|
|
{
|
|
foreach (SourceEvent sourceEvent in SourceEvents)
|
|
if (sourceEvent.Time > previous && sourceEvent.Time <= time)
|
|
foreach (string path in sourceEvent.ReconstructedTargets) TriggerParticles(instance, path);
|
|
}
|
|
|
|
/// <summary>Keep native particles moving while holding the last start pose, or while applying an end
|
|
/// clip to the same instance. Pause by not calling this method; it does not use wall-clock time.</summary>
|
|
public void AdvanceParticles(Instance instance, float deltaTime)
|
|
{
|
|
foreach (var item in instance.Particles)
|
|
{
|
|
bool enabled = item.Value.gameObject.activeInHierarchy &&
|
|
(instance.AutoStartParticles.Contains(instance.SourcePaths[item.Key]) || instance.ExplicitlyTriggered.Contains(item.Key));
|
|
bool restart = enabled && !instance.ParticleActivated[item.Key];
|
|
if (enabled && deltaTime > 0) item.Value.Simulate(deltaTime, false, restart, false);
|
|
instance.ParticleActivated[item.Key] = enabled;
|
|
}
|
|
}
|
|
|
|
public void SamplePoseOnly(Instance instance, float time) => SamplePose(instance, Mathf.Clamp(time, 0, Duration));
|
|
|
|
/// <summary>Visibility adapter for the source TimeFreezeBG whose script-controlled opacity is not
|
|
/// serialized as an animation track. This is an explicit reconstruction control, not a recovered curve.</summary>
|
|
public void SetOverlayOpacity(Instance instance, float alpha)
|
|
{
|
|
foreach (SpriteRenderer renderer in instance.Renderers.Values)
|
|
{Color color = renderer.color; color.a = Mathf.Clamp01(alpha); renderer.color = color;}
|
|
}
|
|
|
|
/// <summary>Event adapter hook for stripped script-driven emitters. Native emission/burst modules
|
|
/// remain intact; callers can override the reconstructed event target binding after a playtest.</summary>
|
|
public void TriggerParticles(Instance instance, string path)
|
|
{
|
|
foreach (var item in instance.Particles)
|
|
if (instance.SourcePaths[item.Key] == path || instance.SourcePaths[item.Key].StartsWith(path + "/", StringComparison.Ordinal))
|
|
{item.Value.gameObject.SetActive(true); item.Value.Stop(false, ParticleSystemStopBehavior.StopEmittingAndClear); instance.ParticleActivated[item.Key] = false; instance.ExplicitlyTriggered.Add(item.Key);}
|
|
}
|
|
|
|
private void SamplePose(Instance instance, float time)
|
|
{
|
|
foreach (TransformTrack track in TransformTracks)
|
|
{
|
|
if (!instance.Transforms.TryGetValue(track.Path, out Transform transform)) continue;
|
|
var vector = new Vector3(track.X.Evaluate(time), track.Y.Evaluate(time), track.Z.Evaluate(time));
|
|
switch (track.Property)
|
|
{
|
|
case "Position": transform.localPosition = vector; break;
|
|
case "Scale": transform.localScale = vector; break;
|
|
case "Euler": transform.localRotation = Quaternion.Euler(vector); break;
|
|
case "Rotation":
|
|
transform.localRotation = new Quaternion(vector.x, vector.y, vector.z, track.W.Evaluate(time)).normalized;
|
|
break;
|
|
}
|
|
}
|
|
foreach (SpriteTrack track in SpriteTracks)
|
|
if (instance.Renderers.TryGetValue(track.Path, out SpriteRenderer renderer)) renderer.sprite = track.Evaluate(time);
|
|
foreach (ScalarTrack track in ScalarTracks)
|
|
{
|
|
float value = track.Curve.Evaluate(time);
|
|
if (track.Property == "m_IsActive")
|
|
{
|
|
if (instance.Transforms.TryGetValue(track.Path, out Transform transform)) transform.gameObject.SetActive(value > .5f);
|
|
}
|
|
else if (instance.Renderers.TryGetValue(track.Path, out SpriteRenderer renderer))
|
|
{
|
|
Color color = renderer.color;
|
|
switch (track.Property)
|
|
{
|
|
case "m_Color.a": color.a = value; break;
|
|
case "m_Color.r": color.r = value; break;
|
|
case "m_Color.g": color.g = value; break;
|
|
case "m_Color.b": color.b = value; break;
|
|
case "m_Enabled": renderer.enabled = value > .5f; break;
|
|
case "m_Size.x": renderer.size = new Vector2(value, renderer.size.y); break;
|
|
case "m_Size.y": renderer.size = new Vector2(renderer.size.x, value); break;
|
|
}
|
|
renderer.color = color;
|
|
if (instance.SkinRenderers.TryGetValue(track.Path, out SkinnedMeshRenderer skin))
|
|
{
|
|
var block = new MaterialPropertyBlock(); block.SetColor("_Color", color); skin.SetPropertyBlock(block);
|
|
if (track.Property == "m_Enabled") skin.enabled = value > .5f;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
internal sealed class OriginalBoosterOwnedMaterials : MonoBehaviour
|
|
{
|
|
public readonly List<Material> Materials = new List<Material>();
|
|
private void OnDestroy() {foreach (var material in Materials) if (material != null) {if (Application.isPlaying) Destroy(material); else DestroyImmediate(material);}}
|
|
}
|
|
}
|