Слияние: наши панели HUD, стройка, казармы, камера + карта мира, чат и рейтинг от Саши
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "Bogatyri.World.Simulation",
|
||||
"rootNamespace": "Bogatyri.World",
|
||||
"references": [],
|
||||
"includePlatforms": [],
|
||||
"excludePlatforms": [],
|
||||
"allowUnsafeCode": false,
|
||||
"overrideReferences": false,
|
||||
"precompiledReferences": [],
|
||||
"autoReferenced": true,
|
||||
"defineConstraints": [],
|
||||
"versionDefines": [],
|
||||
"noEngineReferences": true
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c93980709f2c5264aa4f49a1cf86d9e1
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,223 @@
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a0cbb52ce8b77c943b4d47e3c8db405a
|
||||
@@ -0,0 +1,225 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Bogatyri.World
|
||||
{
|
||||
public enum MarchState
|
||||
{
|
||||
Outbound, // идут к цели
|
||||
Returning, // бой прошёл, возвращаются домой
|
||||
Finished // вернулись, солдаты и добыча зачислены
|
||||
}
|
||||
|
||||
// Один поход. Всё время -- абсолютное (Unix-время в миллисекундах), а не "осталось N секунд":
|
||||
// поход продолжается, пока игра закрыта, и одинаково понимается клиентом и сервером.
|
||||
[Serializable]
|
||||
public class MarchData
|
||||
{
|
||||
public int id;
|
||||
public string ownerId;
|
||||
public string fromNodeId;
|
||||
public string targetNodeId;
|
||||
|
||||
public int soldiers; // сколько ушло
|
||||
public int survivors; // сколько возвращается (известно после боя)
|
||||
public bool victory;
|
||||
public int defenders; // гарнизон цели в момент боя
|
||||
public int defenderLosses; // сколько потерял защитник
|
||||
public List<RewardEntry> loot = new List<RewardEntry>();
|
||||
|
||||
public long departMs;
|
||||
public long arriveMs;
|
||||
public long returnMs; // известно после боя
|
||||
public MarchState state;
|
||||
}
|
||||
|
||||
public struct BattleOutcome
|
||||
{
|
||||
public bool Victory;
|
||||
public int AttackerLosses;
|
||||
public int DefenderLosses;
|
||||
public int Survivors;
|
||||
}
|
||||
|
||||
// Правила боя. Пока самые простые: побеждает тот, у кого больше солдат.
|
||||
// Потом сюда добавятся типы войск, герой, технологии и т.д. -- остальной код менять не придётся.
|
||||
public static class BattleResolver
|
||||
{
|
||||
public static BattleOutcome Resolve(int attackers, int defenders)
|
||||
{
|
||||
bool victory = attackers > defenders; // ничья -- в пользу защитника
|
||||
|
||||
// Проигравший теряет половину своего войска, победитель -- столько же
|
||||
int loserArmy = victory ? defenders : attackers;
|
||||
int losses = (loserArmy + 1) / 2;
|
||||
|
||||
return new BattleOutcome
|
||||
{
|
||||
Victory = victory,
|
||||
AttackerLosses = losses,
|
||||
DefenderLosses = losses,
|
||||
Survivors = attackers - losses
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public static class MarchRules
|
||||
{
|
||||
// Скорость войска: 400 милли-единиц (0,4 клетки) в секунду.
|
||||
// Ближний сосед (~4 клетки) -- около 10 сек, замок (~10 клеток) -- около 25 сек, край карты -- до минуты.
|
||||
public const int SpeedPerSecond = 400;
|
||||
|
||||
public static long GetTravelTimeMs(MapNode from, MapNode to)
|
||||
{
|
||||
return (long)WorldMapLayout.Distance(from, to) * 1000 / SpeedPerSecond;
|
||||
}
|
||||
}
|
||||
|
||||
public enum WorldEventType
|
||||
{
|
||||
BattleResolved, // войско дошло, бой прошёл
|
||||
MarchReturned // войско вернулось домой
|
||||
}
|
||||
|
||||
public struct WorldEvent
|
||||
{
|
||||
public WorldEventType Type;
|
||||
public MarchData March;
|
||||
}
|
||||
|
||||
// Состояние мира: карта + походы. Не зависит от Unity -- в мультиплеере этот же код крутится на сервере,
|
||||
// а клиент только показывает то, что он вернул.
|
||||
public sealed class WorldSimulation
|
||||
{
|
||||
private readonly List<MapNode> nodes;
|
||||
private readonly List<MarchData> marches = new List<MarchData>();
|
||||
private int nextMarchId = 1;
|
||||
|
||||
public IReadOnlyList<MapNode> Nodes => nodes;
|
||||
public IReadOnlyList<MarchData> Marches => marches;
|
||||
|
||||
public WorldSimulation(List<MapNode> nodes)
|
||||
{
|
||||
this.nodes = nodes;
|
||||
}
|
||||
|
||||
public MapNode GetNode(string id)
|
||||
{
|
||||
return WorldMapLayout.Find(nodes, id);
|
||||
}
|
||||
|
||||
// Восстановление походов из сохранения (или из ответа сервера)
|
||||
public void LoadMarches(List<MarchData> saved)
|
||||
{
|
||||
marches.Clear();
|
||||
if (saved == null) return;
|
||||
|
||||
foreach (var m in saved)
|
||||
{
|
||||
// Поход к точке, которой больше нет на карте (карта поменялась), восстановить нельзя
|
||||
if (GetNode(m.fromNodeId) == null || GetNode(m.targetNodeId) == null) continue;
|
||||
|
||||
marches.Add(m);
|
||||
if (m.id >= nextMarchId) nextMarchId = m.id + 1;
|
||||
}
|
||||
}
|
||||
|
||||
public MarchData StartMarch(string ownerId, string fromId, string targetId, int soldiers, long nowMs, out string error)
|
||||
{
|
||||
MapNode from = GetNode(fromId);
|
||||
MapNode target = GetNode(targetId);
|
||||
|
||||
if (from == null || target == null) { error = "Неизвестная точка на карте"; return null; }
|
||||
if (target == from || !target.canBeAttacked) { error = $"На {target.displayName} нападать нельзя"; return null; }
|
||||
if (soldiers <= 0) { error = "Нужно отправить хотя бы одного солдата"; return null; }
|
||||
|
||||
var march = new MarchData
|
||||
{
|
||||
id = nextMarchId++,
|
||||
ownerId = ownerId,
|
||||
fromNodeId = fromId,
|
||||
targetNodeId = targetId,
|
||||
soldiers = soldiers,
|
||||
survivors = soldiers,
|
||||
departMs = nowMs,
|
||||
arriveMs = nowMs + MarchRules.GetTravelTimeMs(from, target),
|
||||
state = MarchState.Outbound
|
||||
};
|
||||
marches.Add(march);
|
||||
|
||||
error = null;
|
||||
return march;
|
||||
}
|
||||
|
||||
// Продвинуть мир до момента nowMs. Если игра была закрыта долго, за один вызов поход
|
||||
// может и дойти, и вернуться -- события придут в правильном порядке.
|
||||
public void Advance(long nowMs, List<WorldEvent> events)
|
||||
{
|
||||
foreach (var m in marches)
|
||||
{
|
||||
if (m.state == MarchState.Outbound && nowMs >= m.arriveMs)
|
||||
{
|
||||
ResolveBattle(m);
|
||||
events.Add(new WorldEvent { Type = WorldEventType.BattleResolved, March = m });
|
||||
}
|
||||
|
||||
if (m.state == MarchState.Returning && nowMs >= m.returnMs)
|
||||
{
|
||||
m.state = MarchState.Finished;
|
||||
events.Add(new WorldEvent { Type = WorldEventType.MarchReturned, March = m });
|
||||
}
|
||||
}
|
||||
|
||||
marches.RemoveAll(m => m.state == MarchState.Finished);
|
||||
}
|
||||
|
||||
void ResolveBattle(MarchData m)
|
||||
{
|
||||
MapNode from = GetNode(m.fromNodeId);
|
||||
MapNode target = GetNode(m.targetNodeId);
|
||||
|
||||
BattleOutcome outcome = BattleResolver.Resolve(m.soldiers, target.garrison);
|
||||
m.victory = outcome.Victory;
|
||||
m.survivors = outcome.Survivors;
|
||||
m.defenders = target.garrison;
|
||||
m.defenderLosses = outcome.DefenderLosses;
|
||||
// Гарнизон соседа пока не меняется: соседи -- заглушки. Когда они станут реальными игроками,
|
||||
// их потери будет списывать сервер из армии того игрока.
|
||||
m.loot = new List<RewardEntry>();
|
||||
if (outcome.Victory)
|
||||
{
|
||||
foreach (var r in target.rewards)
|
||||
m.loot.Add(new RewardEntry { resource = r.resource, amount = r.amount });
|
||||
}
|
||||
|
||||
// Обратный путь отсчитывается от момента боя, а не от "сейчас" -- важно для офлайна
|
||||
m.returnMs = m.arriveMs + MarchRules.GetTravelTimeMs(target, from);
|
||||
m.state = MarchState.Returning;
|
||||
}
|
||||
|
||||
// Где сейчас войско (для отрисовки). Возвращает долю пути 0..1 и точки начала/конца отрезка.
|
||||
public void GetMarchSegment(MarchData m, long nowMs, out MapNode segmentFrom, out MapNode segmentTo, out double progress)
|
||||
{
|
||||
MapNode home = GetNode(m.fromNodeId);
|
||||
MapNode target = GetNode(m.targetNodeId);
|
||||
|
||||
long start, end;
|
||||
if (m.state == MarchState.Outbound)
|
||||
{
|
||||
segmentFrom = home;
|
||||
segmentTo = target;
|
||||
start = m.departMs;
|
||||
end = m.arriveMs;
|
||||
}
|
||||
else
|
||||
{
|
||||
segmentFrom = target;
|
||||
segmentTo = home;
|
||||
start = m.arriveMs;
|
||||
end = m.returnMs;
|
||||
}
|
||||
|
||||
progress = end > start ? Math.Min(1.0, Math.Max(0.0, (nowMs - start) / (double)(end - start))) : 1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2ab9d33e4ab560a4ea1de0e23afd3fd5
|
||||
Reference in New Issue
Block a user