Files
blockout/BlockOutReplica/Assets/Scripts/Presentation/OriginalBoosterAnimationData.cs
T
2026-10-20 13:21:04 +08:00

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);}}
}
}