Модуль Чата (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>
235 lines
9.2 KiB
C#
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];
|
|
}
|
|
}
|