Модуль Боя (Bogatyri.Battle, без Unity и float): - сила стороны по типам войск, сила воина — из GameData.Troops (4/5/9/16) - контры +25%: пехота > кавалерия > лучники > пехота (по составу врага) - стены +20% защитникам; осадные +50% против стен, −25% в поле - потери по типам: проигравший — половина, победитель — до половины (чем ровнее бой, тем больше) - BattleRequest/BattleOutcome расширены (состав, стены, бонусы, сила сторон); бой «числом» считается пехотой - формула — предложение, числа константами в BattleSystem.cs, ждёт утверждения командой Мир и Армия: - состав гарнизона у каждой цели (WorldMapLayout.GarrisonUnits, из id — карта не изменилась) - поход хранит состав по типам; выжившие возвращаются точно по типам (IArmy.GetMarchUnits/ReturnUnits) - время походов по GameClock: перемотка в DEV-панели проматывает и походы Интерфейс карты: - окно атаки: состав гарнизона и ожидаемый исход (сила сторон, победа/поражение, потери) - верхняя панель: воины в походах (а не число отрядов) Тесты: контры, стены, осадные, потери в пределах состава, состав гарнизонов. Документы Battle, World, Army, Core, README обновлены. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
264 lines
11 KiB
C#
264 lines
11 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
|
|
namespace Bogatyri.World
|
|
{
|
|
public enum MapNodeType
|
|
{
|
|
PlayerCity, // город игрока
|
|
NeighborCity, // соседний город -- другой игрок
|
|
BossCastle // Большой замок с боссом в центре карты
|
|
}
|
|
|
|
[Serializable]
|
|
public class RewardEntry
|
|
{
|
|
public string resource; // имя из ResourceType (Gold, Food, ...) -- логика карты про экономику не знает
|
|
public int amount;
|
|
}
|
|
|
|
// Точка на карте мира. Координаты -- целые "милли-единицы" (1000 = 1 клетка Unity),
|
|
// чтобы расчёты были одинаковыми на клиенте и на сервере.
|
|
[Serializable]
|
|
public class MapNode
|
|
{
|
|
public string id;
|
|
public string ownerId; // id игрока-владельца (у замка -- пусто)
|
|
public string displayName;
|
|
public MapNodeType type;
|
|
public int x;
|
|
public int y;
|
|
|
|
public bool canBeAttacked;
|
|
public int garrison; // солдат в обороне
|
|
public List<RewardEntry> rewards = new List<RewardEntry>();
|
|
}
|
|
|
|
// Раскладка карты: замок с боссом в центре, города игроков разбросаны вокруг хаотично.
|
|
// Раскладка строится из seed, поэтому одинакова при каждом запуске и совпадёт с серверной.
|
|
// В мультиплеере карту и реальных соседей будет присылать сервер.
|
|
public static class WorldMapLayout
|
|
{
|
|
public const string PlayerCityId = "player_city";
|
|
public const string BossCastleId = "boss_castle";
|
|
|
|
public const int DefaultSeed = 20260925;
|
|
public const int NeighborCount = 20;
|
|
|
|
// Границы карты (от центра)
|
|
public const int HalfWidth = 20000;
|
|
public const int HalfHeight = 14000;
|
|
|
|
const int EdgeMargin = 1500; // города не липнут к краю карты
|
|
const int CastleClearRadius = 4500; // вокруг замка свободная зона
|
|
const int MinCitySpacing = 3600; // минимальное расстояние между городами
|
|
const int MaxPlacementAttempts = 1000;
|
|
|
|
// Пока соседи -- заглушки с именами. Потом это будут ники реальных игроков.
|
|
static readonly string[] NeighborNames =
|
|
{
|
|
"Ратибор", "Светозар", "Добромир", "Велимир", "Ярополк",
|
|
"Всеслав", "Мстислав", "Остромир", "Богдан", "Любомир",
|
|
"Твердислав", "Радим", "Гостомысл", "Вышеслав", "Путята",
|
|
"Ставр", "Чурила", "Микула", "Василиса", "Забава"
|
|
};
|
|
|
|
public static List<MapNode> CreateDefault()
|
|
{
|
|
return Generate(DefaultSeed, NeighborCount);
|
|
}
|
|
|
|
public static List<MapNode> Generate(int seed, int neighborCount)
|
|
{
|
|
var rng = new DeterministicRandom(seed);
|
|
var nodes = new List<MapNode>();
|
|
|
|
var castle = new MapNode
|
|
{
|
|
id = BossCastleId,
|
|
displayName = "Замок Змея Горыныча",
|
|
type = MapNodeType.BossCastle,
|
|
x = 0,
|
|
y = 0,
|
|
canBeAttacked = true,
|
|
garrison = 20 // требует балансировки
|
|
};
|
|
// Небольшие числа -- по концепции экономики
|
|
castle.rewards.Add(new RewardEntry { resource = "Gold", amount = 20 });
|
|
castle.rewards.Add(new RewardEntry { resource = "Food", amount = 40 });
|
|
castle.rewards.Add(new RewardEntry { resource = "Iron", amount = 10 });
|
|
nodes.Add(castle);
|
|
|
|
if (TryFindFreeSpot(rng, nodes, out int px, out int py))
|
|
{
|
|
nodes.Add(new MapNode
|
|
{
|
|
id = PlayerCityId,
|
|
ownerId = "local_player",
|
|
displayName = "Твой город",
|
|
type = MapNodeType.PlayerCity,
|
|
x = px,
|
|
y = py
|
|
});
|
|
}
|
|
|
|
for (int i = 0; i < neighborCount; i++)
|
|
{
|
|
if (!TryFindFreeSpot(rng, nodes, out int x, out int y)) break; // карта переполнена
|
|
|
|
int garrison = rng.Range(8, 41);
|
|
var city = new MapNode
|
|
{
|
|
id = $"city_{i + 1}",
|
|
ownerId = $"npc_{i + 1}",
|
|
displayName = NeighborNames[i % NeighborNames.Length],
|
|
type = MapNodeType.NeighborCity,
|
|
x = x,
|
|
y = y,
|
|
canBeAttacked = true,
|
|
garrison = garrison
|
|
};
|
|
|
|
// Добыча растёт вместе с гарнизоном: сильный сосед -- богаче, но опаснее
|
|
city.rewards.Add(new RewardEntry { resource = "Gold", amount = 5 + garrison / 3 });
|
|
city.rewards.Add(new RewardEntry { resource = "Food", amount = 10 + garrison });
|
|
city.rewards.Add(new RewardEntry { resource = "Wood", amount = 10 + garrison / 2 });
|
|
if (garrison >= 20) city.rewards.Add(new RewardEntry { resource = "Iron", amount = garrison / 4 });
|
|
|
|
nodes.Add(city);
|
|
}
|
|
|
|
return nodes;
|
|
}
|
|
|
|
// Случайная точка, не слишком близко к замку и к другим городам
|
|
static bool TryFindFreeSpot(DeterministicRandom rng, List<MapNode> nodes, out int x, out int y)
|
|
{
|
|
for (int attempt = 0; attempt < MaxPlacementAttempts; attempt++)
|
|
{
|
|
x = rng.Range(-HalfWidth + EdgeMargin, HalfWidth - EdgeMargin + 1);
|
|
y = rng.Range(-HalfHeight + EdgeMargin, HalfHeight - EdgeMargin + 1);
|
|
|
|
bool free = true;
|
|
foreach (var n in nodes)
|
|
{
|
|
int minDistance = n.type == MapNodeType.BossCastle ? CastleClearRadius : MinCitySpacing;
|
|
if (DistanceSquared(n.x, n.y, x, y) < (long)minDistance * minDistance)
|
|
{
|
|
free = false;
|
|
break;
|
|
}
|
|
}
|
|
if (free) return true;
|
|
}
|
|
|
|
x = 0;
|
|
y = 0;
|
|
return false;
|
|
}
|
|
|
|
// Состав гарнизона по типам войск (TroopType). Вычисляется из id цели, а не из генератора карты:
|
|
// так карта (координаты, гарнизоны) осталась прежней, а состав всегда одинаков у клиента и сервера.
|
|
public static int[] GarrisonUnits(MapNode node)
|
|
{
|
|
var units = new int[TroopType.Count];
|
|
if (node == null || node.garrison <= 0) return units;
|
|
|
|
int[] percent;
|
|
if (node.type == MapNodeType.BossCastle)
|
|
{
|
|
percent = new[] { 40, 30, 20, 10 }; // Змей Горыныч держит всех понемногу
|
|
}
|
|
else
|
|
{
|
|
uint h = StableHash(node.id);
|
|
int infantry = 30 + (int)(h % 31); // 30..60%
|
|
int archers = 10 + (int)(h / 31 % 26); // 10..35%
|
|
int cavalry = 5 + (int)(h / 806 % 21); // 5..25%
|
|
int siege = Math.Max(0, 100 - infantry - archers - cavalry);
|
|
percent = new[] { infantry, archers, cavalry, siege };
|
|
}
|
|
|
|
int given = 0;
|
|
for (int i = 0; i < TroopType.Count; i++)
|
|
{
|
|
units[i] = node.garrison * percent[i] / 100;
|
|
given += units[i];
|
|
}
|
|
units[TroopType.Infantry] += node.garrison - given; // остаток округления -- пехоте
|
|
return units;
|
|
}
|
|
|
|
// FNV-1a: одинаков на любой платформе (string.GetHashCode в .NET меняется между запусками)
|
|
static uint StableHash(string s)
|
|
{
|
|
uint hash = 2166136261;
|
|
foreach (char c in s ?? "") hash = (hash ^ c) * 16777619;
|
|
return hash;
|
|
}
|
|
|
|
public static MapNode Find(List<MapNode> nodes, string id)
|
|
{
|
|
foreach (var n in nodes)
|
|
{
|
|
if (n.id == id) return n;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
static long DistanceSquared(int x1, int y1, int x2, int y2)
|
|
{
|
|
long dx = x2 - x1;
|
|
long dy = y2 - y1;
|
|
return dx * dx + dy * dy;
|
|
}
|
|
|
|
// Целочисленный квадратный корень (метод Ньютона) -- одинаков на любой платформе
|
|
public static int Distance(MapNode a, MapNode b)
|
|
{
|
|
long value = DistanceSquared(a.x, a.y, b.x, b.y);
|
|
if (value <= 0) return 0;
|
|
|
|
long r = value;
|
|
long next = (r + 1) / 2;
|
|
while (next < r)
|
|
{
|
|
r = next;
|
|
next = (r + value / r) / 2;
|
|
}
|
|
return (int)r;
|
|
}
|
|
}
|
|
|
|
// Детерминированный генератор случайных чисел (xorshift32).
|
|
// System.Random не годится: его реализация может отличаться между платформами и версиями .NET,
|
|
// а карта у клиента и сервера должна получиться одинаковой.
|
|
public sealed class DeterministicRandom
|
|
{
|
|
private uint state;
|
|
|
|
public DeterministicRandom(int seed)
|
|
{
|
|
state = (uint)seed;
|
|
if (state == 0) state = 0x9E3779B9; // xorshift не работает с нулевым состоянием
|
|
}
|
|
|
|
public uint NextUInt()
|
|
{
|
|
uint x = state;
|
|
x ^= x << 13;
|
|
x ^= x >> 17;
|
|
x ^= x << 5;
|
|
state = x;
|
|
return x;
|
|
}
|
|
|
|
// Число в диапазоне [minInclusive, maxExclusive)
|
|
public int Range(int minInclusive, int maxExclusive)
|
|
{
|
|
uint span = (uint)(maxExclusive - minInclusive);
|
|
return minInclusive + (int)(NextUInt() % span);
|
|
}
|
|
}
|
|
}
|