ThreeKingdomsSlingshot/Assets/Scripts/Runtime/BattleHUD.cs

272 lines
14 KiB
C#
Raw Normal View History

using System.Collections.Generic;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
namespace TKS
{
/// <summary>화면 HUD(uGUI · 코드 생성): 스테이지·턴·유닛 수·안내·선택 유닛 정보·미니맵·스킬 아이콘(추후)·결과 오버레이.</summary>
public class BattleHUD : MonoBehaviour
{
BattleManager battle;
Camera uiCam;
CameraController camCtrl;
Text stageText;
Text turnText;
Text countText;
Text messageText;
Text hintText;
Text unitText;
GameObject resultOverlay;
Text resultTitle;
Text resultSub;
Unit selected;
Image minimapBg;
Image minimapView;
readonly Dictionary<Unit, Image> minimapDots = new Dictionary<Unit, Image>();
public void Build(BattleManager b, Camera cam, CameraController cc)
{
battle = b;
uiCam = cam;
camCtrl = cc;
var canvas = gameObject.AddComponent<Canvas>();
canvas.renderMode = RenderMode.ScreenSpaceCamera;
canvas.worldCamera = cam;
canvas.planeDistance = 1f;
canvas.sortingOrder = 100;
var scaler = gameObject.AddComponent<CanvasScaler>();
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(1080f, 1920f);
scaler.matchWidthOrHeight = 0.5f;
gameObject.AddComponent<GraphicRaycaster>();
if (EventSystem.current == null && FindFirstObjectByType<EventSystem>() == null)
{
var es = new GameObject("EventSystem");
es.AddComponent<EventSystem>();
es.AddComponent<StandaloneInputModule>();
}
stageText = MakeText("Stage", new Vector2(0f, 1f), new Vector2(0f, 1f), new Vector2(0f, 1f), new Vector2(30f, -28f), new Vector2(760f, 70f), 40, TextAnchor.UpperLeft);
countText = MakeText("Count", new Vector2(1f, 1f), new Vector2(1f, 1f), new Vector2(1f, 1f), new Vector2(-30f, -28f), new Vector2(520f, 70f), 36, TextAnchor.UpperRight);
turnText = MakeText("Turn", new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), new Vector2(0f, -92f), new Vector2(700f, 70f), 46, TextAnchor.UpperCenter);
turnText.color = new Color(1f, 0.95f, 0.6f);
messageText = MakeText("Message", new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), new Vector2(0f, -160f), new Vector2(1040f, 60f), 30, TextAnchor.UpperCenter);
messageText.color = new Color(0.9f, 0.95f, 1f);
hintText = MakeText("Hint", new Vector2(0.5f, 0f), new Vector2(0.5f, 0f), new Vector2(0.5f, 0f), new Vector2(0f, 36f), new Vector2(1040f, 60f), 28, TextAnchor.LowerCenter);
hintText.color = new Color(0.85f, 0.85f, 0.85f);
unitText = MakeText("UnitInfo", new Vector2(0f, 0f), new Vector2(0f, 0f), new Vector2(0f, 0f), new Vector2(30f, 110f), new Vector2(760f, 170f), 32, TextAnchor.LowerLeft);
// 스킬 아이콘(초기 단계 미구현 · 자리만)
var skill = MakeImage("SkillIcon", transform, new Vector2(1f, 0f), new Vector2(1f, 0f), new Vector2(1f, 0f), new Vector2(-36f, 110f), new Vector2(170f, 170f), new Color(0.3f, 0.3f, 0.33f, 0.8f));
var skillLabel = MakeText("Label", new Vector2(0f, 0f), new Vector2(1f, 1f), new Vector2(0.5f, 0.5f), Vector2.zero, Vector2.zero, 30, TextAnchor.MiddleCenter);
skillLabel.transform.SetParent(skill.transform, false);
skillLabel.text = "스킬\n(추후)";
skillLabel.color = new Color(0.75f, 0.75f, 0.75f);
// 미니맵 (전장 비율 · 탭하면 그 지점으로 카메라 이동)
Vector2 bs = battle.Config.boardSize;
float mmW = 180f;
float mmH = mmW * bs.y / Mathf.Max(0.01f, bs.x);
minimapBg = MakeImage("Minimap", transform, new Vector2(1f, 1f), new Vector2(1f, 1f), new Vector2(1f, 1f), new Vector2(-30f, -215f), new Vector2(mmW, mmH), new Color(0.06f, 0.1f, 0.06f, 0.8f));
var mmBorder = MakeImage("Border", minimapBg.transform, Vector2.zero, Vector2.one, new Vector2(0.5f, 0.5f), Vector2.zero, new Vector2(6f, 6f), new Color(0.85f, 0.75f, 0.45f, 0.9f));
mmBorder.transform.SetAsFirstSibling();
var mmMid = MakeImage("Mid", minimapBg.transform, new Vector2(0f, 0.5f), new Vector2(1f, 0.5f), new Vector2(0.5f, 0.5f), Vector2.zero, new Vector2(0f, 2f), new Color(1f, 1f, 1f, 0.25f));
minimapView = MakeImage("View", minimapBg.transform, Vector2.zero, Vector2.zero, Vector2.zero, Vector2.zero, new Vector2(40f, 60f), new Color(1f, 1f, 1f, 0.2f));
resultOverlay = MakeImage("ResultOverlay", transform, Vector2.zero, Vector2.one, new Vector2(0.5f, 0.5f), Vector2.zero, Vector2.zero, new Color(0f, 0f, 0f, 0.65f)).gameObject;
resultTitle = MakeText("Title", new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(0f, 60f), new Vector2(1000f, 160f), 110, TextAnchor.MiddleCenter);
resultTitle.transform.SetParent(resultOverlay.transform, false);
resultSub = MakeText("Sub", new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(0f, -80f), new Vector2(1000f, 120f), 40, TextAnchor.MiddleCenter);
resultSub.transform.SetParent(resultOverlay.transform, false);
resultOverlay.SetActive(false);
}
Text MakeText(string name, Vector2 anchorMin, Vector2 anchorMax, Vector2 pivot, Vector2 anchoredPos, Vector2 size, int fontSize, TextAnchor align)
{
var go = new GameObject(name);
go.transform.SetParent(transform, false);
var rt = go.AddComponent<RectTransform>();
rt.anchorMin = anchorMin;
rt.anchorMax = anchorMax;
rt.pivot = pivot;
rt.anchoredPosition = anchoredPos;
rt.sizeDelta = size;
var t = go.AddComponent<Text>();
t.font = TextUtil.Font;
t.fontSize = fontSize;
t.alignment = align;
t.color = Color.white;
t.horizontalOverflow = HorizontalWrapMode.Overflow;
t.verticalOverflow = VerticalWrapMode.Overflow;
t.raycastTarget = false;
var sh = go.AddComponent<Shadow>();
sh.effectDistance = new Vector2(2f, -2f);
sh.effectColor = new Color(0f, 0f, 0f, 0.8f);
return t;
}
Image MakeImage(string name, Transform parent, Vector2 anchorMin, Vector2 anchorMax, Vector2 pivot, Vector2 anchoredPos, Vector2 size, Color color)
{
var go = new GameObject(name);
go.transform.SetParent(parent, false);
var rt = go.AddComponent<RectTransform>();
rt.anchorMin = anchorMin;
rt.anchorMax = anchorMax;
rt.pivot = pivot;
rt.anchoredPosition = anchoredPos;
rt.sizeDelta = size;
var img = go.AddComponent<Image>();
img.color = color;
img.raycastTarget = false;
return img;
}
public void SetSelected(Unit u)
{
selected = u;
}
/// <summary>화면 점이 미니맵 안이면 해당 월드 좌표를 돌려준다.</summary>
public bool TryMinimapTap(Vector2 screen, out Vector2 world)
{
world = Vector2.zero;
if (minimapBg == null || battle == null || battle.Board == null) return false;
var rt = minimapBg.rectTransform;
if (!RectTransformUtility.RectangleContainsScreenPoint(rt, screen, uiCam)) return false;
Vector2 local;
if (!RectTransformUtility.ScreenPointToLocalPointInRectangle(rt, screen, uiCam, out local)) return false;
var r = rt.rect;
float nx = Mathf.Clamp01((local.x - r.xMin) / r.width);
float ny = Mathf.Clamp01((local.y - r.yMin) / r.height);
var b = battle.Board.Rect;
world = new Vector2(b.xMin + nx * b.width, b.yMin + ny * b.height);
return true;
}
void LateUpdate()
{
if (battle == null || battle.Config == null || battle.Config.stages.Length == 0) return;
Refresh();
RefreshMinimap();
}
void Refresh()
{
var stage = battle.Stage;
stageText.text = "제" + (battle.StageIndex + 1) + "전투 · " + stage.title + (string.IsNullOrEmpty(stage.subtitle) ? "" : " " + stage.subtitle);
countText.text = "아군 " + battle.AliveCount(Faction.Player) + " · 적 " + battle.AliveCount(Faction.Enemy);
switch (battle.Phase)
{
case BattlePhase.PlayerTurn: turnText.text = "아군 턴 " + battle.TurnNumber; break;
case BattlePhase.Resolving: turnText.text = "진행 중…"; break;
case BattlePhase.EnemyTurn: turnText.text = "적군 턴 " + battle.TurnNumber; break;
case BattlePhase.StageClear: turnText.text = "승리"; break;
case BattlePhase.Defeat: turnText.text = "패배"; break;
default: turnText.text = ""; break;
}
messageText.text = battle.LastMessage;
if (battle.Phase == BattlePhase.PlayerTurn)
{
hintText.text = battle.OnlyBonusMovesRemain()
? "추가 이동: 유닛을 당겨 이동 · 빈 곳을 탭하면 건너뜀"
: "유닛을 잡고 뒤로 당겼다 놓기 = 이동·공격 · 유닛 탭 = 사거리 · 빈 곳 드래그 = 전장 이동";
}
else
{
hintText.text = "";
}
if (selected != null && selected.IsAlive)
{
var u = selected;
string side = u.Faction == Faction.Player ? "아군" : "적군";
string line1 = side + " · " + u.DisplayName + " · " + UnitTypeInfo.Korean(u.Type) + (u.IsBoss ? " (장수)" : "");
string line2 = "HP " + u.Hp + "/" + u.MaxHp + " 공 " + u.Atk + " 방 " + u.Def;
string line3 = "슬링 " + u.SlingMaxDistance.ToString("0.#");
전투 로직 #1080 — 알까기 충돌(보병) · 관통·아군 직전 정지(기병) · 밀며 함께 전진(창병) · 밀린 아군 공격 기회 · 궁병 제자리 사격(사거리 = 슬링샷)+접근 20 % · 궁수 근접 피해 50 %/피격 +50 % · 원화 1차·2차 반영(전장 배경 + 황건적 5 + 동탁군 4) · 테스트 22/22 - ActionResolver 전면 재작성: InfantryRoutine(접촉점 정지 · Push 접촉 방향 × collisionPushRatio · 적이면 공격+반격 뒤 밀림) · SpearRoutine(앞 유닛과 같은 속도로 함께 전진 · 접촉 간격 유지) · CavalryRoutine(벽만 막음 · StopBeforeUnclearedAllies) · ArcherRoutine(AttackRange = 슬링 · ArcherMoveAllow = 슬링 × archerRangeRatio) · PushedAllyAttack(규칙 ⑤ · ActionOutcome.Pushed/Notes) - Combat: Damage(melee) 궁병 근접 배율(근접 유닛 상대만) · FirstUnitOnPath · TouchingOpponent · FreeTravel ignore - BalanceConfig/Defaults: archerMeleeDealtRatio 0.5 · archerMeleeTakenRatio 1.5 · collisionPushRatio 1 · boardArt battlefield_01 (자산 재시드) - Board: Resources/Board/<boardArt> 원화 배경(격자·진영 띠 생략) · UnitArtImporter Board 규칙 · GameBootstrap - Resources/Units: hgj_infantry·spear·cavalry·archer·boss_zhangjiao · dz_infantry·spear·cavalry·archer (원화 세션 납품 정지 그림 · rig_frames_export static) · Resources/Board/battlefield_01.png - BattleManager Describe 「밀어냄 · 밀린 B → 적 공격」 · BattleHUD 궁병 줄 · 테스트 22종(신규·개정 7 + 원화 확인) · CLAUDE.md · README Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-04 06:56:02 +00:00
if (u.Type == UnitType.Archer) line3 += " 사거리 " + u.AttackRange.ToString("0.#") + " (접근 +" + u.ArcherMoveAllow.ToString("0.#") + ")";
if (u.Type == UnitType.Spear) line3 += " 견제 반경 " + u.AttackRange.ToString("0.#");
string state = "";
if (u.Faction == Faction.Player)
{
if (u.BonusMoveAvailable) state = " [추가 이동]";
else if (u.ActedThisTurn) state = " [행동 완료]";
}
if (u.Overwatch) state += " [대기 중]";
unitText.text = line1 + "\n" + line2 + "\n" + line3 + state;
}
else
{
unitText.text = "유닛을 선택하세요";
}
bool result = battle.Phase == BattlePhase.StageClear || battle.Phase == BattlePhase.Defeat;
if (resultOverlay.activeSelf != result) resultOverlay.SetActive(result);
if (result)
{
bool win = battle.Phase == BattlePhase.StageClear;
resultTitle.text = win ? "승리" : "패배";
resultTitle.color = win ? new Color(1f, 0.9f, 0.4f) : new Color(1f, 0.5f, 0.5f);
resultSub.text = win
? stage.title + " 제압!\n화면을 탭하면 다음 전투로"
: stage.title + " 에서 패배\n화면을 탭하면 다시 도전";
}
}
void RefreshMinimap()
{
if (minimapBg == null || battle.Board == null) return;
var r = minimapBg.rectTransform.rect;
var b = battle.Board.Rect;
float sx = r.width / b.width;
float sy = r.height / b.height;
// 카메라 시야 사각형
if (camCtrl != null)
{
var v = camCtrl.ViewRect;
float x0 = Mathf.Clamp((v.xMin - b.xMin) * sx, 0f, r.width);
float y0 = Mathf.Clamp((v.yMin - b.yMin) * sy, 0f, r.height);
float x1 = Mathf.Clamp((v.xMax - b.xMin) * sx, 0f, r.width);
float y1 = Mathf.Clamp((v.yMax - b.yMin) * sy, 0f, r.height);
minimapView.rectTransform.anchoredPosition = new Vector2(x0, y0);
minimapView.rectTransform.sizeDelta = new Vector2(Mathf.Max(2f, x1 - x0), Mathf.Max(2f, y1 - y0));
}
// 유닛 점
foreach (var u in battle.Units)
{
if (u == null) continue;
Image dot;
if (!minimapDots.TryGetValue(u, out dot))
{
dot = MakeImage("Dot", minimapBg.transform, Vector2.zero, Vector2.zero, new Vector2(0.5f, 0.5f), Vector2.zero,
new Vector2(u.IsBoss ? 14f : 10f, u.IsBoss ? 14f : 10f),
u.Faction == Faction.Player ? UnitView.PlayerColor : UnitView.EnemyColor);
if (u.IsBoss) dot.color = new Color(1f, 0.85f, 0.3f);
minimapDots[u] = dot;
}
bool show = u.IsAlive;
if (dot.gameObject.activeSelf != show) dot.gameObject.SetActive(show);
if (show) dot.rectTransform.anchoredPosition = new Vector2((u.Position.x - b.xMin) * sx, (u.Position.y - b.yMin) * sy);
}
// 사라진 유닛 정리
var dead = new List<Unit>();
foreach (var kv in minimapDots)
{
if (kv.Key == null) dead.Add(kv.Key);
}
foreach (var k in dead)
{
if (minimapDots[k] != null) Destroy(minimapDots[k].gameObject);
minimapDots.Remove(k);
}
}
}
}