ThreeKingdomsSlingshot/Assets/Scripts/Runtime/UnitView.cs

400 lines
18 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Collections;
using UnityEngine;
namespace TKS
{
/// <summary>
/// 유닛 표시. 아트(Resources/Units/&lt;art&gt;/ 프레임 애니 · UnitArt)가 있으면 스프라이트(아군 = 뒷모습 back_* · 적군 = 앞모습 front_*),
/// 없으면 도형(몸통 원 + 병종 글자). 공통 = 이름 · HP 바 · 선택/표적 링 · 보스 테두리 · 창병 대기 반경.
/// </summary>
public class UnitView : MonoBehaviour
{
public static readonly Color PlayerColor = new Color(0.25f, 0.55f, 0.95f);
public static readonly Color EnemyColor = new Color(0.9f, 0.32f, 0.3f);
static readonly Color DimColor = new Color(0.3f, 0.3f, 0.32f);
static readonly Color ArtDimColor = new Color(0.45f, 0.45f, 0.5f);
static readonly Color ArtHitTint = new Color(1f, 0.45f, 0.45f);
static readonly Color HighlightColor = new Color(1f, 0.5f, 0.15f);
static readonly Color FriendlyFireRingColor = new Color(1f, 0.1f, 0.1f);
const float HpBarWidth = 1.0f;
const float AttackHold = 0.35f;
const float HitHold = 0.3f;
Unit unit;
SpriteRenderer body;
FrameAnimator anim;
SpriteRenderer bossRing;
SpriteRenderer selectRing;
SpriteRenderer highlightRing;
SpriteRenderer overwatchArea;
SpriteRenderer hpBg;
SpriteRenderer hpFill;
bool packHp;
// 체력 바 9-slice 월드 배율(테두리 px → 월드 = px / ppu · 바 높이 0.22 / 0.13 안에 테두리가 들어가게)
const float HpBgPpu = 240f;
const float HpFillPpu = 420f;
TextMesh mark;
TextMesh nameLabel;
TextMesh badge;
TextMesh skillBadge; // 예약한 액티브(next_action) 이름(PD #1093 B)
Color baseColor;
Coroutine flash;
bool aiming;
bool highlightFriendly;
bool charging; // 슬링샷 돌진(이동) 중 = 돌진 포즈 고정
bool facingBack; // 위를 봄(뒷모습 back_* 세트) · false = 아래를 봄(앞모습 front_*)
bool facingLeft; // 왼쪽을 봄(좌우 반전) · false = 오른쪽(기본)
const float FacingEps = 0.05f;
/// <summary>스프라이트 아트로 그리는가(false = 도형).</summary>
public bool HasArt
{
get { return anim != null; }
}
public FrameAnimator Anim
{
get { return anim; }
}
/// <summary>4방향 보기(PD #1082): 위를 보면 뒷모습(↖·↗) · 아래를 보면 앞모습(↙·↘).</summary>
public bool FacingBack
{
get { return facingBack; }
}
/// <summary>왼쪽을 보면 좌우 반전(↙·↖) · 오른쪽이 기본(↘·↗).</summary>
public bool FacingLeft
{
get { return facingLeft; }
}
/// <summary>
/// 마지막 이동 방향으로 보는 방향을 정한다(PD #1082 · 슬링샷·튕김·밀림·돌진 뒤 마지막 방향).
/// 세로 성분이 있으면 앞/뒤 세트를, 가로 성분이 있으면 좌우 반전을 바꾼다(거의 0 인 축은 이전 값 유지).
/// </summary>
public void SetFacing(Vector2 dir)
{
if (Sim.Active) return; // 가상 실행(적 AI 예측) = 표시 그대로
bool viewChanged = false;
if (dir.y > FacingEps && !facingBack)
{
facingBack = true;
viewChanged = true;
}
else if (dir.y < -FacingEps && facingBack)
{
facingBack = false;
viewChanged = true;
}
if (dir.x > FacingEps) facingLeft = false;
else if (dir.x < -FacingEps) facingLeft = true;
if (anim == null) return;
anim.Renderer.flipX = facingLeft;
if (!viewChanged) return;
// 앞/뒤 세트가 바뀌면 지금 동작을 새 보기의 같은 클립으로 이어 간다
string cur = anim.Current ?? "";
string suffix = cur.StartsWith("back_") ? cur.Substring(5) : (cur.StartsWith("front_") ? cur.Substring(6) : "idle");
anim.SetIdle(ClipName("idle"));
if (charging) anim.Play(ClipName("action"), true);
else if (suffix == "idle") anim.PlayIdle();
else if (suffix == "ready") anim.Play(ClipName("ready"), true);
else if (suffix == "victory") anim.Play(ClipName("victory"), false);
else anim.Play(ClipName(suffix), false, AttackHold, true);
}
/// <summary>
/// 되돌아가기(PD 2026-11-10 「기병이 공격 후 복귀할 때 방향 전환 · 이미지 리소스 반전」): 보는 방향을 dir 로 바꾸고,
/// dir 에 가로 성분이 없어 좌우가 그대로면(곧장 위·아래로 돌아감) 좌우 반전을 뒤집는다 — 돌아서는 모습이 늘 보이게.
/// </summary>
public void TurnAround(Vector2 dir)
{
if (Sim.Active) return;
bool sideways = Mathf.Abs(dir.x) > FacingEps;
SetFacing(dir);
if (sideways) return;
facingLeft = !facingLeft;
if (anim != null) anim.Renderer.flipX = facingLeft;
}
public void Build(Unit u)
{
unit = u;
foreach (Transform c in transform) Destroy(c.gameObject);
baseColor = u.Faction == Faction.Player ? PlayerColor : EnemyColor;
float d = u.Radius * 2f;
// 창병 대기 반경 표시(창병만 · 발동 중에만 오브젝트 통째로 켬)
overwatchArea = ProceduralSprites.NewSprite(transform, "OverwatchArea", ProceduralSprites.Circle, new Color(0.3f, 0.65f, 1f, 0.16f), u.Type == UnitType.Spear ? u.AttackRange * 2f : 0f, -6);
var owRing = ProceduralSprites.NewSprite(overwatchArea.transform, "Ring", ProceduralSprites.Ring, new Color(0.5f, 0.8f, 1f, 0.6f), 1f, -5);
owRing.transform.localScale = Vector3.one;
overwatchArea.gameObject.SetActive(false);
if (u.IsBoss)
{
bossRing = ProceduralSprites.NewSprite(transform, "BossRing", ProceduralSprites.Circle, new Color(1f, 0.85f, 0.3f), d + 0.16f, -1);
}
body = ProceduralSprites.NewSprite(transform, "Body", ProceduralSprites.Circle, baseColor, d, 0);
// 시작 보기: 아군 = 위(↗ 뒷모습) · 적군 = 아래(↘ 앞모습) · 이후는 마지막 이동 방향(PD #1082)
facingBack = u.Faction == Faction.Player;
facingLeft = false;
// 아트가 있으면 몸통 원 대신 스프라이트 애니(발 = 원 아래쪽)
anim = null;
var art = UnitArt.Load(u.ArtId);
if (art != null)
{
body.enabled = false;
var ago = new GameObject("Art");
ago.transform.SetParent(transform, false);
ago.transform.localPosition = new Vector3(0f, -u.Radius * 0.55f, 0f);
anim = ago.AddComponent<FrameAnimator>();
anim.Setup(art, u.ArtHeight, 0, u.MountedArtMaxTop);
u.ArtHeight = anim.TopHeight; // 크기 표 배율 뒤 실제 머리 끝(HP 바 · 머리 위 표시 기준)
anim.Renderer.flipX = facingLeft;
anim.SetIdle(ClipName("idle"));
anim.PlayIdle();
}
selectRing = ProceduralSprites.NewSprite(transform, "SelectRing", ProceduralSprites.Ring, new Color(1f, 0.95f, 0.4f), d + 0.28f, 1);
selectRing.enabled = false;
highlightRing = ProceduralSprites.NewSprite(transform, "HighlightRing", ProceduralSprites.Ring, HighlightColor, d + 0.28f, 1);
highlightRing.enabled = false;
// HP 바 = 도형이면 원 위 · 아트면 머리 위(발 = −0.55 R · 세로 ArtHeight) — 얼굴을 가리지 않게(PD #1081 첨부 캡처)
float topY = anim != null ? -u.Radius * 0.55f + u.ArtHeight + 0.12f : u.Radius + 0.22f;
hpBg = ProceduralSprites.NewSprite(transform, "HpBg", ProceduralSprites.Square, new Color(0.08f, 0.08f, 0.08f, 0.9f), 1f, 2);
hpBg.transform.localPosition = new Vector3(0f, topY, 0f);
hpBg.transform.localScale = new Vector3(HpBarWidth, 0.12f, 1f);
hpFill = ProceduralSprites.NewSprite(transform, "HpFill", ProceduralSprites.Square, new Color(0.35f, 0.9f, 0.35f), 1f, 3);
hpFill.transform.localPosition = hpBg.transform.localPosition;
hpFill.transform.localScale = new Vector3(HpBarWidth, 0.12f, 1f);
// 팩 슬라이더 체력 바(PD #1100 · 테마 hp_bg·hp_fill · 9-slice 월드 사본) — 테마가 없으면 위 도형 그대로
var bgSp = UISkin.WorldSprite("hp_bg", HpBgPpu);
var fillSp = UISkin.WorldSprite("hp_fill", HpFillPpu);
if (bgSp != null && fillSp != null)
{
packHp = true;
hpBg.sprite = bgSp;
hpBg.drawMode = SpriteDrawMode.Sliced;
hpBg.transform.localScale = Vector3.one;
hpBg.size = new Vector2(HpBarWidth + 0.1f, 0.22f);
hpBg.color = UISkin.Col("hp_bg", Color.white);
hpFill.sprite = fillSp;
hpFill.drawMode = SpriteDrawMode.Sliced;
hpFill.transform.localScale = Vector3.one;
hpFill.size = new Vector2(HpBarWidth, 0.13f);
}
mark = TextUtil.CreateWorldText(transform, UnitTypeInfo.Mark(u.Type), u.Radius * 1.1f, Color.white, 4);
if (anim != null) mark.gameObject.SetActive(false); // 아트가 있으면 병종은 이름 옆에
nameLabel = TextUtil.CreateWorldText(transform, anim != null ? u.DisplayName + "·" + UnitTypeInfo.Mark(u.Type) : u.DisplayName, 0.26f, new Color(0.95f, 0.95f, 0.9f), 4);
nameLabel.transform.localPosition = new Vector3(0f, -u.Radius - 0.22f, 0f);
badge = TextUtil.CreateWorldText(transform, "추가 이동", 0.22f, new Color(1f, 0.95f, 0.4f), 5);
badge.transform.localPosition = new Vector3(0f, topY + 0.24f, 0f);
badge.gameObject.SetActive(false);
skillBadge = TextUtil.CreateWorldText(transform, "", 0.24f, SkillFx.NameColor, 5);
skillBadge.transform.localPosition = new Vector3(0f, topY + 0.5f, 0f);
skillBadge.gameObject.SetActive(false);
Refresh();
}
/// <summary>예약 표시 중인가(테스트·캡처 확인용).</summary>
public bool SkillBadgeVisible
{
get { return skillBadge != null && skillBadge.gameObject.activeSelf; }
}
/// <summary>보는 방향(위 = back · 아래 = front)에 맞는 클립 이름. 그 보기에 없으면 다른 보기 → 대기.</summary>
string ClipName(string suffix)
{
string v = facingBack ? "back" : "front";
string o = facingBack ? "front" : "back";
if (anim.Has(v + "_" + suffix)) return v + "_" + suffix;
if (anim.Has(o + "_" + suffix)) return o + "_" + suffix;
return anim.Has(v + "_idle") ? v + "_idle" : o + "_idle";
}
public void Refresh()
{
if (Sim.Active) return; // 가상 실행(적 AI 예측) = 표시 그대로
if (unit == null || hpFill == null) return;
float ratio = unit.MaxHp > 0 ? Mathf.Clamp01((float)unit.Hp / unit.MaxHp) : 0f;
if (packHp)
{
float w = HpBarWidth * ratio;
hpFill.enabled = w > 0.045f;
hpFill.size = new Vector2(Mathf.Max(0.045f, w), 0.13f);
hpFill.transform.localPosition = new Vector3(-HpBarWidth * 0.5f + w * 0.5f, hpBg.transform.localPosition.y + 0.015f, 0f);
hpFill.color = ratio > 0.5f ? UISkin.Col("hp_hi", Color.green) : ratio > 0.25f ? UISkin.Col("hp_mid", Color.yellow) : UISkin.Col("hp_low", Color.red);
}
else
{
hpFill.transform.localScale = new Vector3(HpBarWidth * ratio, 0.12f, 1f);
hpFill.transform.localPosition = new Vector3(-HpBarWidth * 0.5f + HpBarWidth * ratio * 0.5f, hpBg.transform.localPosition.y, 0f);
hpFill.color = ratio > 0.5f ? new Color(0.35f, 0.9f, 0.35f) : (ratio > 0.25f ? new Color(0.95f, 0.8f, 0.2f) : new Color(0.95f, 0.3f, 0.2f));
}
bool dim = unit.Faction == Faction.Player && unit.ActedThisTurn;
if (flash == null)
{
if (anim != null) anim.Renderer.color = dim ? Color.Lerp(Color.white, ArtDimColor, 0.6f) : Color.white;
else body.color = dim ? Color.Lerp(baseColor, DimColor, 0.6f) : baseColor;
}
mark.color = dim ? new Color(1f, 1f, 1f, 0.6f) : Color.white;
bool ow = unit.Overwatch && unit.Type == UnitType.Spear && unit.IsAlive;
if (ow)
{
float d = unit.AttackRange * 2f; // 옛 견제 반경(대기 유닛 없음 · 2026-11-10 #1112 스킬 확장 제거)
overwatchArea.transform.localScale = new Vector3(d, d, 1f);
}
if (overwatchArea.gameObject.activeSelf != ow) overwatchArea.gameObject.SetActive(ow);
badge.gameObject.SetActive(unit.BonusMoveAvailable && unit.IsAlive);
var sk = SkillSystem.Current != null ? SkillSystem.Current.Of(unit) : null;
bool reserved = sk != null && sk.Reserved && sk.Active != null && unit.IsAlive;
if (reserved) skillBadge.text = "★" + sk.Active.NameKo;
if (skillBadge.gameObject.activeSelf != reserved) skillBadge.gameObject.SetActive(reserved);
}
public void SetSelected(bool on)
{
if (selectRing != null) selectRing.enabled = on;
}
public void SetHighlight(bool on)
{
SetHighlight(on, false);
}
/// <summary>공격 예정 표시 링 · friendly = 궁병·책사 사격에 맞을 같은 편(붉게 · PD 2026-11-05 #1087 ⑤⑥).</summary>
public void SetHighlight(bool on, bool friendly)
{
if (highlightRing == null) return;
highlightRing.enabled = on;
highlightRing.color = friendly ? FriendlyFireRingColor : HighlightColor;
highlightFriendly = on && friendly;
}
public bool Highlighted
{
get { return highlightRing != null && highlightRing.enabled; }
}
/// <summary>오사 경고(붉은 링) 중인가.</summary>
public bool HighlightFriendly
{
get { return highlightFriendly; }
}
/// <summary>조준(당기는) 중 = 출발 자세(ready · 1프레임) · 끝나면 대기 (PD 2026-11-05 #1084).</summary>
public void SetAiming(bool on)
{
if (Sim.Active) return; // 가상 실행(적 AI 예측) = 표시 그대로
if (anim == null || aiming == on) return;
aiming = on;
if (on) anim.Play(ClipName("ready"), true);
else if (!charging) anim.PlayIdle();
}
/// <summary>슬링샷 돌진(이동) 중 = 병종 돌진 포즈(action · 1프레임 고정) · 멈추면 대기 (PD 2026-11-05 #1084).</summary>
public void SetCharging(bool on)
{
if (Sim.Active) return; // 가상 실행(적 AI 예측) = 표시 그대로
if (anim == null || charging == on) return;
charging = on;
aiming = false;
if (on) anim.Play(ClipName("action"), true);
else if (unit != null && unit.IsAlive) anim.PlayIdle();
}
public bool Charging
{
get { return charging; }
}
/// <summary>공격 자세(1 프레임) 잠깐 → 대기. 돌진 중이면 이미 돌진 포즈라 그대로 둔다.</summary>
public void PlayAttack()
{
if (Sim.Active) return; // 가상 실행(적 AI 예측) = 표시 그대로
if (anim == null || !isActiveAndEnabled || charging) return;
aiming = false;
anim.Play(ClipName("action"), false, AttackHold, true);
}
/// <summary>승리 동작(앞모습 · 끝 프레임 유지).</summary>
public void PlayVictory()
{
if (Sim.Active) return; // 가상 실행(적 AI 예측) = 표시 그대로
if (anim == null || !isActiveAndEnabled || !unit.IsAlive) return;
aiming = false;
anim.Play(ClipName("victory"), false);
}
public void OnHit()
{
if (Sim.Active) return; // 가상 실행(적 AI 예측) = 표시 그대로
if (!isActiveAndEnabled) return;
if (flash != null) StopCoroutine(flash);
flash = StartCoroutine(FlashRoutine());
}
IEnumerator FlashRoutine()
{
if (anim != null)
{
anim.Renderer.color = ArtHitTint;
if (unit.IsAlive && !charging) anim.Play(ClipName("damage"), false, HitHold, true); // 돌진 중 피격은 색만(포즈 유지)
}
else
{
body.color = Color.white;
}
float t = 0f;
while (t < 0.18f)
{
t += Time.deltaTime;
yield return null;
}
flash = null;
Refresh();
}
public void PlayDeath()
{
if (Sim.Active) return; // 가상 실행(적 AI 예측) = 표시 그대로
if (!isActiveAndEnabled) return;
StartCoroutine(DeathRoutine());
}
IEnumerator DeathRoutine()
{
selectRing.enabled = false;
highlightRing.enabled = false;
overwatchArea.gameObject.SetActive(false);
if (anim != null) anim.Play(ClipName("damage"), false, 99f, false);
float t = 0f;
const float dur = 0.45f;
Vector3 s0 = transform.localScale;
var sr = anim != null ? anim.Renderer : body;
while (t < dur)
{
t += Time.deltaTime;
float k = Mathf.Clamp01(t / dur);
transform.localScale = s0 * (1f - 0.5f * k);
var c = sr.color;
c.a = 1f - k;
sr.color = c;
yield return null;
}
unit.gameObject.SetActive(false);
}
}
}