using System.Collections; using UnityEngine; namespace Heroes.Battle { /// /// Scene representation of one using a 3D model. Purely /// reactive: it subscribes to the stack's state machine and animates each /// state — gliding along the move path on , /// lunging on , showing an active marker /// on and playing the death animation on /// . /// /// The model's position stays 2D (the hex's on-screen point); only its /// rotation is driven through a virtual tilted plane (see /// ), so moving up/down/diagonally turns the /// model believably. Animator parameters are driven from the same states. /// /// When the unit prototype has no prefab a capsule placeholder is used so the /// stack is still visible. /// public sealed class UnitStackView : MonoBehaviour { public TextMesh countText; public SpriteRenderer activeMarker; public float moveSpeed = 4f; public float attackDuration = 0.3f; private static Sprite _quadSprite; private BattlefieldView _battlefield; private UnitViewSettings _settings; private Transform _modelRoot; private Animator _animator; public UnitStack Stack { get; private set; } /// Builds a view GameObject under and binds it to the stack. public static UnitStackView Create( Transform parent, UnitStack stack, BattlefieldView battlefield, int sortingOrder, Color placeholderTint, float moveSpeed, UnitViewSettings settings) { string unitName = stack.Definition != null ? stack.Definition.ResolveDisplayName() : "Unknown"; var go = new GameObject($"Unit_{stack.Side}_{stack.SlotIndex}_{unitName}"); go.transform.SetParent(parent, false); var view = go.AddComponent(); view._battlefield = battlefield; view._settings = settings ?? new UnitViewSettings(); view.moveSpeed = moveSpeed; view.BuildModel(stack, placeholderTint, sortingOrder); view.BuildActiveMarker(sortingOrder); view.BuildCountLabel(sortingOrder); view.Bind(stack); view.transform.localPosition = view.CenterFor(stack.X, stack.Y); view.FaceEnemySide(); return view; } private void BuildModel(UnitStack stack, Color placeholderTint, int sortingOrder) { GameObject model; bool isPlaceholder = stack.Definition == null || stack.Definition.prefab == null; if (isPlaceholder) { model = GameObject.CreatePrimitive(PrimitiveType.Capsule); model.name = "Model (Placeholder)"; var collider = model.GetComponent(); if (collider != null) DestroyComponent(collider); model.transform.localScale = Vector3.one * 0.3f; var renderer = model.GetComponent(); if (renderer != null) renderer.material.color = placeholderTint; } else { model = Instantiate(stack.Definition.prefab); model.name = "Model"; } model.transform.SetParent(transform, false); model.transform.localPosition = new Vector3(0f, 0f, _settings.modelDepth); _modelRoot = model.transform; _animator = model.GetComponentInChildren(); if (_settings.steppedAnimation && _animator != null) { var stepped = _animator.gameObject.GetComponent(); if (stepped == null) stepped = _animator.gameObject.AddComponent(); stepped.SetTargetFps(_settings.animationFps); } ApplyRenderOrder(model, sortingOrder); } private void ApplyRenderOrder(GameObject model, int sortingOrder) { var renderers = model.GetComponentsInChildren(true); foreach (var renderer in renderers) { renderer.sortingOrder = sortingOrder; if (_settings.overrideRenderQueue) { foreach (var material in renderer.materials) { if (material != null) material.renderQueue = _settings.renderQueue; } } } } private void BuildActiveMarker(int sortingOrder) { var marker = new GameObject("ActiveMarker"); marker.transform.SetParent(transform, false); activeMarker = marker.AddComponent(); activeMarker.sprite = GetQuadSprite(); activeMarker.color = new Color(1f, 0.9f, 0.2f, 0.55f); activeMarker.sortingOrder = sortingOrder - 1; marker.transform.localScale = Vector3.one * 0.7f; marker.SetActive(false); } private void BuildCountLabel(int sortingOrder) { var label = new GameObject("Count"); label.transform.SetParent(transform, false); label.transform.localPosition = new Vector3(0f, -0.42f, -Mathf.Abs(_settings.modelDepth) - 0.1f); countText = label.AddComponent(); countText.font = Resources.GetBuiltinResource("LegacyRuntime.ttf"); countText.anchor = TextAnchor.MiddleCenter; countText.alignment = TextAlignment.Center; countText.fontSize = 48; countText.characterSize = 0.02f; countText.fontStyle = FontStyle.Bold; countText.color = Color.white; var labelRenderer = label.GetComponent(); labelRenderer.sharedMaterial = countText.font.material; labelRenderer.sortingOrder = sortingOrder + 2; } public void Bind(UnitStack stack) { if (Stack != null) { Stack.StateChanged -= OnStateChanged; Stack.HealthChanged -= OnHealthChanged; } Stack = stack; if (Stack != null) { Stack.StateChanged += OnStateChanged; Stack.HealthChanged += OnHealthChanged; } RefreshCount(); } private void OnDestroy() { if (Stack != null) { Stack.StateChanged -= OnStateChanged; Stack.HealthChanged -= OnHealthChanged; } } private void OnHealthChanged(UnitStack stack) => RefreshCount(); private void OnStateChanged(UnitStack stack) { switch (stack.State) { case UnitStackState.Active: if (activeMarker != null) activeMarker.gameObject.SetActive(true); transform.localPosition = CenterFor(stack.X, stack.Y); FaceEnemySide(); break; case UnitStackState.Idle: if (activeMarker != null) activeMarker.gameObject.SetActive(false); break; case UnitStackState.Moving: if (activeMarker != null) activeMarker.gameObject.SetActive(false); StartCoroutine(AnimateMove()); break; case UnitStackState.Attacking: StartCoroutine(AnimateAttack()); break; case UnitStackState.Dead: if (activeMarker != null) activeMarker.gameObject.SetActive(false); if (countText != null) countText.gameObject.SetActive(false); SetMoving(false); SetTrigger(_settings.deathTrigger); break; } } private IEnumerator AnimateMove() { SetMoving(true); var path = Stack.MovePath; for (int i = 0; i < path.Count; i++) { Vector3 target = CenterFor(path[i].x, path[i].y); FaceTowards(target); while ((transform.localPosition - target).sqrMagnitude > 0.0001f) { transform.localPosition = Vector3.MoveTowards( transform.localPosition, target, moveSpeed * Time.deltaTime); yield return null; } transform.localPosition = target; } SetMoving(false); FaceEnemySide(); Stack.CompleteMove(); } private IEnumerator AnimateAttack() { Vector3 origin = transform.localPosition; Vector3 targetCenter = Stack.AttackTarget != null ? CenterFor(Stack.AttackTarget) : origin; FaceTowards(targetCenter); SetTrigger(_settings.attackTrigger); Vector3 lungePeak = Vector3.Lerp(origin, targetCenter, 0.4f); float half = Mathf.Max(0.01f, attackDuration * 0.5f); yield return Lerp(origin, lungePeak, half); yield return Lerp(lungePeak, origin, half); transform.localPosition = origin; FaceEnemySide(); Stack.CompleteAttack(); } private IEnumerator Lerp(Vector3 from, Vector3 to, float duration) { float t = 0f; while (t < duration) { t += Time.deltaTime; transform.localPosition = Vector3.Lerp(from, to, t / duration); yield return null; } transform.localPosition = to; } private void FaceTowards(Vector3 localTarget) { var dir = new Vector2(localTarget.x - transform.localPosition.x, localTarget.y - transform.localPosition.y); SetFacing(dir); } private void FaceEnemySide() { SetFacing(Stack.Side == BattleSide.Attacker ? Vector2.right : Vector2.left); } private void SetFacing(Vector2 boardDir) { if (_modelRoot == null || boardDir.sqrMagnitude < 1e-6f) return; _modelRoot.localRotation = BattleProjection.FacingRotation( boardDir, _settings.viewPitchDegrees, _settings.applyViewTilt, _settings.modelYawOffset); } // --- Animator helpers -------------------------------------------------- private void SetMoving(bool moving) { if (HasParameter(_settings.moveBoolParam, AnimatorControllerParameterType.Bool)) { _animator.SetBool(_settings.moveBoolParam, moving); } } private void SetTrigger(string trigger) { if (HasParameter(trigger, AnimatorControllerParameterType.Trigger)) { _animator.SetTrigger(trigger); } } private bool HasParameter(string name, AnimatorControllerParameterType type) { if (_animator == null || string.IsNullOrEmpty(name)) return false; foreach (var parameter in _animator.parameters) { if (parameter.type == type && parameter.name == name) return true; } return false; } private void RefreshCount() { if (countText == null || Stack == null) return; countText.text = Stack.Count.ToString(); } // --- Positioning (2D) -------------------------------------------------- /// Battlefield-local center of the stack's footprint if its anchor were at (anchorX, y). public Vector3 CenterFor(int anchorX, int y) { return LocalCenter(_battlefield, Stack.Side, Stack.HexWidth, anchorX, y); } private Vector3 CenterFor(UnitStack other) { return LocalCenter(_battlefield, other.Side, other.HexWidth, other.X, other.Y); } /// Battlefield-local center of a stack footprint (average of its occupied hex centers). public static Vector3 LocalCenter(BattlefieldView battlefield, BattleSide side, int width, int anchorX, int y) { if (battlefield == null) return Vector3.zero; Vector3 sum = Vector3.zero; int count = 0; foreach (int col in UnitStack.ColumnsFor(side, width, anchorX)) { sum += battlefield.HexToWorld(col, y); count++; } return count > 0 ? sum / count : Vector3.zero; } private static void DestroyComponent(Object obj) { if (Application.isPlaying) Destroy(obj); else DestroyImmediate(obj); } private static Sprite GetQuadSprite() { if (_quadSprite == null) { var texture = Texture2D.whiteTexture; _quadSprite = Sprite.Create( texture, new Rect(0f, 0f, texture.width, texture.height), new Vector2(0.5f, 0.5f), texture.width / 0.55f); _quadSprite.name = "UnitQuad"; _quadSprite.hideFlags = HideFlags.DontSave; } return _quadSprite; } } }