ThreeKingdomsSlingshot/Assets/Scripts/OutGame/UI/GachaCutscene.cs

1296 lines
65 KiB
C#

using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
namespace TKS.OutGame
{
/// <summary>
/// 뽑기 연출 표(Resources/UI/gacha_scene.csv · PD 2026-11-13 「삼고초려 — 초가집 앞 유비·관우·장비의 뒷모습 · 문이 덜컹거린 뒤 서서히 열릴 때 새어 나오는 빛으로 등급을 알게 ·
/// 흰(★) · 주황(★★) · 무지개(★★★)」). 형식 = 로비 장면 표(LobbyScene.Load) · 좌표 = 배경 그림 px(왼쪽 위 0,0 · 아래로 +) · 자리·크기·박자·색 = 표(C45) — 줄 뜻은 표 머리 주석.
/// </summary>
public static class GachaScene
{
public const string TablePath = "UI/gacha_scene";
static List<LobbyScene.Row> rows;
public static void Reload()
{
rows = null;
}
public static List<LobbyScene.Row> Rows
{
get
{
if (rows == null) rows = LobbyScene.Load(TablePath);
return rows;
}
}
public static LobbyScene.Row First(string kind)
{
return DomesticScene.First(Rows, kind);
}
public static List<LobbyScene.Row> All(string kind)
{
return DomesticScene.All(Rows, kind);
}
/// <summary>등급 줄(tier1~3 · 없으면 아래 등급).</summary>
public static LobbyScene.Row Tier(int tier)
{
for (int t = Mathf.Clamp(tier, 1, 3); t >= 1; t--)
{
var r = First("tier" + t);
if (r != null) return r;
}
return null;
}
/// <summary>등급별 박자 줄(timeN · 없으면 아래 등급 · 그래도 없으면 time) — PD 2026-11-13 「1성·2성은 문 열림이 길어질 필요 없이 바로 열려도 됨」 = 긴 기대감 연출은 ★★★ 만.</summary>
public static LobbyScene.Row Time(int tier)
{
for (int t = Mathf.Clamp(tier, 1, 3); t >= 1; t--)
{
var r = First("time" + t);
if (r != null) return r;
}
return First("time");
}
/// <summary>빛 그림(표 fx 줄 s_sprite 의 n 번째 · 0 광원 · 1 빛줄기 · 2 색 번짐 · 3 별 · 4 점 · 5 마당 빛 · 6 고리).</summary>
public static Sprite Fx(int n)
{
var r = First("fx");
if (r == null) return null;
var ids = r.Sprites;
return n < ids.Length ? UISkin.Icon(ids[n]) : null;
}
/// <summary>표와 배경 그림이 다 있는가(없으면 호출 쪽이 예전 결과 화면).</summary>
public static bool Ready
{
get
{
var v = First("view");
return v != null && UISkin.Icon(v.sprite) != null && First("door") != null && Time(1) != null && First("rattle") != null && First("card") != null && Tier(1) != null;
}
}
}
/// <summary>
/// 뽑기 연출 — ① 삼고초려 장면: 기다림 → 문이 덜컹(여러 번 · 사이에 뜸) → 문이 서서히 열리며 틈으로 등급 색 빛이 새어 나옴(기대감 · 여기서 등급을 알게 됨) → 활짝 열리며 빛이 터지고 삼형제가 반김/놀람
/// ② 카드 획득(PD 레퍼런스 = 카드가 UI 에 등장할 때): 위에서 떨어져 흰 번쩍 → 눌렸다 튀어 오름 → 먼지·반짝이 · 조각 게이지.
/// 빛 = 부드러운 흰 그림을 덧셈 합성으로 겹침(가운데 흰 광원 + 길고 가는 빛줄기 + 색 번짐 + 별가루 입자 · PD 2026-11-13 「단순히 무지개색이 아니라 예시처럼 화려해 보이게 · 파티클」).
/// 문이 활짝 열리면 영웅의 검은 실루엣만 문 안에 보인다(PD 2026-11-13 최종 · 제 색·이름은 카드에서) · ★·★★ 는 덜컹 한 번 뒤 바로 열림 · 긴 기대감 연출은 ★★★ 만(박자 = 표 timeN) · 장면 중 탭 = 건너뛰어 카드로(PD 「터치로 문이 열리는 장면을 스킵」).
/// 만드는 법 = 내정 장면과 같게(배경 한 장 + 표 좌표 + 원화 프레임 교체 + 자리·배율 움직임) · 삼형제 = 이 연출 전용 그림(Resources/Units/gacha_* · 걸어서 · 무기 없음).
/// 장면·카드는 매 프레임 바뀌므로 Frame 층 · 글자·버튼은 Event 층(P41). 시간 = 실시간(일시정지 무관).
/// </summary>
public class GachaCutscene : MonoBehaviour
{
public enum Step
{
Wait,
Rattle,
Leak,
Open,
Cards,
Done
}
/// <summary>캡처·테스트용: 0 보다 크면 매 프레임 이 초만큼만 흐른다(실시간 무시 · 프레임마다 같은 간격).</summary>
public static float FixedDt = -1f;
/// <summary>캡처·테스트용: true 면 스스로 흐르지 않는다(호출 쪽이 Tick 으로 한 칸씩) · 실제 탭도 받지 않는다.</summary>
public static bool Manual;
const string IdleClip = "back_idle";
/// <summary>덧셈 합성 셰이더(Resources · 없으면 보통 합성으로 — 빛이 덜 화려할 뿐 동작은 같음).</summary>
public const string AdditiveShader = "UI/UiAdditive";
static Material addMat;
/// <summary>빛·별가루용 덧셈 합성 재질(한 장을 같이 씀 = 같은 아틀라스면 한 묶음).</summary>
public static Material AddMat
{
get
{
if (addMat == null)
{
var sh = Resources.Load<Shader>(AdditiveShader);
if (sh != null) addMat = new Material(sh) { name = "GachaAdditive", hideFlags = HideFlags.HideAndDontSave };
}
return addMat;
}
}
class Actor
{
public RectTransform rt;
public Image img;
public UnitArtData data;
public Sprite[] idle;
public Sprite[] react;
public Vector2 home;
public float phase;
public float flip;
public RectTransform bang;
public RectTransform shadow;
public Vector2 shadowHome;
public float idleOffset;
}
class Card
{
public RectTransform rt;
public Image flash;
public RectTransform deco;
public RectTransform rays;
public Image glow;
public Image edge;
public bool rainbow;
public float amb;
public RectTransform sheen;
public float sheenW;
public RectTransform strip;
public RectTransform gauge;
public float g0;
public float g1;
public float start;
public Vector2 home;
public bool landed;
public int tier;
}
class Bit
{
public RectTransform rt;
public Image img;
public Vector2 pos;
public Vector2 vel;
public float grav;
public float drag;
public float t;
public float life;
public float s0;
public float s1;
public float a0;
public float spin;
public float tw;
public float ph;
public bool ring;
public bool live;
}
class Ray
{
public RectTransform rt;
public Image img;
public LobbyScene.Row row;
}
OutGameSession s;
List<GachaPull> pulls;
System.Action onClose;
float mountedTop;
LobbyScene.Row heroRow;
RectTransform sil;
Image silImg;
Image veil;
LobbyScene.Row timeRow, doorRow, rattleRow, zoomRow, tierRow, reactRow, sparkRow, ringRow, cardRow, puffRow, twinkleRow, flashRow, shadowRow, dimRow;
readonly List<Color> tints = new List<Color>();
RectTransform clip, stage, backLayer, doorLayer, groundLayer, shadowLayer, fxLayer, bitLayer, frontLayer, cardsLayer, cardFx, ui;
float artW, artH;
RectTransform leafL, leafR, haloRt, coreRt, leakRt, lobeRoot;
Image leafLImg, leafRImg, dimBack, glow, halo, slit, bloom, coreImg, leakImg, flash, shade, fade;
Vector2 leafLHome, leafRHome;
readonly List<Ray> streaks = new List<Ray>();
readonly List<Ray> lobes = new List<Ray>();
readonly List<Ray> beams = new List<Ray>();
readonly List<Actor> actors = new List<Actor>();
readonly List<Card> cards = new List<Card>();
readonly List<Bit> bits = new List<Bit>();
GameObject hint;
float t;
float tRattle, tOpen, tBurst, tReact, tFull, tEnd;
float tCards = -1f;
float flashA;
float flashT = 99f;
float ambAcc;
int lastBeat = -1;
bool burstDone, doneUi;
float lastScale = -1f;
Sprite coreSp, streakSp, lobeSp, starSp, dotSp, beamSp, ringSp, puffSp;
public Step Stage { get; private set; }
/// <summary>문 빛 등급(뽑힌 것 중 가장 높은 등급 1~3).</summary>
public int Tier { get; private set; }
/// <summary>문 안에 실루엣으로 서는 영웅(가장 높은 등급 · 새 영웅 우선).</summary>
public GachaPull Featured { get; private set; }
/// <summary>검은 실루엣이 보이는가(문이 활짝 열린 뒤 · 테스트).</summary>
public bool SilhouetteShown { get { return silImg != null && silImg.color.a > 0.15f; } }
/// <summary>실루엣 색(검정 · 제 색을 보이지 않음 · 테스트).</summary>
public Color SilhouetteColor { get { return silImg != null ? silImg.color : Color.clear; } }
/// <summary>삼형제 반응 클립 이름(표 tier 줄 i_slot 0 = greet · 1 = shock).</summary>
public string Reaction { get; private set; }
public int ActorCount { get { return actors.Count; } }
public int CardCount { get { return cards.Count; } }
public float Elapsed { get { return t; } }
/// <summary>장면이 끝나 카드로 넘어가는 시각(캡처 길이).</summary>
public float SceneEnd { get { return tEnd; } }
/// <summary>문 열림 비율(0 닫힘 ~ 1 다 열림).</summary>
public float DoorOpen { get; private set; }
/// <summary>지금 문틈으로 새는 빛의 세기(0~1 · 테스트).</summary>
public float LightLevel { get; private set; }
/// <summary>지금까지 덜컹거린 횟수(테스트).</summary>
public int RattleCount { get { return lastBeat + 1; } }
/// <summary>빛줄기 수(표 streakN 줄 · 테스트).</summary>
public int StreakCount { get { return streaks.Count; } }
/// <summary>지금 살아 있는 입자 수(별가루·고리·먼지 · 테스트).</summary>
public int LiveBits
{
get
{
int n = 0;
foreach (var b in bits)
if (b.live) n++;
return n;
}
}
public int CardsLanded
{
get
{
int n = 0;
foreach (var c in cards)
if (c.landed) n++;
return n;
}
}
public static GachaCutscene Show(OutGameRoot root, OutGameSession s, List<GachaPull> pulls, System.Action onClose)
{
var dim = UiKit.Fill("GachaResult", root.TopRoot);
UiLayer.Nest(dim.gameObject, UiRate.Event, true); // 위 덮개 = 자기 캔버스(재화 바와 따로 · PD 2026-11-10)
var c = dim.gameObject.AddComponent<GachaCutscene>();
c.s = s;
c.pulls = pulls;
c.onClose = onClose;
c.mountedTop = root.Battle != null && root.Battle.Config != null ? root.Battle.Config.mountedArtMaxTop : 0f;
c.Build(dim);
return c;
}
// ---------- 만들기 ----------
void Build(RectTransform dim)
{
doorRow = GachaScene.First("door");
rattleRow = GachaScene.First("rattle");
zoomRow = GachaScene.First("zoom");
sparkRow = GachaScene.First("spark");
ringRow = GachaScene.First("ring");
puffRow = GachaScene.First("puff");
twinkleRow = GachaScene.First("twinkle");
flashRow = GachaScene.First("flash");
shadowRow = GachaScene.First("shadow");
dimRow = GachaScene.First("dim");
cardRow = pulls.Count <= 1 && GachaScene.First("card1") != null ? GachaScene.First("card1") : GachaScene.First("card");
Tier = 1;
Featured = null;
foreach (var p in pulls)
{
Tier = Mathf.Max(Tier, Mathf.Clamp(p.tier, 1, 3));
if (Featured == null || p.tier > Featured.tier || (p.tier == Featured.tier && p.isNew && !Featured.isNew)) Featured = p;
}
tierRow = GachaScene.Tier(Tier);
timeRow = GachaScene.Time(Tier);
Reaction = tierRow.slot == 1 ? "shock" : "greet";
reactRow = GachaScene.First(Reaction);
foreach (var r in GachaScene.All("tint" + Tier)) tints.Add(r.color);
if (tints.Count == 0) tints.Add(tierRow.color);
tRattle = timeRow.first + timeRow.stand;
tOpen = tRattle + timeRow.ready;
tBurst = tOpen + timeRow.hit * Mathf.Clamp01(timeRow.waves); // 서서히 열리는 구간 끝 = 활짝 열리기 시작
tReact = tBurst + timeRow.gapMin;
tFull = tOpen + timeRow.hit;
tEnd = tFull + timeRow.hold;
coreSp = GachaScene.Fx(0);
streakSp = GachaScene.Fx(1);
lobeSp = GachaScene.Fx(2);
starSp = GachaScene.Fx(3);
dotSp = GachaScene.Fx(4);
beamSp = GachaScene.Fx(5);
ringSp = GachaScene.Fx(6);
puffSp = puffRow != null ? UISkin.Icon(puffRow.sprite) : null;
var back = UiKit.Paint(dim, GachaScene.First("view").color, false, true); // 그림 밖(다른 화면 비율) 바탕 + 탭 받는 판
var tap = dim.gameObject.AddComponent<Button>();
tap.transition = Selectable.Transition.None;
tap.targetGraphic = back;
tap.onClick.AddListener(OnPointer);
BuildStage(dim);
flash = Full("Flash", dim, Color.white);
SetA(flash, 0f);
fade = Full("Fade", dim, Color.black);
shade = Full("Shade", dim, dimRow != null ? dimRow.color : new Color(0f, 0f, 0f, 0.85f));
shade.gameObject.SetActive(false);
cardsLayer = UiLayer.Group("Cards", dim, UiRate.Frame);
cardFx = UiKit.Fill("CardFx", cardsLayer);
ui = UiKit.Fill("UI", dim);
hint = Hint("화면을 터치하면 건너뜁니다", 34);
Apply(0f);
FitStage();
}
static Image Full(string name, Transform parent, Color c)
{
var img = UiKit.Paint(UiKit.Fill(name, parent), c, false, false);
img.raycastTarget = false;
return img;
}
static void SetA(Graphic g, float a)
{
if (g == null) return;
var c = g.color;
if (Mathf.Approximately(c.a, a)) return;
c.a = a;
g.color = c;
}
static void SetRgb(Graphic g, Color rgb)
{
if (g == null) return;
var c = g.color;
rgb.a = c.a;
if (c != rgb) g.color = rgb;
}
RectTransform Px(string name, Transform parent, float x, float y, float w, float h, float px = 0.5f, float py = 0.5f)
{
return UiKit.Rect(name, parent, new Vector2(0f, 1f), new Vector2(px, py), new Vector2(x, -y), new Vector2(w, h));
}
Image Spr(string name, Transform parent, Sprite sp, float x, float y, float w, float h, float px = 0.5f, float py = 0.5f)
{
var img = Px(name, parent, x, y, w, h, px, py).gameObject.AddComponent<Image>();
img.sprite = sp;
img.raycastTarget = false;
return img;
}
/// <summary>빛 그림 한 장(덧셈 합성 · 처음엔 안 보임).</summary>
Image Glow(string name, Transform parent, Sprite sp, float x, float y, float w, float h, Color c, float px = 0.5f, float py = 0.5f)
{
var img = Spr(name, parent, sp, x, y, w, h, px, py);
c.a = 0f;
img.color = c;
var m = AddMat;
if (m != null) img.material = m;
img.enabled = sp != null;
return img;
}
void BuildStage(RectTransform dim)
{
var view = GachaScene.First("view");
var bg = UISkin.Icon(view.sprite);
artW = view.w > 0f ? view.w : bg.rect.width;
artH = view.h > 0f ? view.h : bg.rect.height;
clip = UiKit.Fill("Clip", dim);
clip.gameObject.AddComponent<RectMask2D>();
stage = UiKit.Rect("Stage", clip, new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), Vector2.zero, new Vector2(artW, artH));
UiLayer.Nest(stage.gameObject, UiRate.Frame, false); // 장면 전체가 매 프레임 움직임(다가가는 화면 · 빛 · 문 · 사람) — 한 장으로
var art = UiKit.Fill("Art", stage).gameObject.AddComponent<Image>();
art.sprite = bg;
art.raycastTarget = false;
backLayer = UiKit.Fill("Back", stage);
doorLayer = UiKit.Fill("Door", stage);
groundLayer = UiKit.Fill("Ground", stage);
shadowLayer = UiKit.Fill("Shadows", stage);
fxLayer = UiKit.Fill("Fx", stage);
frontLayer = UiKit.Fill("Front", stage);
float dx = doorRow.x, dy = doorRow.y, dw = doorRow.w, dh = doorRow.h;
float cx = dx + dw * 0.5f, cy = dy + dh * 0.5f;
var white = Color.white;
var light = tierRow.n == 1 ? white : Color.Lerp(tierRow.color, white, 0.55f); // 가운데 광원 = 등급 색을 흰 쪽으로(빛은 가운데가 흼)
var edgeLight = tierRow.n == 1 ? white : Color.Lerp(tierRow.color, white, 0.2f); // 틈·세로 빛줄기 = 등급 색이 또렷하게(새는 빛으로 등급을 알게)
var insRow = GachaScene.First("inside" + Tier);
var inside = insRow != null ? insRow.color : light; // 문 안 판 = 등급 색(그 위에 흰 광원이 얹혀 가운데 흰 빛 → 바깥 등급색으로 이어짐 · 흰 네모로 안 보이게)
// 문 뒤: 둘레 어둠 → 문 안(어두운 방) → 문 안 빛(틈으로 보임 · 문틀에서 조금 들임) → 문 둘레 번짐
dimBack = Full("DimBack", backLayer, new Color(0.03f, 0.04f, 0.09f, 0f)); // 둘레를 어둡게 = 빛이 도드라짐(덜컹거릴 때부터 서서히)
UiKit.Paint(Px("Inside", backLayer, dx, dy, dw, dh, 0f, 1f), doorRow.color, false, false);
float inset = doorRow.first;
glow = UiKit.Paint(Px("Glow", backLayer, dx + inset, dy + inset, dw - inset * 2f, dh - inset, 0f, 1f), inside, false, false);
float hsz = Mathf.Max(dw, dh) * tierRow.waves;
halo = Glow("Halo", backLayer, coreSp, cx, cy, hsz, hsz, tierRow.color);
haloRt = halo.rectTransform;
// 문짝 2(배경에서 오린 그림 · 바깥 끝 = 경첩 · 폭이 줄며 열림 = 가운데 틈이 벌어짐) + 문틈 빛 번짐
var leafIds = doorRow.Sprites;
leafLImg = Spr("LeafL", doorLayer, leafIds.Length > 0 ? UISkin.Icon(leafIds[0]) : null, dx, dy, dw * 0.5f, dh, 0f, 1f);
leafRImg = Spr("LeafR", doorLayer, leafIds.Length > 1 ? UISkin.Icon(leafIds[1]) : null, dx + dw, dy, dw * 0.5f, dh, 1f, 1f);
leafL = leafLImg.rectTransform;
leafR = leafRImg.rectTransform;
leafLHome = leafL.anchoredPosition;
leafRHome = leafR.anchoredPosition;
slit = Glow("Slit", doorLayer, coreSp, cx, cy, doorRow.gapMin, dh * doorRow.gapMax, edgeLight);
// 문틈에서 마당으로 쏟아지는 빛(부드러운 부채 · 줄마다 색·기울기 = 여러 색이 겹쳐 가운데가 흼)
foreach (var r in GachaScene.All("beam" + Tier))
{
var bi = Glow("Beam" + beams.Count, groundLayer, beamSp, cx, dy + dh, dw * doorRow.max, tierRow.h, r.color, 0.5f, 1f);
bi.rectTransform.localEulerAngles = new Vector3(0f, 0f, r.amp);
beams.Add(new Ray { rt = bi.rectTransform, img = bi, row = r });
}
// 사람 뒤 빛 층: 색 번짐(천천히 돎) → 가운데 흰 광원 → 새는 동안의 세로 빛줄기 → 길고 가는 빛줄기들 → 입자
var lightRoot = UiKit.Fill("Light", fxLayer);
lobeRoot = Px("Lobes", lightRoot, cx, cy, 0f, 0f);
var mid = new Vector2(0.5f, 0.5f);
foreach (var r in GachaScene.All("lobe" + Tier))
{
float a = r.amp * Mathf.Deg2Rad;
var li = UiKit.Rect("Lobe" + lobes.Count, lobeRoot, mid, mid, new Vector2(Mathf.Cos(a), Mathf.Sin(a)) * r.x, new Vector2(r.w, r.h)).gameObject.AddComponent<Image>();
li.sprite = lobeSp;
li.raycastTarget = false;
var lc = r.color;
lc.a = 0f;
li.color = lc;
if (AddMat != null) li.material = AddMat;
li.enabled = lobeSp != null;
li.rectTransform.localEulerAngles = new Vector3(0f, 0f, r.amp);
lobes.Add(new Ray { rt = li.rectTransform, img = li, row = r });
}
bloom = Glow("Bloom", lightRoot, lobeSp, cx, cy, dw * 2.7f, dh * 2.9f, light); // 열린 문(밝은 사각) 둘레로 번지는 빛 = 사각의 딱딱한 가장자리를 누그러뜨림
coreImg = Glow("Core", lightRoot, coreSp, cx, cy, tierRow.w, tierRow.w, light);
coreRt = coreImg.rectTransform;
leakImg = Glow("Leak", lightRoot, streakSp, cx, cy, dh * 1.4f, doorRow.phase, edgeLight);
leakRt = leakImg.rectTransform;
leakRt.localEulerAngles = new Vector3(0f, 0f, 90f);
foreach (var r in GachaScene.All("streak" + Tier))
{
var si = Glow("Streak" + streaks.Count, lightRoot, streakSp, cx, cy, r.w, r.h, r.color);
si.rectTransform.localEulerAngles = new Vector3(0f, 0f, r.amp);
streaks.Add(new Ray { rt = si.rectTransform, img = si, row = r });
}
BuildSilhouette(); // 빛 위 · 별가루 아래
bitLayer = UiKit.Fill("Bits", fxLayer);
BuildActors();
}
/// <summary>
/// 활짝 열린 문 안에 선 영웅의 실루엣(PD 2026-11-13 「문이 활짝 열렸을 때 등장 영웅의 검은 실루엣만 보이게」 → 「빛에 가려져서 좀 더 옅은 형체만」) — 빛 속에 어렴풋한 형체만 · 제 색·이름은 카드에서.
/// 표 hero 줄: f_x,f_y 발 · f_h 표준 키 · f_max 말 탄 영웅 머리 끝 상한 · f_first 활짝 열리기 시작한 뒤 나타나기까지 · f_period 나타나는 초 · f_scale 처음 배율 · s_color 실루엣 색(알파 = 옅기) · f_amp,f_waves 실루엣 위에 덮이는 빛의 세기·크기(문 크기의 배).
/// </summary>
void BuildSilhouette()
{
heroRow = GachaScene.First("hero");
var h = Featured != null ? s.Def(Featured.heroId) : null;
if (h == null || heroRow == null) return;
sil = UiKit.UnitStand("Silhouette", fxLayer, h, new Vector2(0f, 1f), new Vector2(heroRow.x, -heroRow.y), heroRow.h, heroRow.max > 1.001f ? heroRow.max : mountedTop); // 말 탄 영웅도 문 안에 들어가게(표 f_max)
if (sil == null) return;
silImg = sil.GetComponent<Image>();
var c = heroRow.color;
c.a = 0f;
silImg.color = c;
UiFrameAnim.Attach(silImg, UnitArt.Load(h.art), "front_idle"); // 실루엣도 숨은 쉼
// 실루엣 위에 덮이는 빛(PD 2026-11-13 「실루엣은 빛에 가려져서 좀 더 옅은 형체만」) — 가운데가 빛에 묻히고 가장자리만 어렴풋이
float dw = doorRow.w, dh = doorRow.h;
var lc = tierRow.n == 1 ? Color.white : Color.Lerp(tierRow.color, Color.white, 0.55f);
veil = Glow("Veil", fxLayer, lobeSp, doorRow.x + dw * 0.5f, doorRow.y + dh * 0.5f, dw * heroRow.waves, dh * heroRow.waves, lc); // 가운데가 뜨겁지 않은 고른 번짐(광원 그림은 가운데에 밝은 점이 맺힘)
}
/// <summary>발밑 그림자(표 shadow 줄 · 로비와 같은 부드러운 원 · f_scale 키의 사람 기준 크기에 배율 k).</summary>
RectTransform Shadow(string name, float x, float y, float k)
{
var sp = shadowRow != null ? UISkin.Icon(shadowRow.sprite) : null;
if (sp == null) return null;
var img = Spr(name, shadowLayer, sp, x + shadowRow.x * k, y + shadowRow.y * k, shadowRow.w * k, shadowRow.h * k);
img.color = shadowRow.color;
return img.rectTransform;
}
void BuildActors()
{
var bangRow = GachaScene.First("bang");
var bangSp = bangRow != null ? UISkin.Icon(bangRow.sprite) : null;
foreach (var r in GachaScene.All("actor"))
{
var data = UnitArt.Load(r.sprite);
Sprite[] idle;
if (data == null || !data.Clips.TryGetValue(IdleClip, out idle) || idle.Length == 0) continue;
float body = data.BodyPx > 0.5f ? data.BodyPx : idle[0].rect.height;
float k = r.h / body;
var a = new Actor { data = data, idle = idle, phase = r.phase, flip = r.n == 1 ? -1f : 1f };
float pivotY = data.Meta.pivot != null && data.Meta.pivot.Length > 1 ? data.Meta.pivot[1] : 0f;
a.shadow = Shadow("Shadow" + r.slot, r.x, r.y, shadowRow != null && shadowRow.scale > 0.5f ? r.h / shadowRow.scale : 1f);
if (a.shadow != null) a.shadowHome = a.shadow.anchoredPosition;
a.rt = Px("Actor" + r.slot, frontLayer, r.x, r.y, idle[0].rect.width * k, idle[0].rect.height * k, 0.5f, pivotY);
a.home = a.rt.anchoredPosition;
a.img = a.rt.gameObject.AddComponent<Image>();
a.img.sprite = idle[0];
a.img.raycastTarget = false;
a.rt.localScale = new Vector3(a.flip, 1f, 1f);
a.idleOffset = Random.value * data.CycleSeconds(IdleClip, idle.Length);
Sprite[] re;
if (reactRow != null && data.Clips.TryGetValue(reactRow.sprite, out re) && re.Length > 0) a.react = re;
if (bangSp != null && Reaction == "shock")
{
// 느낌표 = 머리 위(사람과 같이 뛰지 않게 장면 층에 따로)
a.bang = Spr("Bang" + r.slot, frontLayer, bangSp, r.x + bangRow.x * a.flip, r.y - r.h - bangRow.y, bangRow.w, bangRow.h, 0.5f, 0f).rectTransform;
a.bang.localScale = Vector3.zero;
}
actors.Add(a);
}
foreach (var a in actors)
if (a.bang != null)
a.bang.SetAsLastSibling();
}
// ---------- 시간 ----------
void Update()
{
if (Manual) return;
Tick(FixedDt > 0f ? FixedDt : Mathf.Min(Time.unscaledDeltaTime, 0.1f));
}
/// <summary>dt 초 진행(테스트·캡처는 직접 호출).</summary>
public void Tick(float dt)
{
if (stage == null) return; // Play 중 재컴파일 = 직렬화 안 된 값이 비면 멈춤
t += dt;
Apply(dt);
FitStage();
UpdateBits(dt);
if (tCards >= 0f) UpdateCards(dt);
else if (t >= tEnd) StartCards(); // 장면이 끝나면 저절로 카드로
}
static float EaseOut(float u)
{
u = Mathf.Clamp01(u);
return 1f - (1f - u) * (1f - u);
}
static float Smooth(float u)
{
u = Mathf.Clamp01(u);
return u * u * (3f - 2f * u);
}
static float Back(float u)
{
u = Mathf.Clamp01(u);
const float c = 1.70158f;
float v = u - 1f;
return 1f + (c + 1f) * v * v * v + c * v * v;
}
/// <summary>
/// 문 열림 곡선(0 닫힘 ~ 1 다 열림) — 앞부분(표 timeN f_waves 비율)은 틈이 천천히 벌어져 f_billow 까지(빛이 새는 구간 = 등급을 알게 됨) → 나머지는 활짝(끝에서 살짝 넘쳤다 돌아옴).
/// </summary>
float OpenAt(float time)
{
if (time <= tOpen) return 0f;
float u = (time - tOpen) / Mathf.Max(0.01f, timeRow.hit);
float c = Mathf.Clamp(timeRow.waves, 0.01f, 0.99f), b = timeRow.billow;
if (u < c) return b * Mathf.Pow(u / c, 1.5f);
return b + (1f - b) * Back((u - c) / (1f - c));
}
void FitStage()
{
var pr = clip.rect;
float k = Mathf.Max(pr.width / Mathf.Max(1f, artW), pr.height / Mathf.Max(1f, artH)); // 화면을 덮게(비율이 달라도 빈 곳 없음)
// 다가가는 화면 = 덜컹거릴 때부터 문이 활짝 열릴 때까지 천천히 다가감 + 터지는 순간 살짝 더(표 zoom 줄)
float z = 1f;
Vector2 focus = Vector2.zero;
if (zoomRow != null)
{
float u = Smooth((t - tRattle) / Mathf.Max(0.01f, tBurst - tRattle));
z = Mathf.Lerp(zoomRow.min, zoomRow.max, u);
if (t >= tBurst) z += zoomRow.scale * Mathf.Exp(-(t - tBurst) / Mathf.Max(0.01f, zoomRow.period));
focus = new Vector2(zoomRow.x - artW * 0.5f, artH * 0.5f - zoomRow.y);
}
float sc = k * z;
if (!Mathf.Approximately(sc, lastScale))
{
lastScale = sc;
stage.localScale = new Vector3(sc, sc, 1f);
}
// 흔들림 = 빛이 새는 동안 잔떨림(등급 줄 f_phase · 틈이 벌어질수록) + 활짝 열리는 순간(f_amp · 금방 잦아듦)
float amp = 0f;
if (tCards < 0f)
{
if (t >= tBurst) amp = tierRow.amp * Mathf.Clamp01(1f - (t - tBurst) / Mathf.Max(0.01f, flashRow != null ? flashRow.hold : 0.4f));
else if (t > tOpen) amp = tierRow.phase * Mathf.Clamp01((t - tOpen) / Mathf.Max(0.01f, tBurst - tOpen));
}
var off = focus * (k * (1f - z));
if (amp > 0.05f) off += new Vector2(Mathf.Sin(t * 71f) * amp, Mathf.Cos(t * 53f) * amp) * k;
if (stage.anchoredPosition != off) stage.anchoredPosition = off;
}
/// <summary>지금 시각 t 의 장면(문·빛·삼형제) — 건너뛰어도 같은 모습이 되게 시각의 함수로(입자만 그때그때 나옴).</summary>
void Apply(float dt)
{
Stage = tCards >= 0f ? (doneUi ? Step.Done : Step.Cards) : t < tRattle ? Step.Wait : t < tOpen ? Step.Rattle : t < tBurst ? Step.Leak : Step.Open;
SetA(fade, Mathf.Clamp01(1f - t / Mathf.Max(0.01f, timeRow.first)));
// 덜컹 = 박자마다 문짝이 떨림(뒤로 갈수록 세게) · 사이에는 멈춤(뜸)
Vector2 shake = Vector2.zero;
if (t >= tRattle && t < tOpen)
{
int n = Mathf.Max(1, timeRow.count > 0 ? timeRow.count : rattleRow.count);
float beat = (tOpen - tRattle) / n;
int i = Mathf.Min(n - 1, Mathf.FloorToInt((t - tRattle) / beat));
float u = (t - tRattle - i * beat) / beat;
if (i != lastBeat)
{
lastBeat = i;
DoorDust(rattleRow.n);
}
if (u < rattleRow.min)
{
float power = Mathf.Lerp(1f, rattleRow.max, n > 1 ? i / (float)(n - 1) : 1f) * (1f - u / rattleRow.min);
float w = t * Mathf.PI * 2f / Mathf.Max(0.01f, rattleRow.period);
shake = new Vector2(Mathf.Sin(w), Mathf.Cos(w * 1.3f) * 0.35f) * (rattleRow.amp * power);
}
}
leafL.anchoredPosition = leafLHome + shake;
leafR.anchoredPosition = leafRHome + shake;
float raw = OpenAt(t);
float o = Mathf.Clamp01(raw);
DoorOpen = o;
float leaf = Mathf.LerpUnclamped(1f, doorRow.min, raw);
leafL.localScale = new Vector3(leaf, 1f, 1f);
leafR.localScale = new Vector3(leaf, 1f, 1f);
var leafC = Color.Lerp(Color.white, doorRow.shade >= 0f ? new Color(doorRow.shade, doorRow.shade, doorRow.shade, 1f) : Color.white, o);
if (leafLImg.color != leafC) leafLImg.color = leafRImg.color = leafC;
ApplyLight(dt, raw, o, leaf);
if (!burstDone && t >= tBurst)
{
burstDone = true;
Burst();
}
if (flashT < 90f)
{
flashT += dt;
SetA(flash, flashA * Mathf.Clamp01(1f - flashT / Mathf.Max(0.01f, flashRow != null ? flashRow.period : 0.3f)));
}
if (sil != null)
{
// 실루엣 = 문이 활짝 열리는 동안 빛 속에서 드러남(조금 작았다가 제 크기로)
float e = EaseOut((t - tBurst - heroRow.first) / Mathf.Max(0.01f, heroRow.period));
float k = Mathf.Lerp(heroRow.scale, 1f, e);
sil.localScale = new Vector3(k, k, 1f);
SetA(silImg, e * heroRow.color.a);
SetA(veil, e * heroRow.amp * tierRow.scale);
}
for (int i = 0; i < actors.Count; i++) ApplyActor(actors[i]);
}
/// <summary>
/// 빛 — 새는 동안: 틈의 빛(문 안 색) + 틈 번짐 + 세로 빛줄기 + 마당 빛(틈 폭만큼) · 두근거리듯 점점 빠르게 · 둘레는 덜컹거릴 때부터 서서히 어두워짐.
/// 활짝: 가운데 흰 광원이 커지고 길고 가는 빛줄기가 뻗으며 색 번짐이 퍼짐(천천히 돌고 숨쉼) · 별가루가 둘레에 계속 떠다님. 등급 = 표 tierN · streakN · lobeN · beamN 줄.
/// </summary>
void ApplyLight(float dt, float raw, float o, float leaf)
{
float breath = 1f + Mathf.Sin(t * Mathf.PI * 2f / Mathf.Max(0.1f, tierRow.period)) * tierRow.shade;
float early = Mathf.Clamp01(o / 0.03f);
float leakU = Mathf.Clamp01((t - tOpen) / Mathf.Max(0.01f, tBurst - tOpen));
float pulse = t < tBurst ? 1f + 0.2f * Mathf.Sin(t * Mathf.PI * 2f * Mathf.Lerp(1.4f, 4f, leakU)) : 1f;
LightLevel = Mathf.Sqrt(o) * early;
if (tierRow.n == 1)
{
// 무지개 등급 = 둘레 번짐 색이 천천히 바뀜(가운데는 늘 흰 빛)
var hue = Color.HSVToRGB(Mathf.Repeat(t * 0.45f, 1f), 0.6f, 1f);
SetRgb(halo, hue);
}
SetA(glow, early * tierRow.min);
SetA(halo, Mathf.Clamp01(0.3f + 0.7f * Mathf.Sqrt(o)) * early * tierRow.scale);
float hs = Mathf.Lerp(0.45f, 1f, o) * breath * pulse;
haloRt.localScale = new Vector3(hs, hs, 1f);
float gapW = doorRow.w * (1f - Mathf.Clamp(leaf, 0f, 1f));
slit.rectTransform.sizeDelta = new Vector2(gapW + doorRow.gapMin, doorRow.h * doorRow.gapMax);
SetA(slit, early * Mathf.Clamp01(1.1f - o) * Mathf.Clamp01(0.8f * pulse) * tierRow.scale);
// 새는 동안의 세로 빛줄기(틈을 따라 위아래로 뻗음 · 활짝 열리면 사라짐)
leakRt.sizeDelta = new Vector2(doorRow.h * Mathf.Lerp(1.5f, 3.4f, leakU), doorRow.phase);
SetA(leakImg, early * Mathf.Clamp01(1f - (o - timeRow.billow) / 0.35f) * Mathf.Clamp01(pulse) * tierRow.scale);
// 마당 빛
for (int i = 0; i < beams.Count; i++)
{
var b = beams[i];
SetA(b.img, Mathf.Clamp01(0.4f + o * 4f) * early * b.row.scale * tierRow.scale);
b.rt.localScale = new Vector3(Mathf.LerpUnclamped(0.06f, 1f, raw), Mathf.Clamp01(0.45f + o * 2.5f) * early, 1f);
b.rt.localEulerAngles = new Vector3(0f, 0f, b.row.amp * Mathf.Clamp01(o * 1.1f)); // 새는 동안은 색이 겹쳐 한 줄기 · 열릴수록 벌어짐
}
SetA(bloom, Mathf.Clamp01(o * 3f) * tierRow.scale * tierRow.hold); // f_hold = 문 둘레 번짐 세기(★ 는 약하게 = 어두운 문 안에 둥근 빛 · 높은 등급은 문 전체가 빛에 잠김)
// 가운데 흰 광원
float cs = Mathf.Lerp(0.22f, 1f, EaseOut(o)) * breath * pulse;
coreRt.localScale = new Vector3(cs, cs, 1f);
SetA(coreImg, early * tierRow.scale);
// 길고 가는 빛줄기 = 활짝 열릴 때 차례로 뻗고(끝에서 살짝 넘침) 천천히 흔들리며 깜빡
float bt = t - tBurst;
for (int i = 0; i < streaks.Count; i++)
{
var st = streaks[i];
float sb = bt >= st.row.phase ? Back((bt - st.row.phase) / 0.38f) : 0f;
st.rt.localScale = new Vector3(Mathf.Max(0f, sb) * breath, 1f, 1f);
st.rt.localEulerAngles = new Vector3(0f, 0f, st.row.amp + st.row.waves * Mathf.Sin(t * Mathf.PI * 2f / Mathf.Max(0.1f, st.row.period) + i * 1.7f));
SetA(st.img, Mathf.Clamp01(sb) * st.row.scale * (0.8f + 0.2f * Mathf.Sin(t * st.row.speed + i * 2.1f)));
}
// 색 번짐 = 활짝 열릴 때 퍼지고 묶음째 천천히 돎
float lb = bt >= 0f ? EaseOut(bt / 0.6f) : 0f;
float ls = lb * breath;
lobeRoot.localScale = new Vector3(ls, ls, 1f);
lobeRoot.localEulerAngles = new Vector3(0f, 0f, t * tierRow.gapMax);
for (int i = 0; i < lobes.Count; i++) SetA(lobes[i].img, lb * lobes[i].row.scale * (0.82f + 0.18f * Mathf.Sin(t * 1.3f + i * 1.9f)));
// 둘레 어둠
float dark = t < tBurst ? Smooth((t - tRattle) / Mathf.Max(0.01f, tBurst - tRattle)) : Mathf.Lerp(1f, tierRow.rest, Mathf.Clamp01(bt / 0.5f));
SetA(dimBack, dark * tierRow.max);
// 별가루 = 새는 동안 틈에서 조금씩(표 f_gap_min 초당) · 열린 뒤 둘레에 계속(f_speed 초당 · 반경 f_billow)
if (tCards < 0f && dt > 0f && o > 0.01f)
{
bool open = t >= tBurst;
ambAcc += dt * (open ? tierRow.speed : tierRow.gapMin);
while (ambAcc >= 1f)
{
ambAcc -= 1f;
Dust(open, gapW);
}
}
}
void ApplyActor(Actor a)
{
float rt = t - tReact - a.phase;
if (rt < 0f || a.react == null || reactRow == null)
{
// 기다림 = 전용 그림 대기 숨 고르기(R22 · 유닛과 같은 규칙) + 첫 덜컹에 움찔
var sp = a.idle[a.data.FrameAt(IdleClip, a.idle.Length, t + a.idleOffset) % a.idle.Length];
if (a.img.sprite != sp) a.img.sprite = sp;
float f = (t - tRattle - a.phase) / 0.28f;
float flinch = f > 0f && f < 1f ? rattleRow.hold * 4f * f * (1f - f) : 0f;
a.rt.anchoredPosition = a.home + new Vector2(0f, flinch);
return;
}
float per = Mathf.Max(0.05f, reactRow.period);
int hop = Mathf.FloorToInt(rt / per);
float u = rt / per - hop;
bool hopping = hop < reactRow.count;
float up = 0f, sx = 1f, sy = 1f, dx = 0f;
if (hopping)
{
float arc = 4f * u * (1f - u);
up = reactRow.amp * arc * (hop == 0 ? 1f : reactRow.billow);
// 뜰 때 길쭉 · 닿을 때 납작(짧게)
float land = Mathf.Clamp01((u - 0.82f) / 0.18f) + (hop > 0 ? Mathf.Clamp01(1f - u / 0.18f) : 0f);
sy = Mathf.Lerp(Mathf.Lerp(1f, reactRow.max, arc), reactRow.min, Mathf.Clamp01(land));
sx = 1f / Mathf.Sqrt(Mathf.Max(0.3f, sy));
}
else
{
// 뛴 뒤 = 반김: 작게 계속 들썩 · 놀람: 얼어붙어 덜덜
float after = rt - reactRow.count * per;
if (reactRow.waves > 0.01f) dx = Mathf.Sin(after * Mathf.PI * 2f / Mathf.Max(0.02f, reactRow.speed)) * reactRow.waves * Mathf.Lerp(1f, 0.35f, Mathf.Clamp01(after / 1.2f));
else up = Mathf.Abs(Mathf.Sin(after * Mathf.PI / per)) * reactRow.amp * reactRow.shade;
}
var frame = a.react[Mathf.Min(a.react.Length - 1, hop % a.react.Length)];
if (a.img.sprite != frame) a.img.sprite = frame;
float back = reactRow.first * Mathf.Clamp01(rt / per); // 뒤로(화면 아래로) 물러남
a.rt.anchoredPosition = a.home + new Vector2(dx, up - back);
a.rt.localScale = new Vector3(a.flip * sx, sy, 1f);
if (a.shadow != null)
{
float air = 1f - 0.3f * Mathf.Clamp01(up / Mathf.Max(1f, reactRow.amp));
a.shadow.localScale = new Vector3(air, air, 1f);
a.shadow.anchoredPosition = a.shadowHome + new Vector2(dx, -back);
}
if (a.bang != null)
{
float s1 = Back(rt / 0.16f) * (1f + 0.06f * Mathf.Sin(rt * 14f));
a.bang.localScale = new Vector3(s1, s1, 1f);
}
}
// ---------- 입자(별가루 · 고리 · 먼지) ----------
Color Tint()
{
return tints[Random.Range(0, tints.Count)];
}
Sprite Sparkle()
{
return Random.value < 0.6f || dotSp == null ? starSp : dotSp;
}
/// <summary>입자 하나 — glow = 덧셈 합성(빛) · drag = 속도가 줄어드는 정도 · tw = 깜빡임(초당 횟수 · 0 = 없음).</summary>
Bit Emit(Transform parent, Sprite sp, Vector2 pos, Vector2 vel, float grav, float life, float s0, float s1, Color col, float spin, bool ring = false, bool glow = false, float drag = 0f, float tw = 0f)
{
if (sp == null) return null;
Bit b = null;
foreach (var x in bits)
if (!x.live)
{
b = x;
break;
}
if (b == null)
{
var go = new GameObject("Bit", typeof(RectTransform));
b = new Bit { rt = (RectTransform)go.transform, img = go.AddComponent<Image>() };
b.img.raycastTarget = false;
bits.Add(b);
}
b.rt.SetParent(parent, false);
b.rt.anchorMin = b.rt.anchorMax = parent == cardFx ? new Vector2(0.5f, 0.5f) : new Vector2(0f, 1f);
b.rt.pivot = new Vector2(0.5f, 0.5f);
b.rt.SetAsLastSibling();
b.img.sprite = sp;
b.img.material = glow ? AddMat : null;
b.img.color = col;
b.pos = pos;
b.vel = vel;
b.grav = grav;
b.drag = drag;
b.t = 0f;
b.life = Mathf.Max(0.05f, life);
b.s0 = s0;
b.s1 = s1;
b.a0 = col.a;
b.spin = spin;
b.tw = tw;
b.ph = Random.value * 6.283f;
b.ring = ring;
b.live = true;
b.rt.localEulerAngles = Vector3.zero;
b.rt.gameObject.SetActive(true);
StepBit(b, 0f);
return b;
}
void StepBit(Bit b, float dt)
{
b.t += dt;
float u = b.t / b.life;
if (u >= 1f)
{
b.live = false;
b.rt.gameObject.SetActive(false);
return;
}
if (b.drag > 0f) b.vel *= Mathf.Exp(-b.drag * dt);
b.vel.y -= b.grav * dt;
b.pos += b.vel * dt;
b.rt.anchoredPosition = b.pos;
float size = Mathf.Lerp(b.s0, b.s1, b.ring ? EaseOut(u) : u);
b.rt.sizeDelta = new Vector2(size, size);
if (Mathf.Abs(b.spin) > 0.01f) b.rt.localEulerAngles = new Vector3(0f, 0f, b.spin * b.t);
float env = b.ring ? 1f - u : u < 0.1f ? u / 0.1f : u > 0.55f ? 1f - (u - 0.55f) / 0.45f : 1f;
if (b.tw > 0f) env *= 0.55f + 0.45f * Mathf.Sin(b.t * b.tw * 6.283f + b.ph);
SetA(b.img, b.a0 * env);
}
void UpdateBits(float dt)
{
for (int i = 0; i < bits.Count; i++)
if (bits[i].live)
StepBit(bits[i], dt);
}
Vector2 DoorCenter
{
get { return new Vector2(doorRow.x + doorRow.w * 0.5f, -(doorRow.y + doorRow.h * 0.5f)); }
}
/// <summary>떠다니는 별가루 한 알 — 새는 동안 = 틈에서 흘러나옴 · 열린 뒤 = 문 둘레(표 등급 줄 f_billow 반경)에 천천히 떠다니며 깜빡.</summary>
void Dust(bool open, float gapW)
{
if (sparkRow == null) return;
Vector2 from, vel;
if (open)
{
float a = Random.Range(0f, Mathf.PI * 2f), d = Mathf.Sqrt(Random.value) * tierRow.billow;
var dir = new Vector2(Mathf.Cos(a), Mathf.Sin(a));
from = DoorCenter + dir * d;
vel = dir * Random.Range(8f, 46f) + new Vector2(0f, Random.Range(4f, 30f));
}
else
{
float side = Random.value < 0.5f ? -1f : 1f;
from = DoorCenter + new Vector2(side * gapW * 0.5f * Random.value, Random.Range(-0.45f, 0.45f) * doorRow.h);
vel = new Vector2(side * Random.Range(10f, 70f), Random.Range(-60f, 40f));
}
Emit(bitLayer, Sparkle(), from, vel, sparkRow.amp * 0.3f, sparkRow.period * Random.Range(0.6f, 1.2f), Random.Range(sparkRow.w, sparkRow.h), sparkRow.w * 0.5f, Tint(), 0f, false, true, 0.6f, sparkRow.billow);
}
/// <summary>덜컹거릴 때 문 위 모서리에서 떨어지는 먼지(등급과 무관한 색 — 아직 등급을 알리지 않음).</summary>
void DoorDust(int n)
{
if (puffRow == null) return;
for (int i = 0; i < n; i++)
{
float side = i % 2 == 0 ? -1f : 1f;
var from = new Vector2(doorRow.x + doorRow.w * (0.5f + side * Random.Range(0.3f, 0.5f)), -(doorRow.y + Random.Range(0f, doorRow.h * 0.15f)));
Emit(bitLayer, puffSp, from, new Vector2(side * rattleRow.speed * Random.Range(0.3f, 1f), -rattleRow.speed * Random.Range(0.2f, 0.6f)), rattleRow.speed * 1.5f,
puffRow.period * Random.Range(0.9f, 1.3f), rattleRow.w, rattleRow.h, puffRow.color, 0f);
}
}
/// <summary>문이 활짝 열리는 순간 — 번쩍 + 흔들림(FitStage) + 얇게 퍼지는 고리 + 사방으로 튀어 나가 느려지며 깜빡이는 별가루(수 = 등급 줄).</summary>
void Burst()
{
flashA = tierRow.first;
flashT = 0f;
if (sparkRow != null)
for (int i = 0; i < tierRow.count; i++)
{
float ang = Random.Range(0f, Mathf.PI * 2f);
float sp = sparkRow.speed * Random.Range(0.15f, 1f);
var from = DoorCenter + new Vector2(Random.Range(-0.4f, 0.4f) * doorRow.w, Random.Range(-0.4f, 0.4f) * doorRow.h);
Emit(bitLayer, Sparkle(), from, new Vector2(Mathf.Cos(ang), Mathf.Sin(ang)) * sp, sparkRow.amp, sparkRow.period * Random.Range(0.7f, 1.5f),
Random.Range(sparkRow.w, sparkRow.h), sparkRow.w * 0.4f, Tint(), Random.Range(-90f, 90f), false, true, sparkRow.waves, sparkRow.billow);
}
if (ringRow == null) return;
int rings = Mathf.RoundToInt(tierRow.hit);
for (int i = 0; i < rings; i++)
{
var col = Tint();
col.a = ringRow.scale;
var b = Emit(bitLayer, ringSp, DoorCenter, Vector2.zero, 0f, ringRow.period * (1f + i * ringRow.phase), ringRow.w, ringRow.h * (1f + i * 0.35f), col, 0f, true, true);
if (b != null) b.rt.SetAsFirstSibling();
}
}
// ---------- 글자(Event 층) ----------
GameObject Hint(string text, int size)
{
var tx = UiKit.Title(UiKit.Label(ui, text, size, UiKit.White, TextAnchor.MiddleCenter, new Vector2(0.5f, 0f), new Vector2(0.5f, 0f), new Vector2(0f, 96f), new Vector2(900f, 70f)), true);
tx.raycastTarget = false;
tx.gameObject.AddComponent<UiPulse>();
return tx.gameObject;
}
// ---------- 탭 ----------
/// <summary>화면 탭(버튼) — 캡처·테스트가 직접 진행하는 동안(Manual)은 실제 마우스·터치를 받지 않는다(캡처 창을 누가 눌러도 장면이 넘어가지 않게).</summary>
void OnPointer()
{
if (!Manual) Tap();
}
/// <summary>장면 중 = 건너뛰어 카드로(PD 「터치로 문이 열리는 장면을 스킵」) · 카드가 떨어지는 중 = 전부 공개 · 다 나온 뒤 = 닫기.</summary>
public void Tap()
{
if (tCards < 0f) StartCards();
else if (CardsLanded < cards.Count) LandAll();
else Close();
}
/// <summary>카드 획득 단계로(장면 = 문이 열린 모습으로 어두운 막 뒤에).</summary>
public void StartCards()
{
if (tCards >= 0f) return;
if (t < tFull)
{
// 건너뜀 = 문이 다 열린 모습으로(터지는 연출은 생략)
t = tFull;
burstDone = true;
Apply(0f);
FitStage();
}
tCards = t;
if (hint != null) Destroy(hint);
shade.gameObject.SetActive(true);
SetA(shade, 0f);
BuildCards();
Stage = Step.Cards;
}
public void LandAll()
{
if (tCards < 0f) StartCards();
foreach (var c in cards)
if (!c.landed)
Land(c, true);
foreach (var c in cards) Settle(c, 1f);
FinishCards();
}
public void Close()
{
var cb = onClose;
onClose = null;
if (cb != null) cb();
else Destroy(gameObject);
}
// ---------- 카드 획득(PD 레퍼런스: 카드가 UI 에 등장할 때의 연출) ----------
void BuildCards()
{
var ribbon = UiKit.Rect("Title", ui, new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), new Vector2(0f, -170f), new Vector2(620f, 130f));
if (UISkin.Has("ribbon_red")) UiKit.Skin(ribbon, "ribbon_red", UiKit.White);
UiKit.Title(UiKit.Label(ribbon, "등용 결과", 58, UiKit.White, TextAnchor.MiddleCenter, new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(0f, 12f), new Vector2(520f, 90f)), true).raycastTarget = false;
// 카드마다 그 뽑기 직후의 조각 수(같은 영웅이 여러 번 나오면 차례로 쌓임 · 세이브에는 이미 다 더해져 있음)
var total = new Dictionary<string, int>();
foreach (var p in pulls)
{
int v;
total.TryGetValue(p.heroId, out v);
total[p.heroId] = v + p.shards;
}
var run = new Dictionary<string, int>();
int cols = Mathf.Max(1, Mathf.Min(cardRow.n > 0 ? cardRow.n : 5, pulls.Count));
int rows = (pulls.Count + cols - 1) / cols;
float W = ((RectTransform)transform).rect.width;
float gapX = cardRow.gapMax, gapY = cardRow.billow, strip = cardRow.h;
float cw = Mathf.Min(cardRow.w, (W - cardRow.x * 2f - (cols - 1) * gapX) / cols);
float k = cw / CollectionCards.CellSize.x;
float ch = CollectionCards.CellSize.y * k;
float pitchY = ch + strip + gapY;
float top = (rows * pitchY - gapY) * 0.5f + cardRow.y;
var mid = new Vector2(0.5f, 0.5f);
for (int i = 0; i < pulls.Count; i++)
{
var p = pulls[i];
var h = s.Def(p.heroId);
int r = i / cols, c = i % cols;
int inRow = Mathf.Min(cols, pulls.Count - r * cols);
var home = new Vector2((c - (inRow - 1) * 0.5f) * (cw + gapX), top - r * pitchY - (ch + strip));
var card = new Card { home = home, tier = p.tier, start = cardRow.first + i * cardRow.gapMin };
// 등급 꾸밈(카드 뒤 · 닿을 때 켜짐): ★★ = 금빛 번짐 + 주황 테두리 · ★★★ = 흰 빛 번짐 + 도는 가는 빛줄기 + 색이 바뀌는 테두리 + 별가루 — 다 나온 뒤에도 등급이 한눈에
var tr = GachaScene.Tier(p.tier);
if (p.tier >= 2 && tr != null)
{
card.rainbow = tr.n == 1;
card.deco = UiKit.Rect("Deco" + i, cardsLayer, mid, mid, home + new Vector2(0f, strip + ch * 0.5f), new Vector2(cw, ch) * cardRow.max * (card.rainbow ? 1.35f : 1f));
card.glow = card.deco.gameObject.AddComponent<Image>();
card.glow.sprite = coreSp;
card.glow.raycastTarget = false;
card.glow.color = card.rainbow ? Color.white : tr.color;
if (AddMat != null) card.glow.material = AddMat;
card.glow.enabled = coreSp != null;
if (card.rainbow && streakSp != null)
{
card.rays = UiKit.Rect("Rays", card.deco, mid, mid, Vector2.zero, Vector2.zero);
for (int q = 0; q < 2; q++)
{
var ri = UiKit.Rect("Ray" + q, card.rays, mid, mid, Vector2.zero, new Vector2(ch * cardRow.max * 1.7f, cardRow.ready)).gameObject.AddComponent<Image>();
ri.sprite = streakSp;
ri.raycastTarget = false;
if (AddMat != null) ri.material = AddMat;
ri.rectTransform.localEulerAngles = new Vector3(0f, 0f, 28f + q * 84f);
}
}
card.edge = UiKit.Paint(UiKit.Rect("Edge", card.deco, mid, mid, Vector2.zero, new Vector2(cw, ch) + Vector2.one * (cardRow.stand + (card.rainbow ? 8f : 0f))), tr.color, true, false);
card.deco.gameObject.SetActive(false);
}
card.rt = UiKit.Rect("Card" + i, cardsLayer, new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0f), home, new Vector2(cw, ch + strip));
var box = UiKit.Rect("Box", card.rt, new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), Vector2.zero, new Vector2(cw, ch));
if (h != null) CollectionCards.CardIn(box, s, h, false, null, k);
if (p.isNew)
{
// NEW = 카드 위 가장자리 가운데에 걸침(이름·병과 심볼을 가리지 않게)
var nw = UiKit.Rect("New", box, new Vector2(0.5f, 1f), new Vector2(0.5f, 0.5f), new Vector2(0f, 12f), new Vector2(86f, 36f));
UiKit.Skin(nw, "tail", UiKit.Col("tail_red", UiKit.Red)).enabled = UISkin.Has("tail");
UiKit.Title(UiKit.FillLabel(nw, "NEW", 24, UiKit.White), true);
}
if (card.rainbow && streakSp != null)
{
// ★★★ = 카드 위를 비스듬히 지나가는 빛(카드 밖은 잘림 · 닿은 뒤 되풀이)
var mask = UiKit.Fill("Sheen", box);
mask.gameObject.AddComponent<RectMask2D>();
var sh = UiKit.Rect("Line", mask, mid, mid, new Vector2(-cw * 2f, 0f), new Vector2(ch * 1.7f, cw * 0.4f)).gameObject.AddComponent<Image>();
sh.sprite = streakSp;
sh.raycastTarget = false;
if (AddMat != null) sh.material = AddMat;
sh.color = new Color(1f, 1f, 1f, 0.7f);
sh.rectTransform.localEulerAngles = new Vector3(0f, 0f, 62f);
card.sheen = sh.rectTransform;
card.sheenW = cw;
}
var st = UiKit.Rect("Strip", card.rt, new Vector2(0.5f, 0f), new Vector2(0.5f, 0f), Vector2.zero, new Vector2(cw, strip));
BuildStrip(card, st, p, h, cw, strip, total, run);
card.strip = st;
st.gameObject.SetActive(false); // 조각 정보는 카드가 닿은 뒤에(떨어지는 동안 다른 카드를 가리지 않게)
card.flash = UiKit.Paint(UiKit.Fill("Flash", box), Color.white, true, false);
card.rt.gameObject.SetActive(false);
cards.Add(card);
}
cardFx.SetAsLastSibling();
}
/// <summary>카드 아래 조각 정보 = 조각 아이콘 + 게이지(보유 / 다음 ★ 필요) + 「+N」(PD 2026-11-13 「영웅을 획득할 때 조각 정보」) · 병사 중복 = 골드.</summary>
void BuildStrip(Card card, RectTransform st, GachaPull p, HeroDb.Hero h, float cw, float strip, Dictionary<string, int> total, Dictionary<string, int> run)
{
if (h == null) return;
if (h.isSoldier)
{
if (p.gold <= 0) return;
UiKit.IconOrDot(st, "cur_gold", "골", UiKit.Coin, new Vector2(0f, 0.5f), new Vector2(0f, 0.5f), new Vector2(cw * 0.16f, 0f), strip * 0.72f);
UiKit.Title(UiKit.Label(st, "+" + p.gold, 30, UiKit.White, TextAnchor.MiddleLeft, new Vector2(0f, 0.5f), new Vector2(0f, 0.5f), new Vector2(cw * 0.16f + strip * 0.8f, 0f), new Vector2(cw * 0.6f, strip)), true);
return;
}
var hs = s.Save.Hero(h.id);
int have = hs != null ? hs.shards : 0;
int sum, done;
total.TryGetValue(h.id, out sum);
run.TryGetValue(h.id, out done);
int after = have - sum + done + p.shards;
run[h.id] = done + p.shards;
int need = hs != null ? HeroRules.ShardsForNext(s.Db, hs) : 0;
float icon = strip * 0.74f;
UiKit.IconOrDot(st, "cur_shard", "조", UiKit.Purple, new Vector2(0f, 0.5f), new Vector2(0f, 0.5f), new Vector2(2f, 0f), icon);
float bw = cw - icon - 10f;
card.gauge = UiKit.Bar("ShardBar", st, new Vector2(0f, 0.5f), new Vector2(0f, 0.5f), new Vector2(icon + 6f, 0f), new Vector2(bw, strip * 0.56f), UiKit.Slot, UiKit.Col("grade_purple", UiKit.Purple));
card.g1 = need > 0 ? Mathf.Clamp01(after / (float)need) : 1f;
card.g0 = need > 0 ? Mathf.Clamp01((after - p.shards) / (float)need) : 1f;
UiKit.SetBar(card.gauge, card.g0);
string num = need > 0 ? after + "/" + need : after + " · 최고";
if (p.shards > 0) num += " <color=#FFE066>+" + p.shards + "</color>";
var tx = UiKit.Title(UiKit.Label(card.gauge.parent, num, 24, UiKit.White, TextAnchor.MiddleCenter, new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), Vector2.zero, new Vector2(bw, strip)), true);
tx.supportRichText = true;
tx.horizontalOverflow = HorizontalWrapMode.Overflow;
}
void UpdateCards(float dt)
{
float ct = t - tCards;
SetA(shade, Mathf.Clamp01(ct / 0.2f) * (dimRow != null ? dimRow.color.a : 0.85f));
bool all = true;
foreach (var c in cards)
{
float u = ct - c.start;
if (u < 0f)
{
all = false;
continue;
}
if (!c.rt.gameObject.activeSelf) c.rt.gameObject.SetActive(true);
float drop = Mathf.Max(0.01f, cardRow.period);
if (u < drop)
{
// 떨어짐 = 위에서 빠르게 · 길쭉하게 늘어남
all = false;
float f = u / drop;
c.rt.anchoredPosition = c.home + new Vector2(0f, cardRow.amp * (1f - f * f));
float sy = Mathf.Lerp(cardRow.speed, 1f, f * f * 0.5f);
c.rt.localScale = new Vector3(1f / Mathf.Sqrt(sy), sy, 1f);
SetA(c.flash, 0f);
continue;
}
if (!c.landed) Land(c, false);
Settle(c, (u - drop) / Mathf.Max(0.01f, cardRow.hold));
SetA(c.flash, Mathf.Clamp01(1f - (u - drop) / Mathf.Max(0.01f, cardRow.hit)));
if (c.gauge != null) UiKit.SetBar(c.gauge, Mathf.Lerp(c.g0, c.g1, EaseOut((u - drop - 0.12f) / 0.35f)));
if (c.rays != null) c.rays.localEulerAngles = new Vector3(0f, 0f, t * 24f);
if (c.rainbow)
{
var hue = Color.HSVToRGB(Mathf.Repeat(t * 0.8f, 1f), 0.7f, 1f);
SetRgb(c.edge, hue);
if (c.sheen != null)
{
float f = Mathf.Repeat((u - drop) / 1.8f, 1f);
c.sheen.anchoredPosition = new Vector2(Mathf.Lerp(-c.sheenW * 1.3f, c.sheenW * 1.3f, Mathf.Clamp01(f / 0.42f)), 0f);
}
SetRgb(c.glow, Color.Lerp(hue, Color.white, 0.35f));
// ★★★ 카드 둘레에 별가루가 계속
if (twinkleRow != null && dt > 0f)
{
c.amb += dt * twinkleRow.speed;
while (c.amb >= 1f)
{
c.amb -= 1f;
CardSparkle(c, Tint());
}
}
}
}
if (all && !doneUi && ct >= (cards.Count > 0 ? cards[cards.Count - 1].start : 0f) + cardRow.period + cardRow.rest) FinishCards();
}
void CardSparkle(Card c, Color col)
{
var size = c.rt.sizeDelta;
var from = c.home + new Vector2(Random.Range(-0.62f, 0.62f) * size.x, Random.Range(0.08f, 1.06f) * size.y);
col.a = 1f;
Emit(cardFx, Sparkle(), from, new Vector2(Random.Range(-1f, 1f), Random.Range(0.2f, 1f)) * twinkleRow.amp, 0f, twinkleRow.period * Random.Range(0.7f, 1.3f),
Random.Range(twinkleRow.w, twinkleRow.h), twinkleRow.w * 0.4f, col, Random.Range(-90f, 90f), false, true, 0.5f, twinkleRow.billow);
}
/// <summary>닿은 뒤 = 납작하게 눌렸다가 튀어 오르며 제 모양으로(u 0 → 1).</summary>
void Settle(Card c, float u)
{
u = Mathf.Clamp01(u);
c.rt.anchoredPosition = c.home;
float sq = 1f + (cardRow.min - 1f) * Mathf.Cos(u * Mathf.PI * 2.5f) * (1f - u) * (1f - u);
c.rt.localScale = new Vector3(1f / Mathf.Sqrt(Mathf.Max(0.3f, sq)), sq, 1f);
if (u >= 1f)
{
SetA(c.flash, 0f);
if (c.gauge != null) UiKit.SetBar(c.gauge, c.g1);
}
}
void Land(Card c, bool quiet)
{
c.landed = true;
if (!c.rt.gameObject.activeSelf) c.rt.gameObject.SetActive(true);
SetA(c.flash, quiet ? 0f : 1f);
if (c.deco != null) c.deco.gameObject.SetActive(true);
if (c.strip != null) c.strip.gameObject.SetActive(true);
if (quiet) return;
var size = c.rt.sizeDelta;
// 먼지 구름 = 카드 발치 양옆으로 퍼짐 · 별가루 = 카드 둘레에서 깜빡(등급이 높을수록 많이)
if (puffRow != null)
for (int i = 0; i < puffRow.count; i++)
{
float side = i % 2 == 0 ? -1f : 1f;
var from = c.home + new Vector2(side * size.x * Random.Range(0.25f, 0.5f), Random.Range(0f, size.y * 0.25f));
Emit(cardFx, puffSp, from, new Vector2(side * puffRow.amp * Random.Range(0.5f, 1f), puffRow.amp * Random.Range(0.1f, 0.6f)), 0f,
puffRow.period * Random.Range(0.8f, 1.2f), puffRow.w, puffRow.h, puffRow.color, 0f);
}
if (twinkleRow != null)
{
var tr = GachaScene.Tier(c.tier);
int n = twinkleRow.count * c.tier;
for (int i = 0; i < n; i++) CardSparkle(c, c.tier == Tier ? Tint() : tr != null ? tr.color : Color.white);
}
}
void FinishCards()
{
if (doneUi) return;
doneUi = true;
Stage = Step.Done;
SetA(shade, dimRow != null ? dimRow.color.a : 0.85f);
UiKit.Btn("Ok", ui, "확인", 40, UiKit.Red, UiKit.White, new Vector2(0.5f, 0f), new Vector2(0.5f, 0f), new Vector2(0f, 180f), new Vector2(420f, 110f), Close);
hint = Hint("화면을 터치하면 닫힙니다", 34);
}
}
}