using System; using System.Collections.Generic; using System.Globalization; using TKS.OutGame; using UnityEngine; using UnityEngine.UI; namespace TKS { /// /// 스킬 테스트 씬(Assets/Scenes/SkillTest.unity · 메뉴 TKS/Skill Test/Build Scene · 2026-11-09 PD /// 「유닛마다 액티브 스킬을 구현하기 쉽도록 목록에서 유닛을 선택 · 선택된 유닛으로 기본 스킬 사용 방식 및 슬링샷 조작이 가능한 공간」). /// WL #947 스킬 테스트 씬과 같은 역할 — 전투 코드(BattleManager · SlingshotInput · ActionResolver · SkillSystem · BattleHUD)는 그대로 쓰고 /// 월드맵·아웃게임·세이브 없이 「고른 영웅 1 + 더미」 만 세운다. /// 더미 = 죽지 않음 · 기본 행동 안 함(토글) · 배치 = 표 Resources/Skills/skill_test.csv(영웅 출발 자리 기준 상대 좌표). /// 왼쪽 위 「유닛」 = 영웅 목록(heroes.csv · 병사 제외) · 「이펙트」 = 팩 이펙트 미리보기(에디터 전용 · Assets/VFX 프리팹 검색 → 고른 영웅에 띄움 · 경로 복사). /// 스킬 이펙트 값 = Resources/Skills/skill_vfx.csv(SkillVfx) · 「다시 배치」 = 표 다시 읽기 + 처음 자리. /// public class SkillTestBootstrap : MonoBehaviour { public const string DummyTablePath = "Skills/skill_test"; const string PrefKey = "TKS_SKILLTEST_HERO"; public const string VfxRoot = "Assets/VFX"; /// 「기운 시험」 스위치가 켜는 이펙트 표 키(skill_vfx.csv test_aura 행). 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 Heroes { get { return heroes; } } public bool NoCooldown = true; public bool EnemyActs; public bool HeroInvincible = true; readonly List heroes = new List(); readonly List blockers = new List(); Camera cam; Text infoText; Text cdLabel, enemyLabel, invLabel, auraLabel; RectTransform heroPanel, fxPanel; void Awake() { Instance = this; if (Config == null) Config = Resources.Load("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(); } 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.Build(Config.boardSize, Config.boardArt); CameraCtrl = cam.GetComponent(); if (CameraCtrl == null) CameraCtrl = cam.gameObject.AddComponent(); CameraCtrl.Init(board, Config.cameraViewHeight, Config.cameraMinViewHeight, Config.cameraMaxViewHeight); Battle = new GameObject("BattleManager").AddComponent(); Battle.Configure(Config, board); Battle.LineupProvider = Lineup; Battle.EnemyHold = !EnemyActs; Hud = new GameObject("HUD").AddComponent(); Hud.Build(Battle, cam, CameraCtrl); Battle.Hud = Hud; Sling = new GameObject("SlingshotInput").AddComponent(); 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(); foreach (var h in heroes) if (!order.Contains(h.faction)) order.Add(h.faction); var sorted = new List(); 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 Lineup() { var list = new List(); 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(); } /// 처음 자리로 다시 배치(표 skill_vfx.csv · skill_test.csv 다시 읽음). 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(); } /// 영웅 출발 자리 = BalanceConfig.playerSpawnPoints[heroSlot](표 skill_test.csv 의 e_type = hero 행 n_slot). 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(DummyTablePath); heroSlot = 0; if (ta == null) return st; var rows = new List(); 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; } /// 더미 그림 = 스테이지 표의 첫 번째 같은 병종 병사 그림(장수 제외 · 없으면 도형). 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.renderMode = RenderMode.ScreenSpaceOverlay; canvas.sortingOrder = 200; var scaler = cgo.AddComponent(); scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize; scaler.referenceResolution = new Vector2(1080f, 1920f); scaler.matchWidthOrHeight = 0.5f; cgo.AddComponent(); 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().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(); 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 play = path => { lastPath = path; var prefab = UnityEditor.AssetDatabase.LoadAssetAtPath(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