2026-10-12 23:54:03 +03:00
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 ;
2026-10-23 18:33:04 +02:00
public static bool HasInstance { get { return instance != null ; } }
2026-10-12 23:54:03 +03:00
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 >();
2026-11-05 18:29:31 +01:00
if ( Data . crystalWaiting == null ) Data . crystalWaiting = new List < string >();
crystals . Load ( Data . crystalWaiting );
2026-11-06 16:24:16 +01:00
if ( Data . crystalQuota == null ) Data . crystalQuota = new List < string >();
var storageCfg = EconomyConfig . Load (). storage ;
quota = new CrystalQuota ( storageCfg . crystalPveLimit , Math . Max ( 1 , storageCfg . crystalPveWindowDays ) * CrystalQuota . Day );
quota . Load ( Data . crystalQuota );
2026-11-06 16:27:27 +01:00
if ( Data . crystalTrader == null ) Data . crystalTrader = new List < string >();
if ( Data . crystalTraderNextId < 1 ) Data . crystalTraderNextId = 1 ;
trader = new CrystalTrader ( storageCfg . crystalTraderPrices , Math . Max ( 1 , storageCfg . crystalPveWindowDays ) * CrystalQuota . Day );
trader . Load ( Data . crystalTrader );
2026-10-12 23:54:03 +03:00
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 >();
2026-10-19 19:27:34 +02:00
ReturnLegacyWorldMarches ();
2026-10-12 23:54:03 +03:00
if ( Data . sashaMarches == null ) Data . sashaMarches = new List < Bogatyri . World . MarchData >();
2026-10-23 18:33:04 +02:00
if ( Data . sashaMarchUnits == null ) Data . sashaMarchUnits = new List < LegacyMarchUnits >();
2026-10-12 23:54:03 +03:00
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 ); }
}
2026-10-19 19:27:34 +02:00
// Старая маленькая карта мира удалена: солдаты из её походов возвращаются в город.
// Ещё идут к цели -- возвращаются все отправленные; уже идут обратно -- выжившие. Добыча таких походов не переносится.
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}." );
}
2026-10-19 18:39:28 +02:00
// СБРОС СОХРАНЕНИЙ: прогресс (progress.json) + все модули (save.json): ресурсы, здания, Ратуша, походы.
// Кнопка в Настройках и меню Tools -> Сбросить сохранения. Документ: Docs/Modules/Save.md
2026-10-12 23:54:03 +03:00
public void ResetAll ()
{
if ( File . Exists ( SavePath )) File . Delete ( SavePath );
2026-10-19 18:39:28 +02:00
// Модули сбрасывают себя сами через модуль Сохранений; возврат ресурсов к старту -- не "сбор"
suppressTracking ++;
if ( GameServices . TryGet ( out ISaveSystem save ))
2026-10-12 23:54:03 +03:00
{
2026-10-19 18:39:28 +02:00
save . ResetAll ();
2026-10-12 23:54:03 +03:00
}
2026-10-19 18:39:28 +02:00
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 --;
2026-10-12 23:54:03 +03:00
Data = new ProgressData ();
FixData ();
2026-10-23 18:33:04 +02:00
Data . armyInModule = true ; // войска сбросил модуль Армии, переносить из progress.json нечего
2026-10-12 23:54:03 +03:00
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 ;
}
2026-11-06 16:24:16 +01:00
// Выдать награду. Синие кристаллы (регламент валют 05.11, ответ 06.11):
// - новые PvE-кристаллы (newPveCrystals) -- в пределах недельной квоты; сверх неё -- золото (компенсация);
// - не сверх вместимости хранилища: не поместившиеся приходят письмом «Кристаллы ждут места» и забираются из него,
// когда место появится (квота за них уже учтена). Ничего не теряется.
// Возвращает, что выдано сейчас (кристаллы -- сколько поместилось, плюс компенсация)
public RewardItem [] Grant ( RewardItem [] rewards , int xp = 0 , bool toast = true , bool newPveCrystals = true )
2026-10-12 23:54:03 +03:00
{
2026-11-06 16:24:16 +01:00
int gems = GemsIn ( rewards ), compensated = 0 , compGold = 0 ;
if ( gems > 0 && newPveCrystals )
{
int allowed = TakeQuota ( null , gems , out compGold );
compensated = gems - allowed ;
gems = allowed ;
}
int fit = CrystalRewards . Fits ( gems , GemsHave , GemsCap ), left = gems - fit ;
2026-11-05 18:29:31 +01:00
var given = new List < RewardItem >( WithoutGems ( rewards ));
if ( fit > 0 ) given . Add ( new RewardItem ( ResourceType . Gems , fit ));
2026-11-06 16:24:16 +01:00
if ( compGold > 0 ) given . Add ( new RewardItem ( ResourceType . Gold , compGold ));
2026-11-05 18:29:31 +01:00
if ( ResourceManager . Instance != null )
2026-10-12 23:54:03 +03:00
{
suppressTracking ++;
2026-11-05 18:29:31 +01:00
foreach ( var r in given ) ResourceManager . Instance . AddResource ( r . type , r . amount );
2026-10-12 23:54:03 +03:00
suppressTracking --;
}
2026-11-05 18:29:31 +01:00
if ( left > 0 )
SendMail ( "Кристаллы ждут места" ,
$"Хранилище синих кристаллов заполнено ({GemsHave:0} / {GemsCap:0}). {left} кристаллов ждут: потратьте кристаллы и заберите их из этого письма." ,
new RewardItem ( ResourceType . Gems , left ));
2026-10-12 23:54:03 +03:00
if ( xp > 0 ) AddXp ( xp );
if ( toast )
{
2026-11-05 18:29:31 +01:00
string text = GameData . RewardsText ( given . ToArray ());
2026-10-12 23:54:03 +03:00
if ( xp > 0 ) text += ( text . Length > 0 ? ", " : "" ) + xp + " опыта" ;
2026-11-05 18:29:31 +01:00
if ( left > 0 ) text += ( text . Length > 0 ? ". " : "" ) + $"Ещё {left} кристаллов ждут места -- в почте" ;
2026-11-06 16:24:16 +01:00
if ( compensated > 0 ) text += ( text . Length > 0 ? ". " : "" ) + QuotaText ( compensated , compGold );
2026-10-12 23:54:03 +03:00
if ( text . Length > 0 ) UIKit . Toast ( "Получено: " + text );
}
MarkChanged ();
2026-11-06 16:24:16 +01:00
return given . ToArray ();
2026-10-12 23:54:03 +03:00
}
2026-11-06 16:24:16 +01:00
// ---------------- Недельный PvE-предел синих кристаллов (правила -- CrystalQuota) ----------------
CrystalQuota quota = new CrystalQuota ();
public int CrystalQuotaUsed => quota . Used ( GameClock . UnixNowMs );
public int CrystalQuotaLimit => quota . Limit ;
public int CrystalQuotaLeft => quota . Left ( GameClock . UnixNowMs );
// Через сколько секунд освободится квота (0 -- уже есть)
public double CrystalQuotaSecondsToFree
{
get { long at = quota . NextFreeMs ( GameClock . UnixNowMs ); return at <= 0 ? 0 : Math . Max ( 0 , at - GameClock . UnixNowMs ) / 1000.0 ; }
}
// Взять квоту: сколько из amount выдать кристаллами; за остальные -- золото (compGold). id -- операция (null -- разовая)
int TakeQuota ( string id , int amount , out int compGold )
{
int allowed = quota . Take ( id , amount , GameClock . UnixNowMs );
compGold = Mathf . RoundToInt (( amount - allowed ) * EconomyConfig . Load (). storage . crystalCompensationGold );
Data . crystalQuota = quota . ToLines ();
MarkChanged ();
return allowed ;
}
2026-11-06 16:27:27 +01:00
// ---------------- Редкий PvE-торговец (правила -- CrystalTrader) ----------------
CrystalTrader trader = new CrystalTrader ();
public int TraderPrice => trader . PriceNow ( GameClock . UnixNowMs ); // -1 -- в этом окне больше нельзя
public int TraderBought => trader . BoughtInWindow ( GameClock . UnixNowMs );
public int TraderMaxPurchases => trader . Prices . Length ;
public double TraderSecondsToSlot
{
get { long at = trader . NextSlotMs ( GameClock . UnixNowMs ); return at <= 0 ? 0 : Math . Max ( 0 , at - GameClock . UnixNowMs ) / 1000.0 ; }
}
// Почему сейчас нельзя купить (без списания); null -- можно, если хватит золота
public string TraderBlockReason ()
{
switch ( trader . Check ( GameClock . UnixNowMs , quota , CrystalRewards . Free ( GemsHave , GemsCap )))
{
case CrystalTrader . Result . NoSlots : return $"Куплено {TraderBought} из {TraderMaxPurchases} за 7 дней. Следующая покупка -- через {GameData.FormatTime(TraderSecondsToSlot)}" ;
case CrystalTrader . Result . NoQuota : return $"Недельный предел новых кристаллов исчерпан ({quota.Limit} за 7 дней). Освободится через {GameData.FormatTime(CrystalQuotaSecondsToFree)}" ;
case CrystalTrader . Result . NoSpace : return $"Хранилище кристаллов заполнено ({GemsHave:0} / {GemsCap:0}). Расширить -- в окне Ратуши" ;
default : return null ;
}
}
// Купить 1 синий кристалл. Проверки (покупки, квота, место) -- до оплаты: при отказе золото не списывается
public void BuyCrystalFromTrader ()
{
string id = "trader_" + Data . crystalTraderNextId ;
var result = trader . TryBuy ( id , GameClock . UnixNowMs , quota , CrystalRewards . Free ( GemsHave , GemsCap ),
price => GameServices . TryGet ( out IEconomy e ) && e . TrySpend ( ResourceType . Gold , price ));
if ( result != CrystalTrader . Result . Bought )
{
UIKit . Toast ( result == CrystalTrader . Result . NoGold ? $"Нужно {TraderPrice:N0} золота" : ( TraderBlockReason () ?? "Покупка недоступна" ));
return ;
}
Data . crystalTraderNextId ++;
if ( ResourceManager . Instance != null )
{
suppressTracking ++;
ResourceManager . Instance . AddResource ( ResourceType . Gems , 1 ); // место проверено до оплаты
suppressTracking --;
}
Data . crystalTrader = trader . ToLines ();
Data . crystalQuota = quota . ToLines ();
MarkChanged ();
if ( SaveManager . Instance != null ) SaveManager . Instance . SaveGame (); // золото, кристалл и покупка -- сразу на диск
UIKit . Toast ( "Куплен 1 синий кристалл" );
}
2026-11-06 16:24:16 +01:00
// Только для проверки (DEV): забыть недельную квоту
public void DevResetCrystalQuota ()
{
quota . Load ( null );
2026-11-06 16:27:27 +01:00
trader . Load ( null );
2026-11-06 16:24:16 +01:00
Data . crystalQuota = quota . ToLines ();
2026-11-06 16:27:27 +01:00
Data . crystalTrader = trader . ToLines ();
2026-11-06 16:24:16 +01:00
MarkChanged ();
}
string QuotaText ( int compensated , int compGold ) =>
$"Недельный предел синих кристаллов ({quota.Limit} за {quota.WindowMs / CrystalQuota.Day} дн.) исчерпан: вместо {compensated} кристаллов -- {compGold} золота" ;
2026-11-05 18:29:31 +01:00
// ---------------- Синие кристаллы при пределе 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 ;
}
2026-10-12 23:54:03 +03:00
// =====================================================================
// ОТСЛЕЖИВАНИЕ СБОРА РЕСУРСОВ (для заданий и статистики)
// =====================================================================
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 ++;
2026-11-06 16:00:31 +01:00
// Подарок за уровень -- золото (было ещё 10 × уровень синих кристаллов; регламент валют 05.11, разд. 5.1)
2026-10-12 23:54:03 +03:00
SendMail ( "Новый уровень: " + Data . level + "!" ,
"Поздравляем, командир! Ваша слава растёт. Примите подарок от совета королевства." ,
2026-11-06 16:00:31 +01:00
new RewardItem ( ResourceType . Gold , 300 * Data . level ));
2026-10-12 23:54:03 +03:00
UIKit . Toast ( "Новый уровень: " + Data . level + "! Подарок в почте." );
Vibrate ();
}
MarkChanged ();
}
// =====================================================================
// СИЛА
// =====================================================================
public int TroopsPower
{
get
{
int p = 0 ;
2026-10-23 18:33:04 +02:00
for ( int i = 0 ; i < GameData . Troops . Count ; i ++) p += TroopCount ( i ) * GameData . Troops [ i ]. power ;
2026-10-12 23:54:03 +03:00
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 ; }
2026-11-05 18:29:31 +01:00
// Забрать награду задания. Кристаллы, которым нет места, ждут в задании («Забрать кристаллы»); пока ждут
// CrystalRewards.MaxWaiting наград, новое задание с кристаллами, которые не помещаются, не выдаётся (регламент 05.11)
2026-10-12 23:54:03 +03:00
public void ClaimQuest ( QuestDef q , bool daily )
{
2026-11-05 18:29:31 +01:00
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 );
2026-11-06 16:24:16 +01:00
if (! crystals . CanStart ( key , Mathf . Min ( gems , quota . Left ( GameClock . UnixNowMs )), GemsHave , GemsCap ))
2026-11-05 18:29:31 +01:00
{
UIKit . Toast ( $"Нет места для кристаллов ({GemsHave:0} / {GemsCap:0}), и уже ждут {CrystalRewards.MaxWaiting} награды. " +
"Потратьте кристаллы или заберите ждущие -- награда этого задания сохранится" );
return ;
}
2026-10-12 23:54:03 +03:00
( daily ? Data . claimedDaily : Data . claimedQuests ). Add ( q . id );
Data . questsDone ++;
AddEventPoints ( 20 );
2026-11-05 18:29:31 +01:00
2026-11-06 16:24:16 +01:00
// Квота -- один раз на задание (id); сверх неё -- золото. Ждущие места кристаллы квоту второй раз не берут
int compGold = 0 , allowed = gems > 0 ? TakeQuota ( key , gems , out compGold ) : 0 ;
var rest = new List < RewardItem >( WithoutGems ( q . rewards ));
if ( compGold > 0 ) rest . Add ( new RewardItem ( ResourceType . Gold , compGold ));
Grant ( rest . ToArray (), q . xp , false , false );
int given = allowed > 0 ? GiveQuestGems ( key , allowed ) : 0 , left = crystals . Left ( key );
2026-11-05 18:29:31 +01:00
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} кристаллов ждут места -- «Забрать» в заданиях" ;
2026-11-06 16:24:16 +01:00
if ( gems - allowed > 0 ) text += ". " + QuotaText ( gems - allowed , compGold );
2026-11-05 18:29:31 +01:00
UIKit . Toast ( "Получено: " + text );
2026-10-12 23:54:03 +03:00
}
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 ++;
2026-11-05 18:29:31 +01:00
// кристаллы, которые ждали места, -- когда место появилось
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 ++;
}
2026-10-12 23:54:03 +03:00
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 ),
2026-11-06 16:00:31 +01:00
new RewardItem ( ResourceType . Food , 500 )); // без 100 синих кристаллов (регламент валют 05.11)
2026-10-12 23:54:03 +03:00
}
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 (); } }
2026-11-05 18:29:31 +01:00
// Забрать подарок письма. Кристаллы -- сколько помещается; остаток остаётся в этом же письме (регламент 05.11).
// false -- в письме остались кристаллы, которым нет места
public bool ClaimMail ( MailItem m , bool toast = true )
2026-10-12 23:54:03 +03:00
{
2026-11-05 18:29:31 +01:00
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 ;
2026-11-06 16:24:16 +01:00
Grant ( give . ToArray (), 0 , toast , false ); // всё помещается; квота за них уже учтена при первой выдаче
2026-11-05 18:29:31 +01:00
if ( toast && left > 0 ) UIKit . Toast ( $"Ещё {left} кристаллов ждут места -- в этом письме" );
return left == 0 ;
2026-10-12 23:54:03 +03:00
}
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 ()
{
2026-11-05 18:29:31 +01:00
int gifts = 0 , waiting = 0 ;
foreach ( var m in Data . mail . ToArray ())
2026-10-12 23:54:03 +03:00
{
m . read = true ;
2026-11-05 18:29:31 +01:00
if (! m . HasRewards || m . claimed ) continue ;
gifts ++;
if (! ClaimMail ( m , false )) waiting ++;
2026-10-12 23:54:03 +03:00
}
2026-11-05 18:29:31 +01:00
if ( gifts == 0 ) UIKit . Toast ( "Все письма прочитаны" );
else UIKit . Toast ( waiting == 0 ? "Подарки получены" : $"Подарки получены. В {waiting} письмах кристаллы ждут места" );
2026-10-12 23:54:03 +03:00
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 ();
}
// =====================================================================
// ВОЙСКА
// =====================================================================
2026-10-23 18:33:04 +02:00
// Войска хранит модуль Армии (Docs/Modules/Army.md) -- здесь только обращение к нему
static IArmy Army => GameServices . Get < IArmy >();
2026-10-31 19:34:33 +03:00
// Люди города -- модуль Населения. null -- модуль не подключён
2026-10-28 22:11:36 +03:00
static IPopulation Population => GameServices . TryGet ( out IPopulation p ) ? p : null ;
// Сколько воинов можно нанять из жителей прямо сейчас (для кнопок найма)
public int Recruitable => Population != null ? Population . Recruitable : int . MaxValue ;
2026-10-23 18:33:04 +02:00
public int TroopCount ( int type ) { return Army . GetTroops ( type ); }
2026-10-12 23:54:03 +03:00
public int TotalTroops
{
2026-10-23 18:33:04 +02:00
get { return Army . SoldiersAtHome ; }
2026-10-12 23:54:03 +03:00
}
2026-10-31 20:52:28 +03:00
// Тип войск открыт, если выполнены оба условия: уровень Ратуши (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 ());
}
2026-10-12 23:54:03 +03:00
2026-11-01 13:19:34 +01:00
// ОЧЕРЕДЬ ОБУЧЕНИЯ ведёт модуль Армии (решения по 8 вопросам Саши, 31.10, п. 8). Здесь -- только то, что знает
// слой приложения: есть ли Казармы и открыт ли тип войск, цена (Экономика), длина очереди от числа Казарм, окна.
// Время -- часы игры (GameClock.UnixNowMs): перемотка DEV и офлайн досчитывают обучение так же, как марши.
public IReadOnlyList < TrainingOrderInfo > Training => Army . Training ;
2026-10-12 23:54:03 +03:00
public void Train ( int index , int count )
{
var def = GameData . Troops [ index ];
if ( Barracks . Count == 0 ) { UIKit . Toast ( "Сначала постройте Казармы" ); return ; }
2026-10-31 20:52:28 +03:00
if (! TroopUnlocked ( index )) { UIKit . Toast ( "Нужно: " + TroopLockText ( index )); return ; }
2026-11-01 13:19:34 +01:00
// Очередь и жители (резерв не отдаётся) -- проверяет Армия вместе с Населением
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 ))
2026-10-28 22:11:36 +03:00
{
2026-11-01 13:19:34 +01:00
// Заказ не встал -- цену возвращаем целиком
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 );
2026-11-06 16:24:16 +01:00
Grant ( refund , 0 , false , false ); // возврат -- не новые кристаллы
2026-11-01 13:19:34 +01:00
UIKit . Toast ( error );
2026-10-28 22:11:36 +03:00
return ;
}
2026-10-12 23:54:03 +03:00
UIKit . Toast ( "Обучение начато: " + count + " × " + def . name );
MarkChanged ();
}
2026-11-01 13:19:34 +01:00
public double TrainingSecondsLeft ( TrainingOrderInfo o ) { return Math . Max ( 0 , o . EndMs - GameClock . UnixNowMs ) / 1000.0 ; }
2026-10-12 23:54:03 +03:00
2026-11-01 13:19:34 +01:00
public float TrainingProgress ( TrainingOrderInfo o )
2026-10-12 23:54:03 +03:00
{
2026-11-01 13:19:34 +01:00
long total = o . EndMs - o . StartMs ;
2026-10-12 23:54:03 +03:00
if ( total <= 0 ) return 1f ;
2026-11-01 13:19:34 +01:00
return Mathf . Clamp01 (( float )( GameClock . UnixNowMs - o . StartMs ) / total );
2026-10-12 23:54:03 +03:00
}
public int SpeedUpCost
{
get
{
2026-11-01 13:19:34 +01:00
var queue = Training ;
if ( queue . Count == 0 ) return 0 ;
double secs = TrainingSecondsLeft ( queue [ queue . Count - 1 ]);
2026-11-06 16:18:28 +01:00
return EconomyConfig . SpeedUpGold ( secs ); // за золото (06.11, ТЕСТ; было 1 кристалл за 30 с)
2026-10-12 23:54:03 +03:00
}
}
public void SpeedUpTraining ()
{
2026-11-01 13:19:34 +01:00
if ( Training . Count == 0 ) return ;
2026-11-06 16:18:28 +01:00
if (! Pay ( new [] { new RewardItem ( ResourceType . Gold , SpeedUpCost ) })) return ;
2026-11-01 13:19:34 +01:00
Army . FinishAllTraining ( GameClock . UnixNowMs );
2026-10-12 23:54:03 +03:00
}
2026-11-01 13:32:25 +01:00
// Раз в секунду: Армия заканчивает заказы обучения и лечение раненых, срок которых наступил (и пока игра была закрыта)
2026-10-12 23:54:03 +03:00
void ProcessTraining ()
{
2026-11-01 13:32:25 +01:00
if (! GameServices . TryGet ( out IArmy army )) return ;
long now = GameClock . UnixNowMs ;
army . ProcessTraining ( now );
army . ProcessHealing ( now );
2026-11-04 15:16:43 +01:00
army . ProcessMissing ( now );
2026-11-01 13:19:34 +01:00
}
// Заказ закончен (событие Армии, один раз на заказ): статистика заданий и всплывашка
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 ();
2026-10-12 23:54:03 +03:00
}
// Потери в бою: убираем процент от каждого типа войск
int LoseTroops ( float fraction )
{
if ( HasHeroSkill ( 3 )) fraction *= 0.8f ;
2026-10-23 18:33:04 +02:00
return Army . LoseFraction ( fraction );
2026-10-12 23:54:03 +03:00
}
// =====================================================================
// БОЙ (кампания)
// =====================================================================
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 );
2026-11-06 16:24:16 +01:00
res . rewards = Grant ( res . rewards , res . xp , false ); // в окне итога -- что выдано на самом деле (квота, место)
2026-10-12 23:54:03 +03:00
}
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 );
2026-11-06 16:00:31 +01:00
res . rewards = new [] { new RewardItem ( ResourceType . Gold , 200 ) }; // без 5 синих кристаллов (регламент валют 05.11)
2026-10-12 23:54:03 +03:00
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 ;
}
}