Author SHA1 Message Date
AlexanderandClaude Opus 5.5 a72a8941ff 3D: местность за краем участка -- холмы, река и горы продолжаются без шва и плавно уходят в равнину; край больше не обрезан
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 16:57:29 +01:00
AlexanderandClaude Opus 5.5 af7150a006 Разведка М2: разведка движущейся армии -- перехват, сопровождение, отчёт II/III (1/2 игровых часа), возвращение
- Мир: поход на армию (targetMarchId), точка перехвата по маршруту армии и скорости отряда, наблюдение рядом с ней, домой от места конца наблюдения; не догнать -- поход не начинается
- Разведка: ReconTargetKind.Army, ReconArmyGameHours {1, 2}; отчёт -- точный состав армии
- Карта: тап по чужой армии -- окно «ЧУЖАЯ АРМИЯ» с планом и кнопками «Разведка II / III»
- 4 теста

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 16:55:59 +01:00
AlexanderandClaude Opus 5.5 538dbea222 3D: герой у Терема -- гуляет по свободным клеткам рядом с Теремом, тап -- фраза и окно героя (перенос из 2D)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 16:42:03 +01:00
Alexander 6d36c14313 Merge pull request 'Feature/blue crystals' (#19) from feature/blue-crystals into develop
Reviewed-on: #19
2026-11-06 18:38:54 +03:00
AlexanderandClaude Opus 5.5 157d3ba510 Кристальная шахта: 3 синих кристалла за 7 реальных суток на шахту, до двух, пауза при полном хранилище (ответ 06.11, п. 3)
- CrystalMine (без Unity) + 5 тестов: ровно 3 за 7 суток, ×12 не действует, 49/50 -> 1, пауза, перезагрузка и часы назад без двойного начисления, офлайн-стройка
- Работает с конца стройки, офлайн досчитывается; вне недельного PvE-предела
- Вторая шахта -- Ратуша 9 (ТЕСТ); окно здания: следующий кристалл / пауза
- DEV «Ресурсы 100 000» -- обычные ресурсы мимо лимита склада

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 16:31:47 +01:00
AlexanderandClaude Opus 5.5 b51ad41925 Редкий PvE-торговец: синий кристалл за 30 000 / 60 000 золота, не больше 2 за 7 суток (регламент валют, разд. 3)
- CrystalTrader (без Unity) + 5 тестов из раздела 12
- Входит в недельный PvE-предел; нет квоты, места или покупок -- блок, золото не списывается
- Магазин: карточка «Редкий торговец»; DEV «Золото 100 000» для проверки

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 16:27:27 +01:00
AlexanderandClaude Opus 5.5 277afdf84e Недельный PvE-предел синих кристаллов: 6 за 7 реальных суток, сверх -- золото (регламент валют 05.11, ответ 06.11)
- CrystalQuota (без Unity) + 6 тестов из раздела 12
- Задания, события, бои: в пределах квоты -- кристаллы, сверх -- 1000 золота за кристалл (ТЕСТ)
- Ждущие места кристаллы квоту второй раз не тратят; письмо «ждут места» -- вне квоты
- Окно Ратуши: «Новых кристаллов за 7 дней: N / 6»; DEV: «+1 PvE-кристалл», «Сбросить квоту»

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 16:24:16 +01:00
AlexanderandClaude Opus 5.5 e05cf87173 3D: при открытии зоны камера показывает только край новой зоны (старая зона + 2 клетки), а не всю -- не улетает далеко
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 16:20:57 +01:00
AlexanderandClaude Opus 5.5 1ce4082b4c Ускорения за золото: стройка, улучшение и обучение войск -- 1 золото за 10 с (ТЕСТ, secondsPerGold), было 1 кристалл за 30 с
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 16:18:28 +01:00
AlexanderandClaude Opus 5.5 e1228e0b3e Награды за вход: кнопка «Забрать подарок» сверху -- в горизонтальном экране она уходила под нижнюю панель HUD
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 16:17:38 +01:00
AlexanderandClaude Opus 5.5 a2d85f56c3 Хранилище синих кристаллов: платное расширение 50 -> 75/100/150/200 на Ратуше 3/5/7/9 (регламент валют, разд. 8)
- CrystalStorage (без Unity) + 5 тестов из раздела 12
- Ступени и цены -- EconomyConfig.crystalExpansions (ТЕСТ), купленная ступень -- в секции economy
- Окно Ратуши: «Хранилище синих кристаллов», «Расширить до N»; цена списывается один раз

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 16:13:43 +01:00
Alexander 98cbe85481 Merge pull request 'Feature/3d hud' (#18) from feature/3d-hud into develop
Reviewed-on: #18
2026-11-06 18:09:32 +03:00
41 changed files with 1862 additions and 71 deletions
+71 -8
View File
@@ -204,10 +204,23 @@ public static class BuildingWindow
// ---------------- Кристалл ----------------
// Кристальная шахта (ответ 06.11, п. 3): 3 синих кристалла за 7 реальных суток, при полном хранилище -- пауза
static void CrystalSection(RectTransform card)
{
UIKit.Text(card, "Синий кристалл добывается в кристальных разломах на карте мира. Здание не улучшается.", 24, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
var gp = GameProgress.Instance;
if (gp != null)
UIKit.Text(card, $"Кристальная шахта: {gp.CrystalMinePerPeriod} синих кристалла за {gp.CrystalMinePeriodDays} реальных суток, " +
"и пока игра закрыта. Не входит в недельный предел. Здание не улучшается.", 24, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
if (GameServices.TryGet(out IEconomy economy)) StorageBar(card, economy, ResourceType.Gems);
if (gp == null) return;
Live(card, () =>
{
int working = gp.CrystalMinesWorking;
if (working == 0) return "Шахта ещё не работает";
if (CrystalRewards.Free(GameProgress.GemsHave, GameProgress.GemsCap) <= 0)
return "Пауза: хранилище кристаллов заполнено. Освободите место или расширьте его в окне Ратуши";
return $"Работает шахт: {working}. Следующий кристалл через {GameData.FormatTime(gp.CrystalMineSecondsToNext)}";
}, 24, UIKit.Theme.Text);
}
// ---------------- Склад и Амбар ----------------
@@ -282,6 +295,8 @@ public static class BuildingWindow
Live(card, () => $"Резерв для найма: {population.CivilianReserve} • можно нанять: {population.Recruitable}", 22, UIKit.Theme.TextDim);
}
CrystalStorageBlock(card, level);
if (level >= townHall.maxLevel)
{
UIKit.Text(card, "Максимальный уровень", 26, UIKit.Theme.Gold, TextAlignmentOptions.Center);
@@ -323,6 +338,54 @@ public static class BuildingWindow
LiveAfford(b, canPay, () => townHall != null && townHall.CanUpgrade());
}
// Хранилище синих кристаллов (регламент валют 05.11, разд. 8; ответ 06.11, п. 1): вместимость и платное
// расширение. Ратуша только разрешает ступень -- игрок покупает её здесь за золото и обычные ресурсы
static void CrystalStorageBlock(RectTransform card, int townHallLevel)
{
var storage = EconomyConfig.CrystalStorageNow();
Header(card, "Хранилище синих кристаллов");
Live(card, () =>
{
float have = GameServices.TryGet(out IEconomy e) ? e.Get(ResourceType.Gems) : 0f;
return $"Кристаллов {have:0} / {EconomyConfig.CrystalStorageNow().Capacity}";
}, 24, UIKit.Theme.Text);
// Недельный PvE-предел (регламент валют 05.11, ответ 06.11): сколько новых кристаллов из заданий, событий и боёв
Live(card, () =>
{
var gp = GameProgress.Instance;
if (gp == null) return "";
string text = $"Новых кристаллов из заданий, событий и боёв за 7 дней: {gp.CrystalQuotaUsed} / {gp.CrystalQuotaLimit}";
if (gp.CrystalQuotaLeft <= 0) text += $". Квота освободится через {GameData.FormatTime(gp.CrystalQuotaSecondsToFree)}, до тех пор -- золото";
return text;
}, 22, UIKit.Theme.TextDim);
var next = storage.Next;
string why = storage.WhyNot(townHallLevel);
if (next == null)
{
UIKit.Text(card, "Хранилище расширено до предела", 22, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
return;
}
var cost = next.Cost ?? new ResourceAmount[0];
if (why != null)
{
UIKit.Text(card, why + ". Цена: " + CostText(cost), 22, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
return;
}
LiveCost(card, cost);
bool canPay = GameServices.TryGet(out IEconomy economy) && economy.CanAfford(cost);
var b = UIKit.Button(card, $"Расширить до {next.Capacity}", canPay ? UIKit.Theme.Green : UIKit.Theme.Gray, () =>
{
if (EconomyConfig.TryExpandCrystalStorage(townHallLevel, out string error))
{
UIKit.Toast($"Хранилище кристаллов: {EconomyConfig.CrystalStorageNow().Capacity}");
Rebuild();
}
else UIKit.Toast(error);
}, -1, 80, 26);
LiveAfford(b, canPay, () => GameServices.TryGet(out IEconomy now) && now.CanAfford(cost));
}
// Каких зданий на следующем уровне Ратуши можно построить больше: "Лес 2 → 3, Дом 3 → 4"
static string MoreBuildingsAt(int level, int next)
{
@@ -600,21 +663,21 @@ public static class BuildingWindow
live.Add(() => { if (t != null) UIKit.SetBar(bar, t.Progress); });
Live(card, () => t != null ? "Осталось " + GameData.FormatTime(t.SecondsLeft) : "Готово", 24, UIKit.Theme.Text);
int perGem = Mathf.Max(1, EconomyConfig.Load().construction.secondsPerGem);
Func<int> gems = () => t != null ? Mathf.Max(1, Mathf.CeilToInt((float)t.SecondsLeft / perGem)) : 0;
// Ускорение -- за золото (06.11, ТЕСТ: EconomyConfig.construction.secondsPerGold)
Func<int> gold = () => t != null ? EconomyConfig.SpeedUpGold(t.SecondsLeft) : 0;
var speed = UIKit.Button(card, "", UIKit.Theme.Blue, () =>
{
if (t == null) return;
int price = gems();
if (!GameServices.TryGet(out IEconomy e) || !e.TrySpend(ResourceType.Gems, price))
int price = gold();
if (!GameServices.TryGet(out IEconomy e) || !e.TrySpend(ResourceType.Gold, price))
{
UIKit.Toast($"Нужно {price} кристаллов");
UIKit.Toast($"Нужно {price} золота");
return;
}
t.Complete(); // окно перестроит OnBuildCompleted
}, -1, 80, 26);
live.Add(() => { if (t != null) UIKit.SetButton(speed, $"Ускорить за {gems()} кристаллов", true); });
UIKit.SetButton(speed, $"Ускорить за {gems()} кристаллов", true);
live.Add(() => { if (t != null) UIKit.SetButton(speed, $"Ускорить за {gold()} золота", true); });
UIKit.SetButton(speed, $"Ускорить за {gold()} золота", true);
}
// ---------------- Перенос (решения 04.11: сноса нет) ----------------
+15
View File
@@ -77,6 +77,21 @@ public class DebugPanel : MonoBehaviour
Btn(row2, "+1000 еды", UIKit.Theme.Green, () => AddResource(ResourceType.Food, 1000));
Btn(row2, "Кристаллы = макс", UIKit.Theme.Blue, FillGems);
// Недельный PvE-предел кристаллов: «+1 PvE-кристалл» выдаёт как настоящая награда (квота, место, компенсация)
var rowQ = UIKit.Row(card, 70, 10);
Btn(rowQ, "+1 PvE-кристалл", UIKit.Theme.Blue, () => { if (GameProgress.Instance != null) GameProgress.Instance.Grant(new[] { new RewardItem(ResourceType.Gems, 1) }); Refresh(); });
// Только для проверки торговца (30 000 / 60 000 золота) и Кристальной шахты (12 000 золота, 3200 камня...):
// обычные ресурсы ставятся напрямую, мимо лимита склада
Btn(rowQ, "Ресурсы 100 000", UIKit.Theme.Green, () =>
{
var rm = ResourceManager.Instance;
if (rm != null)
foreach (var t in new[] { ResourceType.Gold, ResourceType.Wood, ResourceType.Stone, ResourceType.Iron, ResourceType.Food })
rm.SetResource(t, 100000);
Refresh();
});
Btn(rowQ, "Сбросить квоту", UIKit.Theme.Gray, () => { if (GameProgress.Instance != null) GameProgress.Instance.DevResetCrystalQuota(); UIKit.Toast("Недельная квота кристаллов сброшена"); Refresh(); });
var row3 = UIKit.Row(card, 70, 10);
Btn(row3, "Еда = 0", UIKit.Theme.Red, ZeroFood);
Btn(row3, "Сбросить перемотку", UIKit.Theme.Gray, () => { GameClock.ResetOffset(); Refresh(); });
+91 -4
View File
@@ -1,3 +1,4 @@
using System.Collections.Generic;
using System;
using UnityEngine;
@@ -70,6 +71,20 @@ public class EconomyConfig : ScriptableObject
[Range(0f, 1f)] public float famineNonFoodMultiplier = 0.5f;
}
[Serializable]
public class CrystalExpansion
{
public int townHallLevel = 3;
public int capacity = 75;
public float gold, wood, stone, iron;
public CrystalExpansion() { }
public CrystalExpansion(int townHall, int cap, float gold, float wood, float stone, float iron)
{
townHallLevel = townHall; capacity = cap; this.gold = gold; this.wood = wood; this.stone = stone; this.iron = iron;
}
}
[Serializable]
public class StorageSettings // этап 1: лимиты хранения
{
@@ -81,8 +96,31 @@ public class EconomyConfig : ScriptableObject
public float perWarehouseLevel = 1000f;
[Tooltip("+ к лимиту еды за каждый уровень Амбара. Не утверждено")]
public float perGranaryLevel = 1500f;
[Tooltip("Лимит синего кристалла (решение команды от 19.10: небольшой, не бесконечный). Число не утверждено")]
[Tooltip("Стартовая вместимость синих кристаллов (регламент валют 05.11, разд. 8). ТЕСТ")]
public float crystalCap = 50f;
[Tooltip("Недельный PvE-предел: не больше стольких НОВЫХ синих кристаллов за окно из заданий, событий, боёв, PvE-торговца. " +
"Шахта, торговля и грабёж -- вне предела (регламент валют 05.11, разд. 5.2; ответ 06.11, п. 2). ТЕСТ")]
public int crystalPveLimit = 6;
[Tooltip("Окно недельного PvE-предела, реальных суток (скользящее). ТЕСТ")]
public int crystalPveWindowDays = 7;
[Tooltip("Компенсация золотом за каждый синий кристалл PvE-награды сверх недельного предела (величина не согласована). ТЕСТ")]
public float crystalCompensationGold = 1000f;
[Tooltip("Кристальная шахта: синих кристаллов за период на одну работающую шахту (ответ 06.11, п. 3). ТЕСТ")]
public int crystalMinePerPeriod = 3;
[Tooltip("Кристальная шахта: период, РЕАЛЬНЫХ суток (не игровые часы ×12). ТЕСТ")]
public int crystalMinePeriodDays = 7;
[Tooltip("Редкий PvE-торговец: цена синего кристалла в золоте по порядку покупок за 7 реальных суток; покупок не больше, " +
"чем цен (регламент валют 05.11, разд. 3). Входит в недельный PvE-предел. ТЕСТ")]
public int[] crystalTraderPrices = { 30000, 60000 };
[Tooltip("Платные расширения хранилища кристаллов по порядку: с какой Ратуши, до скольки, цена (золото и обычные ресурсы). " +
"Ратуша только разрешает -- само не растёт (регламент валют 05.11, разд. 8; ответ 06.11, п. 1). Числа и цены ТЕСТ")]
public CrystalExpansion[] crystalExpansions =
{
new CrystalExpansion(3, 75, 2000, 1500, 1500, 0),
new CrystalExpansion(5, 100, 4000, 3000, 3000, 1000),
new CrystalExpansion(7, 150, 7000, 5000, 5000, 2000),
new CrystalExpansion(9, 200, 10000, 8000, 8000, 4000),
};
[Tooltip("Цена Склада: дерево / камень. Не утверждено")]
public float warehouseWood = 200f;
@@ -137,8 +175,11 @@ public class EconomyConfig : ScriptableObject
public int[] upgradeSecondsToLevel = { 60, 120, 300, 600, 1200, 1800, 3600, 5400, 7200 };
[Tooltip("Ратуша строится дольше остальных: множитель времени улучшения. ТЕСТ")]
public float townHallTimeMultiplier = 2f;
[Tooltip("Ускорение: секунд стройки за 1 кристалл. ТЕСТ")]
[Tooltip("Устарело (06.11): ускорение теперь за золото -- secondsPerGold")]
public int secondsPerGem = 30;
[Tooltip("Ускорение стройки, улучшения и обучения войск -- за золото (06.11; регламент валют: золото -- основной ресурс " +
"построек и улучшений, синие кристаллы на ускорения не тратятся): секунд за 1 золото. ТЕСТ")]
public int secondsPerGold = 10;
// Сноса зданий нет (решение 04.11): только бесплатный перенос
}
@@ -219,7 +260,7 @@ public class EconomyConfig : ScriptableObject
new BuildingLimit("Казармы", 1, 1, 2, 2, 3, 3, 3, 3, 3, 3),
new BuildingLimit("Госпиталь", 1, 1, 1, 1, 1, 1, 1, 1, 1, 1),
new BuildingLimit("Терем богатыря", 1, 1, 1, 1, 1, 1, 1, 1, 1, 1),
new BuildingLimit("Кристалл", 0, 0, 0, 0, 0, 1, 1, 1, 1, 1),
new BuildingLimit("Кристалл", 0, 0, 0, 0, 0, 1, 1, 1, 2, 2), // Кристальная шахта: вторая -- на Ратуше 9 (ответ 06.11, п. 3; уровень ТЕСТ)
};
[Header("Защита ресурсов от грабежа (ТЕСТ)")]
@@ -253,7 +294,7 @@ public class EconomyConfig : ScriptableObject
case ResourceType.Iron:
case ResourceType.Gold: return s.baseGoods + s.perWarehouseLevel * warehouseLevels;
case ResourceType.Food: return s.baseFood + s.perGranaryLevel * granaryLevels;
case ResourceType.Gems: return s.crystalCap;
case ResourceType.Gems: return CrystalStorageNow().Capacity; // стартовая + купленные расширения
default: return float.PositiveInfinity;
}
}
@@ -326,4 +367,50 @@ public class EconomyConfig : ScriptableObject
default: return 0f;
}
}
// Цена ускорения в золоте: сколько осталось секунд -> золото (не меньше 1). Стройка, улучшение и обучение войск
public static int SpeedUpGold(double secondsLeft)
{
int perGold = Mathf.Max(1, Load().construction.secondsPerGold);
return Mathf.Max(1, Mathf.CeilToInt((float)(secondsLeft / perGold)));
}
// ---------------- Хранилище синих кристаллов (регламент валют 05.11, разд. 8; правила -- CrystalStorage) ----------------
// Правила с купленной ступенью (её хранит Экономика: ResourceManager.CrystalStorageLevel, секция "economy")
public static CrystalStorage CrystalStorageNow()
{
var c = Load();
var tiers = new List<CrystalStorage.Tier>();
if (c.storage.crystalExpansions != null)
foreach (var e in c.storage.crystalExpansions)
{
if (e == null) continue;
var cost = new List<ResourceAmount>();
if (e.gold > 0) cost.Add(new ResourceAmount(ResourceType.Gold, e.gold));
if (e.wood > 0) cost.Add(new ResourceAmount(ResourceType.Wood, e.wood));
if (e.stone > 0) cost.Add(new ResourceAmount(ResourceType.Stone, e.stone));
if (e.iron > 0) cost.Add(new ResourceAmount(ResourceType.Iron, e.iron));
tiers.Add(new CrystalStorage.Tier { TownHallLevel = e.townHallLevel, Capacity = e.capacity, Cost = cost.ToArray() });
}
var storage = new CrystalStorage(Mathf.RoundToInt(c.storage.crystalCap), tiers);
if (ResourceManager.Instance != null) storage.SetLevel(ResourceManager.Instance.CrystalStorageLevel);
return storage;
}
// Купить следующую ступень: цена списывается один раз (всё или ничего), ступень и ресурсы -- в одной секции
// сохранения "economy", сохраняется сразу. error -- почему нельзя
public static bool TryExpandCrystalStorage(int townHallLevel, out string error)
{
var rm = ResourceManager.Instance;
var storage = CrystalStorageNow();
error = storage.WhyNot(townHallLevel);
if (error != null) return false;
if (rm == null || !GameServices.TryGet(out IEconomy economy)) { error = "Склад не найден"; return false; }
if (!storage.TryExpand(townHallLevel, cost => economy.TrySpend(cost))) { error = "Не хватает ресурсов"; return false; }
rm.CrystalStorageLevel = storage.Level;
rm.SetCapacity(ResourceType.Gems, storage.Capacity);
if (SaveManager.Instance != null) SaveManager.Instance.SaveGame();
return true;
}
}
+3 -1
View File
@@ -87,11 +87,13 @@ public class City3DCamera : MonoBehaviour
}
}
public void FocusOn(Rect area)
// onlyZoomOut -- не приближать: если камера уже дальше, расстояние остаётся (открытие зоны, 06.11)
public void FocusOn(Rect area, bool onlyZoomOut = false)
{
focusTarget = new Vector3(area.center.x, 0, area.center.y);
// отойти так, чтобы область поместилась между HUD сверху и снизу
focusDistance = Mathf.Clamp(FitDistance(area), minDistance, MaxDistanceNow);
if (onlyZoomOut) focusDistance = Mathf.Max(focusDistance, Mathf.Min(distance, MaxDistanceNow));
focusing = true;
zoomTo = -1f;
}
+100 -13
View File
@@ -115,6 +115,7 @@ public class City3DController : MonoBehaviour, City3DSave.ILive
}
bool stateApplied; // сохранённый город уже поставлен (в первом кадре, после загрузки сохранения в Start)
public bool StateApplied => stateApplied;
void OnEnable()
{
@@ -407,17 +408,20 @@ public class City3DController : MonoBehaviour, City3DSave.ILive
go.AddComponent<MeshRenderer>().sharedMaterial = Map3D.TexturedMat(groundTexture);
go.AddComponent<MeshCollider>().sharedMesh = mesh;
// Лес и луга за краем участка: камера не видит пустого неба
Map3D.Block(transform, "OuterLand", PrimitiveType.Cube, new Color(0.24f, 0.4f, 0.22f), new Vector3(n / 2f, -0.7f, n / 2f),
new Vector2(n + 200f, n + 200f), 0.1f);
// Местность за краем участка -- продолжение холмов, реки и гор (City3DLayout.SkirtHeight), дальше -- равнина
BuildSkirt();
Map3D.Block(transform, "OuterLand", PrimitiveType.Cube, new Color(0.3f, 0.5f, 0.26f),
new Vector3(n / 2f, City3DLayout.SkirtFloor - 0.05f, n / 2f), new Vector2(n + 2 * City3DLayout.SkirtCells + 300f, n + 2 * City3DLayout.SkirtCells + 300f), 0.1f);
var forest = new System.Random(7);
for (int i = 0; i < 140; i++)
for (int i = 0; i < 260; i++)
{
// кольцо деревьев вокруг участка
float angle = (float)forest.NextDouble() * Mathf.PI * 2f, r = n * 0.72f + (float)forest.NextDouble() * 14f;
var p = new Vector3(n / 2f + Mathf.Cos(angle) * r, -0.6f, n / 2f + Mathf.Sin(angle) * r);
if (p.x > 0 && p.x < n && p.z > 0 && p.z < n) continue; // внутри участка -- свои деревья
Map3D.Tree(transform, "ForestTree", p, 1.4f + (float)forest.NextDouble() * 0.8f, forest.Next(6));
// лес вокруг участка: гуще у края, реже дальше; не в реке и не на горах
float angle = (float)forest.NextDouble() * Mathf.PI * 2f;
float r = n * 0.6f + Mathf.Pow((float)forest.NextDouble(), 1.6f) * (City3DLayout.SkirtCells + 4f);
float x = n / 2f + Mathf.Cos(angle) * r, z = n / 2f + Mathf.Sin(angle) * r;
if (x > -0.5f && x < n + 0.5f && z > -0.5f && z < n + 0.5f) continue; // внутри участка -- свои деревья
if (City3DLayout.IsRiver(x, z) || City3DLayout.IsMountain(x, z)) continue;
Map3D.Tree(transform, "ForestTree", new Vector3(x, City3DLayout.SkirtHeight(x, z), z), 1.4f + (float)forest.NextDouble() * 0.8f, forest.Next(6));
}
// Вода -- плоскость над руслом
@@ -426,10 +430,61 @@ public class City3DController : MonoBehaviour, City3DSave.ILive
Destroy(water.GetComponent<Collider>());
water.transform.SetParent(transform, false);
water.transform.position = new Vector3(37f, -0.25f, n / 2f);
water.transform.localScale = new Vector3(9f, 0.05f, n);
water.transform.localScale = new Vector3(9f, 0.05f, n + 2 * (City3DLayout.SkirtCells - 22)); // река течёт и за краем участка
water.GetComponent<Renderer>().sharedMaterial = Mat(new Color(0.3f, 0.58f, 0.85f));
}
// Местность вокруг участка: сетка 1 клетка (на стыке -- те же вершины, что у земли участка, без щелей),
// без самого участка. Цвет -- тот же, что у земли (трава пятнами, берег, вода, горы со снегом), 1 пиксель на клетку
void BuildSkirt()
{
int n = City3DLayout.Size, s = City3DLayout.SkirtCells, w = n + 2 * s;
var verts = new Vector3[(w + 1) * (w + 1)];
var uvs = new Vector2[verts.Length];
for (int z = 0; z <= w; z++)
for (int x = 0; x <= w; x++)
{
float wx = x - s, wz = z - s;
int i = z * (w + 1) + x;
verts[i] = new Vector3(wx, City3DLayout.SkirtHeight(wx, wz), wz);
uvs[i] = new Vector2(x / (float)w, z / (float)w);
}
var tris = new List<int>(w * w * 6);
for (int z = 0; z < w; z++)
for (int x = 0; x < w; x++)
{
if (x >= s && x < s + n && z >= s && z < s + n) continue; // участок -- своя земля
int i = z * (w + 1) + x;
tris.Add(i); tris.Add(i + w + 1); tris.Add(i + 1);
tris.Add(i + 1); tris.Add(i + w + 1); tris.Add(i + w + 2);
}
var mesh = new Mesh { name = "City3D_Skirt", indexFormat = UnityEngine.Rendering.IndexFormat.UInt32 };
mesh.vertices = verts;
mesh.uv = uvs;
mesh.SetTriangles(tris, 0);
mesh.RecalculateNormals();
var tex = new Texture2D(w, w, TextureFormat.RGBA32, false) { filterMode = FilterMode.Bilinear, wrapMode = TextureWrapMode.Clamp };
var px = new Color32[w * w];
for (int z = 0; z < w; z++)
for (int x = 0; x < w; x++)
{
float wx = x - s + 0.5f, wz = z - s + 0.5f, h = City3DLayout.SkirtHeight(wx, wz);
Color c = City3DLayout.IsRiver(wx, wz) ? new Color(0.32f, 0.5f, 0.72f)
: City3DLayout.IsMountain(wx, wz) && h > 1.5f ? Color.Lerp(new Color(0.48f, 0.45f, 0.42f), new Color(0.85f, 0.86f, 0.88f), Mathf.Clamp01((h - 3.2f) / 1.5f))
: Mathf.Abs(wx - City3DLayout.RiverX(wz)) < 2.2f ? new Color(0.78f, 0.72f, 0.52f)
: Color.Lerp(new Color(0.33f, 0.56f, 0.27f), new Color(0.45f, 0.66f, 0.32f), Mathf.PerlinNoise(wx * 0.25f, wz * 0.25f));
px[z * w + x] = c;
}
tex.SetPixels32(px);
tex.Apply(false);
var go = new GameObject("Skirt");
go.transform.SetParent(transform, false);
go.AddComponent<MeshFilter>().sharedMesh = mesh;
go.AddComponent<MeshRenderer>().sharedMaterial = Map3D.TexturedMat(tex); // без коллайдера: тап и стройка -- только на участке
}
void BuildObstacles()
{
int i = 0;
@@ -827,6 +882,7 @@ public class City3DController : MonoBehaviour, City3DSave.ILive
if (!stateApplied && map != null) ApplySaved();
if (cam == null) return;
UpdateConstructionViews();
UpdateHero();
bool down, held, up;
Vector3 pos;
if (Input.touchCount > 0)
@@ -897,6 +953,12 @@ public class City3DController : MonoBehaviour, City3DSave.ILive
void Tap(Vector3 pos)
{
// Тап по герою -- фраза и окно героя (как в 2D)
if (mode == Mode.View && City3DHero.Instance != null && !City3DHero.Instance.Hidden)
{
foreach (var hit in Physics.RaycastAll(cam.ScreenPointToRay(pos), 500f))
if (hit.collider.GetComponentInParent<City3DHero>() != null) { City3DHero.Instance.OnTapped(); return; }
}
if (!CellUnder(pos, mode == Mode.Place || mode == Mode.Move, out int cx, out int cy)) return;
if (mode == Mode.Place || mode == Mode.Move)
{
@@ -982,14 +1044,18 @@ public class City3DController : MonoBehaviour, City3DSave.ILive
if (ghost != null) UpdateGhost();
if (townHallLevel > before)
{
// Открылась зона -- камера плавно отходит и показывает её; здания остаются на местах
int half = City3DLayout.ZoneHalfSize(townHallLevel), c = City3DLayout.Size / 2;
cameraRig.FocusOn(new Rect(c - half, c - half, half * 2, half * 2));
// Открылась зона -- камера плавно отходит и показывает её КРАЙ: старая зона и ZoneEdgeCells клеток новой за её
// границей (06.11, Саша: вся зона целиком -- камера улетала слишком далеко). Ближе, чем была, не подъезжает
int c = City3DLayout.Size / 2;
int show = Mathf.Min(City3DLayout.ZoneHalfSize(townHallLevel), City3DLayout.ZoneHalfSize(before) + ZoneEdgeCells);
cameraRig.FocusOn(new Rect(c - show, c - show, show * 2, show * 2), onlyZoomOut: true);
Say($"Ратуша {townHallLevel} ур.: открыта новая зона");
if (stateApplied && pendingFrom2D.Count > 0) PlacePendingFrom2D(); // здания из 2D, которые ждали места
}
}
const int ZoneEdgeCells = 2; // сколько клеток новой зоны показать при её открытии
// Камера: центр кадра -- только над открытой зоной, при самом дальнем отдалении видна вся зона (06.11)
void UpdateCameraArea()
{
@@ -1010,6 +1076,27 @@ public class City3DController : MonoBehaviour, City3DSave.ILive
float guiScale = 1f;
GUIStyle button, label, box;
// ---------------- Герой у Терема (перенос из 2D) ----------------
// Терем достроен -- у его ворот герой; Терема нет -- героя нет. В режиме стройки и переноса прячется
void UpdateHero()
{
if (!stateApplied) return;
string castleId = null;
foreach (var pair in buildingType)
{
if (Types[pair.Value].Name != "Терем") continue;
if (!buildingViews.TryGetValue(pair.Key, out var view) || view == null || BuildTimer.IsConstructing(view.transform)) continue;
castleId = pair.Key;
break;
}
var hero = City3DHero.Instance;
if (castleId == null) { if (hero != null) Destroy(hero.gameObject); return; }
if (hero == null || hero.CastleId != castleId || hero.CastleView == null)
hero = City3DHero.Spawn(transform, map, castleId, buildingViews[castleId], () => townHallLevel);
hero.Hidden = mode == Mode.Place || mode == Mode.Move;
}
// ---------------- Стройка по времени в 3D ----------------
// BuildTimer затемняет только 2D-спрайты, поэтому в 3D здание, которое ещё строится, выглядело готовым
// (Казармы «построены», а нанимать нельзя). Здесь: здание темнее и над ним -- «Стройка 0:45» (05.11)
+29
View File
@@ -116,6 +116,35 @@ public static class City3DGameplay
return string.Join(", ", parts.ToArray());
}
// Кристальные шахты: с какого момента каждая добывает (мс; long.MinValue -- давно достроена; конец стройки -- если
// ещё строится или достроилась офлайн). Город открыт -- по сцене, иначе -- по сохранению settlement3d.
// null -- город ещё не загружен: считать рано (иначе офлайн-время шахты пропало бы)
public static List<long> CrystalMinesActiveFrom()
{
var list = new List<long>();
var city = City3DController.Instance;
if (city != null)
{
if (!city.StateApplied) return null;
foreach (var id in Object.FindObjectsByType<PlacedBuildingId>(FindObjectsSortMode.None))
{
if (id.buildingName != "Кристалл") continue;
var t = BuildTimer.Of(id.gameObject);
list.Add(t != null && BuildTimer.IsConstructing(id.transform) ? t.endMs : long.MinValue);
}
return list;
}
if (!City3DSave.Loaded) return null;
if (City3DSave.Cached != null)
foreach (var e in City3DSave.Cached.buildings)
{
if (e == null || e.type != "Кристалл") continue;
bool building = e.timerKind == (int)BuildTimer.Kind.Build && e.level <= 1 && e.timerEndMs > 0;
list.Add(building ? e.timerEndMs : long.MinValue);
}
return list;
}
public static int CountBuilt(string gameName)
{
int n = 0;
+186
View File
@@ -0,0 +1,186 @@
using Bogatyri.Settlement.Placement;
using UnityEngine;
// ГЕРОЙ В 3D-ПОСЕЛЕНИИ (перенос из 2D, решение 05.11: всё из 2D-города -- в 3D). Как HeroWalker в 2D-городе:
// - живёт у Терема богатыря: появляется у его ворот, когда Терем достроен, и исчезает, если Терема нет;
// - гуляет по свободным клеткам 3D-карты (земля, открытая зона, без зданий и деревьев) не дальше WalkRadius от Терема:
// шаг -- на соседнюю свободную клетку, поэтому сквозь здания и деревья не проходит; после нескольких шагов стоит 1-3 с;
// - тап по герою -- фраза и окно героя (окно Терема); в режиме стройки и переноса прячется;
// - пока без арта: фигурка из простых форм (тело, голова, шлем, щит, копьё). Арт подставим в BuildFigure.
// Только оживление города: на экономику, бой и сохранение не влияет. Создаёт и убирает City3DController.
public class City3DHero : MonoBehaviour
{
public static City3DHero Instance { get; private set; }
const float Speed = 1.3f; // клеток в секунду
const int WalkRadius = 6; // насколько далеко уходит от Терема
static readonly string[] Phrases =
{
"Город под защитой!",
"Веди, воевода, — не подведу.",
"Силушка богатырская при мне.",
"Дозором обхожу владения.",
"Есть ли где ворог? Только скажи.",
};
SettlementMap map;
System.Func<int> townHallLevel;
public string CastleId { get; private set; }
public GameObject CastleView { get; private set; }
Transform figure;
Vector2Int cell; // клетка, к которой идёт (или на которой стоит)
Vector2Int previous; // откуда пришёл -- чтобы не топтаться туда-обратно
int stepsLeft;
float waitLeft;
// Видимость: в режиме стройки и переноса герой прячется, чтобы не мешать ставить здания
public bool Hidden
{
get => figure != null && !figure.gameObject.activeSelf;
set { if (figure != null && figure.gameObject.activeSelf == value) figure.gameObject.SetActive(!value); }
}
public static City3DHero Spawn(Transform parent, SettlementMap map, string castleId, GameObject castleView, System.Func<int> townHallLevel)
{
if (Instance != null) Destroy(Instance.gameObject);
var go = new GameObject("Hero3D");
go.transform.SetParent(parent, false);
var h = go.AddComponent<City3DHero>();
h.map = map;
h.CastleId = castleId;
h.CastleView = castleView;
h.townHallLevel = townHallLevel;
h.BuildFigure();
h.cell = h.previous = h.Gate();
go.transform.position = CellWorld(h.cell);
h.waitLeft = 1f;
Instance = h;
return h;
}
void OnDestroy()
{
if (Instance == this) Instance = null;
}
// ---------------- Внешний вид (заглушка) ----------------
void BuildFigure()
{
figure = new GameObject("Figure").transform;
figure.SetParent(transform, false);
Part(PrimitiveType.Capsule, new Vector3(0, 0.32f, 0), new Vector3(0.28f, 0.3f, 0.22f), new Color(0.55f, 0.4f, 0.2f)); // кафтан
Part(PrimitiveType.Sphere, new Vector3(0, 0.72f, 0), new Vector3(0.2f, 0.2f, 0.2f), new Color(1f, 0.85f, 0.7f)); // голова
Part(PrimitiveType.Sphere, new Vector3(0, 0.8f, 0), new Vector3(0.22f, 0.14f, 0.22f), new Color(0.75f, 0.75f, 0.8f)); // шлем
Part(PrimitiveType.Cylinder, new Vector3(0, 0.9f, 0), new Vector3(0.03f, 0.06f, 0.03f), new Color(0.75f, 0.75f, 0.8f)); // шишак
Part(PrimitiveType.Cube, new Vector3(0.17f, 0.36f, 0.04f), new Vector3(0.04f, 0.26f, 0.2f), new Color(0.8f, 0.15f, 0.15f)); // щит
Part(PrimitiveType.Cylinder, new Vector3(-0.17f, 0.5f, 0), new Vector3(0.025f, 0.5f, 0.025f), new Color(0.45f, 0.3f, 0.15f)); // копьё
Part(PrimitiveType.Cylinder, new Vector3(0, 0.005f, 0), new Vector3(0.36f, 0.005f, 0.36f), new Color(0.18f, 0.22f, 0.14f)); // тень
// Тап по герою: коллайдер чуть больше фигурки (City3DController ищет City3DHero под пальцем)
var hit = gameObject.AddComponent<BoxCollider>();
hit.center = new Vector3(0, 0.5f, 0);
hit.size = new Vector3(0.7f, 1.1f, 0.7f);
}
void Part(PrimitiveType type, Vector3 pos, Vector3 scale, Color color)
{
var p = GameObject.CreatePrimitive(type);
Destroy(p.GetComponent<Collider>());
p.transform.SetParent(figure, false);
p.transform.localPosition = pos;
p.transform.localScale = scale;
p.GetComponent<Renderer>().sharedMaterial = Map3D.Mat(color);
}
// ---------------- Прогулка по клеткам ----------------
static Vector3 CellWorld(Vector2Int c)
{
float x = c.x + 0.5f, z = c.y + 0.5f;
return new Vector3(x, City3DLayout.HeightAt(x, z), z);
}
bool Walkable(int x, int y)
{
if (map == null || !map.InMap(x, y)) return false;
if (map.KindAt(x, y) != CellKind.Ground || !map.IsOpen(x, y, townHallLevel != null ? townHallLevel() : 1)) return false;
return map.BuildingAt(x, y) == null && map.ObstacleAt(x, y) == null;
}
bool CastleRect(out PlacedRect r)
{
r = default;
return map != null && map.TryGetBuilding(CastleId, out r);
}
bool NearHome(int x, int y)
{
if (!CastleRect(out var r)) return true;
float cx = r.X + r.W / 2f, cy = r.Y + r.H / 2f;
return Mathf.Abs(x + 0.5f - cx) <= WalkRadius + r.W / 2f && Mathf.Abs(y + 0.5f - cy) <= WalkRadius + r.H / 2f;
}
// Ворота: ближайшая свободная клетка вокруг Терема (с нижней стороны -- первой)
Vector2Int Gate()
{
if (!CastleRect(out var r)) return Vector2Int.zero;
for (int ring = 1; ring <= WalkRadius; ring++)
for (int x = r.X - ring; x < r.X + r.W + ring; x++)
foreach (int y in new[] { r.Y - ring, r.Y + r.H - 1 + ring })
if (Walkable(x, y)) return new Vector2Int(x, y);
return new Vector2Int(r.X + r.W / 2, r.Y - 1);
}
static readonly Vector2Int[] Dirs = { Vector2Int.up, Vector2Int.down, Vector2Int.left, Vector2Int.right };
// Следующая клетка: соседняя свободная рядом с Теремом; назад -- только если больше некуда
Vector2Int NextCell()
{
var options = new System.Collections.Generic.List<Vector2Int>();
foreach (var d in Dirs)
{
var c = cell + d;
if (c != previous && Walkable(c.x, c.y) && NearHome(c.x, c.y)) options.Add(c);
}
if (options.Count == 0 && Walkable(previous.x, previous.y)) return previous;
return options.Count > 0 ? options[Random.Range(0, options.Count)] : cell;
}
void Update()
{
if (Hidden) return;
if (!Walkable(cell.x, cell.y) && cell != Gate()) { cell = previous = Gate(); transform.position = CellWorld(cell); } // на клетку поставили здание
if (waitLeft > 0f) { waitLeft -= Time.deltaTime; figure.localPosition = Vector3.zero; return; }
Vector3 goal = CellWorld(cell), pos = transform.position;
Vector3 flat = new Vector3(goal.x - pos.x, 0, goal.z - pos.z);
if (flat.magnitude < 0.03f)
{
if (stepsLeft <= 0) { stepsLeft = Random.Range(3, 7); waitLeft = Random.Range(1f, 3f); return; }
stepsLeft--;
var next = NextCell();
previous = cell;
cell = next;
return;
}
Vector3 step = flat.normalized * Speed * Time.deltaTime;
if (step.magnitude > flat.magnitude) step = flat;
pos += step;
pos.y = City3DLayout.HeightAt(pos.x, pos.z); // по земле, через холмы
transform.position = pos;
transform.rotation = Quaternion.Slerp(transform.rotation, Quaternion.LookRotation(flat), Time.deltaTime * 10f); // смотрит, куда идёт
figure.localPosition = new Vector3(0f, Mathf.Abs(Mathf.Sin(Time.time * 10f)) * 0.04f, 0f); // покачивание при ходьбе
}
// ---------------- Тап ----------------
public void OnTapped()
{
UIKit.Toast("Богатырь: " + Phrases[Random.Range(0, Phrases.Length)]);
waitLeft = 2f; // остановился поговорить
if (CastleView != null) BuildingWindow.Open(CastleView);
}
}
+11
View File
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8d4c2a7e1f5b4e93b6a1c0d9e2f73a58
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+16 -3
View File
@@ -39,9 +39,22 @@ public sealed class City3DLayout
return hills;
}
static float RiverX(float z) => 37f + 3f * Mathf.Sin(z * 0.17f);
static bool IsRiver(float x, float z) => Mathf.Abs(x - RiverX(z)) < 1.3f;
static bool IsMountain(float x, float z) => x < 9f && z > 34f || x < 4f && z > 24f;
public static float RiverX(float z) => 37f + 3f * Mathf.Sin(z * 0.17f);
public static bool IsRiver(float x, float z) => Mathf.Abs(x - RiverX(z)) < 1.3f;
public static bool IsMountain(float x, float z) => x < 9f && z > 34f || x < 4f && z > 24f;
// Местность за краем участка (только вид, стройки нет): те же холмы, река и горы продолжаются без шва,
// к дальнему краю (SkirtCells) плавно опускаются до общей равнины SkirtFloor (06.11: при отдалении край участка
// выглядел обрезанным -- холмы и река обрывались уступом)
public const int SkirtCells = 60;
public const float SkirtFloor = -0.7f;
public static float SkirtHeight(float x, float z)
{
float outside = Mathf.Max(0f, Mathf.Max(Mathf.Max(-x, x - Size), Mathf.Max(-z, z - Size)));
float fade = 1f - Mathf.SmoothStep(0f, 1f, Mathf.InverseLerp(SkirtCells - 20f, SkirtCells, outside));
return Mathf.Lerp(SkirtFloor, HeightAt(x, z), fade);
}
public static City3DLayout Create(PlacementRules rules = null)
{
+4
View File
@@ -39,6 +39,8 @@ public sealed class City3DSave : ISaveParticipant
public static readonly City3DSave Instance = new City3DSave();
public static Data Cached { get; private set; }
// Сохранение уже прочитано (или новая игра): по Cached можно считать здания вне 3D-сцены (Кристальная шахта на карте мира)
public static bool Loaded { get; private set; }
public static ILive Live;
public string SaveKey => "settlement3d";
@@ -51,6 +53,7 @@ public sealed class City3DSave : ISaveParticipant
public void Restore(string json)
{
Loaded = true;
Cached = string.IsNullOrEmpty(json) ? null : JsonUtility.FromJson<Data>(json);
if (Cached != null)
{
@@ -63,6 +66,7 @@ public sealed class City3DSave : ISaveParticipant
public void ResetState()
{
Loaded = true;
Cached = null;
Live?.OnSaveLoaded();
}
+164 -13
View File
@@ -73,6 +73,7 @@ public class GameProgress : MonoBehaviour
nextTick = Time.unscaledTime + 1f;
EnsureDailyAndWeekly();
ProcessTraining();
ProcessCrystalMine();
UpdateBuildingsCount();
UpdatePowerResource();
}
@@ -146,6 +147,16 @@ public class GameProgress : MonoBehaviour
if (Data.claimedDaily == null) Data.claimedDaily = new List<string>();
if (Data.crystalWaiting == null) Data.crystalWaiting = new List<string>();
crystals.Load(Data.crystalWaiting);
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);
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);
mine = new CrystalMine(storageCfg.crystalMinePerPeriod, Math.Max(1, storageCfg.crystalMinePeriodDays) * CrystalQuota.Day);
mine.Load(Data.crystalMine);
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>();
@@ -309,13 +320,24 @@ public class GameProgress : MonoBehaviour
return true;
}
// Выдать награду. Синие кристаллы -- не сверх предела хранилища (регламент 05.11): не поместившиеся приходят
// письмом «Кристаллы ждут места» и забираются из него, когда место появится. Ничего не теряется
public void Grant(RewardItem[] rewards, int xp = 0, bool toast = true)
// Выдать награду. Синие кристаллы (регламент валют 05.11, ответ 06.11):
// - новые PvE-кристаллы (newPveCrystals) -- в пределах недельной квоты; сверх неё -- золото (компенсация);
// - не сверх вместимости хранилища: не поместившиеся приходят письмом «Кристаллы ждут места» и забираются из него,
// когда место появится (квота за них уже учтена). Ничего не теряется.
// Возвращает, что выдано сейчас (кристаллы -- сколько поместилось, плюс компенсация)
public RewardItem[] Grant(RewardItem[] rewards, int xp = 0, bool toast = true, bool newPveCrystals = true)
{
int gems = GemsIn(rewards), fit = CrystalRewards.Fits(gems, GemsHave, GemsCap), left = gems - fit;
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;
var given = new List<RewardItem>(WithoutGems(rewards));
if (fit > 0) given.Add(new RewardItem(ResourceType.Gems, fit));
if (compGold > 0) given.Add(new RewardItem(ResourceType.Gold, compGold));
if (ResourceManager.Instance != null)
{
suppressTracking++;
@@ -332,11 +354,136 @@ public class GameProgress : MonoBehaviour
string text = GameData.RewardsText(given.ToArray());
if (xp > 0) text += (text.Length > 0 ? ", " : "") + xp + " опыта";
if (left > 0) text += (text.Length > 0 ? ". " : "") + $"Ещё {left} кристаллов ждут места -- в почте";
if (compensated > 0) text += (text.Length > 0 ? ". " : "") + QuotaText(compensated, compGold);
if (text.Length > 0) UIKit.Toast("Получено: " + text);
}
MarkChanged();
return given.ToArray();
}
// ---------------- Недельный 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;
}
// ---------------- Кристальная шахта (правила -- CrystalMine) ----------------
CrystalMine mine = new CrystalMine();
// Работающих шахт сейчас (достроенных)
public int CrystalMinesWorking
{
get
{
var from = City3DGameplay.CrystalMinesActiveFrom();
int n = 0;
if (from != null) foreach (long t in from) if (t <= GameClock.UnixNowMs) n++;
return n;
}
}
public double CrystalMineSecondsToNext { get { long ms = mine.MsToNext(CrystalMinesWorking); return ms < 0 ? -1 : ms / 1000.0; } }
public int CrystalMinePerPeriod => mine.PerPeriod;
public int CrystalMinePeriodDays => (int)(mine.PeriodMs / CrystalQuota.Day);
// Раз в секунду: выработка шахт до текущего момента (и офлайн -- при первом расчёте после входа).
// Не входит в недельный PvE-предел. Хранилище полное -- пауза
void ProcessCrystalMine()
{
if (ResourceManager.Instance == null) return; // склада нет -- не считать (время не теряется)
var from = City3DGameplay.CrystalMinesActiveFrom();
if (from == null) return; // город ещё не загружен
int got = mine.Advance(GameClock.UnixNowMs, from, CrystalRewards.Free(GemsHave, GemsCap));
Data.crystalMine = mine.ToLine();
if (got <= 0) return;
suppressTracking++;
ResourceManager.Instance.AddResource(ResourceType.Gems, got); // место проверено в Advance
suppressTracking--;
MarkChanged();
Save(); // прогресс шахты
if (SaveManager.Instance != null) SaveManager.Instance.SaveGame(); // и кристаллы -- сразу, без повторного начисления
UIKit.Toast($"Кристальная шахта: +{got} синих кристаллов");
}
// ---------------- Редкий 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 синий кристалл");
}
// Только для проверки (DEV): забыть недельную квоту
public void DevResetCrystalQuota()
{
quota.Load(null);
trader.Load(null);
Data.crystalQuota = quota.ToLines();
Data.crystalTrader = trader.ToLines();
MarkChanged();
}
string QuotaText(int compensated, int compGold) =>
$"Недельный предел синих кристаллов ({quota.Limit} за {quota.WindowMs / CrystalQuota.Day} дн.) исчерпан: вместо {compensated} кристаллов -- {compGold} золота";
// ---------------- Синие кристаллы при пределе 50 (регламент 05.11, ред. 1.1; правила -- CrystalRewards) ----------------
readonly CrystalRewards crystals = new CrystalRewards();
@@ -526,7 +673,7 @@ public class GameProgress : MonoBehaviour
if (!IsQuestDone(q, daily)) return;
int gems = GemsIn(q.rewards);
if (!crystals.CanStart(key, gems, GemsHave, GemsCap))
if (!crystals.CanStart(key, Mathf.Min(gems, quota.Left(GameClock.UnixNowMs)), GemsHave, GemsCap))
{
UIKit.Toast($"Нет места для кристаллов ({GemsHave:0} / {GemsCap:0}), и уже ждут {CrystalRewards.MaxWaiting} награды. " +
"Потратьте кристаллы или заберите ждущие -- награда этого задания сохранится");
@@ -536,14 +683,18 @@ public class GameProgress : MonoBehaviour
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);
// Квота -- один раз на задание (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);
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} кристаллов ждут места -- «Забрать» в заданиях";
if (gems - allowed > 0) text += ". " + QuotaText(gems - allowed, compGold);
UIKit.Toast("Получено: " + text);
}
@@ -632,7 +783,7 @@ public class GameProgress : MonoBehaviour
m.rewards.Clear();
if (left > 0) m.rewards.Add(new RewardItem(ResourceType.Gems, left)); // остаток ждёт в письме
else m.claimed = true;
Grant(give.ToArray(), 0, toast); // всё помещается: Grant ничего не отложит
Grant(give.ToArray(), 0, toast, false); // всё помещается; квота за них уже учтена при первой выдаче
if (toast && left > 0) UIKit.Toast($"Ещё {left} кристаллов ждут места -- в этом письме");
return left == 0;
}
@@ -787,7 +938,7 @@ public class GameProgress : MonoBehaviour
// Заказ не встал -- цену возвращаем целиком
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);
Grant(refund, 0, false, false); // возврат -- не новые кристаллы
UIKit.Toast(error);
return;
}
@@ -811,14 +962,14 @@ public class GameProgress : MonoBehaviour
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 сек
return EconomyConfig.SpeedUpGold(secs); // за золото (06.11, ТЕСТ; было 1 кристалл за 30 с)
}
}
public void SpeedUpTraining()
{
if (Training.Count == 0) return;
if (!Pay(new[] { new RewardItem(ResourceType.Gems, SpeedUpCost) })) return;
if (!Pay(new[] { new RewardItem(ResourceType.Gold, SpeedUpCost) })) return;
Army.FinishAllTraining(GameClock.UnixNowMs);
}
@@ -896,7 +1047,7 @@ public class GameProgress : MonoBehaviour
Data.battlesWon++;
Data.dailyBattles++;
AddEventPoints(10);
Grant(res.rewards, res.xp, false);
res.rewards = Grant(res.rewards, res.xp, false); // в окне итога -- что выдано на самом деле (квота, место)
}
else
{
+7
View File
@@ -71,6 +71,13 @@ public class ProgressData
public List<string> claimedDaily = new List<string>();
// Кристаллы наград заданий, которым не хватило места (регламент 05.11): "m:<id задания>|сколько"
public List<string> crystalWaiting = new List<string>();
// Недельный PvE-предел синих кристаллов (регламент валют 05.11): "время мс|сколько|id операции"
public List<string> crystalQuota = new List<string>();
// Покупки у редкого PvE-торговца (регламент валют 05.11, разд. 3): "время мс|id покупки"
public List<string> crystalTrader = new List<string>();
public int crystalTraderNextId = 1;
// Кристальная шахта: "прогресс мс|до какого момента посчитано мс" (ответ 06.11, п. 3)
public string crystalMine = "";
public float[] dailyCollected = new float[8];
public int dailyBattles;
public int dailyTroops;
+9 -7
View File
@@ -16,13 +16,21 @@ public class RewardsPanel : BasePanel
bool canClaim = GP.CanClaimLogin;
int total = GameData.LoginRewards.Length;
// Кнопка и таймер -- сверху: в горизонтальном экране окно низкое, и под календарём кнопка уходила
// за нижнюю панель HUD -- её не было видно (06.11)
var b = UIKit.Button(list, canClaim ? "ЗАБРАТЬ ПОДАРОК" : "Подарок получен", canClaim ? UIKit.Theme.Green : UIKit.Theme.Gray,
() => GP.ClaimLogin(), -1, 90, 32);
b.interactable = canClaim;
timer = UIKit.Text(list, "", 26, UIKit.Theme.TextDim, TextAlignmentOptions.Center, 40);
int claimedUpTo = d.loginRewardIndex; // сколько дней цикла уже забрано
if (!canClaim && claimedUpTo == 0 && d.lastLoginRewardDay >= 0) claimedUpTo = total; // только что забрали 7-й
RectTransform row = null;
for (int i = 0; i < total; i++)
{
if (i % 4 == 0) row = UIKit.Row(list, 230, 12);
if (i % 4 == 0) row = UIKit.Row(list, 170, 12);
bool claimed = i < claimedUpTo;
bool current = canClaim && i == d.loginRewardIndex;
@@ -39,12 +47,6 @@ public class RewardsPanel : BasePanel
claimed ? UIKit.Theme.Green : UIKit.Theme.Gold, TextAlignmentOptions.Center);
}
UIKit.Spacer(list, 10);
var b = UIKit.Button(list, canClaim ? "ЗАБРАТЬ ПОДАРОК" : "Подарок получен", canClaim ? UIKit.Theme.Green : UIKit.Theme.Gray,
() => GP.ClaimLogin(), -1, 110, 36);
b.interactable = canClaim;
timer = UIKit.Text(list, "", 26, UIKit.Theme.TextDim, TextAlignmentOptions.Center, 50);
Tick();
}
+28 -2
View File
@@ -1,8 +1,8 @@
using UnityEngine;
using TMPro;
// МАГАЗИН: бесплатный сундук раз в день, пакеты ресурсов за кристаллы, обмен золота,
// витрина платных наборов (пока "скоро"). Лимиты покупок сбрасываются в полночь.
// МАГАЗИН: редкий PvE-торговец (синий кристалл за золото), бесплатный сундук раз в день, пакеты ресурсов за золото.
// Лимиты пакетов сбрасываются в полночь; у торговца -- свои: не больше 2 кристаллов за 7 реальных суток.
public class ShopPanel : BasePanel
{
TextMeshProUGUI timer;
@@ -15,6 +15,8 @@ public class ShopPanel : BasePanel
WalletItem(wallet, ResourceType.Gems);
WalletItem(wallet, ResourceType.Gold);
TraderCard(list);
timer = UIKit.Text(list, "", 24, UIKit.Theme.TextDim, TextAlignmentOptions.Center, 40);
Tick();
@@ -52,6 +54,30 @@ public class ShopPanel : BasePanel
}
}
// Редкий PvE-торговец (регламент валют 05.11, разд. 3): дорогой обмен золота на синий кристалл
void TraderCard(Transform list)
{
var gp = GameProgress.Instance;
int price = gp.TraderPrice;
string block = gp.TraderBlockReason();
bool enough = price >= 0 && gp.Res(ResourceType.Gold) >= price;
var card = UIKit.Card(list, UIKit.Theme.CardDark);
var row = UIKit.Row(card, 150, 16);
UIKit.Icon(row, GameData.ResColor(ResourceType.Gems), "", 110);
var info = UIKit.Card(row, new Color(0, 0, 0, 0), 0, 4);
UIKit.Size(info, -1, -1, 1);
var title = UIKit.Text(info, "Редкий торговец: синий кристалл", 30);
title.fontStyle = FontStyles.Bold;
UIKit.Text(info, $"Куплено за 7 дней: {gp.TraderBought} / {gp.TraderMaxPurchases}. Входит в недельный предел новых кристаллов " +
$"({gp.CrystalQuotaUsed} / {gp.CrystalQuotaLimit})", 22, UIKit.Theme.TextDim);
if (block != null) UIKit.Text(info, block, 22, UIKit.Theme.Gold);
string label = price < 0 ? "Недоступно" : price.ToString("N0") + " золота";
var b = UIKit.Button(row, label, block == null && enough ? UIKit.Theme.Blue : UIKit.Theme.Gray, () => { gp.BuyCrystalFromTrader(); RequestRebuild(); }, 260, 100, 24);
b.interactable = block == null;
}
static void WalletItem(Transform parent, ResourceType t)
{
UIKit.Icon(parent, GameData.ResColor(t), "", 40);
+2 -2
View File
@@ -4,7 +4,7 @@ using UnityEngine.UI;
using TMPro;
// ВОЙСКА: армия, очередь обучения (идёт и когда игра закрыта), найм воинов за ресурсы,
// ускорение за кристаллы. Найм -- карточки войск в ряд (листаются вбок): иконка, сила, цена,
// ускорение за золото. Найм -- карточки войск в ряд (листаются вбок): иконка, сила, цена,
// против кого силён и слаб (из правил боя). Новые типы войск открывают уровни Ратуши и Казарм.
public class TroopsPanel : BasePanel
{
@@ -293,6 +293,6 @@ public class TroopsPanel : BasePanel
queueTimes[i].text = GameData.FormatTime(GP.TrainingSecondsLeft(o));
}
if (speedUp != null && training.Count > 0)
UIKit.SetButton(speedUp, "Ускорить всё за " + GP.SpeedUpCost + " кристаллов", true);
UIKit.SetButton(speedUp, "Ускорить всё за " + GP.SpeedUpCost + " золота", true);
}
}
@@ -231,7 +231,7 @@ public struct ArmyOnMap
}
// Цель разведки (ТЗ разведки, раздел 6): что за объект -- от этого время наблюдения
public enum ReconTargetKind { None, Settlement, Castle, Camp }
public enum ReconTargetKind { None, Settlement, Castle, Camp, Army } // Army -- движущаяся армия (id "march_<номер>")
// Снимок цели разведки от Мира -- то, что увидят разведчики в момент отчёта
public struct ReconTarget
@@ -30,6 +30,10 @@ public class ResourceManager : MonoBehaviour, IEconomy, ISaveParticipant
// запоминаются в сохранении: на карте мира город не открыт, а лимиты действуют.
private Dictionary<ResourceType, float> capacities = new Dictionary<ResourceType, float>();
// Сколько платных расширений хранилища синих кристаллов куплено (регламент валют 05.11, разд. 8).
// Вместимость по ней считает слой приложения (EconomyConfig.CrystalStorageNow); здесь -- только хранится
public int CrystalStorageLevel { get; set; }
// Лимит изменился (для интерфейса: "запас / лимит")
public event Action<ResourceType, float> OnCapacityChanged;
@@ -189,13 +193,14 @@ public class ResourceManager : MonoBehaviour, IEconomy, ISaveParticipant
{
public List<ResourceSaveEntry> resources = new List<ResourceSaveEntry>();
public List<ResourceSaveEntry> capacities = new List<ResourceSaveEntry>(); // лимиты склада (этап 1)
public int crystalStorageLevel; // купленные расширения хранилища кристаллов (06.11); нет в старом файле -- 0
}
public string SaveKey => "economy";
public string Capture()
{
var data = new EconomySaveData();
var data = new EconomySaveData { crystalStorageLevel = CrystalStorageLevel };
foreach (var kvp in resources)
data.resources.Add(new ResourceSaveEntry { type = kvp.Key, amount = kvp.Value });
foreach (var kvp in capacities)
@@ -207,6 +212,7 @@ public class ResourceManager : MonoBehaviour, IEconomy, ISaveParticipant
{
var data = JsonUtility.FromJson<EconomySaveData>(json);
if (data?.resources == null) return;
CrystalStorageLevel = Mathf.Max(0, data.crystalStorageLevel);
if (data.capacities != null)
foreach (var entry in data.capacities) SetCapacity(entry.type, entry.amount);
foreach (var entry in data.resources) SetResource(entry.type, entry.amount);
@@ -215,6 +221,7 @@ public class ResourceManager : MonoBehaviour, IEconomy, ISaveParticipant
// Новая игра -- стартовые значения из Inspector
public void ResetState()
{
CrystalStorageLevel = 0;
SetStartValues(notify: true);
}
}
@@ -0,0 +1,83 @@
using System;
using System.Collections.Generic;
using System.Globalization;
// КРИСТАЛЬНАЯ ШАХТА: выработка синих кристаллов (Регламент редкости синих кристаллов от 05.11.2026, разд. 2.1;
// Регламент валют от 05.11, разд. 5.2 и 8.1; «Ответ Володе» от 06.11, п. 3). Документ: Docs/Modules/Economy.md.
//
// - Каждая работающая шахта -- PerPeriod (ТЕСТ 3) кристаллов за PeriodMs (7 РЕАЛЬНЫХ суток; не игровые часы ×12),
// равномерно: один кристалл за PeriodMs / PerPeriod. Шахты (до двух) складываются.
// - Шахта работает с момента, когда достроена (activeFromMs): стройка, закончившаяся офлайн, добывает с её конца.
// - Хранилище полное -- пауза: прогресс не растёт, скрытого запаса нет. Хранилище заполнилось в ходе расчёта --
// шахта встаёт сразу после последнего поместившегося кристалла (прогресс 0). Освободилось место -- выработка идёт
// дальше с оставшегося прогресса, без начисления за время паузы.
// - Офлайн: досчёт по меткам при входе; уже посчитанное время второй раз не начисляется; часы назад (сброс
// DEV-перемотки) -- метка не уходит назад, ничего не начисляется.
// - В недельный PvE-предел не входит (отдельный учёт).
// Без Unity: те же правила будут на сервере и в тестах.
public sealed class CrystalMine
{
public readonly int PerPeriod;
public readonly long PeriodMs;
// Прогресс к следующему кристаллу (мс работы одной шахты) и до какого момента посчитано
public long ProgressMs { get; private set; }
public long LastMs { get; private set; }
public CrystalMine(int perPeriod = 3, long periodMs = 7 * CrystalQuota.Day)
{
PerPeriod = Math.Max(1, perPeriod);
PeriodMs = Math.Max(1, periodMs);
}
public long MsPerCrystal => Math.Max(1, PeriodMs / PerPeriod);
// Досчитать до nowMs. mineActiveFromMs -- по шахте: с какого момента она работает (long.MinValue -- давно).
// freeSpace -- сколько кристаллов помещается в хранилище. Возвращает, сколько кристаллов начислить
public int Advance(long nowMs, IEnumerable<long> mineActiveFromMs, int freeSpace)
{
if (LastMs == 0) { LastMs = nowMs; return 0; } // первый расчёт: отсюда и считаем
if (nowMs <= LastMs) return 0; // часы назад или то же время -- ничего
long from = LastMs;
LastMs = nowMs;
if (freeSpace <= 0) return 0; // пауза: хранилище полное, прогресс не растёт
long work = 0;
if (mineActiveFromMs != null)
foreach (long active in mineActiveFromMs)
{
long start = Math.Max(from, active);
if (start < nowMs) work += nowMs - start;
}
if (work <= 0) return 0;
long total = ProgressMs + work;
long made = total / MsPerCrystal;
if (made <= freeSpace)
{
ProgressMs = total - made * MsPerCrystal;
return (int)made;
}
ProgressMs = 0; // хранилище заполнилось: шахта встала сразу после последнего кристалла, лишнее не копится
return freeSpace;
}
// Через сколько мс следующий кристалл при count работающих шахтах (0 шахт -- -1)
public long MsToNext(int count) => count <= 0 ? -1 : Math.Max(0, (MsPerCrystal - ProgressMs + count - 1) / count);
public void Load(string line)
{
ProgressMs = 0; LastMs = 0;
if (string.IsNullOrEmpty(line)) return;
var p = line.Split('|');
if (p.Length >= 2
&& long.TryParse(p[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out long progress)
&& long.TryParse(p[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out long last))
{
ProgressMs = Math.Max(0, Math.Min(progress, MsPerCrystal - 1));
LastMs = Math.Max(0, last);
}
}
public string ToLine() => ProgressMs.ToString(CultureInfo.InvariantCulture) + "|" + LastMs.ToString(CultureInfo.InvariantCulture);
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 5f3b9d1e8a2c4e70a6d4b8c1f9e27d36
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,94 @@
using System;
using System.Collections.Generic;
using System.Globalization;
// НЕДЕЛЬНЫЙ PvE-ПРЕДЕЛ СИНИХ КРИСТАЛЛОВ (Регламент валют, кристаллов и торговли от 05.11.2026, разд. 5.2, 10, 12;
// «Ответ Володе» от 06.11.2026, п. 2). Документ: Docs/Modules/Economy.md, раздел «Синие кристаллы».
//
// - Не больше Limit (ТЕСТ 6) НОВЫХ синих кристаллов за скользящие WindowMs (7 реальных суток) из PvE: задания,
// события, бои, PvE-торговец. Кристальная шахта, торговля и грабёж -- вне предела.
// - Take: сколько из награды можно выдать кристаллами сейчас; остальное вызывающий заменяет компенсацией
// (золото / ресурсы). На следующую неделю излишек не переносится.
// - Квота и место в хранилище -- разные условия: взятое по квоте считается сразу, даже если кристаллы потом
// ждут места, -- второй раз квота за них не тратится.
// - Операция с тем же id второй раз квоту не берёт (повторное «Забрать», загрузка).
// - Время -- метки (мс), окно скользящее: запись старше окна больше не считается. На сервере -- доверенное время.
// Без Unity: те же правила будут на сервере и в тестах.
public sealed class CrystalQuota
{
public const long Day = 24L * 60 * 60 * 1000;
public readonly int Limit;
public readonly long WindowMs;
struct Entry { public long TimeMs; public int Amount; public string Id; }
readonly List<Entry> entries = new List<Entry>();
public CrystalQuota(int limit = 6, long windowMs = 7 * Day)
{
Limit = Math.Max(0, limit);
WindowMs = Math.Max(1, windowMs);
}
// Сколько новых PvE-кристаллов уже выдано за окно, кончающееся в nowMs
public int Used(long nowMs)
{
int n = 0;
foreach (var e in entries) if (e.TimeMs > nowMs - WindowMs && e.TimeMs <= nowMs) n += e.Amount;
return n;
}
public int Left(long nowMs) => Math.Max(0, Limit - Used(nowMs));
// Когда освободится хотя бы один кристалл квоты (мс); 0 -- уже свободно
public long NextFreeMs(long nowMs)
{
if (Left(nowMs) > 0) return 0;
long first = long.MaxValue;
foreach (var e in entries) if (e.TimeMs > nowMs - WindowMs && e.TimeMs < first) first = e.TimeMs;
return first == long.MaxValue ? 0 : first + WindowMs;
}
public bool Taken(string id) => !string.IsNullOrEmpty(id) && entries.Exists(e => e.Id == id);
// Взять квоту под награду: сколько из amount выдать кристаллами (остальное -- компенсацией).
// Тот же id второй раз -- 0 (уже учтено). Записи старше окна убираются
public int Take(string id, int amount, long nowMs)
{
Prune(nowMs);
if (amount <= 0 || Taken(id)) return 0;
int give = Math.Min(amount, Left(nowMs));
if (give > 0 || !string.IsNullOrEmpty(id)) entries.Add(new Entry { TimeMs = nowMs, Amount = give, Id = id ?? "" });
return give;
}
// Можно ли взять amount целиком (покупка у PvE-торговца: при нехватке -- блок, золото не списывается)
public bool CanTakeAll(int amount, long nowMs) => amount <= Left(nowMs);
void Prune(long nowMs) => entries.RemoveAll(e => e.TimeMs <= nowMs - WindowMs);
// ---------------- Сохранение: строки "время|сколько|id" ----------------
public List<string> ToLines()
{
var lines = new List<string>();
foreach (var e in entries)
lines.Add(e.TimeMs.ToString(CultureInfo.InvariantCulture) + "|" + e.Amount.ToString(CultureInfo.InvariantCulture) + "|" + e.Id);
return lines;
}
public void Load(IEnumerable<string> lines)
{
entries.Clear();
if (lines == null) return;
foreach (var line in lines)
{
if (string.IsNullOrEmpty(line)) continue;
var p = line.Split(new[] { '|' }, 3);
if (p.Length < 2) continue;
if (!long.TryParse(p[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out long t)) continue;
if (!int.TryParse(p[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out int n) || n < 0) continue;
entries.Add(new Entry { TimeMs = t, Amount = n, Id = p.Length > 2 ? p[2] : "" });
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 9e1f4c7a2b6d48a3a8c5f0d2e7b39c14
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,63 @@
using System;
using System.Collections.Generic;
// ХРАНИЛИЩЕ СИНИХ КРИСТАЛЛОВ: вместимость и платное расширение
// (Регламент валют, кристаллов и торговли от 05.11.2026, разд. 8; «Ответ Володе» от 06.11.2026, п. 1).
// Документ: Docs/Modules/Economy.md, раздел «Синие кристаллы».
//
// - Старт -- BaseCapacity (ТЕСТ 50). Ступени (ТЕСТ): Ратуша 3 -- 75, 5 -- 100, 7 -- 150, 9 -- 200.
// - Ступень НЕ открывается сама с уровнем Ратуши: Ратуша только разрешает её, игрок покупает за золото и
// обычные ресурсы. Ступени -- по порядку, одна покупка -- одна ступень.
// - Цена списывается один раз: ступень растёт, только если списание прошло (всё или ничего). Повторное
// нажатие без следующей ступени или без нужной Ратуши ничего не списывает.
// - Сохраняется номер купленной ступени (Level), вместимость по нему считается заново -- числа можно менять.
// Без Unity: те же правила будут на сервере и в тестах. Числа и цены даёт слой приложения (EconomyConfig).
public sealed class CrystalStorage
{
public sealed class Tier
{
public int TownHallLevel; // с какого уровня Ратуши можно купить
public int Capacity; // вместимость после покупки
public ResourceAmount[] Cost; // цена: золото и обычные ресурсы
}
public readonly int BaseCapacity;
readonly List<Tier> tiers;
public CrystalStorage(int baseCapacity, IEnumerable<Tier> tiers)
{
BaseCapacity = Math.Max(0, baseCapacity);
this.tiers = new List<Tier>();
if (tiers != null) foreach (var t in tiers) if (t != null) this.tiers.Add(t);
}
// Сколько ступеней куплено (0 -- только стартовая вместимость)
public int Level { get; private set; }
public int TierCount => tiers.Count;
public int Capacity => Level <= 0 ? BaseCapacity : Math.Max(BaseCapacity, tiers[Level - 1].Capacity);
// Следующая ступень; null -- все куплены
public Tier Next => Level < tiers.Count ? tiers[Level] : null;
// Восстановить из сохранения (номер ступени; лишнее обрезается, если ступеней стало меньше)
public void SetLevel(int level) => Level = Math.Max(0, Math.Min(level, tiers.Count));
// Почему сейчас нельзя расширить: null -- можно (если хватит ресурсов -- это проверяет списание)
public string WhyNot(int townHallLevel)
{
var next = Next;
if (next == null) return "Хранилище расширено до предела";
if (townHallLevel < next.TownHallLevel) return $"Расширение до {next.Capacity} откроется на Ратуше {next.TownHallLevel} ур.";
return null;
}
// Купить следующую ступень. trySpend -- списание цены «всё или ничего» (Экономика). true -- ступень куплена
public bool TryExpand(int townHallLevel, Func<IReadOnlyList<ResourceAmount>, bool> trySpend)
{
if (WhyNot(townHallLevel) != null || trySpend == null) return false;
if (!trySpend(Next.Cost ?? new ResourceAmount[0])) return false;
Level++;
return true;
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 4b8d2f6a1c9e47d3b5a0e7f3c2d19a85
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,99 @@
using System;
using System.Collections.Generic;
using System.Globalization;
// РЕДКИЙ PvE-ТОРГОВЕЦ: синий кристалл за золото (Регламент валют, кристаллов и торговли от 05.11.2026, разд. 3, 10, 12;
// «Ответ Володе» от 06.11.2026, п. 2). Документ: Docs/Modules/Economy.md, раздел «Синие кристаллы».
//
// - За скользящие 7 реальных суток: 1-я покупка -- Prices[0] (ТЕСТ 30 000 золота), 2-я -- Prices[1] (60 000),
// дальше -- недоступно (не больше Prices.Length покупок).
// - Покупка входит в недельный PvE-предел (CrystalQuota). Нет квоты -- покупка заблокирована, золото НЕ списывается,
// компенсации за оплаченный товар нет.
// - Нет места в хранилище -- тоже блок без списания (вместимость получателя проверяется до оплаты).
// - Порядок: все проверки -> списание золота (всё или ничего) -> квота -> запись покупки. Одна операция -- один id,
// повтор того же id ничего не делает.
// Без Unity: те же правила будут на сервере и в тестах.
public sealed class CrystalTrader
{
public enum Result { Bought, NoSlots, NoQuota, NoSpace, NoGold, Repeated }
public readonly int[] Prices;
public readonly long WindowMs;
struct Purchase { public long TimeMs; public string Id; }
readonly List<Purchase> purchases = new List<Purchase>();
public CrystalTrader(int[] prices = null, long windowMs = 7 * CrystalQuota.Day)
{
Prices = prices != null && prices.Length > 0 ? prices : new[] { 30000, 60000 };
WindowMs = Math.Max(1, windowMs);
}
public int BoughtInWindow(long nowMs)
{
int n = 0;
foreach (var p in purchases) if (p.TimeMs > nowMs - WindowMs && p.TimeMs <= nowMs) n++;
return n;
}
// Цена следующей покупки; -1 -- в этом окне больше нельзя
public int PriceNow(long nowMs)
{
int n = BoughtInWindow(nowMs);
return n < Prices.Length ? Prices[n] : -1;
}
// Когда снова можно купить (мс), если покупки кончились; 0 -- можно сейчас
public long NextSlotMs(long nowMs)
{
if (PriceNow(nowMs) >= 0) return 0;
long first = long.MaxValue;
foreach (var p in purchases) if (p.TimeMs > nowMs - WindowMs && p.TimeMs < first) first = p.TimeMs;
return first == long.MaxValue ? 0 : first + WindowMs;
}
// Что помешает купить сейчас (без списания); Bought -- можно
public Result Check(long nowMs, CrystalQuota quota, int freeSpace)
{
if (PriceNow(nowMs) < 0) return Result.NoSlots;
if (quota != null && !quota.CanTakeAll(1, nowMs)) return Result.NoQuota;
if (freeSpace < 1) return Result.NoSpace;
return Result.Bought;
}
// Купить 1 синий кристалл. trySpendGold(цена) -- списание «всё или ничего». При Bought кристалл начисляет вызывающий
public Result TryBuy(string id, long nowMs, CrystalQuota quota, int freeSpace, Func<int, bool> trySpendGold)
{
if (!string.IsNullOrEmpty(id) && purchases.Exists(p => p.Id == id)) return Result.Repeated;
var check = Check(nowMs, quota, freeSpace);
if (check != Result.Bought) return check;
int price = PriceNow(nowMs);
if (trySpendGold == null || !trySpendGold(price)) return Result.NoGold;
if (quota != null) quota.Take(id, 1, nowMs);
purchases.RemoveAll(p => p.TimeMs <= nowMs - WindowMs);
purchases.Add(new Purchase { TimeMs = nowMs, Id = id ?? "" });
return Result.Bought;
}
// ---------------- Сохранение: строки "время|id" ----------------
public List<string> ToLines()
{
var lines = new List<string>();
foreach (var p in purchases) lines.Add(p.TimeMs.ToString(CultureInfo.InvariantCulture) + "|" + p.Id);
return lines;
}
public void Load(IEnumerable<string> lines)
{
purchases.Clear();
if (lines == null) return;
foreach (var line in lines)
{
if (string.IsNullOrEmpty(line)) continue;
var p = line.Split(new[] { '|' }, 2);
if (!long.TryParse(p[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out long t)) continue;
purchases.Add(new Purchase { TimeMs = t, Id = p.Length > 1 ? p[1] : "" });
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c25a8e3f7d1b4c69b0e4a6f9d8c37b21
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -26,6 +26,7 @@ public sealed class IntelligenceRules
public int[] ReconSettlementGameHours = { 2, 4 };
public int[] ReconCastleGameHours = { 3, 6 };
public int[] ReconCampGameHours = { 2, 4 }; // лагеря в ТЗ не названы -- как поселение (ТЕСТ)
public int[] ReconArmyGameHours = { 1, 2 }; // вражеская армия (ТЗ, раздел 6): II -- 1, III -- 2 игровых часа
public long GameHourMs = 5 * 60 * 1000; // 1 игровой час = 5 реальных минут
public int ResourceEstimatePercent = 15; // уровень III: ресурсы -- оценочный диапазон ±15%
public int MaxReports = 30; // храним последние отчёты
@@ -274,7 +275,8 @@ public sealed class IntelligenceService : IIntelligence, ISaveParticipant
if (map == null || level < 2 || level > 3 || !map.TryGetReconTarget(targetId, out ReconTarget t)) return 0;
int[] hours = t.Kind == ReconTargetKind.Castle ? rules.ReconCastleGameHours
: t.Kind == ReconTargetKind.Camp ? rules.ReconCampGameHours
: t.Kind == ReconTargetKind.Settlement ? rules.ReconSettlementGameHours : null;
: t.Kind == ReconTargetKind.Settlement ? rules.ReconSettlementGameHours
: t.Kind == ReconTargetKind.Army ? rules.ReconArmyGameHours : null;
if (hours == null || hours.Length < level - 1) return 0;
return Math.Max(0, hours[level - 2]) * rules.GameHourMs;
}
+100
View File
@@ -2182,4 +2182,104 @@ public class ModuleTests
Assert.AreEqual(5, m.survivors, "разведка без потерь");
Assert.IsTrue(intel.CompleteRecon("recon_" + m.id, m.targetNodeId, m.reconLevel, m.gatherEndMs));
}
// ---------------- Разведка М2: движущаяся армия (ТЗ разведки, раздел 6: II -- 1, III -- 2 игровых часа) ----------------
// Медленная чужая армия с долгим маршрутом рядом с игроком (настоящие патрули идут ~40 мин -- их часто не догнать,
// и это правильно: разведать можно армию, которая проходит рядом). Новые патрули не выходят: место занято ею
static WorldSimulation ArmyReconWorld(long t0, out MarchData patrol)
{
var world = PatrolWorld(true);
world.Patrols = new PatrolRules { Enabled = true, MaxActive = 1, IntervalMs = 24 * 3_600_000L };
patrol = new MarchData
{
id = 500, ownerId = "npc_test", fromNodeId = "city_14", targetNodeId = "city_15", soldiers = 20, survivors = 20,
departMs = t0, arriveMs = t0 + 10 * 3_600_000L, returnMs = t0 + 20 * 3_600_000L, state = MarchState.Outbound, patrol = true
};
world.LoadMarches(new List<MarchData> { patrol });
return world;
}
[Test]
public void ArmyRecon_Duration_1and2GameHours()
{
var map = new FakeMap();
map.Targets["march_7"] = new ReconTarget { Id = "march_7", Name = "Армия", Kind = ReconTargetKind.Army, Units = new[] { 20, 0, 0, 0 } };
var intel = Intel(map);
const long H = 5 * 60_000;
Assert.AreEqual(1 * H, intel.ReconDurationMs("march_7", 2), "вражеская армия, уровень II -- 1 игровой час");
Assert.AreEqual(2 * H, intel.ReconDurationMs("march_7", 3), "уровень III -- 2 игровых часа");
}
[Test]
public void ArmyRecon_Intercepts_FollowsTarget_ReportsThenReturns()
{
const long H = 5 * 60_000;
long t0 = 1_000_000_000_000;
var world = ArmyReconWorld(t0, out MarchData patrol);
long start = t0 + 60_000;
MarchData m = world.StartArmyRecon(LocalPlayer.Id, WorldMapLayout.PlayerCityId, patrol.id, 5, start, 2, H, out string error);
Assert.IsNotNull(m, error);
Assert.IsTrue(WorldSimulation.IsArmyRecon(m));
world.AssignUnits(m, new[] { 5, 0, 0, 0 });
Assert.IsTrue(m.arriveMs > start, "путь до перехвата -- физический, не мгновенный");
Assert.AreEqual(m.arriveMs + H, m.gatherEndMs, "наблюдение -- 1 игровой час");
// Точка перехвата -- там, где армия в момент перехвата
world.TryGetPositionAt(patrol, m.arriveMs, out int px, out int py, out _, out _, out _);
Assert.AreEqual(px, m.meetX); Assert.AreEqual(py, m.meetY);
var events = new List<WorldEvent>();
world.Advance(m.arriveMs, events);
Assert.AreEqual(WorldEventType.ScoutArrived, events[events.Count - 1].Type);
Assert.AreEqual(MarchState.Gathering, m.state, "догнали -- наблюдают");
// Во время наблюдения разведчики идут рядом с армией
long mid = m.arriveMs + H / 2;
world.TryGetPositionAt(patrol, mid, out int ax, out int ay, out _, out _, out _);
world.TryGetPositionAt(m, mid, out int sx, out int sy, out _, out _, out _);
Assert.AreEqual(ax, sx); Assert.AreEqual(ay, sy);
events.Clear();
world.Advance(m.returnMs, events);
var mine = events.FindAll(e => e.March == m);
Assert.AreEqual(WorldEventType.ReconCompleted, mine[0].Type, "сначала отчёт");
Assert.AreEqual(m.gatherEndMs, mine[0].TimeMs, "отчёт -- на конец наблюдения");
Assert.AreEqual(WorldEventType.MarchReturned, mine[1].Type, "потом -- домой");
Assert.AreEqual(5, m.survivors, "разведка без боя и потерь");
Assert.IsTrue(m.returnMs > m.gatherEndMs, "домой идут пешком от места, где кончилось наблюдение");
}
[Test]
public void ArmyRecon_Refused_IfArmyLeavesBeforeObservationEnds_OrIsOwn()
{
const long H = 5 * 60_000;
long t0 = 1_000_000_000_000;
var world = ArmyReconWorld(t0, out MarchData patrol);
int before = world.Marches.Count;
// Армия почти дома: наблюдение на 2 часа не успеть
long late = patrol.returnMs - 30_000;
Assert.IsNull(world.StartArmyRecon(LocalPlayer.Id, WorldMapLayout.PlayerCityId, patrol.id, 5, late, 3, 2 * H, out string err));
Assert.IsTrue(err.Contains("не догнать") || err.Contains("раньше"), err);
Assert.AreEqual(before, world.Marches.Count, "поход не создан");
Assert.IsNull(world.StartArmyRecon(patrol.ownerId, WorldMapLayout.PlayerCityId, patrol.id, 5, t0, 2, H, out err), "свою армию не разведывают");
Assert.IsNull(world.StartArmyRecon(LocalPlayer.Id, WorldMapLayout.PlayerCityId, 99999, 5, t0, 2, H, out err), "нет такой армии");
}
[Test]
public void ArmyRecon_TargetGone_BeforeIntercept_ReturnsWithoutReport()
{
const long H = 5 * 60_000;
long t0 = 1_000_000_000_000;
var world = ArmyReconWorld(t0, out MarchData patrol);
MarchData m = world.StartArmyRecon(LocalPlayer.Id, WorldMapLayout.PlayerCityId, patrol.id, 5, t0 + 1000, 2, H, out string error);
Assert.IsNotNull(m, error);
world.Patrols = new PatrolRules { Enabled = false }; // патрули выключили -- цель пропала
var events = new List<WorldEvent>();
world.SpawnPatrolsUntil(t0 + 2000);
world.Advance(m.returnMs + 24 * 3_600_000L, events);
Assert.IsTrue(m.targetEmpty, "цели нет -- дошли до места и вернулись");
Assert.IsFalse(events.Exists(e => e.March == m && e.Type == WorldEventType.ReconCompleted), "без отчёта");
Assert.IsTrue(events.Exists(e => e.March == m && e.Type == WorldEventType.MarchReturned), "вернулись");
}
}
@@ -429,3 +429,307 @@ public class CrystalRewardsTests
Assert.AreEqual(10, CrystalRewards.Fits(10, 0, float.PositiveInfinity), "без предела -- целиком");
}
}
// Хранилище синих кристаллов: платное расширение по уровню Ратуши (регламент валют 05.11, разд. 8 и 12)
public class CrystalStorageTests
{
static CrystalStorage Storage() => new CrystalStorage(50, new[]
{
new CrystalStorage.Tier { TownHallLevel = 3, Capacity = 75, Cost = new[] { new ResourceAmount(ResourceType.Gold, 2000) } },
new CrystalStorage.Tier { TownHallLevel = 5, Capacity = 100, Cost = new[] { new ResourceAmount(ResourceType.Gold, 4000) } },
new CrystalStorage.Tier { TownHallLevel = 7, Capacity = 150, Cost = new[] { new ResourceAmount(ResourceType.Gold, 7000) } },
new CrystalStorage.Tier { TownHallLevel = 9, Capacity = 200, Cost = new[] { new ResourceAmount(ResourceType.Gold, 10000) } },
});
[Test]
public void Storage_NotAutomatic_TownHallOnlyAllows()
{
var s = Storage();
Assert.AreEqual(50, s.Capacity, "старт 50");
int spent = 0;
Assert.IsFalse(s.TryExpand(2, c => { spent++; return true; }), "Ратуша 2 -- расширения нет");
Assert.AreEqual(0, spent, "ничего не списано");
Assert.AreEqual(50, s.Capacity, "Ратуша 3 сама вместимость не меняет -- только разрешает");
Assert.IsNull(s.WhyNot(3));
}
[Test]
public void Storage_50to75_PaidOnce_AndKeptAfterSaveLoad()
{
var s = Storage();
float gold = 2500;
System.Func<IReadOnlyList<ResourceAmount>, bool> pay = cost =>
{
float need = 0;
foreach (var c in cost) if (c.Type == ResourceType.Gold) need += c.Amount;
if (gold < need) return false;
gold -= need;
return true;
};
Assert.IsTrue(s.TryExpand(3, pay));
Assert.AreEqual(75, s.Capacity, "50 -> 75 после оплаты");
Assert.AreEqual(500, gold, 0.01, "списано один раз");
Assert.IsFalse(s.TryExpand(3, pay), "повторное нажатие на Ратуше 3 -- следующая ступень ещё закрыта");
Assert.AreEqual(500, gold, 0.01, "второй раз не списано");
var loaded = Storage();
loaded.SetLevel(s.Level);
Assert.AreEqual(75, loaded.Capacity, "после сохранения и загрузки -- 75");
}
[Test]
public void Storage_NotEnoughResources_NothingChanges()
{
var s = Storage();
Assert.IsFalse(s.TryExpand(9, c => false), "не хватило -- списание не прошло");
Assert.AreEqual(0, s.Level);
Assert.AreEqual(50, s.Capacity);
}
[Test]
public void Storage_TiersInOrder_UpTo200()
{
var s = Storage();
for (int i = 0; i < 4; i++) Assert.IsTrue(s.TryExpand(10, c => true));
Assert.AreEqual(200, s.Capacity);
Assert.IsNull(s.Next);
Assert.IsFalse(s.TryExpand(10, c => true), "дальше 200 -- нет");
s.SetLevel(99);
Assert.AreEqual(4, s.Level, "лишние ступени из сохранения обрезаются");
}
[Test]
public void Storage_Waiting_ClaimedOnceAfterExpansion()
{
// 49/50 + допустимая награда 2 -> 1 сразу и 1 ждёт; после расширения ждущий забирается один раз
var s = Storage();
var r = new CrystalRewards();
float have = 49;
have += r.Claim("q", 2, have, s.Capacity);
Assert.AreEqual(50, have, 0.01);
Assert.AreEqual(1, r.Left("q"));
s.TryExpand(3, c => true);
have += r.Claim("q", 2, have, s.Capacity);
Assert.AreEqual(51, have, 0.01, "после расширения выдан ждущий 1");
Assert.AreEqual(0, r.Left("q"), "больше ничего не ждёт -- выдан ровно один раз");
}
}
// Недельный PvE-предел синих кристаллов (регламент валют 05.11, разд. 5.2, 10, 12; ответ 06.11, п. 2)
public class CrystalQuotaTests
{
const long T0 = 1_800_000_000_000L;
const long Day = CrystalQuota.Day;
[Test]
public void Quota_5of6_Reward2_OneCrystal_OneCompensated()
{
var q = new CrystalQuota(6, 7 * Day);
Assert.AreEqual(5, q.Take("a", 5, T0));
Assert.AreEqual(1, q.Take("b", 2, T0 + 1000), "5/6 + 2 -> 1 кристалл (второй -- компенсацией)");
Assert.AreEqual(0, q.Left(T0 + 1000));
}
[Test]
public void Quota_6of6_Purchase_Blocked()
{
var q = new CrystalQuota(6, 7 * Day);
q.Take("a", 6, T0);
Assert.IsFalse(q.CanTakeAll(1, T0 + 1), "6/6 -- покупка у торговца блокируется (золото не списывается)");
Assert.AreEqual(0, q.Take("b", 1, T0 + 1));
}
[Test]
public void Quota_SameOperationTwice_CountedOnce()
{
var q = new CrystalQuota(6, 7 * Day);
Assert.AreEqual(2, q.Take("event_5", 2, T0));
Assert.AreEqual(0, q.Take("event_5", 2, T0 + 5), "повторное «Забрать» -- квота не берётся второй раз");
Assert.AreEqual(2, q.Used(T0 + 5));
}
[Test]
public void Quota_RollingWindow_FreesAfter7Days_NoCarryOver()
{
var q = new CrystalQuota(6, 7 * Day);
q.Take("a", 6, T0);
Assert.AreEqual(0, q.Left(T0 + 7 * Day - 1), "ещё не прошло 7 суток");
Assert.AreEqual(T0 + 7 * Day, q.NextFreeMs(T0 + Day));
Assert.AreEqual(6, q.Left(T0 + 7 * Day), "через 7 суток квота снова 6, а не 6 + перенос");
Assert.AreEqual(6, q.Take("b", 9, T0 + 7 * Day), "сверх 6 -- компенсацией, на следующую неделю не переносится");
}
[Test]
public void Quota_SurvivesSaveAndLoad()
{
var q = new CrystalQuota(6, 7 * Day);
q.Take("a", 4, T0);
var copy = new CrystalQuota(6, 7 * Day);
copy.Load(q.ToLines());
Assert.AreEqual(2, copy.Left(T0 + Day), "после перезапуска -- те же 4 из 6");
Assert.IsTrue(copy.Taken("a"));
}
[Test]
public void Quota_SeparateFromStorage_WaitingNotCountedTwice()
{
// Квота есть, а места нет: выданное по квоте ждёт места и второй раз квоту не тратит
var q = new CrystalQuota(6, 7 * Day);
var r = new CrystalRewards();
int byQuota = q.Take("q1", 2, T0);
Assert.AreEqual(2, byQuota);
Assert.AreEqual(0, r.Claim("q1", byQuota, 50, 50), "хранилище 50/50 -- ничего не начислено");
Assert.AreEqual(2, r.Left("q1"), "2 ждут места");
Assert.AreEqual(2, r.Claim("q1", 0, 40, 50), "место появилось -- выданы ждущие");
Assert.AreEqual(2, q.Used(T0 + 10), "квота потрачена один раз: 2, а не 4");
}
}
// Редкий PvE-торговец: синий кристалл за золото (регламент валют 05.11, разд. 3, 10, 12)
public class CrystalTraderTests
{
const long T0 = 1_800_000_000_000L;
const long Day = CrystalQuota.Day;
static System.Func<int, bool> Wallet(float[] gold) => price =>
{
if (gold[0] < price) return false;
gold[0] -= price;
return true;
};
[Test]
public void Trader_Quota4of6_Buys_ChargesGold_CountsQuota_Max2()
{
var quota = new CrystalQuota(6, 7 * Day);
quota.Take("old", 4, T0);
var t = new CrystalTrader(new[] { 30000, 60000 }, 7 * Day);
var gold = new[] { 100000f };
Assert.AreEqual(CrystalTrader.Result.Bought, t.TryBuy("b1", T0 + 1, quota, 10, Wallet(gold)));
Assert.AreEqual(70000f, gold[0], 0.01, "первая -- 30 000");
Assert.AreEqual(5, quota.Used(T0 + 1), "квота +1");
Assert.AreEqual(CrystalTrader.Result.Bought, t.TryBuy("b2", T0 + 2, quota, 10, Wallet(gold)));
Assert.AreEqual(10000f, gold[0], 0.01, "вторая -- 60 000");
Assert.AreEqual(CrystalTrader.Result.NoSlots, t.TryBuy("b3", T0 + 3, quota, 10, Wallet(gold)), "третьей нет");
Assert.AreEqual(10000f, gold[0], 0.01, "золото не списано");
}
[Test]
public void Trader_Quota6of6_Blocked_NoGoldSpent()
{
var quota = new CrystalQuota(6, 7 * Day);
quota.Take("old", 6, T0);
var t = new CrystalTrader(null, 7 * Day);
var gold = new[] { 50000f };
Assert.AreEqual(CrystalTrader.Result.NoQuota, t.TryBuy("b1", T0 + 1, quota, 10, Wallet(gold)));
Assert.AreEqual(50000f, gold[0], 0.01, "6/6 + покупка -> блок без расхода золота");
Assert.AreEqual(0, t.BoughtInWindow(T0 + 1));
}
[Test]
public void Trader_NoSpace_Or_NoGold_NothingChanges()
{
var quota = new CrystalQuota(6, 7 * Day);
var t = new CrystalTrader(null, 7 * Day);
var gold = new[] { 50000f };
Assert.AreEqual(CrystalTrader.Result.NoSpace, t.TryBuy("b1", T0, quota, 0, Wallet(gold)), "хранилище полное");
Assert.AreEqual(50000f, gold[0], 0.01);
var poor = new[] { 1000f };
Assert.AreEqual(CrystalTrader.Result.NoGold, t.TryBuy("b2", T0, quota, 10, Wallet(poor)), "не хватает золота");
Assert.AreEqual(0, quota.Used(T0), "квота не тронута");
Assert.AreEqual(30000, t.PriceNow(T0), "цена по-прежнему первая");
}
[Test]
public void Trader_SameOperationTwice_OnePurchase()
{
var quota = new CrystalQuota(6, 7 * Day);
var t = new CrystalTrader(null, 7 * Day);
var gold = new[] { 100000f };
t.TryBuy("b1", T0, quota, 10, Wallet(gold));
Assert.AreEqual(CrystalTrader.Result.Repeated, t.TryBuy("b1", T0 + 1, quota, 10, Wallet(gold)));
Assert.AreEqual(70000f, gold[0], 0.01, "повтор той же покупки -- ничего не списано");
Assert.AreEqual(1, quota.Used(T0 + 1));
}
[Test]
public void Trader_WindowRolls_AndSurvivesSaveLoad()
{
var quota = new CrystalQuota(6, 7 * Day);
var t = new CrystalTrader(null, 7 * Day);
var gold = new[] { 1000000f };
t.TryBuy("b1", T0, quota, 10, Wallet(gold));
t.TryBuy("b2", T0 + Day, quota, 10, Wallet(gold));
var copy = new CrystalTrader(null, 7 * Day);
copy.Load(t.ToLines());
Assert.AreEqual(-1, copy.PriceNow(T0 + 2 * Day), "после перезапуска -- те же 2 покупки");
Assert.AreEqual(T0 + 7 * Day, copy.NextSlotMs(T0 + 2 * Day));
Assert.AreEqual(60000, copy.PriceNow(T0 + 7 * Day), "первая покупка вышла из окна -- следующая снова вторая по счёту");
Assert.AreEqual(30000, copy.PriceNow(T0 + 8 * Day), "обе вышли из окна -- снова 30 000");
}
}
// Кристальная шахта: 3 синих кристалла за 7 реальных суток (регламент кристаллов 05.11, разд. 2.1 и 6; ответ 06.11, п. 3)
public class CrystalMineTests
{
const long T0 = 1_800_000_000_000L;
const long Day = CrystalQuota.Day;
static readonly long[] OneMine = { long.MinValue };
static CrystalMine Started()
{
var m = new CrystalMine(3, 7 * Day);
m.Advance(T0, OneMine, 50); // первый расчёт -- точка отсчёта
return m;
}
[Test]
public void Mine_7RealDays_FreeStorage_Exactly3()
{
var m = Started();
int got = 0;
for (long t = T0; t < T0 + 7 * Day; t += 10 * 60 * 1000) got += m.Advance(t + 10 * 60 * 1000, OneMine, 50 - got);
Assert.AreEqual(3, got, "за 7 реальных суток -- ровно 3");
}
[Test]
public void Mine_5RealMinutes_NotWeeklyAmount()
{
var m = Started();
Assert.AreEqual(0, m.Advance(T0 + 5 * 60 * 1000, OneMine, 50), "5 реальных минут -- не недельный объём (×12 не действует)");
}
[Test]
public void Mine_49of50_AtMostOne_50of50_Paused_ThenContinues()
{
var m = Started();
Assert.AreEqual(1, m.Advance(T0 + 7 * Day, OneMine, 1), "49/50 -- не больше 1");
Assert.AreEqual(0, m.ProgressMs, "лишнее не копится: шахта встала после последнего поместившегося");
Assert.AreEqual(0, m.Advance(T0 + 14 * Day, OneMine, 0), "50/50 -- пауза");
Assert.AreEqual(0, m.ProgressMs, "за паузу прогресс не вырос");
Assert.AreEqual(1, m.Advance(T0 + 14 * Day + m.MsPerCrystal, OneMine, 10), "место есть -- дальше с оставшегося прогресса, без начисления за паузу");
}
[Test]
public void Mine_Reload_And_ClockBack_NoDouble()
{
var m = Started();
Assert.AreEqual(1, m.Advance(T0 + m.MsPerCrystal, OneMine, 50));
var copy = new CrystalMine(3, 7 * Day);
copy.Load(m.ToLine());
Assert.AreEqual(0, copy.Advance(T0 + m.MsPerCrystal, OneMine, 50), "после загрузки то же время -- ничего");
Assert.AreEqual(0, copy.Advance(T0, OneMine, 50), "часы назад (сброс перемотки) -- ничего");
Assert.AreEqual(T0 + m.MsPerCrystal, copy.LastMs, "метка не ушла назад");
}
[Test]
public void Mine_TwoMines_Double_BuiltOffline_CountsFromCompletion()
{
var m = Started();
Assert.AreEqual(6, m.Advance(T0 + 7 * Day, new[] { long.MinValue, long.MinValue }, 50), "две шахты -- 6 за 7 суток");
var late = Started();
Assert.AreEqual(1, late.Advance(T0 + 3 * Day, new[] { T0 + 3 * Day - late.MsPerCrystal }, 50),
"шахта достроена офлайн -- добывает с конца стройки, а не с начала офлайна");
Assert.AreEqual(0, Started().Advance(T0 + Day, new long[0], 50), "шахт нет -- ничего");
}
}
+54 -5
View File
@@ -179,6 +179,21 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant
public bool TryGetReconTarget(string targetId, out ReconTarget target)
{
target = default;
// Движущаяся армия ("march_<номер>", как в GetArmies): состав -- на этот момент, без стен и ресурсов
if (TryParseArmyId(targetId, out int marchId))
{
MarchData m = World.FindMarch(marchId);
if (m == null || m.ownerId == LocalPlayerId) return false;
int[] units = m.units != null && m.units.Length == TroopType.Count ? (int[])m.units.Clone() : new[] { m.soldiers, 0, 0, 0 };
MapNode home = World.GetNode(m.fromNodeId);
target = new ReconTarget
{
Id = targetId, Name = home != null ? $"Армия из «{home.displayName}»" : "Чужая армия", Kind = ReconTargetKind.Army,
Units = units, Fortified = false, HasHero = false, // героев пока нет
ResourceNames = new string[0], ResourceAmounts = new int[0]
};
return true;
}
MapNode node = World.GetNode(targetId);
if (node == null) return false;
ReconTargetKind kind = node.type == MapNodeType.BossCastle ? ReconTargetKind.Castle
@@ -228,7 +243,39 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant
return TryStart(targetId, soldiers, true, out error, level, hold);
}
bool TryStart(string targetId, int soldiers, bool scout, out string error, int reconLevel = 0, long reconHoldMs = 0)
public static bool TryParseArmyId(string id, out int marchId)
{
marchId = 0;
return id != null && id.StartsWith("march_") && int.TryParse(id.Substring(6), out marchId) && marchId > 0;
}
// Цель отчёта разведки этого похода: армия ("march_<номер>") или узел карты
public static string ReconTargetId(MarchData march) =>
WorldSimulation.IsArmyRecon(march) ? "march_" + march.targetMarchId : march.targetNodeId;
// Разведка движущейся армии (Разведка М2, ТЗ раздел 6): отряд догоняет армию, идёт рядом время наблюдения,
// приносит отчёт и возвращается. armyId -- как в GetArmies ("march_<номер>")
public bool TryStartArmyRecon(string armyId, int soldiers, int level, out string error)
{
if (!TryParseArmyId(armyId, out int targetMarchId)) { error = "Это не армия"; return false; }
if (level < 2 || level > 3) { error = "Уровень разведки -- II или III"; return false; }
if (!GameServices.TryGet(out IIntelligence intel)) { error = "Модуль Разведки не подключён"; return false; }
long hold = intel.ReconDurationMs(armyId, level);
if (hold <= 0) { error = "Эту армию разведать нельзя"; return false; }
return TryStart(armyId, soldiers, true, out error, level, hold, targetMarchId);
}
// План разведки армии без старта: когда догонят, когда отчёт, когда дома (как для пехоты -- состав ещё не выбран)
public bool TryPlanArmyRecon(string armyId, int level, out long meetMs, out long reportMs, out long homeMs, out string error)
{
meetMs = reportMs = homeMs = 0;
if (!TryParseArmyId(armyId, out int targetMarchId)) { error = "Это не армия"; return false; }
long hold = GameServices.TryGet(out IIntelligence intel) ? intel.ReconDurationMs(armyId, level) : 0;
if (hold <= 0) { error = "Эту армию разведать нельзя"; return false; }
return World.TryPlanArmyRecon(WorldMapLayout.PlayerCityId, targetMarchId, NowMs, hold, out meetMs, out reportMs, out homeMs, out error);
}
bool TryStart(string targetId, int soldiers, bool scout, out string error, int reconLevel = 0, long reconHoldMs = 0, int targetMarchId = 0)
{
if (!GameServices.TryGet(out IArmy army))
{
@@ -250,9 +297,11 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant
return false;
}
MarchData march = World.StartMarch(LocalPlayerId, WorldMapLayout.PlayerCityId, targetId, soldiers, NowMs, out error, scout);
MarchData march = targetMarchId > 0
? World.StartArmyRecon(LocalPlayerId, WorldMapLayout.PlayerCityId, targetMarchId, soldiers, NowMs, reconLevel, reconHoldMs, out error)
: World.StartMarch(LocalPlayerId, WorldMapLayout.PlayerCityId, targetId, soldiers, NowMs, out error, scout);
if (march == null) return false;
if (reconLevel > 0) World.SetRecon(march, reconLevel, reconHoldMs);
if (reconLevel > 0 && targetMarchId == 0) World.SetRecon(march, reconLevel, reconHoldMs);
// Отправка целиком или никак: поход существует, только если Армия действительно забрала воинов.
// Иначе поход откатывается -- он не должен вернуть домой воинов, которых не забирал.
@@ -266,7 +315,7 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant
World.AssignUnits(march, army.GetMarchUnits(march.id));
if (GameServices.TryGet(out ISaveSystem save)) save.SaveNow();
Debug.Log($"[ПОХОД] {soldiers} солдат выступили {(scout ? "в разведку" : "на")} {World.GetNode(targetId).displayName}");
Debug.Log($"[ПОХОД] {soldiers} солдат выступили {(scout ? "в разведку" : "на")} {World.TargetName(march)}");
OnMarchStarted?.Invoke(march);
EventBus.Publish(new MarchStartedEvent { MarchId = march.id, TimeMs = march.departMs, TargetId = targetId, Soldiers = soldiers, IsScout = scout });
if (reconLevel > 0)
@@ -280,7 +329,7 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant
void HandleReconCompleted(MarchData march)
{
if (GameServices.TryGet(out IIntelligence intel))
intel.CompleteRecon(ReportId(march), march.targetNodeId, march.reconLevel, march.gatherEndMs);
intel.CompleteRecon(ReportId(march), ReconTargetId(march), march.reconLevel, march.gatherEndMs);
Debug.Log($"[РАЗВЕДКА] Отчёт уровня {march.reconLevel}: {World.TargetName(march)}");
OnReconCompleted?.Invoke(march);
}
@@ -0,0 +1,169 @@
using System;
namespace Bogatyri.World
{
// РАЗВЕДКА ДВИЖУЩЕЙСЯ АРМИИ (Разведка М2, ТЗ разведки, раздел 6: «вражеская армия -- 1 / 2 игровых часа»).
// Весь путь -- физический марш, без телепорта и без постоянного слежения:
// 1) отряд идёт из города к точке перехвата -- где армия окажется, когда отряд до неё дойдёт (по маршруту армии
// и скорости отряда, с шагом InterceptStepMs);
// 2) наблюдает reconHoldMs, идя рядом с армией (позиция отряда = позиция армии);
// 3) отчёт на конец наблюдения, затем домой из места, где наблюдение кончилось.
// Всё время похода известно при выступлении (маршрут армии известен Миру), поэтому офлайн считается так же.
// Догнать нельзя или армия уйдёт с карты раньше конца наблюдения -- поход не начинается (ошибка для игрока).
// Армия-цель исчезла до перехвата (патрули выключили) -- отряд дошёл до точки и вернулся (targetEmpty).
public sealed partial class WorldSimulation
{
public const long InterceptStepMs = 10_000;
// Поход ли это на армию (а не на узел карты)
public static bool IsArmyRecon(MarchData m) => m != null && m.targetMarchId > 0;
public MarchData FindMarch(int id) => marches.Find(m => m.id == id);
// Начать разведку армии. Состав ещё неизвестен -- план как для пехоты; AssignUnits пересчитает по составу
public MarchData StartArmyRecon(string ownerId, string fromId, int targetMarchId, int soldiers, long nowMs, int level, long holdMs, out string error)
{
MapNode from = GetNode(fromId);
MarchData target = FindMarch(targetMarchId);
if (from == null) { error = "Неизвестная точка на карте"; return null; }
if (target == null || !TryGetPositionAt(target, nowMs, out _, out _, out _, out _, out _)) { error = "Этой армии уже нет на карте"; return null; }
if (target.ownerId == ownerId) { error = "Это ваша армия"; return null; }
if (soldiers <= 0) { error = "Нужно отправить хотя бы одного солдата"; return null; }
if (level < 2 || level > 3 || holdMs <= 0) { error = "Уровень разведки -- II или III"; return null; }
var march = new MarchData
{
id = nextMarchId++,
ownerId = ownerId,
fromNodeId = fromId,
targetNodeId = target.targetNodeId, // узел, куда идёт армия (для старого кода, которому нужен узел)
targetMarchId = targetMarchId,
targetName = "чужая армия",
soldiers = soldiers,
survivors = soldiers,
departMs = nowMs,
state = MarchState.Outbound,
scout = true,
reconLevel = level,
reconHoldMs = holdMs,
};
if (!PlanArmyRecon(march, null, out error)) { nextMarchId--; return null; }
march.battleSeed = BattleSeed(march.id, nowMs, "march_" + targetMarchId);
marches.Add(march);
return march;
}
// Рассчитать поход без старта (для окна: «догонят через N, вернутся через M»)
public bool TryPlanArmyRecon(string fromId, int targetMarchId, long nowMs, long holdMs, out long meetMs, out long endMs, out long returnMs, out string error)
{
var probe = new MarchData { fromNodeId = fromId, targetMarchId = targetMarchId, departMs = nowMs, reconHoldMs = holdMs, scout = true, reconLevel = 2 };
bool ok = PlanArmyRecon(probe, null, out error);
meetMs = probe.arriveMs; endMs = probe.gatherEndMs; returnMs = probe.returnMs;
return ok;
}
// Перехват, наблюдение, обратный путь -- по составу units (null -- как пехота)
bool PlanArmyRecon(MarchData m, int[] units, out string error)
{
error = null;
MapNode home = GetNode(m.fromNodeId);
MarchData target = FindMarch(m.targetMarchId);
if (home == null || target == null) { error = "Этой армии уже нет на карте"; return false; }
// 1) Перехват: первый момент, когда отряд успевает дойти до места, где в этот момент будет армия
long limit = target.returnMs > 0 ? target.returnMs : m.departMs + 48L * 3_600_000;
long meet = -1;
int mx = 0, my = 0;
for (long t = m.departMs; t <= limit; t += InterceptStepMs)
{
if (!TryGetPositionAt(target, t, out int x, out int y, out _, out _, out _)) break; // армия ушла с карты
if (m.departMs + TravelTimeMs(home, Point(x, y), units) <= t) { meet = t; mx = x; my = y; break; }
}
if (meet < 0) { error = "Армию не догнать: она уйдёт раньше, чем разведчики до неё доберутся"; return false; }
// 2) Наблюдение рядом с армией: она должна оставаться на карте до конца
long end = meet + m.reconHoldMs;
if (!TryGetPositionAt(target, end, out int lx, out int ly, out _, out _, out _))
{
error = "Армия уйдёт с карты раньше, чем закончится наблюдение";
return false;
}
// 3) Домой -- из места, где наблюдение кончилось
m.arriveMs = meet;
m.meetX = mx; m.meetY = my;
m.gatherEndMs = end;
m.leaveX = lx; m.leaveY = ly;
m.returnMs = end + TravelTimeMs(Point(lx, ly), home, units);
return true;
}
// Отряд дошёл до точки перехвата: армия на месте -- наблюдает (время уже рассчитано); армии нет -- домой
void ArriveArmyRecon(MarchData m)
{
MapNode home = GetNode(m.fromNodeId);
int[] units = HasUnits(m) ? m.units : null;
m.victory = false;
m.survivors = m.soldiers;
m.survivorUnits = units != null ? (int[])units.Clone() : null;
m.loot = new System.Collections.Generic.List<RewardEntry>();
MarchData target = FindMarch(m.targetMarchId);
m.targetEmpty = target == null || !TryGetPositionAt(target, m.arriveMs, out _, out _, out _, out _, out _);
if (m.targetEmpty)
{
m.gatherEndMs = 0;
m.leaveX = m.meetX; m.leaveY = m.meetY;
m.returnMs = m.arriveMs + TravelTimeMs(Point(m.meetX, m.meetY), home, units);
m.state = MarchState.Returning;
return;
}
m.state = MarchState.Gathering; // наблюдает до gatherEndMs, обратный путь уже рассчитан
}
// Позиция отряда разведки армии в момент t
bool TryGetArmyReconPositionAt(MarchData m, long t, out int x, out int y, out int toX, out int toY, out int soldiers)
{
x = y = toX = toY = 0;
soldiers = m.soldiers;
MapNode home = GetNode(m.fromNodeId);
if (home == null || t < m.departMs) return false;
if (t < m.arriveMs)
{
LerpXY(home.x, home.y, m.meetX, m.meetY, m.departMs, m.arriveMs, t, out x, out y);
toX = m.meetX; toY = m.meetY;
return true;
}
long backStart = m.targetEmpty || m.gatherEndMs <= 0 ? m.arriveMs : m.gatherEndMs;
if (t < backStart)
{
// идёт рядом с армией; армии уже нет (не должно быть по плану) -- стоит, где кончилось наблюдение
MarchData target = FindMarch(m.targetMarchId);
if (target == null || !TryGetPositionAt(target, t, out x, out y, out toX, out toY, out _)) { x = m.leaveX; y = m.leaveY; toX = x; toY = y; }
return true;
}
if (m.returnMs > 0 && t >= m.returnMs) return false; // дома
LerpXY(m.leaveX, m.leaveY, home.x, home.y, backStart, m.returnMs, t, out x, out y);
toX = home.x; toY = home.y;
return true;
}
// Отрезок для отрисовки: точки как временные узлы карты (не входят в Nodes)
void GetArmyReconSegment(MarchData m, long nowMs, out MapNode from, out MapNode to, out double progress)
{
TryGetArmyReconPositionAt(m, nowMs, out int x, out int y, out int tx, out int ty, out _);
from = Point(x, y);
to = Point(tx, ty);
progress = 0;
}
static MapNode Point(int x, int y) => new MapNode { id = "", x = x, y = y, displayName = "" };
static void LerpXY(int ax, int ay, int bx, int by, long start, long end, long t, out int x, out int y)
{
if (end <= start) { x = bx; y = by; return; }
long p = Math.Max(0, Math.Min(end - start, t - start));
x = ax + (int)((long)(bx - ax) * p / (end - start));
y = ay + (int)((long)(by - ay) * p / (end - start));
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 2a7f5c3d9e1b4a68b4c2d0e8f6a19b37
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -155,6 +155,7 @@ namespace Bogatyri.World
public bool TryGetPositionAt(MarchData m, long t, out int x, out int y, out int toX, out int toY, out int soldiers)
{
x = y = toX = toY = soldiers = 0;
if (IsArmyRecon(m)) return TryGetArmyReconPositionAt(m, t, out x, out y, out toX, out toY, out soldiers);
MapNode home = GetNode(m.fromNodeId), target = GetNode(m.targetNodeId);
if (home == null || target == null || t < m.departMs) return false;
@@ -32,6 +32,9 @@ namespace Bogatyri.World
public bool scout; // поход «Разведка» (решения 31.10, п. 2): дойти до цели и вернуться, без боя и сбора
public int reconLevel; // Разведка М2: 0 -- просто дойти и вернуться; 2 / 3 -- наблюдать reconHoldMs и составить отчёт
public long reconHoldMs; // сколько наблюдать цель (мс часов игры): время уровня из модуля Разведки
public int targetMarchId; // Разведка М2: цель -- движущаяся армия (номер её похода); 0 -- цель -- узел карты
public int meetX, meetY; // где отряд перехватит армию (милли-единицы карты)
public int leaveX, leaveY; // где наблюдение кончится -- отсюда домой
public long gatherEndMs; // когда сбор закончится (груз полон, точка истощена или исчезла)
public int cargo; // сколько ресурса несёт войско (известно с начала сбора)
public int defenders; // гарнизон цели в момент боя
@@ -200,6 +203,7 @@ namespace Bogatyri.World
{
if (m == null || m.state != MarchState.Outbound || !marches.Contains(m)) return false;
m.units = units;
if (IsArmyRecon(m)) { PlanArmyRecon(m, units, out _); return true; } // перехват -- по скорости состава
m.arriveMs = m.departMs + TravelTimeMs(GetNode(m.fromNodeId), GetNode(m.targetNodeId), units);
return true;
}
@@ -373,7 +377,7 @@ namespace Bogatyri.World
// Разведка: дошла -- повернула домой (Разведка видит всё вокруг войска по пути и у цели), вернулась -- дома
if (nextKind == 0)
{
ArriveScout(next);
if (IsArmyRecon(next)) ArriveArmyRecon(next); else ArriveScout(next);
events.Add(new WorldEvent { Type = WorldEventType.ScoutArrived, March = next, TimeMs = nextMs });
}
else if (nextKind == 1)
@@ -544,6 +548,7 @@ namespace Bogatyri.World
// Где сейчас войско (для отрисовки). Возвращает долю пути 0..1 и точки начала/конца отрезка.
public void GetMarchSegment(MarchData m, long nowMs, out MapNode segmentFrom, out MapNode segmentTo, out double progress)
{
if (IsArmyRecon(m)) { GetArmyReconSegment(m, nowMs, out segmentFrom, out segmentTo, out progress); return; }
MapNode home = GetNode(m.fromNodeId);
MapNode target = GetNode(m.targetNodeId);
@@ -143,10 +143,14 @@ public class IntelligenceOverlay : MonoBehaviour
}
// Имя владельца -- по его поселению на карте («npc_3» -> «Велимир»)
string OwnerName(string ownerId)
string OwnerName(string ownerId) => OwnerName(world, ownerId);
// Имя владельца армии -- по его городу на карте (окно чужой армии -- тоже)
public static string OwnerName(WorldSimulation world, string ownerId)
{
foreach (var n in world.Nodes)
if (n.ownerId == ownerId && n.type == MapNodeType.NeighborCity) return n.displayName;
if (world != null)
foreach (var n in world.Nodes)
if (n.ownerId == ownerId && n.type == MapNodeType.NeighborCity) return n.displayName;
return ownerId ?? "?";
}
+4 -2
View File
@@ -85,7 +85,9 @@ public class MarchView : MonoBehaviour
void CreateRouteLine(WorldSimulation world, MarchData march)
{
Vector3 home = WorldMapController.ToWorld(world.GetNode(march.fromNodeId));
Vector3 target = WorldMapController.ToWorld(world.GetNode(march.targetNodeId));
Vector3 target = WorldSimulation.IsArmyRecon(march)
? new Vector3(march.meetX / 1000f, 0f, march.meetY / 1000f) // разведка армии -- до точки перехвата
: WorldMapController.ToWorld(world.GetNode(march.targetNodeId));
Vector3 delta = target - home;
// Тонкая полоса на земле от города до цели
@@ -126,7 +128,7 @@ public class MarchView : MonoBehaviour
long secondsLeft = System.Math.Max(0, (endMs - nowMs + 999) / 1000);
string direction = returning ? (march.cargo > 0 ? $"домой, груз {march.cargo}" : "домой")
: gathering ? (march.scout ? "наблюдает" : $"сбор {march.cargo}")
: march.scout ? "разведка" : march.gather ? "на сбор" : "в бой";
: WorldSimulation.IsArmyRecon(march) ? "догоняют армию" : march.scout ? "разведка" : march.gather ? "на сбор" : "в бой";
// Марши идут часами (регламент 31.10) -- «1:05:30», меньше часа -- «12:30»
string time = secondsLeft >= 3600
? $"{secondsLeft / 3600}:{secondsLeft / 60 % 60:00}:{secondsLeft % 60:00}"
@@ -49,6 +49,41 @@ public static class ReconReportText
return min < 60 ? $"{min} мин" : $"{min / 60} ч {min % 60} мин";
}
// Окно чужой армии (Разведка М2): что видно сейчас, последний отчёт и «Разведка II / III» -- отряд догонит армию,
// пойдёт рядом время наблюдения и вернётся с отчётом. Воинов -- до ArmyReconSoldiers из свободных
public const int ArmyReconSoldiers = 10;
public static void OpenArmyWindow(KnownContact army, System.Action<string> hint)
{
var card = UIKit.Modal("ЧУЖАЯ АРМИЯ", out GameObject root);
long now = MarchManager.NowMs;
string owner = army.Identified ? IntelligenceOverlay.OwnerName(MarchManager.Instance.World, army.OwnerId) : "владелец неизвестен";
UIKit.Text(card, $"~{army.ApproxMin}–{army.ApproxMax} воинов, {owner}", 26, UIKit.Theme.Text, TextAlignmentOptions.Center);
if (GameServices.TryGet(out IIntelligence intel) && intel.TryGetLatestReport(army.ArmyId, out var report))
UIKit.Text(card, Format(report, now), 22, UIKit.Theme.TextDim, TextAlignmentOptions.Left);
int free = GameServices.TryGet(out IArmy myArmy) ? myArmy.AvailableForMarch : 0;
int soldiers = Mathf.Min(ArmyReconSoldiers, free);
UIKit.Text(card, soldiers > 0 ? $"Отправить {soldiers} воинов: догонят армию, пойдут рядом и вернутся с отчётом. Без боя."
: "Нет свободных воинов для разведки", 22, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
for (int level = 2; level <= 3; level++)
{
int lv = level;
string name = lv == 3 ? "III" : "II";
bool ok = MarchManager.Instance.TryPlanArmyRecon(army.ArmyId, lv, out long meet, out long reportAt, out long home, out string error);
string plan = ok ? $"догонят через {Ago(meet - now)}, отчёт через {Ago(reportAt - now)}, дома через {Ago(home - now)}" : error;
UIKit.Text(card, $"Разведка {name}: {plan}", 22, ok ? UIKit.Theme.Text : UIKit.Theme.Gold, TextAlignmentOptions.Center);
var b = UIKit.Button(card, $"Разведка {name}", ok && soldiers > 0 ? UIKit.Theme.Blue : UIKit.Theme.Gray, () =>
{
bool started = MarchManager.Instance.TryStartArmyRecon(army.ArmyId, soldiers, lv, out string err);
hint?.Invoke(started ? $"Разведка {name}: {soldiers} воинов идут догонять армию" : "Разведка не начата: " + err);
Object.Destroy(root);
}, -1, 80, 26);
b.interactable = ok && soldiers > 0;
}
UIKit.Button(card, "Закрыть", UIKit.Theme.Gray, () => Object.Destroy(root), -1, 80, 28);
}
// Окно «Отчёты»: последние отчёты, новые сверху
public static void OpenWindow()
{
+20 -1
View File
@@ -510,6 +510,9 @@ public class WorldMapController : MonoBehaviour
if (ArmiesWindow.IsOpen) return;
if (!mapCamera.TryGetTap(out Vector3 world)) return;
// Видимая чужая армия под пальцем -- окно разведки армии (Разведка М2)
if (FindArmyAt(world, out KnownContact army)) { ReconReportText.OpenArmyWindow(army, s => hintText.text = s); return; }
MapNode node = FindNodeAt(world);
if (node == null) return;
@@ -521,6 +524,22 @@ public class WorldMapController : MonoBehaviour
else if (node.canBeAttacked) OpenAttackPanel(node);
}
// Чужая армия в обзоре рядом с точкой нажатия (метка-ромб размером ~0,55 клетки; зона чуть больше)
static bool FindArmyAt(Vector3 world, out KnownContact found)
{
found = default;
if (!GameServices.TryGet(out IIntelligence intel)) return false;
float best = 0.7f;
bool any = false;
foreach (var c in intel.Contacts)
{
if (!c.Observed) continue;
float d = Vector2.Distance(new Vector2(world.x, world.z), new Vector2(c.X / 1000f, c.Y / 1000f));
if (d <= best) { best = d; found = c; any = true; }
}
return any;
}
MapNode FindNodeAt(Vector3 world)
{
foreach (var node in World.Nodes)
@@ -841,7 +860,7 @@ public class WorldMapController : MonoBehaviour
void HandleReconCompleted(MarchData march)
{
if (GameServices.TryGet(out IIntelligence intel) && intel.TryGetLatestReport(march.targetNodeId, out var report))
if (GameServices.TryGet(out IIntelligence intel) && intel.TryGetLatestReport(MarchManager.ReconTargetId(march), out var report))
EnqueuePopup("ОТЧЁТ РАЗВЕДКИ", ReconReportText.Format(report, MarchManager.NowMs) + "\nРазведчики возвращаются домой");
}
+12 -2
View File
@@ -99,11 +99,16 @@
- каждый 10-й этап боя — 1 (`StageGemsEvery`, `StageGemsAmount`).
Всё ТЕСТ. Без кристаллов теперь: задания (основные и ежедневные), награды за вход (было 50 и 200), письмо-приветствие (было 100), подарок за уровень (было 10 × уровень), арена (было 5), этапы боя (было 25 на каждом 5-м), бесплатный сундук (было 15). Вместо них — золото и ресурсы.
- **Хранилище — платное расширение (разд. 8 регламента валют, ответ 06.11, п. 1).** Старт 50; ступени (ТЕСТ): Ратуша 3 — 75 (2000 золота, 1500 дерева, 1500 камня), Ратуша 5 — 100 (4000 / 3000 / 3000 + 1000 металла), Ратуша 7 — 150 (7000 / 5000 / 5000 / 2000), Ратуша 9 — 200 (10000 / 8000 / 8000 / 4000). Ратуша только разрешает ступень, покупается в окне Ратуши («Хранилище синих кристаллов» → «Расширить до N»). Цена списывается один раз, всё или ничего. Правила — `Modules/Economy/Rules/CrystalStorage.cs`, тесты — `CrystalStorageTests`; числа — `EconomyConfig` → «Склады и лимиты» → `crystalCap`, `crystalExpansions`. Купленная ступень сохраняется в секции `"economy"` (`crystalStorageLevel`; в старом сохранении её нет — 0), вместимость по ней считается заново.
- **Недельный PvE-предел (разд. 5.2, 10, 12 регламента валют; ответ 06.11, п. 2).** Не больше 6 новых синих кристаллов за скользящие 7 реальных суток из заданий, событий и боёв (позже — PvE-торговец). Сверх квоты — 1000 золота за кристалл (компенсация не согласована, ТЕСТ), на следующую неделю не переносится; при частичной квоте — часть кристаллами, остальное золотом. Квота и место в хранилище — разные условия: взятое по квоте, но не поместившееся ждёт места и квоту второй раз не тратит; кристаллы из письма «Кристаллы ждут места» квоту не берут. Задание берёт квоту один раз (по id). Видно в окне Ратуши: «Новых кристаллов из заданий, событий и боёв за 7 дней: N / 6». Правила — `Modules/Economy/Rules/CrystalQuota.cs`, тесты — `CrystalQuotaTests`; числа — `EconomyConfig` → `crystalPveLimit`, `crystalPveWindowDays`, `crystalCompensationGold`; сохранение — `progress.json`, `crystalQuota`. DEV: «+1 PvE-кристалл», «Сбросить квоту».
- **Редкий PvE-торговец (разд. 3 и 10 регламента валют).** Магазин → «Редкий торговец: синий кристалл»: за скользящие 7 реальных суток первая покупка — 30 000 золота, вторая — 60 000, третьей нет (`EconomyConfig` → `crystalTraderPrices`, ТЕСТ). Покупка входит в недельный PvE-предел. Все проверки — до оплаты: нет покупок в окне, нет квоты или нет места в хранилище — блок, золото не списывается, компенсации нет. Одна покупка — один id, повтор ничего не делает; после покупки игра сразу сохраняется. Правила — `Modules/Economy/Rules/CrystalTrader.cs`, тесты — `CrystalTraderTests`; сохранение — `progress.json`, `crystalTrader`. DEV: «Ресурсы 100 000» (мимо лимита склада, для проверки), «Сбросить квоту» сбрасывает и покупки. **Вопрос:** при лимите золота 1000 + 1000 за уровень Склада цены 30 000 / 60 000 почти недостижимы.
- **Кристальная шахта (разд. 2.1 регламента кристаллов, разд. 8.1 регламента валют, ответ 06.11, п. 3).** Здание «Кристалл» (Ратуша 6; вторая шахта — Ратуша 9, ТЕСТ, лимит в `buildingLimits`). Каждая работающая шахта — 3 синих кристалла за 7 РЕАЛЬНЫХ суток, по одному примерно раз в 56 ч (`crystalMinePerPeriod`, `crystalMinePeriodDays`, ТЕСТ); шахты складываются. Работает с конца стройки (и если стройка закончилась офлайн), досчитывается после входа по меткам; уже посчитанное время второй раз не начисляется, часы назад — ничего. Хранилище полное — пауза без скрытого запаса, после освобождения места — с оставшегося прогресса. В недельный PvE-предел не входит. Окно здания: «Работает шахт: N. Следующий кристалл через …» или «Пауза». Вне 3D-сцены (карта мира) шахты считаются по сохранению `settlement3d`. Правила — `Modules/Economy/Rules/CrystalMine.cs`, тесты — `CrystalMineTests`; расчёт — `GameProgress.ProcessCrystalMine` (раз в секунду), сохранение — `progress.json`, `crystalMine`. DEV «Ресурсы 100 000» — обычные ресурсы мимо лимита склада, для проверки.
- **Ускорения — за золото** (06.11, решение Саши, ТЕСТ): стройка, улучшение и обучение войск — 1 золото за 10 секунд (`EconomyConfig` → «Стройка» → `secondsPerGold`, `EconomyConfig.SpeedUpGold`); было 1 синий кристалл за 30 секунд. Подтвердить у руководителя валюту и курс.
- **Магазин — за золото** (пакеты дерева, камня, еды, железа: 600 / 700 / 600 / 900 золота, ТЕСТ). Убраны пакеты за 50–80 кристаллов, «Мешок золота», обмен золота на кристаллы, «Горсть» (500) и «Сундук» (3000) кристаллов.
**Ещё не сделано (после вливания `feature/3d-hud` в `develop`):** вместимость 50 → 75 / 100 / 150 / 200 с платным расширением на Ратуше 3 / 5 / 7 / 9 (сейчас константа `crystalCap` 50); недельный PvE-предел 6 с заменой излишка золотом; редкий PvE-торговец (30 000 / 60 000 золота, не больше 2 за 7 суток); Кристальная шахта (до двух, 3 за 7 реальных суток, пауза при полном хранилище); космо-кристаллы; торговля и военная добыча — этап мультиплеера.
**Ещё не сделано:** космо-кристаллы; торговля и военная добыча — этап мультиплеера.
**Открыто:** цены расширения хранилища, компенсация за кристалл сверх недельного предела, уровень Ратуши для второй шахты, ускорения за кристаллы (обучение — 1 кристалл за 30 с, стройка) — в регламенте среди трат не названы.
**Открыто:** окончательные цены расширения хранилища, компенсация за кристалл сверх недельного предела, уровень Ратуши для второй шахты (сейчас 9), лимит золота против цен торговца и расширений.
## Таблица баланса `EconomyConfig`
@@ -126,6 +131,11 @@
| Дата | Кто | Что |
| --- | --- | --- |
| 06.11.2026 | Claude (Саша) | Кристальная шахта: 3 синих кристалла за 7 реальных суток на шахту, до двух (вторая — Ратуша 9, ТЕСТ), офлайн по меткам, пауза при полном хранилище, вне недельного предела (`CrystalMine` + 5 тестов, `GameProgress.ProcessCrystalMine`, окно здания). |
| 06.11.2026 | Claude (Саша) | Редкий PvE-торговец: синий кристалл за 30 000 / 60 000 золота, не больше 2 за 7 реальных суток, в пределах недельной квоты; блок без списания (`CrystalTrader` + 5 тестов, магазин, DEV «Золото 100 000»). |
| 06.11.2026 | Claude (Саша) | Недельный PvE-предел синих кристаллов: 6 за 7 реальных суток, сверх — 1000 золота за кристалл (ТЕСТ); `CrystalQuota` + 6 тестов, `GameProgress.Grant/ClaimQuest`, окно Ратуши, DEV-кнопки. |
| 06.11.2026 | Claude (Саша) | Ускорения стройки, улучшения и обучения войск — за золото: 1 золото за 10 с (`secondsPerGold`, ТЕСТ), было 1 кристалл за 30 с. |
| 06.11.2026 | Claude (Саша) | Хранилище синих кристаллов: платное расширение 50 → 75 / 100 / 150 / 200 на Ратуше 3 / 5 / 7 / 9 в окне Ратуши (`CrystalStorage`, `EconomyConfig.crystalExpansions`, `ResourceManager.CrystalStorageLevel` в сохранении, 5 тестов). Вместимость кристаллов больше не константа 50. |
| 06.11.2026 | Claude (Саша) | Регламент валют 05.11, разд. 2 и 5.1: синие кристаллы только за верхнюю ступень недельного события (1) и каждый 10-й этап боя (1); задания, вход, письма, уровень, арена, сундук — золото и ресурсы; магазин за золото, убраны пакеты за кристаллы, обмен золота на кристаллы, «Горсть» и «Сундук». |
| 05.11.2026 | Claude (Саша) | Регламент синих кристаллов, пп. 1 и 3: кристаллы не начисляются сверх 50 и не теряются — награды заданий ждут (не больше 2), остальные — письмом «Кристаллы ждут места», из письма забирается сколько помещается (`CrystalRewards`, `GameProgress.Grant/ClaimQuest/ClaimMail`, 6 тестов). Задания — 1–2 кристалла вместо 20–250, ежедневные — без кристаллов. |
| 04.11.2026 | Claude | Решения 04.11: кристаллы грабятся только нападающим с технологией «Грабёж» (`IResourceProtection.Lootable(type, attackerHasPlunder)`; с ней — не больше `plunderGemsShare` запаса, ТЕСТ 10%, точные ограничения — позже). Защита остальных ресурсов без изменений. Из `EconomyConfig` убран возврат при сносе. Офлайн: добыча здания, достроенного в офлайне, — с момента завершения; уже посчитанное время второй раз не начисляется (тесты в `ProductionTests`). |
+4 -1
View File
@@ -58,7 +58,7 @@
| Поселение (соседний город) | 2 игровых часа | 4 |
| Замок | 3 | 6 |
| Лагерь (в ТЗ не назван — как поселение, ТЕСТ) | 2 | 4 |
| Вражеская армия (1 / 2 ч) | **ещё нет** — движущаяся цель, позже | |
| Вражеская армия | 1 | 2 |
1 игровой час = 5 реальных минут.
@@ -69,6 +69,8 @@
- События: `ReconStartedEvent`, `ReconCompletedEvent`. Отчёты сохраняются (`report=`), хранятся последние 30.
- Числа — в `IntelligenceRules` (`ReconSettlementGameHours`, `ReconCastleGameHours`, `ReconCampGameHours`, `ResourceEstimatePercent`).
**Разведка армии (06.11):** тап по видимой чужой армии (красный ромб) — окно «ЧУЖАЯ АРМИЯ»: примерная численность, последний отчёт и «Разведка II / III» для 10 свободных воинов с планом «догонят через …, отчёт через …, дома через …». Отряд физически идёт к точке перехвата (где армия будет, когда он дойдёт), наблюдает, идя рядом с ней, отчёт — на конец наблюдения, домой — из места, где наблюдение кончилось. Не догнать или армия уйдёт с карты раньше конца наблюдения — поход не начинается, причина в окне. Армия пропала до перехвата — отряд дошёл до точки и вернулся без отчёта. Цель отчёта — `march_<номер>` (как в `GetArmies`), снимок — `MarchManager.TryGetReconTarget` (`ReconTargetKind.Army`: точный состав, без стен и ресурсов). Время — `IntelligenceRules.ReconArmyGameHours` {1, 2}. Патрули-заглушки идут ~40 минут — их можно догнать, только если они проходят рядом с городом.
**На карте:** в окне атаки — «Разведка II» и «Разведка III» (у ресурсной точки — просто «Разведка»: дойти и вернуться). Отряд у цели подписан «наблюдает». По окончании — окно «ОТЧЁТ РАЗВЕДКИ». Кнопка «Отчёты» под «Армиями» — последние отчёты. Если цель вне обзора, но есть отчёт, окно атаки показывает гарнизон «по отчёту N мин назад» и прогноз боя по нему.
## Патрули-заглушки (временно)
@@ -132,6 +134,7 @@
| Дата | Кто | Что |
| --- | --- | --- |
| 06.11.2026 | Claude (Саша) | М2: разведка движущейся армии — тап по чужой армии, «Разведка II / III» (1 / 2 игровых часа), перехват и сопровождение физическим маршем, отчёт и возвращение; `ReconTargetKind.Army`, `ReconArmyGameHours`. Тесты: время, перехват и сопровождение, отказ, цель пропала. |
| 04.11.2026 | Claude | М2: целенаправленная разведка поселений, замка и лагерей с отчётами уровней II и III (`ReconDurationMs`, `CompleteRecon`, `Reports`, `TryGetLatestReport`, `ReconStartedEvent` / `ReconCompletedEvent`, сохранение `report=`); на карте — кнопки «Разведка II/III», окно отчёта, «Отчёты», прогноз по отчёту. Разведка армий — позже. |
| 04.11.2026 | Claude | 2.5D: туман рисуется в текстуре земли (объекты стоят над ней и видны всегда), метки чужих армий — ромбы-кубы с подписью к камере. `hideUnexploredObjects` в 3D пока только сильнее затемняет землю. |
| 01.11.2026 | Claude | Поход «Разведка» (решения 31.10, п. 2) — команда Мира; что войско увидит, показывает Разведка. См. World.md. |
+3
View File
@@ -119,6 +119,9 @@
| Дата | Кто | Что |
| --- | --- | --- |
| 06.11.2026 | Claude (Саша) | 3D: местность за краем участка — 60 клеток вокруг (`City3DController.BuildSkirt`, `City3DLayout.SkirtHeight`): те же холмы, река и горы продолжаются без шва (на стыке те же вершины) и к дальнему краю плавно опускаются до равнины; река течёт дальше, вокруг лес. Только вид: без коллайдера, стройки и тапов нет. Раньше при отдалении участок обрывался уступом. |
| 06.11.2026 | Claude (Саша) | Герой у Терема в 3D (перенос из 2D): `City3D/City3DHero.cs` — фигурка из простых форм появляется у ворот достроенного Терема и гуляет по свободным клеткам 3D-карты (земля, открытая зона, без зданий и деревьев) не дальше 6 клеток от Терема, шагами на соседнюю клетку — сквозь здания не проходит; тап — фраза и окно героя; в режиме стройки и переноса прячется. Создаёт и убирает `City3DController.UpdateHero`. На экономику и сохранение не влияет. |
| 06.11.2026 | Claude (Саша) | Открытие зоны Ратушей: камера показывает только край новой зоны — старую зону и 2 клетки новой за её границей (`ZoneEdgeCells`), а не всю зону; ближе, чем была, не подъезжает (`City3DCamera.FocusOn(..., onlyZoomOut)`). Раньше при ускорении улучшения Ратуши камера улетала слишком далеко. |
| 06.11.2026 | Claude (Валдемар) | Этап 1 перехода на 3D (2D-город больше не развиваем). Сверка: все 12 зданий 2D (8 `Data_*.asset` + Склад, Амбар, Госпиталь, Терем) есть в 3D с теми же ценами и уровнем Ратуши; ни одна кнопка не ведёт в MVP1. Камера (`City3DCamera`): центр кадра не уходит за открытые зоны; при самом дальнем отдалении открытая зона видна целиком между HUD сверху и снизу — дальше всего = max(подобранное в DEV, сколько нужно для зоны), считается по проекции камеры (`FitDistance`); «+ / −» и колесо приближают плавно; перетаскивание считается на высоте земли под пальцем (на холмах карта идёт точно за пальцем); подъезд к призраку — `MoveTo`, без смены приближения. Ввод: после щипка отпущенный палец больше не считается тапом (не открывается окно здания). `SceneListFixer` больше не добавляет и не включает MVP1 в Build Settings. Числа камеры — ТЕСТ. |
| 05.11.2026 | Claude (Саша) | Перенос 2D-города надёжнее: если места нет, деревья и валуны под зданием расчищаются (бесплатно, без награды — `SettlementMap.ValidateIfCleared`, `ObstaclesUnder`, тест `Placement_IfCleared_IgnoresOnlyTreesAndListsThem`); если места нет и так — здание не теряется, а ждёт в `settlement3d.pendingFrom2D` и ставится при входе в город или когда Ратуша открывает зону. DEV → «Камера»: ползунки наклона, перспективы (поле зрения), ближе / дальше всего и текущего отдаления; подобранное запоминается на устройстве (только редактор и отладочная сборка), «Сбросить» — числа из сцены. |
| 05.11.2026 | Claude (Саша) | **Решение команды: только 3D-поселение, камера перспективная.** (1) В 3D перенесены Золотой рудник (золото, Ратуша 2, 50 камня), Кристалл (Ратуша 6, цена из `Data_Kristall`) и Терем (`HeroCastle`, окно героя; фигурка героя гуляет только по 2D-сетке, в 3D — нет). (2) Перенос 2D-города: если в сохранении нет `settlement3d`, а есть 2D-город, при первом входе в 3D Ратуша получает уровень и улучшение из 2D, остальные здания встают на ближайшие свободные места с теми же уровнями и стройками (`City3DController.MoveFrom2D`, итог — в Console «[3D] Перенос 2D-города»). Секция `settlement` в файле не меняется. (3) `SceneNavigator.CitySceneName` = City3D; MVP1 выключена в Build Settings (сцена и код 2D остаются в репозитории до конца проверки). (4) `City3DCamera.perspective` (по умолчанию да, поле зрения 40°) — угол, высота и поле зрения ТЕСТ. Меню стройки — в два столбца. |
+1
View File
@@ -158,6 +158,7 @@
| Дата | Кто | Что |
| --- | --- | --- |
| 06.11.2026 | Claude (Саша) | Поход на движущуюся армию (Разведка М2): `MarchData.targetMarchId`, `meetX/meetY`, `leaveX/leaveY`; `WorldSimulation.ArmyRecon.cs` — `StartArmyRecon`, `TryPlanArmyRecon` (перехват с шагом 10 с, наблюдение рядом с армией, путь домой от места конца наблюдения), позиция и отрезок для отрисовки; `MarchManager.TryStartArmyRecon`, `TryPlanArmyRecon`, `ReconTargetId`. Всё время похода известно при выступлении — офлайн считается так же. |
| 04.11.2026 | Claude | Разведка М2: `IWorld.TryStartRecon`, `MarchData.reconLevel` / `reconHoldMs`, отряд наблюдает у цели (`Gathering`) и идёт домой от конца наблюдения, `WorldEventType.ReconCompleted`; `IWorldMap.TryGetReconTarget` — снимок цели для отчёта. |
| 04.11.2026 | Claude | `MarchData.missingUnits`: при бое пропавшие передаются Армии (`MarkMissing`); `MarchReturnedEvent.Missing`. |
| 04.11.2026 | Claude | Карта мира в 2.5D на примитивах: карта лежит на земле (X/Z), замок — стена с башней, города — кубы, лагеря — цилиндры, точки — ромбы, отряды — кубики с полосой маршрута; подписи смотрят в камеру; наклонная перспективная камера (`MapCameraController`: перетаскивание, щипок, тап, границы); солнце в сцене; камера — 3D-рендерер URP (`Map3D.UseRenderer3D`). Логика, окна и кнопки не менялись. Ветка `feature/3d-settlement`. |