using System; using System.Collections.Generic; using UnityEngine; namespace BlockOutReplica.Presentation { /// 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. public sealed class OriginalBoosterAnimationData : ScriptableObject { public string SourceClip; public float Duration; public float SerializedStopTime; public float SampleRate = 60; public Node[] Nodes = Array.Empty(); public SpriteTrack[] SpriteTracks = Array.Empty(); public TransformTrack[] TransformTracks = Array.Empty(); public ScalarTrack[] ScalarTracks = Array.Empty(); public int UnrecoveredParticleSystems; public int UnrecoveredSpriteSkins; public GameObject VisualPrefab; public int RecoveredParticleSystems; public ParticleStart[] ParticleStarts = Array.Empty(); public SourceEvent[] SourceEvents = Array.Empty(); public string EventBindingNote; public string[] UnresolvedSourcePaths = Array.Empty(); public Vector3 SourceRootPosition; public Vector3 SourceRootScale = Vector3.one; public Skin[] Skins = Array.Empty(); [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(); } [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 Transforms = new Dictionary(); public readonly Dictionary Renderers = new Dictionary(); public readonly Dictionary Particles = new Dictionary(); public readonly Dictionary SkinRenderers = new Dictionary(); internal readonly Dictionary SourcePaths = new Dictionary(); internal readonly Dictionary ParticleActivated = new Dictionary(); internal readonly HashSet ExplicitlyTriggered = new HashSet(); internal readonly HashSet AutoStartParticles = new HashSet(); 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;} } } /// 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. 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(true)) group.enabled = false; foreach (Renderer renderer in instance.Root.GetComponentsInChildren(true)) renderer.sortingOrder += sortingOrderBase; var adaptedSources = new HashSet(); 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(); 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() ?? instance.Root.AddComponent(); 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(); 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(); 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); } /// 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. 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)); /// 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. 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;} } /// 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. 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 Materials = new List(); private void OnDestroy() {foreach (var material in Materials) if (material != null) {if (Application.isPlaying) Destroy(material); else DestroyImmediate(material);}} } }