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

235 lines
9.2 KiB
C#

using System;
using System.Collections.Generic;
using System.Text;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
// Панель чата (слой приложения): вкладки "Общий мир" / "Круг", лента сообщений, поле ввода и кнопка отправки.
// Сам чат -- модуль Чата: только через контракт IChat и событие ChatMessageReceivedEvent. Документ: Docs/Modules/Chat.md
public class ChatPanel : MonoBehaviour
{
[Header("Вкладки")]
public Button worldTabButton;
public Button circleTabButton;
[Header("Сообщения")]
public TMP_Text messagesText;
[Header("Ввод")]
public TMP_InputField inputField;
public Button sendButton;
[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);
static readonly string[] NameColors = { "#6EC6FF", "#FF8A65", "#C792EA", "#81C784", "#FFD54F", "#4DD0E1" };
const string OwnNameColor = "#FFC857";
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 = 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();
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()
{
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();
}
void Send()
{
if (Chat.Send(currentChannel, inputField.text))
{
inputField.text = "";
}
inputField.ActivateInputField(); // курсор остаётся в поле -- можно писать дальше
}
static IChat Chat => GameServices.Get<IChat>();
// Канал "Круг" без Круга: поле ввода выключено, в ленте -- подсказка
void UpdateInputState()
{
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 = 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 == LocalPlayer.Id ? OwnNameColor : GetNameColor(m.senderId);
if (sb.Length > 0) sb.Append('\n');
// <noparse> -- чтобы игрок не мог вставить в сообщение теги оформления
sb.Append($"<color=#9AA3B5>[{time}]</color> <color={color}>{m.senderName}:</color> <noparse>{m.text}</noparse>");
}
messagesText.text = sb.ToString();
}
// Один и тот же игрок -- всегда один цвет
static string GetNameColor(string senderId)
{
int hash = 0;
foreach (char c in senderId) hash = hash * 31 + c;
return NameColors[(hash & int.MaxValue) % NameColors.Length];
}
}