Files
AlexanderandClaude Opus 5.5 f10fb3b3aa Модуль Армии хранит войска; окно «Войско вернулось» в городе
Модуль Армии (Bogatyri.Army, без Unity):
- ArmyService — единственное хранилище войск (4 типа), секция сохранения "army"
- IArmy расширен: войска по типам, обучение (AddTroops), потери (LoseFraction), войска в походах
- событие TroopsChangedEvent — по нему перерисовываются панели HUD
- GameProgress и панели работают с войсками только через IArmy
- перенос из progress.json (troops, sashaMarchUnits) при первой загрузке;
  старые данные удаляются только после записи save.json (флаг armyInModule)
- ArmyBridge удалён: контракты и перенос сохранений проверены тестами
- тесты: поход не создаёт и не теряет солдат, потери по типам, сохранение, перенос, сброс

Окно «ВОЙСКО ВЕРНУЛОСЬ» в городе (UI/MarchReturnPopup):
- куда ходили, победа/поражение, сколько вернулось из скольких, добыча
- показывается и за походы, закончившиеся пока игра была закрыта; несколько — по очереди
- в MarchReturnedEvent добавлены Sent, TargetName, LootText

GameLoadedEvent публикуется и без файла сохранения. Документы Army, Core, Save, World, README обновлены.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 18:33:04 +02:00

948 lines
37 KiB
C#
Raw Permalink 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.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;
}
public void Grant(RewardItem[] rewards, int xp = 0, bool toast = true)
{
if (ResourceManager.Instance != null && rewards != null)
{
suppressTracking++;
foreach (var r in rewards) ResourceManager.Instance.AddResource(r.type, r.amount);
suppressTracking--;
}
if (xp > 0) AddXp(xp);
if (toast)
{
string text = GameData.RewardsText(rewards);
if (xp > 0) text += (text.Length > 0 ? ", " : "") + xp + " опыта";
if (text.Length > 0) UIKit.Toast("Получено: " + text);
}
MarkChanged();
}
// =====================================================================
// ОТСЛЕЖИВАНИЕ СБОРА РЕСУРСОВ (для заданий и статистики)
// =====================================================================
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; }
public void ClaimQuest(QuestDef q, bool daily)
{
if (IsQuestClaimed(q, daily) || !IsQuestDone(q, daily)) return;
(daily ? Data.claimedDaily : Data.claimedQuests).Add(q.id);
Data.questsDone++;
AddEventPoints(20);
Grant(q.rewards, q.xp);
}
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++;
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(); } }
public void ClaimMail(MailItem m, bool toast = true)
{
if (m == null || m.claimed || !m.HasRewards) return;
m.claimed = true; m.read = true;
Grant(m.rewards.ToArray(), 0, toast);
}
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()
{
var all = new List<RewardItem>();
foreach (var m in Data.mail)
{
m.read = true;
if (m.HasRewards && !m.claimed) { m.claimed = true; all.AddRange(m.rewards); }
}
if (all.Count > 0) Grant(all.ToArray()); else UIKit.Toast("Все письма прочитаны");
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>();
public int TroopCount(int type) { return Army.GetTroops(type); }
public int TotalTroops
{
get { return Army.SoldiersAtHome; }
}
public bool TroopUnlocked(int index) { return TownHallLevel >= GameData.Troops[index].requiredTownHall; }
public void Train(int index, int count)
{
var def = GameData.Troops[index];
if (!TroopUnlocked(index)) { UIKit.Toast("Нужна Ратуша " + def.requiredTownHall + " уровня"); return; }
if (Barracks.Count == 0) { UIKit.Toast("Сначала постройте Казармы"); return; }
if (Data.training.Count >= Barracks.QueueSize) { UIKit.Toast("Очередь обучения заполнена. Больше казарм -- длиннее очередь"); return; }
if (!Pay(def.cost, count)) return;
long start = Data.training.Count > 0 ? Data.training[Data.training.Count - 1].endTicks : Now;
if (start < Now) start = Now;
long end = start + TimeSpan.FromSeconds(def.secondsPerUnit * count).Ticks;
Data.training.Add(new TrainingOrder { troopIndex = index, count = count, startTicks = start, endTicks = end });
UIKit.Toast("Обучение начато: " + count + " × " + def.name);
MarkChanged();
}
public double TrainingSecondsLeft(TrainingOrder o) { return TimeSpan.FromTicks(Math.Max(0, o.endTicks - Now)).TotalSeconds; }
public float TrainingProgress(TrainingOrder o)
{
long total = o.endTicks - o.startTicks;
if (total <= 0) return 1f;
return Mathf.Clamp01((float)(Now - o.startTicks) / total);
}
public int SpeedUpCost
{
get
{
if (Data.training.Count == 0) return 0;
double secs = TrainingSecondsLeft(Data.training[Data.training.Count - 1]);
return Mathf.Max(1, Mathf.CeilToInt((float)secs / 30f)); // 1 кристалл за 30 сек
}
}
public void SpeedUpTraining()
{
if (Data.training.Count == 0) return;
if (!Pay(new[] { new RewardItem(ResourceType.Gems, SpeedUpCost) })) return;
foreach (var o in Data.training) o.endTicks = Now;
ProcessTraining();
}
void ProcessTraining()
{
bool any = false;
while (Data.training.Count > 0 && Data.training[0].endTicks <= Now)
{
var o = Data.training[0];
Data.training.RemoveAt(0);
Army.AddTroops(o.troopIndex, o.count);
Data.troopsTrained += o.count;
Data.dailyTroops += o.count;
Data.eventTroopRemainder += o.count;
AddEventPoints(Data.eventTroopRemainder / 5);
Data.eventTroopRemainder %= 5;
UIKit.Toast("Обучено: " + o.count + " × " + GameData.Troops[o.troopIndex].name);
any = true;
}
if (any) 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;
}
}