using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; namespace TKS.OutGame { /// /// 로비 배경 연출(PD 2026-11-11 「펄럭이는 깃발 · 환하게 비쳐진 관청의 불빛이나 등불 · 가끔 날아다니는 새」) — 값 = 표 lobby_scene.csv(LobbyScene · C45). /// 한 Frame 캔버스(「Ambient」 · 배경 그림 위 · 영웅 아래)에 모두 담는다 — 매 프레임 바뀌는 것끼리만 다시 묶임(P41 · 화면 캔버스는 그대로). /// 좌표 = 배경 그림 px(그림 크기 artW × artH) → 이 층(그림을 꽉 채움)의 단위로 매 프레임 환산(그림이 화면 폭에 맞춰 커져도 같은 자리). /// public class LobbyAmbient : MonoBehaviour { class Glow { public Graphic g; public LobbyScene.Row row; public float baseA; } class Mover { public RectTransform rt; public Image img; public Sprite[] frames; public float x; public float y; public float vx; public float vy; public float size; public float t; public float life; public float ph; public float rot; public float vrot; public bool on; } RectTransform area; RectTransform back; RectTransform front; float artW; float artH; readonly List glows = new List(); readonly List birds = new List(); readonly List petals = new List(); LobbyScene.Row birdRow; LobbyScene.Row petalRow; float nextFlock; float nextPetal; float openedAt; List src; List All(string kind) { if (src == null) return LobbyScene.All(kind); var list = new List(); foreach (var r in src) if (r.kind == kind) list.Add(r); return list; } LobbyScene.Row First(string kind) { var l = All(kind); return l.Count > 0 ? l[0] : null; } /// 영웅 자리 층(그림을 꽉 채움 · 그림 좌표 = 정규화 앵커) — 뒤 연출(깃발·불빛)과 앞 연출(새·꽃잎) 사이. public RectTransform Lineup { get; private set; } /// /// 그림(art) 위에 장면 층(Frame 캔버스 1장)을 만든다 — 순서 = Back(깃발·불빛) → Lineup(그림자·영웅 · 대기 프레임 재생) → Front(새·꽃잎 · PD 2026-11-11 「벚꽃잎은 유닛보다 앞」). /// 영웅 대기 프레임이 매 프레임 바뀌므로 영웅도 이 Frame 층에 둔다(화면 캔버스 = Event 그대로 · P41). /// public static LobbyAmbient Build(RectTransform art, float artW, float artH, List rows = null) { var layer = UiLayer.Group("Ambient", art, UiRate.Frame); var a = layer.gameObject.AddComponent(); a.src = rows; // null = 로비 표(lobby_scene.csv) · 내정 = 그 탭 줄(DomesticScene.Page) a.area = layer; a.back = UiKit.Fill("Back", layer); a.Lineup = UiKit.Fill("Lineup", layer); a.front = UiKit.Fill("Front", layer); a.artW = artW; a.artH = artH; a.Setup(); return a; } /// 그림 px 사각형(왼쪽 위 x,y · 폭 w · 높이 h) = 정규화 앵커(그림과 함께 늘어남) · 뒤 층. RectTransform ArtRect(string name, float x, float y, float w, float h) { var rt = UiKit.Rect(name, back, new Vector2(0f, 1f), new Vector2(0.5f, 0.5f), Vector2.zero, Vector2.zero); rt.anchorMin = new Vector2(x / artW, 1f - (y + h) / artH); rt.anchorMax = new Vector2((x + w) / artW, 1f - y / artH); rt.offsetMin = rt.offsetMax = Vector2.zero; return rt; } static Sprite[] Load(LobbyScene.Row r) { var list = new List(); foreach (var id in r.Sprites) { var sp = UISkin.Icon(id); if (sp == null && id.IndexOf('/') >= 0) sp = Resources.Load(id); // 타이틀 연출 = Resources 경로(UI/Title/pack/… · 테마 등록 없이 · 2026-11-13) if (sp != null) list.Add(sp); } return list.ToArray(); } void Setup() { // 깃발(원래 깃발 위에 같은 자리 · UiWaveImage) foreach (var r in All("flag")) { var sp = Load(r); if (sp.Length == 0) continue; var rt = ArtRect("Flag_" + sp[0].name, r.x, r.y, r.w, r.h); var wv = rt.gameObject.AddComponent(); wv.sprite = sp[0]; wv.raycastTarget = false; wv.amp = r.amp; wv.period = r.period; wv.waves = r.waves; wv.phase = r.phase; wv.billow = Mathf.Max(0f, r.billow); if (r.shade >= 0f) wv.shade = r.shade; wv.color = r.color; } // 풀·꽃 흔들림(내정 연병장 · PD 2026-11-12) = 깃발과 같은 격자 · 아래끝 고정 · i_n 1 = 좌우 뒤집음 foreach (var r in All("sway")) { var sp = Load(r); if (sp.Length == 0) continue; var rt = ArtRect("Sway_" + sp[0].name, r.x, r.y, r.w, r.h); var wv = rt.gameObject.AddComponent(); wv.sprite = sp[0]; wv.raycastTarget = false; wv.fromBottom = true; wv.rows = 6; wv.cols = 3; wv.amp = r.amp; wv.period = r.period; wv.waves = r.waves; wv.phase = r.phase; wv.shade = r.shade >= 0f ? r.shade : 0f; wv.color = r.color; if (r.n == 1) rt.localScale = new Vector3(-1f, 1f, 1f); } // 불빛(관청 창 · 문 앞 번짐 · 석등) = 알파 깜박임 foreach (var r in All("light")) { var sp = Load(r); if (sp.Length == 0) continue; var img = ArtRect("Light_" + sp[0].name, r.x, r.y, r.w, r.h).gameObject.AddComponent(); img.sprite = sp[0]; img.raycastTarget = false; img.color = r.color; glows.Add(new Glow { g = img, row = r, baseA = r.color.a }); } birdRow = First("bird"); petalRow = First("petal"); openedAt = Time.unscaledTime; if (birdRow != null) { var fr = Load(birdRow); for (int i = 0; i < Mathf.Max(0, birdRow.count) && fr.Length > 0; i++) birds.Add(NewMover("Bird" + i, fr)); nextFlock = openedAt + birdRow.first; } if (petalRow != null) { var fr = Load(petalRow); for (int i = 0; i < Mathf.Max(0, petalRow.count) && fr.Length > 0; i++) petals.Add(NewMover("Petal" + i, fr)); nextPetal = openedAt + petalRow.first; } } Mover NewMover(string name, Sprite[] frames) { var rt = UiKit.Rect(name, front, new Vector2(0f, 1f), new Vector2(0.5f, 0.5f), Vector2.zero, Vector2.zero); var img = rt.gameObject.AddComponent(); img.raycastTarget = false; img.sprite = frames[0]; rt.gameObject.SetActive(false); return new Mover { rt = rt, img = img, frames = frames }; } void OnEnable() { // 화면을 다시 열면 새 떼 · 꽃잎 시계를 다시 시작(열자마자 하나는 보이게 = 표 f_first) float now = Time.unscaledTime; if (birdRow != null) nextFlock = now + birdRow.first; if (petalRow != null) nextPetal = now + petalRow.first; foreach (var m in birds) Hide(m); foreach (var m in petals) Hide(m); } static void Hide(Mover m) { m.on = false; if (m.rt != null) m.rt.gameObject.SetActive(false); } static float Flicker(LobbyScene.Row r, float t) { // 촛불 = 느린 물결 + 빠른 떨림(두 사인의 합 · 0~1) float p = Mathf.Max(0.05f, r.period); float s = 0.62f * Mathf.Sin((t / p + r.phase) * Mathf.PI * 2f) + 0.38f * Mathf.Sin((t / p * 2.71f + r.phase * 1.7f) * Mathf.PI * 2f); return Mathf.Lerp(r.min, r.max, 0.5f + 0.5f * s); } void Update() { float t = Time.unscaledTime; float dt = Mathf.Min(0.1f, Time.unscaledDeltaTime); float unit = area.rect.width / Mathf.Max(1f, artW); if (unit <= 0f) return; foreach (var g in glows) { var c = g.g.color; c.a = g.baseA * Flicker(g.row, t); g.g.color = c; } UpdateBirds(t, dt, unit); UpdatePetals(t, dt, unit); } void UpdateBirds(float t, float dt, float unit) { if (birdRow == null || birds.Count == 0) return; bool any = false; foreach (var b in birds) any |= b.on; if (!any && t >= nextFlock) { // 새 떼 1번 — 왼→오 또는 오→왼 · 앞장선 새 뒤로 비스듬히 int n = Random.Range(1, birds.Count + 1); float dir = Random.value < 0.5f ? 1f : -1f; float y0 = birdRow.y + Random.value * birdRow.h; float sp = birdRow.speed * Random.Range(0.85f, 1.15f); for (int i = 0; i < n; i++) { var b = birds[i]; b.on = true; b.size = birdRow.w * Random.Range(0.85f, 1.05f); b.x = dir > 0f ? -b.size - i * b.size * 1.3f : artW + b.size + i * b.size * 1.3f; // 뒤따르는 새 = 위·아래 번갈아 조금씩(떼가 세로로 퍼지면 좌우 버튼 열 사이 틈에 걸림 · QA r1 M5) b.y = y0 + (i % 2 == 1 ? -1f : 1f) * ((i + 1) / 2) * b.size * 0.3f; b.vx = dir * sp; b.ph = Random.value; b.rt.gameObject.SetActive(true); b.rt.localScale = new Vector3(dir, 1f, 1f); // 그림 = 오른쪽을 봄 } nextFlock = t + Random.Range(birdRow.gapMin, birdRow.gapMax); } foreach (var b in birds) { if (!b.on) continue; b.x += b.vx * dt; float bob = birdRow.amp * Mathf.Sin((t / Mathf.Max(0.05f, birdRow.period) * 0.5f + b.ph) * Mathf.PI * 2f); int f = (int)((t / Mathf.Max(0.05f, birdRow.period) + b.ph) * b.frames.Length) % b.frames.Length; b.img.sprite = b.frames[f]; var sz = b.frames[f].rect.size; float k = b.size / Mathf.Max(1f, sz.x); b.rt.sizeDelta = sz * k * unit; b.rt.anchoredPosition = new Vector2(b.x * unit, -(b.y + bob) * unit); if ((b.vx > 0f && b.x > artW + b.size * 2f) || (b.vx < 0f && b.x < -b.size * 2f)) { Hide(b); nextFlock = Mathf.Max(nextFlock, t + Random.Range(birdRow.gapMin, birdRow.gapMax)); } } } void UpdatePetals(float t, float dt, float unit) { if (petalRow == null || petals.Count == 0) return; if (t >= nextPetal) { foreach (var p in petals) { if (p.on) continue; p.on = true; p.x = petalRow.x + Random.value * petalRow.w; p.y = petalRow.y + Random.value * petalRow.h; p.vy = petalRow.speed * Random.Range(0.8f, 1.2f); p.vx = petalRow.speed * Random.Range(0.35f, 0.7f); p.size = petalRow.scale * Random.Range(0.8f, 1.15f); p.ph = Random.value; p.t = 0f; p.life = Mathf.Max(0.5f, petalRow.period * Random.Range(0.8f, 1.2f)); p.rot = Random.value * 360f; p.vrot = Random.Range(-90f, 90f); p.img.sprite = p.frames[Random.Range(0, p.frames.Length)]; p.rt.gameObject.SetActive(true); break; } nextPetal = t + Random.Range(petalRow.gapMin, petalRow.gapMax); } foreach (var p in petals) { if (!p.on) continue; p.t += dt; if (p.t >= p.life) { Hide(p); continue; } p.x += p.vx * dt; p.y += p.vy * dt; p.rot += p.vrot * dt; float sway = petalRow.amp * Mathf.Sin((p.t * 1.3f + p.ph) * Mathf.PI * 2f); var sz = p.img.sprite.rect.size; float k = p.size / Mathf.Max(1f, Mathf.Max(sz.x, sz.y)); p.rt.sizeDelta = sz * k * unit; p.rt.anchoredPosition = new Vector2((p.x + sway) * unit, -p.y * unit); p.rt.localEulerAngles = new Vector3(0f, 0f, p.rot); // 나타남(f_min 초) · 사라짐(f_max 초) — PD 2026-11-11 「짧게 떨어지는 느낌으로 보이다 투명하게 없어지도록」 float fin = Mathf.Max(0.05f, petalRow.min), fout = Mathf.Max(0.05f, petalRow.max); float a = Mathf.Clamp01(Mathf.Min(p.t / fin, (p.life - p.t) / fout)); var c = p.img.color; if (!Mathf.Approximately(c.a, a)) { c.a = a; p.img.color = c; } } } } }