1/* (c) Magnus Auvinen. See licence.txt in the root of the distribution for more information. */
2/* If you are missing that file, acquire a complete release at teeworlds.com. */
3
4#include "chat.h"
5
6#include <base/io.h>
7#include <base/time.h>
8
9#include <engine/editor.h>
10#include <engine/graphics.h>
11#include <engine/keys.h>
12#include <engine/shared/config.h>
13#include <engine/shared/csv.h>
14#include <engine/textrender.h>
15
16#include <generated/protocol.h>
17#include <generated/protocol7.h>
18
19#include <game/client/animstate.h>
20#include <game/client/components/censor.h>
21#include <game/client/components/scoreboard.h>
22#include <game/client/components/skins.h>
23#include <game/client/components/sounds.h>
24#include <game/client/gameclient.h>
25#include <game/localization.h>
26
27char CChat::ms_aDisplayText[MAX_LINE_LENGTH] = "";
28
29CChat::CLine::CLine()
30{
31 m_TextContainerIndex.Reset();
32 m_QuadContainerIndex = -1;
33}
34
35void CChat::CLine::Reset(CChat &This)
36{
37 This.TextRender()->DeleteTextContainer(TextContainerIndex&: m_TextContainerIndex);
38 This.Graphics()->DeleteQuadContainer(ContainerIndex&: m_QuadContainerIndex);
39 m_Initialized = false;
40 m_Time = 0;
41 m_aText[0] = '\0';
42 m_aName[0] = '\0';
43 m_Friend = false;
44 m_TimesRepeated = 0;
45 m_pManagedTeeRenderInfo = nullptr;
46}
47
48CChat::CChat()
49{
50 m_Mode = MODE_NONE;
51
52 m_Input.SetCalculateOffsetCallback([this]() { return m_IsInputCensored; });
53 m_Input.SetDisplayTextCallback([this](char *pStr, size_t NumChars) {
54 m_IsInputCensored = false;
55 if(
56 g_Config.m_ClStreamerMode &&
57 (str_startswith(str: pStr, prefix: "/login ") ||
58 str_startswith(str: pStr, prefix: "/register ") ||
59 str_startswith(str: pStr, prefix: "/code ") ||
60 str_startswith(str: pStr, prefix: "/timeout ") ||
61 str_startswith(str: pStr, prefix: "/save ") ||
62 str_startswith(str: pStr, prefix: "/load ")))
63 {
64 bool Censor = false;
65 const size_t NumLetters = std::min(a: NumChars, b: sizeof(ms_aDisplayText) - 1);
66 for(size_t i = 0; i < NumLetters; ++i)
67 {
68 if(Censor)
69 ms_aDisplayText[i] = '*';
70 else
71 ms_aDisplayText[i] = pStr[i];
72 if(pStr[i] == ' ')
73 {
74 Censor = true;
75 m_IsInputCensored = true;
76 }
77 }
78 ms_aDisplayText[NumLetters] = '\0';
79 return ms_aDisplayText;
80 }
81 return pStr;
82 });
83}
84
85void CChat::RegisterCommand(const char *pName, const char *pParams, const char *pHelpText)
86{
87 // Don't allow duplicate commands.
88 for(const auto &Command : m_vServerCommands)
89 if(str_comp(a: Command.m_aName, b: pName) == 0)
90 return;
91
92 m_vServerCommands.emplace_back(args&: pName, args&: pParams, args&: pHelpText);
93 m_ServerCommandsNeedSorting = true;
94}
95
96void CChat::UnregisterCommand(const char *pName)
97{
98 m_vServerCommands.erase(first: std::remove_if(first: m_vServerCommands.begin(), last: m_vServerCommands.end(), pred: [pName](const CCommand &Command) { return str_comp(a: Command.m_aName, b: pName) == 0; }), last: m_vServerCommands.end());
99}
100
101void CChat::RebuildChat()
102{
103 for(auto &Line : m_aLines)
104 {
105 if(!Line.m_Initialized)
106 continue;
107 TextRender()->DeleteTextContainer(TextContainerIndex&: Line.m_TextContainerIndex);
108 Graphics()->DeleteQuadContainer(ContainerIndex&: Line.m_QuadContainerIndex);
109 // recalculate sizes
110 Line.m_aYOffset[0] = -1.0f;
111 Line.m_aYOffset[1] = -1.0f;
112 }
113}
114
115void CChat::ClearLines()
116{
117 for(auto &Line : m_aLines)
118 Line.Reset(This&: *this);
119 m_PrevScoreBoardShowed = false;
120 m_PrevShowChat = false;
121}
122
123void CChat::OnWindowResize()
124{
125 RebuildChat();
126}
127
128void CChat::Reset()
129{
130 ClearLines();
131
132 m_Show = false;
133 m_CompletionUsed = false;
134 m_CompletionChosen = -1;
135 m_aCompletionBuffer[0] = 0;
136 m_PlaceholderOffset = 0;
137 m_PlaceholderLength = 0;
138 m_pHistoryEntry = nullptr;
139 m_PendingChatCounter = 0;
140 m_LastChatSend = 0;
141 m_CurrentLine = 0;
142 m_IsInputCensored = false;
143 m_EditingNewLine = true;
144 m_ServerSupportsCommandInfo = false;
145 m_ServerCommandsNeedSorting = false;
146 m_aCurrentInputText[0] = '\0';
147 DisableMode();
148 m_vServerCommands.clear();
149
150 for(int64_t &LastSoundPlayed : m_aLastSoundPlayed)
151 LastSoundPlayed = 0;
152}
153
154void CChat::OnRelease()
155{
156 m_Show = false;
157}
158
159void CChat::OnStateChange(int NewState, int OldState)
160{
161 if(OldState <= IClient::STATE_CONNECTING)
162 Reset();
163}
164
165void CChat::ConSay(IConsole::IResult *pResult, void *pUserData)
166{
167 ((CChat *)pUserData)->SendChat(Team: 0, pLine: pResult->GetString(Index: 0));
168}
169
170void CChat::ConSayTeam(IConsole::IResult *pResult, void *pUserData)
171{
172 ((CChat *)pUserData)->SendChat(Team: 1, pLine: pResult->GetString(Index: 0));
173}
174
175void CChat::ConChat(IConsole::IResult *pResult, void *pUserData)
176{
177 const char *pMode = pResult->GetString(Index: 0);
178 if(str_comp(a: pMode, b: "all") == 0)
179 ((CChat *)pUserData)->EnableMode(Team: 0);
180 else if(str_comp(a: pMode, b: "team") == 0)
181 ((CChat *)pUserData)->EnableMode(Team: 1);
182 else
183 ((CChat *)pUserData)->Console()->Print(Level: IConsole::OUTPUT_LEVEL_STANDARD, pFrom: "console", pStr: "expected all or team as mode");
184
185 if(pResult->GetString(Index: 1)[0] || g_Config.m_ClChatReset)
186 ((CChat *)pUserData)->m_Input.Set(pResult->GetString(Index: 1));
187}
188
189void CChat::ConShowChat(IConsole::IResult *pResult, void *pUserData)
190{
191 ((CChat *)pUserData)->m_Show = pResult->GetInteger(Index: 0) != 0;
192}
193
194void CChat::ConEcho(IConsole::IResult *pResult, void *pUserData)
195{
196 ((CChat *)pUserData)->Echo(pString: pResult->GetString(Index: 0));
197}
198
199void CChat::ConClearChat(IConsole::IResult *pResult, void *pUserData)
200{
201 ((CChat *)pUserData)->ClearLines();
202}
203
204void CChat::ConchainChatOld(IConsole::IResult *pResult, void *pUserData, IConsole::FCommandCallback pfnCallback, void *pCallbackUserData)
205{
206 pfnCallback(pResult, pCallbackUserData);
207 ((CChat *)pUserData)->RebuildChat();
208}
209
210void CChat::ConchainChatFontSize(IConsole::IResult *pResult, void *pUserData, IConsole::FCommandCallback pfnCallback, void *pCallbackUserData)
211{
212 pfnCallback(pResult, pCallbackUserData);
213 CChat *pChat = (CChat *)pUserData;
214 pChat->EnsureCoherentWidth();
215 pChat->RebuildChat();
216}
217
218void CChat::ConchainChatWidth(IConsole::IResult *pResult, void *pUserData, IConsole::FCommandCallback pfnCallback, void *pCallbackUserData)
219{
220 pfnCallback(pResult, pCallbackUserData);
221 CChat *pChat = (CChat *)pUserData;
222 pChat->EnsureCoherentFontSize();
223 pChat->RebuildChat();
224}
225
226void CChat::Echo(const char *pString)
227{
228 AddLine(ClientId: CLIENT_MSG, Team: 0, pLine: pString);
229}
230
231void CChat::OnConsoleInit()
232{
233 Console()->Register(pName: "say", pParams: "r[message]", Flags: CFGFLAG_CLIENT, pfnFunc: ConSay, pUser: this, pHelp: "Say in chat");
234 Console()->Register(pName: "say_team", pParams: "r[message]", Flags: CFGFLAG_CLIENT, pfnFunc: ConSayTeam, pUser: this, pHelp: "Say in team chat");
235 Console()->Register(pName: "chat", pParams: "s['team'|'all'] ?r[message]", Flags: CFGFLAG_CLIENT, pfnFunc: ConChat, pUser: this, pHelp: "Enable chat with all/team mode");
236 Console()->Register(pName: "+show_chat", pParams: "", Flags: CFGFLAG_CLIENT, pfnFunc: ConShowChat, pUser: this, pHelp: "Show chat");
237 Console()->Register(pName: "echo", pParams: "r[message]", Flags: CFGFLAG_CLIENT | CFGFLAG_STORE, pfnFunc: ConEcho, pUser: this, pHelp: "Echo the text in chat window");
238 Console()->Register(pName: "clear_chat", pParams: "", Flags: CFGFLAG_CLIENT | CFGFLAG_STORE, pfnFunc: ConClearChat, pUser: this, pHelp: "Clear chat messages");
239}
240
241void CChat::OnInit()
242{
243 Reset();
244 Console()->Chain(pName: "cl_chat_old", pfnChainFunc: ConchainChatOld, pUser: this);
245 Console()->Chain(pName: "cl_chat_size", pfnChainFunc: ConchainChatFontSize, pUser: this);
246 Console()->Chain(pName: "cl_chat_width", pfnChainFunc: ConchainChatWidth, pUser: this);
247}
248
249bool CChat::OnInput(const IInput::CEvent &Event)
250{
251 if(m_Mode == MODE_NONE)
252 return false;
253
254 if(Event.m_Flags & IInput::FLAG_PRESS && Event.m_Key == KEY_ESCAPE)
255 {
256 DisableMode();
257 GameClient()->OnRelease();
258 if(g_Config.m_ClChatReset)
259 {
260 m_Input.Clear();
261 m_pHistoryEntry = nullptr;
262 }
263 }
264 else if(Event.m_Flags & IInput::FLAG_PRESS && (Event.m_Key == KEY_RETURN || Event.m_Key == KEY_KP_ENTER))
265 {
266 if(m_ServerCommandsNeedSorting)
267 {
268 std::sort(first: m_vServerCommands.begin(), last: m_vServerCommands.end());
269 m_ServerCommandsNeedSorting = false;
270 }
271
272 SendChatQueued(pLine: m_Input.GetString());
273 m_pHistoryEntry = nullptr;
274 DisableMode();
275 GameClient()->OnRelease();
276 m_Input.Clear();
277 }
278 if(Event.m_Flags & IInput::FLAG_PRESS && Event.m_Key == KEY_TAB)
279 {
280 const bool ShiftPressed = Input()->ShiftIsPressed();
281
282 // fill the completion buffer
283 if(!m_CompletionUsed)
284 {
285 const char *pCursor = m_Input.GetString() + m_Input.GetCursorOffset();
286 for(size_t Count = 0; Count < m_Input.GetCursorOffset() && *(pCursor - 1) != ' '; --pCursor, ++Count)
287 ;
288 m_PlaceholderOffset = pCursor - m_Input.GetString();
289
290 for(m_PlaceholderLength = 0; *pCursor && *pCursor != ' '; ++pCursor)
291 ++m_PlaceholderLength;
292
293 str_truncate(dst: m_aCompletionBuffer, dst_size: sizeof(m_aCompletionBuffer), src: m_Input.GetString() + m_PlaceholderOffset, truncation_len: m_PlaceholderLength);
294 }
295
296 if(!m_CompletionUsed && m_aCompletionBuffer[0] != '/')
297 {
298 // Create the completion list of player names through which the player can iterate
299 const char *PlayerName, *FoundInput;
300 m_PlayerCompletionListLength = 0;
301 for(auto &PlayerInfo : GameClient()->m_Snap.m_apInfoByName)
302 {
303 if(PlayerInfo)
304 {
305 PlayerName = GameClient()->m_aClients[PlayerInfo->m_ClientId].m_aName;
306 FoundInput = str_utf8_find_nocase(haystack: PlayerName, needle: m_aCompletionBuffer);
307 if(FoundInput != nullptr)
308 {
309 m_aPlayerCompletionList[m_PlayerCompletionListLength].m_ClientId = PlayerInfo->m_ClientId;
310 // The score for suggesting a player name is determined by the distance of the search input to the beginning of the player name
311 m_aPlayerCompletionList[m_PlayerCompletionListLength].m_Score = (int)(FoundInput - PlayerName);
312 m_PlayerCompletionListLength++;
313 }
314 }
315 }
316 std::stable_sort(first: m_aPlayerCompletionList, last: m_aPlayerCompletionList + m_PlayerCompletionListLength,
317 comp: [](const CRateablePlayer &Player1, const CRateablePlayer &Player2) -> bool {
318 return Player1.m_Score < Player2.m_Score;
319 });
320 }
321
322 if(m_aCompletionBuffer[0] == '/' && !m_vServerCommands.empty())
323 {
324 CCommand *pCompletionCommand = nullptr;
325
326 const size_t NumCommands = m_vServerCommands.size();
327
328 if(ShiftPressed && m_CompletionUsed)
329 m_CompletionChosen--;
330 else if(!ShiftPressed)
331 m_CompletionChosen++;
332 m_CompletionChosen = (m_CompletionChosen + 2 * NumCommands) % (2 * NumCommands);
333
334 m_CompletionUsed = true;
335
336 const char *pCommandStart = m_aCompletionBuffer + 1;
337 for(size_t i = 0; i < 2 * NumCommands; ++i)
338 {
339 int SearchType;
340 int Index;
341
342 if(ShiftPressed)
343 {
344 SearchType = ((m_CompletionChosen - i + 2 * NumCommands) % (2 * NumCommands)) / NumCommands;
345 Index = (m_CompletionChosen - i + NumCommands) % NumCommands;
346 }
347 else
348 {
349 SearchType = ((m_CompletionChosen + i) % (2 * NumCommands)) / NumCommands;
350 Index = (m_CompletionChosen + i) % NumCommands;
351 }
352
353 auto &Command = m_vServerCommands[Index];
354
355 if(str_startswith_nocase(str: Command.m_aName, prefix: pCommandStart))
356 {
357 pCompletionCommand = &Command;
358 m_CompletionChosen = Index + SearchType * NumCommands;
359 break;
360 }
361 }
362
363 // insert the command
364 if(pCompletionCommand)
365 {
366 char aBuf[MAX_LINE_LENGTH];
367 // add part before the name
368 str_truncate(dst: aBuf, dst_size: sizeof(aBuf), src: m_Input.GetString(), truncation_len: m_PlaceholderOffset);
369
370 // add the command
371 str_append(dst&: aBuf, src: "/");
372 str_append(dst&: aBuf, src: pCompletionCommand->m_aName);
373
374 // add separator
375 const char *pSeparator = pCompletionCommand->m_aParams[0] == '\0' ? "" : " ";
376 str_append(dst&: aBuf, src: pSeparator);
377
378 // add part after the name
379 str_append(dst&: aBuf, src: m_Input.GetString() + m_PlaceholderOffset + m_PlaceholderLength);
380
381 m_PlaceholderLength = str_length(str: pSeparator) + str_length(str: pCompletionCommand->m_aName) + 1;
382 m_Input.Set(aBuf);
383 m_Input.SetCursorOffset(m_PlaceholderOffset + m_PlaceholderLength);
384 }
385 }
386 else
387 {
388 // find next possible name
389 const char *pCompletionString = nullptr;
390 if(m_PlayerCompletionListLength > 0)
391 {
392 // We do this in a loop, if a player left the game during the repeated pressing of Tab, they are skipped
393 CGameClient::CClientData *pCompletionClientData;
394 for(int i = 0; i < m_PlayerCompletionListLength; ++i)
395 {
396 if(ShiftPressed && m_CompletionUsed)
397 {
398 m_CompletionChosen--;
399 }
400 else if(!ShiftPressed)
401 {
402 m_CompletionChosen++;
403 }
404 if(m_CompletionChosen < 0)
405 {
406 m_CompletionChosen += m_PlayerCompletionListLength;
407 }
408 m_CompletionChosen %= m_PlayerCompletionListLength;
409 m_CompletionUsed = true;
410
411 pCompletionClientData = &GameClient()->m_aClients[m_aPlayerCompletionList[m_CompletionChosen].m_ClientId];
412 if(!pCompletionClientData->m_Active)
413 {
414 continue;
415 }
416
417 pCompletionString = pCompletionClientData->m_aName;
418 break;
419 }
420 }
421
422 // insert the name
423 if(pCompletionString)
424 {
425 char aBuf[MAX_LINE_LENGTH];
426 // add part before the name
427 str_truncate(dst: aBuf, dst_size: sizeof(aBuf), src: m_Input.GetString(), truncation_len: m_PlaceholderOffset);
428
429 // quote the name
430 char aQuoted[128];
431 if(m_Input.GetString()[0] == '/' && (str_find(haystack: pCompletionString, needle: " ") || str_find(haystack: pCompletionString, needle: "\"")))
432 {
433 // escape the name
434 str_copy(dst&: aQuoted, src: "\"");
435 char *pDst = aQuoted + str_length(str: aQuoted);
436 str_escape(dst: &pDst, src: pCompletionString, end: aQuoted + sizeof(aQuoted));
437 str_append(dst&: aQuoted, src: "\"");
438
439 pCompletionString = aQuoted;
440 }
441
442 // add the name
443 str_append(dst&: aBuf, src: pCompletionString);
444
445 // add separator
446 const char *pSeparator = "";
447 if(*(m_Input.GetString() + m_PlaceholderOffset + m_PlaceholderLength) != ' ')
448 pSeparator = m_PlaceholderOffset == 0 ? ": " : " ";
449 else if(m_PlaceholderOffset == 0)
450 pSeparator = ":";
451 if(*pSeparator)
452 str_append(dst&: aBuf, src: pSeparator);
453
454 // add part after the name
455 str_append(dst&: aBuf, src: m_Input.GetString() + m_PlaceholderOffset + m_PlaceholderLength);
456
457 m_PlaceholderLength = str_length(str: pSeparator) + str_length(str: pCompletionString);
458 m_Input.Set(aBuf);
459 m_Input.SetCursorOffset(m_PlaceholderOffset + m_PlaceholderLength);
460 }
461 }
462 }
463 else
464 {
465 // reset name completion process
466 if(Event.m_Flags & IInput::FLAG_PRESS && Event.m_Key != KEY_TAB && Event.m_Key != KEY_LSHIFT && Event.m_Key != KEY_RSHIFT)
467 {
468 m_CompletionChosen = -1;
469 m_CompletionUsed = false;
470 }
471
472 m_Input.ProcessInput(Event);
473 }
474
475 if(Event.m_Flags & IInput::FLAG_PRESS && Event.m_Key == KEY_UP)
476 {
477 if(m_EditingNewLine)
478 {
479 str_copy(dst&: m_aCurrentInputText, src: m_Input.GetString());
480 m_EditingNewLine = false;
481 }
482
483 if(m_pHistoryEntry)
484 {
485 CHistoryEntry *pTest = m_History.Prev(pCurrent: m_pHistoryEntry);
486
487 if(pTest)
488 m_pHistoryEntry = pTest;
489 }
490 else
491 {
492 m_pHistoryEntry = m_History.Last();
493 }
494
495 if(m_pHistoryEntry)
496 m_Input.Set(m_pHistoryEntry->m_aText);
497 }
498 else if(Event.m_Flags & IInput::FLAG_PRESS && Event.m_Key == KEY_DOWN)
499 {
500 if(m_pHistoryEntry)
501 m_pHistoryEntry = m_History.Next(pCurrent: m_pHistoryEntry);
502
503 if(m_pHistoryEntry)
504 {
505 m_Input.Set(m_pHistoryEntry->m_aText);
506 }
507 else if(!m_EditingNewLine)
508 {
509 m_Input.Set(m_aCurrentInputText);
510 m_EditingNewLine = true;
511 }
512 }
513
514 return true;
515}
516
517void CChat::EnableMode(int Team)
518{
519 if(Client()->State() == IClient::STATE_DEMOPLAYBACK)
520 return;
521
522 if(m_Mode == MODE_NONE)
523 {
524 if(Team)
525 m_Mode = MODE_TEAM;
526 else
527 m_Mode = MODE_ALL;
528
529 m_CompletionChosen = -1;
530 m_CompletionUsed = false;
531 m_Input.Activate(Priority: EInputPriority::CHAT);
532 }
533}
534
535void CChat::DisableMode()
536{
537 if(m_Mode != MODE_NONE)
538 {
539 m_Mode = MODE_NONE;
540 m_Input.Deactivate();
541 }
542}
543
544void CChat::OnMessage(int MsgType, void *pRawMsg)
545{
546 if(GameClient()->m_SuppressEvents)
547 return;
548
549 if(MsgType == NETMSGTYPE_SV_CHAT)
550 {
551 CNetMsg_Sv_Chat *pMsg = (CNetMsg_Sv_Chat *)pRawMsg;
552
553 /*
554 if(g_Config.m_ClCensorChat)
555 {
556 char aMessage[MAX_LINE_LENGTH];
557 str_copy(aMessage, pMsg->m_pMessage);
558 GameClient()->m_Censor.CensorMessage(aMessage);
559 AddLine(pMsg->m_ClientId, pMsg->m_Team, aMessage);
560 }
561 else
562 AddLine(pMsg->m_ClientId, pMsg->m_Team, pMsg->m_pMessage);
563 */
564
565 AddLine(ClientId: pMsg->m_ClientId, Team: pMsg->m_Team, pLine: pMsg->m_pMessage);
566
567 if(Client()->State() != IClient::STATE_DEMOPLAYBACK &&
568 pMsg->m_ClientId == SERVER_MSG)
569 {
570 StoreSave(pText: pMsg->m_pMessage);
571 }
572 }
573 else if(MsgType == NETMSGTYPE_SV_COMMANDINFO)
574 {
575 CNetMsg_Sv_CommandInfo *pMsg = (CNetMsg_Sv_CommandInfo *)pRawMsg;
576 if(!m_ServerSupportsCommandInfo)
577 {
578 m_vServerCommands.clear();
579 m_ServerSupportsCommandInfo = true;
580 }
581 RegisterCommand(pName: pMsg->m_pName, pParams: pMsg->m_pArgsFormat, pHelpText: pMsg->m_pHelpText);
582 }
583 else if(MsgType == NETMSGTYPE_SV_COMMANDINFOREMOVE)
584 {
585 CNetMsg_Sv_CommandInfoRemove *pMsg = (CNetMsg_Sv_CommandInfoRemove *)pRawMsg;
586 UnregisterCommand(pName: pMsg->m_pName);
587 }
588}
589
590bool CChat::LineShouldHighlight(const char *pLine, const char *pName)
591{
592 const char *pHit = str_utf8_find_nocase(haystack: pLine, needle: pName);
593
594 while(pHit)
595 {
596 int Length = str_length(str: pName);
597
598 if(Length > 0 && (pLine == pHit || pHit[-1] == ' ') && (pHit[Length] == 0 || pHit[Length] == ' ' || pHit[Length] == '.' || pHit[Length] == '!' || pHit[Length] == ',' || pHit[Length] == '?' || pHit[Length] == ':'))
599 return true;
600
601 pHit = str_utf8_find_nocase(haystack: pHit + 1, needle: pName);
602 }
603
604 return false;
605}
606
607static constexpr const char *SAVES_HEADER[] = {
608 "Time",
609 "Player",
610 "Map",
611 "Code",
612};
613
614// TODO: remove this in a few releases (in 2027 or later)
615// it got deprecated by CGameClient::StoreSave
616void CChat::StoreSave(const char *pText)
617{
618 const char *pStart = str_find(haystack: pText, needle: "Team successfully saved by ");
619 const char *pMid = str_find(haystack: pText, needle: ". Use '/load ");
620 const char *pOn = str_find(haystack: pText, needle: "' on ");
621 const char *pEnd = str_find(haystack: pText, needle: pOn ? " to continue" : "' to continue");
622
623 if(!pStart || !pMid || !pEnd || pMid < pStart || pEnd < pMid || (pOn && (pOn < pMid || pEnd < pOn)))
624 return;
625
626 char aName[16];
627 str_truncate(dst: aName, dst_size: sizeof(aName), src: pStart + 27, truncation_len: pMid - pStart - 27);
628
629 char aSaveCode[64];
630
631 str_truncate(dst: aSaveCode, dst_size: sizeof(aSaveCode), src: pMid + 13, truncation_len: (pOn ? pOn : pEnd) - pMid - 13);
632
633 char aTimestamp[20];
634 str_timestamp_format(buffer: aTimestamp, buffer_size: sizeof(aTimestamp), format: TimestampFormat::SPACE);
635
636 const bool SavesFileExists = Storage()->FileExists(pFilename: SAVES_FILE, Type: IStorage::TYPE_SAVE);
637 IOHANDLE File = Storage()->OpenFile(pFilename: SAVES_FILE, Flags: IOFLAG_APPEND, Type: IStorage::TYPE_SAVE);
638 if(!File)
639 return;
640
641 const char *apColumns[4] = {
642 aTimestamp,
643 aName,
644 GameClient()->Map()->BaseName(),
645 aSaveCode,
646 };
647
648 if(!SavesFileExists)
649 {
650 CsvWrite(File, NumColumns: 4, ppColumns: SAVES_HEADER);
651 }
652 CsvWrite(File, NumColumns: 4, ppColumns: apColumns);
653 io_close(io: File);
654}
655
656void CChat::AddLine(int ClientId, int Team, const char *pLine)
657{
658 if(*pLine == 0 ||
659 (ClientId == SERVER_MSG && !g_Config.m_ClShowChatSystem) ||
660 (ClientId >= 0 && (GameClient()->m_aClients[ClientId].m_aName[0] == '\0' || // unknown client
661 GameClient()->m_aClients[ClientId].m_ChatIgnore ||
662 (GameClient()->m_Snap.m_LocalClientId != ClientId && g_Config.m_ClShowChatFriends && !GameClient()->m_aClients[ClientId].m_Friend) ||
663 (GameClient()->m_Snap.m_LocalClientId != ClientId && g_Config.m_ClShowChatTeamMembersOnly && GameClient()->IsOtherTeam(ClientId) && GameClient()->m_Teams.Team(ClientId: GameClient()->m_Snap.m_LocalClientId) != TEAM_FLOCK) ||
664 (GameClient()->m_Snap.m_LocalClientId != ClientId && GameClient()->m_aClients[ClientId].m_Foe))))
665 return;
666
667 // trim right and set maximum length to 256 utf8-characters
668 int Length = 0;
669 const char *pStr = pLine;
670 const char *pEnd = nullptr;
671 while(*pStr)
672 {
673 const char *pStrOld = pStr;
674 int Code = str_utf8_decode(ptr: &pStr);
675
676 // check if unicode is not empty
677 if(!str_utf8_isspace(code: Code))
678 {
679 pEnd = nullptr;
680 }
681 else if(pEnd == nullptr)
682 {
683 pEnd = pStrOld;
684 }
685
686 if(++Length >= MAX_LINE_LENGTH)
687 {
688 *(const_cast<char *>(pStr)) = '\0';
689 break;
690 }
691 }
692 if(pEnd != nullptr)
693 *(const_cast<char *>(pEnd)) = '\0';
694
695 if(*pLine == 0)
696 return;
697
698 bool Highlighted = false;
699
700 auto &&FChatMsgCheckAndPrint = [this](const CLine &Line) {
701 char aBuf[1024];
702 str_format(buffer: aBuf, buffer_size: sizeof(aBuf), format: "%s%s%s", Line.m_aName, Line.m_ClientId >= 0 ? ": " : "", Line.m_aText);
703
704 ColorRGBA ChatLogColor = ColorRGBA(1.0f, 1.0f, 1.0f, 1.0f);
705 if(Line.m_Highlighted)
706 {
707 ChatLogColor = color_cast<ColorRGBA>(hsl: ColorHSLA(g_Config.m_ClMessageHighlightColor));
708 }
709 else
710 {
711 if(Line.m_Friend && g_Config.m_ClMessageFriend)
712 ChatLogColor = color_cast<ColorRGBA>(hsl: ColorHSLA(g_Config.m_ClMessageFriendColor));
713 else if(Line.m_Team)
714 ChatLogColor = color_cast<ColorRGBA>(hsl: ColorHSLA(g_Config.m_ClMessageTeamColor));
715 else if(Line.m_ClientId == SERVER_MSG)
716 ChatLogColor = color_cast<ColorRGBA>(hsl: ColorHSLA(g_Config.m_ClMessageSystemColor));
717 else if(Line.m_ClientId == CLIENT_MSG)
718 ChatLogColor = color_cast<ColorRGBA>(hsl: ColorHSLA(g_Config.m_ClMessageClientColor));
719 else // regular message
720 ChatLogColor = color_cast<ColorRGBA>(hsl: ColorHSLA(g_Config.m_ClMessageColor));
721 }
722
723 const char *pFrom;
724 if(Line.m_Whisper)
725 pFrom = "chat/whisper";
726 else if(Line.m_Team)
727 pFrom = "chat/team";
728 else if(Line.m_ClientId == SERVER_MSG)
729 pFrom = "chat/server";
730 else if(Line.m_ClientId == CLIENT_MSG)
731 pFrom = "chat/client";
732 else
733 pFrom = "chat/all";
734
735 Console()->Print(Level: IConsole::OUTPUT_LEVEL_STANDARD, pFrom, pStr: aBuf, PrintColor: ChatLogColor);
736 };
737
738 // Custom color for new line
739 std::optional<ColorRGBA> CustomColor = std::nullopt;
740 if(ClientId == CLIENT_MSG)
741 CustomColor = color_cast<ColorRGBA>(hsl: ColorHSLA(g_Config.m_ClMessageClientColor));
742
743 CLine &PreviousLine = m_aLines[m_CurrentLine];
744
745 // Team Number:
746 // 0 = global; 1 = team; 2 = sending whisper; 3 = receiving whisper
747
748 // If it's a client message, m_aText will have ": " prepended so we have to work around it.
749 if(PreviousLine.m_Initialized &&
750 PreviousLine.m_TeamNumber == Team &&
751 PreviousLine.m_ClientId == ClientId &&
752 str_comp(a: PreviousLine.m_aText, b: pLine) == 0 &&
753 PreviousLine.m_CustomColor == CustomColor)
754 {
755 PreviousLine.m_TimesRepeated++;
756 TextRender()->DeleteTextContainer(TextContainerIndex&: PreviousLine.m_TextContainerIndex);
757 Graphics()->DeleteQuadContainer(ContainerIndex&: PreviousLine.m_QuadContainerIndex);
758 PreviousLine.m_Time = time();
759 PreviousLine.m_aYOffset[0] = -1.0f;
760 PreviousLine.m_aYOffset[1] = -1.0f;
761
762 FChatMsgCheckAndPrint(PreviousLine);
763 return;
764 }
765
766 m_CurrentLine = (m_CurrentLine + 1) % MAX_LINES;
767
768 CLine &CurrentLine = m_aLines[m_CurrentLine];
769 CurrentLine.Reset(This&: *this);
770 CurrentLine.m_Initialized = true;
771 CurrentLine.m_Time = time();
772 CurrentLine.m_aYOffset[0] = -1.0f;
773 CurrentLine.m_aYOffset[1] = -1.0f;
774 CurrentLine.m_ClientId = ClientId;
775 CurrentLine.m_TeamNumber = Team;
776 CurrentLine.m_Team = Team == 1;
777 CurrentLine.m_Whisper = Team >= 2;
778 CurrentLine.m_NameColor = -2;
779 CurrentLine.m_CustomColor = CustomColor;
780
781 // check for highlighted name
782 if(Client()->State() != IClient::STATE_DEMOPLAYBACK)
783 {
784 if(ClientId >= 0 && ClientId != GameClient()->m_aLocalIds[0] && ClientId != GameClient()->m_aLocalIds[1])
785 {
786 for(int LocalId : GameClient()->m_aLocalIds)
787 {
788 Highlighted |= LocalId >= 0 && LineShouldHighlight(pLine, pName: GameClient()->m_aClients[LocalId].m_aName);
789 }
790 }
791 }
792 else
793 {
794 // on demo playback use local id from snap directly,
795 // since m_aLocalIds isn't valid there
796 Highlighted |= GameClient()->m_Snap.m_LocalClientId >= 0 && LineShouldHighlight(pLine, pName: GameClient()->m_aClients[GameClient()->m_Snap.m_LocalClientId].m_aName);
797 }
798 CurrentLine.m_Highlighted = Highlighted;
799
800 str_copy(dst&: CurrentLine.m_aText, src: pLine);
801
802 if(CurrentLine.m_ClientId == SERVER_MSG)
803 {
804 str_copy(dst&: CurrentLine.m_aName, src: "*** ");
805 }
806 else if(CurrentLine.m_ClientId == CLIENT_MSG)
807 {
808 str_copy(dst&: CurrentLine.m_aName, src: "— ");
809 }
810 else
811 {
812 const auto &LineAuthor = GameClient()->m_aClients[CurrentLine.m_ClientId];
813
814 if(LineAuthor.m_Active)
815 {
816 if(LineAuthor.m_Team == TEAM_SPECTATORS)
817 CurrentLine.m_NameColor = TEAM_SPECTATORS;
818
819 if(GameClient()->IsTeamPlay())
820 {
821 if(LineAuthor.m_Team == TEAM_RED)
822 CurrentLine.m_NameColor = TEAM_RED;
823 else if(LineAuthor.m_Team == TEAM_BLUE)
824 CurrentLine.m_NameColor = TEAM_BLUE;
825 }
826 }
827
828 if(Team == TEAM_WHISPER_SEND)
829 {
830 str_copy(dst&: CurrentLine.m_aName, src: "→");
831 if(LineAuthor.m_Active)
832 {
833 str_append(dst&: CurrentLine.m_aName, src: " ");
834 str_append(dst&: CurrentLine.m_aName, src: LineAuthor.m_aName);
835 }
836 CurrentLine.m_NameColor = TEAM_BLUE;
837 CurrentLine.m_Highlighted = false;
838 Highlighted = false;
839 }
840 else if(Team == TEAM_WHISPER_RECV)
841 {
842 str_copy(dst&: CurrentLine.m_aName, src: "←");
843 if(LineAuthor.m_Active)
844 {
845 str_append(dst&: CurrentLine.m_aName, src: " ");
846 str_append(dst&: CurrentLine.m_aName, src: LineAuthor.m_aName);
847 }
848 CurrentLine.m_NameColor = TEAM_RED;
849 CurrentLine.m_Highlighted = true;
850 Highlighted = true;
851 }
852 else
853 {
854 str_copy(dst&: CurrentLine.m_aName, src: LineAuthor.m_aName);
855 }
856
857 if(LineAuthor.m_Active)
858 {
859 CurrentLine.m_Friend = LineAuthor.m_Friend;
860 CurrentLine.m_pManagedTeeRenderInfo = GameClient()->CreateManagedTeeRenderInfo(Client: LineAuthor);
861 }
862 }
863
864 FChatMsgCheckAndPrint(CurrentLine);
865
866 // play sound
867 int64_t Now = time();
868 if(ClientId == SERVER_MSG)
869 {
870 if(Now - m_aLastSoundPlayed[CHAT_SERVER] >= time_freq() * 3 / 10)
871 {
872 if(g_Config.m_SndServerMessage)
873 {
874 GameClient()->m_Sounds.Play(Channel: CSounds::CHN_GUI, SetId: SOUND_CHAT_SERVER, Volume: 1.0f);
875 m_aLastSoundPlayed[CHAT_SERVER] = Now;
876 }
877 }
878 }
879 else if(ClientId == CLIENT_MSG)
880 {
881 // No sound yet
882 }
883 else if(Highlighted && Client()->State() != IClient::STATE_DEMOPLAYBACK)
884 {
885 if(Now - m_aLastSoundPlayed[CHAT_HIGHLIGHT] >= time_freq() * 3 / 10)
886 {
887 char aBuf[1024];
888 str_format(buffer: aBuf, buffer_size: sizeof(aBuf), format: "%s: %s", CurrentLine.m_aName, CurrentLine.m_aText);
889 Client()->Notify(pTitle: "DDNet Chat", pMessage: aBuf);
890 if(g_Config.m_SndHighlight)
891 {
892 GameClient()->m_Sounds.Play(Channel: CSounds::CHN_GUI, SetId: SOUND_CHAT_HIGHLIGHT, Volume: 1.0f);
893 m_aLastSoundPlayed[CHAT_HIGHLIGHT] = Now;
894 }
895
896 if(g_Config.m_ClEditor)
897 {
898 GameClient()->Editor()->UpdateMentions();
899 }
900 }
901 }
902 else if(Team != TEAM_WHISPER_SEND)
903 {
904 if(Now - m_aLastSoundPlayed[CHAT_CLIENT] >= time_freq() * 3 / 10)
905 {
906 bool PlaySound = CurrentLine.m_Team ? g_Config.m_SndTeamChat : g_Config.m_SndChat;
907#if defined(CONF_VIDEORECORDER)
908 if(IVideo::Current())
909 {
910 PlaySound &= (bool)g_Config.m_ClVideoShowChat;
911 }
912#endif
913 if(PlaySound)
914 {
915 GameClient()->m_Sounds.Play(Channel: CSounds::CHN_GUI, SetId: SOUND_CHAT_CLIENT, Volume: 1.0f);
916 m_aLastSoundPlayed[CHAT_CLIENT] = Now;
917 }
918 }
919 }
920}
921
922void CChat::OnPrepareLines(float y)
923{
924 float x = 5.0f;
925 float FontSize = this->FontSize();
926
927 const bool IsScoreBoardOpen = GameClient()->m_Scoreboard.IsActive() && (Graphics()->ScreenAspect() > 1.7f); // only assume scoreboard when screen ratio is widescreen(something around 16:9)
928 const bool ShowLargeArea = m_Show || (m_Mode != MODE_NONE && g_Config.m_ClShowChat == 1) || g_Config.m_ClShowChat == 2;
929 const bool ForceRecreate = IsScoreBoardOpen != m_PrevScoreBoardShowed || ShowLargeArea != m_PrevShowChat;
930 m_PrevScoreBoardShowed = IsScoreBoardOpen;
931 m_PrevShowChat = ShowLargeArea;
932
933 const int TeeSize = MessageTeeSize();
934 float RealMsgPaddingX = MessagePaddingX();
935 float RealMsgPaddingY = MessagePaddingY();
936 float RealMsgPaddingTee = TeeSize + MESSAGE_TEE_PADDING_RIGHT;
937
938 if(g_Config.m_ClChatOld)
939 {
940 RealMsgPaddingX = 0;
941 RealMsgPaddingY = 0;
942 RealMsgPaddingTee = 0;
943 }
944
945 int64_t Now = time();
946 float LineWidth = (IsScoreBoardOpen ? std::max(a: 85.0f, b: FontSize * 85.0f / 6.0f) : g_Config.m_ClChatWidth) - (RealMsgPaddingX * 1.5f) - RealMsgPaddingTee;
947
948 float HeightLimit = IsScoreBoardOpen ? 180.0f : (m_PrevShowChat ? 50.0f : 200.0f);
949 float Begin = x;
950 float TextBegin = Begin + RealMsgPaddingX / 2.0f;
951 int OffsetType = IsScoreBoardOpen ? 1 : 0;
952
953 for(int i = 0; i < MAX_LINES; i++)
954 {
955 CLine &Line = m_aLines[((m_CurrentLine - i) + MAX_LINES) % MAX_LINES];
956 if(!Line.m_Initialized)
957 break;
958 if(Now > Line.m_Time + 16 * time_freq() && !m_PrevShowChat)
959 break;
960
961 if(Line.m_TextContainerIndex.Valid() && !ForceRecreate)
962 continue;
963
964 TextRender()->DeleteTextContainer(TextContainerIndex&: Line.m_TextContainerIndex);
965 Graphics()->DeleteQuadContainer(ContainerIndex&: Line.m_QuadContainerIndex);
966
967 char aClientId[16] = "";
968 if(g_Config.m_ClShowIds && Line.m_ClientId >= 0 && Line.m_aName[0] != '\0')
969 {
970 GameClient()->FormatClientId(ClientId: Line.m_ClientId, aClientId, Format: EClientIdFormat::INDENT_AUTO);
971 }
972
973 char aCount[12];
974 if(Line.m_ClientId < 0)
975 str_format(buffer: aCount, buffer_size: sizeof(aCount), format: "[%d] ", Line.m_TimesRepeated + 1);
976 else
977 str_format(buffer: aCount, buffer_size: sizeof(aCount), format: " [%d]", Line.m_TimesRepeated + 1);
978
979 const char *pText = Line.m_aText;
980 if(Config()->m_ClStreamerMode && Line.m_ClientId == SERVER_MSG)
981 {
982 if(str_startswith(str: Line.m_aText, prefix: "Team save in progress. You'll be able to load with '/load ") && str_endswith(str: Line.m_aText, suffix: "'"))
983 {
984 pText = "Team save in progress. You'll be able to load with '/load *** *** ***'";
985 }
986 else if(str_startswith(str: Line.m_aText, prefix: "Team save in progress. You'll be able to load with '/load") && str_endswith(str: Line.m_aText, suffix: "if it fails"))
987 {
988 pText = "Team save in progress. You'll be able to load with '/load *** *** ***' if save is successful or with '/load *** *** ***' if it fails";
989 }
990 else if(str_startswith(str: Line.m_aText, prefix: "Team successfully saved by ") && str_endswith(str: Line.m_aText, suffix: " to continue"))
991 {
992 pText = "Team successfully saved by ***. Use '/load *** *** ***' to continue";
993 }
994 }
995
996 // get the y offset (calculate it if we haven't done that yet)
997 if(Line.m_aYOffset[OffsetType] < 0.0f)
998 {
999 CTextCursor MeasureCursor;
1000 MeasureCursor.SetPosition(vec2(TextBegin, 0.0f));
1001 MeasureCursor.m_FontSize = FontSize;
1002 MeasureCursor.m_Flags = 0;
1003 MeasureCursor.m_LineWidth = LineWidth;
1004
1005 if(Line.m_ClientId >= 0 && Line.m_aName[0] != '\0')
1006 {
1007 MeasureCursor.m_X += RealMsgPaddingTee;
1008
1009 if(Line.m_Friend && g_Config.m_ClMessageFriend)
1010 {
1011 TextRender()->TextEx(pCursor: &MeasureCursor, pText: "♥ ");
1012 }
1013 }
1014
1015 TextRender()->TextEx(pCursor: &MeasureCursor, pText: aClientId);
1016 TextRender()->TextEx(pCursor: &MeasureCursor, pText: Line.m_aName);
1017 if(Line.m_TimesRepeated > 0)
1018 TextRender()->TextEx(pCursor: &MeasureCursor, pText: aCount);
1019
1020 if(Line.m_ClientId >= 0 && Line.m_aName[0] != '\0')
1021 {
1022 TextRender()->TextEx(pCursor: &MeasureCursor, pText: ": ");
1023 }
1024
1025 CTextCursor AppendCursor = MeasureCursor;
1026 AppendCursor.m_LongestLineWidth = 0.0f;
1027 if(!IsScoreBoardOpen && !g_Config.m_ClChatOld)
1028 {
1029 AppendCursor.m_StartX = MeasureCursor.m_X;
1030 AppendCursor.m_LineWidth -= MeasureCursor.m_LongestLineWidth;
1031 }
1032
1033 TextRender()->TextEx(pCursor: &AppendCursor, pText);
1034
1035 Line.m_aYOffset[OffsetType] = AppendCursor.Height() + RealMsgPaddingY;
1036 }
1037
1038 y -= Line.m_aYOffset[OffsetType];
1039
1040 // cut off if msgs waste too much space
1041 if(y < HeightLimit)
1042 break;
1043
1044 // the position the text was created
1045 Line.m_TextYOffset = y + RealMsgPaddingY / 2.0f;
1046
1047 int CurRenderFlags = TextRender()->GetRenderFlags();
1048 TextRender()->SetRenderFlags(CurRenderFlags | ETextRenderFlags::TEXT_RENDER_FLAG_NO_AUTOMATIC_QUAD_UPLOAD);
1049
1050 // reset the cursor
1051 CTextCursor LineCursor;
1052 LineCursor.SetPosition(vec2(TextBegin, Line.m_TextYOffset));
1053 LineCursor.m_FontSize = FontSize;
1054 LineCursor.m_LineWidth = LineWidth;
1055
1056 // Message is from valid player
1057 if(Line.m_ClientId >= 0 && Line.m_aName[0] != '\0')
1058 {
1059 LineCursor.m_X += RealMsgPaddingTee;
1060
1061 if(Line.m_Friend && g_Config.m_ClMessageFriend)
1062 {
1063 TextRender()->TextColor(Color: color_cast<ColorRGBA>(hsl: ColorHSLA(g_Config.m_ClMessageFriendColor)).WithAlpha(alpha: 1.0f));
1064 TextRender()->CreateOrAppendTextContainer(TextContainerIndex&: Line.m_TextContainerIndex, pCursor: &LineCursor, pText: "♥ ");
1065 }
1066 }
1067
1068 // render name
1069 ColorRGBA NameColor;
1070 if(Line.m_CustomColor)
1071 NameColor = *Line.m_CustomColor;
1072 else if(Line.m_ClientId == SERVER_MSG)
1073 NameColor = color_cast<ColorRGBA>(hsl: ColorHSLA(g_Config.m_ClMessageSystemColor));
1074 else if(Line.m_ClientId == CLIENT_MSG)
1075 NameColor = color_cast<ColorRGBA>(hsl: ColorHSLA(g_Config.m_ClMessageClientColor));
1076 else if(Line.m_Team)
1077 NameColor = CalculateNameColor(TextColorHSL: ColorHSLA(g_Config.m_ClMessageTeamColor));
1078 else if(Line.m_NameColor == TEAM_RED)
1079 NameColor = ColorRGBA(1.0f, 0.5f, 0.5f, 1.0f);
1080 else if(Line.m_NameColor == TEAM_BLUE)
1081 NameColor = ColorRGBA(0.7f, 0.7f, 1.0f, 1.0f);
1082 else if(Line.m_NameColor == TEAM_SPECTATORS)
1083 NameColor = ColorRGBA(0.75f, 0.5f, 0.75f, 1.0f);
1084 else if(Line.m_ClientId >= 0 && g_Config.m_ClChatTeamColors && GameClient()->m_Teams.Team(ClientId: Line.m_ClientId))
1085 NameColor = GameClient()->GetDDTeamColor(DDTeam: GameClient()->m_Teams.Team(ClientId: Line.m_ClientId), Lightness: 0.75f);
1086 else
1087 NameColor = ColorRGBA(0.8f, 0.8f, 0.8f, 1.0f);
1088
1089 TextRender()->TextColor(Color: NameColor);
1090 TextRender()->CreateOrAppendTextContainer(TextContainerIndex&: Line.m_TextContainerIndex, pCursor: &LineCursor, pText: aClientId);
1091 TextRender()->CreateOrAppendTextContainer(TextContainerIndex&: Line.m_TextContainerIndex, pCursor: &LineCursor, pText: Line.m_aName);
1092
1093 if(Line.m_TimesRepeated > 0)
1094 {
1095 TextRender()->TextColor(r: 1.0f, g: 1.0f, b: 1.0f, a: 0.3f);
1096 TextRender()->CreateOrAppendTextContainer(TextContainerIndex&: Line.m_TextContainerIndex, pCursor: &LineCursor, pText: aCount);
1097 }
1098
1099 if(Line.m_ClientId >= 0 && Line.m_aName[0] != '\0')
1100 {
1101 TextRender()->TextColor(Color: NameColor);
1102 TextRender()->CreateOrAppendTextContainer(TextContainerIndex&: Line.m_TextContainerIndex, pCursor: &LineCursor, pText: ": ");
1103 }
1104
1105 ColorRGBA Color;
1106 if(Line.m_CustomColor)
1107 Color = *Line.m_CustomColor;
1108 else if(Line.m_ClientId == SERVER_MSG)
1109 Color = color_cast<ColorRGBA>(hsl: ColorHSLA(g_Config.m_ClMessageSystemColor));
1110 else if(Line.m_ClientId == CLIENT_MSG)
1111 Color = color_cast<ColorRGBA>(hsl: ColorHSLA(g_Config.m_ClMessageClientColor));
1112 else if(Line.m_Highlighted)
1113 Color = color_cast<ColorRGBA>(hsl: ColorHSLA(g_Config.m_ClMessageHighlightColor));
1114 else if(Line.m_Team)
1115 Color = color_cast<ColorRGBA>(hsl: ColorHSLA(g_Config.m_ClMessageTeamColor));
1116 else // regular message
1117 Color = color_cast<ColorRGBA>(hsl: ColorHSLA(g_Config.m_ClMessageColor));
1118 TextRender()->TextColor(Color);
1119
1120 CTextCursor AppendCursor = LineCursor;
1121 AppendCursor.m_LongestLineWidth = 0.0f;
1122 if(!IsScoreBoardOpen && !g_Config.m_ClChatOld)
1123 {
1124 AppendCursor.m_StartX = LineCursor.m_X;
1125 AppendCursor.m_LineWidth -= LineCursor.m_LongestLineWidth;
1126 }
1127
1128 TextRender()->CreateOrAppendTextContainer(TextContainerIndex&: Line.m_TextContainerIndex, pCursor: &AppendCursor, pText);
1129
1130 if(!g_Config.m_ClChatOld && (Line.m_aText[0] != '\0' || Line.m_aName[0] != '\0'))
1131 {
1132 float FullWidth = RealMsgPaddingX * 1.5f;
1133 if(!IsScoreBoardOpen && !g_Config.m_ClChatOld)
1134 {
1135 FullWidth += LineCursor.m_LongestLineWidth + AppendCursor.m_LongestLineWidth;
1136 }
1137 else
1138 {
1139 FullWidth += std::max(a: LineCursor.m_LongestLineWidth, b: AppendCursor.m_LongestLineWidth);
1140 }
1141 Graphics()->SetColor(r: 1, g: 1, b: 1, a: 1);
1142 Line.m_QuadContainerIndex = Graphics()->CreateRectQuadContainer(x: Begin, y, w: FullWidth, h: Line.m_aYOffset[OffsetType], r: MessageRounding(), Corners: IGraphics::CORNER_ALL);
1143 }
1144
1145 TextRender()->SetRenderFlags(CurRenderFlags);
1146 if(Line.m_TextContainerIndex.Valid())
1147 TextRender()->UploadTextContainer(TextContainerIndex: Line.m_TextContainerIndex);
1148 }
1149
1150 TextRender()->TextColor(Color: TextRender()->DefaultTextColor());
1151}
1152
1153void CChat::OnRender()
1154{
1155 if(Client()->State() != IClient::STATE_ONLINE && Client()->State() != IClient::STATE_DEMOPLAYBACK)
1156 return;
1157
1158 // send pending chat messages
1159 if(m_PendingChatCounter > 0 && m_LastChatSend + time_freq() < time())
1160 {
1161 CHistoryEntry *pEntry = m_History.Last();
1162 for(int i = m_PendingChatCounter - 1; pEntry; --i, pEntry = m_History.Prev(pCurrent: pEntry))
1163 {
1164 if(i == 0)
1165 {
1166 SendChat(Team: pEntry->m_Team, pLine: pEntry->m_aText);
1167 break;
1168 }
1169 }
1170 --m_PendingChatCounter;
1171 }
1172
1173 const float Height = 300.0f;
1174 const float Width = Height * Graphics()->ScreenAspect();
1175 Graphics()->MapScreen(TopLeftX: 0.0f, TopLeftY: 0.0f, BottomRightX: Width, BottomRightY: Height);
1176
1177 float x = 5.0f;
1178 float y = 300.0f - 20.0f * FontSize() / 6.0f;
1179 float ScaledFontSize = FontSize() * (8.0f / 6.0f);
1180 if(m_Mode != MODE_NONE)
1181 {
1182 // render chat input
1183 CTextCursor InputCursor;
1184 InputCursor.SetPosition(vec2(x, y));
1185 InputCursor.m_FontSize = ScaledFontSize;
1186 InputCursor.m_LineWidth = Width - 190.0f;
1187
1188 if(m_Mode == MODE_ALL)
1189 TextRender()->TextEx(pCursor: &InputCursor, pText: Localize(pStr: "All"));
1190 else if(m_Mode == MODE_TEAM)
1191 TextRender()->TextEx(pCursor: &InputCursor, pText: Localize(pStr: "Team"));
1192 else
1193 TextRender()->TextEx(pCursor: &InputCursor, pText: Localize(pStr: "Chat"));
1194
1195 TextRender()->TextEx(pCursor: &InputCursor, pText: ": ");
1196
1197 const float MessageMaxWidth = InputCursor.m_LineWidth - (InputCursor.m_X - InputCursor.m_StartX);
1198 const CUIRect ClippingRect = {.x: InputCursor.m_X, .y: InputCursor.m_Y, .w: MessageMaxWidth, .h: 2.25f * InputCursor.m_FontSize};
1199 const float XScale = Graphics()->ScreenWidth() / Width;
1200 const float YScale = Graphics()->ScreenHeight() / Height;
1201 Graphics()->ClipEnable(x: (int)(ClippingRect.x * XScale), y: (int)(ClippingRect.y * YScale), w: (int)(ClippingRect.w * XScale), h: (int)(ClippingRect.h * YScale));
1202
1203 float ScrollOffset = m_Input.GetScrollOffset();
1204 float ScrollOffsetChange = m_Input.GetScrollOffsetChange();
1205
1206 m_Input.Activate(Priority: EInputPriority::CHAT); // Ensure that the input is active
1207 const CUIRect InputCursorRect = {.x: InputCursor.m_X, .y: InputCursor.m_Y - ScrollOffset, .w: 0.0f, .h: 0.0f};
1208 const bool WasChanged = m_Input.WasChanged();
1209 const bool WasCursorChanged = m_Input.WasCursorChanged();
1210 const bool Changed = WasChanged || WasCursorChanged;
1211 const STextBoundingBox BoundingBox = m_Input.Render(pRect: &InputCursorRect, FontSize: InputCursor.m_FontSize, Align: TEXTALIGN_TL, Changed, LineWidth: MessageMaxWidth, LineSpacing: 0.0f);
1212
1213 Graphics()->ClipDisable();
1214
1215 // Scroll up or down to keep the caret inside the clipping rect
1216 const float CaretPositionY = m_Input.GetCaretPosition().y - ScrollOffsetChange;
1217 if(CaretPositionY < ClippingRect.y)
1218 ScrollOffsetChange -= ClippingRect.y - CaretPositionY;
1219 else if(CaretPositionY + InputCursor.m_FontSize > ClippingRect.y + ClippingRect.h)
1220 ScrollOffsetChange += CaretPositionY + InputCursor.m_FontSize - (ClippingRect.y + ClippingRect.h);
1221
1222 Ui()->DoSmoothScrollLogic(pScrollOffset: &ScrollOffset, pScrollOffsetChange: &ScrollOffsetChange, ViewPortSize: ClippingRect.h, TotalSize: BoundingBox.m_H);
1223
1224 m_Input.SetScrollOffset(ScrollOffset);
1225 m_Input.SetScrollOffsetChange(ScrollOffsetChange);
1226
1227 // Autocompletion hint
1228 if(m_Input.GetString()[0] == '/' && m_Input.GetString()[1] != '\0' && !m_vServerCommands.empty())
1229 {
1230 for(const auto &Command : m_vServerCommands)
1231 {
1232 if(str_startswith_nocase(str: Command.m_aName, prefix: m_Input.GetString() + 1))
1233 {
1234 InputCursor.m_X = InputCursor.m_X + TextRender()->TextWidth(Size: InputCursor.m_FontSize, pText: m_Input.GetString(), StrLength: -1, LineWidth: InputCursor.m_LineWidth);
1235 InputCursor.m_Y = m_Input.GetCaretPosition().y;
1236 TextRender()->TextColor(r: 1.0f, g: 1.0f, b: 1.0f, a: 0.5f);
1237 TextRender()->TextEx(pCursor: &InputCursor, pText: Command.m_aName + str_length(str: m_Input.GetString() + 1));
1238 TextRender()->TextColor(Color: TextRender()->DefaultTextColor());
1239 break;
1240 }
1241 }
1242 }
1243 }
1244
1245#if defined(CONF_VIDEORECORDER)
1246 if(!((g_Config.m_ClShowChat && !IVideo::Current()) || (g_Config.m_ClVideoShowChat && IVideo::Current())))
1247#else
1248 if(!g_Config.m_ClShowChat)
1249#endif
1250 return;
1251
1252 y -= ScaledFontSize;
1253
1254 OnPrepareLines(y);
1255
1256 bool IsScoreBoardOpen = GameClient()->m_Scoreboard.IsActive() && (Graphics()->ScreenAspect() > 1.7f); // only assume scoreboard when screen ratio is widescreen(something around 16:9)
1257
1258 int64_t Now = time();
1259 float HeightLimit = IsScoreBoardOpen ? 180.0f : (m_PrevShowChat ? 50.0f : 200.0f);
1260 int OffsetType = IsScoreBoardOpen ? 1 : 0;
1261
1262 float RealMsgPaddingX = MessagePaddingX();
1263 float RealMsgPaddingY = MessagePaddingY();
1264
1265 if(g_Config.m_ClChatOld)
1266 {
1267 RealMsgPaddingX = 0;
1268 RealMsgPaddingY = 0;
1269 }
1270
1271 for(int i = 0; i < MAX_LINES; i++)
1272 {
1273 CLine &Line = m_aLines[((m_CurrentLine - i) + MAX_LINES) % MAX_LINES];
1274 if(!Line.m_Initialized)
1275 break;
1276 if(Now > Line.m_Time + 16 * time_freq() && !m_PrevShowChat)
1277 break;
1278
1279 y -= Line.m_aYOffset[OffsetType];
1280
1281 // cut off if msgs waste too much space
1282 if(y < HeightLimit)
1283 break;
1284
1285 float Blend = Now > Line.m_Time + 14 * time_freq() && !m_PrevShowChat ? 1.0f - (Now - Line.m_Time - 14 * time_freq()) / (2.0f * time_freq()) : 1.0f;
1286
1287 // Draw backgrounds for messages in one batch
1288 if(!g_Config.m_ClChatOld)
1289 {
1290 Graphics()->TextureClear();
1291 if(Line.m_QuadContainerIndex != -1)
1292 {
1293 Graphics()->SetColor(color_cast<ColorRGBA>(hsl: ColorHSLA(g_Config.m_ClChatBackgroundColor, true)).WithMultipliedAlpha(alpha: Blend));
1294 Graphics()->RenderQuadContainerEx(ContainerIndex: Line.m_QuadContainerIndex, QuadOffset: 0, QuadDrawNum: -1, X: 0, Y: ((y + RealMsgPaddingY / 2.0f) - Line.m_TextYOffset));
1295 }
1296 }
1297
1298 if(Line.m_TextContainerIndex.Valid())
1299 {
1300 if(!g_Config.m_ClChatOld && Line.m_pManagedTeeRenderInfo != nullptr)
1301 {
1302 CTeeRenderInfo &TeeRenderInfo = Line.m_pManagedTeeRenderInfo->TeeRenderInfo();
1303 const int TeeSize = MessageTeeSize();
1304 TeeRenderInfo.m_Size = TeeSize;
1305
1306 float RowHeight = FontSize() + RealMsgPaddingY;
1307 float OffsetTeeY = TeeSize / 2.0f;
1308 float FullHeightMinusTee = RowHeight - TeeSize;
1309
1310 const CAnimState *pIdleState = CAnimState::GetIdle();
1311 vec2 OffsetToMid;
1312 CRenderTools::GetRenderTeeOffsetToRenderedTee(pAnim: pIdleState, pInfo: &TeeRenderInfo, TeeOffsetToMid&: OffsetToMid);
1313 vec2 TeeRenderPos(x + (RealMsgPaddingX + TeeSize) / 2.0f, y + OffsetTeeY + FullHeightMinusTee / 2.0f + OffsetToMid.y);
1314 RenderTools()->RenderTee(pAnim: pIdleState, pInfo: &TeeRenderInfo, Emote: EMOTE_NORMAL, Dir: vec2(1, 0.1f), Pos: TeeRenderPos, Alpha: Blend);
1315 }
1316
1317 const ColorRGBA TextColor = TextRender()->DefaultTextColor().WithMultipliedAlpha(alpha: Blend);
1318 const ColorRGBA TextOutlineColor = TextRender()->DefaultTextOutlineColor().WithMultipliedAlpha(alpha: Blend);
1319 TextRender()->RenderTextContainer(TextContainerIndex: Line.m_TextContainerIndex, TextColor, TextOutlineColor, X: 0, Y: (y + RealMsgPaddingY / 2.0f) - Line.m_TextYOffset);
1320 }
1321 }
1322}
1323
1324void CChat::EnsureCoherentFontSize() const
1325{
1326 // Adjust font size based on width
1327 if(g_Config.m_ClChatWidth / (float)g_Config.m_ClChatFontSize >= CHAT_FONTSIZE_WIDTH_RATIO)
1328 return;
1329
1330 // We want to keep a ration between font size and font width so that we don't have a weird rendering
1331 g_Config.m_ClChatFontSize = g_Config.m_ClChatWidth / CHAT_FONTSIZE_WIDTH_RATIO;
1332}
1333
1334void CChat::EnsureCoherentWidth() const
1335{
1336 // Adjust width based on font size
1337 if(g_Config.m_ClChatWidth / (float)g_Config.m_ClChatFontSize >= CHAT_FONTSIZE_WIDTH_RATIO)
1338 return;
1339
1340 // We want to keep a ration between font size and font width so that we don't have a weird rendering
1341 g_Config.m_ClChatWidth = CHAT_FONTSIZE_WIDTH_RATIO * g_Config.m_ClChatFontSize;
1342}
1343
1344// ----- send functions -----
1345
1346void CChat::SendChat(int Team, const char *pLine)
1347{
1348 // don't send empty messages
1349 if(*str_utf8_skip_whitespaces(str: pLine) == '\0')
1350 return;
1351
1352 m_LastChatSend = time();
1353
1354 if(GameClient()->Client()->IsSixup())
1355 {
1356 protocol7::CNetMsg_Cl_Say Msg7;
1357 Msg7.m_Mode = Team == 1 ? protocol7::CHAT_TEAM : protocol7::CHAT_ALL;
1358 Msg7.m_Target = -1;
1359 Msg7.m_pMessage = pLine;
1360 Client()->SendPackMsgActive(pMsg: &Msg7, Flags: MSGFLAG_VITAL, NoTranslate: true);
1361 return;
1362 }
1363
1364 // send chat message
1365 CNetMsg_Cl_Say Msg;
1366 Msg.m_Team = Team;
1367 Msg.m_pMessage = pLine;
1368 Client()->SendPackMsgActive(pMsg: &Msg, Flags: MSGFLAG_VITAL);
1369}
1370
1371void CChat::SendChatQueued(const char *pLine)
1372{
1373 if(!pLine || str_length(str: pLine) < 1)
1374 return;
1375
1376 bool AddEntry = false;
1377
1378 if(m_LastChatSend + time_freq() < time())
1379 {
1380 SendChat(Team: m_Mode == MODE_ALL ? 0 : 1, pLine);
1381 AddEntry = true;
1382 }
1383 else if(m_PendingChatCounter < 3)
1384 {
1385 ++m_PendingChatCounter;
1386 AddEntry = true;
1387 }
1388
1389 if(AddEntry)
1390 {
1391 const int Length = str_length(str: pLine);
1392 CHistoryEntry *pEntry = m_History.Allocate(Size: sizeof(CHistoryEntry) + Length);
1393 pEntry->m_Team = m_Mode == MODE_ALL ? 0 : 1;
1394 str_copy(dst: pEntry->m_aText, src: pLine, dst_size: Length + 1);
1395 }
1396}
1397