using System.Collections.Generic; using BumpCombat.Combat; using BumpCombat.Player; using UnityEngine; namespace BumpCombat.Enemies { // Authored exterior pixels only; this component never changes the damage gate. [DefaultExecutionOrder(10000)] public sealed class NecromancerProtectionVisual : MonoBehaviour { private static readonly Dictionary Masks = new(); private EnemyController controller; private SpriteRenderer body; private SpriteRenderer outline; private SpriteRenderer groggySpiral; private float orbitTime; private TextMesh shieldLabel; private MeshRenderer shieldLabelRenderer; private static readonly Color GroggyColor = new Color32(255, 238, 182, 255); private void Awake() { controller = GetComponent(); body = GetComponent(); var root = new GameObject("Necromancer Protection"); root.transform.SetParent(transform, false); outline = root.AddComponent(); outline.enabled = false; var labelRoot = new GameObject("Artifact Shield Label"); labelRoot.transform.SetParent(transform, false); shieldLabel = labelRoot.AddComponent(); shieldLabel.fontSize = 32; shieldLabel.characterSize = 0.06f; shieldLabel.anchor = TextAnchor.MiddleCenter; shieldLabel.alignment = TextAlignment.Center; Font font = Resources.Load("Presentation/Fonts/Galmuri9"); shieldLabelRenderer = labelRoot.GetComponent(); if (font != null) { shieldLabel.font = font; shieldLabelRenderer.sharedMaterial = font.material; } shieldLabelRenderer.enabled = false; var marker = new GameObject("Groggy Spiral"); marker.transform.SetParent(transform, false); groggySpiral = marker.AddComponent(); groggySpiral.enabled = false; } private void LateUpdate() { if (controller != null && controller.IsGroggy) orbitTime += Time.deltaTime; else orbitTime = 0f; Refresh(); } public void Refresh() { if (outline == null) return; bool visible = controller != null && controller.isActiveAndEnabled && !controller.IsDead && (controller.IsDamageInvulnerable || controller.IsGroggy) && body != null && body.enabled && body.sprite != null; outline.enabled = visible; RefreshGroggyMarker(visible && controller.IsGroggy); RefreshShieldLabel(visible && controller.IsDamageInvulnerable && !controller.IsGroggy); if (!visible) return; if (!Masks.TryGetValue(body.sprite, out var mask)) { var candidates = Resources.LoadAll("Enemies/Protection-v2/" + body.sprite.texture.name + "-outline"); foreach (var candidate in candidates) if (candidate.rect == body.sprite.rect) { mask = candidate; break; } Masks[body.sprite] = mask; } outline.sprite = mask; outline.enabled = mask != null; outline.flipX = body.flipX; outline.flipY = body.flipY; Color tint = controller.IsGroggy ? GroggyColor : GetShieldColor(); tint.a = body.color.a; outline.color = tint; outline.sharedMaterial = body.sharedMaterial; outline.sortingLayerID = body.sortingLayerID; outline.sortingOrder = body.sortingOrder + 1; } private void OnDisable() { if (outline != null) outline.enabled = false; if (shieldLabelRenderer != null) shieldLabelRenderer.enabled = false; RefreshGroggyMarker(false); orbitTime = 0f; } private bool HasProtectionSummons => GetComponent()?.IsDamageBlocked == true; private Color GetShieldColor() { if (HasProtectionSummons) return new Color32(205, 211, 224, 255); return ActiveArtifactDefinition.GetArtifactPaletteColor(controller.ShieldColor); } private void RefreshShieldLabel(bool visible) { if (shieldLabelRenderer == null) return; shieldLabelRenderer.enabled = visible; if (!visible) return; string group = controller.ShieldColor == ArtifactColor.Green ? "초록" : controller.ShieldColor == ArtifactColor.Red ? "빨강" : "파랑"; shieldLabel.text = HasProtectionSummons ? "보호 소환" : group + " 실드 " + controller.ShieldHitsRemaining; shieldLabel.color = GetShieldColor(); shieldLabel.transform.position = transform.TransformPoint(new Vector3(0, GetHeadHeight(), 0)) + Vector3.up * 0.38f; Vector3 scale = transform.lossyScale; shieldLabel.transform.localScale = new Vector3(1f / Mathf.Max(0.001f, Mathf.Abs(scale.x)), 1f / Mathf.Max(0.001f, Mathf.Abs(scale.y)), 1f); shieldLabelRenderer.sortingLayerID = body.sortingLayerID; shieldLabelRenderer.sortingOrder = body.sortingOrder + 101; } private float GetHeadHeight() { return CrowdControlMarkerArt.HeadLocalHeight(body.sprite); } private void RefreshGroggyMarker(bool visible) { if (groggySpiral == null) return; groggySpiral.enabled = visible; if (!visible) return; groggySpiral.sprite = CrowdControlMarkerArt.Frame(orbitTime, true); groggySpiral.transform.position = CrowdControlMarkerArt.MarkerWorldPosition(body); groggySpiral.transform.localScale = CombatFeedback.CalculateStunMarkerBaseLocalScale(transform.lossyScale); groggySpiral.sortingLayerID = body.sortingLayerID; groggySpiral.sortingOrder = body.sortingOrder + 100; groggySpiral.color = new Color(1f, 1f, 1f, body.color.a); groggySpiral.enabled = groggySpiral.sprite != null; } } // Shared authored CC art and head landmarks. No combat state is modified here. public static class CrowdControlMarkerArt { public const float MarkerScale = 0.5f; public const float MarkerHalfHeight = 10f / 32f * MarkerScale; private static Sprite[] stunFrames; private static Sprite[] groggyFrames; [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] private static void ResetFrames() { foreach (Sprite[] frames in new[] { stunFrames, groggyFrames }) if (frames != null) foreach (Sprite frame in frames) if (frame != null) Object.Destroy(frame); stunFrames = null; groggyFrames = null; } public static Sprite Frame(float elapsed, bool groggy) { Sprite[] frames = groggy ? groggyFrames : stunFrames; if (frames == null) { Texture2D texture = Resources.Load( "Artifacts/CrowdControl-v1/CC-Spiral-" + (groggy ? "Groggy" : "Stun") + "-v1"); if (texture == null) return null; frames = new Sprite[8]; for (int i = 0; i < frames.Length; i++) frames[i] = Sprite.Create(texture, new Rect(i * 32, 0, 32, 24), new Vector2(.5f, .5f), 32f); if (groggy) groggyFrames = frames; else stunFrames = frames; } return frames[Mathf.FloorToInt(Mathf.Max(0f, elapsed) / .06f) % frames.Length]; } public static float HeadLocalHeight(Sprite sprite) { if (sprite == null) return 0f; // First idle frame's central body strip. Excludes weapons, shadows and transparent padding. string name = sprite.texture.name; int top = name.StartsWith("ArmoredSkeleton") ? 39 : name.StartsWith("SkeletonArcher") ? 38 : name.StartsWith("GreatswordSkeleton") ? 38 : name.StartsWith("Skeleton") ? 42 : name.StartsWith("Slime") ? 46 : name.StartsWith("Bat") ? 40 : name.StartsWith("NecroGolem") ? 29 : name.StartsWith("Necromancer") ? 30 : name.StartsWith("Necrofire") ? 30 : name.StartsWith("Werebear") ? 40 : name.StartsWith("Werewolf") ? 41 : name.StartsWith("Warlock") ? 34 : name.StartsWith("Lancer") ? 28 : -1; return top < 0 ? sprite.bounds.max.y : (sprite.rect.height - top - sprite.pivot.y) / sprite.pixelsPerUnit; } public static Vector3 MarkerWorldPosition(SpriteRenderer body, float gap = 4f / 32f) { Vector3 head = body.transform.TransformPoint(new Vector3(0f, HeadLocalHeight(body.sprite), 0f)); return head + Vector3.up * (Mathf.Max(0f, gap) + MarkerHalfHeight * Mathf.Abs(body.transform.lossyScale.y)); } } }