using UnityEngine; using UnityEngine.UI; namespace TKS.OutGame { /// /// 훈련 장면 병사 동작(내정 · PD 2026-11-12 「직군별 병사(범용 유닛)가 훈련 중인 장면을 애니메이션화」) — 원화 3 클립을 그대로 이어 붙임(새로 그리는 애니 없음 · 통일성): /// 대기 숨 고르기(front_idle · 깜빡임) → 자세(front_ready) → 모으기(같은 그림 · 뒤로 당김) → 타격(front_action · 앞으로 내딛음 + 흙먼지) → 유지 → 대기. /// 박자 = 표 domestic_scene.csv train_drill(전투 공격과 같은 「서기 0.5 · 모으기 0.3 · 타격 0.1 · 유지 0.5」 · PD 2026-11-01) · 모든 병사가 한 구령(같은 시계)에 · 뒷줄은 f_phase 만큼 늦게. /// 매 프레임 바뀌므로 Frame 캔버스(장면 층) 안에서만. /// public class UiDrillAnim : MonoBehaviour { const string Idle = "front_idle"; const string Ready = "front_ready"; const string Action = "front_action"; const string DrillReady = "drill_ready"; const string DrillAction = "drill_action"; Image img; RectTransform rt; UnitArtData data; Sprite[] idle; Sprite readySp; Sprite actionSp; LobbyScene.Row drill; LobbyScene.Row dustRow; float delay; float k; float artW; float idleOffset; float nextBlink; float blinkEnd = -1f; Image dust; Vector2 dustBase; float lastHit = -99f; int lastCycle = -1; float readyDx; float actionDx; float readyScale = 1f; float actionScale = 1f; Vector3 baseScale = Vector3.one; Vector2 dustPos; Image shotImg; LobbyScene.Row shotRow; Vector2 shotPos; float shotPx; /// 현재 단계(테스트 · 캡처 로그): 0 대기 · 1 자세 · 2 모으기 · 3 타격 · 4 유지. public int Stage { get; private set; } /// /// feet = 표 train_feet 줄(그 병종 그림 · f_x = 자세 · f_w = 타격 오른쪽 이동 그림 px) — 모든 자세의 뒷발이 대기 때 뒷발 자리에 오게 그림을 옮김 /// (PD 2026-11-12 「위치는 고정한 채 발 위치와 모션만 바뀌어 부자연스러움」 → 몸이 내디딘 발을 따라 앞으로 · 그림을 바꿀 때 뒷발은 그대로라 미끄럼 없음). null = 옮기지 않음. /// public static UiDrillAnim Attach(Image img, UnitArtData data, LobbyScene.Row drill, float delay, float scale, float artW, Image dust, LobbyScene.Row dustRow, LobbyScene.Row feet) { Sprite[] id, rd, ac; if (img == null || data == null || drill == null) return null; if (!data.Clips.TryGetValue(Idle, out id) || id.Length == 0) return null; var a = img.gameObject.AddComponent(); a.img = img; a.rt = img.rectTransform; a.data = data; a.idle = id; // 훈련 전용 클립(drill_ready · drill_action)이 있으면 그것(궁병 = 대기 원화 리그로 다시 만든 2 장 · PD 2026-11-12 「전혀 연결되지 않아. 다시 제작」) · 없으면 전투 자세·타격 a.readySp = Pick(data, DrillReady, Ready, id[0]); a.actionSp = Pick(data, DrillAction, Action, a.readySp); a.baseScale = img.rectTransform.localScale; a.drill = drill; a.delay = delay; a.k = scale; a.artW = artW; a.dust = dust; a.dustRow = dustRow; if (dust != null) { a.dustBase = dust.rectTransform.sizeDelta; a.dustPos = dust.rectTransform.anchoredPosition; dust.enabled = false; } if (feet != null) { a.readyDx = feet.x; a.actionDx = feet.w; // 자세·타격 화면 배율(f_min · f_max · 기본 1) = 그림 크기가 대기와 다른 병종(책사 · PD 2026-11-12 「크기가 갑자기 바뀌어서 어색」) · 그림 파일은 그대로 · 발(피벗) 기준 a.readyScale = feet.min > 0.01f ? feet.min : 1f; a.actionScale = feet.max > 0.01f ? feet.max : 1f; } // 숨 고르기는 병사마다 다르게(같이 숨 쉬면 어색) · 동작은 한 구령 a.idleOffset = Random.value * data.CycleSeconds(Idle, id.Length); UiLayer.Isolate(img.gameObject); return a; } static Sprite Pick(UnitArtData data, string first, string second, Sprite fallback) { Sprite[] f; if (data.Clips.TryGetValue(first, out f) && f.Length > 0) return f[0]; if (data.Clips.TryGetValue(second, out f) && f.Length > 0) return f[0]; return fallback; } /// /// 타격 순간 날아가는 화살(궁병 · 표 train_shot) — 병사 그림의 자식(같은 Frame 캔버스 · 같이 움직임) · 자세 그림에 메긴 화살 자리에서 시작해 f_speed 각도로 f_amp 그림 px 날아가며 작아지고 흐려짐. /// shot: s_sprite = 「병사 아트 id|화살 테마 아이콘」 · f_x,f_y = 화살 가운데(그림 px · 왼쪽 위 0,0) · f_w = 길이 · f_scale = 굵기 배율 · f_amp = 거리 · f_period = 나는 시간(초) · f_speed = 각도(도 · 위 +). /// public void AttachShot(Sprite arrow, LobbyScene.Row shot) { if (arrow == null || shot == null || data == null) return; float fw = data.Meta.frame != null && data.Meta.frame.Length > 1 ? data.Meta.frame[0] : idle[0].rect.width; float fh = data.Meta.frame != null && data.Meta.frame.Length > 1 ? data.Meta.frame[1] : idle[0].rect.height; float px = fw > 0f ? rt.rect.width / fw : 0f; var s = UiKit.Rect("Arrow", rt, Vector2.zero, new Vector2(0.5f, 0.5f), new Vector2(shot.x * px, (fh - shot.y) * px), new Vector2(shot.w * px, shot.w * px * arrow.rect.height / Mathf.Max(1f, arrow.rect.width) * (shot.scale > 0.01f ? shot.scale : 1f))); s.localEulerAngles = new Vector3(0f, 0f, shot.speed); shotImg = s.gameObject.AddComponent(); shotImg.sprite = arrow; shotImg.raycastTarget = false; shotImg.enabled = false; shotRow = shot; shotPos = s.anchoredPosition; shotPx = px; } /// 화살이 지금 날고 있는지(테스트 · 캡처 로그). public bool ShotFlying { get { return shotImg != null && shotImg.enabled; } } float Cycle { get { return Mathf.Max(0.1f, drill.rest + drill.stand + drill.ready + drill.hit + drill.hold); } } static float Smooth(float x) { x = Mathf.Clamp01(x); return x * x * (3f - 2f * x); } void Update() { if (img == null || rt == null || drill == null || data == null || idle == null || idle.Length == 0) return; // Play 중 재컴파일(스크립트 다시 불러옴) = 직렬화 안 된 값이 비면 멈춤 float now = Time.unscaledTime; var area = rt.parent as RectTransform; float unit = area != null && artW > 0f ? area.rect.width / artW : 1f; float local = now - delay; int cyc = Mathf.FloorToInt(local / Cycle); float t = local - cyc * Cycle; float t1 = drill.rest, t2 = t1 + drill.stand, t3 = t2 + drill.ready, t4 = t3 + drill.hit; float back = drill.min * k, lunge = drill.amp * k; // 그림 px(병사 크기 배율 곱) · 표 0 = 끔(뒷발 맞춤으로 대신) // 그림 px(원본 틀 = meta frame · Android 512 임포트와 무관) → 이 그래픽 단위 float fw = data.Meta.frame != null && data.Meta.frame.Length > 0 && data.Meta.frame[0] > 0 ? data.Meta.frame[0] : idle[0].rect.width; float px = fw > 0f ? rt.rect.width / fw : 0f; float off; // + = 위로(뒤로) float dx; // + = 오른쪽(뒷발 맞춤) Sprite sp; if (t < t1) { Stage = 0; float it = now + idleOffset; int f = data.ApplyBlink(Idle, idle.Length, data.FrameAt(Idle, idle.Length, it), it, ref nextBlink, ref blinkEnd); sp = idle[Mathf.Clamp(f, 0, idle.Length - 1)]; off = lunge > 0f ? -lunge * (1f - Smooth(t / Mathf.Max(0.01f, drill.period))) : 0f; dx = 0f; } else if (t < t2) { Stage = 1; sp = readySp; off = 0f; dx = readyDx; } else if (t < t3) { Stage = 2; sp = readySp; off = back * Smooth((t - t2) / Mathf.Max(0.01f, drill.ready)); dx = readyDx; } else if (t < t4) { Stage = 3; sp = actionSp; off = Mathf.Lerp(back, -lunge, Smooth((t - t3) / Mathf.Max(0.01f, drill.hit))); dx = actionDx; if (cyc != lastCycle) { lastCycle = cyc; lastHit = now; } } else { Stage = 4; sp = actionSp; off = -lunge; dx = actionDx; } if (img.sprite != sp) img.sprite = sp; var pos = new Vector2(Mathf.Round(dx * px), Mathf.Round(off * unit)); // 정수 화면 px(비정수 1~2 px 계단 방지 · R21 함정) if (rt.anchoredPosition != pos) rt.anchoredPosition = pos; float sc = Stage == 1 || Stage == 2 ? readyScale : Stage >= 3 ? actionScale : 1f; var ls = new Vector3(baseScale.x * sc, baseScale.y * sc, baseScale.z); if (rt.localScale != ls) rt.localScale = ls; UpdateDust(now, unit, actionDx * px); UpdateShot(now); } void UpdateShot(float now) { if (shotImg == null || shotRow == null) return; float life = Mathf.Max(0.05f, shotRow.period); float u = (now - lastHit) / life; bool on = u >= 0f && u < 1f; if (shotImg.enabled != on) shotImg.enabled = on; if (!on) return; float e = 1f - (1f - u) * (1f - u); // 빠르게 나가다 늦춤 float r = shotRow.speed * Mathf.Deg2Rad; var srt = shotImg.rectTransform; srt.anchoredPosition = shotPos + new Vector2(Mathf.Cos(r), Mathf.Sin(r)) * (shotRow.amp * shotPx * e); float k = Mathf.Lerp(1f, 0.7f, e); // 멀어지며 작아짐 srt.localScale = new Vector3(k, k, 1f); var c = shotImg.color; c.a = u < 0.6f ? 1f : 1f - (u - 0.6f) / 0.4f; shotImg.color = c; } void UpdateDust(float now, float unit, float hitDx) { if (dust == null || dustRow == null) return; float life = Mathf.Max(0.05f, dustRow.period); float u = (now - lastHit) / life; bool on = u >= 0f && u < 1f; if (dust.enabled != on) dust.enabled = on; if (!on) return; var dp = dustPos + new Vector2(Mathf.Round(hitDx), 0f); // 흙먼지 = 내디딘 몸 아래 if (dust.rectTransform.anchoredPosition != dp) dust.rectTransform.anchoredPosition = dp; float grow = Mathf.Lerp(1f, Mathf.Max(1f, dustRow.max), Smooth(u)); var size = dustBase * grow; if (dust.rectTransform.sizeDelta != size) dust.rectTransform.sizeDelta = size; var c = dustRow.color; c.a *= 1f - u; dust.color = c; } } }