Files
MVP1/Assets/Scripts/Modules/World/Simulation/WorldMapLayout.cs
T
AlexanderandClaude Opus 5.5 b1a396e9a2 Лагеря разбойников (PvE) на карте мира
Модуль Мира (Bogatyri.World.Simulation, без Unity):
- 12 лагерей трёх уровней: Стан разбойников, Логово лиходеев, Становище Соловья-разбойника
  (гарнизон 6–9 / 14–17 / 26–29, добыча — еда, дерево, золото, металл)
- ставятся отдельным генератором после городов: замок, твой город и соседи остались на местах
- бой в поле (без стен), у разбойников нет осадных
- после победы лагерь разбит на 30 минут (CampRules.RespawnMs), напасть нельзя
- бои считаются по времени прихода: второе войско к разбитому лагерю — без боя, все домой, добычи нет
- секция сохранения world хранит разбитые лагеря (camps); сброс — все лагеря на месте
- MarchReturnedEvent.TargetEmpty, окно в городе пишет «лагерь уже был разбит»

Интерфейс карты:
- лагерь — фиолетовый круг с подписью «название, ур. N, гарнизон»; разбитый — серый с таймером
- окно атаки: уровень лагеря, «бой в поле» / «за стенами», прогноз с учётом стен
- окно «ЛАГЕРЬ ПУСТ», подсказка при нажатии на разбитый лагерь

Тесты: расстановка лагерей, бой в поле без осадных, разбит → второе войско находит пустым → сохранение → появляется снова.
Документы World, Battle, Save, Core, README обновлены. Числа требуют балансировки.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:46:54 +01:00

317 lines
14 KiB
C#

using System;
using System.Collections.Generic;
namespace Bogatyri.World
{
public enum MapNodeType
{
PlayerCity, // город игрока
NeighborCity, // соседний город -- другой игрок
BossCastle, // Большой замок с боссом в центре карты
Camp // PvE-лагерь разбойников: бьётся ради добычи, после победы появляется снова
}
[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 int level; // уровень лагеря (1..3), у остальных точек 0
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;
// Лагеря разбойников (PvE). Ставятся отдельным генератором после городов -- карта городов не изменилась.
public const int CampCount = 12;
public const int CampLevels = 3;
const int CampSpacing = 2600; // лагерь может стоять ближе к городу, чем город к городу
const int CampSeedSalt = 0x5A17C0DE;
// Требует балансировки. Небольшие числа -- по концепции экономики.
static readonly string[] CampNames = { "Стан разбойников", "Логово лиходеев", "Становище Соловья-разбойника" };
static readonly int[] CampGarrison = { 6, 14, 26 }; // + 0..3 случайно
static readonly int[][] CampRewards = // еда, дерево, золото, металл
{
new[] { 15, 15, 0, 0 },
new[] { 25, 25, 8, 0 },
new[] { 40, 40, 15, 8 }
};
// Пока соседи -- заглушки с именами. Потом это будут ники реальных игроков.
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);
}
AddCamps(nodes, new DeterministicRandom(seed ^ CampSeedSalt), CampCount);
return nodes;
}
static void AddCamps(List<MapNode> nodes, DeterministicRandom rng, int count)
{
string[] rewardTypes = { "Food", "Wood", "Gold", "Iron" };
for (int i = 0; i < count; i++)
{
if (!TryFindFreeSpot(rng, nodes, out int x, out int y, CampSpacing)) break;
int level = i % CampLevels + 1; // поровну лагерей каждого уровня
var camp = new MapNode
{
id = $"camp_{i + 1}",
displayName = CampNames[level - 1],
type = MapNodeType.Camp,
x = x,
y = y,
canBeAttacked = true,
level = level,
garrison = CampGarrison[level - 1] + rng.Range(0, 4)
};
int[] amounts = CampRewards[level - 1];
for (int r = 0; r < amounts.Length; r++)
if (amounts[r] > 0) camp.rewards.Add(new RewardEntry { resource = rewardTypes[r], amount = amounts[r] });
nodes.Add(camp);
}
}
// Защитники за стенами: замок и города. Лагерь -- бой в поле.
public static bool IsFortified(MapNode node)
{
return node != null && (node.type == MapNodeType.BossCastle || node.type == MapNodeType.NeighborCity);
}
// Случайная точка, не слишком близко к замку и к другим точкам
static bool TryFindFreeSpot(DeterministicRandom rng, List<MapNode> nodes, out int x, out int y, int spacing = MinCitySpacing)
{
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 : spacing;
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);
if (node.type == MapNodeType.Camp) { infantry += siege; siege = 0; } // у разбойников нет осадных машин
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);
}
}
}