Модули Чата и Круга, разворот чата на весь экран, кнопка «Почта» в 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
+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 });
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: ee8544a4534237643ada27a572040574
+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; }