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