using System; using System.Collections.Generic; using System.IO; using UnityEngine; // "МОЗГ" всех панелей HUD: профиль, задания, почта, магазин, герой, войска, бой, арена, событие. // Панели только РИСУЮТ и вызывают методы отсюда -- вся логика и сохранение здесь. // // Создаётся сам при запуске сцены (ничего вешать руками не нужно). // Сохраняется в persistentDataPath/progress.json -- рядом с save.json от SaveManager. public class GameProgress : MonoBehaviour { static GameProgress instance; public static bool HasInstance { get { return instance != null; } } public static GameProgress Instance { get { if (instance == null) { instance = FindAnyObjectByType(); if (instance == null) instance = new GameObject("GameProgress").AddComponent(); } return instance; } } [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] static void AutoCreate() { var _ = Instance; } public ProgressData Data { get; private set; } // Любое изменение прогресса -> панели перерисовываются public event Action OnChanged; string SavePath { get { return Path.Combine(Application.persistentDataPath, "progress.json"); } } bool dirty; float saveAt = -1f; float nextTick; int suppressTracking; // >0 -- выдаём награду, не считаем это "сбором" float trackingStartsAt; // первые секунды после старта не считаем (идёт загрузка сохранения) bool subscribed; readonly float[] lastValues = new float[8]; int cachedBuildings; static readonly DateTime Epoch = new DateTime(2024, 1, 1); // понедельник // ===================================================================== // ЖИЗНЕННЫЙ ЦИКЛ // ===================================================================== void Awake() { if (instance != null && instance != this) { Destroy(gameObject); return; } instance = this; DontDestroyOnLoad(gameObject); Load(); trackingStartsAt = Time.unscaledTime + 2f; } void Start() { ApplySettings(); } void Update() { if (!subscribed) TrySubscribe(); if (Time.unscaledTime >= nextTick) { nextTick = Time.unscaledTime + 1f; EnsureDailyAndWeekly(); ProcessTraining(); UpdateBuildingsCount(); UpdatePowerResource(); } if (saveAt > 0 && Time.unscaledTime >= saveAt) { saveAt = -1f; Save(); } } void LateUpdate() { if (dirty) { dirty = false; if (OnChanged != null) OnChanged(); } } void OnApplicationPause(bool paused) { if (paused) Save(); } void OnApplicationQuit() { Save(); } // Сохранить без перерисовки панелей (для ползунков настроек) public void SaveSoon() { if (saveAt < 0) saveAt = Time.unscaledTime + 1.5f; } public void MarkChanged() { dirty = true; if (saveAt < 0) saveAt = Time.unscaledTime + 1.5f; } // ===================================================================== // СОХРАНЕНИЕ // ===================================================================== void Load() { Data = null; try { if (File.Exists(SavePath)) Data = JsonUtility.FromJson(File.ReadAllText(SavePath)); } catch (Exception e) { Debug.LogWarning("[ПРОГРЕСС] Не удалось прочитать progress.json: " + e.Message); } if (Data == null) Data = new ProgressData(); FixData(); EnsureDailyAndWeekly(); // Новый день -> считаем день в игре int today = Today; if (Data.lastLoginDay != today) { Data.lastLoginDay = today; Data.daysPlayed++; } if (!Data.starterMailSent) SendStarterMail(); MarkChanged(); } // Защита от старых/кривых файлов: массивы нужной длины, списки не null void FixData() { if (string.IsNullOrEmpty(Data.playerId)) Data.playerId = UnityEngine.Random.Range(10000000, 99999999).ToString(); if (Data.firstLaunchTicks == 0) Data.firstLaunchTicks = DateTime.UtcNow.Ticks; if (Data.collected == null || Data.collected.Length < 8) Data.collected = Resize(Data.collected, 8); if (Data.dailyCollected == null || Data.dailyCollected.Length < 8) Data.dailyCollected = Resize(Data.dailyCollected, 8); if (Data.troops == null || Data.troops.Length < GameData.Troops.Count) { int[] t = new int[GameData.Troops.Count]; if (Data.troops != null) Array.Copy(Data.troops, t, Mathf.Min(Data.troops.Length, t.Length)); Data.troops = t; } if (Data.claimedQuests == null) Data.claimedQuests = new List(); if (Data.claimedDaily == null) Data.claimedDaily = new List(); if (Data.crystalWaiting == null) Data.crystalWaiting = new List(); crystals.Load(Data.crystalWaiting); if (Data.mail == null) Data.mail = new List(); if (Data.shopIds == null) Data.shopIds = new List(); if (Data.shopCounts == null) Data.shopCounts = new List(); if (Data.training == null) Data.training = new List(); if (Data.arenaOpponents == null) Data.arenaOpponents = new List(); if (Data.eventClaimed == null) Data.eventClaimed = new List(); ReturnLegacyWorldMarches(); if (Data.sashaMarches == null) Data.sashaMarches = new List(); if (Data.sashaMarchUnits == null) Data.sashaMarchUnits = new List(); if (Data.level < 1) Data.level = 1; if (Data.heroLevel < 1) Data.heroLevel = 1; if (Data.battleStage < 1) Data.battleStage = 1; } static float[] Resize(float[] src, int len) { float[] r = new float[len]; if (src != null) Array.Copy(src, r, Mathf.Min(src.Length, len)); return r; } public void Save() { try { File.WriteAllText(SavePath, JsonUtility.ToJson(Data, true)); } catch (Exception e) { Debug.LogWarning("[ПРОГРЕСС] Не удалось сохранить: " + e.Message); } } // Старая маленькая карта мира удалена: солдаты из её походов возвращаются в город. // Ещё идут к цели -- возвращаются все отправленные; уже идут обратно -- выжившие. Добыча таких походов не переносится. void ReturnLegacyWorldMarches() { if (Data.marches == null || Data.marches.Count == 0) { Data.marches = new List(); return; } int returned = 0; foreach (var m in Data.marches) { int[] back = m.returning ? m.survivors : m.units; if (back == null) continue; for (int i = 0; i < back.Length && i < Data.troops.Length; i++) { Data.troops[i] += Mathf.Max(0, back[i]); returned += Mathf.Max(0, back[i]); } } Data.marches.Clear(); dirty = true; SaveSoon(); Debug.Log($"[ПРОГРЕСС] Старая карта мира удалена: в город вернулось воинов из её походов: {returned}."); } // СБРОС СОХРАНЕНИЙ: прогресс (progress.json) + все модули (save.json): ресурсы, здания, Ратуша, походы. // Кнопка в Настройках и меню Tools -> Сбросить сохранения. Документ: Docs/Modules/Save.md public void ResetAll() { if (File.Exists(SavePath)) File.Delete(SavePath); // Модули сбрасывают себя сами через модуль Сохранений; возврат ресурсов к старту -- не "сбор" suppressTracking++; if (GameServices.TryGet(out ISaveSystem save)) { save.ResetAll(); } else { // Сцена без SaveManager (например, карту мира запустили напрямую из редактора) string modulesSave = Path.Combine(Application.persistentDataPath, "save.json"); if (File.Exists(modulesSave)) File.Delete(modulesSave); foreach (var participant in SaveRegistry.All) participant.ResetState(); } suppressTracking--; Data = new ProgressData(); FixData(); Data.armyInModule = true; // войска сбросил модуль Армии, переносить из progress.json нечего Data.lastLoginDay = Today; Data.daysPlayed = 1; EnsureDailyAndWeekly(); SendStarterMail(); ApplySettings(); Save(); MarkChanged(); UIKit.Toast("Прогресс сброшен. Новая игра!"); } // ===================================================================== // ВРЕМЯ // ===================================================================== public static int Today { get { return (DateTime.Now.Date - Epoch).Days; } } public static int CurrentWeek { get { return Today / 7; } } public static double SecondsToNextDay { get { return (DateTime.Now.Date.AddDays(1) - DateTime.Now).TotalSeconds; } } public static double SecondsToWeekEnd { get { return (Epoch.AddDays((CurrentWeek + 1) * 7) - DateTime.Now).TotalSeconds; } } static long Now { get { return DateTime.UtcNow.Ticks; } } void EnsureDailyAndWeekly() { int today = Today; if (Data.dailyDay != today) { Data.dailyDay = today; Data.claimedDaily.Clear(); Data.dailyCollected = new float[8]; Data.dailyBattles = 0; Data.dailyTroops = 0; Data.dailyArena = 0; Data.dailyMarches = 0; MarkChanged(); } if (Data.shopDay != today) { Data.shopDay = today; Data.shopIds.Clear(); Data.shopCounts.Clear(); } if (Data.eventWeek != CurrentWeek) { Data.eventWeek = CurrentWeek; Data.eventPoints = 0; Data.eventTroopRemainder = 0; Data.eventClaimed.Clear(); MarkChanged(); } } // ===================================================================== // РЕСУРСЫ И НАГРАДЫ // ===================================================================== public float Res(ResourceType t) { return ResourceManager.Instance != null ? ResourceManager.Instance.GetResource(t) : 0f; } public bool CanAfford(RewardItem[] cost, int multiplier = 1) { if (ResourceManager.Instance == null) return false; foreach (var c in cost) if (!ResourceManager.Instance.HasEnough(c.type, (float)c.amount * multiplier)) return false; return true; } public string MissingText(RewardItem[] cost, int multiplier = 1) { var parts = new List(); foreach (var c in cost) { float need = (float)c.amount * multiplier - Res(c.type); if (need > 0) parts.Add(GameData.Short(Mathf.Ceil(need)) + " " + GameData.ResName(c.type)); } return parts.Count == 0 ? "" : "Не хватает: " + string.Join(", ", parts.ToArray()); } // Списать цену. false -- не хватило (ничего не списано + всплывашка) public bool Pay(RewardItem[] cost, int multiplier = 1) { if (!CanAfford(cost, multiplier)) { UIKit.Toast(MissingText(cost, multiplier)); return false; } foreach (var c in cost) ResourceManager.Instance.TrySpend(c.type, (float)c.amount * multiplier); MarkChanged(); return true; } // Выдать награду. Синие кристаллы -- не сверх предела хранилища (регламент 05.11): не поместившиеся приходят // письмом «Кристаллы ждут места» и забираются из него, когда место появится. Ничего не теряется public void Grant(RewardItem[] rewards, int xp = 0, bool toast = true) { int gems = GemsIn(rewards), fit = CrystalRewards.Fits(gems, GemsHave, GemsCap), left = gems - fit; var given = new List(WithoutGems(rewards)); if (fit > 0) given.Add(new RewardItem(ResourceType.Gems, fit)); if (ResourceManager.Instance != null) { suppressTracking++; foreach (var r in given) ResourceManager.Instance.AddResource(r.type, r.amount); suppressTracking--; } if (left > 0) SendMail("Кристаллы ждут места", $"Хранилище синих кристаллов заполнено ({GemsHave:0} / {GemsCap:0}). {left} кристаллов ждут: потратьте кристаллы и заберите их из этого письма.", new RewardItem(ResourceType.Gems, left)); if (xp > 0) AddXp(xp); if (toast) { string text = GameData.RewardsText(given.ToArray()); if (xp > 0) text += (text.Length > 0 ? ", " : "") + xp + " опыта"; if (left > 0) text += (text.Length > 0 ? ". " : "") + $"Ещё {left} кристаллов ждут места -- в почте"; if (text.Length > 0) UIKit.Toast("Получено: " + text); } MarkChanged(); } // ---------------- Синие кристаллы при пределе 50 (регламент 05.11, ред. 1.1; правила -- CrystalRewards) ---------------- readonly CrystalRewards crystals = new CrystalRewards(); // Нет склада (сцена без ResourceManager) -- места нет: кристаллы ждут, а не теряются public static float GemsHave => ResourceManager.Instance != null ? ResourceManager.Instance.GetResource(ResourceType.Gems) : 0f; public static float GemsCap => ResourceManager.Instance != null ? ResourceManager.Instance.GetCapacity(ResourceType.Gems) : 0f; static int GemsIn(IEnumerable rewards) { int n = 0; if (rewards != null) foreach (var r in rewards) if (r != null && r.type == ResourceType.Gems) n += Mathf.Max(0, r.amount); return n; } static RewardItem[] WithoutGems(IEnumerable rewards) { var list = new List(); if (rewards != null) foreach (var r in rewards) if (r != null && r.type != ResourceType.Gems) list.Add(r); return list.ToArray(); } static string QuestKey(QuestDef q, bool daily) => (daily ? "d:" : "m:") + q.id; // Кристаллы награды задания, которые ждут места (0 -- не ждут) public int QuestGemsWaiting(QuestDef q, bool daily) => crystals.Left(QuestKey(q, daily)); // Начислить кристаллы награды key: сколько помещается, остаток ждёт под этим key (сохраняется) int GiveQuestGems(string key, int amount) { int give = crystals.Claim(key, amount, GemsHave, GemsCap); if (give > 0 && ResourceManager.Instance != null) { suppressTracking++; ResourceManager.Instance.AddResource(ResourceType.Gems, give); suppressTracking--; } Data.crystalWaiting = crystals.ToLines(); MarkChanged(); return give; } // ===================================================================== // ОТСЛЕЖИВАНИЕ СБОРА РЕСУРСОВ (для заданий и статистики) // ===================================================================== void TrySubscribe() { var rm = ResourceManager.Instance; if (rm == null) return; rm.OnResourceChanged += HandleResourceChanged; foreach (ResourceType t in Enum.GetValues(typeof(ResourceType))) if ((int)t < lastValues.Length) lastValues[(int)t] = rm.GetResource(t); subscribed = true; } void OnDestroy() { if (subscribed && ResourceManager.Instance != null) ResourceManager.Instance.OnResourceChanged -= HandleResourceChanged; } void HandleResourceChanged(ResourceType type, float value) { int i = (int)type; if (i < 0 || i >= lastValues.Length) return; float delta = value - lastValues[i]; lastValues[i] = value; if (type == ResourceType.Power) return; if (delta <= 0 || suppressTracking > 0 || Time.unscaledTime < trackingStartsAt) { MarkChanged(); return; } Data.collected[i] += delta; Data.dailyCollected[i] += delta; MarkChanged(); } void UpdateBuildingsCount() { int n = FindObjectsByType(FindObjectsSortMode.None).Length; if (n != cachedBuildings) { cachedBuildings = n; MarkChanged(); } } public int BuildingsCount { get { return cachedBuildings; } } public int TownHallLevel { get { return TownHall.Instance != null ? TownHall.Instance.currentLevel : 1; } } // ===================================================================== // ОПЫТ И УРОВЕНЬ // ===================================================================== public void AddXp(int amount) { if (Data.level >= GameData.MaxPlayerLevel) return; Data.xp += amount; while (Data.level < GameData.MaxPlayerLevel && Data.xp >= GameData.XpToNextLevel(Data.level)) { Data.xp -= GameData.XpToNextLevel(Data.level); Data.level++; int gems = 10 * Data.level; SendMail("Новый уровень: " + Data.level + "!", "Поздравляем, командир! Ваша слава растёт. Примите подарок от совета королевства.", new RewardItem(ResourceType.Gems, gems), new RewardItem(ResourceType.Gold, 300 * Data.level)); UIKit.Toast("Новый уровень: " + Data.level + "! Подарок в почте."); Vibrate(); } MarkChanged(); } // ===================================================================== // СИЛА // ===================================================================== public int TroopsPower { get { int p = 0; for (int i = 0; i < GameData.Troops.Count; i++) p += TroopCount(i) * GameData.Troops[i].power; return p; } } public float ArmyBonus { get { float b = GameData.HeroArmyBonusPercent(Data.heroLevel) / 100f; if (HasHeroSkill(0)) b += 0.05f; if (HasHeroSkill(4)) b += 0.15f; return b; } } // Сила армии в бою = войска * бонус героя + сила героя public int ArmyPower { get { return Mathf.RoundToInt(TroopsPower * (1f + ArmyBonus)) + GameData.HeroPower(Data.heroLevel); } } public int TotalPower { get { return ArmyPower + TownHallLevel * 100 + BuildingsCount * 25; } } void UpdatePowerResource() { var rm = ResourceManager.Instance; if (rm == null) return; int p = TotalPower; if (Mathf.RoundToInt(rm.GetResource(ResourceType.Power)) != p) rm.SetResource(ResourceType.Power, p); } // ===================================================================== // ЗАДАНИЯ // ===================================================================== public int QuestProgress(QuestDef q, bool daily) { switch (q.kind) { case QuestKind.TownHallLevel: return TownHallLevel; case QuestKind.BuildingsBuilt: return BuildingsCount; case QuestKind.Collect: return Mathf.FloorToInt(daily ? Data.dailyCollected[(int)q.resource] : Data.collected[(int)q.resource]); case QuestKind.BattlesWon: return daily ? Data.dailyBattles : Data.battlesWon; case QuestKind.TroopsTrained: return daily ? Data.dailyTroops : Data.troopsTrained; case QuestKind.HeroLevel: return Data.heroLevel; case QuestKind.ArenaFights: return daily ? Data.dailyArena : Data.arenaFights; case QuestKind.PlayerLevel: return Data.level; case QuestKind.Login: return 1; case QuestKind.WorldMarches: return daily ? Data.dailyMarches : Data.marchesSent; case QuestKind.WorldWins: return Data.worldWins; case QuestKind.BarracksBuilt: return Barracks.Count; } return 0; } public bool IsQuestClaimed(QuestDef q, bool daily) { return (daily ? Data.claimedDaily : Data.claimedQuests).Contains(q.id); } public bool IsQuestDone(QuestDef q, bool daily) { return QuestProgress(q, daily) >= q.target; } // Забрать награду задания. Кристаллы, которым нет места, ждут в задании («Забрать кристаллы»); пока ждут // CrystalRewards.MaxWaiting наград, новое задание с кристаллами, которые не помещаются, не выдаётся (регламент 05.11) public void ClaimQuest(QuestDef q, bool daily) { string key = QuestKey(q, daily); if (IsQuestClaimed(q, daily)) { if (crystals.Left(key) <= 0) return; int got = GiveQuestGems(key, 0); // дозабрать остаток int still = crystals.Left(key); UIKit.Toast(got > 0 ? $"Получено: {got} кристаллов" + (still > 0 ? $". Ещё {still} ждут места" : "") : $"Нет места для кристаллов ({GemsHave:0} / {GemsCap:0})"); return; } if (!IsQuestDone(q, daily)) return; int gems = GemsIn(q.rewards); if (!crystals.CanStart(key, gems, GemsHave, GemsCap)) { UIKit.Toast($"Нет места для кристаллов ({GemsHave:0} / {GemsCap:0}), и уже ждут {CrystalRewards.MaxWaiting} награды. " + "Потратьте кристаллы или заберите ждущие -- награда этого задания сохранится"); return; } (daily ? Data.claimedDaily : Data.claimedQuests).Add(q.id); Data.questsDone++; AddEventPoints(20); var rest = WithoutGems(q.rewards); Grant(rest, q.xp, false); int given = gems > 0 ? GiveQuestGems(key, gems) : 0, left = crystals.Left(key); var shown = new List(rest); if (given > 0) shown.Add(new RewardItem(ResourceType.Gems, given)); string text = GameData.RewardsText(shown.ToArray()); if (q.xp > 0) text += (text.Length > 0 ? ", " : "") + q.xp + " опыта"; if (left > 0) text += $". Ещё {left} кристаллов ждут места -- «Забрать» в заданиях"; UIKit.Toast("Получено: " + text); } public int ClaimableQuestsCount { get { int n = 0; foreach (var q in GameData.MainQuests) if (!IsQuestClaimed(q, false) && IsQuestDone(q, false)) n++; foreach (var q in GameData.DailyQuests) if (!IsQuestClaimed(q, true) && IsQuestDone(q, true)) n++; // кристаллы, которые ждали места, -- когда место появилось if (crystals.WaitingCount > 0 && CrystalRewards.Free(GemsHave, GemsCap) > 0) { foreach (var q in GameData.MainQuests) if (IsQuestClaimed(q, false) && QuestGemsWaiting(q, false) > 0) n++; foreach (var q in GameData.DailyQuests) if (IsQuestClaimed(q, true) && QuestGemsWaiting(q, true) > 0) n++; } return n; } } // ===================================================================== // НАГРАДА ЗА ВХОД (7 дней) // ===================================================================== public bool CanClaimLogin { get { return Data.lastLoginRewardDay != Today; } } public void ClaimLogin() { if (!CanClaimLogin) return; int idx = Data.loginRewardIndex % GameData.LoginRewards.Length; Data.lastLoginRewardDay = Today; Data.loginRewardIndex = (idx + 1) % GameData.LoginRewards.Length; Grant(GameData.LoginRewards[idx], 15); } // ===================================================================== // ПОЧТА // ===================================================================== public void SendMail(string title, string body, params RewardItem[] rewards) { var m = new MailItem { id = Data.nextMailId++, title = title, body = body, sentTicks = DateTime.Now.Ticks }; if (rewards != null) m.rewards.AddRange(rewards); Data.mail.Insert(0, m); if (Data.mail.Count > 50) Data.mail.RemoveAt(Data.mail.Count - 1); MarkChanged(); } void SendStarterMail() { Data.starterMailSent = true; SendMail("Как играть", "1. Собирайте ресурсы, нажимая на здания.\n2. Стройте новые здания и улучшайте Ратушу.\n" + "3. Обучайте войска и проходите этапы во вкладке «Бой».\n4. Выполняйте задания и забирайте ежедневные награды.\n" + "5. Сражайтесь на арене и поднимайтесь в рейтинге."); SendMail("Добро пожаловать, командир!", "Королевство ждёт своего правителя. Примите стартовый подарок, чтобы начать строительство.", new RewardItem(ResourceType.Gold, 500), new RewardItem(ResourceType.Wood, 500), new RewardItem(ResourceType.Food, 500), new RewardItem(ResourceType.Gems, 100)); } public int UnreadMailCount { get { int n = 0; foreach (var m in Data.mail) if (!m.read || (m.HasRewards && !m.claimed)) n++; return n; } } public MailItem GetMail(int id) { return Data.mail.Find(m => m.id == id); } public void ReadMail(MailItem m) { if (m != null && !m.read) { m.read = true; MarkChanged(); } } // Забрать подарок письма. Кристаллы -- сколько помещается; остаток остаётся в этом же письме (регламент 05.11). // false -- в письме остались кристаллы, которым нет места public bool ClaimMail(MailItem m, bool toast = true) { if (m == null || m.claimed || !m.HasRewards) return true; m.read = true; int gems = GemsIn(m.rewards), fit = CrystalRewards.Fits(gems, GemsHave, GemsCap), left = gems - fit; var give = new List(WithoutGems(m.rewards)); if (give.Count == 0 && fit == 0) { if (toast) UIKit.Toast($"Нет места для кристаллов ({GemsHave:0} / {GemsCap:0}) -- подарок подождёт в письме"); MarkChanged(); return false; } if (fit > 0) give.Add(new RewardItem(ResourceType.Gems, fit)); m.rewards.Clear(); if (left > 0) m.rewards.Add(new RewardItem(ResourceType.Gems, left)); // остаток ждёт в письме else m.claimed = true; Grant(give.ToArray(), 0, toast); // всё помещается: Grant ничего не отложит if (toast && left > 0) UIKit.Toast($"Ещё {left} кристаллов ждут места -- в этом письме"); return left == 0; } public void DeleteMail(MailItem m) { if (m == null) return; if (m.HasRewards && !m.claimed) { UIKit.Toast("Сначала заберите награду из письма"); return; } Data.mail.Remove(m); MarkChanged(); } public void ClaimAllMail() { int gifts = 0, waiting = 0; foreach (var m in Data.mail.ToArray()) { m.read = true; if (!m.HasRewards || m.claimed) continue; gifts++; if (!ClaimMail(m, false)) waiting++; } if (gifts == 0) UIKit.Toast("Все письма прочитаны"); else UIKit.Toast(waiting == 0 ? "Подарки получены" : $"Подарки получены. В {waiting} письмах кристаллы ждут места"); MarkChanged(); } public void DeleteReadMail() { int removed = Data.mail.RemoveAll(m => m.read && (!m.HasRewards || m.claimed)); UIKit.Toast(removed > 0 ? "Удалено писем: " + removed : "Нечего удалять"); MarkChanged(); } // ===================================================================== // МАГАЗИН // ===================================================================== public int BoughtToday(string id) { int i = Data.shopIds.IndexOf(id); return i >= 0 ? Data.shopCounts[i] : 0; } public bool CanBuy(ShopItemDef item) { if (item.comingSoon) return false; if (item.dailyLimit > 0 && BoughtToday(item.id) >= item.dailyLimit) return false; return true; } public void Buy(ShopItemDef item) { if (item.comingSoon) { UIKit.Toast("Покупки за реальные деньги появятся позже"); return; } if (!CanBuy(item)) { UIKit.Toast("Лимит на сегодня исчерпан"); return; } if (item.price > 0 && !Pay(new[] { new RewardItem(item.priceType, item.price) })) return; int i = Data.shopIds.IndexOf(item.id); if (i < 0) { Data.shopIds.Add(item.id); Data.shopCounts.Add(1); } else Data.shopCounts[i]++; RewardItem[] gives = item.gives; if (item.id == "free_chest") gives = new[] { GameData.FreeChestPool[UnityEngine.Random.Range(0, GameData.FreeChestPool.Length)] }; Grant(gives); } public bool FreeChestAvailable { get { return BoughtToday("free_chest") == 0; } } // ===================================================================== // ГЕРОЙ // ===================================================================== public int HeroMaxLevel { get { return GameData.HeroMaxLevel(TownHallLevel); } } public bool HasHeroSkill(int index) { return Data.heroLevel >= GameData.HeroSkillLevels[index]; } public bool CanUpgradeHero { get { return Data.heroLevel < HeroMaxLevel && CanAfford(GameData.HeroUpgradeCost(Data.heroLevel)); } } public void UpgradeHero() { if (Data.heroLevel >= HeroMaxLevel) { UIKit.Toast("Улучшите Ратушу, чтобы поднять предел уровня героя"); return; } if (!Pay(GameData.HeroUpgradeCost(Data.heroLevel))) return; Data.heroLevel++; AddXp(10); UIKit.Toast("Герой достиг " + Data.heroLevel + " уровня!"); MarkChanged(); } // ===================================================================== // ВОЙСКА // ===================================================================== // Войска хранит модуль Армии (Docs/Modules/Army.md) -- здесь только обращение к нему static IArmy Army => GameServices.Get(); // Люди города -- модуль Населения. null -- модуль не подключён static IPopulation Population => GameServices.TryGet(out IPopulation p) ? p : null; // Сколько воинов можно нанять из жителей прямо сейчас (для кнопок найма) public int Recruitable => Population != null ? Population.Recruitable : int.MaxValue; public int TroopCount(int type) { return Army.GetTroops(type); } public int TotalTroops { get { return Army.SoldiersAtHome; } } // Тип войск открыт, если выполнены оба условия: уровень Ратуши (GameData.Troops) и уровень Казарм // (EconomyConfig, раздел "Солдат", ТЕСТ). Считаются самые прокачанные Казармы. public bool TroopUnlocked(int index) { return TownHallLevel >= GameData.Troops[index].requiredTownHall && Barracks.MaxLevel >= RequiredBarracksLevel(index); } public int RequiredBarracksLevel(int index) { var table = EconomyConfig.Load().soldier.requiredBarracksLevel; return table != null && index >= 0 && index < table.Length ? Mathf.Max(1, table[index]) : 1; } // Чего не хватает, чтобы открыть тип войск: "Ратуша 2 ур., Казармы 3 ур." (пусто -- открыт) public string TroopLockText(int index) { var parts = new List(); int th = GameData.Troops[index].requiredTownHall, bk = RequiredBarracksLevel(index); if (TownHallLevel < th) parts.Add("Ратуша " + th + " ур."); if (Barracks.MaxLevel < bk) parts.Add("Казармы " + bk + " ур."); return string.Join(", ", parts.ToArray()); } // ОЧЕРЕДЬ ОБУЧЕНИЯ ведёт модуль Армии (решения по 8 вопросам Саши, 31.10, п. 8). Здесь -- только то, что знает // слой приложения: есть ли Казармы и открыт ли тип войск, цена (Экономика), длина очереди от числа Казарм, окна. // Время -- часы игры (GameClock.UnixNowMs): перемотка DEV и офлайн досчитывают обучение так же, как марши. public IReadOnlyList Training => Army.Training; public void Train(int index, int count) { var def = GameData.Troops[index]; if (Barracks.Count == 0) { UIKit.Toast("Сначала постройте Казармы"); return; } if (!TroopUnlocked(index)) { UIKit.Toast("Нужно: " + TroopLockText(index)); return; } // Очередь и жители (резерв не отдаётся) -- проверяет Армия вместе с Населением if (!Army.CanQueueTraining(index, count, Barracks.QueueSize, out string error)) { UIKit.Toast(error); return; } if (!Pay(def.cost, count)) return; long durationMs = (long)(def.secondsPerUnit * 1000.0 * count); if (!Army.TryQueueTraining(index, count, durationMs, Barracks.QueueSize, GameClock.UnixNowMs, out error)) { // Заказ не встал -- цену возвращаем целиком var refund = new RewardItem[def.cost.Length]; for (int i = 0; i < def.cost.Length; i++) refund[i] = new RewardItem(def.cost[i].type, def.cost[i].amount * count); Grant(refund, 0, false); UIKit.Toast(error); return; } UIKit.Toast("Обучение начато: " + count + " × " + def.name); MarkChanged(); } public double TrainingSecondsLeft(TrainingOrderInfo o) { return Math.Max(0, o.EndMs - GameClock.UnixNowMs) / 1000.0; } public float TrainingProgress(TrainingOrderInfo o) { long total = o.EndMs - o.StartMs; if (total <= 0) return 1f; return Mathf.Clamp01((float)(GameClock.UnixNowMs - o.StartMs) / total); } public int SpeedUpCost { get { var queue = Training; if (queue.Count == 0) return 0; double secs = TrainingSecondsLeft(queue[queue.Count - 1]); return Mathf.Max(1, Mathf.CeilToInt((float)secs / 30f)); // 1 кристалл за 30 сек } } public void SpeedUpTraining() { if (Training.Count == 0) return; if (!Pay(new[] { new RewardItem(ResourceType.Gems, SpeedUpCost) })) return; Army.FinishAllTraining(GameClock.UnixNowMs); } // Раз в секунду: Армия заканчивает заказы обучения и лечение раненых, срок которых наступил (и пока игра была закрыта) void ProcessTraining() { if (!GameServices.TryGet(out IArmy army)) return; long now = GameClock.UnixNowMs; army.ProcessTraining(now); army.ProcessHealing(now); army.ProcessMissing(now); } // Заказ закончен (событие Армии, один раз на заказ): статистика заданий и всплывашка public void OnTroopsTrained(TroopsTrainedEvent e) { Data.troopsTrained += e.Count; Data.dailyTroops += e.Count; Data.eventTroopRemainder += e.Count; AddEventPoints(Data.eventTroopRemainder / 5); Data.eventTroopRemainder %= 5; if (e.Type >= 0 && e.Type < GameData.Troops.Count) UIKit.Toast("Обучено: " + e.Count + " × " + GameData.Troops[e.Type].name); MarkChanged(); } // Потери в бою: убираем процент от каждого типа войск int LoseTroops(float fraction) { if (HasHeroSkill(3)) fraction *= 0.8f; return Army.LoseFraction(fraction); } // ===================================================================== // БОЙ (кампания) // ===================================================================== public static float WinChance(int myPower, int enemyPower) { if (myPower <= 0) return 0f; float ratio = (float)myPower / Mathf.Max(1, enemyPower); if (ratio >= 2f) return 1f; return Mathf.Clamp(0.5f + (ratio - 1f) * 1.25f, 0.03f, 0.97f); } public class BattleResult { public bool win; public string enemy; public int stage; public RewardItem[] rewards; public int xp; public int lost; } public BattleResult FightStage() { if (TotalTroops == 0) { UIKit.Toast("Нет войск! Обучите их во вкладке «Войска»"); return null; } int stage = Data.battleStage; float chance = WinChance(ArmyPower, GameData.StageEnemyPower(stage)); var res = new BattleResult { stage = stage, enemy = GameData.StageName(stage) }; res.win = UnityEngine.Random.value < chance; if (res.win) { float bonus = HasHeroSkill(2) ? 1.1f : 1f; var list = new List(); foreach (var r in GameData.StageRewards(stage)) list.Add(new RewardItem(r.type, Mathf.RoundToInt(r.amount * bonus))); int gems = GameData.StageGems(stage); if (gems > 0) list.Add(new RewardItem(ResourceType.Gems, gems)); res.rewards = list.ToArray(); res.xp = GameData.StageXp(stage); res.lost = LoseTroops(UnityEngine.Random.Range(0.05f, 0.12f)); Data.battleStage++; Data.battlesWon++; Data.dailyBattles++; AddEventPoints(10); Grant(res.rewards, res.xp, false); } else { res.rewards = new RewardItem[0]; res.lost = LoseTroops(UnityEngine.Random.Range(0.2f, 0.3f)); Data.battlesLost++; AddXp(5); } MarkChanged(); return res; } // ===================================================================== // АРЕНА // ===================================================================== public int ArenaTickets { get { RefreshTickets(); return Data.arenaTickets; } } void RefreshTickets() { if (Data.arenaTickets >= GameData.ArenaMaxTickets) return; long period = TimeSpan.FromMinutes(GameData.ArenaTicketMinutes).Ticks; long elapsed = Now - Data.arenaTicketTicks; if (elapsed < period) return; int gained = (int)(elapsed / period); Data.arenaTickets = Mathf.Min(GameData.ArenaMaxTickets, Data.arenaTickets + gained); Data.arenaTicketTicks += gained * period; MarkChanged(); } public double SecondsToNextTicket { get { if (Data.arenaTickets >= GameData.ArenaMaxTickets) return 0; long period = TimeSpan.FromMinutes(GameData.ArenaTicketMinutes).Ticks; return TimeSpan.FromTicks(Math.Max(0, Data.arenaTicketTicks + period - Now)).TotalSeconds; } } public List ArenaOpponents { get { if (Data.arenaOpponents.Count == 0) GenerateOpponents(); return Data.arenaOpponents; } } public void GenerateOpponents() { Data.arenaOpponents.Clear(); int baseP = Mathf.Max(150, ArmyPower); float[] mult = { 0.75f, 1.0f, 1.3f }; // лёгкий, равный, сильный for (int i = 0; i < 3; i++) { Data.arenaOpponents.Add(new ArenaOpponent { name = GameData.BotNames[UnityEngine.Random.Range(0, GameData.BotNames.Length)], power = Mathf.RoundToInt(baseP * mult[i] * UnityEngine.Random.Range(0.9f, 1.1f)), rating = Mathf.Max(0, Data.arenaRating + (i - 1) * 60 + UnityEngine.Random.Range(-25, 26)), }); } MarkChanged(); } public class ArenaResult { public bool win; public string opponent; public int ratingChange; public RewardItem[] rewards; } public ArenaResult FightArena(int index) { if (TotalTroops == 0) { UIKit.Toast("Нет войск! Обучите их во вкладке «Войска»"); return null; } if (ArenaTickets <= 0) { UIKit.Toast("Нет билетов. Следующий через " + GameData.FormatTime(SecondsToNextTicket)); return null; } var opp = ArenaOpponents[index]; if (Data.arenaTickets == GameData.ArenaMaxTickets) Data.arenaTicketTicks = Now; Data.arenaTickets--; var res = new ArenaResult { opponent = opp.name }; res.win = UnityEngine.Random.value < WinChance(ArmyPower, opp.power); Data.arenaFights++; Data.dailyArena++; AddEventPoints(5); if (res.win) { res.ratingChange = 15 + Mathf.Clamp((opp.rating - Data.arenaRating) / 10, 0, 15) + UnityEngine.Random.Range(0, 6); res.rewards = new[] { new RewardItem(ResourceType.Gold, 150), new RewardItem(ResourceType.Gems, 5) }; Data.arenaWins++; Grant(res.rewards, 15, false); } else { res.ratingChange = -UnityEngine.Random.Range(8, 15); res.rewards = new RewardItem[0]; AddXp(5); } Data.arenaRating = Mathf.Max(0, Data.arenaRating + res.ratingChange); GenerateOpponents(); MarkChanged(); return res; } // Таблица лидеров: стабильные боты (зависят от ID игрока) + сам игрок public List Leaderboard() { var list = new List(); int seed; int.TryParse(Data.playerId, out seed); // ID из цифр -> одинаковая таблица при каждом запуске var rnd = new System.Random(seed); for (int i = 0; i < 15; i++) { list.Add(new ArenaOpponent { name = GameData.BotNames[rnd.Next(GameData.BotNames.Length)] + rnd.Next(10, 99), rating = 2300 - i * 90 - rnd.Next(0, 60), }); } list.Add(new ArenaOpponent { name = Data.playerName + " (вы)", rating = Data.arenaRating, power = -1 }); list.Sort((a, b) => b.rating.CompareTo(a.rating)); return list; } // ===================================================================== // СОБЫТИЕ // ===================================================================== public void AddEventPoints(int points) { if (points <= 0) return; EnsureDailyAndWeekly(); Data.eventPoints += points; MarkChanged(); } public bool EventTierClaimed(int i) { return Data.eventClaimed.Contains(i); } public bool EventTierReady(int i) { return Data.eventPoints >= GameData.EventTiers[i] && !EventTierClaimed(i); } public void ClaimEventTier(int i) { if (!EventTierReady(i)) return; Data.eventClaimed.Add(i); Grant(GameData.EventTierRewards[i], 20); } public bool AnyEventTierReady { get { for (int i = 0; i < GameData.EventTiers.Length; i++) if (EventTierReady(i)) return true; return false; } } // ===================================================================== // НАСТРОЙКИ // ===================================================================== public void ApplySettings() { AudioListener.volume = Data.soundVolume; int count = QualitySettings.names.Length; if (count > 0) { int level = Data.quality == 0 ? 0 : (Data.quality == 1 ? count / 2 : count - 1); QualitySettings.SetQualityLevel(level, true); } Application.targetFrameRate = Data.fps60 ? 60 : 30; } public void Vibrate() { #if UNITY_ANDROID || UNITY_IOS if (Data.vibration) Handheld.Vibrate(); #endif } // ===================================================================== // КРАСНЫЕ ЗНАЧКИ НА КНОПКАХ HUD // ===================================================================== public int BadgeCount(string panelId) { switch (panelId) { case "Quests": return ClaimableQuestsCount; case "Rewards": return CanClaimLogin ? 1 : 0; case "Mail": return UnreadMailCount; case "Events": return AnyEventTierReady ? 1 : 0; case "Shop": return FreeChestAvailable ? 1 : 0; case "Hero": return CanUpgradeHero ? 1 : 0; case "Arena": return ArenaTickets >= GameData.ArenaMaxTickets ? 1 : 0; case "World": return Data.marches.Count; } return 0; } }