Files
MVP1/Assets/Scripts/Meta/Core/GameProgress.cs
T
AlexanderandClaude Opus 5.5 0ebbf56306 Синие кристаллы (регламент 05.11, пп. 1 и 3): не сверх 50 и без потерь, ожидание наград (не больше 2), задания -- 1-2 кристалла
- CrystalRewards (без Unity) + 6 тестов из раздела 6 регламента
- Задания: сколько помещается, остаток ждёт в задании («Забрать»); третья не помещающаяся награда не выдаётся
- Остальные награды: не поместившееся -- письмом «Кристаллы ждут места», из письма -- сколько помещается
- Награды заданий 1-2 вместо 20-250, ежедневные -- без кристаллов

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 18:29:31 +01:00

1099 lines
47 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<GameProgress>();
if (instance == null) instance = new GameObject("GameProgress").AddComponent<GameProgress>();
}
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<ProgressData>(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<string>();
if (Data.claimedDaily == null) Data.claimedDaily = new List<string>();
if (Data.crystalWaiting == null) Data.crystalWaiting = new List<string>();
crystals.Load(Data.crystalWaiting);
if (Data.mail == null) Data.mail = new List<MailItem>();
if (Data.shopIds == null) Data.shopIds = new List<string>();
if (Data.shopCounts == null) Data.shopCounts = new List<int>();
if (Data.training == null) Data.training = new List<TrainingOrder>();
if (Data.arenaOpponents == null) Data.arenaOpponents = new List<ArenaOpponent>();
if (Data.eventClaimed == null) Data.eventClaimed = new List<int>();
ReturnLegacyWorldMarches();
if (Data.sashaMarches == null) Data.sashaMarches = new List<Bogatyri.World.MarchData>();
if (Data.sashaMarchUnits == null) Data.sashaMarchUnits = new List<LegacyMarchUnits>();
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<LegacyWorldMarch>(); 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<string>();
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<RewardItem>(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<RewardItem> 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<RewardItem> rewards)
{
var list = new List<RewardItem>();
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<PlacedBuildingId>(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<RewardItem>(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<RewardItem>(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<IArmy>();
// Люди города -- модуль Населения. 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<string>();
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<TrainingOrderInfo> 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<RewardItem>();
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<ArenaOpponent> 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<ArenaOpponent> Leaderboard()
{
var list = new List<ArenaOpponent>();
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;
}
}