Модули Чата и Круга, разворот чата на весь экран, кнопка «Почта» в 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>
This commit is contained in:
Alexander
2026-10-19 19:07:45 +02:00
co-authored by Claude Opus 5.5
parent 57fc7e0006
commit 15b06b8f42
30 changed files with 1140 additions and 120 deletions
+5
View File
@@ -168,6 +168,11 @@ public static class WorldMapSceneBuilder
SetRect(circleTab, new Vector2(0, 1), new Vector2(0, 1), new Vector2(0, 1), new Vector2(240, -10), new Vector2(220, 54));
chat.circleTabButton = circleTab.GetComponent<Button>();
RectTransform expand = CreateButton(panel, "ExpandButton", "Развернуть", new Color(0.12f, 0.13f, 0.17f), out TMP_Text expandText);
SetRect(expand, new Vector2(1, 1), new Vector2(1, 1), new Vector2(1, 1), new Vector2(-10, -10), new Vector2(150, 54));
expandText.fontSize = 24;
chat.expandButton = expand.GetComponent<Button>();
// Лента: текст прижат к низу, старые строки уходят вверх и обрезаются маской
var messagesGO = new GameObject("Messages", typeof(RectTransform), typeof(RectMask2D));
RectTransform messages = messagesGO.GetComponent<RectTransform>();
+3 -3
View File
@@ -15069,9 +15069,9 @@ RectTransform:
- {fileID: 54912383}
m_Father: {fileID: 503920959}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 2073.0068, y: 172.02252}
m_AnchorMin: {x: 1, y: 1}
m_AnchorMax: {x: 1, y: 1}
m_AnchoredPosition: {x: -162, y: 172.02252}
m_SizeDelta: {x: 128.34, y: 102.34}
m_Pivot: {x: 0, y: 1}
--- !u!114 &1226487462
+8
View File
@@ -18,6 +18,14 @@ public static class GameModules
// Армия: войска пока хранятся в GameProgress, контракт реализует мост
GameServices.Register<IArmy>(ArmyBridge.Service);
// Круг -- чистый C#, сам сохраняет, в каком Круге игрок (секция "circle")
var circle = new CircleService();
GameServices.Register<ICircle>(circle);
SaveRegistry.Register(circle);
// Чат -- чистый C#; история живёт, пока открыта игра (между сценами тоже)
GameServices.Register<IChat>(new ChatService(System.DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()));
// Казармы появляются в списке стройки кодом -- до восстановления зданий из сохранения
EventBus.Subscribe<SettlementRestoringEvent>(_ => BarracksBootstrap.Ensure());
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: ee8294dcaae498342ac5506a282a11a4
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,16 @@
{
"name": "Bogatyri.Chat",
"rootNamespace": "",
"references": [
"Bogatyri.Core"
],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": true
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: b915ec65359a90e4ab597d99b40cdacb
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+118
View File
@@ -0,0 +1,118 @@
using System;
using System.Collections.Generic;
// МОДУЛЬ ЧАТА: общий чат мира и чат Круга. Документ: Docs/Modules/Chat.md
// Без Unity: когда появится сервер, этот класс заменится сетевой реализацией IChat,
// а панель чата (слой приложения) менять не придётся.
//
// История живёт, пока открыта игра: модуль зарегистрирован в GameServices и переживает смену сцен.
// В файл сохранения чат не пишется -- переписку будет хранить сервер.
public sealed class ChatService : IChat
{
public const int MaxLength = 200;
public const int MaxStoredPerChannel = 100;
public int MaxMessageLength => MaxLength;
private readonly Dictionary<ChatChannel, List<ChatMessage>> history = new Dictionary<ChatChannel, List<ChatMessage>>
{
{ ChatChannel.World, new List<ChatMessage>() },
{ ChatChannel.Circle, new List<ChatMessage>() }
};
public ChatService(long nowMs)
{
SeedDemoMessages(nowMs);
// Канал "Круг" -- переписка текущего Круга игрока (модуль Круга сообщает о вступлении и выходе)
EventBus.Subscribe<CircleJoinedEvent>(OnCircleJoined);
EventBus.Subscribe<CircleLeftEvent>(OnCircleLeft);
EventBus.Subscribe<GameResetEvent>(_ => history[ChatChannel.Circle].Clear()); // новая игра -- без Круга
}
public const string SystemSenderId = "system";
// Писать в канал "Круг" можно, только состоя в Круге (если модуль Круга подключён)
public bool CanWrite(ChatChannel channel)
{
if (channel != ChatChannel.Circle) return true;
return !GameServices.TryGet(out ICircle circle) || circle.IsInCircle;
}
void OnCircleJoined(CircleJoinedEvent e)
{
history[ChatChannel.Circle].Clear();
string text = e.Created
? $"Вы основали Круг «{e.CircleName}». Зовите соратников!"
: $"Вы вступили в Круг «{e.CircleName}».";
Add(new ChatMessage { channel = ChatChannel.Circle, senderId = SystemSenderId, senderName = "Круг", text = text, timeMs = NowMs });
}
void OnCircleLeft(CircleLeftEvent e)
{
history[ChatChannel.Circle].Clear();
Add(new ChatMessage
{
channel = ChatChannel.Circle,
senderId = SystemSenderId,
senderName = "Круг",
text = e.Disbanded ? $"Круг «{e.CircleName}» распущен." : $"Вы покинули Круг «{e.CircleName}».",
timeMs = NowMs
});
}
static long NowMs => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
public IReadOnlyList<ChatMessage> GetMessages(ChatChannel channel)
{
return history[channel];
}
public bool Send(ChatChannel channel, string text)
{
text = text?.Trim();
if (string.IsNullOrEmpty(text)) return false;
if (!CanWrite(channel)) return false;
if (text.Length > MaxLength) text = text.Substring(0, MaxLength);
Add(new ChatMessage
{
channel = channel,
senderId = LocalPlayer.Id,
senderName = LocalPlayer.Name,
text = text,
timeMs = NowMs
});
return true;
}
// Входящее сообщение (сейчас -- только свои; позже сюда придут сообщения с сервера)
public void Receive(ChatMessage message)
{
Add(message);
}
void Add(ChatMessage message)
{
List<ChatMessage> list = history[message.channel];
list.Add(message);
if (list.Count > MaxStoredPerChannel) list.RemoveAt(0);
EventBus.Publish(new ChatMessageReceivedEvent { Message = message });
}
// Несколько сообщений "от других игроков", чтобы чат не был пустым. Уберётся с приходом сервера.
void SeedDemoMessages(long nowMs)
{
const long minute = 60_000;
AddDemo(ChatChannel.World, "npc_sever", "Север", "Кто идёт на Змея Горыныча?", nowMs - 5 * minute);
AddDemo(ChatChannel.World, "npc_dreadwolf", "DreadWolf", "Набор в Круг, нужны активные игроки!", nowMs - 4 * minute);
AddDemo(ChatChannel.World, "npc_moroz", "Мороз", "На западе замечен Круг Стальные Волки.", nowMs - 3 * minute);
AddDemo(ChatChannel.World, "npc_tairon", "Тайрон", "Собираем отряд на древний храм.", nowMs - 2 * minute);
AddDemo(ChatChannel.World, "npc_veles", "Велес", "Всем удачной охоты!", nowMs - minute);
}
void AddDemo(ChatChannel channel, string id, string name, string text, long timeMs)
{
history[channel].Add(new ChatMessage { channel = channel, senderId = id, senderName = name, text = text, timeMs = timeMs });
}
}
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 2e7af4ee1c7f41c69fc07d7c840a13dc
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,16 @@
{
"name": "Bogatyri.Circle",
"rootNamespace": "",
"references": [
"Bogatyri.Core"
],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": true
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 9f3dadf8e557473d840cd0d7e6900e64
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,205 @@
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);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 388a95c3ce2349e394b400f27d0724e1
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,3 +1,4 @@
using System;
using System.Collections.Generic;
// Контракты модулей: всё, что один модуль может попросить у другого.
@@ -68,6 +69,77 @@ public interface IBattleSystem
BattleOutcome Resolve(BattleRequest request);
}
// ---------------- Чат: общий мир и Круг ----------------
public enum ChatChannel
{
World, // общий чат всего мира
Circle // чат своего Круга
}
[Serializable]
public class ChatMessage
{
public ChatChannel channel;
public string senderId;
public string senderName;
public string text;
public long timeMs; // Unix-время в миллисекундах
}
public interface IChat
{
int MaxMessageLength { get; }
IReadOnlyList<ChatMessage> GetMessages(ChatChannel channel);
bool CanWrite(ChatChannel channel); // в канал "Круг" -- только состоя в Круге
// Отправить от имени локального игрока. false -- пустое сообщение или канал "Круг" без Круга
bool Send(ChatChannel channel, string text);
}
// ---------------- Круг: объединение игроков ----------------
public enum CircleRole
{
Leader, // глава
Member // участник
}
[Serializable]
public class CircleMember
{
public string playerId;
public string name;
public CircleRole role;
}
[Serializable]
public class CircleInfo
{
public string id;
public string name;
public List<CircleMember> members = new List<CircleMember>();
public string LeaderName
{
get
{
foreach (var m in members) if (m.role == CircleRole.Leader) return m.name;
return "";
}
}
}
public interface ICircle
{
int MaxMembers { get; }
CircleInfo Current { get; } // Круг игрока; null -- игрок не в Круге
bool IsInCircle { get; }
bool IsLeader { get; }
IReadOnlyList<CircleInfo> GetCircles(); // все Круги, в которые можно вступить
bool TryCreate(string name, out string error); // создатель становится главой
bool TryJoin(string circleId, out string error);
void Leave(); // глава один в Круге -- Круг распускается
}
// ---------------- Сохранения: игровое состояние ----------------
public interface ISaveSystem
{
@@ -48,6 +48,30 @@ public struct MarchReturnedEvent
public int Survivors;
}
// ---------------- Чат ----------------
// Новое сообщение в любом канале (своё или чужое)
public struct ChatMessageReceivedEvent
{
public ChatMessage Message;
}
// ---------------- Круг ----------------
// Игрок вступил в Круг или создал свой
public struct CircleJoinedEvent
{
public string CircleId;
public string CircleName;
public bool Created;
}
// Игрок вышел из Круга (Disbanded -- Круг распущен, игрок был в нём один)
public struct CircleLeftEvent
{
public string CircleId;
public string CircleName;
public bool Disbanded;
}
// ---------------- Сохранения ----------------
public struct GameLoadedEvent { }
public struct GameSavedEvent { }
@@ -0,0 +1,7 @@
// Локальный игрок. Пока аккаунтов нет -- одно имя и один id на устройство.
// Когда появится сервер, эти значения будут приходить из аккаунта.
public static class LocalPlayer
{
public const string Id = "local_player";
public const string Name = "Богатырь";
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 79b22348748626644bd5142c32db5821
@@ -7,7 +7,9 @@
"Bogatyri.Core",
"Bogatyri.Army",
"Bogatyri.Battle",
"Bogatyri.World.Simulation"
"Bogatyri.World.Simulation",
"Bogatyri.Chat",
"Bogatyri.Circle"
],
"includePlatforms": [
"Editor"
@@ -24,4 +26,4 @@
],
"versionDefines": [],
"noEngineReferences": false
}
}
+128
View File
@@ -85,6 +85,134 @@ public class ModuleTests
Assert.AreEqual(0, world.Marches.Count);
}
// ---------------- Чат ----------------
[Test]
public void Chat_EmptyMessage_IsRejected()
{
var chat = new ChatService(0);
int before = chat.GetMessages(ChatChannel.World).Count;
Assert.IsFalse(chat.Send(ChatChannel.World, " "));
Assert.AreEqual(before, chat.GetMessages(ChatChannel.World).Count);
}
[Test]
public void Chat_Send_TrimsCutsAndPublishesEvent()
{
var chat = new ChatService(0);
ChatMessage received = null;
EventBus.Subscribe<ChatMessageReceivedEvent>(e => received = e.Message);
Assert.IsTrue(chat.Send(ChatChannel.Circle, " " + new string('а', 500) + " "));
Assert.IsNotNull(received, "событие о новом сообщении");
Assert.AreEqual(ChatChannel.Circle, received.channel);
Assert.AreEqual(LocalPlayer.Id, received.senderId);
Assert.AreEqual(chat.MaxMessageLength, received.text.Length, "длинное сообщение обрезается");
}
[Test]
public void Chat_KeepsOnlyLastMessagesPerChannel()
{
var chat = new ChatService(0);
for (int i = 0; i < 250; i++) chat.Send(ChatChannel.World, "сообщение " + i);
var world = chat.GetMessages(ChatChannel.World);
Assert.AreEqual(ChatService.MaxStoredPerChannel, world.Count);
Assert.AreEqual("сообщение 249", world[world.Count - 1].text);
}
// ---------------- Круг ----------------
[Test]
public void Circle_CreateMakesPlayerLeader_AndCannotJoinSecond()
{
var circle = new CircleService();
Assert.IsFalse(circle.IsInCircle);
Assert.IsTrue(circle.TryCreate(" Дружина ", out string error), error);
Assert.IsTrue(circle.IsInCircle);
Assert.IsTrue(circle.IsLeader);
Assert.AreEqual("Дружина", circle.Current.name);
Assert.IsFalse(circle.TryJoin("circle_1", out _), "нельзя состоять в двух Кругах");
Assert.IsFalse(circle.TryCreate("Ещё один", out _));
}
[Test]
public void Circle_NameValidation()
{
var circle = new CircleService();
Assert.IsFalse(circle.TryCreate("ab", out _), "слишком короткое");
Assert.IsFalse(circle.TryCreate(new string('я', 30), out _), "слишком длинное");
Assert.IsFalse(circle.TryCreate("Круг Перуна", out _), "такое название уже есть");
Assert.IsFalse(circle.TryCreate("a|b<c>", out _), "недопустимые символы");
}
[Test]
public void Circle_JoinFullCircle_IsRejected()
{
var circle = new CircleService();
CircleInfo full = circle.GetCircles()[circle.GetCircles().Count - 1];
Assert.AreEqual(circle.MaxMembers, full.members.Count);
Assert.IsFalse(circle.TryJoin(full.id, out _));
}
[Test]
public void Circle_LeaveAlone_Disbands_LeaveAsMember_KeepsCircle()
{
var circle = new CircleService();
int before = circle.GetCircles().Count;
bool disbanded = false;
EventBus.Subscribe<CircleLeftEvent>(e => disbanded = e.Disbanded);
circle.TryCreate("Дружина", out _);
circle.Leave();
Assert.IsTrue(disbanded);
Assert.AreEqual(before, circle.GetCircles().Count, "свой Круг распущен");
circle.TryJoin("circle_1", out _);
int members = circle.Current.members.Count;
circle.Leave();
Assert.IsFalse(disbanded);
Assert.AreEqual(members - 1, circle.GetCircles()[0].members.Count);
}
[Test]
public void Circle_SaveAndRestore_KeepsMembership()
{
var a = new CircleService();
a.TryCreate("Дружина", out _);
string saved = a.Capture();
var b = new CircleService();
b.Restore(saved);
Assert.IsTrue(b.IsInCircle);
Assert.IsTrue(b.IsLeader);
Assert.AreEqual("Дружина", b.Current.name);
var c = new CircleService();
c.TryJoin("circle_3", out _);
var d = new CircleService();
d.Restore(c.Capture());
Assert.AreEqual("circle_3", d.Current.id);
Assert.IsFalse(d.IsLeader);
}
[Test]
public void Chat_CircleChannel_OnlyWhenInCircle()
{
var circle = new CircleService();
GameServices.Register<ICircle>(circle);
var chat = new ChatService(0);
Assert.IsFalse(chat.Send(ChatChannel.Circle, "привет"), "без Круга писать нельзя");
circle.TryJoin("circle_1", out _);
Assert.AreEqual(1, chat.GetMessages(ChatChannel.Circle).Count, "системное сообщение о вступлении");
Assert.IsTrue(chat.Send(ChatChannel.Circle, "привет"));
Assert.IsTrue(chat.Send(ChatChannel.World, "всем привет"), "общий чат доступен всегда");
}
// ---------------- Ядро ----------------
[Test]
+1 -1
View File
@@ -18,7 +18,7 @@ using UnityEngine;
// Позже TryStartMarch будет отправлять запрос на сервер, а походы и их итоги -- приходить с сервера.
public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant
{
public const string LocalPlayerId = "local_player";
public const string LocalPlayerId = LocalPlayer.Id;
public static MarchManager Instance { get; private set; }
+148 -11
View File
@@ -5,7 +5,8 @@ using TMPro;
using UnityEngine;
using UnityEngine.UI;
// Панель чата: вкладки "Общий мир" / "Круг", лента сообщений, поле ввода и кнопка отправки.
// Панель чата (слой приложения): вкладки "Общий мир" / "Круг", лента сообщений, поле ввода и кнопка отправки.
// Сам чат -- модуль Чата: только через контракт IChat и событие ChatMessageReceivedEvent. Документ: Docs/Modules/Chat.md
public class ChatPanel : MonoBehaviour
{
[Header("Вкладки")]
@@ -19,7 +20,15 @@ public class ChatPanel : MonoBehaviour
public TMP_InputField inputField;
public Button sendButton;
const int MaxShownMessages = 30; // старые всё равно уходят за верхний край ленты
[Header("Разворот на весь экран")]
[Tooltip("Если не задана -- создаётся сама в правом верхнем углу панели")]
public Button expandButton;
[Tooltip("Отступ от краёв экрана в развёрнутом виде")]
public float expandedMargin = 16f;
const int CompactShownMessages = 30; // старые всё равно уходят за верхний край ленты
const int ExpandedShownMessages = 100;
const float ExpandedFontScale = 1.25f;
static readonly Color ActiveTabColor = new Color(0.16f, 0.35f, 0.7f);
static readonly Color InactiveTabColor = new Color(0.12f, 0.13f, 0.17f);
@@ -29,26 +38,128 @@ public class ChatPanel : MonoBehaviour
private ChatChannel currentChannel = ChatChannel.World;
private readonly StringBuilder sb = new StringBuilder();
// Компактный вид -- запоминаем, чтобы вернуться к нему при сворачивании
private RectTransform rect;
private Vector2 compactAnchorMin, compactAnchorMax, compactOffsetMin, compactOffsetMax;
private int compactSiblingIndex;
private float compactFontSize;
private TMP_Text expandLabel;
public bool IsExpanded { get; private set; }
void Start()
{
worldTabButton.onClick.AddListener(() => SwitchChannel(ChatChannel.World));
circleTabButton.onClick.AddListener(() => SwitchChannel(ChatChannel.Circle));
sendButton.onClick.AddListener(Send);
inputField.onSubmit.AddListener(_ => Send()); // Enter на клавиатуре
inputField.characterLimit = ChatService.MaxMessageLength;
inputField.characterLimit = Chat.MaxMessageLength;
EventBus.Subscribe<ChatMessageReceivedEvent>(OnMessageReceived);
EventBus.Subscribe<CircleJoinedEvent>(OnCircleJoined);
EventBus.Subscribe<CircleLeftEvent>(OnCircleLeft);
rect = (RectTransform)transform;
compactFontSize = messagesText.fontSize;
if (expandButton == null) expandButton = CreateExpandButton();
expandLabel = expandButton.GetComponentInChildren<TMP_Text>();
expandButton.onClick.AddListener(ToggleExpanded);
UpdateExpandLabel();
ChatService.Instance.MessageReceived += OnMessageReceived;
SwitchChannel(ChatChannel.World);
}
void Update()
{
if (IsExpanded && Input.GetKeyDown(KeyCode.Escape)) SetExpanded(false);
}
// ---------------- Разворот на весь экран ----------------
public void ToggleExpanded()
{
SetExpanded(!IsExpanded);
}
public void SetExpanded(bool expanded)
{
if (expanded == IsExpanded) return;
if (expanded)
{
compactAnchorMin = rect.anchorMin;
compactAnchorMax = rect.anchorMax;
compactOffsetMin = rect.offsetMin;
compactOffsetMax = rect.offsetMax;
compactSiblingIndex = rect.GetSiblingIndex();
// На весь родитель: это контейнер SafeArea, поэтому окно не залезет под вырез экрана
rect.anchorMin = Vector2.zero;
rect.anchorMax = Vector2.one;
rect.offsetMin = new Vector2(expandedMargin, expandedMargin);
rect.offsetMax = new Vector2(-expandedMargin, -expandedMargin);
rect.SetAsLastSibling(); // поверх остального интерфейса
messagesText.fontSize = compactFontSize * ExpandedFontScale;
}
else
{
rect.anchorMin = compactAnchorMin;
rect.anchorMax = compactAnchorMax;
rect.offsetMin = compactOffsetMin;
rect.offsetMax = compactOffsetMax;
rect.SetSiblingIndex(compactSiblingIndex);
messagesText.fontSize = compactFontSize;
}
IsExpanded = expanded;
UpdateExpandLabel();
Redraw();
}
void UpdateExpandLabel()
{
if (expandLabel != null) expandLabel.text = IsExpanded ? "Свернуть" : "Развернуть";
}
// Кнопка для сцен, собранных до появления разворота (без пересборки через Tools)
Button CreateExpandButton()
{
var go = new GameObject("ExpandButton", typeof(RectTransform), typeof(Image), typeof(Button));
var rt = (RectTransform)go.transform;
rt.SetParent(transform, false);
rt.anchorMin = rt.anchorMax = rt.pivot = new Vector2(1, 1);
rt.sizeDelta = new Vector2(150, 54);
rt.anchoredPosition = new Vector2(-10, -10);
go.GetComponent<Image>().color = InactiveTabColor;
var labelGO = new GameObject("Label", typeof(RectTransform), typeof(TextMeshProUGUI));
var lrt = (RectTransform)labelGO.transform;
lrt.SetParent(rt, false);
lrt.anchorMin = Vector2.zero;
lrt.anchorMax = Vector2.one;
lrt.offsetMin = lrt.offsetMax = Vector2.zero;
var tmp = labelGO.GetComponent<TextMeshProUGUI>();
tmp.fontSize = 24;
tmp.alignment = TextAlignmentOptions.Center;
tmp.color = Color.white;
tmp.raycastTarget = false;
return go.GetComponent<Button>();
}
void OnDestroy()
{
ChatService.Instance.MessageReceived -= OnMessageReceived;
EventBus.Unsubscribe<ChatMessageReceivedEvent>(OnMessageReceived);
EventBus.Unsubscribe<CircleJoinedEvent>(OnCircleJoined);
EventBus.Unsubscribe<CircleLeftEvent>(OnCircleLeft);
}
void OnCircleJoined(CircleJoinedEvent e) { OnCircleChanged(); }
void OnCircleLeft(CircleLeftEvent e) { OnCircleChanged(); }
void SwitchChannel(ChatChannel channel)
{
currentChannel = channel;
UpdateInputState();
worldTabButton.image.color = channel == ChatChannel.World ? ActiveTabColor : InactiveTabColor;
circleTabButton.image.color = channel == ChatChannel.Circle ? ActiveTabColor : InactiveTabColor;
Redraw();
@@ -56,29 +167,55 @@ public class ChatPanel : MonoBehaviour
void Send()
{
if (ChatService.Instance.Send(currentChannel, inputField.text))
if (Chat.Send(currentChannel, inputField.text))
{
inputField.text = "";
}
inputField.ActivateInputField(); // курсор остаётся в поле -- можно писать дальше
}
void OnMessageReceived(ChatMessage message)
static IChat Chat => GameServices.Get<IChat>();
// Канал "Круг" без Круга: поле ввода выключено, в ленте -- подсказка
void UpdateInputState()
{
if (message.channel == currentChannel) Redraw();
bool canWrite = Chat.CanWrite(currentChannel);
inputField.interactable = canWrite;
sendButton.interactable = canWrite;
if (inputField.placeholder is TMP_Text placeholder)
placeholder.text = canWrite ? "Введите сообщение..." : "Вступите в Круг, чтобы писать";
}
void OnCircleChanged()
{
UpdateInputState();
Redraw();
}
void OnMessageReceived(ChatMessageReceivedEvent e)
{
if (e.Message.channel == currentChannel) Redraw();
}
void Redraw()
{
IReadOnlyList<ChatMessage> messages = ChatService.Instance.GetMessages(currentChannel);
int start = Mathf.Max(0, messages.Count - MaxShownMessages);
IReadOnlyList<ChatMessage> messages = Chat.GetMessages(currentChannel);
if (messages.Count == 0 && !Chat.CanWrite(currentChannel))
{
messagesText.text = "<color=#9AA3B5>Вы не состоите в Круге. Нажмите «Круг» внизу экрана, " +
"чтобы вступить или основать свой.</color>";
return;
}
int shown = IsExpanded ? ExpandedShownMessages : CompactShownMessages;
int start = Mathf.Max(0, messages.Count - shown);
sb.Clear();
for (int i = start; i < messages.Count; i++)
{
ChatMessage m = messages[i];
string time = DateTimeOffset.FromUnixTimeMilliseconds(m.timeMs).ToLocalTime().ToString("HH:mm");
string color = m.senderId == MarchManager.LocalPlayerId ? OwnNameColor : GetNameColor(m.senderId);
string color = m.senderId == LocalPlayer.Id ? OwnNameColor : GetNameColor(m.senderId);
if (sb.Length > 0) sb.Append('\n');
// <noparse> -- чтобы игрок не мог вставить в сообщение теги оформления
-95
View File
@@ -1,95 +0,0 @@
using System;
using System.Collections.Generic;
public enum ChatChannel
{
World, // общий чат всего мира
Circle // чат своего Круга
}
[Serializable]
public class ChatMessage
{
public ChatChannel channel;
public string senderId;
public string senderName;
public string text;
public long timeMs; // Unix-время в миллисекундах
}
// История чата. Обычный класс со статическим экземпляром -- переживает смену сцен,
// поэтому переписка не теряется при переходе город <-> карта. Пока всё локально;
// когда появится сервер, Send будет отправлять сообщение туда, а входящие -- приходить через MessageReceived.
public class ChatService
{
public const int MaxMessageLength = 200;
public const int MaxStoredPerChannel = 100;
// Пока аккаунтов нет -- одно имя для локального игрока
public const string LocalPlayerName = "Богатырь";
private static ChatService instance;
public static ChatService Instance => instance ??= new ChatService(MarchManager.NowMs);
public event Action<ChatMessage> MessageReceived;
private readonly Dictionary<ChatChannel, List<ChatMessage>> history = new Dictionary<ChatChannel, List<ChatMessage>>
{
{ ChatChannel.World, new List<ChatMessage>() },
{ ChatChannel.Circle, new List<ChatMessage>() }
};
ChatService(long nowMs)
{
SeedDemoMessages(nowMs);
}
public IReadOnlyList<ChatMessage> GetMessages(ChatChannel channel)
{
return history[channel];
}
public bool Send(ChatChannel channel, string text)
{
text = text?.Trim();
if (string.IsNullOrEmpty(text)) return false;
if (text.Length > MaxMessageLength) text = text.Substring(0, MaxMessageLength);
Add(new ChatMessage
{
channel = channel,
senderId = MarchManager.LocalPlayerId,
senderName = LocalPlayerName,
text = text,
timeMs = MarchManager.NowMs
});
return true;
}
void Add(ChatMessage message)
{
List<ChatMessage> list = history[message.channel];
list.Add(message);
if (list.Count > MaxStoredPerChannel) list.RemoveAt(0);
MessageReceived?.Invoke(message);
}
// Несколько сообщений "от других игроков", чтобы чат не был пустым. Уберётся с приходом сервера.
void SeedDemoMessages(long nowMs)
{
const long minute = 60_000;
AddDemo(ChatChannel.World, "npc_sever", "Север", "Кто идёт на Змея Горыныча?", nowMs - 5 * minute);
AddDemo(ChatChannel.World, "npc_dreadwolf", "DreadWolf", "Набор в Круг, нужны активные игроки!", nowMs - 4 * minute);
AddDemo(ChatChannel.World, "npc_moroz", "Мороз", "На западе замечен Круг Стальные Волки.", nowMs - 3 * minute);
AddDemo(ChatChannel.World, "npc_tairon", "Тайрон", "Собираем отряд на древний храм.", nowMs - 2 * minute);
AddDemo(ChatChannel.World, "npc_veles", "Велес", "Всем удачной охоты!", nowMs - minute);
AddDemo(ChatChannel.Circle, "npc_ratibor", "Ратибор", "Братья, завтра вместе идём на замок.", nowMs - 10 * minute);
AddDemo(ChatChannel.Circle, "npc_svetozar", "Светозар", "Скиньте еды в казну Круга, кому не жалко.", nowMs - 6 * minute);
}
void AddDemo(ChatChannel channel, string id, string name, string text, long timeMs)
{
history[channel].Add(new ChatMessage { channel = channel, senderId = id, senderName = name, text = text, timeMs = timeMs });
}
}
+147
View File
@@ -0,0 +1,147 @@
using System;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
// Окно "Круг" (слой приложения): вступить, основать, состав, покинуть.
// Сам Круг -- модуль Круга: только через контракт ICircle и события CircleJoinedEvent / CircleLeftEvent.
// Документ: Docs/Modules/Circle.md
//
// Открыть: CircleWindow.Open() -- кнопка "Круг" внизу карты мира.
public static class CircleWindow
{
const float ListHeight = 620f;
static GameObject root;
static ICircle Circle => GameServices.Get<ICircle>();
public static bool IsOpen => root != null;
public static void Open()
{
Close();
RectTransform card = UIKit.Modal(Circle.IsInCircle ? Circle.Current.name : "Круг", out root);
if (Circle.IsInCircle) BuildMyCircle(card);
else BuildChooseCircle(card);
var close = UIKit.Button(card, "Закрыть", UIKit.Theme.Gray, Close, -1, 90, 30);
UIKit.Size(close, -1, 90);
}
public static void Close()
{
if (root != null) UnityEngine.Object.Destroy(root);
root = null;
}
// ---------------- Игрок не в Круге ----------------
static void BuildChooseCircle(RectTransform card)
{
UIKit.Text(card, "Вы не состоите в Круге. Вступите в один из Кругов или основайте свой — " +
"вместе проще брать крепости и держать территорию.", 28, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
UIKit.Button(card, "Основать свой Круг", UIKit.Theme.Gold, AskNameAndCreate, -1, 90, 30);
var list = ScrollArea(card);
foreach (var c in Circle.GetCircles())
{
CircleInfo circle = c;
bool full = circle.members.Count >= Circle.MaxMembers;
var row = UIKit.Row(list, 100, 16, UIKit.Theme.Card, 14);
UIKit.Icon(row, BannerColor(circle.name), circle.name.Substring(0, 1), 72);
var info = UIKit.Card(row, new Color(0, 0, 0, 0), 0, 2);
UIKit.Size(info, -1, -1, 1);
UIKit.Text(info, circle.name, 30, UIKit.Theme.Text).fontStyle = FontStyles.Bold;
UIKit.Text(info, $"Глава: {circle.LeaderName} • участников {circle.members.Count} / {Circle.MaxMembers}",
22, UIKit.Theme.TextDim);
var join = UIKit.Button(row, full ? "Мест нет" : "Вступить", full ? UIKit.Theme.Gray : UIKit.Theme.Green,
() => Join(circle), -1, 76, 26);
UIKit.Size(join, 210, 76);
join.interactable = !full;
}
}
static void AskNameAndCreate()
{
UIKit.InputDialog("Название Круга", "", CircleService.MaxNameLength, name =>
{
if (Circle.TryCreate(name, out string error))
{
UIKit.Toast($"Круг «{name}» основан. Вы -- глава");
Open();
}
else UIKit.Toast(error);
});
}
static void Join(CircleInfo circle)
{
if (Circle.TryJoin(circle.id, out string error))
{
UIKit.Toast($"Вы вступили в Круг «{circle.name}»");
Open();
}
else UIKit.Toast(error);
}
// ---------------- Игрок в Круге ----------------
static void BuildMyCircle(RectTransform card)
{
CircleInfo circle = Circle.Current;
string role = Circle.IsLeader ? "Глава" : "Участник";
UIKit.Text(card, $"Ваша роль: {role} • участников {circle.members.Count} / {Circle.MaxMembers}",
28, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
var list = ScrollArea(card);
foreach (var m in circle.members)
{
bool me = m.playerId == LocalPlayer.Id;
var row = UIKit.Row(list, 80, 14, me ? new Color(0.24f, 0.24f, 0.3f) : UIKit.Theme.Card, 12);
UIKit.Icon(row, BannerColor(m.name), m.name.Substring(0, 1), 56);
var name = UIKit.Text(row, me ? m.name + " (вы)" : m.name, 28, me ? UIKit.Theme.Gold : UIKit.Theme.Text);
UIKit.Size(name, -1, -1, 1);
UIKit.Size(UIKit.Text(row, m.role == CircleRole.Leader ? "Глава" : "Участник", 24,
m.role == CircleRole.Leader ? UIKit.Theme.Gold : UIKit.Theme.TextDim, TextAlignmentOptions.Right), 180);
}
string leaveText = circle.members.Count == 1
? "Вы в Круге один -- если выйти, Круг будет распущен."
: Circle.IsLeader ? "Главой станет старейший участник Круга." : "Вернуться можно в любой момент, если будут места.";
UIKit.Button(card, "Покинуть Круг", UIKit.Theme.Red, () =>
UIKit.Confirm("Покинуть Круг?", leaveText, "Покинуть", () =>
{
Circle.Leave();
UIKit.Toast("Вы покинули Круг");
Open();
}), -1, 90, 30);
}
// ---------------- Помощники ----------------
// Список фиксированной высоты: окно растягивается по содержимому, а Кругов может быть много
static RectTransform ScrollArea(RectTransform card)
{
var holder = UIKit.Make("ListHolder", card);
UIKit.Size(holder, -1, ListHeight);
return UIKit.ScrollList(holder, out ScrollRect _, 10);
}
// Один и тот же Круг -- всегда один цвет (string.GetHashCode в Unity меняется между запусками)
static Color BannerColor(string name)
{
Color[] colors =
{
new Color(0.15f, 0.35f, 0.75f), new Color(0.45f, 0.2f, 0.18f), new Color(0.7f, 0.15f, 0.15f),
new Color(0.2f, 0.45f, 0.2f), new Color(0.75f, 0.5f, 0.1f), new Color(0.35f, 0.35f, 0.4f)
};
int hash = 17;
foreach (char ch in name) hash = hash * 31 + ch;
return colors[(hash & int.MaxValue) % colors.Length];
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: a54663bcdb9d4a3fbf5f586bcd3e6296
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -73,7 +73,7 @@ public class WorldMapController : MonoBehaviour
if (ratingButton != null) ratingButton.onClick.AddListener(ratingPanel.Open);
HookUpComingSoon(worldMapNavButton, "Карта мира");
HookUpComingSoon(eventsNavButton, "События");
HookUpComingSoon(circleNavButton, "Круг");
if (circleNavButton != null) circleNavButton.onClick.AddListener(CircleWindow.Open); // модуль Круга
HookUpComingSoon(settlementNavButton, "Поселение");
sendButton.onClick.AddListener(OnSendPressed);
cancelButton.onClick.AddListener(CloseAttackPanel);
+69
View File
@@ -0,0 +1,69 @@
# Чат
Общий чат мира и чат Круга: история сообщений, отправка от имени игрока, новые сообщения для интерфейса.
- **Сборка:** `Bogatyri.Chat` (без Unity — когда появится сервер, модуль заменится сетевой реализацией того же контракта).
- **Папка:** `Assets/Scripts/Modules/Chat`.
- **Зависит от:** ядра.
## Контракт `IChat`
| Член | Что делает |
| --- | --- |
| `MaxMessageLength` | Максимальная длина сообщения (200 символов). |
| `GetMessages(channel)` | История канала: `ChatChannel.World` или `ChatChannel.Circle`. |
| `CanWrite(channel)` | Можно ли писать в канал: в «Круг» — только состоя в Круге. |
| `Send(channel, text)` | Отправить от имени локального игрока. Пробелы по краям обрезаются, слишком длинный текст укорачивается. Пустое сообщение и сообщение в «Круг» без Круга не отправляются (`false`). |
Получить: `GameServices.Get<IChat>()`. Регистрирует `App/GameModules`.
Типы в ядре:
- `ChatChannel`;
- `ChatMessage` (`channel`, `senderId`, `senderName`, `text`, `timeMs`);
- `LocalPlayer` (`Id`, `Name` — пока одно имя «Богатырь» на устройство).
## События
- **Публикует:** `ChatMessageReceivedEvent` — новое сообщение в любом канале, своё или чужое.
- **Слушает:**
- `CircleJoinedEvent` — лента «Круг» очищается, появляется системное сообщение «Вы вступили в Круг…» или «Вы основали Круг…»;
- `CircleLeftEvent` — лента «Круг» очищается, появляется «Вы покинули Круг…» или «Круг распущен»;
- `GameResetEvent` — лента «Круг» очищается.
## Сохранение
Нет. История живёт, пока открыта игра, и не теряется при переходе город ↔ карта мира. В каждом канале хранятся последние 100 сообщений. Переписку будет хранить сервер.
## Интерфейс (слой приложения)
`Assets/Scripts/Social/ChatPanel.cs` — панель на карте мира:
- **вкладки** «Общий мир» и «Круг»;
- **лента сообщений**: время, имя, текст; у каждого игрока свой цвет имени, своё имя золотое; теги оформления в тексте игрока не срабатывают (`<noparse>`);
- **поле ввода**: отправка кнопкой «>» или клавишей Enter;
- **разворот на весь экран**: кнопка «Развернуть» / «Свернуть» в правом верхнем углу или клавиша Esc.
- В развёрнутом виде окно занимает всю безопасную зону экрана, шрифт крупнее в 1,25 раза, видно 100 последних сообщений (в компактном — 30).
- Если сцена собрана до появления кнопки, панель создаёт её сама.
Панель работает только через `IChat` и `ChatMessageReceivedEvent`.
## Файлы
| Файл | Что это |
| --- | --- |
| `Modules/Chat/ChatService.cs` | Реализация `IChat`: история каналов, отправка, демо-сообщения других игроков. |
| `Modules/Core/LocalPlayer.cs` | Id и имя локального игрока. |
| `Social/ChatPanel.cs` (слой приложения) | Панель чата. |
| `Editor/WorldMapSceneBuilder.cs` | Собирает панель чата в сцене карты мира. |
## Решения и план
- **Сервер (этап 10):** сетевая реализация `IChat`. Входящие сообщения — через `ChatService.Receive` → `ChatMessageReceivedEvent`.
- **Круг:** канал «Круг» привязан к модулю Круга. Если игрок не в Круге, поле ввода выключено, а в ленте подсказка открыть «Круг». Когда появится сервер, переписка Круга будет приходить с него.
- **Демо-сообщения** убрать, когда появятся настоящие игроки.
## Журнал изменений
| Дата | Кто | Что |
| --- | --- | --- |
| 19.10.2026 | Claude | Канал «Круг» привязан к модулю Круга: `IChat.CanWrite`, системные сообщения о вступлении и выходе, подсказка и выключенный ввод без Круга. Демо-сообщения в канале «Круг» убраны. |
| 19.10.2026 | Claude | Чат вынесен в отдельный модуль `Bogatyri.Chat`: контракт `IChat`, событие `ChatMessageReceivedEvent`, `LocalPlayer` в ядре. Панель чата больше не обращается к `MarchManager` и `ChatService` напрямую. Добавлены разворот на весь экран и тесты (пустое сообщение, обрезка, событие, предел истории). |
+95
View File
@@ -0,0 +1,95 @@
# Круг
Объединение игроков: основать свой Круг, вступить в чужой, выйти; роли главы и участника. Это этап 7 плана. Территория Круга — крепость, казна, гарнизон — будет на этапе 8, поверх этого модуля.
- **Сборка:** `Bogatyri.Circle` (без Unity — когда появится сервер, список Кругов и состав будут приходить с него).
- **Папка:** `Assets/Scripts/Modules/Circle`.
- **Зависит от:** ядра.
## Контракт `ICircle`
| Член | Что делает |
| --- | --- |
| `Current` | Круг игрока (`CircleInfo`: id, название, участники); `null` — игрок не в Круге. |
| `IsInCircle`, `IsLeader` | В Круге ли игрок и глава ли он. |
| `MaxMembers` | Мест в Круге — 30. |
| `GetCircles()` | Все Круги, в которые можно вступить. |
| `TryCreate(name, out error)` | Основать Круг; создатель становится главой. |
| `TryJoin(circleId, out error)` | Вступить участником. |
| `Leave()` | Выйти. |
Получить: `GameServices.Get<ICircle>()`. Регистрирует `App/GameModules`.
## Правила
| Ситуация | Что происходит |
| --- | --- |
| Основать или вступить, уже состоя в Круге | Нельзя: сначала нужно выйти. Игрок состоит только в одном Круге. |
| Название Круга | От 3 до 24 символов, без `\|`, `<`, `>` и переводов строки. Название не должно повторять существующее, регистр не важен. |
| Круг полон (30 из 30) | Вступить нельзя. |
| Выходит глава, в Круге есть другие | Главой становится старейший участник. |
| Выходит единственный участник | Круг распускается. |
## События
**Публикует:**
- `CircleJoinedEvent` — игрок вступил (`Created = false`) или основал Круг (`Created = true`);
- `CircleLeftEvent` — игрок вышел (`Disbanded = true` — Круг распущен).
**Слушает:** ничего.
На события Круга подписан модуль Чата: канал «Круг» становится перепиской текущего Круга игрока.
## Сохранение
Секция `"circle"`. Модуль без Unity, поэтому это не JSON, а простые строки:
```
joined=<id Круга игрока>
created=<id>|<название>|<роль игрока: Leader или Member>
```
Демо-Круги не сохраняются: они одинаковые при каждом запуске.
Сброс сохранений (`ResetState`): игрок ни в каком Круге, его собственные Круги распущены.
## Интерфейс (слой приложения)
`Assets/Scripts/Social/CircleWindow.cs` — окно на `UIKit`. Открывается кнопкой «Круг» внизу карты мира.
**Игрок не в Круге:**
- пояснение и кнопка «Основать свой Круг» — спрашивает название;
- список Кругов: герб, название, глава, «участников N / 30», кнопка «Вступить» или «Мест нет».
**Игрок в Круге:**
- название и роль игрока;
- состав: каждый участник с ролью, сам игрок выделен;
- кнопка «Покинуть Круг» с подтверждением; в подтверждении сказано, станет ли кто-то главой или Круг будет распущен.
## Файлы
| Файл | Что это |
| --- | --- |
| `Modules/Circle/CircleService.cs` | Реализация `ICircle` и `ISaveParticipant`, демо-Круги. |
| `Modules/Core/Contracts/ModuleContracts.cs` | `ICircle`, `CircleInfo`, `CircleMember`, `CircleRole`. |
| `Modules/Core/Events/GameEvents.cs` | `CircleJoinedEvent`, `CircleLeftEvent`. |
| `Social/CircleWindow.cs` (слой приложения) | Окно Круга. |
## Решения и план
- **Сейчас других игроков нет.** 10 Кругов из рейтинга заполнены выдуманными участниками (от 6 до 30), один Круг полный — чтобы было видно «Мест нет».
- **Этап 8 — территория Круга:**
- совместный штурм крепости;
- казна и восстановление (500 дерева, 300 камня, 100 металла, 50 золота, 300 еды для T1);
- гарнизон и рабочие от участников;
- добыча в казну, уровни крепости, содержание.
- **Рейтинг Кругов** пока на своих демо-данных (`RatingService`). Его нужно перевести на `ICircle`, чтобы в нём появлялся и Круг игрока.
- **Роли.** Пока только глава и участник. Заместители, приглашения, исключение участника и заявки на вступление — после сервера.
- **Сервер (этап 10):** сетевая реализация `ICircle`.
## Журнал изменений
| Дата | Кто | Что |
| --- | --- | --- |
| 19.10.2026 | Claude | Модуль создан: `ICircle` (основать, вступить, выйти, роли), события `CircleJoinedEvent` / `CircleLeftEvent`, секция сохранения `"circle"`, сброс, демо-Круги, окно «Круг» на карте мира, тесты. |
+5
View File
@@ -13,6 +13,7 @@
| Файл | Что это |
| --- | --- |
| `Core/ResourceType.cs` | Перечисление ресурсов `ResourceType` и структура `ResourceAmount` (тип + количество). Числа enum не менять: они в сохранениях. |
| `Core/LocalPlayer.cs` | Id и имя локального игрока (пока без аккаунтов). |
| `Core/GameServices.cs` | Реестр модулей: `Register<T>`, `Get<T>`, `TryGet<T>`, `Has<T>`, `Unregister<T>`. |
| `Core/EventBus.cs` | Шина событий: `Subscribe<T>`, `Unsubscribe<T>`, `Publish<T>`. Ошибка одного подписчика не мешает остальным (уходит в `OnHandlerError`). |
| `Core/Contracts/ModuleContracts.cs` | Контракты всех модулей и `SaveRegistry` — см. таблицу ниже. |
@@ -30,6 +31,8 @@
| `IArmy` | Армия (`ArmyBridge.Service`) | Воины дома; забрать в поход; вернуть выживших. |
| `IWorld` | Мир (`MarchManager`) | Сколько походов в пути; начать поход. |
| `IBattleSystem` | Бой (`BattleSystem`) | Посчитать исход боя по `BattleRequest`. |
| `IChat` | Чат (`ChatService`) | История каналов, можно ли писать в канал, отправка сообщения от локального игрока. |
| `ICircle` | Круг (`CircleService`) | Круг игрока, список Кругов; основать, вступить, выйти. |
| `ISaveSystem` | Сохранения (`SaveManager`) | Есть ли сохранение; сохранить сейчас; стереть; сбросить всё (`ResetAll`). |
| `ISaveParticipant` | Любой модуль с данными | Секция сохранения: ключ, `Capture()`, `Restore(json)`, `ResetState()` — новая игра. |
@@ -50,5 +53,7 @@
| Дата | Кто | Что |
| --- | --- | --- |
| 19.10.2026 | Claude | Контракт `ICircle` и типы `CircleInfo`, `CircleMember`, `CircleRole`; события `CircleJoinedEvent`, `CircleLeftEvent`; `IChat.CanWrite`. |
| 19.10.2026 | Claude | Контракт `IChat`, типы `ChatChannel` и `ChatMessage`, событие `ChatMessageReceivedEvent`, `LocalPlayer`. |
| 19.10.2026 | Claude | Сброс сохранений: `ISaveSystem.ResetAll()`, `ISaveParticipant.ResetState()`, событие `GameResetEvent`. |
| 19.10.2026 | Claude | Создано ядро: реестр, шина событий, контракты шести модулей, события, `SaveRegistry`. `ResourceType` перенесён сюда из `ResourceManager.cs`, `UIBlocker` — из `Meta/Core`. |
+10 -6
View File
@@ -1,14 +1,14 @@
# Модульная система «Богатырей»
Игра собрана из шести модулей вокруг общего **Игрового ядра**. Модули общаются только через контракты и события ядра и не управляют внутренним кодом друг друга. Это правило проверяет сам Unity: каждый модуль — отдельная сборка (`.asmdef`), которая видит только ядро, поэтому прямое обращение к соседнему модулю не скомпилируется.
Игра собрана из восьми модулей вокруг общего **Игрового ядра**. Модули общаются только через контракты и события ядра и не управляют внутренним кодом друг друга. Это правило проверяет сам Unity: каждый модуль — отдельная сборка (`.asmdef`), которая видит только ядро, поэтому прямое обращение к соседнему модулю не скомпилируется.
```
Игровое ядро (Bogatyri.Core)
контракты (IEconomy, IArmy...) · события (EventBus) · реестр (GameServices)
┌───────────┬────────────┬───────────┬──────────────┬───────────────┬────────────┐
Экономика Поселение Армия Глобальный мир Боевая система Сохранения
ресурсы, здания, войска, карта, урон, потери, игровое
производство развитие характеристики перемещения исход состояние
┌───────────┬────────────┬───────────┬──────────────┬───────────────┬────────────┬──────────┬──────────┐
Экономика Поселение Армия Глобальный мир Боевая система Сохранения Чат Круг
ресурсы, здания, войска, карта, урон, потери, игровое мир и объединение
производство развитие характеристики перемещения исход состояние Круг игроков
▲
App/GameModules — точка сборки: регистрирует модули и соединяет их события
со старыми системами (GameProgress, Казармы, панели UI)
@@ -23,6 +23,8 @@
| Глобальный мир | `Bogatyri.World`, `Bogatyri.World.Simulation` (без Unity) | `Assets/Scripts/Modules/World` | [World.md](World.md) |
| Боевая система | `Bogatyri.Battle` (без Unity) | `Assets/Scripts/Modules/Battle` | [Battle.md](Battle.md) |
| Сохранения | `Bogatyri.Save` | `Assets/Scripts/Modules/Save` | [Save.md](Save.md) |
| Чат | `Bogatyri.Chat` (без Unity) | `Assets/Scripts/Modules/Chat` | [Chat.md](Chat.md) |
| Круг | `Bogatyri.Circle` (без Unity) | `Assets/Scripts/Modules/Circle` | [Circle.md](Circle.md) |
## Правила
@@ -44,7 +46,7 @@
EditMode-тесты: `Assets/Scripts/Modules/Tests/ModuleTests.cs`. Запуск: **Window → General → Test Runner → EditMode → Run All**.
Уже есть: деление войск без двойного учёта и без потерь, бой (больше солдат — победа, ничья — защитнику), поход с офлайн-прогрессом, шина событий (ошибка подписчика не ломает остальных), реестр модулей.
Уже есть: Круг (основание, вступление, выход, полный Круг, сохранение), канал «Круг» в чате только для участников, чат (пустое сообщение, обрезка длинного, событие, предел истории), деление войск без двойного учёта и без потерь, бой (больше солдат — победа, ничья — защитнику), поход с офлайн-прогрессом, шина событий (ошибка подписчика не ломает остальных), реестр модулей.
Добавить вместе с этапами 1, 2 и 5 (решение команды от 19.10.2026):
@@ -58,6 +60,8 @@ EditMode-тесты: `Assets/Scripts/Modules/Tests/ModuleTests.cs`. Запуск
| Дата | Кто | Что |
| --- | --- | --- |
| 19.10.2026 | Claude | Добавлен модуль Круга (`Bogatyri.Circle`, [Circle.md](Circle.md)): основать, вступить, выйти, роли, окно «Круг» на карте мира. Канал «Круг» в чате привязан к Кругу игрока. |
| 19.10.2026 | Claude | Добавлен модуль Чата (`Bogatyri.Chat`, [Chat.md](Chat.md)) с разворотом на весь экран. В сцене города кнопка «ПОЧТА» привязана к правому краю нижней панели — в безопасной зоне она уезжала за экран. |
| 19.10.2026 | Claude | Слой приложения: HUD города и карты мира учитывает Safe Area (`UI/SafeArea.cs`, `UI/SafeAreaInstaller.cs`). Элементы, растянутые на весь экран, остаются на весь экран. |
| 19.10.2026 | Claude | Добавлен сброс сохранений (Настройки, меню Tools, `ISaveSystem.ResetAll`). Обновлены Core, Economy, Settlement, World, Save. |
| 19.10.2026 | Claude | Создана модульная система: ядро, шесть модулей, точка сборки `App/GameModules`, тесты, документы модулей. Ветка `feature/modules`. |
+4 -1
View File
@@ -35,6 +35,7 @@ SaveRegistry.Register(myModule); // при запуске модуля
| `economy` | Экономика |
| `settlement` | Поселение |
| `world` | Глобальный мир |
| `circle` | Круг (простые строки, не JSON — модуль без Unity) |
## Формат файла
@@ -66,7 +67,8 @@ SaveRegistry.Register(myModule); // при запуске модуля
- **Экономика** — стартовые ресурсы из Inspector `ResourceManager`;
- **Поселение** — Ратуша 1 уровня, постройки убраны, участки свободны;
- **Мир** — все походы отменены.
- **Мир** — все походы отменены;
- **Круг** — игрок ни в каком Круге, его Круги распущены.
Новый модуль с данными обязан реализовать `ResetState()`, иначе он не скомпилируется: метод входит в контракт.
@@ -97,5 +99,6 @@ SaveRegistry.Register(myModule); // при запуске модуля
| Дата | Кто | Что |
| --- | --- | --- |
| 19.10.2026 | Claude | Новая секция `circle` (модуль Круга). |
| 19.10.2026 | Claude | Сброс сохранений: `ISaveSystem.ResetAll()`, `ResetState()` у модулей, `GameResetEvent`, меню редактора. Кнопка в Настройках теперь сбрасывает и походы, и память Поселения. |
| 19.10.2026 | Claude | `SaveManager` переписан на секции модулей (`ISaveParticipant`), формат v2 с переносом v1. Запись до загрузки запрещена. Сохранение города вынесено в модуль Поселения, походов — в модуль Мира. |