using System; using System.Collections.Generic; using BumpCombat.Core; using BumpCombat.Player; using UnityEngine; namespace BumpCombat.Combat { [DisallowMultipleComponent] [DefaultExecutionOrder(1000)] public sealed class ArtifactChargeVisual : MonoBehaviour { public const int FrameCount = 6; public const int FlashFrameCount = 4; public const int CellSize = 64; public const float PixelsPerUnit = 32f; public const float ChargeVisualY = 0f; public const float ReadyFlashDuration = 0.14f; public const float ReleaseVisualDuration = 0.24f; public const float SwitchVisualDuration = 0.36f; private const int AuraSortingOffset = -1; private const int ReadyAuraSortingOffset = -1; private const int FlashSortingOffset = 1; private const int ReleaseSortingOffset = 1; private const string ResourceRoot = "Artifacts/ThreeColor-v1/Charge/"; private const string AuraSuffix = "-Aura-v1"; private const string ReadyAuraSuffix = "-ReadyAura-v1"; private const string FlashSuffix = "-Flash-v1"; private const string ReleaseSuffix = "-Release-v1"; private static readonly float[] AuraFrameDurations = { 0.06f, 0.06f, 0.06f, 0.06f, 0.06f, 0.06f, }; private static readonly float[] ReadyAuraFrameDurations = { 0.06f, 0.06f, 0.06f, 0.06f, 0.06f, 0.06f, }; private static readonly float[] FlashFrameDurations = { 0.03f, 0.04f, 0.04f, 0.03f, }; private static readonly float[] ReleaseFrameDurations = { 0.03f, 0.04f, 0.04f, 0.04f, 0.04f, 0.05f, }; private sealed class CachedFrames { public CachedFrames(Sprite[] frames, bool ownsSprites) { Frames = frames; OwnsSprites = ownsSprites; } public Sprite[] Frames { get; } public bool OwnsSprites { get; } } private sealed class VisualSlot { public VisualSlot( string objectName, int sortingOffset, float[] frameDurations) { ObjectName = objectName; SortingOffset = sortingOffset; FrameDurations = frameDurations; } public string ObjectName { get; } public int SortingOffset { get; } public float[] FrameDurations { get; } public Sprite[] Frames; public GameObject Root; public SpriteRenderer Renderer; public int FrameIndex; public float FrameElapsed; public bool Loop; } private static readonly Dictionary FrameCache = new(); private readonly VisualSlot aura = new( "Artifact Charge Aura", AuraSortingOffset, AuraFrameDurations); private readonly VisualSlot readyAura = new( "Artifact Charge Ready Aura", ReadyAuraSortingOffset, ReadyAuraFrameDurations); private readonly VisualSlot flash = new( "Artifact Charge Flash", FlashSortingOffset, FlashFrameDurations); private readonly VisualSlot release = new( "Artifact Release", ReleaseSortingOffset, ReleaseFrameDurations); private readonly VisualSlot switchPulse = new( "Artifact Switch", FlashSortingOffset, AuraFrameDurations); private ActiveArtifactController controller; private readonly Dictionary equipmentFrames = new(); private readonly Dictionary equipmentOriginals = new(); private PlayerHealth playerHealth; private SpriteRenderer playerRenderer; private SpriteRenderer guardOutline; private readonly Dictionary guardFrames = new(); private RunManager subscribedRunManager; private ActiveArtifactDefinition chargingArtifact; private bool readyFlashPlayed; private int flashPlayCount; private int releasePlayCount; public bool IsAuraVisible => IsVisible(aura); public bool IsSwitchVisible => IsVisible(switchPulse); public GameObject SwitchObject => switchPulse.Root; public bool IsReadyAuraVisible => IsVisible(readyAura); public bool IsFlashVisible => IsVisible(flash); public bool IsReleaseVisible => IsVisible(release); public GameObject AuraObject => aura.Root; public GameObject ReadyAuraObject => readyAura.Root; public GameObject FlashObject => flash.Root; public GameObject ReleaseObject => release.Root; public int FlashPlayCount => flashPlayCount; public int ReleasePlayCount => releasePlayCount; [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] private static void ResetFrameCache() { foreach (CachedFrames cached in FrameCache.Values) { if (!cached.OwnsSprites) { continue; } foreach (Sprite sprite in cached.Frames) { if (sprite == null) { continue; } if (Application.isPlaying) { Destroy(sprite); } else { DestroyImmediate(sprite); } } } FrameCache.Clear(); } private void Awake() { controller = GetComponent(); playerHealth = GetComponent(); playerRenderer = FindPlayerRenderer(); } private void OnEnable() { if (controller == null) { controller = GetComponent(); } if (playerHealth == null) { playerHealth = GetComponent(); } if (playerRenderer == null) { playerRenderer = FindPlayerRenderer(); } SubscribeController(); SubscribePlayerHealth(); RefreshRunManagerSubscription(); RestoreCurrentChargeVisuals(); } private void OnDisable() { if (guardOutline != null) guardOutline.enabled = false; RestoreEquipmentSprite(); UnsubscribeController(); UnsubscribePlayerHealth(); UnsubscribeRunManager(); ClearAllVisuals(); } private void OnDestroy() { if (guardOutline != null) Destroy(guardOutline.gameObject); UnsubscribeController(); UnsubscribePlayerHealth(); UnsubscribeRunManager(); ClearAllVisuals(); } private void LateUpdate() { RefreshEquipmentSprite(); RefreshGuardOutline(); RefreshRunManagerSubscription(); if (subscribedRunManager != null && (subscribedRunManager.IsSelectionOpen || subscribedRunManager.IsGameOver || subscribedRunManager.IsTitleScreen)) { ClearAllVisuals(); return; } if (controller == null) { ClearAllVisuals(); return; } if (!controller.isActiveAndEnabled) { ClearAllVisuals(); return; } if (controller.IsCharging) { UpdateChargeVisuals(); } else { ClearChargeHoldVisuals(); } SyncSlot(aura, true); SyncSlot(readyAura, true); SyncSlot(flash, true); SyncSlot(release, false); SyncSlot(switchPulse, true); AdvanceSlot(aura, Time.deltaTime); AdvanceSlot(readyAura, Time.deltaTime); AdvanceSlot(flash, Time.deltaTime); AdvanceSlot(release, Time.deltaTime); AdvanceSlot(switchPulse, Time.deltaTime); } public void RefreshEquipmentSprite() { if (playerRenderer == null || playerRenderer.sprite == null) return; Sprite current = playerRenderer.sprite; Sprite original = equipmentOriginals.TryGetValue(current, out Sprite source) ? source : current; if (controller == null || !controller.isActiveAndEnabled || controller.CurrentArtifact == null) { playerRenderer.sprite = original; return; } string path = "Artifacts/ThreeColor-v1/Player/" + original.texture.name + "-" + controller.CurrentArtifact.ArtifactColor + "-v1"; if (!equipmentFrames.TryGetValue(path, out Sprite[] frames)) { frames = Resources.LoadAll(path); equipmentFrames[path] = frames; } foreach (Sprite frame in frames) { if (frame.rect != original.rect) continue; equipmentOriginals[frame] = original; playerRenderer.sprite = frame; return; } playerRenderer.sprite = original; } public void RefreshGuardOutline() { bool visible = isActiveAndEnabled && playerHealth != null && playerHealth.isActiveAndEnabled && playerHealth.CurrentHealth > 0f && playerHealth.IsGuarding && playerRenderer != null && playerRenderer.enabled && playerRenderer.sprite != null && (RunManager.Instance == null || !RunManager.Instance.IsGameOver); if (!visible) { if (guardOutline != null) guardOutline.enabled = false; return; } if (guardOutline == null) { var root = new GameObject("Player Guard Outline"); root.transform.SetParent(playerRenderer.transform, false); guardOutline = root.AddComponent(); } Sprite current = playerRenderer.sprite; Sprite original = equipmentOriginals.TryGetValue(current, out Sprite source) ? source : current; if (!guardFrames.TryGetValue(original, out Sprite mask)) { foreach (Sprite frame in Resources.LoadAll("Combat/Guard-v1/" + original.texture.name + "-outline")) { if (frame.rect != original.rect) continue; mask = frame; break; } guardFrames[original] = mask; } guardOutline.sprite = mask; guardOutline.enabled = mask != null; guardOutline.flipX = playerRenderer.flipX; guardOutline.flipY = playerRenderer.flipY; guardOutline.color = new Color(1f, 1f, 1f, playerRenderer.color.a); guardOutline.sharedMaterial = playerRenderer.sharedMaterial; guardOutline.sortingLayerID = playerRenderer.sortingLayerID; guardOutline.sortingOrder = playerRenderer.sortingOrder + 1; } private void RestoreEquipmentSprite() { if (playerRenderer != null && playerRenderer.sprite != null && equipmentOriginals.TryGetValue(playerRenderer.sprite, out Sprite original)) playerRenderer.sprite = original; } public void Connect(ActiveArtifactController artifactController) { if (controller == artifactController && controller != null) { SubscribeController(); return; } UnsubscribeController(); controller = artifactController; SubscribeController(); } public void NotifyChargeEnded(bool preserveReadyFlash) { ClearChargeHoldVisuals(); if (!preserveReadyFlash) { ClearSlot(flash); } } public void NotifyArtifactUseFailed() { ClearAllVisuals(); } public void ClearAllVisuals() { ClearSlot(aura); ClearSlot(readyAura); ClearSlot(flash); ClearSlot(release); ClearSlot(switchPulse); } private void SubscribeController() { if (controller == null) { return; } controller.OnStateChanged -= HandleControllerStateChanged; controller.OnArtifactChargeStarted -= HandleArtifactChargeStarted; controller.OnArtifactUseSucceeded -= HandleArtifactUseSucceeded; controller.OnArtifactSwitched -= HandleArtifactSwitched; controller.OnStateChanged += HandleControllerStateChanged; controller.OnArtifactChargeStarted += HandleArtifactChargeStarted; controller.OnArtifactUseSucceeded += HandleArtifactUseSucceeded; controller.OnArtifactSwitched += HandleArtifactSwitched; } private void UnsubscribeController() { if (controller == null) { return; } controller.OnStateChanged -= HandleControllerStateChanged; controller.OnArtifactChargeStarted -= HandleArtifactChargeStarted; controller.OnArtifactUseSucceeded -= HandleArtifactUseSucceeded; controller.OnArtifactSwitched -= HandleArtifactSwitched; } private void SubscribePlayerHealth() { if (playerHealth == null) { return; } playerHealth.OnDamaged -= HandlePlayerDamaged; playerHealth.OnDied -= HandlePlayerDied; playerHealth.OnDamaged += HandlePlayerDamaged; playerHealth.OnDied += HandlePlayerDied; } private void UnsubscribePlayerHealth() { if (playerHealth == null) { return; } playerHealth.OnDamaged -= HandlePlayerDamaged; playerHealth.OnDied -= HandlePlayerDied; } private void RefreshRunManagerSubscription() { RunManager current = RunManager.Instance; if (current == subscribedRunManager) { return; } UnsubscribeRunManager(); subscribedRunManager = current; if (subscribedRunManager != null) { subscribedRunManager.OnSelectionChanged += HandleSelectionChanged; } } private void UnsubscribeRunManager() { if (subscribedRunManager != null) { subscribedRunManager.OnSelectionChanged -= HandleSelectionChanged; subscribedRunManager = null; } } private void HandleArtifactChargeStarted(ActiveArtifactDefinition definition) { ClearSlot(switchPulse); chargingArtifact = definition; readyFlashPlayed = false; ClearSlot(readyAura); ClearSlot(flash); ClearSlot(aura); if (!Application.isPlaying || definition == null) { return; } aura.Frames = LoadFrames( GetResourcePath(definition, AuraSuffix), FrameCount); readyAura.Frames = LoadFrames( GetResourcePath(definition, ReadyAuraSuffix), FrameCount); flash.Frames = LoadFrames( GetResourcePath(definition, FlashSuffix), FlashFrameCount); if (aura.Frames.Length == 0) { return; } CreateSlot(aura, true, Vector2.zero, 1f); } private void RestoreCurrentChargeVisuals() { if (!Application.isPlaying || controller == null || !controller.IsCharging) { return; } chargingArtifact = controller.CurrentArtifact; if (chargingArtifact == null) { return; } aura.Frames = LoadFrames( GetResourcePath(chargingArtifact, AuraSuffix), FrameCount); readyAura.Frames = LoadFrames( GetResourcePath(chargingArtifact, ReadyAuraSuffix), FrameCount); flash.Frames = LoadFrames( GetResourcePath(chargingArtifact, FlashSuffix), FlashFrameCount); UpdateChargeVisuals(); } private void HandleControllerStateChanged() { if (controller == null || !controller.IsCharging) { ClearChargeHoldVisuals(); return; } UpdateChargeVisuals(); } private void HandleArtifactUseSucceeded( ActiveArtifactDefinition definition, bool charged) { ClearSlot(switchPulse); ClearChargeHoldVisuals(); if (!Application.isPlaying || definition == null) { return; } release.Frames = LoadFrames( GetResourcePath(definition, ReleaseSuffix), FrameCount); if (release.Frames.Length == 0) { return; } Vector2 origin = controller.LastArtifactUsePosition; CreateSlot(release, false, origin, charged ? 1.2f : 1f); releasePlayCount++; } private void HandleArtifactSwitched(ActiveArtifactDefinition definition) { ClearSlot(switchPulse); if (!Application.isPlaying || definition == null) { return; } switchPulse.Frames = LoadFrames( GetResourcePath(definition, ReleaseSuffix), FrameCount); if (switchPulse.Frames.Length == 0) { return; } // Reuse the existing colored outward burst, following the player // for a single short cycle so movement does not leave it behind. CreateSlot(switchPulse, true, Vector2.zero, 1f); } private void HandlePlayerDamaged(Vector2 _, float __) { ClearAllVisuals(); } private void HandlePlayerDied() { ClearAllVisuals(); } private void HandleSelectionChanged(bool isOpen) { if (isOpen) { ClearAllVisuals(); } } private void UpdateChargeVisuals() { if (controller == null || !controller.IsCharging || chargingArtifact == null) { return; } if (!controller.IsFullyCharged) { EnsureLoopSlot(aura, AuraSuffix, 1f); ClearSlot(readyAura); SetAlpha( aura, Mathf.Lerp(0.55f, 0.85f, controller.ChargeProgress)); return; } EnsureLoopSlot(readyAura, ReadyAuraSuffix, 1f); ClearSlot(aura); if (!readyFlashPlayed) { readyFlashPlayed = true; flashPlayCount++; if (flash.Frames.Length > 0 && Application.isPlaying) { CreateSlot(flash, true, Vector2.zero, 1f); } } } private void EnsureLoopSlot( VisualSlot slot, string suffix, float scale) { if (slot.Root != null || chargingArtifact == null) { return; } slot.Frames = LoadFrames( GetResourcePath(chargingArtifact, suffix), FrameCount); if (slot.Frames.Length == 0 || !Application.isPlaying) { return; } CreateSlot(slot, true, Vector2.zero, scale); } private void CreateSlot( VisualSlot slot, bool followPlayer, Vector2 worldOrigin, float scale) { ClearSlot(slot); slot.Root = new GameObject(slot.ObjectName); if (followPlayer) { slot.Root.transform.SetParent(transform, false); slot.Root.transform.localPosition = new Vector3( 0f, ChargeVisualY, 0f); } else { slot.Root.transform.position = new Vector3( worldOrigin.x, worldOrigin.y + ChargeVisualY, transform.position.z); } slot.Root.transform.localScale = Vector3.one * scale; slot.Renderer = slot.Root.AddComponent(); slot.Renderer.color = Color.white; slot.Renderer.sprite = slot.Frames[0]; slot.Renderer.enabled = true; slot.FrameIndex = 0; slot.FrameElapsed = 0f; slot.Loop = slot == aura || slot == readyAura; SyncSlot(slot, followPlayer); } private void SyncSlot(VisualSlot slot, bool followPlayer) { if (slot.Root == null || slot.Renderer == null) { return; } if (followPlayer) { slot.Root.transform.SetParent(transform, false); slot.Root.transform.localPosition = new Vector3( 0f, ChargeVisualY, 0f); } if (playerRenderer == null) { playerRenderer = FindPlayerRenderer(); } if (playerRenderer != null) { slot.Renderer.sortingLayerID = playerRenderer.sortingLayerID; slot.Renderer.sortingOrder = playerRenderer.sortingOrder + slot.SortingOffset; slot.Renderer.sharedMaterial = playerRenderer.sharedMaterial; } Color color = Color.white; if (slot == aura && controller != null && controller.IsCharging && !controller.IsFullyCharged) { color.a = Mathf.Lerp(0.55f, 0.85f, controller.ChargeProgress); } slot.Renderer.color = color; slot.Renderer.enabled = true; } private static void SetAlpha(VisualSlot slot, float alpha) { if (slot.Renderer == null) { return; } Color color = Color.white; color.a = Mathf.Clamp01(alpha); slot.Renderer.color = color; } private static void AdvanceSlot(VisualSlot slot, float deltaTime) { if (slot.Root == null || slot.Renderer == null || slot.Frames == null || slot.Frames.Length == 0 || deltaTime <= 0f) { return; } slot.FrameElapsed += deltaTime; while (slot.FrameElapsed >= slot.FrameDurations[slot.FrameIndex]) { slot.FrameElapsed -= slot.FrameDurations[slot.FrameIndex]; int nextFrame = slot.FrameIndex + 1; if (nextFrame >= slot.Frames.Length) { if (!slot.Loop) { ClearSlot(slot); return; } nextFrame = 0; } slot.FrameIndex = nextFrame; slot.Renderer.sprite = slot.Frames[slot.FrameIndex]; } } private void ClearChargeHoldVisuals() { ClearSlot(aura); ClearSlot(readyAura); } private static bool IsVisible(VisualSlot slot) { return slot.Root != null && slot.Renderer != null && slot.Renderer.enabled; } private SpriteRenderer FindPlayerRenderer() { SpriteRenderer rootRenderer = GetComponent(); if (rootRenderer != null) { return rootRenderer; } SpriteRenderer[] renderers = GetComponentsInChildren(true); foreach (SpriteRenderer renderer in renderers) { if (renderer != null && renderer.transform != transform) { return renderer; } } return GetComponent(); } private static string GetResourcePath( ActiveArtifactDefinition definition, string suffix) { return ResourceRoot + definition.Effect + suffix; } private static void ClearSlot(VisualSlot slot) { if (slot.Root != null) { slot.Root.SetActive(false); if (Application.isPlaying) { Destroy(slot.Root); } else { DestroyImmediate(slot.Root); } } slot.Root = null; slot.Renderer = null; slot.FrameIndex = 0; slot.FrameElapsed = 0f; } private static Sprite[] LoadFrames( string resourcePath, int expectedFrameCount) { if (FrameCache.TryGetValue(resourcePath, out CachedFrames cached)) { return cached.Frames; } Sprite[] importedFrames = Resources.LoadAll(resourcePath); if (importedFrames != null && importedFrames.Length >= expectedFrameCount) { Array.Sort( importedFrames, (left, right) => String.CompareOrdinal(left.name, right.name)); Sprite[] frames = new Sprite[expectedFrameCount]; Array.Copy(importedFrames, frames, expectedFrameCount); FrameCache[resourcePath] = new CachedFrames(frames, false); return frames; } Texture2D texture = importedFrames != null && importedFrames.Length > 0 ? importedFrames[0].texture : Resources.Load(resourcePath); if (texture == null || texture.width < CellSize * expectedFrameCount || texture.height < CellSize || texture.width % expectedFrameCount != 0) { Sprite[] missing = Array.Empty(); FrameCache[resourcePath] = new CachedFrames(missing, false); return missing; } int frameWidth = texture.width / expectedFrameCount; Sprite[] generatedFrames = new Sprite[expectedFrameCount]; for (int i = 0; i < expectedFrameCount; i++) { generatedFrames[i] = Sprite.Create( texture, new Rect(i * frameWidth, 0f, frameWidth, CellSize), new Vector2(0.5f, 0.5f), PixelsPerUnit, 0, SpriteMeshType.FullRect); generatedFrames[i].name = $"{resourcePath} Frame {i + 1}"; generatedFrames[i].hideFlags = HideFlags.HideAndDontSave; } FrameCache[resourcePath] = new CachedFrames(generatedFrames, true); return generatedFrames; } } }