VSClone/Assets/Scripts/View/GameApp.Systems.cs

401 lines
27 KiB
C#
Raw Normal View History

using System;
using System.Collections.Generic;
using System.Numerics;
using UnityEngine;
using UnityEngine.UI;
using VS.Sim;
using Random = UnityEngine.Random;
using Vector2 = UnityEngine.Vector2;
using Vector3 = UnityEngine.Vector3;
// 부가 시스템 화면 — 선물 슬롯·팝업 · 부스터 · 방치 보상 · 퀘스트 · 일일 보상 · 티켓 상점 · 채팅 · 시청자 수 (systems.md)
namespace VS.View
{
public partial class GameApp
{
static readonly string[] GiftEnum = { "NoAds", "SmallMoneyPack", "BigMoneyPack", "ReducePrice", "X4TapIncome", "X4PassiveIncome", "X10Income" };
static readonly string[] GiftBadge = { "AD", "$", "$$", "%", "x4", "x4", "x10" };
readonly List<(Button b, Image icon, Text badge, Text time)> giftViews = new List<(Button, Image, Text, Text)>();
string GiftTitle(int t) => T.Loc.Get($"Content.Gift.{GiftEnum[t]}.Title", T.Gift(t)?.Title ?? GiftEnum[t]);
string GiftDesc(int t)
{
string d = T.Loc.Get($"Content.Gift.{GiftEnum[t]}.Description", T.Gift(t)?.PopupDescription ?? "");
return d.Replace("{0}", Fmt.Time(G.GiftEffectDuration(t)));
}
// ---------------- 선물 슬롯 (GiftSlotView) ----------------
void BuildGiftSlots(RectTransform parent)
{
int n = Mathf.Max(1, T.Cfg.gift.maxVisibleSlots);
for (int i = 0; i < n; i++)
{
int slot = i;
var b = UI.Btn(parent, "GiftSlot" + i, Skin.Dark, Color.white, () => OnGiftSlot(slot));
b.GetComponent<RectTransform>().sizeDelta = new Vector2(140, 140);
var ic = UI.Icon(b.transform, "Icon", Skin.Icon("gift"), Color.white); ic.rectTransform.Fill(18, 40, 18, 12);
var badge = UI.Txt(b.transform, "Badge", "", 34, Color.white, TextAnchor.MiddleCenter, true);
badge.rectTransform.Place(new Vector2(0.5f, 0), new Vector2(0.5f, 0), new Vector2(0, 24), new Vector2(140, 50));
var tm = UI.Txt(b.transform, "Time", "", 26, Color.white, TextAnchor.MiddleCenter, true);
tm.rectTransform.Place(new Vector2(0.5f, 0), new Vector2(0.5f, 0), new Vector2(0, -16), new Vector2(170, 40));
giftViews.Add((b, ic, badge, tm));
}
}
void OnGiftSlot(int slot)
{
if (G.GiftSlot == null || slot >= G.GiftSlot.Length) return;
int t = G.GiftSlot[slot];
if (t < 0 || G.IsGiftEffectActive(t)) return;
OpenGiftPopup(t);
}
void RefreshGiftSlots()
{
for (int i = 0; i < giftViews.Count; i++)
{
var (b, icon, badge, time) = giftViews[i];
int t = G.GiftSlot != null && i < G.GiftSlot.Length ? G.GiftSlot[i] : -1;
b.gameObject.SetActive(t >= 0);
if (t < 0) continue;
badge.text = GiftBadge[t];
float rem = G.GiftEffectRemain(t);
time.text = rem > 0 ? Fmt.Time(rem) : (t == GiftTypes.NoAds && G.Gifts.TryGetValue(t, out var gs) && gs.Rv > 0 ? $"{gs.Rv}/{T.X.giftNoAdsVideos}" : "");
b.GetComponent<Image>().color = rem > 0 ? new Color32(255, 225, 150, 255) : (Color32)Color.white;
}
}
public void OpenGiftPopup(int t)
{
var p = NewPopup("Gift", GiftTitle(t), 980, Pal.Purple);
var ic = UI.Icon(p.body, "Icon", Skin.Icon("gift"), Color.white);
ic.rectTransform.Place(new Vector2(0.5f, 1), new Vector2(0.5f, 1), new Vector2(0, -120), new Vector2(200, 200));
UI.Txt(ic.transform, "Badge", GiftBadge[t], 64, Color.white, TextAnchor.MiddleCenter, true).rectTransform.Fill();
var desc = UI.Txt(p.body, "Desc", GiftDesc(t), 38, Pal.Ink);
desc.rectTransform.Place(new Vector2(0, 1), new Vector2(1, 1), new Vector2(0, -300), new Vector2(-40, 130));
string amount = GiftTypes.IsInstant(t) ? Fmt.Num(G.MoneyPackReward(t)) + " 코인" : "";
var amt = UI.Txt(p.body, "Amount", amount, 54, Pal.PinkDark);
amt.rectTransform.Place(new Vector2(0, 1), new Vector2(1, 1), new Vector2(0, -410), new Vector2(0, 80));
float x = t == GiftTypes.SmallMoneyPack ? -300 : -170;
void Btn(string label, Color c, Action a) { var b = UI.TextBtn(p.body, label, label, c, 36, () => { a(); RefreshAll(); }, out _); b.GetComponent<RectTransform>().Place(new Vector2(0.5f, 0), new Vector2(0.5f, 0), new Vector2(x, 80), new Vector2(280, 140)); x += 300; }
if (t == GiftTypes.SmallMoneyPack) Btn("받기", Pal.Green, () => { ShowGot(G.ClaimGift(t, false)); ClosePopup(); });
Btn("광고 보기\n(시험)", Pal.Pink, () => { ShowGot(G.ClaimGift(t, true)); ClosePopup(); });
Btn("티켓 1장", Pal.Blue, () => { if (!G.TrySpendTicket()) { Toast("티켓이 부족합니다"); return; } ShowGot(G.ClaimGift(t, true)); ClosePopup(); });
var close = p.panel.Find("Head/Close")?.GetComponent<Button>();
if (close != null) { close.onClick.RemoveAllListeners(); close.onClick.AddListener(() => { G.DismissGift(t); ClosePopup(); }); }
}
void ShowGot(BigInteger v) { if (v > 0) FloatText(new Vector2(0, 260), "+" + Fmt.Num(v), Pal.Yellow, 64, Skin.Icon("coin")); }
void OpenFreeGiftPopup()
{
var p = NewPopup("FreeGift", L("UI.FreeGift.Title", "무료 선물"), 800, Pal.Pink);
UI.Icon(p.body, "Ticket", Skin.Icon("ticket"), Color.white).rectTransform.Place(new Vector2(0.5f, 1), new Vector2(0.5f, 1), new Vector2(0, -130), new Vector2(200, 200));
UI.Txt(p.body, "T", $"티켓 {T.Cfg.remote.FreeGiftTicketAmount}장을 드립니다!\n티켓으로 광고 없이 선물을 받을 수 있어요", 36, Pal.Ink).rectTransform.Place(new Vector2(0, 1), new Vector2(1, 1), new Vector2(0, -320), new Vector2(-40, 140));
var b = UI.TextBtn(p.body, "Claim", "받기", Pal.Green, 44, () => { G.ClaimFreeGiftPopup(); ClosePopup(); RefreshAll(); }, out _);
b.GetComponent<RectTransform>().Place(new Vector2(0.5f, 0), new Vector2(0.5f, 0), new Vector2(0, 80), new Vector2(360, 140));
}
// ---------------- 부스터 (Boosterx2HeartPopup · BoosterAutoClickPopup) ----------------
public void OpenBoosterX2() => OpenBooster(true);
public void OpenBoosterAuto() => OpenBooster(false);
void OpenBooster(bool x2)
{
var r = T.Cfg.remote;
string title = x2 ? L("Gameplay.Booster.DoubleCoin.Title", "코인 2배") : L("Gameplay.Booster.AutoClick.Title", "자동 클릭");
string desc = x2 ? L("Gameplay.Booster.DoubleCoin.Description", "{0} 동안 코인 2배") : L("Gameplay.Booster.AutoClick.Description", "{0} 동안 자동 클릭");
var p = NewPopup(x2 ? "BoosterX2" : "BoosterAuto", title, 860, x2 ? Pal.Red : Pal.Blue);
UI.Icon(p.body, "Icon", Skin.Icon(x2 ? "x2" : "auto"), Color.white).rectTransform.Place(new Vector2(0.5f, 1), new Vector2(0.5f, 1), new Vector2(0, -120), new Vector2(200, 200));
var d = UI.Txt(p.body, "Desc", desc.Replace("{0}", Fmt.Time(x2 ? r.X2CoinRewardTime : r.AutoTapRewardTime)), 40, Pal.Ink);
d.rectTransform.Place(new Vector2(0, 1), new Vector2(1, 1), new Vector2(0, -300), new Vector2(-40, 110));
var remain = UI.Txt(p.body, "Remain", "", 34, Pal.PinkDark);
remain.rectTransform.Place(new Vector2(0, 1), new Vector2(1, 1), new Vector2(0, -390), new Vector2(0, 60));
Action act = () => { if (x2) G.ActivateX2Boost(); else G.ActivateAutoClick(); ClosePopup(); RefreshAll(); };
var ad = UI.TextBtn(p.body, "Ad", "광고 보기\n(시험)", Pal.Pink, 36, () => act(), out _);
ad.GetComponent<RectTransform>().Place(new Vector2(0.5f, 0), new Vector2(0.5f, 0), new Vector2(-170, 80), new Vector2(300, 140));
var tk = UI.TextBtn(p.body, "Ticket", "티켓 1장", Pal.Blue, 36, () => { if (G.TrySpendTicket()) act(); else Toast("티켓이 부족합니다"); }, out _);
tk.GetComponent<RectTransform>().Place(new Vector2(0.5f, 0), new Vector2(0.5f, 0), new Vector2(170, 80), new Vector2(300, 140));
p.refresh = () => { float rm = x2 ? G.AdsBoostRemain : G.AutoClickRemain; remain.text = rm > 0 ? "남은 시간 " + Fmt.Time(rm) + " (누적됨)" : ""; };
p.refresh();
}
// ---------------- 방치 보상 (ClaimAfkPopup · 룰렛 ×2 ×3 ×5 ×3 ×2) ----------------
float afkArrow; bool afkLocked;
public void OpenAfkPopup(bool auto)
{
var pending = G.AfkPending;
if (pending <= 0) { if (!auto) Toast("받을 방치 보상이 없습니다"); return; }
var p = NewPopup("ClaimAfk", L("UI.AfkGift.Title", "방치 보상"), 1100, Pal.Blue);
var msg = UI.Txt(p.body, "Msg", $"자리를 비운 {Fmt.Time(G.AfkSeconds)} 동안 번 코인", 36, Pal.Ink);
msg.rectTransform.Place(new Vector2(0, 1), new Vector2(1, 1), new Vector2(0, -60), new Vector2(-40, 70));
var amt = UI.Txt(p.body, "Amount", Fmt.Num(pending), 80, Pal.PinkDark);
amt.rectTransform.Place(new Vector2(0, 1), new Vector2(1, 1), new Vector2(0, -170), new Vector2(0, 110));
var bar = UI.Rect("Roulette", p.body).Place(new Vector2(0.5f, 1), new Vector2(0.5f, 1), new Vector2(0, -330), new Vector2(820, 110));
var mul = T.X.afkMultipliers;
float w = 820f / mul.Length;
var segs = new List<Image>();
for (int i = 0; i < mul.Length; i++)
{
var s = UI.Img(bar, "Seg" + i, Skin.Round, mul[i] >= 5 ? Pal.Orange : (mul[i] >= 3 ? Pal.Purple : Pal.Pink));
s.rectTransform.Place(new Vector2(0, 0.5f), new Vector2(0, 0.5f), new Vector2(w * i + w / 2, 0), new Vector2(w - 8, 100));
UI.Txt(s.transform, "T", "x" + mul[i], 44, Color.white, TextAnchor.MiddleCenter, true).rectTransform.Fill();
segs.Add(s);
}
var arrow = UI.Icon(bar, "Arrow", Skin.Icon("up"), Color.white);
arrow.rectTransform.Place(new Vector2(0, 0), new Vector2(0, 0), new Vector2(0, -50), new Vector2(70, 70));
afkArrow = 0; afkLocked = false;
int SegAt() => Mathf.Clamp(Mathf.FloorToInt(Mathf.PingPong(afkArrow / Mathf.Max(0.01f, T.X.afkArrowMoveDuration), 1f) * mul.Length), 0, mul.Length - 1);
var claim = UI.TextBtn(p.body, "Claim", "받기", Pal.Green, 40, () => { ShowGot(G.ClaimAfk(1)); ClosePopup(); RefreshAll(); }, out _);
claim.GetComponent<RectTransform>().Place(new Vector2(0.5f, 0), new Vector2(0.5f, 0), new Vector2(-300, 80), new Vector2(280, 140));
var ad = UI.TextBtn(p.body, "Ad", "광고 보기\n(시험)", Pal.Pink, 34, () => { afkLocked = true; ShowGot(G.ClaimAfk(mul[SegAt()])); ClosePopup(); RefreshAll(); }, out _);
ad.GetComponent<RectTransform>().Place(new Vector2(0.5f, 0), new Vector2(0.5f, 0), new Vector2(0, 80), new Vector2(280, 140));
var tk = UI.TextBtn(p.body, "Ticket", "티켓 1장", Pal.Blue, 34, () => { if (!G.TrySpendTicket()) { Toast("티켓이 부족합니다"); return; } afkLocked = true; ShowGot(G.ClaimAfk(mul[SegAt()])); ClosePopup(); RefreshAll(); }, out _);
tk.GetComponent<RectTransform>().Place(new Vector2(0.5f, 0), new Vector2(0.5f, 0), new Vector2(300, 80), new Vector2(280, 140));
p.refresh = () =>
{
if (!afkLocked) afkArrow += 0.1f;
float f = Mathf.PingPong(afkArrow / Mathf.Max(0.01f, T.X.afkArrowMoveDuration), 1f);
arrow.rectTransform.anchoredPosition = new Vector2(f * 820f, -50);
int k = SegAt();
for (int i = 0; i < segs.Count; i++) segs[i].transform.localScale = Vector3.one * (i == k ? 1.08f : 1f);
};
}
// ---------------- 퀘스트 (TaskPopup) ----------------
string QuestText(SaveQuest q)
{
var r = G.QuestRowOf(q.id);
if (r == null) return "";
int idx = r.conditionType < T.X.questTemplateIndex.Length ? T.X.questTemplateIndex[r.conditionType] : 0;
string tpl = T.Loc.Get("Task.Index" + idx, "{0}");
string a0 = r.param1, a1 = r.param2;
switch (r.conditionType)
{
case QuestCond.ConsumeCoin: case QuestCond.IncreaseCoin: case QuestCond.ConsumeCoinDynamic: case QuestCond.IncreaseCoinDynamic:
a0 = Fmt.Num(Big.D(q.target)); break;
case QuestCond.UnlockRoomItem: case QuestCond.UpgradeRoomItem: a0 = TargetName(r.param1); break;
case QuestCond.UnlockHouse: a0 = ((int)Big.D(r.param1) + 1).ToString(); break;
case QuestCond.ClaimGiftMoney: case QuestCond.ClaimGiftBuff:
{ int gt = (int)Big.D(r.param1); a0 = gt > 0 && gt < GiftEnum.Length ? GiftTitle(gt) : "선물"; break; }
}
return tpl.Replace("{0}", a0).Replace("{1}", a1);
}
string TargetName(string target)
{
var p = (target ?? "").Split('_');
if (p[0] == "0") return p.Length >= 2 ? "의상" : L("UI.Closet.Title", "의상");
if (p[0] == "1" && p.Length >= 3 && int.TryParse(p[1], out var h) && int.TryParse(p[2], out var i) && h >= 0 && h < Tables.HouseCount)
{
var it = T.Rooms[h].Items.Find(x => x.Index == i);
return it != null ? ItemName(h, it) : target;
}
if (p[0] == "2" && p.Length >= 2 && int.TryParse(p[1], out var s)) { var it = T.Skills.Items.Find(x => x.Index == s); return it != null ? SkillName(it) : target; }
return target;
}
public void OpenTasks()
{
var p = NewPopup("Task", L("UI.Task.Title", "퀘스트"), 1100, Pal.Green);
var list = UI.Rect("List", p.body).Fill();
var vl = list.gameObject.AddComponent<VerticalLayoutGroup>();
vl.spacing = 24; vl.childControlHeight = true; vl.childControlWidth = true; vl.childForceExpandHeight = false; vl.padding = new RectOffset(0, 0, 10, 10);
var rows = new List<(Text t, Image fill, Text prog, Button b, Text bl)>();
for (int i = 0; i < Mathf.Max(1, T.X.questSlots); i++)
{
var bg = UI.Img(list, "Quest" + i, Skin.Round, Pal.Row);
UI.SetLayoutHeight(bg, 250);
var tx = UI.Txt(bg.transform, "Text", "", 38, Pal.Ink, TextAnchor.MiddleLeft);
tx.rectTransform.Place(new Vector2(0, 1), new Vector2(1, 1), new Vector2(30, -60), new Vector2(-380, 80), new Vector2(0, 0.5f));
var bar = UI.Bar(bg.transform, "Bar", Color.white, Pal.Green, out var fill);
bar.rectTransform.Place(new Vector2(0, 0), new Vector2(1, 0), new Vector2(30, 70), new Vector2(-380, 40), new Vector2(0, 0.5f));
var pr = UI.Txt(bar.transform, "P", "", 26, Pal.Ink); pr.rectTransform.Fill();
int slot = i;
var b = UI.TextBtn(bg.transform, "Claim", "", Pal.Green, 32, () =>
{
var act = G.ActiveQuests();
if (slot >= act.Count) return;
var got = G.ClaimQuest(act[slot].id);
if (got != null) Toast("보상: " + got);
RefreshAll();
}, out var bl);
b.GetComponent<RectTransform>().Place(new Vector2(1, 0.5f), new Vector2(1, 0.5f), new Vector2(-170, 0), new Vector2(300, 150));
rows.Add((tx, fill, pr, b, bl));
}
p.refresh = () =>
{
var act = G.ActiveQuests();
for (int i = 0; i < rows.Count; i++)
{
var (t, fill, prog, b, bl) = rows[i];
bool has = i < act.Count;
b.transform.parent.gameObject.SetActive(has);
if (!has) continue;
var q = act[i]; var r = G.QuestRowOf(q.id);
t.text = QuestText(q);
decimal pv = Big.D(q.progress), tg = Math.Max(1m, Big.D(q.target));
fill.fillAmount = Mathf.Clamp01((float)(pv / tg));
prog.text = $"{Fmt.Num(Math.Min(pv, tg))} / {Fmt.Num(tg)}";
var rw = G.QuestRewardPreview(q);
bl.text = (q.state == 2 ? "받기\n" : "보상\n") + (r != null && r.conditionType == QuestCond.UnlockHouse ? $"티켓 {rw}" : Fmt.Num(rw));
b.interactable = q.state == 2;
}
};
p.refresh();
}
// ---------------- 일일 보상 (DailyRewardPopup) ----------------
public void OpenDaily()
{
var p = NewPopup("DailyReward", L("UI.DailyReward.Title", "일일 보상"), 1200, Pal.Orange);
var grid = UI.Rect("Grid", p.body).Fill(10, 220, 10, 20);
var gl = grid.gameObject.AddComponent<GridLayoutGroup>();
gl.cellSize = new Vector2(210, 260); gl.spacing = new Vector2(16, 16); gl.constraint = GridLayoutGroup.Constraint.FixedColumnCount; gl.constraintCount = 4; gl.childAlignment = TextAnchor.UpperCenter;
var cells = new List<Image>();
for (int i = 0; i < T.X.dailyRewards.Count; i++)
{
var d = T.X.dailyRewards[i];
var c = UI.Img(grid, "Day" + (i + 1), Skin.Round, Color.white);
UI.Txt(c.transform, "Day", $"{i + 1}일차", 30, Pal.Ink).rectTransform.Place(new Vector2(0, 1), new Vector2(1, 1), new Vector2(0, -30), new Vector2(0, 44));
var ic = UI.Img(c.transform, "Icon", d.kind == 0 ? Skin.Icon("ticket") : Art.Idol(d.amount, 0), Color.white); ic.preserveAspect = true;
ic.rectTransform.Place(new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(0, -6), new Vector2(130, 130));
UI.Txt(c.transform, "Amt", d.kind == 0 ? "x" + d.amount : "의상", 30, Pal.PinkDark).rectTransform.Place(new Vector2(0, 0), new Vector2(1, 0), new Vector2(0, 28), new Vector2(0, 44));
cells.Add(c);
}
var b = UI.TextBtn(p.body, "Claim", "받기", Pal.Green, 44, () => { var d = G.ClaimDaily(); if (d != null) Toast(d.kind == 0 ? $"티켓 {d.amount}장" : "새 의상!"); RefreshAll(); }, out var bl);
b.GetComponent<RectTransform>().Place(new Vector2(0.5f, 0), new Vector2(0.5f, 0), new Vector2(0, 90), new Vector2(400, 140));
p.refresh = () =>
{
for (int i = 0; i < cells.Count; i++)
cells[i].color = i < G.S.dailyClaimedDays ? new Color32(210, 240, 214, 255) : (i == G.S.dailyClaimedDays && G.DailyClaimable ? new Color32(255, 236, 170, 255) : Color.white);
b.interactable = G.DailyClaimable;
bl.text = G.DailyClaimable ? "받기" : (G.S.dailyClaimedDays >= cells.Count ? "모두 받음" : "내일 다시");
};
p.refresh();
}
// ---------------- 티켓 상점 (TicketShopPopup) ----------------
public void OpenTicketShop()
{
var p = NewPopup("TicketShop", L("UI.Shop.TicketTitle", "티켓 상점"), 1500, Pal.Pink);
var online = UI.Img(p.body, "Online", Skin.Round, new Color32(255, 240, 248, 255));
online.rectTransform.Place(new Vector2(0, 1), new Vector2(1, 1), new Vector2(0, 0), new Vector2(0, 260), new Vector2(0.5f, 1));
var ot = UI.Txt(online.transform, "T", "", 36, Pal.Ink, TextAnchor.MiddleLeft); ot.rectTransform.Fill(30, 20, 360, 20);
var ob = UI.TextBtn(online.transform, "Claim", "받기", Pal.Green, 36, () => { if (G.ClaimOnline()) Toast("티켓 +1"); RefreshAll(); }, out _);
ob.GetComponent<RectTransform>().Place(new Vector2(1, 0.5f), new Vector2(1, 0.5f), new Vector2(-170, 0), new Vector2(300, 140));
var daily = UI.TextBtn(p.body, "Daily", "일일 보상", Pal.Orange, 38, () => OpenDaily(), out _);
daily.GetComponent<RectTransform>().Place(new Vector2(0.5f, 1), new Vector2(0.5f, 1), new Vector2(0, -330), new Vector2(500, 120));
var grid = UI.Rect("Packs", p.body).Fill(20, 20, 20, 430);
var gl = grid.gameObject.AddComponent<GridLayoutGroup>();
gl.cellSize = new Vector2(280, 300); gl.spacing = new Vector2(20, 20); gl.constraint = GridLayoutGroup.Constraint.FixedColumnCount; gl.constraintCount = 3; gl.childAlignment = TextAnchor.UpperCenter;
foreach (var n in T.X.ticketPacks)
{
int amount = n;
var b = UI.Btn(grid, "Pack" + n, Skin.Round, Color.white, () => { G.S.ticket += amount; Toast($"시험 지급: 티켓 {amount}장"); RefreshAll(); });
UI.Icon(b.transform, "I", Skin.Icon("ticket"), Color.white).rectTransform.Place(new Vector2(0.5f, 1), new Vector2(0.5f, 1), new Vector2(0, -100), new Vector2(150, 150));
UI.Txt(b.transform, "N", "x" + n, 44, Pal.PinkDark).rectTransform.Place(new Vector2(0, 0), new Vector2(1, 0), new Vector2(0, 90), new Vector2(0, 60));
UI.Txt(b.transform, "P", "시험 지급", 26, Pal.Gray).rectTransform.Place(new Vector2(0, 0), new Vector2(1, 0), new Vector2(0, 36), new Vector2(0, 40));
}
p.refresh = () =>
{
ob.interactable = G.OnlineClaimable;
ot.text = G.S.onlineClaimed >= 3 ? "오늘 온라인 티켓을 모두 받았습니다"
: G.OnlineClaimable ? "온라인 보상: 티켓 1장 받을 수 있음" : $"온라인 보상 {G.S.onlineClaimed}/3 · 다음까지 {Fmt.Time(G.OnlineNextAt - G.S.onlineSeconds)}";
};
p.refresh();
}
// ---------------- HUD 갱신 ----------------
void RefreshSystemsHud()
{
var sb = new System.Text.StringBuilder();
if (G.AdsBoostRemain > 0) sb.Append($"♥ x2 {Fmt.Time(G.AdsBoostRemain)}\n");
if (G.AutoClickRemain > 0) sb.Append($"⚡ 자동 {Fmt.Time(G.AutoClickRemain)}\n");
foreach (var t in new[] { GiftTypes.X10Income, GiftTypes.X4TapIncome, GiftTypes.X4PassiveIncome, GiftTypes.ReducePrice })
if (G.IsGiftEffectActive(t)) sb.Append($"{GiftTitle(t)} {Fmt.Time(G.GiftEffectRemain(t))}\n");
boostText.text = sb.ToString();
afkBtn.gameObject.SetActive(G.AfkPending > 0);
taskDot.gameObject.SetActive(G.AnyQuestClaimable());
RefreshGiftSlots();
if (!PopupOpen && !Headless)
{
if (G.PendingFreeGiftPopup && !G.S.freeGiftPopupClaimed) OpenFreeGiftPopup();
else if (G.AfkPending > 0 && !afkShownThisSession) { afkShownThisSession = true; OpenAfkPopup(true); }
else if (G.DailyClaimable && !dailyShownThisSession && G.S.playSeconds > 5f) { dailyShownThisSession = true; OpenDaily(); }
}
}
bool afkShownThisSession, dailyShownThisSession;
void HookSystemEvents() { }
// ---------------- 채팅 (ChatSpawnController · LivestreamChatPresenter) ----------------
class PendingChat { public string text; public bool donate; public float age; }
readonly List<PendingChat> chatQueue = new List<PendingChat>();
readonly List<GameObject> chatLines = new List<GameObject>();
float chatSpawnTimer = 1f, chatShowTimer; int consecutiveDonate; bool lastShownDonate;
float viewTimer; BigInteger viewValue;
/// 상태 = 스트리머 상태(Idle 0 · Pose 1 · Hype 2) — GetCurrentChatSpawnState RVA 0x23FAF6C
int ChatState => G.HypePlaying ? 2 : (poseTimer > 0 ? 1 : 0);
void UpdateChat(float dt)
{
var rules = T.X.chatRules;
if (rules == null || rules.Count == 0 || chatContent == null) return;
var rule = rules.Find(r => r.state == ChatState) ?? rules[0];
chatSpawnTimer -= dt;
if (chatSpawnTimer <= 0f)
{
// GetSpawnInterval RVA 0x23FAEB0
float iv = (1f / Mathf.Max(0.01f, rule.CommentsPerSecond)) * Mathf.Pow(1f - Mathf.Clamp(rule.IntervalReducePercentPerRank, 0f, 0.99f), Mathf.Max(G.S.rankIndex, 0));
chatSpawnTimer = Mathf.Max(0.01f, iv);
// ShouldReplaceChatWithDonate RVA 0x2560AC8
bool donate;
if (consecutiveDonate >= rule.MaxConsecutiveDonate) { consecutiveDonate = 0; donate = false; }
else if (Random.value <= rule.DonateReplaceChance) { consecutiveDonate++; donate = true; }
else { consecutiveDonate = 0; donate = false; }
string name = T.ChatNames.Names[Random.Range(0, T.ChatNames.Names.Count)];
string txt = donate
? $"<color=#FFD24A>{name}</color> 님이 <color=#FFD24A>{Random.Range(T.X.chatDonateMin, T.X.chatDonateMax)}</color> 후원!"
: $"<color=#C9A8FF>{name}</color>: {T.Comments.Items[Random.Range(0, T.Comments.Items.Count)].CommentText}";
chatQueue.Add(new PendingChat { text = txt, donate = donate });
if (chatQueue.Count > T.X.chatQueueCap) chatQueue.RemoveAt(0);
}
for (int i = chatQueue.Count - 1; i >= 0; i--) { chatQueue[i].age += dt; if (chatQueue[i].age > T.X.chatMaxQueueAge) chatQueue.RemoveAt(i); }
chatShowTimer -= dt;
if (chatShowTimer <= 0f && chatQueue.Count > 0)
{
chatShowTimer = G.HypePlaying ? T.X.chatShowIntervalHype : T.X.chatShowInterval;
int pick = 0;
if (lastShownDonate) { int k = chatQueue.FindIndex(c => !c.donate); if (k >= 0) pick = k; } // PickNextPendingIndex RVA 0x25F5E74
var c0 = chatQueue[pick]; chatQueue.RemoveAt(pick);
lastShownDonate = c0.donate;
AddChatLine(c0.text, c0.donate);
}
// 시청자 수(FrameView.RandomView RVA 0x243A2DC) — 팔로워 × 90~119% · 5~120 s 마다
viewTimer -= dt;
if (viewTimer <= 0f || viewValue == 0)
{
viewTimer = Random.Range(T.X.viewRerollMin, T.X.viewRerollMax);
int pct = G.Follower <= T.X.viewSmallFollower ? Random.Range(T.X.viewSmallMin, T.X.viewRandomMax) : Random.Range(T.X.viewRandomMin, T.X.viewRandomMax);
viewValue = G.Follower * pct / 100;
}
if (viewText != null) viewText.text = Fmt.Num(viewValue);
}
void AddChatLine(string text, bool donate)
{
var bg = UI.Img(chatContent, "Chat", Skin.RoundSoft, donate ? new Color(0.45f, 0.28f, 0.05f, 0.8f) : new Color(0.12f, 0.14f, 0.24f, 0.65f));
var t = UI.Txt(bg.transform, "T", text, 30, Color.white, TextAnchor.MiddleLeft);
t.supportRichText = true; t.resizeTextForBestFit = false; t.horizontalOverflow = HorizontalWrapMode.Wrap; t.verticalOverflow = VerticalWrapMode.Overflow;
t.rectTransform.Fill(20, 6, 20, 6);
var le = bg.gameObject.AddComponent<LayoutElement>(); le.preferredHeight = 56; le.minHeight = 56;
chatLines.Add(bg.gameObject);
while (chatLines.Count > Mathf.Max(1, T.X.chatVisibleLines)) { Kill(chatLines[0]); chatLines.RemoveAt(0); }
}
}
}