ThreeKingdomsSlingshot/Assets/Scripts/Runtime/SkillTest/SkillTestBootstrap.cs

686 lines
32 KiB
C#
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
using System.Collections.Generic;
using System.Globalization;
using TKS.OutGame;
using UnityEngine;
using UnityEngine.UI;
namespace TKS
{
/// <summary>
/// 스킬 테스트 씬(<c>Assets/Scenes/SkillTest.unity</c> · 메뉴 TKS/Skill Test/Build Scene · 2026-11-09 PD
/// 「유닛마다 액티브 스킬을 구현하기 쉽도록 목록에서 유닛을 선택 · 선택된 유닛으로 기본 스킬 사용 방식 및 슬링샷 조작이 가능한 공간」).
/// WL #947 스킬 테스트 씬과 같은 역할 — 전투 코드(BattleManager · SlingshotInput · ActionResolver · SkillSystem · BattleHUD)는 그대로 쓰고
/// 월드맵·아웃게임·세이브 없이 「고른 영웅 1 + 더미」 만 세운다.
/// 더미 = 죽지 않음 · 기본 행동 안 함(토글) · 배치 = 표 <c>Resources/Skills/skill_test.csv</c>(영웅 출발 자리 기준 상대 좌표).
/// 왼쪽 위 「유닛」 = 영웅 목록(heroes.csv · 병사 제외) · 「이펙트」 = 팩 이펙트 미리보기(에디터 전용 · <c>Assets/VFX</c> 프리팹 검색 → 고른 영웅에 띄움 · 경로 복사).
/// 스킬 이펙트 값 = <c>Resources/Skills/skill_vfx.csv</c>(SkillVfx) · 「다시 배치」 = 표 다시 읽기 + 처음 자리.
/// </summary>
public class SkillTestBootstrap : MonoBehaviour
{
public const string DummyTablePath = "Skills/skill_test";
const string PrefKey = "TKS_SKILLTEST_HERO";
public const string VfxRoot = "Assets/VFX";
/// <summary>「기운 시험」 스위치가 켜는 이펙트 표 키(skill_vfx.csv test_aura 행).</summary>
public const string AuraTestKey = "test_aura";
public BalanceConfig Config;
public static SkillTestBootstrap Instance { get; private set; }
public BattleManager Battle { get; private set; }
public BattleHUD Hud { get; private set; }
public SlingshotInput Sling { get; private set; }
public CameraController CameraCtrl { get; private set; }
public HeroDb.Hero Current { get; private set; }
public IReadOnlyList<HeroDb.Hero> Heroes
{
get { return heroes; }
}
public bool NoCooldown = true;
public bool EnemyActs;
public bool HeroInvincible = true;
readonly List<HeroDb.Hero> heroes = new List<HeroDb.Hero>();
readonly List<RectTransform> blockers = new List<RectTransform>();
Camera cam;
Text infoText;
Text cdLabel, enemyLabel, invLabel, auraLabel;
RectTransform heroPanel, fxPanel;
void Awake()
{
Instance = this;
if (Config == null) Config = Resources.Load<BalanceConfig>("BalanceConfig");
if (Config == null)
{
Debug.LogError("[TKS] BalanceConfig 자산이 없습니다. 메뉴 TKS/Setup Project 를 실행하세요.");
return;
}
cam = Camera.main;
if (cam == null)
{
var cgo = new GameObject("Main Camera");
cgo.tag = "MainCamera";
cam = cgo.AddComponent<Camera>();
}
cam.orthographic = true;
cam.clearFlags = CameraClearFlags.SolidColor;
cam.backgroundColor = new Color(0.07f, 0.09f, 0.11f);
cam.transform.position = new Vector3(0f, 0f, -10f);
cam.nearClipPlane = 0.1f;
cam.farClipPlane = 100f;
cam.transparencySortMode = TransparencySortMode.CustomAxis;
cam.transparencySortAxis = Vector3.up;
cam.depthTextureMode |= DepthTextureMode.Depth; // 이펙트 셰이더 소프트 파티클(GameBootstrap 과 같음)
var board = new GameObject("Board").AddComponent<Board>();
board.Build(Config.boardSize, Config.boardArt);
CameraCtrl = cam.GetComponent<CameraController>();
if (CameraCtrl == null) CameraCtrl = cam.gameObject.AddComponent<CameraController>();
CameraCtrl.Init(board, Config.cameraViewHeight, Config.cameraMinViewHeight, Config.cameraMaxViewHeight);
Battle = new GameObject("BattleManager").AddComponent<BattleManager>();
Battle.Configure(Config, board);
Battle.LineupProvider = Lineup;
Battle.EnemyHold = !EnemyActs;
Hud = new GameObject("HUD").AddComponent<BattleHUD>();
Hud.Build(Battle, cam, CameraCtrl);
Battle.Hud = Hud;
Sling = new GameObject("SlingshotInput").AddComponent<SlingshotInput>();
Sling.Init(Battle, cam, Hud, CameraCtrl);
Sling.BlockPress = OverPanel;
Battle.Sling = Sling;
var cc = CameraCtrl;
Battle.UnitActing += u => cc.Follow(u.transform);
Battle.PhaseChanged += OnPhase;
Battle.StageStarted += () => cc.SnapTo(Focus());
var db = OutGameData.Default;
if (db != null && db.Heroes != null)
foreach (var h in db.Heroes.heroes)
if (h != null && !h.isSoldier && !string.IsNullOrEmpty(h.id)) heroes.Add(h);
if (heroes.Count == 0)
{
Debug.LogError("[TKS] 스킬 테스트: 영웅 표가 비었습니다(메뉴 TKS/Import OutGame Data).");
return;
}
// 진영(촉 · 위 · 오 · 군) 순으로 묶되 진영 안은 표 순서 그대로(안정 정렬)
var order = new List<string>();
foreach (var h in heroes)
if (!order.Contains(h.faction)) order.Add(h.faction);
var sorted = new List<HeroDb.Hero>();
foreach (var f in order)
foreach (var h in heroes)
if (h.faction == f) sorted.Add(h);
heroes.Clear();
heroes.AddRange(sorted);
BuildUi();
string last = PlayerPrefs.GetString(PrefKey, "");
var first = heroes.Find(h => h.id == last) ?? heroes[0];
SelectHero(first);
}
void OnDestroy()
{
if (Instance == this) Instance = null;
SkillVfx.TestTravelKey = ""; // 시험 키가 다른 씬(실제 전투)으로 새지 않게
}
// ---------- 전투 구성 ----------
List<LineupEntry> Lineup()
{
var list = new List<LineupEntry>();
var h = Current;
if (h == null) return list;
list.Add(new LineupEntry
{
slot = heroSlot, id = h.id, name = h.nameKo, type = h.type, art = h.art,
hpMult = h.hpMult, atkMult = h.atkMult, defMult = h.defMult,
skillActive = h.skillActive ?? "", skillPassive = h.skillPassive ?? "", activeLv = 1, passiveLv = 1, passiveMult = 1f, general = true,
});
return list;
}
public void SelectHero(HeroDb.Hero h)
{
if (h == null) return;
Current = h;
PlayerPrefs.SetString(PrefKey, h.id);
if (heroPanel != null) heroPanel.gameObject.SetActive(false);
Restart();
}
/// <summary>처음 자리로 다시 배치(표 skill_vfx.csv · skill_test.csv 다시 읽음).</summary>
public void Restart()
{
if (Battle == null || Current == null) return;
Battle.EnemyHold = !EnemyActs;
var stage = MakeStage(); // 영웅 자리(heroSlot)도 이 표에서 → Lineup 보다 먼저
var mode = new BattleMode { kind = "skilltest", stage = stage, maxTurns = 0, onExit = Restart };
Battle.StartMode(mode);
ApplyFlags();
RefreshInfo();
}
/// <summary>영웅 출발 자리 = BalanceConfig.playerSpawnPoints[heroSlot](표 skill_test.csv 의 e_type = hero 행 n_slot).</summary>
int heroSlot;
Vector2 HeroSpawn
{
get
{
var sp = Config.playerSpawnPoints;
return sp != null && sp.Length > 0 ? sp[Mathf.Clamp(heroSlot, 0, sp.Length - 1)] : Vector2.zero;
}
}
BalanceConfig.StageDef MakeStage()
{
var st = new BalanceConfig.StageDef { title = "스킬 테스트", subtitle = "", aiAccuracy = 1f, aiAggroRange = 999f };
var ta = Resources.Load<TextAsset>(DummyTablePath);
heroSlot = 0;
if (ta == null) return st;
var rows = new List<string[]>();
string[] head = null;
foreach (var raw in ta.text.Replace("\r", "").Split('\n'))
{
var line = raw.TrimStart('');
if (line.Trim().Length == 0 || line.TrimStart().StartsWith("#")) continue;
var c = SkillVfx.SplitCsv(line);
if (head == null) head = c;
else rows.Add(c);
}
if (head == null) return st;
int iType = Array.IndexOf(head, "e_type"), iSlot = Array.IndexOf(head, "n_slot");
foreach (var c in rows) // 영웅 자리 먼저(더미 좌표 = 영웅 자리 기준)
{
if (iType < 0 || iType >= c.Length || c[iType].Trim().ToLowerInvariant() != "hero") continue;
if (iSlot >= 0 && iSlot < c.Length) int.TryParse(c[iSlot].Trim(), out heroSlot);
}
head = null;
foreach (var raw in ta.text.Replace("\r", "").Split('\n'))
{
var line = raw.TrimStart('');
if (line.Trim().Length == 0 || line.TrimStart().StartsWith("#")) continue;
var c = SkillVfx.SplitCsv(line);
if (head == null)
{
head = c;
continue;
}
UnitType type = UnitType.Infantry;
float dx = 0f, dy = 0f, hp = 1f;
string name = "";
bool skip = false;
for (int i = 0; i < head.Length && i < c.Length; i++)
{
string k = head[i].Trim(), v = c[i].Trim();
if (v.Length == 0) continue;
if (k == "e_type")
{
if (v.ToLowerInvariant() == "hero" || !Enum.TryParse(v, true, out type)) skip = true;
}
else if (k == "f_dx") dx = F(v);
else if (k == "f_dy") dy = F(v);
else if (k == "f_hp") hp = F(v);
else if (k == "s_name") name = v;
}
if (skip) continue;
st.enemies.Add(new BalanceConfig.EnemySpawn
{
name = name.Length > 0 ? name : "더미 " + UnitTypeInfo.Korean(type),
type = type,
position = HeroSpawn + new Vector2(dx, dy),
hpMult = hp > 0f ? hp : 1f,
art = DummyArt(type),
});
}
return st;
}
static float F(string v)
{
float f;
return float.TryParse(v, NumberStyles.Float, CultureInfo.InvariantCulture, out f) ? f : 0f;
}
/// <summary>더미 그림 = 스테이지 표의 첫 번째 같은 병종 병사 그림(장수 제외 · 없으면 도형).</summary>
string DummyArt(UnitType t)
{
if (Config.stages == null) return "";
foreach (var s in Config.stages)
{
if (s == null || s.enemies == null) continue;
foreach (var e in s.enemies)
if (e != null && e.type == t && !e.isBoss && !e.isGeneral && !string.IsNullOrEmpty(e.art)) return e.art;
}
return "";
}
void ApplyFlags()
{
if (Battle == null) return;
foreach (var u in Battle.Units)
{
if (u == null) continue;
u.Invincible = u.Faction == Faction.Enemy || HeroInvincible;
}
}
void OnPhase(BattlePhase p)
{
if (p != BattlePhase.PlayerTurn) return;
ApplyFlags();
foreach (var u in Battle.Units)
{
if (u == null || !u.IsAlive) continue;
if (u.Faction == Faction.Enemy && u.Hp < u.MaxHp)
{
u.Hp = u.MaxHp; // 더미 체력 회복(다음 시험을 같은 조건으로)
if (u.View != null) u.View.Refresh();
}
if (NoCooldown && u.Faction == Faction.Player && Battle.Skills != null)
{
var s = Battle.Skills.Of(u);
if (s != null) s.ReadyTurn = Battle.TurnNumber;
if (u.View != null) u.View.Refresh();
}
}
if (CameraCtrl != null) CameraCtrl.FocusOn(Focus());
Battle.NotifyChanged();
}
Vector2 Focus()
{
var ps = Battle.AliveUnits(Faction.Player);
Vector2 p = ps.Count > 0 ? ps[0].Position : HeroSpawn;
return p + new Vector2(0f, Config.cameraViewHeight * 0.2f);
}
// ---------- UI ----------
static readonly Color PanelColor = new Color(0.08f, 0.09f, 0.12f, 0.92f);
static readonly Color BtnColor = new Color(0.22f, 0.26f, 0.34f, 0.95f);
static readonly Color BtnOnColor = new Color(0.25f, 0.5f, 0.32f, 0.95f);
static readonly Color TextColor = new Color(0.95f, 0.93f, 0.86f);
bool OverPanel(Vector2 screen)
{
foreach (var rt in blockers)
if (rt != null && rt.gameObject.activeInHierarchy && RectTransformUtility.RectangleContainsScreenPoint(rt, screen, null)) return true;
return false;
}
void BuildUi()
{
var cgo = new GameObject("SkillTestUI");
var canvas = cgo.AddComponent<Canvas>();
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
canvas.sortingOrder = 200;
var scaler = cgo.AddComponent<CanvasScaler>();
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(1080f, 1920f);
scaler.matchWidthOrHeight = 0.5f;
cgo.AddComponent<GraphicRaycaster>();
var root = cgo.transform;
// 왼쪽 위: 유닛 · 이펙트 열기 + 고른 영웅 정보
var top = Panel("Top", root, new Vector2(0f, 1f), new Vector2(0f, 1f), new Vector2(0f, 1f), new Vector2(16f, -290f), new Vector2(640f, 300f), PanelColor);
blockers.Add(top);
Btn(top, "▤ 유닛 선택", new Vector2(10f, -10f), new Vector2(300f, 76f), () => Toggle(heroPanel));
Btn(top, "✦ 이펙트 보기", new Vector2(320f, -10f), new Vector2(310f, 76f), () => Toggle(fxPanel));
infoText = Label(top, "", 27, TextAnchor.UpperLeft);
SetRect(infoText.rectTransform, Vector2.zero, Vector2.one, new Vector2(0.5f, 0.5f), Vector2.zero, Vector2.zero);
infoText.rectTransform.offsetMin = new Vector2(14f, 6f);
infoText.rectTransform.offsetMax = new Vector2(-14f, -94f);
// 왼쪽 아래: 시험 토글
var tools = Panel("Tools", root, Vector2.zero, Vector2.zero, Vector2.zero, new Vector2(16f, 40f), new Vector2(560f, 260f), PanelColor);
blockers.Add(tools);
Btn(tools, "다시 배치", new Vector2(10f, -10f), new Vector2(265f, 74f), Restart);
cdLabel = Btn(tools, "", new Vector2(285f, -10f), new Vector2(265f, 74f), () =>
{
NoCooldown = !NoCooldown;
OnPhase(Battle.Phase);
RefreshInfo();
});
enemyLabel = Btn(tools, "", new Vector2(10f, -94f), new Vector2(265f, 74f), () =>
{
EnemyActs = !EnemyActs;
Battle.EnemyHold = !EnemyActs;
RefreshInfo();
});
invLabel = Btn(tools, "", new Vector2(285f, -94f), new Vector2(265f, 74f), () =>
{
HeroInvincible = !HeroInvincible;
ApplyFlags();
RefreshInfo();
});
Btn(tools, "이펙트 표 다시 읽기", new Vector2(10f, -178f), new Vector2(265f, 70f), () =>
{
SkillVfx.Reload();
HitFeel.Reload();
Battle.LastMessage = "skill_vfx.csv 다시 읽음 · 행 " + SkillVfx.Rows.Count;
Battle.NotifyChanged();
RefreshInfo();
});
// 몸 뒤덮는 기운 시험(PD 2026-11-10 「첨부 같은 느낌으로 몸을 뒤덮는 연출 · 스킬용 시험」): 켜면 슬링샷 공격 돌진에 표 test_aura 행을 함께 띄움
auraLabel = Btn(tools, "", new Vector2(285f, -178f), new Vector2(265f, 70f), () =>
{
SkillVfx.TestTravelKey = SkillVfx.TestTravelKey.Length > 0 ? "" : AuraTestKey;
RefreshInfo();
});
heroPanel = BuildHeroList(root);
blockers.Add(heroPanel);
fxPanel = BuildFxBrowser(root);
blockers.Add(fxPanel);
heroPanel.gameObject.SetActive(false);
fxPanel.gameObject.SetActive(false);
}
void Toggle(RectTransform p)
{
if (p == null) return;
bool on = !p.gameObject.activeSelf;
heroPanel.gameObject.SetActive(false);
fxPanel.gameObject.SetActive(false);
p.gameObject.SetActive(on);
}
RectTransform BuildHeroList(Transform root)
{
var p = Panel("HeroList", root, new Vector2(0f, 0f), new Vector2(0.78f, 1f), new Vector2(0f, 1f), new Vector2(16f, -550f), Vector2.zero, PanelColor);
p.offsetMin = new Vector2(16f, 320f);
p.offsetMax = new Vector2(0f, -550f);
var content = Scroll(p);
string lastFaction = null;
foreach (var h in heroes)
{
if (h.faction != lastFaction)
{
lastFaction = h.faction;
var hd = Label(content, "— " + UiKit.FactionKo(h.faction) + " —", 30, TextAnchor.MiddleLeft);
hd.color = new Color(1f, 0.85f, 0.4f);
hd.gameObject.AddComponent<LayoutElement>().preferredHeight = 52f;
}
var hero = h;
var a = SkillCatalog.Get(h.skillActive);
string text = h.nameKo + " · " + UnitTypeInfo.Korean(h.type) + (a != null ? " · " + a.NameKo + "(" + TriggerKo(a.Trigger) + ")" : " · 액티브 없음");
var b = ListItem(content, text, () => SelectHero(hero));
b.gameObject.name = "Hero_" + h.id;
}
return p;
}
RectTransform BuildFxBrowser(Transform root)
{
var p = Panel("FxBrowser", root, new Vector2(0f, 0f), new Vector2(0.86f, 1f), new Vector2(0f, 1f), Vector2.zero, Vector2.zero, PanelColor);
p.offsetMin = new Vector2(16f, 320f);
p.offsetMax = new Vector2(0f, -550f);
#if UNITY_EDITOR
var bar = Rect("Bar", p, new Vector2(0f, 1f), new Vector2(1f, 1f), new Vector2(0.5f, 1f), new Vector2(0f, -8f), new Vector2(-16f, 170f));
var search = SearchField(bar, "검색(예: HCFX_Hit · Slash · Earth)", new Vector2(8f, -4f), new Vector2(520f, 74f));
var scaleBtn = Btn(bar, "", new Vector2(536f, -4f), new Vector2(160f, 74f), null);
var tiltBtn = Btn(bar, "", new Vector2(704f, -4f), new Vector2(170f, 74f), null);
var faceBtn = Btn(bar, "", new Vector2(8f, -86f), new Vector2(250f, 74f), null);
var copyBtn = Btn(bar, "경로 복사", new Vector2(266f, -86f), new Vector2(200f, 74f), null);
var countText = Label(bar, "", 26, TextAnchor.MiddleLeft);
SetRect(countText.rectTransform, new Vector2(0f, 1f), new Vector2(0f, 1f), new Vector2(0f, 1f), new Vector2(476f, -86f), new Vector2(400f, 74f));
var listHost = Rect("ListHost", p, Vector2.zero, Vector2.one, new Vector2(0.5f, 0.5f), Vector2.zero, Vector2.zero);
listHost.offsetMin = new Vector2(0f, 0f);
listHost.offsetMax = new Vector2(0f, -180f);
var content = Scroll(listHost);
var all = new List<string>();
foreach (var g in UnityEditor.AssetDatabase.FindAssets("t:Prefab", new[] { VfxRoot }))
all.Add(UnityEditor.AssetDatabase.GUIDToAssetPath(g));
all.Sort(StringComparer.OrdinalIgnoreCase);
float scale = 1f;
float tilt = 0f;
bool face = false;
string lastPath = null;
Action refreshBar = () =>
{
scaleBtn.text = "크기 ×" + scale.ToString("0.##");
tiltBtn.text = "기울기 " + tilt.ToString("0") + "°";
faceBtn.text = face ? "방향: 더미 쪽" : "방향: 없음";
};
Action<string> play = path =>
{
lastPath = path;
var prefab = UnityEditor.AssetDatabase.LoadAssetAtPath<GameObject>(path);
var hero = Battle.AliveUnits(Faction.Player);
if (prefab == null || hero.Count == 0) return;
var u = hero[0];
Vector2 dir = Vector2.up;
var ds = Battle.AliveUnits(Faction.Enemy);
if (ds.Count > 0) dir = (ds[0].Position - u.Position).normalized;
var go = SkillVfx.SpawnPrefab(prefab, u.Position + new Vector2(0f, u.ArtHeight * 0.45f), scale * u.ArtHeight, tilt,
face ? (Vector2?)dir : null, SkillVfx.SortingBase, 1f, null);
Destroy(go, SkillVfx.AutoLife(go));
Battle.LastMessage = "이펙트 " + System.IO.Path.GetFileNameWithoutExtension(path) + " · 크기 ×" + scale.ToString("0.##") + " · 기울기 " + tilt.ToString("0") + "°";
Battle.NotifyChanged();
};
scaleBtn.GetComponentInParent<Button>().onClick.AddListener(() =>
{
scale = scale >= 4f ? 0.25f : scale * 2f;
refreshBar();
if (lastPath != null) play(lastPath);
});
tiltBtn.GetComponentInParent<Button>().onClick.AddListener(() =>
{
tilt = tilt > -1f ? -45f : tilt > -46f ? -90f : 0f;
refreshBar();
if (lastPath != null) play(lastPath);
});
faceBtn.GetComponentInParent<Button>().onClick.AddListener(() =>
{
face = !face;
refreshBar();
if (lastPath != null) play(lastPath);
});
copyBtn.GetComponentInParent<Button>().onClick.AddListener(() =>
{
if (lastPath == null) return;
GUIUtility.systemCopyBuffer = lastPath;
Battle.LastMessage = "복사: " + lastPath;
Battle.NotifyChanged();
});
const int MaxRows = 120;
Action<string> fill = q =>
{
for (int i = content.childCount - 1; i >= 0; i--) Destroy(content.GetChild(i).gameObject);
int n = 0, hit = 0;
string ql = (q ?? "").Trim().ToLowerInvariant();
foreach (var path in all)
{
if (ql.Length > 0 && path.ToLowerInvariant().IndexOf(ql, StringComparison.Ordinal) < 0) continue;
hit++;
if (n >= MaxRows) continue;
n++;
var pth = path;
string shortName = pth.Substring(VfxRoot.Length + 1);
ListItem(content, shortName, () => play(pth)).GetComponentInChildren<Text>().fontSize = 24;
}
countText.text = hit + "개" + (hit > MaxRows ? " (앞 " + MaxRows + ")" : "") + " / 전체 " + all.Count;
};
search.onValueChanged.AddListener(v => fill(v));
refreshBar();
fill("");
#else
var t = Label(p, "이펙트 보기는 에디터에서만(Assets/VFX 검색)", 30, TextAnchor.MiddleCenter);
SetRect(t.rectTransform, Vector2.zero, Vector2.one, new Vector2(0.5f, 0.5f), Vector2.zero, Vector2.zero);
#endif
return p;
}
static string TriggerKo(SkillTrigger t)
{
return t == SkillTrigger.NextAction ? "다음 행동" : t == SkillTrigger.Instant ? "즉시" : "상시";
}
void RefreshInfo()
{
if (cdLabel != null) cdLabel.text = "쿨 무시: " + (NoCooldown ? "켬" : "끔");
if (enemyLabel != null) enemyLabel.text = "더미 행동: " + (EnemyActs ? "켬" : "끔");
if (invLabel != null) invLabel.text = "영웅 무적: " + (HeroInvincible ? "켬" : "끔");
if (auraLabel != null) auraLabel.text = "기운 시험: " + (SkillVfx.TestTravelKey.Length > 0 ? "켬" : "끔");
if (infoText == null || Current == null) return;
var h = Current;
var a = SkillCatalog.Get(h.skillActive);
var ps = SkillCatalog.Get(h.skillPassive);
string s = h.nameKo + " · " + UnitTypeInfo.Korean(h.type) + " · " + UiKit.FactionKo(h.faction);
s += "\n액티브 " + (a != null ? a.NameKo + "(" + TriggerKo(a.Trigger) + " · 쿨 " + a.Cooldown + ")" : "없음");
s += "\n패시브 " + (ps != null ? ps.NameKo : "없음");
// 이 영웅에게 걸리는 이펙트 표 행 수(스킬 id 행 + 일반 공격 basic_<병종> 행 · 진영 s_camp 일치)
string camp = string.IsNullOrEmpty(h.faction) ? "other" : h.faction.ToLowerInvariant();
string basic = SkillVfx.BasicKey(h.type);
int c = 0, t = 0, hit = 0, bt = 0, bh = 0;
foreach (var r in SkillVfx.Rows)
{
if (r.camp.Length > 0 && r.camp != camp) continue;
if (a != null && r.HasKey(a.Id))
{
if (r.evt == SkillVfx.EventCast) c++;
else if (r.evt == SkillVfx.EventTravel) t++;
else if (r.evt == SkillVfx.EventHit) hit++;
}
if (r.HasKey(basic))
{
if (r.evt == SkillVfx.EventTravel) bt++;
else if (r.evt == SkillVfx.EventHit) bh++;
}
}
s += "\n이펙트 표: 스킬 발동 " + c + " · 돌진 " + t + " · 명중 " + hit + " / 일반 돌진 " + bt + " · 명중 " + bh;
infoText.text = s;
}
// ---------- UI 부품 ----------
static RectTransform Rect(string name, Transform parent, Vector2 aMin, Vector2 aMax, Vector2 pivot, Vector2 pos, Vector2 size)
{
var go = new GameObject(name, typeof(RectTransform));
go.transform.SetParent(parent, false);
var rt = (RectTransform)go.transform;
SetRect(rt, aMin, aMax, pivot, pos, size);
return rt;
}
static void SetRect(RectTransform rt, Vector2 aMin, Vector2 aMax, Vector2 pivot, Vector2 pos, Vector2 size)
{
rt.anchorMin = aMin;
rt.anchorMax = aMax;
rt.pivot = pivot;
rt.anchoredPosition = pos;
rt.sizeDelta = size;
}
static RectTransform Panel(string name, Transform parent, Vector2 aMin, Vector2 aMax, Vector2 pivot, Vector2 pos, Vector2 size, Color c)
{
var rt = Rect(name, parent, aMin, aMax, pivot, pos, size);
var img = rt.gameObject.AddComponent<Image>();
img.color = c;
return rt;
}
static Text Label(Transform parent, string text, int size, TextAnchor anchor)
{
var go = new GameObject("Text", typeof(RectTransform));
go.transform.SetParent(parent, false);
var t = go.AddComponent<Text>();
t.font = TextUtil.Font;
t.fontSize = size;
t.alignment = anchor;
t.color = TextColor;
t.text = text;
t.raycastTarget = false;
t.horizontalOverflow = HorizontalWrapMode.Wrap;
t.verticalOverflow = VerticalWrapMode.Overflow;
return t;
}
/// <summary>왼쪽 위 기준 버튼 · 반환 = 글자(토글 문구 갱신용).</summary>
static Text Btn(Transform parent, string text, Vector2 pos, Vector2 size, Action onClick)
{
var rt = Panel("Btn", parent, new Vector2(0f, 1f), new Vector2(0f, 1f), new Vector2(0f, 1f), pos, size, BtnColor);
var b = rt.gameObject.AddComponent<Button>();
if (onClick != null) b.onClick.AddListener(() => onClick());
var t = Label(rt, text, 30, TextAnchor.MiddleCenter);
SetRect(t.rectTransform, Vector2.zero, Vector2.one, new Vector2(0.5f, 0.5f), Vector2.zero, Vector2.zero);
return t;
}
static Button ListItem(Transform content, string text, Action onClick)
{
var go = new GameObject("Item", typeof(RectTransform));
go.transform.SetParent(content, false);
var img = go.AddComponent<Image>();
img.color = BtnColor;
var b = go.AddComponent<Button>();
b.onClick.AddListener(() => onClick());
go.AddComponent<LayoutElement>().preferredHeight = 76f;
var t = Label(go.transform, text, 30, TextAnchor.MiddleLeft);
SetRect(t.rectTransform, Vector2.zero, Vector2.one, new Vector2(0.5f, 0.5f), Vector2.zero, Vector2.zero);
t.rectTransform.offsetMin = new Vector2(16f, 0f);
t.rectTransform.offsetMax = new Vector2(-10f, 0f);
t.horizontalOverflow = HorizontalWrapMode.Overflow;
return b;
}
static Transform Scroll(RectTransform host)
{
var view = Rect("Viewport", host, Vector2.zero, Vector2.one, new Vector2(0.5f, 0.5f), Vector2.zero, Vector2.zero);
view.offsetMin = new Vector2(8f, 8f);
view.offsetMax = new Vector2(-8f, -8f);
view.gameObject.AddComponent<Image>().color = new Color(0f, 0f, 0f, 0.01f);
view.gameObject.AddComponent<RectMask2D>();
var content = Rect("Content", view, new Vector2(0f, 1f), new Vector2(1f, 1f), new Vector2(0.5f, 1f), Vector2.zero, Vector2.zero);
var lay = content.gameObject.AddComponent<VerticalLayoutGroup>();
lay.spacing = 6f;
lay.childControlHeight = true;
lay.childControlWidth = true;
lay.childForceExpandHeight = false;
lay.childForceExpandWidth = true;
content.gameObject.AddComponent<ContentSizeFitter>().verticalFit = ContentSizeFitter.FitMode.PreferredSize;
var sr = host.gameObject.AddComponent<ScrollRect>();
sr.viewport = view;
sr.content = content;
sr.horizontal = false;
sr.movementType = ScrollRect.MovementType.Clamped;
sr.scrollSensitivity = 60f;
return content;
}
#if UNITY_EDITOR
static InputField SearchField(Transform parent, string placeholder, Vector2 pos, Vector2 size)
{
var rt = Panel("Search", parent, new Vector2(0f, 1f), new Vector2(0f, 1f), new Vector2(0f, 1f), pos, size, new Color(1f, 1f, 1f, 0.92f));
var f = rt.gameObject.AddComponent<InputField>();
var t = Label(rt, "", 30, TextAnchor.MiddleLeft);
t.color = Color.black;
t.supportRichText = false;
SetRect(t.rectTransform, Vector2.zero, Vector2.one, new Vector2(0.5f, 0.5f), Vector2.zero, Vector2.zero);
t.rectTransform.offsetMin = new Vector2(14f, 0f);
t.rectTransform.offsetMax = new Vector2(-10f, 0f);
var ph = Label(rt, placeholder, 26, TextAnchor.MiddleLeft);
ph.color = new Color(0f, 0f, 0f, 0.4f);
SetRect(ph.rectTransform, Vector2.zero, Vector2.one, new Vector2(0.5f, 0.5f), Vector2.zero, Vector2.zero);
ph.rectTransform.offsetMin = new Vector2(14f, 0f);
f.textComponent = t;
f.placeholder = ph;
return f;
}
#endif
}
}