Files
AlexanderandClaude Opus 5.5 15b06b8f42 Модули Чата и Круга, разворот чата на весь экран, кнопка «Почта» в Safe Area
Модуль Чата (Bogatyri.Chat, без Unity):
- чат вынесен из Social в отдельный модуль; контракт IChat в ядре,
  событие ChatMessageReceivedEvent, LocalPlayer (id и имя игрока)
- панель чата работает только через контракт и события
- разворот чата на весь экран: кнопка «Развернуть/Свернуть» или Esc

Модуль Круга (Bogatyri.Circle, без Unity):
- основать Круг, вступить, выйти; роли главы и участника; 30 мест
- уход главы передаёт главенство, последний участник распускает Круг
- секция сохранения "circle", участвует в сбросе сохранений
- окно «Круг» (кнопка внизу карты мира), демо-Круги до появления сервера
- канал «Круг» в чате — только для участников Круга

Прочее:
- кнопка «Почта» привязана к правому краю нижней панели
  (в безопасной зоне она уезжала за экран)
- тесты Чата и Круга; документы Chat.md, Circle.md, обновлены README, Core, Save

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 19:07:45 +02:00

206 lines
9.1 KiB
C#

using System;
using System.Collections.Generic;
using System.Text;
// МОДУЛЬ КРУГА: объединение игроков -- создать, вступить, выйти, роли главы и участника.
// Документ: Docs/Modules/Circle.md
//
// Без Unity: когда появится сервер, список Кругов и состав будут приходить с него,
// а этот класс заменится сетевой реализацией ICircle.
// Пока других игроков нет -- Круги из рейтинга заполнены выдуманными участниками.
public sealed class CircleService : ICircle, ISaveParticipant
{
public const int MaxMembersPerCircle = 30;
public const int MinNameLength = 3;
public const int MaxNameLength = 24;
public int MaxMembers => MaxMembersPerCircle;
private readonly List<CircleInfo> circles = new List<CircleInfo>();
private CircleInfo current;
public CircleInfo Current => current;
public bool IsInCircle => current != null;
public bool IsLeader => current != null && FindMember(current, LocalPlayer.Id)?.role == CircleRole.Leader;
public CircleService()
{
CreateDemoCircles();
}
public IReadOnlyList<CircleInfo> GetCircles()
{
return circles;
}
// ---------------- Действия игрока ----------------
public bool TryCreate(string name, out string error)
{
name = name?.Trim() ?? "";
if (IsInCircle) { error = "Сначала выйдите из своего Круга"; return false; }
if (!IsValidName(name, out error)) return false;
if (circles.Exists(c => string.Equals(c.name, name, StringComparison.OrdinalIgnoreCase)))
{
error = "Круг с таким названием уже есть";
return false;
}
var circle = new CircleInfo { id = "player_" + Guid.NewGuid().ToString("N").Substring(0, 8), name = name };
circle.members.Add(new CircleMember { playerId = LocalPlayer.Id, name = LocalPlayer.Name, role = CircleRole.Leader });
circles.Insert(0, circle);
current = circle;
EventBus.Publish(new CircleJoinedEvent { CircleId = circle.id, CircleName = circle.name, Created = true });
error = null;
return true;
}
public bool TryJoin(string circleId, out string error)
{
if (IsInCircle) { error = "Вы уже состоите в Круге. Сначала выйдите из него"; return false; }
CircleInfo circle = circles.Find(c => c.id == circleId);
if (circle == null) { error = "Такого Круга нет"; return false; }
if (circle.members.Count >= MaxMembersPerCircle) { error = "В Круге нет свободных мест"; return false; }
circle.members.Add(new CircleMember { playerId = LocalPlayer.Id, name = LocalPlayer.Name, role = CircleRole.Member });
current = circle;
EventBus.Publish(new CircleJoinedEvent { CircleId = circle.id, CircleName = circle.name, Created = false });
error = null;
return true;
}
public void Leave()
{
if (current == null) return;
CircleInfo circle = current;
CircleMember me = FindMember(circle, LocalPlayer.Id);
circle.members.Remove(me);
current = null;
bool disbanded = circle.members.Count == 0;
if (disbanded)
{
circles.Remove(circle);
}
else if (me != null && me.role == CircleRole.Leader)
{
circle.members[0].role = CircleRole.Leader; // главой становится старейший участник
}
EventBus.Publish(new CircleLeftEvent { CircleId = circle.id, CircleName = circle.name, Disbanded = disbanded });
}
public static bool IsValidName(string name, out string error)
{
name = name?.Trim() ?? "";
if (name.Length < MinNameLength) { error = $"Название -- не короче {MinNameLength} символов"; return false; }
if (name.Length > MaxNameLength) { error = $"Название -- не длиннее {MaxNameLength} символов"; return false; }
if (name.IndexOfAny(new[] { '\n', '\r', '|', '<', '>' }) >= 0) { error = "В названии есть недопустимые символы"; return false; }
error = null;
return true;
}
static CircleMember FindMember(CircleInfo circle, string playerId)
{
return circle.members.Find(m => m.playerId == playerId);
}
// ---------------- Сохранение (секция "circle") ----------------
// Модуль без Unity, поэтому формат -- простые строки, а не JSON:
// joined=<id Круга игрока>
// created=<id>|<название>|<роль игрока: Leader/Member> -- Круги, созданные игроком
// Демо-Круги не сохраняются: они одинаковые при каждом запуске.
public string SaveKey => "circle";
public string Capture()
{
var sb = new StringBuilder();
if (current != null) sb.Append("joined=").Append(current.id).Append('\n');
foreach (var c in circles)
{
if (!c.id.StartsWith("player_")) continue;
var me = FindMember(c, LocalPlayer.Id);
if (me == null) continue; // свой Круг, из которого игрок вышел, уже распущен
sb.Append("created=").Append(c.id).Append('|').Append(c.name).Append('|').Append(me.role).Append('\n');
}
return sb.ToString();
}
public void Restore(string data)
{
ResetState();
if (string.IsNullOrEmpty(data)) return;
string joinedId = null;
foreach (var raw in data.Split('\n'))
{
string line = raw.Trim();
if (line.StartsWith("joined=")) joinedId = line.Substring("joined=".Length);
else if (line.StartsWith("created="))
{
string[] parts = line.Substring("created=".Length).Split('|');
if (parts.Length < 2) continue;
var circle = new CircleInfo { id = parts[0], name = parts[1] };
CircleRole role = parts.Length > 2 && parts[2] == nameof(CircleRole.Member) ? CircleRole.Member : CircleRole.Leader;
circle.members.Add(new CircleMember { playerId = LocalPlayer.Id, name = LocalPlayer.Name, role = role });
circles.Insert(0, circle);
}
}
if (joinedId == null) return;
CircleInfo joined = circles.Find(c => c.id == joinedId);
if (joined == null) return; // Круга больше нет
if (FindMember(joined, LocalPlayer.Id) == null)
joined.members.Add(new CircleMember { playerId = LocalPlayer.Id, name = LocalPlayer.Name, role = CircleRole.Member });
current = joined;
}
// Новая игра: игрок ни в каком Круге, свои Круги распущены
public void ResetState()
{
current = null;
circles.Clear();
CreateDemoCircles();
}
// ---------------- Демо-Круги (до появления сервера) ----------------
static readonly string[] DemoCircleNames =
{
"Круг Перуна", "Стальные Волки", "Сыны Руси", "Богатыри", "Северный Страж",
"Вольные", "Златые Земли", "Серые Горны", "Речные", "Перунов Круг"
};
static readonly string[] DemoPlayerNames =
{
"Велес", "Север", "Тайрон", "DreadWolf", "Мороз", "Леший", "Князь", "Варяг", "Сокол", "Ратибор",
"Светозар", "Добромир", "Велимир", "Ярополк", "Всеслав", "Мстислав", "Остромир", "Богдан", "Любомир", "Твердислав",
"Радим", "Гостомысл", "Вышеслав", "Путята", "Ставр", "Чурила", "Микула", "Василиса", "Забава", "IronBear"
};
// Сколько участников в каждом демо-Круге (последний -- полный, чтобы было видно "мест нет")
static readonly int[] DemoMemberCounts = { 28, 24, 22, 19, 17, 14, 12, 9, 6, MaxMembersPerCircle };
void CreateDemoCircles()
{
for (int i = 0; i < DemoCircleNames.Length; i++)
{
var circle = new CircleInfo { id = "circle_" + (i + 1), name = DemoCircleNames[i] };
for (int k = 0; k < DemoMemberCounts[i]; k++)
{
string name = DemoPlayerNames[(i + k) % DemoPlayerNames.Length];
circle.members.Add(new CircleMember
{
playerId = $"npc_{i + 1}_{k + 1}",
name = k < DemoPlayerNames.Length ? name : name + " " + (k + 1),
role = k == 0 ? CircleRole.Leader : CircleRole.Member
});
}
circles.Add(circle);
}
}
}