Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a72a8941ff | ||
|
|
af7150a006 | ||
|
|
538dbea222 | ||
|
|
6d36c14313 | ||
|
|
157d3ba510 | ||
|
|
b51ad41925 | ||
|
|
277afdf84e | ||
|
|
e05cf87173 | ||
|
|
1ce4082b4c | ||
|
|
e1228e0b3e | ||
|
|
a2d85f56c3 | ||
|
|
98cbe85481 |
@@ -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: сноса нет) ----------------
|
||||
|
||||
@@ -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(); });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8d4c2a7e1f5b4e93b6a1c0d9e2f73a58
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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), "шахт нет -- ничего");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ?? "?";
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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
@@ -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`). |
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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°) — угол, высота и поле зрения ТЕСТ. Меню стройки — в два столбца. |
|
||||
|
||||
@@ -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`. |
|
||||
|
||||
Reference in New Issue
Block a user